@paleo/workspace 0.14.2 → 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";
@@ -93,6 +93,11 @@ async function start(config, mainWorktree, options) {
93
93
  */
94
94
  async function runForeground(config, mainWorktree, options) {
95
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
+ }
96
101
  const state = { spawnPids: {}, startedCallbacks: [] };
97
102
  let started = false;
98
103
  let shuttingDown = false;
@@ -111,15 +116,16 @@ async function runForeground(config, mainWorktree, options) {
111
116
  process.on("SIGTERM", onSignal);
112
117
  if (await runStartChecks(config, mainWorktree, ctx, options))
113
118
  process.exit(0);
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 });
114
121
  // A signal during startup hands teardown to onSignal (rollback + exit 130); block so we
115
122
  // neither roll back twice nor register a server that is being torn down.
116
- if (!(await spawnWithRollback(config, ctx, state, () => shuttingDown))) {
123
+ if (!(await spawnWithRollback(config, ctx, state, () => shuttingDown, streamLogs))) {
117
124
  await new Promise(() => { });
118
125
  }
119
126
  const slot = registerStartedServer(config, mainWorktree, state.spawnPids);
120
127
  started = true;
121
128
  printStartSummary(config, slot, state.spawnPids);
122
- tailLogs(config, state.spawnPids);
123
129
  watchForExternalStop(Object.values(state.spawnPids), () => {
124
130
  if (shuttingDown)
125
131
  return;
@@ -129,6 +135,42 @@ async function runForeground(config, mainWorktree, options) {
129
135
  });
130
136
  await new Promise(() => { });
131
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
+ }
132
174
  async function shutdownForeground(config, mainWorktree) {
133
175
  console.log("\nStopping dev servers...");
134
176
  await stopLocal(config, mainWorktree);
@@ -148,23 +190,24 @@ async function runStartChecks(config, mainWorktree, ctx, { evict, restart }) {
148
190
  * Spawn and await readiness, rolling back on failure. Returns `false` when `isAborted` reports a
149
191
  * concurrent shutdown — the caller must then yield teardown to whoever set it, not proceed.
150
192
  */
151
- async function spawnWithRollback(config, ctx, state, isAborted = () => false) {
193
+ async function spawnWithRollback(config, ctx, state, isAborted = () => false, onSpawned) {
152
194
  try {
153
- await spawnAndAwait(config, ctx, state);
195
+ await spawnAndAwait(config, ctx, state, onSpawned);
154
196
  }
155
197
  catch (err) {
156
198
  if (isAborted())
157
199
  return false;
158
200
  await rollbackStart(state.spawnPids, state.startedCallbacks, ctx);
159
201
  if (err instanceof StartupError) {
160
- handleStartupFailure(err);
202
+ // The foreground (onSpawned set) already streamed the log live, so skip the redundant tail.
203
+ handleStartupFailure(err, { includeTail: onSpawned === undefined });
161
204
  process.exit(1);
162
205
  }
163
206
  throw err;
164
207
  }
165
208
  return !isAborted();
166
209
  }
167
- async function spawnAndAwait(config, ctx, state) {
210
+ async function spawnAndAwait(config, ctx, state, onSpawned) {
168
211
  for (const server of config.servers) {
169
212
  console.log(`Starting ${server.name} dev server...`);
170
213
  if (server.kind === "spawn") {
@@ -175,6 +218,7 @@ async function spawnAndAwait(config, ctx, state) {
175
218
  state.startedCallbacks.push(server);
176
219
  }
177
220
  }
221
+ onSpawned?.();
178
222
  const spawnEntries = config.servers.filter((s) => s.kind === "spawn");
179
223
  const pollables = spawnEntries.map((s) => ({
180
224
  name: s.name,
@@ -215,16 +259,30 @@ function printStartSummary(config, slot, spawnPids) {
215
259
  }
216
260
  const TAIL_INTERVAL_MS = 300;
217
261
  const LIVENESS_POLL_MS = 1000;
218
- function tailLogs(config, spawnPids) {
262
+ function tailLogs(config, spawnPids, mode) {
219
263
  const names = Object.keys(spawnPids);
220
264
  const prefixed = names.length > 1;
221
265
  for (const name of names) {
222
266
  const path = join(process.cwd(), logFileFor(config.runtimeDir, name));
223
- 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);
224
270
  }
225
271
  }
226
- function followLogFile(path, prefix) {
227
- 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;
228
286
  setInterval(() => {
229
287
  if (!existsSync(path))
230
288
  return;
@@ -239,8 +297,7 @@ function followLogFile(path, prefix) {
239
297
  const bytesRead = readSync(fd, buffer, 0, length, offset);
240
298
  closeSync(fd);
241
299
  offset += bytesRead;
242
- const text = buffer.subarray(0, bytesRead).toString("utf8");
243
- process.stdout.write(prefix === "" ? text : text.replace(/^(?=.)/gm, prefix));
300
+ writeWithPrefix(buffer.subarray(0, bytesRead).toString("utf8"), prefix);
244
301
  }, TAIL_INTERVAL_MS);
245
302
  }
246
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.2",
3
+ "version": "0.15.0",
4
4
  "description": "Run multiple git-worktree dev environments side by side.",
5
5
  "keywords": [
6
6
  "workspace",