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 CHANGED
@@ -1,8 +1,9 @@
1
1
  # spfn — the SPFN CLI (backend layer for Next.js)
2
2
 
3
- `spfn` scaffolds and runs a Hono-based backend that lives inside a Next.js project.
4
- It creates the server structure, runs the dev/build/start lifecycle, manages the
5
- database (Drizzle Kit), generates the RPC route map, and validates environment variables.
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+, Next.js 15+ (App Router, `src/` dir), PostgreSQL (Redis optional).
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
- # New project (runs create-next-app + spfn init)
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 — put server secrets in .env.server
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. With auth enabled it also adds the `/_auth/:path*` → SPFN API rewrite to
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
- `spfn init` (and `create`, which calls it) produces:
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-facing URLs)
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.), excludes `src/server` from the root
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, not in
391
- `.env` (committed) and not in `.env.local` (that's Next.js's local file). There is no
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 prompts from "prompts";
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 prompts(
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 prompts(
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
- let includeAuth = false;
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 authImport = includeAuth ? `import '@spfn/auth/nextjs/api';
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
- ${authImport}import { routeMap } from '@/generated/route-map';
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.61";
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, includeAuth) {
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 (includeAuth) {
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
- writeEnvFile(cwd, ".env.local", ENV_LOCAL_TEMPLATE);
1081
- writeServerEnv(cwd);
1082
- writeExampleEnv(cwd, ENV_EXAMPLE_TEMPLATE);
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, ENV_SERVER_TEMPLATE);
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"), withPlaceholderCreds(ENV_SERVER_TEMPLATE));
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, ENV_EXAMPLE_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). Only non-secret, Next.js-facing
1193
- # values belong here \u2014 server secrets go in .env.server.
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
- ENV_EXAMPLE_TEMPLATE = withPlaceholderCreds(`# Example environment \u2014 committed reference for the variables SPFN uses.
1228
- # Real values live in .env.local (Next.js) and .env.server (backend secrets),
1229
- # both gitignored. This file documents the keys; it is not loaded by anything.
1230
-
1231
- ${ENV_LOCAL_TEMPLATE}
1232
- ${ENV_SERVER_TEMPLATE}`);
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
- includeAuth: ctx.includeAuth
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.includeAuth ? "\n$1\n" : "\n").replaceAll("{{projectName}}", () => vars.projectName).replaceAll("{{pmRun}}", () => vars.pmRun).replaceAll("{{pm}}", () => vars.pm);
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, includeAuth } = await validateProject(cwd, options.yes || false);
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, includeAuth);
1297
- await setupConfigFiles(cwd);
1298
- await setupReadme(cwd, { pm, packageJson, includeAuth, overwrite: options.overwriteReadme ?? false });
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
- console.log(" 3. Run: " + chalk4.cyan(`${getRunCommand(pm)} spfn:dev`));
1306
- console.log(" 4. Visit:");
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
- initCommand = new Command2("init").description("Initialize SPFN in your Next.js project").option("-y, --yes", "Skip prompts and use defaults").action(initializeSpfn);
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
- import { Command as Command3 } from "commander";
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 prompts2 from "prompts";
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 prompts2({
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(sep).some((segment) => segment.startsWith(".") && segment !== "." && segment !== "..");
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 randomBytes(bytes).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
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 randomBytes(32).toString("hex");
2992
+ return randomBytes2(32).toString("hex");
2804
2993
  case "hex64":
2805
- return randomBytes(64).toString("hex");
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 prompts3 from "prompts";
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 prompts3({
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 prompts4 from "prompts";
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 prompts4({
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 prompts5 from "prompts";
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 prompts5({
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 prompts6 from "prompts";
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 prompts6({
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 prompts6({
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 prompts7 from "prompts";
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 prompts7({
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 prompts8 from "prompts";
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 prompts8({
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 prompts8({
5377
+ const { key } = await prompts9({
5189
5378
  type: "select",
5190
5379
  name: "key",
5191
5380
  message: "Which secret?",
@@ -1,7 +1,7 @@
1
1
  # {{projectName}}
2
2
 
3
- A full-stack app built with [SPFN](https://github.com/spfn/spfn) — a typed SPFN
4
- backend running alongside Next.js.
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-facing values (gitignored)
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-facing env (gitignored)
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
- {{pm}} spfn db generate # create a migration from schema changes
50
- {{pm}} spfn db migrate # apply pending migrations
51
- {{pm}} spfn env check # check .env files against the schema
52
- {{pm}} spfn secret set DB_URL # store a secret (keychain locally, SOPS for deploys)
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 server-only secrets and is gitignored — Next.js never loads it.
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
- server setup, and read `@spfn/auth`'s README for the full Entity → Repository →
67
- Service Route flow and the typed `authApi` client.
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,9 @@
1
+ import { configureI18n } from '@spfn/i18n/server';
2
+ import { catalogs } from './catalogs';
3
+
4
+ configureI18n({
5
+ catalogs,
6
+ fallbackLocale: 'en',
7
+ });
8
+
9
+ export { getClientMessages, getT } from '@spfn/i18n/server';
@@ -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,9 @@
1
+ import { route } from '@spfn/core/route';
2
+
3
+ export const getHealth = route.get('/health')
4
+ .skip(['auth'])
5
+ .handler(async () => ({
6
+ status: 'ok',
7
+ timestamp: Date.now(),
8
+ uptime: process.uptime(),
9
+ }));
@@ -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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spfn",
3
- "version": "0.2.0-beta.61",
3
+ "version": "0.2.0-beta.63",
4
4
  "description": "Superfunction CLI - Add SPFN to your Next.js project",
5
5
  "type": "module",
6
6
  "bin": {