kaloko 0.5.0 → 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.
@@ -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,
@@ -0,0 +1,138 @@
1
+ // Signed sign-off records (idea 10): canonical JSON, Ed25519 signatures and their verification. Shared by the service
2
+ // (worker/src/signoff.js) and the CLI (kaloko signoff): WebCrypto only, no node: imports, so it runs in both.
3
+ //
4
+ // What is signed: the record without its `signature` field, serialised as canonical JSON ("kaloko-c14n-v1": object
5
+ // keys sorted by UTF-16 code units at every level, no whitespace, undefined values left out, numbers and strings as
6
+ // JSON.stringify writes them). The signature is Ed25519 over the UTF-8 bytes of that text, base64url without padding.
7
+
8
+ const enc = new TextEncoder()
9
+ const dec = new TextDecoder()
10
+
11
+ export const CANONICAL = 'kaloko-c14n-v1'
12
+ export const SIGNOFF_SCHEMA = 'kaloko.signoff.v1'
13
+ export const PDF_ATTACHMENT = 'kaloko-signoff.json'
14
+ /** Marks the embedded file object in a sign-off PDF so the record can be found again without a PDF parser. */
15
+ export const PDF_MARK = '/KalokoSignoff true'
16
+
17
+ /** Canonical JSON text of a value (see the header). */
18
+ export function canonicalJson (value) {
19
+ if (value === null || typeof value !== 'object') {
20
+ if (typeof value === 'number' && !Number.isFinite(value)) return 'null'
21
+ return JSON.stringify(value) ?? 'null'
22
+ }
23
+ if (Array.isArray(value)) return `[${value.map((v) => (v === undefined || typeof v === 'function' ? 'null' : canonicalJson(v))).join(',')}]`
24
+ if (typeof value.toJSON === 'function') return canonicalJson(value.toJSON())
25
+ const keys = Object.keys(value).filter((k) => value[k] !== undefined && typeof value[k] !== 'function').sort()
26
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`
27
+ }
28
+
29
+ const bytesOf = (x) => typeof x === 'string' ? enc.encode(x) : x instanceof Uint8Array ? x : new Uint8Array(x)
30
+
31
+ /** SHA-256 as lowercase hex of a string (UTF-8) or bytes. */
32
+ export async function sha256Hex (data) {
33
+ const h = new Uint8Array(await crypto.subtle.digest('SHA-256', bytesOf(data)))
34
+ return [...h].map((b) => b.toString(16).padStart(2, '0')).join('')
35
+ }
36
+
37
+ /** base64url without padding. */
38
+ export function b64url (bytes) {
39
+ const b = bytesOf(bytes); let s = ''
40
+ for (let i = 0; i < b.length; i += 0x8000) s += String.fromCharCode(...b.subarray(i, i + 0x8000))
41
+ return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
42
+ }
43
+ /** Bytes of base64 or base64url (padding optional). */
44
+ export function fromB64url (text) {
45
+ const s = String(text).trim().replace(/-/g, '+').replace(/_/g, '/').replace(/\s+/g, '')
46
+ const bin = atob(s + '='.repeat((4 - (s.length % 4)) % 4))
47
+ const out = new Uint8Array(bin.length)
48
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
49
+ return out
50
+ }
51
+
52
+ const PKCS8_SEED_PREFIX = new Uint8Array([0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20])
53
+ const ED = { name: 'Ed25519' }
54
+
55
+ /** Key id: the first 16 hex characters of the SHA-256 of the raw public key. */
56
+ export const keyId = async (raw) => (await sha256Hex(raw)).slice(0, 16)
57
+
58
+ /**
59
+ * The service's signing key from the secret: a base64/base64url 32-byte Ed25519 seed, a base64 PKCS#8 DER, or a JWK
60
+ * (JSON with d and x). Returns { privateKey, publicJwk, publicRaw (base64url), kid }.
61
+ */
62
+ export async function importSigningKey (secret) {
63
+ const text = String(secret || '').trim()
64
+ if (!text) throw new Error('No signing key.')
65
+ let privateKey
66
+ if (text.startsWith('{')) {
67
+ const jwk = JSON.parse(text)
68
+ privateKey = await crypto.subtle.importKey('jwk', { kty: 'OKP', crv: 'Ed25519', d: jwk.d, x: jwk.x, ext: true }, ED, true, ['sign'])
69
+ } else {
70
+ const raw = fromB64url(text)
71
+ let der
72
+ if (raw.length === 32) { der = new Uint8Array(48); der.set(PKCS8_SEED_PREFIX); der.set(raw, 16) } else der = raw
73
+ privateKey = await crypto.subtle.importKey('pkcs8', der, ED, true, ['sign'])
74
+ }
75
+ const jwk = await crypto.subtle.exportKey('jwk', privateKey)
76
+ const publicRawBytes = fromB64url(jwk.x)
77
+ return { privateKey, publicJwk: { kty: 'OKP', crv: 'Ed25519', x: jwk.x }, publicRaw: jwk.x, kid: await keyId(publicRawBytes) }
78
+ }
79
+
80
+ const unsigned = (record) => { const { signature, ...rest } = record || {}; return rest }
81
+
82
+ /** Signs a record: { ...record, signature: { alg, kid, value, canonical } }. */
83
+ export async function signRecord (record, key) {
84
+ const body = unsigned(record)
85
+ const value = new Uint8Array(await crypto.subtle.sign(ED, key.privateKey, enc.encode(canonicalJson(body))))
86
+ return { ...body, signature: { alg: 'Ed25519', kid: key.kid, value: b64url(value), canonical: CANONICAL } }
87
+ }
88
+
89
+ /** SHA-256 (hex) of the signed part of a record: what the PDF and the CLI print as the record hash. */
90
+ export const recordSha256 = (record) => sha256Hex(canonicalJson(unsigned(record)))
91
+
92
+ /** The public keys of a well-known document ({ keys: [...] }), a list, or one key object, as [{ kid, public_key }]. */
93
+ export function keyList (keys) {
94
+ const list = Array.isArray(keys) ? keys : Array.isArray(keys?.keys) ? keys.keys : keys ? [keys] : []
95
+ return list.map((k) => ({ kid: k.kid, public_key: k.public_key || k.x || k.jwk?.x })).filter((k) => k.public_key)
96
+ }
97
+
98
+ /** Verifies a signed record against public keys. Returns { ok, kid, reason }. */
99
+ export async function verifyRecord (signed, keys) {
100
+ const sig = signed?.signature
101
+ if (!sig || typeof sig !== 'object') return { ok: false, kid: null, reason: 'The record has no signature.' }
102
+ if (sig.alg !== 'Ed25519') return { ok: false, kid: sig.kid || null, reason: `Unknown signature algorithm ${sig.alg}.` }
103
+ if (sig.canonical && sig.canonical !== CANONICAL) return { ok: false, kid: sig.kid || null, reason: `Unknown canonical form ${sig.canonical}.` }
104
+ const list = keyList(keys)
105
+ const candidates = sig.kid ? list.filter((k) => !k.kid || k.kid === sig.kid) : list
106
+ if (!candidates.length) return { ok: false, kid: sig.kid || null, reason: `No public key with id ${sig.kid} (the service may have rotated its key).` }
107
+ let value
108
+ try { value = fromB64url(sig.value) } catch { return { ok: false, kid: sig.kid || null, reason: 'The signature is not base64url.' } }
109
+ const data = enc.encode(canonicalJson(unsigned(signed)))
110
+ for (const k of candidates) {
111
+ try {
112
+ const pub = await crypto.subtle.importKey('raw', fromB64url(k.public_key), ED, false, ['verify'])
113
+ if (await crypto.subtle.verify(ED, pub, value, data)) return { ok: true, kid: k.kid || sig.kid || null, reason: null }
114
+ } catch {}
115
+ }
116
+ return { ok: false, kid: sig.kid || null, reason: 'The signature does not match: the record was changed after it was signed, or it was signed with another key.' }
117
+ }
118
+
119
+ /** Reads bytes as latin1 text (one character per byte), for searching a PDF. */
120
+ const latin1 = (b) => { let s = ''; for (let i = 0; i < b.length; i += 0x8000) s += String.fromCharCode(...b.subarray(i, i + 0x8000)); return s }
121
+
122
+ /** The signed record embedded in a sign-off PDF (the attached kaloko-signoff.json), or null. */
123
+ export function extractRecordFromPdf (bytes) {
124
+ const b = bytesOf(bytes)
125
+ const text = latin1(b)
126
+ const at = text.lastIndexOf(PDF_MARK)
127
+ if (at < 0) return null
128
+ const dictStart = text.lastIndexOf('<<', at)
129
+ const dictEnd = text.indexOf('>>', at)
130
+ if (dictStart < 0 || dictEnd < 0) return null
131
+ const len = Number(text.slice(dictStart, dictEnd).match(/\/Length (\d+)/)?.[1])
132
+ const s = text.indexOf('stream', dictEnd)
133
+ if (!Number.isFinite(len) || s < 0) return null
134
+ let start = s + 'stream'.length
135
+ if (text[start] === '\r') start++
136
+ if (text[start] === '\n') start++
137
+ try { return JSON.parse(dec.decode(b.subarray(start, start + len))) } catch { return null }
138
+ }
@@ -0,0 +1,83 @@
1
+ // `kaloko signoff <run> [--out dir] [--lang cs|en] [--json]` downloads the signed acceptance record of an accepted run
2
+ // (JSON and PDF) and checks its signature; `kaloko signoff --verify <file.json|file.pdf> [--key file|url]` checks a
3
+ // record someone handed over, without a token. The keys come from <service>/.well-known/kaloko-signoff.json.
4
+ import { readFile, writeFile, mkdir } from 'node:fs/promises'
5
+ import { join, resolve } from 'node:path'
6
+ import { UserError } from './util.mjs'
7
+ import { serviceUrl } from './service.mjs'
8
+ import { verifyRecord, recordSha256, extractRecordFromPdf, canonicalJson } from './signoff-core.mjs'
9
+
10
+ /** Public keys from a file, a URL or the service's well-known document. */
11
+ export async function loadKeys ({ config, key }) {
12
+ const src = typeof key === 'string' && key ? key : `${serviceUrl(config || {})}/.well-known/kaloko-signoff.json`
13
+ try {
14
+ if (/^https?:\/\//.test(src)) {
15
+ const res = await fetch(src, { headers: { Accept: 'application/json' } })
16
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
17
+ return await res.json()
18
+ }
19
+ return JSON.parse(await readFile(resolve(src), 'utf8'))
20
+ } catch (e) { throw new UserError(`Could not read the public keys from ${src}: ${e.message}`) }
21
+ }
22
+
23
+ /** The signed record in a .json or .pdf file. */
24
+ export async function readRecord (file) {
25
+ let bytes
26
+ try { bytes = await readFile(resolve(file)) } catch (e) { throw new UserError(`Cannot read ${file}: ${e.message}`) }
27
+ if (bytes.subarray(0, 5).toString('latin1') === '%PDF-') {
28
+ const record = extractRecordFromPdf(new Uint8Array(bytes))
29
+ if (!record) throw new UserError(`${file} has no Kaloko sign-off record attached.`)
30
+ return { record, kind: 'pdf' }
31
+ }
32
+ try { return { record: JSON.parse(bytes.toString('utf8')), kind: 'json' } } catch { throw new UserError(`${file} is neither a sign-off JSON nor a sign-off PDF.`) }
33
+ }
34
+
35
+ /** Verification of one record: { ok, kid, reason, sha256, run, verdict }. */
36
+ export async function verifySignoff (record, keys) {
37
+ const v = await verifyRecord(record, keys)
38
+ return { ok: v.ok, kid: v.kid, reason: v.reason, sha256: await recordSha256(record), run: record?.run?.id || null, verdict: record?.verdict || null, issuer: record?.issuer?.service || null }
39
+ }
40
+
41
+ const summary = (r, check) => {
42
+ const v = r.verdict || {}; const a = r.approvals || {}; const s = r.summary || {}
43
+ const lines = [
44
+ `${r.run?.id} · ${r.run?.scenario?.title || r.run?.scenario?.id || ''} · ${r.run?.environment?.name || ''}${r.run?.git?.commit ? ` · ${String(r.run.git.commit).slice(0, 12)}` : ''}`,
45
+ ` verdict: ${v.verdict} by ${v.by}${v.via && v.via !== 'session' ? ` (${v.via}, not a person's decision)` : ''} on ${String(v.at || '').slice(0, 16).replace('T', ' ')}`,
46
+ ` approved in the app: ${(a.people || []).map((x) => x.email).join(', ') || 'nobody beyond the verdict'}${(a.tokens_and_agents || []).length ? `; by tokens or agents (not counted as people): ${a.tokens_and_agents.map((x) => x.email).join(', ')}` : ''}`,
47
+ ` steps: ${s.steps ?? (r.steps || []).length} (${s.pass || 0} pass, ${s.partial || 0} partial, ${s.fail || 0} fail, ${s.skip || 0} not checked)${s.carried ? `, ${s.carried} approval(s) carried over unchanged steps` : ''}`,
48
+ ` record SHA-256: ${check.sha256}`,
49
+ ` run.json SHA-256: ${r.manifest?.sha256 || '—'}`,
50
+ check.ok ? ` ✓ signature valid (Ed25519, key ${check.kid}, ${check.issuer || 'unknown issuer'})` : ` ✕ signature NOT valid: ${check.reason}`
51
+ ]
52
+ return lines.join('\n')
53
+ }
54
+
55
+ /**
56
+ * The command. `client` is service(config) (only for downloading); `runId` the run to download (a shared run id).
57
+ */
58
+ export async function signoffCmd ({ config, args, say, client = null, runId = null }) {
59
+ if (typeof args.verify === 'string') {
60
+ const { record, kind } = await readRecord(args.verify)
61
+ const check = await verifySignoff(record, await loadKeys({ config, key: args.key }))
62
+ if (args.json) say(JSON.stringify({ file: args.verify, kind, ...check }, null, 2))
63
+ else say(summary(record, check))
64
+ if (!check.ok) throw new UserError(`The sign-off record in ${args.verify} does not verify: ${check.reason}`)
65
+ return check
66
+ }
67
+ if (!runId) throw new UserError('Usage: kaloko signoff <run-id> [--out dir] [--lang cs|en] | kaloko signoff --verify <file.json|file.pdf> [--key file|url]')
68
+ if (!client) throw new UserError('Downloading a sign-off record needs the service token (KALOKO_TOKEN).')
69
+ const base = `/orgs/${config.org}/runs/${encodeURIComponent(runId)}`
70
+ const lang = args.lang === 'cs' ? 'cs' : 'en'
71
+ const record = await client.request('GET', `${base}/signoff`)
72
+ const pdf = Buffer.from(await (await client.fetch(`${base}/signoff.pdf?lang=${lang}`)).arrayBuffer())
73
+ const dir = resolve(typeof args.out === 'string' ? args.out : '.')
74
+ await mkdir(dir, { recursive: true })
75
+ const jsonFile = join(dir, `kaloko-signoff-${runId}.json`); const pdfFile = join(dir, `kaloko-signoff-${runId}.pdf`)
76
+ await writeFile(jsonFile, canonicalJson(record))
77
+ await writeFile(pdfFile, pdf)
78
+ const check = await verifySignoff(record, await loadKeys({ config, key: args.key }))
79
+ if (args.json) say(JSON.stringify({ json: jsonFile, pdf: pdfFile, ...check }, null, 2))
80
+ else say(`${summary(record, check)}\nSaved ${jsonFile}\n ${pdfFile}\nAnyone can check it later: kaloko signoff --verify ${pdfFile}`)
81
+ if (!check.ok) throw new UserError(`The downloaded record does not verify: ${check.reason}`)
82
+ return check
83
+ }