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.
- package/LICENSE +21 -0
- package/README.md +148 -0
- package/dist/cli.js +8 -0
- package/dist/program-D9mgsLIa.js +4702 -0
- package/package.json +70 -0
|
@@ -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 };
|