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 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
- const message = error instanceof Error ? error.message : String(error);
395
- process.stderr.write(`[vize] ${message}\n`);
396
- process.exitCode = 1;
1982
+ fail(error);
397
1983
  }
398
1984
  //#endregion
399
1985
  export {};