kaloko 0.3.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.
Files changed (68) hide show
  1. package/README.md +119 -0
  2. package/bin/kaloko.mjs +4 -0
  3. package/bin/qawalk.mjs +4 -0
  4. package/package.json +42 -0
  5. package/schemas/bundle.v1.json +19 -0
  6. package/schemas/config.v1.json +268 -0
  7. package/schemas/revision.v1.json +36 -0
  8. package/schemas/run.v1.json +549 -0
  9. package/schemas/scenario.v1.json +668 -0
  10. package/schemas/tokens.v1.json +102 -0
  11. package/skills/kaloko/SKILL.md +94 -0
  12. package/skills/kaloko/reference/acceptance.md +208 -0
  13. package/skills/kaloko/reference/apps.md +161 -0
  14. package/skills/kaloko/reference/collaboration.md +218 -0
  15. package/skills/kaloko/reference/design.md +143 -0
  16. package/skills/kaloko/reference/documentation.md +72 -0
  17. package/skills/kaloko/reference/environments.md +140 -0
  18. package/skills/kaloko/reference/evaluation.md +62 -0
  19. package/skills/kaloko/reference/execution.md +75 -0
  20. package/skills/kaloko/reference/mcp.md +69 -0
  21. package/skills/kaloko/reference/stacks.md +122 -0
  22. package/src/cli.mjs +1344 -0
  23. package/src/lib/access.mjs +169 -0
  24. package/src/lib/apps/capture.mjs +59 -0
  25. package/src/lib/apps/drivers.mjs +284 -0
  26. package/src/lib/apps/helper.swift +102 -0
  27. package/src/lib/apps/tree.mjs +222 -0
  28. package/src/lib/browser-daemon.mjs +41 -0
  29. package/src/lib/browser.mjs +161 -0
  30. package/src/lib/build.mjs +149 -0
  31. package/src/lib/calibrate.mjs +97 -0
  32. package/src/lib/capture.mjs +233 -0
  33. package/src/lib/compare.mjs +88 -0
  34. package/src/lib/config.mjs +117 -0
  35. package/src/lib/console.mjs +13 -0
  36. package/src/lib/coverage.mjs +75 -0
  37. package/src/lib/design-cli.mjs +440 -0
  38. package/src/lib/design-core.mjs +163 -0
  39. package/src/lib/design.mjs +164 -0
  40. package/src/lib/doctor.mjs +149 -0
  41. package/src/lib/draft.mjs +76 -0
  42. package/src/lib/drift.mjs +145 -0
  43. package/src/lib/evaluate.mjs +222 -0
  44. package/src/lib/git.mjs +70 -0
  45. package/src/lib/htmlview.mjs +130 -0
  46. package/src/lib/jev.mjs +95 -0
  47. package/src/lib/mail.mjs +182 -0
  48. package/src/lib/manifest-core.mjs +127 -0
  49. package/src/lib/pack-help.mjs +55 -0
  50. package/src/lib/packs.mjs +121 -0
  51. package/src/lib/pr.mjs +78 -0
  52. package/src/lib/probe.mjs +379 -0
  53. package/src/lib/recheck.mjs +70 -0
  54. package/src/lib/run.mjs +85 -0
  55. package/src/lib/scenario.mjs +98 -0
  56. package/src/lib/schema.mjs +25 -0
  57. package/src/lib/secrets.mjs +59 -0
  58. package/src/lib/service.mjs +52 -0
  59. package/src/lib/stack.mjs +130 -0
  60. package/src/lib/tokens.mjs +352 -0
  61. package/src/lib/util.mjs +97 -0
  62. package/templates/ACCEPTANCE.md +55 -0
  63. package/templates/cs/ACCEPTANCE.md +55 -0
  64. package/templates/kaloko.config.yml +76 -0
  65. package/templates/mail/letter-opener.mjs +47 -0
  66. package/templates/scenario.yml +119 -0
  67. package/templates/steps/consent.mjs +33 -0
  68. package/viewer/index.html +2772 -0
@@ -0,0 +1,440 @@
1
+ // CLI side of Kaloko Design (#42): walking a design environment (render only what changed), syncing
2
+ // revisions with the shared draft, publishing versions, history, references, token sets, live export.
3
+ import { readFile, writeFile, mkdir, rm, readdir } from 'node:fs/promises'
4
+ import { gzipSync, gunzipSync } from 'node:zlib'
5
+ import { join, dirname, relative } from 'node:path'
6
+ import { readJson, writeJson, UserError } from './util.mjs'
7
+ import { readCaptures, addCapture, runPaths } from './run.mjs'
8
+ import { latestCaptures, criteriaHash, evaluationSource } from './evaluate.mjs'
9
+ import { captureStep } from './capture.mjs'
10
+ import { layout } from './scenario.mjs'
11
+ import { launchEphemeral } from './browser.mjs'
12
+ import { observeConsole } from './console.mjs'
13
+ import { slimCapture } from './manifest-core.mjs'
14
+ import { captureKey, sha256Hex, extOf, TEXT_TYPES, RENDERER } from './design-core.mjs'
15
+ import { designDir, revisionOf, serveDesign, copyLive, fromCache, toCache, restoreCached, readBase, writeBase, headKey, tokensRef } from './design.mjs'
16
+
17
+ const enc = encodeURIComponent
18
+ const screensOf = (scenario) => scenario.nodes.filter((n) => n.kind !== 'decision' && n.kind !== 'external')
19
+
20
+ /** Local revisions of every step and locale of the scenario (fresh from the design folder). */
21
+ export async function localRevisions ({ dir, scenario, locales, allLocales, project, pick = null }) {
22
+ const tokens = await tokensRef(dir, project)
23
+ const out = {}
24
+ for (const locale of locales) {
25
+ for (const node of screensOf(scenario)) {
26
+ if (pick && !pick.includes(node.id)) continue
27
+ const rev = await revisionOf({ dir, scenario: scenario.id, node: node.id, locale, locales: allLocales, tokens })
28
+ if (rev) out[headKey(node.id, locale)] = rev
29
+ }
30
+ }
31
+ return out
32
+ }
33
+
34
+ /**
35
+ * Walk of a design environment: serves the folder, renders each step and locale in every viewport, and reuses
36
+ * captures of revisions that were already rendered (content cache). Writes revisions.json into the run.
37
+ */
38
+ export async function walkDesign ({ config, root, run, scenario, env, pick, locales, viewports, say, noCache = false }) {
39
+ const dir = designDir(env, root)
40
+ const revisions = await readJson(join(run.dir, 'revisions.json'), {})
41
+ const fresh = await localRevisions({ dir, scenario, locales, allLocales: run.meta.scope.locales, project: run.meta.project?.slug, pick })
42
+ const missing = []
43
+ for (const locale of locales) for (const node of screensOf(scenario)) if ((!pick || pick.includes(node.id)) && !fresh[headKey(node.id, locale)]) missing.push(`${node.id} (${locale}): add ${node.id}/index.html to ${relative(process.cwd(), dir)}/`)
44
+ const server = await serveDesign(dir)
45
+ const browser = await launchEphemeral(config)
46
+ const paths = runPaths(run.dir)
47
+ const mode = env.tokens_mode || ''
48
+ let rendered = 0, reused = 0
49
+ try {
50
+ const page = await (await browser.newContext()).newPage()
51
+ observeConsole(page)
52
+ for (const [key, rev] of Object.entries(fresh)) {
53
+ const node = scenario.nodes.find((n) => n.id === rev.node)
54
+ revisions[key] = rev
55
+ await copyLive(run.dir, dir, rev)
56
+ const live = { revision: rev.id, entry: rev.entry }
57
+ const todo = {}
58
+ for (const [vp, size] of Object.entries(viewports)) {
59
+ const ck = captureKey(rev.id, vp, mode)
60
+ const cached = noCache ? null : await fromCache({ root, output: config.output, key: ck })
61
+ if (cached && cached.record.criteria_hash === criteriaHash(node)) {
62
+ const record = await restoreCached({ cached, runDir: run.dir, paths, node: node.id, locale: rev.locale, viewport: vp })
63
+ const file = paths.step(node.id, rev.locale, vp, 'capture.json')
64
+ await writeJson(file, record)
65
+ await addCapture(run.dir, { type: 'capture', node: node.id, locale: rev.locale, viewport: vp, file: relative(run.dir, file), at: new Date().toISOString() })
66
+ reused++
67
+ } else todo[vp] = size
68
+ }
69
+ if (Object.keys(todo).length) {
70
+ const since = new Date().toISOString()
71
+ await page.goto(`${server.url}/${rev.entry}`, { waitUntil: 'load' })
72
+ const results = await captureStep({ page, node, locale: rev.locale, viewports: todo, run, maxChars: config.evaluators?.jev?.max_state_chars, since, extra: { live }, designSummary: true })
73
+ for (const r of results) { await toCache({ root, output: config.output, key: captureKey(rev.id, r.viewport, mode), runDir: run.dir, record: r }); rendered++ }
74
+ }
75
+ say(`✓ ${node.id} ${rev.locale} revision ${rev.id.slice(0, 8)}${Object.keys(todo).length ? '' : ' (unchanged, reused)'}`)
76
+ }
77
+ } finally {
78
+ await browser.close()
79
+ await server.close()
80
+ }
81
+ await writeJson(join(run.dir, 'revisions.json'), revisions)
82
+ say(`\nRendered ${rendered} capture(s), reused ${reused} from unchanged revisions.`)
83
+ if (missing.length) { say(`\nSteps without a design yet:\n - ${missing.join('\n - ')}`) }
84
+ return { rendered, reused, revisions }
85
+ }
86
+
87
+ /** Scenario snapshot the service keeps with the draft (so it can compose versions without the CLI). */
88
+ export function snapshotOf (scenario, run) {
89
+ const pos = Object.fromEntries(layout(scenario).map((p) => [p.id, { col: p.col, lane: p.lane }]))
90
+ return { title: scenario.title, kind: scenario.kind || 'task', source: scenario.source || null, file: run.meta.scenario_file, project: run.meta.project || null, nodes: scenario.nodes, edges: scenario.edges, lanes: scenario.lanes || [], sessions: scenario.sessions || null, layout: pos }
91
+ }
92
+
93
+ export async function uploadBlobs ({ client, org, files, say }) {
94
+ // files: Map hash → { abs, ext }
95
+ const hashes = [...files.keys()]
96
+ const missing = new Set()
97
+ for (let i = 0; i < hashes.length; i += 1000) for (const h of (await client.request('POST', `/orgs/${org}/blobs/missing`, { hashes: hashes.slice(i, i + 1000) })).missing) missing.add(h)
98
+ let bytes = 0
99
+ const queue = [...missing]
100
+ const worker = async () => {
101
+ while (queue.length) {
102
+ const h = queue.shift()
103
+ const f = files.get(h)
104
+ const raw = await readFile(f.abs)
105
+ const gz = TEXT_TYPES.has(f.ext) && raw.length > 512
106
+ const body = gz ? gzipSync(raw, { level: 6 }) : raw
107
+ await client.upload(`/orgs/${org}/blobs/${h}`, body, f.abs, { 'X-QAwalk-Type': f.ext, ...(gz ? { 'Content-Encoding': 'gzip' } : {}) })
108
+ bytes += body.length
109
+ }
110
+ }
111
+ await Promise.all(Array.from({ length: 6 }, worker))
112
+ say(`Uploaded ${missing.size} new file(s) (${Math.round(bytes / 1024)} kB); ${files.size - missing.size} already stored.`)
113
+ }
114
+
115
+ /**
116
+ * Two-way sync of the current design run with the shared draft. Steps changed locally are pushed as
117
+ * revisions (with captures), steps changed only in the draft are pulled into the folder, steps changed on
118
+ * both sides are conflicts unless --mine or --theirs names them.
119
+ */
120
+ export async function syncDesign ({ config, root, run, scenario, env, client, args, say, push = true, pull = true, render }) {
121
+ if (env.kind !== 'design') throw new UserError(`sync works on design environments; the current run is on ${env.name}.`)
122
+ const dir = designDir(env, root)
123
+ const org = config.org
124
+ const branch = typeof args.branch === 'string' ? args.branch : 'main'
125
+ const sid = enc(scenario.id)
126
+ const locales = run.meta.scope.locales
127
+ const bases = (await readBase(dir)) || {}
128
+ const baseKey = `${scenario.id}@${branch}`
129
+ const base = bases[baseKey]?.heads || {}
130
+ const remote = await client.request('GET', `/orgs/${org}/scenarios/${sid}/draft?branch=${enc(branch)}`)
131
+ const remoteHeads = Object.fromEntries((remote.heads || []).map((h) => [headKey(h.node, h.locale, h.state), h.revision]))
132
+ const remoteCaps = Object.fromEntries((remote.heads || []).map((h) => [headKey(h.node, h.locale, h.state), h.captures || {}]))
133
+ const snapshot = snapshotOf(scenario, run)
134
+ const snapshotChanged = !remote.exists || remote.snapshot_hash !== await sha256Hex(JSON.stringify(snapshot))
135
+ let local = await localRevisions({ dir, scenario, locales, allLocales: locales, project: run.meta.project?.slug })
136
+ // the folder mirrors one branch at a time (like a git checkout): steps that mirror another branch are
137
+ // taken from this one instead of being pushed into it
138
+ const folderKey = bases.folder || baseKey
139
+ const mirror = folderKey !== baseKey ? (bases[folderKey]?.heads || {}) : null
140
+ if (mirror) say(`Switching the folder from ${folderKey.split('@')[1]} to ${branch}.`)
141
+ const mine = new Set(typeof args.mine === 'string' ? args.mine.split(',') : [])
142
+ const theirs = new Set(typeof args.theirs === 'string' ? args.theirs.split(',') : [])
143
+ const toPush = [], toPull = [], conflicts = []
144
+ for (const key of new Set([...Object.keys(local), ...Object.keys(remoteHeads)])) {
145
+ const l = local[key]?.id || null, b = base[key] || null, r = remoteHeads[key] || null
146
+ const node = key.split('|')[0]
147
+ if (l && l === r) continue
148
+ const switching = mirror && l && l === mirror[key] && l !== r
149
+ const localChanged = l && l !== b && !switching
150
+ const remoteChanged = (r && r !== b) || switching
151
+ if (localChanged && remoteChanged && !mine.has(node) && !theirs.has(node)) conflicts.push({ key, local: l, remote: r })
152
+ else if (localChanged && !theirs.has(node)) toPush.push(key)
153
+ else if (remoteChanged || theirs.has(node)) toPull.push(key)
154
+ }
155
+ if (conflicts.length) {
156
+ say(`Steps changed both here and in the draft (another person or agent):\n - ${conflicts.map((c) => `${c.key.split('|').slice(0, 2).join(' ')}: yours ${c.local.slice(0, 8)}, draft ${c.remote.slice(0, 8)}`).join('\n - ')}\nKeep yours with --mine <step,…> or take the draft with --theirs <step,…>, then sync again.`)
157
+ process.exitCode = 1
158
+ if (!push || args.strict) return { conflicts }
159
+ }
160
+ // pull first: those steps are unchanged locally
161
+ if (pull && toPull.length) {
162
+ for (const key of toPull) {
163
+ const rev = await client.request('GET', `/orgs/${org}/revisions/${remoteHeads[key]}`)
164
+ const [node, locale] = key.split('|')
165
+ // files of this step's own folders (never _shared/ or another locale's folder) that the pulled revision no longer has are removed
166
+ const own = (p) => p.startsWith(`${node}/${locale}/`) || (p.startsWith(`${node}/`) && !locales.includes(p.split('/')[1]))
167
+ const usedElsewhere = new Set(Object.entries(local).filter(([k]) => k !== key && k.startsWith(`${node}|`)).flatMap(([, r]) => Object.keys(r.files)))
168
+ for (const p of Object.keys(local[key]?.files || {})) if (own(p) && !(p in rev.files) && !usedElsewhere.has(p)) await rm(join(dir, p), { force: true })
169
+ for (const [p, hash] of Object.entries(rev.files)) {
170
+ const target = join(dir, p)
171
+ try { if (await sha256Hex(await readFile(target)) === hash) continue } catch {}
172
+ const res = await client.fetch(`/orgs/${org}/revisions/${rev.id}/files/${p.split('/').map(enc).join('/')}`)
173
+ let buf = Buffer.from(await res.arrayBuffer())
174
+ if (res.headers.get('X-QAwalk-Encoding') === 'gzip') buf = gunzipSync(buf)
175
+ await mkdir(dirname(target), { recursive: true })
176
+ await writeFile(target, buf)
177
+ }
178
+ say(`↓ ${node} ${locale} took the draft's revision ${rev.id.slice(0, 8)} (by ${rev.actor}${rev.agent ? ` via ${rev.agent}` : ''})`)
179
+ }
180
+ local = await localRevisions({ dir, scenario, locales, allLocales: locales, project: run.meta.project?.slug })
181
+ }
182
+ let result = null
183
+ // same revision, but its draft captures were rendered by another renderer or for other criteria: send them again
184
+ const vpsAll = Object.keys(run.meta.scope.viewports)
185
+ const toRefresh = push ? Object.keys(local).filter((key) => local[key].id === remoteHeads[key] && !toPush.includes(key) && vpsAll.some((vp) => { const rc = remoteCaps[key]?.[vp]; const node = scenario.nodes.find((n) => n.id === local[key].node); return !rc || rc.renderer !== RENDERER || rc.criteria_hash !== criteriaHash(node) })) : []
186
+ if (push && (toPush.length || toRefresh.length || snapshotChanged)) {
187
+ // steps to push need captures of their current revision in every viewport of the run
188
+ const vps = Object.keys(run.meta.scope.viewports)
189
+ const capturesByKey = async () => new Map(latestCaptures(await readCaptures(run.dir)).map((c) => [`${c.node}|${c.locale}|${c.viewport}`, c]))
190
+ let byKey = await capturesByKey()
191
+ const stale = []
192
+ const sending = [...toPush, ...toRefresh]
193
+ for (const key of sending) {
194
+ const [node, locale] = key.split('|')
195
+ const want = criteriaHash(scenario.nodes.find((n) => n.id === node))
196
+ for (const vp of vps) {
197
+ const c = byKey.get(`${node}|${locale}|${vp}`)
198
+ const rec = c ? await readJson(join(run.dir, c.file), null) : null
199
+ if (!rec || rec.live?.revision !== local[key].id || rec.criteria_hash !== want || !rec.design) stale.push(node)
200
+ }
201
+ }
202
+ if (stale.length) { await render([...new Set(stale)]); byKey = await capturesByKey() }
203
+ // every pushed capture carries a valid evaluation of the scenario's criteria (deterministic checks are free)
204
+ let unevaluated = false
205
+ for (const key of sending) for (const vp of vps) {
206
+ const [node, locale] = key.split('|')
207
+ const c = byKey.get(`${node}|${locale}|${vp}`)
208
+ const rec = c && await readJson(join(run.dir, c.file), null)
209
+ const ev = await readJson(runPaths(run.dir).step(node, locale, vp, 'eval.json'), null)
210
+ if (rec && ev?.source !== evaluationSource(scenario.nodes.find((n) => n.id === node), rec)) unevaluated = true
211
+ }
212
+ if (unevaluated) { const { evaluateRun } = await import('./evaluate.mjs'); await evaluateRun({ run, scenario, config, root }); say('Evaluated the new captures against the scenario criteria.') }
213
+ const files = new Map()
214
+ const revisions = []
215
+ for (const key of sending) {
216
+ const rev = local[key]
217
+ const node = scenario.nodes.find((n) => n.id === rev.node)
218
+ for (const [p, h] of Object.entries(rev.files)) files.set(h, { abs: join(dir, p), ext: extOf(p) })
219
+ const captures = {}
220
+ for (const vp of vps) {
221
+ const c = byKey.get(`${rev.node}|${rev.locale}|${vp}`)
222
+ if (!c) throw new UserError(`${rev.node} ${rev.locale} ${vp}: no capture; run kaloko walk first.`)
223
+ const rec = await readJson(join(run.dir, c.file))
224
+ if (rec.live?.revision !== rev.id) throw new UserError(`${rev.node} ${rev.locale} ${vp}: capture does not match the folder; run kaloko walk again.`)
225
+ const out = {}
226
+ for (const [k, rel] of [['image', rec.files.image], ['thumb', rec.files.thumb], ['html', rec.files.html]]) {
227
+ if (!rel) continue
228
+ const abs = join(run.dir, rel)
229
+ const h = await sha256Hex(await readFile(abs))
230
+ files.set(h, { abs, ext: extOf(rel) })
231
+ out[k] = h
232
+ }
233
+ const stored = await readJson(runPaths(run.dir).step(rev.node, rev.locale, vp, 'eval.json'), null)
234
+ out.record = slimCapture(rec)
235
+ out.evaluation = stored?.source === evaluationSource(node, rec) ? stored : null
236
+ out.mode = env.tokens_mode || ''
237
+ out.renderer = RENDERER
238
+ captures[vp] = out
239
+ }
240
+ revisions.push({ id: rev.id, node: rev.node, locale: rev.locale, state: rev.state, entry: rev.entry, files: rev.files, tokens: rev.tokens, based_on: base[key] || remoteHeads[key] || null, captures })
241
+ }
242
+ await uploadBlobs({ client, org, files, say })
243
+ const heads = toPush.map((key) => ({ node: local[key].node, locale: local[key].locale, state: local[key].state, revision: local[key].id, base: remoteHeads[key] || null }))
244
+ try {
245
+ result = await client.request('POST', `/orgs/${org}/scenarios/${sid}/draft`, { branch, snapshot, scope: { locales, viewports: run.meta.scope.viewports }, environment: env.name, git_branch: run.meta.git?.branch || null, revisions, heads })
246
+ } catch (e) {
247
+ if (/HTTP 409/.test(e.message)) { say(`${e.message}\nSomeone changed these steps a moment ago; sync again to see them.`); process.exitCode = 1; return { conflicts: true } }
248
+ throw e
249
+ }
250
+ for (const m of result.moved) say(`↑ ${m.node} ${m.locale} revision ${m.revision.slice(0, 8)} saved to the draft`)
251
+ if (toRefresh.length) say(`↑ captures of ${toRefresh.length} unchanged step(s) re-rendered (renderer ${RENDERER} or criteria changed)`)
252
+ if (snapshotChanged && remote.exists) say('↑ scenario snapshot updated (steps, criteria or layout changed)')
253
+ } else if (push && !toPush.length && !remote.exists) {
254
+ // first sync of a new draft with nothing rendered yet
255
+ say('Nothing to sync yet: walk the design first (kaloko walk).')
256
+ }
257
+ const after = await client.request('GET', `/orgs/${org}/scenarios/${sid}/draft?branch=${enc(branch)}`)
258
+ bases.folder = baseKey
259
+ bases[baseKey] = { seq: after.seq, synced_at: new Date().toISOString(), heads: Object.fromEntries((after.heads || []).map((h) => [headKey(h.node, h.locale, h.state), h.revision])) }
260
+ await writeBase(dir, bases)
261
+ const moved = new Set([...toPush, ...toPull])
262
+ const unchanged = Object.keys(local).filter((k) => !moved.has(k) && local[k].id === bases[baseKey].heads[k]).length
263
+ say(`Draft ${scenario.id}${branch !== 'main' ? `@${branch}` : ''} at #${after.seq}: ${toPush.length} pushed, ${toPull.length} pulled, ${unchanged} unchanged.`)
264
+ return { pushed: toPush.length, pulled: toPull.length, conflicts: conflicts.length ? conflicts : null, seq: after.seq }
265
+ }
266
+
267
+ /** Publishes the draft head as a version (a run of the design environment). */
268
+ export async function publishVersion ({ config, scenario, client, args, say }) {
269
+ const branch = typeof args.branch === 'string' ? args.branch : 'main'
270
+ const r = await client.request('POST', `/orgs/${config.org}/scenarios/${enc(scenario.id)}/versions`, { branch, ...(typeof args.name === 'string' ? { name: args.name } : {}) })
271
+ 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}`)
272
+ return r
273
+ }
274
+
275
+ export async function historyCmd ({ config, scenarioId, client, args, say }) {
276
+ const q = new URLSearchParams({ ...(typeof args.step === 'string' ? { step: args.step } : {}), ...(typeof args.locale === 'string' ? { locale: args.locale } : {}) })
277
+ const h = await client.request('GET', `/orgs/${config.org}/scenarios/${enc(scenarioId)}/history?${q}`)
278
+ if (args.json) { say(JSON.stringify(h, null, 2)); return }
279
+ say(`Versions of ${scenarioId}:`)
280
+ for (const v of h.versions) say(` v${String(v.version).padEnd(4)} ${v.at.slice(0, 16).replace('T', ' ')} ${v.verdict.padEnd(9)} ${v.by}${v.name ? ` "${v.name}"` : ''}${v.branch && v.branch !== 'main' ? ` @${v.branch}` : ''} ${v.id}`)
281
+ if (!h.versions.length) say(' none yet (kaloko share on the design environment publishes one)')
282
+ if (h.revisions) {
283
+ say(`\nRevisions of step ${args.step}:`)
284
+ for (const r of h.revisions) say(` ${r.id.slice(0, 8)} ${r.locale.padEnd(6)} ${r.at.slice(0, 16).replace('T', ' ')} ${r.by}${r.agent ? ` via ${r.agent}` : ''}${r.versions.length ? ` in v${r.versions.join(', v')}` : ''}`)
285
+ }
286
+ }
287
+
288
+ export async function referenceCmd ({ config, scenarioId, client, args, say }) {
289
+ const path = `/orgs/${config.org}/scenarios/${enc(scenarioId)}/references`
290
+ const kind = args.design ? 'design' : args.baseline ? 'baseline' : null
291
+ let refs
292
+ if (kind) {
293
+ let runId = typeof args.run === 'string' ? args.run : null
294
+ if (!runId && typeof args.version === 'string') runId = (await client.request('GET', `/orgs/${config.org}/scenarios/${enc(scenarioId)}/versions`)).find((v) => String(v.version) === String(args.version).replace(/^v/, ''))?.id
295
+ if (!runId && !args.off) throw new UserError('Name the run with --run <id> or the version with --version <n> (or pass --off).')
296
+ refs = await client.request('PUT', path, { kind, run_id: args.off ? null : runId, ...(typeof args.branch === 'string' ? { branch: args.branch } : {}) })
297
+ } else refs = await client.request('GET', path)
298
+ if (args.json) { say(JSON.stringify(refs, null, 2)); return }
299
+ say(`References of ${scenarioId}:\n baseline ${refs.baseline || '—'}\n design ${refs.design || '—'}${Object.keys(refs.design_by_branch || {}).filter((b) => b !== 'main').map((b) => `\n design@${b} ${refs.design_by_branch[b]}`).join('')}`)
300
+ }
301
+
302
+ export async function tokensCmd ({ config, root, env, client, args, say, project }) {
303
+ const action = args._[0]
304
+ const tk = await import('./tokens.mjs')
305
+ // tokens.json, or a DTCG resolver (tokens.resolver.json with sets and a theme modifier) next to it
306
+ const folder = env?.kind === 'design' ? designDir(env, root) : root
307
+ let file = typeof args.file === 'string' ? args.file : join(folder, 'tokens.json')
308
+ if (typeof args.file !== 'string') { try { await readFile(join(folder, 'tokens.resolver.json')); file = join(folder, 'tokens.resolver.json') } catch {} }
309
+ /** The set as stored: a resolver gets its file sources inlined so the service holds one document. */
310
+ const readSet = async () => {
311
+ const doc = JSON.parse(await readFile(file, 'utf8'))
312
+ return tk.isResolver(doc) ? tk.inlineResolver(doc, (ref) => readFile(join(dirname(file), ref), 'utf8')) : doc
313
+ }
314
+ const listProblems = (problems) => { if (problems.length) say(`${problems.length} problem(s) in ${relative(process.cwd(), file)}:\n - ${problems.slice(0, 40).join('\n - ')}`) }
315
+ if (action === 'validate') {
316
+ const set = await readSet()
317
+ const problems = tk.validateTokens(set)
318
+ if (args.json) { say(JSON.stringify({ file, problems, modes: tk.modesOf(set), tokens: tk.flattenTokens(set).length }, null, 2)); if (problems.length) process.exitCode = 1; return }
319
+ if (!problems.length) say(`✓ ${relative(process.cwd(), file)}: ${tk.flattenTokens(set).length} tokens${tk.modesOf(set).length ? `, modes ${tk.modesOf(set).join(', ')}` : ''}; names, references and values are fine.`)
320
+ else { listProblems(problems); process.exitCode = 1 }
321
+ return
322
+ }
323
+ if (!project) throw new UserError('Token sets belong to a project: set project: in kaloko.config.yml or pass --project <slug>.')
324
+ const path = `/orgs/${config.org}/projects/${enc(project)}/tokens`
325
+ if (action === 'pull') {
326
+ const t = await client.request('GET', path + (typeof args.version === 'string' ? `?version=${enc(args.version)}` : ''))
327
+ await mkdir(dirname(file), { recursive: true })
328
+ await writeFile(file, JSON.stringify(t.tokens, null, 2) + '\n')
329
+ say(`Token set ${project} v${t.version} → ${relative(process.cwd(), file)}`)
330
+ } else if (action === 'push') {
331
+ const tokens = await readSet()
332
+ const problems = tk.validateTokens(tokens)
333
+ if (problems.length) { listProblems(problems); throw new UserError('Fix the token set first (kaloko tokens validate).') }
334
+ const r = await client.request('PUT', path, { tokens })
335
+ say(r.unchanged ? `Token set ${project} is unchanged (v${r.version}).` : `Token set ${project} v${r.version} saved.`)
336
+ } else if (action === 'check') {
337
+ const { checkTokens } = tk
338
+ const tokens = await readSet()
339
+ listProblems(tk.validateTokens(tokens))
340
+ const dir = env?.kind === 'design' ? designDir(env, root) : null
341
+ if (!dir) throw new UserError('tokens check reads the live folder of a design environment: pass --env <design env>.')
342
+ const report = await checkTokens({ dir, tokens, mode: typeof args.mode === 'string' ? args.mode : env.tokens_mode })
343
+ if (args.json) { say(JSON.stringify(report, null, 2)); return }
344
+ if (!report.offenders.length) say(`✓ Every color, font, size, spacing and radius in ${relative(process.cwd(), dir)} comes from the token set (${report.checked} values).`)
345
+ else { say(`${report.offenders.length} raw value(s) outside the token set:\n - ${report.offenders.slice(0, 40).map((o) => `${o.file}:${o.line} ${o.property}: ${o.value}${o.deprecated ? ` (deprecated token ${o.deprecated}${o.note ? `: ${o.note}` : ''})` : ''}${o.suggest ? ` → ${o.suggest}` : ''}`).join('\n - ')}`); process.exitCode = 1 }
346
+ } else throw new UserError('Usage: kaloko tokens pull|push|check|validate [--project p] [--file tokens.json|tokens.resolver.json] [--mode m] [--json]')
347
+ }
348
+
349
+ /** Writes the live files of a version (or the draft) into a folder: the handoff, or taking back canvas edits. */
350
+ export async function exportLive ({ config, scenarioId, client, args, say, out }) {
351
+ const ref = args.draft ? 'draft' : typeof args.version === 'string' ? `v${String(args.version).replace(/^v/, '')}` : typeof args.run === 'string' ? args.run : 'design'
352
+ const data = await client.request('GET', `/orgs/${config.org}/scenarios/${enc(scenarioId)}/revisions?ref=${enc(ref)}`)
353
+ if (args.views) return forkViews({ config, scenarioId, client, args, say, out, data })
354
+ let written = 0
355
+ const seen = new Set()
356
+ for (const s of data.steps) {
357
+ for (const [p, hash] of Object.entries(s.files)) {
358
+ if (seen.has(p)) continue
359
+ seen.add(p)
360
+ const target = join(out, p)
361
+ try { if (await sha256Hex(await readFile(target)) === hash) continue } catch {}
362
+ const res = await client.fetch(`/orgs/${config.org}/revisions/${s.revision}/files/${p.split('/').map(enc).join('/')}`)
363
+ let buf = Buffer.from(await res.arrayBuffer())
364
+ if (res.headers.get('X-QAwalk-Encoding') === 'gzip') buf = gunzipSync(buf)
365
+ await mkdir(dirname(target), { recursive: true })
366
+ await writeFile(target, buf)
367
+ written++
368
+ }
369
+ }
370
+ say(`Live files of ${scenarioId} at ${ref} → ${relative(process.cwd(), out) || '.'} (${written} written, ${seen.size - written} already up to date, ${data.steps.length} step×locale)`)
371
+ }
372
+
373
+ export async function listDesignFolder (dir) { try { return (await readdir(dir)).filter((n) => !n.startsWith('.')) } catch { return [] } }
374
+
375
+ /** Branches of a design draft: list, create from a version or the draft (optionally linked to a git branch), merge per step. */
376
+ export async function branchCmd ({ config, scenarioId, client, args, say }) {
377
+ const [action, name] = args._
378
+ const base = `/orgs/${config.org}/scenarios/${enc(scenarioId)}/branches`
379
+ if (!action || action === 'list') {
380
+ const list = await client.request('GET', base)
381
+ if (args.json) { say(JSON.stringify(list, null, 2)); return }
382
+ for (const b of list) say(`${b.branch.padEnd(24)} #${String(b.seq).padEnd(4)} ${b.based_on_version ? `from ${b.based_on_version}` : ''}${b.git_branch ? ` git:${b.git_branch}` : ''}${b.merged_at ? ` merged ${b.merged_at.slice(0, 10)}` : ''}`)
383
+ return
384
+ }
385
+ if (action === 'create') {
386
+ if (!name) throw new UserError('Usage: kaloko branch create <name> [--from vN|draft] [--git <branch>]')
387
+ const r = await client.request('POST', base, { name, ...(typeof args.from === 'string' ? { from: args.from } : {}), ...(typeof args.git === 'string' ? { git_branch: args.git } : {}) })
388
+ say(`Branch ${r.branch} created from ${r.based_on_version || 'the draft'} (${r.steps} step×locale). Work on it with --branch ${r.branch}.`)
389
+ return
390
+ }
391
+ if (action === 'merge') {
392
+ if (!name) throw new UserError('Usage: kaloko branch merge <name> [--take-branch a,b] [--keep-main c]')
393
+ const choices = {}
394
+ try {
395
+ const r = await client.request('POST', `${base}/${enc(name)}/merge`, { choices: await choicesFrom(args, client, config, scenarioId, name) })
396
+ say(`Merged ${r.merged} into main: ${r.moved.length} step×locale moved.`)
397
+ } catch (e) {
398
+ if (!/HTTP 409/.test(e.message)) throw e
399
+ say(`${e.message}\nDecide per step: --take-branch <step,…> (the branch wins) or --keep-main <step,…>.`)
400
+ process.exitCode = 1
401
+ }
402
+ return choices
403
+ }
404
+ throw new UserError('Usage: kaloko branch list | create <name> [--from vN] [--git <branch>] | merge <name> [--take-branch a,b] [--keep-main c]')
405
+ }
406
+
407
+ async function choicesFrom (args, client, config, scenarioId, name) {
408
+ const take = new Set(typeof args['take-branch'] === 'string' ? args['take-branch'].split(',') : [])
409
+ const keep = new Set(typeof args['keep-main'] === 'string' ? args['keep-main'].split(',') : [])
410
+ if (!take.size && !keep.size) return {}
411
+ const heads = (await client.request('GET', `/orgs/${config.org}/scenarios/${enc(scenarioId)}/draft?branch=${enc(name)}`)).heads || []
412
+ const out = {}
413
+ for (const h of heads) { const k = `${h.node}|${h.locale}|${h.state}`; if (take.has(h.node)) out[k] = 'branch'; else if (keep.has(h.node)) out[k] = 'main' }
414
+ return out
415
+ }
416
+
417
+ /**
418
+ * Fork into a draft (#42): the HTML views of an app run become design steps in a folder, so a redesign starts
419
+ * from the product as it is. One viewport per step (desktop by default); the first locale is the default page.
420
+ */
421
+ async function forkViews ({ config, client, args, say, out, data }) {
422
+ const vp = typeof args.viewport === 'string' ? args.viewport : 'desktop'
423
+ const views = data.steps.filter((s) => s.state === `view:${vp}`)
424
+ if (!views.length) throw new UserError(`Run ${data.ref} has no ${vp} HTML views (html_view: true on the environment captures them).`)
425
+ const locales = [...new Set(views.map((v) => v.locale))]
426
+ let written = 0
427
+ for (const v of views) {
428
+ const prefix = v.locale === locales[0] ? `${v.node}/` : `${v.node}/${v.locale}/`
429
+ for (const [p] of Object.entries(v.files)) {
430
+ const res = await client.fetch(`/orgs/${config.org}/revisions/${v.revision}/files/${p.split('/').map(enc).join('/')}`)
431
+ let buf = Buffer.from(await res.arrayBuffer())
432
+ if (res.headers.get('X-QAwalk-Encoding') === 'gzip') buf = gunzipSync(buf)
433
+ const target = join(out, prefix, p)
434
+ await mkdir(dirname(target), { recursive: true })
435
+ await writeFile(target, buf)
436
+ written++
437
+ }
438
+ }
439
+ say(`Forked ${views.length} HTML view(s) of ${data.ref} into ${relative(process.cwd(), out) || '.'} as design steps (${written} files). Walk and sync the design environment to start the draft.`)
440
+ }
@@ -0,0 +1,163 @@
1
+ // Kaloko Design core (#42): the bundle contract (qawalk.bundle.v1), revision identity and the
2
+ // reference scanner. Pure and dependency-free (WebCrypto only) so the CLI and the Worker share it.
3
+
4
+ export const BUNDLE_SCHEMA = 'qawalk.bundle.v1'
5
+ export const REVISION_SCHEMA = 'qawalk.revision.v1'
6
+ /** Bumped when captures of the same revision would render differently (probe, encoder, settle). */
7
+ export const RENDERER = 'r3'
8
+
9
+ /** Allowed live file types: extension → content type. Anything else is refused on both sides. */
10
+ export const LIVE_TYPES = {
11
+ html: 'text/html; charset=utf-8',
12
+ css: 'text/css; charset=utf-8',
13
+ js: 'text/javascript; charset=utf-8',
14
+ mjs: 'text/javascript; charset=utf-8',
15
+ json: 'application/json; charset=utf-8',
16
+ svg: 'image/svg+xml',
17
+ woff2: 'font/woff2',
18
+ woff: 'font/woff',
19
+ png: 'image/png',
20
+ jpg: 'image/jpeg',
21
+ jpeg: 'image/jpeg',
22
+ webp: 'image/webp',
23
+ avif: 'image/avif',
24
+ gif: 'image/gif',
25
+ mp4: 'video/mp4',
26
+ webm: 'video/webm',
27
+ txt: 'text/plain; charset=utf-8'
28
+ }
29
+ export const TEXT_TYPES = new Set(['html', 'css', 'js', 'mjs', 'json', 'svg', 'txt'])
30
+
31
+ /** Limits of one revision; plans may lower the per-file size, never raise these. */
32
+ export const BUNDLE_LIMITS = { max_files: 400, max_file_bytes: 10 * 1024 * 1024, max_revision_bytes: 40 * 1024 * 1024, max_path: 240, max_depth: 8 }
33
+
34
+ // bundle-relative paths: no leading slash, no dot segments, no hidden files, conservative characters
35
+ const PATH = /^(?!\/)(?!.*(?:^|\/)\.)[A-Za-z0-9_][A-Za-z0-9._-]*(?:\/[A-Za-z0-9_][A-Za-z0-9._-]*)*$/
36
+ export const SHARED_DIR = '_shared'
37
+ export const TOKENS_FILE = 'tokens.json'
38
+
39
+ export const extOf = (path) => (path.match(/\.([a-z0-9]+)$/i)?.[1] || '').toLowerCase()
40
+ export const contentTypeOf = (path) => LIVE_TYPES[extOf(path)] || null
41
+
42
+ /** Problems with one bundle path (empty when valid). */
43
+ export function pathProblem (path) {
44
+ if (typeof path !== 'string' || !path) return 'empty path'
45
+ if (path.length > BUNDLE_LIMITS.max_path) return `path longer than ${BUNDLE_LIMITS.max_path} characters`
46
+ if (!PATH.test(path) || path.includes('..')) return 'path must be relative, without dot segments, hidden files or unusual characters'
47
+ if (path.split('/').length > BUNDLE_LIMITS.max_depth) return `path deeper than ${BUNDLE_LIMITS.max_depth} folders`
48
+ if (!contentTypeOf(path)) return `file type .${extOf(path) || '?'} is not allowed (allowed: ${Object.keys(LIVE_TYPES).join(', ')})`
49
+ return null
50
+ }
51
+
52
+ /**
53
+ * Validates the file list of one revision: [{ path, bytes }]. Returns a list of problems.
54
+ * The entry must be an HTML file in the list.
55
+ */
56
+ export function bundleProblems (files, entry, limits = BUNDLE_LIMITS) {
57
+ const problems = []
58
+ if (!Array.isArray(files) || !files.length) return ['a revision needs at least its entry file']
59
+ if (files.length > limits.max_files) problems.push(`more than ${limits.max_files} files`)
60
+ let total = 0
61
+ const seen = new Set()
62
+ for (const f of files) {
63
+ const p = pathProblem(f.path)
64
+ if (p) problems.push(`${f.path}: ${p}`)
65
+ if (seen.has(f.path)) problems.push(`${f.path}: listed twice`)
66
+ seen.add(f.path)
67
+ if (!(Number.isInteger(f.bytes) && f.bytes >= 0)) problems.push(`${f.path}: size missing`)
68
+ else if (f.bytes > limits.max_file_bytes) problems.push(`${f.path}: larger than ${Math.round(limits.max_file_bytes / 1048576)} MB`)
69
+ total += f.bytes || 0
70
+ }
71
+ if (total > limits.max_revision_bytes) problems.push(`revision larger than ${Math.round(limits.max_revision_bytes / 1048576)} MB`)
72
+ if (!entry || extOf(entry) !== 'html') problems.push('entry must be an .html file')
73
+ else if (!seen.has(entry)) problems.push(`entry ${entry} is not among the files`)
74
+ return problems
75
+ }
76
+
77
+ /** sha256 hex of bytes (Uint8Array | ArrayBuffer | string). */
78
+ export async function sha256Hex (data) {
79
+ const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data instanceof Uint8Array ? data : new Uint8Array(data)
80
+ const digest = await crypto.subtle.digest('SHA-256', bytes)
81
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')
82
+ }
83
+ export const HASH = /^[0-9a-f]{64}$/
84
+ export const REVISION_ID = /^[0-9a-f]{32}$/
85
+
86
+ /**
87
+ * Identity of a revision: same content, same id, on every machine. `files` maps bundle paths to
88
+ * blob hashes; `tokens` is the token set reference ({ project, version, hash }) or null.
89
+ */
90
+ export async function revisionId ({ scenario, node, locale, state = 'default', kind = 'design', entry, files, tokens = null }) {
91
+ const canonical = JSON.stringify({ v: 1, scenario, node, locale, state, kind, entry, files: Object.keys(files).sort().map((k) => [k, files[k]]), tokens: tokens ? tokens.hash || null : null })
92
+ return (await sha256Hex(canonical)).slice(0, 32)
93
+ }
94
+
95
+ /** Key of a rendered capture: a revision rendered in one viewport and token mode by one renderer. */
96
+ export const captureKey = (revision, viewport, mode = '') => `${revision}|${viewport}|${mode}|${RENDERER}`
97
+
98
+ // ---- reference scanner: which bundle files an entry needs ----
99
+
100
+ const STRIP_QUERY = (u) => u.split('#')[0].split('?')[0]
101
+ const isExternal = (u) => /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i.test(u) // http:, data:, mailto:, protocol-relative
102
+
103
+ /** Relative references found in one text file (unresolved). */
104
+ export function referencesIn (path, text) {
105
+ const ext = extOf(path)
106
+ const out = new Set()
107
+ const add = (u) => { if (u && !isExternal(u.trim()) && !u.trim().startsWith('#')) out.add(STRIP_QUERY(u.trim())) }
108
+ const cssUrls = (t) => {
109
+ for (const m of t.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/gi)) add(m[2])
110
+ for (const m of t.matchAll(/@import\s+(['"])([^'"]+)\1/gi)) add(m[2])
111
+ }
112
+ if (ext === 'html' || ext === 'svg') {
113
+ // resources only: navigation links (<a href>, <area href>, <form action>) point at other steps, not files this step needs
114
+ for (const m of text.matchAll(/\s(?:src|poster|data-src)\s*=\s*(['"])([^'"]*)\1/gi)) add(m[2])
115
+ for (const m of text.matchAll(/<link\b[^>]*\shref\s*=\s*(['"])([^'"]*)\1/gi)) add(m[2])
116
+ for (const m of text.matchAll(/<(?:use|image|feImage)\b[^>]*\s(?:xlink:)?href\s*=\s*(['"])([^'"]*)\1/gi)) add(m[2])
117
+ for (const m of text.matchAll(/\ssrcset\s*=\s*(['"])([^'"]*)\1/gi)) for (const part of m[2].split(',')) add(part.trim().split(/\s+/)[0])
118
+ for (const m of text.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/gi)) cssUrls(m[1])
119
+ for (const m of text.matchAll(/\sstyle\s*=\s*(?:"([^"]*)"|'([^']*)')/gi)) cssUrls(m[1] ?? m[2])
120
+ for (const m of text.matchAll(/<script[^>]*type=["']module["'][^>]*>([\s\S]*?)<\/script>/gi)) for (const r of jsImports(m[1])) add(r)
121
+ } else if (ext === 'css') cssUrls(text)
122
+ else if (ext === 'js' || ext === 'mjs') for (const r of jsImports(text)) add(r)
123
+ return [...out]
124
+ }
125
+ function jsImports (t) {
126
+ const out = []
127
+ for (const m of t.matchAll(/(?:import|export)\s[^'"]*?from\s*(['"])([^'"]+)\1/g)) out.push(m[2])
128
+ for (const m of t.matchAll(/import\s*\(\s*(['"])([^'"]+)\1\s*\)/g)) out.push(m[2])
129
+ for (const m of t.matchAll(/fetch\(\s*(['"])([^'"]+)\1/g)) out.push(m[2])
130
+ return out.filter((u) => u.startsWith('.') || u.startsWith('/'))
131
+ }
132
+
133
+ /** Resolves a reference against the file that contains it; returns a bundle path or null. */
134
+ export function resolveRef (from, ref) {
135
+ const parts = ref.startsWith('/') ? [] : from.split('/').slice(0, -1)
136
+ for (const seg of ref.replace(/^\/+/, '').split('/')) {
137
+ if (seg === '' || seg === '.') continue
138
+ if (seg === '..') { if (!parts.length) return null; parts.pop() } else parts.push(seg)
139
+ }
140
+ let path
141
+ try { path = decodeURIComponent(parts.join('/')) } catch { return null }
142
+ return path || null
143
+ }
144
+
145
+ /**
146
+ * Files one step needs: everything in its own folders plus every bundle file reachable by references
147
+ * from them (typically _shared/…). `read(path)` returns text for text files; `exists(path)` checks.
148
+ */
149
+ export async function closure ({ roots, read, exists }) {
150
+ const need = new Set()
151
+ const queue = [...roots]
152
+ while (queue.length) {
153
+ const p = queue.shift()
154
+ if (need.has(p) || !(await exists(p))) continue
155
+ need.add(p)
156
+ if (!TEXT_TYPES.has(extOf(p))) continue
157
+ for (const ref of referencesIn(p, await read(p))) {
158
+ const r = resolveRef(p, ref)
159
+ if (r && !need.has(r)) queue.push(r)
160
+ }
161
+ }
162
+ return [...need].sort()
163
+ }