kaloko 0.5.2 → 0.7.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.
@@ -0,0 +1,67 @@
1
+ # Jira, Linear, channels and hosted runs
2
+
3
+ What the organization connected on kaloko.app (Settings → Integrations, done by an
4
+ admin) and what you may do with it from the CLI and MCP.
5
+
6
+ ## Issues in Jira or Linear
7
+
8
+ When a reviewer returned a step or a criterion fails and the team tracks work in
9
+ Jira or Linear, file it there from the step instead of copying text by hand:
10
+
11
+ ```bash
12
+ kaloko issue create --step checkout --to jira --criterion AC2 --note "Total ignores the voucher"
13
+ kaloko issue list --state open
14
+ ```
15
+
16
+ - `--run <id>` names the shared run; without it the current run is used once it
17
+ is shared. MCP: `create_issue`, `list_issues`.
18
+ - The issue gets the screenshot, the criterion with its evidence, the reviewers'
19
+ open notes on the step and a link back to the step. The project or team comes
20
+ from the admin's mapping per Kaloko project; `--target WEB` overrides it.
21
+ - The returned note on the step (or a new comment) mirrors the issue: when the
22
+ issue is done or cancelled in the tracker, the thread gets a reply and is
23
+ resolved; reopened, it opens again. Do not resolve that thread yourself.
24
+ - Needs the Developer / Tester role and a token with the comment scope. A 409
25
+ means the tracker is not connected or the project has no mapping: tell the
26
+ person, an admin sets it up.
27
+ - Name the issue key in the branch (`feat/WEB-12-gift-cards`, Linear's
28
+ `anna/eng-42-…`) or the pull request title, or `issue: { key: WEB-12 }` in the
29
+ scenario: the run links to the issue and the issue gets a comment with the run.
30
+
31
+ Ask before filing issues in bulk; one issue per real problem, not per criterion.
32
+
33
+ ## Channels per project
34
+
35
+ Slack and Teams channels are chosen per project by an admin, with the events
36
+ each wants (new run, returned, accepted, comment, mention, release recorded) and
37
+ quiet hours. Nothing to do from the CLI: sharing, returning and recording a
38
+ release reach the right channels. Restricted projects reach only channels that
39
+ name them.
40
+
41
+ ## Hosted runs
42
+
43
+ Kaloko can open a read-only scenario's pages on its own browsers every day or
44
+ week and share the run like yours (Business plan, a monthly page budget):
45
+
46
+ ```bash
47
+ kaloko schedule add --scenario qa/flows/public.yml --env production --every day --at 5
48
+ kaloko schedule add --scenario qa/flows/account.yml --env staging --every week --weekday mon --account anna
49
+ kaloko schedule list # next and last run, pages used this month
50
+ kaloko schedule run <id> # starts within 15 minutes
51
+ kaloko schedule pause|resume|remove <id>
52
+ ```
53
+
54
+ - Works for: public pages, pages behind HTTP Basic (the environment's
55
+ `access.basic` variables are sent once and stored encrypted), and signed-in
56
+ pages through a session saved with `kaloko auth save` (`--account`, cookies only).
57
+ - Opens only steps whose script just opens the page (`steps/open.mjs`, or a
58
+ script that only calls `page.goto`). Steps that click or type are left out and
59
+ named in the answer. Criteria decided: `exists`, `absent`, `count`, `text_*`,
60
+ `attr_*`, `title_matches`, `meta_matches`; the rest shows "not evaluated".
61
+ - Does not work for: native apps, local or private addresses (localhost, IPs,
62
+ `.local`), header credentials, base URLs per locale.
63
+ - For those, or for the full walk on a schedule, write a GitHub Actions
64
+ workflow instead: `kaloko schedule export github --scenario <file> --env <name>
65
+ [--every week] [--at 5]`. It names the repository secrets it needs.
66
+ - A 404 from `schedule add` means hosted runs are not enabled on this service:
67
+ use the workflow.
@@ -33,12 +33,14 @@ file.
33
33
  | `decide_step` | records your (the agent's) decision on a step: `approve`, or `return` with a `note`; marked as an agent's and not counted as a person's. Never use it to approve for a person |
34
34
  | `revoke_carried_approval` | revokes an approval carried over to a step that looks unchanged but should be looked at again (`run_id`, `step`) |
35
35
  | `get_step_assets` | documentation layer (Business): newest capture of every step of a scenario with stable image, HTML and metadata URLs (`scenario`, `step`, `locale`, `viewport`, `accepted`) |
36
+ | `list_products` | the products the caller reads: name, modules, the projects it maps that the caller sees, the newest release, published docs (versions, languages), who reads it (and the public link), whether the caller manages it, links to its page, changelog and docs |
36
37
  | `get_changelog` | the visual changelog of a product (`product`, `version`, `resolve: true` for the pictures, `limit`): releases newest first with the entry as written and its groups; see [docs.md](docs.md) |
37
38
  | `draft_changelog_entry` | what accepted runs since the last release (or `since`) show differently, as a Keep a Changelog draft with before/after step references (`product`, `since`) |
38
39
  | `resolve_step_image` | one step reference (`ref` = `kaloko:<scenario>/<step>?…`, `product`, `version`, `locale`, `viewport`) as a signed image URL with its run, how the version was found and canvas links |
39
40
  | `list_step_changes` | documentation layer: steps whose capture or status changed since the previous accepted run or `since` (`scenario`, `since`, `accepted`); see [documentation.md](documentation.md) |
40
41
  | `get_started` | where the organization stands (`empty`, `sample`, `active`), its sample run, the next steps and a prompt; call it in a new project, see [first-run.md](first-run.md) |
41
42
  | `add_sample_run` | adds the sample run (or returns the one there is); needs an upload or full token and the Developer / Tester role |
43
+ | `search_screens` | the screen library: newest screenshot of every step across the organization's flows (`q` words matched against titles, ids, paths, URLs, page title and H1; `project`, `product`, `module`, `scenario`, `locale`, `viewport`, `device`, `status`, `environment`; `source: design` or `all` for design versions; `group: none` for every locale and viewport; `cursor` from `next`), each with a signed image and the canvas link |
42
44
  | `get_affected_steps` | which steps of a scenario changed files reach, with the reason per step (`scenario`, `changed_files`, `run_id`, `upstream`); route files are matched against the step paths and URLs of the newest run. Imports and `affects:` globs need the repository: `kaloko affected` follows them |
43
45
 
44
46
  ## Comment tools
@@ -52,6 +54,9 @@ file.
52
54
  | `get_signoff_record` | the signed acceptance record of an accepted run (Business): approvals with provenance, criteria, capture hashes, signature |
53
55
  | `resolve_comment` | close a thread with a `note` that says what changed (it becomes the last reply) |
54
56
  | `reopen_comment`, `edit_comment`, `delete_comment` | reopen; edit or delete your own comment |
57
+ | `create_issue` | a Jira or Linear issue from a step (`run_id`, `step`, `to`: jira or linear, optional `criterion`, `title`, `note`, `locale`, `viewport`, `target`): screenshot, criterion, notes and a link back; the step's thread resolves when the issue is done. Developer / Tester, scope comment; see [integrations.md](integrations.md) |
58
+ | `list_issues` | issues created from steps with key, URL and state (`run_id`, `scenario`, `state`) |
59
+ | `list_schedules`, `run_schedule` | hosted runs: schedules of read-only scenarios on Kaloko's browsers with the month's page budget; start one now (`id`) |
55
60
 
56
61
  Writes need a token with scope full. Follow *Processing feedback* in
57
62
  [collaboration.md](collaboration.md).
@@ -79,6 +84,8 @@ The same data and permissions as the REST API; each tool is its REST route.
79
84
  | `list_step_changes` | steps that differ between two references (default design reference → draft) |
80
85
  | `get_references`, `get_tokens` | design reference and baseline; the project's token set |
81
86
  | `get_draft_feedback` | draft comments, pinned ones with their element |
82
- | `publish_version`, `set_reference`, `set_step_status`, `add_draft_comment` | writes: need a token with scope full; ask first where the skill says so |
87
+ | `get_version_notes` | what changed in a version (`version`: vN or run id) or what publishing the draft now would say (`version: draft`): a summary and one plain sentence per changed step; text by default, `format: json` for the structure |
88
+ | `publish_version`, `set_reference`, `set_step_status`, `add_draft_comment` | writes: need a token with scope full; ask first where the skill says so. `publish_version` takes `notes` (a summary, then `step-id: text` lines, or `{ summary, steps }`) |
89
+ | `set_version_notes` | rewrite the summary or step texts of a published version's notes (design scope) |
83
90
 
84
91
  Files are not uploaded over MCP: the CLI (`kaloko sync`) or the REST blob endpoints carry them.
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'
@@ -73,12 +75,17 @@ Project
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
77
  (qa/flows/main-pages.yml) when the project has none; asks "Keep a visual changelog?"
76
- (--docs: changelog adds CHANGELOG.md and the config block, full also docs/ folders)
78
+ (--docs: changelog adds CHANGELOG.md and the config block, full also docs/ folders
79
+ with Diátaxis templates: tutorials, how-to, reference, explanation)
77
80
  ci github|gitlab [--scenario a.yml,b.yml] [--env preview] [--trigger deployment|pull_request] [--no-affected]
78
81
  [--out file] [--print] [--force]
79
82
  Write a workflow that walks what each pull request affects on its preview
80
83
  deployment (Vercel, Netlify, Cloudflare Pages, review apps) and comments on it;
81
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)
82
89
  ci preview-url [--wait 600] [--github-env] [--dotenv file]
83
90
  In CI: the preview URL (KALOKO_PREVIEW_URL, the deployment event, provider
84
91
  variables or the commit's GitHub deployments), the pull request and its base
@@ -228,21 +235,68 @@ Results
228
235
  stability --pull [--scenario id] [--json]
229
236
  Regions people drew on screenshots on kaloko.app (step detail → Page → Draw a
230
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)
231
242
  compare [--run id] [--baseline id | --design] [--json]
232
243
  Text diff of two runs: step statuses, criterion verdicts, scores and evidence
233
244
  (default: current run against the scenario baseline, else the previous run)
234
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
+ Products (products: in kaloko.config.yml; without it the project is the product)
263
+ products sync [--product p] [--dry-run] [--json]
264
+ Put the products of the config on Kaloko: name, projects, modules, version
265
+ source, reference environment and default scenario, without recording a
266
+ release. They show under Products in the app with their changelog and docs;
267
+ publish: is applied only for an admin of the product's projects
268
+ products list [--json] The products on Kaloko you read: newest release, docs, who reads them, and
269
+ the products of the config that are not on Kaloko yet
270
+
235
271
  Changelog (Keep a Changelog in CHANGELOG.md; pictures are step references kaloko:<scenario>/<step>)
236
272
  changelog draft [--product p] [--since <tag|version>] [--apply] [--json] [--offline]
237
273
  The Unreleased entry since the last release: merged pull requests and commits
238
274
  (Conventional Commits when used, gh for titles) grouped Added / Changed / Fixed /
239
275
  Removed, plus before/after pictures of the screens accepted runs show differently;
240
276
  printed, or --apply writes it into CHANGELOG.md (not when a release tool owns it)
241
- release [<version>] [--product p] [--date YYYY-MM-DD] [--runs id,id] [--no-write] [--dry-run] [--json]
277
+ release [<version>] [--product p] [--date YYYY-MM-DD] [--runs id,id] [--no-write] [--no-docs] [--dry-run] [--json]
242
278
  Record a release on Kaloko: the version (default: from git tags, package.json,
243
279
  release-please or changesets), its CHANGELOG.md entry (Unreleased becomes the
244
280
  version unless --no-write) and the accepted runs that are its pictures; prints the
245
- illustrated changelog page
281
+ illustrated changelog page. The docs folder is published as that version's docs
282
+ (Business) unless --no-docs
283
+
284
+ Docs (Markdown in docs/ with step references; Diátaxis folders; translations as guide.cs.md next to guide.md)
285
+ docs publish [--product p] [--version latest|<v>] [--reviewed] [--dry-run] [--json]
286
+ Send the docs folder to Kaloko as the docs of a version (default latest): readers
287
+ get a version selector, language and screen switches, search and training mode;
288
+ --reviewed says the text was checked, which clears its stale notes
289
+ docs check [--product p] [--version v] [--offline] [--strict] [--json]
290
+ Broken or unknown step references, stale sections (the screen changed since the
291
+ text was published), missing or outdated translations and user-visible changelog
292
+ items without a picture; exit 1 on references or stale sections (--strict: on
293
+ anything)
294
+ export --format md|docusaurus|vitepress|mkdocs|html|pdf|json [--product p] [--version v] [--lang l]
295
+ [--what all|docs|changelog] [--local] [--out dir|file]
296
+ The docs and changelog with their pictures as files: Markdown with an assets
297
+ folder, a Docusaurus, VitePress or MkDocs folder with sidebars, offline HTML, PDF
298
+ (printed by the browser) or the changelog as JSON; --local exports the files in
299
+ the repository instead of the published ones
246
300
 
247
301
  Design (environments with kind: design; live HTML steps in a folder)
248
302
  start --scenario <file> --env <design env> A run of the design folder (same scenario as development and QA)
@@ -254,7 +308,15 @@ Design (environments with kind: design; live HTML steps in a folder)
254
308
  design lint [--env e | --dir design/] [--scenario file] [--all] [--strict] [--json]
255
309
  Check the folder against the portable HTML rules (relative URLs, no CDNs, no storage,
256
310
  deterministic content, viewport, lang, links that also work from disk); --strict fails on warnings
257
- share [--name <text>] [--branch b] Publish the draft as a version of the flow (a run): "v4 · 3 changed, 17 unchanged"
311
+ share [--name <text>] [--branch b] [--notes file|-] [--notes-preview]
312
+ Publish the draft as a version of the flow (a run): "v4 · 3 changed, 17 unchanged",
313
+ with "what changed" notes per step (screens per viewport, text, tokens); --notes-preview
314
+ prints them without publishing, --notes replaces them (a summary, then "step: text" lines)
315
+ design import figma <file-url|key|export folder> [--scenario file] [--frames a,b] [--page p] [--map frames.json]
316
+ [--token-env FIGMA_TOKEN] [--tokens auto|write|skip] [--force] [--dry-run] [--publish [--notes file]]
317
+ Figma frames become design steps (frame PNG at 1× and 2×, text layers with their
318
+ typography, variables or styles as tokens.json) matched to steps by name or --map;
319
+ a folder exported from Figma works without a token. One way: no prototype links
258
320
  history [--scenario id] [--step id] [--locale l] [--json]
259
321
  Versions of the flow; revisions of a step with who (person or agent) and when
260
322
  spec [step] [--ref design|draft|vN|<run id>] [--locale l] [--viewport v] [--local] [--json]
@@ -394,6 +456,49 @@ function stepOf (scenario, id) {
394
456
  }
395
457
 
396
458
  /** Scenario of --scenario (id or file) or of the current run. */
459
+ /**
460
+ * kaloko design import figma <file-url|key|folder>: Figma frames (or a folder exported from Figma) become the steps of
461
+ * the design folder; --publish then walks them and publishes a version (the design reference to be).
462
+ */
463
+ async function designImport (args) {
464
+ const [, kind, source] = args._
465
+ 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]]')
466
+ const { config, root } = await context(args)
467
+ let run = null
468
+ try { run = await resolveRun({ root, output: config.output, run: args.run }) } catch {}
469
+ const runEnv = run ? environment(config, run.meta.environment.name) : null
470
+ 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')
471
+ 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>.')
472
+ const scenarioFile = typeof args.scenario === 'string' ? resolve(args.scenario) : run && runEnv?.kind === 'design' ? resolve(root, run.meta.scenario_file) : null
473
+ if (!scenarioFile) throw new UserError('Name the scenario whose steps the frames are: --scenario <file>.')
474
+ const scenario = await loadScenario(scenarioFile)
475
+ const { importFigma, formatImport, readMap } = await import('./lib/figma.mjs')
476
+ const scope = startScope({ args: {}, scenario, config, env })
477
+ const viewports = Object.fromEntries(baseViewports(scope).map((v) => [v, scope.viewports[v]]))
478
+ 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'
479
+ const dir = typeof args.dir === 'string' ? resolve(args.dir) : designDir(env, root)
480
+ const r = await importFigma({
481
+ source, scenario, viewports, locales: scenario.locales || ['default'], dir,
482
+ token: process.env[tokenVar] || (tokenVar === 'FIGMA_TOKEN' ? process.env.FIGMA_ACCESS_TOKEN : null) || null,
483
+ map: typeof args.map === 'string' ? await readMap(resolve(args.map)) : null, only: list(args.frames), page: typeof args.page === 'string' ? args.page : null,
484
+ tokensMode: ['write', 'skip'].includes(args.tokens) ? args.tokens : 'auto', force: !!args.force, dryRun: !!args['dry-run'], say
485
+ })
486
+ if (args.json) say(JSON.stringify(r, null, 2))
487
+ else say(formatImport(r, { dir: relative(process.cwd(), dir) || '.' }))
488
+ if (args['dry-run']) return r
489
+ const lint = await lintDesign({ dir, scenario })
490
+ if (lint.errors) say(`\n${formatLint(lint, { max: 10 })}`)
491
+ if (!args.publish) {
492
+ 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.`)
493
+ return r
494
+ }
495
+ await commands.start({ ...args, _: [], scenario: scenarioFile, env: env.name })
496
+ const current = await resolveRun({ root, output: config.output })
497
+ await commands.walk({ _: [], run: current.dir })
498
+ 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 } : {}) })
499
+ return r
500
+ }
501
+
397
502
  async function scenarioIdOf (args, config, root) {
398
503
  if (typeof args.scenario === 'string') {
399
504
  if (/\.ya?ml$/.test(args.scenario)) return (await loadScenario(resolve(args.scenario))).id
@@ -801,7 +906,7 @@ const commands = {
801
906
  const cfgFile = existsSync(file) ? file : join(cwd, LEGACY_CONFIG_FILE)
802
907
  const cfg = await readFile(cfgFile, 'utf8')
803
908
  const products = /^products:/m.test(cfg) ? [] : await detectProducts(cwd)
804
- if (!/^docs:/m.test(cfg) && !/^products:/m.test(cfg)) { await writeFile(cfgFile, `${cfg.replace(/\n*$/, '\n')}${docsConfigBlock({ products })}`); say(`Added the changelog settings to ${relative(cwd, cfgFile)}${products.length ? ` with ${products.length} products (${products.map((p) => p.slug).join(', ')})` : ''}`) }
909
+ 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(', ')})` : ''}`) }
805
910
  for (const f of products.length ? products.map((p) => join(cwd, p.dir, 'CHANGELOG.md')) : [join(cwd, 'CHANGELOG.md')]) {
806
911
  if (existsSync(f)) continue
807
912
  await mkdirp(dirname(f), { recursive: true })
@@ -815,7 +920,7 @@ const commands = {
815
920
  await mkdirp(dirname(f), { recursive: true })
816
921
  await writeFile(f, text)
817
922
  }
818
- say('Docs folders in docs/ (tutorials, how-to, reference, explanation) with an index')
923
+ 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')
819
924
  }
820
925
  } else if (!docsMode) say('Visual changelog: kaloko init --docs=changelog adds CHANGELOG.md and the config block (recommended).')
821
926
  const agent = typeof args.agent === 'string' ? args.agent : 'none'
@@ -874,7 +979,7 @@ const commands = {
874
979
  try { written = await readFile(existsSync(file) ? file : join(cwd, LEGACY_CONFIG_FILE), 'utf8') } catch {}
875
980
  const org = typeof args.org === 'string' ? args.org : (written.match(/^org:\s*["']?([a-z0-9-]+)["']?\s*$/m) || [])[1]
876
981
  const base = serviceUrl({ share: { url: (written.match(/^share:[ \t]*\n(?:[ \t]+.*\n)*?[ \t]+url:[ \t]*["']?(https?:\/\/[^\s"']+)/m) || [])[1] } })
877
- say(initNextSteps({ base, org, loggedIn: !!process.env.KALOKO_TOKEN, devServer: detected && !running ? detected : null, scenario: first && relative(cwd, first) }).join('\n'))
982
+ say(initNextSteps({ base, org, loggedIn: !!process.env.KALOKO_TOKEN, devServer: detected && !running ? detected : null, scenario: first && relative(cwd, first), products: /^products:/m.test(written) }).join('\n'))
878
983
  say(`Scenario template: ${join(packageRoot, 'templates', 'scenario.yml')}`)
879
984
  },
880
985
 
@@ -920,7 +1025,7 @@ const commands = {
920
1025
  const vpNames = baseViewports(scope)
921
1026
  const git = await gitMeta(root)
922
1027
  const executor = { kind: args.executor ? 'agent' : 'cli', name: args.executor || 'kaloko-cli' }
923
- const { project, issue } = await projectAndIssue({ cwd: root, config, scenario, branch: git?.branch })
1028
+ const { project, issue } = await projectAndIssue({ cwd: root, config, scenario, branch: git?.branch, title: git?.pr?.title || null })
924
1029
  const appVer = typeof args['app-version'] === 'string' ? args['app-version'].slice(0, 80) : await appVersion(root)
925
1030
  // the product the flow documents (visual changelog): its tag, or the only product of the config
926
1031
  const product = scenario.product || (Object.keys(config.products || {}).length === 1 ? Object.keys(config.products)[0] : null)
@@ -1390,8 +1495,32 @@ const commands = {
1390
1495
  console.log(preview.url)
1391
1496
  return
1392
1497
  }
1393
- 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]')
1498
+ 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]')
1394
1499
  const { config, root, file } = await context(args)
1500
+ if (args.release) {
1501
+ // release-tool integrations: record each published release on Kaloko (docs/DOCS-CHANGELOG.md)
1502
+ if (action !== 'github') throw new UserError('--release writes a GitHub Actions workflow; on GitLab run kaloko release <version> --no-write in your release job.')
1503
+ const { productsOf, detectVersionSource } = await import('./lib/changelog.mjs')
1504
+ const { releaseWorkflow, releaseSnippet } = await import('./lib/ci.mjs')
1505
+ const products = productsOf(config, root)
1506
+ const sources = await Promise.all(products.map((p) => detectVersionSource(root, p)))
1507
+ const tool = sources.map((s) => s.tool).find(Boolean) || null
1508
+ let pkg = null
1509
+ try { pkg = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) } catch {}
1510
+ 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'
1511
+ const dependency = !!(pkg && ({ ...pkg.dependencies, ...pkg.devDependencies }).kaloko)
1512
+ const version = `^${JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')).version}`
1513
+ if (args.snippet) { say(releaseSnippet({ tool, pm, dependency, version, product: products.length > 1 ? '<product>' : null })); return }
1514
+ 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 })
1515
+ if (args.print) { say(content); return }
1516
+ const out = resolve(root, typeof args.out === 'string' ? args.out : '.github/workflows/kaloko-release.yml')
1517
+ if (existsSync(out) && !args.force) throw new UserError(`${relative(process.cwd(), out)} exists; pass --force to replace it (or --print to compare).`)
1518
+ await mkdir(dirname(out), { recursive: true })
1519
+ await writeFile(out, content)
1520
+ 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.`)
1521
+ 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')
1522
+ return
1523
+ }
1395
1524
  const envName = typeof args.env === 'string' ? args.env : 'preview'
1396
1525
  const all = (await scenarioFiles(config, root)).map((f) => relative(root, f))
1397
1526
  const scenarios = list(args.scenario) || all
@@ -1568,6 +1697,7 @@ const commands = {
1568
1697
  if (env.kind === 'design') {
1569
1698
  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 })) })
1570
1699
  if (r.conflicts) return
1700
+ if (args['notes-preview']) { await previewNotes({ config, scenario, client, args, say }); return }
1571
1701
  const v = await publishVersion({ config, scenario, client, args, say })
1572
1702
  const meta = await readJson(join(run.dir, 'meta.json'))
1573
1703
  meta.shared = { url: v.url, at: new Date().toISOString(), version: v.version, run: v.id }
@@ -1663,7 +1793,8 @@ const commands = {
1663
1793
  },
1664
1794
 
1665
1795
  async design (args) {
1666
- if (args._[0] !== 'lint') throw new UserError('Usage: kaloko design lint [--env <design env> | --dir design/] [--scenario file] [--all] [--strict] [--json]')
1796
+ if (args._[0] === 'import') return designImport(args)
1797
+ 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]')
1667
1798
  const { config, root } = await context(args)
1668
1799
  let run = null
1669
1800
  try { run = await resolveRun({ root, output: config.output, run: args.run }) } catch {}
@@ -1680,6 +1811,12 @@ const commands = {
1680
1811
  if (report.errors || (args.strict && report.warnings)) process.exitCode = 1
1681
1812
  },
1682
1813
 
1814
+ /** `kaloko screens [words]`: the screen library of the organization (newest capture of every step). */
1815
+ async screens (args) {
1816
+ const { config } = await context(args)
1817
+ await screensCmd({ config, client: service(config), args, say })
1818
+ },
1819
+
1683
1820
  async history (args) {
1684
1821
  const { config, root } = await context(args)
1685
1822
  const scenarioId = await scenarioIdOf(args, config, root)
@@ -1781,6 +1918,20 @@ const commands = {
1781
1918
  * The signed acceptance record of an accepted run: JSON and PDF downloaded and checked against the service's public
1782
1919
  * key; --verify checks a record someone handed over (no token, no project needed).
1783
1920
  */
1921
+ /** Jira and Linear (#21): an issue from a step of a shared run, and the issues created so far. */
1922
+ async issue (args) {
1923
+ const { config, root } = await context(args)
1924
+ let runId = null
1925
+ 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 {} }
1926
+ return issueCmd({ args, config, client: service(config), runId, say })
1927
+ },
1928
+
1929
+ /** Hosted runs (#16): schedules of read-only scenarios on Kaloko's browsers, or a scheduled GitHub workflow. */
1930
+ async schedule (args) {
1931
+ const { config, root } = await context(args)
1932
+ return scheduleCmd({ args, config, root, say, deps: { loadScenario, environment, baseUrl, projectAndIssue, packageRoot, service } })
1933
+ },
1934
+
1784
1935
  async signoff (args) {
1785
1936
  if (typeof args.verify === 'string') {
1786
1937
  const config = await context(args).then((c) => c.config).catch(() => ({}))
@@ -2039,6 +2190,27 @@ const commands = {
2039
2190
  return
2040
2191
  }
2041
2192
  if (typeof args.scenario === 'string') return exportScenario({ args, config, root, client })
2193
+ if (typeof args.format === 'string') {
2194
+ // living docs and the changelog as files (docs/DOCS-CHANGELOG.md → Exports)
2195
+ if (!config.org) throw new UserError('Set org: in the config first.')
2196
+ const { pickProduct } = await import('./lib/changelog.mjs')
2197
+ const { exportDocs } = await import('./lib/docs.mjs')
2198
+ const product = pickProduct(config, root, args.product)
2199
+ const printPdf = async (htmlFile, outFile) => {
2200
+ const { chromium } = await import('playwright')
2201
+ const browser = await chromium.launch(config.browser?.channel ? { channel: config.browser.channel } : {}).catch(() => chromium.launch())
2202
+ try {
2203
+ const page = await browser.newPage()
2204
+ await page.goto(pathToFileURL(htmlFile).href, { waitUntil: 'load', timeout: 60000 })
2205
+ await page.pdf({ path: outFile, format: 'A4', printBackground: true, margin: { top: '16mm', bottom: '16mm', left: '14mm', right: '14mm' } })
2206
+ } finally { await browser.close() }
2207
+ }
2208
+ 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 })
2209
+ if (r.kind === 'file') { say(`Exported ${product.slug} as ${r.format} → ${relative(process.cwd(), r.file) || r.file}`); return }
2210
+ const m = r.manifest
2211
+ 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 ? ' …' : ''}` : ''}`)
2212
+ return
2213
+ }
2042
2214
  let runId = args.run
2043
2215
  if (!runId) { try { runId = (await resolveRun({ root, output: config.output })).meta.id } catch {} }
2044
2216
  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).')
@@ -2095,6 +2267,55 @@ const commands = {
2095
2267
  say(draftText(d))
2096
2268
  },
2097
2269
 
2270
+ /** `kaloko docs publish|check`: the docs folder on Kaloko per version, and what in it needs attention. */
2271
+ async docs (args) {
2272
+ const sub = args._[0]
2273
+ 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]')
2274
+ const { config, root } = await context(args)
2275
+ const { pickProduct } = await import('./lib/changelog.mjs')
2276
+ const docs = await import('./lib/docs.mjs')
2277
+ const product = pickProduct(config, root, args.product)
2278
+ const version = typeof args.version === 'string' ? args.version : 'latest'
2279
+ if (sub === 'publish') {
2280
+ if (!config.org && !args['dry-run']) throw new UserError('Set org: in the config first.')
2281
+ const r = await docs.publishDocs({ root, config, product, client: args['dry-run'] ? null : service(config), version, reviewed: !!args.reviewed, dryRun: !!args['dry-run'] })
2282
+ if (args.json) { say(JSON.stringify(r, null, 2)); return }
2283
+ 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 }
2284
+ 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}`]
2285
+ for (const b of r.broken_refs || []) lines.push(` ! broken step reference in ${b.path || 'index'}${b.lang ? ` (${b.lang})` : ''}: ${b.ref}`)
2286
+ for (const u of (r.unresolved || []).slice(0, 10)) lines.push(` ! no accepted picture yet: ${u.ref} (${u.path || 'index'})`)
2287
+ for (const n of r.notes || []) lines.push(` ! ${n}`)
2288
+ say(lines.join('\n'))
2289
+ return
2290
+ }
2291
+ // check: the scenario files say which steps exist
2292
+ const scenarios = new Map()
2293
+ 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 {} }
2294
+ let client = null
2295
+ if (!args.offline) { try { client = service(config) } catch {} }
2296
+ const r = await docs.docsCheck({ root, config, product, client, scenarios: scenarios.size ? scenarios : null, version })
2297
+ say(args.json ? JSON.stringify(r, null, 2) : docs.formatCheck(r))
2298
+ if (!r.ok || (args.strict && r.warnings)) process.exitCode = 1
2299
+ },
2300
+
2301
+ /** `kaloko products sync|list`: the products of the config on Kaloko, without a release; what the service has. */
2302
+ async products (args) {
2303
+ const sub = args._[0]
2304
+ if (!['sync', 'list'].includes(sub)) throw new UserError('Usage: kaloko products sync [--product p] [--dry-run] [--json]\n kaloko products list [--json]')
2305
+ const { config, root } = await context(args)
2306
+ const lib = await import('./lib/changelog.mjs')
2307
+ if (sub === 'list') {
2308
+ if (!config.org) throw new UserError('Set org: in the config first.')
2309
+ const r = await lib.listProducts({ root, config, client: service(config) })
2310
+ say(args.json ? JSON.stringify(r, null, 2) : lib.formatList(r))
2311
+ return
2312
+ }
2313
+ if (!config.org && !args['dry-run']) throw new UserError('Set org: in the config first.')
2314
+ const r = await lib.syncProducts({ root, config, client: args['dry-run'] ? null : service(config), only: typeof args.product === 'string' ? args.product : null, dryRun: !!args['dry-run'] })
2315
+ say(args.json ? JSON.stringify(r, null, 2) : lib.formatSync(r))
2316
+ if (r.products.some((p) => p.ok === false)) process.exitCode = 1
2317
+ },
2318
+
2098
2319
  /** `kaloko release <version>`: the version's changelog entry and its reference runs on the service. */
2099
2320
  async release (args) {
2100
2321
  const { config, root } = await context(args)
@@ -2103,6 +2324,14 @@ const commands = {
2103
2324
  const product = pickProduct(config, root, args.product)
2104
2325
  const client = args['dry-run'] ? null : service(config)
2105
2326
  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'] })
2327
+ // the docs of this version: what the docs folder says now (Business; latest stays as kaloko docs publish left it)
2328
+ if (r.release && !args['no-docs']) {
2329
+ const docs = await import('./lib/docs.mjs')
2330
+ const pages = await docs.collectDocs(root, product).then((x) => x.pages.length).catch(() => 0)
2331
+ if (pages) {
2332
+ 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] } }
2333
+ }
2334
+ }
2106
2335
  if (args.json) { say(JSON.stringify(r, null, 2)); return }
2107
2336
  const file = relative(process.cwd(), r.file) || r.file
2108
2337
  const lines = [`${product.slug} ${r.version} (${r.date}) · version source: ${r.source.tool || r.source.source}${r.source.path ? ` (${r.source.path})` : ''}`]
@@ -2111,6 +2340,7 @@ const commands = {
2111
2340
  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}` : ''}`)
2112
2341
  lines.push(` ${r.release.url}`)
2113
2342
  } else lines.push(' Dry run: nothing sent.')
2343
+ 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}` : ''}`)
2114
2344
  for (const n of r.notes) lines.push(` ! ${n}`)
2115
2345
  if (r.finalized) lines.push(` Commit ${file} with the release.`)
2116
2346
  say(lines.join('\n'))