@celilo/cli 0.3.16 → 0.3.18

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.
Files changed (43) hide show
  1. package/package.json +1 -1
  2. package/src/api-clients/proxmox.ts +77 -45
  3. package/src/cli/command-registry.ts +23 -35
  4. package/src/cli/commands/completion.ts +12 -11
  5. package/src/cli/commands/module-check.ts +158 -0
  6. package/src/cli/commands/module-import-routing.test.ts +52 -0
  7. package/src/cli/commands/module-import.ts +70 -27
  8. package/src/cli/commands/module-publish.test.ts +3 -90
  9. package/src/cli/commands/module-publish.ts +14 -118
  10. package/src/cli/commands/proxmox-template-selection.test.ts +150 -0
  11. package/src/cli/commands/proxmox-template-selection.ts +258 -0
  12. package/src/cli/commands/service-add-proxmox.ts +49 -127
  13. package/src/cli/commands/service-reconfigure.ts +36 -79
  14. package/src/cli/commands/service-verify.ts +20 -79
  15. package/src/cli/commands/{doctor.test.ts → system-doctor.test.ts} +1 -1
  16. package/src/cli/commands/{doctor.ts → system-doctor.ts} +93 -18
  17. package/src/cli/commands/system-update.ts +1 -1
  18. package/src/cli/completion.ts +29 -8
  19. package/src/cli/index.ts +25 -30
  20. package/src/manifest/schema.ts +9 -1
  21. package/src/module/import.ts +4 -2
  22. package/src/registry/client.ts +14 -1
  23. package/src/services/bus-interview.ts +13 -1
  24. package/src/services/bus-secret-flow.test.ts +94 -0
  25. package/src/services/config-interview.ts +66 -6
  26. package/src/services/module-deploy.ts +19 -1
  27. package/src/services/module-validator/capability-versions.test.ts +90 -0
  28. package/src/services/module-validator/capability-versions.ts +115 -0
  29. package/src/services/module-validator/contract-version.test.ts +24 -0
  30. package/src/services/module-validator/contract-version.ts +69 -0
  31. package/src/services/module-validator/git-hygiene.test.ts +141 -0
  32. package/src/services/module-validator/git-hygiene.ts +144 -0
  33. package/src/services/module-validator/index.test.ts +67 -0
  34. package/src/services/module-validator/index.ts +74 -0
  35. package/src/services/module-validator/manifest-schema.ts +42 -0
  36. package/src/services/module-validator/types.ts +43 -0
  37. package/src/services/module-validator/typescript-build.test.ts +58 -0
  38. package/src/services/module-validator/typescript-build.ts +115 -0
  39. package/src/services/module-validator/workspace-deps.test.ts +137 -0
  40. package/src/services/module-validator/workspace-deps.ts +187 -0
  41. package/src/services/terminal-responder.ts +75 -0
  42. package/src/system/prereqs.test.ts +374 -0
  43. package/src/system/prereqs.ts +377 -0
@@ -4,21 +4,16 @@
4
4
  */
5
5
 
6
6
  import * as p from '@clack/prompts';
7
- import {
8
- buildTemplateUrl,
9
- checkTaskStatus,
10
- downloadTemplate,
11
- extractTemplateFilename,
12
- } from '../../api-clients/proxmox';
7
+ import { extractTemplateFilename } from '../../api-clients/proxmox';
13
8
  import {
14
9
  type ProxmoxCredentials,
15
10
  getContainerServiceByServiceId,
16
11
  getServiceCredentials,
17
12
  verifyContainerService,
18
13
  } from '../../services/container-service';
19
- import { FuelGauge } from '../fuel-gauge';
20
14
  import { celiloIntro, celiloOutro } from '../prompts';
21
15
  import type { CommandResult } from '../types';
16
+ import { runApplianceDownload } from './proxmox-template-selection';
22
17
 
23
18
  /**
24
19
  * Handle service verify command
@@ -108,7 +103,6 @@ export async function handleServiceVerify(
108
103
 
109
104
  if (shouldDownload) {
110
105
  try {
111
- // Get credentials and config
112
106
  const credentials = (await getServiceCredentials(service.id)) as ProxmoxCredentials;
113
107
  const providerConfig = service.providerConfig as {
114
108
  default_target_node: string;
@@ -116,86 +110,33 @@ export async function handleServiceVerify(
116
110
  storage: string;
117
111
  };
118
112
 
119
- // Extract template info
120
113
  const templateFilename = extractTemplateFilename(providerConfig.lxc_template);
121
- const templateParts = providerConfig.lxc_template.split(':');
122
- const templateStorage = templateParts[0] || 'local';
123
-
124
- // Detect Ubuntu version from template filename
125
- const versionMatch = templateFilename.match(/ubuntu-(\d+\.\d+)-/);
126
- if (!versionMatch) {
127
- return {
128
- success: false,
129
- error: 'Cannot auto-download: unrecognized template format',
130
- };
131
- }
132
- const ubuntuVersion = versionMatch[1];
133
-
134
- const templateUrl = buildTemplateUrl(ubuntuVersion);
114
+ const templateStorage = providerConfig.lxc_template.split(':')[0] || 'local';
135
115
 
136
- const downloadResult = await downloadTemplate(
116
+ // The saved volid was the canonical filename when the service was
117
+ // created; if Proxmox refreshed the revision since then, this download
118
+ // will fail with `started-failed` and the user can `service reconfigure`
119
+ // to pick a fresh filename from the catalog.
120
+ const outcome = await runApplianceDownload({
137
121
  credentials,
138
- providerConfig.default_target_node,
122
+ targetNode: providerConfig.default_target_node,
139
123
  templateStorage,
140
- templateUrl,
141
- );
142
-
143
- if (!downloadResult.success) {
144
- return {
145
- success: false,
146
- error: `Failed to download template: ${downloadResult.message}`,
147
- };
148
- }
149
-
150
- // Wait for download to complete with fuel-gauge progress
151
- const upid = downloadResult.data;
152
- const gauge = new FuelGauge(`Downloading ${templateFilename}`);
153
- gauge.start();
154
-
155
- let downloadComplete = false;
156
- let attempts = 0;
157
- const maxAttempts = 60; // 5 minutes
158
-
159
- while (!downloadComplete && attempts < maxAttempts) {
160
- await new Promise((resolve) => setTimeout(resolve, 5000));
161
-
162
- const statusResult = await checkTaskStatus(
163
- credentials,
164
- providerConfig.default_target_node,
165
- upid,
166
- );
167
-
168
- if (statusResult.success) {
169
- if (statusResult.data.status === 'stopped') {
170
- if (statusResult.data.exitstatus === 'OK') {
171
- downloadComplete = true;
172
- gauge.stop(true);
173
- } else {
174
- gauge.stop(false);
175
- return {
176
- success: false,
177
- error: `Template download failed: ${statusResult.data.exitstatus}\n\nThis usually means the Proxmox host cannot reach download.proxmox.com.\n\nTroubleshooting:\n 1. SSH into your Proxmox host and test: curl -I https://download.proxmox.com\n 2. Try downloading manually: pveam download ${templateStorage} ${templateFilename}\n 3. Choose a different Ubuntu version: celilo service reconfigure ${service.serviceId}\n 4. Check DNS and firewall settings on the Proxmox host`,
178
- };
179
- }
180
- } else {
181
- gauge.addOutput(`Status: ${statusResult.data.status} (${attempts * 5}s elapsed)`);
182
- }
183
- } else {
184
- gauge.addOutput(`Waiting for status... (${attempts * 5}s elapsed)`);
185
- }
186
-
187
- attempts++;
188
- }
189
-
190
- if (!downloadComplete) {
191
- gauge.stop(false);
124
+ templateFilename,
125
+ });
126
+
127
+ if (!outcome.ready) {
128
+ const detail =
129
+ outcome.reason === 'task-failed'
130
+ ? `pveam download exited with status: ${outcome.exitStatus ?? 'unknown'}\n\nThis usually means the saved template revision is no longer available on Proxmox's mirror.`
131
+ : outcome.reason === 'started-failed'
132
+ ? `Proxmox rejected the download request: ${outcome.startError ?? 'unknown error'}\n\nThis usually means the saved template name does not match Proxmox's current catalog.`
133
+ : 'Template download did not complete in time. The Proxmox host may have slow internet or connectivity issues.';
192
134
  return {
193
135
  success: false,
194
- error: `Template download timed out after 5 minutes.\n\nThe Proxmox host may have slow internet or connectivity issues.\nTry downloading manually: ssh root@<proxmox-host> pveam download ${templateStorage} ${templateFilename}`,
136
+ error: `${detail}\n\nTroubleshooting:\n 1. Pick a fresh template version: celilo service reconfigure ${service.serviceId}\n 2. SSH into your Proxmox host and run: pveam update && pveam download ${templateStorage} ${templateFilename}\n 3. Check DNS and firewall settings on the Proxmox host`,
195
137
  };
196
138
  }
197
139
 
198
- // Retry verification
199
140
  console.log('\nRetrying verification...');
200
141
  const retryResult = await verifyContainerService(service.id);
201
142
 
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import { compareVersions } from './doctor';
2
+ import { compareVersions } from './system-doctor';
3
3
 
4
4
  describe('compareVersions', () => {
5
5
  test('detects ascending major/minor/patch', () => {
@@ -1,19 +1,32 @@
1
1
  /**
2
- * `celilo doctor` — diagnose @celilo/* version drift between the running CLI
3
- * and the surrounding workspace (if any).
2
+ * `celilo system doctor` — diagnose system-level health for celilo:
3
+ * 1. System prerequisites (ansible, terraform, ssh, etc.) present
4
+ * and at supported versions.
5
+ * 2. @celilo/* version drift between the running CLI and the
6
+ * surrounding workspace (if any).
4
7
  *
5
- * Catches the canonical "I edited the workspace but my global celilo is
6
- * still running an older published version" failure mode.
8
+ * The canonical failures this catches:
9
+ * - "I just installed celilo on a fresh box but module import is
10
+ * erroring with a child-process exit 127." → prereq section.
11
+ * - "I edited the workspace but my global celilo is still running
12
+ * an older published version." → drift section.
7
13
  *
8
- * Resolution strategy:
9
- * - The running CLI's package.json comes from a relative import — that
10
- * anchors us to whatever copy of `@celilo/cli` is actually executing
11
- * (workspace TS source or globally-installed node_modules tree).
14
+ * Renamed from top-level `celilo doctor` (Phase 0; no alias kept) to
15
+ * fit alongside `system init`, `system audit`, `system config`. See
16
+ * apps/celilo/designs/PREREQ_DETECTION.md.
17
+ *
18
+ * Drift resolution strategy:
19
+ * - The running CLI's package.json comes from a relative import —
20
+ * that anchors us to whatever copy of `@celilo/cli` is actually
21
+ * executing (workspace TS source or globally-installed
22
+ * node_modules tree).
12
23
  * - For each `@celilo/*` dependency, we ask the runtime where it
13
- * resolves the package's `package.json` and read the version there.
14
- * - If we can find a workspace root by walking up from `process.cwd()`,
15
- * we read each `packages/*\/package.json` and flag anything where the
16
- * loaded version is older than the workspace.
24
+ * resolves the package's `package.json` and read the version
25
+ * there.
26
+ * - If we can find a workspace root by walking up from
27
+ * `process.cwd()`, we read each `packages/*\/package.json` and
28
+ * flag anything where the loaded version is older than the
29
+ * workspace.
17
30
  */
18
31
 
19
32
  import { spawnSync } from 'node:child_process';
@@ -21,6 +34,7 @@ import { existsSync, readFileSync } from 'node:fs';
21
34
  import { createRequire } from 'node:module';
22
35
  import { dirname, join, resolve } from 'node:path';
23
36
  import cliPkg from '../../../package.json' with { type: 'json' };
37
+ import { checkAllPrerequisites, failingPrerequisites } from '../../system/prereqs';
24
38
  import type { CommandResult } from '../types';
25
39
 
26
40
  interface CeliloPkgInfo {
@@ -247,7 +261,42 @@ function applyFix(drifted: DriftedDep[], cliRoot: string): string[] {
247
261
  return lines;
248
262
  }
249
263
 
250
- export async function handleDoctor(
264
+ /**
265
+ * Render the system-prerequisites block: every tool from the
266
+ * PREREQUISITES table with its detection result, formatted into the
267
+ * doctor's output column-aligned style.
268
+ */
269
+ function renderPrereqSection(): { lines: string[]; failingCount: number } {
270
+ const checks = checkAllPrerequisites();
271
+ const lines: string[] = [];
272
+
273
+ lines.push('System prerequisites');
274
+ // Right-pad column for alignment. Take the longest tool name +1
275
+ // for spacing.
276
+ const nameCol = Math.max(...checks.map((c) => c.name.length), 12);
277
+
278
+ for (const c of checks) {
279
+ if (c.present && c.meetsMinimum) {
280
+ const version = c.version ? c.version : `${ANSI.dim}(version unknown)${ANSI.reset}`;
281
+ lines.push(` ${ANSI.green}✔${ANSI.reset} ${c.name.padEnd(nameCol)} ${version}`);
282
+ } else if (c.present && !c.meetsMinimum) {
283
+ // Present but below minimum (or version-parse failed with a minimum).
284
+ const detail = c.version ? `${c.version} — below minimum required` : 'version unreadable';
285
+ lines.push(` ${ANSI.yellow}⚠${ANSI.reset} ${c.name.padEnd(nameCol)} ${detail}`);
286
+ lines.push(` ${ANSI.dim}install: ${c.installHint}${ANSI.reset}`);
287
+ } else {
288
+ // Missing entirely.
289
+ lines.push(
290
+ ` ${ANSI.red}✗${ANSI.reset} ${c.name.padEnd(nameCol)} ${ANSI.dim}not installed${ANSI.reset}`,
291
+ );
292
+ lines.push(` ${ANSI.dim}install: ${c.installHint}${ANSI.reset}`);
293
+ }
294
+ }
295
+
296
+ return { lines, failingCount: failingPrerequisites(checks).length };
297
+ }
298
+
299
+ export async function handleSystemDoctor(
251
300
  _args: string[],
252
301
  flags: Record<string, string | boolean>,
253
302
  ): Promise<CommandResult> {
@@ -261,6 +310,12 @@ export async function handleDoctor(
261
310
  lines.push(`${ANSI.dim}running from ${cliRoot}${ANSI.reset}`);
262
311
  lines.push('');
263
312
 
313
+ // System prerequisites first — they're the most common reason a
314
+ // fresh management box can't run modules.
315
+ const prereqResult = renderPrereqSection();
316
+ lines.push(...prereqResult.lines);
317
+ lines.push('');
318
+
264
319
  const workspaceRoot = findWorkspaceRoot(process.cwd());
265
320
  const workspaceVersions = workspaceRoot ? collectWorkspaceVersions(workspaceRoot) : [];
266
321
  const workspaceMap = new Map(workspaceVersions.map((w) => [w.name, w]));
@@ -347,8 +402,17 @@ export async function handleDoctor(
347
402
  lines.push(...applyFix(drifted, cliRoot));
348
403
  lines.push('');
349
404
  lines.push(
350
- `${ANSI.dim}Re-run \`celilo doctor\` to verify; \`bun unlink\` from each workspace dir reverses.${ANSI.reset}`,
405
+ `${ANSI.dim}Re-run \`celilo system doctor\` to verify; \`bun unlink\` from each workspace dir reverses.${ANSI.reset}`,
351
406
  );
407
+ // Note: --fix only addresses drift, not missing prereqs. If prereqs
408
+ // failed, surface that even on a successful --fix run.
409
+ if (prereqResult.failingCount > 0) {
410
+ return {
411
+ success: false,
412
+ error: `${prereqResult.failingCount} system prerequisite(s) missing or below minimum — install before running celilo`,
413
+ details: lines.join('\n'),
414
+ };
415
+ }
352
416
  return {
353
417
  success: true,
354
418
  message: lines.join('\n'),
@@ -360,23 +424,34 @@ export async function handleDoctor(
360
424
  lines.push(`${ANSI.dim}--fix: nothing to repair.${ANSI.reset}`);
361
425
  }
362
426
 
363
- if (driftCount > 0 || unresolvedCount > 0) {
427
+ if (driftCount > 0 || unresolvedCount > 0 || prereqResult.failingCount > 0) {
364
428
  const summary: string[] = [];
429
+ if (prereqResult.failingCount > 0) {
430
+ summary.push(`${prereqResult.failingCount} prerequisite(s) missing/below-minimum`);
431
+ }
365
432
  if (driftCount > 0) summary.push(`${driftCount} package(s) behind workspace`);
366
433
  if (unresolvedCount > 0) summary.push(`${unresolvedCount} unresolved`);
367
434
  if (drifted.length > 0) {
368
435
  lines.push(
369
- `${ANSI.dim}Run \`celilo doctor --fix\` to bun-link drifted packages from the workspace.${ANSI.reset}`,
436
+ `${ANSI.dim}Run \`celilo system doctor --fix\` to bun-link drifted packages from the workspace.${ANSI.reset}`,
370
437
  );
371
438
  }
439
+ // Distinguish prereq-only from drift-only from both — the
440
+ // operator's next move is different.
441
+ const errorPrefix =
442
+ driftCount === 0 && unresolvedCount === 0
443
+ ? 'System prerequisites missing'
444
+ : prereqResult.failingCount === 0
445
+ ? 'Drift detected'
446
+ : 'Issues detected';
372
447
  return {
373
448
  success: false,
374
- error: `Drift detected: ${summary.join(', ')}`,
449
+ error: `${errorPrefix}: ${summary.join(', ')}`,
375
450
  details: lines.join('\n'),
376
451
  };
377
452
  }
378
453
 
379
- lines.push(`${ANSI.green}OK${ANSI.reset} — no drift detected`);
454
+ lines.push(`${ANSI.green}OK${ANSI.reset} — no issues detected`);
380
455
  return {
381
456
  success: true,
382
457
  message: lines.join('\n'),
@@ -142,7 +142,7 @@ async function buildSnapshots(
142
142
  * - `backup` calls `createModuleBackup`. Modules without an
143
143
  * `on_backup` hook return ok (nothing to back up; not an error).
144
144
  * - `upgrade` is a no-op for now — the in-place upgrade path lives
145
- * in `module install <name>` and needs a refactor before the
145
+ * in `module import <name>` and needs a refactor before the
146
146
  * orchestrator can drive it cleanly. Deploy will re-converge
147
147
  * against whatever's installed, which is the right behavior for
148
148
  * "redeploy what's there."
@@ -32,7 +32,6 @@ export async function getCompletions(words: string[], current: number): Promise<
32
32
  'backup',
33
33
  'capability',
34
34
  'completion',
35
- 'doctor',
36
35
  'events',
37
36
  'help',
38
37
  'hook',
@@ -122,8 +121,8 @@ export async function getCompletions(words: string[], current: number): Promise<
122
121
  // Module subcommands
123
122
  if (command === 'module' && currentIndex === 1) {
124
123
  const subcommands = [
124
+ 'check',
125
125
  'import',
126
- 'install',
127
126
  'list',
128
127
  'publish',
129
128
  'remove',
@@ -150,11 +149,6 @@ export async function getCompletions(words: string[], current: number): Promise<
150
149
  return filterSuggestions(subcommands, args[1] || '');
151
150
  }
152
151
 
153
- // Module import subcommands (celilo module import file|public-registry)
154
- if (command === 'module' && args[1] === 'import' && currentIndex === 2) {
155
- return filterSuggestions(['file', 'public-registry'], args[2] || '');
156
- }
157
-
158
152
  // Module config subcommands (celilo module config set/get)
159
153
  if (command === 'module' && args[1] === 'config' && currentIndex === 2) {
160
154
  const subcommands = ['set', 'get'];
@@ -437,7 +431,7 @@ export async function getCompletions(words: string[], current: number): Promise<
437
431
 
438
432
  // System subcommands
439
433
  if (command === 'system' && currentIndex === 1) {
440
- const subcommands = ['init', 'config', 'secret', 'vault-password', 'audit', 'update'];
434
+ const subcommands = ['init', 'config', 'secret', 'vault-password', 'audit', 'update', 'doctor'];
441
435
  return filterSuggestions(subcommands, args[1] || '');
442
436
  }
443
437
 
@@ -518,3 +512,30 @@ _celilo() {
518
512
  compdef _celilo celilo
519
513
  `;
520
514
  }
515
+
516
+ /**
517
+ * Generate fish completion script.
518
+ *
519
+ * Fish doesn't have an equivalent of bash's `compgen -W` or zsh's `compadd`,
520
+ * so we register a single dynamic completer that calls the CLI's
521
+ * --get-completions hook on each TAB. The CLI emits one completion per line;
522
+ * fish picks them up as candidates.
523
+ *
524
+ * The shell context fish exposes is `commandline -opc` (tokens before the
525
+ * in-progress word) plus `commandline -ct` (the in-progress word). We
526
+ * recombine them into the same words + cword shape the bash/zsh wrappers
527
+ * use, so the same TypeScript completion logic serves all three shells.
528
+ */
529
+ export function generateFishCompletion(): string {
530
+ return `# Celilo fish completion
531
+ function __celilo_complete
532
+ set -l tokens (commandline -opc)
533
+ set -l current (commandline -ct)
534
+ set -l words celilo $tokens[2..-1] $current
535
+ set -l cword (math (count $words) - 1)
536
+ celilo --get-completions $words $cword 2>/dev/null
537
+ end
538
+
539
+ complete -c celilo -f -a '(__celilo_complete)'
540
+ `;
541
+ }
package/src/cli/index.ts CHANGED
@@ -10,7 +10,6 @@ import { COMMANDS, type CommandDef } from './command-registry';
10
10
  import { handleCapabilityInfo } from './commands/capability-info';
11
11
  import { handleCapabilityList } from './commands/capability-list';
12
12
  import { handleCompletion } from './commands/completion';
13
- import { handleDoctor } from './commands/doctor';
14
13
  import {
15
14
  handleEventsAck,
16
15
  handleEventsDrain,
@@ -45,11 +44,12 @@ import { handleMachineRemove } from './commands/machine-remove';
45
44
  import { handleMachineStatus } from './commands/machine-status';
46
45
  import { moduleAudit } from './commands/module-audit';
47
46
  import { handleModuleBuild } from './commands/module-build';
47
+ import { handleModuleCheck } from './commands/module-check';
48
48
  import { handleModuleConfigGet, handleModuleConfigSet } from './commands/module-config';
49
49
  import { handleModuleDeploy } from './commands/module-deploy';
50
50
  import { handleModuleGenerate } from './commands/module-generate';
51
51
  import { handleModuleHealth } from './commands/module-health';
52
- import { handleModuleImport, handlePublicRegistryImport } from './commands/module-import';
52
+ import { handleModuleImport } from './commands/module-import';
53
53
  import { handleModuleList } from './commands/module-list';
54
54
  import { handleModuleLogs } from './commands/module-logs';
55
55
  import { handleModulePublish } from './commands/module-publish';
@@ -75,6 +75,7 @@ import { handleServiceVerify } from './commands/service-verify';
75
75
  import { handleStatus } from './commands/status';
76
76
  import { handleSystemAudit } from './commands/system-audit';
77
77
  import { handleSystemConfigGet, handleSystemConfigSet } from './commands/system-config';
78
+ import { handleSystemDoctor } from './commands/system-doctor';
78
79
  import { handleSystemInit } from './commands/system-init';
79
80
  import { handleSystemSecretGet } from './commands/system-secret-get';
80
81
  import { handleSystemSecretSet } from './commands/system-secret-set';
@@ -148,7 +149,6 @@ Usage:
148
149
 
149
150
  Commands:
150
151
  status Show system and module status
151
- doctor Diagnose @celilo/* version drift between the running CLI and the workspace
152
152
  audit Top-level alias for 'system audit'
153
153
  events SQLite event-bus operations (status, tail, run dispatcher, etc.)
154
154
  capability View registered module capabilities
@@ -218,7 +218,7 @@ Examples:
218
218
  celilo package ../custom-module
219
219
 
220
220
  Related Commands:
221
- celilo module import <package.netapp> Import a packaged module
221
+ celilo module import <name-or-path> Import a module (registry name or local path)
222
222
  celilo module verify <module-id> Verify package integrity
223
223
  `;
224
224
 
@@ -313,8 +313,9 @@ Usage:
313
313
  celilo module <subcommand> [args...] [options]
314
314
 
315
315
  Subcommands:
316
- import <path> Import a module from directory or .netapp file
316
+ import <name-or-path> Import a module from the registry (bare name) or a local path
317
317
  Options:
318
+ --registry <url> Use a custom registry (default: https://celilo.computer/registry)
318
319
  --target <path> Target base directory (default: platform-specific)
319
320
  --auto-generate-secrets Auto-generate all secrets without prompting
320
321
 
@@ -359,10 +360,6 @@ Subcommands:
359
360
  update <path> [path...] Update module code while preserving state (configs, secrets, infra)
360
361
 
361
362
  Registry:
362
- install <name> Download and import a module from the registry
363
- Options:
364
- --registry <url> Use a custom registry (default: https://celilo.computer/registry)
365
-
366
363
  search [query] Search the registry for modules
367
364
  Options:
368
365
  --registry <url> Use a custom registry
@@ -377,15 +374,21 @@ Registry:
377
374
  --allow-dirty Permit publishing from a dirty git tree
378
375
  --allow-stale Skip the manifest-vs-src stale-check. Use sparingly.
379
376
 
377
+ check [path] Check a module for drift against the framework (default: .)
378
+ Options:
379
+ --no-build Skip the TypeScript build check
380
+ --json Emit a structured Check[] payload (CI-friendly)
381
+ --strict Treat warnings as failures
382
+
380
383
  Examples:
381
- celilo module install caddy
382
- celilo module install namecheap --registry https://my-registry.example.com/registry
384
+ celilo module import caddy # registry (bare name)
385
+ celilo module import namecheap --registry https://my-registry.example.com/registry
386
+ celilo module import ./modules/homebridge # local directory
387
+ celilo module import homebridge.netapp # local .netapp file
388
+ celilo module import /abs/path/to/module --target /custom/location
383
389
  celilo module search dns
384
390
  celilo module publish ./modules/caddy --token mytoken
385
391
  celilo module publish ./modules/* # publish every module in a dir
386
- celilo module import ./modules/homebridge
387
- celilo module import homebridge.netapp
388
- celilo module import /abs/path/to/module --target /custom/location
389
392
  celilo module list
390
393
  celilo module remove homebridge
391
394
  celilo module verify homebridge
@@ -787,6 +790,8 @@ Subcommands:
787
790
  update [--module <id>] [--no-backup] [--allow-destructive] [--dry-run] [--json]
788
791
  Bring the system to the audit-determined READY state
789
792
 
793
+ doctor [--fix] Diagnose system prerequisites and @celilo/* version drift
794
+
790
795
  Runbook:
791
796
  https://celilo.computer/docs/system-update
792
797
  (offline summary in apps/celilo/docs/INDEX.md)
@@ -907,11 +912,6 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
907
912
  return handleStatus();
908
913
  }
909
914
 
910
- // Handle doctor command
911
- if (parsed.command === 'doctor') {
912
- return handleDoctor(parsed.args, parsed.flags);
913
- }
914
-
915
915
  // Top-level alias: `celilo audit` → `celilo system audit`
916
916
  if (parsed.command === 'audit') {
917
917
  return handleSystemAudit(parsed.args, parsed.flags);
@@ -1162,19 +1162,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1162
1162
  return handleModuleShowZone(parsed.args);
1163
1163
  case 'search':
1164
1164
  return handleModuleSearch(parsed.args, parsed.flags);
1165
- case 'install': {
1166
- // `celilo module install <name>` — shorthand for `module import public-registry <name>`
1167
- const name = parsed.args[0] ?? parsed.subcommand;
1168
- if (!name) {
1169
- return {
1170
- success: false,
1171
- error: 'Module name required\n\nUsage: celilo module install <name> [--registry <url>]',
1172
- };
1173
- }
1174
- return handlePublicRegistryImport(name, parsed.flags);
1175
- }
1176
1165
  case 'publish':
1177
1166
  return handleModulePublish(parsed.args, parsed.flags);
1167
+ case 'check':
1168
+ return handleModuleCheck(parsed.args, parsed.flags);
1178
1169
  default:
1179
1170
  return {
1180
1171
  success: false,
@@ -1512,6 +1503,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
1512
1503
  return handleSystemUpdate(parsed.args, parsed.flags);
1513
1504
  }
1514
1505
 
1506
+ if (parsed.subcommand === 'doctor') {
1507
+ return handleSystemDoctor(parsed.args, parsed.flags);
1508
+ }
1509
+
1515
1510
  return {
1516
1511
  success: false,
1517
1512
  error: `Unknown system subcommand: ${parsed.subcommand}\n\nRun "celilo system --help" for usage`,
@@ -95,7 +95,10 @@ export const SecretGenerateSchema = z.object({
95
95
 
96
96
  export const SecretDeclareSchema = z.object({
97
97
  name: z.string().min(1),
98
- type: z.enum(['string', 'integer', 'number']).default('string'),
98
+ // `string-map` is `Record<string, string>` (e.g. domain → password). It's
99
+ // stored as JSON.stringify'd text on disk, but the interview uses an
100
+ // add-loop UX so the operator never has to type JSON braces.
101
+ type: z.enum(['string', 'integer', 'number', 'string-map']).default('string'),
99
102
  required: z.boolean().default(false),
100
103
  description: z.string().optional(),
101
104
  sensitive: z.boolean().default(true), // Don't log in CLI output
@@ -104,6 +107,11 @@ export const SecretDeclareSchema = z.object({
104
107
  minimum: z.number().optional(),
105
108
  maximum: z.number().optional(),
106
109
  pattern: z.string().optional(),
110
+ // For `type: string-map` only: human-readable labels shown in the
111
+ // add-loop prompt. Defaults are 'key' / 'value' which are usually too
112
+ // generic; namecheap wants 'Domain' / 'Password'.
113
+ key_label: z.string().optional(),
114
+ value_label: z.string().optional(),
107
115
  });
108
116
 
109
117
  /**
@@ -417,8 +417,10 @@ async function installScriptDependencies(
417
417
  return;
418
418
  }
419
419
 
420
- // Run bun install in the scripts directory
421
- log.info('Installing script dependencies...');
420
+ // Run bun install in the scripts directory. The bun-install can take a
421
+ // few seconds on a cold cache, so we surface a status line — silent
422
+ // pauses look like hangs.
423
+ log.info('Installing dependencies for module hook scripts...');
422
424
  execSync('bun install', {
423
425
  cwd: scriptsDir,
424
426
  timeout: 120_000,
@@ -115,11 +115,24 @@ export class RegistryClient {
115
115
  version: string;
116
116
  netappPath: string;
117
117
  token: string;
118
+ /**
119
+ * One-line description from the module's manifest.yml. Optional
120
+ * (server tolerates absence) but recommended — it's what shows
121
+ * up in the registry's browse UI per
122
+ * apps/celilo/designs/REGISTRY_BROWSE_UI.md (Phase 2 step 0).
123
+ */
124
+ description?: string;
118
125
  }): Promise<{ ok: boolean; name: string; vers: string }> {
119
126
  const fileData = await readFile(opts.netappPath);
120
127
  const cksum = `sha256:${createHash('sha256').update(fileData).digest('hex')}`;
121
128
 
122
- const meta = JSON.stringify({ name: opts.name, vers: opts.version, deps: [], cksum });
129
+ const meta = JSON.stringify({
130
+ name: opts.name,
131
+ vers: opts.version,
132
+ deps: [],
133
+ cksum,
134
+ ...(opts.description ? { description: opts.description } : {}),
135
+ });
123
136
  const metaBuf = Buffer.from(meta, 'utf-8');
124
137
 
125
138
  const body = Buffer.allocUnsafe(4 + metaBuf.length + 4 + fileData.length);
@@ -70,11 +70,23 @@ export interface ConfigReply {
70
70
  export interface SecretRequiredPayload {
71
71
  module: string;
72
72
  key: string;
73
- type: 'string' | 'integer' | 'number';
73
+ /**
74
+ * `string-map` = Record<string, string>; the responder runs an
75
+ * add-loop and serializes to JSON before storing, so the operator
76
+ * never types braces or commas. See terminal-responder.ts for the UX.
77
+ */
78
+ type: 'string' | 'integer' | 'number' | 'string-map';
74
79
  required: boolean;
75
80
  description?: string;
76
81
  style?: 'user_provided' | 'user_password' | 'generated_optional';
77
82
  generate?: { format: string; length: number };
83
+ /**
84
+ * For `type: string-map` only — labels shown in the add-loop prompt.
85
+ * Defaults to 'key' / 'value' when absent. namecheap uses
86
+ * 'Domain' / 'Password'.
87
+ */
88
+ key_label?: string;
89
+ value_label?: string;
78
90
  }
79
91
 
80
92
  export interface SecretAck {