diff --git a/CLAUDE.md b/CLAUDE.md index bfc7e30..b26382e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -145,3 +145,35 @@ detect-secrets audit .secrets.baseline 2. Create `.go` with `NewXxxCommand()` returning `*cli.Command` with subcommands 3. Import and register in `cmd/root/root.go` `Commands` slice 4. Add to the home screen manual list in `root.go` Action, alphabetically + +## Keeping the Website Docs in Sync + +Any change to a command, flag, or its behavior must be mirrored in the +website docs repo at `../website-04`. The CLI and the published docs are +two separate repos — editing one does **not** update the other. + +Source-of-truth markdown (edit these by hand): + +| File | Covers | +|------|--------| +| `content/docs/Sandbox/CLI/Commands.md` | Per-command flag tables + examples (sandbox group) | +| `content/docs/CLI/Command-Reference.md` | One-line summary row per command | + +After editing the markdown, regenerate the derived files (they carry an +"Auto-generated — do not edit manually" header): + +```bash +cd ../website-04 +node scripts/generate-docs-content.mjs # → lib/docs/docs-content.ts +node scripts/generate-search-index.mjs # → lib/docs/search-index-generated.ts +``` + +Don't hand-edit the `lib/docs/*-generated.ts` files and don't run the +full `prebuild` (it also fetches remote content). Commit the markdown + +both regenerated TS files together. + +Checklist when adding/changing/removing a command or flag: +- [ ] CLI code under `cmd/` +- [ ] This repo's `README.md` +- [ ] `../website-04` `Commands.md` (and `Command-Reference.md` for new commands) +- [ ] Regenerate the two `lib/docs/*.ts` files diff --git a/README.md b/README.md index 429036b..57afc48 100644 --- a/README.md +++ b/README.md @@ -507,6 +507,10 @@ createos sandbox rootfs # Sandbox sync createos sandbox sync my-box --local ~/work/project --remote /root/work +createos sandbox sync my-box --exclude '*.log' --exclude node_modules # skip files (repeatable) +createos sandbox sync my-box --mode one-way # push-only: laptop wins, keep extra remote files +createos sandbox sync my-box --mode mirror # make sandbox identical, delete extra remote files +createos sandbox sync my-box --quiet # run silently until Ctrl+C # Sandbox disks createos sandbox disk create my-data --bucket my-bucket --endpoint https://s3.amazonaws.com \ diff --git a/cmd/sandbox/sync.go b/cmd/sandbox/sync.go index dc6b352..663c2f2 100644 --- a/cmd/sandbox/sync.go +++ b/cmd/sandbox/sync.go @@ -5,6 +5,7 @@ import ( "encoding/pem" "errors" "fmt" + "io" "net" "os" "os/exec" @@ -44,6 +45,18 @@ Examples: createos sandbox sync my-box --local ~/work/project --remote /root/work createos sandbox sync my-box -i ~/.ssh/id_ed25519 + # Skip files you don't want synced (repeatable) + createos sandbox sync my-box --exclude '*.log' --exclude node_modules + + # Push-only: laptop wins, never pull changes back + createos sandbox sync my-box --mode one-way + + # Mirror: make the sandbox identical, deleting extra files there + createos sandbox sync my-box --mode mirror + + # Run silently in the background of your terminal + createos sandbox sync my-box --quiet + Safety: refuses to sync from $HOME directly, /, or known sensitive paths (.ssh, .aws, etc.). Refuses to sync TO system dirs inside the sandbox (/etc, /usr, /bin …). Pass --force to bypass the local check @@ -82,6 +95,24 @@ sandbox (/etc, /usr, /bin …). Pass --force to bypass the local check Aliases: []string{"y"}, Usage: "Install your SSH key into the sandbox without asking (required in non-interactive mode when your key isn't already there)", }, + &cli.StringSliceFlag{ + Name: "exclude", + Usage: "Glob pattern to skip; repeatable (e.g. --exclude '*.log' --exclude node_modules)", + }, + &cli.StringFlag{ + Name: "mode", + Value: "two-way", + Usage: "Sync direction: two-way | one-way (laptop wins, keeps extra files on the sandbox) | mirror (one-way and deletes extra files on the sandbox)", + }, + &cli.BoolFlag{ + Name: "quiet", + Aliases: []string{"q"}, + Usage: "Don't print status; run silently until Ctrl+C", + }, + &cli.BoolFlag{ + Name: "no-ignore-vcs", + Usage: "Sync VCS directories too (.git, .hg …); by default they're skipped", + }, }, Action: runSync, } @@ -162,6 +193,13 @@ func runSync(c *cli.Context) error { return err } + // Resolve --mode up front so a typo fails before we touch the + // sandbox or download Mutagen. + syncMode, err := syncModeToMutagen(c.String("mode")) + if err != nil { + return err + } + mutagenBin, err := ensureMutagen() if err != nil { return err @@ -258,14 +296,25 @@ fi // under our env, picking up the wrapper PATH. _ = runMutagen(ctx, mutagenBin, wrapperEnv, "daemon", "stop") //nolint:errcheck - pterm.Println(pterm.Gray(fmt.Sprintf(" syncing %s ⇄ %s:%s", local, refLabel(ref, id), remote))) + quiet := c.Bool("quiet") + if !quiet { + pterm.Println(pterm.Gray(fmt.Sprintf(" syncing %s ⇄ %s:%s", local, refLabel(ref, id), remote))) + } createArgs := []string{ "sync", "create", "--name=" + sessionName, - "--ignore-vcs", - local, - remoteSpec, + "--sync-mode=" + syncMode, } + if !c.Bool("no-ignore-vcs") { + createArgs = append(createArgs, "--ignore-vcs") + } + for _, pat := range c.StringSlice("exclude") { + if p := strings.TrimSpace(pat); p != "" { + createArgs = append(createArgs, "--ignore="+p) + } + } + // Source and target must come last as positional args. + createArgs = append(createArgs, local, remoteSpec) if err := runMutagen(ctx, mutagenBin, wrapperEnv, createArgs...); err != nil { return fmt.Errorf("mutagen sync create failed: %w", err) } @@ -276,22 +325,52 @@ fi _ = runMutagen(bg, mutagenBin, wrapperEnv, "sync", "terminate", sessionName) //nolint:errcheck }() - pterm.Success.Println("Sync running. Press Ctrl+C to stop.") + if !quiet { + pterm.Success.Println("Sync running. Press Ctrl+C to stop.") + } // 7. Monitor the session in the foreground. `mutagen sync monitor` // streams status lines until the session is terminated or the - // process exits. + // process exits — it blocks, which keeps the sync alive. When + // --quiet is set we still run it (for the blocking lifecycle) but + // drop its status output; errors stay on stderr. mon := exec.CommandContext(ctx, mutagenBin, "sync", "monitor", sessionName) // #nosec G204 -- mutagenBin is our managed binary; sessionName is internally generated mon.Env = wrapperEnv - mon.Stdout = os.Stdout + if quiet { + mon.Stdout = io.Discard + } else { + mon.Stdout = os.Stdout + } mon.Stderr = os.Stderr if err := mon.Run(); err != nil && ctx.Err() == nil { return fmt.Errorf("mutagen monitor exited: %w", err) } - pterm.Println("Sync stopped.") + if !quiet { + pterm.Println("Sync stopped.") + } return nil } +// syncModeToMutagen maps our friendly --mode values onto Mutagen's +// --sync-mode. We surface three of Mutagen's modes under plain names so +// users don't have to learn Mutagen's vocabulary: +// +// two-way → two-way-safe (default; conflicting edits pause, never clobber) +// one-way → one-way-safe (laptop is the source; extra files on the sandbox are kept) +// mirror → one-way-replica (laptop is the source; the sandbox is made identical, extras deleted) +func syncModeToMutagen(mode string) (string, error) { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "", "two-way": + return "two-way-safe", nil + case "one-way": + return "one-way-safe", nil + case "mirror": + return "one-way-replica", nil + default: + return "", fmt.Errorf("unknown --mode %q\n\n Choose one of:\n two-way (default)\n one-way\n mirror", mode) + } +} + // runMutagen runs `mutagen ` with our shadowed PATH env. // stdout/stderr are forwarded so the user sees mutagen's progress. func runMutagen(ctx context.Context, bin string, env []string, args ...string) error {