kaloko 0.12.2 → 0.13.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/package.json +2 -2
- package/skills/kaloko/SKILL.md +10 -1
- package/skills/kaloko/reference/ci.md +8 -0
- package/skills/kaloko/reference/documentation.md +4 -2
- package/skills/kaloko/reference/mcp.md +13 -5
- package/skills/kaloko/reference/recording.md +4 -2
- package/src/cli.mjs +122 -63
- package/src/lib/cli-args.mjs +139 -0
- package/src/lib/design-cli.mjs +9 -9
- package/src/lib/design-export.mjs +1 -1
- package/src/lib/integrations-cli.mjs +5 -5
- package/src/lib/screens-cli.mjs +4 -2
- package/src/lib/service.mjs +37 -12
- 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 +14 -1
- package/src/lib/video-cli.mjs +2 -2
- package/viewer/index.html +20 -20
package/src/cli.mjs
CHANGED
|
@@ -20,7 +20,7 @@ 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 } from './lib/util.mjs'
|
|
24
24
|
import { packageRoot } from './lib/schema.mjs'
|
|
25
25
|
import { compareRuns, formatComparison } from './lib/compare.mjs'
|
|
26
26
|
import { doctor, formatDoctor } from './lib/doctor.mjs'
|
|
@@ -36,6 +36,7 @@ import { upsertPrComment } from './lib/pr.mjs'
|
|
|
36
36
|
import { formatReviewQueue, shareReviewLine } from './lib/review.mjs'
|
|
37
37
|
import { htmlViewAllowed } from './lib/htmlview.mjs'
|
|
38
38
|
import { service, serviceUrl } from './lib/service.mjs'
|
|
39
|
+
import { parseArgs, helpFor, checkOptions, closest } from './lib/cli-args.mjs'
|
|
39
40
|
import { issueCmd, scheduleCmd } from './lib/integrations-cli.mjs'
|
|
40
41
|
import { login } from './lib/login.mjs'
|
|
41
42
|
import { initNextSteps, loginNextSteps } from './lib/onboarding.mjs'
|
|
@@ -65,6 +66,20 @@ import { attachAndroidWebView } from './lib/apps/webview.mjs'
|
|
|
65
66
|
import { piiSettings, piiFindings, piiRequested, guardAllow, formatFindings } from './lib/pii.mjs'
|
|
66
67
|
|
|
67
68
|
|
|
69
|
+
/** Options every command takes and the exit codes; the end of kaloko help and of the help of each command. */
|
|
70
|
+
const COMMON_HELP = `Common options (every command)
|
|
71
|
+
--run <id|dir> The run to act on (default: the current run, or KALOKO_RUN)
|
|
72
|
+
--config <file> Another kaloko.config.yml
|
|
73
|
+
--org <slug> Another organization than org: in the config
|
|
74
|
+
--json Machine-readable output, where a command prints data
|
|
75
|
+
--help, -h Help for one command, like kaloko help <command>
|
|
76
|
+
--version, -v The version of kaloko (also kaloko version)
|
|
77
|
+
KALOKO_AGENT=<name> Labels writes by an agent
|
|
78
|
+
|
|
79
|
+
Exit codes: 0 done · 1 checks found problems or the command failed · 2 usage error (unknown command or option,
|
|
80
|
+
missing argument)
|
|
81
|
+
`
|
|
82
|
+
|
|
68
83
|
const HELP = `kaloko — walk, assess and approve what your agents built, on one flow canvas
|
|
69
84
|
|
|
70
85
|
Usage: kaloko <command> [options]
|
|
@@ -107,7 +122,7 @@ Project
|
|
|
107
122
|
Run
|
|
108
123
|
start --scenario <file> --env <name> [--locales cs,en] [--viewports desktop,mobile] [--executor <name>]
|
|
109
124
|
[--app-version <v>] (default: the git tag on HEAD; documentation captures carry it)
|
|
110
|
-
Create a run directory and make it current
|
|
125
|
+
Create a run directory and make it current (--json: the run id and its folder)
|
|
111
126
|
[--viewports iphone-15,ipad-landscape,desktop] Viewport names of the scenario or device presets
|
|
112
127
|
(kaloko devices lists them; <preset>-landscape turns one sideways)
|
|
113
128
|
[--browsers chromium,webkit,firefox] [--color-schemes light,dark] [--reduced-motion no-preference,reduce]
|
|
@@ -182,7 +197,7 @@ Run
|
|
|
182
197
|
[--env e] [--locale l] [--viewport v] [--map "Title=step,…"] [--format f] [--out file]
|
|
183
198
|
Screenshots, recordings and traces from agents and tools as a run: into the
|
|
184
199
|
current run, a new run of --scenario, or an ad-hoc flow of the screens
|
|
185
|
-
status
|
|
200
|
+
status [--json] Show what the current run has captured
|
|
186
201
|
Scenarios may declare "sessions" (people in the flow); walk keeps one browser context per session and locale
|
|
187
202
|
|
|
188
203
|
Results
|
|
@@ -214,15 +229,18 @@ Results
|
|
|
214
229
|
comment --reply|--resolve|--reopen|--edit|--delete <id> [--note "…"] [--flow]
|
|
215
230
|
Comment, answer and close feedback from the CLI (resolve with "Fixed in <commit>")
|
|
216
231
|
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
|
|
232
|
+
runs [--scenario id] [--env e] [--all] [--json]
|
|
233
|
+
Shared runs: the newest per scenario (with "+ N older"); --all or --scenario lists every
|
|
234
|
+
run (the newest 300; --scenario and --env narrow the list)
|
|
219
235
|
trash <run-id>... | restore <run-id>
|
|
220
236
|
Move runs to the trash (uploader or admin) and back; restorable until purge
|
|
221
|
-
prune [--scenario id] [--
|
|
237
|
+
prune [--scenario id] [--env e] [--keep 3] [--older-than 14d] [--dry-run | --yes] [--json]
|
|
222
238
|
Keep the newest runs per scenario and environment plus kept, accepted, baseline,
|
|
223
|
-
referenced and commented runs; lists what would go to the trash
|
|
239
|
+
referenced and commented runs; lists what would go to the trash (--dry-run, the
|
|
240
|
+
default) unless --yes
|
|
224
241
|
keep <run-id> [--off] A kept run never expires and is never pruned
|
|
225
|
-
archive <scenario-id> [--
|
|
242
|
+
archive <scenario-id> [--off] Hide a retired scenario from the scenario list (admin); its runs stay reachable;
|
|
243
|
+
--off shows it again
|
|
226
244
|
inbox [--all] [--read] [--json] Your notifications on kaloko.app (mentions, returned runs, new runs, expiry); --read marks them read
|
|
227
245
|
calibrate [--golden labels.yml] [--scenario id [--limit 30]] [--target 0.95] [--json]
|
|
228
246
|
Semantic evaluator vs human labels: precision/recall per criterion at the
|
|
@@ -248,7 +266,7 @@ Results
|
|
|
248
266
|
Regions people drew on screenshots on kaloko.app (step detail → Mark a changing
|
|
249
267
|
region) go into the scenario's ignore: of their steps; walk them again after
|
|
250
268
|
screens [words] [--project p] [--product p] [--module m] [--scenario id] [--locale l] [--viewport v] [--device d]
|
|
251
|
-
[--status pass|partial|fail|skip] [--
|
|
269
|
+
[--status pass|partial|fail|skip] [--env e] [--designs] [--all] [--json] [--cursor c]
|
|
252
270
|
The screen library: the newest screenshot of every step across your flows, searched
|
|
253
271
|
by title, text, URL or id, with the canvas link of each (--designs adds design versions)
|
|
254
272
|
compare [--run id] [--baseline id | --design] [--json]
|
|
@@ -353,8 +371,7 @@ Design (environments with kind: design; live HTML steps in a folder)
|
|
|
353
371
|
A design version in open formats: HTML + assets, tokens.json (DTCG), scenario.yml,
|
|
354
372
|
screenshots and an index.html that opens from disk (default: the design reference)
|
|
355
373
|
|
|
356
|
-
|
|
357
|
-
`
|
|
374
|
+
${COMMON_HELP}`
|
|
358
375
|
|
|
359
376
|
/** A comment's drawing for the terminal: "· box 120,340 300×80 px on desktop" (CSS px when the size is known). */
|
|
360
377
|
export function regionText (g) {
|
|
@@ -397,19 +414,6 @@ async function walkVerdicts ({ prefetch, run, scenario, config, root }) {
|
|
|
397
414
|
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
415
|
}
|
|
399
416
|
|
|
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
417
|
const list = (v) => (typeof v === 'string' ? v.split(',').map((s) => s.trim()).filter(Boolean) : null)
|
|
414
418
|
// parallel walks (#27) print each locale's lines together, in locale order: `say` writes into the running unit's buffer
|
|
415
419
|
const output = new AsyncLocalStorage()
|
|
@@ -446,8 +450,15 @@ export function walkConcurrency (args, config, units, shared, cpus = availablePa
|
|
|
446
450
|
return Math.max(1, Math.min(units, 4, Math.floor(cpus / 2)))
|
|
447
451
|
}
|
|
448
452
|
|
|
453
|
+
/** --env names an environment everywhere; --environment is its older spelling on runs, prune and screens. */
|
|
454
|
+
const envOption = (args) => typeof args.env === 'string' ? args.env : typeof args.environment === 'string' ? args.environment : null
|
|
455
|
+
/** The most runs `kaloko runs` lists in one go. */
|
|
456
|
+
const RUNS_CAP = 300
|
|
457
|
+
|
|
449
458
|
async function context (args) {
|
|
450
459
|
const { config, root, file } = await loadConfig(process.cwd(), args.config)
|
|
460
|
+
// --org: another organization than the config's for this command (an agent working for several)
|
|
461
|
+
if (typeof args.org === 'string' && args.org) config.org = args.org
|
|
451
462
|
return { config, root, file }
|
|
452
463
|
}
|
|
453
464
|
|
|
@@ -463,7 +474,7 @@ async function runContext (args) {
|
|
|
463
474
|
}
|
|
464
475
|
|
|
465
476
|
function stepOf (scenario, id) {
|
|
466
|
-
if (!id) throw new
|
|
477
|
+
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
478
|
const node = scenario.nodes.find((n) => n.id === id)
|
|
468
479
|
if (!node) throw new UserError(`Unknown step "${id}". Steps: ${scenario.nodes.filter((n) => n.kind !== 'decision').map((n) => n.id).join(', ')}`)
|
|
469
480
|
return node
|
|
@@ -491,7 +502,7 @@ async function lintOrigins (config, env, { online = false } = {}) {
|
|
|
491
502
|
*/
|
|
492
503
|
async function designImport (args) {
|
|
493
504
|
const [, kind, source] = args._
|
|
494
|
-
if (kind !== 'figma' || !source) throw new
|
|
505
|
+
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
506
|
const { config, root } = await context(args)
|
|
496
507
|
let run = null
|
|
497
508
|
try { run = await resolveRun({ root, output: config.output, run: args.run }) } catch {}
|
|
@@ -506,22 +517,24 @@ async function designImport (args) {
|
|
|
506
517
|
const viewports = Object.fromEntries(baseViewports(scope).map((v) => [v, scope.viewports[v]]))
|
|
507
518
|
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
519
|
const dir = typeof args.dir === 'string' ? resolve(args.dir) : designDir(env, root)
|
|
520
|
+
// --json: standard output is the JSON alone; progress and next steps go to standard error
|
|
521
|
+
const log = args.json ? (...m) => console.error(...m) : say
|
|
509
522
|
const r = await importFigma({
|
|
510
523
|
source, scenario, viewports, locales: scenario.locales || ['default'], dir,
|
|
511
524
|
token: process.env[tokenVar] || (tokenVar === 'FIGMA_TOKEN' ? process.env.FIGMA_ACCESS_TOKEN : null) || null,
|
|
512
525
|
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
|
|
526
|
+
tokensMode: ['write', 'skip'].includes(args.tokens) ? args.tokens : 'auto', force: !!args.force, dryRun: !!args['dry-run'], say: log
|
|
514
527
|
})
|
|
515
528
|
if (args.json) say(JSON.stringify(r, null, 2))
|
|
516
529
|
else say(formatImport(r, { dir: relative(process.cwd(), dir) || '.' }))
|
|
517
530
|
if (args['dry-run']) return r
|
|
518
531
|
const lint = await lintDesign({ dir, scenario, origins: await lintOrigins(config, env) })
|
|
519
|
-
if (lint.errors)
|
|
532
|
+
if (lint.errors) log(`\n${formatLint(lint, { max: 10 })}`)
|
|
520
533
|
if (!args.publish) {
|
|
521
|
-
|
|
534
|
+
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
535
|
return r
|
|
523
536
|
}
|
|
524
|
-
await commands.start({ ...args, _: [], scenario: scenarioFile, env: env.name })
|
|
537
|
+
await commands.start({ ...args, _: [], scenario: scenarioFile, env: env.name, json: false })
|
|
525
538
|
const current = await resolveRun({ root, output: config.output })
|
|
526
539
|
await commands.walk({ _: [], run: current.dir })
|
|
527
540
|
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 +547,7 @@ async function scenarioIdOf (args, config, root) {
|
|
|
534
547
|
return args.scenario
|
|
535
548
|
}
|
|
536
549
|
try { return (await resolveRun({ root, output: config.output, run: args.run })).meta.scenario.id } catch {}
|
|
537
|
-
throw new
|
|
550
|
+
throw new UsageError('--scenario <id|file> is required when there is no current run.')
|
|
538
551
|
}
|
|
539
552
|
|
|
540
553
|
/**
|
|
@@ -943,7 +956,7 @@ const commands = {
|
|
|
943
956
|
if (!exists) {
|
|
944
957
|
let tpl = await readFile(join(packageRoot, 'templates', 'kaloko.config.yml'), 'utf8')
|
|
945
958
|
if (typeof args.org === 'string') {
|
|
946
|
-
if (!/^[a-z0-9][a-z0-9-]{1,40}$/.test(args.org)) throw new
|
|
959
|
+
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
960
|
tpl = tpl.replace(/^org: .*$/m, `org: ${args.org}`)
|
|
948
961
|
}
|
|
949
962
|
if (detected) {
|
|
@@ -956,7 +969,7 @@ const commands = {
|
|
|
956
969
|
} else if (!existsSync(join(cwd, LEGACY_CONFIG_FILE))) say(`${CONFIG_FILE} already exists; kept.`)
|
|
957
970
|
// the visual changelog (docs/DOCS-CHANGELOG.md): asked once; --docs=off|changelog|full for scripts and agents
|
|
958
971
|
let docsMode = typeof args.docs === 'string' ? args.docs : null
|
|
959
|
-
if (docsMode && !['off', 'changelog', 'full'].includes(docsMode)) throw new
|
|
972
|
+
if (docsMode && !['off', 'changelog', 'full'].includes(docsMode)) throw new UsageError('--docs must be off, changelog or full.')
|
|
960
973
|
if (!docsMode && process.stdin.isTTY && process.stdout.isTTY && !process.env.CI && !process.env.NODE_TEST_CONTEXT) {
|
|
961
974
|
const rl = (await import('node:readline/promises')).createInterface({ input: process.stdin, output: process.stdout })
|
|
962
975
|
const a = (await rl.question('Keep a visual changelog? (recommended) [Y/n] ')).trim().toLowerCase(); rl.close()
|
|
@@ -987,7 +1000,7 @@ const commands = {
|
|
|
987
1000
|
}
|
|
988
1001
|
} else if (!docsMode) say('Visual changelog: kaloko init --docs=changelog adds CHANGELOG.md and the config block (recommended).')
|
|
989
1002
|
const agent = typeof args.agent === 'string' ? args.agent : 'none'
|
|
990
|
-
if (!['claude', 'codex', 'all', 'none'].includes(agent)) throw new
|
|
1003
|
+
if (!['claude', 'codex', 'all', 'none'].includes(agent)) throw new UsageError('--agent must be claude, codex, all or none.')
|
|
991
1004
|
const { cp, mkdir, appendFile, rm } = await import('node:fs/promises')
|
|
992
1005
|
const skill = join(packageRoot, 'skills', 'kaloko')
|
|
993
1006
|
const targets = { claude: ['.claude/skills/kaloko'], codex: ['skills/kaloko'], all: ['.claude/skills/kaloko', 'skills/kaloko'], none: [] }[agent]
|
|
@@ -1047,7 +1060,7 @@ const commands = {
|
|
|
1047
1060
|
},
|
|
1048
1061
|
|
|
1049
1062
|
async validate (args) {
|
|
1050
|
-
if (!args._.length) throw new
|
|
1063
|
+
if (!args._.length) throw new UsageError('Usage: kaloko validate <scenario.yml>...')
|
|
1051
1064
|
let failed = 0
|
|
1052
1065
|
for (const file of args._) {
|
|
1053
1066
|
let scenario
|
|
@@ -1078,7 +1091,7 @@ const commands = {
|
|
|
1078
1091
|
|
|
1079
1092
|
async start (args) {
|
|
1080
1093
|
const { config, root } = await context(args)
|
|
1081
|
-
if (!args.scenario || !args.env) throw new
|
|
1094
|
+
if (!args.scenario || !args.env) throw new UsageError('Usage: kaloko start --scenario <file> --env <name>')
|
|
1082
1095
|
const scenarioFile = resolve(args.scenario)
|
|
1083
1096
|
const scenario = await loadScenario(scenarioFile)
|
|
1084
1097
|
const env = environment(config, args.env)
|
|
@@ -1099,9 +1112,11 @@ const commands = {
|
|
|
1099
1112
|
// the product the flow documents (visual changelog): its tag, or the only product of the config
|
|
1100
1113
|
const product = scenario.product || (Object.keys(config.products || {}).length === 1 ? Object.keys(config.products)[0] : null)
|
|
1101
1114
|
const { dir, meta } = await createRun({ output: config.output, scenario, scenarioFile, env, scope, git, executor, root, project, issue, appVersion: appVer, product, pii })
|
|
1115
|
+
const addr = recipient(env.mail, meta.short)
|
|
1116
|
+
// --json: what a script needs to pin the run (KALOKO_RUN) and find its files
|
|
1117
|
+
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
1118
|
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
1119
|
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
1120
|
if (addr) say(` test e-mail: ${addr}`)
|
|
1106
1121
|
say(` other agents may start runs here too: pin this one with --run ${meta.id} or KALOKO_RUN=${meta.id}`)
|
|
1107
1122
|
},
|
|
@@ -1501,7 +1516,7 @@ const commands = {
|
|
|
1501
1516
|
*/
|
|
1502
1517
|
async import (args) {
|
|
1503
1518
|
const from = args._[0]
|
|
1504
|
-
if (!from) throw new
|
|
1519
|
+
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
1520
|
const { config, root } = await context(args)
|
|
1506
1521
|
let source
|
|
1507
1522
|
try { source = await readSource(resolve(from), typeof args.format === 'string' ? args.format : 'auto') } catch (e) { throw new UserError(`kaloko import: ${e.message}`) }
|
|
@@ -1581,7 +1596,7 @@ const commands = {
|
|
|
1581
1596
|
console.log(preview.url)
|
|
1582
1597
|
return
|
|
1583
1598
|
}
|
|
1584
|
-
if (!['github', 'gitlab'].includes(action)) throw new
|
|
1599
|
+
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
1600
|
const { config, root, file } = await context(args)
|
|
1586
1601
|
if (args.release) {
|
|
1587
1602
|
// release-tool integrations: record each published release on Kaloko (docs/DOCS-CHANGELOG.md)
|
|
@@ -1666,7 +1681,7 @@ const commands = {
|
|
|
1666
1681
|
say(rows.length ? rows.join('\n') : 'No accounts in kaloko.config.yml (environments.<env>.accounts).')
|
|
1667
1682
|
return
|
|
1668
1683
|
}
|
|
1669
|
-
if (action !== 'save' || typeof args.env !== 'string' || typeof args.account !== 'string') throw new
|
|
1684
|
+
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
1685
|
const env = environment(config, args.env)
|
|
1671
1686
|
if (env.readonly) throw new UserError(`Environment ${env.name} is readonly; saved sign-ins are for accounts on non-production environments.`)
|
|
1672
1687
|
const account = accountFor(env, args.account, { root, config })
|
|
@@ -1690,7 +1705,7 @@ const commands = {
|
|
|
1690
1705
|
},
|
|
1691
1706
|
|
|
1692
1707
|
async session (args) {
|
|
1693
|
-
if (args._[0] !== 'reset') throw new
|
|
1708
|
+
if (args._[0] !== 'reset') throw new UsageError('Usage: kaloko session reset')
|
|
1694
1709
|
const { config, root, env } = await runContext(args)
|
|
1695
1710
|
const conn = await connectBrowser({ root, config, env })
|
|
1696
1711
|
if (!conn) throw new UserError('Start the browser first: kaloko browser start')
|
|
@@ -1701,6 +1716,18 @@ const commands = {
|
|
|
1701
1716
|
async status (args) {
|
|
1702
1717
|
const { config, root, run, scenario, env } = await runContext(args)
|
|
1703
1718
|
const caps = await readCaptures(run.dir)
|
|
1719
|
+
if (args.json && env.kind !== 'design') {
|
|
1720
|
+
// per step and locale: the viewports captured so far (stack steps: the pages)
|
|
1721
|
+
const steps = scenario.nodes.filter((n) => n.kind !== 'decision').map((node) => ({
|
|
1722
|
+
id: node.id, kind: node.kind, strategy: node.execution?.strategy || null,
|
|
1723
|
+
captured: Object.fromEntries(run.meta.scope.locales.map((l) => {
|
|
1724
|
+
const mine = caps.filter((c) => c.type === 'capture' && c.node === node.id && c.locale === l)
|
|
1725
|
+
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))]]
|
|
1726
|
+
}))
|
|
1727
|
+
}))
|
|
1728
|
+
say(JSON.stringify({ run: run.meta.id, dir: run.dir, env: run.meta.environment.name, locales: run.meta.scope.locales, steps }, null, 2))
|
|
1729
|
+
return
|
|
1730
|
+
}
|
|
1704
1731
|
say(`Run ${run.meta.id} · ${run.meta.environment.name} · ${run.meta.scope.locales.join(', ')}`)
|
|
1705
1732
|
if (env.kind === 'design') {
|
|
1706
1733
|
// per step: unchanged / changed here / changed in the draft, from the folder and the last sync (#42)
|
|
@@ -1889,7 +1916,7 @@ const commands = {
|
|
|
1889
1916
|
|
|
1890
1917
|
async design (args) {
|
|
1891
1918
|
if (args._[0] === 'import') return designImport(args)
|
|
1892
|
-
if (args._[0] !== 'lint') throw new
|
|
1919
|
+
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
1920
|
const { config, root } = await context(args)
|
|
1894
1921
|
let run = null
|
|
1895
1922
|
try { run = await resolveRun({ root, output: config.output, run: args.run }) } catch {}
|
|
@@ -2035,7 +2062,7 @@ const commands = {
|
|
|
2035
2062
|
const { config, root } = await context(args)
|
|
2036
2063
|
let runId = args._[0] || (typeof args.run === 'string' ? args.run : null)
|
|
2037
2064
|
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
|
|
2065
|
+
if (!runId) throw new UsageError('Usage: kaloko signoff <run-id> [--out dir] | kaloko signoff --verify <file.json|file.pdf>')
|
|
2039
2066
|
return signoffCmd({ config, args, say, client: service(config), runId })
|
|
2040
2067
|
},
|
|
2041
2068
|
|
|
@@ -2072,7 +2099,7 @@ const commands = {
|
|
|
2072
2099
|
if (typeof args.reopen === 'string') { await client.request('POST', `${base}/${encodeURIComponent(args.reopen)}/reopen`, {}); say(`✓ reopened ${args.reopen}`); return }
|
|
2073
2100
|
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
2101
|
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
|
|
2102
|
+
if (!note.trim()) throw new UsageError('Write the comment with --note "…".')
|
|
2076
2103
|
const status = ['rejected', 'approved'].includes(args.status) ? args.status : 'comment'
|
|
2077
2104
|
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
2105
|
const c = await client.request('POST', base, body)
|
|
@@ -2083,8 +2110,11 @@ const commands = {
|
|
|
2083
2110
|
const { config } = await context(args)
|
|
2084
2111
|
const client = service(config)
|
|
2085
2112
|
const all = !!args.all || typeof args.scenario === 'string'
|
|
2086
|
-
const
|
|
2113
|
+
const envName = envOption(args)
|
|
2114
|
+
const q = new URLSearchParams({ ...(all ? {} : { latest: '1' }), ...(typeof args.scenario === 'string' ? { scenario: args.scenario } : {}), ...(envName ? { environment: envName } : {}), limit: String(RUNS_CAP) })
|
|
2087
2115
|
const runs = await client.request('GET', `/orgs/${config.org}/runs?${q}`)
|
|
2116
|
+
// the service answers at most RUNS_CAP: say so (on stderr, so --json stays one JSON array)
|
|
2117
|
+
if (runs.length >= RUNS_CAP) console.error(`Showing the newest ${RUNS_CAP}; there may be more. Narrow the list with --scenario <id> or --env <name>.`)
|
|
2088
2118
|
if (args.json) { say(JSON.stringify(runs, null, 2)); return }
|
|
2089
2119
|
if (!runs.length) { say('No shared runs match.'); return }
|
|
2090
2120
|
for (const r of runs) {
|
|
@@ -2096,14 +2126,14 @@ const commands = {
|
|
|
2096
2126
|
|
|
2097
2127
|
async trash (args) {
|
|
2098
2128
|
const { config } = await context(args)
|
|
2099
|
-
if (!args._.length) throw new
|
|
2129
|
+
if (!args._.length) throw new UsageError('Usage: kaloko trash <run-id>...')
|
|
2100
2130
|
const client = service(config)
|
|
2101
2131
|
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
2132
|
},
|
|
2103
2133
|
|
|
2104
2134
|
async restore (args) {
|
|
2105
2135
|
const { config } = await context(args)
|
|
2106
|
-
if (args._.length !== 1) throw new
|
|
2136
|
+
if (args._.length !== 1) throw new UsageError('Usage: kaloko restore <run-id>')
|
|
2107
2137
|
await service(config).request('POST', `/orgs/${config.org}/runs/${encodeURIComponent(args._[0])}/restore`, {})
|
|
2108
2138
|
say(`✓ ${args._[0]} restored`)
|
|
2109
2139
|
},
|
|
@@ -2111,8 +2141,10 @@ const commands = {
|
|
|
2111
2141
|
async prune (args) {
|
|
2112
2142
|
const { config } = await context(args)
|
|
2113
2143
|
const days = typeof args['older-than'] === 'string' ? Number(args['older-than'].replace(/d$/, '')) : null
|
|
2114
|
-
if (days != null && !Number.isInteger(days)) throw new
|
|
2115
|
-
|
|
2144
|
+
if (days != null && !Number.isInteger(days)) throw new UsageError('--older-than takes days, e.g. 14d.')
|
|
2145
|
+
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.')
|
|
2146
|
+
const envName = envOption(args)
|
|
2147
|
+
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
2148
|
const r = await service(config).request('POST', `/orgs/${config.org}/runs/prune`, body)
|
|
2117
2149
|
if (args.json) { say(JSON.stringify(r, null, 2)); return }
|
|
2118
2150
|
if (!r.trash.length) { say(`Nothing to clean up (keeping the newest ${r.keep_latest} per scenario and environment).`); return }
|
|
@@ -2125,23 +2157,25 @@ const commands = {
|
|
|
2125
2157
|
|
|
2126
2158
|
async keep (args) {
|
|
2127
2159
|
const { config } = await context(args)
|
|
2128
|
-
if (args._.length !== 1) throw new
|
|
2160
|
+
if (args._.length !== 1) throw new UsageError('Usage: kaloko keep <run-id> [--off]')
|
|
2129
2161
|
const r = await service(config).request('POST', `/orgs/${config.org}/runs/${encodeURIComponent(args._[0])}/keep`, { keep: !args.off })
|
|
2130
2162
|
say(r.kept ? `✓ ${args._[0]} is kept: it never expires and is never pruned` : `✓ ${args._[0]} is no longer kept`)
|
|
2131
2163
|
},
|
|
2132
2164
|
|
|
2133
2165
|
async archive (args) {
|
|
2134
2166
|
const { config } = await context(args)
|
|
2135
|
-
if (args._.length !== 1) throw new
|
|
2136
|
-
|
|
2137
|
-
|
|
2167
|
+
if (args._.length !== 1) throw new UsageError('Usage: kaloko archive <scenario-id> [--off]')
|
|
2168
|
+
// --off undoes it, as with keep and public; --undo is the older name
|
|
2169
|
+
const off = !!(args.off || args.undo)
|
|
2170
|
+
await service(config).request('POST', `/orgs/${config.org}/scenarios/${encodeURIComponent(args._[0])}/${off ? 'unarchive' : 'archive'}`, {})
|
|
2171
|
+
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
2172
|
},
|
|
2139
2173
|
|
|
2140
2174
|
async login (args) {
|
|
2141
2175
|
// a project is optional: it only names the service URL, the organization and the token variable
|
|
2142
2176
|
let config = {}
|
|
2143
2177
|
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 }
|
|
2178
|
+
try { ({ config } = await context({ ...args, org: undefined })) } catch (e) { if (!(e instanceof UserError)) throw e }
|
|
2145
2179
|
const tokenEnv = config.share?.token_env || 'KALOKO_TOKEN'
|
|
2146
2180
|
const inShell = shell[tokenEnv]
|
|
2147
2181
|
const base = typeof args.url === 'string' ? args.url : serviceUrl(config)
|
|
@@ -2218,7 +2252,7 @@ const commands = {
|
|
|
2218
2252
|
async draft (args) {
|
|
2219
2253
|
const { root } = await context(args).catch(() => ({ root: process.cwd() }))
|
|
2220
2254
|
const urls = args._.filter((u) => /^https?:\/\//.test(u))
|
|
2221
|
-
if (!urls.length) throw new
|
|
2255
|
+
if (!urls.length) throw new UsageError('Usage: kaloko draft <url> [url…] (public pages of one site)')
|
|
2222
2256
|
const pages = []
|
|
2223
2257
|
for (const url of urls) {
|
|
2224
2258
|
let facts = {}
|
|
@@ -2344,7 +2378,7 @@ const commands = {
|
|
|
2344
2378
|
/** `kaloko changelog draft`: the Unreleased entry from merged work and the screens accepted runs show differently. */
|
|
2345
2379
|
async changelog (args) {
|
|
2346
2380
|
const sub = args._[0]
|
|
2347
|
-
if (sub !== 'draft') throw new
|
|
2381
|
+
if (sub !== 'draft') throw new UsageError('Usage: kaloko changelog draft [--product p] [--since <tag|version>] [--apply] [--json] [--offline]')
|
|
2348
2382
|
const { config, root } = await context(args)
|
|
2349
2383
|
const { pickProduct, draftChangelog, draftText, applyToChangelog } = await import('./lib/changelog.mjs')
|
|
2350
2384
|
const product = pickProduct(config, root, args.product)
|
|
@@ -2366,7 +2400,7 @@ const commands = {
|
|
|
2366
2400
|
/** `kaloko docs publish|check`: the docs folder on Kaloko per version, and what in it needs attention. */
|
|
2367
2401
|
async docs (args) {
|
|
2368
2402
|
const sub = args._[0]
|
|
2369
|
-
if (!['publish', 'check'].includes(sub)) throw new
|
|
2403
|
+
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
2404
|
const { config, root } = await context(args)
|
|
2371
2405
|
const { pickProduct } = await import('./lib/changelog.mjs')
|
|
2372
2406
|
const docs = await import('./lib/docs.mjs')
|
|
@@ -2397,7 +2431,7 @@ const commands = {
|
|
|
2397
2431
|
/** `kaloko products sync|list`: the products of the config on Kaloko, without a release; what the service has. */
|
|
2398
2432
|
async products (args) {
|
|
2399
2433
|
const sub = args._[0]
|
|
2400
|
-
if (!['sync', 'list'].includes(sub)) throw new
|
|
2434
|
+
if (!['sync', 'list'].includes(sub)) throw new UsageError('Usage: kaloko products sync [--product p] [--dry-run] [--json]\n kaloko products list [--json]')
|
|
2401
2435
|
const { config, root } = await context(args)
|
|
2402
2436
|
const lib = await import('./lib/changelog.mjs')
|
|
2403
2437
|
if (sub === 'list') {
|
|
@@ -2442,18 +2476,43 @@ const commands = {
|
|
|
2442
2476
|
say(lines.join('\n'))
|
|
2443
2477
|
},
|
|
2444
2478
|
|
|
2445
|
-
|
|
2479
|
+
/** `kaloko help [command]`: everything, or the entries of one command with the common options. */
|
|
2480
|
+
async help (args = { _: [] }) {
|
|
2481
|
+
const topic = args._?.[0]
|
|
2482
|
+
if (!topic) { say(HELP); return }
|
|
2483
|
+
const text = commands[topic] && topic !== 'help' ? helpFor(HELP, topic, COMMON_HELP) : null
|
|
2484
|
+
if (text) { say(text); return }
|
|
2485
|
+
unknownCommand(topic)
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2488
|
+
|
|
2489
|
+
/** The version of this kaloko (package.json). */
|
|
2490
|
+
async function packageVersion () {
|
|
2491
|
+
return JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')).version
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
/** "There is no command": the nearest command when it looks like a typo, the full help, exit code 2. */
|
|
2495
|
+
function unknownCommand (name) {
|
|
2496
|
+
const guess = closest(name, Object.keys(commands))
|
|
2497
|
+
console.error(`There is no command "${name}".${guess ? ` Did you mean kaloko ${guess}?` : ''} The commands are below; kaloko help <command> explains one.\n`)
|
|
2498
|
+
say(HELP)
|
|
2499
|
+
process.exitCode = 2
|
|
2446
2500
|
}
|
|
2447
2501
|
|
|
2448
2502
|
export async function main (argv) {
|
|
2449
|
-
const args =
|
|
2503
|
+
const args = parseArgs(argv)
|
|
2504
|
+
if ((!args._.length && args.version) || args._[0] === 'version') { say(await packageVersion()); return }
|
|
2450
2505
|
const name = args._.shift() || 'help'
|
|
2451
|
-
const cmd = commands[name]
|
|
2452
|
-
if (!cmd) {
|
|
2506
|
+
const cmd = commands[name]
|
|
2507
|
+
if (!cmd) { if (args.help) say(HELP); else unknownCommand(name); return }
|
|
2508
|
+
// kaloko <command> --help: the command's help instead of running it
|
|
2509
|
+
if (args.help && name !== 'help') { say(helpFor(HELP, name, COMMON_HELP) || HELP); return }
|
|
2453
2510
|
try {
|
|
2511
|
+
for (const line of checkOptions(HELP, name, args)) console.error(line)
|
|
2454
2512
|
await cmd(args)
|
|
2455
2513
|
} catch (error) {
|
|
2456
|
-
|
|
2514
|
+
// a usage mistake: the command's help says what is wrong; no support line, exit code 2
|
|
2515
|
+
if (error instanceof UsageError) { console.error(`${error.message}${/kaloko help /.test(error.message) ? '' : `\nkaloko help ${name} shows how to use it.`}`); process.exitCode = 2 } else if (error instanceof UserError) { console.error(`${error.message}\n${SUPPORT_LINE}`); process.exitCode = 1 } else {
|
|
2457
2516
|
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
2517
|
throw error
|
|
2459
2518
|
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// The command line of kaloko: arguments, the help of one command and the options each command takes. Both come from
|
|
2
|
+
// the help text (src/cli.mjs HELP), so a documented option is an accepted one; HIDDEN adds aliases and options kept
|
|
3
|
+
// for older scripts that the help no longer shows.
|
|
4
|
+
import { UsageError } from './util.mjs'
|
|
5
|
+
|
|
6
|
+
/** Options every command takes (Common options in the help). */
|
|
7
|
+
export const COMMON_OPTIONS = ['run', 'config', 'org', 'json', 'help']
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Accepted options the help does not show: aliases of renamed ones (--environment is --env, --undo is --off) and
|
|
11
|
+
* options of sub-commands or older scripts. Each name here is read by the command's code.
|
|
12
|
+
*/
|
|
13
|
+
export const HIDDEN = {
|
|
14
|
+
prune: ['environment', 'project'],
|
|
15
|
+
runs: ['environment'],
|
|
16
|
+
screens: ['environment', 'q', 'source', 'limit'],
|
|
17
|
+
archive: ['undo'],
|
|
18
|
+
start: ['note', 'color_schemes', 'reduced_motion', 'forced_colors', 'display_modes', 'token_modes'],
|
|
19
|
+
design: ['dir', 'token', 'name', 'notes', 'branch', 'allow-pii', 'env'],
|
|
20
|
+
export: ['server-pdf', 'branch'],
|
|
21
|
+
walk: ['items', 'viewports', 'no-cache', 'record-quality', 'trace', 'offline', 'files'],
|
|
22
|
+
affected: ['no-upstream'],
|
|
23
|
+
open: ['page'],
|
|
24
|
+
mail: ['viewports'],
|
|
25
|
+
issue: ['locale', 'viewport', 'target', 'state'],
|
|
26
|
+
video: ['quality', 'card-seconds', 'viewport'],
|
|
27
|
+
spec: ['remote', 'scenario', 'version', 'step'],
|
|
28
|
+
drift: ['locale', 'viewport', 'version', 'scenario', 'step'],
|
|
29
|
+
history: ['lang'],
|
|
30
|
+
tokens: ['env', 'version'],
|
|
31
|
+
stability: ['scenario'],
|
|
32
|
+
status: ['branch'],
|
|
33
|
+
feedback: ['branch'],
|
|
34
|
+
share: ['branch', 'name', 'notes', 'notes-preview', 'strict', 'mine', 'theirs', 'lang'],
|
|
35
|
+
sync: ['mine', 'theirs', 'branch', 'strict'],
|
|
36
|
+
reference: ['branch'],
|
|
37
|
+
branch: ['from', 'git', 'take-branch', 'keep-main', 'scenario'],
|
|
38
|
+
schedule: ['force'],
|
|
39
|
+
comment: ['locale'],
|
|
40
|
+
capture: ['stability'],
|
|
41
|
+
import: ['executor', 'config', 'locales', 'viewports', 'id', 'title', 'force'],
|
|
42
|
+
browser: ['env', 'run'],
|
|
43
|
+
ci: ['wait', 'github-env', 'dotenv']
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Commands whose unknown options stop the command instead of a warning: they delete, publish or change access. */
|
|
47
|
+
export const STRICT = new Set(['prune', 'trash', 'restore', 'keep', 'archive', 'public', 'release', 'comment'])
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* argv → { _: positional, <option>: value | true }. `--key=value` splits at the first `=` only; `--key value` takes
|
|
51
|
+
* the next word unless it is another option; `-h` and `-v` are --help and --version.
|
|
52
|
+
*/
|
|
53
|
+
export function parseArgs (argv) {
|
|
54
|
+
const args = { _: [] }
|
|
55
|
+
const short = { '-h': 'help', '-v': 'version' }
|
|
56
|
+
for (let i = 0; i < argv.length; i++) {
|
|
57
|
+
const a = argv[i]
|
|
58
|
+
if (a.startsWith('--')) {
|
|
59
|
+
const eq = a.indexOf('=')
|
|
60
|
+
const k = eq < 0 ? a.slice(2) : a.slice(2, eq)
|
|
61
|
+
const next = argv[i + 1]
|
|
62
|
+
if (eq >= 0) args[k] = a.slice(eq + 1)
|
|
63
|
+
else if (next && !next.startsWith('--') && !short[next]) args[k] = argv[++i]
|
|
64
|
+
else args[k] = true
|
|
65
|
+
} else if (short[a]) args[short[a]] = true
|
|
66
|
+
else args._.push(a)
|
|
67
|
+
}
|
|
68
|
+
return args
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The help text as entries: each " <command> …" line with its continuation lines, the commands it documents (the
|
|
73
|
+
* first word, and words after " | " such as "trash … | restore") and its section heading.
|
|
74
|
+
*/
|
|
75
|
+
export function helpEntries (help) {
|
|
76
|
+
const out = []
|
|
77
|
+
let section = ''
|
|
78
|
+
let cur = null
|
|
79
|
+
for (const line of help.split('\n')) {
|
|
80
|
+
if (/^[A-Z]/.test(line)) { section = line; cur = null; continue }
|
|
81
|
+
const m = line.match(/^ {2}([a-z][a-z-]*)/)
|
|
82
|
+
if (m) {
|
|
83
|
+
cur = { section, commands: new Set([m[1], ...[...line.matchAll(/ \| ([a-z][a-z-]*)/g)].map((x) => x[1])]), lines: [line] }
|
|
84
|
+
out.push(cur)
|
|
85
|
+
} else if (cur && /^ {3,}\S/.test(line)) cur.lines.push(line)
|
|
86
|
+
else cur = null
|
|
87
|
+
}
|
|
88
|
+
return out
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The help of one command: its entries under their section headings, then `common`; null for an unknown one. */
|
|
92
|
+
export function helpFor (help, name, common = '') {
|
|
93
|
+
const entries = helpEntries(help).filter((e) => e.commands.has(name))
|
|
94
|
+
if (!entries.length) return null
|
|
95
|
+
const parts = []
|
|
96
|
+
let section = null
|
|
97
|
+
for (const e of entries) {
|
|
98
|
+
if (e.section !== section) { section = e.section; parts.push(`${parts.length ? '\n' : ''}${section}`) }
|
|
99
|
+
parts.push(...e.lines)
|
|
100
|
+
}
|
|
101
|
+
return `Usage: kaloko ${name} [options]\n\n${parts.join('\n')}\n${common ? `\n${common}` : ''}`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The options a command takes: the ones its help names, the hidden ones and the common ones. */
|
|
105
|
+
export function optionsFor (help, name) {
|
|
106
|
+
const named = helpEntries(help).filter((e) => e.commands.has(name)).flatMap((e) => e.lines.join('\n').match(/--[a-z][a-z0-9-]*/g) || [])
|
|
107
|
+
return new Set([...named.map((f) => f.slice(2)), ...(HIDDEN[name] || []), ...COMMON_OPTIONS])
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Edit distance (insertions, deletions, substitutions) between two short words. */
|
|
111
|
+
export function distance (a, b) {
|
|
112
|
+
let prev = Array.from({ length: b.length + 1 }, (_, j) => j)
|
|
113
|
+
for (let i = 1; i <= a.length; i++) {
|
|
114
|
+
const row = [i]
|
|
115
|
+
for (let j = 1; j <= b.length; j++) row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1))
|
|
116
|
+
prev = row
|
|
117
|
+
}
|
|
118
|
+
return prev[b.length]
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The closest of `words` to `word` within 2 edits (a typo), or null. */
|
|
122
|
+
export function closest (word, words) {
|
|
123
|
+
let best = null; let d = 3
|
|
124
|
+
for (const w of words) { const x = distance(word, w); if (x < d) { d = x; best = w } }
|
|
125
|
+
return best
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Options `args` has that `name` does not take: a UsageError for commands in STRICT (nothing has happened yet), a
|
|
130
|
+
* list of warning lines for the others (they run as before, without the unknown option).
|
|
131
|
+
*/
|
|
132
|
+
export function checkOptions (help, name, args) {
|
|
133
|
+
const known = optionsFor(help, name)
|
|
134
|
+
const unknown = Object.keys(args).filter((k) => k !== '_' && k !== '' && !known.has(k))
|
|
135
|
+
if (!unknown.length) return []
|
|
136
|
+
const lines = unknown.map((k) => { const guess = closest(k, [...known]); return `kaloko ${name} does not take --${k}.${guess ? ` Did you mean --${guess}?` : ''}` })
|
|
137
|
+
if (STRICT.has(name)) throw new UsageError(`${lines.join('\n')}\nNothing was changed. kaloko help ${name} lists its options.`)
|
|
138
|
+
return lines.map((l) => `! ${l} It is ignored (kaloko help ${name} lists the options).`)
|
|
139
|
+
}
|