scripter-x 1.0.21 → 1.0.23

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.21",
3
+ "version": "1.0.23",
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
@@ -438,8 +438,9 @@ export async function zeptoCmd(io, api, args = {}) {
438
438
  if (worker.stats.succeeded > 0) {
439
439
  const saved = await saveZeptoSessions(results, name, args.out);
440
440
  if (saved) {
441
- io.print(` ✓ saved ${saved.count} session(s) to:`);
442
- io.print(` ${saved.path}`, 'accent');
441
+ io.print(` ✓ saved ${saved.count} session(s) to 2 files:`);
442
+ io.print(` full → ${saved.fullPath}`, 'accent');
443
+ io.print(` zauth → ${saved.zauthPath}`, 'accent');
443
444
  io.print(' ◉ ZAUTH1 envelopes:');
444
445
  for (const r of results) {
445
446
  if (r.status === 'success') {
@@ -457,30 +458,46 @@ async function saveZeptoSessions(results, name, out) {
457
458
  const ok = results.filter((r) => r.status === 'success');
458
459
  if (!ok.length) return null;
459
460
 
460
- const safeName = name.replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'Zepto';
461
- const fname = `Zepto-${safeName}-${Math.floor(Date.now() / 1000)}.json`;
462
- let dest;
461
+ const count = ok.length; // number of sessions extracted
462
+ const ts = Math.floor(Date.now() / 1000);
463
+ const fullName = `zepto-${count}-fullsession-${ts}.json`;
464
+ const zauthName = `zepto-${count}-zauth-${ts}.json`;
465
+
466
+ // Resolve the output directory. `out` may be a directory or an explicit file path;
467
+ // when it's a file path we still write BOTH files alongside it (using its directory)
468
+ // so each file gets its own correct name.
469
+ let dir;
463
470
  if (out) {
464
471
  const expanded = out.startsWith('~') ? join(homedir(), out.slice(1)) : out;
465
472
  const isDir = (existsSync(expanded) && statSync(expanded).isDirectory()) || /[/\\]$/.test(out);
466
- dest = isDir ? join(expanded, fname) : expanded;
473
+ dir = isDir ? expanded : dirname(expanded);
467
474
  } else {
468
475
  const downloads = join(homedir(), 'Downloads');
469
476
  const base = existsSync(downloads) ? downloads : homedir();
470
- dest = join(base, 'scripterx', fname);
477
+ dir = join(base, 'scripterx');
471
478
  }
479
+ mkdirSync(dir, { recursive: true });
472
480
 
473
- const payload = ok.map((r) => ({
481
+ // 1. Full session details — everything we have per extracted account.
482
+ const fullPath = join(dir, fullName);
483
+ const fullPayload = ok.map((r) => ({
474
484
  mobileNo: r.mobile,
475
485
  zauth1_envelope: r.envelope,
476
486
  session: r.session,
477
487
  }));
488
+ writeFileSync(fullPath, JSON.stringify(fullPayload, null, 2));
489
+
490
+ // 2. ZAUTH1-only — just the envelope strings, nothing else.
491
+ const zauthPath = join(dir, zauthName);
492
+ const zauthPayload = ok.map((r) => r.envelope);
493
+ writeFileSync(zauthPath, JSON.stringify(zauthPayload, null, 2));
478
494
 
479
- mkdirSync(dirname(dest), { recursive: true });
480
- writeFileSync(dest, JSON.stringify(payload, null, 2));
481
- return { path: dest, count: payload.length };
495
+ return { fullPath, zauthPath, count };
482
496
  }
483
497
 
498
+ // Exposed for tests only.
499
+ export const __saveZeptoSessionsForTest = saveZeptoSessions;
500
+
484
501
  export async function campaigns(io, api) {
485
502
  api = api || await getApi(io);
486
503
  const camps = await api.listCampaigns();
@@ -607,10 +624,23 @@ export function help(io) {
607
624
 
608
625
  export async function update(io) {
609
626
  io.print(' ◉ updating ScripterX to the latest version…');
610
- const { runUpdate } = await import('./update.js');
627
+ const { runUpdate, restartCli } = await import('./update.js');
611
628
  const r = await runUpdate();
612
- if (r.ok) io.print(' ✓ updated! restart `scripterx` to use the new version.');
613
- else io.print(` ✗ update failed: ${r.output.split('\n')[0]} — try: npm install -g scripter-x@latest`);
629
+ if (!r.ok) {
630
+ io.print(` ✗ update failed: ${r.output.split('\n')[0]} — try: npm install -g scripter-x@latest`);
631
+ return;
632
+ }
633
+ io.print(' ✓ updated! restarting to load the new version…');
634
+ // Tear down the interactive ink UI first (restores the terminal) so the relaunched
635
+ // process paints onto a clean screen. exitApp exists only in the interactive shell;
636
+ // in one-shot mode there's nothing to tear down.
637
+ if (typeof io.exitApp === 'function') {
638
+ try { io.exitApp(); } catch { /* */ }
639
+ }
640
+ // Give ink a beat to unmount + the alt-screen restore to flush, then re-exec.
641
+ await new Promise((res) => setTimeout(res, 250));
642
+ const ok = restartCli();
643
+ if (!ok) io.print(' ◉ could not auto-restart — please run `scripter-x` again.');
614
644
  }
615
645
 
616
646
  // dispatch table used by the interactive shell
@@ -155,12 +155,15 @@ export class OTPCartProvider {
155
155
  }
156
156
  }
157
157
 
158
- // Grace period after the WS connects with no OTP push before we suspect the token
159
- // is dead and reconnect with a freshly re-logged-in token. The real SMS usually
160
- // lands well within this; a *dead-token* socket NEVER pushes, so this is what
161
- // rescues an otherwise-silent run.
162
- const WS_SILENT_RELOGIN_MS = 25000;
163
- const WS_PING_MS = 20000; // keep-alive so an idle socket isn't dropped before the SMS lands
158
+ // Concurrency note: OTPCart happily serves MANY concurrent WS connections on ONE valid
159
+ // session each rented number gets its own socket and its own OTP push. The hazard is
160
+ // NOT the parallel sockets; it's rotating the shared JWT. So the watchdog below must
161
+ // NEVER relogin just because a socket is *quiet* (a slow SMS would then rotate the token
162
+ // and knock out every other concurrent slot's open socket — a relogin storm). We only
163
+ // relogin on hard evidence the token is dead: the HANDSHAKE itself fails (close/error
164
+ // before we ever bound the socket). A connected-but-silent socket just re-subscribes.
165
+ const WS_RESUBSCRIBE_MS = 20000; // periodically re-send the bind frame on a quiet socket
166
+ const WS_PING_MS = 20000; // keep-alive so an idle socket isn't dropped before the SMS lands
164
167
  const WS_DEBUG = process.env.OTPCART_WS_DEBUG === '1';
165
168
 
166
169
  class OTPCartStream {
@@ -176,10 +179,11 @@ class OTPCartStream {
176
179
  this._openP = null; // resolves when the WS is OPEN
177
180
  this._reconnectTimer = null;
178
181
  this._connectedAt = 0;
179
- this._silentTimer = null; // fires if no OTP arrives → relogin + reconnect
182
+ this._everOpened = false; // did this socket ever reach OPEN?
183
+ this._resubTimer = null; // re-subscribe on a quiet socket (no token rotation)
180
184
  this._pingTimer = null; // keep-alive
181
185
  this._reloggingIn = false;
182
- this._silentReloginsLeft = 1; // recover a taken-over session once; don't churn a slow SMS
186
+ this._handshakeReloginsLeft = 1; // recover a taken-over session once on a bad handshake
183
187
  this._gotServerAck = false; // server sent the {serialNumber,mobileId} connect frame
184
188
  this._connect();
185
189
  }
@@ -187,38 +191,34 @@ class OTPCartStream {
187
191
  _currentToken() { return this.provider.token; }
188
192
  _dbg(...a) { if (WS_DEBUG) { try { console.error('[otpcart-ws]', ...a); } catch { /* */ } } }
189
193
 
190
- _clearSilentTimer() {
191
- if (this._silentTimer) { clearTimeout(this._silentTimer); this._silentTimer = null; }
194
+ _clearResubTimer() {
195
+ if (this._resubTimer) { clearInterval(this._resubTimer); this._resubTimer = null; }
192
196
  }
193
197
  _clearPingTimer() {
194
198
  if (this._pingTimer) { clearInterval(this._pingTimer); this._pingTimer = null; }
195
199
  }
196
200
 
197
- // Schedule the "this socket has gone quiet maybe the token is dead" check.
198
- // Only while we still have a relogin budget (a genuinely slow SMS shouldn't keep
199
- // rotating the shared token, which would force every other slot to reconnect).
200
- _armSilentTimer() {
201
- this._clearSilentTimer();
202
- if (this.closed || this.otp || !this.provider.canRelogin || this._silentReloginsLeft <= 0) return;
203
- this._silentTimer = setTimeout(() => this._handleSilent(), WS_SILENT_RELOGIN_MS);
204
- }
205
-
206
- async _handleSilent() {
207
- if (this.closed || this.otp || this._reloggingIn || this._silentReloginsLeft <= 0) return;
208
- this._silentReloginsLeft--;
201
+ // A socket that errored/closed WITHOUT ever opening is the dead-token signal: the
202
+ // server rejected the handshake. Relogin (de-duped + epoch-guarded so concurrent
203
+ // slots don't storm) and reconnect with the fresh token. A socket that DID open is
204
+ // a valid session — we never relogin it, so other concurrent sockets stay intact.
205
+ async _handleHandshakeFailure() {
206
+ if (this.closed || this.otp || this._reloggingIn) return;
207
+ if (!this.provider.canRelogin || this._handshakeReloginsLeft <= 0) {
208
+ // No creds (or budget spent) — just retry the socket with the current token.
209
+ if (!this.closed && !this.otp) this._reconnectTimer = setTimeout(() => this._connect(), 1000);
210
+ return;
211
+ }
212
+ this._handshakeReloginsLeft--;
209
213
  this._reloggingIn = true;
210
- this._dbg('silent watchdog fired — relogin + reconnect');
214
+ this._dbg('handshake failed before open — relogin + reconnect');
211
215
  try {
212
- // Re-login (de-duped at the provider) to get a token that the WS will accept,
213
- // then reconnect this socket with it. If the token was actually fine, this is
214
- // a cheap no-op reconnect that costs nothing but a fresh socket.
215
216
  const epoch = this.provider.tokenEpoch;
216
217
  await this.provider.relogin(epoch);
217
- } catch { /* keep the old socket; nothing better to do */ }
218
+ } catch { /* nothing better to do */ }
218
219
  finally { this._reloggingIn = false; }
219
220
  if (this.closed || this.otp) return;
220
- try { if (this.ws) this.ws.terminate(); } catch { /* */ }
221
- this._connect(); // reconnect with the (possibly refreshed) current token
221
+ this._connect();
222
222
  }
223
223
 
224
224
  // The browser sends an Origin (and a UA) on the WS handshake. The `ws` library
@@ -253,15 +253,21 @@ class OTPCartStream {
253
253
  this._dbg('connecting', WS_URL, 'token', String(token).slice(0, 10) + '…');
254
254
  this.ws = new WebSocket(`${WS_URL}?token=${token}`, this._wsOptions());
255
255
  this._gotServerAck = false;
256
+ let openedThisSocket = false; // did THIS socket reach OPEN (vs. fail the handshake)?
256
257
  this._openP = new Promise((resolve) => {
257
258
  this.ws.once('open', resolve);
258
259
  this.ws.once('error', resolve); // resolve anyway so ready() never hangs
259
260
  });
260
261
  this.ws.on('open', () => {
261
262
  this._connectedAt = Date.now();
263
+ this._everOpened = true;
264
+ openedThisSocket = true;
262
265
  this._dbg('open');
263
266
  this._subscribe(); // bind this socket to our number (browser parity)
264
- this._armSilentTimer(); // start the dead-token watchdog
267
+ // Quiet-socket handling = RE-SUBSCRIBE only (NEVER relogin). This keeps concurrency
268
+ // safe: a slow SMS on one slot won't rotate the shared token and break other slots.
269
+ this._clearResubTimer();
270
+ this._resubTimer = setInterval(() => { if (!this.otp && !this.closed) this._subscribe(); }, WS_RESUBSCRIBE_MS);
265
271
  this._clearPingTimer();
266
272
  this._pingTimer = setInterval(() => { try { this.ws.ping(); } catch { /* */ } }, WS_PING_MS);
267
273
  });
@@ -272,7 +278,7 @@ class OTPCartStream {
272
278
  // OTP frame: {message, otpMessage}.
273
279
  if (msg.otpMessage) {
274
280
  const m = String(msg.otpMessage).match(OTP_RE);
275
- if (m) { this.otp = m[1]; this.arrived = true; this._clearSilentTimer(); this._dbg('OTP', m[1]); }
281
+ if (m) { this.otp = m[1]; this.arrived = true; this._clearResubTimer(); this._dbg('OTP', m[1]); }
276
282
  return;
277
283
  }
278
284
  // Ack frame: {serialNumber, mobileId, resend}. Confirms the socket is bound.
@@ -285,14 +291,20 @@ class OTPCartStream {
285
291
  }
286
292
  });
287
293
  this.ws.on('pong', () => this._dbg('pong'));
288
- this.ws.on('error', (e) => { this._dbg('error', e?.message); /* swallow — poll() returns nothing; we auto-reconnect */ });
289
- // Auto-reconnect if the socket drops before the OTP arrives (so a dropped WS doesn't
290
- // silently lose the push — this is the "purchased but never got OTP" symptom).
294
+ this.ws.on('error', (e) => { this._dbg('error', e?.message); /* swallow — handled in close */ });
291
295
  this.ws.on('close', (code) => {
292
- this._dbg('close', code);
293
- this._clearSilentTimer();
296
+ this._dbg('close', code, 'opened=' + openedThisSocket);
297
+ this._clearResubTimer();
294
298
  this._clearPingTimer();
295
299
  if (this.closed || this.otp || this._reloggingIn) return;
300
+ if (!openedThisSocket) {
301
+ // Never reached OPEN → the handshake was rejected → likely a dead token.
302
+ // Relogin (epoch-guarded, capped) then reconnect — this is the ONLY relogin path.
303
+ this._handleHandshakeFailure();
304
+ return;
305
+ }
306
+ // Was a healthy socket that dropped before the OTP — just reconnect with the SAME
307
+ // token (no relogin, so we don't disturb other concurrent slots).
296
308
  this._reconnectTimer = setTimeout(() => { if (!this.closed && !this.otp) this._connect(); }, 1000);
297
309
  });
298
310
  }
@@ -324,7 +336,7 @@ class OTPCartStream {
324
336
 
325
337
  close() {
326
338
  this.closed = true;
327
- this._clearSilentTimer();
339
+ this._clearResubTimer();
328
340
  this._clearPingTimer();
329
341
  if (this._reconnectTimer) { clearTimeout(this._reconnectTimer); this._reconnectTimer = null; }
330
342
  try { this.ws.close(); } catch { /* */ }
package/src/update.js CHANGED
@@ -2,8 +2,10 @@
2
2
  // • checkForUpdate() — quietly asks the npm registry for the latest version (cached 6h),
3
3
  // returns the newer version string or null. Used to show a notice on launch.
4
4
  // • runUpdate() — runs `npm install -g scripter-x@latest`.
5
- import { execFile } from 'node:child_process';
5
+ // • restartCli() re-exec the freshly-installed CLI so the new version takes over.
6
+ import { execFile, spawn } from 'node:child_process';
6
7
  import { promisify } from 'node:util';
8
+ import process from 'node:process';
7
9
  import * as config from './config.js';
8
10
 
9
11
  const execFileP = promisify(execFile);
@@ -45,3 +47,48 @@ export async function runUpdate() {
45
47
  return { ok: false, output: e.message };
46
48
  }
47
49
  }
50
+
51
+ // Re-exec the CLI so the just-installed version takes over. Spawns a fresh, DETACHED
52
+ // process that inherits this terminal, then exits the current one. The caller MUST have
53
+ // already torn down the ink/alt-screen UI (so the terminal is restored) before calling
54
+ // this — otherwise the child renders over a dirty screen.
55
+ //
56
+ // We re-run the CURRENT entry file with node (`process.argv[1]`): after `npm install -g`,
57
+ // the global bin symlink points at the new version, so this loads the updated code. If
58
+ // that entry path is somehow missing, we fall back to the `scripterx` bin on PATH.
59
+ //
60
+ // NOTE: on success it exits the process and never returns. Returns false only if BOTH
61
+ // spawn attempts throw synchronously, so the caller can show "restart manually".
62
+ export function restartCli({ binName = 'scripterx', delayMs = 150 } = {}) {
63
+ // Re-run with the SAME args the user launched with (drop node + the script path), so
64
+ // `scripterx` → interactive shell, `scripterx zepto` → zepto, etc.
65
+ const argv = process.argv.slice(2);
66
+ const entry = process.argv[1];
67
+
68
+ const trySpawn = (cmd, args) => {
69
+ const child = spawn(cmd, args, { stdio: 'inherit', detached: true, shell: false });
70
+ child.unref();
71
+ return child;
72
+ };
73
+
74
+ try {
75
+ const primary = trySpawn(process.execPath, [entry, ...argv]);
76
+ // If the entry re-exec fails to even start, fall back to the named bin on PATH.
77
+ primary.on('error', () => {
78
+ try { trySpawn(binName, argv); } catch { /* nothing more we can do */ }
79
+ });
80
+ // Exit the old process; its `process.on('exit', restore)` cleans the terminal and the
81
+ // detached child takes over the same TTY running the new version.
82
+ setTimeout(() => process.exit(0), delayMs);
83
+ return true;
84
+ } catch {
85
+ // process.execPath spawn threw synchronously — try the named bin directly.
86
+ try {
87
+ trySpawn(binName, argv);
88
+ setTimeout(() => process.exit(0), delayMs);
89
+ return true;
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+ }