kaloko 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.mjs CHANGED
@@ -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 { issueCmd, scheduleCmd } from './lib/integrations-cli.mjs'
39
40
  import { login } from './lib/login.mjs'
40
41
  import { initNextSteps, loginNextSteps } from './lib/onboarding.mjs'
41
42
  import { specCmd, driftCmd } from './lib/spec-cli.mjs'
@@ -45,7 +46,8 @@ import { formatAffected, selectedIds } from './lib/affected-core.mjs'
45
46
  import { healStep, formatHeal } from './lib/heal.mjs'
46
47
  import { readSource, stepFor, viewportFor, adhocScenario, writeImported, attachImported, keepVideos, slug } from './lib/import.mjs'
47
48
  import { findPreview, pullRequestOf, envLines, writeEnvFile, githubWorkflow, gitlabJob, addPreviewEnvironment } from './lib/ci.mjs'
48
- import { walkDesign, syncDesign, publishVersion, historyCmd, referenceCmd, tokensCmd, exportLive, localRevisions, branchCmd, uploadBlobs } from './lib/design-cli.mjs'
49
+ import { walkDesign, syncDesign, publishVersion, previewNotes, historyCmd, referenceCmd, tokensCmd, exportLive, localRevisions, branchCmd, uploadBlobs } from './lib/design-cli.mjs'
50
+ import { screensCmd } from './lib/screens-cli.mjs'
49
51
  import { designDir, readBase, headKey } from './lib/design.mjs'
50
52
  import { lintDesign, formatLint } from './lib/design-lint.mjs'
51
53
  import { exportDesign } from './lib/design-export.mjs'
@@ -69,15 +71,21 @@ Project
69
71
  draft <url> [url…] [--id SITE-X] [--title t] [--out qa/flows/site.yml] [--force]
70
72
  First plan + read-only production scenario for public pages, drafted from the
71
73
  pages (title, description, H1) with the seo, a11y, perf and console packs
72
- init [--agent claude|codex|all] [--org <slug>] [--no-detect] [--no-scenario]
74
+ init [--agent claude|codex|all] [--org <slug>] [--no-detect] [--no-scenario] [--docs=off|changelog|full]
73
75
  Create ${CONFIG_FILE} and install the kaloko skill for your agent; detects the
74
76
  framework, dev server URL, locales and main routes, and drafts a first scenario
75
- (qa/flows/main-pages.yml) when the project has none
77
+ (qa/flows/main-pages.yml) when the project has none; asks "Keep a visual changelog?"
78
+ (--docs: changelog adds CHANGELOG.md and the config block, full also docs/ folders
79
+ with Diátaxis templates: tutorials, how-to, reference, explanation)
76
80
  ci github|gitlab [--scenario a.yml,b.yml] [--env preview] [--trigger deployment|pull_request] [--no-affected]
77
81
  [--out file] [--print] [--force]
78
82
  Write a workflow that walks what each pull request affects on its preview
79
83
  deployment (Vercel, Netlify, Cloudflare Pages, review apps) and comments on it;
80
84
  adds the preview environment (base_url \${KALOKO_PREVIEW_URL}) to the config
85
+ ci github --release [--snippet] [--print] [--force]
86
+ Write .github/workflows/kaloko-release.yml: when release-please, changesets or
87
+ semantic-release publishes a GitHub release, kaloko release records it on Kaloko
88
+ (--snippet prints the step to add after the tool's own step instead)
81
89
  ci preview-url [--wait 600] [--github-env] [--dotenv file]
82
90
  In CI: the preview URL (KALOKO_PREVIEW_URL, the deployment event, provider
83
91
  variables or the commit's GitHub deployments), the pull request and its base
@@ -227,10 +235,60 @@ Results
227
235
  stability --pull [--scenario id] [--json]
228
236
  Regions people drew on screenshots on kaloko.app (step detail → Page → Draw a
229
237
  region) go into the scenario's ignore: of their steps; walk them again after
238
+ screens [words] [--project p] [--product p] [--module m] [--scenario id] [--locale l] [--viewport v] [--device d]
239
+ [--status pass|partial|fail|skip] [--environment e] [--designs] [--all] [--json] [--cursor c]
240
+ The screen library: the newest screenshot of every step across your flows, searched
241
+ by title, text, URL or id, with the canvas link of each (--designs adds design versions)
230
242
  compare [--run id] [--baseline id | --design] [--json]
231
243
  Text diff of two runs: step statuses, criterion verdicts, scores and evidence
232
244
  (default: current run against the scenario baseline, else the previous run)
233
245
 
246
+ Jira and Linear (connected by an admin in Settings → Integrations)
247
+ issue create --step <id> --to jira|linear [--criterion AC2] [--title "…"] [--note "…"] [--run id] [--json]
248
+ An issue from a returned step or a failing criterion: the screenshot, the
249
+ criterion, the reviewers' notes and a link back; the step's comment is resolved
250
+ when the issue is done. Branches and PR titles that name a key (WEB-12) link runs
251
+ issue list [--run id] [--scenario id] [--state open|done|canceled] [--json]
252
+
253
+ Hosted runs (read-only scenarios on Kaloko's browsers, Business plan)
254
+ schedule add --scenario <file> --env <name> [--every day|week|manual] [--at 5] [--weekday mon] [--account key]
255
+ Kaloko opens the scenario's pages every day or week (hour in UTC) and shares the
256
+ run as you: public pages, HTTP Basic from the environment, or a saved session
257
+ (--account). Steps that click or type are left out; they need the full walk
258
+ schedule list | run <id> | pause <id> | resume <id> | remove <id> [--json]
259
+ schedule export github --scenario <file> --env <name> [--every day|week] [--at 5] [--out file] [--print]
260
+ A GitHub Actions workflow that walks the scenario on a schedule with the full CLI
261
+
262
+ Changelog (Keep a Changelog in CHANGELOG.md; pictures are step references kaloko:<scenario>/<step>)
263
+ changelog draft [--product p] [--since <tag|version>] [--apply] [--json] [--offline]
264
+ The Unreleased entry since the last release: merged pull requests and commits
265
+ (Conventional Commits when used, gh for titles) grouped Added / Changed / Fixed /
266
+ Removed, plus before/after pictures of the screens accepted runs show differently;
267
+ printed, or --apply writes it into CHANGELOG.md (not when a release tool owns it)
268
+ release [<version>] [--product p] [--date YYYY-MM-DD] [--runs id,id] [--no-write] [--no-docs] [--dry-run] [--json]
269
+ Record a release on Kaloko: the version (default: from git tags, package.json,
270
+ release-please or changesets), its CHANGELOG.md entry (Unreleased becomes the
271
+ version unless --no-write) and the accepted runs that are its pictures; prints the
272
+ illustrated changelog page. The docs folder is published as that version's docs
273
+ (Business) unless --no-docs
274
+
275
+ Docs (Markdown in docs/ with step references; Diátaxis folders; translations as guide.cs.md next to guide.md)
276
+ docs publish [--product p] [--version latest|<v>] [--reviewed] [--dry-run] [--json]
277
+ Send the docs folder to Kaloko as the docs of a version (default latest): readers
278
+ get a version selector, language and screen switches, search and training mode;
279
+ --reviewed says the text was checked, which clears its stale notes
280
+ docs check [--product p] [--version v] [--offline] [--strict] [--json]
281
+ Broken or unknown step references, stale sections (the screen changed since the
282
+ text was published), missing or outdated translations and user-visible changelog
283
+ items without a picture; exit 1 on references or stale sections (--strict: on
284
+ anything)
285
+ export --format md|docusaurus|vitepress|mkdocs|html|pdf|json [--product p] [--version v] [--lang l]
286
+ [--what all|docs|changelog] [--local] [--out dir|file]
287
+ The docs and changelog with their pictures as files: Markdown with an assets
288
+ folder, a Docusaurus, VitePress or MkDocs folder with sidebars, offline HTML, PDF
289
+ (printed by the browser) or the changelog as JSON; --local exports the files in
290
+ the repository instead of the published ones
291
+
234
292
  Design (environments with kind: design; live HTML steps in a folder)
235
293
  start --scenario <file> --env <design env> A run of the design folder (same scenario as development and QA)
236
294
  walk [--steps a,b] [--no-cache] Render the steps; unchanged revisions reuse their captures
@@ -241,7 +299,15 @@ Design (environments with kind: design; live HTML steps in a folder)
241
299
  design lint [--env e | --dir design/] [--scenario file] [--all] [--strict] [--json]
242
300
  Check the folder against the portable HTML rules (relative URLs, no CDNs, no storage,
243
301
  deterministic content, viewport, lang, links that also work from disk); --strict fails on warnings
244
- share [--name <text>] [--branch b] Publish the draft as a version of the flow (a run): "v4 · 3 changed, 17 unchanged"
302
+ share [--name <text>] [--branch b] [--notes file|-] [--notes-preview]
303
+ Publish the draft as a version of the flow (a run): "v4 · 3 changed, 17 unchanged",
304
+ with "what changed" notes per step (screens per viewport, text, tokens); --notes-preview
305
+ prints them without publishing, --notes replaces them (a summary, then "step: text" lines)
306
+ design import figma <file-url|key|export folder> [--scenario file] [--frames a,b] [--page p] [--map frames.json]
307
+ [--token-env FIGMA_TOKEN] [--tokens auto|write|skip] [--force] [--dry-run] [--publish [--notes file]]
308
+ Figma frames become design steps (frame PNG at 1× and 2×, text layers with their
309
+ typography, variables or styles as tokens.json) matched to steps by name or --map;
310
+ a folder exported from Figma works without a token. One way: no prototype links
245
311
  history [--scenario id] [--step id] [--locale l] [--json]
246
312
  Versions of the flow; revisions of a step with who (person or agent) and when
247
313
  spec [step] [--ref design|draft|vN|<run id>] [--locale l] [--viewport v] [--local] [--json]
@@ -381,6 +447,49 @@ function stepOf (scenario, id) {
381
447
  }
382
448
 
383
449
  /** Scenario of --scenario (id or file) or of the current run. */
450
+ /**
451
+ * kaloko design import figma <file-url|key|folder>: Figma frames (or a folder exported from Figma) become the steps of
452
+ * the design folder; --publish then walks them and publishes a version (the design reference to be).
453
+ */
454
+ async function designImport (args) {
455
+ const [, kind, source] = args._
456
+ if (kind !== 'figma' || !source) throw new UserError('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]]')
457
+ const { config, root } = await context(args)
458
+ let run = null
459
+ try { run = await resolveRun({ root, output: config.output, run: args.run }) } catch {}
460
+ const runEnv = run ? environment(config, run.meta.environment.name) : null
461
+ const env = typeof args.env === 'string' ? environment(config, args.env) : runEnv?.kind === 'design' ? runEnv : Object.keys(config.environments).map((n) => environment(config, n)).find((e) => e.kind === 'design')
462
+ if (!env || env.kind !== 'design') throw new UserError('No design environment: add one with kind: design and serve: design/ to kaloko.config.yml, or pass --env <design env>.')
463
+ const scenarioFile = typeof args.scenario === 'string' ? resolve(args.scenario) : run && runEnv?.kind === 'design' ? resolve(root, run.meta.scenario_file) : null
464
+ if (!scenarioFile) throw new UserError('Name the scenario whose steps the frames are: --scenario <file>.')
465
+ const scenario = await loadScenario(scenarioFile)
466
+ const { importFigma, formatImport, readMap } = await import('./lib/figma.mjs')
467
+ const scope = startScope({ args: {}, scenario, config, env })
468
+ const viewports = Object.fromEntries(baseViewports(scope).map((v) => [v, scope.viewports[v]]))
469
+ 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'
470
+ const dir = typeof args.dir === 'string' ? resolve(args.dir) : designDir(env, root)
471
+ const r = await importFigma({
472
+ source, scenario, viewports, locales: scenario.locales || ['default'], dir,
473
+ token: process.env[tokenVar] || (tokenVar === 'FIGMA_TOKEN' ? process.env.FIGMA_ACCESS_TOKEN : null) || null,
474
+ map: typeof args.map === 'string' ? await readMap(resolve(args.map)) : null, only: list(args.frames), page: typeof args.page === 'string' ? args.page : null,
475
+ tokensMode: ['write', 'skip'].includes(args.tokens) ? args.tokens : 'auto', force: !!args.force, dryRun: !!args['dry-run'], say
476
+ })
477
+ if (args.json) say(JSON.stringify(r, null, 2))
478
+ else say(formatImport(r, { dir: relative(process.cwd(), dir) || '.' }))
479
+ if (args['dry-run']) return r
480
+ const lint = await lintDesign({ dir, scenario })
481
+ if (lint.errors) say(`\n${formatLint(lint, { max: 10 })}`)
482
+ if (!args.publish) {
483
+ say(`\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.`)
484
+ return r
485
+ }
486
+ await commands.start({ ...args, _: [], scenario: scenarioFile, env: env.name })
487
+ const current = await resolveRun({ root, output: config.output })
488
+ await commands.walk({ _: [], run: current.dir })
489
+ await commands.share({ _: [], run: current.dir, ...(typeof args.notes === 'string' ? { notes: args.notes } : {}), ...(typeof args.name === 'string' ? { name: args.name } : {}), ...(typeof args.branch === 'string' ? { branch: args.branch } : {}) })
490
+ return r
491
+ }
492
+
384
493
  async function scenarioIdOf (args, config, root) {
385
494
  if (typeof args.scenario === 'string') {
386
495
  if (/\.ya?ml$/.test(args.scenario)) return (await loadScenario(resolve(args.scenario))).id
@@ -773,6 +882,38 @@ const commands = {
773
882
  await writeFile(file, tpl)
774
883
  say(`Created ${CONFIG_FILE}${detected ? ` (local: ${detected.url})` : ''}. Edit org and the staging and production environments.`)
775
884
  } else if (!existsSync(join(cwd, LEGACY_CONFIG_FILE))) say(`${CONFIG_FILE} already exists; kept.`)
885
+ // the visual changelog (docs/DOCS-CHANGELOG.md): asked once; --docs=off|changelog|full for scripts and agents
886
+ let docsMode = typeof args.docs === 'string' ? args.docs : null
887
+ if (docsMode && !['off', 'changelog', 'full'].includes(docsMode)) throw new UserError('--docs must be off, changelog or full.')
888
+ if (!docsMode && process.stdin.isTTY && process.stdout.isTTY && !process.env.CI && !process.env.NODE_TEST_CONTEXT) {
889
+ const rl = (await import('node:readline/promises')).createInterface({ input: process.stdin, output: process.stdout })
890
+ const a = (await rl.question('Keep a visual changelog? (recommended) [Y/n] ')).trim().toLowerCase(); rl.close()
891
+ docsMode = a === 'n' || a === 'no' ? 'off' : 'changelog'
892
+ }
893
+ if (docsMode && docsMode !== 'off') {
894
+ const { docsConfigBlock, detectProducts, DOCS_SKELETON } = await import('./lib/changelog.mjs')
895
+ const { CHANGELOG_SKELETON } = await import('./lib/changelog-core.mjs')
896
+ const { mkdir: mkdirp } = await import('node:fs/promises')
897
+ const cfgFile = existsSync(file) ? file : join(cwd, LEGACY_CONFIG_FILE)
898
+ const cfg = await readFile(cfgFile, 'utf8')
899
+ const products = /^products:/m.test(cfg) ? [] : await detectProducts(cwd)
900
+ if (!/^docs:/m.test(cfg) && !/^products:/m.test(cfg)) { await writeFile(cfgFile, `${cfg.replace(/\n*$/, '\n')}${docsConfigBlock({ products, full: docsMode === 'full' })}`); say(`Added the changelog settings to ${relative(cwd, cfgFile)}${products.length ? ` with ${products.length} products (${products.map((p) => p.slug).join(', ')})` : ''}`) }
901
+ for (const f of products.length ? products.map((p) => join(cwd, p.dir, 'CHANGELOG.md')) : [join(cwd, 'CHANGELOG.md')]) {
902
+ if (existsSync(f)) continue
903
+ await mkdirp(dirname(f), { recursive: true })
904
+ await writeFile(f, CHANGELOG_SKELETON)
905
+ say(`Created ${relative(cwd, f)} (Keep a Changelog; kaloko changelog draft fills Unreleased, kaloko release records a version)`)
906
+ }
907
+ if (docsMode === 'full') {
908
+ for (const [rel, text] of Object.entries(DOCS_SKELETON)) {
909
+ const f = join(cwd, rel)
910
+ if (existsSync(f)) continue
911
+ await mkdirp(dirname(f), { recursive: true })
912
+ await writeFile(f, text)
913
+ }
914
+ say('Docs folders in docs/ (tutorials, how-to, reference, explanation) with an index and a template in each; kaloko docs publish sends them, kaloko docs check checks them')
915
+ }
916
+ } else if (!docsMode) say('Visual changelog: kaloko init --docs=changelog adds CHANGELOG.md and the config block (recommended).')
776
917
  const agent = typeof args.agent === 'string' ? args.agent : 'none'
777
918
  if (!['claude', 'codex', 'all', 'none'].includes(agent)) throw new UserError('--agent must be claude, codex, all or none.')
778
919
  const { cp, mkdir, appendFile, rm } = await import('node:fs/promises')
@@ -875,9 +1016,11 @@ const commands = {
875
1016
  const vpNames = baseViewports(scope)
876
1017
  const git = await gitMeta(root)
877
1018
  const executor = { kind: args.executor ? 'agent' : 'cli', name: args.executor || 'kaloko-cli' }
878
- const { project, issue } = await projectAndIssue({ cwd: root, config, scenario, branch: git?.branch })
1019
+ const { project, issue } = await projectAndIssue({ cwd: root, config, scenario, branch: git?.branch, title: git?.pr?.title || null })
879
1020
  const appVer = typeof args['app-version'] === 'string' ? args['app-version'].slice(0, 80) : await appVersion(root)
880
- const { dir, meta } = await createRun({ output: config.output, scenario, scenarioFile, env, scope, git, executor, root, project, issue, appVersion: appVer })
1021
+ // the product the flow documents (visual changelog): its tag, or the only product of the config
1022
+ const product = scenario.product || (Object.keys(config.products || {}).length === 1 ? Object.keys(config.products)[0] : null)
1023
+ const { dir, meta } = await createRun({ output: config.output, scenario, scenarioFile, env, scope, git, executor, root, project, issue, appVersion: appVer, product })
881
1024
  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}` : ''}`)
882
1025
  const addr = recipient(env.mail, meta.short)
883
1026
  if (addr) say(` test e-mail: ${addr}`)
@@ -1343,8 +1486,32 @@ const commands = {
1343
1486
  console.log(preview.url)
1344
1487
  return
1345
1488
  }
1346
- if (!['github', 'gitlab'].includes(action)) throw new UserError('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 preview-url [--wait 600] [--github-env] [--dotenv file]')
1489
+ if (!['github', 'gitlab'].includes(action)) throw new UserError('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]')
1347
1490
  const { config, root, file } = await context(args)
1491
+ if (args.release) {
1492
+ // release-tool integrations: record each published release on Kaloko (docs/DOCS-CHANGELOG.md)
1493
+ if (action !== 'github') throw new UserError('--release writes a GitHub Actions workflow; on GitLab run kaloko release <version> --no-write in your release job.')
1494
+ const { productsOf, detectVersionSource } = await import('./lib/changelog.mjs')
1495
+ const { releaseWorkflow, releaseSnippet } = await import('./lib/ci.mjs')
1496
+ const products = productsOf(config, root)
1497
+ const sources = await Promise.all(products.map((p) => detectVersionSource(root, p)))
1498
+ const tool = sources.map((s) => s.tool).find(Boolean) || null
1499
+ let pkg = null
1500
+ try { pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) } catch {}
1501
+ const pm = existsSync(join(root, 'pnpm-lock.yaml')) ? 'pnpm' : existsSync(join(root, 'yarn.lock')) ? 'yarn' : existsSync(join(root, 'bun.lockb')) || existsSync(join(root, 'bun.lock')) ? 'bun' : 'npm'
1502
+ const dependency = !!(pkg && ({ ...pkg.dependencies, ...pkg.devDependencies }).kaloko)
1503
+ const version = `^${JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')).version}`
1504
+ if (args.snippet) { say(releaseSnippet({ tool, pm, dependency, version, product: products.length > 1 ? '<product>' : null })); return }
1505
+ const content = releaseWorkflow({ products: products.map((p, i) => ({ slug: products.length > 1 || !p.implicit ? p.slug : null, pattern: sources[i].pattern || 'v*' })), pm, dependency, version, tool })
1506
+ if (args.print) { say(content); return }
1507
+ const out = resolve(root, typeof args.out === 'string' ? args.out : '.github/workflows/kaloko-release.yml')
1508
+ if (existsSync(out) && !args.force) throw new UserError(`${relative(process.cwd(), out)} exists; pass --force to replace it (or --print to compare).`)
1509
+ await mkdir(dirname(out), { recursive: true })
1510
+ await writeFile(out, content)
1511
+ say(`Wrote ${relative(process.cwd(), out)}: every published GitHub release${tool ? ` (${tool} publishes them)` : ''} is recorded on Kaloko with its changelog entry, pictures and docs.`)
1512
+ say('Next:\n add the repository secret KALOKO_TOKEN (tester role, upload scope)\n publish releases with your release tool as before; nothing else changes in your release workflow')
1513
+ return
1514
+ }
1348
1515
  const envName = typeof args.env === 'string' ? args.env : 'preview'
1349
1516
  const all = (await scenarioFiles(config, root)).map((f) => relative(root, f))
1350
1517
  const scenarios = list(args.scenario) || all
@@ -1521,6 +1688,7 @@ const commands = {
1521
1688
  if (env.kind === 'design') {
1522
1689
  const r = await syncDesign({ config, root, run, scenario, env, client, args: { ...args, strict: true }, say, pull: false, render: (steps) => walkDesign({ config, root, run, scenario, env, pick: steps, locales: run.meta.scope.locales, viewports: run.meta.scope.viewports, say }).then(() => evaluateRun({ run, scenario, config, root })) })
1523
1690
  if (r.conflicts) return
1691
+ if (args['notes-preview']) { await previewNotes({ config, scenario, client, args, say }); return }
1524
1692
  const v = await publishVersion({ config, scenario, client, args, say })
1525
1693
  const meta = await readJson(join(run.dir, 'meta.json'))
1526
1694
  meta.shared = { url: v.url, at: new Date().toISOString(), version: v.version, run: v.id }
@@ -1616,7 +1784,8 @@ const commands = {
1616
1784
  },
1617
1785
 
1618
1786
  async design (args) {
1619
- if (args._[0] !== 'lint') throw new UserError('Usage: kaloko design lint [--env <design env> | --dir design/] [--scenario file] [--all] [--strict] [--json]')
1787
+ if (args._[0] === 'import') return designImport(args)
1788
+ if (args._[0] !== 'lint') throw new UserError('Usage: kaloko design lint [--env <design env> | --dir design/] [--scenario file] [--all] [--strict] [--json]\n kaloko design import figma <file-url|key|export folder> [--frames a,b] [--map frames.json] [--token-env FIGMA_TOKEN] [--publish]')
1620
1789
  const { config, root } = await context(args)
1621
1790
  let run = null
1622
1791
  try { run = await resolveRun({ root, output: config.output, run: args.run }) } catch {}
@@ -1633,6 +1802,12 @@ const commands = {
1633
1802
  if (report.errors || (args.strict && report.warnings)) process.exitCode = 1
1634
1803
  },
1635
1804
 
1805
+ /** `kaloko screens [words]`: the screen library of the organization (newest capture of every step). */
1806
+ async screens (args) {
1807
+ const { config } = await context(args)
1808
+ await screensCmd({ config, client: service(config), args, say })
1809
+ },
1810
+
1636
1811
  async history (args) {
1637
1812
  const { config, root } = await context(args)
1638
1813
  const scenarioId = await scenarioIdOf(args, config, root)
@@ -1734,6 +1909,20 @@ const commands = {
1734
1909
  * The signed acceptance record of an accepted run: JSON and PDF downloaded and checked against the service's public
1735
1910
  * key; --verify checks a record someone handed over (no token, no project needed).
1736
1911
  */
1912
+ /** Jira and Linear (#21): an issue from a step of a shared run, and the issues created so far. */
1913
+ async issue (args) {
1914
+ const { config, root } = await context(args)
1915
+ let runId = null
1916
+ if (args._[0] === 'create' && typeof args.run !== 'string') { try { const r = await resolveRun({ root, output: config.output }); if (r.meta.shared) runId = r.meta.id } catch {} }
1917
+ return issueCmd({ args, config, client: service(config), runId, say })
1918
+ },
1919
+
1920
+ /** Hosted runs (#16): schedules of read-only scenarios on Kaloko's browsers, or a scheduled GitHub workflow. */
1921
+ async schedule (args) {
1922
+ const { config, root } = await context(args)
1923
+ return scheduleCmd({ args, config, root, say, deps: { loadScenario, environment, baseUrl, projectAndIssue, packageRoot, service } })
1924
+ },
1925
+
1737
1926
  async signoff (args) {
1738
1927
  if (typeof args.verify === 'string') {
1739
1928
  const config = await context(args).then((c) => c.config).catch(() => ({}))
@@ -1992,6 +2181,27 @@ const commands = {
1992
2181
  return
1993
2182
  }
1994
2183
  if (typeof args.scenario === 'string') return exportScenario({ args, config, root, client })
2184
+ if (typeof args.format === 'string') {
2185
+ // living docs and the changelog as files (docs/DOCS-CHANGELOG.md → Exports)
2186
+ if (!config.org) throw new UserError('Set org: in the config first.')
2187
+ const { pickProduct } = await import('./lib/changelog.mjs')
2188
+ const { exportDocs } = await import('./lib/docs.mjs')
2189
+ const product = pickProduct(config, root, args.product)
2190
+ const printPdf = async (htmlFile, outFile) => {
2191
+ const { chromium } = await import('playwright')
2192
+ const browser = await chromium.launch(config.browser?.channel ? { channel: config.browser.channel } : {}).catch(() => chromium.launch())
2193
+ try {
2194
+ const page = await browser.newPage()
2195
+ await page.goto(pathToFileURL(htmlFile).href, { waitUntil: 'load', timeout: 60000 })
2196
+ await page.pdf({ path: outFile, format: 'A4', printBackground: true, margin: { top: '16mm', bottom: '16mm', left: '14mm', right: '14mm' } })
2197
+ } finally { await browser.close() }
2198
+ }
2199
+ const r = await exportDocs({ root, config, product, client, format: args.format, version: typeof args.version === 'string' ? args.version : 'latest', lang: typeof args.lang === 'string' ? args.lang : null, what: ['docs', 'changelog'].includes(args.what) ? args.what : 'all', out: typeof args.out === 'string' ? resolve(args.out) : null, local: !!args.local, printPdf: args['server-pdf'] ? null : printPdf, say })
2200
+ if (r.kind === 'file') { say(`Exported ${product.slug} as ${r.format} → ${relative(process.cwd(), r.file) || r.file}`); return }
2201
+ const m = r.manifest
2202
+ say(`Exported ${product.slug} ${m?.version || ''} as ${r.format} → ${relative(process.cwd(), r.dir) || r.dir}\n ${m ? `${m.pages} page(s), ${m.releases} release(s), ${m.pictures} picture(s)` : `${r.files} file(s)`}${m?.missing?.length ? `\n ! no accepted picture for: ${m.missing.slice(0, 8).join(', ')}${m.missing.length > 8 ? ' …' : ''}` : ''}`)
2203
+ return
2204
+ }
1995
2205
  let runId = args.run
1996
2206
  if (!runId) { try { runId = (await resolveRun({ root, output: config.output })).meta.id } catch {} }
1997
2207
  if (!runId || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9-]+$/.test(runId)) throw new UserError('--run <id> is required (a shared run id).')
@@ -2019,12 +2229,96 @@ const commands = {
2019
2229
  const { config, root, file } = await context(args)
2020
2230
  // engines the scenarios ask for (browsers: [webkit, firefox]), so doctor checks they are installed
2021
2231
  const engines = []
2022
- for (const f of await scenarioFiles(config, root).catch(() => [])) { try { engines.push(...((yaml.load(await readFile(f, 'utf8')) || {}).browsers || [])) } catch {} }
2023
- const r = await doctor({ config, root, file, fix: !!args.fix, engines })
2232
+ const scenarios = []
2233
+ 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 {} }
2234
+ const r = await doctor({ config, root, file, fix: !!args.fix, engines, scenarios })
2024
2235
  say(args.json ? JSON.stringify(r, null, 2) : formatDoctor(r))
2025
2236
  if (!r.ok) process.exitCode = 1
2026
2237
  },
2027
2238
 
2239
+ /** `kaloko changelog draft`: the Unreleased entry from merged work and the screens accepted runs show differently. */
2240
+ async changelog (args) {
2241
+ const sub = args._[0]
2242
+ if (sub !== 'draft') throw new UserError('Usage: kaloko changelog draft [--product p] [--since <tag|version>] [--apply] [--json] [--offline]')
2243
+ const { config, root } = await context(args)
2244
+ const { pickProduct, draftChangelog, draftText, applyToChangelog } = await import('./lib/changelog.mjs')
2245
+ const product = pickProduct(config, root, args.product)
2246
+ let client = null
2247
+ if (!args.offline) { try { client = service(config) } catch {} }
2248
+ const d = await draftChangelog({ root, config, product, since: typeof args.since === 'string' ? args.since : null, client })
2249
+ if (args.json) { say(JSON.stringify(d, null, 2)); return }
2250
+ const pictures = d.changes.reduce((n, s) => n + (s.steps?.length || 0), 0)
2251
+ const head = `${product.slug}: ${d.commits} change(s) since ${d.since.ref || 'the first commit'}${d.service === 'ok' ? `, ${pictures} changed screen(s) in accepted runs since ${d.previous ? `release ${d.previous.version}` : 'the first accepted runs'}` : d.service === 'offline' ? ' (no service: pictures of changed screens need a token)' : ` (service: ${d.service})`}; ${d.internal.length} internal left out.`
2252
+ if (args.apply) {
2253
+ const r = await applyToChangelog(d)
2254
+ say(`${head}\n${r.created ? 'Created' : 'Updated'} ${relative(process.cwd(), d.file) || d.file}: ${r.added} new item(s) in Unreleased. Edit them for people, then kaloko release <version>.`)
2255
+ return
2256
+ }
2257
+ console.error(head)
2258
+ say(draftText(d))
2259
+ },
2260
+
2261
+ /** `kaloko docs publish|check`: the docs folder on Kaloko per version, and what in it needs attention. */
2262
+ async docs (args) {
2263
+ const sub = args._[0]
2264
+ if (!['publish', 'check'].includes(sub)) throw new UserError('Usage: kaloko docs publish [--product p] [--version latest|<v>] [--reviewed] [--dry-run] [--json]\n kaloko docs check [--product p] [--version v] [--offline] [--strict] [--json]')
2265
+ const { config, root } = await context(args)
2266
+ const { pickProduct } = await import('./lib/changelog.mjs')
2267
+ const docs = await import('./lib/docs.mjs')
2268
+ const product = pickProduct(config, root, args.product)
2269
+ const version = typeof args.version === 'string' ? args.version : 'latest'
2270
+ if (sub === 'publish') {
2271
+ if (!config.org && !args['dry-run']) throw new UserError('Set org: in the config first.')
2272
+ const r = await docs.publishDocs({ root, config, product, client: args['dry-run'] ? null : service(config), version, reviewed: !!args.reviewed, dryRun: !!args['dry-run'] })
2273
+ if (args.json) { say(JSON.stringify(r, null, 2)); return }
2274
+ if (r.dry_run) { say(`${product.slug}: ${r.pages} page(s) from ${r.dir} would go to Kaloko as ${r.version} (${r.languages.join(', ')}); dry run, nothing sent.${r.broken.length ? `\n ! broken step references: ${r.broken.join('; ')}` : ''}`); return }
2275
+ const lines = [`${product.slug} docs ${r.version}: ${r.pages} page(s) from ${r.dir}, ${r.changed} changed, ${r.unchanged} unchanged${r.removed ? `, ${r.removed} removed` : ''}${args.reviewed ? ' (marked as checked)' : ''}`, ` ${r.url}`]
2276
+ for (const b of r.broken_refs || []) lines.push(` ! broken step reference in ${b.path || 'index'}${b.lang ? ` (${b.lang})` : ''}: ${b.ref}`)
2277
+ for (const u of (r.unresolved || []).slice(0, 10)) lines.push(` ! no accepted picture yet: ${u.ref} (${u.path || 'index'})`)
2278
+ for (const n of r.notes || []) lines.push(` ! ${n}`)
2279
+ say(lines.join('\n'))
2280
+ return
2281
+ }
2282
+ // check: the scenario files say which steps exist
2283
+ const scenarios = new Map()
2284
+ for (const f of await scenarioFiles(config, root).catch(() => [])) { try { const s = yaml.load(await readFile(f, 'utf8')) || {}; if (s.id) scenarios.set(String(s.id), new Set((s.nodes || []).map((n) => String(n.id)))) } catch {} }
2285
+ let client = null
2286
+ if (!args.offline) { try { client = service(config) } catch {} }
2287
+ const r = await docs.docsCheck({ root, config, product, client, scenarios: scenarios.size ? scenarios : null, version })
2288
+ say(args.json ? JSON.stringify(r, null, 2) : docs.formatCheck(r))
2289
+ if (!r.ok || (args.strict && r.warnings)) process.exitCode = 1
2290
+ },
2291
+
2292
+ /** `kaloko release <version>`: the version's changelog entry and its reference runs on the service. */
2293
+ async release (args) {
2294
+ const { config, root } = await context(args)
2295
+ if (!config.org) throw new UserError('Set org: in the config first.')
2296
+ const { pickProduct, releaseProduct } = await import('./lib/changelog.mjs')
2297
+ const product = pickProduct(config, root, args.product)
2298
+ const client = args['dry-run'] ? null : service(config)
2299
+ const r = await releaseProduct({ root, config, product, version: args._[0] || null, date: typeof args.date === 'string' ? args.date : null, client, write: !args['no-write'], runs: list(args.runs), dryRun: !!args['dry-run'] })
2300
+ // the docs of this version: what the docs folder says now (Business; latest stays as kaloko docs publish left it)
2301
+ if (r.release && !args['no-docs']) {
2302
+ const docs = await import('./lib/docs.mjs')
2303
+ const pages = await docs.collectDocs(root, product).then((x) => x.pages.length).catch(() => 0)
2304
+ if (pages) {
2305
+ try { r.docs = await docs.publishDocs({ root, config, product, client, version: r.version }) } catch (e) { r.docs = { error: String(e.message || e).split('\n')[0] } }
2306
+ }
2307
+ }
2308
+ if (args.json) { say(JSON.stringify(r, null, 2)); return }
2309
+ const file = relative(process.cwd(), r.file) || r.file
2310
+ const lines = [`${product.slug} ${r.version} (${r.date}) · version source: ${r.source.tool || r.source.source}${r.source.path ? ` (${r.source.path})` : ''}`]
2311
+ lines.push(r.section ? ` ${file}: ${r.finalized ? 'Unreleased became' : 'entry'} ${r.version}, ${r.items} item(s), ${r.refs} picture reference(s)` : ` ${file}: no entry for ${r.version}`)
2312
+ if (r.release) {
2313
+ lines.push(` ${r.release.created ? 'Recorded' : 'Updated'} on the service with ${r.release.runs.length} reference run(s)${r.release.versioned_runs ? `; ${r.release.versioned_runs} accepted run(s) now carry ${r.version}` : ''}`)
2314
+ lines.push(` ${r.release.url}`)
2315
+ } else lines.push(' Dry run: nothing sent.')
2316
+ if (r.docs) lines.push(r.docs.error ? ` ! Docs of ${r.version} not published: ${r.docs.error}` : ` Docs of ${r.version}: ${r.docs.pages} page(s)${r.docs.changed ? `, ${r.docs.changed} changed since the last publish of ${r.version}` : ''}`)
2317
+ for (const n of r.notes) lines.push(` ! ${n}`)
2318
+ if (r.finalized) lines.push(` Commit ${file} with the release.`)
2319
+ say(lines.join('\n'))
2320
+ },
2321
+
2028
2322
  async help () { say(HELP) }
2029
2323
  }
2030
2324