@celilo/cli 1.13.0 → 1.14.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.
Files changed (34) hide show
  1. package/CELILO_CORE_MODULES.md +1 -1
  2. package/CELILO_SUBSYSTEMS.md +6 -2
  3. package/package.json +2 -2
  4. package/src/capabilities/public-web-helpers.test.ts +12 -6
  5. package/src/capabilities/public-web-publish.test.ts +24 -13
  6. package/src/capabilities/validation.test.ts +31 -0
  7. package/src/cli/commands/console-get-chain.test.ts +96 -0
  8. package/src/cli/commands/console.ts +13 -5
  9. package/src/cli/commands/notify-config.test.ts +79 -0
  10. package/src/cli/commands/notify-config.ts +13 -2
  11. package/src/cli/commands/system-ensure-fleet-key.ts +52 -0
  12. package/src/cli/completion.ts +1 -0
  13. package/src/cli/index.ts +6 -0
  14. package/src/console/closure.test.ts +76 -0
  15. package/src/console/closure.ts +87 -1
  16. package/src/console/projection.test.ts +63 -1
  17. package/src/console/projection.ts +39 -2
  18. package/src/hooks/capability-loader-control-plane-api.test.ts +124 -0
  19. package/src/hooks/capability-loader.ts +67 -10
  20. package/src/manifest/contracts/v1.ts +22 -1
  21. package/src/manifest/validate.ts +25 -4
  22. package/src/module/web-root.ts +35 -0
  23. package/src/policy/module-business-baseline.ts +14 -2
  24. package/src/policy/module-script-scan.test.ts +22 -0
  25. package/src/policy/module-script-scan.ts +32 -0
  26. package/src/services/api-principal-enrolment.test.ts +73 -0
  27. package/src/services/api-principal-enrolment.ts +55 -0
  28. package/src/services/backup-create.ts +33 -7
  29. package/src/services/celilo-mgmt-hooks.test.ts +38 -79
  30. package/src/services/fleet-key.test.ts +47 -0
  31. package/src/services/fleet-key.ts +75 -0
  32. package/src/services/restore-from-file.ts +6 -5
  33. package/src/services/system-state-stage.test.ts +165 -0
  34. package/src/services/system-state-stage.ts +196 -0
@@ -0,0 +1,47 @@
1
+ /**
2
+ * celilo's fleet SSH keypair, minted by the framework rather than by
3
+ * celilo-mgmt's on_install (design D9b of
4
+ * openspec/changes/hook-process-boundary).
5
+ */
6
+
7
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
8
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+ import { ensureFleetKey, getFleetSshDir } from './fleet-key';
12
+
13
+ describe('ensureFleetKey', () => {
14
+ let dataDir: string;
15
+
16
+ beforeEach(() => {
17
+ dataDir = mkdtempSync(join(tmpdir(), 'celilo-fleet-key-'));
18
+ process.env.CELILO_DB_PATH = join(dataDir, 'celilo.db');
19
+ });
20
+
21
+ afterEach(() => {
22
+ process.env.CELILO_DB_PATH = undefined;
23
+ rmSync(dataDir, { recursive: true, force: true });
24
+ });
25
+
26
+ it('mints an ed25519 keypair next to the DB and returns the public half', () => {
27
+ const key = ensureFleetKey();
28
+
29
+ expect(key.created).toBe(true);
30
+ expect(key.publicKey.startsWith('ssh-ed25519 ')).toBe(true);
31
+ expect(existsSync(join(dataDir, '.ssh', 'id_ed25519'))).toBe(true);
32
+ expect(readFileSync(join(dataDir, '.ssh', 'id_ed25519.pub'), 'utf-8').trim()).toBe(
33
+ key.publicKey,
34
+ );
35
+ expect(getFleetSshDir()).toBe(join(dataDir, '.ssh'));
36
+ });
37
+
38
+ it('reuses an existing key instead of re-keying', () => {
39
+ const first = ensureFleetKey();
40
+ const second = ensureFleetKey();
41
+
42
+ // Re-keying would strand every machine whose authorized_keys holds the
43
+ // old public half, and a redeploy calls this every time.
44
+ expect(second.created).toBe(false);
45
+ expect(second.publicKey).toBe(first.publicKey);
46
+ });
47
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * celilo's fleet SSH keypair — the key celilo authenticates to managed
3
+ * machines with. The DB carries only the public half (`ssh.public_key`);
4
+ * the private half lives on disk and never leaves the management box.
5
+ *
6
+ * It used to be minted by celilo-mgmt's `on_install`, which derived
7
+ * `dirname(config.db_path)` and wrote into celilo's data directory from
8
+ * inside a module hook. That is a write into the one directory the hook
9
+ * jail exists to keep out of the mount set
10
+ * (openspec/changes/hook-process-boundary, design D9b): staging covers
11
+ * copies OUT of celilo's state, and nothing covers writes IN.
12
+ *
13
+ * So minting moved here. celilo owns the key's lifecycle, and "create it
14
+ * if absent" wants to be idempotent and tested once rather than in each
15
+ * module that reaches for it.
16
+ */
17
+
18
+ import { execFileSync } from 'node:child_process';
19
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
20
+ import { dirname, join } from 'node:path';
21
+ import { getDbPath } from '../config/paths';
22
+
23
+ /**
24
+ * Where the fleet keypair lives.
25
+ *
26
+ * Next to the DB, not under `getDataDir()`. Those are the same directory
27
+ * on a deb install (`CELILO_DATA_DIR=/var/celilo`, db at
28
+ * `/var/celilo/celilo.db`) and differ only when `CELILO_DB_PATH` points
29
+ * somewhere custom. Following the DB is what `on_install` did, so it is
30
+ * where every existing box's key already sits, and restore has followed
31
+ * the same rule since it was written (`applyStagedSystemFiles`).
32
+ *
33
+ * One exported helper rather than two hand-derived joins is the point:
34
+ * mint and restore can no longer drift to different directories.
35
+ */
36
+ export function getFleetSshDir(): string {
37
+ return join(dirname(getDbPath()), '.ssh');
38
+ }
39
+
40
+ export interface FleetKey {
41
+ /** The public half, as it goes into `ssh.public_key` and authorized_keys. */
42
+ publicKey: string;
43
+ /** True when this call minted the key; false when it was already there. */
44
+ created: boolean;
45
+ }
46
+
47
+ /**
48
+ * Ensure the fleet keypair exists and return its public half.
49
+ *
50
+ * Idempotent: an existing key is reused, never regenerated. Re-keying
51
+ * would silently strand every machine whose authorized_keys holds the old
52
+ * public half, and a redeploy must not do that.
53
+ *
54
+ * Permissions are left to `mkdirSync`'s mode and to ssh-keygen, which
55
+ * `fchmod`s the private half to 0600 itself. An explicit chmod pass was
56
+ * written here first and then removed: umask only ever REMOVES mode bits, so
57
+ * neither the directory nor the key can come out wider than asked for, and no
58
+ * test could be made to fail without it.
59
+ */
60
+ export function ensureFleetKey(): FleetKey {
61
+ const sshDir = getFleetSshDir();
62
+ const keyPath = join(sshDir, 'id_ed25519');
63
+ const publicKeyPath = `${keyPath}.pub`;
64
+
65
+ if (existsSync(publicKeyPath)) {
66
+ return { publicKey: readFileSync(publicKeyPath, 'utf-8').trim(), created: false };
67
+ }
68
+
69
+ mkdirSync(sshDir, { recursive: true, mode: 0o700 });
70
+ execFileSync('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', keyPath, '-C', 'celilo-fleet'], {
71
+ stdio: 'pipe',
72
+ });
73
+
74
+ return { publicKey: readFileSync(publicKeyPath, 'utf-8').trim(), created: true };
75
+ }
@@ -32,7 +32,7 @@ import {
32
32
  rmSync,
33
33
  } from 'node:fs';
34
34
  import { tmpdir } from 'node:os';
35
- import { dirname, join } from 'node:path';
35
+ import { join } from 'node:path';
36
36
  import { eq } from 'drizzle-orm';
37
37
  import { getDbPath, getMasterKeyPath, getModuleStoragePath } from '../config/paths';
38
38
  import { closeDb, getDb } from '../db/client';
@@ -47,6 +47,7 @@ import { decryptFileToFile } from './backup-cipher';
47
47
  import { assertCompatibleSchema, parseManifest } from './backup-manifest';
48
48
  import { applyCrossModuleWriteRoot, moduleHasCrossModuleRead } from './cross-module-read';
49
49
  import { getModuleSystems } from './deployed-systems';
50
+ import { getFleetSshDir } from './fleet-key';
50
51
  import {
51
52
  completeOperation,
52
53
  failOperation,
@@ -376,10 +377,10 @@ export function applyStagedSystemFiles(systemStagingDir: string): StagedSystemAp
376
377
  // managed machines; the DB only carries the public half, so without the
377
378
  // private half on disk the restored box can't authenticate to the fleet.
378
379
  if (existsSync(stagedSsh)) {
379
- // The fleet key lives next to the DB (on_install writes it to
380
- // dirname(db_path)/.ssh), so follow the DB's location — not getDataDir()
381
- // — in case CELILO_DB_PATH points somewhere custom.
382
- const liveSshDir = join(dirname(getDbPath()), '.ssh');
380
+ // One helper, shared with the minting side, so restore and mint cannot
381
+ // drift to different directories. See getFleetSshDir for why it follows
382
+ // the DB rather than getDataDir().
383
+ const liveSshDir = getFleetSshDir();
383
384
  mkdirSync(liveSshDir, { recursive: true, mode: 0o700 });
384
385
  chmodSync(liveSshDir, 0o700);
385
386
  for (const entry of readdirSync(stagedSsh)) {
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Staging celilo's own state for a backup hook (design D9b of
3
+ * openspec/changes/hook-process-boundary).
4
+ *
5
+ * These assertions used to live in celilo-mgmt-hooks.test.ts, because the
6
+ * work used to live in celilo-mgmt's on_backup. They moved here with the
7
+ * code: the hook no longer reads celilo's data directory, the framework
8
+ * copies it into a staged directory first.
9
+ */
10
+
11
+ import { Database } from 'bun:sqlite';
12
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
13
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import { snapshotDatabase, stageSystemState } from './system-state-stage';
17
+
18
+ describe('stageSystemState', () => {
19
+ let dataDir: string;
20
+ let stageRoot: string;
21
+ let masterKeyPath: string;
22
+
23
+ beforeEach(() => {
24
+ dataDir = mkdtempSync(join(tmpdir(), 'celilo-system-state-'));
25
+ stageRoot = join(dataDir, 'staged');
26
+ process.env.CELILO_DATA_DIR = dataDir;
27
+ process.env.CELILO_DB_PATH = join(dataDir, 'celilo.db');
28
+
29
+ const seed = new Database(join(dataDir, 'celilo.db'));
30
+ seed.run('CREATE TABLE probe (id INTEGER PRIMARY KEY, v TEXT)');
31
+ seed.run("INSERT INTO probe (v) VALUES ('hello')");
32
+ seed.close();
33
+
34
+ masterKeyPath = join(dataDir, 'master.key');
35
+ writeFileSync(masterKeyPath, 'fake-master-key-32-bytes-padding!');
36
+ process.env.CELILO_MASTER_KEY_PATH = masterKeyPath;
37
+ });
38
+
39
+ afterEach(() => {
40
+ process.env.CELILO_DATA_DIR = undefined;
41
+ process.env.CELILO_DB_PATH = undefined;
42
+ process.env.CELILO_MASTER_KEY_PATH = undefined;
43
+ rmSync(dataDir, { recursive: true, force: true });
44
+ });
45
+
46
+ it('stages the DB snapshot and master.key into the root it is given', () => {
47
+ const staged = stageSystemState(stageRoot);
48
+
49
+ expect(existsSync(join(stageRoot, 'celilo.db'))).toBe(true);
50
+ expect(existsSync(join(stageRoot, 'master.key'))).toBe(true);
51
+ expect(staged.masterKeyStaged).toBe(true);
52
+ expect(staged.root).toBe(stageRoot);
53
+ });
54
+
55
+ it('reports a missing master.key rather than throwing', () => {
56
+ rmSync(masterKeyPath);
57
+
58
+ const staged = stageSystemState(stageRoot);
59
+
60
+ expect(staged.masterKeyStaged).toBe(false);
61
+ expect(existsSync(join(stageRoot, 'master.key'))).toBe(false);
62
+ // The DB still travels: a snapshot without the key is degraded, not
63
+ // useless, and refusing here would block backups on a box whose key
64
+ // path is overridden.
65
+ expect(existsSync(join(stageRoot, 'celilo.db'))).toBe(true);
66
+ });
67
+
68
+ it('stages the fleet keypair, private half included', () => {
69
+ const sshDir = join(dataDir, '.ssh');
70
+ mkdirSync(sshDir, { recursive: true });
71
+ writeFileSync(join(sshDir, 'id_ed25519'), 'PRIVATE');
72
+ writeFileSync(join(sshDir, 'id_ed25519.pub'), 'ssh-ed25519 AAAA celilo-fleet');
73
+
74
+ const staged = stageSystemState(stageRoot);
75
+
76
+ expect(staged.fleetSshStaged).toBe(true);
77
+ // The DB carries only the public half. Without the private half on disk a
78
+ // restored box cannot reach machines that already trust the key.
79
+ expect(readFileSync(join(stageRoot, 'ssh', 'id_ed25519'), 'utf-8')).toBe('PRIVATE');
80
+ });
81
+
82
+ it('reports an absent fleet keypair rather than staging an empty dir', () => {
83
+ const staged = stageSystemState(stageRoot);
84
+
85
+ expect(staged.fleetSshStaged).toBe(false);
86
+ expect(existsSync(join(stageRoot, 'ssh'))).toBe(false);
87
+ });
88
+
89
+ it('captures LEAN module source: no generated/, no node_modules/, no oversized files', () => {
90
+ const modSrc = join(dataDir, 'modules', 'caddy');
91
+ mkdirSync(join(modSrc, 'scripts', 'node_modules', '@celilo'), { recursive: true });
92
+ mkdirSync(join(modSrc, 'generated', 'terraform'), { recursive: true });
93
+ mkdirSync(join(modSrc, 'ansible', 'files'), { recursive: true });
94
+ writeFileSync(join(modSrc, 'manifest.yml'), 'id: caddy');
95
+ writeFileSync(join(modSrc, 'scripts', 'hook.ts'), '// hook');
96
+ writeFileSync(join(modSrc, 'generated', 'terraform', 'main.tf'), 'resource {}');
97
+ writeFileSync(join(modSrc, 'scripts', 'node_modules', '@celilo', 'dep.js'), '// vendored');
98
+ // A >2MB "compiled binary" sitting in source — skipped by size, because
99
+ // excluding by directory name misses the ones outside a known build dir.
100
+ writeFileSync(join(modSrc, 'ansible', 'files', 'server-bin'), Buffer.alloc(3 * 1024 * 1024));
101
+
102
+ const staged = stageSystemState(stageRoot);
103
+ const at = (...parts: string[]) => join(stageRoot, 'module_src', 'caddy', ...parts);
104
+
105
+ expect(staged.moduleSourceCount).toBe(1);
106
+ expect(existsSync(at('manifest.yml'))).toBe(true);
107
+ expect(existsSync(at('scripts', 'hook.ts'))).toBe(true);
108
+ expect(existsSync(at('generated'))).toBe(false);
109
+ expect(existsSync(at('scripts', 'node_modules'))).toBe(false);
110
+ expect(existsSync(at('ansible', 'files', 'server-bin'))).toBe(false);
111
+ });
112
+
113
+ it('names every file the size cap dropped', () => {
114
+ const modSrc = join(dataDir, 'modules', 'caddy');
115
+ mkdirSync(join(modSrc, 'ansible'), { recursive: true });
116
+ writeFileSync(join(modSrc, 'ansible', 'server-bin'), Buffer.alloc(3 * 1024 * 1024));
117
+
118
+ const staged = stageSystemState(stageRoot);
119
+
120
+ // No silent caps: a backup that quietly dropped a file reads as complete.
121
+ expect(staged.skippedLarge).toHaveLength(1);
122
+ expect(staged.skippedLarge[0]).toContain('caddy/ansible/server-bin');
123
+ expect(staged.skippedLarge[0]).toContain('3.0MB');
124
+ });
125
+ });
126
+
127
+ describe('snapshotDatabase', () => {
128
+ let dir: string;
129
+
130
+ beforeEach(() => {
131
+ dir = mkdtempSync(join(tmpdir(), 'celilo-db-snapshot-'));
132
+ });
133
+
134
+ afterEach(() => {
135
+ rmSync(dir, { recursive: true, force: true });
136
+ });
137
+
138
+ it('captures rows still sitting in the WAL, uncheckpointed', () => {
139
+ // The defect this guards: celilo runs the DB in WAL mode, so committed
140
+ // rows live in celilo.db-wal until a checkpoint folds them into the main
141
+ // file. A plain copy of the main file produces a snapshot that opens
142
+ // cleanly and contains NOTHING, and restore then installs it. Swap
143
+ // serialize() for copyFileSync and this test is the thing that notices.
144
+ const src = join(dir, 'celilo.db');
145
+ const live = new Database(src);
146
+ live.run('PRAGMA journal_mode = WAL');
147
+ live.run('CREATE TABLE probe (id INTEGER PRIMARY KEY, v TEXT)');
148
+ for (let i = 0; i < 200; i++) {
149
+ live.run('INSERT INTO probe (v) VALUES (?)', [`row-${i}`]);
150
+ }
151
+
152
+ const dest = join(dir, 'snapshot.db');
153
+ snapshotDatabase(src, dest);
154
+ live.close();
155
+
156
+ // Opened read-write, not readonly: the serialized bytes carry WAL journal
157
+ // mode in their header, so the FIRST open has to be able to create the
158
+ // -wal/-shm sidecars. Restore opens it read-write too (it copies the file
159
+ // into place and runs migrations), so this is the real consumer's path.
160
+ const restored = new Database(dest);
161
+ const row = restored.query('SELECT COUNT(*) AS n FROM probe').get() as { n: number };
162
+ restored.close();
163
+ expect(row.n).toBe(200);
164
+ });
165
+ });
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Staging of celilo's own state for a backup hook
3
+ * (openspec/changes/hook-process-boundary, design D9b).
4
+ *
5
+ * celilo-mgmt's `on_backup` used to reach into celilo's data directory and
6
+ * copy `master.key`, the DB, the fleet `.ssh` and every other module's
7
+ * source tree out of it. Measured across the whole hook, it read none of
8
+ * those bytes: every one was a `copyFileSync` / `cpSync` into `backup_dir`.
9
+ * It does not need those files in its filesystem view. It needs them to end
10
+ * up in the backup.
11
+ *
12
+ * So the framework copies them into a directory it creates and hands over,
13
+ * exactly as `materializeCrossModuleRoot` already does for
14
+ * `cross_module_read`. The hook reads from a staged location it was given,
15
+ * celilo-mgmt is fully jailed, and "the jail applies to every module" stays
16
+ * true with no exemption to audit.
17
+ *
18
+ * The layout mirrors what `on_backup` puts in the envelope, so the hook's
19
+ * remaining job is a copy:
20
+ *
21
+ * <root>/celilo.db WAL-correct snapshot
22
+ * <root>/master.key if present
23
+ * <root>/ssh/ fleet keypair, if present
24
+ * <root>/module_src/<id>/ each module's lean source
25
+ */
26
+
27
+ import { Database } from 'bun:sqlite';
28
+ import {
29
+ copyFileSync,
30
+ cpSync,
31
+ existsSync,
32
+ mkdirSync,
33
+ readdirSync,
34
+ statSync,
35
+ writeFileSync,
36
+ } from 'node:fs';
37
+ import { join } from 'node:path';
38
+ import { getDbPath, getMasterKeyPath, getModuleStoragePath } from '../config/paths';
39
+ import { getFleetSshDir } from './fleet-key';
40
+
41
+ /**
42
+ * Consistent SQLite snapshot via bun:sqlite's serialize().
43
+ *
44
+ * celilo runs the DB in WAL mode (apps/celilo/src/db/client.ts), so committed
45
+ * rows live in `celilo.db-wal` until a checkpoint folds them into the main
46
+ * file. The main file is routinely a single near-empty page while ALL the
47
+ * real data (20+ tables, modules, config, secrets) sits in the WAL. A readonly
48
+ * connection reads THROUGH the WAL, so serialize() captures the full committed
49
+ * state into one standalone file — exactly what restore needs.
50
+ *
51
+ * An earlier implementation shelled out to `sqlite3 ".backup"` and fell back
52
+ * to a plain copyFileSync when the CLI was absent. On a deb-installed box
53
+ * there IS no sqlite3 CLI, so the fallback ran — and a plain copy of the main
54
+ * file alone DROPS the WAL, producing a silently EMPTY backup (restore then
55
+ * installs an empty DB). bun:sqlite is a Bun built-in and reads the WAL
56
+ * correctly — no CLI dependency, no data loss.
57
+ *
58
+ * This lives in the framework rather than in a module because celilo owns the
59
+ * schema and `getDbPath()`, and because a bug that silently empties backups
60
+ * should be fixed once, where it is tested.
61
+ */
62
+ export function snapshotDatabase(srcPath: string, destPath: string): void {
63
+ const db = new Database(srcPath, { readonly: true });
64
+ try {
65
+ writeFileSync(destPath, db.serialize());
66
+ } finally {
67
+ db.close();
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Directories never worth capturing from a module's source tree. Every one
73
+ * is rebuilt on deploy (`module build` / `generate`) or re-vendored on
74
+ * restore (`installScriptDependencies`).
75
+ */
76
+ const EXCLUDE_DIRS = new Set([
77
+ 'node_modules',
78
+ 'generated',
79
+ 'dist',
80
+ 'coverage',
81
+ 'coverage-raw',
82
+ '.git',
83
+ 'screenshots',
84
+ 'e2e',
85
+ ]);
86
+
87
+ /**
88
+ * Size ceiling for a single captured source file.
89
+ *
90
+ * celilo module dirs bundle large BUILD artifacts (compiled binaries, built
91
+ * assets, `*.netapp` packages) — turnip's were ~1.6 GB, which made the
92
+ * in-memory tar+encrypt segfault. Excluding by directory name misses the ones
93
+ * that sit outside a known build dir, so a size cap catches them generically.
94
+ */
95
+ const MAX_SRC_FILE_BYTES = 2 * 1024 * 1024;
96
+
97
+ export interface StagedSystemState {
98
+ /** The directory the caller passes to the hook. */
99
+ root: string;
100
+ masterKeyStaged: boolean;
101
+ fleetSshStaged: boolean;
102
+ moduleSourceCount: number;
103
+ /** Files the size cap or the `.netapp` rule dropped, named. No silent caps. */
104
+ skippedLarge: string[];
105
+ }
106
+
107
+ /**
108
+ * Populate `rootDir` with celilo's own state and return what landed there.
109
+ *
110
+ * Absent pieces are reported rather than thrown on: a box with no fleet key
111
+ * yet is an ordinary pre-deploy state, and a missing `master.key` is a fact
112
+ * the caller surfaces to the operator (secrets in the snapshot would be
113
+ * unreadable on restore) rather than a reason to abort the backup.
114
+ */
115
+ export function stageSystemState(rootDir: string): StagedSystemState {
116
+ mkdirSync(rootDir, { recursive: true });
117
+
118
+ snapshotDatabase(getDbPath(), join(rootDir, 'celilo.db'));
119
+
120
+ // `getMasterKeyPath()` honours CELILO_MASTER_KEY_PATH and otherwise sits
121
+ // under getDataDir(). The hook used to re-derive it as
122
+ // `dirname(db_path)/master.key`, which is the same file on a deb install
123
+ // and a different one whenever CELILO_DB_PATH points elsewhere.
124
+ const masterKeyPath = getMasterKeyPath();
125
+ const masterKeyStaged = existsSync(masterKeyPath);
126
+ if (masterKeyStaged) {
127
+ copyFileSync(masterKeyPath, join(rootDir, 'master.key'));
128
+ }
129
+
130
+ // The private half too: the DB carries only `ssh.public_key`, so without
131
+ // it a restored box cannot reach the fleet, and re-keying means
132
+ // re-authorizing every managed machine.
133
+ const fleetSshDir = getFleetSshDir();
134
+ const fleetSshStaged = existsSync(join(fleetSshDir, 'id_ed25519'));
135
+ if (fleetSshStaged) {
136
+ cpSync(fleetSshDir, join(rootDir, 'ssh'), { recursive: true });
137
+ }
138
+
139
+ const { moduleSourceCount, skippedLarge } = stageModuleSources(join(rootDir, 'module_src'));
140
+
141
+ return { root: rootDir, masterKeyStaged, fleetSshStaged, moduleSourceCount, skippedLarge };
142
+ }
143
+
144
+ /**
145
+ * Capture each module's SOURCE (manifest, scripts, ansible, templates) into
146
+ * `destDir`, minus build artifacts.
147
+ *
148
+ * The DB references every module by `source_path`, but the rest of the
149
+ * envelope carries only DB + state, not module CODE. Restoring onto a fresh
150
+ * box — especially a different OS, where the source box's absolute
151
+ * `source_path` does not exist — would otherwise leave it unable to deploy
152
+ * ANY module, including non-registry ones (e.g. lunacycle) that a
153
+ * re-import-from-registry cannot recover.
154
+ *
155
+ * Reads `getModuleStoragePath()`, which is where `applyStagedSystemFiles`
156
+ * lays the source back down on restore. The hook derived
157
+ * `dirname(db_path)/modules` instead — the same directory on a deb install,
158
+ * and a different one otherwise, so backup and restore could disagree about
159
+ * where module source lives.
160
+ */
161
+ function stageModuleSources(destDir: string): {
162
+ moduleSourceCount: number;
163
+ skippedLarge: string[];
164
+ } {
165
+ const modulesSrcDir = getModuleStoragePath();
166
+ const skippedLarge: string[] = [];
167
+ let moduleSourceCount = 0;
168
+
169
+ if (!existsSync(modulesSrcDir)) {
170
+ return { moduleSourceCount, skippedLarge };
171
+ }
172
+
173
+ for (const entry of readdirSync(modulesSrcDir, { withFileTypes: true })) {
174
+ if (!entry.isDirectory()) continue;
175
+ const srcModuleDir = join(modulesSrcDir, entry.name);
176
+ cpSync(srcModuleDir, join(destDir, entry.name), {
177
+ recursive: true,
178
+ filter: (src: string) => {
179
+ const rel = src.slice(srcModuleDir.length).replace(/^\//, '');
180
+ if (rel === '') return true; // module root
181
+ if (rel.split('/').some((seg) => EXCLUDE_DIRS.has(seg))) return false;
182
+ const st = statSync(src);
183
+ if (st.isDirectory()) return true;
184
+ if (src.endsWith('.netapp')) return false;
185
+ if (st.size > MAX_SRC_FILE_BYTES) {
186
+ skippedLarge.push(`${entry.name}/${rel} (${(st.size / 1048576).toFixed(1)}MB)`);
187
+ return false;
188
+ }
189
+ return true;
190
+ },
191
+ });
192
+ moduleSourceCount += 1;
193
+ }
194
+
195
+ return { moduleSourceCount, skippedLarge };
196
+ }