@vodmal/vdx-cli 0.25.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +16 -6
- package/rubric/vdx-rubric.yaml +45 -9
- package/src/ai.ts +48 -49
- package/src/author.ts +1 -1
- package/src/clone-checks.ts +1 -1
- package/src/conversations.ts +33 -26
- package/src/doctor.ts +10 -9
- package/src/evaluator.ts +22 -21
- package/src/facts.ts +24 -49
- package/src/gha.ts +36 -28
- package/src/hooks.ts +34 -34
- package/src/index.ts +22 -13
- package/src/init.ts +12 -20
- package/src/manifest.ts +25 -4
- package/src/mcp-server.ts +39 -35
- package/src/narrow.ts +56 -0
- package/src/personal-hooks.ts +2 -8
- package/src/predicates.ts +10 -11
- package/src/publish.ts +28 -26
- package/src/rubric.ts +6 -6
- package/src/run.ts +3 -2
- package/src/vocabulary.ts +10 -14
package/src/doctor.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
6
6
|
import { effectiveAuthor, isGitRepo, rankAuthors, scanPool } from './author.ts';
|
|
7
7
|
import { authorPoolDirs, loadEnvironment, resolveEnvironmentPath, shellQuote, type Environment } from './ai.ts';
|
|
8
8
|
import { checkRepoHooks } from './clone-checks.ts';
|
|
9
|
+
import { at, dict } from './narrow.ts';
|
|
9
10
|
import {
|
|
10
11
|
checkPersonalHooks,
|
|
11
12
|
defaultPersonalHookDeps,
|
|
@@ -98,24 +99,24 @@ function checkNode(): CheckResult {
|
|
|
98
99
|
label: 'Node.js',
|
|
99
100
|
status: 'missing',
|
|
100
101
|
message: 'node binary not on PATH',
|
|
101
|
-
remedy: 'https://nodejs.org/en/download (
|
|
102
|
+
remedy: 'https://nodejs.org/en/download (vdx needs 22 or newer)',
|
|
102
103
|
};
|
|
103
104
|
}
|
|
104
105
|
const major = parseSemverMajor(v);
|
|
105
106
|
if (major === null) {
|
|
106
107
|
return { id: 'node', label: 'Node.js', status: 'warning', message: `unparseable version: ${v}` };
|
|
107
108
|
}
|
|
108
|
-
if (major <
|
|
109
|
+
if (major < 22) {
|
|
109
110
|
return {
|
|
110
111
|
id: 'node',
|
|
111
112
|
label: 'Node.js',
|
|
112
113
|
status: 'warning',
|
|
113
114
|
level: 1,
|
|
114
|
-
message: `${v} —
|
|
115
|
-
remedy: 'mise use -g node@
|
|
115
|
+
message: `${v} — vdx needs 22 or newer (engines in its package.json)`,
|
|
116
|
+
remedy: 'mise use -g node@24 (or upgrade via nvm/brew)',
|
|
116
117
|
};
|
|
117
118
|
}
|
|
118
|
-
return { id: 'node', label: 'Node.js', status: 'ok', level: major >=
|
|
119
|
+
return { id: 'node', label: 'Node.js', status: 'ok', level: major >= 24 ? 4 : 3, message: v };
|
|
119
120
|
}
|
|
120
121
|
|
|
121
122
|
function checkGit(): CheckResult {
|
|
@@ -354,7 +355,7 @@ export function checkClaudeCodePlugin(
|
|
|
354
355
|
claudeDir: string = process.env['CLAUDE_CONFIG_DIR'] || path.join(os.homedir(), '.claude'),
|
|
355
356
|
): CheckResult {
|
|
356
357
|
const base = { id: 'claude-plugin', label: 'Claude Code vdx plugin' };
|
|
357
|
-
const readJson = (file: string):
|
|
358
|
+
const readJson = (file: string): unknown => {
|
|
358
359
|
try {
|
|
359
360
|
return JSON.parse(fs.readFileSync(path.join(claudeDir, file), 'utf8'));
|
|
360
361
|
} catch {
|
|
@@ -370,13 +371,13 @@ export function checkClaudeCodePlugin(
|
|
|
370
371
|
remedy: PLUGIN_INSTALL_HINT,
|
|
371
372
|
};
|
|
372
373
|
}
|
|
373
|
-
const key = Object.keys(installed
|
|
374
|
+
const key = Object.keys(dict(at(installed, 'plugins')) ?? {}).find((k) => k.startsWith('vdx@'));
|
|
374
375
|
if (!key) {
|
|
375
376
|
return { ...base, status: 'warning', message: 'not installed', remedy: PLUGIN_INSTALL_HINT };
|
|
376
377
|
}
|
|
377
|
-
const version = installed
|
|
378
|
+
const version = at(installed, 'plugins', key, '0', 'version');
|
|
378
379
|
const label = `${key}${typeof version === 'string' ? ` ${version}` : ''}`;
|
|
379
|
-
if (readJson('settings.json')
|
|
380
|
+
if (at(readJson('settings.json'), 'enabledPlugins', key) === false) {
|
|
380
381
|
return { ...base, status: 'warning', message: `${label} installed but disabled`, remedy: `/plugin enable ${key}` };
|
|
381
382
|
}
|
|
382
383
|
return { ...base, status: 'ok', message: label };
|
package/src/evaluator.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { Predicate } from './rubric.ts';
|
|
2
2
|
import type { Ctx } from './facts.ts';
|
|
3
|
-
import { REGISTRY, isUnknown, type Unknown, type Verdict } from './predicates.ts';
|
|
3
|
+
import { REGISTRY, isUnknown, type Args, type Unknown, type Verdict } from './predicates.ts';
|
|
4
|
+
import { at, isDict, list } from './narrow.ts';
|
|
4
5
|
|
|
5
|
-
export { isUnknown, type
|
|
6
|
+
export { isUnknown, type Verdict };
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Sugar shorthand: `{ has_task: "up" }` → `{ fn: "has_task", args: { task: "up" } }`
|
|
@@ -34,21 +35,21 @@ export function evalVerdict(p: Predicate, ctx: Ctx): Verdict {
|
|
|
34
35
|
if (p === null || p === undefined) return false;
|
|
35
36
|
if (typeof p === 'boolean') return p;
|
|
36
37
|
|
|
37
|
-
if (
|
|
38
|
-
const obj = p
|
|
38
|
+
if (isDict(p)) {
|
|
39
|
+
const obj = p;
|
|
39
40
|
|
|
40
41
|
if ('all_of' in obj) {
|
|
41
|
-
const vs = (obj
|
|
42
|
+
const vs = list(obj['all_of']).map((q) => evalVerdict(q, ctx));
|
|
42
43
|
if (vs.some((v) => v === false)) return false;
|
|
43
44
|
return firstUnknown(vs) ?? true;
|
|
44
45
|
}
|
|
45
46
|
if ('any_of' in obj) {
|
|
46
|
-
const vs = (obj
|
|
47
|
+
const vs = list(obj['any_of']).map((q) => evalVerdict(q, ctx));
|
|
47
48
|
if (vs.some((v) => v === true)) return true;
|
|
48
49
|
return firstUnknown(vs) ?? false;
|
|
49
50
|
}
|
|
50
51
|
if ('at_least_n_of' in obj) {
|
|
51
|
-
const { n, predicates } = obj
|
|
52
|
+
const { n, predicates } = obj['at_least_n_of'] as { n: number; predicates: Predicate[] };
|
|
52
53
|
const vs = predicates.map((q) => evalVerdict(q, ctx));
|
|
53
54
|
const matched = vs.filter((v) => v === true).length;
|
|
54
55
|
if (matched >= n) return true;
|
|
@@ -57,16 +58,18 @@ export function evalVerdict(p: Predicate, ctx: Ctx): Verdict {
|
|
|
57
58
|
return firstUnknown(vs)!;
|
|
58
59
|
}
|
|
59
60
|
if ('not' in obj) {
|
|
60
|
-
const v = evalVerdict(obj
|
|
61
|
+
const v = evalVerdict(obj['not'], ctx);
|
|
61
62
|
return isUnknown(v) ? v : !v;
|
|
62
63
|
}
|
|
63
64
|
if ('fn' in obj) {
|
|
64
|
-
const
|
|
65
|
+
const name = String(obj['fn']);
|
|
66
|
+
const fn = REGISTRY[name];
|
|
65
67
|
if (!fn) {
|
|
66
|
-
process.stderr.write(`warning: unknown predicate "${
|
|
68
|
+
process.stderr.write(`warning: unknown predicate "${name}"\n`);
|
|
67
69
|
return false;
|
|
68
70
|
}
|
|
69
|
-
|
|
71
|
+
// `args` is the predicate's to read: package_present takes a bare string too.
|
|
72
|
+
return fn((obj['args'] ?? {}) as Args, ctx);
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
// Sugar form: single-key object where key is a registered predicate
|
|
@@ -76,9 +79,7 @@ export function evalVerdict(p: Predicate, ctx: Ctx): Verdict {
|
|
|
76
79
|
const fn = REGISTRY[key];
|
|
77
80
|
if (fn) {
|
|
78
81
|
const value = obj[key];
|
|
79
|
-
if (
|
|
80
|
-
return fn(value, ctx);
|
|
81
|
-
}
|
|
82
|
+
if (isDict(value)) return fn(value, ctx);
|
|
82
83
|
const argKey = SUGAR_ARG_KEY[key];
|
|
83
84
|
if (argKey) {
|
|
84
85
|
return fn({ [argKey]: value }, ctx);
|
|
@@ -104,14 +105,14 @@ export function evalPredicate(p: Predicate, ctx: Ctx): boolean {
|
|
|
104
105
|
export function predicateNames(p: Predicate): string[] {
|
|
105
106
|
const out: string[] = [];
|
|
106
107
|
const walk = (q: Predicate): void => {
|
|
107
|
-
if (
|
|
108
|
-
const obj = q
|
|
109
|
-
if ('all_of' in obj) return (obj
|
|
110
|
-
if ('any_of' in obj) return (obj
|
|
111
|
-
if ('at_least_n_of' in obj) return ((obj
|
|
112
|
-
if ('not' in obj) return walk(obj
|
|
108
|
+
if (!isDict(q)) return;
|
|
109
|
+
const obj = q;
|
|
110
|
+
if ('all_of' in obj) return list(obj['all_of']).forEach(walk);
|
|
111
|
+
if ('any_of' in obj) return list(obj['any_of']).forEach(walk);
|
|
112
|
+
if ('at_least_n_of' in obj) return list(at(obj, 'at_least_n_of', 'predicates')).forEach(walk);
|
|
113
|
+
if ('not' in obj) return walk(obj['not']);
|
|
113
114
|
if ('fn' in obj) {
|
|
114
|
-
out.push(String(obj
|
|
115
|
+
out.push(String(obj['fn']));
|
|
115
116
|
return;
|
|
116
117
|
}
|
|
117
118
|
const keys = Object.keys(obj);
|
package/src/facts.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as fs from 'node:fs';
|
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import * as TOML from 'smol-toml';
|
|
4
4
|
import * as YAML from 'js-yaml';
|
|
5
|
+
import { at, dict, str } from './narrow.ts';
|
|
5
6
|
|
|
6
7
|
export interface Ctx {
|
|
7
8
|
projectRoot: string;
|
|
@@ -52,7 +53,7 @@ export function readText(ctx: Ctx, rel: string): string | null {
|
|
|
52
53
|
}
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
export function readJson(ctx: Ctx, rel: string):
|
|
56
|
+
export function readJson(ctx: Ctx, rel: string): unknown {
|
|
56
57
|
const key = `json:${rel}`;
|
|
57
58
|
if (ctx.cache.has(key)) return ctx.cache.get(key);
|
|
58
59
|
const text = readText(ctx, rel);
|
|
@@ -61,7 +62,7 @@ export function readJson(ctx: Ctx, rel: string): any {
|
|
|
61
62
|
return null;
|
|
62
63
|
}
|
|
63
64
|
try {
|
|
64
|
-
const data = JSON.parse(text);
|
|
65
|
+
const data: unknown = JSON.parse(text);
|
|
65
66
|
ctx.cache.set(key, data);
|
|
66
67
|
return data;
|
|
67
68
|
} catch {
|
|
@@ -70,7 +71,7 @@ export function readJson(ctx: Ctx, rel: string): any {
|
|
|
70
71
|
}
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
export function readToml(ctx: Ctx, rel: string):
|
|
74
|
+
export function readToml(ctx: Ctx, rel: string): unknown {
|
|
74
75
|
const key = `toml:${rel}`;
|
|
75
76
|
if (ctx.cache.has(key)) return ctx.cache.get(key);
|
|
76
77
|
const text = readText(ctx, rel);
|
|
@@ -88,7 +89,7 @@ export function readToml(ctx: Ctx, rel: string): any {
|
|
|
88
89
|
}
|
|
89
90
|
}
|
|
90
91
|
|
|
91
|
-
|
|
92
|
+
function readYaml(ctx: Ctx, rel: string): unknown {
|
|
92
93
|
const key = `yaml:${rel}`;
|
|
93
94
|
if (ctx.cache.has(key)) return ctx.cache.get(key);
|
|
94
95
|
const text = readText(ctx, rel);
|
|
@@ -107,7 +108,7 @@ export function readYaml(ctx: Ctx, rel: string): any {
|
|
|
107
108
|
}
|
|
108
109
|
|
|
109
110
|
/** Parse a structured file by extension. Returns parsed object/array or null. */
|
|
110
|
-
export function readStructured(ctx: Ctx, rel: string):
|
|
111
|
+
export function readStructured(ctx: Ctx, rel: string): unknown {
|
|
111
112
|
const lower = rel.toLowerCase();
|
|
112
113
|
if (lower.endsWith('.json')) return readJson(ctx, rel);
|
|
113
114
|
if (lower.endsWith('.toml')) return readToml(ctx, rel);
|
|
@@ -122,14 +123,7 @@ export function readStructured(ctx: Ctx, rel: string): any {
|
|
|
122
123
|
*/
|
|
123
124
|
export function resolveConfigPath(root: unknown, jsonpath: string): unknown {
|
|
124
125
|
if (root === null || root === undefined) return undefined;
|
|
125
|
-
|
|
126
|
-
let cur: any = root;
|
|
127
|
-
for (const part of parts) {
|
|
128
|
-
if (cur === null || cur === undefined) return undefined;
|
|
129
|
-
if (typeof cur !== 'object') return undefined;
|
|
130
|
-
cur = (cur as Record<string, unknown>)[part];
|
|
131
|
-
}
|
|
132
|
-
return cur;
|
|
126
|
+
return at(root, ...jsonpath.split('.'));
|
|
133
127
|
}
|
|
134
128
|
|
|
135
129
|
/**
|
|
@@ -142,25 +136,12 @@ export function listAllTasks(ctx: Ctx): Set<string> {
|
|
|
142
136
|
const tasks = new Set<string>();
|
|
143
137
|
const own = ownCtx(ctx);
|
|
144
138
|
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
const composer = readJson(own, 'composer.json');
|
|
151
|
-
if (composer?.scripts && typeof composer.scripts === 'object') {
|
|
152
|
-
for (const k of Object.keys(composer.scripts)) tasks.add(k);
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const pkg = readJson(own, 'package.json');
|
|
156
|
-
if (pkg?.scripts && typeof pkg.scripts === 'object') {
|
|
157
|
-
for (const k of Object.keys(pkg.scripts)) tasks.add(k);
|
|
158
|
-
}
|
|
159
|
-
|
|
139
|
+
for (const k of Object.keys(dict(at(readToml(own, 'mise.toml'), 'tasks')) ?? {})) tasks.add(k);
|
|
140
|
+
for (const k of Object.keys(dict(at(readJson(own, 'composer.json'), 'scripts')) ?? {})) tasks.add(k);
|
|
141
|
+
for (const k of Object.keys(dict(at(readJson(own, 'package.json'), 'scripts')) ?? {})) tasks.add(k);
|
|
160
142
|
// monorepo-ish fallback: server/package.json (см. bookmap)
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
for (const k of Object.keys(serverPkg.scripts)) tasks.add(`server:${k}`);
|
|
143
|
+
for (const k of Object.keys(dict(at(readJson(own, 'server/package.json'), 'scripts')) ?? {})) {
|
|
144
|
+
tasks.add(`server:${k}`);
|
|
164
145
|
}
|
|
165
146
|
|
|
166
147
|
const make = readText(own, 'Makefile');
|
|
@@ -180,18 +161,14 @@ export function isPackagePresent(name: string, ecosystem: string, ctx: Ctx): boo
|
|
|
180
161
|
const eco = ecosystem ?? 'any';
|
|
181
162
|
if (eco === 'any' || eco === 'composer') {
|
|
182
163
|
const composer = readJson(ctx, 'composer.json');
|
|
183
|
-
if (composer)
|
|
184
|
-
|
|
185
|
-
if (composer['require-dev']?.[name]) return true;
|
|
186
|
-
}
|
|
164
|
+
if (at(composer, 'require', name)) return true;
|
|
165
|
+
if (at(composer, 'require-dev', name)) return true;
|
|
187
166
|
}
|
|
188
167
|
if (eco === 'any' || eco === 'npm') {
|
|
189
168
|
for (const rel of ['package.json', 'server/package.json']) {
|
|
190
169
|
const pkg = readJson(ctx, rel);
|
|
191
|
-
if (pkg)
|
|
192
|
-
|
|
193
|
-
if (pkg.devDependencies?.[name]) return true;
|
|
194
|
-
}
|
|
170
|
+
if (at(pkg, 'dependencies', name)) return true;
|
|
171
|
+
if (at(pkg, 'devDependencies', name)) return true;
|
|
195
172
|
}
|
|
196
173
|
}
|
|
197
174
|
return false;
|
|
@@ -222,19 +199,17 @@ export function expandPaths(paths: string[], ctx: Ctx): string[] {
|
|
|
222
199
|
return out;
|
|
223
200
|
}
|
|
224
201
|
|
|
225
|
-
export function readTsconfig(ctx: Ctx):
|
|
226
|
-
|
|
227
|
-
if (!cfg) cfg = readJson(ctx, 'server/tsconfig.json');
|
|
202
|
+
export function readTsconfig(ctx: Ctx): { compilerOptions?: Record<string, unknown> } | null {
|
|
203
|
+
const cfg = dict(readJson(ctx, 'tsconfig.json')) ?? dict(readJson(ctx, 'server/tsconfig.json'));
|
|
228
204
|
if (!cfg) return null;
|
|
205
|
+
const own = dict(cfg['compilerOptions']);
|
|
229
206
|
// basic one-level `extends` resolution
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
const base = readJson(ctx,
|
|
233
|
-
if (base
|
|
234
|
-
cfg.compilerOptions = { ...base.compilerOptions, ...(cfg.compilerOptions ?? {}) };
|
|
235
|
-
}
|
|
207
|
+
const ext = str(cfg['extends']);
|
|
208
|
+
if (ext?.startsWith('.')) {
|
|
209
|
+
const base = dict(at(readJson(ctx, ext.endsWith('.json') ? ext : `${ext}.json`), 'compilerOptions'));
|
|
210
|
+
if (base) return { compilerOptions: { ...base, ...own } };
|
|
236
211
|
}
|
|
237
|
-
return
|
|
212
|
+
return own ? { compilerOptions: own } : {};
|
|
238
213
|
}
|
|
239
214
|
|
|
240
215
|
const MANIFEST_TO_STACK: Array<[string, string]> = [
|
package/src/gha.ts
CHANGED
|
@@ -8,6 +8,7 @@ import * as fs from 'node:fs';
|
|
|
8
8
|
import * as path from 'node:path';
|
|
9
9
|
import YAML from 'js-yaml';
|
|
10
10
|
import { fileExists, readText, type Ctx } from './facts.ts';
|
|
11
|
+
import { at, dict, isDict, list, str } from './narrow.ts';
|
|
11
12
|
import { projectTaskCalls } from './vocabulary.ts';
|
|
12
13
|
|
|
13
14
|
export interface GhaStep {
|
|
@@ -48,9 +49,8 @@ function filterMatches(pattern: string, branch: string): boolean {
|
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
function branchSelected(filters: unknown, branch: string): boolean {
|
|
51
|
-
const list = Array.isArray(filters) ? filters : [filters];
|
|
52
52
|
let selected = false;
|
|
53
|
-
for (const f of
|
|
53
|
+
for (const f of Array.isArray(filters) ? (filters as unknown[]) : [filters]) {
|
|
54
54
|
if (typeof f !== 'string') continue;
|
|
55
55
|
if (f.startsWith('!')) {
|
|
56
56
|
if (filterMatches(f.slice(1), branch)) selected = false;
|
|
@@ -65,15 +65,16 @@ function branchSelected(filters: unknown, branch: string): boolean {
|
|
|
65
65
|
export function pushesMain(on: unknown): boolean {
|
|
66
66
|
if (on === 'push') return true;
|
|
67
67
|
if (Array.isArray(on)) return on.includes('push');
|
|
68
|
-
if (!on ||
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
68
|
+
if (!isDict(on) || !('push' in on)) return false;
|
|
69
|
+
const push = dict(on['push']);
|
|
70
|
+
if (!push) return true;
|
|
71
|
+
const { branches, tags } = push;
|
|
72
|
+
const branchesIgnore = push['branches-ignore'];
|
|
73
|
+
const hasBranchFilter = branches !== undefined || branchesIgnore !== undefined;
|
|
74
|
+
if (!hasBranchFilter && (tags !== undefined || push['tags-ignore'] !== undefined)) return false;
|
|
74
75
|
return MAIN_BRANCHES.some((b) => {
|
|
75
|
-
if (
|
|
76
|
-
if (
|
|
76
|
+
if (branches !== undefined) return branchSelected(branches, b);
|
|
77
|
+
if (branchesIgnore !== undefined) return !branchSelected(branchesIgnore, b);
|
|
77
78
|
return true;
|
|
78
79
|
});
|
|
79
80
|
}
|
|
@@ -82,35 +83,42 @@ function truthy(v: unknown): boolean {
|
|
|
82
83
|
return v === true || v === 'true';
|
|
83
84
|
}
|
|
84
85
|
|
|
85
|
-
|
|
86
|
-
let data:
|
|
86
|
+
function parseWorkflow(file: string, text: string): GhaWorkflow | null {
|
|
87
|
+
let data: unknown;
|
|
87
88
|
try {
|
|
88
89
|
data = YAML.load(text);
|
|
89
90
|
} catch {
|
|
90
91
|
return null;
|
|
91
92
|
}
|
|
93
|
+
// An array is an object too, and the workflow it would be has no jobs.
|
|
92
94
|
if (!data || typeof data !== 'object') return null;
|
|
93
95
|
const jobs: GhaJob[] = [];
|
|
94
|
-
for (const [id,
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
96
|
+
for (const [id, value] of Object.entries(dict(at(data, 'jobs')) ?? {})) {
|
|
97
|
+
const job = dict(value);
|
|
98
|
+
if (!job) continue;
|
|
99
|
+
const needs = job['needs'] === undefined ? [] : Array.isArray(job['needs']) ? list(job['needs']) : [job['needs']];
|
|
100
|
+
const steps: GhaStep[] = list(job['steps'])
|
|
101
|
+
.filter(isDict)
|
|
102
|
+
.map((s) => {
|
|
103
|
+
const run = str(s['run']);
|
|
104
|
+
const uses = str(s['uses']);
|
|
105
|
+
return {
|
|
106
|
+
...(run !== undefined ? { run } : {}),
|
|
107
|
+
...(uses !== undefined ? { uses } : {}),
|
|
108
|
+
continueOnError: truthy(s['continue-on-error']),
|
|
109
|
+
};
|
|
110
|
+
});
|
|
111
|
+
const name = str(job['name']);
|
|
104
112
|
jobs.push({
|
|
105
113
|
id,
|
|
106
|
-
...(
|
|
107
|
-
needs: needs.filter((n
|
|
114
|
+
...(name !== undefined ? { name } : {}),
|
|
115
|
+
needs: needs.filter((n): n is string => typeof n === 'string'),
|
|
108
116
|
steps,
|
|
109
117
|
continueOnError: truthy(job['continue-on-error']),
|
|
110
|
-
matrix: Boolean(job
|
|
118
|
+
matrix: Boolean(at(job, 'strategy', 'matrix')),
|
|
111
119
|
});
|
|
112
120
|
}
|
|
113
|
-
return { file, pushesMain: pushesMain(data
|
|
121
|
+
return { file, pushesMain: pushesMain(at(data, 'on')), jobs };
|
|
114
122
|
}
|
|
115
123
|
|
|
116
124
|
export function readWorkflows(ctx: Ctx): GhaWorkflow[] {
|
|
@@ -118,7 +126,7 @@ export function readWorkflows(ctx: Ctx): GhaWorkflow[] {
|
|
|
118
126
|
if (ctx.cache.has(key)) return ctx.cache.get(key) as GhaWorkflow[];
|
|
119
127
|
const dir = path.join(ctx.projectRoot, '.github', 'workflows');
|
|
120
128
|
const out: GhaWorkflow[] = [];
|
|
121
|
-
let files: string[]
|
|
129
|
+
let files: string[];
|
|
122
130
|
try {
|
|
123
131
|
files = fs
|
|
124
132
|
.readdirSync(dir)
|
|
@@ -154,7 +162,7 @@ export function ciSystems(ctx: Ctx): string[] {
|
|
|
154
162
|
/** `|| true` and its relatives: the step reports success whatever the command did. */
|
|
155
163
|
const MASK_RE = /\|\|\s*(true|:|exit\s+0)\b/;
|
|
156
164
|
|
|
157
|
-
|
|
165
|
+
function stepMasked(step: GhaStep, job: GhaJob): boolean {
|
|
158
166
|
if (job.continueOnError || step.continueOnError) return true;
|
|
159
167
|
return step.run !== undefined && MASK_RE.test(step.run);
|
|
160
168
|
}
|
package/src/hooks.ts
CHANGED
|
@@ -16,8 +16,9 @@ import * as path from 'node:path';
|
|
|
16
16
|
import { execFileSync } from 'node:child_process';
|
|
17
17
|
import YAML from 'js-yaml';
|
|
18
18
|
import { fileExists, isPackagePresent, readJson, readText, readToml, type Ctx } from './facts.ts';
|
|
19
|
+
import { at, dict, isDict, list } from './narrow.ts';
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
type FrameworkId = 'husky' | 'lefthook' | 'simple-git-hooks' | 'cghooks' | 'githooks';
|
|
21
22
|
|
|
22
23
|
export interface HookFramework {
|
|
23
24
|
id: FrameworkId;
|
|
@@ -68,8 +69,8 @@ interface InstallStep {
|
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
/** Yarn 2+ does not run a project's `prepare` (husky docs, How To → Yarn). */
|
|
71
|
-
|
|
72
|
-
const pm = readJson(ctx, 'package.json')
|
|
72
|
+
function yarnBerry(ctx: Ctx): boolean {
|
|
73
|
+
const pm = at(readJson(ctx, 'package.json'), 'packageManager');
|
|
73
74
|
if (typeof pm === 'string') {
|
|
74
75
|
const m = pm.match(/^yarn@(\d+)/);
|
|
75
76
|
if (m) return parseInt(m[1]!, 10) >= 2;
|
|
@@ -79,22 +80,24 @@ export function yarnBerry(ctx: Ctx): boolean {
|
|
|
79
80
|
|
|
80
81
|
function installSteps(ctx: Ctx): InstallStep[] {
|
|
81
82
|
const out: InstallStep[] = [];
|
|
82
|
-
const
|
|
83
|
-
const scripts = pkg?.scripts ?? {};
|
|
83
|
+
const scripts = dict(at(readJson(ctx, 'package.json'), 'scripts')) ?? {};
|
|
84
84
|
const npmHooks = yarnBerry(ctx) ? ['postinstall'] : ['prepare', 'postinstall'];
|
|
85
85
|
for (const name of npmHooks) {
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
const body = scripts[name];
|
|
87
|
+
if (typeof body === 'string') {
|
|
88
|
+
out.push({ where: `package.json ${name}`, text: expandNpm(scripts, body) });
|
|
88
89
|
}
|
|
89
90
|
}
|
|
90
|
-
const
|
|
91
|
-
const cmd =
|
|
91
|
+
const composerScripts = dict(at(readJson(ctx, 'composer.json'), 'scripts')) ?? {};
|
|
92
|
+
const cmd = composerScripts['post-install-cmd'];
|
|
92
93
|
if (cmd !== undefined) {
|
|
93
|
-
out.push({ where: 'composer post-install-cmd', text: expandComposer(
|
|
94
|
+
out.push({ where: 'composer post-install-cmd', text: expandComposer(composerScripts, cmd) });
|
|
94
95
|
}
|
|
95
|
-
const up = readToml(ctx, 'mise.toml')
|
|
96
|
-
if (up
|
|
97
|
-
const runs = (Array.isArray(up
|
|
96
|
+
const up = dict(at(readToml(ctx, 'mise.toml'), 'tasks', 'up'));
|
|
97
|
+
if (up) {
|
|
98
|
+
const runs = (Array.isArray(up['run']) ? (up['run'] as unknown[]) : [up['run']]).filter(
|
|
99
|
+
(r): r is string => typeof r === 'string',
|
|
100
|
+
);
|
|
98
101
|
if (runs.length > 0) out.push({ where: 'mise task up', text: runs.join('\n') });
|
|
99
102
|
}
|
|
100
103
|
return out;
|
|
@@ -135,7 +138,7 @@ function findStep(steps: InstallStep[], re: RegExp): string | null {
|
|
|
135
138
|
/** Hook scripts in a directory: file name = event; the body is the command. */
|
|
136
139
|
function hooksFromDir(ctx: Ctx, dir: string): Map<string, string[]> {
|
|
137
140
|
const out = new Map<string, string[]>();
|
|
138
|
-
let names: string[]
|
|
141
|
+
let names: string[];
|
|
139
142
|
try {
|
|
140
143
|
names = fs.readdirSync(path.join(ctx.projectRoot, dir));
|
|
141
144
|
} catch {
|
|
@@ -162,15 +165,14 @@ function scriptBody(text: string): string {
|
|
|
162
165
|
|
|
163
166
|
function lefthookRuns(node: unknown): string[] {
|
|
164
167
|
const out: string[] = [];
|
|
165
|
-
if (!node
|
|
166
|
-
|
|
167
|
-
if (typeof
|
|
168
|
-
if (typeof o.runner === 'string') out.push(o.runner);
|
|
168
|
+
if (!isDict(node)) return out;
|
|
169
|
+
if (typeof node['run'] === 'string') out.push(node['run']);
|
|
170
|
+
if (typeof node['runner'] === 'string') out.push(node['runner']);
|
|
169
171
|
for (const key of ['commands', 'scripts']) {
|
|
170
|
-
|
|
172
|
+
for (const v of Object.values(dict(node[key]) ?? {})) out.push(...lefthookRuns(v));
|
|
171
173
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
+
for (const j of list(node['jobs'])) out.push(...lefthookRuns(j));
|
|
175
|
+
out.push(...lefthookRuns(node['group']));
|
|
174
176
|
return out;
|
|
175
177
|
}
|
|
176
178
|
|
|
@@ -204,14 +206,14 @@ export function detectHookFrameworks(ctx: Ctx): HookFramework[] {
|
|
|
204
206
|
for (const file of LEFTHOOK_FILES) {
|
|
205
207
|
const text = readText(ctx, file);
|
|
206
208
|
if (text === null) continue;
|
|
207
|
-
let data:
|
|
209
|
+
let data: unknown;
|
|
208
210
|
try {
|
|
209
211
|
data = YAML.load(text);
|
|
210
212
|
} catch {
|
|
211
213
|
continue;
|
|
212
214
|
}
|
|
213
215
|
const hooks = new Map<string, string[]>();
|
|
214
|
-
for (const [event, def] of Object.entries((data ?? {})
|
|
216
|
+
for (const [event, def] of Object.entries(dict(data) ?? {})) {
|
|
215
217
|
if (GIT_HOOK_EVENTS.has(event)) hooks.set(event, lefthookRuns(def));
|
|
216
218
|
}
|
|
217
219
|
if (hooks.size === 0) continue;
|
|
@@ -231,11 +233,9 @@ export function detectHookFrameworks(ctx: Ctx): HookFramework[] {
|
|
|
231
233
|
break;
|
|
232
234
|
}
|
|
233
235
|
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
readJson(ctx, 'simple-git-hooks.json');
|
|
238
|
-
if (sgh && typeof sgh === 'object') {
|
|
236
|
+
const sghInPackage = dict(at(pkg, 'simple-git-hooks'));
|
|
237
|
+
const sgh = sghInPackage ?? dict(readJson(ctx, '.simple-git-hooks.json') ?? readJson(ctx, 'simple-git-hooks.json'));
|
|
238
|
+
if (sgh) {
|
|
239
239
|
const hooks = new Map<string, string[]>();
|
|
240
240
|
for (const [event, cmd] of Object.entries(sgh)) {
|
|
241
241
|
if (GIT_HOOK_EVENTS.has(event) && typeof cmd === 'string') hooks.set(event, [cmd]);
|
|
@@ -243,7 +243,7 @@ export function detectHookFrameworks(ctx: Ctx): HookFramework[] {
|
|
|
243
243
|
if (hooks.size > 0) {
|
|
244
244
|
out.push({
|
|
245
245
|
id: 'simple-git-hooks',
|
|
246
|
-
declaredIn:
|
|
246
|
+
declaredIn: sghInPackage ? 'package.json simple-git-hooks' : 'simple-git-hooks.json',
|
|
247
247
|
hooks,
|
|
248
248
|
installedBy: isPackagePresent('simple-git-hooks', 'npm', ctx)
|
|
249
249
|
? 'simple-git-hooks package (its postinstall)'
|
|
@@ -255,10 +255,10 @@ export function detectHookFrameworks(ctx: Ctx): HookFramework[] {
|
|
|
255
255
|
}
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
-
const extra = composer
|
|
259
|
-
if (extra
|
|
258
|
+
const extra = dict(at(composer, 'extra', 'hooks'));
|
|
259
|
+
if (extra) {
|
|
260
260
|
const hooks = new Map<string, string[]>();
|
|
261
|
-
for (const [event, cmd] of Object.entries(extra
|
|
261
|
+
for (const [event, cmd] of Object.entries(extra)) {
|
|
262
262
|
if (!GIT_HOOK_EVENTS.has(event)) continue;
|
|
263
263
|
const list = (Array.isArray(cmd) ? cmd : [cmd]).filter((c): c is string => typeof c === 'string');
|
|
264
264
|
if (list.length > 0) hooks.set(event, list);
|
|
@@ -297,11 +297,11 @@ export function detectHookFrameworks(ctx: Ctx): HookFramework[] {
|
|
|
297
297
|
}
|
|
298
298
|
|
|
299
299
|
/** What a clone runs to switch hooks on: the install step of the project's package manager. */
|
|
300
|
-
|
|
300
|
+
function installCommand(fw: HookFramework, ctx: Ctx): string {
|
|
301
301
|
if (fw.installedBy === null) return fw.manualInstall;
|
|
302
302
|
if (fw.installedBy.startsWith('composer')) return 'composer install';
|
|
303
303
|
if (fw.installedBy === 'mise task up') return 'vdx up';
|
|
304
|
-
const pm = readJson(ctx, 'package.json')
|
|
304
|
+
const pm = at(readJson(ctx, 'package.json'), 'packageManager');
|
|
305
305
|
if (typeof pm === 'string' && pm.startsWith('pnpm')) return 'pnpm install';
|
|
306
306
|
if ((typeof pm === 'string' && pm.startsWith('yarn')) || fileExists(ctx, 'yarn.lock')) return 'yarn install';
|
|
307
307
|
if (fileExists(ctx, 'pnpm-lock.yaml')) return 'pnpm install';
|