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
32 changes: 32 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,3 +145,35 @@ detect-secrets audit .secrets.baseline
2. Create `<group>.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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
95 changes: 87 additions & 8 deletions cmd/sandbox/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/pem"
"errors"
"fmt"
"io"
"net"
"os"
"os/exec"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand All @@ -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 <args>` 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 {
Expand Down