@paleo/workspace 0.16.0 → 0.17.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
@@ -51,7 +51,6 @@ await runWorkspace({
51
51
  portNames: ["server", "frontend", "db"],
52
52
  sharedDirs: [".local", ".plans"],
53
53
  runtimeDir: ".local-wt",
54
- registryDir: ".local/_workspace-registry",
55
54
  configFiles: [
56
55
  {
57
56
  path: ".env",
@@ -89,7 +88,6 @@ import { runDevServer, helpers } from "@paleo/workspace";
89
88
  await runDevServer({
90
89
  basePort: 8100,
91
90
  runtimeDir: ".local-wt",
92
- registryDir: ".local/_workspace-registry",
93
91
  devLimit: 5,
94
92
  servers: [
95
93
  {
package/dist/cli.d.ts CHANGED
@@ -25,6 +25,9 @@ export type WorkspaceCommand = {
25
25
  kind: "finalize";
26
26
  slot: string;
27
27
  force: boolean;
28
+ } | {
29
+ kind: "migrate";
30
+ oldRegistryDir: string;
28
31
  } | {
29
32
  kind: "help";
30
33
  };
package/dist/cli.js CHANGED
@@ -32,6 +32,8 @@ function parseSubcommand(subcommand, tokens) {
32
32
  return parseSetOwner(tokens);
33
33
  case "__finalize":
34
34
  return parseFinalize(tokens);
35
+ case "migrate-0.16":
36
+ return parseMigrate(tokens);
35
37
  default:
36
38
  throw new ConfigError(`Unknown command "${subcommand}". Run \`workspace --help\`.`);
37
39
  }
@@ -140,6 +142,16 @@ function parseFinalize(tokens) {
140
142
  const slot = takeRequiredPositional(positionals, "__finalize", "slot");
141
143
  return { command: { kind: "finalize", slot, force: values.force ?? false }, verbose: false };
142
144
  }
145
+ function parseMigrate(tokens) {
146
+ const { values, positionals } = parseArgs({
147
+ args: tokens,
148
+ options: { verbose: { type: "boolean", short: "v" } },
149
+ allowPositionals: true,
150
+ strict: true,
151
+ });
152
+ const oldRegistryDir = takeRequiredPositional(positionals, "migrate-0.16", "old-registry-dir");
153
+ return { command: { kind: "migrate", oldRegistryDir }, verbose: values.verbose ?? false };
154
+ }
143
155
  function takeOptionalPositional(positionals, command) {
144
156
  if (positionals.length > 1) {
145
157
  throw new ConfigError(`\`workspace ${command}\` accepts at most one positional argument.`);
@@ -8,12 +8,6 @@ export interface DevServerConfig {
8
8
  basePort: number;
9
9
  /** Per-worktree runtime directory, relative to the worktree root (e.g. `.local-wt`). */
10
10
  runtimeDir: string;
11
- /**
12
- * Shared registry directory, relative to a worktree root (e.g. `.local/_workspace-registry`).
13
- * Holds `slots.json` and `dev-servers.json`. Must resolve to the same physical directory
14
- * across linked worktrees — typically via a symlink (e.g. `.local`).
15
- */
16
- registryDir: string;
17
11
  /** Maximum concurrent dev-servers across all worktrees. Omit for no limit. */
18
12
  devLimit?: number;
19
13
  /** One entry per server to start. Started in array order; stopped in reverse order. */
@@ -8,7 +8,7 @@ import { detectCommonJsError, formatDuration, lastLines, setupLogPath } from "./
8
8
  import { awaitAllReady, handleStartupFailure, LOG_TAIL_LINES, } from "./log-polling.js";
9
9
  import { canonicalCwd, detectPortConflicts, sweepStalePorts, waitForPortsFree, } from "./port-holder.js";
10
10
  import { isProcessAlive, stopProcessGroup } from "./process-control.js";
11
- import { readSlots, resolveCurrentSlot } from "./slots.js";
11
+ import { readSlots, registryDirFor, resolveCurrentSlot, warnLegacyRegistryDir, } from "./slots.js";
12
12
  import { detectWorktree, getWorktreeBranch } from "./worktree.js";
13
13
  function logFileFor(runtimeDir, name) {
14
14
  return join(runtimeDir, "logs", `${name}.log`);
@@ -30,60 +30,65 @@ export async function runDevServer(config) {
30
30
  printDevHelp();
31
31
  return;
32
32
  }
33
+ warnLegacyRegistryDir(config);
34
+ const registryDir = registryDirFor(config.runtimeDir);
33
35
  const { mainWorktree } = detectWorktree();
34
36
  switch (command.kind) {
35
37
  case "list":
36
- listDevServers(mainWorktree, config.registryDir);
38
+ listDevServers(mainWorktree, registryDir);
37
39
  return;
38
40
  case "down":
39
41
  if (command.all) {
40
42
  await stopAllRegistered({
41
43
  mainWorktree,
42
- registryDir: config.registryDir,
44
+ registryDir,
43
45
  callbackServers: callbackServersOf(config),
44
46
  });
45
47
  }
46
48
  else {
47
- await stopLocal(config, mainWorktree);
49
+ await stopLocal(config, mainWorktree, registryDir);
48
50
  }
49
51
  return;
50
52
  case "up":
51
- await start(config, mainWorktree, { evict: command.evict, restart: command.restart });
53
+ await start(config, mainWorktree, registryDir, {
54
+ evict: command.evict,
55
+ restart: command.restart,
56
+ });
52
57
  return;
53
58
  case "restart":
54
- await start(config, mainWorktree, { evict: command.evict, restart: true });
59
+ await start(config, mainWorktree, registryDir, { evict: command.evict, restart: true });
55
60
  return;
56
61
  case "status":
57
- printStatus(config, mainWorktree);
62
+ printStatus(config, mainWorktree, registryDir);
58
63
  return;
59
64
  case "foreground":
60
- await runForeground(config, mainWorktree, {
65
+ await runForeground(config, mainWorktree, registryDir, {
61
66
  evict: command.evict,
62
67
  restart: command.restart,
63
68
  });
64
69
  return;
65
70
  }
66
71
  }
67
- function printStatus(config, mainWorktree) {
68
- const entry = findOwnEntry(mainWorktree, config.registryDir, process.cwd());
72
+ function printStatus(config, mainWorktree, registryDir) {
73
+ const entry = findOwnEntry(mainWorktree, registryDir, process.cwd());
69
74
  if (!entry || !Object.values(entry.pids).some(isProcessAlive)) {
70
75
  console.log("Dev-server status: DOWN.");
71
76
  return;
72
77
  }
73
78
  console.log("Dev-server status: UP.");
74
- const slot = resolveCurrentSlot(config.basePort, config.registryDir);
79
+ const slot = resolveCurrentSlot(config.basePort, registryDir);
75
80
  printStartSummary(config, slot, entry.pids);
76
81
  }
77
82
  function callbackServersOf(config) {
78
83
  return config.servers.filter((s) => s.kind === "callback");
79
84
  }
80
- async function start(config, mainWorktree, options) {
85
+ async function start(config, mainWorktree, registryDir, options) {
81
86
  const ctx = { cwd: process.cwd() };
82
- if (await runStartChecks(config, mainWorktree, ctx, options))
87
+ if (await runStartChecks(config, mainWorktree, registryDir, ctx, options))
83
88
  return;
84
89
  const state = { spawnPids: {}, startedCallbacks: [] };
85
90
  await spawnWithRollback(config, ctx, state);
86
- const slot = registerStartedServer(config, mainWorktree, state.spawnPids);
91
+ const slot = registerStartedServer(config, mainWorktree, registryDir, state.spawnPids);
87
92
  printStartSummary(config, slot, state.spawnPids);
88
93
  }
89
94
  /**
@@ -91,10 +96,10 @@ async function start(config, mainWorktree, options) {
91
96
  * handlers are installed before starting so an interrupt during startup rolls back; after a
92
97
  * successful start they switch to the local stop sequence.
93
98
  */
94
- async function runForeground(config, mainWorktree, options) {
99
+ async function runForeground(config, mainWorktree, registryDir, options) {
95
100
  const ctx = { cwd: process.cwd() };
96
101
  if (!options.restart) {
97
- const running = findOwnLiveEntry(config, mainWorktree, ctx.cwd);
102
+ const running = findOwnLiveEntry(mainWorktree, registryDir, ctx.cwd);
98
103
  if (running)
99
104
  return attachForeground(config, running);
100
105
  }
@@ -106,7 +111,7 @@ async function runForeground(config, mainWorktree, options) {
106
111
  return;
107
112
  shuttingDown = true;
108
113
  if (started) {
109
- void shutdownForeground(config, mainWorktree);
114
+ void shutdownForeground(config, mainWorktree, registryDir);
110
115
  }
111
116
  else {
112
117
  void rollbackStart(state.spawnPids, state.startedCallbacks, ctx).then(() => process.exit(130));
@@ -114,7 +119,7 @@ async function runForeground(config, mainWorktree, options) {
114
119
  };
115
120
  process.on("SIGINT", onSignal);
116
121
  process.on("SIGTERM", onSignal);
117
- if (await runStartChecks(config, mainWorktree, ctx, options))
122
+ if (await runStartChecks(config, mainWorktree, registryDir, ctx, options))
118
123
  process.exit(0);
119
124
  // Stream logs from the first byte so the whole startup (e.g. a slow build) is visible live.
120
125
  const streamLogs = () => tailLogs(config, state.spawnPids, { fromStart: true });
@@ -123,7 +128,7 @@ async function runForeground(config, mainWorktree, options) {
123
128
  if (!(await spawnWithRollback(config, ctx, state, () => shuttingDown, streamLogs))) {
124
129
  await new Promise(() => { });
125
130
  }
126
- const slot = registerStartedServer(config, mainWorktree, state.spawnPids);
131
+ const slot = registerStartedServer(config, mainWorktree, registryDir, state.spawnPids);
127
132
  started = true;
128
133
  printStartSummary(config, slot, state.spawnPids);
129
134
  watchForExternalStop(Object.values(state.spawnPids), () => {
@@ -135,8 +140,8 @@ async function runForeground(config, mainWorktree, options) {
135
140
  });
136
141
  await new Promise(() => { });
137
142
  }
138
- function findOwnLiveEntry(config, mainWorktree, cwd) {
139
- const entry = findOwnEntry(mainWorktree, config.registryDir, cwd);
143
+ function findOwnLiveEntry(mainWorktree, registryDir, cwd) {
144
+ const entry = findOwnEntry(mainWorktree, registryDir, cwd);
140
145
  if (!entry)
141
146
  return;
142
147
  return Object.values(entry.pids).some(isProcessAlive) ? entry : undefined;
@@ -171,18 +176,18 @@ async function attachForeground(config, entry) {
171
176
  });
172
177
  await new Promise(() => { });
173
178
  }
174
- async function shutdownForeground(config, mainWorktree) {
179
+ async function shutdownForeground(config, mainWorktree, registryDir) {
175
180
  console.log("\nStopping dev servers...");
176
- await stopLocal(config, mainWorktree);
181
+ await stopLocal(config, mainWorktree, registryDir);
177
182
  console.log("Stopped.");
178
183
  process.exit(0);
179
184
  }
180
- async function runStartChecks(config, mainWorktree, ctx, { evict, restart }) {
181
- checkWorktreeReady(config, mainWorktree, ctx.cwd);
182
- if (await handleAlreadyRunning(config, mainWorktree, ctx, restart))
185
+ async function runStartChecks(config, mainWorktree, registryDir, ctx, { evict, restart }) {
186
+ checkWorktreeReady(config, mainWorktree, registryDir, ctx.cwd);
187
+ if (await handleAlreadyRunning(config, mainWorktree, registryDir, ctx, restart))
183
188
  return true;
184
- await enforceCap(config, mainWorktree, evict);
185
- checkNoLocalRegistryConflict(config, mainWorktree, ctx.cwd);
189
+ await enforceCap(config, mainWorktree, registryDir, evict);
190
+ checkNoLocalRegistryConflict(mainWorktree, registryDir, ctx.cwd);
186
191
  await checkPortsFree(config.servers, ctx.cwd);
187
192
  return false;
188
193
  }
@@ -244,8 +249,8 @@ async function spawnAndAwait(config, ctx, state, onSpawned) {
244
249
  const pollPids = spawnEntries.map((s) => state.spawnPids[s.name]);
245
250
  await awaitAllReady(pollables, pollPids);
246
251
  }
247
- function registerStartedServer(config, mainWorktree, spawnPids) {
248
- const slot = resolveCurrentSlot(config.basePort, config.registryDir);
252
+ function registerStartedServer(config, mainWorktree, registryDir, spawnPids) {
253
+ const slot = resolveCurrentSlot(config.basePort, registryDir);
249
254
  const devEntry = {
250
255
  slot: slot.slot,
251
256
  worktree: slot.worktree,
@@ -255,7 +260,7 @@ function registerStartedServer(config, mainWorktree, spawnPids) {
255
260
  };
256
261
  if (slot.main)
257
262
  devEntry.main = true;
258
- registerDevServer(mainWorktree, config.registryDir, devEntry);
263
+ registerDevServer(mainWorktree, registryDir, devEntry);
259
264
  return slot;
260
265
  }
261
266
  function printStartSummary(config, slot, spawnPids) {
@@ -382,9 +387,9 @@ export function buildWorktreeReadyMessage(input) {
382
387
  "Re-run `workspace setup` to retry the finalize.",
383
388
  };
384
389
  }
385
- function checkWorktreeReady(config, mainWorktree, cwd) {
386
- const slot = resolveCurrentSlot(config.basePort, config.registryDir);
387
- const entry = readSlots(mainWorktree, config.registryDir).slots[String(slot.slot)];
390
+ function checkWorktreeReady(config, mainWorktree, registryDir, cwd) {
391
+ const slot = resolveCurrentSlot(config.basePort, registryDir);
392
+ const entry = readSlots(mainWorktree, registryDir).slots[String(slot.slot)];
388
393
  const result = buildWorktreeReadyMessage({
389
394
  slotPort: slot.slot,
390
395
  worktreePath: cwd,
@@ -402,8 +407,8 @@ function checkWorktreeReady(config, mainWorktree, cwd) {
402
407
  * normal start path can proceed, or print a friendly notice and return `true` to short-circuit.
403
408
  * Returns `true` when the caller should exit cleanly without starting.
404
409
  */
405
- async function handleAlreadyRunning(config, mainWorktree, ctx, restart) {
406
- const entry = findOwnEntry(mainWorktree, config.registryDir, ctx.cwd);
410
+ async function handleAlreadyRunning(config, mainWorktree, registryDir, ctx, restart) {
411
+ const entry = findOwnEntry(mainWorktree, registryDir, ctx.cwd);
407
412
  if (!entry)
408
413
  return false;
409
414
  const livePids = Object.entries(entry.pids).filter(([, pid]) => isProcessAlive(pid));
@@ -411,7 +416,7 @@ async function handleAlreadyRunning(config, mainWorktree, ctx, restart) {
411
416
  return false;
412
417
  if (restart) {
413
418
  console.log("Restarting dev-server in this worktree...");
414
- await stopLocal(config, mainWorktree);
419
+ await stopLocal(config, mainWorktree, registryDir);
415
420
  return false;
416
421
  }
417
422
  const pidList = livePids.map(([name, pid]) => `${name}=${pid}`).join(", ");
@@ -422,11 +427,11 @@ async function handleAlreadyRunning(config, mainWorktree, ctx, restart) {
422
427
  // TOCTOU: the cap check and the subsequent register are not atomic. Two concurrent `dev up --evict`
423
428
  // from different worktrees can both pass the cap check and both register, exceeding the limit by
424
429
  // one. Accepted: the race window is narrow and the consequence is bounded (one extra dev-server).
425
- async function enforceCap(config, mainWorktree, evict) {
430
+ async function enforceCap(config, mainWorktree, registryDir, evict) {
426
431
  const limit = config.devLimit;
427
432
  if (limit === undefined)
428
433
  return;
429
- const active = pruneAndPersist(mainWorktree, config.registryDir).servers;
434
+ const active = pruneAndPersist(mainWorktree, registryDir).servers;
430
435
  if (active.length < limit)
431
436
  return;
432
437
  if (!evict) {
@@ -440,7 +445,7 @@ async function enforceCap(config, mainWorktree, evict) {
440
445
  console.log(`Evicting ${toEvict} dev-server(s) to make room (cap ${limit}).`);
441
446
  const evicted = await evictOldest({
442
447
  mainWorktree,
443
- registryDir: config.registryDir,
448
+ registryDir,
444
449
  callbackServers: callbackServersOf(config),
445
450
  count: toEvict,
446
451
  });
@@ -482,8 +487,8 @@ async function checkPortsFree(servers, cwd) {
482
487
  process.exit(1);
483
488
  }
484
489
  }
485
- function checkNoLocalRegistryConflict(config, mainWorktree, cwd) {
486
- const entry = findOwnEntry(mainWorktree, config.registryDir, cwd);
490
+ function checkNoLocalRegistryConflict(mainWorktree, registryDir, cwd) {
491
+ const entry = findOwnEntry(mainWorktree, registryDir, cwd);
487
492
  if (!entry)
488
493
  return;
489
494
  for (const [name, pid] of Object.entries(entry.pids)) {
@@ -493,11 +498,11 @@ function checkNoLocalRegistryConflict(config, mainWorktree, cwd) {
493
498
  }
494
499
  }
495
500
  // Stale entry — drop it so registration overwrites cleanly.
496
- removeDevServerEntryByWorktree(mainWorktree, config.registryDir, cwd);
501
+ removeDevServerEntryByWorktree(mainWorktree, registryDir, cwd);
497
502
  }
498
- async function stopLocal(config, mainWorktree) {
503
+ async function stopLocal(config, mainWorktree, registryDir) {
499
504
  const ctx = { cwd: process.cwd() };
500
- const entry = findOwnEntry(mainWorktree, config.registryDir, ctx.cwd);
505
+ const entry = findOwnEntry(mainWorktree, registryDir, ctx.cwd);
501
506
  if (!entry) {
502
507
  console.log("No dev-server running in this worktree.");
503
508
  await sweepStalePorts(config.servers, ctx.cwd);
@@ -519,7 +524,7 @@ async function stopLocal(config, mainWorktree) {
519
524
  console.error(` Failed to stop ${server.name}: ${err.message}`);
520
525
  }
521
526
  }
522
- unregisterDevServer(mainWorktree, config.registryDir, ctx.cwd);
527
+ unregisterDevServer(mainWorktree, registryDir, ctx.cwd);
523
528
  await sweepStalePorts(config.servers, ctx.cwd);
524
529
  }
525
530
  function defaultPrintSummary(slot, servers, runtimeDir) {
@@ -42,3 +42,5 @@ export declare function pruneDeadServers(data: DevServersData, isAlive?: IsAlive
42
42
  export declare function liveWorktrees(data: DevServersData, isAlive?: IsAliveFn): Set<string>;
43
43
  export declare function readDevServers(mainWorktree: string, registryDir: string): DevServersData;
44
44
  export declare function writeDevServers(mainWorktree: string, registryDir: string, data: DevServersData): void;
45
+ /** Union by resolved `worktree`; `override` wins on conflict. Base-first then override-only order. */
46
+ export declare function mergeDevServers(base: DevServersData, override: DevServersData): DevServersData;
@@ -136,6 +136,17 @@ export function writeDevServers(mainWorktree, registryDir, data) {
136
136
  mkdirSync(join(mainWorktree, registryDir), { recursive: true });
137
137
  writeFileSync(fp, `${JSON.stringify(data, undefined, 2)}\n`);
138
138
  }
139
+ /** Union by resolved `worktree`; `override` wins on conflict. Base-first then override-only order. */
140
+ export function mergeDevServers(base, override) {
141
+ const overrideByWorktree = new Map(override.servers.map((e) => [resolve(e.worktree), e]));
142
+ const merged = base.servers.map((entry) => overrideByWorktree.get(resolve(entry.worktree)) ?? entry);
143
+ const baseWorktrees = new Set(base.servers.map((e) => resolve(e.worktree)));
144
+ for (const entry of override.servers) {
145
+ if (!baseWorktrees.has(resolve(entry.worktree)))
146
+ merged.push(entry);
147
+ }
148
+ return { servers: merged };
149
+ }
139
150
  function filePath(mainWorktree, registryDir) {
140
151
  return join(mainWorktree, registryDir, DEV_SERVERS_FILENAME);
141
152
  }
package/dist/slots.d.ts CHANGED
@@ -1,4 +1,11 @@
1
1
  import { type PortScheme } from "./ports.js";
2
+ export declare const REGISTRY_SUBDIR = "shared-registry";
3
+ export declare function registryDirFor(runtimeDir: string): string;
4
+ /** `registryDir` is gone from the config types but may linger in a consumer's config file. */
5
+ export declare function warnLegacyRegistryDir(config: {
6
+ runtimeDir: string;
7
+ registryDir?: string;
8
+ }): void;
2
9
  export interface ResolvedSlot {
3
10
  slot: number;
4
11
  worktree: string;
@@ -24,6 +31,8 @@ export interface SlotsRegistry {
24
31
  }
25
32
  export declare function readSlots(mainWorktree: string, registryDir: string): SlotsRegistry;
26
33
  export declare function writeSlots(mainWorktree: string, registryDir: string, registry: SlotsRegistry): void;
34
+ /** Union of slots keyed by port; `override` wins on conflict. */
35
+ export declare function mergeSlots(base: SlotsRegistry, override: SlotsRegistry): SlotsRegistry;
27
36
  export interface RegisterSlotInput {
28
37
  slot?: string;
29
38
  currentWorktree: string;
package/dist/slots.js CHANGED
@@ -3,7 +3,20 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { allPorts, isValidPort } from "./ports.js";
5
5
  import { getWorktreeBranch } from "./worktree.js";
6
+ export const REGISTRY_SUBDIR = "shared-registry";
6
7
  const SLOTS_FILENAME = "slots.json";
8
+ export function registryDirFor(runtimeDir) {
9
+ return join(runtimeDir, REGISTRY_SUBDIR);
10
+ }
11
+ /** `registryDir` is gone from the config types but may linger in a consumer's config file. */
12
+ export function warnLegacyRegistryDir(config) {
13
+ if (config.registryDir === undefined)
14
+ return;
15
+ console.warn("Warning: `registryDir` is obsolete and ignored. The registry now lives at " +
16
+ `\`${registryDirFor(config.runtimeDir)}\`. Remove \`registryDir\` from your config. ` +
17
+ `If you have an existing registry at "${config.registryDir}", run ` +
18
+ `\`workspace migrate-0.16 ${config.registryDir}\` once to merge it.`);
19
+ }
7
20
  export function readSlots(mainWorktree, registryDir) {
8
21
  const filePath = join(mainWorktree, registryDir, SLOTS_FILENAME);
9
22
  if (!existsSync(filePath))
@@ -15,6 +28,10 @@ export function writeSlots(mainWorktree, registryDir, registry) {
15
28
  mkdirSync(join(mainWorktree, registryDir), { recursive: true });
16
29
  writeFileSync(filePath, `${JSON.stringify(registry, undefined, 2)}\n`);
17
30
  }
31
+ /** Union of slots keyed by port; `override` wins on conflict. */
32
+ export function mergeSlots(base, override) {
33
+ return { slots: { ...base.slots, ...override.slots } };
34
+ }
18
35
  export function resolveAndRegisterSlot(input) {
19
36
  const registry = readSlots(input.mainWorktree, input.registryDir);
20
37
  const port = pickSlotPort(input, registry);
@@ -32,12 +32,6 @@ export interface WorkspaceConfig {
32
32
  * Holds the setup log and dev-server logs.
33
33
  */
34
34
  runtimeDir: string;
35
- /**
36
- * Shared registry directory, relative to a worktree root (e.g. `.local/_workspace-registry`).
37
- * Holds `slots.json` and `dev-servers.json`. Must resolve to the same physical directory
38
- * across linked worktrees — typically via a symlink listed in `sharedDirs` (e.g. `.local`).
39
- */
40
- registryDir: string;
41
35
  /** Config files copied from the main worktree and patched per slot. */
42
36
  configFiles: ConfigFileEntry[];
43
37
  /**
package/dist/workspace.js CHANGED
@@ -1,13 +1,13 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
- import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, symlinkSync, writeFileSync, } from "node:fs";
3
- import { dirname, join, relative, resolve } from "node:path";
2
+ import { appendFileSync, closeSync, existsSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, symlinkSync, writeFileSync, } from "node:fs";
3
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { parseWorkspaceArgs, printWorkspaceHelp } from "./cli.js";
5
- import { findOwnEntry, liveWorktrees, readDevServers, removeDevServerEntryByWorktree, } from "./dev-servers-registry.js";
5
+ import { findOwnEntry, liveWorktrees, mergeDevServers, readDevServers, removeDevServerEntryByWorktree, writeDevServers, } from "./dev-servers-registry.js";
6
6
  import { ConfigError } from "./errors.js";
7
7
  import { copyAndPatchFile, formatDuration, setupLogPath, } from "./helpers.js";
8
8
  import { isProcessAlive } from "./process-control.js";
9
9
  import { defaultComputePorts, isValidPort, resolvePortScheme } from "./ports.js";
10
- import { handleSetOwner, markSlotFailed, markSlotReady, readSlots, resolveAndRegisterSlot, resolveCurrentSlot, validateSlotAvailability, writeSlots, } from "./slots.js";
10
+ import { handleSetOwner, markSlotFailed, markSlotReady, mergeSlots, readSlots, REGISTRY_SUBDIR, registryDirFor, resolveAndRegisterSlot, resolveCurrentSlot, validateSlotAvailability, warnLegacyRegistryDir, writeSlots, } from "./slots.js";
11
11
  import { createBranch, detectWorktree, enforceWorktreeMode, getWorktreeBranch, removeWorktree, useExistingBranch, verifyBranchAbsentFromRemote, } from "./worktree.js";
12
12
  export async function runWorkspace(config) {
13
13
  let command;
@@ -27,6 +27,12 @@ export async function runWorkspace(config) {
27
27
  printWorkspaceHelp();
28
28
  return;
29
29
  }
30
+ warnLegacyRegistryDir(config);
31
+ const registryDir = registryDirFor(config.runtimeDir);
32
+ if (command.kind === "migrate") {
33
+ handleMigrate(command, config, registryDir);
34
+ return;
35
+ }
30
36
  if (!existsSync(config.scriptPath)) {
31
37
  console.error(`Error: scriptPath does not exist: ${config.scriptPath}. ` +
32
38
  "Pass `fileURLToPath(import.meta.url)` from your wrapper script.");
@@ -34,16 +40,16 @@ export async function runWorkspace(config) {
34
40
  }
35
41
  switch (command.kind) {
36
42
  case "finalize":
37
- await runFinalize(command, config);
43
+ await runFinalize(command, config, registryDir);
38
44
  return;
39
45
  case "wait":
40
- await runWait(command, config);
46
+ await runWait(command, config, registryDir);
41
47
  return;
42
48
  case "status":
43
- runStatus(command, config);
49
+ runStatus(command, config, registryDir);
44
50
  return;
45
51
  case "list":
46
- runList(config);
52
+ runList(registryDir);
47
53
  return;
48
54
  }
49
55
  const ctx = detectWorktree();
@@ -51,36 +57,36 @@ export async function runWorkspace(config) {
51
57
  const run = { verbose };
52
58
  switch (command.kind) {
53
59
  case "remove":
54
- await handleRemove(command, ctx, run, config);
60
+ await handleRemove(command, ctx, run, config, registryDir);
55
61
  return;
56
62
  case "set-owner":
57
- handleSetOwnerMode(command, ctx, config);
63
+ handleSetOwnerMode(command, ctx, registryDir);
58
64
  return;
59
65
  case "setup": {
60
- const { slot } = await runSetup(command, ctx, run, config);
66
+ const { slot } = await runSetup(command, ctx, run, config, registryDir);
61
67
  if (command.wait)
62
- await waitForSlot(slot, config, { printSummary: false });
68
+ await waitForSlot(slot, config, registryDir, { printSummary: false });
63
69
  return;
64
70
  }
65
71
  }
66
72
  }
67
- async function runSetup(command, ctx, run, config) {
73
+ async function runSetup(command, ctx, run, config, registryDir) {
68
74
  const scheme = resolvePortScheme(config);
69
75
  const portsFn = resolvePortsFn(config);
70
76
  validateSlotAvailability(command.slot, {
71
77
  currentWorktree: ctx.currentWorktree,
72
78
  mainWorktree: ctx.mainWorktree,
73
- registryDir: config.registryDir,
79
+ registryDir,
74
80
  scheme,
75
81
  });
76
82
  const setupCtx = ensureWorktree(command, ctx, run, config.worktreeDirName);
77
- refuseIfFinalizePending(setupCtx, config.registryDir, command.force);
83
+ refuseIfFinalizePending(setupCtx, registryDir, command.force);
78
84
  const branch = getWorktreeBranch(setupCtx.currentWorktree) ?? "(detached)";
79
85
  const { port: slot, owner, status, } = resolveAndRegisterSlot({
80
86
  slot: command.slot,
81
87
  currentWorktree: setupCtx.currentWorktree,
82
88
  mainWorktree: setupCtx.mainWorktree,
83
- registryDir: config.registryDir,
89
+ registryDir,
84
90
  scheme,
85
91
  requestedOwner: command.owner,
86
92
  isMainWorktree: setupCtx.isMainWorktree,
@@ -117,6 +123,7 @@ async function runSetup(command, ctx, run, config) {
117
123
  });
118
124
  }
119
125
  linkSharedDirectories(setupCtx, config.sharedDirs, verboseLog);
126
+ linkSharedRegistry(setupCtx, config.runtimeDir, verboseLog);
120
127
  generateConfigFiles(setupCtx, config.configFiles, slot, ports, command.force, verboseLog);
121
128
  teeLog(config.printSummary({
122
129
  slot,
@@ -159,14 +166,14 @@ function refuseIfFinalizePending(ctx, registryDir, force) {
159
166
  "then retry. Use --force to bypass.");
160
167
  process.exit(1);
161
168
  }
162
- async function runFinalize(command, config) {
169
+ async function runFinalize(command, config, registryDir) {
163
170
  const slot = Number(command.slot);
164
171
  const ctx = detectWorktree();
165
172
  const logPath = setupLogPath(ctx.currentWorktree, config.runtimeDir);
166
173
  const appendLog = (message) => {
167
174
  appendFileSync(logPath, `${message}\n`);
168
175
  };
169
- const registry = readSlots(ctx.mainWorktree, config.registryDir);
176
+ const registry = readSlots(ctx.mainWorktree, registryDir);
170
177
  const entry = registry.slots[String(slot)];
171
178
  if (!entry || resolve(entry.worktree) !== resolve(ctx.currentWorktree)) {
172
179
  appendLog(`FAILED: No matching slot ${slot} for worktree ${ctx.currentWorktree}.`);
@@ -193,7 +200,7 @@ async function runFinalize(command, config) {
193
200
  };
194
201
  try {
195
202
  await config.finalizeWorktree(setupContext);
196
- markSlotReady(ctx.mainWorktree, config.registryDir, slot);
203
+ markSlotReady(ctx.mainWorktree, registryDir, slot);
197
204
  appendLog("============================================================");
198
205
  appendLog(`READY: branch ${branch} (slot ${slot})`);
199
206
  appendLog("============================================================");
@@ -201,14 +208,14 @@ async function runFinalize(command, config) {
201
208
  catch (err) {
202
209
  const message = err.message;
203
210
  const stack = err.stack ?? "";
204
- markSlotFailed(ctx.mainWorktree, config.registryDir, slot, message);
211
+ markSlotFailed(ctx.mainWorktree, registryDir, slot, message);
205
212
  appendLog(`FAILED: ${message}`);
206
213
  if (stack)
207
214
  appendLog(stack);
208
215
  process.exit(1);
209
216
  }
210
217
  }
211
- function resolveTargetSlot(slotArg, config) {
218
+ function resolveTargetSlot(slotArg, config, registryDir) {
212
219
  if (slotArg !== undefined) {
213
220
  const slot = Number(slotArg);
214
221
  const scheme = resolvePortScheme(config);
@@ -218,11 +225,11 @@ function resolveTargetSlot(slotArg, config) {
218
225
  }
219
226
  return slot;
220
227
  }
221
- return resolveCurrentSlot(config.basePort, config.registryDir).slot;
228
+ return resolveCurrentSlot(config.basePort, registryDir).slot;
222
229
  }
223
- function printWorktreeInfo(config, slot, worktreeForLog, fallback) {
230
+ function printWorktreeInfo(config, registryDir, slot, worktreeForLog, fallback) {
224
231
  const ctx = detectWorktree();
225
- const registry = readSlots(ctx.mainWorktree, config.registryDir);
232
+ const registry = readSlots(ctx.mainWorktree, registryDir);
226
233
  const entry = registry.slots[String(slot)];
227
234
  const ports = resolvePortsFn(config)(slot);
228
235
  const owner = entry?.owner ?? fallback.owner;
@@ -252,10 +259,10 @@ function printWorktreeInfo(config, slot, worktreeForLog, fallback) {
252
259
  const elapsed = formatDuration(now - Date.parse(entry.createdAt));
253
260
  console.log(`Pending since ${elapsed} ago (tail ${setupLogPath})`);
254
261
  }
255
- printDevServerBlock(config, ctx.mainWorktree, targetWorktree, now);
262
+ printDevServerBlock(config, registryDir, ctx.mainWorktree, targetWorktree, now);
256
263
  }
257
- function printDevServerBlock(config, mainWorktree, targetWorktree, now) {
258
- const entry = findOwnEntry(mainWorktree, config.registryDir, targetWorktree);
264
+ function printDevServerBlock(config, registryDir, mainWorktree, targetWorktree, now) {
265
+ const entry = findOwnEntry(mainWorktree, registryDir, targetWorktree);
259
266
  const liveEntries = entry
260
267
  ? Object.entries(entry.pids)
261
268
  .filter(([, pid]) => isProcessAlive(pid))
@@ -272,31 +279,31 @@ function printDevServerBlock(config, mainWorktree, targetWorktree, now) {
272
279
  console.log(` log: ${join(targetWorktree, config.runtimeDir, "logs", `${name}.log`)}`);
273
280
  }
274
281
  }
275
- function runStatus(command, config) {
282
+ function runStatus(command, config, registryDir) {
276
283
  if (command.slot !== undefined) {
277
- const slot = resolveTargetSlot(command.slot, config);
284
+ const slot = resolveTargetSlot(command.slot, config, registryDir);
278
285
  const ctx = detectWorktree();
279
- const entry = readSlots(ctx.mainWorktree, config.registryDir).slots[String(slot)];
286
+ const entry = readSlots(ctx.mainWorktree, registryDir).slots[String(slot)];
280
287
  if (!entry) {
281
288
  console.error(`Error: No slot ${slot} in registry.`);
282
289
  process.exit(1);
283
290
  }
284
- printWorktreeInfo(config, slot, entry.worktree, { owner: entry.owner });
291
+ printWorktreeInfo(config, registryDir, slot, entry.worktree, { owner: entry.owner });
285
292
  return;
286
293
  }
287
- const resolved = resolveCurrentSlot(config.basePort, config.registryDir);
288
- printWorktreeInfo(config, resolved.slot, resolved.worktree, {
294
+ const resolved = resolveCurrentSlot(config.basePort, registryDir);
295
+ printWorktreeInfo(config, registryDir, resolved.slot, resolved.worktree, {
289
296
  owner: resolved.owner,
290
297
  });
291
298
  }
292
- function runList(config) {
299
+ function runList(registryDir) {
293
300
  const ctx = detectWorktree();
294
- const entries = Object.entries(readSlots(ctx.mainWorktree, config.registryDir).slots).sort(([a], [b]) => Number(a) - Number(b));
301
+ const entries = Object.entries(readSlots(ctx.mainWorktree, registryDir).slots).sort(([a], [b]) => Number(a) - Number(b));
295
302
  if (entries.length === 0) {
296
303
  console.log("No workspaces registered.");
297
304
  return;
298
305
  }
299
- const liveSet = liveWorktrees(readDevServers(ctx.mainWorktree, config.registryDir));
306
+ const liveSet = liveWorktrees(readDevServers(ctx.mainWorktree, registryDir));
300
307
  const rows = entries.map(([port, e]) => ({
301
308
  slot: port,
302
309
  type: e.main ? "main" : "linked",
@@ -331,15 +338,15 @@ function runList(config) {
331
338
  for (const r of rows)
332
339
  console.log(fmt(r));
333
340
  }
334
- async function runWait(command, config) {
341
+ async function runWait(command, config, registryDir) {
335
342
  // standalone `workspace wait` (no prior setup in this invocation) → print the full summary on success.
336
- const slot = resolveTargetSlot(command.slot, config);
337
- await waitForSlot(slot, config);
343
+ const slot = resolveTargetSlot(command.slot, config, registryDir);
344
+ await waitForSlot(slot, config, registryDir);
338
345
  }
339
- async function waitForSlot(slot, config, options = {}) {
346
+ async function waitForSlot(slot, config, registryDir, options = {}) {
340
347
  const printSummary = options.printSummary ?? true;
341
348
  const ctx = detectWorktree();
342
- const initial = readSlots(ctx.mainWorktree, config.registryDir).slots[String(slot)];
349
+ const initial = readSlots(ctx.mainWorktree, registryDir).slots[String(slot)];
343
350
  if (!initial) {
344
351
  console.error(`Error: No slot ${slot} in registry.`);
345
352
  process.exit(1);
@@ -348,7 +355,7 @@ async function waitForSlot(slot, config, options = {}) {
348
355
  // Poll slots.json — the finalize child writes `status` on success or failure. Tiny file, no
349
356
  // log-tailing race.
350
357
  for (;;) {
351
- const entry = readSlots(ctx.mainWorktree, config.registryDir).slots[String(slot)];
358
+ const entry = readSlots(ctx.mainWorktree, registryDir).slots[String(slot)];
352
359
  if (!entry) {
353
360
  console.error(`Error: Slot ${slot} disappeared from registry.`);
354
361
  process.exit(1);
@@ -356,7 +363,7 @@ async function waitForSlot(slot, config, options = {}) {
356
363
  if (entry.status === "ready") {
357
364
  console.log("\n… ready");
358
365
  if (printSummary) {
359
- printWorktreeInfo(config, slot, entry.worktree, {
366
+ printWorktreeInfo(config, registryDir, slot, entry.worktree, {
360
367
  owner: entry.owner,
361
368
  });
362
369
  }
@@ -371,10 +378,10 @@ async function waitForSlot(slot, config, options = {}) {
371
378
  await new Promise((r) => setTimeout(r, pollMs));
372
379
  }
373
380
  }
374
- async function handleRemove(command, ctx, run, config) {
381
+ async function handleRemove(command, ctx, run, config, registryDir) {
375
382
  const verboseLog = makeVerboseLog(run.verbose);
376
383
  const removeHere = command.branch === undefined;
377
- const registry = readSlots(ctx.mainWorktree, config.registryDir);
384
+ const registry = readSlots(ctx.mainWorktree, registryDir);
378
385
  const target = resolveRemoveTarget(command, ctx, registry);
379
386
  // Refuse to remove while the detached finalize is still writing to slots.json / workspace-setup.log:
380
387
  // racing the two corrupts the registry and leaves the worktree directory orphaned.
@@ -390,11 +397,11 @@ async function handleRemove(command, ctx, run, config) {
390
397
  if (!existsSync(target.worktreePath)) {
391
398
  console.warn(`Warning: Worktree directory ${target.worktreePath} not found. Cleaning up registry only.`);
392
399
  delete registry.slots[target.slotPort];
393
- writeSlots(ctx.mainWorktree, config.registryDir, registry);
400
+ writeSlots(ctx.mainWorktree, registryDir, registry);
394
401
  console.log(`Removed registry entry for branch "${target.branch}" (slot ${target.slotPort}${ownerSuffix}).`);
395
402
  return;
396
403
  }
397
- const targetEntry = findOwnEntry(ctx.mainWorktree, config.registryDir, target.worktreePath);
404
+ const targetEntry = findOwnEntry(ctx.mainWorktree, registryDir, target.worktreePath);
398
405
  if (targetEntry) {
399
406
  stopTargetDevServer(config.devServerScript, target.worktreePath, verboseLog);
400
407
  }
@@ -409,8 +416,8 @@ async function handleRemove(command, ctx, run, config) {
409
416
  });
410
417
  }
411
418
  delete registry.slots[target.slotPort];
412
- writeSlots(ctx.mainWorktree, config.registryDir, registry);
413
- removeDevServerEntryByWorktree(ctx.mainWorktree, config.registryDir, target.worktreePath);
419
+ writeSlots(ctx.mainWorktree, registryDir, registry);
420
+ removeDevServerEntryByWorktree(ctx.mainWorktree, registryDir, target.worktreePath);
414
421
  if (removeHere) {
415
422
  process.chdir(ctx.mainWorktree);
416
423
  }
@@ -421,17 +428,17 @@ async function handleRemove(command, ctx, run, config) {
421
428
  console.log(`Now run: cd ${ctx.mainWorktree}`);
422
429
  }
423
430
  }
424
- function handleSetOwnerMode(command, ctx, config) {
431
+ function handleSetOwnerMode(command, ctx, registryDir) {
425
432
  const newOwner = command.name;
426
433
  const { slotPort } = handleSetOwner({
427
434
  newOwner,
428
435
  currentWorktree: ctx.currentWorktree,
429
436
  mainWorktree: ctx.mainWorktree,
430
- registryDir: config.registryDir,
437
+ registryDir,
431
438
  isMainWorktree: ctx.isMainWorktree,
432
439
  });
433
440
  // Propagate to dev-servers.json entries for this worktree.
434
- const devServersPath = join(ctx.mainWorktree, config.registryDir, "dev-servers.json");
441
+ const devServersPath = join(ctx.mainWorktree, registryDir, "dev-servers.json");
435
442
  if (existsSync(devServersPath)) {
436
443
  const data = JSON.parse(readFileSync(devServersPath, "utf-8"));
437
444
  let changed = false;
@@ -452,6 +459,56 @@ function handleSetOwnerMode(command, ctx, config) {
452
459
  }
453
460
  console.log(`Owner for slot ${slotPort}: ${newOwner ?? "(none)"}`);
454
461
  }
462
+ /** Transitional (0.16 only): merge a pre-0.16 registry into `${runtimeDir}/shared-registry`. */
463
+ function handleMigrate(command, config, newRel) {
464
+ const ctx = detectWorktree();
465
+ const oldRel = command.oldRegistryDir;
466
+ const oldAbs = join(ctx.mainWorktree, oldRel);
467
+ if (resolve(oldAbs) === resolve(join(ctx.mainWorktree, newRel))) {
468
+ console.log(`Registry already at ${newRel}; relinking worktrees.`);
469
+ relinkWorktrees(readSlots(ctx.mainWorktree, newRel), ctx.mainWorktree, config.runtimeDir);
470
+ return;
471
+ }
472
+ refuseUnlessOldRegistry(oldAbs, ctx.mainWorktree);
473
+ const mergedSlots = mergeSlots(readSlots(ctx.mainWorktree, oldRel), readSlots(ctx.mainWorktree, newRel));
474
+ writeSlots(ctx.mainWorktree, newRel, mergedSlots);
475
+ const mergedDevServers = mergeDevServers(readDevServers(ctx.mainWorktree, oldRel), readDevServers(ctx.mainWorktree, newRel));
476
+ writeDevServers(ctx.mainWorktree, newRel, mergedDevServers);
477
+ rmSync(oldAbs, { recursive: true, force: true });
478
+ const relinked = relinkWorktrees(mergedSlots, ctx.mainWorktree, config.runtimeDir);
479
+ console.log(`Migrated ${oldRel} → ${newRel}: ${Object.keys(mergedSlots.slots).length} slot(s), ` +
480
+ `${mergedDevServers.servers.length} dev-server(s); ${relinked} symlink(s) recreated.`);
481
+ }
482
+ /** `oldAbs` is recursively deleted after the merge — refuse anything that isn't clearly a registry. */
483
+ function refuseUnlessOldRegistry(oldAbs, mainWorktree) {
484
+ const fromMain = relative(mainWorktree, resolve(oldAbs));
485
+ if (fromMain.startsWith("..") || isAbsolute(fromMain)) {
486
+ console.error(`Error: the old registry must be inside the main worktree; got ${oldAbs}.`);
487
+ process.exit(1);
488
+ }
489
+ if (!existsSync(oldAbs)) {
490
+ console.error(`Error: nothing to migrate at ${oldAbs}.`);
491
+ process.exit(1);
492
+ }
493
+ if (!existsSync(join(oldAbs, "slots.json")) && !existsSync(join(oldAbs, "dev-servers.json"))) {
494
+ console.error(`Error: ${oldAbs} does not look like a registry (no slots.json or dev-servers.json).`);
495
+ process.exit(1);
496
+ }
497
+ }
498
+ function relinkWorktrees(slots, mainWorktree, runtimeDir) {
499
+ let count = 0;
500
+ for (const entry of Object.values(slots.slots)) {
501
+ if (entry.main)
502
+ continue;
503
+ if (!existsSync(entry.worktree)) {
504
+ console.warn(`Warning: worktree ${entry.worktree} not found; skipping symlink.`);
505
+ continue;
506
+ }
507
+ linkSharedRegistry({ currentWorktree: entry.worktree, mainWorktree, isMainWorktree: false }, runtimeDir, console.log, { force: true });
508
+ ++count;
509
+ }
510
+ return count;
511
+ }
455
512
  function ensureWorktree(command, ctx, run, dirNameFn) {
456
513
  if (command.branch === undefined)
457
514
  return ctx;
@@ -476,6 +533,36 @@ function linkSharedDirectories(ctx, dirs, log) {
476
533
  }
477
534
  }
478
535
  }
536
+ /**
537
+ * Symlinks the linked worktree's `${runtimeDir}/shared-registry` to the main worktree's, so the
538
+ * cwd-relative registry read in `resolveCurrentSlot` reaches main. `runtimeDir` is per-worktree and
539
+ * not in `sharedDirs`, so this is distinct from {@link linkSharedDirectories}.
540
+ */
541
+ function linkSharedRegistry(ctx, runtimeDir, log, opts) {
542
+ if (ctx.isMainWorktree)
543
+ return;
544
+ const mainDir = join(ctx.mainWorktree, runtimeDir, REGISTRY_SUBDIR);
545
+ mkdirSync(mainDir, { recursive: true });
546
+ const runtimeRoot = join(ctx.currentWorktree, runtimeDir);
547
+ mkdirSync(runtimeRoot, { recursive: true });
548
+ const link = join(runtimeRoot, REGISTRY_SUBDIR);
549
+ // lstat, not existsSync: existsSync follows symlinks, so a broken one would read as absent and
550
+ // make symlinkSync throw EEXIST. A broken symlink is recreated even without `force`.
551
+ const linkStat = lstatSync(link, { throwIfNoEntry: false });
552
+ if (linkStat) {
553
+ if (!linkStat.isSymbolicLink()) {
554
+ log("Skipped shared-registry symlink (a real directory exists here).");
555
+ return;
556
+ }
557
+ if (!opts?.force && existsSync(link)) {
558
+ log("Skipped shared-registry symlink (already exists).");
559
+ return;
560
+ }
561
+ rmSync(link);
562
+ }
563
+ symlinkSync(relative(runtimeRoot, mainDir), link);
564
+ log("Created shared-registry symlink → main worktree.");
565
+ }
479
566
  function generateConfigFiles(ctx, entries, slot, ports, force, log) {
480
567
  for (const entry of entries) {
481
568
  const patchCtx = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paleo/workspace",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Run multiple git-worktree dev environments side by side.",
5
5
  "keywords": [
6
6
  "workspace",