scale-stack 0.0.1-alpha.2 → 0.0.1
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/CHANGELOG.md +17 -0
- package/README.md +97 -3
- package/dist/index.js +3858 -124
- package/package.json +7 -2
- package/templates/ai-chat/chat-panel.tsx.ejs +70 -0
- package/templates/ai-chat/layout.tsx.ejs +39 -0
- package/templates/ai-chat/page.tsx.ejs +31 -0
- package/templates/ai-chat/route.ts.ejs +68 -0
- package/templates/ai-chat/use-chat.ts.ejs +26 -0
- package/templates/analytics/analytics-provider.tsx.ejs +32 -0
- package/templates/analytics/analytics.ts.ejs +40 -0
- package/templates/auth/auth-client.ts.ejs +7 -0
- package/templates/auth/auth.ts.ejs +45 -0
- package/templates/auth/route.ts.ejs +4 -0
- package/templates/auth/sign-in-page.tsx.ejs +122 -0
- package/templates/auth/sign-up-page.tsx.ejs +137 -0
- package/templates/auth/unauthorized.tsx.ejs +28 -0
- package/templates/core/layout.tsx.ejs +36 -0
- package/templates/core/loading.tsx.ejs +7 -0
- package/templates/core/next.config.ts.ejs +33 -0
- package/templates/error-handling/catch-all-not-found-page.tsx.ejs +5 -0
- package/templates/error-handling/error.tsx.ejs +33 -0
- package/templates/error-handling/global-error.tsx.ejs +32 -0
- package/templates/error-handling/not-found.tsx.ejs +28 -0
- package/templates/eslint-prettier/.prettierignore.ejs +29 -0
- package/templates/eslint-prettier/eslint.config.mjs.ejs +31 -0
- package/templates/form-handling/dashboard-page.tsx.ejs +33 -0
- package/templates/form-handling/example-form-action.ts.ejs +50 -0
- package/templates/form-handling/example-form-schema.ts.ejs +87 -0
- package/templates/form-handling/example-form.tsx.ejs +428 -0
- package/templates/i18n/ar.json.ejs +77 -0
- package/templates/i18n/en.json.ejs +77 -0
- package/templates/i18n/locale-layout.tsx.ejs +45 -0
- package/templates/i18n/navigation.ts.ejs +5 -0
- package/templates/i18n/next-intl.d.ts.ejs +9 -0
- package/templates/i18n/request.ts.ejs +15 -0
- package/templates/i18n/routing.ts.ejs +7 -0
- package/templates/orm/prisma.config.ts.ejs +12 -0
- package/templates/orm/prisma.ts.ejs +17 -0
- package/templates/orm/schema.prisma.ejs +8 -0
- package/templates/pre-commit/prek.toml.ejs +35 -0
- package/templates/proxy/proxy.ts.ejs +81 -0
- package/templates/server-actions/safe-action.ts.ejs +51 -0
- package/templates/ui/client-side-wrappers.tsx.ejs +19 -0
- package/templates/ui/page.tsx.ejs +111 -0
package/dist/index.js
CHANGED
|
@@ -2,16 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli/index.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
|
-
import { readFileSync } from "fs";
|
|
6
|
-
import { fileURLToPath } from "url";
|
|
7
|
-
import { dirname, resolve } from "path";
|
|
8
5
|
|
|
9
6
|
// src/cli/commands/init.ts
|
|
10
|
-
import
|
|
7
|
+
import { exec as cpExec } from "child_process";
|
|
8
|
+
import { resolve as resolve3 } from "path";
|
|
9
|
+
import { InvalidArgumentError } from "commander";
|
|
10
|
+
import pc5 from "picocolors";
|
|
11
11
|
|
|
12
12
|
// src/cli/config.ts
|
|
13
13
|
var DEFAULT_CI_PROVIDER = "github";
|
|
14
|
-
var DEFAULT_ANALYTICS_PROVIDER = "
|
|
14
|
+
var DEFAULT_ANALYTICS_PROVIDER = "plausible";
|
|
15
15
|
function deriveAuthStrategy(orm) {
|
|
16
16
|
return orm === "prisma" ? "stateful" : "stateless";
|
|
17
17
|
}
|
|
@@ -40,14 +40,16 @@ function resolveConfig(flags, answers) {
|
|
|
40
40
|
i18n: flags.i18n ?? optMods.has("i18n"),
|
|
41
41
|
ci: resolveCi(),
|
|
42
42
|
analytics: resolveAnalytics(),
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
// Coming-soon modules are accepted by the CLI but must not affect the
|
|
44
|
+
// generator config until their DAG nodes are implemented.
|
|
45
|
+
jobs: false,
|
|
46
|
+
a11y: false,
|
|
47
|
+
apiClient: false,
|
|
48
|
+
preCommit: flags.preCommit ?? optMods.has("preCommit")
|
|
47
49
|
};
|
|
48
50
|
}
|
|
49
51
|
|
|
50
|
-
// src/cli/prompts/
|
|
52
|
+
// src/cli/prompts/tech-picker.ts
|
|
51
53
|
import * as p from "@clack/prompts";
|
|
52
54
|
import pc2 from "picocolors";
|
|
53
55
|
|
|
@@ -68,7 +70,7 @@ function sectionDivider(title) {
|
|
|
68
70
|
return pc.dim(` \u2500\u2500 ${pc.yellow(title)} ${line}`);
|
|
69
71
|
}
|
|
70
72
|
|
|
71
|
-
// src/cli/prompts/
|
|
73
|
+
// src/cli/prompts/tech-picker.ts
|
|
72
74
|
function cancelled() {
|
|
73
75
|
p.cancel(pc2.yellow("Assembly cancelled \u2014 no parts were harmed."));
|
|
74
76
|
process.exit(0);
|
|
@@ -112,12 +114,9 @@ async function runTechPicker(flags) {
|
|
|
112
114
|
answers.auth = auth;
|
|
113
115
|
}
|
|
114
116
|
if (flags.jobs === void 0) {
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
});
|
|
119
|
-
if (p.isCancel(jobs)) cancelled();
|
|
120
|
-
answers.jobs = jobs;
|
|
117
|
+
console.log(
|
|
118
|
+
` ${pc2.yellow("\u25B8")} Background jobs ` + ikea.muted("\u2014 Inngest (not implemented; skipped)")
|
|
119
|
+
);
|
|
121
120
|
}
|
|
122
121
|
if (flags.chat === void 0) {
|
|
123
122
|
const chat = await p.confirm({
|
|
@@ -137,7 +136,14 @@ async function runTechPicker(flags) {
|
|
|
137
136
|
{
|
|
138
137
|
value: "github",
|
|
139
138
|
label: pc2.bold("GitHub Actions"),
|
|
140
|
-
hint: ikea.muted(
|
|
139
|
+
hint: ikea.muted(
|
|
140
|
+
"lint and Docker build now; test jobs as TODO no-ops"
|
|
141
|
+
)
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
value: "circleci",
|
|
145
|
+
label: "CircleCI (coming soon)",
|
|
146
|
+
hint: ikea.muted("placeholder option for a later release")
|
|
141
147
|
},
|
|
142
148
|
{
|
|
143
149
|
value: "none",
|
|
@@ -153,10 +159,17 @@ async function runTechPicker(flags) {
|
|
|
153
159
|
const analytics = await p.select({
|
|
154
160
|
message: "Analytics",
|
|
155
161
|
options: [
|
|
162
|
+
{
|
|
163
|
+
value: "plausible",
|
|
164
|
+
label: pc2.bold("Plausible"),
|
|
165
|
+
hint: ikea.muted(
|
|
166
|
+
"privacy-friendly web analytics with local Compose support"
|
|
167
|
+
)
|
|
168
|
+
},
|
|
156
169
|
{
|
|
157
170
|
value: "posthog",
|
|
158
|
-
label:
|
|
159
|
-
hint: ikea.muted("
|
|
171
|
+
label: "PostHog (not implemented)",
|
|
172
|
+
hint: ikea.muted("placeholder option for a later release")
|
|
160
173
|
},
|
|
161
174
|
{
|
|
162
175
|
value: "none",
|
|
@@ -169,30 +182,30 @@ async function runTechPicker(flags) {
|
|
|
169
182
|
answers.analytics = analytics;
|
|
170
183
|
}
|
|
171
184
|
const alreadySelected = /* @__PURE__ */ new Set();
|
|
172
|
-
if (flags.security) alreadySelected.add("security");
|
|
173
185
|
if (flags.a11y) alreadySelected.add("a11y");
|
|
174
186
|
if (flags.apiClient) alreadySelected.add("apiClient");
|
|
175
187
|
if (flags.i18n) alreadySelected.add("i18n");
|
|
188
|
+
if (flags.preCommit) alreadySelected.add("preCommit");
|
|
176
189
|
const availableModules = [
|
|
177
|
-
{
|
|
178
|
-
value: "security",
|
|
179
|
-
label: "Security hardening",
|
|
180
|
-
hint: ikea.muted("CSP, HSTS, X-Frame-Options, CORS config")
|
|
181
|
-
},
|
|
182
190
|
{
|
|
183
191
|
value: "a11y",
|
|
184
|
-
label: "Accessibility",
|
|
185
|
-
hint: ikea.muted("
|
|
192
|
+
label: "Accessibility (coming soon)",
|
|
193
|
+
hint: ikea.muted("placeholder option for a later release")
|
|
186
194
|
},
|
|
187
195
|
{
|
|
188
196
|
value: "apiClient",
|
|
189
|
-
label: "API client",
|
|
190
|
-
hint: ikea.muted("
|
|
197
|
+
label: "API client (coming soon)",
|
|
198
|
+
hint: ikea.muted("placeholder option for a later release")
|
|
191
199
|
},
|
|
192
200
|
{
|
|
193
201
|
value: "i18n",
|
|
194
202
|
label: "Internationalization",
|
|
195
203
|
hint: ikea.muted("next-intl, locale routing, <LocaleSwitcher>")
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
value: "preCommit",
|
|
207
|
+
label: "Pre-commit hooks",
|
|
208
|
+
hint: ikea.muted("prek hooks for lint, format, and typecheck")
|
|
196
209
|
}
|
|
197
210
|
].filter((m) => !alreadySelected.has(m.value));
|
|
198
211
|
if (availableModules.length > 0) {
|
|
@@ -213,12 +226,12 @@ async function runTechPicker(flags) {
|
|
|
213
226
|
return answers;
|
|
214
227
|
}
|
|
215
228
|
|
|
216
|
-
// src/cli/ui/
|
|
229
|
+
// src/cli/ui/config-display.ts
|
|
217
230
|
import pc3 from "picocolors";
|
|
218
231
|
|
|
219
232
|
// src/cli/ui/animate.ts
|
|
220
233
|
function sleep(ms) {
|
|
221
|
-
return new Promise((
|
|
234
|
+
return new Promise((resolve7) => setTimeout(resolve7, ms));
|
|
222
235
|
}
|
|
223
236
|
function cursorUp(n) {
|
|
224
237
|
process.stdout.write(`\x1B[${n}A`);
|
|
@@ -238,7 +251,7 @@ async function typewriter(text2, charDelayMs) {
|
|
|
238
251
|
}
|
|
239
252
|
}
|
|
240
253
|
|
|
241
|
-
// src/cli/ui/
|
|
254
|
+
// src/cli/ui/config-display.ts
|
|
242
255
|
function row(content) {
|
|
243
256
|
return ` \u2502 ${content}`;
|
|
244
257
|
}
|
|
@@ -267,10 +280,10 @@ function buildLines(config) {
|
|
|
267
280
|
["Analytics", config.analytics === "none" ? false : config.analytics]
|
|
268
281
|
];
|
|
269
282
|
const moduleEntries = [
|
|
270
|
-
["Security", config.security],
|
|
271
283
|
["a11y", config.a11y],
|
|
272
284
|
["API Client", config.apiClient],
|
|
273
|
-
["i18n", config.i18n]
|
|
285
|
+
["i18n", config.i18n],
|
|
286
|
+
["Pre-commit", config.preCommit]
|
|
274
287
|
];
|
|
275
288
|
const allEntries = [...coreEntries, ...featureEntries, ...moduleEntries];
|
|
276
289
|
const maxKey = Math.max(...allEntries.map(([k]) => k.length));
|
|
@@ -312,110 +325,3839 @@ function printConfigLabel(config) {
|
|
|
312
325
|
console.log();
|
|
313
326
|
}
|
|
314
327
|
|
|
328
|
+
// src/engine/runner.ts
|
|
329
|
+
import { exec } from "child_process";
|
|
330
|
+
import { join as join4 } from "path";
|
|
331
|
+
import { promisify } from "util";
|
|
332
|
+
|
|
333
|
+
// src/engine/dag.ts
|
|
334
|
+
function getDependencies(generator, config) {
|
|
335
|
+
return [
|
|
336
|
+
...generator.dependencies,
|
|
337
|
+
...generator.resolveDependencies?.(config) ?? []
|
|
338
|
+
];
|
|
339
|
+
}
|
|
340
|
+
function buildDAG(generators2, config) {
|
|
341
|
+
const active = generators2.filter((g) => !g.condition || g.condition(config));
|
|
342
|
+
const byName = /* @__PURE__ */ new Map();
|
|
343
|
+
for (const g of active) {
|
|
344
|
+
byName.set(g.name, g);
|
|
345
|
+
}
|
|
346
|
+
for (const g of active) {
|
|
347
|
+
for (const dep of getDependencies(g, config)) {
|
|
348
|
+
if (!byName.has(dep)) {
|
|
349
|
+
throw new Error(
|
|
350
|
+
`Generator "${g.name}" depends on "${dep}", which is not registered or was excluded by its condition`
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const inDegree = /* @__PURE__ */ new Map();
|
|
356
|
+
const dependents = /* @__PURE__ */ new Map();
|
|
357
|
+
for (const g of active) {
|
|
358
|
+
inDegree.set(g.name, 0);
|
|
359
|
+
dependents.set(g.name, []);
|
|
360
|
+
}
|
|
361
|
+
for (const g of active) {
|
|
362
|
+
for (const dep of getDependencies(g, config)) {
|
|
363
|
+
inDegree.set(g.name, (inDegree.get(g.name) ?? 0) + 1);
|
|
364
|
+
dependents.get(dep).push(g.name);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
const queue = [];
|
|
368
|
+
for (const [name, degree] of inDegree) {
|
|
369
|
+
if (degree === 0) queue.push(name);
|
|
370
|
+
}
|
|
371
|
+
queue.sort();
|
|
372
|
+
const sorted = [];
|
|
373
|
+
while (queue.length > 0) {
|
|
374
|
+
const name = queue.shift();
|
|
375
|
+
sorted.push(byName.get(name));
|
|
376
|
+
const neighbors = dependents.get(name) ?? [];
|
|
377
|
+
neighbors.sort();
|
|
378
|
+
for (const neighbor of neighbors) {
|
|
379
|
+
const newDeg = (inDegree.get(neighbor) ?? 1) - 1;
|
|
380
|
+
inDegree.set(neighbor, newDeg);
|
|
381
|
+
if (newDeg === 0) queue.push(neighbor);
|
|
382
|
+
}
|
|
383
|
+
queue.sort();
|
|
384
|
+
}
|
|
385
|
+
if (sorted.length !== active.length) {
|
|
386
|
+
const remaining = active.filter((g) => !sorted.some((s) => s.name === g.name)).map((g) => g.name);
|
|
387
|
+
throw new Error(
|
|
388
|
+
`Dependency cycle detected among generators: ${remaining.join(", ")}`
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
return sorted;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// src/engine/fs.ts
|
|
395
|
+
import { readFile, writeFile, mkdir, rm, readdir } from "fs/promises";
|
|
396
|
+
import { existsSync } from "fs";
|
|
397
|
+
import { resolve, dirname, relative, join } from "path";
|
|
398
|
+
import { createPatch } from "diff";
|
|
399
|
+
var VirtualFS = class {
|
|
400
|
+
operations = [];
|
|
401
|
+
/** Maps absolute path → original content (string) or null if the file was new */
|
|
402
|
+
committedFiles = /* @__PURE__ */ new Map();
|
|
403
|
+
targetDir;
|
|
404
|
+
constructor(targetDir) {
|
|
405
|
+
this.targetDir = resolve(targetDir);
|
|
406
|
+
}
|
|
407
|
+
abs(p2) {
|
|
408
|
+
return resolve(this.targetDir, p2);
|
|
409
|
+
}
|
|
410
|
+
write(path, content) {
|
|
411
|
+
this.operations.push({ type: "write", path, content });
|
|
412
|
+
}
|
|
413
|
+
copy(src, dest) {
|
|
414
|
+
this.operations.push({ type: "copy", src, dest });
|
|
415
|
+
}
|
|
416
|
+
merge(path, mergeFn) {
|
|
417
|
+
this.operations.push({ type: "merge", path, mergeFn });
|
|
418
|
+
}
|
|
419
|
+
async commit() {
|
|
420
|
+
for (const op of this.operations) {
|
|
421
|
+
switch (op.type) {
|
|
422
|
+
case "write": {
|
|
423
|
+
const abs = this.abs(op.path);
|
|
424
|
+
await this.trackOriginal(abs);
|
|
425
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
426
|
+
await writeFile(abs, op.content, "utf-8");
|
|
427
|
+
break;
|
|
428
|
+
}
|
|
429
|
+
case "copy": {
|
|
430
|
+
const abs = this.abs(op.dest);
|
|
431
|
+
await this.trackOriginal(abs);
|
|
432
|
+
const content = await readFile(resolve(op.src), "utf-8");
|
|
433
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
434
|
+
await writeFile(abs, content, "utf-8");
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
437
|
+
case "merge": {
|
|
438
|
+
const abs = this.abs(op.path);
|
|
439
|
+
const existing = existsSync(abs) ? await readFile(abs, "utf-8") : "";
|
|
440
|
+
await this.trackOriginal(abs);
|
|
441
|
+
const merged = op.mergeFn(existing);
|
|
442
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
443
|
+
await writeFile(abs, merged, "utf-8");
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
async rollback() {
|
|
450
|
+
for (const [abs, original] of this.committedFiles) {
|
|
451
|
+
if (original === null) {
|
|
452
|
+
if (existsSync(abs)) await rm(abs);
|
|
453
|
+
} else {
|
|
454
|
+
await writeFile(abs, original, "utf-8");
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
const dirs = /* @__PURE__ */ new Set();
|
|
458
|
+
for (const abs of this.committedFiles.keys()) {
|
|
459
|
+
let dir = dirname(abs);
|
|
460
|
+
while (dir.startsWith(this.targetDir) && dir !== this.targetDir) {
|
|
461
|
+
dirs.add(dir);
|
|
462
|
+
dir = dirname(dir);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
const sortedDirs = [...dirs].sort((a, b) => b.length - a.length);
|
|
466
|
+
for (const dir of sortedDirs) {
|
|
467
|
+
try {
|
|
468
|
+
const entries = await readdir(dir);
|
|
469
|
+
if (entries.length === 0) await rm(dir, { recursive: true });
|
|
470
|
+
} catch {
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
this.committedFiles.clear();
|
|
474
|
+
}
|
|
475
|
+
async dryRun() {
|
|
476
|
+
const results = [];
|
|
477
|
+
for (const op of this.operations) {
|
|
478
|
+
switch (op.type) {
|
|
479
|
+
case "write": {
|
|
480
|
+
const abs = this.abs(op.path);
|
|
481
|
+
if (existsSync(abs)) {
|
|
482
|
+
const existing = await readFile(abs, "utf-8");
|
|
483
|
+
results.push({
|
|
484
|
+
path: op.path,
|
|
485
|
+
action: "modify",
|
|
486
|
+
diff: createPatch(op.path, existing, op.content)
|
|
487
|
+
});
|
|
488
|
+
} else {
|
|
489
|
+
results.push({
|
|
490
|
+
path: op.path,
|
|
491
|
+
action: "create",
|
|
492
|
+
content: op.content
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
break;
|
|
496
|
+
}
|
|
497
|
+
case "copy": {
|
|
498
|
+
const content = await readFile(resolve(op.src), "utf-8");
|
|
499
|
+
const abs = this.abs(op.dest);
|
|
500
|
+
if (existsSync(abs)) {
|
|
501
|
+
const existing = await readFile(abs, "utf-8");
|
|
502
|
+
results.push({
|
|
503
|
+
path: op.dest,
|
|
504
|
+
action: "modify",
|
|
505
|
+
diff: createPatch(op.dest, existing, content)
|
|
506
|
+
});
|
|
507
|
+
} else {
|
|
508
|
+
results.push({
|
|
509
|
+
path: op.dest,
|
|
510
|
+
action: "copy",
|
|
511
|
+
content
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
break;
|
|
515
|
+
}
|
|
516
|
+
case "merge": {
|
|
517
|
+
const abs = this.abs(op.path);
|
|
518
|
+
const existing = existsSync(abs) ? await readFile(abs, "utf-8") : "";
|
|
519
|
+
const merged = op.mergeFn(existing);
|
|
520
|
+
if (existing) {
|
|
521
|
+
results.push({
|
|
522
|
+
path: op.path,
|
|
523
|
+
action: "modify",
|
|
524
|
+
diff: createPatch(op.path, existing, merged)
|
|
525
|
+
});
|
|
526
|
+
} else {
|
|
527
|
+
results.push({
|
|
528
|
+
path: op.path,
|
|
529
|
+
action: "create",
|
|
530
|
+
content: merged
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
break;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return results;
|
|
538
|
+
}
|
|
539
|
+
// ── Adopt pattern for external tool output ──────────────
|
|
540
|
+
async snapshotDir() {
|
|
541
|
+
const snapshot = /* @__PURE__ */ new Map();
|
|
542
|
+
if (!existsSync(this.targetDir)) return snapshot;
|
|
543
|
+
await this.walkDir(this.targetDir, snapshot);
|
|
544
|
+
return snapshot;
|
|
545
|
+
}
|
|
546
|
+
async adoptExternalChanges(before) {
|
|
547
|
+
const after = await this.snapshotDir();
|
|
548
|
+
for (const [relPath, content] of after) {
|
|
549
|
+
const abs = this.abs(relPath);
|
|
550
|
+
if (this.committedFiles.has(abs)) continue;
|
|
551
|
+
const prev = before.get(relPath);
|
|
552
|
+
if (prev === void 0) {
|
|
553
|
+
this.committedFiles.set(abs, null);
|
|
554
|
+
} else if (prev !== content) {
|
|
555
|
+
this.committedFiles.set(abs, prev);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
for (const [relPath] of before) {
|
|
559
|
+
if (!after.has(relPath)) {
|
|
560
|
+
const abs = this.abs(relPath);
|
|
561
|
+
if (!this.committedFiles.has(abs)) {
|
|
562
|
+
this.committedFiles.set(abs, before.get(relPath));
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
// ── Private helpers ────────────────────────────────────
|
|
568
|
+
async trackOriginal(abs) {
|
|
569
|
+
if (this.committedFiles.has(abs)) return;
|
|
570
|
+
if (existsSync(abs)) {
|
|
571
|
+
this.committedFiles.set(abs, await readFile(abs, "utf-8"));
|
|
572
|
+
} else {
|
|
573
|
+
this.committedFiles.set(abs, null);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
async walkDir(dir, snapshot) {
|
|
577
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
578
|
+
for (const entry of entries) {
|
|
579
|
+
const full = join(dir, entry.name);
|
|
580
|
+
if (entry.isDirectory()) {
|
|
581
|
+
if (entry.name === "node_modules" || entry.name === ".git") continue;
|
|
582
|
+
await this.walkDir(full, snapshot);
|
|
583
|
+
} else if (entry.isFile()) {
|
|
584
|
+
try {
|
|
585
|
+
const content = await readFile(full, "utf-8");
|
|
586
|
+
snapshot.set(relative(this.targetDir, full), content);
|
|
587
|
+
} catch {
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
// src/engine/template.ts
|
|
595
|
+
import ejs from "ejs";
|
|
596
|
+
import { existsSync as existsSync2 } from "fs";
|
|
597
|
+
import { join as join2, resolve as resolve2 } from "path";
|
|
598
|
+
function resolveTemplatesDir() {
|
|
599
|
+
const here = import.meta.dirname ?? new URL(".", import.meta.url).pathname;
|
|
600
|
+
for (const candidate of [
|
|
601
|
+
join2(here, "../../templates"),
|
|
602
|
+
join2(here, "../templates")
|
|
603
|
+
]) {
|
|
604
|
+
if (existsSync2(candidate)) return candidate;
|
|
605
|
+
}
|
|
606
|
+
return join2(here, "../../templates");
|
|
607
|
+
}
|
|
608
|
+
var TemplateEngine = class {
|
|
609
|
+
templatesDir;
|
|
610
|
+
constructor(templatesDir) {
|
|
611
|
+
this.templatesDir = resolve2(templatesDir);
|
|
612
|
+
}
|
|
613
|
+
async renderFile(templatePath, data) {
|
|
614
|
+
const full = resolve2(this.templatesDir, templatePath);
|
|
615
|
+
return ejs.renderFile(full, data);
|
|
616
|
+
}
|
|
617
|
+
renderString(template, data) {
|
|
618
|
+
return ejs.render(template, data);
|
|
619
|
+
}
|
|
620
|
+
};
|
|
621
|
+
var DEFAULT_TEMPLATE_ENGINE = new TemplateEngine(
|
|
622
|
+
resolveTemplatesDir()
|
|
623
|
+
);
|
|
624
|
+
function renderTemplateFile(templatePath, data) {
|
|
625
|
+
return DEFAULT_TEMPLATE_ENGINE.renderFile(templatePath, data);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// src/engine/logger.ts
|
|
629
|
+
import { writeFile as writeFile2, mkdir as mkdir2 } from "fs/promises";
|
|
630
|
+
import { existsSync as existsSync3 } from "fs";
|
|
631
|
+
import { join as join3 } from "path";
|
|
632
|
+
import pc4 from "picocolors";
|
|
633
|
+
var Logger = class {
|
|
634
|
+
entries = [];
|
|
635
|
+
quiet;
|
|
636
|
+
constructor(quiet = false) {
|
|
637
|
+
this.quiet = quiet;
|
|
638
|
+
}
|
|
639
|
+
info(generator, message) {
|
|
640
|
+
this.log("info", generator, message);
|
|
641
|
+
}
|
|
642
|
+
success(generator, message) {
|
|
643
|
+
this.log("success", generator, message);
|
|
644
|
+
}
|
|
645
|
+
warn(generator, message) {
|
|
646
|
+
this.log("warn", generator, message);
|
|
647
|
+
}
|
|
648
|
+
error(generator, message, err) {
|
|
649
|
+
this.log("error", generator, message, void 0, err);
|
|
650
|
+
}
|
|
651
|
+
fileWritten(generator, path) {
|
|
652
|
+
this.log("info", generator, `wrote ${path}`, path);
|
|
653
|
+
}
|
|
654
|
+
async flush(logDir) {
|
|
655
|
+
if (this.entries.length === 0) return;
|
|
656
|
+
if (!existsSync3(logDir)) {
|
|
657
|
+
await mkdir2(logDir, { recursive: true });
|
|
658
|
+
}
|
|
659
|
+
const logPath = join3(logDir, "generate.log");
|
|
660
|
+
const ndjson = this.entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
|
|
661
|
+
await writeFile2(logPath, ndjson, { flag: "a", encoding: "utf-8" });
|
|
662
|
+
}
|
|
663
|
+
// ── Private ───────────────────────────────────────────
|
|
664
|
+
log(level, generator, message, file, err) {
|
|
665
|
+
const entry = {
|
|
666
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
667
|
+
level,
|
|
668
|
+
generator,
|
|
669
|
+
message,
|
|
670
|
+
...file && { file },
|
|
671
|
+
...err && { error: err.message }
|
|
672
|
+
};
|
|
673
|
+
this.entries.push(entry);
|
|
674
|
+
if (this.quiet) return;
|
|
675
|
+
const tag = pc4.dim(`[${generator}]`);
|
|
676
|
+
const formatted = CONSOLE_FORMATTERS[level](message);
|
|
677
|
+
console.log(` ${tag} ${formatted}`);
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
var CONSOLE_FORMATTERS = {
|
|
681
|
+
info: (msg) => msg,
|
|
682
|
+
success: (msg) => pc4.green(`\u2713 ${msg}`),
|
|
683
|
+
warn: (msg) => pc4.yellow(`\u26A0 ${msg}`),
|
|
684
|
+
error: (msg) => pc4.red(`\u2717 ${msg}`),
|
|
685
|
+
// The engine never emits "skip"; sync uses it to mark conflict-skipped keys.
|
|
686
|
+
// Included so CONSOLE_FORMATTERS covers the full LogLevel union.
|
|
687
|
+
skip: (msg) => pc4.dim(`\u21B7 ${msg}`)
|
|
688
|
+
};
|
|
689
|
+
|
|
690
|
+
// src/types/env-fragments.ts
|
|
691
|
+
function createEnvFragmentBag() {
|
|
692
|
+
const fragments = [];
|
|
693
|
+
return {
|
|
694
|
+
fragments,
|
|
695
|
+
push(fragment) {
|
|
696
|
+
fragments.push(fragment);
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// src/engine/runner.ts
|
|
702
|
+
var execAsync = promisify(exec);
|
|
703
|
+
function isExecException(e) {
|
|
704
|
+
return e instanceof Error && ("stdout" in e || "stderr" in e || "code" in e || "signal" in e);
|
|
705
|
+
}
|
|
706
|
+
function asString(v) {
|
|
707
|
+
if (v === void 0) return "";
|
|
708
|
+
return typeof v === "string" ? v : v.toString("utf-8");
|
|
709
|
+
}
|
|
710
|
+
async function runCommand(options) {
|
|
711
|
+
const { cmd, cwd, env, maxBuffer, generator, logger } = options;
|
|
712
|
+
logger.info(generator, `exec: ${cmd}`);
|
|
713
|
+
try {
|
|
714
|
+
const { stdout, stderr } = await execAsync(cmd, { cwd, env, maxBuffer });
|
|
715
|
+
const stdoutStr = asString(stdout);
|
|
716
|
+
const stderrStr = asString(stderr);
|
|
717
|
+
if (stdoutStr) logger.info(generator, stdoutStr.trimEnd());
|
|
718
|
+
if (stderrStr) logger.warn(generator, stderrStr.trimEnd());
|
|
719
|
+
return { stdout: stdoutStr, stderr: stderrStr, exitCode: 0 };
|
|
720
|
+
} catch (e) {
|
|
721
|
+
if (!isExecException(e)) {
|
|
722
|
+
throw e instanceof Error ? e : new Error(String(e));
|
|
723
|
+
}
|
|
724
|
+
const stdoutStr = asString(e.stdout);
|
|
725
|
+
const stderrStr = asString(e.stderr);
|
|
726
|
+
if (stdoutStr) logger.info(generator, stdoutStr.trimEnd());
|
|
727
|
+
if (stderrStr) logger.warn(generator, stderrStr.trimEnd());
|
|
728
|
+
const exitCode = typeof e.code === "number" ? e.code : 1;
|
|
729
|
+
throw new Error(
|
|
730
|
+
`Command "${cmd}" exited with code ${exitCode}: ${stderrStr}`,
|
|
731
|
+
{ cause: e }
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
async function run(options) {
|
|
736
|
+
const {
|
|
737
|
+
generators: generators2,
|
|
738
|
+
config,
|
|
739
|
+
targetDir,
|
|
740
|
+
dryRun = false,
|
|
741
|
+
quiet = false
|
|
742
|
+
} = options;
|
|
743
|
+
const logger = new Logger(quiet);
|
|
744
|
+
const template = DEFAULT_TEMPLATE_ENGINE;
|
|
745
|
+
const envBag = createEnvFragmentBag();
|
|
746
|
+
let order;
|
|
747
|
+
try {
|
|
748
|
+
order = buildDAG(generators2, config);
|
|
749
|
+
} catch (err) {
|
|
750
|
+
return {
|
|
751
|
+
succeeded: [],
|
|
752
|
+
failed: [
|
|
753
|
+
{
|
|
754
|
+
name: "dag",
|
|
755
|
+
error: err instanceof Error ? err : new Error(String(err))
|
|
756
|
+
}
|
|
757
|
+
],
|
|
758
|
+
skipped: generators2.map((g) => g.name),
|
|
759
|
+
envBag
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
const succeeded = [];
|
|
763
|
+
const failed = [];
|
|
764
|
+
const skipped = [];
|
|
765
|
+
const allDryRunResults = [];
|
|
766
|
+
const dryRunVfsInstances = [];
|
|
767
|
+
for (let i = 0; i < order.length; i++) {
|
|
768
|
+
const generator = order[i];
|
|
769
|
+
const vfs = new VirtualFS(targetDir);
|
|
770
|
+
const execFn = async (cmd, execOpts) => {
|
|
771
|
+
if (dryRun) {
|
|
772
|
+
logger.info(generator.name, `[dry-run] would exec: ${cmd}`);
|
|
773
|
+
allDryRunResults.push({
|
|
774
|
+
path: cmd,
|
|
775
|
+
action: "exec",
|
|
776
|
+
content: `would exec: ${cmd} (file changes not previewable)`
|
|
777
|
+
});
|
|
778
|
+
return { stdout: "", stderr: "", exitCode: 0 };
|
|
779
|
+
}
|
|
780
|
+
const snapshot = await vfs.snapshotDir();
|
|
781
|
+
try {
|
|
782
|
+
return await runCommand({
|
|
783
|
+
cmd,
|
|
784
|
+
cwd: execOpts?.cwd ?? targetDir,
|
|
785
|
+
env: { ...process.env, ...execOpts?.env },
|
|
786
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
787
|
+
generator: generator.name,
|
|
788
|
+
logger
|
|
789
|
+
});
|
|
790
|
+
} finally {
|
|
791
|
+
await vfs.adoptExternalChanges(snapshot);
|
|
792
|
+
}
|
|
793
|
+
};
|
|
794
|
+
const ctx = {
|
|
795
|
+
config,
|
|
796
|
+
envBag,
|
|
797
|
+
fs: vfs,
|
|
798
|
+
template,
|
|
799
|
+
logger,
|
|
800
|
+
targetDir,
|
|
801
|
+
exec: execFn
|
|
802
|
+
};
|
|
803
|
+
try {
|
|
804
|
+
await generator.execute(ctx);
|
|
805
|
+
if (dryRun) {
|
|
806
|
+
const results = await vfs.dryRun();
|
|
807
|
+
allDryRunResults.push(...results);
|
|
808
|
+
await vfs.commit();
|
|
809
|
+
dryRunVfsInstances.push(vfs);
|
|
810
|
+
} else {
|
|
811
|
+
await vfs.commit();
|
|
812
|
+
}
|
|
813
|
+
succeeded.push(generator.name);
|
|
814
|
+
logger.success(generator.name, "completed");
|
|
815
|
+
} catch (err) {
|
|
816
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
817
|
+
await vfs.rollback();
|
|
818
|
+
if (generator.rollback) {
|
|
819
|
+
try {
|
|
820
|
+
await generator.rollback(ctx);
|
|
821
|
+
} catch (rollbackErr) {
|
|
822
|
+
logger.warn(
|
|
823
|
+
generator.name,
|
|
824
|
+
`rollback handler failed: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
logger.error(generator.name, `failed: ${error.message}`, error);
|
|
829
|
+
failed.push({ name: generator.name, error });
|
|
830
|
+
for (let j = i + 1; j < order.length; j++) {
|
|
831
|
+
skipped.push(order[j].name);
|
|
832
|
+
}
|
|
833
|
+
break;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
if (dryRun) {
|
|
837
|
+
for (const vfs of dryRunVfsInstances.reverse()) {
|
|
838
|
+
await vfs.rollback();
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
const logDir = join4(targetDir, ".scale-stack");
|
|
842
|
+
if (!dryRun) {
|
|
843
|
+
await logger.flush(logDir);
|
|
844
|
+
}
|
|
845
|
+
return {
|
|
846
|
+
succeeded,
|
|
847
|
+
failed,
|
|
848
|
+
skipped,
|
|
849
|
+
envBag,
|
|
850
|
+
...dryRun && { dryRunResults: allDryRunResults }
|
|
851
|
+
};
|
|
852
|
+
}
|
|
853
|
+
async function runAfterInstall(opts) {
|
|
854
|
+
const {
|
|
855
|
+
generators: generators2,
|
|
856
|
+
config,
|
|
857
|
+
envBag,
|
|
858
|
+
targetDir,
|
|
859
|
+
succeeded,
|
|
860
|
+
quiet = false
|
|
861
|
+
} = opts;
|
|
862
|
+
const logger = new Logger(quiet);
|
|
863
|
+
const template = DEFAULT_TEMPLATE_ENGINE;
|
|
864
|
+
let ran = false;
|
|
865
|
+
for (const name of succeeded) {
|
|
866
|
+
const generator = generators2.find((g) => g.name === name);
|
|
867
|
+
if (!generator?.afterInstall) continue;
|
|
868
|
+
ran = true;
|
|
869
|
+
const vfs = new VirtualFS(targetDir);
|
|
870
|
+
const execFn = async (cmd, execOpts) => runCommand({
|
|
871
|
+
cmd,
|
|
872
|
+
cwd: execOpts?.cwd ?? targetDir,
|
|
873
|
+
env: { ...process.env, ...execOpts?.env },
|
|
874
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
875
|
+
generator: generator.name,
|
|
876
|
+
logger
|
|
877
|
+
});
|
|
878
|
+
const ctx = {
|
|
879
|
+
config,
|
|
880
|
+
envBag,
|
|
881
|
+
fs: vfs,
|
|
882
|
+
template,
|
|
883
|
+
logger,
|
|
884
|
+
targetDir,
|
|
885
|
+
exec: execFn
|
|
886
|
+
};
|
|
887
|
+
try {
|
|
888
|
+
await generator.afterInstall(ctx);
|
|
889
|
+
await vfs.commit();
|
|
890
|
+
} catch (err) {
|
|
891
|
+
logger.warn(
|
|
892
|
+
generator.name,
|
|
893
|
+
`afterInstall did not complete: ${err instanceof Error ? err.message : String(err)}`
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
if (ran) {
|
|
898
|
+
await logger.flush(join4(targetDir, ".scale-stack"));
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// src/generators/registry.ts
|
|
903
|
+
var generators = /* @__PURE__ */ new Map();
|
|
904
|
+
function registerGenerator(gen) {
|
|
905
|
+
if (generators.has(gen.name)) {
|
|
906
|
+
throw new Error(`Generator "${gen.name}" is already registered`);
|
|
907
|
+
}
|
|
908
|
+
generators.set(gen.name, gen);
|
|
909
|
+
}
|
|
910
|
+
function getRegisteredGenerators() {
|
|
911
|
+
return [...generators.values()];
|
|
912
|
+
}
|
|
913
|
+
|
|
315
914
|
// src/cli/commands/init.ts
|
|
915
|
+
function parseCiProvider(value) {
|
|
916
|
+
if (value === "github" || value === "circleci" || value === "none") {
|
|
917
|
+
return value;
|
|
918
|
+
}
|
|
919
|
+
throw new InvalidArgumentError(
|
|
920
|
+
"CI provider must be one of: github, circleci, none"
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
function parseAnalyticsProvider(value) {
|
|
924
|
+
if (value === "plausible" || value === "posthog" || value === "none") {
|
|
925
|
+
return value;
|
|
926
|
+
}
|
|
927
|
+
throw new InvalidArgumentError(
|
|
928
|
+
"Analytics provider must be one of: plausible, posthog, none"
|
|
929
|
+
);
|
|
930
|
+
}
|
|
316
931
|
function registerInitCommand(program2) {
|
|
317
|
-
program2.command("init").description("Assemble a new SKALST\xC5KK project").argument("[name]", "Project name").option("--name <name>", "Project name (overrides positional argument)").option("--orm", "Use Prisma ORM").option("--auth", "Enable authentication (Better Auth)").option("--chat", "Enable AI chat").option("--i18n", "Enable internationalization").option(
|
|
932
|
+
program2.command("init").description("Assemble a new SKALST\xC5KK project").argument("[name]", "Project name").option("--name <name>", "Project name (overrides positional argument)").option("--orm", "Use Prisma ORM").option("--auth", "Enable authentication (Better Auth)").option("--chat", "Enable AI chat").option("--i18n", "Enable internationalization").option(
|
|
933
|
+
"--ci [provider]",
|
|
934
|
+
"CI pipeline: github, circleci, or none (default: github)",
|
|
935
|
+
parseCiProvider
|
|
936
|
+
).option(
|
|
937
|
+
"--analytics [provider]",
|
|
938
|
+
"Analytics provider: plausible, posthog, or none (default: plausible)",
|
|
939
|
+
parseAnalyticsProvider
|
|
940
|
+
).option("--jobs", "Background jobs (Inngest; not implemented, ignored)").option("--a11y", "Accessibility tooling (coming soon, ignored)").option("--api-client", "API client generation (coming soon, ignored)").option("--pre-commit", "Enable prek pre-commit hooks").option("-y, --yes", "Skip prompts, accept defaults").option("--dry-run", "Show what would be generated without writing files").action(async (positionalName, opts) => {
|
|
318
941
|
const flags = {
|
|
319
942
|
...opts,
|
|
320
943
|
name: opts.name ?? positionalName
|
|
321
944
|
};
|
|
322
945
|
const answers = flags.yes ? {} : await runTechPicker(flags);
|
|
323
946
|
const config = resolveConfig(flags, answers);
|
|
947
|
+
if (flags.jobs) {
|
|
948
|
+
console.log(
|
|
949
|
+
ikea.muted(
|
|
950
|
+
` ${pc5.yellow("\u25B8")} Inngest jobs are not implemented yet; skipping --jobs.`
|
|
951
|
+
)
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
if (flags.a11y || answers.optionalModules?.includes("a11y")) {
|
|
955
|
+
console.log(
|
|
956
|
+
ikea.muted(
|
|
957
|
+
` ${pc5.yellow("\u25B8")} Accessibility tooling is coming soon; skipping --a11y.`
|
|
958
|
+
)
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
if (flags.apiClient || answers.optionalModules?.includes("apiClient")) {
|
|
962
|
+
console.log(
|
|
963
|
+
ikea.muted(
|
|
964
|
+
` ${pc5.yellow("\u25B8")} API client generation is coming soon; skipping --api-client.`
|
|
965
|
+
)
|
|
966
|
+
);
|
|
967
|
+
}
|
|
324
968
|
if (process.stdout.isTTY) {
|
|
325
969
|
await animateConfigLabel(config);
|
|
326
970
|
} else {
|
|
327
971
|
printConfigLabel(config);
|
|
328
972
|
}
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
973
|
+
const targetDir = resolve3(process.cwd(), config.projectName);
|
|
974
|
+
const generators2 = getRegisteredGenerators();
|
|
975
|
+
if (generators2.length === 0) {
|
|
976
|
+
console.log(
|
|
977
|
+
ikea.muted(
|
|
978
|
+
` ${pc5.yellow("\u25B8")} No generators registered \u2014 config collected only.`
|
|
979
|
+
)
|
|
980
|
+
);
|
|
981
|
+
console.log();
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
const result = await run({
|
|
985
|
+
generators: generators2,
|
|
986
|
+
config,
|
|
987
|
+
targetDir,
|
|
988
|
+
dryRun: flags.dryRun
|
|
989
|
+
});
|
|
990
|
+
if (flags.dryRun && result.dryRunResults) {
|
|
991
|
+
console.log();
|
|
992
|
+
console.log(ikea.heading(" Dry-run results:"));
|
|
993
|
+
console.log();
|
|
994
|
+
for (const dr of result.dryRunResults) {
|
|
995
|
+
const tag = dr.action === "exec" ? pc5.cyan("exec") : dr.action === "create" ? pc5.green("create") : dr.action === "copy" ? pc5.blue("copy") : pc5.yellow("modify");
|
|
996
|
+
console.log(` ${tag} ${dr.path}`);
|
|
997
|
+
if (dr.diff) {
|
|
998
|
+
for (const line of dr.diff.split("\n").slice(2)) {
|
|
999
|
+
if (line.startsWith("+")) console.log(` ${pc5.green(line)}`);
|
|
1000
|
+
else if (line.startsWith("-")) console.log(` ${pc5.red(line)}`);
|
|
1001
|
+
else console.log(` ${pc5.dim(line)}`);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
console.log();
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
if (result.failed.length > 0) {
|
|
1009
|
+
console.log();
|
|
1010
|
+
console.log(
|
|
1011
|
+
pc5.red(
|
|
1012
|
+
` \u2717 Generation failed at "${result.failed[0].name}": ${result.failed[0].error.message}`
|
|
1013
|
+
)
|
|
1014
|
+
);
|
|
1015
|
+
if (result.succeeded.length > 0) {
|
|
1016
|
+
console.log(
|
|
1017
|
+
ikea.muted(` Succeeded: ${result.succeeded.join(", ")}`)
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
if (result.skipped.length > 0) {
|
|
1021
|
+
console.log(ikea.muted(` Skipped: ${result.skipped.join(", ")}`));
|
|
1022
|
+
}
|
|
1023
|
+
console.log();
|
|
1024
|
+
process.exitCode = 1;
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
if (!flags.dryRun) {
|
|
1028
|
+
console.log();
|
|
1029
|
+
console.log(ikea.muted(` ${pc5.cyan("\u25B8")} pnpm install\u2026`));
|
|
1030
|
+
try {
|
|
1031
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
1032
|
+
cpExec(
|
|
1033
|
+
"pnpm install",
|
|
1034
|
+
{ cwd: targetDir, maxBuffer: 50 * 1024 * 1024 },
|
|
1035
|
+
(err, stdout, stderr) => {
|
|
1036
|
+
if (stdout) process.stdout.write(stdout);
|
|
1037
|
+
if (stderr) process.stderr.write(stderr);
|
|
1038
|
+
if (err) rejectPromise(err);
|
|
1039
|
+
else resolvePromise();
|
|
1040
|
+
}
|
|
1041
|
+
);
|
|
1042
|
+
});
|
|
1043
|
+
} catch (err) {
|
|
1044
|
+
console.log();
|
|
1045
|
+
console.log(
|
|
1046
|
+
pc5.red(
|
|
1047
|
+
` \u2717 pnpm install failed: ${err instanceof Error ? err.message : String(err)}`
|
|
1048
|
+
)
|
|
1049
|
+
);
|
|
1050
|
+
console.log();
|
|
1051
|
+
process.exitCode = 1;
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
await runAfterInstall({
|
|
1055
|
+
generators: generators2,
|
|
1056
|
+
config,
|
|
1057
|
+
envBag: result.envBag,
|
|
1058
|
+
targetDir,
|
|
1059
|
+
succeeded: result.succeeded
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
console.log();
|
|
1063
|
+
console.log(pc5.green(` \u2713 Project assembled at ${pc5.bold(targetDir)}`));
|
|
334
1064
|
console.log();
|
|
335
1065
|
});
|
|
336
1066
|
}
|
|
337
1067
|
|
|
338
1068
|
// src/cli/commands/sync.ts
|
|
339
|
-
import
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
1069
|
+
import { resolve as resolve6 } from "path";
|
|
1070
|
+
import pc6 from "picocolors";
|
|
1071
|
+
|
|
1072
|
+
// src/sync/run-sync.ts
|
|
1073
|
+
import { mkdir as mkdir6, readFile as readFile5, writeFile as writeFile5 } from "fs/promises";
|
|
1074
|
+
import { existsSync as existsSync10 } from "fs";
|
|
1075
|
+
import { createPatch as createPatch2 } from "diff";
|
|
1076
|
+
import { exec as exec2 } from "child_process";
|
|
1077
|
+
import { join as join12 } from "path";
|
|
1078
|
+
import { promisify as promisify2 } from "util";
|
|
1079
|
+
|
|
1080
|
+
// src/sync/cli-version.ts
|
|
1081
|
+
import { readFileSync } from "fs";
|
|
1082
|
+
import { dirname as dirname2, resolve as resolve4 } from "path";
|
|
1083
|
+
import { fileURLToPath } from "url";
|
|
1084
|
+
var cachedVersion;
|
|
1085
|
+
function getCliVersion() {
|
|
1086
|
+
if (cachedVersion !== void 0) return cachedVersion;
|
|
1087
|
+
const __dirname = dirname2(fileURLToPath(import.meta.url));
|
|
1088
|
+
try {
|
|
1089
|
+
const pkg = JSON.parse(
|
|
1090
|
+
readFileSync(resolve4(__dirname, "../package.json"), "utf-8")
|
|
346
1091
|
);
|
|
1092
|
+
cachedVersion = pkg.version ?? "0.0.0";
|
|
1093
|
+
} catch {
|
|
1094
|
+
cachedVersion = "0.0.0";
|
|
1095
|
+
}
|
|
1096
|
+
return cachedVersion;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// src/sync/targets.ts
|
|
1100
|
+
import { existsSync as existsSync5 } from "fs";
|
|
1101
|
+
import { join as join6 } from "path";
|
|
1102
|
+
|
|
1103
|
+
// src/config/eslint-config-contents.ts
|
|
1104
|
+
function shouldIgnorePrismaGeneratedOutput(config) {
|
|
1105
|
+
return config.orm === "prisma";
|
|
1106
|
+
}
|
|
1107
|
+
function shouldIgnoreAiElementsGeneratedOutput(config) {
|
|
1108
|
+
return config.aiChat === true;
|
|
1109
|
+
}
|
|
1110
|
+
function buildEslintConfigMjsContents(config) {
|
|
1111
|
+
return renderTemplateFile("eslint-prettier/eslint.config.mjs.ejs", {
|
|
1112
|
+
ignoreAiElementsGeneratedOutput: shouldIgnoreAiElementsGeneratedOutput(config),
|
|
1113
|
+
ignorePrismaGeneratedOutput: shouldIgnorePrismaGeneratedOutput(config)
|
|
347
1114
|
});
|
|
348
1115
|
}
|
|
349
1116
|
|
|
350
|
-
// src/
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
var
|
|
354
|
-
"
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
var
|
|
360
|
-
var
|
|
361
|
-
var
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
function
|
|
366
|
-
|
|
367
|
-
|
|
1117
|
+
// src/config/ai-chat-fragments.ts
|
|
1118
|
+
var AI_SDK_VERSION = "^6.0.175";
|
|
1119
|
+
var AI_SDK_REACT_VERSION = "^3.0.177";
|
|
1120
|
+
var AI_CHAT_DEPENDENCIES = {
|
|
1121
|
+
"@ai-sdk/react": AI_SDK_REACT_VERSION,
|
|
1122
|
+
ai: AI_SDK_VERSION
|
|
1123
|
+
};
|
|
1124
|
+
|
|
1125
|
+
// src/config/auth-fragments.ts
|
|
1126
|
+
var BETTER_AUTH_VERSION = "^1.6.9";
|
|
1127
|
+
var BETTER_AUTH_PRISMA_ADAPTER_VERSION = "^1.6.9";
|
|
1128
|
+
var AUTH_DEPENDENCIES = {
|
|
1129
|
+
"@better-auth/prisma-adapter": BETTER_AUTH_PRISMA_ADAPTER_VERSION,
|
|
1130
|
+
"better-auth": BETTER_AUTH_VERSION
|
|
1131
|
+
};
|
|
1132
|
+
function buildAuthEnvFragment() {
|
|
1133
|
+
return {
|
|
1134
|
+
variables: [
|
|
1135
|
+
{
|
|
1136
|
+
name: "BETTER_AUTH_SECRET",
|
|
1137
|
+
scope: "server",
|
|
1138
|
+
schema: "z.string().min(32)",
|
|
1139
|
+
example: "replace-with-at-least-32-character-secret"
|
|
1140
|
+
},
|
|
1141
|
+
{
|
|
1142
|
+
name: "BETTER_AUTH_URL",
|
|
1143
|
+
scope: "server",
|
|
1144
|
+
schema: "z.string().url()",
|
|
1145
|
+
example: "http://localhost:3000"
|
|
1146
|
+
},
|
|
1147
|
+
{
|
|
1148
|
+
name: "MICROSOFT_CLIENT_ID",
|
|
1149
|
+
scope: "server",
|
|
1150
|
+
schema: "z.string().min(1)",
|
|
1151
|
+
example: "replace-with-microsoft-client-id"
|
|
1152
|
+
},
|
|
1153
|
+
{
|
|
1154
|
+
name: "MICROSOFT_CLIENT_SECRET",
|
|
1155
|
+
scope: "server",
|
|
1156
|
+
schema: "z.string().min(1)",
|
|
1157
|
+
example: "replace-with-microsoft-client-secret"
|
|
1158
|
+
},
|
|
1159
|
+
{
|
|
1160
|
+
name: "MICROSOFT_TENANT_ID",
|
|
1161
|
+
scope: "server",
|
|
1162
|
+
schema: "z.string().min(1)",
|
|
1163
|
+
example: "common"
|
|
1164
|
+
}
|
|
1165
|
+
]
|
|
1166
|
+
};
|
|
368
1167
|
}
|
|
369
|
-
|
|
370
|
-
|
|
1168
|
+
|
|
1169
|
+
// src/config/env-management-fragments.ts
|
|
1170
|
+
var T3_ENV_NEXTJS_VERSION = "^0.13.11";
|
|
1171
|
+
var ZOD_VERSION = "^4.3.6";
|
|
1172
|
+
var ENV_MANAGEMENT_DEPENDENCIES = {
|
|
1173
|
+
"@t3-oss/env-nextjs": T3_ENV_NEXTJS_VERSION,
|
|
1174
|
+
zod: ZOD_VERSION
|
|
1175
|
+
};
|
|
1176
|
+
|
|
1177
|
+
// src/config/form-handling-fragments.ts
|
|
1178
|
+
var HOOKFORM_RESOLVERS_VERSION = "^5.2.2";
|
|
1179
|
+
var NEXT_SAFE_ACTION_RHF_ADAPTER_VERSION = "^2.0.6";
|
|
1180
|
+
var REACT_HOOK_FORM_VERSION = "^7.74.0";
|
|
1181
|
+
var FORM_HANDLING_DEPENDENCIES = {
|
|
1182
|
+
"@hookform/resolvers": HOOKFORM_RESOLVERS_VERSION,
|
|
1183
|
+
"@next-safe-action/adapter-react-hook-form": NEXT_SAFE_ACTION_RHF_ADAPTER_VERSION,
|
|
1184
|
+
"react-hook-form": REACT_HOOK_FORM_VERSION
|
|
1185
|
+
};
|
|
1186
|
+
|
|
1187
|
+
// src/config/i18n-fragments.ts
|
|
1188
|
+
var NEXT_INTL_VERSION = "^4.6.0";
|
|
1189
|
+
var I18N_DEPENDENCIES = {
|
|
1190
|
+
"next-intl": NEXT_INTL_VERSION
|
|
1191
|
+
};
|
|
1192
|
+
|
|
1193
|
+
// src/config/orm-fragments.ts
|
|
1194
|
+
var PRISMA_VERSION = "^7.8.0";
|
|
1195
|
+
var PRISMA_CLIENT_VERSION = "^7.8.0";
|
|
1196
|
+
var PRISMA_ADAPTER_PG_VERSION = "^7.8.0";
|
|
1197
|
+
var PG_VERSION = "^8.20.0";
|
|
1198
|
+
var TYPES_PG_VERSION = "^8.20.0";
|
|
1199
|
+
var TSX_VERSION = "^4.21.0";
|
|
1200
|
+
var ORM_DEPENDENCIES = {
|
|
1201
|
+
"@prisma/adapter-pg": PRISMA_ADAPTER_PG_VERSION,
|
|
1202
|
+
"@prisma/client": PRISMA_CLIENT_VERSION,
|
|
1203
|
+
pg: PG_VERSION
|
|
1204
|
+
};
|
|
1205
|
+
var ORM_DEV_DEPENDENCIES = {
|
|
1206
|
+
"@types/pg": TYPES_PG_VERSION,
|
|
1207
|
+
prisma: PRISMA_VERSION,
|
|
1208
|
+
tsx: TSX_VERSION
|
|
1209
|
+
};
|
|
1210
|
+
var ORM_SCRIPT_DESCRIPTIONS = {
|
|
1211
|
+
"db:generate": "Regenerate Prisma Client from prisma/schema.prisma.",
|
|
1212
|
+
"db:validate": "Validate prisma/schema.prisma and prisma.config.ts.",
|
|
1213
|
+
"db:migrate": "Create and apply a local development migration.",
|
|
1214
|
+
"db:migrate:deploy": "Apply committed migrations in CI or production.",
|
|
1215
|
+
"db:migrate:status": "Show whether database migrations are in sync.",
|
|
1216
|
+
"db:push": "Push the Prisma schema to the database without a migration.",
|
|
1217
|
+
"db:pull": "Introspect the database and update the Prisma schema.",
|
|
1218
|
+
"db:studio": "Open Prisma Studio for local data browsing."
|
|
1219
|
+
};
|
|
1220
|
+
function buildOrmHelpScript() {
|
|
1221
|
+
const lines = [
|
|
1222
|
+
"Prisma database commands:",
|
|
1223
|
+
...Object.entries(ORM_SCRIPT_DESCRIPTIONS).map(
|
|
1224
|
+
([script, description]) => ` pnpm ${script.padEnd(18)} ${description}`
|
|
1225
|
+
)
|
|
1226
|
+
];
|
|
1227
|
+
const source = `console.log(${JSON.stringify(lines.join("\n"))})`;
|
|
1228
|
+
return `node -e ${JSON.stringify(source)}`;
|
|
371
1229
|
}
|
|
372
|
-
var
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
1230
|
+
var ORM_SCRIPTS = {
|
|
1231
|
+
"db:help": buildOrmHelpScript(),
|
|
1232
|
+
"db:generate": "prisma generate",
|
|
1233
|
+
"db:validate": "prisma validate",
|
|
1234
|
+
"db:migrate": "prisma migrate dev",
|
|
1235
|
+
"db:migrate:deploy": "prisma migrate deploy",
|
|
1236
|
+
"db:migrate:status": "prisma migrate status",
|
|
1237
|
+
"db:push": "prisma db push",
|
|
1238
|
+
"db:pull": "prisma db pull",
|
|
1239
|
+
"db:studio": "prisma studio"
|
|
1240
|
+
};
|
|
1241
|
+
function buildPostgresDatabaseName(projectName) {
|
|
1242
|
+
const normalized = projectName.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").replace(/_+/g, "_");
|
|
1243
|
+
return normalized || "app";
|
|
384
1244
|
}
|
|
385
|
-
function
|
|
386
|
-
|
|
387
|
-
return "\u2588".repeat(n) + "\u2591".repeat(width - n);
|
|
1245
|
+
function buildDatabaseUrlExample(projectName) {
|
|
1246
|
+
return `postgresql://postgres:postgres@localhost:5432/${buildPostgresDatabaseName(projectName)}`;
|
|
388
1247
|
}
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
1248
|
+
function buildOrmEnvFragment(projectName) {
|
|
1249
|
+
return {
|
|
1250
|
+
variables: [
|
|
1251
|
+
{
|
|
1252
|
+
name: "DATABASE_URL",
|
|
1253
|
+
scope: "server",
|
|
1254
|
+
schema: "z.string().url()",
|
|
1255
|
+
example: buildDatabaseUrlExample(projectName)
|
|
1256
|
+
}
|
|
1257
|
+
]
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// src/config/server-actions-fragments.ts
|
|
1262
|
+
var NEXT_SAFE_ACTION_VERSION = "^8.5.2";
|
|
1263
|
+
var NEXT_SAFE_ACTION_BETTER_AUTH_ADAPTER_VERSION = "^0.1.6";
|
|
1264
|
+
var SERVER_ACTIONS_DEPENDENCIES = {
|
|
1265
|
+
"next-safe-action": NEXT_SAFE_ACTION_VERSION
|
|
1266
|
+
};
|
|
1267
|
+
var SERVER_ACTIONS_AUTH_DEPENDENCIES = {
|
|
1268
|
+
"@next-safe-action/adapter-better-auth": NEXT_SAFE_ACTION_BETTER_AUTH_ADAPTER_VERSION
|
|
1269
|
+
};
|
|
1270
|
+
|
|
1271
|
+
// src/config/state-fragments.ts
|
|
1272
|
+
var NUQS_VERSION = "^2.8.9";
|
|
1273
|
+
var ZUSTAND_VERSION = "^5.0.12";
|
|
1274
|
+
var STATE_DEPENDENCIES = {
|
|
1275
|
+
nuqs: NUQS_VERSION,
|
|
1276
|
+
zustand: ZUSTAND_VERSION
|
|
1277
|
+
};
|
|
1278
|
+
|
|
1279
|
+
// src/config/package-deps-baseline.ts
|
|
1280
|
+
var PACKAGE_DEPS_BASELINE = {
|
|
1281
|
+
...AI_CHAT_DEPENDENCIES,
|
|
1282
|
+
...AUTH_DEPENDENCIES,
|
|
1283
|
+
...ENV_MANAGEMENT_DEPENDENCIES,
|
|
1284
|
+
...FORM_HANDLING_DEPENDENCIES,
|
|
1285
|
+
...I18N_DEPENDENCIES,
|
|
1286
|
+
...ORM_DEPENDENCIES,
|
|
1287
|
+
...SERVER_ACTIONS_DEPENDENCIES,
|
|
1288
|
+
...STATE_DEPENDENCIES
|
|
1289
|
+
};
|
|
1290
|
+
|
|
1291
|
+
// src/config/eslint-prettier-fragments.ts
|
|
1292
|
+
var PRETTIER_VERSION = "^3.8.3";
|
|
1293
|
+
var ESLINT_CONFIG_PRETTIER_VERSION = "^10.1.8";
|
|
1294
|
+
var PRETTIER_RC_BASELINE = {
|
|
1295
|
+
semi: true,
|
|
1296
|
+
singleQuote: false,
|
|
1297
|
+
trailingComma: "all",
|
|
1298
|
+
printWidth: 80,
|
|
1299
|
+
tabWidth: 2,
|
|
1300
|
+
endOfLine: "lf"
|
|
1301
|
+
};
|
|
1302
|
+
var ESLINT_PRETTIER_DEV_DEPS = {
|
|
1303
|
+
prettier: PRETTIER_VERSION,
|
|
1304
|
+
"eslint-config-prettier": ESLINT_CONFIG_PRETTIER_VERSION
|
|
1305
|
+
};
|
|
1306
|
+
var ESLINT_PRETTIER_SCRIPTS = {
|
|
1307
|
+
lint: "eslint && prettier --check .",
|
|
1308
|
+
format: "prettier --write .",
|
|
1309
|
+
"format:check": "prettier --check ."
|
|
1310
|
+
};
|
|
1311
|
+
|
|
1312
|
+
// src/config/package-dev-deps-baseline.ts
|
|
1313
|
+
var PACKAGE_DEV_DEPS_BASELINE = {
|
|
1314
|
+
...ESLINT_PRETTIER_DEV_DEPS,
|
|
1315
|
+
...ORM_DEV_DEPENDENCIES
|
|
1316
|
+
};
|
|
1317
|
+
|
|
1318
|
+
// src/config/package-scripts-baseline.ts
|
|
1319
|
+
var PACKAGE_SCRIPTS_BASELINE = {
|
|
1320
|
+
dev: "next dev",
|
|
1321
|
+
build: "next build",
|
|
1322
|
+
start: "next start",
|
|
1323
|
+
...ESLINT_PRETTIER_SCRIPTS,
|
|
1324
|
+
...ORM_SCRIPTS
|
|
1325
|
+
};
|
|
1326
|
+
|
|
1327
|
+
// src/config/pre-commit-fragments.ts
|
|
1328
|
+
import { existsSync as existsSync4 } from "fs";
|
|
1329
|
+
import { dirname as dirname3, join as join5, resolve as resolve5 } from "path";
|
|
1330
|
+
var PRE_COMMIT_COMMON_SCRIPTS = {
|
|
1331
|
+
typecheck: "tsc --noEmit"
|
|
1332
|
+
};
|
|
1333
|
+
var PRE_COMMIT_STANDALONE_SCRIPTS = {
|
|
1334
|
+
prepare: "pnpm dlx @j178/prek install"
|
|
1335
|
+
};
|
|
1336
|
+
var PRE_COMMIT_MONOREPO_SCRIPTS = {
|
|
1337
|
+
"pre-commit": "pnpm dlx @j178/prek run --directory ."
|
|
1338
|
+
};
|
|
1339
|
+
function buildPrekTomlContents() {
|
|
1340
|
+
return renderTemplateFile("pre-commit/prek.toml.ejs", {});
|
|
1341
|
+
}
|
|
1342
|
+
function inferPreCommitMode(targetDir) {
|
|
1343
|
+
let current = dirname3(resolve5(targetDir));
|
|
1344
|
+
while (true) {
|
|
1345
|
+
if (existsSync4(join5(current, ".git"))) return "monorepo";
|
|
1346
|
+
const parent = dirname3(current);
|
|
1347
|
+
if (parent === current) return "standalone";
|
|
1348
|
+
current = parent;
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
function buildPreCommitScripts(mode) {
|
|
1352
|
+
return {
|
|
1353
|
+
...PRE_COMMIT_COMMON_SCRIPTS,
|
|
1354
|
+
...mode === "standalone" ? PRE_COMMIT_STANDALONE_SCRIPTS : PRE_COMMIT_MONOREPO_SCRIPTS
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
// src/config/next-config-contents.ts
|
|
1359
|
+
function shouldEnableAuthInterrupts(context) {
|
|
1360
|
+
return context ? context.config.authStrategy !== "none" : false;
|
|
1361
|
+
}
|
|
1362
|
+
function shouldEnableNextIntl(context) {
|
|
1363
|
+
return context ? context.config.i18n === true : false;
|
|
1364
|
+
}
|
|
1365
|
+
function buildNextConfigTsContents(context) {
|
|
1366
|
+
return renderTemplateFile("core/next.config.ts.ejs", {
|
|
1367
|
+
authInterrupts: shouldEnableAuthInterrupts(context),
|
|
1368
|
+
nextIntl: shouldEnableNextIntl(context)
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
// src/config/tsconfig-managed-baseline.ts
|
|
1373
|
+
var BASE_EXCLUDE = [
|
|
1374
|
+
"node_modules",
|
|
1375
|
+
// shadcn/ui registry files are generated vendor code. Imported components
|
|
1376
|
+
// are still checked through their app imports.
|
|
1377
|
+
"src/components/ui/**/*.tsx",
|
|
1378
|
+
"src/hooks/use-mobile.ts"
|
|
1379
|
+
];
|
|
1380
|
+
function buildTsconfigManagedBaseline(config) {
|
|
1381
|
+
const exclude = [
|
|
1382
|
+
...BASE_EXCLUDE,
|
|
1383
|
+
...config.aiChat ? ["src/components/ai-elements/**/*.tsx"] : []
|
|
1384
|
+
];
|
|
1385
|
+
return {
|
|
1386
|
+
...TSCONFIG_MANAGED_BASELINE,
|
|
1387
|
+
exclude
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
var TSCONFIG_MANAGED_BASELINE = {
|
|
1391
|
+
compilerOptions: {
|
|
1392
|
+
target: "ES2017",
|
|
1393
|
+
lib: ["dom", "dom.iterable", "esnext"],
|
|
1394
|
+
allowJs: true,
|
|
1395
|
+
skipLibCheck: true,
|
|
1396
|
+
strict: true,
|
|
1397
|
+
noEmit: true,
|
|
1398
|
+
esModuleInterop: true,
|
|
1399
|
+
module: "esnext",
|
|
1400
|
+
moduleResolution: "bundler",
|
|
1401
|
+
resolveJsonModule: true,
|
|
1402
|
+
isolatedModules: true,
|
|
1403
|
+
jsx: "react-jsx",
|
|
1404
|
+
incremental: true,
|
|
1405
|
+
plugins: [{ name: "next" }],
|
|
1406
|
+
// "Super Duper" Strictness Additions
|
|
1407
|
+
noUncheckedIndexedAccess: true,
|
|
1408
|
+
exactOptionalPropertyTypes: true,
|
|
1409
|
+
noPropertyAccessFromIndexSignature: true,
|
|
1410
|
+
noImplicitOverride: true,
|
|
1411
|
+
// Code Cleanliness
|
|
1412
|
+
noUnusedLocals: true,
|
|
1413
|
+
noUnusedParameters: true,
|
|
1414
|
+
noImplicitReturns: true,
|
|
1415
|
+
noFallthroughCasesInSwitch: true,
|
|
1416
|
+
// Modern Environment
|
|
1417
|
+
forceConsistentCasingInFileNames: true,
|
|
1418
|
+
verbatimModuleSyntax: true
|
|
1419
|
+
},
|
|
1420
|
+
include: [
|
|
1421
|
+
"next-env.d.ts",
|
|
1422
|
+
"**/*.ts",
|
|
1423
|
+
"**/*.tsx",
|
|
1424
|
+
".next/types/**/*.ts",
|
|
1425
|
+
".next/dev/types/**/*.ts",
|
|
1426
|
+
"**/*.mts"
|
|
1427
|
+
],
|
|
1428
|
+
exclude: BASE_EXCLUDE
|
|
1429
|
+
};
|
|
1430
|
+
|
|
1431
|
+
// src/sync/targets.ts
|
|
1432
|
+
function inferOrmProvider(context) {
|
|
1433
|
+
if (!context) return "none";
|
|
1434
|
+
return existsSync5(join6(context.projectRoot, "prisma/schema.prisma")) ? "prisma" : "none";
|
|
1435
|
+
}
|
|
1436
|
+
function inferAuthStrategy(context) {
|
|
1437
|
+
if (!context) return "none";
|
|
1438
|
+
return existsSync5(join6(context.projectRoot, "src/lib/auth.ts")) ? "stateless" : "none";
|
|
1439
|
+
}
|
|
1440
|
+
function inferAiChat(context) {
|
|
1441
|
+
if (!context) return false;
|
|
1442
|
+
return existsSync5(join6(context.projectRoot, "src/app/chat/_hooks/useChat.ts")) || existsSync5(join6(context.projectRoot, "src/app/api/chat/route.ts"));
|
|
1443
|
+
}
|
|
1444
|
+
function inferI18n(context) {
|
|
1445
|
+
if (!context) return false;
|
|
1446
|
+
return existsSync5(join6(context.projectRoot, "src/i18n/routing.ts")) || existsSync5(join6(context.projectRoot, "messages/en.json"));
|
|
1447
|
+
}
|
|
1448
|
+
function inferScaleStackConfig(context) {
|
|
1449
|
+
return {
|
|
1450
|
+
projectName: context ? "synced-project" : "project",
|
|
1451
|
+
orm: inferOrmProvider(context),
|
|
1452
|
+
authStrategy: inferAuthStrategy(context),
|
|
1453
|
+
aiChat: inferAiChat(context),
|
|
1454
|
+
i18n: inferI18n(context),
|
|
1455
|
+
ci: "none",
|
|
1456
|
+
analytics: "none",
|
|
1457
|
+
jobs: false,
|
|
1458
|
+
a11y: false,
|
|
1459
|
+
apiClient: false,
|
|
1460
|
+
preCommit: context ? hasPrekConfig(context) : false
|
|
1461
|
+
};
|
|
1462
|
+
}
|
|
1463
|
+
function hasPrekConfig(context) {
|
|
1464
|
+
return existsSync5(join6(context.projectRoot, "prek.toml"));
|
|
1465
|
+
}
|
|
1466
|
+
var SYNC_TARGETS = [
|
|
1467
|
+
{
|
|
1468
|
+
relativePath: "tsconfig.json",
|
|
1469
|
+
kind: "json",
|
|
1470
|
+
getDesiredFragment: (context) => structuredClone(
|
|
1471
|
+
buildTsconfigManagedBaseline(inferScaleStackConfig(context))
|
|
1472
|
+
)
|
|
1473
|
+
},
|
|
1474
|
+
{
|
|
1475
|
+
relativePath: "package.json",
|
|
1476
|
+
kind: "json",
|
|
1477
|
+
getDesiredFragment: (context) => ({
|
|
1478
|
+
scripts: {
|
|
1479
|
+
...structuredClone(PACKAGE_SCRIPTS_BASELINE),
|
|
1480
|
+
...context && hasPrekConfig(context) ? buildPreCommitScripts(inferPreCommitMode(context.projectRoot)) : {}
|
|
1481
|
+
},
|
|
1482
|
+
dependencies: structuredClone(PACKAGE_DEPS_BASELINE),
|
|
1483
|
+
devDependencies: structuredClone(PACKAGE_DEV_DEPS_BASELINE)
|
|
1484
|
+
})
|
|
1485
|
+
},
|
|
1486
|
+
{
|
|
1487
|
+
relativePath: "next.config.ts",
|
|
1488
|
+
kind: "text",
|
|
1489
|
+
getDesiredContent: (context) => buildNextConfigTsContents({
|
|
1490
|
+
config: {
|
|
1491
|
+
authStrategy: inferAuthStrategy(context),
|
|
1492
|
+
i18n: inferI18n(context)
|
|
1493
|
+
}
|
|
1494
|
+
})
|
|
1495
|
+
},
|
|
1496
|
+
{
|
|
1497
|
+
relativePath: "eslint.config.mjs",
|
|
1498
|
+
kind: "text",
|
|
1499
|
+
getDesiredContent: (context) => buildEslintConfigMjsContents(inferScaleStackConfig(context))
|
|
1500
|
+
},
|
|
1501
|
+
{
|
|
1502
|
+
relativePath: ".prettierrc",
|
|
1503
|
+
kind: "json",
|
|
1504
|
+
getDesiredFragment: () => structuredClone(PRETTIER_RC_BASELINE)
|
|
1505
|
+
},
|
|
1506
|
+
{
|
|
1507
|
+
relativePath: "prek.toml",
|
|
1508
|
+
kind: "text",
|
|
1509
|
+
condition: hasPrekConfig,
|
|
1510
|
+
getDesiredContent: () => buildPrekTomlContents()
|
|
1511
|
+
}
|
|
1512
|
+
];
|
|
1513
|
+
|
|
1514
|
+
// src/sync/last-sync.ts
|
|
1515
|
+
import { readFile as readFile2, writeFile as writeFile3, mkdir as mkdir3 } from "fs/promises";
|
|
1516
|
+
import { existsSync as existsSync6 } from "fs";
|
|
1517
|
+
import { dirname as dirname4, join as join7 } from "path";
|
|
1518
|
+
|
|
1519
|
+
// src/sync/constants.ts
|
|
1520
|
+
var MIGRATION_GUIDE_URL = "https://github.com/scaleapi/scale-stack/blob/main/IMPLEMENTATION_PLAN.md#phase-5--sync-command-early-validation";
|
|
1521
|
+
var SCALE_STACK_DIR = ".scale-stack";
|
|
1522
|
+
var LAST_SYNC_FILE = ".scale-stack/last-sync.json";
|
|
1523
|
+
var SYNC_LOG_FILE = ".scale-stack/sync.log";
|
|
1524
|
+
var PRE_SYNC_BACKUP_DIR = ".scale-stack/pre-sync-backup";
|
|
1525
|
+
|
|
1526
|
+
// src/sync/last-sync.ts
|
|
1527
|
+
import { cloneDeep } from "es-toolkit/compat";
|
|
1528
|
+
function isTextMarker(v) {
|
|
1529
|
+
return typeof v === "object" && v !== null && "__content" in v && typeof v.__content === "string";
|
|
1530
|
+
}
|
|
1531
|
+
async function readLastSync(projectRoot) {
|
|
1532
|
+
const abs = join7(projectRoot, LAST_SYNC_FILE);
|
|
1533
|
+
if (!existsSync6(abs)) return null;
|
|
1534
|
+
try {
|
|
1535
|
+
const raw = await readFile2(abs, "utf-8");
|
|
1536
|
+
const data = JSON.parse(raw);
|
|
1537
|
+
if (!data || typeof data.cliVersion !== "string" || !data.files) {
|
|
1538
|
+
return null;
|
|
1539
|
+
}
|
|
1540
|
+
return data;
|
|
1541
|
+
} catch {
|
|
1542
|
+
return null;
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
async function writeLastSync(projectRoot, data) {
|
|
1546
|
+
const abs = join7(projectRoot, LAST_SYNC_FILE);
|
|
1547
|
+
await mkdir3(dirname4(abs), { recursive: true });
|
|
1548
|
+
const body = `${JSON.stringify(data, null, 2)}
|
|
1549
|
+
`;
|
|
1550
|
+
await writeFile3(abs, body, "utf-8");
|
|
1551
|
+
}
|
|
1552
|
+
function getJsonFragment(last, relativePath) {
|
|
1553
|
+
const entry = last?.files?.[relativePath];
|
|
1554
|
+
if (!entry || isTextMarker(entry)) return void 0;
|
|
1555
|
+
return cloneDeep(entry);
|
|
1556
|
+
}
|
|
1557
|
+
function getTextContent(last, relativePath) {
|
|
1558
|
+
const entry = last?.files?.[relativePath];
|
|
1559
|
+
if (!entry || !isTextMarker(entry)) return void 0;
|
|
1560
|
+
return entry.__content;
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// src/sync/version-guard.ts
|
|
1564
|
+
import semver from "semver";
|
|
1565
|
+
var SyncVersionAbortError = class extends Error {
|
|
1566
|
+
constructor(message, recorded, current) {
|
|
1567
|
+
super(message);
|
|
1568
|
+
this.recorded = recorded;
|
|
1569
|
+
this.current = current;
|
|
1570
|
+
this.name = "SyncVersionAbortError";
|
|
1571
|
+
}
|
|
1572
|
+
recorded;
|
|
1573
|
+
current;
|
|
1574
|
+
code = "SYNC_VERSION_ABORT";
|
|
1575
|
+
};
|
|
1576
|
+
function assertSyncVersionCompatible(recordedVersion, currentVersion) {
|
|
1577
|
+
if (!recordedVersion?.trim()) return;
|
|
1578
|
+
const rec = semver.coerce(recordedVersion);
|
|
1579
|
+
const cur = semver.coerce(currentVersion);
|
|
1580
|
+
if (!rec || !cur) return;
|
|
1581
|
+
if (cur.major > rec.major + 1) {
|
|
1582
|
+
throw new SyncVersionAbortError(
|
|
1583
|
+
`Scale Stack CLI (${currentVersion}) is more than one major version ahead of the project baseline (${recordedVersion}). See the migration guide: ${MIGRATION_GUIDE_URL}`,
|
|
1584
|
+
recordedVersion,
|
|
1585
|
+
currentVersion
|
|
1586
|
+
);
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
// src/sync/internal/log-entry.ts
|
|
1591
|
+
import { mkdir as mkdir4, writeFile as writeFile4 } from "fs/promises";
|
|
1592
|
+
import { join as join8 } from "path";
|
|
1593
|
+
function nowIso() {
|
|
1594
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
1595
|
+
}
|
|
1596
|
+
async function appendSyncLog(projectRoot, entries) {
|
|
1597
|
+
if (entries.length === 0) return;
|
|
1598
|
+
const dir = join8(projectRoot, SCALE_STACK_DIR);
|
|
1599
|
+
await mkdir4(dir, { recursive: true });
|
|
1600
|
+
const logPath = join8(projectRoot, SYNC_LOG_FILE);
|
|
1601
|
+
const lines = entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
|
|
1602
|
+
await writeFile4(logPath, lines, { flag: "a", encoding: "utf-8" });
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
// src/sync/internal/backup.ts
|
|
1606
|
+
import { cp, mkdir as mkdir5, readdir as readdir2, rm as rm2 } from "fs/promises";
|
|
1607
|
+
import { existsSync as existsSync7 } from "fs";
|
|
1608
|
+
import { join as join9 } from "path";
|
|
1609
|
+
async function snapshotFile(projectRoot, backupDir, relativePath) {
|
|
1610
|
+
const src = join9(projectRoot, relativePath);
|
|
1611
|
+
if (!existsSync7(src)) return;
|
|
1612
|
+
const dest = join9(backupDir, relativePath);
|
|
1613
|
+
await mkdir5(join9(dest, ".."), { recursive: true });
|
|
1614
|
+
await cp(src, dest, { force: true });
|
|
1615
|
+
}
|
|
1616
|
+
async function removeDirIfExists(abs) {
|
|
1617
|
+
if (existsSync7(abs)) await rm2(abs, { recursive: true, force: true });
|
|
1618
|
+
}
|
|
1619
|
+
async function snapshotBackup(projectRoot, plannedPaths) {
|
|
1620
|
+
if (plannedPaths.length === 0) return void 0;
|
|
1621
|
+
const backupRoot = join9(projectRoot, PRE_SYNC_BACKUP_DIR);
|
|
1622
|
+
const sessionBackup = join9(backupRoot, `backup-${Date.now()}`);
|
|
1623
|
+
await mkdir5(sessionBackup, { recursive: true });
|
|
1624
|
+
const newlyCreated = /* @__PURE__ */ new Set();
|
|
1625
|
+
for (const rel of plannedPaths) {
|
|
1626
|
+
const abs = join9(projectRoot, rel);
|
|
1627
|
+
if (existsSync7(abs)) {
|
|
1628
|
+
await snapshotFile(projectRoot, sessionBackup, rel);
|
|
1629
|
+
} else {
|
|
1630
|
+
newlyCreated.add(rel);
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
return { sessionBackup, newlyCreated };
|
|
1634
|
+
}
|
|
1635
|
+
async function cleanupBackup(projectRoot, session) {
|
|
1636
|
+
await removeDirIfExists(session.sessionBackup);
|
|
1637
|
+
const backupRoot = join9(projectRoot, PRE_SYNC_BACKUP_DIR);
|
|
1638
|
+
try {
|
|
1639
|
+
const entries = await readdir2(backupRoot);
|
|
1640
|
+
if (entries.length === 0) await removeDirIfExists(backupRoot);
|
|
1641
|
+
} catch {
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
async function restoreFromBackup(projectRoot, session, plannedPaths) {
|
|
1645
|
+
for (const rel of plannedPaths) {
|
|
1646
|
+
const abs = join9(projectRoot, rel);
|
|
1647
|
+
if (session?.newlyCreated.has(rel)) {
|
|
1648
|
+
if (existsSync7(abs)) await rm2(abs, { force: true });
|
|
1649
|
+
continue;
|
|
1650
|
+
}
|
|
1651
|
+
if (session) {
|
|
1652
|
+
const snap = join9(session.sessionBackup, rel);
|
|
1653
|
+
if (existsSync7(snap)) {
|
|
1654
|
+
await cp(snap, abs, { force: true });
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
// src/sync/internal/apply-json-target.ts
|
|
1661
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
1662
|
+
import { existsSync as existsSync8 } from "fs";
|
|
1663
|
+
import { join as join10 } from "path";
|
|
1664
|
+
import { isEqual as isEqual2 } from "es-toolkit/predicate";
|
|
1665
|
+
|
|
1666
|
+
// src/lib/merge/managed-json.ts
|
|
1667
|
+
import { isEqual } from "es-toolkit/predicate";
|
|
1668
|
+
|
|
1669
|
+
// src/lib/merge/json-path-utils.ts
|
|
1670
|
+
import { cloneDeep as cloneDeep2, get, set } from "es-toolkit/compat";
|
|
1671
|
+
function hasDottedPath(root, dotted) {
|
|
1672
|
+
const parts = dotted.split(".");
|
|
1673
|
+
let cur = root;
|
|
1674
|
+
for (const p2 of parts) {
|
|
1675
|
+
if (cur === null || typeof cur !== "object" || Array.isArray(cur))
|
|
1676
|
+
return false;
|
|
1677
|
+
if (!Object.prototype.hasOwnProperty.call(cur, p2)) return false;
|
|
1678
|
+
cur = cur[p2];
|
|
1679
|
+
}
|
|
1680
|
+
return true;
|
|
1681
|
+
}
|
|
1682
|
+
function getAt(obj, path) {
|
|
1683
|
+
if (path === "") return obj;
|
|
1684
|
+
return get(obj, path);
|
|
1685
|
+
}
|
|
1686
|
+
function setAt(root, path, value) {
|
|
1687
|
+
if (path === "") {
|
|
1688
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
1689
|
+
return { ...value };
|
|
1690
|
+
}
|
|
1691
|
+
throw new Error(`setAt: cannot replace root with non-object at empty path`);
|
|
1692
|
+
}
|
|
1693
|
+
const draft = cloneDeep2(root);
|
|
1694
|
+
set(draft, path, value);
|
|
1695
|
+
return draft;
|
|
1696
|
+
}
|
|
1697
|
+
function deepCloneJson(v) {
|
|
1698
|
+
return cloneDeep2(v);
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
// src/lib/merge/json-shape.ts
|
|
1702
|
+
function listLeafPathsFromBaseline(baseline, options) {
|
|
1703
|
+
const exclude = new Set(options?.excludeExactPaths ?? []);
|
|
1704
|
+
return leafPaths(baseline, "", exclude);
|
|
1705
|
+
}
|
|
1706
|
+
function leafPaths(v, prefix, exclude) {
|
|
1707
|
+
if (v === null || typeof v !== "object") {
|
|
1708
|
+
return prefix ? [prefix] : [];
|
|
1709
|
+
}
|
|
1710
|
+
if (Array.isArray(v)) {
|
|
1711
|
+
return prefix ? [prefix] : [];
|
|
1712
|
+
}
|
|
1713
|
+
const o = v;
|
|
1714
|
+
const keys = Object.keys(o);
|
|
1715
|
+
if (keys.length === 0) return prefix ? [prefix] : [];
|
|
1716
|
+
const out = [];
|
|
1717
|
+
for (const k of keys) {
|
|
1718
|
+
const p2 = prefix ? `${prefix}.${k}` : k;
|
|
1719
|
+
if (exclude.has(p2)) {
|
|
1720
|
+
continue;
|
|
1721
|
+
}
|
|
1722
|
+
const child = o[k];
|
|
1723
|
+
if (child !== null && typeof child === "object" && !Array.isArray(child)) {
|
|
1724
|
+
const nested = leafPaths(child, p2, exclude);
|
|
1725
|
+
out.push(...nested);
|
|
1726
|
+
} else {
|
|
1727
|
+
out.push(p2);
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
return out;
|
|
1731
|
+
}
|
|
1732
|
+
function extractManagedFragment(disk, baseline) {
|
|
1733
|
+
const out = {};
|
|
1734
|
+
for (const k of Object.keys(baseline)) {
|
|
1735
|
+
const b = baseline[k];
|
|
1736
|
+
const d = disk[k];
|
|
1737
|
+
if (b === null || typeof b !== "object" || Array.isArray(b)) {
|
|
1738
|
+
out[k] = d !== void 0 ? deepCloneLeaf(d) : b;
|
|
1739
|
+
} else if (typeof d === "object" && d !== null && !Array.isArray(d)) {
|
|
1740
|
+
out[k] = extractManagedFragment(
|
|
1741
|
+
d,
|
|
1742
|
+
b
|
|
1743
|
+
);
|
|
1744
|
+
} else {
|
|
1745
|
+
out[k] = d !== void 0 ? deepCloneLeaf(d) : deepCloneLeaf(b);
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
return out;
|
|
1749
|
+
}
|
|
1750
|
+
function deepCloneLeaf(v) {
|
|
1751
|
+
if (v === null || typeof v !== "object") return v;
|
|
1752
|
+
return JSON.parse(JSON.stringify(v));
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
// src/lib/merge/managed-json.ts
|
|
1756
|
+
function mergeManagedJson(options) {
|
|
1757
|
+
const {
|
|
1758
|
+
fileLabel,
|
|
1759
|
+
disk,
|
|
1760
|
+
desiredFragment,
|
|
1761
|
+
lastFileFragment,
|
|
1762
|
+
hasGlobalLastSync,
|
|
1763
|
+
force,
|
|
1764
|
+
managedPaths
|
|
1765
|
+
} = options;
|
|
1766
|
+
const conflicts = [];
|
|
1767
|
+
const skippedPaths = [];
|
|
1768
|
+
const appliedPaths = [];
|
|
1769
|
+
let merged = deepCloneJson(disk);
|
|
1770
|
+
for (const path of managedPaths) {
|
|
1771
|
+
const desiredVal = getAt(desiredFragment, path);
|
|
1772
|
+
const diskVal = getAt(disk, path);
|
|
1773
|
+
let hasConflict = false;
|
|
1774
|
+
if (hasGlobalLastSync && lastFileFragment !== void 0 && hasDottedPath(lastFileFragment, path)) {
|
|
1775
|
+
const prevVal = getAt(lastFileFragment, path);
|
|
1776
|
+
if (!isEqual(diskVal, prevVal)) {
|
|
1777
|
+
hasConflict = true;
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
if (hasConflict && !force) {
|
|
1781
|
+
conflicts.push({ relativePath: fileLabel, path });
|
|
1782
|
+
skippedPaths.push(path);
|
|
1783
|
+
continue;
|
|
1784
|
+
}
|
|
1785
|
+
if (hasConflict && force) {
|
|
1786
|
+
merged = setAt(merged, path, desiredVal);
|
|
1787
|
+
appliedPaths.push(path);
|
|
1788
|
+
continue;
|
|
1789
|
+
}
|
|
1790
|
+
if (!isEqual(diskVal, desiredVal)) {
|
|
1791
|
+
merged = setAt(merged, path, desiredVal);
|
|
1792
|
+
appliedPaths.push(path);
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
return { merged, conflicts, appliedPaths, skippedPaths };
|
|
1796
|
+
}
|
|
1797
|
+
function mergeJsonFile(options) {
|
|
1798
|
+
const {
|
|
1799
|
+
relativePath,
|
|
1800
|
+
disk,
|
|
1801
|
+
desiredFragment,
|
|
1802
|
+
lastFileFragment,
|
|
1803
|
+
hasGlobalLastSync,
|
|
1804
|
+
force
|
|
1805
|
+
} = options;
|
|
1806
|
+
const managedPaths = listLeafPathsFromBaseline(desiredFragment);
|
|
1807
|
+
return mergeManagedJson({
|
|
1808
|
+
fileLabel: relativePath,
|
|
1809
|
+
disk,
|
|
1810
|
+
desiredFragment,
|
|
1811
|
+
lastFileFragment,
|
|
1812
|
+
hasGlobalLastSync,
|
|
1813
|
+
force,
|
|
1814
|
+
managedPaths
|
|
1815
|
+
});
|
|
1816
|
+
}
|
|
1817
|
+
function mergeManagedJsonString(options) {
|
|
1818
|
+
const { relativePath, existingContent, desiredFragment } = options;
|
|
1819
|
+
const disk = JSON.parse(existingContent);
|
|
1820
|
+
const { merged } = mergeJsonFile({
|
|
1821
|
+
relativePath,
|
|
1822
|
+
disk,
|
|
1823
|
+
desiredFragment,
|
|
1824
|
+
lastFileFragment: void 0,
|
|
1825
|
+
hasGlobalLastSync: false,
|
|
1826
|
+
force: false
|
|
1827
|
+
});
|
|
1828
|
+
return merged;
|
|
1829
|
+
}
|
|
1830
|
+
function formatMergedJson(merged) {
|
|
1831
|
+
return `${JSON.stringify(merged, null, 2)}
|
|
1832
|
+
`;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
// src/sync/internal/apply-json-target.ts
|
|
1836
|
+
async function applyJsonTarget(options) {
|
|
1837
|
+
const { projectRoot, target, lastSync, hasGlobalLastSync, force } = options;
|
|
1838
|
+
const rel = target.relativePath;
|
|
1839
|
+
const abs = join10(projectRoot, rel);
|
|
1840
|
+
const logEntries = [];
|
|
1841
|
+
let diskObj;
|
|
1842
|
+
if (!existsSync8(abs)) {
|
|
1843
|
+
diskObj = {};
|
|
1844
|
+
} else {
|
|
1845
|
+
try {
|
|
1846
|
+
diskObj = JSON.parse(await readFile3(abs, "utf-8"));
|
|
1847
|
+
} catch {
|
|
1848
|
+
const err = new Error(`Invalid JSON: ${rel}`);
|
|
1849
|
+
logEntries.push({
|
|
1850
|
+
timestamp: nowIso(),
|
|
1851
|
+
level: "error",
|
|
1852
|
+
message: err.message,
|
|
1853
|
+
file: rel
|
|
1854
|
+
});
|
|
1855
|
+
return { ok: false, error: err, logEntries };
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
const desired = target.getDesiredFragment({ projectRoot });
|
|
1859
|
+
const lastFileFragment = getJsonFragment(lastSync, rel);
|
|
1860
|
+
const outcome = mergeJsonFile({
|
|
1861
|
+
relativePath: rel,
|
|
1862
|
+
disk: diskObj,
|
|
1863
|
+
desiredFragment: desired,
|
|
1864
|
+
lastFileFragment,
|
|
1865
|
+
hasGlobalLastSync,
|
|
1866
|
+
force
|
|
1867
|
+
});
|
|
1868
|
+
for (const c of outcome.conflicts) {
|
|
1869
|
+
logEntries.push({
|
|
1870
|
+
timestamp: nowIso(),
|
|
1871
|
+
level: "skip",
|
|
1872
|
+
message: "Conflict on managed key \u2014 skipped (use --force to overwrite)",
|
|
1873
|
+
file: c.relativePath,
|
|
1874
|
+
path: c.path
|
|
1875
|
+
});
|
|
1876
|
+
}
|
|
1877
|
+
for (const p2 of outcome.appliedPaths) {
|
|
1878
|
+
logEntries.push({
|
|
1879
|
+
timestamp: nowIso(),
|
|
1880
|
+
level: "info",
|
|
1881
|
+
message: "Applied managed key",
|
|
1882
|
+
file: rel,
|
|
1883
|
+
path: p2
|
|
1884
|
+
});
|
|
1885
|
+
}
|
|
1886
|
+
const changed = !isEqual2(outcome.merged, diskObj);
|
|
1887
|
+
let nextFragment = extractManagedFragment(outcome.merged, desired);
|
|
1888
|
+
if (!force && lastFileFragment !== void 0 && outcome.skippedPaths.length > 0) {
|
|
1889
|
+
for (const path of outcome.skippedPaths) {
|
|
1890
|
+
const prev = getAt(lastFileFragment, path);
|
|
1891
|
+
nextFragment = setAt(nextFragment, path, prev);
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
let conflictForcedContent;
|
|
1895
|
+
if (!force && outcome.conflicts.length > 0) {
|
|
1896
|
+
const forced = mergeJsonFile({
|
|
1897
|
+
relativePath: rel,
|
|
1898
|
+
disk: diskObj,
|
|
1899
|
+
desiredFragment: desired,
|
|
1900
|
+
lastFileFragment,
|
|
1901
|
+
hasGlobalLastSync,
|
|
1902
|
+
force: true
|
|
1903
|
+
});
|
|
1904
|
+
if (!isEqual2(forced.merged, diskObj)) {
|
|
1905
|
+
conflictForcedContent = formatMergedJson(forced.merged);
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
return {
|
|
1909
|
+
ok: true,
|
|
1910
|
+
plannedContent: changed ? formatMergedJson(outcome.merged) : void 0,
|
|
1911
|
+
conflictForcedContent,
|
|
1912
|
+
nextFragment,
|
|
1913
|
+
logEntries
|
|
1914
|
+
};
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
// src/sync/internal/apply-text-target.ts
|
|
1918
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
1919
|
+
import { existsSync as existsSync9 } from "fs";
|
|
1920
|
+
import { join as join11 } from "path";
|
|
1921
|
+
|
|
1922
|
+
// src/lib/merge/text.ts
|
|
1923
|
+
function mergeTextFile(options) {
|
|
1924
|
+
const { disk, desired, lastContent, hasGlobalLastSync, force } = options;
|
|
1925
|
+
let conflict = false;
|
|
1926
|
+
if (hasGlobalLastSync && lastContent !== void 0 && disk !== lastContent) {
|
|
1927
|
+
conflict = true;
|
|
1928
|
+
}
|
|
1929
|
+
if (conflict && !force) {
|
|
1930
|
+
return { merged: disk, conflict: true, applied: false };
|
|
1931
|
+
}
|
|
1932
|
+
if (disk === desired) {
|
|
1933
|
+
return { merged: disk, conflict: false, applied: false };
|
|
1934
|
+
}
|
|
1935
|
+
return { merged: desired, conflict, applied: true };
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
// src/sync/internal/apply-text-target.ts
|
|
1939
|
+
async function applyTextTarget(options) {
|
|
1940
|
+
const { projectRoot, target, lastSync, hasGlobalLastSync, force } = options;
|
|
1941
|
+
const rel = target.relativePath;
|
|
1942
|
+
const abs = join11(projectRoot, rel);
|
|
1943
|
+
const logEntries = [];
|
|
1944
|
+
const desired = await target.getDesiredContent({ projectRoot });
|
|
1945
|
+
const diskText = existsSync9(abs) ? await readFile4(abs, "utf-8") : "";
|
|
1946
|
+
const lastContent = getTextContent(lastSync, rel);
|
|
1947
|
+
const out = mergeTextFile({
|
|
1948
|
+
disk: diskText,
|
|
1949
|
+
desired,
|
|
1950
|
+
lastContent,
|
|
1951
|
+
hasGlobalLastSync,
|
|
1952
|
+
force
|
|
1953
|
+
});
|
|
1954
|
+
if (out.conflict && !force) {
|
|
1955
|
+
logEntries.push({
|
|
1956
|
+
timestamp: nowIso(),
|
|
1957
|
+
level: "skip",
|
|
1958
|
+
message: "Conflict on managed file \u2014 skipped (use --force to overwrite)",
|
|
1959
|
+
file: rel
|
|
1960
|
+
});
|
|
1961
|
+
} else if (out.applied) {
|
|
1962
|
+
logEntries.push({
|
|
1963
|
+
timestamp: nowIso(),
|
|
1964
|
+
level: "info",
|
|
1965
|
+
message: "Applied managed file content",
|
|
1966
|
+
file: rel
|
|
1967
|
+
});
|
|
1968
|
+
}
|
|
1969
|
+
const nextContent = out.conflict && !force && lastContent !== void 0 ? lastContent : out.merged;
|
|
1970
|
+
const conflictForcedContent = out.conflict && !force && desired !== diskText ? desired : void 0;
|
|
1971
|
+
return {
|
|
1972
|
+
plannedContent: out.merged !== diskText ? out.merged : void 0,
|
|
1973
|
+
conflictForcedContent,
|
|
1974
|
+
nextMarker: { __content: nextContent },
|
|
1975
|
+
logEntries
|
|
1976
|
+
};
|
|
1977
|
+
}
|
|
1978
|
+
|
|
1979
|
+
// src/sync/run-sync.ts
|
|
1980
|
+
var execAsync2 = promisify2(exec2);
|
|
1981
|
+
async function runSync(opts) {
|
|
1982
|
+
const { projectRoot, dryRun, force, keepBackup } = opts;
|
|
1983
|
+
const logEntries = [];
|
|
1984
|
+
const guardResult = await guardProjectRoot(projectRoot);
|
|
1985
|
+
if (!guardResult.ok) {
|
|
1986
|
+
logEntries.push({
|
|
1987
|
+
timestamp: nowIso(),
|
|
1988
|
+
level: "error",
|
|
1989
|
+
message: guardResult.error.message
|
|
1990
|
+
});
|
|
1991
|
+
return { ok: false, error: guardResult.error, logEntries };
|
|
1992
|
+
}
|
|
1993
|
+
const currentCli = getCliVersion();
|
|
1994
|
+
const lastSync = await readLastSync(projectRoot);
|
|
1995
|
+
try {
|
|
1996
|
+
assertSyncVersionCompatible(lastSync?.cliVersion, currentCli);
|
|
1997
|
+
} catch (e) {
|
|
1998
|
+
if (e instanceof SyncVersionAbortError) {
|
|
1999
|
+
logEntries.push({
|
|
2000
|
+
timestamp: nowIso(),
|
|
2001
|
+
level: "error",
|
|
2002
|
+
message: e.message
|
|
2003
|
+
});
|
|
2004
|
+
return { ok: false, error: e, logEntries };
|
|
2005
|
+
}
|
|
2006
|
+
throw e;
|
|
2007
|
+
}
|
|
2008
|
+
const hasGlobalLastSync = lastSync !== null;
|
|
2009
|
+
const nextLast = {
|
|
2010
|
+
cliVersion: currentCli,
|
|
2011
|
+
files: { ...lastSync?.files ?? {} }
|
|
2012
|
+
};
|
|
2013
|
+
const plannedWrites = /* @__PURE__ */ new Map();
|
|
2014
|
+
const conflictForcedWrites = /* @__PURE__ */ new Map();
|
|
2015
|
+
const changedFiles = [];
|
|
2016
|
+
for (const target of SYNC_TARGETS) {
|
|
2017
|
+
const rel = target.relativePath;
|
|
2018
|
+
const context = { projectRoot };
|
|
2019
|
+
if (target.condition && !target.condition(context)) {
|
|
2020
|
+
continue;
|
|
2021
|
+
}
|
|
2022
|
+
if (target.kind === "json") {
|
|
2023
|
+
const outcome2 = await applyJsonTarget({
|
|
2024
|
+
projectRoot,
|
|
2025
|
+
target,
|
|
2026
|
+
lastSync,
|
|
2027
|
+
hasGlobalLastSync,
|
|
2028
|
+
force
|
|
2029
|
+
});
|
|
2030
|
+
logEntries.push(...outcome2.logEntries);
|
|
2031
|
+
if (!outcome2.ok) {
|
|
2032
|
+
return { ok: false, error: outcome2.error, logEntries };
|
|
2033
|
+
}
|
|
2034
|
+
if (outcome2.plannedContent !== void 0) {
|
|
2035
|
+
plannedWrites.set(rel, outcome2.plannedContent);
|
|
2036
|
+
changedFiles.push(rel);
|
|
2037
|
+
}
|
|
2038
|
+
if (outcome2.conflictForcedContent !== void 0) {
|
|
2039
|
+
conflictForcedWrites.set(rel, outcome2.conflictForcedContent);
|
|
2040
|
+
}
|
|
2041
|
+
nextLast.files[rel] = outcome2.nextFragment;
|
|
2042
|
+
continue;
|
|
2043
|
+
}
|
|
2044
|
+
const outcome = await applyTextTarget({
|
|
2045
|
+
projectRoot,
|
|
2046
|
+
target,
|
|
2047
|
+
lastSync,
|
|
2048
|
+
hasGlobalLastSync,
|
|
2049
|
+
force
|
|
2050
|
+
});
|
|
2051
|
+
logEntries.push(...outcome.logEntries);
|
|
2052
|
+
if (outcome.plannedContent !== void 0) {
|
|
2053
|
+
plannedWrites.set(rel, outcome.plannedContent);
|
|
2054
|
+
changedFiles.push(rel);
|
|
2055
|
+
}
|
|
2056
|
+
if (outcome.conflictForcedContent !== void 0) {
|
|
2057
|
+
conflictForcedWrites.set(rel, outcome.conflictForcedContent);
|
|
2058
|
+
}
|
|
2059
|
+
nextLast.files[rel] = outcome.nextMarker;
|
|
2060
|
+
}
|
|
2061
|
+
const conflictDiffs = await buildConflictDiffs(
|
|
2062
|
+
projectRoot,
|
|
2063
|
+
conflictForcedWrites
|
|
2064
|
+
);
|
|
2065
|
+
const agentPrompt = buildAgentPrompt(conflictDiffs);
|
|
2066
|
+
if (dryRun) {
|
|
2067
|
+
await emitDryRunDiffs(projectRoot, plannedWrites);
|
|
2068
|
+
logEntries.push({
|
|
2069
|
+
timestamp: nowIso(),
|
|
2070
|
+
level: "info",
|
|
2071
|
+
message: `Dry-run: ${plannedWrites.size} file(s) would change`
|
|
2072
|
+
});
|
|
2073
|
+
return {
|
|
2074
|
+
ok: true,
|
|
2075
|
+
dryRun: true,
|
|
2076
|
+
changedFiles: [...changedFiles],
|
|
2077
|
+
conflictDiffs,
|
|
2078
|
+
agentPrompt,
|
|
2079
|
+
logEntries
|
|
2080
|
+
};
|
|
2081
|
+
}
|
|
2082
|
+
const rollbackPaths = [.../* @__PURE__ */ new Set([...plannedWrites.keys(), LAST_SYNC_FILE])];
|
|
2083
|
+
const backup = await snapshotBackup(projectRoot, rollbackPaths);
|
|
2084
|
+
try {
|
|
2085
|
+
await commitPlannedWrites(projectRoot, plannedWrites);
|
|
2086
|
+
await writeLastSync(projectRoot, nextLast);
|
|
2087
|
+
await appendSyncLog(projectRoot, logEntries);
|
|
2088
|
+
if (backup && !keepBackup) {
|
|
2089
|
+
await cleanupBackup(projectRoot, backup);
|
|
2090
|
+
}
|
|
2091
|
+
const { skillsStdout, skillsError } = await runSkillsUpdate({
|
|
2092
|
+
projectRoot,
|
|
2093
|
+
enabled: plannedWrites.size > 0
|
|
2094
|
+
});
|
|
2095
|
+
if (skillsError) {
|
|
2096
|
+
await appendSyncLog(projectRoot, [
|
|
2097
|
+
{
|
|
2098
|
+
timestamp: nowIso(),
|
|
2099
|
+
level: "warn",
|
|
2100
|
+
message: skillsError.message
|
|
2101
|
+
}
|
|
2102
|
+
]);
|
|
2103
|
+
}
|
|
2104
|
+
return {
|
|
2105
|
+
ok: true,
|
|
2106
|
+
dryRun: false,
|
|
2107
|
+
changedFiles: [...changedFiles],
|
|
2108
|
+
conflictDiffs,
|
|
2109
|
+
agentPrompt,
|
|
2110
|
+
logEntries,
|
|
2111
|
+
skillsStdout,
|
|
2112
|
+
...skillsError && { skillsError }
|
|
2113
|
+
};
|
|
2114
|
+
} catch (e) {
|
|
2115
|
+
const err = e instanceof Error ? e : new Error(String(e));
|
|
2116
|
+
await restoreFromBackup(projectRoot, backup, rollbackPaths);
|
|
2117
|
+
logEntries.push({
|
|
2118
|
+
timestamp: nowIso(),
|
|
2119
|
+
level: "error",
|
|
2120
|
+
message: err.message
|
|
2121
|
+
});
|
|
2122
|
+
return { ok: false, error: err, logEntries };
|
|
2123
|
+
}
|
|
2124
|
+
}
|
|
2125
|
+
async function guardProjectRoot(projectRoot) {
|
|
2126
|
+
const pkgPath = join12(projectRoot, "package.json");
|
|
2127
|
+
if (!existsSync10(pkgPath)) {
|
|
2128
|
+
return {
|
|
2129
|
+
ok: false,
|
|
2130
|
+
error: new Error(
|
|
2131
|
+
`No package.json in ${projectRoot}. Run scale-stack sync from a project root.`
|
|
2132
|
+
)
|
|
2133
|
+
};
|
|
2134
|
+
}
|
|
2135
|
+
try {
|
|
2136
|
+
const pkgRaw = await readFile5(pkgPath, "utf-8");
|
|
2137
|
+
const pkg = JSON.parse(pkgRaw);
|
|
2138
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
2139
|
+
if (!deps.next) {
|
|
2140
|
+
return {
|
|
2141
|
+
ok: false,
|
|
2142
|
+
error: new Error(
|
|
2143
|
+
"scale-stack sync applies to Next.js app projects (missing next dependency)."
|
|
2144
|
+
)
|
|
2145
|
+
};
|
|
2146
|
+
}
|
|
2147
|
+
return { ok: true };
|
|
2148
|
+
} catch (e) {
|
|
2149
|
+
return {
|
|
2150
|
+
ok: false,
|
|
2151
|
+
error: e instanceof Error ? e : new Error(`Invalid package.json: ${String(e)}`)
|
|
2152
|
+
};
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
async function emitDryRunDiffs(projectRoot, plannedWrites) {
|
|
2156
|
+
for (const [rel, content] of plannedWrites) {
|
|
2157
|
+
const abs = join12(projectRoot, rel);
|
|
2158
|
+
const before = existsSync10(abs) ? await readFile5(abs, "utf-8") : "";
|
|
2159
|
+
const patch = createPatch2(rel, before, content, "", "");
|
|
2160
|
+
console.log(patch);
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
async function buildConflictDiffs(projectRoot, conflictForcedWrites) {
|
|
2164
|
+
const out = [];
|
|
2165
|
+
for (const [rel, content] of conflictForcedWrites) {
|
|
2166
|
+
const abs = join12(projectRoot, rel);
|
|
2167
|
+
const before = existsSync10(abs) ? await readFile5(abs, "utf-8") : "";
|
|
2168
|
+
const diff = createPatch2(rel, before, content, "current", "canonical");
|
|
2169
|
+
out.push({ file: rel, diff });
|
|
2170
|
+
}
|
|
2171
|
+
return out;
|
|
2172
|
+
}
|
|
2173
|
+
function buildAgentPrompt(conflictDiffs) {
|
|
2174
|
+
if (conflictDiffs.length === 0) return void 0;
|
|
2175
|
+
const files = conflictDiffs.map((d) => d.file);
|
|
2176
|
+
const fileList = files.map((f) => ` - ${f}`).join("\n");
|
|
2177
|
+
const diffBlocks = conflictDiffs.map(
|
|
2178
|
+
({ file, diff }) => [
|
|
2179
|
+
`--- BEGIN diff: ${file} ---`,
|
|
2180
|
+
diff.replace(/\n+$/, ""),
|
|
2181
|
+
`--- END diff: ${file} ---`
|
|
2182
|
+
].join("\n")
|
|
2183
|
+
).join("\n\n");
|
|
2184
|
+
return [
|
|
2185
|
+
"You are resolving sync conflicts reported by `scale-stack sync`.",
|
|
2186
|
+
"",
|
|
2187
|
+
"Each conflicted file is included below as a unified diff wrapped in",
|
|
2188
|
+
"`--- BEGIN diff: <file> --- ... --- END diff: <file> ---`. Within each block:",
|
|
2189
|
+
" * `-` lines are the user's current content on disk.",
|
|
2190
|
+
" * `+` lines are the Scale Stack canonical (what `--force` would write).",
|
|
2191
|
+
"",
|
|
2192
|
+
diffBlocks,
|
|
2193
|
+
"",
|
|
2194
|
+
"For every listed file, produce a merged version that:",
|
|
2195
|
+
" 1. preserves the user's intentional edits that don't contradict the canonical intent,",
|
|
2196
|
+
" 2. incorporates every canonical change (the `+` side),",
|
|
2197
|
+
" 3. is written back to disk at its given relative path.",
|
|
2198
|
+
"",
|
|
2199
|
+
"Conflicted files (relative to project root):",
|
|
2200
|
+
fileList,
|
|
2201
|
+
"",
|
|
2202
|
+
"Do NOT run `scale-stack sync --force` \u2014 it would discard the user's edits.",
|
|
2203
|
+
'After merging, run `scale-stack sync` to verify: it must report "Already up to date".'
|
|
2204
|
+
].join("\n");
|
|
2205
|
+
}
|
|
2206
|
+
async function commitPlannedWrites(projectRoot, plannedWrites) {
|
|
2207
|
+
for (const [rel, content] of plannedWrites) {
|
|
2208
|
+
const abs = join12(projectRoot, rel);
|
|
2209
|
+
await mkdir6(join12(abs, ".."), { recursive: true });
|
|
2210
|
+
await writeFile5(abs, content, "utf-8");
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
async function runSkillsUpdate(options) {
|
|
2214
|
+
if (!options.enabled) return {};
|
|
2215
|
+
if (process.env.SCALE_STACK_SKIP_SKILLS_UPDATE === "1") return {};
|
|
2216
|
+
try {
|
|
2217
|
+
const { stdout, stderr } = await execAsync2("pnpm dlx skills update", {
|
|
2218
|
+
cwd: options.projectRoot,
|
|
2219
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
2220
|
+
env: { ...process.env }
|
|
2221
|
+
});
|
|
2222
|
+
return { skillsStdout: (stdout || "") + (stderr || "") };
|
|
2223
|
+
} catch (e) {
|
|
2224
|
+
const msg = e instanceof Error ? e.message : `skills update failed: ${String(e)}`;
|
|
2225
|
+
return { skillsError: new Error(`pnpm dlx skills update failed: ${msg}`) };
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
// src/cli/commands/sync.ts
|
|
2230
|
+
function groupSkipsByFile(entries) {
|
|
2231
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
2232
|
+
for (const e of entries) {
|
|
2233
|
+
if (e.level !== "skip" || !e.file) continue;
|
|
2234
|
+
const existing = byFile.get(e.file) ?? [];
|
|
2235
|
+
if (e.path) existing.push(e.path);
|
|
2236
|
+
byFile.set(e.file, existing);
|
|
2237
|
+
}
|
|
2238
|
+
return byFile;
|
|
2239
|
+
}
|
|
2240
|
+
function renderSkippedConflicts(skips) {
|
|
2241
|
+
const fileCount = skips.size;
|
|
2242
|
+
if (fileCount === 0) return;
|
|
2243
|
+
console.log(
|
|
2244
|
+
pc6.yellow(` \u26A0 ${fileCount} managed file(s) skipped due to local changes:`)
|
|
2245
|
+
);
|
|
2246
|
+
for (const [file, paths] of skips) {
|
|
2247
|
+
const detail = paths.length > 0 ? ` (${paths.join(", ")})` : "";
|
|
2248
|
+
console.log(pc6.yellow(` - ${file}${detail}`));
|
|
2249
|
+
}
|
|
2250
|
+
console.log(
|
|
2251
|
+
ikea.muted(
|
|
2252
|
+
` Run \`scale-stack sync --force\` to overwrite with Scale Stack baselines.`
|
|
2253
|
+
)
|
|
2254
|
+
);
|
|
2255
|
+
}
|
|
2256
|
+
function colorizeDiffLine(line) {
|
|
2257
|
+
if (line.startsWith("+++") || line.startsWith("---")) return ikea.muted(line);
|
|
2258
|
+
if (line.startsWith("@@")) return pc6.cyan(line);
|
|
2259
|
+
if (line.startsWith("+")) return pc6.green(line);
|
|
2260
|
+
if (line.startsWith("-")) return pc6.red(line);
|
|
2261
|
+
return line;
|
|
2262
|
+
}
|
|
2263
|
+
function renderConflictDiffs(diffs) {
|
|
2264
|
+
if (diffs.length === 0) return;
|
|
2265
|
+
console.log();
|
|
2266
|
+
console.log(ikea.muted(" Proposed merge (what --force would write):"));
|
|
2267
|
+
for (const { file, diff } of diffs) {
|
|
2268
|
+
console.log();
|
|
2269
|
+
console.log(pc6.cyan(` \u2508 --- BEGIN diff: ${file} ---`));
|
|
2270
|
+
for (const line of diff.split("\n")) {
|
|
2271
|
+
console.log(colorizeDiffLine(line));
|
|
2272
|
+
}
|
|
2273
|
+
console.log(pc6.cyan(` \u2508 --- END diff: ${file} ---`));
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
function renderAgentPrompt(prompt) {
|
|
2277
|
+
if (!prompt) return;
|
|
2278
|
+
console.log();
|
|
2279
|
+
console.log(
|
|
2280
|
+
ikea.muted(
|
|
2281
|
+
" Paste this prompt into an agent to reconcile the conflicts above:"
|
|
2282
|
+
)
|
|
2283
|
+
);
|
|
2284
|
+
console.log();
|
|
2285
|
+
console.log(pc6.cyan(" \u2508 --- BEGIN agent prompt ---"));
|
|
2286
|
+
for (const line of prompt.split("\n")) console.log(line);
|
|
2287
|
+
console.log(pc6.cyan(" \u2508 --- END agent prompt ---"));
|
|
2288
|
+
}
|
|
2289
|
+
function registerSyncCommand(program2) {
|
|
2290
|
+
program2.command("sync").description("Update an existing SKALST\xC5KK project to the latest fittings").option("--dry-run", "Print diffs without writing files").option(
|
|
2291
|
+
"--force",
|
|
2292
|
+
"Overwrite user-modified managed keys with Scale Stack baselines"
|
|
2293
|
+
).option("--keep-backup", "Keep .scale-stack/pre-sync-backup/ after success").action(
|
|
2294
|
+
async (opts) => {
|
|
2295
|
+
const projectRoot = resolve6(process.cwd());
|
|
2296
|
+
const result = await runSync({
|
|
2297
|
+
projectRoot,
|
|
2298
|
+
dryRun: Boolean(opts.dryRun),
|
|
2299
|
+
force: Boolean(opts.force),
|
|
2300
|
+
keepBackup: Boolean(opts.keepBackup)
|
|
2301
|
+
});
|
|
2302
|
+
if (!result.ok) {
|
|
2303
|
+
console.log();
|
|
2304
|
+
console.log(
|
|
2305
|
+
pc6.red(
|
|
2306
|
+
` \u2717 ${result.error instanceof Error ? result.error.message : String(result.error)}`
|
|
2307
|
+
)
|
|
2308
|
+
);
|
|
2309
|
+
console.log();
|
|
2310
|
+
process.exitCode = 1;
|
|
2311
|
+
return;
|
|
2312
|
+
}
|
|
2313
|
+
const skips = groupSkipsByFile(result.logEntries);
|
|
2314
|
+
if (result.dryRun) {
|
|
2315
|
+
console.log();
|
|
2316
|
+
console.log(
|
|
2317
|
+
ikea.muted(
|
|
2318
|
+
` ${pc6.cyan("\u25B8")} Dry-run complete (${result.changedFiles.length} file(s) would change).`
|
|
2319
|
+
)
|
|
2320
|
+
);
|
|
2321
|
+
if (skips.size > 0) renderSkippedConflicts(skips);
|
|
2322
|
+
renderConflictDiffs(result.conflictDiffs);
|
|
2323
|
+
renderAgentPrompt(result.agentPrompt);
|
|
2324
|
+
console.log();
|
|
2325
|
+
return;
|
|
2326
|
+
}
|
|
2327
|
+
console.log();
|
|
2328
|
+
if (result.changedFiles.length > 0) {
|
|
2329
|
+
console.log(
|
|
2330
|
+
pc6.green(
|
|
2331
|
+
` \u2713 Sync applied to ${result.changedFiles.length} file(s): ${result.changedFiles.join(", ")}`
|
|
2332
|
+
)
|
|
2333
|
+
);
|
|
2334
|
+
} else if (skips.size === 0) {
|
|
2335
|
+
console.log(
|
|
2336
|
+
ikea.muted(
|
|
2337
|
+
` ${pc6.green("\u25B8")} Already up to date \u2014 no managed files changed.`
|
|
2338
|
+
)
|
|
2339
|
+
);
|
|
2340
|
+
}
|
|
2341
|
+
if (skips.size > 0) renderSkippedConflicts(skips);
|
|
2342
|
+
renderConflictDiffs(result.conflictDiffs);
|
|
2343
|
+
renderAgentPrompt(result.agentPrompt);
|
|
2344
|
+
if (result.skillsError) {
|
|
2345
|
+
console.log(
|
|
2346
|
+
pc6.yellow(
|
|
2347
|
+
` \u26A0 skills update step did not complete: ${result.skillsError.message}`
|
|
2348
|
+
)
|
|
2349
|
+
);
|
|
2350
|
+
}
|
|
2351
|
+
console.log();
|
|
2352
|
+
}
|
|
2353
|
+
);
|
|
2354
|
+
}
|
|
2355
|
+
|
|
2356
|
+
// src/lib/merge/package-json.ts
|
|
2357
|
+
function sortPackageSectionKeys(pkg, sectionName) {
|
|
2358
|
+
const section = pkg[sectionName];
|
|
2359
|
+
if (section === null || typeof section !== "object" || Array.isArray(section)) {
|
|
2360
|
+
return pkg;
|
|
2361
|
+
}
|
|
2362
|
+
const sorted = {};
|
|
2363
|
+
for (const key of Object.keys(section).sort()) {
|
|
2364
|
+
sorted[key] = section[key];
|
|
2365
|
+
}
|
|
2366
|
+
return { ...pkg, [sectionName]: sorted };
|
|
2367
|
+
}
|
|
2368
|
+
function mergeManagedPackageJson(options) {
|
|
2369
|
+
const merged = mergeManagedJsonString({
|
|
2370
|
+
relativePath: "package.json",
|
|
2371
|
+
existingContent: options.existingContent,
|
|
2372
|
+
desiredFragment: options.desiredFragment
|
|
2373
|
+
});
|
|
2374
|
+
const sorted = options.sortSections.reduce(
|
|
2375
|
+
(pkg, sectionName) => sortPackageSectionKeys(pkg, sectionName),
|
|
2376
|
+
merged
|
|
2377
|
+
);
|
|
2378
|
+
return formatMergedJson(sorted);
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
// src/generators/core/agent-skills-init.ts
|
|
2382
|
+
var PHASE_4_1_BASELINE_AGENT_SKILLS = [
|
|
2383
|
+
{ repo: "vercel-labs/skills", skill: "find-skills" },
|
|
2384
|
+
{ repo: "vercel-labs/next-skills", skill: "next-best-practices" },
|
|
2385
|
+
{ repo: "vercel-labs/agent-skills", skill: "vercel-react-view-transitions" },
|
|
2386
|
+
{ repo: "vercel-labs/agent-skills", skill: "vercel-react-best-practices" },
|
|
2387
|
+
{ repo: "vercel-labs/agent-skills", skill: "vercel-composition-patterns" },
|
|
2388
|
+
{ repo: "vercel-labs/agent-skills", skill: "web-design-guidelines" }
|
|
2389
|
+
];
|
|
2390
|
+
function groupSkillsByRepo(specs) {
|
|
2391
|
+
const order = [];
|
|
2392
|
+
const byRepo = /* @__PURE__ */ new Map();
|
|
2393
|
+
for (const { repo, skill } of specs) {
|
|
2394
|
+
let skills = byRepo.get(repo);
|
|
2395
|
+
if (!skills) {
|
|
2396
|
+
skills = [];
|
|
2397
|
+
byRepo.set(repo, skills);
|
|
2398
|
+
order.push(repo);
|
|
2399
|
+
}
|
|
2400
|
+
skills.push(skill);
|
|
2401
|
+
}
|
|
2402
|
+
return order.map((repo) => ({ repo, skills: byRepo.get(repo) ?? [] }));
|
|
2403
|
+
}
|
|
2404
|
+
function buildSkillsAddCommand(repo, skills) {
|
|
2405
|
+
const skillFlags = skills.map((s) => `--skill ${s}`).join(" ");
|
|
2406
|
+
return ["pnpm dlx skills add", repo, skillFlags, "-y"].filter(Boolean).join(" ");
|
|
2407
|
+
}
|
|
2408
|
+
var AGENT_SKILLS_EXEC_ENV = {
|
|
2409
|
+
NPM_CONFIG_LOGLEVEL: "error"
|
|
2410
|
+
};
|
|
2411
|
+
var agentSkillsInitGenerator = {
|
|
2412
|
+
name: "agent-skills-init",
|
|
2413
|
+
dependencies: ["project-init"],
|
|
2414
|
+
async execute(ctx) {
|
|
2415
|
+
const groups = groupSkillsByRepo(PHASE_4_1_BASELINE_AGENT_SKILLS);
|
|
2416
|
+
for (const { repo, skills } of groups) {
|
|
2417
|
+
await ctx.exec(buildSkillsAddCommand(repo, skills), {
|
|
2418
|
+
cwd: ctx.targetDir,
|
|
2419
|
+
env: { ...AGENT_SKILLS_EXEC_ENV }
|
|
2420
|
+
});
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
};
|
|
2424
|
+
|
|
2425
|
+
// src/generators/app-paths.ts
|
|
2426
|
+
function localizeAppRoutePath(outputPath, i18n) {
|
|
2427
|
+
if (!i18n) return outputPath;
|
|
2428
|
+
if (!outputPath.startsWith("src/app/")) return outputPath;
|
|
2429
|
+
if (outputPath.startsWith("src/app/api/")) return outputPath;
|
|
2430
|
+
return outputPath.replace("src/app/", "src/app/[locale]/");
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
// src/generators/ai-chat/ai-chat.ts
|
|
2434
|
+
var PHASE_22_AI_SDK_AGENT_SKILL_COMMAND = buildSkillsAddCommand(
|
|
2435
|
+
"vercel/ai",
|
|
2436
|
+
["ai-sdk"]
|
|
2437
|
+
);
|
|
2438
|
+
var PHASE_22_AI_ELEMENTS_AGENT_SKILL_COMMAND = buildSkillsAddCommand(
|
|
2439
|
+
"vercel/ai-elements",
|
|
2440
|
+
["ai-elements"]
|
|
2441
|
+
);
|
|
2442
|
+
var PHASE_22_AI_ELEMENTS_COMMAND = "pnpm dlx ai-elements@latest";
|
|
2443
|
+
var AI_CHAT_FILES = [
|
|
2444
|
+
{
|
|
2445
|
+
templatePath: "ai-chat/layout.tsx.ejs",
|
|
2446
|
+
outputPath: "src/app/chat/layout.tsx"
|
|
2447
|
+
},
|
|
2448
|
+
{
|
|
2449
|
+
templatePath: "ai-chat/page.tsx.ejs",
|
|
2450
|
+
outputPath: "src/app/chat/page.tsx"
|
|
2451
|
+
},
|
|
2452
|
+
{
|
|
2453
|
+
templatePath: "ai-chat/chat-panel.tsx.ejs",
|
|
2454
|
+
outputPath: "src/app/chat/_components/ChatPanel.tsx"
|
|
2455
|
+
},
|
|
2456
|
+
{
|
|
2457
|
+
templatePath: "ai-chat/use-chat.ts.ejs",
|
|
2458
|
+
outputPath: "src/app/chat/_hooks/useChat.ts"
|
|
2459
|
+
},
|
|
2460
|
+
{
|
|
2461
|
+
templatePath: "ai-chat/route.ts.ejs",
|
|
2462
|
+
outputPath: "src/app/api/chat/route.ts"
|
|
2463
|
+
}
|
|
2464
|
+
];
|
|
2465
|
+
function mergePackageJson(existing) {
|
|
2466
|
+
const desiredFragment = {
|
|
2467
|
+
dependencies: structuredClone(PACKAGE_DEPS_BASELINE)
|
|
2468
|
+
};
|
|
2469
|
+
return mergeManagedPackageJson({
|
|
2470
|
+
existingContent: existing,
|
|
2471
|
+
desiredFragment,
|
|
2472
|
+
sortSections: ["dependencies"]
|
|
2473
|
+
});
|
|
2474
|
+
}
|
|
2475
|
+
async function writeAiChatFiles(ctx) {
|
|
2476
|
+
for (const file of AI_CHAT_FILES) {
|
|
2477
|
+
const content = await ctx.template.renderFile(file.templatePath, {
|
|
2478
|
+
i18n: ctx.config.i18n
|
|
2479
|
+
});
|
|
2480
|
+
ctx.fs.write(
|
|
2481
|
+
localizeAppRoutePath(file.outputPath, ctx.config.i18n),
|
|
2482
|
+
content
|
|
2483
|
+
);
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
var aiChatGenerator = {
|
|
2487
|
+
name: "ai-chat",
|
|
2488
|
+
dependencies: ["ui"],
|
|
2489
|
+
condition: (config) => config.aiChat === true,
|
|
2490
|
+
async execute(ctx) {
|
|
2491
|
+
ctx.fs.merge("package.json", mergePackageJson);
|
|
2492
|
+
await ctx.exec(PHASE_22_AI_SDK_AGENT_SKILL_COMMAND, {
|
|
2493
|
+
cwd: ctx.targetDir,
|
|
2494
|
+
env: { ...AGENT_SKILLS_EXEC_ENV }
|
|
2495
|
+
});
|
|
2496
|
+
await ctx.exec(PHASE_22_AI_ELEMENTS_AGENT_SKILL_COMMAND, {
|
|
2497
|
+
cwd: ctx.targetDir,
|
|
2498
|
+
env: { ...AGENT_SKILLS_EXEC_ENV }
|
|
2499
|
+
});
|
|
2500
|
+
await ctx.exec(PHASE_22_AI_ELEMENTS_COMMAND, { cwd: ctx.targetDir });
|
|
2501
|
+
await writeAiChatFiles(ctx);
|
|
2502
|
+
}
|
|
2503
|
+
};
|
|
2504
|
+
|
|
2505
|
+
// src/generators/ai-assistant/ai-assistant.ts
|
|
2506
|
+
var SCALE_STACK_AGENTS_START = "<!-- scale-stack:agents:start -->";
|
|
2507
|
+
var SCALE_STACK_AGENTS_END = "<!-- scale-stack:agents:end -->";
|
|
2508
|
+
var SCALE_STACK_CLAUDE_START = "<!-- scale-stack:claude:start -->";
|
|
2509
|
+
var SCALE_STACK_CLAUDE_END = "<!-- scale-stack:claude:end -->";
|
|
2510
|
+
var FALLBACK_AGENTS_MD = `# AGENTS.md
|
|
2511
|
+
`;
|
|
2512
|
+
var FALLBACK_CLAUDE_MD = `@AGENTS.md
|
|
2513
|
+
`;
|
|
2514
|
+
function hasAuth(config) {
|
|
2515
|
+
return config.authStrategy !== "none";
|
|
2516
|
+
}
|
|
2517
|
+
function buildTechnologyGuides(config) {
|
|
2518
|
+
const guides = [
|
|
2519
|
+
{
|
|
2520
|
+
name: "Next.js 16.2+ (App Router)",
|
|
2521
|
+
purpose: "application framework",
|
|
2522
|
+
selected: false
|
|
2523
|
+
},
|
|
2524
|
+
{ name: "React 19", purpose: "UI runtime", selected: false },
|
|
2525
|
+
{ name: "Node 22+", purpose: "runtime target", selected: false },
|
|
2526
|
+
{ name: "pnpm", purpose: "exclusive package manager", selected: false },
|
|
2527
|
+
{
|
|
2528
|
+
name: "TypeScript",
|
|
2529
|
+
purpose: "strict application language",
|
|
2530
|
+
selected: false
|
|
2531
|
+
},
|
|
2532
|
+
{ name: "Turbopack", purpose: "Next.js dev bundler", selected: false },
|
|
2533
|
+
{
|
|
2534
|
+
name: "React Compiler",
|
|
2535
|
+
purpose: "default compiler optimization",
|
|
2536
|
+
selected: false
|
|
2537
|
+
},
|
|
2538
|
+
{ name: "Tailwind CSS 4", purpose: "styling system", selected: false },
|
|
2539
|
+
{ name: "shadcn/ui", purpose: "UI component primitives", selected: false },
|
|
2540
|
+
{ name: "Zustand", purpose: "client state management", selected: false },
|
|
2541
|
+
{ name: "nuqs", purpose: "URL-synchronized state", selected: false },
|
|
2542
|
+
{
|
|
2543
|
+
name: "@t3-oss/env-nextjs",
|
|
2544
|
+
purpose: "environment schema validation",
|
|
2545
|
+
selected: false
|
|
2546
|
+
},
|
|
2547
|
+
{ name: "Zod", purpose: "schema validation", selected: false },
|
|
2548
|
+
{
|
|
2549
|
+
name: "next-safe-action",
|
|
2550
|
+
purpose: "typed and validated Server Actions",
|
|
2551
|
+
selected: false
|
|
2552
|
+
},
|
|
2553
|
+
{ name: "react-hook-form", purpose: "form state", selected: false },
|
|
2554
|
+
{
|
|
2555
|
+
name: "@hookform/resolvers",
|
|
2556
|
+
purpose: "Zod form integration",
|
|
2557
|
+
selected: false
|
|
2558
|
+
},
|
|
2559
|
+
{ name: "Docker", purpose: "production container build", selected: false },
|
|
2560
|
+
{
|
|
2561
|
+
name: "Docker Compose",
|
|
2562
|
+
purpose: "local service orchestration",
|
|
2563
|
+
selected: false
|
|
2564
|
+
},
|
|
2565
|
+
{
|
|
2566
|
+
name: "OpenTelemetry",
|
|
2567
|
+
purpose: "provider-agnostic tracing",
|
|
2568
|
+
selected: false
|
|
2569
|
+
},
|
|
2570
|
+
{
|
|
2571
|
+
name: "Agent DevTools MCP",
|
|
2572
|
+
purpose: "local Next.js agent debugging",
|
|
2573
|
+
selected: false
|
|
2574
|
+
},
|
|
2575
|
+
{
|
|
2576
|
+
name: "Vercel Agent Skills",
|
|
2577
|
+
purpose: "packaged agent workflows",
|
|
2578
|
+
selected: false
|
|
2579
|
+
},
|
|
2580
|
+
{ name: "ESLint", purpose: "linting", selected: false },
|
|
2581
|
+
{ name: "Prettier", purpose: "formatting", selected: false }
|
|
2582
|
+
];
|
|
2583
|
+
if (config.orm === "prisma") {
|
|
2584
|
+
guides.push(
|
|
2585
|
+
{ name: "Prisma v7", purpose: "ORM", selected: true },
|
|
2586
|
+
{ name: "PostgreSQL", purpose: "database", selected: true },
|
|
2587
|
+
{
|
|
2588
|
+
name: "@prisma/adapter-pg",
|
|
2589
|
+
purpose: "Prisma PostgreSQL driver adapter",
|
|
2590
|
+
selected: true
|
|
2591
|
+
}
|
|
2592
|
+
);
|
|
2593
|
+
}
|
|
2594
|
+
if (hasAuth(config)) {
|
|
2595
|
+
guides.push(
|
|
2596
|
+
{ name: "Better Auth", purpose: "authentication", selected: true },
|
|
2597
|
+
{
|
|
2598
|
+
name: "Microsoft Entra OAuth",
|
|
2599
|
+
purpose: "SSO provider",
|
|
2600
|
+
selected: true
|
|
2601
|
+
}
|
|
2602
|
+
);
|
|
2603
|
+
}
|
|
2604
|
+
if (config.aiChat) {
|
|
2605
|
+
guides.push(
|
|
2606
|
+
{
|
|
2607
|
+
name: "AI SDK 6",
|
|
2608
|
+
purpose: "client chat transport and UI message stream contract",
|
|
2609
|
+
selected: true
|
|
2610
|
+
},
|
|
2611
|
+
{ name: "AI Elements", purpose: "chat UI components", selected: true }
|
|
2612
|
+
);
|
|
2613
|
+
}
|
|
2614
|
+
if (config.i18n) {
|
|
2615
|
+
guides.push({
|
|
2616
|
+
name: "next-intl",
|
|
2617
|
+
purpose: "internationalization",
|
|
2618
|
+
selected: true
|
|
2619
|
+
});
|
|
2620
|
+
}
|
|
2621
|
+
if (config.ci === "github") {
|
|
2622
|
+
guides.push({
|
|
2623
|
+
name: "GitHub Actions",
|
|
2624
|
+
purpose: "CI/CD pipeline; pnpm lint and Docker build are active, while typecheck/test/e2e steps are TODO no-ops until scripts exist",
|
|
2625
|
+
selected: true
|
|
2626
|
+
});
|
|
2627
|
+
}
|
|
2628
|
+
if (config.analytics === "plausible") {
|
|
2629
|
+
guides.push(
|
|
2630
|
+
{
|
|
2631
|
+
name: "Plausible",
|
|
2632
|
+
purpose: "privacy-focused analytics; runtime script is consent-gated and uses env-driven domain/API host values",
|
|
2633
|
+
selected: true
|
|
2634
|
+
},
|
|
2635
|
+
{
|
|
2636
|
+
name: "ClickHouse",
|
|
2637
|
+
purpose: "Plausible event storage",
|
|
2638
|
+
selected: true
|
|
2639
|
+
}
|
|
2640
|
+
);
|
|
2641
|
+
}
|
|
2642
|
+
if (config.preCommit) {
|
|
2643
|
+
guides.push({
|
|
2644
|
+
name: "prek",
|
|
2645
|
+
purpose: "pre-commit hook runner",
|
|
2646
|
+
selected: true
|
|
2647
|
+
});
|
|
2648
|
+
}
|
|
2649
|
+
return guides;
|
|
2650
|
+
}
|
|
2651
|
+
function buildBaselineSkillGuides() {
|
|
2652
|
+
return PHASE_4_1_BASELINE_AGENT_SKILLS.map(({ skill }) => {
|
|
2653
|
+
switch (skill) {
|
|
2654
|
+
case "find-skills":
|
|
2655
|
+
return {
|
|
2656
|
+
name: skill,
|
|
2657
|
+
purpose: "discover additional project-relevant Agent Skills"
|
|
2658
|
+
};
|
|
2659
|
+
case "next-best-practices":
|
|
2660
|
+
return {
|
|
2661
|
+
name: skill,
|
|
2662
|
+
purpose: "follow current Next.js conventions"
|
|
2663
|
+
};
|
|
2664
|
+
case "vercel-react-view-transitions":
|
|
2665
|
+
return {
|
|
2666
|
+
name: skill,
|
|
2667
|
+
purpose: "work with React view transitions"
|
|
2668
|
+
};
|
|
2669
|
+
case "vercel-react-best-practices":
|
|
2670
|
+
return {
|
|
2671
|
+
name: skill,
|
|
2672
|
+
purpose: "keep React code idiomatic"
|
|
2673
|
+
};
|
|
2674
|
+
case "vercel-composition-patterns":
|
|
2675
|
+
return {
|
|
2676
|
+
name: skill,
|
|
2677
|
+
purpose: "compose React components consistently"
|
|
2678
|
+
};
|
|
2679
|
+
case "web-design-guidelines":
|
|
2680
|
+
return {
|
|
2681
|
+
name: skill,
|
|
2682
|
+
purpose: "follow web design guidance"
|
|
2683
|
+
};
|
|
2684
|
+
default:
|
|
2685
|
+
return {
|
|
2686
|
+
name: skill,
|
|
2687
|
+
purpose: "follow project-specific coding guidance"
|
|
2688
|
+
};
|
|
2689
|
+
}
|
|
2690
|
+
});
|
|
2691
|
+
}
|
|
2692
|
+
function buildInstalledSkillGuides(config) {
|
|
2693
|
+
const guides = [
|
|
2694
|
+
...buildBaselineSkillGuides(),
|
|
2695
|
+
{ name: "shadcn/ui", purpose: "use installed shadcn/ui primitives" },
|
|
2696
|
+
{ name: "nuqs", purpose: "manage URL-synchronized state" },
|
|
2697
|
+
{ name: "zustand-5", purpose: "manage colocated client state" },
|
|
2698
|
+
{
|
|
2699
|
+
name: "next-safe-action",
|
|
2700
|
+
purpose: "write validated, typed Server Actions"
|
|
2701
|
+
}
|
|
2702
|
+
];
|
|
2703
|
+
if (config.orm === "prisma") {
|
|
2704
|
+
guides.push({
|
|
2705
|
+
name: "prisma/skills",
|
|
2706
|
+
purpose: "work with Prisma schema, migrations, and client code"
|
|
2707
|
+
});
|
|
2708
|
+
}
|
|
2709
|
+
if (hasAuth(config)) {
|
|
2710
|
+
guides.push({
|
|
2711
|
+
name: "better-auth-best-practices",
|
|
2712
|
+
purpose: "follow Better Auth integration patterns"
|
|
2713
|
+
});
|
|
2714
|
+
}
|
|
2715
|
+
if (config.aiChat) {
|
|
2716
|
+
guides.push(
|
|
2717
|
+
{
|
|
2718
|
+
name: "ai-sdk",
|
|
2719
|
+
purpose: "use AI SDK client transport and UI message contracts"
|
|
2720
|
+
},
|
|
2721
|
+
{
|
|
2722
|
+
name: "ai-elements",
|
|
2723
|
+
purpose: "compose generated chat UI with AI Elements components"
|
|
2724
|
+
}
|
|
2725
|
+
);
|
|
2726
|
+
}
|
|
2727
|
+
return guides;
|
|
2728
|
+
}
|
|
2729
|
+
function replaceManagedSection(existing, startMarker, endMarker, section) {
|
|
2730
|
+
const trimmedExisting = existing.trimEnd();
|
|
2731
|
+
const pattern = new RegExp(
|
|
2732
|
+
`${escapeRegExp(startMarker)}[\\s\\S]*?${escapeRegExp(endMarker)}`
|
|
2733
|
+
);
|
|
2734
|
+
if (pattern.test(trimmedExisting)) {
|
|
2735
|
+
return `${trimmedExisting.replace(pattern, section)}
|
|
2736
|
+
`;
|
|
2737
|
+
}
|
|
2738
|
+
return `${trimmedExisting}
|
|
2739
|
+
|
|
2740
|
+
${section}
|
|
2741
|
+
`;
|
|
2742
|
+
}
|
|
2743
|
+
function removeManagedSection(existing, startMarker, endMarker) {
|
|
2744
|
+
const pattern = new RegExp(
|
|
2745
|
+
`\\n*${escapeRegExp(startMarker)}[\\s\\S]*?${escapeRegExp(endMarker)}`
|
|
2746
|
+
);
|
|
2747
|
+
return existing.replace(pattern, "");
|
|
2748
|
+
}
|
|
2749
|
+
function escapeRegExp(value) {
|
|
2750
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2751
|
+
}
|
|
2752
|
+
function buildAgentsScaleStackSection(config) {
|
|
2753
|
+
const technologyLines = buildTechnologyGuides(config).map(
|
|
2754
|
+
({ name, purpose, selected }) => `- ${selected ? "Selected" : "Default"}: \`${name}\` - ${purpose}.`
|
|
2755
|
+
);
|
|
2756
|
+
const skillLines = buildInstalledSkillGuides(config).map(
|
|
2757
|
+
({ name, purpose }) => `- \`${name}\`: ${purpose}.`
|
|
2758
|
+
);
|
|
2759
|
+
return [
|
|
2760
|
+
SCALE_STACK_AGENTS_START,
|
|
2761
|
+
"## Scale Stack Guidance",
|
|
2762
|
+
"",
|
|
2763
|
+
"- Keep route-specific UI, hooks, state, actions, and AI artifacts colocated under private `_folders` inside the route segment that consumes them.",
|
|
2764
|
+
"- Use the listed Technology Stack by default; do not add alternatives unless the task requires it.",
|
|
2765
|
+
"- When adding or changing environment variables, update `src/env.ts` and `env.example`; never bypass the schema.",
|
|
2766
|
+
"- Never commit `.env`, credentials, API keys, tokens, or other secrets.",
|
|
2767
|
+
"",
|
|
2768
|
+
"### Technology Stack",
|
|
2769
|
+
"",
|
|
2770
|
+
...technologyLines,
|
|
2771
|
+
"",
|
|
2772
|
+
"### Installed Agent Skills",
|
|
2773
|
+
"",
|
|
2774
|
+
...skillLines,
|
|
2775
|
+
SCALE_STACK_AGENTS_END
|
|
2776
|
+
].join("\n");
|
|
2777
|
+
}
|
|
2778
|
+
function mergeAgentsMdContent(existing, config) {
|
|
2779
|
+
const base = existing.trim() ? existing : FALLBACK_AGENTS_MD;
|
|
2780
|
+
return replaceManagedSection(
|
|
2781
|
+
base,
|
|
2782
|
+
SCALE_STACK_AGENTS_START,
|
|
2783
|
+
SCALE_STACK_AGENTS_END,
|
|
2784
|
+
buildAgentsScaleStackSection(config)
|
|
2785
|
+
);
|
|
2786
|
+
}
|
|
2787
|
+
function mergeClaudeMdContent(existing) {
|
|
2788
|
+
const withImport = existing.includes("@AGENTS.md") ? existing : `${FALLBACK_CLAUDE_MD}
|
|
2789
|
+
${existing}`;
|
|
2790
|
+
const withoutOldManagedSection = removeManagedSection(
|
|
2791
|
+
withImport,
|
|
2792
|
+
SCALE_STACK_CLAUDE_START,
|
|
2793
|
+
SCALE_STACK_CLAUDE_END
|
|
2794
|
+
);
|
|
2795
|
+
return `${withoutOldManagedSection.trimEnd()}
|
|
2796
|
+
`;
|
|
2797
|
+
}
|
|
2798
|
+
function buildMcpJson() {
|
|
2799
|
+
return `${JSON.stringify(
|
|
2800
|
+
{
|
|
2801
|
+
mcpServers: {
|
|
2802
|
+
"next-devtools": {
|
|
2803
|
+
command: "npx",
|
|
2804
|
+
args: ["-y", "next-devtools-mcp@latest"]
|
|
2805
|
+
}
|
|
2806
|
+
}
|
|
2807
|
+
},
|
|
2808
|
+
null,
|
|
2809
|
+
2
|
|
2810
|
+
)}
|
|
2811
|
+
`;
|
|
2812
|
+
}
|
|
2813
|
+
var aiAssistantGenerator = {
|
|
2814
|
+
name: "ai-assistant",
|
|
2815
|
+
dependencies: [
|
|
2816
|
+
"docker",
|
|
2817
|
+
"form-handling",
|
|
2818
|
+
"error-handling",
|
|
2819
|
+
"state",
|
|
2820
|
+
"eslint-prettier"
|
|
2821
|
+
],
|
|
2822
|
+
resolveDependencies: (config) => [
|
|
2823
|
+
...config.preCommit ? ["pre-commit"] : [],
|
|
2824
|
+
...config.aiChat ? ["ai-chat"] : [],
|
|
2825
|
+
...config.ci !== "none" ? ["ci"] : [],
|
|
2826
|
+
...config.analytics !== "none" ? ["analytics"] : []
|
|
2827
|
+
],
|
|
2828
|
+
async execute(ctx) {
|
|
2829
|
+
ctx.fs.merge(
|
|
2830
|
+
"AGENTS.md",
|
|
2831
|
+
(existing) => mergeAgentsMdContent(existing, ctx.config)
|
|
2832
|
+
);
|
|
2833
|
+
ctx.fs.merge("CLAUDE.md", mergeClaudeMdContent);
|
|
2834
|
+
ctx.fs.write(".mcp.json", buildMcpJson());
|
|
2835
|
+
}
|
|
2836
|
+
};
|
|
2837
|
+
|
|
2838
|
+
// src/generators/analytics/analytics.ts
|
|
2839
|
+
var ANALYTICS_PROVIDER_PATH = "src/app/_providers/analytics-provider.tsx";
|
|
2840
|
+
var ANALYTICS_HELPER_PATH = "src/lib/analytics.ts";
|
|
2841
|
+
var POSTHOG_NOT_IMPLEMENTED_MESSAGE = "PostHog analytics is not implemented yet; no analytics runtime files or dependencies generated.";
|
|
2842
|
+
var PLAUSIBLE_ENV_FRAGMENT = {
|
|
2843
|
+
variables: [
|
|
2844
|
+
{
|
|
2845
|
+
name: "NEXT_PUBLIC_PLAUSIBLE_API_HOST",
|
|
2846
|
+
scope: "client",
|
|
2847
|
+
schema: "z.string().url()",
|
|
2848
|
+
example: "http://localhost:8000"
|
|
2849
|
+
},
|
|
2850
|
+
{
|
|
2851
|
+
name: "NEXT_PUBLIC_PLAUSIBLE_DOMAIN",
|
|
2852
|
+
scope: "client",
|
|
2853
|
+
schema: "z.string().min(1)",
|
|
2854
|
+
example: "localhost"
|
|
2855
|
+
},
|
|
2856
|
+
{
|
|
2857
|
+
name: "NEXT_PUBLIC_PLAUSIBLE_CAPTURE_LOCALHOST",
|
|
2858
|
+
scope: "client",
|
|
2859
|
+
schema: 'z.enum(["true", "false"])',
|
|
2860
|
+
example: "true"
|
|
2861
|
+
},
|
|
2862
|
+
{
|
|
2863
|
+
name: "NEXT_PUBLIC_PLAUSIBLE_SCRIPT_SRC",
|
|
2864
|
+
scope: "client",
|
|
2865
|
+
schema: "z.string().url()",
|
|
2866
|
+
example: "http://localhost:8000/js/script.js"
|
|
2867
|
+
}
|
|
2868
|
+
]
|
|
2869
|
+
};
|
|
2870
|
+
async function writePlausibleFiles(ctx) {
|
|
2871
|
+
const [provider, helper] = await Promise.all([
|
|
2872
|
+
ctx.template.renderFile("analytics/analytics-provider.tsx.ejs", {}),
|
|
2873
|
+
ctx.template.renderFile("analytics/analytics.ts.ejs", {})
|
|
2874
|
+
]);
|
|
2875
|
+
ctx.envBag.push(structuredClone(PLAUSIBLE_ENV_FRAGMENT));
|
|
2876
|
+
ctx.fs.write(ANALYTICS_PROVIDER_PATH, provider);
|
|
2877
|
+
ctx.fs.write(ANALYTICS_HELPER_PATH, helper);
|
|
2878
|
+
}
|
|
2879
|
+
var analyticsGenerator = {
|
|
2880
|
+
name: "analytics",
|
|
2881
|
+
dependencies: ["env-management", "ui"],
|
|
2882
|
+
condition: (config) => config.analytics !== "none",
|
|
2883
|
+
async execute(ctx) {
|
|
2884
|
+
if (ctx.config.analytics === "posthog") {
|
|
2885
|
+
ctx.logger.warn("analytics", POSTHOG_NOT_IMPLEMENTED_MESSAGE);
|
|
2886
|
+
return;
|
|
2887
|
+
}
|
|
2888
|
+
await writePlausibleFiles(ctx);
|
|
2889
|
+
}
|
|
2890
|
+
};
|
|
2891
|
+
|
|
2892
|
+
// src/generators/orm/orm.ts
|
|
2893
|
+
var PHASE_13_ORM_AGENT_SKILL_COMMAND = buildSkillsAddCommand(
|
|
2894
|
+
"prisma/skills",
|
|
2895
|
+
[]
|
|
2896
|
+
);
|
|
2897
|
+
var PHASE_13_PRISMA_GENERATE_COMMAND = "pnpm db:generate";
|
|
2898
|
+
var ORM_FILES = [
|
|
2899
|
+
{
|
|
2900
|
+
templatePath: "orm/schema.prisma.ejs",
|
|
2901
|
+
outputPath: "prisma/schema.prisma"
|
|
2902
|
+
},
|
|
2903
|
+
{
|
|
2904
|
+
templatePath: "orm/prisma.config.ts.ejs",
|
|
2905
|
+
outputPath: "prisma.config.ts"
|
|
2906
|
+
},
|
|
2907
|
+
{
|
|
2908
|
+
templatePath: "orm/prisma.ts.ejs",
|
|
2909
|
+
outputPath: "src/lib/prisma.ts"
|
|
2910
|
+
}
|
|
2911
|
+
];
|
|
2912
|
+
function mergePackageJson2(existing) {
|
|
2913
|
+
const desiredFragment = {
|
|
2914
|
+
scripts: structuredClone(PACKAGE_SCRIPTS_BASELINE),
|
|
2915
|
+
dependencies: structuredClone(PACKAGE_DEPS_BASELINE),
|
|
2916
|
+
devDependencies: structuredClone(PACKAGE_DEV_DEPS_BASELINE)
|
|
2917
|
+
};
|
|
2918
|
+
return mergeManagedPackageJson({
|
|
2919
|
+
existingContent: existing,
|
|
2920
|
+
desiredFragment,
|
|
2921
|
+
sortSections: ["dependencies", "devDependencies"]
|
|
2922
|
+
});
|
|
2923
|
+
}
|
|
2924
|
+
async function writeOrmFiles(ctx) {
|
|
2925
|
+
for (const file of ORM_FILES) {
|
|
2926
|
+
const content = await ctx.template.renderFile(file.templatePath, {});
|
|
2927
|
+
ctx.fs.write(file.outputPath, content);
|
|
2928
|
+
}
|
|
2929
|
+
}
|
|
2930
|
+
var ormGenerator = {
|
|
2931
|
+
name: "orm",
|
|
2932
|
+
dependencies: ["env-management"],
|
|
2933
|
+
condition: (config) => config.orm === "prisma",
|
|
2934
|
+
async execute(ctx) {
|
|
2935
|
+
ctx.fs.merge("package.json", mergePackageJson2);
|
|
2936
|
+
await writeOrmFiles(ctx);
|
|
2937
|
+
await ctx.exec(PHASE_13_ORM_AGENT_SKILL_COMMAND, {
|
|
2938
|
+
cwd: ctx.targetDir,
|
|
2939
|
+
env: { ...AGENT_SKILLS_EXEC_ENV }
|
|
2940
|
+
});
|
|
2941
|
+
ctx.envBag.push(buildOrmEnvFragment(ctx.config.projectName));
|
|
2942
|
+
},
|
|
2943
|
+
async afterInstall(ctx) {
|
|
2944
|
+
if (ctx.config.authStrategy === "stateful") return;
|
|
2945
|
+
await ctx.exec(PHASE_13_PRISMA_GENERATE_COMMAND, {
|
|
2946
|
+
cwd: ctx.targetDir,
|
|
2947
|
+
env: {
|
|
2948
|
+
DATABASE_URL: buildDatabaseUrlExample(ctx.config.projectName)
|
|
2949
|
+
}
|
|
2950
|
+
});
|
|
2951
|
+
}
|
|
2952
|
+
};
|
|
2953
|
+
|
|
2954
|
+
// src/generators/auth/auth.ts
|
|
2955
|
+
var PHASE_14_AUTH_AGENT_SKILL_COMMAND = buildSkillsAddCommand(
|
|
2956
|
+
"better-auth/skills",
|
|
2957
|
+
["better-auth-best-practices"]
|
|
2958
|
+
);
|
|
2959
|
+
var PHASE_14_AUTH_GENERATE_COMMAND = "pnpm dlx auth@latest generate --config src/lib/auth.ts --yes";
|
|
2960
|
+
var AUTH_FILES = [
|
|
2961
|
+
{
|
|
2962
|
+
templatePath: "auth/auth.ts.ejs",
|
|
2963
|
+
outputPath: "src/lib/auth.ts"
|
|
2964
|
+
},
|
|
2965
|
+
{
|
|
2966
|
+
templatePath: "auth/auth-client.ts.ejs",
|
|
2967
|
+
outputPath: "src/lib/auth-client.ts"
|
|
2968
|
+
},
|
|
2969
|
+
{
|
|
2970
|
+
templatePath: "auth/route.ts.ejs",
|
|
2971
|
+
outputPath: "src/app/api/auth/[...all]/route.ts"
|
|
2972
|
+
},
|
|
2973
|
+
{
|
|
2974
|
+
templatePath: "auth/sign-in-page.tsx.ejs",
|
|
2975
|
+
outputPath: "src/app/(auth)/sign-in/page.tsx"
|
|
2976
|
+
},
|
|
2977
|
+
{
|
|
2978
|
+
templatePath: "auth/sign-up-page.tsx.ejs",
|
|
2979
|
+
outputPath: "src/app/(auth)/sign-up/page.tsx"
|
|
2980
|
+
},
|
|
2981
|
+
{
|
|
2982
|
+
templatePath: "auth/unauthorized.tsx.ejs",
|
|
2983
|
+
outputPath: "src/app/unauthorized.tsx"
|
|
2984
|
+
}
|
|
2985
|
+
];
|
|
2986
|
+
function isStatefulAuth(strategy) {
|
|
2987
|
+
return strategy === "stateful";
|
|
2988
|
+
}
|
|
2989
|
+
function buildAuthAfterInstallEnv(strategy, projectName) {
|
|
2990
|
+
const env = {
|
|
2991
|
+
BETTER_AUTH_SECRET: "replace-with-at-least-32-character-secret",
|
|
2992
|
+
BETTER_AUTH_URL: "http://localhost:3000",
|
|
2993
|
+
MICROSOFT_CLIENT_ID: "replace-with-microsoft-client-id",
|
|
2994
|
+
MICROSOFT_CLIENT_SECRET: "replace-with-microsoft-client-secret",
|
|
2995
|
+
MICROSOFT_TENANT_ID: "common",
|
|
2996
|
+
NODE_ENV: "development",
|
|
2997
|
+
SKIP_ENV_VALIDATION: "1"
|
|
2998
|
+
};
|
|
2999
|
+
if (isStatefulAuth(strategy)) {
|
|
3000
|
+
env.DATABASE_URL = buildDatabaseUrlExample(projectName);
|
|
3001
|
+
}
|
|
3002
|
+
return env;
|
|
3003
|
+
}
|
|
3004
|
+
function mergePackageJson3(existing) {
|
|
3005
|
+
const desiredFragment = {
|
|
3006
|
+
dependencies: structuredClone(PACKAGE_DEPS_BASELINE)
|
|
3007
|
+
};
|
|
3008
|
+
return mergeManagedPackageJson({
|
|
3009
|
+
existingContent: existing,
|
|
3010
|
+
desiredFragment,
|
|
3011
|
+
sortSections: ["dependencies"]
|
|
3012
|
+
});
|
|
3013
|
+
}
|
|
3014
|
+
async function writeAuthFiles(ctx) {
|
|
3015
|
+
const isStateful = isStatefulAuth(ctx.config.authStrategy);
|
|
3016
|
+
for (const file of AUTH_FILES) {
|
|
3017
|
+
const content = await ctx.template.renderFile(file.templatePath, {
|
|
3018
|
+
isStateful,
|
|
3019
|
+
i18n: ctx.config.i18n
|
|
3020
|
+
});
|
|
3021
|
+
ctx.fs.write(
|
|
3022
|
+
localizeAppRoutePath(file.outputPath, ctx.config.i18n),
|
|
3023
|
+
content
|
|
3024
|
+
);
|
|
3025
|
+
}
|
|
3026
|
+
}
|
|
3027
|
+
var authGenerator = {
|
|
3028
|
+
name: "auth",
|
|
3029
|
+
dependencies: ["ui", "env-management"],
|
|
3030
|
+
resolveDependencies: (config) => isStatefulAuth(config.authStrategy) ? ["orm"] : [],
|
|
3031
|
+
condition: (config) => config.authStrategy !== "none",
|
|
3032
|
+
async execute(ctx) {
|
|
3033
|
+
ctx.fs.merge("package.json", mergePackageJson3);
|
|
3034
|
+
await writeAuthFiles(ctx);
|
|
3035
|
+
await ctx.exec(PHASE_14_AUTH_AGENT_SKILL_COMMAND, {
|
|
3036
|
+
cwd: ctx.targetDir,
|
|
3037
|
+
env: { ...AGENT_SKILLS_EXEC_ENV }
|
|
3038
|
+
});
|
|
3039
|
+
ctx.envBag.push(buildAuthEnvFragment());
|
|
3040
|
+
},
|
|
3041
|
+
async afterInstall(ctx) {
|
|
3042
|
+
if (!isStatefulAuth(ctx.config.authStrategy)) return;
|
|
3043
|
+
const env = buildAuthAfterInstallEnv(
|
|
3044
|
+
ctx.config.authStrategy,
|
|
3045
|
+
ctx.config.projectName
|
|
3046
|
+
);
|
|
3047
|
+
await ctx.exec(PHASE_13_PRISMA_GENERATE_COMMAND, {
|
|
3048
|
+
cwd: ctx.targetDir,
|
|
3049
|
+
env
|
|
3050
|
+
});
|
|
3051
|
+
await ctx.exec(PHASE_14_AUTH_GENERATE_COMMAND, {
|
|
3052
|
+
cwd: ctx.targetDir,
|
|
3053
|
+
env
|
|
3054
|
+
});
|
|
3055
|
+
await ctx.exec(PHASE_13_PRISMA_GENERATE_COMMAND, {
|
|
3056
|
+
cwd: ctx.targetDir,
|
|
3057
|
+
env
|
|
3058
|
+
});
|
|
3059
|
+
}
|
|
3060
|
+
};
|
|
3061
|
+
|
|
3062
|
+
// src/generators/ci/ci.ts
|
|
3063
|
+
var GITHUB_ACTIONS_WORKFLOW_PATH = ".github/workflows/ci.yml";
|
|
3064
|
+
var CIRCLECI_CONFIG_PATH = ".circleci/config.yml";
|
|
3065
|
+
function hasPrisma(config) {
|
|
3066
|
+
return config.orm === "prisma";
|
|
3067
|
+
}
|
|
3068
|
+
function buildPostgresServiceYaml() {
|
|
3069
|
+
return [
|
|
3070
|
+
" services:",
|
|
3071
|
+
" postgres:",
|
|
3072
|
+
" image: postgres:16-alpine",
|
|
3073
|
+
" env:",
|
|
3074
|
+
" POSTGRES_USER: postgres",
|
|
3075
|
+
" POSTGRES_DB: scale_stack_ci",
|
|
3076
|
+
" POSTGRES_HOST_AUTH_METHOD: trust",
|
|
3077
|
+
" ports:",
|
|
3078
|
+
" - 5432:5432",
|
|
3079
|
+
" options: >-",
|
|
3080
|
+
' --health-cmd "pg_isready -U postgres -d scale_stack_ci"',
|
|
3081
|
+
" --health-interval 5s",
|
|
3082
|
+
" --health-timeout 5s",
|
|
3083
|
+
" --health-retries 10"
|
|
3084
|
+
];
|
|
3085
|
+
}
|
|
3086
|
+
function buildPrismaMigrationCheckYaml(config) {
|
|
3087
|
+
if (!hasPrisma(config)) return [];
|
|
3088
|
+
return [
|
|
3089
|
+
" - name: Check Prisma migrations",
|
|
3090
|
+
" env:",
|
|
3091
|
+
" DATABASE_URL: postgresql://postgres@localhost:5432/scale_stack_ci",
|
|
3092
|
+
" run: |",
|
|
3093
|
+
" pnpm prisma migrate diff \\",
|
|
3094
|
+
" --from-migrations prisma/migrations \\",
|
|
3095
|
+
" --to-schema-datamodel prisma/schema.prisma \\",
|
|
3096
|
+
' --shadow-database-url "$DATABASE_URL" \\',
|
|
3097
|
+
" --exit-code",
|
|
3098
|
+
""
|
|
3099
|
+
];
|
|
3100
|
+
}
|
|
3101
|
+
function buildGithubActionsWorkflow(config) {
|
|
3102
|
+
return [
|
|
3103
|
+
"name: CI",
|
|
3104
|
+
"",
|
|
3105
|
+
"on:",
|
|
3106
|
+
" push:",
|
|
3107
|
+
" branches: [main]",
|
|
3108
|
+
" pull_request:",
|
|
3109
|
+
"",
|
|
3110
|
+
"permissions:",
|
|
3111
|
+
" contents: read",
|
|
3112
|
+
"",
|
|
3113
|
+
"jobs:",
|
|
3114
|
+
" validate:",
|
|
3115
|
+
" name: Validate",
|
|
3116
|
+
" runs-on: ubuntu-latest",
|
|
3117
|
+
...hasPrisma(config) ? buildPostgresServiceYaml() : [],
|
|
3118
|
+
" steps:",
|
|
3119
|
+
" - name: Checkout",
|
|
3120
|
+
" uses: actions/checkout@v4",
|
|
3121
|
+
"",
|
|
3122
|
+
" - name: Setup pnpm",
|
|
3123
|
+
" uses: pnpm/action-setup@v4",
|
|
3124
|
+
"",
|
|
3125
|
+
" - name: Setup Node.js",
|
|
3126
|
+
" uses: actions/setup-node@v4",
|
|
3127
|
+
" with:",
|
|
3128
|
+
" node-version: 24",
|
|
3129
|
+
" cache: pnpm",
|
|
3130
|
+
"",
|
|
3131
|
+
" - name: Install dependencies",
|
|
3132
|
+
" run: pnpm install --frozen-lockfile",
|
|
3133
|
+
"",
|
|
3134
|
+
" - name: Lint",
|
|
3135
|
+
" run: pnpm lint",
|
|
3136
|
+
"",
|
|
3137
|
+
" - name: TODO typecheck",
|
|
3138
|
+
` run: 'echo "TODO: enable pnpm typecheck after Phase 18 adds the script"'`,
|
|
3139
|
+
"",
|
|
3140
|
+
" - name: TODO unit tests",
|
|
3141
|
+
` run: 'echo "TODO: enable pnpm test after Phase 18 adds the script"'`,
|
|
3142
|
+
"",
|
|
3143
|
+
" - name: TODO end-to-end tests",
|
|
3144
|
+
` run: 'echo "TODO: enable pnpm test:e2e after Phase 18 adds the script"'`,
|
|
3145
|
+
"",
|
|
3146
|
+
...buildPrismaMigrationCheckYaml(config),
|
|
3147
|
+
" - name: Verify Docker build",
|
|
3148
|
+
" run: docker build .",
|
|
3149
|
+
""
|
|
3150
|
+
].join("\n");
|
|
3151
|
+
}
|
|
3152
|
+
var ciGenerator = {
|
|
3153
|
+
name: "ci",
|
|
3154
|
+
dependencies: ["docker"],
|
|
3155
|
+
resolveDependencies(config) {
|
|
3156
|
+
return hasPrisma(config) ? ["orm"] : [];
|
|
3157
|
+
},
|
|
3158
|
+
condition(config) {
|
|
3159
|
+
return config.ci !== "none";
|
|
3160
|
+
},
|
|
3161
|
+
async execute(ctx) {
|
|
3162
|
+
if (ctx.config.ci === "circleci") {
|
|
3163
|
+
ctx.logger.warn(
|
|
3164
|
+
"ci",
|
|
3165
|
+
`CircleCI coming soon; no ${CIRCLECI_CONFIG_PATH} generated yet.`
|
|
3166
|
+
);
|
|
3167
|
+
return;
|
|
3168
|
+
}
|
|
3169
|
+
ctx.fs.write(
|
|
3170
|
+
GITHUB_ACTIONS_WORKFLOW_PATH,
|
|
3171
|
+
buildGithubActionsWorkflow(ctx.config)
|
|
3172
|
+
);
|
|
3173
|
+
}
|
|
3174
|
+
};
|
|
3175
|
+
|
|
3176
|
+
// src/generators/core/eslint-prettier.ts
|
|
3177
|
+
function buildPrettierRcContents() {
|
|
3178
|
+
return `${JSON.stringify(PRETTIER_RC_BASELINE, null, 2)}
|
|
3179
|
+
`;
|
|
3180
|
+
}
|
|
3181
|
+
function buildPrettierIgnoreContents(config) {
|
|
3182
|
+
return renderTemplateFile("eslint-prettier/.prettierignore.ejs", {
|
|
3183
|
+
ignoreAiElementsGeneratedOutput: shouldIgnoreAiElementsGeneratedOutput(config),
|
|
3184
|
+
ignorePrismaGeneratedOutput: shouldIgnorePrismaGeneratedOutput(config)
|
|
3185
|
+
});
|
|
3186
|
+
}
|
|
3187
|
+
function mergePackageJson4(existing) {
|
|
3188
|
+
const desiredFragment = {
|
|
3189
|
+
scripts: structuredClone(PACKAGE_SCRIPTS_BASELINE),
|
|
3190
|
+
devDependencies: structuredClone(PACKAGE_DEV_DEPS_BASELINE)
|
|
3191
|
+
};
|
|
3192
|
+
return mergeManagedPackageJson({
|
|
3193
|
+
existingContent: existing,
|
|
3194
|
+
desiredFragment,
|
|
3195
|
+
sortSections: ["devDependencies"]
|
|
3196
|
+
});
|
|
3197
|
+
}
|
|
3198
|
+
var eslintPrettierGenerator = {
|
|
3199
|
+
name: "eslint-prettier",
|
|
3200
|
+
dependencies: ["project-init", "env-management"],
|
|
3201
|
+
async execute(ctx) {
|
|
3202
|
+
ctx.fs.write(
|
|
3203
|
+
"eslint.config.mjs",
|
|
3204
|
+
await buildEslintConfigMjsContents(ctx.config)
|
|
3205
|
+
);
|
|
3206
|
+
ctx.fs.write(".prettierrc", buildPrettierRcContents());
|
|
3207
|
+
ctx.fs.write(
|
|
3208
|
+
".prettierignore",
|
|
3209
|
+
await buildPrettierIgnoreContents(ctx.config)
|
|
3210
|
+
);
|
|
3211
|
+
ctx.fs.merge("package.json", mergePackageJson4);
|
|
3212
|
+
},
|
|
3213
|
+
async afterInstall(ctx) {
|
|
3214
|
+
await ctx.exec("pnpm exec prettier --write .");
|
|
3215
|
+
}
|
|
3216
|
+
};
|
|
3217
|
+
|
|
3218
|
+
// src/generators/core/build-env-files-from-fragments.ts
|
|
3219
|
+
function collectVariables(bag) {
|
|
3220
|
+
const merged = /* @__PURE__ */ new Map();
|
|
3221
|
+
for (const fragment of bag.fragments) {
|
|
3222
|
+
for (const variable of fragment.variables) {
|
|
3223
|
+
merged.set(variable.name, variable);
|
|
3224
|
+
}
|
|
3225
|
+
}
|
|
3226
|
+
return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
3227
|
+
}
|
|
3228
|
+
function pickScope(variables, scope) {
|
|
3229
|
+
return variables.filter((variable) => variable.scope === scope);
|
|
3230
|
+
}
|
|
3231
|
+
function buildSchemaSection(variables) {
|
|
3232
|
+
if (variables.length === 0) return "{}";
|
|
3233
|
+
return `{
|
|
3234
|
+
${variables.map((variable) => ` ${variable.name}: ${variable.schema},`).join("\n")}
|
|
3235
|
+
}`;
|
|
3236
|
+
}
|
|
3237
|
+
function buildRuntimeEnvSection(variables) {
|
|
3238
|
+
if (variables.length === 0) return "{}";
|
|
3239
|
+
return `{
|
|
3240
|
+
${variables.map((variable) => ` ${variable.name}: process.env["${variable.name}"],`).join("\n")}
|
|
3241
|
+
}`;
|
|
3242
|
+
}
|
|
3243
|
+
function buildEnvExample(variables) {
|
|
3244
|
+
const exampleLines = [
|
|
3245
|
+
"# Copy this file to `.env` and replace placeholder values before running the app.",
|
|
3246
|
+
"# `src/env.ts` is the source of truth for validation and types for the variables listed here.",
|
|
3247
|
+
"# Variables managed by Next.js/runtime (for example `NODE_ENV`) are intentionally omitted."
|
|
3248
|
+
];
|
|
3249
|
+
const userSuppliedVariables = variables.filter(
|
|
3250
|
+
(variable) => variable.includeInExample !== false
|
|
3251
|
+
);
|
|
3252
|
+
if (userSuppliedVariables.length === 0) {
|
|
3253
|
+
return `${exampleLines.join("\n")}
|
|
3254
|
+
`;
|
|
3255
|
+
}
|
|
3256
|
+
return `${exampleLines.join("\n")}
|
|
3257
|
+
${userSuppliedVariables.map((variable) => `${variable.name}=${variable.example}`).join("\n")}
|
|
3258
|
+
`;
|
|
3259
|
+
}
|
|
3260
|
+
function buildEnvFilesFromFragments(bag) {
|
|
3261
|
+
const variables = collectVariables(bag);
|
|
3262
|
+
const server = pickScope(variables, "server");
|
|
3263
|
+
const client = pickScope(variables, "client");
|
|
3264
|
+
const shared = pickScope(variables, "shared");
|
|
3265
|
+
const runtimeEnv = [...shared, ...client].sort(
|
|
3266
|
+
(a, b) => a.name.localeCompare(b.name)
|
|
3267
|
+
);
|
|
3268
|
+
const envTs = `import { createEnv } from "@t3-oss/env-nextjs";
|
|
3269
|
+
import { z } from "zod";
|
|
3270
|
+
|
|
3271
|
+
export const env = createEnv({
|
|
3272
|
+
server: ${buildSchemaSection(server)},
|
|
3273
|
+
client: ${buildSchemaSection(client)},
|
|
3274
|
+
shared: ${buildSchemaSection(shared)},
|
|
3275
|
+
experimental__runtimeEnv: ${buildRuntimeEnvSection(runtimeEnv)},
|
|
3276
|
+
skipValidation: process.env["SKIP_ENV_VALIDATION"] === "1",
|
|
3277
|
+
});
|
|
3278
|
+
`;
|
|
3279
|
+
return { envTs, envExample: buildEnvExample(variables) };
|
|
3280
|
+
}
|
|
3281
|
+
|
|
3282
|
+
// src/generators/core/env-management.ts
|
|
3283
|
+
var BASELINE_ENV_FRAGMENT = {
|
|
3284
|
+
variables: [
|
|
3285
|
+
{
|
|
3286
|
+
name: "NODE_ENV",
|
|
3287
|
+
scope: "shared",
|
|
3288
|
+
schema: 'z.enum(["development", "production", "test"])',
|
|
3289
|
+
example: "development",
|
|
3290
|
+
includeInExample: false
|
|
3291
|
+
}
|
|
3292
|
+
]
|
|
3293
|
+
};
|
|
3294
|
+
function mergePackageJson5(existing) {
|
|
3295
|
+
const desiredFragment = {
|
|
3296
|
+
dependencies: structuredClone(PACKAGE_DEPS_BASELINE)
|
|
3297
|
+
};
|
|
3298
|
+
return mergeManagedPackageJson({
|
|
3299
|
+
existingContent: existing,
|
|
3300
|
+
desiredFragment,
|
|
3301
|
+
sortSections: ["dependencies"]
|
|
3302
|
+
});
|
|
3303
|
+
}
|
|
3304
|
+
var envManagementGenerator = {
|
|
3305
|
+
name: "env-management",
|
|
3306
|
+
dependencies: ["project-init"],
|
|
3307
|
+
async execute(ctx) {
|
|
3308
|
+
ctx.fs.merge("package.json", mergePackageJson5);
|
|
3309
|
+
ctx.envBag.push(structuredClone(BASELINE_ENV_FRAGMENT));
|
|
3310
|
+
},
|
|
3311
|
+
async afterInstall(ctx) {
|
|
3312
|
+
const files = buildEnvFilesFromFragments(ctx.envBag);
|
|
3313
|
+
ctx.fs.write("src/env.ts", files.envTs);
|
|
3314
|
+
ctx.fs.write("env.example", files.envExample);
|
|
3315
|
+
}
|
|
3316
|
+
};
|
|
3317
|
+
|
|
3318
|
+
// src/generators/core/project-init.ts
|
|
3319
|
+
import { basename, dirname as dirname5 } from "path";
|
|
3320
|
+
import { rm as rm3 } from "fs/promises";
|
|
3321
|
+
var CREATE_NEXT_APP_VERSION = "16.2.6";
|
|
3322
|
+
var CNA_FLAGS = "--react-compiler --eslint --app --src-dir --use-pnpm --skip-install --disable-git";
|
|
3323
|
+
function buildCreateNextAppCommand(projectDirName) {
|
|
3324
|
+
const q = shellQuoteBasename(projectDirName);
|
|
3325
|
+
return `pnpm create next-app@${CREATE_NEXT_APP_VERSION} ${q} ${CNA_FLAGS}`;
|
|
3326
|
+
}
|
|
3327
|
+
function shellQuoteBasename(name) {
|
|
3328
|
+
if (/^[a-zA-Z0-9._-]+$/.test(name)) return name;
|
|
3329
|
+
return `'${name.replace(/'/g, `'\\''`)}'`;
|
|
3330
|
+
}
|
|
3331
|
+
function mergeTsconfigContent(existing, config) {
|
|
3332
|
+
const desiredFragment = structuredClone(
|
|
3333
|
+
buildTsconfigManagedBaseline(config)
|
|
3334
|
+
);
|
|
3335
|
+
const merged = mergeManagedJsonString({
|
|
3336
|
+
relativePath: "tsconfig.json",
|
|
3337
|
+
existingContent: existing,
|
|
3338
|
+
desiredFragment
|
|
3339
|
+
});
|
|
3340
|
+
return formatMergedJson(merged);
|
|
3341
|
+
}
|
|
3342
|
+
function mergeGitignoreContent(existing) {
|
|
3343
|
+
const needEnv = !/^\.env\*$/m.test(existing);
|
|
3344
|
+
const needScaleStack = !/^\.scale-stack\/?$/m.test(existing);
|
|
3345
|
+
const parts = [existing.replace(/\s+$/, "")];
|
|
3346
|
+
if (needEnv || needScaleStack) {
|
|
3347
|
+
parts.push("");
|
|
3348
|
+
parts.push("# Scale Stack");
|
|
3349
|
+
if (needEnv) parts.push(".env*");
|
|
3350
|
+
if (needScaleStack) parts.push(".scale-stack/");
|
|
3351
|
+
}
|
|
3352
|
+
return `${parts.join("\n")}
|
|
3353
|
+
`;
|
|
3354
|
+
}
|
|
3355
|
+
async function writeNextConfig(ctx) {
|
|
3356
|
+
ctx.fs.write(
|
|
3357
|
+
"next.config.ts",
|
|
3358
|
+
await buildNextConfigTsContents({
|
|
3359
|
+
config: {
|
|
3360
|
+
authStrategy: ctx.config.authStrategy,
|
|
3361
|
+
i18n: ctx.config.i18n
|
|
3362
|
+
}
|
|
3363
|
+
})
|
|
3364
|
+
);
|
|
3365
|
+
}
|
|
3366
|
+
async function writeAppOverlays(ctx) {
|
|
3367
|
+
const { projectName } = ctx.config;
|
|
3368
|
+
const [layout, loading] = await Promise.all([
|
|
3369
|
+
ctx.template.renderFile("core/layout.tsx.ejs", { projectName }),
|
|
3370
|
+
ctx.template.renderFile("core/loading.tsx.ejs", {})
|
|
3371
|
+
]);
|
|
3372
|
+
ctx.fs.write("src/app/layout.tsx", layout);
|
|
3373
|
+
ctx.fs.write(
|
|
3374
|
+
localizeAppRoutePath("src/app/loading.tsx", ctx.config.i18n),
|
|
3375
|
+
loading
|
|
3376
|
+
);
|
|
3377
|
+
}
|
|
3378
|
+
var projectInitGenerator = {
|
|
3379
|
+
name: "project-init",
|
|
3380
|
+
dependencies: [],
|
|
3381
|
+
async execute(ctx) {
|
|
3382
|
+
const folderName = basename(ctx.targetDir);
|
|
3383
|
+
const parentDir = dirname5(ctx.targetDir);
|
|
3384
|
+
const cmd = buildCreateNextAppCommand(folderName);
|
|
3385
|
+
await ctx.exec(cmd, { cwd: parentDir });
|
|
3386
|
+
await writeNextConfig(ctx);
|
|
3387
|
+
await writeAppOverlays(ctx);
|
|
3388
|
+
ctx.fs.merge(
|
|
3389
|
+
"tsconfig.json",
|
|
3390
|
+
(existing) => mergeTsconfigContent(existing, ctx.config)
|
|
3391
|
+
);
|
|
3392
|
+
ctx.fs.merge(".gitignore", mergeGitignoreContent);
|
|
3393
|
+
},
|
|
3394
|
+
async rollback(ctx) {
|
|
3395
|
+
await rm3(ctx.targetDir, { recursive: true, force: true });
|
|
3396
|
+
}
|
|
3397
|
+
};
|
|
3398
|
+
|
|
3399
|
+
// src/generators/docker/docker.ts
|
|
3400
|
+
var DOCKER_NODE_VERSION = "24.13.0-slim";
|
|
3401
|
+
var POSTGRES_IMAGE = "postgres:16-alpine";
|
|
3402
|
+
var CLICKHOUSE_IMAGE = "clickhouse/clickhouse-server:24.12-alpine";
|
|
3403
|
+
var PLAUSIBLE_IMAGE = "ghcr.io/plausible/community-edition:v3.2.0";
|
|
3404
|
+
var PLAUSIBLE_LOCAL_SECRET_KEY_BASE = "local-only-change-me-local-only-change-me-local-only-change-me-local-only-change-me";
|
|
3405
|
+
function hasPrisma2(config) {
|
|
3406
|
+
return config.orm === "prisma";
|
|
3407
|
+
}
|
|
3408
|
+
function hasPlausible(config) {
|
|
3409
|
+
return config.analytics === "plausible";
|
|
3410
|
+
}
|
|
3411
|
+
function buildComposeDatabaseUrl(projectName) {
|
|
3412
|
+
return `postgresql://postgres:postgres@postgres:5432/${buildPostgresDatabaseName(projectName)}`;
|
|
3413
|
+
}
|
|
3414
|
+
function buildAppEnvironment(config) {
|
|
3415
|
+
const lines = [' PORT: "3000"', ' HOSTNAME: "0.0.0.0"'];
|
|
3416
|
+
if (hasPrisma2(config)) {
|
|
3417
|
+
lines.push(
|
|
3418
|
+
` DATABASE_URL: ${buildComposeDatabaseUrl(config.projectName)}`
|
|
3419
|
+
);
|
|
3420
|
+
lines.push(' RUN_MIGRATIONS: "true"');
|
|
3421
|
+
}
|
|
3422
|
+
return lines;
|
|
3423
|
+
}
|
|
3424
|
+
function buildAppDependsOn(config) {
|
|
3425
|
+
const lines = [];
|
|
3426
|
+
if (hasPrisma2(config)) {
|
|
3427
|
+
lines.push(" postgres:");
|
|
3428
|
+
lines.push(" condition: service_healthy");
|
|
3429
|
+
}
|
|
3430
|
+
if (hasPlausible(config)) {
|
|
3431
|
+
lines.push(" plausible:");
|
|
3432
|
+
lines.push(" condition: service_healthy");
|
|
3433
|
+
}
|
|
3434
|
+
return lines;
|
|
3435
|
+
}
|
|
3436
|
+
function buildPostgresService(config) {
|
|
3437
|
+
if (!hasPrisma2(config)) return [];
|
|
3438
|
+
const databaseName = buildPostgresDatabaseName(config.projectName);
|
|
3439
|
+
return [
|
|
3440
|
+
" postgres:",
|
|
3441
|
+
` image: ${POSTGRES_IMAGE}`,
|
|
3442
|
+
" environment:",
|
|
3443
|
+
" POSTGRES_USER: postgres",
|
|
3444
|
+
" POSTGRES_PASSWORD: postgres",
|
|
3445
|
+
` POSTGRES_DB: ${databaseName}`,
|
|
3446
|
+
" volumes:",
|
|
3447
|
+
" - postgres-data:/var/lib/postgresql/data",
|
|
3448
|
+
" healthcheck:",
|
|
3449
|
+
` test: ["CMD-SHELL", "pg_isready -U postgres -d ${databaseName}"]`,
|
|
3450
|
+
" interval: 5s",
|
|
3451
|
+
" timeout: 5s",
|
|
3452
|
+
" retries: 10",
|
|
3453
|
+
" start_period: 10s"
|
|
3454
|
+
];
|
|
3455
|
+
}
|
|
3456
|
+
function buildPlausibleServices() {
|
|
3457
|
+
return [
|
|
3458
|
+
" plausible-db:",
|
|
3459
|
+
` image: ${POSTGRES_IMAGE}`,
|
|
3460
|
+
" environment:",
|
|
3461
|
+
" POSTGRES_USER: postgres",
|
|
3462
|
+
" POSTGRES_PASSWORD: postgres",
|
|
3463
|
+
" POSTGRES_DB: plausible",
|
|
3464
|
+
" volumes:",
|
|
3465
|
+
" - plausible-db-data:/var/lib/postgresql/data",
|
|
3466
|
+
" healthcheck:",
|
|
3467
|
+
' test: ["CMD-SHELL", "pg_isready -U postgres -d plausible"]',
|
|
3468
|
+
" interval: 5s",
|
|
3469
|
+
" timeout: 5s",
|
|
3470
|
+
" retries: 10",
|
|
3471
|
+
" start_period: 1m",
|
|
3472
|
+
"",
|
|
3473
|
+
" plausible-events-db:",
|
|
3474
|
+
` image: ${CLICKHOUSE_IMAGE}`,
|
|
3475
|
+
" environment:",
|
|
3476
|
+
' CLICKHOUSE_SKIP_USER_SETUP: "1"',
|
|
3477
|
+
" volumes:",
|
|
3478
|
+
" - plausible-event-data:/var/lib/clickhouse",
|
|
3479
|
+
" - plausible-event-logs:/var/log/clickhouse-server",
|
|
3480
|
+
" ulimits:",
|
|
3481
|
+
" nofile:",
|
|
3482
|
+
" soft: 262144",
|
|
3483
|
+
" hard: 262144",
|
|
3484
|
+
" healthcheck:",
|
|
3485
|
+
" test:",
|
|
3486
|
+
" [",
|
|
3487
|
+
' "CMD-SHELL",',
|
|
3488
|
+
' "wget --no-verbose --tries=1 -O - http://127.0.0.1:8123/ping || exit 1",',
|
|
3489
|
+
" ]",
|
|
3490
|
+
" interval: 5s",
|
|
3491
|
+
" timeout: 5s",
|
|
3492
|
+
" retries: 10",
|
|
3493
|
+
" start_period: 1m",
|
|
3494
|
+
"",
|
|
3495
|
+
" plausible:",
|
|
3496
|
+
` image: ${PLAUSIBLE_IMAGE}`,
|
|
3497
|
+
' command: sh -c "/entrypoint.sh db createdb && /entrypoint.sh db migrate && /entrypoint.sh run"',
|
|
3498
|
+
" depends_on:",
|
|
3499
|
+
" plausible-db:",
|
|
3500
|
+
" condition: service_healthy",
|
|
3501
|
+
" plausible-events-db:",
|
|
3502
|
+
" condition: service_healthy",
|
|
3503
|
+
" ports:",
|
|
3504
|
+
' - "8000:8000"',
|
|
3505
|
+
" volumes:",
|
|
3506
|
+
" - plausible-data:/var/lib/plausible",
|
|
3507
|
+
" ulimits:",
|
|
3508
|
+
" nofile:",
|
|
3509
|
+
" soft: 65535",
|
|
3510
|
+
" hard: 65535",
|
|
3511
|
+
" environment:",
|
|
3512
|
+
" TMPDIR: /var/lib/plausible/tmp",
|
|
3513
|
+
" BASE_URL: http://localhost:8000",
|
|
3514
|
+
` SECRET_KEY_BASE: ${PLAUSIBLE_LOCAL_SECRET_KEY_BASE}`,
|
|
3515
|
+
" DATABASE_URL: postgres://postgres:postgres@plausible-db:5432/plausible",
|
|
3516
|
+
" CLICKHOUSE_DATABASE_URL: http://plausible-events-db:8123/plausible_events_db",
|
|
3517
|
+
" healthcheck:",
|
|
3518
|
+
" test:",
|
|
3519
|
+
" [",
|
|
3520
|
+
' "CMD-SHELL",',
|
|
3521
|
+
' "wget --no-verbose --tries=1 -O - http://127.0.0.1:8000/api/system/health/ready || exit 1",',
|
|
3522
|
+
" ]",
|
|
3523
|
+
" interval: 10s",
|
|
3524
|
+
" timeout: 5s",
|
|
3525
|
+
" retries: 12",
|
|
3526
|
+
" start_period: 1m"
|
|
3527
|
+
];
|
|
3528
|
+
}
|
|
3529
|
+
function buildVolumes(config) {
|
|
3530
|
+
const lines = [];
|
|
3531
|
+
if (hasPrisma2(config)) {
|
|
3532
|
+
lines.push(" postgres-data:");
|
|
3533
|
+
}
|
|
3534
|
+
if (hasPlausible(config)) {
|
|
3535
|
+
lines.push(" plausible-db-data:");
|
|
3536
|
+
lines.push(" plausible-event-data:");
|
|
3537
|
+
lines.push(" plausible-event-logs:");
|
|
3538
|
+
lines.push(" plausible-data:");
|
|
3539
|
+
}
|
|
3540
|
+
return lines;
|
|
3541
|
+
}
|
|
3542
|
+
function buildDockerComposeYaml(config) {
|
|
3543
|
+
const appDependsOn = buildAppDependsOn(config);
|
|
3544
|
+
const services = [
|
|
3545
|
+
"services:",
|
|
3546
|
+
" app:",
|
|
3547
|
+
" build:",
|
|
3548
|
+
" context: .",
|
|
3549
|
+
" dockerfile: Dockerfile",
|
|
3550
|
+
" environment:",
|
|
3551
|
+
...buildAppEnvironment(config),
|
|
3552
|
+
" ports:",
|
|
3553
|
+
' - "3000:3000"',
|
|
3554
|
+
...appDependsOn.length > 0 ? [" depends_on:", ...appDependsOn] : [],
|
|
3555
|
+
...buildPostgresService(config),
|
|
3556
|
+
...hasPlausible(config) ? buildPlausibleServices() : []
|
|
3557
|
+
];
|
|
3558
|
+
const volumes = buildVolumes(config);
|
|
3559
|
+
return [
|
|
3560
|
+
...services,
|
|
3561
|
+
...volumes.length > 0 ? ["", "volumes:", ...volumes] : [],
|
|
3562
|
+
""
|
|
3563
|
+
].join("\n");
|
|
3564
|
+
}
|
|
3565
|
+
function buildPrismaRunnerAdditions(config) {
|
|
3566
|
+
if (!hasPrisma2(config)) return [];
|
|
3567
|
+
return [
|
|
3568
|
+
"RUN apt-get update \\",
|
|
3569
|
+
" && apt-get install -y --no-install-recommends openssl postgresql-client \\",
|
|
3570
|
+
" && rm -rf /var/lib/apt/lists/* \\",
|
|
3571
|
+
" && corepack enable pnpm",
|
|
3572
|
+
"",
|
|
3573
|
+
"COPY --from=deps --chown=node:node /app/node_modules ./node_modules",
|
|
3574
|
+
"COPY --from=builder --chown=node:node /app/package.json ./package.json",
|
|
3575
|
+
"COPY --from=builder --chown=node:node /app/pnpm-lock.yaml ./pnpm-lock.yaml",
|
|
3576
|
+
"COPY --from=builder --chown=node:node /app/prisma ./prisma",
|
|
3577
|
+
"COPY --from=builder --chown=node:node /app/prisma.config.ts ./prisma.config.ts",
|
|
3578
|
+
""
|
|
3579
|
+
];
|
|
3580
|
+
}
|
|
3581
|
+
function buildPrismaBuilderEnvironment(config) {
|
|
3582
|
+
if (!hasPrisma2(config)) return [];
|
|
3583
|
+
return [
|
|
3584
|
+
`ENV DATABASE_URL=${buildComposeDatabaseUrl(config.projectName)}`,
|
|
3585
|
+
""
|
|
3586
|
+
];
|
|
3587
|
+
}
|
|
3588
|
+
function buildDockerfile(config) {
|
|
3589
|
+
return [
|
|
3590
|
+
"# syntax=docker/dockerfile:1.7",
|
|
3591
|
+
`ARG NODE_VERSION=${DOCKER_NODE_VERSION}`,
|
|
3592
|
+
"",
|
|
3593
|
+
"FROM node:${NODE_VERSION} AS deps",
|
|
3594
|
+
"WORKDIR /app",
|
|
3595
|
+
"",
|
|
3596
|
+
"COPY package.json pnpm-lock.yaml* .npmrc* ./",
|
|
3597
|
+
"RUN --mount=type=cache,target=/root/.local/share/pnpm/store \\",
|
|
3598
|
+
" corepack enable pnpm \\",
|
|
3599
|
+
" && pnpm install --frozen-lockfile --dangerously-allow-all-builds",
|
|
3600
|
+
"",
|
|
3601
|
+
"FROM node:${NODE_VERSION} AS builder",
|
|
3602
|
+
"WORKDIR /app",
|
|
3603
|
+
"",
|
|
3604
|
+
"COPY --from=deps /app/node_modules ./node_modules",
|
|
3605
|
+
"COPY . .",
|
|
3606
|
+
"",
|
|
3607
|
+
"ENV NODE_ENV=production",
|
|
3608
|
+
"ENV NEXT_TELEMETRY_DISABLED=1",
|
|
3609
|
+
...buildPrismaBuilderEnvironment(config),
|
|
3610
|
+
"",
|
|
3611
|
+
"RUN corepack enable pnpm && pnpm build",
|
|
3612
|
+
"",
|
|
3613
|
+
"FROM node:${NODE_VERSION} AS runner",
|
|
3614
|
+
"WORKDIR /app",
|
|
3615
|
+
"",
|
|
3616
|
+
"ENV NODE_ENV=production",
|
|
3617
|
+
"ENV PORT=3000",
|
|
3618
|
+
'ENV HOSTNAME="0.0.0.0"',
|
|
3619
|
+
"ENV NEXT_TELEMETRY_DISABLED=1",
|
|
3620
|
+
"",
|
|
3621
|
+
...buildPrismaRunnerAdditions(config),
|
|
3622
|
+
"COPY --from=builder --chown=node:node /app/public ./public",
|
|
3623
|
+
"",
|
|
3624
|
+
"RUN mkdir .next && chown node:node .next",
|
|
3625
|
+
"",
|
|
3626
|
+
"COPY --from=builder --chown=node:node /app/.next/standalone ./",
|
|
3627
|
+
"COPY --from=builder --chown=node:node /app/.next/static ./.next/static",
|
|
3628
|
+
"COPY --chown=node:node start.sh ./start.sh",
|
|
3629
|
+
"RUN chmod +x ./start.sh",
|
|
3630
|
+
"",
|
|
3631
|
+
"USER node",
|
|
3632
|
+
"",
|
|
3633
|
+
"EXPOSE 3000",
|
|
3634
|
+
"",
|
|
3635
|
+
'CMD ["./start.sh"]',
|
|
3636
|
+
""
|
|
3637
|
+
].join("\n");
|
|
3638
|
+
}
|
|
3639
|
+
function buildStartSh(config) {
|
|
3640
|
+
const lines = [
|
|
3641
|
+
"#!/bin/sh",
|
|
3642
|
+
"set -eu",
|
|
3643
|
+
"",
|
|
3644
|
+
'if [ "${RUN_MIGRATIONS:-false}" = "true" ]; then'
|
|
3645
|
+
];
|
|
3646
|
+
if (hasPrisma2(config)) {
|
|
3647
|
+
lines.push(
|
|
3648
|
+
' until pg_isready -d "${DATABASE_URL}"; do',
|
|
3649
|
+
' echo "Waiting for database..."',
|
|
3650
|
+
" sleep 2",
|
|
3651
|
+
" done",
|
|
3652
|
+
"",
|
|
3653
|
+
" pnpm db:migrate:deploy"
|
|
3654
|
+
);
|
|
3655
|
+
} else {
|
|
3656
|
+
lines.push(
|
|
3657
|
+
' echo "RUN_MIGRATIONS=true was set, but no ORM is configured."'
|
|
3658
|
+
);
|
|
3659
|
+
}
|
|
3660
|
+
lines.push("", "fi", "", "exec node server.js", "");
|
|
3661
|
+
return lines.join("\n");
|
|
3662
|
+
}
|
|
3663
|
+
function buildDockerignore() {
|
|
3664
|
+
return [
|
|
3665
|
+
".git",
|
|
3666
|
+
".scale-stack",
|
|
3667
|
+
".mcp.json",
|
|
3668
|
+
".next",
|
|
3669
|
+
"node_modules",
|
|
3670
|
+
"coverage",
|
|
3671
|
+
"dist",
|
|
3672
|
+
".env*",
|
|
3673
|
+
"*.log",
|
|
3674
|
+
"npm-debug.log*",
|
|
3675
|
+
"yarn-debug.log*",
|
|
3676
|
+
"yarn-error.log*",
|
|
3677
|
+
".pnpm-store",
|
|
3678
|
+
".turbo",
|
|
3679
|
+
".DS_Store",
|
|
3680
|
+
""
|
|
3681
|
+
].join("\n");
|
|
3682
|
+
}
|
|
3683
|
+
var dockerGenerator = {
|
|
3684
|
+
name: "docker",
|
|
3685
|
+
dependencies: ["project-init"],
|
|
3686
|
+
resolveDependencies(config) {
|
|
3687
|
+
return hasPrisma2(config) ? ["orm"] : [];
|
|
3688
|
+
},
|
|
3689
|
+
async execute(ctx) {
|
|
3690
|
+
ctx.fs.write("Dockerfile", buildDockerfile(ctx.config));
|
|
3691
|
+
ctx.fs.write("start.sh", buildStartSh(ctx.config));
|
|
3692
|
+
ctx.fs.write("docker-compose.yml", buildDockerComposeYaml(ctx.config));
|
|
3693
|
+
ctx.fs.write(".dockerignore", buildDockerignore());
|
|
3694
|
+
}
|
|
3695
|
+
};
|
|
3696
|
+
|
|
3697
|
+
// src/generators/error-handling/error-handling.ts
|
|
3698
|
+
var ERROR_HANDLING_FILES = [
|
|
3699
|
+
{
|
|
3700
|
+
templatePath: "error-handling/error.tsx.ejs",
|
|
3701
|
+
outputPath: "src/app/error.tsx"
|
|
3702
|
+
},
|
|
3703
|
+
{
|
|
3704
|
+
templatePath: "error-handling/global-error.tsx.ejs",
|
|
3705
|
+
outputPath: "src/app/global-error.tsx"
|
|
3706
|
+
},
|
|
3707
|
+
{
|
|
3708
|
+
templatePath: "error-handling/not-found.tsx.ejs",
|
|
3709
|
+
outputPath: "src/app/not-found.tsx"
|
|
3710
|
+
},
|
|
3711
|
+
{
|
|
3712
|
+
templatePath: "error-handling/catch-all-not-found-page.tsx.ejs",
|
|
3713
|
+
outputPath: "src/app/[...not-found]/page.tsx"
|
|
3714
|
+
}
|
|
3715
|
+
];
|
|
3716
|
+
var errorHandlingGenerator = {
|
|
3717
|
+
name: "error-handling",
|
|
3718
|
+
dependencies: ["ui"],
|
|
3719
|
+
async execute(ctx) {
|
|
3720
|
+
for (const file of ERROR_HANDLING_FILES) {
|
|
3721
|
+
const content = await ctx.template.renderFile(file.templatePath, {
|
|
3722
|
+
i18n: ctx.config.i18n
|
|
3723
|
+
});
|
|
3724
|
+
const outputPath = file.outputPath === "src/app/global-error.tsx" || file.outputPath === "src/app/[...not-found]/page.tsx" ? file.outputPath : localizeAppRoutePath(file.outputPath, ctx.config.i18n);
|
|
3725
|
+
ctx.fs.write(outputPath, content);
|
|
3726
|
+
}
|
|
3727
|
+
}
|
|
3728
|
+
};
|
|
3729
|
+
|
|
3730
|
+
// src/generators/server-actions/server-actions.ts
|
|
3731
|
+
var PHASE_15_SERVER_ACTIONS_AGENT_SKILL_COMMAND = buildSkillsAddCommand("next-safe-action/skills", []);
|
|
3732
|
+
var SERVER_ACTIONS_FILES = [
|
|
3733
|
+
{
|
|
3734
|
+
templatePath: "server-actions/safe-action.ts.ejs",
|
|
3735
|
+
outputPath: "src/lib/safe-action.ts"
|
|
3736
|
+
}
|
|
3737
|
+
];
|
|
3738
|
+
function hasAuth2(strategy) {
|
|
3739
|
+
return strategy !== "none";
|
|
3740
|
+
}
|
|
3741
|
+
function buildDependencies(strategy) {
|
|
3742
|
+
const dependencies = structuredClone(PACKAGE_DEPS_BASELINE);
|
|
3743
|
+
if (hasAuth2(strategy)) {
|
|
3744
|
+
Object.assign(dependencies, SERVER_ACTIONS_AUTH_DEPENDENCIES);
|
|
3745
|
+
}
|
|
3746
|
+
return dependencies;
|
|
3747
|
+
}
|
|
3748
|
+
function mergePackageJson6(existing, strategy = "none") {
|
|
3749
|
+
const desiredFragment = {
|
|
3750
|
+
dependencies: buildDependencies(strategy)
|
|
3751
|
+
};
|
|
3752
|
+
return mergeManagedPackageJson({
|
|
3753
|
+
existingContent: existing,
|
|
3754
|
+
desiredFragment,
|
|
3755
|
+
sortSections: ["dependencies"]
|
|
3756
|
+
});
|
|
3757
|
+
}
|
|
3758
|
+
async function writeServerActionFiles(ctx) {
|
|
3759
|
+
const hasAuthClient = hasAuth2(ctx.config.authStrategy);
|
|
3760
|
+
for (const file of SERVER_ACTIONS_FILES) {
|
|
3761
|
+
const content = await ctx.template.renderFile(file.templatePath, {
|
|
3762
|
+
hasAuth: hasAuthClient
|
|
3763
|
+
});
|
|
3764
|
+
ctx.fs.write(file.outputPath, content);
|
|
3765
|
+
}
|
|
3766
|
+
}
|
|
3767
|
+
var serverActionsGenerator = {
|
|
3768
|
+
name: "server-actions",
|
|
3769
|
+
dependencies: ["ui", "env-management"],
|
|
3770
|
+
resolveDependencies: (config) => hasAuth2(config.authStrategy) ? ["auth"] : [],
|
|
3771
|
+
async execute(ctx) {
|
|
3772
|
+
ctx.fs.merge(
|
|
3773
|
+
"package.json",
|
|
3774
|
+
(existing) => mergePackageJson6(existing, ctx.config.authStrategy)
|
|
3775
|
+
);
|
|
3776
|
+
await writeServerActionFiles(ctx);
|
|
3777
|
+
await ctx.exec(PHASE_15_SERVER_ACTIONS_AGENT_SKILL_COMMAND, {
|
|
3778
|
+
cwd: ctx.targetDir,
|
|
3779
|
+
env: { ...AGENT_SKILLS_EXEC_ENV }
|
|
3780
|
+
});
|
|
3781
|
+
}
|
|
3782
|
+
};
|
|
3783
|
+
|
|
3784
|
+
// src/generators/form-handling/form-handling.ts
|
|
3785
|
+
var FORM_HANDLING_FILES = [
|
|
3786
|
+
{
|
|
3787
|
+
templatePath: "form-handling/example-form-schema.ts.ejs",
|
|
3788
|
+
outputPath: "src/app/dashboard/_lib/example-form-schema.ts"
|
|
3789
|
+
},
|
|
3790
|
+
{
|
|
3791
|
+
templatePath: "form-handling/example-form.tsx.ejs",
|
|
3792
|
+
outputPath: "src/app/dashboard/_components/ExampleForm.tsx"
|
|
3793
|
+
},
|
|
3794
|
+
{
|
|
3795
|
+
templatePath: "form-handling/example-form-action.ts.ejs",
|
|
3796
|
+
outputPath: "src/app/dashboard/_actions/example-form.ts"
|
|
3797
|
+
},
|
|
3798
|
+
{
|
|
3799
|
+
templatePath: "form-handling/dashboard-page.tsx.ejs",
|
|
3800
|
+
outputPath: "src/app/dashboard/page.tsx"
|
|
3801
|
+
}
|
|
3802
|
+
];
|
|
3803
|
+
function mergePackageJson7(existing) {
|
|
3804
|
+
const desiredFragment = {
|
|
3805
|
+
dependencies: structuredClone(PACKAGE_DEPS_BASELINE)
|
|
3806
|
+
};
|
|
3807
|
+
return mergeManagedPackageJson({
|
|
3808
|
+
existingContent: existing,
|
|
3809
|
+
desiredFragment,
|
|
3810
|
+
sortSections: ["dependencies"]
|
|
3811
|
+
});
|
|
3812
|
+
}
|
|
3813
|
+
async function writeFormHandlingFiles(ctx) {
|
|
3814
|
+
const hasAuthClient = hasAuth2(ctx.config.authStrategy);
|
|
3815
|
+
for (const file of FORM_HANDLING_FILES) {
|
|
3816
|
+
const content = await ctx.template.renderFile(file.templatePath, {
|
|
3817
|
+
hasAuth: hasAuthClient,
|
|
3818
|
+
i18n: ctx.config.i18n
|
|
3819
|
+
});
|
|
3820
|
+
ctx.fs.write(
|
|
3821
|
+
localizeAppRoutePath(file.outputPath, ctx.config.i18n),
|
|
3822
|
+
content
|
|
3823
|
+
);
|
|
3824
|
+
}
|
|
3825
|
+
}
|
|
3826
|
+
var formHandlingGenerator = {
|
|
3827
|
+
name: "form-handling",
|
|
3828
|
+
dependencies: ["ui", "server-actions"],
|
|
3829
|
+
async execute(ctx) {
|
|
3830
|
+
ctx.fs.merge("package.json", mergePackageJson7);
|
|
3831
|
+
await writeFormHandlingFiles(ctx);
|
|
3832
|
+
}
|
|
3833
|
+
};
|
|
3834
|
+
|
|
3835
|
+
// src/generators/i18n/i18n.ts
|
|
3836
|
+
var I18N_FILES = [
|
|
3837
|
+
{
|
|
3838
|
+
templatePath: "i18n/routing.ts.ejs",
|
|
3839
|
+
outputPath: "src/i18n/routing.ts"
|
|
3840
|
+
},
|
|
3841
|
+
{
|
|
3842
|
+
templatePath: "i18n/request.ts.ejs",
|
|
3843
|
+
outputPath: "src/i18n/request.ts"
|
|
3844
|
+
},
|
|
3845
|
+
{
|
|
3846
|
+
templatePath: "i18n/navigation.ts.ejs",
|
|
3847
|
+
outputPath: "src/i18n/navigation.ts"
|
|
3848
|
+
},
|
|
3849
|
+
{
|
|
3850
|
+
templatePath: "i18n/next-intl.d.ts.ejs",
|
|
3851
|
+
outputPath: "src/i18n/next-intl.d.ts"
|
|
3852
|
+
},
|
|
3853
|
+
{
|
|
3854
|
+
templatePath: "i18n/locale-layout.tsx.ejs",
|
|
3855
|
+
outputPath: "src/app/[locale]/layout.tsx"
|
|
3856
|
+
},
|
|
3857
|
+
{
|
|
3858
|
+
templatePath: "i18n/en.json.ejs",
|
|
3859
|
+
outputPath: "messages/en.json"
|
|
3860
|
+
},
|
|
3861
|
+
{
|
|
3862
|
+
templatePath: "i18n/ar.json.ejs",
|
|
3863
|
+
outputPath: "messages/ar.json"
|
|
3864
|
+
}
|
|
3865
|
+
];
|
|
3866
|
+
function mergePackageJson8(existing) {
|
|
3867
|
+
const desiredFragment = {
|
|
3868
|
+
dependencies: structuredClone(PACKAGE_DEPS_BASELINE)
|
|
3869
|
+
};
|
|
3870
|
+
return mergeManagedPackageJson({
|
|
3871
|
+
existingContent: existing,
|
|
3872
|
+
desiredFragment,
|
|
3873
|
+
sortSections: ["dependencies"]
|
|
3874
|
+
});
|
|
3875
|
+
}
|
|
3876
|
+
async function writeI18nFiles(ctx) {
|
|
3877
|
+
for (const file of I18N_FILES) {
|
|
3878
|
+
const content = await ctx.template.renderFile(file.templatePath, {
|
|
3879
|
+
projectName: ctx.config.projectName
|
|
3880
|
+
});
|
|
3881
|
+
ctx.fs.write(file.outputPath, content);
|
|
3882
|
+
}
|
|
3883
|
+
}
|
|
3884
|
+
var i18nGenerator = {
|
|
3885
|
+
name: "i18n",
|
|
3886
|
+
dependencies: ["ui", "error-handling", "form-handling"],
|
|
3887
|
+
resolveDependencies: (config) => [
|
|
3888
|
+
...config.authStrategy !== "none" ? ["auth"] : [],
|
|
3889
|
+
...config.aiChat ? ["ai-chat"] : []
|
|
3890
|
+
],
|
|
3891
|
+
condition: (config) => config.i18n === true,
|
|
3892
|
+
async execute(ctx) {
|
|
3893
|
+
ctx.fs.merge("package.json", mergePackageJson8);
|
|
3894
|
+
await writeI18nFiles(ctx);
|
|
3895
|
+
}
|
|
3896
|
+
};
|
|
3897
|
+
|
|
3898
|
+
// src/generators/pre-commit/pre-commit.ts
|
|
3899
|
+
var PHASE_19_GIT_INIT_COMMAND = "git init";
|
|
3900
|
+
function mergePackageJson9(existing, mode) {
|
|
3901
|
+
const desiredFragment = {
|
|
3902
|
+
scripts: buildPreCommitScripts(mode)
|
|
3903
|
+
};
|
|
3904
|
+
return mergeManagedPackageJson({
|
|
3905
|
+
existingContent: existing,
|
|
3906
|
+
desiredFragment,
|
|
3907
|
+
sortSections: []
|
|
3908
|
+
});
|
|
3909
|
+
}
|
|
3910
|
+
var preCommitGenerator = {
|
|
3911
|
+
name: "pre-commit",
|
|
3912
|
+
dependencies: ["eslint-prettier"],
|
|
3913
|
+
condition: (config) => config.preCommit,
|
|
3914
|
+
async execute(ctx) {
|
|
3915
|
+
const mode = inferPreCommitMode(ctx.targetDir);
|
|
3916
|
+
ctx.fs.write("prek.toml", await buildPrekTomlContents());
|
|
3917
|
+
ctx.fs.merge(
|
|
3918
|
+
"package.json",
|
|
3919
|
+
(existing) => mergePackageJson9(existing, mode)
|
|
3920
|
+
);
|
|
3921
|
+
if (mode === "standalone") {
|
|
3922
|
+
await ctx.exec(PHASE_19_GIT_INIT_COMMAND, { cwd: ctx.targetDir });
|
|
3923
|
+
}
|
|
3924
|
+
}
|
|
3925
|
+
};
|
|
3926
|
+
|
|
3927
|
+
// src/generators/proxy/proxy.ts
|
|
3928
|
+
var PROXY_OUTPUT_PATH = "src/proxy.ts";
|
|
3929
|
+
var proxyGenerator = {
|
|
3930
|
+
name: "proxy",
|
|
3931
|
+
dependencies: ["project-init"],
|
|
3932
|
+
resolveDependencies: (config) => [
|
|
3933
|
+
...config.authStrategy !== "none" ? ["auth"] : [],
|
|
3934
|
+
...config.i18n ? ["i18n"] : []
|
|
3935
|
+
],
|
|
3936
|
+
async execute(ctx) {
|
|
3937
|
+
const content = await ctx.template.renderFile("proxy/proxy.ts.ejs", {
|
|
3938
|
+
hasAuth: ctx.config.authStrategy !== "none",
|
|
3939
|
+
hasI18n: ctx.config.i18n
|
|
3940
|
+
});
|
|
3941
|
+
ctx.fs.write(PROXY_OUTPUT_PATH, content);
|
|
3942
|
+
}
|
|
3943
|
+
};
|
|
3944
|
+
|
|
3945
|
+
// src/generators/state/state.ts
|
|
3946
|
+
var PHASE_9_STATE_AGENT_SKILL_COMMANDS = [
|
|
3947
|
+
buildSkillsAddCommand("https://github.com/pproenca/dot-skills", ["nuqs"]),
|
|
3948
|
+
buildSkillsAddCommand("https://github.com/prowler-cloud/prowler", [
|
|
3949
|
+
"zustand-5"
|
|
3950
|
+
])
|
|
3951
|
+
];
|
|
3952
|
+
function mergePackageJson10(existing) {
|
|
3953
|
+
const desiredFragment = {
|
|
3954
|
+
dependencies: structuredClone(PACKAGE_DEPS_BASELINE)
|
|
3955
|
+
};
|
|
3956
|
+
return mergeManagedPackageJson({
|
|
3957
|
+
existingContent: existing,
|
|
3958
|
+
desiredFragment,
|
|
3959
|
+
sortSections: ["dependencies"]
|
|
3960
|
+
});
|
|
3961
|
+
}
|
|
3962
|
+
var stateGenerator = {
|
|
3963
|
+
name: "state",
|
|
3964
|
+
dependencies: ["ui"],
|
|
3965
|
+
async execute(ctx) {
|
|
3966
|
+
ctx.fs.merge("package.json", mergePackageJson10);
|
|
3967
|
+
for (const command of PHASE_9_STATE_AGENT_SKILL_COMMANDS) {
|
|
3968
|
+
await ctx.exec(command, {
|
|
3969
|
+
cwd: ctx.targetDir,
|
|
3970
|
+
env: { ...AGENT_SKILLS_EXEC_ENV }
|
|
3971
|
+
});
|
|
3972
|
+
}
|
|
3973
|
+
}
|
|
3974
|
+
};
|
|
3975
|
+
|
|
3976
|
+
// src/generators/ui/ui.ts
|
|
3977
|
+
var SHADCN_PRESET = "nova";
|
|
3978
|
+
var REACT_SUSPENSE_IMPORT = 'import { Suspense } from "react";';
|
|
3979
|
+
var CLIENT_SIDE_WRAPPERS_IMPORT = 'import { ClientSideWrappers } from "./_providers/client-side-wrappers";';
|
|
3980
|
+
var SONNER_OPTIONAL_THEME_CAST = 'theme={theme as ToasterProps["theme"]}';
|
|
3981
|
+
var SONNER_NON_NULLABLE_THEME_CAST = 'theme={theme as NonNullable<ToasterProps["theme"]>}';
|
|
3982
|
+
var SELF_REFERENTIAL_FONT_SANS = "--font-sans: var(--font-sans);";
|
|
3983
|
+
var GEIST_FONT_SANS = "--font-sans: var(--font-geist-sans);";
|
|
3984
|
+
var FONT_HEADING_ALIAS = "--font-heading: var(--font-sans);";
|
|
3985
|
+
var GEIST_FONT_HEADING = "--font-heading: var(--font-geist-sans);";
|
|
3986
|
+
function buildShadcnInitCommand() {
|
|
3987
|
+
return `pnpm dlx shadcn@latest init --template next --preset ${SHADCN_PRESET} --yes`;
|
|
3988
|
+
}
|
|
3989
|
+
function buildShadcnAddAllCommand() {
|
|
3990
|
+
return "pnpm dlx shadcn@latest add --all --yes";
|
|
3991
|
+
}
|
|
3992
|
+
function buildShadcnSkillAddCommand() {
|
|
3993
|
+
return "pnpm dlx skills add shadcn/ui -y";
|
|
3994
|
+
}
|
|
3995
|
+
function mergeRootLayoutWithClientSideWrappers(existing) {
|
|
3996
|
+
if (existing.includes("<ClientSideWrappers>")) return existing;
|
|
3997
|
+
const importMarker = 'import "./globals.css";\n';
|
|
3998
|
+
if (!existing.includes(importMarker)) {
|
|
3999
|
+
throw new Error("Could not find globals.css import in root layout.");
|
|
4000
|
+
}
|
|
4001
|
+
const childrenMarker = [
|
|
4002
|
+
" {/* Scale Stack: wrap with NuqsAdapter and other root providers under src/app/_providers/ */}",
|
|
4003
|
+
" {children}"
|
|
4004
|
+
].join("\n");
|
|
4005
|
+
if (!existing.includes(childrenMarker)) {
|
|
4006
|
+
throw new Error("Could not find root layout children slot.");
|
|
4007
|
+
}
|
|
4008
|
+
return existing.replace(
|
|
4009
|
+
importMarker,
|
|
4010
|
+
`${importMarker}${REACT_SUSPENSE_IMPORT}
|
|
4011
|
+
${CLIENT_SIDE_WRAPPERS_IMPORT}
|
|
4012
|
+
`
|
|
4013
|
+
).replace(
|
|
4014
|
+
childrenMarker,
|
|
4015
|
+
[
|
|
4016
|
+
" <Suspense>",
|
|
4017
|
+
" <ClientSideWrappers>{children}</ClientSideWrappers>",
|
|
4018
|
+
" </Suspense>"
|
|
4019
|
+
].join("\n")
|
|
4020
|
+
);
|
|
4021
|
+
}
|
|
4022
|
+
function fixSonnerExactOptionalTheme(existing) {
|
|
4023
|
+
if (existing.includes(SONNER_NON_NULLABLE_THEME_CAST)) return existing;
|
|
4024
|
+
return existing.replace(
|
|
4025
|
+
SONNER_OPTIONAL_THEME_CAST,
|
|
4026
|
+
SONNER_NON_NULLABLE_THEME_CAST
|
|
4027
|
+
);
|
|
4028
|
+
}
|
|
4029
|
+
function fixGeneratedFontVariables(existing) {
|
|
4030
|
+
return existing.replace(SELF_REFERENTIAL_FONT_SANS, GEIST_FONT_SANS).replace(FONT_HEADING_ALIAS, GEIST_FONT_HEADING);
|
|
4031
|
+
}
|
|
4032
|
+
async function writeUiFiles(ctx) {
|
|
4033
|
+
const [page, clientSideWrappers] = await Promise.all([
|
|
4034
|
+
ctx.template.renderFile("ui/page.tsx.ejs", {
|
|
4035
|
+
projectName: ctx.config.projectName,
|
|
4036
|
+
hasAuth: ctx.config.authStrategy !== "none",
|
|
4037
|
+
hasAiChat: ctx.config.aiChat,
|
|
4038
|
+
isStatefulAuth: ctx.config.authStrategy === "stateful",
|
|
4039
|
+
i18n: ctx.config.i18n
|
|
4040
|
+
}),
|
|
4041
|
+
ctx.template.renderFile("ui/client-side-wrappers.tsx.ejs", {
|
|
4042
|
+
hasAnalyticsProvider: ctx.config.analytics === "plausible"
|
|
4043
|
+
})
|
|
4044
|
+
]);
|
|
4045
|
+
ctx.fs.write(localizeAppRoutePath("src/app/page.tsx", ctx.config.i18n), page);
|
|
4046
|
+
ctx.fs.write(
|
|
4047
|
+
"src/app/_providers/client-side-wrappers.tsx",
|
|
4048
|
+
clientSideWrappers
|
|
4049
|
+
);
|
|
4050
|
+
}
|
|
4051
|
+
async function wireRootLayout(ctx) {
|
|
4052
|
+
ctx.fs.merge("src/app/layout.tsx", mergeRootLayoutWithClientSideWrappers);
|
|
4053
|
+
}
|
|
4054
|
+
async function patchGeneratedComponentFiles(ctx) {
|
|
4055
|
+
ctx.fs.merge("src/app/globals.css", fixGeneratedFontVariables);
|
|
4056
|
+
ctx.fs.merge("src/components/ui/sonner.tsx", fixSonnerExactOptionalTheme);
|
|
4057
|
+
}
|
|
4058
|
+
var uiGenerator = {
|
|
4059
|
+
name: "ui",
|
|
4060
|
+
dependencies: ["agent-skills-init"],
|
|
4061
|
+
async execute(ctx) {
|
|
4062
|
+
await ctx.exec(buildShadcnInitCommand(), { cwd: ctx.targetDir });
|
|
4063
|
+
await ctx.exec(buildShadcnAddAllCommand(), { cwd: ctx.targetDir });
|
|
4064
|
+
await ctx.exec(buildShadcnSkillAddCommand(), { cwd: ctx.targetDir });
|
|
4065
|
+
await patchGeneratedComponentFiles(ctx);
|
|
4066
|
+
await writeUiFiles(ctx);
|
|
4067
|
+
await wireRootLayout(ctx);
|
|
4068
|
+
}
|
|
4069
|
+
};
|
|
4070
|
+
|
|
4071
|
+
// src/generators/register.ts
|
|
4072
|
+
registerGenerator(projectInitGenerator);
|
|
4073
|
+
registerGenerator(proxyGenerator);
|
|
4074
|
+
registerGenerator(agentSkillsInitGenerator);
|
|
4075
|
+
registerGenerator(envManagementGenerator);
|
|
4076
|
+
registerGenerator(eslintPrettierGenerator);
|
|
4077
|
+
registerGenerator(uiGenerator);
|
|
4078
|
+
registerGenerator(stateGenerator);
|
|
4079
|
+
registerGenerator(errorHandlingGenerator);
|
|
4080
|
+
registerGenerator(ormGenerator);
|
|
4081
|
+
registerGenerator(authGenerator);
|
|
4082
|
+
registerGenerator(serverActionsGenerator);
|
|
4083
|
+
registerGenerator(formHandlingGenerator);
|
|
4084
|
+
registerGenerator(preCommitGenerator);
|
|
4085
|
+
registerGenerator(dockerGenerator);
|
|
4086
|
+
registerGenerator(ciGenerator);
|
|
4087
|
+
registerGenerator(analyticsGenerator);
|
|
4088
|
+
registerGenerator(aiChatGenerator);
|
|
4089
|
+
registerGenerator(i18nGenerator);
|
|
4090
|
+
registerGenerator(aiAssistantGenerator);
|
|
4091
|
+
|
|
4092
|
+
// src/cli/ui/banner.ts
|
|
4093
|
+
import gradient2 from "gradient-string";
|
|
4094
|
+
import pc7 from "picocolors";
|
|
4095
|
+
var LOGO = [
|
|
4096
|
+
" \xB0 ",
|
|
4097
|
+
"\u2554\u2550\u2557 \u2566\u2554\u2550 \u2554\u2550\u2557 \u2566 \u2554\u2550\u2557 \u2554\u2566\u2557 \u2554\u2550\u2557 \u2566\u2554\u2550 \u2566\u2554\u2550",
|
|
4098
|
+
"\u255A\u2550\u2557 \u2560\u2569\u2557 \u2560\u2550\u2563 \u2551 \u255A\u2550\u2557 \u2551 \u2560\u2550\u2563 \u2560\u2569\u2557 \u2560\u2569\u2557",
|
|
4099
|
+
"\u255A\u2550\u255D \u2569 \u2569 \u2569 \u2569 \u2569\u2550\u255D \u255A\u2550\u255D \u2569 \u2569 \u2569 \u2569 \u2569 \u2569 \u2569"
|
|
4100
|
+
];
|
|
4101
|
+
var TAGLINE = "Project foundation w/ smart-fittings";
|
|
4102
|
+
var INNER = 43;
|
|
4103
|
+
var CONTENT_W = INNER - 6;
|
|
4104
|
+
var BORDER_TOP = ` \u256D${"\u2500".repeat(INNER)}\u256E`;
|
|
4105
|
+
var BORDER_BOT = ` \u2570${"\u2500".repeat(INNER)}\u256F`;
|
|
4106
|
+
var EMPTY_ROW = ` \u2502${" ".repeat(INNER)}\u2502`;
|
|
4107
|
+
function boxRow(content, rawLen) {
|
|
4108
|
+
const pad = CONTENT_W - rawLen;
|
|
4109
|
+
return ` \u2502 ${content}${" ".repeat(Math.max(0, pad))} \u2502`;
|
|
4110
|
+
}
|
|
4111
|
+
function logoBoxLine(colored, rawLen) {
|
|
4112
|
+
return pc7.dim(" \u2502") + " " + colored + " ".repeat(Math.max(0, CONTENT_W - rawLen)) + pc7.dim(" \u2502");
|
|
4113
|
+
}
|
|
4114
|
+
var SWEEP_GRADIENTS = [
|
|
4115
|
+
gradient2(["#6B6B2E", "#2E4A6B"]),
|
|
4116
|
+
gradient2(["#B8A31A", "#1A6B9E"]),
|
|
4117
|
+
ikeaGradient
|
|
4118
|
+
];
|
|
4119
|
+
var PARTS = "\u2550\u2554\u2557\u2566\u2560\u2569\u2551\u255A\u255D\u2563\u256C\u2500\u2502\u250C\u2510\u2514\u2518\u252C\u2534\u251C\u2524\u253C";
|
|
4120
|
+
function scrambleLine(line, resolvedUpTo) {
|
|
4121
|
+
return Array.from(line).map((ch, i) => {
|
|
4122
|
+
if (i < resolvedUpTo) return ch;
|
|
4123
|
+
if (ch === " ") return " ";
|
|
4124
|
+
return PARTS[Math.floor(Math.random() * PARTS.length)];
|
|
4125
|
+
}).join("");
|
|
4126
|
+
}
|
|
4127
|
+
function progressBar(filled, total, width = 8) {
|
|
4128
|
+
const n = Math.round(filled / total * width);
|
|
4129
|
+
return "\u2588".repeat(n) + "\u2591".repeat(width - n);
|
|
4130
|
+
}
|
|
4131
|
+
var BOX_CONTENT_LINES = 8;
|
|
4132
|
+
async function animateBanner() {
|
|
4133
|
+
const logoText = LOGO.join("\n");
|
|
4134
|
+
const charCount = LOGO[0].length;
|
|
4135
|
+
const FRAMES = 10;
|
|
4136
|
+
const RATTLE = 2;
|
|
4137
|
+
console.log();
|
|
4138
|
+
console.log(pc7.dim(BORDER_TOP));
|
|
4139
|
+
console.log(pc7.dim(EMPTY_ROW));
|
|
4140
|
+
for (let f = 0; f <= FRAMES; f++) {
|
|
4141
|
+
if (f > 0) cursorUp(BOX_CONTENT_LINES);
|
|
4142
|
+
const progress = f <= RATTLE ? 0 : (f - RATTLE) / (FRAMES - RATTLE);
|
|
4143
|
+
const resolved = Math.floor(progress * charCount);
|
|
4144
|
+
const done = resolved >= charCount;
|
|
4145
|
+
for (const line of LOGO) {
|
|
4146
|
+
const text2 = done ? line : scrambleLine(line, resolved);
|
|
4147
|
+
console.log(logoBoxLine(pc7.dim(text2), line.length));
|
|
4148
|
+
}
|
|
4149
|
+
console.log(pc7.dim(EMPTY_ROW));
|
|
4150
|
+
if (done) {
|
|
409
4151
|
console.log(boxRow(ikea.heading(TAGLINE), TAGLINE.length));
|
|
410
4152
|
} else {
|
|
411
4153
|
const bar = progressBar(Math.max(0, f - RATTLE), FRAMES - RATTLE);
|
|
412
4154
|
const plain = `\u25B8 assembling ${bar}`;
|
|
413
4155
|
console.log(
|
|
414
|
-
boxRow(`${ikea.bullet} assembling ${
|
|
4156
|
+
boxRow(`${ikea.bullet} assembling ${pc7.dim(bar)}`, plain.length)
|
|
415
4157
|
);
|
|
416
4158
|
}
|
|
417
|
-
console.log(
|
|
418
|
-
console.log(
|
|
4159
|
+
console.log(pc7.dim(EMPTY_ROW));
|
|
4160
|
+
console.log(pc7.dim(BORDER_BOT));
|
|
419
4161
|
await sleep(f <= RATTLE ? 120 : done ? 200 : 60);
|
|
420
4162
|
}
|
|
421
4163
|
for (const grad of SWEEP_GRADIENTS) {
|
|
@@ -424,10 +4166,10 @@ async function animateBanner() {
|
|
|
424
4166
|
for (let i = 0; i < colored.length; i++) {
|
|
425
4167
|
console.log(logoBoxLine(colored[i], LOGO[i].length));
|
|
426
4168
|
}
|
|
427
|
-
console.log(
|
|
4169
|
+
console.log(pc7.dim(EMPTY_ROW));
|
|
428
4170
|
console.log(boxRow(ikea.heading(TAGLINE), TAGLINE.length));
|
|
429
|
-
console.log(
|
|
430
|
-
console.log(
|
|
4171
|
+
console.log(pc7.dim(EMPTY_ROW));
|
|
4172
|
+
console.log(pc7.dim(BORDER_BOT));
|
|
431
4173
|
await sleep(70);
|
|
432
4174
|
}
|
|
433
4175
|
console.log();
|
|
@@ -439,7 +4181,7 @@ async function animateDescription() {
|
|
|
439
4181
|
];
|
|
440
4182
|
for (const line of lines) {
|
|
441
4183
|
process.stdout.write(" ");
|
|
442
|
-
await typewriter(
|
|
4184
|
+
await typewriter(pc7.dim(line), 6);
|
|
443
4185
|
console.log();
|
|
444
4186
|
}
|
|
445
4187
|
console.log();
|
|
@@ -447,15 +4189,15 @@ async function animateDescription() {
|
|
|
447
4189
|
function printBanner() {
|
|
448
4190
|
const coloredLines = ikeaGradient.multiline(LOGO.join("\n")).split("\n");
|
|
449
4191
|
console.log();
|
|
450
|
-
console.log(
|
|
451
|
-
console.log(
|
|
4192
|
+
console.log(pc7.dim(BORDER_TOP));
|
|
4193
|
+
console.log(pc7.dim(EMPTY_ROW));
|
|
452
4194
|
for (let i = 0; i < coloredLines.length; i++) {
|
|
453
4195
|
console.log(logoBoxLine(coloredLines[i], LOGO[i].length));
|
|
454
4196
|
}
|
|
455
|
-
console.log(
|
|
4197
|
+
console.log(pc7.dim(EMPTY_ROW));
|
|
456
4198
|
console.log(boxRow(ikea.heading(TAGLINE), TAGLINE.length));
|
|
457
|
-
console.log(
|
|
458
|
-
console.log(
|
|
4199
|
+
console.log(pc7.dim(EMPTY_ROW));
|
|
4200
|
+
console.log(pc7.dim(BORDER_BOT));
|
|
459
4201
|
console.log();
|
|
460
4202
|
}
|
|
461
4203
|
function printDescription() {
|
|
@@ -468,15 +4210,7 @@ function printDescription() {
|
|
|
468
4210
|
}
|
|
469
4211
|
|
|
470
4212
|
// src/cli/index.ts
|
|
471
|
-
var
|
|
472
|
-
var pkgVersion = "0.0.0";
|
|
473
|
-
try {
|
|
474
|
-
const pkg = JSON.parse(
|
|
475
|
-
readFileSync(resolve(__dirname, "../package.json"), "utf-8")
|
|
476
|
-
);
|
|
477
|
-
pkgVersion = pkg.version ?? pkgVersion;
|
|
478
|
-
} catch {
|
|
479
|
-
}
|
|
4213
|
+
var pkgVersion = getCliVersion();
|
|
480
4214
|
var argv = process.argv.slice(2);
|
|
481
4215
|
var wantsHelp = argv.includes("--help") || argv.includes("-h") || argv.includes("--version") || argv.includes("-V");
|
|
482
4216
|
if (process.stdout.isTTY && !wantsHelp) {
|