@paleo/worktree-env 0.5.2 → 0.6.1
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 +4 -0
- package/dist/dev-server.d.ts +9 -22
- package/dist/dev-server.js +109 -60
- package/dist/dev-servers-registry.d.ts +12 -3
- package/dist/dev-servers-registry.js +26 -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 +28 -41
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -37,9 +37,12 @@ npm run setup-worktree -- --remove feat/42 # full teardown
|
|
|
37
37
|
## API
|
|
38
38
|
|
|
39
39
|
```ts
|
|
40
|
+
import { fileURLToPath } from "node:url";
|
|
40
41
|
import { runSetupWorktree, helpers } from "@paleo/worktree-env";
|
|
41
42
|
|
|
42
43
|
await runSetupWorktree({
|
|
44
|
+
scriptPath: fileURLToPath(import.meta.url),
|
|
45
|
+
devServerScript: fileURLToPath(new URL("./dev-server.mjs", import.meta.url)),
|
|
43
46
|
basePort: 8100,
|
|
44
47
|
portNames: ["server", "frontend", "db"],
|
|
45
48
|
sharedDirs: [".local", ".plans"],
|
|
@@ -77,6 +80,7 @@ await runDevServer({
|
|
|
77
80
|
devLimit: 5,
|
|
78
81
|
servers: [
|
|
79
82
|
{
|
|
83
|
+
kind: "spawn",
|
|
80
84
|
name: "dev",
|
|
81
85
|
exec: { command: "npm", args: ["run", "dev"] },
|
|
82
86
|
port: helpers.readPortFromEnvFile(".env", "PORT"),
|
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,85 @@ 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);
|
|
63
|
+
checkNoLocalRegistryConflict(config, mainWorktree, ctx.cwd);
|
|
62
64
|
await checkPortsFree(config.servers);
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
await config.ensureInfrastructure();
|
|
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
|
-
}
|
|
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
|
+
console.log(`Stopping ${server.name}...`);
|
|
130
|
+
try {
|
|
131
|
+
await server.stop(ctx);
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
console.error(` Failed to stop ${server.name}: ${err.message}`);
|
|
135
|
+
}
|
|
114
136
|
}
|
|
115
137
|
}
|
|
116
138
|
// TOCTOU: the cap check and the subsequent register are not atomic. Two concurrent `dev:up --evict`
|
|
@@ -132,72 +154,99 @@ async function enforceCap(config, mainWorktree, evict) {
|
|
|
132
154
|
}
|
|
133
155
|
const toEvict = active.length - limit + 1;
|
|
134
156
|
console.log(`Evicting ${toEvict} dev-server(s) to make room (cap ${limit}).`);
|
|
135
|
-
const evicted = await evictOldest(
|
|
157
|
+
const evicted = await evictOldest({
|
|
158
|
+
mainWorktree,
|
|
159
|
+
registryDir: config.registryDir,
|
|
160
|
+
callbackServers: callbackServersOf(config),
|
|
161
|
+
count: toEvict,
|
|
162
|
+
});
|
|
136
163
|
for (const entry of evicted) {
|
|
137
164
|
const ownerPart = entry.owner ? `, owner=${entry.owner}` : "";
|
|
138
165
|
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
166
|
}
|
|
143
167
|
}
|
|
144
168
|
async function checkPortsFree(servers) {
|
|
145
|
-
const
|
|
169
|
+
const spawnServers = servers.filter((s) => s.kind === "spawn");
|
|
170
|
+
const busy = await Promise.all(spawnServers.map((s) => isPortBusy(s.port)));
|
|
146
171
|
let anyBusy = false;
|
|
147
172
|
busy.forEach((b, i) => {
|
|
148
173
|
if (b) {
|
|
149
|
-
console.error(`Error: Port ${
|
|
174
|
+
console.error(`Error: Port ${spawnServers[i].port} (${spawnServers[i].name}) is already in use.`);
|
|
150
175
|
anyBusy = true;
|
|
151
176
|
}
|
|
152
177
|
});
|
|
153
178
|
if (anyBusy)
|
|
154
179
|
process.exit(1);
|
|
155
180
|
}
|
|
156
|
-
function
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
181
|
+
function checkNoLocalRegistryConflict(config, mainWorktree, cwd) {
|
|
182
|
+
const entry = findOwnEntry(mainWorktree, config.registryDir, cwd);
|
|
183
|
+
if (!entry)
|
|
184
|
+
return;
|
|
185
|
+
for (const [name, pid] of Object.entries(entry.pids)) {
|
|
186
|
+
if (isProcessAlive(pid)) {
|
|
187
|
+
console.error(`Error: ${name} is already running (PID ${pid}).`);
|
|
162
188
|
process.exit(1);
|
|
163
189
|
}
|
|
164
|
-
cleanupPidFile(pidFile);
|
|
165
190
|
}
|
|
191
|
+
// Stale entry — drop it so registration overwrites cleanly.
|
|
192
|
+
removeDevServerEntryByWorktree(mainWorktree, config.registryDir, cwd);
|
|
166
193
|
}
|
|
167
194
|
async function stopLocal(config, mainWorktree) {
|
|
168
|
-
|
|
169
|
-
|
|
195
|
+
const ctx = { cwd: process.cwd() };
|
|
196
|
+
const entry = findOwnEntry(mainWorktree, config.registryDir, ctx.cwd);
|
|
197
|
+
if (!entry) {
|
|
198
|
+
console.log("No dev-server running in this worktree.");
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
for (const [name, pid] of Object.entries(entry.pids)) {
|
|
202
|
+
if (!isProcessAlive(pid))
|
|
203
|
+
continue;
|
|
204
|
+
console.log(`Stopping ${name} (PID ${pid})...`);
|
|
205
|
+
await stopProcessGroup(pid);
|
|
170
206
|
}
|
|
171
|
-
|
|
207
|
+
const callbacks = callbackServersOf(config);
|
|
208
|
+
for (const server of [...callbacks].reverse()) {
|
|
209
|
+
console.log(`Stopping ${server.name}...`);
|
|
210
|
+
try {
|
|
211
|
+
await server.stop(ctx);
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
console.error(` Failed to stop ${server.name}: ${err.message}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
unregisterDevServer(mainWorktree, config.registryDir, ctx.cwd);
|
|
172
218
|
}
|
|
173
|
-
function defaultPrintSummary(slot, servers,
|
|
219
|
+
function defaultPrintSummary(slot, servers, runtimeDir) {
|
|
174
220
|
console.log("\nDev servers started!");
|
|
175
221
|
const ownerSuffix = slot.owner ? `, owner ${slot.owner}` : "";
|
|
176
222
|
console.log(` Worktree: slot ${slot.slot}${ownerSuffix}`);
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
});
|
|
223
|
+
for (const { server, port, pid } of servers) {
|
|
224
|
+
if (server.kind === "spawn") {
|
|
225
|
+
const url = `http://localhost:${port}/`;
|
|
226
|
+
const logPath = join(process.cwd(), logFileFor(runtimeDir, server.name));
|
|
227
|
+
console.log(` ${server.name}: ${url} (PID ${pid})`);
|
|
228
|
+
console.log(` log: ${logPath}`);
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
console.log(` ${server.name}: ready`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
183
234
|
console.log("");
|
|
184
235
|
}
|
|
185
|
-
function spawnServer(server, runtimeDir) {
|
|
236
|
+
function spawnServer(server, runtimeDir, cwd) {
|
|
186
237
|
const logFile = logFileFor(runtimeDir, server.name);
|
|
187
|
-
const pidFile = pidFileFor(runtimeDir, server.name);
|
|
188
238
|
mkdirSync(dirname(logFile), { recursive: true });
|
|
189
|
-
mkdirSync(dirname(pidFile), { recursive: true });
|
|
190
239
|
const logFd = openSync(logFile, "w");
|
|
191
240
|
const child = spawn(server.exec.command, server.exec.args, {
|
|
192
241
|
detached: true,
|
|
193
242
|
stdio: ["ignore", logFd, logFd],
|
|
243
|
+
cwd,
|
|
194
244
|
});
|
|
195
245
|
if (child.pid === undefined) {
|
|
196
246
|
closeSync(logFd);
|
|
197
247
|
console.error(`Error: failed to spawn ${server.name}.`);
|
|
198
248
|
process.exit(1);
|
|
199
249
|
}
|
|
200
|
-
writeFileSync(pidFile, String(child.pid));
|
|
201
250
|
child.unref();
|
|
202
251
|
closeSync(logFd);
|
|
203
252
|
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,40 @@ 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
|
+
console.log(` ${server.name} (callback)`);
|
|
63
|
+
try {
|
|
64
|
+
await server.stop({ cwd: worktree });
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
console.error(` Failed to stop ${server.name} (${worktree}): ${err.message}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
65
71
|
export function registerDevServer(mainWorktree, registryDir, entry) {
|
|
66
72
|
const data = pruneAndPersist(mainWorktree, registryDir);
|
|
67
73
|
data.servers.push(entry);
|
|
@@ -89,6 +95,12 @@ export function removeDevServerEntryByWorktree(mainWorktree, registryDir, worktr
|
|
|
89
95
|
return;
|
|
90
96
|
writeDevServers(mainWorktree, registryDir, { servers: filtered });
|
|
91
97
|
}
|
|
98
|
+
/** Returns the entry whose worktree matches `worktreePath`, or `undefined`. Does not prune. */
|
|
99
|
+
export function findOwnEntry(mainWorktree, registryDir, worktreePath) {
|
|
100
|
+
const data = readDevServers(mainWorktree, registryDir);
|
|
101
|
+
const target = resolve(worktreePath);
|
|
102
|
+
return data.servers.find((entry) => resolve(entry.worktree) === target);
|
|
103
|
+
}
|
|
92
104
|
export function pruneAndPersist(mainWorktree, registryDir, isAlive = isProcessAlive) {
|
|
93
105
|
const data = readDevServers(mainWorktree, registryDir);
|
|
94
106
|
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
|
-
import { removeDevServerEntryByWorktree } from "./dev-servers-registry.js";
|
|
5
|
+
import { findOwnEntry, 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,15 @@ 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 (
|
|
293
|
-
|
|
290
|
+
const targetEntry = findOwnEntry(ctx.mainWorktree, config.registryDir, target.worktreePath);
|
|
291
|
+
if (targetEntry) {
|
|
292
|
+
stopTargetDevServer(config.devServerScript, target.worktreePath, verboseLog);
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
verboseLog(`No dev-server running in ${target.worktreePath}; skipping --stop.`);
|
|
296
|
+
}
|
|
297
|
+
if (config.purgeInfrastructure) {
|
|
298
|
+
await config.purgeInfrastructure({
|
|
294
299
|
worktree: target.worktreePath,
|
|
295
300
|
mainWorktree: ctx.mainWorktree,
|
|
296
301
|
verbose: run.verbose,
|
|
@@ -406,41 +411,23 @@ function resolveRemoveTarget(args, ctx, registry, removeHere) {
|
|
|
406
411
|
}
|
|
407
412
|
return { slotPort: entry[0], branch, worktreePath, owner: entry[1].owner };
|
|
408
413
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
414
|
+
/**
|
|
415
|
+
* Stops the dev-server running in the target worktree by shelling out to
|
|
416
|
+
* `node <devServerScript> --stop` with `cwd: worktreePath`. The subprocess runs the target's
|
|
417
|
+
* own stop flow — registry-based spawn-PID kill + callback `stop()` from the target's branch.
|
|
418
|
+
*/
|
|
419
|
+
function stopTargetDevServer(devServerScript, worktreePath, log) {
|
|
420
|
+
log(`Stopping dev-server in ${worktreePath}...`);
|
|
421
|
+
const result = spawnSync(process.execPath, [devServerScript, "--stop"], {
|
|
422
|
+
cwd: worktreePath,
|
|
423
|
+
stdio: "inherit",
|
|
424
|
+
timeout: 30_000,
|
|
425
|
+
});
|
|
426
|
+
if (result.error) {
|
|
427
|
+
console.warn(`Warning: failed to run dev-server --stop: ${result.error.message}`);
|
|
417
428
|
}
|
|
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);
|
|
429
|
+
else if (result.status !== 0) {
|
|
430
|
+
console.warn(`Warning: dev-server --stop exited with code ${result.status}.`);
|
|
444
431
|
}
|
|
445
432
|
}
|
|
446
433
|
function resolvePortsFn(config) {
|