@paleo/workspace 0.11.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 +113 -0
- package/dist/cli.d.ts +47 -0
- package/dist/cli.js +235 -0
- package/dist/dev-server.d.ts +52 -0
- package/dist/dev-server.js +349 -0
- package/dist/dev-servers-registry.d.ts +42 -0
- package/dist/dev-servers-registry.js +140 -0
- package/dist/errors.d.ts +10 -0
- package/dist/errors.js +18 -0
- package/dist/helpers.d.ts +31 -0
- package/dist/helpers.js +141 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +6 -0
- package/dist/log-polling.d.ts +17 -0
- package/dist/log-polling.js +41 -0
- package/dist/port-holder.d.ts +25 -0
- package/dist/port-holder.js +147 -0
- package/dist/ports.d.ts +16 -0
- package/dist/ports.js +37 -0
- package/dist/process-control.d.ts +4 -0
- package/dist/process-control.js +41 -0
- package/dist/server-descriptor.d.ts +45 -0
- package/dist/server-descriptor.js +1 -0
- package/dist/slots.d.ts +63 -0
- package/dist/slots.js +164 -0
- package/dist/workspace.d.ts +144 -0
- package/dist/workspace.js +550 -0
- package/dist/worktree.d.ts +28 -0
- package/dist/worktree.js +124 -0
- package/package.json +51 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { closeSync, mkdirSync, openSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { parseDevServerArgs, printDevServerHelp, validateDevServerFlags, } from "./cli.js";
|
|
5
|
+
import { evictOldest, findOwnEntry, listDevServers, printActiveServers, pruneAndPersist, registerDevServer, removeDevServerEntryByWorktree, stopAllRegistered, unregisterDevServer, } from "./dev-servers-registry.js";
|
|
6
|
+
import { ConfigError, StartupError } from "./errors.js";
|
|
7
|
+
import { detectCommonJsError, formatDuration } from "./helpers.js";
|
|
8
|
+
import { awaitAllReady, handleStartupFailure } from "./log-polling.js";
|
|
9
|
+
import { canonicalCwd, detectPortConflicts, sweepStalePorts, waitForPortsFree, } from "./port-holder.js";
|
|
10
|
+
import { isProcessAlive, stopProcessGroup } from "./process-control.js";
|
|
11
|
+
import { readSlots, resolveCurrentSlot } from "./slots.js";
|
|
12
|
+
import { detectWorktree, getWorktreeBranch } from "./worktree.js";
|
|
13
|
+
function logFileFor(runtimeDir, name) {
|
|
14
|
+
return join(runtimeDir, "logs", `${name}.log`);
|
|
15
|
+
}
|
|
16
|
+
export async function runDevServer(config) {
|
|
17
|
+
let args;
|
|
18
|
+
try {
|
|
19
|
+
args = parseDevServerArgs();
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
console.error(err.message);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
if (args.help) {
|
|
26
|
+
printDevServerHelp();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
validateDevServerFlags(args);
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
if (err instanceof ConfigError) {
|
|
34
|
+
console.error(err.message);
|
|
35
|
+
process.exit(err.exitCode);
|
|
36
|
+
}
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
const { mainWorktree } = detectWorktree();
|
|
40
|
+
if (args.list) {
|
|
41
|
+
listDevServers(mainWorktree, config.registryDir);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (args.stop && args.all) {
|
|
45
|
+
await stopAllRegistered({
|
|
46
|
+
mainWorktree,
|
|
47
|
+
registryDir: config.registryDir,
|
|
48
|
+
callbackServers: callbackServersOf(config),
|
|
49
|
+
});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (args.stop) {
|
|
53
|
+
await stopLocal(config, mainWorktree);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
await start(config, mainWorktree, {
|
|
57
|
+
evict: Boolean(args.evict),
|
|
58
|
+
restart: Boolean(args.restart),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
function callbackServersOf(config) {
|
|
62
|
+
return config.servers.filter((s) => s.kind === "callback");
|
|
63
|
+
}
|
|
64
|
+
async function start(config, mainWorktree, { evict, restart }) {
|
|
65
|
+
const ctx = { cwd: process.cwd() };
|
|
66
|
+
checkWorktreeReady(config, mainWorktree, ctx.cwd);
|
|
67
|
+
if (await handleAlreadyRunning(config, mainWorktree, ctx, restart))
|
|
68
|
+
return;
|
|
69
|
+
await enforceCap(config, mainWorktree, evict);
|
|
70
|
+
checkNoLocalRegistryConflict(config, mainWorktree, ctx.cwd);
|
|
71
|
+
await checkPortsFree(config.servers, ctx.cwd);
|
|
72
|
+
const spawnPids = {};
|
|
73
|
+
const startedCallbacks = [];
|
|
74
|
+
try {
|
|
75
|
+
for (const server of config.servers) {
|
|
76
|
+
console.log(`Starting ${server.name} dev server...`);
|
|
77
|
+
if (server.kind === "spawn") {
|
|
78
|
+
spawnPids[server.name] = spawnServer(server, config.runtimeDir, ctx.cwd);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
await server.start(ctx);
|
|
82
|
+
startedCallbacks.push(server);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const spawnEntries = config.servers.filter((s) => s.kind === "spawn");
|
|
86
|
+
const pollables = spawnEntries.map((s) => ({
|
|
87
|
+
name: s.name,
|
|
88
|
+
logFile: logFileFor(config.runtimeDir, s.name),
|
|
89
|
+
detectSuccess: s.detectSuccess,
|
|
90
|
+
detectError: s.detectError ?? detectCommonJsError,
|
|
91
|
+
}));
|
|
92
|
+
const pollPids = spawnEntries.map((s) => spawnPids[s.name]);
|
|
93
|
+
await awaitAllReady(pollables, pollPids);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
await rollbackStart(spawnPids, startedCallbacks, ctx);
|
|
97
|
+
if (err instanceof StartupError) {
|
|
98
|
+
handleStartupFailure(err);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
throw err;
|
|
102
|
+
}
|
|
103
|
+
const slot = resolveCurrentSlot(config.basePort, config.registryDir);
|
|
104
|
+
const devEntry = {
|
|
105
|
+
slot: slot.slot,
|
|
106
|
+
worktree: slot.worktree,
|
|
107
|
+
owner: slot.owner,
|
|
108
|
+
pids: spawnPids,
|
|
109
|
+
startedAt: new Date().toISOString(),
|
|
110
|
+
};
|
|
111
|
+
if (slot.main)
|
|
112
|
+
devEntry.main = true;
|
|
113
|
+
registerDevServer(mainWorktree, config.registryDir, devEntry);
|
|
114
|
+
const summaryServers = config.servers.map((server) => {
|
|
115
|
+
if (server.kind === "spawn") {
|
|
116
|
+
return { server, port: server.port, pid: spawnPids[server.name] };
|
|
117
|
+
}
|
|
118
|
+
return { server };
|
|
119
|
+
});
|
|
120
|
+
if (config.printSummary) {
|
|
121
|
+
console.log(config.printSummary({ slot, servers: summaryServers }));
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
defaultPrintSummary(slot, summaryServers, config.runtimeDir);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
async function rollbackStart(spawnPids, startedCallbacks, ctx) {
|
|
128
|
+
console.error("\nStopping dev servers...");
|
|
129
|
+
for (const pid of Object.values(spawnPids)) {
|
|
130
|
+
try {
|
|
131
|
+
await stopProcessGroup(pid);
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
console.error(` Failed to stop PID ${pid}: ${err.message}`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
for (const server of [...startedCallbacks].reverse()) {
|
|
138
|
+
console.log(`Stopping ${server.name}...`);
|
|
139
|
+
try {
|
|
140
|
+
await server.stop(ctx);
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
console.error(` Failed to stop ${server.name}: ${err.message}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Pure builder for the `dev:up` worktree-readiness gate. Returns `ok` when the slot is `ready`
|
|
149
|
+
* or absent (synthesized main); otherwise returns the user-facing error message.
|
|
150
|
+
*/
|
|
151
|
+
export function buildWorktreeReadyMessage(input) {
|
|
152
|
+
const { slotPort, worktreePath, runtimeDir, entry, now } = input;
|
|
153
|
+
if (!entry || entry.status === "ready")
|
|
154
|
+
return { ok: true };
|
|
155
|
+
const logPath = join(worktreePath, runtimeDir, "wt-setup.log");
|
|
156
|
+
if (entry.status === "pending") {
|
|
157
|
+
const elapsed = formatDuration(now - Date.parse(entry.createdAt));
|
|
158
|
+
return {
|
|
159
|
+
ok: false,
|
|
160
|
+
message: `Error: Worktree setup is still in progress (slot ${slotPort}, started ${elapsed} ago).\n` +
|
|
161
|
+
`Tail: ${logPath}\n` +
|
|
162
|
+
"Run `workspace wait` to block until it finishes, or retry `dev:up` once ready.",
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
const failureAt = entry.failure?.at ?? entry.createdAt;
|
|
166
|
+
const elapsed = formatDuration(now - Date.parse(failureAt));
|
|
167
|
+
const reason = entry.failure?.message ?? "(no message)";
|
|
168
|
+
return {
|
|
169
|
+
ok: false,
|
|
170
|
+
message: `Error: Worktree setup failed (slot ${slotPort}, ${elapsed} ago): ${reason}\n` +
|
|
171
|
+
`Tail: ${logPath}\n` +
|
|
172
|
+
"Re-run `workspace setup` to retry the finalize.",
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
function checkWorktreeReady(config, mainWorktree, cwd) {
|
|
176
|
+
const slot = resolveCurrentSlot(config.basePort, config.registryDir);
|
|
177
|
+
const entry = readSlots(mainWorktree, config.registryDir).slots[String(slot.slot)];
|
|
178
|
+
const result = buildWorktreeReadyMessage({
|
|
179
|
+
slotPort: slot.slot,
|
|
180
|
+
worktreePath: cwd,
|
|
181
|
+
runtimeDir: config.runtimeDir,
|
|
182
|
+
entry,
|
|
183
|
+
now: Date.now(),
|
|
184
|
+
});
|
|
185
|
+
if (result.ok)
|
|
186
|
+
return;
|
|
187
|
+
console.error(result.message);
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* If a dev-server is already running in this worktree, either stop it (when `restart`) so the
|
|
192
|
+
* normal start path can proceed, or print a friendly notice and return `true` to short-circuit.
|
|
193
|
+
* Returns `true` when the caller should exit cleanly without starting.
|
|
194
|
+
*/
|
|
195
|
+
async function handleAlreadyRunning(config, mainWorktree, ctx, restart) {
|
|
196
|
+
const entry = findOwnEntry(mainWorktree, config.registryDir, ctx.cwd);
|
|
197
|
+
if (!entry)
|
|
198
|
+
return false;
|
|
199
|
+
const livePids = Object.entries(entry.pids).filter(([, pid]) => isProcessAlive(pid));
|
|
200
|
+
if (livePids.length === 0)
|
|
201
|
+
return false;
|
|
202
|
+
if (restart) {
|
|
203
|
+
console.log("Restarting dev-server in this worktree...");
|
|
204
|
+
await stopLocal(config, mainWorktree);
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
const pidList = livePids.map(([name, pid]) => `${name}=${pid}`).join(", ");
|
|
208
|
+
console.log(`dev-server already running for this worktree (slot ${entry.slot}, pids: ${pidList}).`);
|
|
209
|
+
console.log("Run `dev:down` to stop it, or re-run with --restart to restart.");
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
// TOCTOU: the cap check and the subsequent register are not atomic. Two concurrent `dev:up --evict`
|
|
213
|
+
// from different worktrees can both pass the cap check and both register, exceeding the limit by
|
|
214
|
+
// one. Accepted: the race window is narrow and the consequence is bounded (one extra dev-server).
|
|
215
|
+
async function enforceCap(config, mainWorktree, evict) {
|
|
216
|
+
const limit = config.devLimit;
|
|
217
|
+
if (limit === undefined)
|
|
218
|
+
return;
|
|
219
|
+
const active = pruneAndPersist(mainWorktree, config.registryDir).servers;
|
|
220
|
+
if (active.length < limit)
|
|
221
|
+
return;
|
|
222
|
+
if (!evict) {
|
|
223
|
+
console.error(`Error: dev-server cap reached (${active.length}/${limit}). Active dev-servers:`);
|
|
224
|
+
printActiveServers(active);
|
|
225
|
+
console.error("Run `dev:down` in another worktree, or `dev:down --all`.");
|
|
226
|
+
console.error("Re-run with --evict to evict the oldest.");
|
|
227
|
+
process.exit(1);
|
|
228
|
+
}
|
|
229
|
+
const toEvict = active.length - limit + 1;
|
|
230
|
+
console.log(`Evicting ${toEvict} dev-server(s) to make room (cap ${limit}).`);
|
|
231
|
+
const evicted = await evictOldest({
|
|
232
|
+
mainWorktree,
|
|
233
|
+
registryDir: config.registryDir,
|
|
234
|
+
callbackServers: callbackServersOf(config),
|
|
235
|
+
count: toEvict,
|
|
236
|
+
});
|
|
237
|
+
for (const entry of evicted) {
|
|
238
|
+
const ownerPart = entry.owner ? `, owner=${entry.owner}` : "";
|
|
239
|
+
const branch = getWorktreeBranch(entry.worktree) ?? "(detached)";
|
|
240
|
+
console.log(`Evicted slot ${entry.slot} (branch=${branch}${ownerPart}, startedAt=${entry.startedAt}).`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
async function checkPortsFree(servers, cwd) {
|
|
244
|
+
const conflicts = await detectPortConflicts(servers, canonicalCwd(cwd));
|
|
245
|
+
const foreign = conflicts.filter((c) => c.kind === "foreign");
|
|
246
|
+
if (foreign.length > 0) {
|
|
247
|
+
for (const c of foreign) {
|
|
248
|
+
const info = c.holder
|
|
249
|
+
? ` (PID ${c.holder.pid}: ${c.holder.cmd}${c.holder.cwd ? `, cwd ${c.holder.cwd}` : ""})`
|
|
250
|
+
: "";
|
|
251
|
+
console.error(`Error: Port ${c.server.port} (${c.server.name}) is already in use${info}.`);
|
|
252
|
+
}
|
|
253
|
+
process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
const ours = conflicts.filter((c) => c.kind === "ours");
|
|
256
|
+
if (ours.length === 0)
|
|
257
|
+
return;
|
|
258
|
+
for (const c of ours) {
|
|
259
|
+
console.warn(`Stale ${c.server.name} dev-server detected on port ${c.server.port} (PID ${c.holder.pid}: ${c.holder.cmd}). Cleaning up...`);
|
|
260
|
+
if (c.holder.pgid === undefined) {
|
|
261
|
+
console.error(` Cannot kill: pgid unknown for PID ${c.holder.pid}.`);
|
|
262
|
+
process.exit(1);
|
|
263
|
+
}
|
|
264
|
+
await stopProcessGroup(c.holder.pgid);
|
|
265
|
+
}
|
|
266
|
+
const stillBusy = await waitForPortsFree(ours.map((c) => c.server.port), 2000);
|
|
267
|
+
if (stillBusy.length > 0) {
|
|
268
|
+
for (const port of stillBusy) {
|
|
269
|
+
const server = ours.find((c) => c.server.port === port)?.server;
|
|
270
|
+
console.error(`Error: Port ${port}${server ? ` (${server.name})` : ""} still in use after cleanup attempt.`);
|
|
271
|
+
}
|
|
272
|
+
process.exit(1);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
function checkNoLocalRegistryConflict(config, mainWorktree, cwd) {
|
|
276
|
+
const entry = findOwnEntry(mainWorktree, config.registryDir, cwd);
|
|
277
|
+
if (!entry)
|
|
278
|
+
return;
|
|
279
|
+
for (const [name, pid] of Object.entries(entry.pids)) {
|
|
280
|
+
if (isProcessAlive(pid)) {
|
|
281
|
+
console.error(`Error: ${name} is already running (PID ${pid}).`);
|
|
282
|
+
process.exit(1);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// Stale entry — drop it so registration overwrites cleanly.
|
|
286
|
+
removeDevServerEntryByWorktree(mainWorktree, config.registryDir, cwd);
|
|
287
|
+
}
|
|
288
|
+
async function stopLocal(config, mainWorktree) {
|
|
289
|
+
const ctx = { cwd: process.cwd() };
|
|
290
|
+
const entry = findOwnEntry(mainWorktree, config.registryDir, ctx.cwd);
|
|
291
|
+
if (!entry) {
|
|
292
|
+
console.log("No dev-server running in this worktree.");
|
|
293
|
+
await sweepStalePorts(config.servers, ctx.cwd);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
for (const [name, pid] of Object.entries(entry.pids)) {
|
|
297
|
+
if (!isProcessAlive(pid))
|
|
298
|
+
continue;
|
|
299
|
+
console.log(`Stopping ${name} (PID ${pid})...`);
|
|
300
|
+
await stopProcessGroup(pid);
|
|
301
|
+
}
|
|
302
|
+
const callbacks = callbackServersOf(config);
|
|
303
|
+
for (const server of [...callbacks].reverse()) {
|
|
304
|
+
console.log(`Stopping ${server.name}...`);
|
|
305
|
+
try {
|
|
306
|
+
await server.stop(ctx);
|
|
307
|
+
}
|
|
308
|
+
catch (err) {
|
|
309
|
+
console.error(` Failed to stop ${server.name}: ${err.message}`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
unregisterDevServer(mainWorktree, config.registryDir, ctx.cwd);
|
|
313
|
+
await sweepStalePorts(config.servers, ctx.cwd);
|
|
314
|
+
}
|
|
315
|
+
function defaultPrintSummary(slot, servers, runtimeDir) {
|
|
316
|
+
console.log("\nDev servers started!");
|
|
317
|
+
const ownerSuffix = slot.owner ? `, owner ${slot.owner}` : "";
|
|
318
|
+
console.log(` Worktree: slot ${slot.slot}${ownerSuffix}`);
|
|
319
|
+
for (const { server, port, pid } of servers) {
|
|
320
|
+
if (server.kind === "spawn") {
|
|
321
|
+
const url = `http://localhost:${port}/`;
|
|
322
|
+
const logPath = join(process.cwd(), logFileFor(runtimeDir, server.name));
|
|
323
|
+
console.log(` ${server.name}: ${url} (PID ${pid})`);
|
|
324
|
+
console.log(` log: ${logPath}`);
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
console.log(` ${server.name}: ready`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
console.log("");
|
|
331
|
+
}
|
|
332
|
+
function spawnServer(server, runtimeDir, cwd) {
|
|
333
|
+
const logFile = logFileFor(runtimeDir, server.name);
|
|
334
|
+
mkdirSync(dirname(logFile), { recursive: true });
|
|
335
|
+
const logFd = openSync(logFile, "w");
|
|
336
|
+
const child = spawn(server.exec.command, server.exec.args, {
|
|
337
|
+
detached: true,
|
|
338
|
+
stdio: ["ignore", logFd, logFd],
|
|
339
|
+
cwd,
|
|
340
|
+
});
|
|
341
|
+
if (child.pid === undefined) {
|
|
342
|
+
closeSync(logFd);
|
|
343
|
+
console.error(`Error: failed to spawn ${server.name}.`);
|
|
344
|
+
process.exit(1);
|
|
345
|
+
}
|
|
346
|
+
child.unref();
|
|
347
|
+
closeSync(logFd);
|
|
348
|
+
return child.pid;
|
|
349
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { CallbackServer } from "./server-descriptor.js";
|
|
2
|
+
export interface DevServerEntry {
|
|
3
|
+
slot: number;
|
|
4
|
+
worktree: string;
|
|
5
|
+
owner?: string;
|
|
6
|
+
pids: Record<string, number>;
|
|
7
|
+
startedAt: string;
|
|
8
|
+
/** `true` for the main-worktree entry. */
|
|
9
|
+
main?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export interface DevServersData {
|
|
12
|
+
servers: DevServerEntry[];
|
|
13
|
+
}
|
|
14
|
+
export type IsAliveFn = (pid: number) => boolean;
|
|
15
|
+
export declare function listDevServers(mainWorktree: string, registryDir: string): void;
|
|
16
|
+
export declare function printActiveServers(active: DevServerEntry[]): void;
|
|
17
|
+
export interface StopAllInput {
|
|
18
|
+
mainWorktree: string;
|
|
19
|
+
registryDir: string;
|
|
20
|
+
/** Callback-managed servers from the current process's config. Their `stop()` is invoked for each
|
|
21
|
+
* victim with `ctx.cwd = entry.worktree`. */
|
|
22
|
+
callbackServers: CallbackServer[];
|
|
23
|
+
}
|
|
24
|
+
export declare function stopAllRegistered(input: StopAllInput): Promise<void>;
|
|
25
|
+
export interface EvictInput {
|
|
26
|
+
mainWorktree: string;
|
|
27
|
+
registryDir: string;
|
|
28
|
+
count: number;
|
|
29
|
+
callbackServers: CallbackServer[];
|
|
30
|
+
isAlive?: IsAliveFn;
|
|
31
|
+
stop?: (pid: number) => Promise<void>;
|
|
32
|
+
}
|
|
33
|
+
export declare function evictOldest(input: EvictInput): Promise<DevServerEntry[]>;
|
|
34
|
+
export declare function registerDevServer(mainWorktree: string, registryDir: string, entry: DevServerEntry): void;
|
|
35
|
+
export declare function unregisterDevServer(mainWorktree: string, registryDir: string, worktreePath: string): void;
|
|
36
|
+
export declare function removeDevServerEntryByWorktree(mainWorktree: string, registryDir: string, worktreePath: string): void;
|
|
37
|
+
/** Returns the entry whose worktree matches `worktreePath`, or `undefined`. Does not prune. */
|
|
38
|
+
export declare function findOwnEntry(mainWorktree: string, registryDir: string, worktreePath: string): DevServerEntry | undefined;
|
|
39
|
+
export declare function pruneAndPersist(mainWorktree: string, registryDir: string, isAlive?: IsAliveFn): DevServersData;
|
|
40
|
+
export declare function pruneDeadServers(data: DevServersData, isAlive?: IsAliveFn): DevServersData;
|
|
41
|
+
export declare function readDevServers(mainWorktree: string, registryDir: string): DevServersData;
|
|
42
|
+
export declare function writeDevServers(mainWorktree: string, registryDir: string, data: DevServersData): void;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { isProcessAlive, stopProcessGroup } from "./process-control.js";
|
|
4
|
+
import { getWorktreeBranch } from "./worktree.js";
|
|
5
|
+
const DEV_SERVERS_FILENAME = "dev-servers.json";
|
|
6
|
+
export function listDevServers(mainWorktree, registryDir) {
|
|
7
|
+
const data = pruneAndPersist(mainWorktree, registryDir);
|
|
8
|
+
if (data.servers.length === 0) {
|
|
9
|
+
console.log("No dev-servers running.");
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
const sorted = [...data.servers].sort((a, b) => a.slot - b.slot);
|
|
13
|
+
for (const entry of sorted) {
|
|
14
|
+
console.log(formatEntry(entry));
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function printActiveServers(active) {
|
|
18
|
+
const sorted = [...active].sort((a, b) => a.slot - b.slot);
|
|
19
|
+
for (const entry of sorted) {
|
|
20
|
+
process.stderr.write(`${formatEntry(entry)}\n`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export async function stopAllRegistered(input) {
|
|
24
|
+
const data = pruneAndPersist(input.mainWorktree, input.registryDir);
|
|
25
|
+
if (data.servers.length === 0) {
|
|
26
|
+
console.log("No dev-servers running.");
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
for (const entry of data.servers) {
|
|
30
|
+
const ownerSuffix = entry.owner ? `, owner=${entry.owner}` : "";
|
|
31
|
+
const branch = getWorktreeBranch(entry.worktree) ?? "(detached)";
|
|
32
|
+
console.log(`Stopping slot ${entry.slot} (${branch}${ownerSuffix})...`);
|
|
33
|
+
for (const [name, pid] of Object.entries(entry.pids)) {
|
|
34
|
+
if (!isProcessAlive(pid))
|
|
35
|
+
continue;
|
|
36
|
+
console.log(` ${name} (PID ${pid})`);
|
|
37
|
+
await stopProcessGroup(pid);
|
|
38
|
+
}
|
|
39
|
+
await stopCallbacksForVictim(input.callbackServers, entry.worktree);
|
|
40
|
+
}
|
|
41
|
+
writeDevServers(input.mainWorktree, input.registryDir, { servers: [] });
|
|
42
|
+
console.log(`Stopped ${data.servers.length} dev-server(s).`);
|
|
43
|
+
}
|
|
44
|
+
export async function evictOldest(input) {
|
|
45
|
+
const isAlive = input.isAlive ?? isProcessAlive;
|
|
46
|
+
const stop = input.stop ?? stopProcessGroup;
|
|
47
|
+
const data = pruneDeadServers(readDevServers(input.mainWorktree, input.registryDir), isAlive);
|
|
48
|
+
const sorted = [...data.servers].sort((a, b) => a.startedAt.localeCompare(b.startedAt));
|
|
49
|
+
const victims = sorted.slice(0, input.count);
|
|
50
|
+
for (const entry of victims) {
|
|
51
|
+
for (const pid of Object.values(entry.pids)) {
|
|
52
|
+
if (isAlive(pid))
|
|
53
|
+
await stop(pid);
|
|
54
|
+
}
|
|
55
|
+
await stopCallbacksForVictim(input.callbackServers, entry.worktree);
|
|
56
|
+
}
|
|
57
|
+
const victimSlots = new Set(victims.map((v) => v.slot));
|
|
58
|
+
const filtered = data.servers.filter((entry) => !victimSlots.has(entry.slot));
|
|
59
|
+
writeDevServers(input.mainWorktree, input.registryDir, { servers: filtered });
|
|
60
|
+
return victims;
|
|
61
|
+
}
|
|
62
|
+
async function stopCallbacksForVictim(callbackServers, worktree) {
|
|
63
|
+
for (const server of [...callbackServers].reverse()) {
|
|
64
|
+
console.log(` ${server.name} (callback)`);
|
|
65
|
+
try {
|
|
66
|
+
await server.stop({ cwd: worktree });
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
console.error(` Failed to stop ${server.name} (${worktree}): ${err.message}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export function registerDevServer(mainWorktree, registryDir, entry) {
|
|
74
|
+
const data = pruneAndPersist(mainWorktree, registryDir);
|
|
75
|
+
data.servers.push(entry);
|
|
76
|
+
writeDevServers(mainWorktree, registryDir, data);
|
|
77
|
+
}
|
|
78
|
+
export function unregisterDevServer(mainWorktree, registryDir, worktreePath) {
|
|
79
|
+
const fp = filePath(mainWorktree, registryDir);
|
|
80
|
+
if (!existsSync(fp))
|
|
81
|
+
return;
|
|
82
|
+
const data = pruneAndPersist(mainWorktree, registryDir);
|
|
83
|
+
const target = resolve(worktreePath);
|
|
84
|
+
const filtered = data.servers.filter((entry) => resolve(entry.worktree) !== target);
|
|
85
|
+
if (filtered.length === data.servers.length)
|
|
86
|
+
return;
|
|
87
|
+
writeDevServers(mainWorktree, registryDir, { servers: filtered });
|
|
88
|
+
}
|
|
89
|
+
export function removeDevServerEntryByWorktree(mainWorktree, registryDir, worktreePath) {
|
|
90
|
+
const fp = filePath(mainWorktree, registryDir);
|
|
91
|
+
if (!existsSync(fp))
|
|
92
|
+
return;
|
|
93
|
+
const data = readDevServers(mainWorktree, registryDir);
|
|
94
|
+
const target = resolve(worktreePath);
|
|
95
|
+
const filtered = data.servers.filter((entry) => resolve(entry.worktree) !== target);
|
|
96
|
+
if (filtered.length === data.servers.length)
|
|
97
|
+
return;
|
|
98
|
+
writeDevServers(mainWorktree, registryDir, { servers: filtered });
|
|
99
|
+
}
|
|
100
|
+
/** Returns the entry whose worktree matches `worktreePath`, or `undefined`. Does not prune. */
|
|
101
|
+
export function findOwnEntry(mainWorktree, registryDir, worktreePath) {
|
|
102
|
+
const data = readDevServers(mainWorktree, registryDir);
|
|
103
|
+
const target = resolve(worktreePath);
|
|
104
|
+
return data.servers.find((entry) => resolve(entry.worktree) === target);
|
|
105
|
+
}
|
|
106
|
+
export function pruneAndPersist(mainWorktree, registryDir, isAlive = isProcessAlive) {
|
|
107
|
+
const data = readDevServers(mainWorktree, registryDir);
|
|
108
|
+
const pruned = pruneDeadServers(data, isAlive);
|
|
109
|
+
if (pruned.servers.length !== data.servers.length) {
|
|
110
|
+
writeDevServers(mainWorktree, registryDir, pruned);
|
|
111
|
+
}
|
|
112
|
+
return pruned;
|
|
113
|
+
}
|
|
114
|
+
export function pruneDeadServers(data, isAlive = isProcessAlive) {
|
|
115
|
+
const live = data.servers.filter((entry) => Object.values(entry.pids).some((pid) => isAlive(pid)));
|
|
116
|
+
return { servers: live };
|
|
117
|
+
}
|
|
118
|
+
export function readDevServers(mainWorktree, registryDir) {
|
|
119
|
+
const fp = filePath(mainWorktree, registryDir);
|
|
120
|
+
if (!existsSync(fp))
|
|
121
|
+
return { servers: [] };
|
|
122
|
+
return JSON.parse(readFileSync(fp, "utf-8"));
|
|
123
|
+
}
|
|
124
|
+
export function writeDevServers(mainWorktree, registryDir, data) {
|
|
125
|
+
const fp = filePath(mainWorktree, registryDir);
|
|
126
|
+
mkdirSync(join(mainWorktree, registryDir), { recursive: true });
|
|
127
|
+
writeFileSync(fp, `${JSON.stringify(data, undefined, 2)}\n`);
|
|
128
|
+
}
|
|
129
|
+
function filePath(mainWorktree, registryDir) {
|
|
130
|
+
return join(mainWorktree, registryDir, DEV_SERVERS_FILENAME);
|
|
131
|
+
}
|
|
132
|
+
function formatEntry(entry) {
|
|
133
|
+
const pids = Object.entries(entry.pids)
|
|
134
|
+
.map(([name, pid]) => `${name}=${pid}`)
|
|
135
|
+
.join(",");
|
|
136
|
+
const ownerPart = entry.owner ? ` owner=${entry.owner}` : "";
|
|
137
|
+
const type = entry.main ? "main" : "linked";
|
|
138
|
+
const branch = getWorktreeBranch(entry.worktree) ?? "(detached)";
|
|
139
|
+
return ` slot ${entry.slot} type=${type} branch=${branch}${ownerPart} pids=${pids} startedAt=${entry.startedAt} worktree=${entry.worktree}`;
|
|
140
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare class StartupError extends Error {
|
|
2
|
+
label: string;
|
|
3
|
+
reason: string;
|
|
4
|
+
logFile: string | undefined;
|
|
5
|
+
constructor(label: string, reason: string, logFile?: string);
|
|
6
|
+
}
|
|
7
|
+
export declare class ConfigError extends Error {
|
|
8
|
+
exitCode: number;
|
|
9
|
+
constructor(message: string, exitCode?: number);
|
|
10
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export class StartupError extends Error {
|
|
2
|
+
label;
|
|
3
|
+
reason;
|
|
4
|
+
logFile;
|
|
5
|
+
constructor(label, reason, logFile) {
|
|
6
|
+
super(`${label}: ${reason}`);
|
|
7
|
+
this.label = label;
|
|
8
|
+
this.reason = reason;
|
|
9
|
+
this.logFile = logFile;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export class ConfigError extends Error {
|
|
13
|
+
exitCode;
|
|
14
|
+
constructor(message, exitCode = 1) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.exitCode = exitCode;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export declare function patchEnvFile(content: string, patches: Record<string, string>): string;
|
|
2
|
+
export declare function extractHost(content: string, key: string, fallback?: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* Reads `<varName>=<value>` from a dotenv-style file and parses it as a port.
|
|
5
|
+
* Exits with code 1 on missing file, missing variable, or non-numeric value.
|
|
6
|
+
*/
|
|
7
|
+
export declare function readPortFromEnvFile(file: string, varName: string): number;
|
|
8
|
+
/**
|
|
9
|
+
* Reads a dotted path (e.g. `server.port`) from a JSON file and parses it as a port.
|
|
10
|
+
* Exits with code 1 on missing file, missing path, or non-numeric value.
|
|
11
|
+
*/
|
|
12
|
+
export declare function readPortFromJsonFile(file: string, jsonPath: string): number;
|
|
13
|
+
export interface CopyAndPatchCtx {
|
|
14
|
+
currentWorktree: string;
|
|
15
|
+
mainWorktree: string;
|
|
16
|
+
log: (msg: string) => void;
|
|
17
|
+
}
|
|
18
|
+
export declare function copyAndPatchFile(ctx: CopyAndPatchCtx, relPath: string, patchFn: (content: string) => string, label: string, force: boolean, optional?: boolean): void;
|
|
19
|
+
/**
|
|
20
|
+
* Detects common fatal JS startup failures in a log buffer. Returns a short marker string
|
|
21
|
+
* naming the matched pattern, or `false` when none match. Used as the default `detectError`
|
|
22
|
+
* for spawn servers that don't supply one. A custom `detectError` can compose with this:
|
|
23
|
+
* `detectError: (log) => myDetector(log) || helpers.detectCommonJsError(log)`.
|
|
24
|
+
*/
|
|
25
|
+
/**
|
|
26
|
+
* Formats a millisecond duration as the two largest units among `d`/`h`/`m`/`s`.
|
|
27
|
+
* Drops the smaller unit when zero (`5d` instead of `5d 0h`). Sub-second values
|
|
28
|
+
* round up to `1s` (zero stays `0s`). Negative input returns `0s`.
|
|
29
|
+
*/
|
|
30
|
+
export declare function formatDuration(ms: number): string;
|
|
31
|
+
export declare function detectCommonJsError(log: string): string | false;
|