kaloko 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,59 @@ 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
+
235
262
  Changelog (Keep a Changelog in CHANGELOG.md; pictures are step references kaloko:<scenario>/<step>)
236
263
  changelog draft [--product p] [--since <tag|version>] [--apply] [--json] [--offline]
237
264
  The Unreleased entry since the last release: merged pull requests and commits
238
265
  (Conventional Commits when used, gh for titles) grouped Added / Changed / Fixed /
239
266
  Removed, plus before/after pictures of the screens accepted runs show differently;
240
267
  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]
268
+ release [<version>] [--product p] [--date YYYY-MM-DD] [--runs id,id] [--no-write] [--no-docs] [--dry-run] [--json]
242
269
  Record a release on Kaloko: the version (default: from git tags, package.json,
243
270
  release-please or changesets), its CHANGELOG.md entry (Unreleased becomes the
244
271
  version unless --no-write) and the accepted runs that are its pictures; prints the
245
- illustrated changelog page
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
246
291
 
247
292
  Design (environments with kind: design; live HTML steps in a folder)
248
293
  start --scenario <file> --env <design env> A run of the design folder (same scenario as development and QA)
@@ -254,7 +299,15 @@ Design (environments with kind: design; live HTML steps in a folder)
254
299
  design lint [--env e | --dir design/] [--scenario file] [--all] [--strict] [--json]
255
300
  Check the folder against the portable HTML rules (relative URLs, no CDNs, no storage,
256
301
  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"
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
258
311
  history [--scenario id] [--step id] [--locale l] [--json]
259
312
  Versions of the flow; revisions of a step with who (person or agent) and when
260
313
  spec [step] [--ref design|draft|vN|<run id>] [--locale l] [--viewport v] [--local] [--json]
@@ -394,6 +447,49 @@ function stepOf (scenario, id) {
394
447
  }
395
448
 
396
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
+
397
493
  async function scenarioIdOf (args, config, root) {
398
494
  if (typeof args.scenario === 'string') {
399
495
  if (/\.ya?ml$/.test(args.scenario)) return (await loadScenario(resolve(args.scenario))).id
@@ -801,7 +897,7 @@ const commands = {
801
897
  const cfgFile = existsSync(file) ? file : join(cwd, LEGACY_CONFIG_FILE)
802
898
  const cfg = await readFile(cfgFile, 'utf8')
803
899
  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(', ')})` : ''}`) }
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(', ')})` : ''}`) }
805
901
  for (const f of products.length ? products.map((p) => join(cwd, p.dir, 'CHANGELOG.md')) : [join(cwd, 'CHANGELOG.md')]) {
806
902
  if (existsSync(f)) continue
807
903
  await mkdirp(dirname(f), { recursive: true })
@@ -815,7 +911,7 @@ const commands = {
815
911
  await mkdirp(dirname(f), { recursive: true })
816
912
  await writeFile(f, text)
817
913
  }
818
- say('Docs folders in docs/ (tutorials, how-to, reference, explanation) with an index')
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')
819
915
  }
820
916
  } else if (!docsMode) say('Visual changelog: kaloko init --docs=changelog adds CHANGELOG.md and the config block (recommended).')
821
917
  const agent = typeof args.agent === 'string' ? args.agent : 'none'
@@ -920,7 +1016,7 @@ const commands = {
920
1016
  const vpNames = baseViewports(scope)
921
1017
  const git = await gitMeta(root)
922
1018
  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 })
1019
+ const { project, issue } = await projectAndIssue({ cwd: root, config, scenario, branch: git?.branch, title: git?.pr?.title || null })
924
1020
  const appVer = typeof args['app-version'] === 'string' ? args['app-version'].slice(0, 80) : await appVersion(root)
925
1021
  // the product the flow documents (visual changelog): its tag, or the only product of the config
926
1022
  const product = scenario.product || (Object.keys(config.products || {}).length === 1 ? Object.keys(config.products)[0] : null)
@@ -1390,8 +1486,32 @@ const commands = {
1390
1486
  console.log(preview.url)
1391
1487
  return
1392
1488
  }
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]')
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]')
1394
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
+ }
1395
1515
  const envName = typeof args.env === 'string' ? args.env : 'preview'
1396
1516
  const all = (await scenarioFiles(config, root)).map((f) => relative(root, f))
1397
1517
  const scenarios = list(args.scenario) || all
@@ -1568,6 +1688,7 @@ const commands = {
1568
1688
  if (env.kind === 'design') {
1569
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 })) })
1570
1690
  if (r.conflicts) return
1691
+ if (args['notes-preview']) { await previewNotes({ config, scenario, client, args, say }); return }
1571
1692
  const v = await publishVersion({ config, scenario, client, args, say })
1572
1693
  const meta = await readJson(join(run.dir, 'meta.json'))
1573
1694
  meta.shared = { url: v.url, at: new Date().toISOString(), version: v.version, run: v.id }
@@ -1663,7 +1784,8 @@ const commands = {
1663
1784
  },
1664
1785
 
1665
1786
  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]')
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]')
1667
1789
  const { config, root } = await context(args)
1668
1790
  let run = null
1669
1791
  try { run = await resolveRun({ root, output: config.output, run: args.run }) } catch {}
@@ -1680,6 +1802,12 @@ const commands = {
1680
1802
  if (report.errors || (args.strict && report.warnings)) process.exitCode = 1
1681
1803
  },
1682
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
+
1683
1811
  async history (args) {
1684
1812
  const { config, root } = await context(args)
1685
1813
  const scenarioId = await scenarioIdOf(args, config, root)
@@ -1781,6 +1909,20 @@ const commands = {
1781
1909
  * The signed acceptance record of an accepted run: JSON and PDF downloaded and checked against the service's public
1782
1910
  * key; --verify checks a record someone handed over (no token, no project needed).
1783
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
+
1784
1926
  async signoff (args) {
1785
1927
  if (typeof args.verify === 'string') {
1786
1928
  const config = await context(args).then((c) => c.config).catch(() => ({}))
@@ -2039,6 +2181,27 @@ const commands = {
2039
2181
  return
2040
2182
  }
2041
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
+ }
2042
2205
  let runId = args.run
2043
2206
  if (!runId) { try { runId = (await resolveRun({ root, output: config.output })).meta.id } catch {} }
2044
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).')
@@ -2095,6 +2258,37 @@ const commands = {
2095
2258
  say(draftText(d))
2096
2259
  },
2097
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
+
2098
2292
  /** `kaloko release <version>`: the version's changelog entry and its reference runs on the service. */
2099
2293
  async release (args) {
2100
2294
  const { config, root } = await context(args)
@@ -2103,6 +2297,14 @@ const commands = {
2103
2297
  const product = pickProduct(config, root, args.product)
2104
2298
  const client = args['dry-run'] ? null : service(config)
2105
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
+ }
2106
2308
  if (args.json) { say(JSON.stringify(r, null, 2)); return }
2107
2309
  const file = relative(process.cwd(), r.file) || r.file
2108
2310
  const lines = [`${product.slug} ${r.version} (${r.date}) · version source: ${r.source.tool || r.source.source}${r.source.path ? ` (${r.source.path})` : ''}`]
@@ -2111,6 +2313,7 @@ const commands = {
2111
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}` : ''}`)
2112
2314
  lines.push(` ${r.release.url}`)
2113
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}` : ''}`)
2114
2317
  for (const n of r.notes) lines.push(` ! ${n}`)
2115
2318
  if (r.finalized) lines.push(` Commit ${file} with the release.`)
2116
2319
  say(lines.join('\n'))
@@ -259,7 +259,7 @@ export async function releaseProduct ({ root, config, product, version = null, d
259
259
  const tag = tagFor(src.pattern, v)
260
260
  const commit = (await tagExists(root, tag)) ? ((await out('git', ['rev-list', '-n', '1', tag], root)) || '').trim() : ((await out('git', ['rev-parse', 'HEAD'], root)) || '').trim()
261
261
  const payload = {
262
- product: { slug: product.slug, name: product.name, projects: product.projects, modules: product.modules, reference_env: product.reference_env, default_scenario: product.default_scenario, implicit: product.implicit, version_source: { source: src.source, ...(src.path ? { path: src.path } : {}), pattern: src.pattern, ...(src.tool ? { tool: src.tool } : {}) }, ...(product.publish ? { publish: product.publish } : {}), ...(await repoUrl(root)) },
262
+ product: await productPayload(root, product, src),
263
263
  release: { product: product.slug, version: v, date: section?.date || day, changelog: md, source_hash: sha256(md), tag: (await tagExists(root, tag)) ? tag : null, commit: /^[0-9a-f]{40}$/.test(commit) ? commit : null, ...(runs ? { runs } : {}) }
264
264
  }
265
265
  if (dryRun) return { version: v, date: payload.release.date, file, section: !!section, finalized, source: src, notes, payload, items: parseEntry(md).groups.reduce((n, g) => n + g.items.length, 0), refs: refs.length }
@@ -268,6 +268,11 @@ export async function releaseProduct ({ root, config, product, version = null, d
268
268
  const rec = await client.request('POST', `/orgs/${config.org}/releases`, payload.release)
269
269
  return { version: v, date: payload.release.date, file, section: !!section, finalized, source: src, notes, release: rec, product: saved.product, items: parseEntry(md).groups.reduce((n, g) => n + g.items.length, 0), refs: refs.length }
270
270
  }
271
+ /** The product as the service records it (POST /products): from the config, with the version source found. */
272
+ export async function productPayload (root, product, src) {
273
+ return { slug: product.slug, name: product.name, projects: product.projects, modules: product.modules, reference_env: product.reference_env, default_scenario: product.default_scenario, implicit: product.implicit, version_source: { source: src.source, ...(src.path ? { path: src.path } : {}), pattern: src.pattern, ...(src.tool ? { tool: src.tool } : {}) }, ...(product.publish ? { publish: product.publish } : {}), ...(await repoUrl(root)) }
274
+ }
275
+
271
276
  async function repoUrl (root) {
272
277
  const r = await repoSlug(root)
273
278
  return r?.host === 'github' ? { repo: `https://github.com/${r.slug}` } : r?.host === 'gitlab' ? { repo: `https://gitlab.com/${r.slug}` } : {}
@@ -276,8 +281,9 @@ async function repoUrl (root) {
276
281
  // ---------- init and doctor ----------
277
282
 
278
283
  /** The config block `kaloko init --docs=changelog|full` adds (comments say what each line does). */
279
- export function docsConfigBlock ({ products = null } = {}) {
284
+ export function docsConfigBlock ({ products = null, full = false } = {}) {
280
285
  const lines = ['', '# Visual changelog: kaloko changelog draft writes the Unreleased entry with step references,', '# kaloko release <version> records the release and its pictures (docs/DOCS-CHANGELOG.md on kaloko.app)', 'docs:', ' changelog: CHANGELOG.md', ' version_source: auto # git-tag | package.json | release-please | changesets | semantic-release']
286
+ if (full) lines.push(' dir: docs # guides with step references; kaloko docs publish sends them, kaloko docs check checks them', ' # languages: [en, cs] # the first is the originals\' language; translations sit next to them (guide.cs.md)')
281
287
  if (products?.length) {
282
288
  lines.push('# one product per app in this repository; scenarios say which one they show (product: <slug>)', 'products:')
283
289
  for (const p of products) lines.push(` ${p.slug}:`, ` name: ${JSON.stringify(p.name)}`, ` version: { source: package.json, path: ${p.path} }`, ` docs: { changelog: ${p.dir}/CHANGELOG.md }`)
@@ -298,13 +304,73 @@ export async function detectProducts (root) {
298
304
  return out.length > 1 ? out : []
299
305
  }
300
306
 
301
- /** Diátaxis folders and an index for `kaloko init --docs=full` (only what is missing). */
307
+ /**
308
+ * Diátaxis folders and an index for `kaloko init --docs=full` (only what is missing). Each folder's README says what
309
+ * belongs there and carries a template in a comment (comments never show on Kaloko or in exports).
310
+ */
302
311
  export const DOCS_SKELETON = {
303
- 'docs/index.md': '# Documentation\n\nGuides for the people who use the product. Pictures are step references\n(`![](kaloko:<scenario>/<step>)`) that always show the newest accepted screen.\n\n- [Tutorials](tutorials/) — learn by doing, start to finish\n- [How-to guides](how-to/) — one task each\n- [Reference](reference/) — the facts to look up\n- [Explanation](explanation/) — why it works this way\n',
304
- 'docs/tutorials/README.md': '# Tutorials\n',
305
- 'docs/how-to/README.md': '# How-to guides\n',
306
- 'docs/reference/README.md': '# Reference\n',
307
- 'docs/explanation/README.md': '# Explanation\n'
312
+ 'docs/index.md': '# Documentation\n\nGuides for the people who use the product. Pictures are step references\n(`![](kaloko:<scenario>/<step>)`) that always show the newest accepted screen.\n\n- [Tutorials](tutorials/README.md): learn by doing, start to finish\n- [How-to guides](how-to/README.md): one task each\n- [Reference](reference/README.md): the facts to look up\n- [Explanation](explanation/README.md): why it works this way\n',
313
+ 'docs/tutorials/README.md': `# Tutorials
314
+
315
+ A tutorial takes a newcomer through a first success, start to finish, on a path that always works.
316
+
317
+ <!-- Template (copy into a new file, e.g. first-order.md):
318
+ ---
319
+ title: Your first order
320
+ order: 1
321
+ ---
322
+ # Your first order
323
+
324
+ In ten minutes you will have placed an order and seen it in your account.
325
+
326
+ 1. Open the shop and put a tea in the cart.
327
+ ![Cart](kaloko:checkout/cart)
328
+ 2. Choose **Checkout** and pay with a test card.
329
+ ![Payment](kaloko:checkout/payment)
330
+
331
+ You have placed your first order. Next: [track it](../how-to/track-an-order.md).
332
+ -->
333
+ `,
334
+ 'docs/how-to/README.md': `# How-to guides
335
+
336
+ One task per page, for someone who already knows what they want to do.
337
+
338
+ <!-- Template (copy into a new file, e.g. pay-with-apple-pay.md):
339
+ # Pay with Apple Pay
340
+
341
+ 1. Open the cart and choose **Checkout**.
342
+ ![Cart](kaloko:checkout/cart)
343
+ 2. Choose **Apple Pay** above the card form and confirm on your phone or Mac.
344
+ ![Payment](kaloko:checkout/payment)
345
+
346
+ If the button is missing, your browser has no Apple Pay set up.
347
+ -->
348
+ `,
349
+ 'docs/reference/README.md': `# Reference
350
+
351
+ Facts to look up: fields, limits, settings, keyboard shortcuts. Tables and lists, no story.
352
+
353
+ <!-- Template (copy into a new file, e.g. order-states.md):
354
+ # Order states
355
+
356
+ | State | What it means |
357
+ |---|---|
358
+ | Paid | The payment went through; the order waits for packing. |
359
+ | Shipped | The parcel left the warehouse. |
360
+
361
+ ![Order detail](kaloko:account/order-detail)
362
+ -->
363
+ `,
364
+ 'docs/explanation/README.md': `# Explanation
365
+
366
+ Why things work the way they do: background, choices, trade-offs.
367
+
368
+ <!-- Template (copy into a new file, e.g. why-we-hold-payments.md):
369
+ # Why a payment waits a day
370
+
371
+ Card payments are confirmed the next morning, so a cancelled order costs the customer nothing.
372
+ -->
373
+ `
308
374
  }
309
375
 
310
376
  /**
package/src/lib/ci.mjs CHANGED
@@ -209,3 +209,74 @@ export function addPreviewEnvironment (text, name = 'preview') {
209
209
  const at = m.index + m[0].length
210
210
  return text.slice(0, at) + '\n' + block + text.slice(at)
211
211
  }
212
+
213
+ // ---------- releases (docs/DOCS-CHANGELOG.md → release-tool integrations) ----------
214
+
215
+ const TOOL_SNIPPETS = {
216
+ 'release-please': { after: 'googleapis/release-please-action (id: release)', when: '${{ steps.release.outputs.release_created }}', version: '${{ steps.release.outputs.version }}' },
217
+ changesets: { after: 'changesets/action (id: changesets)', when: "${{ steps.changesets.outputs.published == 'true' }}", version: '' },
218
+ 'semantic-release': { after: 'npx semantic-release', when: null, version: '' }
219
+ }
220
+
221
+ /**
222
+ * .github/workflows/kaloko-release.yml: when a GitHub release is published (release-please, semantic-release with
223
+ * @semantic-release/github and changesets all publish one) or by hand, `kaloko release <version> --no-write` records
224
+ * it on Kaloko: the entry the release tool wrote, the accepted runs that are its pictures, and the docs of that
225
+ * version. `products`: [{ slug, pattern }] (tag patterns such as v* or shop-v*); several products get one step each.
226
+ */
227
+ export function releaseWorkflow ({ products = [{ slug: null, pattern: 'v*' }], pm = 'npm', dependency = true, version = 'latest', node = 22, tool = null }) {
228
+ const { install, kaloko: k } = runner({ pm, dependency, version })
229
+ const several = products.length > 1
230
+ const steps = products.map((p) => {
231
+ const prefix = String(p.pattern || 'v*').split('*')[0]
232
+ const flag = several || p.slug ? ` --product ${q(p.slug)}` : ''
233
+ return ` - name: Record ${p.slug || 'the release'} on Kaloko
234
+ ${several ? ` if: startsWith(env.TAG, '${prefix}')\n` : ''} run: ${k} release "\${TAG#${prefix}}"${flag} --no-write`
235
+ }).join('\n')
236
+ return `# Kaloko: record every release on Kaloko once your release tool has published it: its changelog entry with
237
+ # before/after pictures, the accepted runs that are its reference, and the docs of that version.
238
+ # Written by kaloko ci github --release${tool ? ` (${tool} found)` : ''}. Secret: KALOKO_TOKEN (tester role, upload scope).
239
+ # kaloko release --no-write only reads CHANGELOG.md; the release tool keeps writing it.
240
+ name: Kaloko release
241
+ on:
242
+ release:
243
+ types: [published]
244
+ workflow_dispatch:
245
+ inputs:
246
+ tag:
247
+ description: Tag of the release to record (e.g. v2.4.0)
248
+ required: true
249
+ permissions:
250
+ contents: read
251
+ jobs:
252
+ record:
253
+ runs-on: ubuntu-latest
254
+ timeout-minutes: 10
255
+ env:
256
+ KALOKO_TOKEN: \${{ secrets.KALOKO_TOKEN }}
257
+ GH_TOKEN: \${{ github.token }}
258
+ KALOKO_AGENT: github-actions
259
+ TAG: \${{ github.event.release.tag_name || inputs.tag }}
260
+ steps:
261
+ - uses: actions/checkout@v4
262
+ with:
263
+ ref: \${{ github.event.release.tag_name || inputs.tag }}
264
+ fetch-depth: 0 # the tags and the history since the previous release
265
+ - uses: actions/setup-node@v4
266
+ with:
267
+ node-version: ${node}
268
+ ${install ? `${['pnpm', 'yarn'].includes(pm) ? ' - run: corepack enable\n' : ''} - run: ${install}\n` : ''}${steps}
269
+ `
270
+ }
271
+
272
+ /** The step to paste into an existing release workflow, right after the release tool's own step. */
273
+ export function releaseSnippet ({ tool = null, pm = 'npm', dependency = true, version = 'latest', product = null }) {
274
+ const { kaloko: k } = runner({ pm, dependency, version })
275
+ const s = TOOL_SNIPPETS[tool] || { after: 'the step that publishes the release', when: null, version: '' }
276
+ return `# after ${s.after}:
277
+ - name: Record the release on Kaloko
278
+ ${s.when ? ` if: ${s.when}\n` : ''} run: ${k} release${s.version ? ` ${s.version}` : ''}${product ? ` --product ${q(product)}` : ''} --no-write
279
+ env:
280
+ KALOKO_TOKEN: \${{ secrets.KALOKO_TOKEN }}
281
+ `
282
+ }
@@ -297,11 +297,31 @@ export async function syncDesign ({ config, root, run, scenario, env, client, ar
297
297
  return { pushed: toPush.length, pulled: toPull.length, conflicts: conflicts.length ? conflicts : null, seq: after.seq }
298
298
  }
299
299
 
300
- /** Publishes the draft head as a version (a run of the design environment). */
300
+ /** The notes an agent or person wrote for a version: --notes <file> or --notes - (stdin); null without the option. */
301
+ export async function readNotesArg (value, stdin = process.stdin) {
302
+ if (value == null || value === false) return null
303
+ if (value === true) throw new UserError('--notes takes a file (Markdown, text or JSON) or - for standard input.')
304
+ if (value === '-') { const chunks = []; for await (const c of stdin) chunks.push(c); return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf8') }
305
+ try { return await readFile(value, 'utf8') } catch { throw new UserError(`--notes: cannot read ${value}.`) }
306
+ }
307
+
308
+ /** What publishing the draft now would write as "what changed" (kaloko share --notes-preview). */
309
+ export async function previewNotes ({ config, scenario, client, args, say }) {
310
+ const branch = typeof args.branch === 'string' ? args.branch : 'main'
311
+ const r = await client.request('GET', `/orgs/${config.org}/scenarios/${enc(scenario.id)}/versions/draft/notes?branch=${enc(branch)}`)
312
+ if (args.json) { say(JSON.stringify(r, null, 2)); return r }
313
+ const { notesText } = await import('./design-notes.mjs')
314
+ say(`What publishing now would say (${r.changed} changed, ${r.unchanged} unchanged${r.notes?.from ? ` since v${r.notes.from.version}` : ''}):\n\n${notesText(r.notes, { lang: args.lang === 'cs' ? 'cs' : 'en' })}\n\nWrite your own in a file (a summary, then "step-id: what changed" lines) and publish with kaloko share --notes <file>.`)
315
+ return r
316
+ }
317
+
318
+ /** Publishes the draft head as a version (a run of the design environment), with "what changed" notes. */
301
319
  export async function publishVersion ({ config, scenario, client, args, say }) {
302
320
  const branch = typeof args.branch === 'string' ? args.branch : 'main'
303
- const r = await client.request('POST', `/orgs/${config.org}/scenarios/${enc(scenario.id)}/versions`, { branch, ...(typeof args.name === 'string' ? { name: args.name } : {}) })
321
+ const notes = await readNotesArg(args.notes)
322
+ const r = await client.request('POST', `/orgs/${config.org}/scenarios/${enc(scenario.id)}/versions`, { branch, ...(typeof args.name === 'string' ? { name: args.name } : {}), ...(notes ? { notes } : {}) })
304
323
  say(`Version v${r.version}${r.name ? ` "${r.name}"` : ''} · ${r.changed} changed, ${r.unchanged} unchanged${r.changed_steps.length ? ` (${r.changed_steps.join(', ')})` : ''}\n${r.url}`)
324
+ if (r.notes) { const { notesText } = await import('./design-notes.mjs'); say(`\nWhat changed${notes ? ' (your notes over the generated ones)' : ''}:\n${notesText(r.notes)}`) }
305
325
  return r
306
326
  }
307
327