@celilo/cli 0.1.5 → 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 +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,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System update command — orchestrates the audit-determined upgrade flow.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* celilo system update [--module <id>] [--no-backup] [--allow-destructive]
|
|
6
|
+
* [--dry-run] [--json]
|
|
7
|
+
*
|
|
8
|
+
* Per CELILO_UPDATE Phase 4: runs `system audit` first, refuses on
|
|
9
|
+
* BLOCKED, then walks each drifting module through
|
|
10
|
+
* backup → upgrade → deploy → health, isolating failures to the
|
|
11
|
+
* dependency subtree.
|
|
12
|
+
*
|
|
13
|
+
* This is a thin adapter — per Rule 10.5: parse args, compose deps,
|
|
14
|
+
* call `runSystemUpdate`, format output, return. The real logic
|
|
15
|
+
* lives in `services/update/orchestrator.ts`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
19
|
+
import { dirname, join } from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import { eq } from 'drizzle-orm';
|
|
22
|
+
import { getDb } from '../../db/client';
|
|
23
|
+
import { backups, moduleConfigs as moduleConfigsTbl, modules } from '../../db/schema';
|
|
24
|
+
import type { ModuleManifest } from '../../manifest/schema';
|
|
25
|
+
import { RegistryClient } from '../../registry/client';
|
|
26
|
+
import { runAudit } from '../../services/audit';
|
|
27
|
+
import { fetchLatestCliVersion } from '../../services/audit/cli-version';
|
|
28
|
+
import { makeJournalReader, readAppliedMigrations } from '../../services/audit/schema';
|
|
29
|
+
import { createModuleBackup, createSystemStateBackup } from '../../services/backup-create';
|
|
30
|
+
import { runAllHealthChecks, runModuleHealthCheck } from '../../services/health-runner';
|
|
31
|
+
import { deployModule } from '../../services/module-deploy';
|
|
32
|
+
import { buildModuleGraph } from '../../services/update/dep-graph';
|
|
33
|
+
import {
|
|
34
|
+
type ModuleSnapshot,
|
|
35
|
+
type OrchestratorOps,
|
|
36
|
+
runSystemUpdate,
|
|
37
|
+
} from '../../services/update/orchestrator';
|
|
38
|
+
import type { SystemUpdateResult } from '../../services/update/types';
|
|
39
|
+
import { getFlag, hasFlag } from '../parser';
|
|
40
|
+
import type { CommandResult } from '../types';
|
|
41
|
+
|
|
42
|
+
function readInstalledCliVersion(): string {
|
|
43
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
44
|
+
const candidates = [
|
|
45
|
+
join(here, '..', '..', '..', 'package.json'),
|
|
46
|
+
join(process.cwd(), 'package.json'),
|
|
47
|
+
];
|
|
48
|
+
for (const path of candidates) {
|
|
49
|
+
if (existsSync(path)) {
|
|
50
|
+
try {
|
|
51
|
+
const pkg = JSON.parse(readFileSync(path, 'utf-8')) as { version?: string };
|
|
52
|
+
if (pkg.version) return pkg.version;
|
|
53
|
+
} catch {
|
|
54
|
+
// try the next candidate
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return '0.0.0';
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function findMigrationsFolderSafe(): string | null {
|
|
62
|
+
try {
|
|
63
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
64
|
+
const candidates = [
|
|
65
|
+
join(here, '..', '..', '..', 'drizzle'),
|
|
66
|
+
join(process.cwd(), 'drizzle'),
|
|
67
|
+
join(process.cwd(), 'apps', 'celilo', 'drizzle'),
|
|
68
|
+
];
|
|
69
|
+
for (const c of candidates) {
|
|
70
|
+
if (existsSync(join(c, 'meta', '_journal.json'))) return c;
|
|
71
|
+
}
|
|
72
|
+
return null;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Build a snapshot map from installed modules + registry lookups.
|
|
80
|
+
*
|
|
81
|
+
* `installedProvides` and `pendingRequires` come from the manifest
|
|
82
|
+
* — `installedProvides` from the currently-deployed manifest in the
|
|
83
|
+
* DB; `pendingRequires` from the manifest of the latest registry
|
|
84
|
+
* version (so the version-aware skip can compare what the new code
|
|
85
|
+
* needs against what the still-running provider has).
|
|
86
|
+
*
|
|
87
|
+
* Phase 4 V1: we don't yet fetch the new manifest from the registry
|
|
88
|
+
* — that needs a registry endpoint that exposes the embedded
|
|
89
|
+
* manifest.yml. Until that lands, `pendingRequires` is populated
|
|
90
|
+
* from the *currently installed* manifest (so consumer compatibility
|
|
91
|
+
* checks behave as "are the running versions still compatible?").
|
|
92
|
+
* That degrades the skip logic to the pre-D3 behavior in mixed
|
|
93
|
+
* cases but doesn't crash anything.
|
|
94
|
+
*/
|
|
95
|
+
async function buildSnapshots(
|
|
96
|
+
db: ReturnType<typeof getDb>,
|
|
97
|
+
installed: Array<typeof modules.$inferSelect>,
|
|
98
|
+
registry: RegistryClient,
|
|
99
|
+
): Promise<Map<string, ModuleSnapshot>> {
|
|
100
|
+
const snapshots = new Map<string, ModuleSnapshot>();
|
|
101
|
+
for (const m of installed) {
|
|
102
|
+
const manifest = m.manifestData as ModuleManifest;
|
|
103
|
+
const provides: Record<string, string> = {};
|
|
104
|
+
for (const p of manifest.provides?.capabilities ?? []) {
|
|
105
|
+
provides[p.name] = p.version;
|
|
106
|
+
}
|
|
107
|
+
const requires: Record<string, string> = {};
|
|
108
|
+
for (const r of manifest.requires?.capabilities ?? []) {
|
|
109
|
+
requires[r.name] = r.version;
|
|
110
|
+
}
|
|
111
|
+
for (const r of manifest.optional?.capabilities ?? []) {
|
|
112
|
+
requires[r.name] = r.version;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let latest: string | null = null;
|
|
116
|
+
try {
|
|
117
|
+
const entries = await registry.getIndex(m.id);
|
|
118
|
+
const top = registry.latestVersion(entries);
|
|
119
|
+
latest = top ? top.vers : null;
|
|
120
|
+
} catch {
|
|
121
|
+
// network error → leave null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
snapshots.set(m.id, {
|
|
125
|
+
id: m.id,
|
|
126
|
+
installedVersion: m.version,
|
|
127
|
+
latestVersion: latest,
|
|
128
|
+
installedProvides: provides,
|
|
129
|
+
pendingRequires: requires,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
// Suppress the unused-db warning while we don't yet need the parameter.
|
|
133
|
+
// (Future revs read pending manifests from the DB cache.)
|
|
134
|
+
void db;
|
|
135
|
+
return snapshots;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Live ops — wraps the existing per-module services as the
|
|
140
|
+
* orchestrator's hook callbacks.
|
|
141
|
+
*
|
|
142
|
+
* - `backup` calls `createModuleBackup`. Modules without an
|
|
143
|
+
* `on_backup` hook return ok (nothing to back up; not an error).
|
|
144
|
+
* - `upgrade` is a no-op for now — the in-place upgrade path lives
|
|
145
|
+
* in `module install <name>` and needs a refactor before the
|
|
146
|
+
* orchestrator can drive it cleanly. Deploy will re-converge
|
|
147
|
+
* against whatever's installed, which is the right behavior for
|
|
148
|
+
* "redeploy what's there."
|
|
149
|
+
* - `deploy` calls `deployModule`.
|
|
150
|
+
* - `health` calls `runModuleHealthCheck`.
|
|
151
|
+
* - `snapshotCeliloDb` calls `createSystemStateBackup`.
|
|
152
|
+
*/
|
|
153
|
+
function buildOps(): OrchestratorOps {
|
|
154
|
+
return {
|
|
155
|
+
backup: async (moduleId, _updateId) => {
|
|
156
|
+
const db = getDb();
|
|
157
|
+
const mod = db.select().from(modules).where(eq(modules.id, moduleId)).get();
|
|
158
|
+
if (!mod) return { ok: false, error: `module ${moduleId} not found` };
|
|
159
|
+
const manifest = mod.manifestData as ModuleManifest;
|
|
160
|
+
if (!manifest.hooks?.on_backup) {
|
|
161
|
+
return { ok: true };
|
|
162
|
+
}
|
|
163
|
+
const result = await createModuleBackup(moduleId);
|
|
164
|
+
return result.success ? { ok: true } : { ok: false, error: result.error };
|
|
165
|
+
},
|
|
166
|
+
upgrade: async (_id) => ({ ok: true }),
|
|
167
|
+
deploy: async (id) => {
|
|
168
|
+
const db = getDb();
|
|
169
|
+
const result = await deployModule(id, db, { noInteractive: true });
|
|
170
|
+
return result.success ? { ok: true } : { ok: false, error: result.error };
|
|
171
|
+
},
|
|
172
|
+
health: async (id) => {
|
|
173
|
+
// We can call the existing health-runner directly today — that's
|
|
174
|
+
// already a clean service.
|
|
175
|
+
const db = getDb();
|
|
176
|
+
const r = await runModuleHealthCheck(id, db);
|
|
177
|
+
return { status: r.status, detail: r.error };
|
|
178
|
+
},
|
|
179
|
+
snapshotCeliloDb: async (_updateId) => {
|
|
180
|
+
const result = await createSystemStateBackup();
|
|
181
|
+
return result.success ? { ok: true } : { ok: false, error: result.error };
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function formatResult(result: SystemUpdateResult): string {
|
|
187
|
+
const lines: string[] = [];
|
|
188
|
+
lines.push('');
|
|
189
|
+
lines.push(`update ${result.updateId} → ${result.ok ? 'OK' : 'FAILED'}`);
|
|
190
|
+
lines.push(` audit verdict: ${result.audit.verdict}`);
|
|
191
|
+
lines.push(
|
|
192
|
+
` self-update: ${result.selfUpdate.performed ? `${result.selfUpdate.from} → ${result.selfUpdate.to}` : `(${result.selfUpdate.reason})`}`,
|
|
193
|
+
);
|
|
194
|
+
lines.push(` backups: ${result.backupsCreated ? 'created' : 'skipped'}`);
|
|
195
|
+
lines.push('');
|
|
196
|
+
for (const m of result.modules) {
|
|
197
|
+
const tag =
|
|
198
|
+
m.step === 'done' ? '✓' : m.step === 'failed' ? '✗' : m.step === 'skipped' ? '↳' : '?';
|
|
199
|
+
const change =
|
|
200
|
+
m.fromVersion === m.toVersion ? m.fromVersion : `${m.fromVersion} → ${m.toVersion}`;
|
|
201
|
+
lines.push(` ${tag} ${m.moduleId} (${change}) — ${m.step}`);
|
|
202
|
+
if (m.error) lines.push(` ${m.error}`);
|
|
203
|
+
if (m.skipReason) lines.push(` ${m.skipReason}`);
|
|
204
|
+
}
|
|
205
|
+
return lines.join('\n');
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export async function handleSystemUpdate(
|
|
209
|
+
_args: string[],
|
|
210
|
+
flags: Record<string, string | boolean>,
|
|
211
|
+
): Promise<CommandResult> {
|
|
212
|
+
const json = hasFlag(flags, 'json');
|
|
213
|
+
const dryRun = hasFlag(flags, 'dry-run');
|
|
214
|
+
const noBackup = hasFlag(flags, 'no-backup');
|
|
215
|
+
const allowDestructive = hasFlag(flags, 'allow-destructive');
|
|
216
|
+
const onlyModule = getFlag(flags, 'module', '') || undefined;
|
|
217
|
+
|
|
218
|
+
const db = getDb();
|
|
219
|
+
const installed = db.select().from(modules).all();
|
|
220
|
+
const deployedModules = installed.filter((m) => ['INSTALLED', 'VERIFIED'].includes(m.state));
|
|
221
|
+
const registry = new RegistryClient();
|
|
222
|
+
|
|
223
|
+
// Build the dep graph from installed manifests.
|
|
224
|
+
const graph = buildModuleGraph(deployedModules.map((m) => m.manifestData as ModuleManifest));
|
|
225
|
+
const snapshots = await buildSnapshots(db, deployedModules, registry);
|
|
226
|
+
|
|
227
|
+
// Build audit deps. (Mostly mirrors system-audit.ts; could be refactored
|
|
228
|
+
// into a shared helper in Phase 5.)
|
|
229
|
+
const migrationsFolder = findMigrationsFolderSafe();
|
|
230
|
+
const healthResults = await runAllHealthChecks(db);
|
|
231
|
+
|
|
232
|
+
const latestBackupByModule = new Map<string, number>();
|
|
233
|
+
try {
|
|
234
|
+
const successfulBackups = db
|
|
235
|
+
.select()
|
|
236
|
+
.from(backups)
|
|
237
|
+
.where(eq(backups.status, 'completed'))
|
|
238
|
+
.all();
|
|
239
|
+
for (const b of successfulBackups) {
|
|
240
|
+
if (!b.moduleId || !b.completedAt) continue;
|
|
241
|
+
const prev = latestBackupByModule.get(b.moduleId);
|
|
242
|
+
const ts = b.completedAt.getTime();
|
|
243
|
+
if (prev === undefined || ts > prev) latestBackupByModule.set(b.moduleId, ts);
|
|
244
|
+
}
|
|
245
|
+
} catch {
|
|
246
|
+
// backups table missing on a fresh DB — fine.
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const allConfigs = db.select().from(moduleConfigsTbl).all();
|
|
250
|
+
const configsByModule = new Map<string, Record<string, unknown>>();
|
|
251
|
+
for (const c of allConfigs) {
|
|
252
|
+
const m = configsByModule.get(c.moduleId) ?? {};
|
|
253
|
+
m[c.key] = c.valueJson ? JSON.parse(c.valueJson) : c.value;
|
|
254
|
+
configsByModule.set(c.moduleId, m);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const auditDeps = {
|
|
258
|
+
cliVersion: {
|
|
259
|
+
installedVersion: readInstalledCliVersion(),
|
|
260
|
+
fetcher: fetchLatestCliVersion,
|
|
261
|
+
},
|
|
262
|
+
schema: {
|
|
263
|
+
journal: migrationsFolder ? makeJournalReader(migrationsFolder) : () => null,
|
|
264
|
+
applied: readAppliedMigrations,
|
|
265
|
+
db,
|
|
266
|
+
},
|
|
267
|
+
capabilityAbi: {
|
|
268
|
+
modules: deployedModules.map((m) => ({
|
|
269
|
+
id: m.id,
|
|
270
|
+
manifest: m.manifestData as ModuleManifest,
|
|
271
|
+
})),
|
|
272
|
+
},
|
|
273
|
+
terraformPlan: {
|
|
274
|
+
modules: deployedModules.map((m) => ({
|
|
275
|
+
id: m.id,
|
|
276
|
+
terraformDir: existsSync(join(m.sourcePath, 'generated', 'terraform'))
|
|
277
|
+
? join(m.sourcePath, 'generated', 'terraform')
|
|
278
|
+
: null,
|
|
279
|
+
})),
|
|
280
|
+
run: async () => ({ exitCode: 0, stdout: '', stderr: '' }),
|
|
281
|
+
},
|
|
282
|
+
moduleVersions: {
|
|
283
|
+
installed: deployedModules.map((m) => ({ id: m.id, version: m.version })),
|
|
284
|
+
fetcher: async (id: string) => {
|
|
285
|
+
const entries = await registry.getIndex(id);
|
|
286
|
+
const top = registry.latestVersion(entries);
|
|
287
|
+
return top ? { latest: top.vers } : { latest: null };
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
moduleConfigs: {
|
|
291
|
+
modules: deployedModules.map((m) => ({
|
|
292
|
+
id: m.id,
|
|
293
|
+
manifest: m.manifestData as ModuleManifest,
|
|
294
|
+
configs: configsByModule.get(m.id) ?? {},
|
|
295
|
+
})),
|
|
296
|
+
},
|
|
297
|
+
health: { results: healthResults },
|
|
298
|
+
backups: {
|
|
299
|
+
modules: deployedModules.map((m) => ({
|
|
300
|
+
id: m.id,
|
|
301
|
+
manifest: m.manifestData as ModuleManifest,
|
|
302
|
+
lastSuccessfulBackupAt: latestBackupByModule.get(m.id) ?? null,
|
|
303
|
+
})),
|
|
304
|
+
},
|
|
305
|
+
undeployedModules: {
|
|
306
|
+
modules: installed.map((m) => ({ id: m.id, state: m.state })),
|
|
307
|
+
},
|
|
308
|
+
unconfiguredModules: {
|
|
309
|
+
modules: installed.map((m) => ({
|
|
310
|
+
id: m.id,
|
|
311
|
+
state: m.state,
|
|
312
|
+
configCount: Object.keys(configsByModule.get(m.id) ?? {}).length,
|
|
313
|
+
})),
|
|
314
|
+
},
|
|
315
|
+
// The update flow doesn't run these checks itself — pass empty
|
|
316
|
+
// results so the typed AuditDeps shape is satisfied. The full
|
|
317
|
+
// audit (with credential + secret decryption checks) runs in
|
|
318
|
+
// `system audit`; pre-update we just consume what we have.
|
|
319
|
+
servicesCredentials: { results: [] },
|
|
320
|
+
secretsDecryptable: { results: [] },
|
|
321
|
+
servicesReachable: { results: [] },
|
|
322
|
+
machinesReachable: { results: [] },
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
if (dryRun) {
|
|
326
|
+
const audit = await runAudit(auditDeps);
|
|
327
|
+
const plan = {
|
|
328
|
+
version: 1 as const,
|
|
329
|
+
updateId: crypto.randomUUID(),
|
|
330
|
+
audit,
|
|
331
|
+
willSelfUpdate: false, // computed at run time; the dry-run preview is best-effort
|
|
332
|
+
modules: deployedModules
|
|
333
|
+
.filter((m) => {
|
|
334
|
+
const snap = snapshots.get(m.id);
|
|
335
|
+
return snap?.latestVersion && snap.latestVersion !== snap.installedVersion;
|
|
336
|
+
})
|
|
337
|
+
.map((m) => ({
|
|
338
|
+
moduleId: m.id,
|
|
339
|
+
fromVersion: m.version,
|
|
340
|
+
toVersion: snapshots.get(m.id)?.latestVersion ?? m.version,
|
|
341
|
+
dependsOn: [...(graph.edges.get(m.id) ?? [])],
|
|
342
|
+
})),
|
|
343
|
+
willBackup: !noBackup,
|
|
344
|
+
destructiveTerraformBlocked: !allowDestructive,
|
|
345
|
+
};
|
|
346
|
+
return json
|
|
347
|
+
? { success: true, message: JSON.stringify(plan, null, 2), rawOutput: true }
|
|
348
|
+
: {
|
|
349
|
+
success: true,
|
|
350
|
+
message: `dry-run: ${plan.modules.length} module(s) would be updated, backups=${plan.willBackup ? 'on' : 'off'}, allow-destructive=${allowDestructive}\n${plan.modules
|
|
351
|
+
.map((m) => ` ▸ ${m.moduleId}: ${m.fromVersion} → ${m.toVersion}`)
|
|
352
|
+
.join('\n')}`,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const ops = buildOps();
|
|
357
|
+
|
|
358
|
+
const result = await runSystemUpdate({
|
|
359
|
+
audit: auditDeps,
|
|
360
|
+
graph,
|
|
361
|
+
snapshots,
|
|
362
|
+
ops,
|
|
363
|
+
selfUpdate: {
|
|
364
|
+
installedVersion: readInstalledCliVersion(),
|
|
365
|
+
fetcher: fetchLatestCliVersion,
|
|
366
|
+
updater: async () => ({
|
|
367
|
+
ok: false,
|
|
368
|
+
stderr: 'self-update wiring lands in Phase 5',
|
|
369
|
+
}),
|
|
370
|
+
},
|
|
371
|
+
progress: { emit() {} },
|
|
372
|
+
noBackup,
|
|
373
|
+
allowDestructive,
|
|
374
|
+
onlyModule,
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
if (json) {
|
|
378
|
+
if (result.ok) {
|
|
379
|
+
return { success: true, message: JSON.stringify(result, null, 2), rawOutput: true };
|
|
380
|
+
}
|
|
381
|
+
return { success: false, error: JSON.stringify(result, null, 2) };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (result.ok) {
|
|
385
|
+
return { success: true, message: formatResult(result) };
|
|
386
|
+
}
|
|
387
|
+
return {
|
|
388
|
+
success: false,
|
|
389
|
+
error: `${formatResult(result)}\n\none or more modules failed; see output above`,
|
|
390
|
+
};
|
|
391
|
+
}
|
package/src/cli/completion.ts
CHANGED
|
@@ -41,6 +41,7 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
41
41
|
'ipam',
|
|
42
42
|
'completion',
|
|
43
43
|
'status',
|
|
44
|
+
'audit',
|
|
44
45
|
'version',
|
|
45
46
|
];
|
|
46
47
|
return filterSuggestions(commands, args[0] || '');
|
|
@@ -98,9 +99,13 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
98
99
|
if (command === 'module' && currentIndex === 1) {
|
|
99
100
|
const subcommands = [
|
|
100
101
|
'import',
|
|
102
|
+
'install',
|
|
101
103
|
'list',
|
|
104
|
+
'publish',
|
|
102
105
|
'remove',
|
|
106
|
+
'search',
|
|
103
107
|
'update',
|
|
108
|
+
'verify',
|
|
104
109
|
'audit',
|
|
105
110
|
'config',
|
|
106
111
|
'show-config',
|
|
@@ -114,12 +119,18 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
114
119
|
'run-hook',
|
|
115
120
|
'secret',
|
|
116
121
|
'status',
|
|
122
|
+
'terraform-unlock',
|
|
117
123
|
'types',
|
|
118
124
|
'validate',
|
|
119
125
|
];
|
|
120
126
|
return filterSuggestions(subcommands, args[1] || '');
|
|
121
127
|
}
|
|
122
128
|
|
|
129
|
+
// Module import subcommands (celilo module import file|public-registry)
|
|
130
|
+
if (command === 'module' && args[1] === 'import' && currentIndex === 2) {
|
|
131
|
+
return filterSuggestions(['file', 'public-registry'], args[2] || '');
|
|
132
|
+
}
|
|
133
|
+
|
|
123
134
|
// Module config subcommands (celilo module config set/get)
|
|
124
135
|
if (command === 'module' && args[1] === 'config' && currentIndex === 2) {
|
|
125
136
|
const subcommands = ['set', 'get'];
|
|
@@ -306,6 +317,9 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
306
317
|
'build',
|
|
307
318
|
'run-hook',
|
|
308
319
|
'status',
|
|
320
|
+
'terraform-unlock',
|
|
321
|
+
'verify',
|
|
322
|
+
'audit', // deprecation alias for `verify`
|
|
309
323
|
];
|
|
310
324
|
if (moduleCommands.includes(args[1] || '')) {
|
|
311
325
|
const db = getDb();
|
|
@@ -399,7 +413,7 @@ export async function getCompletions(words: string[], current: number): Promise<
|
|
|
399
413
|
|
|
400
414
|
// System subcommands
|
|
401
415
|
if (command === 'system' && currentIndex === 1) {
|
|
402
|
-
const subcommands = ['init', 'config', 'secret', 'vault-password'];
|
|
416
|
+
const subcommands = ['init', 'config', 'secret', 'vault-password', 'audit', 'update'];
|
|
403
417
|
return filterSuggestions(subcommands, args[1] || '');
|
|
404
418
|
}
|
|
405
419
|
|
package/src/cli/fuel-gauge.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { stdout } from 'node:process';
|
|
13
13
|
import * as p from '@clack/prompts';
|
|
14
|
+
import { getActiveDisplay } from './prompts';
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* ANSI color codes for terminal output
|
|
@@ -53,6 +54,8 @@ export class FuelGauge {
|
|
|
53
54
|
private sigintHandler?: () => void;
|
|
54
55
|
private alreadyCleanedUp = false; // Track if we've already cleaned up terminal
|
|
55
56
|
private linesDrawn = 0; // Track exactly how many lines were drawn last frame
|
|
57
|
+
private startTime = Date.now();
|
|
58
|
+
private lastOutputTime = Date.now();
|
|
56
59
|
|
|
57
60
|
constructor(title: string, options: FuelGaugeOptions = {}) {
|
|
58
61
|
this.title = title;
|
|
@@ -71,6 +74,16 @@ export class FuelGauge {
|
|
|
71
74
|
return;
|
|
72
75
|
}
|
|
73
76
|
|
|
77
|
+
// When a ProgressDisplay is active (e.g. inside `module deploy`),
|
|
78
|
+
// delegate to it instead of running the cursor-redraw animation —
|
|
79
|
+
// the two would otherwise stomp on each other's output.
|
|
80
|
+
const display = getActiveDisplay();
|
|
81
|
+
if (display) {
|
|
82
|
+
this.running = true;
|
|
83
|
+
display.startStep(this.title, this.title);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
74
87
|
this.running = true;
|
|
75
88
|
|
|
76
89
|
// Hide cursor
|
|
@@ -140,7 +153,17 @@ export class FuelGauge {
|
|
|
140
153
|
this.outputLines.shift();
|
|
141
154
|
}
|
|
142
155
|
|
|
156
|
+
const display = getActiveDisplay();
|
|
157
|
+
if (display) {
|
|
158
|
+
// Pass the original line (ANSI preserved) so colour/dim codes from
|
|
159
|
+
// the hook logger render in the display. Skip blank lines that the
|
|
160
|
+
// child process may emit between output chunks.
|
|
161
|
+
if (line.trim()) display.subEvent(line);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
143
165
|
this.hasNewOutput = true;
|
|
166
|
+
this.lastOutputTime = Date.now();
|
|
144
167
|
}
|
|
145
168
|
|
|
146
169
|
/**
|
|
@@ -169,6 +192,21 @@ export class FuelGauge {
|
|
|
169
192
|
return;
|
|
170
193
|
}
|
|
171
194
|
|
|
195
|
+
const display = getActiveDisplay();
|
|
196
|
+
if (display) {
|
|
197
|
+
if (success) {
|
|
198
|
+
display.doneStep();
|
|
199
|
+
} else {
|
|
200
|
+
display.failStep(this.title);
|
|
201
|
+
// Surface the last few output lines so the user can see what broke.
|
|
202
|
+
const errorLines = this.outputLines.slice(-this.errorDisplayLines);
|
|
203
|
+
for (const line of errorLines) {
|
|
204
|
+
if (line.trim()) display.subEvent(line);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
172
210
|
this.cleanup();
|
|
173
211
|
|
|
174
212
|
if (success) {
|
|
@@ -187,6 +225,25 @@ export class FuelGauge {
|
|
|
187
225
|
}
|
|
188
226
|
}
|
|
189
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Tear down the animation without printing a success or failure stamp.
|
|
230
|
+
* Used when the gauge needs to step out of the way temporarily — e.g.
|
|
231
|
+
* for the cross-module ensure interview, where the next prompt would
|
|
232
|
+
* otherwise collide with the running animation. Pair with a fresh
|
|
233
|
+
* FuelGauge for any subsequent work.
|
|
234
|
+
*/
|
|
235
|
+
stopSilent(): void {
|
|
236
|
+
if (!this.running) return;
|
|
237
|
+
this.running = false;
|
|
238
|
+
if (this.skipAnimation) return;
|
|
239
|
+
const display = getActiveDisplay();
|
|
240
|
+
if (display) {
|
|
241
|
+
display.abandon();
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
this.cleanup();
|
|
245
|
+
}
|
|
246
|
+
|
|
190
247
|
/**
|
|
191
248
|
* Clean up resources (keyboard listener, animation, cursor)
|
|
192
249
|
*/
|
|
@@ -351,13 +408,21 @@ export class FuelGauge {
|
|
|
351
408
|
return s + ' '.repeat(remaining);
|
|
352
409
|
};
|
|
353
410
|
|
|
354
|
-
// Title line
|
|
355
|
-
const
|
|
411
|
+
// Title line with elapsed time
|
|
412
|
+
const elapsedSecs = Math.floor((Date.now() - this.startTime) / 1000);
|
|
413
|
+
const elapsedStr = elapsedSecs > 0 ? ` (${elapsedSecs}s)` : '';
|
|
414
|
+
const titleText = `▸ ${this.title}${elapsedStr}`;
|
|
356
415
|
frame += `${pad(colors.cyan(titleText), titleText.length + 2)}\n`;
|
|
357
416
|
lineCount++;
|
|
358
417
|
|
|
418
|
+
// If process has been silent for >5s, inject a status line so user knows it's alive
|
|
419
|
+
const silentSecs = Math.floor((Date.now() - this.lastOutputTime) / 1000);
|
|
420
|
+
const silentLines = silentSecs >= 5 ? [' Status: running'] : [];
|
|
421
|
+
|
|
359
422
|
// Output preview lines
|
|
360
|
-
const
|
|
423
|
+
const sourceLines =
|
|
424
|
+
silentLines.length > 0 ? [...this.outputLines, ...silentLines] : this.outputLines;
|
|
425
|
+
const displayLines = this.formatOutputLines(sourceLines, this.maxDisplayLines);
|
|
361
426
|
for (const line of displayLines) {
|
|
362
427
|
frame += `${pad(colors.dim(line), line.length)}\n`;
|
|
363
428
|
lineCount++;
|
|
@@ -132,9 +132,19 @@ function generateCommandFunction(cmd: CommandDef, path: string[], lines: string[
|
|
|
132
132
|
lines.push(' local curcontext="$curcontext" state line');
|
|
133
133
|
lines.push(' typeset -A opt_args');
|
|
134
134
|
lines.push('');
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
135
|
+
const flagArgs = cmd.flags && cmd.flags.length > 0 ? generateFlagArgs(cmd.flags) : [];
|
|
136
|
+
if (flagArgs.length > 0) {
|
|
137
|
+
lines.push(' _arguments -C \\');
|
|
138
|
+
for (const flag of flagArgs) {
|
|
139
|
+
lines.push(` ${flag} \\`);
|
|
140
|
+
}
|
|
141
|
+
lines.push(` '1: :${fnName}_commands' \\`);
|
|
142
|
+
lines.push(" '*::arg:->args'");
|
|
143
|
+
} else {
|
|
144
|
+
lines.push(' _arguments -C \\');
|
|
145
|
+
lines.push(` '1: :${fnName}_commands' \\`);
|
|
146
|
+
lines.push(" '*::arg:->args'");
|
|
147
|
+
}
|
|
138
148
|
lines.push('');
|
|
139
149
|
lines.push(' case $state in');
|
|
140
150
|
lines.push(' args)');
|