@celilo/cli 0.1.9 → 0.2.1

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.1.9",
3
+ "version": "0.2.1",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -46,8 +46,8 @@
46
46
  },
47
47
  "dependencies": {
48
48
  "@aws-sdk/client-s3": "^3.1024.0",
49
- "@celilo/capabilities": "^0.1.4",
50
- "@celilo/cli-display": "0.1.4",
49
+ "@celilo/capabilities": "^0.1.9",
50
+ "@celilo/cli-display": "^0.1.4",
51
51
  "@clack/prompts": "^1.1.0",
52
52
  "ajv": "^8.18.0",
53
53
  "drizzle-orm": "^0.36.4",
@@ -182,7 +182,7 @@ describe('Well-Known Capabilities Registry', () => {
182
182
 
183
183
  expect(schema).toEqual({
184
184
  provider: 'namecheap',
185
- primary_domain: '$self:primary_domain',
185
+ domains: '$self:domains',
186
186
  supports: ['dynamic_dns_a_record'],
187
187
  });
188
188
  });
@@ -58,8 +58,16 @@ export const WELL_KNOWN_CAPABILITIES: Record<string, WellKnownCapability> = {
58
58
  required_zone: 'dmz',
59
59
  zone_enforced: false,
60
60
  data_schema: {
61
+ // The capability contract no longer carries a `primary_domain`
62
+ // convenience field. Consumers that want a default index into
63
+ // `domains[]` themselves; consumers that need to commit to a
64
+ // specific domain (lunacycle, authentik, knot-unbound-internal,
65
+ // technitium, etc.) declare an explicit user-set `domain`
66
+ // variable in their own manifest. The implicit primary-domain
67
+ // hand-off too easily gave a module the household's first
68
+ // registered domain without anyone deciding to.
61
69
  provider: 'namecheap',
62
- primary_domain: '$self:primary_domain',
70
+ domains: '$self:domains',
63
71
  supports: ['dynamic_dns_a_record'],
64
72
  },
65
73
  },
@@ -91,9 +99,9 @@ export const WELL_KNOWN_CAPABILITIES: Record<string, WellKnownCapability> = {
91
99
  * Note: `oidc.issuer_url` is no longer derived here. Per the firm rule in
92
100
  * design/TECHNICAL_DESIGN_MANIFEST_V2.md D9, well-known capability data
93
101
  * templates must not cross-reference other capabilities. The IDP-providing
94
- * module derives `auth_url` (or whatever it calls the issuer URL) in its
95
- * own manifest from `$capability:dns_registrar.primary_domain` and exposes
96
- * it through `provides.capabilities[].data`.
102
+ * module declares an explicit user-set `domain` field in its own manifest,
103
+ * derives `auth_url` from `$self:domain`, and exposes it through
104
+ * `provides.capabilities[].data`.
97
105
  */
98
106
  auth: {
99
107
  canonical_hostname: 'auth',
@@ -22,6 +22,16 @@ import { cleanupTempDir, extractPackage } from '../../module/packaging/extract';
22
22
  import { log } from '../prompts';
23
23
  import type { CommandResult } from '../types';
24
24
 
25
+ type UpgradeOutcome =
26
+ | { status: 'success'; moduleId: string }
27
+ | { status: 'failed'; moduleId: string; error: string }
28
+ // `skipped` means the path expanded from a glob but isn't an
29
+ // upgradable target — either no manifest at all (probably a non-
30
+ // module sibling like `modules/archive/`) or a real module that
31
+ // isn't installed in this celilo. Treated as a soft pass so
32
+ // `celilo module update modules/*` does what users expect.
33
+ | { status: 'skipped'; moduleId: string; reason: string };
34
+
25
35
  /**
26
36
  * Upgrade a single module from a source path
27
37
  */
@@ -29,11 +39,15 @@ async function upgradeOne(
29
39
  sourcePath: string,
30
40
  db: ReturnType<typeof getDb>,
31
41
  flags: Record<string, string | boolean> = {},
32
- ): Promise<{ moduleId: string; success: boolean; error?: string }> {
42
+ ): Promise<UpgradeOutcome> {
33
43
  const originalCwd = process.env.CELILO_ORIGINAL_CWD || process.cwd();
34
44
  const importPath = resolve(originalCwd, sourcePath);
35
45
  if (!existsSync(importPath)) {
36
- return { moduleId: sourcePath, success: false, error: `Source path not found: ${importPath}` };
46
+ return {
47
+ status: 'failed',
48
+ moduleId: sourcePath,
49
+ error: `Source path not found: ${importPath}`,
50
+ };
37
51
  }
38
52
 
39
53
  // Handle .netapp packages: extract to temp dir
@@ -44,8 +58,8 @@ async function upgradeOne(
44
58
  const extractResult = await extractPackage(importPath);
45
59
  if (!extractResult.success || !extractResult.tempDir) {
46
60
  return {
61
+ status: 'failed',
47
62
  moduleId: sourcePath,
48
- success: false,
49
63
  error: extractResult.error || 'Failed to extract package',
50
64
  };
51
65
  }
@@ -59,8 +73,8 @@ async function upgradeOne(
59
73
  if (!verifyResult.success) {
60
74
  await cleanupTempDir(tempDir);
61
75
  return {
76
+ status: 'failed',
62
77
  moduleId: sourcePath,
63
- success: false,
64
78
  error: verifyResult.error || 'Package verification failed',
65
79
  };
66
80
  }
@@ -72,10 +86,13 @@ async function upgradeOne(
72
86
  const manifestPath = join(actualPath, 'manifest.yml');
73
87
  if (!existsSync(manifestPath)) {
74
88
  if (tempDir) await cleanupTempDir(tempDir);
89
+ // No manifest means the path isn't a module directory at all —
90
+ // a likely outcome of `module update modules/*` matching a
91
+ // non-module sibling. Skip silently rather than fail the batch.
75
92
  return {
93
+ status: 'skipped',
76
94
  moduleId: sourcePath,
77
- success: false,
78
- error: `No manifest.yml found at ${actualPath}`,
95
+ reason: 'not a module directory (no manifest.yml)',
79
96
  };
80
97
  }
81
98
 
@@ -87,17 +104,22 @@ async function upgradeOne(
87
104
  } catch (err) {
88
105
  if (tempDir) await cleanupTempDir(tempDir);
89
106
  const msg = err instanceof Error ? err.message : String(err);
90
- return { moduleId: sourcePath, success: false, error: `Invalid manifest: ${msg}` };
107
+ return { status: 'failed', moduleId: sourcePath, error: `Invalid manifest: ${msg}` };
91
108
  }
92
109
 
93
110
  const moduleId = newManifest.id;
94
111
 
95
112
  const module = db.select().from(modules).where(eq(modules.id, moduleId)).get();
96
113
  if (!module) {
114
+ if (tempDir) await cleanupTempDir(tempDir);
115
+ // Module isn't installed in this celilo. Don't fail the batch —
116
+ // `module update modules/*` should keep going for everything
117
+ // that IS installed. Caller surfaces the skip count so the user
118
+ // sees what was passed over.
97
119
  return {
120
+ status: 'skipped',
98
121
  moduleId,
99
- success: false,
100
- error: `Module '${moduleId}' is not installed. Use 'celilo module import ${sourcePath}' first.`,
122
+ reason: `not installed (run 'celilo module import ${sourcePath}' to add)`,
101
123
  };
102
124
  }
103
125
 
@@ -141,7 +163,7 @@ async function upgradeOne(
141
163
  }
142
164
 
143
165
  log.success(`Upgraded ${moduleId} (v${oldManifest.version} → v${newManifest.version})`);
144
- return { moduleId, success: true };
166
+ return { status: 'success', moduleId };
145
167
  }
146
168
 
147
169
  /**
@@ -162,31 +184,60 @@ export async function handleModuleUpgrade(
162
184
  }
163
185
 
164
186
  const db = getDb();
165
- const results: Array<{ moduleId: string; success: boolean; error?: string }> = [];
187
+ const results: UpgradeOutcome[] = [];
166
188
 
167
189
  for (const path of args) {
168
190
  const result = await upgradeOne(path, db, flags);
169
191
  results.push(result);
170
192
  }
171
193
 
172
- const succeeded = results.filter((r) => r.success);
173
- const failed = results.filter((r) => !r.success);
194
+ const succeeded = results.filter(
195
+ (r): r is Extract<UpgradeOutcome, { status: 'success' }> => r.status === 'success',
196
+ );
197
+ const failed = results.filter(
198
+ (r): r is Extract<UpgradeOutcome, { status: 'failed' }> => r.status === 'failed',
199
+ );
200
+ const skipped = results.filter(
201
+ (r): r is Extract<UpgradeOutcome, { status: 'skipped' }> => r.status === 'skipped',
202
+ );
203
+
204
+ // Skips that fall under a wildcard expansion (e.g. modules/* picking
205
+ // up `modules/archive/`) shouldn't even be mentioned — they're not
206
+ // signal. Skips for "module not installed" ARE signal because the
207
+ // user explicitly named the path; surface those.
208
+ const meaningfulSkips = skipped.filter((r) => !r.reason.startsWith('not a module directory'));
174
209
 
175
210
  if (failed.length > 0) {
176
211
  const errors = failed.map((r) => ` ${r.moduleId}: ${r.error}`).join('\n');
212
+ const parts: string[] = [];
177
213
  if (succeeded.length > 0) {
178
- const names = succeeded.map((r) => r.moduleId).join(', ');
179
- return {
180
- success: false,
181
- error: `Upgraded ${succeeded.length} module(s): ${names}\n\nFailed ${failed.length}:\n${errors}`,
182
- };
214
+ parts.push(
215
+ `Upgraded ${succeeded.length} module(s): ${succeeded.map((r) => r.moduleId).join(', ')}`,
216
+ );
217
+ }
218
+ if (meaningfulSkips.length > 0) {
219
+ const skipLines = meaningfulSkips.map((r) => ` ${r.moduleId}: ${r.reason}`).join('\n');
220
+ parts.push(`Skipped ${meaningfulSkips.length}:\n${skipLines}`);
183
221
  }
184
- return { success: false, error: `Upgrade failed:\n${errors}` };
222
+ parts.push(`Failed ${failed.length}:\n${errors}`);
223
+ return { success: false, error: parts.join('\n\n') };
185
224
  }
186
225
 
187
- const names = succeeded.map((r) => r.moduleId).join(', ');
188
- return {
189
- success: true,
190
- message: `Successfully updated ${succeeded.length} module(s): ${names}`,
191
- };
226
+ const lines: string[] = [];
227
+ if (succeeded.length > 0) {
228
+ lines.push(
229
+ `Updated ${succeeded.length} module(s): ${succeeded.map((r) => r.moduleId).join(', ')}`,
230
+ );
231
+ }
232
+ if (meaningfulSkips.length > 0) {
233
+ const skipLines = meaningfulSkips.map((r) => ` ${r.moduleId}: ${r.reason}`).join('\n');
234
+ lines.push(`Skipped ${meaningfulSkips.length}:\n${skipLines}`);
235
+ }
236
+ if (lines.length === 0) {
237
+ // Every arg was a non-module sibling — odd but not a failure.
238
+ lines.push(
239
+ `No modules to update (${results.length} path(s) skipped — none had a manifest.yml)`,
240
+ );
241
+ }
242
+ return { success: true, message: lines.join('\n\n') };
192
243
  }
@@ -122,7 +122,7 @@ export function showNote(message: string, title?: string): void {
122
122
  export const log = {
123
123
  success: (message: string) => {
124
124
  const d = getActiveDisplay();
125
- if (d) return d.subEvent(`\x1b[32m✓\x1b[0m ${message}`);
125
+ if (d) return d.subEvent(`\x1b[32m✔\x1b[0m ${message}`);
126
126
  p.log.success(message);
127
127
  },
128
128
  error: (message: string) => {
@@ -91,6 +91,9 @@ const fakeIdp: IdpCapability = {
91
91
  async create_user() {
92
92
  return { user_id: 1, created: true };
93
93
  },
94
+ async create_token() {
95
+ return { token: 'fake-token', created: true };
96
+ },
94
97
  };
95
98
 
96
99
  describe('defineHook', () => {
@@ -253,6 +256,9 @@ describe('defineCapabilityFunction', () => {
253
256
  async create_user() {
254
257
  return { user_id: 1, created: true };
255
258
  },
259
+ async create_token() {
260
+ return { token: 'fake-token', created: true };
261
+ },
256
262
  }),
257
263
  });
258
264
 
@@ -313,6 +319,9 @@ describe('defineCapabilityFunction', () => {
313
319
  async create_user(_request: CreateUserRequest): Promise<CreateUserResult> {
314
320
  return { user_id: 1, created: true };
315
321
  },
322
+ async create_token(): Promise<{ token: string; created: boolean }> {
323
+ return { token: 'tok', created: true };
324
+ },
316
325
  };
317
326
  },
318
327
  });
@@ -443,6 +452,9 @@ void defineCapabilityFunction({
443
452
  async create_user() {
444
453
  return { user_id: 1, created: true };
445
454
  },
455
+ async create_token() {
456
+ return { token: 'x', created: true };
457
+ },
446
458
  }),
447
459
  });
448
460
 
@@ -7,6 +7,15 @@
7
7
  * - Output validation
8
8
  * - Structured logging
9
9
  *
10
+ * **Execution model:** hook scripts run *in-process* on the celilo CLI host.
11
+ * `runScript` dynamically `import()`s the hook module and awaits its default
12
+ * export. They do NOT execute on the target machine. A hook that needs to
13
+ * touch the target initiates SSH outbound itself (see e.g.
14
+ * modules/lunacycle/celilo/scripts/health-check.ts's `ssh()` helper). This
15
+ * means hook scripts can freely use npm packages, make HTTP calls, read
16
+ * local files, etc. — and conversely, anything they depend on (chromium,
17
+ * system binaries, credentials) must be available on the celilo CLI host.
18
+ *
10
19
  * Execution function (Rule 10.1) - performs side effects (script execution)
11
20
  */
12
21
 
@@ -66,6 +66,44 @@ export function createGaugeLogger(
66
66
  warn: (message: string) => emit('warn', message),
67
67
  error: (message: string) => emit('error', message),
68
68
  success: (message: string) => emit('success', message),
69
+ beginStep: (name: string) => {
70
+ const display = getActiveDisplay();
71
+ if (display) {
72
+ // pushStep nests under the FuelGauge step that wraps the hook,
73
+ // so inner log calls (logger.info within the capability impl)
74
+ // are sub-events of this nested step.
75
+ display.pushStep(`→ ${name}`, `✓ ${name}`);
76
+ } else {
77
+ // Without a display, fall back to a plain info-style marker so
78
+ // the line still appears in raw log output.
79
+ gauge.addOutput(`${prefix} → ${name}`);
80
+ if (nonInteractive) {
81
+ process.stdout.write(`${prefix} → ${name}\n`);
82
+ }
83
+ }
84
+ },
85
+ endStep: (_name: string) => {
86
+ const display = getActiveDisplay();
87
+ if (display) {
88
+ display.doneStep();
89
+ } else {
90
+ gauge.addOutput(`${prefix} ✓ ${_name}`);
91
+ if (nonInteractive) {
92
+ process.stdout.write(`${prefix} ✓ ${_name}\n`);
93
+ }
94
+ }
95
+ },
96
+ failStep: (name: string, error: string) => {
97
+ const display = getActiveDisplay();
98
+ if (display) {
99
+ display.failStep(`${name}: ${error}`);
100
+ } else {
101
+ gauge.addOutput(`${prefix} ✗ ${name}: ${error}`);
102
+ if (nonInteractive) {
103
+ process.stdout.write(`${prefix} ✗ ${name}: ${error}\n`);
104
+ }
105
+ }
106
+ },
69
107
  };
70
108
  }
71
109
 
@@ -24,13 +24,24 @@ export interface HookDefinition {
24
24
  }
25
25
 
26
26
  /**
27
- * Logger interface provided to hook scripts
27
+ * Logger interface provided to hook scripts.
28
+ *
29
+ * Mirrors `HookLogger` in `@celilo/capabilities/types` — kept in sync so
30
+ * concrete loggers we build here (createGaugeLogger, createConsoleLogger,
31
+ * createCapturingLogger) satisfy both the in-process consumers and the
32
+ * cross-package contract used by `wrapWithLogging`.
28
33
  */
29
34
  export interface HookLogger {
30
35
  info(message: string): void;
31
36
  warn(message: string): void;
32
37
  error(message: string): void;
33
38
  success(message: string): void;
39
+ /** Begin a logical span. Subsequent log calls nest under it visually. */
40
+ beginStep?(name: string): void;
41
+ /** End the current span (success). */
42
+ endStep?(name: string): void;
43
+ /** End the current span (failure) with an error message. */
44
+ failStep?(name: string, error: string): void;
34
45
  }
35
46
 
36
47
  /**
@@ -993,6 +993,74 @@ describe('validateDeriveFromSources', () => {
993
993
  const result = validateDeriveFromSources(manifest);
994
994
  expect(result).toBeNull();
995
995
  });
996
+
997
+ test('should accept $self: references regardless of source', () => {
998
+ // $self: points at another variable in this same manifest, so it
999
+ // doesn't introduce the cross-context mismatch the validator exists
1000
+ // to catch (e.g. authentik's auth_url derives from $self:domain).
1001
+ const manifest = {
1002
+ celilo_contract: '1.0' as const,
1003
+ id: 'authentik',
1004
+ name: 'Authentik',
1005
+ version: '1.0.0',
1006
+ requires: { capabilities: [] },
1007
+ provides: { capabilities: [] },
1008
+ variables: {
1009
+ owns: [
1010
+ {
1011
+ name: 'domain',
1012
+ type: 'string' as const,
1013
+ required: true,
1014
+ source: 'user' as const,
1015
+ },
1016
+ {
1017
+ name: 'auth_url',
1018
+ type: 'string' as const,
1019
+ required: false,
1020
+ source: 'capability' as const,
1021
+ derive_from: 'https://auth.$self:domain',
1022
+ },
1023
+ ],
1024
+ imports: [],
1025
+ },
1026
+ };
1027
+
1028
+ const result = validateDeriveFromSources(manifest);
1029
+ expect(result).toBeNull();
1030
+ });
1031
+
1032
+ test('should still reject mixed $self: and $system: when source is capability', () => {
1033
+ // Self-refs are exempt, but other foreign sources still trigger the
1034
+ // mismatch error. This guards against accidentally green-lighting
1035
+ // "$self:foo and $system:bar" combos.
1036
+ const manifest = {
1037
+ celilo_contract: '1.0' as const,
1038
+ id: 'test',
1039
+ name: 'Test',
1040
+ version: '1.0.0',
1041
+ requires: { capabilities: [] },
1042
+ provides: { capabilities: [] },
1043
+ variables: {
1044
+ owns: [
1045
+ {
1046
+ name: 'mixed',
1047
+ type: 'string' as const,
1048
+ required: false,
1049
+ source: 'capability' as const,
1050
+ derive_from: '$self:domain/$system:primary_domain',
1051
+ },
1052
+ ],
1053
+ imports: [],
1054
+ },
1055
+ };
1056
+
1057
+ const result = validateDeriveFromSources(manifest);
1058
+ expect(result).not.toBeNull();
1059
+ if (result) {
1060
+ expect(result.errors[0]?.message).toContain('$system:');
1061
+ expect(result.errors[0]?.message).not.toContain('$self:');
1062
+ }
1063
+ });
996
1064
  });
997
1065
 
998
1066
  describe('validateHookContract', () => {
@@ -285,9 +285,13 @@ export function validateZoneRequirements(manifest: ModuleManifest): ValidationEr
285
285
  *
286
286
  * Rules:
287
287
  * - `source: capability` → `derive_from` must only reference `$capability:`
288
- * tokens (and may also include `{var}` placeholders).
288
+ * tokens (and may also include `{var}` and `$self:` placeholders).
289
289
  * - `source: system` → `derive_from` must only reference `$system:` tokens
290
- * (and `{var}` placeholders).
290
+ * (and `{var}` and `$self:` placeholders).
291
+ * - `$self:` references are allowed under *any* source. They point at
292
+ * another variable owned by the same manifest, so they don't introduce
293
+ * a cross-context mismatch — that's the bug class this validator was
294
+ * built to catch.
291
295
  * - Other sources (`user`, `infrastructure`, `terraform`) — `derive_from` is
292
296
  * optional and unconstrained; we don't check the prefix.
293
297
  */
@@ -306,7 +310,9 @@ export function validateDeriveFromSources(manifest: ModuleManifest): ValidationE
306
310
  if (!expected) continue;
307
311
 
308
312
  const tokens = [...variable.derive_from.matchAll(tokenPattern)].map((m) => m[1]);
309
- const offending = tokens.filter((t) => t !== expected);
313
+ // `self` is always allowed: it references another variable in this
314
+ // same manifest, which is the same context as the declared source.
315
+ const offending = tokens.filter((t) => t !== expected && t !== 'self');
310
316
  if (offending.length > 0) {
311
317
  const unique = Array.from(new Set(offending)).join(', ');
312
318
  errors.push({
@@ -103,7 +103,7 @@ export async function autoDeriveMachineConfig(
103
103
  const derived = resolveMachineDerivation(variable.derive_from, machine);
104
104
  if (derived === null) continue;
105
105
 
106
- log.info(`✓ ${variable.name} = ${derived} (auto-derived from ${machine.hostname})`);
106
+ log.success(`${variable.name} = ${derived} (auto-derived from ${machine.hostname})`);
107
107
 
108
108
  await db
109
109
  .insert(moduleConfigs)
@@ -132,7 +132,7 @@ export async function autoDeriveMachineConfig(
132
132
  }
133
133
 
134
134
  if (followUpValue !== null) {
135
- log.info(`✓ ${followUpKey} = ${followUpValue} (auto-derived from ${machine.hostname})`);
135
+ log.success(`${followUpKey} = ${followUpValue} (auto-derived from ${machine.hostname})`);
136
136
  await db
137
137
  .insert(moduleConfigs)
138
138
  .values({
@@ -534,7 +534,7 @@ export async function interviewForMissingSecrets(
534
534
  ): Promise<InterviewResult> {
535
535
  const configured: string[] = [];
536
536
 
537
- log.info(`Module '${moduleId}' requires secrets. Configuring:`);
537
+ log.message(`Module '${moduleId}' requires secrets. Configuring:`);
538
538
 
539
539
  const masterKey = await getOrCreateMasterKey();
540
540
 
@@ -635,7 +635,7 @@ export async function interviewForMissingSecrets(
635
635
  deriveMethod: metadata.deriveMethod,
636
636
  });
637
637
 
638
- log.info(`🔗 Derived ${variable.name} from ${metadata.deriveFrom}`);
638
+ log.message(`Derived ${variable.name} from ${metadata.deriveFrom}`);
639
639
  } else if (source === 'generated') {
640
640
  // Auto-generate without prompting
641
641
  // Manifest generate field takes priority over schema metadata
@@ -644,7 +644,7 @@ export async function interviewForMissingSecrets(
644
644
 
645
645
  value = generateSecret({ format, length });
646
646
 
647
- log.info(`🔑 Auto-generated ${format} secret: ${variable.name}`);
647
+ log.message(`Auto-generated ${format} secret: ${variable.name}`);
648
648
  } else if (source === 'user_provided') {
649
649
  // Always prompt, required
650
650
  // Check if we're in interactive mode
@@ -667,7 +667,7 @@ export async function interviewForMissingSecrets(
667
667
  },
668
668
  });
669
669
 
670
- log.info(`✓ Saved ${variable.name}`);
670
+ log.success(`Saved ${variable.name}`);
671
671
  } else if (source === 'user_password') {
672
672
  // Password the user must remember — prompt twice to confirm
673
673
  if (!process.stdin.isTTY) {
@@ -706,7 +706,7 @@ export async function interviewForMissingSecrets(
706
706
  };
707
707
  }
708
708
 
709
- log.info(`✓ Saved ${variable.name}`);
709
+ log.success(`Saved ${variable.name}`);
710
710
  } else if (source === 'generated_optional') {
711
711
  // Prompt with auto-generate option
712
712
  const message = variable.description
@@ -725,11 +725,11 @@ export async function interviewForMissingSecrets(
725
725
 
726
726
  value = generateSecret({ format, length });
727
727
 
728
- log.info(`🔑 Auto-generated ${format} secret: ${variable.name}`);
728
+ log.message(`Auto-generated ${format} secret: ${variable.name}`);
729
729
  } else {
730
730
  // Use user-provided value
731
731
  value = userValue;
732
- log.info(`✓ Saved ${variable.name}`);
732
+ log.success(`Saved ${variable.name}`);
733
733
  }
734
734
  } else {
735
735
  return {
@@ -152,7 +152,7 @@ export async function executeAnsible(
152
152
  await writeFile(passwordPath, vaultPassword, { mode: 0o600 });
153
153
 
154
154
  const logPath = join(generatedPath, 'deploy.log');
155
- log.info('Deploying software...');
155
+ log.success('Configuring host (ansible-playbook)');
156
156
 
157
157
  try {
158
158
  const result = await executeBuildWithProgress({
@@ -117,7 +117,7 @@ export async function runModuleHealthCheck(
117
117
  { debug: false, capabilities: capabilityFunctions, requiredCapabilities },
118
118
  );
119
119
  } else {
120
- const gauge = new FuelGauge(`Checking ${moduleId}`, {
120
+ const gauge = new FuelGauge('Testing app', {
121
121
  skipAnimation: options.noInteractive,
122
122
  });
123
123
  gauge.start();
@@ -295,7 +295,6 @@ export async function deployModule(
295
295
  }
296
296
  }
297
297
 
298
- log.info(`Deploying module: ${moduleId}`);
299
298
  const validation = await validateAndPrepareDeployment(moduleId, db);
300
299
  phases.validation = validation.success;
301
300
  phases.autoGenerated = validation.autoGenerated;
@@ -469,11 +468,9 @@ export async function deployModule(
469
468
  }
470
469
  }
471
470
 
472
- log.success('Validation passed:');
473
- log.message(' Templates generated');
474
- if (validation.autoBuilt) {
475
- log.message(' Module built');
476
- }
471
+ log.success(
472
+ validation.autoBuilt ? 'Templates generated and module built' : 'Templates generated',
473
+ );
477
474
 
478
475
  // Run validate_config hook if defined (e.g., credential validation via Playwright)
479
476
  if (manifest.hooks?.validate_config) {
@@ -613,7 +610,7 @@ export async function deployModule(
613
610
  // Run on_install hook for config-only modules (e.g. publishing static files to caddy)
614
611
  if (manifest.hooks?.on_install) {
615
612
  const onInstallDef = manifest.hooks.on_install;
616
- log.info('Running on_install hook...');
613
+ log.success('Running on_install hook');
617
614
 
618
615
  const { moduleConfigs: pcTable, secrets: secretsTable } = await import('../db/schema');
619
616
  const installConfigs = db
@@ -695,7 +692,6 @@ export async function deployModule(
695
692
  // Run health checks for config-only modules too
696
693
  if (manifest.hooks?.health_check) {
697
694
  const { runModuleHealthCheck } = await import('./health-runner');
698
- log.info('Running health checks...');
699
695
  const healthResult = await runModuleHealthCheck(moduleId, db, {
700
696
  debug: options.debug,
701
697
  noInteractive: options.noInteractive,
@@ -710,7 +706,6 @@ export async function deployModule(
710
706
  .join('\n');
711
707
  return { success: false, phases, error: `Health checks failed:\n${failedChecks}` };
712
708
  }
713
- log.success('Health checks passed → VERIFIED');
714
709
  }
715
710
 
716
711
  return {
@@ -855,7 +850,7 @@ export async function deployModule(
855
850
 
856
851
  if (!containerCreatedHook) continue;
857
852
 
858
- log.info(
853
+ log.message(
859
854
  `Running container_created hook on ${capRecord.moduleId} (provides ${requiredCap.name})`,
860
855
  );
861
856
 
@@ -988,7 +983,6 @@ export async function deployModule(
988
983
  let machineId: string | undefined;
989
984
  if (plan.infrastructure?.type === 'machine' && plan.infrastructure.machineId) {
990
985
  machineId = plan.infrastructure.machineId;
991
- log.info(`Writing temporary SSH key for machine: ${machineId}`);
992
986
  await writeTemporarySshKey(machineId);
993
987
  }
994
988
 
@@ -1012,15 +1006,16 @@ export async function deployModule(
1012
1006
  plan.infrastructure?.type === 'container_service' &&
1013
1007
  plan.infrastructure.serviceId
1014
1008
  ) {
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');
1009
+ // TODO: extract Terraform outputs and persist them on
1010
+ // module_infrastructure.containerMetadata. Until that lands,
1011
+ // the deploy still succeeds we just don't track which
1012
+ // container ID/IP got assigned per module in the DB.
1018
1013
  }
1019
1014
 
1020
1015
  // Run on_install hook (post-deploy actions like port forwarding, DNS registration)
1021
1016
  if (manifest.hooks?.on_install) {
1022
1017
  const onInstallDef = manifest.hooks.on_install;
1023
- log.info('Running on_install hook...');
1018
+ log.success('Running on_install hook');
1024
1019
 
1025
1020
  const { moduleConfigs: pcTable, secrets: secretsTable } = await import('../db/schema');
1026
1021
  const installConfigs = db
@@ -1120,7 +1115,6 @@ export async function deployModule(
1120
1115
  // Run health checks after successful deployment
1121
1116
  if (manifest.hooks?.health_check) {
1122
1117
  const { runModuleHealthCheck } = await import('./health-runner');
1123
- log.info('Running post-deploy health checks...');
1124
1118
  const healthResult = await runModuleHealthCheck(moduleId, db, {
1125
1119
  debug: options.debug,
1126
1120
  noInteractive: options.noInteractive,
@@ -1144,10 +1138,6 @@ export async function deployModule(
1144
1138
  error: `Health checks failed:\n${failedChecks}`,
1145
1139
  };
1146
1140
  }
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
1141
  }
1152
1142
 
1153
1143
  // Auto-register module hostname in internal DNS (if available)
@@ -1167,7 +1157,7 @@ export async function deployModule(
1167
1157
  );
1168
1158
  }
1169
1159
 
1170
- display.instantEvent(`\x1b[32m✓\x1b[0m Module '${moduleId}' deployed successfully`);
1160
+ log.success(`Module '${moduleId}' deployed successfully`);
1171
1161
  return {
1172
1162
  success: true,
1173
1163
  phases,
@@ -1175,7 +1165,6 @@ export async function deployModule(
1175
1165
  } finally {
1176
1166
  // Clean up temporary SSH key
1177
1167
  if (machineId) {
1178
- log.info(`Cleaning up temporary SSH key for machine: ${machineId}`);
1179
1168
  deleteTemporarySshKey(machineId);
1180
1169
  }
1181
1170
  }
@@ -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];