scripter-x 1.0.0 → 1.0.2
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 +1 -1
- package/src/commands.js +10 -2
- package/src/index.js +10 -4
- package/src/theme.js +25 -0
- package/src/ui/App.js +15 -3
- package/src/ui/Shell.js +55 -28
- package/src/ui/fullscreen.js +48 -0
- package/src/ui/mouse.js +4 -3
- package/src/update.js +47 -0
package/package.json
CHANGED
package/src/commands.js
CHANGED
|
@@ -237,11 +237,19 @@ export async function configCmd(io, _api, args = {}) {
|
|
|
237
237
|
}
|
|
238
238
|
|
|
239
239
|
export function help(io) {
|
|
240
|
-
io.print(' commands: run · campaigns · export · balance · creds · stop · delete · whoami · config · logout · exit');
|
|
240
|
+
io.print(' commands: run · campaigns · export · balance · creds · stop · delete · whoami · config · update · logout · exit');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export async function update(io) {
|
|
244
|
+
io.print(' ◉ updating ScripterX to the latest version…');
|
|
245
|
+
const { runUpdate } = await import('./update.js');
|
|
246
|
+
const r = await runUpdate();
|
|
247
|
+
if (r.ok) io.print(' ✓ updated! restart `scripterx` to use the new version.');
|
|
248
|
+
else io.print(` ✗ update failed: ${r.output.split('\n')[0]} — try: npm install -g scripter-x@latest`);
|
|
241
249
|
}
|
|
242
250
|
|
|
243
251
|
// dispatch table used by the interactive shell
|
|
244
252
|
export const REGISTRY = {
|
|
245
253
|
run, campaigns, export: exportCmd, balance, creds, stop, delete: del,
|
|
246
|
-
whoami, login, logout, config: configCmd, help,
|
|
254
|
+
whoami, login, logout, config: configCmd, update, help,
|
|
247
255
|
};
|
package/src/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import { paint } from './util.js';
|
|
|
10
10
|
import { ask, askSecret, confirm, askChoice } from './prompt.js';
|
|
11
11
|
import { REGISTRY } from './commands.js';
|
|
12
12
|
import { App } from './ui/App.js';
|
|
13
|
+
import { FullScreen, enterAltScreen } from './ui/fullscreen.js';
|
|
13
14
|
|
|
14
15
|
const VERSION = '1.0.0';
|
|
15
16
|
const h = React.createElement;
|
|
@@ -73,23 +74,28 @@ async function main() {
|
|
|
73
74
|
let username = cfg.username;
|
|
74
75
|
// validate token quietly so the banner shows the right user
|
|
75
76
|
if (cfg.jwt) { try { username = (await new ApiClient().me()).username; } catch { username = null; } }
|
|
77
|
+
// non-blocking update check (shown as a notice line once the shell is up)
|
|
78
|
+
let updateNotice = null;
|
|
79
|
+
try { const { checkForUpdate } = await import('./update.js'); const v = await checkForUpdate(VERSION); if (v) updateNotice = v; } catch { /* */ }
|
|
76
80
|
const ctx = {
|
|
77
|
-
username, server: cfg.server_base_url,
|
|
81
|
+
username, server: cfg.server_base_url, updateNotice,
|
|
78
82
|
dispatch: async (name, io) => {
|
|
79
83
|
const fn = REGISTRY[name] || REGISTRY[name.split(' ')[0]];
|
|
80
84
|
if (!fn) { io.print(` ✗ unknown command: ${name} (type 'help')`); return; }
|
|
81
85
|
await fn(io, null, {});
|
|
82
86
|
},
|
|
83
87
|
};
|
|
84
|
-
|
|
85
|
-
|
|
88
|
+
// fullscreen: own the screen (stable coords, no flicker), restore on exit
|
|
89
|
+
const restore = enterAltScreen();
|
|
90
|
+
const { waitUntilExit } = render(h(FullScreen, null, h(App, { ctx })), { exitOnCtrlC: false });
|
|
91
|
+
try { await waitUntilExit(); } finally { restore(); }
|
|
86
92
|
return;
|
|
87
93
|
}
|
|
88
94
|
|
|
89
95
|
// ── one-shot mode ──
|
|
90
96
|
const map = { run: 'run', campaigns: 'campaigns', export: 'export', balance: 'balance',
|
|
91
97
|
creds: 'creds', stop: 'stop', delete: 'delete', whoami: 'whoami', login: 'login',
|
|
92
|
-
logout: 'logout', config: 'config', help: 'help' };
|
|
98
|
+
logout: 'logout', config: 'config', update: 'update', help: 'help' };
|
|
93
99
|
const fnName = map[cmd];
|
|
94
100
|
if (!fnName) { cliIo.print(` ✗ unknown command: ${cmd}`); console.log(HELP); process.exit(1); }
|
|
95
101
|
const args = {
|
package/src/theme.js
CHANGED
|
@@ -42,6 +42,31 @@ function hexRgb(h) {
|
|
|
42
42
|
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
// ASCII wordmark for the welcome screen (MOTD-style). Each line gets a gradient color.
|
|
46
|
+
export const ASCII_LOGO = [
|
|
47
|
+
' ███████ ██████ ██████ ██ ██████ ████████ ███████ ██████ ██ ██',
|
|
48
|
+
' ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ',
|
|
49
|
+
' ███████ ██ ██████ ██ ██████ ██ █████ ██████ ███ ',
|
|
50
|
+
' ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ██ ',
|
|
51
|
+
' ███████ ██████ ██ ██ ██ ██ ██ ███████ ██ ██ ██ ██',
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
// Return the logo lines, each tinted with one stop of the brand gradient (top→bottom sweep).
|
|
55
|
+
export function logoLines(colors = BRAND_GRAD) {
|
|
56
|
+
const stops = colors.map(hexRgb);
|
|
57
|
+
const n = Math.max(ASCII_LOGO.length - 1, 1);
|
|
58
|
+
return ASCII_LOGO.map((line, i) => {
|
|
59
|
+
const pos = (i / n) * (stops.length - 1);
|
|
60
|
+
const lo = Math.floor(pos);
|
|
61
|
+
const hi = Math.min(lo + 1, stops.length - 1);
|
|
62
|
+
const f = pos - lo;
|
|
63
|
+
const r = Math.round(stops[lo][0] + (stops[hi][0] - stops[lo][0]) * f);
|
|
64
|
+
const g = Math.round(stops[lo][1] + (stops[hi][1] - stops[lo][1]) * f);
|
|
65
|
+
const b = Math.round(stops[lo][2] + (stops[hi][2] - stops[lo][2]) * f);
|
|
66
|
+
return { text: line, color: `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}` };
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
45
70
|
// Return an array of {char, color} for a smooth gradient sweep across `colors`.
|
|
46
71
|
export function gradientChars(s, colors = BRAND_GRAD) {
|
|
47
72
|
const stops = colors.map(hexRgb);
|
package/src/ui/App.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// 'output' → scrollback lines a command printed (tables, results)
|
|
9
9
|
import React, { useState, useEffect, useRef } from 'react';
|
|
10
10
|
import { Box, Text, useApp } from 'ink';
|
|
11
|
-
import { Shell } from './Shell.js';
|
|
11
|
+
import { Shell, WelcomeBox } from './Shell.js';
|
|
12
12
|
import { SelectList, Confirm, TextField } from './components.js';
|
|
13
13
|
import { RunView } from './RunView.js';
|
|
14
14
|
import { COLORS } from '../theme.js';
|
|
@@ -62,6 +62,13 @@ export function App({ ctx }) {
|
|
|
62
62
|
};
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
// show the "update available" notice once on mount
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (ctx.updateNotice) {
|
|
68
|
+
io.current.print(` ⬆ update available: v${ctx.updateNotice} — type 'update' to upgrade`, 'warn');
|
|
69
|
+
}
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
65
72
|
async function runCommand(name) {
|
|
66
73
|
setBusy(true);
|
|
67
74
|
try {
|
|
@@ -74,12 +81,17 @@ export function App({ ctx }) {
|
|
|
74
81
|
}
|
|
75
82
|
}
|
|
76
83
|
|
|
77
|
-
// scrollback (map semantic color names → theme hexes)
|
|
84
|
+
// scrollback (map semantic color names → theme hexes). Show only the most recent lines
|
|
85
|
+
// that fit, so output grows DOWNWARD toward the prompt instead of pushing it off-screen.
|
|
78
86
|
const colorMap = { accent: COLORS.accent, danger: COLORS.danger, success: COLORS.success, warn: COLORS.warn };
|
|
87
|
+
const visibleLines = lines.slice(-200);
|
|
79
88
|
const scroll = h(Box, { flexDirection: 'column' },
|
|
80
|
-
|
|
89
|
+
visibleLines.map((l, i) => h(Text, { key: i, color: colorMap[l.color] || l.color || undefined }, l.text)));
|
|
81
90
|
|
|
91
|
+
// Layout (top → bottom): banner · output · prompt/palette. So command output appears
|
|
92
|
+
// BELOW the banner and ABOVE the prompt — never above the banner.
|
|
82
93
|
return h(Box, { flexDirection: 'column', ref: frameRef },
|
|
94
|
+
h(WelcomeBox, { username: me.username, server: me.server, frameRef, onLogout: () => runCommand('logout') }),
|
|
83
95
|
scroll,
|
|
84
96
|
screen === 'run' && runController ? h(RunView, { controller: runController, embedded: true }) : null,
|
|
85
97
|
screen === 'prompts' && prompt ? h(PromptView, { prompt, frameRef }) : null,
|
package/src/ui/Shell.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// then control returns to the prompt.
|
|
7
7
|
import React, { useState } from 'react';
|
|
8
8
|
import { Box, Text, useInput, useApp } from 'ink';
|
|
9
|
-
import { COLORS, gradientChars } from '../theme.js';
|
|
9
|
+
import { COLORS, gradientChars, logoLines } from '../theme.js';
|
|
10
10
|
import { Clickable, isMouse, parseMouse } from './mouse.js';
|
|
11
11
|
|
|
12
12
|
const h = React.createElement;
|
|
@@ -23,22 +23,59 @@ export const COMMANDS = [
|
|
|
23
23
|
{ name: 'login', desc: 'sign in / switch account' },
|
|
24
24
|
{ name: 'logout', desc: 'sign out' },
|
|
25
25
|
{ name: 'config', desc: 'view / change settings' },
|
|
26
|
+
{ name: 'update', desc: 'update to the latest version' },
|
|
26
27
|
{ name: 'help', desc: 'show all commands' },
|
|
27
28
|
{ name: 'exit', desc: 'quit ScripterX' },
|
|
28
29
|
];
|
|
29
30
|
|
|
30
|
-
function WelcomeBox({ username, server, frameRef, onLogout }) {
|
|
31
|
-
const
|
|
32
|
-
return h(Box, { borderStyle: 'round', borderColor: COLORS.accent, paddingX: 2, paddingY:
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
h(
|
|
36
|
-
|
|
31
|
+
export function WelcomeBox({ username, server, frameRef, onLogout }) {
|
|
32
|
+
const logo = logoLines();
|
|
33
|
+
return h(Box, { borderStyle: 'round', borderColor: COLORS.accent, paddingX: 2, paddingY: 1, flexDirection: 'column' },
|
|
34
|
+
// ASCII wordmark with a top→bottom gradient
|
|
35
|
+
...logo.map((l, i) => h(Text, { key: i, color: l.color, bold: true }, l.text)),
|
|
36
|
+
h(Text, { color: 'gray' }, ' Flipkart session extractor — runs on your residential IP'),
|
|
37
|
+
h(Box, { marginTop: 1 },
|
|
38
|
+
h(Text, { color: username ? COLORS.success : 'gray' }, username ? ' ● ' : ' ○ '),
|
|
39
|
+
h(Text, { color: 'white' }, username || 'not signed in'),
|
|
37
40
|
h(Text, { color: 'gray' }, ` · ${server}`),
|
|
38
|
-
// clickable logout affordance when signed in
|
|
39
41
|
username ? h(Clickable, { frameRef, onClick: onLogout },
|
|
40
42
|
h(Text, { color: COLORS.accent }, ' [ logout ]')) : null),
|
|
41
|
-
h(Text, { color: 'gray' }, "type a command, or
|
|
43
|
+
h(Text, { color: 'gray' }, " type a command, or / to browse all · 1-9 / ↑↓+enter / click to pick · exit to quit"),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// PaletteList — ALL matching commands in a scrolling window with a scrollbar. The window
|
|
48
|
+
// follows the cursor (idx); a ▐ scrollbar on the right shows position when the list
|
|
49
|
+
// overflows. Each row is hover-highlight + click + number selectable.
|
|
50
|
+
const PALETTE_WINDOW = 6;
|
|
51
|
+
function PaletteList({ matches, idx, frameRef, onHover, onPick }) {
|
|
52
|
+
const total = matches.length;
|
|
53
|
+
const cursor = Math.min(idx, total - 1);
|
|
54
|
+
// compute the scroll window so the cursor stays visible
|
|
55
|
+
let start = 0;
|
|
56
|
+
if (total > PALETTE_WINDOW) {
|
|
57
|
+
start = Math.max(0, Math.min(cursor - Math.floor(PALETTE_WINDOW / 2), total - PALETTE_WINDOW));
|
|
58
|
+
}
|
|
59
|
+
const visible = matches.slice(start, start + PALETTE_WINDOW);
|
|
60
|
+
const thumbAt = total > PALETTE_WINDOW
|
|
61
|
+
? Math.round((cursor / (total - 1)) * (visible.length - 1)) : -1;
|
|
62
|
+
|
|
63
|
+
return h(Box, { flexDirection: 'column', marginLeft: 2 },
|
|
64
|
+
visible.map((c, vi) => {
|
|
65
|
+
const i = start + vi;
|
|
66
|
+
const active = i === cursor;
|
|
67
|
+
const num = i < 9 ? `${i + 1}` : ' ';
|
|
68
|
+
const bar = vi === thumbAt ? '▐' : (total > PALETTE_WINDOW ? '│' : ' ');
|
|
69
|
+
const row = h(Box, null,
|
|
70
|
+
h(Text, { color: active ? COLORS.accent : 'gray' }, active ? '▸ ' : ' '),
|
|
71
|
+
h(Text, { color: active ? COLORS.accent : 'gray', dimColor: !active }, `${num} `),
|
|
72
|
+
h(Text, { color: active ? COLORS.accent : 'white', bold: active }, ('/' + c.name).padEnd(12)),
|
|
73
|
+
h(Text, { color: 'gray' }, ' ' + c.desc.padEnd(34).slice(0, 34)),
|
|
74
|
+
h(Text, { color: COLORS.accent }, ' ' + bar));
|
|
75
|
+
return h(Clickable, { key: c.name, frameRef, onHover: () => onHover(i), onClick: () => onPick(c.name) }, row);
|
|
76
|
+
}),
|
|
77
|
+
total > PALETTE_WINDOW
|
|
78
|
+
? h(Text, { color: 'gray' }, ` ${cursor + 1}/${total} · scroll with ↑↓`) : null,
|
|
42
79
|
);
|
|
43
80
|
}
|
|
44
81
|
|
|
@@ -62,11 +99,10 @@ export function Shell({ username, server, onRun, busy, frameRef }) {
|
|
|
62
99
|
// parseMouse fires the clicked row's onClick via the registry — and NEVER type them.
|
|
63
100
|
if (isMouse(input)) { parseMouse(input); return; }
|
|
64
101
|
if (showPalette) {
|
|
65
|
-
// Number 1-9 = pick that
|
|
102
|
+
// Number 1-9 = pick that command (no command name has a digit, so safe).
|
|
66
103
|
if (/^[1-9]$/.test(input)) {
|
|
67
|
-
const shown = matches.slice(0, 8);
|
|
68
104
|
const n = +input - 1;
|
|
69
|
-
if (n <
|
|
105
|
+
if (n < matches.length) { setQuery(''); setPaletteOpen(false); setIdx(0); run(matches[n].name); }
|
|
70
106
|
return;
|
|
71
107
|
}
|
|
72
108
|
if (key.upArrow) { setIdx((i) => (i - 1 + matches.length) % matches.length); return; }
|
|
@@ -91,24 +127,15 @@ export function Shell({ username, server, onRun, busy, frameRef }) {
|
|
|
91
127
|
onRun(name);
|
|
92
128
|
}
|
|
93
129
|
|
|
130
|
+
// NOTE: the welcome banner is rendered by App at the TOP; Shell renders only the
|
|
131
|
+
// prompt + palette so they sit BELOW the command output.
|
|
94
132
|
return h(Box, { flexDirection: 'column' },
|
|
95
|
-
h(
|
|
96
|
-
h(Box, { marginTop: 1 },
|
|
133
|
+
h(Box, null,
|
|
97
134
|
h(Text, { color: COLORS.accent }, '› '),
|
|
98
135
|
h(Text, null, query || ''),
|
|
99
136
|
busy ? h(Text, { color: 'gray' }, ' …') : h(Text, { color: COLORS.accent }, '▏')),
|
|
100
|
-
showPalette ? h(
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const row = h(Box, null,
|
|
104
|
-
h(Text, { color: active ? COLORS.accent : 'gray' }, active ? '▸ ' : ' '),
|
|
105
|
-
h(Text, { color: active ? COLORS.accent : 'gray', dimColor: !active }, `${i + 1} `),
|
|
106
|
-
h(Text, { color: active ? COLORS.accent : 'white', bold: active }, ('/' + c.name).padEnd(14)),
|
|
107
|
-
h(Text, { color: 'gray' }, ' ' + c.desc));
|
|
108
|
-
// hover → highlight this row; click → select + run it
|
|
109
|
-
return h(Clickable, { key: c.name, frameRef,
|
|
110
|
-
onHover: () => setIdx(i),
|
|
111
|
-
onClick: () => { setQuery(''); setPaletteOpen(false); setIdx(0); run(c.name); } }, row);
|
|
112
|
-
})) : null,
|
|
137
|
+
showPalette ? h(PaletteList, { matches, idx, frameRef,
|
|
138
|
+
onHover: (i) => setIdx(i),
|
|
139
|
+
onPick: (name) => { setQuery(''); setPaletteOpen(false); setIdx(0); run(name); } }) : null,
|
|
113
140
|
);
|
|
114
141
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Fullscreen (alternate screen buffer) host for the ink App.
|
|
2
|
+
//
|
|
3
|
+
// WHY: rendering inline (normal terminal buffer) caused two problems —
|
|
4
|
+
// 1. the whole frame redrew on every operation (the flicker / "rerun" the user saw),
|
|
5
|
+
// 2. mouse coordinates drifted with scrollback, so hover/click only worked at the top.
|
|
6
|
+
// The alternate screen buffer (ESC[?1049h, like vim/htop/lazygit) gives the app its own
|
|
7
|
+
// stable screen with a fixed top-left origin: no scrollback interference, clean redraws,
|
|
8
|
+
// and reliable click/hover mapping everywhere.
|
|
9
|
+
//
|
|
10
|
+
// On exit we restore the normal screen so the user's terminal history is untouched.
|
|
11
|
+
import React, { useState, useEffect } from 'react';
|
|
12
|
+
import { Box, useStdout } from 'ink';
|
|
13
|
+
|
|
14
|
+
const h = React.createElement;
|
|
15
|
+
|
|
16
|
+
// Fills the alt-screen to the full terminal size and re-measures on resize, so the
|
|
17
|
+
// layout (and the bottom-anchored math) always matches the real screen.
|
|
18
|
+
export function FullScreen({ children }) {
|
|
19
|
+
const { stdout } = useStdout();
|
|
20
|
+
const [size, setSize] = useState({
|
|
21
|
+
rows: (stdout && stdout.rows) || 30,
|
|
22
|
+
cols: (stdout && stdout.columns) || 100,
|
|
23
|
+
});
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!stdout) return undefined;
|
|
26
|
+
const onResize = () => setSize({ rows: stdout.rows, cols: stdout.columns });
|
|
27
|
+
stdout.on('resize', onResize);
|
|
28
|
+
return () => stdout.off('resize', onResize);
|
|
29
|
+
}, [stdout]);
|
|
30
|
+
|
|
31
|
+
return h(Box, { width: size.cols, height: size.rows, flexDirection: 'column' }, children);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// enterAltScreen / leaveAltScreen — call around render(). Returns a cleanup fn.
|
|
35
|
+
export function enterAltScreen() {
|
|
36
|
+
process.stdout.write('\x1b[?1049h'); // switch to alternate buffer
|
|
37
|
+
process.stdout.write('\x1b[2J\x1b[H'); // clear + home
|
|
38
|
+
process.stdout.write('\x1b[?25l'); // hide cursor (we draw our own ▏)
|
|
39
|
+
let restored = false;
|
|
40
|
+
const restore = () => {
|
|
41
|
+
if (restored) return;
|
|
42
|
+
restored = true;
|
|
43
|
+
process.stdout.write('\x1b[?25h'); // show cursor
|
|
44
|
+
process.stdout.write('\x1b[?1049l'); // back to normal buffer (history intact)
|
|
45
|
+
};
|
|
46
|
+
process.on('exit', restore);
|
|
47
|
+
return restore;
|
|
48
|
+
}
|
package/src/ui/mouse.js
CHANGED
|
@@ -75,10 +75,11 @@ export function Clickable({ frameRef, onClick, onHover, children }) {
|
|
|
75
75
|
const el = ref.current, frameEl = frameRef && frameRef.current;
|
|
76
76
|
if (!el || !frameEl || !frameEl.yogaNode || !el.yogaNode) return undefined;
|
|
77
77
|
try {
|
|
78
|
-
|
|
78
|
+
// Fullscreen (alt-screen): the frame is anchored to the TOP (row 1), so a row's
|
|
79
|
+
// absolute terminal Y is simply 1 + its offset within the frame. This is stable
|
|
80
|
+
// (no scrollback drift) — the whole reason we run fullscreen.
|
|
79
81
|
const off = offsetWithinFrame(el, frameEl);
|
|
80
|
-
const
|
|
81
|
-
const y = termRows - (frameH - off);
|
|
82
|
+
const y = off + 1;
|
|
82
83
|
if (claimed.current != null && claimed.current !== y) {
|
|
83
84
|
registry.delete(claimed.current); hoverRegistry.delete(claimed.current);
|
|
84
85
|
}
|
package/src/update.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Auto-update support.
|
|
2
|
+
// • checkForUpdate() — quietly asks the npm registry for the latest version (cached 6h),
|
|
3
|
+
// returns the newer version string or null. Used to show a notice on launch.
|
|
4
|
+
// • runUpdate() — runs `npm install -g scripter-x@latest`.
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import * as config from './config.js';
|
|
8
|
+
|
|
9
|
+
const execFileP = promisify(execFile);
|
|
10
|
+
const PKG = 'scripter-x';
|
|
11
|
+
const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h
|
|
12
|
+
|
|
13
|
+
function cmp(a, b) {
|
|
14
|
+
const pa = String(a).split('.').map(Number), pb = String(b).split('.').map(Number);
|
|
15
|
+
for (let i = 0; i < 3; i++) { if ((pa[i] || 0) > (pb[i] || 0)) return 1; if ((pa[i] || 0) < (pb[i] || 0)) return -1; }
|
|
16
|
+
return 0;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Returns the latest version if newer than `current`, else null. Network/registry errors
|
|
20
|
+
// are swallowed (update checks must never break the app). Throttled via config.
|
|
21
|
+
export async function checkForUpdate(current) {
|
|
22
|
+
const cfg = config.load();
|
|
23
|
+
const last = cfg._lastUpdateCheck || 0;
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
// (Date.now is fine here — this is a real CLI, not the constrained workflow sandbox.)
|
|
26
|
+
if (now - last < CHECK_INTERVAL_MS && cfg._latestVersion) {
|
|
27
|
+
return cmp(cfg._latestVersion, current) > 0 ? cfg._latestVersion : null;
|
|
28
|
+
}
|
|
29
|
+
try {
|
|
30
|
+
const { stdout } = await execFileP('npm', ['view', PKG, 'version'], { timeout: 5000 });
|
|
31
|
+
const latest = stdout.trim();
|
|
32
|
+
config.setMany({ _lastUpdateCheck: now, _latestVersion: latest });
|
|
33
|
+
return cmp(latest, current) > 0 ? latest : null;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Run the global update. Returns { ok, output }.
|
|
40
|
+
export async function runUpdate() {
|
|
41
|
+
try {
|
|
42
|
+
const { stdout, stderr } = await execFileP('npm', ['install', '-g', `${PKG}@latest`], { timeout: 120000 });
|
|
43
|
+
return { ok: true, output: (stdout + stderr).trim() };
|
|
44
|
+
} catch (e) {
|
|
45
|
+
return { ok: false, output: e.message };
|
|
46
|
+
}
|
|
47
|
+
}
|