scaffoldry 1.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/dist/bin.d.ts +1 -0
- package/dist/bin.js +198 -0
- package/dist/bin.js.map +1 -0
- package/dist/chunk-AA2UXYNR.js +1952 -0
- package/dist/chunk-AA2UXYNR.js.map +1 -0
- package/dist/chunk-WOS3F5LR.js +250 -0
- package/dist/chunk-WOS3F5LR.js.map +1 -0
- package/dist/chunk-XIP7YNKZ.js +1216 -0
- package/dist/chunk-XIP7YNKZ.js.map +1 -0
- package/dist/index.d.ts +57 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/platform-Z35MB2P5.js +41 -0
- package/dist/platform-Z35MB2P5.js.map +1 -0
- package/dist/setup-L2PO5OVZ.js +18 -0
- package/dist/setup-L2PO5OVZ.js.map +1 -0
- package/package.json +70 -0
|
@@ -0,0 +1,1952 @@
|
|
|
1
|
+
import {
|
|
2
|
+
checkEnvVar,
|
|
3
|
+
checkNodeVersion,
|
|
4
|
+
checkPnpmVersion,
|
|
5
|
+
checkStripeCli,
|
|
6
|
+
clearLicense,
|
|
7
|
+
createTemplateContext,
|
|
8
|
+
getStoredLicense,
|
|
9
|
+
isValidLicenseKeyFormat,
|
|
10
|
+
logger,
|
|
11
|
+
printBox,
|
|
12
|
+
printError,
|
|
13
|
+
printInfo,
|
|
14
|
+
printSuccess,
|
|
15
|
+
printWarning,
|
|
16
|
+
readEnvLocal,
|
|
17
|
+
runConfigurationChecks,
|
|
18
|
+
runEnvironmentChecks,
|
|
19
|
+
runServiceChecks,
|
|
20
|
+
saveProjectData,
|
|
21
|
+
storeLicense,
|
|
22
|
+
toKebabCase,
|
|
23
|
+
toPascalCase,
|
|
24
|
+
validateLicense,
|
|
25
|
+
writeEnvLocal
|
|
26
|
+
} from "./chunk-XIP7YNKZ.js";
|
|
27
|
+
import {
|
|
28
|
+
copyToClipboard,
|
|
29
|
+
getKillSignal,
|
|
30
|
+
getPlatformDisplayName,
|
|
31
|
+
getSpawnOptions,
|
|
32
|
+
getTermSignal,
|
|
33
|
+
setupShutdownHandlers
|
|
34
|
+
} from "./chunk-WOS3F5LR.js";
|
|
35
|
+
|
|
36
|
+
// src/templates/index.ts
|
|
37
|
+
function generateProjectFiles(context) {
|
|
38
|
+
const files = [];
|
|
39
|
+
files.push(generatePackageJson(context));
|
|
40
|
+
files.push(generateTsConfig(context));
|
|
41
|
+
files.push(generateEnvExample(context));
|
|
42
|
+
files.push(generateEnvLocal(context));
|
|
43
|
+
files.push(generateMainEntry(context));
|
|
44
|
+
files.push(generatePlatformConfig(context));
|
|
45
|
+
files.push(generateGitignore());
|
|
46
|
+
return files;
|
|
47
|
+
}
|
|
48
|
+
function generatePackageJson(context) {
|
|
49
|
+
const kebabName = toKebabCase(context.projectName);
|
|
50
|
+
const dependencies = {
|
|
51
|
+
"scaffoldry-platform": "^1.0.0",
|
|
52
|
+
"scaffoldry-db": "^1.0.0",
|
|
53
|
+
"scaffoldry-core": "^1.0.0",
|
|
54
|
+
"dotenv": "^16.5.0"
|
|
55
|
+
};
|
|
56
|
+
if (context.hasAuth) {
|
|
57
|
+
dependencies["scaffoldry-auth"] = "^1.0.0";
|
|
58
|
+
}
|
|
59
|
+
if (context.hasBilling) {
|
|
60
|
+
dependencies["scaffoldry-billing"] = "^1.0.0";
|
|
61
|
+
}
|
|
62
|
+
if (context.hasEmail) {
|
|
63
|
+
dependencies["scaffoldry-notify"] = "^1.0.0";
|
|
64
|
+
}
|
|
65
|
+
if (context.hasStorage) {
|
|
66
|
+
dependencies["scaffoldry-storage"] = "^1.0.0";
|
|
67
|
+
}
|
|
68
|
+
if (context.hasJobs) {
|
|
69
|
+
dependencies["scaffoldry-jobs"] = "^1.0.0";
|
|
70
|
+
}
|
|
71
|
+
if (context.hasWebhooks) {
|
|
72
|
+
dependencies["scaffoldry-webhooks"] = "^1.0.0";
|
|
73
|
+
}
|
|
74
|
+
const pkg = {
|
|
75
|
+
name: kebabName,
|
|
76
|
+
version: "0.1.0",
|
|
77
|
+
description: context.projectDescription,
|
|
78
|
+
type: "module",
|
|
79
|
+
scripts: {
|
|
80
|
+
dev: "tsx watch src/index.ts",
|
|
81
|
+
build: "tsc",
|
|
82
|
+
start: "node dist/index.js",
|
|
83
|
+
typecheck: "tsc --noEmit"
|
|
84
|
+
},
|
|
85
|
+
dependencies,
|
|
86
|
+
devDependencies: {
|
|
87
|
+
"@types/node": "^20.17.57",
|
|
88
|
+
tsx: "^4.20.3",
|
|
89
|
+
typescript: "^5.8.3"
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
return {
|
|
93
|
+
path: "package.json",
|
|
94
|
+
content: JSON.stringify(pkg, null, 2) + "\n"
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function generateTsConfig(_context) {
|
|
98
|
+
const config = {
|
|
99
|
+
compilerOptions: {
|
|
100
|
+
target: "ES2022",
|
|
101
|
+
module: "ESNext",
|
|
102
|
+
moduleResolution: "bundler",
|
|
103
|
+
esModuleInterop: true,
|
|
104
|
+
strict: true,
|
|
105
|
+
skipLibCheck: true,
|
|
106
|
+
outDir: "./dist",
|
|
107
|
+
rootDir: "./src",
|
|
108
|
+
declaration: true
|
|
109
|
+
},
|
|
110
|
+
include: ["src/**/*"],
|
|
111
|
+
exclude: ["node_modules", "dist"]
|
|
112
|
+
};
|
|
113
|
+
return {
|
|
114
|
+
path: "tsconfig.json",
|
|
115
|
+
content: JSON.stringify(config, null, 2) + "\n"
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function generateEnvExample(context) {
|
|
119
|
+
const lines = [
|
|
120
|
+
"# Database",
|
|
121
|
+
"DATABASE_URL=postgresql://localhost:5432/my_app",
|
|
122
|
+
""
|
|
123
|
+
];
|
|
124
|
+
if (context.hasBilling) {
|
|
125
|
+
lines.push(
|
|
126
|
+
"# Stripe",
|
|
127
|
+
"STRIPE_SECRET_KEY=sk_test_...",
|
|
128
|
+
"STRIPE_WEBHOOK_SECRET=whsec_...",
|
|
129
|
+
"STRIPE_PRICE_ID_STARTER=price_...",
|
|
130
|
+
"STRIPE_PRICE_ID_PRO=price_...",
|
|
131
|
+
""
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
if (context.hasEmail) {
|
|
135
|
+
lines.push(
|
|
136
|
+
"# Resend",
|
|
137
|
+
"RESEND_API_KEY=re_...",
|
|
138
|
+
"RESEND_FROM_EMAIL=noreply@example.com",
|
|
139
|
+
""
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
if (context.hasStorage) {
|
|
143
|
+
lines.push(
|
|
144
|
+
"# S3",
|
|
145
|
+
"S3_BUCKET=my-bucket",
|
|
146
|
+
"S3_REGION=us-east-1",
|
|
147
|
+
"S3_ACCESS_KEY_ID=AKIA...",
|
|
148
|
+
"S3_SECRET_ACCESS_KEY=...",
|
|
149
|
+
""
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
path: ".env.example",
|
|
154
|
+
content: lines.join("\n")
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function generateEnvLocal(_context) {
|
|
158
|
+
return {
|
|
159
|
+
path: ".env.local",
|
|
160
|
+
content: "# Local environment overrides\n"
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
function generateMainEntry(context) {
|
|
164
|
+
const imports = ['import "dotenv/config";', 'import { createPlatform } from "./platform.js";'];
|
|
165
|
+
const content = `${imports.join("\n")}
|
|
166
|
+
|
|
167
|
+
async function main() {
|
|
168
|
+
const platform = createPlatform();
|
|
169
|
+
|
|
170
|
+
console.log("Platform initialized successfully!");
|
|
171
|
+
console.log("Available services:");
|
|
172
|
+
console.log(" - Database:", !!platform.db);
|
|
173
|
+
console.log(" - Logger:", !!platform.logger);
|
|
174
|
+
console.log(" - Audit:", !!platform.audit);
|
|
175
|
+
${context.hasAuth ? ' console.log(" - Auth:", !!platform.auth);' : ""}
|
|
176
|
+
${context.hasBilling ? ' console.log(" - Billing:", !!platform.billing);' : ""}
|
|
177
|
+
${context.hasEmail ? ' console.log(" - Email:", !!platform.email);' : ""}
|
|
178
|
+
${context.hasStorage ? ' console.log(" - Storage:", !!platform.storage);' : ""}
|
|
179
|
+
${context.hasJobs ? ' console.log(" - Jobs:", !!platform.jobs);' : ""}
|
|
180
|
+
${context.hasWebhooks ? ' console.log(" - Webhook Inbox:", !!platform.webhookInbox);' : ""}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
main().catch(console.error);
|
|
184
|
+
`;
|
|
185
|
+
return {
|
|
186
|
+
path: "src/index.ts",
|
|
187
|
+
content
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
function generatePlatformConfig(context) {
|
|
191
|
+
const content = `import { createPlatform as createScaffoldryPlatform } from "@scaffoldry/platform";
|
|
192
|
+
|
|
193
|
+
export function createPlatform() {
|
|
194
|
+
return createScaffoldryPlatform({
|
|
195
|
+
database: {
|
|
196
|
+
url: process.env.DATABASE_URL!,
|
|
197
|
+
},
|
|
198
|
+
${context.hasBilling ? ` stripe: {
|
|
199
|
+
secretKey: process.env.STRIPE_SECRET_KEY!,
|
|
200
|
+
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
|
|
201
|
+
priceIdStarter: process.env.STRIPE_PRICE_ID_STARTER!,
|
|
202
|
+
priceIdPro: process.env.STRIPE_PRICE_ID_PRO!,
|
|
203
|
+
},` : ""}
|
|
204
|
+
${context.hasEmail ? ` resend: {
|
|
205
|
+
apiKey: process.env.RESEND_API_KEY!,
|
|
206
|
+
fromEmail: process.env.RESEND_FROM_EMAIL!,
|
|
207
|
+
},` : ""}
|
|
208
|
+
${context.hasStorage ? ` s3: {
|
|
209
|
+
bucket: process.env.S3_BUCKET!,
|
|
210
|
+
region: process.env.S3_REGION!,
|
|
211
|
+
accessKeyId: process.env.S3_ACCESS_KEY_ID!,
|
|
212
|
+
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
|
|
213
|
+
},` : ""}
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export type Platform = ReturnType<typeof createPlatform>;
|
|
218
|
+
`;
|
|
219
|
+
return {
|
|
220
|
+
path: "src/platform.ts",
|
|
221
|
+
content
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
function generateGitignore() {
|
|
225
|
+
return {
|
|
226
|
+
path: ".gitignore",
|
|
227
|
+
content: `# Dependencies
|
|
228
|
+
node_modules/
|
|
229
|
+
|
|
230
|
+
# Build output
|
|
231
|
+
dist/
|
|
232
|
+
|
|
233
|
+
# Environment files
|
|
234
|
+
.env
|
|
235
|
+
.env.local
|
|
236
|
+
.env.*.local
|
|
237
|
+
|
|
238
|
+
# IDE
|
|
239
|
+
.vscode/
|
|
240
|
+
.idea/
|
|
241
|
+
|
|
242
|
+
# OS
|
|
243
|
+
.DS_Store
|
|
244
|
+
Thumbs.db
|
|
245
|
+
|
|
246
|
+
# Logs
|
|
247
|
+
*.log
|
|
248
|
+
npm-debug.log*
|
|
249
|
+
|
|
250
|
+
# Test coverage
|
|
251
|
+
coverage/
|
|
252
|
+
`
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// src/commands/init.ts
|
|
257
|
+
import path from "path";
|
|
258
|
+
import fs from "fs-extra";
|
|
259
|
+
import prompts from "prompts";
|
|
260
|
+
import ora from "ora";
|
|
261
|
+
async function loadConfigFile(configPath) {
|
|
262
|
+
const absolutePath = path.resolve(process.cwd(), configPath);
|
|
263
|
+
if (!await fs.pathExists(absolutePath)) {
|
|
264
|
+
throw new Error(`Config file not found: ${absolutePath}`);
|
|
265
|
+
}
|
|
266
|
+
const content = await fs.readFile(absolutePath, "utf-8");
|
|
267
|
+
const config = JSON.parse(content);
|
|
268
|
+
if (!config.name || typeof config.name !== "string") {
|
|
269
|
+
throw new Error("Config file must contain a 'name' field");
|
|
270
|
+
}
|
|
271
|
+
return config;
|
|
272
|
+
}
|
|
273
|
+
async function initCommand(targetDir, options = {}) {
|
|
274
|
+
const { configFile, skipPrompts } = options;
|
|
275
|
+
let fileConfig = null;
|
|
276
|
+
if (configFile) {
|
|
277
|
+
try {
|
|
278
|
+
fileConfig = await loadConfigFile(configFile);
|
|
279
|
+
logger.log(`Using config file: ${configFile}`);
|
|
280
|
+
logger.newLine();
|
|
281
|
+
} catch (error) {
|
|
282
|
+
logger.error(error instanceof Error ? error.message : "Failed to load config file");
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (!skipPrompts) {
|
|
287
|
+
logger.newLine();
|
|
288
|
+
printBox("Welcome to Scaffoldry!", [
|
|
289
|
+
"Let's build your SaaS in minutes.",
|
|
290
|
+
"",
|
|
291
|
+
"You're getting our production-ready stack:",
|
|
292
|
+
"\u2022 Neon (serverless PostgreSQL) + Drizzle ORM",
|
|
293
|
+
"\u2022 Password + Magic Link authentication",
|
|
294
|
+
"\u2022 Stripe billing",
|
|
295
|
+
"\u2022 Resend transactional email",
|
|
296
|
+
"\u2022 AWS S3 file storage"
|
|
297
|
+
]);
|
|
298
|
+
logger.newLine();
|
|
299
|
+
}
|
|
300
|
+
const license = await ensureLicense(fileConfig?.licenseKey, skipPrompts);
|
|
301
|
+
if (!license) {
|
|
302
|
+
logger.error("A valid license is required to use Scaffoldry.");
|
|
303
|
+
logger.log("Purchase a license at: https://scaffoldry.com");
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
let config;
|
|
307
|
+
if (fileConfig) {
|
|
308
|
+
config = {
|
|
309
|
+
name: fileConfig.name,
|
|
310
|
+
description: fileConfig.description || "A SaaS application built with Scaffoldry",
|
|
311
|
+
features: ["auth", "billing", "email", "storage", "jobs", "webhooks", "admin"],
|
|
312
|
+
database: "neon",
|
|
313
|
+
packageManager: "pnpm"
|
|
314
|
+
};
|
|
315
|
+
logger.log(`Project name: ${config.name}`);
|
|
316
|
+
logger.log(`Description: ${config.description}`);
|
|
317
|
+
logger.newLine();
|
|
318
|
+
} else {
|
|
319
|
+
config = await promptForConfig();
|
|
320
|
+
}
|
|
321
|
+
if (!config) {
|
|
322
|
+
logger.error("Project setup cancelled.");
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const projectDir = targetDir ? path.resolve(process.cwd(), targetDir) : path.resolve(process.cwd(), toKebabCase(config.name));
|
|
326
|
+
if (await fs.pathExists(projectDir)) {
|
|
327
|
+
if (skipPrompts) {
|
|
328
|
+
logger.log(`Removing existing directory: ${projectDir}`);
|
|
329
|
+
await fs.remove(projectDir);
|
|
330
|
+
} else {
|
|
331
|
+
const { overwrite } = await prompts({
|
|
332
|
+
type: "confirm",
|
|
333
|
+
name: "overwrite",
|
|
334
|
+
message: `Directory ${projectDir} already exists. Overwrite?`,
|
|
335
|
+
initial: false
|
|
336
|
+
});
|
|
337
|
+
if (!overwrite) {
|
|
338
|
+
logger.error("Project setup cancelled.");
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
await fs.remove(projectDir);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
const spinner = ora("Creating project files...").start();
|
|
345
|
+
try {
|
|
346
|
+
const context = createTemplateContext(config);
|
|
347
|
+
const files = generateProjectFiles(context);
|
|
348
|
+
for (const file of files) {
|
|
349
|
+
const filePath = path.join(projectDir, file.path);
|
|
350
|
+
await fs.ensureDir(path.dirname(filePath));
|
|
351
|
+
await fs.writeFile(filePath, file.content);
|
|
352
|
+
}
|
|
353
|
+
if (fileConfig?.env && Object.keys(fileConfig.env).length > 0) {
|
|
354
|
+
const envPath = path.join(projectDir, ".env.local");
|
|
355
|
+
const envContent = Object.entries(fileConfig.env).map(([key, value]) => `${key}=${value}`).join("\n");
|
|
356
|
+
await fs.writeFile(envPath, envContent + "\n");
|
|
357
|
+
}
|
|
358
|
+
spinner.succeed("Project files created!");
|
|
359
|
+
logger.newLine();
|
|
360
|
+
logger.success(`Project "${config.name}" created successfully!`);
|
|
361
|
+
let shouldRunSetup = false;
|
|
362
|
+
if (skipPrompts) {
|
|
363
|
+
shouldRunSetup = fileConfig?.runSetup ?? false;
|
|
364
|
+
if (!shouldRunSetup) {
|
|
365
|
+
logger.newLine();
|
|
366
|
+
logger.log("To configure your services:");
|
|
367
|
+
logger.log(` cd ${toKebabCase(config.name)}`);
|
|
368
|
+
logger.log(" pnpm install");
|
|
369
|
+
logger.log(" scaffoldry setup all");
|
|
370
|
+
}
|
|
371
|
+
} else {
|
|
372
|
+
logger.newLine();
|
|
373
|
+
printBox("Next: Configure Your Services", [
|
|
374
|
+
"Your project needs API keys for:",
|
|
375
|
+
"\u2022 Neon Database (required)",
|
|
376
|
+
"\u2022 Stripe Billing (required for billing)",
|
|
377
|
+
"\u2022 Resend Email (required for auth emails)",
|
|
378
|
+
"\u2022 AWS S3 Storage (optional)"
|
|
379
|
+
]);
|
|
380
|
+
logger.newLine();
|
|
381
|
+
const response = await prompts({
|
|
382
|
+
type: "confirm",
|
|
383
|
+
name: "runSetup",
|
|
384
|
+
message: "Run setup wizard now?",
|
|
385
|
+
initial: true
|
|
386
|
+
});
|
|
387
|
+
shouldRunSetup = response.runSetup;
|
|
388
|
+
if (!shouldRunSetup) {
|
|
389
|
+
logger.newLine();
|
|
390
|
+
logger.log("To configure your services later:");
|
|
391
|
+
logger.log(` cd ${toKebabCase(config.name)}`);
|
|
392
|
+
logger.log(" pnpm install");
|
|
393
|
+
logger.log(" scaffoldry setup all");
|
|
394
|
+
logger.newLine();
|
|
395
|
+
logger.log("Or configure individually:");
|
|
396
|
+
logger.log(" scaffoldry setup database");
|
|
397
|
+
logger.log(" scaffoldry setup stripe");
|
|
398
|
+
logger.log(" scaffoldry setup email");
|
|
399
|
+
logger.log(" scaffoldry setup storage");
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (shouldRunSetup) {
|
|
403
|
+
process.chdir(projectDir);
|
|
404
|
+
const { setupAllCommand: setupAllCommand2 } = await import("./setup-L2PO5OVZ.js");
|
|
405
|
+
await setupAllCommand2();
|
|
406
|
+
}
|
|
407
|
+
logger.newLine();
|
|
408
|
+
} catch (error) {
|
|
409
|
+
spinner.fail("Failed to create project files.");
|
|
410
|
+
throw error;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
async function promptForConfig() {
|
|
414
|
+
const response = await prompts([
|
|
415
|
+
{
|
|
416
|
+
type: "text",
|
|
417
|
+
name: "name",
|
|
418
|
+
message: "Project name:",
|
|
419
|
+
initial: "my-saas-app",
|
|
420
|
+
validate: (value) => value.length > 0 ? true : "Project name is required"
|
|
421
|
+
},
|
|
422
|
+
{
|
|
423
|
+
type: "text",
|
|
424
|
+
name: "description",
|
|
425
|
+
message: "What are you building? (one sentence)",
|
|
426
|
+
initial: "A SaaS application built with Scaffoldry"
|
|
427
|
+
}
|
|
428
|
+
]);
|
|
429
|
+
if (!response.name) {
|
|
430
|
+
return null;
|
|
431
|
+
}
|
|
432
|
+
return {
|
|
433
|
+
name: response.name,
|
|
434
|
+
description: response.description,
|
|
435
|
+
// All features enabled by default in v1
|
|
436
|
+
features: ["auth", "billing", "email", "storage", "jobs", "webhooks", "admin"],
|
|
437
|
+
database: "neon",
|
|
438
|
+
// Neon is the v1 choice
|
|
439
|
+
packageManager: "pnpm"
|
|
440
|
+
// pnpm is the v1 choice
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
async function ensureLicense(providedKey, skipPrompts) {
|
|
444
|
+
if (providedKey) {
|
|
445
|
+
const normalizedKey = providedKey.trim().toUpperCase();
|
|
446
|
+
if (!isValidLicenseKeyFormat(normalizedKey)) {
|
|
447
|
+
logger.error("Invalid license key format in config. Expected: SCAF-XXXX-XXXX-XXXX-XXXX");
|
|
448
|
+
return null;
|
|
449
|
+
}
|
|
450
|
+
const spinner2 = ora("Validating license...").start();
|
|
451
|
+
const validation2 = await validateLicense(normalizedKey);
|
|
452
|
+
if (!validation2.valid) {
|
|
453
|
+
spinner2.fail(validation2.error || "License validation failed");
|
|
454
|
+
return null;
|
|
455
|
+
}
|
|
456
|
+
spinner2.succeed(`License validated for ${validation2.email || "licensed user"}`);
|
|
457
|
+
await storeLicense({
|
|
458
|
+
key: normalizedKey,
|
|
459
|
+
...validation2.email && { email: validation2.email },
|
|
460
|
+
validatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
461
|
+
});
|
|
462
|
+
logger.newLine();
|
|
463
|
+
return { key: normalizedKey, email: validation2.email };
|
|
464
|
+
}
|
|
465
|
+
const stored = await getStoredLicense();
|
|
466
|
+
if (stored) {
|
|
467
|
+
logger.log(`Authenticated as ${stored.email || "licensed user"}`);
|
|
468
|
+
logger.newLine();
|
|
469
|
+
const validation2 = await validateLicense(stored.key);
|
|
470
|
+
if (validation2.valid) {
|
|
471
|
+
return { key: stored.key, email: validation2.email || stored.email };
|
|
472
|
+
}
|
|
473
|
+
logger.warn("Your stored license is no longer valid.");
|
|
474
|
+
logger.newLine();
|
|
475
|
+
}
|
|
476
|
+
if (skipPrompts) {
|
|
477
|
+
logger.error("No valid license found. Provide licenseKey in config file or run 'scaffoldry login' first.");
|
|
478
|
+
return null;
|
|
479
|
+
}
|
|
480
|
+
logger.log("Please enter your Scaffoldry license key.");
|
|
481
|
+
logger.log("Purchase at: https://scaffoldry.com");
|
|
482
|
+
logger.newLine();
|
|
483
|
+
const { licenseKey } = await prompts({
|
|
484
|
+
type: "text",
|
|
485
|
+
name: "licenseKey",
|
|
486
|
+
message: "License key:",
|
|
487
|
+
validate: (value) => {
|
|
488
|
+
if (!value.trim()) {
|
|
489
|
+
return "License key is required";
|
|
490
|
+
}
|
|
491
|
+
if (!isValidLicenseKeyFormat(value.trim().toUpperCase())) {
|
|
492
|
+
return "Invalid license key format. Expected: SCAF-XXXX-XXXX-XXXX-XXXX";
|
|
493
|
+
}
|
|
494
|
+
return true;
|
|
495
|
+
},
|
|
496
|
+
format: (value) => value.trim().toUpperCase()
|
|
497
|
+
});
|
|
498
|
+
if (!licenseKey) {
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
const spinner = ora("Validating license...").start();
|
|
502
|
+
const validation = await validateLicense(licenseKey);
|
|
503
|
+
if (!validation.valid) {
|
|
504
|
+
spinner.fail(validation.error || "License validation failed");
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
spinner.succeed(`License validated for ${validation.email || "licensed user"}`);
|
|
508
|
+
await storeLicense({
|
|
509
|
+
key: licenseKey,
|
|
510
|
+
...validation.email && { email: validation.email },
|
|
511
|
+
validatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
512
|
+
});
|
|
513
|
+
logger.newLine();
|
|
514
|
+
return { key: licenseKey, email: validation.email };
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// src/commands/login.ts
|
|
518
|
+
import prompts2 from "prompts";
|
|
519
|
+
import ora2 from "ora";
|
|
520
|
+
import fs2 from "fs/promises";
|
|
521
|
+
import path2 from "path";
|
|
522
|
+
import os from "os";
|
|
523
|
+
async function configureNpmrc(licenseKey) {
|
|
524
|
+
const npmrcPath = path2.join(os.homedir(), ".npmrc");
|
|
525
|
+
let content = "";
|
|
526
|
+
try {
|
|
527
|
+
content = await fs2.readFile(npmrcPath, "utf-8");
|
|
528
|
+
} catch {
|
|
529
|
+
}
|
|
530
|
+
const lines = content.split("\n");
|
|
531
|
+
const registryUrl = "https://scaffoldry.com/api/registry";
|
|
532
|
+
const registryKey = "@scaffoldry:registry";
|
|
533
|
+
const authKey = "//scaffoldry.com/api/registry/:_authToken";
|
|
534
|
+
let registryFound = false;
|
|
535
|
+
let authFound = false;
|
|
536
|
+
const newLines = lines.map((line) => {
|
|
537
|
+
if (line.trim().startsWith(registryKey)) {
|
|
538
|
+
registryFound = true;
|
|
539
|
+
return `${registryKey}=${registryUrl}`;
|
|
540
|
+
}
|
|
541
|
+
if (line.trim().startsWith(authKey)) {
|
|
542
|
+
authFound = true;
|
|
543
|
+
return `${authKey}=${licenseKey}`;
|
|
544
|
+
}
|
|
545
|
+
return line;
|
|
546
|
+
});
|
|
547
|
+
if (!registryFound) {
|
|
548
|
+
newLines.push(`${registryKey}=${registryUrl}`);
|
|
549
|
+
}
|
|
550
|
+
if (!authFound) {
|
|
551
|
+
newLines.push(`${authKey}=${licenseKey}`);
|
|
552
|
+
}
|
|
553
|
+
const cleanContent = newLines.join("\n").replace(/\n+$/, "") + "\n";
|
|
554
|
+
await fs2.writeFile(npmrcPath, cleanContent, "utf-8");
|
|
555
|
+
}
|
|
556
|
+
async function loginCommand() {
|
|
557
|
+
logger.log("");
|
|
558
|
+
logger.info("Scaffoldry Login");
|
|
559
|
+
logger.newLine();
|
|
560
|
+
const stored = await getStoredLicense();
|
|
561
|
+
if (stored) {
|
|
562
|
+
logger.log(`You are already logged in as ${stored.email || "licensed user"}`);
|
|
563
|
+
logger.newLine();
|
|
564
|
+
const { reauth } = await prompts2({
|
|
565
|
+
type: "confirm",
|
|
566
|
+
name: "reauth",
|
|
567
|
+
message: "Do you want to login with a different license key?",
|
|
568
|
+
initial: false
|
|
569
|
+
});
|
|
570
|
+
if (!reauth) {
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
logger.log("Enter your Scaffoldry license key.");
|
|
575
|
+
logger.log("Purchase at: https://scaffoldry.com");
|
|
576
|
+
logger.newLine();
|
|
577
|
+
const { licenseKey } = await prompts2({
|
|
578
|
+
type: "text",
|
|
579
|
+
name: "licenseKey",
|
|
580
|
+
message: "License key:",
|
|
581
|
+
validate: (value) => {
|
|
582
|
+
if (!value.trim()) {
|
|
583
|
+
return "License key is required";
|
|
584
|
+
}
|
|
585
|
+
if (!isValidLicenseKeyFormat(value.trim().toUpperCase())) {
|
|
586
|
+
return "Invalid license key format. Expected: SCAF-XXXX-XXXX-XXXX-XXXX";
|
|
587
|
+
}
|
|
588
|
+
return true;
|
|
589
|
+
},
|
|
590
|
+
format: (value) => value.trim().toUpperCase()
|
|
591
|
+
});
|
|
592
|
+
if (!licenseKey) {
|
|
593
|
+
logger.error("Login cancelled.");
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
const spinner = ora2("Validating license...").start();
|
|
597
|
+
const validation = await validateLicense(licenseKey);
|
|
598
|
+
if (!validation.valid) {
|
|
599
|
+
spinner.fail(validation.error || "License validation failed");
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
spinner.succeed(`License validated!`);
|
|
603
|
+
await storeLicense({
|
|
604
|
+
key: licenseKey,
|
|
605
|
+
...validation.email && { email: validation.email },
|
|
606
|
+
validatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
607
|
+
});
|
|
608
|
+
await configureNpmrc(licenseKey);
|
|
609
|
+
logger.success("Configured .npmrc for private registry access.");
|
|
610
|
+
logger.newLine();
|
|
611
|
+
logger.success(`Logged in as ${validation.email || "licensed user"}`);
|
|
612
|
+
logger.newLine();
|
|
613
|
+
logger.log("You can now use all Scaffoldry features.");
|
|
614
|
+
logger.log("Run 'scaffoldry init' to create a new project.");
|
|
615
|
+
logger.newLine();
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// src/commands/logout.ts
|
|
619
|
+
import prompts3 from "prompts";
|
|
620
|
+
async function logoutCommand() {
|
|
621
|
+
logger.log("");
|
|
622
|
+
logger.info("Scaffoldry Logout");
|
|
623
|
+
logger.newLine();
|
|
624
|
+
const stored = await getStoredLicense();
|
|
625
|
+
if (!stored) {
|
|
626
|
+
logger.log("You are not logged in.");
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
const { confirm } = await prompts3({
|
|
630
|
+
type: "confirm",
|
|
631
|
+
name: "confirm",
|
|
632
|
+
message: `Log out from ${stored.email || "licensed user"}?`,
|
|
633
|
+
initial: true
|
|
634
|
+
});
|
|
635
|
+
if (!confirm) {
|
|
636
|
+
logger.log("Logout cancelled.");
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
await clearLicense();
|
|
640
|
+
logger.success("Logged out successfully.");
|
|
641
|
+
logger.newLine();
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// src/commands/rename.ts
|
|
645
|
+
import path3 from "path";
|
|
646
|
+
import fs3 from "fs-extra";
|
|
647
|
+
import { glob } from "glob";
|
|
648
|
+
import ora3 from "ora";
|
|
649
|
+
var SCAFFOLDRY_MARKERS = {
|
|
650
|
+
productName: ["Scaffoldry", "scaffoldry"],
|
|
651
|
+
productSlug: ["scaffoldry"],
|
|
652
|
+
npmScope: ["@scaffoldry"]
|
|
653
|
+
};
|
|
654
|
+
async function renameCommand(options) {
|
|
655
|
+
const {
|
|
656
|
+
productName,
|
|
657
|
+
productSlug = toKebabCase(productName),
|
|
658
|
+
primaryDomain = `${productSlug}.com`,
|
|
659
|
+
npmScope = `@${productSlug}`,
|
|
660
|
+
dryRun = false
|
|
661
|
+
} = options;
|
|
662
|
+
logger.info(`Renaming project to "${productName}"...`);
|
|
663
|
+
if (dryRun) {
|
|
664
|
+
logger.warn("DRY RUN - no files will be modified");
|
|
665
|
+
}
|
|
666
|
+
logger.newLine();
|
|
667
|
+
const projectDir = process.cwd();
|
|
668
|
+
const files = await glob("**/*.{ts,tsx,js,jsx,json,md,yml,yaml,env,env.*}", {
|
|
669
|
+
cwd: projectDir,
|
|
670
|
+
ignore: ["**/node_modules/**", "**/dist/**", "**/.git/**", "**/pnpm-lock.yaml"],
|
|
671
|
+
dot: true
|
|
672
|
+
});
|
|
673
|
+
const spinner = ora3("Scanning files...").start();
|
|
674
|
+
const replacements = [];
|
|
675
|
+
for (const file of files) {
|
|
676
|
+
const filePath = path3.join(projectDir, file);
|
|
677
|
+
const content = await fs3.readFile(filePath, "utf-8");
|
|
678
|
+
const changes = [];
|
|
679
|
+
if (SCAFFOLDRY_MARKERS.productName.some((m) => content.includes(m))) {
|
|
680
|
+
changes.push({ from: "Scaffoldry", to: toPascalCase(productName) });
|
|
681
|
+
changes.push({ from: "scaffoldry", to: productSlug });
|
|
682
|
+
}
|
|
683
|
+
if (content.includes("@scaffoldry/")) {
|
|
684
|
+
changes.push({ from: "@scaffoldry/", to: `${npmScope}/` });
|
|
685
|
+
}
|
|
686
|
+
if (content.includes('@scaffoldry"')) {
|
|
687
|
+
changes.push({ from: '@scaffoldry"', to: `${npmScope}"` });
|
|
688
|
+
}
|
|
689
|
+
if (content.includes("scaffoldry.com")) {
|
|
690
|
+
changes.push({ from: "scaffoldry.com", to: primaryDomain });
|
|
691
|
+
}
|
|
692
|
+
if (changes.length > 0) {
|
|
693
|
+
replacements.push({ file, changes });
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
spinner.succeed(`Found ${replacements.length} files to update`);
|
|
697
|
+
if (replacements.length === 0) {
|
|
698
|
+
logger.info("No Scaffoldry markers found. Is this a Scaffoldry project?");
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
logger.newLine();
|
|
702
|
+
logger.log("Changes to apply:");
|
|
703
|
+
for (const { file, changes } of replacements) {
|
|
704
|
+
logger.log(` ${file}:`);
|
|
705
|
+
for (const { from, to } of changes) {
|
|
706
|
+
logger.log(` "${from}" -> "${to}"`);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
logger.newLine();
|
|
710
|
+
if (dryRun) {
|
|
711
|
+
logger.warn("DRY RUN complete - no files were modified");
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
const applySpinner = ora3("Applying changes...").start();
|
|
715
|
+
for (const { file, changes } of replacements) {
|
|
716
|
+
const filePath = path3.join(projectDir, file);
|
|
717
|
+
let content = await fs3.readFile(filePath, "utf-8");
|
|
718
|
+
for (const { from, to } of changes) {
|
|
719
|
+
content = content.split(from).join(to);
|
|
720
|
+
}
|
|
721
|
+
await fs3.writeFile(filePath, content);
|
|
722
|
+
}
|
|
723
|
+
applySpinner.succeed("Changes applied successfully");
|
|
724
|
+
logger.newLine();
|
|
725
|
+
logger.success(`Project renamed to "${productName}"!`);
|
|
726
|
+
logger.newLine();
|
|
727
|
+
logger.log("Next steps:");
|
|
728
|
+
logger.log(" 1. Review the changes");
|
|
729
|
+
logger.log(" 2. Run `pnpm install` to update dependencies");
|
|
730
|
+
logger.log(" 3. Commit the changes");
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// src/commands/create-app.ts
|
|
734
|
+
import path4 from "path";
|
|
735
|
+
import fs4 from "fs-extra";
|
|
736
|
+
import ora4 from "ora";
|
|
737
|
+
async function createAppCommand(options) {
|
|
738
|
+
const { slug, name } = options;
|
|
739
|
+
const kebabSlug = toKebabCase(slug);
|
|
740
|
+
const pascalName = toPascalCase(name);
|
|
741
|
+
logger.info(`Creating new app "${name}" (${kebabSlug})...`);
|
|
742
|
+
logger.newLine();
|
|
743
|
+
const projectDir = process.cwd();
|
|
744
|
+
const appsDir = path4.join(projectDir, "apps");
|
|
745
|
+
const appDir = path4.join(appsDir, kebabSlug);
|
|
746
|
+
if (!await fs4.pathExists(appsDir)) {
|
|
747
|
+
logger.error("No 'apps' directory found. Are you in a Scaffoldry project root?");
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
if (await fs4.pathExists(appDir)) {
|
|
751
|
+
logger.error(`App "${kebabSlug}" already exists at ${appDir}`);
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
const spinner = ora4("Creating app files...").start();
|
|
755
|
+
try {
|
|
756
|
+
await fs4.ensureDir(appDir);
|
|
757
|
+
await fs4.writeJSON(
|
|
758
|
+
path4.join(appDir, "package.json"),
|
|
759
|
+
{
|
|
760
|
+
name: kebabSlug,
|
|
761
|
+
version: "0.0.0",
|
|
762
|
+
private: true,
|
|
763
|
+
scripts: {
|
|
764
|
+
dev: "next dev",
|
|
765
|
+
build: "next build",
|
|
766
|
+
start: "next start",
|
|
767
|
+
lint: "next lint",
|
|
768
|
+
typecheck: "tsc --noEmit"
|
|
769
|
+
},
|
|
770
|
+
dependencies: {
|
|
771
|
+
next: "^14.0.0",
|
|
772
|
+
react: "^18.2.0",
|
|
773
|
+
"react-dom": "^18.2.0",
|
|
774
|
+
"scaffoldry-platform": "workspace:*",
|
|
775
|
+
"scaffoldry-ui": "workspace:*"
|
|
776
|
+
},
|
|
777
|
+
devDependencies: {
|
|
778
|
+
"@types/node": "^20.10.0",
|
|
779
|
+
"@types/react": "^18.2.0",
|
|
780
|
+
"@types/react-dom": "^18.2.0",
|
|
781
|
+
typescript: "^5.3.0"
|
|
782
|
+
}
|
|
783
|
+
},
|
|
784
|
+
{ spaces: 2 }
|
|
785
|
+
);
|
|
786
|
+
await fs4.writeJSON(
|
|
787
|
+
path4.join(appDir, "tsconfig.json"),
|
|
788
|
+
{
|
|
789
|
+
extends: "../../tsconfig.json",
|
|
790
|
+
compilerOptions: {
|
|
791
|
+
outDir: "./dist",
|
|
792
|
+
rootDir: "./src",
|
|
793
|
+
noEmit: false,
|
|
794
|
+
jsx: "preserve",
|
|
795
|
+
module: "ESNext",
|
|
796
|
+
moduleResolution: "bundler",
|
|
797
|
+
allowJs: true,
|
|
798
|
+
resolveJsonModule: true,
|
|
799
|
+
isolatedModules: true,
|
|
800
|
+
incremental: true,
|
|
801
|
+
plugins: [{ name: "next" }],
|
|
802
|
+
paths: {
|
|
803
|
+
"@/*": ["./src/*"]
|
|
804
|
+
}
|
|
805
|
+
},
|
|
806
|
+
include: ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
807
|
+
exclude: ["node_modules", "dist"]
|
|
808
|
+
},
|
|
809
|
+
{ spaces: 2 }
|
|
810
|
+
);
|
|
811
|
+
await fs4.writeFile(
|
|
812
|
+
path4.join(appDir, "next.config.js"),
|
|
813
|
+
`/** @type {import('next').NextConfig} */
|
|
814
|
+
const nextConfig = {
|
|
815
|
+
transpilePackages: ["scaffoldry-platform", "scaffoldry-ui"],
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
module.exports = nextConfig;
|
|
819
|
+
`
|
|
820
|
+
);
|
|
821
|
+
await fs4.ensureDir(path4.join(appDir, "src", "app"));
|
|
822
|
+
await fs4.writeFile(
|
|
823
|
+
path4.join(appDir, "src", "app", "layout.tsx"),
|
|
824
|
+
`import type { Metadata } from "next";
|
|
825
|
+
|
|
826
|
+
export const metadata: Metadata = {
|
|
827
|
+
title: "${name}",
|
|
828
|
+
description: "Built with Scaffoldry",
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
export default function RootLayout({
|
|
832
|
+
children,
|
|
833
|
+
}: {
|
|
834
|
+
children: React.ReactNode;
|
|
835
|
+
}) {
|
|
836
|
+
return (
|
|
837
|
+
<html lang="en">
|
|
838
|
+
<body>{children}</body>
|
|
839
|
+
</html>
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
`
|
|
843
|
+
);
|
|
844
|
+
await fs4.writeFile(
|
|
845
|
+
path4.join(appDir, "src", "app", "page.tsx"),
|
|
846
|
+
`export default function Home() {
|
|
847
|
+
return (
|
|
848
|
+
<main className="flex min-h-screen flex-col items-center justify-center p-24">
|
|
849
|
+
<h1 className="text-4xl font-bold">${pascalName}</h1>
|
|
850
|
+
<p className="mt-4 text-lg text-gray-600">
|
|
851
|
+
Welcome to your new Scaffoldry app!
|
|
852
|
+
</p>
|
|
853
|
+
</main>
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
`
|
|
857
|
+
);
|
|
858
|
+
await fs4.writeFile(
|
|
859
|
+
path4.join(appDir, "next-env.d.ts"),
|
|
860
|
+
`/// <reference types="next" />
|
|
861
|
+
/// <reference types="next/image-types/global" />
|
|
862
|
+
|
|
863
|
+
// NOTE: This file should not be edited
|
|
864
|
+
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
|
865
|
+
`
|
|
866
|
+
);
|
|
867
|
+
spinner.succeed("App files created!");
|
|
868
|
+
logger.newLine();
|
|
869
|
+
logger.success(`App "${name}" created successfully at apps/${kebabSlug}!`);
|
|
870
|
+
logger.newLine();
|
|
871
|
+
logger.log("Next steps:");
|
|
872
|
+
logger.log(` pnpm install`);
|
|
873
|
+
logger.log(` pnpm --filter ${kebabSlug} dev`);
|
|
874
|
+
logger.newLine();
|
|
875
|
+
} catch (error) {
|
|
876
|
+
spinner.fail("Failed to create app files.");
|
|
877
|
+
throw error;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// src/commands/upgrade.ts
|
|
882
|
+
import path5 from "path";
|
|
883
|
+
import fs5 from "fs-extra";
|
|
884
|
+
import ora5 from "ora";
|
|
885
|
+
var SCAFFOLDRY_PACKAGES = [
|
|
886
|
+
"scaffoldry-db",
|
|
887
|
+
"scaffoldry-core",
|
|
888
|
+
"scaffoldry-auth",
|
|
889
|
+
"scaffoldry-webhooks",
|
|
890
|
+
"scaffoldry-billing",
|
|
891
|
+
"scaffoldry-jobs",
|
|
892
|
+
"scaffoldry-notify",
|
|
893
|
+
"scaffoldry-storage",
|
|
894
|
+
"scaffoldry-platform",
|
|
895
|
+
"scaffoldry-ui",
|
|
896
|
+
"scaffoldry-admin",
|
|
897
|
+
"scaffoldry-cli"
|
|
898
|
+
];
|
|
899
|
+
async function upgradeCommand(options) {
|
|
900
|
+
const { check = false, dryRun = false, breaking = false } = options;
|
|
901
|
+
logger.info("Checking for Scaffoldry updates...");
|
|
902
|
+
if (dryRun) {
|
|
903
|
+
logger.warn("DRY RUN - no packages will be modified");
|
|
904
|
+
}
|
|
905
|
+
logger.newLine();
|
|
906
|
+
const projectDir = process.cwd();
|
|
907
|
+
const packageJsonPath = path5.join(projectDir, "package.json");
|
|
908
|
+
if (!await fs5.pathExists(packageJsonPath)) {
|
|
909
|
+
logger.error("No package.json found. Are you in a project root?");
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
const spinner = ora5("Fetching version information...").start();
|
|
913
|
+
try {
|
|
914
|
+
const packageJson = await fs5.readJSON(packageJsonPath);
|
|
915
|
+
const dependencies = {
|
|
916
|
+
...packageJson.dependencies,
|
|
917
|
+
...packageJson.devDependencies
|
|
918
|
+
};
|
|
919
|
+
const updates = [];
|
|
920
|
+
for (const pkgName of SCAFFOLDRY_PACKAGES) {
|
|
921
|
+
const currentVersion = dependencies[pkgName];
|
|
922
|
+
if (!currentVersion) continue;
|
|
923
|
+
const latestVersion = await getLatestVersion(pkgName);
|
|
924
|
+
if (latestVersion && currentVersion !== latestVersion) {
|
|
925
|
+
const isBreaking = isBreakingChange(currentVersion, latestVersion);
|
|
926
|
+
updates.push({
|
|
927
|
+
name: pkgName,
|
|
928
|
+
current: currentVersion,
|
|
929
|
+
latest: latestVersion,
|
|
930
|
+
isBreaking
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
spinner.succeed("Version check complete");
|
|
935
|
+
if (updates.length === 0) {
|
|
936
|
+
logger.success("All Scaffoldry packages are up to date!");
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
const filteredUpdates = breaking ? updates : updates.filter((u) => !u.isBreaking);
|
|
940
|
+
logger.newLine();
|
|
941
|
+
logger.log("Available updates:");
|
|
942
|
+
logger.newLine();
|
|
943
|
+
for (const update of filteredUpdates) {
|
|
944
|
+
const breakingIndicator = update.isBreaking ? " (BREAKING)" : "";
|
|
945
|
+
logger.log(
|
|
946
|
+
` ${update.name}: ${update.current} -> ${update.latest}${breakingIndicator}`
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
if (!breaking && updates.some((u) => u.isBreaking)) {
|
|
950
|
+
logger.newLine();
|
|
951
|
+
logger.warn(
|
|
952
|
+
`${updates.filter((u) => u.isBreaking).length} breaking updates available. Use --breaking to include them.`
|
|
953
|
+
);
|
|
954
|
+
}
|
|
955
|
+
if (check) {
|
|
956
|
+
logger.newLine();
|
|
957
|
+
logger.info("Run without --check to apply updates.");
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
if (dryRun) {
|
|
961
|
+
logger.newLine();
|
|
962
|
+
logger.warn("DRY RUN complete - no packages were modified");
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
logger.newLine();
|
|
966
|
+
const applySpinner = ora5("Applying updates...").start();
|
|
967
|
+
for (const update of filteredUpdates) {
|
|
968
|
+
if (packageJson.dependencies?.[update.name]) {
|
|
969
|
+
packageJson.dependencies[update.name] = update.latest;
|
|
970
|
+
}
|
|
971
|
+
if (packageJson.devDependencies?.[update.name]) {
|
|
972
|
+
packageJson.devDependencies[update.name] = update.latest;
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
await fs5.writeJSON(packageJsonPath, packageJson, { spaces: 2 });
|
|
976
|
+
applySpinner.succeed("Updates applied to package.json");
|
|
977
|
+
logger.newLine();
|
|
978
|
+
logger.success("Scaffoldry packages updated!");
|
|
979
|
+
logger.newLine();
|
|
980
|
+
logger.log("Next steps:");
|
|
981
|
+
logger.log(" 1. Run `pnpm install` to install updated packages");
|
|
982
|
+
logger.log(" 2. Run `scaffoldry migrate` to apply any new migrations");
|
|
983
|
+
logger.log(" 3. Review the CHANGELOG for breaking changes");
|
|
984
|
+
logger.newLine();
|
|
985
|
+
} catch (error) {
|
|
986
|
+
spinner.fail("Failed to check for updates");
|
|
987
|
+
throw error;
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
async function getLatestVersion(packageName) {
|
|
991
|
+
try {
|
|
992
|
+
const pkgPath = path5.join(
|
|
993
|
+
process.cwd(),
|
|
994
|
+
"packages",
|
|
995
|
+
packageName,
|
|
996
|
+
"package.json"
|
|
997
|
+
);
|
|
998
|
+
if (await fs5.pathExists(pkgPath)) {
|
|
999
|
+
const pkg = await fs5.readJSON(pkgPath);
|
|
1000
|
+
return pkg.version || "workspace:*";
|
|
1001
|
+
}
|
|
1002
|
+
} catch {
|
|
1003
|
+
}
|
|
1004
|
+
return null;
|
|
1005
|
+
}
|
|
1006
|
+
function isBreakingChange(current, latest) {
|
|
1007
|
+
const currentParts = current.replace(/[^\d.]/g, "").split(".");
|
|
1008
|
+
const latestParts = latest.replace(/[^\d.]/g, "").split(".");
|
|
1009
|
+
const currentMajor = parseInt(currentParts[0] ?? "0", 10);
|
|
1010
|
+
const latestMajor = parseInt(latestParts[0] ?? "0", 10);
|
|
1011
|
+
return !isNaN(currentMajor) && !isNaN(latestMajor) && latestMajor > currentMajor;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// src/commands/migrate.ts
|
|
1015
|
+
import path6 from "path";
|
|
1016
|
+
import fs6 from "fs-extra";
|
|
1017
|
+
import { glob as glob2 } from "glob";
|
|
1018
|
+
import ora6 from "ora";
|
|
1019
|
+
import { neon } from "@neondatabase/serverless";
|
|
1020
|
+
import { drizzle } from "drizzle-orm/neon-http";
|
|
1021
|
+
import { migrate } from "drizzle-orm/neon-http/migrator";
|
|
1022
|
+
import postgres from "postgres";
|
|
1023
|
+
import { drizzle as drizzlePostgres } from "drizzle-orm/postgres-js";
|
|
1024
|
+
import { migrate as migratePostgres } from "drizzle-orm/postgres-js/migrator";
|
|
1025
|
+
async function migrateCommand(options = {}) {
|
|
1026
|
+
const { dryRun = false } = options;
|
|
1027
|
+
logger.info("Running migrations...");
|
|
1028
|
+
logger.newLine();
|
|
1029
|
+
const projectDir = process.cwd();
|
|
1030
|
+
const databaseUrl = process.env.DATABASE_URL;
|
|
1031
|
+
if (!databaseUrl) {
|
|
1032
|
+
logger.error("DATABASE_URL environment variable is not set.");
|
|
1033
|
+
logger.log("Set it in your .env file or export it in your shell.");
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
const migrationDirs = [
|
|
1037
|
+
path6.join(projectDir, "packages", "scaffoldry-db", "drizzle"),
|
|
1038
|
+
path6.join(projectDir, "drizzle")
|
|
1039
|
+
];
|
|
1040
|
+
let migrationsDir = null;
|
|
1041
|
+
for (const dir of migrationDirs) {
|
|
1042
|
+
if (await fs6.pathExists(dir)) {
|
|
1043
|
+
migrationsDir = dir;
|
|
1044
|
+
break;
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
if (!migrationsDir) {
|
|
1048
|
+
logger.error("No migrations directory found.");
|
|
1049
|
+
logger.log("Expected one of:");
|
|
1050
|
+
for (const dir of migrationDirs) {
|
|
1051
|
+
logger.log(` - ${dir}`);
|
|
1052
|
+
}
|
|
1053
|
+
logger.newLine();
|
|
1054
|
+
logger.log("Run 'pnpm --filter scaffoldry-db generate' to create migrations.");
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
const migrations = await glob2("*.sql", { cwd: migrationsDir });
|
|
1058
|
+
if (migrations.length === 0) {
|
|
1059
|
+
logger.warn("No migration files found.");
|
|
1060
|
+
logger.log("Run 'pnpm --filter scaffoldry-db generate' to create migrations.");
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
logger.info(`Found ${migrations.length} migration file(s) in ${migrationsDir}`);
|
|
1064
|
+
for (const migration of migrations.sort()) {
|
|
1065
|
+
logger.log(` - ${migration}`);
|
|
1066
|
+
}
|
|
1067
|
+
logger.newLine();
|
|
1068
|
+
if (dryRun) {
|
|
1069
|
+
logger.info("Dry run mode - no migrations will be applied.");
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
const spinner = ora6("Applying migrations...").start();
|
|
1073
|
+
try {
|
|
1074
|
+
const isNeon = databaseUrl.includes("neon.tech") || databaseUrl.includes("neon-");
|
|
1075
|
+
if (isNeon) {
|
|
1076
|
+
const sql = neon(databaseUrl);
|
|
1077
|
+
const db = drizzle(sql);
|
|
1078
|
+
await migrate(db, { migrationsFolder: migrationsDir });
|
|
1079
|
+
} else {
|
|
1080
|
+
const sql = postgres(databaseUrl, { max: 1 });
|
|
1081
|
+
const db = drizzlePostgres(sql);
|
|
1082
|
+
await migratePostgres(db, { migrationsFolder: migrationsDir });
|
|
1083
|
+
await sql.end();
|
|
1084
|
+
}
|
|
1085
|
+
spinner.succeed("Migrations applied successfully!");
|
|
1086
|
+
logger.newLine();
|
|
1087
|
+
logger.success(`Applied ${migrations.length} migration(s).`);
|
|
1088
|
+
logger.newLine();
|
|
1089
|
+
} catch (error) {
|
|
1090
|
+
spinner.fail("Migration failed");
|
|
1091
|
+
if (error instanceof Error) {
|
|
1092
|
+
logger.error(error.message);
|
|
1093
|
+
if (error.message.includes("already exists")) {
|
|
1094
|
+
logger.newLine();
|
|
1095
|
+
logger.log("This usually means the migration was partially applied.");
|
|
1096
|
+
logger.log("Check your database state and consider resetting if in development.");
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
throw error;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
async function migrateCreateCommand(options) {
|
|
1103
|
+
const { name } = options;
|
|
1104
|
+
if (!/^[a-z][a-z0-9_]*$/.test(name)) {
|
|
1105
|
+
logger.error("Migration name must be snake_case (e.g., add_user_preferences)");
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
logger.info(`Creating migration "${name}"...`);
|
|
1109
|
+
logger.newLine();
|
|
1110
|
+
const projectDir = process.cwd();
|
|
1111
|
+
const migrationsDir = path6.join(projectDir, "drizzle");
|
|
1112
|
+
await fs6.ensureDir(migrationsDir);
|
|
1113
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[-:T]/g, "").slice(0, 14);
|
|
1114
|
+
const fileName = `${timestamp}_${name}.sql`;
|
|
1115
|
+
const filePath = path6.join(migrationsDir, fileName);
|
|
1116
|
+
if (await fs6.pathExists(filePath)) {
|
|
1117
|
+
logger.error(`Migration file already exists: ${fileName}`);
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
const template = `-- Migration: ${name}
|
|
1121
|
+
-- Created at: ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
1122
|
+
|
|
1123
|
+
-- Write your migration SQL here
|
|
1124
|
+
|
|
1125
|
+
-- Example:
|
|
1126
|
+
-- CREATE TABLE user_preferences (
|
|
1127
|
+
-- id TEXT PRIMARY KEY,
|
|
1128
|
+
-- user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
1129
|
+
-- theme TEXT NOT NULL DEFAULT 'light',
|
|
1130
|
+
-- notifications_enabled BOOLEAN NOT NULL DEFAULT true,
|
|
1131
|
+
-- created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
|
1132
|
+
-- updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
|
|
1133
|
+
-- );
|
|
1134
|
+
|
|
1135
|
+
-- CREATE INDEX idx_user_preferences_user_id ON user_preferences(user_id);
|
|
1136
|
+
`;
|
|
1137
|
+
await fs6.writeFile(filePath, template);
|
|
1138
|
+
logger.success(`Migration created: ${fileName}`);
|
|
1139
|
+
logger.newLine();
|
|
1140
|
+
logger.log(`Edit the file at: ${filePath}`);
|
|
1141
|
+
logger.newLine();
|
|
1142
|
+
logger.log("After editing, run:");
|
|
1143
|
+
logger.log(" scaffoldry migrate");
|
|
1144
|
+
logger.newLine();
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// src/commands/dev.ts
|
|
1148
|
+
import { spawn } from "child_process";
|
|
1149
|
+
import chalk from "chalk";
|
|
1150
|
+
var colors = [
|
|
1151
|
+
chalk.cyan,
|
|
1152
|
+
chalk.magenta,
|
|
1153
|
+
chalk.yellow,
|
|
1154
|
+
chalk.green,
|
|
1155
|
+
chalk.blue
|
|
1156
|
+
];
|
|
1157
|
+
function prefixLog(prefix, color, message) {
|
|
1158
|
+
const lines = message.split("\n").filter(Boolean);
|
|
1159
|
+
for (const line of lines) {
|
|
1160
|
+
console.log(color(`[${prefix}]`) + " " + line);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
async function checkPrerequisites(projectDir, options) {
|
|
1164
|
+
console.log();
|
|
1165
|
+
printBox("Starting Development Environment", [
|
|
1166
|
+
"Checking prerequisites..."
|
|
1167
|
+
]);
|
|
1168
|
+
const checks = [];
|
|
1169
|
+
checks.push({ result: await checkNodeVersion(), required: true });
|
|
1170
|
+
checks.push({ result: await checkPnpmVersion(), required: true });
|
|
1171
|
+
const stripeCli = await checkStripeCli();
|
|
1172
|
+
const env = await readEnvLocal(projectDir);
|
|
1173
|
+
const hasStripeKey = Boolean(env.STRIPE_SECRET_KEY);
|
|
1174
|
+
checks.push({ result: stripeCli, required: !options.skipStripe && hasStripeKey });
|
|
1175
|
+
checks.push({ result: await checkEnvVar(projectDir, "DATABASE_URL"), required: true });
|
|
1176
|
+
if (!options.skipStripe) {
|
|
1177
|
+
checks.push({ result: await checkEnvVar(projectDir, "STRIPE_SECRET_KEY", { required: false }), required: false });
|
|
1178
|
+
}
|
|
1179
|
+
let allPassed = true;
|
|
1180
|
+
const stripeCliAvailable = stripeCli.status === "pass";
|
|
1181
|
+
for (const { result, required } of checks) {
|
|
1182
|
+
const icon = result.status === "pass" ? chalk.green("\u2713") : result.status === "warn" ? chalk.yellow("\u26A0") : chalk.red("\u2717");
|
|
1183
|
+
let line = ` ${icon} ${result.label}`;
|
|
1184
|
+
if (result.message) {
|
|
1185
|
+
line += chalk.gray(` (${result.message})`);
|
|
1186
|
+
}
|
|
1187
|
+
console.log(line);
|
|
1188
|
+
if (required && result.status === "fail") {
|
|
1189
|
+
allPassed = false;
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
if (!allPassed) {
|
|
1193
|
+
console.log();
|
|
1194
|
+
printError("Some required prerequisites are missing");
|
|
1195
|
+
printInfo("Run `scaffoldry doctor` for more details");
|
|
1196
|
+
return { pass: false, stripeCli: stripeCliAvailable };
|
|
1197
|
+
}
|
|
1198
|
+
return { pass: true, stripeCli: stripeCliAvailable };
|
|
1199
|
+
}
|
|
1200
|
+
async function devCommand(options = {}) {
|
|
1201
|
+
const projectDir = process.cwd();
|
|
1202
|
+
const port = options.port || 3e3;
|
|
1203
|
+
const prereqs = await checkPrerequisites(projectDir, options);
|
|
1204
|
+
if (!prereqs.pass) {
|
|
1205
|
+
process.exit(1);
|
|
1206
|
+
}
|
|
1207
|
+
const processes = [];
|
|
1208
|
+
let colorIndex = 0;
|
|
1209
|
+
const getNextColor = () => {
|
|
1210
|
+
const color = colors[colorIndex++ % colors.length];
|
|
1211
|
+
return color ?? chalk.white;
|
|
1212
|
+
};
|
|
1213
|
+
console.log();
|
|
1214
|
+
console.log("Starting services...");
|
|
1215
|
+
const nextProcess = spawn("pnpm", ["run", "dev", "--", "--port", String(port)], {
|
|
1216
|
+
...getSpawnOptions({
|
|
1217
|
+
cwd: projectDir,
|
|
1218
|
+
stdio: ["inherit", "pipe", "pipe"]
|
|
1219
|
+
})
|
|
1220
|
+
});
|
|
1221
|
+
const nextInfo = {
|
|
1222
|
+
name: "next",
|
|
1223
|
+
process: nextProcess,
|
|
1224
|
+
color: getNextColor()
|
|
1225
|
+
};
|
|
1226
|
+
processes.push(nextInfo);
|
|
1227
|
+
nextProcess.stdout?.on("data", (data) => {
|
|
1228
|
+
prefixLog("next", nextInfo.color, data.toString());
|
|
1229
|
+
});
|
|
1230
|
+
nextProcess.stderr?.on("data", (data) => {
|
|
1231
|
+
prefixLog("next", nextInfo.color, data.toString());
|
|
1232
|
+
});
|
|
1233
|
+
printSuccess(`Next.js dev server starting on http://localhost:${port}`);
|
|
1234
|
+
const env = await readEnvLocal(projectDir);
|
|
1235
|
+
if (!options.skipStripe && prereqs.stripeCli && env.STRIPE_SECRET_KEY) {
|
|
1236
|
+
const stripeProcess = spawn(
|
|
1237
|
+
"stripe",
|
|
1238
|
+
["listen", "--forward-to", `localhost:${port}/api/webhooks/stripe`],
|
|
1239
|
+
{
|
|
1240
|
+
...getSpawnOptions({
|
|
1241
|
+
cwd: projectDir,
|
|
1242
|
+
stdio: ["inherit", "pipe", "pipe"]
|
|
1243
|
+
})
|
|
1244
|
+
}
|
|
1245
|
+
);
|
|
1246
|
+
const stripeInfo = {
|
|
1247
|
+
name: "stripe",
|
|
1248
|
+
process: stripeProcess,
|
|
1249
|
+
color: getNextColor()
|
|
1250
|
+
};
|
|
1251
|
+
processes.push(stripeInfo);
|
|
1252
|
+
stripeProcess.stdout?.on("data", (data) => {
|
|
1253
|
+
const output = data.toString();
|
|
1254
|
+
prefixLog("stripe", stripeInfo.color, output);
|
|
1255
|
+
const secretMatch = output.match(/whsec_[A-Za-z0-9]+/);
|
|
1256
|
+
if (secretMatch) {
|
|
1257
|
+
console.log();
|
|
1258
|
+
printInfo(`Webhook secret: ${secretMatch[0]}`);
|
|
1259
|
+
printInfo("Update STRIPE_WEBHOOK_SECRET in .env.local if needed");
|
|
1260
|
+
}
|
|
1261
|
+
});
|
|
1262
|
+
stripeProcess.stderr?.on("data", (data) => {
|
|
1263
|
+
prefixLog("stripe", stripeInfo.color, data.toString());
|
|
1264
|
+
});
|
|
1265
|
+
printSuccess("Stripe CLI listening for webhooks");
|
|
1266
|
+
} else if (!options.skipStripe && !prereqs.stripeCli && env.STRIPE_SECRET_KEY) {
|
|
1267
|
+
printWarning("Stripe CLI not installed - webhook forwarding disabled");
|
|
1268
|
+
const { getInstallInstructions } = await import("./platform-Z35MB2P5.js");
|
|
1269
|
+
printInfo(getInstallInstructions("stripe-cli"));
|
|
1270
|
+
}
|
|
1271
|
+
console.log();
|
|
1272
|
+
printBox("Development Server Ready", [
|
|
1273
|
+
`App: http://localhost:${port}`,
|
|
1274
|
+
"",
|
|
1275
|
+
"Press Ctrl+C to stop all services."
|
|
1276
|
+
]);
|
|
1277
|
+
console.log();
|
|
1278
|
+
const cleanup = () => {
|
|
1279
|
+
console.log();
|
|
1280
|
+
console.log("Shutting down...");
|
|
1281
|
+
const termSignal = getTermSignal();
|
|
1282
|
+
const killSignal = getKillSignal();
|
|
1283
|
+
for (const { name, process: proc, color } of processes) {
|
|
1284
|
+
prefixLog(name, color, "Stopping...");
|
|
1285
|
+
proc.kill(termSignal);
|
|
1286
|
+
}
|
|
1287
|
+
setTimeout(() => {
|
|
1288
|
+
for (const { process: proc } of processes) {
|
|
1289
|
+
if (!proc.killed) {
|
|
1290
|
+
proc.kill(killSignal);
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
process.exit(0);
|
|
1294
|
+
}, 3e3);
|
|
1295
|
+
};
|
|
1296
|
+
setupShutdownHandlers(cleanup);
|
|
1297
|
+
for (const { name, process: proc, color } of processes) {
|
|
1298
|
+
proc.on("exit", (code) => {
|
|
1299
|
+
prefixLog(name, color, `Exited with code ${code}`);
|
|
1300
|
+
});
|
|
1301
|
+
proc.on("error", (error) => {
|
|
1302
|
+
prefixLog(name, color, `Error: ${error.message}`);
|
|
1303
|
+
});
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
// src/commands/doctor.ts
|
|
1308
|
+
import chalk2 from "chalk";
|
|
1309
|
+
import prompts4 from "prompts";
|
|
1310
|
+
function printCheckResults(results) {
|
|
1311
|
+
for (const result of results) {
|
|
1312
|
+
const icon = result.status === "pass" ? chalk2.green("\u2713") : result.status === "warn" ? chalk2.yellow("\u26A0") : chalk2.red("\u2717");
|
|
1313
|
+
let line = ` ${icon} ${result.label}`;
|
|
1314
|
+
if (result.message) {
|
|
1315
|
+
line += chalk2.gray(` (${result.message})`);
|
|
1316
|
+
}
|
|
1317
|
+
console.log(line);
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
function collectIssues(results) {
|
|
1321
|
+
const issues = [];
|
|
1322
|
+
for (const result of results) {
|
|
1323
|
+
if (result.status === "fail" || result.status === "warn") {
|
|
1324
|
+
const issue = {
|
|
1325
|
+
label: result.label,
|
|
1326
|
+
message: result.message || "Issue detected"
|
|
1327
|
+
};
|
|
1328
|
+
if (result.fix) {
|
|
1329
|
+
issue.fix = result.fix;
|
|
1330
|
+
}
|
|
1331
|
+
issues.push(issue);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
return issues;
|
|
1335
|
+
}
|
|
1336
|
+
async function checkLicense() {
|
|
1337
|
+
const stored = await getStoredLicense();
|
|
1338
|
+
if (!stored) {
|
|
1339
|
+
return {
|
|
1340
|
+
status: "fail",
|
|
1341
|
+
label: "Scaffoldry license",
|
|
1342
|
+
message: "Not authenticated",
|
|
1343
|
+
fix: "Run: scaffoldry login"
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1346
|
+
const validation = await validateLicense(stored.key);
|
|
1347
|
+
if (validation.valid) {
|
|
1348
|
+
return {
|
|
1349
|
+
status: "pass",
|
|
1350
|
+
label: "Scaffoldry license",
|
|
1351
|
+
message: stored.email || "Valid"
|
|
1352
|
+
};
|
|
1353
|
+
}
|
|
1354
|
+
return {
|
|
1355
|
+
status: "fail",
|
|
1356
|
+
label: "Scaffoldry license",
|
|
1357
|
+
message: validation.error || "Invalid",
|
|
1358
|
+
fix: "Run: scaffoldry login"
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1361
|
+
async function doctorCommand() {
|
|
1362
|
+
const projectDir = process.cwd();
|
|
1363
|
+
console.log();
|
|
1364
|
+
printBox("Scaffoldry Doctor", [
|
|
1365
|
+
"Checking your project configuration..."
|
|
1366
|
+
]);
|
|
1367
|
+
console.log();
|
|
1368
|
+
console.log(chalk2.bold("Platform:"), getPlatformDisplayName());
|
|
1369
|
+
console.log();
|
|
1370
|
+
console.log(chalk2.bold("Checking environment..."));
|
|
1371
|
+
const envResults = await runEnvironmentChecks();
|
|
1372
|
+
printCheckResults(envResults);
|
|
1373
|
+
console.log();
|
|
1374
|
+
console.log(chalk2.bold("Checking license..."));
|
|
1375
|
+
const licenseResult = await checkLicense();
|
|
1376
|
+
printCheckResults([licenseResult]);
|
|
1377
|
+
console.log();
|
|
1378
|
+
console.log(chalk2.bold("Checking configuration..."));
|
|
1379
|
+
const configResults = await runConfigurationChecks(projectDir);
|
|
1380
|
+
printCheckResults(configResults);
|
|
1381
|
+
console.log();
|
|
1382
|
+
console.log(chalk2.bold("Checking services..."));
|
|
1383
|
+
const serviceResults = await runServiceChecks(projectDir);
|
|
1384
|
+
printCheckResults(serviceResults);
|
|
1385
|
+
const licenseIssue = licenseResult.status !== "pass" ? {
|
|
1386
|
+
label: licenseResult.label,
|
|
1387
|
+
message: licenseResult.message || "Issue",
|
|
1388
|
+
...licenseResult.fix ? { fix: licenseResult.fix } : {}
|
|
1389
|
+
} : null;
|
|
1390
|
+
const allIssues = [
|
|
1391
|
+
...collectIssues(envResults),
|
|
1392
|
+
...licenseIssue ? [licenseIssue] : [],
|
|
1393
|
+
...collectIssues(configResults),
|
|
1394
|
+
...collectIssues(serviceResults)
|
|
1395
|
+
];
|
|
1396
|
+
const criticalIssues = allIssues.filter(
|
|
1397
|
+
(issue) => !issue.message.includes("optional") && !issue.message.includes("network error")
|
|
1398
|
+
);
|
|
1399
|
+
console.log();
|
|
1400
|
+
if (criticalIssues.length === 0) {
|
|
1401
|
+
console.log(chalk2.green.bold("\u2713 All checks passed!"));
|
|
1402
|
+
console.log();
|
|
1403
|
+
printInfo("Your project is configured correctly.");
|
|
1404
|
+
printInfo("Run `scaffoldry dev` to start development.");
|
|
1405
|
+
return;
|
|
1406
|
+
}
|
|
1407
|
+
const borderTop = "\u250C" + "\u2500".repeat(61) + "\u2510";
|
|
1408
|
+
const borderBottom = "\u2514" + "\u2500".repeat(61) + "\u2518";
|
|
1409
|
+
console.log(chalk2.yellow(borderTop));
|
|
1410
|
+
console.log(chalk2.yellow("\u2502") + " ".repeat(61) + chalk2.yellow("\u2502"));
|
|
1411
|
+
console.log(chalk2.yellow("\u2502") + chalk2.bold(` ${criticalIssues.length} issue${criticalIssues.length === 1 ? "" : "s"} need attention:`).padEnd(64) + chalk2.yellow("\u2502"));
|
|
1412
|
+
console.log(chalk2.yellow("\u2502") + " ".repeat(61) + chalk2.yellow("\u2502"));
|
|
1413
|
+
criticalIssues.forEach((issue, i) => {
|
|
1414
|
+
console.log(chalk2.yellow("\u2502") + ` ${i + 1}. ${issue.label}`.padEnd(61) + chalk2.yellow("\u2502"));
|
|
1415
|
+
console.log(chalk2.yellow("\u2502") + chalk2.gray(` ${issue.message}`).padEnd(70) + chalk2.yellow("\u2502"));
|
|
1416
|
+
if (issue.fix) {
|
|
1417
|
+
console.log(chalk2.yellow("\u2502") + chalk2.cyan(` \u2192 ${issue.fix}`).padEnd(70) + chalk2.yellow("\u2502"));
|
|
1418
|
+
}
|
|
1419
|
+
console.log(chalk2.yellow("\u2502") + " ".repeat(61) + chalk2.yellow("\u2502"));
|
|
1420
|
+
});
|
|
1421
|
+
console.log(chalk2.yellow(borderBottom));
|
|
1422
|
+
const fixableIssues = criticalIssues.filter((issue) => issue.fix?.startsWith("Run: scaffoldry setup"));
|
|
1423
|
+
if (fixableIssues.length > 0) {
|
|
1424
|
+
console.log();
|
|
1425
|
+
const { autoFix } = await prompts4({
|
|
1426
|
+
type: "confirm",
|
|
1427
|
+
name: "autoFix",
|
|
1428
|
+
message: "Run setup wizards to fix issues?",
|
|
1429
|
+
initial: true
|
|
1430
|
+
});
|
|
1431
|
+
if (autoFix) {
|
|
1432
|
+
const { setupCommand: setupCommand2 } = await import("./setup-L2PO5OVZ.js");
|
|
1433
|
+
const services = /* @__PURE__ */ new Set();
|
|
1434
|
+
for (const issue of fixableIssues) {
|
|
1435
|
+
if (issue.fix?.includes("setup stripe")) services.add("stripe");
|
|
1436
|
+
if (issue.fix?.includes("setup database")) services.add("database");
|
|
1437
|
+
if (issue.fix?.includes("setup email")) services.add("email");
|
|
1438
|
+
if (issue.fix?.includes("setup storage")) services.add("storage");
|
|
1439
|
+
if (issue.fix?.includes("setup all")) {
|
|
1440
|
+
services.add("database");
|
|
1441
|
+
services.add("stripe");
|
|
1442
|
+
services.add("email");
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
for (const service of services) {
|
|
1446
|
+
await setupCommand2({ service });
|
|
1447
|
+
}
|
|
1448
|
+
console.log();
|
|
1449
|
+
printInfo("Run `scaffoldry doctor` again to verify fixes");
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// src/commands/secret.ts
|
|
1455
|
+
import crypto from "crypto";
|
|
1456
|
+
import prompts5 from "prompts";
|
|
1457
|
+
function generateSecureSecret(bytes = 32) {
|
|
1458
|
+
return crypto.randomBytes(bytes).toString("base64url");
|
|
1459
|
+
}
|
|
1460
|
+
async function secretSetCommand(options) {
|
|
1461
|
+
const projectDir = process.cwd();
|
|
1462
|
+
if (!options.key || !options.value) {
|
|
1463
|
+
printError("Both key and value are required");
|
|
1464
|
+
printInfo("Usage: scaffoldry secret set <KEY> <VALUE>");
|
|
1465
|
+
return;
|
|
1466
|
+
}
|
|
1467
|
+
if (!/^[A-Z_][A-Z0-9_]*$/.test(options.key)) {
|
|
1468
|
+
printError("Invalid key format. Use UPPERCASE_WITH_UNDERSCORES");
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
await writeEnvLocal(projectDir, { [options.key]: options.value });
|
|
1472
|
+
logger.newLine();
|
|
1473
|
+
printSuccess(`Set ${options.key} in .env.local`);
|
|
1474
|
+
}
|
|
1475
|
+
async function secretRotateCommand(options) {
|
|
1476
|
+
const projectDir = process.cwd();
|
|
1477
|
+
switch (options.type) {
|
|
1478
|
+
case "auth":
|
|
1479
|
+
await rotateAuthSecret(projectDir);
|
|
1480
|
+
break;
|
|
1481
|
+
case "db":
|
|
1482
|
+
await rotateDatabaseCredentials(projectDir);
|
|
1483
|
+
break;
|
|
1484
|
+
default:
|
|
1485
|
+
printError(`Unknown secret type: ${options.type}`);
|
|
1486
|
+
printInfo("Supported types: auth, db");
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
async function rotateAuthSecret(projectDir) {
|
|
1490
|
+
console.log();
|
|
1491
|
+
printBox("Rotating AUTH_SECRET", [
|
|
1492
|
+
"Per Section 32 rotation rules:",
|
|
1493
|
+
"1. Current AUTH_SECRET \u2192 AUTH_SECRET_PREVIOUS",
|
|
1494
|
+
"2. Generate new 32-byte random AUTH_SECRET",
|
|
1495
|
+
"3. Both keys valid during rotation window"
|
|
1496
|
+
]);
|
|
1497
|
+
const env = await readEnvLocal(projectDir);
|
|
1498
|
+
const currentSecret = env.AUTH_SECRET;
|
|
1499
|
+
if (!currentSecret) {
|
|
1500
|
+
printWarning("No AUTH_SECRET found in .env.local");
|
|
1501
|
+
console.log();
|
|
1502
|
+
const { generate } = await prompts5({
|
|
1503
|
+
type: "confirm",
|
|
1504
|
+
name: "generate",
|
|
1505
|
+
message: "Generate a new AUTH_SECRET?",
|
|
1506
|
+
initial: true
|
|
1507
|
+
});
|
|
1508
|
+
if (generate) {
|
|
1509
|
+
const newSecret2 = generateSecureSecret(32);
|
|
1510
|
+
await writeEnvLocal(projectDir, { AUTH_SECRET: newSecret2 });
|
|
1511
|
+
printSuccess("Generated new AUTH_SECRET");
|
|
1512
|
+
}
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
console.log();
|
|
1516
|
+
const { proceed } = await prompts5({
|
|
1517
|
+
type: "confirm",
|
|
1518
|
+
name: "proceed",
|
|
1519
|
+
message: "Proceed with rotation?",
|
|
1520
|
+
initial: true
|
|
1521
|
+
});
|
|
1522
|
+
if (!proceed) {
|
|
1523
|
+
printInfo("Rotation cancelled");
|
|
1524
|
+
return;
|
|
1525
|
+
}
|
|
1526
|
+
console.log();
|
|
1527
|
+
console.log("Rotating...");
|
|
1528
|
+
const newSecret = generateSecureSecret(32);
|
|
1529
|
+
await writeEnvLocal(projectDir, {
|
|
1530
|
+
AUTH_SECRET: newSecret,
|
|
1531
|
+
AUTH_SECRET_PREVIOUS: currentSecret
|
|
1532
|
+
});
|
|
1533
|
+
printSuccess("Moved current key to AUTH_SECRET_PREVIOUS");
|
|
1534
|
+
printSuccess("Generated new AUTH_SECRET");
|
|
1535
|
+
printSuccess("Updated .env.local");
|
|
1536
|
+
console.log();
|
|
1537
|
+
printWarning("Important: Restart your server to apply changes.");
|
|
1538
|
+
printInfo("Sessions signed with old key will remain valid");
|
|
1539
|
+
printInfo("during the rotation window (AUTH_SECRET_PREVIOUS).");
|
|
1540
|
+
}
|
|
1541
|
+
async function rotateDatabaseCredentials(projectDir) {
|
|
1542
|
+
console.log();
|
|
1543
|
+
printBox("Database Credential Rotation", [
|
|
1544
|
+
"Database credentials require careful rotation to avoid downtime.",
|
|
1545
|
+
"",
|
|
1546
|
+
"Steps for Neon PostgreSQL:",
|
|
1547
|
+
"1. Create new role in Neon dashboard",
|
|
1548
|
+
"2. Grant same permissions as current role",
|
|
1549
|
+
"3. Update DATABASE_URL with new credentials",
|
|
1550
|
+
"4. Verify connection works",
|
|
1551
|
+
"5. Revoke old credentials"
|
|
1552
|
+
]);
|
|
1553
|
+
const env = await readEnvLocal(projectDir);
|
|
1554
|
+
const currentUrl = env.DATABASE_URL;
|
|
1555
|
+
if (!currentUrl) {
|
|
1556
|
+
printError("No DATABASE_URL found in .env.local");
|
|
1557
|
+
printInfo("Run: scaffoldry setup database");
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
console.log();
|
|
1561
|
+
printInfo("Current database host:");
|
|
1562
|
+
try {
|
|
1563
|
+
const url = new URL(currentUrl);
|
|
1564
|
+
console.log(` ${url.hostname}`);
|
|
1565
|
+
} catch {
|
|
1566
|
+
console.log(" (unable to parse URL)");
|
|
1567
|
+
}
|
|
1568
|
+
console.log();
|
|
1569
|
+
printInfo("To rotate database credentials:");
|
|
1570
|
+
console.log(" 1. Go to https://console.neon.tech");
|
|
1571
|
+
console.log(" 2. Navigate to your project > Settings > Roles");
|
|
1572
|
+
console.log(" 3. Create a new role");
|
|
1573
|
+
console.log(" 4. Update the connection string");
|
|
1574
|
+
console.log(" 5. Run: scaffoldry setup database");
|
|
1575
|
+
console.log();
|
|
1576
|
+
const { openDocs } = await prompts5({
|
|
1577
|
+
type: "confirm",
|
|
1578
|
+
name: "openDocs",
|
|
1579
|
+
message: "Open Neon documentation for credential rotation?",
|
|
1580
|
+
initial: true
|
|
1581
|
+
});
|
|
1582
|
+
if (openDocs) {
|
|
1583
|
+
const { exec } = await import("child_process");
|
|
1584
|
+
const { promisify } = await import("util");
|
|
1585
|
+
const execAsync = promisify(exec);
|
|
1586
|
+
const os2 = await import("os");
|
|
1587
|
+
const url = "https://neon.tech/docs/manage/roles";
|
|
1588
|
+
const platform = os2.platform();
|
|
1589
|
+
try {
|
|
1590
|
+
if (platform === "darwin") {
|
|
1591
|
+
await execAsync(`open "${url}"`);
|
|
1592
|
+
} else if (platform === "win32") {
|
|
1593
|
+
await execAsync(`start "${url}"`);
|
|
1594
|
+
} else {
|
|
1595
|
+
await execAsync(`xdg-open "${url}"`);
|
|
1596
|
+
}
|
|
1597
|
+
printInfo("Opened Neon documentation");
|
|
1598
|
+
} catch {
|
|
1599
|
+
printInfo(`Visit: ${url}`);
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// src/commands/plan.ts
|
|
1605
|
+
import fs7 from "fs-extra";
|
|
1606
|
+
import path7 from "path";
|
|
1607
|
+
import prompts6 from "prompts";
|
|
1608
|
+
var MASTER_SPEC_INVARIANTS = `
|
|
1609
|
+
## Scaffoldry v1 Invariants (MUST FOLLOW)
|
|
1610
|
+
|
|
1611
|
+
These are non-negotiable constraints that all generated code MUST follow:
|
|
1612
|
+
|
|
1613
|
+
### Authentication
|
|
1614
|
+
- Use Argon2id for password hashing (NOT bcrypt)
|
|
1615
|
+
- Implement Pwned Passwords check on registration
|
|
1616
|
+
- Magic link tokens: 32-byte random, 15-minute expiry
|
|
1617
|
+
- Session cookies: HttpOnly, Secure, SameSite=Lax
|
|
1618
|
+
|
|
1619
|
+
### Database
|
|
1620
|
+
- All tables MUST have tenant_id column for multi-tenancy
|
|
1621
|
+
- Use Row-Level Security (RLS) policies
|
|
1622
|
+
- Soft-delete with deleted_at timestamp (NOT hard delete)
|
|
1623
|
+
- All timestamps in UTC
|
|
1624
|
+
|
|
1625
|
+
### API Design
|
|
1626
|
+
- All endpoints MUST be rate-limited
|
|
1627
|
+
- Input validation with Zod schemas
|
|
1628
|
+
- Return consistent error format: { error: string, code?: string }
|
|
1629
|
+
- Use POST for mutations, GET for queries
|
|
1630
|
+
|
|
1631
|
+
### Security
|
|
1632
|
+
- No secrets in client-side code (NEXT_PUBLIC_ prefix rules)
|
|
1633
|
+
- CSRF protection on state-changing operations
|
|
1634
|
+
- Validate webhook signatures before processing
|
|
1635
|
+
- Log all authentication events to audit table
|
|
1636
|
+
`;
|
|
1637
|
+
var MASTER_SPEC_SECURITY = `
|
|
1638
|
+
## Security Hardening Requirements
|
|
1639
|
+
|
|
1640
|
+
### Password Security
|
|
1641
|
+
- Minimum 8 characters, check against Pwned Passwords API
|
|
1642
|
+
- Hash with Argon2id: memoryCost=65536, timeCost=3, parallelism=4
|
|
1643
|
+
- Never store plaintext passwords
|
|
1644
|
+
- Never log passwords or password hashes
|
|
1645
|
+
|
|
1646
|
+
### Session Security
|
|
1647
|
+
- Rotate session ID on privilege escalation
|
|
1648
|
+
- 24-hour session expiry, refresh on activity
|
|
1649
|
+
- Store sessions in database with user_agent and ip_hash
|
|
1650
|
+
- Invalidate all sessions on password change
|
|
1651
|
+
|
|
1652
|
+
### Rate Limiting
|
|
1653
|
+
- Login: 5 attempts per 15 minutes per IP
|
|
1654
|
+
- API: 100 requests per minute per user
|
|
1655
|
+
- Webhook: 1000 requests per minute per endpoint
|
|
1656
|
+
|
|
1657
|
+
### Input Validation
|
|
1658
|
+
- Validate ALL user input server-side
|
|
1659
|
+
- Sanitize HTML output to prevent XSS
|
|
1660
|
+
- Use parameterized queries (Drizzle handles this)
|
|
1661
|
+
- Validate file uploads: type, size, content
|
|
1662
|
+
|
|
1663
|
+
### Audit Logging
|
|
1664
|
+
- Log: login, logout, password_change, role_change, data_export
|
|
1665
|
+
- Include: user_id, action, ip_hash, timestamp, metadata
|
|
1666
|
+
- IP addresses MUST be hashed with salt (privacy compliance)
|
|
1667
|
+
`;
|
|
1668
|
+
var TECHNICAL_STACK = `
|
|
1669
|
+
## Technical Stack (v1 - Locked)
|
|
1670
|
+
|
|
1671
|
+
This is the production-ready stack used by Scaffoldry:
|
|
1672
|
+
|
|
1673
|
+
- **Framework**: Next.js 14+ with App Router
|
|
1674
|
+
- **Language**: TypeScript (strict mode)
|
|
1675
|
+
- **Database**: PostgreSQL via Neon (serverless)
|
|
1676
|
+
- **ORM**: Drizzle ORM with migrations
|
|
1677
|
+
- **Auth**: Custom implementation (password + magic link)
|
|
1678
|
+
- **Payments**: Stripe (subscriptions + one-time)
|
|
1679
|
+
- **Email**: Resend (transactional emails)
|
|
1680
|
+
- **Storage**: AWS S3 (file uploads)
|
|
1681
|
+
- **Styling**: Tailwind CSS
|
|
1682
|
+
- **Validation**: Zod
|
|
1683
|
+
- **Testing**: Vitest
|
|
1684
|
+
|
|
1685
|
+
### File Structure
|
|
1686
|
+
\`\`\`
|
|
1687
|
+
apps/web/
|
|
1688
|
+
\u251C\u2500\u2500 src/
|
|
1689
|
+
\u2502 \u251C\u2500\u2500 app/ # Next.js App Router pages
|
|
1690
|
+
\u2502 \u251C\u2500\u2500 components/ # React components
|
|
1691
|
+
\u2502 \u2514\u2500\u2500 lib/ # Utilities and helpers
|
|
1692
|
+
packages/
|
|
1693
|
+
\u251C\u2500\u2500 database/ # Drizzle schema and migrations
|
|
1694
|
+
\u251C\u2500\u2500 auth/ # Authentication logic
|
|
1695
|
+
\u251C\u2500\u2500 billing/ # Stripe integration
|
|
1696
|
+
\u251C\u2500\u2500 email/ # Email templates and sending
|
|
1697
|
+
\u2514\u2500\u2500 shared/ # Shared types and utilities
|
|
1698
|
+
\`\`\`
|
|
1699
|
+
`;
|
|
1700
|
+
function generateDevelopmentPlan(projectName, description, features) {
|
|
1701
|
+
return `# Project: ${projectName}
|
|
1702
|
+
|
|
1703
|
+
## Executive Summary
|
|
1704
|
+
${description}
|
|
1705
|
+
|
|
1706
|
+
## Technical Stack
|
|
1707
|
+
- **Frontend**: Next.js 14 with App Router, TypeScript, Tailwind CSS
|
|
1708
|
+
- **Backend**: Next.js API Routes, PostgreSQL (Neon)
|
|
1709
|
+
- **Authentication**: Email/password + Magic links
|
|
1710
|
+
- **Payments**: Stripe subscriptions
|
|
1711
|
+
- **Email**: Transactional emails via Resend
|
|
1712
|
+
- **Database**: Neon (serverless PostgreSQL) + Drizzle ORM
|
|
1713
|
+
|
|
1714
|
+
## Core Features
|
|
1715
|
+
${features.map((f) => `- ${f}`).join("\n")}
|
|
1716
|
+
|
|
1717
|
+
## Implementation Phases
|
|
1718
|
+
|
|
1719
|
+
### Phase 1: Foundation
|
|
1720
|
+
- [ ] Project setup and configuration
|
|
1721
|
+
- [ ] Database schema design
|
|
1722
|
+
- [ ] User authentication flows
|
|
1723
|
+
- [ ] Basic CRUD operations
|
|
1724
|
+
|
|
1725
|
+
### Phase 2: Core Features
|
|
1726
|
+
- [ ] Implement primary features
|
|
1727
|
+
- [ ] API endpoints
|
|
1728
|
+
- [ ] Frontend components
|
|
1729
|
+
- [ ] Email notifications
|
|
1730
|
+
|
|
1731
|
+
### Phase 3: Polish
|
|
1732
|
+
- [ ] Error handling
|
|
1733
|
+
- [ ] Loading states
|
|
1734
|
+
- [ ] Testing
|
|
1735
|
+
- [ ] Documentation
|
|
1736
|
+
|
|
1737
|
+
## Security Considerations
|
|
1738
|
+
- Row-level security for multi-tenancy
|
|
1739
|
+
- Rate limiting on all endpoints
|
|
1740
|
+
- Input validation with Zod
|
|
1741
|
+
- Audit logging for sensitive operations
|
|
1742
|
+
|
|
1743
|
+
## Deployment
|
|
1744
|
+
- Vercel (recommended) or any Node.js host
|
|
1745
|
+
- Neon for database
|
|
1746
|
+
- Environment variables configured via \`scaffoldry setup\`
|
|
1747
|
+
`;
|
|
1748
|
+
}
|
|
1749
|
+
function generateAIPrompt(projectName, description, features) {
|
|
1750
|
+
return `# AI Implementation Prompt for ${projectName}
|
|
1751
|
+
|
|
1752
|
+
## Project Description
|
|
1753
|
+
${description}
|
|
1754
|
+
|
|
1755
|
+
## Features to Implement
|
|
1756
|
+
${features.map((f, i) => `${i + 1}. ${f}`).join("\n")}
|
|
1757
|
+
|
|
1758
|
+
---
|
|
1759
|
+
|
|
1760
|
+
# CRITICAL: Scaffoldry Framework Context
|
|
1761
|
+
|
|
1762
|
+
You are implementing features for a Scaffoldry project. You MUST follow these constraints exactly.
|
|
1763
|
+
|
|
1764
|
+
${MASTER_SPEC_INVARIANTS}
|
|
1765
|
+
|
|
1766
|
+
${MASTER_SPEC_SECURITY}
|
|
1767
|
+
|
|
1768
|
+
${TECHNICAL_STACK}
|
|
1769
|
+
|
|
1770
|
+
---
|
|
1771
|
+
|
|
1772
|
+
## Implementation Guidelines
|
|
1773
|
+
|
|
1774
|
+
When generating code:
|
|
1775
|
+
|
|
1776
|
+
1. **Use existing patterns** - Check existing code in the codebase for patterns
|
|
1777
|
+
2. **Follow the schema** - Database tables in packages/database/src/schema/
|
|
1778
|
+
3. **Type everything** - No \`any\` types, use Zod for runtime validation
|
|
1779
|
+
4. **Handle errors** - All API routes must have try/catch with proper error responses
|
|
1780
|
+
5. **Add audit logs** - Log sensitive operations to the audit table
|
|
1781
|
+
6. **Test critical paths** - Auth, payments, and data access MUST have tests
|
|
1782
|
+
|
|
1783
|
+
## Example Patterns
|
|
1784
|
+
|
|
1785
|
+
### API Route Pattern
|
|
1786
|
+
\`\`\`typescript
|
|
1787
|
+
// apps/web/src/app/api/example/route.ts
|
|
1788
|
+
import { NextRequest, NextResponse } from "next/server";
|
|
1789
|
+
import { z } from "zod";
|
|
1790
|
+
import { getSession } from "@scaffoldry/auth";
|
|
1791
|
+
import { db } from "@scaffoldry/database";
|
|
1792
|
+
|
|
1793
|
+
const schema = z.object({
|
|
1794
|
+
// Define your schema
|
|
1795
|
+
});
|
|
1796
|
+
|
|
1797
|
+
export async function POST(req: NextRequest) {
|
|
1798
|
+
try {
|
|
1799
|
+
const session = await getSession();
|
|
1800
|
+
if (!session) {
|
|
1801
|
+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
const body = await req.json();
|
|
1805
|
+
const data = schema.parse(body);
|
|
1806
|
+
|
|
1807
|
+
// Implementation here
|
|
1808
|
+
|
|
1809
|
+
return NextResponse.json({ success: true });
|
|
1810
|
+
} catch (error) {
|
|
1811
|
+
if (error instanceof z.ZodError) {
|
|
1812
|
+
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
|
|
1813
|
+
}
|
|
1814
|
+
console.error("API Error:", error);
|
|
1815
|
+
return NextResponse.json({ error: "Internal error" }, { status: 500 });
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
\`\`\`
|
|
1819
|
+
|
|
1820
|
+
### Database Query Pattern
|
|
1821
|
+
\`\`\`typescript
|
|
1822
|
+
import { db, eq, and } from "@scaffoldry/database";
|
|
1823
|
+
import { users } from "@scaffoldry/database/schema";
|
|
1824
|
+
|
|
1825
|
+
// Always filter by tenant_id for multi-tenancy
|
|
1826
|
+
const user = await db.query.users.findFirst({
|
|
1827
|
+
where: and(
|
|
1828
|
+
eq(users.id, userId),
|
|
1829
|
+
eq(users.tenantId, session.tenantId)
|
|
1830
|
+
),
|
|
1831
|
+
});
|
|
1832
|
+
\`\`\`
|
|
1833
|
+
|
|
1834
|
+
---
|
|
1835
|
+
|
|
1836
|
+
Now implement the features described above, following all constraints and patterns.
|
|
1837
|
+
`;
|
|
1838
|
+
}
|
|
1839
|
+
async function planCommand() {
|
|
1840
|
+
console.log();
|
|
1841
|
+
printBox("Project Planner", [
|
|
1842
|
+
"Let's create a development plan for your SaaS.",
|
|
1843
|
+
"",
|
|
1844
|
+
"This will generate:",
|
|
1845
|
+
"\u2022 DEVELOPMENT_PLAN.md - Share with your team",
|
|
1846
|
+
"\u2022 AI_PROMPT.md - Paste into Claude, GPT, or Cursor"
|
|
1847
|
+
]);
|
|
1848
|
+
const { description } = await prompts6({
|
|
1849
|
+
type: "text",
|
|
1850
|
+
name: "description",
|
|
1851
|
+
message: "Describe what you want to build:",
|
|
1852
|
+
validate: (value) => value.length > 10 ? true : "Please provide more detail"
|
|
1853
|
+
});
|
|
1854
|
+
if (!description) {
|
|
1855
|
+
printInfo("Cancelled");
|
|
1856
|
+
return;
|
|
1857
|
+
}
|
|
1858
|
+
console.log();
|
|
1859
|
+
printInfo("Now list the key features (one per line, empty line to finish):");
|
|
1860
|
+
console.log();
|
|
1861
|
+
const features = [];
|
|
1862
|
+
let collecting = true;
|
|
1863
|
+
while (collecting) {
|
|
1864
|
+
const { feature } = await prompts6({
|
|
1865
|
+
type: "text",
|
|
1866
|
+
name: "feature",
|
|
1867
|
+
message: features.length === 0 ? "Feature 1:" : `Feature ${features.length + 1}:`
|
|
1868
|
+
});
|
|
1869
|
+
if (!feature || feature.trim() === "") {
|
|
1870
|
+
if (features.length === 0) {
|
|
1871
|
+
printInfo("Please add at least one feature");
|
|
1872
|
+
continue;
|
|
1873
|
+
}
|
|
1874
|
+
collecting = false;
|
|
1875
|
+
} else {
|
|
1876
|
+
features.push(feature.trim());
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1879
|
+
const projectName = description.split(" ").slice(0, 3).map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join("");
|
|
1880
|
+
console.log();
|
|
1881
|
+
console.log("\u250C" + "\u2500".repeat(61) + "\u2510");
|
|
1882
|
+
console.log("\u2502" + " ".repeat(61) + "\u2502");
|
|
1883
|
+
console.log("\u2502 " + `Project: ${projectName}`.padEnd(58) + "\u2502");
|
|
1884
|
+
console.log("\u2502" + " ".repeat(61) + "\u2502");
|
|
1885
|
+
console.log("\u2502" + " Features:".padEnd(58) + "\u2502");
|
|
1886
|
+
for (const feature of features) {
|
|
1887
|
+
console.log("\u2502" + ` \u2022 ${feature}`.slice(0, 58).padEnd(58) + "\u2502");
|
|
1888
|
+
}
|
|
1889
|
+
console.log("\u2502" + " ".repeat(61) + "\u2502");
|
|
1890
|
+
console.log("\u2514" + "\u2500".repeat(61) + "\u2518");
|
|
1891
|
+
const { confirm } = await prompts6({
|
|
1892
|
+
type: "confirm",
|
|
1893
|
+
name: "confirm",
|
|
1894
|
+
message: "Generate plans?",
|
|
1895
|
+
initial: true
|
|
1896
|
+
});
|
|
1897
|
+
if (!confirm) {
|
|
1898
|
+
printInfo("Cancelled");
|
|
1899
|
+
return;
|
|
1900
|
+
}
|
|
1901
|
+
console.log();
|
|
1902
|
+
console.log("Generating implementation plan...");
|
|
1903
|
+
const devPlan = generateDevelopmentPlan(projectName, description, features);
|
|
1904
|
+
const aiPrompt = generateAIPrompt(projectName, description, features);
|
|
1905
|
+
const projectDir = process.cwd();
|
|
1906
|
+
await fs7.writeFile(path7.join(projectDir, "DEVELOPMENT_PLAN.md"), devPlan);
|
|
1907
|
+
await fs7.writeFile(path7.join(projectDir, "AI_PROMPT.md"), aiPrompt);
|
|
1908
|
+
await saveProjectData(projectName, "plan.md", devPlan);
|
|
1909
|
+
await saveProjectData(projectName, "ai-prompt.md", aiPrompt);
|
|
1910
|
+
printSuccess("Plan saved to: ./DEVELOPMENT_PLAN.md");
|
|
1911
|
+
printSuccess("AI prompt saved to: ./AI_PROMPT.md");
|
|
1912
|
+
console.log();
|
|
1913
|
+
const { wantsCopy } = await prompts6({
|
|
1914
|
+
type: "confirm",
|
|
1915
|
+
name: "wantsCopy",
|
|
1916
|
+
message: "Copy AI prompt to clipboard?",
|
|
1917
|
+
initial: true
|
|
1918
|
+
});
|
|
1919
|
+
if (wantsCopy) {
|
|
1920
|
+
const copied = await copyToClipboard(aiPrompt);
|
|
1921
|
+
if (copied) {
|
|
1922
|
+
printSuccess("Copied to clipboard!");
|
|
1923
|
+
} else {
|
|
1924
|
+
printInfo("Could not copy to clipboard. Open AI_PROMPT.md manually.");
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
console.log();
|
|
1928
|
+
printBox("Next Steps", [
|
|
1929
|
+
"1. Review DEVELOPMENT_PLAN.md with your team",
|
|
1930
|
+
"2. Use AI_PROMPT.md with Claude, GPT, or Cursor",
|
|
1931
|
+
"3. The AI prompt includes critical Scaffoldry",
|
|
1932
|
+
" constraints for compliant code generation"
|
|
1933
|
+
]);
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
export {
|
|
1937
|
+
generateProjectFiles,
|
|
1938
|
+
initCommand,
|
|
1939
|
+
loginCommand,
|
|
1940
|
+
logoutCommand,
|
|
1941
|
+
renameCommand,
|
|
1942
|
+
createAppCommand,
|
|
1943
|
+
upgradeCommand,
|
|
1944
|
+
migrateCommand,
|
|
1945
|
+
migrateCreateCommand,
|
|
1946
|
+
devCommand,
|
|
1947
|
+
doctorCommand,
|
|
1948
|
+
secretSetCommand,
|
|
1949
|
+
secretRotateCommand,
|
|
1950
|
+
planCommand
|
|
1951
|
+
};
|
|
1952
|
+
//# sourceMappingURL=chunk-AA2UXYNR.js.map
|