diff --git a/CHANGELOG.md b/CHANGELOG.md index 923a778..17e6165 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,13 @@ called out in a **Breaking** section. See - **`apply --dry-run` is removed.** The read-only preview is now solely `ferry diff`, which shows what `apply` would change without writing anything. Replace any `ferry apply --dry-run` with `ferry diff`. +- **`ferry init`'s wizard flags are reshaped (7 → 6).** `--no-wizard` and + `--wizard-answers ` are removed, folded into one + `--wizard=off|interactive|answers:` mode flag. `--yes` narrows to + confirmation-assent only — it assents to the `--github` repo creation and the + closing `--apply`, and no longer skips the wizard (use `--wizard=off` for + that). `--repair` is unchanged but now conflicts with `--wizard=off` rather + than `--yes`, so `--repair --yes` on a terminal is valid. ### Added diff --git a/cmd/init.go b/cmd/init.go index 9d0057d..0dda1c5 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -21,25 +21,26 @@ import ( func init() { // init-only flags, registered here (not commands.go, which the skeleton wave owns). - // --fresh force the fresh path (new repo) even if a source arg looks - // present; optionally takes a positional dir (see below) - // --yes don't ask anything: skip the first-run wizard (plain adopt - // with automatic secret extraction) and assume yes for the - // confirmations init would otherwise ask (the --github - // create-confirm; the closing apply confirm with --apply) - // --apply actually run apply at the end (default: show the plan and stop) - // --no-wizard skip the interactive first-run wizard (same fallback as --yes) - // --repair opt into the wizard's repair review (hardcoded-home, - // duplicate-PATH, dead-source fixes); needs the interactive - // wizard or --wizard-answers - // --wizard-answers drive the wizard from a TOML answers file (no TUI, no tty) + // --fresh force the fresh path (new repo) even if a source arg looks + // present; optionally takes a positional dir (see below) + // --yes assume yes for the confirmations init would otherwise ASK + // (the --github create-confirm; the closing apply confirm with + // --apply). It does NOT skip the wizard — use --wizard=off for that. + // --apply actually run apply at the end (default: show the plan and stop) + // --wizard first-run wizard mode: off | interactive | answers:. + // off = skip the wizard (plain adopt with automatic secret + // extraction); interactive = force the TUI; answers: = drive + // the wizard from a TOML answers file (no TUI, no tty). Empty (the + // default) is interactive on a real tty pair, else off. + // --repair opt into the wizard's repair review (hardcoded-home, + // duplicate-PATH, dead-source fixes); needs the interactive wizard + // or --wizard=answers: (conflicts with --wizard=off) initCmd.Flags().Bool("fresh", false, "set up a NEW config repo (capture this machine) instead of cloning") - initCmd.Flags().Bool("yes", false, "don't ask anything: skip the first-run wizard (adopt with automatic secret extraction) and assume yes for init's confirmations") + initCmd.Flags().Bool("yes", false, "assume yes for init's confirmations (the --github create-confirm and the closing --apply confirm); does NOT skip the wizard — use --wizard=off") initCmd.Flags().Bool("apply", false, "run apply at the end of init (default: show the plan and stop)") initCmd.Flags().Bool("github", false, "create a NEW private GitHub repo via the gh CLI and manage it as ferry's remote") - initCmd.Flags().Bool("no-wizard", false, "skip the interactive first-run wizard (plain adopt with automatic secret extraction)") + initCmd.Flags().String("wizard", "", "first-run wizard mode: off | interactive | answers: (default: interactive on a real tty, else off)") initCmd.Flags().Bool("repair", false, "review opt-in repairs (hardcoded home paths, duplicate PATH exports, dead source lines) in the wizard") - initCmd.Flags().String("wizard-answers", "", "drive the first-run wizard from a TOML answers file instead of the interactive TUI") } // defaultRepoDir returns ferry's neutral, ferry-owned default location for a @@ -82,8 +83,15 @@ func runInit(c *cobra.Command, args []string) error { fresh, _ := c.Flags().GetBool("fresh") github, _ := c.Flags().GetBool("github") + // Validate --wizard up front (external CLI input) regardless of which route + // init takes, so a bogus mode errors clearly even on the clone/existing path + // where the wizard never runs. + if _, _, err := parseWizardMode(mustGetString(c, "wizard")); err != nil { + return err + } + // --repair is consent-per-finding by design: it REQUIRES the interactive - // wizard (or a --wizard-answers file, which replaces only the TUI). Reject + // wizard (or a --wizard=answers:, which replaces only the TUI). Reject // conflicting invocations BEFORE any work, naming the conflict (r2-m4). if err := validateRepairFlags(c); err != nil { return err @@ -283,24 +291,19 @@ func cloneExisting(out io.Writer, source string) (string, error) { // initFresh sets up a NEW config repo on the fresh path. It builds ONE // in-memory seedPlan — via the interactive wizard (both stdin AND stdout -// ttys), the --wizard-answers data model, or the non-interactive +// ttys), the --wizard=answers: data model, or the non-interactive // gate-and-extract fallback — PURELY, then executes it (re-gate -> visible // .bak -> secret puts -> git init + seed). declined=true means the user // backed out at the preview gate: nothing was written, and runInit must not // write config.toml either. func initFresh(c *cobra.Command, in *bufio.Reader, out io.Writer, freshDir string) (repoPath string, declined bool, err error) { errOut := c.ErrOrStderr() - yes, _ := c.Flags().GetBool("yes") - noWizard, _ := c.Flags().GetBool("no-wizard") - repair, _ := c.Flags().GetBool("repair") - answersPath, _ := c.Flags().GetString("wizard-answers") - - plan, declined, err := buildInitSeedPlan(in, out, errOut, freshInitOpts{ - yes: yes, - noWizard: noWizard, - repair: repair, - answersPath: answersPath, - }) + opts, err := resolveFreshInitOpts(c) + if err != nil { + return "", false, err + } + + plan, declined, err := buildInitSeedPlan(in, out, errOut, opts) if err != nil || declined { return "", declined, err } @@ -308,22 +311,69 @@ func initFresh(c *cobra.Command, in *bufio.Reader, out io.Writer, freshDir strin return repoPath, false, err } -// freshInitOpts carries the wizard-relevant init flags. +// freshInitOpts carries the resolved wizard-relevant init flags. mode is the +// parsed --wizard mode ("interactive" | "off" | "answers"); answersPath is set +// only for the answers mode. --yes is NOT carried here — it no longer steers +// the wizard, only the confirmation-assent gates read it directly. type freshInitOpts struct { - yes bool - noWizard bool - repair bool + mode string answersPath string + repair bool +} + +// parseWizardMode parses the --wizard flag value into a wizard mode plus an +// optional answers-file path. Valid forms: +// - "" or "interactive" -> interactive (paint the TUI on a real tty pair, +// else fall back to the non-interactive adopt-and-extract); +// - "off" -> skip the wizard (non-interactive fallback even on a tty); +// - "answers:" -> drive the wizard data model from the TOML . +// +// Any other value errors, naming the valid forms. An empty answers path errors. +// This VALIDATES an external CLI argument. +func parseWizardMode(value string) (mode string, answersPath string, err error) { + switch value { + case "", "interactive": + return "interactive", "", nil + case "off": + return "off", "", nil + } + if rest, ok := strings.CutPrefix(value, "answers:"); ok { + if strings.TrimSpace(rest) == "" { + return "", "", fmt.Errorf("--wizard=answers: needs a file path (e.g. --wizard=answers:./init-answers.toml)") + } + return "answers", rest, nil + } + return "", "", fmt.Errorf("--wizard=%q is not a valid mode: use off, interactive, or answers:", value) +} + +// resolveFreshInitOpts reads --wizard and --repair off the command and builds +// the wizard opts consumed by buildInitSeedPlan. Shared by the fresh and +// --github routes so both parse --wizard identically. +func resolveFreshInitOpts(c *cobra.Command) (freshInitOpts, error) { + mode, answersPath, err := parseWizardMode(mustGetString(c, "wizard")) + if err != nil { + return freshInitOpts{}, err + } + repair, _ := c.Flags().GetBool("repair") + return freshInitOpts{mode: mode, answersPath: answersPath, repair: repair}, nil +} + +// mustGetString reads a string flag, ignoring the (never-nil for a registered +// flag) lookup error — the flags below are all registered in init(). +func mustGetString(c *cobra.Command, name string) string { + v, _ := c.Flags().GetString(name) + return v } // buildInitSeedPlan builds the fresh-init seedPlan PURELY (no filesystem or -// network mutation). Surface selection (pinned FLAG PRECEDENCE): -// - --wizard-answers drives the full data model (outranks the wizard-skip -// meaning of --yes / non-tty); -// - otherwise the interactive TUI wizard runs when BOTH stdin and stdout are -// ttys and neither --yes nor --no-wizard was given; -// - otherwise the non-interactive gate-and-extract fallback runs (no TUI, -// no prompts, never blocks on stdin). +// network mutation). Surface selection keys off the resolved --wizard mode: +// - answers mode drives the full data model from the TOML file (outranks the +// tty check); +// - interactive mode paints the TUI wizard when BOTH stdin and stdout are +// ttys, else falls back; +// - off mode (or interactive without a full tty pair) runs the +// non-interactive gate-and-extract fallback (no TUI, no prompts, never +// blocks on stdin). func buildInitSeedPlan(in *bufio.Reader, out, errOut io.Writer, opts freshInitOpts) (*seedPlan, bool, error) { home, err := os.UserHomeDir() if err != nil { @@ -349,17 +399,18 @@ func buildInitSeedPlan(in *bufio.Reader, out, errOut io.Writer, opts freshInitOp } switch { - case opts.answersPath != "": + case opts.mode == "answers": return buildAnswersSeedPlan(out, opts, p, det) - case !opts.yes && !opts.noWizard && stdinIsTerminal() && stdoutIsTerminal(): + case opts.mode == "interactive" && stdinIsTerminal() && stdoutIsTerminal(): return runWizardTUI(in, out, opts, p, det) default: + // off mode, or interactive without a full tty pair. plan, err := buildFallbackSeedPlan(p, det, errOut) return plan, false, err } } -// buildAnswersSeedPlan drives the wizard data model from a --wizard-answers +// buildAnswersSeedPlan drives the wizard data model from a --wizard=answers: // file: every invariant (pure-until-confirm, forced secret routing, gates, // scaffold, masking) runs identically to the TUI; only the TUI is bypassed. // The rendered preview goes to stdout before seeding. @@ -521,25 +572,29 @@ func seedRepoFromPlan(dest string, plan *seedPlan) error { // validateRepairFlags enforces --repair's interactivity requirement: repairs // are consent-per-finding, so --repair needs the interactive wizard OR a -// --wizard-answers file (which replaces only the TUI). Combined with --yes or -// --no-wizard — or without a full tty pair and no answers file — it errors, -// naming the conflict. +// --wizard=answers: (which replaces only the TUI). It CONFLICTS with +// --wizard=off (which skips the wizard entirely), and, in interactive mode +// without an answers file, requires a full tty pair. It no longer references +// --yes: the narrowed --yes only assents to confirmations and does not skip the +// wizard, so --repair --yes on a tty is valid. func validateRepairFlags(c *cobra.Command) error { repair, _ := c.Flags().GetBool("repair") if !repair { return nil } - if answers, _ := c.Flags().GetString("wizard-answers"); answers != "" { - return nil // the answers file satisfies the interactivity requirement - } - if yes, _ := c.Flags().GetBool("yes"); yes { - return fmt.Errorf("--repair conflicts with --yes: repairs are reviewed one by one in the interactive wizard, which --yes skips. Drop --yes, or drive the wizard with --wizard-answers") + mode, _, err := parseWizardMode(mustGetString(c, "wizard")) + if err != nil { + return err } - if noWizard, _ := c.Flags().GetBool("no-wizard"); noWizard { - return fmt.Errorf("--repair conflicts with --no-wizard: repairs are reviewed one by one in the interactive wizard. Drop --no-wizard, or drive the wizard with --wizard-answers") + switch mode { + case "answers": + return nil // the answers file satisfies the interactivity requirement + case "off": + return fmt.Errorf("--repair conflicts with --wizard=off: repairs are reviewed one by one in the wizard, which --wizard=off skips. Drop --wizard=off, or drive the wizard with --wizard=answers:") } + // interactive mode: needs a full tty pair (unless an answers file drives it). if !stdinIsTerminal() || !stdoutIsTerminal() { - return fmt.Errorf("--repair needs an interactive terminal (repairs are reviewed one by one in the wizard, and stdin/stdout is not a tty here) — run it interactively, or drive the wizard with --wizard-answers") + return fmt.Errorf("--repair needs an interactive terminal (repairs are reviewed one by one in the wizard, and stdin/stdout is not a tty here) — run it interactively, or drive the wizard with --wizard=answers:") } return nil } diff --git a/cmd/init_github.go b/cmd/init_github.go index 0542d4e..97c5710 100644 --- a/cmd/init_github.go +++ b/cmd/init_github.go @@ -127,8 +127,8 @@ func initGitHub(c *cobra.Command, in *bufio.Reader, out io.Writer, name string) // Create-confirm: print the FULL resolved //. On a TTY // require confirmation unless --yes; non-interactive REQUIRE --yes. (--yes - // keeps this confirmation-assent meaning even when --wizard-answers drives - // the wizard decisions — the pinned FLAG PRECEDENCE.) + // means confirmation-assent ONLY — it assents to this create-confirm and the + // closing apply confirm; it does not touch the wizard mode.) fmt.Fprintf(out, "ferry will create a PRIVATE GitHub repo: %s\n", resolved) if !yes { if stdinIsTerminal() { @@ -144,15 +144,13 @@ func initGitHub(c *cobra.Command, in *bufio.Reader, out io.Writer, name string) // Wizard / answers-file / gate-and-extract fallback: build the ONE seedPlan // (PURE — no filesystem or network mutation; F2-4/F3-3). Declining at the // wizard's preview gate exits here with nothing written, local or remote. - noWizard, _ := c.Flags().GetBool("no-wizard") - repair, _ := c.Flags().GetBool("repair") - answersPath, _ := c.Flags().GetString("wizard-answers") - plan, declined, err := buildInitSeedPlan(in, out, c.ErrOrStderr(), freshInitOpts{ - yes: yes, - noWizard: noWizard, - repair: repair, - answersPath: answersPath, - }) + // --wizard (mode) and --repair steer this; --yes above is confirmation-assent + // only and does not feed the wizard decision. + opts, err := resolveFreshInitOpts(c) + if err != nil { + return err + } + plan, declined, err := buildInitSeedPlan(in, out, c.ErrOrStderr(), opts) if err != nil { return err } diff --git a/cmd/wizard.go b/cmd/wizard.go index fcc7d89..c4053d6 100644 --- a/cmd/wizard.go +++ b/cmd/wizard.go @@ -2,7 +2,7 @@ package cmd // The v0.3.0 first-run wizard (PLAN-v0.3.0-plugin-wizard.md): consent-driven // SEED-PLANNING for `ferry init`'s fresh path. The wizard (interactive TUI, -// --wizard-answers data model, or the non-interactive gate-and-extract +// --wizard=answers: data model, or the non-interactive gate-and-extract // fallback) produces ONE in-memory seedPlan BEFORE any filesystem or network // mutation; initFresh and `init --github`'s pre-commit gate consume the SAME // plan, so the preview cannot lie about what gets seeded or committed. @@ -76,7 +76,7 @@ type seedPlan struct { } // wizardChoices is the plain data model every wizard surface populates: the -// huh TUI, the --wizard-answers file, and the non-interactive fallback all +// huh TUI, the wizard answers file, and the non-interactive fallback all // feed the SAME struct into buildPlanFromChoices. type wizardChoices struct { mode string // "keep-as-is" | "per-block" | "start-fresh" @@ -88,9 +88,9 @@ type wizardChoices struct { repairsGiven bool // a [repairs] table (or TUI repair step) ran } -// --- answers file (--wizard-answers) ---------------------------------------- +// --- answers file (--wizard=answers:) -------------------------------- -// answersFile is the documented TOML schema for `ferry init --wizard-answers`. +// answersFile is the documented TOML schema for `ferry init --wizard=answers:`. type answersFile struct { Mode string `toml:"mode"` Confirm *bool `toml:"confirm"` @@ -100,31 +100,31 @@ type answersFile struct { Starter map[string]string `toml:"starter"` } -// parseWizardAnswers loads and strictly validates a --wizard-answers file. +// parseWizardAnswers loads and strictly validates a wizard answers file. // Unknown or malformed keys error out loudly — an eval (or user) must never // silently get a default route. func parseWizardAnswers(path string) (*wizardChoices, error) { data, err := os.ReadFile(path) if err != nil { - return nil, fmt.Errorf("read --wizard-answers file: %w", err) + return nil, fmt.Errorf("read wizard answers file: %w", err) } var af answersFile md, err := toml.Decode(string(data), &af) if err != nil { - return nil, fmt.Errorf("parse --wizard-answers file %s: %w", path, err) + return nil, fmt.Errorf("parse wizard answers file %s: %w", path, err) } if undecoded := md.Undecoded(); len(undecoded) > 0 { - return nil, fmt.Errorf("--wizard-answers file %s has unknown key(s) %v — the schema is: mode, confirm, [routes], [secret-routes], [repairs], [starter]", path, undecoded) + return nil, fmt.Errorf("wizard answers file %s has unknown key(s) %v — the schema is: mode, confirm, [routes], [secret-routes], [repairs], [starter]", path, undecoded) } switch af.Mode { case "keep-as-is", "per-block", "start-fresh": case "": - return nil, fmt.Errorf("--wizard-answers file %s is missing `mode` (one of keep-as-is, per-block, start-fresh)", path) + return nil, fmt.Errorf("wizard answers file %s is missing `mode` (one of keep-as-is, per-block, start-fresh)", path) default: - return nil, fmt.Errorf("--wizard-answers file %s has unknown mode %q (want keep-as-is, per-block, or start-fresh)", path, af.Mode) + return nil, fmt.Errorf("wizard answers file %s has unknown mode %q (want keep-as-is, per-block, or start-fresh)", path, af.Mode) } if af.Confirm == nil { - return nil, fmt.Errorf("--wizard-answers file %s is missing `confirm` (true seeds, false declines at the preview gate)", path) + return nil, fmt.Errorf("wizard answers file %s is missing `confirm` (true seeds, false declines at the preview gate)", path) } ch := &wizardChoices{ @@ -177,7 +177,7 @@ func parseWizardAnswers(path string) (*wizardChoices, error) { // user) must never have a table silently ignored. func validateChoicesApplicability(ch *wizardChoices, repairFlag bool) error { if ch.repairsGiven && !repairFlag { - return fmt.Errorf("a [repairs] table was given but --repair was not: repairs are opt-in — re-run with `ferry init --repair --wizard-answers `") + return fmt.Errorf("a [repairs] table was given but --repair was not: repairs are opt-in — re-run with `ferry init --repair --wizard=answers:`") } switch ch.mode { case "start-fresh": @@ -356,7 +356,7 @@ func buildPlanFromChoices(in *wizardInputs, ch *wizardChoices, repairFlag bool) // [repairs] validation: opt-in via --repair; indices within range. if ch.repairsGiven && !repairFlag { - return nil, fmt.Errorf("a [repairs] table was given but --repair was not: repairs are opt-in — re-run with `ferry init --repair --wizard-answers `") + return nil, fmt.Errorf("a [repairs] table was given but --repair was not: repairs are opt-in — re-run with `ferry init --repair --wizard=answers:`") } for idx := range ch.repairs { if idx < 0 || idx >= len(repairables) { @@ -534,8 +534,8 @@ func stripOverlayForGuard(content []byte) []byte { return dotfile.StripFerryOverlayDirective(content) } -// buildFallbackSeedPlan is the NON-INTERACTIVE fallback (non-tty / --yes / -// --no-wizard): keep-everything-Shared whole-file adopt PLUS always-on secret +// buildFallbackSeedPlan is the NON-INTERACTIVE fallback (non-tty or +// --wizard=off): keep-everything-Shared whole-file adopt PLUS always-on secret // extraction. Every SecretLine finding is auto-routed to the SecretStore // (Drop is NEVER auto-selected); machine-specific suggestions and repairs are // declined. A stderr NOTICE lists the extracted ref names (never values); diff --git a/cmd/wizard_tui.go b/cmd/wizard_tui.go index 9ea3deb..8bd9c80 100644 --- a/cmd/wizard_tui.go +++ b/cmd/wizard_tui.go @@ -1,7 +1,7 @@ package cmd // The huh-v2 TUI face of the first-run wizard. THIN BY DESIGN: it only -// populates the same wizardChoices struct the --wizard-answers file feeds — +// populates the same wizardChoices struct the --wizard=answers: file feeds — // the seedPlan engine, routing rules, gates, preview, and confirm ordering all // live in wizard.go and run identically on every surface. The answers-file // path never calls into this file, so the data model works without a tty. diff --git a/docs/reference/cli/ferry_init.md b/docs/reference/cli/ferry_init.md index 51328ad..bbbc1f3 100644 --- a/docs/reference/cli/ferry_init.md +++ b/docs/reference/cli/ferry_init.md @@ -21,14 +21,13 @@ ferry init [flags] ### Options ``` - --apply run apply at the end of init (default: show the plan and stop) - --fresh set up a NEW config repo (capture this machine) instead of cloning - --github create a NEW private GitHub repo via the gh CLI and manage it as ferry's remote - -h, --help help for init - --no-wizard skip the interactive first-run wizard (plain adopt with automatic secret extraction) - --repair review opt-in repairs (hardcoded home paths, duplicate PATH exports, dead source lines) in the wizard - --wizard-answers string drive the first-run wizard from a TOML answers file instead of the interactive TUI - --yes don't ask anything: skip the first-run wizard (adopt with automatic secret extraction) and assume yes for init's confirmations + --apply run apply at the end of init (default: show the plan and stop) + --fresh set up a NEW config repo (capture this machine) instead of cloning + --github create a NEW private GitHub repo via the gh CLI and manage it as ferry's remote + -h, --help help for init + --repair review opt-in repairs (hardcoded home paths, duplicate PATH exports, dead source lines) in the wizard + --wizard string first-run wizard mode: off | interactive | answers: (default: interactive on a real tty, else off) + --yes assume yes for init's confirmations (the --github create-confirm and the closing --apply confirm); does NOT skip the wizard — use --wizard=off ``` ### SEE ALSO diff --git a/docs/reference/commands.md b/docs/reference/commands.md index c7c4c7c..54a624d 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -4,12 +4,11 @@ Every command is run as `ferry ` (e.g. `ferry init`). | Command | What it does | |---|---| -| `init` | First-run setup: locate/clone the config repo into ferry's own space (`~/.config/ferry/repo` by default), write ferry's config. On a fresh interactive run (stdin and stdout both ttys) an adoption **wizard** scans your existing `~/.zshrc` and lets you keep it as-is, route it per block (shared / local / drop), or start fresh from a portable starter: nothing is written before the preview confirm, the original is kept in a timestamped `~/.zshrc.ferry-.bak`, and secret-shaped lines are always routed to the out-of-repo secret store or dropped, never seeded. Non-interactively (or with `--yes`/`--no-wizard`) the same adopt happens without prompts: secrets are extracted to the store automatically (refs listed on stderr) and everything else is kept shared verbatim. The plugin set is currently zsh (`~/.zshrc`); more domains come with later releases. init handles setup only; the single guided reconcile walkthrough lives in `apply`, which init points you to (or runs directly with `--apply`). | -| `init --yes` | Don't ask anything: skip the wizard (plain adopt with automatic secret extraction) and assume yes for the confirmations init would otherwise ask (the `--github` create-confirm; the closing apply confirm with `--apply`). | -| `init --no-wizard` | Skip the interactive wizard only (same non-interactive adopt-and-extract fallback), without `--yes`'s other confirmation assents. | -| `init --repair` | Opt into the wizard's repair review: hardcoded `/Users/` paths to `$HOME`, duplicate `PATH` exports, dead `source` lines: each fix is accepted or declined individually. Needs the interactive wizard, so it conflicts with `--yes`/`--no-wizard` and with a non-tty run — unless `--wizard-answers` is given, which satisfies the consent requirement and composes with them. | -| `init --wizard-answers ` | Drive every wizard decision from a TOML answers file (mode, per-block routes, secret routes, repairs, starter answers) instead of the TUI: same gates, preview, backup, and confirm, no tty needed. | -| `init --github [name]` | Create a **new private** GitHub repo via the `gh` CLI's existing auth and manage it as ferry's HTTPS remote. Needs `gh` authenticated; ferry stores no token. Always private, never reuses an existing repo, and won't push a file that looks like a secret: the wizard (or the non-interactive fallback) extracts detected secrets to the local store first, so only placeholders are committed and pushed. Add `--yes` for non-interactive use. | +| `init` | First-run setup: locate/clone the config repo into ferry's own space (`~/.config/ferry/repo` by default), write ferry's config. On a fresh interactive run (stdin and stdout both ttys) an adoption **wizard** scans your existing `~/.zshrc` and lets you keep it as-is, route it per block (shared / local / drop), or start fresh from a portable starter: nothing is written before the preview confirm, the original is kept in a timestamped `~/.zshrc.ferry-.bak`, and secret-shaped lines are always routed to the out-of-repo secret store or dropped, never seeded. Non-interactively (a non-tty run, or `--wizard=off`) the same adopt happens without prompts: secrets are extracted to the store automatically (refs listed on stderr) and everything else is kept shared verbatim. The plugin set is currently zsh (`~/.zshrc`); more domains come with later releases. init handles setup only; the single guided reconcile walkthrough lives in `apply`, which init points you to (or runs directly with `--apply`). | +| `init --yes` | Assume yes for init's confirmations — the `--github` create-confirm and the closing apply confirm with `--apply`. It does **not** skip the wizard (use `--wizard=off` for that). | +| `init --wizard=off\|interactive\|answers:` | Choose the first-run wizard mode. `off` skips it (the non-interactive adopt-and-extract fallback); `interactive` forces the TUI (needs a tty pair); `answers:` drives every wizard decision from a TOML answers file (same gates, preview, backup, and confirm, no tty needed). Default (unset): interactive on a real tty pair, else `off`. | +| `init --repair` | Opt into the wizard's repair review: hardcoded `/Users/` paths to `$HOME`, duplicate `PATH` exports, dead `source` lines: each fix is accepted or declined individually. Needs a running wizard, so it conflicts with `--wizard=off` and (in interactive mode) a non-tty run — unless `--wizard=answers:` drives it, which satisfies the consent requirement. | +| `init --github [name]` | Create a **new private** GitHub repo via the `gh` CLI's existing auth and manage it as ferry's HTTPS remote. Needs `gh` authenticated; ferry stores no token. Always private, never reuses an existing repo, and won't push a file that looks like a secret: the wizard (or the non-interactive fallback) extracts detected secrets to the local store first, so only placeholders are committed and pushed. Add `--yes` to assent to the create-confirm (needed non-interactively). | | `apply` | Reconcile this machine to the repo (deploy dotfiles, terminal settings). On a run that has changes it walks the pending work **grouped by domain** (dotfiles / agents / terminals), staying **quiet when safe** and **stopping when risky**: a safe change (creating a file where none exists, or updating a target whose live content still matches what ferry last deployed) applies automatically, while a *risky* change — overwriting a file that differs from the last-deployed baseline, adopting a pre-existing file, or deploying a value from the secret store — halts for confirmation. In the walkthrough you confirm a domain wholesale, drill into it to see each change's full diff, apply or skip a change this run, or skip it *always* (remembered per machine in the gitignored `.local` layer). A clean, in-sync apply prints one line. Non-interactively — or with `--skip-wizard` — nothing risky is applied unattended: risky changes **fail closed** (listed, refused, non-zero exit) while the safe subset still applies. Idempotent; safe to re-run. Dependencies install behind `apply --deps`. | | `apply --skip-wizard` | Skip the guided walkthrough (for experts and scripts): safe changes still auto-apply, but risky changes are refused rather than prompted — they never happen unattended. | | `apply --force` | Treat every risky change as confirmed (an explicit override) and overwrite uncaptured local edits on a conflict; the downstream data-loss guards still apply and warn. | diff --git a/docs/tutorials/getting-started.md b/docs/tutorials/getting-started.md index 764ed53..3fa74b4 100644 --- a/docs/tutorials/getting-started.md +++ b/docs/tutorials/getting-started.md @@ -97,13 +97,13 @@ Opt-in repairs (`ferry init --repair`) additionally offer lint-style fixes — h `/Users/` paths to `$HOME`, duplicate `PATH` exports, dead `source` lines — each accepted or declined individually. -Non-interactively (piped stdin/stdout, `--yes`, or `--no-wizard`) there is no TUI and +Non-interactively (piped stdin/stdout, or `--wizard=off`) there is no TUI and no prompt: ferry adopts the whole file shared, automatically extracts every detected secret to the local store (the extracted ref names are listed on stderr; nothing is ever dropped without you), and seeds placeholders in their place. A secret-free `~/.zshrc` is adopted byte-identically, so the first `ferry apply` matches what is already on disk and changes nothing. Your existing shell config is never zeroed. -Scripting the wizard itself is possible with `ferry init --wizard-answers ` +Scripting the wizard itself is possible with `ferry init --wizard=answers:` (a TOML file carrying every decision). If you have no `~/.zshrc` (and skip the starter), ferry seeds no shell source at all — diff --git a/evals/wizard_capture_test.go b/evals/wizard_capture_test.go index 6855cff..7719abd 100644 --- a/evals/wizard_capture_test.go +++ b/evals/wizard_capture_test.go @@ -151,7 +151,7 @@ func wcSetup(t *testing.T, zshrc, answers string) *Sandbox { s := NewSandbox(t) s.WriteHomeFile(t, ".zshrc", zshrc, 0o644) af := wizWriteAnswers(t, answers) - if _, errOut, code := s.Ferry("init", "--wizard-answers", af); code != 0 { + if _, errOut, code := s.Ferry("init", "--wizard=answers:"+af); code != 0 { t.Fatalf("wcSetup: wizard init exited %d\n%s", code, errOut) } s.Repo = managedRepoPath(s) @@ -334,7 +334,7 @@ func TestWizardCapture_AC_capture_roundtrip_sidecar(t *testing.T) { s := NewSandbox(t) s.WriteHomeFile(t, ".zshrc", wcSidecarZshrc, 0o644) af := wizWriteAnswers(t, wcSidecarAnswers) - if _, errOut, code := s.Ferry("init", "--wizard-answers", af); code != 0 { + if _, errOut, code := s.Ferry("init", "--wizard=answers:"+af); code != 0 { t.Fatalf("sidecar setup: init exited %d\n%s", code, errOut) } s.Repo = managedRepoPath(s) diff --git a/evals/wizard_test.go b/evals/wizard_test.go index 713896d..7463c84 100644 --- a/evals/wizard_test.go +++ b/evals/wizard_test.go @@ -3,7 +3,7 @@ package evals // v0.3.0 wizard evals — eval-first, RED until the implementing wave lands. // // These tests drive `ferry init`'s wizard DATA-MODEL path via the documented -// `ferry init --wizard-answers ` flag (PLAN "Answers file — the concrete +// `ferry init --wizard=answers:` mode (PLAN "Answers file — the concrete // data-model driver"): a TOML answers file bypasses ONLY the TUI; every invariant // (pure-until-confirm, gates, backup, scaffold, masking) runs identically and no // tty is required. The non-interactive fallback (no answers file, non-tty) is @@ -24,17 +24,17 @@ package evals // AC-combined-secret-repair TestWizard_AC_secret_store_route_and_combined_secret_repair // AC-repair-machine-local TestWizard_AC_repair_machine_local // AC-repair-dedupe TestWizard_AC_repair_dedupe (BYTE-EXACT survivor bytes; emptied-not-deleted) -// AC-repair-noninteractive-refused TestWizard_AC_repair_noninteractive_refused (--yes, --no-wizard, AND plain non-tty arms) +// AC-repair-noninteractive-refused TestWizard_AC_repair_noninteractive_refused (--wizard=off conflict, --yes non-tty, AND plain non-tty arms) // AC-wizard-pure-until-confirm TestWizard_AC_wizard_pure_until_confirm // AC-wizard-backup TestWizard_AC_wizard_backup (original-bytes/0600/regular arm; symlink + `-2` collision arms UNIT-PHASE, see below) // AC-preview-masking TestWizard_AC_preview_masking (store arm: placeholder shown; drop arm: DROP-LINE MASKING pin — // the removed line is shown masked by its placeholder, value on no stream, store empty) -// AC-noninteractive-fallback TestInitFallback_AC_noninteractive_fallback (non-tty + --yes + --no-wizard triggers over the +// AC-noninteractive-fallback TestInitFallback_AC_noninteractive_fallback (non-tty + --yes + --wizard=off triggers over the // ENRICHED fixture: TWO secrets incl. a PEM span, machine line stays SHARED, /Users/testuser NOT // auto-repaired; first-apply byte-identity; refs stderr-only; recursive HOME-delta confinement) // AC-github-seedplan TestWizard_AC_github_seedplan (fallback_auto_extract + wizard_routed arms; BYTE-EXACT SeedPlan // identity asserted on the LOCAL seed AND on the PUSHED tree of a real local bare remote; -// FLAG PRECEDENCE pin: `--github --wizard-answers --yes` runs the wizard-routed path +// FLAG PRECEDENCE pin: `--github --wizard=answers: --yes` runs the wizard-routed path // fully non-interactively) and // TestGitHubSecretExtracted_AC_github_secret_extracted (evals/github_test.go — the // v0.3.0 recut of the retired v0.2.x AC-github-secret-blocked abort contract) @@ -73,9 +73,9 @@ package evals // high-entropy-token rule for these fixtures to exercise ferry's REAL gate. // // NEWLY PINNED PLAN CONTRACTS exercised here (plan "Pinned contracts"): -// FLAG PRECEDENCE — --wizard-answers outranks the wizard-skip meaning of --yes; -// --yes retains its create-confirm/apply-confirm assent meaning, so -// `--github --wizard-answers f --yes` succeeds non-interactively while +// FLAG PRECEDENCE — --wizard=answers: drives the wizard data model; --yes is +// confirmation-assent only (create-confirm/apply-confirm), so +// `--github --wizard=answers:f --yes` succeeds non-interactively while // `--github` without --yes still refuses. NOTE: the closing apply confirmation // exists only WITH --apply (cmd/init.go:25) — plain `init --yes` is INIT-ONLY // and must not mutate user dotfiles. DROP-LINE MASKING — a dropped secret @@ -234,7 +234,7 @@ func wizRequireGit(t *testing.T) { } } -// wizWriteAnswers writes a --wizard-answers TOML file OUTSIDE the sandbox HOME +// wizWriteAnswers writes a wizard answers (--wizard=answers:) TOML file OUTSIDE the sandbox HOME // (so HOME-content assertions stay clean) and returns its path. func wizWriteAnswers(t *testing.T, content string) string { t.Helper() @@ -491,8 +491,8 @@ confirm = true "1" = "local" "2" = "drop" `) - if _, errOut, code := s.Ferry("init", "--wizard-answers", answers); code != 0 { - t.Fatalf("AC-wizard-adopt-existing: init --wizard-answers exited %d\n%s", code, errOut) + if _, errOut, code := s.Ferry("init", "--wizard=answers:"+answers); code != 0 { + t.Fatalf("AC-wizard-adopt-existing: init --wizard=answers exited %d\n%s", code, errOut) } repoDir := managedRepoPath(s) @@ -587,7 +587,7 @@ confirm = true "1" = "local" "2" = "local" `) - out, errOut, code := s.Ferry("init", "--wizard-answers", answers) + out, errOut, code := s.Ferry("init", "--wizard=answers:"+answers) if code != 0 { t.Fatalf("AC-wizard-scaffold[local]: init exited %d\n%s", code, errOut) } @@ -643,7 +643,7 @@ confirm = true "1" = "drop" "2" = "drop" `) - out, errOut, code := s.Ferry("init", "--wizard-answers", answers) + out, errOut, code := s.Ferry("init", "--wizard=answers:"+answers) if code != 0 { t.Fatalf("AC-wizard-scaffold[drop]: init exited %d\n%s", code, errOut) } @@ -709,7 +709,7 @@ confirm = true [secret-routes] "2" = "store" `) - if _, errOut, code := s.Ferry("init", "--wizard-answers", answers); code != 0 { + if _, errOut, code := s.Ferry("init", "--wizard=answers:"+answers); code != 0 { t.Fatalf("AC-wizard-keep-as-is-secrets: init exited %d\n%s", code, errOut) } repoDir := managedRepoPath(s) @@ -744,7 +744,7 @@ confirm = true answers := wizWriteAnswers(t, `mode = "keep-as-is" confirm = true `) - if _, errOut, code := s.Ferry("init", "--wizard-answers", answers); code != 0 { + if _, errOut, code := s.Ferry("init", "--wizard=answers:"+answers); code != 0 { t.Fatalf("AC-wizard-keep-as-is-secrets[secret-free]: init exited %d\n%s", code, errOut) } seed := wizReadSharedSeed(t, managedRepoPath(s)) @@ -784,7 +784,7 @@ confirm = true [secret-routes] "2" = "drop" `) - if _, errOut, code := s.Ferry("init", "--wizard-answers", answers); code != 0 { + if _, errOut, code := s.Ferry("init", "--wizard=answers:"+answers); code != 0 { t.Fatalf("AC-secret-store-route[drop]: init exited %d\n%s", code, errOut) } repoDir := managedRepoPath(s) @@ -836,7 +836,7 @@ confirm = true "1" = "shared" "2" = "`+route+`" `) - out, errOut, code := s.Ferry("init", "--wizard-answers", answers) + out, errOut, code := s.Ferry("init", "--wizard=answers:"+answers) combined := out + errOut if code == 0 { t.Errorf("AC-secret-store-route[%s-refused]: routing a SECRET block %q was ACCEPTED (exit 0) — SecretLine routes are only {store, drop}\n%s", route, route, combined) @@ -863,7 +863,7 @@ confirm = true answers := wizWriteAnswers(t, `mode = "keep-as-is" confirm = true `) - out, errOut, code := s.Ferry("init", "--wizard-answers", answers) + out, errOut, code := s.Ferry("init", "--wizard=answers:"+answers) combined := out + errOut if code == 0 { t.Errorf("AC-secret-store-route[missing-routes]: a detected secret with NO [secret-routes] entry was accepted (exit 0) — the forced choice must be an error, never a silent default\n%s", combined) @@ -956,7 +956,7 @@ func TestWizard_AC_wizard_symlink_decline(t *testing.T) { args := []string{"init"} if withAnswers { - args = append(args, "--wizard-answers", wizWriteAnswers(t, `mode = "keep-as-is" + args = append(args, "--wizard=answers:"+wizWriteAnswers(t, `mode = "keep-as-is" confirm = true `)) } @@ -998,7 +998,7 @@ confirm = true [starter] prompt = "minimal" `) - _, _, _ = s.Ferry("init", "--wizard-answers", answers) // exit code unpinned + _, _, _ = s.Ferry("init", "--wizard=answers:"+answers) // exit code unpinned if p := wizSharedSeedPath(managedRepoPath(s)); p != "" { t.Errorf("AC-wizard-symlink-decline[%s/start-fresh]: a STARTER was seeded at %s over an unmanageable rc (r5-M1: from-scratch must not be offered)", shape, p) } @@ -1029,7 +1029,7 @@ confirm = true [starter] prompt = "minimal" `) - if _, errOut, code := s.Ferry("init", "--wizard-answers", answers); code != 0 { + if _, errOut, code := s.Ferry("init", "--wizard=answers:"+answers); code != 0 { t.Fatalf("AC-wizard-from-scratch: init exited %d\n%s", code, errOut) } seed := wizReadSharedSeed(t, managedRepoPath(s)) @@ -1086,7 +1086,7 @@ confirm = true [starter] prompt = "minimal" `) - out, errOut, code := s.Ferry("init", "--wizard-answers", answers) + out, errOut, code := s.Ferry("init", "--wizard=answers:"+answers) if code != 0 { t.Fatalf("AC-wizard-fresh-over-existing: init exited %d\n%s", code, errOut) } @@ -1159,7 +1159,7 @@ prompt = "minimal" // line → $HOME offered; accept ⇒ replaced; decline ⇒ byte-identical." // // INTERPRETATION (Codex-validation point): `--repair` composes with -// `--wizard-answers` (the answers file replaces only the TUI, so the r2-m4 tty +// `--wizard=answers:` (the answers file replaces only the TUI, so the r2-m4 tty // requirement is satisfied by the data-model path); [repairs] indexes the // REPAIRABLE findings presented at step 4, 0-based in Analyze order. func TestWizard_AC_repair_home_path(t *testing.T) { @@ -1176,7 +1176,7 @@ confirm = true [repairs] "0" = "`+decision+`" `) - if _, errOut, code := s.Ferry("init", "--repair", "--wizard-answers", answers); code != 0 { + if _, errOut, code := s.Ferry("init", "--repair", "--wizard=answers:"+answers); code != 0 { t.Fatalf("AC-repair-home-path[%s]: init --repair exited %d\n%s", decision, code, errOut) } return wizReadSharedSeed(t, managedRepoPath(s)) @@ -1227,7 +1227,7 @@ confirm = true [repairs] "0" = "accept" `) - if _, errOut, code := s.Ferry("init", "--repair", "--wizard-answers", answers); code != 0 { + if _, errOut, code := s.Ferry("init", "--repair", "--wizard=answers:"+answers); code != 0 { t.Fatalf("AC-combined-secret-repair: init --repair exited %d\n%s", code, errOut) } repoDir := managedRepoPath(s) @@ -1293,7 +1293,7 @@ confirm = true "1" = "shared" "2" = "local" `) - if _, errOut, code := s.Ferry("init", "--wizard-answers", answers); code != 0 { + if _, errOut, code := s.Ferry("init", "--wizard=answers:"+answers); code != 0 { t.Fatalf("AC-repair-machine-local: init exited %d\n%s", code, errOut) } repoDir := managedRepoPath(s) @@ -1345,7 +1345,7 @@ confirm = true "0" = "accept" "1" = "accept" `) - if _, errOut, code := s.Ferry("init", "--repair", "--wizard-answers", answers); code != 0 { + if _, errOut, code := s.Ferry("init", "--repair", "--wizard=answers:"+answers); code != 0 { t.Fatalf("AC-repair-dedupe: init --repair exited %d\n%s", code, errOut) } seed := wizReadSharedSeed(t, managedRepoPath(s)) @@ -1368,15 +1368,19 @@ confirm = true // AC-repair-noninteractive-refused // ----------------------------------------------------------------------------- -// TestWizard_AC_repair_noninteractive_refused covers AC-repair-noninteractive-refused: -// "`--repair` without a full tty pair, or combined with `--yes`/`--no-wizard`, -// exits non-zero with a clear message naming the conflict; nothing seeded." -// Three arms: --yes, --no-wizard, and PLAIN `init --repair` under the harness's -// non-tty stdin/stdout (no answers file, so no data-model path either). +// TestWizard_AC_repair_noninteractive_refused covers AC-repair-noninteractive-refused +// under the v0.8.0 flag model: `--repair` needs the wizard to run, so it CONFLICTS +// with `--wizard=off` (which skips the wizard) and, in the default/interactive mode +// without an answers file, needs a full tty pair. It exits non-zero with a clear +// message naming the conflict, and nothing is seeded. `--yes` no longer conflicts +// (it is confirmation-assent only), so `--repair --yes` on the non-tty harness now +// falls to the plain tty requirement, NOT a --yes conflict. func TestWizard_AC_repair_noninteractive_refused(t *testing.T) { t.Parallel() - run := func(t *testing.T, conflictFlag string) { + // wantConflict = the wizard-off conflict (names --wizard=off); otherwise the + // refusal is the plain interactive/tty requirement. + run := func(t *testing.T, extraFlag string, wantConflict bool) { t.Helper() wizRequireGit(t) s := NewSandbox(t) @@ -1384,21 +1388,22 @@ func TestWizard_AC_repair_noninteractive_refused(t *testing.T) { liveSnap := s.SnapshotFile(t, s.HomePath(".zshrc")) args := []string{"init", "--repair"} - if conflictFlag != "" { - args = append(args, conflictFlag) + if extraFlag != "" { + args = append(args, extraFlag) } out, errOut, code := s.Ferry(args...) combined := out + errOut if code == 0 { t.Errorf("AC-repair-noninteractive-refused: `ferry %s` exited 0 (must refuse)\n%s", strings.Join(args, " "), combined) } - if conflictFlag != "" { - // The error names the conflicting flag. - if !strings.Contains(combined, conflictFlag) { - t.Errorf("AC-repair-noninteractive-refused: error does not name the conflicting flag %s\n%s", conflictFlag, combined) + if wantConflict { + // The error names the conflicting --wizard=off mode. + if !strings.Contains(combined, "--wizard=off") { + t.Errorf("AC-repair-noninteractive-refused: error does not name the conflicting --wizard=off\n%s", combined) } } else { - // Plain non-tty --repair: the refusal must explain the tty requirement. + // Plain non-tty --repair (or --repair --yes, which no longer conflicts): + // the refusal must explain the tty requirement. if !containsAnyFold(combined, "tty", "terminal", "interactive") { t.Errorf("AC-repair-noninteractive-refused: non-tty refusal does not explain the interactive/tty requirement\n%s", combined) } @@ -1411,9 +1416,9 @@ func TestWizard_AC_repair_noninteractive_refused(t *testing.T) { liveSnap.AssertUnchanged(t) } - t.Run("with_yes", func(t *testing.T) { t.Parallel(); run(t, "--yes") }) - t.Run("with_no_wizard", func(t *testing.T) { t.Parallel(); run(t, "--no-wizard") }) - t.Run("plain_non_tty", func(t *testing.T) { t.Parallel(); run(t, "") }) + t.Run("with_wizard_off", func(t *testing.T) { t.Parallel(); run(t, "--wizard=off", true) }) + t.Run("with_yes_non_tty", func(t *testing.T) { t.Parallel(); run(t, "--yes", false) }) + t.Run("plain_non_tty", func(t *testing.T) { t.Parallel(); run(t, "", false) }) } // ----------------------------------------------------------------------------- @@ -1438,7 +1443,7 @@ confirm = false [secret-routes] "2" = "store" `) - out, errOut, code := s.Ferry("init", "--wizard-answers", answers) + out, errOut, code := s.Ferry("init", "--wizard=answers:"+answers) if code != 0 { t.Errorf("AC-wizard-pure-until-confirm: declined wizard exited %d (want clean exit 0)\n%s", code, errOut) } @@ -1477,7 +1482,7 @@ func TestWizard_AC_wizard_backup(t *testing.T) { answers := wizWriteAnswers(t, `mode = "keep-as-is" confirm = true `) - if _, errOut, code := s.Ferry("init", "--wizard-answers", answers); code != 0 { + if _, errOut, code := s.Ferry("init", "--wizard=answers:"+answers); code != 0 { t.Fatalf("AC-wizard-backup: init exited %d\n%s", code, errOut) } baks := wizBaks(t, s) @@ -1543,7 +1548,7 @@ confirm = true [secret-routes] "2" = "`+route+`" `) - out, errOut, code := s.Ferry("init", "--wizard-answers", answers) + out, errOut, code := s.Ferry("init", "--wizard=answers:"+answers) if code != 0 { t.Fatalf("AC-preview-masking[%s]: init exited %d\n%s", route, code, errOut) } @@ -1601,14 +1606,14 @@ confirm = true // ----------------------------------------------------------------------------- // TestInitFallback_AC_noninteractive_fallback covers AC-noninteractive-fallback: -// "non-tty / --yes / --no-wizard: no TUI, no prompt, no block on stdin. +// "non-tty / --yes / --wizard=off: no TUI, no prompt, no block on stdin. // Secret-bearing zshrc: keep-everything-shared seed with EVERY secret span // extracted to the store (placeholders in the seed, nothing dropped, refs listed // on stderr, stdout unchanged); first apply leaves the live file byte-identical // (rendered == adopted bytes). Secret-free zshrc: seed BYTE-IDENTICAL to v0.2.1 // initFresh." The harness is inherently non-tty (piped stdin/stdout) and the // 30s harness timeout makes a hang a hard failure. All three fallback TRIGGERS -// (plain non-tty, --yes, --no-wizard) must behave identically. +// (plain non-tty, --yes, --wizard=off) must behave identically. func TestInitFallback_AC_noninteractive_fallback(t *testing.T) { t.Parallel() @@ -1728,7 +1733,7 @@ func TestInitFallback_AC_noninteractive_fallback(t *testing.T) { t.Run("secret_bearing_non_tty", func(t *testing.T) { t.Parallel(); runSecretBearing(t) }) t.Run("secret_bearing_yes_flag", func(t *testing.T) { t.Parallel(); runSecretBearing(t, "--yes") }) - t.Run("secret_bearing_no_wizard_flag", func(t *testing.T) { t.Parallel(); runSecretBearing(t, "--no-wizard") }) + t.Run("secret_bearing_wizard_off_flag", func(t *testing.T) { t.Parallel(); runSecretBearing(t, "--wizard=off") }) t.Run("secret_free_byte_identical", func(t *testing.T) { t.Parallel() @@ -1839,15 +1844,15 @@ confirm = true [secret-routes] "2" = "store" `) - // FLAG PRECEDENCE (pinned): --wizard-answers outranks --yes's wizard-skip - // meaning — the answers file drives the WIZARD-ROUTED path — while --yes + // FLAG PRECEDENCE (pinned): --wizard=answers: drives the WIZARD-ROUTED + // path (independent of --yes), while --yes // keeps its create-confirm/apply-confirm assent, so the whole run is // non-interactive (EMPTY stdin; a `--github` run without --yes still // refuses non-interactively, pinned by the existing github evals). out, errOut, code := s.FerryEnv(append(sc.env(), combinedPathEnv(gh, git)), - "init", "--github", "wizrepo", "--wizard-answers", answers, "--yes") + "init", "--github", "wizrepo", "--wizard=answers:"+answers, "--yes") if code != 0 { - t.Fatalf("AC-github-seedplan[wizard-routed]: `init --github --wizard-answers --yes` exited %d\n%s", code, out+errOut) + t.Fatalf("AC-github-seedplan[wizard-routed]: `init --github --wizard=answers: --yes` exited %d\n%s", code, out+errOut) } assertPlaceholdersOnlyPush(t, s, git, bare, out+errOut) }) @@ -1877,7 +1882,7 @@ confirm = true "1" = "shared" "2" = "local" `) - if _, errOut, code := s.Ferry("init", "--wizard-answers", answers); code != 0 { + if _, errOut, code := s.Ferry("init", "--wizard=answers:"+answers); code != 0 { t.Fatalf("AC-init-rerun-guard: init exited %d\n%s", code, errOut) } // Adopting the pre-existing ~/.zshrc is risky; confirm the walkthrough. @@ -1947,3 +1952,85 @@ func TestReadOnly_NoStateDirCreate(t *testing.T) { t.Errorf("read-only tripwire: `ferry diff` CREATED %s (read-only commands must not write state)", s.StateDir()) } } + +// ----------------------------------------------------------------------------- +// v0.8.0 --wizard flag surface (removed spellings + mode validation) +// ----------------------------------------------------------------------------- + +// TestInitWizardFlagSurface pins the v0.8.0 init flag reshape: the removed +// spellings `--no-wizard` and `--wizard-answers` are now UNKNOWN flags, an +// unrecognised `--wizard` mode errors clearly, and the valid modes +// (off / interactive / answers:) are accepted. The non-tty harness means +// the default (and interactive) mode falls back to the non-interactive adopt. +func TestInitWizardFlagSurface(t *testing.T) { + t.Parallel() + + // Removed spellings are unknown flags now — no alias, no did-you-mean. + for _, removed := range [][]string{ + {"init", "--no-wizard"}, + {"init", "--wizard-answers", "x"}, + } { + removed := removed + t.Run("removed_"+strings.Join(removed[1:], "_"), func(t *testing.T) { + t.Parallel() + s := NewSandbox(t) + _, errOut, code := s.Ferry(removed...) + if code == 0 { + t.Errorf("`ferry %s` exited 0 — the removed flag must be unknown\n%s", strings.Join(removed, " "), errOut) + } + if !containsAnyFold(errOut, "unknown flag") { + t.Errorf("`ferry %s` did not report an unknown flag\n%s", strings.Join(removed, " "), errOut) + } + }) + } + + // An unrecognised --wizard mode errors, naming the valid forms. + t.Run("bogus_mode", func(t *testing.T) { + t.Parallel() + wizRequireGit(t) + s := NewSandbox(t) + s.WriteHomeFile(t, ".zshrc", wizHomePathZshrc, 0o644) + out, errOut, code := s.Ferry("init", "--wizard=bogus") + combined := out + errOut + if code == 0 { + t.Errorf("`ferry init --wizard=bogus` exited 0 — an unknown mode must error\n%s", combined) + } + if !containsAnyFold(combined, "not a valid mode", "off, interactive, or answers") { + t.Errorf("`ferry init --wizard=bogus` did not explain the valid modes\n%s", combined) + } + // Bogus mode is rejected BEFORE any work: nothing seeded. + wizAssertNothingSeeded(t, s) + }) + + // --wizard=off is accepted (the non-interactive fallback), producing a + // managed repo on the non-tty harness. + t.Run("wizard_off_accepted", func(t *testing.T) { + t.Parallel() + wizRequireGit(t) + s := NewSandbox(t) + s.WriteHomeFile(t, ".zshrc", wizPlainZshrc, 0o644) + if _, errOut, code := s.Ferry("init", "--wizard=off"); code != 0 { + t.Fatalf("`ferry init --wizard=off` exited %d\n%s", code, errOut) + } + if _, err := os.Stat(managedRepoPath(s)); err != nil { + t.Errorf("`ferry init --wizard=off` did not create the managed repo: %v", err) + } + }) + + // --wizard=answers: drives the data model (same as the former + // --wizard-answers ), with an empty path rejected. + t.Run("wizard_answers_empty_path", func(t *testing.T) { + t.Parallel() + wizRequireGit(t) + s := NewSandbox(t) + s.WriteHomeFile(t, ".zshrc", wizPlainZshrc, 0o644) + out, errOut, code := s.Ferry("init", "--wizard=answers:") + combined := out + errOut + if code == 0 { + t.Errorf("`ferry init --wizard=answers:` exited 0 — an empty answers path must error\n%s", combined) + } + if !containsAnyFold(combined, "needs a file path") { + t.Errorf("`ferry init --wizard=answers:` did not explain the missing path\n%s", combined) + } + }) +}