@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 +2 -2
- package/src/cli/command-registry.ts +12 -0
- package/src/cli/commands/doctor.test.ts +36 -0
- package/src/cli/commands/doctor.ts +385 -0
- package/src/cli/commands/module-generate.ts +37 -1
- package/src/cli/commands/module-remove.ts +13 -2
- package/src/cli/completion.ts +30 -6
- package/src/cli/index.ts +8 -0
- package/src/config/paths.ts +13 -27
- package/src/db/client.ts +8 -1
- package/src/hooks/logger.ts +6 -1
- package/src/module/import.ts +97 -73
- package/src/services/deploy-validation.ts +15 -1
- package/src/services/module-build.test.ts +38 -0
- package/src/services/module-build.ts +6 -0
- package/src/services/module-deploy.ts +28 -2
- package/src/services/programmatic-responder.ts +15 -0
- package/src/services/proxmox-preflight.test.ts +63 -0
- package/src/services/proxmox-preflight.ts +100 -0
- package/src/services/responder-probe.ts +45 -0
- package/src/services/terminal-responder.ts +13 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "0.3.
|
|
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.
|
|
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
|
-
//
|
|
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,
|
|
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
|
|
package/src/cli/completion.ts
CHANGED
|
@@ -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
|
-
'
|
|
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);
|
package/src/config/paths.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
36
|
+
// macOS
|
|
53
37
|
if (platform() === 'darwin') {
|
|
54
38
|
return join(homedir(), 'Library', 'Application Support', 'celilo');
|
|
55
39
|
}
|
|
56
40
|
|
|
57
|
-
// Linux/other
|
|
58
|
-
|
|
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
|
/**
|