@vodmal/vdx-cli 0.2.1 → 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 +7 -3
- package/rubric/vdx-rubric.yaml +82 -1
- package/src/audit.ts +11 -0
- package/src/index.ts +75 -3
- package/src/init.ts +290 -30
- package/src/mcp-server.ts +1 -1
- package/src/publish.ts +364 -0
- package/src/rubric.ts +1 -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",
|
|
@@ -39,7 +39,9 @@
|
|
|
39
39
|
"audit": "tsx src/index.ts audit",
|
|
40
40
|
"smoke": "bash smoke.sh",
|
|
41
41
|
"typecheck": "tsc --noEmit",
|
|
42
|
-
"test": "
|
|
42
|
+
"test": "vitest run",
|
|
43
|
+
"test:unit": "vitest run tests/unit",
|
|
44
|
+
"coverage": "vitest run --coverage"
|
|
43
45
|
},
|
|
44
46
|
"dependencies": {
|
|
45
47
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
@@ -51,7 +53,9 @@
|
|
|
51
53
|
"devDependencies": {
|
|
52
54
|
"@types/js-yaml": "^4.0.9",
|
|
53
55
|
"@types/node": "^22.0.0",
|
|
54
|
-
"
|
|
56
|
+
"@vitest/coverage-v8": "^4.1.7",
|
|
57
|
+
"typescript": "^5.6.0",
|
|
58
|
+
"vitest": "^4.1.7"
|
|
55
59
|
},
|
|
56
60
|
"publishConfig": {
|
|
57
61
|
"access": "public"
|
package/rubric/vdx-rubric.yaml
CHANGED
|
@@ -11,7 +11,7 @@ schema_version: "0.2"
|
|
|
11
11
|
|
|
12
12
|
metadata:
|
|
13
13
|
name: vdx-rubric-vodmal
|
|
14
|
-
version: "0.
|
|
14
|
+
version: "0.3.1"
|
|
15
15
|
description: "Personal maturity rubric for multi-stack projects (vodmal)"
|
|
16
16
|
owner: vodmal
|
|
17
17
|
homepage: "https://github.com/vodmal/vdx-rubric"
|
|
@@ -534,3 +534,84 @@ axes:
|
|
|
534
534
|
file_contains:
|
|
535
535
|
paths: [misc/traefik-global/README.md, misc/traefik-global/start.sh]
|
|
536
536
|
pattern: "@v\\d+\\.\\d+\\.\\d+" # semver pin
|
|
537
|
+
|
|
538
|
+
- id: release-artifact
|
|
539
|
+
class: supporting
|
|
540
|
+
description: "Publish-readiness: metadata completeness + entry points + registry signals (O34, v0.3.0). Применяется только к проектам, заявляющим publish-intent (O35, v0.3.1) — иначе excluded."
|
|
541
|
+
applies_to: [node, php, ruby, python]
|
|
542
|
+
# O35 (v0.3.1): ось применяется только если проект декларирует намерение
|
|
543
|
+
# публиковать артефакт. Чистые app'ы (private:true в package.json или
|
|
544
|
+
# composer type:project) → excluded.
|
|
545
|
+
applies_when:
|
|
546
|
+
any_of:
|
|
547
|
+
# Node-сторона: НЕ private И есть хоть один entry-point/publish-signal.
|
|
548
|
+
- all_of:
|
|
549
|
+
- has_file: package.json
|
|
550
|
+
- not:
|
|
551
|
+
config_value: { path: package.json, jsonpath: "private", equals: true }
|
|
552
|
+
- any_of:
|
|
553
|
+
- config_value: { path: package.json, jsonpath: "publishConfig", op: present }
|
|
554
|
+
- config_value: { path: package.json, jsonpath: "bin", op: present }
|
|
555
|
+
- config_value: { path: package.json, jsonpath: "main", op: present }
|
|
556
|
+
- config_value: { path: package.json, jsonpath: "exports", op: present }
|
|
557
|
+
- config_value: { path: package.json, jsonpath: "module", op: present }
|
|
558
|
+
# PHP-сторона: composer.json есть, name задан, type ≠ project (project = app).
|
|
559
|
+
- all_of:
|
|
560
|
+
- has_file: composer.json
|
|
561
|
+
- config_value: { path: composer.json, jsonpath: "name", op: present }
|
|
562
|
+
- not:
|
|
563
|
+
config_value: { path: composer.json, jsonpath: "type", op: equals, equals: "project" }
|
|
564
|
+
storage: level
|
|
565
|
+
default_target: L4
|
|
566
|
+
fact_sources:
|
|
567
|
+
- package.json
|
|
568
|
+
- composer.json
|
|
569
|
+
- LICENSE
|
|
570
|
+
- LICENSE.md
|
|
571
|
+
- LICENSE.txt
|
|
572
|
+
levels:
|
|
573
|
+
L1:
|
|
574
|
+
requires:
|
|
575
|
+
any_of:
|
|
576
|
+
- all_of:
|
|
577
|
+
- config_value: { path: package.json, jsonpath: "name", op: present }
|
|
578
|
+
- config_value: { path: package.json, jsonpath: "version", op: present }
|
|
579
|
+
- config_value: { path: package.json, jsonpath: "license", op: present }
|
|
580
|
+
- all_of:
|
|
581
|
+
- config_value: { path: composer.json, jsonpath: "name", op: present }
|
|
582
|
+
- config_value: { path: composer.json, jsonpath: "license", op: present }
|
|
583
|
+
L2:
|
|
584
|
+
requires:
|
|
585
|
+
any_of:
|
|
586
|
+
- all_of:
|
|
587
|
+
- config_value: { path: package.json, jsonpath: "description", op: present }
|
|
588
|
+
- config_value: { path: package.json, jsonpath: "repository", op: present }
|
|
589
|
+
- any_of:
|
|
590
|
+
- has_file: LICENSE
|
|
591
|
+
- has_file: LICENSE.md
|
|
592
|
+
- has_file: LICENSE.txt
|
|
593
|
+
- all_of:
|
|
594
|
+
- config_value: { path: composer.json, jsonpath: "description", op: present }
|
|
595
|
+
- any_of:
|
|
596
|
+
- has_file: LICENSE
|
|
597
|
+
- has_file: LICENSE.md
|
|
598
|
+
L3:
|
|
599
|
+
requires:
|
|
600
|
+
any_of:
|
|
601
|
+
- all_of:
|
|
602
|
+
- config_value: { path: package.json, jsonpath: "files", op: present }
|
|
603
|
+
- any_of:
|
|
604
|
+
- config_value: { path: package.json, jsonpath: "bin", op: present }
|
|
605
|
+
- config_value: { path: package.json, jsonpath: "main", op: present }
|
|
606
|
+
- config_value: { path: package.json, jsonpath: "exports", op: present }
|
|
607
|
+
- all_of:
|
|
608
|
+
- config_value: { path: composer.json, jsonpath: "autoload", op: present }
|
|
609
|
+
- config_value: { path: composer.json, jsonpath: "type", op: present }
|
|
610
|
+
L4:
|
|
611
|
+
requires:
|
|
612
|
+
any_of:
|
|
613
|
+
- all_of:
|
|
614
|
+
- config_value: { path: package.json, jsonpath: "publishConfig", op: present }
|
|
615
|
+
- config_value: { path: package.json, jsonpath: "homepage", op: present }
|
|
616
|
+
- config_value: { path: package.json, jsonpath: "bugs", op: present }
|
|
617
|
+
- config_value: { path: composer.json, jsonpath: "extra.publish", op: present }
|
package/src/audit.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as fs from 'node:fs';
|
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import type { Rubric, LevelName } from './rubric.ts';
|
|
4
4
|
import { findSubPackages, stackForDir, type Ctx } from './facts.ts';
|
|
5
|
+
import { evalPredicate } from './evaluator.ts';
|
|
5
6
|
import type { Override, VdxManifest } from './manifest.ts';
|
|
6
7
|
import { evalAxis, projectLevel, type AxisResult } from './scoring.ts';
|
|
7
8
|
|
|
@@ -115,6 +116,16 @@ export function audit(
|
|
|
115
116
|
});
|
|
116
117
|
continue;
|
|
117
118
|
}
|
|
119
|
+
if (axis.applies_when && !evalPredicate(axis.applies_when, evalCtx)) {
|
|
120
|
+
perAxis.push({
|
|
121
|
+
axis_id: axis.id,
|
|
122
|
+
class: axis.class,
|
|
123
|
+
achieved: 'L0',
|
|
124
|
+
target: axis.default_target,
|
|
125
|
+
drift_kind: 'excluded',
|
|
126
|
+
});
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
118
129
|
const achieved = evalAxis(axis, evalCtx);
|
|
119
130
|
const ov = overrideByAxis.get(axis.id);
|
|
120
131
|
const target = (ov?.target as LevelName) ?? axis.default_target;
|
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);
|
|
@@ -86,10 +94,19 @@ function cmdInit(opts: ParsedArgs): void {
|
|
|
86
94
|
|
|
87
95
|
const baselineFlag = opts.flags.baseline;
|
|
88
96
|
const baseline = typeof baselineFlag === 'string' ? baselineFlag : undefined;
|
|
89
|
-
const
|
|
97
|
+
const stackFlag = opts.flags.stack;
|
|
98
|
+
const stackOverride = typeof stackFlag === 'string' ? stackFlag : undefined;
|
|
99
|
+
const plan = planInit(projectRoot, {
|
|
100
|
+
...(baseline ? { baseline } : {}),
|
|
101
|
+
...(stackOverride ? { stack: stackOverride } : {}),
|
|
102
|
+
});
|
|
90
103
|
|
|
91
104
|
process.stdout.write(renderPlanSummary(plan));
|
|
92
105
|
|
|
106
|
+
for (const w of plan.warnings) {
|
|
107
|
+
process.stderr.write(`\n⚠ ${w}\n`);
|
|
108
|
+
}
|
|
109
|
+
|
|
93
110
|
if (opts.flags['dry-run']) {
|
|
94
111
|
process.stderr.write('\n[dry-run] mise.toml / AGENTS.md не записаны.\n');
|
|
95
112
|
return;
|
|
@@ -105,7 +122,62 @@ function cmdInit(opts: ParsedArgs): void {
|
|
|
105
122
|
}
|
|
106
123
|
}
|
|
107
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
|
+
|
|
108
179
|
const parsed = parseArgs(process.argv);
|
|
109
180
|
if (parsed.cmd === 'audit') cmdAudit(parsed);
|
|
110
181
|
else if (parsed.cmd === 'init') cmdInit(parsed);
|
|
182
|
+
else if (parsed.cmd === 'publish') cmdPublish(parsed);
|
|
111
183
|
else usage();
|
package/src/init.ts
CHANGED
|
@@ -7,20 +7,72 @@ import {
|
|
|
7
7
|
readText,
|
|
8
8
|
listAllTasks,
|
|
9
9
|
autoDetectStack,
|
|
10
|
+
findSubPackages,
|
|
11
|
+
type SubPackage,
|
|
10
12
|
} from './facts.ts';
|
|
11
13
|
|
|
12
14
|
export const STANDARD_VERBS = ['up', 'down', 'build', 'test', 'check', 'fix'] as const;
|
|
13
15
|
export type Verb = (typeof STANDARD_VERBS)[number];
|
|
14
16
|
|
|
15
|
-
export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v0.
|
|
17
|
+
export const DEFAULT_BASELINE = 'github.com/VoDmAl/vdx-rubric-vodmal@v0.3.1';
|
|
16
18
|
|
|
17
19
|
const VERB_ALIASES: Record<Verb, string[]> = {
|
|
18
|
-
up: ['docker:up', 'docker-up', 'docker:up:detached', 'start'],
|
|
20
|
+
up: ['docker:up', 'docker-up', 'docker:up:detached', 'start', 'dev', 'serve'],
|
|
19
21
|
down: ['docker:down', 'docker-down', 'stop'],
|
|
20
|
-
build: [
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
+
],
|
|
24
76
|
};
|
|
25
77
|
|
|
26
78
|
const VERB_PREFIX_GROUPS: Record<Verb, string[]> = {
|
|
@@ -32,15 +84,41 @@ const VERB_PREFIX_GROUPS: Record<Verb, string[]> = {
|
|
|
32
84
|
fix: ['fix:', 'fix-'],
|
|
33
85
|
};
|
|
34
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
|
+
|
|
35
100
|
export interface VerbMapping {
|
|
36
101
|
verb: Verb;
|
|
37
102
|
nativeTask: string | null;
|
|
38
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[];
|
|
39
113
|
}
|
|
40
114
|
|
|
41
115
|
export interface InitPlan {
|
|
42
116
|
projectRoot: string;
|
|
43
117
|
stack: string;
|
|
118
|
+
/** Was `stack` derived from autoDetectStack (false) or supplied via --stack (true). */
|
|
119
|
+
stackOverridden: boolean;
|
|
120
|
+
/** Non-null only when stack === 'meta' and resolved a single nested manifest. */
|
|
121
|
+
primarySubpackage: string | null;
|
|
44
122
|
pkgManager: 'composer' | 'npm' | 'pnpm' | 'yarn' | null;
|
|
45
123
|
tools: Record<string, string>;
|
|
46
124
|
mappings: VerbMapping[];
|
|
@@ -49,6 +127,8 @@ export interface InitPlan {
|
|
|
49
127
|
agentsMdPath: string;
|
|
50
128
|
agentsMdContent: string;
|
|
51
129
|
baseline: string;
|
|
130
|
+
/** Human-readable advisories for the user — printed to stderr by the CLI. */
|
|
131
|
+
warnings: string[];
|
|
52
132
|
}
|
|
53
133
|
|
|
54
134
|
function detectNodePackageManager(projectRoot: string): 'pnpm' | 'yarn' | 'npm' {
|
|
@@ -67,6 +147,15 @@ function renderRunCommand(task: string, stack: string, projectRoot: string): str
|
|
|
67
147
|
return task;
|
|
68
148
|
}
|
|
69
149
|
|
|
150
|
+
function renderRunCommandInSubpackage(
|
|
151
|
+
task: string,
|
|
152
|
+
subStack: string,
|
|
153
|
+
subAbs: string,
|
|
154
|
+
subRel: string,
|
|
155
|
+
): string {
|
|
156
|
+
return `cd ${subRel} && ${renderRunCommand(task, subStack, subAbs)}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
70
159
|
function pickPrefixGroup(prefixes: string[], tasks: Set<string>, verb: Verb): string | null {
|
|
71
160
|
if (prefixes.length === 0) return null;
|
|
72
161
|
// Prefer entries that look like a canonical "prod/dist/release" first, then any.
|
|
@@ -89,12 +178,74 @@ function pickPrefixGroup(prefixes: string[], tasks: Set<string>, verb: Verb): st
|
|
|
89
178
|
return candidates[0] ?? null;
|
|
90
179
|
}
|
|
91
180
|
|
|
92
|
-
|
|
93
|
-
if (
|
|
94
|
-
|
|
95
|
-
|
|
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
|
+
}
|
|
96
192
|
}
|
|
97
|
-
return
|
|
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
|
+
}
|
|
207
|
+
}
|
|
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;
|
|
98
249
|
}
|
|
99
250
|
|
|
100
251
|
function collectTools(ctx: Ctx): Record<string, string> {
|
|
@@ -135,12 +286,23 @@ export function renderMiseToml(
|
|
|
135
286
|
mappings: VerbMapping[],
|
|
136
287
|
stack: string,
|
|
137
288
|
baseline: string,
|
|
289
|
+
primarySubpackage: string | null = null,
|
|
138
290
|
): string {
|
|
139
291
|
const out: string[] = [];
|
|
140
292
|
out.push('# Generated by vdx init. The [vdx] block is metadata for vdx audit.');
|
|
141
293
|
out.push('# Lifecycle verbs follow the vdx contract: up/down/build/test/check/fix.');
|
|
142
294
|
out.push('');
|
|
143
295
|
|
|
296
|
+
if (stack === 'unknown' || stack === 'monorepo') {
|
|
297
|
+
out.push(
|
|
298
|
+
'# TODO(vdx): stack auto-detection вернул "' +
|
|
299
|
+
stack +
|
|
300
|
+
'". Замени на php/node/go/python/meta',
|
|
301
|
+
);
|
|
302
|
+
out.push('# и заполни [tasks.*] блоки руками, либо перегенери: vdx init . --stack <id>.');
|
|
303
|
+
out.push('');
|
|
304
|
+
}
|
|
305
|
+
|
|
144
306
|
if (Object.keys(tools).length > 0) {
|
|
145
307
|
out.push('[tools]');
|
|
146
308
|
for (const [k, v] of Object.entries(tools)) {
|
|
@@ -151,6 +313,10 @@ export function renderMiseToml(
|
|
|
151
313
|
|
|
152
314
|
for (const m of mappings) {
|
|
153
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
|
+
}
|
|
154
320
|
out.push(`[tasks.${m.verb}]`);
|
|
155
321
|
out.push(`description = ${quoteToml(VERB_DESCRIPTIONS[m.verb])}`);
|
|
156
322
|
out.push(`run = ${quoteToml(m.runCommand)}`);
|
|
@@ -161,6 +327,9 @@ export function renderMiseToml(
|
|
|
161
327
|
out.push('schema_version = "0.2"');
|
|
162
328
|
out.push(`baseline = ${quoteToml(baseline)}`);
|
|
163
329
|
out.push(`stack = ${quoteToml(stack)}`);
|
|
330
|
+
if (primarySubpackage) {
|
|
331
|
+
out.push(`primary_subpackage = ${quoteToml(primarySubpackage)}`);
|
|
332
|
+
}
|
|
164
333
|
const verbsList = mappings
|
|
165
334
|
.filter((m) => m.runCommand)
|
|
166
335
|
.map((m) => quoteToml(m.verb))
|
|
@@ -217,36 +386,99 @@ export function buildAgentsMd(existing: string | null, section: string): string
|
|
|
217
386
|
return existing + sep + section + '\n';
|
|
218
387
|
}
|
|
219
388
|
|
|
220
|
-
export function planInit(
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
389
|
+
export function planInit(
|
|
390
|
+
projectRoot: string,
|
|
391
|
+
opts: { baseline?: string; stack?: string } = {},
|
|
392
|
+
): InitPlan {
|
|
393
|
+
const detectedStack = autoDetectStack(projectRoot);
|
|
394
|
+
const stackOverridden = opts.stack !== undefined && opts.stack !== detectedStack;
|
|
395
|
+
const stack = opts.stack ?? detectedStack;
|
|
396
|
+
const warnings: string[] = [];
|
|
397
|
+
|
|
398
|
+
// For meta-stack — try to resolve a single nested subpackage and scan it for tasks.
|
|
399
|
+
let primarySubpackage: string | null = null;
|
|
400
|
+
let scanRoot = projectRoot;
|
|
401
|
+
let scanStack = stack;
|
|
402
|
+
|
|
403
|
+
if (stack === 'meta') {
|
|
404
|
+
const subs: SubPackage[] = findSubPackages(projectRoot);
|
|
405
|
+
if (subs.length === 1) {
|
|
406
|
+
primarySubpackage = subs[0]!.relPath;
|
|
407
|
+
scanRoot = path.join(projectRoot, primarySubpackage);
|
|
408
|
+
scanStack = subs[0]!.stack;
|
|
409
|
+
} else if (subs.length === 0) {
|
|
410
|
+
warnings.push(
|
|
411
|
+
'stack="meta", но nested manifest не найден. mise.toml будет иметь пустой verbs[]; ' +
|
|
412
|
+
'заполни [tasks.*] руками.',
|
|
413
|
+
);
|
|
414
|
+
} else {
|
|
415
|
+
const list = subs.map((s) => `${s.relPath}(${s.stack})`).join(', ');
|
|
416
|
+
warnings.push(
|
|
417
|
+
`stack="meta", найдено несколько subpackages: ${list}. ` +
|
|
418
|
+
'primary_subpackage не выбран; укажи его руками в [vdx] блоке.',
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
} else if (!stackOverridden && (detectedStack === 'unknown' || detectedStack === 'monorepo')) {
|
|
422
|
+
if (detectedStack === 'unknown') {
|
|
423
|
+
warnings.push(
|
|
424
|
+
'autoDetectStack вернул "unknown" — в корне нет manifest и нет subpackages. ' +
|
|
425
|
+
'Передай --stack <id> (php|node|go|python|meta) либо отредактируй mise.toml вручную.',
|
|
426
|
+
);
|
|
427
|
+
} else {
|
|
428
|
+
const subs = findSubPackages(projectRoot);
|
|
429
|
+
const list = subs.map((s) => `${s.relPath}(${s.stack})`).join(', ');
|
|
430
|
+
warnings.push(
|
|
431
|
+
`autoDetectStack вернул "monorepo": ${list}. ` +
|
|
432
|
+
'Передай --stack <id> чтобы выбрать конкретный стек, либо --stack meta + укажи primary_subpackage.',
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const scanCtx: Ctx = { projectRoot: scanRoot, stack: scanStack, cache: new Map() };
|
|
438
|
+
const tasks = listAllTasks(scanCtx);
|
|
224
439
|
|
|
225
440
|
const mappings: VerbMapping[] = STANDARD_VERBS.map((verb) => {
|
|
226
|
-
const
|
|
441
|
+
const sel = selectVerbTask(verb, tasks);
|
|
442
|
+
let runCommand: string | null = null;
|
|
443
|
+
if (sel.task) {
|
|
444
|
+
runCommand =
|
|
445
|
+
primarySubpackage !== null
|
|
446
|
+
? renderRunCommandInSubpackage(sel.task, scanStack, scanRoot, primarySubpackage)
|
|
447
|
+
: renderRunCommand(sel.task, stack, projectRoot);
|
|
448
|
+
}
|
|
227
449
|
return {
|
|
228
450
|
verb,
|
|
229
|
-
nativeTask:
|
|
230
|
-
runCommand
|
|
451
|
+
nativeTask: sel.task,
|
|
452
|
+
runCommand,
|
|
453
|
+
reason: sel.reason,
|
|
454
|
+
alternatives: sel.alternatives,
|
|
231
455
|
};
|
|
232
456
|
});
|
|
233
457
|
|
|
234
|
-
|
|
458
|
+
// tools читаем из root независимо: для PHP/Node корневой manifest часто
|
|
459
|
+
// содержит engines/require php; для meta-проектов вынос tool-версий в
|
|
460
|
+
// root mise.toml — общая практика.
|
|
461
|
+
const rootCtx: Ctx = { projectRoot, stack, cache: new Map() };
|
|
462
|
+
const tools = collectTools(rootCtx);
|
|
235
463
|
const baseline = opts.baseline ?? DEFAULT_BASELINE;
|
|
236
|
-
const miseTomlContent = renderMiseToml(tools, mappings, stack, baseline);
|
|
464
|
+
const miseTomlContent = renderMiseToml(tools, mappings, stack, baseline, primarySubpackage);
|
|
237
465
|
|
|
238
466
|
const agentsMdPath = path.join(projectRoot, 'AGENTS.md');
|
|
239
|
-
const existingAgents = fileExists(
|
|
467
|
+
const existingAgents = fileExists(rootCtx, 'AGENTS.md') ? readText(rootCtx, 'AGENTS.md') : null;
|
|
240
468
|
const section = renderAgentsCommandsSection(mappings, baseline);
|
|
241
469
|
const agentsMdContent = buildAgentsMd(existingAgents, section);
|
|
242
470
|
|
|
243
471
|
let pkgManager: InitPlan['pkgManager'] = null;
|
|
244
|
-
|
|
245
|
-
|
|
472
|
+
const pmStack = primarySubpackage ? scanStack : stack;
|
|
473
|
+
const pmRoot = primarySubpackage ? scanRoot : projectRoot;
|
|
474
|
+
if (pmStack === 'php') pkgManager = 'composer';
|
|
475
|
+
else if (pmStack === 'node') pkgManager = detectNodePackageManager(pmRoot);
|
|
246
476
|
|
|
247
477
|
return {
|
|
248
478
|
projectRoot,
|
|
249
479
|
stack,
|
|
480
|
+
stackOverridden,
|
|
481
|
+
primarySubpackage,
|
|
250
482
|
pkgManager,
|
|
251
483
|
tools,
|
|
252
484
|
mappings,
|
|
@@ -255,6 +487,7 @@ export function planInit(projectRoot: string, opts: { baseline?: string } = {}):
|
|
|
255
487
|
agentsMdPath,
|
|
256
488
|
agentsMdContent,
|
|
257
489
|
baseline,
|
|
490
|
+
warnings,
|
|
258
491
|
};
|
|
259
492
|
}
|
|
260
493
|
|
|
@@ -272,10 +505,23 @@ export function renderPlanSummary(plan: InitPlan): string {
|
|
|
272
505
|
const lines: string[] = [];
|
|
273
506
|
lines.push(`# vdx init plan`);
|
|
274
507
|
lines.push('');
|
|
275
|
-
lines.push(
|
|
276
|
-
|
|
508
|
+
lines.push(
|
|
509
|
+
`- **Project**: ${plan.projectRoot}`,
|
|
510
|
+
);
|
|
511
|
+
lines.push(
|
|
512
|
+
`- **Stack**: ${plan.stack}${plan.stackOverridden ? ' (via --stack)' : ''}`,
|
|
513
|
+
);
|
|
514
|
+
if (plan.primarySubpackage) {
|
|
515
|
+
lines.push(`- **Primary subpackage**: ${plan.primarySubpackage}`);
|
|
516
|
+
}
|
|
277
517
|
if (plan.pkgManager) lines.push(`- **Package manager**: ${plan.pkgManager}`);
|
|
278
518
|
lines.push(`- **Baseline**: ${plan.baseline}`);
|
|
519
|
+
if (plan.warnings.length > 0) {
|
|
520
|
+
lines.push('');
|
|
521
|
+
lines.push('## ⚠ Warnings');
|
|
522
|
+
lines.push('');
|
|
523
|
+
for (const w of plan.warnings) lines.push(`- ${w}`);
|
|
524
|
+
}
|
|
279
525
|
if (Object.keys(plan.tools).length > 0) {
|
|
280
526
|
const toolStr = Object.entries(plan.tools)
|
|
281
527
|
.map(([k, v]) => `${k}=${v}`)
|
|
@@ -285,19 +531,33 @@ export function renderPlanSummary(plan: InitPlan): string {
|
|
|
285
531
|
lines.push('');
|
|
286
532
|
lines.push('## Verb mappings');
|
|
287
533
|
lines.push('');
|
|
288
|
-
lines.push('| Verb | Native task | Generated `run` |');
|
|
289
|
-
lines.push('
|
|
534
|
+
lines.push('| Verb | Native task | Reason | Generated `run` |');
|
|
535
|
+
lines.push('|------|-------------|--------|-----------------|');
|
|
290
536
|
for (const m of plan.mappings) {
|
|
291
537
|
if (m.runCommand) {
|
|
292
|
-
lines.push(
|
|
538
|
+
lines.push(
|
|
539
|
+
`| \`${m.verb}\` | \`${m.nativeTask}\` | ${m.reason} | \`${m.runCommand}\` |`,
|
|
540
|
+
);
|
|
293
541
|
} else {
|
|
294
|
-
lines.push(`| \`${m.verb}\` | _(not found)_ | _(skipped)_ |`);
|
|
542
|
+
lines.push(`| \`${m.verb}\` | _(not found)_ | not-found | _(skipped)_ |`);
|
|
295
543
|
}
|
|
296
544
|
}
|
|
297
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
|
+
}
|
|
298
558
|
const unmatched = plan.mappings.filter((m) => !m.runCommand).map((m) => m.verb);
|
|
299
559
|
if (unmatched.length > 0) {
|
|
300
|
-
lines.push(`> Not mapped: ${unmatched.join(', ')}.
|
|
560
|
+
lines.push(`> Not mapped: ${unmatched.join(', ')}. Добавь тиски в \`mise.toml\` вручную.`);
|
|
301
561
|
lines.push('');
|
|
302
562
|
}
|
|
303
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/rubric.ts
CHANGED