kaloko 0.5.2 → 0.6.0

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,211 @@
1
+ // Batch H in the CLI: `kaloko issue create|list` (Jira and Linear, #21) and `kaloko schedule …` (hosted runs, #16):
2
+ // add a read-only scenario to Kaloko's own browsers, list, run, pause, resume and remove schedules, or write a GitHub
3
+ // Actions workflow that walks it on a schedule with the full CLI (`schedule export github`).
4
+ import { readFile, writeFile, mkdir } from 'node:fs/promises'
5
+ import { existsSync } from 'node:fs'
6
+ import { resolve, relative, dirname, join } from 'node:path'
7
+ import { UserError } from './util.mjs'
8
+ import { gateOf, accountFor } from './access.mjs'
9
+ import { secret } from './secrets.mjs'
10
+ import { runner } from './ci.mjs'
11
+
12
+ const RUN_ID = /^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]{3,120}$/
13
+ const WEEKDAYS = { mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6, sun: 7 }
14
+
15
+ // ---------- kaloko issue ----------
16
+
17
+ const issueLine = (i) => `${i.key.padEnd(10)} ${String(i.state).padEnd(8)} step ${i.step}${i.criterion ? ` (${i.criterion})` : ''} · ${i.title}\n ${i.url}`
18
+
19
+ /**
20
+ * `kaloko issue create --step <id> --to jira|linear [--criterion AC2] [--title …] [--note …] [--run id] [--locale l]
21
+ * [--viewport v] [--target KEY] [--json]` and `kaloko issue list [--run id] [--scenario id] [--state open] [--json]`.
22
+ */
23
+ export async function issueCmd ({ args, config, client, runId, say }) {
24
+ const sub = args._[0]
25
+ const org = config.org
26
+ if (!org) throw new UserError('Set org: in the config first.')
27
+ if (sub === 'list') {
28
+ const q = new URLSearchParams(Object.entries({ run: typeof args.run === 'string' ? args.run : null, scenario: typeof args.scenario === 'string' ? args.scenario : null, state: typeof args.state === 'string' ? args.state : null }).filter(([, v]) => v))
29
+ const items = await client.request('GET', `/orgs/${org}/issues${q.size ? `?${q}` : ''}`)
30
+ if (args.json) { say(JSON.stringify(items, null, 2)); return items }
31
+ say(items.length ? items.map(issueLine).join('\n') : 'No issues created from steps yet.')
32
+ return items
33
+ }
34
+ if (sub !== 'create') throw new UserError('Usage: kaloko issue create --step <id> --to jira|linear [--criterion AC2] [--title "…"] [--note "…"] [--run id]\n kaloko issue list [--run id] [--scenario id] [--state open|done|canceled] [--json]')
35
+ const to = String(args.to || '')
36
+ if (!['jira', 'linear'].includes(to)) throw new UserError('--to jira or --to linear says where the issue goes.')
37
+ if (typeof args.step !== 'string' || !args.step) throw new UserError('--step <id> names the step the issue is about.')
38
+ const run = typeof args.run === 'string' ? args.run : runId
39
+ if (!run || !RUN_ID.test(run)) throw new UserError('No shared run: pass --run <id>, or share the current run first (kaloko share).')
40
+ const body = { run_id: run, step: args.step, to, ...Object.fromEntries(['criterion', 'title', 'note', 'locale', 'viewport', 'target'].filter((k) => typeof args[k] === 'string' && args[k]).map((k) => [k, args[k]])) }
41
+ const issue = await client.request('POST', `/orgs/${org}/issues`, body)
42
+ if (args.json) { say(JSON.stringify(issue, null, 2)); return issue }
43
+ say(`✓ ${issue.key} in ${to === 'jira' ? 'Jira' : 'Linear'}${issue.screenshot ? ', with the screenshot' : ''}: ${issue.url}\n The step got a comment that is resolved when the issue is done.`)
44
+ return issue
45
+ }
46
+
47
+ // ---------- kaloko schedule ----------
48
+
49
+ /** A step script that only opens its page (export default async ({ page, url }) => { await page.goto(url(…)) }). */
50
+ export function onlyNavigates (code) {
51
+ const s = String(code || '').replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1').trim()
52
+ return /^export\s+default\s+async\s*(?:function\s*\w*\s*)?\(\s*\{\s*page\s*,\s*url\s*\}\s*\)\s*(?:=>)?\s*\{\s*(?:await\s+page\.goto\(\s*url\(\s*\)\s*(?:,\s*\{[^{}]*\})?\s*\)\s*;?\s*)\}\s*;?$/.test(s)
53
+ }
54
+
55
+ /** The scenario as the service takes it: packs expanded, steps whose script only navigates marked hosted: open. */
56
+ export async function hostedScenario (scenario, scenarioFile) {
57
+ const out = JSON.parse(JSON.stringify(scenario))
58
+ for (const n of out.nodes || []) {
59
+ const script = n.execution?.script
60
+ if (!script || /^open\.(m?js|cjs)$/.test(String(script).split('/').pop())) continue
61
+ try { if (onlyNavigates(await readFile(resolve(dirname(scenarioFile), script), 'utf8'))) n.hosted = 'open' } catch {}
62
+ }
63
+ return out
64
+ }
65
+
66
+ /** How the hosted browser gets past the environment's gate: HTTP Basic from its variables, or an account's cookies. */
67
+ export async function hostedAuth ({ env, account = null, root, config, vars = process.env }) {
68
+ if (account) {
69
+ const file = accountFor(env, account, { root, config }).storage_state
70
+ let state
71
+ try { state = JSON.parse(await readFile(file, 'utf8')) } catch { throw new UserError(`No saved session for ${account} in ${env.name} (${relative(process.cwd(), file)}). Sign in once with kaloko auth save --env ${env.name} --account ${account}, then schedule again.`) }
72
+ return { cookies: (state.cookies || []).map(({ name, value, domain, path, expires, httpOnly, secure, sameSite }) => ({ name, value, domain, path, expires, httpOnly, secure, sameSite })) }
73
+ }
74
+ const gate = gateOf(env)
75
+ if (gate.basic) return { basic: { user: secret(gate.basic.user_env, `HTTP Basic user for ${env.name}`, vars), password: secret(gate.basic.password_env, `HTTP Basic password for ${env.name}`, vars) } }
76
+ if (gate.headers && Object.keys(gate.headers).length) throw new UserError(`${env.name} is behind header credentials (access.headers); hosted runs send HTTP Basic or a saved session only. Walk it in CI: kaloko schedule export github.`)
77
+ return null
78
+ }
79
+
80
+ const atHour = (v, d = 5) => { const n = Number(String(v ?? '').split(':')[0]); return Number.isInteger(n) && n >= 0 && n <= 23 ? n : d }
81
+ const weekdayOf = (v, d = 1) => WEEKDAYS[String(v || '').slice(0, 3).toLowerCase()] || (Number(v) >= 1 && Number(v) <= 7 ? Number(v) : d)
82
+ const when = (s) => s.every === 'manual' ? 'on demand' : `${s.every === 'week' ? `weekly on ${Object.keys(WEEKDAYS)[s.weekday - 1]}` : 'daily'} at ${String(s.at_hour).padStart(2, '0')}:00 UTC`
83
+ const scheduleLine = (s) => `${s.id} ${s.scenario_id} on ${s.environment} (${s.base_url}) · ${when(s)}${s.enabled ? '' : ' · paused'}${s.pages_per_run ? ` · ${s.pages_per_run} pages` : ''}\n next ${s.enabled && s.next_run_at ? s.next_run_at.slice(0, 16).replace('T', ' ') : '—'} · last ${s.last_run_at ? `${s.last_run_at.slice(0, 16).replace('T', ' ')} ${s.last_status}${s.last_run_id ? ` ${s.last_run_id}` : ''}` : '—'}${s.last_error ? `\n ! ${s.last_error}` : ''}`
84
+
85
+ /** The GitHub Actions workflow that walks a scenario on a schedule with the full CLI and shares the run. */
86
+ export function githubScheduleWorkflow ({ scenario, scenarioId, env, every = 'day', at_hour = 5, weekday = 1, pm = 'npm', dependency = true, version = 'latest', node = 22, browsers = ['chromium'], secrets = [] }) {
87
+ const { install, kaloko: k } = runner({ pm, dependency, version })
88
+ const cron = every === 'week' ? `0 ${at_hour} * * ${weekday % 7}` : `0 ${at_hour} * * *`
89
+ const engines = browsers.some((b) => b !== 'chromium') ? ` --browsers ${browsers.join(',')}` : ''
90
+ const names = [...new Set(secrets.filter((n) => /^[A-Z_][A-Z0-9_]*$/.test(n)))]
91
+ return `# Kaloko: walk ${scenarioId} on ${env} ${every === 'week' ? 'every week' : 'every day'} and share the run.
92
+ # Written by kaloko schedule export github. Secrets: KALOKO_TOKEN (tester role, upload scope; on Enterprise a service
93
+ # account's)${names.length ? `; ${names.join(', ')} (the environment's credentials, as in kaloko.config.yml)` : ''}.
94
+ name: Kaloko ${scenarioId} on ${env}
95
+ on:
96
+ schedule:
97
+ - cron: '${cron}' # UTC
98
+ workflow_dispatch:
99
+ concurrency:
100
+ group: kaloko-schedule-${scenarioId}-${env}
101
+ cancel-in-progress: false
102
+ permissions:
103
+ contents: read
104
+ jobs:
105
+ walk:
106
+ runs-on: ubuntu-latest
107
+ timeout-minutes: 45
108
+ env:
109
+ KALOKO_TOKEN: \${{ secrets.KALOKO_TOKEN }}
110
+ TYPESAFE_API_KEY: \${{ secrets.TYPESAFE_API_KEY }}
111
+ KALOKO_AGENT: github-actions
112
+ ${names.map((n) => ` ${n}: \${{ secrets.${n} }}`).join('\n')}${names.length ? '\n' : ''} steps:
113
+ - uses: actions/checkout@v4
114
+ - uses: actions/setup-node@v4
115
+ with:
116
+ node-version: ${node}
117
+ ${install ? `${['pnpm', 'yarn'].includes(pm) ? ' - run: corepack enable\n' : ''} - run: ${install}\n` : ''} - run: ${k} browser install --with-deps${engines}
118
+ - name: Walk, evaluate, share
119
+ run: |
120
+ ${k} start --scenario ${scenario} --env ${env}
121
+ ${k} walk
122
+ ${k} evaluate
123
+ ${k} share
124
+ `
125
+ }
126
+
127
+ /** Variable names the environment and the scenario's accounts read their secrets from. */
128
+ export function secretNames (env, scenario = {}) {
129
+ const out = []
130
+ const gate = gateOf(env)
131
+ if (gate.basic) out.push(gate.basic.user_env, gate.basic.password_env)
132
+ for (const spec of Object.values(gate.headers || {})) if (spec && typeof spec === 'object' && spec.env) out.push(spec.env)
133
+ const used = new Set(Object.values(scenario.sessions || {}).map((s) => s?.account).filter(Boolean))
134
+ for (const [key, a] of Object.entries(env.accounts || {})) if (used.has(key)) out.push(a.user_env, a.password_env, a.totp_env)
135
+ return out.filter(Boolean)
136
+ }
137
+
138
+ /**
139
+ * `kaloko schedule add --scenario <file> --env <name> [--every day|week|manual] [--at 5] [--weekday mon] [--account key]`,
140
+ * `list`, `run <id>`, `pause <id>`, `resume <id>`, `remove <id>`, `export github --scenario <file> --env <name>`.
141
+ * deps: { loadScenario, environment, baseUrl, projectAndIssue, packageRoot, service }.
142
+ */
143
+ export async function scheduleCmd ({ args, config, root, say, deps }) {
144
+ const [sub, id] = args._
145
+ const org = config.org
146
+ const client = () => deps.service(config)
147
+ if (sub === 'export') {
148
+ if (id !== 'github') throw new UserError('Usage: kaloko schedule export github --scenario <file> --env <name> [--every day|week] [--at 5] [--weekday mon] [--out file] [--print] [--force]')
149
+ const file = typeof args.scenario === 'string' ? resolve(root, args.scenario) : null
150
+ if (!file) throw new UserError('--scenario <file> names the scenario to walk.')
151
+ const envName = typeof args.env === 'string' ? args.env : null
152
+ if (!envName) throw new UserError('--env <name> names the environment (production, staging …).')
153
+ const scenario = await deps.loadScenario(file)
154
+ // only the names of its variables are needed here: the workflow reads the values from repository secrets
155
+ if (!config.environments?.[envName]) throw new UserError(`Unknown environment "${envName}". Known: ${Object.keys(config.environments || {}).join(', ')}`)
156
+ const env = { name: envName, ...config.environments[envName] }
157
+ if (scenario.environments && !scenario.environments.includes(envName)) throw new UserError(`${relative(root, file)} allows environments [${scenario.environments.join(', ')}], not ${envName}.`)
158
+ let pkg = null
159
+ try { pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) } catch {}
160
+ const pm = existsSync(join(root, 'pnpm-lock.yaml')) ? 'pnpm' : existsSync(join(root, 'yarn.lock')) ? 'yarn' : existsSync(join(root, 'bun.lockb')) || existsSync(join(root, 'bun.lock')) ? 'bun' : 'npm'
161
+ const version = JSON.parse(await readFile(join(deps.packageRoot, 'package.json'), 'utf8')).version
162
+ const every = args.every === 'week' ? 'week' : 'day'
163
+ const content = githubScheduleWorkflow({ scenario: relative(root, file), scenarioId: scenario.id, env: envName, every, at_hour: atHour(args.at), weekday: weekdayOf(args.weekday), pm, dependency: !!(pkg && ({ ...pkg.dependencies, ...pkg.devDependencies }).kaloko), version: `^${version}`, browsers: [...new Set(['chromium', ...(scenario.browsers || []), ...(config.walk?.browsers || [])])], secrets: secretNames(env, scenario) })
164
+ if (args.print) { say(content); return { content } }
165
+ const out = resolve(root, typeof args.out === 'string' ? args.out : `.github/workflows/kaloko-${scenario.id.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${envName}.yml`)
166
+ if (existsSync(out) && !args.force) throw new UserError(`${relative(process.cwd(), out)} exists; pass --force to replace it (or --print to compare).`)
167
+ await mkdir(dirname(out), { recursive: true })
168
+ await writeFile(out, content)
169
+ say(`Wrote ${relative(process.cwd(), out)}: ${scenario.id} on ${envName} ${every === 'week' ? 'every week' : 'every day'} at ${String(atHour(args.at)).padStart(2, '0')}:00 UTC with the full walk (clicks, forms, every criterion).\nNext: add the repository secret KALOKO_TOKEN${secretNames(env, scenario).length ? ` and ${secretNames(env, scenario).join(', ')}` : ''}, then commit the workflow.`)
170
+ return { file: out }
171
+ }
172
+ if (!org) throw new UserError('Set org: in the config first.')
173
+ if (sub === 'list' || !sub) {
174
+ const data = await client().request('GET', `/orgs/${org}/schedules`)
175
+ if (args.json) { say(JSON.stringify(data, null, 2)); return data }
176
+ if (!data.configured) { say('Hosted runs are not enabled on this service. Walk on a schedule in CI: kaloko schedule export github --scenario <file> --env <name>'); return data }
177
+ say(`${data.usage.pages} of ${data.usage.budget} hosted pages used in ${data.usage.month}${data.plan_allows ? '' : ' (hosted runs come with the Business plan)'}`)
178
+ say(data.schedules.length ? data.schedules.map(scheduleLine).join('\n') : 'No schedules yet: kaloko schedule add --scenario <file> --env <name> --every day')
179
+ return data
180
+ }
181
+ if (sub === 'add') {
182
+ const file = typeof args.scenario === 'string' ? resolve(root, args.scenario) : null
183
+ if (!file) throw new UserError('--scenario <file> names a read-only scenario.')
184
+ const envName = typeof args.env === 'string' ? args.env : null
185
+ if (!envName) throw new UserError('--env <name> names the environment (its base_url is what the hosted browser opens).')
186
+ const scenario = await deps.loadScenario(file)
187
+ const env = deps.environment(config, envName)
188
+ if (scenario.environments && !scenario.environments.includes(envName)) throw new UserError(`${relative(root, file)} allows environments [${scenario.environments.join(', ')}], not ${envName}.`)
189
+ if (env.kind === 'design' || env.base_url == null) throw new UserError(`${envName} has no web address; hosted runs open web pages.`)
190
+ if (typeof env.base_url !== 'string') throw new UserError(`${envName} has a base_url per locale; hosted runs take one address. Walk it in CI: kaloko schedule export github.`)
191
+ const every = ['day', 'week', 'manual'].includes(args.every) ? args.every : 'day'
192
+ const { project } = await deps.projectAndIssue({ cwd: root, config, scenario, branch: null })
193
+ const body = { scenario: await hostedScenario(scenario, file), environment: envName, base_url: deps.baseUrl(env), every, at_hour: atHour(args.at), weekday: weekdayOf(args.weekday), project: project?.slug || null, auth: await hostedAuth({ env, account: typeof args.account === 'string' ? args.account : null, root, config }) }
194
+ let s
195
+ try { s = await client().request('POST', `/orgs/${org}/schedules`, body) } catch (e) {
196
+ if (/HTTP 404/.test(e.message)) throw new UserError(`${e.message}\nWalk it on a schedule in CI instead: kaloko schedule export github --scenario ${relative(root, file)} --env ${envName}`)
197
+ throw e
198
+ }
199
+ if (args.json) { say(JSON.stringify(s, null, 2)); return s }
200
+ say(`✓ ${s.created ? 'Scheduled' : 'Updated'} ${s.scenario_id} on ${envName} ${when(s)}: ${s.pages_per_run} pages per run${s.auth !== 'none' ? ` (${s.auth === 'basic' ? 'HTTP Basic' : 'saved session'})` : ''}. Runs are shared as you and land in the inbox.`)
201
+ for (const k of s.skipped || []) say(` - ${k.id}: not walked (${k.reason})`)
202
+ if ((s.skipped || []).length) say(' Steps that click or type need the full walk: kaloko schedule export github')
203
+ return s
204
+ }
205
+ if (!id) throw new UserError(`Usage: kaloko schedule ${sub} <schedule id> (kaloko schedule list shows them)`)
206
+ const path = `/orgs/${org}/schedules/${encodeURIComponent(id)}`
207
+ if (sub === 'run') { await client().request('POST', `${path}/run`); say(`✓ ${id} starts within 15 minutes; the run lands in your inbox.`); return }
208
+ if (sub === 'pause' || sub === 'resume') { const s = await client().request('PATCH', path, { enabled: sub === 'resume' }); say(`✓ ${id} ${s.enabled ? `resumed: ${when(s)}` : 'paused'}`); return s }
209
+ if (sub === 'remove') { await client().request('DELETE', path); say(`✓ ${id} removed; its runs stay.`); return }
210
+ throw new UserError('Usage: kaloko schedule add|list|run|pause|resume|remove|export github (kaloko help)')
211
+ }
@@ -0,0 +1,36 @@
1
+ // `kaloko screens`: the screen library from the terminal (batch G): the newest capture of every step of the
2
+ // organization's flows, searched on the service (GET /orgs/<org>/screens), one line per screen with its canvas link.
3
+ import { UserError } from './util.mjs'
4
+
5
+ const FILTERS = ['project', 'product', 'module', 'scenario', 'locale', 'viewport', 'device', 'status', 'environment', 'limit', 'cursor']
6
+
7
+ /** The query string of a search from CLI arguments: words are the query, --designs adds design versions. */
8
+ export function screensQuery (args) {
9
+ const p = new URLSearchParams()
10
+ const q = (args._ || []).join(' ').trim() || (typeof args.q === 'string' ? args.q : '')
11
+ if (q) p.set('q', q)
12
+ for (const k of FILTERS) if (typeof args[k] === 'string' || typeof args[k] === 'number') p.set(k, String(args[k]))
13
+ if (args.designs || args.source === 'all') p.set('source', 'all')
14
+ else if (args.source === 'design') p.set('source', 'design')
15
+ if (args.all) p.set('group', 'none')
16
+ return p
17
+ }
18
+
19
+ export function formatScreens (r) {
20
+ if (!r.items.length) return 'No screen matches.'
21
+ const lines = r.items.map((it) => {
22
+ const where = [it.locale, it.viewport].filter(Boolean).join('/')
23
+ const st = it.status ? `${it.status}`.padEnd(8) : ''.padEnd(8)
24
+ const tag = it.source === 'design' ? ` [design${it.version ? ` v${it.version}` : ''}]` : ''
25
+ return `${st} ${it.scenario.id}/${it.step.id} ${where}${tag} ${it.step.title || ''}${it.url ? ` ${it.url}` : it.path ? ` ${it.path}` : ''}\n ${it.canvas}`
26
+ })
27
+ if (r.next) lines.push(`… more: kaloko screens ${r.filters?.q ? `"${r.filters.q}" ` : ''}--cursor ${r.next}`)
28
+ return lines.join('\n')
29
+ }
30
+
31
+ export async function screensCmd ({ config, client, args, say }) {
32
+ if (!config.org) throw new UserError('Set org: in kaloko.config.yml (or run kaloko login) to search the screen library.')
33
+ const r = await client.request('GET', `/orgs/${config.org}/screens?${screensQuery(args)}`)
34
+ say(args.json ? JSON.stringify(r, null, 2) : formatScreens(r))
35
+ return r
36
+ }
@@ -7,13 +7,26 @@ export function serviceUrl (config, vars = process.env) {
7
7
  return url.replace(/^https:\/\/app\.qawalk\.com$/, 'https://kaloko.app')
8
8
  }
9
9
 
10
+ /** Which CI runs this (github, gitlab, …), or null on a laptop: the service counts a team's first run from CI. */
11
+ export function ciName (vars = process.env) {
12
+ if (vars.GITHUB_ACTIONS === 'true') return 'github'
13
+ if (vars.GITLAB_CI) return 'gitlab'
14
+ if (vars.CIRCLECI) return 'circleci'
15
+ if (vars.BUILDKITE) return 'buildkite'
16
+ if (vars.JENKINS_URL) return 'jenkins'
17
+ if (vars.BITBUCKET_BUILD_NUMBER) return 'bitbucket'
18
+ if (vars.TF_BUILD) return 'azure'
19
+ return vars.CI && vars.CI !== 'false' ? 'ci' : null
20
+ }
21
+
10
22
  export function service (config) {
11
23
  const base = serviceUrl(config) + '/api/v1'
12
24
  const tokenEnv = config.share?.token_env || 'KALOKO_TOKEN'
13
25
  const token = process.env[tokenEnv]
14
26
  if (!token) throw new UserError(`Set ${tokenEnv} (create a token in Kaloko → Settings).`)
15
27
  // an agent names itself (KALOKO_AGENT, e.g. "claude-code"); the service shows it in presence, history and audit (#42)
16
- const headers = { Authorization: `Bearer ${token}`, ...(process.env.KALOKO_AGENT ? { 'X-QAwalk-Agent': process.env.KALOKO_AGENT.slice(0, 60) } : {}) }
28
+ const ci = ciName()
29
+ const headers = { Authorization: `Bearer ${token}`, ...(process.env.KALOKO_AGENT ? { 'X-QAwalk-Agent': process.env.KALOKO_AGENT.slice(0, 60) } : {}), ...(ci ? { 'X-Kaloko-CI': ci } : {}) }
17
30
  const check = async (res, what) => {
18
31
  if (res.ok) return res.json()
19
32
  const body = await res.json().catch(() => ({}))
@@ -36,6 +49,12 @@ export function service (config) {
36
49
  const res = await limited(() => fetch(base + path, { method, headers: { ...headers, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined }))
37
50
  return check(res, `${method} ${path}`)
38
51
  },
52
+ /** A request whose answer is not JSON (an export): the response, or an error with the service's message. */
53
+ async raw (method, path, body) {
54
+ const res = await limited(() => fetch(base + path, { method, headers: { ...headers, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined }))
55
+ if (!res.ok) { const b = await res.json().catch(() => ({})); throw new UserError(`${method} ${path} → HTTP ${res.status}: ${b.error || res.statusText}`) }
56
+ return res
57
+ },
39
58
  async fetch (path) {
40
59
  const res = await fetch(base + path, { headers })
41
60
  if (!res.ok) { const body = await res.json().catch(() => ({})); throw new UserError(`GET ${path} → HTTP ${res.status}: ${body.error || res.statusText}`) }