@vodmal/vdx-cli 0.3.0 → 0.4.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/package.json +1 -1
- package/src/index.ts +65 -2
- package/src/init.ts +178 -20
- package/src/mcp-server.ts +1 -1
- package/src/publish.ts +364 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vodmal/vdx-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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
|
@@ -6,6 +6,13 @@ import { autoDetectStack, type Ctx } from './facts.ts';
|
|
|
6
6
|
import { audit } from './audit.ts';
|
|
7
7
|
import { reportMarkdown, reportJson } from './report.ts';
|
|
8
8
|
import { planInit, writeInit, renderPlanSummary } from './init.ts';
|
|
9
|
+
import {
|
|
10
|
+
planPublish,
|
|
11
|
+
renderPublishPlan,
|
|
12
|
+
executePublish,
|
|
13
|
+
type BumpKind,
|
|
14
|
+
type PublishOptions,
|
|
15
|
+
} from './publish.ts';
|
|
9
16
|
import { resolveDefaultRubric } from './defaults.ts';
|
|
10
17
|
|
|
11
18
|
const DEFAULT_RUBRIC = resolveDefaultRubric();
|
|
@@ -13,8 +20,9 @@ const DEFAULT_RUBRIC = resolveDefaultRubric();
|
|
|
13
20
|
function usage(): never {
|
|
14
21
|
process.stderr.write(
|
|
15
22
|
`Usage:
|
|
16
|
-
vdx audit
|
|
17
|
-
vdx init
|
|
23
|
+
vdx audit <project_path> [--rubric <path>] [--stack <stack>] [--json]
|
|
24
|
+
vdx init <project_path> [--stack <id>] [--baseline <ref>] [--dry-run] [--force]
|
|
25
|
+
vdx publish <patch|minor|major> [--dry-run] [--force]
|
|
18
26
|
`,
|
|
19
27
|
);
|
|
20
28
|
process.exit(1);
|
|
@@ -114,7 +122,62 @@ function cmdInit(opts: ParsedArgs): void {
|
|
|
114
122
|
}
|
|
115
123
|
}
|
|
116
124
|
|
|
125
|
+
function cmdPublish(opts: ParsedArgs): void {
|
|
126
|
+
const bumpArg = opts.positionals[0];
|
|
127
|
+
if (!bumpArg || !['patch', 'minor', 'major'].includes(bumpArg)) {
|
|
128
|
+
process.stderr.write(
|
|
129
|
+
'Usage: vdx publish <patch|minor|major> [--dry-run] [--force]\n',
|
|
130
|
+
);
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
const bump = bumpArg as BumpKind;
|
|
134
|
+
const projectRoot = process.cwd();
|
|
135
|
+
|
|
136
|
+
const manifest = loadManifest(projectRoot);
|
|
137
|
+
const overrides = loadOverrides(projectRoot);
|
|
138
|
+
const rubric = loadRubric(DEFAULT_RUBRIC);
|
|
139
|
+
const stack = manifest?.stack || autoDetectStack(projectRoot);
|
|
140
|
+
const ctx: Ctx = { projectRoot, stack, cache: new Map() };
|
|
141
|
+
const baselineRef = manifest?.baseline ?? `file://${DEFAULT_RUBRIC}`;
|
|
142
|
+
const auditResult = audit(rubric, ctx, overrides, baselineRef, manifest);
|
|
143
|
+
|
|
144
|
+
const planOpts: PublishOptions = {
|
|
145
|
+
projectRoot,
|
|
146
|
+
bump,
|
|
147
|
+
dryRun: Boolean(opts.flags['dry-run']),
|
|
148
|
+
force: Boolean(opts.flags.force),
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
let plan;
|
|
152
|
+
try {
|
|
153
|
+
plan = planPublish(planOpts, auditResult, ctx);
|
|
154
|
+
} catch (e: any) {
|
|
155
|
+
process.stderr.write(`error: ${e?.message ?? String(e)}\n`);
|
|
156
|
+
process.exit(2);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
process.stdout.write(renderPublishPlan(plan));
|
|
160
|
+
|
|
161
|
+
if (planOpts.dryRun) {
|
|
162
|
+
process.stderr.write('\n[dry-run] не выполнено.\n');
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!plan.preflightPassed && !planOpts.force) {
|
|
167
|
+
process.stderr.write('\nerror: pre-flight failed (use --force to bypass)\n');
|
|
168
|
+
process.exit(2);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
executePublish(plan);
|
|
173
|
+
} catch (e: any) {
|
|
174
|
+
process.stderr.write(`\nerror: ${e?.message ?? String(e)}\n`);
|
|
175
|
+
process.exit(3);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
117
179
|
const parsed = parseArgs(process.argv);
|
|
118
180
|
if (parsed.cmd === 'audit') cmdAudit(parsed);
|
|
119
181
|
else if (parsed.cmd === 'init') cmdInit(parsed);
|
|
182
|
+
else if (parsed.cmd === 'publish') cmdPublish(parsed);
|
|
120
183
|
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
|
+
}
|