@celilo/cli 0.1.4 → 0.1.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/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 +9 -8
- package/src/ansible/inventory.ts +9 -7
- package/src/capabilities/public-web-helpers.test.ts +2 -2
- package/src/capabilities/public-web-publish.test.ts +45 -12
- package/src/capabilities/registration.test.ts +6 -6
- package/src/capabilities/well-known.test.ts +2 -2
- package/src/capabilities/well-known.ts +5 -5
- 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-show.ts +1 -1
- 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.test.ts +3 -3
- package/src/db/schema.ts +26 -17
- package/src/hooks/capability-loader.ts +135 -72
- package/src/hooks/define-hook.test.ts +11 -3
- 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/manifest/template-validator.test.ts +1 -1
- package/src/manifest/template-validator.ts +1 -1
- package/src/manifest/validate.test.ts +1 -1
- package/src/module/import.ts +20 -12
- package/src/module/packaging/build.ts +121 -25
- 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-planner.ts +5 -5
- 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/dns-auto-register.ts +4 -4
- package/src/services/ensure-interview.test.ts +245 -0
- package/src/services/health-runner.ts +110 -38
- package/src/services/infrastructure-variable-resolver.test.ts +1 -1
- package/src/services/infrastructure-variable-resolver.ts +3 -3
- package/src/services/module-build.ts +11 -13
- package/src/services/module-deploy.ts +372 -61
- package/src/services/proxmox-state-recovery.ts +6 -6
- 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 +3 -3
- package/src/templates/generator.ts +43 -2
- 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.test.ts +31 -31
- package/src/variables/context.ts +65 -17
- package/src/variables/declarative-derivation.test.ts +306 -0
- package/src/variables/declarative-derivation.ts +4 -2
- package/src/variables/parser.test.ts +64 -9
- package/src/variables/parser.ts +47 -6
- package/src/variables/resolver.test.ts +14 -14
- package/src/variables/resolver.ts +27 -9
- package/src/variables/types.ts +1 -1
- package/tsconfig.json +1 -0
|
@@ -7,10 +7,14 @@
|
|
|
7
7
|
|
|
8
8
|
import { existsSync } from 'node:fs';
|
|
9
9
|
import { join } from 'node:path';
|
|
10
|
-
import { eq } from 'drizzle-orm';
|
|
10
|
+
import { eq, ne } from 'drizzle-orm';
|
|
11
|
+
import { FuelGauge } from '../../cli/fuel-gauge';
|
|
11
12
|
import { getDb } from '../../db/client';
|
|
12
|
-
import { moduleInfrastructure, modules } from '../../db/schema';
|
|
13
|
+
import { capabilities as capabilitiesTable, moduleInfrastructure, modules } from '../../db/schema';
|
|
14
|
+
import { createGaugeLogger } from '../../hooks/logger';
|
|
15
|
+
import { runNamedHook } from '../../hooks/run-named-hook';
|
|
13
16
|
import { deallocateForModule } from '../../ipam/auto-allocator';
|
|
17
|
+
import { ModuleManifestSchema } from '../../manifest/schema';
|
|
14
18
|
import { executeBuildWithProgress } from '../../services/build-stream';
|
|
15
19
|
import { getContainerService, getServiceCredentials } from '../../services/container-service';
|
|
16
20
|
import { getArg, hasFlag, validateRequiredArgs } from '../parser';
|
|
@@ -61,6 +65,82 @@ export async function handleModuleRemove(
|
|
|
61
65
|
};
|
|
62
66
|
}
|
|
63
67
|
|
|
68
|
+
// Check if any other installed module requires a capability this module provides
|
|
69
|
+
const providedCapabilities = db
|
|
70
|
+
.select()
|
|
71
|
+
.from(capabilitiesTable)
|
|
72
|
+
.where(eq(capabilitiesTable.moduleId, moduleId))
|
|
73
|
+
.all();
|
|
74
|
+
|
|
75
|
+
if (providedCapabilities.length > 0) {
|
|
76
|
+
const providedNames = new Set(providedCapabilities.map((c) => c.capabilityName));
|
|
77
|
+
const otherModules = db.select().from(modules).where(ne(modules.id, moduleId)).all();
|
|
78
|
+
|
|
79
|
+
const dependents = otherModules
|
|
80
|
+
.filter((m) => {
|
|
81
|
+
const parsed = ModuleManifestSchema.safeParse(m.manifestData);
|
|
82
|
+
if (!parsed.success) return false;
|
|
83
|
+
return (parsed.data.requires?.capabilities ?? []).some((req) =>
|
|
84
|
+
providedNames.has(req.name),
|
|
85
|
+
);
|
|
86
|
+
})
|
|
87
|
+
.map((m) => m.id);
|
|
88
|
+
|
|
89
|
+
if (dependents.length > 0) {
|
|
90
|
+
return {
|
|
91
|
+
success: false,
|
|
92
|
+
error: `Cannot remove '${moduleId}': the following installed modules depend on its capabilities: ${dependents.join(', ')}`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Run on_uninstall hook (if defined) BEFORE terraform destroy. Hooks
|
|
98
|
+
// typically need the module's runtime to still be up so they can talk
|
|
99
|
+
// to remote services — e.g. caddy's teardown SSHes the caddy host to
|
|
100
|
+
// unexpose its ports + clear the Caddyfile, which can't happen after
|
|
101
|
+
// terraform has destroyed the LXC. Failures here are best-effort: we
|
|
102
|
+
// log the error and continue with removal so an operator can still
|
|
103
|
+
// unstick a half-broken module. Use --force to skip the confirmation
|
|
104
|
+
// prompt on hook failure.
|
|
105
|
+
const manifestForHook = module.manifestData as { hooks?: { on_uninstall?: unknown } } | undefined;
|
|
106
|
+
if (manifestForHook?.hooks?.on_uninstall) {
|
|
107
|
+
// Match build-stream's TTY detection: in non-TTY contexts (tests,
|
|
108
|
+
// pipes, --no-interactive flow) skipAnimation prevents the gauge's
|
|
109
|
+
// setInterval/raw-stdin handlers from blocking process exit.
|
|
110
|
+
const isInteractive = process.stdout.isTTY && process.stdin.isTTY;
|
|
111
|
+
const gauge = new FuelGauge(`${moduleId}: on_uninstall`, {
|
|
112
|
+
skipAnimation: !isInteractive,
|
|
113
|
+
});
|
|
114
|
+
gauge.start();
|
|
115
|
+
const logger = createGaugeLogger(gauge, moduleId, 'on_uninstall');
|
|
116
|
+
const hookResult = await runNamedHook(moduleId, 'on_uninstall', db, logger, {});
|
|
117
|
+
if (hookResult.success) {
|
|
118
|
+
gauge.stop(true);
|
|
119
|
+
} else {
|
|
120
|
+
gauge.stop(false);
|
|
121
|
+
log.warn(`on_uninstall failed: ${hookResult.error ?? 'unknown error'}`);
|
|
122
|
+
if (force) {
|
|
123
|
+
log.info('--force set, continuing removal despite hook failure');
|
|
124
|
+
} else if (!isInteractive) {
|
|
125
|
+
// Non-TTY (tests, scripts, piped input). Don't try to prompt —
|
|
126
|
+
// @clack/prompts hangs on stdin in that context. Default
|
|
127
|
+
// behaviour: continue with removal so a hook crash doesn't
|
|
128
|
+
// wedge automation. Operators who want strict failure can
|
|
129
|
+
// re-run `celilo module run-hook <id> on_uninstall` manually.
|
|
130
|
+
log.warn('Non-interactive context detected; continuing removal despite hook failure');
|
|
131
|
+
} else {
|
|
132
|
+
const proceed = await promptConfirm({
|
|
133
|
+
message:
|
|
134
|
+
'on_uninstall hook failed. Continue removing the module anyway? ' +
|
|
135
|
+
'(some external state may not be cleaned up)',
|
|
136
|
+
});
|
|
137
|
+
if (!proceed) {
|
|
138
|
+
return { success: false, error: 'Removal cancelled after on_uninstall failure' };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
64
144
|
// Check if module has infrastructure that needs to be destroyed
|
|
65
145
|
const infra = db
|
|
66
146
|
.select()
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module search command — search the registry for modules
|
|
3
|
+
*
|
|
4
|
+
* Usage: celilo module search [<query>] [--registry <url>] [--limit <n>]
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { RegistryClient } from '../../registry/client';
|
|
8
|
+
import { getArg, getFlag } from '../parser';
|
|
9
|
+
import type { CommandResult } from '../types';
|
|
10
|
+
|
|
11
|
+
export async function handleModuleSearch(
|
|
12
|
+
args: string[],
|
|
13
|
+
flags: Record<string, string | boolean>,
|
|
14
|
+
): Promise<CommandResult> {
|
|
15
|
+
const query = getArg(args, 0) ?? '';
|
|
16
|
+
const limit = Number(getFlag(flags, 'limit', '25'));
|
|
17
|
+
const registryUrl = getFlag(flags, 'registry', '');
|
|
18
|
+
const client = new RegistryClient(registryUrl || undefined);
|
|
19
|
+
|
|
20
|
+
let result: Awaited<ReturnType<typeof client.search>>;
|
|
21
|
+
try {
|
|
22
|
+
result = await client.search(query, limit);
|
|
23
|
+
} catch (err) {
|
|
24
|
+
return {
|
|
25
|
+
success: false,
|
|
26
|
+
error: `Registry unreachable: ${err instanceof Error ? err.message : String(err)}`,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (result.modules.length === 0) {
|
|
31
|
+
return {
|
|
32
|
+
success: true,
|
|
33
|
+
message: query ? `No modules found matching "${query}"` : 'Registry is empty',
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const nameWidth = Math.max(4, ...result.modules.map((m) => m.name.length));
|
|
38
|
+
const versionWidth = Math.max(7, ...result.modules.map((m) => m.max_version.length));
|
|
39
|
+
|
|
40
|
+
const header = `${'NAME'.padEnd(nameWidth)} ${'VERSION'.padEnd(versionWidth)} DESCRIPTION`;
|
|
41
|
+
const divider = '-'.repeat(header.length);
|
|
42
|
+
const rows = result.modules.map(
|
|
43
|
+
(m) =>
|
|
44
|
+
`${m.name.padEnd(nameWidth)} ${m.max_version.padEnd(versionWidth)} ${m.description || ''}`,
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const footer =
|
|
48
|
+
result.total > result.modules.length
|
|
49
|
+
? `\n(showing ${result.modules.length} of ${result.total} — use --limit to see more)`
|
|
50
|
+
: '';
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
success: true,
|
|
54
|
+
message: [header, divider, ...rows].join('\n') + footer,
|
|
55
|
+
data: result,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module secret get command
|
|
3
|
+
* Retrieves and decrypts a module-level secret
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { and, eq } from 'drizzle-orm';
|
|
7
|
+
import { getDb } from '../../db/client';
|
|
8
|
+
import { secrets } from '../../db/schema';
|
|
9
|
+
import { decryptSecret } from '../../secrets/encryption';
|
|
10
|
+
import { getOrCreateMasterKey } from '../../secrets/master-key';
|
|
11
|
+
import { getArg } from '../parser';
|
|
12
|
+
import type { CommandResult } from '../types';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Handle module secret get command
|
|
16
|
+
*
|
|
17
|
+
* Usage: celilo module secret get <module-id> <key>
|
|
18
|
+
*
|
|
19
|
+
* @param args - Command arguments
|
|
20
|
+
* @returns Command result with decrypted secret value
|
|
21
|
+
*/
|
|
22
|
+
export async function handleModuleSecretGet(args: string[]): Promise<CommandResult> {
|
|
23
|
+
const moduleId = getArg(args, 0);
|
|
24
|
+
const key = getArg(args, 1);
|
|
25
|
+
|
|
26
|
+
if (!moduleId || !key) {
|
|
27
|
+
return {
|
|
28
|
+
success: false,
|
|
29
|
+
error: 'Usage: celilo module secret get <module-id> <key>',
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const db = getDb();
|
|
34
|
+
const secret = db
|
|
35
|
+
.select()
|
|
36
|
+
.from(secrets)
|
|
37
|
+
.where(and(eq(secrets.moduleId, moduleId), eq(secrets.name, key)))
|
|
38
|
+
.get();
|
|
39
|
+
|
|
40
|
+
if (!secret) {
|
|
41
|
+
return {
|
|
42
|
+
success: false,
|
|
43
|
+
error: `Secret not found: ${moduleId}/${key}`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const masterKey = await getOrCreateMasterKey();
|
|
48
|
+
const decrypted = decryptSecret(
|
|
49
|
+
{ encryptedValue: secret.encryptedValue, iv: secret.iv, authTag: secret.authTag },
|
|
50
|
+
masterKey,
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
success: true,
|
|
55
|
+
message: decrypted,
|
|
56
|
+
rawOutput: true,
|
|
57
|
+
data: { moduleId, key, value: decrypted },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
@@ -68,7 +68,7 @@ export async function handleModuleShowConfig(args: string[]): Promise<CommandRes
|
|
|
68
68
|
if (
|
|
69
69
|
key.startsWith('inventory.') ||
|
|
70
70
|
key === 'vmid' ||
|
|
71
|
-
key === '
|
|
71
|
+
key === 'target_ip' ||
|
|
72
72
|
key === 'gateway' ||
|
|
73
73
|
key === 'vlan' ||
|
|
74
74
|
key === 'subnet' ||
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* module terraform-unlock command
|
|
3
|
+
*
|
|
4
|
+
* Removes a stale Terraform state lock for a module.
|
|
5
|
+
* Only use this if you are certain no other deploy is running.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, rmSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { eq } from 'drizzle-orm';
|
|
11
|
+
import { getDb } from '../../db/client';
|
|
12
|
+
import { modules } from '../../db/schema';
|
|
13
|
+
import { validateRequiredArgs } from '../parser';
|
|
14
|
+
import { log } from '../prompts';
|
|
15
|
+
import type { CommandResult } from '../types';
|
|
16
|
+
|
|
17
|
+
export async function handleModuleTerraformUnlock(args: string[]): Promise<CommandResult> {
|
|
18
|
+
const error = validateRequiredArgs(args, 1);
|
|
19
|
+
if (error) {
|
|
20
|
+
return {
|
|
21
|
+
success: false,
|
|
22
|
+
error: 'Usage: celilo module terraform-unlock <module-id>',
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const moduleId = args[0];
|
|
27
|
+
const db = getDb();
|
|
28
|
+
const module = await db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
29
|
+
|
|
30
|
+
if (!module) {
|
|
31
|
+
return { success: false, error: `Module not found: ${moduleId}` };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const lockFile = join(
|
|
35
|
+
module.sourcePath,
|
|
36
|
+
'generated',
|
|
37
|
+
'terraform',
|
|
38
|
+
'.terraform.tfstate.lock.info',
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
if (!existsSync(lockFile)) {
|
|
42
|
+
log.success(`No state lock found for ${moduleId}`);
|
|
43
|
+
return { success: true, message: `No state lock found for ${moduleId}` };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
rmSync(lockFile);
|
|
48
|
+
log.success(`State lock removed for ${moduleId}`);
|
|
49
|
+
log.message('You can now retry the deploy.');
|
|
50
|
+
return { success: true, message: `State lock removed for ${moduleId}` };
|
|
51
|
+
} catch (err) {
|
|
52
|
+
return {
|
|
53
|
+
success: false,
|
|
54
|
+
error: `Failed to remove lock file: ${err instanceof Error ? err.message : String(err)}\n\nTry manually: rm ${lockFile}`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { moduleAudit } from './module-audit';
|
|
6
|
+
import { moduleVerify } from './module-verify';
|
|
7
|
+
|
|
8
|
+
describe('moduleVerify', () => {
|
|
9
|
+
test('returns error when module ID is missing', async () => {
|
|
10
|
+
const result = await moduleVerify([]);
|
|
11
|
+
expect(result.success).toBe(false);
|
|
12
|
+
if (!result.success) {
|
|
13
|
+
expect(result.error).toContain('Module ID is required');
|
|
14
|
+
expect(result.error).toContain('module verify');
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe('moduleAudit (deprecation alias)', () => {
|
|
20
|
+
let dataDir: string;
|
|
21
|
+
const originalDataDir = process.env.CELILO_DATA_DIR;
|
|
22
|
+
const originalDbPath = process.env.CELILO_DB_PATH;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
dataDir = mkdtempSync(join(tmpdir(), 'celilo-audit-alias-'));
|
|
26
|
+
process.env.CELILO_DATA_DIR = dataDir;
|
|
27
|
+
process.env.CELILO_DB_PATH = join(dataDir, 'celilo.db');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
rmSync(dataDir, { recursive: true, force: true });
|
|
32
|
+
if (originalDataDir === undefined) process.env.CELILO_DATA_DIR = undefined;
|
|
33
|
+
else process.env.CELILO_DATA_DIR = originalDataDir;
|
|
34
|
+
if (originalDbPath === undefined) process.env.CELILO_DB_PATH = undefined;
|
|
35
|
+
else process.env.CELILO_DB_PATH = originalDbPath;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('writes a deprecation warning to stderr and delegates to verify', async () => {
|
|
39
|
+
const writes: Buffer[] = [];
|
|
40
|
+
const originalWrite = process.stderr.write.bind(process.stderr);
|
|
41
|
+
// Narrower override is fine for the duration of the test; cast to
|
|
42
|
+
// bypass the overload set on Process['stderr']['write'].
|
|
43
|
+
process.stderr.write = ((chunk: string | Buffer) => {
|
|
44
|
+
writes.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
|
|
45
|
+
return true;
|
|
46
|
+
}) as typeof process.stderr.write;
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const result = await moduleAudit([]); // no args triggers the same arg-error
|
|
50
|
+
expect(result.success).toBe(false);
|
|
51
|
+
} finally {
|
|
52
|
+
process.stderr.write = originalWrite;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const stderr = Buffer.concat(writes).toString('utf-8');
|
|
56
|
+
expect(stderr).toContain('deprecated');
|
|
57
|
+
expect(stderr).toContain('module verify');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { auditModule } from '../../module/packaging/audit';
|
|
2
|
+
import type { CommandResult } from '../types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Verify module integrity (signature + checksums).
|
|
6
|
+
*
|
|
7
|
+
* Usage: celilo module verify <module-id>
|
|
8
|
+
*
|
|
9
|
+
* Renamed from `module audit` per CELILO_UPDATE D11 — `audit` is now
|
|
10
|
+
* reserved for system-level drift detection (`celilo system audit`).
|
|
11
|
+
* The legacy `module audit` continues to work via a deprecation alias
|
|
12
|
+
* (see `module-audit.ts`).
|
|
13
|
+
*
|
|
14
|
+
* Returns a CommandResult so the dispatcher controls process exit
|
|
15
|
+
* behavior.
|
|
16
|
+
*/
|
|
17
|
+
export async function moduleVerify(args: string[]): Promise<CommandResult> {
|
|
18
|
+
if (args.length === 0) {
|
|
19
|
+
return {
|
|
20
|
+
success: false,
|
|
21
|
+
error: 'Module ID is required\n\nUsage: celilo module verify <module-id>',
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const moduleId = args[0];
|
|
26
|
+
|
|
27
|
+
const result = await auditModule(moduleId);
|
|
28
|
+
|
|
29
|
+
if (result.error) {
|
|
30
|
+
return { success: false, error: result.error };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (result.success) {
|
|
34
|
+
return {
|
|
35
|
+
success: true,
|
|
36
|
+
message: `Module '${moduleId}' passed integrity check\n No violations found.`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const violationLines = result.violations.map((v) => {
|
|
41
|
+
const icon = v.type === 'missing' ? '⚠' : v.type === 'modified' ? '✗' : '!';
|
|
42
|
+
return ` ${icon} [${v.type.toUpperCase()}] ${v.message}`;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
success: false,
|
|
47
|
+
error: [
|
|
48
|
+
`Module '${moduleId}' failed integrity check`,
|
|
49
|
+
` Found ${result.violations.length} violation(s):`,
|
|
50
|
+
...violationLines,
|
|
51
|
+
].join('\n'),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -13,40 +13,40 @@ import type { ModuleManifest } from '../../manifest/schema';
|
|
|
13
13
|
import type { CommandResult } from '../types';
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* Determine module status
|
|
16
|
+
* Determine module status for display.
|
|
17
|
+
*
|
|
18
|
+
* Uses the DB state as the primary source of truth.
|
|
19
|
+
* Falls back to filesystem / config inspection for IMPORTED/CONFIGURED modules
|
|
20
|
+
* that haven't yet been deployed.
|
|
17
21
|
*/
|
|
18
22
|
async function determineModuleStatus(
|
|
19
23
|
_moduleId: string,
|
|
20
24
|
manifest: ModuleManifest,
|
|
21
25
|
configs: (typeof moduleConfigs.$inferSelect)[],
|
|
22
26
|
generatedPath: string,
|
|
27
|
+
dbState: string,
|
|
23
28
|
): Promise<{
|
|
24
|
-
status: 'IMPORTED' | 'CONFIGURED' | 'GENERATED' | 'DEPLOYED' | 'NEEDS_UPDATE';
|
|
29
|
+
status: 'IMPORTED' | 'CONFIGURED' | 'GENERATED' | 'DEPLOYED' | 'VERIFIED' | 'NEEDS_UPDATE';
|
|
25
30
|
missingCount?: number;
|
|
26
31
|
}> {
|
|
27
|
-
//
|
|
32
|
+
// Deployed states come directly from the DB — don't infer from filesystem
|
|
33
|
+
if (dbState === 'VERIFIED') return { status: 'VERIFIED' };
|
|
34
|
+
if (dbState === 'INSTALLED') return { status: 'DEPLOYED' };
|
|
35
|
+
|
|
36
|
+
// For pre-deploy states, derive from filesystem / config
|
|
28
37
|
if (existsSync(generatedPath)) {
|
|
29
|
-
// TODO: Add DEPLOYED and NEEDS_UPDATE detection
|
|
30
|
-
// For now, if generated exists, it's GENERATED
|
|
31
38
|
return { status: 'GENERATED' };
|
|
32
39
|
}
|
|
33
40
|
|
|
34
|
-
// Check if all required variables are set
|
|
35
41
|
const requiredVars = manifest.variables?.owns?.filter((v) => v.required) || [];
|
|
36
42
|
const configMap = new Map(configs.map((c) => [c.key, true]));
|
|
37
|
-
|
|
38
43
|
const missingVars = requiredVars.filter((v) => !configMap.has(v.name));
|
|
39
44
|
|
|
40
|
-
if (missingVars.length === 0 && requiredVars.length > 0) {
|
|
41
|
-
return { status: 'CONFIGURED' };
|
|
42
|
-
}
|
|
43
|
-
|
|
44
45
|
if (missingVars.length > 0) {
|
|
45
46
|
return { status: 'IMPORTED', missingCount: missingVars.length };
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
|
|
49
|
-
if (configs.length > 0) {
|
|
49
|
+
if (requiredVars.length > 0 || configs.length > 0) {
|
|
50
50
|
return { status: 'CONFIGURED' };
|
|
51
51
|
}
|
|
52
52
|
|
|
@@ -116,10 +116,16 @@ export async function handleStatus(): Promise<CommandResult> {
|
|
|
116
116
|
manifest,
|
|
117
117
|
moduleConfigsList,
|
|
118
118
|
generatedPath,
|
|
119
|
+
module.state,
|
|
119
120
|
);
|
|
120
121
|
|
|
121
122
|
// Status icon
|
|
122
|
-
const icon =
|
|
123
|
+
const icon =
|
|
124
|
+
statusInfo.status === 'VERIFIED' ||
|
|
125
|
+
statusInfo.status === 'DEPLOYED' ||
|
|
126
|
+
statusInfo.status === 'GENERATED'
|
|
127
|
+
? '✓'
|
|
128
|
+
: '⚠';
|
|
123
129
|
|
|
124
130
|
lines.push(` ${icon} ${module.id} (v${module.version})`);
|
|
125
131
|
lines.push(` Status: ${statusInfo.status}`);
|
|
@@ -169,7 +175,11 @@ export async function handleStatus(): Promise<CommandResult> {
|
|
|
169
175
|
}
|
|
170
176
|
|
|
171
177
|
// Show when last generated (if applicable)
|
|
172
|
-
if (
|
|
178
|
+
if (
|
|
179
|
+
statusInfo.status === 'GENERATED' ||
|
|
180
|
+
statusInfo.status === 'DEPLOYED' ||
|
|
181
|
+
statusInfo.status === 'VERIFIED'
|
|
182
|
+
) {
|
|
173
183
|
try {
|
|
174
184
|
const stats = await stat(generatedPath);
|
|
175
185
|
const age = Date.now() - stats.mtimeMs;
|
|
@@ -197,11 +207,11 @@ export async function handleStatus(): Promise<CommandResult> {
|
|
|
197
207
|
|
|
198
208
|
// Legend
|
|
199
209
|
lines.push('Legend:');
|
|
200
|
-
lines.push(' IMPORTED
|
|
201
|
-
lines.push(' CONFIGURED
|
|
202
|
-
lines.push(' GENERATED
|
|
203
|
-
lines.push(' DEPLOYED
|
|
204
|
-
lines.push('
|
|
210
|
+
lines.push(' IMPORTED - Module imported, not configured');
|
|
211
|
+
lines.push(' CONFIGURED - Configuration complete, not generated');
|
|
212
|
+
lines.push(' GENERATED - Infrastructure code generated, not deployed');
|
|
213
|
+
lines.push(' DEPLOYED - Deployed (Ansible complete, health check pending)');
|
|
214
|
+
lines.push(' VERIFIED - Deployed and health checks passed');
|
|
205
215
|
|
|
206
216
|
return {
|
|
207
217
|
success: true,
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import type { SystemAuditReport } from '../../services/audit';
|
|
3
|
+
import { formatReport } from './system-audit';
|
|
4
|
+
|
|
5
|
+
const baseReport = (overrides: Partial<SystemAuditReport> = {}): SystemAuditReport => ({
|
|
6
|
+
version: 1,
|
|
7
|
+
verdict: 'READY',
|
|
8
|
+
generatedAt: '2026-04-25T00:00:00.000Z',
|
|
9
|
+
findings: [],
|
|
10
|
+
...overrides,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe('formatReport', () => {
|
|
14
|
+
test('READY report calls out no drift', () => {
|
|
15
|
+
const out = formatReport(baseReport());
|
|
16
|
+
expect(out).toContain('READY');
|
|
17
|
+
expect(out).toContain('No drift detected');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('DRIFT report lists each finding with remediation', () => {
|
|
21
|
+
const out = formatReport(
|
|
22
|
+
baseReport({
|
|
23
|
+
verdict: 'DRIFT',
|
|
24
|
+
findings: [
|
|
25
|
+
{
|
|
26
|
+
category: 'cli_version',
|
|
27
|
+
severity: 'drift',
|
|
28
|
+
code: 'cli_version_drift',
|
|
29
|
+
message: '@celilo/cli 0.1.5 → 0.1.7 available',
|
|
30
|
+
remediation: '`celilo system update` will self-update first',
|
|
31
|
+
subject: 'system',
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
expect(out).toContain('DRIFT (1 finding)');
|
|
38
|
+
expect(out).toContain('@celilo/cli 0.1.5 → 0.1.7');
|
|
39
|
+
expect(out).toContain('→ `celilo system update`');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('BLOCKED report puts blocked findings first', () => {
|
|
43
|
+
const out = formatReport(
|
|
44
|
+
baseReport({
|
|
45
|
+
verdict: 'BLOCKED',
|
|
46
|
+
findings: [
|
|
47
|
+
{
|
|
48
|
+
category: 'cli_version',
|
|
49
|
+
severity: 'drift',
|
|
50
|
+
code: 'cli_version_drift',
|
|
51
|
+
message: 'cli is old',
|
|
52
|
+
subject: 'system',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
category: 'schema',
|
|
56
|
+
severity: 'blocked',
|
|
57
|
+
code: 'schema_pending_migrations',
|
|
58
|
+
message: '2 pending DB migrations',
|
|
59
|
+
subject: 'system',
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
}),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const blockedIdx = out.indexOf('BLOCKED • schema');
|
|
66
|
+
const driftIdx = out.indexOf('DRIFT • cli_version');
|
|
67
|
+
expect(blockedIdx).toBeGreaterThanOrEqual(0);
|
|
68
|
+
expect(driftIdx).toBeGreaterThan(blockedIdx);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('groups findings by category', () => {
|
|
72
|
+
const out = formatReport(
|
|
73
|
+
baseReport({
|
|
74
|
+
verdict: 'DRIFT',
|
|
75
|
+
findings: [
|
|
76
|
+
{
|
|
77
|
+
category: 'module_versions',
|
|
78
|
+
severity: 'drift',
|
|
79
|
+
code: 'x',
|
|
80
|
+
message: 'caddy: 1.0 → 1.1',
|
|
81
|
+
subject: 'caddy',
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
category: 'module_versions',
|
|
85
|
+
severity: 'drift',
|
|
86
|
+
code: 'x',
|
|
87
|
+
message: 'iptables: 1.0 → 1.1',
|
|
88
|
+
subject: 'iptables',
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
expect(out).toContain('DRIFT • module_versions (2)');
|
|
95
|
+
expect(out).toContain('caddy: 1.0 → 1.1');
|
|
96
|
+
expect(out).toContain('iptables: 1.0 → 1.1');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('renders details on indented lines', () => {
|
|
100
|
+
const out = formatReport(
|
|
101
|
+
baseReport({
|
|
102
|
+
verdict: 'BLOCKED',
|
|
103
|
+
findings: [
|
|
104
|
+
{
|
|
105
|
+
category: 'schema',
|
|
106
|
+
severity: 'blocked',
|
|
107
|
+
code: 'schema_pending_migrations',
|
|
108
|
+
message: '2 pending DB migrations',
|
|
109
|
+
details: ' • 0001_zones\n • 0002_backups',
|
|
110
|
+
subject: 'system',
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
expect(out).toContain('0001_zones');
|
|
117
|
+
expect(out).toContain('0002_backups');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('singular wording for exactly one finding', () => {
|
|
121
|
+
const out = formatReport(
|
|
122
|
+
baseReport({
|
|
123
|
+
verdict: 'DRIFT',
|
|
124
|
+
findings: [
|
|
125
|
+
{
|
|
126
|
+
category: 'cli_version',
|
|
127
|
+
severity: 'drift',
|
|
128
|
+
code: 'x',
|
|
129
|
+
message: 'cli is old',
|
|
130
|
+
subject: 'system',
|
|
131
|
+
},
|
|
132
|
+
],
|
|
133
|
+
}),
|
|
134
|
+
);
|
|
135
|
+
expect(out).toContain('DRIFT (1 finding)');
|
|
136
|
+
expect(out).not.toContain('1 findings');
|
|
137
|
+
});
|
|
138
|
+
});
|