diff --git a/cmd/harness/cli/commands/install.go b/cmd/harness/cli/commands/install.go index d6c90d0..120b508 100644 --- a/cmd/harness/cli/commands/install.go +++ b/cmd/harness/cli/commands/install.go @@ -412,7 +412,7 @@ func NewInstallCommand() *cobra.Command { } return cause } - result, err := orbittemplate.ApplyLocalTemplate(cmd.Context(), orbittemplate.TemplateApplyInput{Preview: previewInput}) + result, err := orbittemplate.ApplyTemplatePreview(resolved.Repo.Root, preview, previewInput.OverwriteExisting, previewInput.SkipSharedAgentsWrite) if err != nil { return rollbackOnError(fmt.Errorf("install local template: %w", err)) } @@ -720,7 +720,7 @@ func NewInstallCommand() *cobra.Command { } return cause } - result, err := orbittemplate.ApplyRemoteTemplate(cmd.Context(), orbittemplate.RemoteTemplateApplyInput{Preview: previewInput}) + result, err := orbittemplate.ApplyTemplatePreview(resolved.Repo.Root, preview, previewInput.OverwriteExisting, previewInput.SkipSharedAgentsWrite) if err != nil { return rollbackOnError(fmt.Errorf("install external template: %w", err)) } diff --git a/cmd/harness/cli/commands/install_batch.go b/cmd/harness/cli/commands/install_batch.go index a50c889..4c7c40c 100644 --- a/cmd/harness/cli/commands/install_batch.go +++ b/cmd/harness/cli/commands/install_batch.go @@ -669,30 +669,24 @@ func applyOrbitInstallBatchCandidate( cleanupPlan orbittemplate.InstallOwnedCleanupPlan ) if candidate.LocalPreview != nil { - previewInput := *candidate.LocalPreview - previewInput.OverwriteExisting = true - previewInput.SkipSharedAgentsWrite = true if targetState.RequiresOverwrite { cleanupPlan, err = orbittemplate.BuildInstallOwnedCleanupPlan(cmd.Context(), repoRoot, targetState.ExistingRecord, candidate.Preview) if err != nil { return orbitInstallBatchApplied{}, fmt.Errorf("reconstruct existing install ownership: %w", err) } } - result, err = orbittemplate.ApplyLocalTemplate(cmd.Context(), orbittemplate.TemplateApplyInput{Preview: previewInput}) + result, err = orbittemplate.ApplyTemplatePreview(repoRoot, candidate.Preview, true, true) if err != nil { return orbitInstallBatchApplied{}, fmt.Errorf("install local template: %w", err) } } else { - previewInput := *candidate.RemotePreview - previewInput.OverwriteExisting = true - previewInput.SkipSharedAgentsWrite = true if targetState.RequiresOverwrite { cleanupPlan, err = orbittemplate.BuildInstallOwnedCleanupPlan(cmd.Context(), repoRoot, targetState.ExistingRecord, candidate.Preview) if err != nil { return orbitInstallBatchApplied{}, fmt.Errorf("reconstruct existing install ownership: %w", err) } } - result, err = orbittemplate.ApplyRemoteTemplate(cmd.Context(), orbittemplate.RemoteTemplateApplyInput{Preview: previewInput}) + result, err = orbittemplate.ApplyTemplatePreview(repoRoot, candidate.Preview, true, true) if err != nil { return orbitInstallBatchApplied{}, fmt.Errorf("install external template: %w", err) } diff --git a/cmd/hyard/cli/vars.go b/cmd/hyard/cli/vars.go index 626bc36..30a7598 100644 --- a/cmd/hyard/cli/vars.go +++ b/cmd/hyard/cli/vars.go @@ -1,12 +1,17 @@ package cli import ( + "errors" "fmt" + "os" + "path/filepath" "strings" "github.com/spf13/cobra" + "github.com/zack-nova/harnessyard/cmd/orbit/cli/bindings" harnesspkg "github.com/zack-nova/harnessyard/cmd/orbit/cli/harness" + orbittemplate "github.com/zack-nova/harnessyard/cmd/orbit/cli/template" ) type varsDoctorExitError struct { @@ -30,6 +35,14 @@ type varsValidateOutput struct { Valid bool `json:"valid"` } +type varsInitOutput struct { + Path string `json:"path"` + Source string `json:"source"` + VariableCount int `json:"variable_count"` + MissingRequired []string `json:"missing_required,omitempty"` + ReusedValues []string `json:"reused_values,omitempty"` +} + func newVarsCommand() *cobra.Command { cmd := &cobra.Command{ Use: "vars", @@ -40,6 +53,7 @@ func newVarsCommand() *cobra.Command { } cmd.AddCommand( + newVarsInitCommand(), newVarsPathCommand(), newVarsValidateCommand(), newVarsDoctorCommand(), @@ -49,6 +63,125 @@ func newVarsCommand() *cobra.Command { return cmd } +func newVarsInitCommand() *cobra.Command { + var outputPath string + var requestedRef string + var materializeDefaults bool + + cmd := &cobra.Command{ + Use: "init ", + Short: "Generate a Runtime Bindings skeleton", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + workingDir, err := hyardWorkingDirFromCommand(cmd) + if err != nil { + return err + } + resolved, err := harnesspkg.ResolveRoot(cmd.Context(), workingDir) + if err != nil { + return fmt.Errorf("resolve harness root: %w", err) + } + + preview, err := buildVarsInitPreview(cmd, resolved.Repo.Root, args[0], requestedRef) + if err != nil { + return err + } + repoVars, err := loadVarsInitExistingFile(resolved.Repo.Root) + if err != nil { + return err + } + result, err := harnesspkg.BuildBindingsPlanWithOptions([]orbittemplate.BindingsInitPreview{preview}, repoVars, harnesspkg.BindingsPlanOptions{ + MaterializeDefaults: materializeDefaults, + }) + if err != nil { + return fmt.Errorf("build Runtime Bindings skeleton: %w", err) + } + + destination := resolveVarsInitOutputPath(resolved.Repo.Root, outputPath) + if _, err := bindings.WriteVarsFileAtPath(destination, result.Bindings); err != nil { + return fmt.Errorf("write Runtime Bindings skeleton: %w", err) + } + + jsonOutput, err := wantHyardJSON(cmd) + if err != nil { + return err + } + if jsonOutput { + return emitHyardJSON(cmd, varsInitOutput{ + Path: outputPath, + Source: args[0], + VariableCount: len(result.Bindings.Variables), + MissingRequired: result.MissingRequired, + ReusedValues: result.ReusedValues, + }) + } + + if _, err := fmt.Fprintf(cmd.OutOrStdout(), "wrote Runtime Bindings skeleton to %s\n", outputPath); err != nil { + return fmt.Errorf("write command output: %w", err) + } + return nil + }, + } + cmd.Flags().StringVar(&outputPath, "out", harnesspkg.VarsRepoPath(), "Runtime Bindings output path") + cmd.Flags().StringVar(&requestedRef, "ref", "", "Git ref to read when package-source is a remote repository") + cmd.Flags().BoolVar(&materializeDefaults, "defaults", false, "Materialize declaration defaults as inline values") + addHyardJSONFlag(cmd) + + return cmd +} + +func buildVarsInitPreview(cmd *cobra.Command, repoRoot string, source string, requestedRef string) (orbittemplate.BindingsInitPreview, error) { + preview, localErr := orbittemplate.BuildLocalBindingsInitPreview(cmd.Context(), orbittemplate.LocalBindingsInitInput{ + RepoRoot: repoRoot, + SourceRef: source, + }) + if localErr == nil { + return preview, nil + } + + preview, remoteErr := orbittemplate.BuildRemoteBindingsInitPreview(cmd.Context(), orbittemplate.RemoteBindingsInitInput{ + RepoRoot: repoRoot, + RemoteURL: source, + RequestedRef: requestedRef, + }) + if remoteErr == nil { + return preview, nil + } + + return orbittemplate.BindingsInitPreview{}, fmt.Errorf( + "resolve package source %q: %w", + source, + errors.Join( + fmt.Errorf("local branch: %w", localErr), + fmt.Errorf("remote source: %w", remoteErr), + ), + ) +} + +func loadVarsInitExistingFile(repoRoot string) (bindings.VarsFile, error) { + if _, err := os.Stat(harnesspkg.VarsPath(repoRoot)); err == nil { + file, err := harnesspkg.LoadVarsFile(repoRoot) + if err != nil { + return bindings.VarsFile{}, fmt.Errorf("load %s: %w", harnesspkg.VarsRepoPath(), err) + } + return file, nil + } else if !os.IsNotExist(err) { + return bindings.VarsFile{}, fmt.Errorf("stat %s: %w", harnesspkg.VarsRepoPath(), err) + } + + return bindings.VarsFile{ + SchemaVersion: bindings.VarsSchemaVersion, + Variables: map[string]bindings.VariableBinding{}, + }, nil +} + +func resolveVarsInitOutputPath(repoRoot string, outputPath string) string { + if filepath.IsAbs(outputPath) { + return filepath.Clean(outputPath) + } + return filepath.Join(repoRoot, filepath.FromSlash(outputPath)) +} + func newVarsDoctorCommand() *cobra.Command { cmd := &cobra.Command{ Use: "doctor", diff --git a/cmd/hyard/cli/vars_integration_test.go b/cmd/hyard/cli/vars_integration_test.go index c7fdc06..6771eec 100644 --- a/cmd/hyard/cli/vars_integration_test.go +++ b/cmd/hyard/cli/vars_integration_test.go @@ -2,6 +2,8 @@ package cli_test import ( "encoding/json" + "os" + "path/filepath" "testing" "time" @@ -70,6 +72,81 @@ func TestHyardVarsValidateReportsActionableRuntimeBindingsErrors(t *testing.T) { require.ErrorContains(t, err, "variables.project_name must set exactly one of value or value_from") } +func TestHyardVarsInitWritesSchema2RuntimeBindingsSkeleton(t *testing.T) { + t.Parallel() + + repo := seedHyardVarsInitTemplateRepo(t, true) + + stdout, stderr, err := executeHyardCLI(t, repo.Root, "vars", "init", "orbit-template/docs") + require.NoError(t, err) + require.Empty(t, stderr) + require.Equal(t, "wrote Runtime Bindings skeleton to .harness/vars.yaml\n", stdout) + + file, err := harnesspkg.LoadVarsFile(repo.Root) + require.NoError(t, err) + require.Equal(t, bindings.VarsSchemaVersion, file.SchemaVersion) + require.Empty(t, file.Variables["project_name"].Value) + require.Equal(t, "Product title", file.Variables["project_name"].Description) + require.Empty(t, file.Variables["docs_url"].Value) + require.Equal(t, "Documentation URL", file.Variables["docs_url"].Description) + require.NotNil(t, file.Variables["github_token"].ValueFrom) + require.Equal(t, "GITHUB_TOKEN", file.Variables["github_token"].ValueFrom.Env) + require.Empty(t, file.Variables["github_token"].Value) +} + +func TestHyardVarsInitDefaultsMaterializesDeclarationDefaults(t *testing.T) { + t.Parallel() + + repo := seedHyardVarsInitTemplateRepo(t, true) + + stdout, stderr, err := executeHyardCLI(t, repo.Root, "vars", "init", "orbit-template/docs", "--defaults") + require.NoError(t, err) + require.Empty(t, stderr) + require.Equal(t, "wrote Runtime Bindings skeleton to .harness/vars.yaml\n", stdout) + + file, err := harnesspkg.LoadVarsFile(repo.Root) + require.NoError(t, err) + require.Equal(t, "https://docs.example.test", file.Variables["docs_url"].Value) +} + +func TestHyardInstallInteractivePersistsMissingBindingsAndSkipsDefaults(t *testing.T) { + t.Parallel() + + repo := seedHyardVarsInitTemplateRepo(t, false) + + stdout, stderr, err := executeHyardCLIWithInput( + t, + repo.Root, + "Acme Docs\n", + "install", + "orbit-template/docs", + "--interactive", + "--json", + ) + require.NoError(t, err, "stdout: %s\nstderr: %s", stdout, stderr) + require.Contains(t, stderr, "project_name (Product title): ") + require.NotContains(t, stderr, "docs_url") + + var payload struct { + DryRun bool `json:"dry_run"` + OrbitID string `json:"orbit_id"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &payload)) + require.False(t, payload.DryRun) + require.Equal(t, "docs", payload.OrbitID) + + rendered, err := os.ReadFile(filepath.Join(repo.Root, "docs", "guide.md")) + require.NoError(t, err) + require.Equal(t, "Acme Docs guide at https://docs.example.test\n", string(rendered)) + + file, err := harnesspkg.LoadVarsFile(repo.Root) + require.NoError(t, err) + require.Equal(t, "Acme Docs", file.Variables["project_name"].Value) + _, hasDefaultBinding := file.Variables["docs_url"] + require.False(t, hasDefaultBinding) + require.NoError(t, harnesspkg.ValidateVarsFile(repo.Root)) +} + func TestHyardVarsDoctorReportsRuntimeBindingDiagnostics(t *testing.T) { lockHyardProcessEnv(t) @@ -213,6 +290,57 @@ func seedHyardVarsInstallRuntime(t *testing.T, declarations map[string]bindings. return repo } +func seedHyardVarsInitTemplateRepo(t *testing.T, includeSensitive bool) *testutil.Repo { + t.Helper() + + repo := testutil.NewRepo(t) + repo.Run(t, "branch", "-m", "main") + _, err := harnesspkg.BootstrapRuntimeControlPlane(repo.Root, time.Date(2026, time.May, 12, 12, 0, 0, 0, time.UTC)) + require.NoError(t, err) + repo.AddAndCommit(t, "seed empty runtime") + + repo.Run(t, "checkout", "-b", "orbit-template/docs") + repo.Run(t, "rm", "--ignore-unmatch", ".harness/runtime.yaml") + + variables := "" + + "variables:\n" + + " docs_url:\n" + + " description: Documentation URL\n" + + " required: true\n" + + " default: https://docs.example.test\n" + + " project_name:\n" + + " description: Product title\n" + + " required: true\n" + if includeSensitive { + variables += "" + + " github_token:\n" + + " description: GitHub token\n" + + " required: true\n" + + " sensitive: true\n" + } + + repo.WriteFile(t, ".harness/manifest.yaml", ""+ + "schema_version: 1\n"+ + "kind: orbit_template\n"+ + "template:\n"+ + " orbit_id: docs\n"+ + " default_template: false\n"+ + " created_from_branch: main\n"+ + " created_from_commit: abc123\n"+ + " created_at: 2026-05-12T12:00:00Z\n"+ + variables) + repo.WriteFile(t, ".harness/orbits/docs.yaml", ""+ + "id: docs\n"+ + "description: Docs orbit\n"+ + "include:\n"+ + " - docs/**\n") + repo.WriteFile(t, "docs/guide.md", "{{ vars.project_name }} guide at {{ vars.docs_url }}\n") + repo.AddAndCommit(t, "seed vars init template") + repo.Run(t, "checkout", "main") + + return repo +} + type varsDiagnosticPayload struct { Code string `json:"code"` Variable string `json:"variable"` diff --git a/cmd/orbit/cli/bindings/merge.go b/cmd/orbit/cli/bindings/merge.go index 85cf065..5868a60 100644 --- a/cmd/orbit/cli/bindings/merge.go +++ b/cmd/orbit/cli/bindings/merge.go @@ -17,6 +17,7 @@ const ( SourceRepoVarsScoped MergeSource = "repo_vars_scoped" SourceInteractive MergeSource = "interactive" SourceEditor MergeSource = "editor" + SourceDefault MergeSource = "default" ) // VariableDeclaration is the template manifest metadata needed for merge decisions. @@ -133,6 +134,14 @@ func Merge(input MergeInput) (MergeResult, error) { Source: fillSource, Namespace: namespace, } + case declaration.Default != nil: + result.Resolved[name] = ResolvedBinding{ + Value: *declaration.Default, + Description: description, + Required: declaration.Required, + Source: SourceDefault, + Namespace: namespace, + } case declaration.Required: result.Unresolved = append(result.Unresolved, UnresolvedBinding{ Name: name, diff --git a/cmd/orbit/cli/bindings/merge_test.go b/cmd/orbit/cli/bindings/merge_test.go index 28b3cce..e1eb3c7 100644 --- a/cmd/orbit/cli/bindings/merge_test.go +++ b/cmd/orbit/cli/bindings/merge_test.go @@ -166,6 +166,42 @@ func TestMergeTreatsBlankValuesAsMissing(t *testing.T) { require.Empty(t, result.Unresolved) } +func TestMergeUsesDeclarationDefaultsBeforeReportingRequiredUnresolved(t *testing.T) { + t.Parallel() + + defaultURL := "https://docs.example.test" + result, err := Merge(MergeInput{ + Declared: map[string]VariableDeclaration{ + "docs_url": { + Description: "Documentation URL", + Required: true, + Default: &defaultURL, + }, + "project_name": { + Description: "Project name", + Required: true, + }, + }, + }) + require.NoError(t, err) + + require.Equal(t, map[string]ResolvedBinding{ + "docs_url": { + Value: "https://docs.example.test", + Description: "Documentation URL", + Required: true, + Source: SourceDefault, + }, + }, result.Resolved) + require.Equal(t, []UnresolvedBinding{ + { + Name: "project_name", + Description: "Project name", + Required: true, + }, + }, result.Unresolved) +} + func TestMergePrefersScopedBindingsAndRecordsNamespaces(t *testing.T) { t.Parallel() diff --git a/cmd/orbit/cli/harness/bindings_plan.go b/cmd/orbit/cli/harness/bindings_plan.go index 29177cf..7366b15 100644 --- a/cmd/orbit/cli/harness/bindings_plan.go +++ b/cmd/orbit/cli/harness/bindings_plan.go @@ -26,6 +26,11 @@ type BindingsPlanResult struct { ReusedValues []string } +// BindingsPlanOptions configures Runtime Bindings skeleton generation. +type BindingsPlanOptions struct { + MaterializeDefaults bool +} + type bindingsPlanDeclaration struct { OrbitID string Declaration bindings.VariableDeclaration @@ -35,6 +40,15 @@ type bindingsPlanDeclaration struct { func BuildBindingsPlan( previews []orbittemplate.BindingsInitPreview, repoVars bindings.VarsFile, +) (BindingsPlanResult, error) { + return BuildBindingsPlanWithOptions(previews, repoVars, BindingsPlanOptions{}) +} + +// BuildBindingsPlanWithOptions merges bindings-init previews into one shared runtime bindings skeleton. +func BuildBindingsPlanWithOptions( + previews []orbittemplate.BindingsInitPreview, + repoVars bindings.VarsFile, + options BindingsPlanOptions, ) (BindingsPlanResult, error) { result := BindingsPlanResult{ Sources: make([]BindingsPlanSource, 0, len(previews)), @@ -42,7 +56,7 @@ func BuildBindingsPlan( } if result.Bindings.SchemaVersion == 0 { - result.Bindings.SchemaVersion = 1 + result.Bindings.SchemaVersion = bindings.VarsSchemaVersion } if result.Bindings.Variables == nil { result.Bindings.Variables = map[string]bindings.VariableBinding{} @@ -77,7 +91,7 @@ func BuildBindingsPlan( declarations := declarationsByName[name] delete(result.Bindings.Variables, name) if bindingsPlanHasDeclarationConflict(name, declarations) { - appendScopedBindingsPlanValue(&result, repoVars, name, declarations) + appendScopedBindingsPlanValue(&result, repoVars, name, declarations, options) continue } @@ -85,16 +99,13 @@ func BuildBindingsPlan( if err != nil { return BindingsPlanResult{}, fmt.Errorf("merge shared bindings declarations: %w", err) } - binding := bindings.VariableBinding{ - Value: "", - Description: spec.Description, - } + binding := bindingsPlanBindingFromDeclaration(name, spec, options) - if existing, ok := repoVars.Variables[name]; ok && strings.TrimSpace(existing.Value) != "" { - binding.Value = existing.Value + if existing, ok := repoVars.Variables[name]; ok && bindingsPlanHasConfiguredValue(existing) { + binding = bindingsPlanReusedBinding(existing, spec.Description) result.ReusedValues = append(result.ReusedValues, name) } - if binding.Value == "" && spec.Required { + if !bindingsPlanHasConfiguredValue(binding) && spec.Required && spec.Default == nil { result.MissingRequired = append(result.MissingRequired, name) } @@ -138,6 +149,7 @@ func appendScopedBindingsPlanValue( repoVars bindings.VarsFile, name string, declarations []bindingsPlanDeclaration, + options BindingsPlanOptions, ) { if result.Bindings.ScopedVariables == nil { result.Bindings.ScopedVariables = map[string]bindings.ScopedVariableBindings{} @@ -149,18 +161,15 @@ func appendScopedBindingsPlanValue( scoped.Variables = map[string]bindings.VariableBinding{} } - binding := bindings.VariableBinding{ - Value: "", - Description: item.Declaration.Description, - } - if existing, ok := bindings.ScopedVariablesForNamespace(repoVars, namespace)[name]; ok && strings.TrimSpace(existing.Value) != "" { - binding.Value = existing.Value + binding := bindingsPlanBindingFromDeclaration(name, item.Declaration, options) + if existing, ok := bindings.ScopedVariablesForNamespace(repoVars, namespace)[name]; ok && bindingsPlanHasConfiguredValue(existing) { + binding = bindingsPlanReusedBinding(existing, item.Declaration.Description) result.ReusedValues = append(result.ReusedValues, namespacedBindingsPlanName(namespace, name)) - } else if existing, ok := repoVars.Variables[name]; ok && strings.TrimSpace(existing.Value) != "" { - binding.Value = existing.Value + } else if existing, ok := repoVars.Variables[name]; ok && bindingsPlanHasConfiguredValue(existing) { + binding = bindingsPlanReusedBinding(existing, item.Declaration.Description) result.ReusedValues = append(result.ReusedValues, namespacedBindingsPlanName(namespace, name)) } - if binding.Value == "" && item.Declaration.Required { + if !bindingsPlanHasConfiguredValue(binding) && item.Declaration.Required && item.Declaration.Default == nil { result.MissingRequired = append(result.MissingRequired, namespacedBindingsPlanName(namespace, name)) } @@ -200,6 +209,37 @@ func namespacedBindingsPlanName(namespace string, name string) string { return fmt.Sprintf("%s:%s", namespace, name) } +func bindingsPlanBindingFromDeclaration(name string, declaration bindings.VariableDeclaration, options BindingsPlanOptions) bindings.VariableBinding { + binding := bindings.VariableBinding{ + Value: "", + Description: declaration.Description, + } + if declaration.Sensitive { + binding.ValueFrom = &bindings.ValueSource{Env: bindingsPlanEnvName(name)} + return binding + } + if options.MaterializeDefaults && declaration.Default != nil { + binding.Value = *declaration.Default + } + return binding +} + +func bindingsPlanReusedBinding(existing bindings.VariableBinding, description string) bindings.VariableBinding { + return bindings.VariableBinding{ + Value: existing.Value, + ValueFrom: existing.ValueFrom, + Description: description, + } +} + +func bindingsPlanHasConfiguredValue(binding bindings.VariableBinding) bool { + return strings.TrimSpace(binding.Value) != "" || binding.ValueFrom != nil +} + +func bindingsPlanEnvName(name string) string { + return strings.ToUpper(name) +} + func sortedVariableNames(values map[string]orbittemplate.VariableSpec) []string { names := make([]string, 0, len(values)) for name := range values { diff --git a/cmd/orbit/cli/harness/bindings_plan_test.go b/cmd/orbit/cli/harness/bindings_plan_test.go index 8740a00..ea79811 100644 --- a/cmd/orbit/cli/harness/bindings_plan_test.go +++ b/cmd/orbit/cli/harness/bindings_plan_test.go @@ -97,6 +97,50 @@ func TestBuildBindingsPlanMergesPreviewsAndPrefillsRepoValues(t *testing.T) { }, result.Bindings) } +func TestBuildBindingsPlanRespectsDefaultsAndSensitiveDeclarations(t *testing.T) { + t.Parallel() + + defaultURL := "https://docs.example.test" + preview := orbittemplate.BindingsInitPreview{ + Source: orbittemplate.Source{ + SourceKind: orbittemplate.InstallSourceKindLocalBranch, + SourceRef: "orbit-template/docs", + TemplateCommit: "abc123", + }, + Manifest: orbittemplate.Manifest{ + SchemaVersion: 1, + Kind: orbittemplate.TemplateKind, + Template: orbittemplate.Metadata{ + OrbitID: "docs", + CreatedFromBranch: "main", + CreatedFromCommit: "abc123", + CreatedAt: time.Date(2026, time.April, 9, 10, 0, 0, 0, time.UTC), + }, + Variables: map[string]orbittemplate.VariableSpec{ + "docs_url": {Description: "Documentation URL", Required: true, Default: &defaultURL}, + "github_token": {Description: "GitHub token", Required: true, Sensitive: true}, + "project_name": {Description: "Product title", Required: true}, + }, + }, + } + + result, err := BuildBindingsPlanWithOptions([]orbittemplate.BindingsInitPreview{preview}, bindings.VarsFile{}, BindingsPlanOptions{}) + require.NoError(t, err) + require.Equal(t, bindings.VarsSchemaVersion, result.Bindings.SchemaVersion) + require.Equal(t, []string{"project_name"}, result.MissingRequired) + require.Empty(t, result.Bindings.Variables["docs_url"].Value) + require.NotNil(t, result.Bindings.Variables["github_token"].ValueFrom) + require.Equal(t, "GITHUB_TOKEN", result.Bindings.Variables["github_token"].ValueFrom.Env) + + withDefaults, err := BuildBindingsPlanWithOptions([]orbittemplate.BindingsInitPreview{preview}, bindings.VarsFile{}, BindingsPlanOptions{ + MaterializeDefaults: true, + }) + require.NoError(t, err) + require.Equal(t, "https://docs.example.test", withDefaults.Bindings.Variables["docs_url"].Value) + require.NotNil(t, withDefaults.Bindings.Variables["github_token"].ValueFrom) + require.Equal(t, "GITHUB_TOKEN", withDefaults.Bindings.Variables["github_token"].ValueFrom.Env) +} + func TestBuildBindingsPlanNamespacesVariableDescriptionConflict(t *testing.T) { t.Parallel() diff --git a/cmd/orbit/cli/harness/template_install_preview.go b/cmd/orbit/cli/harness/template_install_preview.go index fb8f51a..b255bbc 100644 --- a/cmd/orbit/cli/harness/template_install_preview.go +++ b/cmd/orbit/cli/harness/template_install_preview.go @@ -1160,7 +1160,7 @@ func planTemplateInstallBindingsWrite( changed := false for name, binding := range resolved { - if binding.Source == bindings.SourceRepoVars || binding.Source == bindings.SourceRepoVarsScoped { + if binding.Source == bindings.SourceRepoVars || binding.Source == bindings.SourceRepoVarsScoped || binding.Source == bindings.SourceDefault { continue } diff --git a/cmd/orbit/cli/template/apply.go b/cmd/orbit/cli/template/apply.go index bb353f0..fed0860 100644 --- a/cmd/orbit/cli/template/apply.go +++ b/cmd/orbit/cli/template/apply.go @@ -724,6 +724,11 @@ func ApplyLocalTemplate(ctx context.Context, input TemplateApplyInput) (Template return applyTemplatePreview(input.Preview.RepoRoot, preview, input.Preview.OverwriteExisting, input.Preview.SkipSharedAgentsWrite) } +// ApplyTemplatePreview writes an already-built template preview into the runtime repository. +func ApplyTemplatePreview(repoRoot string, preview TemplateApplyPreview, overwriteExisting bool, skipSharedAgentsWrite bool) (TemplateApplyResult, error) { + return applyTemplatePreview(repoRoot, preview, overwriteExisting, skipSharedAgentsWrite) +} + // ApplyRemoteTemplate writes the rendered remote template payload into the runtime repository. func ApplyRemoteTemplate(ctx context.Context, input RemoteTemplateApplyInput) (TemplateApplyResult, error) { preview, err := BuildRemoteTemplateApplyPreview(ctx, input.Preview) @@ -735,6 +740,7 @@ func ApplyRemoteTemplate(ctx context.Context, input RemoteTemplateApplyInput) (T } func applyTemplatePreview(repoRoot string, preview TemplateApplyPreview, overwriteExisting bool, skipSharedAgentsWrite bool) (TemplateApplyResult, error) { + preview = applyPreviewForWrite(preview, skipSharedAgentsWrite) if len(preview.Conflicts) > 0 && !overwriteExisting { return TemplateApplyResult{}, fmt.Errorf("conflicts detected; re-run with --overwrite-existing to allow replacing existing runtime files") } @@ -785,6 +791,33 @@ func applyTemplatePreview(repoRoot string, preview TemplateApplyPreview, overwri }, nil } +func applyPreviewForWrite(preview TemplateApplyPreview, skipSharedAgentsWrite bool) TemplateApplyPreview { + if !skipSharedAgentsWrite { + return preview + } + + next := preview + if len(preview.Conflicts) > 0 { + next.Conflicts = make([]ApplyConflict, 0, len(preview.Conflicts)) + for _, conflict := range preview.Conflicts { + if conflict.Path == sharedFilePathAgents { + continue + } + next.Conflicts = append(next.Conflicts, conflict) + } + } + if len(preview.Warnings) > 0 { + next.Warnings = make([]string, 0, len(preview.Warnings)) + for _, warning := range preview.Warnings { + if strings.HasPrefix(warning, "runtime AGENTS.md ") { + continue + } + next.Warnings = append(next.Warnings, warning) + } + } + return next +} + func loadOptionalBindingsFile(filename string) (bindings.VarsFile, error) { if strings.TrimSpace(filename) == "" { return bindings.VarsFile{ @@ -878,7 +911,7 @@ func planResolvedBindingsWrite( changed := false for name, binding := range resolved { - if binding.Source == bindings.SourceRepoVars || binding.Source == bindings.SourceRepoVarsScoped { + if binding.Source == bindings.SourceRepoVars || binding.Source == bindings.SourceRepoVarsScoped || binding.Source == bindings.SourceDefault { continue }