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