devicerent 0.1.0 → 0.2.0

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.
@@ -2,14 +2,15 @@
2
2
  import { createRequire } from "node:module";
3
3
  import { parseArgs } from "node:util";
4
4
  import fs from "node:fs";
5
+ import os from "node:os";
5
6
  import path from "node:path";
6
7
  import { createDecipheriv, createHash, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes, randomUUID } from "node:crypto";
7
8
  import http from "node:http";
8
- import { spawn } from "node:child_process";
9
- import os from "node:os";
9
+ import { execFile, spawn } from "node:child_process";
10
10
  import readline from "node:readline/promises";
11
11
  import sharp from "sharp";
12
12
  import net from "node:net";
13
+ import readline$1 from "node:readline";
13
14
  //#region \0rolldown/runtime.js
14
15
  var __create = Object.create;
15
16
  var __defProp = Object.defineProperty;
@@ -76,6 +77,61 @@ function int$1(opts, key, fallback) {
76
77
  return n;
77
78
  }
78
79
  //#endregion
80
+ //#region src/interactive/gate.ts
81
+ /** CI=true / CI=1 / CI=anything — but not CI=false or CI=0. */
82
+ function isCi(env) {
83
+ const v = env.CI;
84
+ return v !== void 0 && v !== "" && v !== "false" && v !== "0";
85
+ }
86
+ function shouldStartInteractive(i) {
87
+ return i.argv.length === 0 && !i.globals.includes("--json") && i.stdinTTY && i.stdoutTTY && i.stderrTTY && i.env.TERM !== "dumb" && !isCi(i.env) && !i.env.DEVICERENT_NO_INTERACTIVE;
88
+ }
89
+ //#endregion
90
+ //#region src/output.ts
91
+ /** An error whose message is already written for the person at the keyboard. */
92
+ var CliError = class extends Error {
93
+ exitCode;
94
+ constructor(message, exitCode = 1) {
95
+ super(message);
96
+ this.exitCode = exitCode;
97
+ }
98
+ };
99
+ const useColor = () => !process.env.NO_COLOR && process.stderr.isTTY && process.stdout.isTTY;
100
+ const paint = (code) => (s) => useColor() ? `\u001b[${code}m${s}\u001b[0m` : s;
101
+ const bold = paint(1);
102
+ const dim = paint(2);
103
+ const red = paint(31);
104
+ const green = paint(32);
105
+ const yellow = paint(33);
106
+ function note(message) {
107
+ process.stderr.write(`${message}\n`);
108
+ }
109
+ function warn(message) {
110
+ process.stderr.write(`${yellow("!")} ${message}\n`);
111
+ }
112
+ function success(message) {
113
+ process.stderr.write(`${green("✓")} ${message}\n`);
114
+ }
115
+ function printJson(value) {
116
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
117
+ }
118
+ function printLine(line = "") {
119
+ process.stdout.write(`${line}\n`);
120
+ }
121
+ /** A plain left-aligned table; widths fit the widest cell per column. */
122
+ function formatTable(headers, rows) {
123
+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)));
124
+ const line = (cells) => cells.map((c, i) => i === cells.length - 1 ? c : c.padEnd(widths[i])).join(" ").trimEnd();
125
+ return [line(headers), ...rows.map(line)].join("\n");
126
+ }
127
+ function printTable(headers, rows) {
128
+ if (rows.length === 0) return;
129
+ printLine(formatTable(headers, rows));
130
+ }
131
+ function eur(n) {
132
+ return `€${n.toFixed(2)}`;
133
+ }
134
+ //#endregion
79
135
  //#region ../../../OneDrive/Masaüstü/DeviceRent Software part 5/node_modules/zod/v4/core/core.js
80
136
  var _a$1;
81
137
  function $constructor(name, initializer, params) {
@@ -26909,51 +26965,6 @@ function openSealed(privateKey, sealed, aad) {
26909
26965
  return Buffer.concat([decipher.update(body), decipher.final()]).toString("utf8");
26910
26966
  }
26911
26967
  //#endregion
26912
- //#region src/output.ts
26913
- /** An error whose message is already written for the person at the keyboard. */
26914
- var CliError = class extends Error {
26915
- exitCode;
26916
- constructor(message, exitCode = 1) {
26917
- super(message);
26918
- this.exitCode = exitCode;
26919
- }
26920
- };
26921
- const useColor = () => !process.env.NO_COLOR && process.stderr.isTTY && process.stdout.isTTY;
26922
- const paint = (code) => (s) => useColor() ? `\u001b[${code}m${s}\u001b[0m` : s;
26923
- const bold = paint(1);
26924
- const dim = paint(2);
26925
- const red = paint(31);
26926
- const green = paint(32);
26927
- const yellow = paint(33);
26928
- function note(message) {
26929
- process.stderr.write(`${message}\n`);
26930
- }
26931
- function warn(message) {
26932
- process.stderr.write(`${yellow("!")} ${message}\n`);
26933
- }
26934
- function success(message) {
26935
- process.stderr.write(`${green("✓")} ${message}\n`);
26936
- }
26937
- function printJson(value) {
26938
- process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
26939
- }
26940
- function printLine(line = "") {
26941
- process.stdout.write(`${line}\n`);
26942
- }
26943
- /** A plain left-aligned table; widths fit the widest cell per column. */
26944
- function formatTable(headers, rows) {
26945
- const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)));
26946
- const line = (cells) => cells.map((c, i) => i === cells.length - 1 ? c : c.padEnd(widths[i])).join(" ").trimEnd();
26947
- return [line(headers), ...rows.map(line)].join("\n");
26948
- }
26949
- function printTable(headers, rows) {
26950
- if (rows.length === 0) return;
26951
- printLine(formatTable(headers, rows));
26952
- }
26953
- function eur(n) {
26954
- return `€${n.toFixed(2)}`;
26955
- }
26956
- //#endregion
26957
26968
  //#region src/version.ts
26958
26969
  /**
26959
26970
  * This CLI's version. Sent to the server in every request and in the login
@@ -26961,7 +26972,7 @@ function eur(n) {
26961
26972
  * version.test.ts pins it to package.json so the two cannot drift (the
26962
26973
  * connector's hard-coded `--version` once did).
26963
26974
  */
26964
- const CLI_VERSION = "0.1.0";
26975
+ const CLI_VERSION = "0.2.0";
26965
26976
  //#endregion
26966
26977
  //#region src/auth/loopback.ts
26967
26978
  function page(message) {
@@ -27007,9 +27018,20 @@ function listenOnLoopback(state) {
27007
27018
  }
27008
27019
  //#endregion
27009
27020
  //#region src/auth/terminal.ts
27010
- /** Open a URL in the default browser. False when there is no way to. */
27021
+ /**
27022
+ * A Windows system program by its full path under %SystemRoot%\System32.
27023
+ * Never a bare name: Windows looks in the CURRENT folder before PATH, so a
27024
+ * `rundll32.exe` or `powershell.exe` sitting in whatever folder the person
27025
+ * ran `npx devicerent` from (a downloaded project, Downloads itself) would
27026
+ * run instead of the real one.
27027
+ */
27028
+ function systemExe(relative) {
27029
+ const root = process.env.SystemRoot ?? process.env.windir ?? "C:\\Windows";
27030
+ return path.win32.join(root, "System32", relative);
27031
+ }
27032
+ /** Open a URL (or a file) in its default app. False when there is no way to. */
27011
27033
  function openBrowser(url) {
27012
- const [cmd, args] = process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", url]] : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
27034
+ const [cmd, args] = process.platform === "win32" ? [systemExe("rundll32.exe"), ["url.dll,FileProtocolHandler", url]] : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
27013
27035
  try {
27014
27036
  const child = spawn(cmd, args, {
27015
27037
  stdio: "ignore",
@@ -27022,15 +27044,31 @@ function openBrowser(url) {
27022
27044
  return false;
27023
27045
  }
27024
27046
  }
27025
- /** One line from the person at the terminal. Refuses when there is none. */
27047
+ /** Ctrl-C at a prompt: a deliberate stop, exit code 130 as for SIGINT. */
27048
+ const CANCELLED = () => new CliError("Cancelled.", 130);
27049
+ /**
27050
+ * One line from the person at the terminal. Refuses when there is none.
27051
+ *
27052
+ * readline puts the terminal in raw mode while it asks, so Ctrl-C arrives as a
27053
+ * key, never as SIGINT: a process-level SIGINT handler never sees it, and
27054
+ * Node 24 just rejects the question with a bare AbortError. Ctrl-C (and an
27055
+ * aborted `signal`) therefore end here, as a plain "Cancelled.".
27056
+ */
27026
27057
  async function promptLine(question, signal) {
27027
27058
  if (!process.stdin.isTTY) throw new CliError("This step needs an interactive terminal.");
27028
27059
  const rl = readline.createInterface({
27029
27060
  input: process.stdin,
27030
27061
  output: process.stderr
27031
27062
  });
27063
+ const interrupted = new Promise((_, reject) => {
27064
+ rl.once("SIGINT", () => reject(CANCELLED()));
27065
+ });
27032
27066
  try {
27033
- return (await rl.question(question, signal ? { signal } : {})).trim();
27067
+ return (await Promise.race([rl.question(question, signal ? { signal } : {}), interrupted])).trim();
27068
+ } catch (err) {
27069
+ if (err instanceof CliError) throw err;
27070
+ if (err.name === "AbortError" || signal?.aborted) throw CANCELLED();
27071
+ throw err;
27034
27072
  } finally {
27035
27073
  rl.close();
27036
27074
  }
@@ -27062,7 +27100,7 @@ async function login(ctx, opts) {
27062
27100
  const url = `${ctx.config.app_url}/account/cli/${segment}`;
27063
27101
  note("Approve this sign-in in your browser:");
27064
27102
  note(` ${url}`);
27065
- sealed = opts.noBrowser ? await promptLine("\nPaste the code the page shows after you approve: ") : await waitForBrowser(url, listener.payload);
27103
+ sealed = opts.noBrowser ? await promptLine("\nPaste the code the page shows after you approve: ", opts.signal) : await waitForBrowser(url, listener.payload, opts.signal);
27066
27104
  } finally {
27067
27105
  listener.close();
27068
27106
  }
@@ -27084,9 +27122,9 @@ async function login(ctx, opts) {
27084
27122
  const abandon = () => {
27085
27123
  staging.client.auth.signOut({ scope: "local" }).finally(() => process.exit(130));
27086
27124
  };
27087
- process.once("SIGINT", abandon);
27125
+ if (!opts.signal) process.once("SIGINT", abandon);
27088
27126
  try {
27089
- await stepUpIfEnrolled(staging.client);
27127
+ await stepUpIfEnrolled(staging.client, opts.signal);
27090
27128
  } finally {
27091
27129
  process.off("SIGINT", abandon);
27092
27130
  }
@@ -27094,17 +27132,22 @@ async function login(ctx, opts) {
27094
27132
  staging.commit(ctx.store);
27095
27133
  return data.user;
27096
27134
  }
27097
- async function waitForBrowser(url, payload) {
27135
+ async function waitForBrowser(url, payload, signal) {
27098
27136
  if (!openBrowser(url)) warn("Could not open a browser; open the link above yourself.");
27099
27137
  note("Waiting for approval… If the page asked you to sign in first and then showed your dashboard, open the link again. (Ctrl-C to cancel. Browser on another machine? Run: devicerent login --no-browser)");
27100
27138
  let timer;
27139
+ let onAbort;
27101
27140
  const timeout = new Promise((_, reject) => {
27102
27141
  timer = setTimeout(() => reject(new CliError("Timed out waiting for the browser.")), APPROVAL_TIMEOUT_MS);
27142
+ onAbort = () => reject(CANCELLED());
27143
+ if (signal?.aborted) onAbort();
27144
+ else signal?.addEventListener("abort", onAbort, { once: true });
27103
27145
  });
27104
27146
  try {
27105
27147
  return await Promise.race([payload, timeout]);
27106
27148
  } finally {
27107
27149
  clearTimeout(timer);
27150
+ if (onAbort) signal?.removeEventListener("abort", onAbort);
27108
27151
  }
27109
27152
  }
27110
27153
  /**
@@ -27114,7 +27157,7 @@ async function waitForBrowser(url, payload) {
27114
27157
  * (getUser), never from a cached copy. Every failure signs the staged
27115
27158
  * session out, so it ends server-side too.
27116
27159
  */
27117
- async function stepUpIfEnrolled(client) {
27160
+ async function stepUpIfEnrolled(client, signal) {
27118
27161
  const refuse = async (message) => {
27119
27162
  await client.auth.signOut({ scope: "local" });
27120
27163
  throw new CliError(message);
@@ -27128,7 +27171,7 @@ async function stepUpIfEnrolled(client) {
27128
27171
  const mfa = client.auth.mfa;
27129
27172
  try {
27130
27173
  for (let attempt = 1; attempt <= MFA_ATTEMPTS; attempt++) {
27131
- const code = await promptLine("Authenticator code: ");
27174
+ const code = await promptLine("Authenticator code: ", signal);
27132
27175
  const { error } = await mfa.challengeAndVerify({
27133
27176
  factorId: totp.id,
27134
27177
  code
@@ -27354,8 +27397,11 @@ const loginCommand = {
27354
27397
  summary: "Sign this terminal in through your browser",
27355
27398
  usage: "devicerent login [--no-browser]",
27356
27399
  options: { "no-browser": { type: "boolean" } },
27357
- async run({ ctx, opts, json }) {
27358
- const user = await login(ctx, { noBrowser: opts["no-browser"] === true });
27400
+ async run({ ctx, opts, json, signal }) {
27401
+ const user = await login(ctx, {
27402
+ noBrowser: opts["no-browser"] === true,
27403
+ signal
27404
+ });
27359
27405
  if (json) return printJson({
27360
27406
  email: user.email,
27361
27407
  id: user.id,
@@ -29130,7 +29176,7 @@ function write4HexDigits(buffer, index, value) {
29130
29176
  }
29131
29177
  //#endregion
29132
29178
  //#region ../../../OneDrive/Masaüstü/DeviceRent Software part 5/node_modules/@yume-chan/adb/esm/utils/no-op.js
29133
- const NOOP = /* @__NO_SIDE_EFFECTS__ */ () => {};
29179
+ const NOOP$1 = /* @__NO_SIDE_EFFECTS__ */ () => {};
29134
29180
  /**
29135
29181
  * When used in `Promise#catch`, means the promise should never throw errors.
29136
29182
  * An explicit way to suppress ESLint floating promise warnings.
@@ -29968,7 +30014,7 @@ async function pipeFileData(locked, file, packetSize, mtime) {
29968
30014
  } }), { signal: abortController.signal }).then(async () => {
29969
30015
  await adbSyncWriteRequest(locked, AdbSyncRequestId.Done, mtime);
29970
30016
  await locked.flush();
29971
- }, NOOP);
30017
+ }, NOOP$1);
29972
30018
  await adbSyncReadResponse(locked, AdbSyncResponseId.Ok, AdbSyncOkResponse).catch((e) => {
29973
30019
  abortController.abort();
29974
30020
  throw e;
@@ -32022,6 +32068,70 @@ function obj(props, required) {
32022
32068
  };
32023
32069
  }
32024
32070
  const NO_ARGS = obj({}, []);
32071
+ /** Tools that only read. Available as soon as a session exists. */
32072
+ const READ_TOOLS = [
32073
+ {
32074
+ name: "observe_screen",
32075
+ description: "Read what is currently on the phone screen as a numbered list of interactable elements with exact pixel bounds and a ready-made tap point. This is your primary way of seeing the device: it is far cheaper and far more precise than a screenshot. Use it to look at a screen you have not just changed — at the start of a task, or after a wait. You do NOT need it after an action: tap, swipe, type_text, press_key and launch_app already return the resulting screen with them.",
32076
+ parameters: NO_ARGS
32077
+ },
32078
+ {
32079
+ name: "screenshot",
32080
+ description: "Capture the screen as an image. Costs roughly three times as much as observe_screen and gives you no coordinates, so use it only when observe_screen returns no elements (games, video, canvas, some Jetpack Compose surfaces) or when the question is genuinely visual — colours, layout, whether something rendered correctly.",
32081
+ parameters: NO_ARGS
32082
+ },
32083
+ {
32084
+ name: "list_packages",
32085
+ description: "List the third-party app packages installed on the phone. Use this to find the package name of an app before launching it.",
32086
+ parameters: NO_ARGS
32087
+ },
32088
+ {
32089
+ name: "device_info",
32090
+ description: "Report the device model, Android version, screen resolution and current screen orientation.",
32091
+ parameters: NO_ARGS
32092
+ },
32093
+ {
32094
+ name: "wait",
32095
+ description: "Pause for an animation, a page load or a splash screen, then get the screen as it stands after the pause — the same listing observe_screen returns, so you do NOT need observe_screen after a wait. Prefer waiting once for long enough over polling observe_screen repeatedly, which costs tokens each time.",
32096
+ parameters: obj({ ms: {
32097
+ type: "integer",
32098
+ description: "Milliseconds to wait. Clamped to 100-5000."
32099
+ } }, ["ms"])
32100
+ },
32101
+ {
32102
+ name: "list_instrumentations",
32103
+ description: "List the instrumentation test runners installed on the phone, with the exact target string to pass to run_instrumentation. Use this instead of guessing the runner name — a wrong target is the most common reason a test run never starts.",
32104
+ parameters: NO_ARGS
32105
+ },
32106
+ {
32107
+ name: "logcat_tail",
32108
+ description: "Read the tail of the device log. This is the triage tool: when a test fails or an app crashes, read the log before you change anything. Guessing at a cause you have not read is how a fix loop turns into ten wasted steps.",
32109
+ parameters: obj({
32110
+ lines: {
32111
+ type: "integer",
32112
+ description: "How many recent lines to return. Clamped to 1-1000."
32113
+ },
32114
+ filter: {
32115
+ type: "string",
32116
+ description: "Case-insensitive substring to keep only matching lines - a tag, a package, or an exception class. Empty string returns everything."
32117
+ }
32118
+ }, ["lines", "filter"])
32119
+ },
32120
+ {
32121
+ name: "read_device_file",
32122
+ description: "Read a text file off the phone - a JUnit XML report, a coverage file, or anything a test wrote out. Only /data/local/tmp and /sdcard are readable, and binary files are refused.",
32123
+ parameters: obj({
32124
+ path: {
32125
+ type: "string",
32126
+ description: "Absolute path, under /data/local/tmp or /sdcard."
32127
+ },
32128
+ max_bytes: {
32129
+ type: "integer",
32130
+ description: "Maximum bytes to return. Clamped to 1000-100000."
32131
+ }
32132
+ }, ["path", "max_bytes"])
32133
+ }
32134
+ ];
32025
32135
  /** Tools that touch the device. Gated on the session's allow_actions toggle. */
32026
32136
  const ACTION_TOOLS = [
32027
32137
  {
@@ -32805,7 +32915,7 @@ const browserImageCodec = { async pngToScaledJpeg(png) {
32805
32915
  //#region ../../src/lib/ai/clientToolArgs.ts
32806
32916
  /** Cap on any single tool result. One runaway dump must not blow up a turn. */
32807
32917
  const MAX_RESULT_CHARS = 2e4;
32808
- function truncate(s) {
32918
+ function truncate$1(s) {
32809
32919
  if (s.length <= MAX_RESULT_CHARS) return s;
32810
32920
  return s.slice(0, MAX_RESULT_CHARS) + `\n… truncated (${s.length - MAX_RESULT_CHARS} more characters)`;
32811
32921
  }
@@ -34100,7 +34210,7 @@ async function executeToolCall(ctx, call) {
34100
34210
  });
34101
34211
  const ok = (msg) => ({
34102
34212
  toolCallId: call.id,
34103
- content: truncate(msg),
34213
+ content: truncate$1(msg),
34104
34214
  isError: false
34105
34215
  });
34106
34216
  const { adb, control, mirrorSize, status } = ctx.getHandles();
@@ -34807,6 +34917,12 @@ function parseWmSize(out) {
34807
34917
  }
34808
34918
  //#endregion
34809
34919
  //#region src/ai/loop.ts
34920
+ /**
34921
+ * DeviceyAI was given look-only access and the task needs to tap or type. Its
34922
+ * message is the one-shot instruction; the interactive session catches the
34923
+ * type and asks for the permission instead.
34924
+ */
34925
+ var AiNeedsActionsError = class extends CliError {};
34810
34926
  /** What a refused turn means, as an instruction a person can act on. */
34811
34927
  function describeTurnFailure(status, failure) {
34812
34928
  if (status === 409 && failure.needs_actions) return "This task needs to tap and type on the phone. Run it again with --allow-actions.";
@@ -34820,7 +34936,10 @@ async function readTurn(payload, signal, hooks) {
34820
34936
  } catch {
34821
34937
  return null;
34822
34938
  }
34823
- if (reply.kind === "failed") throw new CliError(describeTurnFailure(reply.status, reply.failure));
34939
+ if (reply.kind === "failed") {
34940
+ const message = describeTurnFailure(reply.status, reply.failure);
34941
+ throw reply.status === 409 && reply.failure.needs_actions ? new AiNeedsActionsError(message) : new CliError(message);
34942
+ }
34824
34943
  if (reply.kind === "json") return reply.turn;
34825
34944
  let done = null;
34826
34945
  for await (const ev of reply.events) if (ev.name === "text") hooks.onText(ev.delta);
@@ -35159,17 +35278,139 @@ const RENTAL_OPTION = { rental: {
35159
35278
  type: "string",
35160
35279
  short: "r"
35161
35280
  } };
35162
- /** Resolve the rental, open the phone, run, and always close it again. */
35281
+ const STOPPED = () => new CliError("Stopped.", 130);
35282
+ /**
35283
+ * Resolve the rental, open the phone, run, and always close it again.
35284
+ *
35285
+ * In the interactive session Ctrl-C aborts `input.signal`. That closes the
35286
+ * connection to the phone at once — a `finally` alone would never run while a
35287
+ * `logcat -f` read loop is blocked — and the stream error it causes is
35288
+ * reported as the deliberate stop it was. A one-shot run has no signal.
35289
+ */
35163
35290
  async function withDevice(input, fn) {
35164
35291
  const user = await requireUser(input.ctx);
35165
35292
  const rental = chooseRental(await loadActiveRentals(), str$1(input.opts, "rental") ?? process.env.DEVICERENT_RENTAL);
35293
+ if (input.signal?.aborted) throw STOPPED();
35166
35294
  const device = await openDevice(input.ctx, rental);
35295
+ const onAbort = () => void device.close();
35296
+ input.signal?.addEventListener("abort", onAbort, { once: true });
35167
35297
  try {
35168
35298
  return await fn(device, user);
35299
+ } catch (err) {
35300
+ if (input.signal?.aborted) throw STOPPED();
35301
+ throw err;
35169
35302
  } finally {
35303
+ input.signal?.removeEventListener("abort", onAbort);
35170
35304
  await device.close();
35171
35305
  }
35172
35306
  }
35307
+ const CSI = `[`;
35308
+ function colorLevel(env, isTTY) {
35309
+ if (env.NO_COLOR) return 0;
35310
+ const force = env.FORCE_COLOR;
35311
+ if (force === "0" || force === "false") return 0;
35312
+ const truecolor = env.COLORTERM === "truecolor" || env.COLORTERM === "24bit" || Boolean(env.WT_SESSION) || env.TERM_PROGRAM === "vscode" || env.TERM_PROGRAM === "iTerm.app" || env.TERM_PROGRAM === "WezTerm";
35313
+ if (force !== void 0 && force !== "") return force === "3" || truecolor ? 3 : 1;
35314
+ if (!isTTY || env.TERM === "dumb") return 0;
35315
+ return truecolor ? 3 : 1;
35316
+ }
35317
+ const ANSI_RE = /\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g;
35318
+ function stripAnsi(s) {
35319
+ return s.replace(ANSI_RE, "");
35320
+ }
35321
+ const CONTROL_RE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
35322
+ /**
35323
+ * Text from outside — DeviceyAI's answer, a tool's error, a file name — made
35324
+ * safe to print. Escape sequences are removed whole, then every remaining
35325
+ * control character, so text read off a phone's screen cannot retitle the
35326
+ * window, rewrite the clipboard (OSC 52) or hide what is printed after it. A
35327
+ * sequence split across two chunks loses its ESC and prints as harmless text.
35328
+ */
35329
+ function sanitize(text) {
35330
+ return text.replace(ANSI_RE, "").replace(CONTROL_RE, "");
35331
+ }
35332
+ /** Zero-width: combining marks, joiners, variation selectors. */
35333
+ function isZeroWidth(cp) {
35334
+ return cp >= 768 && cp <= 879 || cp >= 8203 && cp <= 8207 || cp >= 65024 && cp <= 65039 || cp >= 917760 && cp <= 917999 || cp === 8419;
35335
+ }
35336
+ /** Drawn two cells wide: East Asian wide/fullwidth and emoji pictographs. */
35337
+ function isWide(cp) {
35338
+ return cp >= 4352 && cp <= 4447 || cp >= 11904 && cp <= 12350 || cp >= 12353 && cp <= 13311 || cp >= 13312 && cp <= 19903 || cp >= 19968 && cp <= 40959 || cp >= 40960 && cp <= 42191 || cp >= 44032 && cp <= 55203 || cp >= 63744 && cp <= 64255 || cp >= 65072 && cp <= 65103 || cp >= 65280 && cp <= 65376 || cp >= 65504 && cp <= 65510 || cp >= 127744 && cp <= 128591 || cp >= 128640 && cp <= 128767 || cp >= 129280 && cp <= 129535 || cp >= 129648 && cp <= 129791 || cp >= 131072 && cp <= 262141;
35339
+ }
35340
+ function charWidth(ch) {
35341
+ const cp = ch.codePointAt(0) ?? 0;
35342
+ if (cp < 32 || cp >= 127 && cp < 160) return 0;
35343
+ if (isZeroWidth(cp)) return 0;
35344
+ return isWide(cp) ? 2 : 1;
35345
+ }
35346
+ /** How many terminal cells `s` occupies once its escape codes are drawn. */
35347
+ function visibleWidth(s) {
35348
+ let w = 0;
35349
+ for (const ch of stripAnsi(s)) w += charWidth(ch);
35350
+ return w;
35351
+ }
35352
+ /**
35353
+ * Cut `s` to at most `max` visible cells, keeping escape codes intact and
35354
+ * ending with "…" when anything was cut. A reset is appended after a cut so a
35355
+ * colour cannot bleed into the next line.
35356
+ */
35357
+ function truncate(s, max) {
35358
+ if (max <= 0) return "";
35359
+ if (visibleWidth(s) <= max) return s;
35360
+ let out = "";
35361
+ let w = 0;
35362
+ let i = 0;
35363
+ while (i < s.length) {
35364
+ ANSI_RE.lastIndex = i;
35365
+ const m = ANSI_RE.exec(s);
35366
+ if (m && m.index === i) {
35367
+ out += m[0];
35368
+ i += m[0].length;
35369
+ continue;
35370
+ }
35371
+ const ch = String.fromCodePoint(s.codePointAt(i) ?? 0);
35372
+ const cw = charWidth(ch);
35373
+ if (w + cw > max - 1) break;
35374
+ out += ch;
35375
+ w += cw;
35376
+ i += ch.length;
35377
+ }
35378
+ ANSI_RE.lastIndex = 0;
35379
+ return `${out}…${CSI}0m`;
35380
+ }
35381
+ /** Pad with spaces to exactly `width` visible cells (truncating if longer). */
35382
+ function padEnd(s, width) {
35383
+ const t = truncate(s, width);
35384
+ return t + " ".repeat(Math.max(0, width - visibleWidth(t)));
35385
+ }
35386
+ /** Word-wrap plain text to `width` cells. Words longer than a line are split. */
35387
+ function wrap(text, width) {
35388
+ const out = [];
35389
+ for (const paragraph of text.split("\n")) {
35390
+ let line = "";
35391
+ for (const word of paragraph.split(/ +/)) {
35392
+ if (word === "") continue;
35393
+ let w = word;
35394
+ while (visibleWidth(w) > width) {
35395
+ if (line) {
35396
+ out.push(line);
35397
+ line = "";
35398
+ }
35399
+ const head = [...w].slice(0, width).join("");
35400
+ out.push(head);
35401
+ w = w.slice(head.length);
35402
+ }
35403
+ if (!line) line = w;
35404
+ else if (visibleWidth(line) + 1 + visibleWidth(w) <= width) line += ` ${w}`;
35405
+ else {
35406
+ out.push(line);
35407
+ line = w;
35408
+ }
35409
+ }
35410
+ out.push(line);
35411
+ }
35412
+ return out;
35413
+ }
35173
35414
  //#endregion
35174
35415
  //#region src/commands/ai.ts
35175
35416
  const ConfigSchema = object({
@@ -35181,6 +35422,20 @@ const ConfigSchema = object({
35181
35422
  })),
35182
35423
  credits: number$1()
35183
35424
  });
35425
+ /** The models and credit balance DeviceyAI offers on this rental. */
35426
+ async function loadAiConfig(rentalId) {
35427
+ const res = await fetch(`/api/ai/config?rental_id=${encodeURIComponent(rentalId)}`);
35428
+ const cfg = ConfigSchema.safeParse(await res.json().catch(() => ({})));
35429
+ if (!res.ok || !cfg.success) throw new CliError(res.status === 404 ? "DeviceyAI is not available on this deployment." : `DeviceyAI config failed (${res.status}).`);
35430
+ return cfg.data;
35431
+ }
35432
+ /** The model --model names, else the default, else the first offered. */
35433
+ function pickModel(models, wanted) {
35434
+ 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`);
35436
+ return model;
35437
+ }
35438
+ const OUT_OF_CREDITS = "You are out of DeviceyAI credits. Buy a pack: devicerent credits buy s";
35184
35439
  const aiCommands = [{
35185
35440
  name: "ai",
35186
35441
  summary: "Give DeviceyAI a task on your rented phone",
@@ -35196,25 +35451,21 @@ const aiCommands = [{
35196
35451
  const task = input.args.join(" ").trim();
35197
35452
  if (!task && input.opts.models !== true) throw new CliError("Say what to do: devicerent ai \"open Settings and tell me the Android version\"");
35198
35453
  return withDevice(input, async (device) => {
35199
- const res = await fetch(`/api/ai/config?rental_id=${encodeURIComponent(device.rental.id)}`);
35200
- const cfg = ConfigSchema.safeParse(await res.json().catch(() => ({})));
35201
- if (!res.ok || !cfg.success) throw new CliError(res.status === 404 ? "DeviceyAI is not available on this deployment." : `DeviceyAI config failed (${res.status}).`);
35202
- const { models, credits } = cfg.data;
35454
+ const { models, credits } = await loadAiConfig(device.rental.id);
35203
35455
  if (input.opts.models === true) {
35204
35456
  if (input.json) return void printJson(models);
35205
35457
  for (const m of models) printLine(`${m.id} ${dim(m.label)}${m.id === "claude-sonnet-5" ? " (default)" : ""}`);
35206
35458
  return 0;
35207
35459
  }
35208
- const wanted = str$1(input.opts, "model") ?? "claude-sonnet-5";
35209
- const model = models.find((m) => m.id === wanted) ?? (str$1(input.opts, "model") ? void 0 : models[0]);
35210
- if (!model) throw new CliError(`Model ${wanted} is not offered here. See: devicerent ai --models`);
35211
- if (credits <= 0) throw new CliError("You are out of DeviceyAI credits. Buy a pack: devicerent credits buy s");
35460
+ const model = pickModel(models, str$1(input.opts, "model"));
35461
+ if (credits <= 0) throw new CliError(OUT_OF_CREDITS);
35212
35462
  const allowActions = input.opts["allow-actions"] === true;
35213
35463
  if (!allowActions) note("Read-only: DeviceyAI can look but not touch. Add --allow-actions to let it tap and type.");
35214
35464
  note(`DeviceyAI (${model.label}) on ${device.rental.deviceName} — Ctrl-C stops it.`);
35215
35465
  const controller = new AbortController();
35216
35466
  const onSigint = () => controller.abort();
35217
35467
  process.once("SIGINT", onSigint);
35468
+ input.signal?.addEventListener("abort", onSigint, { once: true });
35218
35469
  let streamed = false;
35219
35470
  try {
35220
35471
  const result = await runAiTask(device, {
@@ -35227,7 +35478,7 @@ const aiCommands = [{
35227
35478
  onText: (delta) => {
35228
35479
  if (input.json) return;
35229
35480
  streamed = true;
35230
- process.stdout.write(delta);
35481
+ process.stdout.write(sanitize(delta));
35231
35482
  },
35232
35483
  onTool: (name) => {
35233
35484
  if (input.json) return;
@@ -35236,7 +35487,7 @@ const aiCommands = [{
35236
35487
  process.stderr.write(dim(`→ ${name}\n`));
35237
35488
  },
35238
35489
  onToolDone: (name, isError, content) => {
35239
- if (!input.json && isError) warn(`${name}: ${content.split("\n")[0]}`);
35490
+ if (!input.json && isError) warn(`${name}: ${sanitize(content.split("\n")[0])}`);
35240
35491
  }
35241
35492
  });
35242
35493
  if (input.json) return void printJson(result);
@@ -35248,6 +35499,7 @@ const aiCommands = [{
35248
35499
  return result.stopReason === "done" ? 0 : 1;
35249
35500
  } finally {
35250
35501
  process.off("SIGINT", onSigint);
35502
+ input.signal?.removeEventListener("abort", onSigint);
35251
35503
  }
35252
35504
  });
35253
35505
  }
@@ -36003,7 +36255,7 @@ function progressPrinter(label) {
36003
36255
  let last = -1;
36004
36256
  return (p) => {
36005
36257
  if (p.stage === "installing") {
36006
- process.stderr.write(`${tty ? "\r" : ""}${label}: installing…${tty ? " " : ""}\n`);
36258
+ process.stderr.write(`${tty ? "\r\x1B[K" : ""}${label}: installing…\n`);
36007
36259
  return;
36008
36260
  }
36009
36261
  const pct = p.totalBytes ? Math.floor(p.pushedBytes / p.totalBytes * 100) : 0;
@@ -36058,6 +36310,13 @@ const installCommand = {
36058
36310
  }
36059
36311
  return out;
36060
36312
  });
36313
+ input.onResult?.({
36314
+ kind: "installed",
36315
+ apps: results.map((r) => ({
36316
+ filename: r.filename,
36317
+ packageName: r.packageName
36318
+ }))
36319
+ });
36061
36320
  if (input.json) printJson(results);
36062
36321
  }
36063
36322
  };
@@ -36221,6 +36480,10 @@ const screenshotCommand = {
36221
36480
  });
36222
36481
  if (!bytes) throw new CliError("The phone did not return a PNG.");
36223
36482
  fs.writeFileSync(file, bytes);
36483
+ input.onResult?.({
36484
+ kind: "screenshot",
36485
+ file
36486
+ });
36224
36487
  if (input.json) return printJson({
36225
36488
  file,
36226
36489
  bytes: bytes.length
@@ -36358,7 +36621,7 @@ const rentalCommands = [
36358
36621
  name: "rent",
36359
36622
  summary: "Rent one to three phones (billing starts now)",
36360
36623
  usage: "devicerent rent <device…> e.g. devicerent rent \"Pixel 7a\" \"Galaxy A54\", or an Id from devicerent devices",
36361
- async run({ ctx, args, json }) {
36624
+ async run({ ctx, args, json, onResult }) {
36362
36625
  await requireUser(ctx);
36363
36626
  if (args.length === 0) throw new CliError("Name a device (id, serial or model). See: devicerent devices");
36364
36627
  if (args.length > MAX_PER_REQUEST) throw new CliError(`At most ${MAX_PER_REQUEST} devices at once.`);
@@ -36372,6 +36635,10 @@ const rentalCommands = [
36372
36635
  const { data, error } = picks.length === 1 ? await ctx.supabase.rpc("rent_device", { p_device_id: picks[0].id }) : await ctx.supabase.rpc("rent_devices", { p_device_ids: picks.map((d) => d.id) });
36373
36636
  if (error) throw new CliError(`Rental refused: ${error.message}`);
36374
36637
  const rentals = Array.isArray(data) ? data : [data];
36638
+ onResult?.({
36639
+ kind: "rented",
36640
+ rentalIds: rentals.map((r) => r.id)
36641
+ });
36375
36642
  if (json) return printJson(rentals);
36376
36643
  for (const r of rentals) success(`Rented ${picks.find((p) => p.id === r.device_id)?.name ?? r.device_id} — rental ${r.id}`);
36377
36644
  warn("Billing runs per minute until you `devicerent stop` (a rental with no activity for 30 minutes is stopped for you).");
@@ -36422,12 +36689,16 @@ const rentalCommands = [
36422
36689
  all: { type: "boolean" },
36423
36690
  rental: { type: "string" }
36424
36691
  },
36425
- async run({ ctx, args, opts, json }) {
36692
+ async run({ ctx, args, opts, json, onResult }) {
36426
36693
  await requireUser(ctx);
36427
36694
  if (opts.all === true) {
36428
36695
  const { data, error } = await ctx.supabase.rpc("stop_all_rentals");
36429
36696
  if (error) throw new CliError(`Could not stop: ${error.message}`);
36430
36697
  const stopped = data ?? [];
36698
+ onResult?.({
36699
+ kind: "stopped",
36700
+ rentalIds: stopped.map((r) => r.id)
36701
+ });
36431
36702
  if (json) return printJson(stopped);
36432
36703
  if (stopped.length === 0) return note("Nothing was running.");
36433
36704
  for (const r of stopped) success(`Stopped ${r.id} — ${eur(Number(r.total_cost ?? 0))}`);
@@ -36437,6 +36708,10 @@ const rentalCommands = [
36437
36708
  const { data, error } = await ctx.supabase.rpc("stop_rental", { p_rental_id: rental.id });
36438
36709
  if (error) throw new CliError(`Could not stop: ${error.message}`);
36439
36710
  const row = data;
36711
+ onResult?.({
36712
+ kind: "stopped",
36713
+ rentalIds: [rental.id]
36714
+ });
36440
36715
  if (json) return printJson(data);
36441
36716
  success(`Stopped ${rental.deviceName} — ${eur(Number(row?.total_cost ?? 0))}`);
36442
36717
  }
@@ -40720,7 +40995,7 @@ function createAdbServer({ getCreds, log = () => {} }) {
40720
40995
  const server = net.createServer((sock) => {
40721
40996
  sock.on("error", () => {});
40722
40997
  readFirstRequest(sock, (payload, tail) => {
40723
- dispatch(sock, payload, tail, {
40998
+ dispatch$1(sock, payload, tail, {
40724
40999
  getCreds,
40725
41000
  log,
40726
41001
  forwards
@@ -40758,7 +41033,7 @@ function readFirstRequest(sock, cb) {
40758
41033
  };
40759
41034
  sock.on("data", onData);
40760
41035
  }
40761
- async function dispatch(sock, payload, tail, ctx) {
41036
+ async function dispatch$1(sock, payload, tail, ctx) {
40762
41037
  let creds = null;
40763
41038
  try {
40764
41039
  creds = ctx.getCreds();
@@ -41069,6 +41344,7 @@ const testingCommands = [
41069
41344
  input.ctx.supabase.rpc("touch_rental_activity", { p_rental_id: rental.id });
41070
41345
  }, 6e4);
41071
41346
  let timer;
41347
+ let disconnected = false;
41072
41348
  const renew = async () => {
41073
41349
  try {
41074
41350
  ticket = await mintBridgeTicket(rental.id);
@@ -41077,8 +41353,10 @@ const testingCommands = [
41077
41353
  token: ticket.token,
41078
41354
  tokenTransport: "subprotocol"
41079
41355
  })).getVersion();
41356
+ if (disconnected) return;
41080
41357
  timer = setTimeout(renew, renewDelayMs(ticket.expires_at, Date.now()));
41081
41358
  } catch (err) {
41359
+ if (disconnected) return;
41082
41360
  warn(`Could not renew device access: ${err.message}`);
41083
41361
  timer = setTimeout(renew, 3e4);
41084
41362
  }
@@ -41095,29 +41373,46 @@ const testingCommands = [
41095
41373
  },
41096
41374
  log: (...parts) => note(parts.join(" "))
41097
41375
  });
41098
- await new Promise((resolve, reject) => {
41099
- server.once("error", reject);
41100
- server.listen(port, "127.0.0.1", () => resolve());
41101
- });
41102
- success(`${rental.deviceName} (${ticket.serial}) is now a local adb device.`);
41103
- printLine("");
41104
- printLine(process.platform === "win32" ? ` $env:ANDROID_ADB_SERVER_PORT=${port} # PowerShell` : ` export ANDROID_ADB_SERVER_PORT=${port}`);
41105
- printLine("");
41106
- note("Then use adb, gradle or Appium as usual in that shell. Ctrl-C disconnects (the rental keeps running).");
41107
- await new Promise((resolve) => {
41108
- const stop = () => resolve();
41109
- process.once("SIGINT", stop);
41110
- process.once("SIGTERM", stop);
41376
+ const sockets = /* @__PURE__ */ new Set();
41377
+ server.on("connection", (s) => {
41378
+ sockets.add(s);
41379
+ s.once("close", () => sockets.delete(s));
41111
41380
  });
41112
- clearTimeout(timer);
41113
- clearInterval(touch);
41114
- server.close();
41381
+ try {
41382
+ await new Promise((resolve, reject) => {
41383
+ server.once("error", reject);
41384
+ server.listen(port, "127.0.0.1", () => resolve());
41385
+ });
41386
+ success(`${rental.deviceName} (${ticket.serial}) is now a local adb device.`);
41387
+ printLine("");
41388
+ printLine(process.platform === "win32" ? ` $env:ANDROID_ADB_SERVER_PORT=${port} # PowerShell` : ` export ANDROID_ADB_SERVER_PORT=${port}`);
41389
+ printLine("");
41390
+ note("Then use adb, gradle or Appium as usual in that shell. Ctrl-C disconnects (the rental keeps running).");
41391
+ await new Promise((resolve) => {
41392
+ const stop = () => {
41393
+ process.off("SIGINT", stop);
41394
+ process.off("SIGTERM", stop);
41395
+ input.signal?.removeEventListener("abort", stop);
41396
+ resolve();
41397
+ };
41398
+ process.on("SIGINT", stop);
41399
+ process.on("SIGTERM", stop);
41400
+ if (input.signal?.aborted) stop();
41401
+ else input.signal?.addEventListener("abort", stop);
41402
+ });
41403
+ } finally {
41404
+ disconnected = true;
41405
+ clearTimeout(timer);
41406
+ clearInterval(touch);
41407
+ server.close();
41408
+ for (const s of sockets) s.destroy();
41409
+ }
41115
41410
  note("Disconnected. Stop the rental with: devicerent stop");
41116
41411
  }
41117
41412
  }
41118
41413
  ];
41119
41414
  //#endregion
41120
- //#region src/main.ts
41415
+ //#region src/registry.ts
41121
41416
  const GROUPS = [
41122
41417
  {
41123
41418
  title: "Account",
@@ -41153,6 +41448,2763 @@ function findCommand(argv, commands = COMMANDS) {
41153
41448
  }
41154
41449
  return null;
41155
41450
  }
41451
+ /** --api-base, else DEVICERENT_API_BASE, else production. */
41452
+ function resolveApiBase(values) {
41453
+ return normalizeApiBase((typeof values["api-base"] === "string" ? values["api-base"] : void 0) ?? process.env.DEVICERENT_API_BASE ?? "https://devicerent.net");
41454
+ }
41455
+ /**
41456
+ * Find, parse and run one command. Arguments are parsed BEFORE the context is
41457
+ * asked for, so a usage error never touches the network.
41458
+ */
41459
+ async function runCommandLine(getCtx, argv, opts = {}) {
41460
+ const found = findCommand(argv);
41461
+ if (!found) return { kind: "unknown" };
41462
+ const { command, rest } = found;
41463
+ let values;
41464
+ let positionals;
41465
+ try {
41466
+ ({values, positionals} = parseArgs({
41467
+ args: [...opts.globals ?? [], ...rest],
41468
+ options: {
41469
+ ...GLOBAL_OPTIONS,
41470
+ ...command.options
41471
+ },
41472
+ allowPositionals: true,
41473
+ strict: true
41474
+ }));
41475
+ } catch (err) {
41476
+ process.stderr.write(`${red(err.message)}\nUsage: ${command.usage}\n`);
41477
+ return {
41478
+ kind: "usage",
41479
+ code: 2
41480
+ };
41481
+ }
41482
+ if (values.help === true) {
41483
+ printLine(`${command.summary}\n\nUsage: ${command.usage}`);
41484
+ return {
41485
+ kind: "ran",
41486
+ code: 0
41487
+ };
41488
+ }
41489
+ if (opts.interactive && values["api-base"] !== void 0) throw new CliError("--api-base cannot change inside this session. Leave, then run: devicerent --api-base <url>", 2);
41490
+ const ctx = await getCtx(resolveApiBase(values));
41491
+ const code = await command.run({
41492
+ ctx,
41493
+ args: positionals,
41494
+ opts: values,
41495
+ json: values.json === true,
41496
+ signal: opts.signal,
41497
+ onResult: opts.onResult
41498
+ });
41499
+ return {
41500
+ kind: "ran",
41501
+ code: typeof code === "number" ? code : 0
41502
+ };
41503
+ }
41504
+ //#endregion
41505
+ //#region src/tui/box.ts
41506
+ /** The width a frame is drawn at: the terminal's, kept between 40 and 100. */
41507
+ function frameWidth(columns) {
41508
+ const cols = columns && columns > 0 ? columns : 80;
41509
+ return Math.max(Math.min(cols - 1, 40), Math.min(100, cols - 1));
41510
+ }
41511
+ /**
41512
+ * A rounded box around `lines`. Each line is padded (or cut) to the inside
41513
+ * width, so the right edge stays straight whatever colours a line carries.
41514
+ */
41515
+ function renderBox(lines, opts) {
41516
+ const { g } = opts.theme;
41517
+ const paint = opts.border ?? opts.theme.dim;
41518
+ const inner = Math.max(1, opts.width - 4);
41519
+ let top = g.h.repeat(opts.width - 2);
41520
+ if (opts.title) {
41521
+ const t = ` ${truncate(opts.title, opts.width - 6)} `;
41522
+ top = g.h + t + g.h.repeat(Math.max(0, opts.width - 3 - visibleWidth(t)));
41523
+ }
41524
+ return [
41525
+ paint(`${g.tl}${top}${g.tr}`),
41526
+ ...lines.map((l) => `${paint(g.v)} ${padEnd(l, inner)} ${paint(g.v)}`),
41527
+ paint(`${g.bl}${g.h.repeat(opts.width - 2)}${g.br}`)
41528
+ ];
41529
+ }
41530
+ //#endregion
41531
+ //#region src/tui/keys.ts
41532
+ let decoderReady = false;
41533
+ let owned = false;
41534
+ let restoreInstalled = false;
41535
+ function toKey(str, key) {
41536
+ const k = {
41537
+ name: key?.name,
41538
+ ctrl: key?.ctrl === true,
41539
+ meta: key?.meta === true,
41540
+ shift: key?.shift === true
41541
+ };
41542
+ if (str !== void 0 && !k.ctrl && !k.meta && str.length > 0 && !/[\u0000-\u001f\u007f]/.test(str)) k.text = str;
41543
+ return k;
41544
+ }
41545
+ /** Undo every mode the session may have switched on. Safe to call twice. */
41546
+ function restoreTerminal() {
41547
+ process.stdout.write(`${CSI}?25h${CSI}?1l${CSI}?2004l${CSI}0m`);
41548
+ }
41549
+ /**
41550
+ * Read keys until the returned function is called. Throws if the keyboard is
41551
+ * already being read, so two readers can never race for the same key.
41552
+ */
41553
+ function listenKeys(onKey) {
41554
+ const stdin = process.stdin;
41555
+ if (owned) throw new Error("the keyboard is already being read");
41556
+ if (!decoderReady) {
41557
+ readline$1.emitKeypressEvents(stdin, { escapeCodeTimeout: 50 });
41558
+ decoderReady = true;
41559
+ }
41560
+ if (!restoreInstalled) {
41561
+ process.on("exit", restoreTerminal);
41562
+ restoreInstalled = true;
41563
+ }
41564
+ const handler = (str, key) => onKey(toKey(str, key));
41565
+ stdin.on("keypress", handler);
41566
+ if (!stdin.isRaw) stdin.setRawMode(true);
41567
+ stdin.resume();
41568
+ process.stdout.write(`${CSI}?2004h`);
41569
+ owned = true;
41570
+ let released = false;
41571
+ return () => {
41572
+ if (released) return;
41573
+ released = true;
41574
+ stdin.off("keypress", handler);
41575
+ process.stdout.write(`${CSI}?2004l`);
41576
+ stdin.pause();
41577
+ owned = false;
41578
+ };
41579
+ }
41580
+ /**
41581
+ * Ordinary line mode, for work that needs it: Ctrl-C as SIGINT, readline's
41582
+ * own prompts, a child's input. A no-op when raw mode is already off.
41583
+ */
41584
+ function cookedMode() {
41585
+ if (process.stdin.isTTY && process.stdin.isRaw) process.stdin.setRawMode(false);
41586
+ }
41587
+ //#endregion
41588
+ //#region src/tui/lineEditor.ts
41589
+ function initialEditor(history = []) {
41590
+ return {
41591
+ value: "",
41592
+ cursor: 0,
41593
+ history,
41594
+ historyIndex: null,
41595
+ draft: "",
41596
+ suggestion: 0,
41597
+ dismissed: false,
41598
+ pasting: false
41599
+ };
41600
+ }
41601
+ /** The commands a `/…` line could mean, best match first. */
41602
+ function suggestionsFor(value, all, limit = 8) {
41603
+ if (!value.startsWith("/")) return [];
41604
+ const q = value.slice(1).toLowerCase();
41605
+ if (/\s/.test(q)) return all.filter((s) => s.name.startsWith(q) && s.name !== q).slice(0, limit);
41606
+ const prefix = all.filter((s) => s.name.startsWith(q));
41607
+ const word = all.filter((s) => !prefix.includes(s) && s.name.split(" ").some((w) => w.startsWith(q)));
41608
+ const described = q.length >= 3 ? all.filter((s) => !prefix.includes(s) && !word.includes(s) && s.summary.toLowerCase().includes(q)) : [];
41609
+ return [
41610
+ ...prefix,
41611
+ ...word,
41612
+ ...described
41613
+ ].slice(0, limit);
41614
+ }
41615
+ /** The suggestions that are on screen right now. */
41616
+ function visibleSuggestions(state, all) {
41617
+ return state.dismissed ? [] : suggestionsFor(state.value, all);
41618
+ }
41619
+ function prevBoundary(s, i) {
41620
+ if (i <= 0) return 0;
41621
+ const c = s.charCodeAt(i - 1);
41622
+ return c >= 56320 && c <= 57343 && i >= 2 ? i - 2 : i - 1;
41623
+ }
41624
+ function nextBoundary(s, i) {
41625
+ if (i >= s.length) return s.length;
41626
+ const c = s.charCodeAt(i);
41627
+ return c >= 55296 && c <= 56319 && i + 1 < s.length ? i + 2 : i + 1;
41628
+ }
41629
+ /** Start of the word before the cursor (Ctrl-W, Alt-Backspace). */
41630
+ function wordStart(s, i) {
41631
+ let j = i;
41632
+ while (j > 0 && /\s/.test(s[j - 1])) j--;
41633
+ while (j > 0 && !/\s/.test(s[j - 1])) j--;
41634
+ return j;
41635
+ }
41636
+ function edit(state, value, cursor) {
41637
+ return {
41638
+ ...state,
41639
+ value,
41640
+ cursor,
41641
+ historyIndex: null,
41642
+ draft: value,
41643
+ suggestion: 0,
41644
+ dismissed: false
41645
+ };
41646
+ }
41647
+ function recall(state, index) {
41648
+ const value = index === null ? state.draft : state.history[index] ?? state.draft;
41649
+ return {
41650
+ ...state,
41651
+ historyIndex: index,
41652
+ value,
41653
+ cursor: value.length,
41654
+ suggestion: 0,
41655
+ dismissed: true
41656
+ };
41657
+ }
41658
+ function editorReducer(state, key, all) {
41659
+ const s = state;
41660
+ const ok = (next, event = null) => ({
41661
+ state: next,
41662
+ event
41663
+ });
41664
+ const shown = visibleSuggestions(s, all);
41665
+ if (key.name === "paste-start") return ok({
41666
+ ...s,
41667
+ pasting: true
41668
+ });
41669
+ if (key.name === "paste-end") return ok({
41670
+ ...s,
41671
+ pasting: false
41672
+ });
41673
+ if (key.name === "return" || key.name === "enter") {
41674
+ if (s.pasting) return ok(edit(s, `${s.value.slice(0, s.cursor)} ${s.value.slice(s.cursor)}`, s.cursor + 1));
41675
+ const pick = shown[s.suggestion];
41676
+ if (pick) {
41677
+ const value = `/${pick.name}`;
41678
+ return ok({
41679
+ ...s,
41680
+ value,
41681
+ cursor: value.length
41682
+ }, "submit");
41683
+ }
41684
+ return ok(s, "submit");
41685
+ }
41686
+ if (key.ctrl && key.name === "c") return s.value ? ok(edit(s, "", 0)) : ok(s, "interrupt");
41687
+ if (key.ctrl && key.name === "d") {
41688
+ if (!s.value) return ok(s, "eof");
41689
+ return ok(edit(s, s.value.slice(0, s.cursor) + s.value.slice(nextBoundary(s.value, s.cursor)), s.cursor));
41690
+ }
41691
+ if (key.name === "escape") {
41692
+ if (shown.length > 0) return ok({
41693
+ ...s,
41694
+ dismissed: true
41695
+ });
41696
+ return ok(edit(s, "", 0));
41697
+ }
41698
+ if (key.name === "tab") {
41699
+ const pick = shown[s.suggestion];
41700
+ if (!pick) return ok(s);
41701
+ const value = `/${pick.name} `;
41702
+ return ok({
41703
+ ...edit(s, value, value.length),
41704
+ dismissed: true
41705
+ });
41706
+ }
41707
+ if (key.name === "up" || key.ctrl && key.name === "p") {
41708
+ if (shown.length > 0) return ok({
41709
+ ...s,
41710
+ suggestion: (s.suggestion - 1 + shown.length) % shown.length
41711
+ });
41712
+ if (s.history.length === 0) return ok(s);
41713
+ return ok(recall(s, s.historyIndex === null ? s.history.length - 1 : Math.max(0, s.historyIndex - 1)));
41714
+ }
41715
+ if (key.name === "down" || key.ctrl && key.name === "n") {
41716
+ if (shown.length > 0) return ok({
41717
+ ...s,
41718
+ suggestion: (s.suggestion + 1) % shown.length
41719
+ });
41720
+ if (s.historyIndex === null) return ok(s);
41721
+ const i = s.historyIndex + 1;
41722
+ return ok(recall(s, i >= s.history.length ? null : i));
41723
+ }
41724
+ if (key.name === "left" || key.ctrl && key.name === "b") return ok({
41725
+ ...s,
41726
+ cursor: prevBoundary(s.value, s.cursor)
41727
+ });
41728
+ if (key.name === "right" || key.ctrl && key.name === "f") return ok({
41729
+ ...s,
41730
+ cursor: nextBoundary(s.value, s.cursor)
41731
+ });
41732
+ if (key.name === "home" || key.ctrl && key.name === "a") return ok({
41733
+ ...s,
41734
+ cursor: 0
41735
+ });
41736
+ if (key.name === "end" || key.ctrl && key.name === "e") return ok({
41737
+ ...s,
41738
+ cursor: s.value.length
41739
+ });
41740
+ if (key.name === "backspace" || key.ctrl && key.name === "h") {
41741
+ if (key.meta) {
41742
+ const from = wordStart(s.value, s.cursor);
41743
+ return ok(edit(s, s.value.slice(0, from) + s.value.slice(s.cursor), from));
41744
+ }
41745
+ const from = prevBoundary(s.value, s.cursor);
41746
+ return ok(edit(s, s.value.slice(0, from) + s.value.slice(s.cursor), from));
41747
+ }
41748
+ if (key.name === "delete") return ok(edit(s, s.value.slice(0, s.cursor) + s.value.slice(nextBoundary(s.value, s.cursor)), s.cursor));
41749
+ if (key.ctrl && key.name === "u") return ok(edit(s, s.value.slice(s.cursor), 0));
41750
+ if (key.ctrl && key.name === "k") return ok(edit(s, s.value.slice(0, s.cursor), s.cursor));
41751
+ if (key.ctrl && key.name === "w") {
41752
+ const from = wordStart(s.value, s.cursor);
41753
+ return ok(edit(s, s.value.slice(0, from) + s.value.slice(s.cursor), from));
41754
+ }
41755
+ if (key.text) {
41756
+ const text = key.text.replace(/[\r\n\t]+/g, " ");
41757
+ return ok(edit(s, s.value.slice(0, s.cursor) + text + s.value.slice(s.cursor), s.cursor + text.length));
41758
+ }
41759
+ return ok(s);
41760
+ }
41761
+ //#endregion
41762
+ //#region src/tui/region.ts
41763
+ /**
41764
+ * A block of lines at the bottom of the terminal that can be redrawn in
41765
+ * place — the prompt box, a menu. It remembers how many lines it drew and
41766
+ * where it left the cursor, so the next render can climb back to its top
41767
+ * line and draw again. Every line is cut to the terminal's width first: a
41768
+ * line the terminal wrapped would throw that count off.
41769
+ */
41770
+ var LiveRegion = class {
41771
+ out;
41772
+ lines = 0;
41773
+ cursorRow = 0;
41774
+ constructor(out = process.stdout) {
41775
+ this.out = out;
41776
+ }
41777
+ render(lines, cursor) {
41778
+ const cols = Math.max(10, (this.out.columns || 80) - 1);
41779
+ let s = this.lines > 0 && this.cursorRow > 0 ? `${CSI}${this.cursorRow}A` : "";
41780
+ s += `\r${CSI}0J`;
41781
+ s += lines.map((l) => truncate(l, cols)).join("\n");
41782
+ const last = Math.max(0, lines.length - 1);
41783
+ if (cursor) {
41784
+ const up = last - cursor.row;
41785
+ if (up > 0) s += `${CSI}${up}A`;
41786
+ s += "\r";
41787
+ if (cursor.col > 0) s += `${CSI}${cursor.col}C`;
41788
+ this.cursorRow = cursor.row;
41789
+ } else this.cursorRow = last;
41790
+ this.lines = lines.length;
41791
+ this.out.write(s);
41792
+ }
41793
+ /** Erase the region; the cursor ends where the region began. */
41794
+ clear() {
41795
+ if (this.lines === 0) return;
41796
+ this.out.write(`${this.cursorRow > 0 ? `${CSI}${this.cursorRow}A` : ""}\r${CSI}0J`);
41797
+ this.lines = 0;
41798
+ this.cursorRow = 0;
41799
+ }
41800
+ /** Keep what is drawn and move to a fresh line below it. */
41801
+ done() {
41802
+ if (this.lines === 0) return;
41803
+ const down = this.lines - 1 - this.cursorRow;
41804
+ this.out.write(`${down > 0 ? `${CSI}${down}B` : ""}\n`);
41805
+ this.lines = 0;
41806
+ this.cursorRow = 0;
41807
+ }
41808
+ };
41809
+ //#endregion
41810
+ //#region src/tui/theme.ts
41811
+ const FANCY = {
41812
+ spark: "✻",
41813
+ dot: "⏺",
41814
+ elbow: "⎿",
41815
+ pointer: "❯",
41816
+ check: "✓",
41817
+ cross: "✗",
41818
+ bullet: "●",
41819
+ barFull: "█",
41820
+ barEmpty: "░",
41821
+ tl: "╭",
41822
+ tr: "╮",
41823
+ bl: "╰",
41824
+ br: "╯",
41825
+ h: "─",
41826
+ v: "│",
41827
+ spinner: [
41828
+ "·",
41829
+ "✢",
41830
+ "✳",
41831
+ "✶",
41832
+ "✻",
41833
+ "✽",
41834
+ "✻",
41835
+ "✶",
41836
+ "✳",
41837
+ "✢"
41838
+ ]
41839
+ };
41840
+ const PLAIN = {
41841
+ spark: "*",
41842
+ dot: "*",
41843
+ elbow: "└",
41844
+ pointer: ">",
41845
+ check: "v",
41846
+ cross: "x",
41847
+ bullet: "*",
41848
+ barFull: "#",
41849
+ barEmpty: "-",
41850
+ tl: "┌",
41851
+ tr: "┐",
41852
+ bl: "└",
41853
+ br: "┘",
41854
+ h: "─",
41855
+ v: "│",
41856
+ spinner: [
41857
+ "|",
41858
+ "/",
41859
+ "-",
41860
+ "\\"
41861
+ ]
41862
+ };
41863
+ function glyphsFor(env, platform) {
41864
+ return platform === "win32" && !env.WT_SESSION && !env.TERM_PROGRAM && !env.ConEmuANSI && !env.TERM ? PLAIN : FANCY;
41865
+ }
41866
+ function createTheme(level, g) {
41867
+ const sgr = (open, close) => (s) => level === 0 ? s : `${CSI}${open}m${s}${CSI}${close}m`;
41868
+ return {
41869
+ level,
41870
+ g,
41871
+ accent: level === 3 ? sgr("38;2;57;255;20", "39") : sgr("92", "39"),
41872
+ bold: sgr("1", "22"),
41873
+ dim: sgr("2", "22"),
41874
+ red: sgr("31", "39"),
41875
+ yellow: sgr("33", "39"),
41876
+ green: sgr("32", "39"),
41877
+ inverse: sgr("7", "27")
41878
+ };
41879
+ }
41880
+ let current;
41881
+ /** The theme for this process, decided on first use (never at import). */
41882
+ function theme() {
41883
+ current ??= createTheme(colorLevel(process.env, process.stdout.isTTY === true), glyphsFor(process.env, process.platform));
41884
+ return current;
41885
+ }
41886
+ //#endregion
41887
+ //#region src/tui/spinner.ts
41888
+ const NOOP = {
41889
+ update() {},
41890
+ stop() {}
41891
+ };
41892
+ let active = null;
41893
+ function startSpinner(label, opts = {}) {
41894
+ const out = process.stdout;
41895
+ const err = process.stderr;
41896
+ if (!out.isTTY) return NOOP;
41897
+ active?.stop();
41898
+ const t = opts.theme ?? theme();
41899
+ const now = opts.now ?? Date.now;
41900
+ const started = now();
41901
+ const outWrite = out.write;
41902
+ const errWrite = err.write;
41903
+ const raw = (s) => outWrite.call(out, s);
41904
+ let text = label;
41905
+ let frame = 0;
41906
+ let stopped = false;
41907
+ let timer;
41908
+ const draw = () => {
41909
+ const secs = Math.floor((now() - started) / 1e3);
41910
+ const hint = opts.cancellable ? `(${secs}s · ctrl+c to cancel)` : `(${secs}s)`;
41911
+ const glyph = t.g.spinner[frame % t.g.spinner.length];
41912
+ frame += 1;
41913
+ raw(`\r${CSI}2K${truncate(`${t.accent(glyph)} ${text}… ${t.dim(hint)}`, (out.columns || 80) - 1)}`);
41914
+ };
41915
+ const self = {
41916
+ update(next) {
41917
+ text = next;
41918
+ if (!stopped) draw();
41919
+ },
41920
+ stop() {
41921
+ if (stopped) return;
41922
+ stopped = true;
41923
+ clearInterval(timer);
41924
+ out.write = outWrite;
41925
+ err.write = errWrite;
41926
+ raw(`\r${CSI}2K`);
41927
+ if (active === self) active = null;
41928
+ }
41929
+ };
41930
+ const intercepted = (orig) => function(...args) {
41931
+ self.stop();
41932
+ return orig.apply(this, args);
41933
+ };
41934
+ out.write = intercepted(outWrite);
41935
+ err.write = intercepted(errWrite);
41936
+ timer = setInterval(draw, 100);
41937
+ timer.unref();
41938
+ draw();
41939
+ active = self;
41940
+ return self;
41941
+ }
41942
+ /** Stop whatever spinner is running (before drawing a menu over it). */
41943
+ function stopSpinner() {
41944
+ active?.stop();
41945
+ }
41946
+ //#endregion
41947
+ //#region src/tui/prompt.ts
41948
+ const PROMPT = "> ";
41949
+ const ARM_MS = 2e3;
41950
+ /** Wrap the typed text into box rows and find the cursor's cell. */
41951
+ function layoutInput(value, cursor, inner) {
41952
+ const rows = [];
41953
+ let row = PROMPT;
41954
+ let w = 2;
41955
+ let at = {
41956
+ row: 0,
41957
+ col: w
41958
+ };
41959
+ let i = 0;
41960
+ for (const ch of value) {
41961
+ const cw = charWidth(ch);
41962
+ if (w + cw > inner) {
41963
+ rows.push(row);
41964
+ row = " ".repeat(2);
41965
+ w = 2;
41966
+ }
41967
+ if (i === cursor) at = {
41968
+ row: rows.length,
41969
+ col: w
41970
+ };
41971
+ row += ch;
41972
+ w += cw;
41973
+ i += ch.length;
41974
+ }
41975
+ if (cursor >= value.length) {
41976
+ if (w >= inner) {
41977
+ rows.push(row);
41978
+ row = " ".repeat(2);
41979
+ w = 2;
41980
+ }
41981
+ at = {
41982
+ row: rows.length,
41983
+ col: w
41984
+ };
41985
+ }
41986
+ rows.push(row);
41987
+ return {
41988
+ rows,
41989
+ ...at
41990
+ };
41991
+ }
41992
+ /** The whole prompt as lines, and where the cursor goes. */
41993
+ function renderPrompt(view, t, width) {
41994
+ const inner = width - 4;
41995
+ const { state } = view;
41996
+ const laid = layoutInput(state.value, state.cursor, inner);
41997
+ const rows = laid.rows.map((r, i) => i === 0 ? `${t.bold(">")}${r.slice(1)}` : r);
41998
+ if (!state.value) rows[0] = `${t.bold(">")} ${t.dim(view.placeholder)}`;
41999
+ const lines = renderBox(rows, {
42000
+ width,
42001
+ theme: t
42002
+ });
42003
+ const shown = view.suggestions;
42004
+ if (shown.length > 0) {
42005
+ const nameWidth = Math.max(...shown.map((s) => s.name.length)) + 3;
42006
+ shown.forEach((s, i) => {
42007
+ const name = padEnd(`/${s.name}`, nameWidth);
42008
+ lines.push(i === state.suggestion ? ` ${t.accent(name)}${s.summary}` : t.dim(` ${name}${s.summary}`));
42009
+ });
42010
+ } else {
42011
+ const left = view.armed ? t.yellow("Press Ctrl-C again to exit") : t.dim(view.hint);
42012
+ const gap = width - 2 - visibleWidth(left) - visibleWidth(view.status);
42013
+ lines.push(gap >= 2 ? ` ${left}${" ".repeat(gap)}${view.status}` : ` ${left}`);
42014
+ }
42015
+ return {
42016
+ lines,
42017
+ cursor: {
42018
+ row: 1 + laid.row,
42019
+ col: 2 + laid.col
42020
+ }
42021
+ };
42022
+ }
42023
+ /**
42024
+ * Read one line. Resolves with it on Enter, or with `exit` on Ctrl-D or a
42025
+ * second Ctrl-C within two seconds on an empty line. The box is replaced by
42026
+ * a dim echo of what was typed, as Claude Code does.
42027
+ */
42028
+ function readPrompt(opts) {
42029
+ stopSpinner();
42030
+ const t = opts.theme ?? theme();
42031
+ const out = process.stdout;
42032
+ return new Promise((resolve) => {
42033
+ const region = new LiveRegion(out);
42034
+ let state = initialEditor(opts.history);
42035
+ let armedUntil = 0;
42036
+ let armTimer;
42037
+ const draw = () => {
42038
+ const { lines, cursor } = renderPrompt({
42039
+ state,
42040
+ suggestions: visibleSuggestions(state, opts.commands),
42041
+ placeholder: opts.placeholder,
42042
+ hint: opts.hint,
42043
+ status: opts.status(),
42044
+ armed: Date.now() < armedUntil
42045
+ }, t, frameWidth(out.columns));
42046
+ region.render(lines, cursor);
42047
+ };
42048
+ const tick = setInterval(draw, 3e4);
42049
+ tick.unref();
42050
+ out.on("resize", draw);
42051
+ const finish = (result) => {
42052
+ release();
42053
+ clearInterval(tick);
42054
+ clearTimeout(armTimer);
42055
+ out.off("resize", draw);
42056
+ if (result.kind === "line" && result.value.trim()) {
42057
+ region.render([t.dim(`> ${result.value}`)]);
42058
+ region.done();
42059
+ } else region.clear();
42060
+ resolve(result);
42061
+ };
42062
+ const release = listenKeys((key) => {
42063
+ const r = editorReducer(state, key, opts.commands);
42064
+ state = r.state;
42065
+ if (r.event === "submit") return finish({
42066
+ kind: "line",
42067
+ value: state.value
42068
+ });
42069
+ if (r.event === "eof") return finish({ kind: "exit" });
42070
+ if (r.event === "interrupt") {
42071
+ if (Date.now() < armedUntil) return finish({ kind: "exit" });
42072
+ armedUntil = Date.now() + ARM_MS;
42073
+ clearTimeout(armTimer);
42074
+ armTimer = setTimeout(draw, 2050);
42075
+ armTimer.unref();
42076
+ }
42077
+ draw();
42078
+ });
42079
+ draw();
42080
+ });
42081
+ }
42082
+ //#endregion
42083
+ //#region src/tui/select.ts
42084
+ function initialSelect(choices, start = 0) {
42085
+ const ok = (i) => i >= 0 && i < choices.length && !choices[i].disabled;
42086
+ const index = ok(start) ? start : choices.findIndex((c) => !c.disabled);
42087
+ return {
42088
+ index: Math.max(0, index),
42089
+ done: null
42090
+ };
42091
+ }
42092
+ function step(index, dir, choices) {
42093
+ const n = choices.length;
42094
+ for (let k = 1; k <= n; k++) {
42095
+ const i = (index + dir * k + n * k) % n;
42096
+ if (!choices[i].disabled) return i;
42097
+ }
42098
+ return index;
42099
+ }
42100
+ function selectReducer(state, key, choices) {
42101
+ if (state.done) return state;
42102
+ if (key.name === "escape" || key.ctrl && key.name === "c") return {
42103
+ ...state,
42104
+ done: "cancelled"
42105
+ };
42106
+ if (key.name === "return" || key.name === "enter") return choices[state.index]?.disabled ? state : {
42107
+ ...state,
42108
+ done: "chosen"
42109
+ };
42110
+ if (key.name === "up" || key.text === "k" || key.ctrl && key.name === "p") return {
42111
+ ...state,
42112
+ index: step(state.index, -1, choices)
42113
+ };
42114
+ if (key.name === "down" || key.text === "j" || key.name === "tab" || key.ctrl && key.name === "n") return {
42115
+ ...state,
42116
+ index: step(state.index, 1, choices)
42117
+ };
42118
+ if (key.text && /^[1-9]$/.test(key.text)) {
42119
+ const i = Number(key.text) - 1;
42120
+ if (i < choices.length && !choices[i].disabled) return {
42121
+ index: i,
42122
+ done: "chosen"
42123
+ };
42124
+ }
42125
+ return state;
42126
+ }
42127
+ /** The menu as lines, inside its box, plus the key hint under it. */
42128
+ function renderSelect(state, view, t, width, maxVisible = 12) {
42129
+ const inner = width - 4;
42130
+ const lines = [t.bold(view.title)];
42131
+ if (view.body && view.body.length > 0) {
42132
+ lines.push("");
42133
+ for (const b of view.body) lines.push(...wrap(b, inner));
42134
+ }
42135
+ lines.push("");
42136
+ const labelWidth = Math.min(Math.max(...view.choices.map((c) => visibleWidth(c.label))), Math.floor(inner * .6));
42137
+ const n = view.choices.length;
42138
+ const first = Math.max(0, Math.min(state.index - Math.floor(maxVisible / 2), n - maxVisible));
42139
+ const last = Math.min(n, first + maxVisible);
42140
+ if (first > 0) lines.push(t.dim(` ↑ ${first} more`));
42141
+ for (let i = first; i < last; i++) {
42142
+ const c = view.choices[i];
42143
+ const on = i === state.index;
42144
+ const num = `${i + 1}.`;
42145
+ const plain = sanitize(c.label);
42146
+ const hint = c.hint ? sanitize(c.hint) : "";
42147
+ const label = hint ? padEnd(plain, labelWidth) : plain;
42148
+ const text = `${num} ${label}${hint ? ` ${t.dim(hint)}` : ""}`;
42149
+ if (c.disabled) lines.push(t.dim(` ${num} ${label}${hint ? ` ${hint}` : ""}`));
42150
+ else if (on) lines.push(`${t.accent(t.g.pointer)} ${t.bold(text)}`);
42151
+ else lines.push(` ${text}`);
42152
+ }
42153
+ if (last < n) lines.push(t.dim(` ↓ ${n - last} more`));
42154
+ return [...renderBox(lines, {
42155
+ width,
42156
+ theme: t,
42157
+ border: t.accent
42158
+ }), t.dim(" ↑↓ to move · enter to choose · esc to go back")];
42159
+ }
42160
+ /**
42161
+ * Ask, and resolve with the chosen value, or null if the person backed out.
42162
+ * The menu is replaced by a one-line record of the answer when it closes.
42163
+ */
42164
+ function select(view, opts = {}) {
42165
+ stopSpinner();
42166
+ const t = opts.theme ?? theme();
42167
+ const out = process.stdout;
42168
+ return new Promise((resolve) => {
42169
+ const region = new LiveRegion(out);
42170
+ let state = initialSelect(view.choices, opts.start);
42171
+ const draw = () => region.render(renderSelect(state, view, t, frameWidth(out.columns), Math.max(4, (out.rows || 24) - 14)));
42172
+ out.write(`${CSI}?25l`);
42173
+ draw();
42174
+ out.on("resize", draw);
42175
+ const release = listenKeys((key) => {
42176
+ state = selectReducer(state, key, view.choices);
42177
+ if (!state.done) return draw();
42178
+ release();
42179
+ out.off("resize", draw);
42180
+ out.write(`${CSI}?25h`);
42181
+ 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")}`]);
42183
+ region.done();
42184
+ resolve(chosen ? chosen.value : null);
42185
+ });
42186
+ });
42187
+ }
42188
+ /** Yes/No in the same style; resolves false on Esc. */
42189
+ async function confirm(title, body, yes = "Yes", no = "No") {
42190
+ return await select({
42191
+ title,
42192
+ body,
42193
+ choices: [{
42194
+ label: yes,
42195
+ value: true
42196
+ }, {
42197
+ label: no,
42198
+ value: false
42199
+ }]
42200
+ }) === true;
42201
+ }
42202
+ //#endregion
42203
+ //#region ../../src/lib/session/liveCost.ts
42204
+ function computeLiveCost(i) {
42205
+ const start = new Date(i.startTime).getTime();
42206
+ const end = i.endTime ? new Date(i.endTime).getTime() : i.now;
42207
+ const elapsedMs = Math.max(end - start, 0);
42208
+ const minutes = elapsedMs / 6e4;
42209
+ const retained = 1 - Math.min(Math.max(i.discountPct ?? 0, 0), 100) / 100;
42210
+ const effectiveRate = i.ratePerMinute * retained;
42211
+ return {
42212
+ elapsedMs,
42213
+ effectiveRate,
42214
+ cost: i.free ? 0 : minutes * effectiveRate,
42215
+ ended: Boolean(i.endTime)
42216
+ };
42217
+ }
42218
+ //#endregion
42219
+ //#region ../../src/features/ActiveSessionsFeature/ActiveSessionsLogic.ts
42220
+ /**
42221
+ * A dedicated-slot holder on their reserved device pays nothing. `slotDeviceId`
42222
+ * is null unless the subscription is active on that tier, so the comparison
42223
+ * alone is the whole rule.
42224
+ */
42225
+ function isFreeSession(session, slotDeviceId) {
42226
+ return slotDeviceId !== null && session.deviceId === slotDeviceId;
42227
+ }
42228
+ //#endregion
42229
+ //#region ../../src/lib/ai/credits.ts
42230
+ /**
42231
+ * Monthly AI credit grant per subscription tier, applied on `invoice.paid`.
42232
+ *
42233
+ * Lives HERE rather than in pricing.ts, which is `import "server-only"`, because
42234
+ * /pricing and /deviceyai both render these numbers — /deviceyai has printed
42235
+ * them since it shipped. They are not confidential the way MARKUP is; they are
42236
+ * the offer.
42237
+ *
42238
+ * Moved 2026-09-19 so `tiers.ts` could derive its feature bullets from this
42239
+ * object instead of retyping the figures. It had one for about an hour, and the
42240
+ * retyped Pro bullet was already wrong — it claimed "the largest monthly
42241
+ * allowance" for 1,000 while a slot and enterprise get 2,000. A derived bullet
42242
+ * cannot be wrong about a number; a typed one is only ever right until someone
42243
+ * edits the other copy.
42244
+ */
42245
+ const AI_MONTHLY_GRANT = {
42246
+ free: 0,
42247
+ plus: 300,
42248
+ pro: 1e3,
42249
+ dedicated_slot: 2e3,
42250
+ enterprise: 2e3
42251
+ };
42252
+ //#endregion
42253
+ //#region ../../src/lib/tiers.ts
42254
+ /**
42255
+ * The DeviceyAI bullet for a tier, derived from the grant the Stripe webhook
42256
+ * actually makes rather than retyped beside it.
42257
+ *
42258
+ * Retyping lasted about an hour on 2026-09-19 before it was wrong: the Pro
42259
+ * bullet read "the largest monthly DeviceyAI allowance" for 1,000 credits while
42260
+ * a dedicated slot and enterprise each get 2,000. Superlatives in a feature list
42261
+ * are a comparison against the other rows, and nothing was checking it.
42262
+ */
42263
+ function aiGrantFeature(tier) {
42264
+ return `${(AI_MONTHLY_GRANT[tier] ?? 0).toLocaleString("en-GB")} AI credits a month for DeviceyAI`;
42265
+ }
42266
+ /** "€549.89" / "€1,099.89" — euros with a thousands separator. */
42267
+ function formatEur(euros) {
42268
+ const [whole, frac] = Math.abs(euros).toFixed(2).split(".");
42269
+ const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
42270
+ return `${euros < 0 ? "−" : ""}€${grouped}.${frac}`;
42271
+ }
42272
+ const TIERS = [
42273
+ {
42274
+ id: "free",
42275
+ name: "Free",
42276
+ tagline: "Pay-as-you-go for hobby projects",
42277
+ monthlyPrice: 0,
42278
+ priceLabel: "€0",
42279
+ monthlyCredits: 0,
42280
+ discountPct: 0,
42281
+ features: [
42282
+ "€0.10 per device-minute — the standard rate",
42283
+ "€10 starter credit on signup",
42284
+ "Automation API keys — Appium, Espresso & CI",
42285
+ "Hosted Appium endpoint",
42286
+ `DeviceyAI copilot — 25 credits to try it, once`,
42287
+ "WebUSB local-device pairing"
42288
+ ]
42289
+ },
42290
+ {
42291
+ id: "plus",
42292
+ name: "Plus",
42293
+ tagline: "For solo devs shipping a side project",
42294
+ monthlyPrice: 29.99,
42295
+ priceLabel: "€29.99",
42296
+ monthlyCredits: 35,
42297
+ discountPct: 40,
42298
+ features: [
42299
+ "€0.06 per device-minute (40% off)",
42300
+ "€35 in monthly credits",
42301
+ aiGrantFeature("plus"),
42302
+ "12 months for the price of 11 on yearly billing",
42303
+ "Email support"
42304
+ ],
42305
+ priceIdEnv: "STRIPE_PRICE_PLUS",
42306
+ yearlyPriceIdEnv: "STRIPE_PRICE_PLUS_YEARLY"
42307
+ },
42308
+ {
42309
+ id: "pro",
42310
+ name: "Pro",
42311
+ tagline: "For teams running CI on real hardware",
42312
+ monthlyPrice: 49.99,
42313
+ priceLabel: "€49.99",
42314
+ monthlyCredits: 80,
42315
+ discountPct: 90,
42316
+ features: [
42317
+ "€0.01 per device-minute (90% off) — about €15/day of nonstop use",
42318
+ "€80 in monthly credits",
42319
+ aiGrantFeature("pro"),
42320
+ "12 months for the price of 11 on yearly billing",
42321
+ "Slack support · 1 business day"
42322
+ ],
42323
+ badge: "Recommended",
42324
+ highlight: true,
42325
+ priceIdEnv: "STRIPE_PRICE_PRO",
42326
+ yearlyPriceIdEnv: "STRIPE_PRICE_PRO_YEARLY"
42327
+ },
42328
+ {
42329
+ id: "dedicated_slot",
42330
+ name: "Rent a Slot",
42331
+ tagline: "Your own device, 24/7, never billed by the minute",
42332
+ monthlyPrice: 99.99,
42333
+ priceLabel: "€99.99",
42334
+ monthlyCredits: 0,
42335
+ discountPct: 100,
42336
+ features: [
42337
+ "No per-minute charge at all — the meter is off",
42338
+ "A dedicated device, auto-assigned & reserved to you",
42339
+ "A permanently-paired device — automation with no rental to start",
42340
+ aiGrantFeature("dedicated_slot"),
42341
+ "24/7 availability — nobody else can rent it",
42342
+ "Priority hardware swap on failure"
42343
+ ],
42344
+ priceIdEnv: "STRIPE_PRICE_SLOT",
42345
+ yearlyPriceIdEnv: "STRIPE_PRICE_SLOT_YEARLY"
42346
+ },
42347
+ {
42348
+ id: "enterprise",
42349
+ name: "Enterprise",
42350
+ tagline: "Whitelabel the fleet for your org",
42351
+ monthlyPrice: null,
42352
+ priceLabel: "Custom",
42353
+ monthlyCredits: 0,
42354
+ discountPct: 0,
42355
+ features: [
42356
+ "Custom SLA + uptime guarantees",
42357
+ aiGrantFeature("enterprise"),
42358
+ "Whitelabeled device fleet",
42359
+ "Dedicated account manager",
42360
+ "On-prem racks available"
42361
+ ],
42362
+ contactSales: true
42363
+ }
42364
+ ];
42365
+ function tierById(id) {
42366
+ return TIERS.find((t) => t.id === id) ?? TIERS[0];
42367
+ }
42368
+ /**
42369
+ * The discount the meter will actually apply: the tier's advertised
42370
+ * percentage, but only while the subscription is `active`. That is the
42371
+ * condition consume_credits checks at charge time, so a past_due Plus
42372
+ * customer is billed list price and must be quoted list price — every
42373
+ * surface that shows a rate (session ticker, multi-rent bar, active-sessions
42374
+ * panel) goes through this one rule rather than restating it.
42375
+ */
42376
+ function activeDiscountPct(subscription) {
42377
+ return subscription.status === "active" ? tierById(subscription.tier).discountPct : 0;
42378
+ }
42379
+ function safeTierById(id) {
42380
+ if (!id) return null;
42381
+ const found = TIERS.find((t) => t.id === id);
42382
+ return found ? found.id : null;
42383
+ }
42384
+ //#endregion
42385
+ //#region src/interactive/account.ts
42386
+ async function loadAccount(ctx, user) {
42387
+ const [profile, sub] = await Promise.all([loadProfile(ctx, user.id), ctx.supabase.rpc("my_subscription").maybeSingle()]);
42388
+ const row = sub.data ?? null;
42389
+ const tier = safeTierById(row?.tier ?? profile.tier) ?? "free";
42390
+ const status = row?.status ?? profile.subscriptionStatus;
42391
+ return {
42392
+ userId: user.id,
42393
+ email: user.email ?? "",
42394
+ wallet: profile.wallet,
42395
+ aiCredits: profile.aiCredits,
42396
+ planName: tierById(tier).name,
42397
+ standing: profile.standing,
42398
+ discountPct: activeDiscountPct({
42399
+ status,
42400
+ tier
42401
+ }),
42402
+ slotDeviceId: status === "active" && tier === "dedicated_slot" ? row?.dedicated_device_id ?? null : null
42403
+ };
42404
+ }
42405
+ /** € a minute this account actually pays for a phone at `ratePerMinute`. */
42406
+ function yourRate(account, device) {
42407
+ if (account.slotDeviceId === device.id) return 0;
42408
+ return computeLiveCost({
42409
+ startTime: (/* @__PURE__ */ new Date(0)).toISOString(),
42410
+ ratePerMinute: device.ratePerMinute,
42411
+ discountPct: account.discountPct,
42412
+ now: 0
42413
+ }).effectiveRate;
42414
+ }
42415
+ /** "€0.01/min" — or "included" on a dedicated slot. */
42416
+ function formatRate(rate) {
42417
+ if (rate === 0) return "included in your plan";
42418
+ return Math.round(rate * 1e3) < 10 ? `€${rate.toFixed(3)}/min` : `${eur(rate)}/min`;
42419
+ }
42420
+ /** The catalogue stores "Android 17" (or, on older rows, just "17"). */
42421
+ function androidLabel(version) {
42422
+ const v = version.trim();
42423
+ return /^android\b/i.test(v) ? v : `Android ${v}`;
42424
+ }
42425
+ /**
42426
+ * "● Pixel 7a #2 · 12 min · €0.12" for each running rental — computed from
42427
+ * the start time on every draw, with no network call.
42428
+ */
42429
+ function statusLine(rentals, account, now, t) {
42430
+ if (!account) return t.dim("not signed in");
42431
+ if (rentals.length === 0) return t.dim("no phone rented");
42432
+ return rentals.map((r) => {
42433
+ const free = isFreeSession(r, account.slotDeviceId);
42434
+ const live = computeLiveCost({
42435
+ startTime: r.startTime,
42436
+ ratePerMinute: r.ratePerMinute,
42437
+ discountPct: account.discountPct,
42438
+ free,
42439
+ now
42440
+ });
42441
+ const minutes = Math.floor(live.elapsedMs / 6e4);
42442
+ return `${t.accent(t.g.bullet)} ${r.deviceName} ${t.dim(`· ${minutes} min · ${free ? "included" : eur(live.cost)}`)}`;
42443
+ }).join(" ");
42444
+ }
42445
+ //#endregion
42446
+ //#region ../../src/lib/ai/pricing.ts
42447
+ const AI_CREDIT_PACKS = [
42448
+ {
42449
+ id: "s",
42450
+ label: "Starter",
42451
+ priceEur: 5,
42452
+ credits: 500,
42453
+ priceIdEnv: "STRIPE_PRICE_AI_PACK_S"
42454
+ },
42455
+ {
42456
+ id: "m",
42457
+ label: "Standard",
42458
+ priceEur: 12,
42459
+ credits: 1500,
42460
+ priceIdEnv: "STRIPE_PRICE_AI_PACK_M"
42461
+ },
42462
+ {
42463
+ id: "l",
42464
+ label: "Bulk",
42465
+ priceEur: 30,
42466
+ credits: 4e3,
42467
+ priceIdEnv: "STRIPE_PRICE_AI_PACK_L"
42468
+ }
42469
+ ];
42470
+ //#endregion
42471
+ //#region ../../src/lib/ai/toolBlurbs.ts
42472
+ const TOOL_BLURBS = {
42473
+ observe_screen: "Read the screen as a list of elements with exact positions",
42474
+ screenshot: "Take a picture of the screen and look at it",
42475
+ list_packages: "List the apps installed on the phone",
42476
+ device_info: "Read the model, Android version, screen size and orientation",
42477
+ wait: "Pause while something loads or animates",
42478
+ list_instrumentations: "Find the exact test-runner target installed on the phone",
42479
+ logcat_tail: "Read the tail of the device log, filtered to what matters",
42480
+ read_device_file: "Read a text file a test wrote — a JUnit XML or coverage report",
42481
+ tap: "Tap a point on the screen",
42482
+ swipe: "Swipe or scroll",
42483
+ type_text: "Type into the focused field",
42484
+ press_key: "Press Home, Back, Recents, Power, Volume, Enter or Delete",
42485
+ launch_app: "Open an installed app",
42486
+ install_apk: "Install an APK you uploaded to this rental",
42487
+ list_uploads: "See which of your files are available to install",
42488
+ run_instrumentation: "Run an instrumentation suite already on the phone and report pass/fail",
42489
+ save_test_file: "Keep a test file it wrote, for you to download",
42490
+ shell: "Run an ADB shell command",
42491
+ appium_start_session: "Open a real Appium (UiAutomator2) session on the phone",
42492
+ appium_page_source: "Read the UI hierarchy through that session",
42493
+ appium_find: "Prove a locator resolves on the live app before it goes into a test",
42494
+ appium_click: "Click an element it found, rather than a raw coordinate",
42495
+ appium_send_keys: "Type into an element it found",
42496
+ appium_end_session: "Close the session and release the phone"
42497
+ };
42498
+ [
42499
+ ...READ_TOOLS,
42500
+ ...ACTION_TOOLS,
42501
+ ...SHELL_TOOLS,
42502
+ ...APPIUM_TOOLS
42503
+ ].map((t) => t.name);
42504
+ //#endregion
42505
+ //#region src/interactive/aiChat.ts
42506
+ const KEYS = {
42507
+ home: "Home",
42508
+ back: "Back",
42509
+ app_switch: "Recent apps",
42510
+ power: "Power",
42511
+ volume_up: "Volume up",
42512
+ volume_down: "Volume down",
42513
+ enter: "Enter",
42514
+ tab: "Tab",
42515
+ delete: "Delete"
42516
+ };
42517
+ /**
42518
+ * One line for one tool call. Typed text is NEVER shown — it may be a
42519
+ * password (mask the data, not a view of it) — only how long it is. The
42520
+ * model writes these inputs, so whatever is echoed is sanitized.
42521
+ */
42522
+ function describeToolCall(name, input) {
42523
+ return sanitize(describe(name, input));
42524
+ }
42525
+ function describe(name, input) {
42526
+ const i = input ?? {};
42527
+ const n = (v) => typeof v === "number" ? Math.round(v) : "?";
42528
+ switch (name) {
42529
+ case "tap": return `Tap at ${n(i.x)}, ${n(i.y)}`;
42530
+ case "swipe": return `Swipe from ${n(i.x1)}, ${n(i.y1)} to ${n(i.x2)}, ${n(i.y2)}`;
42531
+ case "type_text": {
42532
+ const len = typeof i.text === "string" ? [...i.text].length : 0;
42533
+ return `Type ${len} character${len === 1 ? "" : "s"}`;
42534
+ }
42535
+ case "press_key": return `Press ${KEYS[String(i.key)] ?? String(i.key ?? "a key")}`;
42536
+ case "launch_app": return `Open ${typeof i.package === "string" ? i.package : "an app"}`;
42537
+ case "observe_screen": return "Read the screen";
42538
+ case "screenshot": return "Look at the screen";
42539
+ case "wait": return "Wait for the screen to settle";
42540
+ case "shell": return typeof i.command === "string" ? `Run: ${i.command.slice(0, 80)}` : "Run a shell command";
42541
+ default: return TOOL_BLURBS[name] ?? name;
42542
+ }
42543
+ }
42544
+ /**
42545
+ * Run one task. Throws AiNeedsActionsError (src/ai/loop.ts) when look-only
42546
+ * access was not enough, so the caller can ask for more and try again.
42547
+ */
42548
+ async function runChatTask(input, task, permission, t) {
42549
+ return withDevice(input, async (device) => {
42550
+ const { models, credits } = await loadAiConfig(device.rental.id);
42551
+ if (credits <= 0) throw new CliError(OUT_OF_CREDITS);
42552
+ const model = pickModel(models, void 0);
42553
+ const write = (s) => process.stdout.write(s);
42554
+ let inText = false;
42555
+ const endText = () => {
42556
+ if (inText) write("\n");
42557
+ inText = false;
42558
+ };
42559
+ const controller = new AbortController();
42560
+ const abort = () => controller.abort();
42561
+ input.signal?.addEventListener("abort", abort, { once: true });
42562
+ try {
42563
+ const result = await runAiTask(device, {
42564
+ task,
42565
+ provider: model.provider,
42566
+ model: model.id,
42567
+ allowActions: permission === "act",
42568
+ allowShell: false
42569
+ }, controller.signal, {
42570
+ onText: (delta) => {
42571
+ const text = sanitize(delta);
42572
+ if (!text) return;
42573
+ if (!inText) write(`${t.accent(t.g.dot)} `);
42574
+ inText = true;
42575
+ write(text.replace(/\n/g, "\n "));
42576
+ },
42577
+ onTool: (name, toolInput) => {
42578
+ endText();
42579
+ write(`${t.accent(t.g.dot)} ${describeToolCall(name, toolInput)}\n`);
42580
+ },
42581
+ onToolDone: (_name, isError, content) => {
42582
+ if (isError) write(` ${t.dim(t.g.elbow)} ${t.red(sanitize(content.split("\n")[0]).slice(0, 200))}\n`);
42583
+ }
42584
+ });
42585
+ endText();
42586
+ return result;
42587
+ } catch (err) {
42588
+ endText();
42589
+ throw err;
42590
+ } finally {
42591
+ input.signal?.removeEventListener("abort", abort);
42592
+ }
42593
+ });
42594
+ }
42595
+ //#endregion
42596
+ //#region src/interactive/files.ts
42597
+ let folders;
42598
+ function existing(p) {
42599
+ if (!p) return null;
42600
+ try {
42601
+ return fs.statSync(p).isDirectory() ? p : null;
42602
+ } catch {
42603
+ return null;
42604
+ }
42605
+ }
42606
+ function askWindows() {
42607
+ const script = "[Console]::OutputEncoding=[Text.Encoding]::UTF8;[Environment]::GetFolderPath('Desktop');(New-Object -ComObject Shell.Application).NameSpace('shell:Downloads').Self.Path";
42608
+ return new Promise((resolve) => {
42609
+ execFile(systemExe("WindowsPowerShell\\v1.0\\powershell.exe"), [
42610
+ "-NoProfile",
42611
+ "-NonInteractive",
42612
+ "-Command",
42613
+ script
42614
+ ], {
42615
+ timeout: 5e3,
42616
+ windowsHide: true,
42617
+ encoding: "utf8"
42618
+ }, (err, stdout) => {
42619
+ const [desktop, downloads] = err ? [] : stdout.split(/\r?\n/).map((l) => l.trim());
42620
+ resolve({
42621
+ desktop: existing(desktop) ?? existing(path.join(os.homedir(), "Desktop")),
42622
+ downloads: existing(downloads) ?? existing(path.join(os.homedir(), "Downloads"))
42623
+ });
42624
+ });
42625
+ });
42626
+ }
42627
+ /** The person's Downloads and Desktop folders (looked up once, lazily). */
42628
+ function knownFolders() {
42629
+ folders ??= process.platform === "win32" ? askWindows() : Promise.resolve({
42630
+ downloads: existing(path.join(os.homedir(), "Downloads")),
42631
+ desktop: existing(path.join(os.homedir(), "Desktop"))
42632
+ });
42633
+ return folders;
42634
+ }
42635
+ /** The newest `.apk` files directly inside `dirs` (not their subfolders). */
42636
+ function findApks(dirs, limit = 8) {
42637
+ const seen = /* @__PURE__ */ new Set();
42638
+ const found = [];
42639
+ for (const dir of dirs) {
42640
+ if (!dir || seen.has(dir)) continue;
42641
+ seen.add(dir);
42642
+ let names;
42643
+ try {
42644
+ names = fs.readdirSync(dir);
42645
+ } catch {
42646
+ continue;
42647
+ }
42648
+ for (const name of names) {
42649
+ if (!name.toLowerCase().endsWith(".apk")) continue;
42650
+ const full = path.join(dir, name);
42651
+ try {
42652
+ const st = fs.statSync(full);
42653
+ if (st.isFile()) found.push({
42654
+ path: full,
42655
+ name,
42656
+ folder: dir,
42657
+ bytes: st.size,
42658
+ modified: st.mtimeMs
42659
+ });
42660
+ } catch {}
42661
+ }
42662
+ }
42663
+ return found.sort((a, b) => b.modified - a.modified).slice(0, limit);
42664
+ }
42665
+ /** "2 h ago", "3 d ago" — for the file picker. */
42666
+ function ago(ms, now) {
42667
+ const s = Math.max(0, Math.round((now - ms) / 1e3));
42668
+ if (s < 90) return "just now";
42669
+ const m = Math.round(s / 60);
42670
+ if (m < 90) return `${m} min ago`;
42671
+ const h = Math.round(m / 60);
42672
+ if (h < 36) return `${h} h ago`;
42673
+ return `${Math.round(h / 24)} d ago`;
42674
+ }
42675
+ /** A folder name a person recognises: "Downloads", not the whole path. */
42676
+ function folderLabel(dir, f, cwd) {
42677
+ if (dir === f.downloads) return "Downloads";
42678
+ if (dir === f.desktop) return "Desktop";
42679
+ if (dir === cwd) return "this folder";
42680
+ return dir;
42681
+ }
42682
+ //#endregion
42683
+ //#region src/interactive/tokenize.ts
42684
+ function tokenize(line, p) {
42685
+ const words = [];
42686
+ let word = "";
42687
+ let inWord = false;
42688
+ let quote = null;
42689
+ const escapes = p.platform !== "win32";
42690
+ for (let i = 0; i < line.length; i++) {
42691
+ const ch = line[i];
42692
+ if (quote) {
42693
+ if (ch === quote) quote = null;
42694
+ else if (escapes && quote === "\"" && ch === "\\" && i + 1 < line.length) word += line[++i];
42695
+ else word += ch;
42696
+ continue;
42697
+ }
42698
+ if (ch === "\"" || ch === "'") {
42699
+ quote = ch;
42700
+ inWord = true;
42701
+ continue;
42702
+ }
42703
+ if (escapes && ch === "\\" && i + 1 < line.length) {
42704
+ word += line[++i];
42705
+ inWord = true;
42706
+ continue;
42707
+ }
42708
+ if (/\s/.test(ch)) {
42709
+ if (inWord) words.push(word);
42710
+ word = "";
42711
+ inWord = false;
42712
+ continue;
42713
+ }
42714
+ word += ch;
42715
+ inWord = true;
42716
+ }
42717
+ if (inWord) words.push(word);
42718
+ return words.map((w) => expandHome(w, p));
42719
+ }
42720
+ function expandHome(word, p) {
42721
+ if (word === "~") return p.home;
42722
+ if (word.startsWith("~/") || p.platform === "win32" && word.startsWith("~\\")) return p.home + word.slice(1);
42723
+ return word;
42724
+ }
42725
+ /**
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.
42729
+ */
42730
+ function droppedPath(line, p) {
42731
+ let s = line.trim();
42732
+ if (s.startsWith("& ")) s = s.slice(2).trim();
42733
+ 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;
42737
+ }
42738
+ //#endregion
42739
+ //#region src/interactive/flows.ts
42740
+ const STATUS_WORDS = {
42741
+ available: "free",
42742
+ busy: "in use",
42743
+ sanitizing: "being wiped for the next person",
42744
+ offline: "offline",
42745
+ maintenance: "in maintenance"
42746
+ };
42747
+ const OTHER = "\0other";
42748
+ function slug(s) {
42749
+ return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
42750
+ }
42751
+ /** A line read at a plain prompt, or null if the person pressed Ctrl-C. */
42752
+ async function ask(question) {
42753
+ try {
42754
+ return await promptLine(question);
42755
+ } catch {
42756
+ return null;
42757
+ }
42758
+ }
42759
+ async function loginFlow(s) {
42760
+ if (s.account) {
42761
+ s.result(`You're signed in as ${s.account.email}.`);
42762
+ return;
42763
+ }
42764
+ if (!s.ctx.config.login_enabled) {
42765
+ s.error(`Signing in from a terminal is switched off on ${s.ctx.apiBase}.`);
42766
+ return;
42767
+ }
42768
+ const how = await select({
42769
+ title: "Sign in to DeviceRent",
42770
+ body: ["Your browser opens devicerent.net; press Approve there and come back. Nothing secret is typed here."],
42771
+ choices: [
42772
+ {
42773
+ label: "Sign in with my browser",
42774
+ value: "browser",
42775
+ hint: "recommended"
42776
+ },
42777
+ {
42778
+ label: "Sign in with a code",
42779
+ value: "code",
42780
+ hint: "when the browser is on another device"
42781
+ },
42782
+ {
42783
+ label: "Not now",
42784
+ value: null
42785
+ }
42786
+ ]
42787
+ });
42788
+ if (!how) return;
42789
+ await s.run(how === "code" ? ["login", "--no-browser"] : ["login"], {
42790
+ title: "Signing in",
42791
+ raw: true
42792
+ });
42793
+ const now = signedIn(s);
42794
+ if (now) s.say(`Signed in as ${s.t.bold(now.email)}.`);
42795
+ }
42796
+ function signedIn(s) {
42797
+ return s.account;
42798
+ }
42799
+ async function ensureSignedIn(s) {
42800
+ if (s.account) return true;
42801
+ s.result("You need to sign in first.");
42802
+ await loginFlow(s);
42803
+ return s.account !== null;
42804
+ }
42805
+ async function logoutFlow(s) {
42806
+ if (!s.account) return s.result("You're not signed in.");
42807
+ 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
+ }
42809
+ async function loadFleet(s) {
42810
+ return s.runTask(() => loadFleetDevices(s.ctx), { spinner: "Checking which phones are free" });
42811
+ }
42812
+ async function phonesFlow(s) {
42813
+ if (!await ensureSignedIn(s)) return;
42814
+ const fleet = await loadFleet(s);
42815
+ if (!fleet) return;
42816
+ const account = s.account;
42817
+ const groups = groupFleet(fleet, new Set(s.rentals.map((r) => r.deviceId)));
42818
+ s.say(s.t.bold("Phones in the rack"));
42819
+ const width = Math.max(...groups.map((g) => g.label.length));
42820
+ groups.forEach((g, i) => {
42821
+ const state = g.free.length > 0 ? s.t.green(`${g.free.length} of ${g.total} free`) : s.t.dim(g.why);
42822
+ const rate = g.free.length > 0 ? ` ${s.t.dim(formatRate(yourRate(account, g.free[0])))}` : "";
42823
+ const line = `${g.label.padEnd(width)} ${state}${rate}`;
42824
+ if (i === 0) s.result(line);
42825
+ else s.write(` ${line}`);
42826
+ });
42827
+ s.hint("Type rent to rent one.");
42828
+ }
42829
+ async function rentFlow(s, query) {
42830
+ if (!await ensureSignedIn(s)) return;
42831
+ const fleet = await loadFleet(s);
42832
+ if (!fleet) return;
42833
+ const account = s.account;
42834
+ const mine = new Set(s.rentals.map((r) => r.deviceId));
42835
+ if (!fleet.some((d) => d.rentable && !mine.has(d.id))) {
42836
+ s.result("Every phone is busy right now. Try again in a few minutes.");
42837
+ return;
42838
+ }
42839
+ let chosen = null;
42840
+ if (query) try {
42841
+ chosen = pickDevice(fleet, query, mine);
42842
+ } catch (err) {
42843
+ s.hint(err.message);
42844
+ }
42845
+ if (!chosen) {
42846
+ chosen = await select({
42847
+ title: "Which phone would you like?",
42848
+ body: ["You pay by the minute from when you rent it until you stop it."],
42849
+ choices: groupFleet(fleet, mine).map((g) => ({
42850
+ label: g.label,
42851
+ value: g.free[0],
42852
+ disabled: g.free.length === 0,
42853
+ hint: g.free.length > 0 ? `${formatRate(yourRate(account, g.free[0]))} · ${g.free.length} free` : g.why
42854
+ }))
42855
+ });
42856
+ if (!chosen) return;
42857
+ }
42858
+ const rate = yourRate(account, chosen);
42859
+ if (!await confirm(`Rent ${chosen.name}?`, [
42860
+ rate === 0 ? "It's included in your plan." : `${formatRate(rate)} on your ${account.planName} plan, from now until you stop it.`,
42861
+ `Your wallet: ${eur(account.wallet)}.`,
42862
+ "A phone left idle for 30 minutes is stopped for you."
42863
+ ], "Yes, rent it", "No")) return;
42864
+ let rented = false;
42865
+ await s.run(["rent", chosen.id], {
42866
+ title: `Renting ${chosen.name}`,
42867
+ spinner: `Renting ${chosen.name}`,
42868
+ onResult: (r) => {
42869
+ if (r.kind === "rented") rented = true;
42870
+ }
42871
+ });
42872
+ if (rented) s.hint("Next: drag your .apk onto this window to install it — or just say what to do on the phone.");
42873
+ }
42874
+ /**
42875
+ * Identical phones as ONE line ("Google Pixel 7a · Android 17 — 5 free"):
42876
+ * several share a name, and a menu of indistinguishable lines only confuses.
42877
+ * One model on two Android versions stays two lines. Pure.
42878
+ */
42879
+ function groupFleet(fleet, mine) {
42880
+ const groups = /* @__PURE__ */ new Map();
42881
+ for (const d of fleet) {
42882
+ const key = `${d.name}\u0000${d.androidVersion}`;
42883
+ groups.set(key, [...groups.get(key) ?? [], d]);
42884
+ }
42885
+ return [...groups.values()].map((all) => {
42886
+ const d = all[0];
42887
+ return {
42888
+ label: `${d.name} · ${androidLabel(d.androidVersion)}`,
42889
+ androidVersion: d.androidVersion,
42890
+ total: all.length,
42891
+ free: all.filter((x) => x.rentable && !mine.has(x.id)),
42892
+ why: all.some((x) => mine.has(x.id)) ? "already yours" : STATUS_WORDS[d.status] ?? d.status
42893
+ };
42894
+ });
42895
+ }
42896
+ /** The rental to act on: the only one, a menu of several, or offer to rent. */
42897
+ async function pickRental(s, title) {
42898
+ if (!await ensureSignedIn(s)) return null;
42899
+ if (s.rentals.length === 0) {
42900
+ if (!await confirm("You don't have a phone yet", ["Rent one now? You only pay while it's rented."], "Yes, pick a phone", "Not now")) return null;
42901
+ await rentFlow(s);
42902
+ }
42903
+ if (s.rentals.length <= 1) return s.rentals[0] ?? null;
42904
+ const now = Date.now();
42905
+ return select({
42906
+ title,
42907
+ choices: s.rentals.map((r) => ({
42908
+ label: r.deviceName,
42909
+ value: r,
42910
+ hint: `rented ${Math.floor(minutesSince(r.startTime, now))} min ago`
42911
+ }))
42912
+ });
42913
+ }
42914
+ async function stopFlow(s, arg) {
42915
+ if (!await ensureSignedIn(s)) return;
42916
+ if (s.rentals.length === 0) {
42917
+ s.result("Nothing is rented, so nothing is being charged.");
42918
+ return;
42919
+ }
42920
+ let target = arg === "all" ? "all" : s.rentals.length === 1 ? s.rentals[0] : null;
42921
+ if (!target) {
42922
+ target = await select({
42923
+ title: "Which phone are you done with?",
42924
+ choices: [...s.rentals.map((r) => ({
42925
+ label: r.deviceName,
42926
+ value: r
42927
+ })), {
42928
+ label: "All of them",
42929
+ value: "all"
42930
+ }]
42931
+ });
42932
+ if (!target) return;
42933
+ }
42934
+ const now = Date.now();
42935
+ const names = target === "all" ? s.rentals.map((r) => r.deviceName).join(", ") : target.deviceName;
42936
+ if (!await confirm(`Stop ${names}?`, [target === "all" ? `${s.rentals.length} phones, billing ends now.` : `It has run ${Math.floor(minutesSince(target.startTime, now))} min; billing ends now.`, "Each phone is wiped after you stop: apps and files you put on it are deleted."], "Yes, stop", "No, keep it")) return;
42937
+ await s.run(target === "all" ? ["stop", "--all"] : ["stop", target.id], {
42938
+ title: `Stopping ${names}`,
42939
+ spinner: "Stopping and settling the bill"
42940
+ });
42941
+ }
42942
+ /** An APK from the usual places, or one dragged in / typed. */
42943
+ async function pickApk(s, title) {
42944
+ const f = await knownFolders();
42945
+ const cwd = process.cwd();
42946
+ const now = Date.now();
42947
+ const apks = findApks([
42948
+ cwd,
42949
+ f.downloads,
42950
+ f.desktop
42951
+ ]);
42952
+ const choices = apks.map((a) => ({
42953
+ label: a.name,
42954
+ value: a.path,
42955
+ hint: `${folderLabel(a.folder, f, cwd)} · ${formatBytes(a.bytes)} · ${ago(a.modified, now)}`
42956
+ }));
42957
+ choices.push({
42958
+ label: "Another file",
42959
+ value: OTHER,
42960
+ hint: "drag it onto this window, or type where it is"
42961
+ });
42962
+ const pick = await select({
42963
+ title,
42964
+ body: apks.length ? [] : ["There are no .apk files in Downloads, on the Desktop or in this folder."],
42965
+ choices
42966
+ });
42967
+ if (pick !== OTHER) return pick;
42968
+ const line = await ask(" Drag the .apk onto this window (or type its path), then press Enter:\n > ");
42969
+ if (!line) return null;
42970
+ const file = droppedPath(line, s.platform) ?? line.trim();
42971
+ if (!fs.existsSync(file)) {
42972
+ s.error(`There is no file at ${file}.`);
42973
+ return null;
42974
+ }
42975
+ return file;
42976
+ }
42977
+ async function installFlow(s, file) {
42978
+ const rental = await pickRental(s, "Install on which phone?");
42979
+ if (!rental) return;
42980
+ const apk = file ?? await pickApk(s, "Which app do you want to install?");
42981
+ if (!apk) return;
42982
+ let pkg = null;
42983
+ if (await s.run([
42984
+ "install",
42985
+ apk,
42986
+ "--rental",
42987
+ rental.id
42988
+ ], {
42989
+ title: `Installing ${path.basename(apk)} on ${rental.deviceName}`,
42990
+ onResult: (r) => {
42991
+ if (r.kind === "installed") pkg = r.apps.find((a) => a.packageName)?.packageName ?? null;
42992
+ }
42993
+ }) !== 0 || !pkg) return;
42994
+ const app = pkg;
42995
+ if (await confirm(`Open ${app} now?`, [], "Yes, open it", "Not now")) await s.run([
42996
+ "launch",
42997
+ app,
42998
+ "--rental",
42999
+ rental.id
43000
+ ], { title: `Opening ${app}` });
43001
+ s.hint("Next: \"test my app\" runs a quick crash check — or say what to do on the phone in plain words.");
43002
+ }
43003
+ /** The apps you installed on the phone (not the system's). */
43004
+ async function listApps(s, rental) {
43005
+ return s.runTask((signal) => withDevice(s.input(rental.id, signal), async (device) => parsePackageList(await sh(device.adb, [
43006
+ "pm",
43007
+ "list",
43008
+ "packages",
43009
+ "-3"
43010
+ ]))), { spinner: `Reading the apps on ${rental.deviceName}` });
43011
+ }
43012
+ async function pickApp(s, rental, title, extra = []) {
43013
+ const apps = await listApps(s, rental);
43014
+ if (!apps) return null;
43015
+ if (apps.length === 0 && extra.length === 0) {
43016
+ s.result(`None of your apps are on ${rental.deviceName} yet. Drag an .apk onto this window to install one.`);
43017
+ return null;
43018
+ }
43019
+ return select({
43020
+ title,
43021
+ body: apps.length ? [] : [`None of your apps are on ${rental.deviceName} yet.`],
43022
+ choices: [...apps.sort().map((a) => ({
43023
+ label: a,
43024
+ value: a
43025
+ })), ...extra]
43026
+ });
43027
+ }
43028
+ async function appsFlow(s) {
43029
+ const rental = await pickRental(s, "Which phone?");
43030
+ if (!rental) return;
43031
+ const apps = await listApps(s, rental);
43032
+ if (!apps) return;
43033
+ s.say(s.t.bold(`Your apps on ${rental.deviceName}`));
43034
+ if (apps.length === 0) s.result("None yet. Drag an .apk onto this window to install one.");
43035
+ apps.sort().forEach((a, i) => i === 0 ? s.result(a) : s.write(` ${a}`));
43036
+ }
43037
+ async function launchFlow(s, pkg) {
43038
+ const rental = await pickRental(s, "On which phone?");
43039
+ if (!rental) return;
43040
+ const app = pkg ?? await pickApp(s, rental, "Which app should I open?");
43041
+ if (!app) return;
43042
+ await s.run([
43043
+ "launch",
43044
+ app,
43045
+ "--rental",
43046
+ rental.id
43047
+ ], { title: `Opening ${app}` });
43048
+ }
43049
+ async function uninstallFlow(s, pkg) {
43050
+ const rental = await pickRental(s, "On which phone?");
43051
+ if (!rental) return;
43052
+ const app = pkg ?? await pickApp(s, rental, "Which app should I remove?");
43053
+ if (!app) return;
43054
+ if (!await confirm(`Remove ${app} from ${rental.deviceName}?`, [], "Yes, remove it", "No")) return;
43055
+ await s.run([
43056
+ "uninstall",
43057
+ app,
43058
+ "--rental",
43059
+ rental.id
43060
+ ], { title: `Removing ${app}` });
43061
+ }
43062
+ async function testFlow(s, target) {
43063
+ const rental = await pickRental(s, "Test on which phone?");
43064
+ if (!rental) return;
43065
+ let what = target ?? null;
43066
+ if (!what) {
43067
+ what = await pickApp(s, rental, "Which app should the quick test check?", [{
43068
+ label: "An .apk file on this computer",
43069
+ value: OTHER,
43070
+ hint: "installs it first"
43071
+ }]);
43072
+ if (what === OTHER) what = await pickApk(s, "Which app do you want to test?");
43073
+ }
43074
+ if (!what) return;
43075
+ s.hint("The quick test opens the app, taps around for a minute and reports any crash. No test code needed.");
43076
+ await s.run([
43077
+ "test",
43078
+ "quick",
43079
+ what,
43080
+ "--rental",
43081
+ rental.id
43082
+ ], { title: `Quick test of ${path.basename(what)} on ${rental.deviceName}` });
43083
+ }
43084
+ async function screenshotFlow(s) {
43085
+ const rental = await pickRental(s, "Screenshot of which phone?");
43086
+ if (!rental) return;
43087
+ const f = await knownFolders();
43088
+ const dir = f.downloads ?? process.cwd();
43089
+ const stamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 19).replace(/[:T]/g, "-");
43090
+ const file = path.join(dir, `devicerent-${slug(rental.deviceName)}-${stamp}.png`);
43091
+ let saved = null;
43092
+ await s.run([
43093
+ "screenshot",
43094
+ file,
43095
+ "--rental",
43096
+ rental.id
43097
+ ], {
43098
+ title: `Screenshot of ${rental.deviceName}`,
43099
+ spinner: "Taking the screenshot",
43100
+ onResult: (r) => {
43101
+ if (r.kind === "screenshot") saved = r.file;
43102
+ }
43103
+ });
43104
+ if (saved) s.hint(openBrowser(saved) ? `Opened it. It's in your ${folderLabel(dir, f, process.cwd())} folder.` : `It's in your ${folderLabel(dir, f, process.cwd())} folder.`);
43105
+ }
43106
+ async function logsFlow(s) {
43107
+ const rental = await pickRental(s, "Logs from which phone?");
43108
+ if (!rental) return;
43109
+ await s.run([
43110
+ "logcat",
43111
+ "--lines",
43112
+ "60",
43113
+ "--rental",
43114
+ rental.id
43115
+ ], { title: `The last lines of ${rental.deviceName}'s log` });
43116
+ s.hint("Follow it live with: /logcat -f (Ctrl-C stops)");
43117
+ }
43118
+ async function balanceFlow(s) {
43119
+ if (!await ensureSignedIn(s)) return;
43120
+ const a = s.account;
43121
+ s.say(s.t.bold("Your account"));
43122
+ s.result(`${a.email} · ${a.planName} plan`);
43123
+ s.write(` Wallet ${s.t.bold(eur(a.wallet))} · AI credits ${s.t.bold(a.aiCredits.toLocaleString("en"))}`);
43124
+ if (a.standing !== "active") s.write(` ${s.t.yellow(`Account ${a.standing}`)}`);
43125
+ s.hint("Add money: topup · Change plan: plans · More AI credits: buy credits");
43126
+ }
43127
+ async function topupFlow(s, amount) {
43128
+ if (!await ensureSignedIn(s)) return;
43129
+ let value = amount ?? null;
43130
+ if (!value) {
43131
+ value = await select({
43132
+ title: "How much would you like to add?",
43133
+ body: [`Your wallet: ${eur(s.account.wallet)}. Payment happens on Stripe, in your browser.`],
43134
+ choices: [
43135
+ {
43136
+ label: "€10",
43137
+ value: "10"
43138
+ },
43139
+ {
43140
+ label: "€20",
43141
+ value: "20"
43142
+ },
43143
+ {
43144
+ label: "€50",
43145
+ value: "50"
43146
+ },
43147
+ {
43148
+ label: "Another amount",
43149
+ value: OTHER
43150
+ }
43151
+ ]
43152
+ });
43153
+ if (value === OTHER) {
43154
+ const typed = await ask(" How many euros? ");
43155
+ value = typed ? typed.replace(/^€/, "").trim() : null;
43156
+ }
43157
+ }
43158
+ if (!value) return;
43159
+ if (await s.run(["topup", value], { title: `Adding €${value} to your wallet` }) === 0) s.hint("Finish the payment in your browser; your wallet updates as soon as it goes through.");
43160
+ }
43161
+ async function plansFlow(s, plan) {
43162
+ if (!await ensureSignedIn(s)) return;
43163
+ const sellable = TIERS.filter((t) => t.monthlyPrice !== null && t.monthlyPrice > 0);
43164
+ const ids = {
43165
+ plus: "plus",
43166
+ pro: "pro",
43167
+ dedicated_slot: "slot"
43168
+ };
43169
+ let choice = plan ?? null;
43170
+ if (!choice) choice = await select({
43171
+ title: "Plans",
43172
+ body: [`You're on ${s.account.planName}.`],
43173
+ choices: [...sellable.map((t) => ({
43174
+ label: t.name,
43175
+ value: ids[t.id] ?? t.id,
43176
+ hint: t.name === s.account.planName ? "your plan now" : t.discountPct >= 100 ? `${formatEur(t.monthlyPrice)} a month · a phone of your own` : `${formatEur(t.monthlyPrice)} a month · ${t.discountPct}% off every minute`,
43177
+ disabled: t.name === s.account.planName
43178
+ })), {
43179
+ label: "Manage my plan and invoices",
43180
+ value: "billing",
43181
+ hint: "opens the Stripe portal"
43182
+ }]
43183
+ });
43184
+ if (!choice) return;
43185
+ if (choice === "billing") {
43186
+ await s.run(["billing"], { title: "Opening the billing portal" });
43187
+ return;
43188
+ }
43189
+ const yearly = await select({
43190
+ title: "Pay monthly or yearly?",
43191
+ choices: [{
43192
+ label: "Monthly",
43193
+ value: false
43194
+ }, {
43195
+ label: "Yearly",
43196
+ value: true,
43197
+ hint: `1 month free`
43198
+ }]
43199
+ });
43200
+ if (yearly === null) return;
43201
+ await s.run([
43202
+ "subscribe",
43203
+ choice,
43204
+ ...yearly ? ["--yearly"] : []
43205
+ ], { title: "Opening Stripe" });
43206
+ }
43207
+ async function buyCreditsFlow(s) {
43208
+ if (!await ensureSignedIn(s)) return;
43209
+ const pack = await select({
43210
+ title: "Buy DeviceyAI credits",
43211
+ body: [`You have ${s.account.aiCredits.toLocaleString("en")} credits.`],
43212
+ choices: AI_CREDIT_PACKS.map((p) => ({
43213
+ label: p.label,
43214
+ value: p.id,
43215
+ hint: `${p.credits.toLocaleString("en")} credits · ${formatEur(p.priceEur)}`
43216
+ }))
43217
+ });
43218
+ if (!pack) return;
43219
+ await s.run([
43220
+ "credits",
43221
+ "buy",
43222
+ pack
43223
+ ], { title: "Opening Stripe" });
43224
+ }
43225
+ async function askAiPermission(s) {
43226
+ const answer = await select({
43227
+ title: "Let DeviceyAI use the phone?",
43228
+ body: [`DeviceyAI does what you ask on your rented phone and is paid for in AI credits (you have ${(s.account?.aiCredits ?? 0).toLocaleString("en")}).`, "Each message is a fresh task."],
43229
+ choices: [
43230
+ {
43231
+ label: "Yes — let it tap and type",
43232
+ value: "act"
43233
+ },
43234
+ {
43235
+ label: "Yes, but only let it look",
43236
+ value: "look",
43237
+ hint: "it can't touch anything"
43238
+ },
43239
+ {
43240
+ label: "No",
43241
+ value: "no"
43242
+ }
43243
+ ]
43244
+ });
43245
+ return answer === "act" || answer === "look" ? answer : null;
43246
+ }
43247
+ async function aiFlow(s, task) {
43248
+ if (!await ensureSignedIn(s)) return;
43249
+ if (s.rentals.length === 0) s.result(`DeviceyAI works on a rented phone, and you don't have one yet.`);
43250
+ const rental = await pickRental(s, "Which phone should DeviceyAI use?");
43251
+ if (!rental) return;
43252
+ if (!s.aiPermission) {
43253
+ s.aiPermission = await askAiPermission(s);
43254
+ if (!s.aiPermission) return;
43255
+ }
43256
+ for (let attempt = 0; attempt < 2; attempt++) {
43257
+ let needsActions = false;
43258
+ const permission = s.aiPermission;
43259
+ const result = await s.runTask((signal) => runChatTask(s.input(rental.id, signal), task, permission, s.t).catch((err) => {
43260
+ if (!(err instanceof AiNeedsActionsError)) throw err;
43261
+ needsActions = true;
43262
+ return null;
43263
+ }), {
43264
+ raw: true,
43265
+ spinner: "DeviceyAI is thinking"
43266
+ });
43267
+ if (needsActions) {
43268
+ if (!await confirm("DeviceyAI needs to tap and type for this", ["It only had permission to look. Let it act on the phone for the rest of this session?"], "Yes, let it act", "No")) return;
43269
+ s.aiPermission = "act";
43270
+ continue;
43271
+ }
43272
+ if (!result) return;
43273
+ if (result.stopReason === "refusal") s.result("DeviceyAI declined this request.");
43274
+ if (result.stopReason === "stopped") s.result(s.t.dim("Stopped."));
43275
+ if (result.stopReason === "step_limit") s.result("It ran out of steps for one message. Try a smaller, more specific request.");
43276
+ const left = result.creditsRemaining ?? s.account?.aiCredits ?? null;
43277
+ s.hint(`${result.creditsCharged} credit${result.creditsCharged === 1 ? "" : "s"} used${left !== null ? ` · ${left.toLocaleString("en")} left` : ""}`);
43278
+ return;
43279
+ }
43280
+ }
43281
+ function helpFlow(s) {
43282
+ const t = s.t;
43283
+ const rows = [
43284
+ ["rent", "rent a phone — you pay by the minute until you stop"],
43285
+ ["install", "put your app (.apk) on it — or just drag the file onto this window"],
43286
+ ["test my app", "a quick crash check: opens your app and taps around"],
43287
+ ["screenshot", "save a picture of the phone's screen (and open it)"],
43288
+ ["apps · logs", "what you installed · the phone's log"],
43289
+ ["stop", "give the phone back and stop paying"],
43290
+ ["balance · topup", "your wallet · add money"],
43291
+ ["plans · buy credits", "change plan · more DeviceyAI credits"],
43292
+ ["phones", "every phone and whether it's free"]
43293
+ ];
43294
+ s.say(t.bold("What you can do"));
43295
+ rows.forEach(([cmd, what], i) => {
43296
+ const line = `${t.accent(cmd.padEnd(22))}${what}`;
43297
+ if (i === 0) s.result(line);
43298
+ else s.write(` ${line}`);
43299
+ });
43300
+ s.write();
43301
+ s.hint("Anything else, in plain words — \"open Settings and turn on dark mode\" — goes to DeviceyAI.");
43302
+ s.hint("Type / for every command · permissions changes what DeviceyAI may do · exit to leave");
43303
+ }
43304
+ //#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
+ //#region src/interactive/session.ts
43726
+ /** Some next steps for refusals a person can fix themselves. */
43727
+ function nextStepFor(message) {
43728
+ const m = message.toLowerCase();
43729
+ if (m.includes("insufficient credit") || m.includes("top up")) return "Add money with: topup";
43730
+ if (m.includes("out of deviceyai credits")) return "Buy more with: buy credits";
43731
+ if (m.includes("not signed in")) return "Sign in with: login";
43732
+ if (m.includes("no active rental")) return "Rent a phone with: rent";
43733
+ if (m.includes("concurrent rental limit")) return "Stop one first with: stop";
43734
+ return null;
43735
+ }
43736
+ const LEADING_ESCAPES = /^(?:\u001b\[[0-9;?]*[ -/]*[@-~])+/;
43737
+ /**
43738
+ * Prefix every line written to stdout/stderr while `fn` runs: " ⎿ " on the
43739
+ * first, five spaces after. A carriage return (a progress bar or a spinner
43740
+ * redrawing its line) gets that line's prefix again, before its next visible
43741
+ * character. Returns the undo.
43742
+ */
43743
+ function indentOutput(t) {
43744
+ const out = process.stdout;
43745
+ const err = process.stderr;
43746
+ const outWrite = out.write;
43747
+ const errWrite = err.write;
43748
+ const first = ` ${t.dim(t.g.elbow)} `;
43749
+ const rest = " ";
43750
+ let used = false;
43751
+ /** A new line has started and has no prefix yet. */
43752
+ let newLine = true;
43753
+ /** The current line was rewound with \r and needs its prefix again. */
43754
+ let rewound = false;
43755
+ let atLineStart = true;
43756
+ let current = first;
43757
+ const decoders = /* @__PURE__ */ new Map();
43758
+ const transform = (text) => {
43759
+ let res = "";
43760
+ for (const part of text.split(/(\r\n|\n|\r)/)) {
43761
+ if (part === "") continue;
43762
+ if (part === "\n" || part === "\r\n") {
43763
+ res += part;
43764
+ newLine = true;
43765
+ rewound = false;
43766
+ atLineStart = true;
43767
+ continue;
43768
+ }
43769
+ if (part === "\r") {
43770
+ res += "\r";
43771
+ if (!newLine) rewound = true;
43772
+ continue;
43773
+ }
43774
+ const escapes = LEADING_ESCAPES.exec(part)?.[0] ?? "";
43775
+ const visible = part.slice(escapes.length);
43776
+ res += escapes;
43777
+ if (!visible) continue;
43778
+ if (newLine) {
43779
+ current = used ? rest : first;
43780
+ used = true;
43781
+ res += current;
43782
+ } else if (rewound) res += current;
43783
+ newLine = false;
43784
+ rewound = false;
43785
+ atLineStart = false;
43786
+ res += visible;
43787
+ }
43788
+ return res;
43789
+ };
43790
+ const wrap = (stream, orig) => function(chunk, ...args) {
43791
+ let text;
43792
+ if (typeof chunk === "string") text = chunk;
43793
+ else if (chunk instanceof Uint8Array) {
43794
+ let d = decoders.get(stream);
43795
+ if (!d) decoders.set(stream, d = new TextDecoder());
43796
+ text = d.decode(chunk, { stream: true });
43797
+ } else text = String(chunk);
43798
+ return orig.call(this, transform(text), ...args);
43799
+ };
43800
+ out.write = wrap(out, outWrite);
43801
+ err.write = wrap(err, errWrite);
43802
+ let undone = false;
43803
+ return () => {
43804
+ if (undone) return;
43805
+ undone = true;
43806
+ out.write = outWrite;
43807
+ err.write = errWrite;
43808
+ if (!atLineStart && !rewound) outWrite.call(out, "\n");
43809
+ };
43810
+ }
43811
+ var Session = class {
43812
+ ctx;
43813
+ t;
43814
+ platform;
43815
+ account = null;
43816
+ rentals = [];
43817
+ aiPermission = null;
43818
+ history = [];
43819
+ /** A message to show before the next prompt (never over a drawn prompt). */
43820
+ pending = [];
43821
+ stoppedHere = /* @__PURE__ */ new Set();
43822
+ constructor(ctx, t, platform) {
43823
+ this.ctx = ctx;
43824
+ this.t = t;
43825
+ this.platform = platform;
43826
+ }
43827
+ write(line = "") {
43828
+ process.stdout.write(`${line}\n`);
43829
+ }
43830
+ /** "⏺ text" — something happening, or an answer. */
43831
+ say(text) {
43832
+ this.write(`${this.t.accent(this.t.g.dot)} ${text}`);
43833
+ }
43834
+ /** " ⎿ text" — the result of what was just said. */
43835
+ result(text) {
43836
+ this.write(` ${this.t.dim(this.t.g.elbow)} ${text}`);
43837
+ }
43838
+ hint(text) {
43839
+ this.write(this.t.dim(` ${text}`));
43840
+ }
43841
+ error(text) {
43842
+ this.result(this.t.red(text));
43843
+ const next = nextStepFor(text);
43844
+ if (next) this.hint(next);
43845
+ }
43846
+ /** Who is signed in (null if nobody), and their running rentals. */
43847
+ async refresh() {
43848
+ try {
43849
+ const user = await requireUser(this.ctx);
43850
+ const [account, rentals] = await Promise.all([loadAccount(this.ctx, user), loadActiveRentals()]);
43851
+ this.account = account;
43852
+ this.noteVanished(rentals);
43853
+ this.rentals = rentals;
43854
+ } catch (err) {
43855
+ if (err instanceof CliError && err.exitCode === 2) {
43856
+ this.account = null;
43857
+ this.rentals = [];
43858
+ return;
43859
+ }
43860
+ throw err;
43861
+ }
43862
+ }
43863
+ async refreshRentals() {
43864
+ if (!this.account) return;
43865
+ const rentals = await loadActiveRentals();
43866
+ this.noteVanished(rentals);
43867
+ this.rentals = rentals;
43868
+ }
43869
+ /** A rental that ended without our stop: the idle sweep, or the website. */
43870
+ noteVanished(next) {
43871
+ for (const r of this.rentals) {
43872
+ if (next.some((n) => n.id === r.id) || this.stoppedHere.has(r.id)) continue;
43873
+ this.pending.push(`${r.deviceName} is no longer rented — it was stopped elsewhere, or after 30 idle minutes.`);
43874
+ }
43875
+ }
43876
+ /** A CommandInput for work the session runs itself (pickers, DeviceyAI). */
43877
+ input(rentalId, signal) {
43878
+ return {
43879
+ ctx: this.ctx,
43880
+ args: [],
43881
+ opts: rentalId ? { rental: rentalId } : {},
43882
+ json: false,
43883
+ signal
43884
+ };
43885
+ }
43886
+ /**
43887
+ * Run `work` with Ctrl-C wired to its signal. The first Ctrl-C aborts it
43888
+ * and is then handed back to Node, so a second one ends the process the
43889
+ * ordinary way. The work is always awaited: a rent or stop already on its
43890
+ * way to the database is never abandoned behind the prompt.
43891
+ */
43892
+ async runTask(work, opts = {}) {
43893
+ if (opts.title) this.say(this.t.bold(opts.title));
43894
+ cookedMode();
43895
+ const controller = new AbortController();
43896
+ const onSigint = () => {
43897
+ process.off("SIGINT", onSigint);
43898
+ controller.abort();
43899
+ process.stderr.write(this.t.dim("\n Stopping…\n"));
43900
+ };
43901
+ process.on("SIGINT", onSigint);
43902
+ const undoIndent = opts.raw ? () => {} : indentOutput(this.t);
43903
+ const spin = opts.spinner ? startSpinner(opts.spinner, {
43904
+ cancellable: true,
43905
+ theme: this.t
43906
+ }) : null;
43907
+ try {
43908
+ return await work(controller.signal);
43909
+ } catch (err) {
43910
+ spin?.stop();
43911
+ undoIndent();
43912
+ const message = err instanceof Error ? err.message : String(err);
43913
+ if (err instanceof CliError && err.exitCode === 130) this.result(this.t.dim(message));
43914
+ else this.error(message);
43915
+ return null;
43916
+ } finally {
43917
+ spin?.stop();
43918
+ undoIndent();
43919
+ process.off("SIGINT", onSigint);
43920
+ if (opts.raw) restoreTerminal();
43921
+ await this.refresh().catch(() => {});
43922
+ }
43923
+ }
43924
+ /**
43925
+ * Run a command line exactly as `devicerent <argv>` would. Returns its exit
43926
+ * code, or null when it failed (the error has been printed).
43927
+ */
43928
+ async run(argv, opts = {}) {
43929
+ const raw = opts.raw ?? (argv[0] === "shell" || argv[0] === "connect" || argv[0] === "logcat" && (argv.includes("-f") || argv.includes("--follow")));
43930
+ return this.runTask(async (signal) => {
43931
+ const outcome = await runCommandLine(async () => this.ctx, argv, {
43932
+ signal,
43933
+ interactive: true,
43934
+ onResult: (r) => {
43935
+ if (r.kind === "stopped") for (const id of r.rentalIds) this.stoppedHere.add(id);
43936
+ opts.onResult?.(r);
43937
+ }
43938
+ });
43939
+ if (outcome.kind === "unknown") {
43940
+ const near = nearMisses(argv[0] ?? "", {
43941
+ ...this.platform,
43942
+ fileExists: () => false,
43943
+ commandNames: COMMANDS.map((c) => c.name)
43944
+ });
43945
+ throw new CliError(`There is no command "${argv.join(" ")}".${near.length ? ` Did you mean ${near.map((n) => `/${n}`).join(" or ")}?` : " Type / to see them all."}`);
43946
+ }
43947
+ return outcome.code;
43948
+ }, {
43949
+ ...opts,
43950
+ raw
43951
+ });
43952
+ }
43953
+ /** True when the command named by argv acts on one rented phone. */
43954
+ static isDeviceCommand(argv) {
43955
+ const found = findCommand(argv);
43956
+ return Boolean(found?.command.options && "rental" in found.command.options);
43957
+ }
43958
+ };
43959
+ //#endregion
43960
+ //#region src/interactive/repl.ts
43961
+ const SESSION_SUMMARIES = {
43962
+ help: "What you can do here, in plain words",
43963
+ exit: "Leave (asks first if a phone is still rented)",
43964
+ quit: "Leave",
43965
+ clear: "Clear the screen",
43966
+ permissions: "Change what DeviceyAI may do on the phone",
43967
+ phones: "Every phone and whether it's free",
43968
+ balance: "Your wallet, AI credits and plan",
43969
+ plans: "Change your plan"
43970
+ };
43971
+ /** Everything the `/` menu offers: session actions first, then commands. */
43972
+ function slashMenu() {
43973
+ return [...Object.keys(SESSION_ACTIONS).filter((k) => k !== "quit").map((name) => ({
43974
+ name,
43975
+ summary: SESSION_SUMMARIES[name] ?? ""
43976
+ })), ...COMMANDS.map((c) => ({
43977
+ name: c.name,
43978
+ summary: c.summary
43979
+ }))];
43980
+ }
43981
+ /** The welcome card's lines (pure). */
43982
+ function welcomeLines(account, rentals, apiBase, t, width) {
43983
+ const card = [
43984
+ `${t.accent(t.g.spark)} ${t.bold("Welcome to DeviceRent")} ${t.dim(CLI_VERSION)}`,
43985
+ "",
43986
+ " Real Android phones, rented by the minute.",
43987
+ account ? ` ${account.email} ${t.dim("·")} ${account.planName} ${t.dim("·")} Wallet ${t.bold(`€${account.wallet.toFixed(2)}`)} ${t.dim("·")} AI credits ${account.aiCredits.toLocaleString("en")}` : t.dim(" You're not signed in yet.")
43988
+ ];
43989
+ if (apiBase !== "https://devicerent.net") card.push(t.dim(` ${apiBase}`));
43990
+ const lines = renderBox(card, {
43991
+ width,
43992
+ theme: t,
43993
+ border: t.accent
43994
+ });
43995
+ lines.push("");
43996
+ if (rentals.length > 0) lines.push(` ${t.accent(t.g.bullet)} You have ${rentals.map((r) => t.bold(r.deviceName)).join(", ")} rented right now.`, "");
43997
+ lines.push(t.dim(" Tips for getting started:"), "", ` 1. Type ${t.bold("rent")} to pick a phone — you pay by the minute until you stop`, ` 2. Drag your ${t.bold(".apk")} file onto this window to install it`, ` 3. Then say what to do: ${t.bold("\"open my app and sign up with a test email\"")}`, ` 4. ${t.bold("help")} explains the rest · ${t.bold("/")} lists every command`, "");
43998
+ return lines;
43999
+ }
44000
+ function placeholderFor(s) {
44001
+ if (!s.account) return "Type \"login\" to sign in";
44002
+ if (s.rentals.length === 0) return "Try \"rent a phone\"";
44003
+ return "Try \"install\", or \"open Settings and turn on dark mode\"";
44004
+ }
44005
+ /** Guided commands typed with their own name but without their arguments. */
44006
+ async function runCommandLineGuided(s, argv) {
44007
+ const found = findCommand(argv);
44008
+ const name = found?.command.name;
44009
+ const rest = found?.rest ?? [];
44010
+ const positional = rest.filter((w) => !w.startsWith("-"));
44011
+ if (name === "rent") return rentFlow(s, rest.join(" ") || void 0);
44012
+ if (name === "stop" && rest.length === 0) return stopFlow(s);
44013
+ if (name === "stop" && rest.length === 1 && rest[0] === "--all") return stopFlow(s, "all");
44014
+ if (name === "install" && positional.length === 0) return installFlow(s);
44015
+ if (name === "screenshot" && positional.length === 0) return screenshotFlow(s);
44016
+ if (name === "test quick" && positional.length === 0) return testFlow(s);
44017
+ if (name === "launch" && positional.length === 0) return launchFlow(s);
44018
+ if (name === "uninstall" && positional.length === 0) return uninstallFlow(s);
44019
+ if (name === "topup" && positional.length === 0) return topupFlow(s);
44020
+ if (name === "subscribe" && positional.length === 0) return plansFlow(s);
44021
+ if (name === "credits" && positional[0] === "buy" && positional.length === 1) return buyCreditsFlow(s);
44022
+ if (name === "login" && rest.length === 0) return loginFlow(s);
44023
+ if (name === "ai" && positional.length > 0 && rest.length === positional.length) return aiFlow(s, positional.join(" "));
44024
+ let line = argv;
44025
+ if (Session.isDeviceCommand(argv) && !argv.some((a) => a === "--rental" || a === "-r" || a.startsWith("--rental="))) {
44026
+ if (s.rentals.length !== 1) {
44027
+ const rental = await pickRental(s, "On which phone?");
44028
+ if (!rental) return;
44029
+ line = [
44030
+ ...argv,
44031
+ "--rental",
44032
+ rental.id
44033
+ ];
44034
+ }
44035
+ }
44036
+ await s.run(line);
44037
+ }
44038
+ async function runAction(s, action, arg) {
44039
+ switch (action) {
44040
+ case "help": return helpFlow(s);
44041
+ case "exit": return "exit";
44042
+ case "clear":
44043
+ process.stdout.write("\x1B[2J\x1B[3J\x1B[H");
44044
+ return;
44045
+ case "rent": return rentFlow(s, arg);
44046
+ case "stop": return stopFlow(s, arg);
44047
+ case "install": return installFlow(s, arg);
44048
+ case "screenshot": return screenshotFlow(s);
44049
+ case "test": return testFlow(s, arg);
44050
+ case "balance": return balanceFlow(s);
44051
+ case "topup": return topupFlow(s, arg);
44052
+ case "plans": return plansFlow(s, arg);
44053
+ case "buy-credits": return buyCreditsFlow(s);
44054
+ case "apps": return appsFlow(s);
44055
+ case "phones": return phonesFlow(s);
44056
+ case "status":
44057
+ await s.run(["status"], { title: "What you have rented" });
44058
+ return;
44059
+ case "login": return loginFlow(s);
44060
+ case "logout": return logoutFlow(s);
44061
+ case "launch": return launchFlow(s, arg);
44062
+ case "uninstall": return uninstallFlow(s, arg);
44063
+ case "logs": return logsFlow(s);
44064
+ case "permissions": {
44065
+ const p = await askAiPermission(s);
44066
+ s.aiPermission = p;
44067
+ s.result(p === "act" ? "DeviceyAI may tap and type on the phone." : p === "look" ? "DeviceyAI may only look at the phone." : "DeviceyAI will ask again next time.");
44068
+ return;
44069
+ }
44070
+ }
44071
+ }
44072
+ async function dispatch(s, intent) {
44073
+ switch (intent.kind) {
44074
+ case "empty": return;
44075
+ case "install-file": return installFlow(s, intent.path);
44076
+ case "action": return runAction(s, intent.action, intent.arg);
44077
+ case "command": return runCommandLineGuided(s, intent.argv);
44078
+ case "did-you-mean": {
44079
+ const pick = await select({
44080
+ title: "Did you mean…",
44081
+ choices: [...intent.options.map((o) => ({
44082
+ label: o,
44083
+ value: o
44084
+ })), {
44085
+ label: "None of these",
44086
+ value: ""
44087
+ }]
44088
+ });
44089
+ if (!pick) return;
44090
+ return dispatch(s, interpret(pick, envFor(s)));
44091
+ }
44092
+ case "ai": return aiFlow(s, intent.task);
44093
+ case "unknown":
44094
+ s.result("I didn't catch that. Type help to see what you can do, or / for every command.");
44095
+ return;
44096
+ }
44097
+ }
44098
+ function envFor(s) {
44099
+ return {
44100
+ ...s.platform,
44101
+ commandNames: COMMANDS.map((c) => c.name),
44102
+ fileExists: (p) => {
44103
+ try {
44104
+ return fs.statSync(p).isFile();
44105
+ } catch {
44106
+ return false;
44107
+ }
44108
+ }
44109
+ };
44110
+ }
44111
+ /** Leaving with a phone still rented: ask, never assume. */
44112
+ async function confirmExit(s) {
44113
+ await s.refreshRentals().catch(() => {});
44114
+ if (s.rentals.length === 0) return true;
44115
+ const names = s.rentals.map((r) => r.deviceName).join(", ");
44116
+ const choice = await select({
44117
+ title: `${names} ${s.rentals.length === 1 ? "is" : "are"} still rented`,
44118
+ body: ["Billing continues until you stop it, or until it has sat idle for 30 minutes."],
44119
+ choices: [
44120
+ {
44121
+ label: "Stop it and leave",
44122
+ value: "stop"
44123
+ },
44124
+ {
44125
+ label: "Leave it running",
44126
+ value: "leave"
44127
+ },
44128
+ {
44129
+ label: "Stay",
44130
+ value: "stay"
44131
+ }
44132
+ ]
44133
+ });
44134
+ if (choice === "stop") {
44135
+ await s.run(["stop", "--all"], {
44136
+ title: `Stopping ${names}`,
44137
+ spinner: "Stopping and settling the bill"
44138
+ });
44139
+ return true;
44140
+ }
44141
+ if (choice === "leave") {
44142
+ s.hint("It keeps running. Stop it later with: npx devicerent stop");
44143
+ return true;
44144
+ }
44145
+ return false;
44146
+ }
44147
+ async function startInteractive(apiBase, helpText) {
44148
+ const t = theme();
44149
+ const platform = {
44150
+ platform: process.platform,
44151
+ home: os.homedir()
44152
+ };
44153
+ const spin = startSpinner("Connecting to DeviceRent", { theme: t });
44154
+ let ctx;
44155
+ try {
44156
+ ctx = await startContext(apiBase);
44157
+ } catch (err) {
44158
+ spin.stop();
44159
+ process.stderr.write(`${t.red(err.message)}\n\n${helpText()}\n`);
44160
+ return 1;
44161
+ }
44162
+ const s = new Session(ctx, t, platform);
44163
+ try {
44164
+ await s.refresh();
44165
+ } catch (err) {
44166
+ spin.stop();
44167
+ process.stderr.write(`${t.red(err.message)}\n`);
44168
+ return 1;
44169
+ }
44170
+ spin.stop();
44171
+ for (const line of welcomeLines(s.account, s.rentals, apiBase, t, frameWidth(process.stdout.columns))) s.write(line);
44172
+ if (!s.account) await loginFlow(s);
44173
+ const poll = setInterval(() => void s.refreshRentals().catch(() => {}), 6e4);
44174
+ poll.unref();
44175
+ const commands = slashMenu();
44176
+ try {
44177
+ for (;;) {
44178
+ for (const p of s.pending.splice(0)) s.hint(p);
44179
+ const r = await readPrompt({
44180
+ history: s.history,
44181
+ commands,
44182
+ placeholder: placeholderFor(s),
44183
+ hint: "/ for commands · ? for help",
44184
+ status: () => statusLine(s.rentals, s.account, Date.now(), t),
44185
+ theme: t
44186
+ });
44187
+ if (r.kind === "exit") {
44188
+ if (await confirmExit(s)) break;
44189
+ continue;
44190
+ }
44191
+ if (r.value.trim()) s.history.push(r.value);
44192
+ try {
44193
+ if (await dispatch(s, interpret(r.value, envFor(s))) === "exit" && await confirmExit(s)) break;
44194
+ } catch (err) {
44195
+ s.error(err instanceof CliError || err instanceof Error ? err.message : String(err));
44196
+ }
44197
+ s.write();
44198
+ }
44199
+ } finally {
44200
+ clearInterval(poll);
44201
+ restoreTerminal();
44202
+ }
44203
+ s.write(t.dim(" Bye."));
44204
+ return 0;
44205
+ }
44206
+ //#endregion
44207
+ //#region src/main.ts
41156
44208
  /**
41157
44209
  * Global flags may come before the command, as with git and gh:
41158
44210
  * `devicerent --api-base http://localhost:3000 keys list`. They are lifted out
@@ -41195,6 +44247,21 @@ function helpText() {
41195
44247
  }
41196
44248
  async function main(rawArgv) {
41197
44249
  const { globals, rest: argv } = splitLeadingGlobals(rawArgv);
44250
+ if (shouldStartInteractive({
44251
+ argv,
44252
+ globals,
44253
+ stdinTTY: process.stdin.isTTY === true,
44254
+ stdoutTTY: process.stdout.isTTY === true,
44255
+ stderrTTY: process.stderr.isTTY === true,
44256
+ env: process.env
44257
+ })) {
44258
+ const { values } = parseArgs({
44259
+ args: globals,
44260
+ options: GLOBAL_OPTIONS,
44261
+ strict: true
44262
+ });
44263
+ return startInteractive(resolveApiBase(values), helpText);
44264
+ }
41198
44265
  if (argv.length === 0 || argv[0] === "help" || argv[0] === "--help" || argv[0] === "-h") {
41199
44266
  printLine(helpText());
41200
44267
  return 0;
@@ -41203,40 +44270,12 @@ async function main(rawArgv) {
41203
44270
  printLine(CLI_VERSION);
41204
44271
  return 0;
41205
44272
  }
41206
- const found = findCommand(argv);
41207
- if (!found) {
44273
+ const outcome = await runCommandLine(startContext, argv, { globals });
44274
+ if (outcome.kind === "unknown") {
41208
44275
  process.stderr.write(`${red("Unknown command:")} ${argv.join(" ")}\n\n${helpText()}\n`);
41209
44276
  return 2;
41210
44277
  }
41211
- const { command, rest } = found;
41212
- let values;
41213
- let positionals;
41214
- try {
41215
- ({values, positionals} = parseArgs({
41216
- args: [...globals, ...rest],
41217
- options: {
41218
- ...GLOBAL_OPTIONS,
41219
- ...command.options
41220
- },
41221
- allowPositionals: true,
41222
- strict: true
41223
- }));
41224
- } catch (err) {
41225
- process.stderr.write(`${red(err.message)}\nUsage: ${command.usage}\n`);
41226
- return 2;
41227
- }
41228
- if (values.help === true) {
41229
- printLine(`${command.summary}\n\nUsage: ${command.usage}`);
41230
- return 0;
41231
- }
41232
- const ctx = await startContext(normalizeApiBase((typeof values["api-base"] === "string" ? values["api-base"] : void 0) ?? process.env.DEVICERENT_API_BASE ?? "https://devicerent.net"));
41233
- const code = await command.run({
41234
- ctx,
41235
- args: positionals,
41236
- opts: values,
41237
- json: values.json === true
41238
- });
41239
- return typeof code === "number" ? code : 0;
44278
+ return outcome.code;
41240
44279
  }
41241
44280
  /** Grace for stray handles before a finished command is ended by force. */
41242
44281
  const EXIT_GRACE_MS = 3e3;
@@ -41261,4 +44300,4 @@ if (process.env.VITEST === void 0) main(process.argv.slice(2)).then(finish, (err
41261
44300
  finish(err instanceof CliError ? err.exitCode : 1);
41262
44301
  });
41263
44302
  //#endregion
41264
- export { findCommand, main, splitLeadingGlobals };
44303
+ export { findCommand, helpText, main, splitLeadingGlobals };