@celilo/cli 0.4.0 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -98,6 +98,11 @@ export async function handleRestore(
98
98
  if (result.systemDbApplied) lines.push(' • celilo.db swapped into place');
99
99
  if (result.masterKeyApplied) lines.push(' • master.key swapped into place');
100
100
  if (result.sshKeyApplied) lines.push(' • fleet SSH key restored to <data-dir>/.ssh/');
101
+ if (result.moduleSourcesApplied && result.moduleSourcesApplied > 0) {
102
+ lines.push(
103
+ ` • module source laid down for ${result.moduleSourcesApplied} module(s) (paths reconciled to this box)`,
104
+ );
105
+ }
101
106
  if (result.crossModuleApplied && result.crossModuleApplied.length > 0) {
102
107
  lines.push(
103
108
  ` • terraform state restored for ${result.crossModuleApplied.length} module(s): ${result.crossModuleApplied.join(', ')}`,
@@ -402,11 +402,16 @@ async function installScriptDependencies(
402
402
  targetPath: string,
403
403
  manifest: ModuleManifest,
404
404
  ): Promise<void> {
405
- // Determine where scripts live by looking at hook declarations.
406
- // All hooks use paths like `./scripts/setup-admin.ts`, so the
407
- // scripts directory is the common parent.
408
- if (!manifest.hooks || Object.keys(manifest.hooks).length === 0) {
409
- return; // No hooks no scripts nothing to install
405
+ // A module's scripts/ holds hook scripts AND/OR capability-provider
406
+ // implementations (e.g. dns_registrar's register-host.ts, referenced via
407
+ // provides.capabilities, not hooks). BOTH import @celilo/capabilities and
408
+ // need deps. A capability-only provider (hooks: {}) still has scripts to
409
+ // resolve keying solely off hooks left its node_modules un-vendored, so
410
+ // the capability-loader's import() failed with ENOENT @celilo/capabilities.
411
+ const hasHooks = Boolean(manifest.hooks && Object.keys(manifest.hooks).length > 0);
412
+ const hasCapabilities = (manifest.provides?.capabilities?.length ?? 0) > 0;
413
+ if (!hasHooks && !hasCapabilities) {
414
+ return; // No hook or capability scripts → nothing to install
410
415
  }
411
416
 
412
417
  const scriptsDir = join(targetPath, 'scripts');
@@ -147,11 +147,30 @@ describe('celilo-mgmt on_backup', () => {
147
147
  ).toBe(true);
148
148
  expect(existsSync(join(backupDir, 'cross_module_state', 'index.json'))).toBe(true);
149
149
 
150
- expect(result.schema_version).toBe('1.0');
150
+ expect(result.schema_version).toBe('1.1');
151
151
  expect(result.artifact_count).toBeGreaterThan(0);
152
152
  expect(result.size_bytes).toBeGreaterThan(0);
153
153
  });
154
154
 
155
+ it('captures module SOURCE (excluding generated/) into module_src/', async () => {
156
+ // stateDir = dirname(db_path) = dir; on_backup reads dir/modules/<id>.
157
+ const modSrc = join(dir, 'modules', 'caddy');
158
+ mkdirSync(join(modSrc, 'scripts'), { recursive: true });
159
+ mkdirSync(join(modSrc, 'generated', 'terraform'), { recursive: true });
160
+ writeFileSync(join(modSrc, 'manifest.yml'), 'id: caddy');
161
+ writeFileSync(join(modSrc, 'scripts', 'hook.ts'), '// hook');
162
+ writeFileSync(join(modSrc, 'generated', 'terraform', 'main.tf'), 'resource {}');
163
+
164
+ const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
165
+ await hook(buildContext({ backup_dir: backupDir, cross_module_root: crossModuleRoot }));
166
+
167
+ // Source files captured...
168
+ expect(existsSync(join(backupDir, 'module_src', 'caddy', 'manifest.yml'))).toBe(true);
169
+ expect(existsSync(join(backupDir, 'module_src', 'caddy', 'scripts', 'hook.ts'))).toBe(true);
170
+ // ...but generated/ excluded (TF state travels via cross_module_state).
171
+ expect(existsSync(join(backupDir, 'module_src', 'caddy', 'generated'))).toBe(false);
172
+ });
173
+
155
174
  it('machine-pool.json is valid JSON (array)', async () => {
156
175
  const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
157
176
  await hook(buildContext({ backup_dir: backupDir, cross_module_root: crossModuleRoot }));
@@ -168,7 +187,7 @@ describe('celilo-mgmt on_backup', () => {
168
187
 
169
188
  expect(existsSync(join(backupDir, 'celilo.db'))).toBe(true);
170
189
  expect(existsSync(join(backupDir, 'cross_module_state'))).toBe(false);
171
- expect(result.schema_version).toBe('1.0');
190
+ expect(result.schema_version).toBe('1.1');
172
191
  });
173
192
 
174
193
  it('proceeds (with a warning) when master.key is missing on disk', async () => {
@@ -8,6 +8,7 @@
8
8
  * via this service) is an e2e concern, not exercised at the unit level.
9
9
  */
10
10
 
11
+ import { Database } from 'bun:sqlite';
11
12
  import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
12
13
  import {
13
14
  existsSync,
@@ -38,11 +39,14 @@ describe('applyStagedSystemFiles', () => {
38
39
  keyPath = join(dir, 'master.key');
39
40
  process.env.CELILO_DB_PATH = livePath;
40
41
  process.env.CELILO_MASTER_KEY_PATH = keyPath;
42
+ process.env.CELILO_DATA_DIR = dir; // getModuleStoragePath() = dir/modules
41
43
  });
42
44
 
43
45
  afterEach(() => {
46
+ closeDb();
44
47
  process.env.CELILO_DB_PATH = undefined;
45
48
  process.env.CELILO_MASTER_KEY_PATH = undefined;
49
+ process.env.CELILO_DATA_DIR = undefined;
46
50
  try {
47
51
  rmSync(dir, { recursive: true, force: true });
48
52
  } catch {
@@ -124,6 +128,48 @@ describe('applyStagedSystemFiles', () => {
124
128
  expect(result.dbApplied).toBe(false);
125
129
  expect(result.keyApplied).toBe(false);
126
130
  });
131
+
132
+ it('lays down staged module source dirs at the modules storage path', () => {
133
+ const stagedSrc = join(stagingDir, 'module_src');
134
+ mkdirSync(join(stagedSrc, 'caddy', 'scripts'), { recursive: true });
135
+ writeFileSync(join(stagedSrc, 'caddy', 'manifest.yml'), 'id: caddy');
136
+ writeFileSync(join(stagedSrc, 'caddy', 'scripts', 'hook.ts'), '// hook');
137
+
138
+ const result = applyStagedSystemFiles(stagingDir);
139
+ expect(result.moduleSourcesApplied).toBe(1);
140
+ // getModuleStoragePath() = <CELILO_DATA_DIR>/modules = dir/modules.
141
+ const laid = join(dir, 'modules', 'caddy');
142
+ expect(readFileSync(join(laid, 'manifest.yml'), 'utf-8')).toBe('id: caddy');
143
+ expect(readFileSync(join(laid, 'scripts', 'hook.ts'), 'utf-8')).toBe('// hook');
144
+ });
145
+
146
+ it("rewrites every module's source_path to this box on the staged DB before swap", () => {
147
+ // A real staged SQLite DB whose module points at a FOREIGN (macOS) path —
148
+ // exactly the macOS→Linux cross-host case ISS-0051 broke on. Build a minimal
149
+ // self-contained DB (no runMigrations, which would hold a connection and
150
+ // lock the journal-mode switch the rewrite needs).
151
+ const stagedDbPath = join(stagingDir, 'celilo.db');
152
+ const seed = new Database(stagedDbPath);
153
+ seed.run(
154
+ 'CREATE TABLE modules (id TEXT PRIMARY KEY, name TEXT, version TEXT, manifest_data TEXT, source_path TEXT)',
155
+ );
156
+ seed.run(
157
+ "INSERT INTO modules (id, name, version, manifest_data, source_path) VALUES ('caddy', 'caddy', '2.0.0', '{}', '/Users/someone/Library/Application Support/celilo/modules/caddy')",
158
+ );
159
+ seed.close();
160
+
161
+ const result = applyStagedSystemFiles(stagingDir);
162
+ expect(result.dbApplied).toBe(true);
163
+
164
+ // The swapped-in live DB must now point at THIS box's modules dir, not the
165
+ // source box's dead macOS path.
166
+ const live = new Database(livePath);
167
+ const row = live.query("SELECT source_path AS sp FROM modules WHERE id = 'caddy'").get() as {
168
+ sp: string;
169
+ };
170
+ live.close();
171
+ expect(row.sp).toBe(join(dir, 'modules', 'caddy'));
172
+ });
127
173
  });
128
174
 
129
175
  describe('restoreFromArtifactFile error paths', () => {
@@ -16,13 +16,18 @@
16
16
  * of the restore the hook itself cannot do (live DB is open).
17
17
  */
18
18
 
19
+ import { Database } from 'bun:sqlite';
19
20
  import { execSync } from 'node:child_process';
20
21
  import {
21
22
  chmodSync,
23
+ closeSync,
22
24
  copyFileSync,
25
+ cpSync,
23
26
  existsSync,
24
27
  mkdirSync,
28
+ openSync,
25
29
  readFileSync,
30
+ readSync,
26
31
  readdirSync,
27
32
  rmSync,
28
33
  writeFileSync,
@@ -30,7 +35,7 @@ import {
30
35
  import { tmpdir } from 'node:os';
31
36
  import { dirname, join } from 'node:path';
32
37
  import { eq } from 'drizzle-orm';
33
- import { getDbPath, getMasterKeyPath } from '../config/paths';
38
+ import { getDbPath, getMasterKeyPath, getModuleStoragePath } from '../config/paths';
34
39
  import { closeDb, getDb } from '../db/client';
35
40
  import { runMigrations } from '../db/migrate';
36
41
  import { modules } from '../db/schema';
@@ -61,6 +66,8 @@ export interface RestoreFromFileResult {
61
66
  masterKeyApplied?: boolean;
62
67
  /** Was the fleet SSH keypair restored to <dataDir>/.ssh/? */
63
68
  sshKeyApplied?: boolean;
69
+ /** How many module source dirs were laid down at the target's modules dir. */
70
+ moduleSourcesApplied?: number;
64
71
  }
65
72
 
66
73
  export interface RestoreFromFileOptions {
@@ -258,6 +265,7 @@ export async function restoreFromArtifactFile(
258
265
  systemDbApplied: apply.dbApplied,
259
266
  masterKeyApplied: apply.keyApplied,
260
267
  sshKeyApplied: apply.sshApplied,
268
+ moduleSourcesApplied: apply.moduleSourcesApplied,
261
269
  };
262
270
  } finally {
263
271
  try {
@@ -272,6 +280,61 @@ export interface StagedSystemApplyResult {
272
280
  dbApplied: boolean;
273
281
  keyApplied: boolean;
274
282
  sshApplied: boolean;
283
+ /** How many module source dirs were laid down at the target's modules dir. */
284
+ moduleSourcesApplied: number;
285
+ }
286
+
287
+ /** Cheap check for the SQLite file magic without reading the whole DB. */
288
+ function looksLikeSqlite(path: string): boolean {
289
+ let fd: number;
290
+ try {
291
+ fd = openSync(path, 'r');
292
+ } catch {
293
+ return false;
294
+ }
295
+ try {
296
+ const buf = Buffer.alloc(16);
297
+ readSync(fd, buf, 0, 16, 0);
298
+ // Magic header is "SQLite format 3" (15 bytes) + a NUL terminator.
299
+ return buf.subarray(0, 15).toString('utf-8') === 'SQLite format 3' && buf[15] === 0;
300
+ } finally {
301
+ closeSync(fd);
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Recompute every module's source_path to THIS box's modules dir, on the
307
+ * STAGED DB file (pre-swap). The artifact carries the SOURCE box's absolute
308
+ * source_path (e.g. macOS `~/Library/Application Support/celilo/modules/<id>`),
309
+ * which doesn't exist on a different box/OS — so a restored DB references module
310
+ * code at a dead path and no deploy can generate. By construction (import.ts)
311
+ * source_path is always `${dataDir}/modules/<id>`, so it's safe to recompute for
312
+ * the target; a no-op when the paths already match (same-OS restore).
313
+ *
314
+ * Done on the staged file (a fresh connection that's about to be swapped in),
315
+ * never the live DB, so there's no post-swap in-process reopen (ISS-0037). The
316
+ * PRAGMA forces commits into the main file (no -wal sibling) so the copy below
317
+ * captures the rewrite; the live DB re-enters WAL when celilo reopens it.
318
+ */
319
+ function rewriteStagedModuleSourcePaths(stagedDbPath: string): void {
320
+ // Tolerate non-SQLite staged files: some callers/tests stage placeholder
321
+ // bytes to exercise the file-copy path. A real artifact DB always opens
322
+ // with the "SQLite format 3\0" magic header — skip anything that doesn't
323
+ // (a valid-header-but-corrupt DB still surfaces by throwing below).
324
+ if (!looksLikeSqlite(stagedDbPath)) return;
325
+
326
+ const moduleStorageBase = getModuleStoragePath();
327
+ const staged = new Database(stagedDbPath);
328
+ try {
329
+ staged.query('PRAGMA journal_mode = DELETE').run();
330
+ const rows = staged.query('SELECT id FROM modules').all() as Array<{ id: string }>;
331
+ const update = staged.query('UPDATE modules SET source_path = ? WHERE id = ?');
332
+ for (const { id } of rows) {
333
+ update.run(join(moduleStorageBase, id), id);
334
+ }
335
+ } finally {
336
+ staged.close();
337
+ }
275
338
  }
276
339
 
277
340
  /**
@@ -291,14 +354,16 @@ export function applyStagedSystemFiles(systemStagingDir: string): StagedSystemAp
291
354
  let dbApplied = false;
292
355
  let keyApplied = false;
293
356
  let sshApplied = false;
357
+ let moduleSourcesApplied = 0;
294
358
 
295
359
  if (!existsSync(systemStagingDir)) {
296
- return { dbApplied, keyApplied, sshApplied };
360
+ return { dbApplied, keyApplied, sshApplied, moduleSourcesApplied };
297
361
  }
298
362
 
299
363
  const stagedDb = join(systemStagingDir, 'celilo.db');
300
364
  const stagedKey = join(systemStagingDir, 'master.key');
301
365
  const stagedSsh = join(systemStagingDir, 'ssh');
366
+ const stagedModuleSrc = join(systemStagingDir, 'module_src');
302
367
 
303
368
  // master.key first: it's not load-bearing for the running process
304
369
  // (already in memory if the daemon's running). Safe to swap before
@@ -329,9 +394,31 @@ export function applyStagedSystemFiles(systemStagingDir: string): StagedSystemAp
329
394
  sshApplied = true;
330
395
  }
331
396
 
397
+ // Module SOURCE code → lay down at THIS box's modules dir so the restored
398
+ // box has the code for EVERY module (incl. non-registry ones like lunacycle).
399
+ // on_backup captured each module's source; without this the restored DB
400
+ // references modules whose code isn't on disk and no deploy can generate.
401
+ // The artifact carries no generated/ subtree, so any existing generated/
402
+ // under a module dir (e.g. restored TF state) is preserved by the merge.
403
+ if (existsSync(stagedModuleSrc)) {
404
+ const moduleStorageBase = getModuleStoragePath();
405
+ mkdirSync(moduleStorageBase, { recursive: true });
406
+ for (const entry of readdirSync(stagedModuleSrc, { withFileTypes: true })) {
407
+ if (!entry.isDirectory()) continue;
408
+ cpSync(join(stagedModuleSrc, entry.name), join(moduleStorageBase, entry.name), {
409
+ recursive: true,
410
+ });
411
+ moduleSourcesApplied += 1;
412
+ }
413
+ }
414
+
332
415
  // celilo.db: must close the live DB connection before replacing
333
416
  // the file or SQLite gets confused (macOS holds a vnode handle).
334
417
  if (existsSync(stagedDb)) {
418
+ // Reconcile module source_paths to THIS box on the staged file BEFORE the
419
+ // swap (and before closeDb) — fixes cross-host / cross-OS restores. See
420
+ // rewriteStagedModuleSourcePaths.
421
+ rewriteStagedModuleSourcePaths(stagedDb);
335
422
  closeDb();
336
423
  const livePath = getDbPath();
337
424
  mkdirSync(join(livePath, '..'), { recursive: true });
@@ -348,7 +435,7 @@ export function applyStagedSystemFiles(systemStagingDir: string): StagedSystemAp
348
435
  dbApplied = true;
349
436
  }
350
437
 
351
- return { dbApplied, keyApplied, sshApplied };
438
+ return { dbApplied, keyApplied, sshApplied, moduleSourcesApplied };
352
439
  }
353
440
 
354
441
  /**