@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,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module publish command — build and publish a module to the registry.
|
|
3
|
+
*
|
|
4
|
+
* Usage: celilo module publish <module-dir> --token <token>
|
|
5
|
+
* [--registry <url>] [--revision <n>]
|
|
6
|
+
* [--message "release note"] [--allow-dirty]
|
|
7
|
+
*
|
|
8
|
+
* The --token flag (or CELILO_PUBLISH_TOKEN env var) must contain a valid
|
|
9
|
+
* publish token configured on the registry server.
|
|
10
|
+
*
|
|
11
|
+
* Per CELILO_UPDATE D4 (strict-publish) the manifest's capability
|
|
12
|
+
* versions are validated against `CAPABILITY_CONTRACT_VERSIONS` from
|
|
13
|
+
* `@celilo/capabilities` before any build work; a mismatch refuses
|
|
14
|
+
* the publish so a stale manifest can't ship code that doesn't match
|
|
15
|
+
* its claimed contract.
|
|
16
|
+
*
|
|
17
|
+
* Per CELILO_UPDATE D5, every published .netapp carries a
|
|
18
|
+
* `release.json` with git SHA / branch / dirty flag / publish
|
|
19
|
+
* timestamp / CLI version / optional --message.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { rm } from 'node:fs/promises';
|
|
23
|
+
import { readFile } from 'node:fs/promises';
|
|
24
|
+
import { tmpdir } from 'node:os';
|
|
25
|
+
import { join, resolve } from 'node:path';
|
|
26
|
+
import {
|
|
27
|
+
CAPABILITY_CONTRACT_VERSIONS,
|
|
28
|
+
type KnownCapabilityName,
|
|
29
|
+
compareProviderToRuntime,
|
|
30
|
+
} from '@celilo/capabilities';
|
|
31
|
+
import { parse as parseYaml } from 'yaml';
|
|
32
|
+
import { buildModule } from '../../module/packaging/build';
|
|
33
|
+
import {
|
|
34
|
+
buildReleaseMetadata,
|
|
35
|
+
collectGitInfo,
|
|
36
|
+
makeRealGitRunner,
|
|
37
|
+
readInstalledCliVersion,
|
|
38
|
+
} from '../../module/packaging/release-metadata';
|
|
39
|
+
import { RegistryClient } from '../../registry/client';
|
|
40
|
+
import { getArg, getFlag, hasFlag } from '../parser';
|
|
41
|
+
import type { CommandResult } from '../types';
|
|
42
|
+
|
|
43
|
+
interface ManifestCapabilityClaim {
|
|
44
|
+
name: string;
|
|
45
|
+
version: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface ManifestForPublish {
|
|
49
|
+
id: string;
|
|
50
|
+
version: string;
|
|
51
|
+
provides?: { capabilities?: ManifestCapabilityClaim[] };
|
|
52
|
+
requires?: { capabilities?: ManifestCapabilityClaim[] };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isKnownCapability(name: string): name is KnownCapabilityName {
|
|
56
|
+
return name in CAPABILITY_CONTRACT_VERSIONS;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Strict-publish: every `provides[X].version` must match the framework's
|
|
61
|
+
* runtime registry; every `requires[X].version` must be at most the
|
|
62
|
+
* framework's runtime version (consumers can require older minors of
|
|
63
|
+
* still-supported majors).
|
|
64
|
+
*
|
|
65
|
+
* Exported for testing.
|
|
66
|
+
*/
|
|
67
|
+
export function validateCapabilityVersions(manifest: ManifestForPublish): string[] {
|
|
68
|
+
const errors: string[] = [];
|
|
69
|
+
|
|
70
|
+
for (const p of manifest.provides?.capabilities ?? []) {
|
|
71
|
+
if (!isKnownCapability(p.name)) continue;
|
|
72
|
+
const runtime = CAPABILITY_CONTRACT_VERSIONS[p.name];
|
|
73
|
+
const r = compareProviderToRuntime(p.version, runtime);
|
|
74
|
+
if (!r.compatible) {
|
|
75
|
+
errors.push(
|
|
76
|
+
`provides[${p.name}].version is ${p.version} but framework registry is ${runtime} ` +
|
|
77
|
+
`(${r.reason}). Update the manifest, then retry.`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const need of manifest.requires?.capabilities ?? []) {
|
|
83
|
+
if (!isKnownCapability(need.name)) continue;
|
|
84
|
+
const runtime = CAPABILITY_CONTRACT_VERSIONS[need.name];
|
|
85
|
+
// Treat the runtime as the de-facto provider for publish-time validation:
|
|
86
|
+
// if a consumer's manifest claims it requires a contract newer than the
|
|
87
|
+
// framework even has registered, no in-tree provider can satisfy it.
|
|
88
|
+
const r = compareProviderToRuntime(need.version, runtime);
|
|
89
|
+
if (!r.compatible && r.reason === 'major_mismatch_higher') {
|
|
90
|
+
errors.push(
|
|
91
|
+
`requires[${need.name}].version is ${need.version} but framework registry is ${runtime}. Bump the framework before publishing, or lower the manifest version.`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return errors;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function handleModulePublish(
|
|
100
|
+
args: string[],
|
|
101
|
+
flags: Record<string, string | boolean>,
|
|
102
|
+
): Promise<CommandResult> {
|
|
103
|
+
const moduleDir = getArg(args, 0);
|
|
104
|
+
if (!moduleDir) {
|
|
105
|
+
return {
|
|
106
|
+
success: false,
|
|
107
|
+
error:
|
|
108
|
+
'Module directory required\n\nUsage: celilo module publish <module-dir> --token <token>',
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const token = getFlag(flags, 'token', '') || process.env.CELILO_PUBLISH_TOKEN || '';
|
|
113
|
+
if (!token) {
|
|
114
|
+
return {
|
|
115
|
+
success: false,
|
|
116
|
+
error: 'Publish token required — pass --token <token> or set CELILO_PUBLISH_TOKEN',
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const registryUrl = getFlag(flags, 'registry', '');
|
|
121
|
+
const revisionOverride = getFlag(flags, 'revision', '');
|
|
122
|
+
const message = getFlag(flags, 'message', '') || null;
|
|
123
|
+
const allowDirty = hasFlag(flags, 'allow-dirty');
|
|
124
|
+
|
|
125
|
+
if (revisionOverride) {
|
|
126
|
+
const parsed = Number(revisionOverride);
|
|
127
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
128
|
+
return { success: false, error: '--revision must be a positive integer' };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const resolvedDir = resolve(moduleDir);
|
|
133
|
+
|
|
134
|
+
// Read manifest
|
|
135
|
+
let manifest: ManifestForPublish;
|
|
136
|
+
try {
|
|
137
|
+
const manifestRaw = await readFile(join(resolvedDir, 'manifest.yml'), 'utf-8');
|
|
138
|
+
manifest = parseYaml(manifestRaw) as ManifestForPublish;
|
|
139
|
+
if (!manifest.id || !manifest.version) {
|
|
140
|
+
return { success: false, error: 'manifest.yml missing id or version' };
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
return { success: false, error: `Could not read manifest.yml in ${resolvedDir}` };
|
|
144
|
+
}
|
|
145
|
+
const name = manifest.id;
|
|
146
|
+
const baseVersion = manifest.version;
|
|
147
|
+
|
|
148
|
+
// Strict-publish: refuse on capability version mismatch (D4).
|
|
149
|
+
const capErrors = validateCapabilityVersions(manifest);
|
|
150
|
+
if (capErrors.length > 0) {
|
|
151
|
+
return {
|
|
152
|
+
success: false,
|
|
153
|
+
error: [
|
|
154
|
+
`Capability version validation failed for ${name}@${baseVersion}:`,
|
|
155
|
+
...capErrors.map((e) => ` • ${e}`),
|
|
156
|
+
].join('\n'),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Collect git state. Refuse a dirty tree unless --allow-dirty.
|
|
161
|
+
const gitInfo = collectGitInfo(resolvedDir, makeRealGitRunner());
|
|
162
|
+
if (gitInfo.dirty && !allowDirty) {
|
|
163
|
+
return {
|
|
164
|
+
success: false,
|
|
165
|
+
error: [
|
|
166
|
+
`Working tree at ${resolvedDir} has uncommitted changes.`,
|
|
167
|
+
'Refusing to publish — commit (or stash) your changes, or pass --allow-dirty to override.',
|
|
168
|
+
].join('\n'),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Determine package revision: --revision flag, or query the registry for next rev
|
|
173
|
+
let revision: number;
|
|
174
|
+
if (revisionOverride) {
|
|
175
|
+
revision = Number(revisionOverride);
|
|
176
|
+
} else {
|
|
177
|
+
const client = new RegistryClient(registryUrl || undefined);
|
|
178
|
+
try {
|
|
179
|
+
const entries = await client.getIndex(name);
|
|
180
|
+
const existingRevs = entries
|
|
181
|
+
.filter((e) => e.vers.startsWith(`${baseVersion}+`))
|
|
182
|
+
.map((e) => Number(e.vers.split('+')[1]))
|
|
183
|
+
.filter((n) => Number.isInteger(n));
|
|
184
|
+
revision = existingRevs.length > 0 ? Math.max(...existingRevs) + 1 : 1;
|
|
185
|
+
} catch {
|
|
186
|
+
// Registry unreachable — start at +1
|
|
187
|
+
revision = 1;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const version = `${baseVersion}+${revision}`;
|
|
192
|
+
|
|
193
|
+
// Assemble release metadata for the .netapp.
|
|
194
|
+
const releaseMetadata = buildReleaseMetadata({
|
|
195
|
+
moduleId: name,
|
|
196
|
+
version,
|
|
197
|
+
git: gitInfo,
|
|
198
|
+
cliVersion: readInstalledCliVersion(),
|
|
199
|
+
message,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// Build the .netapp into a temp dir
|
|
203
|
+
const tmpPath = join(tmpdir(), `${name}-${version}-${Date.now()}.netapp`);
|
|
204
|
+
console.log(`Building ${name}@${version}...`);
|
|
205
|
+
|
|
206
|
+
const buildResult = await buildModule({
|
|
207
|
+
sourceDir: resolvedDir,
|
|
208
|
+
outputPath: tmpPath,
|
|
209
|
+
releaseMetadata,
|
|
210
|
+
});
|
|
211
|
+
if (!buildResult.success || !buildResult.packagePath) {
|
|
212
|
+
return { success: false, error: buildResult.error ?? 'Build failed' };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Publish
|
|
216
|
+
const client = new RegistryClient(registryUrl || undefined);
|
|
217
|
+
try {
|
|
218
|
+
console.log(`Publishing ${name}@${version} to ${client.baseUrl}...`);
|
|
219
|
+
await client.publish({ name, version, netappPath: buildResult.packagePath, token });
|
|
220
|
+
} catch (err) {
|
|
221
|
+
return {
|
|
222
|
+
success: false,
|
|
223
|
+
error: `Publish failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
224
|
+
};
|
|
225
|
+
} finally {
|
|
226
|
+
await rm(tmpPath, { force: true });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
success: true,
|
|
231
|
+
message: `Published ${name}@${version}${message ? ` — "${message}"` : ''}`,
|
|
232
|
+
data: { name, version, releaseMetadata },
|
|
233
|
+
};
|
|
234
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|