kaloko 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +103 -0
- package/package.json +1 -1
- package/schemas/changelog.v1.json +83 -0
- package/schemas/config.v1.json +46 -0
- package/schemas/run.v1.json +27 -0
- package/schemas/scenario.v1.json +14 -1
- package/skills/kaloko/SKILL.md +39 -4
- package/skills/kaloko/reference/design.md +55 -0
- package/skills/kaloko/reference/docs.md +246 -0
- package/skills/kaloko/reference/integrations.md +67 -0
- package/skills/kaloko/reference/mcp.md +10 -1
- package/src/cli.mjs +304 -10
- package/src/lib/changelog-core.mjs +323 -0
- package/src/lib/changelog.mjs +399 -0
- package/src/lib/ci.mjs +71 -0
- package/src/lib/design-cli.mjs +22 -2
- package/src/lib/design-notes.mjs +191 -0
- package/src/lib/docs-core.mjs +450 -0
- package/src/lib/docs-export.mjs +271 -0
- package/src/lib/docs.mjs +221 -0
- package/src/lib/doctor.mjs +6 -1
- package/src/lib/figma.mjs +584 -0
- package/src/lib/git.mjs +23 -3
- package/src/lib/integrations-cli.mjs +211 -0
- package/src/lib/run.mjs +2 -2
- package/src/lib/screens-cli.mjs +36 -0
- package/src/lib/service.mjs +20 -1
- package/viewer/index.html +266 -30
|
@@ -0,0 +1,399 @@
|
|
|
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: await productPayload(root, product, src),
|
|
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
|
+
/** The product as the service records it (POST /products): from the config, with the version source found. */
|
|
272
|
+
export async function productPayload (root, product, src) {
|
|
273
|
+
return { slug: product.slug, name: product.name, projects: product.projects, modules: product.modules, reference_env: product.reference_env, default_scenario: product.default_scenario, implicit: product.implicit, version_source: { source: src.source, ...(src.path ? { path: src.path } : {}), pattern: src.pattern, ...(src.tool ? { tool: src.tool } : {}) }, ...(product.publish ? { publish: product.publish } : {}), ...(await repoUrl(root)) }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async function repoUrl (root) {
|
|
277
|
+
const r = await repoSlug(root)
|
|
278
|
+
return r?.host === 'github' ? { repo: `https://github.com/${r.slug}` } : r?.host === 'gitlab' ? { repo: `https://gitlab.com/${r.slug}` } : {}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ---------- init and doctor ----------
|
|
282
|
+
|
|
283
|
+
/** The config block `kaloko init --docs=changelog|full` adds (comments say what each line does). */
|
|
284
|
+
export function docsConfigBlock ({ products = null, full = false } = {}) {
|
|
285
|
+
const lines = ['', '# Visual changelog: kaloko changelog draft writes the Unreleased entry with step references,', '# kaloko release <version> records the release and its pictures (docs/DOCS-CHANGELOG.md on kaloko.app)', 'docs:', ' changelog: CHANGELOG.md', ' version_source: auto # git-tag | package.json | release-please | changesets | semantic-release']
|
|
286
|
+
if (full) lines.push(' dir: docs # guides with step references; kaloko docs publish sends them, kaloko docs check checks them', ' # languages: [en, cs] # the first is the originals\' language; translations sit next to them (guide.cs.md)')
|
|
287
|
+
if (products?.length) {
|
|
288
|
+
lines.push('# one product per app in this repository; scenarios say which one they show (product: <slug>)', 'products:')
|
|
289
|
+
for (const p of products) lines.push(` ${p.slug}:`, ` name: ${JSON.stringify(p.name)}`, ` version: { source: package.json, path: ${p.path} }`, ` docs: { changelog: ${p.dir}/CHANGELOG.md }`)
|
|
290
|
+
}
|
|
291
|
+
return `${lines.join('\n')}\n`
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Apps of a monorepo that look like products (apps/* with a package.json), for kaloko init. */
|
|
295
|
+
export async function detectProducts (root) {
|
|
296
|
+
const out = []
|
|
297
|
+
for (const base of ['apps']) {
|
|
298
|
+
const names = await readdir(join(root, base)).catch(() => [])
|
|
299
|
+
for (const n of names.sort()) {
|
|
300
|
+
const pkg = await readJson(join(root, base, n, 'package.json'))
|
|
301
|
+
if (pkg) out.push({ slug: slugOf(n), name: pkg.name ? String(pkg.name).replace(/^@[^/]+\//, '') : n, dir: `${base}/${n}`, path: `${base}/${n}/package.json` })
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return out.length > 1 ? out : []
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Diátaxis folders and an index for `kaloko init --docs=full` (only what is missing). Each folder's README says what
|
|
309
|
+
* belongs there and carries a template in a comment (comments never show on Kaloko or in exports).
|
|
310
|
+
*/
|
|
311
|
+
export const DOCS_SKELETON = {
|
|
312
|
+
'docs/index.md': '# Documentation\n\nGuides for the people who use the product. Pictures are step references\n(``) that always show the newest accepted screen.\n\n- [Tutorials](tutorials/README.md): learn by doing, start to finish\n- [How-to guides](how-to/README.md): one task each\n- [Reference](reference/README.md): the facts to look up\n- [Explanation](explanation/README.md): why it works this way\n',
|
|
313
|
+
'docs/tutorials/README.md': `# Tutorials
|
|
314
|
+
|
|
315
|
+
A tutorial takes a newcomer through a first success, start to finish, on a path that always works.
|
|
316
|
+
|
|
317
|
+
<!-- Template (copy into a new file, e.g. first-order.md):
|
|
318
|
+
---
|
|
319
|
+
title: Your first order
|
|
320
|
+
order: 1
|
|
321
|
+
---
|
|
322
|
+
# Your first order
|
|
323
|
+
|
|
324
|
+
In ten minutes you will have placed an order and seen it in your account.
|
|
325
|
+
|
|
326
|
+
1. Open the shop and put a tea in the cart.
|
|
327
|
+

|
|
328
|
+
2. Choose **Checkout** and pay with a test card.
|
|
329
|
+

|
|
330
|
+
|
|
331
|
+
You have placed your first order. Next: [track it](../how-to/track-an-order.md).
|
|
332
|
+
-->
|
|
333
|
+
`,
|
|
334
|
+
'docs/how-to/README.md': `# How-to guides
|
|
335
|
+
|
|
336
|
+
One task per page, for someone who already knows what they want to do.
|
|
337
|
+
|
|
338
|
+
<!-- Template (copy into a new file, e.g. pay-with-apple-pay.md):
|
|
339
|
+
# Pay with Apple Pay
|
|
340
|
+
|
|
341
|
+
1. Open the cart and choose **Checkout**.
|
|
342
|
+

|
|
343
|
+
2. Choose **Apple Pay** above the card form and confirm on your phone or Mac.
|
|
344
|
+

|
|
345
|
+
|
|
346
|
+
If the button is missing, your browser has no Apple Pay set up.
|
|
347
|
+
-->
|
|
348
|
+
`,
|
|
349
|
+
'docs/reference/README.md': `# Reference
|
|
350
|
+
|
|
351
|
+
Facts to look up: fields, limits, settings, keyboard shortcuts. Tables and lists, no story.
|
|
352
|
+
|
|
353
|
+
<!-- Template (copy into a new file, e.g. order-states.md):
|
|
354
|
+
# Order states
|
|
355
|
+
|
|
356
|
+
| State | What it means |
|
|
357
|
+
|---|---|
|
|
358
|
+
| Paid | The payment went through; the order waits for packing. |
|
|
359
|
+
| Shipped | The parcel left the warehouse. |
|
|
360
|
+
|
|
361
|
+

|
|
362
|
+
-->
|
|
363
|
+
`,
|
|
364
|
+
'docs/explanation/README.md': `# Explanation
|
|
365
|
+
|
|
366
|
+
Why things work the way they do: background, choices, trade-offs.
|
|
367
|
+
|
|
368
|
+
<!-- Template (copy into a new file, e.g. why-we-hold-payments.md):
|
|
369
|
+
# Why a payment waits a day
|
|
370
|
+
|
|
371
|
+
Card payments are confirmed the next morning, so a cancelled order costs the customer nothing.
|
|
372
|
+
-->
|
|
373
|
+
`
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Doctor checks for products: each product's version resolves from its source, scenarios name a known product
|
|
378
|
+
* (and module), and with several products every scenario says which one it shows. [{ name, ok, detail, hint }]
|
|
379
|
+
*/
|
|
380
|
+
export async function productChecks ({ config, root, scenarios = [] }) {
|
|
381
|
+
const checks = []
|
|
382
|
+
const products = Object.keys(config.products || {})
|
|
383
|
+
if (!products.length) return checks
|
|
384
|
+
for (const p of productsOf(config, root)) {
|
|
385
|
+
const src = await detectVersionSource(root, p)
|
|
386
|
+
const ok = !!src.version || src.source === 'manual'
|
|
387
|
+
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.` })
|
|
388
|
+
}
|
|
389
|
+
const untagged = []; const unknown = []
|
|
390
|
+
for (const s of scenarios) {
|
|
391
|
+
if (!s.product) { untagged.push(s.file); continue }
|
|
392
|
+
if (!products.includes(s.product)) unknown.push(`${s.file}: product ${s.product}`)
|
|
393
|
+
else if (s.module && !(config.products[s.product].modules || []).includes(s.module)) unknown.push(`${s.file}: module ${s.module} of ${s.product}`)
|
|
394
|
+
}
|
|
395
|
+
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.' })
|
|
396
|
+
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.` })
|
|
397
|
+
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' })
|
|
398
|
+
return checks
|
|
399
|
+
}
|
package/src/lib/ci.mjs
CHANGED
|
@@ -209,3 +209,74 @@ export function addPreviewEnvironment (text, name = 'preview') {
|
|
|
209
209
|
const at = m.index + m[0].length
|
|
210
210
|
return text.slice(0, at) + '\n' + block + text.slice(at)
|
|
211
211
|
}
|
|
212
|
+
|
|
213
|
+
// ---------- releases (docs/DOCS-CHANGELOG.md → release-tool integrations) ----------
|
|
214
|
+
|
|
215
|
+
const TOOL_SNIPPETS = {
|
|
216
|
+
'release-please': { after: 'googleapis/release-please-action (id: release)', when: '${{ steps.release.outputs.release_created }}', version: '${{ steps.release.outputs.version }}' },
|
|
217
|
+
changesets: { after: 'changesets/action (id: changesets)', when: "${{ steps.changesets.outputs.published == 'true' }}", version: '' },
|
|
218
|
+
'semantic-release': { after: 'npx semantic-release', when: null, version: '' }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* .github/workflows/kaloko-release.yml: when a GitHub release is published (release-please, semantic-release with
|
|
223
|
+
* @semantic-release/github and changesets all publish one) or by hand, `kaloko release <version> --no-write` records
|
|
224
|
+
* it on Kaloko: the entry the release tool wrote, the accepted runs that are its pictures, and the docs of that
|
|
225
|
+
* version. `products`: [{ slug, pattern }] (tag patterns such as v* or shop-v*); several products get one step each.
|
|
226
|
+
*/
|
|
227
|
+
export function releaseWorkflow ({ products = [{ slug: null, pattern: 'v*' }], pm = 'npm', dependency = true, version = 'latest', node = 22, tool = null }) {
|
|
228
|
+
const { install, kaloko: k } = runner({ pm, dependency, version })
|
|
229
|
+
const several = products.length > 1
|
|
230
|
+
const steps = products.map((p) => {
|
|
231
|
+
const prefix = String(p.pattern || 'v*').split('*')[0]
|
|
232
|
+
const flag = several || p.slug ? ` --product ${q(p.slug)}` : ''
|
|
233
|
+
return ` - name: Record ${p.slug || 'the release'} on Kaloko
|
|
234
|
+
${several ? ` if: startsWith(env.TAG, '${prefix}')\n` : ''} run: ${k} release "\${TAG#${prefix}}"${flag} --no-write`
|
|
235
|
+
}).join('\n')
|
|
236
|
+
return `# Kaloko: record every release on Kaloko once your release tool has published it: its changelog entry with
|
|
237
|
+
# before/after pictures, the accepted runs that are its reference, and the docs of that version.
|
|
238
|
+
# Written by kaloko ci github --release${tool ? ` (${tool} found)` : ''}. Secret: KALOKO_TOKEN (tester role, upload scope).
|
|
239
|
+
# kaloko release --no-write only reads CHANGELOG.md; the release tool keeps writing it.
|
|
240
|
+
name: Kaloko release
|
|
241
|
+
on:
|
|
242
|
+
release:
|
|
243
|
+
types: [published]
|
|
244
|
+
workflow_dispatch:
|
|
245
|
+
inputs:
|
|
246
|
+
tag:
|
|
247
|
+
description: Tag of the release to record (e.g. v2.4.0)
|
|
248
|
+
required: true
|
|
249
|
+
permissions:
|
|
250
|
+
contents: read
|
|
251
|
+
jobs:
|
|
252
|
+
record:
|
|
253
|
+
runs-on: ubuntu-latest
|
|
254
|
+
timeout-minutes: 10
|
|
255
|
+
env:
|
|
256
|
+
KALOKO_TOKEN: \${{ secrets.KALOKO_TOKEN }}
|
|
257
|
+
GH_TOKEN: \${{ github.token }}
|
|
258
|
+
KALOKO_AGENT: github-actions
|
|
259
|
+
TAG: \${{ github.event.release.tag_name || inputs.tag }}
|
|
260
|
+
steps:
|
|
261
|
+
- uses: actions/checkout@v4
|
|
262
|
+
with:
|
|
263
|
+
ref: \${{ github.event.release.tag_name || inputs.tag }}
|
|
264
|
+
fetch-depth: 0 # the tags and the history since the previous release
|
|
265
|
+
- uses: actions/setup-node@v4
|
|
266
|
+
with:
|
|
267
|
+
node-version: ${node}
|
|
268
|
+
${install ? `${['pnpm', 'yarn'].includes(pm) ? ' - run: corepack enable\n' : ''} - run: ${install}\n` : ''}${steps}
|
|
269
|
+
`
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The step to paste into an existing release workflow, right after the release tool's own step. */
|
|
273
|
+
export function releaseSnippet ({ tool = null, pm = 'npm', dependency = true, version = 'latest', product = null }) {
|
|
274
|
+
const { kaloko: k } = runner({ pm, dependency, version })
|
|
275
|
+
const s = TOOL_SNIPPETS[tool] || { after: 'the step that publishes the release', when: null, version: '' }
|
|
276
|
+
return `# after ${s.after}:
|
|
277
|
+
- name: Record the release on Kaloko
|
|
278
|
+
${s.when ? ` if: ${s.when}\n` : ''} run: ${k} release${s.version ? ` ${s.version}` : ''}${product ? ` --product ${q(product)}` : ''} --no-write
|
|
279
|
+
env:
|
|
280
|
+
KALOKO_TOKEN: \${{ secrets.KALOKO_TOKEN }}
|
|
281
|
+
`
|
|
282
|
+
}
|
package/src/lib/design-cli.mjs
CHANGED
|
@@ -297,11 +297,31 @@ export async function syncDesign ({ config, root, run, scenario, env, client, ar
|
|
|
297
297
|
return { pushed: toPush.length, pulled: toPull.length, conflicts: conflicts.length ? conflicts : null, seq: after.seq }
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
-
/**
|
|
300
|
+
/** The notes an agent or person wrote for a version: --notes <file> or --notes - (stdin); null without the option. */
|
|
301
|
+
export async function readNotesArg (value, stdin = process.stdin) {
|
|
302
|
+
if (value == null || value === false) return null
|
|
303
|
+
if (value === true) throw new UserError('--notes takes a file (Markdown, text or JSON) or - for standard input.')
|
|
304
|
+
if (value === '-') { const chunks = []; for await (const c of stdin) chunks.push(c); return Buffer.concat(chunks.map((c) => Buffer.from(c))).toString('utf8') }
|
|
305
|
+
try { return await readFile(value, 'utf8') } catch { throw new UserError(`--notes: cannot read ${value}.`) }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** What publishing the draft now would write as "what changed" (kaloko share --notes-preview). */
|
|
309
|
+
export async function previewNotes ({ config, scenario, client, args, say }) {
|
|
310
|
+
const branch = typeof args.branch === 'string' ? args.branch : 'main'
|
|
311
|
+
const r = await client.request('GET', `/orgs/${config.org}/scenarios/${enc(scenario.id)}/versions/draft/notes?branch=${enc(branch)}`)
|
|
312
|
+
if (args.json) { say(JSON.stringify(r, null, 2)); return r }
|
|
313
|
+
const { notesText } = await import('./design-notes.mjs')
|
|
314
|
+
say(`What publishing now would say (${r.changed} changed, ${r.unchanged} unchanged${r.notes?.from ? ` since v${r.notes.from.version}` : ''}):\n\n${notesText(r.notes, { lang: args.lang === 'cs' ? 'cs' : 'en' })}\n\nWrite your own in a file (a summary, then "step-id: what changed" lines) and publish with kaloko share --notes <file>.`)
|
|
315
|
+
return r
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Publishes the draft head as a version (a run of the design environment), with "what changed" notes. */
|
|
301
319
|
export async function publishVersion ({ config, scenario, client, args, say }) {
|
|
302
320
|
const branch = typeof args.branch === 'string' ? args.branch : 'main'
|
|
303
|
-
const
|
|
321
|
+
const notes = await readNotesArg(args.notes)
|
|
322
|
+
const r = await client.request('POST', `/orgs/${config.org}/scenarios/${enc(scenario.id)}/versions`, { branch, ...(typeof args.name === 'string' ? { name: args.name } : {}), ...(notes ? { notes } : {}) })
|
|
304
323
|
say(`Version v${r.version}${r.name ? ` "${r.name}"` : ''} · ${r.changed} changed, ${r.unchanged} unchanged${r.changed_steps.length ? ` (${r.changed_steps.join(', ')})` : ''}\n${r.url}`)
|
|
324
|
+
if (r.notes) { const { notesText } = await import('./design-notes.mjs'); say(`\nWhat changed${notes ? ' (your notes over the generated ones)' : ''}:\n${notesText(r.notes)}`) }
|
|
305
325
|
return r
|
|
306
326
|
}
|
|
307
327
|
|