@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.
Files changed (161) hide show
  1. package/drizzle/0004_caddy_hostname_list.sql +25 -0
  2. package/drizzle/meta/_journal.json +14 -0
  3. package/package.json +9 -2
  4. package/src/ansible/inventory.test.ts +9 -8
  5. package/src/ansible/inventory.ts +9 -7
  6. package/src/capabilities/public-web-helpers.test.ts +2 -2
  7. package/src/capabilities/public-web-publish.test.ts +45 -12
  8. package/src/capabilities/registration.test.ts +6 -6
  9. package/src/capabilities/well-known.test.ts +2 -2
  10. package/src/capabilities/well-known.ts +5 -5
  11. package/src/cli/cli.test.ts +2 -2
  12. package/src/cli/command-registry.ts +146 -3
  13. package/src/cli/command-tree-parser.test.ts +1 -1
  14. package/src/cli/command-tree-parser.ts +9 -8
  15. package/src/cli/commands/hook-run.ts +15 -66
  16. package/src/cli/commands/module-audit.ts +14 -44
  17. package/src/cli/commands/module-deploy.ts +4 -1
  18. package/src/cli/commands/module-import-registry.test.ts +115 -0
  19. package/src/cli/commands/module-import.ts +106 -22
  20. package/src/cli/commands/module-publish.test.ts +235 -0
  21. package/src/cli/commands/module-publish.ts +234 -0
  22. package/src/cli/commands/module-remove.ts +82 -2
  23. package/src/cli/commands/module-search.ts +57 -0
  24. package/src/cli/commands/module-secret-get.ts +59 -0
  25. package/src/cli/commands/module-show.ts +1 -1
  26. package/src/cli/commands/module-terraform-unlock.ts +57 -0
  27. package/src/cli/commands/module-verify.test.ts +59 -0
  28. package/src/cli/commands/module-verify.ts +53 -0
  29. package/src/cli/commands/status.ts +30 -20
  30. package/src/cli/commands/system-audit.test.ts +138 -0
  31. package/src/cli/commands/system-audit.ts +571 -0
  32. package/src/cli/commands/system-update.ts +391 -0
  33. package/src/cli/completion.ts +15 -1
  34. package/src/cli/fuel-gauge.ts +68 -3
  35. package/src/cli/generate-zsh-completion.ts +13 -3
  36. package/src/cli/index.ts +112 -5
  37. package/src/cli/parser.ts +11 -0
  38. package/src/cli/prompts.ts +36 -5
  39. package/src/cli/tui/audit-state.test.ts +246 -0
  40. package/src/cli/tui/audit-state.ts +525 -0
  41. package/src/cli/tui/audit-tui.test.tsx +135 -0
  42. package/src/cli/tui/audit-tui.tsx +624 -0
  43. package/src/cli/tui/celebration.tsx +29 -0
  44. package/src/cli/tui/clipboard.test.ts +94 -0
  45. package/src/cli/tui/clipboard.ts +101 -0
  46. package/src/cli/tui/icons.ts +22 -0
  47. package/src/cli/tui/keybar.tsx +65 -0
  48. package/src/cli/tui/keymap.test.ts +105 -0
  49. package/src/cli/tui/keymap.ts +70 -0
  50. package/src/cli/tui/modals/analyzing.tsx +75 -0
  51. package/src/cli/tui/modals/celebration.tsx +44 -0
  52. package/src/cli/tui/modals/reaudit-prompt.tsx +35 -0
  53. package/src/cli/tui/modals/remediate.tsx +44 -0
  54. package/src/cli/tui/modals.test.ts +137 -0
  55. package/src/cli/tui/mouse.test.ts +78 -0
  56. package/src/cli/tui/mouse.ts +114 -0
  57. package/src/cli/tui/panes/categories.tsx +62 -0
  58. package/src/cli/tui/panes/command-log.tsx +87 -0
  59. package/src/cli/tui/panes/detail.tsx +175 -0
  60. package/src/cli/tui/panes/findings.tsx +97 -0
  61. package/src/cli/tui/panes/summary.tsx +64 -0
  62. package/src/cli/tui/spawn.ts +130 -0
  63. package/src/cli/tui/theme.ts +42 -0
  64. package/src/cli/tui/wrap.test.ts +43 -0
  65. package/src/cli/tui/wrap.ts +45 -0
  66. package/src/cli/types.ts +5 -0
  67. package/src/db/client.ts +55 -2
  68. package/src/db/schema.test.ts +3 -3
  69. package/src/db/schema.ts +26 -17
  70. package/src/hooks/capability-loader.ts +135 -72
  71. package/src/hooks/define-hook.test.ts +11 -3
  72. package/src/hooks/executor.ts +22 -1
  73. package/src/hooks/load-hook-config.test.ts +165 -0
  74. package/src/hooks/load-hook-config.ts +60 -0
  75. package/src/hooks/logger.ts +42 -12
  76. package/src/hooks/run-named-hook.ts +128 -0
  77. package/src/hooks/types.ts +19 -0
  78. package/src/manifest/ensure-schema.test.ts +115 -0
  79. package/src/manifest/schema.ts +76 -0
  80. package/src/manifest/template-validator.test.ts +1 -1
  81. package/src/manifest/template-validator.ts +1 -1
  82. package/src/manifest/validate.test.ts +1 -1
  83. package/src/module/import.ts +20 -12
  84. package/src/module/packaging/build.ts +121 -25
  85. package/src/module/packaging/release-metadata.test.ts +103 -0
  86. package/src/module/packaging/release-metadata.ts +145 -0
  87. package/src/registry/client.test.ts +228 -0
  88. package/src/registry/client.ts +157 -0
  89. package/src/services/audit/backups.test.ts +233 -0
  90. package/src/services/audit/backups.ts +128 -0
  91. package/src/services/audit/capability-abi.test.ts +153 -0
  92. package/src/services/audit/capability-abi.ts +204 -0
  93. package/src/services/audit/cli-version.test.ts +60 -0
  94. package/src/services/audit/cli-version.ts +87 -0
  95. package/src/services/audit/health.test.ts +84 -0
  96. package/src/services/audit/health.ts +43 -0
  97. package/src/services/audit/index.test.ts +99 -0
  98. package/src/services/audit/index.ts +118 -0
  99. package/src/services/audit/machines-reachable.test.ts +87 -0
  100. package/src/services/audit/machines-reachable.ts +87 -0
  101. package/src/services/audit/module-configs.test.ts +131 -0
  102. package/src/services/audit/module-configs.ts +80 -0
  103. package/src/services/audit/module-versions.test.ts +99 -0
  104. package/src/services/audit/module-versions.ts +154 -0
  105. package/src/services/audit/schema.test.ts +68 -0
  106. package/src/services/audit/schema.ts +115 -0
  107. package/src/services/audit/secrets-decryptable.test.ts +82 -0
  108. package/src/services/audit/secrets-decryptable.ts +97 -0
  109. package/src/services/audit/services-credentials.test.ts +54 -0
  110. package/src/services/audit/services-credentials.ts +64 -0
  111. package/src/services/audit/services-reachable.test.ts +60 -0
  112. package/src/services/audit/services-reachable.ts +64 -0
  113. package/src/services/audit/terraform-plan.test.ts +127 -0
  114. package/src/services/audit/terraform-plan.ts +153 -0
  115. package/src/services/audit/types.test.ts +36 -0
  116. package/src/services/audit/types.ts +90 -0
  117. package/src/services/audit/unconfigured-modules.test.ts +48 -0
  118. package/src/services/audit/unconfigured-modules.ts +71 -0
  119. package/src/services/audit/undeployed-modules.test.ts +66 -0
  120. package/src/services/audit/undeployed-modules.ts +72 -0
  121. package/src/services/build-stream.ts +122 -122
  122. package/src/services/config-interview.ts +407 -2
  123. package/src/services/deploy-ansible.ts +73 -7
  124. package/src/services/deploy-planner.ts +5 -5
  125. package/src/services/deploy-preflight.ts +45 -4
  126. package/src/services/deploy-terraform.ts +31 -24
  127. package/src/services/deploy-validation.ts +167 -23
  128. package/src/services/dns-auto-register.ts +4 -4
  129. package/src/services/ensure-interview.test.ts +245 -0
  130. package/src/services/health-runner.ts +110 -38
  131. package/src/services/infrastructure-variable-resolver.test.ts +1 -1
  132. package/src/services/infrastructure-variable-resolver.ts +3 -3
  133. package/src/services/module-build.ts +11 -13
  134. package/src/services/module-deploy.ts +372 -61
  135. package/src/services/proxmox-state-recovery.ts +6 -6
  136. package/src/services/ssh-key-manager.test.ts +1 -1
  137. package/src/services/ssh-key-manager.ts +3 -2
  138. package/src/services/terraform-env.ts +62 -0
  139. package/src/services/update/dep-graph.test.ts +214 -0
  140. package/src/services/update/dep-graph.ts +215 -0
  141. package/src/services/update/orchestrator.test.ts +463 -0
  142. package/src/services/update/orchestrator.ts +359 -0
  143. package/src/services/update/progress.ts +49 -0
  144. package/src/services/update/self-update.test.ts +68 -0
  145. package/src/services/update/self-update.ts +57 -0
  146. package/src/services/update/types.ts +94 -0
  147. package/src/templates/generator.test.ts +3 -3
  148. package/src/templates/generator.ts +43 -2
  149. package/src/test-utils/completion-harness.test.ts +1 -1
  150. package/src/test-utils/completion-harness.ts +4 -4
  151. package/src/variables/capability-self-ref.test.ts +203 -0
  152. package/src/variables/context.test.ts +31 -31
  153. package/src/variables/context.ts +65 -17
  154. package/src/variables/declarative-derivation.test.ts +306 -0
  155. package/src/variables/declarative-derivation.ts +4 -2
  156. package/src/variables/parser.test.ts +64 -9
  157. package/src/variables/parser.ts +47 -6
  158. package/src/variables/resolver.test.ts +14 -14
  159. package/src/variables/resolver.ts +27 -9
  160. package/src/variables/types.ts +1 -1
  161. package/tsconfig.json +1 -0
@@ -2,42 +2,51 @@
2
2
  * Module import command
3
3
  */
4
4
 
5
- import { resolve } from 'node:path';
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 { getArg, getFlag, validateRequiredArgs } from '../parser';
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
- * @param args - Command arguments
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
- // Validate arguments
27
- const error = validateRequiredArgs(args, 1);
28
- if (error) {
29
- return {
30
- success: false,
31
- error: `${error}\n\nUsage: celilo module import <path> [--target <path>]`,
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
- const sourcePath = getArg(args, 0);
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
+ }