spfn 0.3.0-beta.2 → 0.3.0-beta.4

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  `spfn` takes a Next.js idea from prototype to production with a consistent full-stack
4
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
5
+ authentication, internationalization, and a terminal operations surface, then runs the
6
6
  dev/build/start lifecycle, database tooling, RPC codegen, and environment validation.
7
7
 
8
8
  Consistent is the point rather than a nicety: what it scaffolds is one fixed shape per
@@ -31,7 +31,7 @@ is not supported — see [the root README](../../README.md#what-do-i-need-instal
31
31
  ## Usage
32
32
 
33
33
  ```bash
34
- # Prototype-to-Production baseline: core + auth + i18n + MCP
34
+ # Prototype-to-Production baseline: core + auth + i18n + ops
35
35
  npx spfn@beta create my-app --mode full
36
36
  cd my-app
37
37
  docker compose up -d # Postgres + Redis
@@ -71,7 +71,7 @@ pass `--mode full`.
71
71
  |--------|-------------|
72
72
  | `--pm <manager>` | Force package manager: `npm` \| `pnpm` \| `yarn` \| `bun` |
73
73
  | `--shadcn` | Also run `shadcn init` |
74
- | `--mode <mode>` | `bare` (core only) \| `full` (core, auth, i18n, MCP) |
74
+ | `--mode <mode>` | `bare` (core only) \| `full` (core, auth, i18n, ops) |
75
75
  | `--skip-install` | Skip dependency install |
76
76
  | `--skip-git` | Skip `git init` |
77
77
  | `-y, --yes` | Skip prompts, use defaults |
@@ -86,7 +86,7 @@ already exists). See [Scaffold structure](#scaffold-structure) for what lands on
86
86
 
87
87
  | Option | Description |
88
88
  |--------|-------------|
89
- | `--mode <mode>` | `bare` (core only) \| `full` (core, auth, i18n, MCP) |
89
+ | `--mode <mode>` | `bare` (core only) \| `full` (core, auth, i18n, ops) |
90
90
  | `-y, --yes` | Skip prompts, use defaults |
91
91
 
92
92
  Generated projects pin `drizzle-orm` and `drizzle-kit` to `1.0.0-rc.4`, matching
@@ -134,9 +134,8 @@ no pre-build needed.
134
134
  |--------|-------------|---------|
135
135
  | `--server-only` | Run only the SPFN/Hono server (also auto-selected if Next.js isn't a dependency) | off |
136
136
  | `--watch` | Restart the server on `src/server` changes (chokidar) | off |
137
- | `-p, --port <port>` | Server port | from `server.config.ts` / env (`4000` in server-only fallback) |
138
- | `-H, --host <host>` | Server host | `localhost` |
139
- | `--routes <path>` | Routes directory path | server default |
137
+ | `-p, --port <port>` | SPFN server port (sets `SPFN_PORT`) | `spfn.config.js` `ports.server`, then `8790` |
138
+ | `-H, --host <host>` | SPFN server host (sets `SPFN_HOST`) | `spfn.config.js` `host`, then `localhost` |
140
139
  | `--allow-pending-migrations` | Start even when migrations are pending (they are listed as a warning) | off |
141
140
 
142
141
  Note: hot reload is **off by default** — pass `--watch` to restart on file changes.
@@ -165,11 +164,19 @@ if `.spfn/server`, `.spfn/prod-server.mjs`, or `.next` are missing.
165
164
  |--------|-------------|---------|
166
165
  | `--server-only` | Run only the SPFN server | off |
167
166
  | `--next-only` | Run only Next.js | off |
168
- | `-p, --port <port>` | SPFN server port (sets `SPFN_PORT`) | `8790` |
169
- | `-h, --host <host>` | SPFN server host (sets `SPFN_HOST`) | `0.0.0.0` |
167
+ | `-p, --port <port>` | SPFN server port (sets `SPFN_PORT`) | `spfn.config.js` `ports.server`, then `8790` |
168
+ | `-h, --host <host>` | SPFN server host (sets `SPFN_HOST`) | `spfn.config.js` `host`, then `localhost` |
170
169
  | `--allow-pending-migrations` | Start even when migrations are pending (they are listed as a warning) | off |
171
170
 
172
- Next.js is started on `0.0.0.0:3790`. Both run together via `concurrently --kill-others`.
171
+ Both run together via `concurrently --kill-others`.
172
+
173
+ Neither flag has a default value, deliberately. A default is indistinguishable
174
+ from a value the operator typed, and it was forwarded as `SPFN_PORT` either way —
175
+ which overrode the app's own configuration. Pass nothing and `spfn.config.js`
176
+ decides; pass a flag and it wins.
177
+
178
+ Next.js is started on the port `spfn.config.js` gives as `ports.next` (`3790` by
179
+ default), overridable with `NEXT_PORT`.
173
180
 
174
181
  Pending migrations stop the boot unless `--allow-pending-migrations` or
175
182
  `SPFN_ALLOW_PENDING_MIGRATIONS=true` is set — see [Database](#spfn-db). `--next-only`
@@ -491,24 +498,30 @@ src/
491
498
  app/auth/callback/page.tsx # OAuth session handoff
492
499
  i18n/catalogs.ts # application-owned en/ko starter messages
493
500
  i18n/server.ts # configured server-side i18n registry
494
- server/mcp.ts # authenticated /mcp endpoint + starter app_status tool
495
- server/router.ts # authRouter + mcpRouter + global authenticate
501
+ server/routes/ops.ts # ops routes under /_ops + the manifest `spfn ops` reads
502
+ server/router.ts # authRouter + opsRouter + global authenticate
496
503
  server/server.config.ts # createAuthLifecycle + i18n startup
497
504
  next.config.ts # /_auth/* callback rewrite
498
505
  .env.local # generated auth session secret (gitignored)
499
- .env.server # auth keyring + MCP operator key (gitignored)
506
+ .env.server # auth keyring (gitignored)
500
507
  ```
501
508
 
502
509
  The full RPC proxy imports the auth interceptor and merges `authRouteMap`. Internal auth
503
- and MCP keys are generated with cryptographic randomness in ignored local env files;
510
+ keys are generated with cryptographic randomness in ignored local env files;
504
511
  `.env.example` contains placeholders only. Add only the provider keys you use, then run
505
- `pnpm spfn db migrate`. The starter MCP endpoint accepts `SPFN_MCP_API_KEY` as a Bearer
506
- token for first-party operation; replace that validator with OAuth before third-party access.
512
+ `pnpm spfn db migrate`.
513
+
514
+ Operating the app is [`spfn ops`](#spfn-ops), not a dashboard: the starter
515
+ `src/server/routes/ops.ts` exposes two read commands, and `spfn ops` discovers them from
516
+ the running server's manifest. Issuing the first token signs in as an administrator, so
517
+ uncomment `SPFN_AUTH_ADMIN_ACCOUNTS` in `.env.server` and restart before
518
+ `spfn ops token issue`. The ops surface adds no dependency — the router comes from
519
+ `@spfn/core/ops` and the tokens from `@spfn/auth`.
507
520
 
508
521
  `init` also patches `package.json` (scripts: `spfn:dev`, `spfn:server`, `spfn:next`,
509
522
  `spfn:build`, `spfn:start`, `codegen`; deps: `@spfn/core`, `spfn`, `drizzle-orm`,
510
523
  `@sinclair/typebox`, `concurrently`, etc.; full also adds `@spfn/auth`, `@spfn/i18n`,
511
- `@spfn/mcp`, auth's `@spfn/notification` peer, and a Node `>=20.0.0` engine when the
524
+ auth's `@spfn/notification` peer, and a Node `>=20.0.0` engine when the
512
525
  existing range still permits older Node versions), excludes `src/server` from the root
513
526
  `tsconfig.json` (Vercel compat), and adds `.spfn/`, `.env.local`, `.env.server` to
514
527
  `.gitignore`.
@@ -580,7 +593,7 @@ docker compose -f docker-compose.production.yml up --build -d
580
593
 
581
594
  The Dockerfile (`node:22-alpine`) installs with `pnpm --frozen-lockfile`, runs
582
595
  `pnpm run spfn:build`, prunes dev deps, exposes `3790`/`8790`, health-checks
583
- `http://localhost:8790/health`, and starts via `pnpm run spfn:start`.
596
+ `http://localhost:8790/_core/health`, and starts via `pnpm run spfn:start`.
584
597
 
585
598
  Run migrations against the target DB before/with deploy:
586
599
 
@@ -595,7 +608,7 @@ the gate is one that never served the 500s. If a rollout has to proceed anyway,
595
608
  logged as a warning instead.
596
609
 
597
610
  A readiness probe can catch the same drift on a cluster the local gate never sees. When
598
- detailed health is on, `GET /health` carries a `migrations` object with per-package
611
+ detailed health is on, `GET /_core/health` carries a `migrations` object with per-package
599
612
  applied/pending counts — assert `migrations.pending === 0` in the probe to hold a
600
613
  drifted pod out of rotation. Reporting drift does not, by itself, change the overall
601
614
  health `status`.
@@ -614,8 +627,8 @@ type ships from `spfn` (`@type {import('spfn').SpfnConfig}`).
614
627
  something with an AI coding agent and now want a real backend under it, `init` is the one.
615
628
 
616
629
  **`bare` or `full`?**
617
- `full` is the recommended baseline: core, auth, i18n and MCP wired together, so you get a
618
- working authenticated app on day one. `bare` is core only — the architecture with nothing
630
+ `full` is the recommended baseline: core, auth, i18n and the ops surface wired together, so
631
+ you get a working authenticated app on day one, operable from the terminal. `bare` is core only — the architecture with nothing
619
632
  else decided. Automation should always pass `--mode` explicitly, because a `--yes` run
620
633
  without one still produces `bare` for backward compatibility.
621
634
 
@@ -630,7 +643,7 @@ pointing at your own PostgreSQL works too. PostgreSQL itself is not optional.
630
643
 
631
644
  **Which Node version do I need?**
632
645
  20 or later, in both modes. `@spfn/core` runs on `@hono/node-server` 2, which declares
633
- that floor, and full mode's MCP server needs the same.
646
+ that floor, and full mode's `@spfn/auth` needs the same.
634
647
 
635
648
  **When do I have to run codegen by hand?**
636
649
  Whenever routes change outside `spfn dev`, which runs a codegen watcher for you. A stale or
@@ -678,5 +691,6 @@ committed.
678
691
 
679
692
  - [`@spfn/core`](../core/README.md) — server, route DSL, codegen, db, client runtime.
680
693
  - [`@spfn/auth`](../auth/README.md) — what `--mode full` wires in for accounts and roles.
681
- - [`@spfn/mcp`](../mcp/README.md) — what `--mode full` wires in for operating the app.
694
+ - [`@spfn/mcp`](../mcp/README.md) — an agent-facing MCP endpoint, added on demand with
695
+ `spfn add @spfn/mcp`.
682
696
  - Project root README — framework overview and getting started.
package/dist/index.js CHANGED
@@ -103,7 +103,7 @@ async function selectScaffoldMode(options) {
103
103
  choices: [
104
104
  {
105
105
  title: "full (recommended)",
106
- description: "Core, auth, i18n, and MCP \u2014 ready for Prototype to Production",
106
+ description: "Core, auth, i18n, and ops CLI \u2014 ready for Prototype to Production",
107
107
  value: "full"
108
108
  },
109
109
  {
@@ -808,11 +808,33 @@ async function setupDeploymentConfig(cwd, packageJson, packageManager) {
808
808
  const configContent = `/**
809
809
  * SPFN Configuration
810
810
  *
811
- * This file configures your SPFN application deployment settings.
811
+ * This file describes how your app is served and deployed. It is committed, so
812
+ * keep secrets out of it.
812
813
  *
813
814
  * @type {import('spfn').SpfnConfig}
814
815
  */
815
816
  export default {
817
+ /**
818
+ * Ports the two processes bind.
819
+ *
820
+ * This is the only place either number is written. The Dockerfile, the
821
+ * compose file and \`spfn dev\` / \`spfn start\` all read it, so changing a
822
+ * port here changes it everywhere.
823
+ *
824
+ * Overridable per environment with NEXT_PORT and SPFN_PORT \u2014 a container
825
+ * setting one of those wins over what is written here.
826
+ */
827
+ ports: {
828
+ next: 3790,
829
+ server: 8790,
830
+ },
831
+
832
+ /**
833
+ * Host the SPFN API server binds. A container sets SPFN_HOST=0.0.0.0; a
834
+ * developer machine has no reason to publish its dev server to the network.
835
+ */
836
+ host: 'localhost',
837
+
816
838
  /**
817
839
  * Package manager to use for dependency installation
818
840
  * Options: 'npm' | 'yarn' | 'pnpm' | 'bun'
@@ -922,7 +944,7 @@ var init_deployment_config = __esm({
922
944
 
923
945
  // src/utils/version.ts
924
946
  function getCliVersion() {
925
- return "0.3.0-beta.2";
947
+ return "0.3.0-beta.4";
926
948
  }
927
949
  function getTagFromVersion(version) {
928
950
  const match = version.match(/-([a-z]+)\./i);
@@ -966,7 +988,6 @@ async function setupPackageJson(cwd, packageJsonPath, packageJson, packageManage
966
988
  if (mode === "full") {
967
989
  packageJson.dependencies["@spfn/auth"] = spfnTag;
968
990
  packageJson.dependencies["@spfn/i18n"] = spfnTag;
969
- packageJson.dependencies["@spfn/mcp"] = spfnTag;
970
991
  packageJson.dependencies["@spfn/notification"] = spfnTag;
971
992
  }
972
993
  packageJson.devDependencies["@types/node"] = "^20.11.0";
@@ -982,7 +1003,7 @@ async function setupPackageJson(cwd, packageJsonPath, packageJson, packageManage
982
1003
  }
983
1004
  packageJson.scripts["spfn:dev"] = "spfn dev";
984
1005
  packageJson.scripts["spfn:server"] = "spfn dev --server-only";
985
- packageJson.scripts["spfn:next"] = "next dev --turbo --port 3790";
1006
+ packageJson.scripts["spfn:next"] = "next dev --turbo";
986
1007
  packageJson.scripts["spfn:start"] = "spfn start";
987
1008
  packageJson.scripts["spfn:build"] = "spfn build";
988
1009
  packageJson.scripts["codegen"] = "spfn codegen run";
@@ -1165,9 +1186,12 @@ SPFN_AUTH_TOKEN_ENCRYPTION_KEYS=v1:${randomSecret("base64")}
1165
1186
  # SPFN_AUTH_NAVER_CLIENT_ID=your-naver-client-id
1166
1187
  # SPFN_AUTH_NAVER_CLIENT_SECRET=your-naver-client-secret
1167
1188
 
1168
- # Full scaffold: MCP operator endpoint
1169
- SPFN_MCP_URL=http://localhost:8790
1170
- SPFN_MCP_API_KEY=${randomSecret("base64url")}
1189
+ # Full scaffold: ops CLI (spfn ops)
1190
+ # Ops commands need no variable of their own \u2014 they authenticate with a token
1191
+ # issued by \`spfn ops token issue\`, and issuing one signs in as an administrator.
1192
+ # Uncomment to seed that administrator on the next server start, then remove the
1193
+ # password once the account exists.
1194
+ # SPFN_AUTH_ADMIN_ACCOUNTS=[{"email":"admin@example.com","password":"replace-with-a-strong-password","role":"admin"}]
1171
1195
  `;
1172
1196
  }
1173
1197
  function randomSecret(encoding) {
@@ -1397,9 +1421,12 @@ SPFN_AUTH_TOKEN_ENCRYPTION_KEYS=v1:replace-with-a-base64-encoded-32-byte-key
1397
1421
  # SPFN_AUTH_NAVER_CLIENT_ID=your-naver-client-id
1398
1422
  # SPFN_AUTH_NAVER_CLIENT_SECRET=your-naver-client-secret
1399
1423
 
1400
- # Full scaffold: MCP operator endpoint
1401
- SPFN_MCP_URL=http://localhost:8790
1402
- SPFN_MCP_API_KEY=replace-with-a-random-operator-key
1424
+ # Full scaffold: ops CLI (spfn ops)
1425
+ # Ops commands need no variable of their own \u2014 they authenticate with a token
1426
+ # issued by \`spfn ops token issue\`, and issuing one signs in as an administrator.
1427
+ # Uncomment to seed that administrator on the next server start, then remove the
1428
+ # password once the account exists.
1429
+ # SPFN_AUTH_ADMIN_ACCOUNTS=[{"email":"admin@example.com","password":"replace-with-a-strong-password","role":"admin"}]
1403
1430
  `;
1404
1431
  }
1405
1432
  });
@@ -1483,7 +1510,7 @@ async function initializeSpfn(options = {}) {
1483
1510
  console.log(` ${mode === "full" ? "4" : "3"}. Run: ` + chalk4.cyan(`${getRunCommand(pm)} spfn:dev`));
1484
1511
  console.log(` ${mode === "full" ? "5" : "4"}. Visit:`);
1485
1512
  console.log(" - Next.js: " + chalk4.cyan("http://localhost:3790"));
1486
- console.log(" - API: " + chalk4.cyan("http://localhost:8790/health"));
1513
+ console.log(" - API: " + chalk4.cyan("http://localhost:8790/_core/health"));
1487
1514
  if (mode === "full") {
1488
1515
  console.log(" - MCP: " + chalk4.cyan("http://localhost:8790/mcp"));
1489
1516
  }
@@ -2122,6 +2149,7 @@ async function resolveKeychainEnv(cwd) {
2122
2149
  }
2123
2150
 
2124
2151
  // src/commands/dev.ts
2152
+ import { loadAppConfig, resolvePorts, resolveHost } from "@spfn/core/app-config";
2125
2153
  function ignoreDotfilesUnder(root) {
2126
2154
  return (watchedPath) => relative(root, watchedPath).split(sep2).some((segment) => segment.startsWith(".") && segment !== "." && segment !== "..");
2127
2155
  }
@@ -2145,13 +2173,22 @@ function waitForReadyFile(filePath, timeoutMs = 3e4) {
2145
2173
  });
2146
2174
  });
2147
2175
  }
2148
- var devCommand = new Command4("dev").description("Start SPFN development server (detects and runs Next.js + Hono)").option("-p, --port <port>", "Server port").option("-H, --host <host>", "Server host").option("--routes <path>", "Routes directory path").option("--server-only", "Run only Hono server (skip Next.js)").option("--watch", "Enable hot reload (watch mode)").option("--allow-pending-migrations", "Start even when migrations are pending (they are listed as a warning)").action(async (options) => {
2176
+ var devCommand = new Command4("dev").description("Start SPFN development server (detects and runs Next.js + Hono)").option("-p, --port <port>", "Server port").option("-H, --host <host>", "Server host").option("--server-only", "Run only Hono server (skip Next.js)").option("--watch", "Enable hot reload (watch mode)").option("--allow-pending-migrations", "Start even when migrations are pending (they are listed as a warning)").action(async (options) => {
2149
2177
  process.setMaxListeners(20);
2150
2178
  if (!process.env.NODE_ENV) {
2151
2179
  process.env.NODE_ENV = "development";
2152
2180
  }
2153
2181
  const cwd = process.cwd();
2154
2182
  const serverDir = join15(cwd, "src", "server");
2183
+ if (options.port) {
2184
+ process.env.SPFN_PORT = String(options.port);
2185
+ }
2186
+ if (options.host) {
2187
+ process.env.SPFN_HOST = String(options.host);
2188
+ }
2189
+ const appConfig = await loadAppConfig(cwd);
2190
+ const { next: nextPort, server: serverPort } = resolvePorts(appConfig);
2191
+ const serverHost = resolveHost(appConfig);
2155
2192
  if (!existsSync14(serverDir)) {
2156
2193
  logger.error("src/server directory not found.");
2157
2194
  logger.info('Run "spfn init" first to initialize SPFN in your project.');
@@ -2197,11 +2234,7 @@ var devCommand = new Command4("dev").description("Start SPFN development server
2197
2234
  if (existsSync14(readySignal)) {
2198
2235
  unlinkSync(readySignal);
2199
2236
  }
2200
- const configParts = [];
2201
- if (options.port) configParts.push(`port: ${options.port}`);
2202
- if (options.host) configParts.push(`host: '${options.host}'`);
2203
- if (options.routes) configParts.push(`routesPath: '${options.routes}'`);
2204
- configParts.push("debug: true");
2237
+ const configParts = ["debug: true"];
2205
2238
  const readyFile = join15(tempDir, "server-ready");
2206
2239
  writeFileSync10(serverEntry, `
2207
2240
  import { writeFileSync } from 'fs';
@@ -2274,9 +2307,7 @@ catch (error)
2274
2307
  const pm = detectPackageManager(cwd);
2275
2308
  if (options.serverOnly || !hasNext) {
2276
2309
  const watchMode2 = options.watch === true;
2277
- const host = options.host ?? process.env.HOST ?? "localhost";
2278
- const port = options.port ?? process.env.PORT ?? "4000";
2279
- logger.info(`Starting SPFN Server on http://${host}:${port}${watchMode2 ? " (watch mode)" : ""}
2310
+ logger.info(`Starting SPFN Server on http://${serverHost}:${serverPort}${watchMode2 ? " (watch mode)" : ""}
2280
2311
  `);
2281
2312
  let serverProcess2 = null;
2282
2313
  let watcherProcess2 = null;
@@ -2392,7 +2423,7 @@ catch (error)
2392
2423
  };
2393
2424
  const startNext = () => {
2394
2425
  const nextCmd = pm === "npm" ? "npm" : pm;
2395
- const nextArgs = pm === "npm" ? ["run", "spfn:next"] : ["run", "spfn:next"];
2426
+ const nextArgs = ["run", "spfn:next", "--", "--port", String(nextPort)];
2396
2427
  nextProcess = execa6(nextCmd, nextArgs, {
2397
2428
  cwd,
2398
2429
  stdio: "inherit",
@@ -2498,6 +2529,19 @@ import { execa as execa7 } from "execa";
2498
2529
  import ora5 from "ora";
2499
2530
  import chalk7 from "chalk";
2500
2531
  import { build } from "tsup";
2532
+ function renderProdServerEntry() {
2533
+ return `// Load environment variables FIRST (before any imports that depend on them)
2534
+ // Use centralized environment loader for standard dotenv priority
2535
+ await import('@spfn/core/config');
2536
+
2537
+ // Now import server (logger singleton will be created with correct NODE_ENV)
2538
+ const { startServer } = await import('@spfn/core/server');
2539
+
2540
+ // No address here on purpose: SPFN_PORT / SPFN_HOST and spfn.config.js decide,
2541
+ // and startServer reads both.
2542
+ await startServer({ debug: false });
2543
+ `;
2544
+ }
2501
2545
  async function buildProject(options) {
2502
2546
  if (!process.env.NODE_ENV) {
2503
2547
  process.env.NODE_ENV = "production";
@@ -2571,6 +2615,12 @@ async function buildProject(options) {
2571
2615
  entry: ["src/server/**/*.ts"],
2572
2616
  format: ["esm"],
2573
2617
  outDir: ".spfn/server",
2618
+ // Pin the extension. Left to tsup it follows the app's
2619
+ // package.json — `.mjs` normally, `.js` under
2620
+ // `"type": "module"` — and @spfn/core looks the compiled
2621
+ // server.config up by name, so an app that declared that field
2622
+ // shipped a production server running on defaults.
2623
+ outExtension: () => ({ js: ".mjs" }),
2574
2624
  clean: true,
2575
2625
  splitting: false,
2576
2626
  tsconfig: "src/server/tsconfig.json",
@@ -2590,29 +2640,7 @@ async function buildProject(options) {
2590
2640
  }
2591
2641
  });
2592
2642
  const prodServerPath = join16(cwd, ".spfn", "prod-server.mjs");
2593
- const prodServerContent = `// Load environment variables FIRST (before any imports that depend on them)
2594
- // Use centralized environment loader for standard dotenv priority
2595
- const { env } = await import('@spfn/core/config');
2596
-
2597
- // Now import server (logger singleton will be created with correct NODE_ENV)
2598
- const { startServer } = await import('@spfn/core/server');
2599
- import { join } from 'path';
2600
- import { fileURLToPath } from 'url';
2601
- import { dirname } from 'path';
2602
-
2603
- const __dirname = dirname(fileURLToPath(import.meta.url));
2604
-
2605
- // Environment variables: from .env files OR injected by container/kubernetes
2606
- const port = env.SPFN_PORT || '8790';
2607
- const host = env.SPFN_HOST || '0.0.0.0';
2608
-
2609
- await startServer({
2610
- port: Number(port),
2611
- host,
2612
- routesPath: join(__dirname, 'server', 'routes'),
2613
- debug: false
2614
- });
2615
- `;
2643
+ const prodServerContent = renderProdServerEntry();
2616
2644
  writeFileSync11(prodServerPath, prodServerContent);
2617
2645
  spinner.succeed(`SPFN server build completed \u2192 .spfn/server`);
2618
2646
  const routesDir = join16(cwd, ".spfn", "server", "routes");
@@ -2676,7 +2704,8 @@ import { existsSync as existsSync16, readFileSync as readFileSync8 } from "fs";
2676
2704
  import { join as join17 } from "path";
2677
2705
  import { execa as execa8 } from "execa";
2678
2706
  import chalk8 from "chalk";
2679
- var startCommand = new Command6("start").description("Start SPFN production server (Next.js + Hono)").option("--server-only", "Run only SPFN server (skip Next.js)").option("--next-only", "Run only Next.js (skip SPFN server)").option("-p, --port <port>", "Server port", "8790").option("-h, --host <host>", "Server host", "0.0.0.0").option("--allow-pending-migrations", "Start even when migrations are pending (they are listed as a warning)").action(async (options) => {
2707
+ import { loadAppConfig as loadAppConfig2, resolvePorts as resolvePorts2 } from "@spfn/core/app-config";
2708
+ var startCommand = new Command6("start").description("Start SPFN production server (Next.js + Hono)").option("--server-only", "Run only SPFN server (skip Next.js)").option("--next-only", "Run only Next.js (skip SPFN server)").option("-p, --port <port>", "SPFN server port (default: spfn.config.js ports.server, then 8790)").option("-h, --host <host>", "SPFN server host (default: spfn.config.js host, then localhost)").option("--allow-pending-migrations", "Start even when migrations are pending (they are listed as a warning)").action(async (options) => {
2680
2709
  if (!process.env.NODE_ENV) {
2681
2710
  process.env.NODE_ENV = "production";
2682
2711
  }
@@ -2705,8 +2734,13 @@ var startCommand = new Command6("start").description("Start SPFN production serv
2705
2734
  logger.error('.spfn/prod-server.mjs not found. Please run "spfn build" first.');
2706
2735
  process.exit(1);
2707
2736
  }
2708
- process.env.SPFN_PORT = options.port;
2709
- process.env.SPFN_HOST = options.host;
2737
+ if (options.port) {
2738
+ process.env.SPFN_PORT = options.port;
2739
+ }
2740
+ if (options.host) {
2741
+ process.env.SPFN_HOST = options.host;
2742
+ }
2743
+ const nextPort = resolvePorts2(await loadAppConfig2(cwd)).next;
2710
2744
  if (!options.nextOnly) {
2711
2745
  const { checkPendingMigrationsBeforeStart: checkPendingMigrationsBeforeStart2 } = await Promise.resolve().then(() => (init_migration_status(), migration_status_exports));
2712
2746
  const migrationCheck = await checkPendingMigrationsBeforeStart2(
@@ -2722,8 +2756,7 @@ var startCommand = new Command6("start").description("Start SPFN production serv
2722
2756
  }
2723
2757
  }
2724
2758
  if (options.serverOnly || !hasNext) {
2725
- logger.info(`Starting SPFN Server (production) on http://${options.host}:${options.port}
2726
- `);
2759
+ logger.info("Starting SPFN Server (production)\n");
2727
2760
  try {
2728
2761
  await execa8("node", [serverEntry], {
2729
2762
  stdio: "inherit",
@@ -2737,9 +2770,10 @@ var startCommand = new Command6("start").description("Start SPFN production serv
2737
2770
  return;
2738
2771
  }
2739
2772
  if (options.nextOnly) {
2740
- logger.info("Starting Next.js (production) on http://0.0.0.0:3790\n");
2773
+ logger.info(`Starting Next.js (production) on http://0.0.0.0:${nextPort}
2774
+ `);
2741
2775
  try {
2742
- await execa8("npx", ["next", "start", "-H", "0.0.0.0", "-p", "3790"], {
2776
+ await execa8("npx", ["next", "start", "-H", "0.0.0.0", "-p", String(nextPort)], {
2743
2777
  stdio: "inherit",
2744
2778
  cwd
2745
2779
  });
@@ -2749,12 +2783,11 @@ var startCommand = new Command6("start").description("Start SPFN production serv
2749
2783
  }
2750
2784
  return;
2751
2785
  }
2752
- const nextCmd = "next start -H 0.0.0.0 -p 3790";
2786
+ const nextCmd = `next start -H 0.0.0.0 -p ${nextPort}`;
2753
2787
  const serverCmd = `node "${serverEntry}"`;
2754
2788
  console.log(chalk8.blue.bold("\n\u{1F680} Starting SPFN production server...\n"));
2755
- logger.info("Next.js: http://0.0.0.0:3790");
2756
- logger.info(`SPFN API: http://${options.host}:${options.port}
2757
- `);
2789
+ logger.info(`Next.js: http://0.0.0.0:${nextPort}`);
2790
+ logger.info("SPFN API: announced by the server below\n");
2758
2791
  try {
2759
2792
  await execa8(
2760
2793
  pm === "npm" ? "npx" : pm,
@@ -24,12 +24,15 @@ RUN pnpm prune --prod
24
24
  # Environment
25
25
  ENV NODE_ENV=production
26
26
 
27
- # Expose ports
27
+ # Expose ports. Docker cannot read spfn.config.js, so these two numbers are the
28
+ # one place they are repeated — keep them in step with `ports` in that file, or
29
+ # set NEXT_PORT / SPFN_PORT here and in the compose file instead.
28
30
  EXPOSE 3790 8790
29
31
 
30
- # Health check
32
+ # Health check. Falls back to the same default the framework uses, so setting
33
+ # SPFN_PORT alone moves both the server and its probe.
31
34
  HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
32
- CMD node -e "require('http').get('http://localhost:8790/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
35
+ CMD node -e "require('http').get('http://localhost:'+(process.env.SPFN_PORT||8790)+'/_core/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"
33
36
 
34
37
  # Start application
35
38
  CMD ["pnpm", "run", "spfn:start"]
@@ -21,7 +21,7 @@ docker compose up -d
21
21
  Then open:
22
22
 
23
23
  - Next.js — http://localhost:3790
24
- - API health — http://localhost:8790/health
24
+ - API health — http://localhost:8790/_core/health
25
25
 
26
26
  ## Project structure
27
27
 
@@ -71,13 +71,28 @@ Next.js interceptor, `/login` starter UI, OAuth callback, and route map are alre
71
71
  Edit `src/i18n/catalogs.ts` to add application-owned messages. Server components
72
72
  and handlers can import `getT` or `getClientMessages` from `@/i18n/server`.
73
73
 
74
- ## Agent operations with MCP
74
+ ## Operations from the terminal
75
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.
76
+ Operating this app needs no admin dashboard. Ops routes live in
77
+ `src/server/routes/ops.ts`, are written like any other route, and the `spfn ops`
78
+ CLI discovers them from the running server:
79
+
80
+ ```bash
81
+ # 1. Seed an administrator: uncomment SPFN_AUTH_ADMIN_ACCOUNTS in .env.server,
82
+ # then restart the server so the account is created.
83
+
84
+ # 2. Issue a token for this machine (stored in the OS keychain on macOS)
85
+ {{pmExec}} spfn ops token issue --name laptop --scopes 'example:read'
86
+
87
+ # 3. Run the app's own operations
88
+ {{pmExec}} spfn ops list --app http://localhost:8790
89
+ {{pmExec}} spfn ops call countExamples --app http://localhost:8790
90
+ {{pmExec}} spfn ops call listRecentExamples --describe # usage from the route's schema
91
+ ```
92
+
93
+ Tokens are scoped and revocable (`spfn ops token list` / `revoke`). Add your own
94
+ commands by exporting more `opsRoute` handlers and passing them to
95
+ `createOpsRouter` — no CLI change is needed, the manifest carries them.
81
96
  <!-- {{/auth}} -->
82
97
 
83
98
  ## Deployment
@@ -22,13 +22,20 @@ services:
22
22
  context: .
23
23
  dockerfile: Dockerfile
24
24
  ports:
25
- - "3790:3790" # Next.js
26
- - "8790:8790" # SPFN API
25
+ - "${NEXT_PORT:-3790}:${NEXT_PORT:-3790}" # Next.js
26
+ - "${SPFN_PORT:-8790}:${SPFN_PORT:-8790}" # SPFN API
27
27
  environment:
28
28
  # Required: Set these via .env file or environment
29
29
  - DATABASE_URL=${DATABASE_URL}
30
30
  - REDIS_URL=${REDIS_URL:-redis://redis:6379}
31
31
  - NODE_ENV=production
32
+ # Ports: leave unset and spfn.config.js decides. Set either one to
33
+ # override it for this deployment — the mapping above follows.
34
+ - NEXT_PORT=${NEXT_PORT:-}
35
+ - SPFN_PORT=${SPFN_PORT:-}
36
+ # A container has to bind every interface; a developer machine does not,
37
+ # which is why localhost is the default and this line is here.
38
+ - SPFN_HOST=0.0.0.0
32
39
  # Optional: Frontend API URL
33
40
  - NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL:-http://localhost:8790}
34
41
  restart: unless-stopped
@@ -2,13 +2,12 @@
2
2
  * Prototype-to-Production application router.
3
3
  *
4
4
  * Auth protects application routes by default. Public routes opt out with
5
- * `.skip(['auth'])`; the MCP router owns its Bearer-token boundary.
5
+ * `.skip(['auth'])`; the ops router owns its own ops-token boundary.
6
6
  */
7
7
  import { authRouter, authenticate } from '@spfn/auth/server';
8
8
  import { defineRouter } from '@spfn/core/route';
9
- import { mcpRouter } from './mcp';
10
9
  import { getRoot } from './routes/root';
11
- import { getHealth } from './routes/health';
10
+ import { opsRouter } from './routes/ops';
12
11
  import {
13
12
  listExamples,
14
13
  getExample,
@@ -19,14 +18,13 @@ import {
19
18
 
20
19
  export const appRouter = defineRouter({
21
20
  getRoot,
22
- getHealth,
23
21
  listExamples,
24
22
  getExample,
25
23
  createExample,
26
24
  updateExample,
27
25
  deleteExample,
28
26
  })
29
- .packages([authRouter, mcpRouter])
27
+ .packages([authRouter, opsRouter])
30
28
  .use([authenticate]);
31
29
 
32
30
  export type AppRouter = typeof appRouter;
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Ops routes — operate this application from the terminal.
3
+ *
4
+ * Operations are written exactly like features: `opsRoute` puts them under the
5
+ * `/_ops` namespace, and `createOpsRouter` injects `opsTokenAuth` into every one
6
+ * of them plus serves `GET /_ops/_manifest`, which the `spfn ops` CLI reads to
7
+ * discover the commands this app owns.
8
+ *
9
+ * First run, against a running server:
10
+ *
11
+ * spfn ops token issue --name laptop --scopes 'example:read'
12
+ * spfn ops list --app http://localhost:8790
13
+ * spfn ops call countExamples --app http://localhost:8790
14
+ *
15
+ * Issuing a token signs in as an administrator, so seed one first with
16
+ * `SPFN_AUTH_ADMIN_ACCOUNTS` in `.env.server`.
17
+ */
18
+ import { createOpsRouter, opsRoute } from '@spfn/core/ops';
19
+ import { opsTokenAuth, requireOpsScope } from '@spfn/auth/server';
20
+ import { Type } from '@sinclair/typebox';
21
+ import { ExampleRepository } from '../repositories/example.repository';
22
+
23
+ const exampleRepo = new ExampleRepository();
24
+
25
+ /**
26
+ * GET /_ops/examples/count — how many examples exist
27
+ */
28
+ const countExamples = opsRoute.get('/examples/count')
29
+ .use([requireOpsScope('example:read')])
30
+ .handler(async () => ({ count: await exampleRepo.countAll() }));
31
+
32
+ /**
33
+ * GET /_ops/examples — recent examples, ops view
34
+ */
35
+ const listRecentExamples = opsRoute.get('/examples')
36
+ .use([requireOpsScope('example:read')])
37
+ .input({
38
+ query: Type.Object({
39
+ limit: Type.Optional(Type.Number({ minimum: 1, maximum: 100 })),
40
+ }),
41
+ })
42
+ .handler(async (c) =>
43
+ {
44
+ const { query } = await c.data();
45
+
46
+ return { items: await exampleRepo.findAll(query.limit ?? 10, 0) };
47
+ });
48
+
49
+ export const opsRouter = createOpsRouter({
50
+ countExamples,
51
+ listRecentExamples,
52
+ }, { auth: opsTokenAuth });
@@ -7,9 +7,9 @@ export const getRoot = route.get('/')
7
7
  version: '1.0.0',
8
8
  status: 'running',
9
9
  endpoints: {
10
- health: '/health',
10
+ health: '/_core/health',
11
11
  examples: '/examples',
12
12
  auth: '/_auth',
13
- mcp: '/mcp',
13
+ ops: '/_ops',
14
14
  },
15
15
  }));
@@ -7,8 +7,6 @@ import { defineServerConfig } from '@spfn/core/server';
7
7
  import { appRouter } from '@/server/router';
8
8
 
9
9
  export default defineServerConfig()
10
- .port(8790)
11
- .host('0.0.0.0')
12
10
  .routes(appRouter)
13
11
  .lifecycle(createAuthLifecycle())
14
12
  .build();
@@ -6,7 +6,6 @@
6
6
 
7
7
  import { defineRouter } from '@spfn/core/route';
8
8
  import { getRoot } from './routes/root';
9
- import { getHealth } from './routes/health';
10
9
  import { listExamples, getExample, createExample, updateExample, deleteExample } from './routes/examples';
11
10
 
12
11
  /**
@@ -14,7 +13,6 @@ import { listExamples, getExample, createExample, updateExample, deleteExample }
14
13
  */
15
14
  export const appRouter = defineRouter({
16
15
  getRoot,
17
- getHealth,
18
16
  listExamples,
19
17
  getExample,
20
18
  createExample,
@@ -14,7 +14,7 @@ export const getRoot = route.get('/')
14
14
  version: '1.0.0',
15
15
  status: 'running',
16
16
  endpoints: {
17
- health: '/health',
17
+ health: '/_core/health',
18
18
  examples: '/examples',
19
19
  },
20
20
  message: 'Welcome to SPFN! Visit /examples for usage examples.',
@@ -5,7 +5,5 @@ import { defineServerConfig } from '@spfn/core/server';
5
5
  import { appRouter } from '@/server/router';
6
6
 
7
7
  export default defineServerConfig()
8
- .port(8790)
9
- .host('0.0.0.0')
10
8
  .routes(appRouter)
11
9
  .build();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spfn",
3
- "version": "0.3.0-beta.2",
3
+ "version": "0.3.0-beta.4",
4
4
  "description": "Scaffold a full-stack TypeScript backend onto a Next.js app built with an AI coding agent: auth, database, typed routes and codegen, one fixed vertical slice per feature",
5
5
  "type": "module",
6
6
  "bin": {
@@ -57,7 +57,7 @@
57
57
  "node": ">=20.0.0"
58
58
  },
59
59
  "peerDependencies": {
60
- "@spfn/core": ">=0.3.0-beta.1 <0.4.0",
60
+ "@spfn/core": ">=0.3.0-beta.3 <0.4.0",
61
61
  "typescript": "^5.3.0"
62
62
  },
63
63
  "peerDependenciesMeta": {
@@ -1,23 +0,0 @@
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;
@@ -1,75 +0,0 @@
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
- }
@@ -1,9 +0,0 @@
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
- }));
@@ -1,18 +0,0 @@
1
- /**
2
- * Health Check Route
3
- *
4
- * Minimal endpoint for monitoring systems, load balancers, and orchestrators.
5
- * Used by Kubernetes probes, uptime monitors, etc.
6
- */
7
-
8
- import { route } from '@spfn/core/route';
9
-
10
- export const getHealth = route.get('/health')
11
- .handler(async () =>
12
- {
13
- return {
14
- status: 'ok',
15
- timestamp: Date.now(),
16
- uptime: process.uptime(),
17
- };
18
- });