spfn 0.3.0-beta.1 → 0.3.0-beta.3
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 +27 -10
- package/dist/index.js +194 -85
- package/dist/templates/Dockerfile +6 -3
- package/dist/templates/README.md +1 -1
- package/dist/templates/docker-compose.production.yml +9 -2
- package/dist/templates/modes/full/src/server/router.ts +0 -2
- package/dist/templates/modes/full/src/server/routes/root.ts +1 -1
- package/dist/templates/modes/full/src/server/server.config.ts +0 -2
- package/dist/templates/server/router.ts +0 -2
- package/dist/templates/server/routes/root.ts +1 -1
- package/dist/templates/server/server.config.ts +0 -2
- package/package.json +2 -2
- package/dist/templates/modes/full/src/server/routes/health.ts +0 -9
- package/dist/templates/server/routes/health.ts +0 -18
package/README.md
CHANGED
|
@@ -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>` |
|
|
138
|
-
| `-H, --host <host>` |
|
|
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`) | `
|
|
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
|
-
|
|
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`
|
|
@@ -411,8 +418,14 @@ listSignups GET /_ops/signups
|
|
|
411
418
|
Add `--json` for the raw JSON Schema. The server still validates every call — `--describe`
|
|
412
419
|
reports what it will accept, and the app's answer decides.
|
|
413
420
|
|
|
414
|
-
The app URL comes from `--app` or `SPFN_OPS_APP
|
|
415
|
-
|
|
421
|
+
The app URL comes from `--app` or `SPFN_OPS_APP`, and it must be **https** — every one of
|
|
422
|
+
these commands carries a secret, and `token issue` carries an administrator's password.
|
|
423
|
+
`http` is accepted only against `localhost`, `127.0.0.1` and `::1`, where there is no
|
|
424
|
+
network to listen on. A URL with a base path (`https://example.com/api`) is kept whole:
|
|
425
|
+
both the ops calls and the administrator sign-in go through it.
|
|
426
|
+
|
|
427
|
+
The ops token resolves `--token` → `SPFN_OPS_TOKEN` → macOS keychain, and its lifecycle is
|
|
428
|
+
managed with:
|
|
416
429
|
|
|
417
430
|
```bash
|
|
418
431
|
spfn ops token issue --name laptop --scopes 'waitlist:read' --app <url>
|
|
@@ -442,6 +455,10 @@ point, which that release added. The CLI does not depend on the package in any f
|
|
|
442
455
|
loads it from the app at run time, and tells a missing package apart from one too old to
|
|
443
456
|
carry the entry point, so the message names the thing to do.
|
|
444
457
|
|
|
458
|
+
Because the package is the app's, it is resolved **from the directory the command runs in**.
|
|
459
|
+
Run `spfn ops token` from the app's root; running it elsewhere reports the package as
|
|
460
|
+
missing, and the message names the directory it looked in.
|
|
461
|
+
|
|
445
462
|
---
|
|
446
463
|
|
|
447
464
|
## Scaffold structure
|
|
@@ -570,7 +587,7 @@ docker compose -f docker-compose.production.yml up --build -d
|
|
|
570
587
|
|
|
571
588
|
The Dockerfile (`node:22-alpine`) installs with `pnpm --frozen-lockfile`, runs
|
|
572
589
|
`pnpm run spfn:build`, prunes dev deps, exposes `3790`/`8790`, health-checks
|
|
573
|
-
`http://localhost:8790/health`, and starts via `pnpm run spfn:start`.
|
|
590
|
+
`http://localhost:8790/_core/health`, and starts via `pnpm run spfn:start`.
|
|
574
591
|
|
|
575
592
|
Run migrations against the target DB before/with deploy:
|
|
576
593
|
|
|
@@ -585,7 +602,7 @@ the gate is one that never served the 500s. If a rollout has to proceed anyway,
|
|
|
585
602
|
logged as a warning instead.
|
|
586
603
|
|
|
587
604
|
A readiness probe can catch the same drift on a cluster the local gate never sees. When
|
|
588
|
-
detailed health is on, `GET /health` carries a `migrations` object with per-package
|
|
605
|
+
detailed health is on, `GET /_core/health` carries a `migrations` object with per-package
|
|
589
606
|
applied/pending counts — assert `migrations.pending === 0` in the probe to hold a
|
|
590
607
|
drifted pod out of rotation. Reporting drift does not, by itself, change the overall
|
|
591
608
|
health `status`.
|
package/dist/index.js
CHANGED
|
@@ -808,11 +808,33 @@ async function setupDeploymentConfig(cwd, packageJson, packageManager) {
|
|
|
808
808
|
const configContent = `/**
|
|
809
809
|
* SPFN Configuration
|
|
810
810
|
*
|
|
811
|
-
* This file
|
|
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.
|
|
947
|
+
return "0.3.0-beta.3";
|
|
926
948
|
}
|
|
927
949
|
function getTagFromVersion(version) {
|
|
928
950
|
const match = version.match(/-([a-z]+)\./i);
|
|
@@ -982,7 +1004,7 @@ async function setupPackageJson(cwd, packageJsonPath, packageJson, packageManage
|
|
|
982
1004
|
}
|
|
983
1005
|
packageJson.scripts["spfn:dev"] = "spfn dev";
|
|
984
1006
|
packageJson.scripts["spfn:server"] = "spfn dev --server-only";
|
|
985
|
-
packageJson.scripts["spfn:next"] = "next dev --turbo
|
|
1007
|
+
packageJson.scripts["spfn:next"] = "next dev --turbo";
|
|
986
1008
|
packageJson.scripts["spfn:start"] = "spfn start";
|
|
987
1009
|
packageJson.scripts["spfn:build"] = "spfn build";
|
|
988
1010
|
packageJson.scripts["codegen"] = "spfn codegen run";
|
|
@@ -1483,7 +1505,7 @@ async function initializeSpfn(options = {}) {
|
|
|
1483
1505
|
console.log(` ${mode === "full" ? "4" : "3"}. Run: ` + chalk4.cyan(`${getRunCommand(pm)} spfn:dev`));
|
|
1484
1506
|
console.log(` ${mode === "full" ? "5" : "4"}. Visit:`);
|
|
1485
1507
|
console.log(" - Next.js: " + chalk4.cyan("http://localhost:3790"));
|
|
1486
|
-
console.log(" - API: " + chalk4.cyan("http://localhost:8790/health"));
|
|
1508
|
+
console.log(" - API: " + chalk4.cyan("http://localhost:8790/_core/health"));
|
|
1487
1509
|
if (mode === "full") {
|
|
1488
1510
|
console.log(" - MCP: " + chalk4.cyan("http://localhost:8790/mcp"));
|
|
1489
1511
|
}
|
|
@@ -2122,6 +2144,7 @@ async function resolveKeychainEnv(cwd) {
|
|
|
2122
2144
|
}
|
|
2123
2145
|
|
|
2124
2146
|
// src/commands/dev.ts
|
|
2147
|
+
import { loadAppConfig, resolvePorts, resolveHost } from "@spfn/core/app-config";
|
|
2125
2148
|
function ignoreDotfilesUnder(root) {
|
|
2126
2149
|
return (watchedPath) => relative(root, watchedPath).split(sep2).some((segment) => segment.startsWith(".") && segment !== "." && segment !== "..");
|
|
2127
2150
|
}
|
|
@@ -2145,13 +2168,22 @@ function waitForReadyFile(filePath, timeoutMs = 3e4) {
|
|
|
2145
2168
|
});
|
|
2146
2169
|
});
|
|
2147
2170
|
}
|
|
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("--
|
|
2171
|
+
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
2172
|
process.setMaxListeners(20);
|
|
2150
2173
|
if (!process.env.NODE_ENV) {
|
|
2151
2174
|
process.env.NODE_ENV = "development";
|
|
2152
2175
|
}
|
|
2153
2176
|
const cwd = process.cwd();
|
|
2154
2177
|
const serverDir = join15(cwd, "src", "server");
|
|
2178
|
+
if (options.port) {
|
|
2179
|
+
process.env.SPFN_PORT = String(options.port);
|
|
2180
|
+
}
|
|
2181
|
+
if (options.host) {
|
|
2182
|
+
process.env.SPFN_HOST = String(options.host);
|
|
2183
|
+
}
|
|
2184
|
+
const appConfig = await loadAppConfig(cwd);
|
|
2185
|
+
const { next: nextPort, server: serverPort } = resolvePorts(appConfig);
|
|
2186
|
+
const serverHost = resolveHost(appConfig);
|
|
2155
2187
|
if (!existsSync14(serverDir)) {
|
|
2156
2188
|
logger.error("src/server directory not found.");
|
|
2157
2189
|
logger.info('Run "spfn init" first to initialize SPFN in your project.');
|
|
@@ -2197,11 +2229,7 @@ var devCommand = new Command4("dev").description("Start SPFN development server
|
|
|
2197
2229
|
if (existsSync14(readySignal)) {
|
|
2198
2230
|
unlinkSync(readySignal);
|
|
2199
2231
|
}
|
|
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");
|
|
2232
|
+
const configParts = ["debug: true"];
|
|
2205
2233
|
const readyFile = join15(tempDir, "server-ready");
|
|
2206
2234
|
writeFileSync10(serverEntry, `
|
|
2207
2235
|
import { writeFileSync } from 'fs';
|
|
@@ -2274,9 +2302,7 @@ catch (error)
|
|
|
2274
2302
|
const pm = detectPackageManager(cwd);
|
|
2275
2303
|
if (options.serverOnly || !hasNext) {
|
|
2276
2304
|
const watchMode2 = options.watch === true;
|
|
2277
|
-
|
|
2278
|
-
const port = options.port ?? process.env.PORT ?? "4000";
|
|
2279
|
-
logger.info(`Starting SPFN Server on http://${host}:${port}${watchMode2 ? " (watch mode)" : ""}
|
|
2305
|
+
logger.info(`Starting SPFN Server on http://${serverHost}:${serverPort}${watchMode2 ? " (watch mode)" : ""}
|
|
2280
2306
|
`);
|
|
2281
2307
|
let serverProcess2 = null;
|
|
2282
2308
|
let watcherProcess2 = null;
|
|
@@ -2392,7 +2418,7 @@ catch (error)
|
|
|
2392
2418
|
};
|
|
2393
2419
|
const startNext = () => {
|
|
2394
2420
|
const nextCmd = pm === "npm" ? "npm" : pm;
|
|
2395
|
-
const nextArgs =
|
|
2421
|
+
const nextArgs = ["run", "spfn:next", "--", "--port", String(nextPort)];
|
|
2396
2422
|
nextProcess = execa6(nextCmd, nextArgs, {
|
|
2397
2423
|
cwd,
|
|
2398
2424
|
stdio: "inherit",
|
|
@@ -2498,6 +2524,19 @@ import { execa as execa7 } from "execa";
|
|
|
2498
2524
|
import ora5 from "ora";
|
|
2499
2525
|
import chalk7 from "chalk";
|
|
2500
2526
|
import { build } from "tsup";
|
|
2527
|
+
function renderProdServerEntry() {
|
|
2528
|
+
return `// Load environment variables FIRST (before any imports that depend on them)
|
|
2529
|
+
// Use centralized environment loader for standard dotenv priority
|
|
2530
|
+
await import('@spfn/core/config');
|
|
2531
|
+
|
|
2532
|
+
// Now import server (logger singleton will be created with correct NODE_ENV)
|
|
2533
|
+
const { startServer } = await import('@spfn/core/server');
|
|
2534
|
+
|
|
2535
|
+
// No address here on purpose: SPFN_PORT / SPFN_HOST and spfn.config.js decide,
|
|
2536
|
+
// and startServer reads both.
|
|
2537
|
+
await startServer({ debug: false });
|
|
2538
|
+
`;
|
|
2539
|
+
}
|
|
2501
2540
|
async function buildProject(options) {
|
|
2502
2541
|
if (!process.env.NODE_ENV) {
|
|
2503
2542
|
process.env.NODE_ENV = "production";
|
|
@@ -2571,6 +2610,12 @@ async function buildProject(options) {
|
|
|
2571
2610
|
entry: ["src/server/**/*.ts"],
|
|
2572
2611
|
format: ["esm"],
|
|
2573
2612
|
outDir: ".spfn/server",
|
|
2613
|
+
// Pin the extension. Left to tsup it follows the app's
|
|
2614
|
+
// package.json — `.mjs` normally, `.js` under
|
|
2615
|
+
// `"type": "module"` — and @spfn/core looks the compiled
|
|
2616
|
+
// server.config up by name, so an app that declared that field
|
|
2617
|
+
// shipped a production server running on defaults.
|
|
2618
|
+
outExtension: () => ({ js: ".mjs" }),
|
|
2574
2619
|
clean: true,
|
|
2575
2620
|
splitting: false,
|
|
2576
2621
|
tsconfig: "src/server/tsconfig.json",
|
|
@@ -2590,29 +2635,7 @@ async function buildProject(options) {
|
|
|
2590
2635
|
}
|
|
2591
2636
|
});
|
|
2592
2637
|
const prodServerPath = join16(cwd, ".spfn", "prod-server.mjs");
|
|
2593
|
-
const prodServerContent =
|
|
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
|
-
`;
|
|
2638
|
+
const prodServerContent = renderProdServerEntry();
|
|
2616
2639
|
writeFileSync11(prodServerPath, prodServerContent);
|
|
2617
2640
|
spinner.succeed(`SPFN server build completed \u2192 .spfn/server`);
|
|
2618
2641
|
const routesDir = join16(cwd, ".spfn", "server", "routes");
|
|
@@ -2676,7 +2699,8 @@ import { existsSync as existsSync16, readFileSync as readFileSync8 } from "fs";
|
|
|
2676
2699
|
import { join as join17 } from "path";
|
|
2677
2700
|
import { execa as execa8 } from "execa";
|
|
2678
2701
|
import chalk8 from "chalk";
|
|
2679
|
-
|
|
2702
|
+
import { loadAppConfig as loadAppConfig2, resolvePorts as resolvePorts2 } from "@spfn/core/app-config";
|
|
2703
|
+
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
2704
|
if (!process.env.NODE_ENV) {
|
|
2681
2705
|
process.env.NODE_ENV = "production";
|
|
2682
2706
|
}
|
|
@@ -2705,8 +2729,13 @@ var startCommand = new Command6("start").description("Start SPFN production serv
|
|
|
2705
2729
|
logger.error('.spfn/prod-server.mjs not found. Please run "spfn build" first.');
|
|
2706
2730
|
process.exit(1);
|
|
2707
2731
|
}
|
|
2708
|
-
|
|
2709
|
-
|
|
2732
|
+
if (options.port) {
|
|
2733
|
+
process.env.SPFN_PORT = options.port;
|
|
2734
|
+
}
|
|
2735
|
+
if (options.host) {
|
|
2736
|
+
process.env.SPFN_HOST = options.host;
|
|
2737
|
+
}
|
|
2738
|
+
const nextPort = resolvePorts2(await loadAppConfig2(cwd)).next;
|
|
2710
2739
|
if (!options.nextOnly) {
|
|
2711
2740
|
const { checkPendingMigrationsBeforeStart: checkPendingMigrationsBeforeStart2 } = await Promise.resolve().then(() => (init_migration_status(), migration_status_exports));
|
|
2712
2741
|
const migrationCheck = await checkPendingMigrationsBeforeStart2(
|
|
@@ -2722,8 +2751,7 @@ var startCommand = new Command6("start").description("Start SPFN production serv
|
|
|
2722
2751
|
}
|
|
2723
2752
|
}
|
|
2724
2753
|
if (options.serverOnly || !hasNext) {
|
|
2725
|
-
logger.info(
|
|
2726
|
-
`);
|
|
2754
|
+
logger.info("Starting SPFN Server (production)\n");
|
|
2727
2755
|
try {
|
|
2728
2756
|
await execa8("node", [serverEntry], {
|
|
2729
2757
|
stdio: "inherit",
|
|
@@ -2737,9 +2765,10 @@ var startCommand = new Command6("start").description("Start SPFN production serv
|
|
|
2737
2765
|
return;
|
|
2738
2766
|
}
|
|
2739
2767
|
if (options.nextOnly) {
|
|
2740
|
-
logger.info(
|
|
2768
|
+
logger.info(`Starting Next.js (production) on http://0.0.0.0:${nextPort}
|
|
2769
|
+
`);
|
|
2741
2770
|
try {
|
|
2742
|
-
await execa8("npx", ["next", "start", "-H", "0.0.0.0", "-p",
|
|
2771
|
+
await execa8("npx", ["next", "start", "-H", "0.0.0.0", "-p", String(nextPort)], {
|
|
2743
2772
|
stdio: "inherit",
|
|
2744
2773
|
cwd
|
|
2745
2774
|
});
|
|
@@ -2749,12 +2778,11 @@ var startCommand = new Command6("start").description("Start SPFN production serv
|
|
|
2749
2778
|
}
|
|
2750
2779
|
return;
|
|
2751
2780
|
}
|
|
2752
|
-
const nextCmd =
|
|
2781
|
+
const nextCmd = `next start -H 0.0.0.0 -p ${nextPort}`;
|
|
2753
2782
|
const serverCmd = `node "${serverEntry}"`;
|
|
2754
2783
|
console.log(chalk8.blue.bold("\n\u{1F680} Starting SPFN production server...\n"));
|
|
2755
|
-
logger.info(
|
|
2756
|
-
logger.info(
|
|
2757
|
-
`);
|
|
2784
|
+
logger.info(`Next.js: http://0.0.0.0:${nextPort}`);
|
|
2785
|
+
logger.info("SPFN API: announced by the server below\n");
|
|
2758
2786
|
try {
|
|
2759
2787
|
await execa8(
|
|
2760
2788
|
pm === "npm" ? "npx" : pm,
|
|
@@ -5380,7 +5408,39 @@ async function fetchOpsManifest(appUrl, token) {
|
|
|
5380
5408
|
if (manifest?.manifestVersion !== 1 || !Array.isArray(manifest.commands)) {
|
|
5381
5409
|
throw new Error("The manifest answer has an unknown shape.");
|
|
5382
5410
|
}
|
|
5383
|
-
return manifest;
|
|
5411
|
+
return { manifestVersion: 1, commands: usableCommands(manifest.commands) };
|
|
5412
|
+
}
|
|
5413
|
+
var OPS_METHODS = /* @__PURE__ */ new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
|
|
5414
|
+
var OPS_PATH_PREFIX = "/_ops/";
|
|
5415
|
+
function unusableBecause(command) {
|
|
5416
|
+
if (typeof command?.name !== "string" || command.name.length === 0) {
|
|
5417
|
+
return "it has no name";
|
|
5418
|
+
}
|
|
5419
|
+
if (typeof command.method !== "string" || !OPS_METHODS.has(command.method)) {
|
|
5420
|
+
return `its method is ${JSON.stringify(command.method)}`;
|
|
5421
|
+
}
|
|
5422
|
+
if (typeof command.path !== "string" || !command.path.startsWith(OPS_PATH_PREFIX)) {
|
|
5423
|
+
return `its path ${JSON.stringify(command.path)} is outside ${OPS_PATH_PREFIX}`;
|
|
5424
|
+
}
|
|
5425
|
+
if (command.path.split("/").includes("..")) {
|
|
5426
|
+
return `its path ${JSON.stringify(command.path)} climbs out of the ops namespace`;
|
|
5427
|
+
}
|
|
5428
|
+
return null;
|
|
5429
|
+
}
|
|
5430
|
+
function usableCommands(commands) {
|
|
5431
|
+
const usable = [];
|
|
5432
|
+
for (const command of commands) {
|
|
5433
|
+
const reason = unusableBecause(command);
|
|
5434
|
+
if (reason !== null) {
|
|
5435
|
+
console.error(`\u26A0\uFE0F Ignoring an ops command the manifest announced: ${reason}.`);
|
|
5436
|
+
continue;
|
|
5437
|
+
}
|
|
5438
|
+
usable.push({ ...command, input: isPlainObject(command.input) ? command.input : {} });
|
|
5439
|
+
}
|
|
5440
|
+
return usable;
|
|
5441
|
+
}
|
|
5442
|
+
function isPlainObject(value) {
|
|
5443
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
5384
5444
|
}
|
|
5385
5445
|
function buildCommandPath(command, params, query) {
|
|
5386
5446
|
const path5 = command.path.replace(/:([A-Za-z0-9_]+)/g, (_match, name) => {
|
|
@@ -5407,6 +5467,10 @@ var SECTIONS = [
|
|
|
5407
5467
|
function isSchema(value) {
|
|
5408
5468
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
5409
5469
|
}
|
|
5470
|
+
var MAX_SCHEMA_DEPTH = 12;
|
|
5471
|
+
function plain(value) {
|
|
5472
|
+
return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, "?");
|
|
5473
|
+
}
|
|
5410
5474
|
function typeName(schema) {
|
|
5411
5475
|
if (Array.isArray(schema.enum)) {
|
|
5412
5476
|
return schema.enum.every((v) => typeof v === "string") ? "string" : "value";
|
|
@@ -5453,7 +5517,7 @@ function constraintNotes(schema) {
|
|
|
5453
5517
|
}
|
|
5454
5518
|
return notes;
|
|
5455
5519
|
}
|
|
5456
|
-
function collectFields(schema, prefix = "") {
|
|
5520
|
+
function collectFields(schema, prefix = "", depth = 0) {
|
|
5457
5521
|
const properties = isSchema(schema.properties) ? schema.properties : {};
|
|
5458
5522
|
const required = Array.isArray(schema.required) ? schema.required : [];
|
|
5459
5523
|
const rows = [];
|
|
@@ -5461,17 +5525,17 @@ function collectFields(schema, prefix = "") {
|
|
|
5461
5525
|
if (!isSchema(raw)) {
|
|
5462
5526
|
continue;
|
|
5463
5527
|
}
|
|
5464
|
-
const path5 = prefix ? `${prefix}.${name}` : name;
|
|
5465
|
-
const nested = isSchema(raw.properties) ? collectFields(raw, path5) : [];
|
|
5528
|
+
const path5 = prefix ? `${prefix}.${plain(name)}` : plain(name);
|
|
5529
|
+
const nested = isSchema(raw.properties) && depth < MAX_SCHEMA_DEPTH ? collectFields(raw, path5, depth + 1) : [];
|
|
5466
5530
|
if (nested.length > 0) {
|
|
5467
5531
|
rows.push(...nested);
|
|
5468
5532
|
continue;
|
|
5469
5533
|
}
|
|
5470
5534
|
rows.push({
|
|
5471
5535
|
name: path5,
|
|
5472
|
-
type: typeName(raw),
|
|
5536
|
+
type: plain(typeName(raw)),
|
|
5473
5537
|
requirement: required.includes(name) ? "required" : "optional",
|
|
5474
|
-
notes: constraintNotes(raw).join(", ")
|
|
5538
|
+
notes: plain(constraintNotes(raw).join(", "))
|
|
5475
5539
|
});
|
|
5476
5540
|
}
|
|
5477
5541
|
return rows;
|
|
@@ -5490,7 +5554,7 @@ function renderSection(label, flag, rows) {
|
|
|
5490
5554
|
return lines;
|
|
5491
5555
|
}
|
|
5492
5556
|
function renderCommandUsage(command) {
|
|
5493
|
-
const lines = [`${command.name} ${command.method} ${command.path}`, ""];
|
|
5557
|
+
const lines = [`${plain(command.name)} ${command.method} ${plain(command.path)}`, ""];
|
|
5494
5558
|
let described = false;
|
|
5495
5559
|
for (const section of SECTIONS) {
|
|
5496
5560
|
const schema = command.input[section.key];
|
|
@@ -5507,7 +5571,7 @@ function renderCommandUsage(command) {
|
|
|
5507
5571
|
if (!described) {
|
|
5508
5572
|
lines.push(" Takes no input.", "");
|
|
5509
5573
|
}
|
|
5510
|
-
lines.push(` Invoke: spfn ops call ${command.name}${exampleFlags(command)}`);
|
|
5574
|
+
lines.push(` Invoke: spfn ops call ${plain(command.name)}${exampleFlags(command)}`);
|
|
5511
5575
|
return lines.join("\n");
|
|
5512
5576
|
}
|
|
5513
5577
|
function exampleFlags(command) {
|
|
@@ -5598,18 +5662,37 @@ function deleteOpsToken(account) {
|
|
|
5598
5662
|
}
|
|
5599
5663
|
|
|
5600
5664
|
// src/commands/ops/resolve.ts
|
|
5665
|
+
function isLoopback(hostname) {
|
|
5666
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname.endsWith(".localhost");
|
|
5667
|
+
}
|
|
5668
|
+
function assertTransportSafe(parsed, appUrl) {
|
|
5669
|
+
if (parsed.protocol === "https:" || parsed.protocol === "http:" && isLoopback(parsed.hostname)) {
|
|
5670
|
+
return;
|
|
5671
|
+
}
|
|
5672
|
+
if (parsed.protocol === "http:") {
|
|
5673
|
+
console.error(chalk33.red(`\u274C Refusing to talk to ${appUrl} over http.`));
|
|
5674
|
+
console.error(chalk33.gray(" An administrator password and an ops token cross this connection, and http sends both in the clear."));
|
|
5675
|
+
console.error(chalk33.gray(" Use https. http is allowed only against localhost."));
|
|
5676
|
+
process.exit(1);
|
|
5677
|
+
}
|
|
5678
|
+
console.error(chalk33.red(`\u274C App URL must be https, got "${parsed.protocol}//".`));
|
|
5679
|
+
console.error(chalk33.gray(" The ops surface is reached over HTTP(S) \u2014 http is allowed only against localhost."));
|
|
5680
|
+
process.exit(1);
|
|
5681
|
+
}
|
|
5601
5682
|
function resolveAppUrl(options) {
|
|
5602
5683
|
const appUrl = options.app ?? process.env.SPFN_OPS_APP;
|
|
5603
5684
|
if (!appUrl) {
|
|
5604
5685
|
console.error(chalk33.red("\u274C No app URL. Pass --app <url> or set SPFN_OPS_APP."));
|
|
5605
5686
|
process.exit(1);
|
|
5606
5687
|
}
|
|
5688
|
+
let parsed;
|
|
5607
5689
|
try {
|
|
5608
|
-
new URL(appUrl);
|
|
5690
|
+
parsed = new URL(appUrl);
|
|
5609
5691
|
} catch {
|
|
5610
5692
|
console.error(chalk33.red(`\u274C Invalid app URL: ${appUrl}`));
|
|
5611
5693
|
process.exit(1);
|
|
5612
5694
|
}
|
|
5695
|
+
assertTransportSafe(parsed, appUrl);
|
|
5613
5696
|
return appUrl;
|
|
5614
5697
|
}
|
|
5615
5698
|
function appAccount(appUrl) {
|
|
@@ -5623,7 +5706,13 @@ async function resolveToken(options, appUrl) {
|
|
|
5623
5706
|
return process.env.SPFN_OPS_TOKEN;
|
|
5624
5707
|
}
|
|
5625
5708
|
if (keychainSupported()) {
|
|
5626
|
-
const stored = await loadOpsToken(appAccount(appUrl))
|
|
5709
|
+
const stored = await loadOpsToken(appAccount(appUrl)).catch((err) => {
|
|
5710
|
+
console.error(chalk33.yellow(
|
|
5711
|
+
`\u26A0\uFE0F Could not read the keychain (${err instanceof Error ? err.message : String(err)}).`
|
|
5712
|
+
));
|
|
5713
|
+
console.error(chalk33.gray(" Unlock it, or pass --token / set SPFN_OPS_TOKEN for this run."));
|
|
5714
|
+
return null;
|
|
5715
|
+
});
|
|
5627
5716
|
if (stored) {
|
|
5628
5717
|
return stored;
|
|
5629
5718
|
}
|
|
@@ -5654,16 +5743,26 @@ import prompts9 from "prompts";
|
|
|
5654
5743
|
import chalk34 from "chalk";
|
|
5655
5744
|
|
|
5656
5745
|
// src/utils/ops/auth-crypto.ts
|
|
5746
|
+
import { createRequire } from "module";
|
|
5747
|
+
import { join as join25 } from "path";
|
|
5748
|
+
import { pathToFileURL as pathToFileURL3 } from "url";
|
|
5657
5749
|
var CRYPTO_ENTRY_SINCE = "0.3.0-beta.2";
|
|
5658
5750
|
var CRYPTO_ENTRY = "@spfn/auth/crypto";
|
|
5751
|
+
async function importFromApp(specifier) {
|
|
5752
|
+
const requireFromApp = createRequire(join25(process.cwd(), "noop.js"));
|
|
5753
|
+
return await import(pathToFileURL3(requireFromApp.resolve(specifier)).href);
|
|
5754
|
+
}
|
|
5659
5755
|
async function loadAuthCrypto() {
|
|
5660
5756
|
try {
|
|
5661
|
-
return await
|
|
5757
|
+
return await importFromApp(CRYPTO_ENTRY);
|
|
5662
5758
|
} catch (err) {
|
|
5663
5759
|
const code = err.code;
|
|
5664
5760
|
if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") {
|
|
5665
5761
|
throw new Error(
|
|
5666
|
-
|
|
5762
|
+
`No @spfn/auth is installed in ${process.cwd()}, and ops tokens live in its schema \u2014 so this app has none to issue, list or revoke.
|
|
5763
|
+
An app that uses the ops surface installs @spfn/auth for opsTokenAuth; add it there.
|
|
5764
|
+
Run this from the app directory \u2014 the package is resolved from where the command runs.
|
|
5765
|
+
Invoking commands with a token you already hold (spfn ops list / call) does not need it.`
|
|
5667
5766
|
);
|
|
5668
5767
|
}
|
|
5669
5768
|
if (code === "ERR_PACKAGE_PATH_NOT_EXPORTED") {
|
|
@@ -5677,8 +5776,18 @@ async function loadAuthCrypto() {
|
|
|
5677
5776
|
}
|
|
5678
5777
|
|
|
5679
5778
|
// src/utils/ops/admin-session.ts
|
|
5779
|
+
function parseJsonAnswer(text, status, statusText) {
|
|
5780
|
+
if (!text) {
|
|
5781
|
+
return {};
|
|
5782
|
+
}
|
|
5783
|
+
try {
|
|
5784
|
+
return JSON.parse(text);
|
|
5785
|
+
} catch {
|
|
5786
|
+
throw new Error(`The app answered ${status} ${statusText} with something that is not JSON.`);
|
|
5787
|
+
}
|
|
5788
|
+
}
|
|
5680
5789
|
async function postJson(appUrl, path5, body, authorization) {
|
|
5681
|
-
const response = await fetch(
|
|
5790
|
+
const response = await fetch(joinUrl(appUrl, path5), {
|
|
5682
5791
|
method: "POST",
|
|
5683
5792
|
headers: {
|
|
5684
5793
|
"Content-Type": "application/json",
|
|
@@ -5687,7 +5796,7 @@ async function postJson(appUrl, path5, body, authorization) {
|
|
|
5687
5796
|
body: JSON.stringify(body)
|
|
5688
5797
|
});
|
|
5689
5798
|
const text = await response.text();
|
|
5690
|
-
const parsed = text
|
|
5799
|
+
const parsed = parseJsonAnswer(text, response.status, response.statusText);
|
|
5691
5800
|
if (!response.ok) {
|
|
5692
5801
|
throw new Error(parsed.message ?? parsed.error ?? `${response.status} ${response.statusText}`);
|
|
5693
5802
|
}
|
|
@@ -5752,7 +5861,7 @@ async function withAdminSession(appUrl, run2) {
|
|
|
5752
5861
|
return result;
|
|
5753
5862
|
}
|
|
5754
5863
|
async function adminRequest(appUrl, method, path5, session, body) {
|
|
5755
|
-
const response = await fetch(
|
|
5864
|
+
const response = await fetch(joinUrl(appUrl, path5), {
|
|
5756
5865
|
method,
|
|
5757
5866
|
headers: {
|
|
5758
5867
|
Authorization: session.authorization,
|
|
@@ -5761,7 +5870,7 @@ async function adminRequest(appUrl, method, path5, session, body) {
|
|
|
5761
5870
|
...body === void 0 ? {} : { body: JSON.stringify(body) }
|
|
5762
5871
|
});
|
|
5763
5872
|
const text = await response.text();
|
|
5764
|
-
const parsed = text
|
|
5873
|
+
const parsed = parseJsonAnswer(text, response.status, response.statusText);
|
|
5765
5874
|
if (!response.ok) {
|
|
5766
5875
|
throw new Error(parsed.message ?? parsed.error ?? `${response.status} ${response.statusText}`);
|
|
5767
5876
|
}
|
|
@@ -5920,7 +6029,7 @@ async function listCommands(options) {
|
|
|
5920
6029
|
console.log(chalk36.bold(`Ops commands at ${appUrl}:
|
|
5921
6030
|
`));
|
|
5922
6031
|
for (const command of manifest.commands) {
|
|
5923
|
-
console.log(` ${chalk36.cyan(command.name)} ${chalk36.gray(`${command.method} ${command.path}`)} input: ${inputSummary(command)}`);
|
|
6032
|
+
console.log(` ${chalk36.cyan(plain(command.name))} ${chalk36.gray(`${command.method} ${plain(command.path)}`)} input: ${inputSummary(command)}`);
|
|
5924
6033
|
}
|
|
5925
6034
|
console.log(chalk36.gray(`
|
|
5926
6035
|
\u{1F4A1} Invoke: spfn ops call <name> [--param k=v] [--query k=v] [--data '{"..."}']`));
|
|
@@ -5932,8 +6041,8 @@ async function callCommand(name, options) {
|
|
|
5932
6041
|
const manifest = await fetchOpsManifest(appUrl, token);
|
|
5933
6042
|
const command = manifest.commands.find((c) => c.name === name);
|
|
5934
6043
|
if (!command) {
|
|
5935
|
-
console.error(chalk36.red(`\u274C Unknown ops command "${name}".`));
|
|
5936
|
-
console.error(chalk36.gray(` Known: ${manifest.commands.map((c) => c.name).join(", ") || "(none)"}`));
|
|
6044
|
+
console.error(chalk36.red(`\u274C Unknown ops command "${plain(name)}".`));
|
|
6045
|
+
console.error(chalk36.gray(` Known: ${manifest.commands.map((c) => plain(c.name)).join(", ") || "(none)"}`));
|
|
5937
6046
|
process.exit(1);
|
|
5938
6047
|
}
|
|
5939
6048
|
if (options.describe) {
|
|
@@ -5943,7 +6052,7 @@ async function callCommand(name, options) {
|
|
|
5943
6052
|
let body;
|
|
5944
6053
|
if (options.data !== void 0) {
|
|
5945
6054
|
if (command.method === "GET") {
|
|
5946
|
-
console.error(chalk36.red(`\u274C "${name}" is a ${command.method} command and takes no request body.`));
|
|
6055
|
+
console.error(chalk36.red(`\u274C "${plain(name)}" is a ${command.method} command and takes no request body.`));
|
|
5947
6056
|
console.error(chalk36.gray(" Pass its input with --query k=v (or --param k=v for path segments)."));
|
|
5948
6057
|
process.exit(1);
|
|
5949
6058
|
}
|
|
@@ -5964,7 +6073,7 @@ async function callCommand(name, options) {
|
|
|
5964
6073
|
console.log(rendered);
|
|
5965
6074
|
return;
|
|
5966
6075
|
}
|
|
5967
|
-
console.error(chalk36.red(`\u274C ${command.method} ${command.path} answered ${response.status}`));
|
|
6076
|
+
console.error(chalk36.red(`\u274C ${command.method} ${plain(command.path)} answered ${response.status}`));
|
|
5968
6077
|
console.error(rendered);
|
|
5969
6078
|
process.exit(1);
|
|
5970
6079
|
}
|
|
@@ -5994,7 +6103,7 @@ function resolveEnv(env9) {
|
|
|
5994
6103
|
|
|
5995
6104
|
// src/commands/secret/store-value.ts
|
|
5996
6105
|
init_logger();
|
|
5997
|
-
import { join as
|
|
6106
|
+
import { join as join27 } from "path";
|
|
5998
6107
|
init_env_file();
|
|
5999
6108
|
|
|
6000
6109
|
// src/utils/sops.ts
|
|
@@ -6043,19 +6152,19 @@ async function sopsUpdateKeys(absFile) {
|
|
|
6043
6152
|
|
|
6044
6153
|
// src/utils/secret-config.ts
|
|
6045
6154
|
import { existsSync as existsSync28, readdirSync as readdirSync2 } from "fs";
|
|
6046
|
-
import { join as
|
|
6155
|
+
import { join as join26 } from "path";
|
|
6047
6156
|
var SECRETS_DIR = "secrets";
|
|
6048
6157
|
function getSopsFile(cwd, env9) {
|
|
6049
6158
|
const relFile = `${SECRETS_DIR}/${env9}.enc.json`;
|
|
6050
|
-
return { absFile:
|
|
6159
|
+
return { absFile: join26(cwd, relFile), relFile };
|
|
6051
6160
|
}
|
|
6052
6161
|
function findUp(cwd, filename, maxDepth = 6) {
|
|
6053
6162
|
let dir = cwd;
|
|
6054
6163
|
for (let depth = 0; depth < maxDepth; depth++) {
|
|
6055
|
-
if (existsSync28(
|
|
6164
|
+
if (existsSync28(join26(dir, filename))) {
|
|
6056
6165
|
return dir;
|
|
6057
6166
|
}
|
|
6058
|
-
const parent =
|
|
6167
|
+
const parent = join26(dir, "..");
|
|
6059
6168
|
if (parent === dir) {
|
|
6060
6169
|
break;
|
|
6061
6170
|
}
|
|
@@ -6065,17 +6174,17 @@ function findUp(cwd, filename, maxDepth = 6) {
|
|
|
6065
6174
|
}
|
|
6066
6175
|
function findSopsConfig(cwd) {
|
|
6067
6176
|
const dir = findUp(cwd, ".sops.yaml");
|
|
6068
|
-
return dir ?
|
|
6177
|
+
return dir ? join26(dir, ".sops.yaml") : null;
|
|
6069
6178
|
}
|
|
6070
6179
|
function hasSopsConfig(cwd) {
|
|
6071
6180
|
return findSopsConfig(cwd) !== null;
|
|
6072
6181
|
}
|
|
6073
6182
|
function listSopsFiles(cwd) {
|
|
6074
|
-
const dir =
|
|
6183
|
+
const dir = join26(cwd, SECRETS_DIR);
|
|
6075
6184
|
if (!existsSync28(dir)) {
|
|
6076
6185
|
return [];
|
|
6077
6186
|
}
|
|
6078
|
-
return readdirSync2(dir).filter((name) => name.endsWith(".enc.json")).map((name) =>
|
|
6187
|
+
return readdirSync2(dir).filter((name) => name.endsWith(".enc.json")).map((name) => join26(dir, name));
|
|
6079
6188
|
}
|
|
6080
6189
|
|
|
6081
6190
|
// src/commands/secret/store-value.ts
|
|
@@ -6104,7 +6213,7 @@ async function storeSecret(cwd, env9, key, value) {
|
|
|
6104
6213
|
);
|
|
6105
6214
|
}
|
|
6106
6215
|
await store.set(keychainName(key), value);
|
|
6107
|
-
const serverEnvPath =
|
|
6216
|
+
const serverEnvPath = join27(cwd, ".env.server");
|
|
6108
6217
|
const result = upsertEnvVar(serverEnvPath, key, keychainRef(key));
|
|
6109
6218
|
restrictEnvFilePerms(serverEnvPath);
|
|
6110
6219
|
ensureGitignored(cwd, [{ pattern: ".env.server", comment: "spfn server env (secrets)" }]);
|
|
@@ -6344,10 +6453,10 @@ init_logger();
|
|
|
6344
6453
|
import { execa as execa12 } from "execa";
|
|
6345
6454
|
import { existsSync as existsSync29, mkdirSync as mkdirSync6 } from "fs";
|
|
6346
6455
|
import { homedir } from "os";
|
|
6347
|
-
import { dirname as dirname5, join as
|
|
6456
|
+
import { dirname as dirname5, join as join28 } from "path";
|
|
6348
6457
|
import chalk41 from "chalk";
|
|
6349
6458
|
function ageKeyFile() {
|
|
6350
|
-
return process.env.SOPS_AGE_KEY_FILE ??
|
|
6459
|
+
return process.env.SOPS_AGE_KEY_FILE ?? join28(homedir(), ".config", "sops", "age", "keys.txt");
|
|
6351
6460
|
}
|
|
6352
6461
|
async function ensureAgeInstalled() {
|
|
6353
6462
|
try {
|
|
@@ -6395,7 +6504,7 @@ function printPublicKeys(keys) {
|
|
|
6395
6504
|
// src/commands/secret/recipients.ts
|
|
6396
6505
|
init_logger();
|
|
6397
6506
|
import { existsSync as existsSync30, readFileSync as readFileSync12, writeFileSync as writeFileSync19 } from "fs";
|
|
6398
|
-
import { join as
|
|
6507
|
+
import { join as join29 } from "path";
|
|
6399
6508
|
import { parse as parse3, stringify } from "yaml";
|
|
6400
6509
|
import chalk42 from "chalk";
|
|
6401
6510
|
var SOPS_CONFIG = ".sops.yaml";
|
|
@@ -6403,7 +6512,7 @@ var DEFAULT_PATH_REGEX = "secrets/.*\\.enc\\.json$";
|
|
|
6403
6512
|
var AGE_RECIPIENT = /^age1[0-9a-z]+$/;
|
|
6404
6513
|
async function secretRecipients(action, key, _options) {
|
|
6405
6514
|
const cwd = process.cwd();
|
|
6406
|
-
const configPath =
|
|
6515
|
+
const configPath = join29(cwd, SOPS_CONFIG);
|
|
6407
6516
|
if (action === "list") {
|
|
6408
6517
|
listRecipients(configPath);
|
|
6409
6518
|
return;
|
|
@@ -6488,7 +6597,7 @@ async function reencrypt(cwd) {
|
|
|
6488
6597
|
|
|
6489
6598
|
// src/commands/secret/check.ts
|
|
6490
6599
|
init_logger();
|
|
6491
|
-
import { join as
|
|
6600
|
+
import { join as join30 } from "path";
|
|
6492
6601
|
import chalk43 from "chalk";
|
|
6493
6602
|
init_env_file();
|
|
6494
6603
|
var COMMITTED_FILES = [".env", ".env.example"];
|
|
@@ -6507,14 +6616,14 @@ async function secretCheck(options) {
|
|
|
6507
6616
|
const issues = [];
|
|
6508
6617
|
const warnings = [];
|
|
6509
6618
|
for (const file of COMMITTED_FILES) {
|
|
6510
|
-
const parsed = parseEnvFile(
|
|
6619
|
+
const parsed = parseEnvFile(join30(cwd, file));
|
|
6511
6620
|
for (const [key, value] of Object.entries(parsed)) {
|
|
6512
6621
|
if (secretKeys.has(key) && value.length > 0 && !PLACEHOLDER.test(value)) {
|
|
6513
6622
|
issues.push(`${chalk43.cyan(key)} has a real value in committed ${chalk43.yellow(file)} \u2014 move it to the keychain/SOPS.`);
|
|
6514
6623
|
}
|
|
6515
6624
|
}
|
|
6516
6625
|
}
|
|
6517
|
-
const serverEnv = parseEnvFile(
|
|
6626
|
+
const serverEnv = parseEnvFile(join30(cwd, ".env.server"));
|
|
6518
6627
|
for (const key of secretKeys) {
|
|
6519
6628
|
const value = serverEnv[key];
|
|
6520
6629
|
if (value && !value.startsWith(KEYCHAIN_REF_PREFIX)) {
|
|
@@ -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"]
|
package/dist/templates/README.md
CHANGED
|
@@ -22,13 +22,20 @@ services:
|
|
|
22
22
|
context: .
|
|
23
23
|
dockerfile: Dockerfile
|
|
24
24
|
ports:
|
|
25
|
-
- "3790
|
|
26
|
-
- "8790
|
|
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
|
|
@@ -8,7 +8,6 @@ import { authRouter, authenticate } from '@spfn/auth/server';
|
|
|
8
8
|
import { defineRouter } from '@spfn/core/route';
|
|
9
9
|
import { mcpRouter } from './mcp';
|
|
10
10
|
import { getRoot } from './routes/root';
|
|
11
|
-
import { getHealth } from './routes/health';
|
|
12
11
|
import {
|
|
13
12
|
listExamples,
|
|
14
13
|
getExample,
|
|
@@ -19,7 +18,6 @@ import {
|
|
|
19
18
|
|
|
20
19
|
export const appRouter = defineRouter({
|
|
21
20
|
getRoot,
|
|
22
|
-
getHealth,
|
|
23
21
|
listExamples,
|
|
24
22
|
getExample,
|
|
25
23
|
createExample,
|
|
@@ -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,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spfn",
|
|
3
|
-
"version": "0.3.0-beta.
|
|
3
|
+
"version": "0.3.0-beta.3",
|
|
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.
|
|
60
|
+
"@spfn/core": ">=0.3.0-beta.3 <0.4.0",
|
|
61
61
|
"typescript": "^5.3.0"
|
|
62
62
|
},
|
|
63
63
|
"peerDependenciesMeta": {
|
|
@@ -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
|
-
});
|