@blockrun/franklin 3.8.9 → 3.8.11
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/dist/agent/error-classifier.js +1 -0
- package/dist/agent/llm.d.ts +7 -0
- package/dist/agent/llm.js +48 -7
- package/dist/agent/loop.js +5 -1
- package/dist/banner.js +33 -0
- package/dist/commands/doctor.js +10 -2
- package/dist/index.js +2 -2
- package/dist/ui/app.js +28 -2
- package/dist/version-check.d.ts +39 -0
- package/dist/version-check.js +134 -0
- package/package.json +1 -1
package/dist/agent/llm.d.ts
CHANGED
|
@@ -27,6 +27,13 @@ export interface LLMClientOptions {
|
|
|
27
27
|
chain: Chain;
|
|
28
28
|
debug?: boolean;
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Extract the most human-readable message from an error body.
|
|
32
|
+
* Some gateways wrap provider errors multiple times, e.g.
|
|
33
|
+
* `{"error":{"message":"{\"error\":{\"message\":\"...\"}}"}}`.
|
|
34
|
+
* Peel those layers so the UI doesn't show raw nested JSON.
|
|
35
|
+
*/
|
|
36
|
+
export declare function extractApiErrorMessage(errorBody: string): string;
|
|
30
37
|
/**
|
|
31
38
|
* Apply Anthropic prompt caching using the `system_and_3` strategy.
|
|
32
39
|
* Pattern from nousresearch/hermes-agent `agent/prompt_caching.py`.
|
package/dist/agent/llm.js
CHANGED
|
@@ -6,6 +6,53 @@
|
|
|
6
6
|
import { getOrCreateWallet, getOrCreateSolanaWallet, createPaymentPayload, createSolanaPaymentPayload, parsePaymentRequired, extractPaymentDetails, solanaKeyToBytes, SOLANA_NETWORK, } from '@blockrun/llm';
|
|
7
7
|
import { USER_AGENT } from '../config.js';
|
|
8
8
|
import { ThinkTagStripper } from './think-tag-stripper.js';
|
|
9
|
+
/**
|
|
10
|
+
* Extract the most human-readable message from an error body.
|
|
11
|
+
* Some gateways wrap provider errors multiple times, e.g.
|
|
12
|
+
* `{"error":{"message":"{\"error\":{\"message\":\"...\"}}"}}`.
|
|
13
|
+
* Peel those layers so the UI doesn't show raw nested JSON.
|
|
14
|
+
*/
|
|
15
|
+
export function extractApiErrorMessage(errorBody) {
|
|
16
|
+
const visited = new Set();
|
|
17
|
+
const walk = (value, depth = 0) => {
|
|
18
|
+
// Some providers wrap the real message under error.message as a JSON
|
|
19
|
+
// string, which adds another object/string hop. Allow a few layers of
|
|
20
|
+
// nesting without risking runaway recursion.
|
|
21
|
+
if (depth > 8 || visited.has(value))
|
|
22
|
+
return null;
|
|
23
|
+
if (value && (typeof value === 'object' || typeof value === 'string')) {
|
|
24
|
+
visited.add(value);
|
|
25
|
+
}
|
|
26
|
+
if (typeof value === 'string') {
|
|
27
|
+
const trimmed = value.trim();
|
|
28
|
+
if (trimmed) {
|
|
29
|
+
try {
|
|
30
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
31
|
+
const parsed = JSON.parse(trimmed);
|
|
32
|
+
const nested = walk(parsed, depth + 1);
|
|
33
|
+
if (nested)
|
|
34
|
+
return nested;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch { /* plain string — use as-is below */ }
|
|
38
|
+
}
|
|
39
|
+
return trimmed || null;
|
|
40
|
+
}
|
|
41
|
+
if (!value || typeof value !== 'object')
|
|
42
|
+
return null;
|
|
43
|
+
const obj = value;
|
|
44
|
+
for (const key of ['error', 'message', 'detail', 'reason']) {
|
|
45
|
+
if (key in obj) {
|
|
46
|
+
const nested = walk(obj[key], depth + 1);
|
|
47
|
+
if (nested)
|
|
48
|
+
return nested;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
};
|
|
53
|
+
const extracted = walk(errorBody) ?? errorBody;
|
|
54
|
+
return extracted.replace(/\s+/g, ' ').trim();
|
|
55
|
+
}
|
|
9
56
|
// ─── Anthropic Prompt Caching ─────────────────────────────────────────────
|
|
10
57
|
/**
|
|
11
58
|
* Apply Anthropic prompt caching using the `system_and_3` strategy.
|
|
@@ -265,13 +312,7 @@ export class ModelClient {
|
|
|
265
312
|
}
|
|
266
313
|
if (!response.ok) {
|
|
267
314
|
const errorBody = await response.text().catch(() => 'unknown error');
|
|
268
|
-
|
|
269
|
-
let message = errorBody;
|
|
270
|
-
try {
|
|
271
|
-
const parsed = JSON.parse(errorBody);
|
|
272
|
-
message = parsed?.error?.message || parsed?.message || errorBody;
|
|
273
|
-
}
|
|
274
|
-
catch { /* not JSON — use raw text */ }
|
|
315
|
+
const message = extractApiErrorMessage(errorBody);
|
|
275
316
|
yield {
|
|
276
317
|
kind: 'error',
|
|
277
318
|
payload: { status: response.status, message },
|
package/dist/agent/loop.js
CHANGED
|
@@ -346,6 +346,9 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
346
346
|
activeTools.add(activateToolCap.spec.name);
|
|
347
347
|
const allToolDefs = [...capabilityMap.values()].map(c => c.spec);
|
|
348
348
|
const buildCallToolDefs = () => dynamicTools ? allToolDefs.filter(t => activeTools.has(t.name)) : allToolDefs;
|
|
349
|
+
const buildActiveCapabilityMap = () => dynamicTools
|
|
350
|
+
? new Map([...capabilityMap.entries()].filter(([name]) => activeTools.has(name)))
|
|
351
|
+
: capabilityMap;
|
|
349
352
|
const maxTurns = config.maxTurns ?? 15;
|
|
350
353
|
const workDir = config.workingDir ?? process.cwd();
|
|
351
354
|
const permissions = new PermissionManager(config.permissionMode ?? 'default', config.permissionPromptFn);
|
|
@@ -649,8 +652,9 @@ export async function interactiveSession(config, getUserInput, onEvent, onAbortR
|
|
|
649
652
|
let usage;
|
|
650
653
|
let stopReason;
|
|
651
654
|
// Create streaming executor for concurrent tool execution
|
|
655
|
+
const activeCapabilityMap = buildActiveCapabilityMap();
|
|
652
656
|
const streamExec = new StreamingExecutor({
|
|
653
|
-
handlers:
|
|
657
|
+
handlers: activeCapabilityMap,
|
|
654
658
|
scope: {
|
|
655
659
|
workingDir: workDir,
|
|
656
660
|
abortSignal: abort.signal,
|
package/dist/banner.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
+
import { kickoffVersionCheck, getAvailableUpdate } from './version-check.js';
|
|
2
3
|
// ─── Ben Franklin portrait ─────────────────────────────────────────────────
|
|
3
4
|
//
|
|
4
5
|
// Generated once, at build time, from the Joseph Duplessis 1785 oil painting
|
|
@@ -82,6 +83,38 @@ function padVisible(s, targetWidth) {
|
|
|
82
83
|
return s + '\x1b[0m' + ' '.repeat(targetWidth - current);
|
|
83
84
|
}
|
|
84
85
|
export function printBanner(version) {
|
|
86
|
+
const style = process.env.FRANKLIN_BANNER?.toLowerCase();
|
|
87
|
+
if (style === 'full' || style === 'legacy') {
|
|
88
|
+
printLegacyBanner(version);
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
printCompactBanner(version);
|
|
92
|
+
}
|
|
93
|
+
// Kick off a background refresh for *next* startup, and print a hint now
|
|
94
|
+
// if the cache already knows about a newer version. All wrapped in
|
|
95
|
+
// try/catch because a banner should never be the reason startup breaks.
|
|
96
|
+
try {
|
|
97
|
+
kickoffVersionCheck();
|
|
98
|
+
const update = getAvailableUpdate();
|
|
99
|
+
if (update) {
|
|
100
|
+
console.log(chalk.yellow('⟳ ') +
|
|
101
|
+
chalk.bold(`Franklin ${update.latest}`) +
|
|
102
|
+
chalk.dim(` available — you have ${update.current}`));
|
|
103
|
+
console.log(chalk.dim(' Run: ') +
|
|
104
|
+
chalk.bold('npm install -g @blockrun/franklin@latest'));
|
|
105
|
+
console.log('');
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch { /* version-check is best-effort; never block startup */ }
|
|
109
|
+
}
|
|
110
|
+
function printCompactBanner(version) {
|
|
111
|
+
const title = chalk.bold.hex(GOLD_START)('FRANKLIN');
|
|
112
|
+
const meta = chalk.dim(` · blockrun.ai · v${version}`);
|
|
113
|
+
console.log(`${title}${meta}`);
|
|
114
|
+
console.log(chalk.dim('The AI agent with a wallet'));
|
|
115
|
+
console.log('');
|
|
116
|
+
}
|
|
117
|
+
function printLegacyBanner(version) {
|
|
85
118
|
const termWidth = process.stdout.columns ?? 80;
|
|
86
119
|
const useSideBySide = termWidth >= MIN_WIDTH_FOR_PORTRAIT;
|
|
87
120
|
if (useSideBySide) {
|
package/dist/commands/doctor.js
CHANGED
|
@@ -17,6 +17,7 @@ import os from 'node:os';
|
|
|
17
17
|
import { setupAgentWallet, setupAgentSolanaWallet, } from '@blockrun/llm';
|
|
18
18
|
import { loadChain, API_URLS, VERSION, BLOCKRUN_DIR } from '../config.js';
|
|
19
19
|
import { isTelemetryEnabled, readAllRecords } from '../telemetry/store.js';
|
|
20
|
+
import { getAvailableUpdate, kickoffVersionCheck } from '../version-check.js';
|
|
20
21
|
async function runChecks() {
|
|
21
22
|
const out = [];
|
|
22
23
|
// ── 1. Runtime ────────────────────────────────────────────────────
|
|
@@ -29,10 +30,17 @@ async function runChecks() {
|
|
|
29
30
|
remedy: nodeMajor >= 20 ? undefined : 'Upgrade Node.js: https://nodejs.org',
|
|
30
31
|
});
|
|
31
32
|
// ── 2. Franklin version ───────────────────────────────────────────
|
|
33
|
+
// Kick the daily cache refresh so subsequent doctor runs carry fresh
|
|
34
|
+
// data. Current run uses whatever's already cached.
|
|
35
|
+
kickoffVersionCheck();
|
|
36
|
+
const update = getAvailableUpdate();
|
|
32
37
|
out.push({
|
|
33
38
|
name: 'Franklin',
|
|
34
|
-
status: 'ok',
|
|
35
|
-
detail:
|
|
39
|
+
status: update ? 'warn' : 'ok',
|
|
40
|
+
detail: update
|
|
41
|
+
? `v${VERSION} — update available: v${update.latest}`
|
|
42
|
+
: `v${VERSION}`,
|
|
43
|
+
remedy: update ? 'npm install -g @blockrun/franklin@latest' : undefined,
|
|
36
44
|
});
|
|
37
45
|
// ── 3. BLOCKRUN_DIR writable ──────────────────────────────────────
|
|
38
46
|
try {
|
package/dist/index.js
CHANGED
|
@@ -265,7 +265,7 @@ if (firstArg === 'solana' || firstArg === 'base') {
|
|
|
265
265
|
saveChain(firstArg);
|
|
266
266
|
const startOpts = parseStartFlags(args, 1);
|
|
267
267
|
await startCommand(startOpts);
|
|
268
|
-
process.exit(0);
|
|
268
|
+
process.exit(process.exitCode ?? 0);
|
|
269
269
|
}
|
|
270
270
|
else if (!firstArg || firstArg.startsWith('-')) {
|
|
271
271
|
if (hasAnyFlag(args, HELP_FLAGS) && hasStartOnlyFlag(args)) {
|
|
@@ -281,7 +281,7 @@ else if (!firstArg || firstArg.startsWith('-')) {
|
|
|
281
281
|
// No subcommand or only flags — treat as 'start' with flags
|
|
282
282
|
const startOpts = parseStartFlags(args, 0);
|
|
283
283
|
await startCommand(startOpts);
|
|
284
|
-
process.exit(0);
|
|
284
|
+
process.exit(process.exitCode ?? 0);
|
|
285
285
|
}
|
|
286
286
|
else {
|
|
287
287
|
program.parse();
|
package/dist/ui/app.js
CHANGED
|
@@ -39,12 +39,16 @@ function useTerminalSize() {
|
|
|
39
39
|
}
|
|
40
40
|
function InputBox({ input, setInput, onSubmit, model, balance, sessionCost, queued, queuedCount, focused, busy, contextPct, vimMode, onVimModeChange }) {
|
|
41
41
|
const { cols } = useTerminalSize();
|
|
42
|
+
// Avoid drawing right up to the terminal edge. Several terminals auto-wrap
|
|
43
|
+
// a full-width border glyph onto the next row, which leaves "ghost" top
|
|
44
|
+
// borders behind on re-render after errors / status changes.
|
|
45
|
+
const boxWidth = Math.max(20, cols - 2);
|
|
42
46
|
const placeholder = busy
|
|
43
47
|
? (queued
|
|
44
48
|
? `⏎ ${queuedCount ?? 1} queued: ${queued.slice(0, 40)}`
|
|
45
49
|
: 'Working...')
|
|
46
50
|
: 'Type a message...';
|
|
47
|
-
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { borderStyle: "round", borderDimColor: true, paddingX: 1, width:
|
|
51
|
+
return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { borderStyle: "round", borderDimColor: true, paddingX: 1, width: boxWidth, children: [busy && !input ? _jsxs(Text, { color: "yellow", children: [_jsx(Spinner, { type: "dots" }), " "] }) : null, _jsx(Box, { flexGrow: 1, children: vimMode ? (_jsx(VimInput, { value: input, onChange: setInput, onSubmit: onSubmit, placeholder: placeholder, focus: focused !== false, showMode: true, onModeChange: onVimModeChange })) : (_jsx(TextInput, { value: input, onChange: setInput, onSubmit: onSubmit, placeholder: placeholder, focus: focused !== false })) })] }), _jsx(Box, { marginLeft: 1, children: _jsxs(Text, { dimColor: true, children: [busy ? _jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }) : null, busy ? ' ' : '', shortModelName(model), " \u00B7 ", balance, sessionCost > 0.00001 ? _jsxs(Text, { color: "yellow", children: [" -$", sessionCost.toFixed(4)] }) : '', contextPct !== undefined && contextPct > 0 ? (() => {
|
|
48
52
|
// Visual context bar: ▓▓▓▓▓▓░░░░ 75%
|
|
49
53
|
const filled = Math.round(contextPct / 10);
|
|
50
54
|
const empty = 10 - filled;
|
|
@@ -52,6 +56,28 @@ function InputBox({ input, setInput, onSubmit, model, balance, sessionCost, queu
|
|
|
52
56
|
return (_jsxs(Text, { children: [' ', _jsx(Text, { color: barColor, children: '▓'.repeat(filled) }), _jsx(Text, { dimColor: true, children: '░'.repeat(empty) }), _jsxs(Text, { color: barColor, children: [' ', contextPct, "%"] })] }));
|
|
53
57
|
})() : null, (queuedCount ?? 0) > 0 ? _jsxs(Text, { color: "cyan", children: [" \u00B7 ", queuedCount, " queued"] }) : null, ' · esc'] }) })] }));
|
|
54
58
|
}
|
|
59
|
+
function formatAgentErrorForDisplay(error) {
|
|
60
|
+
const lines = error.split('\n').map((line) => line.trim()).filter(Boolean);
|
|
61
|
+
const tipIndex = lines.findIndex((line) => /^tip:/i.test(line));
|
|
62
|
+
const mainLines = tipIndex >= 0 ? lines.slice(0, tipIndex) : lines;
|
|
63
|
+
const tipLines = tipIndex >= 0 ? lines.slice(tipIndex) : [];
|
|
64
|
+
let main = mainLines.join(' ').replace(/\s+/g, ' ').trim();
|
|
65
|
+
let tip = tipLines.join(' ').replace(/\s+/g, ' ').trim();
|
|
66
|
+
const labelMatch = /^\[([^\]]+)\]\s*/.exec(main);
|
|
67
|
+
const label = labelMatch?.[1];
|
|
68
|
+
if (labelMatch)
|
|
69
|
+
main = main.slice(labelMatch[0].length).trim();
|
|
70
|
+
if (tip)
|
|
71
|
+
tip = tip.replace(/^tip:\s*/i, '');
|
|
72
|
+
const out = ['**Request failed**'];
|
|
73
|
+
if (label)
|
|
74
|
+
out.push(`- Type: ${label}`);
|
|
75
|
+
if (main)
|
|
76
|
+
out.push(`- Message: ${main}`);
|
|
77
|
+
if (tip)
|
|
78
|
+
out.push(`- Tip: ${tip}`);
|
|
79
|
+
return out.join('\n');
|
|
80
|
+
}
|
|
55
81
|
function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain, startWithPicker, onSubmit, onModelChange, onAbort, onExit, }) {
|
|
56
82
|
const { exit } = useApp();
|
|
57
83
|
const [input, setInput] = useState('');
|
|
@@ -635,7 +661,7 @@ function RunCodeApp({ initialModel, workDir, walletAddress, walletBalance, chain
|
|
|
635
661
|
setStreamText('');
|
|
636
662
|
}
|
|
637
663
|
if (event.reason === 'error' && event.error) {
|
|
638
|
-
commitResponse(
|
|
664
|
+
commitResponse(formatAgentErrorForDisplay(event.error), turnTokensRef.current, turnCostRef.current);
|
|
639
665
|
showStatus('Turn failed', 'error', 5000);
|
|
640
666
|
}
|
|
641
667
|
else if (event.reason === 'aborted') {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update-check utility.
|
|
3
|
+
*
|
|
4
|
+
* Quietly pings npm once per day, caches the latest published version in
|
|
5
|
+
* `~/.blockrun/version-check.json`, and exposes a sync helper the CLI
|
|
6
|
+
* uses to nudge users when they're behind. The check is non-blocking:
|
|
7
|
+
* fire-and-forget at startup, render the notice on the *next* run if the
|
|
8
|
+
* network was slow the first time. Users never wait on it.
|
|
9
|
+
*
|
|
10
|
+
* Respects two opt-outs:
|
|
11
|
+
* - `FRANKLIN_NO_UPDATE_CHECK=1` — explicit user preference
|
|
12
|
+
* - CI-like environments (`CI`, `GITHUB_ACTIONS`, `GITLAB_CI`, etc.)
|
|
13
|
+
*
|
|
14
|
+
* Cache format is intentionally small and forward-compatible: new fields
|
|
15
|
+
* may be added, old fields are tolerated on read.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Compare two semver strings (stripping a leading `v` and any pre-release
|
|
19
|
+
* tag after a hyphen — we don't publish prereleases). Returns:
|
|
20
|
+
* 1 if a > b
|
|
21
|
+
* -1 if a < b
|
|
22
|
+
* 0 if equal or unparseable
|
|
23
|
+
*/
|
|
24
|
+
export declare function compareSemver(a: string, b: string): number;
|
|
25
|
+
/**
|
|
26
|
+
* Refresh the cache in the background if it's stale. Never throws, never
|
|
27
|
+
* awaited by callers — result lands before next startup.
|
|
28
|
+
*/
|
|
29
|
+
export declare function kickoffVersionCheck(): void;
|
|
30
|
+
export interface UpdateInfo {
|
|
31
|
+
current: string;
|
|
32
|
+
latest: string;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Sync check against the cached latest. Returns update info if the cache
|
|
36
|
+
* knows of a newer version, null otherwise. Safe to call before the first
|
|
37
|
+
* background check settles — returns null (we don't speculate).
|
|
38
|
+
*/
|
|
39
|
+
export declare function getAvailableUpdate(): UpdateInfo | null;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Update-check utility.
|
|
3
|
+
*
|
|
4
|
+
* Quietly pings npm once per day, caches the latest published version in
|
|
5
|
+
* `~/.blockrun/version-check.json`, and exposes a sync helper the CLI
|
|
6
|
+
* uses to nudge users when they're behind. The check is non-blocking:
|
|
7
|
+
* fire-and-forget at startup, render the notice on the *next* run if the
|
|
8
|
+
* network was slow the first time. Users never wait on it.
|
|
9
|
+
*
|
|
10
|
+
* Respects two opt-outs:
|
|
11
|
+
* - `FRANKLIN_NO_UPDATE_CHECK=1` — explicit user preference
|
|
12
|
+
* - CI-like environments (`CI`, `GITHUB_ACTIONS`, `GITLAB_CI`, etc.)
|
|
13
|
+
*
|
|
14
|
+
* Cache format is intentionally small and forward-compatible: new fields
|
|
15
|
+
* may be added, old fields are tolerated on read.
|
|
16
|
+
*/
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { BLOCKRUN_DIR, VERSION, USER_AGENT } from './config.js';
|
|
20
|
+
const CACHE_FILE = path.join(BLOCKRUN_DIR, 'version-check.json');
|
|
21
|
+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // once per day
|
|
22
|
+
const FETCH_TIMEOUT_MS = 2_000;
|
|
23
|
+
const REGISTRY_URL = 'https://registry.npmjs.org/@blockrun/franklin/latest';
|
|
24
|
+
function isDisabled() {
|
|
25
|
+
if (process.env.FRANKLIN_NO_UPDATE_CHECK === '1')
|
|
26
|
+
return true;
|
|
27
|
+
// Common CI signals — no point nagging headless runners.
|
|
28
|
+
return Boolean(process.env.CI ||
|
|
29
|
+
process.env.GITHUB_ACTIONS ||
|
|
30
|
+
process.env.GITLAB_CI ||
|
|
31
|
+
process.env.BUILDKITE ||
|
|
32
|
+
process.env.CIRCLECI);
|
|
33
|
+
}
|
|
34
|
+
function readCache() {
|
|
35
|
+
try {
|
|
36
|
+
const raw = fs.readFileSync(CACHE_FILE, 'utf-8');
|
|
37
|
+
const parsed = JSON.parse(raw);
|
|
38
|
+
if (typeof parsed.latestVersion === 'string' && typeof parsed.checkedAt === 'number') {
|
|
39
|
+
return { latestVersion: parsed.latestVersion, checkedAt: parsed.checkedAt };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch { /* no cache, bad JSON, first run — all handled by returning null */ }
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
function writeCache(data) {
|
|
46
|
+
try {
|
|
47
|
+
fs.mkdirSync(BLOCKRUN_DIR, { recursive: true });
|
|
48
|
+
fs.writeFileSync(CACHE_FILE, JSON.stringify(data, null, 2), { mode: 0o600 });
|
|
49
|
+
}
|
|
50
|
+
catch { /* cache write is best-effort — never crash startup over it */ }
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Compare two semver strings (stripping a leading `v` and any pre-release
|
|
54
|
+
* tag after a hyphen — we don't publish prereleases). Returns:
|
|
55
|
+
* 1 if a > b
|
|
56
|
+
* -1 if a < b
|
|
57
|
+
* 0 if equal or unparseable
|
|
58
|
+
*/
|
|
59
|
+
export function compareSemver(a, b) {
|
|
60
|
+
const parse = (v) => {
|
|
61
|
+
const core = v.replace(/^v/, '').split('-')[0];
|
|
62
|
+
const parts = core.split('.').map(n => Number.parseInt(n, 10));
|
|
63
|
+
if (parts.length !== 3 || parts.some(Number.isNaN))
|
|
64
|
+
return null;
|
|
65
|
+
return [parts[0], parts[1], parts[2]];
|
|
66
|
+
};
|
|
67
|
+
const pa = parse(a);
|
|
68
|
+
const pb = parse(b);
|
|
69
|
+
if (!pa || !pb)
|
|
70
|
+
return 0;
|
|
71
|
+
for (let i = 0; i < 3; i++) {
|
|
72
|
+
if (pa[i] > pb[i])
|
|
73
|
+
return 1;
|
|
74
|
+
if (pa[i] < pb[i])
|
|
75
|
+
return -1;
|
|
76
|
+
}
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
async function fetchLatestVersion() {
|
|
80
|
+
const ctrl = new AbortController();
|
|
81
|
+
const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
|
|
82
|
+
try {
|
|
83
|
+
const res = await fetch(REGISTRY_URL, {
|
|
84
|
+
signal: ctrl.signal,
|
|
85
|
+
headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' },
|
|
86
|
+
});
|
|
87
|
+
if (!res.ok)
|
|
88
|
+
return null;
|
|
89
|
+
const body = (await res.json());
|
|
90
|
+
return typeof body.version === 'string' ? body.version : null;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Refresh the cache in the background if it's stale. Never throws, never
|
|
101
|
+
* awaited by callers — result lands before next startup.
|
|
102
|
+
*/
|
|
103
|
+
export function kickoffVersionCheck() {
|
|
104
|
+
if (isDisabled())
|
|
105
|
+
return;
|
|
106
|
+
const cache = readCache();
|
|
107
|
+
if (cache && Date.now() - cache.checkedAt < CHECK_INTERVAL_MS)
|
|
108
|
+
return;
|
|
109
|
+
// Detach from the event loop so startup doesn't wait on network.
|
|
110
|
+
// Node keeps the process alive until the fetch settles, which is fine
|
|
111
|
+
// for short-lived CLI invocations and irrelevant for long-running
|
|
112
|
+
// interactive sessions.
|
|
113
|
+
void fetchLatestVersion().then(latest => {
|
|
114
|
+
if (!latest)
|
|
115
|
+
return;
|
|
116
|
+
writeCache({ latestVersion: latest, checkedAt: Date.now() });
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Sync check against the cached latest. Returns update info if the cache
|
|
121
|
+
* knows of a newer version, null otherwise. Safe to call before the first
|
|
122
|
+
* background check settles — returns null (we don't speculate).
|
|
123
|
+
*/
|
|
124
|
+
export function getAvailableUpdate() {
|
|
125
|
+
if (isDisabled())
|
|
126
|
+
return null;
|
|
127
|
+
const cache = readCache();
|
|
128
|
+
if (!cache)
|
|
129
|
+
return null;
|
|
130
|
+
if (compareSemver(cache.latestVersion, VERSION) > 0) {
|
|
131
|
+
return { current: VERSION, latest: cache.latestVersion };
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
package/package.json
CHANGED