kaloko 0.12.2 → 0.14.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.
@@ -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, EXIT } 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,17 +42,20 @@ 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
50
+ // 401: the token is missing, invalid, expired or revoked (exit code 4); other answers are a failed command (1)
51
+ if (status === 401) { err.exitCode = EXIT.auth; err.code ||= 'unauthorized' }
37
52
  return err
38
53
  }
39
54
 
40
55
  /** A UserError for a request that got no answer at all (offline, DNS, refused, TLS). */
41
56
  export function unreachable (url, e) {
42
57
  const code = e?.cause?.code || e?.code || e?.message || 'no answer'
43
- const err = new UserError(`Cannot reach ${url} (${code}). Check your network or VPN and run the command again; KALOKO_URL points the CLI to another service.`)
58
+ const err = new UserError(`Cannot reach ${url} (${code}). Check your network or VPN and run the command again; KALOKO_URL points the CLI to another service.`, { code: 'unreachable', exitCode: EXIT.unreachable })
44
59
  err.offline = true
45
60
  return err
46
61
  }
@@ -50,16 +65,24 @@ export function service (config) {
50
65
  const base = root + '/api/v1'
51
66
  const tokenEnv = config.share?.token_env || 'KALOKO_TOKEN'
52
67
  const token = process.env[tokenEnv]
53
- 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)
68
+ 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.`, { code: 'no_token', exitCode: EXIT.auth })
69
+ const opts = { tokenEnv, url: root, org: config.org || null }
70
+ // an agent names itself (KALOKO_AGENT, e.g. "claude-code"); the service shows it in presence, history and audit (#42).
71
+ // Both header names: services before the rename read X-QAwalk-Agent only
56
72
  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 } : {}) }
73
+ const agent = process.env.KALOKO_AGENT ? process.env.KALOKO_AGENT.slice(0, 60) : null
74
+ const headers = { Authorization: `Bearer ${token}`, ...(agent ? { 'X-Kaloko-Agent': agent, 'X-QAwalk-Agent': agent } : {}), ...(ci ? { 'X-Kaloko-CI': ci } : {}) }
75
+ // an error answer: its message, and the reason code and billing link the service adds ({ error, code?, billing_url? })
76
+ const failed = async (res, what) => {
77
+ const body = await res.json().catch(() => ({}))
78
+ return httpError(what, res.status, body.error || res.statusText, { ...opts, code: body.code || null, billing: body.billing_url || null })
79
+ }
58
80
  const check = async (res, what) => {
59
81
  if (res.ok) return res.json()
60
- const body = await res.json().catch(() => ({}))
61
- throw httpError(what, res.status, body.error || res.statusText, opts)
82
+ throw await failed(res, what)
62
83
  }
84
+ // no org: in the config would ask for /orgs/undefined/…: say where the slug comes from instead
85
+ const orgOf = (path) => { if (/^\/orgs\/(?:undefined|null)?(?:[/?]|$)/.test(path)) throw new UserError(NO_ORG) }
63
86
  // fetch rejects without an answer (offline, refused, DNS): say so instead of a stack trace
64
87
  const call = (send) => send().catch((e) => { if (e instanceof UserError) throw e; throw unreachable(root, e) })
65
88
  // the service limits requests per token (HTTP 429, nothing was processed): wait as told and try again
@@ -76,26 +99,40 @@ export function service (config) {
76
99
  }
77
100
  return {
78
101
  async request (method, path, body) {
102
+ orgOf(path)
79
103
  const res = await call(() => limited(() => fetch(base + path, { method, headers: { ...headers, ...(body ? { 'Content-Type': 'application/json' } : {}) }, body: body ? JSON.stringify(body) : undefined })))
80
104
  return check(res, `${method} ${path}`)
81
105
  },
106
+ /**
107
+ * One page of a list endpoint: { data, next }. `next` is the cursor of the following page (X-Next-Cursor, or
108
+ * `next` in an object answer), null on the last page and from services that do not page yet.
109
+ */
110
+ async page (path) {
111
+ orgOf(path)
112
+ const res = await call(() => limited(() => fetch(base + path, { headers })))
113
+ const data = await check(res, `GET ${path}`)
114
+ return { data, next: res.headers.get('X-Next-Cursor') || (data && !Array.isArray(data) && typeof data.next === 'string' ? data.next : null) }
115
+ },
82
116
  /** A request whose answer is not JSON (an export): the response, or an error with the service's message. */
83
117
  async raw (method, path, body) {
118
+ orgOf(path)
84
119
  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) }
120
+ if (!res.ok) throw await failed(res, `${method} ${path}`)
86
121
  return res
87
122
  },
88
123
  async fetch (path) {
124
+ orgOf(path)
89
125
  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) }
126
+ if (!res.ok) throw await failed(res, `GET ${path}`)
91
127
  return res
92
128
  },
93
129
  async upload (path, data, name, extra = {}) {
130
+ orgOf(path)
94
131
  for (let attempt = 0; ; attempt++) {
95
132
  // a dropped connection (flaky Wi-Fi, CI runner hiccup) is tried again like a 5xx; the next share resumes otherwise
96
133
  let res
97
134
  try { res = await limited(() => fetch(base + path, { method: 'PUT', headers: { ...headers, ...extra }, body: data })) } catch (e) {
98
- if (attempt >= 3) throw new UserError(`The upload of ${name} broke off (${e.cause?.code || e.message}). Check your network and run kaloko share again: files already stored are not sent twice.`)
135
+ if (attempt >= 3) throw new UserError(`The upload of ${name} broke off (${e.cause?.code || e.message}). Check your network and run kaloko share again: files already stored are not sent twice.`, { code: 'unreachable', exitCode: EXIT.unreachable })
99
136
  await new Promise((r) => setTimeout(r, 1000 * (attempt + 1)))
100
137
  continue
101
138
  }
@@ -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,46 @@ 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 = '', code = null, exitCode = null, ...options } = {}) {
74
+ super([message, next].filter(Boolean).join(' '), options)
75
+ this.reason = message
76
+ this.next = next || ''
77
+ if (code) this.code = code
78
+ if (exitCode) this.exitCode = exitCode
79
+ }
80
+ }
81
+
82
+ /** A command used the wrong way (unknown option, missing argument): exit code 2 and the command's help, no support line. */
83
+ export class UsageError extends UserError {
84
+ constructor (message, options = {}) { super(message, { code: 'usage', exitCode: EXIT.usage, ...options }) }
85
+ }
86
+
87
+ /**
88
+ * Exit codes of every command (kaloko help, help/reference/cli.md, skills/kaloko/reference/ci.md). 3 and 4 are only
89
+ * for the Kaloko service: no connection at all (3), and no, an invalid or an expired token (4). An HTTP error answer
90
+ * other than 401 is 1, like any failed command.
91
+ */
92
+ export const EXIT = { ok: 0, failed: 1, usage: 2, unreachable: 3, auth: 4 }
93
+
94
+ const UNIT_SECONDS = { s: 1, m: 60, h: 3600, d: 86400 }
95
+ const UNIT_NAMES = { s: 'seconds', m: 'minutes', h: 'hours', d: 'days' }
96
+
97
+ /**
98
+ * A duration option (--timeout, --wait, --expires, --older-than): a bare number in the option's own `unit` (what it
99
+ * always meant), or a number with s, m, h or d (90s, 10m, 2h, 14d). Returns the value in `unit`; null when the option
100
+ * is absent or has no value; a UsageError naming `flag` for anything else.
101
+ */
102
+ export function parseDuration (value, unit, flag) {
103
+ if (value == null || value === true || value === '') return null
104
+ const m = String(value).trim().match(/^(\d+(?:\.\d+)?)\s*([smhd])?$/i)
105
+ if (!m) throw new UsageError(`--${flag} takes a duration: a number of ${UNIT_NAMES[unit]}, or one with s, m, h or d (90s, 10m, 2h, 14d); not "${value}".`)
106
+ return Number(m[1]) * UNIT_SECONDS[(m[2] || unit).toLowerCase()] / UNIT_SECONDS[unit]
107
+ }
69
108
 
70
109
  /** Every CLI error ends with a way to a person (docs/HARDENING.md phase 6, item 12). */
71
110
  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, parseDuration } from './util.mjs'
12
12
  import { videoTools, encoderCaps, ffmpeg, preset, installHint, outputSize } from './video.mjs'
13
13
  import { formatTrace } from './trace.mjs'
14
14
 
@@ -115,7 +115,7 @@ export async function exportWalkthrough ({ config, root, args, say }) {
115
115
  const P = preset(args.quality || 'medium')
116
116
  const fps = format === 'gif' ? 10 : P.fps
117
117
  const [width, height] = format === 'gif' ? outputSize(1280, 800, 800) : outputSize(1280, 800, Math.min(P.width, 1280))
118
- const card = Math.max(0.5, Math.min(6, Number(args['card-seconds']) || 1.6))
118
+ const card = Math.max(0.5, Math.min(6, parseDuration(args['card-seconds'], 's', 'card-seconds') || 1.6))
119
119
  const browser = await launchEphemeral(config)
120
120
  const tmp = await mkdtemp(join(tmpdir(), 'kaloko-walkthrough-'))
121
121
  const defaultOut = join(run.dir, 'attachments', `walkthrough.rec.${format}`)
@@ -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)