@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
|
@@ -2,42 +2,51 @@
|
|
|
2
2
|
* Module import command
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import { unlink } from 'node:fs/promises';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join, resolve } from 'node:path';
|
|
6
8
|
import { getModuleStoragePath, shortenPath } from '../../config/paths';
|
|
7
9
|
import { getDb } from '../../db/client';
|
|
8
10
|
import { importModule } from '../../module/import';
|
|
9
|
-
import {
|
|
11
|
+
import { RegistryClient } from '../../registry/client';
|
|
12
|
+
import { getArg, getFlag } from '../parser';
|
|
10
13
|
import type { CommandResult } from '../types';
|
|
11
14
|
import { generateTypesForImportedModule } from './module-types';
|
|
12
15
|
|
|
16
|
+
const USAGE = `Usage:
|
|
17
|
+
celilo module import file <path> Import from local filesystem
|
|
18
|
+
celilo module import public-registry <name> Import from celilo.computer registry`;
|
|
19
|
+
|
|
13
20
|
/**
|
|
14
21
|
* Handle module import command
|
|
15
22
|
*
|
|
16
|
-
* Usage: celilo module import <path> [--target <path>]
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* @param flags - Command flags
|
|
20
|
-
* @returns Command result
|
|
23
|
+
* Usage: celilo module import file <path> [--target <path>]
|
|
24
|
+
* celilo module import public-registry <name>
|
|
25
|
+
* celilo module import <path> (alias for "file", kept for compatibility)
|
|
21
26
|
*/
|
|
22
27
|
export async function handleModuleImport(
|
|
23
28
|
args: string[],
|
|
24
29
|
flags: Record<string, string | boolean>,
|
|
25
30
|
): Promise<CommandResult> {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (
|
|
29
|
-
return {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
const subcommand = getArg(args, 0);
|
|
32
|
+
|
|
33
|
+
if (!subcommand) {
|
|
34
|
+
return { success: false, error: `Subcommand required.\n\n${USAGE}` };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (subcommand === 'public-registry') {
|
|
38
|
+
const moduleName = getArg(args, 1);
|
|
39
|
+
if (!moduleName) {
|
|
40
|
+
return { success: false, error: `Module name required.\n\n${USAGE}` };
|
|
41
|
+
}
|
|
42
|
+
return handlePublicRegistryImport(moduleName, flags);
|
|
33
43
|
}
|
|
34
44
|
|
|
35
|
-
|
|
45
|
+
// Resolve the source path: "file <path>" or bare "<path>" alias
|
|
46
|
+
const sourcePath = subcommand === 'file' ? getArg(args, 1) : subcommand;
|
|
47
|
+
|
|
36
48
|
if (!sourcePath) {
|
|
37
|
-
return {
|
|
38
|
-
success: false,
|
|
39
|
-
error: 'Source path is required',
|
|
40
|
-
};
|
|
49
|
+
return { success: false, error: `Path required.\n\n${USAGE}` };
|
|
41
50
|
}
|
|
42
51
|
|
|
43
52
|
// Resolve paths
|
|
@@ -62,9 +71,6 @@ export async function handleModuleImport(
|
|
|
62
71
|
};
|
|
63
72
|
}
|
|
64
73
|
|
|
65
|
-
// Belt-and-suspenders: regenerate the module's celilo/types.d.ts so
|
|
66
|
-
// the imported module can never have stale types. Non-fatal on error —
|
|
67
|
-
// a codegen failure should not abort a successful import.
|
|
68
74
|
const typesPath = await generateTypesForImportedModule(result.targetPath);
|
|
69
75
|
const typesMessage = typesPath ? `\nGenerated types: ${shortenPath(typesPath)}` : '';
|
|
70
76
|
|
|
@@ -78,3 +84,81 @@ export async function handleModuleImport(
|
|
|
78
84
|
},
|
|
79
85
|
};
|
|
80
86
|
}
|
|
87
|
+
|
|
88
|
+
async function handlePublicRegistryImport(
|
|
89
|
+
name: string,
|
|
90
|
+
flags: Record<string, string | boolean>,
|
|
91
|
+
): Promise<CommandResult> {
|
|
92
|
+
const registryUrl = getFlag(flags, 'registry', '');
|
|
93
|
+
const client = new RegistryClient(registryUrl || undefined);
|
|
94
|
+
|
|
95
|
+
// Look up module in the sparse index
|
|
96
|
+
let entries: Awaited<ReturnType<typeof client.getIndex>>;
|
|
97
|
+
try {
|
|
98
|
+
entries = await client.getIndex(name);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
return {
|
|
101
|
+
success: false,
|
|
102
|
+
error: `Failed to reach registry: ${err instanceof Error ? err.message : String(err)}`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (entries.length === 0) {
|
|
107
|
+
return { success: false, error: `Module '${name}' not found in registry` };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const latest = client.latestVersion(entries);
|
|
111
|
+
if (!latest) {
|
|
112
|
+
return { success: false, error: `All versions of '${name}' are yanked` };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Download the .netapp
|
|
116
|
+
const tmpPath = join(tmpdir(), `${name}-${latest.vers}-${Date.now()}.netapp`);
|
|
117
|
+
try {
|
|
118
|
+
const pkgData = await client.download(name, latest.vers);
|
|
119
|
+
await Bun.write(tmpPath, pkgData);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
return {
|
|
122
|
+
success: false,
|
|
123
|
+
error: `Download failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const targetBasePath = getFlag(flags, 'target', getModuleStoragePath());
|
|
129
|
+
const resolvedTargetBasePath = resolve(targetBasePath);
|
|
130
|
+
const importFlags = { ...flags, 'skip-verify': true };
|
|
131
|
+
|
|
132
|
+
const db = getDb();
|
|
133
|
+
const result = await importModule({
|
|
134
|
+
sourcePath: tmpPath,
|
|
135
|
+
targetBasePath: resolvedTargetBasePath,
|
|
136
|
+
db,
|
|
137
|
+
flags: importFlags,
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
if (!result.success) {
|
|
141
|
+
return { success: false, error: result.error, details: result.details };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const typesPath = await generateTypesForImportedModule(result.targetPath);
|
|
145
|
+
const typesMessage = typesPath ? `\nGenerated types: ${shortenPath(typesPath)}` : '';
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
success: true,
|
|
149
|
+
message: `Imported ${result.moduleId}@${latest.vers}\nFiles: ${shortenPath(result.targetPath)}${typesMessage}`,
|
|
150
|
+
data: {
|
|
151
|
+
moduleId: result.moduleId,
|
|
152
|
+
version: latest.vers,
|
|
153
|
+
targetPath: result.targetPath,
|
|
154
|
+
typesPath: typesPath ?? undefined,
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
} finally {
|
|
158
|
+
try {
|
|
159
|
+
await unlink(tmpPath);
|
|
160
|
+
} catch {}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export { handlePublicRegistryImport };
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for handleModulePublish.
|
|
3
|
+
*
|
|
4
|
+
* Coverage:
|
|
5
|
+
* - Validation error paths (no args, no token, bad --revision)
|
|
6
|
+
* - Manifest reading failures
|
|
7
|
+
* - Revision auto-detection algorithm
|
|
8
|
+
*
|
|
9
|
+
* The full build+publish flow is covered by integration tests. Here we focus
|
|
10
|
+
* on the logic that runs before buildModule is called, which is where the
|
|
11
|
+
* most subtle bugs live (revision calculation, version assembly).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
15
|
+
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import type { IndexEntry } from '../../registry/client';
|
|
18
|
+
import { handleModulePublish, validateCapabilityVersions } from './module-publish';
|
|
19
|
+
|
|
20
|
+
const TEST_DIR = `/tmp/test-module-publish-${Date.now()}`;
|
|
21
|
+
|
|
22
|
+
beforeEach(async () => {
|
|
23
|
+
await mkdir(TEST_DIR, { recursive: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(async () => {
|
|
27
|
+
await rm(TEST_DIR, { recursive: true, force: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// ── Validation error paths ────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
describe('handleModulePublish — validation', () => {
|
|
33
|
+
test('missing module dir returns usage error', async () => {
|
|
34
|
+
const result = await handleModulePublish([], {});
|
|
35
|
+
expect(result.success).toBe(false);
|
|
36
|
+
if (!result.success) expect(result.error).toContain('Module directory required');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('missing token returns error', async () => {
|
|
40
|
+
const origToken = process.env.CELILO_PUBLISH_TOKEN;
|
|
41
|
+
process.env.CELILO_PUBLISH_TOKEN = undefined;
|
|
42
|
+
try {
|
|
43
|
+
const result = await handleModulePublish([TEST_DIR], {});
|
|
44
|
+
expect(result.success).toBe(false);
|
|
45
|
+
if (!result.success) expect(result.error).toContain('Publish token required');
|
|
46
|
+
} finally {
|
|
47
|
+
if (origToken !== undefined) process.env.CELILO_PUBLISH_TOKEN = origToken;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('--revision 0 is rejected', async () => {
|
|
52
|
+
const result = await handleModulePublish([TEST_DIR], { token: 'tok', revision: '0' });
|
|
53
|
+
expect(result.success).toBe(false);
|
|
54
|
+
if (!result.success) expect(result.error).toContain('--revision must be a positive integer');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('--revision -1 is rejected', async () => {
|
|
58
|
+
const result = await handleModulePublish([TEST_DIR], { token: 'tok', revision: '-1' });
|
|
59
|
+
expect(result.success).toBe(false);
|
|
60
|
+
if (!result.success) expect(result.error).toContain('--revision must be a positive integer');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('--revision non-integer string is rejected', async () => {
|
|
64
|
+
const result = await handleModulePublish([TEST_DIR], { token: 'tok', revision: 'abc' });
|
|
65
|
+
expect(result.success).toBe(false);
|
|
66
|
+
if (!result.success) expect(result.error).toContain('--revision must be a positive integer');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ── Manifest reading ──────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
describe('handleModulePublish — manifest reading', () => {
|
|
73
|
+
test('missing manifest.yml returns error', async () => {
|
|
74
|
+
const result = await handleModulePublish([TEST_DIR], { token: 'tok', revision: '1' });
|
|
75
|
+
expect(result.success).toBe(false);
|
|
76
|
+
if (!result.success) expect(result.error).toContain('Could not read manifest.yml');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('manifest missing id returns error', async () => {
|
|
80
|
+
await writeFile(join(TEST_DIR, 'manifest.yml'), 'version: "1.0.0"\n');
|
|
81
|
+
const result = await handleModulePublish([TEST_DIR], { token: 'tok', revision: '1' });
|
|
82
|
+
expect(result.success).toBe(false);
|
|
83
|
+
if (!result.success) expect(result.error).toContain('manifest.yml missing id or version');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('manifest missing version returns error', async () => {
|
|
87
|
+
await writeFile(join(TEST_DIR, 'manifest.yml'), 'id: my-module\n');
|
|
88
|
+
const result = await handleModulePublish([TEST_DIR], { token: 'tok', revision: '1' });
|
|
89
|
+
expect(result.success).toBe(false);
|
|
90
|
+
if (!result.success) expect(result.error).toContain('manifest.yml missing id or version');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// ── Revision auto-detection algorithm ────────────────────────────────────────
|
|
95
|
+
//
|
|
96
|
+
// This mirrors the exact logic in handleModulePublish. Tested here as a pure
|
|
97
|
+
// function so regressions are caught without needing a running registry.
|
|
98
|
+
|
|
99
|
+
function computeNextRevision(entries: IndexEntry[], baseVersion: string): number {
|
|
100
|
+
const existingRevs = entries
|
|
101
|
+
.filter((e) => e.vers.startsWith(`${baseVersion}+`))
|
|
102
|
+
.map((e) => Number(e.vers.split('+')[1]))
|
|
103
|
+
.filter((n) => Number.isInteger(n));
|
|
104
|
+
return existingRevs.length > 0 ? Math.max(...existingRevs) + 1 : 1;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function entry(vers: string): IndexEntry {
|
|
108
|
+
return { name: 'test', vers, deps: [], cksum: '', yanked: false };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
describe('revision auto-detection algorithm', () => {
|
|
112
|
+
test('no existing entries → revision 1', () => {
|
|
113
|
+
expect(computeNextRevision([], '1.0.0')).toBe(1);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('one existing entry → revision 2', () => {
|
|
117
|
+
expect(computeNextRevision([entry('1.0.0+1')], '1.0.0')).toBe(2);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('multiple entries → max + 1', () => {
|
|
121
|
+
const entries = [entry('1.0.0+1'), entry('1.0.0+3'), entry('1.0.0+2')];
|
|
122
|
+
expect(computeNextRevision(entries, '1.0.0')).toBe(4);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('entries for different base version are ignored', () => {
|
|
126
|
+
const entries = [entry('1.0.0+1'), entry('1.0.0+2'), entry('2.0.0+1')];
|
|
127
|
+
expect(computeNextRevision(entries, '2.0.0')).toBe(2);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('entries for different base version produce revision 1 for new base', () => {
|
|
131
|
+
const entries = [entry('1.0.0+1'), entry('1.0.0+2')];
|
|
132
|
+
expect(computeNextRevision(entries, '2.0.0')).toBe(1);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('non-integer revision suffix is ignored', () => {
|
|
136
|
+
const entries = [entry('1.0.0+bad'), entry('1.0.0+2')];
|
|
137
|
+
expect(computeNextRevision(entries, '1.0.0')).toBe(3);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('yanked entries still count toward revision numbering', () => {
|
|
141
|
+
// Revision numbers are global — yanked versions still occupy their slot
|
|
142
|
+
const entries = [entry('1.0.0+1'), entry('1.0.0+2')];
|
|
143
|
+
expect(computeNextRevision(entries, '1.0.0')).toBe(3);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// ── Strict-publish: capability version validation (CELILO_UPDATE D4) ──────────
|
|
148
|
+
//
|
|
149
|
+
// Manifest-claimed versions for known capabilities must match the framework's
|
|
150
|
+
// runtime registry; mismatches refuse the publish.
|
|
151
|
+
|
|
152
|
+
describe('validateCapabilityVersions', () => {
|
|
153
|
+
test('no errors when no capabilities are declared', () => {
|
|
154
|
+
expect(validateCapabilityVersions({ id: 'x', version: '1.0.0' })).toEqual([]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('no errors when provides matches the runtime registry exactly', () => {
|
|
158
|
+
expect(
|
|
159
|
+
validateCapabilityVersions({
|
|
160
|
+
id: 'caddy',
|
|
161
|
+
version: '3.0.0',
|
|
162
|
+
provides: { capabilities: [{ name: 'public_web', version: '3.0.0' }] },
|
|
163
|
+
}),
|
|
164
|
+
).toEqual([]);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('error when provides[X].version differs from runtime', () => {
|
|
168
|
+
// The runtime is set to 3.0.0 for public_web; claiming 1.0.0 is a bug.
|
|
169
|
+
const errors = validateCapabilityVersions({
|
|
170
|
+
id: 'caddy',
|
|
171
|
+
version: '1.0.0',
|
|
172
|
+
provides: { capabilities: [{ name: 'public_web', version: '1.0.0' }] },
|
|
173
|
+
});
|
|
174
|
+
expect(errors).toHaveLength(1);
|
|
175
|
+
expect(errors[0]).toContain('provides[public_web]');
|
|
176
|
+
expect(errors[0]).toContain('1.0.0');
|
|
177
|
+
expect(errors[0]).toContain('3.0.0');
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test('error when provides[X].version is newer than runtime', () => {
|
|
181
|
+
const errors = validateCapabilityVersions({
|
|
182
|
+
id: 'caddy',
|
|
183
|
+
version: '4.0.0',
|
|
184
|
+
provides: { capabilities: [{ name: 'public_web', version: '4.0.0' }] },
|
|
185
|
+
});
|
|
186
|
+
expect(errors).toHaveLength(1);
|
|
187
|
+
expect(errors[0]).toContain('provides[public_web]');
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('skips capabilities not in the framework registry (third-party)', () => {
|
|
191
|
+
expect(
|
|
192
|
+
validateCapabilityVersions({
|
|
193
|
+
id: 'odd',
|
|
194
|
+
version: '1.0.0',
|
|
195
|
+
provides: { capabilities: [{ name: 'custom_metric', version: '99.0.0' }] },
|
|
196
|
+
}),
|
|
197
|
+
).toEqual([]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('error when requires[X].version is a higher major than runtime', () => {
|
|
201
|
+
// Framework can't satisfy a requirement it doesn't know about.
|
|
202
|
+
const errors = validateCapabilityVersions({
|
|
203
|
+
id: 'lunacycle',
|
|
204
|
+
version: '1.0.0',
|
|
205
|
+
requires: { capabilities: [{ name: 'idp', version: '2.0.0' }] },
|
|
206
|
+
});
|
|
207
|
+
expect(errors).toHaveLength(1);
|
|
208
|
+
expect(errors[0]).toContain('requires[idp]');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('no error when requires[X].version is at most the runtime version', () => {
|
|
212
|
+
// dns_registrar runtime is 4.0.0; consumer requiring 4.0.0 is fine.
|
|
213
|
+
expect(
|
|
214
|
+
validateCapabilityVersions({
|
|
215
|
+
id: 'caddy',
|
|
216
|
+
version: '1.0.0',
|
|
217
|
+
requires: { capabilities: [{ name: 'dns_registrar', version: '4.0.0' }] },
|
|
218
|
+
}),
|
|
219
|
+
).toEqual([]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('reports multiple errors at once', () => {
|
|
223
|
+
const errors = validateCapabilityVersions({
|
|
224
|
+
id: 'broken',
|
|
225
|
+
version: '1.0.0',
|
|
226
|
+
provides: {
|
|
227
|
+
capabilities: [
|
|
228
|
+
{ name: 'public_web', version: '99.0.0' },
|
|
229
|
+
{ name: 'idp', version: '99.0.0' },
|
|
230
|
+
],
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
expect(errors).toHaveLength(2);
|
|
234
|
+
});
|
|
235
|
+
});
|
|
@@ -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
|
+
}
|