@celilo/cli 0.5.0-alpha.4 → 0.5.0-alpha.6
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/drizzle/0010_dns_internal_records.sql +12 -0
- package/drizzle/0011_backups_name.sql +1 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +2 -2
- package/src/ansible/inventory.test.ts +10 -10
- package/src/ansible/validation.test.ts +25 -15
- package/src/cli/command-registry.ts +13 -2
- package/src/cli/commands/events.test.ts +4 -4
- package/src/cli/commands/events.ts +2 -2
- package/src/cli/commands/service-add-proxmox.ts +9 -0
- package/src/cli/commands/system-doctor.ts +135 -40
- package/src/cli/commands/system-migrate.test.ts +40 -0
- package/src/cli/commands/system-migrate.ts +65 -0
- package/src/cli/completion.ts +1 -0
- package/src/cli/index.ts +7 -2
- package/src/config/paths.test.ts +61 -48
- package/src/db/client.ts +15 -146
- package/src/db/migrate.ts +14 -6
- package/src/db/schema-introspection.ts +88 -0
- package/src/db/schema.ts +38 -0
- package/src/hooks/capability-loader-firewall.test.ts +3 -3
- package/src/hooks/capability-loader.ts +24 -15
- package/src/infrastructure/property-extractor.test.ts +15 -0
- package/src/infrastructure/property-extractor.ts +12 -0
- package/src/manifest/schema.ts +7 -0
- package/src/manifest/validate.test.ts +53 -0
- package/src/services/bus-interview.test.ts +2 -2
- package/src/services/bus-secret-flow.test.ts +2 -2
- package/src/services/celilo-mgmt-hooks.test.ts +3 -2
- package/src/services/deploy-preflight.ts +25 -0
- package/src/services/deploy-validation.test.ts +2 -2
- package/src/services/dns-internal-records.test.ts +126 -0
- package/src/services/dns-internal-records.ts +119 -0
- package/src/services/dns-provider-backfill.test.ts +2 -2
- package/src/services/dns-registrations.test.ts +10 -10
- package/src/services/fleet-checks.test.ts +495 -0
- package/src/services/fleet-checks.ts +663 -0
- package/src/services/module-build.test.ts +43 -38
- package/src/templates/generator.test.ts +62 -12
- package/src/templates/generator.ts +69 -50
- package/src/test-utils/fixtures.test.ts +1 -1
- package/src/test-utils/integration-guard.ts +33 -0
- package/src/types/infrastructure.ts +6 -0
- package/src/variables/computed/computed-integration.test.ts +3 -3
- package/src/variables/computed/computed.test.ts +5 -5
- package/src/variables/declarative-derivation.test.ts +6 -6
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
CREATE TABLE `dns_internal_records` (
|
|
2
|
+
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
3
|
+
`provider_module_id` text NOT NULL,
|
|
4
|
+
`consumer_module_id` text NOT NULL,
|
|
5
|
+
`host` text NOT NULL,
|
|
6
|
+
`ip` text NOT NULL,
|
|
7
|
+
`registered_at` integer DEFAULT (unixepoch()) NOT NULL,
|
|
8
|
+
FOREIGN KEY (`provider_module_id`) REFERENCES `modules`(`id`) ON UPDATE no action ON DELETE cascade,
|
|
9
|
+
FOREIGN KEY (`consumer_module_id`) REFERENCES `modules`(`id`) ON UPDATE no action ON DELETE cascade
|
|
10
|
+
);
|
|
11
|
+
--> statement-breakpoint
|
|
12
|
+
CREATE UNIQUE INDEX `dns_internal_records_provider_host_idx` ON `dns_internal_records` (`provider_module_id`,`host`);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE `backups` ADD `name` text;
|
|
@@ -71,6 +71,20 @@
|
|
|
71
71
|
"when": 1781280898000,
|
|
72
72
|
"tag": "0009_dns_registrations",
|
|
73
73
|
"breakpoints": true
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
"idx": 10,
|
|
77
|
+
"version": "6",
|
|
78
|
+
"when": 1781481600000,
|
|
79
|
+
"tag": "0010_dns_internal_records",
|
|
80
|
+
"breakpoints": true
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"idx": 11,
|
|
84
|
+
"version": "6",
|
|
85
|
+
"when": 1781481660000,
|
|
86
|
+
"tag": "0011_backups_name",
|
|
87
|
+
"breakpoints": true
|
|
74
88
|
}
|
|
75
89
|
]
|
|
76
90
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/cli",
|
|
3
|
-
"version": "0.5.0-alpha.
|
|
3
|
+
"version": "0.5.0-alpha.6",
|
|
4
4
|
"description": "Celilo — home lab orchestration CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"@aws-sdk/client-s3": "^3.1024.0",
|
|
55
55
|
"@celilo/capabilities": "^0.4.2",
|
|
56
56
|
"@celilo/cli-display": "^0.1.9",
|
|
57
|
-
"@celilo/event-bus": "^0.1.
|
|
57
|
+
"@celilo/event-bus": "^0.1.7",
|
|
58
58
|
"@clack/prompts": "^1.1.0",
|
|
59
59
|
"ajv": "^8.18.0",
|
|
60
60
|
"drizzle-orm": "^0.36.4",
|
|
@@ -61,7 +61,7 @@ describe('generateHostsIni', () => {
|
|
|
61
61
|
},
|
|
62
62
|
{
|
|
63
63
|
hostname: 'dns-ext',
|
|
64
|
-
ansibleHost: '
|
|
64
|
+
ansibleHost: '192.0.2.20',
|
|
65
65
|
ansibleUser: 'root',
|
|
66
66
|
groups: ['dns_external'],
|
|
67
67
|
},
|
|
@@ -72,7 +72,7 @@ describe('generateHostsIni', () => {
|
|
|
72
72
|
expect(result).toContain('[homebridge]');
|
|
73
73
|
expect(result).toContain('[dns_external]');
|
|
74
74
|
expect(result).toContain('iot ansible_host=192.168.0.110 ansible_user=root');
|
|
75
|
-
expect(result).toContain('dns-ext ansible_host=
|
|
75
|
+
expect(result).toContain('dns-ext ansible_host=192.0.2.20 ansible_user=root');
|
|
76
76
|
});
|
|
77
77
|
|
|
78
78
|
test('includes SSH private key file path when provided', () => {
|
|
@@ -136,8 +136,8 @@ describe('generateHostVarsYaml', () => {
|
|
|
136
136
|
test('generates YAML for arrays', () => {
|
|
137
137
|
const vars = {
|
|
138
138
|
zone_records: [
|
|
139
|
-
{ name: 'ns1', type: 'A', value: '
|
|
140
|
-
{ name: 'www', type: 'A', value: '
|
|
139
|
+
{ name: 'ns1', type: 'A', value: '192.0.2.20' },
|
|
140
|
+
{ name: 'www', type: 'A', value: '203.0.113.10' },
|
|
141
141
|
],
|
|
142
142
|
};
|
|
143
143
|
|
|
@@ -146,9 +146,9 @@ describe('generateHostVarsYaml', () => {
|
|
|
146
146
|
expect(result).toContain('zone_records:');
|
|
147
147
|
expect(result).toContain('- name: ns1');
|
|
148
148
|
expect(result).toContain('type: A');
|
|
149
|
-
expect(result).toContain('value:
|
|
149
|
+
expect(result).toContain('value: 192.0.2.20');
|
|
150
150
|
expect(result).toContain('- name: www');
|
|
151
|
-
expect(result).toContain('value:
|
|
151
|
+
expect(result).toContain('value: 203.0.113.10');
|
|
152
152
|
});
|
|
153
153
|
|
|
154
154
|
test('generates YAML for nested objects', () => {
|
|
@@ -359,13 +359,13 @@ describe('Database integration', () => {
|
|
|
359
359
|
);
|
|
360
360
|
|
|
361
361
|
upsertModuleConfig(db, 'dns', 'zone_records', [
|
|
362
|
-
{ name: 'ns1', type: 'A', value: '
|
|
362
|
+
{ name: 'ns1', type: 'A', value: '192.0.2.20' },
|
|
363
363
|
]);
|
|
364
364
|
|
|
365
365
|
const vars = buildHostVars('dns', db);
|
|
366
366
|
|
|
367
367
|
expect(Array.isArray(vars.zone_records)).toBe(true);
|
|
368
|
-
expect(vars.zone_records).toEqual([{ name: 'ns1', type: 'A', value: '
|
|
368
|
+
expect(vars.zone_records).toEqual([{ name: 'ns1', type: 'A', value: '192.0.2.20' }]);
|
|
369
369
|
});
|
|
370
370
|
});
|
|
371
371
|
|
|
@@ -416,13 +416,13 @@ describe('Database integration', () => {
|
|
|
416
416
|
);
|
|
417
417
|
|
|
418
418
|
upsertModuleConfig(db, 'dns-external', 'hostname', 'dns-ext');
|
|
419
|
-
upsertModuleConfig(db, 'dns-external', 'vps_ip', '
|
|
419
|
+
upsertModuleConfig(db, 'dns-external', 'vps_ip', '192.0.2.20');
|
|
420
420
|
|
|
421
421
|
const host = extractInventoryHost('dns-external', db);
|
|
422
422
|
|
|
423
423
|
expect(host).not.toBeNull();
|
|
424
424
|
expect(host?.hostname).toBe('dns-ext');
|
|
425
|
-
expect(host?.ansibleHost).toBe('
|
|
425
|
+
expect(host?.ansibleHost).toBe('192.0.2.20'); // VPS IP used directly
|
|
426
426
|
expect(host?.ansibleUser).toBe('root');
|
|
427
427
|
expect(host?.groups).toEqual(['dns-external']);
|
|
428
428
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { skipIntegration } from '../test-utils/integration-guard';
|
|
2
3
|
import {
|
|
3
4
|
isAnsibleInventoryAvailable,
|
|
4
5
|
isAnsibleLintAvailable,
|
|
@@ -54,17 +55,23 @@ describe('validateWithAnsibleLint', () => {
|
|
|
54
55
|
});
|
|
55
56
|
|
|
56
57
|
describe('validatePlaybookSyntax', () => {
|
|
57
|
-
test(
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
test.skipIf(skipIntegration({ tools: ['ansible'] }))(
|
|
59
|
+
'returns error when playbook does not exist',
|
|
60
|
+
async () => {
|
|
61
|
+
const result = await validatePlaybookSyntax('/nonexistent/playbook.yml', '/tmp');
|
|
62
|
+
expect(result.success).toBe(false);
|
|
63
|
+
expect(result.error).toContain('Playbook not found');
|
|
64
|
+
},
|
|
65
|
+
);
|
|
62
66
|
|
|
63
|
-
test(
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
test.skipIf(skipIntegration({ tools: ['ansible'] }))(
|
|
68
|
+
'returns error when inventory does not exist',
|
|
69
|
+
async () => {
|
|
70
|
+
const result = await validatePlaybookSyntax('/tmp/playbook.yml', '/nonexistent/inventory');
|
|
71
|
+
expect(result.success).toBe(false);
|
|
72
|
+
expect(result.error).toContain('not found');
|
|
73
|
+
},
|
|
74
|
+
);
|
|
68
75
|
|
|
69
76
|
test('returns error when ansible-playbook not installed', async () => {
|
|
70
77
|
if (!isAnsiblePlaybookAvailable()) {
|
|
@@ -76,11 +83,14 @@ describe('validatePlaybookSyntax', () => {
|
|
|
76
83
|
});
|
|
77
84
|
|
|
78
85
|
describe('validateInventory', () => {
|
|
79
|
-
test(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
86
|
+
test.skipIf(skipIntegration({ tools: ['ansible'] }))(
|
|
87
|
+
'returns error when inventory does not exist',
|
|
88
|
+
async () => {
|
|
89
|
+
const result = await validateInventory('/nonexistent/inventory');
|
|
90
|
+
expect(result.success).toBe(false);
|
|
91
|
+
expect(result.error).toContain('Inventory not found');
|
|
92
|
+
},
|
|
93
|
+
);
|
|
84
94
|
|
|
85
95
|
test('returns error when ansible-inventory not installed', async () => {
|
|
86
96
|
if (!isAnsibleInventoryAvailable()) {
|
|
@@ -956,14 +956,25 @@ export const COMMANDS: CommandDef[] = [
|
|
|
956
956
|
},
|
|
957
957
|
],
|
|
958
958
|
},
|
|
959
|
+
{
|
|
960
|
+
name: 'migrate',
|
|
961
|
+
description: 'Apply pending database migrations (idempotent; safe to re-run)',
|
|
962
|
+
},
|
|
959
963
|
{
|
|
960
964
|
name: 'doctor',
|
|
961
|
-
description:
|
|
965
|
+
description:
|
|
966
|
+
'Diagnose system prerequisites, @celilo/* version drift, and (on a management plane) fleet-runtime drift',
|
|
962
967
|
flags: [
|
|
963
968
|
{
|
|
964
969
|
name: 'fix',
|
|
965
970
|
description:
|
|
966
|
-
'Repair
|
|
971
|
+
'Repair the auto-fixable findings: `bun link` drifted @celilo/* packages and resync bus subscribers',
|
|
972
|
+
takesValue: false,
|
|
973
|
+
},
|
|
974
|
+
{
|
|
975
|
+
name: 'fleet',
|
|
976
|
+
description:
|
|
977
|
+
'Force the fleet-runtime section (dispatcher, subscribers, capability chains) even without a celilo DB',
|
|
967
978
|
takesValue: false,
|
|
968
979
|
},
|
|
969
980
|
],
|
|
@@ -165,20 +165,20 @@ describe('celilo events command handlers', () => {
|
|
|
165
165
|
});
|
|
166
166
|
setupBus.close();
|
|
167
167
|
|
|
168
|
-
const res = await handleEventsReply([String(query.id), '"
|
|
168
|
+
const res = await handleEventsReply([String(query.id), '"example.net"'], {});
|
|
169
169
|
expect(res.success).toBe(true);
|
|
170
170
|
if (!res.success) throw new Error('expected success');
|
|
171
171
|
const data = res.data as { status: string; family: string; value: unknown };
|
|
172
172
|
expect(data.status).toBe('replied');
|
|
173
173
|
expect(data.family).toBe('config');
|
|
174
|
-
expect(data.value).toBe('
|
|
174
|
+
expect(data.value).toBe('example.net');
|
|
175
175
|
|
|
176
176
|
const checkBus = openBus({ dbPath, events: defineEvents({}) });
|
|
177
177
|
const replies = checkBus.recentEvents({ type: 'config.required.lunacycle.domain.reply' });
|
|
178
178
|
checkBus.close();
|
|
179
179
|
expect(replies).toHaveLength(1);
|
|
180
180
|
expect(replies[0].replyFor).toBe(query.id);
|
|
181
|
-
expect(replies[0].payload).toEqual({ value: '
|
|
181
|
+
expect(replies[0].payload).toEqual({ value: 'example.net' });
|
|
182
182
|
expect(replies[0].emittedBy).toBe('claude-config-responder');
|
|
183
183
|
});
|
|
184
184
|
|
|
@@ -229,7 +229,7 @@ describe('celilo events command handlers', () => {
|
|
|
229
229
|
setupBus.close();
|
|
230
230
|
|
|
231
231
|
// A bare word isn't valid JSON — the operator must quote strings.
|
|
232
|
-
const res = await handleEventsReply([String(query.id), '
|
|
232
|
+
const res = await handleEventsReply([String(query.id), 'example.net'], {});
|
|
233
233
|
expect(res.success).toBe(false);
|
|
234
234
|
if (res.success) throw new Error('expected failure');
|
|
235
235
|
expect(res.error).toContain('Invalid JSON value');
|
|
@@ -439,7 +439,7 @@ export async function handleEventsReply(
|
|
|
439
439
|
return {
|
|
440
440
|
success: false,
|
|
441
441
|
error: `Usage: celilo events reply <query-event-id> <value-json>
|
|
442
|
-
e.g. celilo events reply 42 '"
|
|
442
|
+
e.g. celilo events reply 42 '"example.net"'`,
|
|
443
443
|
};
|
|
444
444
|
}
|
|
445
445
|
const queryId = Number(idArg);
|
|
@@ -454,7 +454,7 @@ export async function handleEventsReply(
|
|
|
454
454
|
return {
|
|
455
455
|
success: false,
|
|
456
456
|
error: `Invalid JSON value: ${err instanceof Error ? err.message : String(err)}
|
|
457
|
-
Encode the answer as JSON, e.g. '"
|
|
457
|
+
Encode the answer as JSON, e.g. '"example.net"', '8080', '["a","b"]'.`,
|
|
458
458
|
};
|
|
459
459
|
}
|
|
460
460
|
|
|
@@ -122,6 +122,14 @@ export async function handleServiceAddProxmox(
|
|
|
122
122
|
validate: validateRequired('Storage'),
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
+
// VM template name for `requires.system.type: vm` modules. Optional —
|
|
126
|
+
// skip if you only deploy LXC modules. See v2/PROXMOX_VM_TEMPLATE.md.
|
|
127
|
+
const vmTemplateInput = await promptText({
|
|
128
|
+
message: 'VM template name (optional — for VM-type modules):',
|
|
129
|
+
placeholder: 'e.g., ubuntu-2404-cloud-init-9000',
|
|
130
|
+
});
|
|
131
|
+
const vmTemplate = vmTemplateInput?.trim() || undefined;
|
|
132
|
+
|
|
125
133
|
// Find storage that supports vztmpl content. We do this BEFORE prompting
|
|
126
134
|
// for a template so the user only ever sees one storage in subsequent
|
|
127
135
|
// messages, and so the saved volid uses the right storage.
|
|
@@ -178,6 +186,7 @@ export async function handleServiceAddProxmox(
|
|
|
178
186
|
default_target_node: targetNode,
|
|
179
187
|
lxc_template: lxcTemplate,
|
|
180
188
|
storage,
|
|
189
|
+
...(vmTemplate ? { vm_template: vmTemplate } : {}),
|
|
181
190
|
},
|
|
182
191
|
});
|
|
183
192
|
|
|
@@ -30,10 +30,20 @@
|
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
import { spawnSync } from 'node:child_process';
|
|
33
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
33
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
34
34
|
import { createRequire } from 'node:module';
|
|
35
35
|
import { dirname, join, resolve } from 'node:path';
|
|
36
|
+
import { defineEvents, openBus } from '@celilo/event-bus';
|
|
36
37
|
import cliPkg from '../../../package.json' with { type: 'json' };
|
|
38
|
+
import { getDbPath, getEventBusPath } from '../../config/paths';
|
|
39
|
+
import { getDb } from '../../db/client';
|
|
40
|
+
import {
|
|
41
|
+
type FleetFinding,
|
|
42
|
+
type FleetFindingStatus,
|
|
43
|
+
checkSubscribers,
|
|
44
|
+
runFleetChecks,
|
|
45
|
+
} from '../../services/fleet-checks';
|
|
46
|
+
import { resyncAllSubscriptions } from '../../services/module-subscriptions';
|
|
37
47
|
import { checkAllPrerequisites, failingPrerequisites } from '../../system/prereqs';
|
|
38
48
|
import type { CommandResult } from '../types';
|
|
39
49
|
|
|
@@ -296,6 +306,93 @@ function renderPrereqSection(): { lines: string[]; failingCount: number } {
|
|
|
296
306
|
return { lines, failingCount: failingPrerequisites(checks).length };
|
|
297
307
|
}
|
|
298
308
|
|
|
309
|
+
/**
|
|
310
|
+
* mtime (ms) of the installed dispatcher code (`@celilo/event-bus`
|
|
311
|
+
* package.json). The fleet dispatcher check compares this against the
|
|
312
|
+
* running dispatcher's start time to spot a process on stale code. Null
|
|
313
|
+
* when the package can't be located (the staleness aspect is then skipped).
|
|
314
|
+
*/
|
|
315
|
+
function installedEventBusMtime(): number | null {
|
|
316
|
+
try {
|
|
317
|
+
const require = createRequire(import.meta.url);
|
|
318
|
+
return statSync(require.resolve('@celilo/event-bus/package.json')).mtimeMs;
|
|
319
|
+
} catch {
|
|
320
|
+
return null;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const FLEET_GLYPH: Record<FleetFindingStatus, string> = {
|
|
325
|
+
ok: `${ANSI.green}✔${ANSI.reset}`,
|
|
326
|
+
warn: `${ANSI.yellow}⚠${ANSI.reset}`,
|
|
327
|
+
fail: `${ANSI.red}✗${ANSI.reset}`,
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
function renderFleetFinding(f: FleetFinding): string[] {
|
|
331
|
+
const lines = [` ${FLEET_GLYPH[f.status]} ${f.title} ${ANSI.dim}— ${f.summary}${ANSI.reset}`];
|
|
332
|
+
for (const d of f.detail) lines.push(` ${ANSI.dim}${d}${ANSI.reset}`);
|
|
333
|
+
if (f.remediation && f.status !== 'ok')
|
|
334
|
+
lines.push(` ${ANSI.dim}→ ${f.remediation}${ANSI.reset}`);
|
|
335
|
+
return lines;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The fleet-runtime section: dispatcher / subscribers / capability-chain
|
|
340
|
+
* drift, read from the celilo DB + event bus. State-aware, so it's skipped
|
|
341
|
+
* cleanly on a fresh dev box with no DB unless `--fleet` forces it. `--fix`
|
|
342
|
+
* runs only the auto-fixable checks (today: subscribers resync).
|
|
343
|
+
*/
|
|
344
|
+
async function renderFleetSection(opts: { forced: boolean; fix: boolean }): Promise<{
|
|
345
|
+
lines: string[];
|
|
346
|
+
failCount: number;
|
|
347
|
+
warnCount: number;
|
|
348
|
+
}> {
|
|
349
|
+
const lines: string[] = ['Fleet runtime'];
|
|
350
|
+
const dbExists = existsSync(getDbPath());
|
|
351
|
+
|
|
352
|
+
if (!dbExists) {
|
|
353
|
+
if (opts.forced) {
|
|
354
|
+
lines.push(
|
|
355
|
+
` ${ANSI.dim}skipped — no celilo database at ${getDbPath()} (not a management plane)${ANSI.reset}`,
|
|
356
|
+
);
|
|
357
|
+
return { lines, failCount: 0, warnCount: 0 };
|
|
358
|
+
}
|
|
359
|
+
// Dev box, no --fleet: don't render the section at all.
|
|
360
|
+
return { lines: [], failCount: 0, warnCount: 0 };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const bus = openBus({ dbPath: getEventBusPath(), events: defineEvents({}) });
|
|
364
|
+
try {
|
|
365
|
+
const db = getDb();
|
|
366
|
+
let findings = await runFleetChecks(bus, db, {
|
|
367
|
+
installedCodeMtimeMs: installedEventBusMtime(),
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
if (opts.fix) {
|
|
371
|
+
const subscribers = findings.find((f) => f.id === 'subscribers');
|
|
372
|
+
if (subscribers?.autoFixable && subscribers.status !== 'ok') {
|
|
373
|
+
const r = resyncAllSubscriptions();
|
|
374
|
+
lines.push(
|
|
375
|
+
` ${ANSI.dim}--fix: re-registered ${r.registered} subscription(s) from ${r.modules} module(s).${ANSI.reset}`,
|
|
376
|
+
);
|
|
377
|
+
// Re-evaluate the subscribers finding so the section shows the healed state.
|
|
378
|
+
const healed = checkSubscribers(bus, db);
|
|
379
|
+
findings = findings.map((f) => (f.id === 'subscribers' ? healed : f));
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
let failCount = 0;
|
|
384
|
+
let warnCount = 0;
|
|
385
|
+
for (const f of findings) {
|
|
386
|
+
lines.push(...renderFleetFinding(f));
|
|
387
|
+
if (f.status === 'fail') failCount++;
|
|
388
|
+
else if (f.status === 'warn') warnCount++;
|
|
389
|
+
}
|
|
390
|
+
return { lines, failCount, warnCount };
|
|
391
|
+
} finally {
|
|
392
|
+
bus.close();
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
299
396
|
export async function handleSystemDoctor(
|
|
300
397
|
_args: string[],
|
|
301
398
|
flags: Record<string, string | boolean>,
|
|
@@ -397,61 +494,59 @@ export async function handleSystemDoctor(
|
|
|
397
494
|
|
|
398
495
|
const fix = flags.fix === true;
|
|
399
496
|
|
|
497
|
+
// Drift repair (bun link). Does NOT early-return — the fleet section
|
|
498
|
+
// still runs on a --fix invocation so a single `--fix` heals both.
|
|
400
499
|
if (fix && drifted.length > 0) {
|
|
401
500
|
lines.push(`Repairing ${drifted.length} drifted package(s) with \`bun link\`:`);
|
|
402
501
|
lines.push(...applyFix(drifted, cliRoot));
|
|
502
|
+
lines.push(`${ANSI.dim}\`bun unlink\` from each workspace dir reverses.${ANSI.reset}`);
|
|
503
|
+
lines.push('');
|
|
504
|
+
// Linked from the workspace now — no longer drift for the summary.
|
|
505
|
+
driftCount = 0;
|
|
506
|
+
drifted.length = 0;
|
|
507
|
+
} else if (fix && drifted.length === 0) {
|
|
508
|
+
lines.push(`${ANSI.dim}--fix: no drifted packages to repair.${ANSI.reset}`);
|
|
403
509
|
lines.push('');
|
|
510
|
+
} else if (drifted.length > 0) {
|
|
404
511
|
lines.push(
|
|
405
|
-
`${ANSI.dim}
|
|
512
|
+
`${ANSI.dim}Run \`celilo system doctor --fix\` to bun-link drifted packages from the workspace.${ANSI.reset}`,
|
|
406
513
|
);
|
|
407
|
-
|
|
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
|
-
}
|
|
416
|
-
return {
|
|
417
|
-
success: true,
|
|
418
|
-
message: lines.join('\n'),
|
|
419
|
-
rawOutput: true,
|
|
420
|
-
};
|
|
514
|
+
lines.push('');
|
|
421
515
|
}
|
|
422
516
|
|
|
423
|
-
|
|
424
|
-
|
|
517
|
+
// Fleet-runtime section (state-aware; only renders on a management
|
|
518
|
+
// plane with a celilo DB, or when --fleet forces it).
|
|
519
|
+
const fleet = await renderFleetSection({ forced: flags.fleet === true, fix });
|
|
520
|
+
if (fleet.lines.length > 0) {
|
|
521
|
+
lines.push(...fleet.lines);
|
|
522
|
+
lines.push('');
|
|
425
523
|
}
|
|
426
524
|
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
);
|
|
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';
|
|
525
|
+
// Unified summary: anything that fails the run, with a per-cause count.
|
|
526
|
+
const problems: string[] = [];
|
|
527
|
+
if (prereqResult.failingCount > 0) {
|
|
528
|
+
problems.push(`${prereqResult.failingCount} prerequisite(s) missing/below-minimum`);
|
|
529
|
+
}
|
|
530
|
+
if (driftCount > 0) problems.push(`${driftCount} package(s) behind workspace`);
|
|
531
|
+
if (unresolvedCount > 0) problems.push(`${unresolvedCount} unresolved`);
|
|
532
|
+
if (fleet.failCount > 0) problems.push(`${fleet.failCount} fleet check(s) failing`);
|
|
533
|
+
|
|
534
|
+
if (problems.length > 0) {
|
|
447
535
|
return {
|
|
448
536
|
success: false,
|
|
449
|
-
error:
|
|
537
|
+
error: `Issues detected: ${problems.join(', ')}`,
|
|
450
538
|
details: lines.join('\n'),
|
|
451
539
|
};
|
|
452
540
|
}
|
|
453
541
|
|
|
454
|
-
|
|
542
|
+
// Fleet warnings don't fail the run, but they shouldn't read as a clean bill.
|
|
543
|
+
if (fleet.warnCount > 0) {
|
|
544
|
+
lines.push(
|
|
545
|
+
`${ANSI.yellow}OK with warnings${ANSI.reset} — ${fleet.warnCount} fleet warning(s); see above`,
|
|
546
|
+
);
|
|
547
|
+
} else {
|
|
548
|
+
lines.push(`${ANSI.green}OK${ANSI.reset} — no issues detected`);
|
|
549
|
+
}
|
|
455
550
|
return {
|
|
456
551
|
success: true,
|
|
457
552
|
message: lines.join('\n'),
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { closeDb } from '../../db/client';
|
|
6
|
+
import { handleSystemMigrate } from './system-migrate';
|
|
7
|
+
|
|
8
|
+
describe('handleSystemMigrate', () => {
|
|
9
|
+
let dir: string;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
dir = mkdtempSync(join(tmpdir(), 'sysmig-'));
|
|
13
|
+
process.env.CELILO_DB_PATH = join(dir, 'celilo.db');
|
|
14
|
+
});
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
closeDb();
|
|
17
|
+
process.env.CELILO_DB_PATH = undefined;
|
|
18
|
+
try {
|
|
19
|
+
rmSync(dir, { recursive: true, force: true });
|
|
20
|
+
} catch {
|
|
21
|
+
/* ignore */
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('reports a fresh/current DB as up to date with the full schema', async () => {
|
|
26
|
+
const result = await handleSystemMigrate();
|
|
27
|
+
expect(result.success).toBe(true);
|
|
28
|
+
if (result.success) {
|
|
29
|
+
expect(result.message).toContain('up to date');
|
|
30
|
+
expect(result.message).toContain('tables');
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('is idempotent — a second run is also clean', async () => {
|
|
35
|
+
(await handleSystemMigrate()).success;
|
|
36
|
+
closeDb();
|
|
37
|
+
const second = await handleSystemMigrate();
|
|
38
|
+
expect(second.success).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `celilo system migrate` — apply pending DB migrations (ISS-0100).
|
|
3
|
+
*
|
|
4
|
+
* Drizzle's migrator is the single migration mechanism; createDbClient already
|
|
5
|
+
* auto-migrates on open, so this command is the explicit, operator-visible
|
|
6
|
+
* entrypoint the .deb postinst and celilo-mgmt deploy call. Idempotent: a
|
|
7
|
+
* current DB reports "up to date".
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Database } from 'bun:sqlite';
|
|
11
|
+
import { getDb } from '../../db/client';
|
|
12
|
+
import { runMigrationsOn } from '../../db/migrate';
|
|
13
|
+
import { findSchemaDrift } from '../../db/schema-introspection';
|
|
14
|
+
import type { CommandResult } from '../types';
|
|
15
|
+
|
|
16
|
+
function countApplied(sqlite: Database): number {
|
|
17
|
+
try {
|
|
18
|
+
const row = sqlite
|
|
19
|
+
.query<{ c: number }, []>('SELECT COUNT(*) AS c FROM `__drizzle_migrations`')
|
|
20
|
+
.get();
|
|
21
|
+
return row?.c ?? 0;
|
|
22
|
+
} catch {
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function handleSystemMigrate(): Promise<CommandResult> {
|
|
28
|
+
// getDb() auto-migrates on open; do it inside try so an existing DB that
|
|
29
|
+
// predates the drizzle-authoritative change fails with an actionable message
|
|
30
|
+
// instead of a raw migrator error.
|
|
31
|
+
let db: ReturnType<typeof getDb>;
|
|
32
|
+
try {
|
|
33
|
+
db = getDb();
|
|
34
|
+
} catch (error) {
|
|
35
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
36
|
+
return {
|
|
37
|
+
success: false,
|
|
38
|
+
error: `Migration failed: ${msg}\n\nIf this DB predates the drizzle-authoritative migration change, it needs a one-time remediation (stamp \`__drizzle_migrations\` to the latest migration + create any missing table) before the migrator can run cleanly — see ISS-0100.`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const sqlite = db.$client;
|
|
43
|
+
const before = countApplied(sqlite);
|
|
44
|
+
try {
|
|
45
|
+
runMigrationsOn(db); // idempotent re-assert
|
|
46
|
+
} catch (error) {
|
|
47
|
+
return { success: false, error: error instanceof Error ? error.message : String(error) };
|
|
48
|
+
}
|
|
49
|
+
const applied = countApplied(sqlite) - before;
|
|
50
|
+
|
|
51
|
+
const drift = findSchemaDrift(sqlite);
|
|
52
|
+
if (drift.missingTables.length > 0 || drift.missingColumns.length > 0) {
|
|
53
|
+
const missing = [...drift.missingTables, ...drift.missingColumns].join(', ');
|
|
54
|
+
return {
|
|
55
|
+
success: false,
|
|
56
|
+
error: `Schema still behind after migrate — missing: ${missing}. This DB likely needs one-time remediation — see ISS-0100.`,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const lines = [
|
|
61
|
+
applied > 0 ? `Applied ${applied} migration(s).` : 'Schema already up to date.',
|
|
62
|
+
`Schema current: ${drift.tableCount} tables.`,
|
|
63
|
+
];
|
|
64
|
+
return { success: true, message: lines.join('\n') };
|
|
65
|
+
}
|
package/src/cli/completion.ts
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -89,6 +89,7 @@ import { handleSystemAudit } from './commands/system-audit';
|
|
|
89
89
|
import { handleSystemConfigGet, handleSystemConfigSet } from './commands/system-config';
|
|
90
90
|
import { handleSystemDoctor } from './commands/system-doctor';
|
|
91
91
|
import { handleSystemInit } from './commands/system-init';
|
|
92
|
+
import { handleSystemMigrate } from './commands/system-migrate';
|
|
92
93
|
import { handleSystemSecretGet } from './commands/system-secret-get';
|
|
93
94
|
import { handleSystemSecretSet } from './commands/system-secret-set';
|
|
94
95
|
import { handleSystemUpdate } from './commands/system-update';
|
|
@@ -286,7 +287,7 @@ Examples:
|
|
|
286
287
|
celilo events tail --type deploy.completed.lunacycle # filter by type
|
|
287
288
|
celilo events emit deploy.completed.lunacycle '{}' # operator-fired event
|
|
288
289
|
celilo events tail --type 'config.required.*' # see pending deploy questions
|
|
289
|
-
celilo events reply 42 '"
|
|
290
|
+
celilo events reply 42 '"example.net"' # answer query #42 (config)
|
|
290
291
|
`;
|
|
291
292
|
return { success: true, message: helpText.trim() };
|
|
292
293
|
}
|
|
@@ -1090,7 +1091,7 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1090
1091
|
'',
|
|
1091
1092
|
'Examples:',
|
|
1092
1093
|
' celilo hook run namecheap validate_config --debug',
|
|
1093
|
-
' celilo hook run namecheap container_created vps_ip=
|
|
1094
|
+
' celilo hook run namecheap container_created vps_ip=192.0.2.30',
|
|
1094
1095
|
].join('\n'),
|
|
1095
1096
|
};
|
|
1096
1097
|
}
|
|
@@ -1783,6 +1784,10 @@ export async function runCli(argv: string[]): Promise<CommandResult> {
|
|
|
1783
1784
|
return handleSystemDoctor(parsed.args, parsed.flags);
|
|
1784
1785
|
}
|
|
1785
1786
|
|
|
1787
|
+
if (parsed.subcommand === 'migrate') {
|
|
1788
|
+
return handleSystemMigrate();
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1786
1791
|
return {
|
|
1787
1792
|
success: false,
|
|
1788
1793
|
error: `Unknown system subcommand: ${parsed.subcommand}\n\nRun "celilo system --help" for usage`,
|