@celilo/cli 5.2.2 → 5.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "5.2.2",
3
+ "version": "5.3.0",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -61,7 +61,7 @@
61
61
  "@aws-sdk/lib-storage": "^3.1101.0",
62
62
  "@celilo/capabilities": "^6.1.0",
63
63
  "@celilo/cli-display": "^0.2.0",
64
- "@celilo/core": "^0.15.0",
64
+ "@celilo/core": "^0.16.0",
65
65
  "@celilo/event-bus": "^0.7.0",
66
66
  "ajv": "^8.18.0",
67
67
  "drizzle-orm": "^0.36.4",
@@ -0,0 +1,103 @@
1
+ /**
2
+ * A read runs in THIS process; everything else still gets a child.
3
+ *
4
+ * Every command used to be a fresh `celilo` child, which costs a full CLI boot —
5
+ * ~1.1s on celilo-mgr, whose root is an SD card — paid per command even on a
6
+ * reused connection. With connection reuse landed, every console endpoint still
7
+ * measured ~1.0-1.5s regardless of what it did, because the boot dominated the
8
+ * work. A read cannot park, so it has nothing to outlive the transport and needs
9
+ * no child (celilo#1457).
10
+ *
11
+ * These pin the two things that make in-process execution safe. Both are
12
+ * invisible in normal operation and catastrophic when wrong, which is exactly
13
+ * the shape that needs a test rather than a comment.
14
+ */
15
+ import { describe, expect, test } from 'bun:test';
16
+ import { isReadOnlyPath } from '@celilo/core';
17
+
18
+ describe('which commands may run in-process', () => {
19
+ test('the console’s reads are classified read-only', () => {
20
+ // If any of these stopped being a read, it would start paying a CLI boot
21
+ // again and the console would silently regress to ~1s per call.
22
+ for (const argv of [
23
+ ['console', 'status', '--json'],
24
+ ['alerts', 'list', '--json'],
25
+ ['backup', 'list', '--json'],
26
+ ['person', 'list', '--json'],
27
+ ['module', 'list', '--json'],
28
+ ]) {
29
+ expect(isReadOnlyPath(argv)).toBe(true);
30
+ }
31
+ });
32
+
33
+ test('a mutating command is NOT, so it still gets a child', () => {
34
+ // The child is what lets a parked command outlive the ssh transport. A
35
+ // write running in-process would lose that, and a park would die with the
36
+ // connection.
37
+ for (const argv of [
38
+ ['module', 'deploy', 'caddy'],
39
+ ['alerts', 'ack', 'some-id'],
40
+ ['module', 'update'],
41
+ ['system', 'init'],
42
+ ]) {
43
+ expect(isReadOnlyPath(argv)).toBe(false);
44
+ }
45
+ });
46
+
47
+ test('an unknown verb is not treated as a read', () => {
48
+ // Fail closed: an unrecognised path must take the child, not the fast path.
49
+ expect(isReadOnlyPath(['not-a-command'])).toBe(false);
50
+ expect(isReadOnlyPath([])).toBe(false);
51
+ });
52
+ });
53
+
54
+ describe('the guards in-process execution depends on', () => {
55
+ test('process.exit can be intercepted and restored', () => {
56
+ // The CLI calls process.exit on some paths and inside runCli
57
+ // (cli/index.ts:1582). For a CHILD that is an ordinary exit; in-process it
58
+ // would take the whole api-serve down mid-session, dropping the client's
59
+ // connection and any parked session it was holding.
60
+ const real = process.exit;
61
+ class ExitCalled extends Error {
62
+ constructor(readonly code: number) {
63
+ super(`exit ${code}`);
64
+ }
65
+ }
66
+ let caught: number | null = null;
67
+ try {
68
+ (process as { exit: unknown }).exit = (code?: number): never => {
69
+ throw new ExitCalled(code ?? 0);
70
+ };
71
+ try {
72
+ process.exit(3);
73
+ } catch (e) {
74
+ if (e instanceof ExitCalled) caught = e.code;
75
+ }
76
+ } finally {
77
+ (process as { exit: unknown }).exit = real;
78
+ }
79
+ expect(caught).toBe(3);
80
+ expect(process.exit).toBe(real);
81
+ });
82
+
83
+ test('a stdout reference bound BEFORE a redirect survives it', () => {
84
+ // `send` writes the protocol through a reference bound at module load. If
85
+ // it used process.stdout.write directly, the capture would swallow the
86
+ // framing and the client would receive api-serve's own protocol as if it
87
+ // were command output — unparseable, and three layers from the cause.
88
+ const real = process.stdout.write.bind(process.stdout);
89
+ const bound = real;
90
+ const captured: string[] = [];
91
+ try {
92
+ (process.stdout as { write: unknown }).write = (c: unknown): boolean => {
93
+ captured.push(String(c));
94
+ return true;
95
+ };
96
+ process.stdout.write('this is command output\n');
97
+ bound(''); // the protocol path: must NOT land in `captured`
98
+ } finally {
99
+ (process.stdout as { write: unknown }).write = real;
100
+ }
101
+ expect(captured).toEqual(['this is command output\n']);
102
+ });
103
+ });
package/src/api/serve.ts CHANGED
@@ -25,8 +25,10 @@ import {
25
25
  ClientMessageSchema,
26
26
  type ServerMessage,
27
27
  ServerMessageSchema,
28
+ isReadOnlyPath,
28
29
  translateOutputLine,
29
30
  } from '@celilo/core';
31
+ import { runCli } from '../cli/index';
30
32
  import { parseArguments } from '../cli/parser';
31
33
  import { getDataDir, getEventBusPath } from '../config/paths';
32
34
  import { getDb } from '../db/client';
@@ -51,8 +53,17 @@ const EXIT_PERMISSION_DENIED = 126;
51
53
  /** How often an attached client polls a session's buffer for new output. */
52
54
  const ATTACH_POLL_MS = 250;
53
55
 
56
+ /**
57
+ * The real stdout, captured at module load.
58
+ *
59
+ * `runCommandInProcess` redirects `process.stdout.write` to capture a command's
60
+ * output. Without this reference `send` would write the PROTOCOL through that
61
+ * redirect and the client would receive its own framing as command output.
62
+ */
63
+ const writeProtocol = process.stdout.write.bind(process.stdout);
64
+
54
65
  function send(msg: ServerMessage): void {
55
- process.stdout.write(`${JSON.stringify(msg)}\n`);
66
+ writeProtocol(`${JSON.stringify(msg)}\n`);
56
67
  }
57
68
 
58
69
  /**
@@ -132,8 +143,128 @@ async function pumpLines(
132
143
  }
133
144
  }
134
145
 
146
+ /**
147
+ * Run a READ-ONLY command in this process instead of spawning a child.
148
+ *
149
+ * Every command used to be a fresh `celilo` child, which costs a full CLI boot —
150
+ * ~1.1s on celilo-mgr, whose root is an SD card. That is paid per command even
151
+ * on a reused connection, and it was the floor under the web console: with
152
+ * connection reuse landed, every endpoint still measured ~1.0-1.5s regardless of
153
+ * what it actually did, because the boot dominated the work (celilo#1457).
154
+ *
155
+ * WHY ONLY READS. The child exists so a command that PARKS can outlive the ssh
156
+ * transport: the session keeps it alive and buffers its output. A read cannot
157
+ * park — it raises no interview — so it has nothing to outlive and needs no
158
+ * child. `isReadOnlyPath` is the same predicate the API's authz uses, so this
159
+ * cannot widen what runs in-process without also widening what counts as a read.
160
+ *
161
+ * TWO THINGS MAKE IT SAFE, and neither is optional:
162
+ *
163
+ * - Output is captured by redirecting `process.stdout/stderr.write`, so `send`
164
+ * must not use them. It writes through `writeProtocol`, bound before any
165
+ * redirect exists. Commands are serialized (one `commandRunning` slot), so
166
+ * the redirect can never straddle two commands.
167
+ * - `process.exit` is intercepted. The CLI calls it on some paths and inside
168
+ * `runCli` (cli/index.ts:1582), which for a CHILD is an ordinary exit and
169
+ * in-process would take the whole server down mid-session. It is replaced
170
+ * with a throw that carries the code, and restored in `finally`.
171
+ */
172
+ async function runCommandInProcess(
173
+ argv: string[],
174
+ forward: (msg: ServerMessage) => void,
175
+ ): Promise<number> {
176
+ const realStdout = process.stdout.write.bind(process.stdout);
177
+ const realStderr = process.stderr.write.bind(process.stderr);
178
+ const realExit = process.exit.bind(process);
179
+
180
+ const buffers: Record<'stdout' | 'stderr', string> = { stdout: '', stderr: '' };
181
+ const capture =
182
+ (stream: 'stdout' | 'stderr') =>
183
+ (chunk: unknown): boolean => {
184
+ const text =
185
+ typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk as Uint8Array);
186
+ buffers[stream] += text;
187
+ let nl = buffers[stream].indexOf('\n');
188
+ while (nl >= 0) {
189
+ const line = buffers[stream].slice(0, nl);
190
+ buffers[stream] = buffers[stream].slice(nl + 1);
191
+ const msg = translateOutputLine(line);
192
+ forward(msg.type === 'log' ? { ...msg, stream } : msg);
193
+ nl = buffers[stream].indexOf('\n');
194
+ }
195
+ return true;
196
+ };
197
+
198
+ const flush = (): void => {
199
+ for (const stream of ['stdout', 'stderr'] as const) {
200
+ if (buffers[stream].length > 0) {
201
+ const msg = translateOutputLine(buffers[stream]);
202
+ forward(msg.type === 'log' ? { ...msg, stream } : msg);
203
+ buffers[stream] = '';
204
+ }
205
+ }
206
+ };
207
+
208
+ class ExitCalled extends Error {
209
+ constructor(readonly code: number) {
210
+ super(`process.exit(${code})`);
211
+ }
212
+ }
213
+
214
+ let exitCode: number;
215
+ try {
216
+ (process.stdout as { write: unknown }).write = capture('stdout');
217
+ (process.stderr as { write: unknown }).write = capture('stderr');
218
+ (process as { exit: unknown }).exit = (code?: number): never => {
219
+ throw new ExitCalled(code ?? 0);
220
+ };
221
+
222
+ // `parseArguments` does `argv.slice(2)`, so runCli takes process.argv SHAPE —
223
+ // executable, script, then the command. The child path gets this right by
224
+ // construction (`Bun.spawn([process.execPath, Bun.main, ...argv])`); passing
225
+ // bare argv here silently dropped the first two words, so `service list
226
+ // --json` parsed as `--json` and the command failed. Mirror the child.
227
+ const result = await runCli([process.execPath, Bun.main, ...argv]);
228
+ // `runCli` RETURNS the terminal message rather than printing it, so it never
229
+ // passed through the capture above. A child printed it on the way out; in
230
+ // process it has to be forwarded explicitly or the client sees an empty
231
+ // payload for a command that succeeded.
232
+ if (result.success) {
233
+ exitCode = 0;
234
+ if (result.message) {
235
+ forward({ type: 'log', message: result.message, stream: 'stdout' });
236
+ }
237
+ } else {
238
+ exitCode = 1;
239
+ forward({ type: 'log', message: result.error, stream: 'stderr' });
240
+ }
241
+ } catch (error) {
242
+ if (error instanceof ExitCalled) {
243
+ exitCode = error.code;
244
+ } else {
245
+ exitCode = 1;
246
+ forward({
247
+ type: 'log',
248
+ message: error instanceof Error ? error.message : String(error),
249
+ stream: 'stderr',
250
+ });
251
+ }
252
+ } finally {
253
+ (process.stdout as { write: unknown }).write = realStdout;
254
+ (process.stderr as { write: unknown }).write = realStderr;
255
+ (process as { exit: unknown }).exit = realExit;
256
+ }
257
+
258
+ flush();
259
+ forward(resultMessage(exitCode === 0, exitCode));
260
+ return exitCode;
261
+ }
262
+
135
263
  /** Run an authorized command as a child; returns its exit code. */
136
264
  async function runCommand(argv: string[], forward: (msg: ServerMessage) => void): Promise<number> {
265
+ // A read cannot park, so it needs no child and should not pay a CLI boot.
266
+ if (isReadOnlyPath(argv)) return runCommandInProcess(argv, forward);
267
+
137
268
  // Re-invoke this same CLI as a child. The child is non-TTY (piped stdout) so
138
269
  // ProgressDisplay resolves to protocol mode and emits `[progress:*]` markers.
139
270
  const child = Bun.spawn([process.execPath, Bun.main, ...argv], {
@@ -8,10 +8,10 @@
8
8
 
9
9
  import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
10
10
  import { execFileSync } from 'node:child_process';
11
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
11
+ import { mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs';
12
12
  import { tmpdir } from 'node:os';
13
13
  import { join } from 'node:path';
14
- import { buildModule, computeChecksums } from './build';
14
+ import { buildModule, computeChecksums, stagingParent, workspaceRoot } from './build';
15
15
 
16
16
  describe('computeChecksums — scripts/node_modules bundling (ISS-0046)', () => {
17
17
  let dir: string;
@@ -143,3 +143,56 @@ describe('buildModule — a root package.json must not strip the hook runtime',
143
143
  expect(listed).not.toContain('scripts/node_modules/.bin/tldts');
144
144
  });
145
145
  });
146
+
147
+ describe('stagingParent — cross-device staging (celilo#1463)', () => {
148
+ it('stages under tmpdir when it shares a device with the source', () => {
149
+ expect(stagingParent('/src/mod', () => 1)).toBe(tmpdir());
150
+ });
151
+
152
+ it('stages beside the source when tmpdir is a different mount', () => {
153
+ // The CI runner's PrivateTmp=yes case: a rename from the build cwd into
154
+ // tmpdir() would be cross-device, and bun ships an all-NUL binary.
155
+ const devs: Record<string, number> = { [tmpdir()]: 2 };
156
+ expect(stagingParent('/src/mod', (p) => devs[p] ?? 1)).toBe('/src');
157
+ });
158
+
159
+ it('stages beside the source when a device cannot be read', () => {
160
+ expect(
161
+ stagingParent('/src/mod', () => {
162
+ throw new Error('EACCES');
163
+ }),
164
+ ).toBe('/src');
165
+ });
166
+
167
+ it('stages at the workspace root, never inside it (ce-eogp)', () => {
168
+ // Staging at modules/celilo-package-XXX puts a second copy of
169
+ // modules/<id>/site under the root's `modules/*/site` workspace glob, and
170
+ // `bun install` then aborts with `Workspace name ... already exists`.
171
+ const parent = stagingParent(
172
+ '/repo/modules/celilo-website',
173
+ () => {
174
+ throw new Error('EXDEV');
175
+ },
176
+ () => '/repo',
177
+ );
178
+ expect(parent).toBe('/repo');
179
+ });
180
+
181
+ it('finds the nearest ancestor package.json declaring workspaces', () => {
182
+ const files: Record<string, string> = {
183
+ '/repo/package.json': '{"workspaces":["modules/*/site"]}',
184
+ '/repo/modules/m/package.json': '{"name":"m"}',
185
+ };
186
+ expect(workspaceRoot('/repo/modules/m', (f) => files[f] ?? null)).toBe('/repo');
187
+ expect(workspaceRoot('/elsewhere/m', (f) => files[f] ?? null)).toBeNull();
188
+ });
189
+
190
+ it('always picks a parent on the source device, for real paths', () => {
191
+ const src = mkdtempSync(join(tmpdir(), 'celilo-staging-parent-'));
192
+ try {
193
+ expect(statSync(stagingParent(src)).dev).toBe(statSync(src).dev);
194
+ } finally {
195
+ rmSync(src, { recursive: true, force: true });
196
+ }
197
+ });
198
+ });
@@ -1,8 +1,8 @@
1
1
  import { execFileSync, execSync } from 'node:child_process';
2
- import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
2
+ import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs';
3
3
  import { readFile, readdir, writeFile } from 'node:fs/promises';
4
4
  import { tmpdir } from 'node:os';
5
- import { basename, join, relative } from 'node:path';
5
+ import { basename, dirname, join, relative } from 'node:path';
6
6
  import { gunzipSync } from 'node:zlib';
7
7
  import { create as tarCreate } from 'tar';
8
8
  import { parse as parseYaml } from 'yaml';
@@ -16,6 +16,63 @@ import { classifyModulePath, includeNodeModulesPath } from './package-rules';
16
16
  import { signChecksums } from './signature';
17
17
  import { rewriteWorkspaceDeps } from './workspace-deps';
18
18
 
19
+ /**
20
+ * Pick the parent directory the build staging dir is created under.
21
+ *
22
+ * `bun build --compile --outfile=X` writes its staging temp file in the
23
+ * process CWD and `renameat()`s it onto X. Across a mount boundary that
24
+ * rename returns EXDEV, and bun's fallback fallocates the right size then
25
+ * `copy_file_range()`s nothing (it never rewinds the source fd, and never
26
+ * checks the return) — producing a right-sized, entirely-NUL binary and a
27
+ * zero exit. The CI runner's systemd unit sets `PrivateTmp=yes`, which makes
28
+ * tmpdir() a different mount from the checkout, so every celilo module build
29
+ * that renamed an artifact into the stage hit it (celilo#1463).
30
+ *
31
+ * So: stage under tmpdir() when it shares a device with the module source,
32
+ * and on the source's own device otherwise. That keeps the rename intra-device
33
+ * for the whole class of builds, not just bun's.
34
+ *
35
+ * "Beside the source" is NOT safe for a module inside a bun workspace: the
36
+ * staged copy of `modules/<id>` lands at `modules/celilo-package-XXXXXX`, whose
37
+ * `site/`, `server/` and `e2e/` subdirs match the root package.json's
38
+ * `modules/*\/site` globs, so `bun install` during the module build aborts with
39
+ * `Workspace name "<x>" already exists` (ce-eogp, regression from celilo#1463).
40
+ * So when the source sits inside a workspace, stage at the workspace ROOT,
41
+ * which is on the same device and matched by no workspace glob.
42
+ */
43
+ export function workspaceRoot(
44
+ dir: string,
45
+ readPkg: (p: string) => string | null = safeRead,
46
+ ): string | null {
47
+ for (let d = dir; ; d = dirname(d)) {
48
+ const raw = readPkg(join(d, 'package.json'));
49
+ if (raw?.includes('"workspaces"')) return d;
50
+ if (dirname(d) === d) return null;
51
+ }
52
+ }
53
+
54
+ function safeRead(p: string): string | null {
55
+ try {
56
+ return readFileSync(p, 'utf-8');
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ export function stagingParent(
63
+ sourceDir: string,
64
+ deviceOf: (p: string) => number = (p) => statSync(p).dev,
65
+ findRoot: (d: string) => string | null = workspaceRoot,
66
+ ): string {
67
+ try {
68
+ if (deviceOf(tmpdir()) === deviceOf(sourceDir)) return tmpdir();
69
+ } catch {
70
+ // Either path unreadable — fall through to a same-device parent, which is
71
+ // the safe side: same device by construction.
72
+ }
73
+ return findRoot(sourceDir) ?? dirname(sourceDir);
74
+ }
75
+
19
76
  /**
20
77
  * Checksums data structure
21
78
  */
@@ -230,7 +287,7 @@ export async function buildModule(options: ModuleBuildOptions): Promise<ModuleBu
230
287
  // Avoids copying node_modules, build artifacts, git history.
231
288
  // - If no package.json (simple modules, test fixtures), fall back to
232
289
  // recursive copy with EXCLUDE_PATTERNS filter.
233
- const buildDir = mkdtempSync(join(tmpdir(), 'celilo-package-'));
290
+ const buildDir = mkdtempSync(join(stagingParent(sourceDir), 'celilo-package-'));
234
291
  const hasPackageJson = existsSync(join(sourceDir, 'package.json'));
235
292
 
236
293
  try {