@celilo/cli 0.1.9 → 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.9",
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
  }