spfn 0.3.0-beta.4 → 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.
Files changed (3) hide show
  1. package/README.md +50 -1
  2. package/dist/index.js +1258 -19
  3. package/package.json +2 -1
package/dist/index.js CHANGED
@@ -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.4";
947
+ return "0.3.0-beta.5";
948
948
  }
949
949
  function getTagFromVersion(version) {
950
950
  const match = version.match(/-([a-z]+)\./i);
@@ -1757,7 +1757,7 @@ var init_function_migrations = __esm({
1757
1757
  });
1758
1758
 
1759
1759
  // src/index.ts
1760
- import { Command as Command17 } from "commander";
1760
+ import { Command as Command18 } from "commander";
1761
1761
 
1762
1762
  // src/commands/create.ts
1763
1763
  init_logger();
@@ -5377,6 +5377,11 @@ envCommand.command("validate").description("Validate environment variables again
5377
5377
  import { Command as Command15 } from "commander";
5378
5378
  import chalk36 from "chalk";
5379
5379
 
5380
+ // src/utils/ops/plain.ts
5381
+ function plain(value) {
5382
+ return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, "?");
5383
+ }
5384
+
5380
5385
  // src/utils/ops/client.ts
5381
5386
  function joinUrl(appUrl, path5) {
5382
5387
  return appUrl.replace(/\/+$/, "") + path5;
@@ -5413,10 +5418,59 @@ async function fetchOpsManifest(appUrl, token) {
5413
5418
  if (manifest?.manifestVersion !== 1 || !Array.isArray(manifest.commands)) {
5414
5419
  throw new Error("The manifest answer has an unknown shape.");
5415
5420
  }
5416
- return { manifestVersion: 1, commands: usableCommands(manifest.commands) };
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
+ };
5417
5427
  }
5418
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;
5419
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
+ }
5420
5474
  function unusableBecause(command) {
5421
5475
  if (typeof command?.name !== "string" || command.name.length === 0) {
5422
5476
  return "it has no name";
@@ -5424,15 +5478,16 @@ function unusableBecause(command) {
5424
5478
  if (typeof command.method !== "string" || !OPS_METHODS.has(command.method)) {
5425
5479
  return `its method is ${JSON.stringify(command.method)}`;
5426
5480
  }
5427
- if (typeof command.path !== "string" || !command.path.startsWith(OPS_PATH_PREFIX)) {
5428
- return `its path ${JSON.stringify(command.path)} is outside ${OPS_PATH_PREFIX}`;
5481
+ if (typeof command.path !== "string") {
5482
+ return `its path ${JSON.stringify(command.path)} is not a string`;
5429
5483
  }
5430
- if (command.path.split("/").includes("..")) {
5431
- return `its path ${JSON.stringify(command.path)} climbs out of the ops namespace`;
5484
+ const pathReason = unstableOpsPathBecause(command.path);
5485
+ if (pathReason !== null) {
5486
+ return `its path ${JSON.stringify(command.path)} ${pathReason}`;
5432
5487
  }
5433
5488
  return null;
5434
5489
  }
5435
- function usableCommands(commands) {
5490
+ function usableCommands(commands, moduleIds) {
5436
5491
  const usable = [];
5437
5492
  for (const command of commands) {
5438
5493
  const reason = unusableBecause(command);
@@ -5440,19 +5495,142 @@ function usableCommands(commands) {
5440
5495
  console.error(`\u26A0\uFE0F Ignoring an ops command the manifest announced: ${reason}.`);
5441
5496
  continue;
5442
5497
  }
5443
- usable.push({ ...command, input: isPlainObject(command.input) ? command.input : {} });
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);
5444
5517
  }
5445
5518
  return usable;
5446
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
+ }
5447
5594
  function isPlainObject(value) {
5448
5595
  return value !== null && typeof value === "object" && !Array.isArray(value);
5449
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
+ }
5450
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
+ }
5451
5621
  const path5 = command.path.replace(/:([A-Za-z0-9_]+)/g, (_match, name) => {
5452
5622
  const value = params[name];
5453
5623
  if (value === void 0) {
5454
5624
  throw new Error(`Missing path parameter "${name}" (pass --param ${name}=<value>).`);
5455
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
+ }
5456
5634
  return encodeURIComponent(value);
5457
5635
  });
5458
5636
  const search = new URLSearchParams(query).toString();
@@ -5473,9 +5651,6 @@ function isSchema(value) {
5473
5651
  return value !== null && typeof value === "object" && !Array.isArray(value);
5474
5652
  }
5475
5653
  var MAX_SCHEMA_DEPTH = 12;
5476
- function plain(value) {
5477
- return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, "?");
5478
- }
5479
5654
  function typeName(schema) {
5480
5655
  if (Array.isArray(schema.enum)) {
5481
5656
  return schema.enum.every((v) => typeof v === "string") ? "string" : "value";
@@ -5561,6 +5736,13 @@ function renderSection(label, flag, rows) {
5561
5736
  function renderCommandUsage(command) {
5562
5737
  const lines = [`${plain(command.name)} ${command.method} ${plain(command.path)}`, ""];
5563
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
+ }
5564
5746
  for (const section of SECTIONS) {
5565
5747
  const schema = command.input[section.key];
5566
5748
  if (!isSchema(schema)) {
@@ -6023,18 +6205,68 @@ function inputSummary(command) {
6023
6205
  }
6024
6206
  return sections.length > 0 ? sections.join("+") : "-";
6025
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
+ }
6026
6240
  async function listCommands(options) {
6027
6241
  const appUrl = resolveAppUrl(options);
6028
6242
  const token = await resolveToken(options, appUrl);
6029
6243
  const manifest = await fetchOpsManifest(appUrl, token);
6030
- if (manifest.commands.length === 0) {
6031
- console.log(chalk36.yellow("The app exposes no ops commands."));
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."));
6032
6258
  return;
6033
6259
  }
6034
6260
  console.log(chalk36.bold(`Ops commands at ${appUrl}:
6035
6261
  `));
6036
- for (const command of manifest.commands) {
6037
- console.log(` ${chalk36.cyan(plain(command.name))} ${chalk36.gray(`${command.method} ${plain(command.path)}`)} input: ${inputSummary(command)}`);
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
+ }
6038
6270
  }
6039
6271
  console.log(chalk36.gray(`
6040
6272
  \u{1F4A1} Invoke: spfn ops call <name> [--param k=v] [--query k=v] [--data '{"..."}']`));
@@ -6054,6 +6286,12 @@ async function callCommand(name, options) {
6054
6286
  console.log(options.json ? JSON.stringify(command, null, 2) : renderCommandUsage(command));
6055
6287
  return;
6056
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
+ }
6057
6295
  let body;
6058
6296
  if (options.data !== void 0) {
6059
6297
  if (command.method === "GET") {
@@ -6082,9 +6320,16 @@ async function callCommand(name, options) {
6082
6320
  console.error(rendered);
6083
6321
  process.exit(1);
6084
6322
  }
6323
+ function destructiveConfirmationRequired(command, confirmed) {
6324
+ if (confirmed === true) {
6325
+ return false;
6326
+ }
6327
+ return command.effect === "destructive" || command.metadataRejected === true;
6328
+ }
6085
6329
  var opsCommand = new Command15("ops").description("Invoke the app's ops surface (discovered from /_ops/_manifest)");
6086
- 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)").action(listCommands);
6087
- 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").action(callCommand).addHelpText("after", "\nA command's own inputs: spfn ops call <name> --describe");
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");
6088
6333
  opsCommand.addCommand(buildTokenCommand());
6089
6334
 
6090
6335
  // src/commands/secret/index.ts
@@ -6669,9 +6914,1002 @@ secretCommand.command("keygen").description("Generate an age key pair for SOPS (
6669
6914
  secretCommand.command("recipients <action> [key]").description("Manage .sops.yaml age recipients: add | remove | list").action(secretRecipients);
6670
6915
  secretCommand.command("check").description("Static hygiene lint \u2014 flag plaintext secret leaks").option(...PKG_OPTION).action(secretCheck);
6671
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
+
6672
7910
  // src/index.ts
6673
7911
  init_version();
6674
- var program = new Command17();
7912
+ var program = new Command18();
6675
7913
  program.name("spfn").description("SPFN CLI - The Missing Backend for Next.js").version(getCliVersion());
6676
7914
  program.addCommand(createCommand);
6677
7915
  program.addCommand(initCommand);
@@ -6688,6 +7926,7 @@ program.addCommand(dbCommand);
6688
7926
  program.addCommand(envCommand);
6689
7927
  program.addCommand(opsCommand);
6690
7928
  program.addCommand(secretCommand);
7929
+ program.addCommand(cloudCommand);
6691
7930
  async function run() {
6692
7931
  await program.parseAsync(process.argv);
6693
7932
  }