@celilo/cli 0.1.5 → 0.1.7
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/0004_caddy_hostname_list.sql +25 -0
- package/drizzle/meta/_journal.json +14 -0
- package/package.json +9 -2
- package/src/ansible/inventory.test.ts +3 -2
- package/src/ansible/inventory.ts +5 -1
- package/src/capabilities/public-web-helpers.test.ts +2 -2
- package/src/capabilities/public-web-publish.test.ts +34 -1
- package/src/cli/cli.test.ts +2 -2
- package/src/cli/command-registry.ts +146 -3
- package/src/cli/command-tree-parser.test.ts +1 -1
- package/src/cli/command-tree-parser.ts +9 -8
- package/src/cli/commands/hook-run.ts +15 -66
- package/src/cli/commands/module-audit.ts +14 -44
- package/src/cli/commands/module-deploy.ts +4 -1
- package/src/cli/commands/module-import-registry.test.ts +115 -0
- package/src/cli/commands/module-import.ts +106 -22
- package/src/cli/commands/module-publish.test.ts +235 -0
- package/src/cli/commands/module-publish.ts +234 -0
- package/src/cli/commands/module-remove.ts +82 -2
- package/src/cli/commands/module-search.ts +57 -0
- package/src/cli/commands/module-secret-get.ts +59 -0
- package/src/cli/commands/module-terraform-unlock.ts +57 -0
- package/src/cli/commands/module-verify.test.ts +59 -0
- package/src/cli/commands/module-verify.ts +53 -0
- package/src/cli/commands/status.ts +30 -20
- package/src/cli/commands/system-audit.test.ts +138 -0
- package/src/cli/commands/system-audit.ts +571 -0
- package/src/cli/commands/system-update.ts +391 -0
- package/src/cli/completion.ts +15 -1
- package/src/cli/fuel-gauge.ts +68 -3
- package/src/cli/generate-zsh-completion.ts +13 -3
- package/src/cli/index.ts +112 -5
- package/src/cli/parser.ts +11 -0
- package/src/cli/prompts.ts +36 -5
- package/src/cli/tui/audit-state.test.ts +246 -0
- package/src/cli/tui/audit-state.ts +525 -0
- package/src/cli/tui/audit-tui.test.tsx +135 -0
- package/src/cli/tui/audit-tui.tsx +624 -0
- package/src/cli/tui/celebration.tsx +29 -0
- package/src/cli/tui/clipboard.test.ts +94 -0
- package/src/cli/tui/clipboard.ts +101 -0
- package/src/cli/tui/icons.ts +22 -0
- package/src/cli/tui/keybar.tsx +65 -0
- package/src/cli/tui/keymap.test.ts +105 -0
- package/src/cli/tui/keymap.ts +70 -0
- package/src/cli/tui/modals/analyzing.tsx +75 -0
- package/src/cli/tui/modals/celebration.tsx +44 -0
- package/src/cli/tui/modals/reaudit-prompt.tsx +35 -0
- package/src/cli/tui/modals/remediate.tsx +44 -0
- package/src/cli/tui/modals.test.ts +137 -0
- package/src/cli/tui/mouse.test.ts +78 -0
- package/src/cli/tui/mouse.ts +114 -0
- package/src/cli/tui/panes/categories.tsx +62 -0
- package/src/cli/tui/panes/command-log.tsx +87 -0
- package/src/cli/tui/panes/detail.tsx +175 -0
- package/src/cli/tui/panes/findings.tsx +97 -0
- package/src/cli/tui/panes/summary.tsx +64 -0
- package/src/cli/tui/spawn.ts +130 -0
- package/src/cli/tui/theme.ts +42 -0
- package/src/cli/tui/wrap.test.ts +43 -0
- package/src/cli/tui/wrap.ts +45 -0
- package/src/cli/types.ts +5 -0
- package/src/db/client.ts +55 -2
- package/src/db/schema.ts +26 -17
- package/src/hooks/capability-loader.ts +133 -73
- package/src/hooks/define-hook.test.ts +9 -1
- package/src/hooks/executor.ts +22 -1
- package/src/hooks/load-hook-config.test.ts +165 -0
- package/src/hooks/load-hook-config.ts +60 -0
- package/src/hooks/logger.ts +42 -12
- package/src/hooks/run-named-hook.ts +128 -0
- package/src/hooks/types.ts +19 -0
- package/src/manifest/ensure-schema.test.ts +115 -0
- package/src/manifest/schema.ts +76 -0
- package/src/module/import.ts +20 -12
- package/src/module/packaging/build.ts +85 -16
- package/src/module/packaging/release-metadata.test.ts +103 -0
- package/src/module/packaging/release-metadata.ts +145 -0
- package/src/registry/client.test.ts +228 -0
- package/src/registry/client.ts +157 -0
- package/src/services/audit/backups.test.ts +233 -0
- package/src/services/audit/backups.ts +128 -0
- package/src/services/audit/capability-abi.test.ts +153 -0
- package/src/services/audit/capability-abi.ts +204 -0
- package/src/services/audit/cli-version.test.ts +60 -0
- package/src/services/audit/cli-version.ts +87 -0
- package/src/services/audit/health.test.ts +84 -0
- package/src/services/audit/health.ts +43 -0
- package/src/services/audit/index.test.ts +99 -0
- package/src/services/audit/index.ts +118 -0
- package/src/services/audit/machines-reachable.test.ts +87 -0
- package/src/services/audit/machines-reachable.ts +87 -0
- package/src/services/audit/module-configs.test.ts +131 -0
- package/src/services/audit/module-configs.ts +80 -0
- package/src/services/audit/module-versions.test.ts +99 -0
- package/src/services/audit/module-versions.ts +154 -0
- package/src/services/audit/schema.test.ts +68 -0
- package/src/services/audit/schema.ts +115 -0
- package/src/services/audit/secrets-decryptable.test.ts +82 -0
- package/src/services/audit/secrets-decryptable.ts +97 -0
- package/src/services/audit/services-credentials.test.ts +54 -0
- package/src/services/audit/services-credentials.ts +64 -0
- package/src/services/audit/services-reachable.test.ts +60 -0
- package/src/services/audit/services-reachable.ts +64 -0
- package/src/services/audit/terraform-plan.test.ts +127 -0
- package/src/services/audit/terraform-plan.ts +153 -0
- package/src/services/audit/types.test.ts +36 -0
- package/src/services/audit/types.ts +90 -0
- package/src/services/audit/unconfigured-modules.test.ts +48 -0
- package/src/services/audit/unconfigured-modules.ts +71 -0
- package/src/services/audit/undeployed-modules.test.ts +66 -0
- package/src/services/audit/undeployed-modules.ts +72 -0
- package/src/services/build-stream.ts +122 -122
- package/src/services/config-interview.ts +407 -2
- package/src/services/deploy-ansible.ts +73 -7
- package/src/services/deploy-preflight.ts +45 -4
- package/src/services/deploy-terraform.ts +31 -24
- package/src/services/deploy-validation.ts +167 -23
- package/src/services/ensure-interview.test.ts +245 -0
- package/src/services/health-runner.ts +110 -38
- package/src/services/module-build.ts +11 -13
- package/src/services/module-deploy.ts +370 -59
- package/src/services/ssh-key-manager.test.ts +1 -1
- package/src/services/ssh-key-manager.ts +3 -2
- package/src/services/terraform-env.ts +62 -0
- package/src/services/update/dep-graph.test.ts +214 -0
- package/src/services/update/dep-graph.ts +215 -0
- package/src/services/update/orchestrator.test.ts +463 -0
- package/src/services/update/orchestrator.ts +359 -0
- package/src/services/update/progress.ts +49 -0
- package/src/services/update/self-update.test.ts +68 -0
- package/src/services/update/self-update.ts +57 -0
- package/src/services/update/types.ts +94 -0
- package/src/templates/generator.test.ts +1 -1
- package/src/templates/generator.ts +42 -1
- package/src/test-utils/completion-harness.test.ts +1 -1
- package/src/test-utils/completion-harness.ts +4 -4
- package/src/variables/capability-self-ref.test.ts +203 -0
- package/src/variables/context.ts +49 -1
- package/src/variables/declarative-derivation.test.ts +306 -0
- package/src/variables/declarative-derivation.ts +4 -2
- package/src/variables/parser.test.ts +56 -1
- package/src/variables/parser.ts +47 -6
- package/src/variables/resolver.ts +27 -9
- package/tsconfig.json +1 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capability ABI compatibility check (CELILO_UPDATE D4).
|
|
3
|
+
*
|
|
4
|
+
* For each installed module, compares the version it claims for each
|
|
5
|
+
* capability (in `provides` / `requires`) against:
|
|
6
|
+
* - the framework's runtime registry (`CAPABILITY_CONTRACT_VERSIONS`),
|
|
7
|
+
* for providers
|
|
8
|
+
* - the actually-deployed provider's claimed version, for consumers
|
|
9
|
+
*
|
|
10
|
+
* Mismatches produce `blocked` findings with concrete remediation
|
|
11
|
+
* (rebuild the provider, upgrade the consumer, etc.).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
CAPABILITY_CONTRACT_VERSIONS,
|
|
16
|
+
type KnownCapabilityName,
|
|
17
|
+
compareConsumerToProvider,
|
|
18
|
+
compareProviderToRuntime,
|
|
19
|
+
} from '@celilo/capabilities';
|
|
20
|
+
import type {
|
|
21
|
+
CapabilityProvider,
|
|
22
|
+
CapabilityRequirement,
|
|
23
|
+
ModuleManifest,
|
|
24
|
+
} from '../../manifest/schema';
|
|
25
|
+
import type { DriftFinding } from './types';
|
|
26
|
+
|
|
27
|
+
export interface InstalledCapabilityModule {
|
|
28
|
+
id: string;
|
|
29
|
+
manifest: ModuleManifest;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface CapabilityAbiAuditDeps {
|
|
33
|
+
modules: InstalledCapabilityModule[];
|
|
34
|
+
/** Override the framework registry — for tests. Defaults to `CAPABILITY_CONTRACT_VERSIONS`. */
|
|
35
|
+
contractVersions?: Record<string, string>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isKnownCapability(name: string): name is KnownCapabilityName {
|
|
39
|
+
return name in CAPABILITY_CONTRACT_VERSIONS;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface ProviderClaim {
|
|
43
|
+
capability: string;
|
|
44
|
+
version: string;
|
|
45
|
+
moduleId: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function collectProviders(modules: InstalledCapabilityModule[]): Map<string, ProviderClaim> {
|
|
49
|
+
// capability name → { module that provides it, version it claims }.
|
|
50
|
+
// If multiple modules provide the same capability, the first one wins
|
|
51
|
+
// for the audit's consumer-vs-provider check; the framework's
|
|
52
|
+
// capability-loader handles real provider selection elsewhere.
|
|
53
|
+
const map = new Map<string, ProviderClaim>();
|
|
54
|
+
for (const m of modules) {
|
|
55
|
+
const provides: CapabilityProvider[] = m.manifest.provides?.capabilities ?? [];
|
|
56
|
+
for (const p of provides) {
|
|
57
|
+
if (!map.has(p.name)) {
|
|
58
|
+
map.set(p.name, { capability: p.name, version: p.version, moduleId: m.id });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return map;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function auditCapabilityAbi(deps: CapabilityAbiAuditDeps): Promise<DriftFinding[]> {
|
|
66
|
+
const findings: DriftFinding[] = [];
|
|
67
|
+
const contractVersions = deps.contractVersions ?? CAPABILITY_CONTRACT_VERSIONS;
|
|
68
|
+
const providers = collectProviders(deps.modules);
|
|
69
|
+
|
|
70
|
+
// 1. Provider checks: each `provides[X].version` must match the
|
|
71
|
+
// framework's runtime registry (within compareProviderToRuntime's rules).
|
|
72
|
+
for (const m of deps.modules) {
|
|
73
|
+
const provides: CapabilityProvider[] = m.manifest.provides?.capabilities ?? [];
|
|
74
|
+
for (const p of provides) {
|
|
75
|
+
if (!isKnownCapability(p.name)) {
|
|
76
|
+
// Capability isn't registered in the framework — that's fine
|
|
77
|
+
// (third-party / well-known-extension scenario).
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const runtimeVersion = contractVersions[p.name];
|
|
81
|
+
const result = compareProviderToRuntime(p.version, runtimeVersion);
|
|
82
|
+
if (result.compatible) continue;
|
|
83
|
+
|
|
84
|
+
const isFrameworkBehind = result.reason === 'major_mismatch_higher';
|
|
85
|
+
const details = isFrameworkBehind
|
|
86
|
+
? [
|
|
87
|
+
'ABI = Application Binary Interface — the contract between',
|
|
88
|
+
'the celilo framework and a module for a given capability',
|
|
89
|
+
'(method names, argument shapes, return values).',
|
|
90
|
+
'',
|
|
91
|
+
`${m.id} expects ${p.name}@${p.version} but the running`,
|
|
92
|
+
`framework only ships ${p.name}@${runtimeVersion}.`,
|
|
93
|
+
'',
|
|
94
|
+
'To fix:',
|
|
95
|
+
' 1. Upgrade celilo: bun update -g @celilo/cli',
|
|
96
|
+
' (or `git pull && bun install` in this repo)',
|
|
97
|
+
' 2. Re-run system audit',
|
|
98
|
+
'',
|
|
99
|
+
'This is a code-level mismatch — there is no single CLI',
|
|
100
|
+
'command that can resolve it.',
|
|
101
|
+
].join('\n')
|
|
102
|
+
: [
|
|
103
|
+
'ABI = Application Binary Interface — the contract between',
|
|
104
|
+
'the celilo framework and a module for a given capability',
|
|
105
|
+
'(method names, argument shapes, return values).',
|
|
106
|
+
'',
|
|
107
|
+
`${m.id} was built against ${p.name}@${p.version} but the`,
|
|
108
|
+
`framework now expects ${p.name}@${runtimeVersion}.`,
|
|
109
|
+
'',
|
|
110
|
+
'To fix:',
|
|
111
|
+
` 1. cd modules/${m.id}`,
|
|
112
|
+
' 2. Update the @celilo/capabilities dep to match the runtime',
|
|
113
|
+
' 3. bun run build',
|
|
114
|
+
' 4. Republish (or reinstall locally with `module import`)',
|
|
115
|
+
' 5. Re-run system audit',
|
|
116
|
+
'',
|
|
117
|
+
'This is a code-level mismatch — there is no single CLI',
|
|
118
|
+
'command that can resolve it.',
|
|
119
|
+
].join('\n');
|
|
120
|
+
|
|
121
|
+
findings.push({
|
|
122
|
+
category: 'capability_abi',
|
|
123
|
+
severity: 'blocked',
|
|
124
|
+
code: 'capability_abi_provider_mismatch',
|
|
125
|
+
message: `${m.id} provides ${p.name}@${p.version} but framework runtime expects ${runtimeVersion}`,
|
|
126
|
+
details,
|
|
127
|
+
actionable: false,
|
|
128
|
+
subject: m.id,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 2. Consumer checks: each `requires[X].version` must be satisfied by
|
|
134
|
+
// the actual provider's `provides[X].version`.
|
|
135
|
+
for (const m of deps.modules) {
|
|
136
|
+
const requires: CapabilityRequirement[] = m.manifest.requires?.capabilities ?? [];
|
|
137
|
+
const optional: CapabilityRequirement[] = m.manifest.optional?.capabilities ?? [];
|
|
138
|
+
|
|
139
|
+
for (const need of [...requires, ...optional]) {
|
|
140
|
+
const provider = providers.get(need.name);
|
|
141
|
+
if (!provider) {
|
|
142
|
+
// No installed provider for this capability — that's a deploy-time
|
|
143
|
+
// concern, not an ABI concern. The deploy preflight handles missing
|
|
144
|
+
// providers; we skip here.
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const result = compareConsumerToProvider(need.version, provider.version);
|
|
148
|
+
if (result.compatible) continue;
|
|
149
|
+
|
|
150
|
+
const providerTooOld = result.reason === 'caller_minor_too_old';
|
|
151
|
+
const details = providerTooOld
|
|
152
|
+
? [
|
|
153
|
+
'ABI = Application Binary Interface — the contract between',
|
|
154
|
+
'modules for a given capability (method names, argument',
|
|
155
|
+
'shapes, return values).',
|
|
156
|
+
'',
|
|
157
|
+
`${m.id} requires ${need.name}@${need.version} but the`,
|
|
158
|
+
`installed provider ${provider.moduleId} only offers`,
|
|
159
|
+
`${provider.version}.`,
|
|
160
|
+
'',
|
|
161
|
+
'To fix:',
|
|
162
|
+
` 1. Upgrade ${provider.moduleId} to a version that provides`,
|
|
163
|
+
` ${need.name}@${need.version} or newer (compatible major)`,
|
|
164
|
+
' 2. Re-run system audit',
|
|
165
|
+
'',
|
|
166
|
+
'This is a code-level mismatch — there is no single CLI',
|
|
167
|
+
'command that can resolve it.',
|
|
168
|
+
].join('\n')
|
|
169
|
+
: [
|
|
170
|
+
'ABI = Application Binary Interface — the contract between',
|
|
171
|
+
'modules for a given capability (method names, argument',
|
|
172
|
+
'shapes, return values).',
|
|
173
|
+
'',
|
|
174
|
+
`${m.id} was built against ${need.name}@${need.version} but`,
|
|
175
|
+
`${provider.moduleId} now provides ${provider.version} on a`,
|
|
176
|
+
'different major. The interface has changed in an',
|
|
177
|
+
'incompatible way.',
|
|
178
|
+
'',
|
|
179
|
+
'To fix:',
|
|
180
|
+
` 1. cd modules/${m.id}`,
|
|
181
|
+
` 2. Update its requires entry for ${need.name} to match`,
|
|
182
|
+
` ${provider.moduleId}'s major (${provider.version})`,
|
|
183
|
+
' 3. Adjust the module code if the interface changed',
|
|
184
|
+
' 4. bun run build && republish',
|
|
185
|
+
' 5. Re-run system audit',
|
|
186
|
+
'',
|
|
187
|
+
'This is a code-level mismatch — there is no single CLI',
|
|
188
|
+
'command that can resolve it.',
|
|
189
|
+
].join('\n');
|
|
190
|
+
|
|
191
|
+
findings.push({
|
|
192
|
+
category: 'capability_abi',
|
|
193
|
+
severity: 'blocked',
|
|
194
|
+
code: 'capability_abi_consumer_mismatch',
|
|
195
|
+
message: `${m.id} requires ${need.name}@${need.version} but ${provider.moduleId} provides ${provider.version}`,
|
|
196
|
+
details,
|
|
197
|
+
actionable: false,
|
|
198
|
+
subject: m.id,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return findings;
|
|
204
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { auditCliVersion, compareSemver } from './cli-version';
|
|
3
|
+
|
|
4
|
+
describe('compareSemver', () => {
|
|
5
|
+
test.each([
|
|
6
|
+
['1.0.0', '1.0.0', 0],
|
|
7
|
+
['1.0.0', '1.0.1', -1],
|
|
8
|
+
['1.0.1', '1.0.0', 1],
|
|
9
|
+
['1.0.0', '2.0.0', -1],
|
|
10
|
+
['2.0.0', '1.9.9', 1],
|
|
11
|
+
['v1.0.0', '1.0.0', 0],
|
|
12
|
+
['1.0.0-beta.1', '1.0.0', 0], // pre-release stripped
|
|
13
|
+
['1.0.0+build.42', '1.0.0', 0], // build metadata stripped
|
|
14
|
+
])('compareSemver(%s, %s) = %s', (a, b, expected) => {
|
|
15
|
+
expect(compareSemver(a, b)).toBe(expected);
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('auditCliVersion', () => {
|
|
20
|
+
test('no finding when running version equals latest', async () => {
|
|
21
|
+
const result = await auditCliVersion({
|
|
22
|
+
installedVersion: '0.1.5',
|
|
23
|
+
fetcher: async () => '0.1.5',
|
|
24
|
+
});
|
|
25
|
+
expect(result).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('no finding when running ahead of latest (dev build)', async () => {
|
|
29
|
+
const result = await auditCliVersion({
|
|
30
|
+
installedVersion: '0.2.0-dev',
|
|
31
|
+
fetcher: async () => '0.1.5',
|
|
32
|
+
});
|
|
33
|
+
expect(result).toEqual([]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('drift finding when published is newer', async () => {
|
|
37
|
+
const result = await auditCliVersion({
|
|
38
|
+
installedVersion: '0.1.5',
|
|
39
|
+
fetcher: async () => '0.1.7',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(result).toHaveLength(1);
|
|
43
|
+
expect(result[0]).toMatchObject({
|
|
44
|
+
category: 'cli_version',
|
|
45
|
+
severity: 'drift',
|
|
46
|
+
code: 'cli_version_drift',
|
|
47
|
+
subject: 'system',
|
|
48
|
+
});
|
|
49
|
+
expect(result[0].message).toContain('0.1.5');
|
|
50
|
+
expect(result[0].message).toContain('0.1.7');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('no finding when fetcher returns null (network failure)', async () => {
|
|
54
|
+
const result = await auditCliVersion({
|
|
55
|
+
installedVersion: '0.1.5',
|
|
56
|
+
fetcher: async () => null,
|
|
57
|
+
});
|
|
58
|
+
expect(result).toEqual([]);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI version drift check.
|
|
3
|
+
*
|
|
4
|
+
* Compares the running `@celilo/cli` version against the latest version
|
|
5
|
+
* on npm. A newer published version produces a single `drift` finding;
|
|
6
|
+
* a network failure (offline, npm down) produces no finding — we don't
|
|
7
|
+
* want a transient lookup failure to block `system update`. Instead the
|
|
8
|
+
* caller logs a debug warning. Schema drift, capability ABI drift, etc.
|
|
9
|
+
* are the BLOCKED gates; CLI version is informational only.
|
|
10
|
+
*
|
|
11
|
+
* `latestVersionFetcher` is injectable so tests don't hit npm.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { DriftFinding } from './types';
|
|
15
|
+
|
|
16
|
+
export type LatestCliVersionFetcher = () => Promise<string | null>;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Default fetcher — queries the npm registry's metadata endpoint.
|
|
20
|
+
*
|
|
21
|
+
* Returns null on any non-2xx or parse failure, so a network blip
|
|
22
|
+
* doesn't surface as a confusing "drift" finding.
|
|
23
|
+
*/
|
|
24
|
+
export const fetchLatestCliVersion: LatestCliVersionFetcher = async () => {
|
|
25
|
+
try {
|
|
26
|
+
const resp = await fetch('https://registry.npmjs.org/@celilo/cli', {
|
|
27
|
+
signal: AbortSignal.timeout(5_000),
|
|
28
|
+
headers: { Accept: 'application/json' },
|
|
29
|
+
});
|
|
30
|
+
if (!resp.ok) return null;
|
|
31
|
+
const data = (await resp.json()) as { 'dist-tags'?: { latest?: string } };
|
|
32
|
+
return data['dist-tags']?.latest ?? null;
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Strict semver comparison. Returns:
|
|
40
|
+
* - `-1` if `a < b`
|
|
41
|
+
* - `0` if `a === b`
|
|
42
|
+
* - `1` if `a > b`
|
|
43
|
+
*
|
|
44
|
+
* Doesn't support pre-release tags (`-beta.1` etc.) — we strip
|
|
45
|
+
* everything after a `-` and compare the numeric portions only.
|
|
46
|
+
*/
|
|
47
|
+
export function compareSemver(a: string, b: string): number {
|
|
48
|
+
const parse = (v: string): [number, number, number] => {
|
|
49
|
+
const stripped = v.replace(/^v/, '').split('-')[0].split('+')[0];
|
|
50
|
+
const parts = stripped.split('.').map((n) => Number.parseInt(n, 10) || 0);
|
|
51
|
+
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
|
|
52
|
+
};
|
|
53
|
+
const [aa, ab, ac] = parse(a);
|
|
54
|
+
const [ba, bb, bc] = parse(b);
|
|
55
|
+
if (aa !== ba) return aa < ba ? -1 : 1;
|
|
56
|
+
if (ab !== bb) return ab < bb ? -1 : 1;
|
|
57
|
+
if (ac !== bc) return ac < bc ? -1 : 1;
|
|
58
|
+
return 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface CliVersionAuditDeps {
|
|
62
|
+
/** Version of the running CLI, typically read from `package.json`. */
|
|
63
|
+
installedVersion: string;
|
|
64
|
+
fetcher?: LatestCliVersionFetcher;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function auditCliVersion(deps: CliVersionAuditDeps): Promise<DriftFinding[]> {
|
|
68
|
+
const fetcher = deps.fetcher ?? fetchLatestCliVersion;
|
|
69
|
+
const latest = await fetcher();
|
|
70
|
+
if (!latest) return []; // network failure — silently no finding
|
|
71
|
+
|
|
72
|
+
if (compareSemver(deps.installedVersion, latest) >= 0) {
|
|
73
|
+
return []; // up to date or ahead (dev build)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return [
|
|
77
|
+
{
|
|
78
|
+
category: 'cli_version',
|
|
79
|
+
severity: 'drift',
|
|
80
|
+
code: 'cli_version_drift',
|
|
81
|
+
message: `@celilo/cli ${deps.installedVersion} → ${latest} available`,
|
|
82
|
+
remediation: 'celilo system update',
|
|
83
|
+
actionable: true,
|
|
84
|
+
subject: 'system',
|
|
85
|
+
},
|
|
86
|
+
];
|
|
87
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { HealthCheckResult } from '../health-runner';
|
|
3
|
+
import { auditHealth } from './health';
|
|
4
|
+
|
|
5
|
+
describe('auditHealth', () => {
|
|
6
|
+
test('no findings when every module is healthy or has no checks', async () => {
|
|
7
|
+
const results: HealthCheckResult[] = [
|
|
8
|
+
{ moduleId: 'caddy', status: 'healthy', checks: [] },
|
|
9
|
+
{ moduleId: 'iptables', status: 'no-checks', checks: [] },
|
|
10
|
+
];
|
|
11
|
+
expect(await auditHealth({ results })).toEqual([]);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('drift finding for unhealthy module', async () => {
|
|
15
|
+
const results: HealthCheckResult[] = [
|
|
16
|
+
{
|
|
17
|
+
moduleId: 'lunacycle',
|
|
18
|
+
status: 'unhealthy',
|
|
19
|
+
checks: [
|
|
20
|
+
{ name: 'service_running', status: 'fail', message: 'Service inactive' },
|
|
21
|
+
{ name: 'api_reachable', status: 'pass', message: 'API responded' },
|
|
22
|
+
],
|
|
23
|
+
},
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const result = await auditHealth({ results });
|
|
27
|
+
|
|
28
|
+
expect(result).toHaveLength(1);
|
|
29
|
+
expect(result[0]).toMatchObject({
|
|
30
|
+
category: 'health',
|
|
31
|
+
severity: 'drift',
|
|
32
|
+
code: 'health_unhealthy',
|
|
33
|
+
subject: 'lunacycle',
|
|
34
|
+
});
|
|
35
|
+
expect(result[0].details).toContain('service_running');
|
|
36
|
+
expect(result[0].details).not.toContain('api_reachable'); // pass entries excluded
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('drift finding for degraded module', async () => {
|
|
40
|
+
const results: HealthCheckResult[] = [
|
|
41
|
+
{
|
|
42
|
+
moduleId: 'caddy',
|
|
43
|
+
status: 'degraded',
|
|
44
|
+
checks: [{ name: 'dns_resolution', status: 'warn', message: 'propagation pending' }],
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
const result = await auditHealth({ results });
|
|
49
|
+
expect(result[0].code).toBe('health_degraded');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('error status surfaces with the error message', async () => {
|
|
53
|
+
const results: HealthCheckResult[] = [
|
|
54
|
+
{
|
|
55
|
+
moduleId: 'lunacycle',
|
|
56
|
+
status: 'error',
|
|
57
|
+
checks: [],
|
|
58
|
+
error: 'Hook script not found',
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
const result = await auditHealth({ results });
|
|
63
|
+
expect(result[0].message).toContain('Hook script not found');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('multiple unhealthy modules each produce a finding', async () => {
|
|
67
|
+
const results: HealthCheckResult[] = [
|
|
68
|
+
{
|
|
69
|
+
moduleId: 'a',
|
|
70
|
+
status: 'unhealthy',
|
|
71
|
+
checks: [{ name: 'x', status: 'fail', message: 'no' }],
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
moduleId: 'b',
|
|
75
|
+
status: 'degraded',
|
|
76
|
+
checks: [{ name: 'y', status: 'warn', message: 'meh' }],
|
|
77
|
+
},
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
const result = await auditHealth({ results });
|
|
81
|
+
expect(result).toHaveLength(2);
|
|
82
|
+
expect(result.map((f) => f.subject).sort()).toEqual(['a', 'b']);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health drift check.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the existing health-runner. Health-check failures are
|
|
5
|
+
* informational (DRIFT, not BLOCKED) — the user might want to update
|
|
6
|
+
* even if some module is currently unhealthy (an update could fix it).
|
|
7
|
+
*
|
|
8
|
+
* The health-runner result is passed in directly so this file stays
|
|
9
|
+
* pure for testing. The aggregator wires `runAllHealthChecks` to it.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { HealthCheckResult } from '../health-runner';
|
|
13
|
+
import type { DriftFinding } from './types';
|
|
14
|
+
|
|
15
|
+
export interface HealthAuditDeps {
|
|
16
|
+
results: HealthCheckResult[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function auditHealth(deps: HealthAuditDeps): Promise<DriftFinding[]> {
|
|
20
|
+
const findings: DriftFinding[] = [];
|
|
21
|
+
|
|
22
|
+
for (const r of deps.results) {
|
|
23
|
+
if (r.status === 'healthy' || r.status === 'no-checks') continue;
|
|
24
|
+
|
|
25
|
+
const failingChecks = r.checks
|
|
26
|
+
.filter((c) => c.status === 'fail' || c.status === 'warn')
|
|
27
|
+
.map((c) => ` • ${c.name}: ${c.message}`)
|
|
28
|
+
.join('\n');
|
|
29
|
+
|
|
30
|
+
findings.push({
|
|
31
|
+
category: 'health',
|
|
32
|
+
severity: 'drift',
|
|
33
|
+
code: r.status === 'unhealthy' ? 'health_unhealthy' : `health_${r.status}`,
|
|
34
|
+
message: `${r.moduleId}: ${r.status}${r.error ? ` — ${r.error}` : ''}`,
|
|
35
|
+
details: failingChecks || undefined,
|
|
36
|
+
remediation: `celilo module health ${r.moduleId} --debug`,
|
|
37
|
+
actionable: true,
|
|
38
|
+
subject: r.moduleId,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return findings;
|
|
43
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { DbClient } from '../../db/client';
|
|
3
|
+
import { runAudit } from './index';
|
|
4
|
+
|
|
5
|
+
const fakeDb = {} as DbClient;
|
|
6
|
+
|
|
7
|
+
const emptyDeps = {
|
|
8
|
+
cliVersion: {
|
|
9
|
+
installedVersion: '0.1.5',
|
|
10
|
+
fetcher: async () => '0.1.5',
|
|
11
|
+
},
|
|
12
|
+
schema: {
|
|
13
|
+
journal: () => null,
|
|
14
|
+
applied: () => [],
|
|
15
|
+
db: fakeDb,
|
|
16
|
+
},
|
|
17
|
+
capabilityAbi: { modules: [] },
|
|
18
|
+
terraformPlan: {
|
|
19
|
+
modules: [],
|
|
20
|
+
run: async () => ({ exitCode: 0, stdout: '', stderr: '' }),
|
|
21
|
+
},
|
|
22
|
+
moduleVersions: {
|
|
23
|
+
installed: [],
|
|
24
|
+
fetcher: async () => ({ latest: null }),
|
|
25
|
+
},
|
|
26
|
+
moduleConfigs: { modules: [] },
|
|
27
|
+
health: { results: [] },
|
|
28
|
+
backups: { modules: [] },
|
|
29
|
+
undeployedModules: { modules: [] },
|
|
30
|
+
unconfiguredModules: { modules: [] },
|
|
31
|
+
servicesCredentials: { results: [] },
|
|
32
|
+
secretsDecryptable: { results: [] },
|
|
33
|
+
servicesReachable: { results: [] },
|
|
34
|
+
machinesReachable: { results: [] },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
describe('runAudit', () => {
|
|
38
|
+
test('READY when no checks produce findings', async () => {
|
|
39
|
+
const report = await runAudit({ ...emptyDeps, now: () => new Date('2026-04-25T00:00:00Z') });
|
|
40
|
+
|
|
41
|
+
expect(report.version).toBe(1);
|
|
42
|
+
expect(report.verdict).toBe('READY');
|
|
43
|
+
expect(report.findings).toEqual([]);
|
|
44
|
+
expect(report.generatedAt).toBe('2026-04-25T00:00:00.000Z');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('DRIFT when only drift-severity findings', async () => {
|
|
48
|
+
const report = await runAudit({
|
|
49
|
+
...emptyDeps,
|
|
50
|
+
cliVersion: {
|
|
51
|
+
installedVersion: '0.1.5',
|
|
52
|
+
fetcher: async () => '0.1.7',
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(report.verdict).toBe('DRIFT');
|
|
57
|
+
expect(report.findings).toHaveLength(1);
|
|
58
|
+
expect(report.findings[0].category).toBe('cli_version');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('BLOCKED when any blocked finding is present', async () => {
|
|
62
|
+
const report = await runAudit({
|
|
63
|
+
...emptyDeps,
|
|
64
|
+
schema: {
|
|
65
|
+
journal: () => ({
|
|
66
|
+
version: '5',
|
|
67
|
+
dialect: 'sqlite',
|
|
68
|
+
entries: [{ idx: 0, tag: '0001_pending', when: 1 }],
|
|
69
|
+
}),
|
|
70
|
+
applied: () => [],
|
|
71
|
+
db: fakeDb,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
expect(report.verdict).toBe('BLOCKED');
|
|
76
|
+
expect(report.findings.some((f) => f.category === 'schema')).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('aggregates findings across all categories', async () => {
|
|
80
|
+
const report = await runAudit({
|
|
81
|
+
...emptyDeps,
|
|
82
|
+
cliVersion: {
|
|
83
|
+
installedVersion: '0.1.5',
|
|
84
|
+
fetcher: async () => '0.1.7',
|
|
85
|
+
},
|
|
86
|
+
moduleVersions: {
|
|
87
|
+
installed: [{ id: 'caddy', version: '1.0.0' }],
|
|
88
|
+
fetcher: async () => ({ latest: '1.1.0' }),
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(report.verdict).toBe('DRIFT');
|
|
93
|
+
expect(report.findings).toHaveLength(2);
|
|
94
|
+
expect(report.findings.map((f) => f.category).sort()).toEqual([
|
|
95
|
+
'cli_version',
|
|
96
|
+
'module_versions',
|
|
97
|
+
]);
|
|
98
|
+
});
|
|
99
|
+
});
|