kaloko 0.12.2 → 0.13.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.
@@ -3,7 +3,7 @@
3
3
  import { readFile, writeFile, mkdir, rm, readdir } from 'node:fs/promises'
4
4
  import { gzipSync, gunzipSync } from 'node:zlib'
5
5
  import { join, dirname, relative } from 'node:path'
6
- import { readJson, writeJson, UserError } from './util.mjs'
6
+ import { readJson, writeJson, UserError, UsageError } from './util.mjs'
7
7
  import { readCaptures, addCapture, runPaths } from './run.mjs'
8
8
  import { latestCaptures, criteriaHash, evaluationSource } from './evaluate.mjs'
9
9
  import { captureStep } from './capture.mjs'
@@ -198,7 +198,7 @@ export async function uploadBlobs ({ client, org, files, say }) {
198
198
  const raw = await readFile(f.abs)
199
199
  const gz = TEXT_TYPES.has(f.ext) && raw.length > 512
200
200
  const body = gz ? gzipSync(raw, { level: 6 }) : raw
201
- await client.upload(`/orgs/${org}/blobs/${h}`, body, f.abs, { 'X-QAwalk-Type': f.ext, ...(gz ? { 'Content-Encoding': 'gzip' } : {}) })
201
+ await client.upload(`/orgs/${org}/blobs/${h}`, body, f.abs, { 'X-Kaloko-Type': f.ext, 'X-QAwalk-Type': f.ext, ...(gz ? { 'Content-Encoding': 'gzip' } : {}) })
202
202
  bytes += body.length
203
203
  }
204
204
  }
@@ -267,7 +267,7 @@ export async function syncDesign ({ config, root, run, scenario, env, client, ar
267
267
  try { if (await sha256Hex(await readFile(target)) === hash) continue } catch {}
268
268
  const res = await client.fetch(`/orgs/${org}/revisions/${rev.id}/files/${p.split('/').map(enc).join('/')}`)
269
269
  let buf = Buffer.from(await res.arrayBuffer())
270
- if (res.headers.get('X-QAwalk-Encoding') === 'gzip') buf = gunzipSync(buf)
270
+ if ((res.headers.get('X-Kaloko-Encoding') || res.headers.get('X-QAwalk-Encoding')) === 'gzip') buf = gunzipSync(buf)
271
271
  await mkdir(dirname(target), { recursive: true })
272
272
  await writeFile(target, buf)
273
273
  }
@@ -470,7 +470,7 @@ export async function tokensCmd ({ config, root, env, client, args, say, project
470
470
  if (args.json) { say(JSON.stringify(report, null, 2)); return }
471
471
  if (!report.offenders.length) say(`✓ Every color, font, size, spacing and radius in ${relative(process.cwd(), dir)} comes from the token set (${report.checked} values).`)
472
472
  else { say(`${report.offenders.length} raw value(s) outside the token set:\n - ${report.offenders.slice(0, 40).map((o) => `${o.file}:${o.line} ${o.property}: ${o.value}${o.deprecated ? ` (deprecated token ${o.deprecated}${o.note ? `: ${o.note}` : ''})` : ''}${o.suggest ? ` → ${o.suggest}` : ''}`).join('\n - ')}`); process.exitCode = 1 }
473
- } else throw new UserError('Usage: kaloko tokens pull|push|check|validate [--project p] [--file tokens.json|tokens.resolver.json] [--mode m] [--json]')
473
+ } else throw new UsageError('Usage: kaloko tokens pull|push|check|validate [--project p] [--file tokens.json|tokens.resolver.json] [--mode m] [--json]')
474
474
  }
475
475
 
476
476
  /** Writes the live files of a version (or the draft) into a folder: the handoff, or taking back canvas edits. */
@@ -488,7 +488,7 @@ export async function exportLive ({ config, scenarioId, client, args, say, out }
488
488
  try { if (await sha256Hex(await readFile(target)) === hash) continue } catch {}
489
489
  const res = await client.fetch(`/orgs/${config.org}/revisions/${s.revision}/files/${p.split('/').map(enc).join('/')}`)
490
490
  let buf = Buffer.from(await res.arrayBuffer())
491
- if (res.headers.get('X-QAwalk-Encoding') === 'gzip') buf = gunzipSync(buf)
491
+ if ((res.headers.get('X-Kaloko-Encoding') || res.headers.get('X-QAwalk-Encoding')) === 'gzip') buf = gunzipSync(buf)
492
492
  await mkdir(dirname(target), { recursive: true })
493
493
  await writeFile(target, buf)
494
494
  written++
@@ -510,13 +510,13 @@ export async function branchCmd ({ config, scenarioId, client, args, say }) {
510
510
  return
511
511
  }
512
512
  if (action === 'create') {
513
- if (!name) throw new UserError('Usage: kaloko branch create <name> [--from vN|draft] [--git <branch>]')
513
+ if (!name) throw new UsageError('Usage: kaloko branch create <name> [--from vN|draft] [--git <branch>]')
514
514
  const r = await client.request('POST', base, { name, ...(typeof args.from === 'string' ? { from: args.from } : {}), ...(typeof args.git === 'string' ? { git_branch: args.git } : {}) })
515
515
  say(`Branch ${r.branch} created from ${r.based_on_version || 'the draft'} (${r.steps} step×locale). Work on it with --branch ${r.branch}.`)
516
516
  return
517
517
  }
518
518
  if (action === 'merge') {
519
- if (!name) throw new UserError('Usage: kaloko branch merge <name> [--take-branch a,b] [--keep-main c]')
519
+ if (!name) throw new UsageError('Usage: kaloko branch merge <name> [--take-branch a,b] [--keep-main c]')
520
520
  const choices = {}
521
521
  try {
522
522
  const r = await client.request('POST', `${base}/${enc(name)}/merge`, { choices: await choicesFrom(args, client, config, scenarioId, name) })
@@ -528,7 +528,7 @@ export async function branchCmd ({ config, scenarioId, client, args, say }) {
528
528
  }
529
529
  return choices
530
530
  }
531
- throw new UserError('Usage: kaloko branch list | create <name> [--from vN] [--git <branch>] | merge <name> [--take-branch a,b] [--keep-main c]')
531
+ throw new UsageError('Usage: kaloko branch list | create <name> [--from vN] [--git <branch>] | merge <name> [--take-branch a,b] [--keep-main c]')
532
532
  }
533
533
 
534
534
  async function choicesFrom (args, client, config, scenarioId, name) {
@@ -556,7 +556,7 @@ async function forkViews ({ config, client, args, say, out, data }) {
556
556
  for (const [p] of Object.entries(v.files)) {
557
557
  const res = await client.fetch(`/orgs/${config.org}/revisions/${v.revision}/files/${p.split('/').map(enc).join('/')}`)
558
558
  let buf = Buffer.from(await res.arrayBuffer())
559
- if (res.headers.get('X-QAwalk-Encoding') === 'gzip') buf = gunzipSync(buf)
559
+ if ((res.headers.get('X-Kaloko-Encoding') || res.headers.get('X-QAwalk-Encoding')) === 'gzip') buf = gunzipSync(buf)
560
560
  const target = join(out, prefix, p)
561
561
  await mkdir(dirname(target), { recursive: true })
562
562
  await writeFile(target, buf)
@@ -136,7 +136,7 @@ export async function exportDesign ({ config, scenarioId, client, ref = 'design'
136
136
  for (const [p, { revision }] of paths) {
137
137
  const res = await client.fetch(`/orgs/${org}/revisions/${revision}/files/${p.split('/').map(enc).join('/')}`)
138
138
  let buf = Buffer.from(await res.arrayBuffer())
139
- if (res.headers.get('X-QAwalk-Encoding') === 'gzip') buf = gunzipSync(buf)
139
+ if ((res.headers.get('X-Kaloko-Encoding') || res.headers.get('X-QAwalk-Encoding')) === 'gzip') buf = gunzipSync(buf)
140
140
  await sink.put(p, buf)
141
141
  }
142
142
  const steps = []
@@ -4,7 +4,7 @@
4
4
  import { readFile, writeFile, mkdir } from 'node:fs/promises'
5
5
  import { existsSync } from 'node:fs'
6
6
  import { resolve, relative, dirname, join } from 'node:path'
7
- import { UserError, NO_ORG } from './util.mjs'
7
+ import { UserError, UsageError, NO_ORG } from './util.mjs'
8
8
  import { gateOf, accountFor } from './access.mjs'
9
9
  import { secret } from './secrets.mjs'
10
10
  import { runner } from './ci.mjs'
@@ -31,7 +31,7 @@ export async function issueCmd ({ args, config, client, runId, say }) {
31
31
  say(items.length ? items.map(issueLine).join('\n') : 'No issues created from steps yet.')
32
32
  return items
33
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]')
34
+ if (sub !== 'create') throw new UsageError('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
35
  const to = String(args.to || '')
36
36
  if (!['jira', 'linear'].includes(to)) throw new UserError('--to jira or --to linear says where the issue goes.')
37
37
  if (typeof args.step !== 'string' || !args.step) throw new UserError('--step <id> names the step the issue is about.')
@@ -145,7 +145,7 @@ export async function scheduleCmd ({ args, config, root, say, deps }) {
145
145
  const org = config.org
146
146
  const client = () => deps.service(config)
147
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]')
148
+ if (id !== 'github') throw new UsageError('Usage: kaloko schedule export github --scenario <file> --env <name> [--every day|week] [--at 5] [--weekday mon] [--out file] [--print] [--force]')
149
149
  const file = typeof args.scenario === 'string' ? resolve(root, args.scenario) : null
150
150
  if (!file) throw new UserError('--scenario <file> names the scenario to walk.')
151
151
  const envName = typeof args.env === 'string' ? args.env : null
@@ -202,10 +202,10 @@ export async function scheduleCmd ({ args, config, root, say, deps }) {
202
202
  if ((s.skipped || []).length) say(' Steps that click or type need the full walk: kaloko schedule export github')
203
203
  return s
204
204
  }
205
- if (!id) throw new UserError(`Usage: kaloko schedule ${sub} <schedule id> (kaloko schedule list shows them)`)
205
+ if (!id) throw new UsageError(`Usage: kaloko schedule ${sub} <schedule id> (kaloko schedule list shows them)`)
206
206
  const path = `/orgs/${org}/schedules/${encodeURIComponent(id)}`
207
207
  if (sub === 'run') { await client().request('POST', `${path}/run`); say(`✓ ${id} starts within 15 minutes; the run lands in your inbox.`); return }
208
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
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)')
210
+ throw new UsageError('Usage: kaloko schedule add|list|run|pause|resume|remove|export github (kaloko help)')
211
211
  }
@@ -1,6 +1,6 @@
1
1
  // `kaloko screens`: the screen library from the terminal (batch G): the newest capture of every step of the
2
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'
3
+ import { UserError, NO_ORG } from './util.mjs'
4
4
 
5
5
  const FILTERS = ['project', 'product', 'module', 'scenario', 'locale', 'viewport', 'device', 'status', 'environment', 'limit', 'cursor']
6
6
 
@@ -10,6 +10,8 @@ export function screensQuery (args) {
10
10
  const q = (args._ || []).join(' ').trim() || (typeof args.q === 'string' ? args.q : '')
11
11
  if (q) p.set('q', q)
12
12
  for (const k of FILTERS) if (typeof args[k] === 'string' || typeof args[k] === 'number') p.set(k, String(args[k]))
13
+ // --env is the environment, as in every other command (--environment still works)
14
+ if (typeof args.env === 'string') p.set('environment', args.env)
13
15
  if (args.designs || args.source === 'all') p.set('source', 'all')
14
16
  else if (args.source === 'design') p.set('source', 'design')
15
17
  if (args.all) p.set('group', 'none')
@@ -29,7 +31,7 @@ export function formatScreens (r) {
29
31
  }
30
32
 
31
33
  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.')
34
+ if (!config.org) throw new UserError(NO_ORG)
33
35
  const r = await client.request('GET', `/orgs/${config.org}/screens?${screensQuery(args)}`)
34
36
  say(args.json ? JSON.stringify(r, null, 2) : formatScreens(r))
35
37
  return r
@@ -1,5 +1,5 @@
1
1
  // Client of the Kaloko service API (kaloko.app or KALOKO_URL) with the organization token.
2
- import { UserError } from './util.mjs'
2
+ import { UserError, NO_ORG } from './util.mjs'
3
3
 
4
4
  /** Base URL of the service; configs written before the rename name app.qawalk.com, which now means kaloko.app. */
5
5
  export function serviceUrl (config, vars = process.env) {
@@ -19,8 +19,20 @@ export function ciName (vars = process.env) {
19
19
  return vars.CI && vars.CI !== 'false' ? 'ci' : null
20
20
  }
21
21
 
22
- /** What to do after the service answered `status`; '' when its own message says enough. */
23
- export function nextStepFor (status, { tokenEnv = 'KALOKO_TOKEN', url = 'https://kaloko.app' } = {}) {
22
+ /** Plan & billing of the organization on the service (the service names it for 402 answers). */
23
+ export const billingUrl = (url, org) => `${url}${org ? `/${org}` : ''}/billing`
24
+
25
+ /**
26
+ * What to do after the service answered `status` (and `code`, when it names the reason); '' when its own message
27
+ * says enough. `message` is the service's message: a link it already holds is not repeated.
28
+ */
29
+ export function nextStepFor (status, { tokenEnv = 'KALOKO_TOKEN', url = 'https://kaloko.app', code = null, billing = null, org = null, message = '' } = {}) {
30
+ const plan = billing || billingUrl(url, org)
31
+ const link = message.includes(plan) ? '' : ` Plan & billing: ${plan}`
32
+ if (code === 'billing_restricted') return `Billing of the organization is restricted until the payment is fixed; an admin updates it.${link}`
33
+ if (code === 'seat_limit') return `An admin buys seats or upgrades the plan.${link}`
34
+ if (code === 'sso_required') return 'Sign in through your company sign-in on kaloko.app in the browser, then run kaloko login.'
35
+ if (status === 402 || code === 'plan') return `Your plan does not include this.${link}`
24
36
  if (status === 401) return `The token in ${tokenEnv} is invalid, expired or revoked. Run kaloko login for a new one (or put a fresh token from Settings → API tokens into .env).`
25
37
  if (status === 403) return 'Your role or the token\'s scopes do not allow this; ask an admin of the organization, or use a token that may do it.'
26
38
  if (status === 413) return 'The file is larger than the service takes; leave it out of the run or make it smaller.'
@@ -30,10 +42,11 @@ export function nextStepFor (status, { tokenEnv = 'KALOKO_TOKEN', url = 'https:/
30
42
  }
31
43
 
32
44
  /** A UserError for an HTTP answer: what failed, the service's message, what to do next; `status` kept for callers. */
33
- export function httpError (what, status, message, opts) {
34
- const err = new UserError([`${what} → HTTP ${status}: ${message}`, nextStepFor(status, opts)].filter(Boolean).join(' '))
45
+ export function httpError (what, status, message, opts = {}) {
46
+ const err = new UserError(`${what} → HTTP ${status}: ${message}`, { next: nextStepFor(status, { ...opts, message: String(message || '') }) })
35
47
  err.status = status
36
48
  err.serviceMessage = message
49
+ if (opts.code) err.code = opts.code
37
50
  return err
38
51
  }
39
52
 
@@ -51,15 +64,23 @@ export function service (config) {
51
64
  const tokenEnv = config.share?.token_env || 'KALOKO_TOKEN'
52
65
  const token = process.env[tokenEnv]
53
66
  if (!token) throw new UserError(`${tokenEnv} is not set, so the CLI cannot reach your organization on ${root}. Run kaloko login, or create a token in Settings → API tokens and put ${tokenEnv}=… into .env.`)
54
- const opts = { tokenEnv, url: root }
55
- // an agent names itself (KALOKO_AGENT, e.g. "claude-code"); the service shows it in presence, history and audit (#42)
67
+ const opts = { tokenEnv, url: root, org: config.org || null }
68
+ // an agent names itself (KALOKO_AGENT, e.g. "claude-code"); the service shows it in presence, history and audit (#42).
69
+ // Both header names: services before the rename read X-QAwalk-Agent only
56
70
  const ci = ciName()
57
- const headers = { Authorization: `Bearer ${token}`, ...(process.env.KALOKO_AGENT ? { 'X-QAwalk-Agent': process.env.KALOKO_AGENT.slice(0, 60) } : {}), ...(ci ? { 'X-Kaloko-CI': ci } : {}) }
71
+ const agent = process.env.KALOKO_AGENT ? process.env.KALOKO_AGENT.slice(0, 60) : null
72
+ const headers = { Authorization: `Bearer ${token}`, ...(agent ? { 'X-Kaloko-Agent': agent, 'X-QAwalk-Agent': agent } : {}), ...(ci ? { 'X-Kaloko-CI': ci } : {}) }
73
+ // an error answer: its message, and the reason code and billing link the service adds ({ error, code?, billing_url? })
74
+ const failed = async (res, what) => {
75
+ const body = await res.json().catch(() => ({}))
76
+ return httpError(what, res.status, body.error || res.statusText, { ...opts, code: body.code || null, billing: body.billing_url || null })
77
+ }
58
78
  const check = async (res, what) => {
59
79
  if (res.ok) return res.json()
60
- const body = await res.json().catch(() => ({}))
61
- throw httpError(what, res.status, body.error || res.statusText, opts)
80
+ throw await failed(res, what)
62
81
  }
82
+ // no org: in the config would ask for /orgs/undefined/…: say where the slug comes from instead
83
+ const orgOf = (path) => { if (/^\/orgs\/(?:undefined|null)?(?:[/?]|$)/.test(path)) throw new UserError(NO_ORG) }
63
84
  // fetch rejects without an answer (offline, refused, DNS): say so instead of a stack trace
64
85
  const call = (send) => send().catch((e) => { if (e instanceof UserError) throw e; throw unreachable(root, e) })
65
86
  // the service limits requests per token (HTTP 429, nothing was processed): wait as told and try again
@@ -76,21 +97,25 @@ export function service (config) {
76
97
  }
77
98
  return {
78
99
  async request (method, path, body) {
100
+ orgOf(path)
79
101
  const res = await call(() => limited(() => fetch(base + path, { method, headers: { ...headers, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined })))
80
102
  return check(res, `${method} ${path}`)
81
103
  },
82
104
  /** A request whose answer is not JSON (an export): the response, or an error with the service's message. */
83
105
  async raw (method, path, body) {
106
+ orgOf(path)
84
107
  const res = await call(() => limited(() => fetch(base + path, { method, headers: { ...headers, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined })))
85
- if (!res.ok) { const b = await res.json().catch(() => ({})); throw httpError(`${method} ${path}`, res.status, b.error || res.statusText, opts) }
108
+ if (!res.ok) throw await failed(res, `${method} ${path}`)
86
109
  return res
87
110
  },
88
111
  async fetch (path) {
112
+ orgOf(path)
89
113
  const res = await call(() => limited(() => fetch(base + path, { headers })))
90
- if (!res.ok) { const body = await res.json().catch(() => ({})); throw httpError(`GET ${path}`, res.status, body.error || res.statusText, opts) }
114
+ if (!res.ok) throw await failed(res, `GET ${path}`)
91
115
  return res
92
116
  },
93
117
  async upload (path, data, name, extra = {}) {
118
+ orgOf(path)
94
119
  for (let attempt = 0; ; attempt++) {
95
120
  // a dropped connection (flaky Wi-Fi, CI runner hiccup) is tried again like a 5xx; the next share resumes otherwise
96
121
  let res
@@ -3,7 +3,7 @@
3
3
  // record someone handed over, without a token. The keys come from <service>/.well-known/kaloko-signoff.json.
4
4
  import { readFile, writeFile, mkdir } from 'node:fs/promises'
5
5
  import { join, resolve } from 'node:path'
6
- import { UserError } from './util.mjs'
6
+ import { UserError, UsageError } from './util.mjs'
7
7
  import { serviceUrl } from './service.mjs'
8
8
  import { verifyRecord, recordSha256, extractRecordFromPdf, canonicalJson } from './signoff-core.mjs'
9
9
 
@@ -64,7 +64,7 @@ export async function signoffCmd ({ config, args, say, client = null, runId = nu
64
64
  if (!check.ok) throw new UserError(`The sign-off record in ${args.verify} does not verify: ${check.reason}`)
65
65
  return check
66
66
  }
67
- if (!runId) throw new UserError('Usage: kaloko signoff <run-id> [--out dir] [--lang cs|en] | kaloko signoff --verify <file.json|file.pdf> [--key file|url]')
67
+ if (!runId) throw new UsageError('Usage: kaloko signoff <run-id> [--out dir] [--lang cs|en] | kaloko signoff --verify <file.json|file.pdf> [--key file|url]')
68
68
  if (!client) throw new UserError('Downloading a sign-off record needs the service token (KALOKO_TOKEN).')
69
69
  const base = `/orgs/${config.org}/runs/${encodeURIComponent(runId)}`
70
70
  const lang = args.lang === 'cs' ? 'cs' : 'en'
@@ -3,7 +3,7 @@
3
3
  // interaction states). The spec comes from the service (design reference, draft, a version or a shared run) or from
4
4
  // a local run's captures; drift compares the local implementation run with the reference from the service.
5
5
  import { join } from 'node:path'
6
- import { readJson, UserError } from './util.mjs'
6
+ import { readJson, UserError, UsageError } from './util.mjs'
7
7
  import { readCaptures } from './run.mjs'
8
8
  import { latestCaptures } from './evaluate.mjs'
9
9
  import { specText, specDrift, driftText } from './devspec.mjs'
@@ -31,7 +31,7 @@ export async function specCmd ({ config, client, run, scenario, scenarioId, args
31
31
  // a step state (#46): its captures (keys <viewport>.state-<name>); default otherwise
32
32
  const state = typeof args.state === 'string' ? args.state : null
33
33
  const steps = step ? [step] : (scenario?.nodes || []).filter((n) => n.kind !== 'decision' && n.kind !== 'external').map((n) => n.id)
34
- if (!steps.length) throw new UserError('Usage: kaloko spec <step> [--ref design|draft|vN|<run id>] [--locale l] [--viewport v] [--state s] [--local] [--json]')
34
+ if (!steps.length) throw new UsageError('Usage: kaloko spec <step> [--ref design|draft|vN|<run id>] [--locale l] [--viewport v] [--state s] [--local] [--json]')
35
35
  const out = []
36
36
  for (const s of steps) {
37
37
  if (args.local || !client) {
@@ -66,7 +66,7 @@ export async function driftCmd ({ config, client, run, scenarioId, args, say })
66
66
  if (!client) throw new UserError('kaloko drift compares with the approved design on the service: set org and a token (kaloko login).')
67
67
  const results = []
68
68
  if (args.remote) {
69
- if (!step) throw new UserError('Usage: kaloko drift <step> --remote <run id|latest>')
69
+ if (!step) throw new UsageError('Usage: kaloko drift <step> --remote <run id|latest>')
70
70
  const q = new URLSearchParams(Object.entries({ run: typeof args.remote === 'string' ? args.remote : 'latest', ref, locale, viewport }).filter(([, v]) => v))
71
71
  const d = await client.request('GET', `/orgs/${config.org}/scenarios/${enc(scenarioId)}/steps/${enc(step)}/drift?${q}`)
72
72
  results.push({ node: step, locale: d.locale, viewport: d.viewport, ref: d.ref, findings: d.findings, text: d.text })
@@ -71,7 +71,7 @@ export async function uploadRunFiles ({ client, org, runId, dir, entries, missin
71
71
  const tick = progressLine(write, 'uploaded')
72
72
  await pool([...byHash.values()], workers, async (e) => {
73
73
  const { data, gz } = encode(e.path, await readFile(join(dir, e.path)))
74
- await client.upload(`/orgs/${org}/blobs/${e.hash}`, data, e.path, { 'X-QAwalk-Type': e.ext, ...(gz ? { 'Content-Encoding': 'gzip' } : {}) })
74
+ await client.upload(`/orgs/${org}/blobs/${e.hash}`, data, e.path, { 'X-Kaloko-Type': e.ext, 'X-QAwalk-Type': e.ext, ...(gz ? { 'Content-Encoding': 'gzip' } : {}) })
75
75
  sent += data.length
76
76
  }, () => tick(++done, byHash.size, sent))
77
77
  // 2. the run's file list points at the blobs; a service that does not know references says so by leaving out `blobs`
package/src/lib/util.mjs CHANGED
@@ -65,7 +65,20 @@ export function globToRegExp (glob) {
65
65
  return new RegExp('^' + out + '$')
66
66
  }
67
67
 
68
- export class UserError extends Error {}
68
+ /**
69
+ * An error the person can act on: what happened, then (`next`) what to do about it, in one format for every command:
70
+ * "<what happened> <what to do next>". `reason` and `next` stay apart for callers that show them separately.
71
+ */
72
+ export class UserError extends Error {
73
+ constructor (message, { next = '', ...options } = {}) {
74
+ super([message, next].filter(Boolean).join(' '), options)
75
+ this.reason = message
76
+ this.next = next || ''
77
+ }
78
+ }
79
+
80
+ /** A command used the wrong way (unknown option, missing argument): exit code 2 and the command's help, no support line. */
81
+ export class UsageError extends UserError {}
69
82
 
70
83
  /** Every CLI error ends with a way to a person (docs/HARDENING.md phase 6, item 12). */
71
84
  export const SUPPORT_EMAIL = 'support@sinfin.cz'
@@ -8,7 +8,7 @@ import { resolveRun, addCapture } from './run.mjs'
8
8
  import { loadScenario } from './scenario.mjs'
9
9
  import { buildManifest } from './build.mjs'
10
10
  import { launchEphemeral } from './browser.mjs'
11
- import { readJson, localized, UserError } from './util.mjs'
11
+ import { readJson, localized, UserError, UsageError } from './util.mjs'
12
12
  import { videoTools, encoderCaps, ffmpeg, preset, installHint, outputSize } from './video.mjs'
13
13
  import { formatTrace } from './trace.mjs'
14
14
 
@@ -189,7 +189,7 @@ export async function videoCmd ({ config, root, args, say }) {
189
189
  if (sub === 'trace') {
190
190
  const step = typeof args.step === 'string' ? args.step : null
191
191
  const n = manifest.nodes.find((x) => x.id === step)
192
- if (!n) throw new UserError(`Usage: kaloko video trace --step <id> [--locale l] [--viewport v] [--json]. Steps: ${manifest.nodes.map((x) => x.id).join(', ')}`)
192
+ if (!n) throw new UsageError(`Usage: kaloko video trace --step <id> [--locale l] [--viewport v] [--json]. Steps: ${manifest.nodes.map((x) => x.id).join(', ')}`)
193
193
  const loc = typeof args.locale === 'string' ? args.locale : Object.keys(n.captures || {})[0]
194
194
  const vps = n.captures?.[loc] || {}
195
195
  const vp = typeof args.viewport === 'string' ? args.viewport : Object.keys(vps).find((v) => vps[v]?.trace)