@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 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"),
@@ -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 process to spawn. Started in array order. */
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
- /** Describes one process to spawn. */
24
- export interface ServerDescriptor {
25
- /** Short label used in logs and the registry. Derives `<runtimeDir>/<name>.pid` and `<runtimeDir>/logs/<name>.log`. */
26
- name: string;
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: number;
45
- pid: number;
31
+ port?: number;
32
+ pid?: number;
46
33
  }[];
47
34
  }
48
35
  export declare function runDevServer(config: DevServerConfig): Promise<void>;
@@ -1,17 +1,14 @@
1
1
  import { spawn } from "node:child_process";
2
- import { closeSync, mkdirSync, openSync, writeFileSync } from "node:fs";
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 { cleanupPidFile, isProcessAlive, readPid, stopByPidFile } from "./process-control.js";
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
- runtimeDir: config.runtimeDir,
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
- checkNoLocalPidConflict(config);
64
- if (config.ensureInfrastructure)
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 pollables = config.servers.map((s) => ({
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
- await awaitAllReady(pollables, pids);
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: pidMap,
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, config.servers, config.servers.map((s) => s.port), pids, config.runtimeDir);
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(mainWorktree, config.registryDir, toEvict);
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 busy = await Promise.all(servers.map((s) => isPortBusy(s.port)));
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 ${servers[i].port} (${servers[i].name}) is already in use.`);
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 checkNoLocalPidConflict(config) {
157
- for (const server of config.servers) {
158
- const pidFile = pidFileFor(config.runtimeDir, server.name);
159
- const existingPid = readPid(pidFile);
160
- if (existingPid !== undefined && isProcessAlive(existingPid)) {
161
- console.error(`Error: ${server.name} is already running (PID ${existingPid}).`);
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
- for (const server of config.servers) {
169
- await stopByPidFile(pidFileFor(config.runtimeDir, server.name), server.name, (msg) => console.log(msg));
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
- unregisterDevServer(mainWorktree, config.registryDir, process.cwd());
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, ports, pids, runtimeDir) {
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
- servers.forEach((server, i) => {
178
- const url = `http://localhost:${ports[i]}/`;
179
- const logPath = join(process.cwd(), logFileFor(runtimeDir, server.name));
180
- console.log(` ${server.name}: ${url} (PID ${pids[i]})`);
181
- console.log(` log: ${logPath}`);
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
- runtimeDir: string;
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 EvictDeps {
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(mainWorktree: string, registryDir: string, count: number, deps?: EvictDeps): Promise<DevServerEntry[]>;
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, unlinkSync, writeFileSync } from "node:fs";
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
- // `runtimeDir` is repo-wide, so each entry's PID files live at the same relative path.
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(mainWorktree, registryDir, count, deps = {}) {
49
- const isAlive = deps.isAlive ?? isProcessAlive;
50
- const stop = deps.stop ?? stopProcessGroup;
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, TeardownContext, } from "./setup-worktree.js";
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;
@@ -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 {};
@@ -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, dev-server PID files, and dev-server logs.
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
- /** Tears down infrastructure on `--remove` (e.g. `docker compose down -v`). Best-effort; errors should be swallowed. */
42
- teardownInfrastructure?: (ctx: TeardownContext) => Promise<void> | void;
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.teardownInfrastructure}. */
67
- export interface TeardownContext {
76
+ /** Context passed to {@link SetupWorktreeConfig.purgeInfrastructure}. */
77
+ export interface PurgeContext {
68
78
  worktree: string;
69
79
  mainWorktree: string;
70
80
  verbose: boolean;
@@ -1,12 +1,11 @@
1
- import { spawn } from "node:child_process";
2
- import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, symlinkSync, writeFileSync, } from "node:fs";
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
- await stopAllDevServersInRuntimeDir(target.worktreePath, config.runtimeDir, verboseLog);
292
- if (config.teardownInfrastructure) {
293
- await config.teardownInfrastructure({
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
- async function stopAllDevServersInRuntimeDir(worktreePath, runtimeDir, log) {
410
- const dir = join(worktreePath, runtimeDir);
411
- let entries;
412
- try {
413
- entries = readdirSync(dir);
414
- }
415
- catch {
416
- return;
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
- for (const name of entries) {
419
- if (!name.endsWith(".pid"))
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paleo/worktree-env",
3
- "version": "0.5.2",
3
+ "version": "0.6.1",
4
4
  "description": "Worktree-based concurrent local environment kernel.",
5
5
  "keywords": [
6
6
  "worktree",