@paleo/workspace 0.21.0 → 0.23.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/dist/helpers.js CHANGED
@@ -81,8 +81,8 @@ export function copyAndPatchFile(ctx, relPath, source, patchFn, label, force, op
81
81
  else {
82
82
  if (!existsSync(source.path)) {
83
83
  if (!optional) {
84
- console.error(`Error: source ${source.path} not found. Bootstrap the main worktree first ` +
85
- "(`workspace setup`), provide a `source`, or mark the entry as optional.");
84
+ console.error(`Error: config source ${source.path} not found. Bootstrap it first ` +
85
+ "(`workspace setup`, or commit the template), or mark the entry as optional.");
86
86
  process.exit(1);
87
87
  }
88
88
  ctx.log(`Warning: source ${source.path} not found, skipping (optional).`);
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, MainWorktreeConfigFileSource, NewWorktreeConfigFileSource, ContentConfigFileSource, 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;
@@ -1,3 +1,4 @@
1
+ import { type ResolvedFileSource } from "./helpers.js";
1
2
  import { type SlotStatus } from "./slots.js";
2
3
  import { type WorktreeDirNameFn } from "./worktree.js";
3
4
  /** Configuration accepted by {@link runWorkspace}. */
@@ -32,7 +33,7 @@ export interface WorkspaceConfig {
32
33
  * Holds the setup log and dev-server logs.
33
34
  */
34
35
  runtimeDir: string;
35
- /** Config files copied from the main worktree and patched per slot. */
36
+ /** Gitignored files seeded into each worktree (from main, a committed template, or content) and patched per slot. */
36
37
  configFiles: ConfigFileEntry[];
37
38
  /**
38
39
  * Runs before `configFiles` are copied. Use this to bootstrap source files the kernel expects
@@ -49,11 +50,19 @@ export interface WorkspaceConfig {
49
50
  *
50
51
  * Runs in a detached child whose stdout/stderr are already redirected to
51
52
  * `<runtimeDir>/logs/workspace-setup.log`. `console.log` and child-process `stdio: "inherit"` land there.
53
+ *
54
+ * May return `{ extra }` — an opaque blob persisted on the slot entry and handed back to
55
+ * {@link purgeInfrastructure}. Use it to record what infrastructure to tear down by name (e.g.
56
+ * container and volume names) so an orphaned worktree can still be cleaned up after its config is gone.
52
57
  */
53
- finalizeWorktree: (ctx: SetupContext) => Promise<void> | void;
58
+ finalizeWorktree: (ctx: SetupContext) => Promise<FinalizeResult | undefined> | FinalizeResult | undefined;
54
59
  /**
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.
60
+ * Destructive infrastructure teardown (e.g. `docker compose down -v` to wipe volumes). Runs after
61
+ * the dev-server stop on `workspace remove`, and on `workspace prune` / removing an orphaned
62
+ * worktree. MUST be idempotent and tolerate already-absent infrastructure: it may run when the
63
+ * worktree directory is gone (`ctx.extra` carries the recorded teardown identifiers; `ctx.worktree`
64
+ * no longer exists), so branch on the worktree's presence and tear down by name in that case.
65
+ * Best-effort; errors should be swallowed.
57
66
  */
58
67
  purgeInfrastructure?: (ctx: PurgeContext) => Promise<void> | void;
59
68
  /** Builds the post-setup summary printed to stdout. */
@@ -77,6 +86,10 @@ export interface PreSetupContext {
77
86
  /** Writes to stdout and the setup log. */
78
87
  log: (msg: string) => void;
79
88
  }
89
+ /** Return value of {@link WorkspaceConfig.finalizeWorktree}. */
90
+ export interface FinalizeResult {
91
+ extra: unknown;
92
+ }
80
93
  /** Context passed to {@link WorkspaceConfig.finalizeWorktree}. */
81
94
  export interface SetupContext {
82
95
  currentWorktree: string;
@@ -111,45 +124,46 @@ export interface SummaryContext {
111
124
  }
112
125
  /** Context passed to {@link WorkspaceConfig.purgeInfrastructure}. */
113
126
  export interface PurgeContext {
127
+ /** The target worktree. May no longer exist on disk when purging an orphan — check before
128
+ * running cwd-bound commands; tear down by name (from {@link extra}) in that case. */
114
129
  worktree: string;
115
130
  mainWorktree: string;
131
+ slot: number;
132
+ /** The blob the consumer returned from `finalizeWorktree`, if any. */
133
+ extra?: unknown;
116
134
  verbose: boolean;
117
135
  }
118
- /** A `{ path }` (relative to the main worktree) or `{ content }` (verbatim) initial source. */
119
- export type ConfigFileSourceSpec = {
120
- path: string;
121
- } | {
122
- content: string;
123
- };
124
- /**
125
- * Overrides where a {@link ConfigFileEntry}'s initial content comes from. Defaults to reading
126
- * `entry.path` from the main worktree.
127
- *
128
- * - `{ path }` — read this path (relative to the main worktree) instead of `entry.path`,
129
- * e.g. seed from a committed example: `{ path: "packages/api/.env.local.example" }`.
130
- * - `{ content }` — use this string as the initial content verbatim.
131
- * - a callback returning either of the above, resolved per worktree with the same
132
- * {@link PatchContext} the `patch` callback receives.
133
- */
134
- export type ConfigFileSource = ConfigFileSourceSpec | ((ctx: PatchContext) => ConfigFileSourceSpec);
135
- /** One config file seeded from its source (the main worktree by default) and patched per slot. */
136
+ /** One config file seeded from its source and patched per slot. */
136
137
  export interface ConfigFileEntry {
137
138
  /** Path relative to the worktree root. Written to the current worktree. */
138
139
  path: string;
140
+ /** Where the initial content comes from. */
141
+ source: ConfigFileSource;
142
+ /** Rewrites the source content per slot. Omit to copy the content verbatim. */
143
+ patch?: (content: string, ctx: PatchContext) => string;
139
144
  /**
140
- * Overrides the initial content's source. Defaults to reading `path` from the main worktree.
141
- * Use this to seed from a committed example or supplied content instead. See {@link ConfigFileSource}.
142
- */
143
- source?: ConfigFileSource;
144
- /** Returns the patched content given the source content and the slot's ports. */
145
- patch: (content: string, ctx: PatchContext) => string;
146
- /**
147
- * When `true`, a missing `{ path }` source logs a warning and skips the entry.
148
- * Default: required (missing source aborts setup). Bootstrap the main worktree first via
149
- * `workspace setup`, or seed sources in `preSetup`. Ignored for `{ content }` sources.
145
+ * When `true`, a missing source file logs a warning and skips the entry instead of aborting.
146
+ * Applies to `mainWorktree` and `newWorktree` sources; ignored for `content`.
150
147
  */
151
148
  optional?: boolean;
152
149
  }
150
+ /** Where a {@link ConfigFileEntry}'s initial content comes from. */
151
+ export type ConfigFileSource = MainWorktreeConfigFileSource | NewWorktreeConfigFileSource | ContentConfigFileSource;
152
+ /** Copies the gitignored file at the entry's `path` from the main worktree. */
153
+ export interface MainWorktreeConfigFileSource {
154
+ kind: "mainWorktree";
155
+ }
156
+ /** Copies a committed template from the new worktree's own checkout. */
157
+ export interface NewWorktreeConfigFileSource {
158
+ kind: "newWorktree";
159
+ /** Path of the template, relative to the worktree root (e.g. a committed `.example` file). */
160
+ path: string;
161
+ }
162
+ /** Uses the given content verbatim. The function form may be async. */
163
+ export interface ContentConfigFileSource {
164
+ kind: "content";
165
+ content: string | (() => string | Promise<string>);
166
+ }
153
167
  /** Context passed to {@link ConfigFileEntry.patch}. */
154
168
  export interface PatchContext {
155
169
  slot: number;
@@ -158,3 +172,4 @@ export interface PatchContext {
158
172
  currentWorktree: string;
159
173
  }
160
174
  export declare function runWorkspace(config: WorkspaceConfig): Promise<void>;
175
+ export declare function resolveConfigSource(entry: ConfigFileEntry, ctx: PatchContext): Promise<ResolvedFileSource>;
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();
@@ -148,7 +148,7 @@ async function runSetup(command, ctx, run, config, registryDir) {
148
148
  }
149
149
  linkSharedDirectories(setupCtx, config.sharedDirs, verboseLog);
150
150
  linkWorkspaceRegistry(setupCtx, config.runtimeDir, verboseLog);
151
- generateConfigFiles(setupCtx, config.configFiles, slot, ports, command.force, verboseLog);
151
+ await generateConfigFiles(setupCtx, config.configFiles, slot, ports, command.force, verboseLog);
152
152
  teeLog(config.printSummary({
153
153
  slot,
154
154
  branch,
@@ -223,8 +223,8 @@ async function runFinalize(command, config, registryDir) {
223
223
  verbose: false,
224
224
  };
225
225
  try {
226
- await config.finalizeWorktree(setupContext);
227
- markSlotReady(ctx.mainWorktree, registryDir, slot);
226
+ const result = await config.finalizeWorktree(setupContext);
227
+ markSlotReady(ctx.mainWorktree, registryDir, slot, result?.extra);
228
228
  appendLog("============================================================");
229
229
  appendLog(`READY: branch ${branch} (slot ${slot})`);
230
230
  appendLog("============================================================");
@@ -399,7 +399,7 @@ function hintLiveOrphans(liveOrphans) {
399
399
  console.log(`\nNote: ${liveOrphans.length} workspace(s) have a deleted worktree but a still-running ` +
400
400
  "dev-server. Run `workspace prune` to stop them and clean up.");
401
401
  }
402
- async function runPrune(registryDir) {
402
+ async function runPrune(config, registryDir, verbose) {
403
403
  const ctx = detectWorktree();
404
404
  const registry = readSlots(ctx.mainWorktree, registryDir);
405
405
  const orphanPorts = findOrphanPorts(registry);
@@ -407,6 +407,13 @@ async function runPrune(registryDir) {
407
407
  for (const port of orphanPorts) {
408
408
  const entry = registry.slots[port];
409
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
+ });
410
417
  delete registry.slots[port];
411
418
  const ownerSuffix = entry.owner ? `, owner ${entry.owner}` : "";
412
419
  console.log(`Pruned slot ${port} (${entry.worktree}${ownerSuffix}).`);
@@ -419,16 +426,18 @@ async function runPrune(registryDir) {
419
426
  return;
420
427
  }
421
428
  console.log(`Pruned ${orphanPorts.length} orphaned workspace(s).`);
422
- if (stoppedProcesses > 0) {
423
- console.log(`Stopped ${stoppedProcesses} orphaned process(es). Note: infrastructure managed by callback ` +
424
- "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.");
425
434
  }
426
435
  }
427
436
  /**
428
437
  * Stop a gone worktree's dev-server the only way left: its dir (and `dev-server.mjs`) is deleted, so
429
438
  * we can't shell out to `dev down` to run callback stop() — we kill the recorded spawn PIDs directly
430
- * and drop the dev-server entry. Returns the count of live PIDs stopped. Callback-managed infra is
431
- * 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.
432
441
  */
433
442
  async function stopOrphanedDevServer(mainWorktree, registryDir, worktree) {
434
443
  const devEntry = findOwnEntry(mainWorktree, registryDir, worktree);
@@ -509,6 +518,13 @@ async function handleRemove(command, ctx, run, config, registryDir) {
509
518
  if (!existsSync(target.worktreePath)) {
510
519
  console.warn(`Warning: Worktree directory ${target.worktreePath} not found. Cleaning up registry only.`);
511
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
+ });
512
528
  delete registry.slots[target.slotPort];
513
529
  writeSlots(ctx.mainWorktree, registryDir, registry);
514
530
  pruneGitWorktrees(ctx.mainWorktree);
@@ -526,13 +542,13 @@ async function handleRemove(command, ctx, run, config, registryDir) {
526
542
  else {
527
543
  verboseLog(`No dev-server running in ${target.worktreePath}; skipping stop.`);
528
544
  }
529
- if (config.purgeInfrastructure) {
530
- await config.purgeInfrastructure({
531
- worktree: target.worktreePath,
532
- mainWorktree: ctx.mainWorktree,
533
- verbose: run.verbose,
534
- });
535
- }
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
+ });
536
552
  delete registry.slots[target.slotPort];
537
553
  writeSlots(ctx.mainWorktree, registryDir, registry);
538
554
  removeDevServerEntryByWorktree(ctx.mainWorktree, registryDir, target.worktreePath);
@@ -546,6 +562,10 @@ async function handleRemove(command, ctx, run, config, registryDir) {
546
562
  console.log(`Now run: cd ${ctx.mainWorktree}`);
547
563
  }
548
564
  }
565
+ async function runPurgeInfrastructure(config, ctx) {
566
+ if (config.purgeInfrastructure)
567
+ await config.purgeInfrastructure(ctx);
568
+ }
549
569
  function handleSetOwnerMode(command, ctx, registryDir) {
550
570
  const newOwner = command.name;
551
571
  const { slotPort } = handleSetOwner({
@@ -681,7 +701,7 @@ function linkWorkspaceRegistry(ctx, runtimeDir, log, opts) {
681
701
  symlinkSync(relative(runtimeRoot, mainDir), link);
682
702
  log("Created workspace-registry symlink → main worktree.");
683
703
  }
684
- function generateConfigFiles(ctx, entries, slot, ports, force, log) {
704
+ async function generateConfigFiles(ctx, entries, slot, ports, force, log) {
685
705
  for (const entry of entries) {
686
706
  const patchCtx = {
687
707
  slot,
@@ -689,16 +709,25 @@ function generateConfigFiles(ctx, entries, slot, ports, force, log) {
689
709
  mainWorktree: ctx.mainWorktree,
690
710
  currentWorktree: ctx.currentWorktree,
691
711
  };
692
- copyAndPatchFile({ currentWorktree: ctx.currentWorktree, log }, entry.path, resolveConfigSource(entry, patchCtx), (content) => entry.patch(content, patchCtx), entry.path, force, entry.optional ?? false);
712
+ const { patch } = entry;
713
+ const patchFn = patch
714
+ ? (content) => patch(content, patchCtx)
715
+ : (content) => content;
716
+ copyAndPatchFile({ currentWorktree: ctx.currentWorktree, log }, entry.path, await resolveConfigSource(entry, patchCtx), patchFn, entry.path, force, entry.optional ?? false);
693
717
  }
694
718
  }
695
- function resolveConfigSource(entry, ctx) {
696
- const spec = typeof entry.source === "function" ? entry.source(ctx) : entry.source;
697
- if (spec === undefined)
698
- return { path: join(ctx.mainWorktree, entry.path) };
699
- if ("content" in spec)
700
- return { content: spec.content };
701
- return { path: join(ctx.mainWorktree, spec.path) };
719
+ export async function resolveConfigSource(entry, ctx) {
720
+ const { source } = entry;
721
+ switch (source.kind) {
722
+ case "mainWorktree":
723
+ return { path: join(ctx.mainWorktree, entry.path) };
724
+ case "newWorktree":
725
+ return { path: join(ctx.currentWorktree, source.path) };
726
+ case "content":
727
+ return {
728
+ content: typeof source.content === "function" ? await source.content() : source.content,
729
+ };
730
+ }
702
731
  }
703
732
  function resolveRemoveTarget(command, ctx, registry) {
704
733
  if (command.branch === undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paleo/workspace",
3
- "version": "0.21.0",
3
+ "version": "0.23.0",
4
4
  "description": "Run multiple git-worktree dev environments side by side.",
5
5
  "keywords": [
6
6
  "workspace",