@spfn/cli 0.0.9 → 0.1.0-alpha.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/LICENSE +21 -0
- package/README.md +283 -0
- package/bin/spfn.js +10 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +829 -0
- package/dist/templates/.guide/api-routes.md +388 -0
- package/dist/templates/server/entities/README.md +131 -0
- package/dist/templates/server/routes/examples/contract.ts +101 -0
- package/dist/templates/server/routes/examples/index.ts +112 -0
- package/dist/templates/server/routes/health/contract.ts +13 -0
- package/dist/templates/server/routes/health/index.ts +23 -0
- package/dist/templates/server/routes/index/contract.ts +13 -0
- package/dist/templates/server/routes/index/index.ts +28 -0
- package/package.json +67 -47
- package/lib/index.js +0 -19
- package/lib/login.js +0 -206
package/dist/index.js
ADDED
@@ -0,0 +1,829 @@
|
|
1
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
2
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
3
|
+
}) : x)(function(x) {
|
4
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
5
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
6
|
+
});
|
7
|
+
|
8
|
+
// src/index.ts
|
9
|
+
import { Command as Command6 } from "commander";
|
10
|
+
|
11
|
+
// src/commands/init.ts
|
12
|
+
import { Command } from "commander";
|
13
|
+
import { existsSync as existsSync2 } from "fs";
|
14
|
+
import { join as join2 } from "path";
|
15
|
+
import prompts from "prompts";
|
16
|
+
import ora from "ora";
|
17
|
+
import { execa } from "execa";
|
18
|
+
import fse from "fs-extra";
|
19
|
+
import { dirname } from "path";
|
20
|
+
import { fileURLToPath } from "url";
|
21
|
+
import chalk2 from "chalk";
|
22
|
+
|
23
|
+
// src/utils/logger.ts
|
24
|
+
import chalk from "chalk";
|
25
|
+
var logger = {
|
26
|
+
info: (message) => {
|
27
|
+
console.log(chalk.blue("\u2139"), message);
|
28
|
+
},
|
29
|
+
success: (message) => {
|
30
|
+
console.log(chalk.green("\u2713"), message);
|
31
|
+
},
|
32
|
+
warn: (message) => {
|
33
|
+
console.log(chalk.yellow("\u26A0"), message);
|
34
|
+
},
|
35
|
+
error: (message) => {
|
36
|
+
console.log(chalk.red("\u2717"), message);
|
37
|
+
},
|
38
|
+
step: (message) => {
|
39
|
+
console.log(chalk.cyan("\u25B8"), message);
|
40
|
+
}
|
41
|
+
};
|
42
|
+
|
43
|
+
// src/utils/package-manager.ts
|
44
|
+
import { existsSync } from "fs";
|
45
|
+
import { join } from "path";
|
46
|
+
function detectPackageManager(cwd) {
|
47
|
+
if (existsSync(join(cwd, "bun.lockb"))) {
|
48
|
+
return "bun";
|
49
|
+
}
|
50
|
+
if (existsSync(join(cwd, "pnpm-lock.yaml"))) {
|
51
|
+
return "pnpm";
|
52
|
+
}
|
53
|
+
if (existsSync(join(cwd, "yarn.lock"))) {
|
54
|
+
return "yarn";
|
55
|
+
}
|
56
|
+
let currentDir = cwd;
|
57
|
+
let depth = 0;
|
58
|
+
const maxDepth = 5;
|
59
|
+
while (depth < maxDepth) {
|
60
|
+
const parentDir = join(currentDir, "..");
|
61
|
+
if (parentDir === currentDir) {
|
62
|
+
break;
|
63
|
+
}
|
64
|
+
if (existsSync(join(parentDir, "pnpm-lock.yaml"))) {
|
65
|
+
return "pnpm";
|
66
|
+
}
|
67
|
+
if (existsSync(join(parentDir, "yarn.lock"))) {
|
68
|
+
return "yarn";
|
69
|
+
}
|
70
|
+
if (existsSync(join(parentDir, "bun.lockb"))) {
|
71
|
+
return "bun";
|
72
|
+
}
|
73
|
+
currentDir = parentDir;
|
74
|
+
depth++;
|
75
|
+
}
|
76
|
+
return "npm";
|
77
|
+
}
|
78
|
+
|
79
|
+
// src/commands/init.ts
|
80
|
+
var { copySync, ensureDirSync, writeFileSync } = fse;
|
81
|
+
var __dirname = dirname(fileURLToPath(import.meta.url));
|
82
|
+
function findTemplatesPath() {
|
83
|
+
const npmPath = join2(__dirname, "templates");
|
84
|
+
if (existsSync2(npmPath)) {
|
85
|
+
return npmPath;
|
86
|
+
}
|
87
|
+
const devPath = join2(__dirname, "..", "templates");
|
88
|
+
if (existsSync2(devPath)) {
|
89
|
+
return devPath;
|
90
|
+
}
|
91
|
+
throw new Error("Templates directory not found. Please rebuild the package.");
|
92
|
+
}
|
93
|
+
var initCommand = new Command("init").description("Initialize SPFN in your Next.js project").option("-y, --yes", "Skip prompts and use defaults").action(async (options) => {
|
94
|
+
const cwd = process.cwd();
|
95
|
+
const packageJsonPath = join2(cwd, "package.json");
|
96
|
+
if (!existsSync2(packageJsonPath)) {
|
97
|
+
logger.error("No package.json found. Please run this in a Next.js project.");
|
98
|
+
process.exit(1);
|
99
|
+
}
|
100
|
+
const packageJson = JSON.parse(await import("fs").then(
|
101
|
+
(fs) => fs.promises.readFile(packageJsonPath, "utf-8")
|
102
|
+
));
|
103
|
+
const hasNext = packageJson.dependencies?.next || packageJson.devDependencies?.next;
|
104
|
+
if (!hasNext) {
|
105
|
+
logger.warn("Next.js not detected in dependencies.");
|
106
|
+
if (!options.yes) {
|
107
|
+
const { proceed } = await prompts(
|
108
|
+
{
|
109
|
+
type: "confirm",
|
110
|
+
name: "proceed",
|
111
|
+
message: "Continue anyway?",
|
112
|
+
initial: false
|
113
|
+
}
|
114
|
+
);
|
115
|
+
if (!proceed) {
|
116
|
+
process.exit(0);
|
117
|
+
}
|
118
|
+
}
|
119
|
+
}
|
120
|
+
logger.info("Initializing SPFN in your Next.js project...\n");
|
121
|
+
if (existsSync2(join2(cwd, "src", "server"))) {
|
122
|
+
logger.warn("src/server directory already exists.");
|
123
|
+
if (!options.yes) {
|
124
|
+
const { overwrite } = await prompts(
|
125
|
+
{
|
126
|
+
type: "confirm",
|
127
|
+
name: "overwrite",
|
128
|
+
message: "Overwrite existing files?",
|
129
|
+
initial: false
|
130
|
+
}
|
131
|
+
);
|
132
|
+
if (!overwrite) {
|
133
|
+
logger.info("Cancelled.");
|
134
|
+
process.exit(0);
|
135
|
+
}
|
136
|
+
}
|
137
|
+
}
|
138
|
+
const pm = detectPackageManager(cwd);
|
139
|
+
logger.step(`Detected package manager: ${pm}`);
|
140
|
+
const spinner = ora("Installing @spfn/core...").start();
|
141
|
+
try {
|
142
|
+
const devPackages = ["tsx", "drizzle-kit", "concurrently", "dotenv"];
|
143
|
+
const corePackagePath = join2(cwd, "node_modules", "@spfn", "core", "package.json");
|
144
|
+
const isCoreInstalled = existsSync2(corePackagePath);
|
145
|
+
if (!isCoreInstalled) {
|
146
|
+
spinner.text = "Installing @spfn/core...";
|
147
|
+
await execa(
|
148
|
+
pm,
|
149
|
+
pm === "npm" ? ["install", "--legacy-peer-deps", "@spfn/core"] : ["add", "@spfn/core"],
|
150
|
+
{
|
151
|
+
cwd
|
152
|
+
}
|
153
|
+
);
|
154
|
+
} else {
|
155
|
+
spinner.text = "@spfn/core already installed, skipping...";
|
156
|
+
}
|
157
|
+
await execa(
|
158
|
+
pm,
|
159
|
+
pm === "npm" ? ["install", "--save-dev", ...devPackages] : ["add", "-D", ...devPackages],
|
160
|
+
{
|
161
|
+
cwd
|
162
|
+
}
|
163
|
+
);
|
164
|
+
spinner.succeed("Dependencies installed");
|
165
|
+
} catch (error) {
|
166
|
+
spinner.fail("Failed to install dependencies");
|
167
|
+
logger.error(String(error));
|
168
|
+
process.exit(1);
|
169
|
+
}
|
170
|
+
spinner.start("Setting up server structure...");
|
171
|
+
try {
|
172
|
+
const templatesDir = findTemplatesPath();
|
173
|
+
const serverTemplateDir = join2(templatesDir, "server");
|
174
|
+
const targetDir = join2(cwd, "src", "server");
|
175
|
+
if (!existsSync2(serverTemplateDir)) {
|
176
|
+
throw new Error(`Server templates not found at: ${serverTemplateDir}`);
|
177
|
+
}
|
178
|
+
ensureDirSync(targetDir);
|
179
|
+
copySync(serverTemplateDir, targetDir);
|
180
|
+
spinner.succeed("Server structure created");
|
181
|
+
} catch (error) {
|
182
|
+
spinner.fail("Failed to create server structure");
|
183
|
+
logger.error(String(error));
|
184
|
+
process.exit(1);
|
185
|
+
}
|
186
|
+
spinner.start("Updating package.json scripts...");
|
187
|
+
packageJson.scripts = packageJson.scripts || {};
|
188
|
+
packageJson.scripts["spfn:dev"] = "spfn dev";
|
189
|
+
packageJson.scripts["spfn:server"] = "spfn dev --server-only";
|
190
|
+
packageJson.scripts["spfn:next"] = "next dev --turbo --port 3790";
|
191
|
+
packageJson.scripts["spfn:start"] = "spfn start";
|
192
|
+
writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2));
|
193
|
+
spinner.succeed("package.json updated");
|
194
|
+
const envExamplePath = join2(cwd, ".env.local.example");
|
195
|
+
if (!existsSync2(envExamplePath)) {
|
196
|
+
writeFileSync(envExamplePath, `# Database
|
197
|
+
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
|
198
|
+
|
199
|
+
# API URL (for frontend)
|
200
|
+
NEXT_PUBLIC_API_URL=http://localhost:8790
|
201
|
+
`);
|
202
|
+
logger.success("Created .env.local.example");
|
203
|
+
}
|
204
|
+
console.log("\n" + chalk2.green.bold("\u2713 SPFN initialized successfully!\n"));
|
205
|
+
console.log("Next steps:");
|
206
|
+
console.log(" 1. Copy .env.local.example to .env.local and configure your database");
|
207
|
+
console.log(" 2. Run: " + chalk2.cyan(pm === "npm" ? "npm run spfn:dev" : `${pm} run spfn:dev`));
|
208
|
+
console.log(" 3. Visit:");
|
209
|
+
console.log(" - Next.js: " + chalk2.cyan("http://localhost:3790"));
|
210
|
+
console.log(" - API: " + chalk2.cyan("http://localhost:8790/health"));
|
211
|
+
console.log("\nAvailable scripts:");
|
212
|
+
console.log(" \u2022 " + chalk2.cyan("spfn:dev") + " - Start SPFN server (8790) + Next.js (3790)");
|
213
|
+
console.log(" \u2022 " + chalk2.cyan("spfn:server") + " - Start SPFN server only (8790)");
|
214
|
+
console.log(" \u2022 " + chalk2.cyan("spfn:next") + " - Start Next.js only (3790)");
|
215
|
+
});
|
216
|
+
|
217
|
+
// src/commands/dev.ts
|
218
|
+
import { Command as Command2 } from "commander";
|
219
|
+
import { existsSync as existsSync3, readFileSync, writeFileSync as writeFileSync2, mkdirSync } from "fs";
|
220
|
+
import { join as join3 } from "path";
|
221
|
+
import { execa as execa2 } from "execa";
|
222
|
+
var devCommand = new Command2("dev").description("Start SPFN development server (detects and runs Next.js + Hono)").option("-p, --port <port>", "Server port", "8790").option("-h, --host <host>", "Server host", "localhost").option("--routes <path>", "Routes directory path").option("--server-only", "Run only Hono server (skip Next.js)").option("--no-watch", "Disable hot reload (watch mode)").action(async (options) => {
|
223
|
+
const cwd = process.cwd();
|
224
|
+
const serverDir = join3(cwd, "src", "server");
|
225
|
+
if (!existsSync3(serverDir)) {
|
226
|
+
logger.error("src/server directory not found.");
|
227
|
+
logger.info('Run "spfn init" first to initialize SPFN in your project.');
|
228
|
+
process.exit(1);
|
229
|
+
}
|
230
|
+
const packageJsonPath = join3(cwd, "package.json");
|
231
|
+
let hasNext = false;
|
232
|
+
if (existsSync3(packageJsonPath)) {
|
233
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
234
|
+
hasNext = !!(packageJson.dependencies?.next || packageJson.devDependencies?.next);
|
235
|
+
}
|
236
|
+
const tempDir = join3(cwd, "node_modules", ".spfn");
|
237
|
+
const serverEntry = join3(tempDir, "server.mjs");
|
238
|
+
const watcherEntry = join3(tempDir, "watcher.mjs");
|
239
|
+
mkdirSync(tempDir, { recursive: true });
|
240
|
+
writeFileSync2(serverEntry, `
|
241
|
+
import { config } from 'dotenv';
|
242
|
+
import { startServer } from '@spfn/core/server';
|
243
|
+
|
244
|
+
// Load .env.local
|
245
|
+
config({ path: '.env.local' });
|
246
|
+
|
247
|
+
await startServer({
|
248
|
+
port: ${options.port},
|
249
|
+
host: '${options.host}',
|
250
|
+
routesPath: ${options.routes ? `'${options.routes}'` : "undefined"},
|
251
|
+
debug: true
|
252
|
+
});
|
253
|
+
`);
|
254
|
+
writeFileSync2(watcherEntry, `
|
255
|
+
import { watchAndGenerate } from '@spfn/core/codegen';
|
256
|
+
|
257
|
+
await watchAndGenerate({
|
258
|
+
routesDir: ${options.routes ? `'${options.routes}'` : "undefined"},
|
259
|
+
debug: true
|
260
|
+
});
|
261
|
+
`);
|
262
|
+
const pm = detectPackageManager(cwd);
|
263
|
+
if (options.serverOnly || !hasNext) {
|
264
|
+
const watchMode2 = options.watch !== false;
|
265
|
+
logger.info(`Starting SPFN Server on http://${options.host}:${options.port}${watchMode2 ? " (watch mode)" : ""}
|
266
|
+
`);
|
267
|
+
try {
|
268
|
+
const tsxCmd2 = watchMode2 ? "tsx --watch" : "tsx";
|
269
|
+
const serverCmd2 = pm === "npm" ? `npx ${tsxCmd2} ${serverEntry}` : `${pm} exec ${tsxCmd2} ${serverEntry}`;
|
270
|
+
const watcherCmd2 = pm === "npm" ? `npx ${tsxCmd2} ${watcherEntry}` : `${pm} exec ${tsxCmd2} ${watcherEntry}`;
|
271
|
+
await execa2(
|
272
|
+
pm === "npm" ? "npx" : pm,
|
273
|
+
pm === "npm" ? ["concurrently", "--raw", "--kill-others", `"${serverCmd2}"`, `"${watcherCmd2}"`] : ["exec", "concurrently", "--raw", "--kill-others", `"${serverCmd2}"`, `"${watcherCmd2}"`],
|
274
|
+
{
|
275
|
+
stdio: "inherit",
|
276
|
+
cwd,
|
277
|
+
shell: true
|
278
|
+
}
|
279
|
+
);
|
280
|
+
} catch (error) {
|
281
|
+
if (error.exitCode === 130) {
|
282
|
+
process.exit(0);
|
283
|
+
}
|
284
|
+
logger.error(`Failed to start server: ${error}`);
|
285
|
+
process.exit(1);
|
286
|
+
}
|
287
|
+
return;
|
288
|
+
}
|
289
|
+
const watchMode = options.watch !== false;
|
290
|
+
const nextCmd = pm === "npm" ? "npm run spfn:next" : `${pm} run spfn:next`;
|
291
|
+
const tsxCmd = watchMode ? "tsx --watch" : "tsx";
|
292
|
+
const serverCmd = pm === "npm" ? `npx ${tsxCmd} ${serverEntry}` : `${pm} exec ${tsxCmd} ${serverEntry}`;
|
293
|
+
const watcherCmd = pm === "npm" ? `npx ${tsxCmd} ${watcherEntry}` : `${pm} exec ${tsxCmd} ${watcherEntry}`;
|
294
|
+
logger.info(`Starting SPFN server + Next.js (Turbopack)${watchMode ? " (watch mode)" : ""}...
|
295
|
+
`);
|
296
|
+
try {
|
297
|
+
await execa2(
|
298
|
+
pm === "npm" ? "npx" : pm,
|
299
|
+
pm === "npm" ? ["concurrently", "--raw", "--kill-others", `"${nextCmd}"`, `"${serverCmd}"`, `"${watcherCmd}"`] : ["exec", "concurrently", "--raw", "--kill-others", `"${nextCmd}"`, `"${serverCmd}"`, `"${watcherCmd}"`],
|
300
|
+
{
|
301
|
+
stdio: "inherit",
|
302
|
+
cwd,
|
303
|
+
shell: true
|
304
|
+
}
|
305
|
+
);
|
306
|
+
} catch (error) {
|
307
|
+
if (error.exitCode === 130) {
|
308
|
+
process.exit(0);
|
309
|
+
}
|
310
|
+
logger.error(`Failed to start development servers: ${error}`);
|
311
|
+
process.exit(1);
|
312
|
+
}
|
313
|
+
});
|
314
|
+
|
315
|
+
// src/commands/start.ts
|
316
|
+
import { Command as Command3 } from "commander";
|
317
|
+
import { existsSync as existsSync4 } from "fs";
|
318
|
+
import { join as join4 } from "path";
|
319
|
+
var startCommand = new Command3("start").description("Start SPFN production server").option("-p, --port <port>", "Server port", "4000").option("-h, --host <host>", "Server host", "0.0.0.0").action(async (options) => {
|
320
|
+
const cwd = process.cwd();
|
321
|
+
const serverDir = join4(cwd, "src", "server");
|
322
|
+
if (!existsSync4(serverDir)) {
|
323
|
+
logger.error("src/server directory not found.");
|
324
|
+
logger.info('Run "spfn init" first to initialize SPFN in your project.');
|
325
|
+
process.exit(1);
|
326
|
+
}
|
327
|
+
try {
|
328
|
+
const { startServer } = await import("@spfn/core");
|
329
|
+
await startServer(
|
330
|
+
{
|
331
|
+
port: parseInt(options.port),
|
332
|
+
host: options.host,
|
333
|
+
debug: false
|
334
|
+
}
|
335
|
+
);
|
336
|
+
} catch (error) {
|
337
|
+
if (error.code === "ERR_MODULE_NOT_FOUND") {
|
338
|
+
logger.error("@spfn/core is not installed.");
|
339
|
+
logger.info('Run "spfn init" first to set up your project.');
|
340
|
+
} else {
|
341
|
+
logger.error(`Failed to start server: ${error}`);
|
342
|
+
}
|
343
|
+
process.exit(1);
|
344
|
+
}
|
345
|
+
});
|
346
|
+
|
347
|
+
// src/commands/generate.ts
|
348
|
+
import { Command as Command4 } from "commander";
|
349
|
+
import { existsSync as existsSync5, readFileSync as readFileSync2 } from "fs";
|
350
|
+
import { join as join5, relative } from "path";
|
351
|
+
import prompts2 from "prompts";
|
352
|
+
import ora2 from "ora";
|
353
|
+
import fse2 from "fs-extra";
|
354
|
+
import chalk3 from "chalk";
|
355
|
+
import { dirname as dirname2 } from "path";
|
356
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
357
|
+
var { ensureDirSync: ensureDirSync2, writeFileSync: writeFileSync3 } = fse2;
|
358
|
+
var __dirname2 = dirname2(fileURLToPath2(import.meta.url));
|
359
|
+
function findScriptTemplatesPath() {
|
360
|
+
const devPath = join5(__dirname2, "..", "..", "core", "dist", "scripts", "templates");
|
361
|
+
if (existsSync5(devPath)) {
|
362
|
+
return devPath;
|
363
|
+
}
|
364
|
+
const npmPath = join5(__dirname2, "..", "..", "..", "core", "dist", "scripts", "templates");
|
365
|
+
if (existsSync5(npmPath)) {
|
366
|
+
return npmPath;
|
367
|
+
}
|
368
|
+
throw new Error("CRUD templates directory not found. Please rebuild the package.");
|
369
|
+
}
|
370
|
+
function extractEntityName(entityPath) {
|
371
|
+
try {
|
372
|
+
const content = readFileSync2(entityPath, "utf-8");
|
373
|
+
const match = content.match(/export\s+const\s+(\w+)\s*=\s*pgTable\(/);
|
374
|
+
if (match) {
|
375
|
+
return match[1];
|
376
|
+
}
|
377
|
+
return null;
|
378
|
+
} catch (error) {
|
379
|
+
return null;
|
380
|
+
}
|
381
|
+
}
|
382
|
+
function generateVariables(entityName) {
|
383
|
+
const normalized = entityName.toLowerCase();
|
384
|
+
const singular = normalized.endsWith("s") ? normalized.slice(0, -1) : normalized;
|
385
|
+
const toPascalCase = (str) => str.charAt(0).toUpperCase() + str.slice(1);
|
386
|
+
const pascal = toPascalCase(normalized);
|
387
|
+
const pascalSingular = toPascalCase(singular);
|
388
|
+
const camel = normalized;
|
389
|
+
const camelSingular = singular;
|
390
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
391
|
+
const typeSelect = `Select${pascal}`;
|
392
|
+
const typeInsert = `Insert${pascal}`;
|
393
|
+
return {
|
394
|
+
"ENTITY_NAME": normalized,
|
395
|
+
"ENTITY_NAME_PASCAL": pascal,
|
396
|
+
"ENTITY_NAME_CAMEL": camel,
|
397
|
+
"ENTITY_NAME_SINGULAR": camelSingular,
|
398
|
+
"ENTITY_NAME_PASCAL_SINGULAR": pascalSingular,
|
399
|
+
"TYPE_SELECT": typeSelect,
|
400
|
+
"TYPE_INSERT": typeInsert,
|
401
|
+
"TIMESTAMP": timestamp
|
402
|
+
};
|
403
|
+
}
|
404
|
+
function renderTemplate(template, variables) {
|
405
|
+
let result = template;
|
406
|
+
for (const [key, value] of Object.entries(variables)) {
|
407
|
+
const regex = new RegExp(`{{${key}}}`, "g");
|
408
|
+
result = result.replace(regex, value);
|
409
|
+
}
|
410
|
+
return result;
|
411
|
+
}
|
412
|
+
function planFileOperations(cwd, entityName, templatesDir, variables, options, entityExists) {
|
413
|
+
const operations = [];
|
414
|
+
const entityNameNormalized = entityName.toLowerCase();
|
415
|
+
const entitiesBase = join5(cwd, "src", "server", "entities");
|
416
|
+
const routesBase = join5(cwd, "src", "server", "routes", entityNameNormalized);
|
417
|
+
const repositoriesBase = join5(cwd, "src", "server", "repositories");
|
418
|
+
const routesIdBase = join5(routesBase, "[id]");
|
419
|
+
const fileMap = [
|
420
|
+
["entity.template.txt", join5(entitiesBase, `${entityNameNormalized}.ts`)],
|
421
|
+
["contract.template.txt", join5(routesBase, "contract.ts")],
|
422
|
+
["routes-index.template.txt", join5(routesBase, "index.ts")],
|
423
|
+
["routes-id.template.txt", join5(routesIdBase, "index.ts")],
|
424
|
+
["repository.template.txt", join5(repositoriesBase, `${entityNameNormalized}.repository.ts`)]
|
425
|
+
];
|
426
|
+
const onlyFilter = options.only?.split(",").map((f) => f.trim()) || null;
|
427
|
+
for (const [templateName, targetPath] of fileMap) {
|
428
|
+
if (templateName === "entity.template.txt" && entityExists) {
|
429
|
+
continue;
|
430
|
+
}
|
431
|
+
if (onlyFilter) {
|
432
|
+
const fileType = templateName.replace(".template.txt", "").replace("routes-", "");
|
433
|
+
if (!onlyFilter.includes(fileType) && !onlyFilter.includes("routes") && !onlyFilter.includes(fileType.replace("index", "routes"))) {
|
434
|
+
continue;
|
435
|
+
}
|
436
|
+
}
|
437
|
+
const templatePath = join5(templatesDir, templateName);
|
438
|
+
if (!existsSync5(templatePath)) {
|
439
|
+
logger.warn(`Template not found: ${templateName}`);
|
440
|
+
continue;
|
441
|
+
}
|
442
|
+
const templateContent = readFileSync2(templatePath, "utf-8");
|
443
|
+
const renderedContent = renderTemplate(templateContent, variables);
|
444
|
+
operations.push({
|
445
|
+
path: targetPath,
|
446
|
+
content: renderedContent,
|
447
|
+
exists: existsSync5(targetPath)
|
448
|
+
});
|
449
|
+
}
|
450
|
+
return operations;
|
451
|
+
}
|
452
|
+
async function executeOperations(operations, options) {
|
453
|
+
const { force, interactive, dryRun } = options;
|
454
|
+
const existingFiles = operations.filter((op) => op.exists);
|
455
|
+
if (existingFiles.length > 0 && !force) {
|
456
|
+
logger.warn(`${existingFiles.length} file(s) already exist:`);
|
457
|
+
existingFiles.forEach((op) => {
|
458
|
+
logger.warn(` - ${op.path}`);
|
459
|
+
});
|
460
|
+
if (interactive) {
|
461
|
+
const { proceed } = await prompts2({
|
462
|
+
type: "confirm",
|
463
|
+
name: "proceed",
|
464
|
+
message: "Overwrite existing files?",
|
465
|
+
initial: false
|
466
|
+
});
|
467
|
+
if (!proceed) {
|
468
|
+
logger.info("Cancelled.");
|
469
|
+
return;
|
470
|
+
}
|
471
|
+
} else {
|
472
|
+
logger.error("\nFiles already exist. Use --force to overwrite or --interactive for prompts.");
|
473
|
+
process.exit(1);
|
474
|
+
}
|
475
|
+
}
|
476
|
+
if (dryRun) {
|
477
|
+
logger.info("\n[DRY RUN] The following files would be created:");
|
478
|
+
operations.forEach((op) => {
|
479
|
+
const status = op.exists ? chalk3.yellow("[OVERWRITE]") : chalk3.green("[CREATE]");
|
480
|
+
logger.info(` ${status} ${relative(process.cwd(), op.path)}`);
|
481
|
+
});
|
482
|
+
logger.info("\nRun without --dry-run to actually create files.");
|
483
|
+
return;
|
484
|
+
}
|
485
|
+
const spinner = ora2("Generating CRUD files...").start();
|
486
|
+
try {
|
487
|
+
for (const op of operations) {
|
488
|
+
ensureDirSync2(dirname2(op.path));
|
489
|
+
writeFileSync3(op.path, op.content, "utf-8");
|
490
|
+
const status = op.exists ? "Updated" : "Created";
|
491
|
+
spinner.text = `${status}: ${relative(process.cwd(), op.path)}`;
|
492
|
+
}
|
493
|
+
spinner.succeed(`Generated ${operations.length} file(s)`);
|
494
|
+
} catch (error) {
|
495
|
+
spinner.fail("Failed to generate files");
|
496
|
+
throw error;
|
497
|
+
}
|
498
|
+
}
|
499
|
+
var generateCommand = new Command4("generate").alias("g").description("Generate CRUD routes and repository from entity").argument("<entity>", 'Entity name or path to entity file (e.g., "users" or "src/server/entities/users.ts")').option("-f, --force", "Overwrite existing files without confirmation").option("-i, --interactive", "Prompt before overwriting each file").option("--only <files>", "Only generate specific files (comma-separated: contract,repository,routes)").option("--dry-run", "Show what would be generated without creating files").action(async (entityArg, options) => {
|
500
|
+
const cwd = process.cwd();
|
501
|
+
if (!existsSync5(join5(cwd, "src", "server"))) {
|
502
|
+
logger.error("SPFN not initialized. Run `spfn init` first.");
|
503
|
+
process.exit(1);
|
504
|
+
}
|
505
|
+
let entityName;
|
506
|
+
let entityPath = null;
|
507
|
+
let entityExists = false;
|
508
|
+
if (entityArg.includes("/") || entityArg.endsWith(".ts")) {
|
509
|
+
entityPath = entityArg.startsWith("/") ? entityArg : join5(cwd, entityArg);
|
510
|
+
if (!existsSync5(entityPath)) {
|
511
|
+
logger.error(`Entity file not found: ${entityPath}`);
|
512
|
+
process.exit(1);
|
513
|
+
}
|
514
|
+
const extractedName = extractEntityName(entityPath);
|
515
|
+
if (!extractedName) {
|
516
|
+
logger.error("Could not extract entity name from file. Make sure it exports a pgTable.");
|
517
|
+
process.exit(1);
|
518
|
+
}
|
519
|
+
entityName = extractedName;
|
520
|
+
entityExists = true;
|
521
|
+
logger.info(`Detected entity: ${chalk3.cyan(entityName)}`);
|
522
|
+
} else {
|
523
|
+
entityName = entityArg;
|
524
|
+
const possiblePath = join5(cwd, "src", "server", "entities", `${entityName}.ts`);
|
525
|
+
if (existsSync5(possiblePath)) {
|
526
|
+
entityPath = possiblePath;
|
527
|
+
entityExists = true;
|
528
|
+
logger.info(`Found entity file: ${chalk3.cyan(relative(cwd, entityPath))}`);
|
529
|
+
} else {
|
530
|
+
logger.info(`Creating new entity: ${chalk3.cyan(entityName)}`);
|
531
|
+
}
|
532
|
+
}
|
533
|
+
const variables = generateVariables(entityName);
|
534
|
+
const templatesDir = findScriptTemplatesPath();
|
535
|
+
const operations = planFileOperations(cwd, entityName, templatesDir, variables, options, entityExists);
|
536
|
+
if (operations.length === 0) {
|
537
|
+
logger.warn("No files to generate. Check your --only filter or templates directory.");
|
538
|
+
process.exit(0);
|
539
|
+
}
|
540
|
+
await executeOperations(operations, options);
|
541
|
+
if (!options.dryRun) {
|
542
|
+
console.log("\n" + chalk3.green.bold("\u2713 CRUD boilerplate generated successfully!\n"));
|
543
|
+
console.log("Generated files:");
|
544
|
+
operations.forEach((op) => {
|
545
|
+
console.log(" \u2022 " + chalk3.cyan(relative(cwd, op.path)));
|
546
|
+
});
|
547
|
+
if (!entityExists) {
|
548
|
+
console.log("\n" + chalk3.yellow("\u{1F4DD} Next steps:"));
|
549
|
+
console.log(" 1. " + chalk3.cyan(`Edit entities/${entityName}.ts`) + " - Add your custom fields");
|
550
|
+
console.log(" 2. " + chalk3.cyan("spfn db generate") + " - Generate database migration");
|
551
|
+
console.log(" 3. " + chalk3.cyan("spfn db migrate") + " - Run migration");
|
552
|
+
console.log(" 4. Customize routes and test your API");
|
553
|
+
} else {
|
554
|
+
console.log("\n" + chalk3.yellow("\u{1F4DD} Next steps:"));
|
555
|
+
console.log(" 1. Review and customize the generated routes");
|
556
|
+
console.log(" 2. Update repository with custom query methods if needed");
|
557
|
+
console.log(" 3. Test your API endpoints");
|
558
|
+
}
|
559
|
+
}
|
560
|
+
});
|
561
|
+
|
562
|
+
// src/commands/key.ts
|
563
|
+
import { Command as Command5 } from "commander";
|
564
|
+
import { randomBytes } from "crypto";
|
565
|
+
import chalk4 from "chalk";
|
566
|
+
var PRESETS = {
|
567
|
+
"auth-encryption": {
|
568
|
+
bytes: 32,
|
569
|
+
description: "AES-256 encryption key for @spfn/auth",
|
570
|
+
envVar: "SPFN_ENCRYPTION_KEY",
|
571
|
+
usage: "setupAuth({ encryptionKey: ... })"
|
572
|
+
},
|
573
|
+
"nextauth-secret": {
|
574
|
+
bytes: 32,
|
575
|
+
description: "NextAuth.js secret key",
|
576
|
+
envVar: "NEXTAUTH_SECRET",
|
577
|
+
usage: "Used by NextAuth.js for session encryption"
|
578
|
+
},
|
579
|
+
"jwt-secret": {
|
580
|
+
bytes: 64,
|
581
|
+
description: "JWT signing secret (512 bits)",
|
582
|
+
envVar: "JWT_SECRET",
|
583
|
+
usage: "For signing/verifying JWT tokens"
|
584
|
+
},
|
585
|
+
"session-secret": {
|
586
|
+
bytes: 32,
|
587
|
+
description: "Session encryption secret",
|
588
|
+
envVar: "SESSION_SECRET",
|
589
|
+
usage: "For encrypting session data"
|
590
|
+
},
|
591
|
+
"api-key": {
|
592
|
+
bytes: 32,
|
593
|
+
description: "Generic API key",
|
594
|
+
envVar: "API_KEY",
|
595
|
+
usage: "For API authentication"
|
596
|
+
}
|
597
|
+
};
|
598
|
+
function copyToClipboard(text) {
|
599
|
+
try {
|
600
|
+
const { execSync } = __require("child_process");
|
601
|
+
if (process.platform === "darwin") {
|
602
|
+
execSync("pbcopy", { input: text });
|
603
|
+
return true;
|
604
|
+
} else if (process.platform === "linux") {
|
605
|
+
execSync("xclip -selection clipboard", { input: text });
|
606
|
+
return true;
|
607
|
+
} else if (process.platform === "win32") {
|
608
|
+
execSync("clip", { input: text });
|
609
|
+
return true;
|
610
|
+
}
|
611
|
+
return false;
|
612
|
+
} catch (error) {
|
613
|
+
return false;
|
614
|
+
}
|
615
|
+
}
|
616
|
+
function generateSecret(bytes, preset, envVarName, copy) {
|
617
|
+
const key = randomBytes(bytes).toString("hex");
|
618
|
+
const config = preset ? PRESETS[preset] : null;
|
619
|
+
console.log("\n" + chalk4.green.bold("\u2713 Generated secret key:"));
|
620
|
+
if (config) {
|
621
|
+
console.log(chalk4.dim(` ${config.description} (${bytes * 8} bits)`));
|
622
|
+
} else {
|
623
|
+
console.log(chalk4.dim(` ${bytes * 8}-bit secret`));
|
624
|
+
}
|
625
|
+
console.log("\n" + chalk4.cyan(key) + "\n");
|
626
|
+
const varName = envVarName || config?.envVar || "SECRET_KEY";
|
627
|
+
console.log(chalk4.dim("Add to your .env file:"));
|
628
|
+
console.log(chalk4.yellow(`${varName}=${key}
|
629
|
+
`));
|
630
|
+
if (config?.usage) {
|
631
|
+
console.log(chalk4.dim("Usage:"));
|
632
|
+
console.log(chalk4.gray(` ${config.usage}
|
633
|
+
`));
|
634
|
+
}
|
635
|
+
if (copy) {
|
636
|
+
if (copyToClipboard(key)) {
|
637
|
+
console.log(chalk4.green("\u2713 Copied to clipboard!\n"));
|
638
|
+
} else {
|
639
|
+
logger.warn("Could not copy to clipboard");
|
640
|
+
}
|
641
|
+
}
|
642
|
+
}
|
643
|
+
function listPresets() {
|
644
|
+
console.log("\n" + chalk4.bold("Available presets:"));
|
645
|
+
console.log();
|
646
|
+
Object.entries(PRESETS).forEach(([name, config]) => {
|
647
|
+
console.log(` ${chalk4.cyan(name.padEnd(20))} ${chalk4.dim(config.description)}`);
|
648
|
+
console.log(` ${" ".repeat(20)} ${chalk4.gray(`\u2192 ${config.envVar} (${config.bytes * 8} bits)`)}`);
|
649
|
+
console.log();
|
650
|
+
});
|
651
|
+
console.log(chalk4.dim("Usage:"));
|
652
|
+
console.log(chalk4.gray(" spfn key <preset>"));
|
653
|
+
console.log(chalk4.gray(" spfn key auth-encryption --copy"));
|
654
|
+
console.log();
|
655
|
+
}
|
656
|
+
var generateValueCommand = new Command5("generate").alias("gen").description("Generate random value (simple output, no metadata)").option("-b, --bytes <number>", "Number of random bytes", "32").option("-c, --copy", "Copy to clipboard").action((options) => {
|
657
|
+
const bytes = parseInt(options.bytes, 10);
|
658
|
+
if (isNaN(bytes) || bytes < 1 || bytes > 128) {
|
659
|
+
logger.error("Invalid bytes value. Must be between 1 and 128.");
|
660
|
+
process.exit(1);
|
661
|
+
}
|
662
|
+
const value = randomBytes(bytes).toString("hex");
|
663
|
+
console.log(value);
|
664
|
+
if (options.copy) {
|
665
|
+
if (copyToClipboard(value)) {
|
666
|
+
console.error(chalk4.green("\u2713 Copied to clipboard"));
|
667
|
+
} else {
|
668
|
+
console.error(chalk4.yellow("\u26A0 Could not copy to clipboard"));
|
669
|
+
}
|
670
|
+
}
|
671
|
+
});
|
672
|
+
var keyCommand = new Command5("key").alias("k").description("Generate secure random keys and secrets").argument("[preset]", `Preset type (use --list to see all)`).option("-l, --list", "List all available presets").option("-b, --bytes <number>", "Number of random bytes to generate", "32").option("-e, --env <name>", "Environment variable name").option("-c, --copy", "Copy to clipboard").action((preset, options) => {
|
673
|
+
if (options.list) {
|
674
|
+
listPresets();
|
675
|
+
return;
|
676
|
+
}
|
677
|
+
const bytes = parseInt(options.bytes, 10);
|
678
|
+
if (isNaN(bytes) || bytes < 1 || bytes > 128) {
|
679
|
+
logger.error("Invalid bytes value. Must be between 1 and 128.");
|
680
|
+
process.exit(1);
|
681
|
+
}
|
682
|
+
if (preset && !(preset in PRESETS)) {
|
683
|
+
logger.error(`Unknown preset: ${preset}`);
|
684
|
+
console.log("\nAvailable presets:");
|
685
|
+
Object.entries(PRESETS).forEach(([name, config]) => {
|
686
|
+
console.log(` ${chalk4.cyan(name)}: ${config.description}`);
|
687
|
+
});
|
688
|
+
console.log("\nUse " + chalk4.cyan("--list") + " to see detailed information");
|
689
|
+
process.exit(1);
|
690
|
+
}
|
691
|
+
generateSecret(
|
692
|
+
bytes,
|
693
|
+
preset,
|
694
|
+
options.env,
|
695
|
+
options.copy
|
696
|
+
);
|
697
|
+
});
|
698
|
+
keyCommand.addCommand(generateValueCommand);
|
699
|
+
|
700
|
+
// src/commands/db.ts
|
701
|
+
import { existsSync as existsSync6, writeFileSync as writeFileSync4, unlinkSync } from "fs";
|
702
|
+
import { exec } from "child_process";
|
703
|
+
import { promisify } from "util";
|
704
|
+
import chalk5 from "chalk";
|
705
|
+
import ora3 from "ora";
|
706
|
+
var execAsync = promisify(exec);
|
707
|
+
async function runDrizzleCommand(command) {
|
708
|
+
const hasUserConfig = existsSync6("./drizzle.config.ts");
|
709
|
+
const tempConfigPath = `./drizzle.config.${process.pid}.${Date.now()}.temp.ts`;
|
710
|
+
try {
|
711
|
+
const configPath = hasUserConfig ? "./drizzle.config.ts" : tempConfigPath;
|
712
|
+
if (!hasUserConfig) {
|
713
|
+
if (!process.env.DATABASE_URL) {
|
714
|
+
console.error(chalk5.red("\u274C DATABASE_URL not found in environment"));
|
715
|
+
console.log(chalk5.yellow("\n\u{1F4A1} Tip: Add DATABASE_URL to your .env file"));
|
716
|
+
process.exit(1);
|
717
|
+
}
|
718
|
+
const { generateDrizzleConfigFile } = await import("@spfn/core");
|
719
|
+
const configContent = generateDrizzleConfigFile();
|
720
|
+
writeFileSync4(tempConfigPath, configContent);
|
721
|
+
console.log(chalk5.dim("Using auto-generated Drizzle config\n"));
|
722
|
+
}
|
723
|
+
const fullCommand = `drizzle-kit ${command} --config=${configPath}`;
|
724
|
+
const { stdout, stderr } = await execAsync(fullCommand);
|
725
|
+
if (stdout) {
|
726
|
+
console.log(stdout);
|
727
|
+
}
|
728
|
+
if (stderr) {
|
729
|
+
console.error(stderr);
|
730
|
+
}
|
731
|
+
} finally {
|
732
|
+
if (!hasUserConfig && existsSync6(tempConfigPath)) {
|
733
|
+
unlinkSync(tempConfigPath);
|
734
|
+
}
|
735
|
+
}
|
736
|
+
}
|
737
|
+
async function dbGenerate() {
|
738
|
+
const spinner = ora3("Generating database migrations...").start();
|
739
|
+
try {
|
740
|
+
spinner.stop();
|
741
|
+
await runDrizzleCommand("generate");
|
742
|
+
console.log(chalk5.green("\u2705 Migrations generated successfully"));
|
743
|
+
} catch (error) {
|
744
|
+
spinner.fail("Failed to generate migrations");
|
745
|
+
console.error(chalk5.red(error instanceof Error ? error.message : "Unknown error"));
|
746
|
+
process.exit(1);
|
747
|
+
}
|
748
|
+
}
|
749
|
+
async function dbPush() {
|
750
|
+
const spinner = ora3("Pushing schema changes to database...").start();
|
751
|
+
try {
|
752
|
+
spinner.stop();
|
753
|
+
await runDrizzleCommand("push");
|
754
|
+
console.log(chalk5.green("\u2705 Schema pushed successfully"));
|
755
|
+
} catch (error) {
|
756
|
+
spinner.fail("Failed to push schema");
|
757
|
+
console.error(chalk5.red(error instanceof Error ? error.message : "Unknown error"));
|
758
|
+
process.exit(1);
|
759
|
+
}
|
760
|
+
}
|
761
|
+
async function dbMigrate() {
|
762
|
+
const spinner = ora3("Running database migrations...").start();
|
763
|
+
try {
|
764
|
+
spinner.stop();
|
765
|
+
await runDrizzleCommand("migrate");
|
766
|
+
console.log(chalk5.green("\u2705 Migrations applied successfully"));
|
767
|
+
} catch (error) {
|
768
|
+
spinner.fail("Failed to run migrations");
|
769
|
+
console.error(chalk5.red(error instanceof Error ? error.message : "Unknown error"));
|
770
|
+
process.exit(1);
|
771
|
+
}
|
772
|
+
}
|
773
|
+
async function dbStudio(port = 4983) {
|
774
|
+
console.log(chalk5.blue("\u{1F3A8} Opening Drizzle Studio...\n"));
|
775
|
+
try {
|
776
|
+
await runDrizzleCommand(`studio --port ${port}`);
|
777
|
+
} catch (error) {
|
778
|
+
console.error(chalk5.red("\u274C Failed to start Drizzle Studio"));
|
779
|
+
console.error(chalk5.red(error instanceof Error ? error.message : "Unknown error"));
|
780
|
+
process.exit(1);
|
781
|
+
}
|
782
|
+
}
|
783
|
+
async function dbDrop() {
|
784
|
+
console.log(chalk5.yellow("\u26A0\uFE0F WARNING: This will drop all tables in your database!"));
|
785
|
+
const spinner = ora3("Dropping all tables...").start();
|
786
|
+
try {
|
787
|
+
spinner.stop();
|
788
|
+
await runDrizzleCommand("drop");
|
789
|
+
console.log(chalk5.green("\u2705 All tables dropped"));
|
790
|
+
} catch (error) {
|
791
|
+
spinner.fail("Failed to drop tables");
|
792
|
+
console.error(chalk5.red(error instanceof Error ? error.message : "Unknown error"));
|
793
|
+
process.exit(1);
|
794
|
+
}
|
795
|
+
}
|
796
|
+
async function dbCheck() {
|
797
|
+
const spinner = ora3("Checking database connection...").start();
|
798
|
+
try {
|
799
|
+
await runDrizzleCommand("check");
|
800
|
+
spinner.succeed("Database connection OK");
|
801
|
+
} catch (error) {
|
802
|
+
spinner.fail("Database connection failed");
|
803
|
+
console.error(chalk5.red(error instanceof Error ? error.message : "Unknown error"));
|
804
|
+
process.exit(1);
|
805
|
+
}
|
806
|
+
}
|
807
|
+
|
808
|
+
// src/index.ts
|
809
|
+
var program = new Command6();
|
810
|
+
program.name("spfn").description("SPFN CLI - The Missing Backend for Next.js").version("0.1.0");
|
811
|
+
program.addCommand(initCommand);
|
812
|
+
program.addCommand(devCommand);
|
813
|
+
program.addCommand(startCommand);
|
814
|
+
program.addCommand(generateCommand);
|
815
|
+
program.addCommand(keyCommand);
|
816
|
+
var dbCommand = new Command6("db").description("Database management commands (wraps Drizzle Kit)");
|
817
|
+
dbCommand.command("generate").alias("g").description("Generate database migrations from schema changes").action(dbGenerate);
|
818
|
+
dbCommand.command("push").description("Push schema changes directly to database (no migrations)").action(dbPush);
|
819
|
+
dbCommand.command("migrate").alias("m").description("Run pending migrations").action(dbMigrate);
|
820
|
+
dbCommand.command("studio").description("Open Drizzle Studio (database GUI)").option("-p, --port <port>", "Studio port", "4983").action((options) => dbStudio(Number(options.port)));
|
821
|
+
dbCommand.command("drop").description("Drop all database tables (\u26A0\uFE0F dangerous!)").action(dbDrop);
|
822
|
+
dbCommand.command("check").description("Check database connection").action(dbCheck);
|
823
|
+
program.addCommand(dbCommand);
|
824
|
+
async function run() {
|
825
|
+
await program.parseAsync(process.argv);
|
826
|
+
}
|
827
|
+
export {
|
828
|
+
run
|
829
|
+
};
|