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
package/src/cli.mjs
CHANGED
|
@@ -20,7 +20,8 @@ import { evaluateRun, semanticPrefetch } from './lib/evaluate.mjs'
|
|
|
20
20
|
import { buildManifest, writePreview, attentionLines } from './lib/build.mjs'
|
|
21
21
|
import { stabilityCmd, pullDrawn } from './lib/stability.mjs'
|
|
22
22
|
import { signoffCmd } from './lib/signoff.mjs'
|
|
23
|
-
import { localized, readJson, UserError, globToRegExp, failureStreak, expiryNote, SUPPORT_LINE, SUPPORT_EMAIL, NO_ORG } from './lib/util.mjs'
|
|
23
|
+
import { localized, readJson, UserError, UsageError, globToRegExp, failureStreak, expiryNote, SUPPORT_LINE, SUPPORT_EMAIL, NO_ORG, EXIT, parseDuration } from './lib/util.mjs'
|
|
24
|
+
import { noteLegacy, legacyHint, resetLegacy, legacyInUse } from './lib/legacy.mjs'
|
|
24
25
|
import { packageRoot } from './lib/schema.mjs'
|
|
25
26
|
import { compareRuns, formatComparison } from './lib/compare.mjs'
|
|
26
27
|
import { doctor, formatDoctor } from './lib/doctor.mjs'
|
|
@@ -36,6 +37,7 @@ import { upsertPrComment } from './lib/pr.mjs'
|
|
|
36
37
|
import { formatReviewQueue, shareReviewLine } from './lib/review.mjs'
|
|
37
38
|
import { htmlViewAllowed } from './lib/htmlview.mjs'
|
|
38
39
|
import { service, serviceUrl } from './lib/service.mjs'
|
|
40
|
+
import { parseArgs, helpFor, checkOptions, closest } from './lib/cli-args.mjs'
|
|
39
41
|
import { issueCmd, scheduleCmd } from './lib/integrations-cli.mjs'
|
|
40
42
|
import { login } from './lib/login.mjs'
|
|
41
43
|
import { initNextSteps, loginNextSteps } from './lib/onboarding.mjs'
|
|
@@ -65,6 +67,26 @@ import { attachAndroidWebView } from './lib/apps/webview.mjs'
|
|
|
65
67
|
import { piiSettings, piiFindings, piiRequested, guardAllow, formatFindings } from './lib/pii.mjs'
|
|
66
68
|
|
|
67
69
|
|
|
70
|
+
/** Options every command takes and the exit codes; the end of kaloko help and of the help of each command. */
|
|
71
|
+
const COMMON_HELP = `Common options (every command)
|
|
72
|
+
--run <id|dir> The run to act on (default: the current run, or KALOKO_RUN)
|
|
73
|
+
--config <file> Another kaloko.config.yml
|
|
74
|
+
--org <slug> Another organization than org: in the config
|
|
75
|
+
--json Machine-readable output, where a command prints data: stdout holds only that
|
|
76
|
+
JSON, progress and notes go to stderr, and an error is one line on stderr:
|
|
77
|
+
{"error":{"message":"…","code":"…","next":"…"}} with the exit code below
|
|
78
|
+
--help, -h Help for one command, like kaloko help <command> (kaloko help export run: one
|
|
79
|
+
sub-command)
|
|
80
|
+
--version, -v The version of kaloko (also kaloko version)
|
|
81
|
+
KALOKO_AGENT=<name> Labels writes by an agent
|
|
82
|
+
Durations (--timeout, --wait, --expires, --older-than) take a number in the option's unit or one with s, m, h or d:
|
|
83
|
+
90s, 10m, 2h, 14d
|
|
84
|
+
|
|
85
|
+
Exit codes: 0 done · 1 checks found problems or the command failed · 2 usage error (unknown command or option,
|
|
86
|
+
missing argument) · 3 the Kaloko service cannot be reached (no connection; an HTTP error answer is 1) · 4 no token,
|
|
87
|
+
or an invalid, expired or revoked one (kaloko login)
|
|
88
|
+
`
|
|
89
|
+
|
|
68
90
|
const HELP = `kaloko — walk, assess and approve what your agents built, on one flow canvas
|
|
69
91
|
|
|
70
92
|
Usage: kaloko <command> [options]
|
|
@@ -88,9 +110,10 @@ Project
|
|
|
88
110
|
Write .github/workflows/kaloko-release.yml: when release-please, changesets or
|
|
89
111
|
semantic-release publishes a GitHub release, kaloko release records it on Kaloko
|
|
90
112
|
(--snippet prints the step to add after the tool's own step instead)
|
|
91
|
-
ci preview-url [--wait
|
|
113
|
+
ci preview-url [--wait 10m] [--github-env] [--dotenv file] [--json]
|
|
92
114
|
In CI: the preview URL (KALOKO_PREVIEW_URL, the deployment event, provider
|
|
93
|
-
variables or the commit's GitHub deployments), the pull request and its base
|
|
115
|
+
variables or the commit's GitHub deployments), the pull request and its base;
|
|
116
|
+
--wait polls that long for the deployment (seconds, or 10m)
|
|
94
117
|
login [--org <slug>] [--url <service>] [--print] [--no-open]
|
|
95
118
|
Sign this machine in through the browser: approve the code on kaloko.app, choose
|
|
96
119
|
the organization and what the token may do; the token (at most 30 days) goes to
|
|
@@ -99,15 +122,15 @@ Project
|
|
|
99
122
|
and app tools; --fix installs missing Playwright browsers, creates the run and
|
|
100
123
|
scenario folders, writes org: from the token and asks macOS for the desktop
|
|
101
124
|
capture permissions
|
|
102
|
-
validate <scenario.yml>... [--coverage [--source ACCEPTANCE.md]]
|
|
125
|
+
validate <scenario.yml>... [--coverage [--source ACCEPTANCE.md]] [--json]
|
|
103
126
|
Validate scenarios (schema + references); --coverage compares criteria with the plan
|
|
104
127
|
With a token it also warns about scenarios the service still lists whose file is gone (--offline skips it)
|
|
105
|
-
scenarios
|
|
128
|
+
scenarios [--json] List scenarios matched by the config
|
|
106
129
|
|
|
107
130
|
Run
|
|
108
131
|
start --scenario <file> --env <name> [--locales cs,en] [--viewports desktop,mobile] [--executor <name>]
|
|
109
132
|
[--app-version <v>] (default: the git tag on HEAD; documentation captures carry it)
|
|
110
|
-
Create a run directory and make it current
|
|
133
|
+
Create a run directory and make it current (--json: the run id and its folder)
|
|
111
134
|
[--viewports iphone-15,ipad-landscape,desktop] Viewport names of the scenario or device presets
|
|
112
135
|
(kaloko devices lists them; <preset>-landscape turns one sideways)
|
|
113
136
|
[--browsers chromium,webkit,firefox] [--color-schemes light,dark] [--reduced-motion no-preference,reduce]
|
|
@@ -120,7 +143,7 @@ Run
|
|
|
120
143
|
Design environments: the token modes to render (default every mode of the set)
|
|
121
144
|
[--pii redact|pseudonymize|off] Hide personal data in every capture (placeholders or consistent fake values);
|
|
122
145
|
default from the environment's or the scenario's pii
|
|
123
|
-
browser start|stop|status [--env e] [--headed] One shared browser per environment (port = browser.port + index)
|
|
146
|
+
browser start|stop|status [--env e] [--headed] [--json] One shared browser per environment (port = browser.port + index)
|
|
124
147
|
Shared Chromium with CDP (agents, scripts and people drive it)
|
|
125
148
|
browser install [--with-deps] [--browsers chromium,webkit,firefox]
|
|
126
149
|
Download the engines of kaloko's Playwright (default Chromium; CI:
|
|
@@ -137,7 +160,7 @@ Run
|
|
|
137
160
|
An app screen taken by any tool (Maestro, Appium, XCUITest, a person) with its
|
|
138
161
|
UI dump (uiautomator/Appium XML, idb or Maestro JSON); app scenarios without
|
|
139
162
|
--image capture the current screen of the environment's app
|
|
140
|
-
mail --step <id> [--locale l] [--to <addr>] [--subject <text>] [--timeout
|
|
163
|
+
mail --step <id> [--locale l] [--to <addr>] [--subject <text>] [--timeout 60s]
|
|
141
164
|
[--since <ISO date> | --any-time] [--id <mailbox message id>]
|
|
142
165
|
Only messages received since the run started, unless --since/--any-time; --id takes one exact message
|
|
143
166
|
Capture an e-mail from the environment's mailbox (mail.provider) as an email step
|
|
@@ -146,7 +169,7 @@ Run
|
|
|
146
169
|
auth save --env <e> --account <key> [--url <url>]
|
|
147
170
|
A person signs in once (SSO, MFA) in a visible browser; the session is saved
|
|
148
171
|
for that account and walk reuses it (never uploaded)
|
|
149
|
-
auth list
|
|
172
|
+
auth list [--json] Saved sessions per environment and account, with their age
|
|
150
173
|
walk [--steps a,b] [--locales l] [--keep-going] [--ephemeral] [--browsers webkit]
|
|
151
174
|
Run playwright steps (execution.script) and capture them; a session stops
|
|
152
175
|
after 3 steps in a row fail with the same error unless --keep-going
|
|
@@ -174,30 +197,32 @@ Run
|
|
|
174
197
|
per locale with a chapter per step (flow), or only steps that fail (failed);
|
|
175
198
|
also capture.record in the config or scenario, record: true on a step.
|
|
176
199
|
A trace (actions, requests, console) is kept per step unless --no-trace
|
|
177
|
-
affected [--scenario file | --all] [--base ref] [--files a,b] [--json] [--
|
|
200
|
+
affected [--scenario file | --all] [--base ref] [--files a,b] [--json] [--fail-if-none] [--offline]
|
|
178
201
|
Which steps changed files reach, and why: framework route files against step paths
|
|
179
202
|
and URLs captured earlier, imports, global files, i18n messages, step scripts and
|
|
180
|
-
affects: globs; --
|
|
203
|
+
affects: globs; --fail-if-none exits 1 when nothing is affected (--check, its
|
|
204
|
+
older name, still works)
|
|
181
205
|
import <folder | playwright-report/ | results.json | trace.zip | walkthrough.md> [--scenario file | --adhoc]
|
|
182
206
|
[--env e] [--locale l] [--viewport v] [--map "Title=step,…"] [--format f] [--out file]
|
|
183
207
|
Screenshots, recordings and traces from agents and tools as a run: into the
|
|
184
208
|
current run, a new run of --scenario, or an ad-hoc flow of the screens
|
|
185
|
-
status
|
|
209
|
+
status [--json] Show what the current run has captured
|
|
186
210
|
Scenarios may declare "sessions" (people in the flow); walk keeps one browser context per session and locale
|
|
187
211
|
|
|
188
212
|
Results
|
|
189
|
-
evaluate
|
|
213
|
+
evaluate [--json] Deterministic checks + semantic evaluator (default JEV) for all captures
|
|
190
214
|
evaluate --recheck [--steps a,b] After a scenario change: re-run deterministic checks on the stored HTML and ask
|
|
191
215
|
the semantic questions again on the stored captures (no walk);
|
|
192
216
|
console, overflow and performance keep their live result
|
|
193
|
-
build
|
|
217
|
+
build [--json] Write run.json and the local preview (index.html)
|
|
194
218
|
video export [--format mp4|webm|gif] [--locale l] [--steps a,b] [--lang en|cs] [--out file]
|
|
195
219
|
The run's recordings in flow order with a title card per step (title, verdict);
|
|
196
220
|
shared with the run on Business and up
|
|
197
221
|
video list | trace --step <id> | tools
|
|
198
222
|
Recordings and traces of the run; a step's trace as a timeline; ffmpeg, adb, simctl
|
|
199
223
|
preview Build and print the preview path
|
|
200
|
-
share [--expires
|
|
224
|
+
share [--expires 30d] [--pr] [--json]
|
|
225
|
+
Upload the run to kaloko.app (KALOKO_TOKEN, tester role); files travel by content
|
|
201
226
|
hash, so unchanged captures are not sent again and a broken-off share resumes;
|
|
202
227
|
--pr adds or updates one comment on the branch's pull request via gh (KALOKO_PR in
|
|
203
228
|
CI), or a note on the GitLab merge request (KALOKO_GITLAB_TOKEN)
|
|
@@ -214,25 +239,32 @@ Results
|
|
|
214
239
|
comment --reply|--resolve|--reopen|--edit|--delete <id> [--note "…"] [--flow]
|
|
215
240
|
Comment, answer and close feedback from the CLI (resolve with "Fixed in <commit>")
|
|
216
241
|
projects [--json] Projects of the organization (use the slug as project: in a scenario or config)
|
|
217
|
-
runs [--scenario id] [--all] [--json]
|
|
218
|
-
Shared runs: the newest per scenario (with "+ N older"); --all or --scenario lists every
|
|
242
|
+
runs [--scenario id] [--env e] [--all] [--limit N] [--cursor c] [--json]
|
|
243
|
+
Shared runs: the newest per scenario (with "+ N older"); --all or --scenario lists every
|
|
244
|
+
run: --all to the last one page by page, otherwise the newest 300; --limit N lists at
|
|
245
|
+
most N, --cursor c goes on where a list stopped (it prints the cursor)
|
|
219
246
|
trash <run-id>... | restore <run-id>
|
|
220
247
|
Move runs to the trash (uploader or admin) and back; restorable until purge
|
|
221
|
-
prune [--scenario id] [--
|
|
248
|
+
prune [--scenario id] [--env e] [--keep 3] [--older-than 14d|48h] [--dry-run | --yes] [--json]
|
|
222
249
|
Keep the newest runs per scenario and environment plus kept, accepted, baseline,
|
|
223
|
-
referenced and commented runs; lists what would go to the trash
|
|
250
|
+
referenced and commented runs; lists what would go to the trash (--dry-run, the
|
|
251
|
+
default) unless --yes
|
|
224
252
|
keep <run-id> [--off] A kept run never expires and is never pruned
|
|
225
|
-
archive <scenario-id> [--
|
|
253
|
+
archive <scenario-id> [--off] Hide a retired scenario from the scenario list (admin); its runs stay reachable;
|
|
254
|
+
--off shows it again
|
|
226
255
|
inbox [--all] [--read] [--json] Your notifications on kaloko.app (mentions, returned runs, new runs, expiry); --read marks them read
|
|
227
256
|
calibrate [--golden labels.yml] [--scenario id [--limit 30]] [--target 0.95] [--json]
|
|
228
257
|
Semantic evaluator vs human labels: precision/recall per criterion at the
|
|
229
258
|
configured thresholds and suggested thresholds (labels from a golden file or
|
|
230
259
|
from reviewers' criterion verdicts in the service)
|
|
231
|
-
export [--run id] [--out file.zip]
|
|
260
|
+
export run [--run id] [--out file.zip]
|
|
261
|
+
Download a shared run as a zip before it expires: run.json, every file, the live
|
|
232
262
|
HTML of design steps and index.html, the canvas that opens offline from disk
|
|
233
|
-
|
|
263
|
+
(also kaloko export --run id)
|
|
264
|
+
export steps --scenario <id> [--accepted] [--out dir] [--locales l] [--viewports v]
|
|
234
265
|
Newest capture of every step as steps/<step>/<locale>/<viewport>.<ext> plus
|
|
235
266
|
index.json (documentation layer, Business plan); unchanged files are skipped
|
|
267
|
+
(also kaloko export --scenario <id>)
|
|
236
268
|
public [--run id] [--off] Make a shared run readable without sign-in (admin; testers may refresh an already public scenario)
|
|
237
269
|
stability [--last 4 | --runs a,b] [--same-commit] [--apply] [--json]
|
|
238
270
|
Regions that change on every run or move while the page is idle (clocks, ads,
|
|
@@ -248,7 +280,7 @@ Results
|
|
|
248
280
|
Regions people drew on screenshots on kaloko.app (step detail → Mark a changing
|
|
249
281
|
region) go into the scenario's ignore: of their steps; walk them again after
|
|
250
282
|
screens [words] [--project p] [--product p] [--module m] [--scenario id] [--locale l] [--viewport v] [--device d]
|
|
251
|
-
[--status pass|partial|fail|skip] [--
|
|
283
|
+
[--status pass|partial|fail|skip] [--env e] [--designs] [--all] [--json] [--cursor c]
|
|
252
284
|
The screen library: the newest screenshot of every step across your flows, searched
|
|
253
285
|
by title, text, URL or id, with the canvas link of each (--designs adds design versions)
|
|
254
286
|
compare [--run id] [--baseline id | --design] [--json]
|
|
@@ -268,6 +300,9 @@ Hosted runs (read-only scenarios on Kaloko's browsers, Business plan)
|
|
|
268
300
|
run as you: public pages, HTTP Basic from the environment, or a saved session
|
|
269
301
|
(--account). Steps that click or type are left out; they need the full walk
|
|
270
302
|
schedule list | run <id> | pause <id> | resume <id> | remove <id> [--json]
|
|
303
|
+
schedule hook <id> [--remove] [--json]
|
|
304
|
+
A URL and secret that start the hosted run from a deploy (Vercel, Netlify) or
|
|
305
|
+
any CI job; the secret is shown once, a second call replaces it
|
|
271
306
|
schedule export github --scenario <file> --env <name> [--every day|week] [--at 5] [--out file] [--print]
|
|
272
307
|
A GitHub Actions workflow that walks the scenario on a schedule with the full CLI
|
|
273
308
|
|
|
@@ -303,12 +338,13 @@ Docs (Markdown in docs/ with step references; Diátaxis folders; translations as
|
|
|
303
338
|
text was published), missing or outdated translations and user-visible changelog
|
|
304
339
|
items without a picture; exit 1 on references or stale sections (--strict: on
|
|
305
340
|
anything)
|
|
306
|
-
export --format md|docusaurus|vitepress|mkdocs|html|pdf|json [--product p] [--version v] [--lang l]
|
|
341
|
+
export docs [--format md|docusaurus|vitepress|mkdocs|html|pdf|json] [--product p] [--version v] [--lang l]
|
|
307
342
|
[--what all|docs|changelog] [--local] [--out dir|file]
|
|
308
343
|
The docs and changelog with their pictures as files: Markdown with an assets
|
|
309
|
-
folder, a Docusaurus, VitePress or MkDocs folder with sidebars,
|
|
310
|
-
(printed by the browser) or the changelog as JSON; --local
|
|
311
|
-
the repository instead of the published ones
|
|
344
|
+
folder (the default), a Docusaurus, VitePress or MkDocs folder with sidebars,
|
|
345
|
+
offline HTML, PDF (printed by the browser) or the changelog as JSON; --local
|
|
346
|
+
exports the files in the repository instead of the published ones (also kaloko
|
|
347
|
+
export --format <f>)
|
|
312
348
|
|
|
313
349
|
Design (environments with kind: design; live HTML steps in a folder)
|
|
314
350
|
start --scenario <file> --env <design env> A run of the design folder (same scenario as development and QA)
|
|
@@ -345,16 +381,17 @@ Design (environments with kind: design; live HTML steps in a folder)
|
|
|
345
381
|
The project's token set (DTCG); check lists raw values outside it in the live folder
|
|
346
382
|
branch list | create <name> [--from vN] [--git <branch>] | merge <name> [--take-branch a,b] [--keep-main c]
|
|
347
383
|
Branches of the draft; a git-linked branch is what implementation runs of that git branch check against
|
|
348
|
-
export
|
|
384
|
+
export live [--version n|--draft|--run id] [--out design/]
|
|
349
385
|
Live files of a version (default: the design reference) into a folder: the handoff
|
|
350
|
-
|
|
386
|
+
(also kaloko export --live)
|
|
387
|
+
export live --views --run <id> [--viewport desktop] [--out design/]
|
|
351
388
|
Fork into a draft: the HTML views of an app run become design steps (experimental)
|
|
352
|
-
export
|
|
389
|
+
export design [--version n|--draft|--run id] [--out dir|file.zip]
|
|
353
390
|
A design version in open formats: HTML + assets, tokens.json (DTCG), scenario.yml,
|
|
354
|
-
screenshots and an index.html that opens from disk (default: the design reference
|
|
391
|
+
screenshots and an index.html that opens from disk (default: the design reference;
|
|
392
|
+
also kaloko export --design)
|
|
355
393
|
|
|
356
|
-
|
|
357
|
-
`
|
|
394
|
+
${COMMON_HELP}`
|
|
358
395
|
|
|
359
396
|
/** A comment's drawing for the terminal: "· box 120,340 300×80 px on desktop" (CSS px when the size is known). */
|
|
360
397
|
export function regionText (g) {
|
|
@@ -367,18 +404,19 @@ export function regionText (g) {
|
|
|
367
404
|
|
|
368
405
|
/** validate: scenarios the service still lists as active whose scenario file no longer exists here (#58). */
|
|
369
406
|
async function warnRetired (args) {
|
|
370
|
-
if (args.offline) return
|
|
407
|
+
if (args.offline) return []
|
|
371
408
|
let config, client
|
|
372
|
-
try { ({ config } = await context(args)); client = service(config) } catch { return } // no project or no token: offline
|
|
373
|
-
if (!config?.org) return
|
|
409
|
+
try { ({ config } = await context(args)); client = service(config) } catch { return [] } // no project or no token: offline
|
|
410
|
+
if (!config?.org) return []
|
|
374
411
|
let list
|
|
375
|
-
try { list = await client.request('GET', `/orgs/${config.org}/scenarios?runs=1`) } catch { return }
|
|
412
|
+
try { list = await client.request('GET', `/orgs/${config.org}/scenarios?runs=1`) } catch { return [] }
|
|
376
413
|
const gone = []
|
|
377
414
|
for (const s of list || []) {
|
|
378
415
|
if (s.archived || !s.file || (config.project && s.project && s.project !== config.project)) continue
|
|
379
416
|
try { await readFile(resolve(s.file)) } catch { gone.push(s) }
|
|
380
417
|
}
|
|
381
|
-
for (const s of gone) say(`! ${s.id}: the service lists it as active, but ${s.file} is gone here (split or renamed?). Archive it: kaloko archive ${s.id}`)
|
|
418
|
+
if (!args.json) for (const s of gone) say(`! ${s.id}: the service lists it as active, but ${s.file} is gone here (split or renamed?). Archive it: kaloko archive ${s.id}`)
|
|
419
|
+
return gone.map((s) => ({ id: s.id, file: s.file }))
|
|
382
420
|
}
|
|
383
421
|
|
|
384
422
|
/** The end of a walk: evaluate (the semantic answers are mostly cached by now) and say what did not pass. */
|
|
@@ -397,19 +435,6 @@ async function walkVerdicts ({ prefetch, run, scenario, config, root }) {
|
|
|
397
435
|
if (report.skipped.length) say(` Semantic criteria not evaluated: ${[...new Set(report.skipped.map((x) => x.split(': ').slice(1).join(': ')))].slice(0, 2).join('; ')}`)
|
|
398
436
|
}
|
|
399
437
|
|
|
400
|
-
function parse (argv) {
|
|
401
|
-
const args = { _: [] }
|
|
402
|
-
for (let i = 0; i < argv.length; i++) {
|
|
403
|
-
const a = argv[i]
|
|
404
|
-
if (a.startsWith('--')) {
|
|
405
|
-
const [k, v] = a.slice(2).split('=')
|
|
406
|
-
if (v !== undefined) args[k] = v
|
|
407
|
-
else if (argv[i + 1] && !argv[i + 1].startsWith('--')) args[k] = argv[++i]
|
|
408
|
-
else args[k] = true
|
|
409
|
-
} else args._.push(a)
|
|
410
|
-
}
|
|
411
|
-
return args
|
|
412
|
-
}
|
|
413
438
|
const list = (v) => (typeof v === 'string' ? v.split(',').map((s) => s.trim()).filter(Boolean) : null)
|
|
414
439
|
// parallel walks (#27) print each locale's lines together, in locale order: `say` writes into the running unit's buffer
|
|
415
440
|
const output = new AsyncLocalStorage()
|
|
@@ -446,8 +471,15 @@ export function walkConcurrency (args, config, units, shared, cpus = availablePa
|
|
|
446
471
|
return Math.max(1, Math.min(units, 4, Math.floor(cpus / 2)))
|
|
447
472
|
}
|
|
448
473
|
|
|
474
|
+
/** --env names an environment everywhere; --environment is its older spelling on runs, prune and screens. */
|
|
475
|
+
const envOption = (args) => typeof args.env === 'string' ? args.env : typeof args.environment === 'string' ? args.environment : null
|
|
476
|
+
/** The most runs `kaloko runs` lists in one go. */
|
|
477
|
+
const RUNS_CAP = 300
|
|
478
|
+
|
|
449
479
|
async function context (args) {
|
|
450
480
|
const { config, root, file } = await loadConfig(process.cwd(), args.config)
|
|
481
|
+
// --org: another organization than the config's for this command (an agent working for several)
|
|
482
|
+
if (typeof args.org === 'string' && args.org) config.org = args.org
|
|
451
483
|
return { config, root, file }
|
|
452
484
|
}
|
|
453
485
|
|
|
@@ -463,7 +495,7 @@ async function runContext (args) {
|
|
|
463
495
|
}
|
|
464
496
|
|
|
465
497
|
function stepOf (scenario, id) {
|
|
466
|
-
if (!id) throw new
|
|
498
|
+
if (!id) throw new UsageError(`Which step? Pass --step <id>; this scenario has: ${scenario.nodes.filter((n) => n.kind !== 'decision').map((n) => n.id).join(', ')}.`)
|
|
467
499
|
const node = scenario.nodes.find((n) => n.id === id)
|
|
468
500
|
if (!node) throw new UserError(`Unknown step "${id}". Steps: ${scenario.nodes.filter((n) => n.kind !== 'decision').map((n) => n.id).join(', ')}`)
|
|
469
501
|
return node
|
|
@@ -491,7 +523,7 @@ async function lintOrigins (config, env, { online = false } = {}) {
|
|
|
491
523
|
*/
|
|
492
524
|
async function designImport (args) {
|
|
493
525
|
const [, kind, source] = args._
|
|
494
|
-
if (kind !== 'figma' || !source) throw new
|
|
526
|
+
if (kind !== 'figma' || !source) throw new UsageError('Usage: kaloko design import figma <file-url|key|export folder> [--scenario file] [--env <design env>] [--frames a,b] [--page name] [--map frames.json|yml] [--token-env FIGMA_TOKEN] [--tokens auto|write|skip] [--force] [--dry-run] [--publish [--notes file]]')
|
|
495
527
|
const { config, root } = await context(args)
|
|
496
528
|
let run = null
|
|
497
529
|
try { run = await resolveRun({ root, output: config.output, run: args.run }) } catch {}
|
|
@@ -506,22 +538,24 @@ async function designImport (args) {
|
|
|
506
538
|
const viewports = Object.fromEntries(baseViewports(scope).map((v) => [v, scope.viewports[v]]))
|
|
507
539
|
const tokenVar = typeof args['token-env'] === 'string' ? args['token-env'] : typeof args.token === 'string' && /^[A-Z_][A-Z0-9_]*$/.test(args.token) ? args.token : 'FIGMA_TOKEN'
|
|
508
540
|
const dir = typeof args.dir === 'string' ? resolve(args.dir) : designDir(env, root)
|
|
541
|
+
// --json: standard output is the JSON alone; progress and next steps go to standard error
|
|
542
|
+
const log = args.json ? (...m) => console.error(...m) : say
|
|
509
543
|
const r = await importFigma({
|
|
510
544
|
source, scenario, viewports, locales: scenario.locales || ['default'], dir,
|
|
511
545
|
token: process.env[tokenVar] || (tokenVar === 'FIGMA_TOKEN' ? process.env.FIGMA_ACCESS_TOKEN : null) || null,
|
|
512
546
|
map: typeof args.map === 'string' ? await readMap(resolve(args.map)) : null, only: list(args.frames), page: typeof args.page === 'string' ? args.page : null,
|
|
513
|
-
tokensMode: ['write', 'skip'].includes(args.tokens) ? args.tokens : 'auto', force: !!args.force, dryRun: !!args['dry-run'], say
|
|
547
|
+
tokensMode: ['write', 'skip'].includes(args.tokens) ? args.tokens : 'auto', force: !!args.force, dryRun: !!args['dry-run'], say: log
|
|
514
548
|
})
|
|
515
549
|
if (args.json) say(JSON.stringify(r, null, 2))
|
|
516
550
|
else say(formatImport(r, { dir: relative(process.cwd(), dir) || '.' }))
|
|
517
551
|
if (args['dry-run']) return r
|
|
518
552
|
const lint = await lintDesign({ dir, scenario, origins: await lintOrigins(config, env) })
|
|
519
|
-
if (lint.errors)
|
|
553
|
+
if (lint.errors) log(`\n${formatLint(lint, { max: 10 })}`)
|
|
520
554
|
if (!args.publish) {
|
|
521
|
-
|
|
555
|
+
log(`\nNext: kaloko start --scenario ${relative(process.cwd(), scenarioFile)} --env ${env.name} && kaloko walk && kaloko share (or rerun with --publish).\nAccept the version on the canvas and set it as the design reference (kaloko reference --design --version <n>): implementation runs with packs: [design] are then checked against these frames.`)
|
|
522
556
|
return r
|
|
523
557
|
}
|
|
524
|
-
await commands.start({ ...args, _: [], scenario: scenarioFile, env: env.name })
|
|
558
|
+
await commands.start({ ...args, _: [], scenario: scenarioFile, env: env.name, json: false })
|
|
525
559
|
const current = await resolveRun({ root, output: config.output })
|
|
526
560
|
await commands.walk({ _: [], run: current.dir })
|
|
527
561
|
await commands.share({ _: [], run: current.dir, ...(args['allow-pii'] ? { 'allow-pii': true } : {}), ...(typeof args.notes === 'string' ? { notes: args.notes } : {}), ...(typeof args.name === 'string' ? { name: args.name } : {}), ...(typeof args.branch === 'string' ? { branch: args.branch } : {}) })
|
|
@@ -534,7 +568,7 @@ async function scenarioIdOf (args, config, root) {
|
|
|
534
568
|
return args.scenario
|
|
535
569
|
}
|
|
536
570
|
try { return (await resolveRun({ root, output: config.output, run: args.run })).meta.scenario.id } catch {}
|
|
537
|
-
throw new
|
|
571
|
+
throw new UsageError('--scenario <id|file> is required when there is no current run.')
|
|
538
572
|
}
|
|
539
573
|
|
|
540
574
|
/**
|
|
@@ -923,6 +957,29 @@ async function captureImage ({ config, run, scenario, node, locale, args }) {
|
|
|
923
957
|
}
|
|
924
958
|
|
|
925
959
|
/** walk --affected / kaloko affected: the selection for one scenario (a token adds URLs of the newest shared run). */
|
|
960
|
+
/**
|
|
961
|
+
* `kaloko export run|steps|docs|live|design …`: the sub-command sets the flag form it stands for (export --live,
|
|
962
|
+
* --design, --format, --scenario), which keeps working. A flag of another kind of export is a usage mistake.
|
|
963
|
+
*/
|
|
964
|
+
export function exportSubcommand (args) {
|
|
965
|
+
const sub = args._[0]
|
|
966
|
+
if (sub === undefined) return null
|
|
967
|
+
// the flags of the other kinds (live and design exports take --scenario too)
|
|
968
|
+
const clashes = { run: ['live', 'design', 'format', 'scenario'], steps: ['live', 'design', 'format'], docs: ['live', 'design', 'scenario'], live: ['design', 'format'], design: ['live', 'format'] }
|
|
969
|
+
const owner = { live: 'live', design: 'design', format: 'docs', scenario: 'steps' }
|
|
970
|
+
// kaloko export <run-id>: the run named as a word, as people type it
|
|
971
|
+
if (/^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]+$/.test(sub) && args.run === undefined) { args._.shift(); args.run = sub; return 'run' }
|
|
972
|
+
if (!(sub in clashes)) throw new UsageError(`kaloko export ${sub}: there is no such export. Use run, steps, docs, live or design (kaloko help export).`)
|
|
973
|
+
args._.shift()
|
|
974
|
+
const clash = clashes[sub].find((f) => args[f] !== undefined)
|
|
975
|
+
if (clash) throw new UsageError(`kaloko export ${sub} does not take --${clash}; that is kaloko export ${owner[clash]}.`)
|
|
976
|
+
if (sub === 'live') args.live = true
|
|
977
|
+
if (sub === 'design') args.design = typeof args.design === 'string' ? args.design : true
|
|
978
|
+
if (sub === 'docs' && typeof args.format !== 'string') args.format = 'md'
|
|
979
|
+
if (sub === 'steps' && typeof args.scenario !== 'string') throw new UsageError('kaloko export steps needs --scenario <id>: the flow whose steps to export.')
|
|
980
|
+
return sub
|
|
981
|
+
}
|
|
982
|
+
|
|
926
983
|
async function affectedSelection (args, { config, root, scenario, scenarioFile }) {
|
|
927
984
|
let client = null
|
|
928
985
|
try { if (!args.offline) client = service(config) } catch {}
|
|
@@ -943,7 +1000,7 @@ const commands = {
|
|
|
943
1000
|
if (!exists) {
|
|
944
1001
|
let tpl = await readFile(join(packageRoot, 'templates', 'kaloko.config.yml'), 'utf8')
|
|
945
1002
|
if (typeof args.org === 'string') {
|
|
946
|
-
if (!/^[a-z0-9][a-z0-9-]{1,40}$/.test(args.org)) throw new
|
|
1003
|
+
if (!/^[a-z0-9][a-z0-9-]{1,40}$/.test(args.org)) throw new UsageError('--org must be the organization slug from kaloko.app (a-z, 0-9, -).')
|
|
947
1004
|
tpl = tpl.replace(/^org: .*$/m, `org: ${args.org}`)
|
|
948
1005
|
}
|
|
949
1006
|
if (detected) {
|
|
@@ -956,7 +1013,7 @@ const commands = {
|
|
|
956
1013
|
} else if (!existsSync(join(cwd, LEGACY_CONFIG_FILE))) say(`${CONFIG_FILE} already exists; kept.`)
|
|
957
1014
|
// the visual changelog (docs/DOCS-CHANGELOG.md): asked once; --docs=off|changelog|full for scripts and agents
|
|
958
1015
|
let docsMode = typeof args.docs === 'string' ? args.docs : null
|
|
959
|
-
if (docsMode && !['off', 'changelog', 'full'].includes(docsMode)) throw new
|
|
1016
|
+
if (docsMode && !['off', 'changelog', 'full'].includes(docsMode)) throw new UsageError('--docs must be off, changelog or full.')
|
|
960
1017
|
if (!docsMode && process.stdin.isTTY && process.stdout.isTTY && !process.env.CI && !process.env.NODE_TEST_CONTEXT) {
|
|
961
1018
|
const rl = (await import('node:readline/promises')).createInterface({ input: process.stdin, output: process.stdout })
|
|
962
1019
|
const a = (await rl.question('Keep a visual changelog? (recommended) [Y/n] ')).trim().toLowerCase(); rl.close()
|
|
@@ -987,7 +1044,7 @@ const commands = {
|
|
|
987
1044
|
}
|
|
988
1045
|
} else if (!docsMode) say('Visual changelog: kaloko init --docs=changelog adds CHANGELOG.md and the config block (recommended).')
|
|
989
1046
|
const agent = typeof args.agent === 'string' ? args.agent : 'none'
|
|
990
|
-
if (!['claude', 'codex', 'all', 'none'].includes(agent)) throw new
|
|
1047
|
+
if (!['claude', 'codex', 'all', 'none'].includes(agent)) throw new UsageError('--agent must be claude, codex, all or none.')
|
|
991
1048
|
const { cp, mkdir, appendFile, rm } = await import('node:fs/promises')
|
|
992
1049
|
const skill = join(packageRoot, 'skills', 'kaloko')
|
|
993
1050
|
const targets = { claude: ['.claude/skills/kaloko'], codex: ['skills/kaloko'], all: ['.claude/skills/kaloko', 'skills/kaloko'], none: [] }[agent]
|
|
@@ -1047,15 +1104,20 @@ const commands = {
|
|
|
1047
1104
|
},
|
|
1048
1105
|
|
|
1049
1106
|
async validate (args) {
|
|
1050
|
-
if (!args._.length) throw new
|
|
1107
|
+
if (!args._.length) throw new UsageError('Usage: kaloko validate <scenario.yml>...')
|
|
1051
1108
|
let failed = 0
|
|
1109
|
+
// --json: one entry per file with its problems, warnings and coverage; the lines below go to stderr
|
|
1110
|
+
const results = []
|
|
1052
1111
|
for (const file of args._) {
|
|
1053
1112
|
let scenario
|
|
1054
|
-
try { scenario = parseScenario(await readFile(file, 'utf8'), file) } catch (e) { if (!(e instanceof UserError)) throw e; failed++; say(`✕ ${e.message}`); continue }
|
|
1113
|
+
try { scenario = parseScenario(await readFile(file, 'utf8'), file) } catch (e) { if (!(e instanceof UserError)) throw e; failed++; say(`✕ ${e.message}`); results.push({ file, ok: false, problems: [e.message], warnings: [] }); continue }
|
|
1055
1114
|
const problems = validateScenario(scenario)
|
|
1056
1115
|
if (!problems.length) expandPacks(scenario)
|
|
1057
1116
|
if (problems.length) { failed++; say(`✕ ${file}\n - ${problems.join('\n - ')}`) } else say(`✓ ${file}`)
|
|
1058
|
-
|
|
1117
|
+
const warnings = problems.length ? [] : scenarioWarnings(scenario)
|
|
1118
|
+
for (const w of warnings) say(` ! ${w}`)
|
|
1119
|
+
const entry = { file, id: scenario.id || null, ok: !problems.length, problems, warnings }
|
|
1120
|
+
results.push(entry)
|
|
1059
1121
|
if (args.coverage) {
|
|
1060
1122
|
// the plan is scenario.source (relative to the working directory or to the scenario file), or --source
|
|
1061
1123
|
const candidates = typeof args.source === 'string' ? [resolve(args.source)] : scenario.source ? [resolve(scenario.source), resolve(file, '..', scenario.source), resolve(file, '..', '..', scenario.source)] : []
|
|
@@ -1064,21 +1126,26 @@ const commands = {
|
|
|
1064
1126
|
if (!plan) { say(` · no acceptance plan found (${scenario.source ? `source: ${scenario.source}` : 'scenario has no source'}); pass --source <ACCEPTANCE.md>`); continue }
|
|
1065
1127
|
const cov = coverage(scenario, plan)
|
|
1066
1128
|
say(formatCoverage(file, cov))
|
|
1129
|
+
entry.coverage = { missing: cov.missing }
|
|
1067
1130
|
if (cov.missing.length) process.exitCode = 1
|
|
1068
1131
|
}
|
|
1069
1132
|
}
|
|
1070
1133
|
if (failed) process.exitCode = 1
|
|
1071
|
-
await warnRetired(args)
|
|
1134
|
+
const retired = await warnRetired(args)
|
|
1135
|
+
if (args.json) say(JSON.stringify({ ok: !failed && process.exitCode !== 1, files: results, retired }, null, 2))
|
|
1072
1136
|
},
|
|
1073
1137
|
|
|
1074
1138
|
async scenarios (args) {
|
|
1075
1139
|
const { config, root } = await context(args)
|
|
1076
|
-
|
|
1140
|
+
const all = await catalogFor(config, root)
|
|
1141
|
+
// --json: id, title, kind, file and the local runs (newest first) of every scenario the config matches
|
|
1142
|
+
if (args.json) { say(JSON.stringify(all, null, 2)); return }
|
|
1143
|
+
for (const s of all) say(`${s.id.padEnd(36)} ${s.kind.padEnd(5)} ${String(s.runs.length).padStart(3)} runs ${s.file}`)
|
|
1077
1144
|
},
|
|
1078
1145
|
|
|
1079
1146
|
async start (args) {
|
|
1080
1147
|
const { config, root } = await context(args)
|
|
1081
|
-
if (!args.scenario || !args.env) throw new
|
|
1148
|
+
if (!args.scenario || !args.env) throw new UsageError('Usage: kaloko start --scenario <file> --env <name>')
|
|
1082
1149
|
const scenarioFile = resolve(args.scenario)
|
|
1083
1150
|
const scenario = await loadScenario(scenarioFile)
|
|
1084
1151
|
const env = environment(config, args.env)
|
|
@@ -1099,9 +1166,11 @@ const commands = {
|
|
|
1099
1166
|
// the product the flow documents (visual changelog): its tag, or the only product of the config
|
|
1100
1167
|
const product = scenario.product || (Object.keys(config.products || {}).length === 1 ? Object.keys(config.products)[0] : null)
|
|
1101
1168
|
const { dir, meta } = await createRun({ output: config.output, scenario, scenarioFile, env, scope, git, executor, root, project, issue, appVersion: appVer, product, pii })
|
|
1169
|
+
const addr = recipient(env.mail, meta.short)
|
|
1170
|
+
// --json: what a script needs to pin the run (KALOKO_RUN) and find its files
|
|
1171
|
+
if (args.json) { say(JSON.stringify({ run: meta.id, dir, env: env.name, readonly: !!env.readonly, locales, viewports: vpNames, project: project.slug, issue: issue || null, test_email: addr || null, pii: pii?.mode || null }, null, 2)); return }
|
|
1102
1172
|
say(`Run ${meta.id}\n dir: ${relative(process.cwd(), dir)}\n env: ${env.name}${env.readonly ? ' (readonly)' : ''}\n locales: ${locales.join(', ')} viewports: ${vpNames.length > 12 ? `${vpNames.slice(0, 12).join(', ')} … (${vpNames.length})` : vpNames.join(', ')}${dimensionLine(scope)}\n project: ${project.slug}${issue ? ` issue: ${issue.repo ? issue.repo + '#' : '#'}${issue.number}` : ''}`)
|
|
1103
1173
|
if (pii) say(` personal data: ${pii.mode === 'pseudonymize' ? 'replaced with fake values' : 'hidden'} in every capture`)
|
|
1104
|
-
const addr = recipient(env.mail, meta.short)
|
|
1105
1174
|
if (addr) say(` test e-mail: ${addr}`)
|
|
1106
1175
|
say(` other agents may start runs here too: pin this one with --run ${meta.id} or KALOKO_RUN=${meta.id}`)
|
|
1107
1176
|
},
|
|
@@ -1138,6 +1207,7 @@ const commands = {
|
|
|
1138
1207
|
say(await stopBrowser({ root, config, env }) ? `Browser stopped${env ? ` (${env.name})` : ' (all environments)'}.` : 'Browser was not running.')
|
|
1139
1208
|
} else {
|
|
1140
1209
|
const states = await allStates({ root, config })
|
|
1210
|
+
if (args.json) { say(JSON.stringify(states.map((s) => ({ environment: s.environment, cdp: s.cdp, pid: s.pid, readonly: !!s.readonly })), null, 2)); return }
|
|
1141
1211
|
say(states.length ? states.map((s) => `${s.environment}: ${s.cdp} (pid ${s.pid})${s.readonly ? ', readonly' : ''}`).join('\n') : 'No shared browser is running.')
|
|
1142
1212
|
}
|
|
1143
1213
|
},
|
|
@@ -1232,7 +1302,7 @@ const commands = {
|
|
|
1232
1302
|
let since
|
|
1233
1303
|
if (args['any-time']) since = null
|
|
1234
1304
|
else if (typeof args.since === 'string') { const t = Date.parse(args.since); if (Number.isNaN(t)) throw new UserError(`--since ${args.since} is not a date (ISO 8601, e.g. 2026-09-27T10:00).`); since = new Date(t).toISOString() }
|
|
1235
|
-
const results = await captureMail({ config, root, run, env, scenario, node, locale, to, subject, timeoutMs: (
|
|
1305
|
+
const results = await captureMail({ config, root, run, env, scenario, node, locale, to, subject, timeoutMs: (parseDuration(args.timeout, 's', 'timeout') || 60) * 1000, viewports: list(args.viewports), message, since, id: typeof args.id === 'string' ? args.id : null })
|
|
1236
1306
|
say(`✓ ${node.id} ${locale}: "${results.subject}" → ${results.count} viewport(s)`)
|
|
1237
1307
|
},
|
|
1238
1308
|
|
|
@@ -1489,10 +1559,13 @@ const commands = {
|
|
|
1489
1559
|
out.push({ scenario: scenario.id, file: relative(root, file), base: r.base, changed: r.files, runs: r.runs, ...r.sel, walk: selectedIds(scenario, r.sel) })
|
|
1490
1560
|
if (!args.json) say(formatAffected(r.sel, { scenario, base: r.base, changed: r.files.length }) + '\n')
|
|
1491
1561
|
}
|
|
1562
|
+
const failIfNone = !!(args['fail-if-none'] || args.check)
|
|
1492
1563
|
if (args.json) say(JSON.stringify(targets.length === 1 && typeof args.scenario === 'string' ? out[0] : out, null, 2))
|
|
1493
|
-
else if (!
|
|
1494
|
-
// --check
|
|
1495
|
-
if (args.check && !
|
|
1564
|
+
else if (!failIfNone) say(`Walk them with: kaloko walk --affected${args.base ? ` --base ${args.base}` : ''} (after kaloko start)`)
|
|
1565
|
+
// --check is the older name of --fail-if-none: it works the same and says so once
|
|
1566
|
+
if (args.check && !args['fail-if-none']) console.error('! kaloko affected --check is now --fail-if-none: it works the same; rename it in your CI when you next touch it.')
|
|
1567
|
+
// --fail-if-none: exit code 1 when nothing is affected (CI skips the scenario)
|
|
1568
|
+
if (failIfNone && !out.some((o) => o.walk.length && !o.none)) process.exitCode = 1
|
|
1496
1569
|
},
|
|
1497
1570
|
|
|
1498
1571
|
/**
|
|
@@ -1501,7 +1574,7 @@ const commands = {
|
|
|
1501
1574
|
*/
|
|
1502
1575
|
async import (args) {
|
|
1503
1576
|
const from = args._[0]
|
|
1504
|
-
if (!from) throw new
|
|
1577
|
+
if (!from) throw new UsageError('Usage: kaloko import <folder | playwright-report/ | results.json | trace.zip | walkthrough.md> [--scenario file | --adhoc] [--env e] [--locale l] [--viewport v] [--map "Title=step,…"]')
|
|
1505
1578
|
const { config, root } = await context(args)
|
|
1506
1579
|
let source
|
|
1507
1580
|
try { source = await readSource(resolve(from), typeof args.format === 'string' ? args.format : 'auto') } catch (e) { throw new UserError(`kaloko import: ${e.message}`) }
|
|
@@ -1568,7 +1641,7 @@ const commands = {
|
|
|
1568
1641
|
const action = args._[0]
|
|
1569
1642
|
if (action === 'preview-url') {
|
|
1570
1643
|
const err = (m) => console.error(m)
|
|
1571
|
-
const preview = await findPreview({ wait:
|
|
1644
|
+
const preview = await findPreview({ wait: parseDuration(args.wait, 's', 'wait') || 0, say: err })
|
|
1572
1645
|
const pr = await pullRequestOf().catch(() => null)
|
|
1573
1646
|
if (!preview) throw new UserError('No preview URL found. Set KALOKO_PREVIEW_URL, or let Vercel, Netlify or Cloudflare Pages report deployments to GitHub (the workflow needs deployments: read).')
|
|
1574
1647
|
const lines = envLines({ preview, pr })
|
|
@@ -1578,10 +1651,12 @@ const commands = {
|
|
|
1578
1651
|
}
|
|
1579
1652
|
if (typeof args.dotenv === 'string') await writeEnvFile(resolve(args.dotenv), lines)
|
|
1580
1653
|
err(`Preview ${preview.url} (from ${preview.source})${pr?.number ? `, pull request #${pr.number}${pr.base ? ` into ${pr.base}` : ''}` : ''}`)
|
|
1654
|
+
// --json: the URL, where it came from and the pull request, with the variables the env lines set
|
|
1655
|
+
if (args.json) { say(JSON.stringify({ url: preview.url, source: preview.source, pr: pr?.number || null, base: pr?.base || null, head: pr?.head || null, env: Object.fromEntries(lines.map((l) => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])) }, null, 2)); return }
|
|
1581
1656
|
console.log(preview.url)
|
|
1582
1657
|
return
|
|
1583
1658
|
}
|
|
1584
|
-
if (!['github', 'gitlab'].includes(action)) throw new
|
|
1659
|
+
if (!['github', 'gitlab'].includes(action)) throw new UsageError('Usage: kaloko ci github|gitlab [--scenario a.yml,b.yml] [--env preview] [--trigger deployment|pull_request] [--no-affected] [--out file] [--print] [--force]\n kaloko ci github --release [--snippet] [--print] [--force]\n kaloko ci preview-url [--wait 600] [--github-env] [--dotenv file]')
|
|
1585
1660
|
const { config, root, file } = await context(args)
|
|
1586
1661
|
if (args.release) {
|
|
1587
1662
|
// release-tool integrations: record each published release on Kaloko (docs/DOCS-CHANGELOG.md)
|
|
@@ -1654,19 +1729,22 @@ const commands = {
|
|
|
1654
1729
|
const { stat, mkdir, writeFile: write, chmod } = await import('node:fs/promises')
|
|
1655
1730
|
if (action === 'list') {
|
|
1656
1731
|
const rows = []
|
|
1732
|
+
const items = []
|
|
1657
1733
|
for (const name of Object.keys(config.environments)) {
|
|
1658
1734
|
let env
|
|
1659
1735
|
try { env = environment(config, name) } catch { continue }
|
|
1660
1736
|
for (const key of Object.keys(env.accounts || {})) {
|
|
1661
1737
|
const file = accountFor(env, key, { root, config }).storage_state
|
|
1662
1738
|
const st = await stat(file).catch(() => null)
|
|
1739
|
+
items.push({ environment: name, account: key, saved: !!st, saved_at: st ? new Date(st.mtimeMs).toISOString() : null, file: relative(process.cwd(), file) })
|
|
1663
1740
|
rows.push(`${name.padEnd(12)} ${key.padEnd(16)} ${st ? `saved ${Math.round((Date.now() - st.mtimeMs) / 36e5)} h ago · ${relative(process.cwd(), file)}` : 'no saved session'}`)
|
|
1664
1741
|
}
|
|
1665
1742
|
}
|
|
1743
|
+
if (args.json) { say(JSON.stringify(items, null, 2)); return }
|
|
1666
1744
|
say(rows.length ? rows.join('\n') : 'No accounts in kaloko.config.yml (environments.<env>.accounts).')
|
|
1667
1745
|
return
|
|
1668
1746
|
}
|
|
1669
|
-
if (action !== 'save' || typeof args.env !== 'string' || typeof args.account !== 'string') throw new
|
|
1747
|
+
if (action !== 'save' || typeof args.env !== 'string' || typeof args.account !== 'string') throw new UsageError('Usage: kaloko auth save --env <name> --account <key> [--url <url>] | kaloko auth list')
|
|
1670
1748
|
const env = environment(config, args.env)
|
|
1671
1749
|
if (env.readonly) throw new UserError(`Environment ${env.name} is readonly; saved sign-ins are for accounts on non-production environments.`)
|
|
1672
1750
|
const account = accountFor(env, args.account, { root, config })
|
|
@@ -1690,7 +1768,7 @@ const commands = {
|
|
|
1690
1768
|
},
|
|
1691
1769
|
|
|
1692
1770
|
async session (args) {
|
|
1693
|
-
if (args._[0] !== 'reset') throw new
|
|
1771
|
+
if (args._[0] !== 'reset') throw new UsageError('Usage: kaloko session reset')
|
|
1694
1772
|
const { config, root, env } = await runContext(args)
|
|
1695
1773
|
const conn = await connectBrowser({ root, config, env })
|
|
1696
1774
|
if (!conn) throw new UserError('Start the browser first: kaloko browser start')
|
|
@@ -1701,6 +1779,18 @@ const commands = {
|
|
|
1701
1779
|
async status (args) {
|
|
1702
1780
|
const { config, root, run, scenario, env } = await runContext(args)
|
|
1703
1781
|
const caps = await readCaptures(run.dir)
|
|
1782
|
+
if (args.json && env.kind !== 'design') {
|
|
1783
|
+
// per step and locale: the viewports captured so far (stack steps: the pages)
|
|
1784
|
+
const steps = scenario.nodes.filter((n) => n.kind !== 'decision').map((node) => ({
|
|
1785
|
+
id: node.id, kind: node.kind, strategy: node.execution?.strategy || null,
|
|
1786
|
+
captured: Object.fromEntries(run.meta.scope.locales.map((l) => {
|
|
1787
|
+
const mine = caps.filter((c) => c.type === 'capture' && c.node === node.id && c.locale === l)
|
|
1788
|
+
return [l, node.kind === 'stack' ? { pages: new Set(mine.filter((c) => c.item).map((c) => c.item)).size } : [...new Set(mine.map((c) => c.viewport).filter(Boolean))]]
|
|
1789
|
+
}))
|
|
1790
|
+
}))
|
|
1791
|
+
say(JSON.stringify({ run: run.meta.id, dir: run.dir, env: run.meta.environment.name, locales: run.meta.scope.locales, steps }, null, 2))
|
|
1792
|
+
return
|
|
1793
|
+
}
|
|
1704
1794
|
say(`Run ${run.meta.id} · ${run.meta.environment.name} · ${run.meta.scope.locales.join(', ')}`)
|
|
1705
1795
|
if (env.kind === 'design') {
|
|
1706
1796
|
// per step: unchanged / changed here / changed in the draft, from the folder and the last sync (#42)
|
|
@@ -1753,6 +1843,11 @@ const commands = {
|
|
|
1753
1843
|
say(`Semantic criteria not evaluated:\n - ${[...byReason].slice(0, 8).map(([why, where]) => `${where.length > 1 ? `${where.length} captures (${where.slice(0, 3).join(', ')}${where.length > 3 ? ', …' : ''})` : where[0]}: ${why}`).join('\n - ')}`)
|
|
1754
1844
|
}
|
|
1755
1845
|
if (report.outdated.length) say(`Criteria changed after capture of ${report.outdated.join(', ')}: not evaluated. Run "kaloko evaluate --recheck --steps ${report.outdated.join(',')}": it rechecks the stored captures against the new criteria, deterministic and semantic, without walking again.`)
|
|
1846
|
+
// --json: the counts and every verdict a person or agent has to act on (fail, needs_review)
|
|
1847
|
+
if (args.json) {
|
|
1848
|
+
const pick = (f) => ({ step: f.node, ...(f.item ? { item: f.item } : {}), locale: f.locale, viewport: f.viewport, criterion: f.id, verdict: f.verdict, why: f.why || null, fix: f.fix || null })
|
|
1849
|
+
say(JSON.stringify({ run: run.meta.id, evaluated: report.evaluated, semantic: { model: report.model, calls: report.semantic_calls, cached: report.semantic_cached, via: report.via.qawalk ? 'kaloko' : report.via['own-key'] ? 'own-key' : null }, vision: report.vision || null, triage: report.triage || null, findings: (report.findings || []).filter((f) => f.verdict === 'fail' || f.verdict === 'needs_review').map(pick), skipped: report.skipped, outdated: report.outdated }, null, 2))
|
|
1850
|
+
}
|
|
1756
1851
|
},
|
|
1757
1852
|
|
|
1758
1853
|
async build (args) {
|
|
@@ -1760,8 +1855,11 @@ const commands = {
|
|
|
1760
1855
|
const manifest = await buildManifest({ run, scenario, config, root, catalog: await catalogFor(config, root) })
|
|
1761
1856
|
const file = await writePreview({ run, manifest })
|
|
1762
1857
|
const s = manifest.summary
|
|
1858
|
+
const why = attentionLines(manifest)
|
|
1859
|
+
// --json: the run id, its summary, the preview file and the AI verdicts to act on
|
|
1860
|
+
if (args.json) { say(JSON.stringify({ run: manifest.id, summary: s, preview: file, manifest: join(run.dir, 'run.json'), attention: why.map((l) => l.trim()) }, null, 2)); return file }
|
|
1763
1861
|
say(`${manifest.id}: ${s.pass} pass · ${s.partial} partial · ${s.fail} fail · ${s.skip} not checked\nPreview: ${file}`)
|
|
1764
|
-
|
|
1862
|
+
if (why.length) say(`AI verdicts to act on (score and why):\n${why.join('\n')}`)
|
|
1765
1863
|
return file
|
|
1766
1864
|
},
|
|
1767
1865
|
|
|
@@ -1774,7 +1872,7 @@ const commands = {
|
|
|
1774
1872
|
|
|
1775
1873
|
async preview (args) {
|
|
1776
1874
|
const file = await commands.build(args)
|
|
1777
|
-
say(pathToFileURL(file).href)
|
|
1875
|
+
if (!args.json) say(pathToFileURL(file).href)
|
|
1778
1876
|
},
|
|
1779
1877
|
|
|
1780
1878
|
async share (args) {
|
|
@@ -1789,6 +1887,7 @@ const commands = {
|
|
|
1789
1887
|
const meta = await readJson(join(run.dir, 'meta.json'))
|
|
1790
1888
|
meta.shared = { url: v.url, at: new Date().toISOString(), version: v.version, run: v.id }
|
|
1791
1889
|
await writeFile(join(run.dir, 'meta.json'), JSON.stringify(meta, null, 2) + '\n')
|
|
1890
|
+
if (args.json) say(JSON.stringify({ run: v.id, url: v.url, version: v.version ?? null, scenario: scenario.id }, null, 2))
|
|
1792
1891
|
return
|
|
1793
1892
|
}
|
|
1794
1893
|
let manifest = await buildManifest({ run, scenario, config, root, catalog: [] })
|
|
@@ -1819,7 +1918,10 @@ const commands = {
|
|
|
1819
1918
|
if (findings.length && strict && !args['allow-pii']) throw new UserError(`Not shared: the run holds ${findings.length} unmasked personal data value(s):\n${formatFindings(findings)}\nMask them (capture.mask in the scenario, or pii: redact for automatic detection) and capture the steps again. Values that are not personal data, such as a company contact, go under pii.allow in kaloko.config.yml. To share anyway: kaloko share --allow-pii.`)
|
|
1820
1919
|
if (findings.length && strict) say(`Sharing ${findings.length} unmasked personal data value(s), as --allow-pii says:\n${formatFindings(findings, 5)}`)
|
|
1821
1920
|
else if (findings.length) say(`This run shows ${findings.length} value(s) that look like personal data:\n${formatFindings(findings, 5)}\nShared anyway, because the run does not hide personal data. To hide it, add pii: redact to the scenario or the environment; values that are not personal data go under pii.allow in kaloko.config.yml.`)
|
|
1822
|
-
|
|
1921
|
+
// --expires: days (30), or with a unit that makes whole days (whole days: 48h is 2)
|
|
1922
|
+
const asked = parseDuration(args.expires, 'd', 'expires')
|
|
1923
|
+
if (asked != null && (!Number.isInteger(asked) || asked < 1)) throw new UsageError(`--expires counts whole days: ${args.expires} is ${Math.round(asked * 100) / 100} days. Use 30, 30d or 72h.`)
|
|
1924
|
+
const days = asked || config.share?.expires_days || 30
|
|
1823
1925
|
// by content hash (#28): files the organization already stores (earlier runs, an interrupted share) are not sent
|
|
1824
1926
|
let entries = await hashFiles(run.dir, [...files])
|
|
1825
1927
|
// a recording above the service's file limit stays local (with its poster and preview), the rest is shared
|
|
@@ -1843,7 +1945,7 @@ const commands = {
|
|
|
1843
1945
|
} catch (e) { if (e instanceof UserError && /limit|quota/.test(e.message)) throw e }
|
|
1844
1946
|
const created = await client.request('POST', `/orgs/${config.org}/runs?expires_days=${days}`, manifest)
|
|
1845
1947
|
await writeFile(join(run.dir, PROGRESS_FILE), JSON.stringify({ run: manifest.id, started_at: new Date().toISOString() }) + '\n').catch(() => {})
|
|
1846
|
-
const up = await uploadRunFiles({ client, org: config.org, runId: manifest.id, dir: run.dir, entries, missing, say })
|
|
1948
|
+
const up = await uploadRunFiles({ client, org: config.org, runId: manifest.id, dir: run.dir, entries, missing, say, ...(args.json ? { write: (t) => process.stderr.write(t) } : {}) })
|
|
1847
1949
|
if (up.mode === 'blobs') say(` ${up.uploaded} new file(s) sent (${fmtBytes(up.bytes)}), ${up.reused} unchanged file(s) reused`)
|
|
1848
1950
|
// experimental HTML views (#42): their bundles travel as blobs and attach to the run as revisions
|
|
1849
1951
|
const viewIds = new Set(manifest.nodes.flatMap((n) => Object.values(n.captures || {}).flatMap((vps) => Object.values(vps).map((c) => c.view?.revision).filter(Boolean))))
|
|
@@ -1869,10 +1971,14 @@ const commands = {
|
|
|
1869
1971
|
if (review) say(review)
|
|
1870
1972
|
const capped = expiryNote(days, created.expires_days)
|
|
1871
1973
|
if (capped) say(`Note: ${capped}`)
|
|
1974
|
+
let prComment = null
|
|
1872
1975
|
if (args.pr) {
|
|
1873
1976
|
const r = await upsertPrComment({ manifest, url: fin.url, cwd: root })
|
|
1977
|
+
prComment = r.ok ? { action: r.action, url: r.url } : { skipped: r.reason }
|
|
1874
1978
|
say(r.ok ? `PR comment ${r.action}: ${r.url}` : `PR comment skipped: ${r.reason}.`)
|
|
1875
1979
|
}
|
|
1980
|
+
// --json: what a script needs next, the run id and its canvas URL (the lines above went to stderr)
|
|
1981
|
+
if (args.json) say(JSON.stringify({ run: manifest.id, url: fin.url, scenario: scenario.id, files: files.size, expires_days: created.expires_days, review: fin.review || null, ...(args.pr ? { pr_comment: prComment } : {}) }, null, 2))
|
|
1876
1982
|
},
|
|
1877
1983
|
|
|
1878
1984
|
async sync (args) {
|
|
@@ -1889,7 +1995,7 @@ const commands = {
|
|
|
1889
1995
|
|
|
1890
1996
|
async design (args) {
|
|
1891
1997
|
if (args._[0] === 'import') return designImport(args)
|
|
1892
|
-
if (args._[0] !== 'lint') throw new
|
|
1998
|
+
if (args._[0] !== 'lint') throw new UsageError('Usage: kaloko design lint [--env <design env> | --dir design/] [--scenario file] [--all] [--strict] [--offline] [--json]\n kaloko design import figma <file-url|key|export folder> [--frames a,b] [--map frames.json] [--token-env FIGMA_TOKEN] [--publish]')
|
|
1893
1999
|
const { config, root } = await context(args)
|
|
1894
2000
|
let run = null
|
|
1895
2001
|
try { run = await resolveRun({ root, output: config.output, run: args.run }) } catch {}
|
|
@@ -2035,7 +2141,7 @@ const commands = {
|
|
|
2035
2141
|
const { config, root } = await context(args)
|
|
2036
2142
|
let runId = args._[0] || (typeof args.run === 'string' ? args.run : null)
|
|
2037
2143
|
if (!runId) { try { const r = await resolveRun({ root, output: config.output }); if (r.meta.shared) runId = r.meta.id } catch {} }
|
|
2038
|
-
if (!runId) throw new
|
|
2144
|
+
if (!runId) throw new UsageError('Usage: kaloko signoff <run-id> [--out dir] | kaloko signoff --verify <file.json|file.pdf>')
|
|
2039
2145
|
return signoffCmd({ config, args, say, client: service(config), runId })
|
|
2040
2146
|
},
|
|
2041
2147
|
|
|
@@ -2072,7 +2178,7 @@ const commands = {
|
|
|
2072
2178
|
if (typeof args.reopen === 'string') { await client.request('POST', `${base}/${encodeURIComponent(args.reopen)}/reopen`, {}); say(`✓ reopened ${args.reopen}`); return }
|
|
2073
2179
|
if (typeof args.delete === 'string') { const r = await client.request('DELETE', `${base}/${encodeURIComponent(args.delete)}`); say(`✓ deleted ${args.delete}${r.kept ? ' (kept as "deleted" because others replied)' : ''}`); return }
|
|
2074
2180
|
if (typeof args.edit === 'string') { await client.request('PATCH', `${base}/${encodeURIComponent(args.edit)}`, { note }); say(`✓ edited ${args.edit}`); return }
|
|
2075
|
-
if (!note.trim()) throw new
|
|
2181
|
+
if (!note.trim()) throw new UsageError('Write the comment with --note "…".')
|
|
2076
2182
|
const status = ['rejected', 'approved'].includes(args.status) ? args.status : 'comment'
|
|
2077
2183
|
const body = { note, status, node_id: typeof args.step === 'string' ? args.step : '', ...(typeof args.reply === 'string' ? { parent_id: args.reply } : {}), ...(typeof args.locale === 'string' ? { locale: args.locale } : {}) }
|
|
2078
2184
|
const c = await client.request('POST', base, body)
|
|
@@ -2083,8 +2189,23 @@ const commands = {
|
|
|
2083
2189
|
const { config } = await context(args)
|
|
2084
2190
|
const client = service(config)
|
|
2085
2191
|
const all = !!args.all || typeof args.scenario === 'string'
|
|
2086
|
-
const
|
|
2087
|
-
|
|
2192
|
+
const envName = envOption(args)
|
|
2193
|
+
// --limit N: at most N runs; --all follows the service's cursors to the last run, otherwise the newest RUNS_CAP;
|
|
2194
|
+
// --cursor continues where an earlier list stopped (the cursor it printed)
|
|
2195
|
+
if (args.limit !== undefined && !(Number(args.limit) >= 1)) throw new UsageError('--limit takes a number of runs, e.g. --limit 50.')
|
|
2196
|
+
const want = args.limit !== undefined ? Math.floor(Number(args.limit)) : args.all ? Infinity : RUNS_CAP
|
|
2197
|
+
const runs = []
|
|
2198
|
+
let cursor = typeof args.cursor === 'string' ? args.cursor : null
|
|
2199
|
+
let next = null
|
|
2200
|
+
do {
|
|
2201
|
+
const page = Math.min(1000, want - runs.length)
|
|
2202
|
+
const q = new URLSearchParams({ ...(all ? {} : { latest: '1' }), ...(typeof args.scenario === 'string' ? { scenario: args.scenario } : {}), ...(envName ? { environment: envName } : {}), limit: String(page), ...(cursor ? { cursor } : {}) })
|
|
2203
|
+
const got = await client.page(`/orgs/${config.org}/runs?${q}`)
|
|
2204
|
+
runs.push(...got.data)
|
|
2205
|
+
next = cursor = got.next
|
|
2206
|
+
} while (next && runs.length < want)
|
|
2207
|
+
// more runs than were listed: say how to go on (on stderr, so --json stays one JSON array)
|
|
2208
|
+
if (next) console.error(`Showing ${runs.length}; there are more. kaloko runs${args.all ? ' --all' : ''}${typeof args.scenario === 'string' ? ` --scenario ${args.scenario}` : ''}${envName ? ` --env ${envName}` : ''} --cursor ${next} lists the next ones${all && !args.all ? ', --all every one' : ''}.`)
|
|
2088
2209
|
if (args.json) { say(JSON.stringify(runs, null, 2)); return }
|
|
2089
2210
|
if (!runs.length) { say('No shared runs match.'); return }
|
|
2090
2211
|
for (const r of runs) {
|
|
@@ -2096,23 +2217,25 @@ const commands = {
|
|
|
2096
2217
|
|
|
2097
2218
|
async trash (args) {
|
|
2098
2219
|
const { config } = await context(args)
|
|
2099
|
-
if (!args._.length) throw new
|
|
2220
|
+
if (!args._.length) throw new UsageError('Usage: kaloko trash <run-id>...')
|
|
2100
2221
|
const client = service(config)
|
|
2101
2222
|
for (const id of args._) { await client.request('DELETE', `/orgs/${config.org}/runs/${encodeURIComponent(id)}`); say(`✓ ${id} moved to the trash (kaloko restore ${id} brings it back)`) }
|
|
2102
2223
|
},
|
|
2103
2224
|
|
|
2104
2225
|
async restore (args) {
|
|
2105
2226
|
const { config } = await context(args)
|
|
2106
|
-
if (args._.length !== 1) throw new
|
|
2227
|
+
if (args._.length !== 1) throw new UsageError('Usage: kaloko restore <run-id>')
|
|
2107
2228
|
await service(config).request('POST', `/orgs/${config.org}/runs/${encodeURIComponent(args._[0])}/restore`, {})
|
|
2108
2229
|
say(`✓ ${args._[0]} restored`)
|
|
2109
2230
|
},
|
|
2110
2231
|
|
|
2111
2232
|
async prune (args) {
|
|
2112
2233
|
const { config } = await context(args)
|
|
2113
|
-
const days =
|
|
2114
|
-
if (days != null && !Number.isInteger(days)) throw new
|
|
2115
|
-
|
|
2234
|
+
const days = parseDuration(args['older-than'], 'd', 'older-than')
|
|
2235
|
+
if (days != null && !Number.isInteger(days)) throw new UsageError(`--older-than counts whole days, e.g. 14, 14d or 48h; ${args['older-than']} is ${Math.round(days * 100) / 100} days.`)
|
|
2236
|
+
if (args.yes && args['dry-run']) throw new UsageError('--dry-run and --yes contradict each other: --dry-run (the default) lists, --yes moves runs to the trash.')
|
|
2237
|
+
const envName = envOption(args)
|
|
2238
|
+
const body = { dry_run: !args.yes, keep_latest: args.keep === undefined ? 3 : Number(args.keep), ...(typeof args.scenario === 'string' ? { scenario: args.scenario } : {}), ...(envName ? { environment: envName } : {}), ...(typeof args.project === 'string' ? { project: args.project } : {}), ...(days != null ? { older_than_days: days } : {}) }
|
|
2116
2239
|
const r = await service(config).request('POST', `/orgs/${config.org}/runs/prune`, body)
|
|
2117
2240
|
if (args.json) { say(JSON.stringify(r, null, 2)); return }
|
|
2118
2241
|
if (!r.trash.length) { say(`Nothing to clean up (keeping the newest ${r.keep_latest} per scenario and environment).`); return }
|
|
@@ -2125,23 +2248,25 @@ const commands = {
|
|
|
2125
2248
|
|
|
2126
2249
|
async keep (args) {
|
|
2127
2250
|
const { config } = await context(args)
|
|
2128
|
-
if (args._.length !== 1) throw new
|
|
2251
|
+
if (args._.length !== 1) throw new UsageError('Usage: kaloko keep <run-id> [--off]')
|
|
2129
2252
|
const r = await service(config).request('POST', `/orgs/${config.org}/runs/${encodeURIComponent(args._[0])}/keep`, { keep: !args.off })
|
|
2130
2253
|
say(r.kept ? `✓ ${args._[0]} is kept: it never expires and is never pruned` : `✓ ${args._[0]} is no longer kept`)
|
|
2131
2254
|
},
|
|
2132
2255
|
|
|
2133
2256
|
async archive (args) {
|
|
2134
2257
|
const { config } = await context(args)
|
|
2135
|
-
if (args._.length !== 1) throw new
|
|
2136
|
-
|
|
2137
|
-
|
|
2258
|
+
if (args._.length !== 1) throw new UsageError('Usage: kaloko archive <scenario-id> [--off]')
|
|
2259
|
+
// --off undoes it, as with keep and public; --undo is the older name
|
|
2260
|
+
const off = !!(args.off || args.undo)
|
|
2261
|
+
await service(config).request('POST', `/orgs/${config.org}/scenarios/${encodeURIComponent(args._[0])}/${off ? 'unarchive' : 'archive'}`, {})
|
|
2262
|
+
say(off ? `✓ ${args._[0]} is active again` : `✓ ${args._[0]} archived: hidden from the scenario list, its runs stay reachable (kaloko archive ${args._[0]} --off)`)
|
|
2138
2263
|
},
|
|
2139
2264
|
|
|
2140
2265
|
async login (args) {
|
|
2141
2266
|
// a project is optional: it only names the service URL, the organization and the token variable
|
|
2142
2267
|
let config = {}
|
|
2143
2268
|
const shell = { ...process.env } // before the project and user .env files are read
|
|
2144
|
-
try { ({ config } = await context(args)) } catch (e) { if (!(e instanceof UserError)) throw e }
|
|
2269
|
+
try { ({ config } = await context({ ...args, org: undefined })) } catch (e) { if (!(e instanceof UserError)) throw e }
|
|
2145
2270
|
const tokenEnv = config.share?.token_env || 'KALOKO_TOKEN'
|
|
2146
2271
|
const inShell = shell[tokenEnv]
|
|
2147
2272
|
const base = typeof args.url === 'string' ? args.url : serviceUrl(config)
|
|
@@ -2218,7 +2343,7 @@ const commands = {
|
|
|
2218
2343
|
async draft (args) {
|
|
2219
2344
|
const { root } = await context(args).catch(() => ({ root: process.cwd() }))
|
|
2220
2345
|
const urls = args._.filter((u) => /^https?:\/\//.test(u))
|
|
2221
|
-
if (!urls.length) throw new
|
|
2346
|
+
if (!urls.length) throw new UsageError('Usage: kaloko draft <url> [url…] (public pages of one site)')
|
|
2222
2347
|
const pages = []
|
|
2223
2348
|
for (const url of urls) {
|
|
2224
2349
|
let facts = {}
|
|
@@ -2266,6 +2391,7 @@ const commands = {
|
|
|
2266
2391
|
},
|
|
2267
2392
|
|
|
2268
2393
|
async export (args) {
|
|
2394
|
+
exportSubcommand(args)
|
|
2269
2395
|
const { config, root } = await context(args)
|
|
2270
2396
|
const client = service(config)
|
|
2271
2397
|
if (args.design) {
|
|
@@ -2336,7 +2462,7 @@ const commands = {
|
|
|
2336
2462
|
const engines = []
|
|
2337
2463
|
const scenarios = []
|
|
2338
2464
|
for (const f of await scenarioFiles(config, root).catch(() => [])) { try { const s = yaml.load(await readFile(f, 'utf8')) || {}; engines.push(...(s.browsers || [])); scenarios.push({ file: relative(root, f), product: s.product || null, module: s.module || null }) } catch {} }
|
|
2339
|
-
const r = await doctor({ config, root, file, fix: !!args.fix, engines, scenarios })
|
|
2465
|
+
const r = await doctor({ config, root, file, fix: !!args.fix, engines, scenarios, legacy: legacyInUse() })
|
|
2340
2466
|
say(args.json ? JSON.stringify(r, null, 2) : formatDoctor(r))
|
|
2341
2467
|
if (!r.ok) process.exitCode = 1
|
|
2342
2468
|
},
|
|
@@ -2344,7 +2470,7 @@ const commands = {
|
|
|
2344
2470
|
/** `kaloko changelog draft`: the Unreleased entry from merged work and the screens accepted runs show differently. */
|
|
2345
2471
|
async changelog (args) {
|
|
2346
2472
|
const sub = args._[0]
|
|
2347
|
-
if (sub !== 'draft') throw new
|
|
2473
|
+
if (sub !== 'draft') throw new UsageError('Usage: kaloko changelog draft [--product p] [--since <tag|version>] [--apply] [--json] [--offline]')
|
|
2348
2474
|
const { config, root } = await context(args)
|
|
2349
2475
|
const { pickProduct, draftChangelog, draftText, applyToChangelog } = await import('./lib/changelog.mjs')
|
|
2350
2476
|
const product = pickProduct(config, root, args.product)
|
|
@@ -2366,7 +2492,7 @@ const commands = {
|
|
|
2366
2492
|
/** `kaloko docs publish|check`: the docs folder on Kaloko per version, and what in it needs attention. */
|
|
2367
2493
|
async docs (args) {
|
|
2368
2494
|
const sub = args._[0]
|
|
2369
|
-
if (!['publish', 'check'].includes(sub)) throw new
|
|
2495
|
+
if (!['publish', 'check'].includes(sub)) throw new UsageError('Usage: kaloko docs publish [--product p] [--version latest|<v>] [--reviewed] [--dry-run] [--json]\n kaloko docs check [--product p] [--version v] [--offline] [--strict] [--json]')
|
|
2370
2496
|
const { config, root } = await context(args)
|
|
2371
2497
|
const { pickProduct } = await import('./lib/changelog.mjs')
|
|
2372
2498
|
const docs = await import('./lib/docs.mjs')
|
|
@@ -2397,7 +2523,7 @@ const commands = {
|
|
|
2397
2523
|
/** `kaloko products sync|list`: the products of the config on Kaloko, without a release; what the service has. */
|
|
2398
2524
|
async products (args) {
|
|
2399
2525
|
const sub = args._[0]
|
|
2400
|
-
if (!['sync', 'list'].includes(sub)) throw new
|
|
2526
|
+
if (!['sync', 'list'].includes(sub)) throw new UsageError('Usage: kaloko products sync [--product p] [--dry-run] [--json]\n kaloko products list [--json]')
|
|
2401
2527
|
const { config, root } = await context(args)
|
|
2402
2528
|
const lib = await import('./lib/changelog.mjs')
|
|
2403
2529
|
if (sub === 'list') {
|
|
@@ -2442,18 +2568,86 @@ const commands = {
|
|
|
2442
2568
|
say(lines.join('\n'))
|
|
2443
2569
|
},
|
|
2444
2570
|
|
|
2445
|
-
|
|
2571
|
+
/** `kaloko help [command]`: everything, or the entries of one command with the common options. */
|
|
2572
|
+
async help (args = { _: [] }) {
|
|
2573
|
+
const topic = args._?.[0]
|
|
2574
|
+
if (!topic) { say(HELP); return }
|
|
2575
|
+
// kaloko help export run: the entries of that sub-command (all of the command's when it has none by that name)
|
|
2576
|
+
const text = commands[topic] && topic !== 'help' ? helpFor(HELP, topic, COMMON_HELP, args._?.[1]) : null
|
|
2577
|
+
if (text) { say(text); return }
|
|
2578
|
+
unknownCommand(topic)
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
/** The version of this kaloko (package.json). */
|
|
2583
|
+
async function packageVersion () {
|
|
2584
|
+
return JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')).version
|
|
2585
|
+
}
|
|
2586
|
+
|
|
2587
|
+
/** "There is no command": the nearest command when it looks like a typo, the full help, exit code 2. */
|
|
2588
|
+
function unknownCommand (name, json = false) {
|
|
2589
|
+
const guess = closest(name, Object.keys(commands))
|
|
2590
|
+
const message = `There is no command "${name}".${guess ? ` Did you mean kaloko ${guess}?` : ''}`
|
|
2591
|
+
if (json) { console.error(JSON.stringify(jsonError(new UsageError(message, { next: 'kaloko help lists the commands.' })))); process.exitCode = EXIT.usage; return }
|
|
2592
|
+
console.error(`${message} The commands are below; kaloko help <command> explains one.\n`)
|
|
2593
|
+
say(HELP)
|
|
2594
|
+
process.exitCode = EXIT.usage
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
/** A line of output that is a JSON document (the --json answer), told apart from progress and notes. */
|
|
2598
|
+
const isJsonText = (text) => { const t = text.trim(); if (!/^[[{]/.test(t)) return false; try { JSON.parse(t); return true } catch { return false } }
|
|
2599
|
+
|
|
2600
|
+
/** The --json error: { error: { message, code, next } } (help/reference/cli.md → Machine output). */
|
|
2601
|
+
export function jsonError (error) {
|
|
2602
|
+
const usage = error instanceof UsageError
|
|
2603
|
+
return { error: { message: error.reason || error.message, code: typeof error.code === 'string' ? error.code : usage ? 'usage' : error instanceof UserError ? 'failed' : 'internal', next: error.next || (usage ? 'kaloko help <command> shows how to use it.' : null) } }
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
/** The exit code of an error: 2 usage, 3 the service cannot be reached, 4 no or a bad token, else 1. */
|
|
2607
|
+
export const exitCodeOf = (error) => error?.exitCode || (error instanceof UsageError ? EXIT.usage : EXIT.failed)
|
|
2608
|
+
|
|
2609
|
+
/**
|
|
2610
|
+
* Runs one command. `bin` is the name it was started by: the qawalk command from before the rename still works.
|
|
2611
|
+
* With --json, stdout carries only the JSON answer: every other line goes to stderr, and an error is one JSON line
|
|
2612
|
+
* on stderr ({ error: { message, code, next } }) with the same exit code as without --json.
|
|
2613
|
+
*/
|
|
2614
|
+
export async function main (argv, { bin = 'kaloko' } = {}) {
|
|
2615
|
+
resetLegacy()
|
|
2616
|
+
if (bin === 'qawalk') noteLegacy('the qawalk command', 'kaloko')
|
|
2617
|
+
const args = parseArgs(argv)
|
|
2618
|
+
const json = !!args.json
|
|
2619
|
+
const stdout = console.log
|
|
2620
|
+
if (json) console.log = (...m) => (isJsonText(m.join(' ')) ? stdout : console.error)(...m)
|
|
2621
|
+
try {
|
|
2622
|
+
await runCommand(args, json)
|
|
2623
|
+
} finally {
|
|
2624
|
+
if (json) console.log = stdout
|
|
2625
|
+
const hint = legacyHint()
|
|
2626
|
+
if (hint) console.error(hint)
|
|
2627
|
+
}
|
|
2446
2628
|
}
|
|
2447
2629
|
|
|
2448
|
-
|
|
2449
|
-
|
|
2630
|
+
async function runCommand (args, json) {
|
|
2631
|
+
if ((!args._.length && args.version) || args._[0] === 'version') { say(json ? JSON.stringify({ version: await packageVersion() }) : await packageVersion()); return }
|
|
2450
2632
|
const name = args._.shift() || 'help'
|
|
2451
|
-
const cmd = commands[name]
|
|
2452
|
-
if (!cmd) {
|
|
2633
|
+
const cmd = commands[name]
|
|
2634
|
+
if (!cmd) { if (args.help) say(HELP); else unknownCommand(name, json); return }
|
|
2635
|
+
// kaloko <command> --help: the command's help instead of running it
|
|
2636
|
+
if (args.help && name !== 'help') { say(helpFor(HELP, name, COMMON_HELP, args._[0]) || HELP); return }
|
|
2453
2637
|
try {
|
|
2638
|
+
for (const line of checkOptions(HELP, name, args)) console.error(line)
|
|
2454
2639
|
await cmd(args)
|
|
2455
2640
|
} catch (error) {
|
|
2456
|
-
|
|
2641
|
+
const known = error instanceof UserError
|
|
2642
|
+
if (json) {
|
|
2643
|
+
// the reason in one JSON line on stderr, the documented exit code; a bug also prints its stack before it
|
|
2644
|
+
if (!known) console.error(error?.stack || String(error))
|
|
2645
|
+
console.error(JSON.stringify(jsonError(error)))
|
|
2646
|
+
process.exitCode = exitCodeOf(error)
|
|
2647
|
+
return
|
|
2648
|
+
}
|
|
2649
|
+
// a usage mistake: the command's help says what is wrong; no support line, exit code 2
|
|
2650
|
+
if (error instanceof UsageError) { console.error(`${error.message}${/kaloko help /.test(error.message) ? '' : `\nkaloko help ${name} shows how to use it.`}`); process.exitCode = EXIT.usage } else if (known) { console.error(`${error.message}\n${SUPPORT_LINE}`); process.exitCode = exitCodeOf(error) } else {
|
|
2457
2651
|
console.error(`Kaloko stopped on an unexpected error (a bug on our side). Send the output below to ${SUPPORT_EMAIL} with the command you ran.`)
|
|
2458
2652
|
throw error
|
|
2459
2653
|
}
|