@mettlecast/domain-cli 0.2.85 → 0.2.87

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.
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Toolchain manifest types and factory for the @mettlecast publish workflow.
3
+ *
4
+ * The manifest is included in the published domain-cli tarball so that
5
+ * `mc-domain-module update-all` (or a future standalone updater) can
6
+ * determine exact package versions to install / pin.
7
+ *
8
+ * Manifest schema:
9
+ * ```json
10
+ * {
11
+ * "schemaVersion": 1,
12
+ * "registrySchemaVersion": "1",
13
+ * "packages": {
14
+ * "domainCli": "0.2.53",
15
+ * "domainCdkPacker": "0.2.53",
16
+ * "domainRuntime": "0.2.53",
17
+ * "eslintPluginDomainModule": "0.2.53"
18
+ * }
19
+ * }
20
+ * ```
21
+ *
22
+ * `registrySchemaVersion` matches the existing `DomainRegistry.schemaVersion`
23
+ * string literal `'1'` so that consumer validation can compare the manifest
24
+ * registry schema version to the generated registry schema version directly.
25
+ */
26
+ /**
27
+ * Shape of the published toolchain-manifest.json at package root.
28
+ */
29
+ export interface ToolchainManifest {
30
+ /** Manifest schema version (integer). Must be 1 for this generation. */
31
+ schemaVersion: number;
32
+ /**
33
+ * Registry schema version — a string matching the `DomainRegistry.schemaVersion`
34
+ * literal (currently `'1'`). Consumer validation compares this against the
35
+ * generated registry schema version to verify contract compatibility.
36
+ */
37
+ registrySchemaVersion: string;
38
+ packages: ToolchainPackages;
39
+ }
40
+ export interface ToolchainPackages {
41
+ domainCli: string;
42
+ domainCdkPacker: string;
43
+ domainRuntime: string;
44
+ eslintPluginDomainModule: string;
45
+ }
46
+ /**
47
+ * The expected registry schema version that matches `DomainRegistry.schemaVersion`.
48
+ * Consumer validation compares the manifest's `registrySchemaVersion` against
49
+ * this constant and the generated registry's `schemaVersion`.
50
+ */
51
+ export declare const EXPECTED_REGISTRY_SCHEMA_VERSION = "1";
52
+ /**
53
+ * Package identifiers used in the manifest.
54
+ * Maps to the `packages` key in ToolchainManifest.
55
+ */
56
+ export type ToolchainPackageId = keyof ToolchainPackages;
57
+ /**
58
+ * Create a ToolchainManifest from exact resolved versions.
59
+ * This is a pure factory function (no side-effects).
60
+ *
61
+ * @param versions - Exact semver strings for each package
62
+ * @returns A complete ToolchainManifest
63
+ */
64
+ export declare function createToolchainManifest(versions: ToolchainPackages): ToolchainManifest;
65
+ /**
66
+ * Known @mettlecast dependency names that the domain-cli may reference
67
+ * with wildcard ranges and should be replaced with exact versions.
68
+ */
69
+ export declare const METTLECAST_CLI_DEPS: Record<ToolchainPackageId, string>;
70
+ /**
71
+ * Rewrite the domain-cli package.json's @mettlecast dependencies from
72
+ * wildcard / workspace ranges to exact resolved versions.
73
+ *
74
+ * @param pkgJson - Parsed package.json content (mutated in place)
75
+ * @param versions - Exact semver versions for each toolchain package
76
+ * @returns True if any dependency was rewritten
77
+ */
78
+ export declare function rewriteCliDependencies(pkgJson: {
79
+ dependencies?: Record<string, string>;
80
+ }, versions: ToolchainPackages): boolean;
81
+ /**
82
+ * Resolve the toolchain manifest from the installed @mettlecast/domain-cli package.
83
+ * Tries the project's `node_modules/@mettlecast/domain-cli/` first, then falls
84
+ * back to the CLI's own package root (for global/npx installations).
85
+ *
86
+ * @param projectRoot - Project root directory.
87
+ * @returns The parsed toolchain manifest.
88
+ * @throws If the manifest is missing, malformed, or has an incompatible schema version.
89
+ */
90
+ export declare function loadToolchainManifest(projectRoot: string): Promise<ToolchainManifest>;
@@ -0,0 +1,144 @@
1
+ import { join, dirname } from 'node:path';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { existsSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ /**
6
+ * The expected registry schema version that matches `DomainRegistry.schemaVersion`.
7
+ * Consumer validation compares the manifest's `registrySchemaVersion` against
8
+ * this constant and the generated registry's `schemaVersion`.
9
+ */
10
+ export const EXPECTED_REGISTRY_SCHEMA_VERSION = '1';
11
+ /**
12
+ * Create a ToolchainManifest from exact resolved versions.
13
+ * This is a pure factory function (no side-effects).
14
+ *
15
+ * @param versions - Exact semver strings for each package
16
+ * @returns A complete ToolchainManifest
17
+ */
18
+ export function createToolchainManifest(versions) {
19
+ return {
20
+ schemaVersion: 1,
21
+ registrySchemaVersion: EXPECTED_REGISTRY_SCHEMA_VERSION,
22
+ packages: {
23
+ domainCli: versions.domainCli,
24
+ domainCdkPacker: versions.domainCdkPacker,
25
+ domainRuntime: versions.domainRuntime,
26
+ eslintPluginDomainModule: versions.eslintPluginDomainModule,
27
+ },
28
+ };
29
+ }
30
+ /**
31
+ * Known @mettlecast dependency names that the domain-cli may reference
32
+ * with wildcard ranges and should be replaced with exact versions.
33
+ */
34
+ export const METTLECAST_CLI_DEPS = {
35
+ domainCli: '@mettlecast/domain-cli',
36
+ domainCdkPacker: '@mettlecast/domain-cdk-packer',
37
+ domainRuntime: '@mettlecast/domain-runtime',
38
+ eslintPluginDomainModule: '@mettlecast/eslint-plugin-domain-module',
39
+ };
40
+ /**
41
+ * Rewrite the domain-cli package.json's @mettlecast dependencies from
42
+ * wildcard / workspace ranges to exact resolved versions.
43
+ *
44
+ * @param pkgJson - Parsed package.json content (mutated in place)
45
+ * @param versions - Exact semver versions for each toolchain package
46
+ * @returns True if any dependency was rewritten
47
+ */
48
+ export function rewriteCliDependencies(pkgJson, versions) {
49
+ let changed = false;
50
+ if (!pkgJson.dependencies)
51
+ return changed;
52
+ for (const [id, depName] of Object.entries(METTLECAST_CLI_DEPS)) {
53
+ const packageId = id;
54
+ if (depName in pkgJson.dependencies) {
55
+ const exact = versions[packageId];
56
+ pkgJson.dependencies[depName] = exact;
57
+ changed = true;
58
+ }
59
+ }
60
+ return changed;
61
+ }
62
+ /**
63
+ * Resolve the toolchain manifest from the installed @mettlecast/domain-cli package.
64
+ * Tries the project's `node_modules/@mettlecast/domain-cli/` first, then falls
65
+ * back to the CLI's own package root (for global/npx installations).
66
+ *
67
+ * @param projectRoot - Project root directory.
68
+ * @returns The parsed toolchain manifest.
69
+ * @throws If the manifest is missing, malformed, or has an incompatible schema version.
70
+ */
71
+ export async function loadToolchainManifest(projectRoot) {
72
+ // Primary path: project's node_modules
73
+ const projectManifestPath = join(projectRoot, 'node_modules', '@mettlecast', 'domain-cli', 'toolchain-manifest.json');
74
+ // Fallback path: CLI's own package root (resolved from this module's location)
75
+ const cliPackageRoot = resolveCliPackageRoot();
76
+ const cliManifestPath = join(cliPackageRoot, 'toolchain-manifest.json');
77
+ let manifestPath;
78
+ if (existsSync(projectManifestPath)) {
79
+ manifestPath = projectManifestPath;
80
+ }
81
+ else if (existsSync(cliManifestPath)) {
82
+ manifestPath = cliManifestPath;
83
+ }
84
+ else {
85
+ throw new Error(`toolchain-manifest.json not found at ${projectManifestPath} or ${cliManifestPath}. ` +
86
+ "Run 'npm install' or 'npm ci' in the project root first, then retry.");
87
+ }
88
+ const raw = await readFile(manifestPath, 'utf-8');
89
+ let manifest;
90
+ try {
91
+ manifest = JSON.parse(raw);
92
+ }
93
+ catch {
94
+ throw new Error(`toolchain-manifest.json at ${manifestPath} is not valid JSON.`);
95
+ }
96
+ // Validate schema version
97
+ if (typeof manifest.schemaVersion !== 'number' || manifest.schemaVersion !== 1) {
98
+ throw new Error(`toolchain manifest schemaVersion is ${String(manifest.schemaVersion)}, expected 1. ` +
99
+ 'The installed @mettlecast/domain-cli version is incompatible with this toolchain resolver.');
100
+ }
101
+ // Validate registry schema version equals expected value
102
+ if (typeof manifest.registrySchemaVersion !== 'string' ||
103
+ manifest.registrySchemaVersion !== EXPECTED_REGISTRY_SCHEMA_VERSION) {
104
+ throw new Error(`toolchain manifest registrySchemaVersion is "${String(manifest.registrySchemaVersion)}", ` +
105
+ `expected "${EXPECTED_REGISTRY_SCHEMA_VERSION}". ` +
106
+ 'The registry schema contract is incompatible with this toolchain resolver.');
107
+ }
108
+ // Validate all package keys are present and non-empty
109
+ if (!manifest.packages || typeof manifest.packages !== 'object') {
110
+ throw new Error('toolchain manifest is missing required field "packages".');
111
+ }
112
+ const TOOLCHAIN_PACKAGE_KEYS = [
113
+ 'domainCli',
114
+ 'domainCdkPacker',
115
+ 'domainRuntime',
116
+ 'eslintPluginDomainModule',
117
+ ];
118
+ for (const key of TOOLCHAIN_PACKAGE_KEYS) {
119
+ const version = manifest.packages[key];
120
+ if (typeof version !== 'string' || version.length === 0) {
121
+ throw new Error(`toolchain manifest is missing or has empty version for package "${key}".`);
122
+ }
123
+ }
124
+ return manifest;
125
+ }
126
+ /**
127
+ * Resolve the @mettlecast/domain-cli package root directory.
128
+ * Uses the location of this module file (toolchain-manifest.ts) to find
129
+ * the CLI package root, supporting both source and dist layouts.
130
+ */
131
+ function resolveCliPackageRoot() {
132
+ const thisFile = fileURLToPath(import.meta.url);
133
+ // In source: packages/domain-cli/src/utils/toolchain-manifest.ts -> up 3 levels
134
+ // In dist: packages/domain-cli/dist/utils/toolchain-manifest.js -> up 2 levels
135
+ const candidate = dirname(dirname(dirname(thisFile)));
136
+ // Check if candidate has package.json with @mettlecast/domain-cli name
137
+ const pkgJsonPath = join(candidate, 'package.json');
138
+ if (existsSync(pkgJsonPath)) {
139
+ return candidate;
140
+ }
141
+ // Fall back to dirname(dirname(thisFile)) for dist layout
142
+ const candidate2 = dirname(dirname(thisFile));
143
+ return candidate2;
144
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mettlecast/domain-cli",
3
- "version": "0.2.85",
3
+ "version": "0.2.87",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "registry": "https://registry.npmjs.org",
@@ -22,8 +22,8 @@
22
22
  "dependencies": {
23
23
  "@aws-sdk/client-s3": "^3.0.0",
24
24
  "@aws-sdk/client-sfn": "^3.0.0",
25
- "@mettlecast/domain-cdk-packer": "*",
26
- "@mettlecast/domain-runtime": "*",
25
+ "@mettlecast/domain-cdk-packer": "0.2.88",
26
+ "@mettlecast/domain-runtime": "0.2.87",
27
27
  "commander": "^12.0.0",
28
28
  "dotenv": "^16.0.0",
29
29
  "fastify": "^5.0.0",
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * generate-toolchain-manifest.mjs
5
+ *
6
+ * Generates toolchain-manifest.json in the domain-cli package root and,
7
+ * optionally, rewrites wildcard @mettlecast dependencies in the domain-cli
8
+ * package.json to exact resolved versions.
9
+ *
10
+ * Usage:
11
+ * node generate-toolchain-manifest.mjs \
12
+ * --cli-version 0.2.53 \
13
+ * --runtime-version 0.2.53 \
14
+ * --packer-version 0.2.53 \
15
+ * --eslint-version 0.2.53 \
16
+ * [--rewrite-deps]
17
+ *
18
+ * All version flags are required.
19
+ * The --rewrite-deps flag additionally rewrites the domain-cli's package.json
20
+ * so that @mettlecast/* dependency ranges are pinned to exact versions.
21
+ */
22
+
23
+ import { readFile, writeFile } from 'node:fs/promises';
24
+ import { resolve, dirname } from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+
27
+ const __dirname = dirname(fileURLToPath(import.meta.url));
28
+ const CLI_PKG_DIR = resolve(__dirname, '..');
29
+ const PROJECT_ROOT = resolve(__dirname, '..', '..', '..');
30
+
31
+ function parseArgs() {
32
+ const args = process.argv.slice(2);
33
+ const opts = {
34
+ cliVersion: '',
35
+ runtimeVersion: '',
36
+ packerVersion: '',
37
+ eslintVersion: '',
38
+ rewriteDeps: false,
39
+ };
40
+
41
+ for (let i = 0; i < args.length; i++) {
42
+ switch (args[i]) {
43
+ case '--cli-version':
44
+ opts.cliVersion = args[++i];
45
+ break;
46
+ case '--runtime-version':
47
+ opts.runtimeVersion = args[++i];
48
+ break;
49
+ case '--packer-version':
50
+ opts.packerVersion = args[++i];
51
+ break;
52
+ case '--eslint-version':
53
+ opts.eslintVersion = args[++i];
54
+ break;
55
+ case '--rewrite-deps':
56
+ opts.rewriteDeps = true;
57
+ break;
58
+ default:
59
+ console.error(`Unknown flag: ${args[i]}`);
60
+ process.exit(1);
61
+ }
62
+ }
63
+
64
+ if (!opts.cliVersion || !opts.runtimeVersion || !opts.packerVersion || !opts.eslintVersion) {
65
+ console.error(
66
+ 'Missing required flags: --cli-version, --runtime-version, --packer-version, --eslint-version',
67
+ );
68
+ process.exit(1);
69
+ }
70
+
71
+ return opts;
72
+ }
73
+
74
+ /**
75
+ * Create a ToolchainManifest object.
76
+ */
77
+ function createToolchainManifest(versions) {
78
+ return {
79
+ schemaVersion: 1,
80
+ registrySchemaVersion: '1',
81
+ packages: {
82
+ domainCli: versions.cliVersion,
83
+ domainCdkPacker: versions.packerVersion,
84
+ domainRuntime: versions.runtimeVersion,
85
+ eslintPluginDomainModule: versions.eslintVersion,
86
+ },
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Rewrite @mettlecast dependencies in the given package.json to exact versions.
92
+ * Returns true if any dep was modified.
93
+ */
94
+ function rewriteCliDependencies(pkgJson, versions) {
95
+ const depMap = {
96
+ '@mettlecast/domain-cli': versions.cliVersion,
97
+ '@mettlecast/domain-cdk-packer': versions.packerVersion,
98
+ '@mettlecast/domain-runtime': versions.runtimeVersion,
99
+ '@mettlecast/eslint-plugin-domain-module': versions.eslintVersion,
100
+ };
101
+
102
+ let changed = false;
103
+ if (!pkgJson.dependencies) return changed;
104
+
105
+ for (const [depName, exactVersion] of Object.entries(depMap)) {
106
+ if (depName in pkgJson.dependencies) {
107
+ pkgJson.dependencies[depName] = exactVersion;
108
+ changed = true;
109
+ }
110
+ }
111
+
112
+ return changed;
113
+ }
114
+
115
+ async function main() {
116
+ const opts = parseArgs();
117
+
118
+ // 1. Generate toolchain-manifest.json
119
+ const manifest = createToolchainManifest({
120
+ cliVersion: opts.cliVersion,
121
+ runtimeVersion: opts.runtimeVersion,
122
+ packerVersion: opts.packerVersion,
123
+ eslintVersion: opts.eslintVersion,
124
+ });
125
+
126
+ const manifestPath = resolve(CLI_PKG_DIR, 'toolchain-manifest.json');
127
+ await writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
128
+ console.log(`Generated toolchain-manifest.json → ${manifestPath}`);
129
+
130
+ // 2. Optionally rewrite domain-cli package.json deps
131
+ if (opts.rewriteDeps) {
132
+ const pkgJsonPath = resolve(CLI_PKG_DIR, 'package.json');
133
+ const rawPkg = await readFile(pkgJsonPath, 'utf-8');
134
+ const pkgJson = JSON.parse(rawPkg);
135
+
136
+ const changed = rewriteCliDependencies(pkgJson, {
137
+ cliVersion: opts.cliVersion,
138
+ runtimeVersion: opts.runtimeVersion,
139
+ packerVersion: opts.packerVersion,
140
+ eslintVersion: opts.eslintVersion,
141
+ });
142
+
143
+ if (changed) {
144
+ await writeFile(pkgJsonPath, JSON.stringify(pkgJson, null, 2) + '\n', 'utf-8');
145
+ console.log(`Rewrote @mettlecast deps in package.json → ${pkgJsonPath}`);
146
+ } else {
147
+ console.log('No @mettlecast deps to rewrite in package.json');
148
+ }
149
+ }
150
+
151
+ // For CI consumption, print version summary
152
+ console.log(`\n--- toolchain versions ---`);
153
+ console.log(`domain-runtime: ${opts.runtimeVersion}`);
154
+ console.log(`domain-cdk-packer: ${opts.packerVersion}`);
155
+ console.log(`eslint-plugin-domain-module: ${opts.eslintVersion}`);
156
+ console.log(`domain-cli: ${opts.cliVersion}`);
157
+ console.log(`rewrite-deps: ${opts.rewriteDeps}`);
158
+ }
159
+
160
+ main().catch((err) => {
161
+ console.error(err);
162
+ process.exit(1);
163
+ });
@@ -53,9 +53,6 @@ describe('buildRegistry', () => {
53
53
  });
54
54
 
55
55
  it('builds a registry with domain + one API-exposed action', async () => {
56
- // Issue #4689: defineApi was removed; the canonical HTTP endpoint
57
- // surface is `defineAction({ exposure: { type: 'api', ... } })`.
58
- // The action is recorded under `registry.actions` with API exposure metadata.
59
56
  mockWalkDomainDir.mockResolvedValue({
60
57
  domain: '/fake/domains/billing/domain.config.ts',
61
58
  webhooks: [],
@@ -162,4 +159,129 @@ describe('buildRegistry', () => {
162
159
  expect(warnings[0]).toContain('tsx error');
163
160
  expect(registry.actions).toHaveLength(0);
164
161
  });
162
+
163
+ /**
164
+ * Strict contract (#5090): missing exposure causes build failure
165
+ */
166
+ it('throws when action has no exposure (#5090)', async () => {
167
+ mockWalkDomainDir.mockResolvedValue({
168
+ domain: '/fake/domains/billing/domain.config.ts',
169
+ webhooks: [],
170
+ subscribers: [],
171
+ actions: ['/fake/domains/billing/actions/no-exposure.ts'],
172
+ schedules: [],
173
+ jobs: [],
174
+ integrations: [],
175
+ publishes: undefined,
176
+ });
177
+
178
+ mockLoadModuleExports
179
+ .mockResolvedValueOnce([
180
+ { _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
181
+ ])
182
+ .mockResolvedValueOnce([
183
+ {
184
+ _kind: 'action',
185
+ _exportName: 'noExposure',
186
+ id: 'no-exposure',
187
+ backendAccess: 'private',
188
+ // exposure deliberately omitted
189
+ idempotent: false,
190
+ },
191
+ ]);
192
+
193
+ await expect(buildRegistry(DOMAIN_ROOT)).rejects.toThrow(/exposure/i);
194
+ });
195
+
196
+ it('throws when action has missing backendAccess (#5090)', async () => {
197
+ mockWalkDomainDir.mockResolvedValue({
198
+ domain: '/fake/domains/billing/domain.config.ts',
199
+ webhooks: [],
200
+ subscribers: [],
201
+ actions: ['/fake/domains/billing/actions/no-backend.ts'],
202
+ schedules: [],
203
+ jobs: [],
204
+ integrations: [],
205
+ publishes: undefined,
206
+ });
207
+
208
+ mockLoadModuleExports
209
+ .mockResolvedValueOnce([
210
+ { _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
211
+ ])
212
+ .mockResolvedValueOnce([
213
+ {
214
+ _kind: 'action',
215
+ _exportName: 'noBackend',
216
+ id: 'no-backend',
217
+ // backendAccess omitted
218
+ exposure: { type: 'internal' },
219
+ idempotent: false,
220
+ },
221
+ ]);
222
+
223
+ await expect(buildRegistry(DOMAIN_ROOT)).rejects.toThrow(/backendAccess/i);
224
+ });
225
+
226
+ it('throws when action has missing idempotent (#5090)', async () => {
227
+ mockWalkDomainDir.mockResolvedValue({
228
+ domain: '/fake/domains/billing/domain.config.ts',
229
+ webhooks: [],
230
+ subscribers: [],
231
+ actions: ['/fake/domains/billing/actions/no-idempotent.ts'],
232
+ schedules: [],
233
+ jobs: [],
234
+ integrations: [],
235
+ publishes: undefined,
236
+ });
237
+
238
+ mockLoadModuleExports
239
+ .mockResolvedValueOnce([
240
+ { _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
241
+ ])
242
+ .mockResolvedValueOnce([
243
+ {
244
+ _kind: 'action',
245
+ _exportName: 'noIdempotent',
246
+ id: 'no-idempotent',
247
+ backendAccess: 'private',
248
+ exposure: { type: 'internal' },
249
+ // idempotent omitted
250
+ },
251
+ ]);
252
+
253
+ await expect(buildRegistry(DOMAIN_ROOT)).rejects.toThrow(/idempotent/i);
254
+ });
255
+
256
+ it('accepts a valid internal action with exposure explicitly declared (#5090)', async () => {
257
+ mockWalkDomainDir.mockResolvedValue({
258
+ domain: '/fake/domains/billing/domain.config.ts',
259
+ webhooks: [],
260
+ subscribers: [],
261
+ actions: ['/fake/domains/billing/actions/internal.ts'],
262
+ schedules: [],
263
+ jobs: [],
264
+ integrations: [],
265
+ publishes: undefined,
266
+ });
267
+
268
+ mockLoadModuleExports
269
+ .mockResolvedValueOnce([
270
+ { _kind: 'domain', _exportName: 'default', id: 'billing', name: 'Billing', tenancy: 'required' },
271
+ ])
272
+ .mockResolvedValueOnce([
273
+ {
274
+ _kind: 'action',
275
+ _exportName: 'internalOnly',
276
+ id: 'internal-only',
277
+ backendAccess: 'private',
278
+ exposure: { type: 'internal' },
279
+ idempotent: false,
280
+ },
281
+ ]);
282
+
283
+ const { registry } = await buildRegistry(DOMAIN_ROOT);
284
+ expect(registry.actions).toHaveLength(1);
285
+ expect(registry.actions[0]!.exposure).toEqual({ type: 'internal' });
286
+ });
165
287
  });
@@ -0,0 +1,89 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { writeFile, readFile, mkdir } from 'node:fs/promises';
3
+ import { createHash } from 'node:crypto';
4
+ import { existsSync, readFileSync } from 'node:fs';
5
+ import { join, resolve } from 'node:path';
6
+ import { tmpdir } from 'node:os';
7
+ import { rmSync } from 'node:fs';
8
+ import { runBuildCatalog } from '../../commands/build-catalog.js';
9
+
10
+ describe('build-catalog', () => {
11
+ let tempDir: string;
12
+
13
+ beforeEach(async () => {
14
+ tempDir = join(tmpdir(), `tib-build-catalog-${Math.random().toString(36).slice(2)}`);
15
+ await mkdir(tempDir, { recursive: true });
16
+ });
17
+
18
+ afterEach(() => {
19
+ rmSync(tempDir, { recursive: true, force: true });
20
+ });
21
+
22
+ /**
23
+ * Regression test for #5087.
24
+ * When registryDir is explicitly given, the catalog output must be written
25
+ * to that directory (resolved) — NOT to process.cwd()/.mc.
26
+ * This ensures --mc-dir controls both read and write locations.
27
+ */
28
+ it('writes catalog to the resolved registryDir, not to cwd/.mc (#5087)', async () => {
29
+ // Arrange: seed a temp .mc directory with one per-domain registry file.
30
+ const mcDir = join(tempDir, '.mc');
31
+ await mkdir(mcDir, { recursive: true });
32
+
33
+ await writeFile(
34
+ join(mcDir, 'test-domain-registry.json'),
35
+ JSON.stringify({
36
+ domain: { id: 'test-domain', name: 'Test Domain', tenancy: 'required' },
37
+ actions: [],
38
+ events: [],
39
+ subscribers: [],
40
+ jobs: [],
41
+ schedules: [],
42
+ integrations: [],
43
+ })
44
+ );
45
+
46
+ // Capture hash of the project's existing domain-registry.json (if any)
47
+ // so we can assert the test did NOT overwrite it.
48
+ const projectCatalogPath = join(process.cwd(), '.mc', 'domain-registry.json');
49
+ const beforeHash = existsSync(projectCatalogPath)
50
+ ? createHash('sha256').update(readFileSync(projectCatalogPath)).digest('hex')
51
+ : null;
52
+
53
+ // Act: pass explicit registryDir (the temp .mc dir)
54
+ const catalog = await runBuildCatalog(mcDir);
55
+
56
+ // Assert — output landed inside the resolved registryDir
57
+ const outPath = join(mcDir, 'domain-registry.json');
58
+ expect(existsSync(outPath)).toBe(true);
59
+
60
+ const onDisk = JSON.parse(await readFile(outPath, 'utf8'));
61
+ expect(onDisk.version).toBe(2);
62
+ expect(onDisk.generatedAt).toBeDefined();
63
+ expect(onDisk.domains).toHaveLength(1);
64
+ expect(onDisk.domains[0]?.id).toBe('test-domain');
65
+
66
+ // Returned catalog matches what was written
67
+ expect(catalog.domains[0]?.id).toBe('test-domain');
68
+
69
+ // Assert — project .mc/domain-registry.json must NOT have been touched
70
+ const afterHash = existsSync(projectCatalogPath)
71
+ ? createHash('sha256').update(readFileSync(projectCatalogPath)).digest('hex')
72
+ : null;
73
+ expect(afterHash).toBe(beforeHash);
74
+ });
75
+
76
+ it('preserves default behavior when registryDir is omitted', async () => {
77
+ // Seed cwd/.mc with a temp registry file. We can't actually create
78
+ // files in cwd, but we CAN verify the default directory resolution
79
+ // produces the expected path relative to cwd.
80
+ // Instead, create a temp dir and pass no argument, then verify
81
+ // the function resolves dir to process.cwd() + '/.mc'.
82
+ // Since the project's .mc may have real registries, just verify
83
+ // no error is thrown and a valid catalog is returned.
84
+ const catalog = await runBuildCatalog();
85
+ expect(catalog.version).toBe(2);
86
+ expect(Array.isArray(catalog.domains)).toBe(true);
87
+ expect(Array.isArray(catalog.actions)).toBe(true);
88
+ });
89
+ });