@paleo/worktree-env 0.1.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 ADDED
@@ -0,0 +1,93 @@
1
+ # @paleo/worktree-env
2
+
3
+ Run multiple local dev environments side by side, one per git worktree, with isolated ports, databases, and config files. Built for branches worked in parallel, by humans or AI agents.
4
+
5
+ Each project writes two custom scripts on top, using these entry points:
6
+
7
+ - `runSetupWorktree(config)` — worktree lifecycle (create / setup / remove / set-owner).
8
+ - `runDevServer(config)` — background dev-server start / stop / list.
9
+
10
+ ## Setup
11
+
12
+ The `worktree-env-guide` skill is a setup-time companion. Install the skill (globally or locally):
13
+
14
+ ```bash
15
+ npx skills add https://github.com/paleo/alignfirst --skill worktree-env-guide
16
+ ```
17
+
18
+ Then, in your project, ask your agent:
19
+
20
+ ```text
21
+ Use your worktree-env-guide skill. Set up worktree-based local environments in this project.
22
+ ```
23
+
24
+ The agent reads the skill, adapts the reference scripts to your stack, installs `@paleo/worktree-env` as a dev dependency, and wires the npm scripts. After that, you can uninstall the skill, it won't be used by your project anymore.
25
+
26
+ ## Workflow
27
+
28
+ ```sh
29
+ npm run setup-worktree -- --create feat/42 # new branch + worktree + isolated env
30
+ npm run dev:up # start dev server in the background
31
+ npm run dev:list # active dev-servers across all worktrees
32
+ npm run dev:down # stop dev server (infrastructure stays up)
33
+ npm run setup-worktree -- --remove feat/42 # full teardown
34
+ ```
35
+
36
+ ## API
37
+
38
+ ```ts
39
+ import { runSetupWorktree, helpers } from "@paleo/worktree-env";
40
+
41
+ await runSetupWorktree({
42
+ basePort: 8100,
43
+ portNames: ["server", "frontend", "db"],
44
+ devLimitEnvVar: "MYAPP_DEV_LIMIT",
45
+ devServerPidFiles: [".local-data/dev-server.pid"],
46
+ configFiles: [
47
+ {
48
+ path: ".env",
49
+ patch: (content, { ports }) =>
50
+ helpers.patchEnvFile(content, {
51
+ PORT: String(ports.frontend),
52
+ SERVER_PORT: String(ports.server),
53
+ }),
54
+ },
55
+ ],
56
+ provisionDatabase: async () => {},
57
+ installAndBuild: async () => {},
58
+ printSummary: ({ slot, branch, owner, ports }) =>
59
+ `Slot ${slot} (${branch}, ${owner}) — server :${ports.server}`,
60
+ });
61
+ ```
62
+
63
+ ```ts
64
+ import { runDevServer } from "@paleo/worktree-env";
65
+
66
+ await runDevServer({
67
+ basePort: 8100,
68
+ devLimitEnvVar: "MYAPP_DEV_LIMIT",
69
+ servers: [
70
+ {
71
+ name: "dev",
72
+ command: "npm",
73
+ args: ["run", "dev"],
74
+ pidFile: ".local-data/dev-server.pid",
75
+ logFile: ".local-data/logs/dev-server.log",
76
+ detectSuccess: (log) => log.includes("Server is ready on port"),
77
+ portConfig: { file: ".env", var: "PORT" },
78
+ },
79
+ ],
80
+ printSummary: ({ slot, servers }) =>
81
+ `Dev servers started in slot ${slot.slot} (${slot.owner}): ${servers
82
+ .map((s) => `${s.server.name} :${s.port} (PID ${s.pid})`)
83
+ .join(", ")}`,
84
+ });
85
+ ```
86
+
87
+ ## Build / test
88
+
89
+ ```sh
90
+ npm install
91
+ npm run build
92
+ npm test
93
+ ```
package/dist/cli.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ export interface SetupArgs {
2
+ help?: boolean;
3
+ use?: string;
4
+ create?: string;
5
+ self?: boolean;
6
+ owner?: string;
7
+ "set-owner"?: string;
8
+ remove?: string;
9
+ "remove-self"?: boolean;
10
+ "no-remote-check"?: boolean;
11
+ slot?: string;
12
+ force?: boolean;
13
+ verbose?: boolean;
14
+ }
15
+ export interface DevServerArgs {
16
+ help?: boolean;
17
+ stop?: boolean;
18
+ list?: boolean;
19
+ all?: boolean;
20
+ }
21
+ export declare function parseSetupArgs(argv?: string[]): SetupArgs;
22
+ export declare function parseDevServerArgs(argv?: string[]): DevServerArgs;
23
+ export declare function printSetupHelp(): void;
24
+ export declare function printDevServerHelp(): void;
25
+ export declare function isSetupMode(args: SetupArgs): boolean;
26
+ export declare function isRemoveMode(args: SetupArgs): boolean;
27
+ export declare function isSetOwnerMode(args: SetupArgs): boolean;
28
+ export declare function validateSetupFlags(args: SetupArgs): void;
29
+ export declare function validateDevServerFlags(args: DevServerArgs): void;
package/dist/cli.js ADDED
@@ -0,0 +1,129 @@
1
+ import { parseArgs } from "node:util";
2
+ import { ConfigError } from "./errors.js";
3
+ const SETUP_OPTIONS = {
4
+ help: { type: "boolean", short: "h", description: "Show this help message" },
5
+ use: {
6
+ type: "string",
7
+ arg: "branch",
8
+ description: "Create a worktree for an existing branch, then set up the local environment",
9
+ },
10
+ create: {
11
+ type: "string",
12
+ arg: "branch",
13
+ description: "Create a new branch + worktree, then set up the local environment. If the branch already exists, appends a numeric suffix (-2, -3, ...)",
14
+ },
15
+ self: {
16
+ type: "boolean",
17
+ description: "Set up the local environment in the current linked worktree",
18
+ },
19
+ owner: {
20
+ type: "string",
21
+ arg: "name",
22
+ description: 'Owner of the slot (free-form label, defaults to "default")',
23
+ },
24
+ "set-owner": {
25
+ type: "string",
26
+ arg: "name",
27
+ description: "Update the owner of the current linked worktree's slot (no rebuild)",
28
+ },
29
+ remove: {
30
+ type: "string",
31
+ arg: "branch",
32
+ description: "Remove a worktree by branch name (stop dev server, free slot, delete directory)",
33
+ },
34
+ "remove-self": {
35
+ type: "boolean",
36
+ description: "Remove the current linked worktree (same as --remove, but for the worktree you are in)",
37
+ },
38
+ "no-remote-check": {
39
+ type: "boolean",
40
+ description: "Skip remote branch verification when removing (use with --remove or --remove-self)",
41
+ },
42
+ slot: {
43
+ type: "string",
44
+ short: "s",
45
+ arg: "port",
46
+ description: "Use a specific slot instead of auto-assigning",
47
+ },
48
+ force: {
49
+ type: "boolean",
50
+ description: "Overwrite existing config files and re-provision the database",
51
+ },
52
+ verbose: { type: "boolean", short: "v", description: "Show intermediate output" },
53
+ };
54
+ const DEV_SERVER_OPTIONS = {
55
+ help: { type: "boolean", short: "h", description: "Show this help message" },
56
+ stop: { type: "boolean", description: "Stop dev servers in the current worktree" },
57
+ list: { type: "boolean", description: "List active dev-servers across all worktrees" },
58
+ all: { type: "boolean", description: "Apply --stop to every active dev-server" },
59
+ };
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
+ export function parseSetupArgs(argv) {
68
+ return parseOptions(argv, SETUP_OPTIONS);
69
+ }
70
+ export function parseDevServerArgs(argv) {
71
+ return parseOptions(argv, DEV_SERVER_OPTIONS);
72
+ }
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
+ export function printSetupHelp() {
84
+ console.log(formatHelp("setup-worktree [options]", "Manage worktree lifecycle: creation, local environment setup, and removal.", SETUP_OPTIONS));
85
+ }
86
+ export function printDevServerHelp() {
87
+ console.log(formatHelp("dev-server [options]", "Start, stop, or list background dev-server processes.", DEV_SERVER_OPTIONS));
88
+ }
89
+ export function isSetupMode(args) {
90
+ return args.use !== undefined || args.create !== undefined || Boolean(args.self);
91
+ }
92
+ export function isRemoveMode(args) {
93
+ return args.remove !== undefined || Boolean(args["remove-self"]);
94
+ }
95
+ export function isSetOwnerMode(args) {
96
+ return args["set-owner"] !== undefined;
97
+ }
98
+ export function validateSetupFlags(args) {
99
+ const modeFlags = [
100
+ args.use,
101
+ args.create,
102
+ args.self,
103
+ isRemoveMode(args),
104
+ isSetOwnerMode(args),
105
+ ].filter(Boolean);
106
+ if (modeFlags.length > 1) {
107
+ throw new ConfigError("Error: --use, --create, --self, --remove, --remove-self, and --set-owner are mutually exclusive.");
108
+ }
109
+ if (args.remove !== undefined && args["remove-self"]) {
110
+ throw new ConfigError("Error: --remove and --remove-self are mutually exclusive.");
111
+ }
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 --self.");
114
+ }
115
+ if (args.owner !== undefined && !isSetupMode(args)) {
116
+ throw new ConfigError("Error: --owner is only valid with --use, --create, or --self.");
117
+ }
118
+ if (args["no-remote-check"] && !isRemoveMode(args)) {
119
+ throw new ConfigError("Error: --no-remote-check is only valid with --remove or --remove-self.");
120
+ }
121
+ }
122
+ export function validateDevServerFlags(args) {
123
+ if (args.all && !args.stop) {
124
+ throw new ConfigError("Error: --all requires --stop.");
125
+ }
126
+ if (args.list && (args.stop || args.all)) {
127
+ throw new ConfigError("Error: --list is mutually exclusive with --stop and --all.");
128
+ }
129
+ }
@@ -0,0 +1,6 @@
1
+ export interface ReadDevLimitOptions {
2
+ projectVar: string;
3
+ defaultLimit?: number;
4
+ env?: NodeJS.ProcessEnv;
5
+ }
6
+ export declare function readDevLimit(opts: ReadDevLimitOptions): number;
@@ -0,0 +1,13 @@
1
+ export function readDevLimit(opts) {
2
+ const env = opts.env ?? process.env;
3
+ const defaultLimit = opts.defaultLimit ?? 5;
4
+ const candidates = [env[opts.projectVar], env.PROJECT_DEV_LIMIT];
5
+ for (const raw of candidates) {
6
+ if (raw === undefined || raw === "")
7
+ continue;
8
+ const parsed = Number(raw);
9
+ if (Number.isInteger(parsed) && parsed >= 0)
10
+ return parsed;
11
+ }
12
+ return defaultLimit;
13
+ }
@@ -0,0 +1,35 @@
1
+ import { type ResolvedSlot } from "./slots.js";
2
+ export type PortConfig = {
3
+ file: string;
4
+ var: string;
5
+ } | {
6
+ file: string;
7
+ jsonPath: string;
8
+ };
9
+ export interface DevServerConfig {
10
+ basePort: number;
11
+ devLimitEnvVar: string;
12
+ defaultLimit?: number;
13
+ servers: ServerDescriptor[];
14
+ ensureInfrastructure?: () => Promise<void> | void;
15
+ printSummary?: (ctx: DevServerSummaryContext) => string;
16
+ }
17
+ export interface ServerDescriptor {
18
+ name: string;
19
+ command: string;
20
+ args: string[];
21
+ pidFile: string;
22
+ logFile: string;
23
+ detectSuccess: (logContent: string) => boolean;
24
+ detectError?: (logContent: string) => string | false;
25
+ portConfig: PortConfig;
26
+ }
27
+ export interface DevServerSummaryContext {
28
+ slot: ResolvedSlot;
29
+ servers: {
30
+ server: ServerDescriptor;
31
+ port: number;
32
+ pid: number;
33
+ }[];
34
+ }
35
+ export declare function runDevServer(config: DevServerConfig): Promise<void>;
@@ -0,0 +1,234 @@
1
+ import { spawn } from "node:child_process";
2
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { createConnection } from "node:net";
4
+ import { dirname, join } from "node:path";
5
+ import { parseDevServerArgs, printDevServerHelp, validateDevServerFlags, } from "./cli.js";
6
+ import { readDevLimit } from "./dev-limit.js";
7
+ import { listDevServers, printActiveServers, pruneAndPersist, registerDevServer, stopAllRegistered, unregisterDevServer, } from "./dev-servers-registry.js";
8
+ import { ConfigError, StartupError } from "./errors.js";
9
+ import { awaitAllReady, handleStartupFailure } from "./log-polling.js";
10
+ import { cleanupPidFile, isProcessAlive, readPid, stopByPidFile } from "./process-control.js";
11
+ import { resolveCurrentSlot } from "./slots.js";
12
+ import { detectWorktree } from "./worktree.js";
13
+ function readEnvFileVar(filePath, varName) {
14
+ const content = readFileSync(filePath, "utf-8");
15
+ const match = content.match(new RegExp(`^${varName}=(.+)`, "m"));
16
+ if (!match) {
17
+ console.error(`Error: ${varName} not found in ${filePath}.`);
18
+ process.exit(1);
19
+ }
20
+ return match[1].trim();
21
+ }
22
+ function readJsonPath(filePath, jsonPath) {
23
+ const content = readFileSync(filePath, "utf-8");
24
+ const data = JSON.parse(content);
25
+ const segments = jsonPath.split(".");
26
+ let cur = data;
27
+ for (const seg of segments) {
28
+ if (cur === null || cur === undefined || typeof cur !== "object") {
29
+ console.error(`Error: ${jsonPath} not found in ${filePath}.`);
30
+ process.exit(1);
31
+ }
32
+ cur = cur[seg];
33
+ }
34
+ if (cur === undefined || cur === null) {
35
+ console.error(`Error: ${jsonPath} not found in ${filePath}.`);
36
+ process.exit(1);
37
+ }
38
+ return String(cur);
39
+ }
40
+ function readPortFromConfig(portConfig) {
41
+ if (!existsSync(portConfig.file)) {
42
+ console.error(`Error: ${portConfig.file} not found. Run setup-worktree first.`);
43
+ process.exit(1);
44
+ }
45
+ const raw = "var" in portConfig
46
+ ? readEnvFileVar(portConfig.file, portConfig.var)
47
+ : readJsonPath(portConfig.file, portConfig.jsonPath);
48
+ const port = Number(raw);
49
+ if (!Number.isFinite(port)) {
50
+ console.error(`Error: invalid port "${raw}" in ${portConfig.file}.`);
51
+ process.exit(1);
52
+ }
53
+ return port;
54
+ }
55
+ function isPortBusy(port) {
56
+ return new Promise((resolve) => {
57
+ const socket = createConnection({ port, host: "127.0.0.1" });
58
+ socket.setTimeout(500);
59
+ socket.once("connect", () => {
60
+ socket.destroy();
61
+ resolve(true);
62
+ });
63
+ socket.once("timeout", () => {
64
+ socket.destroy();
65
+ resolve(false);
66
+ });
67
+ socket.once("error", () => {
68
+ resolve(false);
69
+ });
70
+ });
71
+ }
72
+ function spawnServer(server) {
73
+ mkdirSync(dirname(server.logFile), { recursive: true });
74
+ mkdirSync(dirname(server.pidFile), { recursive: true });
75
+ const logFd = openSync(server.logFile, "w");
76
+ const child = spawn(server.command, server.args, {
77
+ detached: true,
78
+ stdio: ["ignore", logFd, logFd],
79
+ });
80
+ if (child.pid === undefined) {
81
+ closeSync(logFd);
82
+ console.error(`Error: failed to spawn ${server.name}.`);
83
+ process.exit(1);
84
+ }
85
+ writeFileSync(server.pidFile, String(child.pid));
86
+ child.unref();
87
+ closeSync(logFd);
88
+ return child.pid;
89
+ }
90
+ export async function runDevServer(config) {
91
+ let args;
92
+ try {
93
+ args = parseDevServerArgs();
94
+ }
95
+ catch (err) {
96
+ console.error(err.message);
97
+ process.exit(1);
98
+ }
99
+ if (args.help) {
100
+ printDevServerHelp();
101
+ return;
102
+ }
103
+ try {
104
+ validateDevServerFlags(args);
105
+ }
106
+ catch (err) {
107
+ if (err instanceof ConfigError) {
108
+ console.error(err.message);
109
+ process.exit(err.exitCode);
110
+ }
111
+ throw err;
112
+ }
113
+ const { mainWorktree } = detectWorktree();
114
+ if (args.list) {
115
+ listDevServers(mainWorktree);
116
+ return;
117
+ }
118
+ if (args.stop && args.all) {
119
+ await stopAllRegistered({
120
+ mainWorktree,
121
+ pidFiles: config.servers.map((s) => s.pidFile),
122
+ });
123
+ return;
124
+ }
125
+ if (args.stop) {
126
+ await stopLocal(config, mainWorktree);
127
+ return;
128
+ }
129
+ await start(config, mainWorktree);
130
+ }
131
+ async function start(config, mainWorktree) {
132
+ const limit = readDevLimit({
133
+ projectVar: config.devLimitEnvVar,
134
+ defaultLimit: config.defaultLimit,
135
+ });
136
+ const active = pruneAndPersist(mainWorktree).servers;
137
+ if (limit > 0 && active.length >= limit) {
138
+ console.error(`Error: dev-server cap reached (${active.length}/${limit}). Active dev-servers:`);
139
+ printActiveServers(active);
140
+ console.error("Run `dev:down` in another worktree, or `dev:down --all`.");
141
+ process.exit(1);
142
+ }
143
+ const serverPorts = config.servers.map((server) => [
144
+ server,
145
+ readPortFromConfig(server.portConfig),
146
+ ]);
147
+ const busyResults = await Promise.all(serverPorts.map(([, port]) => isPortBusy(port)));
148
+ let anyBusy = false;
149
+ busyResults.forEach((busy, i) => {
150
+ if (busy) {
151
+ const [server, port] = serverPorts[i];
152
+ console.error(`Error: Port ${port} (${server.name}) is already in use.`);
153
+ anyBusy = true;
154
+ }
155
+ });
156
+ if (anyBusy)
157
+ process.exit(1);
158
+ for (const server of config.servers) {
159
+ const existingPid = readPid(server.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(server.pidFile);
165
+ }
166
+ if (config.ensureInfrastructure)
167
+ await config.ensureInfrastructure();
168
+ const pids = [];
169
+ for (const server of config.servers) {
170
+ console.log(`Starting ${server.name} dev server...`);
171
+ pids.push(spawnServer(server));
172
+ }
173
+ try {
174
+ const pollables = config.servers.map((s) => ({
175
+ name: s.name,
176
+ logFile: s.logFile,
177
+ detectSuccess: s.detectSuccess,
178
+ detectError: s.detectError,
179
+ }));
180
+ await awaitAllReady(pollables, pids);
181
+ }
182
+ catch (err) {
183
+ if (err instanceof StartupError) {
184
+ handleStartupFailure(err);
185
+ console.error("\nStopping dev servers...");
186
+ await stopLocal(config, mainWorktree);
187
+ process.exit(1);
188
+ }
189
+ throw err;
190
+ }
191
+ const slot = resolveCurrentSlot(config.basePort);
192
+ const pidMap = {};
193
+ config.servers.forEach((server, i) => {
194
+ pidMap[server.name] = pids[i];
195
+ });
196
+ registerDevServer(mainWorktree, {
197
+ slot: slot.slot,
198
+ worktree: slot.worktree,
199
+ branch: slot.branch,
200
+ owner: slot.owner,
201
+ pids: pidMap,
202
+ startedAt: new Date().toISOString(),
203
+ });
204
+ if (config.printSummary) {
205
+ console.log(config.printSummary({
206
+ slot,
207
+ servers: config.servers.map((server, i) => ({
208
+ server,
209
+ port: serverPorts[i][1],
210
+ pid: pids[i],
211
+ })),
212
+ }));
213
+ }
214
+ else {
215
+ defaultPrintSummary(slot, config.servers, serverPorts.map(([, p]) => p), pids);
216
+ }
217
+ }
218
+ function defaultPrintSummary(slot, servers, ports, pids) {
219
+ console.log("\nDev servers started!");
220
+ console.log(` Worktree: slot ${slot.slot}, owner ${slot.owner}`);
221
+ servers.forEach((server, i) => {
222
+ const url = `http://localhost:${ports[i]}/`;
223
+ const logPath = join(process.cwd(), server.logFile);
224
+ console.log(` ${server.name}: ${url} (PID ${pids[i]})`);
225
+ console.log(` log: ${logPath}`);
226
+ });
227
+ console.log("");
228
+ }
229
+ async function stopLocal(config, mainWorktree) {
230
+ for (const server of config.servers) {
231
+ await stopByPidFile(server.pidFile, server.name, (msg) => console.log(msg));
232
+ }
233
+ unregisterDevServer(mainWorktree, process.cwd());
234
+ }
@@ -0,0 +1,28 @@
1
+ export declare const DEV_SERVERS_FILE = ".local/worktrees/dev-servers.json";
2
+ export declare const WORKTREES_DIR = ".local/worktrees";
3
+ export interface DevServerEntry {
4
+ slot: number;
5
+ worktree: string;
6
+ branch: string;
7
+ owner: string;
8
+ pids: Record<string, number>;
9
+ startedAt: string;
10
+ }
11
+ export interface DevServersData {
12
+ servers: DevServerEntry[];
13
+ }
14
+ export declare function readDevServers(mainWorktree: string): DevServersData;
15
+ export declare function writeDevServers(mainWorktree: string, data: DevServersData): void;
16
+ 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;
22
+ export declare function printActiveServers(active: DevServerEntry[]): void;
23
+ export declare function listDevServers(mainWorktree: string): void;
24
+ export interface StopAllInput {
25
+ mainWorktree: string;
26
+ pidFiles: string[];
27
+ }
28
+ export declare function stopAllRegistered(input: StopAllInput): Promise<void>;
@@ -0,0 +1,104 @@
1
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+ import { isProcessAlive, stopProcessGroup } from "./process-control.js";
4
+ export const DEV_SERVERS_FILE = ".local/worktrees/dev-servers.json";
5
+ export const WORKTREES_DIR = ".local/worktrees";
6
+ function filePath(mainWorktree) {
7
+ return join(mainWorktree, DEV_SERVERS_FILE);
8
+ }
9
+ export function readDevServers(mainWorktree) {
10
+ const fp = filePath(mainWorktree);
11
+ if (!existsSync(fp))
12
+ return { servers: [] };
13
+ return JSON.parse(readFileSync(fp, "utf-8"));
14
+ }
15
+ export function writeDevServers(mainWorktree, data) {
16
+ const fp = filePath(mainWorktree);
17
+ mkdirSync(join(mainWorktree, WORKTREES_DIR), { recursive: true });
18
+ writeFileSync(fp, `${JSON.stringify(data, undefined, 2)}\n`);
19
+ }
20
+ export function pruneDeadServers(data, isAlive = isProcessAlive) {
21
+ const live = data.servers.filter((entry) => Object.values(entry.pids).some((pid) => isAlive(pid)));
22
+ return { servers: live };
23
+ }
24
+ export function pruneAndPersist(mainWorktree, isAlive = isProcessAlive) {
25
+ const data = readDevServers(mainWorktree);
26
+ const pruned = pruneDeadServers(data, isAlive);
27
+ if (pruned.servers.length !== data.servers.length) {
28
+ writeDevServers(mainWorktree, pruned);
29
+ }
30
+ return pruned;
31
+ }
32
+ export function registerDevServer(mainWorktree, entry) {
33
+ const data = pruneAndPersist(mainWorktree);
34
+ data.servers.push(entry);
35
+ writeDevServers(mainWorktree, data);
36
+ }
37
+ export function unregisterDevServer(mainWorktree, worktreePath) {
38
+ const fp = filePath(mainWorktree);
39
+ if (!existsSync(fp))
40
+ return;
41
+ const data = pruneAndPersist(mainWorktree);
42
+ const target = resolve(worktreePath);
43
+ const filtered = data.servers.filter((entry) => resolve(entry.worktree) !== target);
44
+ if (filtered.length === data.servers.length)
45
+ return;
46
+ writeDevServers(mainWorktree, { servers: filtered });
47
+ }
48
+ export function removeDevServerEntryByWorktree(mainWorktree, worktreePath) {
49
+ const fp = filePath(mainWorktree);
50
+ if (!existsSync(fp))
51
+ return;
52
+ const data = readDevServers(mainWorktree);
53
+ const target = resolve(worktreePath);
54
+ const filtered = data.servers.filter((entry) => resolve(entry.worktree) !== target);
55
+ if (filtered.length === data.servers.length)
56
+ return;
57
+ writeDevServers(mainWorktree, { servers: filtered });
58
+ }
59
+ function formatEntry(entry) {
60
+ const pids = Object.entries(entry.pids)
61
+ .map(([name, pid]) => `${name}=${pid}`)
62
+ .join(",");
63
+ return ` slot ${entry.slot} branch=${entry.branch} owner=${entry.owner} pids=${pids} startedAt=${entry.startedAt} worktree=${entry.worktree}`;
64
+ }
65
+ export function printActiveServers(active) {
66
+ const sorted = [...active].sort((a, b) => a.slot - b.slot);
67
+ for (const entry of sorted) {
68
+ process.stderr.write(`${formatEntry(entry)}\n`);
69
+ }
70
+ }
71
+ export function listDevServers(mainWorktree) {
72
+ const data = pruneAndPersist(mainWorktree);
73
+ if (data.servers.length === 0) {
74
+ console.log("No dev-servers running.");
75
+ return;
76
+ }
77
+ const sorted = [...data.servers].sort((a, b) => a.slot - b.slot);
78
+ for (const entry of sorted) {
79
+ console.log(formatEntry(entry));
80
+ }
81
+ }
82
+ export async function stopAllRegistered(input) {
83
+ const data = pruneAndPersist(input.mainWorktree);
84
+ if (data.servers.length === 0) {
85
+ console.log("No dev-servers running.");
86
+ return;
87
+ }
88
+ for (const entry of data.servers) {
89
+ console.log(`Stopping slot ${entry.slot} (${entry.branch}, owner=${entry.owner})...`);
90
+ for (const [name, pid] of Object.entries(entry.pids)) {
91
+ if (!isProcessAlive(pid))
92
+ continue;
93
+ console.log(` ${name} (PID ${pid})`);
94
+ await stopProcessGroup(pid);
95
+ }
96
+ for (const pidFile of input.pidFiles) {
97
+ const fp = join(entry.worktree, pidFile);
98
+ if (existsSync(fp))
99
+ unlinkSync(fp);
100
+ }
101
+ }
102
+ writeDevServers(input.mainWorktree, { servers: [] });
103
+ console.log(`Stopped ${data.servers.length} dev-server(s).`);
104
+ }
@@ -0,0 +1,11 @@
1
+ export declare class StartupError extends Error {
2
+ label: string;
3
+ reason: string;
4
+ logFile: string | undefined;
5
+ constructor(label: string, reason: string, logFile?: string);
6
+ }
7
+ export declare class ConfigError extends Error {
8
+ exitCode: number;
9
+ constructor(message: string, exitCode?: number);
10
+ }
11
+ export declare function exitWith(code: number, message: string): never;