@paleo/worktree-env 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dev-server.d.ts +9 -22
- package/dist/dev-server.js +107 -60
- package/dist/dev-servers-registry.d.ts +12 -3
- package/dist/dev-servers-registry.js +25 -14
- package/dist/index.d.ts +2 -2
- package/dist/process-control.d.ts +0 -3
- package/dist/process-control.js +0 -26
- package/dist/server-descriptor.d.ts +41 -0
- package/dist/server-descriptor.js +1 -0
- package/dist/setup-worktree.d.ts +15 -5
- package/dist/setup-worktree.js +21 -40
- package/package.json +1 -1
package/dist/dev-server.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import type { CallbackServer, ServerContext, ServerDescriptor, SpawnServer } from "./server-descriptor.js";
|
|
1
2
|
import { type ResolvedSlot } from "./slots.js";
|
|
3
|
+
export type { CallbackServer, ServerContext, ServerDescriptor, SpawnServer };
|
|
2
4
|
/** Configuration accepted by {@link runDevServer}. */
|
|
3
5
|
export interface DevServerConfig {
|
|
4
6
|
/** Anchor port for the slot range. Used to synthesize the main worktree's slot. */
|
|
@@ -13,36 +15,21 @@ export interface DevServerConfig {
|
|
|
13
15
|
registryDir: string;
|
|
14
16
|
/** Maximum concurrent dev-servers across all worktrees. Omit for no limit. */
|
|
15
17
|
devLimit?: number;
|
|
16
|
-
/** One entry per
|
|
18
|
+
/** One entry per server to start. Started in array order; stopped in reverse order. */
|
|
17
19
|
servers: ServerDescriptor[];
|
|
18
|
-
/** Hook invoked once before any dev-server is spawned (e.g. `docker compose up -d`). */
|
|
19
|
-
ensureInfrastructure?: () => Promise<void> | void;
|
|
20
20
|
/** Builds the post-start summary printed to stdout. Defaults to a generic layout. */
|
|
21
21
|
printSummary?: (ctx: DevServerSummaryContext) => string;
|
|
22
22
|
}
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
/** Command and arguments passed to `child_process.spawn`. */
|
|
28
|
-
exec: {
|
|
29
|
-
command: string;
|
|
30
|
-
args: string[];
|
|
31
|
-
};
|
|
32
|
-
/** Port the process will listen on. Use `helpers.readPortFromEnvFile` / `readPortFromJsonFile` to read it from a config file. */
|
|
33
|
-
port: number;
|
|
34
|
-
/** Returns `true` once the log content indicates the server is ready. */
|
|
35
|
-
detectSuccess: (logContent: string) => boolean;
|
|
36
|
-
/** Returns a non-empty marker string when the log content indicates a fatal error, or `false` otherwise. */
|
|
37
|
-
detectError?: (logContent: string) => string | false;
|
|
38
|
-
}
|
|
39
|
-
/** Context passed to {@link DevServerConfig.printSummary}. */
|
|
23
|
+
/**
|
|
24
|
+
* Context passed to {@link DevServerConfig.printSummary}. `port` and `pid` are present only for
|
|
25
|
+
* `kind: "spawn"` servers; callback servers expose neither.
|
|
26
|
+
*/
|
|
40
27
|
export interface DevServerSummaryContext {
|
|
41
28
|
slot: ResolvedSlot;
|
|
42
29
|
servers: {
|
|
43
30
|
server: ServerDescriptor;
|
|
44
|
-
port
|
|
45
|
-
pid
|
|
31
|
+
port?: number;
|
|
32
|
+
pid?: number;
|
|
46
33
|
}[];
|
|
47
34
|
}
|
|
48
35
|
export declare function runDevServer(config: DevServerConfig): Promise<void>;
|
package/dist/dev-server.js
CHANGED
|
@@ -1,17 +1,14 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { closeSync, mkdirSync, openSync
|
|
2
|
+
import { closeSync, mkdirSync, openSync } from "node:fs";
|
|
3
3
|
import { createConnection } from "node:net";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { parseDevServerArgs, printDevServerHelp, validateDevServerFlags, } from "./cli.js";
|
|
6
|
-
import { evictOldest, listDevServers, printActiveServers, pruneAndPersist, registerDevServer, stopAllRegistered, unregisterDevServer, } from "./dev-servers-registry.js";
|
|
6
|
+
import { evictOldest, findOwnEntry, listDevServers, printActiveServers, pruneAndPersist, registerDevServer, removeDevServerEntryByWorktree, stopAllRegistered, unregisterDevServer, } from "./dev-servers-registry.js";
|
|
7
7
|
import { ConfigError, StartupError } from "./errors.js";
|
|
8
8
|
import { awaitAllReady, handleStartupFailure } from "./log-polling.js";
|
|
9
|
-
import {
|
|
9
|
+
import { isProcessAlive, stopProcessGroup } from "./process-control.js";
|
|
10
10
|
import { resolveCurrentSlot } from "./slots.js";
|
|
11
11
|
import { detectWorktree } from "./worktree.js";
|
|
12
|
-
function pidFileFor(runtimeDir, name) {
|
|
13
|
-
return join(runtimeDir, `${name}.pid`);
|
|
14
|
-
}
|
|
15
12
|
function logFileFor(runtimeDir, name) {
|
|
16
13
|
return join(runtimeDir, "logs", `${name}.log`);
|
|
17
14
|
}
|
|
@@ -47,7 +44,7 @@ export async function runDevServer(config) {
|
|
|
47
44
|
await stopAllRegistered({
|
|
48
45
|
mainWorktree,
|
|
49
46
|
registryDir: config.registryDir,
|
|
50
|
-
|
|
47
|
+
callbackServers: callbackServersOf(config),
|
|
51
48
|
});
|
|
52
49
|
return;
|
|
53
50
|
}
|
|
@@ -57,60 +54,84 @@ export async function runDevServer(config) {
|
|
|
57
54
|
}
|
|
58
55
|
await start(config, mainWorktree, { evict: Boolean(args.evict) });
|
|
59
56
|
}
|
|
57
|
+
function callbackServersOf(config) {
|
|
58
|
+
return config.servers.filter((s) => s.kind === "callback");
|
|
59
|
+
}
|
|
60
60
|
async function start(config, mainWorktree, { evict }) {
|
|
61
|
+
const ctx = { cwd: process.cwd() };
|
|
61
62
|
await enforceCap(config, mainWorktree, evict);
|
|
62
63
|
await checkPortsFree(config.servers);
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const pids = [];
|
|
67
|
-
for (const server of config.servers) {
|
|
68
|
-
console.log(`Starting ${server.name} dev server...`);
|
|
69
|
-
pids.push(spawnServer(server, config.runtimeDir));
|
|
70
|
-
}
|
|
64
|
+
checkNoLocalRegistryConflict(config, mainWorktree, ctx.cwd);
|
|
65
|
+
const spawnPids = {};
|
|
66
|
+
const startedCallbacks = [];
|
|
71
67
|
try {
|
|
72
|
-
const
|
|
68
|
+
for (const server of config.servers) {
|
|
69
|
+
console.log(`Starting ${server.name} dev server...`);
|
|
70
|
+
if (server.kind === "spawn") {
|
|
71
|
+
spawnPids[server.name] = spawnServer(server, config.runtimeDir, ctx.cwd);
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
await server.start(ctx);
|
|
75
|
+
startedCallbacks.push(server);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const spawnEntries = config.servers.filter((s) => s.kind === "spawn");
|
|
79
|
+
const pollables = spawnEntries.map((s) => ({
|
|
73
80
|
name: s.name,
|
|
74
81
|
logFile: logFileFor(config.runtimeDir, s.name),
|
|
75
82
|
detectSuccess: s.detectSuccess,
|
|
76
83
|
detectError: s.detectError,
|
|
77
84
|
}));
|
|
78
|
-
|
|
85
|
+
const pollPids = spawnEntries.map((s) => spawnPids[s.name]);
|
|
86
|
+
await awaitAllReady(pollables, pollPids);
|
|
79
87
|
}
|
|
80
88
|
catch (err) {
|
|
89
|
+
await rollbackStart(spawnPids, startedCallbacks, ctx);
|
|
81
90
|
if (err instanceof StartupError) {
|
|
82
91
|
handleStartupFailure(err);
|
|
83
|
-
console.error("\nStopping dev servers...");
|
|
84
|
-
await stopLocal(config, mainWorktree);
|
|
85
92
|
process.exit(1);
|
|
86
93
|
}
|
|
87
94
|
throw err;
|
|
88
95
|
}
|
|
89
96
|
const slot = resolveCurrentSlot(config.basePort, config.registryDir);
|
|
90
|
-
const pidMap = {};
|
|
91
|
-
config.servers.forEach((server, i) => {
|
|
92
|
-
pidMap[server.name] = pids[i];
|
|
93
|
-
});
|
|
94
97
|
registerDevServer(mainWorktree, config.registryDir, {
|
|
95
98
|
slot: slot.slot,
|
|
96
99
|
worktree: slot.worktree,
|
|
97
100
|
branch: slot.branch,
|
|
98
101
|
owner: slot.owner,
|
|
99
|
-
pids:
|
|
102
|
+
pids: spawnPids,
|
|
100
103
|
startedAt: new Date().toISOString(),
|
|
101
104
|
});
|
|
105
|
+
const summaryServers = config.servers.map((server) => {
|
|
106
|
+
if (server.kind === "spawn") {
|
|
107
|
+
return { server, port: server.port, pid: spawnPids[server.name] };
|
|
108
|
+
}
|
|
109
|
+
return { server };
|
|
110
|
+
});
|
|
102
111
|
if (config.printSummary) {
|
|
103
|
-
console.log(config.printSummary({
|
|
104
|
-
slot,
|
|
105
|
-
servers: config.servers.map((server, i) => ({
|
|
106
|
-
server,
|
|
107
|
-
port: server.port,
|
|
108
|
-
pid: pids[i],
|
|
109
|
-
})),
|
|
110
|
-
}));
|
|
112
|
+
console.log(config.printSummary({ slot, servers: summaryServers }));
|
|
111
113
|
}
|
|
112
114
|
else {
|
|
113
|
-
defaultPrintSummary(slot,
|
|
115
|
+
defaultPrintSummary(slot, summaryServers, config.runtimeDir);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async function rollbackStart(spawnPids, startedCallbacks, ctx) {
|
|
119
|
+
console.error("\nStopping dev servers...");
|
|
120
|
+
for (const pid of Object.values(spawnPids)) {
|
|
121
|
+
try {
|
|
122
|
+
await stopProcessGroup(pid);
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
console.error(` Failed to stop PID ${pid}: ${err.message}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
for (const server of [...startedCallbacks].reverse()) {
|
|
129
|
+
try {
|
|
130
|
+
await server.stop(ctx);
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
console.error(` Failed to stop ${server.name}: ${err.message}`);
|
|
134
|
+
}
|
|
114
135
|
}
|
|
115
136
|
}
|
|
116
137
|
// TOCTOU: the cap check and the subsequent register are not atomic. Two concurrent `dev:up --evict`
|
|
@@ -132,72 +153,98 @@ async function enforceCap(config, mainWorktree, evict) {
|
|
|
132
153
|
}
|
|
133
154
|
const toEvict = active.length - limit + 1;
|
|
134
155
|
console.log(`Evicting ${toEvict} dev-server(s) to make room (cap ${limit}).`);
|
|
135
|
-
const evicted = await evictOldest(
|
|
156
|
+
const evicted = await evictOldest({
|
|
157
|
+
mainWorktree,
|
|
158
|
+
registryDir: config.registryDir,
|
|
159
|
+
callbackServers: callbackServersOf(config),
|
|
160
|
+
count: toEvict,
|
|
161
|
+
});
|
|
136
162
|
for (const entry of evicted) {
|
|
137
163
|
const ownerPart = entry.owner ? `, owner=${entry.owner}` : "";
|
|
138
164
|
console.log(`Evicted slot ${entry.slot} (branch=${entry.branch}${ownerPart}, startedAt=${entry.startedAt}).`);
|
|
139
|
-
for (const name of Object.keys(entry.pids)) {
|
|
140
|
-
cleanupPidFile(join(entry.worktree, config.runtimeDir, `${name}.pid`));
|
|
141
|
-
}
|
|
142
165
|
}
|
|
143
166
|
}
|
|
144
167
|
async function checkPortsFree(servers) {
|
|
145
|
-
const
|
|
168
|
+
const spawnServers = servers.filter((s) => s.kind === "spawn");
|
|
169
|
+
const busy = await Promise.all(spawnServers.map((s) => isPortBusy(s.port)));
|
|
146
170
|
let anyBusy = false;
|
|
147
171
|
busy.forEach((b, i) => {
|
|
148
172
|
if (b) {
|
|
149
|
-
console.error(`Error: Port ${
|
|
173
|
+
console.error(`Error: Port ${spawnServers[i].port} (${spawnServers[i].name}) is already in use.`);
|
|
150
174
|
anyBusy = true;
|
|
151
175
|
}
|
|
152
176
|
});
|
|
153
177
|
if (anyBusy)
|
|
154
178
|
process.exit(1);
|
|
155
179
|
}
|
|
156
|
-
function
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
180
|
+
function checkNoLocalRegistryConflict(config, mainWorktree, cwd) {
|
|
181
|
+
const entry = findOwnEntry(mainWorktree, config.registryDir, cwd);
|
|
182
|
+
if (!entry)
|
|
183
|
+
return;
|
|
184
|
+
for (const [name, pid] of Object.entries(entry.pids)) {
|
|
185
|
+
if (isProcessAlive(pid)) {
|
|
186
|
+
console.error(`Error: ${name} is already running (PID ${pid}).`);
|
|
162
187
|
process.exit(1);
|
|
163
188
|
}
|
|
164
|
-
cleanupPidFile(pidFile);
|
|
165
189
|
}
|
|
190
|
+
// Stale entry — drop it so registration overwrites cleanly.
|
|
191
|
+
removeDevServerEntryByWorktree(mainWorktree, config.registryDir, cwd);
|
|
166
192
|
}
|
|
167
193
|
async function stopLocal(config, mainWorktree) {
|
|
168
|
-
|
|
169
|
-
|
|
194
|
+
const ctx = { cwd: process.cwd() };
|
|
195
|
+
const entry = findOwnEntry(mainWorktree, config.registryDir, ctx.cwd);
|
|
196
|
+
if (!entry) {
|
|
197
|
+
console.log("No dev-server running in this worktree.");
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
for (const [name, pid] of Object.entries(entry.pids)) {
|
|
201
|
+
if (!isProcessAlive(pid))
|
|
202
|
+
continue;
|
|
203
|
+
console.log(`Stopping ${name} (PID ${pid})...`);
|
|
204
|
+
await stopProcessGroup(pid);
|
|
170
205
|
}
|
|
171
|
-
|
|
206
|
+
const callbacks = callbackServersOf(config);
|
|
207
|
+
for (const server of [...callbacks].reverse()) {
|
|
208
|
+
try {
|
|
209
|
+
await server.stop(ctx);
|
|
210
|
+
}
|
|
211
|
+
catch (err) {
|
|
212
|
+
console.error(` Failed to stop ${server.name}: ${err.message}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
unregisterDevServer(mainWorktree, config.registryDir, ctx.cwd);
|
|
172
216
|
}
|
|
173
|
-
function defaultPrintSummary(slot, servers,
|
|
217
|
+
function defaultPrintSummary(slot, servers, runtimeDir) {
|
|
174
218
|
console.log("\nDev servers started!");
|
|
175
219
|
const ownerSuffix = slot.owner ? `, owner ${slot.owner}` : "";
|
|
176
220
|
console.log(` Worktree: slot ${slot.slot}${ownerSuffix}`);
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
});
|
|
221
|
+
for (const { server, port, pid } of servers) {
|
|
222
|
+
if (server.kind === "spawn") {
|
|
223
|
+
const url = `http://localhost:${port}/`;
|
|
224
|
+
const logPath = join(process.cwd(), logFileFor(runtimeDir, server.name));
|
|
225
|
+
console.log(` ${server.name}: ${url} (PID ${pid})`);
|
|
226
|
+
console.log(` log: ${logPath}`);
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
console.log(` ${server.name}: ready`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
183
232
|
console.log("");
|
|
184
233
|
}
|
|
185
|
-
function spawnServer(server, runtimeDir) {
|
|
234
|
+
function spawnServer(server, runtimeDir, cwd) {
|
|
186
235
|
const logFile = logFileFor(runtimeDir, server.name);
|
|
187
|
-
const pidFile = pidFileFor(runtimeDir, server.name);
|
|
188
236
|
mkdirSync(dirname(logFile), { recursive: true });
|
|
189
|
-
mkdirSync(dirname(pidFile), { recursive: true });
|
|
190
237
|
const logFd = openSync(logFile, "w");
|
|
191
238
|
const child = spawn(server.exec.command, server.exec.args, {
|
|
192
239
|
detached: true,
|
|
193
240
|
stdio: ["ignore", logFd, logFd],
|
|
241
|
+
cwd,
|
|
194
242
|
});
|
|
195
243
|
if (child.pid === undefined) {
|
|
196
244
|
closeSync(logFd);
|
|
197
245
|
console.error(`Error: failed to spawn ${server.name}.`);
|
|
198
246
|
process.exit(1);
|
|
199
247
|
}
|
|
200
|
-
writeFileSync(pidFile, String(child.pid));
|
|
201
248
|
child.unref();
|
|
202
249
|
closeSync(logFd);
|
|
203
250
|
return child.pid;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CallbackServer } from "./server-descriptor.js";
|
|
1
2
|
export interface DevServerEntry {
|
|
2
3
|
slot: number;
|
|
3
4
|
worktree: string;
|
|
@@ -15,17 +16,25 @@ export declare function printActiveServers(active: DevServerEntry[]): void;
|
|
|
15
16
|
export interface StopAllInput {
|
|
16
17
|
mainWorktree: string;
|
|
17
18
|
registryDir: string;
|
|
18
|
-
|
|
19
|
+
/** Callback-managed servers from the current process's config. Their `stop()` is invoked for each
|
|
20
|
+
* victim with `ctx.cwd = entry.worktree`. */
|
|
21
|
+
callbackServers: CallbackServer[];
|
|
19
22
|
}
|
|
20
23
|
export declare function stopAllRegistered(input: StopAllInput): Promise<void>;
|
|
21
|
-
export interface
|
|
24
|
+
export interface EvictInput {
|
|
25
|
+
mainWorktree: string;
|
|
26
|
+
registryDir: string;
|
|
27
|
+
count: number;
|
|
28
|
+
callbackServers: CallbackServer[];
|
|
22
29
|
isAlive?: IsAliveFn;
|
|
23
30
|
stop?: (pid: number) => Promise<void>;
|
|
24
31
|
}
|
|
25
|
-
export declare function evictOldest(
|
|
32
|
+
export declare function evictOldest(input: EvictInput): Promise<DevServerEntry[]>;
|
|
26
33
|
export declare function registerDevServer(mainWorktree: string, registryDir: string, entry: DevServerEntry): void;
|
|
27
34
|
export declare function unregisterDevServer(mainWorktree: string, registryDir: string, worktreePath: string): void;
|
|
28
35
|
export declare function removeDevServerEntryByWorktree(mainWorktree: string, registryDir: string, worktreePath: string): void;
|
|
36
|
+
/** Returns the entry whose worktree matches `worktreePath`, or `undefined`. Does not prune. */
|
|
37
|
+
export declare function findOwnEntry(mainWorktree: string, registryDir: string, worktreePath: string): DevServerEntry | undefined;
|
|
29
38
|
export declare function pruneAndPersist(mainWorktree: string, registryDir: string, isAlive?: IsAliveFn): DevServersData;
|
|
30
39
|
export declare function pruneDeadServers(data: DevServersData, isAlive?: IsAliveFn): DevServersData;
|
|
31
40
|
export declare function readDevServers(mainWorktree: string, registryDir: string): DevServersData;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync,
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { isProcessAlive, stopProcessGroup } from "./process-control.js";
|
|
4
4
|
const DEV_SERVERS_FILENAME = "dev-servers.json";
|
|
@@ -34,34 +34,39 @@ export async function stopAllRegistered(input) {
|
|
|
34
34
|
console.log(` ${name} (PID ${pid})`);
|
|
35
35
|
await stopProcessGroup(pid);
|
|
36
36
|
}
|
|
37
|
-
|
|
38
|
-
// Server names vary per entry, hence reading them from `entry.pids` rather than caller config.
|
|
39
|
-
for (const name of Object.keys(entry.pids)) {
|
|
40
|
-
const fp = join(entry.worktree, input.runtimeDir, `${name}.pid`);
|
|
41
|
-
if (existsSync(fp))
|
|
42
|
-
unlinkSync(fp);
|
|
43
|
-
}
|
|
37
|
+
await stopCallbacksForVictim(input.callbackServers, entry.worktree);
|
|
44
38
|
}
|
|
45
39
|
writeDevServers(input.mainWorktree, input.registryDir, { servers: [] });
|
|
46
40
|
console.log(`Stopped ${data.servers.length} dev-server(s).`);
|
|
47
41
|
}
|
|
48
|
-
export async function evictOldest(
|
|
49
|
-
const isAlive =
|
|
50
|
-
const stop =
|
|
51
|
-
const data = pruneDeadServers(readDevServers(mainWorktree, registryDir), isAlive);
|
|
42
|
+
export async function evictOldest(input) {
|
|
43
|
+
const isAlive = input.isAlive ?? isProcessAlive;
|
|
44
|
+
const stop = input.stop ?? stopProcessGroup;
|
|
45
|
+
const data = pruneDeadServers(readDevServers(input.mainWorktree, input.registryDir), isAlive);
|
|
52
46
|
const sorted = [...data.servers].sort((a, b) => a.startedAt.localeCompare(b.startedAt));
|
|
53
|
-
const victims = sorted.slice(0, count);
|
|
47
|
+
const victims = sorted.slice(0, input.count);
|
|
54
48
|
for (const entry of victims) {
|
|
55
49
|
for (const pid of Object.values(entry.pids)) {
|
|
56
50
|
if (isAlive(pid))
|
|
57
51
|
await stop(pid);
|
|
58
52
|
}
|
|
53
|
+
await stopCallbacksForVictim(input.callbackServers, entry.worktree);
|
|
59
54
|
}
|
|
60
55
|
const victimSlots = new Set(victims.map((v) => v.slot));
|
|
61
56
|
const filtered = data.servers.filter((entry) => !victimSlots.has(entry.slot));
|
|
62
|
-
writeDevServers(mainWorktree, registryDir, { servers: filtered });
|
|
57
|
+
writeDevServers(input.mainWorktree, input.registryDir, { servers: filtered });
|
|
63
58
|
return victims;
|
|
64
59
|
}
|
|
60
|
+
async function stopCallbacksForVictim(callbackServers, worktree) {
|
|
61
|
+
for (const server of [...callbackServers].reverse()) {
|
|
62
|
+
try {
|
|
63
|
+
await server.stop({ cwd: worktree });
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
console.error(` Failed to stop ${server.name} (${worktree}): ${err.message}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
65
70
|
export function registerDevServer(mainWorktree, registryDir, entry) {
|
|
66
71
|
const data = pruneAndPersist(mainWorktree, registryDir);
|
|
67
72
|
data.servers.push(entry);
|
|
@@ -89,6 +94,12 @@ export function removeDevServerEntryByWorktree(mainWorktree, registryDir, worktr
|
|
|
89
94
|
return;
|
|
90
95
|
writeDevServers(mainWorktree, registryDir, { servers: filtered });
|
|
91
96
|
}
|
|
97
|
+
/** Returns the entry whose worktree matches `worktreePath`, or `undefined`. Does not prune. */
|
|
98
|
+
export function findOwnEntry(mainWorktree, registryDir, worktreePath) {
|
|
99
|
+
const data = readDevServers(mainWorktree, registryDir);
|
|
100
|
+
const target = resolve(worktreePath);
|
|
101
|
+
return data.servers.find((entry) => resolve(entry.worktree) === target);
|
|
102
|
+
}
|
|
92
103
|
export function pruneAndPersist(mainWorktree, registryDir, isAlive = isProcessAlive) {
|
|
93
104
|
const data = readDevServers(mainWorktree, registryDir);
|
|
94
105
|
const pruned = pruneDeadServers(data, isAlive);
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export { runSetupWorktree } from "./setup-worktree.js";
|
|
2
|
-
export type { SetupWorktreeConfig, SetupContext, SummaryContext, PatchContext, ConfigFileEntry,
|
|
2
|
+
export type { SetupWorktreeConfig, SetupContext, SummaryContext, PatchContext, ConfigFileEntry, PurgeContext, } from "./setup-worktree.js";
|
|
3
3
|
export { runDevServer } from "./dev-server.js";
|
|
4
|
-
export type { DevServerConfig, DevServerSummaryContext, ServerDescriptor, } from "./dev-server.js";
|
|
4
|
+
export type { DevServerConfig, DevServerSummaryContext, ServerDescriptor, ServerContext, SpawnServer, CallbackServer, } from "./dev-server.js";
|
|
5
5
|
export type { ResolvedSlot } from "./slots.js";
|
|
6
6
|
import * as helpers from "./helpers.js";
|
|
7
7
|
export { helpers };
|
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
export declare function stopByPidFile(pidFile: string, label: string, log?: (msg: string) => void): Promise<void>;
|
|
2
1
|
export declare function stopProcessGroup(pid: number, timeoutMs?: number): Promise<void>;
|
|
3
|
-
export declare function readPid(pidFile: string): number | undefined;
|
|
4
2
|
export declare function isProcessAlive(pid: number): boolean;
|
|
5
3
|
export declare function isProcessGroupAlive(pid: number): boolean;
|
|
6
4
|
export declare function killProcessGroup(pid: number, signal: NodeJS.Signals): void;
|
|
7
|
-
export declare function cleanupPidFile(pidFile: string): void;
|
package/dist/process-control.js
CHANGED
|
@@ -1,16 +1,3 @@
|
|
|
1
|
-
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
2
|
-
export async function stopByPidFile(pidFile, label, log = () => { }) {
|
|
3
|
-
const pid = readPid(pidFile);
|
|
4
|
-
if (pid === undefined || !isProcessAlive(pid)) {
|
|
5
|
-
cleanupPidFile(pidFile);
|
|
6
|
-
log(`No ${label} process is running.`);
|
|
7
|
-
return;
|
|
8
|
-
}
|
|
9
|
-
log(`Stopping ${label} (PID ${pid})...`);
|
|
10
|
-
await stopProcessGroup(pid);
|
|
11
|
-
cleanupPidFile(pidFile);
|
|
12
|
-
log(`${label} stopped.`);
|
|
13
|
-
}
|
|
14
1
|
export async function stopProcessGroup(pid, timeoutMs = 10_000) {
|
|
15
2
|
killProcessGroup(pid, "SIGTERM");
|
|
16
3
|
const deadline = Date.now() + timeoutMs;
|
|
@@ -21,15 +8,6 @@ export async function stopProcessGroup(pid, timeoutMs = 10_000) {
|
|
|
21
8
|
}
|
|
22
9
|
killProcessGroup(pid, "SIGKILL");
|
|
23
10
|
}
|
|
24
|
-
export function readPid(pidFile) {
|
|
25
|
-
if (!existsSync(pidFile))
|
|
26
|
-
return undefined;
|
|
27
|
-
const raw = readFileSync(pidFile, "utf-8").trim();
|
|
28
|
-
const pid = Number(raw);
|
|
29
|
-
if (!Number.isInteger(pid) || pid <= 0)
|
|
30
|
-
return undefined;
|
|
31
|
-
return pid;
|
|
32
|
-
}
|
|
33
11
|
export function isProcessAlive(pid) {
|
|
34
12
|
try {
|
|
35
13
|
process.kill(pid, 0);
|
|
@@ -61,7 +39,3 @@ export function killProcessGroup(pid, signal) {
|
|
|
61
39
|
}
|
|
62
40
|
}
|
|
63
41
|
}
|
|
64
|
-
export function cleanupPidFile(pidFile) {
|
|
65
|
-
if (existsSync(pidFile))
|
|
66
|
-
unlinkSync(pidFile);
|
|
67
|
-
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Context threaded into every callback-managed server lifecycle hook. */
|
|
2
|
+
export interface ServerContext {
|
|
3
|
+
/**
|
|
4
|
+
* Worktree directory for this lifecycle call. Equals `process.cwd()` at start time for local
|
|
5
|
+
* starts/stops; equals the victim entry's worktree for cross-worktree stops (eviction,
|
|
6
|
+
* `dev:down --all`). Callbacks MUST thread this into every child-process call
|
|
7
|
+
* (`{ cwd: ctx.cwd }` on `execSync`, `spawn`, etc.) and resolve relative paths against it.
|
|
8
|
+
*/
|
|
9
|
+
cwd: string;
|
|
10
|
+
}
|
|
11
|
+
/** One process spawned and tracked by the runner. */
|
|
12
|
+
export interface SpawnServer {
|
|
13
|
+
kind: "spawn";
|
|
14
|
+
/** Short label used in logs and the registry. Derives `<runtimeDir>/logs/<name>.log`. */
|
|
15
|
+
name: string;
|
|
16
|
+
/** Command and arguments passed to `child_process.spawn`. */
|
|
17
|
+
exec: {
|
|
18
|
+
command: string;
|
|
19
|
+
args: string[];
|
|
20
|
+
};
|
|
21
|
+
/** Port the process will listen on. Use `helpers.readPortFromEnvFile` / `readPortFromJsonFile`. */
|
|
22
|
+
port: number;
|
|
23
|
+
/** Returns `true` once the log content indicates the server is ready. */
|
|
24
|
+
detectSuccess: (logContent: string) => boolean;
|
|
25
|
+
/** Returns a non-empty marker string when the log content indicates a fatal error, or `false`. */
|
|
26
|
+
detectError?: (logContent: string) => string | false;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A resource whose lifecycle the user owns (typically Docker / databases). The runner only
|
|
30
|
+
* invokes `start` and `stop`; it never spawns, polls logs, or tracks PIDs for callback servers.
|
|
31
|
+
*/
|
|
32
|
+
export interface CallbackServer {
|
|
33
|
+
kind: "callback";
|
|
34
|
+
/** Short label used in logs. */
|
|
35
|
+
name: string;
|
|
36
|
+
/** Must resolve only once the resource is ready. Thread `ctx.cwd` into every child-process call. */
|
|
37
|
+
start: (ctx: ServerContext) => Promise<void>;
|
|
38
|
+
/** Tears down the resource. Thread `ctx.cwd` into every child-process call. */
|
|
39
|
+
stop: (ctx: ServerContext) => Promise<void>;
|
|
40
|
+
}
|
|
41
|
+
export type ServerDescriptor = SpawnServer | CallbackServer;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/setup-worktree.d.ts
CHANGED
|
@@ -20,7 +20,7 @@ export interface SetupWorktreeConfig {
|
|
|
20
20
|
sharedDirs: string[];
|
|
21
21
|
/**
|
|
22
22
|
* Per-worktree runtime directory, relative to the worktree root (e.g. `.local-wt`).
|
|
23
|
-
* Holds the setup log
|
|
23
|
+
* Holds the setup log and dev-server logs.
|
|
24
24
|
*/
|
|
25
25
|
runtimeDir: string;
|
|
26
26
|
/**
|
|
@@ -38,8 +38,18 @@ export interface SetupWorktreeConfig {
|
|
|
38
38
|
* installed deps, etc.).
|
|
39
39
|
*/
|
|
40
40
|
finalizeWorktree: (ctx: SetupContext) => Promise<void> | void;
|
|
41
|
-
/**
|
|
42
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Absolute path to your dev-server script (the file that calls `runDevServer`). On `--remove`,
|
|
43
|
+
* the kernel shells out to `node <devServerScript> --stop` with `cwd: <target worktree>`.
|
|
44
|
+
* Typically `fileURLToPath(new URL('./dev-server.mjs', import.meta.url))` from your
|
|
45
|
+
* `setup-worktree.mjs`.
|
|
46
|
+
*/
|
|
47
|
+
devServerScript: string;
|
|
48
|
+
/**
|
|
49
|
+
* Destructive infrastructure teardown on `--remove` (e.g. `docker compose down -v` to wipe
|
|
50
|
+
* volumes). Runs after the dev-server stop. Best-effort; errors should be swallowed.
|
|
51
|
+
*/
|
|
52
|
+
purgeInfrastructure?: (ctx: PurgeContext) => Promise<void> | void;
|
|
43
53
|
/** Builds the post-setup summary printed to stdout. */
|
|
44
54
|
printSummary: (ctx: SummaryContext) => string;
|
|
45
55
|
}
|
|
@@ -63,8 +73,8 @@ export interface SummaryContext {
|
|
|
63
73
|
currentWorktree: string;
|
|
64
74
|
mainWorktree: string;
|
|
65
75
|
}
|
|
66
|
-
/** Context passed to {@link SetupWorktreeConfig.
|
|
67
|
-
export interface
|
|
76
|
+
/** Context passed to {@link SetupWorktreeConfig.purgeInfrastructure}. */
|
|
77
|
+
export interface PurgeContext {
|
|
68
78
|
worktree: string;
|
|
69
79
|
mainWorktree: string;
|
|
70
80
|
verbose: boolean;
|
package/dist/setup-worktree.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
|
-
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync,
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, symlinkSync, writeFileSync, } from "node:fs";
|
|
3
3
|
import { dirname, join, relative, resolve } from "node:path";
|
|
4
4
|
import { isFinalizeMode, isInfoMode, isRemoveMode, isSetOwnerMode, isSetupMode, isWaitMode, parseSetupArgs, printSetupHelp, validateSetupFlags, } from "./cli.js";
|
|
5
5
|
import { removeDevServerEntryByWorktree } from "./dev-servers-registry.js";
|
|
6
6
|
import { ConfigError } from "./errors.js";
|
|
7
7
|
import { copyAndPatchFile } from "./helpers.js";
|
|
8
8
|
import { defaultComputePorts, isValidPort, resolvePortScheme } from "./ports.js";
|
|
9
|
-
import { cleanupPidFile, isProcessAlive, isProcessGroupAlive, killProcessGroup, readPid, } from "./process-control.js";
|
|
10
9
|
import { handleSetOwner, markSlotFailed, markSlotReady, readSlots, resolveAndRegisterSlot, resolveCurrentSlot, validateSlotAvailability, writeSlots, } from "./slots.js";
|
|
11
10
|
import { createBranch, detectWorktree, enforceWorktreeMode, getCurrentBranch, removeWorktree, useExistingBranch, verifyBranchAbsentFromRemote, } from "./worktree.js";
|
|
12
11
|
export async function runSetupWorktree(config) {
|
|
@@ -288,9 +287,9 @@ async function handleRemove(args, ctx, run, config) {
|
|
|
288
287
|
console.log(`Removed registry entry for branch "${target.branch}" (slot ${target.slotPort}${ownerSuffix}).`);
|
|
289
288
|
return;
|
|
290
289
|
}
|
|
291
|
-
|
|
292
|
-
if (config.
|
|
293
|
-
await config.
|
|
290
|
+
stopTargetDevServer(config.devServerScript, target.worktreePath, verboseLog);
|
|
291
|
+
if (config.purgeInfrastructure) {
|
|
292
|
+
await config.purgeInfrastructure({
|
|
294
293
|
worktree: target.worktreePath,
|
|
295
294
|
mainWorktree: ctx.mainWorktree,
|
|
296
295
|
verbose: run.verbose,
|
|
@@ -406,41 +405,23 @@ function resolveRemoveTarget(args, ctx, registry, removeHere) {
|
|
|
406
405
|
}
|
|
407
406
|
return { slotPort: entry[0], branch, worktreePath, owner: entry[1].owner };
|
|
408
407
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
408
|
+
/**
|
|
409
|
+
* Stops the dev-server running in the target worktree by shelling out to
|
|
410
|
+
* `node <devServerScript> --stop` with `cwd: worktreePath`. The subprocess runs the target's
|
|
411
|
+
* own stop flow — registry-based spawn-PID kill + callback `stop()` from the target's branch.
|
|
412
|
+
*/
|
|
413
|
+
function stopTargetDevServer(devServerScript, worktreePath, log) {
|
|
414
|
+
log(`Stopping dev-server in ${worktreePath}...`);
|
|
415
|
+
const result = spawnSync(process.execPath, [devServerScript, "--stop"], {
|
|
416
|
+
cwd: worktreePath,
|
|
417
|
+
stdio: "inherit",
|
|
418
|
+
timeout: 30_000,
|
|
419
|
+
});
|
|
420
|
+
if (result.error) {
|
|
421
|
+
console.warn(`Warning: failed to run dev-server --stop: ${result.error.message}`);
|
|
417
422
|
}
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
continue;
|
|
421
|
-
const pidFile = join(dir, name);
|
|
422
|
-
const pid = readPid(pidFile);
|
|
423
|
-
if (pid === undefined)
|
|
424
|
-
continue;
|
|
425
|
-
if (!isProcessAlive(pid)) {
|
|
426
|
-
cleanupPidFile(pidFile);
|
|
427
|
-
continue;
|
|
428
|
-
}
|
|
429
|
-
log(`Stopping dev server (PID ${pid})...`);
|
|
430
|
-
killProcessGroup(pid, "SIGTERM");
|
|
431
|
-
const deadline = Date.now() + 5_000;
|
|
432
|
-
let stillAlive = true;
|
|
433
|
-
while (Date.now() < deadline) {
|
|
434
|
-
if (!isProcessGroupAlive(pid)) {
|
|
435
|
-
stillAlive = false;
|
|
436
|
-
break;
|
|
437
|
-
}
|
|
438
|
-
await new Promise((r) => setTimeout(r, 300));
|
|
439
|
-
}
|
|
440
|
-
if (stillAlive) {
|
|
441
|
-
killProcessGroup(pid, "SIGKILL");
|
|
442
|
-
}
|
|
443
|
-
cleanupPidFile(pidFile);
|
|
423
|
+
else if (result.status !== 0) {
|
|
424
|
+
console.warn(`Warning: dev-server --stop exited with code ${result.status}.`);
|
|
444
425
|
}
|
|
445
426
|
}
|
|
446
427
|
function resolvePortsFn(config) {
|