kaloko 0.16.0 → 0.16.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kaloko",
3
- "version": "0.16.0",
3
+ "version": "0.16.1",
4
4
  "description": "Your AI agents build; Kaloko walks every screen, puts it on one canvas and records who approved what.",
5
5
  "keywords": [
6
6
  "qa",
@@ -32,7 +32,8 @@
32
32
  "access": "public"
33
33
  },
34
34
  "scripts": {
35
- "test": "node scripts/test.mjs"
35
+ "test": "node scripts/test.mjs",
36
+ "version": "node scripts/cli-catalogue.mjs && git add worker/src/cli-latest.json"
36
37
  },
37
38
  "dependencies": {
38
39
  "ajv": "^8.20.0",
@@ -210,9 +210,12 @@ only when a person says that value may be uploaded.
210
210
  tools (relative URLs, no CDNs, state in the URL, deterministic content,
211
211
  links between steps; what `kaloko design lint` checks):
212
212
  [reference/portable-html.md](reference/portable-html.md)
213
- - Command reference: `kaloko help`; when something does not work (Chrome,
213
+ - Command reference: `kaloko help` (`kaloko help --json` lists every command,
214
+ sub-command and option as data); when something does not work (Chrome,
214
215
  token, environment URL, evaluator key), run `kaloko doctor` first and fix
215
- what it marks ✕.
216
+ what it marks ✕. When a command answers "… is in kaloko X (you have Y)",
217
+ the project's kaloko is too old for it: tell the person and suggest the
218
+ update command from that line; change the dependency only when they agree.
216
219
 
217
220
  Project configuration is `kaloko.config.yml` in the repository root.
218
221
  Project skills may add conventions (paths, test data, locales); follow them
package/src/cli.mjs CHANGED
@@ -39,7 +39,7 @@ import { formatReviewQueue, shareReviewLine } from './lib/review.mjs'
39
39
  import { healthCmd } from './lib/health.mjs'
40
40
  import { htmlViewAllowed } from './lib/htmlview.mjs'
41
41
  import { service, serviceUrl } from './lib/service.mjs'
42
- import { parseArgs, helpFor, checkOptions, closest } from './lib/cli-args.mjs'
42
+ import { parseArgs, helpFor, checkOptions, closest, catalogue, unknownOptions, subcommandsFor, takesWords } from './lib/cli-args.mjs'
43
43
  import { issueCmd, scheduleCmd } from './lib/integrations-cli.mjs'
44
44
  import { login } from './lib/login.mjs'
45
45
  import { initNextSteps, loginNextSteps } from './lib/onboarding.mjs'
@@ -67,6 +67,7 @@ import { startScope, scopeKeys, dimensionLine, baseViewports, engineKeys, appDev
67
67
  import { deviceConfig, prepareAndroid, readyAndroid, finishAndroid, prepareSimulator, applyAppAppearance } from './lib/apps/devices.mjs'
68
68
  import { attachAndroidWebView } from './lib/apps/webview.mjs'
69
69
  import { piiSettings, piiFindings, piiRequested, guardAllow, formatFindings } from './lib/pii.mjs'
70
+ import { updateHint } from './lib/update-check.mjs'
70
71
 
71
72
 
72
73
  /** Options every command takes and the exit codes; the end of kaloko help and of the help of each command. */
@@ -78,9 +79,10 @@ const COMMON_HELP = `Common options (every command)
78
79
  JSON, progress and notes go to stderr, and an error is one line on stderr:
79
80
  {"error":{"message":"…","code":"…","next":"…"}} with the exit code below
80
81
  --help, -h Help for one command, like kaloko help <command> (kaloko help export run: one
81
- sub-command)
82
+ sub-command); kaloko help --json lists every command, sub-command and option
82
83
  --version, -v The version of kaloko (also kaloko version)
83
84
  KALOKO_AGENT=<name> Labels writes by an agent
85
+ KALOKO_NO_UPDATE_CHECK=1 An unknown command or option does not ask kaloko.app whether a newer kaloko has it
84
86
  Durations (--timeout, --wait, --expires, --older-than) take a number in the option's unit or one with s, m, h or d:
85
87
  90s, 10m, 2h, 14d
86
88
 
@@ -2722,11 +2724,18 @@ const commands = {
2722
2724
  /** `kaloko help [command]`: everything, or the entries of one command with the common options. */
2723
2725
  async help (args = { _: [] }) {
2724
2726
  const topic = args._?.[0]
2727
+ // kaloko help --json: every command, sub-command and option as data (kaloko help prune --json: that command)
2728
+ if (args.json) {
2729
+ const all = await cliCatalogue()
2730
+ if (!topic) { say(JSON.stringify(all, null, 2)); return }
2731
+ if (all.commands[topic]) { say(JSON.stringify({ version: all.version, commands: { [topic]: all.commands[topic] } }, null, 2)); return }
2732
+ return unknownCommand(topic, true, args)
2733
+ }
2725
2734
  if (!topic) { say(HELP); return }
2726
2735
  // kaloko help export run: the entries of that sub-command (all of the command's when it has none by that name)
2727
2736
  const text = commands[topic] && topic !== 'help' ? helpFor(HELP, topic, COMMON_HELP, args._?.[1]) : null
2728
2737
  if (text) { say(text); return }
2729
- unknownCommand(topic)
2738
+ return unknownCommand(topic, false, args)
2730
2739
  }
2731
2740
  }
2732
2741
 
@@ -2735,14 +2744,41 @@ async function packageVersion () {
2735
2744
  return JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')).version
2736
2745
  }
2737
2746
 
2738
- /** "There is no command": the nearest command when it looks like a typo, the full help, exit code 2. */
2739
- function unknownCommand (name, json = false) {
2740
- const guess = closest(name, Object.keys(commands))
2741
- const message = `There is no command "${name}".${guess ? ` Did you mean kaloko ${guess}?` : ''}`
2742
- if (json) { console.error(JSON.stringify(jsonError(new UsageError(message, { next: 'kaloko help lists the commands.' })))); process.exitCode = EXIT.usage; return }
2747
+ /** Every command, sub-command and option of this kaloko as data: kaloko help --json, and the service's /cli/latest.json. */
2748
+ export async function cliCatalogue () {
2749
+ return catalogue(HELP, [...Object.keys(commands), 'version'], await packageVersion())
2750
+ }
2751
+
2752
+ /**
2753
+ * "There is no command": when a newer kaloko has it, the line that says which version and how to update; else the
2754
+ * nearest command when it looks like a typo and the full help. Exit code 2 either way.
2755
+ */
2756
+ async function unknownCommand (name, json = false, args = {}) {
2757
+ process.exitCode = EXIT.usage
2758
+ const update = await newerKaloko({ command: name }, args)
2759
+ const guess = update ? null : closest(name, Object.keys(commands))
2760
+ const message = `There is no command "${name}"${update ? ' in this kaloko' : ''}.${guess ? ` Did you mean kaloko ${guess}?` : ''}`
2761
+ if (json) { console.error(JSON.stringify(jsonError(new UsageError(message, { next: update || 'kaloko help lists the commands.' })))); return }
2762
+ if (update) { console.error(`${message}\n${update}`); return }
2743
2763
  console.error(`${message} The commands are below; kaloko help <command> explains one.\n`)
2744
2764
  say(HELP)
2745
- process.exitCode = EXIT.usage
2765
+ }
2766
+
2767
+ /** A usage error whose next step is updating kaloko (printed on its own line). */
2768
+ const updateError = (reason, update) => Object.assign(new UsageError(reason, { next: update }), { update: true })
2769
+
2770
+ /**
2771
+ * The update line for what this kaloko does not know ({ command, sub, options }), or null: only when the newest
2772
+ * released kaloko (the service's /cli/latest.json, cached for a day) is newer and has exactly that. With `detail`:
2773
+ * { update: line, newer: [options the newer kaloko takes] }.
2774
+ */
2775
+ async function newerKaloko (typed, args = {}, detail = false) {
2776
+ try {
2777
+ let config = {}
2778
+ try { ({ config } = await loadConfig(process.cwd(), typeof args.config === 'string' ? args.config : undefined)) } catch {}
2779
+ const r = await updateHint(typed, { installed: await packageVersion(), config, root: packageRoot, args })
2780
+ return detail ? r : r?.line || null
2781
+ } catch { return null }
2746
2782
  }
2747
2783
 
2748
2784
  /** A line of output that is a JSON document (the --json answer), told apart from progress and notes. */
@@ -2782,13 +2818,27 @@ async function runCommand (args, json) {
2782
2818
  if ((!args._.length && args.version) || args._[0] === 'version') { say(json ? JSON.stringify({ version: await packageVersion() }) : await packageVersion()); return }
2783
2819
  const name = args._.shift() || 'help'
2784
2820
  const cmd = commands[name]
2785
- if (!cmd) { if (args.help) say(HELP); else unknownCommand(name, json); return }
2821
+ if (!cmd) { if (args.help) say(HELP); else await unknownCommand(name, json, args); return }
2786
2822
  // kaloko <command> --help: the command's help instead of running it
2787
2823
  if (args.help && name !== 'help') { say(helpFor(HELP, name, COMMON_HELP, args._[0]) || HELP); return }
2824
+ // what this kaloko does not know: a word after a command that takes none (kaloko projects create) and options its
2825
+ // help does not name. A newer kaloko may have them; only then a line says which version and how to update.
2826
+ const word = name !== 'help' && typeof args._[0] === 'string' && !subcommandsFor(HELP, name).has(args._[0]) ? args._[0] : null
2827
+ const unknown = unknownOptions(HELP, name, args)
2788
2828
  try {
2789
- for (const line of checkOptions(HELP, name, args)) console.error(line)
2829
+ if (word && !takesWords(HELP, name)) {
2830
+ const update = await newerKaloko({ command: name, sub: word }, args)
2831
+ if (update) throw updateError(`There is no "kaloko ${name} ${word}" in this kaloko.`, update)
2832
+ }
2833
+ const newer = unknown.length ? await newerKaloko({ command: name, options: unknown }, args, true) : null
2834
+ for (const line of checkOptions(HELP, name, args, newer || {})) console.error(line)
2790
2835
  await cmd(args)
2791
2836
  } catch (error) {
2837
+ // a usage error over a sub-command a newer kaloko has (kaloko tokens <new>): say so instead of only the help
2838
+ if (error instanceof UsageError && !error.update && !error.next && word) {
2839
+ const update = await newerKaloko({ command: name, sub: word }, args)
2840
+ if (update) error = updateError(error.reason, update)
2841
+ }
2792
2842
  const known = error instanceof UserError
2793
2843
  if (json) {
2794
2844
  // the reason in one JSON line on stderr, the documented exit code; a bug also prints its stack before it
@@ -2798,7 +2848,7 @@ async function runCommand (args, json) {
2798
2848
  return
2799
2849
  }
2800
2850
  // a usage mistake: the command's help says what is wrong; no support line, exit code 2
2801
- if (error instanceof UsageError) { console.error(`${error.message}${/kaloko help /.test(error.message) ? '' : `\nkaloko help ${name} shows how to use it.`}`); process.exitCode = EXIT.usage } else if (known) { console.error(`${error.message}\n${SUPPORT_LINE}`); process.exitCode = exitCodeOf(error) } else {
2851
+ if (error instanceof UsageError && error.update) { console.error(`${error.reason}\n${error.next}`); process.exitCode = EXIT.usage } else if (error instanceof UsageError) { console.error(`${error.message}${/kaloko help /.test(error.message) ? '' : `\nkaloko help ${name} shows how to use it.`}`); process.exitCode = EXIT.usage } else if (known) { console.error(`${error.message}\n${SUPPORT_LINE}`); process.exitCode = exitCodeOf(error) } else {
2802
2852
  console.error(`Kaloko stopped on an unexpected error (a bug on our side). Send the output below to ${SUPPORT_EMAIL} with the command you ran.`)
2803
2853
  throw error
2804
2854
  }
@@ -136,13 +136,76 @@ export function closest (word, words) {
136
136
 
137
137
  /**
138
138
  * 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).
139
+ * list of warning lines for the others (they run as before, without the unknown option). `update` is the line that
140
+ * says a newer kaloko takes the options in `newer`: those get no "Did you mean", and the line comes last (as the next
141
+ * step of the error, or as the last warning).
140
142
  */
141
- export function checkOptions (help, name, args) {
143
+ export function checkOptions (help, name, args, { update = null, newer = [] } = {}) {
142
144
  const known = optionsFor(help, name)
143
- const unknown = Object.keys(args).filter((k) => k !== '_' && k !== '' && !known.has(k))
145
+ const unknown = unknownOptions(help, name, args)
144
146
  if (!unknown.length) return []
145
- const lines = unknown.map((k) => { const guess = closest(k, [...known]); return `kaloko ${name} does not take --${k}.${guess ? ` Did you mean --${guess}?` : ''}` })
146
- if (STRICT.has(name) || STRICT.has(`${name} ${args._?.[0]}`)) throw new UsageError(`${lines.join('\n')}\nNothing was changed. kaloko help ${name} lists its options.`)
147
- return lines.map((l) => `! ${l} It is ignored (kaloko help ${name} lists the options).`)
147
+ const lines = unknown.map((k) => {
148
+ if (update && newer.includes(k)) return `kaloko ${name} does not take --${k} in this kaloko.`
149
+ const guess = closest(k, [...known]); return `kaloko ${name} does not take --${k}.${guess ? ` Did you mean --${guess}?` : ''}`
150
+ })
151
+ if (STRICT.has(name) || STRICT.has(`${name} ${args._?.[0]}`)) {
152
+ if (update) throw Object.assign(new UsageError(`${lines.join('\n')}\nNothing was changed.`, { next: update }), { update: true })
153
+ throw new UsageError(`${lines.join('\n')}\nNothing was changed. kaloko help ${name} lists its options.`)
154
+ }
155
+ return [...lines.map((l) => `! ${l} It is ignored (kaloko help ${name} lists the options).`), ...(update ? [update] : [])]
156
+ }
157
+
158
+ /** The usage part of an entry's first line: what follows " <name> " up to the description column. */
159
+ const usageOf = (entry, name) => entry.lines[0].slice(name.length + 3).split(/\s{3,}/)[0]
160
+
161
+ /** The help entries that start with `name` (not the ones that only mention it after " | "). */
162
+ const ownEntries = (help, name) => helpEntries(help).filter((e) => e.lines[0].startsWith(` ${name} `) || e.lines[0] === ` ${name}`)
163
+
164
+ /**
165
+ * The sub-commands of a command with the options each one's help names: "tokens pull|push|check|validate",
166
+ * "export run …", and after the first one the words after " | " ("video list | trace --step <id> | tools").
167
+ */
168
+ export function subcommandsFor (help, name) {
169
+ const subs = new Map()
170
+ for (const e of ownEntries(help, name)) {
171
+ const [first, ...rest] = usageOf(e, name).split(' | ')
172
+ const words = first.match(/^([a-z][a-z-]*(?:\|[a-z][a-z-]*)*)(?=\s|$)/)?.[1].split('|') || []
173
+ if (words.length) for (const seg of rest) { const w = seg.match(/^([a-z][a-z-]*)(?=\s|$)/)?.[1]; if (w) words.push(w) }
174
+ const flags = e.lines.join('\n').match(/--[a-z][a-z0-9-]*/g) || []
175
+ 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)) }
176
+ }
177
+ return subs
178
+ }
179
+
180
+ /**
181
+ * Whether a command takes a word of its own after its name other than a sub-command: "validate <scenario.yml>",
182
+ * "spec [step]", "release [<version>]". "projects [--json]" and "doctor [--json] [--fix]" take none.
183
+ */
184
+ export function takesWords (help, name) {
185
+ return ownEntries(help, name).some((e) => { const u = usageOf(e, name); return !/^[a-z]/.test(u) && /(^|[\s[(])<|\[(?!-)/.test(u) })
186
+ }
187
+
188
+ /**
189
+ * The commands of this kaloko as data (kaloko help --json, and the service's /cli/latest.json): each documented
190
+ * command with its documented and common options and its sub-commands with theirs. Hidden aliases stay out.
191
+ */
192
+ export function catalogue (help, names, version) {
193
+ const commands = {}
194
+ for (const name of [...names].sort()) {
195
+ const entries = helpEntries(help).filter((e) => e.commands.has(name))
196
+ if (!entries.length && name !== 'help' && name !== 'version') continue
197
+ const named = entries.flatMap((e) => e.lines.join('\n').match(/--[a-z][a-z0-9-]*/g) || []).map((f) => f.slice(2))
198
+ const subs = [...subcommandsFor(help, name)].sort(([a], [b]) => a.localeCompare(b))
199
+ commands[name] = {
200
+ flags: [...new Set([...named, ...COMMON_OPTIONS])].sort(),
201
+ ...(subs.length ? { subcommands: Object.fromEntries(subs.map(([s, f]) => [s, { flags: [...f].sort() }])) } : {})
202
+ }
203
+ }
204
+ return { version, commands }
205
+ }
206
+
207
+ /** The options in `args` that `name` does not take. */
208
+ export function unknownOptions (help, name, args) {
209
+ const known = optionsFor(help, name)
210
+ return Object.keys(args).filter((k) => k !== '_' && k !== '' && !known.has(k))
148
211
  }
@@ -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
@@ -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
+ }