@celilo/cli 0.2.0 → 0.3.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.
@@ -244,10 +244,61 @@ async function invokeHookWithEnsureRetry(
244
244
  * @param options - Deployment options
245
245
  * @returns Deployment result with phase tracking
246
246
  */
247
+ /**
248
+ * Public entry point. Wraps the core deploy work with event-bus
249
+ * lifecycle emits (`deploy.started.<id>`, `deploy.completed.<id>`,
250
+ * `deploy.failed.<id>`) so subscribers — production smoke tests,
251
+ * alerting, etc. — can react. Bus emit failures are best-effort and
252
+ * never affect the deploy outcome.
253
+ */
247
254
  export async function deployModule(
248
255
  moduleId: string,
249
256
  db: DbClient,
250
257
  options: DeployOptions = {},
258
+ ): Promise<DeployResult> {
259
+ const startedAt = Date.now();
260
+ const { emitDeployCompleted, emitDeployFailed, emitDeployStarted, emitHealthCheckFailed } =
261
+ await import('./celilo-events');
262
+
263
+ emitDeployStarted({ module: moduleId, startedAt });
264
+
265
+ let result: DeployResult;
266
+ try {
267
+ result = await deployModuleImpl(moduleId, db, options);
268
+ } catch (err) {
269
+ const error = err instanceof Error ? err.message : String(err);
270
+ emitDeployFailed({
271
+ module: moduleId,
272
+ startedAt,
273
+ durationMs: Date.now() - startedAt,
274
+ error,
275
+ });
276
+ throw err;
277
+ }
278
+
279
+ const durationMs = Date.now() - startedAt;
280
+ if (result.success) {
281
+ emitDeployCompleted({ module: moduleId, startedAt, durationMs });
282
+ } else {
283
+ emitDeployFailed({
284
+ module: moduleId,
285
+ startedAt,
286
+ durationMs,
287
+ error: result.error ?? 'unknown error',
288
+ });
289
+ // Health-check failures are a sub-class worth surfacing on their own
290
+ // channel so subscribers can target them without parsing error strings.
291
+ if (result.error?.toLowerCase().includes('health check')) {
292
+ emitHealthCheckFailed({ module: moduleId, reason: result.error });
293
+ }
294
+ }
295
+ return result;
296
+ }
297
+
298
+ async function deployModuleImpl(
299
+ moduleId: string,
300
+ db: DbClient,
301
+ options: DeployOptions = {},
251
302
  ): Promise<DeployResult> {
252
303
  const phases: DeployResult['phases'] = {};
253
304
 
@@ -295,7 +346,6 @@ export async function deployModule(
295
346
  }
296
347
  }
297
348
 
298
- log.info(`Deploying module: ${moduleId}`);
299
349
  const validation = await validateAndPrepareDeployment(moduleId, db);
300
350
  phases.validation = validation.success;
301
351
  phases.autoGenerated = validation.autoGenerated;
@@ -469,11 +519,9 @@ export async function deployModule(
469
519
  }
470
520
  }
471
521
 
472
- log.success('Validation passed:');
473
- log.message(' Templates generated');
474
- if (validation.autoBuilt) {
475
- log.message(' Module built');
476
- }
522
+ log.success(
523
+ validation.autoBuilt ? 'Templates generated and module built' : 'Templates generated',
524
+ );
477
525
 
478
526
  // Run validate_config hook if defined (e.g., credential validation via Playwright)
479
527
  if (manifest.hooks?.validate_config) {
@@ -613,7 +661,7 @@ export async function deployModule(
613
661
  // Run on_install hook for config-only modules (e.g. publishing static files to caddy)
614
662
  if (manifest.hooks?.on_install) {
615
663
  const onInstallDef = manifest.hooks.on_install;
616
- log.info('Running on_install hook...');
664
+ log.success('Running on_install hook');
617
665
 
618
666
  const { moduleConfigs: pcTable, secrets: secretsTable } = await import('../db/schema');
619
667
  const installConfigs = db
@@ -695,7 +743,6 @@ export async function deployModule(
695
743
  // Run health checks for config-only modules too
696
744
  if (manifest.hooks?.health_check) {
697
745
  const { runModuleHealthCheck } = await import('./health-runner');
698
- log.info('Running health checks...');
699
746
  const healthResult = await runModuleHealthCheck(moduleId, db, {
700
747
  debug: options.debug,
701
748
  noInteractive: options.noInteractive,
@@ -710,7 +757,6 @@ export async function deployModule(
710
757
  .join('\n');
711
758
  return { success: false, phases, error: `Health checks failed:\n${failedChecks}` };
712
759
  }
713
- log.success('Health checks passed → VERIFIED');
714
760
  }
715
761
 
716
762
  return {
@@ -855,7 +901,7 @@ export async function deployModule(
855
901
 
856
902
  if (!containerCreatedHook) continue;
857
903
 
858
- log.info(
904
+ log.message(
859
905
  `Running container_created hook on ${capRecord.moduleId} (provides ${requiredCap.name})`,
860
906
  );
861
907
 
@@ -988,7 +1034,6 @@ export async function deployModule(
988
1034
  let machineId: string | undefined;
989
1035
  if (plan.infrastructure?.type === 'machine' && plan.infrastructure.machineId) {
990
1036
  machineId = plan.infrastructure.machineId;
991
- log.info(`Writing temporary SSH key for machine: ${machineId}`);
992
1037
  await writeTemporarySshKey(machineId);
993
1038
  }
994
1039
 
@@ -1012,15 +1057,16 @@ export async function deployModule(
1012
1057
  plan.infrastructure?.type === 'container_service' &&
1013
1058
  plan.infrastructure.serviceId
1014
1059
  ) {
1015
- // TODO: Extract Terraform outputs and update module_infrastructure.containerMetadata
1016
- // This will be implemented when we add Terraform output parsing
1017
- log.warn('Container service metadata update not yet implemented');
1060
+ // TODO: extract Terraform outputs and persist them on
1061
+ // module_infrastructure.containerMetadata. Until that lands,
1062
+ // the deploy still succeeds we just don't track which
1063
+ // container ID/IP got assigned per module in the DB.
1018
1064
  }
1019
1065
 
1020
1066
  // Run on_install hook (post-deploy actions like port forwarding, DNS registration)
1021
1067
  if (manifest.hooks?.on_install) {
1022
1068
  const onInstallDef = manifest.hooks.on_install;
1023
- log.info('Running on_install hook...');
1069
+ log.success('Running on_install hook');
1024
1070
 
1025
1071
  const { moduleConfigs: pcTable, secrets: secretsTable } = await import('../db/schema');
1026
1072
  const installConfigs = db
@@ -1120,7 +1166,6 @@ export async function deployModule(
1120
1166
  // Run health checks after successful deployment
1121
1167
  if (manifest.hooks?.health_check) {
1122
1168
  const { runModuleHealthCheck } = await import('./health-runner');
1123
- log.info('Running post-deploy health checks...');
1124
1169
  const healthResult = await runModuleHealthCheck(moduleId, db, {
1125
1170
  debug: options.debug,
1126
1171
  noInteractive: options.noInteractive,
@@ -1144,10 +1189,6 @@ export async function deployModule(
1144
1189
  error: `Health checks failed:\n${failedChecks}`,
1145
1190
  };
1146
1191
  }
1147
- const summary = healthResult.checks
1148
- .map((c) => ` ${c.status === 'pass' ? '✓' : '⚠'} ${c.name}`)
1149
- .join('\n');
1150
- log.success(`Health checks passed → VERIFIED\n${summary}`);
1151
1192
  }
1152
1193
 
1153
1194
  // Auto-register module hostname in internal DNS (if available)
@@ -1167,7 +1208,7 @@ export async function deployModule(
1167
1208
  );
1168
1209
  }
1169
1210
 
1170
- display.instantEvent(`\x1b[32m✓\x1b[0m Module '${moduleId}' deployed successfully`);
1211
+ log.success(`Module '${moduleId}' deployed successfully`);
1171
1212
  return {
1172
1213
  success: true,
1173
1214
  phases,
@@ -1175,7 +1216,6 @@ export async function deployModule(
1175
1216
  } finally {
1176
1217
  // Clean up temporary SSH key
1177
1218
  if (machineId) {
1178
- log.info(`Cleaning up temporary SSH key for machine: ${machineId}`);
1179
1219
  deleteTemporarySshKey(machineId);
1180
1220
  }
1181
1221
  }
@@ -0,0 +1,197 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2
+ import { mkdtempSync, rmSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { defineEvents, openBus } from '@celilo/event-bus';
6
+ import type { ModuleManifest } from '../manifest/schema';
7
+ import {
8
+ registerModuleSubscriptions,
9
+ resolveSubscription,
10
+ unregisterModuleSubscriptions,
11
+ } from './module-subscriptions';
12
+
13
+ const baseManifest = (overrides: Partial<ModuleManifest> = {}): ModuleManifest => ({
14
+ celilo_contract: '1.0',
15
+ id: 'lunacycle',
16
+ name: 'Lunacycle',
17
+ version: '1.0.0',
18
+ requires: { capabilities: [] },
19
+ provides: { capabilities: [] },
20
+ variables: { owns: [], imports: [] },
21
+ ...overrides,
22
+ });
23
+
24
+ describe('resolveSubscription', () => {
25
+ it('substitutes $self in pattern and ${MODULE_PATH} in handler', () => {
26
+ const resolved = resolveSubscription(
27
+ {
28
+ name: 'smoke-after-deploy',
29
+ pattern: 'deploy.completed.$self',
30
+ handler: 'bun ${MODULE_PATH}/celilo/scripts/smoke.ts',
31
+ },
32
+ 'lunacycle',
33
+ '/var/lib/celilo/modules/lunacycle',
34
+ );
35
+
36
+ expect(resolved.name).toBe('lunacycle.smoke-after-deploy');
37
+ expect(resolved.pattern).toBe('deploy.completed.lunacycle');
38
+ expect(resolved.handler).toBe('bun /var/lib/celilo/modules/lunacycle/celilo/scripts/smoke.ts');
39
+ expect(resolved.registeredBy).toBe('lunacycle');
40
+ });
41
+
42
+ it('only substitutes $self when followed by . or end-of-string', () => {
43
+ // `$selfish` would not be a real pattern but we want to ensure the
44
+ // substitution doesn't accidentally rewrite identifier-like names.
45
+ const resolved = resolveSubscription(
46
+ {
47
+ name: 'a',
48
+ pattern: '$self.x.$selfish',
49
+ handler: 'echo',
50
+ },
51
+ 'foo',
52
+ '/p',
53
+ );
54
+ expect(resolved.pattern).toBe('foo.x.$selfish');
55
+ });
56
+
57
+ it('passes through max_attempts and timeout_ms when set', () => {
58
+ const resolved = resolveSubscription(
59
+ {
60
+ name: 'a',
61
+ pattern: 'x',
62
+ handler: 'echo',
63
+ max_attempts: 5,
64
+ timeout_ms: 90000,
65
+ },
66
+ 'foo',
67
+ '/p',
68
+ );
69
+ expect(resolved.maxAttempts).toBe(5);
70
+ expect(resolved.timeoutMs).toBe(90000);
71
+ });
72
+ });
73
+
74
+ describe('register / unregister roundtrip', () => {
75
+ let dir: string;
76
+ let dbPath: string;
77
+
78
+ beforeEach(() => {
79
+ dir = mkdtempSync(join(tmpdir(), 'modsubs-test-'));
80
+ dbPath = join(dir, 'events.db');
81
+ process.env.CELILO_EVENT_BUS_PATH = dbPath;
82
+ });
83
+ afterEach(() => {
84
+ process.env.CELILO_EVENT_BUS_PATH = undefined;
85
+ try {
86
+ rmSync(dir, { recursive: true, force: true });
87
+ } catch {
88
+ /* ignore */
89
+ }
90
+ });
91
+
92
+ it('manifest with no subscriptions is a no-op', () => {
93
+ const result = registerModuleSubscriptions(baseManifest(), '/p');
94
+ expect(result.registered).toBe(0);
95
+ });
96
+
97
+ it('registers each subscription as a row, names scoped to module id', () => {
98
+ const result = registerModuleSubscriptions(
99
+ baseManifest({
100
+ subscriptions: [
101
+ {
102
+ name: 'smoke',
103
+ pattern: 'deploy.completed.$self',
104
+ handler: 'bun ${MODULE_PATH}/x.ts',
105
+ },
106
+ {
107
+ name: 'on-cert-rotated',
108
+ pattern: 'cert.rotated',
109
+ handler: 'echo',
110
+ },
111
+ ],
112
+ }),
113
+ '/var/lib/celilo/modules/lunacycle',
114
+ );
115
+ expect(result.registered).toBe(2);
116
+
117
+ const bus = openBus({ dbPath, events: defineEvents({}) });
118
+ try {
119
+ const rows = bus.db
120
+ .query<{ name: string; pattern: string; handler: string }, []>(
121
+ 'SELECT name, pattern, handler FROM subscribers ORDER BY name',
122
+ )
123
+ .all();
124
+ expect(rows).toEqual([
125
+ {
126
+ name: 'lunacycle.on-cert-rotated',
127
+ pattern: 'cert.rotated',
128
+ handler: 'echo',
129
+ },
130
+ {
131
+ name: 'lunacycle.smoke',
132
+ pattern: 'deploy.completed.lunacycle',
133
+ handler: 'bun /var/lib/celilo/modules/lunacycle/x.ts',
134
+ },
135
+ ]);
136
+ } finally {
137
+ bus.close();
138
+ }
139
+ });
140
+
141
+ it('re-registering the same manifest is idempotent (updates rows in place)', () => {
142
+ const m = baseManifest({
143
+ subscriptions: [{ name: 's', pattern: 'a', handler: 'echo first' }],
144
+ });
145
+ registerModuleSubscriptions(m, '/p');
146
+ // Edit the in-memory manifest and re-register; same name, new handler.
147
+ if (!m.subscriptions) throw new Error('subscriptions missing');
148
+ m.subscriptions[0].handler = 'echo second';
149
+ registerModuleSubscriptions(m, '/p');
150
+
151
+ const bus = openBus({ dbPath, events: defineEvents({}) });
152
+ try {
153
+ const rows = bus.db
154
+ .query<{ count: number }, []>('SELECT COUNT(*) AS count FROM subscribers')
155
+ .get();
156
+ expect(rows?.count).toBe(1);
157
+ const row = bus.db.query<{ handler: string }, []>('SELECT handler FROM subscribers').get();
158
+ expect(row?.handler).toBe('echo second');
159
+ } finally {
160
+ bus.close();
161
+ }
162
+ });
163
+
164
+ it('unregister removes all subscriptions for the module, scoped by name prefix', () => {
165
+ registerModuleSubscriptions(
166
+ baseManifest({
167
+ id: 'lunacycle',
168
+ subscriptions: [
169
+ { name: 'a', pattern: 'x.$self', handler: 'echo' },
170
+ { name: 'b', pattern: 'y.$self', handler: 'echo' },
171
+ ],
172
+ }),
173
+ '/p',
174
+ );
175
+ // Another module's subs should NOT be touched.
176
+ registerModuleSubscriptions(
177
+ baseManifest({
178
+ id: 'authentik',
179
+ subscriptions: [{ name: 'a', pattern: 'z.$self', handler: 'echo' }],
180
+ }),
181
+ '/p',
182
+ );
183
+
184
+ const result = unregisterModuleSubscriptions('lunacycle');
185
+ expect(result.unregistered).toBe(2);
186
+
187
+ const bus = openBus({ dbPath, events: defineEvents({}) });
188
+ try {
189
+ const rows = bus.db
190
+ .query<{ name: string }, []>('SELECT name FROM subscribers ORDER BY name')
191
+ .all();
192
+ expect(rows).toEqual([{ name: 'authentik.a' }]);
193
+ } finally {
194
+ bus.close();
195
+ }
196
+ });
197
+ });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Wire a module's manifest `subscriptions:` block into the SQLite event
3
+ * bus. Called from `module/import.ts` on install and from
4
+ * `cli/commands/module-remove.ts` on remove.
5
+ *
6
+ * Substitutions performed at subscribe time:
7
+ * - `$self` in `pattern` → the module's id
8
+ * - `${MODULE_PATH}` in `handler` → the module's installed targetPath
9
+ *
10
+ * The bus subscriber's name is namespaced as `<module-id>.<sub-name>`
11
+ * so two modules can declare a subscription named `smoke` without
12
+ * colliding.
13
+ */
14
+
15
+ import { defineEvents, openBus } from '@celilo/event-bus';
16
+ import { getEventBusPath } from '../config/paths';
17
+ import type { ModuleManifest, ModuleSubscription } from '../manifest/schema';
18
+
19
+ /**
20
+ * The bus is opened by the celilo CLI without an event registry — the
21
+ * CLI doesn't know the schemas of every module's events. The bus's
22
+ * empty-registry path skips payload validation, leaving that to the
23
+ * linked handlers (which open the bus *with* their own registry).
24
+ */
25
+ const NO_SCHEMAS = defineEvents({});
26
+
27
+ /**
28
+ * Resolve the per-module substitutions on a single subscription. Pure
29
+ * function: takes a parsed manifest entry, returns the bus-shaped
30
+ * subscribe options.
31
+ */
32
+ export function resolveSubscription(
33
+ sub: ModuleSubscription,
34
+ moduleId: string,
35
+ modulePath: string,
36
+ ): {
37
+ name: string;
38
+ pattern: string;
39
+ handler: string;
40
+ maxAttempts?: number;
41
+ timeoutMs?: number;
42
+ registeredBy: string;
43
+ } {
44
+ return {
45
+ name: scopedName(moduleId, sub.name),
46
+ pattern: substituteSelf(sub.pattern, moduleId),
47
+ handler: substituteModulePath(sub.handler, modulePath),
48
+ maxAttempts: sub.max_attempts,
49
+ timeoutMs: sub.timeout_ms,
50
+ registeredBy: moduleId,
51
+ };
52
+ }
53
+
54
+ /**
55
+ * Register all of a module's subscriptions on the bus. Idempotent —
56
+ * re-running with the same manifest updates existing rows in place.
57
+ *
58
+ * If the module's manifest declares no subscriptions, this is a
59
+ * cheap no-op (the bus DB isn't even touched).
60
+ */
61
+ export function registerModuleSubscriptions(
62
+ manifest: ModuleManifest,
63
+ modulePath: string,
64
+ ): { registered: number } {
65
+ const subs = manifest.subscriptions ?? [];
66
+ if (subs.length === 0) return { registered: 0 };
67
+
68
+ const bus = openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS });
69
+ try {
70
+ for (const sub of subs) {
71
+ const resolved = resolveSubscription(sub, manifest.id, modulePath);
72
+ bus.subscribe(resolved);
73
+ }
74
+ return { registered: subs.length };
75
+ } finally {
76
+ bus.close();
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Tear down every bus subscription that belongs to this module. Looks
82
+ * up rows by name prefix `<module-id>.` rather than rereading the
83
+ * old manifest, so a stale subscription left behind by a manifest
84
+ * change still gets cleaned up.
85
+ *
86
+ * Best-effort: if the bus DB doesn't exist (never opened), returns 0.
87
+ */
88
+ export function unregisterModuleSubscriptions(moduleId: string): {
89
+ unregistered: number;
90
+ } {
91
+ const bus = openBus({ dbPath: getEventBusPath(), events: NO_SCHEMAS });
92
+ try {
93
+ const likePattern = `${moduleId}.%`;
94
+ const rows = bus.db
95
+ .query<{ name: string }, [string]>('SELECT name FROM subscribers WHERE name LIKE ?')
96
+ .all(likePattern);
97
+ for (const row of rows) {
98
+ bus.unsubscribe(row.name);
99
+ }
100
+ return { unregistered: rows.length };
101
+ } finally {
102
+ bus.close();
103
+ }
104
+ }
105
+
106
+ function scopedName(moduleId: string, subName: string): string {
107
+ return `${moduleId}.${subName}`;
108
+ }
109
+
110
+ function substituteSelf(pattern: string, moduleId: string): string {
111
+ // `$self` matches when followed by a dot or end-of-string, so a
112
+ // pattern like `deploy.$self.foo` substitutes correctly without
113
+ // confusing `$selfish` if anyone wrote that. (No real reason they
114
+ // would, but be precise.)
115
+ return pattern.replace(/\$self(?=\.|$)/g, moduleId);
116
+ }
117
+
118
+ function substituteModulePath(handler: string, modulePath: string): string {
119
+ return handler.replace(/\$\{MODULE_PATH\}/g, modulePath);
120
+ }
@@ -491,9 +491,6 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
491
491
  }
492
492
  }
493
493
  } else {
494
- // Module doesn't require infrastructure provisioning (e.g., VPS-based modules)
495
- // Skip infrastructure selection
496
- log.info('Skipping infrastructure selection (no requires.machine specified)');
497
494
  infrastructureSelection = undefined;
498
495
  }
499
496
 
@@ -525,12 +522,12 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
525
522
  if (!allocation) {
526
523
  // First generation - allocate VMID and IP
527
524
  allocation = await allocateForModule(moduleId, zone, db, db.$client);
528
- log.info(
525
+ log.success(
529
526
  `Allocated VMID ${allocation.vmid} and IP ${allocation.containerIp} for ${moduleId}`,
530
527
  );
531
528
  } else {
532
529
  // Reuse existing allocation
533
- log.info(
530
+ log.success(
534
531
  `Using existing allocation: VMID ${allocation.vmid}, IP ${allocation.containerIp}`,
535
532
  );
536
533
  }
@@ -615,7 +612,7 @@ export async function generateTemplates(options: GenerateOptions): Promise<Gener
615
612
  .run();
616
613
  }
617
614
 
618
- log.info(
615
+ log.success(
619
616
  `Infrastructure properties resolved from service: target_node=${providerConfig.default_target_node}`,
620
617
  );
621
618
  }
@@ -940,11 +940,13 @@ describe('applyDeclarativeDerivations', () => {
940
940
  });
941
941
  });
942
942
 
943
- describe('$self: directly in derive_from', () => {
944
- // $self: in derive_from is a manifest authoring error substituteVariables
945
- // does not handle $self:, so it passes through all resolution passes unchanged.
946
- // The hasUnresolved check must prevent it from poisoning selfConfig.
947
- test('does not set selfConfig when derive_from contains $self: directly', () => {
943
+ describe('$self: in derive_from', () => {
944
+ // $self: is the canonical reference syntax in capability data blocks
945
+ // (e.g. authentik's `data.auth_url: $self:auth_url`), so it must
946
+ // resolve in derive_from too otherwise capability-sourced variables
947
+ // that derive from another self variable can't be persisted.
948
+ // Equivalent to the {var} syntax; reads from selfConfig.
949
+ test('resolves $self: against selfConfig like {var}', () => {
948
950
  const manifest: ModuleManifest = {
949
951
  celilo_contract: '1.0',
950
952
  id: 'test-module',
@@ -959,7 +961,7 @@ describe('applyDeclarativeDerivations', () => {
959
961
  type: 'string',
960
962
  required: false,
961
963
  source: 'user',
962
- derive_from: '$self:hostname', // Wrong syntax — $self: is not supported in derive_from
964
+ derive_from: '$self:hostname',
963
965
  },
964
966
  ],
965
967
  imports: [],
@@ -977,8 +979,91 @@ describe('applyDeclarativeDerivations', () => {
977
979
 
978
980
  applyDeclarativeDerivations(manifest, context);
979
981
 
980
- // $self: passes through unresolved; must not poison selfConfig
981
- expect(context.selfConfig.hostname_copy).toBeUndefined();
982
+ expect(context.selfConfig.hostname_copy).toBe('my-host');
983
+ });
984
+
985
+ test('resolves $self: inside string interpolation (authentik auth_url shape)', () => {
986
+ // Mirrors the authentik manifest pattern that motivated the fix:
987
+ // auth_url derives from "https://auth.$self:domain" where domain
988
+ // is another self-owned variable set by the user.
989
+ const manifest: ModuleManifest = {
990
+ celilo_contract: '1.0',
991
+ id: 'authentik',
992
+ name: 'Authentik',
993
+ version: '1.0.0',
994
+ requires: { capabilities: [] },
995
+ provides: { capabilities: [] },
996
+ variables: {
997
+ owns: [
998
+ {
999
+ name: 'domain',
1000
+ type: 'string',
1001
+ required: true,
1002
+ source: 'user',
1003
+ },
1004
+ {
1005
+ name: 'auth_url',
1006
+ type: 'string',
1007
+ required: false,
1008
+ source: 'capability',
1009
+ derive_from: 'https://auth.$self:domain',
1010
+ },
1011
+ ],
1012
+ imports: [],
1013
+ },
1014
+ };
1015
+
1016
+ const context: ResolutionContext = {
1017
+ moduleId: 'authentik',
1018
+ selfConfig: { domain: 'iamtheinternet.org' },
1019
+ systemConfig: {},
1020
+ systemSecrets: {},
1021
+ secrets: {},
1022
+ capabilities: {},
1023
+ };
1024
+
1025
+ applyDeclarativeDerivations(manifest, context);
1026
+
1027
+ expect(context.selfConfig.auth_url).toBe('https://auth.iamtheinternet.org');
1028
+ });
1029
+
1030
+ test('throws when $self: references an undeclared variable', () => {
1031
+ const manifest: ModuleManifest = {
1032
+ celilo_contract: '1.0',
1033
+ id: 'test',
1034
+ name: 'Test',
1035
+ version: '1.0.0',
1036
+ requires: { capabilities: [] },
1037
+ provides: { capabilities: [] },
1038
+ variables: {
1039
+ owns: [
1040
+ {
1041
+ name: 'derived',
1042
+ type: 'string',
1043
+ required: false,
1044
+ source: 'user',
1045
+ derive_from: '$self:nonexistent',
1046
+ },
1047
+ ],
1048
+ imports: [],
1049
+ },
1050
+ };
1051
+
1052
+ const context: ResolutionContext = {
1053
+ moduleId: 'test',
1054
+ selfConfig: {},
1055
+ systemConfig: {},
1056
+ systemSecrets: {},
1057
+ secrets: {},
1058
+ capabilities: {},
1059
+ };
1060
+
1061
+ // Optional variable + missing dep → derivation fails silently per
1062
+ // applyDeclarativeDerivations' optional-variable rule. The error
1063
+ // is thrown from substituteVariables but caught by the optional
1064
+ // branch in applyDeclarativeDerivations, leaving selfConfig clean.
1065
+ applyDeclarativeDerivations(manifest, context);
1066
+ expect(context.selfConfig.derived).toBeUndefined();
982
1067
  });
983
1068
  });
984
1069
 
@@ -65,6 +65,21 @@ function substituteVariables(
65
65
  return value;
66
66
  });
67
67
 
68
+ // Replace $self:key patterns (both $self:key and ${self:key} forms).
69
+ // Same lookup target as the {var} syntax below — both read selfConfig —
70
+ // but $self: is the form that appears in user-authored manifests and
71
+ // capability data blocks, so it must resolve here too. Without this,
72
+ // a `derive_from: "https://auth.$self:domain"` stays literally
73
+ // unresolved and the defensive guard in applyDeclarativeDerivations
74
+ // refuses to store it, breaking downstream capability consumers.
75
+ result = result.replace(/\$\{?self:([a-zA-Z0-9_]+)\}?/g, (_match, key) => {
76
+ const value = context.selfConfig[key];
77
+ if (value === undefined) {
78
+ throw new Error(`Missing self variable: ${key} (required by variable '${variableName}')`);
79
+ }
80
+ return value;
81
+ });
82
+
68
83
  // Replace {variable_name} patterns
69
84
  result = result.replace(/\{([a-zA-Z0-9_]+)\}/g, (_match, varName) => {
70
85
  const value = context.selfConfig[varName];