Skip to content

Commit c06f0d9

Browse files
beyondnetPeruclaude
andcommitted
fix(cli): make smart-cli usable for automation — DI, rulesets, exit codes, batch init
Analyzed + fixed runtime defects that made the CLI unusable non-interactively. - evaluate/drift no longer crash: thread IConfigParser + RulesetValidator through the shared core-domain composition root (kind-evaluators, architecture-drift); register ArchitectureDriftService as a provider and inject it. - Self-contained rulesets: bundle <repo>/src/rulesets into the CLI package (copy-rulesets.js, prebuild/prepublishOnly; gitignored — regenerated) + a rulesets-resolver that resolves from the install (__dirname), --core/profile as override, NEVER process.cwd(). Fixes topology-recommend/phase-artifacts ENOENT. - Exit codes: BaseEvolithCommand.handleError + json error paths set exitCode=1 (nest-commander swallowed errors and exited 0). - init non-interactive: --config <setup.json> or --name --yes (+ runtime/monorepo/ arch/db) run prompt-free; copy-assets.js ships config JSONs to dist so init completes. scaffold guards a missing workspace (NOT_A_SATELLITE, exit 1) instead of raw spawn ENOENT. - test hygiene: mock the npm-registry version fetch (no network / WARN noise). Build clean; 919 unit tests. Verified live from a scratch dir: evaluate/drift/ topology succeed with correct exit codes, and `init --name x --yes` scaffolds a satellite non-interactively. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7a051e1 commit c06f0d9

19 files changed

Lines changed: 485 additions & 31 deletions

src/packages/core-domain/src/application/validators/architecture-drift.service.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as path from 'path';
2-
import { IFileSystem, ILogger } from '../../domain/interfaces';
2+
import { IFileSystem, ILogger, IConfigParser } from '../../domain/interfaces';
33
import { RulesetValidatorService, ArchitectureValidationResult, ValidationIssue } from './ruleset-validator.service';
44

55
export interface DriftReport {
@@ -50,12 +50,19 @@ export class ArchitectureDriftService {
5050
private readonly logger: ILogger;
5151
private readonly validator: RulesetValidatorService;
5252

53-
constructor(corePath?: string, options?: { fileSystem?: IFileSystem; logger?: ILogger; validator?: RulesetValidatorService }) {
53+
constructor(corePath?: string, options?: { fileSystem?: IFileSystem; logger?: ILogger; configParser?: IConfigParser; validator?: RulesetValidatorService }) {
5454
if (!options?.fileSystem) throw new Error('IFileSystem is required');
5555
if (!options?.logger) throw new Error('ILogger is required');
5656
this.fs = options.fileSystem;
5757
this.logger = options.logger;
58-
this.validator = options?.validator ?? new RulesetValidatorService({ fileSystem: this.fs, logger: this.logger });
58+
// The default RulesetValidatorService requires a configParser; when no
59+
// explicit validator is supplied, forward the configParser so the drift
60+
// service is usable from the shared evaluator composition root.
61+
this.validator = options?.validator ?? new RulesetValidatorService({
62+
fileSystem: this.fs,
63+
logger: this.logger,
64+
configParser: options.configParser,
65+
});
5966
}
6067

6168
async detectDrift(options: DriftDetectionOptions): Promise<DriftReport> {

src/packages/core-domain/src/evaluation/kind-evaluators.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@
1919
import { Verdict } from '../domain/verdict/verdict';
2020
import { CANONICAL_PHASE_IDS, normalizePhaseId } from '../domain/sdlc/phase-id';
2121
import type { PhaseId } from '../domain/sdlc/phase-id';
22-
import type { IFileSystem, ILogger } from '../domain/interfaces';
22+
import type { IFileSystem, ILogger, IConfigParser } from '../domain/interfaces';
2323
import type { KindEvaluator } from './ports/kind-evaluator.port';
2424
import { ArchitectureDriftService } from '../application/validators/architecture-drift.service';
25+
import type { RulesetValidatorService } from '../application/validators/ruleset-validator.service';
2526
import { PhaseGateValidatorService } from '../application/validators/phase-gate-validator.service';
2627
import { EvaluateGateUseCase } from '../application/use-cases/evaluate-gate.use-case';
2728
import { ProposePhaseAdvanceUseCase } from '../application/use-cases/propose-phase-advance.use-case';
@@ -522,6 +523,20 @@ export function createPhaseArtifactKindEvaluator(
522523
export interface DefaultKindEvaluatorDeps {
523524
readonly fileSystem: IFileSystem;
524525
readonly logger: ILogger;
526+
/**
527+
* Config parser used by the architecture evaluator's default RulesetValidator.
528+
* Optional for backward compatibility; when omitted the drift service falls back
529+
* to its own default (which requires a parser and would otherwise throw). Every
530+
* surface should pass its IConfigParser so the architecture kind evaluates.
531+
*/
532+
readonly configParser?: IConfigParser;
533+
/**
534+
* A fully-wired RulesetValidatorService for the architecture evaluator. Prefer
535+
* passing this: the default validator also needs an IRulesetRepository, whose
536+
* concrete implementation lives outside core-domain (infra-providers), so a
537+
* surface that owns one should inject it here rather than rely on the default.
538+
*/
539+
readonly rulesetValidator?: RulesetValidatorService;
525540
/** Resolves the Core repository path when a context/workspace omits corePath. */
526541
readonly resolveCorePath: () => string;
527542
}
@@ -533,13 +548,18 @@ export interface DefaultKindEvaluatorDeps {
533548
* the structural guarantee behind BR-008 parity.
534549
*/
535550
export function createDefaultKindEvaluators(deps: DefaultKindEvaluatorDeps): KindEvaluator[] {
536-
const { fileSystem, logger, resolveCorePath } = deps;
551+
const { fileSystem, logger, configParser, rulesetValidator, resolveCorePath } = deps;
537552

538553
const validatorFactory = (corePath?: string) =>
539554
new PhaseGateValidatorService(corePath, { fileSystem, logger });
540555
const evaluateGate = new EvaluateGateUseCase(validatorFactory);
541556
const proposeAdvance = new ProposePhaseAdvanceUseCase(evaluateGate);
542-
const driftService = new ArchitectureDriftService(undefined, { fileSystem, logger });
557+
const driftService = new ArchitectureDriftService(undefined, {
558+
fileSystem,
559+
logger,
560+
configParser,
561+
validator: rulesetValidator,
562+
});
543563
const topologyCatalog = new TopologyCatalogService(fileSystem, logger);
544564

545565
const blueprintExists = async (corePath: string, blueprintRef: string): Promise<boolean> => {

src/sdk/cli/.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,8 @@ MASTER_INDEX.md
1515

1616
# Local env overrides
1717
.env.local
18+
19+
# Bundled canonical rulesets — copied from src/rulesets at build time (copy-rulesets.js).
20+
# Source of truth is src/rulesets; these are regenerated on prebuild/prepublishOnly.
21+
rulesets/*
22+
!rulesets/agents/

src/sdk/cli/package.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010
"doc": "docs"
1111
},
1212
"scripts": {
13-
"build": "tsc",
13+
"copy-rulesets": "node scripts/copy-rulesets.js",
14+
"copy-assets": "node scripts/copy-assets.js",
15+
"prebuild": "npm run copy-rulesets",
16+
"build": "tsc && npm run copy-assets",
1417
"start": "node dist/main.js",
1518
"start:dev": "tsc -w & node dist/main.js",
1619
"build:policy": "npm run build:policy --prefix ../../",
@@ -23,7 +26,7 @@
2326
"test:e2e": "NODE_OPTIONS=--max-old-space-size=4096 jest --config ./test/jest-e2e.json --runInBand --no-bail",
2427
"mcp:smoke": "npm run build && node examples/mcp-test.js",
2528
"lint": "eslint src --ext .ts",
26-
"prepublishOnly": "npm run build"
29+
"prepublishOnly": "npm run copy-rulesets && npm run build && npm run copy-assets"
2730
},
2831
"keywords": [
2932
"evolith",
@@ -53,6 +56,7 @@
5356
},
5457
"files": [
5558
"dist/",
59+
"rulesets/",
5660
"shell/",
5761
"templates/",
5862
"README.md",

src/sdk/cli/scripts/copy-assets.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
#!/usr/bin/env node
2+
/**
3+
* copy-assets — copy non-TS runtime assets that `tsc` does not emit into `dist/`.
4+
*
5+
* The catalog loader reads JSON catalogs from `dist/config/*.json`
6+
* (`__dirname/../../config`), but `tsc` only compiles `.ts`. Without this step an
7+
* installed (or built-from-dist) CLI cannot run `init` — it fails with
8+
* `ENOENT ... dist/config/runtimes.json`. Copy the source `config/*.json`
9+
* (and any other non-TS assets) into `dist/config/` after compilation.
10+
*
11+
* Runs POST-tsc (tsc regenerates dist), and is idempotent.
12+
*/
13+
const fs = require('fs');
14+
const path = require('path');
15+
16+
const cliRoot = path.resolve(__dirname, '..');
17+
const srcConfig = path.join(cliRoot, 'src', 'config');
18+
const destConfig = path.join(cliRoot, 'dist', 'config');
19+
20+
if (!fs.existsSync(destConfig)) fs.mkdirSync(destConfig, { recursive: true });
21+
22+
let copied = 0;
23+
if (fs.existsSync(srcConfig)) {
24+
for (const entry of fs.readdirSync(srcConfig, { withFileTypes: true })) {
25+
if (entry.isFile() && entry.name.endsWith('.json')) {
26+
fs.copyFileSync(path.join(srcConfig, entry.name), path.join(destConfig, entry.name));
27+
copied++;
28+
}
29+
}
30+
}
31+
32+
console.log(`[copy-assets] copied ${copied} config JSON file(s) → dist/config/`);
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
#!/usr/bin/env node
2+
/**
3+
* copy-rulesets — bundle the canonical rulesets into the CLI package so an
4+
* installed `@beyondnet/evolith-cli` is self-contained (no repo checkout needed).
5+
*
6+
* Copies `<repo root>/src/rulesets` → `<cli>/rulesets`, MERGING into the
7+
* existing `rulesets/` dir (which already holds agent fixtures) rather than
8+
* replacing it. Idempotent; safe to run on every build and before publish.
9+
*/
10+
const fs = require('fs');
11+
const path = require('path');
12+
13+
const cliRoot = path.resolve(__dirname, '..');
14+
// <cli> = src/sdk/cli → repo root is three levels up; canonical rulesets live
15+
// at <repo>/src/rulesets.
16+
const canonical = path.resolve(cliRoot, '..', '..', 'rulesets');
17+
const dest = path.join(cliRoot, 'rulesets');
18+
19+
function copyDir(from, to) {
20+
if (!fs.existsSync(to)) fs.mkdirSync(to, { recursive: true });
21+
for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
22+
const src = path.join(from, entry.name);
23+
const dst = path.join(to, entry.name);
24+
if (entry.isDirectory()) {
25+
copyDir(src, dst);
26+
} else if (entry.isFile()) {
27+
fs.copyFileSync(src, dst);
28+
}
29+
}
30+
}
31+
32+
if (!fs.existsSync(canonical)) {
33+
// In an installed/detached context there is no source tree to copy from; the
34+
// rulesets are expected to be already bundled. Do not fail the build.
35+
console.warn(`[copy-rulesets] canonical source not found at ${canonical}; skipping (already bundled?)`);
36+
process.exit(0);
37+
}
38+
39+
copyDir(canonical, dest);
40+
console.log(`[copy-rulesets] bundled canonical rulesets → ${path.relative(cliRoot, dest)}/`);

src/sdk/cli/src/app.module.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import { EvaluateGateUseCase } from '@beyondnet/evolith-core-domain/application/
3838
import { PhaseGateValidatorService } from '@beyondnet/evolith-core-domain/application/validators/phase-gate-validator.service';
3939
import { ProposePhaseAdvanceUseCase } from '@beyondnet/evolith-core-domain/application/use-cases/propose-phase-advance.use-case';
4040
import { RulesetValidatorService } from '@beyondnet/evolith-core-domain/application/validators/ruleset-validator.service';
41+
import { ArchitectureDriftService } from '@beyondnet/evolith-core-domain/application/validators/architecture-drift.service';
4142
import type { IFileSystem, ILogger, IConfigParser } from '@beyondnet/evolith-core-domain/domain/interfaces';
4243
import { PromptService } from './infrastructure/prompts/prompt.service';
4344
import { WizardService } from './infrastructure/prompts/wizard.service';
@@ -94,7 +95,6 @@ import { PluginModule } from './infrastructure/plugins/plugin.module';
9495
AliasCommand,
9596
SatelliteCreateCommand,
9697
ChatCommand,
97-
ValidateSatelliteUseCase,
9898
EvaluateGateUseCase,
9999
ProposePhaseAdvanceUseCase,
100100
{
@@ -109,6 +109,23 @@ import { PluginModule } from './infrastructure/plugins/plugin.module';
109109
},
110110
inject: ['IFileSystem', 'ILogger', 'IConfigParser'],
111111
},
112+
{
113+
// ValidateSatelliteUseCase constructs a bare RulesetValidatorService when
114+
// given no validator — which throws `IConfigParser is required` at DI time.
115+
// Inject the fully-wired validator so `evaluate`/`validate` construct cleanly.
116+
provide: ValidateSatelliteUseCase,
117+
useFactory: (validator: RulesetValidatorService) => new ValidateSatelliteUseCase(validator),
118+
inject: [RulesetValidatorService],
119+
},
120+
{
121+
// ArchitectureDriftService requires fileSystem + logger; the DriftCommand
122+
// previously constructed it with `new ArchitectureDriftService()` → throws
123+
// `IFileSystem is required`. Wire it here and inject into the command.
124+
provide: ArchitectureDriftService,
125+
useFactory: (fs: IFileSystem, logger: ILogger, validator: RulesetValidatorService) =>
126+
new ArchitectureDriftService(undefined, { fileSystem: fs, logger, validator }),
127+
inject: ['IFileSystem', 'ILogger', RulesetValidatorService],
128+
},
112129
PromptService,
113130
WizardService,
114131
InitWizardCommand,

src/sdk/cli/src/commands/architecture/scaffold.command.spec.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ describe('ScaffoldCommand', () => {
3434
(NxWorkspaceStrategy as jest.Mock).mockImplementation(() => mockStrategy);
3535
mockSpinner.mockReturnValue(mockSpinnerInstance);
3636
command = new ScaffoldCommand();
37+
// By default assume a valid Nx workspace so the pre-spawn guard passes; the
38+
// dedicated guard test below overrides this to exercise the failure path.
39+
jest.spyOn(command as any, 'checkWorkspace').mockReturnValue(undefined);
3740
});
3841

3942
describe('run', () => {
@@ -277,6 +280,23 @@ describe('ScaffoldCommand', () => {
277280
expect(envelope.error.message).toContain('--frontend');
278281
});
279282

283+
it('should emit an actionable NOT_A_SATELLITE envelope when no workspace exists', async () => {
284+
(command as any).checkWorkspace.mockReturnValue(
285+
'No workspace found at /x/src. Run `smart-cli init` first.',
286+
);
287+
288+
await command.run([], { format: 'json', frontend: 'react', orm: 'prisma', phase: '1' });
289+
290+
const envelope = JSON.parse(logSpy.mock.calls.find((c: any[]) => {
291+
try { return JSON.parse(c[0]).success === false; } catch { return false; }
292+
})![0]);
293+
expect(envelope.success).toBe(false);
294+
expect(envelope.error.code).toBe('NOT_A_SATELLITE');
295+
expect(envelope.error.message).toContain('smart-cli init');
296+
// The guard must short-circuit before any strategy work.
297+
expect(mockStrategy.installDependencies).not.toHaveBeenCalled();
298+
});
299+
280300
it('should emit error envelope on strategy failure', async () => {
281301
mockStrategy.installDependencies.mockRejectedValueOnce(new Error('npm failed'));
282302

src/sdk/cli/src/commands/architecture/scaffold.command.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import * as fs from 'node:fs';
2+
import * as path from 'node:path';
13
import { Command, Option } from 'nest-commander';
24
import { randomUUID } from 'node:crypto';
35
import { BaseEvolithCommand } from '../../infrastructure/cli/base-command';
@@ -36,6 +38,25 @@ export class ScaffoldCommand extends BaseEvolithCommand {
3638
this.strategy.setDryRun(dryRun);
3739
}
3840

41+
// Guard: the Nx strategy runs `npm install` / `nx g` in `<cwd>/src`. If that
42+
// workspace does not exist, the spawn fails deep with a raw `spawn ENOENT`.
43+
// Fail fast with an actionable message instead. Dry-run doesn't spawn, so skip.
44+
if (!dryRun) {
45+
const workspaceError = this.checkWorkspace();
46+
if (workspaceError) {
47+
if (json) {
48+
process.exitCode = 1;
49+
console.log(JSON.stringify(createErrorEnvelope(
50+
'NOT_A_SATELLITE',
51+
workspaceError,
52+
{ ...meta, durationMs: Date.now() - startedAt },
53+
), null, 2));
54+
return;
55+
}
56+
throw new Error(workspaceError);
57+
}
58+
}
59+
3960
if (json) {
4061
try {
4162
const frontendFramework = options?.frontend as string;
@@ -233,6 +254,32 @@ export class ScaffoldCommand extends BaseEvolithCommand {
233254
this.promptService.showOutro('Completed');
234255
}
235256

257+
/**
258+
* The Nx strategy operates in `<cwd>/src`. Verify that directory exists and is
259+
* an Nx workspace before we spawn any `npm`/`nx` process there. Returns an
260+
* actionable error message, or `undefined` when the workspace is usable.
261+
*/
262+
private checkWorkspace(): string | undefined {
263+
const cwd = process.cwd();
264+
const workspaceDir = path.join(cwd, 'src');
265+
if (!fs.existsSync(workspaceDir)) {
266+
return (
267+
`No workspace found at ${workspaceDir}. Run \`smart-cli init\` first to ` +
268+
`scaffold a satellite, then re-run \`smart-cli architecture scaffold\`.`
269+
);
270+
}
271+
const isNxWorkspace =
272+
fs.existsSync(path.join(workspaceDir, 'nx.json')) ||
273+
fs.existsSync(path.join(workspaceDir, 'package.json'));
274+
if (!isNxWorkspace) {
275+
return (
276+
`${workspaceDir} exists but is not an Nx workspace (no nx.json/package.json). ` +
277+
`Run \`smart-cli init\` first to scaffold the base workspace.`
278+
);
279+
}
280+
return undefined;
281+
}
282+
236283
@Option({
237284
flags: '--frontend [framework]',
238285
description: 'Framework para el frontend (react, angular)',

src/sdk/cli/src/commands/drift/drift.command.spec.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,14 @@ describe('DriftCommand', () => {
6969

7070
beforeEach(() => {
7171
// GT-345: real PromptService so showError routes through the mocked @clack/prompts log.error.
72-
command = new DriftCommand(new PromptService());
72+
// The drift service is now injected (previously constructed internally); pass a
73+
// mock exposing the same three methods the command calls.
74+
const driftServiceMock = {
75+
detectDrift: mockDetectDrift,
76+
getDriftHistory: mockGetDriftHistory,
77+
getDriftTrend: mockGetDriftTrend,
78+
} as unknown as ArchitectureDriftService;
79+
command = new DriftCommand(driftServiceMock, new PromptService());
7380
logSpy = jest.spyOn(console, 'log').mockImplementation(() => {});
7481
exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => undefined as never);
7582
jest.clearAllMocks();

0 commit comments

Comments
 (0)