spfn 0.3.0-beta.15 → 0.3.0-beta.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,7 +24,7 @@ pnpm dlx spfn@beta <command>
24
24
  Or add it as a project dependency (`spfn init`/`spfn create` do this for you), then
25
25
  call it via `pnpm spfn <command>` / `npm run spfn:<script>`.
26
26
 
27
- Requirements: Node.js 20+ in both modes, Next.js 16.3.3+ (App Router, `src/` dir),
27
+ Requirements: Node.js 20+ in both modes, Next.js 16.3.8+ (App Router, `src/` dir),
28
28
  PostgreSQL 14+ (Redis optional). Next.js 15
29
29
  is not supported — see [the root README](../../README.md#what-do-i-need-installed).
30
30
 
@@ -635,6 +635,12 @@ spfn ops list --module ledger # just that module's comma
635
635
  spfn ops call ledger.compact --yes # effect=destructive needs this
636
636
  ```
637
637
 
638
+ An app's own command can carry an effect and a summary too, declared on its route with
639
+ `.effect()` and `.summary()`; `spfn ops list` and `--describe` show them, and an app command
640
+ announcing `read` or `write` needs no `--yes`. A CLI at 0.3.0-beta.15 or older does not
641
+ know app commands carry them: it warns that such a command "names no usable module" and
642
+ asks for `--yes` on it, so upgrade the CLI together with `@spfn/core`.
643
+
638
644
  `spfn ops call` refuses a command the app declared `effect: destructive` unless `--yes` is
639
645
  given. It refuses the same way when the app announced module metadata this CLI could not
640
646
  validate: the effect is then unknown rather than absent, and an unknown effect is not
@@ -707,8 +713,10 @@ const answer = await client.call('pageAdd', { params: {}, query: {}, body: { tit
707
713
  // answer: { status, body } — the app's answer, relayed
708
714
  ```
709
715
 
710
- It keeps every check `spfn ops` makes: the manifest's shape, each command's path staying
711
- under `<namespace>/`, module metadata, and path parameters that would decode into a dot
716
+ Each command carries the `effect` and `summary` the app declared, so your code can tell a
717
+ read from a write before it calls; `call()` itself makes no decision on them. It keeps
718
+ every check `spfn ops` makes: the manifest's shape, each command's path staying under
719
+ `<namespace>/`, module and command metadata, and path parameters that would decode into a dot
712
720
  segment. `appUrl` must be https (`http` only against a loopback host) — refused when the
713
721
  client is created, with a thrown `Error`. A manifest request that does not answer 200
714
722
  throws a `SurfaceManifestError` carrying the `status`. The library prints nothing and does
package/dist/index.js CHANGED
@@ -949,7 +949,7 @@ import { dirname as dirname2, join as join10 } from "path";
949
949
  import { fileURLToPath as fileURLToPath2 } from "url";
950
950
  function getCliVersion() {
951
951
  if (true) {
952
- return "0.3.0-beta.15";
952
+ return "0.3.0-beta.17";
953
953
  }
954
954
  return fromPackageJson ?? (fromPackageJson = readPackageVersion());
955
955
  }
@@ -1006,7 +1006,7 @@ async function setupPackageJson(cwd, packageJsonPath, packageJson, packageManage
1006
1006
  packageJson.dependencies["postgres"] = "^3.4.0";
1007
1007
  packageJson.dependencies["pg"] = "^8.16.3";
1008
1008
  packageJson.dependencies["spfn"] = spfnTag;
1009
- packageJson.dependencies["concurrently"] = "^9.2.1";
1009
+ packageJson.dependencies["concurrently"] = "^9.2.5";
1010
1010
  packageJson.engines = packageJson.engines || {};
1011
1011
  const existingNodeRange = packageJson.engines.node;
1012
1012
  if (!existingNodeRange || !requiresNode20OrNewer(existingNodeRange)) {
@@ -6522,10 +6522,7 @@ function renderCommandUsage(command, invoke) {
6522
6522
  if (command.summary) {
6523
6523
  lines.push(` ${plain(command.summary)}`, "");
6524
6524
  }
6525
- if (command.effect || command.scopes) {
6526
- lines.push(` Effect: ${command.effect ?? "unknown"}`);
6527
- lines.push(` Scopes: ${command.scopes?.map(plain).join(", ") || "(not declared)"}`, "");
6528
- }
6525
+ lines.push(...metadataLines(command));
6529
6526
  for (const section of SECTIONS) {
6530
6527
  const schema = command.input[section.key];
6531
6528
  if (!isSchema(schema)) {
@@ -6546,6 +6543,16 @@ function renderCommandUsage(command, invoke) {
6546
6543
  }
6547
6544
  return lines.join("\n").replace(/\n+$/, "");
6548
6545
  }
6546
+ function metadataLines(command) {
6547
+ if (!command.effect && !command.scopes) {
6548
+ return [];
6549
+ }
6550
+ const lines = [` Effect: ${command.effect ?? "unknown"}`];
6551
+ if (command.module !== void 0 || command.scopes !== void 0) {
6552
+ lines.push(` Scopes: ${command.scopes?.map(plain).join(", ") || "(not declared)"}`);
6553
+ }
6554
+ return [...lines, ""];
6555
+ }
6549
6556
  function exampleFlags(command) {
6550
6557
  const parts = [];
6551
6558
  for (const section of SECTIONS) {
@@ -6611,34 +6618,66 @@ function usableCommands(commands, moduleIds, reader) {
6611
6618
  );
6612
6619
  continue;
6613
6620
  }
6614
- const normalized = {
6615
- name: command.name,
6616
- method: command.method,
6617
- path: command.path,
6618
- input: isPlainObject(command.input) ? command.input : {}
6619
- };
6620
- const metadataReason = unusableModuleMetadataBecause(command, moduleIds, reader);
6621
- if (metadataReason === null && command.module !== void 0) {
6622
- normalized.module = command.module;
6623
- normalized.summary = command.summary;
6624
- normalized.effect = command.effect;
6625
- normalized.scopes = [...command.scopes];
6626
- } else if (metadataReason !== null) {
6627
- normalized.metadataRejected = true;
6628
- reader.warn(
6629
- `Ignoring module metadata for command "${plain(command.name)}": ${metadataReason}. Its effect is unknown, so treat it as destructive.`,
6630
- { kind: "metadata", command: command.name, reason: metadataReason }
6631
- );
6632
- }
6633
- usable.push(normalized);
6621
+ usable.push(withMetadata(command, moduleIds, reader));
6634
6622
  }
6635
6623
  return usable;
6636
6624
  }
6637
- function unusableModuleMetadataBecause(command, moduleIds, reader) {
6638
- const carriesMetadata = command.module !== void 0 || command.summary !== void 0 || command.effect !== void 0 || command.scopes !== void 0;
6639
- if (!carriesMetadata) {
6640
- return null;
6625
+ function withMetadata(command, moduleIds, reader) {
6626
+ const normalized = {
6627
+ name: command.name,
6628
+ method: command.method,
6629
+ path: command.path,
6630
+ input: isPlainObject(command.input) ? command.input : {}
6631
+ };
6632
+ if (command.module !== void 0 || command.scopes !== void 0) {
6633
+ return withModuleMetadata(normalized, command, moduleIds, reader);
6634
+ }
6635
+ return withAppMetadata(normalized, command, reader);
6636
+ }
6637
+ function withModuleMetadata(normalized, command, moduleIds, reader) {
6638
+ const reason = unusableModuleMetadataBecause(command, moduleIds, reader);
6639
+ if (reason !== null) {
6640
+ return rejectedMetadata(normalized, "module", reason, reader);
6641
+ }
6642
+ return {
6643
+ ...normalized,
6644
+ module: command.module,
6645
+ summary: command.summary,
6646
+ effect: command.effect,
6647
+ scopes: [...command.scopes]
6648
+ };
6649
+ }
6650
+ function withAppMetadata(normalized, command, reader) {
6651
+ const reason = unusableAppMetadataBecause(command);
6652
+ if (reason !== null) {
6653
+ return rejectedMetadata(normalized, "command", reason, reader);
6641
6654
  }
6655
+ return {
6656
+ ...normalized,
6657
+ ...command.summary !== void 0 ? { summary: command.summary } : {},
6658
+ ...command.effect !== void 0 ? { effect: command.effect } : {}
6659
+ };
6660
+ }
6661
+ function rejectedMetadata(normalized, metadata, reason, reader) {
6662
+ reader.warn(
6663
+ `Ignoring ${metadata} metadata for command "${plain(normalized.name)}": ${reason}. Its effect is unknown, so treat it as destructive.`,
6664
+ { kind: "metadata", command: normalized.name, metadata, reason }
6665
+ );
6666
+ return { ...normalized, metadataRejected: true };
6667
+ }
6668
+ function unusableAppMetadataBecause(command) {
6669
+ if (command.summary !== void 0 && !isUsableSummary(command.summary)) {
6670
+ return "its summary is empty or too long";
6671
+ }
6672
+ if (command.effect !== void 0 && !EFFECTS.has(command.effect)) {
6673
+ return "its effect is unknown";
6674
+ }
6675
+ return null;
6676
+ }
6677
+ function isUsableSummary(summary) {
6678
+ return typeof summary === "string" && summary.length > 0 && summary.length <= MAX_SUMMARY_LENGTH;
6679
+ }
6680
+ function unusableModuleMetadataBecause(command, moduleIds, reader) {
6642
6681
  if (typeof command.module !== "string" || !moduleIds.has(command.module)) {
6643
6682
  return "it names no usable module";
6644
6683
  }
@@ -6648,7 +6687,7 @@ function unusableModuleMetadataBecause(command, moduleIds, reader) {
6648
6687
  if (!command.path.startsWith(`${reader.prefix}${command.module}/`)) {
6649
6688
  return "its path is outside the module namespace";
6650
6689
  }
6651
- if (typeof command.summary !== "string" || command.summary.length === 0 || command.summary.length > MAX_SUMMARY_LENGTH) {
6690
+ if (!isUsableSummary(command.summary)) {
6652
6691
  return "its summary is missing or too long";
6653
6692
  }
6654
6693
  if (typeof command.effect !== "string" || !EFFECTS.has(command.effect)) {
@@ -6835,7 +6874,7 @@ function printOpsWarning(_message, warning) {
6835
6874
  break;
6836
6875
  case "metadata":
6837
6876
  console.error(
6838
- `\u26A0\uFE0F Ignoring module metadata for ops command "${plain(warning.command)}": ${warning.reason}. Calling it will require --yes.`
6877
+ `\u26A0\uFE0F Ignoring ${warning.metadata ?? "module"} metadata for ops command "${plain(warning.command)}": ${warning.reason}. Calling it will require --yes.`
6839
6878
  );
6840
6879
  break;
6841
6880
  case "modules":
package/dist/surface.d.ts CHANGED
@@ -57,13 +57,18 @@ interface SurfaceResponse {
57
57
  * terminal.
58
58
  */
59
59
 
60
- /** What the client dropped from a manifest, and why. */
60
+ /**
61
+ * What the client dropped from a manifest, and why. A `metadata` warning says
62
+ * whose rules refused it: a module command's, or an app command's own
63
+ * `effect` and `summary`.
64
+ */
61
65
  type SurfaceWarning = {
62
66
  kind: 'command';
63
67
  reason: string;
64
68
  } | {
65
69
  kind: 'metadata';
66
70
  command: string;
71
+ metadata?: 'module' | 'command';
67
72
  reason: string;
68
73
  } | {
69
74
  kind: 'modules';
package/dist/surface.js CHANGED
@@ -181,10 +181,7 @@ function renderCommandUsage(command, invoke) {
181
181
  if (command.summary) {
182
182
  lines.push(` ${plain(command.summary)}`, "");
183
183
  }
184
- if (command.effect || command.scopes) {
185
- lines.push(` Effect: ${command.effect ?? "unknown"}`);
186
- lines.push(` Scopes: ${command.scopes?.map(plain).join(", ") || "(not declared)"}`, "");
187
- }
184
+ lines.push(...metadataLines(command));
188
185
  for (const section of SECTIONS) {
189
186
  const schema = command.input[section.key];
190
187
  if (!isSchema(schema)) {
@@ -205,6 +202,16 @@ function renderCommandUsage(command, invoke) {
205
202
  }
206
203
  return lines.join("\n").replace(/\n+$/, "");
207
204
  }
205
+ function metadataLines(command) {
206
+ if (!command.effect && !command.scopes) {
207
+ return [];
208
+ }
209
+ const lines = [` Effect: ${command.effect ?? "unknown"}`];
210
+ if (command.module !== void 0 || command.scopes !== void 0) {
211
+ lines.push(` Scopes: ${command.scopes?.map(plain).join(", ") || "(not declared)"}`);
212
+ }
213
+ return [...lines, ""];
214
+ }
208
215
  function exampleFlags(command) {
209
216
  const parts = [];
210
217
  for (const section of SECTIONS) {
@@ -270,34 +277,66 @@ function usableCommands(commands, moduleIds, reader) {
270
277
  );
271
278
  continue;
272
279
  }
273
- const normalized = {
274
- name: command.name,
275
- method: command.method,
276
- path: command.path,
277
- input: isPlainObject(command.input) ? command.input : {}
278
- };
279
- const metadataReason = unusableModuleMetadataBecause(command, moduleIds, reader);
280
- if (metadataReason === null && command.module !== void 0) {
281
- normalized.module = command.module;
282
- normalized.summary = command.summary;
283
- normalized.effect = command.effect;
284
- normalized.scopes = [...command.scopes];
285
- } else if (metadataReason !== null) {
286
- normalized.metadataRejected = true;
287
- reader.warn(
288
- `Ignoring module metadata for command "${plain(command.name)}": ${metadataReason}. Its effect is unknown, so treat it as destructive.`,
289
- { kind: "metadata", command: command.name, reason: metadataReason }
290
- );
291
- }
292
- usable.push(normalized);
280
+ usable.push(withMetadata(command, moduleIds, reader));
293
281
  }
294
282
  return usable;
295
283
  }
296
- function unusableModuleMetadataBecause(command, moduleIds, reader) {
297
- const carriesMetadata = command.module !== void 0 || command.summary !== void 0 || command.effect !== void 0 || command.scopes !== void 0;
298
- if (!carriesMetadata) {
299
- return null;
284
+ function withMetadata(command, moduleIds, reader) {
285
+ const normalized = {
286
+ name: command.name,
287
+ method: command.method,
288
+ path: command.path,
289
+ input: isPlainObject(command.input) ? command.input : {}
290
+ };
291
+ if (command.module !== void 0 || command.scopes !== void 0) {
292
+ return withModuleMetadata(normalized, command, moduleIds, reader);
293
+ }
294
+ return withAppMetadata(normalized, command, reader);
295
+ }
296
+ function withModuleMetadata(normalized, command, moduleIds, reader) {
297
+ const reason = unusableModuleMetadataBecause(command, moduleIds, reader);
298
+ if (reason !== null) {
299
+ return rejectedMetadata(normalized, "module", reason, reader);
300
+ }
301
+ return {
302
+ ...normalized,
303
+ module: command.module,
304
+ summary: command.summary,
305
+ effect: command.effect,
306
+ scopes: [...command.scopes]
307
+ };
308
+ }
309
+ function withAppMetadata(normalized, command, reader) {
310
+ const reason = unusableAppMetadataBecause(command);
311
+ if (reason !== null) {
312
+ return rejectedMetadata(normalized, "command", reason, reader);
313
+ }
314
+ return {
315
+ ...normalized,
316
+ ...command.summary !== void 0 ? { summary: command.summary } : {},
317
+ ...command.effect !== void 0 ? { effect: command.effect } : {}
318
+ };
319
+ }
320
+ function rejectedMetadata(normalized, metadata, reason, reader) {
321
+ reader.warn(
322
+ `Ignoring ${metadata} metadata for command "${plain(normalized.name)}": ${reason}. Its effect is unknown, so treat it as destructive.`,
323
+ { kind: "metadata", command: normalized.name, metadata, reason }
324
+ );
325
+ return { ...normalized, metadataRejected: true };
326
+ }
327
+ function unusableAppMetadataBecause(command) {
328
+ if (command.summary !== void 0 && !isUsableSummary(command.summary)) {
329
+ return "its summary is empty or too long";
330
+ }
331
+ if (command.effect !== void 0 && !EFFECTS.has(command.effect)) {
332
+ return "its effect is unknown";
300
333
  }
334
+ return null;
335
+ }
336
+ function isUsableSummary(summary) {
337
+ return typeof summary === "string" && summary.length > 0 && summary.length <= MAX_SUMMARY_LENGTH;
338
+ }
339
+ function unusableModuleMetadataBecause(command, moduleIds, reader) {
301
340
  if (typeof command.module !== "string" || !moduleIds.has(command.module)) {
302
341
  return "it names no usable module";
303
342
  }
@@ -307,7 +346,7 @@ function unusableModuleMetadataBecause(command, moduleIds, reader) {
307
346
  if (!command.path.startsWith(`${reader.prefix}${command.module}/`)) {
308
347
  return "its path is outside the module namespace";
309
348
  }
310
- if (typeof command.summary !== "string" || command.summary.length === 0 || command.summary.length > MAX_SUMMARY_LENGTH) {
349
+ if (!isUsableSummary(command.summary)) {
311
350
  return "its summary is missing or too long";
312
351
  }
313
352
  if (typeof command.effect !== "string" || !EFFECTS.has(command.effect)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spfn",
3
- "version": "0.3.0-beta.15",
3
+ "version": "0.3.0-beta.17",
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": {
@@ -104,7 +104,7 @@
104
104
  "@types/node": "^20.11.0",
105
105
  "@types/pg": "^8.10.9",
106
106
  "@types/prompts": "^2.4.9",
107
- "concurrently": "^9.2.1",
107
+ "concurrently": "^9.2.5",
108
108
  "hono": "^4.13.5",
109
109
  "tsx": "^4.20.6",
110
110
  "typescript": "^5.3.3",