@celilo/cli 0.3.30-alpha.0 → 0.4.0-alpha.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 (155) hide show
  1. package/drizzle/0005_module_operations.sql +12 -0
  2. package/drizzle/0006_base_module_aspects.sql +15 -0
  3. package/drizzle/0007_module_systems.sql +17 -0
  4. package/drizzle/meta/_journal.json +21 -0
  5. package/package.json +3 -3
  6. package/schemas/system_config.json +14 -28
  7. package/src/ansible/inventory.test.ts +46 -62
  8. package/src/ansible/inventory.ts +48 -25
  9. package/src/capabilities/registration.ts +25 -7
  10. package/src/capabilities/validation.test.ts +30 -0
  11. package/src/capabilities/validation.ts +8 -0
  12. package/src/cli/backup-rename.test.ts +95 -0
  13. package/src/cli/cli.test.ts +17 -23
  14. package/src/cli/command-registry.ts +199 -0
  15. package/src/cli/commands/backup-list.ts +1 -1
  16. package/src/cli/commands/events.ts +96 -0
  17. package/src/cli/commands/machine-add.ts +103 -59
  18. package/src/cli/commands/module-import.ts +153 -4
  19. package/src/cli/commands/module-remove.ts +86 -17
  20. package/src/cli/commands/module-status.ts +6 -2
  21. package/src/cli/commands/publish/alpha.test.ts +185 -0
  22. package/src/cli/commands/publish/alpha.ts +226 -0
  23. package/src/cli/commands/publish/changesets.test.ts +89 -0
  24. package/src/cli/commands/publish/changesets.ts +144 -0
  25. package/src/cli/commands/publish/consumer-pins.test.ts +155 -0
  26. package/src/cli/commands/publish/consumer-pins.ts +149 -0
  27. package/src/cli/commands/publish/execute.ts +131 -0
  28. package/src/cli/commands/publish/global-install.test.ts +154 -0
  29. package/src/cli/commands/publish/global-install.ts +171 -0
  30. package/src/cli/commands/publish/helpers.ts +227 -0
  31. package/src/cli/commands/publish/index.ts +365 -0
  32. package/src/cli/commands/publish/module-registry.test.ts +40 -0
  33. package/src/cli/commands/publish/module-registry.ts +64 -0
  34. package/src/cli/commands/publish/plan.ts +107 -0
  35. package/src/cli/commands/publish/preflight.ts +238 -0
  36. package/src/cli/commands/publish/types.ts +264 -0
  37. package/src/cli/commands/publish/workspace.test.ts +323 -0
  38. package/src/cli/commands/publish/workspace.ts +596 -0
  39. package/src/cli/commands/restore.ts +126 -0
  40. package/src/cli/commands/storage-add-local.ts +1 -1
  41. package/src/cli/commands/storage-add-s3.ts +1 -1
  42. package/src/cli/commands/subscribers-add.ts +68 -0
  43. package/src/cli/commands/subscribers-list.ts +48 -0
  44. package/src/cli/commands/subscribers-remove.ts +38 -0
  45. package/src/cli/commands/subscribers-serve.ts +77 -0
  46. package/src/cli/commands/subscribers-status.ts +33 -0
  47. package/src/cli/commands/subscribers-test.ts +71 -0
  48. package/src/cli/commands/system-apply-config-equivalence.test.ts +108 -0
  49. package/src/cli/commands/system-apply-config.test.ts +70 -0
  50. package/src/cli/commands/system-apply-config.ts +130 -0
  51. package/src/cli/commands/system-audit.ts +2 -1
  52. package/src/cli/commands/system-init-deprecation.test.ts +90 -0
  53. package/src/cli/commands/system-init.ts +36 -70
  54. package/src/cli/commands/system-update.ts +3 -2
  55. package/src/cli/completion.ts +22 -1
  56. package/src/cli/index.ts +214 -6
  57. package/src/cli/interactive-config.test.ts +19 -0
  58. package/src/cli/restore-command.test.ts +131 -0
  59. package/src/db/client.ts +42 -0
  60. package/src/db/schema.test.ts +13 -16
  61. package/src/db/schema.ts +161 -9
  62. package/src/hooks/capability-loader-firewall.test.ts +6 -15
  63. package/src/hooks/capability-loader.test.ts +2 -3
  64. package/src/hooks/capability-loader.ts +36 -2
  65. package/src/hooks/define-hook.test.ts +4 -0
  66. package/src/hooks/executor.test.ts +18 -0
  67. package/src/hooks/executor.ts +21 -2
  68. package/src/hooks/load-hook-config.test.ts +26 -24
  69. package/src/hooks/load-hook-config.ts +11 -2
  70. package/src/hooks/run-named-hook.ts +16 -0
  71. package/src/hooks/types.ts +9 -1
  72. package/src/manifest/contracts/v1.ts +70 -0
  73. package/src/manifest/schema.ts +262 -16
  74. package/src/manifest/validate-privileged.test.ts +84 -0
  75. package/src/manifest/validate.test.ts +156 -0
  76. package/src/manifest/validate.ts +69 -0
  77. package/src/module/import.ts +12 -0
  78. package/src/services/aspect-approvals.test.ts +231 -0
  79. package/src/services/aspect-approvals.ts +120 -0
  80. package/src/services/aspect-runner.test.ts +493 -0
  81. package/src/services/aspect-runner.ts +438 -0
  82. package/src/services/aspect-template-resolver.test.ts +101 -0
  83. package/src/services/aspect-template-resolver.ts +122 -0
  84. package/src/services/backup-create.ts +104 -25
  85. package/src/services/backup-envelope-roundtrip.test.ts +199 -0
  86. package/src/services/backup-in-flight-refusal.test.ts +163 -0
  87. package/src/services/backup-manifest.test.ts +115 -0
  88. package/src/services/backup-manifest.ts +163 -0
  89. package/src/services/backup-restore.ts +154 -19
  90. package/src/services/build-bus/delivery-events.ts +92 -0
  91. package/src/services/build-bus/event-factory.ts +54 -0
  92. package/src/services/build-bus/fan-out.test.ts +279 -0
  93. package/src/services/build-bus/fan-out.ts +161 -0
  94. package/src/services/build-bus/hook-dispatch-mgmt.test.ts +157 -0
  95. package/src/services/build-bus/hook-dispatch.test.ts +207 -0
  96. package/src/services/build-bus/hook-dispatch.ts +198 -0
  97. package/src/services/build-bus/hook-dispatcher.ts +115 -0
  98. package/src/services/build-bus/index.ts +41 -0
  99. package/src/services/build-bus/receiver-server.test.ts +179 -0
  100. package/src/services/build-bus/receiver-server.ts +159 -0
  101. package/src/services/build-bus/status.test.ts +212 -0
  102. package/src/services/build-bus/status.ts +213 -0
  103. package/src/services/build-bus/subscriber-store.ts +113 -0
  104. package/src/services/celilo-events.test.ts +70 -0
  105. package/src/services/celilo-events.ts +92 -0
  106. package/src/services/celilo-mgmt-hooks.test.ts +296 -0
  107. package/src/services/config-interview.ts +13 -95
  108. package/src/services/cross-module-data-manager.ts +2 -31
  109. package/src/services/cross-module-read.test.ts +250 -0
  110. package/src/services/cross-module-read.ts +232 -0
  111. package/src/services/deploy-validation.ts +7 -0
  112. package/src/services/deployed-systems.test.ts +235 -0
  113. package/src/services/deployed-systems.ts +308 -0
  114. package/src/services/dns-provider-backfill.ts +75 -0
  115. package/src/services/health-runner.ts +19 -3
  116. package/src/services/infrastructure-variable-resolver.test.ts +6 -32
  117. package/src/services/infrastructure-variable-resolver.ts +3 -13
  118. package/src/services/machine-detector.ts +104 -48
  119. package/src/services/machine-pool.ts +145 -2
  120. package/src/services/module-config.ts +78 -120
  121. package/src/services/module-deploy.ts +113 -40
  122. package/src/services/module-operations.test.ts +154 -0
  123. package/src/services/module-operations.ts +154 -0
  124. package/src/services/module-subscriptions.test.ts +58 -0
  125. package/src/services/module-subscriptions.ts +24 -1
  126. package/src/services/module-types-generator.test.ts +3 -3
  127. package/src/services/module-types-generator.ts +7 -2
  128. package/src/services/proxmox-reconcile.test.ts +333 -0
  129. package/src/services/proxmox-reconcile.ts +156 -0
  130. package/src/services/proxmox-state-recovery.ts +3 -24
  131. package/src/services/restore-from-file.test.ts +177 -0
  132. package/src/services/restore-from-file.ts +355 -0
  133. package/src/services/restore-preflight.test.ts +127 -0
  134. package/src/services/restore-preflight.ts +118 -0
  135. package/src/services/storage-providers/s3.ts +10 -2
  136. package/src/services/system-identity.ts +30 -0
  137. package/src/services/system-init.test.ts +64 -21
  138. package/src/services/system-init.ts +28 -26
  139. package/src/templates/generator.test.ts +7 -16
  140. package/src/templates/generator.ts +28 -115
  141. package/src/test-utils/integration.ts +5 -2
  142. package/src/types/infrastructure.ts +8 -0
  143. package/src/variables/computed/computed-integration.test.ts +191 -0
  144. package/src/variables/computed/computed.test.ts +177 -0
  145. package/src/variables/computed/evaluate.ts +271 -0
  146. package/src/variables/computed/marker.ts +53 -0
  147. package/src/variables/computed/parse.ts +262 -0
  148. package/src/variables/computed/provider-lookup.ts +130 -0
  149. package/src/variables/context.test.ts +89 -28
  150. package/src/variables/context.ts +196 -191
  151. package/src/variables/parser.ts +3 -3
  152. package/src/variables/resolver.test.ts +61 -0
  153. package/src/variables/resolver.ts +81 -0
  154. package/src/variables/types.ts +23 -1
  155. package/src/services/dns-auto-register.ts +0 -211
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Proxmox reconciliation (v2/CELILO_BASE.md D5).
3
+ *
4
+ * When a base-module aspect declares `proxmox_reconcile.tfvars` and
5
+ * its fan-out plan touches Proxmox-provisioned LXCs, the running
6
+ * config (Ansible writes /etc/resolv.conf) is only half the story:
7
+ * the LXC's persisted Proxmox terraform config also needs the new
8
+ * value, otherwise a re-provision would revert to a stale resolver
9
+ * and break the fleet.
10
+ *
11
+ * SC5 SCOPE (this file):
12
+ * - Plan: pure function that takes the aspect + fan-out targets
13
+ * + resolution context, returns one action per affected LXC.
14
+ * - Execute: surfaces a clear WARN per action, documenting which
15
+ * tfvar would be updated on which owning-module's terraform
16
+ * state.
17
+ *
18
+ * DEFERRED to a follow-up (Phase 1c / Phase 2):
19
+ * - Actually running terraform apply per LXC owning-module. The
20
+ * value needs to flow through celilo's persistent state
21
+ * (system_config / module_config) AND be picked up by the
22
+ * owning module's terraform template on next regular deploy,
23
+ * not just injected as a one-off -var. Doing that right
24
+ * requires (a) a celilo system-config key with a clear
25
+ * semantic, (b) updates to every Proxmox-using module's
26
+ * terraform template to read from it, and (c) reconciliation
27
+ * against existing tfvars files so the next deploy doesn't
28
+ * overwrite.
29
+ *
30
+ * That's meaningful design work and the simulator's Phase 1
31
+ * e2e doesn't have a real Proxmox to validate against. The
32
+ * responsible move is to ship the SHAPE (manifest schema +
33
+ * planning + observable warn-on-skip), defer the persistence
34
+ * layer, and pick it up when production rollout actually
35
+ * touches Proxmox LXCs.
36
+ */
37
+
38
+ import { log } from '../cli/prompts';
39
+ import type { getDb } from '../db/client';
40
+ import type { BaseModuleAspect } from '../manifest/schema';
41
+ import { resolveAspectTemplateRecord } from './aspect-template-resolver';
42
+ import { type ContainerSystem, getContainerSystemsByZone } from './machine-pool';
43
+
44
+ type DbClient = ReturnType<typeof getDb>;
45
+
46
+ export interface ProxmoxReconcileAction {
47
+ /** The owning module of the affected LXC — its terraform state holds the var. */
48
+ moduleId: string;
49
+ /** The container_service this LXC was provisioned through. */
50
+ serviceId: string;
51
+ /** Resolved tfvar updates, mapping tfvar name → final string value. */
52
+ tfvarUpdates: Record<string, string>;
53
+ /** Source LXC for logging / observability. */
54
+ containerSystem: ContainerSystem;
55
+ }
56
+
57
+ export interface ProxmoxReconcilePlan {
58
+ actions: ProxmoxReconcileAction[];
59
+ /** Containers that matched the zones but were skipped (non-Proxmox provider). */
60
+ skipped: Array<{ system: ContainerSystem; reason: string }>;
61
+ }
62
+
63
+ /**
64
+ * Planning phase: figure out which Proxmox LXCs in the aspect's
65
+ * zone scope would need a terraform-config update. Pure aside from
66
+ * the DB reads (container_service rows + value resolution).
67
+ *
68
+ * The `providerModuleId` is the module whose aspect we're acting
69
+ * on (e.g., knot-unbound-internal). Its config + capability data
70
+ * is what tfvar value templates resolve against — the aspect knows
71
+ * its own service's IP, the resolution context just makes that
72
+ * available.
73
+ */
74
+ export async function planProxmoxReconcile(args: {
75
+ aspect: BaseModuleAspect;
76
+ providerModuleId: string;
77
+ db: DbClient;
78
+ }): Promise<ProxmoxReconcilePlan> {
79
+ const { aspect, providerModuleId, db } = args;
80
+ if (!aspect.proxmox_reconcile) {
81
+ return { actions: [], skipped: [] };
82
+ }
83
+
84
+ const systems = await getContainerSystemsByZone(aspect.applicable_zones);
85
+
86
+ const actions: ProxmoxReconcileAction[] = [];
87
+ const skipped: ProxmoxReconcilePlan['skipped'] = [];
88
+
89
+ for (const sys of systems) {
90
+ if (sys.providerName !== 'proxmox') {
91
+ // DO droplets / future providers don't ride the proxmox_lxc
92
+ // terraform path. The aspect's Ansible run still hit them;
93
+ // they just don't get a tfvar-overlay update from this
94
+ // mechanism.
95
+ skipped.push({ system: sys, reason: `non-proxmox provider (${sys.providerName})` });
96
+ continue;
97
+ }
98
+
99
+ // Resolve every tfvar template against the provider module's
100
+ // context. Any resolution failure surfaces as a thrown error
101
+ // up to the caller — aspect authors are expected to declare
102
+ // resolvable templates.
103
+ const tfvarUpdates = await resolveAspectTemplateRecord(
104
+ aspect.proxmox_reconcile.tfvars,
105
+ providerModuleId,
106
+ db,
107
+ 'proxmox_reconcile.tfvars',
108
+ );
109
+
110
+ actions.push({
111
+ moduleId: sys.moduleId,
112
+ serviceId: sys.serviceId,
113
+ tfvarUpdates,
114
+ containerSystem: sys,
115
+ });
116
+ }
117
+
118
+ return { actions, skipped };
119
+ }
120
+
121
+ /**
122
+ * Execution phase. **Currently surfaces operator warnings instead
123
+ * of running terraform** — see file header for why.
124
+ *
125
+ * The warning is structured so operators see exactly which
126
+ * Proxmox LXC has drifted from its desired tfvar state and can
127
+ * manually reconcile via:
128
+ *
129
+ * celilo module deploy <owning-module>
130
+ *
131
+ * (which re-runs that module's terraform with whatever the
132
+ * current celilo state says the tfvars should be).
133
+ *
134
+ * When the persistence layer lands, this function gains a real
135
+ * terraform-apply path; the planning function above stays
136
+ * unchanged.
137
+ */
138
+ export function executeProxmoxReconcile(plan: ProxmoxReconcilePlan): void {
139
+ if (plan.actions.length === 0) {
140
+ return;
141
+ }
142
+ log.warn(
143
+ `Proxmox reconciliation pending (${plan.actions.length} LXC${plan.actions.length === 1 ? '' : 's'} affected). The aspect's Ansible run updated the running config on each, but the persisted Proxmox terraform config has NOT been changed. Re-provisioning would revert. Affected:`,
144
+ );
145
+ for (const action of plan.actions) {
146
+ const tfvarSummary = Object.entries(action.tfvarUpdates)
147
+ .map(([k, v]) => `${k}=${v}`)
148
+ .join(', ');
149
+ log.warn(
150
+ ` - LXC owned by module '${action.moduleId}' (service ${action.serviceId}, vmid ${(action.containerSystem.containerMetadata as { vmid?: string | number } | null)?.vmid ?? '?'}): would set ${tfvarSummary}`,
151
+ );
152
+ }
153
+ log.warn(
154
+ 'To persist these values, re-deploy the owning modules after the underlying celilo state catches up. Tracked as Phase 1c in v2/CELILO_BASE.md.',
155
+ );
156
+ }
@@ -11,6 +11,7 @@ import { and, eq } from 'drizzle-orm';
11
11
  import { log } from '../cli/prompts';
12
12
  import type { DbClient } from '../db/client';
13
13
  import { moduleConfigs } from '../db/schema';
14
+ import { upsertModuleConfig } from './module-config';
14
15
 
15
16
  /**
16
17
  * Terraform state file structure (subset we care about)
@@ -107,33 +108,11 @@ export async function ensureProxmoxConfigFromState(
107
108
  log.warn(' Recovering vmid and target_ip from Terraform state...');
108
109
 
109
110
  if (!existingVmid) {
110
- await db
111
- .insert(moduleConfigs)
112
- .values({
113
- moduleId,
114
- key: 'vmid',
115
- value: vmid.toString(),
116
- })
117
- .onConflictDoUpdate({
118
- target: [moduleConfigs.moduleId, moduleConfigs.key],
119
- set: { value: vmid.toString() },
120
- })
121
- .run();
111
+ upsertModuleConfig(db, moduleId, 'vmid', vmid);
122
112
  }
123
113
 
124
114
  if (!existingContainerIp) {
125
- await db
126
- .insert(moduleConfigs)
127
- .values({
128
- moduleId,
129
- key: 'target_ip',
130
- value: containerIp,
131
- })
132
- .onConflictDoUpdate({
133
- target: [moduleConfigs.moduleId, moduleConfigs.key],
134
- set: { value: containerIp },
135
- })
136
- .run();
115
+ upsertModuleConfig(db, moduleId, 'target_ip', containerIp);
137
116
  }
138
117
 
139
118
  log.success(` Recovered: vmid=${vmid}, target_ip=${containerIp}`);
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Tests for the file-direct restore service (Phase 4).
3
+ *
4
+ * Focused on the apply-staged-system-files step + restoreFromArtifactFile's
5
+ * error-handling for malformed / mismatched / missing artifacts.
6
+ *
7
+ * Full round-trip (create artifact via celilo-mgmt backup, then restore
8
+ * via this service) is an e2e concern, not exercised at the unit level.
9
+ */
10
+
11
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
12
+ import {
13
+ existsSync,
14
+ mkdirSync,
15
+ mkdtempSync,
16
+ readFileSync,
17
+ rmSync,
18
+ statSync,
19
+ writeFileSync,
20
+ } from 'node:fs';
21
+ import { tmpdir } from 'node:os';
22
+ import { join } from 'node:path';
23
+ import { closeDb } from '../db/client';
24
+ import { runMigrations } from '../db/migrate';
25
+ import { applyStagedSystemFiles, restoreFromArtifactFile } from './restore-from-file';
26
+
27
+ describe('applyStagedSystemFiles', () => {
28
+ let dir: string;
29
+ let stagingDir: string;
30
+ let livePath: string;
31
+ let keyPath: string;
32
+
33
+ beforeEach(() => {
34
+ dir = mkdtempSync(join(tmpdir(), 'celilo-staged-apply-test-'));
35
+ stagingDir = join(dir, 'system');
36
+ mkdirSync(stagingDir, { recursive: true });
37
+ livePath = join(dir, 'celilo.db');
38
+ keyPath = join(dir, 'master.key');
39
+ process.env.CELILO_DB_PATH = livePath;
40
+ process.env.CELILO_MASTER_KEY_PATH = keyPath;
41
+ });
42
+
43
+ afterEach(() => {
44
+ process.env.CELILO_DB_PATH = undefined;
45
+ process.env.CELILO_MASTER_KEY_PATH = undefined;
46
+ try {
47
+ rmSync(dir, { recursive: true, force: true });
48
+ } catch {
49
+ /* ignore */
50
+ }
51
+ });
52
+
53
+ it('copies celilo.db + master.key into their live paths', () => {
54
+ writeFileSync(join(stagingDir, 'celilo.db'), 'restored-db');
55
+ writeFileSync(join(stagingDir, 'master.key'), 'restored-key');
56
+
57
+ const result = applyStagedSystemFiles(stagingDir);
58
+ expect(result.dbApplied).toBe(true);
59
+ expect(result.keyApplied).toBe(true);
60
+ expect(readFileSync(livePath, 'utf-8')).toBe('restored-db');
61
+ expect(readFileSync(keyPath, 'utf-8')).toBe('restored-key');
62
+ });
63
+
64
+ it('overwrites pre-existing files at the live paths', () => {
65
+ writeFileSync(livePath, 'old-db');
66
+ writeFileSync(keyPath, 'old-key');
67
+ writeFileSync(join(stagingDir, 'celilo.db'), 'new-db');
68
+ writeFileSync(join(stagingDir, 'master.key'), 'new-key');
69
+
70
+ applyStagedSystemFiles(stagingDir);
71
+ expect(readFileSync(livePath, 'utf-8')).toBe('new-db');
72
+ expect(readFileSync(keyPath, 'utf-8')).toBe('new-key');
73
+ });
74
+
75
+ it('cleans up stale WAL/SHM siblings of the restored DB', () => {
76
+ writeFileSync(`${livePath}-wal`, 'stale-wal');
77
+ writeFileSync(`${livePath}-shm`, 'stale-shm');
78
+ writeFileSync(join(stagingDir, 'celilo.db'), 'fresh-db');
79
+
80
+ applyStagedSystemFiles(stagingDir);
81
+ expect(existsSync(`${livePath}-wal`)).toBe(false);
82
+ expect(existsSync(`${livePath}-shm`)).toBe(false);
83
+ });
84
+
85
+ it('handles partial staging (only master.key, no DB)', () => {
86
+ writeFileSync(join(stagingDir, 'master.key'), 'just-the-key');
87
+ const result = applyStagedSystemFiles(stagingDir);
88
+ expect(result.dbApplied).toBe(false);
89
+ expect(result.keyApplied).toBe(true);
90
+ });
91
+
92
+ it('handles partial staging (only DB, no master.key)', () => {
93
+ writeFileSync(join(stagingDir, 'celilo.db'), 'just-the-db');
94
+ const result = applyStagedSystemFiles(stagingDir);
95
+ expect(result.dbApplied).toBe(true);
96
+ expect(result.keyApplied).toBe(false);
97
+ expect(result.sshApplied).toBe(false);
98
+ });
99
+
100
+ it('restores the fleet SSH keypair next to the DB with strict perms', () => {
101
+ const stagedSsh = join(stagingDir, 'ssh');
102
+ mkdirSync(stagedSsh, { recursive: true });
103
+ writeFileSync(join(stagedSsh, 'id_ed25519'), 'PRIVATE-KEY-BYTES');
104
+ writeFileSync(join(stagedSsh, 'id_ed25519.pub'), 'ssh-ed25519 AAAA... celilo-fleet');
105
+
106
+ const result = applyStagedSystemFiles(stagingDir);
107
+ expect(result.sshApplied).toBe(true);
108
+
109
+ // Lands at dirname(getDbPath())/.ssh — which is `dir` here (CELILO_DB_PATH=dir/celilo.db).
110
+ const liveSshDir = join(dir, '.ssh');
111
+ expect(readFileSync(join(liveSshDir, 'id_ed25519'), 'utf-8')).toBe('PRIVATE-KEY-BYTES');
112
+ expect(readFileSync(join(liveSshDir, 'id_ed25519.pub'), 'utf-8')).toBe(
113
+ 'ssh-ed25519 AAAA... celilo-fleet',
114
+ );
115
+ // Private key 0600, public 0644, dir 0700.
116
+ expect(statSync(join(liveSshDir, 'id_ed25519')).mode & 0o777).toBe(0o600);
117
+ expect(statSync(join(liveSshDir, 'id_ed25519.pub')).mode & 0o777).toBe(0o644);
118
+ expect(statSync(liveSshDir).mode & 0o777).toBe(0o700);
119
+ });
120
+
121
+ it('is a no-op when staging dir does not exist', () => {
122
+ rmSync(stagingDir, { recursive: true });
123
+ const result = applyStagedSystemFiles(stagingDir);
124
+ expect(result.dbApplied).toBe(false);
125
+ expect(result.keyApplied).toBe(false);
126
+ });
127
+ });
128
+
129
+ describe('restoreFromArtifactFile error paths', () => {
130
+ let dir: string;
131
+
132
+ beforeEach(async () => {
133
+ dir = mkdtempSync(join(tmpdir(), 'celilo-restore-from-file-test-'));
134
+ process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
135
+ process.env.CELILO_DATA_DIR = dir;
136
+ process.env.CELILO_MASTER_KEY_PATH = join(dir, 'master.key');
137
+ await runMigrations(process.env.CELILO_DB_PATH);
138
+ });
139
+
140
+ afterEach(() => {
141
+ closeDb();
142
+ process.env.CELILO_DB_PATH = undefined;
143
+ process.env.CELILO_DATA_DIR = undefined;
144
+ process.env.CELILO_MASTER_KEY_PATH = undefined;
145
+ try {
146
+ rmSync(dir, { recursive: true, force: true });
147
+ } catch {
148
+ /* ignore */
149
+ }
150
+ });
151
+
152
+ it('returns an error for a non-existent file', async () => {
153
+ const result = await restoreFromArtifactFile('/tmp/definitely-does-not-exist.backup');
154
+ expect(result.success).toBe(false);
155
+ expect(result.error).toContain('not found');
156
+ });
157
+
158
+ it('returns an error for a file that is not valid JSON', async () => {
159
+ const bogus = join(dir, 'bogus.backup');
160
+ writeFileSync(bogus, 'this is not JSON');
161
+ const result = await restoreFromArtifactFile(bogus);
162
+ expect(result.success).toBe(false);
163
+ expect(result.error).toContain('JSON');
164
+ });
165
+
166
+ it('returns an error for valid JSON that fails decryption', async () => {
167
+ // Valid JSON shape that decryptSecret will fail on.
168
+ const bogus = join(dir, 'malformed.backup');
169
+ writeFileSync(
170
+ bogus,
171
+ JSON.stringify({ encryptedValue: 'not-real', iv: 'not-real', authTag: 'not-real' }),
172
+ );
173
+ const result = await restoreFromArtifactFile(bogus);
174
+ expect(result.success).toBe(false);
175
+ expect((result.error ?? '').toLowerCase()).toMatch(/decrypt|json|artifact/);
176
+ });
177
+ });