@celilo/cli 0.1.5 → 0.1.7

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 (145) 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 +3 -2
  5. package/src/ansible/inventory.ts +5 -1
  6. package/src/capabilities/public-web-helpers.test.ts +2 -2
  7. package/src/capabilities/public-web-publish.test.ts +34 -1
  8. package/src/cli/cli.test.ts +2 -2
  9. package/src/cli/command-registry.ts +146 -3
  10. package/src/cli/command-tree-parser.test.ts +1 -1
  11. package/src/cli/command-tree-parser.ts +9 -8
  12. package/src/cli/commands/hook-run.ts +15 -66
  13. package/src/cli/commands/module-audit.ts +14 -44
  14. package/src/cli/commands/module-deploy.ts +4 -1
  15. package/src/cli/commands/module-import-registry.test.ts +115 -0
  16. package/src/cli/commands/module-import.ts +106 -22
  17. package/src/cli/commands/module-publish.test.ts +235 -0
  18. package/src/cli/commands/module-publish.ts +234 -0
  19. package/src/cli/commands/module-remove.ts +82 -2
  20. package/src/cli/commands/module-search.ts +57 -0
  21. package/src/cli/commands/module-secret-get.ts +59 -0
  22. package/src/cli/commands/module-terraform-unlock.ts +57 -0
  23. package/src/cli/commands/module-verify.test.ts +59 -0
  24. package/src/cli/commands/module-verify.ts +53 -0
  25. package/src/cli/commands/status.ts +30 -20
  26. package/src/cli/commands/system-audit.test.ts +138 -0
  27. package/src/cli/commands/system-audit.ts +571 -0
  28. package/src/cli/commands/system-update.ts +391 -0
  29. package/src/cli/completion.ts +15 -1
  30. package/src/cli/fuel-gauge.ts +68 -3
  31. package/src/cli/generate-zsh-completion.ts +13 -3
  32. package/src/cli/index.ts +112 -5
  33. package/src/cli/parser.ts +11 -0
  34. package/src/cli/prompts.ts +36 -5
  35. package/src/cli/tui/audit-state.test.ts +246 -0
  36. package/src/cli/tui/audit-state.ts +525 -0
  37. package/src/cli/tui/audit-tui.test.tsx +135 -0
  38. package/src/cli/tui/audit-tui.tsx +624 -0
  39. package/src/cli/tui/celebration.tsx +29 -0
  40. package/src/cli/tui/clipboard.test.ts +94 -0
  41. package/src/cli/tui/clipboard.ts +101 -0
  42. package/src/cli/tui/icons.ts +22 -0
  43. package/src/cli/tui/keybar.tsx +65 -0
  44. package/src/cli/tui/keymap.test.ts +105 -0
  45. package/src/cli/tui/keymap.ts +70 -0
  46. package/src/cli/tui/modals/analyzing.tsx +75 -0
  47. package/src/cli/tui/modals/celebration.tsx +44 -0
  48. package/src/cli/tui/modals/reaudit-prompt.tsx +35 -0
  49. package/src/cli/tui/modals/remediate.tsx +44 -0
  50. package/src/cli/tui/modals.test.ts +137 -0
  51. package/src/cli/tui/mouse.test.ts +78 -0
  52. package/src/cli/tui/mouse.ts +114 -0
  53. package/src/cli/tui/panes/categories.tsx +62 -0
  54. package/src/cli/tui/panes/command-log.tsx +87 -0
  55. package/src/cli/tui/panes/detail.tsx +175 -0
  56. package/src/cli/tui/panes/findings.tsx +97 -0
  57. package/src/cli/tui/panes/summary.tsx +64 -0
  58. package/src/cli/tui/spawn.ts +130 -0
  59. package/src/cli/tui/theme.ts +42 -0
  60. package/src/cli/tui/wrap.test.ts +43 -0
  61. package/src/cli/tui/wrap.ts +45 -0
  62. package/src/cli/types.ts +5 -0
  63. package/src/db/client.ts +55 -2
  64. package/src/db/schema.ts +26 -17
  65. package/src/hooks/capability-loader.ts +133 -73
  66. package/src/hooks/define-hook.test.ts +9 -1
  67. package/src/hooks/executor.ts +22 -1
  68. package/src/hooks/load-hook-config.test.ts +165 -0
  69. package/src/hooks/load-hook-config.ts +60 -0
  70. package/src/hooks/logger.ts +42 -12
  71. package/src/hooks/run-named-hook.ts +128 -0
  72. package/src/hooks/types.ts +19 -0
  73. package/src/manifest/ensure-schema.test.ts +115 -0
  74. package/src/manifest/schema.ts +76 -0
  75. package/src/module/import.ts +20 -12
  76. package/src/module/packaging/build.ts +85 -16
  77. package/src/module/packaging/release-metadata.test.ts +103 -0
  78. package/src/module/packaging/release-metadata.ts +145 -0
  79. package/src/registry/client.test.ts +228 -0
  80. package/src/registry/client.ts +157 -0
  81. package/src/services/audit/backups.test.ts +233 -0
  82. package/src/services/audit/backups.ts +128 -0
  83. package/src/services/audit/capability-abi.test.ts +153 -0
  84. package/src/services/audit/capability-abi.ts +204 -0
  85. package/src/services/audit/cli-version.test.ts +60 -0
  86. package/src/services/audit/cli-version.ts +87 -0
  87. package/src/services/audit/health.test.ts +84 -0
  88. package/src/services/audit/health.ts +43 -0
  89. package/src/services/audit/index.test.ts +99 -0
  90. package/src/services/audit/index.ts +118 -0
  91. package/src/services/audit/machines-reachable.test.ts +87 -0
  92. package/src/services/audit/machines-reachable.ts +87 -0
  93. package/src/services/audit/module-configs.test.ts +131 -0
  94. package/src/services/audit/module-configs.ts +80 -0
  95. package/src/services/audit/module-versions.test.ts +99 -0
  96. package/src/services/audit/module-versions.ts +154 -0
  97. package/src/services/audit/schema.test.ts +68 -0
  98. package/src/services/audit/schema.ts +115 -0
  99. package/src/services/audit/secrets-decryptable.test.ts +82 -0
  100. package/src/services/audit/secrets-decryptable.ts +97 -0
  101. package/src/services/audit/services-credentials.test.ts +54 -0
  102. package/src/services/audit/services-credentials.ts +64 -0
  103. package/src/services/audit/services-reachable.test.ts +60 -0
  104. package/src/services/audit/services-reachable.ts +64 -0
  105. package/src/services/audit/terraform-plan.test.ts +127 -0
  106. package/src/services/audit/terraform-plan.ts +153 -0
  107. package/src/services/audit/types.test.ts +36 -0
  108. package/src/services/audit/types.ts +90 -0
  109. package/src/services/audit/unconfigured-modules.test.ts +48 -0
  110. package/src/services/audit/unconfigured-modules.ts +71 -0
  111. package/src/services/audit/undeployed-modules.test.ts +66 -0
  112. package/src/services/audit/undeployed-modules.ts +72 -0
  113. package/src/services/build-stream.ts +122 -122
  114. package/src/services/config-interview.ts +407 -2
  115. package/src/services/deploy-ansible.ts +73 -7
  116. package/src/services/deploy-preflight.ts +45 -4
  117. package/src/services/deploy-terraform.ts +31 -24
  118. package/src/services/deploy-validation.ts +167 -23
  119. package/src/services/ensure-interview.test.ts +245 -0
  120. package/src/services/health-runner.ts +110 -38
  121. package/src/services/module-build.ts +11 -13
  122. package/src/services/module-deploy.ts +370 -59
  123. package/src/services/ssh-key-manager.test.ts +1 -1
  124. package/src/services/ssh-key-manager.ts +3 -2
  125. package/src/services/terraform-env.ts +62 -0
  126. package/src/services/update/dep-graph.test.ts +214 -0
  127. package/src/services/update/dep-graph.ts +215 -0
  128. package/src/services/update/orchestrator.test.ts +463 -0
  129. package/src/services/update/orchestrator.ts +359 -0
  130. package/src/services/update/progress.ts +49 -0
  131. package/src/services/update/self-update.test.ts +68 -0
  132. package/src/services/update/self-update.ts +57 -0
  133. package/src/services/update/types.ts +94 -0
  134. package/src/templates/generator.test.ts +1 -1
  135. package/src/templates/generator.ts +42 -1
  136. package/src/test-utils/completion-harness.test.ts +1 -1
  137. package/src/test-utils/completion-harness.ts +4 -4
  138. package/src/variables/capability-self-ref.test.ts +203 -0
  139. package/src/variables/context.ts +49 -1
  140. package/src/variables/declarative-derivation.test.ts +306 -0
  141. package/src/variables/declarative-derivation.ts +4 -2
  142. package/src/variables/parser.test.ts +56 -1
  143. package/src/variables/parser.ts +47 -6
  144. package/src/variables/resolver.ts +27 -9
  145. package/tsconfig.json +1 -0
@@ -1,51 +1,21 @@
1
- import { auditModule } from '../../module/packaging/audit';
2
- import type { CommandResult } from '../types';
3
-
4
1
  /**
5
- * Audit module integrity
2
+ * Deprecation alias: `module audit` → `module verify`.
6
3
  *
7
- * Usage: celilo module audit <module-id>
4
+ * Per CELILO_UPDATE D11, `audit` is now reserved for system-level
5
+ * drift detection (`celilo system audit`). The integrity check that
6
+ * used to live at `module audit` was renamed to `module verify`.
8
7
  *
9
- * Returns a CommandResult so the dispatcher controls process exit
10
- * behavior. An earlier implementation called `process.exit()` directly,
11
- * which killed the persistent CLI process used by `CLIContext` in
12
- * integration tests.
8
+ * This shim emits a one-line warning to stderr and delegates to
9
+ * `moduleVerify` so existing scripts and tests don't break overnight.
10
+ * Remove after one release cycle.
13
11
  */
14
- export async function moduleAudit(args: string[]): Promise<CommandResult> {
15
- if (args.length === 0) {
16
- return {
17
- success: false,
18
- error: 'Module ID is required\n\nUsage: celilo module audit <module-id>',
19
- };
20
- }
21
-
22
- const moduleId = args[0];
23
-
24
- const result = await auditModule(moduleId);
25
12
 
26
- if (result.error) {
27
- return { success: false, error: result.error };
28
- }
29
-
30
- if (result.success) {
31
- return {
32
- success: true,
33
- message: `Module '${moduleId}' passed integrity check\n No violations found.`,
34
- };
35
- }
36
-
37
- // Build a multi-line failure message that includes every violation.
38
- const violationLines = result.violations.map((v) => {
39
- const icon = v.type === 'missing' ? '⚠' : v.type === 'modified' ? '✗' : '!';
40
- return ` ${icon} [${v.type.toUpperCase()}] ${v.message}`;
41
- });
13
+ import type { CommandResult } from '../types';
14
+ import { moduleVerify } from './module-verify';
42
15
 
43
- return {
44
- success: false,
45
- error: [
46
- `Module '${moduleId}' failed integrity check`,
47
- ` Found ${result.violations.length} violation(s):`,
48
- ...violationLines,
49
- ].join('\n'),
50
- };
16
+ export async function moduleAudit(args: string[]): Promise<CommandResult> {
17
+ process.stderr.write(
18
+ 'warning: `celilo module audit` is deprecated; use `celilo module verify` instead.\n',
19
+ );
20
+ return moduleVerify(args);
51
21
  }
@@ -63,9 +63,12 @@ export async function handleModuleDeploy(
63
63
  };
64
64
  }
65
65
 
66
+ // Success message is emitted by deployModule via the active ProgressDisplay,
67
+ // so we return an empty message to avoid the top-level clack outro printing
68
+ // a duplicate line in a different style.
66
69
  return {
67
70
  success: true,
68
- message: `✓ Module '${moduleId}' deployed successfully`,
71
+ message: '',
69
72
  data: result.phases,
70
73
  };
71
74
  }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Tests for handlePublicRegistryImport (sparse-protocol registry path).
3
+ *
4
+ * We spy on RegistryClient.prototype methods to control network behavior
5
+ * without making real HTTP calls. The importModule call (DB + filesystem)
6
+ * is exercised by integration tests; here we cover the registry-layer logic:
7
+ * - 404 / unreachable registry
8
+ * - Module not in index
9
+ * - All versions yanked
10
+ * - Correct version is picked (latest non-yanked)
11
+ */
12
+
13
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
14
+ import type { Mock } from 'bun:test';
15
+ import { mkdtempSync, rmSync } from 'node:fs';
16
+ import { tmpdir } from 'node:os';
17
+ import { join } from 'node:path';
18
+ import type { IndexEntry } from '../../registry/client';
19
+ import { RegistryClient } from '../../registry/client';
20
+ import { handlePublicRegistryImport } from './module-import';
21
+
22
+ // handlePublicRegistryImport is not exported by default — re-exported at end of module-import.ts
23
+ // If the import fails, check that `export { handlePublicRegistryImport }` exists there.
24
+
25
+ function entry(vers: string, yanked = false): IndexEntry {
26
+ return { name: 'homebridge', vers, deps: [], cksum: 'abc', yanked };
27
+ }
28
+
29
+ let getIndexSpy: Mock<(name: string) => Promise<IndexEntry[]>>;
30
+ let downloadSpy: Mock<(name: string, vers: string) => Promise<ArrayBuffer>>;
31
+ let tempDir: string;
32
+
33
+ beforeEach(() => {
34
+ tempDir = mkdtempSync(join(tmpdir(), 'celilo-import-test-'));
35
+ process.env.CELILO_DB_PATH = join(tempDir, 'test.db');
36
+ process.env.CELILO_DATA_DIR = tempDir;
37
+ getIndexSpy = spyOn(RegistryClient.prototype, 'getIndex').mockResolvedValue([]);
38
+ downloadSpy = spyOn(RegistryClient.prototype, 'download').mockResolvedValue(new ArrayBuffer(0));
39
+ });
40
+
41
+ afterEach(() => {
42
+ getIndexSpy.mockRestore();
43
+ downloadSpy.mockRestore();
44
+ rmSync(tempDir, { recursive: true, force: true });
45
+ process.env.CELILO_DB_PATH = undefined;
46
+ process.env.CELILO_DATA_DIR = undefined;
47
+ });
48
+
49
+ describe('handlePublicRegistryImport — registry lookup', () => {
50
+ test('registry error returns failure', async () => {
51
+ getIndexSpy.mockRejectedValue(new Error('connection refused'));
52
+ const result = await handlePublicRegistryImport('homebridge', {});
53
+ expect(result.success).toBe(false);
54
+ if (!result.success) {
55
+ expect(result.error).toContain('Failed to reach registry');
56
+ expect(result.error).toContain('connection refused');
57
+ }
58
+ });
59
+
60
+ test('empty index (404) returns not-found error', async () => {
61
+ getIndexSpy.mockResolvedValue([]);
62
+ const result = await handlePublicRegistryImport('homebridge', {});
63
+ expect(result.success).toBe(false);
64
+ if (!result.success) expect(result.error).toContain("'homebridge' not found in registry");
65
+ });
66
+
67
+ test('all versions yanked returns yanked error', async () => {
68
+ getIndexSpy.mockResolvedValue([entry('1.0.0+1', true), entry('1.0.0+2', true)]);
69
+ const result = await handlePublicRegistryImport('homebridge', {});
70
+ expect(result.success).toBe(false);
71
+ if (!result.success) expect(result.error).toContain('yanked');
72
+ });
73
+
74
+ test('calls getIndex with the correct module name', async () => {
75
+ await handlePublicRegistryImport('caddy', {});
76
+ expect(getIndexSpy).toHaveBeenCalledWith('caddy');
77
+ });
78
+
79
+ test('constructs RegistryClient with --registry flag when provided', async () => {
80
+ // Spy on the constructor to capture the URL it receives
81
+ const constructorSpy = spyOn(RegistryClient.prototype, 'getIndex').mockResolvedValue([]);
82
+ await handlePublicRegistryImport('homebridge', { registry: 'https://custom.example.com' });
83
+ // The getIndex call means a RegistryClient was constructed — verify via baseUrl check
84
+ // (we can't easily spy on the constructor itself, but the URL is visible in download calls)
85
+ constructorSpy.mockRestore();
86
+ });
87
+ });
88
+
89
+ describe('handlePublicRegistryImport — version selection', () => {
90
+ test('downloads the latest non-yanked version', async () => {
91
+ getIndexSpy.mockResolvedValue([
92
+ entry('1.0.0+1'),
93
+ entry('1.0.0+2'),
94
+ entry('1.0.0+3', true), // yanked
95
+ ]);
96
+ // download will be called with the latest non-yanked: 1.0.0+2
97
+ // importModule will then fail (no real module dir), but we can check what download received
98
+ await handlePublicRegistryImport('homebridge', {});
99
+ if (downloadSpy.mock.calls.length > 0) {
100
+ expect(downloadSpy.mock.calls[0][1]).toBe('1.0.0+2');
101
+ }
102
+ });
103
+
104
+ test('downloads the only non-yanked version when others are yanked', async () => {
105
+ getIndexSpy.mockResolvedValue([
106
+ entry('1.0.0+1', true),
107
+ entry('1.0.0+2', true),
108
+ entry('1.0.0+3'),
109
+ ]);
110
+ await handlePublicRegistryImport('homebridge', {});
111
+ if (downloadSpy.mock.calls.length > 0) {
112
+ expect(downloadSpy.mock.calls[0][1]).toBe('1.0.0+3');
113
+ }
114
+ });
115
+ });
@@ -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
+ });