@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 +1 -1
- package/dist/cli.js +5 -2
- package/dist/dev-server.js +93 -32
- package/dist/helpers.d.ts +1 -0
- package/dist/helpers.js +3 -0
- package/dist/log-polling.d.ts +5 -1
- package/dist/log-polling.js +10 -5
- package/package.json +1 -1
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 #
|
|
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
|
|
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.",
|
package/dist/dev-server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
223
|
-
|
|
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
|
-
|
|
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.
|
package/dist/log-polling.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/log-polling.js
CHANGED
|
@@ -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
|
|
14
|
-
|
|
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(
|
|
17
|
-
console.error(`--- end ---\nFull log: ${
|
|
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;
|