@biffo/cli 0.274.0 → 0.275.1

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.
@@ -0,0 +1,125 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { ApiError, createRequest } from './api-core'
3
+
4
+ /**
5
+ * Guards for the distributed admin-API request core (biffo-template#1492),
6
+ * and in particular for the `onError` mapper added so the marketing plugin
7
+ * could adopt it without losing its endpoint-specific messages.
8
+ */
9
+
10
+ function jsonResponse(status: number, body: unknown): Response {
11
+ return new Response(JSON.stringify(body), {
12
+ status,
13
+ headers: { 'Content-Type': 'application/json' },
14
+ })
15
+ }
16
+
17
+ afterEach(() => {
18
+ vi.unstubAllGlobals()
19
+ })
20
+
21
+ function stubFetch(res: Response) {
22
+ const fetchMock = vi.fn().mockResolvedValue(res)
23
+ vi.stubGlobal('fetch', fetchMock)
24
+ return fetchMock
25
+ }
26
+
27
+ describe('createRequest without a mapper (the default path)', () => {
28
+ it('throws ApiError carrying the status and the response body', async () => {
29
+ stubFetch(new Response('you may not do that', { status: 403 }))
30
+ const request = createRequest(() => 'tok', '/base')
31
+
32
+ await expect(request('GET', '/thing')).rejects.toBeInstanceOf(ApiError)
33
+ })
34
+
35
+ it('falls back to statusText when the body is empty', async () => {
36
+ stubFetch(new Response('', { status: 500, statusText: 'Internal Server Error' }))
37
+ const request = createRequest(() => 'tok', '/base')
38
+
39
+ await expect(request('GET', '/thing')).rejects.toThrow('Internal Server Error')
40
+ })
41
+ })
42
+
43
+ describe('createRequest with an onError mapper', () => {
44
+ /**
45
+ * THE POINT OF THE HOOK. The mapper must be able to read the JSON body,
46
+ * which means `request()` must not have consumed the stream before calling
47
+ * it. A mapper handed an already-read Response sees an empty body and falls
48
+ * back to a generic message — the silent degradation this exists to stop.
49
+ */
50
+ it('hands the mapper a response whose body is still readable', async () => {
51
+ stubFetch(jsonResponse(403, { detail: 'you are not an admin of this brand' }))
52
+ const request = createRequest(
53
+ () => 'tok',
54
+ '/base',
55
+ async (res, context) => {
56
+ const body = (await res.json()) as { detail?: string }
57
+ throw new Error(`${body.detail} (${res.status}) while trying to ${context}`)
58
+ },
59
+ )
60
+
61
+ await expect(request('POST', '/links', {}, '/base', 'mint links')).rejects.toThrow(
62
+ 'you are not an admin of this brand (403) while trying to mint links',
63
+ )
64
+ })
65
+
66
+ it('passes the per-call context through, and undefined when omitted', async () => {
67
+ const seen: (string | undefined)[] = []
68
+ stubFetch(jsonResponse(500, {}))
69
+ const request = createRequest(
70
+ () => 'tok',
71
+ '/base',
72
+ (_res, context) => {
73
+ seen.push(context)
74
+ throw new Error('mapped')
75
+ },
76
+ )
77
+
78
+ await expect(request('GET', '/a', undefined, '/base', 'load campaigns')).rejects.toThrow(
79
+ 'mapped',
80
+ )
81
+ stubFetch(jsonResponse(500, {}))
82
+ await expect(request('GET', '/b')).rejects.toThrow('mapped')
83
+
84
+ expect(seen).toEqual(['load campaigns', undefined])
85
+ })
86
+
87
+ it('never reaches the default ApiError once the mapper has thrown', async () => {
88
+ stubFetch(jsonResponse(403, { detail: 'nope' }))
89
+ const request = createRequest(
90
+ () => 'tok',
91
+ '/base',
92
+ () => {
93
+ throw new Error('mapped')
94
+ },
95
+ )
96
+
97
+ // If the default path still ran, this would be an ApiError instead.
98
+ await expect(request('GET', '/thing')).rejects.not.toBeInstanceOf(ApiError)
99
+ })
100
+
101
+ /**
102
+ * A mapper is typed `=> never`, but types are not enforcement at runtime and
103
+ * this is the dangerous failure: a mapper that returns normally would let
104
+ * `request()` fall through to the default throw. That is recoverable. What
105
+ * must never happen is a failed request RESOLVING — so this pins that even a
106
+ * misbehaving mapper cannot turn a 403 into a successful call.
107
+ */
108
+ it('still throws if a mapper wrongly returns instead of throwing', async () => {
109
+ stubFetch(jsonResponse(403, { detail: 'nope' }))
110
+ const request = createRequest(() => 'tok', '/base', (() => undefined) as never)
111
+
112
+ await expect(request('GET', '/thing')).rejects.toBeInstanceOf(ApiError)
113
+ })
114
+
115
+ it('does not run the mapper on a successful response', async () => {
116
+ stubFetch(jsonResponse(200, { ok: true }))
117
+ const onError = vi.fn(() => {
118
+ throw new Error('should not run')
119
+ })
120
+ const request = createRequest(() => 'tok', '/base', onError as never)
121
+
122
+ await expect(request('GET', '/thing')).resolves.toEqual({ ok: true })
123
+ expect(onError).not.toHaveBeenCalled()
124
+ })
125
+ })
@@ -26,18 +26,48 @@ export class ApiError extends Error {
26
26
 
27
27
  export type GetIdToken = () => string | null | Promise<string | null>
28
28
 
29
+ /**
30
+ * Maps a failed response into the error a plugin wants to surface, INSTEAD of
31
+ * the default `ApiError(status, body)`.
32
+ *
33
+ * It receives the `Response` with its body **unread**, so it can `.json()` the
34
+ * payload — Core returns `{"detail": "..."}` for a permission failure, and a
35
+ * plugin usually wants that sentence rather than a status code. It also
36
+ * receives the per-call `context` string, because the useful wording is
37
+ * endpoint-specific: "you need the admin role to **mint links**" is actionable
38
+ * where "403" is not.
39
+ *
40
+ * Added for biffo-template#1492. The marketing plugin could not adopt this core
41
+ * without it: `createRequest` read the body into a string and threw immediately,
42
+ * leaving no seam to inspect it first, so migrating would have collapsed a dozen
43
+ * hand-written, endpoint-specific messages into one generic status string. That
44
+ * is a behaviour regression, and the plugin correctly refused rather than
45
+ * dropping them — the gap was in this module, not in the plugin.
46
+ *
47
+ * MUST NOT RETURN NORMALLY: it either throws, or returns a rejected promise.
48
+ * The `never` return type says so; a mapper that falls through would make
49
+ * `request()` resolve `undefined` for a failed call, which is the silent-success
50
+ * shape this estate spends most of its time eliminating. `assertThrew` in
51
+ * `api-core.test.ts` holds that line.
52
+ */
53
+ export type ErrorMapper = (
54
+ response: Response,
55
+ context: string | undefined,
56
+ ) => Promise<never> | never
57
+
29
58
  /**
30
59
  * Build a `request<T>(method, path, body?, base?)` function bound to a token
31
60
  * source and a default base. A plugin's own api.ts calls this once per
32
61
  * `createApi()` and defines its endpoints on top of the result — see the
33
62
  * starter `api.ts` in this same directory for the worked shape.
34
63
  */
35
- export function createRequest(getIdToken: GetIdToken, defaultBase: string) {
64
+ export function createRequest(getIdToken: GetIdToken, defaultBase: string, onError?: ErrorMapper) {
36
65
  return async function request<T>(
37
66
  method: string,
38
67
  path: string,
39
68
  body?: unknown,
40
69
  base: string = defaultBase,
70
+ context?: string,
41
71
  ): Promise<T> {
42
72
  const token = await getIdToken()
43
73
  const res = await fetch(`${base}${path}`, {
@@ -49,6 +79,12 @@ export function createRequest(getIdToken: GetIdToken, defaultBase: string) {
49
79
  ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
50
80
  })
51
81
  if (!res.ok) {
82
+ // The mapper runs FIRST, and gets the response with its body still
83
+ // unread. Order is load-bearing: `res.text()` below consumes the stream,
84
+ // and a mapper handed an already-consumed Response cannot call `.json()`
85
+ // — it would silently see an empty body and fall back to a generic
86
+ // message, which is the exact failure this hook exists to prevent.
87
+ if (onError) await onError(res, context)
52
88
  // Read the body for the reason: Core returns a JSON detail for a
53
89
  // permission failure, and "403" alone tells an admin nothing about
54
90
  // which rule bit.
@@ -117,6 +117,42 @@ it deliberately and say so in a comment; never steal a fresh one.
117
117
  - **Squash-merge, delete the branch, remove the worktree:**
118
118
  `gh pr merge <N> --squash --delete-branch`.
119
119
 
120
+ ### If you were dispatched by another agent, you do NOT wait and you do NOT merge
121
+
122
+ Everything above assumes the session doing the work is also the session that
123
+ will merge it. **A delegated subagent is not that session.** If an orchestrator
124
+ spawned you to do one unit of work, your finished state is:
125
+
126
+ > branch pushed → PR opened → **report back immediately**, with the PR number
127
+ > and what you changed.
128
+
129
+ Do **not** run `wait-for-checks`. Do **not** merge. The orchestrator watches CI
130
+ and merges, and sends you back if CI fails on your change.
131
+
132
+ **Why this is a rule and not a preference.** A subagent has no way to sleep. Told
133
+ to wait for CI, it stops, wakes, re-reads the same pending status, and stops
134
+ again — and every wake is a full context reload that reports nothing new. Measured
135
+ on 2026-08-11: one dispatch tranche produced **25+ wake-ups** returning only
136
+ "still waiting"; a single agent burned ~145k tokens re-reading a queued deploy
137
+ before it was killed. The orchestrator does the same wait for nothing extra: it
138
+ is already awake, and one loop covers every PR in the tranche at once instead of
139
+ N agents each paying a context reload for the same minutes.
140
+
141
+ **The instruction is the cause, so the fix lives here.** Both of those incidents
142
+ happened in sessions whose dispatch briefs ended "get CI green, then squash-merge"
143
+ — written by an orchestrator that _had already recorded the lesson_ and then
144
+ followed §5 above, which says exactly that. §5 is right for the session that owns
145
+ the work and wrong for a delegate, and until now nothing said so.
146
+
147
+ **Keep a delegate alive only when it still has a DECISION to make** — a real test
148
+ failure to diagnose, a design question to answer. "Wait, then merge" is not a
149
+ decision.
150
+
151
+ Orchestrators: end every dispatch brief with the finished state above, and reach
152
+ for `gh pr merge --auto` when the merge needs no judgement — then still verify,
153
+ because `--auto` waits for ever on a check that cannot re-evaluate itself (a
154
+ `Release Guards` closing-keyword failure needs a re-run, not a wait).
155
+
120
156
  ## 6. Push honestly, and verify the remote has your commit
121
157
 
122
158
  - **Push with the exit status visible:** `git push origin HEAD; echo $?`. A pipe
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.274.0",
3
+ "version": "0.275.1",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",