@paleo/workspace 0.15.1 → 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 +1 -3
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +13 -1
- package/dist/dev-server.d.ts +7 -6
- package/dist/dev-server.js +67 -47
- package/dist/dev-servers-registry.d.ts +2 -0
- package/dist/dev-servers-registry.js +11 -0
- package/dist/helpers.d.ts +7 -2
- package/dist/helpers.js +15 -10
- package/dist/index.d.ts +1 -1
- package/dist/slots.d.ts +9 -0
- package/dist/slots.js +17 -0
- package/dist/workspace.d.ts +26 -10
- package/dist/workspace.js +151 -55
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -27,7 +27,7 @@ The agent reads the skill, adapts the reference scripts to your stack, installs
|
|
|
27
27
|
|
|
28
28
|
```sh
|
|
29
29
|
npm run workspace -- setup feat/42 -c # new branch + worktree + isolated env
|
|
30
|
-
npm run dev # foreground: stream logs
|
|
30
|
+
npm run dev # foreground: stream logs, CTRL+C stops; attaches if already running
|
|
31
31
|
npm run dev -- up # start in the background (no-op if already running here)
|
|
32
32
|
npm run dev -- up --restart # stop the dev-server in this worktree if running, then start fresh
|
|
33
33
|
npm run dev -- up --evict # if devLimit is reached, evict the oldest dev-server and start
|
|
@@ -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
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.`);
|
|
@@ -287,7 +299,7 @@ export function printDevHelp() {
|
|
|
287
299
|
"",
|
|
288
300
|
"Commands:",
|
|
289
301
|
" dev Start in the foreground, streaming logs from startup; CTRL+C stops it.",
|
|
290
|
-
" If one is already running here, attach to its logs instead",
|
|
302
|
+
" If one is already running here, attach to its logs instead.",
|
|
291
303
|
" dev up Start in the background and return once ready.",
|
|
292
304
|
" dev restart Stop this worktree's dev-server if running, then start in the background.",
|
|
293
305
|
" dev down [--all] Stop this worktree's dev-server, or every dev-server with --all.",
|
package/dist/dev-server.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { StartupError } from "./errors.js";
|
|
1
2
|
import type { CallbackServer, ServerContext, ServerDescriptor, SpawnServer } from "./server-descriptor.js";
|
|
2
3
|
import { type ResolvedSlot, type SlotEntry } from "./slots.js";
|
|
3
4
|
export type { CallbackServer, ServerContext, ServerDescriptor, SpawnServer };
|
|
@@ -7,12 +8,6 @@ export interface DevServerConfig {
|
|
|
7
8
|
basePort: number;
|
|
8
9
|
/** Per-worktree runtime directory, relative to the worktree root (e.g. `.local-wt`). */
|
|
9
10
|
runtimeDir: string;
|
|
10
|
-
/**
|
|
11
|
-
* Shared registry directory, relative to a worktree root (e.g. `.local/_workspace-registry`).
|
|
12
|
-
* Holds `slots.json` and `dev-servers.json`. Must resolve to the same physical directory
|
|
13
|
-
* across linked worktrees — typically via a symlink (e.g. `.local`).
|
|
14
|
-
*/
|
|
15
|
-
registryDir: string;
|
|
16
11
|
/** Maximum concurrent dev-servers across all worktrees. Omit for no limit. */
|
|
17
12
|
devLimit?: number;
|
|
18
13
|
/** One entry per server to start. Started in array order; stopped in reverse order. */
|
|
@@ -33,6 +28,12 @@ export interface DevServerSummaryContext {
|
|
|
33
28
|
}[];
|
|
34
29
|
}
|
|
35
30
|
export declare function runDevServer(config: DevServerConfig): Promise<void>;
|
|
31
|
+
/**
|
|
32
|
+
* Wraps an error thrown by a callback server's `start()` into a {@link StartupError}, so a failing
|
|
33
|
+
* callback (e.g. `docker compose up -d`) gets the same clean rollback-and-report path as a spawn
|
|
34
|
+
* server instead of surfacing as a raw unhandled-rejection stack trace.
|
|
35
|
+
*/
|
|
36
|
+
export declare function toCallbackStartupError(name: string, err: unknown): StartupError;
|
|
36
37
|
/**
|
|
37
38
|
* Poll the foreground's own spawn PIDs; when none remain alive, the servers were stopped from
|
|
38
39
|
* outside this process (`dev down`, `down --all`, eviction, or a manual kill). Fires `onStopped`
|
package/dist/dev-server.js
CHANGED
|
@@ -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,
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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(
|
|
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(
|
|
139
|
-
const entry = findOwnEntry(mainWorktree,
|
|
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(
|
|
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
|
}
|
|
@@ -207,6 +212,16 @@ async function spawnWithRollback(config, ctx, state, isAborted = () => false, on
|
|
|
207
212
|
}
|
|
208
213
|
return !isAborted();
|
|
209
214
|
}
|
|
215
|
+
/**
|
|
216
|
+
* Wraps an error thrown by a callback server's `start()` into a {@link StartupError}, so a failing
|
|
217
|
+
* callback (e.g. `docker compose up -d`) gets the same clean rollback-and-report path as a spawn
|
|
218
|
+
* server instead of surfacing as a raw unhandled-rejection stack trace.
|
|
219
|
+
*/
|
|
220
|
+
export function toCallbackStartupError(name, err) {
|
|
221
|
+
if (err instanceof StartupError)
|
|
222
|
+
return err;
|
|
223
|
+
return new StartupError(name, err instanceof Error ? err.message : String(err));
|
|
224
|
+
}
|
|
210
225
|
async function spawnAndAwait(config, ctx, state, onSpawned) {
|
|
211
226
|
for (const server of config.servers) {
|
|
212
227
|
console.log(`Starting ${server.name} dev server...`);
|
|
@@ -214,7 +229,12 @@ async function spawnAndAwait(config, ctx, state, onSpawned) {
|
|
|
214
229
|
state.spawnPids[server.name] = spawnServer(server, config.runtimeDir, ctx.cwd);
|
|
215
230
|
}
|
|
216
231
|
else {
|
|
217
|
-
|
|
232
|
+
try {
|
|
233
|
+
await server.start(ctx);
|
|
234
|
+
}
|
|
235
|
+
catch (err) {
|
|
236
|
+
throw toCallbackStartupError(server.name, err);
|
|
237
|
+
}
|
|
218
238
|
state.startedCallbacks.push(server);
|
|
219
239
|
}
|
|
220
240
|
}
|
|
@@ -229,8 +249,8 @@ async function spawnAndAwait(config, ctx, state, onSpawned) {
|
|
|
229
249
|
const pollPids = spawnEntries.map((s) => state.spawnPids[s.name]);
|
|
230
250
|
await awaitAllReady(pollables, pollPids);
|
|
231
251
|
}
|
|
232
|
-
function registerStartedServer(config, mainWorktree, spawnPids) {
|
|
233
|
-
const slot = resolveCurrentSlot(config.basePort,
|
|
252
|
+
function registerStartedServer(config, mainWorktree, registryDir, spawnPids) {
|
|
253
|
+
const slot = resolveCurrentSlot(config.basePort, registryDir);
|
|
234
254
|
const devEntry = {
|
|
235
255
|
slot: slot.slot,
|
|
236
256
|
worktree: slot.worktree,
|
|
@@ -240,7 +260,7 @@ function registerStartedServer(config, mainWorktree, spawnPids) {
|
|
|
240
260
|
};
|
|
241
261
|
if (slot.main)
|
|
242
262
|
devEntry.main = true;
|
|
243
|
-
registerDevServer(mainWorktree,
|
|
263
|
+
registerDevServer(mainWorktree, registryDir, devEntry);
|
|
244
264
|
return slot;
|
|
245
265
|
}
|
|
246
266
|
function printStartSummary(config, slot, spawnPids) {
|
|
@@ -367,9 +387,9 @@ export function buildWorktreeReadyMessage(input) {
|
|
|
367
387
|
"Re-run `workspace setup` to retry the finalize.",
|
|
368
388
|
};
|
|
369
389
|
}
|
|
370
|
-
function checkWorktreeReady(config, mainWorktree, cwd) {
|
|
371
|
-
const slot = resolveCurrentSlot(config.basePort,
|
|
372
|
-
const entry = readSlots(mainWorktree,
|
|
390
|
+
function checkWorktreeReady(config, mainWorktree, registryDir, cwd) {
|
|
391
|
+
const slot = resolveCurrentSlot(config.basePort, registryDir);
|
|
392
|
+
const entry = readSlots(mainWorktree, registryDir).slots[String(slot.slot)];
|
|
373
393
|
const result = buildWorktreeReadyMessage({
|
|
374
394
|
slotPort: slot.slot,
|
|
375
395
|
worktreePath: cwd,
|
|
@@ -387,8 +407,8 @@ function checkWorktreeReady(config, mainWorktree, cwd) {
|
|
|
387
407
|
* normal start path can proceed, or print a friendly notice and return `true` to short-circuit.
|
|
388
408
|
* Returns `true` when the caller should exit cleanly without starting.
|
|
389
409
|
*/
|
|
390
|
-
async function handleAlreadyRunning(config, mainWorktree, ctx, restart) {
|
|
391
|
-
const entry = findOwnEntry(mainWorktree,
|
|
410
|
+
async function handleAlreadyRunning(config, mainWorktree, registryDir, ctx, restart) {
|
|
411
|
+
const entry = findOwnEntry(mainWorktree, registryDir, ctx.cwd);
|
|
392
412
|
if (!entry)
|
|
393
413
|
return false;
|
|
394
414
|
const livePids = Object.entries(entry.pids).filter(([, pid]) => isProcessAlive(pid));
|
|
@@ -396,7 +416,7 @@ async function handleAlreadyRunning(config, mainWorktree, ctx, restart) {
|
|
|
396
416
|
return false;
|
|
397
417
|
if (restart) {
|
|
398
418
|
console.log("Restarting dev-server in this worktree...");
|
|
399
|
-
await stopLocal(config, mainWorktree);
|
|
419
|
+
await stopLocal(config, mainWorktree, registryDir);
|
|
400
420
|
return false;
|
|
401
421
|
}
|
|
402
422
|
const pidList = livePids.map(([name, pid]) => `${name}=${pid}`).join(", ");
|
|
@@ -407,11 +427,11 @@ async function handleAlreadyRunning(config, mainWorktree, ctx, restart) {
|
|
|
407
427
|
// TOCTOU: the cap check and the subsequent register are not atomic. Two concurrent `dev up --evict`
|
|
408
428
|
// from different worktrees can both pass the cap check and both register, exceeding the limit by
|
|
409
429
|
// one. Accepted: the race window is narrow and the consequence is bounded (one extra dev-server).
|
|
410
|
-
async function enforceCap(config, mainWorktree, evict) {
|
|
430
|
+
async function enforceCap(config, mainWorktree, registryDir, evict) {
|
|
411
431
|
const limit = config.devLimit;
|
|
412
432
|
if (limit === undefined)
|
|
413
433
|
return;
|
|
414
|
-
const active = pruneAndPersist(mainWorktree,
|
|
434
|
+
const active = pruneAndPersist(mainWorktree, registryDir).servers;
|
|
415
435
|
if (active.length < limit)
|
|
416
436
|
return;
|
|
417
437
|
if (!evict) {
|
|
@@ -425,7 +445,7 @@ async function enforceCap(config, mainWorktree, evict) {
|
|
|
425
445
|
console.log(`Evicting ${toEvict} dev-server(s) to make room (cap ${limit}).`);
|
|
426
446
|
const evicted = await evictOldest({
|
|
427
447
|
mainWorktree,
|
|
428
|
-
registryDir
|
|
448
|
+
registryDir,
|
|
429
449
|
callbackServers: callbackServersOf(config),
|
|
430
450
|
count: toEvict,
|
|
431
451
|
});
|
|
@@ -467,8 +487,8 @@ async function checkPortsFree(servers, cwd) {
|
|
|
467
487
|
process.exit(1);
|
|
468
488
|
}
|
|
469
489
|
}
|
|
470
|
-
function checkNoLocalRegistryConflict(
|
|
471
|
-
const entry = findOwnEntry(mainWorktree,
|
|
490
|
+
function checkNoLocalRegistryConflict(mainWorktree, registryDir, cwd) {
|
|
491
|
+
const entry = findOwnEntry(mainWorktree, registryDir, cwd);
|
|
472
492
|
if (!entry)
|
|
473
493
|
return;
|
|
474
494
|
for (const [name, pid] of Object.entries(entry.pids)) {
|
|
@@ -478,11 +498,11 @@ function checkNoLocalRegistryConflict(config, mainWorktree, cwd) {
|
|
|
478
498
|
}
|
|
479
499
|
}
|
|
480
500
|
// Stale entry — drop it so registration overwrites cleanly.
|
|
481
|
-
removeDevServerEntryByWorktree(mainWorktree,
|
|
501
|
+
removeDevServerEntryByWorktree(mainWorktree, registryDir, cwd);
|
|
482
502
|
}
|
|
483
|
-
async function stopLocal(config, mainWorktree) {
|
|
503
|
+
async function stopLocal(config, mainWorktree, registryDir) {
|
|
484
504
|
const ctx = { cwd: process.cwd() };
|
|
485
|
-
const entry = findOwnEntry(mainWorktree,
|
|
505
|
+
const entry = findOwnEntry(mainWorktree, registryDir, ctx.cwd);
|
|
486
506
|
if (!entry) {
|
|
487
507
|
console.log("No dev-server running in this worktree.");
|
|
488
508
|
await sweepStalePorts(config.servers, ctx.cwd);
|
|
@@ -504,7 +524,7 @@ async function stopLocal(config, mainWorktree) {
|
|
|
504
524
|
console.error(` Failed to stop ${server.name}: ${err.message}`);
|
|
505
525
|
}
|
|
506
526
|
}
|
|
507
|
-
unregisterDevServer(mainWorktree,
|
|
527
|
+
unregisterDevServer(mainWorktree, registryDir, ctx.cwd);
|
|
508
528
|
await sweepStalePorts(config.servers, ctx.cwd);
|
|
509
529
|
}
|
|
510
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/helpers.d.ts
CHANGED
|
@@ -14,10 +14,15 @@ export declare function readPortFromEnvFile(file: string, varName: string): numb
|
|
|
14
14
|
export declare function readPortFromJsonFile(file: string, jsonPath: string): number;
|
|
15
15
|
export interface CopyAndPatchCtx {
|
|
16
16
|
currentWorktree: string;
|
|
17
|
-
mainWorktree: string;
|
|
18
17
|
log: (msg: string) => void;
|
|
19
18
|
}
|
|
20
|
-
|
|
19
|
+
/** Resolved initial-content source for {@link copyAndPatchFile}. `path` is absolute. */
|
|
20
|
+
export type ResolvedFileSource = {
|
|
21
|
+
path: string;
|
|
22
|
+
} | {
|
|
23
|
+
content: string;
|
|
24
|
+
};
|
|
25
|
+
export declare function copyAndPatchFile(ctx: CopyAndPatchCtx, relPath: string, source: ResolvedFileSource, patchFn: (content: string) => string, label: string, force: boolean, optional?: boolean): void;
|
|
21
26
|
/**
|
|
22
27
|
* Formats a millisecond duration as the two largest units among `d`/`h`/`m`/`s`.
|
|
23
28
|
* Drops the smaller unit when zero (`5d` instead of `5d 0h`). Sub-second values
|
package/dist/helpers.js
CHANGED
|
@@ -67,24 +67,29 @@ export function readPortFromJsonFile(file, jsonPath) {
|
|
|
67
67
|
}
|
|
68
68
|
return toPort(String(cur), file);
|
|
69
69
|
}
|
|
70
|
-
export function copyAndPatchFile(ctx, relPath, patchFn, label, force, optional = false) {
|
|
70
|
+
export function copyAndPatchFile(ctx, relPath, source, patchFn, label, force, optional = false) {
|
|
71
71
|
const targetPath = join(ctx.currentWorktree, relPath);
|
|
72
|
-
const sourcePath = join(ctx.mainWorktree, relPath);
|
|
73
72
|
const alreadyExists = existsSync(targetPath);
|
|
74
73
|
if (alreadyExists && !force) {
|
|
75
74
|
ctx.log(`Skipped ${label} (already exists; use --force to overwrite).`);
|
|
76
75
|
return;
|
|
77
76
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
77
|
+
let content;
|
|
78
|
+
if ("content" in source) {
|
|
79
|
+
content = source.content;
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
if (!existsSync(source.path)) {
|
|
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.");
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
ctx.log(`Warning: source ${source.path} not found, skipping (optional).`);
|
|
89
|
+
return;
|
|
83
90
|
}
|
|
84
|
-
|
|
85
|
-
return;
|
|
91
|
+
content = readFileSync(source.path, "utf-8");
|
|
86
92
|
}
|
|
87
|
-
const content = readFileSync(sourcePath, "utf-8");
|
|
88
93
|
const patched = patchFn(content);
|
|
89
94
|
mkdirSync(dirname(targetPath), { recursive: true });
|
|
90
95
|
writeFileSync(targetPath, patched);
|
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, PurgeContext, } from "./workspace.js";
|
|
4
|
+
export type { WorkspaceConfig, SetupContext, 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
|
@@ -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);
|
package/dist/workspace.d.ts
CHANGED
|
@@ -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
|
/**
|
|
@@ -121,16 +115,38 @@ export interface PurgeContext {
|
|
|
121
115
|
mainWorktree: string;
|
|
122
116
|
verbose: boolean;
|
|
123
117
|
}
|
|
124
|
-
/**
|
|
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. */
|
|
125
136
|
export interface ConfigFileEntry {
|
|
126
|
-
/** Path relative to the worktree root.
|
|
137
|
+
/** Path relative to the worktree root. Written to the current worktree. */
|
|
127
138
|
path: string;
|
|
139
|
+
/**
|
|
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;
|
|
128
144
|
/** Returns the patched content given the source content and the slot's ports. */
|
|
129
145
|
patch: (content: string, ctx: PatchContext) => string;
|
|
130
146
|
/**
|
|
131
|
-
* When `true`, a missing
|
|
147
|
+
* When `true`, a missing `{ path }` source logs a warning and skips the entry.
|
|
132
148
|
* Default: required (missing source aborts setup). Bootstrap the main worktree first via
|
|
133
|
-
* `workspace setup`, or seed sources in `preSetup`.
|
|
149
|
+
* `workspace setup`, or seed sources in `preSetup`. Ignored for `{ content }` sources.
|
|
134
150
|
*/
|
|
135
151
|
optional?: boolean;
|
|
136
152
|
}
|
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
|
-
import { copyAndPatchFile, formatDuration, setupLogPath } from "./helpers.js";
|
|
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(
|
|
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,
|
|
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
|
|
79
|
+
registryDir,
|
|
74
80
|
scheme,
|
|
75
81
|
});
|
|
76
82
|
const setupCtx = ensureWorktree(command, ctx, run, config.worktreeDirName);
|
|
77
|
-
refuseIfFinalizePending(setupCtx,
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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(
|
|
299
|
+
function runList(registryDir) {
|
|
293
300
|
const ctx = detectWorktree();
|
|
294
|
-
const entries = Object.entries(readSlots(ctx.mainWorktree,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
413
|
-
removeDevServerEntryByWorktree(ctx.mainWorktree,
|
|
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,
|
|
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
|
|
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,
|
|
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,16 +533,55 @@ 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 = {
|
|
482
569
|
slot,
|
|
483
570
|
ports,
|
|
484
571
|
mainWorktree: ctx.mainWorktree,
|
|
485
572
|
currentWorktree: ctx.currentWorktree,
|
|
486
|
-
}
|
|
573
|
+
};
|
|
574
|
+
copyAndPatchFile({ currentWorktree: ctx.currentWorktree, log }, entry.path, resolveConfigSource(entry, patchCtx), (content) => entry.patch(content, patchCtx), entry.path, force, entry.optional ?? false);
|
|
487
575
|
}
|
|
488
576
|
}
|
|
577
|
+
function resolveConfigSource(entry, ctx) {
|
|
578
|
+
const spec = typeof entry.source === "function" ? entry.source(ctx) : entry.source;
|
|
579
|
+
if (spec === undefined)
|
|
580
|
+
return { path: join(ctx.mainWorktree, entry.path) };
|
|
581
|
+
if ("content" in spec)
|
|
582
|
+
return { content: spec.content };
|
|
583
|
+
return { path: join(ctx.mainWorktree, spec.path) };
|
|
584
|
+
}
|
|
489
585
|
function resolveRemoveTarget(command, ctx, registry) {
|
|
490
586
|
if (command.branch === undefined) {
|
|
491
587
|
if (ctx.isMainWorktree) {
|