@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vodmal/vdx-cli",
3
- "version": "0.3.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 <project_path> [--rubric <path>] [--stack <stack>] [--json]
17
- vdx init <project_path> [--stack <id>] [--baseline <ref>] [--dry-run] [--force]
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: ['docker:build', 'build:prod', 'build-prod', 'build:dev', 'build-dev', 'build:assets'],
23
- test: ['phpunit', 'test:unit', 'test-unit', 'test:unit:phpunit', 'jest', 'vitest'],
24
- check: ['lint', 'check:config', 'check:before:commit', 'check:code:static', 'check:code:lint'],
25
- fix: ['fix:ecs', 'format', 'lint:fix'],
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
- export function mapVerb(verb: Verb, tasks: Set<string>): string | null {
110
- if (tasks.has(verb)) return verb;
111
- for (const alt of VERB_ALIASES[verb]) {
112
- if (tasks.has(alt)) return alt;
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 pickPrefixGroup(VERB_PREFIX_GROUPS[verb], tasks, verb);
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 native = mapVerb(verb, tasks);
441
+ const sel = selectVerbTask(verb, tasks);
304
442
  let runCommand: string | null = null;
305
- if (native) {
443
+ if (sel.task) {
306
444
  runCommand =
307
445
  primarySubpackage !== null
308
- ? renderRunCommandInSubpackage(native, scanStack, scanRoot, primarySubpackage)
309
- : renderRunCommand(native, stack, projectRoot);
446
+ ? renderRunCommandInSubpackage(sel.task, scanStack, scanRoot, primarySubpackage)
447
+ : renderRunCommand(sel.task, stack, projectRoot);
310
448
  }
311
- return { verb, nativeTask: native, runCommand };
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(`| \`${m.verb}\` | \`${m.nativeTask}\` | \`${m.runCommand}\` |`);
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(', ')}. Add тиски в \`mise.toml\` вручную.`);
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.2.1',
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
+ }