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.
- package/README.md +11 -5
- package/bin/qawalk.mjs +2 -1
- package/package.json +2 -2
- package/schemas/changelog.v1.json +5 -0
- package/schemas/config.v1.json +2 -1
- package/skills/kaloko/SKILL.md +11 -2
- package/skills/kaloko/reference/ci.md +40 -5
- package/skills/kaloko/reference/collaboration.md +8 -1
- package/skills/kaloko/reference/design.md +9 -8
- package/skills/kaloko/reference/docs.md +9 -3
- package/skills/kaloko/reference/documentation.md +6 -4
- package/skills/kaloko/reference/integrations.md +18 -4
- package/skills/kaloko/reference/mcp.md +22 -9
- package/skills/kaloko/reference/portable-html.md +1 -1
- package/skills/kaloko/reference/recording.md +4 -2
- package/src/cli.mjs +301 -107
- package/src/lib/changelog-core.mjs +11 -8
- package/src/lib/ci.mjs +1 -1
- package/src/lib/cli-args.mjs +144 -0
- package/src/lib/config.mjs +25 -1
- package/src/lib/design-cli.mjs +9 -9
- package/src/lib/design-export.mjs +1 -1
- package/src/lib/docs-export.mjs +44 -10
- package/src/lib/docs.mjs +3 -2
- package/src/lib/doctor.mjs +3 -1
- package/src/lib/integrations-cli.mjs +15 -6
- package/src/lib/legacy.mjs +28 -0
- package/src/lib/login.mjs +1 -1
- package/src/lib/screens-cli.mjs +4 -2
- package/src/lib/service.mjs +52 -15
- package/src/lib/signoff.mjs +2 -2
- package/src/lib/spec-cli.mjs +3 -3
- package/src/lib/upload.mjs +1 -1
- package/src/lib/util.mjs +40 -1
- package/src/lib/video-cli.mjs +3 -3
- package/viewer/index.html +36 -28
|
@@ -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>`
|
|
19
|
-
* means a step of the product's default scenario. `version` is a released version, `latest` (default) or `prev`
|
|
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)
|
|
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" --
|
|
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
|
+
}
|
package/src/lib/config.mjs
CHANGED
|
@@ -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
|
}
|
package/src/lib/design-cli.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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 = []
|
package/src/lib/docs-export.mjs
CHANGED
|
@@ -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
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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 = `[](${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: `` → ``, 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 `})`
|
|
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'}`)
|
package/src/lib/doctor.mjs
CHANGED
|
@@ -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
|
|
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>`, `
|
|
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
|
|
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
|
|
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
|
-
|
|
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) })
|