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.
@@ -0,0 +1,98 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { detectJsonIndent, type PlannedFile } from "../setup/config.js";
5
+ import type { ProjectDetection } from "./detect.js";
6
+ import type { FeatureResult } from "./plan-types.js";
7
+ import { renderVscodeExtensions, VSCODE_EXTENSION_ID } from "./templates.js";
8
+
9
+ const EXTENSIONS_FILE = path.join(".vscode", "extensions.json");
10
+
11
+ /**
12
+ * Plans the editor recommendation.
13
+ *
14
+ * `.vscode/extensions.json` is preferred over `code --install-extension` because
15
+ * it is checked in, applies to everyone on the project, and changes nothing on
16
+ * the machine running `init`. An existing file is merged, never replaced: it
17
+ * usually carries the team's other recommendations.
18
+ */
19
+ export function planEditorFile(
20
+ detection: ProjectDetection,
21
+ files: PlannedFile[],
22
+ createdFiles: string[],
23
+ updatedFiles: string[],
24
+ ): FeatureResult {
25
+ const filename = path.join(detection.root, ".vscode", "extensions.json");
26
+ let source: string;
27
+ try {
28
+ source = fs.readFileSync(filename, "utf8");
29
+ } catch {
30
+ files.push({ filename, source: renderVscodeExtensions(2) });
31
+ createdFiles.push(EXTENSIONS_FILE);
32
+ return {
33
+ id: "editor",
34
+ outcome: "configured",
35
+ detail: `writes ${EXTENSIONS_FILE} recommending ${VSCODE_EXTENSION_ID}`,
36
+ snippet: null,
37
+ };
38
+ }
39
+
40
+ const merged = mergeRecommendation(source);
41
+ if (merged === null) {
42
+ return {
43
+ id: "editor",
44
+ outcome: "blocked",
45
+ detail: `${EXTENSIONS_FILE} is not a plain JSON object this tool can extend safely`,
46
+ snippet: `"recommendations": ["${VSCODE_EXTENSION_ID}"]`,
47
+ };
48
+ }
49
+ if (merged === source) {
50
+ return {
51
+ id: "editor",
52
+ outcome: "unchanged",
53
+ detail: `${EXTENSIONS_FILE} already recommends ${VSCODE_EXTENSION_ID}`,
54
+ snippet: null,
55
+ };
56
+ }
57
+ files.push({ filename, source: merged });
58
+ updatedFiles.push(EXTENSIONS_FILE);
59
+ return {
60
+ id: "editor",
61
+ outcome: "configured",
62
+ detail: `adds ${VSCODE_EXTENSION_ID} to ${EXTENSIONS_FILE}`,
63
+ snippet: null,
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Adds the recommendation to an existing file, preserving its other keys and its
69
+ * indentation. Returns the input unchanged when the id is already listed, and
70
+ * `null` when the file is not a JSON object with an array of string
71
+ * recommendations.
72
+ */
73
+ function mergeRecommendation(source: string): string | null {
74
+ let parsed: unknown;
75
+ try {
76
+ parsed = JSON.parse(source);
77
+ } catch {
78
+ return null;
79
+ }
80
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
81
+ return null;
82
+ }
83
+ const document = parsed as Record<string, unknown>;
84
+ const existing = document.recommendations;
85
+ if (existing !== undefined && !isStringArray(existing)) {
86
+ return null;
87
+ }
88
+ const recommendations = existing ?? [];
89
+ if (recommendations.includes(VSCODE_EXTENSION_ID)) {
90
+ return source;
91
+ }
92
+ document.recommendations = [...recommendations, VSCODE_EXTENSION_ID];
93
+ return `${JSON.stringify(document, null, detectJsonIndent(source))}\n`;
94
+ }
95
+
96
+ function isStringArray(value: unknown): value is string[] {
97
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
98
+ }
@@ -0,0 +1,68 @@
1
+ import path from "node:path";
2
+
3
+ import type { ProjectDetection } from "./detect.js";
4
+ import { injectViteLint } from "./edit-config.js";
5
+ import { INIT_OXLINT_CONFIG_FILE, type LintTarget } from "./lint-target.js";
6
+ import type { PlanDraft } from "./plan-types.js";
7
+ import { INIT_OXLINT_CONFIG } from "./templates.js";
8
+
9
+ /**
10
+ * Plans the Oxlint wiring into whichever file the project's lint command reads.
11
+ *
12
+ * The single rule this function exists to enforce: never write an Oxlint config
13
+ * the project's own lint command ignores. `vp lint` and `vp check` read only the
14
+ * `lint` block of the Vite config; `oxlint` and `oxlint-vize` read only their own
15
+ * config file. Writing the wrong one produces a project that looks configured,
16
+ * reports zero `vize/*` diagnostics and exits `0` -- #3389, fixed in #3407.
17
+ *
18
+ * When the required file cannot be edited safely this returns a `blocked`
19
+ * result and writes nothing at all. Falling back to the *other* file would be
20
+ * the bug: the user would see a success message and get silence from the linter.
21
+ *
22
+ * @returns the possibly-edited Vite config source, or the input unchanged.
23
+ */
24
+ export function planLint(
25
+ detection: ProjectDetection,
26
+ lintTarget: LintTarget,
27
+ viteDraft: string | null,
28
+ draft: PlanDraft,
29
+ ): string | null {
30
+ if (lintTarget.blockedReason !== null) {
31
+ draft.features.push({
32
+ id: "lint",
33
+ outcome: "blocked",
34
+ detail:
35
+ `vp lint reads the \`lint\` block in the Vite config, but ${lintTarget.blockedReason}. ` +
36
+ "Nothing was written: an unconfigured project fails loudly, while an Oxlint config vp " +
37
+ "lint never reads reports zero Vize diagnostics and exits 0",
38
+ snippet: lintTarget.blockedSnippet,
39
+ });
40
+ return viteDraft;
41
+ }
42
+
43
+ let source = viteDraft;
44
+ const wrote: string[] = [];
45
+ if (lintTarget.viteConfig !== null && !detection.hasVitePlusLintBlock && source !== null) {
46
+ const injected = injectViteLint(source);
47
+ if (injected !== null) {
48
+ source = injected;
49
+ wrote.push(lintTarget.viteConfig);
50
+ }
51
+ }
52
+ if (lintTarget.oxlintConfig !== null) {
53
+ draft.files.push({
54
+ filename: path.join(detection.root, INIT_OXLINT_CONFIG_FILE),
55
+ source: INIT_OXLINT_CONFIG,
56
+ });
57
+ draft.createdFiles.push(INIT_OXLINT_CONFIG_FILE);
58
+ wrote.push(INIT_OXLINT_CONFIG_FILE);
59
+ }
60
+ draft.features.push({
61
+ id: "lint",
62
+ outcome: wrote.length > 0 ? "configured" : "unchanged",
63
+ detail:
64
+ wrote.length > 0 ? `${lintTarget.reason}; writes ${wrote.join(" and ")}` : lintTarget.reason,
65
+ snippet: null,
66
+ });
67
+ return source;
68
+ }
@@ -0,0 +1,114 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { DEFAULT_SCRIPTS, detectJsonIndent, parsePackageJson } from "../setup/config.js";
5
+ import type { ProjectDetection } from "./detect.js";
6
+ import { skipped, type PlanDraft } from "./plan-types.js";
7
+ import type { FeatureId, FeatureSelection } from "./select.js";
8
+ import { renderVizeConfig } from "./templates.js";
9
+
10
+ /** Scripts each feature contributes, reusing the command strings `setup` ships. */
11
+ const FEATURE_SCRIPTS: Readonly<Record<FeatureId, readonly string[]>> = {
12
+ lint: ["vize:lint"],
13
+ bundler: [],
14
+ fmt: ["vize:fmt", "vize:fmt:fix"],
15
+ typecheck: ["vize:check"],
16
+ editor: [],
17
+ };
18
+
19
+ /**
20
+ * Plans `vize.config.ts` and the fmt/typecheck feature results.
21
+ *
22
+ * Only selected features contribute a block, so asking for the formatter alone
23
+ * does not hand the project a type checker it never opted into. An existing Vize
24
+ * config is never rewritten: merging into a user's config is exactly the kind of
25
+ * guess that loses their settings.
26
+ */
27
+ export function planVizeConfig(
28
+ detection: ProjectDetection,
29
+ selection: FeatureSelection,
30
+ draft: PlanDraft,
31
+ ): void {
32
+ for (const id of ["fmt", "typecheck"] as const) {
33
+ if (!selection[id]) {
34
+ draft.features.push(
35
+ id === "typecheck" && detection.tsconfig === null
36
+ ? skipped(id, "no tsconfig.json, so vize check has nothing to check")
37
+ : skipped(id),
38
+ );
39
+ continue;
40
+ }
41
+ if (id === "typecheck" && detection.tsconfig === null) {
42
+ draft.features.push({
43
+ id,
44
+ outcome: "blocked",
45
+ detail: "vize check needs a tsconfig.json; none was found",
46
+ snippet: null,
47
+ });
48
+ continue;
49
+ }
50
+ draft.features.push({
51
+ id,
52
+ outcome: detection.vizeConfig === null ? "configured" : "unchanged",
53
+ detail:
54
+ detection.vizeConfig === null
55
+ ? "writes vize.config.ts"
56
+ : `${detection.vizeConfig} already exists and was left unchanged`,
57
+ snippet: null,
58
+ });
59
+ }
60
+
61
+ const needsConfig = selection.lint || selection.fmt || selection.typecheck;
62
+ if (!needsConfig || detection.vizeConfig !== null) {
63
+ return;
64
+ }
65
+ draft.files.push({
66
+ filename: path.join(detection.root, "vize.config.ts"),
67
+ source: renderVizeConfig({
68
+ lint: selection.lint,
69
+ fmt: selection.fmt,
70
+ typecheck: selection.typecheck && detection.tsconfig !== null,
71
+ vite: detection.framework === "vite",
72
+ }),
73
+ });
74
+ draft.createdFiles.push("vize.config.ts");
75
+ }
76
+
77
+ /**
78
+ * Adds the scripts the selected features need.
79
+ *
80
+ * A script the project already defines is left alone, whatever its value: the
81
+ * user's version of `vize:lint` outranks the default, and rewriting it would
82
+ * make a second `init` run destructive.
83
+ */
84
+ export function planScripts(
85
+ detection: ProjectDetection,
86
+ selection: FeatureSelection,
87
+ draft: PlanDraft,
88
+ ): readonly string[] {
89
+ const wanted: string[] = [];
90
+ for (const id of ["lint", "fmt", "typecheck"] as const) {
91
+ if (!selection[id] || (id === "typecheck" && detection.tsconfig === null)) {
92
+ continue;
93
+ }
94
+ wanted.push(...FEATURE_SCRIPTS[id]);
95
+ }
96
+ const missing = wanted.filter((name) => !(name in detection.scripts));
97
+ if (missing.length === 0) {
98
+ return [];
99
+ }
100
+ const packagePath = path.join(detection.root, "package.json");
101
+ const source = fs.readFileSync(packagePath, "utf8");
102
+ const packageJson = parsePackageJson(packagePath, source);
103
+ const scripts = { ...detection.scripts } as Record<string, string>;
104
+ for (const name of missing) {
105
+ scripts[name] = DEFAULT_SCRIPTS[name as keyof typeof DEFAULT_SCRIPTS];
106
+ }
107
+ packageJson.scripts = scripts;
108
+ draft.files.push({
109
+ filename: packagePath,
110
+ source: `${JSON.stringify(packageJson, null, detectJsonIndent(source))}\n`,
111
+ });
112
+ draft.updatedFiles.push("package.json");
113
+ return missing;
114
+ }
@@ -0,0 +1,77 @@
1
+ import type { PlannedFile } from "../setup/config.js";
2
+ import type { ProjectDetection } from "./detect.js";
3
+ import type { LintTarget } from "./lint-target.js";
4
+ import type { FeatureId, FeatureSelection } from "./select.js";
5
+
6
+ /**
7
+ * Shared plan vocabulary.
8
+ *
9
+ * Lives apart from `plan.ts` so the per-feature planners can name these types
10
+ * without importing the orchestrator that calls them.
11
+ */
12
+
13
+ export type FeatureOutcome =
14
+ /** The feature was selected and something was written for it. */
15
+ | "configured"
16
+ /** The feature was selected and is already wired up. A re-run lands here. */
17
+ | "unchanged"
18
+ /** The feature was not selected, or the project cannot support it. */
19
+ | "skipped"
20
+ /** Selected, but a user-owned file has to be edited by hand. Nothing written. */
21
+ | "blocked";
22
+
23
+ export interface FeatureResult {
24
+ readonly id: FeatureId;
25
+ readonly outcome: FeatureOutcome;
26
+ readonly detail: string;
27
+ /** Snippet the user must paste when `outcome` is `blocked`. */
28
+ readonly snippet: string | null;
29
+ }
30
+
31
+ export interface InitCommand {
32
+ readonly command: string;
33
+ readonly args: readonly string[];
34
+ readonly cwd: string;
35
+ }
36
+
37
+ export interface InitPlan {
38
+ readonly root: string;
39
+ readonly detection: ProjectDetection;
40
+ readonly lintTarget: LintTarget;
41
+ readonly features: readonly FeatureResult[];
42
+ readonly files: readonly PlannedFile[];
43
+ readonly createdFiles: readonly string[];
44
+ readonly updatedFiles: readonly string[];
45
+ readonly addedScripts: readonly string[];
46
+ readonly commands: readonly InitCommand[];
47
+ }
48
+
49
+ export interface PlanInitOptions {
50
+ readonly detection: ProjectDetection;
51
+ readonly selection: FeatureSelection;
52
+ readonly install: boolean;
53
+ readonly packageManager?: string;
54
+ }
55
+
56
+ /** Mutable accumulators the per-feature planners append to. */
57
+ export interface PlanDraft {
58
+ readonly files: PlannedFile[];
59
+ readonly createdFiles: string[];
60
+ readonly updatedFiles: string[];
61
+ readonly features: FeatureResult[];
62
+ readonly dependencies: Set<string>;
63
+ }
64
+
65
+ export function createPlanDraft(): PlanDraft {
66
+ return {
67
+ files: [],
68
+ createdFiles: [],
69
+ updatedFiles: [],
70
+ features: [],
71
+ dependencies: new Set<string>(),
72
+ };
73
+ }
74
+
75
+ export function skipped(id: FeatureId, detail = "not selected"): FeatureResult {
76
+ return { id, outcome: "skipped", detail, snippet: null };
77
+ }
@@ -0,0 +1,167 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import type { ProjectDetection } from "./detect.js";
5
+ import { resolveLintTarget } from "./lint-target.js";
6
+ import { planBundler } from "./plan-bundler.js";
7
+ import { planEditorFile } from "./plan-editor.js";
8
+ import { planLint } from "./plan-lint.js";
9
+ import { planScripts, planVizeConfig } from "./plan-project.js";
10
+ import {
11
+ createPlanDraft,
12
+ skipped,
13
+ type FeatureResult,
14
+ type InitCommand,
15
+ type InitPlan,
16
+ type PlanInitOptions,
17
+ } from "./plan-types.js";
18
+ import type { FeatureId } from "./select.js";
19
+
20
+ export type {
21
+ FeatureOutcome,
22
+ FeatureResult,
23
+ InitCommand,
24
+ InitPlan,
25
+ PlanInitOptions,
26
+ } from "./plan-types.js";
27
+
28
+ /** Dev dependencies each feature needs. */
29
+ const FEATURE_DEPENDENCIES: Readonly<Record<FeatureId, readonly string[]>> = {
30
+ lint: ["oxlint", "oxlint-plugin-vize"],
31
+ bundler: [],
32
+ fmt: ["vize"],
33
+ typecheck: ["vize"],
34
+ editor: [],
35
+ };
36
+
37
+ const INSTALL_ARGS: Readonly<Record<string, readonly string[]>> = {
38
+ pnpm: ["add", "-D"],
39
+ yarn: ["add", "-D"],
40
+ bun: ["add", "-D"],
41
+ npm: ["install", "-D"],
42
+ vp: ["add", "-D"],
43
+ };
44
+
45
+ const FEATURE_ORDER: readonly FeatureId[] = ["lint", "bundler", "fmt", "typecheck", "editor"];
46
+
47
+ /**
48
+ * Builds the full plan without touching the filesystem.
49
+ *
50
+ * Planning is separated from execution so `--dry-run`, the interactive
51
+ * confirmation and the tests all inspect the same object the writer consumes;
52
+ * a plan that is correct in `--dry-run` and wrong on disk is not possible.
53
+ */
54
+ export function planInit(options: PlanInitOptions): InitPlan {
55
+ const { detection, selection } = options;
56
+ const draft = createPlanDraft();
57
+
58
+ const viteSource = readSingleViteConfig(detection);
59
+ const lintTarget = resolveLintTarget({ detection, viteSource });
60
+
61
+ let viteDraft = viteSource;
62
+ if (selection.lint) {
63
+ viteDraft = planLint(detection, lintTarget, viteDraft, draft);
64
+ addAll(draft.dependencies, FEATURE_DEPENDENCIES.lint);
65
+ } else {
66
+ draft.features.push(skipped("lint"));
67
+ }
68
+
69
+ if (selection.bundler) {
70
+ viteDraft = planBundler(detection, viteDraft, draft);
71
+ } else {
72
+ draft.features.push(skipped("bundler"));
73
+ }
74
+
75
+ // One write for the Vite config, however many features touched it, so the
76
+ // plugin edit and the lint edit cannot overwrite one another.
77
+ if (viteSource !== null && viteDraft !== null && viteDraft !== viteSource) {
78
+ const filename = detection.viteConfigs[0]!;
79
+ draft.files.push({ filename: path.join(detection.root, filename), source: viteDraft });
80
+ draft.updatedFiles.push(filename);
81
+ }
82
+
83
+ planVizeConfig(detection, selection, draft);
84
+ for (const id of ["fmt", "typecheck"] as const) {
85
+ if (selection[id]) {
86
+ addAll(draft.dependencies, FEATURE_DEPENDENCIES[id]);
87
+ }
88
+ }
89
+
90
+ draft.features.push(
91
+ selection.editor
92
+ ? planEditorFile(detection, draft.files, draft.createdFiles, draft.updatedFiles)
93
+ : skipped("editor"),
94
+ );
95
+
96
+ const addedScripts = planScripts(detection, selection, draft);
97
+ return {
98
+ root: detection.root,
99
+ detection,
100
+ lintTarget,
101
+ features: sortFeatures(draft.features),
102
+ files: draft.files,
103
+ createdFiles: draft.createdFiles,
104
+ updatedFiles: draft.updatedFiles,
105
+ addedScripts,
106
+ commands: planCommands(detection, draft.dependencies, options),
107
+ };
108
+ }
109
+
110
+ /**
111
+ * The install commands, as a list so callers can assert on them.
112
+ *
113
+ * Exactly one command is emitted, or none when every dependency is already
114
+ * declared -- which is what makes a second `init` run a no-op.
115
+ */
116
+ function planCommands(
117
+ detection: ProjectDetection,
118
+ dependencies: ReadonlySet<string>,
119
+ options: PlanInitOptions,
120
+ ): readonly InitCommand[] {
121
+ if (!options.install) {
122
+ return [];
123
+ }
124
+ const missing = [...dependencies].filter((name) => !detection.dependencies.has(name)).sort();
125
+ if (missing.length === 0) {
126
+ return [];
127
+ }
128
+ const command = resolveInstaller(detection, options.packageManager);
129
+ return [{ command, args: [...INSTALL_ARGS[command]!, ...missing], cwd: detection.root }];
130
+ }
131
+
132
+ /**
133
+ * Installer used for the one install command.
134
+ *
135
+ * A Vite+ project gets `vp add`, matching `setup` and the project's own
136
+ * workflow. Otherwise the package manager comes from the same lockfile rules
137
+ * `detect_package_manager` uses on the Rust side, defaulting to npm when
138
+ * nothing identifies one.
139
+ */
140
+ export function resolveInstaller(detection: ProjectDetection, override?: string): string {
141
+ if (override !== undefined) {
142
+ return override;
143
+ }
144
+ if (detection.usesVitePlus) {
145
+ return "vp";
146
+ }
147
+ return detection.packageManager ?? "npm";
148
+ }
149
+
150
+ function readSingleViteConfig(detection: ProjectDetection): string | null {
151
+ if (detection.viteConfigs.length !== 1) {
152
+ return null;
153
+ }
154
+ return fs.readFileSync(path.join(detection.root, detection.viteConfigs[0]!), "utf8");
155
+ }
156
+
157
+ function addAll(target: Set<string>, values: readonly string[]): void {
158
+ for (const value of values) {
159
+ target.add(value);
160
+ }
161
+ }
162
+
163
+ function sortFeatures(features: readonly FeatureResult[]): readonly FeatureResult[] {
164
+ return [...features].sort(
165
+ (left, right) => FEATURE_ORDER.indexOf(left.id) - FEATURE_ORDER.indexOf(right.id),
166
+ );
167
+ }
@@ -0,0 +1,161 @@
1
+ import readline from "node:readline";
2
+
3
+ import type { FeatureId, FeatureOffer, FeatureSelection } from "./select.js";
4
+
5
+ /**
6
+ * Interactive multi-select for the five features.
7
+ *
8
+ * Implemented on `node:readline` rather than a prompt package: `vize` is a
9
+ * published CLI whose install cost every user pays, and a numbered toggle list
10
+ * needs no raw-mode handling, no terminal restore path, and no dependency in the
11
+ * runtime path. It is a real multi-select -- numbers toggle, Enter accepts.
12
+ */
13
+
14
+ export interface PromptIo {
15
+ readonly input: NodeJS.ReadableStream;
16
+ readonly output: NodeJS.WritableStream;
17
+ }
18
+
19
+ export interface PromptDeps extends PromptIo {
20
+ /** Resolves to `null` when the input ended before an answer arrived. */
21
+ readonly question: (query: string) => Promise<string | null>;
22
+ /**
23
+ * Releases the terminal. Required for readline-backed deps: an open interface
24
+ * keeps stdin referenced and the process never exits.
25
+ */
26
+ readonly close?: () => void;
27
+ }
28
+
29
+ /** True when stdin cannot answer a prompt, so `init` must not ask one. */
30
+ export function isNonInteractive(stream: NodeJS.ReadableStream): boolean {
31
+ return (stream as NodeJS.ReadStream).isTTY !== true;
32
+ }
33
+
34
+ /**
35
+ * Wraps `node:readline` so a closed input resolves instead of hanging.
36
+ *
37
+ * `rl.question` never invokes its callback when stdin reaches EOF first. Left
38
+ * alone that leaves `init`'s promise permanently pending, and the process exits
39
+ * `0` having written nothing -- a silent no-op that looks like success. Resolving
40
+ * to `null` on close turns that into an explicit cancellation.
41
+ */
42
+ export function createPromptDeps(io: PromptIo): PromptDeps {
43
+ const rl = readline.createInterface({ input: io.input, output: io.output });
44
+ let closed = false;
45
+ rl.on("close", () => {
46
+ closed = true;
47
+ });
48
+ return {
49
+ ...io,
50
+ question: (query) =>
51
+ new Promise<string | null>((resolve) => {
52
+ if (closed) {
53
+ resolve(null);
54
+ return;
55
+ }
56
+ let settled = false;
57
+ const onClose = (): void => {
58
+ if (!settled) {
59
+ settled = true;
60
+ resolve(null);
61
+ }
62
+ };
63
+ rl.once("close", onClose);
64
+ rl.question(query, (answer) => {
65
+ if (settled) {
66
+ return;
67
+ }
68
+ settled = true;
69
+ rl.removeListener("close", onClose);
70
+ resolve(answer);
71
+ });
72
+ }),
73
+ close: () => {
74
+ rl.close();
75
+ },
76
+ };
77
+ }
78
+
79
+ /** Runs the checklist. Returns `null` when the input ended before confirmation. */
80
+ export async function selectFeatures(
81
+ offers: readonly FeatureOffer[],
82
+ initial: FeatureSelection,
83
+ deps: PromptDeps,
84
+ ): Promise<FeatureSelection | null> {
85
+ const selection: Record<FeatureId, boolean> = { ...initial };
86
+ const toggleable = offers.filter((offer) => offer.available);
87
+ for (;;) {
88
+ deps.output.write(renderChecklist(offers, selection));
89
+ const raw = await deps.question("> ");
90
+ if (raw === null) {
91
+ return null;
92
+ }
93
+ const answer = raw.trim();
94
+ if (answer === "") {
95
+ return selection;
96
+ }
97
+ const indexes = parseIndexes(answer, toggleable.length);
98
+ if (indexes === null) {
99
+ deps.output.write(
100
+ `Enter numbers between 1 and ${toggleable.length}, or press Enter to accept.\n`,
101
+ );
102
+ continue;
103
+ }
104
+ for (const index of indexes) {
105
+ const offer = toggleable[index]!;
106
+ selection[offer.id] = !selection[offer.id];
107
+ }
108
+ }
109
+ }
110
+
111
+ /** Yes/no confirmation. A closed input counts as "no", never as "yes". */
112
+ export async function confirm(query: string, deps: PromptDeps): Promise<boolean> {
113
+ const raw = await deps.question(`${query} [Y/n] `);
114
+ if (raw === null) {
115
+ return false;
116
+ }
117
+ const answer = raw.trim().toLowerCase();
118
+ return answer === "" || answer === "y" || answer === "yes";
119
+ }
120
+
121
+ function renderChecklist(
122
+ offers: readonly FeatureOffer[],
123
+ selection: Readonly<Record<FeatureId, boolean>>,
124
+ ): string {
125
+ const lines = [
126
+ "",
127
+ "Select the features to configure.",
128
+ "Type the numbers to toggle (space or comma separated), then press Enter.",
129
+ "",
130
+ ];
131
+ let position = 0;
132
+ for (const offer of offers) {
133
+ if (!offer.available) {
134
+ lines.push(` - ${offer.label}${offer.note === "" ? "" : ` (${offer.note})`}`);
135
+ continue;
136
+ }
137
+ position += 1;
138
+ const mark = selection[offer.id] ? "x" : " ";
139
+ const note = offer.note === "" ? "" : ` (${offer.note})`;
140
+ lines.push(` ${position}. [${mark}] ${offer.label}${note}`);
141
+ }
142
+ lines.push("");
143
+ return `${lines.join("\n")}\n`;
144
+ }
145
+
146
+ /** Parses a toggle answer into zero-based indexes, or `null` when any entry is out of range. */
147
+ function parseIndexes(answer: string, count: number): readonly number[] | null {
148
+ const tokens = answer.split(/[\s,]+/u).filter((token) => token !== "");
149
+ const indexes: number[] = [];
150
+ for (const token of tokens) {
151
+ if (!/^\d+$/u.test(token)) {
152
+ return null;
153
+ }
154
+ const value = Number.parseInt(token, 10);
155
+ if (value < 1 || value > count) {
156
+ return null;
157
+ }
158
+ indexes.push(value - 1);
159
+ }
160
+ return indexes.length === 0 ? null : indexes;
161
+ }