kaloko 0.5.1 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -210,6 +210,34 @@ link) and reads names from the accessibility tree. Pixel diffs skip the
210
210
  regions a step ignores; `kaloko walk --stability 3` and `kaloko stability`
211
211
  find the clocks, ads and carousels worth ignoring.
212
212
 
213
+ ## A changelog with pictures
214
+
215
+ Your accepted runs already hold every screen of the release. `CHANGELOG.md`
216
+ can show them: a picture names a step instead of a file, and Kaloko shows the
217
+ accepted capture of it.
218
+
219
+ ```markdown
220
+ - You can pay with Apple Pay in the checkout.
221
+ ![before](kaloko:checkout/payment?version=prev) ![after](kaloko:checkout/payment)
222
+ ```
223
+
224
+ ```bash
225
+ npx kaloko changelog draft # the Unreleased entry: merged PRs and changed screens, before and after
226
+ npx kaloko changelog draft --apply # the same, written into CHANGELOG.md (Keep a Changelog)
227
+ npx kaloko release 2.4.0 # record the release and its pictures; prints the changelog page
228
+ ```
229
+
230
+ The draft groups pull requests and commits (Conventional Commits when you
231
+ use them) into Added, Changed, Fixed and Removed; your agent rewrites the
232
+ lines for people. The version comes from what the repository already uses
233
+ (git tags, `package.json`, release-please, changesets, semantic-release);
234
+ when a release tool owns `CHANGELOG.md`, Kaloko only reads it. The page on
235
+ kaloko.app has a slider between before and after, switches language and
236
+ screen size, and links each picture to its flow on the canvas. Share it with
237
+ guests, readers from your company's domain, or publish it with a feed.
238
+ Repositories with several apps list them under `products:` in
239
+ `kaloko.config.yml`.
240
+
213
241
  ## Design (Kaloko Design)
214
242
 
215
243
  Design the screens of a flow as live HTML with your own agent and skills, in the same scenario that development
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaloko",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "See what your AI agents built: every screen, language and device walked, assessed and approved on one canvas.",
5
5
  "keywords": [
6
6
  "qa",
@@ -9,6 +9,13 @@
9
9
  "schema": { "const": "qawalk.config.v1" },
10
10
  "org": { "type": "string", "description": "Organization slug on kaloko.app." },
11
11
  "project": { "type": "string" },
12
+ "products": {
13
+ "type": "object",
14
+ "description": "Optional: what is released and versioned as a whole and has its own visual changelog (docs/DOCS-CHANGELOG.md). Without it the project is the product. Keys are product slugs.",
15
+ "propertyNames": { "pattern": "^[a-z0-9][a-z0-9._-]{0,79}$" },
16
+ "additionalProperties": { "$ref": "#/$defs/product" }
17
+ },
18
+ "docs": { "$ref": "#/$defs/docs" },
12
19
  "scenarios": { "type": "array", "items": { "type": "string" }, "description": "Glob patterns of scenario files." },
13
20
  "output": { "type": "string", "description": "Run folder; default tmp/kaloko (tmp/qawalk when only that one exists)" },
14
21
  "browser": {
@@ -106,6 +113,45 @@
106
113
  }
107
114
  },
108
115
  "$defs": {
116
+ "product": {
117
+ "type": "object",
118
+ "additionalProperties": false,
119
+ "properties": {
120
+ "name": { "oneOf": [{ "type": "string" }, { "type": "object", "additionalProperties": { "type": "string" }, "description": "Name per language, e.g. { en: Web shop, cs: E-shop }." }] },
121
+ "version": {
122
+ "type": "object",
123
+ "additionalProperties": false,
124
+ "description": "Where the product's version comes from. Default: detected (release-please, changesets, semantic-release, package.json, pyproject.toml, Cargo.toml, else git tags v*).",
125
+ "properties": {
126
+ "source": { "enum": ["auto", "git-tag", "package.json", "release-please", "changesets", "semantic-release", "pyproject", "cargo", "gemspec", "manual"] },
127
+ "path": { "type": "string", "description": "File that holds the version (package.json of the app, the release-please manifest)." },
128
+ "pattern": { "type": "string", "description": "Git tag pattern with one *, e.g. shop-v* (default v*)." }
129
+ }
130
+ },
131
+ "docs": { "$ref": "#/$defs/docs" },
132
+ "project": { "type": "string", "description": "The Kaloko project people review this product in." },
133
+ "projects": { "type": "array", "items": { "type": "string" }, "description": "Several projects that work on this product." },
134
+ "modules": { "type": "array", "items": { "type": "string" } },
135
+ "reference_env": { "type": "string", "description": "Environment whose accepted runs the pictures come from (default: any)." },
136
+ "default_scenario": { "type": "string", "description": "Scenario of short step references (kaloko:<step>)." },
137
+ "publish": { "enum": ["members", "domain", "public"], "description": "Who reads the changelog on Kaloko; domain and public need an admin on the service." }
138
+ }
139
+ },
140
+ "docs": {
141
+ "type": "object",
142
+ "additionalProperties": false,
143
+ "description": "Visual changelog and docs settings (all optional).",
144
+ "properties": {
145
+ "dir": { "type": "string", "description": "Docs sources (default docs)." },
146
+ "changelog": { "type": "string", "description": "Changelog file (default CHANGELOG.md)." },
147
+ "style": { "enum": ["keep-a-changelog", "release-notes"] },
148
+ "version_source": { "enum": ["auto", "git-tag", "package.json", "release-please", "changesets", "semantic-release", "pyproject", "cargo", "gemspec", "manual"] },
149
+ "languages": { "type": "array", "items": { "type": "string" } },
150
+ "framework": { "enum": ["auto", "docusaurus", "vitepress", "mkdocs", "nextra", "plain"] },
151
+ "publish": { "enum": ["members", "domain", "public"], "description": "Who reads the changelog on Kaloko; domain and public need an admin on the service." },
152
+ "images": { "type": "object", "additionalProperties": false, "properties": { "viewport": { "type": "string" }, "locale": { "type": "string", "description": "reader (the reader's language) or a locale." } } }
153
+ }
154
+ },
109
155
  "record": {
110
156
  "description": "Recordings of walked steps (opt-in): true or step records each step from the previous screen to its capture; flow records the whole walk per locale with a chapter per step; failed keeps only steps that fail; false or off turns it off. Uploaded on Business and Enterprise.",
111
157
  "oneOf": [
@@ -58,6 +58,14 @@
58
58
  },
59
59
  "archived": {
60
60
  "type": "boolean"
61
+ },
62
+ "product": {
63
+ "type": ["string", "null"],
64
+ "description": "Product the flow documents (scenario product tag, or the only product of the config)."
65
+ },
66
+ "module": {
67
+ "type": ["string", "null"],
68
+ "description": "Module of the product (scenario module tag)."
61
69
  }
62
70
  }
63
71
  },
@@ -166,6 +166,14 @@
166
166
  "$ref": "#/$defs/slug",
167
167
  "description": "Optional project this flow belongs to; overrides `project` from kaloko.config.yml and the repository name for its runs."
168
168
  },
169
+ "product": {
170
+ "$ref": "#/$defs/slug",
171
+ "description": "Product this flow documents (a key of `products:` in kaloko.config.yml). The visual changelog and docs of that product use its steps; with one product in the config, untagged flows belong to it."
172
+ },
173
+ "module": {
174
+ "$ref": "#/$defs/slug",
175
+ "description": "Module of the product (one of its `modules:`), for changelog and docs sections per module."
176
+ },
169
177
  "capture": {
170
178
  "$ref": "#/$defs/capture"
171
179
  }
@@ -16,7 +16,9 @@ description: >
16
16
  reference, checking implementation against it), and when implementing a
17
17
  screen that has an approved design (read the step spec, build with its
18
18
  tokens, check drift), and when a step should be recorded on video or its
19
- trace read (actions, requests, console).
19
+ trace read (actions, requests, console), and when a user-visible change
20
+ should land in the visual changelog (CHANGELOG.md with step references,
21
+ kaloko changelog draft, kaloko release) or a guide should show a step.
20
22
  ---
21
23
 
22
24
  # Kaloko
@@ -37,6 +39,11 @@ the canvas, storage and collaboration are the service.
37
39
  for them (`kaloko share` prints it; `kaloko review` repeats it), and you
38
40
  read `kaloko feedback` and `kaloko inbox` before continuing on that task.
39
41
  - Changes to intent are written back to `ACCEPTANCE.md` (change log row).
42
+ - When the project keeps a visual changelog (`CHANGELOG.md`, `docs:` or
43
+ `products:` in the config) and the change is user-visible: the Unreleased
44
+ entry says it for people, with step references for the screens it changed
45
+ (`kaloko changelog draft`), and new scenarios carry `product:` (and
46
+ `module:`) when the config has products.
40
47
  - When you implemented a step from an approved design: you read its spec
41
48
  (`kaloko spec <step>`), walked the implementation with `packs: [design]`,
42
49
  and `kaloko drift` is clean or the PR names each difference left for a
@@ -61,6 +68,8 @@ the design folder, walk, and sync the draft (including taking others' steps
61
68
  with `--theirs`). Move to the trash runs you uploaded in this session that
62
69
  were superseded before anyone looked at them (debug rounds: no comments, no
63
70
  verdict), and run `kaloko prune` without `--yes` to see what a clean-up would do.
71
+ Draft and edit the Unreleased section of the changelog (`kaloko changelog draft
72
+ --apply`) and add step references to it and to guides.
64
73
 
65
74
  ## Ask first
66
75
 
@@ -70,7 +79,8 @@ replacing a CI workflow (`kaloko ci github|gitlab`), uploading with
70
79
  design reference or baseline, merging a design branch, keeping your version of
71
80
  a step in a sync conflict (`--mine`), turning on `html_view`, turning on recordings for
72
81
  a whole project (`capture.record` in the config: storage), a real clean-up
73
- (`kaloko prune --yes`), trashing someone else's run, archiving a scenario, and
82
+ (`kaloko prune --yes`), trashing someone else's run, archiving a scenario,
83
+ recording a release (`kaloko release`) or asking to publish a changelog, and
74
84
  anything the environment profile blocks. Never trash an accepted run, a
75
85
  baseline, a run with review comments, or a run linked from an open PR or issue
76
86
  comment. Never approve a
@@ -120,6 +130,10 @@ they can do; never write secrets into the config, a scenario or a script.
120
130
  [reference/collaboration.md](reference/collaboration.md)
121
131
  - Reading runs, feedback and the inbox over MCP instead of the CLI:
122
132
  [reference/mcp.md](reference/mcp.md)
133
+ - The visual changelog and guides with step references (`kaloko:<scenario>/<step>`):
134
+ drafting the Unreleased entry, templates for entries, client release notes and
135
+ how-tos, products, modules and versions, who reads it:
136
+ [reference/docs.md](reference/docs.md)
123
137
  - Refreshing a manual, course or product documentation from the newest
124
138
  captures (latest URLs, changed steps, webhooks, export):
125
139
  [reference/documentation.md](reference/documentation.md)
@@ -0,0 +1,127 @@
1
+ # Visual changelog and docs with step references
2
+
3
+ The project keeps `CHANGELOG.md` (and guides in `docs/`) in the repository. You write and update them as part of
4
+ normal work; Kaloko turns step references into pictures from accepted runs and shows the changelog as a page that
5
+ clients, support and marketing can read.
6
+
7
+ ## Step references
8
+
9
+ A picture in Markdown names a step instead of a PNG file:
10
+
11
+ ```markdown
12
+ ![Payment](kaloko:checkout/payment) newest accepted capture
13
+ ![Payment](kaloko:checkout/payment?version=2.4.0) the capture of release 2.4.0
14
+ ![Payment](kaloko:checkout/payment?version=prev) the release before the one this entry belongs to
15
+ ![Payment](kaloko:checkout/payment?locale=cs&viewport=mobile&state=dark)
16
+ ![Payment](kaloko:payment) a step of the product's default_scenario
17
+ ```
18
+
19
+ `<scenario>` is the scenario id, `<step>` the node id. Without `locale` and `viewport` the reader's language and
20
+ the product's default screen are used. `state` picks a capture variant (`dark`, `webkit`, `reduce`: the values in
21
+ capture keys like `mobile.webkit.dark`). Check a reference with MCP `resolve_step_image` or
22
+ `GET /api/v1/orgs/<org>/steps/resolve?ref=…`: it answers the signed image URL, the run and how the version was found
23
+ (`release`, `app_version`, `reference_env`, `accepted`; `exact: false` means a fallback).
24
+
25
+ Never invent a step id or a version. Use ids from the scenario files and versions from `CHANGELOG.md` or git tags.
26
+
27
+ ## After a user-visible change
28
+
29
+ 1. Walk the affected steps and share the run as usual; a person accepts it on the canvas.
30
+ 2. `kaloko changelog draft` prints the Unreleased entry: merged pull requests and commits since the last release,
31
+ grouped Added / Changed / Fixed / Removed, plus before/after pictures of the screens that accepted runs show
32
+ differently (it needs a token for the pictures). `--apply` writes it into `CHANGELOG.md`.
33
+ 3. Edit the draft for people: what they can do now or what looks different, one change per line, no commit jargon.
34
+ Delete internal items (refactors, tests, CI). Keep the step references; move a picture under the line it shows.
35
+ Remove the `<!-- … -->` hints.
36
+ 4. Commit `CHANGELOG.md` with the change. At release time `kaloko release <version>` (ask first: it records the
37
+ release on Kaloko) turns Unreleased into the version, records its pictures and prints the changelog page.
38
+
39
+ `--since v2.3.0` drafts from another tag; `--product shop` picks a product when the config has several.
40
+
41
+ ## Templates
42
+
43
+ A changelog entry (Keep a Changelog):
44
+
45
+ ```markdown
46
+ ## [2.4.0] - 2026-10-12
47
+
48
+ ### Added
49
+
50
+ - You can pay with Apple Pay in the checkout. ([#412](https://github.com/acme/shop/pull/412))
51
+ ![Payment before](kaloko:checkout/payment?version=prev) ![Payment after](kaloko:checkout/payment)
52
+
53
+ ### Fixed
54
+
55
+ - The cart total no longer jumps while prices load on phones.
56
+ ![Cart](kaloko:checkout/cart?viewport=mobile)
57
+ ```
58
+
59
+ Release notes for a client (a sibling file, or the text of a release on your Git host):
60
+
61
+ ```markdown
62
+ # What's new in the web shop, October
63
+
64
+ Customers can pay with Apple Pay. The button sits above the card form on phones and computers.
65
+
66
+ ![Payment](kaloko:checkout/payment?locale=cs)
67
+
68
+ Also fixed: the cart total no longer jumps on phones.
69
+ ```
70
+
71
+ A how-to guide (`docs/how-to/pay-with-apple-pay.md`):
72
+
73
+ ```markdown
74
+ # Pay with Apple Pay
75
+
76
+ 1. Open the cart and choose **Checkout**.
77
+ ![Cart](kaloko:checkout/cart)
78
+ 2. Choose **Apple Pay** above the card form and confirm on your phone or Mac.
79
+ ![Payment](kaloko:checkout/payment)
80
+ ```
81
+
82
+ Rules: write for the person using the product; one change per line; before/after pictures for changes people see;
83
+ no internal names, prices or numbers you cannot show.
84
+
85
+ ## Products, modules and versions
86
+
87
+ Without `products:` in `kaloko.config.yml` the project is the product and `CHANGELOG.md` in the root is its
88
+ changelog. Pick the pattern that fits; tag new scenarios accordingly without asking each time:
89
+
90
+ 1. **One app, one team**: no products. Optional `docs: { changelog: CHANGELOG.md }`.
91
+ 2. **Monorepo with several apps**: one product per app, each with its version file and changelog; every scenario
92
+ says `product: <slug>`.
93
+ ```yaml
94
+ products:
95
+ shop:
96
+ name: { en: Web shop, cs: E-shop }
97
+ version: { source: package.json, path: apps/shop/package.json }
98
+ docs: { changelog: apps/shop/CHANGELOG.md }
99
+ project: eshop
100
+ reference_env: staging
101
+ admin:
102
+ version: { source: git-tag, pattern: "admin-v*" }
103
+ ```
104
+ 3. **Agency**: a project per client, a product per client deliverable; client guests of the project read only that
105
+ product's changelog.
106
+ 4. **Large app with modules**: one product with `modules: [checkout, account, …]`; scenarios carry
107
+ `product:` and `module:`; one combined release changelog with a section per module if the team wants it.
108
+
109
+ The version comes from what the repository already uses: release-please, changesets, semantic-release,
110
+ `package.json` (or pyproject/Cargo/gemspec), else git tags `v*`. When a release tool writes `CHANGELOG.md`, Kaloko
111
+ reads its entry and never rewrites the file; put your draft into the pull request or release notes instead.
112
+ `reference_env` says which environment the pictures come from (default: any accepted run).
113
+
114
+ `kaloko doctor` warns when a product's version cannot be read or a scenario names no product while several exist.
115
+
116
+ ## Readers
117
+
118
+ The changelog page (`/<org>/products/<product>/changelog`, or `/<org>/projects/<project>/changelog`) is read by
119
+ everyone who sees one of the product's projects, guests included. An admin of those projects may open it to
120
+ readers from the organization's verified domains or publish it (`/p/<org>/<product>/changelog` with an Atom feed;
121
+ not indexed until the organization allows it). `publish:` in the config only asks for it; an admin confirms on the
122
+ page. Never publish without the person asking.
123
+
124
+ ## MCP
125
+
126
+ `get_changelog` (releases with entries; `resolve: true` adds the pictures), `draft_changelog_entry` (the service's
127
+ half of a draft: changed screens with before/after references), `resolve_step_image` (one reference).
@@ -33,6 +33,9 @@ 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
+ | `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
+ | `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
+ | `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 |
36
39
  | `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) |
37
40
  | `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) |
38
41
  | `add_sample_run` | adds the sample run (or returns the one there is); needs an upload or full token and the Developer / Tester role |
package/src/cli.mjs CHANGED
@@ -69,10 +69,11 @@ Project
69
69
  draft <url> [url…] [--id SITE-X] [--title t] [--out qa/flows/site.yml] [--force]
70
70
  First plan + read-only production scenario for public pages, drafted from the
71
71
  pages (title, description, H1) with the seo, a11y, perf and console packs
72
- init [--agent claude|codex|all] [--org <slug>] [--no-detect] [--no-scenario]
72
+ init [--agent claude|codex|all] [--org <slug>] [--no-detect] [--no-scenario] [--docs=off|changelog|full]
73
73
  Create ${CONFIG_FILE} and install the kaloko skill for your agent; detects the
74
74
  framework, dev server URL, locales and main routes, and drafts a first scenario
75
- (qa/flows/main-pages.yml) when the project has none
75
+ (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)
76
77
  ci github|gitlab [--scenario a.yml,b.yml] [--env preview] [--trigger deployment|pull_request] [--no-affected]
77
78
  [--out file] [--print] [--force]
78
79
  Write a workflow that walks what each pull request affects on its preview
@@ -231,6 +232,18 @@ Results
231
232
  Text diff of two runs: step statuses, criterion verdicts, scores and evidence
232
233
  (default: current run against the scenario baseline, else the previous run)
233
234
 
235
+ Changelog (Keep a Changelog in CHANGELOG.md; pictures are step references kaloko:<scenario>/<step>)
236
+ changelog draft [--product p] [--since <tag|version>] [--apply] [--json] [--offline]
237
+ The Unreleased entry since the last release: merged pull requests and commits
238
+ (Conventional Commits when used, gh for titles) grouped Added / Changed / Fixed /
239
+ Removed, plus before/after pictures of the screens accepted runs show differently;
240
+ 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]
242
+ Record a release on Kaloko: the version (default: from git tags, package.json,
243
+ release-please or changesets), its CHANGELOG.md entry (Unreleased becomes the
244
+ version unless --no-write) and the accepted runs that are its pictures; prints the
245
+ illustrated changelog page
246
+
234
247
  Design (environments with kind: design; live HTML steps in a folder)
235
248
  start --scenario <file> --env <design env> A run of the design folder (same scenario as development and QA)
236
249
  walk [--steps a,b] [--no-cache] Render the steps; unchanged revisions reuse their captures
@@ -773,6 +786,38 @@ const commands = {
773
786
  await writeFile(file, tpl)
774
787
  say(`Created ${CONFIG_FILE}${detected ? ` (local: ${detected.url})` : ''}. Edit org and the staging and production environments.`)
775
788
  } else if (!existsSync(join(cwd, LEGACY_CONFIG_FILE))) say(`${CONFIG_FILE} already exists; kept.`)
789
+ // the visual changelog (docs/DOCS-CHANGELOG.md): asked once; --docs=off|changelog|full for scripts and agents
790
+ let docsMode = typeof args.docs === 'string' ? args.docs : null
791
+ if (docsMode && !['off', 'changelog', 'full'].includes(docsMode)) throw new UserError('--docs must be off, changelog or full.')
792
+ if (!docsMode && process.stdin.isTTY && process.stdout.isTTY && !process.env.CI && !process.env.NODE_TEST_CONTEXT) {
793
+ const rl = (await import('node:readline/promises')).createInterface({ input: process.stdin, output: process.stdout })
794
+ const a = (await rl.question('Keep a visual changelog? (recommended) [Y/n] ')).trim().toLowerCase(); rl.close()
795
+ docsMode = a === 'n' || a === 'no' ? 'off' : 'changelog'
796
+ }
797
+ if (docsMode && docsMode !== 'off') {
798
+ const { docsConfigBlock, detectProducts, DOCS_SKELETON } = await import('./lib/changelog.mjs')
799
+ const { CHANGELOG_SKELETON } = await import('./lib/changelog-core.mjs')
800
+ const { mkdir: mkdirp } = await import('node:fs/promises')
801
+ const cfgFile = existsSync(file) ? file : join(cwd, LEGACY_CONFIG_FILE)
802
+ const cfg = await readFile(cfgFile, 'utf8')
803
+ 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(', ')})` : ''}`) }
805
+ for (const f of products.length ? products.map((p) => join(cwd, p.dir, 'CHANGELOG.md')) : [join(cwd, 'CHANGELOG.md')]) {
806
+ if (existsSync(f)) continue
807
+ await mkdirp(dirname(f), { recursive: true })
808
+ await writeFile(f, CHANGELOG_SKELETON)
809
+ say(`Created ${relative(cwd, f)} (Keep a Changelog; kaloko changelog draft fills Unreleased, kaloko release records a version)`)
810
+ }
811
+ if (docsMode === 'full') {
812
+ for (const [rel, text] of Object.entries(DOCS_SKELETON)) {
813
+ const f = join(cwd, rel)
814
+ if (existsSync(f)) continue
815
+ await mkdirp(dirname(f), { recursive: true })
816
+ await writeFile(f, text)
817
+ }
818
+ say('Docs folders in docs/ (tutorials, how-to, reference, explanation) with an index')
819
+ }
820
+ } else if (!docsMode) say('Visual changelog: kaloko init --docs=changelog adds CHANGELOG.md and the config block (recommended).')
776
821
  const agent = typeof args.agent === 'string' ? args.agent : 'none'
777
822
  if (!['claude', 'codex', 'all', 'none'].includes(agent)) throw new UserError('--agent must be claude, codex, all or none.')
778
823
  const { cp, mkdir, appendFile, rm } = await import('node:fs/promises')
@@ -877,7 +922,9 @@ const commands = {
877
922
  const executor = { kind: args.executor ? 'agent' : 'cli', name: args.executor || 'kaloko-cli' }
878
923
  const { project, issue } = await projectAndIssue({ cwd: root, config, scenario, branch: git?.branch })
879
924
  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 })
925
+ // the product the flow documents (visual changelog): its tag, or the only product of the config
926
+ const product = scenario.product || (Object.keys(config.products || {}).length === 1 ? Object.keys(config.products)[0] : null)
927
+ const { dir, meta } = await createRun({ output: config.output, scenario, scenarioFile, env, scope, git, executor, root, project, issue, appVersion: appVer, product })
881
928
  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
929
  const addr = recipient(env.mail, meta.short)
883
930
  if (addr) say(` test e-mail: ${addr}`)
@@ -2019,12 +2066,56 @@ const commands = {
2019
2066
  const { config, root, file } = await context(args)
2020
2067
  // engines the scenarios ask for (browsers: [webkit, firefox]), so doctor checks they are installed
2021
2068
  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 })
2069
+ const scenarios = []
2070
+ 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 {} }
2071
+ const r = await doctor({ config, root, file, fix: !!args.fix, engines, scenarios })
2024
2072
  say(args.json ? JSON.stringify(r, null, 2) : formatDoctor(r))
2025
2073
  if (!r.ok) process.exitCode = 1
2026
2074
  },
2027
2075
 
2076
+ /** `kaloko changelog draft`: the Unreleased entry from merged work and the screens accepted runs show differently. */
2077
+ async changelog (args) {
2078
+ const sub = args._[0]
2079
+ if (sub !== 'draft') throw new UserError('Usage: kaloko changelog draft [--product p] [--since <tag|version>] [--apply] [--json] [--offline]')
2080
+ const { config, root } = await context(args)
2081
+ const { pickProduct, draftChangelog, draftText, applyToChangelog } = await import('./lib/changelog.mjs')
2082
+ const product = pickProduct(config, root, args.product)
2083
+ let client = null
2084
+ if (!args.offline) { try { client = service(config) } catch {} }
2085
+ const d = await draftChangelog({ root, config, product, since: typeof args.since === 'string' ? args.since : null, client })
2086
+ if (args.json) { say(JSON.stringify(d, null, 2)); return }
2087
+ const pictures = d.changes.reduce((n, s) => n + (s.steps?.length || 0), 0)
2088
+ 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.`
2089
+ if (args.apply) {
2090
+ const r = await applyToChangelog(d)
2091
+ 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>.`)
2092
+ return
2093
+ }
2094
+ console.error(head)
2095
+ say(draftText(d))
2096
+ },
2097
+
2098
+ /** `kaloko release <version>`: the version's changelog entry and its reference runs on the service. */
2099
+ async release (args) {
2100
+ const { config, root } = await context(args)
2101
+ if (!config.org) throw new UserError('Set org: in the config first.')
2102
+ const { pickProduct, releaseProduct } = await import('./lib/changelog.mjs')
2103
+ const product = pickProduct(config, root, args.product)
2104
+ const client = args['dry-run'] ? null : service(config)
2105
+ 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'] })
2106
+ if (args.json) { say(JSON.stringify(r, null, 2)); return }
2107
+ const file = relative(process.cwd(), r.file) || r.file
2108
+ const lines = [`${product.slug} ${r.version} (${r.date}) · version source: ${r.source.tool || r.source.source}${r.source.path ? ` (${r.source.path})` : ''}`]
2109
+ 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}`)
2110
+ if (r.release) {
2111
+ 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
+ lines.push(` ${r.release.url}`)
2113
+ } else lines.push(' Dry run: nothing sent.')
2114
+ for (const n of r.notes) lines.push(` ! ${n}`)
2115
+ if (r.finalized) lines.push(` Commit ${file} with the release.`)
2116
+ say(lines.join('\n'))
2117
+ },
2118
+
2028
2119
  async help () { say(HELP) }
2029
2120
  }
2030
2121
 
@@ -0,0 +1,323 @@
1
+ // The visual changelog (docs/DOCS-CHANGELOG.md, phase 1), the parts the CLI and the service share: step references
2
+ // in Markdown, Keep a Changelog parsing and editing, commit classification and the draft skeleton. No I/O here: the
3
+ // worker imports this file too (worker/src/changelog.js), so it stays plain JavaScript without Node built-ins.
4
+
5
+ // ---------- step references ----------
6
+
7
+ /** Scenario and step ids as the service accepts them (design.js SCENARIO_ID). */
8
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/
9
+ const PARAMS = {
10
+ locale: /^[A-Za-z0-9_-]{1,20}$/,
11
+ viewport: /^[A-Za-z0-9._-]{1,80}$/,
12
+ version: /^[A-Za-z0-9._+-]{1,80}$/,
13
+ state: /^[A-Za-z0-9._-]{1,60}$/
14
+ }
15
+ export const REF_SCHEME = 'kaloko:'
16
+
17
+ /**
18
+ * A step reference `kaloko:<scenario>/<step>[?locale=&viewport=&version=&state=]`, or the short `kaloko:<step>` that
19
+ * means a step of the product's default scenario. `version` is a released version, `latest` (default) or `prev` (the
20
+ * release before the one the text belongs to); `state` picks a variant of the capture (dark, webkit, reduce: the
21
+ * dimension values in the capture key). Returns null for anything else.
22
+ */
23
+ export function parseStepRef (ref) {
24
+ const s = String(ref || '').trim()
25
+ if (!s.startsWith(REF_SCHEME)) return null
26
+ const rest = s.slice(REF_SCHEME.length)
27
+ const qi = rest.indexOf('?')
28
+ const path = qi < 0 ? rest : rest.slice(0, qi)
29
+ let parts
30
+ try { parts = path.split('/').map((p) => decodeURIComponent(p)) } catch { return null }
31
+ if (!parts.length || parts.length > 2 || parts.some((p) => !ID.test(p))) return null
32
+ const out = { scenario: parts.length === 2 ? parts[0] : null, step: parts[parts.length - 1], locale: null, viewport: null, version: null, state: null }
33
+ const q = new URLSearchParams(qi < 0 ? '' : rest.slice(qi + 1))
34
+ for (const [k, v] of q) {
35
+ if (!(k in PARAMS)) return null
36
+ if (!PARAMS[k].test(v)) return null
37
+ out[k] = v
38
+ }
39
+ if (out.version === 'latest') out.version = null
40
+ return out
41
+ }
42
+
43
+ /** The reference text of a parsed (or hand-built) reference; parameters in a fixed order. */
44
+ export function formatStepRef ({ scenario = null, step, locale = null, viewport = null, version = null, state = null }) {
45
+ const path = `${scenario ? `${encodeURIComponent(scenario)}/` : ''}${encodeURIComponent(step)}`
46
+ const q = Object.entries({ locale, viewport, version, state }).filter(([, v]) => v != null && v !== '')
47
+ return `${REF_SCHEME}${path}${q.length ? `?${q.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')}` : ''}`
48
+ }
49
+
50
+ /** Every image with a step reference in a Markdown text: { alt, ref, parsed, index }; broken ones have parsed null. */
51
+ export function findStepRefs (markdown) {
52
+ const out = []
53
+ const re = /!\[([^\]]*)\]\((kaloko:[^)\s]+)\)/g
54
+ let m
55
+ while ((m = re.exec(String(markdown || '')))) out.push({ alt: m[1], ref: m[2], parsed: parseStepRef(m[2]), index: m.index })
56
+ return out
57
+ }
58
+
59
+ // ---------- versions ----------
60
+
61
+ /** "v2.4.0" → "2.4.0"; anything else stays as it is (trimmed). */
62
+ export const cleanVersion = (v) => String(v ?? '').trim().replace(/^v(?=\d)/, '')
63
+
64
+ /** Semantic Versioning order (prereleases before their release); versions that are not semver sort by text. */
65
+ export function compareVersions (a, b) {
66
+ const p = (v) => { const m = cleanVersion(v).match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/); return m ? { n: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] || null } : null }
67
+ const x = p(a); const y = p(b)
68
+ if (!x || !y) return String(a).localeCompare(String(b))
69
+ for (let i = 0; i < 3; i++) if (x.n[i] !== y.n[i]) return x.n[i] - y.n[i]
70
+ if (x.pre === y.pre) return 0
71
+ if (!x.pre) return 1
72
+ if (!y.pre) return -1
73
+ return x.pre.localeCompare(y.pre, 'en', { numeric: true })
74
+ }
75
+
76
+ /** The git tag of a version for a tag pattern ("v*" → "v2.4.0", "shop-v*" → "shop-v2.4.0"). */
77
+ export const tagFor = (pattern, version) => String(pattern || 'v*').replace('*', cleanVersion(version))
78
+ /** The version a tag names under a pattern, or null when the tag does not match it. */
79
+ export function versionOfTag (pattern, tag) {
80
+ const [pre, post = ''] = String(pattern || 'v*').split('*')
81
+ const t = String(tag || '')
82
+ if (!t.startsWith(pre) || !t.endsWith(post) || t.length <= pre.length + post.length) return null
83
+ return cleanVersion(t.slice(pre.length, t.length - post.length))
84
+ }
85
+
86
+ // ---------- Keep a Changelog ----------
87
+
88
+ export const GROUPS = ['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security']
89
+ const GROUP_ALIASES = { added: 'Added', new: 'Added', features: 'Added', 'minor changes': 'Added', changed: 'Changed', 'patch changes': 'Fixed', 'major changes': 'Changed', improvements: 'Changed', deprecated: 'Deprecated', removed: 'Removed', fixed: 'Fixed', 'bug fixes': 'Fixed', fixes: 'Fixed', security: 'Security', 'performance improvements': 'Changed' }
90
+ /** A group heading as written by people or release tools ("Bug Fixes", "Minor Changes") in Keep a Changelog terms. */
91
+ export const groupName = (h) => GROUP_ALIASES[String(h || '').trim().toLowerCase()] || String(h || '').trim()
92
+
93
+ export const CHANGELOG_HEAD = `# Changelog
94
+
95
+ All notable changes for the people who use this product. The format follows
96
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and versions follow
97
+ [Semantic Versioning](https://semver.org/). Pictures are step references
98
+ (\`![](kaloko:<scenario>/<step>)\`) that Kaloko shows from the accepted runs.
99
+ `
100
+ export const CHANGELOG_SKELETON = `${CHANGELOG_HEAD}
101
+ ## [Unreleased]
102
+ `
103
+
104
+ const RELEASE_HEADING = /^##\s+(.+?)\s*$/
105
+ const DATE = /(\d{4}-\d{2}-\d{2})/
106
+
107
+ /** "## [2.4.0] - 2026-10-12", "## [1.2.0](https://…compare…) (2026-10-12)", "## 1.2.0", "## [Unreleased]". */
108
+ export function parseHeading (line) {
109
+ const m = String(line).match(RELEASE_HEADING)
110
+ if (!m || /^#/.test(m[1])) return null
111
+ const text = m[1]
112
+ const bracket = text.match(/^\[([^\]]+)\]/)
113
+ const label = (bracket ? bracket[1] : text.split(/\s+/)[0]).trim()
114
+ if (/^unreleased$/i.test(label)) return { unreleased: true, version: null, date: null }
115
+ const version = cleanVersion(label)
116
+ if (!/^\d/.test(version)) return null
117
+ const rest = text.slice(bracket ? bracket[0].length : label.length)
118
+ return { unreleased: false, version, date: (rest.match(DATE) || [])[1] || null }
119
+ }
120
+
121
+ /** List items of a group body: each item keeps its continuation lines (pictures under it), without the "- ". */
122
+ export function listItems (body) {
123
+ const items = []
124
+ let cur = null
125
+ for (const line of String(body || '').split('\n')) {
126
+ const m = line.match(/^[-*+]\s+(.*)$/)
127
+ if (m) { if (cur) items.push(cur.trimEnd()); cur = m[1]; continue }
128
+ if (cur != null && (/^\s+\S/.test(line) || line.trim() === '')) { cur += `\n${line.replace(/^ {1,4}/, '')}`; continue }
129
+ if (cur != null) { items.push(cur.trimEnd()); cur = null }
130
+ }
131
+ if (cur) items.push(cur.trimEnd())
132
+ return items.map((x) => x.replace(/\n+$/, '')).filter(Boolean)
133
+ }
134
+
135
+ /** Groups of a release body: [{ name, heading, items }] plus text before the first group (notes). */
136
+ export function parseEntry (body) {
137
+ const groups = []; const notes = []
138
+ let cur = null
139
+ for (const line of String(body || '').replace(/\r/g, '').split('\n')) {
140
+ const h = line.match(/^###\s+(.+?)\s*$/)
141
+ if (h) { cur = { name: groupName(h[1]), heading: h[1].trim(), lines: [] }; groups.push(cur); continue }
142
+ if (cur) cur.lines.push(line); else notes.push(line)
143
+ }
144
+ return { notes: notes.join('\n').trim(), groups: groups.map((g) => ({ name: g.name, heading: g.heading, items: listItems(g.lines.join('\n')) })).filter((g) => g.items.length || g.name) }
145
+ }
146
+
147
+ /**
148
+ * A changelog file: { head (text before the first release heading), sections: [{ unreleased, version, date, heading,
149
+ * body, start, end }] (line numbers, end exclusive), links (the reference definitions at the bottom, by lower-case
150
+ * label), linksStart }.
151
+ */
152
+ export function parseChangelog (text) {
153
+ const lines = String(text || '').replace(/\r/g, '').split('\n')
154
+ let linksStart = lines.length
155
+ while (linksStart > 0 && (/^\[[^\]]+\]:\s*\S+/.test(lines[linksStart - 1]) || lines[linksStart - 1].trim() === '')) linksStart--
156
+ if (!lines.slice(linksStart).some((l) => /^\[[^\]]+\]:/.test(l))) linksStart = lines.length
157
+ const links = {}
158
+ for (const l of lines.slice(linksStart)) { const m = l.match(/^\[([^\]]+)\]:\s*(\S+)/); if (m) links[m[1].toLowerCase()] = m[2] }
159
+ const sections = []
160
+ for (let i = 0; i < linksStart; i++) {
161
+ const h = /^##\s/.test(lines[i]) ? parseHeading(lines[i]) : null
162
+ if (!h) continue
163
+ if (sections.length) sections[sections.length - 1].end = i
164
+ sections.push({ ...h, heading: lines[i], start: i, end: linksStart })
165
+ }
166
+ for (const s of sections) s.body = lines.slice(s.start + 1, s.end).join('\n').trim()
167
+ const head = lines.slice(0, sections.length ? sections[0].start : linksStart).join('\n').trimEnd()
168
+ return { head, sections, links, linksStart, lines }
169
+ }
170
+
171
+ /** The section of a version (or the Unreleased one for null), or null. */
172
+ export function sectionOf (text, version) {
173
+ const { sections } = parseChangelog(text)
174
+ const v = version == null ? null : cleanVersion(version)
175
+ return sections.find((s) => v == null ? s.unreleased : !s.unreleased && s.version === v) || null
176
+ }
177
+
178
+ /** The newest released version written in the file (null when none). */
179
+ export function latestVersionIn (text) {
180
+ const v = parseChangelog(text).sections.filter((s) => !s.unreleased).map((s) => s.version)
181
+ return v.sort(compareVersions).pop() || null
182
+ }
183
+
184
+ const norm = (item) => String(item).split('\n')[0].replace(/<!--[\s\S]*?-->/g, '').replace(/\s+/g, ' ').trim().toLowerCase()
185
+
186
+ /** Markdown of groups ({ Added: [items] } in Keep a Changelog order; other names after them). */
187
+ export function groupsMarkdown (groups) {
188
+ const names = [...GROUPS.filter((g) => groups[g]?.length), ...Object.keys(groups).filter((g) => !GROUPS.includes(g) && groups[g]?.length)]
189
+ return names.map((g) => `### ${g}\n\n${groups[g].map((it) => `- ${String(it).split('\n').map((l, i) => i && l ? ` ${l}` : l).join('\n')}`).join('\n')}`).join('\n\n')
190
+ }
191
+
192
+ /**
193
+ * Puts draft items into the Unreleased section of a changelog: items whose first line is already there are left
194
+ * out, groups that exist get the new items at their end, missing groups are added in Keep a Changelog order. A file
195
+ * without an Unreleased section gets one above the newest release; an empty file gets the skeleton.
196
+ * Returns { text, added } (the number of new items).
197
+ */
198
+ export function applyDraft (text, groups) {
199
+ let src = String(text || '')
200
+ if (!src.trim()) src = CHANGELOG_SKELETON
201
+ let parsed = parseChangelog(src)
202
+ let un = parsed.sections.find((s) => s.unreleased)
203
+ if (!un) {
204
+ const lines = parsed.lines
205
+ const at = parsed.sections.length ? parsed.sections[0].start : parsed.linksStart
206
+ const before = lines.slice(0, at); while (before.length && before[before.length - 1].trim() === '') before.pop()
207
+ src = [...before, '', '## [Unreleased]', '', ...lines.slice(at)].join('\n')
208
+ parsed = parseChangelog(src)
209
+ un = parsed.sections.find((s) => s.unreleased)
210
+ }
211
+ const existing = parseEntry(un.body)
212
+ const seen = new Set(parsed.sections.flatMap((s) => parseEntry(s.body).groups.flatMap((g) => g.items.map(norm))))
213
+ const merged = new Map(existing.groups.map((g) => [g.name, { heading: g.heading, items: [...g.items] }]))
214
+ let added = 0
215
+ for (const [name, items] of Object.entries(groups)) {
216
+ for (const it of items || []) {
217
+ if (seen.has(norm(it))) continue
218
+ seen.add(norm(it))
219
+ if (!merged.has(name)) merged.set(name, { heading: name, items: [] })
220
+ merged.get(name).items.push(it); added++
221
+ }
222
+ }
223
+ const order = [...GROUPS.filter((g) => merged.has(g)), ...[...merged.keys()].filter((g) => !GROUPS.includes(g))]
224
+ const body = [existing.notes, ...order.map((g) => { const x = merged.get(g); return groupsMarkdown({ [g]: x.items }).replace(/^### [^\n]+/, `### ${x.heading}`) })].filter(Boolean).join('\n\n')
225
+ const lines = parsed.lines
226
+ const out = [...lines.slice(0, un.start + 1), '', ...(body ? [body, ''] : []), ...lines.slice(un.end)]
227
+ return { text: out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n*$/, '\n'), added }
228
+ }
229
+
230
+ /**
231
+ * Turns the Unreleased section into the release `version` of `date` and opens a new, empty Unreleased section above
232
+ * it. Compare links at the bottom ([unreleased]: …/compare/v1...HEAD) move along. Returns { text, changed, empty }.
233
+ */
234
+ export function finalizeRelease (text, version, date, { tagPattern = 'v*' } = {}) {
235
+ const v = cleanVersion(version)
236
+ const parsed = parseChangelog(text)
237
+ if (parsed.sections.some((s) => !s.unreleased && s.version === v)) return { text, changed: false, empty: false }
238
+ const un = parsed.sections.find((s) => s.unreleased)
239
+ if (!un) return { text, changed: false, empty: true }
240
+ const lines = [...parsed.lines]
241
+ lines.splice(un.start, 1, '## [Unreleased]', '', `## [${v}] - ${date}`)
242
+ let out = lines.join('\n')
243
+ const link = parsed.links.unreleased
244
+ const m = link && link.match(/^(.*\/compare\/)(.+)\.\.\.HEAD$/)
245
+ if (m) {
246
+ const tag = tagFor(tagPattern, v)
247
+ out = out.replace(/^\[unreleased\]:\s*\S+$/im, `[unreleased]: ${m[1]}${tag}...HEAD\n[${v}]: ${m[1]}${m[2]}...${tag}`)
248
+ }
249
+ return { text: out, changed: true, empty: !un.body.trim() }
250
+ }
251
+
252
+ // ---------- commits and the draft ----------
253
+
254
+ const CONVENTIONAL = /^(\w+)(?:\(([^)]*)\))?(!)?:\s*(.+)$/
255
+ const INTERNAL = new Set(['docs', 'test', 'tests', 'chore', 'ci', 'build', 'style', 'refactor', 'release', 'wip', 'deps'])
256
+ const TYPE_GROUP = { feat: 'Added', feature: 'Added', add: 'Added', fix: 'Fixed', bugfix: 'Fixed', hotfix: 'Fixed', perf: 'Changed', revert: 'Changed', remove: 'Removed', deprecate: 'Deprecated', security: 'Security', sec: 'Security' }
257
+
258
+ /**
259
+ * Where a commit or pull request title goes: { group, text, internal, breaking, pr }. Conventional Commits when the
260
+ * title uses them; otherwise the first verb decides (add → Added, fix → Fixed, remove → Removed, else Changed).
261
+ * Documentation, tests, chores, CI, refactors, merges and releases are internal.
262
+ */
263
+ export function classifyCommit (subject, body = '') {
264
+ let s = String(subject || '').trim()
265
+ let pr = null
266
+ const sq = s.match(/\s*\(#(\d+)\)\s*$/)
267
+ if (sq) { pr = Number(sq[1]); s = s.slice(0, sq.index).trim() }
268
+ const breaking = /BREAKING[ -]CHANGE/.test(body)
269
+ if (/^Merge\b|^Revert "|^v?\d+\.\d+\.\d+$|\[skip changelog\]|\[no changelog\]/i.test(s)) return { group: null, text: s, internal: true, breaking, pr }
270
+ const m = s.match(CONVENTIONAL)
271
+ if (m) {
272
+ const type = m[1].toLowerCase()
273
+ const text = sentence(m[4])
274
+ if (INTERNAL.has(type) && !m[3] && !breaking) return { group: null, text, internal: true, breaking: false, pr, type, scope: m[2] || null }
275
+ const group = TYPE_GROUP[type] || (/^(remove|drop|delete)\b/i.test(m[4]) ? 'Removed' : 'Changed')
276
+ return { group: m[3] || breaking ? (group === 'Added' ? 'Added' : 'Changed') : group, text, internal: false, breaking: !!m[3] || breaking, pr, type, scope: m[2] || null }
277
+ }
278
+ const verb = s.toLowerCase()
279
+ const group = /^(add|adds|added|new|introduce|support|allow|enable)\b/.test(verb) ? 'Added'
280
+ : /^(fix|fixes|fixed|correct|repair|resolve|prevent)\b/.test(verb) ? 'Fixed'
281
+ : /^(remove|removes|removed|drop|delete)\b/.test(verb) ? 'Removed'
282
+ : /^(deprecate)\b/.test(verb) ? 'Deprecated' : 'Changed'
283
+ return { group, text: sentence(s), internal: false, breaking, pr }
284
+ }
285
+ const sentence = (t) => { const x = String(t || '').trim().replace(/\.$/, ''); return x ? x[0].toUpperCase() + x.slice(1) : x }
286
+
287
+ /**
288
+ * The draft entry: items grouped Added / Changed / Fixed / Removed (Deprecated, Security when present).
289
+ *
290
+ * commits [{ subject, body, pr: { number, url } | null }] — merged pull requests or commits since the last release
291
+ * changes [{ scenario, title, steps: [{ step, title, reason: new|changed|removed, pr: number|null }] }] — what the
292
+ * accepted runs since the last release show differently (from the service)
293
+ * Pictures are step references: a changed step gets before and after (`?version=prev` is the previous release), a
294
+ * new step one picture. A step whose run came from a pull request goes under that pull request's item.
295
+ * Returns { groups, internal (titles left out), markdown }.
296
+ */
297
+ export function draftEntry ({ commits = [], changes = [], scenarioLabel = (s) => s.title || s.scenario } = {}) {
298
+ const groups = {}
299
+ const internal = []
300
+ const byPr = new Map()
301
+ const add = (g, item) => { (groups[g] ||= []).push(item); return groups[g].length - 1 }
302
+ for (const c of commits) {
303
+ const k = classifyCommit(c.subject, c.body)
304
+ if (k.internal) { internal.push(c.subject); continue }
305
+ const number = c.pr?.number || k.pr
306
+ const url = c.pr?.url || null
307
+ const text = `${k.text}${k.breaking ? ' (breaking: say what people have to do)' : ''}${number ? ` (${url ? `[#${number}](${url})` : `#${number}`})` : ''}`
308
+ const i = add(k.group, text)
309
+ if (number && !byPr.has(number)) byPr.set(number, { group: k.group, i })
310
+ }
311
+ for (const sc of changes) {
312
+ for (const st of sc.steps || []) {
313
+ const ref = (version) => formatStepRef({ scenario: sc.scenario, step: st.step, version })
314
+ const title = st.title || st.step
315
+ const pics = st.reason === 'new' ? `![${title}](${ref(null)})` : st.reason === 'removed' ? `![${title} before](${ref('prev')})` : `![${title} before](${ref('prev')}) ![${title} after](${ref(null)})`
316
+ const home = st.pr && byPr.get(st.pr)
317
+ if (home) { groups[home.group][home.i] += `\n${pics}`; continue }
318
+ const g = st.reason === 'new' ? 'Added' : st.reason === 'removed' ? 'Removed' : 'Changed'
319
+ add(g, `**${title}** in ${scenarioLabel(sc)} <!-- say what a person notices here -->\n${pics}`)
320
+ }
321
+ }
322
+ return { groups, internal, markdown: groupsMarkdown(groups) }
323
+ }
@@ -0,0 +1,333 @@
1
+ // `kaloko changelog draft` and `kaloko release` (docs/DOCS-CHANGELOG.md, phase 1): products from kaloko.config.yml,
2
+ // the version source the repository already uses (never fighting a release tool), merged pull requests and commits
3
+ // since the last release, the steps the accepted runs show differently (from the service), the draft entry and the
4
+ // release record. Parsing and the draft itself are in changelog-core.mjs, shared with the service.
5
+ import { readFile, writeFile, access, readdir } from 'node:fs/promises'
6
+ import { execFile } from 'node:child_process'
7
+ import { promisify } from 'node:util'
8
+ import { join, resolve, relative, dirname, basename } from 'node:path'
9
+ import { UserError, sha256 } from './util.mjs'
10
+ import { repoSlug } from './git.mjs'
11
+ import { cleanVersion, compareVersions, tagFor, versionOfTag, latestVersionIn, sectionOf, applyDraft, finalizeRelease, draftEntry, groupsMarkdown, parseEntry, findStepRefs, CHANGELOG_SKELETON } from './changelog-core.mjs'
12
+
13
+ const run = promisify(execFile)
14
+ async function out (cmd, args, cwd, timeout = 20000) {
15
+ try { return (await run(cmd, args, { cwd, timeout, maxBuffer: 50 * 1024 * 1024 })).stdout } catch { return null }
16
+ }
17
+ const exists = async (f) => { try { await access(f); return true } catch { return false } }
18
+ const readJson = async (f) => { try { return JSON.parse(await readFile(f, 'utf8')) } catch { return null } }
19
+ const readText = async (f) => { try { return await readFile(f, 'utf8') } catch { return null } }
20
+ export const today = () => new Date().toISOString().slice(0, 10)
21
+
22
+ // ---------- products ----------
23
+
24
+ const slugOf = (s) => String(s || '').toLowerCase().replace(/^@[^/]+\//, '').replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 80)
25
+
26
+ /**
27
+ * Products of the configuration, normalized. Without `products:` the project is the product (implicit): its slug is
28
+ * the config's project, else the directory name.
29
+ */
30
+ export function productsOf (config, root = process.cwd()) {
31
+ const docs = config.docs || {}
32
+ const list = Object.entries(config.products || {}).map(([slug, p]) => ({
33
+ slug, implicit: false, name: p.name || slug,
34
+ projects: [...new Set([p.project, ...(p.projects || [])].filter(Boolean))],
35
+ modules: p.modules || [], reference_env: p.reference_env || null, default_scenario: p.default_scenario || null,
36
+ version: p.version || null, docs: { ...docs, ...(p.docs || {}) }, publish: p.publish || docs.publish || null
37
+ }))
38
+ if (list.length) return list
39
+ const slug = slugOf(config.project || basename(root)) || 'app'
40
+ return [{ slug, implicit: true, name: config.project || slug, projects: [slug], modules: [], reference_env: null, default_scenario: null, version: null, docs, publish: docs.publish || null }]
41
+ }
42
+
43
+ /** The product a command works on: --product, else the only one. */
44
+ export function pickProduct (config, root, name) {
45
+ const all = productsOf(config, root)
46
+ if (typeof name === 'string' && name) {
47
+ const p = all.find((x) => x.slug === name)
48
+ if (!p) throw new UserError(`No product ${name} in the config (${all.map((x) => x.slug).join(', ')}).`)
49
+ return p
50
+ }
51
+ if (all.length > 1) throw new UserError(`The config has several products; pass --product (${all.map((x) => x.slug).join(', ')}).`)
52
+ return all[0]
53
+ }
54
+
55
+ export const changelogFile = (root, product) => resolve(root, product.docs?.changelog || 'CHANGELOG.md')
56
+
57
+ // ---------- the version source ----------
58
+
59
+ const SEMREL = ['.releaserc', '.releaserc.json', '.releaserc.yml', '.releaserc.yaml', '.releaserc.js', '.releaserc.cjs', 'release.config.js', 'release.config.cjs', 'release.config.mjs']
60
+
61
+ /**
62
+ * Where the product's version comes from, as the repository already decides it: { source, path, pattern, version,
63
+ * owns_changelog, tool }. The config wins (products.<p>.version or docs.version_source); otherwise release-please,
64
+ * changesets, semantic-release, package.json, pyproject.toml, Cargo.toml, a gemspec, else git tags (v*).
65
+ */
66
+ export async function detectVersionSource (root, product = {}) {
67
+ const v = product.version || {}
68
+ const wanted = v.source && v.source !== 'auto' ? v.source : product.docs?.version_source && product.docs.version_source !== 'auto' ? product.docs.version_source : null
69
+ const pattern = v.pattern || 'v*'
70
+ const pkgPath = v.path && /package\.json$/.test(v.path) ? v.path : 'package.json'
71
+ const dir = v.path ? dirname(v.path) : '.'
72
+ const found = async (source) => {
73
+ if (source === 'release-please') {
74
+ const manifest = await readJson(join(root, '.release-please-manifest.json'))
75
+ const key = dir === '.' ? '.' : dir
76
+ return { source, path: '.release-please-manifest.json', pattern, version: manifest ? cleanVersion(manifest[key] ?? manifest['.'] ?? '') || null : null, owns_changelog: true, tool: 'release-please' }
77
+ }
78
+ if (source === 'changesets') return { source, path: pkgPath, pattern, version: cleanVersion((await readJson(join(root, pkgPath)))?.version || '') || null, owns_changelog: true, tool: 'changesets' }
79
+ if (source === 'semantic-release') {
80
+ let conf = ''
81
+ for (const f of SEMREL) conf += (await readText(join(root, f))) || ''
82
+ conf += JSON.stringify((await readJson(join(root, 'package.json')))?.release || '')
83
+ return { source, path: null, pattern, version: await latestTag(root, pattern), owns_changelog: conf.includes('@semantic-release/changelog'), tool: 'semantic-release' }
84
+ }
85
+ if (source === 'package.json') return { source, path: pkgPath, pattern, version: cleanVersion((await readJson(join(root, pkgPath)))?.version || '') || null, owns_changelog: false }
86
+ if (source === 'pyproject') { const t = await readText(join(root, v.path || 'pyproject.toml')); return { source, path: v.path || 'pyproject.toml', pattern, version: (t?.match(/^version\s*=\s*["']([^"']+)["']/m) || [])[1] || null, owns_changelog: false } }
87
+ if (source === 'cargo') { const t = await readText(join(root, v.path || 'Cargo.toml')); return { source, path: v.path || 'Cargo.toml', pattern, version: (t?.match(/^\[package\][\s\S]*?^version\s*=\s*"([^"]+)"/m) || [])[1] || null, owns_changelog: false } }
88
+ if (source === 'gemspec') {
89
+ const f = v.path || (await readdir(root).catch(() => [])).find((x) => x.endsWith('.gemspec'))
90
+ const t = f ? await readText(join(root, f)) : null
91
+ return { source, path: f || null, pattern, version: (t?.match(/\.version\s*=\s*["']([^"']+)["']/) || [])[1] || null, owns_changelog: false }
92
+ }
93
+ if (source === 'manual') return { source, path: null, pattern, version: null, owns_changelog: false }
94
+ return { source: 'git-tag', path: null, pattern, version: await latestTag(root, pattern), owns_changelog: false }
95
+ }
96
+ if (wanted) return found(wanted)
97
+ if (await exists(join(root, '.release-please-manifest.json')) || await exists(join(root, 'release-please-config.json'))) return found('release-please')
98
+ if (await exists(join(root, '.changeset', 'config.json'))) return found('changesets')
99
+ for (const f of SEMREL) if (await exists(join(root, f))) return found('semantic-release')
100
+ const pkg = await readJson(join(root, pkgPath))
101
+ if (pkg?.release) return found('semantic-release')
102
+ if (pkg?.version && !pkg.private) return found('package.json')
103
+ if (pkg?.version && pkg.version !== '0.0.0') return found('package.json')
104
+ if (await exists(join(root, 'pyproject.toml')) && /^version\s*=/m.test(await readText(join(root, 'pyproject.toml')) || '')) return found('pyproject')
105
+ if (await exists(join(root, 'Cargo.toml'))) return found('cargo')
106
+ if ((await readdir(root).catch(() => [])).some((x) => x.endsWith('.gemspec'))) return found('gemspec')
107
+ return found('git-tag')
108
+ }
109
+
110
+ /** The newest tag of the pattern (version order), or null. */
111
+ async function latestTag (root, pattern) {
112
+ const tags = ((await out('git', ['tag', '--list', pattern], root)) || '').split('\n').map((t) => t.trim()).filter(Boolean)
113
+ const versions = tags.map((t) => versionOfTag(pattern, t)).filter(Boolean)
114
+ return versions.sort(compareVersions).pop() || null
115
+ }
116
+ const tagExists = async (root, tag) => !!(await out('git', ['rev-parse', '--verify', '--quiet', `refs/tags/${tag}`], root))
117
+
118
+ /**
119
+ * Where the draft starts: --since (a tag, or a version that has a tag), else the newest tag of the pattern reachable
120
+ * from HEAD, else the newest release written in the changelog when it has a tag; null = the whole history.
121
+ */
122
+ export async function sinceRef (root, product, src, since, changelogText = '') {
123
+ const pattern = src.pattern || 'v*'
124
+ if (typeof since === 'string' && since) {
125
+ if (await tagExists(root, since)) return { ref: since, version: versionOfTag(pattern, since) || cleanVersion(since) }
126
+ const tag = tagFor(pattern, since)
127
+ if (await tagExists(root, tag)) return { ref: tag, version: cleanVersion(since) }
128
+ if (await out('git', ['rev-parse', '--verify', '--quiet', `${since}^{commit}`], root)) return { ref: since, version: null }
129
+ throw new UserError(`--since ${since}: no tag ${since} or ${tag} and no such commit.`)
130
+ }
131
+ const described = ((await out('git', ['describe', '--tags', '--abbrev=0', '--match', pattern, 'HEAD'], root)) || '').trim()
132
+ if (described) return { ref: described, version: versionOfTag(pattern, described) }
133
+ const written = latestVersionIn(changelogText)
134
+ if (written && await tagExists(root, tagFor(pattern, written))) return { ref: tagFor(pattern, written), version: written }
135
+ return { ref: null, version: written || null }
136
+ }
137
+
138
+ // ---------- pull requests and commits ----------
139
+
140
+ /**
141
+ * Merged work since `ref` along the first-parent history: a merge of a pull request is one item (its title), other
142
+ * merges are opened up into their commits, plain commits are items. PR numbers come from merge subjects and squash
143
+ * suffixes ("(#12)"); titles and links from gh when it is there (KALOKO_NO_GH=1 skips it). `dir` limits the history
144
+ * to a product's folder in a monorepo.
145
+ */
146
+ export async function collectCommits (root, ref, { dir = null, limit = 400 } = {}) {
147
+ const range = ref ? `${ref}..HEAD` : 'HEAD'
148
+ const SEP = '\u001f'; const END = '\u001e'
149
+ const fmt = `--format=%H${SEP}%P${SEP}%s${SEP}%b${END}`
150
+ const log = async (args) => ((await out('git', ['log', fmt, ...args], root)) || '').split(END).map((x) => x.trim()).filter(Boolean).map((x) => { const [sha, parents, subject, body = ''] = x.split(SEP); return { sha, parents: parents.split(' ').filter(Boolean), subject, body: body.trim() } })
151
+ const scope = dir && dir !== '.' ? ['--', dir] : []
152
+ const first = await log(['--first-parent', `-n${limit}`, range, ...scope])
153
+ const items = []
154
+ for (const c of first) {
155
+ if (c.parents.length > 1) {
156
+ const pr = c.subject.match(/^Merge pull request #(\d+)/) || c.subject.match(/^Merge branch .* into .*\(#(\d+)\)/) || c.subject.match(/\(#(\d+)\)\s*$/)
157
+ if (pr) { items.push({ sha: c.sha, subject: c.body.split('\n')[0] || c.subject, body: c.body.split('\n').slice(1).join('\n'), pr: { number: Number(pr[1]), url: null } }); continue }
158
+ for (const inner of await log(['--no-merges', `-n${limit}`, `${c.parents[0]}..${c.sha}`, ...scope])) items.push({ sha: inner.sha, subject: inner.subject, body: inner.body, pr: null })
159
+ continue
160
+ }
161
+ const sq = c.subject.match(/\(#(\d+)\)\s*$/)
162
+ items.push({ sha: c.sha, subject: c.subject, body: c.body, pr: sq ? { number: Number(sq[1]), url: null } : null })
163
+ }
164
+ // links and titles of pull requests (GitHub)
165
+ const repo = await repoSlug(root)
166
+ const numbers = [...new Set(items.map((i) => i.pr?.number).filter(Boolean))]
167
+ if (repo?.host === 'github') {
168
+ let titles = new Map()
169
+ if (numbers.length && process.env.KALOKO_NO_GH !== '1') {
170
+ const json = await out('gh', ['pr', 'list', '--state', 'merged', '--limit', '200', '--json', 'number,title,url'], root, 15000)
171
+ try { titles = new Map(JSON.parse(json || '[]').map((p) => [p.number, p])) } catch {}
172
+ }
173
+ for (const i of items) {
174
+ if (!i.pr) continue
175
+ const p = titles.get(i.pr.number)
176
+ i.pr.url = p?.url || `https://github.com/${repo.slug}/pull/${i.pr.number}`
177
+ // a squash commit or merge keeps its own title unless it is GitHub's default "Merge pull request" line
178
+ if (p?.title && /^Merge pull request/.test(i.subject)) i.subject = p.title
179
+ }
180
+ }
181
+ return items
182
+ }
183
+
184
+ // ---------- the draft ----------
185
+
186
+ /**
187
+ * The draft entry of a product: commits and pull requests since the last release plus the steps that accepted runs
188
+ * show differently (when a service client is given). Returns { product, since, source, commits, changes, groups,
189
+ * markdown, internal, service, file }.
190
+ */
191
+ export async function draftChangelog ({ root, config, product, since = null, client = null }) {
192
+ const file = changelogFile(root, product)
193
+ const text = (await readText(file)) || ''
194
+ const src = await detectVersionSource(root, product)
195
+ const from = await sinceRef(root, product, src, since, text)
196
+ const dir = product.version?.path ? dirname(product.version.path) : null
197
+ const commits = await collectCommits(root, from.ref, { dir })
198
+ let changes = []; let service = 'offline'; let previous = null
199
+ if (client && config.org) {
200
+ try {
201
+ const q = new URLSearchParams({ product: product.slug, ...(from.version && since ? { since: from.version } : {}) })
202
+ const r = await client.request('GET', `/orgs/${config.org}/changelog/changes?${q}`)
203
+ changes = (r.scenarios || []).map((s) => ({ scenario: s.scenario, title: s.title, steps: (s.steps || []).map((st) => ({ ...st, pr: st.pr ?? s.run?.pr?.number ?? null })) }))
204
+ previous = r.previous || null
205
+ service = 'ok'
206
+ } catch (e) { service = `error: ${String(e.message || e).split('\n')[0].slice(0, 200)}` }
207
+ }
208
+ const d = draftEntry({ commits, changes })
209
+ return { product: product.slug, since: from, previous, source: src, commits: commits.length, changes, groups: d.groups, markdown: d.markdown, internal: d.internal, service, file }
210
+ }
211
+
212
+ /** The draft as an Unreleased section, with a note for whoever edits it. */
213
+ export function draftText (d) {
214
+ if (!d.markdown) return '## [Unreleased]\n\n<!-- Nothing user-facing since the last release: no merged changes and no changed screens. -->\n'
215
+ return `## [Unreleased]\n\n<!-- Draft by kaloko changelog draft: write each line for the people who use the product (what they can do now,\n what looks different), one change per line; delete what is internal; keep the step references for the pictures. -->\n\n${d.markdown}\n`
216
+ }
217
+
218
+ /** Writes the draft into the changelog's Unreleased section (creating the file with the skeleton when missing). */
219
+ export async function applyToChangelog (d) {
220
+ if (d.source.owns_changelog) throw new UserError(`${d.source.tool} writes ${relative(process.cwd(), d.file) || 'CHANGELOG.md'}; kaloko leaves it to it. Use the printed draft in your pull request or release notes, and kaloko release reads the entry ${d.source.tool} writes.`)
221
+ const before = (await readText(d.file)) || ''
222
+ const { text, added } = applyDraft(before || CHANGELOG_SKELETON, d.groups)
223
+ if (text !== before) await writeFile(d.file, text)
224
+ return { added, created: !before }
225
+ }
226
+
227
+ // ---------- the release ----------
228
+
229
+ /**
230
+ * Records a release on the service: the product (from the config), the version (given, else the version source's),
231
+ * the entry of that version in the changelog (an Unreleased section becomes the release unless a release tool owns
232
+ * the file; --no-write leaves the file alone) and the runs, which the service picks (newest accepted run per flow)
233
+ * unless `runs` names them. Returns what happened, for the CLI to say.
234
+ */
235
+ export async function releaseProduct ({ root, config, product, version = null, date = null, client, write = true, runs = null, dryRun = false }) {
236
+ const src = await detectVersionSource(root, product)
237
+ const v = cleanVersion(version || src.version || '')
238
+ if (!v) throw new UserError(`Which version? Pass it (kaloko release 2.4.0); ${src.source === 'git-tag' ? `no tag matches ${src.pattern}` : `${src.path || src.source} names none`}.`)
239
+ if (!/^[0-9A-Za-z][0-9A-Za-z.+-]{0,79}$/.test(v)) throw new UserError(`Not a version: ${v}`)
240
+ const notes = []
241
+ if (src.version && src.version !== v && src.source !== 'git-tag' && src.source !== 'semantic-release') notes.push(`${src.tool || src.source} (${src.path}) says ${src.version}; recording ${v} as you asked.`)
242
+ const file = changelogFile(root, product)
243
+ let text = (await readText(file)) || ''
244
+ let section = sectionOf(text, v)
245
+ let finalized = false
246
+ const day = /^\d{4}-\d{2}-\d{2}$/.test(date || '') ? date : section?.date || today()
247
+ if (!section && write && !src.owns_changelog && sectionOf(text, null)?.body?.trim()) {
248
+ const f = finalizeRelease(text, v, day, { tagPattern: src.pattern })
249
+ if (f.changed) {
250
+ if (!dryRun) await writeFile(file, f.text)
251
+ text = f.text; section = sectionOf(text, v); finalized = true
252
+ }
253
+ }
254
+ if (!section) notes.push(`${relative(root, file) || 'CHANGELOG.md'} has no entry for ${v}${src.owns_changelog ? ` (${src.tool} writes it)` : ''}; the release is recorded without text.`)
255
+ const md = section?.body || ''
256
+ const refs = findStepRefs(md)
257
+ const broken = refs.filter((r) => !r.parsed).map((r) => r.ref)
258
+ if (broken.length) notes.push(`Broken step references: ${broken.join(', ')}`)
259
+ const tag = tagFor(src.pattern, v)
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
+ 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)) },
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
+ }
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 }
266
+ const saved = await client.request('POST', `/orgs/${config.org}/products`, payload.product)
267
+ notes.push(...(saved.notes || []))
268
+ const rec = await client.request('POST', `/orgs/${config.org}/releases`, payload.release)
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
+ }
271
+ async function repoUrl (root) {
272
+ const r = await repoSlug(root)
273
+ return r?.host === 'github' ? { repo: `https://github.com/${r.slug}` } : r?.host === 'gitlab' ? { repo: `https://gitlab.com/${r.slug}` } : {}
274
+ }
275
+
276
+ // ---------- init and doctor ----------
277
+
278
+ /** The config block `kaloko init --docs=changelog|full` adds (comments say what each line does). */
279
+ export function docsConfigBlock ({ products = null } = {}) {
280
+ 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']
281
+ if (products?.length) {
282
+ lines.push('# one product per app in this repository; scenarios say which one they show (product: <slug>)', 'products:')
283
+ 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 }`)
284
+ }
285
+ return `${lines.join('\n')}\n`
286
+ }
287
+
288
+ /** Apps of a monorepo that look like products (apps/* with a package.json), for kaloko init. */
289
+ export async function detectProducts (root) {
290
+ const out = []
291
+ for (const base of ['apps']) {
292
+ const names = await readdir(join(root, base)).catch(() => [])
293
+ for (const n of names.sort()) {
294
+ const pkg = await readJson(join(root, base, n, 'package.json'))
295
+ if (pkg) out.push({ slug: slugOf(n), name: pkg.name ? String(pkg.name).replace(/^@[^/]+\//, '') : n, dir: `${base}/${n}`, path: `${base}/${n}/package.json` })
296
+ }
297
+ }
298
+ return out.length > 1 ? out : []
299
+ }
300
+
301
+ /** Diátaxis folders and an index for `kaloko init --docs=full` (only what is missing). */
302
+ 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'
308
+ }
309
+
310
+ /**
311
+ * Doctor checks for products: each product's version resolves from its source, scenarios name a known product
312
+ * (and module), and with several products every scenario says which one it shows. [{ name, ok, detail, hint }]
313
+ */
314
+ export async function productChecks ({ config, root, scenarios = [] }) {
315
+ const checks = []
316
+ const products = Object.keys(config.products || {})
317
+ if (!products.length) return checks
318
+ for (const p of productsOf(config, root)) {
319
+ const src = await detectVersionSource(root, p)
320
+ const ok = !!src.version || src.source === 'manual'
321
+ checks.push({ name: `product ${p.slug}`, ok, detail: ok ? `version ${src.version || '(set by kaloko release)'} from ${src.source}${src.path ? ` (${src.path})` : src.source === 'git-tag' ? ` (${src.pattern})` : ''}` : `no version from ${src.source}${src.path ? ` (${src.path})` : ` (${src.pattern})`}`, hint: ok ? '' : src.source === 'git-tag' ? `Tag a release (git tag ${tagFor(src.pattern, '1.0.0')}) or set products.${p.slug}.version.` : `Set the version in ${src.path || src.source}, or products.${p.slug}.version in the config.` })
322
+ }
323
+ const untagged = []; const unknown = []
324
+ for (const s of scenarios) {
325
+ if (!s.product) { untagged.push(s.file); continue }
326
+ if (!products.includes(s.product)) unknown.push(`${s.file}: product ${s.product}`)
327
+ else if (s.module && !(config.products[s.product].modules || []).includes(s.module)) unknown.push(`${s.file}: module ${s.module} of ${s.product}`)
328
+ }
329
+ if (unknown.length) checks.push({ name: 'scenario products', ok: false, detail: unknown.slice(0, 5).join('; '), hint: 'Use a key of products: (and one of its modules:) in the scenario.' })
330
+ if (untagged.length && products.length > 1) checks.push({ name: 'scenario products', ok: false, detail: `${untagged.length} scenario(s) without product: ${untagged.slice(0, 4).join(', ')}${untagged.length > 4 ? ' …' : ''}`, hint: `Add product: <${products.join('|')}> to each, so their pictures go to the right changelog.` })
331
+ else if (!unknown.length) checks.push({ name: 'scenario products', ok: true, detail: products.length === 1 ? `untagged scenarios belong to ${products[0]}` : 'every scenario names its product' })
332
+ return checks
333
+ }
@@ -12,6 +12,7 @@ import { DEFAULTS as JEV } from './jev.mjs'
12
12
  import { visionSettings, VISION_WEIGHT } from './vision-core.mjs'
13
13
  import { serviceUrl } from './service.mjs'
14
14
  import { videoTools, recordingChecks } from './video.mjs'
15
+ import { productChecks } from './changelog.mjs'
15
16
 
16
17
  const portFree = (port) => new Promise((done) => {
17
18
  const s = createServer().once('error', () => done(false)).once('listening', () => s.close(() => done(true)))
@@ -36,8 +37,9 @@ async function reach (url, fetchImpl, ms = 6000, headers = {}) {
36
37
  * @param {string[]} [o.engines] browser engines the scenarios and config.walk ask for (webkit, firefox)
37
38
  * @param {Function} [o.launchEngine] async (engine) => { version } — throws when the engine is not installed
38
39
  * @param {object} [o.tools] app tools found (default: looked up on PATH)
40
+ * @param {object[]} [o.scenarios] { file, product, module } of the scenario files (products checks)
39
41
  */
40
- export async function doctor ({ config, root, file = null, fetchImpl = fetch, launchChrome = defaultLaunch, env = process.env, fix = false, engines = null, launchEngine = defaultEngine, tools = null, os = process.platform, videoToolsImpl = null }) {
42
+ export async function doctor ({ config, root, file = null, fetchImpl = fetch, launchChrome = defaultLaunch, env = process.env, fix = false, engines = null, launchEngine = defaultEngine, tools = null, os = process.platform, videoToolsImpl = null, scenarios = [] }) {
41
43
  const checks = []
42
44
  const add = (name, ok, detail = '', hint = '') => checks.push({ name, ok, detail, hint })
43
45
 
@@ -165,6 +167,9 @@ export async function doctor ({ config, root, file = null, fetchImpl = fetch, la
165
167
  // recordings (batch D): ffmpeg and the device recorders, with install hints per OS
166
168
  checks.push(...recordingChecks({ config, tools: await (videoToolsImpl || videoTools)() }))
167
169
 
170
+ // the visual changelog: each product's version resolves and scenarios say which product they show
171
+ checks.push(...await productChecks({ config, root, scenarios }))
172
+
168
173
  return { ok: checks.every((c) => c.ok), checks }
169
174
  }
170
175
 
package/src/lib/run.mjs CHANGED
@@ -28,7 +28,7 @@ export function newRunId (scenario, date = new Date()) {
28
28
  return { id: `${today(date)}-${slugify(scenario.id)}-${short}`, short }
29
29
  }
30
30
 
31
- export async function createRun ({ output, scenario, scenarioFile, env, scope, git, executor, root, project = null, issue = null, appVersion = null }) {
31
+ export async function createRun ({ output, scenario, scenarioFile, env, scope, git, executor, root, project = null, issue = null, appVersion = null, product = null }) {
32
32
  const { id, short } = newRunId(scenario)
33
33
  const dir = resolve(root, output, id)
34
34
  await mkdir(dir, { recursive: true })
@@ -36,7 +36,7 @@ export async function createRun ({ output, scenario, scenarioFile, env, scope, g
36
36
  id,
37
37
  short,
38
38
  scenario_file: relative(root, scenarioFile),
39
- scenario: { id: scenario.id, title: scenario.title, kind: scenario.kind || 'task', version: scenario.version ?? null, source: scenario.source ?? null, ...(scenario.archived ? { archived: true } : {}), ...(scenario.readonly ? { readonly: true } : {}), ...(scenario.platform && scenario.platform !== 'web' ? { platform: scenario.platform } : {}) },
39
+ scenario: { id: scenario.id, title: scenario.title, kind: scenario.kind || 'task', version: scenario.version ?? null, source: scenario.source ?? null, ...(scenario.archived ? { archived: true } : {}), ...(scenario.readonly ? { readonly: true } : {}), ...(scenario.platform && scenario.platform !== 'web' ? { platform: scenario.platform } : {}), ...(product || scenario.product ? { product: product || scenario.product } : {}), ...(scenario.module ? { module: scenario.module } : {}) },
40
40
  environment: { name: env.name, base_url: env.base_url, readonly: env.readonly, ...(env.kind === 'design' ? { kind: 'design' } : {}) },
41
41
  scope,
42
42
  git,