@celilo/cli 0.1.8 → 0.2.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.1.8",
3
+ "version": "0.2.0",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
  }
@@ -519,7 +519,12 @@ async function buildFirewallChain(
519
519
 
520
520
  // Apply rules by default. Set CELILO_FIREWALL_DRY_RUN=1 to preview without applying.
521
521
  const dryRun = process.env.CELILO_FIREWALL_DRY_RUN === '1';
522
- const downstreamFirewall = provFactory({ firewallIp, natIp, dryRun }, currentUpstream);
522
+ // Third arg (logger) lets iptables route its internal status lines
523
+ // ("[iptables] Rule already exists…", etc.) through the parent
524
+ // celilo's ProgressDisplay instead of dumping to stderr. Older
525
+ // iptables modules ignore it — the factory's signature is
526
+ // backward-compatible.
527
+ const downstreamFirewall = provFactory({ firewallIp, natIp, dryRun }, currentUpstream, logger);
523
528
 
524
529
  debugLog(`firewall chain: wired ${provider.moduleId} → ${hasExternal.moduleId}`);
525
530
  // Wrap each downstream layer with auto-logging.
@@ -77,13 +77,11 @@ const FRAMEWORK_OWNED_PATHS = new Set(['celilo/types.d.ts']);
77
77
  *
78
78
  * `node_modules` exclusion is path-aware: regular npm dependencies are
79
79
  * skipped (size + bun re-installs them at module-import time), but the
80
- * framework's own `@celilo/*` packages stay in. That captures the
81
- * capabilities API the module was authored against, so a module's
82
- * hooks aren't silently broken by a runtime that ships a different
83
- * version of `@celilo/capabilities` than the one on npm.
80
+ * framework's own `@celilo/capabilities` package stays in. That
81
+ * captures the capabilities API the module was authored against, so a
82
+ * module's hooks aren't silently broken by a runtime that ships a
83
+ * different version of `@celilo/capabilities` than the one on npm.
84
84
  */
85
- const BUNDLED_CELILO_PACKAGES = new Set(['capabilities', 'cli-display']);
86
-
87
85
  function shouldExclude(filePath: string): boolean {
88
86
  if (FRAMEWORK_OWNED_PATHS.has(filePath)) return true;
89
87
 
@@ -95,18 +93,15 @@ function shouldExclude(filePath: string): boolean {
95
93
 
96
94
  const nmIdx = segments.indexOf('node_modules');
97
95
  if (nmIdx >= 0) {
98
- // `node_modules` itself: descend so we can pick out @celilo/* SDK
99
- // packages. capabilities is the public surface the module was
100
- // authored against; cli-display is its transitive dep (the
101
- // ProgressDisplay singleton that capability functions reach for to
102
- // route output through the parent celilo's display). Everything
103
- // else is regular npm cruft we'd just re-install on the target
104
- // (or test-only deps inside packages like `@celilo/e2e` we don't
105
- // want to drag in).
96
+ // `node_modules` itself: descend so we can pick out @celilo/capabilities.
97
+ // The capabilities scope is the only thing we ship — it's the framework
98
+ // SDK the module was authored against. Everything else is regular npm
99
+ // cruft we'd just re-install on the target (or test-only deps inside
100
+ // packages like `@celilo/e2e` we don't want to drag in).
106
101
  if (nmIdx + 1 >= segments.length) return false; // node_modules dir itself
107
102
  if (segments[nmIdx + 1] !== '@celilo') return true;
108
103
  if (nmIdx + 2 >= segments.length) return false; // node_modules/@celilo dir itself
109
- return !BUNDLED_CELILO_PACKAGES.has(segments[nmIdx + 2]);
104
+ return segments[nmIdx + 2] !== 'capabilities';
110
105
  }
111
106
 
112
107
  const name = basename(filePath);