devicerent 0.2.0 → 0.2.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.
@@ -8,7 +8,7 @@ import { createDecipheriv, createHash, createPublicKey, diffieHellman, generateK
8
8
  import http from "node:http";
9
9
  import { execFile, spawn } from "node:child_process";
10
10
  import readline from "node:readline/promises";
11
- import sharp from "sharp";
11
+ import { pipeline } from "node:stream/promises";
12
12
  import net from "node:net";
13
13
  import readline$1 from "node:readline";
14
14
  //#region \0rolldown/runtime.js
@@ -26972,7 +26972,7 @@ function openSealed(privateKey, sealed, aad) {
26972
26972
  * version.test.ts pins it to package.json so the two cannot drift (the
26973
26973
  * connector's hard-coded `--version` once did).
26974
26974
  */
26975
- const CLI_VERSION = "0.2.0";
26975
+ const CLI_VERSION = "0.2.1";
26976
26976
  //#endregion
26977
26977
  //#region src/auth/loopback.ts
26978
26978
  function page(message) {
@@ -27029,8 +27029,19 @@ function systemExe(relative) {
27029
27029
  const root = process.env.SystemRoot ?? process.env.windir ?? "C:\\Windows";
27030
27030
  return path.win32.join(root, "System32", relative);
27031
27031
  }
27032
- /** Open a URL (or a file) in its default app. False when there is no way to. */
27032
+ /**
27033
+ * What the CLI ever hands to the system's "open" handler: a web page (the
27034
+ * approval page, a Stripe hand-off), or a screenshot it just saved. A URL the
27035
+ * server sent is otherwise opened as-is, and `file:`, `ms-*:` or a program's
27036
+ * path would launch whatever it names.
27037
+ */
27038
+ function isOpenable(target) {
27039
+ if (/^https?:\/\/[^\s]+$/i.test(target)) return true;
27040
+ return path.isAbsolute(target) && /\.png$/i.test(target);
27041
+ }
27042
+ /** Open a web page, or a saved screenshot, in its default app. False when there is no way to. */
27033
27043
  function openBrowser(url) {
27044
+ if (!isOpenable(url)) return false;
27034
27045
  const [cmd, args] = process.platform === "win32" ? [systemExe("rundll32.exe"), ["url.dll,FileProtocolHandler", url]] : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
27035
27046
  try {
27036
27047
  const child = spawn(cmd, args, {
@@ -27460,7 +27471,7 @@ const walletCommand = {
27460
27471
  printLine(eur(p.wallet));
27461
27472
  return;
27462
27473
  }
27463
- const page = Math.max(0, Number(str$1(opts, "page") ?? "1") - 1);
27474
+ const page = int$1(opts, "page", 1) - 1;
27464
27475
  const { rows, hasMore } = await fetchWalletTransactions(page);
27465
27476
  if (json) return printJson({
27466
27477
  balance: p.wallet,
@@ -27491,7 +27502,7 @@ const historyCommand = {
27491
27502
  options: { limit: { type: "string" } },
27492
27503
  async run({ ctx, opts, json }) {
27493
27504
  const user = await requireUser(ctx);
27494
- const limit = Math.min(200, Math.max(1, Number(str$1(opts, "limit") ?? "20")));
27505
+ const limit = Math.min(200, int$1(opts, "limit", 20));
27495
27506
  const { data, error } = await ctx.supabase.from("rentals").select("id,device_id,status,start_time,end_time,total_cost,devices!inner(name)").eq("user_id", user.id).eq("status", "completed").order("end_time", {
27496
27507
  ascending: false,
27497
27508
  nullsFirst: false
@@ -33766,65 +33777,77 @@ var WebSocketServerConnector = class {
33766
33777
  });
33767
33778
  let readableController = null;
33768
33779
  let readableClosed = false;
33769
- return {
33770
- readable: new ReadableStream$1({
33771
- start(controller) {
33772
- readableController = controller;
33773
- ws.addEventListener("message", (ev) => {
33774
- if (readableClosed) return;
33775
- if (ev.data instanceof ArrayBuffer) controller.enqueue(new Uint8Array(ev.data));
33776
- else if (ev.data instanceof Uint8Array) controller.enqueue(ev.data);
33777
- });
33778
- ws.addEventListener("close", () => {
33779
- if (readableClosed) return;
33780
- readableClosed = true;
33781
- try {
33782
- controller.close();
33783
- } catch {}
33784
- resolveClosed(void 0);
33785
- });
33786
- ws.addEventListener("error", () => {
33787
- if (readableClosed) return;
33788
- readableClosed = true;
33789
- try {
33790
- controller.error(/* @__PURE__ */ new Error("WebSocket error"));
33791
- } catch {}
33792
- resolveClosed(void 0);
33793
- });
33794
- },
33795
- cancel() {
33796
- try {
33797
- ws.close();
33798
- } catch {}
33799
- }
33800
- }),
33801
- writable: new MaybeConsumableWritableStream({
33802
- write(chunk) {
33803
- if (ws.readyState !== WebSocket.OPEN) throw new Error("bridge socket not open");
33804
- ws.send(chunk.slice());
33805
- },
33806
- close() {
33807
- try {
33808
- ws.close(1e3, "writable closed");
33809
- } catch {}
33810
- },
33811
- abort() {
33780
+ const readable = new ReadableStream$1({
33781
+ start(controller) {
33782
+ readableController = controller;
33783
+ ws.addEventListener("message", (ev) => {
33784
+ if (readableClosed) return;
33785
+ if (ev.data instanceof ArrayBuffer) controller.enqueue(new Uint8Array(ev.data));
33786
+ else if (ev.data instanceof Uint8Array) controller.enqueue(ev.data);
33787
+ });
33788
+ ws.addEventListener("close", () => {
33789
+ if (readableClosed) return;
33790
+ readableClosed = true;
33812
33791
  try {
33813
- ws.close(1011, "writable aborted");
33792
+ controller.close();
33814
33793
  } catch {}
33815
- }
33816
- }),
33817
- close: () => {
33818
- if (!readableClosed && readableController) {
33794
+ resolveClosed(void 0);
33795
+ });
33796
+ ws.addEventListener("error", () => {
33797
+ if (readableClosed) return;
33819
33798
  readableClosed = true;
33820
33799
  try {
33821
- readableController.close();
33800
+ controller.error(/* @__PURE__ */ new Error("WebSocket error"));
33822
33801
  } catch {}
33823
- }
33802
+ resolveClosed(void 0);
33803
+ });
33804
+ },
33805
+ cancel() {
33824
33806
  try {
33825
- ws.close(1e3, "client closed");
33807
+ ws.close();
33826
33808
  } catch {}
33809
+ }
33810
+ });
33811
+ const writable = new MaybeConsumableWritableStream({
33812
+ write(chunk) {
33813
+ if (ws.readyState !== WebSocket.OPEN) throw new Error("bridge socket not open");
33814
+ ws.send(chunk.slice());
33827
33815
  },
33816
+ close() {
33817
+ try {
33818
+ ws.close(1e3, "writable closed");
33819
+ } catch {}
33820
+ },
33821
+ abort() {
33822
+ try {
33823
+ ws.close(1011, "writable aborted");
33824
+ } catch {}
33825
+ }
33826
+ });
33827
+ const close = () => {
33828
+ if (!readableClosed && readableController) {
33829
+ readableClosed = true;
33830
+ try {
33831
+ readableController.close();
33832
+ } catch {}
33833
+ }
33834
+ try {
33835
+ ws.close(1e3, "client closed");
33836
+ } catch {}
33837
+ };
33838
+ const signal = options?.signal;
33839
+ if (signal) {
33840
+ const onAbort = () => close();
33841
+ if (signal.aborted) onAbort();
33842
+ else {
33843
+ signal.addEventListener("abort", onAbort);
33844
+ ws.addEventListener("close", () => signal.removeEventListener("abort", onAbort));
33845
+ }
33846
+ }
33847
+ return {
33848
+ readable,
33849
+ writable,
33850
+ close,
33828
33851
  get closed() {
33829
33852
  return closed;
33830
33853
  }
@@ -34576,6 +34599,7 @@ async function executeToolCall(ctx, call) {
34576
34599
  * which is the one conversion here and the one place they could have drifted.
34577
34600
  */
34578
34601
  const nodeImageCodec = { async pngToScaledJpeg(png) {
34602
+ const { default: sharp } = await import("sharp");
34579
34603
  const image = sharp(Buffer.from(png));
34580
34604
  const meta = await image.metadata();
34581
34605
  if (!meta.width || !meta.height) throw new Error("screencap bytes were not a decodable image");
@@ -34929,12 +34953,22 @@ function describeTurnFailure(status, failure) {
34929
34953
  if (status === 402) return "You are out of DeviceyAI credits. Buy a pack: devicerent credits buy s";
34930
34954
  return failure.error ?? `DeviceyAI refused the turn (${status}).`;
34931
34955
  }
34956
+ /**
34957
+ * A turn that did not come back. Null when the person stopped it (Ctrl-C);
34958
+ * anything else — a dropped connection, a reply cut off mid-stream — says
34959
+ * so, instead of reading as if Ctrl-C had been pressed.
34960
+ */
34961
+ function lostTurn(err, signal) {
34962
+ if (signal.aborted) return null;
34963
+ if (err instanceof CliError) throw err;
34964
+ throw new CliError(`Lost the connection to DeviceyAI (${String(err?.message ?? err).split("\n")[0].slice(0, 160)}). Your phone is still rented; try again.`);
34965
+ }
34932
34966
  async function readTurn(payload, signal, hooks) {
34933
34967
  let reply;
34934
34968
  try {
34935
34969
  reply = await postAiTurn(payload, signal);
34936
- } catch {
34937
- return null;
34970
+ } catch (err) {
34971
+ return lostTurn(err, signal);
34938
34972
  }
34939
34973
  if (reply.kind === "failed") {
34940
34974
  const message = describeTurnFailure(reply.status, reply.failure);
@@ -34942,9 +34976,14 @@ async function readTurn(payload, signal, hooks) {
34942
34976
  }
34943
34977
  if (reply.kind === "json") return reply.turn;
34944
34978
  let done = null;
34945
- for await (const ev of reply.events) if (ev.name === "text") hooks.onText(ev.delta);
34946
- else if (ev.name === "done") done = ev.turn;
34947
- else if (ev.name === "error") throw new CliError(ev.error);
34979
+ try {
34980
+ for await (const ev of reply.events) if (ev.name === "text") hooks.onText(ev.delta);
34981
+ else if (ev.name === "done") done = ev.turn;
34982
+ else if (ev.name === "error") throw new CliError(ev.error);
34983
+ } catch (err) {
34984
+ return lostTurn(err, signal);
34985
+ }
34986
+ if (!done) return lostTurn(/* @__PURE__ */ new Error("the reply ended early"), signal);
34948
34987
  return done;
34949
34988
  }
34950
34989
  async function runAiTask(device, opts, signal, hooks) {
@@ -35133,8 +35172,10 @@ async function loadFleetDevices(ctx) {
35133
35172
  */
35134
35173
  function pickDevice(devices, query, taken = /* @__PURE__ */ new Set()) {
35135
35174
  const q = query.trim().toLowerCase();
35175
+ if (!q) throw new CliError("Name a device (id, serial or model). See: devicerent devices");
35136
35176
  const exact = devices.find((d) => d.id.toLowerCase() === q || q.length >= 8 && d.id.toLowerCase().startsWith(q) || d.serial.toLowerCase() === q || d.serial.toLowerCase().endsWith(`:${q}`));
35137
35177
  if (exact) {
35178
+ if (taken.has(exact.id)) throw new CliError(`${exact.name} is named twice in one request.`);
35138
35179
  if (!exact.rentable) throw new CliError(`${exact.name} is ${exact.status} right now.`);
35139
35180
  return exact;
35140
35181
  }
@@ -35147,20 +35188,29 @@ function pickDevice(devices, query, taken = /* @__PURE__ */ new Set()) {
35147
35188
  async function loadActiveRentals() {
35148
35189
  return loadActiveRentalRows();
35149
35190
  }
35191
+ const listRentals = (rentals) => rentals.map((r) => `${r.id.slice(0, 8)} ${r.deviceName}`).join(", ");
35150
35192
  /**
35151
35193
  * Which rental a device command acts on: the one named by --rental (an id, an
35152
35194
  * id prefix, or a device name), else the only one running.
35195
+ *
35196
+ * A prefix or name that fits SEVERAL rentals is refused, never resolved to
35197
+ * the first: `stop pixel` with two Pixels rented must not stop — and wipe —
35198
+ * whichever one happened to come back first.
35153
35199
  */
35154
35200
  function chooseRental(rentals, selector) {
35155
35201
  if (rentals.length === 0) throw new CliError("No active rental. Start one: devicerent rent <device>");
35156
35202
  if (!selector) {
35157
35203
  if (rentals.length === 1) return rentals[0];
35158
- throw new CliError(`You have ${rentals.length} rentals running; pick one with --rental (${rentals.map((r) => `${r.id.slice(0, 8)} ${r.deviceName}`).join(", ")}).`);
35204
+ throw new CliError(`You have ${rentals.length} rentals running; pick one with --rental (${listRentals(rentals)}).`);
35205
+ }
35206
+ const s = selector.trim().toLowerCase();
35207
+ const exact = rentals.find((r) => r.id.toLowerCase() === s);
35208
+ if (exact) return exact;
35209
+ for (const matches of [rentals.filter((r) => r.id.toLowerCase().startsWith(s)), rentals.filter((r) => r.deviceName.toLowerCase().includes(s))]) {
35210
+ if (matches.length === 1) return matches[0];
35211
+ if (matches.length > 1) throw new CliError(`"${selector}" fits ${matches.length} of your rentals (${listRentals(matches)}); name one by its id.`);
35159
35212
  }
35160
- const s = selector.toLowerCase();
35161
- const hit = rentals.find((r) => r.id.toLowerCase() === s) ?? rentals.find((r) => r.id.toLowerCase().startsWith(s)) ?? rentals.find((r) => r.deviceName.toLowerCase().includes(s));
35162
- if (!hit) throw new CliError(`No active rental matches "${selector}".`);
35163
- return hit;
35213
+ throw new CliError(`No active rental matches "${selector}".`);
35164
35214
  }
35165
35215
  /** Minutes since a timestamp, never negative. */
35166
35216
  function minutesSince(iso, now) {
@@ -35195,6 +35245,8 @@ function renewDelayMs(expiresAt, now) {
35195
35245
  return Math.max(5e3, Date.parse(expiresAt) - now - RENEW_BEFORE_EXPIRY_MS);
35196
35246
  }
35197
35247
  async function openDevice(ctx, rental) {
35248
+ const touch = () => ctx.supabase.rpc("touch_rental_activity", { p_rental_id: rental.id }).then(() => void 0, () => void 0);
35249
+ const opened = touch();
35198
35250
  let ticket;
35199
35251
  try {
35200
35252
  ticket = await mintBridgeTicket(rental.id);
@@ -35224,17 +35276,18 @@ async function openDevice(ctx, rental) {
35224
35276
  if (closed) return;
35225
35277
  try {
35226
35278
  const next = await mintBridgeTicket(rental.id);
35279
+ if (closed) return;
35227
35280
  connector.setToken(next.token);
35228
35281
  await client.getVersion();
35229
35282
  schedule(renewDelayMs(next.expires_at, Date.now()));
35230
35283
  } catch (err) {
35284
+ if (closed) return;
35231
35285
  warn(`Could not renew access to ${rental.deviceName}: ${err.message}`);
35232
35286
  schedule(RENEW_RETRY_MS);
35233
35287
  }
35234
35288
  };
35235
35289
  schedule(renewDelayMs(ticket.expires_at, Date.now()));
35236
- const touch = () => ctx.supabase.rpc("touch_rental_activity", { p_rental_id: rental.id }).then(() => void 0, () => void 0);
35237
- await touch();
35290
+ await opened;
35238
35291
  let dirty = false;
35239
35292
  const flush = setInterval(() => {
35240
35293
  if (!dirty) return;
@@ -35288,8 +35341,10 @@ const STOPPED = () => new CliError("Stopped.", 130);
35288
35341
  * reported as the deliberate stop it was. A one-shot run has no signal.
35289
35342
  */
35290
35343
  async function withDevice(input, fn) {
35344
+ const rentals = loadActiveRentals();
35345
+ rentals.catch(() => {});
35291
35346
  const user = await requireUser(input.ctx);
35292
- const rental = chooseRental(await loadActiveRentals(), str$1(input.opts, "rental") ?? process.env.DEVICERENT_RENTAL);
35347
+ const rental = chooseRental(await rentals, str$1(input.opts, "rental") ?? process.env.DEVICERENT_RENTAL);
35293
35348
  if (input.signal?.aborted) throw STOPPED();
35294
35349
  const device = await openDevice(input.ctx, rental);
35295
35350
  const onAbort = () => void device.close();
@@ -35432,7 +35487,7 @@ async function loadAiConfig(rentalId) {
35432
35487
  /** The model --model names, else the default, else the first offered. */
35433
35488
  function pickModel(models, wanted) {
35434
35489
  const model = models.find((m) => m.id === (wanted ?? "claude-sonnet-5")) ?? (wanted ? void 0 : models[0]);
35435
- if (!model) throw new CliError(`Model ${wanted} is not offered here. See: devicerent ai --models`);
35490
+ if (!model) throw new CliError(wanted ? `Model ${wanted} is not offered here. See: devicerent ai --models` : "DeviceyAI has no model available right now. Try again in a few minutes.");
35436
35491
  return model;
35437
35492
  }
35438
35493
  const OUT_OF_CREDITS = "You are out of DeviceyAI credits. Buy a pack: devicerent credits buy s";
@@ -35707,6 +35762,19 @@ function parseAmStart(out) {
35707
35762
  function isOwnProcess(name, pkg) {
35708
35763
  return name === pkg || name.startsWith(`${pkg}:`);
35709
35764
  }
35765
+ /**
35766
+ * Taps, from monkey's own event total, when it printed none of them.
35767
+ *
35768
+ * Monkey on Android 16 and 17 no longer prints `:Sending Touch` at `-v` (nor
35769
+ * at `-v -v`, checked on a Pixel 7a on 17, 2026-10-07), so counting those
35770
+ * lines reported "survived 0 taps" for every run. `Events injected` still
35771
+ * comes: with MONKEY_WEIGHTS every event but the opening `:Switch:` is a
35772
+ * touch, and a tap is exactly two of them — ACTION_DOWN and ACTION_UP (its
35773
+ * throttle sleep is not counted). A trailing half-tap rounds down.
35774
+ */
35775
+ function tapsFromInjected(injected, switches) {
35776
+ return Math.max(0, Math.floor((injected - switches) / 2));
35777
+ }
35710
35778
  function parseMonkey(out, pkg) {
35711
35779
  const lines = out.split("\n").map((l) => l.replace(/\r$/, ""));
35712
35780
  const pid = /^QTPID=(\d+)/m.exec(out);
@@ -35717,10 +35785,12 @@ function parseMonkey(out, pkg) {
35717
35785
  let inCrash = false;
35718
35786
  let error = null;
35719
35787
  let taps = 0;
35788
+ let switches = 0;
35720
35789
  let rejected = 0;
35721
35790
  for (const raw of lines) {
35722
35791
  const line = raw.trimStart();
35723
35792
  if (line.includes(":Sending Touch (ACTION_DOWN)")) taps++;
35793
+ if (line.startsWith(":Switch:")) switches++;
35724
35794
  if (/^\/\/ Rejecting (start|resume)/.test(line)) rejected++;
35725
35795
  const c = /^\/\/ CRASH: (\S+)/.exec(line);
35726
35796
  if (c) {
@@ -35742,10 +35812,11 @@ function parseMonkey(out, pkg) {
35742
35812
  const e = /^\*\* (.+)$/.exec(line);
35743
35813
  if (e && !error && !/^Monkey aborted due to error/.test(e[1])) error = e[1].replace(/^Error:\s*/, "").trim();
35744
35814
  }
35815
+ const injectedCount = injected ? Number(injected[1]) : 0;
35745
35816
  return {
35746
35817
  pid: pid ? Number(pid[1]) : null,
35747
- injected: injected ? Number(injected[1]) : 0,
35748
- taps,
35818
+ injected: injectedCount,
35819
+ taps: taps > 0 ? taps : tapsFromInjected(injectedCount, switches),
35749
35820
  crash,
35750
35821
  anr,
35751
35822
  crashLines,
@@ -36245,6 +36316,74 @@ async function archive(ctx, file, storagePath) {
36245
36316
  }
36246
36317
  }
36247
36318
  //#endregion
36319
+ //#region src/device/transfer.ts
36320
+ /** `push`'s remote path: a folder ("/sdcard/Download/") gets the file's own name, as adb push does. */
36321
+ function remoteTarget(local, remote) {
36322
+ return remote.endsWith("/") ? `${remote}${path.basename(local)}` : remote;
36323
+ }
36324
+ /** `pull`'s local path: none, or an existing folder, gets the phone file's own name. */
36325
+ function localTarget(remote, local) {
36326
+ const name = path.posix.basename(remote);
36327
+ if (!local) return name;
36328
+ try {
36329
+ if (fs.statSync(local).isDirectory()) return path.join(local, name);
36330
+ } catch {}
36331
+ return local;
36332
+ }
36333
+ /**
36334
+ * Stream a file read off the phone into `local`, honouring backpressure.
36335
+ *
36336
+ * Either side can fail — the phone has no such file, the folder cannot be
36337
+ * written — and either way what was written is removed and the error says
36338
+ * which side it was. An unhandled write-stream error used to crash the
36339
+ * process (the whole interactive session, with a phone still rented), and a
36340
+ * missing remote file used to leave an empty local one behind, which looks
36341
+ * like a copy that worked.
36342
+ */
36343
+ async function pullToFile(source, local, remote) {
36344
+ const reader = source.getReader();
36345
+ let bytes = 0;
36346
+ let readFailed = false;
36347
+ let finished = false;
36348
+ async function* chunks() {
36349
+ try {
36350
+ for (;;) {
36351
+ let r;
36352
+ try {
36353
+ r = await reader.read();
36354
+ } catch (err) {
36355
+ readFailed = true;
36356
+ throw err;
36357
+ }
36358
+ if (r.done) {
36359
+ finished = true;
36360
+ return;
36361
+ }
36362
+ bytes += r.value.length;
36363
+ yield r.value;
36364
+ }
36365
+ } finally {
36366
+ if (!finished && !readFailed) reader.cancel().catch(() => {});
36367
+ }
36368
+ }
36369
+ let file;
36370
+ try {
36371
+ file = await fs.promises.open(local, "w");
36372
+ } catch (err) {
36373
+ reader.cancel().catch(() => {});
36374
+ throw new CliError(`Could not write ${local}: ${err.message}`);
36375
+ }
36376
+ try {
36377
+ await pipeline(chunks(), file.createWriteStream());
36378
+ return bytes;
36379
+ } catch (err) {
36380
+ await file.close().catch(() => {});
36381
+ fs.rmSync(local, { force: true });
36382
+ const message = err.message;
36383
+ throw new CliError(readFailed ? `Could not read ${remote} from the phone: ${message}` : `Could not write ${local}: ${message}`);
36384
+ }
36385
+ }
36386
+ //#endregion
36248
36387
  //#region src/commands/device.ts
36249
36388
  function requirePackage(pkg) {
36250
36389
  if (!pkg || !QUICK_TEST_PACKAGE.test(pkg)) throw new CliError("Give an Android package name, e.g. com.example.app");
@@ -36518,9 +36657,10 @@ const deviceCommands = [
36518
36657
  usage: "devicerent push <local> <remote-path> [--rental <id>]",
36519
36658
  options: { ...RENTAL_OPTION },
36520
36659
  async run(input) {
36521
- const [local, remote] = input.args;
36522
- if (!local || !remote) throw new CliError("devicerent push <local> <remote-path>, e.g. /sdcard/Download/data.json");
36523
- if (!fs.existsSync(local)) throw new CliError(`No such file: ${local}`);
36660
+ const [local, remoteArg] = input.args;
36661
+ if (!local || !remoteArg) throw new CliError("devicerent push <local> <remote-path>, e.g. /sdcard/Download/data.json");
36662
+ if (!fs.statSync(local, { throwIfNoEntry: false })?.isFile()) throw new CliError(`No such file: ${local}`);
36663
+ const remote = remoteTarget(local, remoteArg);
36524
36664
  const blob = await fs.openAsBlob(local);
36525
36665
  await withDevice(input, async (device) => {
36526
36666
  const sync = await device.adb.sync();
@@ -36544,25 +36684,15 @@ const deviceCommands = [
36544
36684
  async run(input) {
36545
36685
  const [remote, localArg] = input.args;
36546
36686
  if (!remote) throw new CliError("devicerent pull <remote-path> [local]");
36547
- const local = localArg ?? path.basename(remote);
36548
- let written = 0;
36549
- await withDevice(input, async (device) => {
36687
+ const local = localTarget(remote, localArg);
36688
+ success(`Pulled ${formatBytes(await withDevice(input, async (device) => {
36550
36689
  const sync = await device.adb.sync();
36551
- const out = fs.createWriteStream(local);
36552
36690
  try {
36553
- const reader = sync.read(remote).getReader();
36554
- for (;;) {
36555
- const { value, done } = await reader.read();
36556
- if (done) break;
36557
- written += value.length;
36558
- out.write(value);
36559
- }
36691
+ return await device.whileBusy(pullToFile(sync.read(remote), local, remote));
36560
36692
  } finally {
36561
- await new Promise((r) => out.end(() => r()));
36562
36693
  await sync.dispose();
36563
36694
  }
36564
- });
36565
- success(`Pulled ${formatBytes(written)} to ${local}`);
36695
+ }))} to ${local}`);
36566
36696
  }
36567
36697
  },
36568
36698
  {
@@ -41380,7 +41510,7 @@ const testingCommands = [
41380
41510
  });
41381
41511
  try {
41382
41512
  await new Promise((resolve, reject) => {
41383
- server.once("error", reject);
41513
+ server.once("error", (err) => reject(err.code === "EADDRINUSE" ? new CliError(`Port ${port} is already in use — is another devicerent connect (or an adb server) running there? Pick another with --port.`) : err));
41384
41514
  server.listen(port, "127.0.0.1", () => resolve());
41385
41515
  });
41386
41516
  success(`${rental.deviceName} (${ticket.serial}) is now a local adb device.`);
@@ -42179,7 +42309,7 @@ function select(view, opts = {}) {
42179
42309
  out.off("resize", draw);
42180
42310
  out.write(`${CSI}?25h`);
42181
42311
  const chosen = state.done === "chosen" ? view.choices[state.index] : null;
42182
- region.render([`${t.dim(view.title)} ${t.accent(t.g.pointer)} ${chosen ? chosen.label : t.dim("back")}`]);
42312
+ region.render([`${t.dim(view.title)} ${t.accent(t.g.pointer)} ${chosen ? sanitize(chosen.label) : t.dim("back")}`]);
42183
42313
  region.done();
42184
42314
  resolve(chosen ? chosen.value : null);
42185
42315
  });
@@ -42722,18 +42852,460 @@ function expandHome(word, p) {
42722
42852
  if (word.startsWith("~/") || p.platform === "win32" && word.startsWith("~\\")) return p.home + word.slice(1);
42723
42853
  return word;
42724
42854
  }
42855
+ function looksLikePath(w, p) {
42856
+ return (p.platform === "win32" ? /^[a-zA-Z]:[\\/]/.test(w) || w.startsWith("\\\\") || /[\\/]/.test(w) : w.startsWith("/") || w.startsWith("./") || w.startsWith("../") || w.includes("/")) || /\.apk$/i.test(w);
42857
+ }
42725
42858
  /**
42726
- * The one path a line consists of, if it is nothing but a (dragged or pasted)
42727
- * path: `"C:\My Apps\app.apk"`, `& 'C:\x\app.apk'`, `/Users/me/My\ App.apk`.
42728
- * Null when the line is anything else.
42859
+ * The paths a line consists of, if it is nothing but (dragged or pasted)
42860
+ * paths: `"C:\My Apps\app.apk"`, `& 'C:\x\app.apk'`, `/Users/me/My\ App.apk`
42861
+ * — or several at once: dragging an app and its androidTest APK together
42862
+ * drops both. Null when the line is anything else.
42729
42863
  */
42730
- function droppedPath(line, p) {
42864
+ function droppedPaths(line, p) {
42731
42865
  let s = line.trim();
42732
42866
  if (s.startsWith("& ")) s = s.slice(2).trim();
42733
42867
  const words = tokenize(s, p);
42734
- if (words.length !== 1) return null;
42735
- const w = words[0];
42736
- return (p.platform === "win32" ? /^[a-zA-Z]:[\\/]/.test(w) || w.startsWith("\\\\") || /[\\/]/.test(w) : w.startsWith("/") || w.startsWith("./") || w.startsWith("../") || w.includes("/")) || /\.apk$/i.test(w) ? w : null;
42868
+ if (words.length === 0 || !words.every((w) => looksLikePath(w, p))) return null;
42869
+ return words;
42870
+ }
42871
+ /** The one path a line consists of (see droppedPaths), or null. */
42872
+ function droppedPath(line, p) {
42873
+ const paths = droppedPaths(line, p);
42874
+ return paths?.length === 1 ? paths[0] : null;
42875
+ }
42876
+ //#endregion
42877
+ //#region src/interactive/intent.ts
42878
+ /** Session-only commands: typed with or without the slash. */
42879
+ const SESSION_ACTIONS = {
42880
+ help: "help",
42881
+ exit: "exit",
42882
+ quit: "exit",
42883
+ clear: "clear",
42884
+ permissions: "permissions",
42885
+ phones: "phones",
42886
+ balance: "balance",
42887
+ plans: "plans"
42888
+ };
42889
+ /** Words that carry no meaning after a verb: "install THE APP ON MY PHONE". */
42890
+ const FILLER = new Set("a an the my me this that it its one please now phone device android app apk application rental renting session for to some another new current of screen on in up".split(" "));
42891
+ const PACKAGE_RE = /^[a-zA-Z][\w]*(\.[a-zA-Z][\w]*)+$/;
42892
+ /** "top up 20 euros": the currency, said as a word, after an amount. */
42893
+ const CURRENCY = /* @__PURE__ */ new Set([
42894
+ "€",
42895
+ "eur",
42896
+ "euro",
42897
+ "euros"
42898
+ ]);
42899
+ /** `devicerent rent`, `npx devicerent@latest status` → `rent`, `status`. */
42900
+ const PROGRAM_NAME = /^(?:npx\s+)?devicerent(?:@\S*)?(?=\s|$)\s*/i;
42901
+ /** "20", "€20", "20€", "20eur", "20,50" → the number, or NaN. */
42902
+ function parseEuros(word) {
42903
+ return Number(word.replace(/^€|€$/g, "").replace(/(eur|euros?)$/i, "").replace(",", "."));
42904
+ }
42905
+ const VERBS = [
42906
+ [
42907
+ "help",
42908
+ "none",
42909
+ [
42910
+ "help",
42911
+ "?",
42912
+ "what can you do",
42913
+ "what can i do",
42914
+ "how does this work",
42915
+ "commands",
42916
+ "menu",
42917
+ "options"
42918
+ ]
42919
+ ],
42920
+ [
42921
+ "exit",
42922
+ "none",
42923
+ [
42924
+ "exit",
42925
+ "quit",
42926
+ "bye",
42927
+ "goodbye"
42928
+ ]
42929
+ ],
42930
+ [
42931
+ "clear",
42932
+ "none",
42933
+ ["clear", "cls"]
42934
+ ],
42935
+ [
42936
+ "rent",
42937
+ "query",
42938
+ [
42939
+ "rent",
42940
+ "hire",
42941
+ "borrow"
42942
+ ]
42943
+ ],
42944
+ [
42945
+ "stop",
42946
+ "all",
42947
+ [
42948
+ "stop",
42949
+ "end",
42950
+ "finish",
42951
+ "return",
42952
+ "release",
42953
+ "i'm done",
42954
+ "im done",
42955
+ "i am done",
42956
+ "done",
42957
+ "stop renting"
42958
+ ]
42959
+ ],
42960
+ [
42961
+ "install",
42962
+ "path",
42963
+ [
42964
+ "install",
42965
+ "upload",
42966
+ "add"
42967
+ ]
42968
+ ],
42969
+ [
42970
+ "screenshot",
42971
+ "none",
42972
+ [
42973
+ "screenshot",
42974
+ "screen shot",
42975
+ "take a screenshot",
42976
+ "take screenshot",
42977
+ "capture the screen",
42978
+ "capture screen",
42979
+ "take a picture",
42980
+ "picture"
42981
+ ]
42982
+ ],
42983
+ [
42984
+ "test",
42985
+ "target",
42986
+ [
42987
+ "test",
42988
+ "quick test",
42989
+ "smoke test",
42990
+ "check",
42991
+ "try"
42992
+ ]
42993
+ ],
42994
+ [
42995
+ "buy-credits",
42996
+ "none",
42997
+ [
42998
+ "buy credits",
42999
+ "buy ai credits",
43000
+ "buy deviceyai credits",
43001
+ "more credits",
43002
+ "credits buy"
43003
+ ]
43004
+ ],
43005
+ [
43006
+ "balance",
43007
+ "none",
43008
+ [
43009
+ "balance",
43010
+ "wallet",
43011
+ "money",
43012
+ "account",
43013
+ "whoami",
43014
+ "who am i",
43015
+ "my account",
43016
+ "my balance",
43017
+ "how much money do i have",
43018
+ "how much money",
43019
+ "credits",
43020
+ "ai credits"
43021
+ ]
43022
+ ],
43023
+ [
43024
+ "topup",
43025
+ "amount",
43026
+ [
43027
+ "top up",
43028
+ "topup",
43029
+ "add money",
43030
+ "add funds",
43031
+ "add credit",
43032
+ "recharge",
43033
+ "deposit"
43034
+ ]
43035
+ ],
43036
+ [
43037
+ "plans",
43038
+ "plan",
43039
+ [
43040
+ "plans",
43041
+ "plan",
43042
+ "pricing",
43043
+ "prices",
43044
+ "subscribe",
43045
+ "subscription",
43046
+ "upgrade"
43047
+ ]
43048
+ ],
43049
+ [
43050
+ "apps",
43051
+ "none",
43052
+ [
43053
+ "apps",
43054
+ "list apps",
43055
+ "installed apps",
43056
+ "show apps",
43057
+ "what's installed",
43058
+ "what is installed"
43059
+ ]
43060
+ ],
43061
+ [
43062
+ "phones",
43063
+ "none",
43064
+ [
43065
+ "phones",
43066
+ "devices",
43067
+ "list phones",
43068
+ "list devices",
43069
+ "show phones",
43070
+ "show devices",
43071
+ "show me the phones",
43072
+ "which phones",
43073
+ "what phones",
43074
+ "available phones",
43075
+ "available"
43076
+ ]
43077
+ ],
43078
+ [
43079
+ "status",
43080
+ "none",
43081
+ [
43082
+ "status",
43083
+ "what's running",
43084
+ "what is running",
43085
+ "rentals",
43086
+ "my rentals"
43087
+ ]
43088
+ ],
43089
+ [
43090
+ "login",
43091
+ "none",
43092
+ [
43093
+ "login",
43094
+ "log in",
43095
+ "sign in",
43096
+ "signin"
43097
+ ]
43098
+ ],
43099
+ [
43100
+ "logout",
43101
+ "none",
43102
+ [
43103
+ "logout",
43104
+ "log out",
43105
+ "sign out",
43106
+ "signout"
43107
+ ]
43108
+ ],
43109
+ [
43110
+ "launch",
43111
+ "package",
43112
+ [
43113
+ "launch",
43114
+ "open",
43115
+ "start",
43116
+ "run"
43117
+ ]
43118
+ ],
43119
+ [
43120
+ "uninstall",
43121
+ "package",
43122
+ [
43123
+ "uninstall",
43124
+ "remove",
43125
+ "delete"
43126
+ ]
43127
+ ],
43128
+ [
43129
+ "logs",
43130
+ "none",
43131
+ [
43132
+ "logs",
43133
+ "log",
43134
+ "logcat",
43135
+ "show logs",
43136
+ "show me the logs",
43137
+ "show the logs"
43138
+ ]
43139
+ ],
43140
+ [
43141
+ "permissions",
43142
+ "none",
43143
+ ["permissions"]
43144
+ ]
43145
+ ];
43146
+ const RENT_PHRASE = /^(?:can i |could i |please )?(?:get|give me|i want|i need|i'd like|id like|book|start|grab)(?: me)?(?: (?:a|an|another|one|new))? (phone|device|android|pixel|galaxy|samsung)\b(.*)$/;
43147
+ function normalize(line) {
43148
+ return line.toLowerCase().replace(/[.!?,]+$/g, "").replace(/\s+/g, " ").trim();
43149
+ }
43150
+ /** Classify the words after a verb; null when they do not fit the action. */
43151
+ function fitArgs(mode, rest, env) {
43152
+ const meaningful = rest.filter((w) => !FILLER.has(w.toLowerCase()) && !(mode === "amount" && CURRENCY.has(w.toLowerCase())));
43153
+ if (mode === "query") return meaningful.length ? { arg: meaningful.join(" ") } : {};
43154
+ if (mode === "all" && rest.some((w) => /^(app|apk|application)s?$/i.test(w))) return null;
43155
+ if (meaningful.length === 0) return {};
43156
+ if (meaningful.length !== 1) return null;
43157
+ const w = meaningful[0];
43158
+ const lower = w.toLowerCase();
43159
+ switch (mode) {
43160
+ case "all": return lower === "all" || lower === "everything" ? { arg: "all" } : null;
43161
+ case "path": return env.fileExists(w) ? { arg: w } : null;
43162
+ case "package": return PACKAGE_RE.test(w) ? { arg: w } : null;
43163
+ case "target": return env.fileExists(w) || PACKAGE_RE.test(w) ? { arg: w } : null;
43164
+ case "amount": {
43165
+ const n = parseEuros(w);
43166
+ return Number.isFinite(n) && n > 0 ? { arg: String(n) } : null;
43167
+ }
43168
+ case "plan": return [
43169
+ "plus",
43170
+ "pro",
43171
+ "slot"
43172
+ ].includes(lower) ? { arg: lower } : null;
43173
+ default: return null;
43174
+ }
43175
+ }
43176
+ /** What may follow a command's name for the line to count as that command. */
43177
+ function positionalsFit(name, rest, env) {
43178
+ const positionals = rest.filter((w, i) => !w.startsWith("-") && !(i > 0 && rest[i - 1].startsWith("--") && !rest[i - 1].includes("=")));
43179
+ if (positionals.length === 0) return true;
43180
+ switch (name) {
43181
+ case "shell":
43182
+ case "ai":
43183
+ case "keys create":
43184
+ case "keys revoke": return true;
43185
+ case "push": return env.fileExists(positionals[0]);
43186
+ case "pull": return positionals[0].startsWith("/");
43187
+ case "screenshot":
43188
+ case "install":
43189
+ case "test quick": return positionals.every((p) => env.fileExists(p) || PACKAGE_RE.test(p) || p.endsWith(".png"));
43190
+ case "launch":
43191
+ case "uninstall": return PACKAGE_RE.test(positionals[0]);
43192
+ case "topup": return Number.isFinite(Number(positionals[0]));
43193
+ case "subscribe": return [
43194
+ "plus",
43195
+ "pro",
43196
+ "slot"
43197
+ ].includes(positionals[0]);
43198
+ case "credits": return positionals[0] === "buy";
43199
+ case "stop": return /^[0-9a-f][0-9a-f-]{7,}$/i.test(positionals[0]);
43200
+ default: return false;
43201
+ }
43202
+ }
43203
+ function matchCommand(words, names) {
43204
+ const byLength = [...names].sort((a, b) => b.split(" ").length - a.split(" ").length);
43205
+ for (const name of byLength) {
43206
+ const parts = name.split(" ");
43207
+ if (parts.every((p, i) => words[i] === p)) return {
43208
+ name,
43209
+ rest: words.slice(parts.length)
43210
+ };
43211
+ }
43212
+ return null;
43213
+ }
43214
+ /** Edit distance with adjacent swaps ("stauts" → "status" is 1). */
43215
+ function editDistance(a, b) {
43216
+ const d = Array.from({ length: a.length + 1 }, (_, i) => Array.from({ length: b.length + 1 }, (_, j) => i === 0 ? j : j === 0 ? i : 0));
43217
+ for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) {
43218
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
43219
+ d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
43220
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
43221
+ }
43222
+ return d[a.length][b.length];
43223
+ }
43224
+ /** The commands and actions a mistyped single word was probably meant to be. */
43225
+ function nearMisses(word, env) {
43226
+ const candidates = /* @__PURE__ */ new Set([
43227
+ ...env.commandNames.filter((n) => !n.includes(" ")),
43228
+ ...Object.keys(SESSION_ACTIONS),
43229
+ "rent",
43230
+ "stop",
43231
+ "install",
43232
+ "screenshot",
43233
+ "topup",
43234
+ "logs"
43235
+ ]);
43236
+ const limit = word.length <= 4 ? 1 : 2;
43237
+ return [...candidates].map((c) => ({
43238
+ c,
43239
+ d: editDistance(word, c)
43240
+ })).filter((x) => x.d > 0 && x.d <= limit).sort((x, y) => x.d - y.d || x.c.localeCompare(y.c)).slice(0, 3).map((x) => x.c);
43241
+ }
43242
+ function interpret(line, env) {
43243
+ const typed = line.trim();
43244
+ if (!typed) return { kind: "empty" };
43245
+ const trimmed = typed.replace(PROGRAM_NAME, "");
43246
+ if (!trimmed || trimmed === "?") return {
43247
+ kind: "action",
43248
+ action: "help"
43249
+ };
43250
+ const dropped = droppedPaths(trimmed, env);
43251
+ if (dropped && dropped.every((p) => env.fileExists(p))) return {
43252
+ kind: "install-file",
43253
+ paths: dropped
43254
+ };
43255
+ if (trimmed.startsWith("/")) {
43256
+ const words = tokenize(trimmed.slice(1), env);
43257
+ if (words.length === 0) return {
43258
+ kind: "action",
43259
+ action: "help"
43260
+ };
43261
+ const session = SESSION_ACTIONS[words[0].toLowerCase()];
43262
+ if (session && words.length === 1) return {
43263
+ kind: "action",
43264
+ action: session
43265
+ };
43266
+ return {
43267
+ kind: "command",
43268
+ argv: words
43269
+ };
43270
+ }
43271
+ const norm = normalize(trimmed);
43272
+ const words = tokenize(trimmed, env);
43273
+ const normWords = norm.split(" ");
43274
+ const rent = RENT_PHRASE.exec(norm);
43275
+ if (rent) {
43276
+ const query = [rent[1], ...rent[2].trim().split(" ")].filter((w) => w && !FILLER.has(w)).join(" ");
43277
+ return {
43278
+ kind: "action",
43279
+ action: "rent",
43280
+ ...query ? { arg: query } : {}
43281
+ };
43282
+ }
43283
+ for (const [action, mode, verbs] of VERBS) {
43284
+ const verb = [...verbs].sort((a, b) => b.length - a.length).find((v) => norm === v || norm.startsWith(`${v} `));
43285
+ if (!verb) continue;
43286
+ const fit = fitArgs(mode, words.slice(verb.split(" ").length), env);
43287
+ if (fit) return {
43288
+ kind: "action",
43289
+ action,
43290
+ ...fit
43291
+ };
43292
+ }
43293
+ const found = matchCommand(words, env.commandNames);
43294
+ if (found && positionalsFit(found.name, found.rest, env)) return {
43295
+ kind: "command",
43296
+ argv: words
43297
+ };
43298
+ if (normWords.length === 1) {
43299
+ const near = nearMisses(normWords[0], env);
43300
+ return near.length ? {
43301
+ kind: "did-you-mean",
43302
+ options: near
43303
+ } : { kind: "unknown" };
43304
+ }
43305
+ return {
43306
+ kind: "ai",
43307
+ task: trimmed
43308
+ };
42737
43309
  }
42738
43310
  //#endregion
42739
43311
  //#region src/interactive/flows.ts
@@ -42807,7 +43379,10 @@ async function logoutFlow(s) {
42807
43379
  if (await confirm("Sign this terminal out?", ["Your browser stays signed in. Rented phones keep running."], "Yes, sign out", "No")) await s.run(["logout"], { title: "Signing out" });
42808
43380
  }
42809
43381
  async function loadFleet(s) {
42810
- return s.runTask(() => loadFleetDevices(s.ctx), { spinner: "Checking which phones are free" });
43382
+ return s.runTask(() => loadFleetDevices(s.ctx), {
43383
+ spinner: "Checking which phones are free",
43384
+ refresh: false
43385
+ });
42811
43386
  }
42812
43387
  async function phonesFlow(s) {
42813
43388
  if (!await ensureSignedIn(s)) return;
@@ -42974,21 +43549,30 @@ async function pickApk(s, title) {
42974
43549
  }
42975
43550
  return file;
42976
43551
  }
42977
- async function installFlow(s, file) {
43552
+ /**
43553
+ * The app to offer to open after an install: an app and its androidTest APK
43554
+ * dragged in together install both, and the test package (`….test`) has no
43555
+ * screen to open.
43556
+ */
43557
+ function appToOpen(apps) {
43558
+ return apps.flatMap((a) => a.packageName ? [a.packageName] : []).find((n) => !n.endsWith(".test")) ?? null;
43559
+ }
43560
+ async function installFlow(s, files) {
42978
43561
  const rental = await pickRental(s, "Install on which phone?");
42979
43562
  if (!rental) return;
42980
- const apk = file ?? await pickApk(s, "Which app do you want to install?");
42981
- if (!apk) return;
43563
+ const picked = files?.length ? null : await pickApk(s, "Which app do you want to install?");
43564
+ const apks = files?.length ? [...files] : picked ? [picked] : [];
43565
+ if (apks.length === 0) return;
42982
43566
  let pkg = null;
42983
43567
  if (await s.run([
42984
43568
  "install",
42985
- apk,
43569
+ ...apks,
42986
43570
  "--rental",
42987
43571
  rental.id
42988
43572
  ], {
42989
- title: `Installing ${path.basename(apk)} on ${rental.deviceName}`,
43573
+ title: `Installing ${apks.map((a) => path.basename(a)).join(" and ")} on ${rental.deviceName}`,
42990
43574
  onResult: (r) => {
42991
- if (r.kind === "installed") pkg = r.apps.find((a) => a.packageName)?.packageName ?? null;
43575
+ if (r.kind === "installed") pkg = appToOpen(r.apps);
42992
43576
  }
42993
43577
  }) !== 0 || !pkg) return;
42994
43578
  const app = pkg;
@@ -43007,7 +43591,10 @@ async function listApps(s, rental) {
43007
43591
  "list",
43008
43592
  "packages",
43009
43593
  "-3"
43010
- ]))), { spinner: `Reading the apps on ${rental.deviceName}` });
43594
+ ]))), {
43595
+ spinner: `Reading the apps on ${rental.deviceName}`,
43596
+ refresh: false
43597
+ });
43011
43598
  }
43012
43599
  async function pickApp(s, rental, title, extra = []) {
43013
43600
  const apps = await listApps(s, rental);
@@ -43152,7 +43739,9 @@ async function topupFlow(s, amount) {
43152
43739
  });
43153
43740
  if (value === OTHER) {
43154
43741
  const typed = await ask(" How many euros? ");
43155
- value = typed ? typed.replace(/^€/, "").trim() : null;
43742
+ if (!typed) return;
43743
+ const n = parseEuros(typed.replace(/\s*(?:eur|euros?)$/i, "").trim());
43744
+ value = Number.isFinite(n) ? String(n) : typed.trim();
43156
43745
  }
43157
43746
  }
43158
43747
  if (!value) return;
@@ -43302,427 +43891,20 @@ function helpFlow(s) {
43302
43891
  s.hint("Type / for every command · permissions changes what DeviceyAI may do · exit to leave");
43303
43892
  }
43304
43893
  //#endregion
43305
- //#region src/interactive/intent.ts
43306
- /** Session-only commands: typed with or without the slash. */
43307
- const SESSION_ACTIONS = {
43308
- help: "help",
43309
- exit: "exit",
43310
- quit: "exit",
43311
- clear: "clear",
43312
- permissions: "permissions",
43313
- phones: "phones",
43314
- balance: "balance",
43315
- plans: "plans"
43316
- };
43317
- /** Words that carry no meaning after a verb: "install THE APP ON MY PHONE". */
43318
- const FILLER = new Set("a an the my me this that it its one please now phone device android app apk application rental renting session for to some another new current of screen on in up".split(" "));
43319
- const PACKAGE_RE = /^[a-zA-Z][\w]*(\.[a-zA-Z][\w]*)+$/;
43320
- const VERBS = [
43321
- [
43322
- "help",
43323
- "none",
43324
- [
43325
- "help",
43326
- "?",
43327
- "what can you do",
43328
- "what can i do",
43329
- "how does this work",
43330
- "commands",
43331
- "menu",
43332
- "options"
43333
- ]
43334
- ],
43335
- [
43336
- "exit",
43337
- "none",
43338
- [
43339
- "exit",
43340
- "quit",
43341
- "bye",
43342
- "goodbye"
43343
- ]
43344
- ],
43345
- [
43346
- "clear",
43347
- "none",
43348
- ["clear", "cls"]
43349
- ],
43350
- [
43351
- "rent",
43352
- "query",
43353
- [
43354
- "rent",
43355
- "hire",
43356
- "borrow"
43357
- ]
43358
- ],
43359
- [
43360
- "stop",
43361
- "all",
43362
- [
43363
- "stop",
43364
- "end",
43365
- "finish",
43366
- "return",
43367
- "release",
43368
- "i'm done",
43369
- "im done",
43370
- "i am done",
43371
- "done",
43372
- "stop renting"
43373
- ]
43374
- ],
43375
- [
43376
- "install",
43377
- "path",
43378
- [
43379
- "install",
43380
- "upload",
43381
- "add"
43382
- ]
43383
- ],
43384
- [
43385
- "screenshot",
43386
- "none",
43387
- [
43388
- "screenshot",
43389
- "screen shot",
43390
- "take a screenshot",
43391
- "take screenshot",
43392
- "capture the screen",
43393
- "capture screen",
43394
- "take a picture",
43395
- "picture"
43396
- ]
43397
- ],
43398
- [
43399
- "test",
43400
- "target",
43401
- [
43402
- "test",
43403
- "quick test",
43404
- "smoke test",
43405
- "check",
43406
- "try"
43407
- ]
43408
- ],
43409
- [
43410
- "buy-credits",
43411
- "none",
43412
- [
43413
- "buy credits",
43414
- "buy ai credits",
43415
- "buy deviceyai credits",
43416
- "more credits",
43417
- "credits buy"
43418
- ]
43419
- ],
43420
- [
43421
- "balance",
43422
- "none",
43423
- [
43424
- "balance",
43425
- "wallet",
43426
- "money",
43427
- "account",
43428
- "whoami",
43429
- "who am i",
43430
- "my account",
43431
- "my balance",
43432
- "how much money do i have",
43433
- "how much money",
43434
- "credits",
43435
- "ai credits"
43436
- ]
43437
- ],
43438
- [
43439
- "topup",
43440
- "amount",
43441
- [
43442
- "top up",
43443
- "topup",
43444
- "add money",
43445
- "add funds",
43446
- "add credit",
43447
- "recharge",
43448
- "deposit"
43449
- ]
43450
- ],
43451
- [
43452
- "plans",
43453
- "plan",
43454
- [
43455
- "plans",
43456
- "plan",
43457
- "pricing",
43458
- "prices",
43459
- "subscribe",
43460
- "subscription",
43461
- "upgrade"
43462
- ]
43463
- ],
43464
- [
43465
- "apps",
43466
- "none",
43467
- [
43468
- "apps",
43469
- "list apps",
43470
- "installed apps",
43471
- "show apps",
43472
- "what's installed",
43473
- "what is installed"
43474
- ]
43475
- ],
43476
- [
43477
- "phones",
43478
- "none",
43479
- [
43480
- "phones",
43481
- "devices",
43482
- "list phones",
43483
- "list devices",
43484
- "show phones",
43485
- "show devices",
43486
- "show me the phones",
43487
- "which phones",
43488
- "what phones",
43489
- "available phones",
43490
- "available"
43491
- ]
43492
- ],
43493
- [
43494
- "status",
43495
- "none",
43496
- [
43497
- "status",
43498
- "what's running",
43499
- "what is running",
43500
- "rentals",
43501
- "my rentals"
43502
- ]
43503
- ],
43504
- [
43505
- "login",
43506
- "none",
43507
- [
43508
- "login",
43509
- "log in",
43510
- "sign in",
43511
- "signin"
43512
- ]
43513
- ],
43514
- [
43515
- "logout",
43516
- "none",
43517
- [
43518
- "logout",
43519
- "log out",
43520
- "sign out",
43521
- "signout"
43522
- ]
43523
- ],
43524
- [
43525
- "launch",
43526
- "package",
43527
- [
43528
- "launch",
43529
- "open",
43530
- "start",
43531
- "run"
43532
- ]
43533
- ],
43534
- [
43535
- "uninstall",
43536
- "package",
43537
- [
43538
- "uninstall",
43539
- "remove",
43540
- "delete"
43541
- ]
43542
- ],
43543
- [
43544
- "logs",
43545
- "none",
43546
- [
43547
- "logs",
43548
- "log",
43549
- "logcat",
43550
- "show logs",
43551
- "show me the logs",
43552
- "show the logs"
43553
- ]
43554
- ],
43555
- [
43556
- "permissions",
43557
- "none",
43558
- ["permissions"]
43559
- ]
43560
- ];
43561
- const RENT_PHRASE = /^(?:can i |could i |please )?(?:get|give me|i want|i need|i'd like|id like|book|start|grab)(?: me)?(?: (?:a|an|another|one|new))? (phone|device|android|pixel|galaxy|samsung)\b(.*)$/;
43562
- function normalize(line) {
43563
- return line.toLowerCase().replace(/[.!?,]+$/g, "").replace(/\s+/g, " ").trim();
43564
- }
43565
- /** Classify the words after a verb; null when they do not fit the action. */
43566
- function fitArgs(mode, rest, env) {
43567
- const meaningful = rest.filter((w) => !FILLER.has(w.toLowerCase()));
43568
- if (mode === "query") return meaningful.length ? { arg: meaningful.join(" ") } : {};
43569
- if (mode === "all" && rest.some((w) => /^(app|apk|application)s?$/i.test(w))) return null;
43570
- if (meaningful.length === 0) return {};
43571
- if (meaningful.length !== 1) return null;
43572
- const w = meaningful[0];
43573
- const lower = w.toLowerCase();
43574
- switch (mode) {
43575
- case "all": return lower === "all" || lower === "everything" ? { arg: "all" } : null;
43576
- case "path": return env.fileExists(w) ? { arg: w } : null;
43577
- case "package": return PACKAGE_RE.test(w) ? { arg: w } : null;
43578
- case "target": return env.fileExists(w) || PACKAGE_RE.test(w) ? { arg: w } : null;
43579
- case "amount": {
43580
- const n = Number(w.replace(/^€/, "").replace(/(eur|euros?)$/i, ""));
43581
- return Number.isFinite(n) && n > 0 ? { arg: String(n) } : null;
43582
- }
43583
- case "plan": return [
43584
- "plus",
43585
- "pro",
43586
- "slot"
43587
- ].includes(lower) ? { arg: lower } : null;
43588
- default: return null;
43589
- }
43590
- }
43591
- /** What may follow a command's name for the line to count as that command. */
43592
- function positionalsFit(name, rest, env) {
43593
- const positionals = rest.filter((w, i) => !w.startsWith("-") && !(i > 0 && rest[i - 1].startsWith("--") && !rest[i - 1].includes("=")));
43594
- if (positionals.length === 0) return true;
43595
- switch (name) {
43596
- case "shell":
43597
- case "ai":
43598
- case "keys create":
43599
- case "keys revoke": return true;
43600
- case "push": return env.fileExists(positionals[0]);
43601
- case "pull": return positionals[0].startsWith("/");
43602
- case "screenshot":
43603
- case "install":
43604
- case "test quick": return positionals.every((p) => env.fileExists(p) || PACKAGE_RE.test(p) || p.endsWith(".png"));
43605
- case "launch":
43606
- case "uninstall": return PACKAGE_RE.test(positionals[0]);
43607
- case "topup": return Number.isFinite(Number(positionals[0]));
43608
- case "subscribe": return [
43609
- "plus",
43610
- "pro",
43611
- "slot"
43612
- ].includes(positionals[0]);
43613
- case "credits": return positionals[0] === "buy";
43614
- case "stop": return /^[0-9a-f][0-9a-f-]{7,}$/i.test(positionals[0]);
43615
- default: return false;
43616
- }
43617
- }
43618
- function matchCommand(words, names) {
43619
- const byLength = [...names].sort((a, b) => b.split(" ").length - a.split(" ").length);
43620
- for (const name of byLength) {
43621
- const parts = name.split(" ");
43622
- if (parts.every((p, i) => words[i] === p)) return {
43623
- name,
43624
- rest: words.slice(parts.length)
43625
- };
43626
- }
43627
- return null;
43628
- }
43629
- /** Edit distance with adjacent swaps ("stauts" → "status" is 1). */
43630
- function editDistance(a, b) {
43631
- const d = Array.from({ length: a.length + 1 }, (_, i) => Array.from({ length: b.length + 1 }, (_, j) => i === 0 ? j : j === 0 ? i : 0));
43632
- for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) {
43633
- const cost = a[i - 1] === b[j - 1] ? 0 : 1;
43634
- d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
43635
- if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
43636
- }
43637
- return d[a.length][b.length];
43638
- }
43639
- /** The commands and actions a mistyped single word was probably meant to be. */
43640
- function nearMisses(word, env) {
43641
- const candidates = /* @__PURE__ */ new Set([
43642
- ...env.commandNames.filter((n) => !n.includes(" ")),
43643
- ...Object.keys(SESSION_ACTIONS),
43644
- "rent",
43645
- "stop",
43646
- "install",
43647
- "screenshot",
43648
- "topup",
43649
- "logs"
43650
- ]);
43651
- const limit = word.length <= 4 ? 1 : 2;
43652
- return [...candidates].map((c) => ({
43653
- c,
43654
- d: editDistance(word, c)
43655
- })).filter((x) => x.d > 0 && x.d <= limit).sort((x, y) => x.d - y.d || x.c.localeCompare(y.c)).slice(0, 3).map((x) => x.c);
43656
- }
43657
- function interpret(line, env) {
43658
- const trimmed = line.trim();
43659
- if (!trimmed) return { kind: "empty" };
43660
- if (trimmed === "?") return {
43661
- kind: "action",
43662
- action: "help"
43663
- };
43664
- const dropped = droppedPath(trimmed, env);
43665
- if (dropped && env.fileExists(dropped)) return {
43666
- kind: "install-file",
43667
- path: dropped
43668
- };
43669
- if (trimmed.startsWith("/")) {
43670
- const words = tokenize(trimmed.slice(1), env);
43671
- if (words.length === 0) return {
43672
- kind: "action",
43673
- action: "help"
43674
- };
43675
- const session = SESSION_ACTIONS[words[0].toLowerCase()];
43676
- if (session && words.length === 1) return {
43677
- kind: "action",
43678
- action: session
43679
- };
43680
- return {
43681
- kind: "command",
43682
- argv: words
43683
- };
43684
- }
43685
- const norm = normalize(trimmed);
43686
- const words = tokenize(trimmed, env);
43687
- const normWords = norm.split(" ");
43688
- const rent = RENT_PHRASE.exec(norm);
43689
- if (rent) {
43690
- const query = [rent[1], ...rent[2].trim().split(" ")].filter((w) => w && !FILLER.has(w)).join(" ");
43691
- return {
43692
- kind: "action",
43693
- action: "rent",
43694
- ...query ? { arg: query } : {}
43695
- };
43696
- }
43697
- for (const [action, mode, verbs] of VERBS) {
43698
- const verb = [...verbs].sort((a, b) => b.length - a.length).find((v) => norm === v || norm.startsWith(`${v} `));
43699
- if (!verb) continue;
43700
- const fit = fitArgs(mode, words.slice(verb.split(" ").length), env);
43701
- if (fit) return {
43702
- kind: "action",
43703
- action,
43704
- ...fit
43705
- };
43706
- }
43707
- const found = matchCommand(words, env.commandNames);
43708
- if (found && positionalsFit(found.name, found.rest, env)) return {
43709
- kind: "command",
43710
- argv: words
43711
- };
43712
- if (normWords.length === 1) {
43713
- const near = nearMisses(normWords[0], env);
43714
- return near.length ? {
43715
- kind: "did-you-mean",
43716
- options: near
43717
- } : { kind: "unknown" };
43718
- }
43719
- return {
43720
- kind: "ai",
43721
- task: trimmed
43722
- };
43723
- }
43724
- //#endregion
43725
43894
  //#region src/interactive/session.ts
43895
+ /** What can change the wallet, credits, plan, rentals or who is signed in. */
43896
+ const CHANGES_ACCOUNT = /* @__PURE__ */ new Set([
43897
+ "rent",
43898
+ "stop",
43899
+ "login",
43900
+ "logout",
43901
+ "ai"
43902
+ ]);
43903
+ /** True when, after the command `argv` names, the session's account view may be stale. */
43904
+ function refreshesAfter(argv) {
43905
+ const found = findCommand(argv);
43906
+ return found !== null && CHANGES_ACCOUNT.has(found.command.name);
43907
+ }
43726
43908
  /** Some next steps for refusals a person can fix themselves. */
43727
43909
  function nextStepFor(message) {
43728
43910
  const m = message.toLowerCase();
@@ -43918,7 +44100,7 @@ var Session = class {
43918
44100
  undoIndent();
43919
44101
  process.off("SIGINT", onSigint);
43920
44102
  if (opts.raw) restoreTerminal();
43921
- await this.refresh().catch(() => {});
44103
+ if (opts.refresh !== false) await this.refresh().catch(() => {});
43922
44104
  }
43923
44105
  }
43924
44106
  /**
@@ -43947,7 +44129,8 @@ var Session = class {
43947
44129
  return outcome.code;
43948
44130
  }, {
43949
44131
  ...opts,
43950
- raw
44132
+ raw,
44133
+ refresh: opts.refresh ?? refreshesAfter(argv)
43951
44134
  });
43952
44135
  }
43953
44136
  /** True when the command named by argv acts on one rented phone. */
@@ -44044,7 +44227,7 @@ async function runAction(s, action, arg) {
44044
44227
  return;
44045
44228
  case "rent": return rentFlow(s, arg);
44046
44229
  case "stop": return stopFlow(s, arg);
44047
- case "install": return installFlow(s, arg);
44230
+ case "install": return installFlow(s, arg ? [arg] : void 0);
44048
44231
  case "screenshot": return screenshotFlow(s);
44049
44232
  case "test": return testFlow(s, arg);
44050
44233
  case "balance": return balanceFlow(s);
@@ -44072,7 +44255,7 @@ async function runAction(s, action, arg) {
44072
44255
  async function dispatch(s, intent) {
44073
44256
  switch (intent.kind) {
44074
44257
  case "empty": return;
44075
- case "install-file": return installFlow(s, intent.path);
44258
+ case "install-file": return installFlow(s, intent.paths);
44076
44259
  case "action": return runAction(s, intent.action, intent.arg);
44077
44260
  case "command": return runCommandLineGuided(s, intent.argv);
44078
44261
  case "did-you-mean": {
@@ -44113,8 +44296,9 @@ async function confirmExit(s) {
44113
44296
  await s.refreshRentals().catch(() => {});
44114
44297
  if (s.rentals.length === 0) return true;
44115
44298
  const names = s.rentals.map((r) => r.deviceName).join(", ");
44299
+ const isAre = s.rentals.length === 1 ? "is" : "are";
44116
44300
  const choice = await select({
44117
- title: `${names} ${s.rentals.length === 1 ? "is" : "are"} still rented`,
44301
+ title: `${names} ${isAre} still rented`,
44118
44302
  body: ["Billing continues until you stop it, or until it has sat idle for 30 minutes."],
44119
44303
  choices: [
44120
44304
  {
@@ -44132,11 +44316,12 @@ async function confirmExit(s) {
44132
44316
  ]
44133
44317
  });
44134
44318
  if (choice === "stop") {
44135
- await s.run(["stop", "--all"], {
44319
+ if (await s.run(["stop", "--all"], {
44136
44320
  title: `Stopping ${names}`,
44137
44321
  spinner: "Stopping and settling the bill"
44138
- });
44139
- return true;
44322
+ }) === 0) return true;
44323
+ s.hint(`${names} ${isAre} still rented and billing. Try exit again, or choose "Leave it running".`);
44324
+ return false;
44140
44325
  }
44141
44326
  if (choice === "leave") {
44142
44327
  s.hint("It keeps running. Stop it later with: npx devicerent stop");