@celilo/cli 0.3.11 → 0.3.13

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.3.11",
3
+ "version": "0.3.13",
4
4
  "description": "Celilo — home lab orchestration CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -53,7 +53,7 @@
53
53
  "dependencies": {
54
54
  "@aws-sdk/client-s3": "^3.1024.0",
55
55
  "@celilo/capabilities": "^0.1.10",
56
- "@celilo/cli-display": "^0.1.8",
56
+ "@celilo/cli-display": "^0.1.9",
57
57
  "@celilo/event-bus": "^0.1.4",
58
58
  "@clack/prompts": "^1.1.0",
59
59
  "ajv": "^8.18.0",
@@ -66,6 +66,18 @@ export const COMMANDS: CommandDef[] = [
66
66
  name: 'status',
67
67
  description: 'Show system and module status',
68
68
  },
69
+ {
70
+ name: 'doctor',
71
+ description: 'Diagnose @celilo/* version drift between the running CLI and the workspace',
72
+ flags: [
73
+ {
74
+ name: 'fix',
75
+ description:
76
+ 'Repair drift by `bun link`-ing each drifted @celilo/* package from the workspace',
77
+ takesValue: false,
78
+ },
79
+ ],
80
+ },
69
81
  {
70
82
  name: 'audit',
71
83
  description: 'Top-level alias for `system audit`',
@@ -0,0 +1,36 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { compareVersions } from './doctor';
3
+
4
+ describe('compareVersions', () => {
5
+ test('detects ascending major/minor/patch', () => {
6
+ expect(compareVersions('1.0.0', '2.0.0')).toBe(-1);
7
+ expect(compareVersions('1.0.0', '1.1.0')).toBe(-1);
8
+ expect(compareVersions('1.0.0', '1.0.1')).toBe(-1);
9
+ });
10
+
11
+ test('detects descending major/minor/patch', () => {
12
+ expect(compareVersions('2.0.0', '1.0.0')).toBe(1);
13
+ expect(compareVersions('1.1.0', '1.0.0')).toBe(1);
14
+ expect(compareVersions('1.0.1', '1.0.0')).toBe(1);
15
+ });
16
+
17
+ test('treats equal versions as equal', () => {
18
+ expect(compareVersions('1.2.3', '1.2.3')).toBe(0);
19
+ expect(compareVersions('0.1.9', '0.1.9')).toBe(0);
20
+ });
21
+
22
+ test('catches the canonical drift case (loaded < workspace)', () => {
23
+ // The case that triggered #2 in the first place: globally-installed
24
+ // 0.1.8 vs. workspace 0.1.9.
25
+ expect(compareVersions('0.1.8', '0.1.9')).toBe(-1);
26
+ });
27
+
28
+ test('treats missing trailing segments as zeros', () => {
29
+ expect(compareVersions('1.0', '1.0.0')).toBe(0);
30
+ expect(compareVersions('1.0', '1.0.1')).toBe(-1);
31
+ });
32
+
33
+ test('strips a leading v prefix', () => {
34
+ expect(compareVersions('v1.2.3', '1.2.3')).toBe(0);
35
+ });
36
+ });
@@ -0,0 +1,385 @@
1
+ /**
2
+ * `celilo doctor` — diagnose @celilo/* version drift between the running CLI
3
+ * and the surrounding workspace (if any).
4
+ *
5
+ * Catches the canonical "I edited the workspace but my global celilo is
6
+ * still running an older published version" failure mode.
7
+ *
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).
12
+ * - 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.
17
+ */
18
+
19
+ import { spawnSync } from 'node:child_process';
20
+ import { existsSync, readFileSync } from 'node:fs';
21
+ import { createRequire } from 'node:module';
22
+ import { dirname, join, resolve } from 'node:path';
23
+ import cliPkg from '../../../package.json' with { type: 'json' };
24
+ import type { CommandResult } from '../types';
25
+
26
+ interface CeliloPkgInfo {
27
+ name: string;
28
+ declaredRange: string;
29
+ loadedVersion: string | null;
30
+ loadedFrom: string | null;
31
+ resolveError: string | null;
32
+ }
33
+
34
+ interface WorkspaceVersion {
35
+ name: string;
36
+ version: string;
37
+ path: string;
38
+ }
39
+
40
+ const ANSI = {
41
+ reset: '\x1b[0m',
42
+ dim: '\x1b[2m',
43
+ green: '\x1b[32m',
44
+ yellow: '\x1b[33m',
45
+ red: '\x1b[31m',
46
+ };
47
+
48
+ /**
49
+ * Discover every `@celilo/*` entry in the running CLI's package.json
50
+ * dependencies and resolve where each one is actually loaded from.
51
+ */
52
+ function inspectCeliloDeps(): CeliloPkgInfo[] {
53
+ const deps: Record<string, string> = {
54
+ ...((cliPkg as { dependencies?: Record<string, string> }).dependencies ?? {}),
55
+ };
56
+ const celiloDeps = Object.entries(deps)
57
+ .filter(([name]) => name.startsWith('@celilo/'))
58
+ .sort(([a], [b]) => a.localeCompare(b));
59
+
60
+ const require = createRequire(import.meta.url);
61
+
62
+ return celiloDeps.map(([name, declaredRange]) => {
63
+ try {
64
+ const pkgJsonPath = require.resolve(`${name}/package.json`);
65
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')) as { version: string };
66
+ return {
67
+ name,
68
+ declaredRange,
69
+ loadedVersion: pkg.version,
70
+ loadedFrom: dirname(pkgJsonPath),
71
+ resolveError: null,
72
+ };
73
+ } catch (err) {
74
+ return {
75
+ name,
76
+ declaredRange,
77
+ loadedVersion: null,
78
+ loadedFrom: null,
79
+ resolveError: err instanceof Error ? err.message : String(err),
80
+ };
81
+ }
82
+ });
83
+ }
84
+
85
+ /**
86
+ * Walk up from `start` until we find a `package.json` whose `workspaces`
87
+ * key is non-empty. Returns the directory containing that file or null.
88
+ */
89
+ function findWorkspaceRoot(start: string): string | null {
90
+ let dir = resolve(start);
91
+ while (true) {
92
+ const candidate = join(dir, 'package.json');
93
+ if (existsSync(candidate)) {
94
+ try {
95
+ const pkg = JSON.parse(readFileSync(candidate, 'utf-8')) as {
96
+ workspaces?: string[] | { packages?: string[] };
97
+ };
98
+ const workspaces = Array.isArray(pkg.workspaces)
99
+ ? pkg.workspaces
100
+ : (pkg.workspaces?.packages ?? []);
101
+ if (workspaces.length > 0) return dir;
102
+ } catch {
103
+ /* malformed package.json — keep walking */
104
+ }
105
+ }
106
+ const parent = dirname(dir);
107
+ if (parent === dir) return null;
108
+ dir = parent;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Read every `@celilo/*` package.json in the workspace and return the
114
+ * version each one declares. Used to compare against what the running
115
+ * CLI actually loaded.
116
+ */
117
+ function collectWorkspaceVersions(workspaceRoot: string): WorkspaceVersion[] {
118
+ const out: WorkspaceVersion[] = [];
119
+ // Hard-code the two glob roots used in this monorepo to avoid pulling
120
+ // in a glob library. Both directories are scanned the same way: read
121
+ // each immediate child, look for a package.json that names a @celilo/*
122
+ // package.
123
+ for (const dir of ['packages', 'apps']) {
124
+ const root = join(workspaceRoot, dir);
125
+ if (!existsSync(root)) continue;
126
+ for (const entry of readSubdirs(root)) {
127
+ const pkgJsonPath = join(root, entry, 'package.json');
128
+ if (!existsSync(pkgJsonPath)) continue;
129
+ try {
130
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')) as {
131
+ name?: string;
132
+ version?: string;
133
+ };
134
+ if (pkg.name?.startsWith('@celilo/') && pkg.version) {
135
+ out.push({ name: pkg.name, version: pkg.version, path: join(root, entry) });
136
+ }
137
+ } catch {
138
+ /* skip malformed */
139
+ }
140
+ }
141
+ }
142
+ return out;
143
+ }
144
+
145
+ function readSubdirs(dir: string): string[] {
146
+ // node:fs readdirSync — keep stdlib, no extra deps.
147
+ // Hidden dirs filtered out.
148
+ try {
149
+ const fs = require('node:fs') as typeof import('node:fs');
150
+ return fs
151
+ .readdirSync(dir, { withFileTypes: true })
152
+ .filter((d) => d.isDirectory() && !d.name.startsWith('.'))
153
+ .map((d) => d.name);
154
+ } catch {
155
+ return [];
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Compare two semver-ish version strings. Returns -1 if a < b, 0 if
161
+ * equal, 1 if a > b. Tolerates non-numeric prerelease tags by comparing
162
+ * them as strings after the numeric segments.
163
+ *
164
+ * Exported for unit testing.
165
+ */
166
+ export function compareVersions(a: string, b: string): number {
167
+ const split = (v: string) => v.replace(/^[v=]+/, '').split(/[.+-]/);
168
+ const aParts = split(a);
169
+ const bParts = split(b);
170
+ const len = Math.max(aParts.length, bParts.length);
171
+ for (let i = 0; i < len; i++) {
172
+ const ap = aParts[i] ?? '0';
173
+ const bp = bParts[i] ?? '0';
174
+ const an = Number(ap);
175
+ const bn = Number(bp);
176
+ if (!Number.isNaN(an) && !Number.isNaN(bn)) {
177
+ if (an !== bn) return an < bn ? -1 : 1;
178
+ } else if (ap !== bp) {
179
+ return ap < bp ? -1 : 1;
180
+ }
181
+ }
182
+ return 0;
183
+ }
184
+
185
+ interface DriftedDep {
186
+ name: string;
187
+ loadedVersion: string;
188
+ workspaceVersion: string;
189
+ workspacePath: string;
190
+ }
191
+
192
+ /**
193
+ * Run a command, capture stdout/stderr, return whether it succeeded.
194
+ * Used for the `bun link` calls that --fix orchestrates.
195
+ */
196
+ function runCommand(
197
+ cmd: string,
198
+ args: string[],
199
+ cwd: string,
200
+ ): { ok: boolean; stdout: string; stderr: string } {
201
+ const r = spawnSync(cmd, args, { cwd, encoding: 'utf-8' });
202
+ return {
203
+ ok: r.status === 0,
204
+ stdout: (r.stdout ?? '').trim(),
205
+ stderr: (r.stderr ?? '').trim(),
206
+ };
207
+ }
208
+
209
+ /**
210
+ * Repair drift by `bun link`-ing each drifted package from the
211
+ * workspace into the running CLI's package directory.
212
+ *
213
+ * Two-step bun link workflow:
214
+ * 1. From each workspace package dir: `bun link` registers it
215
+ * globally under its package name.
216
+ * 2. From the running CLI's package dir: `bun link <pkgname>`
217
+ * replaces the resolved copy with the symlink to the workspace.
218
+ *
219
+ * `bun unlink` reverses both steps if the user wants to revert.
220
+ *
221
+ * Only safe to run when running from a globally-installed CLI; from
222
+ * a workspace TS-source invocation there's nothing to repair.
223
+ */
224
+ function applyFix(drifted: DriftedDep[], cliRoot: string): string[] {
225
+ const lines: string[] = [];
226
+ for (const d of drifted) {
227
+ lines.push(` ${d.name}: linking ${d.workspaceVersion} from ${d.workspacePath}`);
228
+
229
+ const reg = runCommand('bun', ['link'], d.workspacePath);
230
+ if (!reg.ok) {
231
+ lines.push(
232
+ ` ${ANSI.red}✗${ANSI.reset} register failed: ${reg.stderr || reg.stdout || 'no output'}`,
233
+ );
234
+ continue;
235
+ }
236
+
237
+ const link = runCommand('bun', ['link', d.name], cliRoot);
238
+ if (!link.ok) {
239
+ lines.push(
240
+ ` ${ANSI.red}✗${ANSI.reset} link failed: ${link.stderr || link.stdout || 'no output'}`,
241
+ );
242
+ continue;
243
+ }
244
+
245
+ lines.push(` ${ANSI.green}✔${ANSI.reset} linked`);
246
+ }
247
+ return lines;
248
+ }
249
+
250
+ export async function handleDoctor(
251
+ _args: string[],
252
+ flags: Record<string, string | boolean>,
253
+ ): Promise<CommandResult> {
254
+ const lines: string[] = [];
255
+
256
+ const cliVersion = (cliPkg as { version: string }).version;
257
+ const cliName = (cliPkg as { name: string }).name;
258
+ // Where is *this* file loaded from? Anchors the "running from" line.
259
+ const cliRoot = resolve(dirname(new URL(import.meta.url).pathname), '../../..');
260
+ lines.push(`${cliName} ${cliVersion}`);
261
+ lines.push(`${ANSI.dim}running from ${cliRoot}${ANSI.reset}`);
262
+ lines.push('');
263
+
264
+ const workspaceRoot = findWorkspaceRoot(process.cwd());
265
+ const workspaceVersions = workspaceRoot ? collectWorkspaceVersions(workspaceRoot) : [];
266
+ const workspaceMap = new Map(workspaceVersions.map((w) => [w.name, w]));
267
+
268
+ if (workspaceRoot) {
269
+ lines.push(`workspace: ${workspaceRoot}`);
270
+ } else {
271
+ lines.push(`${ANSI.dim}no workspace detected from ${process.cwd()}${ANSI.reset}`);
272
+ }
273
+ lines.push('');
274
+
275
+ const deps = inspectCeliloDeps();
276
+ // Track whether anything is amiss so we can summarize and exit non-zero.
277
+ let driftCount = 0;
278
+ let unresolvedCount = 0;
279
+ const drifted: DriftedDep[] = [];
280
+
281
+ // Compute column widths for a clean table.
282
+ const nameCol = Math.max(...deps.map((d) => d.name.length), 12);
283
+ const declCol = Math.max(...deps.map((d) => d.declaredRange.length), 8);
284
+ const loadedCol = Math.max(...deps.map((d) => (d.loadedVersion ?? '?').length), 8);
285
+
286
+ lines.push(
287
+ ` ${'package'.padEnd(nameCol)} ${'declares'.padEnd(declCol)} ${'loaded'.padEnd(loadedCol)} notes`,
288
+ );
289
+ lines.push(` ${'-'.repeat(nameCol)} ${'-'.repeat(declCol)} ${'-'.repeat(loadedCol)} -----`);
290
+
291
+ for (const dep of deps) {
292
+ const loaded = dep.loadedVersion ?? '?';
293
+ const notes: string[] = [];
294
+ let glyph = `${ANSI.green}✔${ANSI.reset}`;
295
+
296
+ if (dep.resolveError) {
297
+ glyph = `${ANSI.red}✗${ANSI.reset}`;
298
+ notes.push(`unresolved: ${dep.resolveError.split('\n')[0]}`);
299
+ unresolvedCount++;
300
+ } else if (dep.loadedVersion) {
301
+ const ws = workspaceMap.get(dep.name);
302
+ if (ws) {
303
+ const cmp = compareVersions(dep.loadedVersion, ws.version);
304
+ if (cmp < 0) {
305
+ glyph = `${ANSI.yellow}⚠${ANSI.reset}`;
306
+ notes.push(`workspace has ${ws.version} — running CLI is behind`);
307
+ driftCount++;
308
+ drifted.push({
309
+ name: dep.name,
310
+ loadedVersion: dep.loadedVersion,
311
+ workspaceVersion: ws.version,
312
+ workspacePath: ws.path,
313
+ });
314
+ } else if (cmp > 0) {
315
+ notes.push(`workspace has ${ws.version} (older — unpublished bump?)`);
316
+ }
317
+ }
318
+ if (dep.loadedFrom) {
319
+ const shortPath = dep.loadedFrom.replace(process.env.HOME ?? '', '~');
320
+ notes.push(`from ${shortPath}`);
321
+ }
322
+ }
323
+
324
+ lines.push(
325
+ `${glyph} ${dep.name.padEnd(nameCol)} ${dep.declaredRange.padEnd(declCol)} ${loaded.padEnd(loadedCol)} ${ANSI.dim}${notes.join('; ')}${ANSI.reset}`,
326
+ );
327
+ }
328
+
329
+ // Workspace packages that the CLI doesn't depend on — surface them so
330
+ // the operator sees the full set of @celilo/* in play.
331
+ const declaredNames = new Set(deps.map((d) => d.name));
332
+ const extras = workspaceVersions.filter((w) => !declaredNames.has(w.name));
333
+ if (extras.length > 0) {
334
+ lines.push('');
335
+ lines.push(`${ANSI.dim}other workspace packages (not depended on by this CLI):${ANSI.reset}`);
336
+ for (const w of extras) {
337
+ lines.push(` ${w.name} ${w.version} ${ANSI.dim}${w.path}${ANSI.reset}`);
338
+ }
339
+ }
340
+
341
+ lines.push('');
342
+
343
+ const fix = flags.fix === true;
344
+
345
+ if (fix && drifted.length > 0) {
346
+ lines.push(`Repairing ${drifted.length} drifted package(s) with \`bun link\`:`);
347
+ lines.push(...applyFix(drifted, cliRoot));
348
+ lines.push('');
349
+ lines.push(
350
+ `${ANSI.dim}Re-run \`celilo doctor\` to verify; \`bun unlink\` from each workspace dir reverses.${ANSI.reset}`,
351
+ );
352
+ return {
353
+ success: true,
354
+ message: lines.join('\n'),
355
+ rawOutput: true,
356
+ };
357
+ }
358
+
359
+ if (fix && drifted.length === 0) {
360
+ lines.push(`${ANSI.dim}--fix: nothing to repair.${ANSI.reset}`);
361
+ }
362
+
363
+ if (driftCount > 0 || unresolvedCount > 0) {
364
+ const summary: string[] = [];
365
+ if (driftCount > 0) summary.push(`${driftCount} package(s) behind workspace`);
366
+ if (unresolvedCount > 0) summary.push(`${unresolvedCount} unresolved`);
367
+ if (drifted.length > 0) {
368
+ lines.push(
369
+ `${ANSI.dim}Run \`celilo doctor --fix\` to bun-link drifted packages from the workspace.${ANSI.reset}`,
370
+ );
371
+ }
372
+ return {
373
+ success: false,
374
+ error: `Drift detected: ${summary.join(', ')}`,
375
+ details: lines.join('\n'),
376
+ };
377
+ }
378
+
379
+ lines.push(`${ANSI.green}OK${ANSI.reset} — no drift detected`);
380
+ return {
381
+ success: true,
382
+ message: lines.join('\n'),
383
+ rawOutput: true,
384
+ };
385
+ }
@@ -133,7 +133,43 @@ export async function handleModuleGenerate(
133
133
  const moduleSecretsMissing = await validateModuleSecrets(moduleId, db);
134
134
 
135
135
  if (moduleSecretsMissing.length > 0) {
136
- // Always try to interview for missing secrets
136
+ // interviewForMissingSecrets fires `secret.required.*` bus events for
137
+ // user_provided secrets and waits forever for a responder. In a
138
+ // non-interactive context (no TTY, no piped responder), that's a
139
+ // silent hang. Probe the bus first; if nothing answers, fail fast
140
+ // with an actionable error instead of stalling indefinitely.
141
+ //
142
+ // Auto-generated secrets (manifest `generate:` field or schema
143
+ // source: 'generated') don't go through the bus, so missing-but-
144
+ // auto-generatable doesn't need a responder. Filter those out
145
+ // before deciding whether to probe.
146
+ if (!process.stdin.isTTY) {
147
+ const { getSecretMetadata } = await import('../../services/secret-schema-loader');
148
+ const promptable: typeof moduleSecretsMissing = [];
149
+ for (const s of moduleSecretsMissing) {
150
+ if (s.generate) continue; // manifest-declared auto-generate
151
+ const meta = await getSecretMetadata(moduleId, s.name, db);
152
+ if (meta?.source === 'generated') continue; // schema-declared auto-generate
153
+ promptable.push(s);
154
+ }
155
+
156
+ if (promptable.length > 0) {
157
+ const { probeForResponder } = await import('../../services/responder-probe');
158
+ const { getEventBusPath } = await import('../../config/paths');
159
+ const responderAvailable = await probeForResponder(getEventBusPath());
160
+ if (!responderAvailable) {
161
+ const names = promptable.map((s) => s.name).join(', ');
162
+ const setCommands = promptable
163
+ .map((s) => ` celilo module secret set ${moduleId} ${s.name} <value>`)
164
+ .join('\n');
165
+ return {
166
+ success: false,
167
+ error: `Missing required secret(s): ${names}\n\nNo responder is running and stdin isn't a TTY, so module generate can't prompt for them. Either:\n 1. Run interactively (in a terminal)\n 2. Pre-set the secrets:\n${setCommands}\n 3. Run a responder in another shell:\n celilo events respond --values values.json`,
168
+ };
169
+ }
170
+ }
171
+ }
172
+
137
173
  // Auto-generated secrets work in non-interactive mode
138
174
  const result = await interviewForMissingSecrets(moduleId, moduleSecretsMissing, db);
139
175
  if (!result.success) {
@@ -221,11 +221,22 @@ export async function handleModuleRemove(
221
221
  log.success('Infrastructure destroyed');
222
222
  }
223
223
 
224
- // Remove DNS record (if dns_internal is available)
224
+ // Remove DNS record (if dns_internal is available). Wrapped in a
225
+ // FuelGauge with a gauge logger so the dns_internal capability calls
226
+ // inside (`deleteRecord`, etc.) nest as sub-events rather than
227
+ // leaking to scrollback as unindented top-level lines via
228
+ // cli/prompts.log.instantEvent (which pops every pending step).
229
+ const dnsGauge = new FuelGauge(`Removing ${moduleId} from DNS`, {
230
+ skipAnimation: !process.stdout.isTTY,
231
+ });
232
+ dnsGauge.start();
225
233
  try {
234
+ const dnsLogger = createGaugeLogger(dnsGauge, moduleId, 'auto_deregister_dns');
226
235
  const { autoDeregisterDns } = await import('../../services/dns-auto-register');
227
- await autoDeregisterDns(moduleId, db, log);
236
+ await autoDeregisterDns(moduleId, db, dnsLogger);
237
+ dnsGauge.stop(true);
228
238
  } catch {
239
+ dnsGauge.stop(false);
229
240
  // Non-fatal -- continue with removal
230
241
  }
231
242
 
@@ -28,20 +28,22 @@ export async function getCompletions(words: string[], current: number): Promise<
28
28
  // currentIndex === 0 means we're completing the first word (the command)
29
29
  if (currentIndex === 0) {
30
30
  const commands = [
31
+ 'audit',
31
32
  'backup',
32
33
  'capability',
34
+ 'completion',
35
+ 'doctor',
36
+ 'events',
33
37
  'help',
34
38
  'hook',
35
- 'package',
39
+ 'ipam',
40
+ 'machine',
36
41
  'module',
42
+ 'package',
37
43
  'service',
44
+ 'status',
38
45
  'storage',
39
- 'machine',
40
46
  'system',
41
- 'ipam',
42
- 'completion',
43
- 'status',
44
- 'audit',
45
47
  'version',
46
48
  ];
47
49
  return filterSuggestions(commands, args[0] || '');
@@ -66,6 +68,28 @@ export async function getCompletions(words: string[], current: number): Promise<
66
68
  return filterSuggestions(capabilityNames, args[2] || '');
67
69
  }
68
70
 
71
+ // Events subcommands — keep this list in sync with command-registry.ts.
72
+ if (command === 'events' && currentIndex === 1) {
73
+ const subcommands = [
74
+ 'status',
75
+ 'tail',
76
+ 'list-subscribers',
77
+ 'list-pending',
78
+ 'drain',
79
+ 'run',
80
+ 'emit',
81
+ 'ack',
82
+ 'fail',
83
+ 'repair',
84
+ 'resume',
85
+ 'respond',
86
+ 'install-daemon',
87
+ 'uninstall-daemon',
88
+ 'show-daemon',
89
+ ];
90
+ return filterSuggestions(subcommands, args[1] || '');
91
+ }
92
+
69
93
  // Hook subcommands
70
94
  if (command === 'hook' && currentIndex === 1) {
71
95
  const subcommands = ['run'];
package/src/cli/index.ts CHANGED
@@ -10,6 +10,7 @@ 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';
13
14
  import {
14
15
  handleEventsAck,
15
16
  handleEventsDrain,
@@ -147,7 +148,9 @@ Usage:
147
148
 
148
149
  Commands:
149
150
  status Show system and module status
151
+ doctor Diagnose @celilo/* version drift between the running CLI and the workspace
150
152
  audit Top-level alias for 'system audit'
153
+ events SQLite event-bus operations (status, tail, run dispatcher, etc.)
151
154
  capability View registered module capabilities
152
155
  package Create distributable .netapp packages from module source
153
156
  module Manage modules (import, list, configure, build, generate)
@@ -900,6 +903,11 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
900
903
  return handleStatus();
901
904
  }
902
905
 
906
+ // Handle doctor command
907
+ if (parsed.command === 'doctor') {
908
+ return handleDoctor(parsed.args, parsed.flags);
909
+ }
910
+
903
911
  // Top-level alias: `celilo audit` → `celilo system audit`
904
912
  if (parsed.command === 'audit') {
905
913
  return handleSystemAudit(parsed.args, parsed.flags);
@@ -4,38 +4,22 @@ import { join } from 'node:path';
4
4
  /**
5
5
  * Get the module storage path based on environment and platform
6
6
  *
7
- * Priority:
8
- * 1. CELILO_DATA_DIR environment variable (explicit override)
9
- * 2. ENVIRONMENT=dev uses ./celilo-data/modules/ (for testing)
10
- * 3. Platform defaults:
11
- * - macOS: ~/Library/Application Support/celilo/modules/
12
- * - Linux: /var/lib/celilo/modules/
13
- *
14
7
  * @returns Absolute path to module storage directory
15
8
  */
16
9
  export function getModuleStoragePath(): string {
17
- // Explicit override
18
- if (process.env.CELILO_DATA_DIR) {
19
- return join(process.env.CELILO_DATA_DIR, 'modules');
20
- }
21
-
22
- // Development mode
23
- if (process.env.ENVIRONMENT === 'dev') {
24
- return join(process.cwd(), 'celilo-data', 'modules');
25
- }
26
-
27
- // Platform defaults
28
- if (platform() === 'darwin') {
29
- return join(homedir(), 'Library', 'Application Support', 'celilo', 'modules');
30
- }
31
-
32
- // Linux/other
33
- return '/var/lib/celilo/modules';
10
+ return join(getDataDir(), 'modules');
34
11
  }
35
12
 
36
13
  /**
37
14
  * Get the base data directory for Celilo
38
15
  *
16
+ * Priority:
17
+ * 1. CELILO_DATA_DIR environment variable (explicit override)
18
+ * 2. ENVIRONMENT=dev uses ./celilo-data/ (for testing)
19
+ * 3. Platform defaults — always user-scoped so the CLI works without root:
20
+ * - macOS: ~/Library/Application Support/celilo/
21
+ * - Linux: $XDG_DATA_HOME/celilo/ (defaults to ~/.local/share/celilo/)
22
+ *
39
23
  * @returns Absolute path to base data directory
40
24
  */
41
25
  export function getDataDir(): string {
@@ -49,13 +33,15 @@ export function getDataDir(): string {
49
33
  return join(process.cwd(), 'celilo-data');
50
34
  }
51
35
 
52
- // Platform defaults
36
+ // macOS
53
37
  if (platform() === 'darwin') {
54
38
  return join(homedir(), 'Library', 'Application Support', 'celilo');
55
39
  }
56
40
 
57
- // Linux/other
58
- return '/var/lib/celilo';
41
+ // Linux/other — XDG Base Directory spec, user-scoped (no sudo needed).
42
+ // System-wide installs can opt in via CELILO_DATA_DIR=/var/lib/celilo.
43
+ const xdgDataHome = process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share');
44
+ return join(xdgDataHome, 'celilo');
59
45
  }
60
46
 
61
47
  /**