@paleo/workspace 0.14.1 → 0.15.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
@@ -27,7 +27,7 @@ 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 # start in the foreground; holds the terminal, stops on CTRL+C
30
+ npm run dev # foreground: stream logs from startup, CTRL+C stops; attaches if already running (CTRL+C then detaches)
31
31
  npm run dev -- up # start in the background (no-op if already running here)
32
32
  npm run dev -- up --restart # stop the dev-server in this worktree if running, then start fresh
33
33
  npm run dev -- up --evict # if devLimit is reached, evict the oldest dev-server and start
package/dist/cli.js CHANGED
@@ -182,6 +182,7 @@ export function printWorkspaceHelp() {
182
182
  "",
183
183
  "Global options:",
184
184
  " -v, --verbose Show intermediate output.",
185
+ " -h, --help Show this help message.",
185
186
  ].join("\n"));
186
187
  }
187
188
  export function parseDevArgs(argv = process.argv.slice(2)) {
@@ -285,13 +286,15 @@ export function printDevHelp() {
285
286
  "Start, stop, or list dev-server processes for worktree-based environments.",
286
287
  "",
287
288
  "Commands:",
288
- " dev Start in the foreground; holds the terminal, stops on CTRL+C.",
289
+ " dev Start in the foreground, streaming logs from startup; CTRL+C stops it.",
290
+ " If one is already running here, attach to its logs instead",
291
+ " (CTRL+C then detaches, leaving it running).",
289
292
  " dev up Start in the background and return once ready.",
290
293
  " dev restart Stop this worktree's dev-server if running, then start in the background.",
291
294
  " dev down [--all] Stop this worktree's dev-server, or every dev-server with --all.",
292
295
  " dev list List active dev-servers across all worktrees.",
293
296
  " dev status Report whether this worktree's dev-server is UP or DOWN.",
294
- " dev --help Show this help message.",
297
+ " dev --help Show this help message (alias: -h).",
295
298
  "",
296
299
  "Options (dev, dev up, dev restart):",
297
300
  " --evict Evict the oldest dev-server when the cap is reached.",
@@ -1,11 +1,11 @@
1
1
  import { spawn } from "node:child_process";
2
- import { closeSync, existsSync, mkdirSync, openSync, readSync, statSync } from "node:fs";
2
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
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, setupLogPath } from "./helpers.js";
8
- import { awaitAllReady, handleStartupFailure } from "./log-polling.js";
7
+ import { detectCommonJsError, formatDuration, lastLines, setupLogPath } from "./helpers.js";
8
+ import { awaitAllReady, handleStartupFailure, LOG_TAIL_LINES, } from "./log-polling.js";
9
9
  import { canonicalCwd, detectPortConflicts, sweepStalePorts, waitForPortsFree, } from "./port-holder.js";
10
10
  import { isProcessAlive, stopProcessGroup } from "./process-control.js";
11
11
  import { readSlots, resolveCurrentSlot } from "./slots.js";
@@ -82,17 +82,7 @@ async function start(config, mainWorktree, options) {
82
82
  if (await runStartChecks(config, mainWorktree, ctx, options))
83
83
  return;
84
84
  const state = { spawnPids: {}, startedCallbacks: [] };
85
- try {
86
- await spawnAndAwait(config, ctx, state);
87
- }
88
- catch (err) {
89
- await rollbackStart(state.spawnPids, state.startedCallbacks, ctx);
90
- if (err instanceof StartupError) {
91
- handleStartupFailure(err);
92
- process.exit(1);
93
- }
94
- throw err;
95
- }
85
+ await spawnWithRollback(config, ctx, state);
96
86
  const slot = registerStartedServer(config, mainWorktree, state.spawnPids);
97
87
  printStartSummary(config, slot, state.spawnPids);
98
88
  }
@@ -103,6 +93,11 @@ async function start(config, mainWorktree, options) {
103
93
  */
104
94
  async function runForeground(config, mainWorktree, options) {
105
95
  const ctx = { cwd: process.cwd() };
96
+ if (!options.restart) {
97
+ const running = findOwnLiveEntry(config, mainWorktree, ctx.cwd);
98
+ if (running)
99
+ return attachForeground(config, running);
100
+ }
106
101
  const state = { spawnPids: {}, startedCallbacks: [] };
107
102
  let started = false;
108
103
  let shuttingDown = false;
@@ -121,21 +116,16 @@ async function runForeground(config, mainWorktree, options) {
121
116
  process.on("SIGTERM", onSignal);
122
117
  if (await runStartChecks(config, mainWorktree, ctx, options))
123
118
  process.exit(0);
124
- try {
125
- await spawnAndAwait(config, ctx, state);
126
- }
127
- catch (err) {
128
- await rollbackStart(state.spawnPids, state.startedCallbacks, ctx);
129
- if (err instanceof StartupError) {
130
- handleStartupFailure(err);
131
- process.exit(1);
132
- }
133
- throw err;
119
+ // Stream logs from the first byte so the whole startup (e.g. a slow build) is visible live.
120
+ const streamLogs = () => tailLogs(config, state.spawnPids, { fromStart: true });
121
+ // A signal during startup hands teardown to onSignal (rollback + exit 130); block so we
122
+ // neither roll back twice nor register a server that is being torn down.
123
+ if (!(await spawnWithRollback(config, ctx, state, () => shuttingDown, streamLogs))) {
124
+ await new Promise(() => { });
134
125
  }
135
126
  const slot = registerStartedServer(config, mainWorktree, state.spawnPids);
136
127
  started = true;
137
128
  printStartSummary(config, slot, state.spawnPids);
138
- tailLogs(config, state.spawnPids);
139
129
  watchForExternalStop(Object.values(state.spawnPids), () => {
140
130
  if (shuttingDown)
141
131
  return;
@@ -145,6 +135,42 @@ async function runForeground(config, mainWorktree, options) {
145
135
  });
146
136
  await new Promise(() => { });
147
137
  }
138
+ function findOwnLiveEntry(config, mainWorktree, cwd) {
139
+ const entry = findOwnEntry(mainWorktree, config.registryDir, cwd);
140
+ if (!entry)
141
+ return;
142
+ return Object.values(entry.pids).some(isProcessAlive) ? entry : undefined;
143
+ }
144
+ /**
145
+ * Attach to a dev-server already running in this worktree: replay the recent log, follow it live,
146
+ * and exit when it stops. CTRL+C only detaches — this session does not own the server.
147
+ */
148
+ async function attachForeground(config, entry) {
149
+ const pidList = Object.entries(entry.pids)
150
+ .map(([name, pid]) => `${name}=${pid}`)
151
+ .join(", ");
152
+ console.log(`Attaching to the dev-server in this worktree (slot ${entry.slot}, pids: ${pidList}).`);
153
+ console.log("Showing live logs. Press CTRL+C to detach — the dev-server keeps running.");
154
+ let detaching = false;
155
+ const onSignal = () => {
156
+ if (detaching)
157
+ return;
158
+ detaching = true;
159
+ console.log("\nDetached. The dev-server is still running (`dev down` to stop it).");
160
+ process.exit(0);
161
+ };
162
+ process.on("SIGINT", onSignal);
163
+ process.on("SIGTERM", onSignal);
164
+ tailLogs(config, entry.pids, { replayLines: LOG_TAIL_LINES });
165
+ watchForExternalStop(Object.values(entry.pids).filter(isProcessAlive), () => {
166
+ if (detaching)
167
+ return;
168
+ detaching = true;
169
+ console.log("\nDev-server stopped externally (e.g. `dev down`). Exiting.");
170
+ process.exit(0);
171
+ });
172
+ await new Promise(() => { });
173
+ }
148
174
  async function shutdownForeground(config, mainWorktree) {
149
175
  console.log("\nStopping dev servers...");
150
176
  await stopLocal(config, mainWorktree);
@@ -160,7 +186,28 @@ async function runStartChecks(config, mainWorktree, ctx, { evict, restart }) {
160
186
  await checkPortsFree(config.servers, ctx.cwd);
161
187
  return false;
162
188
  }
163
- async function spawnAndAwait(config, ctx, state) {
189
+ /**
190
+ * Spawn and await readiness, rolling back on failure. Returns `false` when `isAborted` reports a
191
+ * concurrent shutdown — the caller must then yield teardown to whoever set it, not proceed.
192
+ */
193
+ async function spawnWithRollback(config, ctx, state, isAborted = () => false, onSpawned) {
194
+ try {
195
+ await spawnAndAwait(config, ctx, state, onSpawned);
196
+ }
197
+ catch (err) {
198
+ if (isAborted())
199
+ return false;
200
+ await rollbackStart(state.spawnPids, state.startedCallbacks, ctx);
201
+ if (err instanceof StartupError) {
202
+ // The foreground (onSpawned set) already streamed the log live, so skip the redundant tail.
203
+ handleStartupFailure(err, { includeTail: onSpawned === undefined });
204
+ process.exit(1);
205
+ }
206
+ throw err;
207
+ }
208
+ return !isAborted();
209
+ }
210
+ async function spawnAndAwait(config, ctx, state, onSpawned) {
164
211
  for (const server of config.servers) {
165
212
  console.log(`Starting ${server.name} dev server...`);
166
213
  if (server.kind === "spawn") {
@@ -171,6 +218,7 @@ async function spawnAndAwait(config, ctx, state) {
171
218
  state.startedCallbacks.push(server);
172
219
  }
173
220
  }
221
+ onSpawned?.();
174
222
  const spawnEntries = config.servers.filter((s) => s.kind === "spawn");
175
223
  const pollables = spawnEntries.map((s) => ({
176
224
  name: s.name,
@@ -211,16 +259,30 @@ function printStartSummary(config, slot, spawnPids) {
211
259
  }
212
260
  const TAIL_INTERVAL_MS = 300;
213
261
  const LIVENESS_POLL_MS = 1000;
214
- function tailLogs(config, spawnPids) {
262
+ function tailLogs(config, spawnPids, mode) {
215
263
  const names = Object.keys(spawnPids);
216
264
  const prefixed = names.length > 1;
217
265
  for (const name of names) {
218
266
  const path = join(process.cwd(), logFileFor(config.runtimeDir, name));
219
- followLogFile(path, prefixed ? `[${name}] ` : "");
267
+ const prefix = prefixed ? `[${name}] ` : "";
268
+ const offset = "fromStart" in mode ? 0 : replayTail(path, prefix, mode.replayLines);
269
+ followLogFile(path, prefix, offset);
220
270
  }
221
271
  }
222
- function followLogFile(path, prefix) {
223
- let offset = existsSync(path) ? statSync(path).size : 0;
272
+ function replayTail(path, prefix, lines) {
273
+ if (!existsSync(path))
274
+ return 0;
275
+ const content = readFileSync(path, "utf8");
276
+ const tail = lastLines(content, lines);
277
+ if (tail.length > 0)
278
+ writeWithPrefix(tail.endsWith("\n") ? tail : `${tail}\n`, prefix);
279
+ return Buffer.byteLength(content, "utf8");
280
+ }
281
+ function writeWithPrefix(text, prefix) {
282
+ process.stdout.write(prefix === "" ? text : text.replace(/^(?=.)/gm, prefix));
283
+ }
284
+ function followLogFile(path, prefix, initialOffset) {
285
+ let offset = initialOffset;
224
286
  setInterval(() => {
225
287
  if (!existsSync(path))
226
288
  return;
@@ -235,8 +297,7 @@ function followLogFile(path, prefix) {
235
297
  const bytesRead = readSync(fd, buffer, 0, length, offset);
236
298
  closeSync(fd);
237
299
  offset += bytesRead;
238
- const text = buffer.subarray(0, bytesRead).toString("utf8");
239
- process.stdout.write(prefix === "" ? text : text.replace(/^(?=.)/gm, prefix));
300
+ writeWithPrefix(buffer.subarray(0, bytesRead).toString("utf8"), prefix);
240
301
  }, TAIL_INTERVAL_MS);
241
302
  }
242
303
  /**
package/dist/helpers.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export declare function setupLogPath(worktreeRoot: string, runtimeDir: string): string;
2
2
  export declare function patchEnvFile(content: string, patches: Record<string, string>): string;
3
3
  export declare function extractHost(content: string, key: string, fallback?: string): string;
4
+ export declare function lastLines(content: string, count: number): string;
4
5
  /**
5
6
  * Reads `<varName>=<value>` from a dotenv-style file and parses it as a port.
6
7
  * Exits with code 1 on missing file, missing variable, or non-numeric value.
package/dist/helpers.js CHANGED
@@ -21,6 +21,9 @@ export function extractHost(content, key, fallback = "localhost") {
21
21
  const m = content.match(re);
22
22
  return m ? m[1] : fallback;
23
23
  }
24
+ export function lastLines(content, count) {
25
+ return content.split("\n").slice(-count).join("\n");
26
+ }
24
27
  /**
25
28
  * Reads `<varName>=<value>` from a dotenv-style file and parses it as a port.
26
29
  * Exits with code 1 on missing file, missing variable, or non-numeric value.
@@ -14,4 +14,8 @@ export interface AwaitOptions {
14
14
  isAlive?: (pid: number) => boolean;
15
15
  }
16
16
  export declare function awaitAllReady(servers: PollableServer[], pids: number[], options?: AwaitOptions): Promise<void>;
17
- export declare function handleStartupFailure(err: StartupError): void;
17
+ export interface StartupFailureOptions {
18
+ /** When false (the foreground already streamed the log live), skip the redundant log tail. */
19
+ includeTail?: boolean;
20
+ }
21
+ export declare function handleStartupFailure(err: StartupError, options?: StartupFailureOptions): void;
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { StartupError } from "./errors.js";
4
+ import { lastLines } from "./helpers.js";
4
5
  import { isProcessAlive as defaultIsAlive } from "./process-control.js";
5
6
  export const LOG_TAIL_LINES = 30;
6
7
  export const POLL_INTERVAL_MS = 500;
@@ -8,14 +9,18 @@ export const TIMEOUT_MS = 120_000;
8
9
  export async function awaitAllReady(servers, pids, options) {
9
10
  await Promise.all(servers.map((server, i) => waitForReady(server, pids[i], options)));
10
11
  }
11
- export function handleStartupFailure(err) {
12
+ export function handleStartupFailure(err, options = {}) {
12
13
  console.error(`\nError: ${err.label} ${err.reason}.`);
13
- if (err.logFile && existsSync(err.logFile)) {
14
- const lines = readFileSync(err.logFile, "utf-8").split("\n").slice(-LOG_TAIL_LINES);
14
+ if (!err.logFile || !existsSync(err.logFile))
15
+ return;
16
+ const fullLog = join(process.cwd(), err.logFile);
17
+ if (options.includeTail ?? true) {
15
18
  console.error(`\n--- ${err.label} log tail (last ${LOG_TAIL_LINES} lines) ---`);
16
- console.error(lines.join("\n"));
17
- console.error(`--- end ---\nFull log: ${join(process.cwd(), err.logFile)}`);
19
+ console.error(lastLines(readFileSync(err.logFile, "utf-8"), LOG_TAIL_LINES));
20
+ console.error(`--- end ---\nFull log: ${fullLog}`);
21
+ return;
18
22
  }
23
+ console.error(`Full log: ${fullLog}`);
19
24
  }
20
25
  async function waitForReady(server, pid, options = {}) {
21
26
  const timeoutMs = options.timeoutMs ?? TIMEOUT_MS;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paleo/workspace",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "description": "Run multiple git-worktree dev environments side by side.",
5
5
  "keywords": [
6
6
  "workspace",