@untestutils/cli 0.5.4 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@untestutils/cli",
3
- "version": "0.5.4",
3
+ "version": "0.6.1",
4
4
  "description": "CLI for untestutils: init, convert, ai, doctor, and monorepo commands",
5
5
  "homepage": "https://s00d.github.io/untestutils/",
6
6
  "license": "MIT",
@@ -40,9 +40,9 @@
40
40
  "pathe": "^2.0.3",
41
41
  "tinyglobby": "^0.2.17",
42
42
  "tsx": "^4.23.13",
43
- "@untestutils/ai": "0.5.4",
44
- "@untestutils/perf": "0.5.4",
45
- "@untestutils/core": "0.5.4"
43
+ "@untestutils/perf": "0.6.1",
44
+ "@untestutils/ai": "0.6.1",
45
+ "@untestutils/core": "0.6.1"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@types/node": "^26.6.1",
@@ -1,8 +1,9 @@
1
1
  import { defineCommand, type CommandDef } from 'citty';
2
2
  import { consola } from 'consola';
3
- import { existsSync } from 'node:fs';
3
+ import { existsSync, readFileSync } from 'node:fs';
4
4
  import { resolve, join } from 'pathe';
5
5
  import { createRequire } from 'node:module';
6
+ import { pathToFileURL } from 'node:url';
6
7
  import { findMonorepoRoot } from '../utils/workspace';
7
8
 
8
9
  type Check = { ok: boolean; label: string; hint?: string; optional?: boolean };
@@ -17,89 +18,222 @@ function canResolve(id: string, cwd: string): boolean {
17
18
  }
18
19
  }
19
20
 
21
+ function readPkgVersion(id: string, cwd: string): string | undefined {
22
+ try {
23
+ const req = createRequire(join(cwd, 'package.json'));
24
+ const pkgJson = req.resolve(`${id}/package.json`);
25
+ const ver = (JSON.parse(readFileSync(pkgJson, 'utf8')) as { version?: string }).version;
26
+ return ver;
27
+ } catch {
28
+ return undefined;
29
+ }
30
+ }
31
+
32
+ function findRecipesFile(cwd: string): string | undefined {
33
+ for (const rel of ['recipes.ts', 'tests/recipes.ts', 'e2e/recipes.ts', 'recipes.mts']) {
34
+ const p = join(cwd, rel);
35
+ if (existsSync(p)) return p;
36
+ }
37
+ return undefined;
38
+ }
39
+
40
+ function hasPlaywrightConfig(cwd: string): boolean {
41
+ return ['playwright.config.ts', 'playwright.config.mts', 'playwright.config.js'].some((f) =>
42
+ existsSync(join(cwd, f)),
43
+ );
44
+ }
45
+
46
+ function fileMentions(cwd: string, needle: string): boolean {
47
+ const candidates = [
48
+ 'recipes.ts',
49
+ 'vitest.config.ts',
50
+ 'vitest.config.mts',
51
+ 'playwright.config.ts',
52
+ 'package.json',
53
+ ];
54
+ for (const rel of candidates) {
55
+ const p = join(cwd, rel);
56
+ if (!existsSync(p)) continue;
57
+ try {
58
+ if (readFileSync(p, 'utf8').includes(needle)) return true;
59
+ } catch {
60
+ /* skip */
61
+ }
62
+ }
63
+ return false;
64
+ }
65
+
66
+ async function runDoctorChecks(cwd: string): Promise<number> {
67
+ const checks: Check[] = [];
68
+ const mono = findMonorepoRoot(cwd);
69
+ const inMono = Boolean(mono && (cwd === mono || cwd.startsWith(`${mono}/`)));
70
+
71
+ const major = Number(process.versions.node.split('.')[0]);
72
+ checks.push({
73
+ ok: major >= 20,
74
+ label: `Node.js ${process.version}`,
75
+ hint: 'Requires Node >= 20',
76
+ });
77
+
78
+ for (const id of ['untestutils', 'vitest'] as const) {
79
+ checks.push({
80
+ ok: canResolve(id, cwd) || inMono,
81
+ label: `package ${id}`,
82
+ hint: `pnpm add -D ${id}`,
83
+ });
84
+ }
85
+
86
+ const vitestVer = readPkgVersion('vitest', cwd);
87
+ if (vitestVer) {
88
+ const vitestMajor = Number(vitestVer.split('.')[0]);
89
+ checks.push({
90
+ ok: vitestMajor === 4 || vitestMajor === 5,
91
+ label: `vitest major ${vitestMajor} (${vitestVer})`,
92
+ hint: 'Supported majors: 4 or 5 (peer ^4 || ^5)',
93
+ });
94
+ } else if (!inMono) {
95
+ checks.push({
96
+ ok: false,
97
+ label: 'vitest version',
98
+ hint: 'pnpm add -D vitest@^4 || vitest@^5',
99
+ });
100
+ }
101
+
102
+ const needsPw = hasPlaywrightConfig(cwd);
103
+ const hasPw = canResolve('@playwright/test', cwd) || inMono;
104
+ checks.push({
105
+ ok: !needsPw || hasPw,
106
+ optional: !needsPw,
107
+ label: needsPw ? '@playwright/test (playwright.config present)' : 'optional: @playwright/test',
108
+ hint: 'pnpm add -D @playwright/test playwright-core',
109
+ });
110
+
111
+ for (const id of ['playwright-core', 'nuxt', '@untestutils/nuxt'] as const) {
112
+ const ok = canResolve(id, cwd) || (inMono && id !== 'nuxt' && id !== '@untestutils/nuxt');
113
+ checks.push({
114
+ ok,
115
+ optional: true,
116
+ label: `optional: ${id}`,
117
+ hint: ok ? undefined : `needed for the matching preset (pnpm add -D ${id})`,
118
+ });
119
+ }
120
+
121
+ if (fileMentions(cwd, 'untestutils/perf') || fileMentions(cwd, '@untestutils/perf')) {
122
+ const ok = canResolve('@untestutils/perf', cwd) || inMono;
123
+ checks.push({
124
+ ok,
125
+ optional: true,
126
+ label: 'optional: @untestutils/perf (imported)',
127
+ hint: ok ? undefined : 'pnpm add -D @untestutils/perf',
128
+ });
129
+ }
130
+
131
+ if (fileMentions(cwd, 'untestutils/ai') || fileMentions(cwd, '@untestutils/ai')) {
132
+ const ok = canResolve('@untestutils/ai', cwd) || inMono;
133
+ checks.push({
134
+ ok,
135
+ optional: true,
136
+ label: 'optional: @untestutils/ai (imported)',
137
+ hint: ok ? undefined : 'pnpm add -D @untestutils/ai',
138
+ });
139
+ }
140
+
141
+ const configCandidates = [
142
+ 'vitest.config.ts',
143
+ 'vitest.config.mts',
144
+ 'vitest.config.js',
145
+ 'playwright.config.ts',
146
+ 'playwright.config.mts',
147
+ 'vitest.unit.config.ts',
148
+ ];
149
+ const hasConfig = inMono || configCandidates.some((f) => existsSync(join(cwd, f)));
150
+ checks.push({
151
+ ok: hasConfig,
152
+ label: 'vitest/playwright config',
153
+ hint: 'untestutils init --preset vitest',
154
+ });
155
+
156
+ const recipesFile = findRecipesFile(cwd);
157
+ checks.push({
158
+ ok: inMono || Boolean(recipesFile),
159
+ label: 'recipes.ts',
160
+ hint: 'create defineRecipes(...) or run init',
161
+ });
162
+
163
+ let failed = 0;
164
+ for (const c of checks) {
165
+ if (c.ok) {
166
+ consola.success(c.label);
167
+ } else if (c.optional) {
168
+ consola.info(`${c.label}${c.hint ? ` — ${c.hint}` : ''}`);
169
+ } else {
170
+ failed++;
171
+ consola.error(`${c.label}${c.hint ? ` — ${c.hint}` : ''}`);
172
+ }
173
+ }
174
+ return failed;
175
+ }
176
+
177
+ async function listRecipes(cwd: string): Promise<void> {
178
+ const recipesFile = findRecipesFile(cwd);
179
+ if (!recipesFile) {
180
+ consola.error('No recipes.ts found (looked for recipes.ts, tests/recipes.ts, e2e/recipes.ts)');
181
+ process.exitCode = 1;
182
+ return;
183
+ }
184
+ consola.start(`Loading ${recipesFile}`);
185
+ try {
186
+ const mod = (await import(pathToFileURL(recipesFile).href)) as {
187
+ recipes?: Record<string, { id?: string }>;
188
+ };
189
+ const map = mod.recipes;
190
+ if (!map || typeof map !== 'object') {
191
+ consola.error('recipes module must export `recipes` from defineRecipes(...)');
192
+ process.exitCode = 1;
193
+ return;
194
+ }
195
+ const ids = Object.entries(map).map(([key, recipe]) => recipe?.id ?? key);
196
+ if (ids.length === 0) {
197
+ consola.warn('No recipes registered');
198
+ return;
199
+ }
200
+ consola.success(`Recipes (${ids.length}):`);
201
+ for (const id of ids.sort()) {
202
+ consola.log(` - ${id}`);
203
+ }
204
+ } catch (e) {
205
+ consola.error(`Failed to load recipes: ${e}`);
206
+ consola.info('Ensure dependencies are installed and the recipes module can be imported.');
207
+ process.exitCode = 1;
208
+ }
209
+ }
210
+
20
211
  const doctorArgs = {
21
212
  cwd: {
22
213
  type: 'string',
23
214
  description: 'Project directory',
24
215
  default: '.',
25
216
  },
217
+ recipes: {
218
+ type: 'boolean',
219
+ description: 'List recipe ids from defineRecipes export',
220
+ default: false,
221
+ },
26
222
  } as const;
27
223
 
28
224
  export const doctorCommand: CommandDef<typeof doctorArgs> = defineCommand({
29
225
  meta: {
30
226
  name: 'doctor',
31
- description: 'Check untestutils environment and configs',
227
+ description: 'Check untestutils environment and configs (use --recipes to list ids)',
32
228
  },
33
229
  args: doctorArgs,
34
230
  async run({ args }) {
35
231
  const cwd = resolve(String(args.cwd));
36
- const checks: Check[] = [];
37
- const mono = findMonorepoRoot(cwd);
38
- const inMono = Boolean(mono && (cwd === mono || cwd.startsWith(`${mono}/`)));
39
-
40
- const major = Number(process.versions.node.split('.')[0]);
41
- checks.push({
42
- ok: major >= 20,
43
- label: `Node.js ${process.version}`,
44
- hint: 'Requires Node >= 20',
45
- });
46
-
47
- for (const id of ['untestutils', 'vitest'] as const) {
48
- checks.push({
49
- ok: canResolve(id, cwd) || inMono,
50
- label: `package ${id}`,
51
- hint: `pnpm add -D ${id}`,
52
- });
53
- }
54
-
55
- for (const id of ['@playwright/test', 'playwright-core', 'nuxt'] as const) {
56
- const ok = canResolve(id, cwd) || (inMono && id !== 'nuxt');
57
- checks.push({
58
- ok,
59
- optional: true,
60
- label: `optional: ${id}`,
61
- hint: ok ? undefined : `needed for the matching preset (pnpm add -D ${id})`,
62
- });
232
+ if (args.recipes) {
233
+ await listRecipes(cwd);
234
+ return;
63
235
  }
64
-
65
- const configCandidates = [
66
- 'vitest.config.ts',
67
- 'vitest.config.mts',
68
- 'vitest.config.js',
69
- 'playwright.config.ts',
70
- 'playwright.config.mts',
71
- 'vitest.unit.config.ts',
72
- ];
73
- const hasConfig = inMono || configCandidates.some((f) => existsSync(join(cwd, f)));
74
- checks.push({
75
- ok: hasConfig,
76
- label: 'vitest/playwright config',
77
- hint: 'untestutils init --preset vitest',
78
- });
79
-
80
- const hasRecipes =
81
- inMono ||
82
- existsSync(join(cwd, 'recipes.ts')) ||
83
- existsSync(join(cwd, 'tests/recipes.ts')) ||
84
- existsSync(join(cwd, 'e2e/recipes.ts'));
85
- checks.push({
86
- ok: hasRecipes,
87
- label: 'recipes.ts',
88
- hint: 'create defineRecipes(...) or run init',
89
- });
90
-
91
- let failed = 0;
92
- for (const c of checks) {
93
- if (c.ok) {
94
- consola.success(c.label);
95
- } else if (c.optional) {
96
- consola.info(`${c.label}${c.hint ? ` — ${c.hint}` : ''}`);
97
- } else {
98
- failed++;
99
- consola.error(`${c.label}${c.hint ? ` — ${c.hint}` : ''}`);
100
- }
101
- }
102
-
236
+ const failed = await runDoctorChecks(cwd);
103
237
  if (failed > 0) {
104
238
  consola.warn(`Issues: ${failed}`);
105
239
  process.exitCode = 1;
@@ -2,9 +2,15 @@ import { defineCommand, type CommandDef } from 'citty';
2
2
  import { consola } from 'consola';
3
3
  import { addDependency } from 'nypm';
4
4
  import { resolve } from 'pathe';
5
- import { applyPreset, peersForPreset, type InitPreset } from '../utils/templates';
5
+ import {
6
+ applyPreset,
7
+ peersForPreset,
8
+ type InitPreset,
9
+ type PackageManagerName,
10
+ } from '../utils/templates';
6
11
 
7
12
  const PRESETS: InitPreset[] = ['vitest', 'playwright', 'nuxt', 'full'];
13
+ const PMS: PackageManagerName[] = ['npm', 'pnpm', 'yarn', 'bun'];
8
14
 
9
15
  const initArgs = {
10
16
  preset: {
@@ -25,9 +31,13 @@ const initArgs = {
25
31
  },
26
32
  install: {
27
33
  type: 'boolean',
28
- description: 'Install peer dependencies via nypm',
34
+ description: 'Install peer dependencies via nypm (use --no-install to skip)',
29
35
  default: true,
30
36
  },
37
+ pm: {
38
+ type: 'string',
39
+ description: 'Package manager: pnpm | npm | yarn | bun (default: auto-detect, else pnpm)',
40
+ },
31
41
  } as const;
32
42
 
33
43
  export const initCommand: CommandDef<typeof initArgs> = defineCommand({
@@ -57,20 +67,32 @@ export const initCommand: CommandDef<typeof initArgs> = defineCommand({
57
67
  consola.start(`Installing dependencies: ${deps.join(', ')}`);
58
68
  try {
59
69
  // Fresh projects often have no lockfile — nypm cannot auto-detect. Prefer
60
- // detected PM, else pnpm (matches engines/docs), always as -D.
70
+ // --pm, else detected PM, else pnpm (matches engines/docs), always as -D.
71
+ const pmArg = args.pm ? String(args.pm) : undefined;
72
+ if (pmArg && !PMS.includes(pmArg as PackageManagerName)) {
73
+ consola.error(`Unknown --pm "${pmArg}". Available: ${PMS.join(', ')}`);
74
+ process.exitCode = 1;
75
+ return;
76
+ }
61
77
  const { detectPackageManager } = await import('nypm');
62
- const detected = await detectPackageManager(cwd, { includeParentDirs: false });
78
+ const detected = pmArg
79
+ ? undefined
80
+ : await detectPackageManager(cwd, { includeParentDirs: false });
81
+ const packageManager =
82
+ (pmArg as PackageManagerName | undefined) ?? detected?.name ?? 'pnpm';
63
83
  await addDependency(deps, {
64
84
  cwd,
65
85
  silent: false,
66
- packageManager: detected?.name ?? 'pnpm',
86
+ packageManager,
67
87
  dev: true,
68
88
  });
69
- consola.success('Dependencies installed');
89
+ consola.success(`Dependencies installed (${packageManager})`);
70
90
  } catch (e) {
71
91
  consola.warn(`Could not install automatically: ${e}`);
72
92
  consola.info(`Install manually: ${deps.join(' ')}`);
73
93
  }
94
+ } else {
95
+ consola.info('Skipped install (--no-install). Install peers manually when ready.');
74
96
  }
75
97
 
76
98
  consola.box(
@@ -79,6 +101,7 @@ export const initCommand: CommandDef<typeof initArgs> = defineCommand({
79
101
  ' 1. Review recipes.ts and fixture app paths',
80
102
  ' 2. pnpm test / npx vitest run',
81
103
  ' 3. untestutils doctor — environment check',
104
+ ' 4. untestutils doctor --recipes — list recipe ids',
82
105
  ].join('\n'),
83
106
  );
84
107
  },
@@ -5,7 +5,9 @@ import { writeIfMissing } from './fs';
5
5
 
6
6
  export type InitPreset = 'vitest' | 'playwright' | 'nuxt' | 'full';
7
7
 
8
- /** Relative template paths under src/templates */
8
+ export type PackageManagerName = 'npm' | 'pnpm' | 'yarn' | 'bun';
9
+
10
+ /** Relative template paths under templates/ */
9
11
  const PRESET_FILES: Record<InitPreset, string[]> = {
10
12
  vitest: [
11
13
  'vitest/vitest.config.ts',
@@ -19,7 +21,14 @@ const PRESET_FILES: Record<InitPreset, string[]> = {
19
21
  'playwright/tests/e2e/smoke.spec.ts',
20
22
  'vitest/fixtures/basic/index.html',
21
23
  ],
22
- nuxt: ['nuxt/vitest.config.ts', 'nuxt/recipes.ts', 'nuxt/tests/e2e/smoke.test.ts'],
24
+ nuxt: [
25
+ 'nuxt/vitest.config.ts',
26
+ 'nuxt/recipes.ts',
27
+ 'nuxt/tests/e2e/smoke.test.ts',
28
+ 'nuxt/fixtures/nuxt/package.json',
29
+ 'nuxt/fixtures/nuxt/nuxt.config.ts',
30
+ 'nuxt/fixtures/nuxt/app.vue',
31
+ ],
23
32
  full: [
24
33
  'vitest/vitest.config.ts',
25
34
  'playwright/playwright.config.ts',
@@ -64,7 +73,7 @@ export function peersForPreset(preset: InitPreset): string[] {
64
73
  base.push('@playwright/test', 'playwright-core');
65
74
  }
66
75
  if (preset === 'nuxt' || preset === 'full') {
67
- base.push('nuxt');
76
+ base.push('nuxt', '@untestutils/nuxt', 'vitest-environment-untestutils');
68
77
  }
69
78
  return [...new Set(base)];
70
79
  }
@@ -0,0 +1,5 @@
1
+ <template>
2
+ <div>
3
+ <h1>nuxt fixture ok</h1>
4
+ </div>
5
+ </template>
@@ -0,0 +1,4 @@
1
+ export default defineNuxtConfig({
2
+ ssr: true,
3
+ compatibilityDate: '2024-11-01',
4
+ });
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "untestutils-nuxt-fixture",
3
+ "private": true,
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "nuxt dev",
7
+ "build": "nuxt build",
8
+ "preview": "nuxt preview"
9
+ }
10
+ }