@vodmal/vdx-cli 0.3.0 → 0.5.0
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/README.md +35 -3
- package/package.json +1 -1
- package/src/index.ts +97 -2
- package/src/init.ts +178 -20
- package/src/mcp-server.ts +1 -1
- package/src/publish.ts +364 -0
- package/src/run.ts +56 -0
package/README.md
CHANGED
|
@@ -19,9 +19,34 @@ npx -y -p @vodmal/vdx-cli vdx audit /path/to/project
|
|
|
19
19
|
## Use
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
|
+
# Lifecycle verbs (pass-through to `mise run <verb>`; requires mise + mise.toml)
|
|
23
|
+
vdx up | down | build | test | check | fix
|
|
24
|
+
|
|
25
|
+
# Maturity audit against the owner baseline rubric
|
|
22
26
|
vdx audit <project-path> [--json] [--rubric <path>] [--stack <id>]
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
|
|
28
|
+
# Generate mise.toml + AGENTS.md for a project
|
|
29
|
+
vdx init <project-path> [--baseline github.com/org/repo@vX.Y] [--stack <id>] [--dry-run] [--force]
|
|
30
|
+
|
|
31
|
+
# Publish a library (Node MVP; PHP/Python coming in Y.3)
|
|
32
|
+
vdx publish <patch|minor|major> [--dry-run] [--force]
|
|
33
|
+
|
|
34
|
+
# MCP stdio server consumed by the Claude Code plugin
|
|
35
|
+
vdx-mcp --project <path>
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
**Three quick examples**
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# 1. Score the project I'm standing in
|
|
42
|
+
vdx audit .
|
|
43
|
+
|
|
44
|
+
# 2. Wire native scripts into the 6 lifecycle verbs, then run one
|
|
45
|
+
vdx init . --stack node
|
|
46
|
+
vdx test # → mise run test (which calls `vitest run` or whatever was detected)
|
|
47
|
+
|
|
48
|
+
# 3. Ship a new minor release of a Node lib
|
|
49
|
+
vdx publish minor # bump → npm publish (OTP prompt) → git commit + tag (no push)
|
|
25
50
|
```
|
|
26
51
|
|
|
27
52
|
By default the bundled `rubric/vdx-rubric.yaml` is used (a mirror of canonical
|
|
@@ -48,6 +73,9 @@ npm run typecheck
|
|
|
48
73
|
- `src/evaluator.ts` — recursive evaluator + sugar notation
|
|
49
74
|
- `src/scoring.ts` — delta-style levels, flags for orthogonal axes
|
|
50
75
|
- `src/audit.ts` — orchestrator: overrides + applies_to filter + subpackage-ctx
|
|
76
|
+
- `src/init.ts` — `vdx init` planner (`selectVerbTask` + mise.toml/AGENTS.md renderers)
|
|
77
|
+
- `src/run.ts` — `resolveLifecycleVerb` + error renderer (pure logic for `vdx <verb>`)
|
|
78
|
+
- `src/publish.ts` — `planPublish` (pre-flight) + `executePublish` (bump → npm → git)
|
|
51
79
|
- `src/report.ts` — markdown / JSON output
|
|
52
80
|
- `src/index.ts` — CLI entry
|
|
53
81
|
- `src/mcp-server.ts` — MCP stdio server (9 tools)
|
|
@@ -60,4 +88,8 @@ npm run typecheck
|
|
|
60
88
|
- Baseline loading from a git ref is documented but evaluator still reads file
|
|
61
89
|
paths only — `baseline:` in `mise.toml` is recorded but does not auto-fetch.
|
|
62
90
|
- Watermark drift (phase 2 of `drift-algorithm.md`).
|
|
63
|
-
-
|
|
91
|
+
- `vdx publish` only ships for Node (MVP). PHP/Python in Y.3; Cargo/Ruby/Go/Java
|
|
92
|
+
in Y.4.
|
|
93
|
+
- Open items: O25 (mock-infra delta for Node), O26 (TOML round-trip), O27 (real
|
|
94
|
+
shared-infra precheck), O32 (multi-subpackage monorepo). See
|
|
95
|
+
[docs/decisions.md](../docs/decisions.md).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vodmal/vdx-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "vdx — unified lifecycle interface (up/down/build/test/check/fix) + versioned maturity audit with drift detection. Pairs with the vdx Claude Code plugin.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"vdx",
|
package/src/index.ts
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import * as path from 'node:path';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
3
4
|
import { loadRubric } from './rubric.ts';
|
|
4
5
|
import { loadManifest, loadOverrides } from './manifest.ts';
|
|
5
6
|
import { autoDetectStack, type Ctx } from './facts.ts';
|
|
7
|
+
import {
|
|
8
|
+
LIFECYCLE_VERBS,
|
|
9
|
+
type LifecycleVerb,
|
|
10
|
+
resolveLifecycleVerb,
|
|
11
|
+
renderResolveError,
|
|
12
|
+
} from './run.ts';
|
|
6
13
|
import { audit } from './audit.ts';
|
|
7
14
|
import { reportMarkdown, reportJson } from './report.ts';
|
|
8
15
|
import { planInit, writeInit, renderPlanSummary } from './init.ts';
|
|
16
|
+
import {
|
|
17
|
+
planPublish,
|
|
18
|
+
renderPublishPlan,
|
|
19
|
+
executePublish,
|
|
20
|
+
type BumpKind,
|
|
21
|
+
type PublishOptions,
|
|
22
|
+
} from './publish.ts';
|
|
9
23
|
import { resolveDefaultRubric } from './defaults.ts';
|
|
10
24
|
|
|
11
25
|
const DEFAULT_RUBRIC = resolveDefaultRubric();
|
|
@@ -13,8 +27,10 @@ const DEFAULT_RUBRIC = resolveDefaultRubric();
|
|
|
13
27
|
function usage(): never {
|
|
14
28
|
process.stderr.write(
|
|
15
29
|
`Usage:
|
|
16
|
-
vdx
|
|
17
|
-
vdx
|
|
30
|
+
vdx <up|down|build|test|check|fix> run lifecycle verb (via mise run <verb>)
|
|
31
|
+
vdx audit <project_path> [--rubric <path>] [--stack <stack>] [--json]
|
|
32
|
+
vdx init <project_path> [--stack <id>] [--baseline <ref>] [--dry-run] [--force]
|
|
33
|
+
vdx publish <patch|minor|major> [--dry-run] [--force]
|
|
18
34
|
`,
|
|
19
35
|
);
|
|
20
36
|
process.exit(1);
|
|
@@ -114,7 +130,86 @@ function cmdInit(opts: ParsedArgs): void {
|
|
|
114
130
|
}
|
|
115
131
|
}
|
|
116
132
|
|
|
133
|
+
function cmdPublish(opts: ParsedArgs): void {
|
|
134
|
+
const bumpArg = opts.positionals[0];
|
|
135
|
+
if (!bumpArg || !['patch', 'minor', 'major'].includes(bumpArg)) {
|
|
136
|
+
process.stderr.write(
|
|
137
|
+
'Usage: vdx publish <patch|minor|major> [--dry-run] [--force]\n',
|
|
138
|
+
);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
const bump = bumpArg as BumpKind;
|
|
142
|
+
const projectRoot = process.cwd();
|
|
143
|
+
|
|
144
|
+
const manifest = loadManifest(projectRoot);
|
|
145
|
+
const overrides = loadOverrides(projectRoot);
|
|
146
|
+
const rubric = loadRubric(DEFAULT_RUBRIC);
|
|
147
|
+
const stack = manifest?.stack || autoDetectStack(projectRoot);
|
|
148
|
+
const ctx: Ctx = { projectRoot, stack, cache: new Map() };
|
|
149
|
+
const baselineRef = manifest?.baseline ?? `file://${DEFAULT_RUBRIC}`;
|
|
150
|
+
const auditResult = audit(rubric, ctx, overrides, baselineRef, manifest);
|
|
151
|
+
|
|
152
|
+
const planOpts: PublishOptions = {
|
|
153
|
+
projectRoot,
|
|
154
|
+
bump,
|
|
155
|
+
dryRun: Boolean(opts.flags['dry-run']),
|
|
156
|
+
force: Boolean(opts.flags.force),
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
let plan;
|
|
160
|
+
try {
|
|
161
|
+
plan = planPublish(planOpts, auditResult, ctx);
|
|
162
|
+
} catch (e: any) {
|
|
163
|
+
process.stderr.write(`error: ${e?.message ?? String(e)}\n`);
|
|
164
|
+
process.exit(2);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
process.stdout.write(renderPublishPlan(plan));
|
|
168
|
+
|
|
169
|
+
if (planOpts.dryRun) {
|
|
170
|
+
process.stderr.write('\n[dry-run] не выполнено.\n');
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!plan.preflightPassed && !planOpts.force) {
|
|
175
|
+
process.stderr.write('\nerror: pre-flight failed (use --force to bypass)\n');
|
|
176
|
+
process.exit(2);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
executePublish(plan);
|
|
181
|
+
} catch (e: any) {
|
|
182
|
+
process.stderr.write(`\nerror: ${e?.message ?? String(e)}\n`);
|
|
183
|
+
process.exit(3);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function cmdRun(verb: LifecycleVerb): void {
|
|
188
|
+
const projectRoot = process.cwd();
|
|
189
|
+
const res = resolveLifecycleVerb(projectRoot, verb);
|
|
190
|
+
if (!res.ok) {
|
|
191
|
+
process.stderr.write(renderResolveError(res, projectRoot, verb));
|
|
192
|
+
process.exit(2);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
execFileSync('mise', ['run', verb], { cwd: projectRoot, stdio: 'inherit' });
|
|
197
|
+
} catch (e: any) {
|
|
198
|
+
if (e?.code === 'ENOENT') {
|
|
199
|
+
process.stderr.write(
|
|
200
|
+
`vdx: \`mise\` binary not found on PATH\n` +
|
|
201
|
+
`hint: install mise — https://mise.jdx.dev/getting-started.html\n`,
|
|
202
|
+
);
|
|
203
|
+
process.exit(127);
|
|
204
|
+
}
|
|
205
|
+
process.exit(typeof e?.status === 'number' ? e.status : 1);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
117
209
|
const parsed = parseArgs(process.argv);
|
|
118
210
|
if (parsed.cmd === 'audit') cmdAudit(parsed);
|
|
119
211
|
else if (parsed.cmd === 'init') cmdInit(parsed);
|
|
212
|
+
else if (parsed.cmd === 'publish') cmdPublish(parsed);
|
|
213
|
+
else if ((LIFECYCLE_VERBS as readonly string[]).includes(parsed.cmd))
|
|
214
|
+
cmdRun(parsed.cmd as LifecycleVerb);
|
|
120
215
|
else usage();
|
package/src/init.ts
CHANGED
|
@@ -17,12 +17,62 @@ export type Verb = (typeof STANDARD_VERBS)[number];
|
|
|
17
17
|
export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v0.3.1';
|
|
18
18
|
|
|
19
19
|
const VERB_ALIASES: Record<Verb, string[]> = {
|
|
20
|
-
up: ['docker:up', 'docker-up', 'docker:up:detached', 'start'],
|
|
20
|
+
up: ['docker:up', 'docker-up', 'docker:up:detached', 'start', 'dev', 'serve'],
|
|
21
21
|
down: ['docker:down', 'docker-down', 'stop'],
|
|
22
|
-
build: [
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
build: [
|
|
23
|
+
'docker:build',
|
|
24
|
+
'build:prod',
|
|
25
|
+
'build-prod',
|
|
26
|
+
'build:dev',
|
|
27
|
+
'build-dev',
|
|
28
|
+
'build:assets',
|
|
29
|
+
'compile',
|
|
30
|
+
'dist',
|
|
31
|
+
],
|
|
32
|
+
test: [
|
|
33
|
+
'phpunit',
|
|
34
|
+
'pest',
|
|
35
|
+
'test:unit',
|
|
36
|
+
'test-unit',
|
|
37
|
+
'test:unit:phpunit',
|
|
38
|
+
'tests',
|
|
39
|
+
'unit',
|
|
40
|
+
'jest',
|
|
41
|
+
'vitest',
|
|
42
|
+
'mocha',
|
|
43
|
+
'ava',
|
|
44
|
+
'spec',
|
|
45
|
+
'coverage',
|
|
46
|
+
],
|
|
47
|
+
check: [
|
|
48
|
+
'lint',
|
|
49
|
+
'check:before:commit',
|
|
50
|
+
'check:before:push',
|
|
51
|
+
'check:config',
|
|
52
|
+
'check:code:static',
|
|
53
|
+
'check:code:lint',
|
|
54
|
+
'typecheck',
|
|
55
|
+
'tsc',
|
|
56
|
+
'eslint',
|
|
57
|
+
'prettier:check',
|
|
58
|
+
'format:check',
|
|
59
|
+
'qa',
|
|
60
|
+
'phpstan',
|
|
61
|
+
'psalm',
|
|
62
|
+
],
|
|
63
|
+
fix: [
|
|
64
|
+
'fix:ecs',
|
|
65
|
+
'fix:rector',
|
|
66
|
+
'format',
|
|
67
|
+
'lint:fix',
|
|
68
|
+
'eslint:fix',
|
|
69
|
+
'prettier',
|
|
70
|
+
'prettier:write',
|
|
71
|
+
'prettier:fix',
|
|
72
|
+
'format:write',
|
|
73
|
+
'cs-fix',
|
|
74
|
+
'cs:fix',
|
|
75
|
+
],
|
|
26
76
|
};
|
|
27
77
|
|
|
28
78
|
const VERB_PREFIX_GROUPS: Record<Verb, string[]> = {
|
|
@@ -34,10 +84,32 @@ const VERB_PREFIX_GROUPS: Record<Verb, string[]> = {
|
|
|
34
84
|
fix: ['fix:', 'fix-'],
|
|
35
85
|
};
|
|
36
86
|
|
|
87
|
+
// Suffix-based monorepo fallback: lets us pick up `server:test`, `api:lint`, etc.
|
|
88
|
+
// when no exact/alias/prefix-group match exists.
|
|
89
|
+
const VERB_SUFFIX_GROUPS: Record<Verb, string[]> = {
|
|
90
|
+
up: [':up', '-up'],
|
|
91
|
+
down: [':down', '-down'],
|
|
92
|
+
build: [':build', '-build'],
|
|
93
|
+
test: [':test', '-test'],
|
|
94
|
+
check: [':check', '-check', ':lint', ':typecheck'],
|
|
95
|
+
fix: [':fix', '-fix', ':format'],
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export type MatchReason = 'exact' | 'alias' | 'prefix-group' | 'suffix-group' | 'not-found';
|
|
99
|
+
|
|
37
100
|
export interface VerbMapping {
|
|
38
101
|
verb: Verb;
|
|
39
102
|
nativeTask: string | null;
|
|
40
103
|
runCommand: string | null;
|
|
104
|
+
reason: MatchReason;
|
|
105
|
+
/** Other candidate tasks that also matched but weren't chosen — useful for tuning. */
|
|
106
|
+
alternatives: string[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface VerbMatch {
|
|
110
|
+
task: string | null;
|
|
111
|
+
reason: MatchReason;
|
|
112
|
+
alternatives: string[];
|
|
41
113
|
}
|
|
42
114
|
|
|
43
115
|
export interface InitPlan {
|
|
@@ -106,12 +178,74 @@ function pickPrefixGroup(prefixes: string[], tasks: Set<string>, verb: Verb): st
|
|
|
106
178
|
return candidates[0] ?? null;
|
|
107
179
|
}
|
|
108
180
|
|
|
109
|
-
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
|
|
181
|
+
function collectPrefixCandidates(prefixes: string[], tasks: Set<string>, verb: Verb): string[] {
|
|
182
|
+
if (prefixes.length === 0) return [];
|
|
183
|
+
const candidates: string[] = [];
|
|
184
|
+
for (const t of tasks) {
|
|
185
|
+
if (t === verb) continue;
|
|
186
|
+
for (const pfx of prefixes) {
|
|
187
|
+
if (t.startsWith(pfx)) {
|
|
188
|
+
candidates.push(t);
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return candidates;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function collectSuffixCandidates(suffixes: string[], tasks: Set<string>, verb: Verb): string[] {
|
|
197
|
+
if (suffixes.length === 0) return [];
|
|
198
|
+
const candidates: string[] = [];
|
|
199
|
+
for (const t of tasks) {
|
|
200
|
+
if (t === verb) continue;
|
|
201
|
+
for (const sfx of suffixes) {
|
|
202
|
+
if (t.endsWith(sfx)) {
|
|
203
|
+
candidates.push(t);
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
113
207
|
}
|
|
114
|
-
return
|
|
208
|
+
return candidates;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function selectVerbTask(verb: Verb, tasks: Set<string>): VerbMatch {
|
|
212
|
+
// 1) exact match wins; siblings (aliases / prefix-group) reported as alternatives.
|
|
213
|
+
if (tasks.has(verb)) {
|
|
214
|
+
const alts = new Set<string>();
|
|
215
|
+
for (const a of VERB_ALIASES[verb]) if (tasks.has(a)) alts.add(a);
|
|
216
|
+
for (const c of collectPrefixCandidates(VERB_PREFIX_GROUPS[verb], tasks, verb)) alts.add(c);
|
|
217
|
+
return { task: verb, reason: 'exact', alternatives: [...alts].sort() };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// 2) alias hit (curated common alternates per stack).
|
|
221
|
+
const aliasHits = VERB_ALIASES[verb].filter((a) => tasks.has(a));
|
|
222
|
+
if (aliasHits.length > 0) {
|
|
223
|
+
const [chosen, ...rest] = aliasHits;
|
|
224
|
+
return { task: chosen!, reason: 'alias', alternatives: rest.slice().sort() };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// 3) prefix-group with canonical hint (prod/release/all preferred, then shortest).
|
|
228
|
+
const prefixCandidates = collectPrefixCandidates(VERB_PREFIX_GROUPS[verb], tasks, verb);
|
|
229
|
+
if (prefixCandidates.length > 0) {
|
|
230
|
+
const chosen = pickPrefixGroup(VERB_PREFIX_GROUPS[verb], tasks, verb)!;
|
|
231
|
+
const alts = prefixCandidates.filter((c) => c !== chosen).sort();
|
|
232
|
+
return { task: chosen, reason: 'prefix-group', alternatives: alts };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// 4) suffix-group (monorepo fallback: `server:test` -> verb 'test').
|
|
236
|
+
const suffixCandidates = collectSuffixCandidates(VERB_SUFFIX_GROUPS[verb], tasks, verb);
|
|
237
|
+
if (suffixCandidates.length > 0) {
|
|
238
|
+
suffixCandidates.sort((a, b) => a.length - b.length);
|
|
239
|
+
const [chosen, ...rest] = suffixCandidates;
|
|
240
|
+
return { task: chosen!, reason: 'suffix-group', alternatives: rest.slice().sort() };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { task: null, reason: 'not-found', alternatives: [] };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Kept for back-compat with potential external callers; prefer `selectVerbTask`. */
|
|
247
|
+
export function mapVerb(verb: Verb, tasks: Set<string>): string | null {
|
|
248
|
+
return selectVerbTask(verb, tasks).task;
|
|
115
249
|
}
|
|
116
250
|
|
|
117
251
|
function collectTools(ctx: Ctx): Record<string, string> {
|
|
@@ -179,6 +313,10 @@ export function renderMiseToml(
|
|
|
179
313
|
|
|
180
314
|
for (const m of mappings) {
|
|
181
315
|
if (!m.runCommand) continue;
|
|
316
|
+
if (m.reason !== 'exact' || m.alternatives.length > 0) {
|
|
317
|
+
const altPart = m.alternatives.length > 0 ? `; alt: ${m.alternatives.join(', ')}` : '';
|
|
318
|
+
out.push(`# vdx: matched "${m.nativeTask}" via ${m.reason}${altPart}`);
|
|
319
|
+
}
|
|
182
320
|
out.push(`[tasks.${m.verb}]`);
|
|
183
321
|
out.push(`description = ${quoteToml(VERB_DESCRIPTIONS[m.verb])}`);
|
|
184
322
|
out.push(`run = ${quoteToml(m.runCommand)}`);
|
|
@@ -300,15 +438,21 @@ export function planInit(
|
|
|
300
438
|
const tasks = listAllTasks(scanCtx);
|
|
301
439
|
|
|
302
440
|
const mappings: VerbMapping[] = STANDARD_VERBS.map((verb) => {
|
|
303
|
-
const
|
|
441
|
+
const sel = selectVerbTask(verb, tasks);
|
|
304
442
|
let runCommand: string | null = null;
|
|
305
|
-
if (
|
|
443
|
+
if (sel.task) {
|
|
306
444
|
runCommand =
|
|
307
445
|
primarySubpackage !== null
|
|
308
|
-
? renderRunCommandInSubpackage(
|
|
309
|
-
: renderRunCommand(
|
|
446
|
+
? renderRunCommandInSubpackage(sel.task, scanStack, scanRoot, primarySubpackage)
|
|
447
|
+
: renderRunCommand(sel.task, stack, projectRoot);
|
|
310
448
|
}
|
|
311
|
-
return {
|
|
449
|
+
return {
|
|
450
|
+
verb,
|
|
451
|
+
nativeTask: sel.task,
|
|
452
|
+
runCommand,
|
|
453
|
+
reason: sel.reason,
|
|
454
|
+
alternatives: sel.alternatives,
|
|
455
|
+
};
|
|
312
456
|
});
|
|
313
457
|
|
|
314
458
|
// tools читаем из root независимо: для PHP/Node корневой manifest часто
|
|
@@ -387,19 +531,33 @@ export function renderPlanSummary(plan: InitPlan): string {
|
|
|
387
531
|
lines.push('');
|
|
388
532
|
lines.push('## Verb mappings');
|
|
389
533
|
lines.push('');
|
|
390
|
-
lines.push('| Verb | Native task | Generated `run` |');
|
|
391
|
-
lines.push('
|
|
534
|
+
lines.push('| Verb | Native task | Reason | Generated `run` |');
|
|
535
|
+
lines.push('|------|-------------|--------|-----------------|');
|
|
392
536
|
for (const m of plan.mappings) {
|
|
393
537
|
if (m.runCommand) {
|
|
394
|
-
lines.push(
|
|
538
|
+
lines.push(
|
|
539
|
+
`| \`${m.verb}\` | \`${m.nativeTask}\` | ${m.reason} | \`${m.runCommand}\` |`,
|
|
540
|
+
);
|
|
395
541
|
} else {
|
|
396
|
-
lines.push(`| \`${m.verb}\` | _(not found)_ | _(skipped)_ |`);
|
|
542
|
+
lines.push(`| \`${m.verb}\` | _(not found)_ | not-found | _(skipped)_ |`);
|
|
397
543
|
}
|
|
398
544
|
}
|
|
399
545
|
lines.push('');
|
|
546
|
+
const withAlts = plan.mappings.filter((m) => m.alternatives.length > 0);
|
|
547
|
+
if (withAlts.length > 0) {
|
|
548
|
+
lines.push('### Alternatives considered');
|
|
549
|
+
lines.push('');
|
|
550
|
+
for (const m of withAlts) {
|
|
551
|
+
const alts = m.alternatives.map((a) => `\`${a}\``).join(', ');
|
|
552
|
+
lines.push(
|
|
553
|
+
`- \`${m.verb}\` chose \`${m.nativeTask}\` (${m.reason}); also matched: ${alts}`,
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
lines.push('');
|
|
557
|
+
}
|
|
400
558
|
const unmatched = plan.mappings.filter((m) => !m.runCommand).map((m) => m.verb);
|
|
401
559
|
if (unmatched.length > 0) {
|
|
402
|
-
lines.push(`> Not mapped: ${unmatched.join(', ')}.
|
|
560
|
+
lines.push(`> Not mapped: ${unmatched.join(', ')}. Добавь тиски в \`mise.toml\` вручную.`);
|
|
403
561
|
lines.push('');
|
|
404
562
|
}
|
|
405
563
|
lines.push('## Generated mise.toml');
|
package/src/mcp-server.ts
CHANGED
|
@@ -156,7 +156,7 @@ function recordSuccessPath(input: {
|
|
|
156
156
|
|
|
157
157
|
doc.vdx = doc.vdx ?? {
|
|
158
158
|
schema_version: '0.2',
|
|
159
|
-
baseline: 'github.com/VoDmAl/vdx-rubric-vodmal@v0.
|
|
159
|
+
baseline: 'github.com/VoDmAl/vdx-rubric-vodmal@v0.3.1',
|
|
160
160
|
stack: autoDetectStack(PROJECT_ROOT),
|
|
161
161
|
};
|
|
162
162
|
doc.vdx.verbs = input.verbs.map((v) => v.name);
|
package/src/publish.ts
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import type { AuditResult } from './audit.ts';
|
|
5
|
+
import { stackForDir, type Ctx } from './facts.ts';
|
|
6
|
+
|
|
7
|
+
export type BumpKind = 'patch' | 'minor' | 'major';
|
|
8
|
+
|
|
9
|
+
export interface PublishOptions {
|
|
10
|
+
projectRoot: string;
|
|
11
|
+
bump: BumpKind;
|
|
12
|
+
dryRun: boolean;
|
|
13
|
+
force: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface PreflightCheck {
|
|
17
|
+
name: string;
|
|
18
|
+
ok: boolean;
|
|
19
|
+
message: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface PublishPlan {
|
|
23
|
+
projectRoot: string;
|
|
24
|
+
stack: string;
|
|
25
|
+
packageName: string;
|
|
26
|
+
currentVersion: string;
|
|
27
|
+
newVersion: string;
|
|
28
|
+
bump: BumpKind;
|
|
29
|
+
packageJsonPath: string;
|
|
30
|
+
preflight: PreflightCheck[];
|
|
31
|
+
preflightPassed: boolean;
|
|
32
|
+
delegatedToMise: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function bumpSemver(current: string, kind: BumpKind): string {
|
|
36
|
+
const m = current.match(/^(\d+)\.(\d+)\.(\d+)/);
|
|
37
|
+
if (!m) throw new Error(`Cannot parse semver "${current}"`);
|
|
38
|
+
let major = parseInt(m[1]!, 10);
|
|
39
|
+
let minor = parseInt(m[2]!, 10);
|
|
40
|
+
let patch = parseInt(m[3]!, 10);
|
|
41
|
+
if (kind === 'major') {
|
|
42
|
+
major += 1;
|
|
43
|
+
minor = 0;
|
|
44
|
+
patch = 0;
|
|
45
|
+
} else if (kind === 'minor') {
|
|
46
|
+
minor += 1;
|
|
47
|
+
patch = 0;
|
|
48
|
+
} else {
|
|
49
|
+
patch += 1;
|
|
50
|
+
}
|
|
51
|
+
return `${major}.${minor}.${patch}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function compareSemver(a: string, b: string): number {
|
|
55
|
+
const norm = (s: string) =>
|
|
56
|
+
s.replace(/^v/, '').split(/[.\-+]/).map((p) => parseInt(p, 10) || 0);
|
|
57
|
+
const pa = norm(a);
|
|
58
|
+
const pb = norm(b);
|
|
59
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
60
|
+
const av = pa[i] ?? 0;
|
|
61
|
+
const bv = pb[i] ?? 0;
|
|
62
|
+
if (av !== bv) return av < bv ? -1 : 1;
|
|
63
|
+
}
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isGitWorkingTreeClean(projectRoot: string): { ok: boolean; detail: string } {
|
|
68
|
+
try {
|
|
69
|
+
const out = execFileSync('git', ['status', '--porcelain'], {
|
|
70
|
+
cwd: projectRoot,
|
|
71
|
+
encoding: 'utf8',
|
|
72
|
+
});
|
|
73
|
+
if (out.trim() === '') return { ok: true, detail: 'OK' };
|
|
74
|
+
const lines = out.trim().split('\n');
|
|
75
|
+
const sample = lines.slice(0, 3).join(', ');
|
|
76
|
+
return { ok: false, detail: `${lines.length} uncommitted file(s) — ${sample}` };
|
|
77
|
+
} catch {
|
|
78
|
+
return { ok: false, detail: 'not a git repo (or git not available)' };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function fetchPublishedVersion(packageName: string): string | null {
|
|
83
|
+
try {
|
|
84
|
+
const out = execFileSync('npm', ['view', packageName, 'version'], {
|
|
85
|
+
encoding: 'utf8',
|
|
86
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
87
|
+
});
|
|
88
|
+
return out.trim() || null;
|
|
89
|
+
} catch (e: any) {
|
|
90
|
+
const stderr = String(e?.stderr ?? e?.message ?? '');
|
|
91
|
+
if (stderr.includes('E404') || stderr.includes('404 Not Found')) return null;
|
|
92
|
+
throw new Error(`npm view failed: ${stderr.split('\n')[0] ?? stderr}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function checkMiseOverride(projectRoot: string): boolean {
|
|
97
|
+
const miseToml = path.join(projectRoot, 'mise.toml');
|
|
98
|
+
if (!fs.existsSync(miseToml)) return false;
|
|
99
|
+
try {
|
|
100
|
+
const text = fs.readFileSync(miseToml, 'utf8');
|
|
101
|
+
// Lightweight check — a [tasks.publish] section header is unambiguous
|
|
102
|
+
// enough for the delegation gate; no need for full TOML parsing here.
|
|
103
|
+
return /^\[tasks\.publish\b/m.test(text);
|
|
104
|
+
} catch {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function resolvePackageJsonPath(projectRoot: string, audit: AuditResult): string {
|
|
110
|
+
const subpkg = audit.primary_subpackage;
|
|
111
|
+
const root = subpkg ? path.join(projectRoot, subpkg) : projectRoot;
|
|
112
|
+
return path.join(root, 'package.json');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function planPublish(
|
|
116
|
+
opts: PublishOptions,
|
|
117
|
+
audit: AuditResult,
|
|
118
|
+
ctx: Ctx,
|
|
119
|
+
): PublishPlan {
|
|
120
|
+
if (checkMiseOverride(opts.projectRoot)) {
|
|
121
|
+
return {
|
|
122
|
+
projectRoot: opts.projectRoot,
|
|
123
|
+
stack: ctx.stack,
|
|
124
|
+
packageName: '(delegated)',
|
|
125
|
+
currentVersion: '',
|
|
126
|
+
newVersion: '',
|
|
127
|
+
bump: opts.bump,
|
|
128
|
+
packageJsonPath: '',
|
|
129
|
+
preflight: [
|
|
130
|
+
{
|
|
131
|
+
name: 'mise-override',
|
|
132
|
+
ok: true,
|
|
133
|
+
message: '[tasks.publish] present in mise.toml — delegating to mise',
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
preflightPassed: true,
|
|
137
|
+
delegatedToMise: true,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Resolve effective stack: prefer subpackage stack if primary_subpackage is set
|
|
142
|
+
// (meta-projects publishing a nested lib, e.g. vdx itself with primary_subpackage=cli).
|
|
143
|
+
const subpkg = audit.primary_subpackage;
|
|
144
|
+
const effectiveStack = subpkg
|
|
145
|
+
? stackForDir(path.join(opts.projectRoot, subpkg))
|
|
146
|
+
: ctx.stack;
|
|
147
|
+
|
|
148
|
+
if (effectiveStack !== 'node') {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`vdx publish: only "node" stack is supported in the MVP (Шаг X). ` +
|
|
151
|
+
`Effective stack: "${effectiveStack}" (root: "${ctx.stack}"${
|
|
152
|
+
subpkg ? `, subpackage: "${subpkg}"` : ''
|
|
153
|
+
}). PHP/Python come in Шаг X.2.`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const pkgJsonPath = resolvePackageJsonPath(opts.projectRoot, audit);
|
|
158
|
+
if (!fs.existsSync(pkgJsonPath)) {
|
|
159
|
+
throw new Error(`vdx publish: package.json not found at ${pkgJsonPath}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'));
|
|
163
|
+
const packageName: string = pkg.name ?? '(unnamed)';
|
|
164
|
+
const currentVersion: string = pkg.version ?? '0.0.0';
|
|
165
|
+
const newVersion = bumpSemver(currentVersion, opts.bump);
|
|
166
|
+
|
|
167
|
+
const preflight: PreflightCheck[] = [];
|
|
168
|
+
|
|
169
|
+
const tree = isGitWorkingTreeClean(opts.projectRoot);
|
|
170
|
+
preflight.push({
|
|
171
|
+
name: 'working-tree-clean',
|
|
172
|
+
ok: tree.ok,
|
|
173
|
+
message: tree.detail,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const raAxis = audit.per_axis.find((a) => a.axis_id === 'release-artifact');
|
|
177
|
+
const isExcluded = raAxis?.drift_kind === 'excluded';
|
|
178
|
+
preflight.push({
|
|
179
|
+
name: 'lib-intent (applies_when)',
|
|
180
|
+
ok: !!raAxis && !isExcluded,
|
|
181
|
+
message: !raAxis
|
|
182
|
+
? 'release-artifact axis missing from rubric'
|
|
183
|
+
: isExcluded
|
|
184
|
+
? 'release-artifact axis excluded — this looks like an app, not a library. ' +
|
|
185
|
+
'Use [tasks.publish] in mise.toml if you really need vdx publish here.'
|
|
186
|
+
: 'OK (axis applies → lib-intent)',
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const raLevel = raAxis?.achieved ?? 'L0';
|
|
190
|
+
const raLevelInt = parseInt(raLevel.slice(1), 10) || 0;
|
|
191
|
+
preflight.push({
|
|
192
|
+
name: 'release-artifact >= L3',
|
|
193
|
+
ok: raLevelInt >= 3,
|
|
194
|
+
message:
|
|
195
|
+
raLevelInt >= 3
|
|
196
|
+
? `OK (release-artifact ${raLevel})`
|
|
197
|
+
: `release-artifact at ${raLevel}, need >= L3. Add files[], main/exports, license in package.json.`,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
let published: string | null = null;
|
|
201
|
+
let registryError: string | null = null;
|
|
202
|
+
try {
|
|
203
|
+
published = fetchPublishedVersion(packageName);
|
|
204
|
+
} catch (e: any) {
|
|
205
|
+
registryError = e?.message ?? String(e);
|
|
206
|
+
}
|
|
207
|
+
if (registryError) {
|
|
208
|
+
preflight.push({
|
|
209
|
+
name: 'registry-collision',
|
|
210
|
+
ok: false,
|
|
211
|
+
message: `could not query npm registry: ${registryError}`,
|
|
212
|
+
});
|
|
213
|
+
} else if (published === null) {
|
|
214
|
+
preflight.push({
|
|
215
|
+
name: 'registry-collision',
|
|
216
|
+
ok: true,
|
|
217
|
+
message: `first publish (no prior version on registry)`,
|
|
218
|
+
});
|
|
219
|
+
} else {
|
|
220
|
+
const cmp = compareSemver(newVersion, published);
|
|
221
|
+
preflight.push({
|
|
222
|
+
name: 'registry-collision',
|
|
223
|
+
ok: cmp > 0,
|
|
224
|
+
message:
|
|
225
|
+
cmp > 0
|
|
226
|
+
? `OK (registry @ ${published}, new ${newVersion})`
|
|
227
|
+
: `registry @ ${published} ≥ new ${newVersion}; pick a higher bump`,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const preflightPassed = preflight.every((c) => c.ok);
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
projectRoot: opts.projectRoot,
|
|
235
|
+
stack: ctx.stack,
|
|
236
|
+
packageName,
|
|
237
|
+
currentVersion,
|
|
238
|
+
newVersion,
|
|
239
|
+
bump: opts.bump,
|
|
240
|
+
packageJsonPath: pkgJsonPath,
|
|
241
|
+
preflight,
|
|
242
|
+
preflightPassed,
|
|
243
|
+
delegatedToMise: false,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Execute the publish pipeline. Order is transactional:
|
|
249
|
+
* 1. bump package.json in-place (reversible by file restore).
|
|
250
|
+
* 2. `npm publish` (irreversible — first because npm errors are easier
|
|
251
|
+
* to recover from than a published-but-uncommitted state).
|
|
252
|
+
* 3. git add + commit + tag (reversible by reset/tag delete, but only
|
|
253
|
+
* a problem if step 2 succeeded — uncommitted bump after success
|
|
254
|
+
* is recoverable manually).
|
|
255
|
+
* 4. NOT pushed — that's the user's call.
|
|
256
|
+
*
|
|
257
|
+
* On npm publish failure → restore original package.json contents.
|
|
258
|
+
* Git failures after a successful npm publish leave the user in a
|
|
259
|
+
* partially-released state; they fix it manually (commit/tag what's
|
|
260
|
+
* needed) — we don't try to unpublish.
|
|
261
|
+
*/
|
|
262
|
+
export function executePublish(plan: PublishPlan): void {
|
|
263
|
+
if (plan.delegatedToMise) {
|
|
264
|
+
process.stderr.write('→ delegating to `mise run publish`...\n');
|
|
265
|
+
execFileSync('mise', ['run', 'publish'], {
|
|
266
|
+
cwd: plan.projectRoot,
|
|
267
|
+
stdio: 'inherit',
|
|
268
|
+
});
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// 1. Bump package.json in-place (keep original text for revert)
|
|
273
|
+
const originalText = fs.readFileSync(plan.packageJsonPath, 'utf8');
|
|
274
|
+
const pkg = JSON.parse(originalText);
|
|
275
|
+
pkg.version = plan.newVersion;
|
|
276
|
+
fs.writeFileSync(
|
|
277
|
+
plan.packageJsonPath,
|
|
278
|
+
JSON.stringify(pkg, null, 2) + '\n',
|
|
279
|
+
'utf8',
|
|
280
|
+
);
|
|
281
|
+
process.stderr.write(
|
|
282
|
+
`✓ bumped ${path.relative(plan.projectRoot, plan.packageJsonPath)}: ${plan.currentVersion} → ${plan.newVersion}\n`,
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
// 2. npm publish (interactive — OTP prompt may appear)
|
|
286
|
+
process.stderr.write(`→ running \`npm publish\` (OTP prompt may appear)...\n`);
|
|
287
|
+
const pkgDir = path.dirname(plan.packageJsonPath);
|
|
288
|
+
try {
|
|
289
|
+
execFileSync('npm', ['publish'], { cwd: pkgDir, stdio: 'inherit' });
|
|
290
|
+
} catch (e: any) {
|
|
291
|
+
fs.writeFileSync(plan.packageJsonPath, originalText, 'utf8');
|
|
292
|
+
process.stderr.write(
|
|
293
|
+
`✗ npm publish failed (exit ${e?.status ?? '?'}); reverted package.json\n`,
|
|
294
|
+
);
|
|
295
|
+
throw new Error('npm publish failed');
|
|
296
|
+
}
|
|
297
|
+
process.stderr.write(`✓ published ${plan.packageName}@${plan.newVersion}\n`);
|
|
298
|
+
|
|
299
|
+
// 3. git add + commit + tag (at project root)
|
|
300
|
+
execFileSync('git', ['add', plan.packageJsonPath], {
|
|
301
|
+
cwd: plan.projectRoot,
|
|
302
|
+
stdio: 'inherit',
|
|
303
|
+
});
|
|
304
|
+
execFileSync('git', ['commit', '-m', `release: v${plan.newVersion}`], {
|
|
305
|
+
cwd: plan.projectRoot,
|
|
306
|
+
stdio: 'inherit',
|
|
307
|
+
});
|
|
308
|
+
execFileSync(
|
|
309
|
+
'git',
|
|
310
|
+
['tag', '-a', `v${plan.newVersion}`, '-m', `v${plan.newVersion}`],
|
|
311
|
+
{ cwd: plan.projectRoot, stdio: 'inherit' },
|
|
312
|
+
);
|
|
313
|
+
process.stderr.write(`✓ committed + tagged v${plan.newVersion}\n`);
|
|
314
|
+
process.stderr.write(
|
|
315
|
+
`\n! NOT pushed — your call: \`git push --follow-tags\`\n`,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export function renderPublishPlan(plan: PublishPlan): string {
|
|
320
|
+
const lines: string[] = [];
|
|
321
|
+
lines.push('# vdx publish plan');
|
|
322
|
+
lines.push('');
|
|
323
|
+
if (plan.delegatedToMise) {
|
|
324
|
+
lines.push('- **Mode**: delegated to `[tasks.publish]` from mise.toml');
|
|
325
|
+
lines.push('');
|
|
326
|
+
lines.push('## Pre-flight checks');
|
|
327
|
+
lines.push('');
|
|
328
|
+
lines.push('| Check | Status | Message |');
|
|
329
|
+
lines.push('|-------|:------:|---------|');
|
|
330
|
+
for (const c of plan.preflight) {
|
|
331
|
+
lines.push(`| ${c.name} | ${c.ok ? 'OK' : 'FAIL'} | ${c.message} |`);
|
|
332
|
+
}
|
|
333
|
+
lines.push('');
|
|
334
|
+
lines.push('> vdx will exec `mise run publish` — no vdx-native pipeline involved.');
|
|
335
|
+
return lines.join('\n') + '\n';
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
lines.push(`- **Package**: ${plan.packageName}`);
|
|
339
|
+
lines.push(`- **Stack**: ${plan.stack}`);
|
|
340
|
+
lines.push(`- **Bump**: ${plan.bump}`);
|
|
341
|
+
lines.push(`- **Version**: ${plan.currentVersion} → ${plan.newVersion}`);
|
|
342
|
+
lines.push(`- **package.json**: ${plan.packageJsonPath}`);
|
|
343
|
+
lines.push('');
|
|
344
|
+
lines.push('## Pre-flight checks');
|
|
345
|
+
lines.push('');
|
|
346
|
+
lines.push('| Check | Status | Message |');
|
|
347
|
+
lines.push('|-------|:------:|---------|');
|
|
348
|
+
for (const c of plan.preflight) {
|
|
349
|
+
lines.push(`| ${c.name} | ${c.ok ? 'OK' : 'FAIL'} | ${c.message} |`);
|
|
350
|
+
}
|
|
351
|
+
lines.push('');
|
|
352
|
+
if (plan.preflightPassed) {
|
|
353
|
+
lines.push('## Pipeline (would execute on `vdx publish`)');
|
|
354
|
+
lines.push('');
|
|
355
|
+
lines.push(`1. Set \`version\` in package.json → \`${plan.newVersion}\``);
|
|
356
|
+
lines.push(`2. \`npm publish\` (cwd = directory containing package.json)`);
|
|
357
|
+
lines.push(`3. \`git add package.json && git commit -m "release: v${plan.newVersion}"\``);
|
|
358
|
+
lines.push(`4. \`git tag -a v${plan.newVersion} -m "v${plan.newVersion}"\``);
|
|
359
|
+
lines.push(`5. (NOT pushed — your call: \`git push --follow-tags\`)`);
|
|
360
|
+
} else {
|
|
361
|
+
lines.push('> Pre-flight failed. Use `--force` to bypass at your own risk.');
|
|
362
|
+
}
|
|
363
|
+
return lines.join('\n') + '\n';
|
|
364
|
+
}
|
package/src/run.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import * as TOML from 'smol-toml';
|
|
4
|
+
|
|
5
|
+
export const LIFECYCLE_VERBS = ['up', 'down', 'build', 'test', 'check', 'fix'] as const;
|
|
6
|
+
export type LifecycleVerb = (typeof LIFECYCLE_VERBS)[number];
|
|
7
|
+
|
|
8
|
+
export type VerbResolveResult =
|
|
9
|
+
| { ok: true; miseTomlPath: string }
|
|
10
|
+
| {
|
|
11
|
+
ok: false;
|
|
12
|
+
reason: 'no-mise-toml' | 'parse-error' | 'no-task';
|
|
13
|
+
detail?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function resolveLifecycleVerb(
|
|
17
|
+
projectRoot: string,
|
|
18
|
+
verb: string,
|
|
19
|
+
): VerbResolveResult {
|
|
20
|
+
const miseTomlPath = path.join(projectRoot, 'mise.toml');
|
|
21
|
+
if (!fs.existsSync(miseTomlPath)) {
|
|
22
|
+
return { ok: false, reason: 'no-mise-toml' };
|
|
23
|
+
}
|
|
24
|
+
let parsed: unknown;
|
|
25
|
+
try {
|
|
26
|
+
parsed = TOML.parse(fs.readFileSync(miseTomlPath, 'utf8'));
|
|
27
|
+
} catch (e: any) {
|
|
28
|
+
return { ok: false, reason: 'parse-error', detail: e?.message ?? String(e) };
|
|
29
|
+
}
|
|
30
|
+
const tasks = (parsed as { tasks?: Record<string, unknown> })?.tasks;
|
|
31
|
+
if (!tasks || typeof tasks !== 'object' || !(verb in tasks)) {
|
|
32
|
+
return { ok: false, reason: 'no-task' };
|
|
33
|
+
}
|
|
34
|
+
return { ok: true, miseTomlPath };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function renderResolveError(
|
|
38
|
+
res: Exclude<VerbResolveResult, { ok: true }>,
|
|
39
|
+
projectRoot: string,
|
|
40
|
+
verb: string,
|
|
41
|
+
): string {
|
|
42
|
+
switch (res.reason) {
|
|
43
|
+
case 'no-mise-toml':
|
|
44
|
+
return (
|
|
45
|
+
`vdx: no mise.toml in ${projectRoot}\n` +
|
|
46
|
+
`hint: run \`vdx init .\` to generate one, or define it manually.\n`
|
|
47
|
+
);
|
|
48
|
+
case 'parse-error':
|
|
49
|
+
return `vdx: failed to parse mise.toml: ${res.detail ?? 'unknown error'}\n`;
|
|
50
|
+
case 'no-task':
|
|
51
|
+
return (
|
|
52
|
+
`vdx: no \`[tasks.${verb}]\` in mise.toml\n` +
|
|
53
|
+
`hint: run \`vdx init . --force\` to re-detect, or add the task manually.\n`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|