@crouton-kit/grove
v0.2.37
Published
Parallel project instance manager for declarative apply, labels, ready pools, rollout, and rollback
Maintainers
Readme
Grove
Grove manages parallel project instances with isolated slot-based ports. Each registered project defines ports as base + slot × offset; grove plant reserves a slot, then clones or copies the project, applies the slot configuration, installs dependencies, and runs the project setup script. Registry changes are serialized with ~/.grove/grove.lock, so concurrent plants cannot take the same slot.
Development
pnpm install
pnpm build
pnpm dev -- --helpProject configuration
grove setup reads .grove/config.json at a source repository root, validates its contract, and converges the machine registration without authoring repository files. grove register remains the lower-level primitive for custom or explicit registration updates. Composite workspaces can keep the definition in their owning repository and pass its path relative to the registered source root:
grove register /path/to/workspace --config northlight/.grove/config.jsonThe selected config path is stored in ~/.grove/grove.json. An optional setup.sh beside the selected config runs after the instance has been copied or cloned. Teardown runs when the config names teardownScript.
A version 1 config can define:
nameandinstancesDir- slot-based
ports aliasesfor instance directoriesreposto clone into a composite instancecopyFromSourcefor untracked local configurationpatchPortsInglobssubstituteInrules for per-slot values that are strings rather than ports- per-repository
installcommands teardownScriptsecrets, per-repository commands that materialize untracked configuration in the targetdevCommand, an optional executable path relative to the target root (for examplescripts/dev.shornorthlight/scripts/dev.sh)lifecycle, an optional mapping ofstart,stop,status, andresetto non-empty argument arrays fordevCommandnameIsSlot, an optional boolean that requires instances to use their slot number as both name and directory namestateCommand, an optional executable path with the same shape, giving the project a data-state layer
devCommand and stateCommand must each point to an existing regular executable file inside the project root. Grove validates them during registration, doctor checks, and dispatch; it does not guess a script path.
secrets takes the same { dir, cmds } shape as install and runs in the target after copyFromSource and before patchPortsIn, so a generated .env gets its ports rewritten exactly like a copied one. Unlike install, a failing secrets command aborts the plant — a missing secret otherwise surfaces much later as an unexplained runtime failure.
Secret environment files
Every dispatched command — development, lifecycle, state, the legacy init script, setup, teardown, secrets, and install — receives optional secret env files. Grove reads and merges them in this order, so the nearest applicable scope wins:
~/.grove/env— user scope for every project~/.grove/env.d/<project>.env— project scope for every slot<target>/.grove/env— slot scope after the target exists
The legacy init script receives the user and project scopes, but not the slot scope because it creates the target. Every other dispatched command receives all three scopes. A missing file is skipped. Each non-blank, non-comment line is KEY=value; the value is literal after the first =, surrounding whitespace is trimmed, and one matching pair of surrounding single or double quotes is removed. There is no interpolation, export prefix, or inline-comment syntax. A malformed line, NUL byte, or GROVE_* key refuses the command and names its file and line. A repeated key in one file refuses and names both line numbers. Refusal messages never print a value. GROVE_* keys are reserved for Grove's derived context and are refused in every secret env file; Grove's own context values always win over inherited and secret-file values.
grove doctor reports every scope file for the source and each instance, including whether it is missing, its key count and names, or a parse error. It never prints a value. Grove never creates, copies, or rewrites <target>/.grove/env: the normal source copy excludes it, and copyFromSource, port patching, and substitutions skip it.
"secrets": [
{ "dir": "northlight/apps/core", "cmds": ["op inject -i .env.tpl -o .env"] }
]substituteIn covers the identities grove cannot derive arithmetically. A port is base + slot × offset, but a reserved hostname or a namespace token is a name, and an instance that inherits the source's copy claims an external identity another instance already owns. Each rule names its own files, pattern, and slot-shaped value:
"substituteIn": [
{
"in": ["northlight/apps/core/env/crouter.*.env"],
"find": "nl-core-g\\d+\\.ngrok\\.app",
"replace": "nl-core-${machine}-g${slot}.ngrok.app"
}
]in is a glob list relative to the target root, find is a JavaScript regular expression applied globally to each matching file, and replace is its replacement template with ${slot} expanded to the slot number and ${machine} expanded to the machine handle. Capture groups ($1, $<name>) work as usual. Grove compiles find when it validates the config, so a bad pattern is a refusal naming the rule rather than a half-rewritten instance. It also requires the result of applying all applicable rules a second time to be unchanged; otherwise it refuses before rewriting any selected file. Rules run after patchPortsIn, and never against .grove/config.json itself.
Grove computes the slot cap from the declared ports: it is the largest slot N for which every port in slots 0 through N is distinct and at most 65535. Requested slots must be positive safe integers; grove register and grove setup print the cap, and grove plant refuses a slot above it. A project with no declared ports has no port-derived cap.
Slot 0 is not special-cased the way ports are: a rule that produces the value the source already holds simply rewrites nothing.
Code
An instance's code and its data state are separate choices. plant --code-from picks the code:
configured(the default) clones each repo's branch from the project config, so a plaingrove plantis reproducible no matter what the source checkout is doing@sourceclones each source repo at its exact current commit, including commits that were never pushed, and keeps the real remote asorigin
--code-from @source refuses before any filesystem work when a source repo is dirty, missing, or cannot resolve HEAD — a half-source, half-configured instance is worse than being asked to commit first. It applies only to projects that declare repos; a project grove copies wholesale already gets the source tree.
The two intentional launches are grove plant <project> onboarding — configured branches at the baseline state, both defaults — and grove plant <project> feature --code-from @source --from @source, the code and data you are working on right now.
Apply and configuration drift
Every planted instance records its requested spec (codeFrom, state from, and labels) and a newest-first history of its last ten applied revisions. Each revision has a SHA-256 configHash, time, and the branch and commit in each configured repository. The newest revision is the current applied record. The hash is the canonical JSON of the validated source config: object keys are sorted recursively and arrays retain their declared order. Whitespace and object-key order therefore do not make an instance stale.
grove apply <project/instance> converges an existing checkout without cloning code or changing data state. It can also select a fleet as described in Labels, selectors, and fan-out. It reruns copyFromSource, secrets, patchPortsIn, substituteIn, install, and setup.sh, then records the current validated source config as applied. It refuses the source checkout and an instance being planted, uprooted, or restored. It marks an instance applying before setup without holding the registry lock during setup; if interrupted, re-run grove apply <project/instance> to complete it. It also refuses a source port contract that differs from the registered project ports (run grove register --update first), any configured repository that is not a Git checkout, and tracked repository changes; pass --force only when overwriting tracked changes is intended. Untracked files are not a refusal and copyFromSource may overwrite them.
Port patching and substitutions write a file only when its content changes, so a second apply leaves an already-patched file unchanged. A substitution replacement may match its own pattern when the second substitution is unchanged, such as a canonical final identity.
grove doctor reports built from older config — grove apply <project>/<name> when the current source config hash differs from the recorded one. grove list marks the same instance stale config; grove list --json includes each instance's spec, applied, and configStale fields.
Labels, selectors, and fan-out
Pass --label key=value repeatedly to grove plant to record labels on a new instance. Label keys must match [a-z0-9._-]+; values must be non-empty and cannot contain a comma. Change labels later with grove label <target> key=value ... [--rm key]. grove list prints labels, and grove list --json carries them in spec.labels.
The start, stop, status, reset, restore, apply, uproot, and label commands target one explicit <target> as before, instances matching every label pair with -l key=value[,key=value] [project], or every planted instance with --all [project]. Omit [project] only when the current directory is in a registered project or exactly one project is registered. A selector matching no instances refuses and names the selector. The source at slot 0 is never selected by -l or --all; name it explicitly only for commands that permit the source.
A selector or --all walks selected instances sequentially in slot order. Grove stops at the first non-zero exit and prints a summary showing each target that succeeded, failed, or never started. grove uproot -l ... and grove uproot --all require --force; explicit grove uproot <project/name> retains its confirmation prompt.
For restore, use grove restore <target> <ref> for one target and grove restore [project] <ref> -l ... or grove restore [project] <ref> --all for a set. For label, use grove label [project] key=value ... -l ... or grove label [project] key=value ... --all for a set.
Warm pool
grove pool <project> shows the number of ready instances, their slots, and the number of claimed instances. Ready means claimable: exactly what grove claim will hand out, so an in-progress plant is not counted ready. grove pool <project> --size N plants with configured code and baseline state until N completed instances carry grove.pool=ready. It only grows; a pool already at or above the requested size is left unchanged. An in-progress plant does count toward that size, so concurrent grove pool runs do not overshoot; an interrupted plant keeps counting until you remove it with grove uproot <project/name> --force.
grove claim <project> [--label key=value...] atomically takes the lowest-slot ready instance, removes grove.pool=ready, and adds the supplied labels. It prints the same --- grove-output --- JSON block as grove plant. When no ready instance exists, grow the pool with grove pool <project> --size N.
grove release <project/instance> [--force] returns a claimed instance to the pool. It refuses tracked or untracked changes in every configured repository, side branches with commits on no remote, changed extra worktrees, and checkouts detached on commits no remote holds. Grove fast-forwards configured branches, removes every extra worktree, deletes every local branch except each configured branch, resets the data state, applies the project configuration, drops all labels, and sets grove.pool=ready. --force runs git reset --hard and git clean -fd in every configured repository and allows release to discard the otherwise refused side branches and worktrees. An interruption before the final save leaves the releasing reservation and the instance's existing labels in place; re-run grove release <target> to finish it.
Rollout and rollback
grove rollout <project> moves every planted instance in the project forward in slot order. Use -l key=value[,key=value] to select matching labels or --all to state the default explicitly. Before touching an instance, rollout refuses if any configured repository has tracked changes. It fetches and fast-forwards every configured repository to its configured branch, runs apply, then runs lifecycle stop and start when both are declared. When lifecycle status is declared, it must exit 0. Rollout stops at the first failure, names that instance, and leaves later instances unstarted.
grove rollback <project/instance> is the single-instance escape hatch. It refuses tracked changes and requires at least two recorded revisions. Grove checks out every configured repository on the previous revision's recorded branch and commit, reruns apply, and records a new revision with rolledBackFrom set to the timestamp of the revision it replaced.
State
Ports, namespaces, and env files are an instance's identity; its database contents are state. stateCommand lets a project choose what state a new instance starts from and return a live instance to a known one. Grove owns the store, the ref grammar, and the schema gate; the project owns what its state actually is.
Grove invokes the executable with cwd at the target root and the same context environment devCommand gets. Non-zero exit fails the operation.
| invocation | contract |
|---|---|
| state.sh reset | bring this instance to the empty/migrated baseline |
| state.sh capture <dir> | write everything constituting this instance's state into <dir> (grove creates it empty) |
| state.sh restore <dir> | load a previously captured <dir> back into this instance |
| state.sh fingerprint | print one opaque line identifying the schema generation |
One ref grammar is shared by plant --from and restore:
baseline— runsreset; this is theplantdefault and reproduces a project's pre-state-layer behavior@<instance>— captured live from that instance at the moment of use;@sourcemeans the project source at slot 0<name>— a snapshot in the store
Snapshots live in ~/.grove/states/<project>/<name>/, holding meta.json and whatever capture wrote into data/. They are project-scoped, so uprooting the instance a snapshot came from leaves the snapshot intact.
capture records the fingerprint output; restore runs fingerprint against the destination and refuses on mismatch, naming both values. --ignore-fingerprint overrides it.
Settings
~/.grove/settings.json holds machine-level settings. It is optional, has no writer — edit it by hand — and grove doctor validates it.
{ "version": 1, "machine": "my-machine", "killTmuxSessionOnStop": true }machine is optional. It must match ^[a-z0-9-]{1,16}$; when omitted, Grove uses the short hostname lowercased with every other character removed, truncated to 16 characters. Grove passes the effective value as GROVE_MACHINE, uses it for ${machine} substitutions, and prints it in grove doctor.
killTmuxSessionOnStop (default false) makes grove stop <target> kill the target's tmux session after the project's stop verb exits 0. The kill runs last, so the verb's output is written first, and a failed kill warns rather than failing the command — the services genuinely stopped. A non-zero stop leaves the session alone, so the window showing why it failed survives.
Grove derives the session name as <project>-<slot>: northlight-3 for slot 3 and northlight-0 for the project source. grove open --json prints it as tmuxSession, and grove ui's o key switches to it. An unknown key, a wrong type, a version other than 1, or unparseable JSON is a refusal naming the file and the key — from grove doctor and from grove stop before it stops anything.
grove ui
grove ui [project] is a terminal UI over one project's slots — it shows the source at slot 0, every instance, and empty rows through the project's computed slot cap, limited to rows that fit in the terminal. When the terminal is too short for all of them, the table keeps the lowest slots that fit and ends with … N more slots not shown.
grove ui — fixture pool 2 ready · 3 claimed /Users/silasrhyneer/Code/cli/.grove-fixtures/ui-surfaces/source
SLOT NAME BRANCH SYNC STATE SERVICES
▸ 0 (source) main ↑0 ↓0 app:65000 ○ api:63000 ○
1 alpha main ↑0 ↓0 app:65100 ○ api:63100 ○
2 beta main ↑0 ↓0 stale app:65200 ○ api:63200 ○
3 3 main ↑0 ↓0 pool app:65300 ○ api:63300 ○
4 gamma applying Re-run with: grove apply fixture/gamma
5 delta main ↑0 ↓0 no-state app:65500 ○ api:63500 ○The title line carries the project's pool: how many instances are ready to claim and how many are claimed, by the same rule grove pool prints.
A composite project has several repos per slot, so each column aggregates them: BRANCH is the branch every repo agrees on or the literal word mixed; SYNC is ↑A ↓B summed across the repos that have an upstream, with a trailing ? when any repo lacks one or its state is unknown; ✱ marks a slot where any repo has tracked changes (dirty never counts untracked files). The detail pane under the table always shows the selected slot's per-repo truth. SERVICES is each declared port and whether something is listening on it, which every project that declares ports gets without cooperating.
STATE is the recorded intent a fleet is scanned for: no-state when the instance's data state was never applied, stale when it was built from an older source config, and pool when it carries grove.pool=ready and is claimable. An instance with a reservation replaces its whole row with the operation and the command that resolves it, so an interrupted plant, uproot, apply, restore, release, rollout, or rollback is visible without selecting the row. Every row is one line at any width: cells truncate, nothing wraps.
The detail pane adds the selected instance's intent — its requested code mode, state ref, and labels — and its applied record: the short config hash, the time, how many revisions are recorded, the revision it was rolled back from, and stale — grove apply <target> when the source config has moved on. The source at slot 0 has no registry entry, so it shows neither.
| key | action |
|---|---|
| ↑/k, ↓/j | move the cursor |
| 0–9 | jump to one of the table's first ten rows |
| o | switch to the slot's tmux session, creating it if absent, and exit |
| p | plant the selected empty slot |
| u | uproot the selected instance, after a y/n confirmation |
| s S r t | the project's start, stop, reset, and status lifecycle verbs; r confirms first |
| a A | grove apply the selected instance, and apply --force over tracked changes; both confirm |
| e E | grove release the selected instance into the pool, deleting side branches and extra worktrees, and release --force also discarding repository changes; both confirm |
| b | grove rollback the selected instance one revision, after a confirmation; dimmed under two recorded revisions |
| l L | add labels, or remove them by key — each opens a one-line prompt; Enter runs, Esc cancels |
| c | grove claim the project's lowest-slot ready instance |
| P | grow the project's ready pool: a prompt for the size, then a confirmation |
| R | git fetch every repo, then re-read |
| ? | help · q or Esc quit |
Every action runs Grove's own CLI as a child and shows the resolved argv, a rising elapsed count, and the child's output in the pane below the table; Ctrl-C interrupts it and leaves the UI running, and the inventory is re-read when it exits. Actions act on one target: c and P on the project, everything else on the selected row. grove ui has no rollout and no selector fan-out — use grove rollout and -l/--all from the command line for those.
A key the selected row does not support is dimmed in the footer and answers with the same refusal Grove's own command would print — p on an occupied slot, u or a on the source, or a lifecycle role the project's lifecycle mapping does not declare. A prompt refuses the same way before it confirms anything: a --size that is not a non-negative integer, or an option typed where it expects labels.
t runs the project's status verb and shows its output verbatim in the detail pane. Grove never parses it, so a project can print whatever it likes.
The project comes from the [project] argument, else the registered project containing the current directory, else the only registered project, else a picker. grove ui needs a terminal on both stdin and stdout and refuses when either is redirected — for a machine-readable inventory, grove list --json is the same data.
Commands
grove setup [source]
grove register <source> [--config <relative-path>] [--update]
grove dev [--at <target>] [raw argv...]
grove plant <project> [name] [--slot <n>] [--code-from <mode>] [--from <ref>] [--ignore-fingerprint] [--label <key=value>...]
grove apply [target-or-project] [-l <key=value[,key=value]> | --all] [--force]
grove pool <project> [--size N]
grove claim <project> [--label <key=value>...]
grove release <target> [--force]
grove rollout <project> [-l <key=value[,key=value]> | --all]
grove rollback <project/instance>
grove adopt <project> <name> <path> [--slot <n>]
grove list [project] [--json]
grove open [target] [--json]
grove start [target-or-project] [-l <key=value[,key=value]> | --all]
grove stop [target-or-project] [-l <key=value[,key=value]> | --all]
grove status [target-or-project] [-l <key=value[,key=value]> | --all]
grove reset [target-or-project] [-l <key=value[,key=value]> | --all]
grove label [target-or-project] [key=value...] [-l <key=value[,key=value]> | --all] [--rm <key>...]
grove doctor [project]
grove ui [project]
grove uproot [target-or-project] [-l <key=value[,key=value]> | --all] [--force]
grove snapshot <project/instance> <name> [--force]
grove restore [target-or-project] [ref] [-l <key=value[,key=value]> | --all] [--force] [--ignore-fingerprint]
grove states [project] [--rm <name>]grove reset <target> runs the project's own lifecycle reset from its lifecycle mapping; grove restore <target> baseline runs the data-state reset through stateCommand. They are different operations — one returns the working environment to a known state, the other returns the database to its baseline.
grove dev resolves the current directory to the longest containing registered source or instance, then directly runs that target's configured devCommand. Arguments are forwarded unchanged, and Grove supplies GROVE_MACHINE, GROVE_SOURCE, GROVE_TARGET, GROVE_SLOT, GROVE_INSTANCE_NAME, GROVE_PORTS_JSON, and GROVE_PORT_<NAME> environment variables.
Teardown alone also receives GROVE_SIBLINGS_JSON: a JSON array of the project entries remaining after the instance being uprooted is removed, with objects shaped as { "name": string, "slot": number, "path": string }, sorted by slot. The source is always included as { "name": "<project>", "slot": 0, "path": "<registered source path>" }; other registered instances follow it, except the instance being uprooted. This lets a teardown distinguish another instance that still needs a shared resource from the source alone. Pending planting entries are included when they are siblings and excluded when they are the target. If the target's registry entry is missing, grove uproot refuses before teardown; if a pending target has no directory or no teardown script, teardown is not dispatched. In either case no process receives GROVE_SIBLINGS_JSON.
grove plant prints a --- grove-output --- JSON block for callers that need the created path, slot, ports, state ref, the branch and commit each repo landed on, and the recorded spec and applied intent.
grove plant, grove uproot, grove apply, grove restore, and grove release mark their in-progress instance planting, uprooting, applying, restoring, or releasing before long work and release the registry lock while that work runs. grove list, grove doctor, and grove ui show the state and its resolving command. grove dev, lifecycle commands, grove snapshot, and other conflicting operations refuse an in-progress instance. Remove a partial planting with grove uproot <project/name> --force; re-run grove uproot, grove apply, grove restore <project/name> <ref>, or grove release <project/name> to finish an interrupted operation of that type. If release reports repository changes, re-run it with grove release <project/name> --force. grove uproot refuses an instance being applied or restored, but remains the cleanup path for planting and uprooting instances. Once an operation succeeds, Grove clears its marker. An instance whose state was not applied is still marked state not applied and can be repaired with grove restore.
Grove does not infer a moved config path. Re-run grove register <source> --config <relative-path> --update; config-backed re-registration replaces stored ports, aliases, init, teardown, and development-command values.
