@mettlecast/domain-cli 0.2.61 → 0.2.63
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/dist/cli.js +20 -2
- package/dist/commands/check-hashes.d.ts +2 -0
- package/dist/commands/check-hashes.js +8 -0
- package/dist/commands/doctor.js +4 -4
- package/dist/commands/regenerate-modules-hashes.d.ts +25 -0
- package/dist/commands/regenerate-modules-hashes.js +58 -0
- package/dist/commands/update-all.d.ts +21 -0
- package/dist/commands/update-all.js +62 -0
- package/dist/utils/manifest.d.ts +1 -0
- package/dist/utils/manifest.js +16 -1
- package/package.json +1 -1
- package/src/__tests__/commands/build-flows.test.ts +55 -0
- package/src/__tests__/commands/check-hashes.test.ts +31 -0
- package/src/__tests__/commands/regenerate-modules-hashes.test.ts +170 -0
- package/src/__tests__/commands/update-all.test.ts +322 -0
- package/src/__tests__/doctor.test.ts +73 -0
- package/src/__tests__/utils/manifest.test.ts +130 -2
- package/src/cli.ts +22 -2
- package/src/commands/check-hashes.ts +12 -0
- package/src/commands/doctor.ts +4 -4
- package/src/commands/regenerate-modules-hashes.ts +89 -0
- package/src/commands/update-all.ts +79 -0
- package/src/utils/manifest.ts +16 -1
package/dist/cli.js
CHANGED
|
@@ -13,6 +13,8 @@ import { runAddModule } from './commands/add-module.js';
|
|
|
13
13
|
import { runBuildFlows } from './commands/build-flows.js';
|
|
14
14
|
import { runDoctor } from './commands/doctor.js';
|
|
15
15
|
import { runCheckHashes } from './commands/check-hashes.js';
|
|
16
|
+
import { runUpdateAll } from './commands/update-all.js';
|
|
17
|
+
import { runRegenerateModulesHashes } from './commands/regenerate-modules-hashes.js';
|
|
16
18
|
import { runUpgradeBackend } from './commands/upgrade-backend.js';
|
|
17
19
|
import { runAddPage } from './commands/add-page.js';
|
|
18
20
|
import { runCreateProject } from './commands/create-project.js';
|
|
@@ -173,10 +175,26 @@ program
|
|
|
173
175
|
program
|
|
174
176
|
.command('check-hashes')
|
|
175
177
|
.description('Verify infra/modules/ has not been hand-edited since last scaffold')
|
|
176
|
-
.
|
|
177
|
-
|
|
178
|
+
.option('--write', 'Regenerate modules-hashes.json from current disk state instead of verifying')
|
|
179
|
+
.action(async (opts) => {
|
|
180
|
+
const result = await runCheckHashes({ write: opts.write });
|
|
178
181
|
process.exit(result.ok ? 0 : 1);
|
|
179
182
|
});
|
|
183
|
+
program
|
|
184
|
+
.command('update-all')
|
|
185
|
+
.description('Full refresh: build all domains, build catalog, build flows, build UI, regenerate hashes, run doctor')
|
|
186
|
+
.option('--project-root <path>', 'Root of the project (defaults to cwd)')
|
|
187
|
+
.action(async (opts) => {
|
|
188
|
+
const result = await runUpdateAll({ projectRoot: opts.projectRoot });
|
|
189
|
+
process.exit(result.success ? 0 : 1);
|
|
190
|
+
});
|
|
191
|
+
program
|
|
192
|
+
.command('regenerate-modules-hashes')
|
|
193
|
+
.description('Walk infra/modules/, compute SHA256 hashes, and write .mc/modules-hashes.json')
|
|
194
|
+
.option('--project-root <path>', 'Root of the project (defaults to cwd)')
|
|
195
|
+
.action(async (opts) => {
|
|
196
|
+
await runRegenerateModulesHashes({ projectRoot: opts.projectRoot });
|
|
197
|
+
});
|
|
180
198
|
program
|
|
181
199
|
.command('upgrade-backend <target-major>')
|
|
182
200
|
.description('Run jscodeshift/ts-morph migrations between major versions of domain-runtime')
|
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
export interface CheckHashesOptions {
|
|
5
5
|
/** Root directory of the project (defaults to cwd). */
|
|
6
6
|
projectRoot?: string;
|
|
7
|
+
/** If true, regenerate .mc/modules-hashes.json from current infra/modules/ before checking. */
|
|
8
|
+
write?: boolean;
|
|
7
9
|
}
|
|
8
10
|
/**
|
|
9
11
|
* A single hash check result.
|
|
@@ -10,6 +10,14 @@ import { cliLogger } from '../utils/logger.js';
|
|
|
10
10
|
*/
|
|
11
11
|
export async function runCheckHashes(opts = {}) {
|
|
12
12
|
const root = opts.projectRoot ?? process.cwd();
|
|
13
|
+
// --write bootstraps the manifest from current infra/modules/ before verification.
|
|
14
|
+
// Must run before the manifest read below so it can create a missing manifest.
|
|
15
|
+
if (opts.write) {
|
|
16
|
+
const { runRegenerateModulesHashes } = await import('./regenerate-modules-hashes.js');
|
|
17
|
+
const outPath = await runRegenerateModulesHashes({ projectRoot: root });
|
|
18
|
+
cliLogger.info({ outPath }, 'modules-hashes.json regenerated');
|
|
19
|
+
return { ok: true, drifted: [], missing: [], unexpected: [] };
|
|
20
|
+
}
|
|
13
21
|
const manifestPath = join(root, '.mc', 'modules-hashes.json');
|
|
14
22
|
const modulesDir = join(root, 'infra', 'modules');
|
|
15
23
|
let manifest;
|
package/dist/commands/doctor.js
CHANGED
|
@@ -520,9 +520,9 @@ async function checkScaffoldConfigMatchesDisk(projectRoot) {
|
|
|
520
520
|
catch {
|
|
521
521
|
return {
|
|
522
522
|
name: 'scaffold-config.json matches on-disk',
|
|
523
|
-
status: '
|
|
524
|
-
message: '
|
|
525
|
-
fixHint: '
|
|
523
|
+
status: 'WARN',
|
|
524
|
+
message: '.mc/scaffold-config.json not found or unreadable — skipping domain list sync check (expected on fresh clones before first scaffold)',
|
|
525
|
+
fixHint: 'Run mc-domain-module update-all to regenerate scaffold metadata',
|
|
526
526
|
kNodeRef: 'K:runbook:add-domain',
|
|
527
527
|
};
|
|
528
528
|
}
|
|
@@ -634,7 +634,7 @@ async function checkRootLevelFlows(projectRoot) {
|
|
|
634
634
|
}
|
|
635
635
|
return {
|
|
636
636
|
name: 'No root-level flows (deprecated)',
|
|
637
|
-
status: '
|
|
637
|
+
status: 'FAIL',
|
|
638
638
|
message: `${flowFiles.length} flow(s) still in root-level flows/: ${flowFiles.join(', ')}`,
|
|
639
639
|
fixHint: 'Move these flows to domains/{owningDomain}/flows/ and delete the root-level copies.',
|
|
640
640
|
kNodeRef: 'K:convention:flow-vs-subscriber-rule',
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Options for the regenerate-modules-hashes command.
|
|
3
|
+
*/
|
|
4
|
+
export interface RegenerateModulesHashesOptions {
|
|
5
|
+
/** Root directory of the project (defaults to cwd). */
|
|
6
|
+
projectRoot?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Manifest of infra/modules/ files with their SHA256 hashes.
|
|
10
|
+
*/
|
|
11
|
+
export interface ModulesHashesManifest {
|
|
12
|
+
/** Schema version. */
|
|
13
|
+
version: '1';
|
|
14
|
+
/** ISO timestamp when the manifest was generated. */
|
|
15
|
+
generatedAt: string;
|
|
16
|
+
/** Map of relative file path to SHA256 hex digest. */
|
|
17
|
+
files: Record<string, string>;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Walk infra/modules/, compute a SHA256 for every file, and write the result
|
|
21
|
+
* to .mc/modules-hashes.json. Used to refresh the scaffold drift baseline.
|
|
22
|
+
* @param opts Options including project root directory.
|
|
23
|
+
* @returns Absolute path to the written manifest file.
|
|
24
|
+
*/
|
|
25
|
+
export declare function runRegenerateModulesHashes(opts?: RegenerateModulesHashesOptions): Promise<string>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { readFile, readdir, writeFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { cliLogger } from '../utils/logger.js';
|
|
5
|
+
/**
|
|
6
|
+
* Walk infra/modules/, compute a SHA256 for every file, and write the result
|
|
7
|
+
* to .mc/modules-hashes.json. Used to refresh the scaffold drift baseline.
|
|
8
|
+
* @param opts Options including project root directory.
|
|
9
|
+
* @returns Absolute path to the written manifest file.
|
|
10
|
+
*/
|
|
11
|
+
export async function runRegenerateModulesHashes(opts = {}) {
|
|
12
|
+
const root = opts.projectRoot ?? process.cwd();
|
|
13
|
+
const mcDir = join(root, '.mc');
|
|
14
|
+
const manifestPath = join(mcDir, 'modules-hashes.json');
|
|
15
|
+
const modulesDir = join(root, 'infra', 'modules');
|
|
16
|
+
// Files/directories to skip when walking infra/modules/
|
|
17
|
+
const SKIP_NAMES = new Set(['node_modules', 'dist', 'cdk.out', 'package-lock.json', '.npmrc']);
|
|
18
|
+
/** Normalize CRLF → LF so hashes match across platforms (Windows vs Linux). */
|
|
19
|
+
function normalizeLineEndings(buf) {
|
|
20
|
+
const str = buf.toString('utf8');
|
|
21
|
+
if (!str.includes('\r\n'))
|
|
22
|
+
return buf;
|
|
23
|
+
return Buffer.from(str.replace(/\r\n/g, '\n'), 'utf8');
|
|
24
|
+
}
|
|
25
|
+
// Walk infra/modules/ and collect every file path
|
|
26
|
+
async function walk(dir) {
|
|
27
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
28
|
+
const files = [];
|
|
29
|
+
for (const entry of entries) {
|
|
30
|
+
if (SKIP_NAMES.has(entry.name))
|
|
31
|
+
continue;
|
|
32
|
+
const full = join(dir, entry.name);
|
|
33
|
+
if (entry.isDirectory())
|
|
34
|
+
files.push(...await walk(full));
|
|
35
|
+
else if (entry.isFile())
|
|
36
|
+
files.push(full);
|
|
37
|
+
}
|
|
38
|
+
return files;
|
|
39
|
+
}
|
|
40
|
+
const onDiskFiles = await walk(modulesDir);
|
|
41
|
+
// Compute SHA256 for each file (line-ending-normalised)
|
|
42
|
+
const files = {};
|
|
43
|
+
for (const absPath of onDiskFiles) {
|
|
44
|
+
const relPath = relative(root, absPath).replace(/\\/g, '/');
|
|
45
|
+
const content = await readFile(absPath);
|
|
46
|
+
const normalized = normalizeLineEndings(content);
|
|
47
|
+
files[relPath] = createHash('sha256').update(normalized).digest('hex');
|
|
48
|
+
}
|
|
49
|
+
const manifest = {
|
|
50
|
+
version: '1',
|
|
51
|
+
generatedAt: new Date().toISOString(),
|
|
52
|
+
files,
|
|
53
|
+
};
|
|
54
|
+
await mkdir(mcDir, { recursive: true });
|
|
55
|
+
await writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
|
|
56
|
+
cliLogger.info({ path: manifestPath, fileCount: Object.keys(files).length }, 'modules-hashes manifest regenerated');
|
|
57
|
+
return manifestPath;
|
|
58
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run the full scaffold update pipeline:
|
|
3
|
+
* 1. Build each domain registry from `.mc/scaffold-config.json`
|
|
4
|
+
* 2. Build the combined domain catalog
|
|
5
|
+
* 3. Build flows registry
|
|
6
|
+
* 4. Build UI manifests
|
|
7
|
+
* 5. Regenerate module hashes
|
|
8
|
+
* 6. Run doctor
|
|
9
|
+
*
|
|
10
|
+
* Per-domain build failures are logged and skipped so that one bad domain does
|
|
11
|
+
* not abort the whole pipeline. The remaining steps still run.
|
|
12
|
+
*
|
|
13
|
+
* @param opts - Optional projectRoot override. Defaults to cwd.
|
|
14
|
+
* @returns success derived from doctor pass/fail and a human-readable summary.
|
|
15
|
+
*/
|
|
16
|
+
export declare function runUpdateAll(opts?: {
|
|
17
|
+
projectRoot?: string;
|
|
18
|
+
}): Promise<{
|
|
19
|
+
success: boolean;
|
|
20
|
+
summary: string;
|
|
21
|
+
}>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { cliLogger } from '../utils/logger.js';
|
|
3
|
+
import { readScaffoldConfig } from '../utils/scaffold-config.js';
|
|
4
|
+
import { runBuild } from './build.js';
|
|
5
|
+
import { runBuildCatalog } from './build-catalog.js';
|
|
6
|
+
import { runBuildFlows } from './build-flows.js';
|
|
7
|
+
import { runBuildUi } from './build-ui.js';
|
|
8
|
+
import { runRegenerateModulesHashes } from './regenerate-modules-hashes.js';
|
|
9
|
+
import { runDoctor } from './doctor.js';
|
|
10
|
+
/**
|
|
11
|
+
* Run the full scaffold update pipeline:
|
|
12
|
+
* 1. Build each domain registry from `.mc/scaffold-config.json`
|
|
13
|
+
* 2. Build the combined domain catalog
|
|
14
|
+
* 3. Build flows registry
|
|
15
|
+
* 4. Build UI manifests
|
|
16
|
+
* 5. Regenerate module hashes
|
|
17
|
+
* 6. Run doctor
|
|
18
|
+
*
|
|
19
|
+
* Per-domain build failures are logged and skipped so that one bad domain does
|
|
20
|
+
* not abort the whole pipeline. The remaining steps still run.
|
|
21
|
+
*
|
|
22
|
+
* @param opts - Optional projectRoot override. Defaults to cwd.
|
|
23
|
+
* @returns success derived from doctor pass/fail and a human-readable summary.
|
|
24
|
+
*/
|
|
25
|
+
export async function runUpdateAll(opts) {
|
|
26
|
+
const projectRoot = opts?.projectRoot ?? process.cwd();
|
|
27
|
+
cliLogger.info({ projectRoot }, 'update-all: starting');
|
|
28
|
+
// 1. Read scaffold-config to discover domains
|
|
29
|
+
const scaffoldConfig = await readScaffoldConfig(projectRoot);
|
|
30
|
+
const domainIds = scaffoldConfig.domainIds ?? [];
|
|
31
|
+
cliLogger.info({ domainCount: domainIds.length }, 'update-all: building domain registries');
|
|
32
|
+
// 2. Build each domain. Per-domain failures are logged and skipped.
|
|
33
|
+
for (const domainId of domainIds) {
|
|
34
|
+
const domainRoot = join(projectRoot, 'domains', domainId);
|
|
35
|
+
try {
|
|
36
|
+
cliLogger.info({ domainId, domainRoot }, 'update-all: building domain');
|
|
37
|
+
await runBuild({ domainRoot });
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
cliLogger.error({ domainId, err: err instanceof Error ? err.message : String(err) }, 'update-all: domain build failed — continuing');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// 3. Build catalog from .mc
|
|
44
|
+
const mcDir = join(projectRoot, '.mc');
|
|
45
|
+
cliLogger.info({ mcDir }, 'update-all: building domain catalog');
|
|
46
|
+
await runBuildCatalog(mcDir);
|
|
47
|
+
// 4. Build flows
|
|
48
|
+
cliLogger.info({ projectRoot }, 'update-all: building flows registry');
|
|
49
|
+
await runBuildFlows({ projectRoot });
|
|
50
|
+
// 5. Build UI manifests
|
|
51
|
+
cliLogger.info({ projectRoot }, 'update-all: building UI manifests');
|
|
52
|
+
await runBuildUi({ projectRoot });
|
|
53
|
+
// 6. Regenerate module hashes
|
|
54
|
+
cliLogger.info({ projectRoot }, 'update-all: regenerating module hashes');
|
|
55
|
+
await runRegenerateModulesHashes({ projectRoot });
|
|
56
|
+
// 7. Run doctor — final gate
|
|
57
|
+
cliLogger.info({ projectRoot }, 'update-all: running doctor');
|
|
58
|
+
const report = await runDoctor({ projectRoot });
|
|
59
|
+
cliLogger.info({ pass: report.pass, exitCode: report.exitCode, checkCount: report.checks.length }, 'update-all: doctor complete');
|
|
60
|
+
const summary = `Updated ${domainIds.length} domains; doctor ${report.pass ? 'PASS' : 'FAIL'}`;
|
|
61
|
+
return { success: report.pass, summary };
|
|
62
|
+
}
|
package/dist/utils/manifest.d.ts
CHANGED
|
@@ -26,3 +26,4 @@ export declare function upsertManifestFile(manifest: TibManifest, entry: Manifes
|
|
|
26
26
|
export declare function removeManifestFile(manifest: TibManifest, filePath: string): void;
|
|
27
27
|
export declare function getManifestFile(manifest: TibManifest, filePath: string): ManifestFileEntry | undefined;
|
|
28
28
|
export declare function isScaffoldManaged(manifest: TibManifest, filePath: string): boolean;
|
|
29
|
+
export declare function pruneManifest(projectRoot: string, manifest: TibManifest): Promise<number>;
|
package/dist/utils/manifest.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
1
|
+
import { readFile, writeFile, mkdir, access } from 'fs/promises';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { cliLogger } from './logger.js';
|
|
4
4
|
const MANIFEST_PATH = '.mc/manifest.json';
|
|
@@ -132,3 +132,18 @@ export function isScaffoldManaged(manifest, filePath) {
|
|
|
132
132
|
// Returns true only when the file is in the manifest with policy 'managed'
|
|
133
133
|
return entry != null && entry.policy === 'managed';
|
|
134
134
|
}
|
|
135
|
+
export async function pruneManifest(projectRoot, manifest) {
|
|
136
|
+
let pruned = 0;
|
|
137
|
+
const survivors = [];
|
|
138
|
+
for (const entry of manifest.files) {
|
|
139
|
+
try {
|
|
140
|
+
await access(path.join(projectRoot, entry.path));
|
|
141
|
+
survivors.push(entry);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
pruned++;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
manifest.files = survivors;
|
|
148
|
+
return pruned;
|
|
149
|
+
}
|
package/package.json
CHANGED
|
@@ -151,4 +151,59 @@ describe('build-flows command', () => {
|
|
|
151
151
|
|
|
152
152
|
expect(registry.flows[0]?.trigger?.eventId).toBe('domain.event');
|
|
153
153
|
});
|
|
154
|
+
|
|
155
|
+
it('picks up flow from domains/auth/flows/ with owningDomain=\'auth\'', async () => {
|
|
156
|
+
const domainFlowsDir = join(tmpDir, 'domains', 'auth', 'flows');
|
|
157
|
+
await mkdir(domainFlowsDir, { recursive: true });
|
|
158
|
+
|
|
159
|
+
const flow = {
|
|
160
|
+
id: 'onboarding-flow',
|
|
161
|
+
name: 'Onboarding Flow',
|
|
162
|
+
steps: [
|
|
163
|
+
{
|
|
164
|
+
type: 'flow-control',
|
|
165
|
+
control: 'succeed',
|
|
166
|
+
name: 'End',
|
|
167
|
+
},
|
|
168
|
+
],
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
await writeFile(join(domainFlowsDir, 'onboarding.json'), JSON.stringify(flow));
|
|
172
|
+
|
|
173
|
+
const outFile = await runBuildFlows({ projectRoot: tmpDir });
|
|
174
|
+
const content = await readFile(outFile, 'utf8');
|
|
175
|
+
const registry = JSON.parse(content) as FlowRegistry;
|
|
176
|
+
|
|
177
|
+
expect(registry.flows).toHaveLength(1);
|
|
178
|
+
expect(registry.flows[0]?.id).toBe('onboarding-flow');
|
|
179
|
+
expect(registry.flows[0]?.owningDomain).toBe('auth');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('detects ID collision between root-level and domain-level flows (FlowIdCollisionError)', async () => {
|
|
183
|
+
// Same flow ID in BOTH root flows/ and a domain's flows/.
|
|
184
|
+
const rootFlowsDir = join(tmpDir, 'flows');
|
|
185
|
+
await mkdir(rootFlowsDir, { recursive: true });
|
|
186
|
+
|
|
187
|
+
const domainFlowsDir = join(tmpDir, 'domains', 'auth', 'flows');
|
|
188
|
+
await mkdir(domainFlowsDir, { recursive: true });
|
|
189
|
+
|
|
190
|
+
const flow = {
|
|
191
|
+
id: 'shared-flow',
|
|
192
|
+
name: 'Shared Flow',
|
|
193
|
+
steps: [
|
|
194
|
+
{
|
|
195
|
+
type: 'flow-control',
|
|
196
|
+
control: 'succeed',
|
|
197
|
+
name: 'End',
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
await writeFile(join(rootFlowsDir, 'shared.json'), JSON.stringify(flow));
|
|
203
|
+
await writeFile(join(domainFlowsDir, 'shared.json'), JSON.stringify(flow));
|
|
204
|
+
|
|
205
|
+
await expect(runBuildFlows({ projectRoot: tmpDir }))
|
|
206
|
+
.rejects
|
|
207
|
+
.toThrow(FlowIdCollisionError);
|
|
208
|
+
});
|
|
154
209
|
});
|
|
@@ -139,4 +139,35 @@ describe('check-hashes', () => {
|
|
|
139
139
|
/cannot read.*modules-hashes\.json/
|
|
140
140
|
);
|
|
141
141
|
});
|
|
142
|
+
|
|
143
|
+
it('--write regenerates manifest from disk (even if manifest was missing)', async () => {
|
|
144
|
+
// Set up infra/modules/ with a file, but NO .mc/modules-hashes.json.
|
|
145
|
+
await mkdir(join(tempDir, '.mc'), { recursive: true });
|
|
146
|
+
await mkdir(join(tempDir, 'infra', 'modules'), { recursive: true });
|
|
147
|
+
|
|
148
|
+
const content = 'export const MyStack = {};';
|
|
149
|
+
await writeFile(join(tempDir, 'infra', 'modules', 'fresh.ts'), content);
|
|
150
|
+
|
|
151
|
+
// Confirm the manifest doesn't exist yet.
|
|
152
|
+
const { existsSync } = await import('node:fs');
|
|
153
|
+
expect(existsSync(join(tempDir, '.mc', 'modules-hashes.json'))).toBe(false);
|
|
154
|
+
|
|
155
|
+
// --write should bootstrap the manifest from disk and succeed.
|
|
156
|
+
const result = await runCheckHashes({ projectRoot: tempDir, write: true });
|
|
157
|
+
expect(result.ok).toBe(true);
|
|
158
|
+
expect(result.drifted).toHaveLength(0);
|
|
159
|
+
expect(result.missing).toHaveLength(0);
|
|
160
|
+
expect(result.unexpected).toHaveLength(0);
|
|
161
|
+
|
|
162
|
+
// The manifest should now exist on disk with the file's hash.
|
|
163
|
+
expect(existsSync(join(tempDir, '.mc', 'modules-hashes.json'))).toBe(true);
|
|
164
|
+
const manifestContent = JSON.parse(
|
|
165
|
+
await (await import('node:fs/promises')).readFile(
|
|
166
|
+
join(tempDir, '.mc', 'modules-hashes.json'),
|
|
167
|
+
'utf8'
|
|
168
|
+
)
|
|
169
|
+
);
|
|
170
|
+
const expectedHash = createHash('sha256').update(content).digest('hex');
|
|
171
|
+
expect(manifestContent.files['infra/modules/fresh.ts']).toBe(expectedHash);
|
|
172
|
+
});
|
|
142
173
|
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { mkdtemp, writeFile, mkdir } from 'node:fs/promises';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { rmSync } from 'node:fs';
|
|
8
|
+
import { runRegenerateModulesHashes } from '../../commands/regenerate-modules-hashes.js';
|
|
9
|
+
|
|
10
|
+
describe('regenerate-modules-hashes', () => {
|
|
11
|
+
let tempDir: string;
|
|
12
|
+
|
|
13
|
+
beforeEach(async () => {
|
|
14
|
+
tempDir = await mkdtemp(join(tmpdir(), 'tib-regenerate-modules-hashes-'));
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('writes modules-hashes.json with correct version and structure', async () => {
|
|
22
|
+
await mkdir(join(tempDir, 'infra', 'modules'), { recursive: true });
|
|
23
|
+
|
|
24
|
+
const content = 'export const MyStack = {};';
|
|
25
|
+
await writeFile(join(tempDir, 'infra', 'modules', 'test.ts'), content);
|
|
26
|
+
|
|
27
|
+
const manifestPath = await runRegenerateModulesHashes({ projectRoot: tempDir });
|
|
28
|
+
|
|
29
|
+
expect(manifestPath).toBe(join(tempDir, '.mc', 'modules-hashes.json'));
|
|
30
|
+
|
|
31
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
32
|
+
expect(manifest.version).toBe('1');
|
|
33
|
+
expect(typeof manifest.generatedAt).toBe('string');
|
|
34
|
+
expect(new Date(manifest.generatedAt).toISOString()).toBe(manifest.generatedAt);
|
|
35
|
+
expect(manifest.files).toEqual({
|
|
36
|
+
'infra/modules/test.ts': createHash('sha256').update(content).digest('hex'),
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('computes correct SHA-256 hashes for every file', async () => {
|
|
41
|
+
await mkdir(join(tempDir, 'infra', 'modules'), { recursive: true });
|
|
42
|
+
|
|
43
|
+
const files: Record<string, string> = {
|
|
44
|
+
'infra/modules/a.ts': 'export const a = 1;',
|
|
45
|
+
'infra/modules/b.ts': 'export const b = 2;\nexport const c = 3;',
|
|
46
|
+
'infra/modules/nested/c.ts': 'export const c = "hello world";',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
await mkdir(join(tempDir, 'infra', 'modules', 'nested'), { recursive: true });
|
|
50
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
51
|
+
await writeFile(join(tempDir, rel), content);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const manifestPath = await runRegenerateModulesHashes({ projectRoot: tempDir });
|
|
55
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
56
|
+
|
|
57
|
+
expect(Object.keys(manifest.files).sort()).toEqual(Object.keys(files).sort());
|
|
58
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
59
|
+
const expected = createHash('sha256').update(content).digest('hex');
|
|
60
|
+
expect(manifest.files[rel]).toBe(expected);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('skips node_modules, dist, cdk.out, package-lock.json, and .npmrc', async () => {
|
|
65
|
+
const root = tempDir;
|
|
66
|
+
const modules = join(root, 'infra', 'modules');
|
|
67
|
+
await mkdir(modules, { recursive: true });
|
|
68
|
+
|
|
69
|
+
// Real file we want hashed
|
|
70
|
+
await writeFile(join(modules, 'real.ts'), 'export const real = true;');
|
|
71
|
+
|
|
72
|
+
// Files/dirs that must be excluded
|
|
73
|
+
await mkdir(join(modules, 'node_modules', 'pkg'), { recursive: true });
|
|
74
|
+
await writeFile(join(modules, 'node_modules', 'pkg', 'index.js'), 'module.exports = {};');
|
|
75
|
+
|
|
76
|
+
await mkdir(join(modules, 'dist'), { recursive: true });
|
|
77
|
+
await writeFile(join(modules, 'dist', 'bundle.js'), 'var x = 1;');
|
|
78
|
+
|
|
79
|
+
await mkdir(join(modules, 'cdk.out'), { recursive: true });
|
|
80
|
+
await writeFile(join(modules, 'cdk.out', 'template.json'), '{}');
|
|
81
|
+
|
|
82
|
+
await writeFile(join(modules, 'package-lock.json'), '{}');
|
|
83
|
+
await writeFile(join(modules, '.npmrc'), 'registry=https://example.com');
|
|
84
|
+
|
|
85
|
+
// Also nested exclude
|
|
86
|
+
await mkdir(join(modules, 'sub', 'node_modules'), { recursive: true });
|
|
87
|
+
await writeFile(join(modules, 'sub', 'node_modules', 'dep.js'), '// skip');
|
|
88
|
+
|
|
89
|
+
await writeFile(join(modules, 'sub', 'kept.ts'), 'export const kept = 1;');
|
|
90
|
+
|
|
91
|
+
const manifestPath = await runRegenerateModulesHashes({ projectRoot: root });
|
|
92
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
93
|
+
|
|
94
|
+
expect(manifest.files).toEqual({
|
|
95
|
+
'infra/modules/real.ts': createHash('sha256').update('export const real = true;').digest('hex'),
|
|
96
|
+
'infra/modules/sub/kept.ts': createHash('sha256').update('export const kept = 1;').digest('hex'),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// None of the excluded entries appear
|
|
100
|
+
const keys = Object.keys(manifest.files);
|
|
101
|
+
expect(keys.some((k) => k.includes('node_modules'))).toBe(false);
|
|
102
|
+
expect(keys.some((k) => k.includes('dist/'))).toBe(false);
|
|
103
|
+
expect(keys.some((k) => k.includes('cdk.out/'))).toBe(false);
|
|
104
|
+
expect(keys.some((k) => k.endsWith('package-lock.json'))).toBe(false);
|
|
105
|
+
expect(keys.some((k) => k.endsWith('.npmrc'))).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('handles empty infra/modules/ directory by emitting empty files map', async () => {
|
|
109
|
+
await mkdir(join(tempDir, 'infra', 'modules'), { recursive: true });
|
|
110
|
+
|
|
111
|
+
const manifestPath = await runRegenerateModulesHashes({ projectRoot: tempDir });
|
|
112
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
113
|
+
|
|
114
|
+
expect(manifest.version).toBe('1');
|
|
115
|
+
expect(typeof manifest.generatedAt).toBe('string');
|
|
116
|
+
expect(manifest.files).toEqual({});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('handles missing infra/modules/ directory gracefully', async () => {
|
|
120
|
+
// Do NOT create infra/modules/ — only the project root exists
|
|
121
|
+
const manifestPath = await runRegenerateModulesHashes({ projectRoot: tempDir });
|
|
122
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
123
|
+
|
|
124
|
+
expect(manifestPath).toBe(join(tempDir, '.mc', 'modules-hashes.json'));
|
|
125
|
+
expect(manifest.version).toBe('1');
|
|
126
|
+
expect(typeof manifest.generatedAt).toBe('string');
|
|
127
|
+
expect(manifest.files).toEqual({});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('is idempotent — second run produces identical file map and structure', async () => {
|
|
131
|
+
await mkdir(join(tempDir, 'infra', 'modules'), { recursive: true });
|
|
132
|
+
|
|
133
|
+
await writeFile(join(tempDir, 'infra', 'modules', 'a.ts'), 'export const a = 1;');
|
|
134
|
+
await mkdir(join(tempDir, 'infra', 'modules', 'sub'), { recursive: true });
|
|
135
|
+
await writeFile(join(tempDir, 'infra', 'modules', 'sub', 'b.ts'), 'export const b = 2;');
|
|
136
|
+
|
|
137
|
+
const firstPath = await runRegenerateModulesHashes({ projectRoot: tempDir });
|
|
138
|
+
const first = JSON.parse(readFileSync(firstPath, 'utf8'));
|
|
139
|
+
|
|
140
|
+
// Wait long enough that a fresh ISO timestamp would differ
|
|
141
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
142
|
+
|
|
143
|
+
const secondPath = await runRegenerateModulesHashes({ projectRoot: tempDir });
|
|
144
|
+
const second = JSON.parse(readFileSync(secondPath, 'utf8'));
|
|
145
|
+
|
|
146
|
+
expect(secondPath).toBe(firstPath);
|
|
147
|
+
expect(second.version).toBe(first.version);
|
|
148
|
+
expect(second.files).toEqual(first.files);
|
|
149
|
+
|
|
150
|
+
// Timestamps may differ, but the structure (ignoring generatedAt) is identical
|
|
151
|
+
const { generatedAt: _g1, ...firstRest } = first;
|
|
152
|
+
const { generatedAt: _g2, ...secondRest } = second;
|
|
153
|
+
expect(secondRest).toEqual(firstRest);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('includes deeply nested files under infra/modules/', async () => {
|
|
157
|
+
await mkdir(join(tempDir, 'infra', 'modules', 'flows', 'sub'), { recursive: true });
|
|
158
|
+
|
|
159
|
+
const nestedContent = 'export const nested = true;';
|
|
160
|
+
const nestedPath = join(tempDir, 'infra', 'modules', 'flows', 'sub', 'nested.ts');
|
|
161
|
+
await writeFile(nestedPath, nestedContent);
|
|
162
|
+
|
|
163
|
+
const manifestPath = await runRegenerateModulesHashes({ projectRoot: tempDir });
|
|
164
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
165
|
+
|
|
166
|
+
expect(manifest.files['infra/modules/flows/sub/nested.ts']).toBe(
|
|
167
|
+
createHash('sha256').update(nestedContent).digest('hex'),
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
});
|