Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions cmd/harness/cli/commands/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down Expand Up @@ -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))
}
Expand Down
10 changes: 2 additions & 8 deletions cmd/harness/cli/commands/install_batch.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
133 changes: 133 additions & 0 deletions cmd/hyard/cli/vars.go
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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",
Expand All @@ -40,6 +53,7 @@ func newVarsCommand() *cobra.Command {
}

cmd.AddCommand(
newVarsInitCommand(),
newVarsPathCommand(),
newVarsValidateCommand(),
newVarsDoctorCommand(),
Expand All @@ -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 <package-source>",
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",
Expand Down
128 changes: 128 additions & 0 deletions cmd/hyard/cli/vars_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package cli_test

import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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"`
Expand Down
9 changes: 9 additions & 0 deletions cmd/orbit/cli/bindings/merge.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading