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