@paleo/workspace 0.11.1 → 0.13.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 +13 -11
- package/dist/cli.d.ts +18 -11
- package/dist/cli.js +97 -59
- package/dist/dev-server.d.ts +2 -2
- package/dist/dev-server.js +162 -71
- package/dist/dev-servers-registry.d.ts +2 -0
- package/dist/dev-servers-registry.js +10 -0
- package/dist/helpers.d.ts +1 -0
- package/dist/helpers.js +3 -0
- package/dist/server-descriptor.d.ts +1 -1
- package/dist/slots.js +2 -2
- package/dist/workspace.d.ts +3 -3
- package/dist/workspace.js +27 -23
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ Run multiple local dev environments side by side, one per git worktree, with iso
|
|
|
5
5
|
Each project writes two custom scripts on top, using these entry points:
|
|
6
6
|
|
|
7
7
|
- `runWorkspace(config)` — worktree lifecycle (setup / remove / set-owner).
|
|
8
|
-
- `runDevServer(config)` —
|
|
8
|
+
- `runDevServer(config)` — dev-server start (foreground or background) / stop / list.
|
|
9
9
|
|
|
10
10
|
## Setup
|
|
11
11
|
|
|
@@ -27,11 +27,12 @@ The agent reads the skill, adapts the reference scripts to your stack, installs
|
|
|
27
27
|
|
|
28
28
|
```sh
|
|
29
29
|
npm run workspace -- setup feat/42 -c # new branch + worktree + isolated env
|
|
30
|
-
npm run dev
|
|
31
|
-
npm run dev
|
|
32
|
-
npm run dev
|
|
33
|
-
npm run dev
|
|
34
|
-
npm run dev
|
|
30
|
+
npm run dev # start in the foreground; holds the terminal, stops on CTRL+C
|
|
31
|
+
npm run dev -- up # start in the background (no-op if already running here)
|
|
32
|
+
npm run dev -- up --restart # stop the dev-server in this worktree if running, then start fresh
|
|
33
|
+
npm run dev -- up --evict # if devLimit is reached, evict the oldest dev-server and start
|
|
34
|
+
npm run dev -- list # active dev-servers across all worktrees
|
|
35
|
+
npm run dev -- down # stop dev server (infrastructure stays up)
|
|
35
36
|
npm run workspace -- remove feat/42 # full teardown
|
|
36
37
|
```
|
|
37
38
|
|
|
@@ -48,7 +49,7 @@ await runWorkspace({
|
|
|
48
49
|
portNames: ["server", "frontend", "db"],
|
|
49
50
|
sharedDirs: [".local", ".plans"],
|
|
50
51
|
runtimeDir: ".local-wt",
|
|
51
|
-
registryDir: ".local/
|
|
52
|
+
registryDir: ".local/_workspace-registry",
|
|
52
53
|
configFiles: [
|
|
53
54
|
{
|
|
54
55
|
path: ".env",
|
|
@@ -74,11 +75,11 @@ await runWorkspace({
|
|
|
74
75
|
|
|
75
76
|
`branch` is resolved live from the worktree on each call (not persisted in the registry — `git checkout` makes any stored value stale). For detached HEAD or missing directory, it falls back to `"(detached)"`. `status` is the slot's finalize status: `"pending"` until `finalizeWorktree` succeeds, then `"ready"` (or `"failed"`).
|
|
76
77
|
|
|
77
|
-
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>/
|
|
78
|
+
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>/logs/workspace-setup.log`. If Part 2 fails, `cd` into the worktree and run `workspace setup` — it is idempotent and retries the finalize step. To block until Part 2 finishes (CI, agent orchestration), run `workspace wait` from inside the worktree (or `workspace wait --slot 8110` from anywhere) — exits 0 on `READY`, 1 on `FAILED`.
|
|
78
79
|
|
|
79
80
|
**Bootstrap the main worktree first.** Linked-worktree setup copies config sources from the main worktree, so the main must already have those files. Run `workspace setup` once on the main checkout. Use `preSetup` (with `isMainWorktree === true`) to seed sources from examples or templates. `configFiles` entries are required by default; mark `optional: true` for sources that may legitimately be missing.
|
|
80
81
|
|
|
81
|
-
`--evict` is best-effort: the cap check and the subsequent register are not atomic, so two concurrent `dev
|
|
82
|
+
`--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.
|
|
82
83
|
|
|
83
84
|
```ts
|
|
84
85
|
import { runDevServer, helpers } from "@paleo/workspace";
|
|
@@ -86,13 +87,14 @@ import { runDevServer, helpers } from "@paleo/workspace";
|
|
|
86
87
|
await runDevServer({
|
|
87
88
|
basePort: 8100,
|
|
88
89
|
runtimeDir: ".local-wt",
|
|
89
|
-
registryDir: ".local/
|
|
90
|
+
registryDir: ".local/_workspace-registry",
|
|
90
91
|
devLimit: 5,
|
|
91
92
|
servers: [
|
|
92
93
|
{
|
|
93
94
|
kind: "spawn",
|
|
94
95
|
name: "dev",
|
|
95
|
-
|
|
96
|
+
// Not `dev` — the workspace wrapper script is named `dev`, so `npm run dev` would recurse.
|
|
97
|
+
exec: { command: "npm", args: ["run", "dev:app"] },
|
|
96
98
|
port: helpers.readPortFromEnvFile(".env", "PORT"),
|
|
97
99
|
detectSuccess: (log) => log.includes("Server is ready on port"),
|
|
98
100
|
},
|
package/dist/cli.d.ts
CHANGED
|
@@ -34,14 +34,21 @@ export interface ParsedWorkspaceArgs {
|
|
|
34
34
|
}
|
|
35
35
|
export declare function parseWorkspaceArgs(argv?: string[]): ParsedWorkspaceArgs;
|
|
36
36
|
export declare function printWorkspaceHelp(): void;
|
|
37
|
-
export
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
37
|
+
export type DevCommand = {
|
|
38
|
+
kind: "foreground";
|
|
39
|
+
evict: boolean;
|
|
40
|
+
restart: boolean;
|
|
41
|
+
} | {
|
|
42
|
+
kind: "up";
|
|
43
|
+
evict: boolean;
|
|
44
|
+
restart: boolean;
|
|
45
|
+
} | {
|
|
46
|
+
kind: "down";
|
|
47
|
+
all: boolean;
|
|
48
|
+
} | {
|
|
49
|
+
kind: "list";
|
|
50
|
+
} | {
|
|
51
|
+
kind: "help";
|
|
52
|
+
};
|
|
53
|
+
export declare function parseDevArgs(argv?: string[]): DevCommand;
|
|
54
|
+
export declare function printDevHelp(): void;
|
package/dist/cli.js
CHANGED
|
@@ -1,28 +1,19 @@
|
|
|
1
1
|
import { parseArgs } from "node:util";
|
|
2
2
|
import { ConfigError } from "./errors.js";
|
|
3
|
-
const DEV_SERVER_OPTIONS = {
|
|
4
|
-
help: { type: "boolean", short: "h", description: "Show this help message" },
|
|
5
|
-
stop: { type: "boolean", description: "Stop dev servers in the current worktree" },
|
|
6
|
-
list: { type: "boolean", description: "List active dev-servers across all worktrees" },
|
|
7
|
-
all: { type: "boolean", description: "Apply --stop to every active dev-server" },
|
|
8
|
-
evict: { type: "boolean", description: "Evict the oldest dev-server when the cap is reached" },
|
|
9
|
-
restart: {
|
|
10
|
-
type: "boolean",
|
|
11
|
-
description: "If a dev-server is already running in this worktree, stop it first, then start",
|
|
12
|
-
},
|
|
13
|
-
};
|
|
14
3
|
export function parseWorkspaceArgs(argv = process.argv.slice(2)) {
|
|
15
4
|
const [subcommand, ...tokens] = argv;
|
|
16
|
-
if (subcommand ===
|
|
5
|
+
if (subcommand === "--help" || subcommand === "-h") {
|
|
17
6
|
return { command: { kind: "help" }, verbose: false };
|
|
18
7
|
}
|
|
8
|
+
if (subcommand === undefined)
|
|
9
|
+
throw new ConfigError("No command given.");
|
|
19
10
|
try {
|
|
20
11
|
return parseSubcommand(subcommand, tokens);
|
|
21
12
|
}
|
|
22
13
|
catch (err) {
|
|
23
14
|
if (err instanceof ConfigError)
|
|
24
15
|
throw err;
|
|
25
|
-
throw new ConfigError(
|
|
16
|
+
throw new ConfigError(err.message);
|
|
26
17
|
}
|
|
27
18
|
}
|
|
28
19
|
function parseSubcommand(subcommand, tokens) {
|
|
@@ -42,7 +33,7 @@ function parseSubcommand(subcommand, tokens) {
|
|
|
42
33
|
case "__finalize":
|
|
43
34
|
return parseFinalize(tokens);
|
|
44
35
|
default:
|
|
45
|
-
throw new ConfigError(`
|
|
36
|
+
throw new ConfigError(`Unknown command "${subcommand}". Run \`workspace --help\`.`);
|
|
46
37
|
}
|
|
47
38
|
}
|
|
48
39
|
function parseSetup(tokens) {
|
|
@@ -62,7 +53,7 @@ function parseSetup(tokens) {
|
|
|
62
53
|
const branch = takeOptionalPositional(positionals, "setup");
|
|
63
54
|
const newBranch = values["new-branch"] ?? false;
|
|
64
55
|
if (newBranch && branch === undefined) {
|
|
65
|
-
throw new ConfigError("
|
|
56
|
+
throw new ConfigError("`workspace setup <branch> -c` requires a branch name.");
|
|
66
57
|
}
|
|
67
58
|
return {
|
|
68
59
|
command: {
|
|
@@ -151,86 +142,133 @@ function parseFinalize(tokens) {
|
|
|
151
142
|
}
|
|
152
143
|
function takeOptionalPositional(positionals, command) {
|
|
153
144
|
if (positionals.length > 1) {
|
|
154
|
-
throw new ConfigError(
|
|
145
|
+
throw new ConfigError(`\`workspace ${command}\` accepts at most one positional argument.`);
|
|
155
146
|
}
|
|
156
147
|
return positionals[0];
|
|
157
148
|
}
|
|
158
149
|
function takeRequiredPositional(positionals, command, label) {
|
|
159
150
|
if (positionals.length !== 1) {
|
|
160
|
-
throw new ConfigError(
|
|
151
|
+
throw new ConfigError(`\`workspace ${command}\` requires exactly one ${label}.`);
|
|
161
152
|
}
|
|
162
153
|
return positionals[0];
|
|
163
154
|
}
|
|
164
155
|
function rejectPositionals(positionals, command) {
|
|
165
156
|
if (positionals.length > 0) {
|
|
166
|
-
throw new ConfigError(
|
|
157
|
+
throw new ConfigError(`\`workspace ${command}\` takes no positional arguments.`);
|
|
167
158
|
}
|
|
168
159
|
}
|
|
169
160
|
export function printWorkspaceHelp() {
|
|
170
161
|
console.log([
|
|
171
162
|
"Usage: workspace <command> [options]",
|
|
172
163
|
"",
|
|
173
|
-
"Manage worktree
|
|
164
|
+
"Manage workspaces: a git worktree plus its own dev setup (ports, config, database, dev server).",
|
|
174
165
|
"",
|
|
175
166
|
"Commands:",
|
|
176
167
|
" setup [<branch>] [-c|--new-branch] [--owner <name>] [-s|--slot <port>] [--force] [--wait]",
|
|
177
|
-
" Set up the
|
|
168
|
+
" Set up the workspace. With <branch>, create a sibling worktree for it",
|
|
178
169
|
" (add -c to create the branch first). Without, set up the current worktree",
|
|
179
170
|
" (idempotent; bootstrap and retry path).",
|
|
180
171
|
" Finalize runs in the background; add --wait to block until it reaches READY.",
|
|
181
172
|
" remove [<branch>] [--no-remote-check]",
|
|
182
|
-
" Remove a
|
|
173
|
+
" Remove a workspace by branch, or the current one when omitted.",
|
|
183
174
|
" list",
|
|
184
|
-
" List all registered
|
|
175
|
+
" List all registered workspaces (slot, status, branch, path, owner, created).",
|
|
185
176
|
" info [-s|--slot <port>]",
|
|
186
|
-
" Print a
|
|
177
|
+
" Print a workspace summary (ports, branch, readiness).",
|
|
187
178
|
" wait [-s|--slot <port>]",
|
|
188
179
|
" Block until the background finalize reaches READY (exit 0) or FAILED (exit 1).",
|
|
189
180
|
" set-owner <name>",
|
|
190
|
-
" Update the current
|
|
181
|
+
" Update the current workspace's owner (no rebuild).",
|
|
191
182
|
"",
|
|
192
183
|
"Global options:",
|
|
193
184
|
" -v, --verbose Show intermediate output.",
|
|
194
185
|
].join("\n"));
|
|
195
186
|
}
|
|
196
|
-
export function
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
throw new ConfigError("Error: --all requires --stop.");
|
|
205
|
-
}
|
|
206
|
-
if (args.list && (args.stop || args.all)) {
|
|
207
|
-
throw new ConfigError("Error: --list is mutually exclusive with --stop and --all.");
|
|
187
|
+
export function parseDevArgs(argv = process.argv.slice(2)) {
|
|
188
|
+
const [first] = argv;
|
|
189
|
+
if (first === "--help" || first === "-h")
|
|
190
|
+
return { kind: "help" };
|
|
191
|
+
try {
|
|
192
|
+
if (first === undefined || first.startsWith("-"))
|
|
193
|
+
return parseForeground(argv);
|
|
194
|
+
return parseDevSubcommand(first, argv.slice(1));
|
|
208
195
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
196
|
+
catch (err) {
|
|
197
|
+
if (err instanceof ConfigError)
|
|
198
|
+
throw err;
|
|
199
|
+
throw new ConfigError(err.message);
|
|
212
200
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
201
|
+
}
|
|
202
|
+
function parseDevSubcommand(subcommand, tokens) {
|
|
203
|
+
switch (subcommand) {
|
|
204
|
+
case "up":
|
|
205
|
+
return parseUp(tokens);
|
|
206
|
+
case "down":
|
|
207
|
+
return parseDown(tokens);
|
|
208
|
+
case "list":
|
|
209
|
+
return parseDevList(tokens);
|
|
210
|
+
default:
|
|
211
|
+
throw new ConfigError(`Unknown command "${subcommand}". Run \`dev --help\`.`);
|
|
216
212
|
}
|
|
217
213
|
}
|
|
218
|
-
function
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
214
|
+
function parseForeground(tokens) {
|
|
215
|
+
const { evict, restart } = parseEvictRestart(tokens, "dev");
|
|
216
|
+
return { kind: "foreground", evict, restart };
|
|
217
|
+
}
|
|
218
|
+
function parseUp(tokens) {
|
|
219
|
+
const { evict, restart } = parseEvictRestart(tokens, "dev up");
|
|
220
|
+
return { kind: "up", evict, restart };
|
|
221
|
+
}
|
|
222
|
+
function parseEvictRestart(tokens, command) {
|
|
223
|
+
const { values, positionals } = parseArgs({
|
|
224
|
+
args: tokens,
|
|
225
|
+
options: { evict: { type: "boolean" }, restart: { type: "boolean" } },
|
|
226
|
+
allowPositionals: true,
|
|
227
|
+
strict: true,
|
|
228
|
+
});
|
|
229
|
+
rejectDevPositionals(positionals, command);
|
|
230
|
+
return { evict: values.evict ?? false, restart: values.restart ?? false };
|
|
231
|
+
}
|
|
232
|
+
function parseDown(tokens) {
|
|
233
|
+
const { values, positionals } = parseArgs({
|
|
234
|
+
args: tokens,
|
|
235
|
+
options: { all: { type: "boolean" } },
|
|
236
|
+
allowPositionals: true,
|
|
237
|
+
strict: true,
|
|
238
|
+
});
|
|
239
|
+
rejectDevPositionals(positionals, "dev down");
|
|
240
|
+
return { kind: "down", all: values.all ?? false };
|
|
241
|
+
}
|
|
242
|
+
function parseDevList(tokens) {
|
|
243
|
+
const { positionals } = parseArgs({
|
|
244
|
+
args: tokens,
|
|
245
|
+
options: {},
|
|
246
|
+
allowPositionals: true,
|
|
247
|
+
strict: true,
|
|
248
|
+
});
|
|
249
|
+
rejectDevPositionals(positionals, "dev list");
|
|
250
|
+
return { kind: "list" };
|
|
251
|
+
}
|
|
252
|
+
function rejectDevPositionals(positionals, command) {
|
|
253
|
+
if (positionals.length > 0) {
|
|
254
|
+
throw new ConfigError(`\`${command}\` takes no positional arguments.`);
|
|
234
255
|
}
|
|
235
|
-
|
|
256
|
+
}
|
|
257
|
+
export function printDevHelp() {
|
|
258
|
+
console.log([
|
|
259
|
+
"Usage: dev [command] [options]",
|
|
260
|
+
"",
|
|
261
|
+
"Start, stop, or list dev-server processes for worktree-based environments.",
|
|
262
|
+
"",
|
|
263
|
+
"Commands:",
|
|
264
|
+
" dev Start in the foreground; holds the terminal, stops on CTRL+C.",
|
|
265
|
+
" dev up Start in the background and return once ready.",
|
|
266
|
+
" dev down [--all] Stop this worktree's dev-server, or every dev-server with --all.",
|
|
267
|
+
" dev list List active dev-servers across all worktrees.",
|
|
268
|
+
" dev --help Show this help message.",
|
|
269
|
+
"",
|
|
270
|
+
"Options (dev, dev up):",
|
|
271
|
+
" --evict Evict the oldest dev-server when the cap is reached.",
|
|
272
|
+
" --restart If a dev-server is already running here, stop it first, then start.",
|
|
273
|
+
].join("\n"));
|
|
236
274
|
}
|
package/dist/dev-server.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export interface DevServerConfig {
|
|
|
8
8
|
/** Per-worktree runtime directory, relative to the worktree root (e.g. `.local-wt`). */
|
|
9
9
|
runtimeDir: string;
|
|
10
10
|
/**
|
|
11
|
-
* Shared registry directory, relative to a worktree root (e.g. `.local/
|
|
11
|
+
* Shared registry directory, relative to a worktree root (e.g. `.local/_workspace-registry`).
|
|
12
12
|
* Holds `slots.json` and `dev-servers.json`. Must resolve to the same physical directory
|
|
13
13
|
* across linked worktrees — typically via a symlink (e.g. `.local`).
|
|
14
14
|
*/
|
|
@@ -40,7 +40,7 @@ export type WorktreeReadyCheck = {
|
|
|
40
40
|
message: string;
|
|
41
41
|
};
|
|
42
42
|
/**
|
|
43
|
-
* Pure builder for the `dev
|
|
43
|
+
* Pure builder for the `dev` worktree-readiness gate. Returns `ok` when the slot is `ready`
|
|
44
44
|
* or absent (synthesized main); otherwise returns the user-facing error message.
|
|
45
45
|
*/
|
|
46
46
|
export declare function buildWorktreeReadyMessage(input: {
|
package/dist/dev-server.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
import { closeSync, mkdirSync, openSync } from "node:fs";
|
|
2
|
+
import { closeSync, existsSync, mkdirSync, openSync, readSync, statSync } from "node:fs";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import { parseDevArgs, printDevHelp } from "./cli.js";
|
|
5
5
|
import { evictOldest, findOwnEntry, listDevServers, printActiveServers, pruneAndPersist, registerDevServer, removeDevServerEntryByWorktree, stopAllRegistered, unregisterDevServer, } from "./dev-servers-registry.js";
|
|
6
6
|
import { ConfigError, StartupError } from "./errors.js";
|
|
7
|
-
import { detectCommonJsError, formatDuration } from "./helpers.js";
|
|
7
|
+
import { detectCommonJsError, formatDuration, setupLogPath } from "./helpers.js";
|
|
8
8
|
import { awaitAllReady, handleStartupFailure } from "./log-polling.js";
|
|
9
9
|
import { canonicalCwd, detectPortConflicts, sweepStalePorts, waitForPortsFree, } from "./port-holder.js";
|
|
10
10
|
import { isProcessAlive, stopProcessGroup } from "./process-control.js";
|
|
@@ -14,92 +14,151 @@ function logFileFor(runtimeDir, name) {
|
|
|
14
14
|
return join(runtimeDir, "logs", `${name}.log`);
|
|
15
15
|
}
|
|
16
16
|
export async function runDevServer(config) {
|
|
17
|
-
let
|
|
17
|
+
let command;
|
|
18
18
|
try {
|
|
19
|
-
|
|
20
|
-
}
|
|
21
|
-
catch (err) {
|
|
22
|
-
console.error(err.message);
|
|
23
|
-
process.exit(1);
|
|
24
|
-
}
|
|
25
|
-
if (args.help) {
|
|
26
|
-
printDevServerHelp();
|
|
27
|
-
return;
|
|
28
|
-
}
|
|
29
|
-
try {
|
|
30
|
-
validateDevServerFlags(args);
|
|
19
|
+
command = parseDevArgs();
|
|
31
20
|
}
|
|
32
21
|
catch (err) {
|
|
33
22
|
if (err instanceof ConfigError) {
|
|
34
|
-
console.error(err.message);
|
|
35
|
-
|
|
23
|
+
console.error(`Warning: ${err.message}`);
|
|
24
|
+
printDevHelp();
|
|
25
|
+
process.exit(1);
|
|
36
26
|
}
|
|
37
27
|
throw err;
|
|
38
28
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
listDevServers(mainWorktree, config.registryDir);
|
|
42
|
-
return;
|
|
43
|
-
}
|
|
44
|
-
if (args.stop && args.all) {
|
|
45
|
-
await stopAllRegistered({
|
|
46
|
-
mainWorktree,
|
|
47
|
-
registryDir: config.registryDir,
|
|
48
|
-
callbackServers: callbackServersOf(config),
|
|
49
|
-
});
|
|
29
|
+
if (command.kind === "help") {
|
|
30
|
+
printDevHelp();
|
|
50
31
|
return;
|
|
51
32
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
33
|
+
const { mainWorktree } = detectWorktree();
|
|
34
|
+
switch (command.kind) {
|
|
35
|
+
case "list":
|
|
36
|
+
listDevServers(mainWorktree, config.registryDir);
|
|
37
|
+
return;
|
|
38
|
+
case "down":
|
|
39
|
+
if (command.all) {
|
|
40
|
+
await stopAllRegistered({
|
|
41
|
+
mainWorktree,
|
|
42
|
+
registryDir: config.registryDir,
|
|
43
|
+
callbackServers: callbackServersOf(config),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
await stopLocal(config, mainWorktree);
|
|
48
|
+
}
|
|
49
|
+
return;
|
|
50
|
+
case "up":
|
|
51
|
+
await start(config, mainWorktree, { evict: command.evict, restart: command.restart });
|
|
52
|
+
return;
|
|
53
|
+
case "foreground":
|
|
54
|
+
await runForeground(config, mainWorktree, {
|
|
55
|
+
evict: command.evict,
|
|
56
|
+
restart: command.restart,
|
|
57
|
+
});
|
|
58
|
+
return;
|
|
55
59
|
}
|
|
56
|
-
await start(config, mainWorktree, {
|
|
57
|
-
evict: Boolean(args.evict),
|
|
58
|
-
restart: Boolean(args.restart),
|
|
59
|
-
});
|
|
60
60
|
}
|
|
61
61
|
function callbackServersOf(config) {
|
|
62
62
|
return config.servers.filter((s) => s.kind === "callback");
|
|
63
63
|
}
|
|
64
|
-
async function start(config, mainWorktree,
|
|
64
|
+
async function start(config, mainWorktree, options) {
|
|
65
65
|
const ctx = { cwd: process.cwd() };
|
|
66
|
-
|
|
67
|
-
if (await handleAlreadyRunning(config, mainWorktree, ctx, restart))
|
|
66
|
+
if (await runStartChecks(config, mainWorktree, ctx, options))
|
|
68
67
|
return;
|
|
69
|
-
|
|
70
|
-
checkNoLocalRegistryConflict(config, mainWorktree, ctx.cwd);
|
|
71
|
-
await checkPortsFree(config.servers, ctx.cwd);
|
|
72
|
-
const spawnPids = {};
|
|
73
|
-
const startedCallbacks = [];
|
|
68
|
+
const state = { spawnPids: {}, startedCallbacks: [] };
|
|
74
69
|
try {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
startedCallbacks.push(server);
|
|
83
|
-
}
|
|
70
|
+
await spawnAndAwait(config, ctx, state);
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
await rollbackStart(state.spawnPids, state.startedCallbacks, ctx);
|
|
74
|
+
if (err instanceof StartupError) {
|
|
75
|
+
handleStartupFailure(err);
|
|
76
|
+
process.exit(1);
|
|
84
77
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
78
|
+
throw err;
|
|
79
|
+
}
|
|
80
|
+
const slot = registerStartedServer(config, mainWorktree, state.spawnPids);
|
|
81
|
+
printStartSummary(config, slot, state.spawnPids);
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Foreground start: hold the terminal and tail logs until CTRL+C, then stop cleanly. Signal
|
|
85
|
+
* handlers are installed before starting so an interrupt during startup rolls back; after a
|
|
86
|
+
* successful start they switch to the local stop sequence.
|
|
87
|
+
*/
|
|
88
|
+
async function runForeground(config, mainWorktree, options) {
|
|
89
|
+
const ctx = { cwd: process.cwd() };
|
|
90
|
+
const state = { spawnPids: {}, startedCallbacks: [] };
|
|
91
|
+
let started = false;
|
|
92
|
+
let shuttingDown = false;
|
|
93
|
+
const onSignal = () => {
|
|
94
|
+
if (shuttingDown)
|
|
95
|
+
return;
|
|
96
|
+
shuttingDown = true;
|
|
97
|
+
if (started) {
|
|
98
|
+
void shutdownForeground(config, mainWorktree);
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
void rollbackStart(state.spawnPids, state.startedCallbacks, ctx).then(() => process.exit(130));
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
process.on("SIGINT", onSignal);
|
|
105
|
+
process.on("SIGTERM", onSignal);
|
|
106
|
+
if (await runStartChecks(config, mainWorktree, ctx, options))
|
|
107
|
+
process.exit(0);
|
|
108
|
+
try {
|
|
109
|
+
await spawnAndAwait(config, ctx, state);
|
|
94
110
|
}
|
|
95
111
|
catch (err) {
|
|
96
|
-
await rollbackStart(spawnPids, startedCallbacks, ctx);
|
|
112
|
+
await rollbackStart(state.spawnPids, state.startedCallbacks, ctx);
|
|
97
113
|
if (err instanceof StartupError) {
|
|
98
114
|
handleStartupFailure(err);
|
|
99
115
|
process.exit(1);
|
|
100
116
|
}
|
|
101
117
|
throw err;
|
|
102
118
|
}
|
|
119
|
+
const slot = registerStartedServer(config, mainWorktree, state.spawnPids);
|
|
120
|
+
started = true;
|
|
121
|
+
printStartSummary(config, slot, state.spawnPids);
|
|
122
|
+
tailLogs(config, state.spawnPids);
|
|
123
|
+
await new Promise(() => { });
|
|
124
|
+
}
|
|
125
|
+
async function shutdownForeground(config, mainWorktree) {
|
|
126
|
+
console.log("\nStopping dev servers...");
|
|
127
|
+
await stopLocal(config, mainWorktree);
|
|
128
|
+
console.log("Stopped.");
|
|
129
|
+
process.exit(0);
|
|
130
|
+
}
|
|
131
|
+
async function runStartChecks(config, mainWorktree, ctx, { evict, restart }) {
|
|
132
|
+
checkWorktreeReady(config, mainWorktree, ctx.cwd);
|
|
133
|
+
if (await handleAlreadyRunning(config, mainWorktree, ctx, restart))
|
|
134
|
+
return true;
|
|
135
|
+
await enforceCap(config, mainWorktree, evict);
|
|
136
|
+
checkNoLocalRegistryConflict(config, mainWorktree, ctx.cwd);
|
|
137
|
+
await checkPortsFree(config.servers, ctx.cwd);
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
async function spawnAndAwait(config, ctx, state) {
|
|
141
|
+
for (const server of config.servers) {
|
|
142
|
+
console.log(`Starting ${server.name} dev server...`);
|
|
143
|
+
if (server.kind === "spawn") {
|
|
144
|
+
state.spawnPids[server.name] = spawnServer(server, config.runtimeDir, ctx.cwd);
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
await server.start(ctx);
|
|
148
|
+
state.startedCallbacks.push(server);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const spawnEntries = config.servers.filter((s) => s.kind === "spawn");
|
|
152
|
+
const pollables = spawnEntries.map((s) => ({
|
|
153
|
+
name: s.name,
|
|
154
|
+
logFile: logFileFor(config.runtimeDir, s.name),
|
|
155
|
+
detectSuccess: s.detectSuccess,
|
|
156
|
+
detectError: s.detectError ?? detectCommonJsError,
|
|
157
|
+
}));
|
|
158
|
+
const pollPids = spawnEntries.map((s) => state.spawnPids[s.name]);
|
|
159
|
+
await awaitAllReady(pollables, pollPids);
|
|
160
|
+
}
|
|
161
|
+
function registerStartedServer(config, mainWorktree, spawnPids) {
|
|
103
162
|
const slot = resolveCurrentSlot(config.basePort, config.registryDir);
|
|
104
163
|
const devEntry = {
|
|
105
164
|
slot: slot.slot,
|
|
@@ -111,6 +170,9 @@ async function start(config, mainWorktree, { evict, restart }) {
|
|
|
111
170
|
if (slot.main)
|
|
112
171
|
devEntry.main = true;
|
|
113
172
|
registerDevServer(mainWorktree, config.registryDir, devEntry);
|
|
173
|
+
return slot;
|
|
174
|
+
}
|
|
175
|
+
function printStartSummary(config, slot, spawnPids) {
|
|
114
176
|
const summaryServers = config.servers.map((server) => {
|
|
115
177
|
if (server.kind === "spawn") {
|
|
116
178
|
return { server, port: server.port, pid: spawnPids[server.name] };
|
|
@@ -124,6 +186,35 @@ async function start(config, mainWorktree, { evict, restart }) {
|
|
|
124
186
|
defaultPrintSummary(slot, summaryServers, config.runtimeDir);
|
|
125
187
|
}
|
|
126
188
|
}
|
|
189
|
+
const TAIL_INTERVAL_MS = 300;
|
|
190
|
+
function tailLogs(config, spawnPids) {
|
|
191
|
+
const names = Object.keys(spawnPids);
|
|
192
|
+
const prefixed = names.length > 1;
|
|
193
|
+
for (const name of names) {
|
|
194
|
+
const path = join(process.cwd(), logFileFor(config.runtimeDir, name));
|
|
195
|
+
followLogFile(path, prefixed ? `[${name}] ` : "");
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
function followLogFile(path, prefix) {
|
|
199
|
+
let offset = existsSync(path) ? statSync(path).size : 0;
|
|
200
|
+
setInterval(() => {
|
|
201
|
+
if (!existsSync(path))
|
|
202
|
+
return;
|
|
203
|
+
const size = statSync(path).size;
|
|
204
|
+
if (size < offset)
|
|
205
|
+
offset = 0;
|
|
206
|
+
if (size <= offset)
|
|
207
|
+
return;
|
|
208
|
+
const length = size - offset;
|
|
209
|
+
const fd = openSync(path, "r");
|
|
210
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
211
|
+
const bytesRead = readSync(fd, buffer, 0, length, offset);
|
|
212
|
+
closeSync(fd);
|
|
213
|
+
offset += bytesRead;
|
|
214
|
+
const text = buffer.subarray(0, bytesRead).toString("utf8");
|
|
215
|
+
process.stdout.write(prefix === "" ? text : text.replace(/^(?=.)/gm, prefix));
|
|
216
|
+
}, TAIL_INTERVAL_MS);
|
|
217
|
+
}
|
|
127
218
|
async function rollbackStart(spawnPids, startedCallbacks, ctx) {
|
|
128
219
|
console.error("\nStopping dev servers...");
|
|
129
220
|
for (const pid of Object.values(spawnPids)) {
|
|
@@ -145,21 +236,21 @@ async function rollbackStart(spawnPids, startedCallbacks, ctx) {
|
|
|
145
236
|
}
|
|
146
237
|
}
|
|
147
238
|
/**
|
|
148
|
-
* Pure builder for the `dev
|
|
239
|
+
* Pure builder for the `dev` worktree-readiness gate. Returns `ok` when the slot is `ready`
|
|
149
240
|
* or absent (synthesized main); otherwise returns the user-facing error message.
|
|
150
241
|
*/
|
|
151
242
|
export function buildWorktreeReadyMessage(input) {
|
|
152
243
|
const { slotPort, worktreePath, runtimeDir, entry, now } = input;
|
|
153
244
|
if (!entry || entry.status === "ready")
|
|
154
245
|
return { ok: true };
|
|
155
|
-
const logPath =
|
|
246
|
+
const logPath = setupLogPath(worktreePath, runtimeDir);
|
|
156
247
|
if (entry.status === "pending") {
|
|
157
248
|
const elapsed = formatDuration(now - Date.parse(entry.createdAt));
|
|
158
249
|
return {
|
|
159
250
|
ok: false,
|
|
160
251
|
message: `Error: Worktree setup is still in progress (slot ${slotPort}, started ${elapsed} ago).\n` +
|
|
161
252
|
`Tail: ${logPath}\n` +
|
|
162
|
-
"Run `workspace wait` to block until it finishes, or retry `dev
|
|
253
|
+
"Run `workspace wait` to block until it finishes, or retry `dev` once ready.",
|
|
163
254
|
};
|
|
164
255
|
}
|
|
165
256
|
const failureAt = entry.failure?.at ?? entry.createdAt;
|
|
@@ -206,10 +297,10 @@ async function handleAlreadyRunning(config, mainWorktree, ctx, restart) {
|
|
|
206
297
|
}
|
|
207
298
|
const pidList = livePids.map(([name, pid]) => `${name}=${pid}`).join(", ");
|
|
208
299
|
console.log(`dev-server already running for this worktree (slot ${entry.slot}, pids: ${pidList}).`);
|
|
209
|
-
console.log("Run `dev
|
|
300
|
+
console.log("Run `dev down` to stop it, or re-run with `--restart` to restart.");
|
|
210
301
|
return true;
|
|
211
302
|
}
|
|
212
|
-
// TOCTOU: the cap check and the subsequent register are not atomic. Two concurrent `dev
|
|
303
|
+
// TOCTOU: the cap check and the subsequent register are not atomic. Two concurrent `dev up --evict`
|
|
213
304
|
// from different worktrees can both pass the cap check and both register, exceeding the limit by
|
|
214
305
|
// one. Accepted: the race window is narrow and the consequence is bounded (one extra dev-server).
|
|
215
306
|
async function enforceCap(config, mainWorktree, evict) {
|
|
@@ -222,8 +313,8 @@ async function enforceCap(config, mainWorktree, evict) {
|
|
|
222
313
|
if (!evict) {
|
|
223
314
|
console.error(`Error: dev-server cap reached (${active.length}/${limit}). Active dev-servers:`);
|
|
224
315
|
printActiveServers(active);
|
|
225
|
-
console.error("Run `dev
|
|
226
|
-
console.error("Re-run with
|
|
316
|
+
console.error("Run `dev down` in another worktree, or `dev down --all`.");
|
|
317
|
+
console.error("Re-run with `--evict` to evict the oldest.");
|
|
227
318
|
process.exit(1);
|
|
228
319
|
}
|
|
229
320
|
const toEvict = active.length - limit + 1;
|
|
@@ -315,7 +406,7 @@ async function stopLocal(config, mainWorktree) {
|
|
|
315
406
|
function defaultPrintSummary(slot, servers, runtimeDir) {
|
|
316
407
|
console.log("\nDev servers started!");
|
|
317
408
|
const ownerSuffix = slot.owner ? `, owner ${slot.owner}` : "";
|
|
318
|
-
console.log(`
|
|
409
|
+
console.log(` Workspace: slot ${slot.slot}${ownerSuffix}`);
|
|
319
410
|
for (const { server, port, pid } of servers) {
|
|
320
411
|
if (server.kind === "spawn") {
|
|
321
412
|
const url = `http://localhost:${port}/`;
|
|
@@ -38,5 +38,7 @@ export declare function removeDevServerEntryByWorktree(mainWorktree: string, reg
|
|
|
38
38
|
export declare function findOwnEntry(mainWorktree: string, registryDir: string, worktreePath: string): DevServerEntry | undefined;
|
|
39
39
|
export declare function pruneAndPersist(mainWorktree: string, registryDir: string, isAlive?: IsAliveFn): DevServersData;
|
|
40
40
|
export declare function pruneDeadServers(data: DevServersData, isAlive?: IsAliveFn): DevServersData;
|
|
41
|
+
/** Resolved worktree paths whose dev-server entry has at least one live PID. */
|
|
42
|
+
export declare function liveWorktrees(data: DevServersData, isAlive?: IsAliveFn): Set<string>;
|
|
41
43
|
export declare function readDevServers(mainWorktree: string, registryDir: string): DevServersData;
|
|
42
44
|
export declare function writeDevServers(mainWorktree: string, registryDir: string, data: DevServersData): void;
|
|
@@ -115,6 +115,16 @@ export function pruneDeadServers(data, isAlive = isProcessAlive) {
|
|
|
115
115
|
const live = data.servers.filter((entry) => Object.values(entry.pids).some((pid) => isAlive(pid)));
|
|
116
116
|
return { servers: live };
|
|
117
117
|
}
|
|
118
|
+
/** Resolved worktree paths whose dev-server entry has at least one live PID. */
|
|
119
|
+
export function liveWorktrees(data, isAlive = isProcessAlive) {
|
|
120
|
+
const live = new Set();
|
|
121
|
+
for (const entry of data.servers) {
|
|
122
|
+
if (Object.values(entry.pids).some((pid) => isAlive(pid))) {
|
|
123
|
+
live.add(resolve(entry.worktree));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return live;
|
|
127
|
+
}
|
|
118
128
|
export function readDevServers(mainWorktree, registryDir) {
|
|
119
129
|
const fp = filePath(mainWorktree, registryDir);
|
|
120
130
|
if (!existsSync(fp))
|
package/dist/helpers.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export declare function setupLogPath(worktreeRoot: string, runtimeDir: string): string;
|
|
1
2
|
export declare function patchEnvFile(content: string, patches: Record<string, string>): string;
|
|
2
3
|
export declare function extractHost(content: string, key: string, fallback?: string): string;
|
|
3
4
|
/**
|
package/dist/helpers.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
|
+
export function setupLogPath(worktreeRoot, runtimeDir) {
|
|
4
|
+
return join(worktreeRoot, runtimeDir, "logs", "workspace-setup.log");
|
|
5
|
+
}
|
|
3
6
|
export function patchEnvFile(content, patches) {
|
|
4
7
|
const lines = content.trimEnd().split("\n");
|
|
5
8
|
for (const [key, value] of Object.entries(patches)) {
|
|
@@ -3,7 +3,7 @@ export interface ServerContext {
|
|
|
3
3
|
/**
|
|
4
4
|
* Worktree directory for this lifecycle call. Equals `process.cwd()` at start time for local
|
|
5
5
|
* starts/stops; equals the victim entry's worktree for cross-worktree stops (eviction,
|
|
6
|
-
* `dev
|
|
6
|
+
* `dev down --all`). Callbacks MUST thread this into every child-process call
|
|
7
7
|
* (`{ cwd: ctx.cwd }` on `execSync`, `spawn`, etc.) and resolve relative paths against it.
|
|
8
8
|
*/
|
|
9
9
|
cwd: string;
|
package/dist/slots.js
CHANGED
|
@@ -22,7 +22,7 @@ export function resolveAndRegisterSlot(input) {
|
|
|
22
22
|
const owner = input.requestedOwner ?? existing?.owner;
|
|
23
23
|
const createdAt = existing?.createdAt ?? new Date().toISOString();
|
|
24
24
|
// Re-runs of `workspace setup` keep a previously finalized slot ready, unless `--force` is set —
|
|
25
|
-
// then we reset to pending so `workspace wait` blocks and `dev
|
|
25
|
+
// then we reset to pending so `workspace wait` blocks and `dev` refuses during the re-finalize.
|
|
26
26
|
const status = existing?.status === "ready" && !input.force ? "ready" : "pending";
|
|
27
27
|
const entry = {
|
|
28
28
|
worktree: input.currentWorktree,
|
|
@@ -74,7 +74,7 @@ export function validateSlotAvailability(slotArg, ctx) {
|
|
|
74
74
|
export function resolveCurrentSlot(basePort, registryDir) {
|
|
75
75
|
const slot = lookupSlotForCwd(registryDir) ?? synthesizeMainSlot(basePort);
|
|
76
76
|
if (!slot) {
|
|
77
|
-
console.error("Error: No
|
|
77
|
+
console.error("Error: No workspace here. Run `workspace setup` first.");
|
|
78
78
|
process.exit(1);
|
|
79
79
|
}
|
|
80
80
|
return slot;
|
package/dist/workspace.d.ts
CHANGED
|
@@ -10,7 +10,7 @@ export interface WorkspaceConfig {
|
|
|
10
10
|
scriptPath: string;
|
|
11
11
|
/**
|
|
12
12
|
* Absolute path to your dev-server script (the file that calls `runDevServer`). On
|
|
13
|
-
* `workspace remove`, the kernel shells out to `node <devServerScript>
|
|
13
|
+
* `workspace remove`, the kernel shells out to `node <devServerScript> down` with
|
|
14
14
|
* `cwd: <target worktree>`. Typically
|
|
15
15
|
* `fileURLToPath(new URL('./dev-server.mjs', import.meta.url))` from your `workspace.mjs`.
|
|
16
16
|
*/
|
|
@@ -33,7 +33,7 @@ export interface WorkspaceConfig {
|
|
|
33
33
|
*/
|
|
34
34
|
runtimeDir: string;
|
|
35
35
|
/**
|
|
36
|
-
* Shared registry directory, relative to a worktree root (e.g. `.local/
|
|
36
|
+
* Shared registry directory, relative to a worktree root (e.g. `.local/_workspace-registry`).
|
|
37
37
|
* Holds `slots.json` and `dev-servers.json`. Must resolve to the same physical directory
|
|
38
38
|
* across linked worktrees — typically via a symlink listed in `sharedDirs` (e.g. `.local`).
|
|
39
39
|
*/
|
|
@@ -54,7 +54,7 @@ export interface WorkspaceConfig {
|
|
|
54
54
|
* installed deps, etc.).
|
|
55
55
|
*
|
|
56
56
|
* Runs in a detached child whose stdout/stderr are already redirected to
|
|
57
|
-
* `<runtimeDir>/
|
|
57
|
+
* `<runtimeDir>/logs/workspace-setup.log`. `console.log` and child-process `stdio: "inherit"` land there.
|
|
58
58
|
*/
|
|
59
59
|
finalizeWorktree: (ctx: SetupContext) => Promise<void> | void;
|
|
60
60
|
/**
|
package/dist/workspace.js
CHANGED
|
@@ -2,9 +2,9 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
2
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 { parseWorkspaceArgs, printWorkspaceHelp } from "./cli.js";
|
|
5
|
-
import { findOwnEntry, removeDevServerEntryByWorktree } from "./dev-servers-registry.js";
|
|
5
|
+
import { findOwnEntry, liveWorktrees, readDevServers, removeDevServerEntryByWorktree, } from "./dev-servers-registry.js";
|
|
6
6
|
import { ConfigError } from "./errors.js";
|
|
7
|
-
import { copyAndPatchFile, formatDuration } from "./helpers.js";
|
|
7
|
+
import { copyAndPatchFile, formatDuration, setupLogPath } from "./helpers.js";
|
|
8
8
|
import { isProcessAlive } from "./process-control.js";
|
|
9
9
|
import { defaultComputePorts, isValidPort, resolvePortScheme } from "./ports.js";
|
|
10
10
|
import { handleSetOwner, markSlotFailed, markSlotReady, readSlots, resolveAndRegisterSlot, resolveCurrentSlot, validateSlotAvailability, writeSlots, } from "./slots.js";
|
|
@@ -17,8 +17,9 @@ export async function runWorkspace(config) {
|
|
|
17
17
|
}
|
|
18
18
|
catch (err) {
|
|
19
19
|
if (err instanceof ConfigError) {
|
|
20
|
-
console.error(err.message);
|
|
21
|
-
|
|
20
|
+
console.error(`Warning: ${err.message}`);
|
|
21
|
+
printWorkspaceHelp();
|
|
22
|
+
process.exit(1);
|
|
22
23
|
}
|
|
23
24
|
throw err;
|
|
24
25
|
}
|
|
@@ -86,9 +87,8 @@ async function runSetup(command, ctx, run, config) {
|
|
|
86
87
|
force: command.force,
|
|
87
88
|
});
|
|
88
89
|
const ports = portsFn(slot);
|
|
89
|
-
const
|
|
90
|
-
mkdirSync(
|
|
91
|
-
const logPath = join(runtimeDir, "wt-setup.log");
|
|
90
|
+
const logPath = setupLogPath(setupCtx.currentWorktree, config.runtimeDir);
|
|
91
|
+
mkdirSync(dirname(logPath), { recursive: true });
|
|
92
92
|
// Truncate any prior log so `workspace setup` retries start with a clean record (the previous run's
|
|
93
93
|
// FAILED: banner would otherwise linger and produce false positives for grep-based tooling).
|
|
94
94
|
writeFileSync(logPath, "");
|
|
@@ -128,7 +128,7 @@ async function runSetup(command, ctx, run, config) {
|
|
|
128
128
|
isMainWorktree: setupCtx.isMainWorktree,
|
|
129
129
|
status,
|
|
130
130
|
}));
|
|
131
|
-
teeLog(`
|
|
131
|
+
teeLog(`WORKSPACE_CREATED path=${setupCtx.currentWorktree} branch=${branch} slot=${slot}`);
|
|
132
132
|
if (status !== "ready") {
|
|
133
133
|
teeLog(`Setup continuing in background. Tail: ${logPath}`);
|
|
134
134
|
teeLog(`Block until ready: workspace wait --slot ${slot}`);
|
|
@@ -162,7 +162,7 @@ function refuseIfFinalizePending(ctx, registryDir, force) {
|
|
|
162
162
|
async function runFinalize(command, config) {
|
|
163
163
|
const slot = Number(command.slot);
|
|
164
164
|
const ctx = detectWorktree();
|
|
165
|
-
const logPath =
|
|
165
|
+
const logPath = setupLogPath(ctx.currentWorktree, config.runtimeDir);
|
|
166
166
|
const appendLog = (message) => {
|
|
167
167
|
appendFileSync(logPath, `${message}\n`);
|
|
168
168
|
};
|
|
@@ -227,7 +227,7 @@ function printWorktreeInfo(config, slot, worktreeForLog, fallback) {
|
|
|
227
227
|
const ports = resolvePortsFn(config)(slot);
|
|
228
228
|
const owner = entry?.owner ?? fallback.owner;
|
|
229
229
|
const status = entry?.status ?? "pending";
|
|
230
|
-
const
|
|
230
|
+
const setupLog = setupLogPath(worktreeForLog, config.runtimeDir);
|
|
231
231
|
const now = Date.now();
|
|
232
232
|
const isMainWorktree = entry?.main ?? false;
|
|
233
233
|
const targetWorktree = entry?.worktree ?? ctx.currentWorktree;
|
|
@@ -246,7 +246,7 @@ function printWorktreeInfo(config, slot, worktreeForLog, fallback) {
|
|
|
246
246
|
const at = entry?.failure?.at ?? entry?.createdAt;
|
|
247
247
|
const elapsed = at ? formatDuration(now - Date.parse(at)) : "?";
|
|
248
248
|
const reason = entry?.failure?.message ?? "(no message)";
|
|
249
|
-
console.log(`Failure: ${reason} (${elapsed} ago, tail ${
|
|
249
|
+
console.log(`Failure: ${reason} (${elapsed} ago, tail ${setupLog})`);
|
|
250
250
|
}
|
|
251
251
|
else if (status === "pending" && entry) {
|
|
252
252
|
const elapsed = formatDuration(now - Date.parse(entry.createdAt));
|
|
@@ -293,13 +293,15 @@ function runList(config) {
|
|
|
293
293
|
const ctx = detectWorktree();
|
|
294
294
|
const entries = Object.entries(readSlots(ctx.mainWorktree, config.registryDir).slots).sort(([a], [b]) => Number(a) - Number(b));
|
|
295
295
|
if (entries.length === 0) {
|
|
296
|
-
console.log("No
|
|
296
|
+
console.log("No workspaces registered.");
|
|
297
297
|
return;
|
|
298
298
|
}
|
|
299
|
+
const liveSet = liveWorktrees(readDevServers(ctx.mainWorktree, config.registryDir));
|
|
299
300
|
const rows = entries.map(([port, e]) => ({
|
|
300
301
|
slot: port,
|
|
301
302
|
type: e.main ? "main" : "linked",
|
|
302
303
|
status: e.status,
|
|
304
|
+
dev: liveSet.has(resolve(e.worktree)) ? "up" : "-",
|
|
303
305
|
branch: getWorktreeBranch(e.worktree) ?? "(detached)",
|
|
304
306
|
worktree: e.worktree,
|
|
305
307
|
owner: e.owner ?? "-",
|
|
@@ -309,8 +311,9 @@ function runList(config) {
|
|
|
309
311
|
slot: "SLOT",
|
|
310
312
|
type: "TYPE",
|
|
311
313
|
status: "STATUS",
|
|
314
|
+
dev: "DEV",
|
|
312
315
|
branch: "BRANCH",
|
|
313
|
-
worktree: "
|
|
316
|
+
worktree: "PATH",
|
|
314
317
|
owner: "OWNER",
|
|
315
318
|
created: "CREATED",
|
|
316
319
|
};
|
|
@@ -318,11 +321,12 @@ function runList(config) {
|
|
|
318
321
|
slot: Math.max(headers.slot.length, ...rows.map((r) => r.slot.length)),
|
|
319
322
|
type: Math.max(headers.type.length, ...rows.map((r) => r.type.length)),
|
|
320
323
|
status: Math.max(headers.status.length, ...rows.map((r) => r.status.length)),
|
|
324
|
+
dev: Math.max(headers.dev.length, ...rows.map((r) => r.dev.length)),
|
|
321
325
|
branch: Math.max(headers.branch.length, ...rows.map((r) => r.branch.length)),
|
|
322
326
|
worktree: Math.max(headers.worktree.length, ...rows.map((r) => r.worktree.length)),
|
|
323
327
|
owner: Math.max(headers.owner.length, ...rows.map((r) => r.owner.length)),
|
|
324
328
|
};
|
|
325
|
-
const fmt = (r) => `${r.slot.padEnd(widths.slot)} ${r.type.padEnd(widths.type)} ${r.status.padEnd(widths.status)} ${r.branch.padEnd(widths.branch)} ${r.worktree.padEnd(widths.worktree)} ${r.owner.padEnd(widths.owner)} ${r.created}`;
|
|
329
|
+
const fmt = (r) => `${r.slot.padEnd(widths.slot)} ${r.type.padEnd(widths.type)} ${r.status.padEnd(widths.status)} ${r.dev.padEnd(widths.dev)} ${r.branch.padEnd(widths.branch)} ${r.worktree.padEnd(widths.worktree)} ${r.owner.padEnd(widths.owner)} ${r.created}`;
|
|
326
330
|
console.log(fmt(headers));
|
|
327
331
|
for (const r of rows)
|
|
328
332
|
console.log(fmt(r));
|
|
@@ -359,7 +363,7 @@ async function waitForSlot(slot, config, options = {}) {
|
|
|
359
363
|
return;
|
|
360
364
|
}
|
|
361
365
|
if (entry.status === "failed") {
|
|
362
|
-
const logPath =
|
|
366
|
+
const logPath = setupLogPath(entry.worktree, config.runtimeDir);
|
|
363
367
|
console.error(`FAILED: ${entry.failure?.message ?? "(no message)"}`);
|
|
364
368
|
console.error(`Full log: ${logPath}`);
|
|
365
369
|
process.exit(1);
|
|
@@ -372,7 +376,7 @@ async function handleRemove(command, ctx, run, config) {
|
|
|
372
376
|
const removeHere = command.branch === undefined;
|
|
373
377
|
const registry = readSlots(ctx.mainWorktree, config.registryDir);
|
|
374
378
|
const target = resolveRemoveTarget(command, ctx, registry);
|
|
375
|
-
// Refuse to remove while the detached finalize is still writing to slots.json /
|
|
379
|
+
// Refuse to remove while the detached finalize is still writing to slots.json / workspace-setup.log:
|
|
376
380
|
// racing the two corrupts the registry and leaves the worktree directory orphaned.
|
|
377
381
|
if (registry.slots[target.slotPort]?.status === "pending") {
|
|
378
382
|
console.error(`Error: Setup is still in progress for slot ${target.slotPort}. ` +
|
|
@@ -395,7 +399,7 @@ async function handleRemove(command, ctx, run, config) {
|
|
|
395
399
|
stopTargetDevServer(config.devServerScript, target.worktreePath, verboseLog);
|
|
396
400
|
}
|
|
397
401
|
else {
|
|
398
|
-
verboseLog(`No dev-server running in ${target.worktreePath}; skipping
|
|
402
|
+
verboseLog(`No dev-server running in ${target.worktreePath}; skipping stop.`);
|
|
399
403
|
}
|
|
400
404
|
if (config.purgeInfrastructure) {
|
|
401
405
|
await config.purgeInfrastructure({
|
|
@@ -411,7 +415,7 @@ async function handleRemove(command, ctx, run, config) {
|
|
|
411
415
|
process.chdir(ctx.mainWorktree);
|
|
412
416
|
}
|
|
413
417
|
removeWorktree(target.worktreePath, run);
|
|
414
|
-
console.log(`Removed
|
|
418
|
+
console.log(`Removed workspace for branch "${target.branch}" (slot ${target.slotPort}${ownerSuffix}). ` +
|
|
415
419
|
`Branch "${target.branch}" kept.`);
|
|
416
420
|
if (removeHere) {
|
|
417
421
|
console.log(`Now run: cd ${ctx.mainWorktree}`);
|
|
@@ -505,7 +509,7 @@ function resolveRemoveTarget(command, ctx, registry) {
|
|
|
505
509
|
const branch = command.branch;
|
|
506
510
|
const entry = Object.entries(registry.slots).find(([, v]) => getWorktreeBranch(v.worktree) === branch);
|
|
507
511
|
if (!entry) {
|
|
508
|
-
console.error(`Error: No
|
|
512
|
+
console.error(`Error: No workspace found for branch "${branch}" in the registry.`);
|
|
509
513
|
process.exit(1);
|
|
510
514
|
}
|
|
511
515
|
const worktreePath = entry[1].worktree;
|
|
@@ -517,21 +521,21 @@ function resolveRemoveTarget(command, ctx, registry) {
|
|
|
517
521
|
}
|
|
518
522
|
/**
|
|
519
523
|
* Stops the dev-server running in the target worktree by shelling out to
|
|
520
|
-
* `node <devServerScript>
|
|
524
|
+
* `node <devServerScript> down` with `cwd: worktreePath`. The subprocess runs the target's
|
|
521
525
|
* own stop flow — registry-based spawn-PID kill + callback `stop()` from the target's branch.
|
|
522
526
|
*/
|
|
523
527
|
function stopTargetDevServer(devServerScript, worktreePath, log) {
|
|
524
528
|
log(`Stopping dev-server in ${worktreePath}...`);
|
|
525
|
-
const result = spawnSync(process.execPath, [devServerScript, "
|
|
529
|
+
const result = spawnSync(process.execPath, [devServerScript, "down"], {
|
|
526
530
|
cwd: worktreePath,
|
|
527
531
|
stdio: "inherit",
|
|
528
532
|
timeout: 30_000,
|
|
529
533
|
});
|
|
530
534
|
if (result.error) {
|
|
531
|
-
console.warn(`Warning: failed to run dev
|
|
535
|
+
console.warn(`Warning: failed to run dev down: ${result.error.message}`);
|
|
532
536
|
}
|
|
533
537
|
else if (result.status !== 0) {
|
|
534
|
-
console.warn(`Warning: dev
|
|
538
|
+
console.warn(`Warning: dev down exited with code ${result.status}.`);
|
|
535
539
|
}
|
|
536
540
|
}
|
|
537
541
|
function resolvePortsFn(config) {
|