kaloko 0.16.0 → 0.17.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 +7 -0
- package/package.json +3 -2
- package/schemas/evidence.v1.json +27 -4
- package/schemas/run.v1.json +19 -0
- package/skills/kaloko/SKILL.md +5 -2
- package/skills/kaloko/reference/collaboration.md +7 -1
- package/skills/kaloko/reference/evidence.md +44 -3
- package/skills/kaloko/reference/execution.md +5 -0
- package/skills/kaloko/reference/mcp.md +2 -1
- package/src/cli.mjs +84 -21
- package/src/lib/build.mjs +2 -0
- package/src/lib/cli-args.mjs +80 -7
- package/src/lib/docs-core.mjs +3 -1
- package/src/lib/doctor.mjs +11 -1
- package/src/lib/evidence-core.mjs +21 -10
- package/src/lib/lanes-core.mjs +61 -0
- package/src/lib/run-refs.mjs +55 -0
- package/src/lib/run.mjs +4 -1
- package/src/lib/scenario.mjs +2 -1
- package/src/lib/signoff.mjs +4 -0
- package/src/lib/update-check.mjs +132 -0
- package/viewer/index.html +45 -9
package/src/lib/cli-args.mjs
CHANGED
|
@@ -50,13 +50,21 @@ export const HIDDEN = {
|
|
|
50
50
|
*/
|
|
51
51
|
export const STRICT = new Set(['prune', 'trash', 'restore', 'keep', 'archive', 'public', 'release', 'comment', 'projects create', 'projects rename', 'projects sync'])
|
|
52
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Options a command takes more than once (kaloko start --ref A --ref B): their value is the list of every value given.
|
|
55
|
+
* Elsewhere the same name keeps the last value (kaloko spec --ref design).
|
|
56
|
+
*/
|
|
57
|
+
export const REPEATABLE = { start: ['ref'] }
|
|
58
|
+
|
|
53
59
|
/**
|
|
54
60
|
* argv → { _: positional, <option>: value | true }. `--key=value` splits at the first `=` only; `--key value` takes
|
|
55
|
-
* the next word unless it is another option; `-h` and `-v` are --help and --version.
|
|
61
|
+
* the next word unless it is another option; `-h` and `-v` are --help and --version. An option REPEATABLE for the
|
|
62
|
+
* command (the first positional) collects its values in a list.
|
|
56
63
|
*/
|
|
57
64
|
export function parseArgs (argv) {
|
|
58
65
|
const args = { _: [] }
|
|
59
66
|
const short = { '-h': 'help', '-v': 'version' }
|
|
67
|
+
const seen = {}
|
|
60
68
|
for (let i = 0; i < argv.length; i++) {
|
|
61
69
|
const a = argv[i]
|
|
62
70
|
if (a.startsWith('--')) {
|
|
@@ -66,9 +74,11 @@ export function parseArgs (argv) {
|
|
|
66
74
|
if (eq >= 0) args[k] = a.slice(eq + 1)
|
|
67
75
|
else if (next && !next.startsWith('--') && !short[next]) args[k] = argv[++i]
|
|
68
76
|
else args[k] = true
|
|
77
|
+
;(seen[k] ||= []).push(args[k])
|
|
69
78
|
} else if (short[a]) args[short[a]] = true
|
|
70
79
|
else args._.push(a)
|
|
71
80
|
}
|
|
81
|
+
for (const k of REPEATABLE[args._[0]] || []) if (seen[k]) args[k] = seen[k]
|
|
72
82
|
return args
|
|
73
83
|
}
|
|
74
84
|
|
|
@@ -136,13 +146,76 @@ export function closest (word, words) {
|
|
|
136
146
|
|
|
137
147
|
/**
|
|
138
148
|
* Options `args` has that `name` does not take: a UsageError for commands in STRICT (nothing has happened yet), a
|
|
139
|
-
* list of warning lines for the others (they run as before, without the unknown option).
|
|
149
|
+
* list of warning lines for the others (they run as before, without the unknown option). `update` is the line that
|
|
150
|
+
* says a newer kaloko takes the options in `newer`: those get no "Did you mean", and the line comes last (as the next
|
|
151
|
+
* step of the error, or as the last warning).
|
|
140
152
|
*/
|
|
141
|
-
export function checkOptions (help, name, args) {
|
|
153
|
+
export function checkOptions (help, name, args, { update = null, newer = [] } = {}) {
|
|
142
154
|
const known = optionsFor(help, name)
|
|
143
|
-
const unknown =
|
|
155
|
+
const unknown = unknownOptions(help, name, args)
|
|
144
156
|
if (!unknown.length) return []
|
|
145
|
-
const lines = unknown.map((k) => {
|
|
146
|
-
|
|
147
|
-
|
|
157
|
+
const lines = unknown.map((k) => {
|
|
158
|
+
if (update && newer.includes(k)) return `kaloko ${name} does not take --${k} in this kaloko.`
|
|
159
|
+
const guess = closest(k, [...known]); return `kaloko ${name} does not take --${k}.${guess ? ` Did you mean --${guess}?` : ''}`
|
|
160
|
+
})
|
|
161
|
+
if (STRICT.has(name) || STRICT.has(`${name} ${args._?.[0]}`)) {
|
|
162
|
+
if (update) throw Object.assign(new UsageError(`${lines.join('\n')}\nNothing was changed.`, { next: update }), { update: true })
|
|
163
|
+
throw new UsageError(`${lines.join('\n')}\nNothing was changed. kaloko help ${name} lists its options.`)
|
|
164
|
+
}
|
|
165
|
+
return [...lines.map((l) => `! ${l} It is ignored (kaloko help ${name} lists the options).`), ...(update ? [update] : [])]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The usage part of an entry's first line: what follows " <name> " up to the description column. */
|
|
169
|
+
const usageOf = (entry, name) => entry.lines[0].slice(name.length + 3).split(/\s{3,}/)[0]
|
|
170
|
+
|
|
171
|
+
/** The help entries that start with `name` (not the ones that only mention it after " | "). */
|
|
172
|
+
const ownEntries = (help, name) => helpEntries(help).filter((e) => e.lines[0].startsWith(` ${name} `) || e.lines[0] === ` ${name}`)
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The sub-commands of a command with the options each one's help names: "tokens pull|push|check|validate",
|
|
176
|
+
* "export run …", and after the first one the words after " | " ("video list | trace --step <id> | tools").
|
|
177
|
+
*/
|
|
178
|
+
export function subcommandsFor (help, name) {
|
|
179
|
+
const subs = new Map()
|
|
180
|
+
for (const e of ownEntries(help, name)) {
|
|
181
|
+
const [first, ...rest] = usageOf(e, name).split(' | ')
|
|
182
|
+
const words = first.match(/^([a-z][a-z-]*(?:\|[a-z][a-z-]*)*)(?=\s|$)/)?.[1].split('|') || []
|
|
183
|
+
if (words.length) for (const seg of rest) { const w = seg.match(/^([a-z][a-z-]*)(?=\s|$)/)?.[1]; if (w) words.push(w) }
|
|
184
|
+
const flags = e.lines.join('\n').match(/--[a-z][a-z0-9-]*/g) || []
|
|
185
|
+
for (const w of words) { if (!subs.has(w)) subs.set(w, new Set()); for (const f of flags) subs.get(w).add(f.slice(2)) }
|
|
186
|
+
}
|
|
187
|
+
return subs
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Whether a command takes a word of its own after its name other than a sub-command: "validate <scenario.yml>",
|
|
192
|
+
* "spec [step]", "release [<version>]". "projects [--json]" and "doctor [--json] [--fix]" take none.
|
|
193
|
+
*/
|
|
194
|
+
export function takesWords (help, name) {
|
|
195
|
+
return ownEntries(help, name).some((e) => { const u = usageOf(e, name); return !/^[a-z]/.test(u) && /(^|[\s[(])<|\[(?!-)/.test(u) })
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The commands of this kaloko as data (kaloko help --json, and the service's /cli/latest.json): each documented
|
|
200
|
+
* command with its documented and common options and its sub-commands with theirs. Hidden aliases stay out.
|
|
201
|
+
*/
|
|
202
|
+
export function catalogue (help, names, version) {
|
|
203
|
+
const commands = {}
|
|
204
|
+
for (const name of [...names].sort()) {
|
|
205
|
+
const entries = helpEntries(help).filter((e) => e.commands.has(name))
|
|
206
|
+
if (!entries.length && name !== 'help' && name !== 'version') continue
|
|
207
|
+
const named = entries.flatMap((e) => e.lines.join('\n').match(/--[a-z][a-z0-9-]*/g) || []).map((f) => f.slice(2))
|
|
208
|
+
const subs = [...subcommandsFor(help, name)].sort(([a], [b]) => a.localeCompare(b))
|
|
209
|
+
commands[name] = {
|
|
210
|
+
flags: [...new Set([...named, ...COMMON_OPTIONS])].sort(),
|
|
211
|
+
...(subs.length ? { subcommands: Object.fromEntries(subs.map(([s, f]) => [s, { flags: [...f].sort() }])) } : {})
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return { version, commands }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** The options in `args` that `name` does not take. */
|
|
218
|
+
export function unknownOptions (help, name, args) {
|
|
219
|
+
const known = optionsFor(help, name)
|
|
220
|
+
return Object.keys(args).filter((k) => k !== '_' && k !== '' && !known.has(k))
|
|
148
221
|
}
|
package/src/lib/docs-core.mjs
CHANGED
|
@@ -419,12 +419,14 @@ function blocks (lines, o) {
|
|
|
419
419
|
|
|
420
420
|
/** A list item: its first paragraph inline (tight lists), the rest as blocks (nested lists, pictures, code). */
|
|
421
421
|
function listItem (lines, o) {
|
|
422
|
+
// lists nest at most 16 deep, like quotes; deeper items are text (1 000 levels of "- - -" would overflow the stack)
|
|
423
|
+
if ((o.listDepth || 0) >= 16) return inlineHtml(lines.join(' ').trim(), o)
|
|
422
424
|
const first = []
|
|
423
425
|
let i = 0
|
|
424
426
|
while (i < lines.length && lines[i].trim() && !LIST.test(lines[i]) && !/^\s{0,3}(```|~~~)/.test(lines[i])) first.push(lines[i++])
|
|
425
427
|
const { imgs, words } = picturesOf(first.join('\n'))
|
|
426
428
|
const rest = lines.slice(i)
|
|
427
|
-
return `${words ? inlineHtml(words.replace(/\n/g, ' '), o) : ''}${imgs.length ? pics(imgs, o) : ''}${rest.some((l) => l.trim()) ? blocks(rest, { ...o, notice: null }) : ''}`
|
|
429
|
+
return `${words ? inlineHtml(words.replace(/\n/g, ' '), o) : ''}${imgs.length ? pics(imgs, o) : ''}${rest.some((l) => l.trim()) ? blocks(rest, { ...o, notice: null, listDepth: (o.listDepth || 0) + 1 }) : ''}`
|
|
428
430
|
}
|
|
429
431
|
|
|
430
432
|
function pics (imgs, o) {
|
package/src/lib/doctor.mjs
CHANGED
|
@@ -15,6 +15,8 @@ import { visionSettings, VISION_WEIGHT } from './vision-core.mjs'
|
|
|
15
15
|
import { serviceUrl } from './service.mjs'
|
|
16
16
|
import { videoTools, recordingChecks } from './video.mjs'
|
|
17
17
|
import { productChecks } from './changelog.mjs'
|
|
18
|
+
import { versionCheck } from './update-check.mjs'
|
|
19
|
+
import { packageRoot } from './schema.mjs'
|
|
18
20
|
|
|
19
21
|
const portFree = (port) => new Promise((done) => {
|
|
20
22
|
const s = createServer().once('error', () => done(false)).once('listening', () => s.close(() => done(true)))
|
|
@@ -43,8 +45,10 @@ async function reach (url, fetchImpl, ms = 6000, headers = {}) {
|
|
|
43
45
|
* @param {boolean} [o.fix] repair what can be repaired here: install missing Playwright browsers, create the run and
|
|
44
46
|
* scenario folders, write org: from the token into the config, ask macOS for permissions
|
|
45
47
|
* @param {Function} [o.installBrowser] async (engine) => void — installs a Playwright engine (--fix)
|
|
48
|
+
* @param {Function} [o.newest] async () => { detail } — this kaloko and the newest one (default: the service's
|
|
49
|
+
* /cli/latest.json, cached for a day; never a failure)
|
|
46
50
|
*/
|
|
47
|
-
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 = [], installBrowser = defaultInstall, legacy = [] }) {
|
|
51
|
+
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 = [], installBrowser = defaultInstall, legacy = [], newest = null }) {
|
|
48
52
|
const checks = []
|
|
49
53
|
const add = (name, ok, detail = '', hint = '', fixed = null) => checks.push({ name, ok, detail, hint, ...(fixed ? { fixed } : {}) })
|
|
50
54
|
// --fix: install an engine, then look again; returns the version or throws what the install said
|
|
@@ -52,6 +56,12 @@ export async function doctor ({ config, root, file = null, fetchImpl = fetch, la
|
|
|
52
56
|
|
|
53
57
|
const major = Number(process.versions.node.split('.')[0])
|
|
54
58
|
add('node', major >= 20, `Node ${process.versions.node}`, major >= 20 ? '' : 'Kaloko needs Node 20 or newer.')
|
|
59
|
+
// this kaloko, and the newest released one when it is newer: information, never a failure
|
|
60
|
+
try {
|
|
61
|
+
const installed = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')).version
|
|
62
|
+
const v = newest ? await newest(installed) : await versionCheck({ installed, config, env: { ...process.env, ...env }, fetchImpl, root: packageRoot })
|
|
63
|
+
add('kaloko', true, v.detail)
|
|
64
|
+
} catch {}
|
|
55
65
|
|
|
56
66
|
add('config', true, `${file || `${root}/kaloko.config.yml`} · org ${config.org || '—'} · ${Object.keys(config.environments || {}).length} environments`)
|
|
57
67
|
// names from before the rename that still work (legacy.mjs): listed with their new names, never a failure
|
|
@@ -6,12 +6,14 @@
|
|
|
6
6
|
// evidenceOf(record, extras) the evidence document of one run, derived from its signed acceptance record
|
|
7
7
|
// releaseEvidenceOf(...) the evidence document of a release: its runs and one trace matrix over all of them
|
|
8
8
|
// traceRows(evidence) the trace matrix: requirement → criterion → step/locale/viewport → evidence → result
|
|
9
|
-
// → human decision
|
|
9
|
+
// → human decision, with the step's session (role) and lane; TRACE_COLUMNS is the
|
|
10
|
+
// column order of the CSV
|
|
10
11
|
// signatureSubject(...) what an electronic signature is bound to; verifySignatures(record) recomputes it
|
|
11
12
|
// packageManifest / verifyPackageFiles the MANIFEST.json of an evidence package and its check
|
|
12
13
|
//
|
|
13
14
|
// The JSON is the contract; CSV is a convenience for people (cells that start like a formula get a leading ').
|
|
14
15
|
import { canonicalJson, sha256Hex, recordSha256, verifyRecord } from './signoff-core.mjs'
|
|
16
|
+
import { stepPlaces, flowStructure } from './lanes-core.mjs'
|
|
15
17
|
|
|
16
18
|
export const EVIDENCE_SCHEMA = 'qawalk.evidence.v1'
|
|
17
19
|
export const MANIFEST_FILE = 'MANIFEST.json'
|
|
@@ -26,7 +28,9 @@ export const TRACE_COLUMNS = [
|
|
|
26
28
|
'criterion_id', 'criterion_text', 'criterion_type', 'locale', 'viewport', 'evidence_file', 'evidence_sha256',
|
|
27
29
|
'result', 'assessed_by', 'assessor', 'advisory',
|
|
28
30
|
'step_decision', 'step_decided_by', 'step_decided_at', 'step_decided_via', 'step_signature_meaning', 'step_signer_name', 'step_signed_at', 'step_signer_time_zone', 'step_comment',
|
|
29
|
-
'run_verdict', 'run_decided_by', 'run_decided_at', 'run_decided_via', 'run_signature_meaning', 'run_signer_name', 'run_signed_at', 'run_signer_time_zone'
|
|
31
|
+
'run_verdict', 'run_decided_by', 'run_decided_at', 'run_decided_via', 'run_signature_meaning', 'run_signer_name', 'run_signed_at', 'run_signer_time_zone',
|
|
32
|
+
// added later, so they come last: a reader that takes columns by position keeps working
|
|
33
|
+
'session', 'session_title', 'lane', 'lane_label'
|
|
30
34
|
]
|
|
31
35
|
export const AUDIT_COLUMNS = ['at', 'actor', 'action', 'target', 'via', 'agent', 'country', 'source', 'detail']
|
|
32
36
|
|
|
@@ -87,11 +91,15 @@ const sigCols = (s, prefix) => ({ [`${prefix}_signature_meaning`]: s?.meaning ??
|
|
|
87
91
|
/**
|
|
88
92
|
* The evidence document of a run (qawalk.evidence.v1, kind run) from its acceptance record. extras: { record_sha256,
|
|
89
93
|
* signed (the record carries the service's signature), audit (rows of the run's audit trail), generated_at,
|
|
90
|
-
* public_keys (URL of the well-known keys)
|
|
94
|
+
* public_keys (URL of the well-known keys), manifest (the run's run.json: the record names its SHA-256; it gives each
|
|
95
|
+
* step its session and lane, and the scenario its lanes and sessions) }.
|
|
91
96
|
*/
|
|
92
|
-
export function evidenceOf (record, { record_sha256: recordSha = null, signed = !!record?.signature, audit = [], generated_at: at = new Date().toISOString(), public_keys: keysUrl = null } = {}) {
|
|
97
|
+
export function evidenceOf (record, { record_sha256: recordSha = null, signed = !!record?.signature, audit = [], generated_at: at = new Date().toISOString(), public_keys: keysUrl = null, manifest = null } = {}) {
|
|
93
98
|
const r = record || {}
|
|
94
99
|
const run = r.run || {}
|
|
100
|
+
const places = manifest ? stepPlaces(manifest) : null
|
|
101
|
+
const flow = manifest ? flowStructure(manifest) : null
|
|
102
|
+
const placed = (s) => ({ session: places?.get(s.id)?.session ?? s.session ?? null, lane: places?.get(s.id)?.lane ?? s.lane ?? null })
|
|
95
103
|
const ev = {
|
|
96
104
|
schema: EVIDENCE_SCHEMA,
|
|
97
105
|
kind: 'run',
|
|
@@ -102,8 +110,8 @@ export function evidenceOf (record, { record_sha256: recordSha = null, signed =
|
|
|
102
110
|
acceptance: r.acceptance ?? (r.verdict?.verdict === 'accepted' ? 'accepted' : r.verdict?.verdict === 'returned' ? 'returned' : 'none'),
|
|
103
111
|
signing: r.signing ?? ((r.signatures || []).some((x) => !x.withdrawn) ? 'in_kaloko' : 'none'),
|
|
104
112
|
record: { schema: r.schema ?? null, kind: r.kind ?? (r.verdict?.verdict === 'accepted' ? 'acceptance' : 'execution'), sha256: recordSha, signed: !!signed, kid: r.signature?.kid ?? null, issued_at: r.issued_at ?? null, exported_by: r.exported_by ?? null },
|
|
105
|
-
run: { id: run.id ?? null, url: run.url ?? null, project: run.project ?? null, started_at: run.started_at ?? null, finished_at: run.finished_at ?? null, version: run.version ?? null, app_version: run.app_version ?? null },
|
|
106
|
-
scenario: { id: run.scenario?.id ?? null, title: run.scenario?.title ?? null, version: run.scenario?.version ?? null, file: run.scenario?.file ?? null, sha256: run.scenario?.sha256 ?? null, refs: cleanRefs(run.scenario?.refs) },
|
|
113
|
+
run: { id: run.id ?? null, url: run.url ?? null, project: run.project ?? null, started_at: run.started_at ?? null, finished_at: run.finished_at ?? null, version: run.version ?? null, app_version: run.app_version ?? null, refs: Array.isArray(run.refs) ? run.refs.filter((x) => typeof x === 'string') : [], purpose: run.purpose ?? null },
|
|
114
|
+
scenario: { id: run.scenario?.id ?? null, title: run.scenario?.title ?? null, version: run.scenario?.version ?? null, file: run.scenario?.file ?? null, sha256: run.scenario?.sha256 ?? null, refs: cleanRefs(run.scenario?.refs), lanes: flow?.lanes ?? null, sessions: flow?.sessions ?? null },
|
|
107
115
|
environment: run.environment || null,
|
|
108
116
|
git: run.git || null,
|
|
109
117
|
tools: r.tools || null,
|
|
@@ -113,7 +121,7 @@ export function evidenceOf (record, { record_sha256: recordSha = null, signed =
|
|
|
113
121
|
summary: r.summary || null,
|
|
114
122
|
ai: r.ai || null,
|
|
115
123
|
advisory: r.advisory ?? null,
|
|
116
|
-
steps: (r.steps || []).map((s) => ({ ...s, refs: cleanRefs(s.refs), criteria: (s.criteria || []).map((c) => ({ ...c, refs: cleanRefs(c.refs) })) })),
|
|
124
|
+
steps: (r.steps || []).map((s) => ({ ...s, ...placed(s), refs: cleanRefs(s.refs), criteria: (s.criteria || []).map((c) => ({ ...c, refs: cleanRefs(c.refs) })) })),
|
|
117
125
|
signatures: r.signatures || [],
|
|
118
126
|
decision_changes: r.decision_changes || [],
|
|
119
127
|
audit: (audit || []).map(auditEntry)
|
|
@@ -130,7 +138,7 @@ export function auditEntry (a) {
|
|
|
130
138
|
}
|
|
131
139
|
|
|
132
140
|
/** The evidence document of a release (kind release): its runs' evidence and one trace matrix over all of them. */
|
|
133
|
-
export function releaseEvidenceOf ({ release, runs, missing = [], org = null, issuer = null, generated_at: at = new Date().toISOString() }) {
|
|
141
|
+
export function releaseEvidenceOf ({ release, runs, missing = [], org = null, issuer = null, generated_at: at = new Date().toISOString(), filter = null }) {
|
|
134
142
|
return {
|
|
135
143
|
schema: EVIDENCE_SCHEMA,
|
|
136
144
|
kind: 'release',
|
|
@@ -141,6 +149,8 @@ export function releaseEvidenceOf ({ release, runs, missing = [], org = null, is
|
|
|
141
149
|
runs,
|
|
142
150
|
// runs of the release with no evidence: not accepted, deleted or out of reach, each with the reason
|
|
143
151
|
missing,
|
|
152
|
+
// ?ref= / ?purpose= narrowed the release to the runs of one record or purpose (#126)
|
|
153
|
+
...(filter && Object.keys(filter).length ? { filter } : {}),
|
|
144
154
|
trace: runs.flatMap((r) => r.trace || [])
|
|
145
155
|
}
|
|
146
156
|
}
|
|
@@ -161,6 +171,7 @@ export function traceRows (ev) {
|
|
|
161
171
|
const d = s.decision || (s.carried ? { state: 'approved', by: s.carried.approved_by, at: s.carried.approved_at, via: 'carried' } : null)
|
|
162
172
|
const stepCols = { step_decision: d?.state ?? null, step_decided_by: d?.by ?? null, step_decided_at: d?.at ?? null, step_decided_via: d?.via ?? null, ...sigCols(stepSig, 'step'), step_comment: d?.note || null }
|
|
163
173
|
const stepRefs = cleanRefs(s.refs)
|
|
174
|
+
const placeCols = { session: s.session?.key ?? null, session_title: s.session?.title ?? null, lane: s.lane?.index ?? null, lane_label: s.lane?.label ?? null }
|
|
164
175
|
const caps = new Map((s.captures || []).map((c) => [`${c.locale}|${c.viewport}`, c]))
|
|
165
176
|
for (const c of s.criteria || []) {
|
|
166
177
|
const refs = [...cleanRefs(c.refs).map((x) => [x, 'criterion']), ...stepRefs.map((x) => [x, 'step']), ...scenarioRefs.map((x) => [x, 'scenario'])]
|
|
@@ -175,7 +186,7 @@ export function traceRows (ev) {
|
|
|
175
186
|
step_id: s.id, step_title: s.title ?? null, criterion_id: c.id, criterion_text: c.text ?? null, criterion_type: c.type ?? null,
|
|
176
187
|
locale: cell.locale ?? null, viewport: cell.viewport ?? null, evidence_file: cap?.path ?? null, evidence_sha256: cap?.sha256 ?? null,
|
|
177
188
|
result: cell.verdict ?? null, assessed_by: cell.assessed_by ?? null, assessor: cell.assessor ?? null, advisory: !!cell.advisory,
|
|
178
|
-
...stepCols, ...runCols
|
|
189
|
+
...stepCols, ...runCols, ...placeCols
|
|
179
190
|
})
|
|
180
191
|
}
|
|
181
192
|
}
|
|
@@ -287,7 +298,7 @@ export function packageReadme ({ run, service, recordSha }) {
|
|
|
287
298
|
'record/kaloko-signoff.json the acceptance record, signed by Kaloko (Ed25519)',
|
|
288
299
|
'record/kaloko-signoff.pdf the same record for people, with the JSON attached',
|
|
289
300
|
'evidence.json everything below in one machine-readable file (schema qawalk.evidence.v1)',
|
|
290
|
-
'trace.json, trace.csv requirement → criterion → step, locale, viewport → screenshot (SHA-256) → result → decision',
|
|
301
|
+
'trace.json, trace.csv requirement → criterion → step (role, lane), locale, viewport → screenshot (SHA-256) → result → decision',
|
|
291
302
|
'audit.csv the audit trail of the run',
|
|
292
303
|
'captures/ the screenshots the record names',
|
|
293
304
|
'decisions/ files attached to step decisions (approve / return), as the record names them',
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Lanes (sub-processes) and sessions (roles) of a flow: which lane and which person a step belongs to, with the names
|
|
2
|
+
// the canvas shows. Pure, no Node APIs: shared by the scenario layout (scenario.mjs), the quality evidence
|
|
3
|
+
// (evidence-core.mjs, CLI and service) and the MCP run summary. The canvas (viewer/index.html) keeps a byte-identical
|
|
4
|
+
// copy of laneLabels; test/lanes.test.mjs checks that the two stay the same.
|
|
5
|
+
//
|
|
6
|
+
// A scenario names its lanes in `lanes:`. Without them, its sessions draw the lanes: one per session, in declaration
|
|
7
|
+
// order. The lane index of a step is resolved once, by the layout when the run is built (run.json nodes[].lane), and
|
|
8
|
+
// every reader takes it from there.
|
|
9
|
+
|
|
10
|
+
/** Lane index of each session when the sessions draw the lanes (no `lanes` declared); null when `lanes` are declared. */
|
|
11
|
+
export function sessionLanes (lanes, sessions) {
|
|
12
|
+
if (!sessions || (Array.isArray(lanes) && lanes.length)) return null
|
|
13
|
+
return new Map(Object.keys(sessions).map((k, i) => [k, i]))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Names of the lanes, index = lane: the scenario's `lanes`, or (none declared) the titles of its sessions in
|
|
18
|
+
* declaration order. A localized title reads in `locale`, else its default, else its first language. Self-contained:
|
|
19
|
+
* the canvas carries the same function.
|
|
20
|
+
*/
|
|
21
|
+
export function laneLabels (lanes, sessions, locale) {
|
|
22
|
+
const text = (v) => v == null ? '' : typeof v === 'string' ? v : (v[locale] ?? v.default ?? Object.values(v)[0] ?? '')
|
|
23
|
+
return Array.isArray(lanes) && lanes.length ? lanes.map((l) => text(l)) : Object.entries(sessions || {}).map(([k, s]) => text(s?.title) || k)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A plain or localized title as text (the same reading as laneLabels). */
|
|
27
|
+
export const textOf = (v, locale = null) => v == null ? '' : typeof v === 'string' ? v : (v[locale] ?? v.default ?? Object.values(v)[0] ?? '')
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Where each step of a run manifest (qawalk.run.v1) sits: Map step id → { session: { key, title } | null,
|
|
31
|
+
* lane: { index, label } | null }. label is null for a lane without a name (a flow without lanes or sessions, or an
|
|
32
|
+
* extra row the layout opened for an alternative branch).
|
|
33
|
+
*/
|
|
34
|
+
export function stepPlaces (manifest, locale = null) {
|
|
35
|
+
const m = manifest || {}
|
|
36
|
+
const labels = laneLabels(m.lanes, m.sessions, locale)
|
|
37
|
+
const out = new Map()
|
|
38
|
+
for (const n of m.nodes || []) {
|
|
39
|
+
const key = typeof n.session === 'string' && n.session ? n.session : null
|
|
40
|
+
out.set(n.id, {
|
|
41
|
+
session: key ? { key, title: textOf(m.sessions?.[key]?.title, locale) || key } : null,
|
|
42
|
+
lane: Number.isInteger(n.lane) ? { index: n.lane, label: labels[n.lane] || null } : null
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
return out
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The lanes and sessions of a run manifest as a portal lists them: lanes [{ index, label }] (every named lane and
|
|
50
|
+
* every lane a step sits in) and sessions [{ key, title }] in declaration order.
|
|
51
|
+
*/
|
|
52
|
+
export function flowStructure (manifest, locale = null) {
|
|
53
|
+
const m = manifest || {}
|
|
54
|
+
const labels = laneLabels(m.lanes, m.sessions, locale)
|
|
55
|
+
const used = new Set(labels.map((_, i) => i))
|
|
56
|
+
for (const n of m.nodes || []) if (Number.isInteger(n.lane)) used.add(n.lane)
|
|
57
|
+
return {
|
|
58
|
+
lanes: [...used].sort((a, b) => a - b).map((index) => ({ index, label: labels[index] || null })),
|
|
59
|
+
sessions: Object.entries(m.sessions || {}).map(([key, s]) => ({ key, title: textOf(s?.title, locale) || key }))
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Run references and purpose (#126): the external records a run serves (a change-control record CC-2026-001, a
|
|
2
|
+
// validation protocol) and why it was walked (dry_run, formal, smoke). Set with kaloko start --ref / --purpose or
|
|
3
|
+
// KALOKO_REFS / KALOKO_PURPOSE, kept in the run manifest (refs, purpose) and checked again by the service
|
|
4
|
+
// (worker/src/manifest.js), which stores them so GET …/runs?ref= and ?purpose= can filter. No node: imports: the
|
|
5
|
+
// Worker uses it too.
|
|
6
|
+
|
|
7
|
+
/** A reference as people write record ids: letters, digits and . _ - / : # +, starting with a letter or digit. */
|
|
8
|
+
export const RUN_REF = /^[A-Za-z0-9][A-Za-z0-9._:/#+-]{0,79}$/
|
|
9
|
+
export const MAX_RUN_REFS = 20
|
|
10
|
+
export const RUN_PURPOSES = ['dry_run', 'formal', 'smoke']
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* References from options or the environment: an array of values (--ref repeated) or one string, each split at
|
|
14
|
+
* commas and white space. Trimmed, empty pieces dropped, duplicates removed in order. Not validated: see refProblems.
|
|
15
|
+
*/
|
|
16
|
+
export function splitRunRefs (value) {
|
|
17
|
+
const list = Array.isArray(value) ? value : value == null || value === true || value === false ? [] : [value]
|
|
18
|
+
const out = []
|
|
19
|
+
for (const v of list) for (const piece of String(v).split(/[\s,]+/)) if (piece && !out.includes(piece)) out.push(piece)
|
|
20
|
+
return out
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** What is wrong with a list of references ([] when it is fine). */
|
|
24
|
+
export function refProblems (refs) {
|
|
25
|
+
if (!Array.isArray(refs)) return ['refs must be a list of references.']
|
|
26
|
+
const problems = []
|
|
27
|
+
if (refs.length > MAX_RUN_REFS) problems.push(`A run takes at most ${MAX_RUN_REFS} references; this one has ${refs.length}.`)
|
|
28
|
+
for (const r of refs) if (typeof r !== 'string' || !RUN_REF.test(r)) problems.push(`"${String(r).slice(0, 100)}" is not a reference Kaloko keeps: up to 80 letters, digits and . _ - / : # +, starting with a letter or digit.`)
|
|
29
|
+
if (new Set(refs).size !== refs.length) problems.push('refs names a reference twice.')
|
|
30
|
+
return problems
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The references and purpose of kaloko start: --ref (repeated or comma separated) or else KALOKO_REFS, --purpose or
|
|
35
|
+
* else KALOKO_PURPOSE. Returns { refs, purpose, problems }; problems name the option or variable they came from.
|
|
36
|
+
*/
|
|
37
|
+
export function startRefs (args = {}, env = {}) {
|
|
38
|
+
const fromFlag = args.ref !== undefined && args.ref !== true && !(Array.isArray(args.ref) && args.ref.every((v) => v === true))
|
|
39
|
+
const refs = splitRunRefs(fromFlag ? [].concat(args.ref).filter((v) => v !== true) : env.KALOKO_REFS)
|
|
40
|
+
const problems = []
|
|
41
|
+
if (args.ref === true || (Array.isArray(args.ref) && args.ref.includes(true))) problems.push('--ref takes a reference, e.g. --ref CC-2026-001.')
|
|
42
|
+
for (const p of refProblems(refs)) problems.push(`${fromFlag ? '--ref' : 'KALOKO_REFS'}: ${p}`)
|
|
43
|
+
const given = typeof args.purpose === 'string' ? args.purpose : args.purpose === true ? '' : env.KALOKO_PURPOSE
|
|
44
|
+
const purpose = normalizePurpose(given)
|
|
45
|
+
if (args.purpose === true) problems.push(`--purpose takes one of ${RUN_PURPOSES.join(', ')}.`)
|
|
46
|
+
else if (purpose === undefined) problems.push(`${typeof args.purpose === 'string' ? '--purpose' : 'KALOKO_PURPOSE'} is "${String(given).slice(0, 40)}"; use ${RUN_PURPOSES.join(', ')}.`)
|
|
47
|
+
return { refs, purpose: purpose || null, problems }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** dry_run | formal | smoke from what people type (dry-run, Formal); null when empty; undefined when not a purpose. */
|
|
51
|
+
export function normalizePurpose (value) {
|
|
52
|
+
if (value == null || value === '') return null
|
|
53
|
+
const p = String(value).trim().toLowerCase().replace(/-/g, '_')
|
|
54
|
+
return RUN_PURPOSES.includes(p) ? p : undefined
|
|
55
|
+
}
|
package/src/lib/run.mjs
CHANGED
|
@@ -37,7 +37,7 @@ export function newRunId (scenario, date = new Date()) {
|
|
|
37
37
|
return { id: `${today(date)}-${slugify(scenario.id)}-${short}`, short }
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
export async function createRun ({ output, scenario, scenarioFile, env, scope, git, executor, root, project = null, issue = null, appVersion = null, product = null, pii = null }) {
|
|
40
|
+
export async function createRun ({ output, scenario, scenarioFile, env, scope, git, executor, root, project = null, issue = null, refs = [], purpose = null, appVersion = null, product = null, pii = null }) {
|
|
41
41
|
const { id, short } = newRunId(scenario)
|
|
42
42
|
const dir = resolve(root, output, id)
|
|
43
43
|
await mkdir(dir, { recursive: true })
|
|
@@ -54,6 +54,9 @@ export async function createRun ({ output, scenario, scenarioFile, env, scope, g
|
|
|
54
54
|
executor,
|
|
55
55
|
project,
|
|
56
56
|
issue,
|
|
57
|
+
// the records the run serves (change control, protocol) and why it was walked (#126; src/lib/run-refs.mjs)
|
|
58
|
+
...(refs?.length ? { refs } : {}),
|
|
59
|
+
...(purpose ? { purpose } : {}),
|
|
57
60
|
app_version: appVersion,
|
|
58
61
|
// personal data hidden in every capture (#64): { mode: redact | pseudonymize, allow }
|
|
59
62
|
...(pii ? { pii } : {}),
|
package/src/lib/scenario.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { schemaErrors } from './schema.mjs'
|
|
|
4
4
|
import { UserError } from './util.mjs'
|
|
5
5
|
import { expandPacks } from './packs.mjs'
|
|
6
6
|
import { jsGuard } from './assertions.mjs'
|
|
7
|
+
import { sessionLanes } from './lanes-core.mjs'
|
|
7
8
|
|
|
8
9
|
// a scenario holds thousands of values, not millions: aliases (&a, *a) can expand a few kilobytes of YAML into
|
|
9
10
|
// billions that validation and its JSON copy would walk ("billion laughs"), so the expanded size has a ceiling
|
|
@@ -156,7 +157,7 @@ export function layout (scenario) {
|
|
|
156
157
|
const taken = new Set()
|
|
157
158
|
const claim = (c, l) => { while (taken.has(`${c}:${l}`)) l++; taken.add(`${c}:${l}`); return l }
|
|
158
159
|
// sessions without explicit lanes: one swimlane per person, in declaration order
|
|
159
|
-
const sessionLane =
|
|
160
|
+
const sessionLane = sessionLanes(scenario.lanes, scenario.sessions)
|
|
160
161
|
for (const n of [...nodes].sort((a, b) => col.get(a.id) - col.get(b.id) || order.get(a.id) - order.get(b.id))) {
|
|
161
162
|
let wanted = n.lane
|
|
162
163
|
if (wanted == null && sessionLane && n.session != null && sessionLane.has(n.session)) wanted = sessionLane.get(n.session)
|
package/src/lib/signoff.mjs
CHANGED
|
@@ -143,6 +143,10 @@ export async function evidenceCmd ({ config, args, say, client, runId = null })
|
|
|
143
143
|
const product = typeof args.product === 'string' ? args.product : Object.keys(config.products || {}).length === 1 ? Object.keys(config.products)[0] : (config.project || null)
|
|
144
144
|
if (!product) throw new UsageError('Name the product of the release: --product <slug>.')
|
|
145
145
|
path = `/orgs/${config.org}/evidence?product=${encodeURIComponent(product)}&version=${encodeURIComponent(release)}`
|
|
146
|
+
// only the release's runs of one record or purpose (#126)
|
|
147
|
+
const ref = typeof args.ref === 'string' ? args.ref : null
|
|
148
|
+
if (ref) path += `&ref=${encodeURIComponent(ref)}`
|
|
149
|
+
if (typeof args.purpose === 'string') path += `&purpose=${encodeURIComponent(args.purpose)}`
|
|
146
150
|
} else path = `/orgs/${config.org}/runs/${encodeURIComponent(runId)}/evidence`
|
|
147
151
|
const ev = await client.request('GET', path)
|
|
148
152
|
const text = args.csv ? traceCsv(ev.trace || []) : JSON.stringify(ev, null, 2)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// "Is this in a newer kaloko?": when someone types a command, sub-command or option this kaloko does not know, the
|
|
2
|
+
// commands of the newest released kaloko (GET <service>/cli/latest.json, cached for a day) say whether updating
|
|
3
|
+
// helps. A line says so only when that version is newer than this one and has exactly what was typed; offline, a
|
|
4
|
+
// slow answer, an odd answer or KALOKO_NO_UPDATE_CHECK=1 mean no line, never an error or a wait over 1.5 s.
|
|
5
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
7
|
+
import { homedir } from 'node:os'
|
|
8
|
+
import { join, dirname, sep } from 'node:path'
|
|
9
|
+
import { serviceUrl } from './service.mjs'
|
|
10
|
+
import { compareVersions } from './changelog-core.mjs'
|
|
11
|
+
|
|
12
|
+
export const CATALOGUE_PATH = '/cli/latest.json'
|
|
13
|
+
export const TIMEOUT_MS = 1500
|
|
14
|
+
const FRESH_MS = 24 * 3600e3 // a catalogue read from the service
|
|
15
|
+
const RETRY_MS = 3600e3 // no answer: ask again after an hour, not on every typo
|
|
16
|
+
const NAME = /^[a-z][a-z0-9-]*$/
|
|
17
|
+
|
|
18
|
+
/** Off with KALOKO_NO_UPDATE_CHECK=1 (or NO_UPDATE_NOTIFIER, the npm convention) and with --offline. */
|
|
19
|
+
export const updateCheckOff = (env = process.env, args = {}) => !!(truthy(env.KALOKO_NO_UPDATE_CHECK) || truthy(env.NO_UPDATE_NOTIFIER) || args.offline)
|
|
20
|
+
const truthy = (v) => v !== undefined && v !== '' && v !== '0' && v !== 'false'
|
|
21
|
+
|
|
22
|
+
/** Where the catalogue is kept: KALOKO_CACHE_DIR, else the OS cache folder (~/.cache/kaloko, %LOCALAPPDATA%\kaloko). */
|
|
23
|
+
export function cacheDir (env = process.env, os = process.platform) {
|
|
24
|
+
if (env.KALOKO_CACHE_DIR) return env.KALOKO_CACHE_DIR
|
|
25
|
+
if (os === 'win32' && env.LOCALAPPDATA) return join(env.LOCALAPPDATA, 'kaloko', 'cache')
|
|
26
|
+
return join(env.XDG_CACHE_HOME || join(homedir(), '.cache'), 'kaloko')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A catalogue as the service sends it, or null when it is not one: { version, commands: { name: { flags, subcommands } } }. */
|
|
30
|
+
export function validCatalogue (c) {
|
|
31
|
+
if (!c || typeof c !== 'object' || typeof c.version !== 'string' || !/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(c.version)) return null
|
|
32
|
+
if (!c.commands || typeof c.commands !== 'object' || Array.isArray(c.commands)) return null
|
|
33
|
+
const names = (list) => Array.isArray(list) && list.every((f) => typeof f === 'string' && NAME.test(f))
|
|
34
|
+
for (const [name, cmd] of Object.entries(c.commands)) {
|
|
35
|
+
if (!NAME.test(name) || !cmd || !names(cmd.flags)) return null
|
|
36
|
+
if (cmd.subcommands !== undefined && (typeof cmd.subcommands !== 'object' || !Object.entries(cmd.subcommands).every(([s, v]) => NAME.test(s) && v && names(v.flags)))) return null
|
|
37
|
+
}
|
|
38
|
+
return c
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The newest kaloko's catalogue: from the cache while it is fresh, else from the service (one try, `timeout` ms).
|
|
43
|
+
* Null when the check is off or nothing usable came back.
|
|
44
|
+
*/
|
|
45
|
+
export async function newestCatalogue ({ config = {}, env = process.env, fetchImpl = fetch, now = Date.now(), timeout = TIMEOUT_MS, args = {} } = {}) {
|
|
46
|
+
if (updateCheckOff(env, args)) return null
|
|
47
|
+
let url
|
|
48
|
+
try { url = `${serviceUrl(config, env)}${CATALOGUE_PATH}` } catch { return null }
|
|
49
|
+
const file = join(cacheDir(env), 'cli-latest.json')
|
|
50
|
+
let cached = null
|
|
51
|
+
try { cached = JSON.parse(await readFile(file, 'utf8')) } catch {}
|
|
52
|
+
if (cached?.url === url && typeof cached.fetched_at === 'number' && cached.fetched_at <= now) {
|
|
53
|
+
const age = now - cached.fetched_at
|
|
54
|
+
if (cached.catalogue && age < FRESH_MS) return validCatalogue(cached.catalogue)
|
|
55
|
+
if (!cached.catalogue && age < RETRY_MS) return null
|
|
56
|
+
}
|
|
57
|
+
let catalogue = null
|
|
58
|
+
const ctl = new AbortController()
|
|
59
|
+
const timer = setTimeout(() => ctl.abort(), timeout)
|
|
60
|
+
try {
|
|
61
|
+
const res = await fetchImpl(url, { signal: ctl.signal, redirect: 'follow', headers: { Accept: 'application/json', 'User-Agent': 'kaloko-cli' } })
|
|
62
|
+
if (res.ok) catalogue = validCatalogue(JSON.parse(await res.text()))
|
|
63
|
+
} catch {} finally { clearTimeout(timer) }
|
|
64
|
+
try { await mkdir(dirname(file), { recursive: true }); await writeFile(file, JSON.stringify({ url, fetched_at: now, catalogue })) } catch {}
|
|
65
|
+
return catalogue
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* What a newer kaloko has of what was typed, or null: `catalogue` must be newer than `installed` and have the command,
|
|
70
|
+
* the sub-command or the options exactly. { words: 'projects create', version } or { words: 'init --site', version }.
|
|
71
|
+
*/
|
|
72
|
+
export function newerHas (catalogue, installed, { command, sub = null, options = [] }) {
|
|
73
|
+
if (!validCatalogue(catalogue) || !installed || compareVersions(catalogue.version, installed) <= 0) return null
|
|
74
|
+
const cmd = Object.hasOwn(catalogue.commands, command) ? catalogue.commands[command] : null
|
|
75
|
+
if (!cmd) return null
|
|
76
|
+
const out = (words) => ({ words: `${command} ${words}`.trim(), version: catalogue.version })
|
|
77
|
+
if (sub && cmd.subcommands && Object.hasOwn(cmd.subcommands, sub)) return out(sub)
|
|
78
|
+
const known = options.filter((o) => cmd.flags.includes(o))
|
|
79
|
+
if (known.length) return { ...out(known.map((o) => `--${o}`).join(' ')), options: known }
|
|
80
|
+
if (!sub && !options.length) return out('')
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* How this project gets a newer kaloko: the package manager's add command when package.json lists kaloko (lockfile
|
|
86
|
+
* decides npm, pnpm, yarn or bun), npx kaloko@latest when this kaloko runs from the npx cache, else both.
|
|
87
|
+
*/
|
|
88
|
+
export function installCommand ({ cwd = process.cwd(), root = null, words = '' } = {}) {
|
|
89
|
+
const npx = `npx kaloko@latest${words ? ` ${words}` : ''}`
|
|
90
|
+
if (root && root.split(sep).includes('_npx')) return { run: npx }
|
|
91
|
+
for (let dir = cwd; ; dir = dirname(dir)) {
|
|
92
|
+
const pkg = join(dir, 'package.json')
|
|
93
|
+
if (existsSync(pkg)) {
|
|
94
|
+
let p = null
|
|
95
|
+
try { p = JSON.parse(readFileSync(pkg, 'utf8')) } catch {}
|
|
96
|
+
const dev = !!p?.devDependencies?.kaloko
|
|
97
|
+
if (!dev && !p?.dependencies?.kaloko) break
|
|
98
|
+
const d = dev ? ' -D' : ''
|
|
99
|
+
const pm = existsSync(join(dir, 'pnpm-lock.yaml')) ? `pnpm add${d}` : existsSync(join(dir, 'yarn.lock')) ? `yarn add${d}` : existsSync(join(dir, 'bun.lockb')) || existsSync(join(dir, 'bun.lock')) ? `bun add${dev ? ' -d' : ''}` : `npm i${d}`
|
|
100
|
+
return { update: `${pm} kaloko@latest` }
|
|
101
|
+
}
|
|
102
|
+
if (dirname(dir) === dir) break
|
|
103
|
+
}
|
|
104
|
+
return { update: 'npm i -D kaloko@latest', run: npx }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** "kaloko projects create is in kaloko 0.16.0 (you have 0.15.0). Update: npm i -D kaloko@latest" */
|
|
108
|
+
export function updateLine ({ words, version }, installed, how) {
|
|
109
|
+
const what = `kaloko ${words} ${/ --\S+ --/.test(` ${words}`) ? 'are' : 'is'} in kaloko ${version} (you have ${installed}).`
|
|
110
|
+
if (how.update && how.run) return `${what} Update: ${how.update} (or run ${how.run})`
|
|
111
|
+
return how.update ? `${what} Update: ${how.update}` : `${what} Run it with ${how.run}`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* For something this kaloko does not know (`typed`: { command, sub, options }), or null: the cached or fetched
|
|
116
|
+
* catalogue must be newer and have it. { line, update: line, newer: [the options it takes] }.
|
|
117
|
+
*/
|
|
118
|
+
export async function updateHint (typed, { installed, config = {}, env = process.env, fetchImpl = fetch, cwd = process.cwd(), root = null, args = {}, now } = {}) {
|
|
119
|
+
const catalogue = await newestCatalogue({ config, env, fetchImpl, args, ...(now ? { now } : {}) })
|
|
120
|
+
const has = catalogue && newerHas(catalogue, installed, typed)
|
|
121
|
+
if (!has) return null
|
|
122
|
+
const line = updateLine(has, installed, installCommand({ cwd, root, words: has.words }))
|
|
123
|
+
return { line, update: line, newer: has.options || [] }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The doctor line: this version, and the newest one when it is newer (from the same cached catalogue). */
|
|
127
|
+
export async function versionCheck ({ installed, config = {}, env = process.env, fetchImpl = fetch, cwd = process.cwd(), root = null }) {
|
|
128
|
+
const catalogue = await newestCatalogue({ config, env, fetchImpl })
|
|
129
|
+
if (!catalogue || compareVersions(catalogue.version, installed) <= 0) return { installed, newest: catalogue?.version || null, newer: false, detail: catalogue ? `${installed} (the newest)` : installed }
|
|
130
|
+
const how = installCommand({ cwd, root })
|
|
131
|
+
return { installed, newest: catalogue.version, newer: true, detail: `${installed} · a newer kaloko is available: ${catalogue.version} (${how.update ? how.update : how.run})` }
|
|
132
|
+
}
|