spfn 0.3.0-beta.1 → 0.3.0-beta.2

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 +12 -2
  2. package/dist/index.js +119 -38
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -411,8 +411,14 @@ listSignups GET /_ops/signups
411
411
  Add `--json` for the raw JSON Schema. The server still validates every call — `--describe`
412
412
  reports what it will accept, and the app's answer decides.
413
413
 
414
- The app URL comes from `--app` or `SPFN_OPS_APP`. The ops token resolves `--token`
415
- `SPFN_OPS_TOKEN` macOS keychain, and its lifecycle is managed with:
414
+ The app URL comes from `--app` or `SPFN_OPS_APP`, and it must be **https** — every one of
415
+ these commands carries a secret, and `token issue` carries an administrator's password.
416
+ `http` is accepted only against `localhost`, `127.0.0.1` and `::1`, where there is no
417
+ network to listen on. A URL with a base path (`https://example.com/api`) is kept whole:
418
+ both the ops calls and the administrator sign-in go through it.
419
+
420
+ The ops token resolves `--token` → `SPFN_OPS_TOKEN` → macOS keychain, and its lifecycle is
421
+ managed with:
416
422
 
417
423
  ```bash
418
424
  spfn ops token issue --name laptop --scopes 'waitlist:read' --app <url>
@@ -442,6 +448,10 @@ point, which that release added. The CLI does not depend on the package in any f
442
448
  loads it from the app at run time, and tells a missing package apart from one too old to
443
449
  carry the entry point, so the message names the thing to do.
444
450
 
451
+ Because the package is the app's, it is resolved **from the directory the command runs in**.
452
+ Run `spfn ops token` from the app's root; running it elsewhere reports the package as
453
+ missing, and the message names the directory it looked in.
454
+
445
455
  ---
446
456
 
447
457
  ## Scaffold structure
package/dist/index.js CHANGED
@@ -922,7 +922,7 @@ var init_deployment_config = __esm({
922
922
 
923
923
  // src/utils/version.ts
924
924
  function getCliVersion() {
925
- return "0.3.0-beta.1";
925
+ return "0.3.0-beta.2";
926
926
  }
927
927
  function getTagFromVersion(version) {
928
928
  const match = version.match(/-([a-z]+)\./i);
@@ -5380,7 +5380,39 @@ async function fetchOpsManifest(appUrl, token) {
5380
5380
  if (manifest?.manifestVersion !== 1 || !Array.isArray(manifest.commands)) {
5381
5381
  throw new Error("The manifest answer has an unknown shape.");
5382
5382
  }
5383
- return manifest;
5383
+ return { manifestVersion: 1, commands: usableCommands(manifest.commands) };
5384
+ }
5385
+ var OPS_METHODS = /* @__PURE__ */ new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
5386
+ var OPS_PATH_PREFIX = "/_ops/";
5387
+ function unusableBecause(command) {
5388
+ if (typeof command?.name !== "string" || command.name.length === 0) {
5389
+ return "it has no name";
5390
+ }
5391
+ if (typeof command.method !== "string" || !OPS_METHODS.has(command.method)) {
5392
+ return `its method is ${JSON.stringify(command.method)}`;
5393
+ }
5394
+ if (typeof command.path !== "string" || !command.path.startsWith(OPS_PATH_PREFIX)) {
5395
+ return `its path ${JSON.stringify(command.path)} is outside ${OPS_PATH_PREFIX}`;
5396
+ }
5397
+ if (command.path.split("/").includes("..")) {
5398
+ return `its path ${JSON.stringify(command.path)} climbs out of the ops namespace`;
5399
+ }
5400
+ return null;
5401
+ }
5402
+ function usableCommands(commands) {
5403
+ const usable = [];
5404
+ for (const command of commands) {
5405
+ const reason = unusableBecause(command);
5406
+ if (reason !== null) {
5407
+ console.error(`\u26A0\uFE0F Ignoring an ops command the manifest announced: ${reason}.`);
5408
+ continue;
5409
+ }
5410
+ usable.push({ ...command, input: isPlainObject(command.input) ? command.input : {} });
5411
+ }
5412
+ return usable;
5413
+ }
5414
+ function isPlainObject(value) {
5415
+ return value !== null && typeof value === "object" && !Array.isArray(value);
5384
5416
  }
5385
5417
  function buildCommandPath(command, params, query) {
5386
5418
  const path5 = command.path.replace(/:([A-Za-z0-9_]+)/g, (_match, name) => {
@@ -5407,6 +5439,10 @@ var SECTIONS = [
5407
5439
  function isSchema(value) {
5408
5440
  return value !== null && typeof value === "object" && !Array.isArray(value);
5409
5441
  }
5442
+ var MAX_SCHEMA_DEPTH = 12;
5443
+ function plain(value) {
5444
+ return value.replace(/[\u0000-\u001f\u007f-\u009f]/g, "?");
5445
+ }
5410
5446
  function typeName(schema) {
5411
5447
  if (Array.isArray(schema.enum)) {
5412
5448
  return schema.enum.every((v) => typeof v === "string") ? "string" : "value";
@@ -5453,7 +5489,7 @@ function constraintNotes(schema) {
5453
5489
  }
5454
5490
  return notes;
5455
5491
  }
5456
- function collectFields(schema, prefix = "") {
5492
+ function collectFields(schema, prefix = "", depth = 0) {
5457
5493
  const properties = isSchema(schema.properties) ? schema.properties : {};
5458
5494
  const required = Array.isArray(schema.required) ? schema.required : [];
5459
5495
  const rows = [];
@@ -5461,17 +5497,17 @@ function collectFields(schema, prefix = "") {
5461
5497
  if (!isSchema(raw)) {
5462
5498
  continue;
5463
5499
  }
5464
- const path5 = prefix ? `${prefix}.${name}` : name;
5465
- const nested = isSchema(raw.properties) ? collectFields(raw, path5) : [];
5500
+ const path5 = prefix ? `${prefix}.${plain(name)}` : plain(name);
5501
+ const nested = isSchema(raw.properties) && depth < MAX_SCHEMA_DEPTH ? collectFields(raw, path5, depth + 1) : [];
5466
5502
  if (nested.length > 0) {
5467
5503
  rows.push(...nested);
5468
5504
  continue;
5469
5505
  }
5470
5506
  rows.push({
5471
5507
  name: path5,
5472
- type: typeName(raw),
5508
+ type: plain(typeName(raw)),
5473
5509
  requirement: required.includes(name) ? "required" : "optional",
5474
- notes: constraintNotes(raw).join(", ")
5510
+ notes: plain(constraintNotes(raw).join(", "))
5475
5511
  });
5476
5512
  }
5477
5513
  return rows;
@@ -5490,7 +5526,7 @@ function renderSection(label, flag, rows) {
5490
5526
  return lines;
5491
5527
  }
5492
5528
  function renderCommandUsage(command) {
5493
- const lines = [`${command.name} ${command.method} ${command.path}`, ""];
5529
+ const lines = [`${plain(command.name)} ${command.method} ${plain(command.path)}`, ""];
5494
5530
  let described = false;
5495
5531
  for (const section of SECTIONS) {
5496
5532
  const schema = command.input[section.key];
@@ -5507,7 +5543,7 @@ function renderCommandUsage(command) {
5507
5543
  if (!described) {
5508
5544
  lines.push(" Takes no input.", "");
5509
5545
  }
5510
- lines.push(` Invoke: spfn ops call ${command.name}${exampleFlags(command)}`);
5546
+ lines.push(` Invoke: spfn ops call ${plain(command.name)}${exampleFlags(command)}`);
5511
5547
  return lines.join("\n");
5512
5548
  }
5513
5549
  function exampleFlags(command) {
@@ -5598,18 +5634,37 @@ function deleteOpsToken(account) {
5598
5634
  }
5599
5635
 
5600
5636
  // src/commands/ops/resolve.ts
5637
+ function isLoopback(hostname) {
5638
+ return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname.endsWith(".localhost");
5639
+ }
5640
+ function assertTransportSafe(parsed, appUrl) {
5641
+ if (parsed.protocol === "https:" || parsed.protocol === "http:" && isLoopback(parsed.hostname)) {
5642
+ return;
5643
+ }
5644
+ if (parsed.protocol === "http:") {
5645
+ console.error(chalk33.red(`\u274C Refusing to talk to ${appUrl} over http.`));
5646
+ console.error(chalk33.gray(" An administrator password and an ops token cross this connection, and http sends both in the clear."));
5647
+ console.error(chalk33.gray(" Use https. http is allowed only against localhost."));
5648
+ process.exit(1);
5649
+ }
5650
+ console.error(chalk33.red(`\u274C App URL must be https, got "${parsed.protocol}//".`));
5651
+ console.error(chalk33.gray(" The ops surface is reached over HTTP(S) \u2014 http is allowed only against localhost."));
5652
+ process.exit(1);
5653
+ }
5601
5654
  function resolveAppUrl(options) {
5602
5655
  const appUrl = options.app ?? process.env.SPFN_OPS_APP;
5603
5656
  if (!appUrl) {
5604
5657
  console.error(chalk33.red("\u274C No app URL. Pass --app <url> or set SPFN_OPS_APP."));
5605
5658
  process.exit(1);
5606
5659
  }
5660
+ let parsed;
5607
5661
  try {
5608
- new URL(appUrl);
5662
+ parsed = new URL(appUrl);
5609
5663
  } catch {
5610
5664
  console.error(chalk33.red(`\u274C Invalid app URL: ${appUrl}`));
5611
5665
  process.exit(1);
5612
5666
  }
5667
+ assertTransportSafe(parsed, appUrl);
5613
5668
  return appUrl;
5614
5669
  }
5615
5670
  function appAccount(appUrl) {
@@ -5623,7 +5678,13 @@ async function resolveToken(options, appUrl) {
5623
5678
  return process.env.SPFN_OPS_TOKEN;
5624
5679
  }
5625
5680
  if (keychainSupported()) {
5626
- const stored = await loadOpsToken(appAccount(appUrl));
5681
+ const stored = await loadOpsToken(appAccount(appUrl)).catch((err) => {
5682
+ console.error(chalk33.yellow(
5683
+ `\u26A0\uFE0F Could not read the keychain (${err instanceof Error ? err.message : String(err)}).`
5684
+ ));
5685
+ console.error(chalk33.gray(" Unlock it, or pass --token / set SPFN_OPS_TOKEN for this run."));
5686
+ return null;
5687
+ });
5627
5688
  if (stored) {
5628
5689
  return stored;
5629
5690
  }
@@ -5654,16 +5715,26 @@ import prompts9 from "prompts";
5654
5715
  import chalk34 from "chalk";
5655
5716
 
5656
5717
  // src/utils/ops/auth-crypto.ts
5718
+ import { createRequire } from "module";
5719
+ import { join as join25 } from "path";
5720
+ import { pathToFileURL as pathToFileURL3 } from "url";
5657
5721
  var CRYPTO_ENTRY_SINCE = "0.3.0-beta.2";
5658
5722
  var CRYPTO_ENTRY = "@spfn/auth/crypto";
5723
+ async function importFromApp(specifier) {
5724
+ const requireFromApp = createRequire(join25(process.cwd(), "noop.js"));
5725
+ return await import(pathToFileURL3(requireFromApp.resolve(specifier)).href);
5726
+ }
5659
5727
  async function loadAuthCrypto() {
5660
5728
  try {
5661
- return await import(CRYPTO_ENTRY);
5729
+ return await importFromApp(CRYPTO_ENTRY);
5662
5730
  } catch (err) {
5663
5731
  const code = err.code;
5664
5732
  if (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") {
5665
5733
  throw new Error(
5666
- "This project does not have @spfn/auth installed, and ops tokens live in its schema \u2014 so this app has none to issue, list or revoke.\n An app that uses the ops surface installs @spfn/auth for opsTokenAuth; add it there.\n Invoking commands with a token you already hold (spfn ops list / call) does not need it."
5734
+ `No @spfn/auth is installed in ${process.cwd()}, and ops tokens live in its schema \u2014 so this app has none to issue, list or revoke.
5735
+ An app that uses the ops surface installs @spfn/auth for opsTokenAuth; add it there.
5736
+ Run this from the app directory \u2014 the package is resolved from where the command runs.
5737
+ Invoking commands with a token you already hold (spfn ops list / call) does not need it.`
5667
5738
  );
5668
5739
  }
5669
5740
  if (code === "ERR_PACKAGE_PATH_NOT_EXPORTED") {
@@ -5677,8 +5748,18 @@ async function loadAuthCrypto() {
5677
5748
  }
5678
5749
 
5679
5750
  // src/utils/ops/admin-session.ts
5751
+ function parseJsonAnswer(text, status, statusText) {
5752
+ if (!text) {
5753
+ return {};
5754
+ }
5755
+ try {
5756
+ return JSON.parse(text);
5757
+ } catch {
5758
+ throw new Error(`The app answered ${status} ${statusText} with something that is not JSON.`);
5759
+ }
5760
+ }
5680
5761
  async function postJson(appUrl, path5, body, authorization) {
5681
- const response = await fetch(new URL(path5, appUrl), {
5762
+ const response = await fetch(joinUrl(appUrl, path5), {
5682
5763
  method: "POST",
5683
5764
  headers: {
5684
5765
  "Content-Type": "application/json",
@@ -5687,7 +5768,7 @@ async function postJson(appUrl, path5, body, authorization) {
5687
5768
  body: JSON.stringify(body)
5688
5769
  });
5689
5770
  const text = await response.text();
5690
- const parsed = text ? JSON.parse(text) : {};
5771
+ const parsed = parseJsonAnswer(text, response.status, response.statusText);
5691
5772
  if (!response.ok) {
5692
5773
  throw new Error(parsed.message ?? parsed.error ?? `${response.status} ${response.statusText}`);
5693
5774
  }
@@ -5752,7 +5833,7 @@ async function withAdminSession(appUrl, run2) {
5752
5833
  return result;
5753
5834
  }
5754
5835
  async function adminRequest(appUrl, method, path5, session, body) {
5755
- const response = await fetch(new URL(path5, appUrl), {
5836
+ const response = await fetch(joinUrl(appUrl, path5), {
5756
5837
  method,
5757
5838
  headers: {
5758
5839
  Authorization: session.authorization,
@@ -5761,7 +5842,7 @@ async function adminRequest(appUrl, method, path5, session, body) {
5761
5842
  ...body === void 0 ? {} : { body: JSON.stringify(body) }
5762
5843
  });
5763
5844
  const text = await response.text();
5764
- const parsed = text ? JSON.parse(text) : {};
5845
+ const parsed = parseJsonAnswer(text, response.status, response.statusText);
5765
5846
  if (!response.ok) {
5766
5847
  throw new Error(parsed.message ?? parsed.error ?? `${response.status} ${response.statusText}`);
5767
5848
  }
@@ -5920,7 +6001,7 @@ async function listCommands(options) {
5920
6001
  console.log(chalk36.bold(`Ops commands at ${appUrl}:
5921
6002
  `));
5922
6003
  for (const command of manifest.commands) {
5923
- console.log(` ${chalk36.cyan(command.name)} ${chalk36.gray(`${command.method} ${command.path}`)} input: ${inputSummary(command)}`);
6004
+ console.log(` ${chalk36.cyan(plain(command.name))} ${chalk36.gray(`${command.method} ${plain(command.path)}`)} input: ${inputSummary(command)}`);
5924
6005
  }
5925
6006
  console.log(chalk36.gray(`
5926
6007
  \u{1F4A1} Invoke: spfn ops call <name> [--param k=v] [--query k=v] [--data '{"..."}']`));
@@ -5932,8 +6013,8 @@ async function callCommand(name, options) {
5932
6013
  const manifest = await fetchOpsManifest(appUrl, token);
5933
6014
  const command = manifest.commands.find((c) => c.name === name);
5934
6015
  if (!command) {
5935
- console.error(chalk36.red(`\u274C Unknown ops command "${name}".`));
5936
- console.error(chalk36.gray(` Known: ${manifest.commands.map((c) => c.name).join(", ") || "(none)"}`));
6016
+ console.error(chalk36.red(`\u274C Unknown ops command "${plain(name)}".`));
6017
+ console.error(chalk36.gray(` Known: ${manifest.commands.map((c) => plain(c.name)).join(", ") || "(none)"}`));
5937
6018
  process.exit(1);
5938
6019
  }
5939
6020
  if (options.describe) {
@@ -5943,7 +6024,7 @@ async function callCommand(name, options) {
5943
6024
  let body;
5944
6025
  if (options.data !== void 0) {
5945
6026
  if (command.method === "GET") {
5946
- console.error(chalk36.red(`\u274C "${name}" is a ${command.method} command and takes no request body.`));
6027
+ console.error(chalk36.red(`\u274C "${plain(name)}" is a ${command.method} command and takes no request body.`));
5947
6028
  console.error(chalk36.gray(" Pass its input with --query k=v (or --param k=v for path segments)."));
5948
6029
  process.exit(1);
5949
6030
  }
@@ -5964,7 +6045,7 @@ async function callCommand(name, options) {
5964
6045
  console.log(rendered);
5965
6046
  return;
5966
6047
  }
5967
- console.error(chalk36.red(`\u274C ${command.method} ${command.path} answered ${response.status}`));
6048
+ console.error(chalk36.red(`\u274C ${command.method} ${plain(command.path)} answered ${response.status}`));
5968
6049
  console.error(rendered);
5969
6050
  process.exit(1);
5970
6051
  }
@@ -5994,7 +6075,7 @@ function resolveEnv(env9) {
5994
6075
 
5995
6076
  // src/commands/secret/store-value.ts
5996
6077
  init_logger();
5997
- import { join as join26 } from "path";
6078
+ import { join as join27 } from "path";
5998
6079
  init_env_file();
5999
6080
 
6000
6081
  // src/utils/sops.ts
@@ -6043,19 +6124,19 @@ async function sopsUpdateKeys(absFile) {
6043
6124
 
6044
6125
  // src/utils/secret-config.ts
6045
6126
  import { existsSync as existsSync28, readdirSync as readdirSync2 } from "fs";
6046
- import { join as join25 } from "path";
6127
+ import { join as join26 } from "path";
6047
6128
  var SECRETS_DIR = "secrets";
6048
6129
  function getSopsFile(cwd, env9) {
6049
6130
  const relFile = `${SECRETS_DIR}/${env9}.enc.json`;
6050
- return { absFile: join25(cwd, relFile), relFile };
6131
+ return { absFile: join26(cwd, relFile), relFile };
6051
6132
  }
6052
6133
  function findUp(cwd, filename, maxDepth = 6) {
6053
6134
  let dir = cwd;
6054
6135
  for (let depth = 0; depth < maxDepth; depth++) {
6055
- if (existsSync28(join25(dir, filename))) {
6136
+ if (existsSync28(join26(dir, filename))) {
6056
6137
  return dir;
6057
6138
  }
6058
- const parent = join25(dir, "..");
6139
+ const parent = join26(dir, "..");
6059
6140
  if (parent === dir) {
6060
6141
  break;
6061
6142
  }
@@ -6065,17 +6146,17 @@ function findUp(cwd, filename, maxDepth = 6) {
6065
6146
  }
6066
6147
  function findSopsConfig(cwd) {
6067
6148
  const dir = findUp(cwd, ".sops.yaml");
6068
- return dir ? join25(dir, ".sops.yaml") : null;
6149
+ return dir ? join26(dir, ".sops.yaml") : null;
6069
6150
  }
6070
6151
  function hasSopsConfig(cwd) {
6071
6152
  return findSopsConfig(cwd) !== null;
6072
6153
  }
6073
6154
  function listSopsFiles(cwd) {
6074
- const dir = join25(cwd, SECRETS_DIR);
6155
+ const dir = join26(cwd, SECRETS_DIR);
6075
6156
  if (!existsSync28(dir)) {
6076
6157
  return [];
6077
6158
  }
6078
- return readdirSync2(dir).filter((name) => name.endsWith(".enc.json")).map((name) => join25(dir, name));
6159
+ return readdirSync2(dir).filter((name) => name.endsWith(".enc.json")).map((name) => join26(dir, name));
6079
6160
  }
6080
6161
 
6081
6162
  // src/commands/secret/store-value.ts
@@ -6104,7 +6185,7 @@ async function storeSecret(cwd, env9, key, value) {
6104
6185
  );
6105
6186
  }
6106
6187
  await store.set(keychainName(key), value);
6107
- const serverEnvPath = join26(cwd, ".env.server");
6188
+ const serverEnvPath = join27(cwd, ".env.server");
6108
6189
  const result = upsertEnvVar(serverEnvPath, key, keychainRef(key));
6109
6190
  restrictEnvFilePerms(serverEnvPath);
6110
6191
  ensureGitignored(cwd, [{ pattern: ".env.server", comment: "spfn server env (secrets)" }]);
@@ -6344,10 +6425,10 @@ init_logger();
6344
6425
  import { execa as execa12 } from "execa";
6345
6426
  import { existsSync as existsSync29, mkdirSync as mkdirSync6 } from "fs";
6346
6427
  import { homedir } from "os";
6347
- import { dirname as dirname5, join as join27 } from "path";
6428
+ import { dirname as dirname5, join as join28 } from "path";
6348
6429
  import chalk41 from "chalk";
6349
6430
  function ageKeyFile() {
6350
- return process.env.SOPS_AGE_KEY_FILE ?? join27(homedir(), ".config", "sops", "age", "keys.txt");
6431
+ return process.env.SOPS_AGE_KEY_FILE ?? join28(homedir(), ".config", "sops", "age", "keys.txt");
6351
6432
  }
6352
6433
  async function ensureAgeInstalled() {
6353
6434
  try {
@@ -6395,7 +6476,7 @@ function printPublicKeys(keys) {
6395
6476
  // src/commands/secret/recipients.ts
6396
6477
  init_logger();
6397
6478
  import { existsSync as existsSync30, readFileSync as readFileSync12, writeFileSync as writeFileSync19 } from "fs";
6398
- import { join as join28 } from "path";
6479
+ import { join as join29 } from "path";
6399
6480
  import { parse as parse3, stringify } from "yaml";
6400
6481
  import chalk42 from "chalk";
6401
6482
  var SOPS_CONFIG = ".sops.yaml";
@@ -6403,7 +6484,7 @@ var DEFAULT_PATH_REGEX = "secrets/.*\\.enc\\.json$";
6403
6484
  var AGE_RECIPIENT = /^age1[0-9a-z]+$/;
6404
6485
  async function secretRecipients(action, key, _options) {
6405
6486
  const cwd = process.cwd();
6406
- const configPath = join28(cwd, SOPS_CONFIG);
6487
+ const configPath = join29(cwd, SOPS_CONFIG);
6407
6488
  if (action === "list") {
6408
6489
  listRecipients(configPath);
6409
6490
  return;
@@ -6488,7 +6569,7 @@ async function reencrypt(cwd) {
6488
6569
 
6489
6570
  // src/commands/secret/check.ts
6490
6571
  init_logger();
6491
- import { join as join29 } from "path";
6572
+ import { join as join30 } from "path";
6492
6573
  import chalk43 from "chalk";
6493
6574
  init_env_file();
6494
6575
  var COMMITTED_FILES = [".env", ".env.example"];
@@ -6507,14 +6588,14 @@ async function secretCheck(options) {
6507
6588
  const issues = [];
6508
6589
  const warnings = [];
6509
6590
  for (const file of COMMITTED_FILES) {
6510
- const parsed = parseEnvFile(join29(cwd, file));
6591
+ const parsed = parseEnvFile(join30(cwd, file));
6511
6592
  for (const [key, value] of Object.entries(parsed)) {
6512
6593
  if (secretKeys.has(key) && value.length > 0 && !PLACEHOLDER.test(value)) {
6513
6594
  issues.push(`${chalk43.cyan(key)} has a real value in committed ${chalk43.yellow(file)} \u2014 move it to the keychain/SOPS.`);
6514
6595
  }
6515
6596
  }
6516
6597
  }
6517
- const serverEnv = parseEnvFile(join29(cwd, ".env.server"));
6598
+ const serverEnv = parseEnvFile(join30(cwd, ".env.server"));
6518
6599
  for (const key of secretKeys) {
6519
6600
  const value = serverEnv[key];
6520
6601
  if (value && !value.startsWith(KEYCHAIN_REF_PREFIX)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spfn",
3
- "version": "0.3.0-beta.1",
3
+ "version": "0.3.0-beta.2",
4
4
  "description": "Scaffold a full-stack TypeScript backend onto a Next.js app built with an AI coding agent: auth, database, typed routes and codegen, one fixed vertical slice per feature",
5
5
  "type": "module",
6
6
  "bin": {