@paleo/workspace 0.14.2 → 0.15.1
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 +4 -2
- package/dist/dev-server.js +75 -15
- package/dist/helpers.d.ts +1 -0
- package/dist/helpers.js +5 -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,14 @@ 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",
|
|
289
291
|
" dev up Start in the background and return once ready.",
|
|
290
292
|
" dev restart Stop this worktree's dev-server if running, then start in the background.",
|
|
291
293
|
" dev down [--all] Stop this worktree's dev-server, or every dev-server with --all.",
|
|
292
294
|
" dev list List active dev-servers across all worktrees.",
|
|
293
295
|
" dev status Report whether this worktree's dev-server is UP or DOWN.",
|
|
294
|
-
" dev --help Show this help message.",
|
|
296
|
+
" dev --help Show this help message (alias: -h).",
|
|
295
297
|
"",
|
|
296
298
|
"Options (dev, dev up, dev restart):",
|
|
297
299
|
" --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";
|
|
@@ -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
|
-
|
|
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,33 @@ 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
|
-
|
|
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
|
-
|
|
227
|
-
|
|
272
|
+
// Prints the last `lines` of the log, then returns the byte offset where `followLogFile` resumes
|
|
273
|
+
// (the file's current size). Reads raw bytes so the offset matches the file even if it holds
|
|
274
|
+
// invalid UTF-8, which a decoded string's byte length would not.
|
|
275
|
+
function replayTail(path, prefix, lines) {
|
|
276
|
+
if (!existsSync(path))
|
|
277
|
+
return 0;
|
|
278
|
+
const buffer = readFileSync(path);
|
|
279
|
+
const tail = lastLines(buffer.toString("utf8"), lines);
|
|
280
|
+
if (tail.length > 0)
|
|
281
|
+
writeWithPrefix(tail.endsWith("\n") ? tail : `${tail}\n`, prefix);
|
|
282
|
+
return buffer.length;
|
|
283
|
+
}
|
|
284
|
+
function writeWithPrefix(text, prefix) {
|
|
285
|
+
process.stdout.write(prefix === "" ? text : text.replace(/^(?=.)/gm, prefix));
|
|
286
|
+
}
|
|
287
|
+
function followLogFile(path, prefix, initialOffset) {
|
|
288
|
+
let offset = initialOffset;
|
|
228
289
|
setInterval(() => {
|
|
229
290
|
if (!existsSync(path))
|
|
230
291
|
return;
|
|
@@ -239,8 +300,7 @@ function followLogFile(path, prefix) {
|
|
|
239
300
|
const bytesRead = readSync(fd, buffer, 0, length, offset);
|
|
240
301
|
closeSync(fd);
|
|
241
302
|
offset += bytesRead;
|
|
242
|
-
|
|
243
|
-
process.stdout.write(prefix === "" ? text : text.replace(/^(?=.)/gm, prefix));
|
|
303
|
+
writeWithPrefix(buffer.subarray(0, bytesRead).toString("utf8"), prefix);
|
|
244
304
|
}, TAIL_INTERVAL_MS);
|
|
245
305
|
}
|
|
246
306
|
/**
|
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,11 @@ 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
|
+
if (count <= 0)
|
|
26
|
+
return "";
|
|
27
|
+
return content.split("\n").slice(-count).join("\n");
|
|
28
|
+
}
|
|
24
29
|
/**
|
|
25
30
|
* Reads `<varName>=<value>` from a dotenv-style file and parses it as a port.
|
|
26
31
|
* 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;
|