From 4afdd9b82612a59e4f51c329de1ab436d86c67d0 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 2 Nov 2026 19:17:42 +0000 Subject: [PATCH] Add CLI publication recovery commands --- cmd/releasekit/internal/cli/config.go | 43 +++- cmd/releasekit/internal/cli/config_test.go | 27 ++ cmd/releasekit/main.go | 3 + cmd/releasekit/recovery.go | 245 ++++++++++++++++++ cmd/releasekit/recovery_test.go | 283 +++++++++++++++++++++ docs/roadmap/implementation.md | 2 + 6 files changed, 596 insertions(+), 7 deletions(-) create mode 100644 cmd/releasekit/recovery.go create mode 100644 cmd/releasekit/recovery_test.go diff --git a/cmd/releasekit/internal/cli/config.go b/cmd/releasekit/internal/cli/config.go index 8d2afca..1c1e020 100644 --- a/cmd/releasekit/internal/cli/config.go +++ b/cmd/releasekit/internal/cli/config.go @@ -17,17 +17,19 @@ import ( "go.maximumdirect.net/releasekit/internal/versioncontract" ) -const Usage = "usage: releasekit version\n releasekit check [--repo DIR] [--config FILE] VERSION\n releasekit publish [--repo DIR] [--config FILE] [--expected-module DIR] VERSION\n releasekit build [--repo DIR] [--config FILE] [--output DIR] VERSION\n releasekit deliver [--repo DIR] [--config FILE] [--assets DIR] VERSION\n releasekit ci-deliver [--repo DIR] [--config FILE] [--remote NAME] [--prepared] [--expected-module DIR] --commit OID TAG" +const Usage = "usage: releasekit version\n releasekit check [--repo DIR] [--config FILE] VERSION\n releasekit publish [--repo DIR] [--config FILE] [--expected-module DIR] VERSION\n releasekit build [--repo DIR] [--config FILE] [--output DIR] VERSION\n releasekit deliver [--repo DIR] [--config FILE] [--assets DIR] VERSION\n releasekit ci-deliver [--repo DIR] [--config FILE] [--remote NAME] [--prepared] [--expected-module DIR] --commit OID TAG\n releasekit reconcile --record FILE [--json]\n releasekit publication-attempts [--repo DIR] [--record-dir DIR] [--json]" type Command string const ( - VersionCommand Command = "version" - CheckAction Command = "check" - PublishAction Command = "publish" - BuildAction Command = "build" - DeliveryAction Command = "deliver" - CIDeliveryAction Command = "ci-deliver" + VersionCommand Command = "version" + CheckAction Command = "check" + PublishAction Command = "publish" + BuildAction Command = "build" + DeliveryAction Command = "deliver" + CIDeliveryAction Command = "ci-deliver" + ReconcileAction Command = "reconcile" + PublicationAttemptsAction Command = "publication-attempts" ) // Invocation contains parsed command settings owned by the CLI adapter. @@ -43,6 +45,9 @@ type Invocation struct { ExpectedModule string ExpectedTag string Prepared bool + RecordPath string + RecordDir string + JSON bool } // Parse accepts only commands that have been implemented by the executable. @@ -51,6 +56,30 @@ func Parse(args []string) (Invocation, error) { return Invocation{}, errors.New("a command is required") } switch args[0] { + case string(ReconcileAction), string(PublicationAttemptsAction): + action := Command(args[0]) + flags := flag.NewFlagSet(string(action), flag.ContinueOnError) + flags.SetOutput(io.Discard) + record := "" + recordDir := "" + repository := "." + if action == ReconcileAction { + flags.StringVar(&record, "record", "", "attempt record file") + } else { + flags.StringVar(&repository, "repo", ".", "repository directory") + flags.StringVar(&recordDir, "record-dir", "", "attempt-record directory") + } + structured := flags.Bool("json", false, "write structured JSON") + if err := flags.Parse(args[1:]); err != nil { + return Invocation{}, err + } + if flags.NArg() != 0 { + return Invocation{}, fmt.Errorf("%s takes no positional arguments", action) + } + if action == ReconcileAction && record == "" { + return Invocation{}, errors.New("reconcile requires --record FILE") + } + return Invocation{Command: action, Repository: repository, RecordPath: record, RecordDir: recordDir, JSON: *structured}, nil case string(VersionCommand): if len(args) != 1 { return Invocation{}, errors.New("version takes no arguments") diff --git a/cmd/releasekit/internal/cli/config_test.go b/cmd/releasekit/internal/cli/config_test.go index d144e5c..b25521f 100644 --- a/cmd/releasekit/internal/cli/config_test.go +++ b/cmd/releasekit/internal/cli/config_test.go @@ -19,6 +19,33 @@ func TestParse(t *testing.T) { if invocation.Command != VersionCommand { t.Fatalf("Parse() command = %q, want %q", invocation.Command, VersionCommand) } + reconcile, err := Parse([]string{"reconcile", "--record", "records/release record's.json", "--json"}) + if err != nil { + t.Fatal(err) + } + if reconcile.Command != ReconcileAction || reconcile.RecordPath != "records/release record's.json" || !reconcile.JSON { + t.Fatalf("Parse(reconcile) = %#v", reconcile) + } + attempts, err := Parse([]string{"publication-attempts", "--repo", "repository with spaces", "--record-dir", "records with spaces", "--json"}) + if err != nil { + t.Fatal(err) + } + if attempts.Command != PublicationAttemptsAction || attempts.Repository != "repository with spaces" || attempts.RecordDir != "records with spaces" || !attempts.JSON { + t.Fatalf("Parse(publication-attempts) = %#v", attempts) + } + for _, args := range [][]string{ + {"reconcile"}, + {"reconcile", "record.json"}, + {"reconcile", "--record", "record.json", "--repo", "."}, + {"reconcile", "--record", "record.json", "--record-dir", "records"}, + {"reconcile", "--record", "record.json", "VERSION"}, + {"publication-attempts", "--record", "record.json"}, + {"publication-attempts", "VERSION"}, + } { + if _, err := Parse(args); err == nil { + t.Errorf("Parse(%q) succeeded, want a usage error", args) + } + } check, err := Parse([]string{"check", "--repo", "repository with spaces", "--config", "profiles/release config.json", "v1.2.3"}) if err != nil { t.Fatal(err) diff --git a/cmd/releasekit/main.go b/cmd/releasekit/main.go index b68cb27..7e7df90 100644 --- a/cmd/releasekit/main.go +++ b/cmd/releasekit/main.go @@ -32,6 +32,9 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "%v\n%s\n", err, cli.Usage) return 2 } + if invocation.Command == cli.ReconcileAction || invocation.Command == cli.PublicationAttemptsAction { + return runRecovery(ctx, invocation, stdout, stderr) + } if invocation.Command != cli.VersionCommand { invocation.Repository, err = git.RepositoryRoot(ctx, invocation.Repository) if err != nil { diff --git a/cmd/releasekit/recovery.go b/cmd/releasekit/recovery.go new file mode 100644 index 0000000..967b8ec --- /dev/null +++ b/cmd/releasekit/recovery.go @@ -0,0 +1,245 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + + "go.maximumdirect.net/releasekit" + "go.maximumdirect.net/releasekit/cmd/releasekit/internal/cli" + "go.maximumdirect.net/releasekit/internal/git" +) + +type recoveryDiagnostic struct { + Kind string `json:"kind"` + Message string `json:"message"` + RecordPath string `json:"record_path,omitempty"` + MissingFields []string `json:"missing_fields,omitempty"` +} + +type safeEvidence struct { + RepositoryDir string `json:"repository_dir,omitempty"` + ModuleDir string `json:"module_dir,omitempty"` + ModulePath string `json:"module_path,omitempty"` + Version string `json:"version,omitempty"` + Tag string `json:"tag,omitempty"` + CandidateCommit string `json:"candidate_commit,omitempty"` + TagObject string `json:"tag_object,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + Remote string `json:"remote,omitempty"` + RecordedAt string `json:"recorded_at,omitempty"` +} + +type attemptDocument struct { + RecordPath string `json:"record_path"` + Evidence safeEvidence `json:"evidence"` +} + +type reconciliationDocument struct { + SchemaVersion int `json:"schema_version"` + Command string `json:"command"` + RecordPath string `json:"record_path"` + Evidence *safeEvidence `json:"evidence,omitempty"` + State string `json:"state,omitempty"` + ObservedTagObject string `json:"observed_tag_object,omitempty"` + ObservedCommit string `json:"observed_commit,omitempty"` + Diagnostic *recoveryDiagnostic `json:"diagnostic,omitempty"` +} + +type attemptsDocument struct { + SchemaVersion int `json:"schema_version"` + Command string `json:"command"` + RecordDir string `json:"record_dir"` + Attempts []attemptDocument `json:"attempts"` + Diagnostics []*recoveryDiagnostic `json:"diagnostics"` + Diagnostic *recoveryDiagnostic `json:"diagnostic,omitempty"` +} + +func safe(e releasekit.PublicationEvidence) safeEvidence { + return safeEvidence{ + RepositoryDir: e.RepositoryDir, + ModuleDir: e.ModuleDir, + ModulePath: e.ModulePath, + Version: e.Version, + Tag: e.Tag, + CandidateCommit: e.CandidateCommit, + TagObject: e.TagObject, + Endpoint: git.DisplayURL(e.Endpoint), + Remote: e.Remote, + RecordedAt: e.RecordedAt, + } +} + +func diagnostic(err error) *recoveryDiagnostic { + if err == nil { + return nil + } + var typed *releasekit.PublicationRecoveryError + if errors.As(err, &typed) { + return &recoveryDiagnostic{Kind: typed.Kind, Message: err.Error(), RecordPath: typed.RecordPath, MissingFields: typed.MissingFields} + } + return &recoveryDiagnostic{Kind: "invocation", Message: err.Error()} +} + +func runRecovery(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int { + if invocation.Command == cli.ReconcileAction { + return runReconcile(ctx, invocation, stdout, stderr) + } + listed, err := releasekit.ListPublicationAttempts(ctx, releasekit.PublicationAttemptsRequest{RepositoryDir: invocation.Repository, RecordDir: invocation.RecordDir}) + if invocation.JSON { + attempts := make([]attemptDocument, 0, len(listed.Attempts)) + for _, item := range listed.Attempts { + attempts = append(attempts, attemptDocument{RecordPath: item.RecordPath, Evidence: safe(item.Evidence)}) + } + diagnostics := make([]*recoveryDiagnostic, 0, len(listed.Diagnostics)) + for _, item := range listed.Diagnostics { + diagnostics = append(diagnostics, diagnostic(item)) + } + writeJSON(stdout, attemptsDocument{ + SchemaVersion: 1, + Command: "publication-attempts", + RecordDir: listed.RecordDir, + Attempts: attempts, + Diagnostics: diagnostics, + Diagnostic: diagnostic(err), + }) + } else { + fmt.Fprintf(stdout, "publication records in %s\n", listed.RecordDir) + for _, item := range listed.Attempts { + fmt.Fprintf(stdout, "%s module=%s tag=%s commit=%s recorded-at=%s\n", item.RecordPath, item.Evidence.ModulePath, item.Evidence.Tag, item.Evidence.CandidateCommit, item.Evidence.RecordedAt) + } + if err != nil || len(listed.Diagnostics) > 0 { + fmt.Fprintln(stderr, "discovery is incomplete") + } + for _, item := range listed.Diagnostics { + fmt.Fprintf(stderr, "record %s: %s\n", item.RecordPath, humanDiagnostic(item)) + } + if err != nil { + fmt.Fprintf(stderr, "discovery failed: %v\n", err) + } + } + if err != nil || len(listed.Diagnostics) > 0 { + return 1 + } + return 0 +} + +func runReconcile(ctx context.Context, invocation cli.Invocation, stdout, stderr io.Writer) int { + evidence, loadErr := releasekit.LoadPublicationEvidence(invocation.RecordPath) + var result releasekit.PublicationReconciliationResult + err := loadErr + if err == nil { + result, err = releasekit.ReconcilePublication(ctx, evidence) + } + status := reconciliationStatus(result.State, err) + if invocation.JSON { + dto := reconciliationDocument{SchemaVersion: 1, Command: "reconcile", RecordPath: invocation.RecordPath} + if loadErr == nil { + safeEvidence := safe(evidence) + dto.Evidence = &safeEvidence + dto.State = string(result.State) + dto.ObservedTagObject = result.ObservedTagObject + dto.ObservedCommit = result.ObservedCommit + } + dto.Diagnostic = diagnostic(err) + writeJSON(stdout, dto) + } else if err == nil { + fmt.Fprintf(stdout, "reconciled %s module=%s tag=%s state=%s\n", invocation.RecordPath, evidence.ModulePath, evidence.Tag, result.State) + if result.State == releasekit.PublicationConflict { + fmt.Fprintf(stdout, "expected tag-object=%s commit=%s; observed tag-object=%s commit=%s\n", evidence.TagObject, evidence.CandidateCommit, result.ObservedTagObject, result.ObservedCommit) + } else if result.ObservedTagObject != "" || result.ObservedCommit != "" { + fmt.Fprintf(stdout, "observed tag-object=%s commit=%s\n", result.ObservedTagObject, result.ObservedCommit) + } + if guidance := reconciliationGuidance(result.State); guidance != "" { + fmt.Fprintf(stdout, "%s\n", guidance) + } + } + if err != nil && !invocation.JSON { + if loadErr == nil { + fmt.Fprintf(stdout, "reconciled %s module=%s tag=%s state=%s\n", invocation.RecordPath, evidence.ModulePath, evidence.Tag, result.State) + if result.ObservedTagObject != "" || result.ObservedCommit != "" { + fmt.Fprintf(stdout, "observed tag-object=%s commit=%s\n", result.ObservedTagObject, result.ObservedCommit) + } + } + fmt.Fprintf(stderr, "reconcile %s: %s\n", invocation.RecordPath, humanDiagnostic(err)) + if guidance := recoveryGuidance(err); guidance != "" { + fmt.Fprintf(stderr, "%s\n", guidance) + } + } + return status +} + +func reconciliationStatus(state releasekit.PublicationState, err error) int { + if err == nil { + switch state { + case releasekit.PublicationPublished: + return 0 + case releasekit.PublicationNotPublished, releasekit.PublicationConflict: + return 1 + case releasekit.PublicationUnknown: + return 3 + default: + return 2 + } + } + var typed *releasekit.PublicationRecoveryError + if errors.As(err, &typed) { + switch typed.Kind { + case "record_read", "record_format": + return 2 + default: + return 3 + } + } + return 2 +} + +func recoveryGuidance(err error) string { + var typed *releasekit.PublicationRecoveryError + if !errors.As(err, &typed) { + return "" + } + switch typed.Kind { + case "missing_evidence": + return "Select a record with the missing identity fields, or provide complete typed evidence through the Go API." + case "invalid_evidence": + return "Use a record with a valid full tag and Git object ID." + case "endpoint_changed": + return "Review Git URL rewrite settings and restore the captured endpoint mapping before retrying." + case "transport_configuration": + return "Review the current supported transport settings for the recorded remote, then retry observation." + case "canceled": + return "Retry observation with a context that allows it to complete." + case "remote_observation", "inconsistent_observation": + return "Review the Git diagnostic and the remote tag refs before taking any corrective action." + default: + return "" + } +} + +func reconciliationGuidance(state releasekit.PublicationState) string { + switch state { + case releasekit.PublicationNotPublished: + return "This reports current absence only; review publication history and version policy before retrying." + case releasekit.PublicationConflict: + return "Do not replace a conflicting remote tag; verify the observed IDs and follow the repository's immutable-tag procedure." + default: + return "" + } +} + +func humanDiagnostic(err error) string { + var typed *releasekit.PublicationRecoveryError + if errors.As(err, &typed) && len(typed.MissingFields) > 0 { + return fmt.Sprintf("%s (missing fields: %s): %s", typed.Kind, strings.Join(typed.MissingFields, ", "), err.Error()) + } + return err.Error() +} + +func writeJSON(writer io.Writer, value any) { + encoder := json.NewEncoder(writer) + _ = encoder.Encode(value) +} diff --git a/cmd/releasekit/recovery_test.go b/cmd/releasekit/recovery_test.go new file mode 100644 index 0000000..56b6f19 --- /dev/null +++ b/cmd/releasekit/recovery_test.go @@ -0,0 +1,283 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "go.maximumdirect.net/releasekit/internal/publish" +) + +func TestReconcileCommandClassifiesRecordsFromAnotherWorkingDirectory(t *testing.T) { + repository := newCLIRepository(t) + remote := filepath.Join(t.TempDir(), "bare remote.git") + cliGitRun(t, repository, "init", "--bare", "--initial-branch=main", remote) + cliGitRun(t, repository, "remote", "add", "origin", remote) + cliGitRun(t, repository, "tag", "-a", "v1.2.3", "-m", "first tag object") + cliGitRun(t, repository, "push", "origin", "refs/tags/v1.2.3") + commit := cliGitOutput(t, repository, "rev-parse", "HEAD") + tagObject := cliGitOutput(t, repository, "rev-parse", "refs/tags/v1.2.3") + + store := filepath.Join(t.TempDir(), "records with ' punctuation") + evidence := publish.Evidence{ + RepositoryDir: repository, + ModuleDir: repository, + ModulePath: "example.com/cli-candidate", + Version: "v1.2.3", + Tag: "v1.2.3", + CandidateCommit: commit, + TagObject: tagObject, + Endpoint: remote, + Remote: "origin", + RecordedAt: "2026-11-02T00:00:00Z", + } + record, err := publish.SaveEvidence(repository, store, evidence) + if err != nil { + t.Fatal(err) + } + cliGitRun(t, repository, "switch", "-c", "changed-head") + cliGitRun(t, repository, "commit", "--allow-empty", "-m", "change current HEAD") + cliGitRun(t, repository, "tag", "-d", "v1.2.3") + if err := os.WriteFile(filepath.Join(repository, "uncommitted recovery check.txt"), []byte("dirty"), 0o600); err != nil { + t.Fatal(err) + } + + otherDirectory := t.TempDir() + relativeRecord, err := filepath.Rel(otherDirectory, record) + if err != nil { + t.Fatal(err) + } + previousDirectory, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(otherDirectory); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := os.Chdir(previousDirectory); err != nil { + t.Errorf("restore working directory: %v", err) + } + }) + + var stdout, stderr bytes.Buffer + status := run(context.Background(), []string{"reconcile", "--record", relativeRecord, "--json"}, &stdout, &stderr) + if status != 0 || stderr.Len() != 0 { + t.Fatalf("published status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String()) + } + var published map[string]any + if err := json.Unmarshal(stdout.Bytes(), &published); err != nil { + t.Fatalf("decode published JSON: %v; output = %q", err, stdout.String()) + } + if published["state"] != string("published") || published["record_path"] != relativeRecord { + t.Fatalf("published JSON = %#v", published) + } + if _, ok := published["diagnostic"]; ok { + t.Fatalf("successful reconciliation includes a diagnostic: %#v", published) + } + evidenceJSON, ok := published["evidence"].(map[string]any) + if !ok || evidenceJSON["candidate_commit"] != commit || evidenceJSON["tag_object"] != tagObject { + t.Fatalf("safe evidence = %#v", published["evidence"]) + } + if evidenceJSON["module_path"] != evidence.ModulePath || evidenceJSON["candidateCommit"] != nil { + t.Fatalf("evidence does not use the versioned snake-case shape: %#v", evidenceJSON) + } + + cliGitRun(t, repository, "push", "origin", ":refs/tags/v1.2.3") + cliGitRun(t, repository, "tag", "-f", "-a", "v1.2.3", "-m", "replacement tag object") + cliGitRun(t, repository, "push", "origin", "refs/tags/v1.2.3") + var conflictOut, conflictErr bytes.Buffer + status = run(context.Background(), []string{"reconcile", "--record", record, "--json"}, &conflictOut, &conflictErr) + if status != 1 || conflictErr.Len() != 0 { + t.Fatalf("conflict status = %d, stdout = %q, stderr = %q", status, conflictOut.String(), conflictErr.String()) + } + var conflict map[string]any + if err := json.Unmarshal(conflictOut.Bytes(), &conflict); err != nil { + t.Fatal(err) + } + if conflict["state"] != string("conflict") || conflict["observed_tag_object"] == tagObject { + t.Fatalf("conflict JSON = %#v", conflict) + } + + var humanOut, humanErr bytes.Buffer + status = run(context.Background(), []string{"reconcile", "--record", record}, &humanOut, &humanErr) + if status != 1 || !strings.Contains(humanOut.String(), "expected tag-object="+tagObject) || !strings.Contains(humanOut.String(), "observed tag-object=") || !strings.Contains(humanOut.String(), "Do not replace a conflicting remote tag") { + t.Fatalf("human conflict status = %d, stdout = %q, stderr = %q", status, humanOut.String(), humanErr.String()) + } + + partialEvidence := evidence + partialEvidence.TagObject = cliGitOutput(t, repository, "rev-parse", "refs/tags/v1.2.3") + partialEvidence.CandidateCommit = "" + partialRecord, err := publish.SaveEvidence(repository, store, partialEvidence) + if err != nil { + t.Fatal(err) + } + var unknownOut, unknownErr bytes.Buffer + status = run(context.Background(), []string{"reconcile", "--record", partialRecord, "--json"}, &unknownOut, &unknownErr) + if status != 3 || unknownErr.Len() != 0 { + t.Fatalf("unknown status = %d, stdout = %q, stderr = %q", status, unknownOut.String(), unknownErr.String()) + } + var unknown map[string]any + if err := json.Unmarshal(unknownOut.Bytes(), &unknown); err != nil { + t.Fatal(err) + } + unknownDiagnostic, ok := unknown["diagnostic"].(map[string]any) + if unknown["state"] != "unknown" || !ok || unknownDiagnostic["kind"] != "missing_evidence" { + t.Fatalf("unknown JSON = %#v", unknown) + } + var unknownHumanOut, unknownHumanErr bytes.Buffer + status = run(context.Background(), []string{"reconcile", "--record", partialRecord}, &unknownHumanOut, &unknownHumanErr) + if status != 3 || !strings.Contains(unknownHumanOut.String(), "state=unknown") || !strings.Contains(unknownHumanErr.String(), "candidate_commit") { + t.Fatalf("human unknown status = %d, stdout = %q, stderr = %q", status, unknownHumanOut.String(), unknownHumanErr.String()) + } + + cliGitRun(t, repository, "push", "origin", ":refs/tags/v1.2.3") + var absentOut, absentErr bytes.Buffer + status = run(context.Background(), []string{"reconcile", "--record", record, "--json"}, &absentOut, &absentErr) + if status != 1 || absentErr.Len() != 0 { + t.Fatalf("absent status = %d, stdout = %q, stderr = %q", status, absentOut.String(), absentErr.String()) + } + var absent map[string]any + if err := json.Unmarshal(absentOut.Bytes(), &absent); err != nil { + t.Fatal(err) + } + if absent["state"] != "not_published" { + t.Fatalf("absence JSON = %#v", absent) + } + if _, ok := absent["diagnostic"]; ok { + t.Fatalf("known absence includes an unresolved diagnostic: %#v", absent) + } + var absentHumanOut, absentHumanErr bytes.Buffer + status = run(context.Background(), []string{"reconcile", "--record", record}, &absentHumanOut, &absentHumanErr) + if status != 1 || !strings.Contains(absentHumanOut.String(), "state=not_published") || !strings.Contains(absentHumanOut.String(), "current absence only") { + t.Fatalf("human absence status = %d, stdout = %q, stderr = %q", status, absentHumanOut.String(), absentHumanErr.String()) + } + + brokenRecord := filepath.Join(store, "broken record's.json") + if err := os.WriteFile(brokenRecord, []byte("{"), 0o600); err != nil { + t.Fatal(err) + } + var brokenOut, brokenErr bytes.Buffer + status = run(context.Background(), []string{"reconcile", "--record", brokenRecord, "--json"}, &brokenOut, &brokenErr) + if status != 2 || brokenErr.Len() != 0 { + t.Fatalf("record-format status = %d, stdout = %q, stderr = %q", status, brokenOut.String(), brokenErr.String()) + } + var broken map[string]any + if err := json.Unmarshal(brokenOut.Bytes(), &broken); err != nil { + t.Fatal(err) + } + brokenDiagnostic, ok := broken["diagnostic"].(map[string]any) + if !ok || brokenDiagnostic["kind"] != "record_format" || broken["record_path"] != brokenRecord { + t.Fatalf("record-format JSON = %#v", broken) + } + for _, field := range []string{"evidence", "state", "observed_tag_object", "observed_commit"} { + if _, ok := broken[field]; ok { + t.Errorf("record-format JSON includes %q: %#v", field, broken) + } + } +} + +func TestPublicationAttemptsCommandRedactsCredentialsAndReportsPartialDiscovery(t *testing.T) { + repository := newCLIRepository(t) + store := filepath.Join(t.TempDir(), "external records") + evidence := publish.Evidence{ + RepositoryDir: repository, + ModulePath: "example.com/cli-candidate", + Version: "v1.2.3", + Tag: "v1.2.3", + Endpoint: "https://cli-user:cli-secret@example.test/repository.git", + Remote: "origin", + RecordedAt: "2026-11-02T00:00:00Z", + } + if _, err := publish.SaveEvidence(repository, store, evidence); err != nil { + t.Fatal(err) + } + brokenPath := filepath.Join(store, "damaged.json") + if err := os.WriteFile(brokenPath, []byte(`{"format_version":99,"evidence":{}}`), 0o600); err != nil { + t.Fatal(err) + } + + var stdout, stderr bytes.Buffer + status := run(context.Background(), []string{"publication-attempts", "--record-dir", store, "--json"}, &stdout, &stderr) + if status != 1 || stderr.Len() != 0 { + t.Fatalf("partial discovery status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String()) + } + output := stdout.String() + if strings.Contains(output, "cli-user") || strings.Contains(output, "cli-secret") || !strings.Contains(output, "https://redacted@example.test/repository.git") { + t.Fatalf("structured discovery exposed or failed to redact endpoint credentials: %q", output) + } + var listed map[string]any + if err := json.Unmarshal(stdout.Bytes(), &listed); err != nil { + t.Fatal(err) + } + attempts, ok := listed["attempts"].([]any) + if !ok || len(attempts) != 1 { + t.Fatalf("attempts = %#v", listed["attempts"]) + } + diagnostics, ok := listed["diagnostics"].([]any) + if !ok || len(diagnostics) != 1 { + t.Fatalf("diagnostics = %#v", listed["diagnostics"]) + } + if listed["schema_version"] != float64(1) || listed["command"] != "publication-attempts" || listed["diagnostic"] != nil { + t.Fatalf("listing document = %#v", listed) + } + entry := attempts[0].(map[string]any) + if entry["record_path"] == "" { + t.Fatalf("attempt is missing record_path: %#v", entry) + } + entryEvidence := entry["evidence"].(map[string]any) + if entryEvidence["module_path"] != evidence.ModulePath || entryEvidence["recorded_at"] != evidence.RecordedAt { + t.Fatalf("attempt evidence = %#v", entryEvidence) + } + diagnostic := diagnostics[0].(map[string]any) + if diagnostic["kind"] != "record_format" || diagnostic["record_path"] != brokenPath { + t.Fatalf("record diagnostic = %#v", diagnostic) + } + + emptyStore := filepath.Join(t.TempDir(), "empty store") + if err := os.Mkdir(emptyStore, 0o700); err != nil { + t.Fatal(err) + } + var emptyOut, emptyErr bytes.Buffer + status = run(context.Background(), []string{"publication-attempts", "--record-dir", emptyStore, "--json"}, &emptyOut, &emptyErr) + if status != 0 || emptyErr.Len() != 0 { + t.Fatalf("empty discovery status = %d, stdout = %q, stderr = %q", status, emptyOut.String(), emptyErr.String()) + } + var empty map[string]any + if err := json.Unmarshal(emptyOut.Bytes(), &empty); err != nil { + t.Fatal(err) + } + if len(empty["attempts"].([]any)) != 0 || len(empty["diagnostics"].([]any)) != 0 || empty["diagnostic"] != nil { + t.Fatalf("empty listing document = %#v", empty) + } + + var failedOut, failedErr bytes.Buffer + status = run(context.Background(), []string{"publication-attempts", "--record-dir", filepath.Join(emptyStore, "missing"), "--json"}, &failedOut, &failedErr) + if status != 1 || failedErr.Len() != 0 { + t.Fatalf("enumeration failure status = %d, stdout = %q, stderr = %q", status, failedOut.String(), failedErr.String()) + } + var failed map[string]any + if err := json.Unmarshal(failedOut.Bytes(), &failed); err != nil { + t.Fatal(err) + } + failedDiagnostic, ok := failed["diagnostic"].(map[string]any) + if !ok || failedDiagnostic["kind"] != "storage_enumeration" { + t.Fatalf("enumeration failure JSON = %#v", failed) + } +} + +func TestRecoveryArgumentErrorsUseExistingUsagePath(t *testing.T) { + var stdout, stderr bytes.Buffer + status := run(context.Background(), []string{"reconcile", "--json"}, &stdout, &stderr) + if status != 2 || stdout.Len() != 0 || !strings.Contains(stderr.String(), "reconcile requires --record FILE") { + t.Fatalf("argument failure status = %d, stdout = %q, stderr = %q", status, stdout.String(), stderr.String()) + } + if strings.Contains(stdout.String(), "schema_version") || strings.Contains(stderr.String(), fmt.Sprint("\"command\"")) { + t.Fatalf("argument failure unexpectedly used structured output: stdout=%q stderr=%q", stdout.String(), stderr.String()) + } +} diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 6d4dc58..2b17529 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -356,6 +356,8 @@ reuse codec cases rather than repeating all parser permutations here. ## Stage 6: Add CLI recovery, discovery, and structured output +**Completed:** 2026-11-02 + **Outcome:** Operators and automation can discover and reconcile explicitly selected attempts without profiles or diagnostic parsing.