@celilo/cli 0.5.0-alpha.1 → 0.5.0-alpha.11

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 (101) hide show
  1. package/drizzle/0009_dns_registrations.sql +13 -0
  2. package/drizzle/0010_dns_internal_records.sql +12 -0
  3. package/drizzle/0011_backups_name.sql +1 -0
  4. package/drizzle/meta/_journal.json +22 -1
  5. package/package.json +3 -3
  6. package/src/ansible/inventory.test.ts +10 -10
  7. package/src/ansible/validation.test.ts +25 -15
  8. package/src/api-clients/proxmox.test.ts +211 -1
  9. package/src/api-clients/proxmox.ts +399 -8
  10. package/src/cli/command-registry.ts +83 -6
  11. package/src/cli/commands/backup-delete.ts +10 -7
  12. package/src/cli/commands/backup-import.ts +11 -8
  13. package/src/cli/commands/backup-restore.ts +11 -8
  14. package/src/cli/commands/dns.ts +57 -0
  15. package/src/cli/commands/events.test.ts +4 -4
  16. package/src/cli/commands/events.ts +89 -24
  17. package/src/cli/commands/machine-add.ts +178 -163
  18. package/src/cli/commands/machine-remove.ts +10 -7
  19. package/src/cli/commands/module-config.test.ts +78 -0
  20. package/src/cli/commands/module-config.ts +18 -3
  21. package/src/cli/commands/module-import.ts +9 -5
  22. package/src/cli/commands/module-publish.ts +24 -0
  23. package/src/cli/commands/module-remove.ts +20 -9
  24. package/src/cli/commands/module-status.ts +15 -0
  25. package/src/cli/commands/module-upgrade.test.ts +37 -0
  26. package/src/cli/commands/module-upgrade.ts +26 -6
  27. package/src/cli/commands/proxmox-node-list.ts +101 -0
  28. package/src/cli/commands/proxmox-template-selection.ts +16 -15
  29. package/src/cli/commands/proxmox-vm-template-build.ts +171 -0
  30. package/src/cli/commands/publish/alpha.test.ts +26 -0
  31. package/src/cli/commands/publish/alpha.ts +23 -0
  32. package/src/cli/commands/publish/types.ts +7 -2
  33. package/src/cli/commands/publish/workspace.ts +11 -1
  34. package/src/cli/commands/restore.ts +29 -0
  35. package/src/cli/commands/service-add-digitalocean.ts +120 -109
  36. package/src/cli/commands/service-add-proxmox.ts +283 -209
  37. package/src/cli/commands/service-reconfigure.test.ts +115 -0
  38. package/src/cli/commands/service-reconfigure.ts +252 -129
  39. package/src/cli/commands/service-remove.ts +19 -13
  40. package/src/cli/commands/service-verify.ts +9 -10
  41. package/src/cli/commands/storage-add-local.ts +120 -107
  42. package/src/cli/commands/storage-add-s3.ts +145 -131
  43. package/src/cli/commands/storage-remove.ts +11 -8
  44. package/src/cli/commands/system-doctor.ts +135 -40
  45. package/src/cli/commands/system-init.ts +119 -128
  46. package/src/cli/commands/system-migrate.test.ts +40 -0
  47. package/src/cli/commands/system-migrate.ts +65 -0
  48. package/src/cli/completion.ts +23 -0
  49. package/src/cli/index.ts +91 -7
  50. package/src/cli/service-credential.ts +54 -0
  51. package/src/config/paths.test.ts +61 -48
  52. package/src/db/client.ts +15 -146
  53. package/src/db/migrate.ts +14 -6
  54. package/src/db/schema-introspection.ts +88 -0
  55. package/src/db/schema.ts +74 -0
  56. package/src/hooks/capability-loader-firewall.test.ts +3 -3
  57. package/src/hooks/capability-loader.ts +43 -2
  58. package/src/hooks/run-named-hook.ts +28 -2
  59. package/src/hooks/types.ts +2 -1
  60. package/src/infrastructure/property-extractor.test.ts +15 -0
  61. package/src/infrastructure/property-extractor.ts +12 -0
  62. package/src/manifest/contracts/v1.ts +16 -0
  63. package/src/manifest/schema.ts +17 -0
  64. package/src/manifest/validate.test.ts +53 -0
  65. package/src/services/bus-interview.test.ts +2 -2
  66. package/src/services/bus-interview.ts +232 -0
  67. package/src/services/bus-secret-flow.test.ts +2 -2
  68. package/src/services/celilo-mgmt-hooks.test.ts +3 -2
  69. package/src/services/deploy-preflight.ts +25 -0
  70. package/src/services/deploy-validation.test.ts +54 -4
  71. package/src/services/deploy-validation.ts +27 -36
  72. package/src/services/dns-internal-records.test.ts +126 -0
  73. package/src/services/dns-internal-records.ts +119 -0
  74. package/src/services/dns-provider-backfill.test.ts +2 -2
  75. package/src/services/dns-provider-backfill.ts +14 -2
  76. package/src/services/dns-registrations.test.ts +120 -0
  77. package/src/services/dns-registrations.ts +108 -0
  78. package/src/services/events-daemon.test.ts +59 -0
  79. package/src/services/events-daemon.ts +191 -57
  80. package/src/services/fleet-checks.test.ts +508 -0
  81. package/src/services/fleet-checks.ts +678 -0
  82. package/src/services/module-build.test.ts +43 -38
  83. package/src/services/module-config.ts +12 -0
  84. package/src/services/module-deploy.ts +7 -6
  85. package/src/services/module-subscriptions.test.ts +88 -0
  86. package/src/services/module-subscriptions.ts +50 -1
  87. package/src/services/module-validator/bundled-deps.test.ts +55 -0
  88. package/src/services/module-validator/bundled-deps.ts +115 -0
  89. package/src/services/module-validator/capability-versions.test.ts +1 -1
  90. package/src/services/placement-reconcile.test.ts +86 -0
  91. package/src/services/placement-reconcile.ts +108 -0
  92. package/src/services/programmatic-responder.ts +34 -0
  93. package/src/services/terminal-responder.ts +113 -0
  94. package/src/templates/generator.test.ts +92 -12
  95. package/src/templates/generator.ts +165 -80
  96. package/src/test-utils/fixtures.test.ts +1 -1
  97. package/src/test-utils/integration-guard.ts +33 -0
  98. package/src/types/infrastructure.ts +6 -0
  99. package/src/variables/computed/computed-integration.test.ts +3 -3
  100. package/src/variables/computed/computed.test.ts +5 -5
  101. package/src/variables/declarative-derivation.test.ts +6 -6
@@ -5,6 +5,7 @@ import { eq } from 'drizzle-orm';
5
5
  import { log } from '../cli/prompts';
6
6
  import { type DbClient, createDbClient } from '../db/client';
7
7
  import { moduleBuilds, modules } from '../db/schema';
8
+ import { skipIntegration } from '../test-utils/integration-guard';
8
9
  import { buildModuleFromSource, getModuleBuildStatus, verifyArtifactsExist } from './module-build';
9
10
 
10
11
  const TEST_DB_PATH = './test-module-build.db';
@@ -156,12 +157,14 @@ describe('Module Build Service', () => {
156
157
  // We're just testing that detection happens (message logged)
157
158
  }, 10000); // 10 second timeout
158
159
 
159
- test('should record build metadata in database', async () => {
160
- await mkdir(TEST_MODULE_DIR, { recursive: true });
161
- await mkdir(`${TEST_MODULE_DIR}/build`, { recursive: true });
162
- await writeFile(
163
- `${TEST_MODULE_DIR}/build/playbook.yml`,
164
- `---
160
+ test.skipIf(skipIntegration({ tools: ['ansible'] }))(
161
+ 'should record build metadata in database',
162
+ async () => {
163
+ await mkdir(TEST_MODULE_DIR, { recursive: true });
164
+ await mkdir(`${TEST_MODULE_DIR}/build`, { recursive: true });
165
+ await writeFile(
166
+ `${TEST_MODULE_DIR}/build/playbook.yml`,
167
+ `---
165
168
  - name: Quick test build
166
169
  hosts: localhost
167
170
  gather_facts: false
@@ -170,43 +173,45 @@ describe('Module Build Service', () => {
170
173
  ansible.builtin.debug:
171
174
  msg: "Build test"
172
175
  `,
173
- );
176
+ );
174
177
 
175
- db.insert(modules)
176
- .values({
177
- id: 'record-test',
178
- name: 'Record Test',
179
- version: '1.0.0',
180
- sourcePath: TEST_MODULE_DIR,
181
- manifestData: {
178
+ db.insert(modules)
179
+ .values({
182
180
  id: 'record-test',
183
181
  name: 'Record Test',
184
182
  version: '1.0.0',
185
- build: {
186
- script: 'build/playbook.yml',
183
+ sourcePath: TEST_MODULE_DIR,
184
+ manifestData: {
185
+ id: 'record-test',
186
+ name: 'Record Test',
187
+ version: '1.0.0',
188
+ build: {
189
+ script: 'build/playbook.yml',
190
+ },
187
191
  },
188
- },
189
- })
190
- .run();
191
-
192
- const result = await buildModuleFromSource('record-test', db);
193
-
194
- // Build should succeed (simple debug task)
195
- expect(result.success).toBe(true);
196
-
197
- // Verify build metadata was recorded
198
- const buildRecord = await db
199
- .select()
200
- .from(moduleBuilds)
201
- .where(eq(moduleBuilds.moduleId, 'record-test'))
202
- .get();
203
-
204
- expect(buildRecord).toBeDefined();
205
- expect(buildRecord?.moduleId).toBe('record-test');
206
- expect(buildRecord?.version).toBe('1.0.0');
207
- expect(buildRecord?.status).toBe('success');
208
- expect(buildRecord?.environment).toBe('system'); // No flake.nix
209
- }, 10000); // 10 second timeout for ansible execution
192
+ })
193
+ .run();
194
+
195
+ const result = await buildModuleFromSource('record-test', db);
196
+
197
+ // Build should succeed (simple debug task)
198
+ expect(result.success).toBe(true);
199
+
200
+ // Verify build metadata was recorded
201
+ const buildRecord = await db
202
+ .select()
203
+ .from(moduleBuilds)
204
+ .where(eq(moduleBuilds.moduleId, 'record-test'))
205
+ .get();
206
+
207
+ expect(buildRecord).toBeDefined();
208
+ expect(buildRecord?.moduleId).toBe('record-test');
209
+ expect(buildRecord?.version).toBe('1.0.0');
210
+ expect(buildRecord?.status).toBe('success');
211
+ expect(buildRecord?.environment).toBe('system'); // No flake.nix
212
+ },
213
+ 10000,
214
+ ); // 10 second timeout for ansible execution
210
215
 
211
216
  // Regression: a build.command that references $CELILO_MODULE_SOURCE_DIR
212
217
  // (the variable celilo-registry's manifest uses to find sibling packages
@@ -106,6 +106,18 @@ export function upsertModuleConfig(
106
106
  .run();
107
107
  }
108
108
 
109
+ /**
110
+ * Delete a module config row if present (no-op if absent). Used to drop a
111
+ * derived/cached key that should no longer persist — e.g. `__infra_target_node`,
112
+ * now resolved live from Proxmox each generate rather than cached in the DB
113
+ * (ISS-0090: "the DB stores intent, Proxmox reports reality").
114
+ */
115
+ export function deleteModuleConfig(db: DbClient, moduleId: string, key: string): void {
116
+ db.delete(moduleConfigs)
117
+ .where(and(eq(moduleConfigs.moduleId, moduleId), eq(moduleConfigs.key, key)))
118
+ .run();
119
+ }
120
+
109
121
  /**
110
122
  * Parse a stored module_configs row into its canonical typed value.
111
123
  * Throws if valueJson is null — that's a row written before the
@@ -48,7 +48,6 @@ export interface DeployResult {
48
48
  phases: {
49
49
  validation?: boolean;
50
50
  autoGenerated?: boolean;
51
- autoBuilt?: boolean;
52
51
  planning?: boolean;
53
52
  terraformInit?: boolean;
54
53
  terraformPlan?: boolean;
@@ -353,7 +352,6 @@ async function deployModuleImpl(
353
352
  const validation = await validateAndPrepareDeployment(moduleId, db);
354
353
  phases.validation = validation.success;
355
354
  phases.autoGenerated = validation.autoGenerated;
356
- phases.autoBuilt = validation.autoBuilt;
357
355
  if (!validation.success) {
358
356
  return {
359
357
  success: false,
@@ -532,9 +530,7 @@ async function deployModuleImpl(
532
530
  }
533
531
  }
534
532
 
535
- log.success(
536
- validation.autoBuilt ? 'Templates generated and module built' : 'Templates generated',
537
- );
533
+ log.success('Templates generated');
538
534
 
539
535
  // Run validate_config hook if defined (e.g., credential validation via Playwright)
540
536
  if (manifest.hooks?.validate_config) {
@@ -911,7 +907,12 @@ async function deployModuleImpl(
911
907
  lines.push(` ${key} = ${value}`);
912
908
  }
913
909
  if (resolution.skipped.length > 0) {
914
- lines.push(` (skipped user-configured: ${resolution.skipped.join(', ')})`);
910
+ // These are infrastructure-managed vars with no value this deploy (e.g.
911
+ // vmid/target_node on a machine deploy). NOT honored operator overrides —
912
+ // such keys are rejected at `config set` (ISS-0069); don't imply otherwise.
913
+ lines.push(
914
+ ` (auto-managed, not applicable this deploy: ${resolution.skipped.join(', ')})`,
915
+ );
915
916
  }
916
917
  log.success(lines.join('\n'));
917
918
 
@@ -3,11 +3,14 @@ import { mkdtempSync, rmSync } from 'node:fs';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { defineEvents, openBus } from '@celilo/event-bus';
6
+ import { closeDb, getDb } from '../db/client';
6
7
  import { ModuleSubscriptionSchema } from '../manifest/schema';
7
8
  import type { ModuleManifest } from '../manifest/schema';
9
+ import { setupTestDatabase as migrateDbFile } from '../test-utils/setup-test-db';
8
10
  import {
9
11
  registerModuleSubscriptions,
10
12
  resolveSubscription,
13
+ resyncAllSubscriptions,
11
14
  unregisterModuleSubscriptions,
12
15
  } from './module-subscriptions';
13
16
 
@@ -253,3 +256,88 @@ describe('register / unregister roundtrip', () => {
253
256
  }
254
257
  });
255
258
  });
259
+
260
+ describe('resyncAllSubscriptions (ISS-0088)', () => {
261
+ let dir: string;
262
+ let busPath: string;
263
+
264
+ beforeEach(async () => {
265
+ closeDb();
266
+ dir = mkdtempSync(join(tmpdir(), 'resync-test-'));
267
+ busPath = join(dir, 'events.db');
268
+ process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
269
+ process.env.EVENT_BUS_DB = busPath;
270
+ // Migrate the celilo.db file getDb() will open (resyncAllSubscriptions reads it).
271
+ const setupDb = await migrateDbFile(join(dir, 'celilo.db'));
272
+ setupDb.$client.close();
273
+ });
274
+ afterEach(() => {
275
+ closeDb();
276
+ process.env.CELILO_DB_PATH = undefined;
277
+ process.env.EVENT_BUS_DB = undefined;
278
+ try {
279
+ rmSync(dir, { recursive: true, force: true });
280
+ } catch {
281
+ /* ignore */
282
+ }
283
+ });
284
+
285
+ function seedModule(
286
+ id: string,
287
+ state: string,
288
+ subs: Array<{ name: string; pattern: string; handler: string }>,
289
+ ): void {
290
+ const manifest = JSON.stringify({
291
+ celilo_contract: '1.0',
292
+ id,
293
+ name: id,
294
+ version: '1.0.0',
295
+ requires: { capabilities: [] },
296
+ provides: { capabilities: [] },
297
+ variables: { owns: [], imports: [] },
298
+ subscriptions: subs,
299
+ });
300
+ getDb().$client.run(
301
+ 'INSERT INTO modules (id, name, version, source_path, manifest_data, state) VALUES (?, ?, ?, ?, ?, ?)',
302
+ [id, id, '1.0.0', '/p', manifest, state],
303
+ );
304
+ }
305
+
306
+ function subscriberNames(): string[] {
307
+ const bus = openBus({ dbPath: busPath, events: defineEvents({}) });
308
+ try {
309
+ return bus.db
310
+ .query<{ name: string }, []>('SELECT name FROM subscribers ORDER BY name')
311
+ .all()
312
+ .map((r) => r.name);
313
+ } finally {
314
+ bus.close();
315
+ }
316
+ }
317
+
318
+ it('registers subs for DEPLOYED modules only, skipping imported/undeployed', () => {
319
+ seedModule('caddy', 'VERIFIED', [
320
+ { name: 'reconcile', pattern: 'public_web.routes_changed', handler: 'echo' },
321
+ ]);
322
+ seedModule('technitium', 'INSTALLED', [
323
+ { name: 'dns', pattern: 'system.created.*', handler: 'echo' },
324
+ ]);
325
+ seedModule('forgejo', 'IMPORTED', [{ name: 'x', pattern: 'y', handler: 'echo' }]);
326
+ seedModule('greenwave', 'VERIFIED', []); // deployed but declares no subs
327
+
328
+ const result = resyncAllSubscriptions();
329
+
330
+ expect(result.modules).toBe(2); // caddy + technitium (forgejo not deployed, greenwave has none)
331
+ expect(result.registered).toBe(2);
332
+ expect(result.failures).toEqual([]);
333
+ // forgejo.x absent — it was IMPORTED, not deployed.
334
+ expect(subscriberNames()).toEqual(['caddy.reconcile', 'technitium.dns']);
335
+ });
336
+
337
+ it('is idempotent — a second resync does not duplicate rows', () => {
338
+ seedModule('caddy', 'VERIFIED', [{ name: 'reconcile', pattern: 'a', handler: 'echo' }]);
339
+ resyncAllSubscriptions();
340
+ resyncAllSubscriptions();
341
+ expect(subscriberNames()).toEqual(['caddy.reconcile']);
342
+ });
343
+ });
@@ -14,8 +14,12 @@
14
14
  * colliding.
15
15
  */
16
16
 
17
+ import { join } from 'node:path';
17
18
  import { defineEvents, openBus } from '@celilo/event-bus';
18
- import { getEventBusPath } from '../config/paths';
19
+ import { inArray } from 'drizzle-orm';
20
+ import { getEventBusPath, getModuleStoragePath } from '../config/paths';
21
+ import { getDb } from '../db/client';
22
+ import { modules } from '../db/schema';
19
23
  import type { ModuleManifest, ModuleSubscription } from '../manifest/schema';
20
24
 
21
25
  /**
@@ -126,6 +130,51 @@ export function unregisterModuleSubscriptions(moduleId: string): {
126
130
  }
127
131
  }
128
132
 
133
+ /**
134
+ * Rebuild the event-bus `subscribers` table from every deployed module's
135
+ * manifest `subscriptions:`. The reactive layer (who-reacts-to-what) is
136
+ * registered at module DEPLOY time and lives in the bus (events.db), which a
137
+ * restore/migration starts EMPTY — so after a cutover, event-driven reconciles
138
+ * (caddy public_web, dns register, …) are dead until every module redeploys
139
+ * (ISS-0088). This reconstructs them from durable celilo.db state instead.
140
+ *
141
+ * Idempotent (registerModuleSubscriptions upserts by subscriber name). Per-module
142
+ * failures are collected, not thrown, so one bad manifest doesn't abort the rest.
143
+ */
144
+ export function resyncAllSubscriptions(): {
145
+ modules: number;
146
+ registered: number;
147
+ failures: Array<{ moduleId: string; error: string }>;
148
+ } {
149
+ const db = getDb();
150
+ const deployed = db
151
+ .select()
152
+ .from(modules)
153
+ .where(inArray(modules.state, ['INSTALLED', 'VERIFIED']))
154
+ .all();
155
+
156
+ let modulesWithSubs = 0;
157
+ let registered = 0;
158
+ const failures: Array<{ moduleId: string; error: string }> = [];
159
+
160
+ for (const mod of deployed) {
161
+ const manifest = mod.manifestData as unknown as ModuleManifest;
162
+ if (!manifest?.subscriptions?.length) continue;
163
+ // Code always lives at ${getModuleStoragePath()}/<id> by construction
164
+ // (import.ts) — the same path module-upgrade re-registers from (ISS-0091).
165
+ const modulePath = join(getModuleStoragePath(), mod.id);
166
+ try {
167
+ const result = registerModuleSubscriptions(manifest, modulePath);
168
+ registered += result.registered;
169
+ modulesWithSubs += 1;
170
+ } catch (err) {
171
+ failures.push({ moduleId: mod.id, error: err instanceof Error ? err.message : String(err) });
172
+ }
173
+ }
174
+
175
+ return { modules: modulesWithSubs, registered, failures };
176
+ }
177
+
129
178
  function scopedName(moduleId: string, subName: string): string {
130
179
  return `${moduleId}.${subName}`;
131
180
  }
@@ -0,0 +1,55 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { findStaleBundledDeps, refreshAndVerifyBundledDeps } from './bundled-deps';
3
+
4
+ describe('findStaleBundledDeps (ISS-0104)', () => {
5
+ test('flags a bundled @celilo dep older than the workspace', () => {
6
+ const mismatches = findStaleBundledDeps(
7
+ { '@celilo/capabilities': '0.1.8' },
8
+ { '@celilo/capabilities': '0.4.1' },
9
+ );
10
+ expect(mismatches).toEqual([
11
+ { pkg: '@celilo/capabilities', bundled: '0.1.8', workspace: '0.4.1' },
12
+ ]);
13
+ });
14
+
15
+ test('no mismatch when bundled matches workspace', () => {
16
+ expect(
17
+ findStaleBundledDeps(
18
+ { '@celilo/capabilities': '0.4.1', '@celilo/event-bus': '0.1.6' },
19
+ { '@celilo/capabilities': '0.4.1', '@celilo/event-bus': '0.1.6' },
20
+ ),
21
+ ).toEqual([]);
22
+ });
23
+
24
+ test('ignores workspace packages the module does not bundle', () => {
25
+ // Module bundles only capabilities; cli-display is a workspace package but
26
+ // not in this module's closure — must not be flagged.
27
+ expect(
28
+ findStaleBundledDeps(
29
+ { '@celilo/capabilities': '0.4.1' },
30
+ { '@celilo/capabilities': '0.4.1', '@celilo/cli-display': '0.1.9' },
31
+ ),
32
+ ).toEqual([]);
33
+ });
34
+
35
+ test('flags each stale dep independently', () => {
36
+ const mismatches = findStaleBundledDeps(
37
+ { '@celilo/capabilities': '0.1.8', '@celilo/event-bus': '0.1.6' },
38
+ { '@celilo/capabilities': '0.4.1', '@celilo/event-bus': '0.1.6' },
39
+ );
40
+ expect(mismatches).toEqual([
41
+ { pkg: '@celilo/capabilities', bundled: '0.1.8', workspace: '0.4.1' },
42
+ ]);
43
+ });
44
+ });
45
+
46
+ describe('refreshAndVerifyBundledDeps (ISS-0104)', () => {
47
+ test('no-op for a module without scripts/package.json', () => {
48
+ let installs = 0;
49
+ const result = refreshAndVerifyBundledDeps('/tmp/does-not-exist-module-xyz', () => {
50
+ installs++;
51
+ });
52
+ expect(result).toEqual({ refreshed: false, mismatches: [] });
53
+ expect(installs).toBe(0);
54
+ });
55
+ });
@@ -0,0 +1,115 @@
1
+ /**
2
+ * ISS-0104: a module's hooks resolve `@celilo/*` from the module's OWN
3
+ * `scripts/node_modules` (nearest-wins), and those are gitignored local
4
+ * installs that go stale. The publish bundles them as-is, so a deployed module
5
+ * can silently run capability code months older than what was just published
6
+ * (e.g. caddy shipped capabilities@0.1.8 while the workspace was at 0.4.1, so
7
+ * ISS-0102 never reached the rendered Caddyfile).
8
+ *
9
+ * Before packing, the publish refreshes a module's `scripts/` deps and verifies
10
+ * the bundled `@celilo/*` versions match the workspace — the versions being
11
+ * co-published. A mismatch fails the publish (unless --allow-stale).
12
+ */
13
+
14
+ import { execSync } from 'node:child_process';
15
+ import { existsSync, readFileSync } from 'node:fs';
16
+ import { dirname, join } from 'node:path';
17
+
18
+ /** The @celilo workspace packages a module may bundle (by published name). */
19
+ const CELILO_WORKSPACE_PACKAGES = ['capabilities', 'cli-display', 'event-bus'] as const;
20
+
21
+ export interface DepMismatch {
22
+ pkg: string;
23
+ bundled: string;
24
+ workspace: string;
25
+ }
26
+
27
+ /**
28
+ * Pure: which bundled `@celilo/*` versions differ from the workspace. Only deps
29
+ * the module actually bundles AND the workspace owns are compared — a module
30
+ * that doesn't bundle a given package, or a package the workspace doesn't own,
31
+ * is ignored.
32
+ */
33
+ export function findStaleBundledDeps(
34
+ bundled: Record<string, string>,
35
+ workspace: Record<string, string>,
36
+ ): DepMismatch[] {
37
+ const mismatches: DepMismatch[] = [];
38
+ for (const [pkg, wsVersion] of Object.entries(workspace)) {
39
+ const bundledVersion = bundled[pkg];
40
+ if (bundledVersion && bundledVersion !== wsVersion) {
41
+ mismatches.push({ pkg, bundled: bundledVersion, workspace: wsVersion });
42
+ }
43
+ }
44
+ return mismatches;
45
+ }
46
+
47
+ function readVersion(packageJsonPath: string): string | undefined {
48
+ try {
49
+ const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf-8')) as { version?: string };
50
+ return parsed.version;
51
+ } catch {
52
+ return undefined;
53
+ }
54
+ }
55
+
56
+ /** Find the monorepo root (the dir with `packages/capabilities`) by walking up. */
57
+ function findWorkspaceRoot(start: string): string | undefined {
58
+ let dir = start;
59
+ for (let depth = 0; depth < 8; depth++) {
60
+ if (existsSync(join(dir, 'packages', 'capabilities', 'package.json'))) return dir;
61
+ const parent = dirname(dir);
62
+ if (parent === dir) break;
63
+ dir = parent;
64
+ }
65
+ return undefined;
66
+ }
67
+
68
+ export interface RefreshResult {
69
+ /** Whether the module had a scripts/ package to refresh. */
70
+ refreshed: boolean;
71
+ /** Bundled @celilo/* that don't match the workspace (empty = clean). */
72
+ mismatches: DepMismatch[];
73
+ }
74
+
75
+ /**
76
+ * Refresh a module's `scripts/` deps (so the bundled hook runtime is current)
77
+ * and verify the bundled `@celilo/*` versions match the workspace. No-op for a
78
+ * module without `scripts/package.json`, or when run outside the monorepo (a
79
+ * standalone module has no workspace to compare against — refresh only).
80
+ *
81
+ * `run` is injected so tests can stub the `bun install` side effect.
82
+ */
83
+ export function refreshAndVerifyBundledDeps(
84
+ moduleDir: string,
85
+ run: (cmd: string, cwd: string) => void = (cmd, cwd) =>
86
+ void execSync(cmd, { cwd, stdio: 'pipe' }),
87
+ ): RefreshResult {
88
+ const scriptsDir = join(moduleDir, 'scripts');
89
+ if (!existsSync(join(scriptsDir, 'package.json'))) {
90
+ return { refreshed: false, mismatches: [] };
91
+ }
92
+
93
+ // Reconcile node_modules with the lockfile + package.json pins so the pack
94
+ // bundles a CURRENT closure, not whatever the operator last installed.
95
+ run('bun install', scriptsDir);
96
+
97
+ const workspaceRoot = findWorkspaceRoot(moduleDir);
98
+ if (!workspaceRoot) {
99
+ return { refreshed: true, mismatches: [] };
100
+ }
101
+
102
+ const workspace: Record<string, string> = {};
103
+ for (const name of CELILO_WORKSPACE_PACKAGES) {
104
+ const version = readVersion(join(workspaceRoot, 'packages', name, 'package.json'));
105
+ if (version) workspace[`@celilo/${name}`] = version;
106
+ }
107
+
108
+ const bundled: Record<string, string> = {};
109
+ for (const pkg of Object.keys(workspace)) {
110
+ const version = readVersion(join(scriptsDir, 'node_modules', pkg, 'package.json'));
111
+ if (version) bundled[pkg] = version;
112
+ }
113
+
114
+ return { refreshed: true, mismatches: findStaleBundledDeps(bundled, workspace) };
115
+ }
@@ -31,7 +31,7 @@ describe('validateCapabilityVersions', () => {
31
31
  expect(errors).toHaveLength(1);
32
32
  expect(errors[0]).toContain('provides[public_web]');
33
33
  expect(errors[0]).toContain('1.0.0');
34
- expect(errors[0]).toContain('3.0.0');
34
+ expect(errors[0]).toContain('3.1.0');
35
35
  });
36
36
 
37
37
  test('error when provides[X].version is newer than runtime', () => {
@@ -0,0 +1,86 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { DeployedSystem } from '@celilo/capabilities';
3
+ import { formatPlacementLine, resolveOnePlacement } from './placement-reconcile';
4
+
5
+ function sys(
6
+ overrides: Partial<DeployedSystem> & { infrastructure: DeployedSystem['infrastructure'] },
7
+ ): DeployedSystem {
8
+ return {
9
+ name: 'main',
10
+ hostname: 'caddy',
11
+ ipv4_address: '10.0.10.10',
12
+ zone: 'dmz',
13
+ ...overrides,
14
+ } as DeployedSystem;
15
+ }
16
+
17
+ const SVC = 'svc-1';
18
+ const cs = (vmid?: number) =>
19
+ sys({
20
+ infrastructure: {
21
+ type: 'container_service',
22
+ serviceId: SVC,
23
+ ...(vmid != null ? { vmid } : {}),
24
+ },
25
+ });
26
+
27
+ describe('resolveOnePlacement (ISS-0060 — node reconciled from Proxmox, not a cached DB value)', () => {
28
+ test('machine-pool system → machine (no Proxmox node)', () => {
29
+ const s = sys({ infrastructure: { type: 'machine', machineId: 'm1' } });
30
+ expect(resolveOnePlacement(s, new Map(), new Set(), new Set())).toEqual({ kind: 'machine' });
31
+ });
32
+
33
+ test('non-Proxmox container service → other', () => {
34
+ expect(resolveOnePlacement(cs(200), new Map(), new Set(), new Set([SVC]))).toEqual({
35
+ kind: 'other',
36
+ });
37
+ });
38
+
39
+ test('container_service without a vmid → uncreated', () => {
40
+ expect(resolveOnePlacement(cs(undefined), new Map(), new Set(), new Set())).toEqual({
41
+ kind: 'uncreated',
42
+ });
43
+ });
44
+
45
+ test('Proxmox unreachable for the service → unreachable (never a stale fallback)', () => {
46
+ expect(resolveOnePlacement(cs(200), new Map(), new Set([SVC]), new Set())).toEqual({
47
+ kind: 'unreachable',
48
+ });
49
+ });
50
+
51
+ test('vmid present in the cluster → node (the reconciled reality)', () => {
52
+ expect(resolveOnePlacement(cs(200), new Map([[200, 'node2']]), new Set(), new Set())).toEqual({
53
+ kind: 'node',
54
+ node: 'node2',
55
+ });
56
+ });
57
+
58
+ test('vmid absent from the cluster → absent', () => {
59
+ expect(resolveOnePlacement(cs(200), new Map([[999, 'node2']]), new Set(), new Set())).toEqual({
60
+ kind: 'absent',
61
+ });
62
+ });
63
+ });
64
+
65
+ describe('formatPlacementLine', () => {
66
+ const s = cs(200);
67
+
68
+ test('node resolution shows the real node + vmid', () => {
69
+ expect(formatPlacementLine(s, { kind: 'node', node: 'node2' })).toBe(
70
+ 'caddy (vmid 200) → node2 (zone dmz)',
71
+ );
72
+ });
73
+
74
+ test('unreachable is explicit — no silent stale value', () => {
75
+ expect(formatPlacementLine(s, { kind: 'unreachable' })).toContain('Proxmox unreachable');
76
+ });
77
+
78
+ test('absent flags a vmid not in the cluster', () => {
79
+ expect(formatPlacementLine(s, { kind: 'absent' })).toContain('not found in Proxmox');
80
+ });
81
+
82
+ test('machine line omits vmid/node', () => {
83
+ const m = sys({ hostname: 'iot', zone: 'internal', infrastructure: { type: 'machine' } });
84
+ expect(formatPlacementLine(m, { kind: 'machine' })).toBe('iot — machine (zone internal)');
85
+ });
86
+ });