@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 +12 -6
- package/dist/cli.d.ts +10 -2
- package/dist/cli.js +60 -29
- package/dist/dev-server.d.ts +9 -5
- package/dist/dev-server.js +113 -81
- package/dist/dev-servers-registry.d.ts +15 -11
- package/dist/dev-servers-registry.js +92 -74
- package/dist/errors.d.ts +0 -1
- package/dist/errors.js +0 -4
- package/dist/helpers.js +8 -8
- package/dist/log-polling.d.ts +0 -1
- package/dist/log-polling.js +13 -13
- package/dist/ports.d.ts +5 -5
- package/dist/process-control.d.ts +2 -2
- package/dist/process-control.js +22 -22
- package/dist/setup-worktree.d.ts +60 -48
- package/dist/setup-worktree.js +257 -102
- package/dist/slots.d.ts +20 -19
- package/dist/slots.js +93 -67
- package/dist/worktree.d.ts +5 -6
- package/dist/worktree.js +31 -31
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
56
|
-
//
|
|
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
|
|
29
|
-
export declare function
|
|
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,
|
|
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 (
|
|
113
|
-
throw new ConfigError("Error: --slot
|
|
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
|
}
|
package/dist/dev-server.d.ts
CHANGED
|
@@ -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. */
|
package/dist/dev-server.js
CHANGED
|
@@ -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
|
|
13
|
-
return
|
|
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
|
|
30
|
-
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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.
|
|
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:
|
|
107
|
+
port: server.port,
|
|
164
108
|
pid: pids[i],
|
|
165
109
|
})),
|
|
166
110
|
}));
|
|
167
111
|
}
|
|
168
112
|
else {
|
|
169
|
-
defaultPrintSummary(slot, config.servers,
|
|
113
|
+
defaultPrintSummary(slot, config.servers, config.servers.map((s) => s.port), pids, config.runtimeDir);
|
|
170
114
|
}
|
|
171
115
|
}
|
|
172
|
-
|
|
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.
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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;
|