Files

341 lines
10 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// interface-snapshot is an internal CI helper. It is intentionally a separate
// binary so it can be copied into a temporary worktree and compiled against an
// older revision's real Cobra root.
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
"github.com/spf13/cobra"
)
var newRootCommand = func() *cobra.Command { return app.NewRootCommand() }
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}
func run(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
printUsage(stderr)
return 2
}
switch args[0] {
case "generate":
if err := runGenerate(args[1:], stdout, stderr); err != nil {
fmt.Fprintln(stderr, err)
return 2
}
return 0
case "compare":
compatible, err := runCompare(args[1:], stdout, stderr)
if err != nil {
fmt.Fprintln(stderr, err)
return 2
}
if !compatible {
return 1
}
return 0
default:
fmt.Fprintf(stderr, "unknown command %q\n", args[0])
printUsage(stderr)
return 2
}
}
func runGenerate(args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("generate", flag.ContinueOnError)
flags.SetOutput(stderr)
output := flags.String("output", "-", "snapshot output path, or - for stdout")
if err := flags.Parse(args); err != nil {
return err
}
if flags.NArg() != 0 {
return fmt.Errorf("generate accepts no positional arguments")
}
home, err := os.MkdirTemp("", "dws-interface-snapshot-*")
if err != nil {
return fmt.Errorf("create isolated home: %w", err)
}
defer os.RemoveAll(home)
environment := map[string]string{
"DWS_CONFIG_DIR": home,
"DWS_LANG": "en",
"HOME": home,
"NO_COLOR": "1",
"USERPROFILE": home,
}
type previousEnv struct {
value string
set bool
}
previous := make(map[string]previousEnv, len(environment))
for key, value := range environment {
oldValue, wasSet := os.LookupEnv(key)
previous[key] = previousEnv{value: oldValue, set: wasSet}
if err := os.Setenv(key, value); err != nil {
return fmt.Errorf("set %s: %w", key, err)
}
}
defer func() {
for key, old := range previous {
if old.set {
_ = os.Setenv(key, old.value)
} else {
_ = os.Unsetenv(key)
}
}
}()
previousLang := i18n.Lang()
defer i18n.SetLang(previousLang)
i18n.SetLang("en")
root := newRootCommand()
snapshot := interfacesnapshot.Capture(root)
if err := validateHelpRendering(root, snapshot); err != nil {
return err
}
if *output == "-" {
return interfacesnapshot.Write(stdout, snapshot)
}
file, err := os.Create(filepath.Clean(*output))
if err != nil {
return fmt.Errorf("create snapshot %q: %w", *output, err)
}
writeErr := interfacesnapshot.Write(file, snapshot)
closeErr := file.Close()
if writeErr != nil {
return fmt.Errorf("write snapshot %q: %w", *output, writeErr)
}
if closeErr != nil {
return fmt.Errorf("close snapshot %q: %w", *output, closeErr)
}
return nil
}
func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
flags := flag.NewFlagSet("compare", flag.ContinueOnError)
flags.SetOutput(stderr)
currentPath := flags.String("current", "", "candidate snapshot path")
basePath := flags.String("base", "", "target main/development baseline snapshot path")
stablePath := flags.String("stable", "", "latest stable GA snapshot path")
approvedMigrationsPath := flags.String(
"approved-flag-migrations",
"",
"merge-base-owned approved flag migration manifest",
)
candidateMigrationsPath := flags.String(
"candidate-flag-migrations",
"",
"candidate flag migration manifest",
)
approvedCommandMigrationsPath := flags.String(
"approved-command-migrations",
"",
"merge-base-owned approved command migration manifest",
)
candidateCommandMigrationsPath := flags.String(
"candidate-command-migrations",
"",
"candidate command migration manifest",
)
if err := flags.Parse(args); err != nil {
return false, err
}
if flags.NArg() != 0 {
return false, fmt.Errorf("compare accepts no positional arguments")
}
if *currentPath == "" {
return false, fmt.Errorf("compare requires --current")
}
if *basePath == "" && *stablePath == "" {
return false, fmt.Errorf("compare requires --base, --stable, or both")
}
if (*approvedMigrationsPath == "") != (*candidateMigrationsPath == "") {
return false, fmt.Errorf(
"--approved-flag-migrations and --candidate-flag-migrations must be provided together",
)
}
if (*approvedCommandMigrationsPath == "") != (*candidateCommandMigrationsPath == "") {
return false, fmt.Errorf(
"--approved-command-migrations and --candidate-command-migrations must be provided together",
)
}
if (*approvedMigrationsPath != "" || *approvedCommandMigrationsPath != "") && (*basePath == "" || *stablePath == "") {
return false, fmt.Errorf("migration compare requires both --base and --stable")
}
current, err := readSnapshot(*currentPath)
if err != nil {
return false, fmt.Errorf("read current snapshot: %w", err)
}
references := make(map[string]interfacesnapshot.Snapshot, 2)
if *basePath != "" {
references["main"], err = readSnapshot(*basePath)
if err != nil {
return false, fmt.Errorf("read main/development baseline snapshot: %w", err)
}
}
if *stablePath != "" {
references["stable"], err = readSnapshot(*stablePath)
if err != nil {
return false, fmt.Errorf("read stable snapshot: %w", err)
}
}
report := interfacesnapshot.CompareAll(current, references)
if *approvedCommandMigrationsPath != "" {
flagApproved := interfacesnapshot.FlagMigrationManifest{Version: interfacesnapshot.FlagMigrationManifestVersion, Migrations: []interfacesnapshot.FlagMigration{}}
flagCandidate := flagApproved
if *approvedMigrationsPath != "" {
flagApproved, err = readFlagMigrationManifest(*approvedMigrationsPath)
if err != nil {
return false, fmt.Errorf("read approved flag migrations: %w", err)
}
flagCandidate, err = readFlagMigrationManifest(*candidateMigrationsPath)
if err != nil {
return false, fmt.Errorf("read candidate flag migrations: %w", err)
}
}
commandApproved, readErr := readCommandMigrationManifest(*approvedCommandMigrationsPath)
if readErr != nil {
return false, fmt.Errorf("read approved command migrations: %w", readErr)
}
commandCandidate, readErr := readCommandMigrationManifest(*candidateCommandMigrationsPath)
if readErr != nil {
return false, fmt.Errorf("read candidate command migrations: %w", readErr)
}
report, err = interfacesnapshot.CompareAllWithInterfaceMigrations(
current,
references,
flagApproved,
flagCandidate,
commandApproved,
commandCandidate,
)
if err != nil {
return false, fmt.Errorf("validate interface migration lifecycle: %w", err)
}
} else if *approvedMigrationsPath != "" {
approved, readErr := readFlagMigrationManifest(*approvedMigrationsPath)
if readErr != nil {
return false, fmt.Errorf("read approved flag migrations: %w", readErr)
}
candidate, readErr := readFlagMigrationManifest(*candidateMigrationsPath)
if readErr != nil {
return false, fmt.Errorf("read candidate flag migrations: %w", readErr)
}
report, err = interfacesnapshot.CompareAllWithFlagMigrations(
current,
references,
approved,
candidate,
)
if err != nil {
return false, fmt.Errorf("validate flag migration lifecycle: %w", err)
}
}
encoder := json.NewEncoder(stdout)
encoder.SetEscapeHTML(false)
encoder.SetIndent("", " ")
if err := encoder.Encode(report); err != nil {
return false, fmt.Errorf("write comparison report: %w", err)
}
return report.Compatible, nil
}
func readFlagMigrationManifest(path string) (interfacesnapshot.FlagMigrationManifest, error) {
file, err := os.Open(filepath.Clean(path))
if err != nil {
return interfacesnapshot.FlagMigrationManifest{}, err
}
defer file.Close()
return interfacesnapshot.ReadFlagMigrationManifest(file)
}
func readCommandMigrationManifest(path string) (interfacesnapshot.CommandMigrationManifest, error) {
file, err := os.Open(filepath.Clean(path))
if err != nil {
return interfacesnapshot.CommandMigrationManifest{}, err
}
defer file.Close()
return interfacesnapshot.ReadCommandMigrationManifest(file)
}
func validateHelpRendering(root *cobra.Command, snapshot interfacesnapshot.Snapshot) error {
for _, command := range snapshot.Commands {
path := strings.TrimPrefix(command.Path, "dws")
resolved, remaining, err := root.Find(strings.Fields(path))
if err != nil || len(remaining) != 0 || resolved == nil {
return fmt.Errorf("resolve %q before help rendering: remaining=%v error=%v", command.Path, remaining, err)
}
if err := renderCommandHelp(resolved); err != nil {
return fmt.Errorf("render %q help: %w", command.Path, err)
}
}
return nil
}
func renderCommandHelp(command *cobra.Command) (err error) {
var stdout, stderr bytes.Buffer
command.InitDefaultHelpFlag()
command.SetOut(&stdout)
command.SetErr(&stderr)
defer func() {
if recovered := recover(); recovered != nil {
err = fmt.Errorf("help renderer panicked: %v", recovered)
}
}()
command.HelpFunc()(command, []string{})
if stderr.Len() > 0 {
return fmt.Errorf("help renderer wrote an error: %s", strings.TrimSpace(stderr.String()))
}
if stdout.Len() == 0 {
return fmt.Errorf("help renderer produced empty output")
}
return nil
}
func readSnapshot(path string) (interfacesnapshot.Snapshot, error) {
file, err := os.Open(filepath.Clean(path))
if err != nil {
return interfacesnapshot.Snapshot{}, err
}
defer file.Close()
return interfacesnapshot.Read(file)
}
func printUsage(w io.Writer) {
fmt.Fprintln(w, "usage:")
fmt.Fprintln(w, " interface-snapshot generate [--output FILE]")
fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE] [--approved-flag-migrations FILE --candidate-flag-migrations FILE] [--approved-command-migrations FILE --candidate-command-migrations FILE]")
}