@vodmal/vdx-cli 0.24.0 → 0.26.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 +5 -4
- package/package.json +10 -1
- package/rubric/vdx-rubric.yaml +76 -32
- package/src/ai.ts +221 -76
- package/src/audit.ts +19 -9
- package/src/author.ts +27 -8
- package/src/clone-checks.ts +14 -9
- package/src/conversations.ts +48 -22
- package/src/doctor.ts +14 -15
- package/src/evaluator.ts +21 -20
- package/src/facts.ts +71 -56
- package/src/gha.ts +38 -27
- package/src/hooks.ts +78 -43
- package/src/index.ts +56 -41
- package/src/init.ts +22 -61
- package/src/manifest.ts +25 -4
- package/src/mcp-server.ts +40 -39
- package/src/narrow.ts +56 -0
- package/src/personal-hooks.ts +16 -5
- package/src/predicates.ts +12 -16
- package/src/publish.ts +38 -31
- package/src/report.ts +1 -3
- package/src/rubric.ts +2 -2
- package/src/run.ts +5 -8
- package/src/scoring.ts +2 -8
- package/src/vocabulary.ts +13 -12
package/src/facts.ts
CHANGED
|
@@ -2,21 +2,47 @@ 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;
|
|
8
9
|
stack: string;
|
|
9
10
|
cache: Map<string, unknown>;
|
|
11
|
+
/**
|
|
12
|
+
* Meta repo: the other half, where a file missing here is looked for — the repo root
|
|
13
|
+
* for an axis evaluated in the primary subpackage (`.github/` lives there), the
|
|
14
|
+
* subpackage for an axis evaluated in a root without a stack manifest of its own.
|
|
15
|
+
*/
|
|
16
|
+
fallbackRoot?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Absolute path of `rel`: here, else in `fallbackRoot` when it is there. */
|
|
20
|
+
function locate(ctx: Ctx, rel: string): string {
|
|
21
|
+
const here = path.join(ctx.projectRoot, rel);
|
|
22
|
+
if (ctx.fallbackRoot === undefined || fs.existsSync(here)) return here;
|
|
23
|
+
const there = path.join(ctx.fallbackRoot, rel);
|
|
24
|
+
return fs.existsSync(there) ? there : here;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The same directory without the fallback: a project's tasks are the ones its own runner sees. */
|
|
28
|
+
function ownCtx(ctx: Ctx): Ctx {
|
|
29
|
+
if (ctx.fallbackRoot === undefined) return ctx;
|
|
30
|
+
let own = ctx.cache.get('ctx:own') as Ctx | undefined;
|
|
31
|
+
if (!own) {
|
|
32
|
+
own = { projectRoot: ctx.projectRoot, stack: ctx.stack, cache: new Map() };
|
|
33
|
+
ctx.cache.set('ctx:own', own);
|
|
34
|
+
}
|
|
35
|
+
return own;
|
|
10
36
|
}
|
|
11
37
|
|
|
12
38
|
export function fileExists(ctx: Ctx, rel: string): boolean {
|
|
13
|
-
return fs.existsSync(
|
|
39
|
+
return fs.existsSync(locate(ctx, rel));
|
|
14
40
|
}
|
|
15
41
|
|
|
16
42
|
export function readText(ctx: Ctx, rel: string): string | null {
|
|
17
43
|
const key = `file:${rel}`;
|
|
18
44
|
if (ctx.cache.has(key)) return ctx.cache.get(key) as string | null;
|
|
19
|
-
const abs =
|
|
45
|
+
const abs = locate(ctx, rel);
|
|
20
46
|
try {
|
|
21
47
|
const t = fs.readFileSync(abs, 'utf8');
|
|
22
48
|
ctx.cache.set(key, t);
|
|
@@ -27,7 +53,7 @@ export function readText(ctx: Ctx, rel: string): string | null {
|
|
|
27
53
|
}
|
|
28
54
|
}
|
|
29
55
|
|
|
30
|
-
export function readJson(ctx: Ctx, rel: string):
|
|
56
|
+
export function readJson(ctx: Ctx, rel: string): unknown {
|
|
31
57
|
const key = `json:${rel}`;
|
|
32
58
|
if (ctx.cache.has(key)) return ctx.cache.get(key);
|
|
33
59
|
const text = readText(ctx, rel);
|
|
@@ -36,7 +62,7 @@ export function readJson(ctx: Ctx, rel: string): any {
|
|
|
36
62
|
return null;
|
|
37
63
|
}
|
|
38
64
|
try {
|
|
39
|
-
const data = JSON.parse(text);
|
|
65
|
+
const data: unknown = JSON.parse(text);
|
|
40
66
|
ctx.cache.set(key, data);
|
|
41
67
|
return data;
|
|
42
68
|
} catch {
|
|
@@ -45,7 +71,7 @@ export function readJson(ctx: Ctx, rel: string): any {
|
|
|
45
71
|
}
|
|
46
72
|
}
|
|
47
73
|
|
|
48
|
-
export function readToml(ctx: Ctx, rel: string):
|
|
74
|
+
export function readToml(ctx: Ctx, rel: string): unknown {
|
|
49
75
|
const key = `toml:${rel}`;
|
|
50
76
|
if (ctx.cache.has(key)) return ctx.cache.get(key);
|
|
51
77
|
const text = readText(ctx, rel);
|
|
@@ -63,7 +89,7 @@ export function readToml(ctx: Ctx, rel: string): any {
|
|
|
63
89
|
}
|
|
64
90
|
}
|
|
65
91
|
|
|
66
|
-
export function readYaml(ctx: Ctx, rel: string):
|
|
92
|
+
export function readYaml(ctx: Ctx, rel: string): unknown {
|
|
67
93
|
const key = `yaml:${rel}`;
|
|
68
94
|
if (ctx.cache.has(key)) return ctx.cache.get(key);
|
|
69
95
|
const text = readText(ctx, rel);
|
|
@@ -82,7 +108,7 @@ export function readYaml(ctx: Ctx, rel: string): any {
|
|
|
82
108
|
}
|
|
83
109
|
|
|
84
110
|
/** Parse a structured file by extension. Returns parsed object/array or null. */
|
|
85
|
-
export function readStructured(ctx: Ctx, rel: string):
|
|
111
|
+
export function readStructured(ctx: Ctx, rel: string): unknown {
|
|
86
112
|
const lower = rel.toLowerCase();
|
|
87
113
|
if (lower.endsWith('.json')) return readJson(ctx, rel);
|
|
88
114
|
if (lower.endsWith('.toml')) return readToml(ctx, rel);
|
|
@@ -97,44 +123,28 @@ export function readStructured(ctx: Ctx, rel: string): any {
|
|
|
97
123
|
*/
|
|
98
124
|
export function resolveConfigPath(root: unknown, jsonpath: string): unknown {
|
|
99
125
|
if (root === null || root === undefined) return undefined;
|
|
100
|
-
|
|
101
|
-
let cur: any = root;
|
|
102
|
-
for (const part of parts) {
|
|
103
|
-
if (cur === null || cur === undefined) return undefined;
|
|
104
|
-
if (typeof cur !== 'object') return undefined;
|
|
105
|
-
cur = (cur as Record<string, unknown>)[part];
|
|
106
|
-
}
|
|
107
|
-
return cur;
|
|
126
|
+
return at(root, ...jsonpath.split('.'));
|
|
108
127
|
}
|
|
109
128
|
|
|
110
|
-
/**
|
|
129
|
+
/**
|
|
130
|
+
* Aggregated task names from mise.toml, composer scripts, npm scripts, Makefile.
|
|
131
|
+
* Without the fallback: `vdx test` here runs this directory's tasks, not the other half's.
|
|
132
|
+
*/
|
|
111
133
|
export function listAllTasks(ctx: Ctx): Set<string> {
|
|
112
134
|
const key = 'tasks:all';
|
|
113
135
|
if (ctx.cache.has(key)) return ctx.cache.get(key) as Set<string>;
|
|
114
136
|
const tasks = new Set<string>();
|
|
137
|
+
const own = ownCtx(ctx);
|
|
115
138
|
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const composer = readJson(ctx, 'composer.json');
|
|
122
|
-
if (composer?.scripts && typeof composer.scripts === 'object') {
|
|
123
|
-
for (const k of Object.keys(composer.scripts)) tasks.add(k);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
const pkg = readJson(ctx, 'package.json');
|
|
127
|
-
if (pkg?.scripts && typeof pkg.scripts === 'object') {
|
|
128
|
-
for (const k of Object.keys(pkg.scripts)) tasks.add(k);
|
|
129
|
-
}
|
|
130
|
-
|
|
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);
|
|
131
142
|
// monorepo-ish fallback: server/package.json (см. bookmap)
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
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}`);
|
|
135
145
|
}
|
|
136
146
|
|
|
137
|
-
const make = readText(
|
|
147
|
+
const make = readText(own, 'Makefile');
|
|
138
148
|
if (make) {
|
|
139
149
|
const lines = make.split('\n');
|
|
140
150
|
for (const line of lines) {
|
|
@@ -151,18 +161,14 @@ export function isPackagePresent(name: string, ecosystem: string, ctx: Ctx): boo
|
|
|
151
161
|
const eco = ecosystem ?? 'any';
|
|
152
162
|
if (eco === 'any' || eco === 'composer') {
|
|
153
163
|
const composer = readJson(ctx, 'composer.json');
|
|
154
|
-
if (composer)
|
|
155
|
-
|
|
156
|
-
if (composer['require-dev']?.[name]) return true;
|
|
157
|
-
}
|
|
164
|
+
if (at(composer, 'require', name)) return true;
|
|
165
|
+
if (at(composer, 'require-dev', name)) return true;
|
|
158
166
|
}
|
|
159
167
|
if (eco === 'any' || eco === 'npm') {
|
|
160
168
|
for (const rel of ['package.json', 'server/package.json']) {
|
|
161
169
|
const pkg = readJson(ctx, rel);
|
|
162
|
-
if (pkg)
|
|
163
|
-
|
|
164
|
-
if (pkg.devDependencies?.[name]) return true;
|
|
165
|
-
}
|
|
170
|
+
if (at(pkg, 'dependencies', name)) return true;
|
|
171
|
+
if (at(pkg, 'devDependencies', name)) return true;
|
|
166
172
|
}
|
|
167
173
|
}
|
|
168
174
|
return false;
|
|
@@ -176,7 +182,7 @@ export function expandPaths(paths: string[], ctx: Ctx): string[] {
|
|
|
176
182
|
if (m && m[1] && m[2]) {
|
|
177
183
|
const dir = m[1];
|
|
178
184
|
const ext = m[2];
|
|
179
|
-
const abs =
|
|
185
|
+
const abs = locate(ctx, dir);
|
|
180
186
|
try {
|
|
181
187
|
const files = fs.readdirSync(abs);
|
|
182
188
|
for (const f of files) {
|
|
@@ -193,19 +199,17 @@ export function expandPaths(paths: string[], ctx: Ctx): string[] {
|
|
|
193
199
|
return out;
|
|
194
200
|
}
|
|
195
201
|
|
|
196
|
-
export function readTsconfig(ctx: Ctx):
|
|
197
|
-
|
|
198
|
-
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'));
|
|
199
204
|
if (!cfg) return null;
|
|
205
|
+
const own = dict(cfg['compilerOptions']);
|
|
200
206
|
// basic one-level `extends` resolution
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const base = readJson(ctx,
|
|
204
|
-
if (base
|
|
205
|
-
cfg.compilerOptions = { ...base.compilerOptions, ...(cfg.compilerOptions ?? {}) };
|
|
206
|
-
}
|
|
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 } };
|
|
207
211
|
}
|
|
208
|
-
return
|
|
212
|
+
return own ? { compilerOptions: own } : {};
|
|
209
213
|
}
|
|
210
214
|
|
|
211
215
|
const MANIFEST_TO_STACK: Array<[string, string]> = [
|
|
@@ -224,8 +228,19 @@ export function stackForDir(dir: string): string | null {
|
|
|
224
228
|
}
|
|
225
229
|
|
|
226
230
|
const SUBPACKAGE_SCAN_IGNORE = new Set([
|
|
227
|
-
'node_modules',
|
|
228
|
-
'
|
|
231
|
+
'node_modules',
|
|
232
|
+
'vendor',
|
|
233
|
+
'dist',
|
|
234
|
+
'build',
|
|
235
|
+
'out',
|
|
236
|
+
'target',
|
|
237
|
+
'.git',
|
|
238
|
+
'.next',
|
|
239
|
+
'__pycache__',
|
|
240
|
+
'.venv',
|
|
241
|
+
'venv',
|
|
242
|
+
'.cache',
|
|
243
|
+
'coverage',
|
|
229
244
|
]);
|
|
230
245
|
|
|
231
246
|
export interface SubPackage {
|
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
|
}
|
|
@@ -83,34 +84,41 @@ function truthy(v: unknown): boolean {
|
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
export function parseWorkflow(file: string, text: string): GhaWorkflow | null {
|
|
86
|
-
let data:
|
|
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,9 +126,12 @@ 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
|
-
files = fs
|
|
131
|
+
files = fs
|
|
132
|
+
.readdirSync(dir)
|
|
133
|
+
.filter((f) => /\.ya?ml$/.test(f))
|
|
134
|
+
.sort();
|
|
124
135
|
} catch {
|
|
125
136
|
files = [];
|
|
126
137
|
}
|
package/src/hooks.ts
CHANGED
|
@@ -16,6 +16,7 @@ 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
|
export type FrameworkId = 'husky' | 'lefthook' | 'simple-git-hooks' | 'cghooks' | 'githooks';
|
|
21
22
|
|
|
@@ -36,11 +37,29 @@ export interface HookFramework {
|
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
export const GIT_HOOK_EVENTS = new Set([
|
|
39
|
-
'applypatch-msg',
|
|
40
|
-
'
|
|
41
|
-
'
|
|
42
|
-
'
|
|
43
|
-
'
|
|
40
|
+
'applypatch-msg',
|
|
41
|
+
'pre-applypatch',
|
|
42
|
+
'post-applypatch',
|
|
43
|
+
'pre-commit',
|
|
44
|
+
'pre-merge-commit',
|
|
45
|
+
'prepare-commit-msg',
|
|
46
|
+
'commit-msg',
|
|
47
|
+
'post-commit',
|
|
48
|
+
'pre-rebase',
|
|
49
|
+
'post-checkout',
|
|
50
|
+
'post-merge',
|
|
51
|
+
'pre-push',
|
|
52
|
+
'pre-auto-gc',
|
|
53
|
+
'post-rewrite',
|
|
54
|
+
'reference-transaction',
|
|
55
|
+
'push-to-checkout',
|
|
56
|
+
'post-index-change',
|
|
57
|
+
'sendemail-validate',
|
|
58
|
+
'fsmonitor-watchman',
|
|
59
|
+
'p4-changelist',
|
|
60
|
+
'p4-prepare-changelist',
|
|
61
|
+
'p4-post-changelist',
|
|
62
|
+
'p4-pre-submit',
|
|
44
63
|
]);
|
|
45
64
|
|
|
46
65
|
/** Install steps a clone runs anyway: npm-family lifecycle scripts, composer's, the vdx `up` task. */
|
|
@@ -51,7 +70,7 @@ interface InstallStep {
|
|
|
51
70
|
|
|
52
71
|
/** Yarn 2+ does not run a project's `prepare` (husky docs, How To → Yarn). */
|
|
53
72
|
export function yarnBerry(ctx: Ctx): boolean {
|
|
54
|
-
const pm = readJson(ctx, 'package.json')
|
|
73
|
+
const pm = at(readJson(ctx, 'package.json'), 'packageManager');
|
|
55
74
|
if (typeof pm === 'string') {
|
|
56
75
|
const m = pm.match(/^yarn@(\d+)/);
|
|
57
76
|
if (m) return parseInt(m[1]!, 10) >= 2;
|
|
@@ -61,22 +80,24 @@ export function yarnBerry(ctx: Ctx): boolean {
|
|
|
61
80
|
|
|
62
81
|
function installSteps(ctx: Ctx): InstallStep[] {
|
|
63
82
|
const out: InstallStep[] = [];
|
|
64
|
-
const
|
|
65
|
-
const scripts = pkg?.scripts ?? {};
|
|
83
|
+
const scripts = dict(at(readJson(ctx, 'package.json'), 'scripts')) ?? {};
|
|
66
84
|
const npmHooks = yarnBerry(ctx) ? ['postinstall'] : ['prepare', 'postinstall'];
|
|
67
85
|
for (const name of npmHooks) {
|
|
68
|
-
|
|
69
|
-
|
|
86
|
+
const body = scripts[name];
|
|
87
|
+
if (typeof body === 'string') {
|
|
88
|
+
out.push({ where: `package.json ${name}`, text: expandNpm(scripts, body) });
|
|
70
89
|
}
|
|
71
90
|
}
|
|
72
|
-
const
|
|
73
|
-
const cmd =
|
|
91
|
+
const composerScripts = dict(at(readJson(ctx, 'composer.json'), 'scripts')) ?? {};
|
|
92
|
+
const cmd = composerScripts['post-install-cmd'];
|
|
74
93
|
if (cmd !== undefined) {
|
|
75
|
-
out.push({ where: 'composer post-install-cmd', text: expandComposer(
|
|
94
|
+
out.push({ where: 'composer post-install-cmd', text: expandComposer(composerScripts, cmd) });
|
|
76
95
|
}
|
|
77
|
-
const up = readToml(ctx, 'mise.toml')
|
|
78
|
-
if (up
|
|
79
|
-
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
|
+
);
|
|
80
101
|
if (runs.length > 0) out.push({ where: 'mise task up', text: runs.join('\n') });
|
|
81
102
|
}
|
|
82
103
|
return out;
|
|
@@ -117,7 +138,7 @@ function findStep(steps: InstallStep[], re: RegExp): string | null {
|
|
|
117
138
|
/** Hook scripts in a directory: file name = event; the body is the command. */
|
|
118
139
|
function hooksFromDir(ctx: Ctx, dir: string): Map<string, string[]> {
|
|
119
140
|
const out = new Map<string, string[]>();
|
|
120
|
-
let names: string[]
|
|
141
|
+
let names: string[];
|
|
121
142
|
try {
|
|
122
143
|
names = fs.readdirSync(path.join(ctx.projectRoot, dir));
|
|
123
144
|
} catch {
|
|
@@ -144,15 +165,14 @@ function scriptBody(text: string): string {
|
|
|
144
165
|
|
|
145
166
|
function lefthookRuns(node: unknown): string[] {
|
|
146
167
|
const out: string[] = [];
|
|
147
|
-
if (!node
|
|
148
|
-
|
|
149
|
-
if (typeof
|
|
150
|
-
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']);
|
|
151
171
|
for (const key of ['commands', 'scripts']) {
|
|
152
|
-
|
|
172
|
+
for (const v of Object.values(dict(node[key]) ?? {})) out.push(...lefthookRuns(v));
|
|
153
173
|
}
|
|
154
|
-
|
|
155
|
-
|
|
174
|
+
for (const j of list(node['jobs'])) out.push(...lefthookRuns(j));
|
|
175
|
+
out.push(...lefthookRuns(node['group']));
|
|
156
176
|
return out;
|
|
157
177
|
}
|
|
158
178
|
|
|
@@ -173,7 +193,10 @@ export function detectHookFrameworks(ctx: Ctx): HookFramework[] {
|
|
|
173
193
|
id: 'husky',
|
|
174
194
|
declaredIn: '.husky/',
|
|
175
195
|
hooks: husky,
|
|
176
|
-
installedBy: findStep(
|
|
196
|
+
installedBy: findStep(
|
|
197
|
+
steps.filter((s) => s.where.startsWith('package.json') || s.where === 'mise task up'),
|
|
198
|
+
/(^|[\s;&|(])husky(\s+install)?(?=$|[\s;&|)])/m,
|
|
199
|
+
),
|
|
177
200
|
manualInstall: 'npx husky',
|
|
178
201
|
hooksPath: '.husky/_',
|
|
179
202
|
copies: false,
|
|
@@ -183,14 +206,14 @@ export function detectHookFrameworks(ctx: Ctx): HookFramework[] {
|
|
|
183
206
|
for (const file of LEFTHOOK_FILES) {
|
|
184
207
|
const text = readText(ctx, file);
|
|
185
208
|
if (text === null) continue;
|
|
186
|
-
let data:
|
|
209
|
+
let data: unknown;
|
|
187
210
|
try {
|
|
188
211
|
data = YAML.load(text);
|
|
189
212
|
} catch {
|
|
190
213
|
continue;
|
|
191
214
|
}
|
|
192
215
|
const hooks = new Map<string, string[]>();
|
|
193
|
-
for (const [event, def] of Object.entries((data ?? {})
|
|
216
|
+
for (const [event, def] of Object.entries(dict(data) ?? {})) {
|
|
194
217
|
if (GIT_HOOK_EVENTS.has(event)) hooks.set(event, lefthookRuns(def));
|
|
195
218
|
}
|
|
196
219
|
if (hooks.size === 0) continue;
|
|
@@ -210,11 +233,9 @@ export function detectHookFrameworks(ctx: Ctx): HookFramework[] {
|
|
|
210
233
|
break;
|
|
211
234
|
}
|
|
212
235
|
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
readJson(ctx, 'simple-git-hooks.json');
|
|
217
|
-
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) {
|
|
218
239
|
const hooks = new Map<string, string[]>();
|
|
219
240
|
for (const [event, cmd] of Object.entries(sgh)) {
|
|
220
241
|
if (GIT_HOOK_EVENTS.has(event) && typeof cmd === 'string') hooks.set(event, [cmd]);
|
|
@@ -222,7 +243,7 @@ export function detectHookFrameworks(ctx: Ctx): HookFramework[] {
|
|
|
222
243
|
if (hooks.size > 0) {
|
|
223
244
|
out.push({
|
|
224
245
|
id: 'simple-git-hooks',
|
|
225
|
-
declaredIn:
|
|
246
|
+
declaredIn: sghInPackage ? 'package.json simple-git-hooks' : 'simple-git-hooks.json',
|
|
226
247
|
hooks,
|
|
227
248
|
installedBy: isPackagePresent('simple-git-hooks', 'npm', ctx)
|
|
228
249
|
? 'simple-git-hooks package (its postinstall)'
|
|
@@ -234,10 +255,10 @@ export function detectHookFrameworks(ctx: Ctx): HookFramework[] {
|
|
|
234
255
|
}
|
|
235
256
|
}
|
|
236
257
|
|
|
237
|
-
const extra = composer
|
|
238
|
-
if (extra
|
|
258
|
+
const extra = dict(at(composer, 'extra', 'hooks'));
|
|
259
|
+
if (extra) {
|
|
239
260
|
const hooks = new Map<string, string[]>();
|
|
240
|
-
for (const [event, cmd] of Object.entries(extra
|
|
261
|
+
for (const [event, cmd] of Object.entries(extra)) {
|
|
241
262
|
if (!GIT_HOOK_EVENTS.has(event)) continue;
|
|
242
263
|
const list = (Array.isArray(cmd) ? cmd : [cmd]).filter((c): c is string => typeof c === 'string');
|
|
243
264
|
if (list.length > 0) hooks.set(event, list);
|
|
@@ -247,7 +268,10 @@ export function detectHookFrameworks(ctx: Ctx): HookFramework[] {
|
|
|
247
268
|
id: 'cghooks',
|
|
248
269
|
declaredIn: 'composer.json extra.hooks',
|
|
249
270
|
hooks,
|
|
250
|
-
installedBy: findStep(
|
|
271
|
+
installedBy: findStep(
|
|
272
|
+
steps.filter((s) => s.where.startsWith('composer') || s.where === 'mise task up'),
|
|
273
|
+
/\bcghooks\s+(add|update)\b/,
|
|
274
|
+
),
|
|
251
275
|
manualInstall: 'vendor/bin/cghooks update',
|
|
252
276
|
hooksPath: null,
|
|
253
277
|
copies: true,
|
|
@@ -277,7 +301,7 @@ export function installCommand(fw: HookFramework, ctx: Ctx): string {
|
|
|
277
301
|
if (fw.installedBy === null) return fw.manualInstall;
|
|
278
302
|
if (fw.installedBy.startsWith('composer')) return 'composer install';
|
|
279
303
|
if (fw.installedBy === 'mise task up') return 'vdx up';
|
|
280
|
-
const pm = readJson(ctx, 'package.json')
|
|
304
|
+
const pm = at(readJson(ctx, 'package.json'), 'packageManager');
|
|
281
305
|
if (typeof pm === 'string' && pm.startsWith('pnpm')) return 'pnpm install';
|
|
282
306
|
if ((typeof pm === 'string' && pm.startsWith('yarn')) || fileExists(ctx, 'yarn.lock')) return 'yarn install';
|
|
283
307
|
if (fileExists(ctx, 'pnpm-lock.yaml')) return 'pnpm install';
|
|
@@ -353,7 +377,13 @@ export function cloneHookStates(root: string, ctx: Ctx, frameworks: HookFramewor
|
|
|
353
377
|
}
|
|
354
378
|
if (fw.id === 'lefthook') {
|
|
355
379
|
const foreign = events.filter((e) => !/lefthook/.test(readFile(path.join(dir, e))));
|
|
356
|
-
if (foreign.length > 0)
|
|
380
|
+
if (foreign.length > 0)
|
|
381
|
+
return {
|
|
382
|
+
framework: fw,
|
|
383
|
+
enabled: false,
|
|
384
|
+
problem: `${foreign.join(', ')} in ${path.relative(root, dir)} is not lefthook's`,
|
|
385
|
+
remedy,
|
|
386
|
+
};
|
|
357
387
|
}
|
|
358
388
|
if (fw.copies) {
|
|
359
389
|
const stale = events.filter((e) => {
|
|
@@ -361,7 +391,12 @@ export function cloneHookStates(root: string, ctx: Ctx, frameworks: HookFramewor
|
|
|
361
391
|
return fw.hooks.get(e)!.some((cmd) => !body.includes(cmd.trim()));
|
|
362
392
|
});
|
|
363
393
|
if (stale.length > 0) {
|
|
364
|
-
return {
|
|
394
|
+
return {
|
|
395
|
+
framework: fw,
|
|
396
|
+
enabled: false,
|
|
397
|
+
problem: `installed ${stale.join(', ')} older than ${fw.declaredIn}`,
|
|
398
|
+
remedy,
|
|
399
|
+
};
|
|
365
400
|
}
|
|
366
401
|
}
|
|
367
402
|
return { framework: fw, enabled: true };
|
|
@@ -386,8 +421,7 @@ function readFile(file: string): string {
|
|
|
386
421
|
* anywhere in a script says nothing about whether the hook still does its job,
|
|
387
422
|
* while an unset variable inside the guard means the guarded block never runs.
|
|
388
423
|
*/
|
|
389
|
-
const GATING_VAR_RE =
|
|
390
|
-
/\[\s+-[nxfse]\s+"\$\{([A-Z][A-Z0-9_]*)(?::-[^}]*)?\}"|\[\s+-[nxfse]\s+"\$([A-Z][A-Z0-9_]*)"/g;
|
|
424
|
+
const GATING_VAR_RE = /\[\s+-[nxfse]\s+"\$\{([A-Z][A-Z0-9_]*)(?::-[^}]*)?\}"|\[\s+-[nxfse]\s+"\$([A-Z][A-Z0-9_]*)"/g;
|
|
391
425
|
|
|
392
426
|
export function unsetGatingVars(commands: string[], env: NodeJS.ProcessEnv = process.env): string[] {
|
|
393
427
|
const out = new Set<string>();
|
|
@@ -406,7 +440,8 @@ export function unsetGatingVars(commands: string[], env: NodeJS.ProcessEnv = pro
|
|
|
406
440
|
* Only what is run: a path in an argument or an `echo` says nothing about
|
|
407
441
|
* whether the hook works here.
|
|
408
442
|
*/
|
|
409
|
-
const MACHINE_PROGRAM_RE =
|
|
443
|
+
const MACHINE_PROGRAM_RE =
|
|
444
|
+
/^(?:"((?:\$HOME|\$\{HOME\}|~|\/Users|\/home|\/Volumes)\/[^"]+)"|((?:\$HOME|\$\{HOME\}|~|\/Users|\/home|\/Volumes)\/[^\s"';|&)]+))/;
|
|
410
445
|
|
|
411
446
|
export function missingMachinePaths(commands: string[], home: string = os.homedir()): string[] {
|
|
412
447
|
const out = new Set<string>();
|