@paleo/worktree-env 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,6 +28,7 @@ The agent reads the skill, adapts the reference scripts to your stack, installs
28
28
  ```sh
29
29
  npm run setup-worktree -- --create feat/42 # new branch + worktree + isolated env
30
30
  npm run dev:up # start dev server in the background
31
+ npm run dev:up -- --evict # if devLimit is reached, evict the oldest dev-server and start
31
32
  npm run dev:list # active dev-servers across all worktrees
32
33
  npm run dev:down # stop dev server (infrastructure stays up)
33
34
  npm run setup-worktree -- --remove feat/42 # full teardown
@@ -41,7 +42,9 @@ import { runSetupWorktree, helpers } from "@paleo/worktree-env";
41
42
  await runSetupWorktree({
42
43
  basePort: 8100,
43
44
  portNames: ["server", "frontend", "db"],
44
- devServerPidFiles: [".local-data/dev-server.pid"],
45
+ sharedDirs: [".local", ".plans"],
46
+ runtimeDir: ".local-wt",
47
+ registryDir: ".local/wt-registry",
45
48
  configFiles: [
46
49
  {
47
50
  path: ".env",
@@ -52,28 +55,31 @@ await runSetupWorktree({
52
55
  }),
53
56
  },
54
57
  ],
55
- setupWorktreeData: async ({ currentWorktree }) => {
56
- // Create per-worktree directories, copy seed data, start containers, etc.
58
+ finalizeWorktree: async ({ currentWorktree }) => {
59
+ // MUST be idempotent. Install deps, start containers, seed a database, etc.
57
60
  },
58
- installAndBuild: async () => {},
59
61
  printSummary: ({ slot, branch, owner, ports }) =>
60
62
  `Slot ${slot} (${branch}${owner ? `, ${owner}` : ""}) — server :${ports.server}`,
61
63
  });
62
64
  ```
63
65
 
66
+ Setup runs in two phases: a fast foreground Part 1 creates the worktree and config, then a detached Part 2 runs `finalizeWorktree` and writes progress to `<runtimeDir>/wt-setup.log`. If Part 2 fails, `cd` into the worktree and run `setup-worktree --here` — it is idempotent and retries the finalize step. To block until Part 2 finishes (CI, agent orchestration), run `setup-worktree --wait` from inside the worktree (or `setup-worktree --wait --slot 8110` from anywhere) — exits 0 on `READY`, 1 on `FAILED`.
67
+
68
+ `--evict` is best-effort: the cap check and the subsequent register are not atomic, so two concurrent `dev:up --evict` from different worktrees can both pass the check and end up at `devLimit + 1` live servers. The window is narrow; if it matters, `dev:list` + `dev:down` deterministically.
69
+
64
70
  ```ts
65
71
  import { runDevServer, helpers } from "@paleo/worktree-env";
66
72
 
67
73
  await runDevServer({
68
74
  basePort: 8100,
75
+ runtimeDir: ".local-wt",
76
+ registryDir: ".local/wt-registry",
69
77
  devLimit: 5,
70
78
  servers: [
71
79
  {
72
80
  name: "dev",
73
81
  exec: { command: "npm", args: ["run", "dev"] },
74
82
  port: helpers.readPortFromEnvFile(".env", "PORT"),
75
- pidFile: ".local-data/dev-server.pid",
76
- logFile: ".local-data/logs/dev-server.log",
77
83
  detectSuccess: (log) => log.includes("Server is ready on port"),
78
84
  },
79
85
  ],
package/dist/cli.d.ts CHANGED
@@ -11,19 +11,27 @@ export interface SetupArgs {
11
11
  slot?: string;
12
12
  force?: boolean;
13
13
  verbose?: boolean;
14
+ wait?: boolean;
15
+ info?: boolean;
16
+ /** @internal Set by `runSetupWorktree` when it re-spawns itself to run the finalize phase. */
17
+ __finalize?: string;
14
18
  }
15
19
  export interface DevServerArgs {
16
20
  help?: boolean;
17
21
  stop?: boolean;
18
22
  list?: boolean;
19
23
  all?: boolean;
24
+ evict?: boolean;
20
25
  }
21
26
  export declare function parseSetupArgs(argv?: string[]): SetupArgs;
22
27
  export declare function parseDevServerArgs(argv?: string[]): DevServerArgs;
23
28
  export declare function printSetupHelp(): void;
24
29
  export declare function printDevServerHelp(): void;
30
+ export declare function validateSetupFlags(args: SetupArgs): void;
31
+ export declare function validateDevServerFlags(args: DevServerArgs): void;
25
32
  export declare function isSetupMode(args: SetupArgs): boolean;
26
33
  export declare function isRemoveMode(args: SetupArgs): boolean;
27
34
  export declare function isSetOwnerMode(args: SetupArgs): boolean;
28
- export declare function validateSetupFlags(args: SetupArgs): void;
29
- export declare function validateDevServerFlags(args: DevServerArgs): void;
35
+ export declare function isFinalizeMode(args: SetupArgs): boolean;
36
+ export declare function isWaitMode(args: SetupArgs): boolean;
37
+ export declare function isInfoMode(args: SetupArgs): boolean;
package/dist/cli.js CHANGED
@@ -50,51 +50,35 @@ const SETUP_OPTIONS = {
50
50
  description: "Overwrite existing config files and re-provision the database",
51
51
  },
52
52
  verbose: { type: "boolean", short: "v", description: "Show intermediate output" },
53
+ wait: {
54
+ type: "boolean",
55
+ description: "Wait for the background finalize to reach READY (exit 0, prints the worktree summary) or FAILED (exit 1). Uses the current worktree's slot, or --slot PORT to target another.",
56
+ },
57
+ info: {
58
+ type: "boolean",
59
+ description: "Print the worktree summary (ports, branch, readiness) for the current worktree.",
60
+ },
61
+ __finalize: { type: "string", arg: "slot", description: "" },
53
62
  };
54
63
  const DEV_SERVER_OPTIONS = {
55
64
  help: { type: "boolean", short: "h", description: "Show this help message" },
56
65
  stop: { type: "boolean", description: "Stop dev servers in the current worktree" },
57
66
  list: { type: "boolean", description: "List active dev-servers across all worktrees" },
58
67
  all: { type: "boolean", description: "Apply --stop to every active dev-server" },
68
+ evict: { type: "boolean", description: "Evict the oldest dev-server when the cap is reached" },
59
69
  };
60
- function parseOptions(argv, options) {
61
- const cfg = { options: options, strict: true };
62
- if (argv)
63
- cfg.args = argv;
64
- const { values } = parseArgs(cfg);
65
- return values;
66
- }
67
70
  export function parseSetupArgs(argv) {
68
71
  return parseOptions(argv, SETUP_OPTIONS);
69
72
  }
70
73
  export function parseDevServerArgs(argv) {
71
74
  return parseOptions(argv, DEV_SERVER_OPTIONS);
72
75
  }
73
- function formatHelp(usage, intro, options) {
74
- const lines = [`Usage: ${usage}`, "", intro, ""];
75
- for (const [name, opt] of Object.entries(options)) {
76
- const shortFlag = opt.short ? `-${opt.short}, ` : "";
77
- const argSuffix = opt.arg ? ` <${opt.arg}>` : "";
78
- const flag = `${shortFlag}--${name}${argSuffix}`;
79
- lines.push(` ${flag.padEnd(28)} ${opt.description}`);
80
- }
81
- return lines.join("\n");
82
- }
83
76
  export function printSetupHelp() {
84
77
  console.log(formatHelp("setup-worktree [options]", "Manage worktree lifecycle: creation, local environment setup, and removal.", SETUP_OPTIONS));
85
78
  }
86
79
  export function printDevServerHelp() {
87
80
  console.log(formatHelp("dev-server [options]", "Start, stop, or list background dev-server processes.", DEV_SERVER_OPTIONS));
88
81
  }
89
- export function isSetupMode(args) {
90
- return args.use !== undefined || args.create !== undefined || Boolean(args.here);
91
- }
92
- export function isRemoveMode(args) {
93
- return args.remove !== undefined || Boolean(args["remove-here"]);
94
- }
95
- export function isSetOwnerMode(args) {
96
- return args["set-owner"] !== undefined;
97
- }
98
82
  export function validateSetupFlags(args) {
99
83
  const modeFlags = [
100
84
  args.use,
@@ -102,15 +86,21 @@ export function validateSetupFlags(args) {
102
86
  args.here,
103
87
  isRemoveMode(args),
104
88
  isSetOwnerMode(args),
89
+ isFinalizeMode(args),
90
+ isWaitMode(args),
91
+ isInfoMode(args),
105
92
  ].filter(Boolean);
106
93
  if (modeFlags.length > 1) {
107
- throw new ConfigError("Error: --use, --create, --here, --remove, --remove-here, and --set-owner are mutually exclusive.");
94
+ throw new ConfigError("Error: --use, --create, --here, --remove, --remove-here, --set-owner, --wait, and --info are mutually exclusive.");
108
95
  }
109
96
  if (args.remove !== undefined && args["remove-here"]) {
110
97
  throw new ConfigError("Error: --remove and --remove-here are mutually exclusive.");
111
98
  }
112
- if ((args.slot !== undefined || args.force) && !isSetupMode(args)) {
113
- throw new ConfigError("Error: --slot and --force can only be used with --use, --create, or --here.");
99
+ if (args.slot !== undefined && !isSetupMode(args) && !isWaitMode(args)) {
100
+ throw new ConfigError("Error: --slot can only be used with --use, --create, --here, or --wait.");
101
+ }
102
+ if (args.force && !isSetupMode(args) && !isFinalizeMode(args)) {
103
+ throw new ConfigError("Error: --force can only be used with --use, --create, or --here.");
114
104
  }
115
105
  if (args.owner !== undefined && !isSetupMode(args)) {
116
106
  throw new ConfigError("Error: --owner is only valid with --use, --create, or --here.");
@@ -126,4 +116,45 @@ export function validateDevServerFlags(args) {
126
116
  if (args.list && (args.stop || args.all)) {
127
117
  throw new ConfigError("Error: --list is mutually exclusive with --stop and --all.");
128
118
  }
119
+ if (args.evict && (args.stop || args.list || args.all)) {
120
+ const conflict = args.stop ? "--stop" : args.list ? "--list" : "--all";
121
+ throw new ConfigError(`Error: --evict cannot be combined with ${conflict}.`);
122
+ }
123
+ }
124
+ export function isSetupMode(args) {
125
+ return args.use !== undefined || args.create !== undefined || Boolean(args.here);
126
+ }
127
+ export function isRemoveMode(args) {
128
+ return args.remove !== undefined || Boolean(args["remove-here"]);
129
+ }
130
+ export function isSetOwnerMode(args) {
131
+ return args["set-owner"] !== undefined;
132
+ }
133
+ export function isFinalizeMode(args) {
134
+ return args.__finalize !== undefined;
135
+ }
136
+ export function isWaitMode(args) {
137
+ return Boolean(args.wait);
138
+ }
139
+ export function isInfoMode(args) {
140
+ return Boolean(args.info);
141
+ }
142
+ function parseOptions(argv, options) {
143
+ const cfg = { options: options, strict: true };
144
+ if (argv)
145
+ cfg.args = argv;
146
+ const { values } = parseArgs(cfg);
147
+ return values;
148
+ }
149
+ function formatHelp(usage, intro, options) {
150
+ const lines = [`Usage: ${usage}`, "", intro, ""];
151
+ for (const [name, opt] of Object.entries(options)) {
152
+ if (opt.description === "")
153
+ continue;
154
+ const shortFlag = opt.short ? `-${opt.short}, ` : "";
155
+ const argSuffix = opt.arg ? ` <${opt.arg}>` : "";
156
+ const flag = `${shortFlag}--${name}${argSuffix}`;
157
+ lines.push(` ${flag.padEnd(28)} ${opt.description}`);
158
+ }
159
+ return lines.join("\n");
129
160
  }
@@ -3,6 +3,14 @@ import { type ResolvedSlot } from "./slots.js";
3
3
  export interface DevServerConfig {
4
4
  /** Anchor port for the slot range. Used to synthesize the main worktree's slot. */
5
5
  basePort: number;
6
+ /** Per-worktree runtime directory, relative to the worktree root (e.g. `.local-wt`). */
7
+ runtimeDir: string;
8
+ /**
9
+ * Shared registry directory, relative to a worktree root (e.g. `.local/wt-registry`).
10
+ * Holds `slots.json` and `dev-servers.json`. Must resolve to the same physical directory
11
+ * across linked worktrees — typically via a symlink (e.g. `.local`).
12
+ */
13
+ registryDir: string;
6
14
  /** Maximum concurrent dev-servers across all worktrees. Omit for no limit. */
7
15
  devLimit?: number;
8
16
  /** One entry per process to spawn. Started in array order. */
@@ -14,7 +22,7 @@ export interface DevServerConfig {
14
22
  }
15
23
  /** Describes one process to spawn. */
16
24
  export interface ServerDescriptor {
17
- /** Short label used in logs and the registry. */
25
+ /** Short label used in logs and the registry. Derives `<runtimeDir>/<name>.pid` and `<runtimeDir>/logs/<name>.log`. */
18
26
  name: string;
19
27
  /** Command and arguments passed to `child_process.spawn`. */
20
28
  exec: {
@@ -23,10 +31,6 @@ export interface ServerDescriptor {
23
31
  };
24
32
  /** Port the process will listen on. Use `helpers.readPortFromEnvFile` / `readPortFromJsonFile` to read it from a config file. */
25
33
  port: number;
26
- /** Path (relative to cwd) where the spawned PID is written. */
27
- pidFile: string;
28
- /** Path (relative to cwd) where stdout+stderr are tee'd. */
29
- logFile: string;
30
34
  /** Returns `true` once the log content indicates the server is ready. */
31
35
  detectSuccess: (logContent: string) => boolean;
32
36
  /** Returns a non-empty marker string when the log content indicates a fatal error, or `false` otherwise. */
@@ -3,46 +3,17 @@ import { closeSync, mkdirSync, openSync, writeFileSync } 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 { listDevServers, printActiveServers, pruneAndPersist, registerDevServer, stopAllRegistered, unregisterDevServer, } from "./dev-servers-registry.js";
6
+ import { evictOldest, listDevServers, printActiveServers, pruneAndPersist, registerDevServer, 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
9
  import { cleanupPidFile, isProcessAlive, readPid, stopByPidFile } from "./process-control.js";
10
10
  import { resolveCurrentSlot } from "./slots.js";
11
11
  import { detectWorktree } from "./worktree.js";
12
- function isPortBusy(port) {
13
- return new Promise((resolve) => {
14
- const socket = createConnection({ port, host: "127.0.0.1" });
15
- socket.setTimeout(500);
16
- socket.once("connect", () => {
17
- socket.destroy();
18
- resolve(true);
19
- });
20
- socket.once("timeout", () => {
21
- socket.destroy();
22
- resolve(false);
23
- });
24
- socket.once("error", () => {
25
- resolve(false);
26
- });
27
- });
12
+ function pidFileFor(runtimeDir, name) {
13
+ return join(runtimeDir, `${name}.pid`);
28
14
  }
29
- function spawnServer(server) {
30
- mkdirSync(dirname(server.logFile), { recursive: true });
31
- mkdirSync(dirname(server.pidFile), { recursive: true });
32
- const logFd = openSync(server.logFile, "w");
33
- const child = spawn(server.exec.command, server.exec.args, {
34
- detached: true,
35
- stdio: ["ignore", logFd, logFd],
36
- });
37
- if (child.pid === undefined) {
38
- closeSync(logFd);
39
- console.error(`Error: failed to spawn ${server.name}.`);
40
- process.exit(1);
41
- }
42
- writeFileSync(server.pidFile, String(child.pid));
43
- child.unref();
44
- closeSync(logFd);
45
- return child.pid;
15
+ function logFileFor(runtimeDir, name) {
16
+ return join(runtimeDir, "logs", `${name}.log`);
46
17
  }
47
18
  export async function runDevServer(config) {
48
19
  let args;
@@ -69,13 +40,14 @@ export async function runDevServer(config) {
69
40
  }
70
41
  const { mainWorktree } = detectWorktree();
71
42
  if (args.list) {
72
- listDevServers(mainWorktree);
43
+ listDevServers(mainWorktree, config.registryDir);
73
44
  return;
74
45
  }
75
46
  if (args.stop && args.all) {
76
47
  await stopAllRegistered({
77
48
  mainWorktree,
78
- pidFiles: config.servers.map((s) => s.pidFile),
49
+ registryDir: config.registryDir,
50
+ runtimeDir: config.runtimeDir,
79
51
  });
80
52
  return;
81
53
  }
@@ -83,51 +55,23 @@ export async function runDevServer(config) {
83
55
  await stopLocal(config, mainWorktree);
84
56
  return;
85
57
  }
86
- await start(config, mainWorktree);
58
+ await start(config, mainWorktree, { evict: Boolean(args.evict) });
87
59
  }
88
- async function start(config, mainWorktree) {
89
- const limit = config.devLimit;
90
- const active = pruneAndPersist(mainWorktree).servers;
91
- if (limit !== undefined && active.length >= limit) {
92
- console.error(`Error: dev-server cap reached (${active.length}/${limit}). Active dev-servers:`);
93
- printActiveServers(active);
94
- console.error("Run `dev:down` in another worktree, or `dev:down --all`.");
95
- process.exit(1);
96
- }
97
- const serverPorts = config.servers.map((server) => [
98
- server,
99
- server.port,
100
- ]);
101
- const busyResults = await Promise.all(serverPorts.map(([, port]) => isPortBusy(port)));
102
- let anyBusy = false;
103
- busyResults.forEach((busy, i) => {
104
- if (busy) {
105
- const [server, port] = serverPorts[i];
106
- console.error(`Error: Port ${port} (${server.name}) is already in use.`);
107
- anyBusy = true;
108
- }
109
- });
110
- if (anyBusy)
111
- process.exit(1);
112
- for (const server of config.servers) {
113
- const existingPid = readPid(server.pidFile);
114
- if (existingPid !== undefined && isProcessAlive(existingPid)) {
115
- console.error(`Error: ${server.name} is already running (PID ${existingPid}).`);
116
- process.exit(1);
117
- }
118
- cleanupPidFile(server.pidFile);
119
- }
60
+ async function start(config, mainWorktree, { evict }) {
61
+ await enforceCap(config, mainWorktree, evict);
62
+ await checkPortsFree(config.servers);
63
+ checkNoLocalPidConflict(config);
120
64
  if (config.ensureInfrastructure)
121
65
  await config.ensureInfrastructure();
122
66
  const pids = [];
123
67
  for (const server of config.servers) {
124
68
  console.log(`Starting ${server.name} dev server...`);
125
- pids.push(spawnServer(server));
69
+ pids.push(spawnServer(server, config.runtimeDir));
126
70
  }
127
71
  try {
128
72
  const pollables = config.servers.map((s) => ({
129
73
  name: s.name,
130
- logFile: s.logFile,
74
+ logFile: logFileFor(config.runtimeDir, s.name),
131
75
  detectSuccess: s.detectSuccess,
132
76
  detectError: s.detectError,
133
77
  }));
@@ -142,12 +86,12 @@ async function start(config, mainWorktree) {
142
86
  }
143
87
  throw err;
144
88
  }
145
- const slot = resolveCurrentSlot(config.basePort);
89
+ const slot = resolveCurrentSlot(config.basePort, config.registryDir);
146
90
  const pidMap = {};
147
91
  config.servers.forEach((server, i) => {
148
92
  pidMap[server.name] = pids[i];
149
93
  });
150
- registerDevServer(mainWorktree, {
94
+ registerDevServer(mainWorktree, config.registryDir, {
151
95
  slot: slot.slot,
152
96
  worktree: slot.worktree,
153
97
  branch: slot.branch,
@@ -160,30 +104,118 @@ async function start(config, mainWorktree) {
160
104
  slot,
161
105
  servers: config.servers.map((server, i) => ({
162
106
  server,
163
- port: serverPorts[i][1],
107
+ port: server.port,
164
108
  pid: pids[i],
165
109
  })),
166
110
  }));
167
111
  }
168
112
  else {
169
- defaultPrintSummary(slot, config.servers, serverPorts.map(([, p]) => p), pids);
113
+ defaultPrintSummary(slot, config.servers, config.servers.map((s) => s.port), pids, config.runtimeDir);
170
114
  }
171
115
  }
172
- function defaultPrintSummary(slot, servers, ports, pids) {
116
+ // TOCTOU: the cap check and the subsequent register are not atomic. Two concurrent `dev:up --evict`
117
+ // from different worktrees can both pass the cap check and both register, exceeding the limit by
118
+ // one. Accepted: the race window is narrow and the consequence is bounded (one extra dev-server).
119
+ async function enforceCap(config, mainWorktree, evict) {
120
+ const limit = config.devLimit;
121
+ if (limit === undefined)
122
+ return;
123
+ const active = pruneAndPersist(mainWorktree, config.registryDir).servers;
124
+ if (active.length < limit)
125
+ return;
126
+ if (!evict) {
127
+ console.error(`Error: dev-server cap reached (${active.length}/${limit}). Active dev-servers:`);
128
+ printActiveServers(active);
129
+ console.error("Run `dev:down` in another worktree, or `dev:down --all`.");
130
+ console.error("Re-run with --evict to evict the oldest.");
131
+ process.exit(1);
132
+ }
133
+ const toEvict = active.length - limit + 1;
134
+ console.log(`Evicting ${toEvict} dev-server(s) to make room (cap ${limit}).`);
135
+ const evicted = await evictOldest(mainWorktree, config.registryDir, toEvict);
136
+ for (const entry of evicted) {
137
+ const ownerPart = entry.owner ? `, owner=${entry.owner}` : "";
138
+ 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
+ }
143
+ }
144
+ async function checkPortsFree(servers) {
145
+ const busy = await Promise.all(servers.map((s) => isPortBusy(s.port)));
146
+ let anyBusy = false;
147
+ busy.forEach((b, i) => {
148
+ if (b) {
149
+ console.error(`Error: Port ${servers[i].port} (${servers[i].name}) is already in use.`);
150
+ anyBusy = true;
151
+ }
152
+ });
153
+ if (anyBusy)
154
+ process.exit(1);
155
+ }
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}).`);
162
+ process.exit(1);
163
+ }
164
+ cleanupPidFile(pidFile);
165
+ }
166
+ }
167
+ 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));
170
+ }
171
+ unregisterDevServer(mainWorktree, config.registryDir, process.cwd());
172
+ }
173
+ function defaultPrintSummary(slot, servers, ports, pids, runtimeDir) {
173
174
  console.log("\nDev servers started!");
174
175
  const ownerSuffix = slot.owner ? `, owner ${slot.owner}` : "";
175
176
  console.log(` Worktree: slot ${slot.slot}${ownerSuffix}`);
176
177
  servers.forEach((server, i) => {
177
178
  const url = `http://localhost:${ports[i]}/`;
178
- const logPath = join(process.cwd(), server.logFile);
179
+ const logPath = join(process.cwd(), logFileFor(runtimeDir, server.name));
179
180
  console.log(` ${server.name}: ${url} (PID ${pids[i]})`);
180
181
  console.log(` log: ${logPath}`);
181
182
  });
182
183
  console.log("");
183
184
  }
184
- async function stopLocal(config, mainWorktree) {
185
- for (const server of config.servers) {
186
- await stopByPidFile(server.pidFile, server.name, (msg) => console.log(msg));
185
+ function spawnServer(server, runtimeDir) {
186
+ const logFile = logFileFor(runtimeDir, server.name);
187
+ const pidFile = pidFileFor(runtimeDir, server.name);
188
+ mkdirSync(dirname(logFile), { recursive: true });
189
+ mkdirSync(dirname(pidFile), { recursive: true });
190
+ const logFd = openSync(logFile, "w");
191
+ const child = spawn(server.exec.command, server.exec.args, {
192
+ detached: true,
193
+ stdio: ["ignore", logFd, logFd],
194
+ });
195
+ if (child.pid === undefined) {
196
+ closeSync(logFd);
197
+ console.error(`Error: failed to spawn ${server.name}.`);
198
+ process.exit(1);
187
199
  }
188
- unregisterDevServer(mainWorktree, process.cwd());
200
+ writeFileSync(pidFile, String(child.pid));
201
+ child.unref();
202
+ closeSync(logFd);
203
+ return child.pid;
204
+ }
205
+ function isPortBusy(port) {
206
+ return new Promise((resolve) => {
207
+ const socket = createConnection({ port, host: "127.0.0.1" });
208
+ socket.setTimeout(500);
209
+ socket.once("connect", () => {
210
+ socket.destroy();
211
+ resolve(true);
212
+ });
213
+ socket.once("timeout", () => {
214
+ socket.destroy();
215
+ resolve(false);
216
+ });
217
+ socket.once("error", () => {
218
+ resolve(false);
219
+ });
220
+ });
189
221
  }
@@ -1,5 +1,3 @@
1
- export declare const DEV_SERVERS_FILE = ".local/worktrees/dev-servers.json";
2
- export declare const WORKTREES_DIR = ".local/worktrees";
3
1
  export interface DevServerEntry {
4
2
  slot: number;
5
3
  worktree: string;
@@ -11,18 +9,24 @@ export interface DevServerEntry {
11
9
  export interface DevServersData {
12
10
  servers: DevServerEntry[];
13
11
  }
14
- export declare function readDevServers(mainWorktree: string): DevServersData;
15
- export declare function writeDevServers(mainWorktree: string, data: DevServersData): void;
16
12
  export type IsAliveFn = (pid: number) => boolean;
17
- export declare function pruneDeadServers(data: DevServersData, isAlive?: IsAliveFn): DevServersData;
18
- export declare function pruneAndPersist(mainWorktree: string, isAlive?: IsAliveFn): DevServersData;
19
- export declare function registerDevServer(mainWorktree: string, entry: DevServerEntry): void;
20
- export declare function unregisterDevServer(mainWorktree: string, worktreePath: string): void;
21
- export declare function removeDevServerEntryByWorktree(mainWorktree: string, worktreePath: string): void;
13
+ export declare function listDevServers(mainWorktree: string, registryDir: string): void;
22
14
  export declare function printActiveServers(active: DevServerEntry[]): void;
23
- export declare function listDevServers(mainWorktree: string): void;
24
15
  export interface StopAllInput {
25
16
  mainWorktree: string;
26
- pidFiles: string[];
17
+ registryDir: string;
18
+ runtimeDir: string;
27
19
  }
28
20
  export declare function stopAllRegistered(input: StopAllInput): Promise<void>;
21
+ export interface EvictDeps {
22
+ isAlive?: IsAliveFn;
23
+ stop?: (pid: number) => Promise<void>;
24
+ }
25
+ export declare function evictOldest(mainWorktree: string, registryDir: string, count: number, deps?: EvictDeps): Promise<DevServerEntry[]>;
26
+ export declare function registerDevServer(mainWorktree: string, registryDir: string, entry: DevServerEntry): void;
27
+ export declare function unregisterDevServer(mainWorktree: string, registryDir: string, worktreePath: string): void;
28
+ export declare function removeDevServerEntryByWorktree(mainWorktree: string, registryDir: string, worktreePath: string): void;
29
+ export declare function pruneAndPersist(mainWorktree: string, registryDir: string, isAlive?: IsAliveFn): DevServersData;
30
+ export declare function pruneDeadServers(data: DevServersData, isAlive?: IsAliveFn): DevServersData;
31
+ export declare function readDevServers(mainWorktree: string, registryDir: string): DevServersData;
32
+ export declare function writeDevServers(mainWorktree: string, registryDir: string, data: DevServersData): void;