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 +3 -2
- package/skills/kaloko/SKILL.md +5 -2
- package/src/cli.mjs +62 -12
- package/src/lib/cli-args.mjs +69 -6
- package/src/lib/doctor.mjs +11 -1
- package/src/lib/update-check.mjs +132 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kaloko",
|
|
3
|
-
"version": "0.16.
|
|
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",
|
package/skills/kaloko/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
2739
|
-
function
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/lib/cli-args.mjs
CHANGED
|
@@ -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 =
|
|
145
|
+
const unknown = unknownOptions(help, name, args)
|
|
144
146
|
if (!unknown.length) return []
|
|
145
|
-
const lines = unknown.map((k) => {
|
|
146
|
-
|
|
147
|
-
|
|
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
|
}
|
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
|
|
@@ -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
|
+
}
|