spfn 0.3.0-beta.3 → 0.3.0-beta.5
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 +72 -16
- package/dist/index.js +1271 -27
- package/dist/templates/README.md +21 -6
- package/dist/templates/modes/full/src/server/router.ts +3 -3
- package/dist/templates/modes/full/src/server/routes/ops.ts +52 -0
- package/dist/templates/modes/full/src/server/routes/root.ts +1 -1
- package/package.json +2 -1
- package/dist/templates/modes/full/src/server/config/env.config.ts +0 -23
- package/dist/templates/modes/full/src/server/mcp.ts +0 -75
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
|
|
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.
|
|
947
|
+
return "0.3.0-beta.5";
|
|
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:
|
|
1191
|
-
|
|
1192
|
-
|
|
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:
|
|
1423
|
-
|
|
1424
|
-
|
|
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
|
});
|
|
@@ -1752,7 +1757,7 @@ var init_function_migrations = __esm({
|
|
|
1752
1757
|
});
|
|
1753
1758
|
|
|
1754
1759
|
// src/index.ts
|
|
1755
|
-
import { Command as
|
|
1760
|
+
import { Command as Command18 } from "commander";
|
|
1756
1761
|
|
|
1757
1762
|
// src/commands/create.ts
|
|
1758
1763
|
init_logger();
|
|
@@ -5372,6 +5377,11 @@ envCommand.command("validate").description("Validate environment variables again
|
|
|
5372
5377
|
import { Command as Command15 } from "commander";
|
|
5373
5378
|
import chalk36 from "chalk";
|
|
5374
5379
|
|
|
5380
|
+
// src/utils/ops/plain.ts
|
|
5381
|
+
function plain(value) {
|
|
5382
|
+
return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, "?");
|
|
5383
|
+
}
|
|
5384
|
+
|
|
5375
5385
|
// src/utils/ops/client.ts
|
|
5376
5386
|
function joinUrl(appUrl, path5) {
|
|
5377
5387
|
return appUrl.replace(/\/+$/, "") + path5;
|
|
@@ -5408,10 +5418,59 @@ async function fetchOpsManifest(appUrl, token) {
|
|
|
5408
5418
|
if (manifest?.manifestVersion !== 1 || !Array.isArray(manifest.commands)) {
|
|
5409
5419
|
throw new Error("The manifest answer has an unknown shape.");
|
|
5410
5420
|
}
|
|
5411
|
-
|
|
5421
|
+
const modules = usableModules(manifest.modules);
|
|
5422
|
+
return {
|
|
5423
|
+
manifestVersion: 1,
|
|
5424
|
+
...manifest.modules !== void 0 ? { modules } : {},
|
|
5425
|
+
commands: usableCommands(manifest.commands, new Set(modules.map((module) => module.id)))
|
|
5426
|
+
};
|
|
5412
5427
|
}
|
|
5413
5428
|
var OPS_METHODS = /* @__PURE__ */ new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
|
|
5429
|
+
var OPS_EFFECTS = /* @__PURE__ */ new Set(["read", "write", "destructive"]);
|
|
5430
|
+
var MODULE_ID = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
5431
|
+
var CONTRACT_VERSION = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)*$/;
|
|
5432
|
+
var MAX_ID_LENGTH = 64;
|
|
5433
|
+
var MAX_SOURCE_LENGTH = 128;
|
|
5434
|
+
var MAX_SUMMARY_LENGTH = 500;
|
|
5435
|
+
var MAX_SCOPES = 64;
|
|
5436
|
+
var MAX_SCOPE_LENGTH = 128;
|
|
5414
5437
|
var OPS_PATH_PREFIX = "/_ops/";
|
|
5438
|
+
var OPS_PATH_BASE = "https://spfn.invalid";
|
|
5439
|
+
function unstableOpsPathBecause(path5) {
|
|
5440
|
+
let url;
|
|
5441
|
+
try {
|
|
5442
|
+
url = new URL(path5, OPS_PATH_BASE);
|
|
5443
|
+
} catch {
|
|
5444
|
+
return "is not a valid URL path";
|
|
5445
|
+
}
|
|
5446
|
+
if (url.origin !== OPS_PATH_BASE || url.search !== "" || url.hash !== "") {
|
|
5447
|
+
return "is not a plain absolute path";
|
|
5448
|
+
}
|
|
5449
|
+
if (url.pathname !== path5) {
|
|
5450
|
+
return "changes when URL-normalized";
|
|
5451
|
+
}
|
|
5452
|
+
if (!url.pathname.startsWith(OPS_PATH_PREFIX)) {
|
|
5453
|
+
return `is outside ${OPS_PATH_PREFIX}`;
|
|
5454
|
+
}
|
|
5455
|
+
const slashCount = (path5.match(/\//g) ?? []).length;
|
|
5456
|
+
let decoded = path5;
|
|
5457
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
5458
|
+
let next;
|
|
5459
|
+
try {
|
|
5460
|
+
next = decodeURIComponent(decoded);
|
|
5461
|
+
} catch {
|
|
5462
|
+
return "contains malformed percent encoding";
|
|
5463
|
+
}
|
|
5464
|
+
if (next.includes("\\") || (next.match(/\//g) ?? []).length !== slashCount || next.split("/").some((segment) => segment === "." || segment === "..")) {
|
|
5465
|
+
return "contains encoded path separators or dot segments";
|
|
5466
|
+
}
|
|
5467
|
+
if (next === decoded) {
|
|
5468
|
+
return null;
|
|
5469
|
+
}
|
|
5470
|
+
decoded = next;
|
|
5471
|
+
}
|
|
5472
|
+
return "uses too many percent-encoding layers";
|
|
5473
|
+
}
|
|
5415
5474
|
function unusableBecause(command) {
|
|
5416
5475
|
if (typeof command?.name !== "string" || command.name.length === 0) {
|
|
5417
5476
|
return "it has no name";
|
|
@@ -5419,15 +5478,16 @@ function unusableBecause(command) {
|
|
|
5419
5478
|
if (typeof command.method !== "string" || !OPS_METHODS.has(command.method)) {
|
|
5420
5479
|
return `its method is ${JSON.stringify(command.method)}`;
|
|
5421
5480
|
}
|
|
5422
|
-
if (typeof command.path !== "string"
|
|
5423
|
-
return `its path ${JSON.stringify(command.path)} is
|
|
5481
|
+
if (typeof command.path !== "string") {
|
|
5482
|
+
return `its path ${JSON.stringify(command.path)} is not a string`;
|
|
5424
5483
|
}
|
|
5425
|
-
|
|
5426
|
-
|
|
5484
|
+
const pathReason = unstableOpsPathBecause(command.path);
|
|
5485
|
+
if (pathReason !== null) {
|
|
5486
|
+
return `its path ${JSON.stringify(command.path)} ${pathReason}`;
|
|
5427
5487
|
}
|
|
5428
5488
|
return null;
|
|
5429
5489
|
}
|
|
5430
|
-
function usableCommands(commands) {
|
|
5490
|
+
function usableCommands(commands, moduleIds) {
|
|
5431
5491
|
const usable = [];
|
|
5432
5492
|
for (const command of commands) {
|
|
5433
5493
|
const reason = unusableBecause(command);
|
|
@@ -5435,19 +5495,142 @@ function usableCommands(commands) {
|
|
|
5435
5495
|
console.error(`\u26A0\uFE0F Ignoring an ops command the manifest announced: ${reason}.`);
|
|
5436
5496
|
continue;
|
|
5437
5497
|
}
|
|
5438
|
-
|
|
5498
|
+
const normalized = {
|
|
5499
|
+
name: command.name,
|
|
5500
|
+
method: command.method,
|
|
5501
|
+
path: command.path,
|
|
5502
|
+
input: isPlainObject(command.input) ? command.input : {}
|
|
5503
|
+
};
|
|
5504
|
+
const metadataReason = unusableModuleMetadataBecause(command, moduleIds);
|
|
5505
|
+
if (metadataReason === null && command.module !== void 0) {
|
|
5506
|
+
normalized.module = command.module;
|
|
5507
|
+
normalized.summary = command.summary;
|
|
5508
|
+
normalized.effect = command.effect;
|
|
5509
|
+
normalized.scopes = [...command.scopes];
|
|
5510
|
+
} else if (metadataReason !== null) {
|
|
5511
|
+
normalized.metadataRejected = true;
|
|
5512
|
+
console.error(
|
|
5513
|
+
`\u26A0\uFE0F Ignoring module metadata for ops command "${plain(command.name)}": ${metadataReason}. Calling it will require --yes.`
|
|
5514
|
+
);
|
|
5515
|
+
}
|
|
5516
|
+
usable.push(normalized);
|
|
5439
5517
|
}
|
|
5440
5518
|
return usable;
|
|
5441
5519
|
}
|
|
5520
|
+
function unusableModuleMetadataBecause(command, moduleIds) {
|
|
5521
|
+
const carriesMetadata = command.module !== void 0 || command.summary !== void 0 || command.effect !== void 0 || command.scopes !== void 0;
|
|
5522
|
+
if (!carriesMetadata) {
|
|
5523
|
+
return null;
|
|
5524
|
+
}
|
|
5525
|
+
if (typeof command.module !== "string" || !moduleIds.has(command.module)) {
|
|
5526
|
+
return "it names no usable module";
|
|
5527
|
+
}
|
|
5528
|
+
if (!command.name.startsWith(`${command.module}.`)) {
|
|
5529
|
+
return "its qualified name does not start with the module id";
|
|
5530
|
+
}
|
|
5531
|
+
if (!command.path.startsWith(`${OPS_PATH_PREFIX}${command.module}/`)) {
|
|
5532
|
+
return "its path is outside the module namespace";
|
|
5533
|
+
}
|
|
5534
|
+
if (typeof command.summary !== "string" || command.summary.length === 0 || command.summary.length > MAX_SUMMARY_LENGTH) {
|
|
5535
|
+
return "its summary is missing or too long";
|
|
5536
|
+
}
|
|
5537
|
+
if (typeof command.effect !== "string" || !OPS_EFFECTS.has(command.effect)) {
|
|
5538
|
+
return "its effect is unknown";
|
|
5539
|
+
}
|
|
5540
|
+
if (!Array.isArray(command.scopes) || command.scopes.length === 0 || command.scopes.length > MAX_SCOPES || command.scopes.some((scope) => typeof scope !== "string" || scope.length === 0 || scope.length > MAX_SCOPE_LENGTH)) {
|
|
5541
|
+
return "its scopes are missing or invalid";
|
|
5542
|
+
}
|
|
5543
|
+
return null;
|
|
5544
|
+
}
|
|
5545
|
+
function usableModules(value) {
|
|
5546
|
+
if (value === void 0) {
|
|
5547
|
+
return [];
|
|
5548
|
+
}
|
|
5549
|
+
if (!Array.isArray(value)) {
|
|
5550
|
+
console.error("\u26A0\uFE0F Ignoring ops module metadata: modules is not an array.");
|
|
5551
|
+
return [];
|
|
5552
|
+
}
|
|
5553
|
+
const modules = [];
|
|
5554
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
5555
|
+
for (const raw of value) {
|
|
5556
|
+
const reason = unusableModuleBecause(raw, claimed);
|
|
5557
|
+
if (reason !== null) {
|
|
5558
|
+
console.error(`\u26A0\uFE0F Ignoring an ops module the manifest announced: ${reason}.`);
|
|
5559
|
+
continue;
|
|
5560
|
+
}
|
|
5561
|
+
const module = raw;
|
|
5562
|
+
claimed.add(module.id);
|
|
5563
|
+
modules.push({
|
|
5564
|
+
id: module.id,
|
|
5565
|
+
source: module.source,
|
|
5566
|
+
contractVersion: module.contractVersion,
|
|
5567
|
+
summary: module.summary
|
|
5568
|
+
});
|
|
5569
|
+
}
|
|
5570
|
+
return modules;
|
|
5571
|
+
}
|
|
5572
|
+
function unusableModuleBecause(value, claimed) {
|
|
5573
|
+
if (!isPlainObject(value)) {
|
|
5574
|
+
return "it is not an object";
|
|
5575
|
+
}
|
|
5576
|
+
const id = value.id;
|
|
5577
|
+
if (typeof id !== "string" || id.length > MAX_ID_LENGTH || !MODULE_ID.test(id)) {
|
|
5578
|
+
return "its id is invalid";
|
|
5579
|
+
}
|
|
5580
|
+
if (claimed.has(id)) {
|
|
5581
|
+
return `id "${id}" is duplicated`;
|
|
5582
|
+
}
|
|
5583
|
+
if (typeof value.source !== "string" || value.source.length === 0 || value.source.length > MAX_SOURCE_LENGTH) {
|
|
5584
|
+
return `module "${id}" has an invalid source`;
|
|
5585
|
+
}
|
|
5586
|
+
if (typeof value.contractVersion !== "string" || !CONTRACT_VERSION.test(value.contractVersion)) {
|
|
5587
|
+
return `module "${id}" has an invalid contract version`;
|
|
5588
|
+
}
|
|
5589
|
+
if (typeof value.summary !== "string" || value.summary.length === 0 || value.summary.length > MAX_SUMMARY_LENGTH) {
|
|
5590
|
+
return `module "${id}" has an invalid summary`;
|
|
5591
|
+
}
|
|
5592
|
+
return null;
|
|
5593
|
+
}
|
|
5442
5594
|
function isPlainObject(value) {
|
|
5443
5595
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
5444
5596
|
}
|
|
5597
|
+
function climbsOutOfItsSegment(value) {
|
|
5598
|
+
let decoded = value;
|
|
5599
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
5600
|
+
if (decoded.split(/[/\\]/).some((part) => part === "." || part === "..")) {
|
|
5601
|
+
return true;
|
|
5602
|
+
}
|
|
5603
|
+
let next;
|
|
5604
|
+
try {
|
|
5605
|
+
next = decodeURIComponent(decoded);
|
|
5606
|
+
} catch {
|
|
5607
|
+
return false;
|
|
5608
|
+
}
|
|
5609
|
+
if (next === decoded) {
|
|
5610
|
+
return false;
|
|
5611
|
+
}
|
|
5612
|
+
decoded = next;
|
|
5613
|
+
}
|
|
5614
|
+
return true;
|
|
5615
|
+
}
|
|
5445
5616
|
function buildCommandPath(command, params, query) {
|
|
5617
|
+
const templateReason = unstableOpsPathBecause(command.path);
|
|
5618
|
+
if (templateReason !== null) {
|
|
5619
|
+
throw new Error(`Refusing ops command path ${JSON.stringify(command.path)} because it ${templateReason}.`);
|
|
5620
|
+
}
|
|
5446
5621
|
const path5 = command.path.replace(/:([A-Za-z0-9_]+)/g, (_match, name) => {
|
|
5447
5622
|
const value = params[name];
|
|
5448
5623
|
if (value === void 0) {
|
|
5449
5624
|
throw new Error(`Missing path parameter "${name}" (pass --param ${name}=<value>).`);
|
|
5450
5625
|
}
|
|
5626
|
+
if (value === "") {
|
|
5627
|
+
throw new Error(`Path parameter "${name}" cannot be empty.`);
|
|
5628
|
+
}
|
|
5629
|
+
if (climbsOutOfItsSegment(value)) {
|
|
5630
|
+
throw new Error(
|
|
5631
|
+
`Path parameter "${name}" contains dot segments and cannot be sent: ${JSON.stringify(value)}.`
|
|
5632
|
+
);
|
|
5633
|
+
}
|
|
5451
5634
|
return encodeURIComponent(value);
|
|
5452
5635
|
});
|
|
5453
5636
|
const search = new URLSearchParams(query).toString();
|
|
@@ -5468,9 +5651,6 @@ function isSchema(value) {
|
|
|
5468
5651
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
5469
5652
|
}
|
|
5470
5653
|
var MAX_SCHEMA_DEPTH = 12;
|
|
5471
|
-
function plain(value) {
|
|
5472
|
-
return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, "?");
|
|
5473
|
-
}
|
|
5474
5654
|
function typeName(schema) {
|
|
5475
5655
|
if (Array.isArray(schema.enum)) {
|
|
5476
5656
|
return schema.enum.every((v) => typeof v === "string") ? "string" : "value";
|
|
@@ -5556,6 +5736,13 @@ function renderSection(label, flag, rows) {
|
|
|
5556
5736
|
function renderCommandUsage(command) {
|
|
5557
5737
|
const lines = [`${plain(command.name)} ${command.method} ${plain(command.path)}`, ""];
|
|
5558
5738
|
let described = false;
|
|
5739
|
+
if (command.summary) {
|
|
5740
|
+
lines.push(` ${plain(command.summary)}`, "");
|
|
5741
|
+
}
|
|
5742
|
+
if (command.effect || command.scopes) {
|
|
5743
|
+
lines.push(` Effect: ${command.effect ?? "unknown"}`);
|
|
5744
|
+
lines.push(` Scopes: ${command.scopes?.map(plain).join(", ") || "(not declared)"}`, "");
|
|
5745
|
+
}
|
|
5559
5746
|
for (const section of SECTIONS) {
|
|
5560
5747
|
const schema = command.input[section.key];
|
|
5561
5748
|
if (!isSchema(schema)) {
|
|
@@ -6018,18 +6205,68 @@ function inputSummary(command) {
|
|
|
6018
6205
|
}
|
|
6019
6206
|
return sections.length > 0 ? sections.join("+") : "-";
|
|
6020
6207
|
}
|
|
6208
|
+
function moduleViews(manifest) {
|
|
6209
|
+
return (manifest.modules ?? []).map((module) => {
|
|
6210
|
+
const commands = manifest.commands.filter((command) => command.module === module.id);
|
|
6211
|
+
return {
|
|
6212
|
+
...module,
|
|
6213
|
+
commandCount: commands.length,
|
|
6214
|
+
effects: [...new Set(commands.map((command) => command.effect).filter(Boolean))].sort()
|
|
6215
|
+
};
|
|
6216
|
+
});
|
|
6217
|
+
}
|
|
6218
|
+
async function listModules(options) {
|
|
6219
|
+
const appUrl = resolveAppUrl(options);
|
|
6220
|
+
const token = await resolveToken(options, appUrl);
|
|
6221
|
+
const modules = moduleViews(await fetchOpsManifest(appUrl, token));
|
|
6222
|
+
if (options.json) {
|
|
6223
|
+
console.log(JSON.stringify(modules, null, 2));
|
|
6224
|
+
return;
|
|
6225
|
+
}
|
|
6226
|
+
if (modules.length === 0) {
|
|
6227
|
+
console.log(chalk36.yellow("The app exposes no capability ops modules."));
|
|
6228
|
+
return;
|
|
6229
|
+
}
|
|
6230
|
+
console.log(chalk36.bold(`Ops modules at ${appUrl}:
|
|
6231
|
+
`));
|
|
6232
|
+
for (const module of modules) {
|
|
6233
|
+
const effects = module.effects.length > 0 ? module.effects.join(",") : "-";
|
|
6234
|
+
console.log(
|
|
6235
|
+
` ${chalk36.cyan(plain(module.id))} ${chalk36.gray(plain(module.source))} contract: ${plain(module.contractVersion)} commands: ${module.commandCount} effects: ${effects}`
|
|
6236
|
+
);
|
|
6237
|
+
console.log(chalk36.gray(` ${plain(module.summary)}`));
|
|
6238
|
+
}
|
|
6239
|
+
}
|
|
6021
6240
|
async function listCommands(options) {
|
|
6022
6241
|
const appUrl = resolveAppUrl(options);
|
|
6023
6242
|
const token = await resolveToken(options, appUrl);
|
|
6024
6243
|
const manifest = await fetchOpsManifest(appUrl, token);
|
|
6025
|
-
|
|
6026
|
-
|
|
6244
|
+
const commands = options.module ? manifest.commands.filter((command) => command.module === options.module) : manifest.commands;
|
|
6245
|
+
if (options.module && !(manifest.modules ?? []).some((module) => module.id === options.module)) {
|
|
6246
|
+
console.error(chalk36.red(`\u274C Unknown ops module "${plain(options.module)}".`));
|
|
6247
|
+
console.error(chalk36.gray(
|
|
6248
|
+
` Known: ${(manifest.modules ?? []).map((module) => plain(module.id)).join(", ") || "(none)"}`
|
|
6249
|
+
));
|
|
6250
|
+
process.exit(1);
|
|
6251
|
+
}
|
|
6252
|
+
if (options.json) {
|
|
6253
|
+
console.log(JSON.stringify(commands, null, 2));
|
|
6254
|
+
return;
|
|
6255
|
+
}
|
|
6256
|
+
if (commands.length === 0) {
|
|
6257
|
+
console.log(chalk36.yellow(options.module ? `The "${plain(options.module)}" module exposes no ops commands.` : "The app exposes no ops commands."));
|
|
6027
6258
|
return;
|
|
6028
6259
|
}
|
|
6029
6260
|
console.log(chalk36.bold(`Ops commands at ${appUrl}:
|
|
6030
6261
|
`));
|
|
6031
|
-
for (const command of
|
|
6032
|
-
|
|
6262
|
+
for (const command of commands) {
|
|
6263
|
+
const effect = command.effect ? ` effect: ${command.effect}` : "";
|
|
6264
|
+
console.log(
|
|
6265
|
+
` ${chalk36.cyan(plain(command.name))} ${chalk36.gray(`${command.method} ${plain(command.path)}`)} input: ${inputSummary(command)}${effect}`
|
|
6266
|
+
);
|
|
6267
|
+
if (command.summary) {
|
|
6268
|
+
console.log(chalk36.gray(` ${plain(command.summary)}`));
|
|
6269
|
+
}
|
|
6033
6270
|
}
|
|
6034
6271
|
console.log(chalk36.gray(`
|
|
6035
6272
|
\u{1F4A1} Invoke: spfn ops call <name> [--param k=v] [--query k=v] [--data '{"..."}']`));
|
|
@@ -6049,6 +6286,12 @@ async function callCommand(name, options) {
|
|
|
6049
6286
|
console.log(options.json ? JSON.stringify(command, null, 2) : renderCommandUsage(command));
|
|
6050
6287
|
return;
|
|
6051
6288
|
}
|
|
6289
|
+
if (destructiveConfirmationRequired(command, options.yes)) {
|
|
6290
|
+
console.error(command.metadataRejected ? chalk36.red(`\u274C "${plain(name)}" announced metadata this CLI refused, so its effect is unknown.`) : chalk36.red(`\u274C "${plain(name)}" is destructive and was not called with --yes.`));
|
|
6291
|
+
console.error(chalk36.gray(` Review it first: spfn ops call ${plain(name)} --describe`));
|
|
6292
|
+
console.error(chalk36.gray(" Then re-run with --yes."));
|
|
6293
|
+
process.exit(1);
|
|
6294
|
+
}
|
|
6052
6295
|
let body;
|
|
6053
6296
|
if (options.data !== void 0) {
|
|
6054
6297
|
if (command.method === "GET") {
|
|
@@ -6077,9 +6320,16 @@ async function callCommand(name, options) {
|
|
|
6077
6320
|
console.error(rendered);
|
|
6078
6321
|
process.exit(1);
|
|
6079
6322
|
}
|
|
6323
|
+
function destructiveConfirmationRequired(command, confirmed) {
|
|
6324
|
+
if (confirmed === true) {
|
|
6325
|
+
return false;
|
|
6326
|
+
}
|
|
6327
|
+
return command.effect === "destructive" || command.metadataRejected === true;
|
|
6328
|
+
}
|
|
6080
6329
|
var opsCommand = new Command15("ops").description("Invoke the app's ops surface (discovered from /_ops/_manifest)");
|
|
6081
|
-
opsCommand.command("
|
|
6082
|
-
opsCommand.command("
|
|
6330
|
+
opsCommand.command("modules").description("List explicitly mounted capability ops modules").option("--app <url>", "app URL (or SPFN_OPS_APP)").option("--token <token>", "ops token (or SPFN_OPS_TOKEN, or keychain)").option("--json", "print machine-readable JSON").action(listModules);
|
|
6331
|
+
opsCommand.command("list").description("List the ops commands the app exposes").option("--app <url>", "app URL (or SPFN_OPS_APP)").option("--token <token>", "ops token (or SPFN_OPS_TOKEN, or keychain)").option("--module <id>", "show commands from one capability module").option("--json", "print machine-readable JSON").action(listCommands);
|
|
6332
|
+
opsCommand.command("call <name>").description("Invoke one ops command").option("--app <url>", "app URL (or SPFN_OPS_APP)").option("--token <token>", "ops token (or SPFN_OPS_TOKEN, or keychain)").option("--param <k=v>", "path parameter (repeatable)", collectKeyValue, {}).option("--query <k=v>", "query parameter (repeatable)", collectKeyValue, {}).option("--data <json>", "JSON request body").option("--describe", "print the command's usage instead of calling it").option("--json", "with --describe, print the raw JSON Schema").option("--yes", "confirm an effect=destructive command").action(callCommand).addHelpText("after", "\nA command's own inputs: spfn ops call <name> --describe");
|
|
6083
6333
|
opsCommand.addCommand(buildTokenCommand());
|
|
6084
6334
|
|
|
6085
6335
|
// src/commands/secret/index.ts
|
|
@@ -6664,9 +6914,1002 @@ secretCommand.command("keygen").description("Generate an age key pair for SOPS (
|
|
|
6664
6914
|
secretCommand.command("recipients <action> [key]").description("Manage .sops.yaml age recipients: add | remove | list").action(secretRecipients);
|
|
6665
6915
|
secretCommand.command("check").description("Static hygiene lint \u2014 flag plaintext secret leaks").option(...PKG_OPTION).action(secretCheck);
|
|
6666
6916
|
|
|
6917
|
+
// src/commands/cloud/index.ts
|
|
6918
|
+
import { Command as Command17 } from "commander";
|
|
6919
|
+
|
|
6920
|
+
// src/commands/cloud/link.ts
|
|
6921
|
+
init_logger();
|
|
6922
|
+
import { basename as basename2 } from "path";
|
|
6923
|
+
import prompts12 from "prompts";
|
|
6924
|
+
import chalk44 from "chalk";
|
|
6925
|
+
|
|
6926
|
+
// src/utils/cloud/tokens.ts
|
|
6927
|
+
var TOKEN_KEYS = {
|
|
6928
|
+
vercel: "CLOUD_VERCEL_TOKEN",
|
|
6929
|
+
supabase: "CLOUD_SUPABASE_TOKEN"
|
|
6930
|
+
};
|
|
6931
|
+
async function availableStore() {
|
|
6932
|
+
const store = detectStore();
|
|
6933
|
+
if (!await store.isAvailable()) {
|
|
6934
|
+
throw new Error(
|
|
6935
|
+
`${store.label} is unavailable on this machine, and cloud tokens are stored nowhere else. On Windows install \`@napi-rs/keyring\`; on headless Linux no keyring daemon is present.`
|
|
6936
|
+
);
|
|
6937
|
+
}
|
|
6938
|
+
return store;
|
|
6939
|
+
}
|
|
6940
|
+
async function storeCloudToken(provider, value) {
|
|
6941
|
+
const store = await availableStore();
|
|
6942
|
+
await store.set(keychainName(TOKEN_KEYS[provider]), value);
|
|
6943
|
+
}
|
|
6944
|
+
async function getCloudToken(provider) {
|
|
6945
|
+
const store = await availableStore();
|
|
6946
|
+
return store.get(keychainName(TOKEN_KEYS[provider]));
|
|
6947
|
+
}
|
|
6948
|
+
async function requireCloudToken(provider) {
|
|
6949
|
+
const token = await getCloudToken(provider);
|
|
6950
|
+
if (!token) {
|
|
6951
|
+
throw new Error(`No ${provider} token is stored. Run \`spfn cloud link\` first.`);
|
|
6952
|
+
}
|
|
6953
|
+
return token;
|
|
6954
|
+
}
|
|
6955
|
+
async function deleteCloudToken(provider) {
|
|
6956
|
+
const store = await availableStore();
|
|
6957
|
+
await store.delete(keychainName(TOKEN_KEYS[provider]));
|
|
6958
|
+
}
|
|
6959
|
+
|
|
6960
|
+
// src/utils/cloud/config.ts
|
|
6961
|
+
init_env_file();
|
|
6962
|
+
import { existsSync as existsSync31, mkdirSync as mkdirSync7, readFileSync as readFileSync13, writeFileSync as writeFileSync20 } from "fs";
|
|
6963
|
+
import { dirname as dirname6, join as join31 } from "path";
|
|
6964
|
+
var CONFIG_REL_PATH = join31(".spfn", "cloud.json");
|
|
6965
|
+
function cloudConfigPath(cwd) {
|
|
6966
|
+
return join31(cwd, CONFIG_REL_PATH);
|
|
6967
|
+
}
|
|
6968
|
+
function readCloudConfig(cwd) {
|
|
6969
|
+
const path5 = cloudConfigPath(cwd);
|
|
6970
|
+
if (!existsSync31(path5)) {
|
|
6971
|
+
return {};
|
|
6972
|
+
}
|
|
6973
|
+
let parsed;
|
|
6974
|
+
try {
|
|
6975
|
+
parsed = JSON.parse(readFileSync13(path5, "utf-8"));
|
|
6976
|
+
} catch {
|
|
6977
|
+
throw new Error(`${path5} is not valid JSON \u2014 delete it and run \`spfn cloud link\` again.`);
|
|
6978
|
+
}
|
|
6979
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
6980
|
+
throw new Error(`${path5} does not contain a JSON object \u2014 delete it and run \`spfn cloud link\` again.`);
|
|
6981
|
+
}
|
|
6982
|
+
return parsed;
|
|
6983
|
+
}
|
|
6984
|
+
function writeCloudConfig(cwd, config) {
|
|
6985
|
+
const path5 = cloudConfigPath(cwd);
|
|
6986
|
+
mkdirSync7(dirname6(path5), { recursive: true });
|
|
6987
|
+
writeFileSync20(path5, JSON.stringify(config, null, 4) + "\n", "utf-8");
|
|
6988
|
+
ensureGitignored(cwd, [{ pattern: ".spfn/cloud.json", comment: "spfn cloud link state (per-user accounts)" }]);
|
|
6989
|
+
}
|
|
6990
|
+
function requireLinked(config, provider) {
|
|
6991
|
+
const linked = config[provider];
|
|
6992
|
+
if (!linked) {
|
|
6993
|
+
throw new Error(`No ${provider} project is linked. Run \`spfn cloud link\` first.`);
|
|
6994
|
+
}
|
|
6995
|
+
return linked;
|
|
6996
|
+
}
|
|
6997
|
+
|
|
6998
|
+
// src/utils/cloud/http.ts
|
|
6999
|
+
var REQUEST_TIMEOUT_MS = 3e4;
|
|
7000
|
+
async function cloudFetch(url, init) {
|
|
7001
|
+
const response = await fetch(url, {
|
|
7002
|
+
method: init.method ?? "GET",
|
|
7003
|
+
headers: {
|
|
7004
|
+
Authorization: `Bearer ${init.token}`,
|
|
7005
|
+
...init.body !== void 0 ? { "Content-Type": "application/json" } : {}
|
|
7006
|
+
},
|
|
7007
|
+
body: init.body !== void 0 ? JSON.stringify(init.body) : void 0,
|
|
7008
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
7009
|
+
});
|
|
7010
|
+
if (init.allowNotFound && response.status === 404) {
|
|
7011
|
+
return null;
|
|
7012
|
+
}
|
|
7013
|
+
if (!response.ok) {
|
|
7014
|
+
throw new Error(await describeFailure(url, init.provider, response));
|
|
7015
|
+
}
|
|
7016
|
+
return response;
|
|
7017
|
+
}
|
|
7018
|
+
async function cloudFetchJson(url, init) {
|
|
7019
|
+
const response = await cloudFetch(url, init);
|
|
7020
|
+
return await response.json();
|
|
7021
|
+
}
|
|
7022
|
+
async function describeFailure(url, provider, response) {
|
|
7023
|
+
const hint = response.status === 401 || response.status === 403 ? " The stored token may be invalid or expired \u2014 run `spfn cloud link --replace` to enter a new one." : "";
|
|
7024
|
+
return `${provider} API responded ${response.status} for ${new URL(url).pathname}.${hint}${await bodyMessage(response)}`;
|
|
7025
|
+
}
|
|
7026
|
+
async function bodyMessage(response) {
|
|
7027
|
+
try {
|
|
7028
|
+
const body = await response.json();
|
|
7029
|
+
const message = body.error?.message ?? body.message;
|
|
7030
|
+
return message ? ` (${message})` : "";
|
|
7031
|
+
} catch {
|
|
7032
|
+
return "";
|
|
7033
|
+
}
|
|
7034
|
+
}
|
|
7035
|
+
|
|
7036
|
+
// src/utils/cloud/vercel-api.ts
|
|
7037
|
+
var BASE = "https://api.vercel.com";
|
|
7038
|
+
var PROVIDER = "Vercel";
|
|
7039
|
+
async function getVercelUser(token) {
|
|
7040
|
+
return cloudFetchJson(`${BASE}/v2/user`, { token, provider: PROVIDER });
|
|
7041
|
+
}
|
|
7042
|
+
async function listVercelProjects(token, teamId) {
|
|
7043
|
+
const query = teamId ? `?teamId=${encodeURIComponent(teamId)}` : "";
|
|
7044
|
+
const data = await cloudFetchJson(`${BASE}/v9/projects${query}`, { token, provider: PROVIDER });
|
|
7045
|
+
return data.projects;
|
|
7046
|
+
}
|
|
7047
|
+
async function getVercelUsage(token, from, to, teamId) {
|
|
7048
|
+
const params = new URLSearchParams({ from: from.toISOString(), to: to.toISOString() });
|
|
7049
|
+
if (teamId) {
|
|
7050
|
+
params.set("teamId", teamId);
|
|
7051
|
+
}
|
|
7052
|
+
const response = await cloudFetch(`${BASE}/v1/billing/charges?${params}`, { token, provider: PROVIDER, allowNotFound: true });
|
|
7053
|
+
if (response === null) {
|
|
7054
|
+
return [];
|
|
7055
|
+
}
|
|
7056
|
+
const body = await response.text();
|
|
7057
|
+
if (!focusFeedReadable(body)) {
|
|
7058
|
+
throw new Error("Vercel billing feed answered in an unrecognized shape \u2014 usage is unavailable, not zero.");
|
|
7059
|
+
}
|
|
7060
|
+
return aggregateFocusCharges(body);
|
|
7061
|
+
}
|
|
7062
|
+
function focusFeedReadable(body) {
|
|
7063
|
+
const lines = body.split("\n").filter((line) => line.trim() !== "");
|
|
7064
|
+
return lines.length === 0 || lines.some((line) => Boolean(parseFocusLine(line)?.ServiceName));
|
|
7065
|
+
}
|
|
7066
|
+
function aggregateFocusCharges(jsonl) {
|
|
7067
|
+
const totals = /* @__PURE__ */ new Map();
|
|
7068
|
+
for (const line of jsonl.split("\n")) {
|
|
7069
|
+
const record = parseFocusLine(line);
|
|
7070
|
+
if (!record?.ServiceName) {
|
|
7071
|
+
continue;
|
|
7072
|
+
}
|
|
7073
|
+
const consumed = Number(record.ConsumedQuantity ?? 0);
|
|
7074
|
+
if (!Number.isFinite(consumed) || consumed === 0) {
|
|
7075
|
+
continue;
|
|
7076
|
+
}
|
|
7077
|
+
const unit = record.ConsumedUnit ?? "";
|
|
7078
|
+
const key = `${record.ServiceName}\0${unit}`;
|
|
7079
|
+
const existing = totals.get(key);
|
|
7080
|
+
if (existing) {
|
|
7081
|
+
existing.consumed += consumed;
|
|
7082
|
+
} else {
|
|
7083
|
+
totals.set(key, { serviceName: record.ServiceName, consumed, unit });
|
|
7084
|
+
}
|
|
7085
|
+
}
|
|
7086
|
+
return [...totals.values()].sort((a, b) => a.serviceName.localeCompare(b.serviceName));
|
|
7087
|
+
}
|
|
7088
|
+
function parseFocusLine(line) {
|
|
7089
|
+
const trimmed = line.trim();
|
|
7090
|
+
if (!trimmed) {
|
|
7091
|
+
return null;
|
|
7092
|
+
}
|
|
7093
|
+
try {
|
|
7094
|
+
return JSON.parse(trimmed);
|
|
7095
|
+
} catch {
|
|
7096
|
+
return null;
|
|
7097
|
+
}
|
|
7098
|
+
}
|
|
7099
|
+
async function upsertVercelEnvVars(token, projectId, vars, teamId) {
|
|
7100
|
+
const query = new URLSearchParams({ upsert: "true" });
|
|
7101
|
+
if (teamId) {
|
|
7102
|
+
query.set("teamId", teamId);
|
|
7103
|
+
}
|
|
7104
|
+
const response = await cloudFetch(`${BASE}/v10/projects/${encodeURIComponent(projectId)}/env?${query}`, {
|
|
7105
|
+
method: "POST",
|
|
7106
|
+
token,
|
|
7107
|
+
provider: PROVIDER,
|
|
7108
|
+
body: vars
|
|
7109
|
+
});
|
|
7110
|
+
return { failed: extractEnvPushFailures(await response.json().catch(() => null)) };
|
|
7111
|
+
}
|
|
7112
|
+
function extractEnvPushFailures(body) {
|
|
7113
|
+
const failed = body?.failed;
|
|
7114
|
+
if (!Array.isArray(failed)) {
|
|
7115
|
+
return [];
|
|
7116
|
+
}
|
|
7117
|
+
return failed.map((item) => {
|
|
7118
|
+
const error = item?.error;
|
|
7119
|
+
return {
|
|
7120
|
+
key: error?.key ?? error?.envVarKey ?? "unknown key",
|
|
7121
|
+
message: error?.message ?? "rejected by Vercel"
|
|
7122
|
+
};
|
|
7123
|
+
});
|
|
7124
|
+
}
|
|
7125
|
+
|
|
7126
|
+
// src/utils/cloud/supabase-api.ts
|
|
7127
|
+
var BASE2 = "https://api.supabase.com";
|
|
7128
|
+
var PROVIDER2 = "Supabase";
|
|
7129
|
+
async function listSupabaseOrganizations(token) {
|
|
7130
|
+
return cloudFetchJson(`${BASE2}/v1/organizations`, { token, provider: PROVIDER2 });
|
|
7131
|
+
}
|
|
7132
|
+
async function listSupabaseProjects(token) {
|
|
7133
|
+
return cloudFetchJson(`${BASE2}/v1/projects`, { token, provider: PROVIDER2 });
|
|
7134
|
+
}
|
|
7135
|
+
async function getSupabaseProject(token, ref) {
|
|
7136
|
+
return cloudFetchJson(`${BASE2}/v1/projects/${encodeURIComponent(ref)}`, { token, provider: PROVIDER2 });
|
|
7137
|
+
}
|
|
7138
|
+
async function getSupabaseApiKeys(token, ref) {
|
|
7139
|
+
return cloudFetchJson(
|
|
7140
|
+
`${BASE2}/v1/projects/${encodeURIComponent(ref)}/api-keys`,
|
|
7141
|
+
{ token, provider: PROVIDER2 }
|
|
7142
|
+
);
|
|
7143
|
+
}
|
|
7144
|
+
async function getSupabasePoolerConfigs(token, ref) {
|
|
7145
|
+
return cloudFetchJson(
|
|
7146
|
+
`${BASE2}/v1/projects/${encodeURIComponent(ref)}/config/database/pooler`,
|
|
7147
|
+
{ token, provider: PROVIDER2 }
|
|
7148
|
+
);
|
|
7149
|
+
}
|
|
7150
|
+
async function runSupabaseQuery(token, ref, query) {
|
|
7151
|
+
return cloudFetchJson(`${BASE2}/v1/projects/${encodeURIComponent(ref)}/database/query`, {
|
|
7152
|
+
method: "POST",
|
|
7153
|
+
token,
|
|
7154
|
+
provider: PROVIDER2,
|
|
7155
|
+
body: { query }
|
|
7156
|
+
});
|
|
7157
|
+
}
|
|
7158
|
+
async function getSupabaseDbSizeBytes(token, ref) {
|
|
7159
|
+
const rows = await runSupabaseQuery(
|
|
7160
|
+
token,
|
|
7161
|
+
ref,
|
|
7162
|
+
"select pg_database_size(current_database())::bigint as total_bytes;"
|
|
7163
|
+
);
|
|
7164
|
+
return extractDbSizeBytes(rows);
|
|
7165
|
+
}
|
|
7166
|
+
function extractDbSizeBytes(rows) {
|
|
7167
|
+
if (!Array.isArray(rows) || rows.length === 0) {
|
|
7168
|
+
return null;
|
|
7169
|
+
}
|
|
7170
|
+
const value = rows[0].total_bytes;
|
|
7171
|
+
const parsed = Number(value);
|
|
7172
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
7173
|
+
}
|
|
7174
|
+
async function getSupabaseDailyApiCount(token, ref) {
|
|
7175
|
+
const data = await cloudFetchJson(
|
|
7176
|
+
`${BASE2}/v1/projects/${encodeURIComponent(ref)}/analytics/endpoints/usage.api-counts?interval=1day`,
|
|
7177
|
+
{ token, provider: PROVIDER2 }
|
|
7178
|
+
);
|
|
7179
|
+
return sumApiCounts(data);
|
|
7180
|
+
}
|
|
7181
|
+
function sumApiCounts(data) {
|
|
7182
|
+
const result = data?.result;
|
|
7183
|
+
if (!Array.isArray(result)) {
|
|
7184
|
+
return null;
|
|
7185
|
+
}
|
|
7186
|
+
let total = 0;
|
|
7187
|
+
let found = false;
|
|
7188
|
+
for (const row of result) {
|
|
7189
|
+
for (const [field, value] of Object.entries(row)) {
|
|
7190
|
+
if (field !== "count" && !field.startsWith("total_")) {
|
|
7191
|
+
continue;
|
|
7192
|
+
}
|
|
7193
|
+
const count = Number(value);
|
|
7194
|
+
if (Number.isFinite(count)) {
|
|
7195
|
+
total += count;
|
|
7196
|
+
found = true;
|
|
7197
|
+
}
|
|
7198
|
+
}
|
|
7199
|
+
}
|
|
7200
|
+
return found ? total : null;
|
|
7201
|
+
}
|
|
7202
|
+
|
|
7203
|
+
// src/commands/cloud/link.ts
|
|
7204
|
+
async function cloudLink(options) {
|
|
7205
|
+
const cwd = process.cwd();
|
|
7206
|
+
try {
|
|
7207
|
+
const config = readCloudConfig(cwd);
|
|
7208
|
+
await linkVercel(config, cwd, options);
|
|
7209
|
+
writeCloudConfig(cwd, config);
|
|
7210
|
+
await linkSupabase(config, options);
|
|
7211
|
+
writeCloudConfig(cwd, config);
|
|
7212
|
+
} catch (error) {
|
|
7213
|
+
logger.error(error instanceof Error ? error.message : String(error));
|
|
7214
|
+
process.exit(1);
|
|
7215
|
+
}
|
|
7216
|
+
logger.success(`Link state saved to ${chalk44.cyan(cloudConfigPath(cwd))} (identifiers only \u2014 tokens stay in the keychain).`);
|
|
7217
|
+
logger.info("Next: `spfn cloud status` for limits vs usage, `spfn cloud env pull` for project keys.");
|
|
7218
|
+
}
|
|
7219
|
+
async function linkVercel(config, cwd, options) {
|
|
7220
|
+
const token = await obtainToken("vercel", "Vercel access token", "VERCEL_TOKEN", "https://vercel.com/account/settings/tokens", options.replace);
|
|
7221
|
+
const user = await getVercelUser(token);
|
|
7222
|
+
logger.success(`Vercel token verified (account: ${user.user.username}).`);
|
|
7223
|
+
await storeCloudToken("vercel", token);
|
|
7224
|
+
const projects = await listVercelProjects(token);
|
|
7225
|
+
if (projects.length === 0) {
|
|
7226
|
+
logger.warn("No Vercel projects found \u2014 deploy once, then re-run `spfn cloud link`.");
|
|
7227
|
+
return;
|
|
7228
|
+
}
|
|
7229
|
+
let projectId;
|
|
7230
|
+
if (options.vercelProject) {
|
|
7231
|
+
projectId = projects.find((p) => p.name === options.vercelProject || p.id === options.vercelProject)?.id;
|
|
7232
|
+
if (!projectId) {
|
|
7233
|
+
throw new Error(`No Vercel project named "${options.vercelProject}" \u2014 available: ${projects.map((p) => p.name).join(", ")}.`);
|
|
7234
|
+
}
|
|
7235
|
+
} else {
|
|
7236
|
+
const defaultIndex = Math.max(0, projects.findIndex((p) => p.name === basename2(cwd)));
|
|
7237
|
+
({ projectId } = await prompts12({
|
|
7238
|
+
type: "select",
|
|
7239
|
+
name: "projectId",
|
|
7240
|
+
message: "Which Vercel project is this app?",
|
|
7241
|
+
initial: defaultIndex,
|
|
7242
|
+
choices: projects.map((p) => ({ title: p.name, value: p.id }))
|
|
7243
|
+
}));
|
|
7244
|
+
}
|
|
7245
|
+
if (!projectId) {
|
|
7246
|
+
throw new Error("Cancelled \u2014 no Vercel project selected.");
|
|
7247
|
+
}
|
|
7248
|
+
const project = projects.find((p) => p.id === projectId);
|
|
7249
|
+
config.vercel = { projectId: project.id, projectName: project.name };
|
|
7250
|
+
logger.success(`Vercel project linked: ${project.name}`);
|
|
7251
|
+
}
|
|
7252
|
+
async function linkSupabase(config, options) {
|
|
7253
|
+
const token = await obtainToken("supabase", "Supabase personal access token", "SUPABASE_ACCESS_TOKEN", "https://supabase.com/dashboard/account/tokens", options.replace);
|
|
7254
|
+
const orgs = await listSupabaseOrganizations(token);
|
|
7255
|
+
logger.success(`Supabase token verified (${orgs.length} organization${orgs.length === 1 ? "" : "s"}).`);
|
|
7256
|
+
await storeCloudToken("supabase", token);
|
|
7257
|
+
const projects = await listSupabaseProjects(token);
|
|
7258
|
+
if (projects.length === 0) {
|
|
7259
|
+
logger.warn("No Supabase projects found \u2014 create one, then re-run `spfn cloud link`.");
|
|
7260
|
+
return;
|
|
7261
|
+
}
|
|
7262
|
+
let ref;
|
|
7263
|
+
if (options.supabaseProject) {
|
|
7264
|
+
const match = projects.find((p) => projectRef(p) === options.supabaseProject || p.name === options.supabaseProject);
|
|
7265
|
+
if (!match) {
|
|
7266
|
+
throw new Error(`No Supabase project "${options.supabaseProject}" \u2014 available: ${projects.map((p) => p.name).join(", ")}.`);
|
|
7267
|
+
}
|
|
7268
|
+
ref = projectRef(match);
|
|
7269
|
+
} else {
|
|
7270
|
+
({ ref } = await prompts12({
|
|
7271
|
+
type: "select",
|
|
7272
|
+
name: "ref",
|
|
7273
|
+
message: "Which Supabase project is this app?",
|
|
7274
|
+
choices: projects.map((p) => ({ title: `${p.name} (${p.region}, ${p.status})`, value: projectRef(p) }))
|
|
7275
|
+
}));
|
|
7276
|
+
}
|
|
7277
|
+
if (!ref) {
|
|
7278
|
+
throw new Error("Cancelled \u2014 no Supabase project selected.");
|
|
7279
|
+
}
|
|
7280
|
+
const project = projects.find((p) => projectRef(p) === ref);
|
|
7281
|
+
const org = orgs.find((o) => o.id === project.organization_id);
|
|
7282
|
+
config.supabase = {
|
|
7283
|
+
projectRef: ref,
|
|
7284
|
+
projectName: project.name,
|
|
7285
|
+
orgSlug: org?.slug ?? project.organization_id,
|
|
7286
|
+
region: project.region
|
|
7287
|
+
};
|
|
7288
|
+
}
|
|
7289
|
+
function projectRef(project) {
|
|
7290
|
+
return project.ref ?? project.id;
|
|
7291
|
+
}
|
|
7292
|
+
async function obtainToken(provider, label, envVar, createUrl, replace) {
|
|
7293
|
+
if (replace) {
|
|
7294
|
+
await deleteCloudToken(provider);
|
|
7295
|
+
logger.info(`Stored ${label} discarded${process.env[envVar] ? ` (ignoring ${envVar})` : ""} \u2014 enter a fresh one.`);
|
|
7296
|
+
} else {
|
|
7297
|
+
const fromEnv = process.env[envVar];
|
|
7298
|
+
if (fromEnv) {
|
|
7299
|
+
logger.info(`Using ${label} from ${envVar} (value not shown).`);
|
|
7300
|
+
return fromEnv;
|
|
7301
|
+
}
|
|
7302
|
+
const fromKeychain = await getCloudToken(provider);
|
|
7303
|
+
if (fromKeychain) {
|
|
7304
|
+
logger.info(`Using the ${label} already in the keychain (value not shown). To enter a new one, run \`spfn cloud link --replace\`.`);
|
|
7305
|
+
return fromKeychain;
|
|
7306
|
+
}
|
|
7307
|
+
}
|
|
7308
|
+
logger.info(`Create one at ${chalk44.cyan(createUrl)} if you don't have it yet.`);
|
|
7309
|
+
const { token } = await prompts12({
|
|
7310
|
+
type: "password",
|
|
7311
|
+
name: "token",
|
|
7312
|
+
message: label
|
|
7313
|
+
});
|
|
7314
|
+
if (!token) {
|
|
7315
|
+
throw new Error(`Cancelled \u2014 no ${label} entered.`);
|
|
7316
|
+
}
|
|
7317
|
+
return token;
|
|
7318
|
+
}
|
|
7319
|
+
|
|
7320
|
+
// src/commands/cloud/limits.ts
|
|
7321
|
+
import chalk46 from "chalk";
|
|
7322
|
+
|
|
7323
|
+
// src/utils/cloud/limits-data.ts
|
|
7324
|
+
var VERCEL_HOBBY_LIMITS = [
|
|
7325
|
+
{ key: "fast-data-transfer", label: "Fast data transfer", limit: 100, unit: "GB", per: "month" },
|
|
7326
|
+
{ key: "function-invocations", label: "Function invocations", limit: 1e6, unit: "invocations", per: "month" },
|
|
7327
|
+
{ key: "active-cpu", label: "Active CPU", limit: 4, unit: "CPU-hours", per: "month" },
|
|
7328
|
+
{ key: "provisioned-memory", label: "Provisioned memory", limit: 360, unit: "GB-hours", per: "month" },
|
|
7329
|
+
{ key: "edge-requests", label: "Edge requests", limit: 1e6, unit: "requests", per: "month" },
|
|
7330
|
+
{ key: "image-transformations", label: "Image transformations", limit: 5e3, unit: "transformations", per: "month" },
|
|
7331
|
+
{ key: "deployments", label: "Deployments", limit: 100, unit: "deploys", per: "day" },
|
|
7332
|
+
{ key: "concurrent-builds", label: "Concurrent builds", limit: 1, unit: "build", per: "concurrent" }
|
|
7333
|
+
];
|
|
7334
|
+
var SUPABASE_FREE_LIMITS = [
|
|
7335
|
+
{ key: "db-size", label: "Database size (per project)", limit: 500, unit: "MB", per: "total" },
|
|
7336
|
+
{ key: "egress", label: "Egress", limit: 5, unit: "GB", per: "month" },
|
|
7337
|
+
{ key: "cached-egress", label: "Cached egress", limit: 5, unit: "GB", per: "month" },
|
|
7338
|
+
{ key: "mau", label: "Monthly active users", limit: 5e4, unit: "MAU", per: "month" },
|
|
7339
|
+
{ key: "storage", label: "File storage", limit: 1, unit: "GB", per: "total" },
|
|
7340
|
+
{ key: "edge-invocations", label: "Edge function invocations", limit: 5e5, unit: "invocations", per: "month" },
|
|
7341
|
+
{ key: "realtime-connections", label: "Realtime connections", limit: 200, unit: "connections", per: "concurrent" },
|
|
7342
|
+
{ key: "realtime-messages", label: "Realtime messages", limit: 2e6, unit: "messages", per: "month" },
|
|
7343
|
+
{ key: "active-projects", label: "Active free projects (per org)", limit: 2, unit: "projects", per: "total" }
|
|
7344
|
+
];
|
|
7345
|
+
var VERCEL_HOBBY_NOTES = [
|
|
7346
|
+
"Cron jobs: at most one, at most once per day (finer schedules fail the deploy).",
|
|
7347
|
+
"Runtime logs are kept for 1 hour.",
|
|
7348
|
+
"Over a limit, the capability pauses until rolling 30-day usage drops back under it \u2014 Hobby is never billed.",
|
|
7349
|
+
"Hobby is for personal, non-commercial use; monetized apps need Pro or a migration."
|
|
7350
|
+
];
|
|
7351
|
+
var SUPABASE_FREE_NOTES = [
|
|
7352
|
+
"Quota is summed per organization (except database size) \u2014 other projects in the same org share it.",
|
|
7353
|
+
"Projects pause after ~7 idle days; a few DB requests per day keeps one active (1-year restore window).",
|
|
7354
|
+
"No automatic backups; log retention is 1 day.",
|
|
7355
|
+
"Over quota: notification, grace until the end of the billing cycle, then org-wide restrictions."
|
|
7356
|
+
];
|
|
7357
|
+
var LIMITS_VERIFIED_ON = "2026-08-13";
|
|
7358
|
+
|
|
7359
|
+
// src/utils/cloud/format.ts
|
|
7360
|
+
import chalk45 from "chalk";
|
|
7361
|
+
var MIGRATION_PROMPT_THRESHOLD = 0.8;
|
|
7362
|
+
function formatPercent(used, limit) {
|
|
7363
|
+
if (limit <= 0) {
|
|
7364
|
+
return chalk45.dim("n/a");
|
|
7365
|
+
}
|
|
7366
|
+
const ratio = used / limit;
|
|
7367
|
+
const text = `${(ratio * 100).toFixed(1)}%`;
|
|
7368
|
+
if (ratio >= 1) {
|
|
7369
|
+
return chalk45.red.bold(text);
|
|
7370
|
+
}
|
|
7371
|
+
if (ratio >= MIGRATION_PROMPT_THRESHOLD) {
|
|
7372
|
+
return chalk45.yellow.bold(text);
|
|
7373
|
+
}
|
|
7374
|
+
return chalk45.green(text);
|
|
7375
|
+
}
|
|
7376
|
+
function isNearLimit(used, limit) {
|
|
7377
|
+
return limit > 0 && used / limit >= MIGRATION_PROMPT_THRESHOLD;
|
|
7378
|
+
}
|
|
7379
|
+
var BYTES_PER_MB = 1e6;
|
|
7380
|
+
function formatBytes2(bytes) {
|
|
7381
|
+
if (bytes < 1e3) return `${bytes} B`;
|
|
7382
|
+
if (bytes < BYTES_PER_MB) return `${(bytes / 1e3).toFixed(1)} KB`;
|
|
7383
|
+
if (bytes < 1e3 * BYTES_PER_MB) return `${(bytes / BYTES_PER_MB).toFixed(1)} MB`;
|
|
7384
|
+
return `${(bytes / (1e3 * BYTES_PER_MB)).toFixed(2)} GB`;
|
|
7385
|
+
}
|
|
7386
|
+
function sameUnit(reported, limitUnit) {
|
|
7387
|
+
return reported === "" || reported.trim().toLowerCase() === limitUnit.trim().toLowerCase();
|
|
7388
|
+
}
|
|
7389
|
+
function formatCount(value) {
|
|
7390
|
+
return value.toLocaleString("en-US");
|
|
7391
|
+
}
|
|
7392
|
+
function usageLine(label, used, limit, unit) {
|
|
7393
|
+
const percent = formatPercent(used, limit);
|
|
7394
|
+
const amounts = `${formatCount(round(used))} / ${formatCount(limit)} ${unit}`;
|
|
7395
|
+
return ` ${label.padEnd(34)} ${amounts.padEnd(32)} ${percent}`;
|
|
7396
|
+
}
|
|
7397
|
+
function round(value) {
|
|
7398
|
+
return Number.isInteger(value) ? value : Number(value.toFixed(2));
|
|
7399
|
+
}
|
|
7400
|
+
|
|
7401
|
+
// src/commands/cloud/limits.ts
|
|
7402
|
+
var PER_LABEL = {
|
|
7403
|
+
month: "/ month",
|
|
7404
|
+
day: "/ day",
|
|
7405
|
+
concurrent: "concurrent",
|
|
7406
|
+
total: "total"
|
|
7407
|
+
};
|
|
7408
|
+
async function cloudLimits() {
|
|
7409
|
+
printPlan("Vercel Hobby", VERCEL_HOBBY_LIMITS, VERCEL_HOBBY_NOTES);
|
|
7410
|
+
printPlan("Supabase Free", SUPABASE_FREE_LIMITS, SUPABASE_FREE_NOTES);
|
|
7411
|
+
console.log(chalk46.dim(`Numbers verified against the official docs on ${LIMITS_VERIFIED_ON}.`));
|
|
7412
|
+
console.log(chalk46.dim("Live usage against these limits: `spfn cloud status`.\n"));
|
|
7413
|
+
}
|
|
7414
|
+
function printPlan(title, limits, notes) {
|
|
7415
|
+
console.log(chalk46.bold(`
|
|
7416
|
+
${title}`));
|
|
7417
|
+
for (const limit of limits) {
|
|
7418
|
+
const amount = `${formatCount(limit.limit)} ${limit.unit}`;
|
|
7419
|
+
console.log(` ${limit.label.padEnd(34)} ${amount.padEnd(26)} ${chalk46.dim(PER_LABEL[limit.per])}`);
|
|
7420
|
+
}
|
|
7421
|
+
for (const note of notes) {
|
|
7422
|
+
console.log(chalk46.dim(` \u2022 ${note}`));
|
|
7423
|
+
}
|
|
7424
|
+
console.log("");
|
|
7425
|
+
}
|
|
7426
|
+
|
|
7427
|
+
// src/commands/cloud/usage.ts
|
|
7428
|
+
init_logger();
|
|
7429
|
+
import chalk47 from "chalk";
|
|
7430
|
+
|
|
7431
|
+
// src/commands/cloud/collect.ts
|
|
7432
|
+
async function collectSnapshot(cwd) {
|
|
7433
|
+
let config;
|
|
7434
|
+
try {
|
|
7435
|
+
config = readCloudConfig(cwd);
|
|
7436
|
+
} catch (error) {
|
|
7437
|
+
return {
|
|
7438
|
+
config: {},
|
|
7439
|
+
vercel: null,
|
|
7440
|
+
supabase: null,
|
|
7441
|
+
problems: [error instanceof Error ? error.message : String(error)]
|
|
7442
|
+
};
|
|
7443
|
+
}
|
|
7444
|
+
const snapshot = { config, vercel: null, supabase: null, problems: [] };
|
|
7445
|
+
await Promise.all([
|
|
7446
|
+
collectVercel(config, snapshot),
|
|
7447
|
+
collectSupabase(config, snapshot)
|
|
7448
|
+
]);
|
|
7449
|
+
return snapshot;
|
|
7450
|
+
}
|
|
7451
|
+
async function collectVercel(config, snapshot) {
|
|
7452
|
+
try {
|
|
7453
|
+
const linked = requireLinked(config, "vercel");
|
|
7454
|
+
const token = await requireCloudToken("vercel");
|
|
7455
|
+
const to = /* @__PURE__ */ new Date();
|
|
7456
|
+
const from = new Date(to.getTime() - 30 * 24 * 60 * 60 * 1e3);
|
|
7457
|
+
snapshot.vercel = {
|
|
7458
|
+
projectName: linked.projectName,
|
|
7459
|
+
services: await getVercelUsage(token, from, to, linked.teamId)
|
|
7460
|
+
};
|
|
7461
|
+
} catch (error) {
|
|
7462
|
+
snapshot.problems.push(`Vercel: ${error instanceof Error ? error.message : String(error)}`);
|
|
7463
|
+
}
|
|
7464
|
+
}
|
|
7465
|
+
async function collectSupabase(config, snapshot) {
|
|
7466
|
+
let linked;
|
|
7467
|
+
let token;
|
|
7468
|
+
try {
|
|
7469
|
+
linked = requireLinked(config, "supabase");
|
|
7470
|
+
token = await requireCloudToken("supabase");
|
|
7471
|
+
} catch (error) {
|
|
7472
|
+
snapshot.problems.push(`Supabase: ${error instanceof Error ? error.message : String(error)}`);
|
|
7473
|
+
return;
|
|
7474
|
+
}
|
|
7475
|
+
const project = await swallow(() => getSupabaseProject(token, linked.projectRef), "Supabase project", snapshot);
|
|
7476
|
+
const status = project?.status ?? "unknown";
|
|
7477
|
+
if (isSupabasePaused(status)) {
|
|
7478
|
+
snapshot.supabase = { projectName: project?.name ?? linked.projectName, status, dbSizeBytes: null, dailyApiCount: null };
|
|
7479
|
+
return;
|
|
7480
|
+
}
|
|
7481
|
+
const [dbSizeBytes, dailyApiCount] = await Promise.all([
|
|
7482
|
+
swallow(() => getSupabaseDbSizeBytes(token, linked.projectRef), "Supabase DB size", snapshot),
|
|
7483
|
+
swallow(() => getSupabaseDailyApiCount(token, linked.projectRef), "Supabase API counts", snapshot)
|
|
7484
|
+
]);
|
|
7485
|
+
snapshot.supabase = {
|
|
7486
|
+
projectName: project?.name ?? linked.projectName,
|
|
7487
|
+
status,
|
|
7488
|
+
dbSizeBytes: dbSizeBytes ?? null,
|
|
7489
|
+
dailyApiCount: dailyApiCount ?? null
|
|
7490
|
+
};
|
|
7491
|
+
}
|
|
7492
|
+
function isSupabasePaused(status) {
|
|
7493
|
+
return ["INACTIVE", "PAUSED", "PAUSING", "PAUSE_FAILED"].includes(status.toUpperCase());
|
|
7494
|
+
}
|
|
7495
|
+
async function swallow(call, label, snapshot) {
|
|
7496
|
+
try {
|
|
7497
|
+
return await call();
|
|
7498
|
+
} catch (error) {
|
|
7499
|
+
snapshot.problems.push(`${label}: ${error instanceof Error ? error.message : String(error)}`);
|
|
7500
|
+
return null;
|
|
7501
|
+
}
|
|
7502
|
+
}
|
|
7503
|
+
function matchVercelLimit(serviceName) {
|
|
7504
|
+
const normalized = normalize(serviceName);
|
|
7505
|
+
return VERCEL_HOBBY_LIMITS.find((limit) => limit.per === "month" && (normalize(limit.label) === normalized || normalize(limit.key) === normalized));
|
|
7506
|
+
}
|
|
7507
|
+
function normalize(text) {
|
|
7508
|
+
return text.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
7509
|
+
}
|
|
7510
|
+
|
|
7511
|
+
// src/commands/cloud/usage.ts
|
|
7512
|
+
async function cloudUsage() {
|
|
7513
|
+
const snapshot = await collectSnapshot(process.cwd());
|
|
7514
|
+
printVercel(snapshot);
|
|
7515
|
+
printSupabase(snapshot);
|
|
7516
|
+
printProblems(snapshot);
|
|
7517
|
+
}
|
|
7518
|
+
function printVercel(snapshot) {
|
|
7519
|
+
if (!snapshot.vercel) {
|
|
7520
|
+
return;
|
|
7521
|
+
}
|
|
7522
|
+
console.log(chalk47.bold(`
|
|
7523
|
+
Vercel \u2014 account-wide usage, rolling 30 days (linked project: ${snapshot.vercel.projectName})`));
|
|
7524
|
+
if (snapshot.vercel.services.length === 0) {
|
|
7525
|
+
console.log(chalk47.dim(" No usage recorded in the billing feed yet."));
|
|
7526
|
+
}
|
|
7527
|
+
for (const service of snapshot.vercel.services) {
|
|
7528
|
+
const amount = `${formatCount(service.consumed)} ${service.unit}`.trim();
|
|
7529
|
+
console.log(` ${service.serviceName.padEnd(34)} ${amount}`);
|
|
7530
|
+
}
|
|
7531
|
+
}
|
|
7532
|
+
function printSupabase(snapshot) {
|
|
7533
|
+
if (!snapshot.supabase) {
|
|
7534
|
+
return;
|
|
7535
|
+
}
|
|
7536
|
+
const { projectName, status, dbSizeBytes, dailyApiCount } = snapshot.supabase;
|
|
7537
|
+
console.log(chalk47.bold(`
|
|
7538
|
+
Supabase \u2014 ${projectName}`));
|
|
7539
|
+
console.log(` ${"Project status".padEnd(34)} ${status}`);
|
|
7540
|
+
console.log(` ${"Database size".padEnd(34)} ${dbSizeBytes === null ? chalk47.dim("unavailable") : formatBytes2(dbSizeBytes)}`);
|
|
7541
|
+
console.log(` ${"API requests (last 24h)".padEnd(34)} ${dailyApiCount === null ? chalk47.dim("unavailable") : formatCount(dailyApiCount)}`);
|
|
7542
|
+
console.log(chalk47.dim(" Org totals (egress, MAU) have no public API \u2014 exact numbers: supabase.com/dashboard \u2192 Usage."));
|
|
7543
|
+
}
|
|
7544
|
+
function printProblems(snapshot) {
|
|
7545
|
+
console.log("");
|
|
7546
|
+
for (const problem of snapshot.problems) {
|
|
7547
|
+
logger.warn(problem);
|
|
7548
|
+
}
|
|
7549
|
+
if (snapshot.problems.length > 0 && !snapshot.vercel && !snapshot.supabase) {
|
|
7550
|
+
process.exit(1);
|
|
7551
|
+
}
|
|
7552
|
+
}
|
|
7553
|
+
|
|
7554
|
+
// src/commands/cloud/status.ts
|
|
7555
|
+
init_logger();
|
|
7556
|
+
import chalk48 from "chalk";
|
|
7557
|
+
async function cloudStatus() {
|
|
7558
|
+
const snapshot = await collectSnapshot(process.cwd());
|
|
7559
|
+
const nearLimit = [];
|
|
7560
|
+
printVercel2(snapshot, nearLimit);
|
|
7561
|
+
printSupabase2(snapshot, nearLimit);
|
|
7562
|
+
console.log("");
|
|
7563
|
+
for (const problem of snapshot.problems) {
|
|
7564
|
+
logger.warn(problem);
|
|
7565
|
+
}
|
|
7566
|
+
printMigrationPrompt(nearLimit);
|
|
7567
|
+
if (!snapshot.vercel && !snapshot.supabase) {
|
|
7568
|
+
process.exit(1);
|
|
7569
|
+
}
|
|
7570
|
+
}
|
|
7571
|
+
function printVercel2(snapshot, nearLimit) {
|
|
7572
|
+
if (!snapshot.vercel) {
|
|
7573
|
+
return;
|
|
7574
|
+
}
|
|
7575
|
+
console.log(chalk48.bold(`
|
|
7576
|
+
Vercel Hobby \u2014 account-wide usage, rolling 30 days (linked project: ${snapshot.vercel.projectName}; limits as of ${LIMITS_VERIFIED_ON})`));
|
|
7577
|
+
if (snapshot.vercel.services.length === 0) {
|
|
7578
|
+
console.log(chalk48.dim(" No usage recorded in the billing feed yet."));
|
|
7579
|
+
}
|
|
7580
|
+
for (const service of snapshot.vercel.services) {
|
|
7581
|
+
const limit = matchVercelLimit(service.serviceName);
|
|
7582
|
+
if (!limit || !sameUnit(service.unit, limit.unit)) {
|
|
7583
|
+
console.log(` ${service.serviceName.padEnd(34)} ${formatCount(service.consumed)} ${service.unit}`.trimEnd());
|
|
7584
|
+
continue;
|
|
7585
|
+
}
|
|
7586
|
+
console.log(usageLine(limit.label, service.consumed, limit.limit, limit.unit));
|
|
7587
|
+
if (isNearLimit(service.consumed, limit.limit)) {
|
|
7588
|
+
nearLimit.push(`Vercel ${limit.label}`);
|
|
7589
|
+
}
|
|
7590
|
+
}
|
|
7591
|
+
}
|
|
7592
|
+
function printSupabase2(snapshot, nearLimit) {
|
|
7593
|
+
if (!snapshot.supabase) {
|
|
7594
|
+
return;
|
|
7595
|
+
}
|
|
7596
|
+
const { projectName, status, dbSizeBytes, dailyApiCount } = snapshot.supabase;
|
|
7597
|
+
console.log(chalk48.bold(`
|
|
7598
|
+
Supabase Free \u2014 ${projectName}`));
|
|
7599
|
+
if (isSupabasePaused(status)) {
|
|
7600
|
+
console.log(` ${"Project status".padEnd(34)} ${chalk48.red.bold(status)} \u2014 run \`spfn cloud keepalive\` after restoring it`);
|
|
7601
|
+
} else {
|
|
7602
|
+
console.log(` ${"Project status".padEnd(34)} ${chalk48.green(status)}`);
|
|
7603
|
+
}
|
|
7604
|
+
if (dbSizeBytes !== null) {
|
|
7605
|
+
const dbLimit = SUPABASE_FREE_LIMITS.find((l) => l.key === "db-size");
|
|
7606
|
+
const usedMb = dbSizeBytes / BYTES_PER_MB;
|
|
7607
|
+
console.log(usageLine(dbLimit.label, usedMb, dbLimit.limit, dbLimit.unit));
|
|
7608
|
+
if (isNearLimit(usedMb, dbLimit.limit)) {
|
|
7609
|
+
nearLimit.push("Supabase database size");
|
|
7610
|
+
}
|
|
7611
|
+
} else {
|
|
7612
|
+
console.log(` ${"Database size".padEnd(34)} ${chalk48.dim("unavailable")}`);
|
|
7613
|
+
}
|
|
7614
|
+
console.log(` ${"API requests (last 24h)".padEnd(34)} ${dailyApiCount === null ? chalk48.dim("unavailable") : formatCount(dailyApiCount)}`);
|
|
7615
|
+
console.log(chalk48.dim(` DB size ${dbSizeBytes === null ? "unknown" : formatBytes2(dbSizeBytes)} counts per project; egress/MAU are org totals \u2014 exact numbers on the dashboard.`));
|
|
7616
|
+
}
|
|
7617
|
+
function printMigrationPrompt(nearLimit) {
|
|
7618
|
+
if (nearLimit.length === 0) {
|
|
7619
|
+
return;
|
|
7620
|
+
}
|
|
7621
|
+
console.log(chalk48.yellow.bold(`\u26A0 Approaching free-tier limits: ${nearLimit.join(", ")}.`));
|
|
7622
|
+
console.log(chalk48.yellow(" When a Vercel Hobby limit is hit, that capability pauses; Supabase over quota restricts the whole org."));
|
|
7623
|
+
console.log(chalk48.yellow(" Migrating to your own cloud removes these ceilings \u2014 `spfn cloud migrate` (coming soon).\n"));
|
|
7624
|
+
}
|
|
7625
|
+
|
|
7626
|
+
// src/commands/cloud/keepalive.ts
|
|
7627
|
+
init_logger();
|
|
7628
|
+
import { existsSync as existsSync32, mkdirSync as mkdirSync8, readFileSync as readFileSync14, writeFileSync as writeFileSync21 } from "fs";
|
|
7629
|
+
import { dirname as dirname7, join as join32 } from "path";
|
|
7630
|
+
import chalk49 from "chalk";
|
|
7631
|
+
var DEFAULT_HEALTH_PATH = "/api/backend/_core/health?detailed=true";
|
|
7632
|
+
var DEFAULT_SCHEDULE = "0 3 * * *";
|
|
7633
|
+
async function cloudKeepalive(options) {
|
|
7634
|
+
const cwd = process.cwd();
|
|
7635
|
+
const healthPath = options.path ?? DEFAULT_HEALTH_PATH;
|
|
7636
|
+
if (!healthPath.startsWith("/")) {
|
|
7637
|
+
logger.error(`--path must start with a slash (got "${healthPath}") \u2014 both Vercel crons and the workflow URL treat it as absolute.`);
|
|
7638
|
+
process.exit(1);
|
|
7639
|
+
}
|
|
7640
|
+
if (options.githubActions) {
|
|
7641
|
+
setupGithubActions(cwd, healthPath, options.url);
|
|
7642
|
+
} else {
|
|
7643
|
+
setupVercelCron(cwd, healthPath);
|
|
7644
|
+
}
|
|
7645
|
+
try {
|
|
7646
|
+
const config = readCloudConfig(cwd);
|
|
7647
|
+
config.keepalive = { path: healthPath, schedule: DEFAULT_SCHEDULE };
|
|
7648
|
+
writeCloudConfig(cwd, config);
|
|
7649
|
+
} catch (error) {
|
|
7650
|
+
logger.warn(error instanceof Error ? error.message : String(error));
|
|
7651
|
+
}
|
|
7652
|
+
logger.info("The DB ping needs detailed health checks: keep `healthCheck: { detailed: true }` in your server config.");
|
|
7653
|
+
}
|
|
7654
|
+
function setupVercelCron(cwd, healthPath) {
|
|
7655
|
+
const vercelJsonPath = join32(cwd, "vercel.json");
|
|
7656
|
+
let vercelJson = {};
|
|
7657
|
+
if (existsSync32(vercelJsonPath)) {
|
|
7658
|
+
let parsed;
|
|
7659
|
+
try {
|
|
7660
|
+
parsed = JSON.parse(readFileSync14(vercelJsonPath, "utf-8"));
|
|
7661
|
+
} catch {
|
|
7662
|
+
logger.error("vercel.json is not valid JSON (comments and trailing commas are not allowed) \u2014 fix it and re-run.");
|
|
7663
|
+
process.exit(1);
|
|
7664
|
+
}
|
|
7665
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
7666
|
+
logger.error("vercel.json does not contain a JSON object \u2014 fix it and re-run.");
|
|
7667
|
+
process.exit(1);
|
|
7668
|
+
}
|
|
7669
|
+
vercelJson = parsed;
|
|
7670
|
+
}
|
|
7671
|
+
const result = addCron(vercelJson, healthPath);
|
|
7672
|
+
if (result === "exists") {
|
|
7673
|
+
logger.success(`vercel.json already has a cron for ${healthPath} \u2014 nothing to do.`);
|
|
7674
|
+
return;
|
|
7675
|
+
}
|
|
7676
|
+
if (result === "occupied") {
|
|
7677
|
+
logger.error("vercel.json already has a cron job, and Vercel Hobby allows only one.");
|
|
7678
|
+
logger.info(`Point the existing cron at ${chalk49.cyan(healthPath)} yourself, or use \`--github-actions --url <deployed-url>\`.`);
|
|
7679
|
+
process.exit(1);
|
|
7680
|
+
}
|
|
7681
|
+
writeFileSync21(vercelJsonPath, JSON.stringify(vercelJson, null, 4) + "\n", "utf-8");
|
|
7682
|
+
logger.success(`vercel.json: daily cron added (${DEFAULT_SCHEDULE} UTC \u2192 ${healthPath}). Deploy to activate.`);
|
|
7683
|
+
warnIfBackendRouteMissing(cwd);
|
|
7684
|
+
}
|
|
7685
|
+
function addCron(vercelJson, healthPath) {
|
|
7686
|
+
const crons = Array.isArray(vercelJson.crons) ? vercelJson.crons : [];
|
|
7687
|
+
if (crons.some((cron) => cron.path === healthPath)) {
|
|
7688
|
+
return "exists";
|
|
7689
|
+
}
|
|
7690
|
+
if (crons.length > 0) {
|
|
7691
|
+
return "occupied";
|
|
7692
|
+
}
|
|
7693
|
+
vercelJson.crons = [{ path: healthPath, schedule: DEFAULT_SCHEDULE }];
|
|
7694
|
+
return "added";
|
|
7695
|
+
}
|
|
7696
|
+
function setupGithubActions(cwd, healthPath, url) {
|
|
7697
|
+
if (!url) {
|
|
7698
|
+
logger.error("`--github-actions` needs `--url <deployed-url>` \u2014 the workflow runs outside your app and must know where it lives.");
|
|
7699
|
+
process.exit(1);
|
|
7700
|
+
}
|
|
7701
|
+
const workflowPath = join32(cwd, ".github", "workflows", "spfn-keepalive.yml");
|
|
7702
|
+
if (existsSync32(workflowPath)) {
|
|
7703
|
+
logger.success(`${workflowPath} already exists \u2014 nothing to do.`);
|
|
7704
|
+
return;
|
|
7705
|
+
}
|
|
7706
|
+
const base = normalizeDeployUrl(url);
|
|
7707
|
+
if (!base) {
|
|
7708
|
+
logger.error(`--url "${url}" is not a valid URL. Pass the deployed app origin, e.g. https://myapp.vercel.app.`);
|
|
7709
|
+
process.exit(1);
|
|
7710
|
+
}
|
|
7711
|
+
mkdirSync8(dirname7(workflowPath), { recursive: true });
|
|
7712
|
+
writeFileSync21(workflowPath, githubWorkflow(new URL(healthPath, base).toString()), "utf-8");
|
|
7713
|
+
logger.success(".github/workflows/spfn-keepalive.yml: daily keep-alive workflow added. Commit and push to activate.");
|
|
7714
|
+
}
|
|
7715
|
+
function normalizeDeployUrl(url) {
|
|
7716
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(url) && !/^https?:\/\//i.test(url)) {
|
|
7717
|
+
return null;
|
|
7718
|
+
}
|
|
7719
|
+
let parsed;
|
|
7720
|
+
try {
|
|
7721
|
+
parsed = new URL(/^https?:\/\//i.test(url) ? url : `https://${url}`);
|
|
7722
|
+
} catch {
|
|
7723
|
+
return null;
|
|
7724
|
+
}
|
|
7725
|
+
if (!parsed.hostname.includes(".")) {
|
|
7726
|
+
return null;
|
|
7727
|
+
}
|
|
7728
|
+
return parsed.origin + "/";
|
|
7729
|
+
}
|
|
7730
|
+
function githubWorkflow(healthUrl) {
|
|
7731
|
+
return `# Keeps the Supabase Free project from pausing: one DB-touching request per day.
|
|
7732
|
+
# Generated by \`spfn cloud keepalive --github-actions\`.
|
|
7733
|
+
name: spfn-keepalive
|
|
7734
|
+
|
|
7735
|
+
on:
|
|
7736
|
+
schedule:
|
|
7737
|
+
- cron: '${DEFAULT_SCHEDULE}'
|
|
7738
|
+
workflow_dispatch:
|
|
7739
|
+
|
|
7740
|
+
jobs:
|
|
7741
|
+
ping:
|
|
7742
|
+
runs-on: ubuntu-latest
|
|
7743
|
+
steps:
|
|
7744
|
+
- name: Ping health endpoint (detailed = includes a DB query)
|
|
7745
|
+
run: curl --fail --silent --show-error --max-time 30 '${healthUrl}'
|
|
7746
|
+
`;
|
|
7747
|
+
}
|
|
7748
|
+
function warnIfBackendRouteMissing(cwd) {
|
|
7749
|
+
const routePath = join32(cwd, "src", "app", "api", "backend", "[[...route]]", "route.ts");
|
|
7750
|
+
if (!existsSync32(routePath)) {
|
|
7751
|
+
logger.warn("src/app/api/backend/[[...route]]/route.ts not found \u2014 the cron path answers only after `spfn add vercel`.");
|
|
7752
|
+
}
|
|
7753
|
+
}
|
|
7754
|
+
|
|
7755
|
+
// src/commands/cloud/env.ts
|
|
7756
|
+
init_logger();
|
|
7757
|
+
init_env_file();
|
|
7758
|
+
import { join as join33 } from "path";
|
|
7759
|
+
import prompts13 from "prompts";
|
|
7760
|
+
import chalk50 from "chalk";
|
|
7761
|
+
async function cloudEnvPull(options) {
|
|
7762
|
+
const cwd = process.cwd();
|
|
7763
|
+
try {
|
|
7764
|
+
const linked = requireLinked(readCloudConfig(cwd), "supabase");
|
|
7765
|
+
const token = await requireCloudToken("supabase");
|
|
7766
|
+
const keys = await getSupabaseApiKeys(token, linked.projectRef);
|
|
7767
|
+
writePublicValues(cwd, linked.projectRef, keys);
|
|
7768
|
+
await storeServiceKey(cwd, keys);
|
|
7769
|
+
if (options.dbUrl) {
|
|
7770
|
+
await storeDatabaseUrl(cwd, token, linked.projectRef);
|
|
7771
|
+
}
|
|
7772
|
+
} catch (error) {
|
|
7773
|
+
logger.error(error instanceof Error ? error.message : String(error));
|
|
7774
|
+
process.exit(1);
|
|
7775
|
+
}
|
|
7776
|
+
logger.info("Values were written to files/keychain only \u2014 none were displayed.");
|
|
7777
|
+
}
|
|
7778
|
+
function writePublicValues(cwd, projectRef2, keys) {
|
|
7779
|
+
const envLocalPath = join33(cwd, ".env.local");
|
|
7780
|
+
ensureGitignored(cwd, [{ pattern: ".env.local", comment: "local env (per-machine values)" }]);
|
|
7781
|
+
upsertEnvVar(envLocalPath, "NEXT_PUBLIC_SUPABASE_URL", `https://${projectRef2}.supabase.co`);
|
|
7782
|
+
logger.success("NEXT_PUBLIC_SUPABASE_URL \u2192 .env.local");
|
|
7783
|
+
const anon = findKey(keys, ["anon", "publishable"]);
|
|
7784
|
+
if (anon) {
|
|
7785
|
+
upsertEnvVar(envLocalPath, "NEXT_PUBLIC_SUPABASE_ANON_KEY", anon.api_key);
|
|
7786
|
+
logger.success(`NEXT_PUBLIC_SUPABASE_ANON_KEY \u2192 .env.local (from "${anon.name}"; public by design)`);
|
|
7787
|
+
} else {
|
|
7788
|
+
logger.warn("No anon/publishable key found on the project \u2014 skipped NEXT_PUBLIC_SUPABASE_ANON_KEY.");
|
|
7789
|
+
}
|
|
7790
|
+
}
|
|
7791
|
+
async function storeServiceKey(cwd, keys) {
|
|
7792
|
+
const service = findKey(keys, ["service_role", "secret"]);
|
|
7793
|
+
if (!service) {
|
|
7794
|
+
logger.warn("No service_role/secret key found on the project \u2014 skipped SUPABASE_SERVICE_ROLE_KEY.");
|
|
7795
|
+
return;
|
|
7796
|
+
}
|
|
7797
|
+
await storeSecret(cwd, "local", "SUPABASE_SERVICE_ROLE_KEY", service.api_key);
|
|
7798
|
+
}
|
|
7799
|
+
function findKey(keys, candidates) {
|
|
7800
|
+
return keys.find((key) => candidates.includes(key.name)) ?? keys.find((key) => key.type !== void 0 && candidates.includes(key.type));
|
|
7801
|
+
}
|
|
7802
|
+
async function storeDatabaseUrl(cwd, token, projectRef2) {
|
|
7803
|
+
const template = await poolerTemplate(token, projectRef2);
|
|
7804
|
+
if (!template) {
|
|
7805
|
+
logger.warn("Could not read the pooler connection template from the API \u2014 skipped DATABASE_URL. Copy it from the dashboard (Connect \u2192 Session pooler) into `spfn secret set DATABASE_URL` instead.");
|
|
7806
|
+
return;
|
|
7807
|
+
}
|
|
7808
|
+
const { password } = await prompts13({
|
|
7809
|
+
type: "password",
|
|
7810
|
+
name: "password",
|
|
7811
|
+
message: "Supabase database password (set at project creation; not retrievable via API)"
|
|
7812
|
+
});
|
|
7813
|
+
if (!password) {
|
|
7814
|
+
logger.warn("No password entered \u2014 skipped DATABASE_URL.");
|
|
7815
|
+
return;
|
|
7816
|
+
}
|
|
7817
|
+
const url = sessionPoolerUrl(template, password);
|
|
7818
|
+
await storeSecret(cwd, "local", "DATABASE_URL", url);
|
|
7819
|
+
logger.info("This is the session-mode pooler connection (IPv4-safe, works for migrations). Serverless runtime can use the transaction pooler (port 6543) instead.");
|
|
7820
|
+
}
|
|
7821
|
+
async function poolerTemplate(token, projectRef2) {
|
|
7822
|
+
try {
|
|
7823
|
+
const configs = await getSupabasePoolerConfigs(token, projectRef2);
|
|
7824
|
+
return configs.find((c) => c.database_type === "PRIMARY")?.connection_string ?? configs.find((c) => c.connection_string)?.connection_string;
|
|
7825
|
+
} catch (error) {
|
|
7826
|
+
logger.warn(error instanceof Error ? error.message : String(error));
|
|
7827
|
+
return void 0;
|
|
7828
|
+
}
|
|
7829
|
+
}
|
|
7830
|
+
function sessionPoolerUrl(template, password) {
|
|
7831
|
+
const filled = template.replace("[YOUR-PASSWORD]", encodeURIComponent(password));
|
|
7832
|
+
const url = new URL(filled);
|
|
7833
|
+
url.port = "5432";
|
|
7834
|
+
return url.toString();
|
|
7835
|
+
}
|
|
7836
|
+
async function cloudEnvPush(keys) {
|
|
7837
|
+
const cwd = process.cwd();
|
|
7838
|
+
if (keys.length === 0) {
|
|
7839
|
+
logger.error("Name the env keys to push: `spfn cloud env push KEY [KEY...]` (values are looked up locally, never passed as arguments).");
|
|
7840
|
+
process.exit(1);
|
|
7841
|
+
}
|
|
7842
|
+
try {
|
|
7843
|
+
const linked = requireLinked(readCloudConfig(cwd), "vercel");
|
|
7844
|
+
const token = await requireCloudToken("vercel");
|
|
7845
|
+
const { vars, missing, unresolved } = await resolveLocalValues(cwd, keys);
|
|
7846
|
+
for (const key of unresolved) {
|
|
7847
|
+
logger.warn(`${key}: .env.server points at the keychain but the item could not be resolved \u2014 skipped (a stale value from another file will not be pushed in its place).`);
|
|
7848
|
+
}
|
|
7849
|
+
for (const key of missing) {
|
|
7850
|
+
logger.warn(`${key}: not found in .env/.env.local/.env.server or the keychain \u2014 skipped.`);
|
|
7851
|
+
}
|
|
7852
|
+
if (vars.length === 0) {
|
|
7853
|
+
logger.error("Nothing to push.");
|
|
7854
|
+
process.exit(1);
|
|
7855
|
+
}
|
|
7856
|
+
const result = await upsertVercelEnvVars(token, linked.projectId, vars, linked.teamId);
|
|
7857
|
+
const failedKeys = new Set(result.failed.map((f) => f.key));
|
|
7858
|
+
for (const failure of result.failed) {
|
|
7859
|
+
logger.error(`${failure.key}: rejected by Vercel \u2014 ${failure.message}`);
|
|
7860
|
+
}
|
|
7861
|
+
for (const envVar of vars.filter((v) => !failedKeys.has(v.key))) {
|
|
7862
|
+
logger.success(`${envVar.key} \u2192 Vercel ${chalk50.cyan(linked.projectName)} (${envVar.type}, ${envVar.target.join("+")})`);
|
|
7863
|
+
}
|
|
7864
|
+
if (result.failed.length > 0) {
|
|
7865
|
+
process.exit(1);
|
|
7866
|
+
}
|
|
7867
|
+
} catch (error) {
|
|
7868
|
+
logger.error(error instanceof Error ? error.message : String(error));
|
|
7869
|
+
process.exit(1);
|
|
7870
|
+
}
|
|
7871
|
+
}
|
|
7872
|
+
async function resolveLocalValues(cwd, keys) {
|
|
7873
|
+
const publicEnv = { ...parseEnvFile(join33(cwd, ".env")), ...parseEnvFile(join33(cwd, ".env.local")) };
|
|
7874
|
+
const serverEnv = parseEnvFile(join33(cwd, ".env.server"));
|
|
7875
|
+
const keychain = await resolveKeychainEnv(cwd);
|
|
7876
|
+
const vars = [];
|
|
7877
|
+
const missing = [];
|
|
7878
|
+
const unresolved = [];
|
|
7879
|
+
for (const key of keys) {
|
|
7880
|
+
if (serverEnv[key]?.startsWith(KEYCHAIN_REF_PREFIX) && keychain.env[key] === void 0) {
|
|
7881
|
+
unresolved.push(key);
|
|
7882
|
+
continue;
|
|
7883
|
+
}
|
|
7884
|
+
const value = keychain.env[key] ?? serverEnv[key] ?? publicEnv[key];
|
|
7885
|
+
if (value === void 0) {
|
|
7886
|
+
missing.push(key);
|
|
7887
|
+
continue;
|
|
7888
|
+
}
|
|
7889
|
+
vars.push({
|
|
7890
|
+
key,
|
|
7891
|
+
value,
|
|
7892
|
+
type: key.startsWith("NEXT_PUBLIC_") ? "plain" : "encrypted",
|
|
7893
|
+
target: ["production", "preview"]
|
|
7894
|
+
});
|
|
7895
|
+
}
|
|
7896
|
+
return { vars, missing, unresolved };
|
|
7897
|
+
}
|
|
7898
|
+
|
|
7899
|
+
// src/commands/cloud/index.ts
|
|
7900
|
+
var cloudCommand = new Command17("cloud").description("Manage the free-tier cloud accounts this app deploys to (Vercel + Supabase)");
|
|
7901
|
+
cloudCommand.command("link").description("Connect your Vercel and Supabase accounts (tokens go to the OS keychain)").option("--vercel-project <nameOrId>", "Pick the Vercel project without prompting").option("--supabase-project <refOrName>", "Pick the Supabase project without prompting").option("--replace", "Discard the stored tokens and enter fresh ones").action(cloudLink);
|
|
7902
|
+
cloudCommand.command("limits").description("Show the free-plan limits (Vercel Hobby, Supabase Free)").action(cloudLimits);
|
|
7903
|
+
cloudCommand.command("usage").description("Show current usage from the provider APIs").action(cloudUsage);
|
|
7904
|
+
cloudCommand.command("status").description("Usage measured against the free-plan limits, with warnings from 80%").action(cloudStatus);
|
|
7905
|
+
cloudCommand.command("keepalive").description("Keep the Supabase project from pausing (daily health-check cron)").option("--path <path>", "Health endpoint path the cron should hit").option("--github-actions", "Generate a GitHub Actions workflow instead of a Vercel cron").option("--url <url>", "Deployed app URL (required with --github-actions)").action(cloudKeepalive);
|
|
7906
|
+
var envGroup = cloudCommand.command("env").description("Sync env vars and API keys between the providers and this app");
|
|
7907
|
+
envGroup.command("pull").description("Fetch Supabase keys into .env.local / the keychain (values never displayed)").option("--db-url", "Also compose DATABASE_URL (prompts for the database password)").action(cloudEnvPull);
|
|
7908
|
+
envGroup.command("push").description("Push local env values to the Vercel project env by key name").argument("[keys...]", "Env var names to push (values resolve locally)").action(cloudEnvPush);
|
|
7909
|
+
|
|
6667
7910
|
// src/index.ts
|
|
6668
7911
|
init_version();
|
|
6669
|
-
var program = new
|
|
7912
|
+
var program = new Command18();
|
|
6670
7913
|
program.name("spfn").description("SPFN CLI - The Missing Backend for Next.js").version(getCliVersion());
|
|
6671
7914
|
program.addCommand(createCommand);
|
|
6672
7915
|
program.addCommand(initCommand);
|
|
@@ -6683,6 +7926,7 @@ program.addCommand(dbCommand);
|
|
|
6683
7926
|
program.addCommand(envCommand);
|
|
6684
7927
|
program.addCommand(opsCommand);
|
|
6685
7928
|
program.addCommand(secretCommand);
|
|
7929
|
+
program.addCommand(cloudCommand);
|
|
6686
7930
|
async function run() {
|
|
6687
7931
|
await program.parseAsync(process.argv);
|
|
6688
7932
|
}
|