diff --git a/cmd/harness/cli/cli_integration_test.go b/cmd/harness/cli/cli_integration_test.go index 599345d..5a9f061 100644 --- a/cmd/harness/cli/cli_integration_test.go +++ b/cmd/harness/cli/cli_integration_test.go @@ -3561,7 +3561,7 @@ func TestHarnessCheckTextOutputAggregatesUnresolvedBindings(t *testing.T) { repo := seedHarnessInstallRepo(t) - _, _, err := executeHarnessCLI(t, repo.Root, "install", "orbit-template/docs") + _, _, err := executeHarnessCLI(t, repo.Root, "install", "orbit-template/docs", "--allow-unresolved-bindings") require.NoError(t, err) stdout, stderr, err := executeHarnessCLI(t, repo.Root, "check") @@ -6541,7 +6541,7 @@ func TestHarnessInstallTextOutputContract(t *testing.T) { "next_step: hyard orbit show docs intent=inspect orbit entry contract\n", stdout) } -func TestHarnessInstallDefaultsToUnresolvedBindingsAndPlaceholderRuntime(t *testing.T) { +func TestHarnessInstallFailsClosedForMissingBindingsByDefault(t *testing.T) { t.Parallel() repo := seedHarnessInstallRepo(t) @@ -6553,54 +6553,18 @@ func TestHarnessInstallDefaultsToUnresolvedBindingsAndPlaceholderRuntime(t *test "orbit-template/docs", "--json", ) - require.NoError(t, err) + require.Error(t, err) + require.Empty(t, stdout) require.Empty(t, stderr) - - var payload struct { - HarnessRoot string `json:"harness_root"` - OrbitID string `json:"orbit_id"` - WrittenPaths []string `json:"written_paths"` - Warnings []string `json:"warnings"` - Readiness struct { - Status string `json:"status"` - NextSteps []struct { - Command string `json:"command"` - } `json:"next_steps"` - } `json:"readiness"` - } - require.NoError(t, json.Unmarshal([]byte(stdout), &payload)) - require.Equal(t, repo.Root, payload.HarnessRoot) - require.Equal(t, "docs", payload.OrbitID) - require.Contains(t, payload.WrittenPaths, ".harness/orbits/docs.yaml") - require.Contains(t, payload.WrittenPaths, ".harness/installs/docs.yaml") - require.NotContains(t, payload.WrittenPaths, ".harness/vars.yaml") - require.Equal(t, []string{ - "install kept template variables unresolved: project_name", - }, payload.Warnings) - require.Equal(t, "usable", payload.Readiness.Status) - require.Contains(t, readinessCommandsFromPayload(payload.Readiness.NextSteps), "hyard plumbing harness bindings missing --all --json") - - guideData, err := os.ReadFile(filepath.Join(repo.Root, "docs", "guide.md")) - require.NoError(t, err) - require.Equal(t, "{{ vars.project_name }} guide\n", string(guideData)) - - record, err := harnesspkg.LoadInstallRecord(repo.Root, "docs") - require.NoError(t, err) - require.NotNil(t, record.Variables) - require.Equal(t, map[string]bindings.VariableDeclaration{ - "project_name": { - Description: "Product title", - Required: true, - }, - }, record.Variables.Declarations) - require.Empty(t, record.Variables.ResolvedAtApply) - require.Equal(t, []string{"project_name"}, record.Variables.UnresolvedAtApply) - - _, err = os.Stat(filepath.Join(repo.Root, ".harness", "vars.yaml")) - require.ErrorIs(t, err, os.ErrNotExist) + require.ErrorContains(t, err, "missing required bindings: project_name") + require.ErrorContains(t, err, "hyard vars init orbit-template/docs --out .harness/vars.yaml") + require.ErrorContains(t, err, "hyard install orbit-template/docs --bindings .harness/vars.yaml") + require.NoFileExists(t, filepath.Join(repo.Root, "docs", "guide.md")) + require.NoFileExists(t, filepath.Join(repo.Root, ".harness", "installs", "docs.yaml")) + require.NoFileExists(t, filepath.Join(repo.Root, ".harness", "vars.yaml")) } -func TestHarnessInstallTreatsBlankRepoVarAsUnresolved(t *testing.T) { +func TestHarnessInstallFailsClosedForBlankRepoVar(t *testing.T) { t.Parallel() repo := seedHarnessInstallRepo(t) @@ -6618,20 +6582,49 @@ func TestHarnessInstallTreatsBlankRepoVarAsUnresolved(t *testing.T) { "orbit-template/docs", "--json", ) - require.NoError(t, err) + require.Error(t, err) + require.Empty(t, stdout) require.Empty(t, stderr) + require.ErrorContains(t, err, "missing required bindings: project_name") + require.ErrorContains(t, err, "hyard vars init orbit-template/docs --out .harness/vars.yaml") + require.ErrorContains(t, err, "hyard install orbit-template/docs --bindings .harness/vars.yaml") + require.NoFileExists(t, filepath.Join(repo.Root, "docs", "guide.md")) + require.NoFileExists(t, filepath.Join(repo.Root, ".harness", "installs", "docs.yaml")) +} - var payload struct { - Warnings []string `json:"warnings"` - } - require.NoError(t, json.Unmarshal([]byte(stdout), &payload)) - require.Equal(t, []string{ - "install kept template variables unresolved: project_name", - }, payload.Warnings) +func TestHarnessInstallFailsClosedForUnsafeTemplateReference(t *testing.T) { + t.Parallel() - guideData, err := os.ReadFile(filepath.Join(repo.Root, "docs", "guide.md")) - require.NoError(t, err) - require.Equal(t, "{{ vars.project_name }} guide\n", string(guideData)) + repo := seedHarnessInstallRepo(t) + runtimeBranch := strings.TrimSpace(repo.Run(t, "branch", "--show-current")) + repo.Run(t, "checkout", "orbit-template/docs") + repo.WriteFile(t, "docs/guide.md", "{{ secrets.project_name }} guide\n") + repo.AddAndCommit(t, "add unsafe package template reference") + repo.Run(t, "checkout", runtimeBranch) + bindingsPath := filepath.Join(repo.Root, "install-bindings.yaml") + require.NoError(t, os.WriteFile(bindingsPath, []byte(""+ + "schema_version: 2\n"+ + "variables:\n"+ + " project_name:\n"+ + " value: Installed Orbit\n"), 0o600)) + + stdout, stderr, err := executeHarnessCLI( + t, + repo.Root, + "install", + "orbit-template/docs", + "--bindings", + bindingsPath, + "--json", + ) + require.Error(t, err) + require.Empty(t, stdout) + require.Empty(t, stderr) + require.ErrorContains(t, err, `unsupported Package Template Reference namespace "secrets"`) + require.ErrorContains(t, err, "hyard vars init orbit-template/docs --out .harness/vars.yaml") + require.ErrorContains(t, err, "hyard install orbit-template/docs --bindings .harness/vars.yaml") + require.NoFileExists(t, filepath.Join(repo.Root, "docs", "guide.md")) + require.NoFileExists(t, filepath.Join(repo.Root, ".harness", "installs", "docs.yaml")) } func TestHarnessInstallStrictBindingsFailsOnMissingBindings(t *testing.T) { @@ -7094,7 +7087,7 @@ func TestHarnessInstallBatchRollsBackSharedVarsAndEarlierItemsWhenLaterInstallFa } } -func TestHarnessInstallBatchDefaultsToUnresolvedBindingsAndWarnings(t *testing.T) { +func TestHarnessInstallBatchFailsClosedForMissingBindingsByDefault(t *testing.T) { t.Parallel() repo := seedHarnessBatchInstallRepo(t, []bindingsPlanTemplateSpec{ @@ -7136,42 +7129,17 @@ func TestHarnessInstallBatchDefaultsToUnresolvedBindingsAndWarnings(t *testing.T "orbit-template/cmd", "--json", ) - require.NoError(t, err) + require.Error(t, err) + require.Empty(t, stdout) require.Empty(t, stderr) - - var payload struct { - HarnessRoot string `json:"harness_root"` - ItemCount int `json:"item_count"` - OrbitIDs []string `json:"orbit_ids"` - MemberCount int `json:"member_count"` - WarningCount int `json:"warning_count"` - Items []struct { - OrbitID string `json:"orbit_id"` - Warnings []string `json:"warnings"` - } `json:"items"` - } - require.NoError(t, json.Unmarshal([]byte(stdout), &payload)) - require.Equal(t, repo.Root, payload.HarnessRoot) - require.Equal(t, 2, payload.ItemCount) - require.ElementsMatch(t, []string{"docs", "cmd"}, payload.OrbitIDs) - require.Equal(t, 2, payload.MemberCount) - require.Equal(t, 2, payload.WarningCount) - require.Len(t, payload.Items, 2) - require.Equal(t, "docs", payload.Items[0].OrbitID) - require.Equal(t, []string{"install kept template variables unresolved: project_name"}, payload.Items[0].Warnings) - require.Equal(t, "cmd", payload.Items[1].OrbitID) - require.Equal(t, []string{"install kept template variables unresolved: binary_name, project_name"}, payload.Items[1].Warnings) - - docsData, err := os.ReadFile(filepath.Join(repo.Root, "docs", "guide.md")) - require.NoError(t, err) - require.Equal(t, "{{ vars.project_name }} guide\n", string(docsData)) - - cmdData, err := os.ReadFile(filepath.Join(repo.Root, "cmd", "README.md")) - require.NoError(t, err) - require.Equal(t, "Run {{ vars.project_name }} as `{{ vars.binary_name }}`.\n", string(cmdData)) - - _, err = os.Stat(filepath.Join(repo.Root, ".harness", "vars.yaml")) - require.ErrorIs(t, err, os.ErrNotExist) + require.ErrorContains(t, err, "missing required bindings: project_name") + require.ErrorContains(t, err, "hyard vars init orbit-template/docs --out .harness/vars.yaml") + require.ErrorContains(t, err, "hyard install orbit-template/docs --bindings .harness/vars.yaml") + require.NoFileExists(t, filepath.Join(repo.Root, "docs", "guide.md")) + require.NoFileExists(t, filepath.Join(repo.Root, "cmd", "README.md")) + require.NoFileExists(t, filepath.Join(repo.Root, ".harness", "installs", "docs.yaml")) + require.NoFileExists(t, filepath.Join(repo.Root, ".harness", "installs", "cmd.yaml")) + require.NoFileExists(t, filepath.Join(repo.Root, ".harness", "vars.yaml")) } func TestHarnessInstallBatchStrictBindingsFailsOnMissingBindings(t *testing.T) { diff --git a/cmd/harness/cli/commands/install.go b/cmd/harness/cli/commands/install.go index 1d7317a..d6c90d0 100644 --- a/cmd/harness/cli/commands/install.go +++ b/cmd/harness/cli/commands/install.go @@ -191,6 +191,7 @@ func NewInstallCommand() *cobra.Command { if err != nil { return err } + allowUnresolvedPreview := allowUnresolvedBindings || dryRun interactive, err := cmd.Flags().GetBool("interactive") if err != nil { return fmt.Errorf("read --interactive flag: %w", err) @@ -272,12 +273,12 @@ func NewInstallCommand() *cobra.Command { Prompter: prompter, EditorMode: editorMode, Editor: editor, - RequireResolvedBindings: !allowUnresolvedBindings, + RequireResolvedBindings: !allowUnresolvedPreview, Now: now, Registry: registryProvenancePtr, }) if err != nil { - return fmt.Errorf("build harness template install preview: %w", err) + return installPreviewError("build harness template install preview", sourceArg, err) } if err := stageProgress(progress, "checking conflicts"); err != nil { return err @@ -305,12 +306,12 @@ func NewInstallCommand() *cobra.Command { Prompter: prompter, EditorMode: editorMode, Editor: editor, - RequireResolvedBindings: !allowUnresolvedBindings, + RequireResolvedBindings: !allowUnresolvedPreview, Now: now, Registry: registryProvenancePtr, }) if err != nil { - return fmt.Errorf("build harness template install preview: %w", err) + return installPreviewError("build harness template install preview", sourceArg, err) } if err := stageProgress(progress, "checking conflicts"); err != nil { return err @@ -341,7 +342,7 @@ func NewInstallCommand() *cobra.Command { SourceRef: sourceArg, BindingsFilePath: bindingsPath, RuntimeInstallOrbitIDs: activeInstallOrbitIDs, - AllowUnresolvedBindings: allowUnresolvedBindings, + AllowUnresolvedBindings: allowUnresolvedPreview, Interactive: interactive, Prompter: prompter, EditorMode: editorMode, @@ -354,7 +355,7 @@ func NewInstallCommand() *cobra.Command { } preview, err := orbittemplate.BuildTemplateApplyPreview(cmd.Context(), previewInput) if err != nil { - return fmt.Errorf("build harness install preview: %w", err) + return installPreviewError("build harness install preview", sourceArg, err) } if err := stageProgress(progress, "checking conflicts"); err != nil { return err @@ -453,7 +454,7 @@ func NewInstallCommand() *cobra.Command { RequestedRef: requestedRef, BindingsFilePath: bindingsPath, RuntimeInstallOrbitIDs: activeInstallOrbitIDs, - AllowUnresolvedBindings: allowUnresolvedBindings, + AllowUnresolvedBindings: allowUnresolvedPreview, Interactive: interactive, Prompter: prompter, EditorMode: editorMode, @@ -512,12 +513,12 @@ func NewInstallCommand() *cobra.Command { Prompter: prompter, EditorMode: editorMode, Editor: editor, - RequireResolvedBindings: !allowUnresolvedBindings, + RequireResolvedBindings: !allowUnresolvedPreview, Now: now, Registry: registryProvenancePtr, }) if err != nil { - return fmt.Errorf("build harness template install preview: %w", err) + return installPreviewError("build harness template install preview", sourceArg, err) } if err := stageProgress(progress, "checking conflicts"); err != nil { return err @@ -549,12 +550,12 @@ func NewInstallCommand() *cobra.Command { Prompter: prompter, EditorMode: editorMode, Editor: editor, - RequireResolvedBindings: !allowUnresolvedBindings, + RequireResolvedBindings: !allowUnresolvedPreview, Now: now, Registry: registryProvenancePtr, }) if err != nil { - return fmt.Errorf("build harness template install preview: %w", err) + return installPreviewError("build harness template install preview", sourceArg, err) } if err := stageProgress(progress, "checking conflicts"); err != nil { return err @@ -609,12 +610,12 @@ func NewInstallCommand() *cobra.Command { Prompter: prompter, EditorMode: editorMode, Editor: editor, - RequireResolvedBindings: !allowUnresolvedBindings, + RequireResolvedBindings: !allowUnresolvedPreview, Now: now, Registry: registryProvenancePtr, }) if err != nil { - return fmt.Errorf("build harness template install preview: %w", err) + return installPreviewError("build harness template install preview", sourceArg, err) } return emitHarnessTemplateInstallPreview( cmd, @@ -639,12 +640,12 @@ func NewInstallCommand() *cobra.Command { Prompter: prompter, EditorMode: editorMode, Editor: editor, - RequireResolvedBindings: !allowUnresolvedBindings, + RequireResolvedBindings: !allowUnresolvedPreview, Now: now, Registry: registryProvenancePtr, }) if err != nil { - return fmt.Errorf("build harness template install preview: %w", err) + return installPreviewError("build harness template install preview", sourceArg, err) } result, err := harnesspkg.ApplyTemplateInstallPreview(cmd.Context(), resolved.Repo.Root, preview, overwriteExisting) if err != nil { @@ -654,7 +655,7 @@ func NewInstallCommand() *cobra.Command { return emitHarnessTemplateInstallResult(cmd, resolved.Repo.Root, result, jsonOutput) } } - return fmt.Errorf("build harness install preview: %w", err) + return installPreviewError("build harness install preview", sourceArg, err) } if preview.RemoteResolutionKind == orbittemplate.RemoteTemplateResolutionSourceAlias { if err := stageProgress(progress, "source branch detected; resolving published template"); err != nil { @@ -761,7 +762,8 @@ func NewInstallCommand() *cobra.Command { cmd.Flags().Bool("overwrite-existing", false, "Allow overwriting an existing install-backed orbit and removing stale install-owned files") cmd.Flags().StringSlice("override", nil, "Explicitly transfer ownership of one or more existing orbit members from another install unit") cmd.Flags().Bool("allow-unresolved-bindings", false, "Compatibility no-op: unresolved required bindings are preserved by default") - cmd.Flags().Bool("strict-bindings", false, "Fail when required bindings are unresolved instead of preserving placeholders") + cmd.Flags().Bool("strict-bindings", false, "Compatibility no-op: strict binding resolution is the default") + hideInstallBindingCompatibilityFlags(cmd) cmd.Flags().Bool("dry-run", false, "Preview harness install without writing files") addProgressFlag(cmd) cmd.Flags().Bool("interactive", false, "Prompt for missing bindings interactively") @@ -943,7 +945,51 @@ func allowUnresolvedBindingsFromFlags(cmd *cobra.Command) (bool, error) { if allowUnresolvedBindings && strictBindings { return false, fmt.Errorf("--strict-bindings cannot be used with --allow-unresolved-bindings") } - return !strictBindings, nil + return allowUnresolvedBindings, nil +} + +func hideInstallBindingCompatibilityFlags(cmd *cobra.Command) { + for _, flagName := range []string{"allow-unresolved-bindings", "strict-bindings"} { + if err := cmd.Flags().MarkHidden(flagName); err != nil { + panic(err) + } + } +} + +func installPreviewError(context string, sourceArg string, err error) error { + wrapped := fmt.Errorf("%s: %w", context, err) + if !installErrorNeedsBindingsRecovery(err) { + return wrapped + } + return fmt.Errorf("%w; %s", wrapped, installBindingsRecoveryHint(sourceArg)) +} + +func installErrorNeedsBindingsRecovery(err error) bool { + message := err.Error() + for _, needle := range []string{ + "missing required bindings:", + "unsupported Package Template Reference namespace", + "unknown Package Variable", + "unresolved Package Variable", + "malformed Package Template Reference", + } { + if strings.Contains(message, needle) { + return true + } + } + return false +} + +func installBindingsRecoveryHint(sourceArg string) string { + source := strings.TrimSpace(sourceArg) + if source == "" { + source = "" + } + return fmt.Sprintf( + "run hyard vars init %s --out .harness/vars.yaml, then retry with hyard install %s --bindings .harness/vars.yaml", + source, + source, + ) } func installOverrideIDsFromFlags(cmd *cobra.Command) (map[string]struct{}, error) { diff --git a/cmd/harness/cli/commands/install_batch.go b/cmd/harness/cli/commands/install_batch.go index 3b4c041..a50c889 100644 --- a/cmd/harness/cli/commands/install_batch.go +++ b/cmd/harness/cli/commands/install_batch.go @@ -106,6 +106,7 @@ func NewInstallBatchCommand() *cobra.Command { if err != nil { return err } + allowUnresolvedPreview := allowUnresolvedBindings || dryRun interactive, err := cmd.Flags().GetBool("interactive") if err != nil { return fmt.Errorf("read --interactive flag: %w", err) @@ -148,7 +149,7 @@ func NewInstallBatchCommand() *cobra.Command { sourceArg, bindingsPath, overwriteExisting, - allowUnresolvedBindings, + allowUnresolvedPreview, interactive, prompter, editorMode, @@ -243,7 +244,8 @@ func NewInstallBatchCommand() *cobra.Command { cmd.Flags().String("bindings", "", "Path to an explicit bindings YAML file") cmd.Flags().Bool("overwrite-existing", false, "Allow overwriting an existing install-backed orbit and removing stale install-owned files") cmd.Flags().Bool("allow-unresolved-bindings", false, "Compatibility no-op: unresolved required bindings are preserved by default") - cmd.Flags().Bool("strict-bindings", false, "Fail when required bindings are unresolved instead of preserving placeholders") + cmd.Flags().Bool("strict-bindings", false, "Compatibility no-op: strict binding resolution is the default") + hideInstallBindingCompatibilityFlags(cmd) cmd.Flags().Bool("dry-run", false, "Preview harness install without writing files") addProgressFlag(cmd) cmd.Flags().Bool("interactive", false, "Prompt for missing bindings interactively") @@ -293,7 +295,7 @@ func buildOrbitInstallBatchCandidate( } preview, err := orbittemplate.BuildTemplateApplyPreview(cmd.Context(), previewInput) if err != nil { - return orbitInstallBatchCandidate{}, fmt.Errorf("build harness install preview: %w", err) + return orbitInstallBatchCandidate{}, installPreviewError("build harness install preview", sourceArg, err) } if err := validateBatchTargetState(repoRoot, preview.Source.Manifest.Template.OrbitID, overwriteExisting); err != nil { return orbitInstallBatchCandidate{}, err @@ -319,7 +321,7 @@ func buildOrbitInstallBatchCandidate( } preview, err := orbittemplate.BuildRemoteTemplateApplyPreview(cmd.Context(), previewInput) if err != nil { - return orbitInstallBatchCandidate{}, fmt.Errorf("build harness install preview: %w", err) + return orbitInstallBatchCandidate{}, installPreviewError("build harness install preview", sourceArg, err) } if err := validateBatchTargetState(repoRoot, preview.Source.Manifest.Template.OrbitID, overwriteExisting); err != nil { return orbitInstallBatchCandidate{}, err diff --git a/cmd/harness/cli/help_integration_test.go b/cmd/harness/cli/help_integration_test.go index c68539b..0f3d99e 100644 --- a/cmd/harness/cli/help_integration_test.go +++ b/cmd/harness/cli/help_integration_test.go @@ -287,8 +287,8 @@ func TestHarnessInstallHelpIncludesFormalExamples(t *testing.T) { require.Contains(t, stdout, "harness install orbit-template/docs --bindings .harness/vars.yaml") require.Contains(t, stdout, "harness install https://example.com/acme/templates.git --ref orbit-template/docs --bindings .harness/vars.yaml") require.Contains(t, stdout, "harness install orbit-template/docs --overwrite-existing --bindings .harness/vars.yaml --json") - require.Contains(t, stdout, "--allow-unresolved-bindings") - require.Contains(t, stdout, "--strict-bindings") + require.NotContains(t, stdout, "--allow-unresolved-bindings") + require.NotContains(t, stdout, "--strict-bindings") require.Contains(t, stdout, "--progress") } @@ -301,8 +301,8 @@ func TestHarnessInstallBatchHelpIncludesExamples(t *testing.T) { require.Contains(t, stdout, "Examples:") require.Contains(t, stdout, "harness install batch orbit-template/docs orbit-template/cmd --bindings .harness/vars.yaml --dry-run") require.Contains(t, stdout, "harness install batch orbit-template/docs orbit-template/cmd --bindings .harness/vars.yaml --json") - require.Contains(t, stdout, "--allow-unresolved-bindings") - require.Contains(t, stdout, "--strict-bindings") + require.NotContains(t, stdout, "--allow-unresolved-bindings") + require.NotContains(t, stdout, "--strict-bindings") require.Contains(t, stdout, "shared preview") }