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.
@@ -10,15 +10,18 @@ const PARAMS = {
10
10
  locale: /^[A-Za-z0-9_-]{1,20}$/,
11
11
  viewport: /^[A-Za-z0-9._-]{1,80}$/,
12
12
  version: /^[A-Za-z0-9._+-]{1,80}$/,
13
- state: /^[A-Za-z0-9._-]{1,60}$/
13
+ state: /^[A-Za-z0-9._-]{1,60}$/,
14
+ media: /^(video|image)$/
14
15
  }
15
16
  export const REF_SCHEME = 'kaloko:'
16
17
 
17
18
  /**
18
- * A step reference `kaloko:<scenario>/<step>[?locale=&viewport=&version=&state=]`, or the short `kaloko:<step>` that
19
- * means a step of the product's default scenario. `version` is a released version, `latest` (default) or `prev` (the
20
- * release before the one the text belongs to); `state` picks a variant of the capture (dark, webkit, reduce: the
21
- * dimension values in the capture key). Returns null for anything else.
19
+ * A step reference `kaloko:<scenario>/<step>[?locale=&viewport=&version=&state=&media=]`, or the short `kaloko:<step>`
20
+ * that means a step of the product's default scenario. `version` is a released version, `latest` (default) or `prev`
21
+ * (the release before the one the text belongs to); `state` picks a variant of the capture (dark, webkit, reduce: the
22
+ * dimension values in the capture key); `media` is `video` (play the step's recording; docs check warns when the
23
+ * accepted run has none) or `image` (never play it). Without `media` the recording plays when the run has one.
24
+ * Returns null for anything else.
22
25
  */
23
26
  export function parseStepRef (ref) {
24
27
  const s = String(ref || '').trim()
@@ -29,7 +32,7 @@ export function parseStepRef (ref) {
29
32
  let parts
30
33
  try { parts = path.split('/').map((p) => decodeURIComponent(p)) } catch { return null }
31
34
  if (!parts.length || parts.length > 2 || parts.some((p) => !ID.test(p))) return null
32
- const out = { scenario: parts.length === 2 ? parts[0] : null, step: parts[parts.length - 1], locale: null, viewport: null, version: null, state: null }
35
+ const out = { scenario: parts.length === 2 ? parts[0] : null, step: parts[parts.length - 1], locale: null, viewport: null, version: null, state: null, media: null }
33
36
  const q = new URLSearchParams(qi < 0 ? '' : rest.slice(qi + 1))
34
37
  for (const [k, v] of q) {
35
38
  if (!(k in PARAMS)) return null
@@ -41,9 +44,9 @@ export function parseStepRef (ref) {
41
44
  }
42
45
 
43
46
  /** The reference text of a parsed (or hand-built) reference; parameters in a fixed order. */
44
- export function formatStepRef ({ scenario = null, step, locale = null, viewport = null, version = null, state = null }) {
47
+ export function formatStepRef ({ scenario = null, step, locale = null, viewport = null, version = null, state = null, media = null }) {
45
48
  const path = `${scenario ? `${encodeURIComponent(scenario)}/` : ''}${encodeURIComponent(step)}`
46
- const q = Object.entries({ locale, viewport, version, state }).filter(([, v]) => v != null && v !== '')
49
+ const q = Object.entries({ locale, viewport, version, state, media }).filter(([, v]) => v != null && v !== '')
47
50
  return `${REF_SCHEME}${path}${q.length ? `?${q.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')}` : ''}`
48
51
  }
49
52
 
package/src/lib/ci.mjs CHANGED
@@ -103,7 +103,7 @@ function walkScript ({ scenarios, env, affected, k }) {
103
103
  return [
104
104
  'status=0',
105
105
  `for s in ${scenarios.map(q).join(' ')}; do`,
106
- ...(affected ? [` if ! ${k} affected --scenario "$s" --check; then echo "Nothing in $s is affected; skipped."; continue; fi`] : []),
106
+ ...(affected ? [` if ! ${k} affected --scenario "$s" --fail-if-none; then echo "Nothing in $s is affected; skipped."; continue; fi`] : []),
107
107
  ` ${k} start --scenario "$s" --env ${env}`,
108
108
  ` ${k} walk --ephemeral${affected ? ' --affected' : ''} || status=1`,
109
109
  ` ${k} share --pr || status=1`,
@@ -0,0 +1,144 @@
1
+ // The command line of kaloko: arguments, the help of one command and the options each command takes. Both come from
2
+ // the help text (src/cli.mjs HELP), so a documented option is an accepted one; HIDDEN adds aliases and options kept
3
+ // for older scripts that the help no longer shows.
4
+ import { UsageError } from './util.mjs'
5
+
6
+ /** Options every command takes (Common options in the help). */
7
+ export const COMMON_OPTIONS = ['run', 'config', 'org', 'json', 'help']
8
+
9
+ /**
10
+ * Accepted options the help does not show: aliases of renamed ones (--environment is --env, --undo is --off) and
11
+ * options of sub-commands or older scripts. Each name here is read by the command's code.
12
+ */
13
+ export const HIDDEN = {
14
+ prune: ['environment', 'project'],
15
+ runs: ['environment'],
16
+ screens: ['environment', 'q', 'source', 'limit'],
17
+ archive: ['undo'],
18
+ start: ['note', 'color_schemes', 'reduced_motion', 'forced_colors', 'display_modes', 'token_modes'],
19
+ design: ['dir', 'token', 'name', 'notes', 'branch', 'allow-pii', 'env'],
20
+ export: ['server-pdf', 'branch', 'live', 'design'],
21
+ walk: ['items', 'viewports', 'no-cache', 'record-quality', 'trace', 'offline', 'files'],
22
+ affected: ['no-upstream', 'check'],
23
+ open: ['page'],
24
+ mail: ['viewports'],
25
+ issue: ['locale', 'viewport', 'target', 'state'],
26
+ video: ['quality', 'card-seconds', 'viewport'],
27
+ spec: ['remote', 'scenario', 'version', 'step'],
28
+ drift: ['locale', 'viewport', 'version', 'scenario', 'step'],
29
+ history: ['lang'],
30
+ tokens: ['env', 'version'],
31
+ stability: ['scenario'],
32
+ status: ['branch'],
33
+ feedback: ['branch'],
34
+ share: ['branch', 'name', 'notes', 'notes-preview', 'strict', 'mine', 'theirs', 'lang'],
35
+ sync: ['mine', 'theirs', 'branch', 'strict'],
36
+ reference: ['branch'],
37
+ branch: ['from', 'git', 'take-branch', 'keep-main', 'scenario'],
38
+ schedule: ['force'],
39
+ comment: ['locale'],
40
+ capture: ['stability'],
41
+ import: ['executor', 'config', 'locales', 'viewports', 'id', 'title', 'force'],
42
+ browser: ['env', 'run'],
43
+ ci: ['wait', 'github-env', 'dotenv']
44
+ }
45
+
46
+ /** Commands whose unknown options stop the command instead of a warning: they delete, publish or change access. */
47
+ export const STRICT = new Set(['prune', 'trash', 'restore', 'keep', 'archive', 'public', 'release', 'comment'])
48
+
49
+ /**
50
+ * argv → { _: positional, <option>: value | true }. `--key=value` splits at the first `=` only; `--key value` takes
51
+ * the next word unless it is another option; `-h` and `-v` are --help and --version.
52
+ */
53
+ export function parseArgs (argv) {
54
+ const args = { _: [] }
55
+ const short = { '-h': 'help', '-v': 'version' }
56
+ for (let i = 0; i < argv.length; i++) {
57
+ const a = argv[i]
58
+ if (a.startsWith('--')) {
59
+ const eq = a.indexOf('=')
60
+ const k = eq < 0 ? a.slice(2) : a.slice(2, eq)
61
+ const next = argv[i + 1]
62
+ if (eq >= 0) args[k] = a.slice(eq + 1)
63
+ else if (next && !next.startsWith('--') && !short[next]) args[k] = argv[++i]
64
+ else args[k] = true
65
+ } else if (short[a]) args[short[a]] = true
66
+ else args._.push(a)
67
+ }
68
+ return args
69
+ }
70
+
71
+ /**
72
+ * The help text as entries: each " <command> …" line with its continuation lines, the commands it documents (the
73
+ * first word, and words after " | " such as "trash … | restore") and its section heading.
74
+ */
75
+ export function helpEntries (help) {
76
+ const out = []
77
+ let section = ''
78
+ let cur = null
79
+ for (const line of help.split('\n')) {
80
+ if (/^[A-Z]/.test(line)) { section = line; cur = null; continue }
81
+ const m = line.match(/^ {2}([a-z][a-z-]*)/)
82
+ if (m) {
83
+ cur = { section, commands: new Set([m[1], ...[...line.matchAll(/ \| ([a-z][a-z-]*)/g)].map((x) => x[1])]), lines: [line] }
84
+ out.push(cur)
85
+ } else if (cur && /^ {3,}\S/.test(line)) cur.lines.push(line)
86
+ else cur = null
87
+ }
88
+ return out
89
+ }
90
+
91
+ /**
92
+ * The help of one command: its entries under their section headings, then `common`; null for an unknown one. With
93
+ * `sub` (kaloko help export run, kaloko export run --help) only the entries of that sub-command, when it has any.
94
+ */
95
+ export function helpFor (help, name, common = '', sub = null) {
96
+ let entries = helpEntries(help).filter((e) => e.commands.has(name))
97
+ if (!entries.length) return null
98
+ const own = typeof sub === 'string' ? entries.filter((e) => (e.lines[0].match(new RegExp(`^ {2}${name} ([a-z][a-z|-]*)`))?.[1] || '').split('|').includes(sub)) : []
99
+ if (own.length) { entries = own; name = `${name} ${sub}` }
100
+ const parts = []
101
+ let section = null
102
+ for (const e of entries) {
103
+ if (e.section !== section) { section = e.section; parts.push(`${parts.length ? '\n' : ''}${section}`) }
104
+ parts.push(...e.lines)
105
+ }
106
+ return `Usage: kaloko ${name} [options]\n\n${parts.join('\n')}\n${common ? `\n${common}` : ''}`
107
+ }
108
+
109
+ /** The options a command takes: the ones its help names, the hidden ones and the common ones. */
110
+ export function optionsFor (help, name) {
111
+ const named = helpEntries(help).filter((e) => e.commands.has(name)).flatMap((e) => e.lines.join('\n').match(/--[a-z][a-z0-9-]*/g) || [])
112
+ return new Set([...named.map((f) => f.slice(2)), ...(HIDDEN[name] || []), ...COMMON_OPTIONS])
113
+ }
114
+
115
+ /** Edit distance (insertions, deletions, substitutions) between two short words. */
116
+ export function distance (a, b) {
117
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j)
118
+ for (let i = 1; i <= a.length; i++) {
119
+ const row = [i]
120
+ for (let j = 1; j <= b.length; j++) row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
121
+ prev = row
122
+ }
123
+ return prev[b.length]
124
+ }
125
+
126
+ /** The closest of `words` to `word` within 2 edits (a typo), or null. */
127
+ export function closest (word, words) {
128
+ let best = null; let d = 3
129
+ for (const w of words) { const x = distance(word, w); if (x < d) { d = x; best = w } }
130
+ return best
131
+ }
132
+
133
+ /**
134
+ * Options `args` has that `name` does not take: a UsageError for commands in STRICT (nothing has happened yet), a
135
+ * list of warning lines for the others (they run as before, without the unknown option).
136
+ */
137
+ export function checkOptions (help, name, args) {
138
+ const known = optionsFor(help, name)
139
+ const unknown = Object.keys(args).filter((k) => k !== '_' && k !== '' && !known.has(k))
140
+ if (!unknown.length) return []
141
+ const lines = unknown.map((k) => { const guess = closest(k, [...known]); return `kaloko ${name} does not take --${k}.${guess ? ` Did you mean --${guess}?` : ''}` })
142
+ if (STRICT.has(name)) throw new UsageError(`${lines.join('\n')}\nNothing was changed. kaloko help ${name} lists its options.`)
143
+ return lines.map((l) => `! ${l} It is ignored (kaloko help ${name} lists the options).`)
144
+ }
@@ -1,9 +1,10 @@
1
1
  import { readFile, access } from 'node:fs/promises'
2
- import { join, dirname, resolve } from 'node:path'
2
+ import { join, dirname, resolve, basename } from 'node:path'
3
3
  import { homedir } from 'node:os'
4
4
  import yaml from 'js-yaml'
5
5
  import { schemaErrors } from './schema.mjs'
6
6
  import { UserError, localized, globToRegExp } from './util.mjs'
7
+ import { noteLegacy, legacyVariables } from './legacy.mjs'
7
8
 
8
9
  export const CONFIG_FILE = 'kaloko.config.yml'
9
10
  /** The file name before the rename to Kaloko; still found, so existing projects keep working. */
@@ -17,8 +18,13 @@ export function aliasLegacyEnv (vars = process.env) {
17
18
  if (m && vars[`${m[1] === 'QAWALK' ? 'KALOKO' : 'QAWALK'}_${m[2]}`] == null) vars[`${m[1] === 'QAWALK' ? 'KALOKO' : 'QAWALK'}_${m[2]}`] = v
18
19
  }
19
20
  }
21
+ // the variables as the shell gave them, before aliasing: QAWALK_* names someone still sets (kaloko doctor, legacy.mjs)
22
+ const shellVars = { ...process.env }
20
23
  aliasLegacyEnv()
21
24
 
25
+ /** QAWALK_* variables set in the shell (not copies of KALOKO_* ones). */
26
+ export const legacyShellVariables = () => legacyVariables(shellVars)
27
+
22
28
  async function exists (file) {
23
29
  try { await access(file); return true } catch { return false }
24
30
  }
@@ -37,13 +43,17 @@ export async function loadConfig (start = process.cwd(), explicit) {
37
43
  }
38
44
  }
39
45
  if (!file) throw new UserError(`${CONFIG_FILE} not found. Run "kaloko init" in the project root.`)
46
+ if (basename(file) === LEGACY_CONFIG_FILE) noteLegacy(LEGACY_CONFIG_FILE, CONFIG_FILE)
47
+ for (const k of legacyShellVariables()) noteLegacy(k, `KALOKO_${k.slice(7)}`)
40
48
  // the user-level file holds personal keys and tokens: tests (node --test) and KALOKO_USER_ENV=0 leave it out
41
49
  const userEnv = process.env.KALOKO_USER_ENV !== '0' && !process.env.NODE_TEST_CONTEXT
50
+ if (userEnv && await exists(join(homedir(), '.config', 'qawalk', '.env'))) noteLegacy('~/.config/qawalk/.env', '~/.config/kaloko/.env')
42
51
  await loadEnvFiles([join(dirname(file), '.env'), ...(userEnv ? [join(homedir(), '.config', 'kaloko', '.env'), join(homedir(), '.config', 'qawalk', '.env')] : [])])
43
52
  aliasLegacyEnv()
44
53
  const config = yaml.load(await readFile(file, 'utf8'))
45
54
  const problems = schemaErrors('config.v1', config)
46
55
  if (problems.length) throw new UserError(`${file} is invalid:\n - ${problems.join('\n - ')}`)
56
+ aliasConfigKeys(config, file)
47
57
  // projects started before the rename keep their run folder (it is in their .gitignore)
48
58
  config.output ||= !(await exists(join(dirname(file), 'tmp', 'kaloko'))) && (await exists(join(dirname(file), 'tmp', 'qawalk'))) ? 'tmp/qawalk' : 'tmp/kaloko'
49
59
  config.browser ||= {}
@@ -53,6 +63,19 @@ export async function loadConfig (start = process.cwd(), explicit) {
53
63
  return { config, root: dirname(file), file }
54
64
  }
55
65
 
66
+ /**
67
+ * Config keys with a newer name: environments.<env>.default_token_mode is read as tokens_mode (its older name, which
68
+ * the code reads and which keeps working). Both with different values is a mistake worth stopping for.
69
+ */
70
+ export function aliasConfigKeys (config, file = CONFIG_FILE) {
71
+ for (const [name, env] of Object.entries(config.environments || {})) {
72
+ if (env?.default_token_mode == null) continue
73
+ if (env.tokens_mode != null && env.tokens_mode !== env.default_token_mode) throw new UserError(`${file}: environments.${name} sets default_token_mode: ${env.default_token_mode} and tokens_mode: ${env.tokens_mode}. tokens_mode is the older name of default_token_mode; keep one.`)
74
+ env.tokens_mode = env.default_token_mode
75
+ }
76
+ return config
77
+ }
78
+
56
79
  export function environment (config, name, vars = process.env) {
57
80
  const env = config.environments[name]
58
81
  if (!env) throw new UserError(`Unknown environment "${name}". Known: ${Object.keys(config.environments).join(', ')}`)
@@ -116,6 +139,7 @@ export async function loadEnvFiles (files) {
116
139
  try { text = await readFile(file, 'utf8') } catch { continue }
117
140
  for (const line of text.split('\n')) {
118
141
  const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/)
142
+ if (m && /^QAWALK_[A-Z0-9_]+$/.test(m[1])) noteLegacy(m[1], `KALOKO_${m[1].slice(7)}`)
119
143
  if (!m || process.env[m[1]] !== undefined) continue
120
144
  process.env[m[1]] = m[2].replace(/^(['"])(.*)\1$/, '$2')
121
145
  }
@@ -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 = []
@@ -18,6 +18,17 @@ export function assetFile (pic) {
18
18
  return `assets/${safe(pic.scenario)}--${safe(pic.step?.id || pic.step)}--${safe(pic.locale)}--${safe(pic.viewport)}--${safe(String(pic.run?.id || pic.run || '').split('-').pop())}.${ext}`
19
19
  }
20
20
 
21
+ /**
22
+ * The files of a step's recording inside an export, next to its picture (assetFile with .mp4 / .webm), or null when
23
+ * the resolved reference carries no recording ({ video: { files: { mp4, webm } } }).
24
+ */
25
+ export function videoAssets (pic) {
26
+ const files = pic?.video?.files
27
+ if (!files || !(files.mp4 || files.webm)) return null
28
+ const base = assetFile(pic).replace(/\.[a-z]+$/, '')
29
+ return { mp4: files.mp4 ? `${base}.mp4` : null, webm: files.webm ? `${base}.webm` : null }
30
+ }
31
+
21
32
  /** The key of a picture in a bundle: the reference, in the context of a release for changelog entries (prev). */
22
33
  export const pictureKey = (ref, context = null) => `${context || ''}|${ref}`
23
34
 
@@ -37,14 +48,33 @@ export function relativePath (from, to) {
37
48
  }
38
49
 
39
50
  /**
40
- * Markdown with its step references replaced by files of the export: `![alt](kaloko:…)` → `![alt](../assets/…)`.
41
- * A reference without a picture becomes its alt text in italics, so the text still reads. Links to other pages are
42
- * rewritten by `link(href)` when given.
51
+ * A step's recording in exported Markdown (`p.export_video`): a player with the picture as its poster. Docusaurus
52
+ * gets JSX whose require() bundles the files; VitePress HTML with ./ paths (Vue turns them into imports); MkDocs, whose
53
+ * page addresses are folders (raw HTML paths would miss), the picture linked to the recording; plain Markdown the
54
+ * player with that linked picture inside, which renderers without <video> (GitHub) show instead.
55
+ */
56
+ function videoMarkdown (p, alt, { file, root, flavor }) {
57
+ const rel = (f) => { const r = relativePath(file, `${root}/${f}`); return flavor === 'vitepress' && !r.startsWith('.') ? `./${r}` : r }
58
+ const poster = rel(p.export_file); const v = p.export_video
59
+ const linked = `[![${alt}](${poster})](${rel(v.mp4 || v.webm)})`
60
+ if (flavor === 'mkdocs') return linked
61
+ if (flavor === 'docusaurus') {
62
+ const req = (f) => `{require('${rel(f).replace(/'/g, "\\'")}').default}`
63
+ return `<video controls muted playsInline preload="none" poster=${req(p.export_file)} title=${JSON.stringify(alt)}>${v.mp4 ? `<source src=${req(v.mp4)} type="video/mp4" />` : ''}${v.webm ? `<source src=${req(v.webm)} type="video/webm" />` : ''}</video>`
64
+ }
65
+ return `<video controls muted playsinline preload="none" poster="${esc(poster)}" title="${esc(alt)}">${v.mp4 ? `<source src="${esc(rel(v.mp4))}" type="video/mp4">` : ''}${v.webm ? `<source src="${esc(rel(v.webm))}" type="video/webm">` : ''}${flavor === 'md' ? linked : ''}</video>`
66
+ }
67
+
68
+ /**
69
+ * Markdown with its step references replaced by files of the export: `![alt](kaloko:…)` → `![alt](../assets/…)`, or
70
+ * a player for a step whose recording plays (videoMarkdown). A reference without a picture becomes its alt text in
71
+ * italics, so the text still reads. Links to other pages are rewritten by `link(href)` when given.
43
72
  */
44
- export function localizeMarkdown (md, { file, pictures, context = null, root = 'docs', link = null }) {
73
+ export function localizeMarkdown (md, { file, pictures, context = null, root = 'docs', link = null, flavor = 'md' }) {
45
74
  let out = String(md || '').replace(/!\[([^\]]*)\]\((kaloko:[^)\s]+)\)/g, (_, alt, ref) => {
46
75
  const p = pictures.get(pictureKey(ref, context))
47
76
  if (!p?.ok) return `*${alt || 'picture'}*`
77
+ if (p.export_video) return videoMarkdown(p, alt || p.alt || '', { file, root, flavor })
48
78
  return `![${alt || p.alt || ''}](${relativePath(file, `${root}/${p.export_file}`)})`
49
79
  })
50
80
  if (link) out = out.replace(/(?<!!)\[([^\]]+)\]\(([^)\s]+)\)/g, (m, t, href) => { const x = link(href); return x == null ? m : `[${t}](${x})` })
@@ -61,9 +91,9 @@ export function bundleRefs (bundle) {
61
91
  }
62
92
 
63
93
  const productTitle = (b) => typeof b.product?.name === 'string' ? b.product.name : b.product?.slug || 'Docs'
64
- const changelogMarkdown = (b, file, root) => {
94
+ const changelogMarkdown = (b, file, root, flavor) => {
65
95
  const head = `# ${b.lang === 'cs' ? 'Co je nového' : 'Changelog'}: ${productTitle(b)}\n`
66
- return `${head}\n${(b.releases || []).map((r) => `## [${r.version}] - ${r.released_on}\n\n${localizeMarkdown(String(r.markdown || '').replace(/<!--[\s\S]*?-->/g, '').trim(), { file, pictures: b.pictureMap, context: r.version, root })}\n`).join('\n')}`
96
+ return `${head}\n${(b.releases || []).map((r) => `## [${r.version}] - ${r.released_on}\n\n${localizeMarkdown(String(r.markdown || '').replace(/<!--[\s\S]*?-->/g, '').trim(), { file, pictures: b.pictureMap, context: r.version, root, flavor })}\n`).join('\n')}`
67
97
  }
68
98
 
69
99
  /**
@@ -86,7 +116,7 @@ export function markdownExport (bundle, flavor = 'md') {
86
116
  const file = fileOf(p.path)
87
117
  const link = (href) => { const t = docLink(p.path, href, { isIndex: folders.has(p.path) || p.path === '' }); return t && pages.some((x) => x.path === t.path) ? `${relativePath(file, fileOf(t.path))}${t.hash ? `#${t.hash}` : ''}` : null }
88
118
  const { data, body } = frontMatter(p.md)
89
- let text = localizeMarkdown(body, { file, pictures: b.pictureMap, link })
119
+ let text = localizeMarkdown(body, { file, pictures: b.pictureMap, link, flavor })
90
120
  const pos = navOrder(nav).indexOf(p.path) + 1
91
121
  if (flavor === 'docusaurus') text = `---\ntitle: ${yamlString(p.title)}\nsidebar_position: ${data.sidebar_position ?? data.order ?? pos}\n${p.path === '' ? 'slug: /\n' : ''}---\n\n${text.trim()}\n`
92
122
  else if (flavor === 'vitepress' || flavor === 'mkdocs') text = `---\ntitle: ${yamlString(p.title)}\n---\n\n${text.trim()}\n`
@@ -96,7 +126,7 @@ export function markdownExport (bundle, flavor = 'md') {
96
126
  const hasChangelog = (b.releases || []).length > 0
97
127
  if (hasChangelog) {
98
128
  const file = flavor === 'md' ? 'CHANGELOG.md' : 'docs/changelog.md'
99
- let text = changelogMarkdown(b, file, 'docs')
129
+ let text = changelogMarkdown(b, file, 'docs', flavor)
100
130
  if (flavor === 'docusaurus') text = `---\ntitle: ${yamlString(b.lang === 'cs' ? 'Co je nového' : 'Changelog')}\nsidebar_position: 1000\n---\n\n${text}`
101
131
  files.push({ name: file, text })
102
132
  }
@@ -139,7 +169,7 @@ a{color:inherit}.wrap{display:grid;grid-template-columns:240px minmax(0,1fr);gap
139
169
  nav.side{font-size:14px;position:sticky;top:16px;align-self:start;max-height:calc(100vh - 32px);overflow:auto}nav.side ul{list-style:none;margin:0 0 8px;padding:0}nav.side ul ul{padding-left:12px}nav.side li{margin:2px 0}
140
170
  nav.side a{text-decoration:none;display:block;padding:3px 8px;border-radius:6px}nav.side a[aria-current]{background:var(--accent);color:#151514;font-weight:600}nav.side .grp{font-weight:700;margin-top:10px;padding:3px 8px}
141
171
  main{min-width:0;max-width:760px}h1{font-size:30px;line-height:1.2;letter-spacing:-.02em}h2{margin-top:2em}img{max-width:100%;height:auto}
142
- figure{margin:12px 0}figure img{display:block;border:1px solid var(--line);border-radius:10px}figcaption{font-size:13px;color:var(--muted);margin-top:4px}
172
+ figure{margin:12px 0}figure video{display:block;max-width:100%;height:auto;border:1px solid var(--line);border-radius:10px;background:#111}figure img{display:block;border:1px solid var(--line);border-radius:10px}figcaption{font-size:13px;color:var(--muted);margin-top:4px}
143
173
  pre{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px;overflow:auto}code{font-family:ui-monospace,Menlo,monospace;font-size:.92em}
144
174
  table{border-collapse:collapse}td,th{border:1px solid var(--line);padding:4px 8px}.pager{display:flex;justify-content:space-between;gap:12px;margin-top:40px;font-size:14px}.foot{color:var(--muted);font-size:12.5px;margin-top:40px}
145
175
  .release{border-top:1px solid var(--line);margin-top:28px;padding-top:8px}.missing{color:var(--muted);font-style:italic}
@@ -153,6 +183,10 @@ function htmlPictures (imgs, { file, pictures, context }) {
153
183
  const p = i.ref ? pictures.get(pictureKey(i.src, context)) : null
154
184
  if (!i.ref) return /^https?:\/\//.test(i.src) ? `<p><a href="${esc(i.src)}" rel="noopener">${esc(i.alt || i.src)}</a></p>` : ''
155
185
  if (!p?.ok) return `<p class="missing">${esc(i.alt || i.src)}</p>`
186
+ // a step's recording: the player with the picture as poster; it plays when the reader starts it
187
+ // print.html (the CLI's PDF is printed from it) keeps the picture: a PDF cannot play anything
188
+ const v = file === 'print.html' ? null : p.export_video
189
+ if (v) return `<figure><video controls muted playsinline preload="none" poster="${esc(relativePath(file, p.export_file))}" title="${esc(i.alt || p.alt || '')}">${v.mp4 ? `<source src="${esc(relativePath(file, v.mp4))}" type="video/mp4">` : ''}${v.webm ? `<source src="${esc(relativePath(file, v.webm))}" type="video/webm">` : ''}<img src="${esc(relativePath(file, p.export_file))}" alt="${esc(i.alt || p.alt || '')}"></video><figcaption>${esc(i.alt || p.step?.title || '')} · ${esc(p.locale)} · ${esc(p.viewport)}</figcaption></figure>`
156
190
  return `<figure><img src="${esc(relativePath(file, p.export_file))}" alt="${esc(i.alt || p.alt || '')}" loading="lazy"><figcaption>${esc(i.alt || p.step?.title || '')} · ${esc(p.locale)} · ${esc(p.viewport)}</figcaption></figure>`
157
191
  }).join('')
158
192
  }
@@ -212,7 +246,7 @@ export function changelogJson (bundle) {
212
246
  const pic = (ref, context) => {
213
247
  const p = b.pictureMap.get(pictureKey(ref, context))
214
248
  if (!p?.ok) return { ref, ok: false }
215
- return { ref, ok: true, scenario: p.scenario, step: p.step?.id || p.step, title: p.step?.title || null, locale: p.locale, viewport: p.viewport, run: p.run?.id || null, version: p.version?.resolved || null, image: p.image?.url || null, canvas: p.canvas?.step || null }
249
+ return { ref, ok: true, scenario: p.scenario, step: p.step?.id || p.step, title: p.step?.title || null, locale: p.locale, viewport: p.viewport, run: p.run?.id || null, version: p.version?.resolved || null, image: p.image?.url || null, ...(p.video ? { video: { mp4: p.video.mp4 || null, webm: p.video.webm || null, duration_ms: p.video.duration_ms ?? null } } : {}), canvas: p.canvas?.step || null }
216
250
  }
217
251
  return {
218
252
  schema: 'qawalk.changelog.v1',
package/src/lib/docs.mjs CHANGED
@@ -111,11 +111,11 @@ export async function docsCheck ({ root, config, product, client = null, scenari
111
111
  try { service = await client.request('GET', `/orgs/${config.org}/docs/check?${new URLSearchParams({ product: product.slug, version })}`) } catch (e) { serviceError = String(e.message || e).split('\n')[0].slice(0, 200) }
112
112
  }
113
113
  const errors = local.broken.length + local.unknown.length + (service ? service.stale.length + service.unresolved.length : 0)
114
- const warnings = local.translations.missing.length + local.translations.outdated.length + local.changelog_without_pictures.length
114
+ const warnings = local.translations.missing.length + local.translations.outdated.length + local.changelog_without_pictures.length + (service?.no_recording?.length || 0)
115
115
  return {
116
116
  product: product.slug, version, dir: relative(root, local.dir) || '.', pages: local.pages.length, languages: local.languages,
117
117
  broken: local.broken, unknown: local.unknown, translations: local.translations, changelog_without_pictures: local.changelog_without_pictures,
118
- service: service ? { published: service.published, published_at: service.published_at || null, stale: service.stale, unresolved: service.unresolved, stale_checked: service.stale_checked !== false, url: service.url } : null,
118
+ service: service ? { published: service.published, published_at: service.published_at || null, stale: service.stale, unresolved: service.unresolved, no_recording: service.no_recording || [], stale_checked: service.stale_checked !== false, url: service.url } : null,
119
119
  service_error: serviceError, errors, warnings, ok: errors === 0
120
120
  }
121
121
  }
@@ -129,6 +129,7 @@ export function formatCheck (r) {
129
129
  if (r.service) {
130
130
  list('✗ stale sections (the screen changed since the text was published)', r.service.stale, (x) => `${x.path || 'index'}${x.lang ? ` (${x.lang})` : ''} § ${x.title || 'intro'}: the screen changed${x.refs[0]?.changed_in ? ` in ${x.refs[0].changed_in}` : ''} — check the text (${x.refs.map((y) => y.ref).join(', ')})`)
131
131
  list('✗ references without an accepted picture', r.service.unresolved, (x) => `${x.path || 'index'}${x.lang ? ` (${x.lang})` : ''}: ${x.ref}`)
132
+ list('! media=video, but the accepted run has no recording of the step (readers see the picture; walk it with --record)', r.service.no_recording || [], (x) => `${x.release ? `changelog ${x.release}` : `${x.path || 'index'}${x.lang ? ` (${x.lang})` : ''}`}: ${x.ref}${x.run ? ` (run ${x.run})` : ''}`)
132
133
  if (!r.service.published) lines.push(' · nothing published yet: kaloko docs publish')
133
134
  else if (!r.service.stale_checked) lines.push(' · stale sections are checked on the Business plan')
134
135
  } else lines.push(` · ${r.service_error ? `service: ${r.service_error}` : 'offline: stale sections and pictures need a token'}`)
@@ -44,7 +44,7 @@ async function reach (url, fetchImpl, ms = 6000, headers = {}) {
44
44
  * scenario folders, write org: from the token into the config, ask macOS for permissions
45
45
  * @param {Function} [o.installBrowser] async (engine) => void — installs a Playwright engine (--fix)
46
46
  */
47
- export async function doctor ({ config, root, file = null, fetchImpl = fetch, launchChrome = defaultLaunch, env = process.env, fix = false, engines = null, launchEngine = defaultEngine, tools = null, os = process.platform, videoToolsImpl = null, scenarios = [], installBrowser = defaultInstall }) {
47
+ export async function doctor ({ config, root, file = null, fetchImpl = fetch, launchChrome = defaultLaunch, env = process.env, fix = false, engines = null, launchEngine = defaultEngine, tools = null, os = process.platform, videoToolsImpl = null, scenarios = [], installBrowser = defaultInstall, legacy = [] }) {
48
48
  const checks = []
49
49
  const add = (name, ok, detail = '', hint = '', fixed = null) => checks.push({ name, ok, detail, hint, ...(fixed ? { fixed } : {}) })
50
50
  // --fix: install an engine, then look again; returns the version or throws what the install said
@@ -54,6 +54,8 @@ export async function doctor ({ config, root, file = null, fetchImpl = fetch, la
54
54
  add('node', major >= 20, `Node ${process.versions.node}`, major >= 20 ? '' : 'Kaloko needs Node 20 or newer.')
55
55
 
56
56
  add('config', true, `${file || `${root}/kaloko.config.yml`} · org ${config.org || '—'} · ${Object.keys(config.environments || {}).length} environments`)
57
+ // names from before the rename that still work (legacy.mjs): listed with their new names, never a failure
58
+ if (legacy.length) add('old names', true, `${legacy.map((x) => `${x.old} → ${x.now}`).join(', ')} (they still work; rename them when convenient)`)
57
59
  if (!config.org) add('org', false, 'no org in config', 'Set org: <slug> (the part after kaloko.app/ when you are signed in), or run kaloko doctor --fix with a token to fill it in.')
58
60
  await folderChecks({ config, root, fix, add })
59
61
 
@@ -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.')
@@ -137,7 +137,8 @@ export function secretNames (env, scenario = {}) {
137
137
 
138
138
  /**
139
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>`.
140
+ * `list`, `run <id>`, `pause <id>`, `resume <id>`, `remove <id>`, `hook <id> [--remove] [--json]`,
141
+ * `export github --scenario <file> --env <name>`.
141
142
  * deps: { loadScenario, environment, baseUrl, projectAndIssue, packageRoot, service }.
142
143
  */
143
144
  export async function scheduleCmd ({ args, config, root, say, deps }) {
@@ -145,7 +146,7 @@ export async function scheduleCmd ({ args, config, root, say, deps }) {
145
146
  const org = config.org
146
147
  const client = () => deps.service(config)
147
148
  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
+ 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
150
  const file = typeof args.scenario === 'string' ? resolve(root, args.scenario) : null
150
151
  if (!file) throw new UserError('--scenario <file> names the scenario to walk.')
151
152
  const envName = typeof args.env === 'string' ? args.env : null
@@ -202,10 +203,18 @@ export async function scheduleCmd ({ args, config, root, say, deps }) {
202
203
  if ((s.skipped || []).length) say(' Steps that click or type need the full walk: kaloko schedule export github')
203
204
  return s
204
205
  }
205
- if (!id) throw new UserError(`Usage: kaloko schedule ${sub} <schedule id> (kaloko schedule list shows them)`)
206
+ if (!id) throw new UsageError(`Usage: kaloko schedule ${sub} <schedule id> (kaloko schedule list shows them)`)
206
207
  const path = `/orgs/${org}/schedules/${encodeURIComponent(id)}`
207
208
  if (sub === 'run') { await client().request('POST', `${path}/run`); say(`✓ ${id} starts within 15 minutes; the run lands in your inbox.`); return }
208
209
  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
210
  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
+ // the webhook trigger: a deploy or CI job starts the run with the secret (shown once, here)
212
+ if (sub === 'hook') {
213
+ if (args.remove) { await client().request('DELETE', `${path}/hook`); say(`✓ The trigger of ${id} is removed; calls to its URL fail from now on.`); return { id, hook: null } }
214
+ const h = await client().request('POST', `${path}/hook`)
215
+ if (args.json) { say(JSON.stringify(h, null, 2)); return h }
216
+ say(`✓ ${h.rotated ? 'New secret for' : 'Trigger for'} ${id}${h.rotated ? ' (the old one stopped working)' : ''}.\n URL ${h.url}\n Secret ${h.secret}\nStore the secret in your CI or hosting secrets now (Kaloko shows it only once), then call:\n curl -X POST ${h.url} -H "Authorization: Bearer $KALOKO_HOOK_SECRET"`)
217
+ return h
218
+ }
219
+ throw new UsageError('Usage: kaloko schedule add|list|run|pause|resume|remove|hook|export github (kaloko help)')
211
220
  }
@@ -0,0 +1,28 @@
1
+ // Names from before the rename to Kaloko that still work: the qawalk command, QAWALK_* variables, qawalk.config.yml
2
+ // and ~/.config/qawalk/.env. Each one used in a run is noted here; the CLI prints one line about them on stderr at the
3
+ // end of the command (once per run) and kaloko doctor lists them with their new names.
4
+
5
+ const seen = new Map() // old name → new name
6
+
7
+ /** Notes an old name in use and the name that replaces it. */
8
+ export function noteLegacy (old, now) { if (!seen.has(old)) seen.set(old, now) }
9
+
10
+ /** [{ old, now }] noted in this run. */
11
+ export const legacyInUse = () => [...seen].map(([old, now]) => ({ old, now }))
12
+
13
+ /** Forgets what was noted (tests, and main() calls in one process). */
14
+ export const resetLegacy = () => seen.clear()
15
+
16
+ /** The one stderr line about old names, or null when none was used. */
17
+ export function legacyHint (list = legacyInUse()) {
18
+ if (!list.length) return null
19
+ return `! Old names from before Kaloko still work, but will not forever: ${list.map((x) => `${x.old} → ${x.now}`).join(', ')}. kaloko doctor lists them.`
20
+ }
21
+
22
+ /**
23
+ * QAWALK_* variables someone set (in the shell or a .env file), not the copies the CLI makes of KALOKO_* ones: a
24
+ * QAWALK_X counts when KALOKO_X is unset or holds another value. `vars` is a snapshot taken before aliasing.
25
+ */
26
+ export function legacyVariables (vars) {
27
+ return Object.keys(vars).filter((k) => /^QAWALK_[A-Z0-9_]+$/.test(k) && vars[k] !== vars[`KALOKO_${k.slice(7)}`]).sort()
28
+ }
package/src/lib/login.mjs CHANGED
@@ -34,7 +34,7 @@ export async function login (base, { org = null, print = false, open = true, tok
34
34
  base = base.replace(/\/$/, '')
35
35
  const post = async (path, body) => {
36
36
  let res
37
- try { res = await fetchImpl(`${base}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body) }) } catch (e) { throw new UserError(`Cannot reach ${base} (${e.cause?.code || e.message}). Check your network or VPN and run kaloko login again; --url or KALOKO_URL points it to another service.`) }
37
+ try { res = await fetchImpl(`${base}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify(body) }) } catch (e) { throw new UserError(`Cannot reach ${base} (${e.cause?.code || e.message}). Check your network or VPN and run kaloko login again; --url or KALOKO_URL points it to another service.`, { code: 'unreachable', exitCode: 3 }) }
38
38
  return { status: res.status, body: await res.json().catch(() => ({})) }
39
39
  }
40
40
  const start = await post('/cli/device', { client: `kaloko${version ? ` ${version}` : ''} on ${hostname()}`.slice(0, 60) })