vize 0.303.0 → 0.310.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/dist/cli.mjs +1589 -3
- package/dist/cli.mjs.map +1 -1
- package/package.json +3 -3
- package/src/cli.ts +12 -3
- package/src/init/args.ts +135 -0
- package/src/init/detect.ts +221 -0
- package/src/init/edit-config.ts +161 -0
- package/src/init/lint-target.ts +202 -0
- package/src/init/plan-bundler.ts +97 -0
- package/src/init/plan-editor.ts +98 -0
- package/src/init/plan-lint.ts +68 -0
- package/src/init/plan-project.ts +114 -0
- package/src/init/plan-types.ts +77 -0
- package/src/init/plan.ts +167 -0
- package/src/init/prompt.ts +161 -0
- package/src/init/report.ts +136 -0
- package/src/init/select.ts +153 -0
- package/src/init/templates.ts +175 -0
- package/src/init/top-level.ts +143 -0
- package/src/init.ts +189 -0
package/dist/cli.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { createRequire } from "node:module";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { execFileSync } from "node:child_process";
|
|
5
|
+
import readline from "node:readline";
|
|
5
6
|
//#region src/setup/config.ts
|
|
6
7
|
const VIZE_CONFIG_FILES = [
|
|
7
8
|
"vize.config.pkl",
|
|
@@ -152,6 +153,1587 @@ function isNodeError(value) {
|
|
|
152
153
|
return value instanceof Error;
|
|
153
154
|
}
|
|
154
155
|
//#endregion
|
|
156
|
+
//#region src/init/templates.ts
|
|
157
|
+
/**
|
|
158
|
+
* Config sources `vize init` writes.
|
|
159
|
+
*
|
|
160
|
+
* Every Oxlint-facing template here is derived from one settings object so the
|
|
161
|
+
* `vp lint` block and the `oxlint` config can never describe different presets.
|
|
162
|
+
* See `lint-target.ts` for why writing the wrong one of the two is silent.
|
|
163
|
+
*/
|
|
164
|
+
/** Preset both Oxlint entry points run with. The bridge's own default. */
|
|
165
|
+
const INIT_LINT_PRESET = "general-recommended";
|
|
166
|
+
/** `settings.vize.helpLevel` both Oxlint entry points run with. */
|
|
167
|
+
const INIT_LINT_HELP_LEVEL = "short";
|
|
168
|
+
/** VS Code extension id published from `editors/vscode`. */
|
|
169
|
+
const VSCODE_EXTENSION_ID = "ubugeeei.vize";
|
|
170
|
+
/**
|
|
171
|
+
* Builds `vize.config.ts` from the selected features.
|
|
172
|
+
*
|
|
173
|
+
* Only selected features contribute a block, so a project that asked for the
|
|
174
|
+
* formatter alone does not silently get a type checker it never opted into.
|
|
175
|
+
*/
|
|
176
|
+
function renderVizeConfig(features) {
|
|
177
|
+
const blocks = [` compiler: {
|
|
178
|
+
templateSyntax: "standard",
|
|
179
|
+
},`];
|
|
180
|
+
if (features.lint) blocks.push(` linter: {
|
|
181
|
+
enabled: true,
|
|
182
|
+
preset: "${INIT_LINT_PRESET}",
|
|
183
|
+
},`);
|
|
184
|
+
if (features.fmt) blocks.push(` formatter: {
|
|
185
|
+
singleAttributePerLine: false,
|
|
186
|
+
sortBlocks: true,
|
|
187
|
+
},`);
|
|
188
|
+
if (features.typecheck) blocks.push(` typeChecker: {
|
|
189
|
+
enabled: true,
|
|
190
|
+
strict: true,
|
|
191
|
+
},`);
|
|
192
|
+
if (features.vite) blocks.push(` vite: {
|
|
193
|
+
scanPatterns: ["src/**/*.vue"],
|
|
194
|
+
},`);
|
|
195
|
+
return `import { defineConfig } from "vize";
|
|
196
|
+
|
|
197
|
+
export default defineConfig({
|
|
198
|
+
${blocks.join("\n")}
|
|
199
|
+
});
|
|
200
|
+
`;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Config for the `oxlint` binary.
|
|
204
|
+
*
|
|
205
|
+
* `.oxlintrc.json` cannot import `configs.recommended`, and the bridge only runs
|
|
206
|
+
* `vize/*` rules that appear in `rules`, so a JSON config would need every rule
|
|
207
|
+
* id inlined and would rot on the next rule addition. `oxlint.config.ts` is
|
|
208
|
+
* Oxlint's TypeScript config format and is auto-discovered (verified against
|
|
209
|
+
* oxlint 1.64; `oxlint.config.mjs`, `.js`, `.cjs`, `.mts` and `.cts` are not --
|
|
210
|
+
* see #3474), so it is the only form that stays correct over time.
|
|
211
|
+
*/
|
|
212
|
+
const INIT_OXLINT_CONFIG = `import { defineConfig } from "oxlint";
|
|
213
|
+
import { configs } from "oxlint-plugin-vize";
|
|
214
|
+
|
|
215
|
+
export default defineConfig({
|
|
216
|
+
plugins: ["vue"],
|
|
217
|
+
jsPlugins: ["oxlint-plugin-vize"],
|
|
218
|
+
settings: {
|
|
219
|
+
vize: {
|
|
220
|
+
preset: "${INIT_LINT_PRESET}",
|
|
221
|
+
helpLevel: "${INIT_LINT_HELP_LEVEL}",
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
rules: configs.recommended,
|
|
225
|
+
});
|
|
226
|
+
`;
|
|
227
|
+
/** Import line the Vite+ `lint` block needs. */
|
|
228
|
+
const VITE_LINT_IMPORT = "import { createVizeLintConfig } from \"oxlint-plugin-vize\";\n";
|
|
229
|
+
/**
|
|
230
|
+
* The Vite+ `lint` block, the only Oxlint configuration `vp lint` and `vp check`
|
|
231
|
+
* read.
|
|
232
|
+
*
|
|
233
|
+
* `createVizeLintConfig()` returns the whole block rather than fragments, which
|
|
234
|
+
* is what makes the `jsPlugins` entry impossible to omit. Hand-assembling the
|
|
235
|
+
* block is how a config ends up looking wired while reporting nothing.
|
|
236
|
+
*/
|
|
237
|
+
const VITE_LINT_BLOCK = ` lint: createVizeLintConfig({
|
|
238
|
+
preset: "${INIT_LINT_PRESET}",
|
|
239
|
+
settings: {
|
|
240
|
+
helpLevel: "${INIT_LINT_HELP_LEVEL}",
|
|
241
|
+
},
|
|
242
|
+
}),
|
|
243
|
+
`;
|
|
244
|
+
/** Snippet printed when a Vite config has no `lint` block and cannot be edited safely. */
|
|
245
|
+
const VITE_LINT_SNIPPET = `import { createVizeLintConfig } from "oxlint-plugin-vize";
|
|
246
|
+
|
|
247
|
+
export default defineConfig({
|
|
248
|
+
${VITE_LINT_BLOCK}});
|
|
249
|
+
`;
|
|
250
|
+
/**
|
|
251
|
+
* Snippet printed when the Vite config already has a `lint` block.
|
|
252
|
+
*
|
|
253
|
+
* Spreading is the documented way to keep an existing block's other keys while
|
|
254
|
+
* still taking the whole Vize block, `jsPlugins` included.
|
|
255
|
+
*/
|
|
256
|
+
const VITE_LINT_MERGE_SNIPPET = `import { createVizeLintConfig } from "oxlint-plugin-vize";
|
|
257
|
+
|
|
258
|
+
export default defineConfig({
|
|
259
|
+
lint: {
|
|
260
|
+
...createVizeLintConfig({
|
|
261
|
+
preset: "${INIT_LINT_PRESET}",
|
|
262
|
+
settings: {
|
|
263
|
+
helpLevel: "${INIT_LINT_HELP_LEVEL}",
|
|
264
|
+
},
|
|
265
|
+
}),
|
|
266
|
+
// keep your existing lint keys here
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
`;
|
|
270
|
+
const VITE_PLUGIN_IMPORT = "import vize from \"@vizejs/vite-plugin\";\n";
|
|
271
|
+
/**
|
|
272
|
+
* `.vscode/extensions.json` written when no file exists yet.
|
|
273
|
+
*
|
|
274
|
+
* Recommendations are chosen over `code --install-extension` because they are
|
|
275
|
+
* checked in, apply to the whole team, and change nothing on the machine that
|
|
276
|
+
* runs `init`.
|
|
277
|
+
*/
|
|
278
|
+
function renderVscodeExtensions(indent) {
|
|
279
|
+
return `${JSON.stringify({ recommendations: [VSCODE_EXTENSION_ID] }, null, indent)}\n`;
|
|
280
|
+
}
|
|
281
|
+
/** Editor integrations shipped from this repo, reported alongside the VS Code one. */
|
|
282
|
+
const EDITOR_INTEGRATIONS = [
|
|
283
|
+
"VS Code: ubugeeei.vize (recommended in .vscode/extensions.json)",
|
|
284
|
+
"Zed: tools/zed-vize",
|
|
285
|
+
"Neovim: tools/nvim-vize",
|
|
286
|
+
"Vim: tools/vim-vize",
|
|
287
|
+
"Helix: tools/helix-vize",
|
|
288
|
+
"Emacs: tools/emacs-vize"
|
|
289
|
+
];
|
|
290
|
+
//#endregion
|
|
291
|
+
//#region src/init/top-level.ts
|
|
292
|
+
/**
|
|
293
|
+
* Depth-aware lookup of a top-level key in a `defineConfig({ ... })` call.
|
|
294
|
+
*
|
|
295
|
+
* A plain regex cannot tell the config's own `plugins` key from the `plugins`
|
|
296
|
+
* key inside a `lint: { ... }` block, and picking the wrong one rewrites a part
|
|
297
|
+
* of the user's config they never asked to change. This scanner tracks bracket
|
|
298
|
+
* depth and skips strings, template literals and comments, so a key only matches
|
|
299
|
+
* at depth 0 of the config object.
|
|
300
|
+
*
|
|
301
|
+
* It is not a JavaScript parser and does not try to be: template-literal
|
|
302
|
+
* substitutions and regex literals are treated as ordinary text. Both make the
|
|
303
|
+
* scan give up or miss, which turns into a refusal to edit -- the safe direction.
|
|
304
|
+
*/
|
|
305
|
+
const IDENTIFIER = /^[$A-Z_a-z][$\w]*/u;
|
|
306
|
+
const KEY_SEPARATOR = /^\s*:/u;
|
|
307
|
+
/** Finds `key` at the top level of `callee({ ... })`, or `null`. */
|
|
308
|
+
function findTopLevelKey(source, callee, key) {
|
|
309
|
+
const opening = new RegExp(`\\b${callee}\\s*\\(\\s*\\{`, "u").exec(source);
|
|
310
|
+
if (opening === null) return null;
|
|
311
|
+
let index = opening.index + opening[0].length;
|
|
312
|
+
let depth = 0;
|
|
313
|
+
while (index < source.length) {
|
|
314
|
+
const char = source[index];
|
|
315
|
+
const skipped = skipNonCode(source, index);
|
|
316
|
+
if (skipped !== index) {
|
|
317
|
+
index = skipped;
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (char === "{" || char === "[" || char === "(") {
|
|
321
|
+
depth += 1;
|
|
322
|
+
index += 1;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
if (char === "}" || char === "]" || char === ")") {
|
|
326
|
+
if (depth === 0) return null;
|
|
327
|
+
depth -= 1;
|
|
328
|
+
index += 1;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
const identifier = IDENTIFIER.exec(source.slice(index));
|
|
332
|
+
if (identifier === null) {
|
|
333
|
+
index += 1;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const separator = KEY_SEPARATOR.exec(source.slice(index + identifier[0].length));
|
|
337
|
+
if (depth === 0 && identifier[0] === key && separator !== null) return {
|
|
338
|
+
keyStart: index,
|
|
339
|
+
valueStart: index + identifier[0].length + separator[0].length
|
|
340
|
+
};
|
|
341
|
+
index += identifier[0].length;
|
|
342
|
+
}
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
/** Number of `callee({` openings in the source. */
|
|
346
|
+
function countConfigCalls(source, callee) {
|
|
347
|
+
return [...source.matchAll(new RegExp(`\\b${callee}\\s*\\(\\s*\\{`, "gu"))].length;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Reads the array literal a top-level key is assigned to.
|
|
351
|
+
*
|
|
352
|
+
* Returns `null` when the value is not an array literal -- a spread from a
|
|
353
|
+
* variable, or a helper call -- because inserting into those would change what
|
|
354
|
+
* the config evaluates to.
|
|
355
|
+
*/
|
|
356
|
+
function readTopLevelArray(source, callee, key) {
|
|
357
|
+
const found = findTopLevelKey(source, callee, key);
|
|
358
|
+
if (found === null) return null;
|
|
359
|
+
const rest = source.slice(found.valueStart);
|
|
360
|
+
const leading = /^\s*/u.exec(rest)[0];
|
|
361
|
+
if (rest[leading.length] !== "[") return null;
|
|
362
|
+
const contentStart = found.valueStart + leading.length + 1;
|
|
363
|
+
return {
|
|
364
|
+
contentStart,
|
|
365
|
+
empty: /^\s*\]/u.test(source.slice(contentStart))
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Advances past a string, template literal or comment starting at `index`.
|
|
370
|
+
*
|
|
371
|
+
* Returns `index` unchanged when nothing at that position needs skipping.
|
|
372
|
+
*/
|
|
373
|
+
function skipNonCode(source, index) {
|
|
374
|
+
const char = source[index];
|
|
375
|
+
if (char === "\"" || char === "'" || char === "`") return skipQuoted(source, index, char);
|
|
376
|
+
if (char !== "/") return index;
|
|
377
|
+
const next = source[index + 1];
|
|
378
|
+
if (next === "/") {
|
|
379
|
+
const end = source.indexOf("\n", index);
|
|
380
|
+
return end === -1 ? source.length : end;
|
|
381
|
+
}
|
|
382
|
+
if (next === "*") {
|
|
383
|
+
const end = source.indexOf("*/", index + 2);
|
|
384
|
+
return end === -1 ? source.length : end + 2;
|
|
385
|
+
}
|
|
386
|
+
return index;
|
|
387
|
+
}
|
|
388
|
+
function skipQuoted(source, index, quote) {
|
|
389
|
+
let cursor = index + 1;
|
|
390
|
+
while (cursor < source.length) {
|
|
391
|
+
const char = source[cursor];
|
|
392
|
+
if (char === "\\") {
|
|
393
|
+
cursor += 2;
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
if (char === quote) return cursor + 1;
|
|
397
|
+
cursor += 1;
|
|
398
|
+
}
|
|
399
|
+
return source.length;
|
|
400
|
+
}
|
|
401
|
+
//#endregion
|
|
402
|
+
//#region src/init/edit-config.ts
|
|
403
|
+
/**
|
|
404
|
+
* Conservative source edits for user-owned `vite.config.*` and `nuxt.config.*`.
|
|
405
|
+
*
|
|
406
|
+
* Every function here returns `null` rather than guessing. A wrong edit to a
|
|
407
|
+
* build config breaks the project; a `null` costs the user one paste of a
|
|
408
|
+
* snippet `init` prints for them.
|
|
409
|
+
*/
|
|
410
|
+
const VITE_CALLEE = "defineConfig";
|
|
411
|
+
const NUXT_CALLEE = "defineNuxtConfig";
|
|
412
|
+
/**
|
|
413
|
+
* `defineConfig({` plus the newline that usually follows it.
|
|
414
|
+
*
|
|
415
|
+
* The trailing newline is consumed and re-emitted by the injectors so an
|
|
416
|
+
* inserted key does not leave a stray blank line behind in the user's file.
|
|
417
|
+
*/
|
|
418
|
+
const VITE_OPENING = /\bdefineConfig\s*\(\s*\{[^\S\r\n]*(?:\r?\n)?/u;
|
|
419
|
+
const NUXT_OPENING = /\bdefineNuxtConfig\s*\(\s*\{[^\S\r\n]*(?:\r?\n)?/u;
|
|
420
|
+
/**
|
|
421
|
+
* Whether a Vite config is a single plain `defineConfig({ ... })` call that a
|
|
422
|
+
* new top-level key can be inserted into.
|
|
423
|
+
*
|
|
424
|
+
* Anything else -- several `defineConfig` calls, a config built from a variable,
|
|
425
|
+
* or a config that already declares the key -- is left alone.
|
|
426
|
+
*/
|
|
427
|
+
function canInjectViteKey(source, key) {
|
|
428
|
+
if (hasTopLevelKey(source, key)) return false;
|
|
429
|
+
return countConfigCalls(source, VITE_CALLEE) === 1;
|
|
430
|
+
}
|
|
431
|
+
/** Whether the Vite config declares `key` at the top level of its `defineConfig` call. */
|
|
432
|
+
function hasTopLevelKey(source, key) {
|
|
433
|
+
return findTopLevelKey(source, VITE_CALLEE, key) !== null;
|
|
434
|
+
}
|
|
435
|
+
/** Whether the Vite+ `lint` block can be injected into this source. */
|
|
436
|
+
function canInjectViteLint(source) {
|
|
437
|
+
if (source.includes("oxlint-plugin-vize")) return false;
|
|
438
|
+
return canInjectViteKey(source, "lint");
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Inserts the `lint` block, and its import, into a Vite config.
|
|
442
|
+
*
|
|
443
|
+
* Returns `null` when the source does not have the shape `canInjectViteLint`
|
|
444
|
+
* accepts, so callers cannot inject blindly.
|
|
445
|
+
*/
|
|
446
|
+
function injectViteLint(source) {
|
|
447
|
+
if (!canInjectViteLint(source)) return null;
|
|
448
|
+
const withImport = insertImport(source, VITE_LINT_IMPORT);
|
|
449
|
+
if (withImport === null) return null;
|
|
450
|
+
return withImport.replace(VITE_OPENING, () => `defineConfig({\n${VITE_LINT_BLOCK}`);
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Adds `vize()` to a Vite config's top-level `plugins` array, importing the
|
|
454
|
+
* plugin.
|
|
455
|
+
*
|
|
456
|
+
* The array is located by depth-aware scan rather than by regex: a Vite+ config
|
|
457
|
+
* can carry a second `plugins` key inside its `lint` block, and appending Vize's
|
|
458
|
+
* Vite plugin to Oxlint's plugin list would corrupt both.
|
|
459
|
+
*/
|
|
460
|
+
function injectVitePlugin(source) {
|
|
461
|
+
if (source.includes("@vizejs/vite-plugin")) return null;
|
|
462
|
+
const withImport = insertImport(source, VITE_PLUGIN_IMPORT);
|
|
463
|
+
if (withImport === null) return null;
|
|
464
|
+
const plugins = readTopLevelArray(withImport, VITE_CALLEE, "plugins");
|
|
465
|
+
if (plugins !== null) return insertArrayEntry(withImport, plugins.contentStart, "vize()", plugins.empty);
|
|
466
|
+
if (findTopLevelKey(withImport, VITE_CALLEE, "plugins") !== null) return null;
|
|
467
|
+
if (!canInjectViteKey(withImport, "plugins")) return null;
|
|
468
|
+
return withImport.replace(VITE_OPENING, () => `defineConfig({\n plugins: [vize()],\n`);
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Adds `"@vizejs/nuxt"` to a Nuxt config's top-level `modules` array.
|
|
472
|
+
*
|
|
473
|
+
* Nuxt owns its own Vite instance, so the module is the supported integration
|
|
474
|
+
* point; adding `@vizejs/vite-plugin` to a Nuxt-managed Vite config would fight
|
|
475
|
+
* it.
|
|
476
|
+
*/
|
|
477
|
+
function injectNuxtModule(source) {
|
|
478
|
+
if (source.includes("@vizejs/nuxt")) return null;
|
|
479
|
+
if (countConfigCalls(source, NUXT_CALLEE) !== 1) return null;
|
|
480
|
+
const modules = readTopLevelArray(source, NUXT_CALLEE, "modules");
|
|
481
|
+
if (modules !== null) return insertArrayEntry(source, modules.contentStart, "\"@vizejs/nuxt\"", modules.empty);
|
|
482
|
+
if (findTopLevelKey(source, NUXT_CALLEE, "modules") !== null) return null;
|
|
483
|
+
return source.replace(NUXT_OPENING, () => `defineNuxtConfig({\n modules: ["@vizejs/nuxt"],\n`);
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Inserts `entry` as the first element of an array literal.
|
|
487
|
+
*
|
|
488
|
+
* Prepending keeps the user's existing entries in their original order and
|
|
489
|
+
* leaves their formatting alone.
|
|
490
|
+
*/
|
|
491
|
+
function insertArrayEntry(source, contentStart, entry, empty) {
|
|
492
|
+
const suffix = empty ? "" : ", ";
|
|
493
|
+
const tail = empty ? source.slice(contentStart).replace(/^\s*/u, "") : source.slice(contentStart);
|
|
494
|
+
return `${source.slice(0, contentStart)}${entry}${suffix}${tail}`;
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Inserts an import after the last existing top-level import.
|
|
498
|
+
*
|
|
499
|
+
* A config with no imports at all returns `null`: the safe insertion point is
|
|
500
|
+
* not obvious, and the file is unusual enough to be worth a human look.
|
|
501
|
+
*/
|
|
502
|
+
function insertImport(source, importLine) {
|
|
503
|
+
if (source.includes(importLine.trimEnd())) return source;
|
|
504
|
+
const lastImport = [...source.matchAll(/^import[^\r\n]*(?:from\s+["'][^"']+["']|["'][^"']+["'])\s*;?[^\S\r\n]*(?:\r?\n|$)/gmu)].at(-1);
|
|
505
|
+
if (lastImport === void 0 || lastImport.index === void 0) return null;
|
|
506
|
+
const end = lastImport.index + lastImport[0].length;
|
|
507
|
+
return source.slice(0, end) + importLine + source.slice(end);
|
|
508
|
+
}
|
|
509
|
+
//#endregion
|
|
510
|
+
//#region src/init/lint-target.ts
|
|
511
|
+
/**
|
|
512
|
+
* Oxlint config filenames the `oxlint` binary actually auto-discovers.
|
|
513
|
+
*
|
|
514
|
+
* Verified against oxlint 1.64: `.oxlintrc.json`, `.oxlintrc.jsonc` and
|
|
515
|
+
* `oxlint.config.ts` are read; `oxlint.config.mts`, `.js`, `.mjs`, `.cjs` and
|
|
516
|
+
* `.cts` produce a run byte-identical to having no config at all. The wider list
|
|
517
|
+
* in `setup/config.ts` treats all eight as configuration, which is #3474. `init`
|
|
518
|
+
* uses this narrower list so it never reports an unread file as configured.
|
|
519
|
+
*/
|
|
520
|
+
const DISCOVERED_OXLINT_CONFIG_FILES = [
|
|
521
|
+
".oxlintrc.json",
|
|
522
|
+
".oxlintrc.jsonc",
|
|
523
|
+
"oxlint.config.ts"
|
|
524
|
+
];
|
|
525
|
+
/** Filename `init` writes when the `oxlint` binary is the lint entry point. */
|
|
526
|
+
const INIT_OXLINT_CONFIG_FILE = "oxlint.config.ts";
|
|
527
|
+
/**
|
|
528
|
+
* Chooses which Oxlint configuration file(s) the project needs.
|
|
529
|
+
*
|
|
530
|
+
* The `oxlint` binary is treated as an entry point whenever the project already
|
|
531
|
+
* carries a discovered Oxlint config or runs `oxlint` from a script. A Vite+
|
|
532
|
+
* project that also does either gets both files, generated from the same preset
|
|
533
|
+
* and help level, because keeping one of them silently stale is the same class
|
|
534
|
+
* of bug as writing the wrong one.
|
|
535
|
+
*/
|
|
536
|
+
function resolveLintTarget(input) {
|
|
537
|
+
const { detection } = input;
|
|
538
|
+
const existing = discoveredOxlintConfig(detection);
|
|
539
|
+
const runsOxlintBinary = existing !== null || hasOxlintScript(detection);
|
|
540
|
+
if (!detection.usesVitePlus) return {
|
|
541
|
+
kind: "oxlint",
|
|
542
|
+
viteConfig: null,
|
|
543
|
+
oxlintConfig: existing === null ? INIT_OXLINT_CONFIG_FILE : null,
|
|
544
|
+
preservedOxlintConfig: existing,
|
|
545
|
+
reason: `no Vite+ detected, so \`oxlint\` is the lint entry point and reads ${existing ?? "oxlint.config.ts"}`,
|
|
546
|
+
blockedReason: null,
|
|
547
|
+
blockedSnippet: null
|
|
548
|
+
};
|
|
549
|
+
const viteConfig = detection.viteConfigs.length === 1 ? detection.viteConfigs[0] : null;
|
|
550
|
+
const injectable = input.viteSource !== null && canInjectViteLint(input.viteSource);
|
|
551
|
+
if (!detection.hasVitePlusLintBlock && !injectable) {
|
|
552
|
+
const blocked = describeBlocked(detection, input.viteSource);
|
|
553
|
+
return {
|
|
554
|
+
kind: "manual",
|
|
555
|
+
viteConfig: null,
|
|
556
|
+
oxlintConfig: null,
|
|
557
|
+
preservedOxlintConfig: existing,
|
|
558
|
+
reason: "Vite+ detected, so `vp lint` reads the `lint` block in the Vite config",
|
|
559
|
+
blockedReason: blocked.reason,
|
|
560
|
+
blockedSnippet: blocked.snippet
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
if (!runsOxlintBinary) return {
|
|
564
|
+
kind: "vite-plus",
|
|
565
|
+
viteConfig,
|
|
566
|
+
oxlintConfig: null,
|
|
567
|
+
preservedOxlintConfig: null,
|
|
568
|
+
reason: `Vite+ detected, so \`vp lint\` reads the \`lint\` block in ${viteConfig ?? "the Vite config"} and never reads .oxlintrc.json`,
|
|
569
|
+
blockedReason: null,
|
|
570
|
+
blockedSnippet: null
|
|
571
|
+
};
|
|
572
|
+
return {
|
|
573
|
+
kind: "both",
|
|
574
|
+
viteConfig,
|
|
575
|
+
oxlintConfig: existing === null ? INIT_OXLINT_CONFIG_FILE : null,
|
|
576
|
+
preservedOxlintConfig: existing,
|
|
577
|
+
reason: `Vite+ and the \`oxlint\` binary are both in use, so the \`lint\` block in ${viteConfig ?? "the Vite config"} and ${existing ?? "oxlint.config.ts"} are written from the same preset`,
|
|
578
|
+
blockedReason: null,
|
|
579
|
+
blockedSnippet: null
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* The existing Oxlint config, restricted to names Oxlint actually reads.
|
|
584
|
+
*
|
|
585
|
+
* A project holding only `oxlint.config.mjs` is deliberately treated as having
|
|
586
|
+
* no Oxlint config, because that is how Oxlint treats it.
|
|
587
|
+
*/
|
|
588
|
+
function discoveredOxlintConfig(detection) {
|
|
589
|
+
const existing = detection.oxlintConfig;
|
|
590
|
+
if (existing === null) return null;
|
|
591
|
+
return DISCOVERED_OXLINT_CONFIG_FILES.includes(existing) ? existing : null;
|
|
592
|
+
}
|
|
593
|
+
/** An Oxlint config file that is present but which Oxlint will never read. */
|
|
594
|
+
function unreadOxlintConfig(detection) {
|
|
595
|
+
const existing = detection.oxlintConfig;
|
|
596
|
+
if (existing === null || discoveredOxlintConfig(detection) !== null) return null;
|
|
597
|
+
return existing;
|
|
598
|
+
}
|
|
599
|
+
function hasOxlintScript(detection) {
|
|
600
|
+
return Object.values(detection.scripts).some((command) => /(?:^|[\s&|;])oxlint(?:-vize)?(?:\s|$)/u.test(command));
|
|
601
|
+
}
|
|
602
|
+
/**
|
|
603
|
+
* Why the `lint` block will not be written.
|
|
604
|
+
*
|
|
605
|
+
* The message is the whole value of a blocked result, so it names the specific
|
|
606
|
+
* obstacle instead of a generic "could not edit". An existing `lint` block in
|
|
607
|
+
* particular is a merge the user has to make, not a failure of the file.
|
|
608
|
+
*/
|
|
609
|
+
function describeBlocked(detection, viteSource) {
|
|
610
|
+
if (detection.viteConfigs.length === 0) return {
|
|
611
|
+
reason: "no vite.config file to hold the `lint` block",
|
|
612
|
+
snippet: VITE_LINT_SNIPPET
|
|
613
|
+
};
|
|
614
|
+
if (detection.viteConfigs.length > 1) return {
|
|
615
|
+
reason: `several Vite configs (${detection.viteConfigs.join(", ")}), so the target is ambiguous`,
|
|
616
|
+
snippet: VITE_LINT_SNIPPET
|
|
617
|
+
};
|
|
618
|
+
const filename = detection.viteConfigs[0];
|
|
619
|
+
if (viteSource !== null && hasTopLevelKey(viteSource, "lint")) return {
|
|
620
|
+
reason: `${filename} already has a \`lint\` block and merging into it would risk dropping settings, so spread createVizeLintConfig() into it by hand`,
|
|
621
|
+
snippet: VITE_LINT_MERGE_SNIPPET
|
|
622
|
+
};
|
|
623
|
+
return {
|
|
624
|
+
reason: `${filename} is not a single plain defineConfig({ ... }) call`,
|
|
625
|
+
snippet: VITE_LINT_SNIPPET
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
//#endregion
|
|
629
|
+
//#region src/init/select.ts
|
|
630
|
+
const FEATURE_IDS = [
|
|
631
|
+
"lint",
|
|
632
|
+
"bundler",
|
|
633
|
+
"fmt",
|
|
634
|
+
"typecheck",
|
|
635
|
+
"editor"
|
|
636
|
+
];
|
|
637
|
+
/**
|
|
638
|
+
* Turns detection into the five offers `init` presents.
|
|
639
|
+
*
|
|
640
|
+
* Already-configured features stay selected by default so a re-run is a no-op
|
|
641
|
+
* the user can confirm rather than a set of boxes they have to re-tick.
|
|
642
|
+
*/
|
|
643
|
+
function offerFeatures(detection) {
|
|
644
|
+
return [
|
|
645
|
+
lintOffer(detection),
|
|
646
|
+
bundlerOffer(detection),
|
|
647
|
+
fmtOffer(detection),
|
|
648
|
+
typecheckOffer(detection),
|
|
649
|
+
editorOffer(detection)
|
|
650
|
+
];
|
|
651
|
+
}
|
|
652
|
+
/** Selection implied by detection alone, used by `--yes` and as the prompt default. */
|
|
653
|
+
function defaultSelection(offers) {
|
|
654
|
+
const selection = {
|
|
655
|
+
lint: false,
|
|
656
|
+
bundler: false,
|
|
657
|
+
fmt: false,
|
|
658
|
+
typecheck: false,
|
|
659
|
+
editor: false
|
|
660
|
+
};
|
|
661
|
+
for (const offer of offers) selection[offer.id] = offer.defaultSelected;
|
|
662
|
+
return selection;
|
|
663
|
+
}
|
|
664
|
+
function lintOffer(detection) {
|
|
665
|
+
const configured = detection.usesVitePlus ? detection.hasVitePlusLintBlock : discoveredOxlintConfig(detection) !== null;
|
|
666
|
+
const unread = unreadOxlintConfig(detection);
|
|
667
|
+
return {
|
|
668
|
+
id: "lint",
|
|
669
|
+
label: detection.usesVitePlus ? "oxlint plugin (vp lint reads the `lint` block in the Vite config)" : "oxlint plugin (the oxlint binary reads oxlint.config.ts)",
|
|
670
|
+
available: true,
|
|
671
|
+
configured,
|
|
672
|
+
note: configured ? "already configured" : unread === null ? "" : `${unread} exists but oxlint never reads it (#3474)`,
|
|
673
|
+
defaultSelected: true
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
function bundlerOffer(detection) {
|
|
677
|
+
if (detection.framework === "nuxt") return {
|
|
678
|
+
id: "bundler",
|
|
679
|
+
label: "nuxt module (@vizejs/nuxt)",
|
|
680
|
+
available: detection.nuxtConfig !== null,
|
|
681
|
+
configured: detection.hasVizeNuxtModule,
|
|
682
|
+
note: detection.hasVizeNuxtModule ? "already configured" : detection.nuxtConfig === null ? "no nuxt.config file to add @vizejs/nuxt to" : "",
|
|
683
|
+
defaultSelected: detection.nuxtConfig !== null
|
|
684
|
+
};
|
|
685
|
+
if (detection.framework === "vite") {
|
|
686
|
+
const single = detection.viteConfigs.length === 1;
|
|
687
|
+
return {
|
|
688
|
+
id: "bundler",
|
|
689
|
+
label: "vite plugin (@vizejs/vite-plugin)",
|
|
690
|
+
available: single,
|
|
691
|
+
configured: detection.hasVizeVitePlugin,
|
|
692
|
+
note: detection.hasVizeVitePlugin ? "already configured" : single ? "" : `several Vite configs (${detection.viteConfigs.join(", ")})`,
|
|
693
|
+
defaultSelected: single
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
return {
|
|
697
|
+
id: "bundler",
|
|
698
|
+
label: "vite plugin or nuxt module",
|
|
699
|
+
available: false,
|
|
700
|
+
configured: false,
|
|
701
|
+
note: "no vite.config or nuxt.config found; the other features work without one",
|
|
702
|
+
defaultSelected: false
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
function fmtOffer(detection) {
|
|
706
|
+
const configured = detection.vizeConfig !== null && "vize:fmt" in detection.scripts;
|
|
707
|
+
return {
|
|
708
|
+
id: "fmt",
|
|
709
|
+
label: "fmt (vize fmt)",
|
|
710
|
+
available: true,
|
|
711
|
+
configured,
|
|
712
|
+
note: configured ? "already configured" : "",
|
|
713
|
+
defaultSelected: true
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
function typecheckOffer(detection) {
|
|
717
|
+
const configured = detection.vizeConfig !== null && "vize:check" in detection.scripts;
|
|
718
|
+
return {
|
|
719
|
+
id: "typecheck",
|
|
720
|
+
label: "typecheck (vize check)",
|
|
721
|
+
available: detection.tsconfig !== null,
|
|
722
|
+
configured,
|
|
723
|
+
note: configured ? "already configured" : detection.tsconfig === null ? "needs a tsconfig.json" : "",
|
|
724
|
+
defaultSelected: detection.tsconfig !== null
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
function editorOffer(detection) {
|
|
728
|
+
return {
|
|
729
|
+
id: "editor",
|
|
730
|
+
label: "editor extension (.vscode/extensions.json recommendation)",
|
|
731
|
+
available: true,
|
|
732
|
+
configured: detection.vscodeRecommendsVize,
|
|
733
|
+
note: detection.vscodeRecommendsVize ? "already recommended" : "",
|
|
734
|
+
defaultSelected: true
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
//#endregion
|
|
738
|
+
//#region src/init/args.ts
|
|
739
|
+
const PACKAGE_MANAGERS = [
|
|
740
|
+
"pnpm",
|
|
741
|
+
"npm",
|
|
742
|
+
"yarn",
|
|
743
|
+
"bun",
|
|
744
|
+
"vp"
|
|
745
|
+
];
|
|
746
|
+
/**
|
|
747
|
+
* Parses `vize init` arguments.
|
|
748
|
+
*
|
|
749
|
+
* `--yes` is the only switch that disables prompting. Per-feature flags without
|
|
750
|
+
* it still prompt, using the flags as the pre-ticked defaults, which keeps a
|
|
751
|
+
* half-typed command from silently writing files.
|
|
752
|
+
*/
|
|
753
|
+
function parseInitArgs(args) {
|
|
754
|
+
const overrides = {};
|
|
755
|
+
let root = null;
|
|
756
|
+
let bundlerOverride = null;
|
|
757
|
+
let yes = false;
|
|
758
|
+
let dryRun = false;
|
|
759
|
+
let install = true;
|
|
760
|
+
let packageManager = null;
|
|
761
|
+
let help = false;
|
|
762
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
763
|
+
const arg = args[index];
|
|
764
|
+
if (arg === "-h" || arg === "--help") {
|
|
765
|
+
help = true;
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
if (arg === "-y" || arg === "--yes") {
|
|
769
|
+
yes = true;
|
|
770
|
+
continue;
|
|
771
|
+
}
|
|
772
|
+
if (arg === "--dry-run") {
|
|
773
|
+
dryRun = true;
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
if (arg === "--no-install") {
|
|
777
|
+
install = false;
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
if (arg === "--package-manager") {
|
|
781
|
+
packageManager = requirePackageManager(args[index + 1]);
|
|
782
|
+
index += 1;
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
if (arg.startsWith("--package-manager=")) {
|
|
786
|
+
packageManager = requirePackageManager(arg.slice(18));
|
|
787
|
+
continue;
|
|
788
|
+
}
|
|
789
|
+
if (arg === "--vite" || arg === "--nuxt") {
|
|
790
|
+
bundlerOverride = arg === "--vite" ? "vite" : "nuxt";
|
|
791
|
+
overrides.bundler = true;
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
const feature = matchFeatureFlag(arg);
|
|
795
|
+
if (feature !== null) {
|
|
796
|
+
overrides[feature.id] = feature.enabled;
|
|
797
|
+
continue;
|
|
798
|
+
}
|
|
799
|
+
if (arg.startsWith("-")) throw new Error(`Unknown init option: ${arg}`);
|
|
800
|
+
if (root !== null) throw new Error(`Unexpected init argument: ${arg}`);
|
|
801
|
+
root = arg;
|
|
802
|
+
}
|
|
803
|
+
return {
|
|
804
|
+
root,
|
|
805
|
+
overrides,
|
|
806
|
+
bundlerOverride,
|
|
807
|
+
yes,
|
|
808
|
+
dryRun,
|
|
809
|
+
install,
|
|
810
|
+
packageManager,
|
|
811
|
+
help
|
|
812
|
+
};
|
|
813
|
+
}
|
|
814
|
+
function matchFeatureFlag(arg) {
|
|
815
|
+
for (const id of FEATURE_IDS) {
|
|
816
|
+
if (arg === `--${id}`) return {
|
|
817
|
+
id,
|
|
818
|
+
enabled: true
|
|
819
|
+
};
|
|
820
|
+
if (arg === `--no-${id}`) return {
|
|
821
|
+
id,
|
|
822
|
+
enabled: false
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
return null;
|
|
826
|
+
}
|
|
827
|
+
function requirePackageManager(value) {
|
|
828
|
+
if (value === void 0 || value.startsWith("-")) throw new Error("--package-manager requires a value");
|
|
829
|
+
if (!PACKAGE_MANAGERS.includes(value)) throw new Error(`Unknown package manager: ${value}. Expected one of ${PACKAGE_MANAGERS.join(", ")}`);
|
|
830
|
+
return value;
|
|
831
|
+
}
|
|
832
|
+
function initHelp() {
|
|
833
|
+
return `Select, install, and configure Vize in an existing project
|
|
834
|
+
|
|
835
|
+
Usage: vize init [ROOT] [OPTIONS]
|
|
836
|
+
|
|
837
|
+
Arguments:
|
|
838
|
+
[ROOT] Project root containing package.json (default: current directory)
|
|
839
|
+
|
|
840
|
+
Options:
|
|
841
|
+
-y, --yes Accept the detected selection without prompting
|
|
842
|
+
--lint / --no-lint oxlint plugin
|
|
843
|
+
--vite vite plugin (forces the Vite target)
|
|
844
|
+
--nuxt nuxt module (forces the Nuxt target)
|
|
845
|
+
--bundler/--no-bundler vite plugin or nuxt module, auto-detected
|
|
846
|
+
--fmt / --no-fmt vize fmt
|
|
847
|
+
--typecheck vize check (needs a tsconfig.json)
|
|
848
|
+
--no-typecheck
|
|
849
|
+
--editor / --no-editor .vscode/extensions.json recommendation
|
|
850
|
+
--dry-run Print the plan without writing anything
|
|
851
|
+
--no-install Write configuration without installing dependencies
|
|
852
|
+
--package-manager <PM> One of ${PACKAGE_MANAGERS.join(", ")} (default: detected)
|
|
853
|
+
-h, --help Print help
|
|
854
|
+
|
|
855
|
+
Without --yes, init prompts. A non-TTY stdin is detected and refused rather than
|
|
856
|
+
hung, so CI must pass --yes together with the per-feature flags it wants.
|
|
857
|
+
`;
|
|
858
|
+
}
|
|
859
|
+
//#endregion
|
|
860
|
+
//#region src/init/detect.ts
|
|
861
|
+
const NUXT_CONFIG_FILES = [
|
|
862
|
+
"nuxt.config.ts",
|
|
863
|
+
"nuxt.config.mts",
|
|
864
|
+
"nuxt.config.js",
|
|
865
|
+
"nuxt.config.mjs"
|
|
866
|
+
];
|
|
867
|
+
const VITE_CONFIG_FILES$1 = [
|
|
868
|
+
"vite.config.ts",
|
|
869
|
+
"vite.config.mts",
|
|
870
|
+
"vite.config.js",
|
|
871
|
+
"vite.config.mjs"
|
|
872
|
+
];
|
|
873
|
+
/**
|
|
874
|
+
* Package-manager detection.
|
|
875
|
+
*
|
|
876
|
+
* Deliberately mirrors `detect_package_manager` in
|
|
877
|
+
* `crates/vize_canon/src/batch/error.rs`, including the lockfile priority order
|
|
878
|
+
* and the `packageManager` prefix fallback. The Rust side suggests an install
|
|
879
|
+
* command in its corsa-not-found message; if the two ever disagreed, a user
|
|
880
|
+
* would be told to run `pnpm add` by one half of the toolchain and `npm install`
|
|
881
|
+
* by the other.
|
|
882
|
+
*/
|
|
883
|
+
function detectPackageManager(root) {
|
|
884
|
+
const exists = (name) => fs.existsSync(path.join(root, name));
|
|
885
|
+
if (exists("pnpm-lock.yaml")) return "pnpm";
|
|
886
|
+
if (exists("bun.lockb") || exists("bun.lock")) return "bun";
|
|
887
|
+
if (exists("yarn.lock")) return "yarn";
|
|
888
|
+
if (exists("package-lock.json")) return "npm";
|
|
889
|
+
return detectPackageManagerField(root);
|
|
890
|
+
}
|
|
891
|
+
function detectPackageManagerField(root) {
|
|
892
|
+
let source;
|
|
893
|
+
try {
|
|
894
|
+
source = fs.readFileSync(path.join(root, "package.json"), "utf8");
|
|
895
|
+
} catch {
|
|
896
|
+
return null;
|
|
897
|
+
}
|
|
898
|
+
let field;
|
|
899
|
+
try {
|
|
900
|
+
field = JSON.parse(source).packageManager;
|
|
901
|
+
} catch {
|
|
902
|
+
return null;
|
|
903
|
+
}
|
|
904
|
+
if (typeof field !== "string") return null;
|
|
905
|
+
for (const candidate of [
|
|
906
|
+
"pnpm",
|
|
907
|
+
"yarn",
|
|
908
|
+
"bun",
|
|
909
|
+
"npm"
|
|
910
|
+
]) if (field.startsWith(candidate)) return candidate;
|
|
911
|
+
return null;
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Applies an explicit `--vite` / `--nuxt` choice over what detection concluded.
|
|
915
|
+
*
|
|
916
|
+
* Overriding the framework rather than branching later keeps one code path: the
|
|
917
|
+
* planner, the prompt and the printed detection summary all see the same answer,
|
|
918
|
+
* so the summary cannot claim Vite while the plan configures Nuxt.
|
|
919
|
+
*/
|
|
920
|
+
function withFramework(detection, framework) {
|
|
921
|
+
return framework === null || framework === detection.framework ? detection : {
|
|
922
|
+
...detection,
|
|
923
|
+
framework
|
|
924
|
+
};
|
|
925
|
+
}
|
|
926
|
+
function detectProject(root) {
|
|
927
|
+
const packagePath = path.join(root, "package.json");
|
|
928
|
+
const packageJson = parsePackageJson(packagePath, readRequiredFile(packagePath, "No package.json found"));
|
|
929
|
+
const dependencies = dependencyNames(packageJson);
|
|
930
|
+
const scripts = readScripts(packageJson);
|
|
931
|
+
const nuxtConfig = findExisting(root, NUXT_CONFIG_FILES);
|
|
932
|
+
const viteConfigs = VITE_CONFIG_FILES$1.filter((candidate) => fs.existsSync(path.join(root, candidate)));
|
|
933
|
+
const viteSource = viteConfigs.length === 1 ? readFile(root, viteConfigs[0]) : null;
|
|
934
|
+
const nuxtSource = nuxtConfig === null ? null : readFile(root, nuxtConfig);
|
|
935
|
+
return {
|
|
936
|
+
root,
|
|
937
|
+
packageManager: detectPackageManager(root),
|
|
938
|
+
framework: detectFramework(nuxtConfig, viteConfigs, dependencies),
|
|
939
|
+
nuxtConfig,
|
|
940
|
+
viteConfigs,
|
|
941
|
+
usesVitePlus: detectVitePlus(dependencies, viteSource, scripts),
|
|
942
|
+
typescript: dependencies.has("typescript") || fs.existsSync(path.join(root, "tsconfig.json")),
|
|
943
|
+
tsconfig: fs.existsSync(path.join(root, "tsconfig.json")) ? "tsconfig.json" : null,
|
|
944
|
+
vizeConfig: findExisting(root, VIZE_CONFIG_FILES),
|
|
945
|
+
oxlintConfig: findExisting(root, OXLINT_CONFIG_FILES),
|
|
946
|
+
hasVitePlusLintBlock: viteSource !== null && viteSource.includes("oxlint-plugin-vize"),
|
|
947
|
+
hasVizeVitePlugin: viteSource !== null && viteSource.includes("@vizejs/vite-plugin"),
|
|
948
|
+
hasVizeNuxtModule: nuxtSource !== null && nuxtSource.includes("@vizejs/nuxt"),
|
|
949
|
+
dependencies,
|
|
950
|
+
scripts,
|
|
951
|
+
vscodeRecommendsVize: detectVscodeRecommendation(root)
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
function detectFramework(nuxtConfig, viteConfigs, dependencies) {
|
|
955
|
+
if (nuxtConfig !== null || dependencies.has("nuxt")) return "nuxt";
|
|
956
|
+
return viteConfigs.length > 0 ? "vite" : "none";
|
|
957
|
+
}
|
|
958
|
+
/**
|
|
959
|
+
* Whether the project's lint command is `vp lint` rather than the `oxlint` binary.
|
|
960
|
+
*
|
|
961
|
+
* This single boolean decides which file `init` must write the Oxlint
|
|
962
|
+
* configuration into, so it is deliberately generous: a project is treated as a
|
|
963
|
+
* Vite+ project if the dependency is declared, if its Vite config imports from
|
|
964
|
+
* `vite-plus`, or if any script invokes `vp`. Guessing "plain Oxlint" for a
|
|
965
|
+
* Vite+ project is the failure that #3389 documented — `vp lint` would ignore
|
|
966
|
+
* `.oxlintrc.json` and report zero Vize diagnostics while exiting 0.
|
|
967
|
+
*/
|
|
968
|
+
function detectVitePlus(dependencies, viteSource, scripts) {
|
|
969
|
+
if (dependencies.has("vite-plus")) return true;
|
|
970
|
+
if (viteSource !== null && /from\s+["']vite-plus["']/u.test(viteSource)) return true;
|
|
971
|
+
return Object.values(scripts).some((command) => /(?:^|[\s&|;])vpx?(?:\s|$)/u.test(command));
|
|
972
|
+
}
|
|
973
|
+
function detectVscodeRecommendation(root) {
|
|
974
|
+
let source;
|
|
975
|
+
try {
|
|
976
|
+
source = fs.readFileSync(path.join(root, ".vscode", "extensions.json"), "utf8");
|
|
977
|
+
} catch {
|
|
978
|
+
return false;
|
|
979
|
+
}
|
|
980
|
+
return source.includes("ubugeeei.vize");
|
|
981
|
+
}
|
|
982
|
+
function readScripts(packageJson) {
|
|
983
|
+
const scripts = packageJson.scripts;
|
|
984
|
+
if (typeof scripts !== "object" || scripts === null || Array.isArray(scripts)) return {};
|
|
985
|
+
const entries = {};
|
|
986
|
+
for (const [name, command] of Object.entries(scripts)) if (typeof command === "string") entries[name] = command;
|
|
987
|
+
return entries;
|
|
988
|
+
}
|
|
989
|
+
function findExisting(root, candidates) {
|
|
990
|
+
return candidates.find((candidate) => fs.existsSync(path.join(root, candidate))) ?? null;
|
|
991
|
+
}
|
|
992
|
+
function readFile(root, relative) {
|
|
993
|
+
return fs.readFileSync(path.join(root, relative), "utf8");
|
|
994
|
+
}
|
|
995
|
+
//#endregion
|
|
996
|
+
//#region src/init/plan-types.ts
|
|
997
|
+
function createPlanDraft() {
|
|
998
|
+
return {
|
|
999
|
+
files: [],
|
|
1000
|
+
createdFiles: [],
|
|
1001
|
+
updatedFiles: [],
|
|
1002
|
+
features: [],
|
|
1003
|
+
dependencies: /* @__PURE__ */ new Set()
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
function skipped(id, detail = "not selected") {
|
|
1007
|
+
return {
|
|
1008
|
+
id,
|
|
1009
|
+
outcome: "skipped",
|
|
1010
|
+
detail,
|
|
1011
|
+
snippet: null
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
//#endregion
|
|
1015
|
+
//#region src/init/plan-bundler.ts
|
|
1016
|
+
/**
|
|
1017
|
+
* Plans the bundler integration: the Vite plugin, or the Nuxt module.
|
|
1018
|
+
*
|
|
1019
|
+
* Nuxt outranks Vite because a Nuxt project owns its own Vite instance --
|
|
1020
|
+
* adding `@vizejs/vite-plugin` to a Nuxt-managed Vite config would fight the
|
|
1021
|
+
* module rather than complement it.
|
|
1022
|
+
*
|
|
1023
|
+
* @returns the possibly-edited Vite config source, or the input unchanged.
|
|
1024
|
+
*/
|
|
1025
|
+
function planBundler(detection, viteDraft, draft) {
|
|
1026
|
+
if (detection.framework === "nuxt") {
|
|
1027
|
+
planNuxtModule(detection, draft);
|
|
1028
|
+
return viteDraft;
|
|
1029
|
+
}
|
|
1030
|
+
if (detection.framework !== "vite" || detection.viteConfigs.length !== 1) {
|
|
1031
|
+
draft.features.push(skipped("bundler", "no single vite.config or nuxt.config to configure"));
|
|
1032
|
+
return viteDraft;
|
|
1033
|
+
}
|
|
1034
|
+
draft.dependencies.add("@vizejs/vite-plugin");
|
|
1035
|
+
const filename = detection.viteConfigs[0];
|
|
1036
|
+
if (detection.hasVizeVitePlugin) {
|
|
1037
|
+
draft.features.push({
|
|
1038
|
+
id: "bundler",
|
|
1039
|
+
outcome: "unchanged",
|
|
1040
|
+
detail: `${filename} already uses @vizejs/vite-plugin`,
|
|
1041
|
+
snippet: null
|
|
1042
|
+
});
|
|
1043
|
+
return viteDraft;
|
|
1044
|
+
}
|
|
1045
|
+
const injected = viteDraft === null ? null : injectVitePlugin(viteDraft);
|
|
1046
|
+
if (injected === null) {
|
|
1047
|
+
draft.features.push({
|
|
1048
|
+
id: "bundler",
|
|
1049
|
+
outcome: "blocked",
|
|
1050
|
+
detail: `${filename} has no plugins array this tool can extend safely`,
|
|
1051
|
+
snippet: "plugins: [vize()]"
|
|
1052
|
+
});
|
|
1053
|
+
return viteDraft;
|
|
1054
|
+
}
|
|
1055
|
+
draft.features.push({
|
|
1056
|
+
id: "bundler",
|
|
1057
|
+
outcome: "configured",
|
|
1058
|
+
detail: `adds vize() to ${filename}`,
|
|
1059
|
+
snippet: null
|
|
1060
|
+
});
|
|
1061
|
+
return injected;
|
|
1062
|
+
}
|
|
1063
|
+
function planNuxtModule(detection, draft) {
|
|
1064
|
+
draft.dependencies.add("@vizejs/nuxt");
|
|
1065
|
+
if (detection.nuxtConfig === null) {
|
|
1066
|
+
draft.features.push(skipped("bundler", "no nuxt.config file to add @vizejs/nuxt to"));
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
if (detection.hasVizeNuxtModule) {
|
|
1070
|
+
draft.features.push({
|
|
1071
|
+
id: "bundler",
|
|
1072
|
+
outcome: "unchanged",
|
|
1073
|
+
detail: `${detection.nuxtConfig} already lists @vizejs/nuxt`,
|
|
1074
|
+
snippet: null
|
|
1075
|
+
});
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
const injected = injectNuxtModule(fs.readFileSync(path.join(detection.root, detection.nuxtConfig), "utf8"));
|
|
1079
|
+
if (injected === null) {
|
|
1080
|
+
draft.features.push({
|
|
1081
|
+
id: "bundler",
|
|
1082
|
+
outcome: "blocked",
|
|
1083
|
+
detail: `${detection.nuxtConfig} is not a single plain defineNuxtConfig({ ... }) call`,
|
|
1084
|
+
snippet: "modules: [\"@vizejs/nuxt\"]"
|
|
1085
|
+
});
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
draft.files.push({
|
|
1089
|
+
filename: path.join(detection.root, detection.nuxtConfig),
|
|
1090
|
+
source: injected
|
|
1091
|
+
});
|
|
1092
|
+
draft.updatedFiles.push(detection.nuxtConfig);
|
|
1093
|
+
draft.features.push({
|
|
1094
|
+
id: "bundler",
|
|
1095
|
+
outcome: "configured",
|
|
1096
|
+
detail: `adds @vizejs/nuxt to ${detection.nuxtConfig}`,
|
|
1097
|
+
snippet: null
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1100
|
+
//#endregion
|
|
1101
|
+
//#region src/init/plan-editor.ts
|
|
1102
|
+
const EXTENSIONS_FILE = path.join(".vscode", "extensions.json");
|
|
1103
|
+
/**
|
|
1104
|
+
* Plans the editor recommendation.
|
|
1105
|
+
*
|
|
1106
|
+
* `.vscode/extensions.json` is preferred over `code --install-extension` because
|
|
1107
|
+
* it is checked in, applies to everyone on the project, and changes nothing on
|
|
1108
|
+
* the machine running `init`. An existing file is merged, never replaced: it
|
|
1109
|
+
* usually carries the team's other recommendations.
|
|
1110
|
+
*/
|
|
1111
|
+
function planEditorFile(detection, files, createdFiles, updatedFiles) {
|
|
1112
|
+
const filename = path.join(detection.root, ".vscode", "extensions.json");
|
|
1113
|
+
let source;
|
|
1114
|
+
try {
|
|
1115
|
+
source = fs.readFileSync(filename, "utf8");
|
|
1116
|
+
} catch {
|
|
1117
|
+
files.push({
|
|
1118
|
+
filename,
|
|
1119
|
+
source: renderVscodeExtensions(2)
|
|
1120
|
+
});
|
|
1121
|
+
createdFiles.push(EXTENSIONS_FILE);
|
|
1122
|
+
return {
|
|
1123
|
+
id: "editor",
|
|
1124
|
+
outcome: "configured",
|
|
1125
|
+
detail: `writes ${EXTENSIONS_FILE} recommending ${VSCODE_EXTENSION_ID}`,
|
|
1126
|
+
snippet: null
|
|
1127
|
+
};
|
|
1128
|
+
}
|
|
1129
|
+
const merged = mergeRecommendation(source);
|
|
1130
|
+
if (merged === null) return {
|
|
1131
|
+
id: "editor",
|
|
1132
|
+
outcome: "blocked",
|
|
1133
|
+
detail: `${EXTENSIONS_FILE} is not a plain JSON object this tool can extend safely`,
|
|
1134
|
+
snippet: `"recommendations": ["${VSCODE_EXTENSION_ID}"]`
|
|
1135
|
+
};
|
|
1136
|
+
if (merged === source) return {
|
|
1137
|
+
id: "editor",
|
|
1138
|
+
outcome: "unchanged",
|
|
1139
|
+
detail: `${EXTENSIONS_FILE} already recommends ${VSCODE_EXTENSION_ID}`,
|
|
1140
|
+
snippet: null
|
|
1141
|
+
};
|
|
1142
|
+
files.push({
|
|
1143
|
+
filename,
|
|
1144
|
+
source: merged
|
|
1145
|
+
});
|
|
1146
|
+
updatedFiles.push(EXTENSIONS_FILE);
|
|
1147
|
+
return {
|
|
1148
|
+
id: "editor",
|
|
1149
|
+
outcome: "configured",
|
|
1150
|
+
detail: `adds ${VSCODE_EXTENSION_ID} to ${EXTENSIONS_FILE}`,
|
|
1151
|
+
snippet: null
|
|
1152
|
+
};
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* Adds the recommendation to an existing file, preserving its other keys and its
|
|
1156
|
+
* indentation. Returns the input unchanged when the id is already listed, and
|
|
1157
|
+
* `null` when the file is not a JSON object with an array of string
|
|
1158
|
+
* recommendations.
|
|
1159
|
+
*/
|
|
1160
|
+
function mergeRecommendation(source) {
|
|
1161
|
+
let parsed;
|
|
1162
|
+
try {
|
|
1163
|
+
parsed = JSON.parse(source);
|
|
1164
|
+
} catch {
|
|
1165
|
+
return null;
|
|
1166
|
+
}
|
|
1167
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
1168
|
+
const document = parsed;
|
|
1169
|
+
const existing = document.recommendations;
|
|
1170
|
+
if (existing !== void 0 && !isStringArray(existing)) return null;
|
|
1171
|
+
const recommendations = existing ?? [];
|
|
1172
|
+
if (recommendations.includes("ubugeeei.vize")) return source;
|
|
1173
|
+
document.recommendations = [...recommendations, VSCODE_EXTENSION_ID];
|
|
1174
|
+
return `${JSON.stringify(document, null, detectJsonIndent(source))}\n`;
|
|
1175
|
+
}
|
|
1176
|
+
function isStringArray(value) {
|
|
1177
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === "string");
|
|
1178
|
+
}
|
|
1179
|
+
//#endregion
|
|
1180
|
+
//#region src/init/plan-lint.ts
|
|
1181
|
+
/**
|
|
1182
|
+
* Plans the Oxlint wiring into whichever file the project's lint command reads.
|
|
1183
|
+
*
|
|
1184
|
+
* The single rule this function exists to enforce: never write an Oxlint config
|
|
1185
|
+
* the project's own lint command ignores. `vp lint` and `vp check` read only the
|
|
1186
|
+
* `lint` block of the Vite config; `oxlint` and `oxlint-vize` read only their own
|
|
1187
|
+
* config file. Writing the wrong one produces a project that looks configured,
|
|
1188
|
+
* reports zero `vize/*` diagnostics and exits `0` -- #3389, fixed in #3407.
|
|
1189
|
+
*
|
|
1190
|
+
* When the required file cannot be edited safely this returns a `blocked`
|
|
1191
|
+
* result and writes nothing at all. Falling back to the *other* file would be
|
|
1192
|
+
* the bug: the user would see a success message and get silence from the linter.
|
|
1193
|
+
*
|
|
1194
|
+
* @returns the possibly-edited Vite config source, or the input unchanged.
|
|
1195
|
+
*/
|
|
1196
|
+
function planLint(detection, lintTarget, viteDraft, draft) {
|
|
1197
|
+
if (lintTarget.blockedReason !== null) {
|
|
1198
|
+
draft.features.push({
|
|
1199
|
+
id: "lint",
|
|
1200
|
+
outcome: "blocked",
|
|
1201
|
+
detail: `vp lint reads the \`lint\` block in the Vite config, but ${lintTarget.blockedReason}. Nothing was written: an unconfigured project fails loudly, while an Oxlint config vp lint never reads reports zero Vize diagnostics and exits 0`,
|
|
1202
|
+
snippet: lintTarget.blockedSnippet
|
|
1203
|
+
});
|
|
1204
|
+
return viteDraft;
|
|
1205
|
+
}
|
|
1206
|
+
let source = viteDraft;
|
|
1207
|
+
const wrote = [];
|
|
1208
|
+
if (lintTarget.viteConfig !== null && !detection.hasVitePlusLintBlock && source !== null) {
|
|
1209
|
+
const injected = injectViteLint(source);
|
|
1210
|
+
if (injected !== null) {
|
|
1211
|
+
source = injected;
|
|
1212
|
+
wrote.push(lintTarget.viteConfig);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
if (lintTarget.oxlintConfig !== null) {
|
|
1216
|
+
draft.files.push({
|
|
1217
|
+
filename: path.join(detection.root, INIT_OXLINT_CONFIG_FILE),
|
|
1218
|
+
source: INIT_OXLINT_CONFIG
|
|
1219
|
+
});
|
|
1220
|
+
draft.createdFiles.push(INIT_OXLINT_CONFIG_FILE);
|
|
1221
|
+
wrote.push(INIT_OXLINT_CONFIG_FILE);
|
|
1222
|
+
}
|
|
1223
|
+
draft.features.push({
|
|
1224
|
+
id: "lint",
|
|
1225
|
+
outcome: wrote.length > 0 ? "configured" : "unchanged",
|
|
1226
|
+
detail: wrote.length > 0 ? `${lintTarget.reason}; writes ${wrote.join(" and ")}` : lintTarget.reason,
|
|
1227
|
+
snippet: null
|
|
1228
|
+
});
|
|
1229
|
+
return source;
|
|
1230
|
+
}
|
|
1231
|
+
//#endregion
|
|
1232
|
+
//#region src/init/plan-project.ts
|
|
1233
|
+
/** Scripts each feature contributes, reusing the command strings `setup` ships. */
|
|
1234
|
+
const FEATURE_SCRIPTS = {
|
|
1235
|
+
lint: ["vize:lint"],
|
|
1236
|
+
bundler: [],
|
|
1237
|
+
fmt: ["vize:fmt", "vize:fmt:fix"],
|
|
1238
|
+
typecheck: ["vize:check"],
|
|
1239
|
+
editor: []
|
|
1240
|
+
};
|
|
1241
|
+
/**
|
|
1242
|
+
* Plans `vize.config.ts` and the fmt/typecheck feature results.
|
|
1243
|
+
*
|
|
1244
|
+
* Only selected features contribute a block, so asking for the formatter alone
|
|
1245
|
+
* does not hand the project a type checker it never opted into. An existing Vize
|
|
1246
|
+
* config is never rewritten: merging into a user's config is exactly the kind of
|
|
1247
|
+
* guess that loses their settings.
|
|
1248
|
+
*/
|
|
1249
|
+
function planVizeConfig(detection, selection, draft) {
|
|
1250
|
+
for (const id of ["fmt", "typecheck"]) {
|
|
1251
|
+
if (!selection[id]) {
|
|
1252
|
+
draft.features.push(id === "typecheck" && detection.tsconfig === null ? skipped(id, "no tsconfig.json, so vize check has nothing to check") : skipped(id));
|
|
1253
|
+
continue;
|
|
1254
|
+
}
|
|
1255
|
+
if (id === "typecheck" && detection.tsconfig === null) {
|
|
1256
|
+
draft.features.push({
|
|
1257
|
+
id,
|
|
1258
|
+
outcome: "blocked",
|
|
1259
|
+
detail: "vize check needs a tsconfig.json; none was found",
|
|
1260
|
+
snippet: null
|
|
1261
|
+
});
|
|
1262
|
+
continue;
|
|
1263
|
+
}
|
|
1264
|
+
draft.features.push({
|
|
1265
|
+
id,
|
|
1266
|
+
outcome: detection.vizeConfig === null ? "configured" : "unchanged",
|
|
1267
|
+
detail: detection.vizeConfig === null ? "writes vize.config.ts" : `${detection.vizeConfig} already exists and was left unchanged`,
|
|
1268
|
+
snippet: null
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
if (!(selection.lint || selection.fmt || selection.typecheck) || detection.vizeConfig !== null) return;
|
|
1272
|
+
draft.files.push({
|
|
1273
|
+
filename: path.join(detection.root, "vize.config.ts"),
|
|
1274
|
+
source: renderVizeConfig({
|
|
1275
|
+
lint: selection.lint,
|
|
1276
|
+
fmt: selection.fmt,
|
|
1277
|
+
typecheck: selection.typecheck && detection.tsconfig !== null,
|
|
1278
|
+
vite: detection.framework === "vite"
|
|
1279
|
+
})
|
|
1280
|
+
});
|
|
1281
|
+
draft.createdFiles.push("vize.config.ts");
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* Adds the scripts the selected features need.
|
|
1285
|
+
*
|
|
1286
|
+
* A script the project already defines is left alone, whatever its value: the
|
|
1287
|
+
* user's version of `vize:lint` outranks the default, and rewriting it would
|
|
1288
|
+
* make a second `init` run destructive.
|
|
1289
|
+
*/
|
|
1290
|
+
function planScripts(detection, selection, draft) {
|
|
1291
|
+
const wanted = [];
|
|
1292
|
+
for (const id of [
|
|
1293
|
+
"lint",
|
|
1294
|
+
"fmt",
|
|
1295
|
+
"typecheck"
|
|
1296
|
+
]) {
|
|
1297
|
+
if (!selection[id] || id === "typecheck" && detection.tsconfig === null) continue;
|
|
1298
|
+
wanted.push(...FEATURE_SCRIPTS[id]);
|
|
1299
|
+
}
|
|
1300
|
+
const missing = wanted.filter((name) => !(name in detection.scripts));
|
|
1301
|
+
if (missing.length === 0) return [];
|
|
1302
|
+
const packagePath = path.join(detection.root, "package.json");
|
|
1303
|
+
const source = fs.readFileSync(packagePath, "utf8");
|
|
1304
|
+
const packageJson = parsePackageJson(packagePath, source);
|
|
1305
|
+
const scripts = { ...detection.scripts };
|
|
1306
|
+
for (const name of missing) scripts[name] = DEFAULT_SCRIPTS[name];
|
|
1307
|
+
packageJson.scripts = scripts;
|
|
1308
|
+
draft.files.push({
|
|
1309
|
+
filename: packagePath,
|
|
1310
|
+
source: `${JSON.stringify(packageJson, null, detectJsonIndent(source))}\n`
|
|
1311
|
+
});
|
|
1312
|
+
draft.updatedFiles.push("package.json");
|
|
1313
|
+
return missing;
|
|
1314
|
+
}
|
|
1315
|
+
//#endregion
|
|
1316
|
+
//#region src/init/plan.ts
|
|
1317
|
+
/** Dev dependencies each feature needs. */
|
|
1318
|
+
const FEATURE_DEPENDENCIES = {
|
|
1319
|
+
lint: ["oxlint", "oxlint-plugin-vize"],
|
|
1320
|
+
bundler: [],
|
|
1321
|
+
fmt: ["vize"],
|
|
1322
|
+
typecheck: ["vize"],
|
|
1323
|
+
editor: []
|
|
1324
|
+
};
|
|
1325
|
+
const INSTALL_ARGS = {
|
|
1326
|
+
pnpm: ["add", "-D"],
|
|
1327
|
+
yarn: ["add", "-D"],
|
|
1328
|
+
bun: ["add", "-D"],
|
|
1329
|
+
npm: ["install", "-D"],
|
|
1330
|
+
vp: ["add", "-D"]
|
|
1331
|
+
};
|
|
1332
|
+
const FEATURE_ORDER = [
|
|
1333
|
+
"lint",
|
|
1334
|
+
"bundler",
|
|
1335
|
+
"fmt",
|
|
1336
|
+
"typecheck",
|
|
1337
|
+
"editor"
|
|
1338
|
+
];
|
|
1339
|
+
/**
|
|
1340
|
+
* Builds the full plan without touching the filesystem.
|
|
1341
|
+
*
|
|
1342
|
+
* Planning is separated from execution so `--dry-run`, the interactive
|
|
1343
|
+
* confirmation and the tests all inspect the same object the writer consumes;
|
|
1344
|
+
* a plan that is correct in `--dry-run` and wrong on disk is not possible.
|
|
1345
|
+
*/
|
|
1346
|
+
function planInit(options) {
|
|
1347
|
+
const { detection, selection } = options;
|
|
1348
|
+
const draft = createPlanDraft();
|
|
1349
|
+
const viteSource = readSingleViteConfig(detection);
|
|
1350
|
+
const lintTarget = resolveLintTarget({
|
|
1351
|
+
detection,
|
|
1352
|
+
viteSource
|
|
1353
|
+
});
|
|
1354
|
+
let viteDraft = viteSource;
|
|
1355
|
+
if (selection.lint) {
|
|
1356
|
+
viteDraft = planLint(detection, lintTarget, viteDraft, draft);
|
|
1357
|
+
addAll(draft.dependencies, FEATURE_DEPENDENCIES.lint);
|
|
1358
|
+
} else draft.features.push(skipped("lint"));
|
|
1359
|
+
if (selection.bundler) viteDraft = planBundler(detection, viteDraft, draft);
|
|
1360
|
+
else draft.features.push(skipped("bundler"));
|
|
1361
|
+
if (viteSource !== null && viteDraft !== null && viteDraft !== viteSource) {
|
|
1362
|
+
const filename = detection.viteConfigs[0];
|
|
1363
|
+
draft.files.push({
|
|
1364
|
+
filename: path.join(detection.root, filename),
|
|
1365
|
+
source: viteDraft
|
|
1366
|
+
});
|
|
1367
|
+
draft.updatedFiles.push(filename);
|
|
1368
|
+
}
|
|
1369
|
+
planVizeConfig(detection, selection, draft);
|
|
1370
|
+
for (const id of ["fmt", "typecheck"]) if (selection[id]) addAll(draft.dependencies, FEATURE_DEPENDENCIES[id]);
|
|
1371
|
+
draft.features.push(selection.editor ? planEditorFile(detection, draft.files, draft.createdFiles, draft.updatedFiles) : skipped("editor"));
|
|
1372
|
+
const addedScripts = planScripts(detection, selection, draft);
|
|
1373
|
+
return {
|
|
1374
|
+
root: detection.root,
|
|
1375
|
+
detection,
|
|
1376
|
+
lintTarget,
|
|
1377
|
+
features: sortFeatures(draft.features),
|
|
1378
|
+
files: draft.files,
|
|
1379
|
+
createdFiles: draft.createdFiles,
|
|
1380
|
+
updatedFiles: draft.updatedFiles,
|
|
1381
|
+
addedScripts,
|
|
1382
|
+
commands: planCommands(detection, draft.dependencies, options)
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
/**
|
|
1386
|
+
* The install commands, as a list so callers can assert on them.
|
|
1387
|
+
*
|
|
1388
|
+
* Exactly one command is emitted, or none when every dependency is already
|
|
1389
|
+
* declared -- which is what makes a second `init` run a no-op.
|
|
1390
|
+
*/
|
|
1391
|
+
function planCommands(detection, dependencies, options) {
|
|
1392
|
+
if (!options.install) return [];
|
|
1393
|
+
const missing = [...dependencies].filter((name) => !detection.dependencies.has(name)).sort();
|
|
1394
|
+
if (missing.length === 0) return [];
|
|
1395
|
+
const command = resolveInstaller(detection, options.packageManager);
|
|
1396
|
+
return [{
|
|
1397
|
+
command,
|
|
1398
|
+
args: [...INSTALL_ARGS[command], ...missing],
|
|
1399
|
+
cwd: detection.root
|
|
1400
|
+
}];
|
|
1401
|
+
}
|
|
1402
|
+
/**
|
|
1403
|
+
* Installer used for the one install command.
|
|
1404
|
+
*
|
|
1405
|
+
* A Vite+ project gets `vp add`, matching `setup` and the project's own
|
|
1406
|
+
* workflow. Otherwise the package manager comes from the same lockfile rules
|
|
1407
|
+
* `detect_package_manager` uses on the Rust side, defaulting to npm when
|
|
1408
|
+
* nothing identifies one.
|
|
1409
|
+
*/
|
|
1410
|
+
function resolveInstaller(detection, override) {
|
|
1411
|
+
if (override !== void 0) return override;
|
|
1412
|
+
if (detection.usesVitePlus) return "vp";
|
|
1413
|
+
return detection.packageManager ?? "npm";
|
|
1414
|
+
}
|
|
1415
|
+
function readSingleViteConfig(detection) {
|
|
1416
|
+
if (detection.viteConfigs.length !== 1) return null;
|
|
1417
|
+
return fs.readFileSync(path.join(detection.root, detection.viteConfigs[0]), "utf8");
|
|
1418
|
+
}
|
|
1419
|
+
function addAll(target, values) {
|
|
1420
|
+
for (const value of values) target.add(value);
|
|
1421
|
+
}
|
|
1422
|
+
function sortFeatures(features) {
|
|
1423
|
+
return [...features].sort((left, right) => FEATURE_ORDER.indexOf(left.id) - FEATURE_ORDER.indexOf(right.id));
|
|
1424
|
+
}
|
|
1425
|
+
//#endregion
|
|
1426
|
+
//#region src/init/prompt.ts
|
|
1427
|
+
/** True when stdin cannot answer a prompt, so `init` must not ask one. */
|
|
1428
|
+
function isNonInteractive(stream) {
|
|
1429
|
+
return stream.isTTY !== true;
|
|
1430
|
+
}
|
|
1431
|
+
/**
|
|
1432
|
+
* Wraps `node:readline` so a closed input resolves instead of hanging.
|
|
1433
|
+
*
|
|
1434
|
+
* `rl.question` never invokes its callback when stdin reaches EOF first. Left
|
|
1435
|
+
* alone that leaves `init`'s promise permanently pending, and the process exits
|
|
1436
|
+
* `0` having written nothing -- a silent no-op that looks like success. Resolving
|
|
1437
|
+
* to `null` on close turns that into an explicit cancellation.
|
|
1438
|
+
*/
|
|
1439
|
+
function createPromptDeps(io) {
|
|
1440
|
+
const rl = readline.createInterface({
|
|
1441
|
+
input: io.input,
|
|
1442
|
+
output: io.output
|
|
1443
|
+
});
|
|
1444
|
+
let closed = false;
|
|
1445
|
+
rl.on("close", () => {
|
|
1446
|
+
closed = true;
|
|
1447
|
+
});
|
|
1448
|
+
return {
|
|
1449
|
+
...io,
|
|
1450
|
+
question: (query) => new Promise((resolve) => {
|
|
1451
|
+
if (closed) {
|
|
1452
|
+
resolve(null);
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
1455
|
+
let settled = false;
|
|
1456
|
+
const onClose = () => {
|
|
1457
|
+
if (!settled) {
|
|
1458
|
+
settled = true;
|
|
1459
|
+
resolve(null);
|
|
1460
|
+
}
|
|
1461
|
+
};
|
|
1462
|
+
rl.once("close", onClose);
|
|
1463
|
+
rl.question(query, (answer) => {
|
|
1464
|
+
if (settled) return;
|
|
1465
|
+
settled = true;
|
|
1466
|
+
rl.removeListener("close", onClose);
|
|
1467
|
+
resolve(answer);
|
|
1468
|
+
});
|
|
1469
|
+
}),
|
|
1470
|
+
close: () => {
|
|
1471
|
+
rl.close();
|
|
1472
|
+
}
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
/** Runs the checklist. Returns `null` when the input ended before confirmation. */
|
|
1476
|
+
async function selectFeatures(offers, initial, deps) {
|
|
1477
|
+
const selection = { ...initial };
|
|
1478
|
+
const toggleable = offers.filter((offer) => offer.available);
|
|
1479
|
+
for (;;) {
|
|
1480
|
+
deps.output.write(renderChecklist(offers, selection));
|
|
1481
|
+
const raw = await deps.question("> ");
|
|
1482
|
+
if (raw === null) return null;
|
|
1483
|
+
const answer = raw.trim();
|
|
1484
|
+
if (answer === "") return selection;
|
|
1485
|
+
const indexes = parseIndexes(answer, toggleable.length);
|
|
1486
|
+
if (indexes === null) {
|
|
1487
|
+
deps.output.write(`Enter numbers between 1 and ${toggleable.length}, or press Enter to accept.\n`);
|
|
1488
|
+
continue;
|
|
1489
|
+
}
|
|
1490
|
+
for (const index of indexes) {
|
|
1491
|
+
const offer = toggleable[index];
|
|
1492
|
+
selection[offer.id] = !selection[offer.id];
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
/** Yes/no confirmation. A closed input counts as "no", never as "yes". */
|
|
1497
|
+
async function confirm(query, deps) {
|
|
1498
|
+
const raw = await deps.question(`${query} [Y/n] `);
|
|
1499
|
+
if (raw === null) return false;
|
|
1500
|
+
const answer = raw.trim().toLowerCase();
|
|
1501
|
+
return answer === "" || answer === "y" || answer === "yes";
|
|
1502
|
+
}
|
|
1503
|
+
function renderChecklist(offers, selection) {
|
|
1504
|
+
const lines = [
|
|
1505
|
+
"",
|
|
1506
|
+
"Select the features to configure.",
|
|
1507
|
+
"Type the numbers to toggle (space or comma separated), then press Enter.",
|
|
1508
|
+
""
|
|
1509
|
+
];
|
|
1510
|
+
let position = 0;
|
|
1511
|
+
for (const offer of offers) {
|
|
1512
|
+
if (!offer.available) {
|
|
1513
|
+
lines.push(` - ${offer.label}${offer.note === "" ? "" : ` (${offer.note})`}`);
|
|
1514
|
+
continue;
|
|
1515
|
+
}
|
|
1516
|
+
position += 1;
|
|
1517
|
+
const mark = selection[offer.id] ? "x" : " ";
|
|
1518
|
+
const note = offer.note === "" ? "" : ` (${offer.note})`;
|
|
1519
|
+
lines.push(` ${position}. [${mark}] ${offer.label}${note}`);
|
|
1520
|
+
}
|
|
1521
|
+
lines.push("");
|
|
1522
|
+
return `${lines.join("\n")}\n`;
|
|
1523
|
+
}
|
|
1524
|
+
/** Parses a toggle answer into zero-based indexes, or `null` when any entry is out of range. */
|
|
1525
|
+
function parseIndexes(answer, count) {
|
|
1526
|
+
const tokens = answer.split(/[\s,]+/u).filter((token) => token !== "");
|
|
1527
|
+
const indexes = [];
|
|
1528
|
+
for (const token of tokens) {
|
|
1529
|
+
if (!/^\d+$/u.test(token)) return null;
|
|
1530
|
+
const value = Number.parseInt(token, 10);
|
|
1531
|
+
if (value < 1 || value > count) return null;
|
|
1532
|
+
indexes.push(value - 1);
|
|
1533
|
+
}
|
|
1534
|
+
return indexes.length === 0 ? null : indexes;
|
|
1535
|
+
}
|
|
1536
|
+
//#endregion
|
|
1537
|
+
//#region src/init/report.ts
|
|
1538
|
+
const PREFIX = "[vize init]";
|
|
1539
|
+
/**
|
|
1540
|
+
* Detection summary, printed before any prompt.
|
|
1541
|
+
*
|
|
1542
|
+
* Users need to see what `init` concluded before they are asked to act on it;
|
|
1543
|
+
* an unexpected line here is the cheapest place to catch a wrong root or a
|
|
1544
|
+
* missing lockfile.
|
|
1545
|
+
*/
|
|
1546
|
+
function renderDetection(detection) {
|
|
1547
|
+
return `${[
|
|
1548
|
+
`${PREFIX} detected in ${detection.root}:`,
|
|
1549
|
+
` framework: ${describeFramework(detection)}`,
|
|
1550
|
+
` package manager: ${detection.packageManager ?? "none detected (defaulting to npm)"}`,
|
|
1551
|
+
` language: ${detection.typescript ? "TypeScript" : "JavaScript"}${detection.tsconfig === null ? " (no tsconfig.json)" : " (tsconfig.json)"}`,
|
|
1552
|
+
` lint command: ${detection.usesVitePlus ? "vp lint" : "oxlint"}`,
|
|
1553
|
+
` vize config: ${detection.vizeConfig ?? "none"}`,
|
|
1554
|
+
` oxlint config: ${describeOxlintConfig(detection)}`
|
|
1555
|
+
].join("\n")}\n`;
|
|
1556
|
+
}
|
|
1557
|
+
function describeFramework(detection) {
|
|
1558
|
+
if (detection.framework === "nuxt") return `Nuxt (${detection.nuxtConfig ?? "nuxt dependency, no nuxt.config"})`;
|
|
1559
|
+
if (detection.framework === "vite") {
|
|
1560
|
+
const configs = detection.viteConfigs.join(", ");
|
|
1561
|
+
return detection.usesVitePlus ? `Vite+ (${configs})` : `Vite (${configs})`;
|
|
1562
|
+
}
|
|
1563
|
+
return "none (no vite.config or nuxt.config)";
|
|
1564
|
+
}
|
|
1565
|
+
function describeOxlintConfig(detection) {
|
|
1566
|
+
const unread = unreadOxlintConfig(detection);
|
|
1567
|
+
if (unread !== null) return `${unread} — present but oxlint does not read this name (#3474)`;
|
|
1568
|
+
return detection.oxlintConfig ?? "none";
|
|
1569
|
+
}
|
|
1570
|
+
/**
|
|
1571
|
+
* The full plan.
|
|
1572
|
+
*
|
|
1573
|
+
* Printed before anything is written in both modes, so the wording is what the
|
|
1574
|
+
* run is about to do, not what it has done. `--dry-run` differs only in stopping
|
|
1575
|
+
* afterwards.
|
|
1576
|
+
*/
|
|
1577
|
+
function renderPlan(plan, dryRun) {
|
|
1578
|
+
const verb = dryRun ? "would" : "will";
|
|
1579
|
+
const lines = [`${PREFIX} plan:`];
|
|
1580
|
+
for (const feature of plan.features) lines.push(` ${feature.id.padEnd(9)} ${feature.outcome.padEnd(10)} ${feature.detail}`);
|
|
1581
|
+
for (const filename of plan.createdFiles) lines.push(`${PREFIX} ${verb} create ${filename}`);
|
|
1582
|
+
for (const filename of plan.updatedFiles) lines.push(`${PREFIX} ${verb} update ${filename}`);
|
|
1583
|
+
if (plan.addedScripts.length > 0) lines.push(`${PREFIX} ${verb} add scripts: ${plan.addedScripts.join(", ")}`);
|
|
1584
|
+
for (const command of plan.commands) lines.push(`${PREFIX} ${verb} run: ${command.command} ${command.args.join(" ")}`);
|
|
1585
|
+
if (plan.createdFiles.length + plan.updatedFiles.length + plan.commands.length === 0) lines.push(`${PREFIX} nothing to do; the project is already configured`);
|
|
1586
|
+
return `${lines.join("\n")}\n`;
|
|
1587
|
+
}
|
|
1588
|
+
/**
|
|
1589
|
+
* Snippets for anything `init` refused to edit.
|
|
1590
|
+
*
|
|
1591
|
+
* A blocked feature is deliberately loud. The alternative for the lint feature
|
|
1592
|
+
* would be writing an Oxlint config the project's lint command never reads,
|
|
1593
|
+
* which reports zero Vize diagnostics and exits 0 (#3389).
|
|
1594
|
+
*/
|
|
1595
|
+
function renderBlocked(plan) {
|
|
1596
|
+
const blocked = plan.features.filter((feature) => feature.outcome === "blocked");
|
|
1597
|
+
if (blocked.length === 0) return "";
|
|
1598
|
+
const lines = [];
|
|
1599
|
+
for (const feature of blocked) {
|
|
1600
|
+
lines.push(`${PREFIX} ${feature.id}: NOT configured — ${feature.detail}`);
|
|
1601
|
+
if (feature.snippet !== null) lines.push("", indent(feature.snippet), "");
|
|
1602
|
+
}
|
|
1603
|
+
return `${lines.join("\n")}\n`;
|
|
1604
|
+
}
|
|
1605
|
+
function renderEditors() {
|
|
1606
|
+
const lines = [`${PREFIX} editor integrations shipped with Vize:`];
|
|
1607
|
+
for (const integration of EDITOR_INTEGRATIONS) lines.push(` ${integration}`);
|
|
1608
|
+
return `${lines.join("\n")}\n`;
|
|
1609
|
+
}
|
|
1610
|
+
/**
|
|
1611
|
+
* Printed when the prompt ends without a confirmation.
|
|
1612
|
+
*
|
|
1613
|
+
* Covers both a declined confirmation and an input stream that closed
|
|
1614
|
+
* mid-prompt. Saying so is what keeps a closed stdin from looking like a
|
|
1615
|
+
* successful run that happened to change nothing.
|
|
1616
|
+
*/
|
|
1617
|
+
function renderCancelled() {
|
|
1618
|
+
return `${PREFIX} cancelled; nothing was written.\n`;
|
|
1619
|
+
}
|
|
1620
|
+
function renderNonInteractiveRefusal() {
|
|
1621
|
+
return `${PREFIX} stdin is not a TTY, so init will not prompt.\n${PREFIX} pass --yes with the features you want, for example:\n${PREFIX} vize init --yes --lint --vite --fmt --typecheck --editor\n${PREFIX} or run with --dry-run to print the plan without writing.\n`;
|
|
1622
|
+
}
|
|
1623
|
+
function indent(source) {
|
|
1624
|
+
return source.split("\n").map((line) => line === "" ? line : ` ${line}`).join("\n").trimEnd();
|
|
1625
|
+
}
|
|
1626
|
+
//#endregion
|
|
1627
|
+
//#region src/init.ts
|
|
1628
|
+
/**
|
|
1629
|
+
* Resolves the feature selection from detection, flags, and -- when the terminal
|
|
1630
|
+
* allows it -- the user.
|
|
1631
|
+
*
|
|
1632
|
+
* A non-TTY stdin without `--yes` returns `null`: refusing is the only correct
|
|
1633
|
+
* answer, because prompting would hang a CI job forever.
|
|
1634
|
+
*/
|
|
1635
|
+
async function resolveSelection(detection, args, deps) {
|
|
1636
|
+
const offers = offerFeatures(detection);
|
|
1637
|
+
const withOverrides = { ...defaultSelection(offers) };
|
|
1638
|
+
for (const [id, enabled] of Object.entries(args.overrides)) withOverrides[id] = enabled;
|
|
1639
|
+
const selection = withOverrides;
|
|
1640
|
+
if (args.yes) return selection;
|
|
1641
|
+
if (deps.promptDeps === void 0 && isNonInteractive(deps.stdin)) {
|
|
1642
|
+
deps.output(renderNonInteractiveRefusal());
|
|
1643
|
+
return null;
|
|
1644
|
+
}
|
|
1645
|
+
const owned = deps.promptDeps === void 0 ? createPromptDeps({
|
|
1646
|
+
input: deps.stdin,
|
|
1647
|
+
output: process.stdout
|
|
1648
|
+
}) : null;
|
|
1649
|
+
const promptDeps = deps.promptDeps ?? owned;
|
|
1650
|
+
try {
|
|
1651
|
+
const chosen = await selectFeatures(offers, selection, promptDeps);
|
|
1652
|
+
if (chosen !== null && await confirm("Apply this selection?", promptDeps)) return chosen;
|
|
1653
|
+
deps.output(renderCancelled());
|
|
1654
|
+
return null;
|
|
1655
|
+
} finally {
|
|
1656
|
+
owned?.close?.();
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
/**
|
|
1660
|
+
* Runs `init` end to end.
|
|
1661
|
+
*
|
|
1662
|
+
* Detection is reported before anything is decided, the plan is reported before
|
|
1663
|
+
* anything is written, and a blocked feature is reported as NOT configured
|
|
1664
|
+
* rather than quietly downgraded.
|
|
1665
|
+
*/
|
|
1666
|
+
async function initProject(options) {
|
|
1667
|
+
const args = parseInitArgs(options.args ?? []);
|
|
1668
|
+
const output = options.output ?? ((chunk) => process.stdout.write(chunk));
|
|
1669
|
+
const detection = withFramework(detectProject(path.resolve(args.root ?? options.root)), args.bundlerOverride);
|
|
1670
|
+
output(renderDetection(detection));
|
|
1671
|
+
const selection = await resolveSelection(detection, args, {
|
|
1672
|
+
output,
|
|
1673
|
+
stdin: options.stdin ?? process.stdin,
|
|
1674
|
+
promptDeps: options.promptDeps
|
|
1675
|
+
});
|
|
1676
|
+
if (selection === null) return null;
|
|
1677
|
+
const plan = planInit({
|
|
1678
|
+
detection,
|
|
1679
|
+
selection,
|
|
1680
|
+
install: args.install,
|
|
1681
|
+
packageManager: args.packageManager ?? void 0
|
|
1682
|
+
});
|
|
1683
|
+
output(renderPlan(plan, args.dryRun));
|
|
1684
|
+
output(renderBlocked(plan));
|
|
1685
|
+
if (args.dryRun) return plan;
|
|
1686
|
+
writePlannedFiles$1(plan, options.writeFile ?? writeProjectFile);
|
|
1687
|
+
const runCommand = options.runCommand ?? runInitCommand;
|
|
1688
|
+
for (const command of plan.commands) runCommand(command);
|
|
1689
|
+
if (selection.editor) output(renderEditors());
|
|
1690
|
+
return plan;
|
|
1691
|
+
}
|
|
1692
|
+
async function runInitCli(args) {
|
|
1693
|
+
if (parseInitArgs(args).help) {
|
|
1694
|
+
process.stdout.write(initHelp());
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
const plan = await initProject({
|
|
1698
|
+
root: process.cwd(),
|
|
1699
|
+
args
|
|
1700
|
+
});
|
|
1701
|
+
if (plan === null) {
|
|
1702
|
+
process.exitCode = 1;
|
|
1703
|
+
return;
|
|
1704
|
+
}
|
|
1705
|
+
if (plan.features.some((feature) => feature.outcome === "blocked")) process.exitCode = 1;
|
|
1706
|
+
}
|
|
1707
|
+
/**
|
|
1708
|
+
* Default writer.
|
|
1709
|
+
*
|
|
1710
|
+
* Creates the parent directory first so `.vscode/extensions.json` works in a
|
|
1711
|
+
* project that has never had a `.vscode` folder, then reuses `setup`'s atomic
|
|
1712
|
+
* write so a crash mid-run cannot leave a half-written config behind.
|
|
1713
|
+
*/
|
|
1714
|
+
function writeProjectFile(filename, source) {
|
|
1715
|
+
fs.mkdirSync(path.dirname(filename), { recursive: true });
|
|
1716
|
+
atomicWriteFile(filename, source);
|
|
1717
|
+
}
|
|
1718
|
+
function writePlannedFiles$1(plan, writeFile) {
|
|
1719
|
+
const written = [];
|
|
1720
|
+
for (const file of plan.files) {
|
|
1721
|
+
try {
|
|
1722
|
+
writeFile(file.filename, file.source);
|
|
1723
|
+
} catch (error) {
|
|
1724
|
+
if (written.length === 0) throw error;
|
|
1725
|
+
throw new Error(`init partially completed: wrote ${written.join(", ")} before ${path.relative(plan.root, file.filename)} failed. Run init again to finish.`, { cause: error });
|
|
1726
|
+
}
|
|
1727
|
+
written.push(path.relative(plan.root, file.filename));
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
function runInitCommand(command) {
|
|
1731
|
+
execFileSync(command.command, [...command.args], {
|
|
1732
|
+
cwd: command.cwd,
|
|
1733
|
+
stdio: "inherit"
|
|
1734
|
+
});
|
|
1735
|
+
}
|
|
1736
|
+
//#endregion
|
|
155
1737
|
//#region src/setup/vite.ts
|
|
156
1738
|
const VITE_CONFIG_FILES = [
|
|
157
1739
|
"vite.config.ts",
|
|
@@ -386,14 +1968,18 @@ function writePlannedFiles(root, plannedFiles, writeFile) {
|
|
|
386
1968
|
//#endregion
|
|
387
1969
|
//#region src/cli.ts
|
|
388
1970
|
const require = createRequire(import.meta.url);
|
|
1971
|
+
function fail(error) {
|
|
1972
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1973
|
+
process.stderr.write(`[vize] ${message}\n`);
|
|
1974
|
+
process.exitCode = 1;
|
|
1975
|
+
}
|
|
389
1976
|
try {
|
|
390
1977
|
const args = process.argv.slice(2);
|
|
391
1978
|
if (args[0] === "setup") runSetupCli(args.slice(1));
|
|
1979
|
+
else if (args[0] === "init") runInitCli(args.slice(1)).catch(fail);
|
|
392
1980
|
else require("@vizejs/native").runCli(args);
|
|
393
1981
|
} catch (error) {
|
|
394
|
-
|
|
395
|
-
process.stderr.write(`[vize] ${message}\n`);
|
|
396
|
-
process.exitCode = 1;
|
|
1982
|
+
fail(error);
|
|
397
1983
|
}
|
|
398
1984
|
//#endregion
|
|
399
1985
|
export {};
|