@paleo/workspace 0.12.0 → 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 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)` — background dev-server start / stop / list.
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:up # start dev server in the background (no-op if already running here)
31
- npm run dev:up -- --restart # stop the dev-server in this worktree if running, then start fresh
32
- npm run dev:up -- --evict # if devLimit is reached, evict the oldest dev-server and start
33
- npm run dev:list # active dev-servers across all worktrees
34
- npm run dev:down # stop dev server (infrastructure stays up)
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
 
@@ -78,7 +79,7 @@ Setup runs in two phases: a fast foreground Part 1 creates the worktree and conf
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: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
+ `--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";
@@ -92,7 +93,8 @@ await runDevServer({
92
93
  {
93
94
  kind: "spawn",
94
95
  name: "dev",
95
- exec: { command: "npm", args: ["run", "dev"] },
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 interface DevServerArgs {
38
- help?: boolean;
39
- stop?: boolean;
40
- list?: boolean;
41
- all?: boolean;
42
- evict?: boolean;
43
- restart?: boolean;
44
- }
45
- export declare function parseDevServerArgs(argv?: string[]): DevServerArgs;
46
- export declare function printDevServerHelp(): void;
47
- export declare function validateDevServerFlags(args: DevServerArgs): void;
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 === undefined || subcommand === "--help" || subcommand === "-h") {
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(`Error: ${err.message}`);
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(`Error: Unknown command "${subcommand}". Run \`workspace --help\`.`);
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("Error: `workspace setup <branch> -c` requires a branch name.");
56
+ throw new ConfigError("`workspace setup <branch> -c` requires a branch name.");
66
57
  }
67
58
  return {
68
59
  command: {
@@ -151,19 +142,19 @@ function parseFinalize(tokens) {
151
142
  }
152
143
  function takeOptionalPositional(positionals, command) {
153
144
  if (positionals.length > 1) {
154
- throw new ConfigError(`Error: \`workspace ${command}\` accepts at most one positional argument.`);
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(`Error: \`workspace ${command}\` requires exactly one ${label}.`);
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(`Error: \`workspace ${command}\` takes no positional arguments.`);
157
+ throw new ConfigError(`\`workspace ${command}\` takes no positional arguments.`);
167
158
  }
168
159
  }
169
160
  export function printWorkspaceHelp() {
@@ -193,44 +184,91 @@ export function printWorkspaceHelp() {
193
184
  " -v, --verbose Show intermediate output.",
194
185
  ].join("\n"));
195
186
  }
196
- export function parseDevServerArgs(argv) {
197
- return parseOptions(argv, DEV_SERVER_OPTIONS);
198
- }
199
- export function printDevServerHelp() {
200
- console.log(formatHelp("dev-server [options]", "Start, stop, or list background dev-server processes.", DEV_SERVER_OPTIONS));
201
- }
202
- export function validateDevServerFlags(args) {
203
- if (args.all && !args.stop) {
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
- if (args.evict && (args.stop || args.list || args.all)) {
210
- const conflict = args.stop ? "--stop" : args.list ? "--list" : "--all";
211
- throw new ConfigError(`Error: --evict cannot be combined with ${conflict}.`);
196
+ catch (err) {
197
+ if (err instanceof ConfigError)
198
+ throw err;
199
+ throw new ConfigError(err.message);
212
200
  }
213
- if (args.restart && (args.stop || args.list || args.all)) {
214
- const conflict = args.stop ? "--stop" : args.list ? "--list" : "--all";
215
- throw new ConfigError(`Error: --restart cannot be combined with ${conflict}.`);
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 parseOptions(argv, options) {
219
- const cfg = { options: options, strict: true };
220
- if (argv)
221
- cfg.args = argv;
222
- const { values } = parseArgs(cfg);
223
- return values;
224
- }
225
- function formatHelp(usage, intro, options) {
226
- const lines = [`Usage: ${usage}`, "", intro, ""];
227
- for (const [name, opt] of Object.entries(options)) {
228
- if (opt.description === "")
229
- continue;
230
- const shortFlag = opt.short ? `-${opt.short}, ` : "";
231
- const argSuffix = opt.arg ? ` <${opt.arg}>` : "";
232
- const flag = `${shortFlag}--${name}${argSuffix}`;
233
- lines.push(` ${flag.padEnd(28)} ${opt.description}`);
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
- return lines.join("\n");
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
  }
@@ -40,7 +40,7 @@ export type WorktreeReadyCheck = {
40
40
  message: string;
41
41
  };
42
42
  /**
43
- * Pure builder for the `dev:up` worktree-readiness gate. Returns `ok` when the slot is `ready`
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: {
@@ -1,7 +1,7 @@
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 { parseDevServerArgs, printDevServerHelp, validateDevServerFlags, } from "./cli.js";
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
7
  import { detectCommonJsError, formatDuration, setupLogPath } from "./helpers.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 args;
17
+ let command;
18
18
  try {
19
- args = parseDevServerArgs();
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
- process.exit(err.exitCode);
23
+ console.error(`Warning: ${err.message}`);
24
+ printDevHelp();
25
+ process.exit(1);
36
26
  }
37
27
  throw err;
38
28
  }
39
- const { mainWorktree } = detectWorktree();
40
- if (args.list) {
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
- if (args.stop) {
53
- await stopLocal(config, mainWorktree);
54
- return;
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, { evict, restart }) {
64
+ async function start(config, mainWorktree, options) {
65
65
  const ctx = { cwd: process.cwd() };
66
- checkWorktreeReady(config, mainWorktree, ctx.cwd);
67
- if (await handleAlreadyRunning(config, mainWorktree, ctx, restart))
66
+ if (await runStartChecks(config, mainWorktree, ctx, options))
68
67
  return;
69
- await enforceCap(config, mainWorktree, evict);
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
- for (const server of config.servers) {
76
- console.log(`Starting ${server.name} dev server...`);
77
- if (server.kind === "spawn") {
78
- spawnPids[server.name] = spawnServer(server, config.runtimeDir, ctx.cwd);
79
- }
80
- else {
81
- await server.start(ctx);
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
- const spawnEntries = config.servers.filter((s) => s.kind === "spawn");
86
- const pollables = spawnEntries.map((s) => ({
87
- name: s.name,
88
- logFile: logFileFor(config.runtimeDir, s.name),
89
- detectSuccess: s.detectSuccess,
90
- detectError: s.detectError ?? detectCommonJsError,
91
- }));
92
- const pollPids = spawnEntries.map((s) => spawnPids[s.name]);
93
- await awaitAllReady(pollables, pollPids);
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,7 +236,7 @@ async function rollbackStart(spawnPids, startedCallbacks, ctx) {
145
236
  }
146
237
  }
147
238
  /**
148
- * Pure builder for the `dev:up` worktree-readiness gate. Returns `ok` when the slot is `ready`
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) {
@@ -159,7 +250,7 @@ export function buildWorktreeReadyMessage(input) {
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:up` once ready.",
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:down` to stop it, or re-run with --restart to restart.");
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:up --evict`
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:down` in another worktree, or `dev:down --all`.");
226
- console.error("Re-run with --evict to evict the oldest.");
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;
@@ -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))
@@ -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:down --all`). Callbacks MUST thread this into every child-process call
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:up` refuses during the re-finalize.
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,
@@ -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> --stop` with
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
  */
package/dist/workspace.js CHANGED
@@ -2,7 +2,7 @@ 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
7
  import { copyAndPatchFile, formatDuration, setupLogPath } from "./helpers.js";
8
8
  import { isProcessAlive } from "./process-control.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
- process.exit(err.exitCode);
20
+ console.error(`Warning: ${err.message}`);
21
+ printWorkspaceHelp();
22
+ process.exit(1);
22
23
  }
23
24
  throw err;
24
25
  }
@@ -295,10 +296,12 @@ function runList(config) {
295
296
  console.log("No workspaces registered.");
296
297
  return;
297
298
  }
299
+ const liveSet = liveWorktrees(readDevServers(ctx.mainWorktree, config.registryDir));
298
300
  const rows = entries.map(([port, e]) => ({
299
301
  slot: port,
300
302
  type: e.main ? "main" : "linked",
301
303
  status: e.status,
304
+ dev: liveSet.has(resolve(e.worktree)) ? "up" : "-",
302
305
  branch: getWorktreeBranch(e.worktree) ?? "(detached)",
303
306
  worktree: e.worktree,
304
307
  owner: e.owner ?? "-",
@@ -308,6 +311,7 @@ function runList(config) {
308
311
  slot: "SLOT",
309
312
  type: "TYPE",
310
313
  status: "STATUS",
314
+ dev: "DEV",
311
315
  branch: "BRANCH",
312
316
  worktree: "PATH",
313
317
  owner: "OWNER",
@@ -317,11 +321,12 @@ function runList(config) {
317
321
  slot: Math.max(headers.slot.length, ...rows.map((r) => r.slot.length)),
318
322
  type: Math.max(headers.type.length, ...rows.map((r) => r.type.length)),
319
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)),
320
325
  branch: Math.max(headers.branch.length, ...rows.map((r) => r.branch.length)),
321
326
  worktree: Math.max(headers.worktree.length, ...rows.map((r) => r.worktree.length)),
322
327
  owner: Math.max(headers.owner.length, ...rows.map((r) => r.owner.length)),
323
328
  };
324
- 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}`;
325
330
  console.log(fmt(headers));
326
331
  for (const r of rows)
327
332
  console.log(fmt(r));
@@ -394,7 +399,7 @@ async function handleRemove(command, ctx, run, config) {
394
399
  stopTargetDevServer(config.devServerScript, target.worktreePath, verboseLog);
395
400
  }
396
401
  else {
397
- verboseLog(`No dev-server running in ${target.worktreePath}; skipping --stop.`);
402
+ verboseLog(`No dev-server running in ${target.worktreePath}; skipping stop.`);
398
403
  }
399
404
  if (config.purgeInfrastructure) {
400
405
  await config.purgeInfrastructure({
@@ -516,21 +521,21 @@ function resolveRemoveTarget(command, ctx, registry) {
516
521
  }
517
522
  /**
518
523
  * Stops the dev-server running in the target worktree by shelling out to
519
- * `node <devServerScript> --stop` with `cwd: worktreePath`. The subprocess runs the target's
524
+ * `node <devServerScript> down` with `cwd: worktreePath`. The subprocess runs the target's
520
525
  * own stop flow — registry-based spawn-PID kill + callback `stop()` from the target's branch.
521
526
  */
522
527
  function stopTargetDevServer(devServerScript, worktreePath, log) {
523
528
  log(`Stopping dev-server in ${worktreePath}...`);
524
- const result = spawnSync(process.execPath, [devServerScript, "--stop"], {
529
+ const result = spawnSync(process.execPath, [devServerScript, "down"], {
525
530
  cwd: worktreePath,
526
531
  stdio: "inherit",
527
532
  timeout: 30_000,
528
533
  });
529
534
  if (result.error) {
530
- console.warn(`Warning: failed to run dev-server --stop: ${result.error.message}`);
535
+ console.warn(`Warning: failed to run dev down: ${result.error.message}`);
531
536
  }
532
537
  else if (result.status !== 0) {
533
- console.warn(`Warning: dev-server --stop exited with code ${result.status}.`);
538
+ console.warn(`Warning: dev down exited with code ${result.status}.`);
534
539
  }
535
540
  }
536
541
  function resolvePortsFn(config) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paleo/workspace",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Run multiple git-worktree dev environments side by side.",
5
5
  "keywords": [
6
6
  "workspace",