spfn 0.3.0-beta.3 → 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
@@ -498,24 +498,30 @@ src/
498
498
  app/auth/callback/page.tsx # OAuth session handoff
499
499
  i18n/catalogs.ts # application-owned en/ko starter messages
500
500
  i18n/server.ts # configured server-side i18n registry
501
- server/mcp.ts # authenticated /mcp endpoint + starter app_status tool
502
- 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
503
503
  server/server.config.ts # createAuthLifecycle + i18n startup
504
504
  next.config.ts # /_auth/* callback rewrite
505
505
  .env.local # generated auth session secret (gitignored)
506
- .env.server # auth keyring + MCP operator key (gitignored)
506
+ .env.server # auth keyring (gitignored)
507
507
  ```
508
508
 
509
509
  The full RPC proxy imports the auth interceptor and merges `authRouteMap`. Internal auth
510
- 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;
511
511
  `.env.example` contains placeholders only. Add only the provider keys you use, then run
512
- `pnpm spfn db migrate`. The starter MCP endpoint accepts `SPFN_MCP_API_KEY` as a Bearer
513
- 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`.
514
520
 
515
521
  `init` also patches `package.json` (scripts: `spfn:dev`, `spfn:server`, `spfn:next`,
516
522
  `spfn:build`, `spfn:start`, `codegen`; deps: `@spfn/core`, `spfn`, `drizzle-orm`,
517
523
  `@sinclair/typebox`, `concurrently`, etc.; full also adds `@spfn/auth`, `@spfn/i18n`,
518
- `@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
519
525
  existing range still permits older Node versions), excludes `src/server` from the root
520
526
  `tsconfig.json` (Vercel compat), and adds `.spfn/`, `.env.local`, `.env.server` to
521
527
  `.gitignore`.
@@ -621,8 +627,8 @@ type ships from `spfn` (`@type {import('spfn').SpfnConfig}`).
621
627
  something with an AI coding agent and now want a real backend under it, `init` is the one.
622
628
 
623
629
  **`bare` or `full`?**
624
- `full` is the recommended baseline: core, auth, i18n and MCP wired together, so you get a
625
- 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
626
632
  else decided. Automation should always pass `--mode` explicitly, because a `--yes` run
627
633
  without one still produces `bare` for backward compatibility.
628
634
 
@@ -637,7 +643,7 @@ pointing at your own PostgreSQL works too. PostgreSQL itself is not optional.
637
643
 
638
644
  **Which Node version do I need?**
639
645
  20 or later, in both modes. `@spfn/core` runs on `@hono/node-server` 2, which declares
640
- that floor, and full mode's MCP server needs the same.
646
+ that floor, and full mode's `@spfn/auth` needs the same.
641
647
 
642
648
  **When do I have to run codegen by hand?**
643
649
  Whenever routes change outside `spfn dev`, which runs a codegen watcher for you. A stale or
@@ -685,5 +691,6 @@ committed.
685
691
 
686
692
  - [`@spfn/core`](../core/README.md) — server, route DSL, codegen, db, client runtime.
687
693
  - [`@spfn/auth`](../auth/README.md) — what `--mode full` wires in for accounts and roles.
688
- - [`@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`.
689
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
  {
@@ -944,7 +944,7 @@ var init_deployment_config = __esm({
944
944
 
945
945
  // src/utils/version.ts
946
946
  function getCliVersion() {
947
- return "0.3.0-beta.3";
947
+ return "0.3.0-beta.4";
948
948
  }
949
949
  function getTagFromVersion(version) {
950
950
  const match = version.match(/-([a-z]+)\./i);
@@ -988,7 +988,6 @@ async function setupPackageJson(cwd, packageJsonPath, packageJson, packageManage
988
988
  if (mode === "full") {
989
989
  packageJson.dependencies["@spfn/auth"] = spfnTag;
990
990
  packageJson.dependencies["@spfn/i18n"] = spfnTag;
991
- packageJson.dependencies["@spfn/mcp"] = spfnTag;
992
991
  packageJson.dependencies["@spfn/notification"] = spfnTag;
993
992
  }
994
993
  packageJson.devDependencies["@types/node"] = "^20.11.0";
@@ -1187,9 +1186,12 @@ SPFN_AUTH_TOKEN_ENCRYPTION_KEYS=v1:${randomSecret("base64")}
1187
1186
  # SPFN_AUTH_NAVER_CLIENT_ID=your-naver-client-id
1188
1187
  # SPFN_AUTH_NAVER_CLIENT_SECRET=your-naver-client-secret
1189
1188
 
1190
- # Full scaffold: MCP operator endpoint
1191
- SPFN_MCP_URL=http://localhost:8790
1192
- 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"}]
1193
1195
  `;
1194
1196
  }
1195
1197
  function randomSecret(encoding) {
@@ -1419,9 +1421,12 @@ SPFN_AUTH_TOKEN_ENCRYPTION_KEYS=v1:replace-with-a-base64-encoded-32-byte-key
1419
1421
  # SPFN_AUTH_NAVER_CLIENT_ID=your-naver-client-id
1420
1422
  # SPFN_AUTH_NAVER_CLIENT_SECRET=your-naver-client-secret
1421
1423
 
1422
- # Full scaffold: MCP operator endpoint
1423
- SPFN_MCP_URL=http://localhost:8790
1424
- 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"}]
1425
1430
  `;
1426
1431
  }
1427
1432
  });
@@ -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
@@ -2,12 +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';
10
+ import { opsRouter } from './routes/ops';
11
11
  import {
12
12
  listExamples,
13
13
  getExample,
@@ -24,7 +24,7 @@ export const appRouter = defineRouter({
24
24
  updateExample,
25
25
  deleteExample,
26
26
  })
27
- .packages([authRouter, mcpRouter])
27
+ .packages([authRouter, opsRouter])
28
28
  .use([authenticate]);
29
29
 
30
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 });
@@ -10,6 +10,6 @@ export const getRoot = route.get('/')
10
10
  health: '/_core/health',
11
11
  examples: '/examples',
12
12
  auth: '/_auth',
13
- mcp: '/mcp',
13
+ ops: '/_ops',
14
14
  },
15
15
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spfn",
3
- "version": "0.3.0-beta.3",
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": {
@@ -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
- }