spfn 0.2.0-beta.61 → 0.2.0-beta.63
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/README.md +57 -17
- package/dist/index.js +267 -78
- package/dist/templates/README.md +24 -11
- package/dist/templates/modes/full/src/app/auth/callback/page.tsx +1 -0
- package/dist/templates/modes/full/src/app/login/page.tsx +68 -0
- package/dist/templates/modes/full/src/i18n/catalogs.ts +16 -0
- package/dist/templates/modes/full/src/i18n/server.ts +9 -0
- package/dist/templates/modes/full/src/server/config/env.config.ts +23 -0
- package/dist/templates/modes/full/src/server/mcp.ts +75 -0
- package/dist/templates/modes/full/src/server/router.ts +32 -0
- package/dist/templates/modes/full/src/server/routes/examples.ts +94 -0
- package/dist/templates/modes/full/src/server/routes/health.ts +9 -0
- package/dist/templates/modes/full/src/server/routes/root.ts +15 -0
- package/dist/templates/modes/full/src/server/server.config.ts +14 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
# spfn — the SPFN CLI (backend layer for Next.js)
|
|
2
2
|
|
|
3
|
-
`spfn`
|
|
4
|
-
It
|
|
5
|
-
|
|
3
|
+
`spfn` takes a Next.js idea from prototype to production with a consistent full-stack
|
|
4
|
+
architecture. It can scaffold either a core-only backend or a production baseline with
|
|
5
|
+
authentication, internationalization, and an agent-facing MCP endpoint, then runs the
|
|
6
|
+
dev/build/start lifecycle, database tooling, RPC codegen, and environment validation.
|
|
6
7
|
|
|
7
8
|
> Beta: install with the `@beta` tag (`spfn@beta`). The binary is `spfn`.
|
|
8
9
|
|
|
@@ -18,20 +19,25 @@ pnpm dlx spfn@beta <command>
|
|
|
18
19
|
Or add it as a project dependency (`spfn init`/`spfn create` do this for you), then
|
|
19
20
|
call it via `pnpm spfn <command>` / `npm run spfn:<script>`.
|
|
20
21
|
|
|
21
|
-
Requirements: Node.js 18.18
|
|
22
|
+
Requirements: Node.js 18.18+ for bare mode, Node.js 20+ for full mode's MCP server,
|
|
23
|
+
Next.js 15+ (App Router, `src/` dir), PostgreSQL (Redis optional).
|
|
22
24
|
|
|
23
25
|
## Usage
|
|
24
26
|
|
|
25
27
|
```bash
|
|
26
|
-
#
|
|
27
|
-
npx spfn@beta create my-app
|
|
28
|
+
# Prototype-to-Production baseline: core + auth + i18n + MCP
|
|
29
|
+
npx spfn@beta create my-app --mode full
|
|
28
30
|
cd my-app
|
|
29
31
|
docker compose up -d # Postgres + Redis
|
|
30
|
-
# .env.local & .env.server are generated —
|
|
32
|
+
# .env.local & .env.server are generated — keep both gitignored
|
|
33
|
+
pnpm spfn db migrate # Apply auth migrations
|
|
31
34
|
pnpm spfn:dev # Next.js :3790 + SPFN API :8790
|
|
32
35
|
|
|
36
|
+
# Core-only full-stack skeleton
|
|
37
|
+
npx spfn@beta create my-api --mode bare
|
|
38
|
+
|
|
33
39
|
# Add SPFN to an existing Next.js project
|
|
34
|
-
npx spfn@beta init
|
|
40
|
+
npx spfn@beta init --mode full
|
|
35
41
|
```
|
|
36
42
|
|
|
37
43
|
The package manager is auto-detected (pnpm > yarn > bun > npm) from lockfiles; override
|
|
@@ -49,24 +55,32 @@ Registered top-level commands: `create`, `init`, `add`, `dev`, `build`, `start`,
|
|
|
49
55
|
Runs `create-next-app` with SPFN-recommended flags (TypeScript, App Router, `src/`,
|
|
50
56
|
Tailwind, import alias `@/*`, no ESLint), sets up SVGR icons, then runs `init`.
|
|
51
57
|
|
|
58
|
+
Choose `full` for the recommended Prototype-to-Production baseline or `bare` for the
|
|
59
|
+
historical core-only skeleton. Without `--mode`, interactive runs show a mode selector
|
|
60
|
+
with `full` recommended. For backward compatibility, non-interactive `--yes` runs without
|
|
61
|
+
an explicit mode continue to generate `bare`; automation that wants full should always
|
|
62
|
+
pass `--mode full`.
|
|
63
|
+
|
|
52
64
|
| Option | Description |
|
|
53
65
|
|--------|-------------|
|
|
54
66
|
| `--pm <manager>` | Force package manager: `npm` \| `pnpm` \| `yarn` \| `bun` |
|
|
55
67
|
| `--shadcn` | Also run `shadcn init` |
|
|
68
|
+
| `--mode <mode>` | `bare` (core only) \| `full` (core, auth, i18n, MCP) |
|
|
56
69
|
| `--skip-install` | Skip dependency install |
|
|
57
70
|
| `--skip-git` | Skip `git init` |
|
|
58
71
|
| `-y, --yes` | Skip prompts, use defaults |
|
|
59
72
|
|
|
60
73
|
### `spfn init`
|
|
61
74
|
|
|
62
|
-
Adds SPFN to an existing Next.js project: copies the server templates, wires the RPC
|
|
75
|
+
Adds SPFN to an existing Next.js project: copies the selected server templates, wires the RPC
|
|
63
76
|
proxy route, Docker files, deploy + codegen config, updates `package.json` scripts/deps,
|
|
64
|
-
and installs.
|
|
77
|
+
and installs. Full mode also adds the `/_auth/:path*` → SPFN API rewrite to
|
|
65
78
|
`next.config` (OAuth callbacks return to the app origin; merged manually if a `rewrites()`
|
|
66
79
|
already exists). See [Scaffold structure](#scaffold-structure) for what lands on disk.
|
|
67
80
|
|
|
68
81
|
| Option | Description |
|
|
69
82
|
|--------|-------------|
|
|
83
|
+
| `--mode <mode>` | `bare` (core only) \| `full` (core, auth, i18n, MCP) |
|
|
70
84
|
| `-y, --yes` | Skip prompts, use defaults |
|
|
71
85
|
|
|
72
86
|
Generated projects pin `drizzle-orm` and `drizzle-kit` to `1.0.0-rc.4`, matching
|
|
@@ -280,7 +294,7 @@ Install and configure SVGR for SVG-as-component imports (Next.js only).
|
|
|
280
294
|
|
|
281
295
|
## Scaffold structure
|
|
282
296
|
|
|
283
|
-
|
|
297
|
+
Both modes produce the core full-stack skeleton:
|
|
284
298
|
|
|
285
299
|
```
|
|
286
300
|
src/
|
|
@@ -303,13 +317,37 @@ docker-compose.production.yml
|
|
|
303
317
|
Dockerfile, .dockerignore
|
|
304
318
|
next.config.ts # patched when auth is enabled: /_auth/:path* rewrite → SPFN API
|
|
305
319
|
.env.example # committed reference — every key, placeholder values
|
|
306
|
-
.env.local # generated, gitignored (Next.js
|
|
320
|
+
.env.local # generated, gitignored (values loaded by Next.js)
|
|
307
321
|
.env.server # generated, gitignored (server secrets: DB, cache)
|
|
308
322
|
```
|
|
309
323
|
|
|
324
|
+
Full mode overlays the Prototype-to-Production baseline:
|
|
325
|
+
|
|
326
|
+
```
|
|
327
|
+
src/
|
|
328
|
+
app/login/page.tsx # provider login starter UI
|
|
329
|
+
app/auth/callback/page.tsx # OAuth session handoff
|
|
330
|
+
i18n/catalogs.ts # application-owned en/ko starter messages
|
|
331
|
+
i18n/server.ts # configured server-side i18n registry
|
|
332
|
+
server/mcp.ts # authenticated /mcp endpoint + starter app_status tool
|
|
333
|
+
server/router.ts # authRouter + mcpRouter + global authenticate
|
|
334
|
+
server/server.config.ts # createAuthLifecycle + i18n startup
|
|
335
|
+
next.config.ts # /_auth/* callback rewrite
|
|
336
|
+
.env.local # generated auth session secret (gitignored)
|
|
337
|
+
.env.server # auth keyring + MCP operator key (gitignored)
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
The full RPC proxy imports the auth interceptor and merges `authRouteMap`. Internal auth
|
|
341
|
+
and MCP keys are generated with cryptographic randomness in ignored local env files;
|
|
342
|
+
`.env.example` contains placeholders only. Add only the provider keys you use, then run
|
|
343
|
+
`pnpm spfn db migrate`. The starter MCP endpoint accepts `SPFN_MCP_API_KEY` as a Bearer
|
|
344
|
+
token for first-party operation; replace that validator with OAuth before third-party access.
|
|
345
|
+
|
|
310
346
|
`init` also patches `package.json` (scripts: `spfn:dev`, `spfn:server`, `spfn:next`,
|
|
311
347
|
`spfn:build`, `spfn:start`, `codegen`; deps: `@spfn/core`, `spfn`, `drizzle-orm`,
|
|
312
|
-
`@sinclair/typebox`, `concurrently`, etc
|
|
348
|
+
`@sinclair/typebox`, `concurrently`, etc.; full also adds `@spfn/auth`, `@spfn/i18n`,
|
|
349
|
+
`@spfn/mcp`, auth's `@spfn/notification` peer, and a Node `>=20.0.0` engine when the
|
|
350
|
+
existing range still permits older Node versions), excludes `src/server` from the root
|
|
313
351
|
`tsconfig.json` (Vercel compat), and adds `.spfn/`, `.env.local`, `.env.server` to
|
|
314
352
|
`.gitignore`.
|
|
315
353
|
|
|
@@ -387,8 +425,9 @@ type ships from `spfn` (`@type {import('spfn').SpfnConfig}`).
|
|
|
387
425
|
|
|
388
426
|
## Pitfalls
|
|
389
427
|
|
|
390
|
-
- **`.env.server` is gitignored and server-only.** Put DB/secret values there,
|
|
391
|
-
|
|
428
|
+
- **`.env.server` is gitignored and server-only.** Put backend-only DB/secret values there,
|
|
429
|
+
not in `.env` (committed). Full mode's session-cookie secret is the intentional exception:
|
|
430
|
+
it lives in gitignored `.env.local` because Next.js must encrypt the cookie. There is no
|
|
392
431
|
`.env.server.local`. `spfn init` generates `.env.server`; put DB/secret values there.
|
|
393
432
|
Load order is the standard dotenv chain ending with `.env.server`.
|
|
394
433
|
- **Never commit secrets in `spfn.config.js`.** It's checked into Git; its `env` block is
|
|
@@ -406,6 +445,9 @@ type ships from `spfn` (`@type {import('spfn').SpfnConfig}`).
|
|
|
406
445
|
- **Package manager is auto-detected from lockfiles.** If detection is wrong (e.g. mixed
|
|
407
446
|
lockfiles), pass `--pm` to `create`. In a pnpm workspace, `create` installs from the
|
|
408
447
|
workspace root, not the new project dir.
|
|
448
|
+
- **Make scaffold mode explicit in automation.** Interactive runs recommend `full`, while
|
|
449
|
+
historical `--yes` calls without `--mode` remain `bare`. Pass `--mode full` or
|
|
450
|
+
`--mode bare` so scripts state their intended architecture.
|
|
409
451
|
- **Regenerate the route map after route changes outside dev.** If
|
|
410
452
|
`src/generated/route-map.ts` is missing or stale, run `spfn codegen run` — the RPC proxy
|
|
411
453
|
depends on it.
|
|
@@ -416,5 +458,3 @@ type ships from `spfn` (`@type {import('spfn').SpfnConfig}`).
|
|
|
416
458
|
|
|
417
459
|
- `@spfn/core` — server, route DSL, codegen, db, client runtime.
|
|
418
460
|
- Project root README — framework overview and getting started.
|
|
419
|
-
</content>
|
|
420
|
-
</invoke>
|
package/dist/index.js
CHANGED
|
@@ -87,6 +87,44 @@ var init_messages = __esm({
|
|
|
87
87
|
}
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
+
// src/commands/init/mode.ts
|
|
91
|
+
import prompts from "prompts";
|
|
92
|
+
async function selectScaffoldMode(options) {
|
|
93
|
+
if (options.mode) {
|
|
94
|
+
return options.mode;
|
|
95
|
+
}
|
|
96
|
+
if (options.yes) {
|
|
97
|
+
return "bare";
|
|
98
|
+
}
|
|
99
|
+
const { selectedMode } = await prompts({
|
|
100
|
+
type: "select",
|
|
101
|
+
name: "selectedMode",
|
|
102
|
+
message: "Which SPFN scaffold do you want?",
|
|
103
|
+
choices: [
|
|
104
|
+
{
|
|
105
|
+
title: "full (recommended)",
|
|
106
|
+
description: "Core, auth, i18n, and MCP \u2014 ready for Prototype to Production",
|
|
107
|
+
value: "full"
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
title: "bare",
|
|
111
|
+
description: "Core-only full-stack skeleton",
|
|
112
|
+
value: "bare"
|
|
113
|
+
}
|
|
114
|
+
],
|
|
115
|
+
initial: 0
|
|
116
|
+
});
|
|
117
|
+
if (!selectedMode) {
|
|
118
|
+
process.exit(0);
|
|
119
|
+
}
|
|
120
|
+
return selectedMode;
|
|
121
|
+
}
|
|
122
|
+
var init_mode = __esm({
|
|
123
|
+
"src/commands/init/mode.ts"() {
|
|
124
|
+
"use strict";
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
90
128
|
// src/commands/setup.ts
|
|
91
129
|
var setup_exports = {};
|
|
92
130
|
__export(setup_exports, {
|
|
@@ -387,7 +425,7 @@ var init_setup = __esm({
|
|
|
387
425
|
// src/commands/init/steps/validate.ts
|
|
388
426
|
import { existsSync as existsSync3 } from "fs";
|
|
389
427
|
import { join as join3 } from "path";
|
|
390
|
-
import
|
|
428
|
+
import prompts2 from "prompts";
|
|
391
429
|
async function validateProject(cwd, skipPrompts) {
|
|
392
430
|
const packageJsonPath = join3(cwd, "package.json");
|
|
393
431
|
if (!existsSync3(packageJsonPath)) {
|
|
@@ -401,7 +439,7 @@ async function validateProject(cwd, skipPrompts) {
|
|
|
401
439
|
if (!hasNext) {
|
|
402
440
|
logger.warn("Next.js not detected in dependencies.");
|
|
403
441
|
if (!skipPrompts) {
|
|
404
|
-
const { proceed } = await
|
|
442
|
+
const { proceed } = await prompts2(
|
|
405
443
|
{
|
|
406
444
|
type: "confirm",
|
|
407
445
|
name: "proceed",
|
|
@@ -418,7 +456,7 @@ async function validateProject(cwd, skipPrompts) {
|
|
|
418
456
|
if (existsSync3(join3(cwd, "src", "server"))) {
|
|
419
457
|
logger.warn("src/server directory already exists.");
|
|
420
458
|
if (!skipPrompts) {
|
|
421
|
-
const { overwrite } = await
|
|
459
|
+
const { overwrite } = await prompts2(
|
|
422
460
|
{
|
|
423
461
|
type: "confirm",
|
|
424
462
|
name: "overwrite",
|
|
@@ -432,19 +470,7 @@ async function validateProject(cwd, skipPrompts) {
|
|
|
432
470
|
}
|
|
433
471
|
}
|
|
434
472
|
}
|
|
435
|
-
|
|
436
|
-
if (!skipPrompts) {
|
|
437
|
-
const { auth } = await prompts(
|
|
438
|
-
{
|
|
439
|
-
type: "confirm",
|
|
440
|
-
name: "auth",
|
|
441
|
-
message: "Include authentication (@spfn/auth)?",
|
|
442
|
-
initial: true
|
|
443
|
-
}
|
|
444
|
-
);
|
|
445
|
-
includeAuth = auth;
|
|
446
|
-
}
|
|
447
|
-
return { packageJson, packageJsonPath, includeAuth };
|
|
473
|
+
return { packageJson, packageJsonPath };
|
|
448
474
|
}
|
|
449
475
|
var init_validate = __esm({
|
|
450
476
|
"src/commands/init/steps/validate.ts"() {
|
|
@@ -470,6 +496,10 @@ function findTemplatesPath() {
|
|
|
470
496
|
if (existsSync4(devPath)) {
|
|
471
497
|
return devPath;
|
|
472
498
|
}
|
|
499
|
+
const sourcePath = join4(__dirname, "..", "..", "..", "..", "templates");
|
|
500
|
+
if (existsSync4(sourcePath)) {
|
|
501
|
+
return sourcePath;
|
|
502
|
+
}
|
|
473
503
|
throw new Error("Templates directory not found. Please rebuild the package.");
|
|
474
504
|
}
|
|
475
505
|
var __dirname;
|
|
@@ -482,10 +512,10 @@ var init_templates = __esm({
|
|
|
482
512
|
|
|
483
513
|
// src/commands/init/steps/server-structure.ts
|
|
484
514
|
import { existsSync as existsSync5 } from "fs";
|
|
485
|
-
import { join as join5 } from "path";
|
|
515
|
+
import { join as join5, sep } from "path";
|
|
486
516
|
import ora2 from "ora";
|
|
487
517
|
import fse2 from "fs-extra";
|
|
488
|
-
async function setupServerStructure(cwd) {
|
|
518
|
+
async function setupServerStructure(cwd, mode) {
|
|
489
519
|
const spinner = ora2("Setting up server structure...").start();
|
|
490
520
|
try {
|
|
491
521
|
const templatesDir = findTemplatesPath();
|
|
@@ -511,6 +541,27 @@ async function setupServerStructure(cwd) {
|
|
|
511
541
|
copySync(envConfigTemplate, envConfigTarget);
|
|
512
542
|
logger.success("Created src/server/config/env.config.ts (environment management)");
|
|
513
543
|
}
|
|
544
|
+
if (mode === "full") {
|
|
545
|
+
const fullTemplateDir = join5(templatesDir, "modes", "full");
|
|
546
|
+
if (!existsSync5(fullTemplateDir)) {
|
|
547
|
+
throw new Error(`Full scaffold templates not found at: ${fullTemplateDir}`);
|
|
548
|
+
}
|
|
549
|
+
const fullAppTemplateDir = join5(fullTemplateDir, "src", "app");
|
|
550
|
+
const fullI18nTemplateDir = join5(fullTemplateDir, "src", "i18n");
|
|
551
|
+
const mergeOnlyDirs = [fullAppTemplateDir, fullI18nTemplateDir];
|
|
552
|
+
copySync(fullTemplateDir, cwd, {
|
|
553
|
+
filter: (source) => !mergeOnlyDirs.some((directory) => source === directory || source.startsWith(`${directory}${sep}`))
|
|
554
|
+
});
|
|
555
|
+
const appTargetDir = existsSync5(join5(cwd, "src", "app")) ? join5(cwd, "src", "app") : join5(cwd, "app");
|
|
556
|
+
copySync(fullAppTemplateDir, appTargetDir, {
|
|
557
|
+
overwrite: false,
|
|
558
|
+
errorOnExist: false
|
|
559
|
+
});
|
|
560
|
+
copySync(fullI18nTemplateDir, join5(cwd, "src", "i18n"), {
|
|
561
|
+
overwrite: false,
|
|
562
|
+
errorOnExist: false
|
|
563
|
+
});
|
|
564
|
+
}
|
|
514
565
|
spinner.succeed("Server structure created");
|
|
515
566
|
} catch (error) {
|
|
516
567
|
spinner.fail("Failed to create server structure");
|
|
@@ -551,8 +602,10 @@ async function setupApiProxy(cwd, includeAuth) {
|
|
|
551
602
|
return;
|
|
552
603
|
}
|
|
553
604
|
ensureDirSync3(rpcDir);
|
|
554
|
-
const
|
|
605
|
+
const authImports = includeAuth ? `import '@spfn/auth/nextjs/api';
|
|
606
|
+
import { authRouteMap } from '@spfn/auth';
|
|
555
607
|
` : "";
|
|
608
|
+
const proxyRouteMap = includeAuth ? "{ ...routeMap, ...authRouteMap }" : "routeMap";
|
|
556
609
|
const routeContent = `/**
|
|
557
610
|
* SPFN RPC Proxy
|
|
558
611
|
*
|
|
@@ -566,10 +619,10 @@ async function setupApiProxy(cwd, includeAuth) {
|
|
|
566
619
|
* Run \`spfn codegen run\` if route-map.ts is missing.
|
|
567
620
|
*/
|
|
568
621
|
|
|
569
|
-
${
|
|
622
|
+
${authImports}import { routeMap } from '@/generated/route-map';
|
|
570
623
|
import { createRpcProxy } from '@spfn/core/nextjs/server';
|
|
571
624
|
|
|
572
|
-
export const { GET, POST } = createRpcProxy({ routeMap });
|
|
625
|
+
export const { GET, POST } = createRpcProxy({ routeMap: ${proxyRouteMap} });
|
|
573
626
|
`;
|
|
574
627
|
writeFileSync2(rpcRoutePath, routeContent);
|
|
575
628
|
const relativePath = rpcRoutePath.replace(cwd + "/", "");
|
|
@@ -869,7 +922,7 @@ var init_deployment_config = __esm({
|
|
|
869
922
|
|
|
870
923
|
// src/utils/version.ts
|
|
871
924
|
function getCliVersion() {
|
|
872
|
-
return "0.2.0-beta.
|
|
925
|
+
return "0.2.0-beta.63";
|
|
873
926
|
}
|
|
874
927
|
function getTagFromVersion(version) {
|
|
875
928
|
const match = version.match(/-([a-z]+)\./i);
|
|
@@ -888,7 +941,7 @@ var init_version = __esm({
|
|
|
888
941
|
import ora3 from "ora";
|
|
889
942
|
import { execa as execa2 } from "execa";
|
|
890
943
|
import fse7 from "fs-extra";
|
|
891
|
-
async function setupPackageJson(cwd, packageJsonPath, packageJson, packageManager,
|
|
944
|
+
async function setupPackageJson(cwd, packageJsonPath, packageJson, packageManager, mode) {
|
|
892
945
|
const spinner = ora3("Updating package.json...").start();
|
|
893
946
|
packageJson.dependencies = packageJson.dependencies || {};
|
|
894
947
|
packageJson.devDependencies = packageJson.devDependencies || {};
|
|
@@ -902,8 +955,19 @@ async function setupPackageJson(cwd, packageJsonPath, packageJson, packageManage
|
|
|
902
955
|
packageJson.dependencies["pg"] = "^8.16.3";
|
|
903
956
|
packageJson.dependencies["spfn"] = spfnTag;
|
|
904
957
|
packageJson.dependencies["concurrently"] = "^9.2.1";
|
|
905
|
-
if (
|
|
958
|
+
if (mode === "full") {
|
|
906
959
|
packageJson.dependencies["@spfn/auth"] = spfnTag;
|
|
960
|
+
packageJson.dependencies["@spfn/i18n"] = spfnTag;
|
|
961
|
+
packageJson.dependencies["@spfn/mcp"] = spfnTag;
|
|
962
|
+
packageJson.dependencies["@spfn/notification"] = spfnTag;
|
|
963
|
+
packageJson.engines = packageJson.engines || {};
|
|
964
|
+
const existingNodeRange = packageJson.engines.node;
|
|
965
|
+
if (!existingNodeRange || !requiresNode20OrNewer(existingNodeRange)) {
|
|
966
|
+
packageJson.engines.node = ">=20.0.0";
|
|
967
|
+
if (existingNodeRange) {
|
|
968
|
+
logger.warn(`Updated engines.node from "${existingNodeRange}" to ">=20.0.0" because full mode includes @spfn/mcp`);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
907
971
|
}
|
|
908
972
|
packageJson.devDependencies["@types/node"] = "^20.11.0";
|
|
909
973
|
packageJson.devDependencies["tsx"] = "^4.20.6";
|
|
@@ -935,6 +999,16 @@ async function setupPackageJson(cwd, packageJsonPath, packageJson, packageManage
|
|
|
935
999
|
process.exit(1);
|
|
936
1000
|
}
|
|
937
1001
|
}
|
|
1002
|
+
function requiresNode20OrNewer(range) {
|
|
1003
|
+
return range.split("||").map((alternative) => alternative.trim()).every((alternative) => {
|
|
1004
|
+
const lowerBound = alternative.match(/^(?:>=|>|\^|~|=)?\s*v?(\d+)/);
|
|
1005
|
+
if (!lowerBound) {
|
|
1006
|
+
return false;
|
|
1007
|
+
}
|
|
1008
|
+
const major = Number(lowerBound[1]);
|
|
1009
|
+
return major >= 20;
|
|
1010
|
+
});
|
|
1011
|
+
}
|
|
938
1012
|
var writeFileSync6;
|
|
939
1013
|
var init_package = __esm({
|
|
940
1014
|
"src/commands/init/steps/package.ts"() {
|
|
@@ -1041,14 +1115,70 @@ var init_env_file = __esm({
|
|
|
1041
1115
|
|
|
1042
1116
|
// src/commands/init/steps/config-files.ts
|
|
1043
1117
|
import { existsSync as existsSync11, readFileSync as readFileSync5 } from "fs";
|
|
1118
|
+
import { randomBytes } from "crypto";
|
|
1119
|
+
import { spawnSync } from "child_process";
|
|
1044
1120
|
import { join as join11 } from "path";
|
|
1045
1121
|
import fse8 from "fs-extra";
|
|
1122
|
+
function envExampleTemplate(mode) {
|
|
1123
|
+
return withPlaceholderCreds(`# Example environment \u2014 committed reference for the variables SPFN uses.
|
|
1124
|
+
# Real values live in .env.local (Next.js) and .env.server (backend secrets),
|
|
1125
|
+
# both gitignored. This file documents the keys; it is not loaded by anything.
|
|
1126
|
+
|
|
1127
|
+
${ENV_LOCAL_TEMPLATE}${mode === "full" ? FULL_ENV_LOCAL_EXAMPLE : ""}
|
|
1128
|
+
${ENV_SERVER_TEMPLATE}${mode === "full" ? FULL_ENV_SERVER_EXAMPLE : ""}`);
|
|
1129
|
+
}
|
|
1130
|
+
function envServerExampleTemplate(mode) {
|
|
1131
|
+
return withPlaceholderCreds(
|
|
1132
|
+
`${ENV_SERVER_TEMPLATE}${mode === "full" ? FULL_ENV_SERVER_EXAMPLE : ""}`
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
function envLocalTemplate(mode) {
|
|
1136
|
+
if (mode === "bare") {
|
|
1137
|
+
return ENV_LOCAL_TEMPLATE;
|
|
1138
|
+
}
|
|
1139
|
+
return `${ENV_LOCAL_TEMPLATE}
|
|
1140
|
+
# Full scaffold: auth session encryption (Next.js server only; never NEXT_PUBLIC_*)
|
|
1141
|
+
SPFN_AUTH_SESSION_SECRET=${randomSecret("base64url")}
|
|
1142
|
+
SPFN_AUTH_SESSION_TTL=7d
|
|
1143
|
+
NEXT_PUBLIC_SPFN_APP_URL=http://localhost:3790
|
|
1144
|
+
`;
|
|
1145
|
+
}
|
|
1146
|
+
function envLocalExampleTemplate(mode) {
|
|
1147
|
+
return `${ENV_LOCAL_TEMPLATE}${mode === "full" ? FULL_ENV_LOCAL_EXAMPLE : ""}`;
|
|
1148
|
+
}
|
|
1149
|
+
function envServerTemplate(mode) {
|
|
1150
|
+
if (mode === "bare") {
|
|
1151
|
+
return ENV_SERVER_TEMPLATE;
|
|
1152
|
+
}
|
|
1153
|
+
return `${ENV_SERVER_TEMPLATE}
|
|
1154
|
+
# Full scaffold: authentication
|
|
1155
|
+
SPFN_AUTH_VERIFICATION_TOKEN_SECRET=${randomSecret("base64url")}
|
|
1156
|
+
SPFN_AUTH_TOKEN_ENCRYPTION_KEYS=v1:${randomSecret("base64")}
|
|
1157
|
+
|
|
1158
|
+
# Enable the social providers you need.
|
|
1159
|
+
# SPFN_AUTH_GOOGLE_CLIENT_ID=your-google-client-id
|
|
1160
|
+
# SPFN_AUTH_GOOGLE_CLIENT_SECRET=your-google-client-secret
|
|
1161
|
+
# SPFN_AUTH_GITHUB_CLIENT_ID=your-github-client-id
|
|
1162
|
+
# SPFN_AUTH_GITHUB_CLIENT_SECRET=your-github-client-secret
|
|
1163
|
+
# SPFN_AUTH_KAKAO_CLIENT_ID=your-kakao-rest-api-key
|
|
1164
|
+
# SPFN_AUTH_KAKAO_CLIENT_SECRET=your-kakao-client-secret
|
|
1165
|
+
# SPFN_AUTH_NAVER_CLIENT_ID=your-naver-client-id
|
|
1166
|
+
# SPFN_AUTH_NAVER_CLIENT_SECRET=your-naver-client-secret
|
|
1167
|
+
|
|
1168
|
+
# Full scaffold: MCP operator endpoint
|
|
1169
|
+
SPFN_MCP_URL=http://localhost:8790
|
|
1170
|
+
SPFN_MCP_API_KEY=${randomSecret("base64url")}
|
|
1171
|
+
`;
|
|
1172
|
+
}
|
|
1173
|
+
function randomSecret(encoding) {
|
|
1174
|
+
return randomBytes(32).toString(encoding);
|
|
1175
|
+
}
|
|
1046
1176
|
function withPlaceholderCreds(text) {
|
|
1047
1177
|
return text.replace(/(postgresql:\/\/)[^@\s/]+@/g, "$1user:password@");
|
|
1048
1178
|
}
|
|
1049
|
-
async function setupConfigFiles(cwd) {
|
|
1179
|
+
async function setupConfigFiles(cwd, mode) {
|
|
1050
1180
|
updateGitignore(cwd);
|
|
1051
|
-
generateEnvFiles(cwd);
|
|
1181
|
+
generateEnvFiles(cwd, mode);
|
|
1052
1182
|
const spfnrcPath = join11(cwd, ".spfnrc.ts");
|
|
1053
1183
|
if (!existsSync11(spfnrcPath)) {
|
|
1054
1184
|
const spfnrcContent = `import { defineConfig, defineGenerator } from '@spfn/core/codegen';
|
|
@@ -1076,20 +1206,42 @@ export default defineConfig({
|
|
|
1076
1206
|
}
|
|
1077
1207
|
updateTsconfig(cwd);
|
|
1078
1208
|
}
|
|
1079
|
-
function generateEnvFiles(cwd) {
|
|
1080
|
-
|
|
1081
|
-
writeServerEnv(cwd);
|
|
1082
|
-
writeExampleEnv(cwd,
|
|
1209
|
+
function generateEnvFiles(cwd, mode) {
|
|
1210
|
+
writeLocalEnv(cwd, envLocalTemplate(mode), envLocalExampleTemplate(mode));
|
|
1211
|
+
writeServerEnv(cwd, envServerTemplate(mode), envServerExampleTemplate(mode));
|
|
1212
|
+
writeExampleEnv(cwd, envExampleTemplate(mode));
|
|
1213
|
+
}
|
|
1214
|
+
function writeLocalEnv(cwd, template, exampleTemplate) {
|
|
1215
|
+
const filename = ".env.local";
|
|
1216
|
+
const filePath = join11(cwd, filename);
|
|
1217
|
+
if (isGitTracked(cwd, filename)) {
|
|
1218
|
+
const referenceName = ".env.local.spfn.example";
|
|
1219
|
+
const referencePath = join11(cwd, referenceName);
|
|
1220
|
+
if (!existsSync11(referencePath)) {
|
|
1221
|
+
writeFileSync8(referencePath, exampleTemplate);
|
|
1222
|
+
}
|
|
1223
|
+
logger.warn(`${filename} is tracked by Git \u2014 left it untouched; wrote ${referenceName}. Untrack ${filename}, then add the missing keys with freshly generated values`);
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
writeEnvFile(cwd, filename, template);
|
|
1227
|
+
restrictEnvFilePerms(filePath);
|
|
1228
|
+
}
|
|
1229
|
+
function isGitTracked(cwd, filename) {
|
|
1230
|
+
const result = spawnSync("git", ["ls-files", "--error-unmatch", "--", filename], {
|
|
1231
|
+
cwd,
|
|
1232
|
+
stdio: "ignore"
|
|
1233
|
+
});
|
|
1234
|
+
return result.status === 0;
|
|
1083
1235
|
}
|
|
1084
|
-
function writeServerEnv(cwd) {
|
|
1236
|
+
function writeServerEnv(cwd, template, exampleTemplate) {
|
|
1085
1237
|
const filePath = join11(cwd, ".env.server");
|
|
1086
1238
|
if (!existsSync11(filePath)) {
|
|
1087
|
-
writeFileSync8(filePath,
|
|
1239
|
+
writeFileSync8(filePath, template);
|
|
1088
1240
|
restrictEnvFilePerms(filePath);
|
|
1089
1241
|
logger.success("Created .env.server");
|
|
1090
1242
|
return;
|
|
1091
1243
|
}
|
|
1092
|
-
writeFileSync8(join11(cwd, ".env.server.example"),
|
|
1244
|
+
writeFileSync8(join11(cwd, ".env.server.example"), exampleTemplate);
|
|
1093
1245
|
logger.warn(".env.server already exists \u2014 left it untouched; wrote SPFN's reference to .env.server.example, add any missing keys manually");
|
|
1094
1246
|
}
|
|
1095
1247
|
function writeExampleEnv(cwd, content) {
|
|
@@ -1181,7 +1333,7 @@ function updateTsconfig(cwd) {
|
|
|
1181
1333
|
logger.warn('Could not update tsconfig.json (you can add "src/server" to exclude manually)');
|
|
1182
1334
|
}
|
|
1183
1335
|
}
|
|
1184
|
-
var writeFileSync8, ENV_LOCAL_TEMPLATE, ENV_SERVER_TEMPLATE,
|
|
1336
|
+
var writeFileSync8, ENV_LOCAL_TEMPLATE, FULL_ENV_LOCAL_EXAMPLE, ENV_SERVER_TEMPLATE, FULL_ENV_SERVER_EXAMPLE;
|
|
1185
1337
|
var init_config_files = __esm({
|
|
1186
1338
|
"src/commands/init/steps/config-files.ts"() {
|
|
1187
1339
|
"use strict";
|
|
@@ -1189,8 +1341,8 @@ var init_config_files = __esm({
|
|
|
1189
1341
|
init_env_file();
|
|
1190
1342
|
({ writeFileSync: writeFileSync8 } = fse8);
|
|
1191
1343
|
ENV_LOCAL_TEMPLATE = `# Next.js environment
|
|
1192
|
-
# Loaded by Next.js (and the SPFN backend).
|
|
1193
|
-
#
|
|
1344
|
+
# Loaded by Next.js (and the SPFN backend). Never prefix secrets with
|
|
1345
|
+
# NEXT_PUBLIC_. Backend-only secrets belong in .env.server.
|
|
1194
1346
|
|
|
1195
1347
|
# SPFN API endpoint \u2014 browser + Next.js SSR/proxy target
|
|
1196
1348
|
SPFN_API_URL=http://localhost:8790
|
|
@@ -1198,6 +1350,12 @@ NEXT_PUBLIC_SPFN_API_URL=http://localhost:8790
|
|
|
1198
1350
|
|
|
1199
1351
|
# Next.js app URL (used by the SPFN server for CORS/redirects)
|
|
1200
1352
|
SPFN_APP_URL=http://localhost:3790
|
|
1353
|
+
`;
|
|
1354
|
+
FULL_ENV_LOCAL_EXAMPLE = `
|
|
1355
|
+
# Full scaffold: auth session encryption (Next.js server only; never NEXT_PUBLIC_*)
|
|
1356
|
+
SPFN_AUTH_SESSION_SECRET=replace-with-a-random-secret-at-least-32-characters
|
|
1357
|
+
SPFN_AUTH_SESSION_TTL=7d
|
|
1358
|
+
NEXT_PUBLIC_SPFN_APP_URL=http://localhost:3790
|
|
1201
1359
|
`;
|
|
1202
1360
|
ENV_SERVER_TEMPLATE = `# SPFN backend environment
|
|
1203
1361
|
# Loaded ONLY by the SPFN server, never by Next.js. Keep all server-only config
|
|
@@ -1224,12 +1382,25 @@ DB_POOL_IDLE_TIMEOUT=30
|
|
|
1224
1382
|
# DATABASE_READ_URL=postgresql://user:password@replica:5432/dbname
|
|
1225
1383
|
# CACHE_PASSWORD=your-redis-password
|
|
1226
1384
|
`;
|
|
1227
|
-
|
|
1228
|
-
#
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1385
|
+
FULL_ENV_SERVER_EXAMPLE = `
|
|
1386
|
+
# Full scaffold: authentication
|
|
1387
|
+
SPFN_AUTH_VERIFICATION_TOKEN_SECRET=replace-with-a-random-secret-at-least-32-characters
|
|
1388
|
+
SPFN_AUTH_TOKEN_ENCRYPTION_KEYS=v1:replace-with-a-base64-encoded-32-byte-key
|
|
1389
|
+
|
|
1390
|
+
# Enable the social providers you need.
|
|
1391
|
+
# SPFN_AUTH_GOOGLE_CLIENT_ID=your-google-client-id
|
|
1392
|
+
# SPFN_AUTH_GOOGLE_CLIENT_SECRET=your-google-client-secret
|
|
1393
|
+
# SPFN_AUTH_GITHUB_CLIENT_ID=your-github-client-id
|
|
1394
|
+
# SPFN_AUTH_GITHUB_CLIENT_SECRET=your-github-client-secret
|
|
1395
|
+
# SPFN_AUTH_KAKAO_CLIENT_ID=your-kakao-rest-api-key
|
|
1396
|
+
# SPFN_AUTH_KAKAO_CLIENT_SECRET=your-kakao-client-secret
|
|
1397
|
+
# SPFN_AUTH_NAVER_CLIENT_ID=your-naver-client-id
|
|
1398
|
+
# SPFN_AUTH_NAVER_CLIENT_SECRET=your-naver-client-secret
|
|
1399
|
+
|
|
1400
|
+
# Full scaffold: MCP operator endpoint
|
|
1401
|
+
SPFN_MCP_URL=http://localhost:8790
|
|
1402
|
+
SPFN_MCP_API_KEY=replace-with-a-random-operator-key
|
|
1403
|
+
`;
|
|
1233
1404
|
}
|
|
1234
1405
|
});
|
|
1235
1406
|
|
|
@@ -1254,15 +1425,16 @@ async function setupReadme(cwd, ctx) {
|
|
|
1254
1425
|
const content = renderReadme(template, {
|
|
1255
1426
|
projectName: ctx.packageJson.name || basename(cwd) || "my-app",
|
|
1256
1427
|
pm: ctx.pm,
|
|
1428
|
+
pmExec: ctx.pm === "npm" ? "npx" : ctx.pm,
|
|
1257
1429
|
pmRun: getRunCommand(ctx.pm),
|
|
1258
|
-
|
|
1430
|
+
mode: ctx.mode
|
|
1259
1431
|
});
|
|
1260
1432
|
writeFileSync9(readmePath, content);
|
|
1261
1433
|
logger.success("Created README.md");
|
|
1262
1434
|
}
|
|
1263
1435
|
function renderReadme(template, vars) {
|
|
1264
1436
|
const authBlock = /\r?\n?<!-- \{\{#auth\}\} -->\r?\n([\s\S]*?)\r?\n<!-- \{\{\/auth\}\} -->\r?\n/;
|
|
1265
|
-
return template.replace(authBlock, vars.
|
|
1437
|
+
return template.replace(authBlock, vars.mode === "full" ? "\n$1\n" : "\n").replaceAll("{{mode}}", () => vars.mode).replaceAll("{{projectName}}", () => vars.projectName).replaceAll("{{pmRun}}", () => vars.pmRun).replaceAll("{{pmExec}}", () => vars.pmExec).replaceAll("{{pm}}", () => vars.pm);
|
|
1266
1438
|
}
|
|
1267
1439
|
var writeFileSync9;
|
|
1268
1440
|
var init_readme = __esm({
|
|
@@ -1281,31 +1453,40 @@ __export(init_exports, {
|
|
|
1281
1453
|
initCommand: () => initCommand,
|
|
1282
1454
|
initializeSpfn: () => initializeSpfn
|
|
1283
1455
|
});
|
|
1284
|
-
import { Command as Command2 } from "commander";
|
|
1456
|
+
import { Command as Command2, Option } from "commander";
|
|
1285
1457
|
import chalk4 from "chalk";
|
|
1286
1458
|
async function initializeSpfn(options = {}) {
|
|
1287
1459
|
const cwd = process.cwd();
|
|
1288
|
-
const { packageJson, packageJsonPath
|
|
1460
|
+
const { packageJson, packageJsonPath } = await validateProject(cwd, options.yes || false);
|
|
1461
|
+
const mode = await selectScaffoldMode(options);
|
|
1462
|
+
const includeAuth = mode === "full";
|
|
1289
1463
|
const pm = detectPackageManager(cwd);
|
|
1290
1464
|
logger.step(`Detected package manager: ${pm}`);
|
|
1291
|
-
await setupServerStructure(cwd);
|
|
1465
|
+
await setupServerStructure(cwd, mode);
|
|
1292
1466
|
await setupApiProxy(cwd, includeAuth);
|
|
1293
1467
|
await setupNextConfig(cwd, includeAuth);
|
|
1294
1468
|
await setupDockerFiles(cwd, pm);
|
|
1295
1469
|
await setupDeploymentConfig(cwd, packageJson, pm);
|
|
1296
|
-
await setupPackageJson(cwd, packageJsonPath, packageJson, pm,
|
|
1297
|
-
await setupConfigFiles(cwd);
|
|
1298
|
-
await setupReadme(cwd, { pm, packageJson,
|
|
1470
|
+
await setupPackageJson(cwd, packageJsonPath, packageJson, pm, mode);
|
|
1471
|
+
await setupConfigFiles(cwd, mode);
|
|
1472
|
+
await setupReadme(cwd, { pm, packageJson, mode, overwrite: options.overwriteReadme ?? false });
|
|
1299
1473
|
console.log("\n" + chalk4.green.bold("\u2713 SPFN initialized successfully!\n"));
|
|
1300
1474
|
console.log("Next steps:");
|
|
1301
1475
|
console.log(" 1. Start PostgreSQL & Redis (if not installed locally):");
|
|
1302
1476
|
console.log(" " + chalk4.cyan("docker compose up -d"));
|
|
1303
1477
|
console.log(" 2. Review the generated env files (.env.local, .env.server)");
|
|
1304
1478
|
console.log(" " + chalk4.dim(ENV_FILES_HINT));
|
|
1305
|
-
|
|
1306
|
-
|
|
1479
|
+
if (mode === "full") {
|
|
1480
|
+
const spfnCommand = pm === "npm" ? "npx spfn" : `${pm} spfn`;
|
|
1481
|
+
console.log(" 3. Apply the auth migrations: " + chalk4.cyan(`${spfnCommand} db migrate`));
|
|
1482
|
+
}
|
|
1483
|
+
console.log(` ${mode === "full" ? "4" : "3"}. Run: ` + chalk4.cyan(`${getRunCommand(pm)} spfn:dev`));
|
|
1484
|
+
console.log(` ${mode === "full" ? "5" : "4"}. Visit:`);
|
|
1307
1485
|
console.log(" - Next.js: " + chalk4.cyan("http://localhost:3790"));
|
|
1308
1486
|
console.log(" - API: " + chalk4.cyan("http://localhost:8790/health"));
|
|
1487
|
+
if (mode === "full") {
|
|
1488
|
+
console.log(" - MCP: " + chalk4.cyan("http://localhost:8790/mcp"));
|
|
1489
|
+
}
|
|
1309
1490
|
console.log("\nAvailable commands:");
|
|
1310
1491
|
console.log(" \u2022 " + chalk4.cyan(`${getRunCommand(pm)} spfn:dev`) + " - Start SPFN + Next.js");
|
|
1311
1492
|
console.log(" \u2022 " + chalk4.cyan("spfn env validate") + " - Validate environment variables");
|
|
@@ -1329,7 +1510,8 @@ var init_init = __esm({
|
|
|
1329
1510
|
init_package();
|
|
1330
1511
|
init_config_files();
|
|
1331
1512
|
init_readme();
|
|
1332
|
-
|
|
1513
|
+
init_mode();
|
|
1514
|
+
initCommand = new Command2("init").description("Initialize SPFN in your Next.js project").addOption(new Option("--mode <mode>", "Scaffold mode: bare (core) or full (core, auth, i18n, MCP)").choices(["bare", "full"])).option("-y, --yes", "Skip prompts and use defaults").action(initializeSpfn);
|
|
1333
1515
|
}
|
|
1334
1516
|
});
|
|
1335
1517
|
|
|
@@ -1684,10 +1866,11 @@ import { Command as Command13 } from "commander";
|
|
|
1684
1866
|
init_logger();
|
|
1685
1867
|
init_package_manager();
|
|
1686
1868
|
init_messages();
|
|
1687
|
-
|
|
1869
|
+
init_mode();
|
|
1870
|
+
import { Command as Command3, Option as Option2 } from "commander";
|
|
1688
1871
|
import { existsSync as existsSync13 } from "fs";
|
|
1689
1872
|
import { join as join13, resolve, dirname as dirname2 } from "path";
|
|
1690
|
-
import
|
|
1873
|
+
import prompts3 from "prompts";
|
|
1691
1874
|
import ora4 from "ora";
|
|
1692
1875
|
import { execa as execa3 } from "execa";
|
|
1693
1876
|
import chalk5 from "chalk";
|
|
@@ -1713,7 +1896,7 @@ async function createProject(projectName, options) {
|
|
|
1713
1896
|
console.log(chalk5.blue.bold("\n\u{1F680} Creating Next.js project with SPFN...\n"));
|
|
1714
1897
|
let pm = options.pm || detectPackageManager(cwd);
|
|
1715
1898
|
if (!options.yes && !options.pm) {
|
|
1716
|
-
const { selectedPm } = await
|
|
1899
|
+
const { selectedPm } = await prompts3({
|
|
1717
1900
|
type: "select",
|
|
1718
1901
|
name: "selectedPm",
|
|
1719
1902
|
message: "Which package manager do you want to use?",
|
|
@@ -1731,6 +1914,8 @@ async function createProject(projectName, options) {
|
|
|
1731
1914
|
pm = selectedPm;
|
|
1732
1915
|
}
|
|
1733
1916
|
logger.step(`Using package manager: ${pm}`);
|
|
1917
|
+
const mode = await selectScaffoldMode(options);
|
|
1918
|
+
logger.step(`Using scaffold mode: ${mode}`);
|
|
1734
1919
|
const workspaceRoot = pm === "pnpm" ? findPnpmWorkspaceRoot(cwd) : null;
|
|
1735
1920
|
const isInWorkspace = workspaceRoot !== null;
|
|
1736
1921
|
if (isInWorkspace) {
|
|
@@ -1829,7 +2014,7 @@ async function createProject(projectName, options) {
|
|
|
1829
2014
|
const initSpinner = ora4("Initializing SPFN...").start();
|
|
1830
2015
|
try {
|
|
1831
2016
|
const { initializeSpfn: initializeSpfn2 } = await Promise.resolve().then(() => (init_init(), init_exports));
|
|
1832
|
-
await initializeSpfn2({ yes: true, overwriteReadme: true });
|
|
2017
|
+
await initializeSpfn2({ yes: true, overwriteReadme: true, mode });
|
|
1833
2018
|
initSpinner.succeed("SPFN initialized");
|
|
1834
2019
|
} catch (error) {
|
|
1835
2020
|
initSpinner.fail("Failed to initialize SPFN");
|
|
@@ -1841,6 +2026,10 @@ async function createProject(projectName, options) {
|
|
|
1841
2026
|
console.log(` ${chalk5.cyan("cd")} ${projectName}`);
|
|
1842
2027
|
console.log(` ${chalk5.cyan("docker compose up -d")} ${chalk5.gray("# Start PostgreSQL & Redis")}`);
|
|
1843
2028
|
console.log(` ${chalk5.gray(`# .env.local & .env.server are generated \u2014 ${ENV_FILES_HINT}`)}`);
|
|
2029
|
+
if (mode === "full") {
|
|
2030
|
+
const spfnCommand = pm === "npm" ? "npx spfn" : `${pm} spfn`;
|
|
2031
|
+
console.log(` ${chalk5.cyan(`${spfnCommand} db migrate`)} ${chalk5.gray("# Apply auth migrations")}`);
|
|
2032
|
+
}
|
|
1844
2033
|
console.log(` ${chalk5.cyan(`${getRunCommand(pm)} spfn:dev`)} ${chalk5.gray("# Start dev server")}
|
|
1845
2034
|
`);
|
|
1846
2035
|
console.log(chalk5.bold("Your app will be available at:\n"));
|
|
@@ -1857,7 +2046,7 @@ async function createProject(projectName, options) {
|
|
|
1857
2046
|
`);
|
|
1858
2047
|
console.log(chalk5.dim(" \u{1F310} Documentation: https://github.com/spfn/spfn\n"));
|
|
1859
2048
|
}
|
|
1860
|
-
var createCommand = new Command3("create").description("Create a new Next.js project with SPFN").argument("<project-name>", "Name of the project directory").option("--skip-install", "Skip installing dependencies").option("--skip-git", "Skip initializing a git repository").option("--pm <manager>", "Package manager to use (npm, pnpm, yarn, bun)").option("--shadcn", "Setup shadcn/ui (component library)").option("-y, --yes", "Skip prompts and use defaults").action(async (projectName, options) => {
|
|
2049
|
+
var createCommand = new Command3("create").description("Create a new Next.js project with SPFN").argument("<project-name>", "Name of the project directory").option("--skip-install", "Skip installing dependencies").option("--skip-git", "Skip initializing a git repository").option("--pm <manager>", "Package manager to use (npm, pnpm, yarn, bun)").option("--shadcn", "Setup shadcn/ui (component library)").addOption(new Option2("--mode <mode>", "Scaffold mode: bare (core) or full (core, auth, i18n, MCP)").choices(["bare", "full"])).option("-y, --yes", "Skip prompts and use defaults").action(async (projectName, options) => {
|
|
1861
2050
|
await createProject(projectName, options);
|
|
1862
2051
|
});
|
|
1863
2052
|
|
|
@@ -1869,7 +2058,7 @@ init_logger();
|
|
|
1869
2058
|
init_package_manager();
|
|
1870
2059
|
import { Command as Command4 } from "commander";
|
|
1871
2060
|
import { existsSync as existsSync16, readFileSync as readFileSync8, writeFileSync as writeFileSync10, mkdirSync, unlinkSync, rmSync, watch } from "fs";
|
|
1872
|
-
import { join as join17, relative, sep } from "path";
|
|
2061
|
+
import { join as join17, relative, sep as sep2 } from "path";
|
|
1873
2062
|
import { execa as execa6 } from "execa";
|
|
1874
2063
|
import chokidar from "chokidar";
|
|
1875
2064
|
|
|
@@ -2064,7 +2253,7 @@ async function resolveKeychainEnv(cwd) {
|
|
|
2064
2253
|
|
|
2065
2254
|
// src/commands/dev.ts
|
|
2066
2255
|
function ignoreDotfilesUnder(root) {
|
|
2067
|
-
return (watchedPath) => relative(root, watchedPath).split(
|
|
2256
|
+
return (watchedPath) => relative(root, watchedPath).split(sep2).some((segment) => segment.startsWith(".") && segment !== "." && segment !== "..");
|
|
2068
2257
|
}
|
|
2069
2258
|
function waitForReadyFile(filePath, timeoutMs = 3e4) {
|
|
2070
2259
|
return new Promise((resolve3, reject) => {
|
|
@@ -2793,16 +2982,16 @@ import { execSync } from "child_process";
|
|
|
2793
2982
|
import chalk11 from "chalk";
|
|
2794
2983
|
|
|
2795
2984
|
// src/utils/secret-gen.ts
|
|
2796
|
-
import { randomBytes, randomUUID } from "crypto";
|
|
2985
|
+
import { randomBytes as randomBytes2, randomUUID } from "crypto";
|
|
2797
2986
|
function randomBase64Url(bytes) {
|
|
2798
|
-
return
|
|
2987
|
+
return randomBytes2(bytes).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
2799
2988
|
}
|
|
2800
2989
|
function generateSecretValue(spec) {
|
|
2801
2990
|
switch (spec) {
|
|
2802
2991
|
case "hex32":
|
|
2803
|
-
return
|
|
2992
|
+
return randomBytes2(32).toString("hex");
|
|
2804
2993
|
case "hex64":
|
|
2805
|
-
return
|
|
2994
|
+
return randomBytes2(64).toString("hex");
|
|
2806
2995
|
case "base64url32":
|
|
2807
2996
|
return randomBase64Url(32);
|
|
2808
2997
|
case "uuid":
|
|
@@ -3137,7 +3326,7 @@ async function dbGenerate() {
|
|
|
3137
3326
|
|
|
3138
3327
|
// src/commands/db/push.ts
|
|
3139
3328
|
import chalk15 from "chalk";
|
|
3140
|
-
import
|
|
3329
|
+
import prompts4 from "prompts";
|
|
3141
3330
|
import "@spfn/core/config";
|
|
3142
3331
|
import { loadEnv as loadEnv4 } from "@spfn/core/server";
|
|
3143
3332
|
import { sql } from "drizzle-orm";
|
|
@@ -3322,7 +3511,7 @@ async function dbPush(options = {}) {
|
|
|
3322
3511
|
console.log(chalk15.red(` ${stmt.sql.replace(/\s+/g, " ").trim()}`));
|
|
3323
3512
|
console.log(chalk15.dim(` \u2192 ${stmt.reason}`));
|
|
3324
3513
|
}
|
|
3325
|
-
const { confirm } = await
|
|
3514
|
+
const { confirm } = await prompts4({
|
|
3326
3515
|
type: "confirm",
|
|
3327
3516
|
name: "confirm",
|
|
3328
3517
|
message: "Apply destructive changes?",
|
|
@@ -3388,7 +3577,7 @@ import ora7 from "ora";
|
|
|
3388
3577
|
// src/commands/db/utils/database.ts
|
|
3389
3578
|
import net from "net";
|
|
3390
3579
|
import chalk16 from "chalk";
|
|
3391
|
-
import
|
|
3580
|
+
import prompts5 from "prompts";
|
|
3392
3581
|
function parseDatabaseUrl2(dbUrl) {
|
|
3393
3582
|
try {
|
|
3394
3583
|
const url = new URL(dbUrl);
|
|
@@ -3412,7 +3601,7 @@ async function confirmDangerousTarget(dbInfo) {
|
|
|
3412
3601
|
if (!isRemoteHost(dbInfo.host) && process.env.NODE_ENV !== "production") {
|
|
3413
3602
|
return;
|
|
3414
3603
|
}
|
|
3415
|
-
const { typed } = await
|
|
3604
|
+
const { typed } = await prompts5({
|
|
3416
3605
|
type: "text",
|
|
3417
3606
|
name: "typed",
|
|
3418
3607
|
message: `Remote/production database detected. Type the database name "${dbInfo.database}" to continue:`
|
|
@@ -3931,7 +4120,7 @@ async function dbStudio(requestedPort) {
|
|
|
3931
4120
|
|
|
3932
4121
|
// src/commands/db/drop.ts
|
|
3933
4122
|
import chalk23 from "chalk";
|
|
3934
|
-
import
|
|
4123
|
+
import prompts6 from "prompts";
|
|
3935
4124
|
import { env as env7 } from "@spfn/core/config";
|
|
3936
4125
|
import { loadEnv as loadEnv7 } from "@spfn/core/server";
|
|
3937
4126
|
async function dbDrop() {
|
|
@@ -3945,7 +4134,7 @@ async function dbDrop() {
|
|
|
3945
4134
|
console.log(chalk23.yellow("\u26A0\uFE0F WARNING: This will drop all tables in your database!"));
|
|
3946
4135
|
console.log(chalk23.dim(` Target: ${dbInfo.database} @ ${dbInfo.host}:${dbInfo.port}
|
|
3947
4136
|
`));
|
|
3948
|
-
const { confirm } = await
|
|
4137
|
+
const { confirm } = await prompts6({
|
|
3949
4138
|
type: "confirm",
|
|
3950
4139
|
name: "confirm",
|
|
3951
4140
|
message: "Are you sure you want to drop all tables?",
|
|
@@ -3984,7 +4173,7 @@ import path4 from "path";
|
|
|
3984
4173
|
import { spawn as spawn4 } from "child_process";
|
|
3985
4174
|
import chalk25 from "chalk";
|
|
3986
4175
|
import ora9 from "ora";
|
|
3987
|
-
import
|
|
4176
|
+
import prompts7 from "prompts";
|
|
3988
4177
|
import { env as env8 } from "@spfn/core/config";
|
|
3989
4178
|
import { loadEnv as loadEnv8 } from "@spfn/core/server";
|
|
3990
4179
|
async function dbRestore(backupFile, options = {}) {
|
|
@@ -4003,7 +4192,7 @@ async function dbRestore(backupFile, options = {}) {
|
|
|
4003
4192
|
console.log(chalk25.yellow("No backups found in ./backups directory"));
|
|
4004
4193
|
process.exit(0);
|
|
4005
4194
|
}
|
|
4006
|
-
const { selected } = await
|
|
4195
|
+
const { selected } = await prompts7({
|
|
4007
4196
|
type: "select",
|
|
4008
4197
|
name: "selected",
|
|
4009
4198
|
message: "Select backup to restore:",
|
|
@@ -4066,7 +4255,7 @@ async function dbRestore(backupFile, options = {}) {
|
|
|
4066
4255
|
}
|
|
4067
4256
|
}
|
|
4068
4257
|
const dbInfo = parseDatabaseUrl2(dbUrl);
|
|
4069
|
-
const { confirm } = await
|
|
4258
|
+
const { confirm } = await prompts7({
|
|
4070
4259
|
type: "confirm",
|
|
4071
4260
|
name: "confirm",
|
|
4072
4261
|
message: chalk25.yellow(`\u26A0\uFE0F This will replace all data in "${dbInfo.database}" @ ${dbInfo.host}:${dbInfo.port}. Continue?`),
|
|
@@ -4249,7 +4438,7 @@ async function dbBackupList() {
|
|
|
4249
4438
|
import { promises as fs4 } from "fs";
|
|
4250
4439
|
import chalk27 from "chalk";
|
|
4251
4440
|
import ora10 from "ora";
|
|
4252
|
-
import
|
|
4441
|
+
import prompts8 from "prompts";
|
|
4253
4442
|
async function dbBackupClean(options) {
|
|
4254
4443
|
console.log(chalk27.blue("\u{1F9F9} Cleaning old backups...\n"));
|
|
4255
4444
|
const backups = await listBackupFiles();
|
|
@@ -4279,7 +4468,7 @@ async function dbBackupClean(options) {
|
|
|
4279
4468
|
toDelete.forEach((backup) => {
|
|
4280
4469
|
console.log(chalk27.gray(` - ${backup.name} (${backup.size})`));
|
|
4281
4470
|
});
|
|
4282
|
-
const { confirm } = await
|
|
4471
|
+
const { confirm } = await prompts8({
|
|
4283
4472
|
type: "confirm",
|
|
4284
4473
|
name: "confirm",
|
|
4285
4474
|
message: "\nProceed with deletion?",
|
|
@@ -5004,7 +5193,7 @@ import { Command as Command12 } from "commander";
|
|
|
5004
5193
|
|
|
5005
5194
|
// src/commands/secret/set.ts
|
|
5006
5195
|
init_logger();
|
|
5007
|
-
import
|
|
5196
|
+
import prompts9 from "prompts";
|
|
5008
5197
|
import chalk31 from "chalk";
|
|
5009
5198
|
|
|
5010
5199
|
// src/commands/secret/options.ts
|
|
@@ -5158,7 +5347,7 @@ async function secretSet(key, options) {
|
|
|
5158
5347
|
process.exit(1);
|
|
5159
5348
|
}
|
|
5160
5349
|
await warnIfNotSecret(pkg, resolvedKey);
|
|
5161
|
-
const { value } = await
|
|
5350
|
+
const { value } = await prompts9({
|
|
5162
5351
|
type: "password",
|
|
5163
5352
|
name: "value",
|
|
5164
5353
|
message: `Value for ${chalk31.cyan(resolvedKey)} (${env9})`
|
|
@@ -5185,7 +5374,7 @@ async function pickSecretKey(pkg) {
|
|
|
5185
5374
|
if (entries.length === 0) {
|
|
5186
5375
|
return void 0;
|
|
5187
5376
|
}
|
|
5188
|
-
const { key } = await
|
|
5377
|
+
const { key } = await prompts9({
|
|
5189
5378
|
type: "select",
|
|
5190
5379
|
name: "key",
|
|
5191
5380
|
message: "Which secret?",
|
package/dist/templates/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# {{projectName}}
|
|
2
2
|
|
|
3
|
-
A full-stack app built with [SPFN](https://github.com/spfn/spfn) — a
|
|
4
|
-
|
|
3
|
+
A full-stack app built with [SPFN](https://github.com/spfn/spfn) — a consistent
|
|
4
|
+
architecture for taking an AI-built prototype to production. Scaffold mode: `{{mode}}`.
|
|
5
5
|
|
|
6
6
|
## Getting started
|
|
7
7
|
|
|
@@ -10,7 +10,7 @@ backend running alongside Next.js.
|
|
|
10
10
|
docker compose up -d
|
|
11
11
|
|
|
12
12
|
# 2. The env files are generated for you:
|
|
13
|
-
# .env.local — Next.js
|
|
13
|
+
# .env.local — values loaded by Next.js, including auth session crypto (gitignored)
|
|
14
14
|
# .env.server — server secrets: DB, cache (gitignored, never loaded by Next.js)
|
|
15
15
|
# Review them and adjust as needed.
|
|
16
16
|
|
|
@@ -30,7 +30,7 @@ src/
|
|
|
30
30
|
app/ Next.js App Router
|
|
31
31
|
server/ SPFN backend (entities, repositories, routes, router)
|
|
32
32
|
generated/ codegen output (route map) — do not edit by hand
|
|
33
|
-
.env.local Next.js
|
|
33
|
+
.env.local Next.js runtime env (gitignored)
|
|
34
34
|
.env.server server secrets (gitignored)
|
|
35
35
|
.env.example committed reference — keys only, placeholder values
|
|
36
36
|
.spfnrc.ts codegen configuration
|
|
@@ -46,15 +46,15 @@ A feature is a vertical slice: `Entity` (Drizzle table) → `Repository` →
|
|
|
46
46
|
{{pmRun}} spfn:dev # dev: Next.js + SPFN API (add --watch to restart on changes)
|
|
47
47
|
{{pmRun}} spfn:build # production build
|
|
48
48
|
{{pmRun}} spfn:start # run the production build
|
|
49
|
-
{{
|
|
50
|
-
{{
|
|
51
|
-
{{
|
|
52
|
-
{{
|
|
49
|
+
{{pmExec}} spfn db generate # create a migration from schema changes
|
|
50
|
+
{{pmExec}} spfn db migrate # apply pending migrations
|
|
51
|
+
{{pmExec}} spfn env check # check .env files against the schema
|
|
52
|
+
{{pmExec}} spfn secret set DB_URL # store a secret (keychain locally, SOPS for deploys)
|
|
53
53
|
```
|
|
54
54
|
|
|
55
55
|
## Environment & secrets
|
|
56
56
|
|
|
57
|
-
`.env.server` holds
|
|
57
|
+
`.env.server` holds backend-only secrets and is gitignored — Next.js never loads it.
|
|
58
58
|
For a managed workflow use `spfn secret`: local values go to the OS keychain (only a
|
|
59
59
|
`secret:keychain:` reference lands in `.env.server`), and deployed secrets are stored
|
|
60
60
|
in encrypted SOPS files. See the [SPFN CLI docs](https://github.com/spfn/spfn).
|
|
@@ -63,8 +63,21 @@ in encrypted SOPS files. See the [SPFN CLI docs](https://github.com/spfn/spfn).
|
|
|
63
63
|
## Authentication
|
|
64
64
|
|
|
65
65
|
This project includes `@spfn/auth`. Configure providers and session settings in your
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
generated env files, then run `{{pmExec}} spfn db migrate`. The lifecycle, router,
|
|
67
|
+
Next.js interceptor, `/login` starter UI, OAuth callback, and route map are already wired.
|
|
68
|
+
|
|
69
|
+
## Internationalization
|
|
70
|
+
|
|
71
|
+
Edit `src/i18n/catalogs.ts` to add application-owned messages. Server components
|
|
72
|
+
and handlers can import `getT` or `getClientMessages` from `@/i18n/server`.
|
|
73
|
+
|
|
74
|
+
## Agent operations with MCP
|
|
75
|
+
|
|
76
|
+
The SPFN API serves MCP at `http://localhost:8790/mcp`. Connect with the Bearer
|
|
77
|
+
token stored as `SPFN_MCP_API_KEY` in `.env.server`, then replace the starter
|
|
78
|
+
`app_status` tool in `src/server/mcp.ts` with operations from your domain layer.
|
|
79
|
+
Before third-party access, replace the generated operator-key validator with your
|
|
80
|
+
OAuth access-token validator and scope each tool to the resolved operator.
|
|
68
81
|
<!-- {{/auth}} -->
|
|
69
82
|
|
|
70
83
|
## Deployment
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { OAuthCallback as default } from '@spfn/auth/nextjs/client';
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { authApi } from '@spfn/auth';
|
|
5
|
+
import { I18nProvider, useT } from '@spfn/i18n/client';
|
|
6
|
+
import { catalogs } from '@/i18n/catalogs';
|
|
7
|
+
|
|
8
|
+
const providers = ['google', 'github', 'kakao', 'naver'] as const;
|
|
9
|
+
|
|
10
|
+
export default function LoginPage()
|
|
11
|
+
{
|
|
12
|
+
return (
|
|
13
|
+
<I18nProvider locale="en" messages={catalogs.en ?? {}}>
|
|
14
|
+
<LoginContent />
|
|
15
|
+
</I18nProvider>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function LoginContent()
|
|
20
|
+
{
|
|
21
|
+
const [error, setError] = useState<string>();
|
|
22
|
+
const [pending, setPending] = useState<string>();
|
|
23
|
+
const t = useT('common');
|
|
24
|
+
|
|
25
|
+
async function signIn(provider: typeof providers[number]): Promise<void>
|
|
26
|
+
{
|
|
27
|
+
setError(undefined);
|
|
28
|
+
setPending(provider);
|
|
29
|
+
|
|
30
|
+
try
|
|
31
|
+
{
|
|
32
|
+
const { authUrl } = provider === 'google'
|
|
33
|
+
? await authApi.getGoogleOAuthUrl.call({ body: { returnUrl: '/' } })
|
|
34
|
+
: await authApi.getProviderOAuthUrl.call({
|
|
35
|
+
params: { provider },
|
|
36
|
+
body: { returnUrl: '/' },
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
window.location.assign(authUrl);
|
|
40
|
+
}
|
|
41
|
+
catch (cause)
|
|
42
|
+
{
|
|
43
|
+
setPending(undefined);
|
|
44
|
+
setError(cause instanceof Error ? cause.message : 'Could not start sign in');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<main style={{ maxWidth: 420, margin: '10vh auto', padding: 24 }}>
|
|
50
|
+
<h1>{t('appName')} — Sign in</h1>
|
|
51
|
+
<p>Connect a provider key in <code>.env.server</code>, then continue.</p>
|
|
52
|
+
<div style={{ display: 'grid', gap: 12, marginTop: 24 }}>
|
|
53
|
+
{providers.map(provider => (
|
|
54
|
+
<button
|
|
55
|
+
key={provider}
|
|
56
|
+
type="button"
|
|
57
|
+
disabled={pending !== undefined}
|
|
58
|
+
onClick={() => void signIn(provider)}
|
|
59
|
+
style={{ padding: 12, textTransform: 'capitalize' }}
|
|
60
|
+
>
|
|
61
|
+
{pending === provider ? 'Connecting…' : `Continue with ${provider}`}
|
|
62
|
+
</button>
|
|
63
|
+
))}
|
|
64
|
+
</div>
|
|
65
|
+
{error ? <p role="alert">{error}</p> : null}
|
|
66
|
+
</main>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { LocaleCatalogs } from '@spfn/i18n';
|
|
2
|
+
|
|
3
|
+
export const catalogs: LocaleCatalogs = {
|
|
4
|
+
en: {
|
|
5
|
+
common: {
|
|
6
|
+
appName: 'My SPFN app',
|
|
7
|
+
welcome: 'Welcome, {name}',
|
|
8
|
+
},
|
|
9
|
+
},
|
|
10
|
+
ko: {
|
|
11
|
+
common: {
|
|
12
|
+
appName: '나의 SPFN 앱',
|
|
13
|
+
welcome: '환영합니다, {name}',
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createEnvRegistry,
|
|
3
|
+
defineEnvSchema,
|
|
4
|
+
envSecret,
|
|
5
|
+
envUrl,
|
|
6
|
+
} from '@spfn/core/env';
|
|
7
|
+
|
|
8
|
+
export const envSchema = defineEnvSchema({
|
|
9
|
+
SPFN_MCP_URL: envUrl({
|
|
10
|
+
description: 'Public base URL of the SPFN server that exposes /mcp',
|
|
11
|
+
default: 'http://localhost:8790',
|
|
12
|
+
required: false,
|
|
13
|
+
}),
|
|
14
|
+
SPFN_MCP_API_KEY: envSecret({
|
|
15
|
+
description: 'First-party Bearer token for the generated MCP operator endpoint',
|
|
16
|
+
required: true,
|
|
17
|
+
generate: 'base64url32',
|
|
18
|
+
}),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const env = createEnvRegistry(envSchema).validate();
|
|
22
|
+
|
|
23
|
+
export default env;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote MCP endpoint for operating this application with an agent.
|
|
3
|
+
*
|
|
4
|
+
* The generated operator key makes the endpoint usable immediately. Replace
|
|
5
|
+
* `validateToken` with your OAuth access-token validator before granting access
|
|
6
|
+
* to third-party clients, and expose only tools the resolved operator may use.
|
|
7
|
+
*/
|
|
8
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
9
|
+
import type { McpAuth, McpTool } from '@spfn/mcp';
|
|
10
|
+
import { createMcpRoute } from '@spfn/mcp/server';
|
|
11
|
+
import env from './config/env.config';
|
|
12
|
+
|
|
13
|
+
type OperatorAuth = McpAuth & {
|
|
14
|
+
operatorId: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type OperatorContext = {
|
|
18
|
+
operatorId: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const tools: McpTool<OperatorContext>[] = [
|
|
22
|
+
{
|
|
23
|
+
name: 'app_status',
|
|
24
|
+
title: 'Application status',
|
|
25
|
+
description: 'Check that the deployed application MCP endpoint is available.',
|
|
26
|
+
inputSchema: {
|
|
27
|
+
type: 'object',
|
|
28
|
+
properties: {},
|
|
29
|
+
},
|
|
30
|
+
annotations: {
|
|
31
|
+
readOnlyHint: true,
|
|
32
|
+
destructiveHint: false,
|
|
33
|
+
},
|
|
34
|
+
handler: async (_args, context) => ({
|
|
35
|
+
status: 'ok',
|
|
36
|
+
operatorId: context.operatorId,
|
|
37
|
+
}),
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
export const mcpRouter = createMcpRoute<OperatorAuth, OperatorContext>({
|
|
42
|
+
appUrl: env.SPFN_MCP_URL,
|
|
43
|
+
serverInfo: {
|
|
44
|
+
name: 'spfn-app',
|
|
45
|
+
version: '1.0.0',
|
|
46
|
+
description: 'Operate this SPFN application with an agent.',
|
|
47
|
+
},
|
|
48
|
+
validateToken: async (token) =>
|
|
49
|
+
{
|
|
50
|
+
// The registry validates this required secret at startup. Its public
|
|
51
|
+
// proxy type remains optional so schemas can also describe values that
|
|
52
|
+
// are not required in every environment.
|
|
53
|
+
if (!secretsMatch(token, env.SPFN_MCP_API_KEY!))
|
|
54
|
+
{
|
|
55
|
+
throw new Error('Invalid MCP access token');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
clientId: 'operator-agent',
|
|
60
|
+
operatorId: 'operator',
|
|
61
|
+
scopes: ['operate'],
|
|
62
|
+
};
|
|
63
|
+
},
|
|
64
|
+
resolveContext: async auth => ({ operatorId: auth.operatorId }),
|
|
65
|
+
listTools: () => tools,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
function secretsMatch(actual: string, expected: string): boolean
|
|
69
|
+
{
|
|
70
|
+
const actualBytes = Buffer.from(actual);
|
|
71
|
+
const expectedBytes = Buffer.from(expected);
|
|
72
|
+
|
|
73
|
+
return actualBytes.length === expectedBytes.length
|
|
74
|
+
&& timingSafeEqual(actualBytes, expectedBytes);
|
|
75
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prototype-to-Production application router.
|
|
3
|
+
*
|
|
4
|
+
* Auth protects application routes by default. Public routes opt out with
|
|
5
|
+
* `.skip(['auth'])`; the MCP router owns its Bearer-token boundary.
|
|
6
|
+
*/
|
|
7
|
+
import { authRouter, authenticate } from '@spfn/auth/server';
|
|
8
|
+
import { defineRouter } from '@spfn/core/route';
|
|
9
|
+
import { mcpRouter } from './mcp';
|
|
10
|
+
import { getRoot } from './routes/root';
|
|
11
|
+
import { getHealth } from './routes/health';
|
|
12
|
+
import {
|
|
13
|
+
listExamples,
|
|
14
|
+
getExample,
|
|
15
|
+
createExample,
|
|
16
|
+
updateExample,
|
|
17
|
+
deleteExample,
|
|
18
|
+
} from './routes/examples';
|
|
19
|
+
|
|
20
|
+
export const appRouter = defineRouter({
|
|
21
|
+
getRoot,
|
|
22
|
+
getHealth,
|
|
23
|
+
listExamples,
|
|
24
|
+
getExample,
|
|
25
|
+
createExample,
|
|
26
|
+
updateExample,
|
|
27
|
+
deleteExample,
|
|
28
|
+
})
|
|
29
|
+
.packages([authRouter, mcpRouter])
|
|
30
|
+
.use([authenticate]);
|
|
31
|
+
|
|
32
|
+
export type AppRouter = typeof appRouter;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { route } from '@spfn/core/route';
|
|
2
|
+
import { Type } from '@sinclair/typebox';
|
|
3
|
+
import { ExampleRepository } from '../repositories/example.repository';
|
|
4
|
+
|
|
5
|
+
const exampleRepo = new ExampleRepository();
|
|
6
|
+
|
|
7
|
+
export const listExamples = route.get('/examples')
|
|
8
|
+
.input({
|
|
9
|
+
query: Type.Object({
|
|
10
|
+
limit: Type.Optional(Type.Number({ minimum: 1, maximum: 100 })),
|
|
11
|
+
offset: Type.Optional(Type.Number({ minimum: 0 })),
|
|
12
|
+
}),
|
|
13
|
+
})
|
|
14
|
+
.skip(['auth'])
|
|
15
|
+
.handler(async (c) =>
|
|
16
|
+
{
|
|
17
|
+
const { query } = await c.data();
|
|
18
|
+
const limit = query.limit ?? 10;
|
|
19
|
+
const offset = query.offset ?? 0;
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
examples: await exampleRepo.findAll(limit, offset),
|
|
23
|
+
total: await exampleRepo.countAll(),
|
|
24
|
+
limit,
|
|
25
|
+
offset,
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const getExample = route.get('/examples/:id')
|
|
30
|
+
.input({
|
|
31
|
+
params: Type.Object({ id: Type.String() }),
|
|
32
|
+
})
|
|
33
|
+
.skip(['auth'])
|
|
34
|
+
.handler(async (c) =>
|
|
35
|
+
{
|
|
36
|
+
const { params } = await c.data();
|
|
37
|
+
const example = await exampleRepo.findById(params.id);
|
|
38
|
+
if (!example)
|
|
39
|
+
{
|
|
40
|
+
throw new Error('Example not found');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return example;
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
export const createExample = route.post('/examples')
|
|
47
|
+
.input({
|
|
48
|
+
body: Type.Object({
|
|
49
|
+
name: Type.String(),
|
|
50
|
+
description: Type.String(),
|
|
51
|
+
}),
|
|
52
|
+
})
|
|
53
|
+
.handler(async (c) =>
|
|
54
|
+
{
|
|
55
|
+
const { body } = await c.data();
|
|
56
|
+
|
|
57
|
+
return exampleRepo.createExample(body);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
export const updateExample = route.put('/examples/:id')
|
|
61
|
+
.input({
|
|
62
|
+
params: Type.Object({ id: Type.String() }),
|
|
63
|
+
body: Type.Object({
|
|
64
|
+
name: Type.Optional(Type.String()),
|
|
65
|
+
description: Type.Optional(Type.String()),
|
|
66
|
+
}),
|
|
67
|
+
})
|
|
68
|
+
.handler(async (c) =>
|
|
69
|
+
{
|
|
70
|
+
const { params, body } = await c.data();
|
|
71
|
+
const example = await exampleRepo.updateExample(params.id, body);
|
|
72
|
+
if (!example)
|
|
73
|
+
{
|
|
74
|
+
throw new Error('Example not found');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return example;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
export const deleteExample = route.delete('/examples/:id')
|
|
81
|
+
.input({
|
|
82
|
+
params: Type.Object({ id: Type.String() }),
|
|
83
|
+
})
|
|
84
|
+
.handler(async (c) =>
|
|
85
|
+
{
|
|
86
|
+
const { params } = await c.data();
|
|
87
|
+
const example = await exampleRepo.deleteExample(params.id);
|
|
88
|
+
if (!example)
|
|
89
|
+
{
|
|
90
|
+
throw new Error('Example not found');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { success: true, id: params.id };
|
|
94
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { route } from '@spfn/core/route';
|
|
2
|
+
|
|
3
|
+
export const getRoot = route.get('/')
|
|
4
|
+
.skip(['auth'])
|
|
5
|
+
.handler(async () => ({
|
|
6
|
+
name: 'SPFN API',
|
|
7
|
+
version: '1.0.0',
|
|
8
|
+
status: 'running',
|
|
9
|
+
endpoints: {
|
|
10
|
+
health: '/health',
|
|
11
|
+
examples: '/examples',
|
|
12
|
+
auth: '/_auth',
|
|
13
|
+
mcp: '/mcp',
|
|
14
|
+
},
|
|
15
|
+
}));
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prototype-to-Production server configuration.
|
|
3
|
+
*/
|
|
4
|
+
import '@/i18n/server';
|
|
5
|
+
import { createAuthLifecycle } from '@spfn/auth/server';
|
|
6
|
+
import { defineServerConfig } from '@spfn/core/server';
|
|
7
|
+
import { appRouter } from '@/server/router';
|
|
8
|
+
|
|
9
|
+
export default defineServerConfig()
|
|
10
|
+
.port(8790)
|
|
11
|
+
.host('0.0.0.0')
|
|
12
|
+
.routes(appRouter)
|
|
13
|
+
.lifecycle(createAuthLifecycle())
|
|
14
|
+
.build();
|