@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/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 (LTS ≥ 20 recommended)',
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 < 20) {
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} — below recommended (≥ 20 LTS)`,
115
- remedy: 'mise use -g node@20 (or upgrade via nvm/brew)',
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 >= 22 ? 4 : 3, message: v };
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): any => {
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.plugins ?? {}).find((k) => k.startsWith('vdx@'));
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.plugins[key]?.[0]?.version;
378
+ const version = at(installed, 'plugins', key, '0', 'version');
378
379
  const label = `${key}${typeof version === 'string' ? ` ${version}` : ''}`;
379
- if (readJson('settings.json')?.enabledPlugins?.[key] === false) {
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 Unknown, type Verdict };
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 (typeof p === 'object' && !Array.isArray(p)) {
38
- const obj = p as Record<string, any>;
38
+ if (isDict(p)) {
39
+ const obj = p;
39
40
 
40
41
  if ('all_of' in obj) {
41
- const vs = (obj.all_of as Predicate[]).map((q) => evalVerdict(q, ctx));
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.any_of as Predicate[]).map((q) => evalVerdict(q, ctx));
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.at_least_n_of as { n: number; predicates: Predicate[] };
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.not, ctx);
61
+ const v = evalVerdict(obj['not'], ctx);
61
62
  return isUnknown(v) ? v : !v;
62
63
  }
63
64
  if ('fn' in obj) {
64
- const fn = REGISTRY[String(obj.fn)];
65
+ const name = String(obj['fn']);
66
+ const fn = REGISTRY[name];
65
67
  if (!fn) {
66
- process.stderr.write(`warning: unknown predicate "${obj.fn}"\n`);
68
+ process.stderr.write(`warning: unknown predicate "${name}"\n`);
67
69
  return false;
68
70
  }
69
- return fn(obj.args ?? {}, ctx);
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 (typeof value === 'object' && value !== null && !Array.isArray(value)) {
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 (q === null || q === undefined || typeof q !== 'object' || Array.isArray(q)) return;
108
- const obj = q as Record<string, any>;
109
- if ('all_of' in obj) return (obj.all_of as Predicate[]).forEach(walk);
110
- if ('any_of' in obj) return (obj.any_of as Predicate[]).forEach(walk);
111
- if ('at_least_n_of' in obj) return ((obj.at_least_n_of?.predicates as Predicate[]) ?? []).forEach(walk);
112
- if ('not' in obj) return walk(obj.not);
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.fn));
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): any {
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): any {
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
- export function readYaml(ctx: Ctx, rel: string): any {
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): any {
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
- const parts = jsonpath.split('.');
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 miseT = readToml(own, 'mise.toml');
146
- if (miseT?.tasks && typeof miseT.tasks === 'object') {
147
- for (const k of Object.keys(miseT.tasks as object)) tasks.add(k);
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 serverPkg = readJson(own, 'server/package.json');
162
- if (serverPkg?.scripts && typeof serverPkg.scripts === 'object') {
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
- if (composer.require?.[name]) return true;
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
- if (pkg.dependencies?.[name]) return true;
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): any {
226
- let cfg = readJson(ctx, 'tsconfig.json');
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
- if (cfg.extends && typeof cfg.extends === 'string' && cfg.extends.startsWith('.')) {
231
- const baseRel = cfg.extends.endsWith('.json') ? cfg.extends : `${cfg.extends}.json`;
232
- const base = readJson(ctx, baseRel);
233
- if (base?.compilerOptions) {
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 cfg;
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 list) {
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 || typeof on !== 'object') return false;
69
- if (!('push' in on)) return false;
70
- const push = (on as Record<string, any>).push;
71
- if (push === null || push === undefined || typeof push !== 'object') return true;
72
- const hasBranchFilter = push.branches !== undefined || push['branches-ignore'] !== undefined;
73
- if (!hasBranchFilter && (push.tags !== undefined || push['tags-ignore'] !== undefined)) return false;
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 (push.branches !== undefined) return branchSelected(push.branches, b);
76
- if (push['branches-ignore'] !== undefined) return !branchSelected(push['branches-ignore'], b);
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
- export function parseWorkflow(file: string, text: string): GhaWorkflow | null {
86
- let data: any;
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, job] of Object.entries((data.jobs ?? {}) as Record<string, any>)) {
95
- if (!job || typeof job !== 'object') continue;
96
- const needs = job.needs === undefined ? [] : Array.isArray(job.needs) ? job.needs : [job.needs];
97
- const steps: GhaStep[] = (Array.isArray(job.steps) ? job.steps : [])
98
- .filter((s: unknown) => s && typeof s === 'object')
99
- .map((s: any) => ({
100
- ...(typeof s.run === 'string' ? { run: s.run } : {}),
101
- ...(typeof s.uses === 'string' ? { uses: s.uses } : {}),
102
- continueOnError: truthy(s['continue-on-error']),
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
- ...(typeof job.name === 'string' ? { name: job.name } : {}),
107
- needs: needs.filter((n: unknown): n is string => typeof n === 'string'),
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.strategy?.matrix),
118
+ matrix: Boolean(at(job, 'strategy', 'matrix')),
111
119
  });
112
120
  }
113
- return { file, pushesMain: pushesMain(data.on), jobs };
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
- export function stepMasked(step: GhaStep, job: GhaJob): boolean {
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
- export type FrameworkId = 'husky' | 'lefthook' | 'simple-git-hooks' | 'cghooks' | 'githooks';
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
- export function yarnBerry(ctx: Ctx): boolean {
72
- const pm = readJson(ctx, 'package.json')?.packageManager;
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 pkg = readJson(ctx, 'package.json');
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
- if (typeof scripts[name] === 'string') {
87
- out.push({ where: `package.json ${name}`, text: expandNpm(scripts, scripts[name]) });
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 composer = readJson(ctx, 'composer.json');
91
- const cmd = composer?.scripts?.['post-install-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(composer.scripts, cmd) });
94
+ out.push({ where: 'composer post-install-cmd', text: expandComposer(composerScripts, cmd) });
94
95
  }
95
- const up = readToml(ctx, 'mise.toml')?.tasks?.up;
96
- if (up && typeof up === 'object') {
97
- const runs = (Array.isArray(up.run) ? up.run : [up.run]).filter((r: unknown) => typeof r === 'string');
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 || typeof node !== 'object') return out;
166
- const o = node as Record<string, any>;
167
- if (typeof o.run === 'string') out.push(o.run);
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
- if (o[key] && typeof o[key] === 'object') for (const v of Object.values(o[key])) out.push(...lefthookRuns(v));
172
+ for (const v of Object.values(dict(node[key]) ?? {})) out.push(...lefthookRuns(v));
171
173
  }
172
- if (Array.isArray(o.jobs)) for (const j of o.jobs) out.push(...lefthookRuns(j));
173
- if (o.group && typeof o.group === 'object') out.push(...lefthookRuns(o.group));
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: any;
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 ?? {}) as Record<string, unknown>)) {
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 sgh =
235
- (pkg?.['simple-git-hooks'] as Record<string, unknown> | undefined) ??
236
- readJson(ctx, '.simple-git-hooks.json') ??
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: pkg?.['simple-git-hooks'] ? 'package.json simple-git-hooks' : 'simple-git-hooks.json',
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?.extra?.hooks;
259
- if (extra && typeof extra === 'object') {
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 as Record<string, unknown>)) {
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
- export function installCommand(fw: HookFramework, ctx: Ctx): string {
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')?.packageManager;
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';