@paleo/workspace 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -26,7 +26,8 @@ The agent reads the skill, adapts the reference scripts to your stack, installs
26
26
  ## Workflow
27
27
 
28
28
  ```sh
29
- npm run workspace -- setup feat/42 -c # new branch + worktree + isolated env
29
+ npm run workspace -- setup feat/42 -c # new branch + worktree + isolated env
30
+ npm run workspace -- setup feat/42 -c --go # …then drop into a shell there (exit to return)
30
31
  npm run dev # foreground: stream logs, CTRL+C stops; attaches if already running
31
32
  npm run dev -- up # start in the background (no-op if already running here)
32
33
  npm run dev -- up --restart # stop the dev-server in this worktree if running, then start fresh
package/dist/cli.d.ts CHANGED
@@ -7,6 +7,7 @@ export type WorkspaceCommand = {
7
7
  slot?: string;
8
8
  force: boolean;
9
9
  wait: boolean;
10
+ go: boolean;
10
11
  } | {
11
12
  kind: "remove";
12
13
  branch?: string;
package/dist/cli.js CHANGED
@@ -53,6 +53,7 @@ function parseSetup(tokens) {
53
53
  slot: { type: "string", short: "s" },
54
54
  force: { type: "boolean" },
55
55
  wait: { type: "boolean" },
56
+ go: { type: "boolean" },
56
57
  verbose: { type: "boolean", short: "v" },
57
58
  },
58
59
  allowPositionals: true,
@@ -60,12 +61,16 @@ function parseSetup(tokens) {
60
61
  });
61
62
  const branch = takeOptionalPositional(positionals, "setup");
62
63
  const newBranch = values["new-branch"] ?? false;
64
+ const go = values.go ?? false;
63
65
  if (newBranch && branch === undefined) {
64
66
  throw new ConfigError("`workspace setup <branch> -c` requires a branch name.");
65
67
  }
66
68
  if (values.from !== undefined && !newBranch) {
67
69
  throw new ConfigError("`--from` requires `-c`/`--new-branch`.");
68
70
  }
71
+ if (go && branch === undefined) {
72
+ throw new ConfigError("`--go` requires a branch (the worktree to enter).");
73
+ }
69
74
  return {
70
75
  command: {
71
76
  kind: "setup",
@@ -76,6 +81,7 @@ function parseSetup(tokens) {
76
81
  slot: values.slot,
77
82
  force: values.force ?? false,
78
83
  wait: values.wait ?? false,
84
+ go,
79
85
  },
80
86
  verbose: values.verbose ?? false,
81
87
  };
@@ -196,12 +202,14 @@ export function printWorkspaceHelp() {
196
202
  "Manage workspaces: a git worktree plus its own dev setup (ports, config, database, dev server).",
197
203
  "",
198
204
  "Commands:",
199
- " setup [<branch>] [-c|--new-branch] [--from <ref>] [--owner <name>] [-s|--slot <port>] [--force] [--wait]",
205
+ " setup [<branch>] [-c|--new-branch] [--from <ref>] [--owner <name>] [-s|--slot <port>] [--force] [--wait] [--go]",
200
206
  " Set up the workspace. With <branch>, create a sibling worktree for it",
201
207
  " (add -c to create the branch first). Without, set up the current worktree",
202
208
  " (idempotent; bootstrap and retry path).",
203
209
  " With -c, the new branch starts at the current worktree's HEAD, or at <ref> with --from.",
204
210
  " Finalize runs in the background; add --wait to block until it reaches READY.",
211
+ " With --go, drop into an interactive shell in the new worktree (exit to return);",
212
+ " combine with --wait to enter only once it is READY. Requires a branch and $SHELL.",
205
213
  " remove [<branch>] [--force]",
206
214
  " Remove a workspace by branch, or the current one when omitted.",
207
215
  " Refuses on uncommitted changes unless --force.",
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { runWorkspace } from "./workspace.js";
2
2
  export { defaultWorktreeDirName } from "./worktree.js";
3
3
  export type { WorktreeDirNameFn } from "./worktree.js";
4
- export type { WorkspaceConfig, SetupContext, SummaryContext, PatchContext, ConfigFileEntry, ConfigFileSource, ConfigFileSourceSpec, PurgeContext, } from "./workspace.js";
4
+ export type { WorkspaceConfig, SetupContext, FinalizeResult, SummaryContext, PatchContext, ConfigFileEntry, ConfigFileSource, ConfigFileSourceSpec, PurgeContext, } from "./workspace.js";
5
5
  export { runDevServer } from "./dev-server.js";
6
6
  export type { DevServerConfig, DevServerSummaryContext, ServerDescriptor, ServerContext, SpawnServer, CallbackServer, } from "./dev-server.js";
7
7
  export type { ResolvedSlot } from "./slots.js";
package/dist/slots.d.ts CHANGED
@@ -25,6 +25,9 @@ export interface SlotEntry {
25
25
  };
26
26
  /** `true` for the main-worktree entry. Absent on linked entries. */
27
27
  main?: boolean;
28
+ /** Opaque blob the consumer returns from `finalizeWorktree`, handed back to `purgeInfrastructure`
29
+ * so an orphan's infrastructure can be torn down by name after its worktree (and config) is gone. */
30
+ extra?: unknown;
28
31
  }
29
32
  export interface SlotsRegistry {
30
33
  slots: Record<string, SlotEntry>;
@@ -50,7 +53,7 @@ export declare function resolveAndRegisterSlot(input: RegisterSlotInput): {
50
53
  owner: string | undefined;
51
54
  status: SlotStatus;
52
55
  };
53
- export declare function markSlotReady(mainWorktree: string, registryDir: string, slotPort: number): void;
56
+ export declare function markSlotReady(mainWorktree: string, registryDir: string, slotPort: number, extra?: unknown): void;
54
57
  export declare function markSlotFailed(mainWorktree: string, registryDir: string, slotPort: number, message: string): void;
55
58
  export declare function validateSlotAvailability(slotArg: string | undefined, ctx: {
56
59
  currentWorktree: string;
package/dist/slots.js CHANGED
@@ -50,17 +50,21 @@ export function resolveAndRegisterSlot(input) {
50
50
  entry.main = true;
51
51
  if (owner !== undefined)
52
52
  entry.owner = owner;
53
+ if (existing?.extra !== undefined)
54
+ entry.extra = existing.extra;
53
55
  registry.slots[String(port)] = entry;
54
56
  writeSlots(input.mainWorktree, input.registryDir, registry);
55
57
  return { port, owner, status };
56
58
  }
57
- export function markSlotReady(mainWorktree, registryDir, slotPort) {
59
+ export function markSlotReady(mainWorktree, registryDir, slotPort, extra) {
58
60
  const registry = readSlots(mainWorktree, registryDir);
59
61
  const entry = registry.slots[String(slotPort)];
60
62
  if (!entry)
61
63
  return;
62
64
  entry.status = "ready";
63
65
  delete entry.failure;
66
+ if (extra !== undefined)
67
+ entry.extra = extra;
64
68
  writeSlots(mainWorktree, registryDir, registry);
65
69
  }
66
70
  export function markSlotFailed(mainWorktree, registryDir, slotPort, message) {
@@ -116,6 +120,8 @@ export function handleSetOwner(input) {
116
120
  };
117
121
  if (slotData.failure)
118
122
  updated.failure = slotData.failure;
123
+ if (slotData.extra !== undefined)
124
+ updated.extra = slotData.extra;
119
125
  if (input.newOwner !== undefined)
120
126
  updated.owner = input.newOwner;
121
127
  registry.slots[slotPort] = updated;
@@ -49,11 +49,19 @@ export interface WorkspaceConfig {
49
49
  *
50
50
  * Runs in a detached child whose stdout/stderr are already redirected to
51
51
  * `<runtimeDir>/logs/workspace-setup.log`. `console.log` and child-process `stdio: "inherit"` land there.
52
+ *
53
+ * May return `{ extra }` — an opaque blob persisted on the slot entry and handed back to
54
+ * {@link purgeInfrastructure}. Use it to record what infrastructure to tear down by name (e.g.
55
+ * container and volume names) so an orphaned worktree can still be cleaned up after its config is gone.
52
56
  */
53
- finalizeWorktree: (ctx: SetupContext) => Promise<void> | void;
57
+ finalizeWorktree: (ctx: SetupContext) => Promise<FinalizeResult | undefined> | FinalizeResult | undefined;
54
58
  /**
55
- * Destructive infrastructure teardown on `workspace remove` (e.g. `docker compose down -v` to
56
- * wipe volumes). Runs after the dev-server stop. Best-effort; errors should be swallowed.
59
+ * Destructive infrastructure teardown (e.g. `docker compose down -v` to wipe volumes). Runs after
60
+ * the dev-server stop on `workspace remove`, and on `workspace prune` / removing an orphaned
61
+ * worktree. MUST be idempotent and tolerate already-absent infrastructure: it may run when the
62
+ * worktree directory is gone (`ctx.extra` carries the recorded teardown identifiers; `ctx.worktree`
63
+ * no longer exists), so branch on the worktree's presence and tear down by name in that case.
64
+ * Best-effort; errors should be swallowed.
57
65
  */
58
66
  purgeInfrastructure?: (ctx: PurgeContext) => Promise<void> | void;
59
67
  /** Builds the post-setup summary printed to stdout. */
@@ -77,6 +85,10 @@ export interface PreSetupContext {
77
85
  /** Writes to stdout and the setup log. */
78
86
  log: (msg: string) => void;
79
87
  }
88
+ /** Return value of {@link WorkspaceConfig.finalizeWorktree}. */
89
+ export interface FinalizeResult {
90
+ extra: unknown;
91
+ }
80
92
  /** Context passed to {@link WorkspaceConfig.finalizeWorktree}. */
81
93
  export interface SetupContext {
82
94
  currentWorktree: string;
@@ -111,8 +123,13 @@ export interface SummaryContext {
111
123
  }
112
124
  /** Context passed to {@link WorkspaceConfig.purgeInfrastructure}. */
113
125
  export interface PurgeContext {
126
+ /** The target worktree. May no longer exist on disk when purging an orphan — check before
127
+ * running cwd-bound commands; tear down by name (from {@link extra}) in that case. */
114
128
  worktree: string;
115
129
  mainWorktree: string;
130
+ slot: number;
131
+ /** The blob the consumer returned from `finalizeWorktree`, if any. */
132
+ extra?: unknown;
116
133
  verbose: boolean;
117
134
  }
118
135
  /** A `{ path }` (relative to the main worktree) or `{ content }` (verbatim) initial source. */
package/dist/workspace.js CHANGED
@@ -58,7 +58,7 @@ export async function runWorkspace(config) {
58
58
  runList(registryDir);
59
59
  return;
60
60
  case "prune":
61
- await runPrune(registryDir);
61
+ await runPrune(config, registryDir, verbose);
62
62
  return;
63
63
  }
64
64
  const ctx = detectWorktree();
@@ -71,13 +71,29 @@ export async function runWorkspace(config) {
71
71
  handleSetOwnerMode(command, ctx, registryDir);
72
72
  return;
73
73
  case "setup": {
74
- const { slot } = await runSetup(command, ctx, run, config, registryDir);
74
+ const { slot, worktree } = await runSetup(command, ctx, run, config, registryDir);
75
75
  if (command.wait)
76
76
  await waitForSlot(slot, config, registryDir, { printSummary: false });
77
+ if (command.go)
78
+ enterWorktree(worktree);
77
79
  return;
78
80
  }
79
81
  }
80
82
  }
83
+ /**
84
+ * `--go`: open an interactive shell in the freshly set-up worktree (exit to return). Falls back to
85
+ * printing a `cd` hint when there is no `$SHELL` or stdin is not a tty (scripts, pipes) — dropping
86
+ * into an interactive shell there would hang.
87
+ */
88
+ function enterWorktree(worktree) {
89
+ const shell = process.env.SHELL;
90
+ if (shell === undefined || !process.stdin.isTTY) {
91
+ console.log(`Now run: cd ${worktree}`);
92
+ return;
93
+ }
94
+ console.error(`Entering ${worktree} (exit to return).`);
95
+ spawnSync(shell, [], { cwd: worktree, stdio: "inherit" });
96
+ }
81
97
  async function runSetup(command, ctx, run, config, registryDir) {
82
98
  const scheme = resolvePortScheme(config);
83
99
  const portsFn = resolvePortsFn(config);
@@ -158,7 +174,7 @@ async function runSetup(command, ctx, run, config, registryDir) {
158
174
  });
159
175
  child.unref();
160
176
  closeSync(logFd);
161
- return { slot };
177
+ return { slot, worktree: setupCtx.currentWorktree };
162
178
  }
163
179
  function refuseIfFinalizePending(ctx, registryDir, force) {
164
180
  if (force)
@@ -207,8 +223,8 @@ async function runFinalize(command, config, registryDir) {
207
223
  verbose: false,
208
224
  };
209
225
  try {
210
- await config.finalizeWorktree(setupContext);
211
- markSlotReady(ctx.mainWorktree, registryDir, slot);
226
+ const result = await config.finalizeWorktree(setupContext);
227
+ markSlotReady(ctx.mainWorktree, registryDir, slot, result?.extra);
212
228
  appendLog("============================================================");
213
229
  appendLog(`READY: branch ${branch} (slot ${slot})`);
214
230
  appendLog("============================================================");
@@ -383,7 +399,7 @@ function hintLiveOrphans(liveOrphans) {
383
399
  console.log(`\nNote: ${liveOrphans.length} workspace(s) have a deleted worktree but a still-running ` +
384
400
  "dev-server. Run `workspace prune` to stop them and clean up.");
385
401
  }
386
- async function runPrune(registryDir) {
402
+ async function runPrune(config, registryDir, verbose) {
387
403
  const ctx = detectWorktree();
388
404
  const registry = readSlots(ctx.mainWorktree, registryDir);
389
405
  const orphanPorts = findOrphanPorts(registry);
@@ -391,6 +407,13 @@ async function runPrune(registryDir) {
391
407
  for (const port of orphanPorts) {
392
408
  const entry = registry.slots[port];
393
409
  stoppedProcesses += await stopOrphanedDevServer(ctx.mainWorktree, registryDir, entry.worktree);
410
+ await runPurgeInfrastructure(config, {
411
+ worktree: entry.worktree,
412
+ mainWorktree: ctx.mainWorktree,
413
+ slot: Number(port),
414
+ extra: entry.extra,
415
+ verbose,
416
+ });
394
417
  delete registry.slots[port];
395
418
  const ownerSuffix = entry.owner ? `, owner ${entry.owner}` : "";
396
419
  console.log(`Pruned slot ${port} (${entry.worktree}${ownerSuffix}).`);
@@ -403,16 +426,18 @@ async function runPrune(registryDir) {
403
426
  return;
404
427
  }
405
428
  console.log(`Pruned ${orphanPorts.length} orphaned workspace(s).`);
406
- if (stoppedProcesses > 0) {
407
- console.log(`Stopped ${stoppedProcesses} orphaned process(es). Note: infrastructure managed by callback ` +
408
- "servers (e.g. `docker compose`) is not torn down automatically — check for leftover containers.");
429
+ if (stoppedProcesses > 0)
430
+ console.log(`Stopped ${stoppedProcesses} orphaned process(es).`);
431
+ if (config.purgeInfrastructure === undefined) {
432
+ console.log("Note: infrastructure managed by callback servers (e.g. `docker compose`) is not torn down " +
433
+ "automatically — check for leftover containers.");
409
434
  }
410
435
  }
411
436
  /**
412
437
  * Stop a gone worktree's dev-server the only way left: its dir (and `dev-server.mjs`) is deleted, so
413
438
  * we can't shell out to `dev down` to run callback stop() — we kill the recorded spawn PIDs directly
414
- * and drop the dev-server entry. Returns the count of live PIDs stopped. Callback-managed infra is
415
- * not torn down (see `runPrune`'s caveat).
439
+ * and drop the dev-server entry. Returns the count of live PIDs stopped. The caller separately runs
440
+ * `purgeInfrastructure` (by name, from the slot's `extra`) to tear down callback-managed infra.
416
441
  */
417
442
  async function stopOrphanedDevServer(mainWorktree, registryDir, worktree) {
418
443
  const devEntry = findOwnEntry(mainWorktree, registryDir, worktree);
@@ -493,6 +518,13 @@ async function handleRemove(command, ctx, run, config, registryDir) {
493
518
  if (!existsSync(target.worktreePath)) {
494
519
  console.warn(`Warning: Worktree directory ${target.worktreePath} not found. Cleaning up registry only.`);
495
520
  await stopOrphanedDevServer(ctx.mainWorktree, registryDir, target.worktreePath);
521
+ await runPurgeInfrastructure(config, {
522
+ worktree: target.worktreePath,
523
+ mainWorktree: ctx.mainWorktree,
524
+ slot: Number(target.slotPort),
525
+ extra: registry.slots[target.slotPort]?.extra,
526
+ verbose: run.verbose,
527
+ });
496
528
  delete registry.slots[target.slotPort];
497
529
  writeSlots(ctx.mainWorktree, registryDir, registry);
498
530
  pruneGitWorktrees(ctx.mainWorktree);
@@ -510,13 +542,13 @@ async function handleRemove(command, ctx, run, config, registryDir) {
510
542
  else {
511
543
  verboseLog(`No dev-server running in ${target.worktreePath}; skipping stop.`);
512
544
  }
513
- if (config.purgeInfrastructure) {
514
- await config.purgeInfrastructure({
515
- worktree: target.worktreePath,
516
- mainWorktree: ctx.mainWorktree,
517
- verbose: run.verbose,
518
- });
519
- }
545
+ await runPurgeInfrastructure(config, {
546
+ worktree: target.worktreePath,
547
+ mainWorktree: ctx.mainWorktree,
548
+ slot: Number(target.slotPort),
549
+ extra: registry.slots[target.slotPort]?.extra,
550
+ verbose: run.verbose,
551
+ });
520
552
  delete registry.slots[target.slotPort];
521
553
  writeSlots(ctx.mainWorktree, registryDir, registry);
522
554
  removeDevServerEntryByWorktree(ctx.mainWorktree, registryDir, target.worktreePath);
@@ -530,6 +562,10 @@ async function handleRemove(command, ctx, run, config, registryDir) {
530
562
  console.log(`Now run: cd ${ctx.mainWorktree}`);
531
563
  }
532
564
  }
565
+ async function runPurgeInfrastructure(config, ctx) {
566
+ if (config.purgeInfrastructure)
567
+ await config.purgeInfrastructure(ctx);
568
+ }
533
569
  function handleSetOwnerMode(command, ctx, registryDir) {
534
570
  const newOwner = command.name;
535
571
  const { slotPort } = handleSetOwner({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paleo/workspace",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "Run multiple git-worktree dev environments side by side.",
5
5
  "keywords": [
6
6
  "workspace",