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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scripter-x",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "ScripterX — local Flipkart session extractor (runs on your residential IP, syncs to marthunt)",
5
5
  "type": "module",
6
6
  "bin": {
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
- const { waitUntilExit } = render(h(App, { ctx }));
85
- await waitUntilExit();
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
- lines.map((l, i) => h(Text, { key: i, color: colorMap[l.color] || l.color || undefined }, l.text)));
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 chars = gradientChars('ScripterX');
32
- return h(Box, { borderStyle: 'round', borderColor: COLORS.accent, paddingX: 2, paddingY: 0, flexDirection: 'column' },
33
- h(Box, null, chars.map((c, i) => h(Text, { key: i, color: c.color, bold: true }, c.char)),
34
- h(Text, { color: 'gray' }, ' Flipkart session extractor')),
35
- h(Box, null,
36
- h(Text, { color: 'gray' }, username ? `◉ ${username}` : '○ not signed in'),
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 / to browse — press 1-9 or ↑↓+enter to pick · 'exit' to quit"),
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 visible row (no command name has a digit, so safe).
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 < shown.length) { setQuery(''); setPaletteOpen(false); setIdx(0); run(shown[n].name); }
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(WelcomeBox, { username, server, frameRef, onLogout: () => run('logout') }),
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(Box, { flexDirection: 'column', marginTop: 0, marginLeft: 2 },
101
- matches.slice(0, 8).map((c, i) => {
102
- const active = i === Math.min(idx, matches.length - 1);
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
- const frameH = frameEl.yogaNode.getComputedHeight();
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 termRows = process.stdout.rows || 40;
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
+ }