@celilo/cli 0.4.0 → 0.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@celilo/cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.0-alpha.0",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -6,7 +6,7 @@
6
6
  import { eq } from 'drizzle-orm';
7
7
  import { getDb } from '../../db/client';
8
8
  import { modules } from '../../db/schema';
9
- import type { ModuleManifest } from '../../manifest/schema';
9
+ import { type ModuleManifest, getSingularSystemSpec } from '../../manifest/schema';
10
10
  import { buildResolutionContext } from '../../variables/context';
11
11
  import { getArg, validateRequiredArgs } from '../parser';
12
12
  import type { CommandResult } from '../types';
@@ -60,7 +60,7 @@ export async function handleModuleShowConfig(args: string[]): Promise<CommandRes
60
60
 
61
61
  for (const [key, value] of Object.entries(context.selfConfig)) {
62
62
  // Skip internal/derived keys that aren't useful for display
63
- if (key.startsWith('requires.machine.')) continue;
63
+ if (key.startsWith('requires.system.')) continue;
64
64
 
65
65
  const line = ` ${key} = ${value}`;
66
66
 
@@ -152,7 +152,7 @@ export async function handleModuleShowZone(args: string[]): Promise<CommandResul
152
152
  const context = await buildResolutionContext(moduleId, db);
153
153
  const manifest = module.manifestData as ModuleManifest;
154
154
 
155
- const zone = context.selfConfig.zone || manifest.requires?.machine?.zone;
155
+ const zone = context.selfConfig.zone || getSingularSystemSpec(manifest)?.zone;
156
156
 
157
157
  if (!zone) {
158
158
  return {
@@ -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(', ')}`,
@@ -9,7 +9,7 @@ import { join } from 'node:path';
9
9
  import { eq } from 'drizzle-orm';
10
10
  import { getDb } from '../../db/client';
11
11
  import { capabilities, moduleConfigs, modules, systemConfig, systemSecrets } from '../../db/schema';
12
- import type { ModuleManifest } from '../../manifest/schema';
12
+ import { type ModuleManifest, getSingularSystemSpec } from '../../manifest/schema';
13
13
  import type { CommandResult } from '../types';
14
14
 
15
15
  /**
@@ -131,7 +131,7 @@ export async function handleStatus(): Promise<CommandResult> {
131
131
  lines.push(` Status: ${statusInfo.status}`);
132
132
 
133
133
  // Type: VPS or Container
134
- const zone = manifest.requires?.machine?.zone;
134
+ const zone = getSingularSystemSpec(manifest)?.zone;
135
135
  if (zone) {
136
136
  lines.push(` Type: Container (${zone.toUpperCase()})`);
137
137
 
@@ -368,13 +368,24 @@ export async function loadCapabilityFunctions(
368
368
  // otherwise race the async reconcile.
369
369
  onRoutesChanged: async () => {
370
370
  const reconcile = await emitWebRoutesChangedAndWait(consumingModuleId);
371
+ // Authoritative (ISS-0081): a route the provider (caddy) never
372
+ // reconciled is a deploy FAILURE, not a warning. These used to be
373
+ // logger.warn while register_route returned success anyway, so a
374
+ // consumer could report "ready" while caddy never learned the
375
+ // hostname — no site block, no cert, TLS internal_error for clients.
376
+ if (reconcile.noDispatcher) {
377
+ throw new Error(
378
+ `public_web route for ${consumingModuleId} was persisted but NOT delivered to the provider (caddy): no event dispatcher is running, so caddy never reconciled and the hostname has no site block or cert. Run this through \`celilo module deploy\` (which runs the dispatcher) rather than a bare hook.`,
379
+ );
380
+ }
371
381
  if (reconcile.timedOut) {
372
- logger.warn(
373
- `public_web reconcile for ${consumingModuleId} did not finish within the deadline (${reconcile.succeeded} ok, ${reconcile.failed} failed of ${reconcile.events} change event(s)); the route is persisted and the provider will reconcile on its next run`,
382
+ throw new Error(
383
+ `public_web reconcile for ${consumingModuleId} did not finish within the deadline (${reconcile.succeeded} ok, ${reconcile.failed} failed of ${reconcile.events} change event(s)) caddy did not confirm the route is live.`,
374
384
  );
375
- } else if (reconcile.failed > 0) {
376
- logger.warn(
377
- `public_web reconcile for ${consumingModuleId}: ${reconcile.failed} delivery(ies) failed; the route is persisted but the provider config may be stale`,
385
+ }
386
+ if (reconcile.failed > 0) {
387
+ throw new Error(
388
+ `public_web reconcile for ${consumingModuleId}: ${reconcile.failed} delivery(ies) failed — caddy could not apply the route, so the hostname is not served.`,
378
389
  );
379
390
  }
380
391
  },
@@ -306,7 +306,7 @@ export const UpstreamPublishHookSchema = z.object({
306
306
  * Machine resource recommendations
307
307
  * Module declares recommended machine resources (CPU, memory, disk, storage)
308
308
  */
309
- export const MachineResourceSchema = z.object({
309
+ export const SystemResourceSchema = z.object({
310
310
  cpu: z.number().int().positive().optional().describe('Recommended CPU cores'),
311
311
  memory: z.number().int().positive().optional().describe('Recommended memory in MB'),
312
312
  disk: z.number().int().positive().optional().describe('Recommended disk size in GB'),
@@ -328,7 +328,7 @@ export const SystemDeclarationSchema = z.object({
328
328
  .string()
329
329
  .regex(/^[a-z0-9]+(-[a-z0-9]+)*$/, 'system name must be kebab-case')
330
330
  .describe('Stable handle for this system; used in $infra:<name> and as the per-system key'),
331
- resources: MachineResourceSchema,
331
+ resources: SystemResourceSchema,
332
332
  });
333
333
 
334
334
  /**
@@ -439,17 +439,17 @@ export const ModuleManifestSchema = z
439
439
  capabilities: z.array(CapabilityRequirementSchema).default([]),
440
440
  /**
441
441
  * The 0..N systems this module deploys (v2/MODULE_SYSTEMS_ADDRESSING.md).
442
- * Preferred over the legacy singular `machine`. A module declaring
442
+ * Preferred over the singular `system`. A module declaring
443
443
  * `systems` gets one host per entry, each addressable as `$infra:<name>`.
444
444
  */
445
445
  systems: z.array(SystemDeclarationSchema).optional(),
446
446
  /**
447
- * Legacy singular form — sugar for a single unnamed system. Normalized
448
- * to one `systems` entry (name `main`) by `getDeclaredSystems`. Optional
449
- * because config-only providers (e.g. namecheap) deploy no infrastructure.
450
- * New modules should prefer `systems`.
447
+ * Singular form — sugar for a single unnamed system. Normalized to one
448
+ * `systems` entry (name `main`) by `getDeclaredSystems`. Optional because
449
+ * config-only providers (e.g. namecheap) deploy no infrastructure.
450
+ * Modules declaring more than one host should use `systems`.
451
451
  */
452
- machine: MachineResourceSchema.optional(),
452
+ system: SystemResourceSchema.optional(),
453
453
  })
454
454
  .default({ capabilities: [] }),
455
455
 
@@ -730,25 +730,36 @@ export type LifecycleHook = z.infer<typeof LifecycleHookSchema>;
730
730
  export type VariableSource = z.infer<typeof VariableSourceSchema>;
731
731
  export type VariableType = z.infer<typeof VariableTypeSchema>;
732
732
  export type AnsibleCollection = z.infer<typeof AnsibleCollectionSchema>;
733
- export type MachineResource = z.infer<typeof MachineResourceSchema>;
733
+ export type SystemResource = z.infer<typeof SystemResourceSchema>;
734
734
  export type SystemDeclaration = z.infer<typeof SystemDeclarationSchema>;
735
735
  export type BaseModuleAspect = NonNullable<ModuleManifest['base_module_aspect']>;
736
736
  export type BaseModuleAspectTrigger = BaseModuleAspect['triggers'][number];
737
737
 
738
738
  /**
739
739
  * Normalize a manifest's declared systems (v2/MODULE_SYSTEMS_ADDRESSING.md).
740
- * Returns `requires.systems` if present; else the legacy singular
741
- * `requires.machine` as one entry named `main`; else `[]` (config-only module
742
- * like namecheap). This is the single place the machine→systems sugar lives, so
743
- * the rest of the codebase only ever reasons about the 0..N collection.
740
+ * Returns `requires.systems` if present; else the singular `requires.system`
741
+ * as one entry named `main`; else `[]` (config-only module like namecheap).
742
+ * This is the single place the system→systems sugar lives, so the rest of the
743
+ * codebase only ever reasons about the 0..N collection.
744
744
  */
745
745
  export function getDeclaredSystems(manifest: ModuleManifest): SystemDeclaration[] {
746
746
  const requires = manifest.requires;
747
747
  if (requires?.systems && requires.systems.length > 0) {
748
748
  return requires.systems;
749
749
  }
750
- if (requires?.machine) {
751
- return [{ name: 'main', resources: requires.machine }];
750
+ const singular = getSingularSystemSpec(manifest);
751
+ if (singular) {
752
+ return [{ name: 'main', resources: singular }];
752
753
  }
753
754
  return [];
754
755
  }
756
+
757
+ /**
758
+ * The singular system resource spec a module declares: `requires.system`. The
759
+ * one place callers read the single-system spec (zone, sizing, "does this module
760
+ * need infra?"). Returns undefined for config-only modules or those declaring the
761
+ * plural `requires.systems`.
762
+ */
763
+ export function getSingularSystemSpec(manifest: ModuleManifest): SystemResource | undefined {
764
+ return manifest.requires?.system;
765
+ }
@@ -14,7 +14,7 @@ function createTestManifest(overrides?: Partial<ModuleManifest>): ModuleManifest
14
14
  description: 'Test module',
15
15
  requires: {
16
16
  capabilities: [],
17
- machine: {
17
+ system: {
18
18
  cpu: 2,
19
19
  memory: 2048,
20
20
  disk: 20,
@@ -54,16 +54,16 @@ function createTestManifest(overrides?: Partial<ModuleManifest>): ModuleManifest
54
54
 
55
55
  describe('template-validator', () => {
56
56
  describe('validateModuleTemplates - valid references', () => {
57
- test('validates nested manifest paths (requires.machine.*)', async () => {
57
+ test('validates nested manifest paths (requires.system.*)', async () => {
58
58
  const manifest = createTestManifest();
59
59
 
60
- // Test that requires.machine.cpu is valid
61
- const machine = manifest.requires.machine;
60
+ // Test that requires.system.cpu is valid
61
+ const system = manifest.requires.system;
62
62
 
63
- expect(machine?.cpu).toBe(2);
64
- expect(machine?.memory).toBe(2048);
65
- expect(machine?.disk).toBe(20);
66
- expect(machine?.storage).toBe('local-lvm');
63
+ expect(system?.cpu).toBe(2);
64
+ expect(system?.memory).toBe(2048);
65
+ expect(system?.disk).toBe(20);
66
+ expect(system?.storage).toBe('local-lvm');
67
67
  });
68
68
 
69
69
  test('validates capability references', async () => {
@@ -26,7 +26,7 @@ export interface TemplateValidationResult {
26
26
  * Returns undefined if path doesn't exist
27
27
  *
28
28
  * @param obj - Object to traverse
29
- * @param path - Dot-separated path (e.g., 'requires.machine.cpu')
29
+ * @param path - Dot-separated path (e.g., 'requires.system.cpu')
30
30
  * @returns Value if found, undefined otherwise
31
31
  */
32
32
  function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
@@ -50,7 +50,7 @@ function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
50
50
  * Check if a path exists in the manifest
51
51
  *
52
52
  * @param manifest - Module manifest
53
- * @param path - Dot-separated path (e.g., 'requires.machine.cpu')
53
+ * @param path - Dot-separated path (e.g., 'requires.system.cpu')
54
54
  * @returns True if path exists in manifest
55
55
  */
56
56
  function pathExistsInManifest(manifest: ModuleManifest, path: string): boolean {
@@ -126,7 +126,7 @@ function validateSelfVariable(
126
126
  return `Self variable '${path}' not found in module configuration`;
127
127
  }
128
128
 
129
- // Check if it's a nested path (e.g., $self:requires.machine.cpu)
129
+ // Check if it's a nested path (e.g., $self:requires.system.cpu)
130
130
  if (pathExistsInManifest(manifest, path)) {
131
131
  return null; // Valid
132
132
  }
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
+ import { getSingularSystemSpec } from './schema';
2
3
  import {
3
4
  validateCapabilityNames,
4
5
  validateCapabilityRequirements,
@@ -42,7 +43,7 @@ description: HomeKit bridge for smart home devices
42
43
 
43
44
  requires:
44
45
  capabilities: []
45
- machine:
46
+ system:
46
47
  cpu: 1
47
48
  memory: 1024
48
49
  disk: 20
@@ -71,8 +72,8 @@ variables:
71
72
  if (result.success) {
72
73
  expect(result.data.id).toBe('homebridge');
73
74
  expect(result.data.variables.owns).toHaveLength(2);
74
- expect(result.data.requires.machine?.cpu).toBe(1);
75
- expect(result.data.requires.machine?.zone).toBe('app');
75
+ expect(result.data.requires.system?.cpu).toBe(1);
76
+ expect(result.data.requires.system?.zone).toBe('app');
76
77
  }
77
78
  });
78
79
 
@@ -442,7 +443,7 @@ build:
442
443
  }
443
444
  });
444
445
 
445
- test('should validate manifest with VM resource recommendations under requires.machine', () => {
446
+ test('should validate manifest with VM resources under requires.system (canonical name)', () => {
446
447
  const yaml = `
447
448
  ${CONTRACT_LINE}
448
449
  id: grafana
@@ -451,7 +452,7 @@ version: 1.0.0
451
452
 
452
453
  requires:
453
454
  capabilities: []
454
- machine:
455
+ system:
455
456
  cpu: 2
456
457
  memory: 2048
457
458
  disk: 20
@@ -463,11 +464,11 @@ requires:
463
464
 
464
465
  expect(result.success).toBe(true);
465
466
  if (result.success) {
466
- expect(result.data.requires.machine?.cpu).toBe(2);
467
- expect(result.data.requires.machine?.memory).toBe(2048);
468
- expect(result.data.requires.machine?.disk).toBe(20);
469
- expect(result.data.requires.machine?.storage).toBe('local-lvm');
470
- expect(result.data.requires.machine?.zone).toBe('app');
467
+ expect(result.data.requires.system?.cpu).toBe(2);
468
+ expect(result.data.requires.system?.memory).toBe(2048);
469
+ expect(result.data.requires.system?.zone).toBe('app');
470
+ // getSingularSystemSpec resolves the canonical field…
471
+ expect(getSingularSystemSpec(result.data)?.cpu).toBe(2);
471
472
  }
472
473
  });
473
474
 
@@ -480,7 +481,7 @@ version: 1.0.0
480
481
 
481
482
  requires:
482
483
  capabilities: []
483
- machine:
484
+ system:
484
485
  zone: dmz
485
486
  `;
486
487
 
@@ -488,9 +489,9 @@ requires:
488
489
 
489
490
  expect(result.success).toBe(true);
490
491
  if (result.success) {
491
- expect(result.data.requires.machine?.zone).toBe('dmz');
492
- expect(result.data.requires.machine?.cpu).toBeUndefined();
493
- expect(result.data.requires.machine?.memory).toBeUndefined();
492
+ expect(result.data.requires.system?.zone).toBe('dmz');
493
+ expect(result.data.requires.system?.cpu).toBeUndefined();
494
+ expect(result.data.requires.system?.memory).toBeUndefined();
494
495
  }
495
496
  });
496
497
 
@@ -503,7 +504,7 @@ version: 1.0.0
503
504
 
504
505
  requires:
505
506
  capabilities: []
506
- machine:
507
+ system:
507
508
  cpu: -2
508
509
  zone: app
509
510
  `;
@@ -521,7 +522,7 @@ version: 1.0.0
521
522
 
522
523
  requires:
523
524
  capabilities: []
524
- machine:
525
+ system:
525
526
  zone: invalid
526
527
  `;
527
528
 
@@ -538,7 +539,7 @@ version: 1.0.0
538
539
 
539
540
  requires:
540
541
  capabilities: []
541
- machine:
542
+ system:
542
543
  cpu: 2.5
543
544
  zone: app
544
545
  `;
@@ -3,7 +3,7 @@ import { parse as parseYaml } from 'yaml';
3
3
  import type { ZodError } from 'zod';
4
4
  import { validateModuleZoneRequirements } from '../services/zone-policy';
5
5
  import { resolveContract, supportedContractVersions } from './contracts';
6
- import { ModuleManifestSchema } from './schema';
6
+ import { ModuleManifestSchema, getSingularSystemSpec } from './schema';
7
7
  import type { ModuleManifest } from './schema';
8
8
 
9
9
  /**
@@ -304,16 +304,16 @@ export function validateVariableSources(manifest: ModuleManifest): ValidationErr
304
304
  export function validateZoneRequirements(manifest: ModuleManifest): ValidationError | null {
305
305
  const errors: Array<{ path: string; message: string }> = [];
306
306
 
307
- // Check if module has infrastructure spec (requires.machine)
308
- const hasInfrastructureSpec = manifest.requires?.machine;
307
+ // Check if module has infrastructure spec (requires.system)
308
+ const hasInfrastructureSpec = getSingularSystemSpec(manifest);
309
309
 
310
310
  // If module requires infrastructure, zone field is mandatory
311
311
  if (hasInfrastructureSpec) {
312
- const zone = manifest.requires?.machine?.zone;
312
+ const zone = hasInfrastructureSpec.zone;
313
313
 
314
314
  if (!zone) {
315
315
  errors.push({
316
- path: 'requires.machine.zone',
316
+ path: 'requires.system.zone',
317
317
  message: 'Zone field is required for modules with infrastructure requirements',
318
318
  });
319
319
  // Can't validate zone requirements without a zone
@@ -327,7 +327,7 @@ export function validateZoneRequirements(manifest: ModuleManifest): ValidationEr
327
327
 
328
328
  if (!zoneValidation.valid && zoneValidation.error) {
329
329
  errors.push({
330
- path: 'requires.machine.zone',
330
+ path: 'requires.system.zone',
331
331
  message: zoneValidation.error,
332
332
  });
333
333
  }
@@ -10,7 +10,7 @@ import { getModuleStoragePath } from '../config/paths';
10
10
  import { type DbClient, getDb } from '../db/client';
11
11
  import { capabilities, moduleIntegrity, modules } from '../db/schema';
12
12
  import type { NewModule, NewModuleIntegrity } from '../db/schema';
13
- import type { ModuleManifest } from '../manifest/schema';
13
+ import { type ModuleManifest, getSingularSystemSpec } from '../manifest/schema';
14
14
  import {
15
15
  validateCapabilityNames,
16
16
  validateDeriveFromSources,
@@ -348,7 +348,7 @@ export async function validateWellKnownCapabilities(
348
348
  }
349
349
 
350
350
  // Check 2: Zone enforcement - module must be in the correct zone
351
- const moduleZone = manifest.requires?.machine?.zone;
351
+ const moduleZone = getSingularSystemSpec(manifest)?.zone;
352
352
 
353
353
  if (wellKnown.zone_enforced && moduleZone) {
354
354
  if (moduleZone !== wellKnown.required_zone) {
@@ -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');
@@ -217,7 +217,13 @@ describe('celilo lifecycle events', () => {
217
217
  it('returns immediately when the deploy changed no routes', async () => {
218
218
  const since = routesChangedHighWater();
219
219
  const result = await waitForRouteReconcile(since, { timeoutMs: 1000 });
220
- expect(result).toEqual({ events: 0, succeeded: 0, failed: 0, timedOut: false });
220
+ expect(result).toEqual({
221
+ events: 0,
222
+ succeeded: 0,
223
+ failed: 0,
224
+ timedOut: false,
225
+ noDispatcher: false,
226
+ });
221
227
  });
222
228
 
223
229
  it('skips the wait (no time-out) when no dispatcher is running', async () => {
@@ -226,7 +232,13 @@ describe('celilo lifecycle events', () => {
226
232
  emitWebRoutesChanged('celilo-website');
227
233
  // No heartbeat stamped → health() is no_dispatcher.
228
234
  const result = await waitForRouteReconcile(since, { timeoutMs: 5000, pollMs: 50 });
229
- expect(result).toEqual({ events: 1, succeeded: 0, failed: 0, timedOut: false });
235
+ expect(result).toEqual({
236
+ events: 1,
237
+ succeeded: 0,
238
+ failed: 0,
239
+ timedOut: false,
240
+ noDispatcher: true,
241
+ });
230
242
  });
231
243
 
232
244
  it('settles immediately when no provider subscribes (zero deliveries)', async () => {
@@ -234,7 +246,13 @@ describe('celilo lifecycle events', () => {
234
246
  const since = routesChangedHighWater();
235
247
  emitWebRoutesChanged('celilo-website');
236
248
  const result = await waitForRouteReconcile(since, { timeoutMs: 1000, pollMs: 50 });
237
- expect(result).toEqual({ events: 1, succeeded: 0, failed: 0, timedOut: false });
249
+ expect(result).toEqual({
250
+ events: 1,
251
+ succeeded: 0,
252
+ failed: 0,
253
+ timedOut: false,
254
+ noDispatcher: false,
255
+ });
238
256
  });
239
257
 
240
258
  it('times out while the provider delivery is still pending', async () => {
@@ -255,7 +273,13 @@ describe('celilo lifecycle events', () => {
255
273
  settleDelivery('succeed');
256
274
 
257
275
  const result = await waitForRouteReconcile(since, { timeoutMs: 1000, pollMs: 50 });
258
- expect(result).toEqual({ events: 1, succeeded: 1, failed: 0, timedOut: false });
276
+ expect(result).toEqual({
277
+ events: 1,
278
+ succeeded: 1,
279
+ failed: 0,
280
+ timedOut: false,
281
+ noDispatcher: false,
282
+ });
259
283
  });
260
284
 
261
285
  it('reports a failed delivery without timing out', async () => {
@@ -284,7 +308,13 @@ describe('celilo lifecycle events', () => {
284
308
  settleDelivery('succeed');
285
309
 
286
310
  const result = await pending;
287
- expect(result).toEqual({ events: 1, succeeded: 1, failed: 0, timedOut: false });
311
+ expect(result).toEqual({
312
+ events: 1,
313
+ succeeded: 1,
314
+ failed: 0,
315
+ timedOut: false,
316
+ noDispatcher: false,
317
+ });
288
318
  });
289
319
  });
290
320
  });
@@ -222,6 +222,13 @@ export interface RouteReconcileWaitResult {
222
222
  succeeded: number;
223
223
  failed: number;
224
224
  timedOut: boolean;
225
+ /**
226
+ * True when no event dispatcher was running, so the `routes_changed` event
227
+ * was persisted but never DELIVERED to the provider (caddy). The route is
228
+ * therefore NOT live. Authoritative callers (ISS-0081) must treat this as a
229
+ * failure, not a benign "0 deliveries" success.
230
+ */
231
+ noDispatcher: boolean;
225
232
  }
226
233
 
227
234
  /**
@@ -283,14 +290,20 @@ export async function waitForRouteReconcile(
283
290
  .filter((e) => e.id > sinceEventId);
284
291
 
285
292
  if (events.length === 0) {
286
- return { events: 0, succeeded: 0, failed: 0, timedOut: false };
293
+ return { events: 0, succeeded: 0, failed: 0, timedOut: false, noDispatcher: false };
287
294
  }
288
295
 
289
296
  if (b.health().status === 'no_dispatcher') {
290
297
  console.warn(
291
298
  `[celilo] route-reconcile: no dispatcher running — not waiting; ${events.length} change event(s) persisted, the provider will reconcile on its next run`,
292
299
  );
293
- return { events: events.length, succeeded: 0, failed: 0, timedOut: false };
300
+ return {
301
+ events: events.length,
302
+ succeeded: 0,
303
+ failed: 0,
304
+ timedOut: false,
305
+ noDispatcher: true,
306
+ };
294
307
  }
295
308
 
296
309
  while (true) {
@@ -306,6 +319,7 @@ export async function waitForRouteReconcile(
306
319
  succeeded: settled('succeeded'),
307
320
  failed: settled('failed') + settled('abandoned'),
308
321
  timedOut: pending > 0,
322
+ noDispatcher: false,
309
323
  };
310
324
  }
311
325
 
@@ -314,7 +328,7 @@ export async function waitForRouteReconcile(
314
328
  } catch (err) {
315
329
  const msg = err instanceof Error ? err.message : String(err);
316
330
  console.warn(`[celilo] route-reconcile wait errored: ${msg}`);
317
- return { events: 0, succeeded: 0, failed: 0, timedOut: false };
331
+ return { events: 0, succeeded: 0, failed: 0, timedOut: false, noDispatcher: false };
318
332
  } finally {
319
333
  bus?.close();
320
334
  }
@@ -147,11 +147,41 @@ 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 LEAN module source (excludes generated/, node_modules/, large build artifacts)', 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', 'node_modules', '@celilo'), { recursive: true });
159
+ mkdirSync(join(modSrc, 'generated', 'terraform'), { recursive: true });
160
+ mkdirSync(join(modSrc, 'ansible', 'files'), { recursive: true });
161
+ writeFileSync(join(modSrc, 'manifest.yml'), 'id: caddy');
162
+ writeFileSync(join(modSrc, 'scripts', 'hook.ts'), '// hook');
163
+ writeFileSync(join(modSrc, 'generated', 'terraform', 'main.tf'), 'resource {}');
164
+ writeFileSync(join(modSrc, 'scripts', 'node_modules', '@celilo', 'dep.js'), '// vendored');
165
+ // A >2MB "compiled binary" sitting in source — must be skipped by size.
166
+ writeFileSync(join(modSrc, 'ansible', 'files', 'server-bin'), Buffer.alloc(3 * 1024 * 1024));
167
+
168
+ const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
169
+ await hook(buildContext({ backup_dir: backupDir, cross_module_root: crossModuleRoot }));
170
+
171
+ // Source files captured...
172
+ expect(existsSync(join(backupDir, 'module_src', 'caddy', 'manifest.yml'))).toBe(true);
173
+ expect(existsSync(join(backupDir, 'module_src', 'caddy', 'scripts', 'hook.ts'))).toBe(true);
174
+ // ...but build/vendored content excluded (keeps the backup small enough to
175
+ // not OOM the in-memory tar+encrypt — turnip's full dirs were ~1.6GB).
176
+ expect(existsSync(join(backupDir, 'module_src', 'caddy', 'generated'))).toBe(false);
177
+ expect(existsSync(join(backupDir, 'module_src', 'caddy', 'scripts', 'node_modules'))).toBe(
178
+ false,
179
+ );
180
+ expect(
181
+ existsSync(join(backupDir, 'module_src', 'caddy', 'ansible', 'files', 'server-bin')),
182
+ ).toBe(false);
183
+ });
184
+
155
185
  it('machine-pool.json is valid JSON (array)', async () => {
156
186
  const { default: hook } = await import(`${HOOK_DIR}/on_backup.ts`);
157
187
  await hook(buildContext({ backup_dir: backupDir, cross_module_root: crossModuleRoot }));
@@ -168,7 +198,7 @@ describe('celilo-mgmt on_backup', () => {
168
198
 
169
199
  expect(existsSync(join(backupDir, 'celilo.db'))).toBe(true);
170
200
  expect(existsSync(join(backupDir, 'cross_module_state'))).toBe(false);
171
- expect(result.schema_version).toBe('1.0');
201
+ expect(result.schema_version).toBe('1.1');
172
202
  });
173
203
 
174
204
  it('proceeds (with a warning) when master.key is missing on disk', async () => {
@@ -26,7 +26,7 @@ import { eq } from 'drizzle-orm';
26
26
  import type { DbClient } from '../db/client';
27
27
  import { getDb } from '../db/client';
28
28
  import { capabilities, moduleConfigs, modules, secrets } from '../db/schema';
29
- import type { ModuleManifest } from '../manifest/schema';
29
+ import { type ModuleManifest, getSingularSystemSpec } from '../manifest/schema';
30
30
  import { buildResolutionContext } from '../variables/context';
31
31
  import { E2E_CONFLICT_FIX, runningE2eContainers } from './e2e-guard';
32
32
 
@@ -241,8 +241,9 @@ export async function runPreflight(
241
241
  }
242
242
 
243
243
  // 5. Infrastructure availability (for modules that need it)
244
- if (manifest.requires?.machine) {
245
- const zone = manifest.requires.machine.zone;
244
+ const systemSpec = getSingularSystemSpec(manifest);
245
+ if (systemSpec) {
246
+ const zone = systemSpec.zone;
246
247
  if (zone) {
247
248
  // Simplified check: is there a machine or service for the module's zone?
248
249
  // The full infrastructure selector (selectInfrastructure) needs a Module
@@ -60,7 +60,7 @@ function seedProxmoxModule(
60
60
  id: opts.id,
61
61
  name: opts.id,
62
62
  version: '1.0.0',
63
- manifestData: { requires: { machine: { zone: opts.zone } } },
63
+ manifestData: { requires: { system: { zone: opts.zone } } },
64
64
  sourcePath: `/tmp/${opts.id}`,
65
65
  state: 'VERIFIED',
66
66
  })
@@ -180,7 +180,7 @@ describe('backfillModuleSystems', () => {
180
180
  id: 'technitium',
181
181
  name: 'technitium',
182
182
  version: '1.0.0',
183
- manifestData: { requires: { machine: { zone: 'internal' } } },
183
+ manifestData: { requires: { system: { zone: 'internal' } } },
184
184
  sourcePath: '/tmp/technitium',
185
185
  state: 'VERIFIED',
186
186
  })