stackpack-cli 0.3.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,4702 @@
1
+ import { Command, Option } from "commander";
2
+ import pc from "picocolors";
3
+ import * as p from "@clack/prompts";
4
+ import fs from "node:fs/promises";
5
+ import path from "node:path";
6
+ import fs$1 from "node:fs";
7
+ import { z } from "zod";
8
+ import { applyEdits, modify, parse } from "jsonc-parser";
9
+ import fg from "fast-glob";
10
+ import validatePackageName from "validate-npm-package-name";
11
+ import semver from "semver";
12
+ import os from "node:os";
13
+ import crypto from "node:crypto";
14
+ import { execa } from "execa";
15
+ //#region src/utils/errors.ts
16
+ var StackPackError = class extends Error {
17
+ hints;
18
+ constructor(message, options) {
19
+ super(message, { cause: options?.cause });
20
+ this.name = "StackPackError";
21
+ this.hints = options?.hints ?? [];
22
+ }
23
+ };
24
+ var CancelledError = class extends Error {
25
+ constructor(message = "Operation cancelled.") {
26
+ super(message);
27
+ this.name = "CancelledError";
28
+ }
29
+ };
30
+ function toErrorMessage(error) {
31
+ if (error instanceof Error) return error.message;
32
+ return String(error);
33
+ }
34
+ //#endregion
35
+ //#region src/ui/prompts.ts
36
+ /** Unwraps a Clack prompt result, converting cancellation into a typed error. */
37
+ function guard(value) {
38
+ if (p.isCancel(value)) throw new CancelledError();
39
+ return value;
40
+ }
41
+ /**
42
+ * Unwraps a Clack prompt result, returning null on cancel (Esc) so callers
43
+ * can treat it as "go back one level" instead of aborting the whole flow.
44
+ */
45
+ function orBack(value) {
46
+ if (p.isCancel(value)) return null;
47
+ return value;
48
+ }
49
+ //#endregion
50
+ //#region src/ui/errors.ts
51
+ /**
52
+ * Prints an actionable error. Returns the exit code the process should use.
53
+ */
54
+ function printError(error, options = {}) {
55
+ if (error instanceof CancelledError) {
56
+ console.log("");
57
+ console.log("Operation cancelled.");
58
+ console.log(options.changesApplied ? "Some changes may have been applied before cancellation. Check .stackpack/backups for backups." : "No project files were changed.");
59
+ return 0;
60
+ }
61
+ console.error("");
62
+ console.error(pc.red(`✗ ${toErrorMessage(error)}`));
63
+ if (error instanceof StackPackError) for (const hint of error.hints) console.error(` ${hint}`);
64
+ return 1;
65
+ }
66
+ //#endregion
67
+ //#region src/creators/vite/adapter.ts
68
+ /**
69
+ * Official create-vite adapter. Fresh setups pass no --template so the
70
+ * official creator asks for the framework (React, Vue, Svelte, ...) and the
71
+ * variant (TypeScript, JavaScript, SWC, ...) itself. Preset runs recorded a
72
+ * React project, so they pin the matching official React template.
73
+ *
74
+ * --no-immediate is always passed: it suppresses only create-vite's
75
+ * "Install with npm and start now?" step (which would install and then
76
+ * block on a dev server) while keeping the framework and variant prompts,
77
+ * so dependencies install once at the end together with the integrations.
78
+ */
79
+ const viteAdapter = {
80
+ id: "vite",
81
+ name: "Vite",
82
+ officialTool: "create-vite",
83
+ frameworkLabel: "Your pick from create-vite (React, Vue, Svelte, and more)",
84
+ templateLabel(options) {
85
+ if (!options.language) return "You choose the framework and variant in create-vite's prompts";
86
+ return options.language === "typescript" ? "Official React TypeScript template (react-ts)" : "Official React template (react)";
87
+ },
88
+ async collectOptions() {
89
+ return { setupStyle: "custom" };
90
+ },
91
+ buildCommand(projectName, options, packageManager, parentDirectory) {
92
+ const templateArgs = [...options.language ? ["--template", options.language === "typescript" ? "react-ts" : "react"] : [], "--no-immediate"];
93
+ switch (packageManager) {
94
+ case "npm": return {
95
+ command: "npm",
96
+ args: [
97
+ "create",
98
+ "vite@latest",
99
+ projectName,
100
+ "--",
101
+ ...templateArgs
102
+ ],
103
+ cwd: parentDirectory,
104
+ interactive: true
105
+ };
106
+ case "pnpm": return {
107
+ command: "pnpm",
108
+ args: [
109
+ "create",
110
+ "vite",
111
+ projectName,
112
+ ...templateArgs
113
+ ],
114
+ cwd: parentDirectory,
115
+ interactive: true
116
+ };
117
+ case "yarn": return {
118
+ command: "yarn",
119
+ args: [
120
+ "create",
121
+ "vite",
122
+ projectName,
123
+ ...templateArgs
124
+ ],
125
+ cwd: parentDirectory,
126
+ interactive: true
127
+ };
128
+ case "bun": return {
129
+ command: "bun",
130
+ args: [
131
+ "create",
132
+ "vite",
133
+ projectName,
134
+ ...templateArgs
135
+ ],
136
+ cwd: parentDirectory,
137
+ interactive: true
138
+ };
139
+ }
140
+ }
141
+ };
142
+ //#endregion
143
+ //#region src/creators/next/adapter.ts
144
+ /**
145
+ * Official create-next-app adapter. create-next-app treats ANY "--" flag as
146
+ * "answer everything else with the defaults", so its own step-by-step
147
+ * prompts cannot be combined with --skip-install. To keep a single combined
148
+ * dependency install at the end, StackPack asks the same questions itself
149
+ * (same choices and defaults as create-next-app 16) and passes every answer
150
+ * as a flag, always together with --skip-install.
151
+ */
152
+ async function askLanguage() {
153
+ return guard(await p.select({
154
+ message: "Would you like to use TypeScript?",
155
+ options: [{
156
+ value: "typescript",
157
+ label: "TypeScript",
158
+ hint: "official recommendation"
159
+ }, {
160
+ value: "javascript",
161
+ label: "JavaScript"
162
+ }]
163
+ }));
164
+ }
165
+ async function askCustomAnswers() {
166
+ return {
167
+ linter: guard(await p.select({
168
+ message: "Which linter would you like to use?",
169
+ options: [
170
+ {
171
+ value: "eslint",
172
+ label: "ESLint"
173
+ },
174
+ {
175
+ value: "biome",
176
+ label: "Biome"
177
+ },
178
+ {
179
+ value: "none",
180
+ label: "None"
181
+ }
182
+ ]
183
+ })),
184
+ reactCompiler: guard(await p.confirm({
185
+ message: "Would you like to use React Compiler?",
186
+ initialValue: false
187
+ })),
188
+ tailwind: guard(await p.confirm({
189
+ message: "Would you like to use Tailwind CSS?",
190
+ initialValue: true
191
+ })),
192
+ srcDir: guard(await p.confirm({
193
+ message: "Would you like your code inside a `src/` directory?",
194
+ initialValue: false
195
+ })),
196
+ appRouter: guard(await p.confirm({
197
+ message: "Would you like to use App Router? (recommended)",
198
+ initialValue: true
199
+ })),
200
+ importAlias: guard(await p.text({
201
+ message: "What import alias would you like configured?",
202
+ initialValue: "@/*",
203
+ validate(value) {
204
+ return /^.+\/\*$/.test(value ?? "") ? void 0 : "Import alias must follow the pattern <prefix>/*";
205
+ }
206
+ })),
207
+ agentsMd: guard(await p.confirm({
208
+ message: "Include AGENTS.md to guide coding agents?",
209
+ initialValue: true
210
+ }))
211
+ };
212
+ }
213
+ function customFlags(answers) {
214
+ return [
215
+ answers.linter === "eslint" ? "--eslint" : answers.linter === "biome" ? "--biome" : "--no-linter",
216
+ answers.reactCompiler ? "--react-compiler" : "--no-react-compiler",
217
+ answers.tailwind ? "--tailwind" : "--no-tailwind",
218
+ answers.srcDir ? "--src-dir" : "--no-src-dir",
219
+ answers.appRouter ? "--app" : "--no-app",
220
+ "--import-alias",
221
+ answers.importAlias,
222
+ answers.agentsMd ? "--agents-md" : "--no-agents-md"
223
+ ];
224
+ }
225
+ //#endregion
226
+ //#region src/creators/registry.ts
227
+ const allCreators = [viteAdapter, {
228
+ id: "next",
229
+ name: "Next.js",
230
+ officialTool: "create-next-app",
231
+ frameworkLabel: "Next.js",
232
+ templateLabel(options) {
233
+ const language = options.language === "javascript" ? "JavaScript" : "TypeScript";
234
+ if (options.setupStyle === "custom" && options.nextAnswers) {
235
+ const a = options.nextAnswers;
236
+ const parts = [
237
+ language,
238
+ a.linter === "none" ? "no linter" : a.linter === "biome" ? "Biome" : "ESLint",
239
+ a.tailwind ? "Tailwind CSS" : "no Tailwind",
240
+ a.appRouter ? "App Router" : "Pages Router"
241
+ ];
242
+ if (a.reactCompiler) parts.push("React Compiler");
243
+ if (a.srcDir) parts.push("src/ directory");
244
+ return `Customized: ${parts.join(", ")}`;
245
+ }
246
+ return `Official Next.js defaults with ${language}`;
247
+ },
248
+ async collectOptions() {
249
+ const setupStyle = guard(await p.select({
250
+ message: "How should the Next.js project be configured?",
251
+ options: [{
252
+ value: "custom",
253
+ label: "Customize step by step",
254
+ hint: "TypeScript, linter, React Compiler, Tailwind, src/, App Router, import alias"
255
+ }, {
256
+ value: "recommended",
257
+ label: "Official recommended defaults",
258
+ hint: "no further questions; currently Tailwind CSS, Biome, App Router"
259
+ }]
260
+ }));
261
+ const language = await askLanguage();
262
+ if (setupStyle === "recommended") return {
263
+ language,
264
+ setupStyle
265
+ };
266
+ return {
267
+ language,
268
+ setupStyle,
269
+ nextAnswers: await askCustomAnswers()
270
+ };
271
+ },
272
+ buildCommand(projectName, options, packageManager, parentDirectory) {
273
+ const creatorArgs = [
274
+ projectName,
275
+ options.language === "javascript" ? "--javascript" : "--typescript",
276
+ ...options.setupStyle === "custom" && options.nextAnswers ? customFlags(options.nextAnswers) : ["--yes"],
277
+ `--use-${packageManager}`,
278
+ "--skip-install"
279
+ ];
280
+ switch (packageManager) {
281
+ case "npm": return {
282
+ command: "npx",
283
+ args: ["create-next-app@latest", ...creatorArgs],
284
+ cwd: parentDirectory,
285
+ interactive: true
286
+ };
287
+ case "pnpm": return {
288
+ command: "pnpm",
289
+ args: [
290
+ "create",
291
+ "next-app",
292
+ ...creatorArgs
293
+ ],
294
+ cwd: parentDirectory,
295
+ interactive: true
296
+ };
297
+ case "yarn": return {
298
+ command: "yarn",
299
+ args: [
300
+ "create",
301
+ "next-app",
302
+ ...creatorArgs
303
+ ],
304
+ cwd: parentDirectory,
305
+ interactive: true
306
+ };
307
+ case "bun": return {
308
+ command: "bunx",
309
+ args: ["create-next-app@latest", ...creatorArgs],
310
+ cwd: parentDirectory,
311
+ interactive: true
312
+ };
313
+ }
314
+ }
315
+ }];
316
+ function getCreator(id) {
317
+ const creator = allCreators.find((adapter) => adapter.id === id);
318
+ if (!creator) throw new Error(`Unknown creator adapter: ${id}`);
319
+ return creator;
320
+ }
321
+ //#endregion
322
+ //#region src/schemas/project-context.ts
323
+ const frameworkSchema = z.enum([
324
+ "react",
325
+ "next",
326
+ "unknown"
327
+ ]);
328
+ const buildToolSchema = z.enum([
329
+ "vite",
330
+ "next",
331
+ "unknown"
332
+ ]);
333
+ const languageSchema = z.enum([
334
+ "typescript",
335
+ "javascript",
336
+ "unknown"
337
+ ]);
338
+ const packageManagerSchema = z.enum([
339
+ "npm",
340
+ "pnpm",
341
+ "yarn",
342
+ "bun"
343
+ ]);
344
+ const routerTypeSchema = z.enum([
345
+ "app-router",
346
+ "pages-router",
347
+ "client-router",
348
+ "unknown"
349
+ ]);
350
+ const detectionStateSchema = z.enum([
351
+ "detected",
352
+ "partially-detected",
353
+ "ambiguous",
354
+ "unsupported"
355
+ ]);
356
+ const packageJsonSchema = z.object({
357
+ name: z.string().optional(),
358
+ version: z.string().optional(),
359
+ scripts: z.record(z.string(), z.string()).optional(),
360
+ dependencies: z.record(z.string(), z.string()).optional(),
361
+ devDependencies: z.record(z.string(), z.string()).optional()
362
+ }).loose();
363
+ const projectContextSchema = z.object({
364
+ rootDirectory: z.string(),
365
+ framework: frameworkSchema,
366
+ buildTool: buildToolSchema,
367
+ language: languageSchema,
368
+ packageManager: packageManagerSchema,
369
+ /** All package managers whose lockfiles were found; >1 means ambiguous. */
370
+ packageManagerCandidates: z.array(packageManagerSchema),
371
+ routerType: routerTypeSchema.optional(),
372
+ detection: detectionStateSchema,
373
+ packageJson: packageJsonSchema,
374
+ detectedFiles: z.array(z.string()),
375
+ installedPackages: z.record(z.string(), z.string())
376
+ });
377
+ //#endregion
378
+ //#region src/project/package-json.ts
379
+ async function readPackageJson(projectRoot) {
380
+ const filePath = path.join(projectRoot, "package.json");
381
+ let raw;
382
+ try {
383
+ raw = await fs.readFile(filePath, "utf8");
384
+ } catch (error) {
385
+ throw new StackPackError("package.json was not found.", {
386
+ hints: ["StackPack must run inside a supported JavaScript project.", `Current directory: ${projectRoot}`],
387
+ cause: error
388
+ });
389
+ }
390
+ let parsed;
391
+ try {
392
+ parsed = JSON.parse(raw);
393
+ } catch (error) {
394
+ throw new StackPackError("package.json could not be parsed as JSON.", {
395
+ hints: [`File: ${filePath}`],
396
+ cause: error
397
+ });
398
+ }
399
+ const validated = packageJsonSchema.safeParse(parsed);
400
+ if (!validated.success) throw new StackPackError("package.json has an unexpected structure.", { hints: validated.error.issues.slice(0, 3).map((i) => `${i.path.join(".")}: ${i.message}`) });
401
+ return {
402
+ raw,
403
+ data: validated.data
404
+ };
405
+ }
406
+ /** Merged dependencies and devDependencies from package.json. */
407
+ function getInstalledPackages(pkg) {
408
+ return {
409
+ ...pkg.dependencies,
410
+ ...pkg.devDependencies
411
+ };
412
+ }
413
+ /**
414
+ * Applies script changes to package.json using structured JSONC edits so
415
+ * formatting and unrelated properties are preserved.
416
+ */
417
+ async function updatePackageJsonScripts(projectRoot, scripts) {
418
+ const filePath = path.join(projectRoot, "package.json");
419
+ let content = await fs.readFile(filePath, "utf8");
420
+ for (const [name, command] of Object.entries(scripts)) {
421
+ const edits = modify(content, ["scripts", name], command, { formattingOptions: {
422
+ insertSpaces: true,
423
+ tabSize: 2
424
+ } });
425
+ content = applyEdits(content, edits);
426
+ }
427
+ const reparsed = parse(content);
428
+ if (typeof reparsed !== "object" || reparsed === null) throw new StackPackError("Refusing to write package.json: edit produced invalid JSON.");
429
+ await fs.writeFile(filePath, content, "utf8");
430
+ }
431
+ //#endregion
432
+ //#region src/project/detect-files.ts
433
+ const MARKER_PATTERNS = [
434
+ "package.json",
435
+ "package-lock.json",
436
+ "pnpm-lock.yaml",
437
+ "yarn.lock",
438
+ "bun.lock",
439
+ "bun.lockb",
440
+ "vite.config.*",
441
+ "vitest.config.*",
442
+ "next.config.*",
443
+ "playwright.config.*",
444
+ "components.json",
445
+ "tsconfig.json",
446
+ "jsconfig.json",
447
+ "src/main.*",
448
+ "src/App.*",
449
+ "src/test/setup.*",
450
+ "app/layout.*",
451
+ "app/page.*",
452
+ "src/app/layout.*",
453
+ "src/app/page.*",
454
+ "pages/_app.*",
455
+ "pages/index.*",
456
+ "src/pages/_app.*",
457
+ "src/pages/index.*"
458
+ ];
459
+ const IGNORED = [
460
+ "**/node_modules/**",
461
+ "**/dist/**",
462
+ "**/build/**",
463
+ "**/coverage/**",
464
+ "**/.git/**"
465
+ ];
466
+ /**
467
+ * Finds well-known project marker files. Never scans outside the given root
468
+ * and skips dependency and build output directories.
469
+ */
470
+ async function detectProjectFiles(projectRoot) {
471
+ const entries = await fg(MARKER_PATTERNS, {
472
+ cwd: projectRoot,
473
+ ignore: IGNORED,
474
+ onlyFiles: true,
475
+ dot: false,
476
+ followSymbolicLinks: false
477
+ });
478
+ return new Set(entries.map((entry) => entry.replaceAll("\\", "/")));
479
+ }
480
+ //#endregion
481
+ //#region src/package-manager/detect.ts
482
+ const LOCKFILES = [
483
+ ["package-lock.json", "npm"],
484
+ ["pnpm-lock.yaml", "pnpm"],
485
+ ["yarn.lock", "yarn"],
486
+ ["bun.lock", "bun"],
487
+ ["bun.lockb", "bun"]
488
+ ];
489
+ /**
490
+ * Detects package managers from lockfiles in the project root.
491
+ * Returns every distinct match; more than one means the project is ambiguous
492
+ * and the user must choose.
493
+ */
494
+ function detectPackageManagers(projectRoot) {
495
+ const found = [];
496
+ for (const [lockfile, manager] of LOCKFILES) if (fs$1.existsSync(path.join(projectRoot, lockfile)) && !found.includes(manager)) found.push(manager);
497
+ return found;
498
+ }
499
+ //#endregion
500
+ //#region src/engine/detect-project.ts
501
+ function has(files, prefix) {
502
+ for (const file of files) if (file.startsWith(prefix)) return true;
503
+ return false;
504
+ }
505
+ /**
506
+ * Inspects a project directory and builds a normalized, validated context.
507
+ * Never trusts prior selections: everything is derived from real files.
508
+ */
509
+ async function detectProject(rootDirectory, options = {}) {
510
+ const root = path.resolve(rootDirectory);
511
+ const { data: packageJson } = await readPackageJson(root);
512
+ const files = await detectProjectFiles(root);
513
+ const installedPackages = getInstalledPackages(packageJson);
514
+ const hasNext = "next" in installedPackages;
515
+ const hasReact = "react" in installedPackages;
516
+ const hasVite = "vite" in installedPackages || has(files, "vite.config.");
517
+ const framework = hasNext ? "next" : hasReact ? "react" : "unknown";
518
+ const buildTool = hasNext ? "next" : hasVite ? "vite" : "unknown";
519
+ const hasTsConfig = files.has("tsconfig.json");
520
+ const hasTsDep = "typescript" in installedPackages;
521
+ const hasTsEntry = has(files, "src/main.ts") || has(files, "src/App.ts");
522
+ const hasJsEntry = has(files, "src/main.js") || has(files, "src/App.js") || files.has("jsconfig.json");
523
+ const language = hasTsConfig || hasTsDep || hasTsEntry ? "typescript" : hasJsEntry || hasReact || hasNext ? "javascript" : "unknown";
524
+ let routerType;
525
+ if (framework === "next") {
526
+ const hasAppDir = has(files, "app/layout.") || has(files, "app/page.") || has(files, "src/app/layout.") || has(files, "src/app/page.") || fs$1.existsSync(path.join(root, "app")) || fs$1.existsSync(path.join(root, "src", "app"));
527
+ const hasPagesDir = has(files, "pages/") || has(files, "src/pages/") || fs$1.existsSync(path.join(root, "pages")) || fs$1.existsSync(path.join(root, "src", "pages"));
528
+ routerType = hasAppDir ? "app-router" : hasPagesDir ? "pages-router" : "unknown";
529
+ } else if (framework === "react") routerType = "react-router-dom" in installedPackages || "react-router" in installedPackages ? "client-router" : "unknown";
530
+ const candidates = detectPackageManagers(root);
531
+ const packageManager = options.packageManagerOverride ?? candidates[0] ?? "npm";
532
+ let detection;
533
+ if (framework === "unknown") detection = "unsupported";
534
+ else if (candidates.length > 1 && !options.packageManagerOverride) detection = "ambiguous";
535
+ else if (framework === "react" && buildTool === "unknown") detection = "partially-detected";
536
+ else detection = "detected";
537
+ return projectContextSchema.parse({
538
+ rootDirectory: root,
539
+ framework,
540
+ buildTool,
541
+ language,
542
+ packageManager,
543
+ packageManagerCandidates: candidates,
544
+ routerType,
545
+ detection,
546
+ packageJson,
547
+ detectedFiles: [...files].sort(),
548
+ installedPackages
549
+ });
550
+ }
551
+ //#endregion
552
+ //#region src/schemas/recipe.ts
553
+ /** Validates the static, serializable metadata of an integration recipe. */
554
+ const recipeMetadataSchema = z.object({
555
+ id: z.string().regex(/^[a-z0-9-]+$/),
556
+ recipeVersion: z.number().int().positive(),
557
+ name: z.string().min(1),
558
+ category: z.enum([
559
+ "routing",
560
+ "state-management",
561
+ "data-fetching",
562
+ "forms-validation",
563
+ "ui",
564
+ "orm",
565
+ "testing"
566
+ ]),
567
+ status: z.enum([
568
+ "stable",
569
+ "experimental",
570
+ "deprecated"
571
+ ]),
572
+ officialSource: z.object({
573
+ documentationUrl: z.string().url(),
574
+ lastVerifiedAt: z.string()
575
+ }),
576
+ requires: z.array(z.string()).default([]),
577
+ runsAfter: z.array(z.string()).default([]),
578
+ conflictsWith: z.array(z.string()).default([])
579
+ });
580
+ //#endregion
581
+ //#region src/integrations/detect.ts
582
+ /** Standard detection: any of the given packages present in package.json. */
583
+ function detectByPackages(context, packageNames) {
584
+ for (const name of packageNames) {
585
+ const version = context.installedPackages[name];
586
+ if (version !== void 0) return {
587
+ status: "installed",
588
+ installedVersion: version,
589
+ details: `${name}@${version}`
590
+ };
591
+ }
592
+ return { status: "not-installed" };
593
+ }
594
+ function sourceExtension(context, jsx) {
595
+ if (context.language === "typescript") return jsx ? "tsx" : "ts";
596
+ return jsx ? "jsx" : "js";
597
+ }
598
+ //#endregion
599
+ //#region src/integrations/react-router/recipe.ts
600
+ const packages$3 = [{
601
+ name: "react-router-dom",
602
+ version: "latest",
603
+ dependencyType: "dependency",
604
+ reason: "Official React Router package for web applications."
605
+ }];
606
+ const reactRouterRecipe = {
607
+ id: "react-router",
608
+ recipeVersion: 1,
609
+ name: "React Router",
610
+ category: "routing",
611
+ status: "stable",
612
+ installationSummary: "Official documented package installation",
613
+ officialSource: {
614
+ documentationUrl: "https://reactrouter.com/start/library/installation",
615
+ lastVerifiedAt: "2026-07-19"
616
+ },
617
+ requires: [],
618
+ runsAfter: [],
619
+ conflictsWith: [],
620
+ supportedProjects: [{
621
+ frameworks: ["react"],
622
+ buildTools: ["vite"]
623
+ }],
624
+ installation: {
625
+ type: "official-package-install",
626
+ dependencies: packages$3,
627
+ devDependencies: []
628
+ },
629
+ detectInstalled(context) {
630
+ return detectByPackages(context, ["react-router-dom", "react-router"]);
631
+ },
632
+ createPlan() {
633
+ return {
634
+ packages: packages$3,
635
+ filesToCreate: [],
636
+ scripts: [],
637
+ postInstallNotes: ["React Router was installed without generated routing files.", "Wrap your application in <BrowserRouter> (see the official docs) when you are ready to add routes."]
638
+ };
639
+ }
640
+ };
641
+ //#endregion
642
+ //#region src/integrations/zustand/recipe.ts
643
+ const packages$2 = [{
644
+ name: "zustand",
645
+ version: "latest",
646
+ dependencyType: "dependency",
647
+ reason: "Official Zustand package."
648
+ }];
649
+ const zustandRecipe = {
650
+ id: "zustand",
651
+ recipeVersion: 1,
652
+ name: "Zustand",
653
+ category: "state-management",
654
+ status: "stable",
655
+ installationSummary: "Official documented package installation",
656
+ officialSource: {
657
+ documentationUrl: "https://zustand.docs.pmnd.rs/getting-started/introduction",
658
+ lastVerifiedAt: "2026-07-19"
659
+ },
660
+ requires: [],
661
+ runsAfter: [],
662
+ conflictsWith: [],
663
+ supportedProjects: [{ frameworks: ["react", "next"] }],
664
+ installation: {
665
+ type: "official-package-install",
666
+ dependencies: packages$2,
667
+ devDependencies: []
668
+ },
669
+ detectInstalled(context) {
670
+ return detectByPackages(context, ["zustand"]);
671
+ },
672
+ createPlan() {
673
+ return {
674
+ packages: packages$2,
675
+ filesToCreate: [],
676
+ scripts: [],
677
+ postInstallNotes: ["Zustand requires no configuration by default."]
678
+ };
679
+ }
680
+ };
681
+ //#endregion
682
+ //#region src/integrations/redux-toolkit/recipe.ts
683
+ const packages$1 = [{
684
+ name: "@reduxjs/toolkit",
685
+ version: "latest",
686
+ dependencyType: "dependency",
687
+ reason: "Official Redux Toolkit package."
688
+ }, {
689
+ name: "react-redux",
690
+ version: "latest",
691
+ dependencyType: "dependency",
692
+ reason: "Official React binding for Redux."
693
+ }];
694
+ function storeFiles(context) {
695
+ const ts = context.language === "typescript";
696
+ const ext = sourceExtension(context, false);
697
+ const storeContents = ts ? `import { configureStore } from "@reduxjs/toolkit";
698
+
699
+ export const store = configureStore({
700
+ reducer: {},
701
+ });
702
+
703
+ export type RootState = ReturnType<typeof store.getState>;
704
+ export type AppDispatch = typeof store.dispatch;
705
+ ` : `import { configureStore } from "@reduxjs/toolkit";
706
+
707
+ export const store = configureStore({
708
+ reducer: {},
709
+ });
710
+ `;
711
+ const files = [{
712
+ path: `src/store/store.${ext}`,
713
+ contents: storeContents,
714
+ description: "Basic Redux store following the official Redux Toolkit quick start."
715
+ }];
716
+ if (ts) files.push({
717
+ path: "src/store/hooks.ts",
718
+ contents: `import { useDispatch, useSelector } from "react-redux";
719
+ import type { AppDispatch, RootState } from "./store";
720
+
721
+ export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
722
+ export const useAppSelector = useSelector.withTypes<RootState>();
723
+ `,
724
+ description: "Typed hooks recommended by the official Redux Toolkit TypeScript guide."
725
+ });
726
+ return files;
727
+ }
728
+ const reduxToolkitRecipe = {
729
+ id: "redux-toolkit",
730
+ recipeVersion: 1,
731
+ name: "Redux Toolkit",
732
+ category: "state-management",
733
+ status: "stable",
734
+ installationSummary: "Official React package installation",
735
+ officialSource: {
736
+ documentationUrl: "https://redux-toolkit.js.org/introduction/getting-started",
737
+ lastVerifiedAt: "2026-07-19"
738
+ },
739
+ requires: [],
740
+ runsAfter: [],
741
+ conflictsWith: [],
742
+ supportedProjects: [{ frameworks: ["react", "next"] }],
743
+ installation: {
744
+ type: "official-package-install",
745
+ dependencies: packages$1,
746
+ devDependencies: []
747
+ },
748
+ detectInstalled(context) {
749
+ return detectByPackages(context, ["@reduxjs/toolkit"]);
750
+ },
751
+ describeOptions(options) {
752
+ return options.generateStore === true ? "with basic store files" : void 0;
753
+ },
754
+ createPlan(context, options) {
755
+ const generateStore = options.generateStore === true;
756
+ return {
757
+ packages: packages$1,
758
+ filesToCreate: generateStore ? storeFiles(context) : [],
759
+ scripts: [],
760
+ postInstallNotes: generateStore ? ["Wrap your application in <Provider store={store}> to activate the store."] : ["Packages installed only. No store files were generated."]
761
+ };
762
+ }
763
+ };
764
+ //#endregion
765
+ //#region src/integrations/tanstack-query/recipe.ts
766
+ const mainPackage = {
767
+ name: "@tanstack/react-query",
768
+ version: "latest",
769
+ dependencyType: "dependency",
770
+ reason: "Official TanStack Query package for React."
771
+ };
772
+ const devtoolsPackage = {
773
+ name: "@tanstack/react-query-devtools",
774
+ version: "latest",
775
+ dependencyType: "dependency",
776
+ reason: "Officially documented development tools for TanStack Query."
777
+ };
778
+ function providerFiles(context, devtools) {
779
+ const ext = sourceExtension(context, false);
780
+ const jsxExt = sourceExtension(context, true);
781
+ return [{
782
+ path: `src/lib/query-client.${ext}`,
783
+ contents: `import { QueryClient } from "@tanstack/react-query";
784
+
785
+ export const queryClient = new QueryClient();
786
+ `,
787
+ description: "Shared QueryClient instance."
788
+ }, {
789
+ path: `src/lib/query-provider.${jsxExt}`,
790
+ contents: `import { QueryClientProvider } from "@tanstack/react-query";
791
+ ${devtools ? `import { ReactQueryDevtools } from "@tanstack/react-query-devtools";\n` : ""}${context.language === "typescript" ? `import type { ReactNode } from "react";\n` : ""}import { queryClient } from "./query-client";
792
+
793
+ ${context.language === "typescript" ? "export function AppQueryProvider({ children }: { children: ReactNode }) {" : "export function AppQueryProvider({ children }) {"}
794
+ return (
795
+ <QueryClientProvider client={queryClient}>
796
+ {children}
797
+ ${devtools ? " <ReactQueryDevtools initialIsOpen={false} />\n" : ""} </QueryClientProvider>
798
+ );
799
+ }
800
+ `,
801
+ description: "Provider component ready to wrap your application entry."
802
+ }];
803
+ }
804
+ const tanstackQueryRecipe = {
805
+ id: "tanstack-query",
806
+ recipeVersion: 1,
807
+ name: "TanStack Query",
808
+ category: "data-fetching",
809
+ status: "stable",
810
+ installationSummary: "Official documented package installation",
811
+ officialSource: {
812
+ documentationUrl: "https://tanstack.com/query/latest/docs/framework/react/installation",
813
+ lastVerifiedAt: "2026-07-19"
814
+ },
815
+ requires: [],
816
+ runsAfter: [],
817
+ conflictsWith: [],
818
+ supportedProjects: [{ frameworks: ["react", "next"] }],
819
+ installation: {
820
+ type: "official-package-install",
821
+ dependencies: [mainPackage],
822
+ devDependencies: []
823
+ },
824
+ detectInstalled(context) {
825
+ return detectByPackages(context, ["@tanstack/react-query"]);
826
+ },
827
+ describeOptions(options) {
828
+ const parts = [];
829
+ if (options.devtools === true) parts.push("Devtools");
830
+ if (options.configureProvider === true) parts.push("provider files");
831
+ return parts.length > 0 ? `with ${parts.join(" + ")}` : void 0;
832
+ },
833
+ createPlan(context, options) {
834
+ const devtools = options.devtools === true;
835
+ const configureProvider = options.configureProvider === true;
836
+ const packages = devtools ? [mainPackage, devtoolsPackage] : [mainPackage];
837
+ const notes = [];
838
+ if (configureProvider) notes.push("StackPack does not rewrite your application entry file automatically.", "Wrap your app with <AppQueryProvider> from src/lib/query-provider to finish the setup.");
839
+ else notes.push("Packages installed only. Create a QueryClient and provider when ready.");
840
+ return {
841
+ packages,
842
+ filesToCreate: configureProvider ? providerFiles(context, devtools) : [],
843
+ scripts: [],
844
+ postInstallNotes: notes
845
+ };
846
+ }
847
+ };
848
+ //#endregion
849
+ //#region src/integrations/react-hook-form-zod/recipe.ts
850
+ const packages = [
851
+ {
852
+ name: "react-hook-form",
853
+ version: "latest",
854
+ dependencyType: "dependency",
855
+ reason: "Form state and validation lifecycle."
856
+ },
857
+ {
858
+ name: "zod",
859
+ version: "latest",
860
+ dependencyType: "dependency",
861
+ reason: "Schema validation."
862
+ },
863
+ {
864
+ name: "@hookform/resolvers",
865
+ version: "latest",
866
+ dependencyType: "dependency",
867
+ reason: "Connects React Hook Form to Zod."
868
+ }
869
+ ];
870
+ const reactHookFormZodRecipe = {
871
+ id: "react-hook-form-zod",
872
+ recipeVersion: 1,
873
+ name: "React Hook Form with Zod",
874
+ category: "forms-validation",
875
+ status: "stable",
876
+ installationSummary: "Official documented package installation",
877
+ officialSource: {
878
+ documentationUrl: "https://react-hook-form.com/get-started#SchemaValidation",
879
+ lastVerifiedAt: "2026-07-19"
880
+ },
881
+ requires: [],
882
+ runsAfter: [],
883
+ conflictsWith: [],
884
+ supportedProjects: [{ frameworks: ["react", "next"] }],
885
+ installation: {
886
+ type: "official-package-install",
887
+ dependencies: packages,
888
+ devDependencies: []
889
+ },
890
+ detectInstalled(context) {
891
+ const rhf = detectByPackages(context, ["react-hook-form"]);
892
+ if (rhf.status !== "installed") return rhf;
893
+ if (detectByPackages(context, ["@hookform/resolvers"]).status !== "installed") return {
894
+ status: "partially-configured",
895
+ installedVersion: rhf.installedVersion,
896
+ details: "react-hook-form is installed but @hookform/resolvers is missing."
897
+ };
898
+ return rhf;
899
+ },
900
+ createPlan() {
901
+ return {
902
+ packages,
903
+ filesToCreate: [],
904
+ scripts: [],
905
+ postInstallNotes: ["Use zodResolver from @hookform/resolvers/zod to connect your Zod schemas to useForm."]
906
+ };
907
+ }
908
+ };
909
+ //#endregion
910
+ //#region src/integrations/vitest-react/recipe.ts
911
+ const vitestPackage = {
912
+ name: "vitest",
913
+ version: "latest",
914
+ dependencyType: "devDependency",
915
+ reason: "Official Vitest test runner."
916
+ };
917
+ const componentTestingPackages = [
918
+ {
919
+ name: "jsdom",
920
+ version: "latest",
921
+ dependencyType: "devDependency",
922
+ reason: "Browser-like DOM environment for component tests."
923
+ },
924
+ {
925
+ name: "@testing-library/react",
926
+ version: "latest",
927
+ dependencyType: "devDependency",
928
+ reason: "Official React Testing Library."
929
+ },
930
+ {
931
+ name: "@testing-library/jest-dom",
932
+ version: "latest",
933
+ dependencyType: "devDependency",
934
+ reason: "DOM matchers for assertions."
935
+ },
936
+ {
937
+ name: "@testing-library/user-event",
938
+ version: "latest",
939
+ dependencyType: "devDependency",
940
+ reason: "User interaction utilities."
941
+ }
942
+ ];
943
+ function vitestConfigFile(context, components) {
944
+ const ext = context.language === "typescript" ? "ts" : "js";
945
+ const hasViteConfig = context.detectedFiles.some((file) => file.startsWith("vite.config."));
946
+ const testBlock = components ? ` test: {
947
+ environment: "jsdom",
948
+ globals: true,
949
+ setupFiles: ["./src/test/setup.${ext}"],
950
+ },` : ` test: {
951
+ environment: "node",
952
+ },`;
953
+ const contents = hasViteConfig ? `import { defineConfig, mergeConfig } from "vitest/config";
954
+ import viteConfig from "./vite.config";
955
+
956
+ export default mergeConfig(
957
+ viteConfig,
958
+ defineConfig({
959
+ ${testBlock}
960
+ }),
961
+ );
962
+ ` : `import { defineConfig } from "vitest/config";
963
+
964
+ export default defineConfig({
965
+ ${testBlock}
966
+ });
967
+ `;
968
+ return {
969
+ path: `vitest.config.${ext}`,
970
+ contents,
971
+ description: hasViteConfig ? "Vitest configuration merged with your existing Vite configuration (official pattern)." : "Standalone Vitest configuration."
972
+ };
973
+ }
974
+ function setupFile(context) {
975
+ return {
976
+ path: `src/test/setup.${sourceExtension(context, false)}`,
977
+ contents: `import "@testing-library/jest-dom/vitest";
978
+ `,
979
+ description: "Registers Testing Library DOM matchers with Vitest."
980
+ };
981
+ }
982
+ const vitestReactRecipe = {
983
+ id: "vitest-react",
984
+ recipeVersion: 1,
985
+ name: "Vitest with React Testing Library",
986
+ category: "testing",
987
+ status: "stable",
988
+ installationSummary: "Official documented package installation and configuration",
989
+ officialSource: {
990
+ documentationUrl: "https://vitest.dev/guide/",
991
+ lastVerifiedAt: "2026-07-19"
992
+ },
993
+ requires: [],
994
+ runsAfter: [],
995
+ conflictsWith: [],
996
+ supportedProjects: [{
997
+ frameworks: ["react"],
998
+ buildTools: ["vite"]
999
+ }],
1000
+ installation: {
1001
+ type: "official-package-install",
1002
+ dependencies: [],
1003
+ devDependencies: [vitestPackage, ...componentTestingPackages]
1004
+ },
1005
+ detectInstalled(context) {
1006
+ const vitest = detectByPackages(context, ["vitest"]);
1007
+ if (vitest.status !== "installed") return vitest;
1008
+ if (!context.detectedFiles.some((file) => file.startsWith("vitest.config.") || file.startsWith("vite.config."))) return {
1009
+ status: "partially-configured",
1010
+ installedVersion: vitest.installedVersion,
1011
+ details: "vitest is installed but no configuration file was found."
1012
+ };
1013
+ return vitest;
1014
+ },
1015
+ describeOptions(options) {
1016
+ return options.testTarget === "utils" ? "utility tests only" : "React component testing";
1017
+ },
1018
+ createPlan(context, options) {
1019
+ const components = options.testTarget !== "utils";
1020
+ const packages = components ? [vitestPackage, ...componentTestingPackages] : [vitestPackage];
1021
+ const filesToCreate = [vitestConfigFile(context, components)];
1022
+ if (components) filesToCreate.push(setupFile(context));
1023
+ return {
1024
+ packages,
1025
+ filesToCreate,
1026
+ scripts: [{
1027
+ name: "test",
1028
+ command: "vitest"
1029
+ }],
1030
+ postInstallNotes: components ? ["Run your first component test with: npm run test"] : ["Vitest is configured for utility tests only."]
1031
+ };
1032
+ }
1033
+ };
1034
+ //#endregion
1035
+ //#region src/integrations/playwright/recipe.ts
1036
+ const initializer$1 = {
1037
+ description: "The official Playwright initializer will run interactively. It may ask questions, install browsers, and create configuration, example tests, and CI files.",
1038
+ interactive: true,
1039
+ buildCommand(packageManager, cwd) {
1040
+ switch (packageManager) {
1041
+ case "npm": return {
1042
+ command: "npm",
1043
+ args: ["init", "playwright@latest"],
1044
+ cwd,
1045
+ interactive: true
1046
+ };
1047
+ case "pnpm": return {
1048
+ command: "pnpm",
1049
+ args: ["create", "playwright"],
1050
+ cwd,
1051
+ interactive: true
1052
+ };
1053
+ case "yarn": return {
1054
+ command: "yarn",
1055
+ args: ["create", "playwright"],
1056
+ cwd,
1057
+ interactive: true
1058
+ };
1059
+ case "bun": return {
1060
+ command: "bun",
1061
+ args: ["create", "playwright"],
1062
+ cwd,
1063
+ interactive: true
1064
+ };
1065
+ }
1066
+ }
1067
+ };
1068
+ const playwrightRecipe = {
1069
+ id: "playwright",
1070
+ recipeVersion: 1,
1071
+ name: "Playwright",
1072
+ category: "testing",
1073
+ status: "stable",
1074
+ installationSummary: "Official initializer CLI",
1075
+ officialSource: {
1076
+ documentationUrl: "https://playwright.dev/docs/intro",
1077
+ lastVerifiedAt: "2026-07-19"
1078
+ },
1079
+ requires: [],
1080
+ runsAfter: ["vitest-react"],
1081
+ conflictsWith: [],
1082
+ supportedProjects: [{ frameworks: ["react", "next"] }],
1083
+ installation: {
1084
+ type: "official-initializer",
1085
+ initializer: initializer$1
1086
+ },
1087
+ detectInstalled(context) {
1088
+ const byPackage = detectByPackages(context, ["@playwright/test"]);
1089
+ if (byPackage.status === "installed") return byPackage;
1090
+ if (context.detectedFiles.some((file) => file.startsWith("playwright.config."))) return {
1091
+ status: "partially-configured",
1092
+ details: "Config exists but @playwright/test is not installed."
1093
+ };
1094
+ return { status: "not-installed" };
1095
+ },
1096
+ createPlan() {
1097
+ return {
1098
+ packages: [],
1099
+ filesToCreate: [],
1100
+ scripts: [],
1101
+ postInstallNotes: ["Playwright setup is delegated to the official initializer.", "StackPack will rescan the project after the initializer finishes."],
1102
+ initializer: initializer$1
1103
+ };
1104
+ }
1105
+ };
1106
+ //#endregion
1107
+ //#region src/integrations/install-only.ts
1108
+ /**
1109
+ * Factory for recipes that install official packages and nothing else: no
1110
+ * generated files, no scripts. The developer does their own setup following
1111
+ * the official documentation (linked per recipe).
1112
+ */
1113
+ function installOnlyRecipe(definition) {
1114
+ const dependencies = definition.dependencies;
1115
+ const devDependencies = definition.devDependencies ?? [];
1116
+ const packages = [...dependencies, ...devDependencies];
1117
+ const detectNames = dependencies.map((pkg) => pkg.name);
1118
+ return {
1119
+ id: definition.id,
1120
+ recipeVersion: 1,
1121
+ name: definition.name,
1122
+ category: definition.category,
1123
+ status: "stable",
1124
+ installationSummary: "Official documented package installation",
1125
+ officialSource: {
1126
+ documentationUrl: definition.documentationUrl,
1127
+ lastVerifiedAt: "2026-07-19"
1128
+ },
1129
+ requires: [],
1130
+ runsAfter: [],
1131
+ conflictsWith: [],
1132
+ supportedProjects: [{ frameworks: ["react", "next"] }],
1133
+ installation: {
1134
+ type: "official-package-install",
1135
+ dependencies,
1136
+ devDependencies
1137
+ },
1138
+ detectInstalled(context) {
1139
+ return detectByPackages(context, detectNames);
1140
+ },
1141
+ createPlan() {
1142
+ return {
1143
+ packages,
1144
+ filesToCreate: [],
1145
+ scripts: [],
1146
+ postInstallNotes: definition.notes ?? []
1147
+ };
1148
+ }
1149
+ };
1150
+ }
1151
+ const jotaiRecipe = installOnlyRecipe({
1152
+ id: "jotai",
1153
+ name: "Jotai",
1154
+ category: "state-management",
1155
+ documentationUrl: "https://jotai.org/docs/introduction",
1156
+ dependencies: [{
1157
+ name: "jotai",
1158
+ version: "latest",
1159
+ dependencyType: "dependency",
1160
+ reason: "Atomic state."
1161
+ }],
1162
+ notes: ["Jotai needs no configuration: create atoms anywhere and use useAtom()."]
1163
+ });
1164
+ const mobxRecipe = installOnlyRecipe({
1165
+ id: "mobx",
1166
+ name: "MobX",
1167
+ category: "state-management",
1168
+ documentationUrl: "https://mobx.js.org/README.html",
1169
+ dependencies: [{
1170
+ name: "mobx",
1171
+ version: "latest",
1172
+ dependencyType: "dependency",
1173
+ reason: "Core library."
1174
+ }, {
1175
+ name: "mobx-react-lite",
1176
+ version: "latest",
1177
+ dependencyType: "dependency",
1178
+ reason: "Official React bindings."
1179
+ }],
1180
+ notes: ["Wrap components with observer() from mobx-react-lite to react to store changes."]
1181
+ });
1182
+ const valtioRecipe = installOnlyRecipe({
1183
+ id: "valtio",
1184
+ name: "Valtio",
1185
+ category: "state-management",
1186
+ documentationUrl: "https://valtio.dev/docs/introduction/getting-started",
1187
+ dependencies: [{
1188
+ name: "valtio",
1189
+ version: "latest",
1190
+ dependencyType: "dependency",
1191
+ reason: "Proxy-based state."
1192
+ }],
1193
+ notes: ["Create state with proxy() and read it in components with useSnapshot()."]
1194
+ });
1195
+ const xstateRecipe = installOnlyRecipe({
1196
+ id: "xstate",
1197
+ name: "XState",
1198
+ category: "state-management",
1199
+ documentationUrl: "https://stately.ai/docs/xstate",
1200
+ dependencies: [{
1201
+ name: "xstate",
1202
+ version: "latest",
1203
+ dependencyType: "dependency",
1204
+ reason: "State machines and statecharts."
1205
+ }, {
1206
+ name: "@xstate/react",
1207
+ version: "latest",
1208
+ dependencyType: "dependency",
1209
+ reason: "Official React hooks."
1210
+ }],
1211
+ notes: ["Define machines with createMachine() and use them via useMachine() from @xstate/react."]
1212
+ });
1213
+ const valibotRecipe = installOnlyRecipe({
1214
+ id: "valibot",
1215
+ name: "Valibot",
1216
+ category: "forms-validation",
1217
+ documentationUrl: "https://valibot.dev/guides/introduction/",
1218
+ dependencies: [{
1219
+ name: "valibot",
1220
+ version: "latest",
1221
+ dependencyType: "dependency",
1222
+ reason: "Lightweight schema validation."
1223
+ }],
1224
+ notes: ["Valibot is modular and tree-shakeable; import only the validators you use."]
1225
+ });
1226
+ const arktypeRecipe = installOnlyRecipe({
1227
+ id: "arktype",
1228
+ name: "ArkType",
1229
+ category: "forms-validation",
1230
+ documentationUrl: "https://arktype.io/docs/intro/setup",
1231
+ dependencies: [{
1232
+ name: "arktype",
1233
+ version: "latest",
1234
+ dependencyType: "dependency",
1235
+ reason: "TypeScript-first runtime validation."
1236
+ }],
1237
+ notes: ["ArkType shines with TypeScript: definitions are inferred as precise static types."]
1238
+ });
1239
+ const axiosRecipe = installOnlyRecipe({
1240
+ id: "axios",
1241
+ name: "Axios",
1242
+ category: "data-fetching",
1243
+ documentationUrl: "https://axios-http.com/docs/intro",
1244
+ dependencies: [{
1245
+ name: "axios",
1246
+ version: "latest",
1247
+ dependencyType: "dependency",
1248
+ reason: "HTTP request client."
1249
+ }],
1250
+ notes: ["Axios is an HTTP client only; pair it with TanStack Query for caching if needed."]
1251
+ });
1252
+ const ormNote = "Dependencies only — StackPack creates no ORM files; follow the official docs.";
1253
+ const driverNote = "Remember to install your database driver too (e.g. pg, mysql2, better-sqlite3).";
1254
+ const installOnlyRecipes = [
1255
+ jotaiRecipe,
1256
+ mobxRecipe,
1257
+ valtioRecipe,
1258
+ xstateRecipe,
1259
+ valibotRecipe,
1260
+ arktypeRecipe,
1261
+ axiosRecipe,
1262
+ installOnlyRecipe({
1263
+ id: "prisma",
1264
+ name: "Prisma",
1265
+ category: "orm",
1266
+ documentationUrl: "https://www.prisma.io/docs/getting-started",
1267
+ dependencies: [{
1268
+ name: "@prisma/client",
1269
+ version: "latest",
1270
+ dependencyType: "dependency",
1271
+ reason: "Generated query client."
1272
+ }],
1273
+ devDependencies: [{
1274
+ name: "prisma",
1275
+ version: "latest",
1276
+ dependencyType: "devDependency",
1277
+ reason: "CLI and migrations."
1278
+ }],
1279
+ notes: [ormNote, "Start with \"npx prisma init\" to create schema.prisma and .env."]
1280
+ }),
1281
+ installOnlyRecipe({
1282
+ id: "drizzle-orm",
1283
+ name: "Drizzle ORM",
1284
+ category: "orm",
1285
+ documentationUrl: "https://orm.drizzle.team/docs/get-started",
1286
+ dependencies: [{
1287
+ name: "drizzle-orm",
1288
+ version: "latest",
1289
+ dependencyType: "dependency",
1290
+ reason: "TypeScript SQL ORM."
1291
+ }],
1292
+ devDependencies: [{
1293
+ name: "drizzle-kit",
1294
+ version: "latest",
1295
+ dependencyType: "devDependency",
1296
+ reason: "Migrations and studio."
1297
+ }],
1298
+ notes: [ormNote, driverNote]
1299
+ }),
1300
+ installOnlyRecipe({
1301
+ id: "typeorm",
1302
+ name: "TypeORM",
1303
+ category: "orm",
1304
+ documentationUrl: "https://typeorm.io",
1305
+ dependencies: [{
1306
+ name: "typeorm",
1307
+ version: "latest",
1308
+ dependencyType: "dependency",
1309
+ reason: "Entity-based ORM."
1310
+ }, {
1311
+ name: "reflect-metadata",
1312
+ version: "latest",
1313
+ dependencyType: "dependency",
1314
+ reason: "Required for decorators."
1315
+ }],
1316
+ notes: [
1317
+ ormNote,
1318
+ driverNote,
1319
+ "Enable \"experimentalDecorators\" and \"emitDecoratorMetadata\" in tsconfig."
1320
+ ]
1321
+ }),
1322
+ installOnlyRecipe({
1323
+ id: "sequelize",
1324
+ name: "Sequelize",
1325
+ category: "orm",
1326
+ documentationUrl: "https://sequelize.org/docs/v6/getting-started/",
1327
+ dependencies: [{
1328
+ name: "sequelize",
1329
+ version: "latest",
1330
+ dependencyType: "dependency",
1331
+ reason: "Mature traditional ORM."
1332
+ }],
1333
+ notes: [ormNote, driverNote]
1334
+ }),
1335
+ installOnlyRecipe({
1336
+ id: "mikro-orm",
1337
+ name: "MikroORM",
1338
+ category: "orm",
1339
+ documentationUrl: "https://mikro-orm.io/docs/quick-start",
1340
+ dependencies: [{
1341
+ name: "@mikro-orm/core",
1342
+ version: "latest",
1343
+ dependencyType: "dependency",
1344
+ reason: "Data Mapper ORM core."
1345
+ }],
1346
+ notes: [ormNote, "Add the matching driver package (e.g. @mikro-orm/postgresql) yourself."]
1347
+ })
1348
+ ];
1349
+ //#endregion
1350
+ //#region src/integrations/ui/recipes.ts
1351
+ /**
1352
+ * Every UI-library recipe ships a small test button component so the user
1353
+ * can render it immediately and confirm the installed dependency works.
1354
+ */
1355
+ function testButtonFile(context, fileName, contents) {
1356
+ return {
1357
+ path: `src/components/${fileName}.${context.language === "typescript" ? "tsx" : "jsx"}`,
1358
+ contents,
1359
+ description: "Test button to verify the UI library works."
1360
+ };
1361
+ }
1362
+ const uiNote = (component, path) => `Render <${component} /> (from ${path}) in any page to test that the library works.`;
1363
+ const radixRecipe = {
1364
+ id: "radix-ui",
1365
+ recipeVersion: 1,
1366
+ name: "Radix UI",
1367
+ category: "ui",
1368
+ status: "stable",
1369
+ installationSummary: "Official package installation + generated test button",
1370
+ officialSource: {
1371
+ documentationUrl: "https://www.radix-ui.com/primitives/docs/overview/getting-started",
1372
+ lastVerifiedAt: "2026-07-19"
1373
+ },
1374
+ requires: [],
1375
+ runsAfter: [],
1376
+ conflictsWith: [],
1377
+ supportedProjects: [{ frameworks: ["react", "next"] }],
1378
+ installation: {
1379
+ type: "official-package-install",
1380
+ dependencies: [{
1381
+ name: "radix-ui",
1382
+ version: "latest",
1383
+ dependencyType: "dependency",
1384
+ reason: "Unstyled accessible primitives (unified package)."
1385
+ }],
1386
+ devDependencies: []
1387
+ },
1388
+ detectInstalled(context) {
1389
+ return detectByPackages(context, ["radix-ui"]);
1390
+ },
1391
+ createPlan(context) {
1392
+ return {
1393
+ packages: [{
1394
+ name: "radix-ui",
1395
+ version: "latest",
1396
+ dependencyType: "dependency",
1397
+ reason: "Unstyled accessible primitives (unified package)."
1398
+ }],
1399
+ filesToCreate: [testButtonFile(context, "radix-test-button", `${context.framework === "next" ? `"use client";\n\n` : ""}import { Toggle } from "radix-ui";
1400
+
1401
+ export function RadixTestButton() {
1402
+ return (
1403
+ <Toggle.Root
1404
+ onPressedChange={(pressed) => console.log("Radix Toggle pressed:", pressed)}
1405
+ >
1406
+ Radix UI test button
1407
+ </Toggle.Root>
1408
+ );
1409
+ }
1410
+ `)],
1411
+ scripts: [],
1412
+ postInstallNotes: [uiNote("RadixTestButton", "src/components/radix-test-button"), "Radix primitives are unstyled; add your own CSS or a styling layer."]
1413
+ };
1414
+ }
1415
+ };
1416
+ const baseUiRecipe = {
1417
+ id: "base-ui",
1418
+ recipeVersion: 1,
1419
+ name: "Base UI",
1420
+ category: "ui",
1421
+ status: "stable",
1422
+ installationSummary: "Official package installation + generated test button",
1423
+ officialSource: {
1424
+ documentationUrl: "https://base-ui.com/react/overview/quick-start",
1425
+ lastVerifiedAt: "2026-07-19"
1426
+ },
1427
+ requires: [],
1428
+ runsAfter: [],
1429
+ conflictsWith: [],
1430
+ supportedProjects: [{ frameworks: ["react", "next"] }],
1431
+ installation: {
1432
+ type: "official-package-install",
1433
+ dependencies: [{
1434
+ name: "@base-ui-components/react",
1435
+ version: "latest",
1436
+ dependencyType: "dependency",
1437
+ reason: "Unstyled accessible React components."
1438
+ }],
1439
+ devDependencies: []
1440
+ },
1441
+ detectInstalled(context) {
1442
+ return detectByPackages(context, ["@base-ui-components/react"]);
1443
+ },
1444
+ createPlan(context) {
1445
+ return {
1446
+ packages: [{
1447
+ name: "@base-ui-components/react",
1448
+ version: "latest",
1449
+ dependencyType: "dependency",
1450
+ reason: "Unstyled accessible React components."
1451
+ }],
1452
+ filesToCreate: [testButtonFile(context, "base-ui-test-button", `${context.framework === "next" ? `"use client";\n\n` : ""}import { Toggle } from "@base-ui-components/react/toggle";
1453
+
1454
+ export function BaseUiTestButton() {
1455
+ return (
1456
+ <Toggle onPressedChange={(pressed) => console.log("Base UI Toggle pressed:", pressed)}>
1457
+ Base UI test button
1458
+ </Toggle>
1459
+ );
1460
+ }
1461
+ `)],
1462
+ scripts: [],
1463
+ postInstallNotes: [uiNote("BaseUiTestButton", "src/components/base-ui-test-button"), "Base UI components are unstyled; style them with your own CSS."]
1464
+ };
1465
+ }
1466
+ };
1467
+ const reactAriaRecipe = {
1468
+ id: "react-aria",
1469
+ recipeVersion: 1,
1470
+ name: "React Aria",
1471
+ category: "ui",
1472
+ status: "stable",
1473
+ installationSummary: "Official package installation + generated test button",
1474
+ officialSource: {
1475
+ documentationUrl: "https://react-spectrum.adobe.com/react-aria/getting-started.html",
1476
+ lastVerifiedAt: "2026-07-19"
1477
+ },
1478
+ requires: [],
1479
+ runsAfter: [],
1480
+ conflictsWith: [],
1481
+ supportedProjects: [{ frameworks: ["react", "next"] }],
1482
+ installation: {
1483
+ type: "official-package-install",
1484
+ dependencies: [{
1485
+ name: "react-aria-components",
1486
+ version: "latest",
1487
+ dependencyType: "dependency",
1488
+ reason: "Adobe's accessible component library."
1489
+ }],
1490
+ devDependencies: []
1491
+ },
1492
+ detectInstalled(context) {
1493
+ return detectByPackages(context, ["react-aria-components"]);
1494
+ },
1495
+ createPlan(context) {
1496
+ return {
1497
+ packages: [{
1498
+ name: "react-aria-components",
1499
+ version: "latest",
1500
+ dependencyType: "dependency",
1501
+ reason: "Adobe's accessible component library."
1502
+ }],
1503
+ filesToCreate: [testButtonFile(context, "aria-test-button", `${context.framework === "next" ? `"use client";\n\n` : ""}import { Button } from "react-aria-components";
1504
+
1505
+ export function AriaTestButton() {
1506
+ return (
1507
+ <Button onPress={() => console.log("React Aria button pressed")}>
1508
+ React Aria test button
1509
+ </Button>
1510
+ );
1511
+ }
1512
+ `)],
1513
+ scripts: [],
1514
+ postInstallNotes: [uiNote("AriaTestButton", "src/components/aria-test-button")]
1515
+ };
1516
+ }
1517
+ };
1518
+ //#endregion
1519
+ //#region src/integrations/shadcn/recipe.ts
1520
+ /**
1521
+ * shadcn/ui is driven by its official CLI, not a package dependency. Adding
1522
+ * the Button component on a fresh project makes the CLI run its own init
1523
+ * questions first, then generates src/components/ui/button — so the user
1524
+ * gets a real component to test immediately.
1525
+ *
1526
+ * The CLI validates two prerequisites and aborts without them: Tailwind CSS
1527
+ * and a "@/*" import alias. Next.js projects created with Tailwind already
1528
+ * satisfy both. Vite projects satisfy neither, so this recipe prepares them
1529
+ * first, following the official shadcn Vite guide
1530
+ * (https://ui.shadcn.com/docs/installation/vite): install tailwindcss +
1531
+ * @tailwindcss/vite, import Tailwind in index.css, add the plugin and alias
1532
+ * to vite.config, and register the alias in tsconfig/jsconfig.
1533
+ */
1534
+ const initializer = {
1535
+ description: "The official shadcn CLI runs interactively: it initializes components.json (asking its own questions) and adds the Button component so you can test it right away.",
1536
+ interactive: true,
1537
+ buildCommand(packageManager, cwd) {
1538
+ switch (packageManager) {
1539
+ case "npm": return {
1540
+ command: "npx",
1541
+ args: [
1542
+ "shadcn@latest",
1543
+ "add",
1544
+ "button"
1545
+ ],
1546
+ cwd,
1547
+ interactive: true
1548
+ };
1549
+ case "pnpm": return {
1550
+ command: "pnpm",
1551
+ args: [
1552
+ "dlx",
1553
+ "shadcn@latest",
1554
+ "add",
1555
+ "button"
1556
+ ],
1557
+ cwd,
1558
+ interactive: true
1559
+ };
1560
+ case "yarn": return {
1561
+ command: "yarn",
1562
+ args: [
1563
+ "dlx",
1564
+ "shadcn@latest",
1565
+ "add",
1566
+ "button"
1567
+ ],
1568
+ cwd,
1569
+ interactive: true
1570
+ };
1571
+ case "bun": return {
1572
+ command: "bunx",
1573
+ args: [
1574
+ "--bun",
1575
+ "shadcn@latest",
1576
+ "add",
1577
+ "button"
1578
+ ],
1579
+ cwd,
1580
+ interactive: true
1581
+ };
1582
+ }
1583
+ }
1584
+ };
1585
+ const aliasPaths = { "@/*": ["./src/*"] };
1586
+ /**
1587
+ * Rebuilds vite.config with the Tailwind plugin and @/* alias while keeping
1588
+ * the plugin variant the official template used (plain React, React
1589
+ * Compiler via @rolldown/plugin-babel, or SWC).
1590
+ */
1591
+ function viteConfigContents(existingConfig) {
1592
+ const usesSwc = existingConfig.includes("@vitejs/plugin-react-swc");
1593
+ const usesReactCompiler = existingConfig.includes("reactCompilerPreset") || existingConfig.includes("@rolldown/plugin-babel");
1594
+ let reactImports = `import react from "@vitejs/plugin-react";`;
1595
+ let plugins = "react(), tailwindcss()";
1596
+ if (usesSwc) reactImports = `import react from "@vitejs/plugin-react-swc";`;
1597
+ else if (usesReactCompiler) {
1598
+ reactImports = `import react, { reactCompilerPreset } from "@vitejs/plugin-react";
1599
+ import babel from "@rolldown/plugin-babel";`;
1600
+ plugins = "react(), babel({ presets: [reactCompilerPreset()] }), tailwindcss()";
1601
+ }
1602
+ return `import path from "path";
1603
+ import tailwindcss from "@tailwindcss/vite";
1604
+ ${reactImports}
1605
+ import { defineConfig } from "vite";
1606
+
1607
+ // https://vite.dev/config/
1608
+ export default defineConfig({
1609
+ plugins: [${plugins}],
1610
+ resolve: {
1611
+ alias: {
1612
+ "@": path.resolve(__dirname, "./src"),
1613
+ },
1614
+ },
1615
+ });
1616
+ `;
1617
+ }
1618
+ function vitePreparation(context) {
1619
+ const isTypescript = context.language === "typescript";
1620
+ const packages = [{
1621
+ name: "tailwindcss",
1622
+ version: "latest",
1623
+ dependencyType: "dependency",
1624
+ reason: "Required by shadcn/ui."
1625
+ }, {
1626
+ name: "@tailwindcss/vite",
1627
+ version: "latest",
1628
+ dependencyType: "dependency",
1629
+ reason: "Official Tailwind CSS plugin for Vite."
1630
+ }];
1631
+ if (isTypescript && !("@types/node" in context.installedPackages)) packages.push({
1632
+ name: "@types/node",
1633
+ version: "latest",
1634
+ dependencyType: "devDependency",
1635
+ reason: "Types for path.resolve in vite.config (per the shadcn guide)."
1636
+ });
1637
+ const configFileName = isTypescript ? "vite.config.ts" : "vite.config.js";
1638
+ let existingConfig = "";
1639
+ try {
1640
+ existingConfig = fs$1.readFileSync(path.join(context.rootDirectory, configFileName), "utf8");
1641
+ } catch {}
1642
+ const filesToCreate = [{
1643
+ path: "src/index.css",
1644
+ contents: `@import "tailwindcss";\n`,
1645
+ description: "Tailwind CSS entry (replaces the Vite template styles, per the shadcn guide).",
1646
+ requiredReason: "shadcn/ui fails without the Tailwind CSS import in this file"
1647
+ }, {
1648
+ path: configFileName,
1649
+ contents: viteConfigContents(existingConfig),
1650
+ description: "Vite config with the Tailwind plugin and the @/* alias, per the shadcn guide.",
1651
+ requiredReason: "shadcn/ui needs the Tailwind plugin and @/* alias configured here"
1652
+ }];
1653
+ const jsonEdits = [];
1654
+ if (isTypescript) {
1655
+ jsonEdits.push({
1656
+ path: "tsconfig.json",
1657
+ description: "Registers the @/* import alias for the shadcn CLI.",
1658
+ edits: [{
1659
+ jsonPath: ["compilerOptions", "paths"],
1660
+ value: aliasPaths
1661
+ }]
1662
+ });
1663
+ if (fs$1.existsSync(path.join(context.rootDirectory, "tsconfig.app.json"))) jsonEdits.push({
1664
+ path: "tsconfig.app.json",
1665
+ description: "Registers the @/* import alias for the app build.",
1666
+ edits: [{
1667
+ jsonPath: ["compilerOptions", "paths"],
1668
+ value: aliasPaths
1669
+ }]
1670
+ });
1671
+ } else filesToCreate.push({
1672
+ path: "jsconfig.json",
1673
+ contents: `${JSON.stringify({ compilerOptions: { paths: aliasPaths } }, null, 2)}\n`,
1674
+ description: "Registers the @/* import alias for the shadcn CLI."
1675
+ });
1676
+ return {
1677
+ packages,
1678
+ filesToCreate,
1679
+ jsonEdits
1680
+ };
1681
+ }
1682
+ const shadcnRecipe = {
1683
+ id: "shadcn",
1684
+ recipeVersion: 1,
1685
+ name: "shadcn/ui",
1686
+ category: "ui",
1687
+ status: "stable",
1688
+ installationSummary: "Official shadcn CLI (Vite projects get Tailwind + alias set up first)",
1689
+ officialSource: {
1690
+ documentationUrl: "https://ui.shadcn.com/docs/installation",
1691
+ lastVerifiedAt: "2026-07-19"
1692
+ },
1693
+ requires: [],
1694
+ runsAfter: [],
1695
+ conflictsWith: [],
1696
+ supportedProjects: [{ frameworks: ["react", "next"] }],
1697
+ installation: {
1698
+ type: "official-initializer",
1699
+ initializer
1700
+ },
1701
+ detectInstalled(context) {
1702
+ if (context.detectedFiles.includes("components.json")) return {
1703
+ status: "installed",
1704
+ details: "components.json exists."
1705
+ };
1706
+ return { status: "not-installed" };
1707
+ },
1708
+ createPlan(context) {
1709
+ const needsVitePrep = context.buildTool === "vite";
1710
+ const prep = needsVitePrep ? vitePreparation(context) : {
1711
+ packages: [],
1712
+ filesToCreate: [],
1713
+ jsonEdits: []
1714
+ };
1715
+ return {
1716
+ packages: prep.packages,
1717
+ filesToCreate: prep.filesToCreate,
1718
+ jsonEdits: prep.jsonEdits,
1719
+ scripts: [],
1720
+ postInstallNotes: [
1721
+ ...needsVitePrep ? ["Tailwind CSS and the @/* alias were configured following the official shadcn Vite guide."] : [],
1722
+ "Render <Button> from @/components/ui/button in any page to test shadcn/ui.",
1723
+ "Add more components anytime with: npx shadcn@latest add <component>."
1724
+ ],
1725
+ initializer
1726
+ };
1727
+ }
1728
+ };
1729
+ //#endregion
1730
+ //#region src/integrations/registry.ts
1731
+ const allRecipes = [
1732
+ reactRouterRecipe,
1733
+ zustandRecipe,
1734
+ reduxToolkitRecipe,
1735
+ tanstackQueryRecipe,
1736
+ reactHookFormZodRecipe,
1737
+ ...installOnlyRecipes,
1738
+ shadcnRecipe,
1739
+ radixRecipe,
1740
+ baseUiRecipe,
1741
+ reactAriaRecipe,
1742
+ vitestReactRecipe,
1743
+ playwrightRecipe
1744
+ ];
1745
+ const seen = /* @__PURE__ */ new Set();
1746
+ for (const recipe of allRecipes) {
1747
+ const result = recipeMetadataSchema.safeParse(recipe);
1748
+ if (!result.success) throw new StackPackError(`Integration recipe "${recipe.id}" has invalid metadata.`, { hints: result.error.issues.slice(0, 3).map((i) => `${i.path.join(".")}: ${i.message}`) });
1749
+ if (seen.has(recipe.id)) throw new StackPackError(`Duplicate integration recipe id "${recipe.id}".`);
1750
+ seen.add(recipe.id);
1751
+ }
1752
+ function getRecipe(id) {
1753
+ return allRecipes.find((recipe) => recipe.id === id);
1754
+ }
1755
+ //#endregion
1756
+ //#region src/dashboard/state.ts
1757
+ function createEmptySelection() {
1758
+ return {
1759
+ testing: [],
1760
+ customPackages: [],
1761
+ versionOverrides: {}
1762
+ };
1763
+ }
1764
+ function selectedIntegrations(selection) {
1765
+ return [
1766
+ selection.routing,
1767
+ selection.stateManagement,
1768
+ selection.dataFetching,
1769
+ selection.formsAndValidation,
1770
+ selection.ui,
1771
+ selection.orm,
1772
+ ...selection.testing
1773
+ ].filter((entry) => entry !== void 0);
1774
+ }
1775
+ function selectedIntegrationCount(selection) {
1776
+ return selectedIntegrations(selection).length;
1777
+ }
1778
+ /** Recipe + options pairs for every selected integration, in selection order. */
1779
+ function selectedRecipes(selection) {
1780
+ return selectedIntegrations(selection).flatMap((entry) => {
1781
+ const recipe = getRecipe(entry.id);
1782
+ return recipe ? [{
1783
+ recipe,
1784
+ options: entry.options
1785
+ }] : [];
1786
+ });
1787
+ }
1788
+ /** Package names visible in the version editor for the current selection. */
1789
+ function editablePackageNames(selection) {
1790
+ const names = /* @__PURE__ */ new Set();
1791
+ for (const { recipe, options } of selectedRecipes(selection)) for (const pkg of recipe.createPlan(fakeContextForPackageListing(), options).packages) names.add(pkg.name);
1792
+ for (const custom of selection.customPackages) names.add(custom.name);
1793
+ return [...names].sort();
1794
+ }
1795
+ /**
1796
+ * The version editor only needs package names, which do not depend on the
1797
+ * project; a minimal context keeps createPlan callable without a real project.
1798
+ */
1799
+ function fakeContextForPackageListing() {
1800
+ return {
1801
+ rootDirectory: ".",
1802
+ framework: "react",
1803
+ buildTool: "vite",
1804
+ language: "typescript",
1805
+ packageManager: "npm",
1806
+ packageManagerCandidates: [],
1807
+ detection: "detected",
1808
+ packageJson: {},
1809
+ detectedFiles: [],
1810
+ installedPackages: {}
1811
+ };
1812
+ }
1813
+ //#endregion
1814
+ //#region src/integrations/types.ts
1815
+ function isProjectSupported(recipe, context) {
1816
+ if (recipe.supportedProjects.length === 0) return true;
1817
+ return recipe.supportedProjects.some((req) => {
1818
+ const frameworkOk = !req.frameworks || req.frameworks.includes(context.framework);
1819
+ const buildToolOk = !req.buildTools || req.buildTools.includes(context.buildTool);
1820
+ return frameworkOk && buildToolOk;
1821
+ });
1822
+ }
1823
+ //#endregion
1824
+ //#region src/engine/filter-integrations.ts
1825
+ /**
1826
+ * Evaluates every recipe against the detected project. Incompatible recipes
1827
+ * keep an explicit reason so a future info view can explain why they are
1828
+ * hidden.
1829
+ */
1830
+ function filterIntegrations(context, recipes) {
1831
+ return recipes.map((recipe) => {
1832
+ if (recipe.id === "react-router" && context.framework === "next") return {
1833
+ recipe,
1834
+ compatibility: "incompatible",
1835
+ reason: "React Router is unavailable because this project uses Next.js routing.",
1836
+ detection: { status: "not-installed" }
1837
+ };
1838
+ if (recipe.id === "shadcn" && context.framework === "next" && !("tailwindcss" in context.installedPackages)) return {
1839
+ recipe,
1840
+ compatibility: "incompatible",
1841
+ reason: "shadcn/ui needs Tailwind CSS. Create the Next.js project with Tailwind (the recommended defaults include it) or add Tailwind first.",
1842
+ detection: { status: "not-installed" }
1843
+ };
1844
+ if (!isProjectSupported(recipe, context)) return {
1845
+ recipe,
1846
+ compatibility: "incompatible",
1847
+ reason: `${recipe.name} does not support this project type.`,
1848
+ detection: { status: "not-installed" }
1849
+ };
1850
+ const detection = recipe.detectInstalled(context);
1851
+ if (detection.status === "installed" || detection.status === "fully-configured") return {
1852
+ recipe,
1853
+ compatibility: "already-installed",
1854
+ reason: `${recipe.name} is already installed${detection.installedVersion ? ` at version ${detection.installedVersion}` : ""}.`,
1855
+ detection
1856
+ };
1857
+ if (detection.status === "partially-configured") return {
1858
+ recipe,
1859
+ compatibility: "partially-configured",
1860
+ reason: detection.details,
1861
+ detection
1862
+ };
1863
+ return {
1864
+ recipe,
1865
+ compatibility: "compatible",
1866
+ detection
1867
+ };
1868
+ });
1869
+ }
1870
+ //#endregion
1871
+ //#region src/dashboard/category-shared.ts
1872
+ function describeInstallation(recipe) {
1873
+ const lines = [`Installation method\n ${recipe.installationSummary}`];
1874
+ if (recipe.installation.type === "official-package-install") {
1875
+ const all = [...recipe.installation.dependencies, ...recipe.installation.devDependencies];
1876
+ if (all.length > 0) lines.push(`Packages\n${all.map((pkg) => ` ${pkg.name}@${pc.green(pkg.version)}${pkg.reason ? ` — ${pkg.reason}` : ""}`).join("\n")}`);
1877
+ } else lines.push(recipe.installation.initializer.description);
1878
+ return lines.join("\n\n");
1879
+ }
1880
+ /**
1881
+ * Shared single-select category screen: pick one integration, None to remove,
1882
+ * or return without changes. Selecting an already-installed integration offers
1883
+ * keep/complete choices instead of pretending it is new.
1884
+ */
1885
+ async function runSingleSelectCategory(params) {
1886
+ const visible = params.availabilities.filter((a) => a.compatibility !== "incompatible");
1887
+ if (visible.length === 0) {
1888
+ p.log.warn("No compatible integrations are available in this category.");
1889
+ return { kind: "unchanged" };
1890
+ }
1891
+ const choice = orBack(await p.select({
1892
+ message: params.prompt,
1893
+ initialValue: params.current?.id ?? visible[0]?.recipe.id,
1894
+ options: [
1895
+ ...visible.map((availability) => {
1896
+ const isCurrent = params.current?.id === availability.recipe.id;
1897
+ const hint = availability.compatibility === "already-installed" ? `already installed${availability.detection.installedVersion ? ` at ${availability.detection.installedVersion}` : ""}` : availability.compatibility === "partially-configured" ? "installed but not fully configured" : availability.recipe.installationSummary;
1898
+ return {
1899
+ value: availability.recipe.id,
1900
+ label: isCurrent ? `${availability.recipe.name} ${pc.green("✓")}` : availability.recipe.name,
1901
+ hint: isCurrent ? pc.green("currently selected") : hint
1902
+ };
1903
+ }),
1904
+ {
1905
+ value: "__none__",
1906
+ label: "None",
1907
+ hint: "Remove the current selection"
1908
+ },
1909
+ {
1910
+ value: "__return__",
1911
+ label: "Return without changes"
1912
+ }
1913
+ ]
1914
+ }));
1915
+ if (choice === null || choice === "__return__") return { kind: "unchanged" };
1916
+ if (choice === "__none__") return { kind: "removed" };
1917
+ const availability = visible.find((a) => a.recipe.id === choice);
1918
+ if (!availability) return { kind: "unchanged" };
1919
+ const recipe = availability.recipe;
1920
+ if (availability.compatibility === "already-installed") {
1921
+ const action = orBack(await p.select({
1922
+ message: `${recipe.name} is already installed. What would you like to do?`,
1923
+ options: [
1924
+ {
1925
+ value: "keep",
1926
+ label: "Keep current setup",
1927
+ hint: "No changes planned"
1928
+ },
1929
+ {
1930
+ value: "reinstall",
1931
+ label: "Update or complete configuration",
1932
+ hint: "Plan it again with official packages and files"
1933
+ },
1934
+ {
1935
+ value: "remove",
1936
+ label: "Remove from current selection"
1937
+ }
1938
+ ]
1939
+ }));
1940
+ if (action === null || action === "keep") return { kind: "unchanged" };
1941
+ if (action === "remove") return { kind: "removed" };
1942
+ }
1943
+ p.note(describeInstallation(recipe), recipe.name);
1944
+ const options = params.collectOptions ? await params.collectOptions(recipe, params.current?.id === recipe.id ? params.current.options : {}) : {};
1945
+ if (options === null) return { kind: "unchanged" };
1946
+ p.log.success(`${recipe.name} selected`);
1947
+ return {
1948
+ kind: "selected",
1949
+ selection: {
1950
+ id: recipe.id,
1951
+ options
1952
+ }
1953
+ };
1954
+ }
1955
+ //#endregion
1956
+ //#region src/dashboard/categories.ts
1957
+ function applyResult(result, set) {
1958
+ if (result.kind === "selected") set(result.selection);
1959
+ if (result.kind === "removed") set(void 0);
1960
+ }
1961
+ async function runRoutingCategory(selection, availabilities) {
1962
+ applyResult(await runSingleSelectCategory({
1963
+ title: "Routing",
1964
+ prompt: "Choose a routing integration",
1965
+ availabilities: availabilities.filter((a) => a.recipe.category === "routing"),
1966
+ current: selection.routing
1967
+ }), (value) => {
1968
+ selection.routing = value;
1969
+ });
1970
+ }
1971
+ async function runStateManagementCategory(selection, availabilities) {
1972
+ applyResult(await runSingleSelectCategory({
1973
+ title: "State Management",
1974
+ prompt: "Choose a state-management integration",
1975
+ availabilities: availabilities.filter((a) => a.recipe.category === "state-management"),
1976
+ current: selection.stateManagement,
1977
+ collectOptions: async (recipe, currentOptions) => {
1978
+ if (recipe.id !== "redux-toolkit") return {};
1979
+ const generateStore = orBack(await p.select({
1980
+ message: "Generate basic store files?",
1981
+ initialValue: currentOptions.generateStore === true ? "yes" : "no",
1982
+ options: [{
1983
+ value: "no",
1984
+ label: "No, install only"
1985
+ }, {
1986
+ value: "yes",
1987
+ label: "Yes",
1988
+ hint: "src/store files following the official quick start"
1989
+ }]
1990
+ }));
1991
+ if (generateStore === null) return null;
1992
+ return { generateStore: generateStore === "yes" };
1993
+ }
1994
+ }), (value) => {
1995
+ selection.stateManagement = value;
1996
+ });
1997
+ }
1998
+ async function runDataFetchingCategory(selection, availabilities) {
1999
+ applyResult(await runSingleSelectCategory({
2000
+ title: "Data Fetching and API",
2001
+ prompt: "Choose an integration",
2002
+ availabilities: availabilities.filter((a) => a.recipe.category === "data-fetching"),
2003
+ current: selection.dataFetching,
2004
+ collectOptions: async (recipe, currentOptions) => {
2005
+ if (recipe.id !== "tanstack-query") return {};
2006
+ const devtools = orBack(await p.confirm({
2007
+ message: "Include TanStack Query Devtools?",
2008
+ initialValue: currentOptions.devtools !== false
2009
+ }));
2010
+ if (devtools === null) return null;
2011
+ const provider = orBack(await p.select({
2012
+ message: "Generate QueryClient and provider files?",
2013
+ initialValue: currentOptions.configureProvider === false ? "install-only" : "files",
2014
+ options: [{
2015
+ value: "files",
2016
+ label: "Yes, generate provider files",
2017
+ hint: "src/lib/query-client and src/lib/query-provider; your entry file is never rewritten"
2018
+ }, {
2019
+ value: "install-only",
2020
+ label: "Install packages only"
2021
+ }]
2022
+ }));
2023
+ if (provider === null) return null;
2024
+ return {
2025
+ devtools,
2026
+ configureProvider: provider === "files"
2027
+ };
2028
+ }
2029
+ }), (value) => {
2030
+ selection.dataFetching = value;
2031
+ });
2032
+ }
2033
+ async function runUiCategory(selection, availabilities) {
2034
+ applyResult(await runSingleSelectCategory({
2035
+ title: "UI Components",
2036
+ prompt: "Choose a UI library",
2037
+ availabilities: availabilities.filter((a) => a.recipe.category === "ui"),
2038
+ current: selection.ui
2039
+ }), (value) => {
2040
+ selection.ui = value;
2041
+ });
2042
+ }
2043
+ async function runOrmCategory(selection, availabilities) {
2044
+ applyResult(await runSingleSelectCategory({
2045
+ title: "Database / ORM",
2046
+ prompt: "Choose an ORM (dependencies only; you write the setup files)",
2047
+ availabilities: availabilities.filter((a) => a.recipe.category === "orm"),
2048
+ current: selection.orm
2049
+ }), (value) => {
2050
+ selection.orm = value;
2051
+ });
2052
+ }
2053
+ async function runFormsValidationCategory(selection, availabilities) {
2054
+ applyResult(await runSingleSelectCategory({
2055
+ title: "Forms and Validation",
2056
+ prompt: "Choose an integration",
2057
+ availabilities: availabilities.filter((a) => a.recipe.category === "forms-validation"),
2058
+ current: selection.formsAndValidation
2059
+ }), (value) => {
2060
+ selection.formsAndValidation = value;
2061
+ });
2062
+ }
2063
+ async function runTestingCategory(selection, availabilities) {
2064
+ const testing = availabilities.filter((a) => a.recipe.category === "testing" && a.compatibility !== "incompatible");
2065
+ if (testing.length === 0) {
2066
+ p.log.warn("No compatible testing integrations are available for this project.");
2067
+ return;
2068
+ }
2069
+ const hintFor = (availability) => availability.compatibility === "already-installed" ? `already installed${availability.detection.installedVersion ? ` at ${availability.detection.installedVersion}` : ""}` : availability.recipe.installationSummary;
2070
+ const currentIds = selection.testing.map((entry) => entry.id);
2071
+ const choice = orBack(await p.select({
2072
+ message: "Choose testing tools",
2073
+ initialValue: currentIds.length > 1 ? "__all__" : currentIds[0] ?? testing[0]?.recipe.id,
2074
+ options: [
2075
+ ...testing.map((availability) => ({
2076
+ value: availability.recipe.id,
2077
+ label: currentIds.length === 1 && currentIds[0] === availability.recipe.id ? `${availability.recipe.name} ${pc.green("✓")}` : availability.recipe.name,
2078
+ hint: hintFor(availability)
2079
+ })),
2080
+ ...testing.length > 1 ? [{
2081
+ value: "__all__",
2082
+ label: testing.map((availability) => availability.recipe.name).join(" + ") + (currentIds.length > 1 ? ` ${pc.green("✓")}` : ""),
2083
+ hint: "install all testing tools"
2084
+ }] : [],
2085
+ {
2086
+ value: "__none__",
2087
+ label: "None",
2088
+ hint: "remove testing selections"
2089
+ },
2090
+ {
2091
+ value: "__return__",
2092
+ label: "Return without changes"
2093
+ }
2094
+ ]
2095
+ }));
2096
+ if (choice === null || choice === "__return__") return;
2097
+ if (choice === "__none__") {
2098
+ selection.testing = [];
2099
+ return;
2100
+ }
2101
+ const chosen = choice === "__all__" ? testing.map((availability) => availability.recipe.id) : [choice];
2102
+ const next = [];
2103
+ for (const id of chosen) {
2104
+ const previous = selection.testing.find((entry) => entry.id === id);
2105
+ if (id === "vitest-react") {
2106
+ const target = orBack(await p.select({
2107
+ message: "What will you test?",
2108
+ initialValue: previous?.options.testTarget === "utils" ? "utils" : "components",
2109
+ options: [{
2110
+ value: "components",
2111
+ label: "React components",
2112
+ hint: "Vitest + jsdom + React Testing Library + matchers + user-event"
2113
+ }, {
2114
+ value: "utils",
2115
+ label: "Utility functions only",
2116
+ hint: "Vitest only"
2117
+ }]
2118
+ }));
2119
+ if (target === null) return;
2120
+ next.push({
2121
+ id,
2122
+ options: { testTarget: target }
2123
+ });
2124
+ } else if (id === "playwright") {
2125
+ p.note("The official Playwright initializer will run interactively during installation.\nIt may ask its own questions, install browsers, and create config, tests, and CI files.", "Playwright");
2126
+ next.push({
2127
+ id,
2128
+ options: previous?.options ?? {}
2129
+ });
2130
+ } else next.push({
2131
+ id,
2132
+ options: previous?.options ?? {}
2133
+ });
2134
+ }
2135
+ selection.testing = next;
2136
+ }
2137
+ //#endregion
2138
+ //#region src/utils/versions.ts
2139
+ const TAG_PATTERN = /^[a-zA-Z][a-zA-Z0-9._-]*$/;
2140
+ /**
2141
+ * Parses an npm version input: an exact version ("18.3.1"), a range
2142
+ * ("18", "^18.3.1", "~18.3.1"), or a distribution tag ("latest", "beta").
2143
+ * Returns null when the input is not a valid version, range, or tag.
2144
+ */
2145
+ function parseVersionSpec(input) {
2146
+ const raw = input.trim();
2147
+ if (raw.length === 0) return null;
2148
+ if (/\s/.test(raw)) return null;
2149
+ if (semver.validRange(raw, { loose: true }) !== null) return {
2150
+ kind: "range",
2151
+ raw
2152
+ };
2153
+ if (TAG_PATTERN.test(raw)) return {
2154
+ kind: "tag",
2155
+ raw
2156
+ };
2157
+ return null;
2158
+ }
2159
+ /**
2160
+ * Whether two version specs can be satisfied together. Tags only match the
2161
+ * exact same tag; ranges use semver intersection.
2162
+ */
2163
+ function versionSpecsCompatible(a, b) {
2164
+ if (a === b) return true;
2165
+ const specA = parseVersionSpec(a);
2166
+ const specB = parseVersionSpec(b);
2167
+ if (specA === null || specB === null) return false;
2168
+ if (specA.kind === "tag" || specB.kind === "tag") return false;
2169
+ try {
2170
+ return semver.intersects(specA.raw, specB.raw, { loose: true });
2171
+ } catch {
2172
+ return false;
2173
+ }
2174
+ }
2175
+ //#endregion
2176
+ //#region src/utils/package-specifier.ts
2177
+ /**
2178
+ * Parses user input such as "axios", "axios@1", "@scope/pkg", or
2179
+ * "@scope/pkg@2.1.0". A missing version defaults to "latest".
2180
+ */
2181
+ function parsePackageSpecifier(input) {
2182
+ const raw = input.trim();
2183
+ if (raw.length === 0) return {
2184
+ ok: false,
2185
+ reason: "Enter a package name."
2186
+ };
2187
+ if (/\s/.test(raw)) return {
2188
+ ok: false,
2189
+ reason: "Package names cannot contain spaces."
2190
+ };
2191
+ if (raw.includes("\\") || raw.includes("..") || raw.startsWith(".") || raw.startsWith("/")) return {
2192
+ ok: false,
2193
+ reason: "Package names cannot look like filesystem paths."
2194
+ };
2195
+ let name = raw;
2196
+ let version = "latest";
2197
+ const atIndex = raw.startsWith("@") ? raw.indexOf("@", 1) : raw.indexOf("@");
2198
+ if (atIndex > 0) {
2199
+ name = raw.slice(0, atIndex);
2200
+ version = raw.slice(atIndex + 1);
2201
+ if (version.length === 0) return {
2202
+ ok: false,
2203
+ reason: "A version after \"@\" cannot be empty."
2204
+ };
2205
+ }
2206
+ const nameResult = validatePackageName(name);
2207
+ if (!nameResult.validForNewPackages) {
2208
+ const problem = nameResult.errors?.[0] ?? nameResult.warnings?.[0] ?? "invalid package name";
2209
+ return {
2210
+ ok: false,
2211
+ reason: `"${name}" is not a valid npm package name (${problem}).`
2212
+ };
2213
+ }
2214
+ if (parseVersionSpec(version) === null) return {
2215
+ ok: false,
2216
+ reason: `"${version}" is not a valid npm version, range, or tag.`
2217
+ };
2218
+ return {
2219
+ ok: true,
2220
+ value: {
2221
+ name,
2222
+ version
2223
+ }
2224
+ };
2225
+ }
2226
+ function formatPackageSpecifier(pkg) {
2227
+ return `${pkg.name}@${pkg.version}`;
2228
+ }
2229
+ //#endregion
2230
+ //#region src/storage/paths.ts
2231
+ /**
2232
+ * Global StackPack storage under the user's home directory.
2233
+ * STACKPACK_HOME overrides the base directory (used by tests).
2234
+ */
2235
+ function getStoragePaths(baseDir) {
2236
+ const home = baseDir ?? process.env.STACKPACK_HOME ?? path.join(os.homedir(), ".stackpack");
2237
+ return {
2238
+ home,
2239
+ presetsDir: path.join(home, "presets"),
2240
+ backupsDir: path.join(home, "backups"),
2241
+ cacheDir: path.join(home, "cache"),
2242
+ configFile: path.join(home, "config.json")
2243
+ };
2244
+ }
2245
+ /** Project-local StackPack directory (may be committed to Git). */
2246
+ function getProjectLocalDir(projectRoot) {
2247
+ return path.join(projectRoot, ".stackpack");
2248
+ }
2249
+ function getProjectLocalPresetsDir(projectRoot) {
2250
+ return getProjectLocalDir(projectRoot);
2251
+ }
2252
+ function getProjectBackupsDir(projectRoot) {
2253
+ return path.join(getProjectLocalDir(projectRoot), "backups");
2254
+ }
2255
+ //#endregion
2256
+ //#region src/storage/config-store.ts
2257
+ const savedCustomPackageSchema = z.object({
2258
+ name: z.string(),
2259
+ version: z.string(),
2260
+ dependencyType: z.enum(["dependency", "devDependency"])
2261
+ });
2262
+ const configSchema = z.object({
2263
+ defaultPackageManager: packageManagerSchema.optional(),
2264
+ /** Custom packages the user added before, offered again in future setups. */
2265
+ savedCustomPackages: z.array(savedCustomPackageSchema).optional()
2266
+ });
2267
+ async function loadConfig(globalBaseDir) {
2268
+ const { configFile } = getStoragePaths(globalBaseDir);
2269
+ try {
2270
+ const raw = await fs.readFile(configFile, "utf8");
2271
+ const parsed = configSchema.safeParse(JSON.parse(raw));
2272
+ return parsed.success ? parsed.data : {};
2273
+ } catch {
2274
+ return {};
2275
+ }
2276
+ }
2277
+ /** Adds or updates packages in the saved custom-package library. */
2278
+ async function rememberCustomPackages(packages, globalBaseDir) {
2279
+ const config = await loadConfig(globalBaseDir);
2280
+ const saved = [...config.savedCustomPackages ?? []];
2281
+ for (const pkg of packages) {
2282
+ const index = saved.findIndex((entry) => entry.name === pkg.name);
2283
+ if (index >= 0) saved[index] = pkg;
2284
+ else saved.push(pkg);
2285
+ }
2286
+ saved.sort((a, b) => a.name.localeCompare(b.name));
2287
+ await saveConfig({
2288
+ ...config,
2289
+ savedCustomPackages: saved
2290
+ }, globalBaseDir);
2291
+ }
2292
+ /** Removes a package from the saved library (projects are not touched). */
2293
+ async function forgetCustomPackage(name, globalBaseDir) {
2294
+ const config = await loadConfig(globalBaseDir);
2295
+ await saveConfig({
2296
+ ...config,
2297
+ savedCustomPackages: (config.savedCustomPackages ?? []).filter((entry) => entry.name !== name)
2298
+ }, globalBaseDir);
2299
+ }
2300
+ async function saveConfig(config, globalBaseDir) {
2301
+ const { configFile } = getStoragePaths(globalBaseDir);
2302
+ await fs.mkdir(path.dirname(configFile), { recursive: true });
2303
+ await fs.writeFile(configFile, JSON.stringify(configSchema.parse(config), null, 2) + "\n", "utf8");
2304
+ }
2305
+ //#endregion
2306
+ //#region src/dashboard/custom-packages.ts
2307
+ async function runCustomPackagesCategory(selection) {
2308
+ for (;;) {
2309
+ const saved = (await loadConfig()).savedCustomPackages ?? [];
2310
+ const savedSelectable = saved.filter((entry) => !selection.customPackages.some((pkg) => pkg.name === entry.name));
2311
+ const action = orBack(await p.select({
2312
+ message: `Custom packages (${selection.customPackages.length} added)`,
2313
+ options: [
2314
+ {
2315
+ value: "add",
2316
+ label: "Add an npm package",
2317
+ hint: "typed packages are saved for future setups"
2318
+ },
2319
+ ...savedSelectable.length > 0 ? [{
2320
+ value: "saved",
2321
+ label: "Add from saved packages",
2322
+ hint: `${savedSelectable.length} saved earlier`
2323
+ }] : [],
2324
+ ...selection.customPackages.length > 0 ? [{
2325
+ value: "remove",
2326
+ label: "Remove a package from this setup"
2327
+ }] : [],
2328
+ ...saved.length > 0 ? [{
2329
+ value: "manage",
2330
+ label: "Delete saved packages"
2331
+ }] : [],
2332
+ {
2333
+ value: "return",
2334
+ label: "Return to integrations"
2335
+ }
2336
+ ]
2337
+ }));
2338
+ if (action === null || action === "return") return;
2339
+ if (action === "saved") {
2340
+ const chosen = orBack(await p.multiselect({
2341
+ message: "Select saved packages to add (space to toggle, enter to confirm)",
2342
+ required: false,
2343
+ options: savedSelectable.map((entry) => ({
2344
+ value: entry.name,
2345
+ label: formatPackageSpecifier(entry),
2346
+ hint: entry.dependencyType
2347
+ }))
2348
+ }));
2349
+ if (chosen === null) continue;
2350
+ for (const name of chosen) {
2351
+ const entry = savedSelectable.find((pkg) => pkg.name === name);
2352
+ if (entry) {
2353
+ selection.customPackages.push({ ...entry });
2354
+ p.log.success(`Added ${formatPackageSpecifier(entry)}`);
2355
+ }
2356
+ }
2357
+ continue;
2358
+ }
2359
+ if (action === "manage") {
2360
+ const chosen = orBack(await p.multiselect({
2361
+ message: "Delete which saved packages? (no project is changed)",
2362
+ required: false,
2363
+ options: saved.map((entry) => ({
2364
+ value: entry.name,
2365
+ label: formatPackageSpecifier(entry),
2366
+ hint: entry.dependencyType
2367
+ }))
2368
+ }));
2369
+ if (chosen === null) continue;
2370
+ for (const name of chosen) await forgetCustomPackage(name);
2371
+ if (chosen.length > 0) p.log.success(`Deleted ${chosen.length} saved package(s).`);
2372
+ continue;
2373
+ }
2374
+ if (action === "remove") {
2375
+ const name = orBack(await p.select({
2376
+ message: "Remove which package?",
2377
+ options: selection.customPackages.map((pkg) => ({
2378
+ value: pkg.name,
2379
+ label: formatPackageSpecifier(pkg),
2380
+ hint: pkg.dependencyType
2381
+ }))
2382
+ }));
2383
+ if (name === null) continue;
2384
+ selection.customPackages = selection.customPackages.filter((pkg) => pkg.name !== name);
2385
+ continue;
2386
+ }
2387
+ const input = orBack(await p.text({
2388
+ message: "Enter an npm package (name or name@version, Esc to go back)",
2389
+ placeholder: "sonner@latest",
2390
+ validate(value) {
2391
+ const result = parsePackageSpecifier(value ?? "");
2392
+ return result.ok ? void 0 : result.reason;
2393
+ }
2394
+ }));
2395
+ if (input === null) continue;
2396
+ const parsed = parsePackageSpecifier(input);
2397
+ if (!parsed.ok) continue;
2398
+ if (selection.customPackages.some((pkg) => pkg.name === parsed.value.name)) {
2399
+ p.log.warn(`${parsed.value.name} is already in the custom package list.`);
2400
+ continue;
2401
+ }
2402
+ const dependencyType = orBack(await p.select({
2403
+ message: "Add package as",
2404
+ options: [{
2405
+ value: "dependency",
2406
+ label: "dependency"
2407
+ }, {
2408
+ value: "devDependency",
2409
+ label: "devDependency"
2410
+ }]
2411
+ }));
2412
+ if (dependencyType === null) continue;
2413
+ p.note("StackPack does not have a verified automatic recipe for this package.\nIt will be installed only. No configuration files will be generated.", formatPackageSpecifier(parsed.value));
2414
+ const entry = {
2415
+ ...parsed.value,
2416
+ dependencyType
2417
+ };
2418
+ selection.customPackages.push(entry);
2419
+ await rememberCustomPackages([entry]);
2420
+ p.log.success(`Added ${formatPackageSpecifier(parsed.value)} — saved for future setups too.`);
2421
+ }
2422
+ }
2423
+ //#endregion
2424
+ //#region src/dashboard/version-editor.ts
2425
+ async function runVersionEditor(selection) {
2426
+ for (;;) {
2427
+ const names = editablePackageNames(selection);
2428
+ if (names.length === 0) {
2429
+ p.log.warn("Select integrations or custom packages first, then edit their versions.");
2430
+ return;
2431
+ }
2432
+ const choice = orBack(await p.select({
2433
+ message: "Choose a package to set a version for",
2434
+ options: [...names.map((name) => ({
2435
+ value: name,
2436
+ label: name,
2437
+ hint: selection.versionOverrides[name] ?? "latest"
2438
+ })), {
2439
+ value: "__return__",
2440
+ label: "Return"
2441
+ }]
2442
+ }));
2443
+ if (choice === null || choice === "__return__") return;
2444
+ const input = orBack(await p.text({
2445
+ message: `Enter an npm version, range, or tag for ${choice} (Esc to go back)`,
2446
+ placeholder: "latest",
2447
+ initialValue: selection.versionOverrides[choice] ?? "",
2448
+ validate(value) {
2449
+ if (!value || value.trim().length === 0) return void 0;
2450
+ return parseVersionSpec(value) === null ? "Enter a valid npm version, range, or tag (e.g. 5, ^5.1.0, latest)." : void 0;
2451
+ }
2452
+ }));
2453
+ if (input === null) continue;
2454
+ const trimmed = input.trim();
2455
+ const customPackage = selection.customPackages.find((pkg) => pkg.name === choice);
2456
+ if (trimmed.length === 0 || trimmed === "latest") {
2457
+ delete selection.versionOverrides[choice];
2458
+ if (customPackage) customPackage.version = "latest";
2459
+ p.log.success(`${choice} set to latest`);
2460
+ } else {
2461
+ selection.versionOverrides[choice] = trimmed;
2462
+ if (customPackage) customPackage.version = trimmed;
2463
+ p.log.success(`${choice} set to version ${trimmed}`);
2464
+ }
2465
+ }
2466
+ }
2467
+ //#endregion
2468
+ //#region src/dashboard/main-dashboard.ts
2469
+ function selectionLabel(entry) {
2470
+ if (!entry) return void 0;
2471
+ const recipe = getRecipe(entry.id);
2472
+ if (!recipe) return entry.id;
2473
+ const detail = recipe.describeOptions?.(entry.options);
2474
+ return detail ? `${recipe.name} (${detail})` : recipe.name;
2475
+ }
2476
+ function testingLabel(selection) {
2477
+ if (selection.testing.length === 0) return void 0;
2478
+ return selection.testing.map((entry) => getRecipe(entry.id)?.name ?? entry.id).join(" + ");
2479
+ }
2480
+ /** Category rows turn green with a check once something is selected. */
2481
+ function categoryOption(value, label, selected) {
2482
+ return selected ? {
2483
+ value,
2484
+ label: `${label} ${pc.green("✓")}`,
2485
+ hint: pc.green(selected)
2486
+ } : {
2487
+ value,
2488
+ label,
2489
+ hint: "Not selected"
2490
+ };
2491
+ }
2492
+ function categoryHasVisibleRecipes(availabilities, category) {
2493
+ return availabilities.some((a) => a.recipe.category === category && a.compatibility !== "incompatible");
2494
+ }
2495
+ /**
2496
+ * The jumpable category dashboard. Selections persist in memory while the
2497
+ * user moves between categories; nothing is installed here.
2498
+ */
2499
+ async function runDashboard(context, selection) {
2500
+ for (;;) {
2501
+ const availabilities = filterIntegrations(context, allRecipes);
2502
+ const count = selectedIntegrationCount(selection);
2503
+ const customCount = selection.customPackages.length;
2504
+ const overrideCount = Object.keys(selection.versionOverrides).length;
2505
+ p.log.info(`Selected integrations: ${count} Additional packages: ${customCount}${overrideCount > 0 ? ` Version overrides: ${overrideCount}` : ""}`);
2506
+ const options = [];
2507
+ if (categoryHasVisibleRecipes(availabilities, "routing")) options.push(categoryOption("routing", "Routing", selectionLabel(selection.routing)));
2508
+ options.push(categoryOption("state-management", "State Management", selectionLabel(selection.stateManagement)), categoryOption("data-fetching", "Data Fetching and API", selectionLabel(selection.dataFetching)), categoryOption("forms-validation", "Forms and Validation", selectionLabel(selection.formsAndValidation)), categoryOption("ui", "UI Components", selectionLabel(selection.ui)), categoryOption("orm", "Database / ORM", selectionLabel(selection.orm)), categoryOption("testing", "Testing", testingLabel(selection)), categoryOption("custom-packages", "Custom Packages", customCount > 0 ? `${customCount} package${customCount === 1 ? "" : "s"}` : void 0), {
2509
+ value: "versions",
2510
+ label: "Edit Package Versions"
2511
+ }, {
2512
+ value: "review",
2513
+ label: "Review and Install"
2514
+ });
2515
+ switch (guard(await p.select({
2516
+ message: "Choose a category",
2517
+ options
2518
+ }))) {
2519
+ case "routing":
2520
+ await runRoutingCategory(selection, availabilities);
2521
+ break;
2522
+ case "state-management":
2523
+ await runStateManagementCategory(selection, availabilities);
2524
+ break;
2525
+ case "data-fetching":
2526
+ await runDataFetchingCategory(selection, availabilities);
2527
+ break;
2528
+ case "forms-validation":
2529
+ await runFormsValidationCategory(selection, availabilities);
2530
+ break;
2531
+ case "ui":
2532
+ await runUiCategory(selection, availabilities);
2533
+ break;
2534
+ case "orm":
2535
+ await runOrmCategory(selection, availabilities);
2536
+ break;
2537
+ case "testing":
2538
+ await runTestingCategory(selection, availabilities);
2539
+ break;
2540
+ case "custom-packages":
2541
+ await runCustomPackagesCategory(selection);
2542
+ break;
2543
+ case "versions":
2544
+ await runVersionEditor(selection);
2545
+ break;
2546
+ case "review": return "review";
2547
+ }
2548
+ }
2549
+ }
2550
+ //#endregion
2551
+ //#region src/engine/resolve-order.ts
2552
+ /**
2553
+ * Produces a valid execution order for the selected recipes.
2554
+ * - `requires` must be present in the selection and orders before.
2555
+ * - `runsAfter` orders before only when the other recipe is selected.
2556
+ * - `conflictsWith` aborts when both are selected.
2557
+ * Detects circular dependencies before anything runs.
2558
+ */
2559
+ function resolveExecutionOrder(selected) {
2560
+ const ids = new Set(selected.map((r) => r.id));
2561
+ for (const recipe of selected) {
2562
+ for (const required of recipe.requires) if (!ids.has(required)) throw new StackPackError(`Integration "${recipe.id}" requires "${required}", which is not selected.`);
2563
+ for (const conflict of recipe.conflictsWith) if (ids.has(conflict)) throw new StackPackError(`Integrations "${recipe.id}" and "${conflict}" cannot be installed together.`);
2564
+ }
2565
+ const indegree = new Map(selected.map((r) => [r.id, 0]));
2566
+ const edges = /* @__PURE__ */ new Map();
2567
+ for (const recipe of selected) {
2568
+ const before = [...recipe.requires, ...recipe.runsAfter.filter((id) => ids.has(id))];
2569
+ for (const prerequisite of before) {
2570
+ edges.set(prerequisite, [...edges.get(prerequisite) ?? [], recipe.id]);
2571
+ indegree.set(recipe.id, (indegree.get(recipe.id) ?? 0) + 1);
2572
+ }
2573
+ }
2574
+ const queue = selected.filter((r) => (indegree.get(r.id) ?? 0) === 0).map((r) => r.id);
2575
+ const orderedIds = [];
2576
+ while (queue.length > 0) {
2577
+ const id = queue.shift();
2578
+ orderedIds.push(id);
2579
+ for (const dependent of edges.get(id) ?? []) {
2580
+ const remaining = (indegree.get(dependent) ?? 0) - 1;
2581
+ indegree.set(dependent, remaining);
2582
+ if (remaining === 0) queue.push(dependent);
2583
+ }
2584
+ }
2585
+ if (orderedIds.length !== selected.length) throw new StackPackError(`Invalid recipe graph: circular dependency involving ${selected.map((r) => r.id).filter((id) => !orderedIds.includes(id)).join(", ")}.`);
2586
+ const byId = new Map(selected.map((r) => [r.id, r]));
2587
+ return orderedIds.map((id) => byId.get(id));
2588
+ }
2589
+ //#endregion
2590
+ //#region src/engine/resolve-dependencies.ts
2591
+ /**
2592
+ * Merges every package requirement (recipes + custom packages), applies user
2593
+ * version overrides, and detects conflicts instead of silently choosing.
2594
+ */
2595
+ function resolveDependencies(input) {
2596
+ const byName = /* @__PURE__ */ new Map();
2597
+ for (const requirement of input.requirements) {
2598
+ const list = byName.get(requirement.name) ?? [];
2599
+ list.push(requirement);
2600
+ byName.set(requirement.name, list);
2601
+ }
2602
+ const packages = [];
2603
+ const conflicts = [];
2604
+ const warnings = [];
2605
+ for (const [name, requests] of byName) {
2606
+ const requestedBy = [...new Set(requests.map((r) => r.requestedBy))];
2607
+ const requestedVersions = [...new Set(requests.map((r) => r.version))];
2608
+ const dependencyTypes = new Set(requests.map((r) => r.dependencyType));
2609
+ let dependencyType = requests[0]?.dependencyType ?? "dependency";
2610
+ if (dependencyTypes.size > 1) {
2611
+ dependencyType = "dependency";
2612
+ warnings.push(`${name} was requested as both a dependency and a devDependency; installing as a dependency.`);
2613
+ }
2614
+ const override = input.versionOverrides[name];
2615
+ let resolvedVersion;
2616
+ if (override !== void 0) {
2617
+ resolvedVersion = override;
2618
+ if (!requestedBy.includes("version override")) requestedBy.push("version override");
2619
+ } else if (requestedVersions.length === 1) resolvedVersion = requestedVersions[0] ?? "latest";
2620
+ else {
2621
+ const specific = requestedVersions.filter((v) => v !== "latest");
2622
+ if (specific.length === 1) {
2623
+ resolvedVersion = specific[0] ?? "latest";
2624
+ warnings.push(`${name} was requested as "latest" and "${resolvedVersion}"; using "${resolvedVersion}".`);
2625
+ } else if (specific.every((v, _, all) => all.every((other) => versionSpecsCompatible(v, other)))) {
2626
+ conflicts.push({
2627
+ name,
2628
+ versions: requestedVersions,
2629
+ requestedBy,
2630
+ message: `${name} has overlapping but different version requests (${requestedVersions.join(", ")}). Set an explicit version override to resolve.`
2631
+ });
2632
+ continue;
2633
+ } else {
2634
+ conflicts.push({
2635
+ name,
2636
+ versions: requestedVersions,
2637
+ requestedBy,
2638
+ message: `${name} has incompatible version requests (${requestedVersions.join(", ")}) from ${requestedBy.join(", ")}. Set an explicit version override to resolve.`
2639
+ });
2640
+ continue;
2641
+ }
2642
+ }
2643
+ const installed = input.installedPackages[name];
2644
+ if (installed !== void 0) warnings.push(`${name} is already installed (${installed}); it will be updated to ${resolvedVersion}.`);
2645
+ packages.push({
2646
+ name,
2647
+ requestedVersions,
2648
+ resolvedVersion,
2649
+ dependencyType,
2650
+ requestedBy
2651
+ });
2652
+ }
2653
+ packages.sort((a, b) => a.name.localeCompare(b.name));
2654
+ return {
2655
+ packages,
2656
+ conflicts,
2657
+ warnings
2658
+ };
2659
+ }
2660
+ //#endregion
2661
+ //#region src/project/conflicts.ts
2662
+ /**
2663
+ * Finds package.json scripts that already exist with a different command.
2664
+ * Scripts whose current value equals the proposal are not conflicts.
2665
+ */
2666
+ function findScriptConflicts(existing, proposed) {
2667
+ const conflicts = [];
2668
+ for (const [name, command] of Object.entries(proposed)) {
2669
+ const current = existing?.[name];
2670
+ if (current !== void 0 && current !== command) conflicts.push({
2671
+ name,
2672
+ current,
2673
+ proposed: command
2674
+ });
2675
+ }
2676
+ return conflicts;
2677
+ }
2678
+ /** Which of the planned files already exist in the project. */
2679
+ function findExistingFiles(projectRoot, plannedFiles) {
2680
+ return plannedFiles.filter((relative) => fs$1.existsSync(path.join(projectRoot, relative)));
2681
+ }
2682
+ //#endregion
2683
+ //#region src/engine/build-plan.ts
2684
+ /**
2685
+ * Builds the complete installation plan for review. Pure planning: nothing is
2686
+ * installed or written here.
2687
+ */
2688
+ function buildInstallationPlan(context, selection) {
2689
+ const chosen = selectedRecipes(selection);
2690
+ const integrations = resolveExecutionOrder(chosen.map(({ recipe }) => recipe)).map((recipe) => chosen.find((c) => c.recipe.id === recipe.id)).map(({ recipe, options }) => ({
2691
+ recipe,
2692
+ options,
2693
+ plan: recipe.createPlan(context, options)
2694
+ }));
2695
+ const requirements = [];
2696
+ for (const { recipe, plan } of integrations) for (const pkg of plan.packages) requirements.push({
2697
+ ...pkg,
2698
+ requestedBy: recipe.name
2699
+ });
2700
+ for (const custom of selection.customPackages) requirements.push({
2701
+ name: custom.name,
2702
+ version: custom.version,
2703
+ dependencyType: custom.dependencyType,
2704
+ requestedBy: "custom package"
2705
+ });
2706
+ const resolution = resolveDependencies({
2707
+ requirements,
2708
+ versionOverrides: selection.versionOverrides,
2709
+ installedPackages: context.installedPackages
2710
+ });
2711
+ const filesToCreate = integrations.flatMap(({ plan }) => plan.filesToCreate);
2712
+ const jsonEdits = integrations.flatMap(({ plan }) => plan.jsonEdits ?? []);
2713
+ const scripts = integrations.flatMap(({ plan }) => plan.scripts);
2714
+ const initializers = integrations.flatMap(({ recipe, plan }) => plan.initializer ? [{
2715
+ id: recipe.id,
2716
+ name: recipe.name,
2717
+ initializer: plan.initializer
2718
+ }] : []);
2719
+ const filesToModify = [];
2720
+ if (resolution.packages.length > 0 || scripts.length > 0) filesToModify.push("package.json");
2721
+ filesToModify.push(...jsonEdits.map((edit) => edit.path));
2722
+ const warnings = [...resolution.warnings];
2723
+ if (initializers.length > 0) warnings.push("Official initializers may create additional files StackPack cannot predict.");
2724
+ return {
2725
+ context,
2726
+ integrations,
2727
+ dependencies: resolution.packages.filter((p) => p.dependencyType === "dependency"),
2728
+ devDependencies: resolution.packages.filter((p) => p.dependencyType === "devDependency"),
2729
+ conflicts: resolution.conflicts,
2730
+ filesToCreate,
2731
+ jsonEdits,
2732
+ filesToModify,
2733
+ scripts,
2734
+ scriptConflicts: findScriptConflicts(context.packageJson.scripts, { ...Object.fromEntries(scripts.map((s) => [s.name, s.command])) }),
2735
+ existingFileConflicts: findExistingFiles(context.rootDirectory, filesToCreate.map((f) => f.path)),
2736
+ initializers,
2737
+ warnings,
2738
+ notes: integrations.flatMap(({ plan }) => plan.postInstallNotes)
2739
+ };
2740
+ }
2741
+ //#endregion
2742
+ //#region src/package-manager/commands.ts
2743
+ function toSpecifiers(packages) {
2744
+ return packages.map((pkg) => `${pkg.name}@${pkg.version}`);
2745
+ }
2746
+ /** Builds the plain "install everything in package.json" command. */
2747
+ function baseInstallCommand(packageManager, cwd) {
2748
+ return {
2749
+ command: packageManager,
2750
+ args: ["install"],
2751
+ cwd,
2752
+ interactive: true
2753
+ };
2754
+ }
2755
+ /** Builds the official install command for the detected package manager. */
2756
+ function packageInstallCommand(packageManager, packages, options) {
2757
+ const specs = toSpecifiers(packages);
2758
+ switch (packageManager) {
2759
+ case "npm": return {
2760
+ command: "npm",
2761
+ args: [
2762
+ "install",
2763
+ options.dev ? "--save-dev" : "--save",
2764
+ ...specs
2765
+ ],
2766
+ cwd: options.cwd
2767
+ };
2768
+ case "pnpm": return {
2769
+ command: "pnpm",
2770
+ args: [
2771
+ "add",
2772
+ ...options.dev ? ["-D"] : [],
2773
+ ...specs
2774
+ ],
2775
+ cwd: options.cwd
2776
+ };
2777
+ case "yarn": return {
2778
+ command: "yarn",
2779
+ args: [
2780
+ "add",
2781
+ ...options.dev ? ["-D"] : [],
2782
+ ...specs
2783
+ ],
2784
+ cwd: options.cwd
2785
+ };
2786
+ case "bun": return {
2787
+ command: "bun",
2788
+ args: [
2789
+ "add",
2790
+ ...options.dev ? ["-d"] : [],
2791
+ ...specs
2792
+ ],
2793
+ cwd: options.cwd
2794
+ };
2795
+ }
2796
+ }
2797
+ //#endregion
2798
+ //#region src/package-manager/types.ts
2799
+ function formatCommand(cmd) {
2800
+ return [cmd.command, ...cmd.args].join(" ");
2801
+ }
2802
+ //#endregion
2803
+ //#region src/project/safe-paths.ts
2804
+ /** Whether target (absolute or relative) stays inside root after resolution. */
2805
+ function isInsideRoot(root, target) {
2806
+ const resolvedRoot = path.resolve(root);
2807
+ const resolvedTarget = path.resolve(resolvedRoot, target);
2808
+ const relative = path.relative(resolvedRoot, resolvedTarget);
2809
+ return relative === "" || !relative.startsWith("..") && !path.isAbsolute(relative);
2810
+ }
2811
+ /**
2812
+ * Resolves a relative path against a project root and guarantees the result
2813
+ * stays inside the root. Throws for traversal or absolute inputs.
2814
+ */
2815
+ function resolveInsideRoot(root, relativePath) {
2816
+ if (path.isAbsolute(relativePath)) throw new StackPackError(`Refusing absolute path "${relativePath}" inside the project.`);
2817
+ if (!isInsideRoot(root, relativePath)) throw new StackPackError(`Refusing path "${relativePath}" because it escapes the project directory.`);
2818
+ return path.resolve(path.resolve(root), relativePath);
2819
+ }
2820
+ //#endregion
2821
+ //#region src/project/backups.ts
2822
+ const NEVER_BACKED_UP = [
2823
+ "node_modules",
2824
+ "dist",
2825
+ "build",
2826
+ "coverage",
2827
+ ".git"
2828
+ ];
2829
+ function isBackupExcluded(relativePath) {
2830
+ const first = relativePath.replaceAll("\\", "/").split("/")[0] ?? "";
2831
+ return NEVER_BACKED_UP.includes(first);
2832
+ }
2833
+ /**
2834
+ * Copies existing files (relative to the project root) into the operation's
2835
+ * backup directory, preserving relative structure. Missing files are skipped.
2836
+ * Returns the list of files that were actually backed up.
2837
+ */
2838
+ async function backupFiles(projectRoot, backupFilesDir, relativeFiles) {
2839
+ const backedUp = [];
2840
+ for (const relative of relativeFiles) {
2841
+ if (isBackupExcluded(relative)) continue;
2842
+ const source = resolveInsideRoot(projectRoot, relative);
2843
+ try {
2844
+ await fs.access(source);
2845
+ } catch {
2846
+ continue;
2847
+ }
2848
+ const target = path.join(backupFilesDir, relative);
2849
+ await fs.mkdir(path.dirname(target), { recursive: true });
2850
+ await fs.copyFile(source, target);
2851
+ backedUp.push(relative);
2852
+ }
2853
+ return backedUp;
2854
+ }
2855
+ /** Restores every backed-up file to its original location. Best effort. */
2856
+ async function restoreBackup(projectRoot, backupFilesDir) {
2857
+ const restored = [];
2858
+ const entries = await fs.readdir(backupFilesDir, {
2859
+ recursive: true,
2860
+ withFileTypes: true
2861
+ });
2862
+ for (const entry of entries) {
2863
+ if (!entry.isFile()) continue;
2864
+ const absolute = path.join(entry.parentPath ?? backupFilesDir, entry.name);
2865
+ const relative = path.relative(backupFilesDir, absolute);
2866
+ const target = resolveInsideRoot(projectRoot, relative);
2867
+ await fs.mkdir(path.dirname(target), { recursive: true });
2868
+ await fs.copyFile(absolute, target);
2869
+ restored.push(relative.replaceAll("\\", "/"));
2870
+ }
2871
+ return restored;
2872
+ }
2873
+ //#endregion
2874
+ //#region src/schemas/operation.ts
2875
+ const operationSummarySchema = z.object({
2876
+ operationId: z.string(),
2877
+ startedAt: z.string(),
2878
+ finishedAt: z.string().optional(),
2879
+ integrations: z.array(z.string()),
2880
+ filesBackedUp: z.array(z.string()),
2881
+ commandsRun: z.array(z.string()),
2882
+ status: z.enum([
2883
+ "started",
2884
+ "completed",
2885
+ "failed",
2886
+ "cancelled"
2887
+ ])
2888
+ });
2889
+ //#endregion
2890
+ //#region src/storage/operation-store.ts
2891
+ async function createOperation(projectRoot) {
2892
+ const id = `${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}-${crypto.randomUUID().slice(0, 8)}`;
2893
+ const dir = path.join(getProjectBackupsDir(projectRoot), id);
2894
+ const filesDir = path.join(dir, "files");
2895
+ await fs.mkdir(filesDir, { recursive: true });
2896
+ return {
2897
+ id,
2898
+ dir,
2899
+ filesDir
2900
+ };
2901
+ }
2902
+ async function writeOperationSummary(operation, summary) {
2903
+ const validated = operationSummarySchema.parse(summary);
2904
+ await fs.writeFile(path.join(operation.dir, "operation.json"), JSON.stringify(validated, null, 2) + "\n", "utf8");
2905
+ }
2906
+ //#endregion
2907
+ //#region src/engine/apply-plan.ts
2908
+ function renameWithSuffix(filePath) {
2909
+ const ext = path.extname(filePath);
2910
+ return ext.length > 0 ? `${filePath.slice(0, -ext.length)}.stackpack${ext}` : `${filePath}.stackpack`;
2911
+ }
2912
+ async function runOrThrow(runner, cmd, what, commandsRun) {
2913
+ commandsRun.push(formatCommand(cmd));
2914
+ const result = await runner(cmd);
2915
+ if (result.exitCode !== 0) {
2916
+ const stderrTail = result.stderr.split("\n").slice(-15).join("\n").trim();
2917
+ throw new StackPackError(`${what} failed with exit code ${result.exitCode}.`, { hints: [
2918
+ `Command: ${formatCommand(cmd)}`,
2919
+ ...stderrTail.length > 0 ? [stderrTail] : [],
2920
+ "The project may contain partial changes; a backup was created before installation."
2921
+ ] });
2922
+ }
2923
+ }
2924
+ /**
2925
+ * Applies a reviewed installation plan using the safe execution flow:
2926
+ * backups -> official package installs -> StackPack file changes and JSON
2927
+ * edits -> official initializers -> rescan -> package.json script updates.
2928
+ * Files come before initializers because official CLIs (e.g. shadcn)
2929
+ * validate prerequisites like Tailwind config and import aliases.
2930
+ */
2931
+ async function applyPlan(plan, options) {
2932
+ if (plan.conflicts.length > 0) throw new StackPackError("Unresolved dependency conflicts block installation.", { hints: plan.conflicts.map((c) => c.message) });
2933
+ const progress = options.onProgress ?? (() => {});
2934
+ const root = plan.context.rootDirectory;
2935
+ const pm = plan.context.packageManager;
2936
+ const result = {
2937
+ dryRun: options.dryRun,
2938
+ commandsRun: [],
2939
+ createdFiles: [],
2940
+ skippedFiles: [],
2941
+ modifiedFiles: [],
2942
+ notes: [...plan.notes]
2943
+ };
2944
+ const dependencyInstall = plan.dependencies.length > 0 ? packageInstallCommand(pm, plan.dependencies.map((p) => ({
2945
+ name: p.name,
2946
+ version: p.resolvedVersion
2947
+ })), {
2948
+ dev: false,
2949
+ cwd: root
2950
+ }) : null;
2951
+ const devDependencyInstall = plan.devDependencies.length > 0 ? packageInstallCommand(pm, plan.devDependencies.map((p) => ({
2952
+ name: p.name,
2953
+ version: p.resolvedVersion
2954
+ })), {
2955
+ dev: true,
2956
+ cwd: root
2957
+ }) : null;
2958
+ if (options.dryRun) {
2959
+ for (const cmd of [dependencyInstall, devDependencyInstall]) if (cmd) result.commandsRun.push(formatCommand(cmd));
2960
+ for (const entry of plan.initializers) result.commandsRun.push(formatCommand(entry.initializer.buildCommand(pm, root)));
2961
+ result.createdFiles = plan.filesToCreate.map((f) => f.path);
2962
+ result.modifiedFiles = plan.filesToModify;
2963
+ result.notes.push("Dry run: no commands were executed and no files were changed.");
2964
+ return result;
2965
+ }
2966
+ const operation = await createOperation(root);
2967
+ result.operationDir = operation.dir;
2968
+ const filesBackedUp = await backupFiles(root, operation.filesDir, [
2969
+ "package.json",
2970
+ ...plan.filesToModify,
2971
+ ...plan.existingFileConflicts
2972
+ ]);
2973
+ const summaryBase = {
2974
+ operationId: operation.id,
2975
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
2976
+ integrations: plan.integrations.map((i) => i.recipe.id),
2977
+ filesBackedUp,
2978
+ commandsRun: result.commandsRun
2979
+ };
2980
+ await writeOperationSummary(operation, {
2981
+ ...summaryBase,
2982
+ status: "started"
2983
+ });
2984
+ try {
2985
+ if (dependencyInstall) {
2986
+ progress(`Installing dependencies with ${pm}...`);
2987
+ await runOrThrow(options.runner, dependencyInstall, "Package installation", result.commandsRun);
2988
+ }
2989
+ if (devDependencyInstall) {
2990
+ progress(`Installing development dependencies with ${pm}...`);
2991
+ await runOrThrow(options.runner, devDependencyInstall, "Development package installation", result.commandsRun);
2992
+ }
2993
+ for (const file of plan.filesToCreate) {
2994
+ const target = resolveInsideRoot(root, file.path);
2995
+ let finalPath = file.path;
2996
+ let exists = false;
2997
+ try {
2998
+ await fs.access(target);
2999
+ exists = true;
3000
+ } catch {
3001
+ exists = false;
3002
+ }
3003
+ if (exists) {
3004
+ const resolution = options.fileResolutions?.[file.path] ?? "keep";
3005
+ if (resolution === "keep") {
3006
+ result.skippedFiles.push(file.path);
3007
+ result.notes.push(`Kept existing ${file.path}; the planned file was not written.`);
3008
+ continue;
3009
+ }
3010
+ if (resolution === "rename") finalPath = renameWithSuffix(file.path);
3011
+ else await backupFiles(root, operation.filesDir, [file.path]);
3012
+ }
3013
+ const finalTarget = resolveInsideRoot(root, finalPath);
3014
+ await fs.mkdir(path.dirname(finalTarget), { recursive: true });
3015
+ await fs.writeFile(finalTarget, file.contents, "utf8");
3016
+ result.createdFiles.push(finalPath);
3017
+ }
3018
+ for (const edit of plan.jsonEdits) {
3019
+ const target = resolveInsideRoot(root, edit.path);
3020
+ try {
3021
+ await fs.access(target);
3022
+ } catch {
3023
+ result.notes.push(`Skipped JSON edit: ${edit.path} does not exist.`);
3024
+ continue;
3025
+ }
3026
+ progress(`Updating ${edit.path}...`);
3027
+ let content = await fs.readFile(target, "utf8");
3028
+ for (const change of edit.edits) {
3029
+ const edits = modify(content, change.jsonPath, change.value, { formattingOptions: {
3030
+ insertSpaces: true,
3031
+ tabSize: 2
3032
+ } });
3033
+ content = applyEdits(content, edits);
3034
+ }
3035
+ const reparsed = parse(content);
3036
+ if (typeof reparsed !== "object" || reparsed === null) throw new StackPackError(`Refusing to write ${edit.path}: edit produced invalid JSON.`);
3037
+ await fs.writeFile(target, content, "utf8");
3038
+ result.modifiedFiles.push(edit.path);
3039
+ }
3040
+ for (const entry of plan.initializers) {
3041
+ progress(`Running the official ${entry.name} initializer...`);
3042
+ await runOrThrow(options.runner, entry.initializer.buildCommand(pm, root), `Official ${entry.name} initializer`, result.commandsRun);
3043
+ }
3044
+ progress("Rescanning the project...");
3045
+ result.refreshedContext = await detectProject(root, { packageManagerOverride: pm });
3046
+ const finalScripts = {};
3047
+ const conflictNames = new Set(plan.scriptConflicts.map((c) => c.name));
3048
+ for (const script of plan.scripts) {
3049
+ if (!conflictNames.has(script.name)) {
3050
+ finalScripts[script.name] = script.command;
3051
+ continue;
3052
+ }
3053
+ const resolution = options.scriptResolutions?.[script.name] ?? "alternate";
3054
+ if (resolution === "skip") result.notes.push(`Kept the existing "${script.name}" script unchanged.`);
3055
+ else if (resolution === "replace") finalScripts[script.name] = script.command;
3056
+ else {
3057
+ const alternate = `${script.name}:${script.command.split(" ")[0]}`;
3058
+ finalScripts[alternate] = script.command;
3059
+ result.notes.push(`Added the script as "${alternate}" to keep your existing "${script.name}".`);
3060
+ }
3061
+ }
3062
+ if (Object.keys(finalScripts).length > 0) {
3063
+ progress("Updating package.json scripts...");
3064
+ await updatePackageJsonScripts(root, finalScripts);
3065
+ result.modifiedFiles.push("package.json");
3066
+ } else if (plan.dependencies.length > 0 || plan.devDependencies.length > 0) result.modifiedFiles.push("package.json");
3067
+ await writeOperationSummary(operation, {
3068
+ ...summaryBase,
3069
+ status: "completed",
3070
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString()
3071
+ });
3072
+ } catch (error) {
3073
+ await writeOperationSummary(operation, {
3074
+ ...summaryBase,
3075
+ status: "failed",
3076
+ finishedAt: (/* @__PURE__ */ new Date()).toISOString()
3077
+ }).catch(() => {});
3078
+ throw error;
3079
+ }
3080
+ return result;
3081
+ }
3082
+ //#endregion
3083
+ //#region src/engine/verify-project.ts
3084
+ /**
3085
+ * Verifies the project after installation. Only runs scripts that actually
3086
+ * exist in package.json; never assumes every project has build or typecheck.
3087
+ */
3088
+ async function verifyProject(context, plan, options) {
3089
+ const checks = [];
3090
+ const root = context.rootDirectory;
3091
+ const installed = context.installedPackages;
3092
+ const missingPackages = [...plan.dependencies, ...plan.devDependencies].map((p) => p.name).filter((name) => !(name in installed));
3093
+ checks.push(missingPackages.length === 0 ? {
3094
+ name: "Expected packages found",
3095
+ status: "passed"
3096
+ } : {
3097
+ name: "Expected packages found",
3098
+ status: "failed",
3099
+ detail: `Missing from package.json: ${missingPackages.join(", ")}`
3100
+ });
3101
+ const missingFiles = [];
3102
+ for (const file of plan.filesToCreate) try {
3103
+ await fs.access(path.join(root, file.path));
3104
+ } catch {
3105
+ missingFiles.push(file.path);
3106
+ }
3107
+ checks.push(missingFiles.length === 0 ? {
3108
+ name: "Expected files exist",
3109
+ status: "passed"
3110
+ } : {
3111
+ name: "Expected files exist",
3112
+ status: "failed",
3113
+ detail: `Missing: ${missingFiles.join(", ")} (existing files may have been kept instead)`
3114
+ });
3115
+ const configFiles = ["package.json", "tsconfig.json"].filter((file) => context.detectedFiles.includes(file));
3116
+ let configsOk = true;
3117
+ const configProblems = [];
3118
+ for (const file of configFiles) try {
3119
+ const raw = await fs.readFile(path.join(root, file), "utf8");
3120
+ const errors = [];
3121
+ parse(raw, errors, { allowTrailingComma: true });
3122
+ if (errors.length > 0) {
3123
+ configsOk = false;
3124
+ configProblems.push(file);
3125
+ }
3126
+ } catch {
3127
+ configsOk = false;
3128
+ configProblems.push(file);
3129
+ }
3130
+ checks.push(configsOk ? {
3131
+ name: "Configuration files parsed",
3132
+ status: "passed"
3133
+ } : {
3134
+ name: "Configuration files parsed",
3135
+ status: "failed",
3136
+ detail: `Could not parse: ${configProblems.join(", ")}`
3137
+ });
3138
+ const scripts = context.packageJson.scripts ?? {};
3139
+ for (const scriptName of ["typecheck", "build"]) {
3140
+ const label = scriptName === "typecheck" ? "TypeScript check" : "Production build";
3141
+ if (!(scriptName in scripts)) {
3142
+ checks.push({
3143
+ name: label,
3144
+ status: "skipped",
3145
+ detail: `No "${scriptName}" script.`
3146
+ });
3147
+ continue;
3148
+ }
3149
+ if (!options.runCommands) {
3150
+ checks.push({
3151
+ name: label,
3152
+ status: "skipped",
3153
+ detail: "Command checks were not requested."
3154
+ });
3155
+ continue;
3156
+ }
3157
+ const cmd = {
3158
+ command: context.packageManager,
3159
+ args: ["run", scriptName],
3160
+ cwd: root
3161
+ };
3162
+ const result = await options.runner(cmd);
3163
+ checks.push(result.exitCode === 0 ? {
3164
+ name: label,
3165
+ status: "passed"
3166
+ } : {
3167
+ name: label,
3168
+ status: "failed",
3169
+ detail: `${formatCommand(cmd)} exited with code ${result.exitCode}.`
3170
+ });
3171
+ }
3172
+ return checks;
3173
+ }
3174
+ //#endregion
3175
+ //#region src/package-manager/execute.ts
3176
+ /**
3177
+ * Runs a command with execa. The executable and arguments are always passed
3178
+ * separately; no shell is used. Interactive commands inherit stdio so
3179
+ * official tools can show their own prompts.
3180
+ */
3181
+ const realCommandRunner = async (cmd) => {
3182
+ const debug = process.env.STACKPACK_DEBUG === "1";
3183
+ if (debug) console.error(`[debug] spawning: ${cmd.command} ${cmd.args.join(" ")} (cwd=${cmd.cwd})`);
3184
+ const subprocess = execa(cmd.command, cmd.args, {
3185
+ cwd: cmd.cwd,
3186
+ stdio: cmd.interactive ? "inherit" : "pipe",
3187
+ reject: false
3188
+ });
3189
+ if (debug) console.error(`[debug] spawned pid: ${subprocess.pid}`);
3190
+ const result = await subprocess;
3191
+ if (debug) console.error(`[debug] resolved exitCode: ${result.exitCode}`);
3192
+ return {
3193
+ exitCode: typeof result.exitCode === "number" ? result.exitCode : 1,
3194
+ stdout: typeof result.stdout === "string" ? result.stdout : "",
3195
+ stderr: typeof result.stderr === "string" ? result.stderr : ""
3196
+ };
3197
+ };
3198
+ //#endregion
3199
+ //#region src/package-manager/registry.ts
3200
+ const cache = /* @__PURE__ */ new Map();
3201
+ /**
3202
+ * Resolves the exact version a spec (e.g. "latest" or "^5") would install
3203
+ * right now, via the npm registry. Returns null when offline or unknown so
3204
+ * callers can fall back to showing the spec.
3205
+ */
3206
+ async function resolveRegistryVersion(name, spec) {
3207
+ const key = `${name}@${spec}`;
3208
+ const cached = cache.get(key);
3209
+ if (cached !== void 0) return cached;
3210
+ let version = null;
3211
+ try {
3212
+ const result = await execa("npm", [
3213
+ "view",
3214
+ key,
3215
+ "version",
3216
+ "--json"
3217
+ ], {
3218
+ timeout: 15e3,
3219
+ reject: false
3220
+ });
3221
+ const parsed = JSON.parse(typeof result.stdout === "string" ? result.stdout : "null");
3222
+ if (typeof parsed === "string") version = parsed;
3223
+ else if (Array.isArray(parsed)) {
3224
+ const last = parsed[parsed.length - 1];
3225
+ if (typeof last === "string") version = last;
3226
+ }
3227
+ } catch {
3228
+ version = null;
3229
+ }
3230
+ cache.set(key, version);
3231
+ return version;
3232
+ }
3233
+ /** Resolves many specs in parallel; entries that fail are simply absent. */
3234
+ async function resolveRegistryVersions(entries) {
3235
+ const resolved = /* @__PURE__ */ new Map();
3236
+ await Promise.all(entries.map(async ({ name, version }) => {
3237
+ const exact = await resolveRegistryVersion(name, version);
3238
+ if (exact !== null) resolved.set(name, exact);
3239
+ }));
3240
+ return resolved;
3241
+ }
3242
+ //#endregion
3243
+ //#region src/ui/messages.ts
3244
+ function frameworkLabel(context) {
3245
+ switch (context.framework) {
3246
+ case "react": return "React";
3247
+ case "next": return "Next.js";
3248
+ default: return "Unknown";
3249
+ }
3250
+ }
3251
+ function buildToolLabel(context) {
3252
+ switch (context.buildTool) {
3253
+ case "vite": return "Vite";
3254
+ case "next": return "Next.js";
3255
+ default: return "Unknown";
3256
+ }
3257
+ }
3258
+ function languageLabel(context) {
3259
+ switch (context.language) {
3260
+ case "typescript": return "TypeScript";
3261
+ case "javascript": return "JavaScript";
3262
+ default: return "Unknown";
3263
+ }
3264
+ }
3265
+ function routerLabel(context) {
3266
+ switch (context.routerType) {
3267
+ case "app-router": return "App Router";
3268
+ case "pages-router": return "Pages Router";
3269
+ case "client-router": return "Client-side router";
3270
+ default: return;
3271
+ }
3272
+ }
3273
+ function dependencySection(title, packages) {
3274
+ const entries = Object.entries(packages);
3275
+ if (entries.length === 0) return void 0;
3276
+ const cap = 12;
3277
+ const shown = entries.slice(0, cap).map(([name, version]) => ` ${name} ${pc.green(version)}`).join("\n");
3278
+ const more = entries.length > cap ? `\n ${pc.dim(`…and ${entries.length - cap} more`)}` : "";
3279
+ return `${pc.dim(title)}\n${shown}${more}`;
3280
+ }
3281
+ function describeContext(context) {
3282
+ const lines = [
3283
+ `${pc.dim("Framework")}\n ${frameworkLabel(context)}`,
3284
+ `${pc.dim("Build tool")}\n ${buildToolLabel(context)}`,
3285
+ `${pc.dim("Language")}\n ${languageLabel(context)}`
3286
+ ];
3287
+ const router = routerLabel(context);
3288
+ if (router) lines.push(`${pc.dim("Router")}\n ${router}`);
3289
+ lines.push(`${pc.dim("Package manager")}\n ${context.packageManager}`);
3290
+ const dependencies = dependencySection("Dependencies", context.packageJson.dependencies ?? {});
3291
+ if (dependencies) lines.push(dependencies);
3292
+ const devDependencies = dependencySection("Development dependencies", context.packageJson.devDependencies ?? {});
3293
+ if (devDependencies) lines.push(devDependencies);
3294
+ return lines.join("\n");
3295
+ }
3296
+ //#endregion
3297
+ //#region src/ui/review.ts
3298
+ function section(title, lines) {
3299
+ if (lines.length === 0) return [];
3300
+ return [
3301
+ "",
3302
+ pc.bold(title),
3303
+ ...lines.map((line) => ` ${line}`)
3304
+ ];
3305
+ }
3306
+ function dependencyLine(dep, registryVersions) {
3307
+ const exact = registryVersions?.get(dep.name);
3308
+ const version = exact && exact !== dep.resolvedVersion ? `${dep.name}@${dep.resolvedVersion} ${pc.green(`→ ${exact}`)}` : `${dep.name}@${pc.green(dep.resolvedVersion)}`;
3309
+ return `${pc.green("+")} ${version} ${pc.dim(`(${dep.requestedBy.join(", ")})`)}`;
3310
+ }
3311
+ /** Renders the full installation plan for the review screen. */
3312
+ function renderPlanReview(plan, registryVersions) {
3313
+ const lines = [];
3314
+ lines.push(pc.bold("Base project"));
3315
+ lines.push(` ${frameworkLabel(plan.context)} with ${buildToolLabel(plan.context)}`);
3316
+ lines.push(` ${languageLabel(plan.context)}`);
3317
+ lines.push(` ${plan.context.packageManager}`);
3318
+ const integrationLines = plan.integrations.map(({ recipe, options }) => {
3319
+ const detail = recipe.describeOptions?.(options);
3320
+ return detail ? `${recipe.name} (${detail})` : recipe.name;
3321
+ });
3322
+ lines.push(...section("Selected integrations", integrationLines));
3323
+ lines.push(...section("Dependencies", plan.dependencies.map((dep) => dependencyLine(dep, registryVersions))));
3324
+ lines.push(...section("Development dependencies", plan.devDependencies.map((dep) => dependencyLine(dep, registryVersions))));
3325
+ lines.push(...section("Official initializers (delegated changes)", plan.initializers.map((entry) => `${entry.name} — ${entry.initializer.description}`)));
3326
+ lines.push(...section("Files StackPack plans to create", plan.filesToCreate.map((file) => `${pc.green("+")} ${file.path}`)));
3327
+ lines.push(...section("Files StackPack plans to modify", plan.filesToModify.map((file) => `${pc.yellow("~")} ${file}`)));
3328
+ lines.push(...section("Scripts", plan.scripts.map((script) => `${script.name}: ${script.command}`)));
3329
+ lines.push(...section("Existing files that conflict", plan.existingFileConflicts.map((file) => `${pc.yellow("⚠")} ${file} already exists`)));
3330
+ lines.push(...section("Script conflicts", plan.scriptConflicts.map((conflict) => `${pc.yellow("⚠")} "${conflict.name}" exists (current: ${conflict.current}, proposed: ${conflict.proposed})`)));
3331
+ lines.push(...section("Unresolved version conflicts", plan.conflicts.map((conflict) => `${pc.red("✗")} ${conflict.message}`)));
3332
+ lines.push(...section("Warnings", plan.warnings.map((warning) => `${pc.yellow("⚠")} ${warning}`)));
3333
+ if (plan.initializers.length > 0) {
3334
+ lines.push("");
3335
+ lines.push(pc.dim("StackPack lists its own exact changes above; official initializers control their own additional changes."));
3336
+ }
3337
+ return lines.join("\n");
3338
+ }
3339
+ //#endregion
3340
+ //#region src/utils/names.ts
3341
+ const WINDOWS_RESERVED = /* @__PURE__ */ new Set([
3342
+ "con",
3343
+ "prn",
3344
+ "aux",
3345
+ "nul",
3346
+ ...Array.from({ length: 9 }, (_, i) => `com${i + 1}`),
3347
+ ...Array.from({ length: 9 }, (_, i) => `lpt${i + 1}`)
3348
+ ]);
3349
+ /** Validates a project folder name: safe, portable, no path traversal. */
3350
+ function validateProjectName(input) {
3351
+ const name = input.trim();
3352
+ if (name.length === 0) return {
3353
+ ok: false,
3354
+ reason: "Project name is required."
3355
+ };
3356
+ if (name.length > 214) return {
3357
+ ok: false,
3358
+ reason: "Project name is too long."
3359
+ };
3360
+ if (name === "." || name === "..") return {
3361
+ ok: false,
3362
+ reason: "Project name cannot be a relative path."
3363
+ };
3364
+ if (/[/\\]/.test(name)) return {
3365
+ ok: false,
3366
+ reason: "Project name cannot contain path separators."
3367
+ };
3368
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name)) return {
3369
+ ok: false,
3370
+ reason: "Use letters, numbers, dots, dashes, and underscores, starting with a letter or number."
3371
+ };
3372
+ if (name.includes("..")) return {
3373
+ ok: false,
3374
+ reason: "Project name cannot contain path traversal sequences."
3375
+ };
3376
+ if (WINDOWS_RESERVED.has(name.toLowerCase().split(".")[0] ?? "")) return {
3377
+ ok: false,
3378
+ reason: `"${name}" is a reserved name on Windows.`
3379
+ };
3380
+ return { ok: true };
3381
+ }
3382
+ /** Validates a preset name so it can never become an arbitrary filesystem path. */
3383
+ function validatePresetName(input) {
3384
+ const name = input.trim();
3385
+ if (name.length === 0) return {
3386
+ ok: false,
3387
+ reason: "Preset name is required."
3388
+ };
3389
+ if (name.length > 100) return {
3390
+ ok: false,
3391
+ reason: "Preset name is too long."
3392
+ };
3393
+ if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(name) || name.includes("..")) return {
3394
+ ok: false,
3395
+ reason: "Use letters, numbers, dots, dashes, and underscores, starting with a letter or number."
3396
+ };
3397
+ return { ok: true };
3398
+ }
3399
+ const versionValueSchema = z.string().refine((value) => parseVersionSpec(value) !== null, "Invalid npm version, range, or tag.");
3400
+ const packageNameSchema = z.string().refine((value) => validatePackageName(value).validForNewPackages, "Invalid npm package name.");
3401
+ const packageMapSchema = z.record(packageNameSchema, versionValueSchema);
3402
+ const presetSchema = z.object({
3403
+ schemaVersion: z.literal(1),
3404
+ name: z.string().refine((value) => validatePresetName(value).ok, "Unsafe preset name."),
3405
+ displayName: z.string().max(120).optional(),
3406
+ scope: z.enum(["global", "local"]),
3407
+ createdAt: z.string(),
3408
+ updatedAt: z.string(),
3409
+ base: z.object({
3410
+ creator: z.enum([
3411
+ "vite",
3412
+ "next",
3413
+ "vite-react"
3414
+ ]).transform((value) => value === "vite-react" ? "vite" : value),
3415
+ language: z.enum(["typescript", "javascript"]),
3416
+ creatorOptions: z.record(z.string(), z.unknown()).default({})
3417
+ }),
3418
+ project: z.object({
3419
+ framework: z.enum(["react", "next"]),
3420
+ buildTool: z.enum(["vite", "next"]),
3421
+ language: z.enum(["typescript", "javascript"])
3422
+ }),
3423
+ integrations: z.array(z.object({
3424
+ id: z.string().regex(/^[a-z0-9-]+$/),
3425
+ recipeVersion: z.number().int().positive(),
3426
+ options: z.record(z.string(), z.unknown()).default({})
3427
+ })).default([]),
3428
+ customPackages: z.object({
3429
+ dependencies: packageMapSchema.default({}),
3430
+ devDependencies: packageMapSchema.default({})
3431
+ }).default({
3432
+ dependencies: {},
3433
+ devDependencies: {}
3434
+ }),
3435
+ versionOverrides: packageMapSchema.default({})
3436
+ });
3437
+ //#endregion
3438
+ //#region src/storage/preset-store.ts
3439
+ function presetDirFor(scope, options) {
3440
+ if (scope === "global") return getStoragePaths(options.globalBaseDir).presetsDir;
3441
+ if (!options.projectRoot) throw new StackPackError("A project directory is required for local presets.");
3442
+ return getProjectLocalPresetsDir(options.projectRoot);
3443
+ }
3444
+ function presetFilePath(name, scope, options) {
3445
+ const validation = validatePresetName(name);
3446
+ if (!validation.ok) throw new StackPackError(`Invalid preset name: ${validation.reason}`);
3447
+ const dir = presetDirFor(scope, options);
3448
+ const filePath = path.resolve(dir, `${name}.json`);
3449
+ if (path.dirname(filePath) !== path.resolve(dir)) throw new StackPackError("Preset name resolved outside the preset directory.");
3450
+ return filePath;
3451
+ }
3452
+ function parsePresetJson(raw, source) {
3453
+ let data;
3454
+ try {
3455
+ data = JSON.parse(raw);
3456
+ } catch (error) {
3457
+ throw new StackPackError(`Preset file is not valid JSON: ${source}`, { cause: error });
3458
+ }
3459
+ const versionProbe = data;
3460
+ if (typeof versionProbe?.schemaVersion === "number" && versionProbe.schemaVersion > 1) throw new StackPackError("This preset was created by a newer StackPack version. Update StackPack before using it.");
3461
+ const parsed = presetSchema.safeParse(data);
3462
+ if (!parsed.success) throw new StackPackError(`Preset file failed validation: ${source}`, { hints: parsed.error.issues.slice(0, 5).map((i) => `${i.path.join(".")}: ${i.message}`) });
3463
+ return parsed.data;
3464
+ }
3465
+ async function listPresets(options = {}) {
3466
+ const results = [];
3467
+ const scopes = options.projectRoot ? ["local", "global"] : ["global"];
3468
+ for (const scope of scopes) {
3469
+ const dir = presetDirFor(scope, options);
3470
+ let entries;
3471
+ try {
3472
+ entries = await fs.readdir(dir);
3473
+ } catch {
3474
+ continue;
3475
+ }
3476
+ for (const entry of entries.sort()) {
3477
+ if (!entry.endsWith(".json")) continue;
3478
+ const name = entry.slice(0, -5);
3479
+ if (!validatePresetName(name).ok) continue;
3480
+ results.push({
3481
+ name,
3482
+ scope,
3483
+ filePath: path.join(dir, entry)
3484
+ });
3485
+ }
3486
+ }
3487
+ return results;
3488
+ }
3489
+ async function findPreset(name, options = {}) {
3490
+ return (await listPresets(options)).find((p) => p.name === name) ?? null;
3491
+ }
3492
+ async function loadPreset(name, options = {}) {
3493
+ const location = await findPreset(name, options);
3494
+ if (!location) throw new StackPackError(`Preset "${name}" was not found.`, { hints: ["Run: stackpack presets list"] });
3495
+ return {
3496
+ preset: parsePresetJson(await fs.readFile(location.filePath, "utf8"), location.filePath),
3497
+ location
3498
+ };
3499
+ }
3500
+ async function presetExists(name, scope, options = {}) {
3501
+ try {
3502
+ await fs.access(presetFilePath(name, scope, options));
3503
+ return true;
3504
+ } catch {
3505
+ return false;
3506
+ }
3507
+ }
3508
+ /**
3509
+ * Atomically saves a preset: writes a temporary file, re-validates it,
3510
+ * then renames it into place.
3511
+ */
3512
+ async function savePreset(preset, scope, options = {}) {
3513
+ const validated = presetSchema.parse(preset);
3514
+ const filePath = presetFilePath(validated.name, scope, options);
3515
+ const dir = path.dirname(filePath);
3516
+ await fs.mkdir(dir, { recursive: true });
3517
+ const tmpPath = path.join(dir, `.${validated.name}.${crypto.randomUUID()}.tmp`);
3518
+ const serialized = JSON.stringify(validated, null, 2) + "\n";
3519
+ await fs.writeFile(tmpPath, serialized, "utf8");
3520
+ try {
3521
+ parsePresetJson(await fs.readFile(tmpPath, "utf8"), tmpPath);
3522
+ await fs.rename(tmpPath, filePath);
3523
+ } catch (error) {
3524
+ await fs.rm(tmpPath, { force: true });
3525
+ throw error;
3526
+ }
3527
+ return {
3528
+ name: validated.name,
3529
+ scope,
3530
+ filePath
3531
+ };
3532
+ }
3533
+ async function deletePreset(name, options = {}) {
3534
+ const location = await findPreset(name, options);
3535
+ if (!location) throw new StackPackError(`Preset "${name}" was not found.`);
3536
+ await fs.rm(location.filePath);
3537
+ return location;
3538
+ }
3539
+ //#endregion
3540
+ //#region src/commands/shared.ts
3541
+ async function resolveScriptConflicts(plan) {
3542
+ const resolutions = {};
3543
+ for (const conflict of plan.scriptConflicts) {
3544
+ const alternate = `${conflict.name}:${conflict.proposed.split(" ")[0]}`;
3545
+ const choice = guard(await p.select({
3546
+ message: `Script "${conflict.name}" already exists (current: ${conflict.current})`,
3547
+ options: [
3548
+ {
3549
+ value: "alternate",
3550
+ label: `Add as ${alternate}`
3551
+ },
3552
+ {
3553
+ value: "replace",
3554
+ label: `Replace existing ${conflict.name} script`
3555
+ },
3556
+ {
3557
+ value: "skip",
3558
+ label: "Keep current script"
3559
+ },
3560
+ {
3561
+ value: "cancel",
3562
+ label: "Cancel installation"
3563
+ }
3564
+ ]
3565
+ }));
3566
+ if (choice === "cancel") return "cancel";
3567
+ resolutions[conflict.name] = choice;
3568
+ }
3569
+ return resolutions;
3570
+ }
3571
+ async function resolveFileConflicts(plan) {
3572
+ const resolutions = {};
3573
+ for (const filePath of plan.existingFileConflicts) {
3574
+ const planned = plan.filesToCreate.find((file) => file.path === filePath);
3575
+ const required = planned?.requiredReason;
3576
+ for (;;) {
3577
+ const options = required ? [
3578
+ {
3579
+ value: "replace",
3580
+ label: "Replace after backup (recommended)",
3581
+ hint: required
3582
+ },
3583
+ {
3584
+ value: "show",
3585
+ label: "Show proposed contents"
3586
+ },
3587
+ {
3588
+ value: "rename",
3589
+ label: "Create with another name",
3590
+ hint: "adds a .stackpack suffix"
3591
+ },
3592
+ {
3593
+ value: "keep",
3594
+ label: "Keep existing file",
3595
+ hint: "warning: the integration will not work"
3596
+ },
3597
+ {
3598
+ value: "cancel",
3599
+ label: "Cancel installation"
3600
+ }
3601
+ ] : [
3602
+ {
3603
+ value: "keep",
3604
+ label: "Keep existing file",
3605
+ hint: "planned file is skipped"
3606
+ },
3607
+ {
3608
+ value: "show",
3609
+ label: "Show proposed contents"
3610
+ },
3611
+ {
3612
+ value: "rename",
3613
+ label: "Create with another name",
3614
+ hint: "adds a .stackpack suffix"
3615
+ },
3616
+ {
3617
+ value: "replace",
3618
+ label: "Replace after backup"
3619
+ },
3620
+ {
3621
+ value: "cancel",
3622
+ label: "Cancel installation"
3623
+ }
3624
+ ];
3625
+ const choice = guard(await p.select({
3626
+ message: `${filePath} already exists`,
3627
+ options
3628
+ }));
3629
+ if (choice === "show") {
3630
+ p.note(planned?.contents ?? "(unknown contents)", filePath);
3631
+ continue;
3632
+ }
3633
+ if (choice === "cancel") return "cancel";
3634
+ resolutions[filePath] = choice;
3635
+ break;
3636
+ }
3637
+ }
3638
+ return resolutions;
3639
+ }
3640
+ async function runVerification(refreshed, plan, runner, applyResult) {
3641
+ const runCommands = guard(await p.confirm({
3642
+ message: "Run available verification commands (typecheck/build)? This can take a while.",
3643
+ initialValue: true
3644
+ }));
3645
+ for (;;) {
3646
+ const spinner = p.spinner();
3647
+ spinner.start("Verifying project...");
3648
+ const checks = await verifyProject(refreshed, plan, {
3649
+ runner,
3650
+ runCommands
3651
+ });
3652
+ spinner.stop("Verification finished");
3653
+ for (const check of checks) {
3654
+ const symbol = check.status === "passed" ? pc.green("✓") : check.status === "failed" ? pc.red("✗") : pc.dim("-");
3655
+ console.log(` ${symbol} ${check.name}${check.detail ? pc.dim(` — ${check.detail}`) : ""}`);
3656
+ }
3657
+ if (checks.filter((check) => check.status === "failed").length === 0) return;
3658
+ const choice = guard(await p.select({
3659
+ message: "Project verification failed. What would you like to do?",
3660
+ options: [
3661
+ {
3662
+ value: "keep",
3663
+ label: "Keep changes",
3664
+ hint: "details are shown above"
3665
+ },
3666
+ ...applyResult.operationDir ? [{
3667
+ value: "restore",
3668
+ label: "Restore backed-up files"
3669
+ }] : [],
3670
+ {
3671
+ value: "retry",
3672
+ label: "Retry verification"
3673
+ }
3674
+ ]
3675
+ }));
3676
+ if (choice === "keep") return;
3677
+ if (choice === "restore") {
3678
+ const restored = await restoreBackup(refreshed.rootDirectory, path.join(applyResult.operationDir, "files"));
3679
+ p.log.success(`Restored ${restored.length} file(s) from backup.`);
3680
+ p.log.warn("Installed packages are not automatically removed; review package.json.");
3681
+ return;
3682
+ }
3683
+ }
3684
+ }
3685
+ /**
3686
+ * The shared review -> confirm -> install -> verify flow used by new, add,
3687
+ * and apply. Nothing is installed before the final confirmation.
3688
+ */
3689
+ async function reviewAndInstall(params) {
3690
+ const runner = params.runner ?? realCommandRunner;
3691
+ for (;;) {
3692
+ const plan = buildInstallationPlan(params.context, params.selection);
3693
+ const plannedPackages = [...plan.dependencies, ...plan.devDependencies];
3694
+ let registryVersions;
3695
+ if (plannedPackages.length > 0) {
3696
+ const spinner = p.spinner();
3697
+ spinner.start("Checking exact versions on the npm registry...");
3698
+ registryVersions = await resolveRegistryVersions(plannedPackages.map((dep) => ({
3699
+ name: dep.name,
3700
+ version: dep.resolvedVersion
3701
+ })));
3702
+ spinner.stop(registryVersions.size > 0 ? "Exact versions resolved from the npm registry" : "Could not reach the npm registry; showing version ranges only");
3703
+ }
3704
+ p.note(renderPlanReview(plan, registryVersions), "Review StackPack setup");
3705
+ if (plan.dependencies.length === 0 && plan.devDependencies.length === 0 && plan.initializers.length === 0 && plan.filesToCreate.length === 0) {
3706
+ p.log.warn("Nothing is selected yet.");
3707
+ return params.allowReturnToDashboard ? "back" : "cancelled";
3708
+ }
3709
+ const action = guard(await p.select({
3710
+ message: "Choose an action",
3711
+ options: [
3712
+ params.dryRun ? {
3713
+ value: "install",
3714
+ label: "Finish dry run",
3715
+ hint: "no changes will be made"
3716
+ } : {
3717
+ value: "install",
3718
+ label: "Install everything"
3719
+ },
3720
+ ...params.allowReturnToDashboard ? [{
3721
+ value: "back",
3722
+ label: "Return to integration dashboard"
3723
+ }] : [],
3724
+ {
3725
+ value: "versions",
3726
+ label: "Edit package versions"
3727
+ },
3728
+ {
3729
+ value: "cancel",
3730
+ label: "Cancel"
3731
+ }
3732
+ ]
3733
+ }));
3734
+ if (action === "cancel") return "cancelled";
3735
+ if (action === "back") return "back";
3736
+ if (action === "versions") {
3737
+ await runVersionEditor(params.selection);
3738
+ continue;
3739
+ }
3740
+ if (plan.conflicts.length > 0) {
3741
+ p.log.error("Version conflicts must be resolved before installing:");
3742
+ for (const conflict of plan.conflicts) p.log.message(` ${conflict.message}`);
3743
+ continue;
3744
+ }
3745
+ if (params.dryRun) {
3746
+ const result = await applyPlan(plan, {
3747
+ dryRun: true,
3748
+ runner
3749
+ });
3750
+ p.note([
3751
+ "Commands that would run:",
3752
+ ...result.commandsRun.map((cmd) => ` ${cmd}`),
3753
+ "Files that would be created:",
3754
+ ...result.createdFiles.length > 0 ? result.createdFiles.map((file) => ` + ${file}`) : [" (none)"],
3755
+ "Files that would be modified:",
3756
+ ...result.modifiedFiles.length > 0 ? result.modifiedFiles.map((file) => ` ~ ${file}`) : [" (none)"]
3757
+ ].join("\n"), "Dry run");
3758
+ p.log.success("Dry run complete. No project files were changed.");
3759
+ return "dry-run";
3760
+ }
3761
+ const scriptResolutions = await resolveScriptConflicts(plan);
3762
+ if (scriptResolutions === "cancel") continue;
3763
+ const fileResolutions = await resolveFileConflicts(plan);
3764
+ if (fileResolutions === "cancel") continue;
3765
+ let applyResult;
3766
+ const spinner = p.spinner();
3767
+ const interactive = plan.initializers.length > 0;
3768
+ try {
3769
+ if (!interactive) spinner.start("Installing...");
3770
+ applyResult = await applyPlan(plan, {
3771
+ dryRun: false,
3772
+ runner,
3773
+ scriptResolutions,
3774
+ fileResolutions,
3775
+ onProgress: (message) => {
3776
+ if (interactive) p.log.step(message);
3777
+ else spinner.message(message);
3778
+ }
3779
+ });
3780
+ if (!interactive) spinner.stop("Installation finished");
3781
+ } catch (error) {
3782
+ if (!interactive) spinner.stop("Installation failed");
3783
+ throw error;
3784
+ }
3785
+ if (applyResult.createdFiles.length > 0) p.log.success(`Created: ${applyResult.createdFiles.join(", ")}`);
3786
+ if (applyResult.modifiedFiles.length > 0) p.log.success(`Modified: ${[...new Set(applyResult.modifiedFiles)].join(", ")}`);
3787
+ for (const note of applyResult.notes) p.log.message(pc.dim(note));
3788
+ await runVerification(applyResult.refreshedContext ?? await detectProject(params.context.rootDirectory, { packageManagerOverride: params.context.packageManager }), plan, runner, applyResult);
3789
+ return "installed";
3790
+ }
3791
+ }
3792
+ /** Builds a preset from the current context and selection. */
3793
+ function buildPresetFromSelection(params) {
3794
+ const { context, selection } = params;
3795
+ if (context.framework === "unknown" || context.language === "unknown") return null;
3796
+ const buildTool = context.buildTool === "unknown" ? void 0 : context.buildTool;
3797
+ if (!buildTool) return null;
3798
+ const now = (/* @__PURE__ */ new Date()).toISOString();
3799
+ const dependencies = {};
3800
+ const devDependencies = {};
3801
+ for (const pkg of selection.customPackages) (pkg.dependencyType === "dependency" ? dependencies : devDependencies)[pkg.name] = pkg.version;
3802
+ return {
3803
+ schemaVersion: 1,
3804
+ name: params.name,
3805
+ displayName: params.name,
3806
+ scope: params.scope,
3807
+ createdAt: now,
3808
+ updatedAt: now,
3809
+ base: {
3810
+ creator: context.framework === "next" ? "next" : "vite",
3811
+ language: context.language,
3812
+ creatorOptions: {}
3813
+ },
3814
+ project: {
3815
+ framework: context.framework,
3816
+ buildTool,
3817
+ language: context.language
3818
+ },
3819
+ integrations: selectedRecipes(selection).map(({ recipe, options }) => ({
3820
+ id: recipe.id,
3821
+ recipeVersion: recipe.recipeVersion,
3822
+ options
3823
+ })),
3824
+ customPackages: {
3825
+ dependencies,
3826
+ devDependencies
3827
+ },
3828
+ versionOverrides: selection.versionOverrides
3829
+ };
3830
+ }
3831
+ /** Post-install offer to save the applied setup as a preset. */
3832
+ async function offerToSavePreset(context, selection) {
3833
+ if (context.framework === "unknown" || context.language === "unknown") return;
3834
+ if (!guard(await p.confirm({
3835
+ message: "Save this setup as a preset?",
3836
+ initialValue: false
3837
+ }))) return;
3838
+ const name = guard(await p.text({
3839
+ message: "Preset name",
3840
+ placeholder: "my-react-stack",
3841
+ validate(value) {
3842
+ const result = validatePresetName(value ?? "");
3843
+ return result.ok ? void 0 : result.reason;
3844
+ }
3845
+ })).trim();
3846
+ const scope = guard(await p.select({
3847
+ message: "Preset location",
3848
+ options: [{
3849
+ value: "global",
3850
+ label: "Global, available on this device"
3851
+ }, {
3852
+ value: "local",
3853
+ label: "Local, stored inside this project",
3854
+ hint: ".stackpack/"
3855
+ }]
3856
+ }));
3857
+ const storeOptions = { projectRoot: context.rootDirectory };
3858
+ if (await presetExists(name, scope, storeOptions)) {
3859
+ if (guard(await p.select({
3860
+ message: `Preset "${name}" already exists`,
3861
+ options: [{
3862
+ value: "cancel",
3863
+ label: "Keep the existing preset"
3864
+ }, {
3865
+ value: "replace",
3866
+ label: "Replace it"
3867
+ }]
3868
+ })) === "cancel") return;
3869
+ }
3870
+ const preset = buildPresetFromSelection({
3871
+ name,
3872
+ scope,
3873
+ context,
3874
+ selection
3875
+ });
3876
+ if (!preset) {
3877
+ p.log.warn("This project cannot be represented as a preset (unknown framework or language).");
3878
+ return;
3879
+ }
3880
+ const location = await savePreset(preset, scope, storeOptions);
3881
+ p.log.success(`Preset saved\n Name: ${name}\n Location: ${location.filePath}`);
3882
+ }
3883
+ //#endregion
3884
+ //#region src/commands/flow.ts
3885
+ /**
3886
+ * Dashboard <-> review loop shared by new, add, and apply. The user can jump
3887
+ * back and forth until they install, finish a dry run, or cancel.
3888
+ */
3889
+ async function dashboardInstallLoop(params) {
3890
+ let atReview = params.startAtReview === true;
3891
+ for (;;) {
3892
+ if (!atReview) await runDashboard(params.context, params.selection);
3893
+ const result = await reviewAndInstall({
3894
+ context: params.context,
3895
+ selection: params.selection,
3896
+ dryRun: params.dryRun,
3897
+ runner: params.runner,
3898
+ allowReturnToDashboard: true
3899
+ });
3900
+ if (result === "back") {
3901
+ atReview = false;
3902
+ continue;
3903
+ }
3904
+ if (result === "installed") {
3905
+ await offerToSavePreset(params.context, params.selection);
3906
+ return "installed";
3907
+ }
3908
+ return result === "dry-run" ? "dry-run" : "cancelled";
3909
+ }
3910
+ }
3911
+ //#endregion
3912
+ //#region src/commands/selection-utils.ts
3913
+ /** Places an integration into its category slot in the selection. */
3914
+ function addToSelection(selection, id, options) {
3915
+ const recipe = getRecipe(id);
3916
+ if (!recipe) return false;
3917
+ const entry = {
3918
+ id,
3919
+ options
3920
+ };
3921
+ switch (recipe.category) {
3922
+ case "routing":
3923
+ selection.routing = entry;
3924
+ return true;
3925
+ case "state-management":
3926
+ selection.stateManagement = entry;
3927
+ return true;
3928
+ case "data-fetching":
3929
+ selection.dataFetching = entry;
3930
+ return true;
3931
+ case "forms-validation":
3932
+ selection.formsAndValidation = entry;
3933
+ return true;
3934
+ case "ui":
3935
+ selection.ui = entry;
3936
+ return true;
3937
+ case "orm":
3938
+ selection.orm = entry;
3939
+ return true;
3940
+ case "testing":
3941
+ if (!selection.testing.some((existing) => existing.id === id)) selection.testing.push(entry);
3942
+ return true;
3943
+ }
3944
+ }
3945
+ /** Rebuilds a dashboard selection from a saved preset. */
3946
+ function presetToSelection(preset) {
3947
+ const selection = createEmptySelection();
3948
+ for (const integration of preset.integrations) if (!addToSelection(selection, integration.id, integration.options)) p.log.warn(`Preset integration "${integration.id}" is not known to this StackPack version and was skipped.`);
3949
+ for (const [name, version] of Object.entries(preset.customPackages.dependencies)) selection.customPackages.push({
3950
+ name,
3951
+ version,
3952
+ dependencyType: "dependency"
3953
+ });
3954
+ for (const [name, version] of Object.entries(preset.customPackages.devDependencies)) selection.customPackages.push({
3955
+ name,
3956
+ version,
3957
+ dependencyType: "devDependency"
3958
+ });
3959
+ selection.versionOverrides = { ...preset.versionOverrides };
3960
+ return selection;
3961
+ }
3962
+ //#endregion
3963
+ //#region src/commands/new.ts
3964
+ async function directoryState(target) {
3965
+ try {
3966
+ return (await fs.readdir(target)).length === 0 ? "empty" : "occupied";
3967
+ } catch {
3968
+ return "missing";
3969
+ }
3970
+ }
3971
+ async function askProjectName(initial) {
3972
+ let candidate = initial;
3973
+ for (;;) {
3974
+ if (!candidate) candidate = guard(await p.text({
3975
+ message: "Project name",
3976
+ placeholder: "my-application",
3977
+ validate(value) {
3978
+ const result = validateProjectName(value ?? "");
3979
+ return result.ok ? void 0 : result.reason;
3980
+ }
3981
+ })).trim();
3982
+ const validation = validateProjectName(candidate);
3983
+ if (!validation.ok) {
3984
+ p.log.error(validation.reason);
3985
+ candidate = void 0;
3986
+ continue;
3987
+ }
3988
+ const destination = path.resolve(process.cwd(), candidate);
3989
+ const state = await directoryState(destination);
3990
+ if (state === "missing") return {
3991
+ name: candidate,
3992
+ destination
3993
+ };
3994
+ if (state === "empty") {
3995
+ const choice = guard(await p.select({
3996
+ message: `Folder "${candidate}" already exists but is empty.`,
3997
+ options: [
3998
+ {
3999
+ value: "use",
4000
+ label: "Use it (it is empty)"
4001
+ },
4002
+ {
4003
+ value: "rename",
4004
+ label: "Enter another name"
4005
+ },
4006
+ {
4007
+ value: "cancel",
4008
+ label: "Cancel"
4009
+ }
4010
+ ]
4011
+ }));
4012
+ if (choice === "use") return {
4013
+ name: candidate,
4014
+ destination
4015
+ };
4016
+ if (choice === "cancel") throw new CancelledError();
4017
+ candidate = void 0;
4018
+ continue;
4019
+ }
4020
+ if (guard(await p.select({
4021
+ message: `Folder "${candidate}" already exists and is not empty.`,
4022
+ options: [{
4023
+ value: "rename",
4024
+ label: "Enter another name"
4025
+ }, {
4026
+ value: "cancel",
4027
+ label: "Cancel"
4028
+ }]
4029
+ })) === "cancel") throw new CancelledError();
4030
+ candidate = void 0;
4031
+ }
4032
+ }
4033
+ async function runOfficialCreator(creator, projectName, creatorOptions, packageManager) {
4034
+ const command = creator.buildCommand(projectName, creatorOptions, packageManager, process.cwd());
4035
+ for (;;) {
4036
+ p.log.step(`Running the official ${creator.officialTool} creator: ${formatCommand(command)}`);
4037
+ const result = await realCommandRunner(command);
4038
+ const generated = await fs.access(path.join(process.cwd(), projectName, "package.json")).then(() => true).catch(() => false);
4039
+ if (result.exitCode === 0 && generated) {
4040
+ p.log.success(`Official ${creator.name} project created`);
4041
+ return;
4042
+ }
4043
+ const reason = result.exitCode === 0 ? "reported success but no package.json was generated — a file lock from an IDE or antivirus may have interrupted it" : `exit code ${result.exitCode}`;
4044
+ const choice = guard(await p.select({
4045
+ message: `Official project creator failed (${creator.officialTool}, ${reason}). The destination folder may contain partial files.`,
4046
+ options: [
4047
+ {
4048
+ value: "retry",
4049
+ label: "Retry"
4050
+ },
4051
+ {
4052
+ value: "keep",
4053
+ label: "Keep partial project and exit"
4054
+ },
4055
+ {
4056
+ value: "exit",
4057
+ label: "Exit"
4058
+ }
4059
+ ]
4060
+ }));
4061
+ if (choice === "retry") continue;
4062
+ if (choice === "keep") throw new StackPackError(`The official creator failed; partial files may exist in "${projectName}". Nothing was deleted.`);
4063
+ throw new CancelledError();
4064
+ }
4065
+ }
4066
+ /**
4067
+ * Creators run with installs skipped so all dependencies land in a single
4068
+ * install at the end. Installing integrations covers the base dependencies
4069
+ * too; this handles every path where that install did not happen.
4070
+ */
4071
+ async function ensureDependenciesInstalled(destination, packageManager) {
4072
+ try {
4073
+ await fs.access(path.join(destination, "node_modules"));
4074
+ return;
4075
+ } catch {}
4076
+ p.log.info("No integrations are being added. The base project's own dependencies were deferred earlier, so they install now — otherwise the app could not run.");
4077
+ p.log.step(`Installing base project dependencies: ${packageManager} install`);
4078
+ const result = await realCommandRunner(baseInstallCommand(packageManager, destination));
4079
+ if (result.exitCode !== 0) p.log.warn(`Dependency installation failed (exit code ${result.exitCode}). Run "${packageManager} install" inside the project to finish setup.`);
4080
+ }
4081
+ async function runNew(projectNameArg, options) {
4082
+ p.intro("StackPack — new project");
4083
+ p.log.message(pc.dim("Official project tooling with real-world integrations."));
4084
+ let preset;
4085
+ if (options.preset) preset = (await loadPreset(options.preset, { projectRoot: process.cwd() })).preset;
4086
+ else {
4087
+ const available = await listPresets({ projectRoot: process.cwd() });
4088
+ if (available.length > 0) {
4089
+ const choice = guard(await p.select({
4090
+ message: "Start from a saved preset?",
4091
+ options: [{
4092
+ value: "__fresh__",
4093
+ label: "No, start fresh",
4094
+ hint: "choose everything yourself"
4095
+ }, ...available.map((entry, index) => ({
4096
+ value: String(index),
4097
+ label: entry.name,
4098
+ hint: entry.scope
4099
+ }))]
4100
+ }));
4101
+ if (choice !== "__fresh__") {
4102
+ const entry = available[Number(choice)];
4103
+ if (entry) preset = (await loadPreset(entry.name, { projectRoot: process.cwd() })).preset;
4104
+ }
4105
+ }
4106
+ }
4107
+ if (preset) {
4108
+ const customEntries = [...Object.entries(preset.customPackages.dependencies), ...Object.entries(preset.customPackages.devDependencies).map(([name, version]) => [name, `${version} (dev)`])];
4109
+ p.note([
4110
+ `Preset\n ${preset.displayName ?? preset.name}`,
4111
+ `Base\n ${getCreator(preset.base.creator).name}\n ${preset.base.language === "typescript" ? "TypeScript" : "JavaScript"}`,
4112
+ `Integrations\n${preset.integrations.length > 0 ? preset.integrations.map((i) => ` ${i.id}`).join("\n") : " (none)"}`,
4113
+ `Other packages\n${customEntries.length > 0 ? customEntries.map(([name, version]) => ` ${name}@${version}`).join("\n") : " (none)"}`
4114
+ ].join("\n\n"), "Preset");
4115
+ p.log.info("Everything above will be shown again with exact versions on the review screen before anything installs.");
4116
+ if (!guard(await p.confirm({
4117
+ message: "Continue?",
4118
+ initialValue: true
4119
+ }))) throw new CancelledError();
4120
+ }
4121
+ const { name, destination } = await askProjectName(projectNameArg);
4122
+ let creator;
4123
+ if (preset) creator = getCreator(preset.base.creator);
4124
+ else {
4125
+ const baseChoice = guard(await p.select({
4126
+ message: "Choose a base project",
4127
+ options: [...allCreators.map((adapter) => ({
4128
+ value: adapter.id,
4129
+ label: adapter.name,
4130
+ hint: `official ${adapter.officialTool}`
4131
+ })), {
4132
+ value: "__back__",
4133
+ label: "Back",
4134
+ hint: "leave without creating anything"
4135
+ }]
4136
+ }));
4137
+ if (baseChoice === "__back__") throw new CancelledError();
4138
+ creator = getCreator(baseChoice);
4139
+ }
4140
+ const config = await loadConfig();
4141
+ const packageManager = options.packageManager ?? config.defaultPackageManager ?? "npm";
4142
+ let creatorOptions;
4143
+ if (preset) creatorOptions = { language: preset.base.language };
4144
+ else if (creator.collectOptions) creatorOptions = await creator.collectOptions();
4145
+ else creatorOptions = { language: guard(await p.select({
4146
+ message: "Choose a language",
4147
+ options: [{
4148
+ value: "typescript",
4149
+ label: "TypeScript"
4150
+ }, {
4151
+ value: "javascript",
4152
+ label: "JavaScript"
4153
+ }]
4154
+ })) };
4155
+ const languageLabel = creatorOptions.language === void 0 ? `You choose in ${creator.officialTool}'s prompts` : creatorOptions.language === "typescript" ? "TypeScript" : "JavaScript";
4156
+ p.note([
4157
+ `Project\n ${name}`,
4158
+ `Official creator\n ${creator.officialTool}`,
4159
+ `Framework\n ${creator.frameworkLabel}`,
4160
+ `Language\n ${languageLabel}`,
4161
+ `Template\n ${creator.templateLabel(creatorOptions)}`,
4162
+ `Package manager\n ${packageManager}`
4163
+ ].join("\n\n"), "Base project");
4164
+ if (!guard(await p.confirm({
4165
+ message: "Create the base project?",
4166
+ initialValue: true
4167
+ }))) throw new CancelledError();
4168
+ await runOfficialCreator(creator, name, creatorOptions, packageManager);
4169
+ p.log.info("Dependency installation is deferred: everything installs in one step after you pick integrations.");
4170
+ const context = await detectProject(destination, { packageManagerOverride: packageManager });
4171
+ p.note(describeContext(context), "Generated project detected");
4172
+ if (context.detection === "unsupported") {
4173
+ p.log.warn("StackPack integrations currently support React (Vite) and Next.js projects, so the integration dashboard is not available for this framework yet.");
4174
+ await ensureDependenciesInstalled(destination, packageManager);
4175
+ p.outro(`Base project created at ${destination}`);
4176
+ return;
4177
+ }
4178
+ if (await dashboardInstallLoop({
4179
+ context,
4180
+ selection: preset ? presetToSelection(preset) : createEmptySelection(),
4181
+ dryRun: false,
4182
+ startAtReview: preset !== void 0
4183
+ }) === "cancelled") {
4184
+ await ensureDependenciesInstalled(destination, packageManager);
4185
+ p.outro(`Base project created at ${destination}. No integrations were installed.`);
4186
+ return;
4187
+ }
4188
+ await ensureDependenciesInstalled(destination, packageManager);
4189
+ p.outro(`Project ready at ${destination}`);
4190
+ }
4191
+ //#endregion
4192
+ //#region src/project/git-status.ts
4193
+ /** Git is optional; any failure simply reports "not a repository". */
4194
+ async function getGitInfo(projectRoot) {
4195
+ try {
4196
+ const inside = await execa("git", ["rev-parse", "--is-inside-work-tree"], {
4197
+ cwd: projectRoot,
4198
+ reject: false
4199
+ });
4200
+ if (inside.exitCode !== 0 || String(inside.stdout).trim() !== "true") return {
4201
+ isRepository: false,
4202
+ hasUncommittedChanges: false
4203
+ };
4204
+ const status = await execa("git", ["status", "--porcelain"], {
4205
+ cwd: projectRoot,
4206
+ reject: false
4207
+ });
4208
+ return {
4209
+ isRepository: true,
4210
+ hasUncommittedChanges: status.exitCode === 0 && String(status.stdout).trim().length > 0
4211
+ };
4212
+ } catch {
4213
+ return {
4214
+ isRepository: false,
4215
+ hasUncommittedChanges: false
4216
+ };
4217
+ }
4218
+ }
4219
+ //#endregion
4220
+ //#region src/commands/add.ts
4221
+ async function resolveContextWithPackageManager(cwd, packageManagerOverride) {
4222
+ let context = await detectProject(cwd, { packageManagerOverride });
4223
+ if (context.detection === "ambiguous" && context.packageManagerCandidates.length > 1) {
4224
+ p.log.warn(`Multiple lockfiles detected: the project matches ${context.packageManagerCandidates.join(", ")}. The other lockfiles will not be modified or deleted.`);
4225
+ context = await detectProject(cwd, { packageManagerOverride: guard(await p.select({
4226
+ message: "Choose a package manager",
4227
+ options: context.packageManagerCandidates.map((pm) => ({
4228
+ value: pm,
4229
+ label: pm
4230
+ }))
4231
+ })) });
4232
+ }
4233
+ return context;
4234
+ }
4235
+ async function runAdd(options) {
4236
+ const cwd = options.cwd ?? process.cwd();
4237
+ p.intro("StackPack — add integrations");
4238
+ const context = await resolveContextWithPackageManager(cwd, options.packageManager);
4239
+ if (context.detection === "unsupported") throw new StackPackError("This project is not a supported React or Next.js project.", { hints: ["StackPack currently supports React with Vite and Next.js projects.", `Detected directory: ${context.rootDirectory}`] });
4240
+ if (context.detection === "partially-detected") p.log.warn("The project was only partially detected; some integrations may be hidden.");
4241
+ p.note(describeContext(context), "Existing project detected");
4242
+ if (!guard(await p.confirm({
4243
+ message: "Continue to the integration dashboard?",
4244
+ initialValue: true
4245
+ }))) throw new CancelledError();
4246
+ if (!options.dryRun) {
4247
+ const git = await getGitInfo(context.rootDirectory);
4248
+ if (git.isRepository && git.hasUncommittedChanges) {
4249
+ if (!guard(await p.confirm({
4250
+ message: "Uncommitted Git changes detected. StackPack will create its own backup, but committing first is safer. Continue?",
4251
+ initialValue: true
4252
+ }))) throw new CancelledError();
4253
+ }
4254
+ }
4255
+ const outcome = await dashboardInstallLoop({
4256
+ context,
4257
+ selection: createEmptySelection(),
4258
+ dryRun: options.dryRun === true
4259
+ });
4260
+ if (outcome === "cancelled") throw new CancelledError();
4261
+ p.outro(outcome === "dry-run" ? "Dry run finished." : "Integrations installed.");
4262
+ }
4263
+ //#endregion
4264
+ //#region src/commands/scan.ts
4265
+ async function runScan(options = {}) {
4266
+ const cwd = options.cwd ?? process.cwd();
4267
+ p.intro("StackPack — scan");
4268
+ const context = await detectProject(cwd);
4269
+ p.note(describeContext(context), "Project detection");
4270
+ if (context.packageManagerCandidates.length > 1) p.log.warn(`Multiple lockfiles detected (${context.packageManagerCandidates.join(", ")}).`);
4271
+ const lines = filterIntegrations(context, allRecipes).map((availability) => {
4272
+ switch (availability.compatibility) {
4273
+ case "already-installed": return `${pc.green("●")} ${availability.recipe.name} — ${availability.reason}`;
4274
+ case "partially-configured": return `${pc.yellow("◐")} ${availability.recipe.name} — ${availability.reason ?? "partially configured"}`;
4275
+ case "incompatible": return `${pc.dim("○")} ${pc.dim(`${availability.recipe.name} — ${availability.reason}`)}`;
4276
+ default: return `${pc.dim("○")} ${availability.recipe.name} — not installed`;
4277
+ }
4278
+ });
4279
+ p.note(lines.join("\n"), "Known integrations");
4280
+ p.outro(`Detection state: ${context.detection}`);
4281
+ }
4282
+ //#endregion
4283
+ //#region src/commands/save.ts
4284
+ /**
4285
+ * Packages the official creators scaffold themselves. They are not saved in
4286
+ * presets because every new base project already ships them.
4287
+ */
4288
+ const SCAFFOLD_PACKAGES = /* @__PURE__ */ new Set([
4289
+ "next",
4290
+ "react",
4291
+ "react-dom",
4292
+ "vite",
4293
+ "typescript",
4294
+ "@types/react",
4295
+ "@types/react-dom",
4296
+ "@types/node",
4297
+ "eslint",
4298
+ "eslint-config-next",
4299
+ "eslint-plugin-react-hooks",
4300
+ "eslint-plugin-react-refresh",
4301
+ "@eslint/js",
4302
+ "@eslint/eslintrc",
4303
+ "globals",
4304
+ "typescript-eslint",
4305
+ "@biomejs/biome",
4306
+ "oxlint",
4307
+ "tailwindcss",
4308
+ "@tailwindcss/postcss",
4309
+ "postcss",
4310
+ "autoprefixer",
4311
+ "@vitejs/plugin-react",
4312
+ "@vitejs/plugin-react-swc",
4313
+ "@rolldown/plugin-babel",
4314
+ "babel-plugin-react-compiler",
4315
+ "@babel/core",
4316
+ "@types/babel__core"
4317
+ ]);
4318
+ /**
4319
+ * Every dependency that is neither owned by a detected integration nor part
4320
+ * of the base scaffold is captured as a custom package, so the preset
4321
+ * reproduces the full tech stack.
4322
+ */
4323
+ function collectExtraDependencies(context, selection) {
4324
+ const recipeOwned = /* @__PURE__ */ new Set();
4325
+ for (const { recipe, options } of selectedRecipes(selection)) for (const pkg of recipe.createPlan(context, options).packages) recipeOwned.add(pkg.name);
4326
+ const extras = [];
4327
+ const sections = [[context.packageJson.dependencies ?? {}, "dependency"], [context.packageJson.devDependencies ?? {}, "devDependency"]];
4328
+ for (const [packages, dependencyType] of sections) for (const [name, version] of Object.entries(packages)) {
4329
+ if (recipeOwned.has(name) || SCAFFOLD_PACKAGES.has(name)) continue;
4330
+ if (parseVersionSpec(version) === null) {
4331
+ p.log.warn(`Skipping ${name}: version "${version}" cannot be stored in a preset.`);
4332
+ continue;
4333
+ }
4334
+ extras.push({
4335
+ name,
4336
+ version,
4337
+ dependencyType
4338
+ });
4339
+ }
4340
+ return extras;
4341
+ }
4342
+ /**
4343
+ * Saves the current project's detected setup as a preset: recognized
4344
+ * integrations plus every other dependency as custom packages.
4345
+ */
4346
+ async function runSave(name, options) {
4347
+ const cwd = options.cwd ?? process.cwd();
4348
+ p.intro("StackPack — save preset");
4349
+ const validation = validatePresetName(name);
4350
+ if (!validation.ok) throw new StackPackError(`Invalid preset name: ${validation.reason}`);
4351
+ const context = await detectProject(cwd);
4352
+ if (context.framework === "unknown" || context.language === "unknown") throw new StackPackError("This project cannot be saved as a preset.", { hints: ["StackPack could not detect a supported framework and language."] });
4353
+ const selection = createEmptySelection();
4354
+ const availabilities = filterIntegrations(context, allRecipes);
4355
+ for (const availability of availabilities) if (availability.compatibility === "already-installed" || availability.compatibility === "partially-configured") addToSelection(selection, availability.recipe.id, {});
4356
+ selection.customPackages = collectExtraDependencies(context, selection);
4357
+ const scope = options.local ? "local" : "global";
4358
+ const preset = buildPresetFromSelection({
4359
+ name,
4360
+ scope,
4361
+ context,
4362
+ selection
4363
+ });
4364
+ if (!preset) throw new StackPackError("This project cannot be represented as a preset.");
4365
+ const storeOptions = { projectRoot: cwd };
4366
+ if (await presetExists(name, scope, storeOptions)) {
4367
+ if (guard(await p.select({
4368
+ message: `Preset "${name}" already exists`,
4369
+ options: [{
4370
+ value: "cancel",
4371
+ label: "Cancel"
4372
+ }, {
4373
+ value: "replace",
4374
+ label: "Replace it"
4375
+ }]
4376
+ })) === "cancel") {
4377
+ p.outro("Nothing was saved.");
4378
+ return;
4379
+ }
4380
+ }
4381
+ const location = await savePreset(preset, scope, storeOptions);
4382
+ const extraCount = Object.keys(preset.customPackages.dependencies).length + Object.keys(preset.customPackages.devDependencies).length;
4383
+ p.log.success(`Preset saved with ${preset.integrations.length} detected integration(s) and ${extraCount} other package(s).\n Location: ${location.filePath}`);
4384
+ p.outro("Done.");
4385
+ }
4386
+ /** Menu version of save: asks for the preset name and storage location. */
4387
+ async function runSaveInteractive(options = {}) {
4388
+ await runSave(guard(await p.text({
4389
+ message: "Preset name",
4390
+ placeholder: "my-react-stack",
4391
+ validate(value) {
4392
+ const result = validatePresetName(value ?? "");
4393
+ return result.ok ? void 0 : result.reason;
4394
+ }
4395
+ })).trim(), {
4396
+ local: guard(await p.select({
4397
+ message: "Where should the preset be saved?",
4398
+ options: [{
4399
+ value: "global",
4400
+ label: "Globally",
4401
+ hint: "usable in every project on this device"
4402
+ }, {
4403
+ value: "local",
4404
+ label: "In this project only",
4405
+ hint: ".stackpack/ folder"
4406
+ }]
4407
+ })) === "local",
4408
+ cwd: options.cwd
4409
+ });
4410
+ }
4411
+ //#endregion
4412
+ //#region src/commands/apply.ts
4413
+ /** Applies a saved preset's integrations to the current project. */
4414
+ async function runApply(name, options) {
4415
+ const cwd = options.cwd ?? process.cwd();
4416
+ p.intro("StackPack — apply preset");
4417
+ const { preset, location } = await loadPreset(name, { projectRoot: cwd });
4418
+ p.log.info(`Using ${location.scope} preset: ${location.filePath}`);
4419
+ const context = await resolveContextWithPackageManager(cwd);
4420
+ if (context.detection === "unsupported") throw new StackPackError("This project is not a supported React or Next.js project.");
4421
+ p.note(describeContext(context), "Existing project detected");
4422
+ const mismatches = [];
4423
+ if (preset.project.framework !== context.framework) mismatches.push(`framework (preset: ${preset.project.framework}, project: ${context.framework})`);
4424
+ if (preset.project.buildTool !== context.buildTool) mismatches.push(`build tool (preset: ${preset.project.buildTool}, project: ${context.buildTool})`);
4425
+ if (mismatches.length > 0) throw new StackPackError(`Preset "${name}" does not match this project.`, { hints: mismatches.map((m) => `Mismatched ${m}`) });
4426
+ if (!options.dryRun) {
4427
+ const git = await getGitInfo(context.rootDirectory);
4428
+ if (git.isRepository && git.hasUncommittedChanges) {
4429
+ if (!guard(await p.confirm({
4430
+ message: "Uncommitted Git changes detected. StackPack will create its own backup, but committing first is safer. Continue?",
4431
+ initialValue: true
4432
+ }))) throw new CancelledError();
4433
+ }
4434
+ }
4435
+ const outcome = await dashboardInstallLoop({
4436
+ context,
4437
+ selection: presetToSelection(preset),
4438
+ dryRun: options.dryRun === true,
4439
+ startAtReview: true
4440
+ });
4441
+ if (outcome === "cancelled") throw new CancelledError();
4442
+ p.outro(outcome === "dry-run" ? "Dry run finished." : `Preset "${name}" applied.`);
4443
+ }
4444
+ //#endregion
4445
+ //#region src/commands/presets.ts
4446
+ async function runPresetsList(options = {}) {
4447
+ const presets = await listPresets({ projectRoot: options.cwd ?? process.cwd() });
4448
+ if (presets.length === 0) {
4449
+ console.log("No presets saved yet. Save one after an installation or with: stackpack save <name>");
4450
+ return;
4451
+ }
4452
+ for (const preset of presets) console.log(`${preset.name} ${pc.dim(`[${preset.scope}]`)} ${pc.dim(preset.filePath)}`);
4453
+ }
4454
+ async function runPresetsShow(name, options = {}) {
4455
+ const { preset, location } = await loadPreset(name, { projectRoot: options.cwd ?? process.cwd() });
4456
+ console.log(pc.bold(preset.displayName ?? preset.name));
4457
+ console.log(pc.dim(`${location.scope} preset — ${location.filePath}`));
4458
+ console.log("");
4459
+ console.log(`Base: ${preset.base.creator} (${preset.base.language})`);
4460
+ console.log(`Project: ${preset.project.framework} / ${preset.project.buildTool} / ${preset.project.language}`);
4461
+ console.log("");
4462
+ console.log("Integrations:");
4463
+ if (preset.integrations.length === 0) console.log(" (none)");
4464
+ for (const integration of preset.integrations) {
4465
+ const opts = Object.entries(integration.options).map(([key, value]) => `${key}=${String(value)}`).join(", ");
4466
+ console.log(` - ${integration.id}${opts.length > 0 ? ` (${opts})` : ""}`);
4467
+ }
4468
+ const deps = Object.entries(preset.customPackages.dependencies);
4469
+ const devDeps = Object.entries(preset.customPackages.devDependencies);
4470
+ if (deps.length + devDeps.length > 0) {
4471
+ console.log("");
4472
+ console.log("Custom packages:");
4473
+ for (const [pkg, version] of deps) console.log(` - ${pkg}@${version}`);
4474
+ for (const [pkg, version] of devDeps) console.log(` - ${pkg}@${version} (dev)`);
4475
+ }
4476
+ const overrides = Object.entries(preset.versionOverrides);
4477
+ if (overrides.length > 0) {
4478
+ console.log("");
4479
+ console.log("Version overrides:");
4480
+ for (const [pkg, version] of overrides) console.log(` - ${pkg}: ${version}`);
4481
+ }
4482
+ }
4483
+ /**
4484
+ * Interactive preset browser used by the main menu: pick a preset to see its
4485
+ * contents, optionally delete it, and always have a way back.
4486
+ */
4487
+ async function runPresetsBrowser(options = {}) {
4488
+ const projectRoot = options.cwd ?? process.cwd();
4489
+ for (;;) {
4490
+ const presets = await listPresets({ projectRoot });
4491
+ if (presets.length === 0) {
4492
+ p.log.info("No presets saved yet. Save one after an installation or with: stackpack save <name>");
4493
+ return;
4494
+ }
4495
+ const choice = guard(await p.select({
4496
+ message: "Saved presets",
4497
+ options: [
4498
+ ...presets.map((preset, index) => ({
4499
+ value: String(index),
4500
+ label: preset.name,
4501
+ hint: `${preset.scope} — ${preset.filePath}`
4502
+ })),
4503
+ {
4504
+ value: "__delete__",
4505
+ label: "Delete a preset"
4506
+ },
4507
+ {
4508
+ value: "__back__",
4509
+ label: "Back to main menu"
4510
+ }
4511
+ ]
4512
+ }));
4513
+ if (choice === "__back__") return;
4514
+ if (choice === "__delete__") {
4515
+ const target = guard(await p.select({
4516
+ message: "Which preset do you want to delete?",
4517
+ options: [...presets.map((preset, index) => ({
4518
+ value: String(index),
4519
+ label: preset.name,
4520
+ hint: `${preset.scope} — ${preset.filePath}`
4521
+ })), {
4522
+ value: "__cancel__",
4523
+ label: "Cancel"
4524
+ }]
4525
+ }));
4526
+ if (target === "__cancel__") continue;
4527
+ const preset = presets[Number(target)];
4528
+ if (!preset) continue;
4529
+ if (guard(await p.confirm({
4530
+ message: `Delete preset "${preset.name}"?`,
4531
+ initialValue: false
4532
+ }))) {
4533
+ const location = await deletePreset(preset.name, { projectRoot });
4534
+ p.log.success(`Deleted ${location.scope} preset: ${location.filePath}`);
4535
+ }
4536
+ continue;
4537
+ }
4538
+ const selected = presets[Number(choice)];
4539
+ if (!selected) continue;
4540
+ await runPresetsShow(selected.name, { cwd: projectRoot });
4541
+ const action = guard(await p.select({
4542
+ message: `Preset "${selected.name}"`,
4543
+ options: [
4544
+ {
4545
+ value: "list",
4546
+ label: "Back to preset list"
4547
+ },
4548
+ {
4549
+ value: "delete",
4550
+ label: "Delete this preset"
4551
+ },
4552
+ {
4553
+ value: "menu",
4554
+ label: "Back to main menu"
4555
+ }
4556
+ ]
4557
+ }));
4558
+ if (action === "menu") return;
4559
+ if (action === "delete") {
4560
+ if (guard(await p.confirm({
4561
+ message: `Delete preset "${selected.name}"?`,
4562
+ initialValue: false
4563
+ }))) {
4564
+ const location = await deletePreset(selected.name, { projectRoot });
4565
+ p.log.success(`Deleted ${location.scope} preset: ${location.filePath}`);
4566
+ }
4567
+ }
4568
+ }
4569
+ }
4570
+ async function runPresetsDelete(name, options = {}) {
4571
+ const storeOptions = { projectRoot: options.cwd ?? process.cwd() };
4572
+ p.intro("StackPack — delete preset");
4573
+ if (!guard(await p.confirm({
4574
+ message: `Delete preset "${name}"?`,
4575
+ initialValue: false
4576
+ }))) {
4577
+ p.outro("Nothing was deleted.");
4578
+ return;
4579
+ }
4580
+ const location = await deletePreset(name, storeOptions);
4581
+ p.outro(`Deleted ${location.scope} preset: ${location.filePath}`);
4582
+ }
4583
+ //#endregion
4584
+ //#region src/version.ts
4585
+ const VERSION = "0.3.0";
4586
+ //#endregion
4587
+ //#region src/program.ts
4588
+ const packageManagerOption = new Option("--package-manager <manager>", "package manager to use").choices([
4589
+ "npm",
4590
+ "pnpm",
4591
+ "yarn",
4592
+ "bun"
4593
+ ]);
4594
+ async function runMainMenu() {
4595
+ p.intro("StackPack");
4596
+ p.log.message("Official project tooling with real-world integrations.");
4597
+ p.log.message("Presets stay on this device.");
4598
+ p.log.message(pc.dim("Ctrl+C on any question brings you back to this menu instead of exiting."));
4599
+ for (;;) {
4600
+ const choice = await p.select({
4601
+ message: "What would you like to do?",
4602
+ options: [
4603
+ {
4604
+ value: "new",
4605
+ label: "Create a new project"
4606
+ },
4607
+ {
4608
+ value: "add",
4609
+ label: "Add integrations to the current project"
4610
+ },
4611
+ {
4612
+ value: "scan",
4613
+ label: "Scan the current project"
4614
+ },
4615
+ {
4616
+ value: "save",
4617
+ label: "Save this project's stack as a preset",
4618
+ hint: "integrations + all other dependencies"
4619
+ },
4620
+ {
4621
+ value: "presets",
4622
+ label: "View saved presets"
4623
+ },
4624
+ {
4625
+ value: "exit",
4626
+ label: "Exit"
4627
+ }
4628
+ ]
4629
+ });
4630
+ if (p.isCancel(choice) || choice === "exit") {
4631
+ p.outro("Goodbye.");
4632
+ return;
4633
+ }
4634
+ try {
4635
+ switch (choice) {
4636
+ case "new":
4637
+ await runNew(void 0, {});
4638
+ break;
4639
+ case "add":
4640
+ await runAdd({});
4641
+ break;
4642
+ case "scan":
4643
+ await runScan();
4644
+ break;
4645
+ case "save":
4646
+ await runSaveInteractive();
4647
+ break;
4648
+ case "presets":
4649
+ await runPresetsBrowser();
4650
+ break;
4651
+ }
4652
+ } catch (error) {
4653
+ if (error instanceof CancelledError) {
4654
+ p.log.info("Cancelled — back to the main menu.");
4655
+ continue;
4656
+ }
4657
+ printError(error);
4658
+ }
4659
+ }
4660
+ }
4661
+ async function runCli(argv) {
4662
+ const program = new Command();
4663
+ program.name("stackpack").description("Local-first terminal integration builder. Official project tooling with real-world integrations; presets stay on this device.").version(VERSION).option("--no-color", "disable colored output");
4664
+ program.command("new").description("Create a new project with an official creator, then add integrations").argument("[project-name]", "name of the project folder to create").option("--preset <name>", "create the project from a saved preset").addOption(packageManagerOption).action(async (projectName, options) => {
4665
+ await runNew(projectName, options);
4666
+ });
4667
+ program.command("add").description("Add integrations to the current project").option("--dry-run", "show the full plan without changing anything").addOption(packageManagerOption).action(async (options) => {
4668
+ await runAdd(options);
4669
+ });
4670
+ program.command("scan").description("Detect the current project's stack and installed integrations").action(async () => {
4671
+ await runScan();
4672
+ });
4673
+ program.command("save").description("Save the current project's detected setup as a preset").argument("<preset-name>", "name for the preset").option("--local", "store the preset inside this project (.stackpack/)").option("--global", "store the preset in your home directory (default)").action(async (name, options) => {
4674
+ await runSave(name, options);
4675
+ });
4676
+ program.command("apply").description("Apply a saved preset's integrations to the current project").argument("<preset-name>", "preset to apply").option("--dry-run", "show the full plan without changing anything").action(async (name, options) => {
4677
+ await runApply(name, options);
4678
+ });
4679
+ const presets = program.command("presets").description("Manage saved presets");
4680
+ presets.command("list").description("List saved presets (global and project-local)").action(async () => {
4681
+ await runPresetsList();
4682
+ });
4683
+ presets.command("show").description("Show a preset's contents").argument("<preset-name>").action(async (name) => {
4684
+ await runPresetsShow(name);
4685
+ });
4686
+ presets.command("delete").description("Delete a saved preset").argument("<preset-name>").action(async (name) => {
4687
+ await runPresetsDelete(name);
4688
+ });
4689
+ const hasCommand = argv.slice(2).some((arg) => !arg.startsWith("-"));
4690
+ const onlyFlags = argv.slice(2).every((arg) => arg === "--no-color");
4691
+ try {
4692
+ if (!hasCommand && onlyFlags) {
4693
+ await runMainMenu();
4694
+ return;
4695
+ }
4696
+ await program.parseAsync(argv);
4697
+ } catch (error) {
4698
+ process.exitCode = printError(error);
4699
+ }
4700
+ }
4701
+ //#endregion
4702
+ export { runCli };