devicerent 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -2
- package/bin/devicerent.mjs +16 -1
- package/bin/nodeCheck.mjs +36 -0
- package/dist/devicerent.mjs +3440 -216
- package/package.json +3 -3
package/dist/devicerent.mjs
CHANGED
|
@@ -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
|
-
import
|
|
11
|
+
import { pipeline } from "node:stream/promises";
|
|
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
|
|
26975
|
+
const CLI_VERSION = "0.2.1";
|
|
26965
26976
|
//#endregion
|
|
26966
26977
|
//#region src/auth/loopback.ts
|
|
26967
26978
|
function page(message) {
|
|
@@ -27007,9 +27018,31 @@ function listenOnLoopback(state) {
|
|
|
27007
27018
|
}
|
|
27008
27019
|
//#endregion
|
|
27009
27020
|
//#region src/auth/terminal.ts
|
|
27010
|
-
/**
|
|
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
|
+
/**
|
|
27033
|
+
* What the CLI ever hands to the system's "open" handler: a web page (the
|
|
27034
|
+
* approval page, a Stripe hand-off), or a screenshot it just saved. A URL the
|
|
27035
|
+
* server sent is otherwise opened as-is, and `file:`, `ms-*:` or a program's
|
|
27036
|
+
* path would launch whatever it names.
|
|
27037
|
+
*/
|
|
27038
|
+
function isOpenable(target) {
|
|
27039
|
+
if (/^https?:\/\/[^\s]+$/i.test(target)) return true;
|
|
27040
|
+
return path.isAbsolute(target) && /\.png$/i.test(target);
|
|
27041
|
+
}
|
|
27042
|
+
/** Open a web page, or a saved screenshot, in its default app. False when there is no way to. */
|
|
27011
27043
|
function openBrowser(url) {
|
|
27012
|
-
|
|
27044
|
+
if (!isOpenable(url)) return false;
|
|
27045
|
+
const [cmd, args] = process.platform === "win32" ? [systemExe("rundll32.exe"), ["url.dll,FileProtocolHandler", url]] : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
|
|
27013
27046
|
try {
|
|
27014
27047
|
const child = spawn(cmd, args, {
|
|
27015
27048
|
stdio: "ignore",
|
|
@@ -27022,15 +27055,31 @@ function openBrowser(url) {
|
|
|
27022
27055
|
return false;
|
|
27023
27056
|
}
|
|
27024
27057
|
}
|
|
27025
|
-
/**
|
|
27058
|
+
/** Ctrl-C at a prompt: a deliberate stop, exit code 130 as for SIGINT. */
|
|
27059
|
+
const CANCELLED = () => new CliError("Cancelled.", 130);
|
|
27060
|
+
/**
|
|
27061
|
+
* One line from the person at the terminal. Refuses when there is none.
|
|
27062
|
+
*
|
|
27063
|
+
* readline puts the terminal in raw mode while it asks, so Ctrl-C arrives as a
|
|
27064
|
+
* key, never as SIGINT: a process-level SIGINT handler never sees it, and
|
|
27065
|
+
* Node 24 just rejects the question with a bare AbortError. Ctrl-C (and an
|
|
27066
|
+
* aborted `signal`) therefore end here, as a plain "Cancelled.".
|
|
27067
|
+
*/
|
|
27026
27068
|
async function promptLine(question, signal) {
|
|
27027
27069
|
if (!process.stdin.isTTY) throw new CliError("This step needs an interactive terminal.");
|
|
27028
27070
|
const rl = readline.createInterface({
|
|
27029
27071
|
input: process.stdin,
|
|
27030
27072
|
output: process.stderr
|
|
27031
27073
|
});
|
|
27074
|
+
const interrupted = new Promise((_, reject) => {
|
|
27075
|
+
rl.once("SIGINT", () => reject(CANCELLED()));
|
|
27076
|
+
});
|
|
27032
27077
|
try {
|
|
27033
|
-
return (await rl.question(question, signal ? { signal } : {})).trim();
|
|
27078
|
+
return (await Promise.race([rl.question(question, signal ? { signal } : {}), interrupted])).trim();
|
|
27079
|
+
} catch (err) {
|
|
27080
|
+
if (err instanceof CliError) throw err;
|
|
27081
|
+
if (err.name === "AbortError" || signal?.aborted) throw CANCELLED();
|
|
27082
|
+
throw err;
|
|
27034
27083
|
} finally {
|
|
27035
27084
|
rl.close();
|
|
27036
27085
|
}
|
|
@@ -27062,7 +27111,7 @@ async function login(ctx, opts) {
|
|
|
27062
27111
|
const url = `${ctx.config.app_url}/account/cli/${segment}`;
|
|
27063
27112
|
note("Approve this sign-in in your browser:");
|
|
27064
27113
|
note(` ${url}`);
|
|
27065
|
-
sealed = opts.noBrowser ? await promptLine("\nPaste the code the page shows after you approve: ") : await waitForBrowser(url, listener.payload);
|
|
27114
|
+
sealed = opts.noBrowser ? await promptLine("\nPaste the code the page shows after you approve: ", opts.signal) : await waitForBrowser(url, listener.payload, opts.signal);
|
|
27066
27115
|
} finally {
|
|
27067
27116
|
listener.close();
|
|
27068
27117
|
}
|
|
@@ -27084,9 +27133,9 @@ async function login(ctx, opts) {
|
|
|
27084
27133
|
const abandon = () => {
|
|
27085
27134
|
staging.client.auth.signOut({ scope: "local" }).finally(() => process.exit(130));
|
|
27086
27135
|
};
|
|
27087
|
-
process.once("SIGINT", abandon);
|
|
27136
|
+
if (!opts.signal) process.once("SIGINT", abandon);
|
|
27088
27137
|
try {
|
|
27089
|
-
await stepUpIfEnrolled(staging.client);
|
|
27138
|
+
await stepUpIfEnrolled(staging.client, opts.signal);
|
|
27090
27139
|
} finally {
|
|
27091
27140
|
process.off("SIGINT", abandon);
|
|
27092
27141
|
}
|
|
@@ -27094,17 +27143,22 @@ async function login(ctx, opts) {
|
|
|
27094
27143
|
staging.commit(ctx.store);
|
|
27095
27144
|
return data.user;
|
|
27096
27145
|
}
|
|
27097
|
-
async function waitForBrowser(url, payload) {
|
|
27146
|
+
async function waitForBrowser(url, payload, signal) {
|
|
27098
27147
|
if (!openBrowser(url)) warn("Could not open a browser; open the link above yourself.");
|
|
27099
27148
|
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
27149
|
let timer;
|
|
27150
|
+
let onAbort;
|
|
27101
27151
|
const timeout = new Promise((_, reject) => {
|
|
27102
27152
|
timer = setTimeout(() => reject(new CliError("Timed out waiting for the browser.")), APPROVAL_TIMEOUT_MS);
|
|
27153
|
+
onAbort = () => reject(CANCELLED());
|
|
27154
|
+
if (signal?.aborted) onAbort();
|
|
27155
|
+
else signal?.addEventListener("abort", onAbort, { once: true });
|
|
27103
27156
|
});
|
|
27104
27157
|
try {
|
|
27105
27158
|
return await Promise.race([payload, timeout]);
|
|
27106
27159
|
} finally {
|
|
27107
27160
|
clearTimeout(timer);
|
|
27161
|
+
if (onAbort) signal?.removeEventListener("abort", onAbort);
|
|
27108
27162
|
}
|
|
27109
27163
|
}
|
|
27110
27164
|
/**
|
|
@@ -27114,7 +27168,7 @@ async function waitForBrowser(url, payload) {
|
|
|
27114
27168
|
* (getUser), never from a cached copy. Every failure signs the staged
|
|
27115
27169
|
* session out, so it ends server-side too.
|
|
27116
27170
|
*/
|
|
27117
|
-
async function stepUpIfEnrolled(client) {
|
|
27171
|
+
async function stepUpIfEnrolled(client, signal) {
|
|
27118
27172
|
const refuse = async (message) => {
|
|
27119
27173
|
await client.auth.signOut({ scope: "local" });
|
|
27120
27174
|
throw new CliError(message);
|
|
@@ -27128,7 +27182,7 @@ async function stepUpIfEnrolled(client) {
|
|
|
27128
27182
|
const mfa = client.auth.mfa;
|
|
27129
27183
|
try {
|
|
27130
27184
|
for (let attempt = 1; attempt <= MFA_ATTEMPTS; attempt++) {
|
|
27131
|
-
const code = await promptLine("Authenticator code: ");
|
|
27185
|
+
const code = await promptLine("Authenticator code: ", signal);
|
|
27132
27186
|
const { error } = await mfa.challengeAndVerify({
|
|
27133
27187
|
factorId: totp.id,
|
|
27134
27188
|
code
|
|
@@ -27354,8 +27408,11 @@ const loginCommand = {
|
|
|
27354
27408
|
summary: "Sign this terminal in through your browser",
|
|
27355
27409
|
usage: "devicerent login [--no-browser]",
|
|
27356
27410
|
options: { "no-browser": { type: "boolean" } },
|
|
27357
|
-
async run({ ctx, opts, json }) {
|
|
27358
|
-
const user = await login(ctx, {
|
|
27411
|
+
async run({ ctx, opts, json, signal }) {
|
|
27412
|
+
const user = await login(ctx, {
|
|
27413
|
+
noBrowser: opts["no-browser"] === true,
|
|
27414
|
+
signal
|
|
27415
|
+
});
|
|
27359
27416
|
if (json) return printJson({
|
|
27360
27417
|
email: user.email,
|
|
27361
27418
|
id: user.id,
|
|
@@ -27414,7 +27471,7 @@ const walletCommand = {
|
|
|
27414
27471
|
printLine(eur(p.wallet));
|
|
27415
27472
|
return;
|
|
27416
27473
|
}
|
|
27417
|
-
const page =
|
|
27474
|
+
const page = int$1(opts, "page", 1) - 1;
|
|
27418
27475
|
const { rows, hasMore } = await fetchWalletTransactions(page);
|
|
27419
27476
|
if (json) return printJson({
|
|
27420
27477
|
balance: p.wallet,
|
|
@@ -27445,7 +27502,7 @@ const historyCommand = {
|
|
|
27445
27502
|
options: { limit: { type: "string" } },
|
|
27446
27503
|
async run({ ctx, opts, json }) {
|
|
27447
27504
|
const user = await requireUser(ctx);
|
|
27448
|
-
const limit = Math.min(200,
|
|
27505
|
+
const limit = Math.min(200, int$1(opts, "limit", 20));
|
|
27449
27506
|
const { data, error } = await ctx.supabase.from("rentals").select("id,device_id,status,start_time,end_time,total_cost,devices!inner(name)").eq("user_id", user.id).eq("status", "completed").order("end_time", {
|
|
27450
27507
|
ascending: false,
|
|
27451
27508
|
nullsFirst: false
|
|
@@ -29130,7 +29187,7 @@ function write4HexDigits(buffer, index, value) {
|
|
|
29130
29187
|
}
|
|
29131
29188
|
//#endregion
|
|
29132
29189
|
//#region ../../../OneDrive/Masaüstü/DeviceRent Software part 5/node_modules/@yume-chan/adb/esm/utils/no-op.js
|
|
29133
|
-
const NOOP = /* @__NO_SIDE_EFFECTS__ */ () => {};
|
|
29190
|
+
const NOOP$1 = /* @__NO_SIDE_EFFECTS__ */ () => {};
|
|
29134
29191
|
/**
|
|
29135
29192
|
* When used in `Promise#catch`, means the promise should never throw errors.
|
|
29136
29193
|
* An explicit way to suppress ESLint floating promise warnings.
|
|
@@ -29968,7 +30025,7 @@ async function pipeFileData(locked, file, packetSize, mtime) {
|
|
|
29968
30025
|
} }), { signal: abortController.signal }).then(async () => {
|
|
29969
30026
|
await adbSyncWriteRequest(locked, AdbSyncRequestId.Done, mtime);
|
|
29970
30027
|
await locked.flush();
|
|
29971
|
-
}, NOOP);
|
|
30028
|
+
}, NOOP$1);
|
|
29972
30029
|
await adbSyncReadResponse(locked, AdbSyncResponseId.Ok, AdbSyncOkResponse).catch((e) => {
|
|
29973
30030
|
abortController.abort();
|
|
29974
30031
|
throw e;
|
|
@@ -32022,6 +32079,70 @@ function obj(props, required) {
|
|
|
32022
32079
|
};
|
|
32023
32080
|
}
|
|
32024
32081
|
const NO_ARGS = obj({}, []);
|
|
32082
|
+
/** Tools that only read. Available as soon as a session exists. */
|
|
32083
|
+
const READ_TOOLS = [
|
|
32084
|
+
{
|
|
32085
|
+
name: "observe_screen",
|
|
32086
|
+
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.",
|
|
32087
|
+
parameters: NO_ARGS
|
|
32088
|
+
},
|
|
32089
|
+
{
|
|
32090
|
+
name: "screenshot",
|
|
32091
|
+
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.",
|
|
32092
|
+
parameters: NO_ARGS
|
|
32093
|
+
},
|
|
32094
|
+
{
|
|
32095
|
+
name: "list_packages",
|
|
32096
|
+
description: "List the third-party app packages installed on the phone. Use this to find the package name of an app before launching it.",
|
|
32097
|
+
parameters: NO_ARGS
|
|
32098
|
+
},
|
|
32099
|
+
{
|
|
32100
|
+
name: "device_info",
|
|
32101
|
+
description: "Report the device model, Android version, screen resolution and current screen orientation.",
|
|
32102
|
+
parameters: NO_ARGS
|
|
32103
|
+
},
|
|
32104
|
+
{
|
|
32105
|
+
name: "wait",
|
|
32106
|
+
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.",
|
|
32107
|
+
parameters: obj({ ms: {
|
|
32108
|
+
type: "integer",
|
|
32109
|
+
description: "Milliseconds to wait. Clamped to 100-5000."
|
|
32110
|
+
} }, ["ms"])
|
|
32111
|
+
},
|
|
32112
|
+
{
|
|
32113
|
+
name: "list_instrumentations",
|
|
32114
|
+
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.",
|
|
32115
|
+
parameters: NO_ARGS
|
|
32116
|
+
},
|
|
32117
|
+
{
|
|
32118
|
+
name: "logcat_tail",
|
|
32119
|
+
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.",
|
|
32120
|
+
parameters: obj({
|
|
32121
|
+
lines: {
|
|
32122
|
+
type: "integer",
|
|
32123
|
+
description: "How many recent lines to return. Clamped to 1-1000."
|
|
32124
|
+
},
|
|
32125
|
+
filter: {
|
|
32126
|
+
type: "string",
|
|
32127
|
+
description: "Case-insensitive substring to keep only matching lines - a tag, a package, or an exception class. Empty string returns everything."
|
|
32128
|
+
}
|
|
32129
|
+
}, ["lines", "filter"])
|
|
32130
|
+
},
|
|
32131
|
+
{
|
|
32132
|
+
name: "read_device_file",
|
|
32133
|
+
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.",
|
|
32134
|
+
parameters: obj({
|
|
32135
|
+
path: {
|
|
32136
|
+
type: "string",
|
|
32137
|
+
description: "Absolute path, under /data/local/tmp or /sdcard."
|
|
32138
|
+
},
|
|
32139
|
+
max_bytes: {
|
|
32140
|
+
type: "integer",
|
|
32141
|
+
description: "Maximum bytes to return. Clamped to 1000-100000."
|
|
32142
|
+
}
|
|
32143
|
+
}, ["path", "max_bytes"])
|
|
32144
|
+
}
|
|
32145
|
+
];
|
|
32025
32146
|
/** Tools that touch the device. Gated on the session's allow_actions toggle. */
|
|
32026
32147
|
const ACTION_TOOLS = [
|
|
32027
32148
|
{
|
|
@@ -32805,7 +32926,7 @@ const browserImageCodec = { async pngToScaledJpeg(png) {
|
|
|
32805
32926
|
//#region ../../src/lib/ai/clientToolArgs.ts
|
|
32806
32927
|
/** Cap on any single tool result. One runaway dump must not blow up a turn. */
|
|
32807
32928
|
const MAX_RESULT_CHARS = 2e4;
|
|
32808
|
-
function truncate(s) {
|
|
32929
|
+
function truncate$1(s) {
|
|
32809
32930
|
if (s.length <= MAX_RESULT_CHARS) return s;
|
|
32810
32931
|
return s.slice(0, MAX_RESULT_CHARS) + `\n… truncated (${s.length - MAX_RESULT_CHARS} more characters)`;
|
|
32811
32932
|
}
|
|
@@ -33656,65 +33777,77 @@ var WebSocketServerConnector = class {
|
|
|
33656
33777
|
});
|
|
33657
33778
|
let readableController = null;
|
|
33658
33779
|
let readableClosed = false;
|
|
33659
|
-
|
|
33660
|
-
|
|
33661
|
-
|
|
33662
|
-
|
|
33663
|
-
|
|
33664
|
-
|
|
33665
|
-
|
|
33666
|
-
|
|
33667
|
-
|
|
33668
|
-
|
|
33669
|
-
|
|
33670
|
-
readableClosed = true;
|
|
33671
|
-
try {
|
|
33672
|
-
controller.close();
|
|
33673
|
-
} catch {}
|
|
33674
|
-
resolveClosed(void 0);
|
|
33675
|
-
});
|
|
33676
|
-
ws.addEventListener("error", () => {
|
|
33677
|
-
if (readableClosed) return;
|
|
33678
|
-
readableClosed = true;
|
|
33679
|
-
try {
|
|
33680
|
-
controller.error(/* @__PURE__ */ new Error("WebSocket error"));
|
|
33681
|
-
} catch {}
|
|
33682
|
-
resolveClosed(void 0);
|
|
33683
|
-
});
|
|
33684
|
-
},
|
|
33685
|
-
cancel() {
|
|
33686
|
-
try {
|
|
33687
|
-
ws.close();
|
|
33688
|
-
} catch {}
|
|
33689
|
-
}
|
|
33690
|
-
}),
|
|
33691
|
-
writable: new MaybeConsumableWritableStream({
|
|
33692
|
-
write(chunk) {
|
|
33693
|
-
if (ws.readyState !== WebSocket.OPEN) throw new Error("bridge socket not open");
|
|
33694
|
-
ws.send(chunk.slice());
|
|
33695
|
-
},
|
|
33696
|
-
close() {
|
|
33697
|
-
try {
|
|
33698
|
-
ws.close(1e3, "writable closed");
|
|
33699
|
-
} catch {}
|
|
33700
|
-
},
|
|
33701
|
-
abort() {
|
|
33780
|
+
const readable = new ReadableStream$1({
|
|
33781
|
+
start(controller) {
|
|
33782
|
+
readableController = controller;
|
|
33783
|
+
ws.addEventListener("message", (ev) => {
|
|
33784
|
+
if (readableClosed) return;
|
|
33785
|
+
if (ev.data instanceof ArrayBuffer) controller.enqueue(new Uint8Array(ev.data));
|
|
33786
|
+
else if (ev.data instanceof Uint8Array) controller.enqueue(ev.data);
|
|
33787
|
+
});
|
|
33788
|
+
ws.addEventListener("close", () => {
|
|
33789
|
+
if (readableClosed) return;
|
|
33790
|
+
readableClosed = true;
|
|
33702
33791
|
try {
|
|
33703
|
-
|
|
33792
|
+
controller.close();
|
|
33704
33793
|
} catch {}
|
|
33705
|
-
|
|
33706
|
-
|
|
33707
|
-
|
|
33708
|
-
|
|
33794
|
+
resolveClosed(void 0);
|
|
33795
|
+
});
|
|
33796
|
+
ws.addEventListener("error", () => {
|
|
33797
|
+
if (readableClosed) return;
|
|
33709
33798
|
readableClosed = true;
|
|
33710
33799
|
try {
|
|
33711
|
-
|
|
33800
|
+
controller.error(/* @__PURE__ */ new Error("WebSocket error"));
|
|
33712
33801
|
} catch {}
|
|
33713
|
-
|
|
33802
|
+
resolveClosed(void 0);
|
|
33803
|
+
});
|
|
33804
|
+
},
|
|
33805
|
+
cancel() {
|
|
33714
33806
|
try {
|
|
33715
|
-
ws.close(
|
|
33807
|
+
ws.close();
|
|
33716
33808
|
} catch {}
|
|
33809
|
+
}
|
|
33810
|
+
});
|
|
33811
|
+
const writable = new MaybeConsumableWritableStream({
|
|
33812
|
+
write(chunk) {
|
|
33813
|
+
if (ws.readyState !== WebSocket.OPEN) throw new Error("bridge socket not open");
|
|
33814
|
+
ws.send(chunk.slice());
|
|
33717
33815
|
},
|
|
33816
|
+
close() {
|
|
33817
|
+
try {
|
|
33818
|
+
ws.close(1e3, "writable closed");
|
|
33819
|
+
} catch {}
|
|
33820
|
+
},
|
|
33821
|
+
abort() {
|
|
33822
|
+
try {
|
|
33823
|
+
ws.close(1011, "writable aborted");
|
|
33824
|
+
} catch {}
|
|
33825
|
+
}
|
|
33826
|
+
});
|
|
33827
|
+
const close = () => {
|
|
33828
|
+
if (!readableClosed && readableController) {
|
|
33829
|
+
readableClosed = true;
|
|
33830
|
+
try {
|
|
33831
|
+
readableController.close();
|
|
33832
|
+
} catch {}
|
|
33833
|
+
}
|
|
33834
|
+
try {
|
|
33835
|
+
ws.close(1e3, "client closed");
|
|
33836
|
+
} catch {}
|
|
33837
|
+
};
|
|
33838
|
+
const signal = options?.signal;
|
|
33839
|
+
if (signal) {
|
|
33840
|
+
const onAbort = () => close();
|
|
33841
|
+
if (signal.aborted) onAbort();
|
|
33842
|
+
else {
|
|
33843
|
+
signal.addEventListener("abort", onAbort);
|
|
33844
|
+
ws.addEventListener("close", () => signal.removeEventListener("abort", onAbort));
|
|
33845
|
+
}
|
|
33846
|
+
}
|
|
33847
|
+
return {
|
|
33848
|
+
readable,
|
|
33849
|
+
writable,
|
|
33850
|
+
close,
|
|
33718
33851
|
get closed() {
|
|
33719
33852
|
return closed;
|
|
33720
33853
|
}
|
|
@@ -34100,7 +34233,7 @@ async function executeToolCall(ctx, call) {
|
|
|
34100
34233
|
});
|
|
34101
34234
|
const ok = (msg) => ({
|
|
34102
34235
|
toolCallId: call.id,
|
|
34103
|
-
content: truncate(msg),
|
|
34236
|
+
content: truncate$1(msg),
|
|
34104
34237
|
isError: false
|
|
34105
34238
|
});
|
|
34106
34239
|
const { adb, control, mirrorSize, status } = ctx.getHandles();
|
|
@@ -34466,6 +34599,7 @@ async function executeToolCall(ctx, call) {
|
|
|
34466
34599
|
* which is the one conversion here and the one place they could have drifted.
|
|
34467
34600
|
*/
|
|
34468
34601
|
const nodeImageCodec = { async pngToScaledJpeg(png) {
|
|
34602
|
+
const { default: sharp } = await import("sharp");
|
|
34469
34603
|
const image = sharp(Buffer.from(png));
|
|
34470
34604
|
const meta = await image.metadata();
|
|
34471
34605
|
if (!meta.width || !meta.height) throw new Error("screencap bytes were not a decodable image");
|
|
@@ -34807,25 +34941,49 @@ function parseWmSize(out) {
|
|
|
34807
34941
|
}
|
|
34808
34942
|
//#endregion
|
|
34809
34943
|
//#region src/ai/loop.ts
|
|
34944
|
+
/**
|
|
34945
|
+
* DeviceyAI was given look-only access and the task needs to tap or type. Its
|
|
34946
|
+
* message is the one-shot instruction; the interactive session catches the
|
|
34947
|
+
* type and asks for the permission instead.
|
|
34948
|
+
*/
|
|
34949
|
+
var AiNeedsActionsError = class extends CliError {};
|
|
34810
34950
|
/** What a refused turn means, as an instruction a person can act on. */
|
|
34811
34951
|
function describeTurnFailure(status, failure) {
|
|
34812
34952
|
if (status === 409 && failure.needs_actions) return "This task needs to tap and type on the phone. Run it again with --allow-actions.";
|
|
34813
34953
|
if (status === 402) return "You are out of DeviceyAI credits. Buy a pack: devicerent credits buy s";
|
|
34814
34954
|
return failure.error ?? `DeviceyAI refused the turn (${status}).`;
|
|
34815
34955
|
}
|
|
34956
|
+
/**
|
|
34957
|
+
* A turn that did not come back. Null when the person stopped it (Ctrl-C);
|
|
34958
|
+
* anything else — a dropped connection, a reply cut off mid-stream — says
|
|
34959
|
+
* so, instead of reading as if Ctrl-C had been pressed.
|
|
34960
|
+
*/
|
|
34961
|
+
function lostTurn(err, signal) {
|
|
34962
|
+
if (signal.aborted) return null;
|
|
34963
|
+
if (err instanceof CliError) throw err;
|
|
34964
|
+
throw new CliError(`Lost the connection to DeviceyAI (${String(err?.message ?? err).split("\n")[0].slice(0, 160)}). Your phone is still rented; try again.`);
|
|
34965
|
+
}
|
|
34816
34966
|
async function readTurn(payload, signal, hooks) {
|
|
34817
34967
|
let reply;
|
|
34818
34968
|
try {
|
|
34819
34969
|
reply = await postAiTurn(payload, signal);
|
|
34820
|
-
} catch {
|
|
34821
|
-
return
|
|
34970
|
+
} catch (err) {
|
|
34971
|
+
return lostTurn(err, signal);
|
|
34972
|
+
}
|
|
34973
|
+
if (reply.kind === "failed") {
|
|
34974
|
+
const message = describeTurnFailure(reply.status, reply.failure);
|
|
34975
|
+
throw reply.status === 409 && reply.failure.needs_actions ? new AiNeedsActionsError(message) : new CliError(message);
|
|
34822
34976
|
}
|
|
34823
|
-
if (reply.kind === "failed") throw new CliError(describeTurnFailure(reply.status, reply.failure));
|
|
34824
34977
|
if (reply.kind === "json") return reply.turn;
|
|
34825
34978
|
let done = null;
|
|
34826
|
-
|
|
34827
|
-
|
|
34828
|
-
|
|
34979
|
+
try {
|
|
34980
|
+
for await (const ev of reply.events) if (ev.name === "text") hooks.onText(ev.delta);
|
|
34981
|
+
else if (ev.name === "done") done = ev.turn;
|
|
34982
|
+
else if (ev.name === "error") throw new CliError(ev.error);
|
|
34983
|
+
} catch (err) {
|
|
34984
|
+
return lostTurn(err, signal);
|
|
34985
|
+
}
|
|
34986
|
+
if (!done) return lostTurn(/* @__PURE__ */ new Error("the reply ended early"), signal);
|
|
34829
34987
|
return done;
|
|
34830
34988
|
}
|
|
34831
34989
|
async function runAiTask(device, opts, signal, hooks) {
|
|
@@ -35014,8 +35172,10 @@ async function loadFleetDevices(ctx) {
|
|
|
35014
35172
|
*/
|
|
35015
35173
|
function pickDevice(devices, query, taken = /* @__PURE__ */ new Set()) {
|
|
35016
35174
|
const q = query.trim().toLowerCase();
|
|
35175
|
+
if (!q) throw new CliError("Name a device (id, serial or model). See: devicerent devices");
|
|
35017
35176
|
const exact = devices.find((d) => d.id.toLowerCase() === q || q.length >= 8 && d.id.toLowerCase().startsWith(q) || d.serial.toLowerCase() === q || d.serial.toLowerCase().endsWith(`:${q}`));
|
|
35018
35177
|
if (exact) {
|
|
35178
|
+
if (taken.has(exact.id)) throw new CliError(`${exact.name} is named twice in one request.`);
|
|
35019
35179
|
if (!exact.rentable) throw new CliError(`${exact.name} is ${exact.status} right now.`);
|
|
35020
35180
|
return exact;
|
|
35021
35181
|
}
|
|
@@ -35028,20 +35188,29 @@ function pickDevice(devices, query, taken = /* @__PURE__ */ new Set()) {
|
|
|
35028
35188
|
async function loadActiveRentals() {
|
|
35029
35189
|
return loadActiveRentalRows();
|
|
35030
35190
|
}
|
|
35191
|
+
const listRentals = (rentals) => rentals.map((r) => `${r.id.slice(0, 8)} ${r.deviceName}`).join(", ");
|
|
35031
35192
|
/**
|
|
35032
35193
|
* Which rental a device command acts on: the one named by --rental (an id, an
|
|
35033
35194
|
* id prefix, or a device name), else the only one running.
|
|
35195
|
+
*
|
|
35196
|
+
* A prefix or name that fits SEVERAL rentals is refused, never resolved to
|
|
35197
|
+
* the first: `stop pixel` with two Pixels rented must not stop — and wipe —
|
|
35198
|
+
* whichever one happened to come back first.
|
|
35034
35199
|
*/
|
|
35035
35200
|
function chooseRental(rentals, selector) {
|
|
35036
35201
|
if (rentals.length === 0) throw new CliError("No active rental. Start one: devicerent rent <device>");
|
|
35037
35202
|
if (!selector) {
|
|
35038
35203
|
if (rentals.length === 1) return rentals[0];
|
|
35039
|
-
throw new CliError(`You have ${rentals.length} rentals running; pick one with --rental (${rentals
|
|
35204
|
+
throw new CliError(`You have ${rentals.length} rentals running; pick one with --rental (${listRentals(rentals)}).`);
|
|
35040
35205
|
}
|
|
35041
|
-
const s = selector.toLowerCase();
|
|
35042
|
-
const
|
|
35043
|
-
if (
|
|
35044
|
-
|
|
35206
|
+
const s = selector.trim().toLowerCase();
|
|
35207
|
+
const exact = rentals.find((r) => r.id.toLowerCase() === s);
|
|
35208
|
+
if (exact) return exact;
|
|
35209
|
+
for (const matches of [rentals.filter((r) => r.id.toLowerCase().startsWith(s)), rentals.filter((r) => r.deviceName.toLowerCase().includes(s))]) {
|
|
35210
|
+
if (matches.length === 1) return matches[0];
|
|
35211
|
+
if (matches.length > 1) throw new CliError(`"${selector}" fits ${matches.length} of your rentals (${listRentals(matches)}); name one by its id.`);
|
|
35212
|
+
}
|
|
35213
|
+
throw new CliError(`No active rental matches "${selector}".`);
|
|
35045
35214
|
}
|
|
35046
35215
|
/** Minutes since a timestamp, never negative. */
|
|
35047
35216
|
function minutesSince(iso, now) {
|
|
@@ -35076,6 +35245,8 @@ function renewDelayMs(expiresAt, now) {
|
|
|
35076
35245
|
return Math.max(5e3, Date.parse(expiresAt) - now - RENEW_BEFORE_EXPIRY_MS);
|
|
35077
35246
|
}
|
|
35078
35247
|
async function openDevice(ctx, rental) {
|
|
35248
|
+
const touch = () => ctx.supabase.rpc("touch_rental_activity", { p_rental_id: rental.id }).then(() => void 0, () => void 0);
|
|
35249
|
+
const opened = touch();
|
|
35079
35250
|
let ticket;
|
|
35080
35251
|
try {
|
|
35081
35252
|
ticket = await mintBridgeTicket(rental.id);
|
|
@@ -35105,17 +35276,18 @@ async function openDevice(ctx, rental) {
|
|
|
35105
35276
|
if (closed) return;
|
|
35106
35277
|
try {
|
|
35107
35278
|
const next = await mintBridgeTicket(rental.id);
|
|
35279
|
+
if (closed) return;
|
|
35108
35280
|
connector.setToken(next.token);
|
|
35109
35281
|
await client.getVersion();
|
|
35110
35282
|
schedule(renewDelayMs(next.expires_at, Date.now()));
|
|
35111
35283
|
} catch (err) {
|
|
35284
|
+
if (closed) return;
|
|
35112
35285
|
warn(`Could not renew access to ${rental.deviceName}: ${err.message}`);
|
|
35113
35286
|
schedule(RENEW_RETRY_MS);
|
|
35114
35287
|
}
|
|
35115
35288
|
};
|
|
35116
35289
|
schedule(renewDelayMs(ticket.expires_at, Date.now()));
|
|
35117
|
-
|
|
35118
|
-
await touch();
|
|
35290
|
+
await opened;
|
|
35119
35291
|
let dirty = false;
|
|
35120
35292
|
const flush = setInterval(() => {
|
|
35121
35293
|
if (!dirty) return;
|
|
@@ -35159,17 +35331,141 @@ const RENTAL_OPTION = { rental: {
|
|
|
35159
35331
|
type: "string",
|
|
35160
35332
|
short: "r"
|
|
35161
35333
|
} };
|
|
35162
|
-
|
|
35334
|
+
const STOPPED = () => new CliError("Stopped.", 130);
|
|
35335
|
+
/**
|
|
35336
|
+
* Resolve the rental, open the phone, run, and always close it again.
|
|
35337
|
+
*
|
|
35338
|
+
* In the interactive session Ctrl-C aborts `input.signal`. That closes the
|
|
35339
|
+
* connection to the phone at once — a `finally` alone would never run while a
|
|
35340
|
+
* `logcat -f` read loop is blocked — and the stream error it causes is
|
|
35341
|
+
* reported as the deliberate stop it was. A one-shot run has no signal.
|
|
35342
|
+
*/
|
|
35163
35343
|
async function withDevice(input, fn) {
|
|
35344
|
+
const rentals = loadActiveRentals();
|
|
35345
|
+
rentals.catch(() => {});
|
|
35164
35346
|
const user = await requireUser(input.ctx);
|
|
35165
|
-
const rental = chooseRental(await
|
|
35347
|
+
const rental = chooseRental(await rentals, str$1(input.opts, "rental") ?? process.env.DEVICERENT_RENTAL);
|
|
35348
|
+
if (input.signal?.aborted) throw STOPPED();
|
|
35166
35349
|
const device = await openDevice(input.ctx, rental);
|
|
35350
|
+
const onAbort = () => void device.close();
|
|
35351
|
+
input.signal?.addEventListener("abort", onAbort, { once: true });
|
|
35167
35352
|
try {
|
|
35168
35353
|
return await fn(device, user);
|
|
35354
|
+
} catch (err) {
|
|
35355
|
+
if (input.signal?.aborted) throw STOPPED();
|
|
35356
|
+
throw err;
|
|
35169
35357
|
} finally {
|
|
35358
|
+
input.signal?.removeEventListener("abort", onAbort);
|
|
35170
35359
|
await device.close();
|
|
35171
35360
|
}
|
|
35172
35361
|
}
|
|
35362
|
+
const CSI = `[`;
|
|
35363
|
+
function colorLevel(env, isTTY) {
|
|
35364
|
+
if (env.NO_COLOR) return 0;
|
|
35365
|
+
const force = env.FORCE_COLOR;
|
|
35366
|
+
if (force === "0" || force === "false") return 0;
|
|
35367
|
+
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";
|
|
35368
|
+
if (force !== void 0 && force !== "") return force === "3" || truecolor ? 3 : 1;
|
|
35369
|
+
if (!isTTY || env.TERM === "dumb") return 0;
|
|
35370
|
+
return truecolor ? 3 : 1;
|
|
35371
|
+
}
|
|
35372
|
+
const ANSI_RE = /\u001b\[[0-9;?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)/g;
|
|
35373
|
+
function stripAnsi(s) {
|
|
35374
|
+
return s.replace(ANSI_RE, "");
|
|
35375
|
+
}
|
|
35376
|
+
const CONTROL_RE = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
|
|
35377
|
+
/**
|
|
35378
|
+
* Text from outside — DeviceyAI's answer, a tool's error, a file name — made
|
|
35379
|
+
* safe to print. Escape sequences are removed whole, then every remaining
|
|
35380
|
+
* control character, so text read off a phone's screen cannot retitle the
|
|
35381
|
+
* window, rewrite the clipboard (OSC 52) or hide what is printed after it. A
|
|
35382
|
+
* sequence split across two chunks loses its ESC and prints as harmless text.
|
|
35383
|
+
*/
|
|
35384
|
+
function sanitize(text) {
|
|
35385
|
+
return text.replace(ANSI_RE, "").replace(CONTROL_RE, "");
|
|
35386
|
+
}
|
|
35387
|
+
/** Zero-width: combining marks, joiners, variation selectors. */
|
|
35388
|
+
function isZeroWidth(cp) {
|
|
35389
|
+
return cp >= 768 && cp <= 879 || cp >= 8203 && cp <= 8207 || cp >= 65024 && cp <= 65039 || cp >= 917760 && cp <= 917999 || cp === 8419;
|
|
35390
|
+
}
|
|
35391
|
+
/** Drawn two cells wide: East Asian wide/fullwidth and emoji pictographs. */
|
|
35392
|
+
function isWide(cp) {
|
|
35393
|
+
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;
|
|
35394
|
+
}
|
|
35395
|
+
function charWidth(ch) {
|
|
35396
|
+
const cp = ch.codePointAt(0) ?? 0;
|
|
35397
|
+
if (cp < 32 || cp >= 127 && cp < 160) return 0;
|
|
35398
|
+
if (isZeroWidth(cp)) return 0;
|
|
35399
|
+
return isWide(cp) ? 2 : 1;
|
|
35400
|
+
}
|
|
35401
|
+
/** How many terminal cells `s` occupies once its escape codes are drawn. */
|
|
35402
|
+
function visibleWidth(s) {
|
|
35403
|
+
let w = 0;
|
|
35404
|
+
for (const ch of stripAnsi(s)) w += charWidth(ch);
|
|
35405
|
+
return w;
|
|
35406
|
+
}
|
|
35407
|
+
/**
|
|
35408
|
+
* Cut `s` to at most `max` visible cells, keeping escape codes intact and
|
|
35409
|
+
* ending with "…" when anything was cut. A reset is appended after a cut so a
|
|
35410
|
+
* colour cannot bleed into the next line.
|
|
35411
|
+
*/
|
|
35412
|
+
function truncate(s, max) {
|
|
35413
|
+
if (max <= 0) return "";
|
|
35414
|
+
if (visibleWidth(s) <= max) return s;
|
|
35415
|
+
let out = "";
|
|
35416
|
+
let w = 0;
|
|
35417
|
+
let i = 0;
|
|
35418
|
+
while (i < s.length) {
|
|
35419
|
+
ANSI_RE.lastIndex = i;
|
|
35420
|
+
const m = ANSI_RE.exec(s);
|
|
35421
|
+
if (m && m.index === i) {
|
|
35422
|
+
out += m[0];
|
|
35423
|
+
i += m[0].length;
|
|
35424
|
+
continue;
|
|
35425
|
+
}
|
|
35426
|
+
const ch = String.fromCodePoint(s.codePointAt(i) ?? 0);
|
|
35427
|
+
const cw = charWidth(ch);
|
|
35428
|
+
if (w + cw > max - 1) break;
|
|
35429
|
+
out += ch;
|
|
35430
|
+
w += cw;
|
|
35431
|
+
i += ch.length;
|
|
35432
|
+
}
|
|
35433
|
+
ANSI_RE.lastIndex = 0;
|
|
35434
|
+
return `${out}…${CSI}0m`;
|
|
35435
|
+
}
|
|
35436
|
+
/** Pad with spaces to exactly `width` visible cells (truncating if longer). */
|
|
35437
|
+
function padEnd(s, width) {
|
|
35438
|
+
const t = truncate(s, width);
|
|
35439
|
+
return t + " ".repeat(Math.max(0, width - visibleWidth(t)));
|
|
35440
|
+
}
|
|
35441
|
+
/** Word-wrap plain text to `width` cells. Words longer than a line are split. */
|
|
35442
|
+
function wrap(text, width) {
|
|
35443
|
+
const out = [];
|
|
35444
|
+
for (const paragraph of text.split("\n")) {
|
|
35445
|
+
let line = "";
|
|
35446
|
+
for (const word of paragraph.split(/ +/)) {
|
|
35447
|
+
if (word === "") continue;
|
|
35448
|
+
let w = word;
|
|
35449
|
+
while (visibleWidth(w) > width) {
|
|
35450
|
+
if (line) {
|
|
35451
|
+
out.push(line);
|
|
35452
|
+
line = "";
|
|
35453
|
+
}
|
|
35454
|
+
const head = [...w].slice(0, width).join("");
|
|
35455
|
+
out.push(head);
|
|
35456
|
+
w = w.slice(head.length);
|
|
35457
|
+
}
|
|
35458
|
+
if (!line) line = w;
|
|
35459
|
+
else if (visibleWidth(line) + 1 + visibleWidth(w) <= width) line += ` ${w}`;
|
|
35460
|
+
else {
|
|
35461
|
+
out.push(line);
|
|
35462
|
+
line = w;
|
|
35463
|
+
}
|
|
35464
|
+
}
|
|
35465
|
+
out.push(line);
|
|
35466
|
+
}
|
|
35467
|
+
return out;
|
|
35468
|
+
}
|
|
35173
35469
|
//#endregion
|
|
35174
35470
|
//#region src/commands/ai.ts
|
|
35175
35471
|
const ConfigSchema = object({
|
|
@@ -35181,6 +35477,20 @@ const ConfigSchema = object({
|
|
|
35181
35477
|
})),
|
|
35182
35478
|
credits: number$1()
|
|
35183
35479
|
});
|
|
35480
|
+
/** The models and credit balance DeviceyAI offers on this rental. */
|
|
35481
|
+
async function loadAiConfig(rentalId) {
|
|
35482
|
+
const res = await fetch(`/api/ai/config?rental_id=${encodeURIComponent(rentalId)}`);
|
|
35483
|
+
const cfg = ConfigSchema.safeParse(await res.json().catch(() => ({})));
|
|
35484
|
+
if (!res.ok || !cfg.success) throw new CliError(res.status === 404 ? "DeviceyAI is not available on this deployment." : `DeviceyAI config failed (${res.status}).`);
|
|
35485
|
+
return cfg.data;
|
|
35486
|
+
}
|
|
35487
|
+
/** The model --model names, else the default, else the first offered. */
|
|
35488
|
+
function pickModel(models, wanted) {
|
|
35489
|
+
const model = models.find((m) => m.id === (wanted ?? "claude-sonnet-5")) ?? (wanted ? void 0 : models[0]);
|
|
35490
|
+
if (!model) throw new CliError(wanted ? `Model ${wanted} is not offered here. See: devicerent ai --models` : "DeviceyAI has no model available right now. Try again in a few minutes.");
|
|
35491
|
+
return model;
|
|
35492
|
+
}
|
|
35493
|
+
const OUT_OF_CREDITS = "You are out of DeviceyAI credits. Buy a pack: devicerent credits buy s";
|
|
35184
35494
|
const aiCommands = [{
|
|
35185
35495
|
name: "ai",
|
|
35186
35496
|
summary: "Give DeviceyAI a task on your rented phone",
|
|
@@ -35196,25 +35506,21 @@ const aiCommands = [{
|
|
|
35196
35506
|
const task = input.args.join(" ").trim();
|
|
35197
35507
|
if (!task && input.opts.models !== true) throw new CliError("Say what to do: devicerent ai \"open Settings and tell me the Android version\"");
|
|
35198
35508
|
return withDevice(input, async (device) => {
|
|
35199
|
-
const
|
|
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;
|
|
35509
|
+
const { models, credits } = await loadAiConfig(device.rental.id);
|
|
35203
35510
|
if (input.opts.models === true) {
|
|
35204
35511
|
if (input.json) return void printJson(models);
|
|
35205
35512
|
for (const m of models) printLine(`${m.id} ${dim(m.label)}${m.id === "claude-sonnet-5" ? " (default)" : ""}`);
|
|
35206
35513
|
return 0;
|
|
35207
35514
|
}
|
|
35208
|
-
const
|
|
35209
|
-
|
|
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");
|
|
35515
|
+
const model = pickModel(models, str$1(input.opts, "model"));
|
|
35516
|
+
if (credits <= 0) throw new CliError(OUT_OF_CREDITS);
|
|
35212
35517
|
const allowActions = input.opts["allow-actions"] === true;
|
|
35213
35518
|
if (!allowActions) note("Read-only: DeviceyAI can look but not touch. Add --allow-actions to let it tap and type.");
|
|
35214
35519
|
note(`DeviceyAI (${model.label}) on ${device.rental.deviceName} — Ctrl-C stops it.`);
|
|
35215
35520
|
const controller = new AbortController();
|
|
35216
35521
|
const onSigint = () => controller.abort();
|
|
35217
35522
|
process.once("SIGINT", onSigint);
|
|
35523
|
+
input.signal?.addEventListener("abort", onSigint, { once: true });
|
|
35218
35524
|
let streamed = false;
|
|
35219
35525
|
try {
|
|
35220
35526
|
const result = await runAiTask(device, {
|
|
@@ -35227,7 +35533,7 @@ const aiCommands = [{
|
|
|
35227
35533
|
onText: (delta) => {
|
|
35228
35534
|
if (input.json) return;
|
|
35229
35535
|
streamed = true;
|
|
35230
|
-
process.stdout.write(delta);
|
|
35536
|
+
process.stdout.write(sanitize(delta));
|
|
35231
35537
|
},
|
|
35232
35538
|
onTool: (name) => {
|
|
35233
35539
|
if (input.json) return;
|
|
@@ -35236,7 +35542,7 @@ const aiCommands = [{
|
|
|
35236
35542
|
process.stderr.write(dim(`→ ${name}\n`));
|
|
35237
35543
|
},
|
|
35238
35544
|
onToolDone: (name, isError, content) => {
|
|
35239
|
-
if (!input.json && isError) warn(`${name}: ${content.split("\n")[0]}`);
|
|
35545
|
+
if (!input.json && isError) warn(`${name}: ${sanitize(content.split("\n")[0])}`);
|
|
35240
35546
|
}
|
|
35241
35547
|
});
|
|
35242
35548
|
if (input.json) return void printJson(result);
|
|
@@ -35248,6 +35554,7 @@ const aiCommands = [{
|
|
|
35248
35554
|
return result.stopReason === "done" ? 0 : 1;
|
|
35249
35555
|
} finally {
|
|
35250
35556
|
process.off("SIGINT", onSigint);
|
|
35557
|
+
input.signal?.removeEventListener("abort", onSigint);
|
|
35251
35558
|
}
|
|
35252
35559
|
});
|
|
35253
35560
|
}
|
|
@@ -35455,6 +35762,19 @@ function parseAmStart(out) {
|
|
|
35455
35762
|
function isOwnProcess(name, pkg) {
|
|
35456
35763
|
return name === pkg || name.startsWith(`${pkg}:`);
|
|
35457
35764
|
}
|
|
35765
|
+
/**
|
|
35766
|
+
* Taps, from monkey's own event total, when it printed none of them.
|
|
35767
|
+
*
|
|
35768
|
+
* Monkey on Android 16 and 17 no longer prints `:Sending Touch` at `-v` (nor
|
|
35769
|
+
* at `-v -v`, checked on a Pixel 7a on 17, 2026-10-07), so counting those
|
|
35770
|
+
* lines reported "survived 0 taps" for every run. `Events injected` still
|
|
35771
|
+
* comes: with MONKEY_WEIGHTS every event but the opening `:Switch:` is a
|
|
35772
|
+
* touch, and a tap is exactly two of them — ACTION_DOWN and ACTION_UP (its
|
|
35773
|
+
* throttle sleep is not counted). A trailing half-tap rounds down.
|
|
35774
|
+
*/
|
|
35775
|
+
function tapsFromInjected(injected, switches) {
|
|
35776
|
+
return Math.max(0, Math.floor((injected - switches) / 2));
|
|
35777
|
+
}
|
|
35458
35778
|
function parseMonkey(out, pkg) {
|
|
35459
35779
|
const lines = out.split("\n").map((l) => l.replace(/\r$/, ""));
|
|
35460
35780
|
const pid = /^QTPID=(\d+)/m.exec(out);
|
|
@@ -35465,10 +35785,12 @@ function parseMonkey(out, pkg) {
|
|
|
35465
35785
|
let inCrash = false;
|
|
35466
35786
|
let error = null;
|
|
35467
35787
|
let taps = 0;
|
|
35788
|
+
let switches = 0;
|
|
35468
35789
|
let rejected = 0;
|
|
35469
35790
|
for (const raw of lines) {
|
|
35470
35791
|
const line = raw.trimStart();
|
|
35471
35792
|
if (line.includes(":Sending Touch (ACTION_DOWN)")) taps++;
|
|
35793
|
+
if (line.startsWith(":Switch:")) switches++;
|
|
35472
35794
|
if (/^\/\/ Rejecting (start|resume)/.test(line)) rejected++;
|
|
35473
35795
|
const c = /^\/\/ CRASH: (\S+)/.exec(line);
|
|
35474
35796
|
if (c) {
|
|
@@ -35490,10 +35812,11 @@ function parseMonkey(out, pkg) {
|
|
|
35490
35812
|
const e = /^\*\* (.+)$/.exec(line);
|
|
35491
35813
|
if (e && !error && !/^Monkey aborted due to error/.test(e[1])) error = e[1].replace(/^Error:\s*/, "").trim();
|
|
35492
35814
|
}
|
|
35815
|
+
const injectedCount = injected ? Number(injected[1]) : 0;
|
|
35493
35816
|
return {
|
|
35494
35817
|
pid: pid ? Number(pid[1]) : null,
|
|
35495
|
-
injected:
|
|
35496
|
-
taps,
|
|
35818
|
+
injected: injectedCount,
|
|
35819
|
+
taps: taps > 0 ? taps : tapsFromInjected(injectedCount, switches),
|
|
35497
35820
|
crash,
|
|
35498
35821
|
anr,
|
|
35499
35822
|
crashLines,
|
|
@@ -35993,6 +36316,74 @@ async function archive(ctx, file, storagePath) {
|
|
|
35993
36316
|
}
|
|
35994
36317
|
}
|
|
35995
36318
|
//#endregion
|
|
36319
|
+
//#region src/device/transfer.ts
|
|
36320
|
+
/** `push`'s remote path: a folder ("/sdcard/Download/") gets the file's own name, as adb push does. */
|
|
36321
|
+
function remoteTarget(local, remote) {
|
|
36322
|
+
return remote.endsWith("/") ? `${remote}${path.basename(local)}` : remote;
|
|
36323
|
+
}
|
|
36324
|
+
/** `pull`'s local path: none, or an existing folder, gets the phone file's own name. */
|
|
36325
|
+
function localTarget(remote, local) {
|
|
36326
|
+
const name = path.posix.basename(remote);
|
|
36327
|
+
if (!local) return name;
|
|
36328
|
+
try {
|
|
36329
|
+
if (fs.statSync(local).isDirectory()) return path.join(local, name);
|
|
36330
|
+
} catch {}
|
|
36331
|
+
return local;
|
|
36332
|
+
}
|
|
36333
|
+
/**
|
|
36334
|
+
* Stream a file read off the phone into `local`, honouring backpressure.
|
|
36335
|
+
*
|
|
36336
|
+
* Either side can fail — the phone has no such file, the folder cannot be
|
|
36337
|
+
* written — and either way what was written is removed and the error says
|
|
36338
|
+
* which side it was. An unhandled write-stream error used to crash the
|
|
36339
|
+
* process (the whole interactive session, with a phone still rented), and a
|
|
36340
|
+
* missing remote file used to leave an empty local one behind, which looks
|
|
36341
|
+
* like a copy that worked.
|
|
36342
|
+
*/
|
|
36343
|
+
async function pullToFile(source, local, remote) {
|
|
36344
|
+
const reader = source.getReader();
|
|
36345
|
+
let bytes = 0;
|
|
36346
|
+
let readFailed = false;
|
|
36347
|
+
let finished = false;
|
|
36348
|
+
async function* chunks() {
|
|
36349
|
+
try {
|
|
36350
|
+
for (;;) {
|
|
36351
|
+
let r;
|
|
36352
|
+
try {
|
|
36353
|
+
r = await reader.read();
|
|
36354
|
+
} catch (err) {
|
|
36355
|
+
readFailed = true;
|
|
36356
|
+
throw err;
|
|
36357
|
+
}
|
|
36358
|
+
if (r.done) {
|
|
36359
|
+
finished = true;
|
|
36360
|
+
return;
|
|
36361
|
+
}
|
|
36362
|
+
bytes += r.value.length;
|
|
36363
|
+
yield r.value;
|
|
36364
|
+
}
|
|
36365
|
+
} finally {
|
|
36366
|
+
if (!finished && !readFailed) reader.cancel().catch(() => {});
|
|
36367
|
+
}
|
|
36368
|
+
}
|
|
36369
|
+
let file;
|
|
36370
|
+
try {
|
|
36371
|
+
file = await fs.promises.open(local, "w");
|
|
36372
|
+
} catch (err) {
|
|
36373
|
+
reader.cancel().catch(() => {});
|
|
36374
|
+
throw new CliError(`Could not write ${local}: ${err.message}`);
|
|
36375
|
+
}
|
|
36376
|
+
try {
|
|
36377
|
+
await pipeline(chunks(), file.createWriteStream());
|
|
36378
|
+
return bytes;
|
|
36379
|
+
} catch (err) {
|
|
36380
|
+
await file.close().catch(() => {});
|
|
36381
|
+
fs.rmSync(local, { force: true });
|
|
36382
|
+
const message = err.message;
|
|
36383
|
+
throw new CliError(readFailed ? `Could not read ${remote} from the phone: ${message}` : `Could not write ${local}: ${message}`);
|
|
36384
|
+
}
|
|
36385
|
+
}
|
|
36386
|
+
//#endregion
|
|
35996
36387
|
//#region src/commands/device.ts
|
|
35997
36388
|
function requirePackage(pkg) {
|
|
35998
36389
|
if (!pkg || !QUICK_TEST_PACKAGE.test(pkg)) throw new CliError("Give an Android package name, e.g. com.example.app");
|
|
@@ -36003,7 +36394,7 @@ function progressPrinter(label) {
|
|
|
36003
36394
|
let last = -1;
|
|
36004
36395
|
return (p) => {
|
|
36005
36396
|
if (p.stage === "installing") {
|
|
36006
|
-
process.stderr.write(`${tty ? "\r" : ""}${label}: installing
|
|
36397
|
+
process.stderr.write(`${tty ? "\r\x1B[K" : ""}${label}: installing…\n`);
|
|
36007
36398
|
return;
|
|
36008
36399
|
}
|
|
36009
36400
|
const pct = p.totalBytes ? Math.floor(p.pushedBytes / p.totalBytes * 100) : 0;
|
|
@@ -36058,6 +36449,13 @@ const installCommand = {
|
|
|
36058
36449
|
}
|
|
36059
36450
|
return out;
|
|
36060
36451
|
});
|
|
36452
|
+
input.onResult?.({
|
|
36453
|
+
kind: "installed",
|
|
36454
|
+
apps: results.map((r) => ({
|
|
36455
|
+
filename: r.filename,
|
|
36456
|
+
packageName: r.packageName
|
|
36457
|
+
}))
|
|
36458
|
+
});
|
|
36061
36459
|
if (input.json) printJson(results);
|
|
36062
36460
|
}
|
|
36063
36461
|
};
|
|
@@ -36221,6 +36619,10 @@ const screenshotCommand = {
|
|
|
36221
36619
|
});
|
|
36222
36620
|
if (!bytes) throw new CliError("The phone did not return a PNG.");
|
|
36223
36621
|
fs.writeFileSync(file, bytes);
|
|
36622
|
+
input.onResult?.({
|
|
36623
|
+
kind: "screenshot",
|
|
36624
|
+
file
|
|
36625
|
+
});
|
|
36224
36626
|
if (input.json) return printJson({
|
|
36225
36627
|
file,
|
|
36226
36628
|
bytes: bytes.length
|
|
@@ -36255,9 +36657,10 @@ const deviceCommands = [
|
|
|
36255
36657
|
usage: "devicerent push <local> <remote-path> [--rental <id>]",
|
|
36256
36658
|
options: { ...RENTAL_OPTION },
|
|
36257
36659
|
async run(input) {
|
|
36258
|
-
const [local,
|
|
36259
|
-
if (!local || !
|
|
36260
|
-
if (!fs.
|
|
36660
|
+
const [local, remoteArg] = input.args;
|
|
36661
|
+
if (!local || !remoteArg) throw new CliError("devicerent push <local> <remote-path>, e.g. /sdcard/Download/data.json");
|
|
36662
|
+
if (!fs.statSync(local, { throwIfNoEntry: false })?.isFile()) throw new CliError(`No such file: ${local}`);
|
|
36663
|
+
const remote = remoteTarget(local, remoteArg);
|
|
36261
36664
|
const blob = await fs.openAsBlob(local);
|
|
36262
36665
|
await withDevice(input, async (device) => {
|
|
36263
36666
|
const sync = await device.adb.sync();
|
|
@@ -36281,25 +36684,15 @@ const deviceCommands = [
|
|
|
36281
36684
|
async run(input) {
|
|
36282
36685
|
const [remote, localArg] = input.args;
|
|
36283
36686
|
if (!remote) throw new CliError("devicerent pull <remote-path> [local]");
|
|
36284
|
-
const local =
|
|
36285
|
-
|
|
36286
|
-
await withDevice(input, async (device) => {
|
|
36687
|
+
const local = localTarget(remote, localArg);
|
|
36688
|
+
success(`Pulled ${formatBytes(await withDevice(input, async (device) => {
|
|
36287
36689
|
const sync = await device.adb.sync();
|
|
36288
|
-
const out = fs.createWriteStream(local);
|
|
36289
36690
|
try {
|
|
36290
|
-
|
|
36291
|
-
for (;;) {
|
|
36292
|
-
const { value, done } = await reader.read();
|
|
36293
|
-
if (done) break;
|
|
36294
|
-
written += value.length;
|
|
36295
|
-
out.write(value);
|
|
36296
|
-
}
|
|
36691
|
+
return await device.whileBusy(pullToFile(sync.read(remote), local, remote));
|
|
36297
36692
|
} finally {
|
|
36298
|
-
await new Promise((r) => out.end(() => r()));
|
|
36299
36693
|
await sync.dispose();
|
|
36300
36694
|
}
|
|
36301
|
-
});
|
|
36302
|
-
success(`Pulled ${formatBytes(written)} to ${local}`);
|
|
36695
|
+
}))} to ${local}`);
|
|
36303
36696
|
}
|
|
36304
36697
|
},
|
|
36305
36698
|
{
|
|
@@ -36358,7 +36751,7 @@ const rentalCommands = [
|
|
|
36358
36751
|
name: "rent",
|
|
36359
36752
|
summary: "Rent one to three phones (billing starts now)",
|
|
36360
36753
|
usage: "devicerent rent <device…> e.g. devicerent rent \"Pixel 7a\" \"Galaxy A54\", or an Id from devicerent devices",
|
|
36361
|
-
async run({ ctx, args, json }) {
|
|
36754
|
+
async run({ ctx, args, json, onResult }) {
|
|
36362
36755
|
await requireUser(ctx);
|
|
36363
36756
|
if (args.length === 0) throw new CliError("Name a device (id, serial or model). See: devicerent devices");
|
|
36364
36757
|
if (args.length > MAX_PER_REQUEST) throw new CliError(`At most ${MAX_PER_REQUEST} devices at once.`);
|
|
@@ -36372,6 +36765,10 @@ const rentalCommands = [
|
|
|
36372
36765
|
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
36766
|
if (error) throw new CliError(`Rental refused: ${error.message}`);
|
|
36374
36767
|
const rentals = Array.isArray(data) ? data : [data];
|
|
36768
|
+
onResult?.({
|
|
36769
|
+
kind: "rented",
|
|
36770
|
+
rentalIds: rentals.map((r) => r.id)
|
|
36771
|
+
});
|
|
36375
36772
|
if (json) return printJson(rentals);
|
|
36376
36773
|
for (const r of rentals) success(`Rented ${picks.find((p) => p.id === r.device_id)?.name ?? r.device_id} — rental ${r.id}`);
|
|
36377
36774
|
warn("Billing runs per minute until you `devicerent stop` (a rental with no activity for 30 minutes is stopped for you).");
|
|
@@ -36422,12 +36819,16 @@ const rentalCommands = [
|
|
|
36422
36819
|
all: { type: "boolean" },
|
|
36423
36820
|
rental: { type: "string" }
|
|
36424
36821
|
},
|
|
36425
|
-
async run({ ctx, args, opts, json }) {
|
|
36822
|
+
async run({ ctx, args, opts, json, onResult }) {
|
|
36426
36823
|
await requireUser(ctx);
|
|
36427
36824
|
if (opts.all === true) {
|
|
36428
36825
|
const { data, error } = await ctx.supabase.rpc("stop_all_rentals");
|
|
36429
36826
|
if (error) throw new CliError(`Could not stop: ${error.message}`);
|
|
36430
36827
|
const stopped = data ?? [];
|
|
36828
|
+
onResult?.({
|
|
36829
|
+
kind: "stopped",
|
|
36830
|
+
rentalIds: stopped.map((r) => r.id)
|
|
36831
|
+
});
|
|
36431
36832
|
if (json) return printJson(stopped);
|
|
36432
36833
|
if (stopped.length === 0) return note("Nothing was running.");
|
|
36433
36834
|
for (const r of stopped) success(`Stopped ${r.id} — ${eur(Number(r.total_cost ?? 0))}`);
|
|
@@ -36437,6 +36838,10 @@ const rentalCommands = [
|
|
|
36437
36838
|
const { data, error } = await ctx.supabase.rpc("stop_rental", { p_rental_id: rental.id });
|
|
36438
36839
|
if (error) throw new CliError(`Could not stop: ${error.message}`);
|
|
36439
36840
|
const row = data;
|
|
36841
|
+
onResult?.({
|
|
36842
|
+
kind: "stopped",
|
|
36843
|
+
rentalIds: [rental.id]
|
|
36844
|
+
});
|
|
36440
36845
|
if (json) return printJson(data);
|
|
36441
36846
|
success(`Stopped ${rental.deviceName} — ${eur(Number(row?.total_cost ?? 0))}`);
|
|
36442
36847
|
}
|
|
@@ -40720,7 +41125,7 @@ function createAdbServer({ getCreds, log = () => {} }) {
|
|
|
40720
41125
|
const server = net.createServer((sock) => {
|
|
40721
41126
|
sock.on("error", () => {});
|
|
40722
41127
|
readFirstRequest(sock, (payload, tail) => {
|
|
40723
|
-
dispatch(sock, payload, tail, {
|
|
41128
|
+
dispatch$1(sock, payload, tail, {
|
|
40724
41129
|
getCreds,
|
|
40725
41130
|
log,
|
|
40726
41131
|
forwards
|
|
@@ -40758,7 +41163,7 @@ function readFirstRequest(sock, cb) {
|
|
|
40758
41163
|
};
|
|
40759
41164
|
sock.on("data", onData);
|
|
40760
41165
|
}
|
|
40761
|
-
async function dispatch(sock, payload, tail, ctx) {
|
|
41166
|
+
async function dispatch$1(sock, payload, tail, ctx) {
|
|
40762
41167
|
let creds = null;
|
|
40763
41168
|
try {
|
|
40764
41169
|
creds = ctx.getCreds();
|
|
@@ -41069,6 +41474,7 @@ const testingCommands = [
|
|
|
41069
41474
|
input.ctx.supabase.rpc("touch_rental_activity", { p_rental_id: rental.id });
|
|
41070
41475
|
}, 6e4);
|
|
41071
41476
|
let timer;
|
|
41477
|
+
let disconnected = false;
|
|
41072
41478
|
const renew = async () => {
|
|
41073
41479
|
try {
|
|
41074
41480
|
ticket = await mintBridgeTicket(rental.id);
|
|
@@ -41077,8 +41483,10 @@ const testingCommands = [
|
|
|
41077
41483
|
token: ticket.token,
|
|
41078
41484
|
tokenTransport: "subprotocol"
|
|
41079
41485
|
})).getVersion();
|
|
41486
|
+
if (disconnected) return;
|
|
41080
41487
|
timer = setTimeout(renew, renewDelayMs(ticket.expires_at, Date.now()));
|
|
41081
41488
|
} catch (err) {
|
|
41489
|
+
if (disconnected) return;
|
|
41082
41490
|
warn(`Could not renew device access: ${err.message}`);
|
|
41083
41491
|
timer = setTimeout(renew, 3e4);
|
|
41084
41492
|
}
|
|
@@ -41095,29 +41503,46 @@ const testingCommands = [
|
|
|
41095
41503
|
},
|
|
41096
41504
|
log: (...parts) => note(parts.join(" "))
|
|
41097
41505
|
});
|
|
41098
|
-
|
|
41099
|
-
|
|
41100
|
-
|
|
41506
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
41507
|
+
server.on("connection", (s) => {
|
|
41508
|
+
sockets.add(s);
|
|
41509
|
+
s.once("close", () => sockets.delete(s));
|
|
41101
41510
|
});
|
|
41102
|
-
|
|
41103
|
-
|
|
41104
|
-
|
|
41105
|
-
|
|
41106
|
-
|
|
41107
|
-
|
|
41108
|
-
|
|
41109
|
-
process.
|
|
41110
|
-
|
|
41111
|
-
|
|
41112
|
-
|
|
41113
|
-
|
|
41114
|
-
|
|
41511
|
+
try {
|
|
41512
|
+
await new Promise((resolve, reject) => {
|
|
41513
|
+
server.once("error", (err) => reject(err.code === "EADDRINUSE" ? new CliError(`Port ${port} is already in use — is another devicerent connect (or an adb server) running there? Pick another with --port.`) : err));
|
|
41514
|
+
server.listen(port, "127.0.0.1", () => resolve());
|
|
41515
|
+
});
|
|
41516
|
+
success(`${rental.deviceName} (${ticket.serial}) is now a local adb device.`);
|
|
41517
|
+
printLine("");
|
|
41518
|
+
printLine(process.platform === "win32" ? ` $env:ANDROID_ADB_SERVER_PORT=${port} # PowerShell` : ` export ANDROID_ADB_SERVER_PORT=${port}`);
|
|
41519
|
+
printLine("");
|
|
41520
|
+
note("Then use adb, gradle or Appium as usual in that shell. Ctrl-C disconnects (the rental keeps running).");
|
|
41521
|
+
await new Promise((resolve) => {
|
|
41522
|
+
const stop = () => {
|
|
41523
|
+
process.off("SIGINT", stop);
|
|
41524
|
+
process.off("SIGTERM", stop);
|
|
41525
|
+
input.signal?.removeEventListener("abort", stop);
|
|
41526
|
+
resolve();
|
|
41527
|
+
};
|
|
41528
|
+
process.on("SIGINT", stop);
|
|
41529
|
+
process.on("SIGTERM", stop);
|
|
41530
|
+
if (input.signal?.aborted) stop();
|
|
41531
|
+
else input.signal?.addEventListener("abort", stop);
|
|
41532
|
+
});
|
|
41533
|
+
} finally {
|
|
41534
|
+
disconnected = true;
|
|
41535
|
+
clearTimeout(timer);
|
|
41536
|
+
clearInterval(touch);
|
|
41537
|
+
server.close();
|
|
41538
|
+
for (const s of sockets) s.destroy();
|
|
41539
|
+
}
|
|
41115
41540
|
note("Disconnected. Stop the rental with: devicerent stop");
|
|
41116
41541
|
}
|
|
41117
41542
|
}
|
|
41118
41543
|
];
|
|
41119
41544
|
//#endregion
|
|
41120
|
-
//#region src/
|
|
41545
|
+
//#region src/registry.ts
|
|
41121
41546
|
const GROUPS = [
|
|
41122
41547
|
{
|
|
41123
41548
|
title: "Account",
|
|
@@ -41153,6 +41578,2818 @@ function findCommand(argv, commands = COMMANDS) {
|
|
|
41153
41578
|
}
|
|
41154
41579
|
return null;
|
|
41155
41580
|
}
|
|
41581
|
+
/** --api-base, else DEVICERENT_API_BASE, else production. */
|
|
41582
|
+
function resolveApiBase(values) {
|
|
41583
|
+
return normalizeApiBase((typeof values["api-base"] === "string" ? values["api-base"] : void 0) ?? process.env.DEVICERENT_API_BASE ?? "https://devicerent.net");
|
|
41584
|
+
}
|
|
41585
|
+
/**
|
|
41586
|
+
* Find, parse and run one command. Arguments are parsed BEFORE the context is
|
|
41587
|
+
* asked for, so a usage error never touches the network.
|
|
41588
|
+
*/
|
|
41589
|
+
async function runCommandLine(getCtx, argv, opts = {}) {
|
|
41590
|
+
const found = findCommand(argv);
|
|
41591
|
+
if (!found) return { kind: "unknown" };
|
|
41592
|
+
const { command, rest } = found;
|
|
41593
|
+
let values;
|
|
41594
|
+
let positionals;
|
|
41595
|
+
try {
|
|
41596
|
+
({values, positionals} = parseArgs({
|
|
41597
|
+
args: [...opts.globals ?? [], ...rest],
|
|
41598
|
+
options: {
|
|
41599
|
+
...GLOBAL_OPTIONS,
|
|
41600
|
+
...command.options
|
|
41601
|
+
},
|
|
41602
|
+
allowPositionals: true,
|
|
41603
|
+
strict: true
|
|
41604
|
+
}));
|
|
41605
|
+
} catch (err) {
|
|
41606
|
+
process.stderr.write(`${red(err.message)}\nUsage: ${command.usage}\n`);
|
|
41607
|
+
return {
|
|
41608
|
+
kind: "usage",
|
|
41609
|
+
code: 2
|
|
41610
|
+
};
|
|
41611
|
+
}
|
|
41612
|
+
if (values.help === true) {
|
|
41613
|
+
printLine(`${command.summary}\n\nUsage: ${command.usage}`);
|
|
41614
|
+
return {
|
|
41615
|
+
kind: "ran",
|
|
41616
|
+
code: 0
|
|
41617
|
+
};
|
|
41618
|
+
}
|
|
41619
|
+
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);
|
|
41620
|
+
const ctx = await getCtx(resolveApiBase(values));
|
|
41621
|
+
const code = await command.run({
|
|
41622
|
+
ctx,
|
|
41623
|
+
args: positionals,
|
|
41624
|
+
opts: values,
|
|
41625
|
+
json: values.json === true,
|
|
41626
|
+
signal: opts.signal,
|
|
41627
|
+
onResult: opts.onResult
|
|
41628
|
+
});
|
|
41629
|
+
return {
|
|
41630
|
+
kind: "ran",
|
|
41631
|
+
code: typeof code === "number" ? code : 0
|
|
41632
|
+
};
|
|
41633
|
+
}
|
|
41634
|
+
//#endregion
|
|
41635
|
+
//#region src/tui/box.ts
|
|
41636
|
+
/** The width a frame is drawn at: the terminal's, kept between 40 and 100. */
|
|
41637
|
+
function frameWidth(columns) {
|
|
41638
|
+
const cols = columns && columns > 0 ? columns : 80;
|
|
41639
|
+
return Math.max(Math.min(cols - 1, 40), Math.min(100, cols - 1));
|
|
41640
|
+
}
|
|
41641
|
+
/**
|
|
41642
|
+
* A rounded box around `lines`. Each line is padded (or cut) to the inside
|
|
41643
|
+
* width, so the right edge stays straight whatever colours a line carries.
|
|
41644
|
+
*/
|
|
41645
|
+
function renderBox(lines, opts) {
|
|
41646
|
+
const { g } = opts.theme;
|
|
41647
|
+
const paint = opts.border ?? opts.theme.dim;
|
|
41648
|
+
const inner = Math.max(1, opts.width - 4);
|
|
41649
|
+
let top = g.h.repeat(opts.width - 2);
|
|
41650
|
+
if (opts.title) {
|
|
41651
|
+
const t = ` ${truncate(opts.title, opts.width - 6)} `;
|
|
41652
|
+
top = g.h + t + g.h.repeat(Math.max(0, opts.width - 3 - visibleWidth(t)));
|
|
41653
|
+
}
|
|
41654
|
+
return [
|
|
41655
|
+
paint(`${g.tl}${top}${g.tr}`),
|
|
41656
|
+
...lines.map((l) => `${paint(g.v)} ${padEnd(l, inner)} ${paint(g.v)}`),
|
|
41657
|
+
paint(`${g.bl}${g.h.repeat(opts.width - 2)}${g.br}`)
|
|
41658
|
+
];
|
|
41659
|
+
}
|
|
41660
|
+
//#endregion
|
|
41661
|
+
//#region src/tui/keys.ts
|
|
41662
|
+
let decoderReady = false;
|
|
41663
|
+
let owned = false;
|
|
41664
|
+
let restoreInstalled = false;
|
|
41665
|
+
function toKey(str, key) {
|
|
41666
|
+
const k = {
|
|
41667
|
+
name: key?.name,
|
|
41668
|
+
ctrl: key?.ctrl === true,
|
|
41669
|
+
meta: key?.meta === true,
|
|
41670
|
+
shift: key?.shift === true
|
|
41671
|
+
};
|
|
41672
|
+
if (str !== void 0 && !k.ctrl && !k.meta && str.length > 0 && !/[\u0000-\u001f\u007f]/.test(str)) k.text = str;
|
|
41673
|
+
return k;
|
|
41674
|
+
}
|
|
41675
|
+
/** Undo every mode the session may have switched on. Safe to call twice. */
|
|
41676
|
+
function restoreTerminal() {
|
|
41677
|
+
process.stdout.write(`${CSI}?25h${CSI}?1l${CSI}?2004l${CSI}0m`);
|
|
41678
|
+
}
|
|
41679
|
+
/**
|
|
41680
|
+
* Read keys until the returned function is called. Throws if the keyboard is
|
|
41681
|
+
* already being read, so two readers can never race for the same key.
|
|
41682
|
+
*/
|
|
41683
|
+
function listenKeys(onKey) {
|
|
41684
|
+
const stdin = process.stdin;
|
|
41685
|
+
if (owned) throw new Error("the keyboard is already being read");
|
|
41686
|
+
if (!decoderReady) {
|
|
41687
|
+
readline$1.emitKeypressEvents(stdin, { escapeCodeTimeout: 50 });
|
|
41688
|
+
decoderReady = true;
|
|
41689
|
+
}
|
|
41690
|
+
if (!restoreInstalled) {
|
|
41691
|
+
process.on("exit", restoreTerminal);
|
|
41692
|
+
restoreInstalled = true;
|
|
41693
|
+
}
|
|
41694
|
+
const handler = (str, key) => onKey(toKey(str, key));
|
|
41695
|
+
stdin.on("keypress", handler);
|
|
41696
|
+
if (!stdin.isRaw) stdin.setRawMode(true);
|
|
41697
|
+
stdin.resume();
|
|
41698
|
+
process.stdout.write(`${CSI}?2004h`);
|
|
41699
|
+
owned = true;
|
|
41700
|
+
let released = false;
|
|
41701
|
+
return () => {
|
|
41702
|
+
if (released) return;
|
|
41703
|
+
released = true;
|
|
41704
|
+
stdin.off("keypress", handler);
|
|
41705
|
+
process.stdout.write(`${CSI}?2004l`);
|
|
41706
|
+
stdin.pause();
|
|
41707
|
+
owned = false;
|
|
41708
|
+
};
|
|
41709
|
+
}
|
|
41710
|
+
/**
|
|
41711
|
+
* Ordinary line mode, for work that needs it: Ctrl-C as SIGINT, readline's
|
|
41712
|
+
* own prompts, a child's input. A no-op when raw mode is already off.
|
|
41713
|
+
*/
|
|
41714
|
+
function cookedMode() {
|
|
41715
|
+
if (process.stdin.isTTY && process.stdin.isRaw) process.stdin.setRawMode(false);
|
|
41716
|
+
}
|
|
41717
|
+
//#endregion
|
|
41718
|
+
//#region src/tui/lineEditor.ts
|
|
41719
|
+
function initialEditor(history = []) {
|
|
41720
|
+
return {
|
|
41721
|
+
value: "",
|
|
41722
|
+
cursor: 0,
|
|
41723
|
+
history,
|
|
41724
|
+
historyIndex: null,
|
|
41725
|
+
draft: "",
|
|
41726
|
+
suggestion: 0,
|
|
41727
|
+
dismissed: false,
|
|
41728
|
+
pasting: false
|
|
41729
|
+
};
|
|
41730
|
+
}
|
|
41731
|
+
/** The commands a `/…` line could mean, best match first. */
|
|
41732
|
+
function suggestionsFor(value, all, limit = 8) {
|
|
41733
|
+
if (!value.startsWith("/")) return [];
|
|
41734
|
+
const q = value.slice(1).toLowerCase();
|
|
41735
|
+
if (/\s/.test(q)) return all.filter((s) => s.name.startsWith(q) && s.name !== q).slice(0, limit);
|
|
41736
|
+
const prefix = all.filter((s) => s.name.startsWith(q));
|
|
41737
|
+
const word = all.filter((s) => !prefix.includes(s) && s.name.split(" ").some((w) => w.startsWith(q)));
|
|
41738
|
+
const described = q.length >= 3 ? all.filter((s) => !prefix.includes(s) && !word.includes(s) && s.summary.toLowerCase().includes(q)) : [];
|
|
41739
|
+
return [
|
|
41740
|
+
...prefix,
|
|
41741
|
+
...word,
|
|
41742
|
+
...described
|
|
41743
|
+
].slice(0, limit);
|
|
41744
|
+
}
|
|
41745
|
+
/** The suggestions that are on screen right now. */
|
|
41746
|
+
function visibleSuggestions(state, all) {
|
|
41747
|
+
return state.dismissed ? [] : suggestionsFor(state.value, all);
|
|
41748
|
+
}
|
|
41749
|
+
function prevBoundary(s, i) {
|
|
41750
|
+
if (i <= 0) return 0;
|
|
41751
|
+
const c = s.charCodeAt(i - 1);
|
|
41752
|
+
return c >= 56320 && c <= 57343 && i >= 2 ? i - 2 : i - 1;
|
|
41753
|
+
}
|
|
41754
|
+
function nextBoundary(s, i) {
|
|
41755
|
+
if (i >= s.length) return s.length;
|
|
41756
|
+
const c = s.charCodeAt(i);
|
|
41757
|
+
return c >= 55296 && c <= 56319 && i + 1 < s.length ? i + 2 : i + 1;
|
|
41758
|
+
}
|
|
41759
|
+
/** Start of the word before the cursor (Ctrl-W, Alt-Backspace). */
|
|
41760
|
+
function wordStart(s, i) {
|
|
41761
|
+
let j = i;
|
|
41762
|
+
while (j > 0 && /\s/.test(s[j - 1])) j--;
|
|
41763
|
+
while (j > 0 && !/\s/.test(s[j - 1])) j--;
|
|
41764
|
+
return j;
|
|
41765
|
+
}
|
|
41766
|
+
function edit(state, value, cursor) {
|
|
41767
|
+
return {
|
|
41768
|
+
...state,
|
|
41769
|
+
value,
|
|
41770
|
+
cursor,
|
|
41771
|
+
historyIndex: null,
|
|
41772
|
+
draft: value,
|
|
41773
|
+
suggestion: 0,
|
|
41774
|
+
dismissed: false
|
|
41775
|
+
};
|
|
41776
|
+
}
|
|
41777
|
+
function recall(state, index) {
|
|
41778
|
+
const value = index === null ? state.draft : state.history[index] ?? state.draft;
|
|
41779
|
+
return {
|
|
41780
|
+
...state,
|
|
41781
|
+
historyIndex: index,
|
|
41782
|
+
value,
|
|
41783
|
+
cursor: value.length,
|
|
41784
|
+
suggestion: 0,
|
|
41785
|
+
dismissed: true
|
|
41786
|
+
};
|
|
41787
|
+
}
|
|
41788
|
+
function editorReducer(state, key, all) {
|
|
41789
|
+
const s = state;
|
|
41790
|
+
const ok = (next, event = null) => ({
|
|
41791
|
+
state: next,
|
|
41792
|
+
event
|
|
41793
|
+
});
|
|
41794
|
+
const shown = visibleSuggestions(s, all);
|
|
41795
|
+
if (key.name === "paste-start") return ok({
|
|
41796
|
+
...s,
|
|
41797
|
+
pasting: true
|
|
41798
|
+
});
|
|
41799
|
+
if (key.name === "paste-end") return ok({
|
|
41800
|
+
...s,
|
|
41801
|
+
pasting: false
|
|
41802
|
+
});
|
|
41803
|
+
if (key.name === "return" || key.name === "enter") {
|
|
41804
|
+
if (s.pasting) return ok(edit(s, `${s.value.slice(0, s.cursor)} ${s.value.slice(s.cursor)}`, s.cursor + 1));
|
|
41805
|
+
const pick = shown[s.suggestion];
|
|
41806
|
+
if (pick) {
|
|
41807
|
+
const value = `/${pick.name}`;
|
|
41808
|
+
return ok({
|
|
41809
|
+
...s,
|
|
41810
|
+
value,
|
|
41811
|
+
cursor: value.length
|
|
41812
|
+
}, "submit");
|
|
41813
|
+
}
|
|
41814
|
+
return ok(s, "submit");
|
|
41815
|
+
}
|
|
41816
|
+
if (key.ctrl && key.name === "c") return s.value ? ok(edit(s, "", 0)) : ok(s, "interrupt");
|
|
41817
|
+
if (key.ctrl && key.name === "d") {
|
|
41818
|
+
if (!s.value) return ok(s, "eof");
|
|
41819
|
+
return ok(edit(s, s.value.slice(0, s.cursor) + s.value.slice(nextBoundary(s.value, s.cursor)), s.cursor));
|
|
41820
|
+
}
|
|
41821
|
+
if (key.name === "escape") {
|
|
41822
|
+
if (shown.length > 0) return ok({
|
|
41823
|
+
...s,
|
|
41824
|
+
dismissed: true
|
|
41825
|
+
});
|
|
41826
|
+
return ok(edit(s, "", 0));
|
|
41827
|
+
}
|
|
41828
|
+
if (key.name === "tab") {
|
|
41829
|
+
const pick = shown[s.suggestion];
|
|
41830
|
+
if (!pick) return ok(s);
|
|
41831
|
+
const value = `/${pick.name} `;
|
|
41832
|
+
return ok({
|
|
41833
|
+
...edit(s, value, value.length),
|
|
41834
|
+
dismissed: true
|
|
41835
|
+
});
|
|
41836
|
+
}
|
|
41837
|
+
if (key.name === "up" || key.ctrl && key.name === "p") {
|
|
41838
|
+
if (shown.length > 0) return ok({
|
|
41839
|
+
...s,
|
|
41840
|
+
suggestion: (s.suggestion - 1 + shown.length) % shown.length
|
|
41841
|
+
});
|
|
41842
|
+
if (s.history.length === 0) return ok(s);
|
|
41843
|
+
return ok(recall(s, s.historyIndex === null ? s.history.length - 1 : Math.max(0, s.historyIndex - 1)));
|
|
41844
|
+
}
|
|
41845
|
+
if (key.name === "down" || key.ctrl && key.name === "n") {
|
|
41846
|
+
if (shown.length > 0) return ok({
|
|
41847
|
+
...s,
|
|
41848
|
+
suggestion: (s.suggestion + 1) % shown.length
|
|
41849
|
+
});
|
|
41850
|
+
if (s.historyIndex === null) return ok(s);
|
|
41851
|
+
const i = s.historyIndex + 1;
|
|
41852
|
+
return ok(recall(s, i >= s.history.length ? null : i));
|
|
41853
|
+
}
|
|
41854
|
+
if (key.name === "left" || key.ctrl && key.name === "b") return ok({
|
|
41855
|
+
...s,
|
|
41856
|
+
cursor: prevBoundary(s.value, s.cursor)
|
|
41857
|
+
});
|
|
41858
|
+
if (key.name === "right" || key.ctrl && key.name === "f") return ok({
|
|
41859
|
+
...s,
|
|
41860
|
+
cursor: nextBoundary(s.value, s.cursor)
|
|
41861
|
+
});
|
|
41862
|
+
if (key.name === "home" || key.ctrl && key.name === "a") return ok({
|
|
41863
|
+
...s,
|
|
41864
|
+
cursor: 0
|
|
41865
|
+
});
|
|
41866
|
+
if (key.name === "end" || key.ctrl && key.name === "e") return ok({
|
|
41867
|
+
...s,
|
|
41868
|
+
cursor: s.value.length
|
|
41869
|
+
});
|
|
41870
|
+
if (key.name === "backspace" || key.ctrl && key.name === "h") {
|
|
41871
|
+
if (key.meta) {
|
|
41872
|
+
const from = wordStart(s.value, s.cursor);
|
|
41873
|
+
return ok(edit(s, s.value.slice(0, from) + s.value.slice(s.cursor), from));
|
|
41874
|
+
}
|
|
41875
|
+
const from = prevBoundary(s.value, s.cursor);
|
|
41876
|
+
return ok(edit(s, s.value.slice(0, from) + s.value.slice(s.cursor), from));
|
|
41877
|
+
}
|
|
41878
|
+
if (key.name === "delete") return ok(edit(s, s.value.slice(0, s.cursor) + s.value.slice(nextBoundary(s.value, s.cursor)), s.cursor));
|
|
41879
|
+
if (key.ctrl && key.name === "u") return ok(edit(s, s.value.slice(s.cursor), 0));
|
|
41880
|
+
if (key.ctrl && key.name === "k") return ok(edit(s, s.value.slice(0, s.cursor), s.cursor));
|
|
41881
|
+
if (key.ctrl && key.name === "w") {
|
|
41882
|
+
const from = wordStart(s.value, s.cursor);
|
|
41883
|
+
return ok(edit(s, s.value.slice(0, from) + s.value.slice(s.cursor), from));
|
|
41884
|
+
}
|
|
41885
|
+
if (key.text) {
|
|
41886
|
+
const text = key.text.replace(/[\r\n\t]+/g, " ");
|
|
41887
|
+
return ok(edit(s, s.value.slice(0, s.cursor) + text + s.value.slice(s.cursor), s.cursor + text.length));
|
|
41888
|
+
}
|
|
41889
|
+
return ok(s);
|
|
41890
|
+
}
|
|
41891
|
+
//#endregion
|
|
41892
|
+
//#region src/tui/region.ts
|
|
41893
|
+
/**
|
|
41894
|
+
* A block of lines at the bottom of the terminal that can be redrawn in
|
|
41895
|
+
* place — the prompt box, a menu. It remembers how many lines it drew and
|
|
41896
|
+
* where it left the cursor, so the next render can climb back to its top
|
|
41897
|
+
* line and draw again. Every line is cut to the terminal's width first: a
|
|
41898
|
+
* line the terminal wrapped would throw that count off.
|
|
41899
|
+
*/
|
|
41900
|
+
var LiveRegion = class {
|
|
41901
|
+
out;
|
|
41902
|
+
lines = 0;
|
|
41903
|
+
cursorRow = 0;
|
|
41904
|
+
constructor(out = process.stdout) {
|
|
41905
|
+
this.out = out;
|
|
41906
|
+
}
|
|
41907
|
+
render(lines, cursor) {
|
|
41908
|
+
const cols = Math.max(10, (this.out.columns || 80) - 1);
|
|
41909
|
+
let s = this.lines > 0 && this.cursorRow > 0 ? `${CSI}${this.cursorRow}A` : "";
|
|
41910
|
+
s += `\r${CSI}0J`;
|
|
41911
|
+
s += lines.map((l) => truncate(l, cols)).join("\n");
|
|
41912
|
+
const last = Math.max(0, lines.length - 1);
|
|
41913
|
+
if (cursor) {
|
|
41914
|
+
const up = last - cursor.row;
|
|
41915
|
+
if (up > 0) s += `${CSI}${up}A`;
|
|
41916
|
+
s += "\r";
|
|
41917
|
+
if (cursor.col > 0) s += `${CSI}${cursor.col}C`;
|
|
41918
|
+
this.cursorRow = cursor.row;
|
|
41919
|
+
} else this.cursorRow = last;
|
|
41920
|
+
this.lines = lines.length;
|
|
41921
|
+
this.out.write(s);
|
|
41922
|
+
}
|
|
41923
|
+
/** Erase the region; the cursor ends where the region began. */
|
|
41924
|
+
clear() {
|
|
41925
|
+
if (this.lines === 0) return;
|
|
41926
|
+
this.out.write(`${this.cursorRow > 0 ? `${CSI}${this.cursorRow}A` : ""}\r${CSI}0J`);
|
|
41927
|
+
this.lines = 0;
|
|
41928
|
+
this.cursorRow = 0;
|
|
41929
|
+
}
|
|
41930
|
+
/** Keep what is drawn and move to a fresh line below it. */
|
|
41931
|
+
done() {
|
|
41932
|
+
if (this.lines === 0) return;
|
|
41933
|
+
const down = this.lines - 1 - this.cursorRow;
|
|
41934
|
+
this.out.write(`${down > 0 ? `${CSI}${down}B` : ""}\n`);
|
|
41935
|
+
this.lines = 0;
|
|
41936
|
+
this.cursorRow = 0;
|
|
41937
|
+
}
|
|
41938
|
+
};
|
|
41939
|
+
//#endregion
|
|
41940
|
+
//#region src/tui/theme.ts
|
|
41941
|
+
const FANCY = {
|
|
41942
|
+
spark: "✻",
|
|
41943
|
+
dot: "⏺",
|
|
41944
|
+
elbow: "⎿",
|
|
41945
|
+
pointer: "❯",
|
|
41946
|
+
check: "✓",
|
|
41947
|
+
cross: "✗",
|
|
41948
|
+
bullet: "●",
|
|
41949
|
+
barFull: "█",
|
|
41950
|
+
barEmpty: "░",
|
|
41951
|
+
tl: "╭",
|
|
41952
|
+
tr: "╮",
|
|
41953
|
+
bl: "╰",
|
|
41954
|
+
br: "╯",
|
|
41955
|
+
h: "─",
|
|
41956
|
+
v: "│",
|
|
41957
|
+
spinner: [
|
|
41958
|
+
"·",
|
|
41959
|
+
"✢",
|
|
41960
|
+
"✳",
|
|
41961
|
+
"✶",
|
|
41962
|
+
"✻",
|
|
41963
|
+
"✽",
|
|
41964
|
+
"✻",
|
|
41965
|
+
"✶",
|
|
41966
|
+
"✳",
|
|
41967
|
+
"✢"
|
|
41968
|
+
]
|
|
41969
|
+
};
|
|
41970
|
+
const PLAIN = {
|
|
41971
|
+
spark: "*",
|
|
41972
|
+
dot: "*",
|
|
41973
|
+
elbow: "└",
|
|
41974
|
+
pointer: ">",
|
|
41975
|
+
check: "v",
|
|
41976
|
+
cross: "x",
|
|
41977
|
+
bullet: "*",
|
|
41978
|
+
barFull: "#",
|
|
41979
|
+
barEmpty: "-",
|
|
41980
|
+
tl: "┌",
|
|
41981
|
+
tr: "┐",
|
|
41982
|
+
bl: "└",
|
|
41983
|
+
br: "┘",
|
|
41984
|
+
h: "─",
|
|
41985
|
+
v: "│",
|
|
41986
|
+
spinner: [
|
|
41987
|
+
"|",
|
|
41988
|
+
"/",
|
|
41989
|
+
"-",
|
|
41990
|
+
"\\"
|
|
41991
|
+
]
|
|
41992
|
+
};
|
|
41993
|
+
function glyphsFor(env, platform) {
|
|
41994
|
+
return platform === "win32" && !env.WT_SESSION && !env.TERM_PROGRAM && !env.ConEmuANSI && !env.TERM ? PLAIN : FANCY;
|
|
41995
|
+
}
|
|
41996
|
+
function createTheme(level, g) {
|
|
41997
|
+
const sgr = (open, close) => (s) => level === 0 ? s : `${CSI}${open}m${s}${CSI}${close}m`;
|
|
41998
|
+
return {
|
|
41999
|
+
level,
|
|
42000
|
+
g,
|
|
42001
|
+
accent: level === 3 ? sgr("38;2;57;255;20", "39") : sgr("92", "39"),
|
|
42002
|
+
bold: sgr("1", "22"),
|
|
42003
|
+
dim: sgr("2", "22"),
|
|
42004
|
+
red: sgr("31", "39"),
|
|
42005
|
+
yellow: sgr("33", "39"),
|
|
42006
|
+
green: sgr("32", "39"),
|
|
42007
|
+
inverse: sgr("7", "27")
|
|
42008
|
+
};
|
|
42009
|
+
}
|
|
42010
|
+
let current;
|
|
42011
|
+
/** The theme for this process, decided on first use (never at import). */
|
|
42012
|
+
function theme() {
|
|
42013
|
+
current ??= createTheme(colorLevel(process.env, process.stdout.isTTY === true), glyphsFor(process.env, process.platform));
|
|
42014
|
+
return current;
|
|
42015
|
+
}
|
|
42016
|
+
//#endregion
|
|
42017
|
+
//#region src/tui/spinner.ts
|
|
42018
|
+
const NOOP = {
|
|
42019
|
+
update() {},
|
|
42020
|
+
stop() {}
|
|
42021
|
+
};
|
|
42022
|
+
let active = null;
|
|
42023
|
+
function startSpinner(label, opts = {}) {
|
|
42024
|
+
const out = process.stdout;
|
|
42025
|
+
const err = process.stderr;
|
|
42026
|
+
if (!out.isTTY) return NOOP;
|
|
42027
|
+
active?.stop();
|
|
42028
|
+
const t = opts.theme ?? theme();
|
|
42029
|
+
const now = opts.now ?? Date.now;
|
|
42030
|
+
const started = now();
|
|
42031
|
+
const outWrite = out.write;
|
|
42032
|
+
const errWrite = err.write;
|
|
42033
|
+
const raw = (s) => outWrite.call(out, s);
|
|
42034
|
+
let text = label;
|
|
42035
|
+
let frame = 0;
|
|
42036
|
+
let stopped = false;
|
|
42037
|
+
let timer;
|
|
42038
|
+
const draw = () => {
|
|
42039
|
+
const secs = Math.floor((now() - started) / 1e3);
|
|
42040
|
+
const hint = opts.cancellable ? `(${secs}s · ctrl+c to cancel)` : `(${secs}s)`;
|
|
42041
|
+
const glyph = t.g.spinner[frame % t.g.spinner.length];
|
|
42042
|
+
frame += 1;
|
|
42043
|
+
raw(`\r${CSI}2K${truncate(`${t.accent(glyph)} ${text}… ${t.dim(hint)}`, (out.columns || 80) - 1)}`);
|
|
42044
|
+
};
|
|
42045
|
+
const self = {
|
|
42046
|
+
update(next) {
|
|
42047
|
+
text = next;
|
|
42048
|
+
if (!stopped) draw();
|
|
42049
|
+
},
|
|
42050
|
+
stop() {
|
|
42051
|
+
if (stopped) return;
|
|
42052
|
+
stopped = true;
|
|
42053
|
+
clearInterval(timer);
|
|
42054
|
+
out.write = outWrite;
|
|
42055
|
+
err.write = errWrite;
|
|
42056
|
+
raw(`\r${CSI}2K`);
|
|
42057
|
+
if (active === self) active = null;
|
|
42058
|
+
}
|
|
42059
|
+
};
|
|
42060
|
+
const intercepted = (orig) => function(...args) {
|
|
42061
|
+
self.stop();
|
|
42062
|
+
return orig.apply(this, args);
|
|
42063
|
+
};
|
|
42064
|
+
out.write = intercepted(outWrite);
|
|
42065
|
+
err.write = intercepted(errWrite);
|
|
42066
|
+
timer = setInterval(draw, 100);
|
|
42067
|
+
timer.unref();
|
|
42068
|
+
draw();
|
|
42069
|
+
active = self;
|
|
42070
|
+
return self;
|
|
42071
|
+
}
|
|
42072
|
+
/** Stop whatever spinner is running (before drawing a menu over it). */
|
|
42073
|
+
function stopSpinner() {
|
|
42074
|
+
active?.stop();
|
|
42075
|
+
}
|
|
42076
|
+
//#endregion
|
|
42077
|
+
//#region src/tui/prompt.ts
|
|
42078
|
+
const PROMPT = "> ";
|
|
42079
|
+
const ARM_MS = 2e3;
|
|
42080
|
+
/** Wrap the typed text into box rows and find the cursor's cell. */
|
|
42081
|
+
function layoutInput(value, cursor, inner) {
|
|
42082
|
+
const rows = [];
|
|
42083
|
+
let row = PROMPT;
|
|
42084
|
+
let w = 2;
|
|
42085
|
+
let at = {
|
|
42086
|
+
row: 0,
|
|
42087
|
+
col: w
|
|
42088
|
+
};
|
|
42089
|
+
let i = 0;
|
|
42090
|
+
for (const ch of value) {
|
|
42091
|
+
const cw = charWidth(ch);
|
|
42092
|
+
if (w + cw > inner) {
|
|
42093
|
+
rows.push(row);
|
|
42094
|
+
row = " ".repeat(2);
|
|
42095
|
+
w = 2;
|
|
42096
|
+
}
|
|
42097
|
+
if (i === cursor) at = {
|
|
42098
|
+
row: rows.length,
|
|
42099
|
+
col: w
|
|
42100
|
+
};
|
|
42101
|
+
row += ch;
|
|
42102
|
+
w += cw;
|
|
42103
|
+
i += ch.length;
|
|
42104
|
+
}
|
|
42105
|
+
if (cursor >= value.length) {
|
|
42106
|
+
if (w >= inner) {
|
|
42107
|
+
rows.push(row);
|
|
42108
|
+
row = " ".repeat(2);
|
|
42109
|
+
w = 2;
|
|
42110
|
+
}
|
|
42111
|
+
at = {
|
|
42112
|
+
row: rows.length,
|
|
42113
|
+
col: w
|
|
42114
|
+
};
|
|
42115
|
+
}
|
|
42116
|
+
rows.push(row);
|
|
42117
|
+
return {
|
|
42118
|
+
rows,
|
|
42119
|
+
...at
|
|
42120
|
+
};
|
|
42121
|
+
}
|
|
42122
|
+
/** The whole prompt as lines, and where the cursor goes. */
|
|
42123
|
+
function renderPrompt(view, t, width) {
|
|
42124
|
+
const inner = width - 4;
|
|
42125
|
+
const { state } = view;
|
|
42126
|
+
const laid = layoutInput(state.value, state.cursor, inner);
|
|
42127
|
+
const rows = laid.rows.map((r, i) => i === 0 ? `${t.bold(">")}${r.slice(1)}` : r);
|
|
42128
|
+
if (!state.value) rows[0] = `${t.bold(">")} ${t.dim(view.placeholder)}`;
|
|
42129
|
+
const lines = renderBox(rows, {
|
|
42130
|
+
width,
|
|
42131
|
+
theme: t
|
|
42132
|
+
});
|
|
42133
|
+
const shown = view.suggestions;
|
|
42134
|
+
if (shown.length > 0) {
|
|
42135
|
+
const nameWidth = Math.max(...shown.map((s) => s.name.length)) + 3;
|
|
42136
|
+
shown.forEach((s, i) => {
|
|
42137
|
+
const name = padEnd(`/${s.name}`, nameWidth);
|
|
42138
|
+
lines.push(i === state.suggestion ? ` ${t.accent(name)}${s.summary}` : t.dim(` ${name}${s.summary}`));
|
|
42139
|
+
});
|
|
42140
|
+
} else {
|
|
42141
|
+
const left = view.armed ? t.yellow("Press Ctrl-C again to exit") : t.dim(view.hint);
|
|
42142
|
+
const gap = width - 2 - visibleWidth(left) - visibleWidth(view.status);
|
|
42143
|
+
lines.push(gap >= 2 ? ` ${left}${" ".repeat(gap)}${view.status}` : ` ${left}`);
|
|
42144
|
+
}
|
|
42145
|
+
return {
|
|
42146
|
+
lines,
|
|
42147
|
+
cursor: {
|
|
42148
|
+
row: 1 + laid.row,
|
|
42149
|
+
col: 2 + laid.col
|
|
42150
|
+
}
|
|
42151
|
+
};
|
|
42152
|
+
}
|
|
42153
|
+
/**
|
|
42154
|
+
* Read one line. Resolves with it on Enter, or with `exit` on Ctrl-D or a
|
|
42155
|
+
* second Ctrl-C within two seconds on an empty line. The box is replaced by
|
|
42156
|
+
* a dim echo of what was typed, as Claude Code does.
|
|
42157
|
+
*/
|
|
42158
|
+
function readPrompt(opts) {
|
|
42159
|
+
stopSpinner();
|
|
42160
|
+
const t = opts.theme ?? theme();
|
|
42161
|
+
const out = process.stdout;
|
|
42162
|
+
return new Promise((resolve) => {
|
|
42163
|
+
const region = new LiveRegion(out);
|
|
42164
|
+
let state = initialEditor(opts.history);
|
|
42165
|
+
let armedUntil = 0;
|
|
42166
|
+
let armTimer;
|
|
42167
|
+
const draw = () => {
|
|
42168
|
+
const { lines, cursor } = renderPrompt({
|
|
42169
|
+
state,
|
|
42170
|
+
suggestions: visibleSuggestions(state, opts.commands),
|
|
42171
|
+
placeholder: opts.placeholder,
|
|
42172
|
+
hint: opts.hint,
|
|
42173
|
+
status: opts.status(),
|
|
42174
|
+
armed: Date.now() < armedUntil
|
|
42175
|
+
}, t, frameWidth(out.columns));
|
|
42176
|
+
region.render(lines, cursor);
|
|
42177
|
+
};
|
|
42178
|
+
const tick = setInterval(draw, 3e4);
|
|
42179
|
+
tick.unref();
|
|
42180
|
+
out.on("resize", draw);
|
|
42181
|
+
const finish = (result) => {
|
|
42182
|
+
release();
|
|
42183
|
+
clearInterval(tick);
|
|
42184
|
+
clearTimeout(armTimer);
|
|
42185
|
+
out.off("resize", draw);
|
|
42186
|
+
if (result.kind === "line" && result.value.trim()) {
|
|
42187
|
+
region.render([t.dim(`> ${result.value}`)]);
|
|
42188
|
+
region.done();
|
|
42189
|
+
} else region.clear();
|
|
42190
|
+
resolve(result);
|
|
42191
|
+
};
|
|
42192
|
+
const release = listenKeys((key) => {
|
|
42193
|
+
const r = editorReducer(state, key, opts.commands);
|
|
42194
|
+
state = r.state;
|
|
42195
|
+
if (r.event === "submit") return finish({
|
|
42196
|
+
kind: "line",
|
|
42197
|
+
value: state.value
|
|
42198
|
+
});
|
|
42199
|
+
if (r.event === "eof") return finish({ kind: "exit" });
|
|
42200
|
+
if (r.event === "interrupt") {
|
|
42201
|
+
if (Date.now() < armedUntil) return finish({ kind: "exit" });
|
|
42202
|
+
armedUntil = Date.now() + ARM_MS;
|
|
42203
|
+
clearTimeout(armTimer);
|
|
42204
|
+
armTimer = setTimeout(draw, 2050);
|
|
42205
|
+
armTimer.unref();
|
|
42206
|
+
}
|
|
42207
|
+
draw();
|
|
42208
|
+
});
|
|
42209
|
+
draw();
|
|
42210
|
+
});
|
|
42211
|
+
}
|
|
42212
|
+
//#endregion
|
|
42213
|
+
//#region src/tui/select.ts
|
|
42214
|
+
function initialSelect(choices, start = 0) {
|
|
42215
|
+
const ok = (i) => i >= 0 && i < choices.length && !choices[i].disabled;
|
|
42216
|
+
const index = ok(start) ? start : choices.findIndex((c) => !c.disabled);
|
|
42217
|
+
return {
|
|
42218
|
+
index: Math.max(0, index),
|
|
42219
|
+
done: null
|
|
42220
|
+
};
|
|
42221
|
+
}
|
|
42222
|
+
function step(index, dir, choices) {
|
|
42223
|
+
const n = choices.length;
|
|
42224
|
+
for (let k = 1; k <= n; k++) {
|
|
42225
|
+
const i = (index + dir * k + n * k) % n;
|
|
42226
|
+
if (!choices[i].disabled) return i;
|
|
42227
|
+
}
|
|
42228
|
+
return index;
|
|
42229
|
+
}
|
|
42230
|
+
function selectReducer(state, key, choices) {
|
|
42231
|
+
if (state.done) return state;
|
|
42232
|
+
if (key.name === "escape" || key.ctrl && key.name === "c") return {
|
|
42233
|
+
...state,
|
|
42234
|
+
done: "cancelled"
|
|
42235
|
+
};
|
|
42236
|
+
if (key.name === "return" || key.name === "enter") return choices[state.index]?.disabled ? state : {
|
|
42237
|
+
...state,
|
|
42238
|
+
done: "chosen"
|
|
42239
|
+
};
|
|
42240
|
+
if (key.name === "up" || key.text === "k" || key.ctrl && key.name === "p") return {
|
|
42241
|
+
...state,
|
|
42242
|
+
index: step(state.index, -1, choices)
|
|
42243
|
+
};
|
|
42244
|
+
if (key.name === "down" || key.text === "j" || key.name === "tab" || key.ctrl && key.name === "n") return {
|
|
42245
|
+
...state,
|
|
42246
|
+
index: step(state.index, 1, choices)
|
|
42247
|
+
};
|
|
42248
|
+
if (key.text && /^[1-9]$/.test(key.text)) {
|
|
42249
|
+
const i = Number(key.text) - 1;
|
|
42250
|
+
if (i < choices.length && !choices[i].disabled) return {
|
|
42251
|
+
index: i,
|
|
42252
|
+
done: "chosen"
|
|
42253
|
+
};
|
|
42254
|
+
}
|
|
42255
|
+
return state;
|
|
42256
|
+
}
|
|
42257
|
+
/** The menu as lines, inside its box, plus the key hint under it. */
|
|
42258
|
+
function renderSelect(state, view, t, width, maxVisible = 12) {
|
|
42259
|
+
const inner = width - 4;
|
|
42260
|
+
const lines = [t.bold(view.title)];
|
|
42261
|
+
if (view.body && view.body.length > 0) {
|
|
42262
|
+
lines.push("");
|
|
42263
|
+
for (const b of view.body) lines.push(...wrap(b, inner));
|
|
42264
|
+
}
|
|
42265
|
+
lines.push("");
|
|
42266
|
+
const labelWidth = Math.min(Math.max(...view.choices.map((c) => visibleWidth(c.label))), Math.floor(inner * .6));
|
|
42267
|
+
const n = view.choices.length;
|
|
42268
|
+
const first = Math.max(0, Math.min(state.index - Math.floor(maxVisible / 2), n - maxVisible));
|
|
42269
|
+
const last = Math.min(n, first + maxVisible);
|
|
42270
|
+
if (first > 0) lines.push(t.dim(` ↑ ${first} more`));
|
|
42271
|
+
for (let i = first; i < last; i++) {
|
|
42272
|
+
const c = view.choices[i];
|
|
42273
|
+
const on = i === state.index;
|
|
42274
|
+
const num = `${i + 1}.`;
|
|
42275
|
+
const plain = sanitize(c.label);
|
|
42276
|
+
const hint = c.hint ? sanitize(c.hint) : "";
|
|
42277
|
+
const label = hint ? padEnd(plain, labelWidth) : plain;
|
|
42278
|
+
const text = `${num} ${label}${hint ? ` ${t.dim(hint)}` : ""}`;
|
|
42279
|
+
if (c.disabled) lines.push(t.dim(` ${num} ${label}${hint ? ` ${hint}` : ""}`));
|
|
42280
|
+
else if (on) lines.push(`${t.accent(t.g.pointer)} ${t.bold(text)}`);
|
|
42281
|
+
else lines.push(` ${text}`);
|
|
42282
|
+
}
|
|
42283
|
+
if (last < n) lines.push(t.dim(` ↓ ${n - last} more`));
|
|
42284
|
+
return [...renderBox(lines, {
|
|
42285
|
+
width,
|
|
42286
|
+
theme: t,
|
|
42287
|
+
border: t.accent
|
|
42288
|
+
}), t.dim(" ↑↓ to move · enter to choose · esc to go back")];
|
|
42289
|
+
}
|
|
42290
|
+
/**
|
|
42291
|
+
* Ask, and resolve with the chosen value, or null if the person backed out.
|
|
42292
|
+
* The menu is replaced by a one-line record of the answer when it closes.
|
|
42293
|
+
*/
|
|
42294
|
+
function select(view, opts = {}) {
|
|
42295
|
+
stopSpinner();
|
|
42296
|
+
const t = opts.theme ?? theme();
|
|
42297
|
+
const out = process.stdout;
|
|
42298
|
+
return new Promise((resolve) => {
|
|
42299
|
+
const region = new LiveRegion(out);
|
|
42300
|
+
let state = initialSelect(view.choices, opts.start);
|
|
42301
|
+
const draw = () => region.render(renderSelect(state, view, t, frameWidth(out.columns), Math.max(4, (out.rows || 24) - 14)));
|
|
42302
|
+
out.write(`${CSI}?25l`);
|
|
42303
|
+
draw();
|
|
42304
|
+
out.on("resize", draw);
|
|
42305
|
+
const release = listenKeys((key) => {
|
|
42306
|
+
state = selectReducer(state, key, view.choices);
|
|
42307
|
+
if (!state.done) return draw();
|
|
42308
|
+
release();
|
|
42309
|
+
out.off("resize", draw);
|
|
42310
|
+
out.write(`${CSI}?25h`);
|
|
42311
|
+
const chosen = state.done === "chosen" ? view.choices[state.index] : null;
|
|
42312
|
+
region.render([`${t.dim(view.title)} ${t.accent(t.g.pointer)} ${chosen ? sanitize(chosen.label) : t.dim("back")}`]);
|
|
42313
|
+
region.done();
|
|
42314
|
+
resolve(chosen ? chosen.value : null);
|
|
42315
|
+
});
|
|
42316
|
+
});
|
|
42317
|
+
}
|
|
42318
|
+
/** Yes/No in the same style; resolves false on Esc. */
|
|
42319
|
+
async function confirm(title, body, yes = "Yes", no = "No") {
|
|
42320
|
+
return await select({
|
|
42321
|
+
title,
|
|
42322
|
+
body,
|
|
42323
|
+
choices: [{
|
|
42324
|
+
label: yes,
|
|
42325
|
+
value: true
|
|
42326
|
+
}, {
|
|
42327
|
+
label: no,
|
|
42328
|
+
value: false
|
|
42329
|
+
}]
|
|
42330
|
+
}) === true;
|
|
42331
|
+
}
|
|
42332
|
+
//#endregion
|
|
42333
|
+
//#region ../../src/lib/session/liveCost.ts
|
|
42334
|
+
function computeLiveCost(i) {
|
|
42335
|
+
const start = new Date(i.startTime).getTime();
|
|
42336
|
+
const end = i.endTime ? new Date(i.endTime).getTime() : i.now;
|
|
42337
|
+
const elapsedMs = Math.max(end - start, 0);
|
|
42338
|
+
const minutes = elapsedMs / 6e4;
|
|
42339
|
+
const retained = 1 - Math.min(Math.max(i.discountPct ?? 0, 0), 100) / 100;
|
|
42340
|
+
const effectiveRate = i.ratePerMinute * retained;
|
|
42341
|
+
return {
|
|
42342
|
+
elapsedMs,
|
|
42343
|
+
effectiveRate,
|
|
42344
|
+
cost: i.free ? 0 : minutes * effectiveRate,
|
|
42345
|
+
ended: Boolean(i.endTime)
|
|
42346
|
+
};
|
|
42347
|
+
}
|
|
42348
|
+
//#endregion
|
|
42349
|
+
//#region ../../src/features/ActiveSessionsFeature/ActiveSessionsLogic.ts
|
|
42350
|
+
/**
|
|
42351
|
+
* A dedicated-slot holder on their reserved device pays nothing. `slotDeviceId`
|
|
42352
|
+
* is null unless the subscription is active on that tier, so the comparison
|
|
42353
|
+
* alone is the whole rule.
|
|
42354
|
+
*/
|
|
42355
|
+
function isFreeSession(session, slotDeviceId) {
|
|
42356
|
+
return slotDeviceId !== null && session.deviceId === slotDeviceId;
|
|
42357
|
+
}
|
|
42358
|
+
//#endregion
|
|
42359
|
+
//#region ../../src/lib/ai/credits.ts
|
|
42360
|
+
/**
|
|
42361
|
+
* Monthly AI credit grant per subscription tier, applied on `invoice.paid`.
|
|
42362
|
+
*
|
|
42363
|
+
* Lives HERE rather than in pricing.ts, which is `import "server-only"`, because
|
|
42364
|
+
* /pricing and /deviceyai both render these numbers — /deviceyai has printed
|
|
42365
|
+
* them since it shipped. They are not confidential the way MARKUP is; they are
|
|
42366
|
+
* the offer.
|
|
42367
|
+
*
|
|
42368
|
+
* Moved 2026-09-19 so `tiers.ts` could derive its feature bullets from this
|
|
42369
|
+
* object instead of retyping the figures. It had one for about an hour, and the
|
|
42370
|
+
* retyped Pro bullet was already wrong — it claimed "the largest monthly
|
|
42371
|
+
* allowance" for 1,000 while a slot and enterprise get 2,000. A derived bullet
|
|
42372
|
+
* cannot be wrong about a number; a typed one is only ever right until someone
|
|
42373
|
+
* edits the other copy.
|
|
42374
|
+
*/
|
|
42375
|
+
const AI_MONTHLY_GRANT = {
|
|
42376
|
+
free: 0,
|
|
42377
|
+
plus: 300,
|
|
42378
|
+
pro: 1e3,
|
|
42379
|
+
dedicated_slot: 2e3,
|
|
42380
|
+
enterprise: 2e3
|
|
42381
|
+
};
|
|
42382
|
+
//#endregion
|
|
42383
|
+
//#region ../../src/lib/tiers.ts
|
|
42384
|
+
/**
|
|
42385
|
+
* The DeviceyAI bullet for a tier, derived from the grant the Stripe webhook
|
|
42386
|
+
* actually makes rather than retyped beside it.
|
|
42387
|
+
*
|
|
42388
|
+
* Retyping lasted about an hour on 2026-09-19 before it was wrong: the Pro
|
|
42389
|
+
* bullet read "the largest monthly DeviceyAI allowance" for 1,000 credits while
|
|
42390
|
+
* a dedicated slot and enterprise each get 2,000. Superlatives in a feature list
|
|
42391
|
+
* are a comparison against the other rows, and nothing was checking it.
|
|
42392
|
+
*/
|
|
42393
|
+
function aiGrantFeature(tier) {
|
|
42394
|
+
return `${(AI_MONTHLY_GRANT[tier] ?? 0).toLocaleString("en-GB")} AI credits a month for DeviceyAI`;
|
|
42395
|
+
}
|
|
42396
|
+
/** "€549.89" / "€1,099.89" — euros with a thousands separator. */
|
|
42397
|
+
function formatEur(euros) {
|
|
42398
|
+
const [whole, frac] = Math.abs(euros).toFixed(2).split(".");
|
|
42399
|
+
const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
42400
|
+
return `${euros < 0 ? "−" : ""}€${grouped}.${frac}`;
|
|
42401
|
+
}
|
|
42402
|
+
const TIERS = [
|
|
42403
|
+
{
|
|
42404
|
+
id: "free",
|
|
42405
|
+
name: "Free",
|
|
42406
|
+
tagline: "Pay-as-you-go for hobby projects",
|
|
42407
|
+
monthlyPrice: 0,
|
|
42408
|
+
priceLabel: "€0",
|
|
42409
|
+
monthlyCredits: 0,
|
|
42410
|
+
discountPct: 0,
|
|
42411
|
+
features: [
|
|
42412
|
+
"€0.10 per device-minute — the standard rate",
|
|
42413
|
+
"€10 starter credit on signup",
|
|
42414
|
+
"Automation API keys — Appium, Espresso & CI",
|
|
42415
|
+
"Hosted Appium endpoint",
|
|
42416
|
+
`DeviceyAI copilot — 25 credits to try it, once`,
|
|
42417
|
+
"WebUSB local-device pairing"
|
|
42418
|
+
]
|
|
42419
|
+
},
|
|
42420
|
+
{
|
|
42421
|
+
id: "plus",
|
|
42422
|
+
name: "Plus",
|
|
42423
|
+
tagline: "For solo devs shipping a side project",
|
|
42424
|
+
monthlyPrice: 29.99,
|
|
42425
|
+
priceLabel: "€29.99",
|
|
42426
|
+
monthlyCredits: 35,
|
|
42427
|
+
discountPct: 40,
|
|
42428
|
+
features: [
|
|
42429
|
+
"€0.06 per device-minute (40% off)",
|
|
42430
|
+
"€35 in monthly credits",
|
|
42431
|
+
aiGrantFeature("plus"),
|
|
42432
|
+
"12 months for the price of 11 on yearly billing",
|
|
42433
|
+
"Email support"
|
|
42434
|
+
],
|
|
42435
|
+
priceIdEnv: "STRIPE_PRICE_PLUS",
|
|
42436
|
+
yearlyPriceIdEnv: "STRIPE_PRICE_PLUS_YEARLY"
|
|
42437
|
+
},
|
|
42438
|
+
{
|
|
42439
|
+
id: "pro",
|
|
42440
|
+
name: "Pro",
|
|
42441
|
+
tagline: "For teams running CI on real hardware",
|
|
42442
|
+
monthlyPrice: 49.99,
|
|
42443
|
+
priceLabel: "€49.99",
|
|
42444
|
+
monthlyCredits: 80,
|
|
42445
|
+
discountPct: 90,
|
|
42446
|
+
features: [
|
|
42447
|
+
"€0.01 per device-minute (90% off) — about €15/day of nonstop use",
|
|
42448
|
+
"€80 in monthly credits",
|
|
42449
|
+
aiGrantFeature("pro"),
|
|
42450
|
+
"12 months for the price of 11 on yearly billing",
|
|
42451
|
+
"Slack support · 1 business day"
|
|
42452
|
+
],
|
|
42453
|
+
badge: "Recommended",
|
|
42454
|
+
highlight: true,
|
|
42455
|
+
priceIdEnv: "STRIPE_PRICE_PRO",
|
|
42456
|
+
yearlyPriceIdEnv: "STRIPE_PRICE_PRO_YEARLY"
|
|
42457
|
+
},
|
|
42458
|
+
{
|
|
42459
|
+
id: "dedicated_slot",
|
|
42460
|
+
name: "Rent a Slot",
|
|
42461
|
+
tagline: "Your own device, 24/7, never billed by the minute",
|
|
42462
|
+
monthlyPrice: 99.99,
|
|
42463
|
+
priceLabel: "€99.99",
|
|
42464
|
+
monthlyCredits: 0,
|
|
42465
|
+
discountPct: 100,
|
|
42466
|
+
features: [
|
|
42467
|
+
"No per-minute charge at all — the meter is off",
|
|
42468
|
+
"A dedicated device, auto-assigned & reserved to you",
|
|
42469
|
+
"A permanently-paired device — automation with no rental to start",
|
|
42470
|
+
aiGrantFeature("dedicated_slot"),
|
|
42471
|
+
"24/7 availability — nobody else can rent it",
|
|
42472
|
+
"Priority hardware swap on failure"
|
|
42473
|
+
],
|
|
42474
|
+
priceIdEnv: "STRIPE_PRICE_SLOT",
|
|
42475
|
+
yearlyPriceIdEnv: "STRIPE_PRICE_SLOT_YEARLY"
|
|
42476
|
+
},
|
|
42477
|
+
{
|
|
42478
|
+
id: "enterprise",
|
|
42479
|
+
name: "Enterprise",
|
|
42480
|
+
tagline: "Whitelabel the fleet for your org",
|
|
42481
|
+
monthlyPrice: null,
|
|
42482
|
+
priceLabel: "Custom",
|
|
42483
|
+
monthlyCredits: 0,
|
|
42484
|
+
discountPct: 0,
|
|
42485
|
+
features: [
|
|
42486
|
+
"Custom SLA + uptime guarantees",
|
|
42487
|
+
aiGrantFeature("enterprise"),
|
|
42488
|
+
"Whitelabeled device fleet",
|
|
42489
|
+
"Dedicated account manager",
|
|
42490
|
+
"On-prem racks available"
|
|
42491
|
+
],
|
|
42492
|
+
contactSales: true
|
|
42493
|
+
}
|
|
42494
|
+
];
|
|
42495
|
+
function tierById(id) {
|
|
42496
|
+
return TIERS.find((t) => t.id === id) ?? TIERS[0];
|
|
42497
|
+
}
|
|
42498
|
+
/**
|
|
42499
|
+
* The discount the meter will actually apply: the tier's advertised
|
|
42500
|
+
* percentage, but only while the subscription is `active`. That is the
|
|
42501
|
+
* condition consume_credits checks at charge time, so a past_due Plus
|
|
42502
|
+
* customer is billed list price and must be quoted list price — every
|
|
42503
|
+
* surface that shows a rate (session ticker, multi-rent bar, active-sessions
|
|
42504
|
+
* panel) goes through this one rule rather than restating it.
|
|
42505
|
+
*/
|
|
42506
|
+
function activeDiscountPct(subscription) {
|
|
42507
|
+
return subscription.status === "active" ? tierById(subscription.tier).discountPct : 0;
|
|
42508
|
+
}
|
|
42509
|
+
function safeTierById(id) {
|
|
42510
|
+
if (!id) return null;
|
|
42511
|
+
const found = TIERS.find((t) => t.id === id);
|
|
42512
|
+
return found ? found.id : null;
|
|
42513
|
+
}
|
|
42514
|
+
//#endregion
|
|
42515
|
+
//#region src/interactive/account.ts
|
|
42516
|
+
async function loadAccount(ctx, user) {
|
|
42517
|
+
const [profile, sub] = await Promise.all([loadProfile(ctx, user.id), ctx.supabase.rpc("my_subscription").maybeSingle()]);
|
|
42518
|
+
const row = sub.data ?? null;
|
|
42519
|
+
const tier = safeTierById(row?.tier ?? profile.tier) ?? "free";
|
|
42520
|
+
const status = row?.status ?? profile.subscriptionStatus;
|
|
42521
|
+
return {
|
|
42522
|
+
userId: user.id,
|
|
42523
|
+
email: user.email ?? "",
|
|
42524
|
+
wallet: profile.wallet,
|
|
42525
|
+
aiCredits: profile.aiCredits,
|
|
42526
|
+
planName: tierById(tier).name,
|
|
42527
|
+
standing: profile.standing,
|
|
42528
|
+
discountPct: activeDiscountPct({
|
|
42529
|
+
status,
|
|
42530
|
+
tier
|
|
42531
|
+
}),
|
|
42532
|
+
slotDeviceId: status === "active" && tier === "dedicated_slot" ? row?.dedicated_device_id ?? null : null
|
|
42533
|
+
};
|
|
42534
|
+
}
|
|
42535
|
+
/** € a minute this account actually pays for a phone at `ratePerMinute`. */
|
|
42536
|
+
function yourRate(account, device) {
|
|
42537
|
+
if (account.slotDeviceId === device.id) return 0;
|
|
42538
|
+
return computeLiveCost({
|
|
42539
|
+
startTime: (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
42540
|
+
ratePerMinute: device.ratePerMinute,
|
|
42541
|
+
discountPct: account.discountPct,
|
|
42542
|
+
now: 0
|
|
42543
|
+
}).effectiveRate;
|
|
42544
|
+
}
|
|
42545
|
+
/** "€0.01/min" — or "included" on a dedicated slot. */
|
|
42546
|
+
function formatRate(rate) {
|
|
42547
|
+
if (rate === 0) return "included in your plan";
|
|
42548
|
+
return Math.round(rate * 1e3) < 10 ? `€${rate.toFixed(3)}/min` : `${eur(rate)}/min`;
|
|
42549
|
+
}
|
|
42550
|
+
/** The catalogue stores "Android 17" (or, on older rows, just "17"). */
|
|
42551
|
+
function androidLabel(version) {
|
|
42552
|
+
const v = version.trim();
|
|
42553
|
+
return /^android\b/i.test(v) ? v : `Android ${v}`;
|
|
42554
|
+
}
|
|
42555
|
+
/**
|
|
42556
|
+
* "● Pixel 7a #2 · 12 min · €0.12" for each running rental — computed from
|
|
42557
|
+
* the start time on every draw, with no network call.
|
|
42558
|
+
*/
|
|
42559
|
+
function statusLine(rentals, account, now, t) {
|
|
42560
|
+
if (!account) return t.dim("not signed in");
|
|
42561
|
+
if (rentals.length === 0) return t.dim("no phone rented");
|
|
42562
|
+
return rentals.map((r) => {
|
|
42563
|
+
const free = isFreeSession(r, account.slotDeviceId);
|
|
42564
|
+
const live = computeLiveCost({
|
|
42565
|
+
startTime: r.startTime,
|
|
42566
|
+
ratePerMinute: r.ratePerMinute,
|
|
42567
|
+
discountPct: account.discountPct,
|
|
42568
|
+
free,
|
|
42569
|
+
now
|
|
42570
|
+
});
|
|
42571
|
+
const minutes = Math.floor(live.elapsedMs / 6e4);
|
|
42572
|
+
return `${t.accent(t.g.bullet)} ${r.deviceName} ${t.dim(`· ${minutes} min · ${free ? "included" : eur(live.cost)}`)}`;
|
|
42573
|
+
}).join(" ");
|
|
42574
|
+
}
|
|
42575
|
+
//#endregion
|
|
42576
|
+
//#region ../../src/lib/ai/pricing.ts
|
|
42577
|
+
const AI_CREDIT_PACKS = [
|
|
42578
|
+
{
|
|
42579
|
+
id: "s",
|
|
42580
|
+
label: "Starter",
|
|
42581
|
+
priceEur: 5,
|
|
42582
|
+
credits: 500,
|
|
42583
|
+
priceIdEnv: "STRIPE_PRICE_AI_PACK_S"
|
|
42584
|
+
},
|
|
42585
|
+
{
|
|
42586
|
+
id: "m",
|
|
42587
|
+
label: "Standard",
|
|
42588
|
+
priceEur: 12,
|
|
42589
|
+
credits: 1500,
|
|
42590
|
+
priceIdEnv: "STRIPE_PRICE_AI_PACK_M"
|
|
42591
|
+
},
|
|
42592
|
+
{
|
|
42593
|
+
id: "l",
|
|
42594
|
+
label: "Bulk",
|
|
42595
|
+
priceEur: 30,
|
|
42596
|
+
credits: 4e3,
|
|
42597
|
+
priceIdEnv: "STRIPE_PRICE_AI_PACK_L"
|
|
42598
|
+
}
|
|
42599
|
+
];
|
|
42600
|
+
//#endregion
|
|
42601
|
+
//#region ../../src/lib/ai/toolBlurbs.ts
|
|
42602
|
+
const TOOL_BLURBS = {
|
|
42603
|
+
observe_screen: "Read the screen as a list of elements with exact positions",
|
|
42604
|
+
screenshot: "Take a picture of the screen and look at it",
|
|
42605
|
+
list_packages: "List the apps installed on the phone",
|
|
42606
|
+
device_info: "Read the model, Android version, screen size and orientation",
|
|
42607
|
+
wait: "Pause while something loads or animates",
|
|
42608
|
+
list_instrumentations: "Find the exact test-runner target installed on the phone",
|
|
42609
|
+
logcat_tail: "Read the tail of the device log, filtered to what matters",
|
|
42610
|
+
read_device_file: "Read a text file a test wrote — a JUnit XML or coverage report",
|
|
42611
|
+
tap: "Tap a point on the screen",
|
|
42612
|
+
swipe: "Swipe or scroll",
|
|
42613
|
+
type_text: "Type into the focused field",
|
|
42614
|
+
press_key: "Press Home, Back, Recents, Power, Volume, Enter or Delete",
|
|
42615
|
+
launch_app: "Open an installed app",
|
|
42616
|
+
install_apk: "Install an APK you uploaded to this rental",
|
|
42617
|
+
list_uploads: "See which of your files are available to install",
|
|
42618
|
+
run_instrumentation: "Run an instrumentation suite already on the phone and report pass/fail",
|
|
42619
|
+
save_test_file: "Keep a test file it wrote, for you to download",
|
|
42620
|
+
shell: "Run an ADB shell command",
|
|
42621
|
+
appium_start_session: "Open a real Appium (UiAutomator2) session on the phone",
|
|
42622
|
+
appium_page_source: "Read the UI hierarchy through that session",
|
|
42623
|
+
appium_find: "Prove a locator resolves on the live app before it goes into a test",
|
|
42624
|
+
appium_click: "Click an element it found, rather than a raw coordinate",
|
|
42625
|
+
appium_send_keys: "Type into an element it found",
|
|
42626
|
+
appium_end_session: "Close the session and release the phone"
|
|
42627
|
+
};
|
|
42628
|
+
[
|
|
42629
|
+
...READ_TOOLS,
|
|
42630
|
+
...ACTION_TOOLS,
|
|
42631
|
+
...SHELL_TOOLS,
|
|
42632
|
+
...APPIUM_TOOLS
|
|
42633
|
+
].map((t) => t.name);
|
|
42634
|
+
//#endregion
|
|
42635
|
+
//#region src/interactive/aiChat.ts
|
|
42636
|
+
const KEYS = {
|
|
42637
|
+
home: "Home",
|
|
42638
|
+
back: "Back",
|
|
42639
|
+
app_switch: "Recent apps",
|
|
42640
|
+
power: "Power",
|
|
42641
|
+
volume_up: "Volume up",
|
|
42642
|
+
volume_down: "Volume down",
|
|
42643
|
+
enter: "Enter",
|
|
42644
|
+
tab: "Tab",
|
|
42645
|
+
delete: "Delete"
|
|
42646
|
+
};
|
|
42647
|
+
/**
|
|
42648
|
+
* One line for one tool call. Typed text is NEVER shown — it may be a
|
|
42649
|
+
* password (mask the data, not a view of it) — only how long it is. The
|
|
42650
|
+
* model writes these inputs, so whatever is echoed is sanitized.
|
|
42651
|
+
*/
|
|
42652
|
+
function describeToolCall(name, input) {
|
|
42653
|
+
return sanitize(describe(name, input));
|
|
42654
|
+
}
|
|
42655
|
+
function describe(name, input) {
|
|
42656
|
+
const i = input ?? {};
|
|
42657
|
+
const n = (v) => typeof v === "number" ? Math.round(v) : "?";
|
|
42658
|
+
switch (name) {
|
|
42659
|
+
case "tap": return `Tap at ${n(i.x)}, ${n(i.y)}`;
|
|
42660
|
+
case "swipe": return `Swipe from ${n(i.x1)}, ${n(i.y1)} to ${n(i.x2)}, ${n(i.y2)}`;
|
|
42661
|
+
case "type_text": {
|
|
42662
|
+
const len = typeof i.text === "string" ? [...i.text].length : 0;
|
|
42663
|
+
return `Type ${len} character${len === 1 ? "" : "s"}`;
|
|
42664
|
+
}
|
|
42665
|
+
case "press_key": return `Press ${KEYS[String(i.key)] ?? String(i.key ?? "a key")}`;
|
|
42666
|
+
case "launch_app": return `Open ${typeof i.package === "string" ? i.package : "an app"}`;
|
|
42667
|
+
case "observe_screen": return "Read the screen";
|
|
42668
|
+
case "screenshot": return "Look at the screen";
|
|
42669
|
+
case "wait": return "Wait for the screen to settle";
|
|
42670
|
+
case "shell": return typeof i.command === "string" ? `Run: ${i.command.slice(0, 80)}` : "Run a shell command";
|
|
42671
|
+
default: return TOOL_BLURBS[name] ?? name;
|
|
42672
|
+
}
|
|
42673
|
+
}
|
|
42674
|
+
/**
|
|
42675
|
+
* Run one task. Throws AiNeedsActionsError (src/ai/loop.ts) when look-only
|
|
42676
|
+
* access was not enough, so the caller can ask for more and try again.
|
|
42677
|
+
*/
|
|
42678
|
+
async function runChatTask(input, task, permission, t) {
|
|
42679
|
+
return withDevice(input, async (device) => {
|
|
42680
|
+
const { models, credits } = await loadAiConfig(device.rental.id);
|
|
42681
|
+
if (credits <= 0) throw new CliError(OUT_OF_CREDITS);
|
|
42682
|
+
const model = pickModel(models, void 0);
|
|
42683
|
+
const write = (s) => process.stdout.write(s);
|
|
42684
|
+
let inText = false;
|
|
42685
|
+
const endText = () => {
|
|
42686
|
+
if (inText) write("\n");
|
|
42687
|
+
inText = false;
|
|
42688
|
+
};
|
|
42689
|
+
const controller = new AbortController();
|
|
42690
|
+
const abort = () => controller.abort();
|
|
42691
|
+
input.signal?.addEventListener("abort", abort, { once: true });
|
|
42692
|
+
try {
|
|
42693
|
+
const result = await runAiTask(device, {
|
|
42694
|
+
task,
|
|
42695
|
+
provider: model.provider,
|
|
42696
|
+
model: model.id,
|
|
42697
|
+
allowActions: permission === "act",
|
|
42698
|
+
allowShell: false
|
|
42699
|
+
}, controller.signal, {
|
|
42700
|
+
onText: (delta) => {
|
|
42701
|
+
const text = sanitize(delta);
|
|
42702
|
+
if (!text) return;
|
|
42703
|
+
if (!inText) write(`${t.accent(t.g.dot)} `);
|
|
42704
|
+
inText = true;
|
|
42705
|
+
write(text.replace(/\n/g, "\n "));
|
|
42706
|
+
},
|
|
42707
|
+
onTool: (name, toolInput) => {
|
|
42708
|
+
endText();
|
|
42709
|
+
write(`${t.accent(t.g.dot)} ${describeToolCall(name, toolInput)}\n`);
|
|
42710
|
+
},
|
|
42711
|
+
onToolDone: (_name, isError, content) => {
|
|
42712
|
+
if (isError) write(` ${t.dim(t.g.elbow)} ${t.red(sanitize(content.split("\n")[0]).slice(0, 200))}\n`);
|
|
42713
|
+
}
|
|
42714
|
+
});
|
|
42715
|
+
endText();
|
|
42716
|
+
return result;
|
|
42717
|
+
} catch (err) {
|
|
42718
|
+
endText();
|
|
42719
|
+
throw err;
|
|
42720
|
+
} finally {
|
|
42721
|
+
input.signal?.removeEventListener("abort", abort);
|
|
42722
|
+
}
|
|
42723
|
+
});
|
|
42724
|
+
}
|
|
42725
|
+
//#endregion
|
|
42726
|
+
//#region src/interactive/files.ts
|
|
42727
|
+
let folders;
|
|
42728
|
+
function existing(p) {
|
|
42729
|
+
if (!p) return null;
|
|
42730
|
+
try {
|
|
42731
|
+
return fs.statSync(p).isDirectory() ? p : null;
|
|
42732
|
+
} catch {
|
|
42733
|
+
return null;
|
|
42734
|
+
}
|
|
42735
|
+
}
|
|
42736
|
+
function askWindows() {
|
|
42737
|
+
const script = "[Console]::OutputEncoding=[Text.Encoding]::UTF8;[Environment]::GetFolderPath('Desktop');(New-Object -ComObject Shell.Application).NameSpace('shell:Downloads').Self.Path";
|
|
42738
|
+
return new Promise((resolve) => {
|
|
42739
|
+
execFile(systemExe("WindowsPowerShell\\v1.0\\powershell.exe"), [
|
|
42740
|
+
"-NoProfile",
|
|
42741
|
+
"-NonInteractive",
|
|
42742
|
+
"-Command",
|
|
42743
|
+
script
|
|
42744
|
+
], {
|
|
42745
|
+
timeout: 5e3,
|
|
42746
|
+
windowsHide: true,
|
|
42747
|
+
encoding: "utf8"
|
|
42748
|
+
}, (err, stdout) => {
|
|
42749
|
+
const [desktop, downloads] = err ? [] : stdout.split(/\r?\n/).map((l) => l.trim());
|
|
42750
|
+
resolve({
|
|
42751
|
+
desktop: existing(desktop) ?? existing(path.join(os.homedir(), "Desktop")),
|
|
42752
|
+
downloads: existing(downloads) ?? existing(path.join(os.homedir(), "Downloads"))
|
|
42753
|
+
});
|
|
42754
|
+
});
|
|
42755
|
+
});
|
|
42756
|
+
}
|
|
42757
|
+
/** The person's Downloads and Desktop folders (looked up once, lazily). */
|
|
42758
|
+
function knownFolders() {
|
|
42759
|
+
folders ??= process.platform === "win32" ? askWindows() : Promise.resolve({
|
|
42760
|
+
downloads: existing(path.join(os.homedir(), "Downloads")),
|
|
42761
|
+
desktop: existing(path.join(os.homedir(), "Desktop"))
|
|
42762
|
+
});
|
|
42763
|
+
return folders;
|
|
42764
|
+
}
|
|
42765
|
+
/** The newest `.apk` files directly inside `dirs` (not their subfolders). */
|
|
42766
|
+
function findApks(dirs, limit = 8) {
|
|
42767
|
+
const seen = /* @__PURE__ */ new Set();
|
|
42768
|
+
const found = [];
|
|
42769
|
+
for (const dir of dirs) {
|
|
42770
|
+
if (!dir || seen.has(dir)) continue;
|
|
42771
|
+
seen.add(dir);
|
|
42772
|
+
let names;
|
|
42773
|
+
try {
|
|
42774
|
+
names = fs.readdirSync(dir);
|
|
42775
|
+
} catch {
|
|
42776
|
+
continue;
|
|
42777
|
+
}
|
|
42778
|
+
for (const name of names) {
|
|
42779
|
+
if (!name.toLowerCase().endsWith(".apk")) continue;
|
|
42780
|
+
const full = path.join(dir, name);
|
|
42781
|
+
try {
|
|
42782
|
+
const st = fs.statSync(full);
|
|
42783
|
+
if (st.isFile()) found.push({
|
|
42784
|
+
path: full,
|
|
42785
|
+
name,
|
|
42786
|
+
folder: dir,
|
|
42787
|
+
bytes: st.size,
|
|
42788
|
+
modified: st.mtimeMs
|
|
42789
|
+
});
|
|
42790
|
+
} catch {}
|
|
42791
|
+
}
|
|
42792
|
+
}
|
|
42793
|
+
return found.sort((a, b) => b.modified - a.modified).slice(0, limit);
|
|
42794
|
+
}
|
|
42795
|
+
/** "2 h ago", "3 d ago" — for the file picker. */
|
|
42796
|
+
function ago(ms, now) {
|
|
42797
|
+
const s = Math.max(0, Math.round((now - ms) / 1e3));
|
|
42798
|
+
if (s < 90) return "just now";
|
|
42799
|
+
const m = Math.round(s / 60);
|
|
42800
|
+
if (m < 90) return `${m} min ago`;
|
|
42801
|
+
const h = Math.round(m / 60);
|
|
42802
|
+
if (h < 36) return `${h} h ago`;
|
|
42803
|
+
return `${Math.round(h / 24)} d ago`;
|
|
42804
|
+
}
|
|
42805
|
+
/** A folder name a person recognises: "Downloads", not the whole path. */
|
|
42806
|
+
function folderLabel(dir, f, cwd) {
|
|
42807
|
+
if (dir === f.downloads) return "Downloads";
|
|
42808
|
+
if (dir === f.desktop) return "Desktop";
|
|
42809
|
+
if (dir === cwd) return "this folder";
|
|
42810
|
+
return dir;
|
|
42811
|
+
}
|
|
42812
|
+
//#endregion
|
|
42813
|
+
//#region src/interactive/tokenize.ts
|
|
42814
|
+
function tokenize(line, p) {
|
|
42815
|
+
const words = [];
|
|
42816
|
+
let word = "";
|
|
42817
|
+
let inWord = false;
|
|
42818
|
+
let quote = null;
|
|
42819
|
+
const escapes = p.platform !== "win32";
|
|
42820
|
+
for (let i = 0; i < line.length; i++) {
|
|
42821
|
+
const ch = line[i];
|
|
42822
|
+
if (quote) {
|
|
42823
|
+
if (ch === quote) quote = null;
|
|
42824
|
+
else if (escapes && quote === "\"" && ch === "\\" && i + 1 < line.length) word += line[++i];
|
|
42825
|
+
else word += ch;
|
|
42826
|
+
continue;
|
|
42827
|
+
}
|
|
42828
|
+
if (ch === "\"" || ch === "'") {
|
|
42829
|
+
quote = ch;
|
|
42830
|
+
inWord = true;
|
|
42831
|
+
continue;
|
|
42832
|
+
}
|
|
42833
|
+
if (escapes && ch === "\\" && i + 1 < line.length) {
|
|
42834
|
+
word += line[++i];
|
|
42835
|
+
inWord = true;
|
|
42836
|
+
continue;
|
|
42837
|
+
}
|
|
42838
|
+
if (/\s/.test(ch)) {
|
|
42839
|
+
if (inWord) words.push(word);
|
|
42840
|
+
word = "";
|
|
42841
|
+
inWord = false;
|
|
42842
|
+
continue;
|
|
42843
|
+
}
|
|
42844
|
+
word += ch;
|
|
42845
|
+
inWord = true;
|
|
42846
|
+
}
|
|
42847
|
+
if (inWord) words.push(word);
|
|
42848
|
+
return words.map((w) => expandHome(w, p));
|
|
42849
|
+
}
|
|
42850
|
+
function expandHome(word, p) {
|
|
42851
|
+
if (word === "~") return p.home;
|
|
42852
|
+
if (word.startsWith("~/") || p.platform === "win32" && word.startsWith("~\\")) return p.home + word.slice(1);
|
|
42853
|
+
return word;
|
|
42854
|
+
}
|
|
42855
|
+
function looksLikePath(w, p) {
|
|
42856
|
+
return (p.platform === "win32" ? /^[a-zA-Z]:[\\/]/.test(w) || w.startsWith("\\\\") || /[\\/]/.test(w) : w.startsWith("/") || w.startsWith("./") || w.startsWith("../") || w.includes("/")) || /\.apk$/i.test(w);
|
|
42857
|
+
}
|
|
42858
|
+
/**
|
|
42859
|
+
* The paths a line consists of, if it is nothing but (dragged or pasted)
|
|
42860
|
+
* paths: `"C:\My Apps\app.apk"`, `& 'C:\x\app.apk'`, `/Users/me/My\ App.apk`
|
|
42861
|
+
* — or several at once: dragging an app and its androidTest APK together
|
|
42862
|
+
* drops both. Null when the line is anything else.
|
|
42863
|
+
*/
|
|
42864
|
+
function droppedPaths(line, p) {
|
|
42865
|
+
let s = line.trim();
|
|
42866
|
+
if (s.startsWith("& ")) s = s.slice(2).trim();
|
|
42867
|
+
const words = tokenize(s, p);
|
|
42868
|
+
if (words.length === 0 || !words.every((w) => looksLikePath(w, p))) return null;
|
|
42869
|
+
return words;
|
|
42870
|
+
}
|
|
42871
|
+
/** The one path a line consists of (see droppedPaths), or null. */
|
|
42872
|
+
function droppedPath(line, p) {
|
|
42873
|
+
const paths = droppedPaths(line, p);
|
|
42874
|
+
return paths?.length === 1 ? paths[0] : null;
|
|
42875
|
+
}
|
|
42876
|
+
//#endregion
|
|
42877
|
+
//#region src/interactive/intent.ts
|
|
42878
|
+
/** Session-only commands: typed with or without the slash. */
|
|
42879
|
+
const SESSION_ACTIONS = {
|
|
42880
|
+
help: "help",
|
|
42881
|
+
exit: "exit",
|
|
42882
|
+
quit: "exit",
|
|
42883
|
+
clear: "clear",
|
|
42884
|
+
permissions: "permissions",
|
|
42885
|
+
phones: "phones",
|
|
42886
|
+
balance: "balance",
|
|
42887
|
+
plans: "plans"
|
|
42888
|
+
};
|
|
42889
|
+
/** Words that carry no meaning after a verb: "install THE APP ON MY PHONE". */
|
|
42890
|
+
const FILLER = new Set("a an the my me this that it its one please now phone device android app apk application rental renting session for to some another new current of screen on in up".split(" "));
|
|
42891
|
+
const PACKAGE_RE = /^[a-zA-Z][\w]*(\.[a-zA-Z][\w]*)+$/;
|
|
42892
|
+
/** "top up 20 euros": the currency, said as a word, after an amount. */
|
|
42893
|
+
const CURRENCY = /* @__PURE__ */ new Set([
|
|
42894
|
+
"€",
|
|
42895
|
+
"eur",
|
|
42896
|
+
"euro",
|
|
42897
|
+
"euros"
|
|
42898
|
+
]);
|
|
42899
|
+
/** `devicerent rent`, `npx devicerent@latest status` → `rent`, `status`. */
|
|
42900
|
+
const PROGRAM_NAME = /^(?:npx\s+)?devicerent(?:@\S*)?(?=\s|$)\s*/i;
|
|
42901
|
+
/** "20", "€20", "20€", "20eur", "20,50" → the number, or NaN. */
|
|
42902
|
+
function parseEuros(word) {
|
|
42903
|
+
return Number(word.replace(/^€|€$/g, "").replace(/(eur|euros?)$/i, "").replace(",", "."));
|
|
42904
|
+
}
|
|
42905
|
+
const VERBS = [
|
|
42906
|
+
[
|
|
42907
|
+
"help",
|
|
42908
|
+
"none",
|
|
42909
|
+
[
|
|
42910
|
+
"help",
|
|
42911
|
+
"?",
|
|
42912
|
+
"what can you do",
|
|
42913
|
+
"what can i do",
|
|
42914
|
+
"how does this work",
|
|
42915
|
+
"commands",
|
|
42916
|
+
"menu",
|
|
42917
|
+
"options"
|
|
42918
|
+
]
|
|
42919
|
+
],
|
|
42920
|
+
[
|
|
42921
|
+
"exit",
|
|
42922
|
+
"none",
|
|
42923
|
+
[
|
|
42924
|
+
"exit",
|
|
42925
|
+
"quit",
|
|
42926
|
+
"bye",
|
|
42927
|
+
"goodbye"
|
|
42928
|
+
]
|
|
42929
|
+
],
|
|
42930
|
+
[
|
|
42931
|
+
"clear",
|
|
42932
|
+
"none",
|
|
42933
|
+
["clear", "cls"]
|
|
42934
|
+
],
|
|
42935
|
+
[
|
|
42936
|
+
"rent",
|
|
42937
|
+
"query",
|
|
42938
|
+
[
|
|
42939
|
+
"rent",
|
|
42940
|
+
"hire",
|
|
42941
|
+
"borrow"
|
|
42942
|
+
]
|
|
42943
|
+
],
|
|
42944
|
+
[
|
|
42945
|
+
"stop",
|
|
42946
|
+
"all",
|
|
42947
|
+
[
|
|
42948
|
+
"stop",
|
|
42949
|
+
"end",
|
|
42950
|
+
"finish",
|
|
42951
|
+
"return",
|
|
42952
|
+
"release",
|
|
42953
|
+
"i'm done",
|
|
42954
|
+
"im done",
|
|
42955
|
+
"i am done",
|
|
42956
|
+
"done",
|
|
42957
|
+
"stop renting"
|
|
42958
|
+
]
|
|
42959
|
+
],
|
|
42960
|
+
[
|
|
42961
|
+
"install",
|
|
42962
|
+
"path",
|
|
42963
|
+
[
|
|
42964
|
+
"install",
|
|
42965
|
+
"upload",
|
|
42966
|
+
"add"
|
|
42967
|
+
]
|
|
42968
|
+
],
|
|
42969
|
+
[
|
|
42970
|
+
"screenshot",
|
|
42971
|
+
"none",
|
|
42972
|
+
[
|
|
42973
|
+
"screenshot",
|
|
42974
|
+
"screen shot",
|
|
42975
|
+
"take a screenshot",
|
|
42976
|
+
"take screenshot",
|
|
42977
|
+
"capture the screen",
|
|
42978
|
+
"capture screen",
|
|
42979
|
+
"take a picture",
|
|
42980
|
+
"picture"
|
|
42981
|
+
]
|
|
42982
|
+
],
|
|
42983
|
+
[
|
|
42984
|
+
"test",
|
|
42985
|
+
"target",
|
|
42986
|
+
[
|
|
42987
|
+
"test",
|
|
42988
|
+
"quick test",
|
|
42989
|
+
"smoke test",
|
|
42990
|
+
"check",
|
|
42991
|
+
"try"
|
|
42992
|
+
]
|
|
42993
|
+
],
|
|
42994
|
+
[
|
|
42995
|
+
"buy-credits",
|
|
42996
|
+
"none",
|
|
42997
|
+
[
|
|
42998
|
+
"buy credits",
|
|
42999
|
+
"buy ai credits",
|
|
43000
|
+
"buy deviceyai credits",
|
|
43001
|
+
"more credits",
|
|
43002
|
+
"credits buy"
|
|
43003
|
+
]
|
|
43004
|
+
],
|
|
43005
|
+
[
|
|
43006
|
+
"balance",
|
|
43007
|
+
"none",
|
|
43008
|
+
[
|
|
43009
|
+
"balance",
|
|
43010
|
+
"wallet",
|
|
43011
|
+
"money",
|
|
43012
|
+
"account",
|
|
43013
|
+
"whoami",
|
|
43014
|
+
"who am i",
|
|
43015
|
+
"my account",
|
|
43016
|
+
"my balance",
|
|
43017
|
+
"how much money do i have",
|
|
43018
|
+
"how much money",
|
|
43019
|
+
"credits",
|
|
43020
|
+
"ai credits"
|
|
43021
|
+
]
|
|
43022
|
+
],
|
|
43023
|
+
[
|
|
43024
|
+
"topup",
|
|
43025
|
+
"amount",
|
|
43026
|
+
[
|
|
43027
|
+
"top up",
|
|
43028
|
+
"topup",
|
|
43029
|
+
"add money",
|
|
43030
|
+
"add funds",
|
|
43031
|
+
"add credit",
|
|
43032
|
+
"recharge",
|
|
43033
|
+
"deposit"
|
|
43034
|
+
]
|
|
43035
|
+
],
|
|
43036
|
+
[
|
|
43037
|
+
"plans",
|
|
43038
|
+
"plan",
|
|
43039
|
+
[
|
|
43040
|
+
"plans",
|
|
43041
|
+
"plan",
|
|
43042
|
+
"pricing",
|
|
43043
|
+
"prices",
|
|
43044
|
+
"subscribe",
|
|
43045
|
+
"subscription",
|
|
43046
|
+
"upgrade"
|
|
43047
|
+
]
|
|
43048
|
+
],
|
|
43049
|
+
[
|
|
43050
|
+
"apps",
|
|
43051
|
+
"none",
|
|
43052
|
+
[
|
|
43053
|
+
"apps",
|
|
43054
|
+
"list apps",
|
|
43055
|
+
"installed apps",
|
|
43056
|
+
"show apps",
|
|
43057
|
+
"what's installed",
|
|
43058
|
+
"what is installed"
|
|
43059
|
+
]
|
|
43060
|
+
],
|
|
43061
|
+
[
|
|
43062
|
+
"phones",
|
|
43063
|
+
"none",
|
|
43064
|
+
[
|
|
43065
|
+
"phones",
|
|
43066
|
+
"devices",
|
|
43067
|
+
"list phones",
|
|
43068
|
+
"list devices",
|
|
43069
|
+
"show phones",
|
|
43070
|
+
"show devices",
|
|
43071
|
+
"show me the phones",
|
|
43072
|
+
"which phones",
|
|
43073
|
+
"what phones",
|
|
43074
|
+
"available phones",
|
|
43075
|
+
"available"
|
|
43076
|
+
]
|
|
43077
|
+
],
|
|
43078
|
+
[
|
|
43079
|
+
"status",
|
|
43080
|
+
"none",
|
|
43081
|
+
[
|
|
43082
|
+
"status",
|
|
43083
|
+
"what's running",
|
|
43084
|
+
"what is running",
|
|
43085
|
+
"rentals",
|
|
43086
|
+
"my rentals"
|
|
43087
|
+
]
|
|
43088
|
+
],
|
|
43089
|
+
[
|
|
43090
|
+
"login",
|
|
43091
|
+
"none",
|
|
43092
|
+
[
|
|
43093
|
+
"login",
|
|
43094
|
+
"log in",
|
|
43095
|
+
"sign in",
|
|
43096
|
+
"signin"
|
|
43097
|
+
]
|
|
43098
|
+
],
|
|
43099
|
+
[
|
|
43100
|
+
"logout",
|
|
43101
|
+
"none",
|
|
43102
|
+
[
|
|
43103
|
+
"logout",
|
|
43104
|
+
"log out",
|
|
43105
|
+
"sign out",
|
|
43106
|
+
"signout"
|
|
43107
|
+
]
|
|
43108
|
+
],
|
|
43109
|
+
[
|
|
43110
|
+
"launch",
|
|
43111
|
+
"package",
|
|
43112
|
+
[
|
|
43113
|
+
"launch",
|
|
43114
|
+
"open",
|
|
43115
|
+
"start",
|
|
43116
|
+
"run"
|
|
43117
|
+
]
|
|
43118
|
+
],
|
|
43119
|
+
[
|
|
43120
|
+
"uninstall",
|
|
43121
|
+
"package",
|
|
43122
|
+
[
|
|
43123
|
+
"uninstall",
|
|
43124
|
+
"remove",
|
|
43125
|
+
"delete"
|
|
43126
|
+
]
|
|
43127
|
+
],
|
|
43128
|
+
[
|
|
43129
|
+
"logs",
|
|
43130
|
+
"none",
|
|
43131
|
+
[
|
|
43132
|
+
"logs",
|
|
43133
|
+
"log",
|
|
43134
|
+
"logcat",
|
|
43135
|
+
"show logs",
|
|
43136
|
+
"show me the logs",
|
|
43137
|
+
"show the logs"
|
|
43138
|
+
]
|
|
43139
|
+
],
|
|
43140
|
+
[
|
|
43141
|
+
"permissions",
|
|
43142
|
+
"none",
|
|
43143
|
+
["permissions"]
|
|
43144
|
+
]
|
|
43145
|
+
];
|
|
43146
|
+
const RENT_PHRASE = /^(?:can i |could i |please )?(?:get|give me|i want|i need|i'd like|id like|book|start|grab)(?: me)?(?: (?:a|an|another|one|new))? (phone|device|android|pixel|galaxy|samsung)\b(.*)$/;
|
|
43147
|
+
function normalize(line) {
|
|
43148
|
+
return line.toLowerCase().replace(/[.!?,]+$/g, "").replace(/\s+/g, " ").trim();
|
|
43149
|
+
}
|
|
43150
|
+
/** Classify the words after a verb; null when they do not fit the action. */
|
|
43151
|
+
function fitArgs(mode, rest, env) {
|
|
43152
|
+
const meaningful = rest.filter((w) => !FILLER.has(w.toLowerCase()) && !(mode === "amount" && CURRENCY.has(w.toLowerCase())));
|
|
43153
|
+
if (mode === "query") return meaningful.length ? { arg: meaningful.join(" ") } : {};
|
|
43154
|
+
if (mode === "all" && rest.some((w) => /^(app|apk|application)s?$/i.test(w))) return null;
|
|
43155
|
+
if (meaningful.length === 0) return {};
|
|
43156
|
+
if (meaningful.length !== 1) return null;
|
|
43157
|
+
const w = meaningful[0];
|
|
43158
|
+
const lower = w.toLowerCase();
|
|
43159
|
+
switch (mode) {
|
|
43160
|
+
case "all": return lower === "all" || lower === "everything" ? { arg: "all" } : null;
|
|
43161
|
+
case "path": return env.fileExists(w) ? { arg: w } : null;
|
|
43162
|
+
case "package": return PACKAGE_RE.test(w) ? { arg: w } : null;
|
|
43163
|
+
case "target": return env.fileExists(w) || PACKAGE_RE.test(w) ? { arg: w } : null;
|
|
43164
|
+
case "amount": {
|
|
43165
|
+
const n = parseEuros(w);
|
|
43166
|
+
return Number.isFinite(n) && n > 0 ? { arg: String(n) } : null;
|
|
43167
|
+
}
|
|
43168
|
+
case "plan": return [
|
|
43169
|
+
"plus",
|
|
43170
|
+
"pro",
|
|
43171
|
+
"slot"
|
|
43172
|
+
].includes(lower) ? { arg: lower } : null;
|
|
43173
|
+
default: return null;
|
|
43174
|
+
}
|
|
43175
|
+
}
|
|
43176
|
+
/** What may follow a command's name for the line to count as that command. */
|
|
43177
|
+
function positionalsFit(name, rest, env) {
|
|
43178
|
+
const positionals = rest.filter((w, i) => !w.startsWith("-") && !(i > 0 && rest[i - 1].startsWith("--") && !rest[i - 1].includes("=")));
|
|
43179
|
+
if (positionals.length === 0) return true;
|
|
43180
|
+
switch (name) {
|
|
43181
|
+
case "shell":
|
|
43182
|
+
case "ai":
|
|
43183
|
+
case "keys create":
|
|
43184
|
+
case "keys revoke": return true;
|
|
43185
|
+
case "push": return env.fileExists(positionals[0]);
|
|
43186
|
+
case "pull": return positionals[0].startsWith("/");
|
|
43187
|
+
case "screenshot":
|
|
43188
|
+
case "install":
|
|
43189
|
+
case "test quick": return positionals.every((p) => env.fileExists(p) || PACKAGE_RE.test(p) || p.endsWith(".png"));
|
|
43190
|
+
case "launch":
|
|
43191
|
+
case "uninstall": return PACKAGE_RE.test(positionals[0]);
|
|
43192
|
+
case "topup": return Number.isFinite(Number(positionals[0]));
|
|
43193
|
+
case "subscribe": return [
|
|
43194
|
+
"plus",
|
|
43195
|
+
"pro",
|
|
43196
|
+
"slot"
|
|
43197
|
+
].includes(positionals[0]);
|
|
43198
|
+
case "credits": return positionals[0] === "buy";
|
|
43199
|
+
case "stop": return /^[0-9a-f][0-9a-f-]{7,}$/i.test(positionals[0]);
|
|
43200
|
+
default: return false;
|
|
43201
|
+
}
|
|
43202
|
+
}
|
|
43203
|
+
function matchCommand(words, names) {
|
|
43204
|
+
const byLength = [...names].sort((a, b) => b.split(" ").length - a.split(" ").length);
|
|
43205
|
+
for (const name of byLength) {
|
|
43206
|
+
const parts = name.split(" ");
|
|
43207
|
+
if (parts.every((p, i) => words[i] === p)) return {
|
|
43208
|
+
name,
|
|
43209
|
+
rest: words.slice(parts.length)
|
|
43210
|
+
};
|
|
43211
|
+
}
|
|
43212
|
+
return null;
|
|
43213
|
+
}
|
|
43214
|
+
/** Edit distance with adjacent swaps ("stauts" → "status" is 1). */
|
|
43215
|
+
function editDistance(a, b) {
|
|
43216
|
+
const d = Array.from({ length: a.length + 1 }, (_, i) => Array.from({ length: b.length + 1 }, (_, j) => i === 0 ? j : j === 0 ? i : 0));
|
|
43217
|
+
for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) {
|
|
43218
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
43219
|
+
d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost);
|
|
43220
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1);
|
|
43221
|
+
}
|
|
43222
|
+
return d[a.length][b.length];
|
|
43223
|
+
}
|
|
43224
|
+
/** The commands and actions a mistyped single word was probably meant to be. */
|
|
43225
|
+
function nearMisses(word, env) {
|
|
43226
|
+
const candidates = /* @__PURE__ */ new Set([
|
|
43227
|
+
...env.commandNames.filter((n) => !n.includes(" ")),
|
|
43228
|
+
...Object.keys(SESSION_ACTIONS),
|
|
43229
|
+
"rent",
|
|
43230
|
+
"stop",
|
|
43231
|
+
"install",
|
|
43232
|
+
"screenshot",
|
|
43233
|
+
"topup",
|
|
43234
|
+
"logs"
|
|
43235
|
+
]);
|
|
43236
|
+
const limit = word.length <= 4 ? 1 : 2;
|
|
43237
|
+
return [...candidates].map((c) => ({
|
|
43238
|
+
c,
|
|
43239
|
+
d: editDistance(word, c)
|
|
43240
|
+
})).filter((x) => x.d > 0 && x.d <= limit).sort((x, y) => x.d - y.d || x.c.localeCompare(y.c)).slice(0, 3).map((x) => x.c);
|
|
43241
|
+
}
|
|
43242
|
+
function interpret(line, env) {
|
|
43243
|
+
const typed = line.trim();
|
|
43244
|
+
if (!typed) return { kind: "empty" };
|
|
43245
|
+
const trimmed = typed.replace(PROGRAM_NAME, "");
|
|
43246
|
+
if (!trimmed || trimmed === "?") return {
|
|
43247
|
+
kind: "action",
|
|
43248
|
+
action: "help"
|
|
43249
|
+
};
|
|
43250
|
+
const dropped = droppedPaths(trimmed, env);
|
|
43251
|
+
if (dropped && dropped.every((p) => env.fileExists(p))) return {
|
|
43252
|
+
kind: "install-file",
|
|
43253
|
+
paths: dropped
|
|
43254
|
+
};
|
|
43255
|
+
if (trimmed.startsWith("/")) {
|
|
43256
|
+
const words = tokenize(trimmed.slice(1), env);
|
|
43257
|
+
if (words.length === 0) return {
|
|
43258
|
+
kind: "action",
|
|
43259
|
+
action: "help"
|
|
43260
|
+
};
|
|
43261
|
+
const session = SESSION_ACTIONS[words[0].toLowerCase()];
|
|
43262
|
+
if (session && words.length === 1) return {
|
|
43263
|
+
kind: "action",
|
|
43264
|
+
action: session
|
|
43265
|
+
};
|
|
43266
|
+
return {
|
|
43267
|
+
kind: "command",
|
|
43268
|
+
argv: words
|
|
43269
|
+
};
|
|
43270
|
+
}
|
|
43271
|
+
const norm = normalize(trimmed);
|
|
43272
|
+
const words = tokenize(trimmed, env);
|
|
43273
|
+
const normWords = norm.split(" ");
|
|
43274
|
+
const rent = RENT_PHRASE.exec(norm);
|
|
43275
|
+
if (rent) {
|
|
43276
|
+
const query = [rent[1], ...rent[2].trim().split(" ")].filter((w) => w && !FILLER.has(w)).join(" ");
|
|
43277
|
+
return {
|
|
43278
|
+
kind: "action",
|
|
43279
|
+
action: "rent",
|
|
43280
|
+
...query ? { arg: query } : {}
|
|
43281
|
+
};
|
|
43282
|
+
}
|
|
43283
|
+
for (const [action, mode, verbs] of VERBS) {
|
|
43284
|
+
const verb = [...verbs].sort((a, b) => b.length - a.length).find((v) => norm === v || norm.startsWith(`${v} `));
|
|
43285
|
+
if (!verb) continue;
|
|
43286
|
+
const fit = fitArgs(mode, words.slice(verb.split(" ").length), env);
|
|
43287
|
+
if (fit) return {
|
|
43288
|
+
kind: "action",
|
|
43289
|
+
action,
|
|
43290
|
+
...fit
|
|
43291
|
+
};
|
|
43292
|
+
}
|
|
43293
|
+
const found = matchCommand(words, env.commandNames);
|
|
43294
|
+
if (found && positionalsFit(found.name, found.rest, env)) return {
|
|
43295
|
+
kind: "command",
|
|
43296
|
+
argv: words
|
|
43297
|
+
};
|
|
43298
|
+
if (normWords.length === 1) {
|
|
43299
|
+
const near = nearMisses(normWords[0], env);
|
|
43300
|
+
return near.length ? {
|
|
43301
|
+
kind: "did-you-mean",
|
|
43302
|
+
options: near
|
|
43303
|
+
} : { kind: "unknown" };
|
|
43304
|
+
}
|
|
43305
|
+
return {
|
|
43306
|
+
kind: "ai",
|
|
43307
|
+
task: trimmed
|
|
43308
|
+
};
|
|
43309
|
+
}
|
|
43310
|
+
//#endregion
|
|
43311
|
+
//#region src/interactive/flows.ts
|
|
43312
|
+
const STATUS_WORDS = {
|
|
43313
|
+
available: "free",
|
|
43314
|
+
busy: "in use",
|
|
43315
|
+
sanitizing: "being wiped for the next person",
|
|
43316
|
+
offline: "offline",
|
|
43317
|
+
maintenance: "in maintenance"
|
|
43318
|
+
};
|
|
43319
|
+
const OTHER = "\0other";
|
|
43320
|
+
function slug(s) {
|
|
43321
|
+
return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
43322
|
+
}
|
|
43323
|
+
/** A line read at a plain prompt, or null if the person pressed Ctrl-C. */
|
|
43324
|
+
async function ask(question) {
|
|
43325
|
+
try {
|
|
43326
|
+
return await promptLine(question);
|
|
43327
|
+
} catch {
|
|
43328
|
+
return null;
|
|
43329
|
+
}
|
|
43330
|
+
}
|
|
43331
|
+
async function loginFlow(s) {
|
|
43332
|
+
if (s.account) {
|
|
43333
|
+
s.result(`You're signed in as ${s.account.email}.`);
|
|
43334
|
+
return;
|
|
43335
|
+
}
|
|
43336
|
+
if (!s.ctx.config.login_enabled) {
|
|
43337
|
+
s.error(`Signing in from a terminal is switched off on ${s.ctx.apiBase}.`);
|
|
43338
|
+
return;
|
|
43339
|
+
}
|
|
43340
|
+
const how = await select({
|
|
43341
|
+
title: "Sign in to DeviceRent",
|
|
43342
|
+
body: ["Your browser opens devicerent.net; press Approve there and come back. Nothing secret is typed here."],
|
|
43343
|
+
choices: [
|
|
43344
|
+
{
|
|
43345
|
+
label: "Sign in with my browser",
|
|
43346
|
+
value: "browser",
|
|
43347
|
+
hint: "recommended"
|
|
43348
|
+
},
|
|
43349
|
+
{
|
|
43350
|
+
label: "Sign in with a code",
|
|
43351
|
+
value: "code",
|
|
43352
|
+
hint: "when the browser is on another device"
|
|
43353
|
+
},
|
|
43354
|
+
{
|
|
43355
|
+
label: "Not now",
|
|
43356
|
+
value: null
|
|
43357
|
+
}
|
|
43358
|
+
]
|
|
43359
|
+
});
|
|
43360
|
+
if (!how) return;
|
|
43361
|
+
await s.run(how === "code" ? ["login", "--no-browser"] : ["login"], {
|
|
43362
|
+
title: "Signing in",
|
|
43363
|
+
raw: true
|
|
43364
|
+
});
|
|
43365
|
+
const now = signedIn(s);
|
|
43366
|
+
if (now) s.say(`Signed in as ${s.t.bold(now.email)}.`);
|
|
43367
|
+
}
|
|
43368
|
+
function signedIn(s) {
|
|
43369
|
+
return s.account;
|
|
43370
|
+
}
|
|
43371
|
+
async function ensureSignedIn(s) {
|
|
43372
|
+
if (s.account) return true;
|
|
43373
|
+
s.result("You need to sign in first.");
|
|
43374
|
+
await loginFlow(s);
|
|
43375
|
+
return s.account !== null;
|
|
43376
|
+
}
|
|
43377
|
+
async function logoutFlow(s) {
|
|
43378
|
+
if (!s.account) return s.result("You're not signed in.");
|
|
43379
|
+
if (await confirm("Sign this terminal out?", ["Your browser stays signed in. Rented phones keep running."], "Yes, sign out", "No")) await s.run(["logout"], { title: "Signing out" });
|
|
43380
|
+
}
|
|
43381
|
+
async function loadFleet(s) {
|
|
43382
|
+
return s.runTask(() => loadFleetDevices(s.ctx), {
|
|
43383
|
+
spinner: "Checking which phones are free",
|
|
43384
|
+
refresh: false
|
|
43385
|
+
});
|
|
43386
|
+
}
|
|
43387
|
+
async function phonesFlow(s) {
|
|
43388
|
+
if (!await ensureSignedIn(s)) return;
|
|
43389
|
+
const fleet = await loadFleet(s);
|
|
43390
|
+
if (!fleet) return;
|
|
43391
|
+
const account = s.account;
|
|
43392
|
+
const groups = groupFleet(fleet, new Set(s.rentals.map((r) => r.deviceId)));
|
|
43393
|
+
s.say(s.t.bold("Phones in the rack"));
|
|
43394
|
+
const width = Math.max(...groups.map((g) => g.label.length));
|
|
43395
|
+
groups.forEach((g, i) => {
|
|
43396
|
+
const state = g.free.length > 0 ? s.t.green(`${g.free.length} of ${g.total} free`) : s.t.dim(g.why);
|
|
43397
|
+
const rate = g.free.length > 0 ? ` ${s.t.dim(formatRate(yourRate(account, g.free[0])))}` : "";
|
|
43398
|
+
const line = `${g.label.padEnd(width)} ${state}${rate}`;
|
|
43399
|
+
if (i === 0) s.result(line);
|
|
43400
|
+
else s.write(` ${line}`);
|
|
43401
|
+
});
|
|
43402
|
+
s.hint("Type rent to rent one.");
|
|
43403
|
+
}
|
|
43404
|
+
async function rentFlow(s, query) {
|
|
43405
|
+
if (!await ensureSignedIn(s)) return;
|
|
43406
|
+
const fleet = await loadFleet(s);
|
|
43407
|
+
if (!fleet) return;
|
|
43408
|
+
const account = s.account;
|
|
43409
|
+
const mine = new Set(s.rentals.map((r) => r.deviceId));
|
|
43410
|
+
if (!fleet.some((d) => d.rentable && !mine.has(d.id))) {
|
|
43411
|
+
s.result("Every phone is busy right now. Try again in a few minutes.");
|
|
43412
|
+
return;
|
|
43413
|
+
}
|
|
43414
|
+
let chosen = null;
|
|
43415
|
+
if (query) try {
|
|
43416
|
+
chosen = pickDevice(fleet, query, mine);
|
|
43417
|
+
} catch (err) {
|
|
43418
|
+
s.hint(err.message);
|
|
43419
|
+
}
|
|
43420
|
+
if (!chosen) {
|
|
43421
|
+
chosen = await select({
|
|
43422
|
+
title: "Which phone would you like?",
|
|
43423
|
+
body: ["You pay by the minute from when you rent it until you stop it."],
|
|
43424
|
+
choices: groupFleet(fleet, mine).map((g) => ({
|
|
43425
|
+
label: g.label,
|
|
43426
|
+
value: g.free[0],
|
|
43427
|
+
disabled: g.free.length === 0,
|
|
43428
|
+
hint: g.free.length > 0 ? `${formatRate(yourRate(account, g.free[0]))} · ${g.free.length} free` : g.why
|
|
43429
|
+
}))
|
|
43430
|
+
});
|
|
43431
|
+
if (!chosen) return;
|
|
43432
|
+
}
|
|
43433
|
+
const rate = yourRate(account, chosen);
|
|
43434
|
+
if (!await confirm(`Rent ${chosen.name}?`, [
|
|
43435
|
+
rate === 0 ? "It's included in your plan." : `${formatRate(rate)} on your ${account.planName} plan, from now until you stop it.`,
|
|
43436
|
+
`Your wallet: ${eur(account.wallet)}.`,
|
|
43437
|
+
"A phone left idle for 30 minutes is stopped for you."
|
|
43438
|
+
], "Yes, rent it", "No")) return;
|
|
43439
|
+
let rented = false;
|
|
43440
|
+
await s.run(["rent", chosen.id], {
|
|
43441
|
+
title: `Renting ${chosen.name}`,
|
|
43442
|
+
spinner: `Renting ${chosen.name}`,
|
|
43443
|
+
onResult: (r) => {
|
|
43444
|
+
if (r.kind === "rented") rented = true;
|
|
43445
|
+
}
|
|
43446
|
+
});
|
|
43447
|
+
if (rented) s.hint("Next: drag your .apk onto this window to install it — or just say what to do on the phone.");
|
|
43448
|
+
}
|
|
43449
|
+
/**
|
|
43450
|
+
* Identical phones as ONE line ("Google Pixel 7a · Android 17 — 5 free"):
|
|
43451
|
+
* several share a name, and a menu of indistinguishable lines only confuses.
|
|
43452
|
+
* One model on two Android versions stays two lines. Pure.
|
|
43453
|
+
*/
|
|
43454
|
+
function groupFleet(fleet, mine) {
|
|
43455
|
+
const groups = /* @__PURE__ */ new Map();
|
|
43456
|
+
for (const d of fleet) {
|
|
43457
|
+
const key = `${d.name}\u0000${d.androidVersion}`;
|
|
43458
|
+
groups.set(key, [...groups.get(key) ?? [], d]);
|
|
43459
|
+
}
|
|
43460
|
+
return [...groups.values()].map((all) => {
|
|
43461
|
+
const d = all[0];
|
|
43462
|
+
return {
|
|
43463
|
+
label: `${d.name} · ${androidLabel(d.androidVersion)}`,
|
|
43464
|
+
androidVersion: d.androidVersion,
|
|
43465
|
+
total: all.length,
|
|
43466
|
+
free: all.filter((x) => x.rentable && !mine.has(x.id)),
|
|
43467
|
+
why: all.some((x) => mine.has(x.id)) ? "already yours" : STATUS_WORDS[d.status] ?? d.status
|
|
43468
|
+
};
|
|
43469
|
+
});
|
|
43470
|
+
}
|
|
43471
|
+
/** The rental to act on: the only one, a menu of several, or offer to rent. */
|
|
43472
|
+
async function pickRental(s, title) {
|
|
43473
|
+
if (!await ensureSignedIn(s)) return null;
|
|
43474
|
+
if (s.rentals.length === 0) {
|
|
43475
|
+
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;
|
|
43476
|
+
await rentFlow(s);
|
|
43477
|
+
}
|
|
43478
|
+
if (s.rentals.length <= 1) return s.rentals[0] ?? null;
|
|
43479
|
+
const now = Date.now();
|
|
43480
|
+
return select({
|
|
43481
|
+
title,
|
|
43482
|
+
choices: s.rentals.map((r) => ({
|
|
43483
|
+
label: r.deviceName,
|
|
43484
|
+
value: r,
|
|
43485
|
+
hint: `rented ${Math.floor(minutesSince(r.startTime, now))} min ago`
|
|
43486
|
+
}))
|
|
43487
|
+
});
|
|
43488
|
+
}
|
|
43489
|
+
async function stopFlow(s, arg) {
|
|
43490
|
+
if (!await ensureSignedIn(s)) return;
|
|
43491
|
+
if (s.rentals.length === 0) {
|
|
43492
|
+
s.result("Nothing is rented, so nothing is being charged.");
|
|
43493
|
+
return;
|
|
43494
|
+
}
|
|
43495
|
+
let target = arg === "all" ? "all" : s.rentals.length === 1 ? s.rentals[0] : null;
|
|
43496
|
+
if (!target) {
|
|
43497
|
+
target = await select({
|
|
43498
|
+
title: "Which phone are you done with?",
|
|
43499
|
+
choices: [...s.rentals.map((r) => ({
|
|
43500
|
+
label: r.deviceName,
|
|
43501
|
+
value: r
|
|
43502
|
+
})), {
|
|
43503
|
+
label: "All of them",
|
|
43504
|
+
value: "all"
|
|
43505
|
+
}]
|
|
43506
|
+
});
|
|
43507
|
+
if (!target) return;
|
|
43508
|
+
}
|
|
43509
|
+
const now = Date.now();
|
|
43510
|
+
const names = target === "all" ? s.rentals.map((r) => r.deviceName).join(", ") : target.deviceName;
|
|
43511
|
+
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;
|
|
43512
|
+
await s.run(target === "all" ? ["stop", "--all"] : ["stop", target.id], {
|
|
43513
|
+
title: `Stopping ${names}`,
|
|
43514
|
+
spinner: "Stopping and settling the bill"
|
|
43515
|
+
});
|
|
43516
|
+
}
|
|
43517
|
+
/** An APK from the usual places, or one dragged in / typed. */
|
|
43518
|
+
async function pickApk(s, title) {
|
|
43519
|
+
const f = await knownFolders();
|
|
43520
|
+
const cwd = process.cwd();
|
|
43521
|
+
const now = Date.now();
|
|
43522
|
+
const apks = findApks([
|
|
43523
|
+
cwd,
|
|
43524
|
+
f.downloads,
|
|
43525
|
+
f.desktop
|
|
43526
|
+
]);
|
|
43527
|
+
const choices = apks.map((a) => ({
|
|
43528
|
+
label: a.name,
|
|
43529
|
+
value: a.path,
|
|
43530
|
+
hint: `${folderLabel(a.folder, f, cwd)} · ${formatBytes(a.bytes)} · ${ago(a.modified, now)}`
|
|
43531
|
+
}));
|
|
43532
|
+
choices.push({
|
|
43533
|
+
label: "Another file",
|
|
43534
|
+
value: OTHER,
|
|
43535
|
+
hint: "drag it onto this window, or type where it is"
|
|
43536
|
+
});
|
|
43537
|
+
const pick = await select({
|
|
43538
|
+
title,
|
|
43539
|
+
body: apks.length ? [] : ["There are no .apk files in Downloads, on the Desktop or in this folder."],
|
|
43540
|
+
choices
|
|
43541
|
+
});
|
|
43542
|
+
if (pick !== OTHER) return pick;
|
|
43543
|
+
const line = await ask(" Drag the .apk onto this window (or type its path), then press Enter:\n > ");
|
|
43544
|
+
if (!line) return null;
|
|
43545
|
+
const file = droppedPath(line, s.platform) ?? line.trim();
|
|
43546
|
+
if (!fs.existsSync(file)) {
|
|
43547
|
+
s.error(`There is no file at ${file}.`);
|
|
43548
|
+
return null;
|
|
43549
|
+
}
|
|
43550
|
+
return file;
|
|
43551
|
+
}
|
|
43552
|
+
/**
|
|
43553
|
+
* The app to offer to open after an install: an app and its androidTest APK
|
|
43554
|
+
* dragged in together install both, and the test package (`….test`) has no
|
|
43555
|
+
* screen to open.
|
|
43556
|
+
*/
|
|
43557
|
+
function appToOpen(apps) {
|
|
43558
|
+
return apps.flatMap((a) => a.packageName ? [a.packageName] : []).find((n) => !n.endsWith(".test")) ?? null;
|
|
43559
|
+
}
|
|
43560
|
+
async function installFlow(s, files) {
|
|
43561
|
+
const rental = await pickRental(s, "Install on which phone?");
|
|
43562
|
+
if (!rental) return;
|
|
43563
|
+
const picked = files?.length ? null : await pickApk(s, "Which app do you want to install?");
|
|
43564
|
+
const apks = files?.length ? [...files] : picked ? [picked] : [];
|
|
43565
|
+
if (apks.length === 0) return;
|
|
43566
|
+
let pkg = null;
|
|
43567
|
+
if (await s.run([
|
|
43568
|
+
"install",
|
|
43569
|
+
...apks,
|
|
43570
|
+
"--rental",
|
|
43571
|
+
rental.id
|
|
43572
|
+
], {
|
|
43573
|
+
title: `Installing ${apks.map((a) => path.basename(a)).join(" and ")} on ${rental.deviceName}`,
|
|
43574
|
+
onResult: (r) => {
|
|
43575
|
+
if (r.kind === "installed") pkg = appToOpen(r.apps);
|
|
43576
|
+
}
|
|
43577
|
+
}) !== 0 || !pkg) return;
|
|
43578
|
+
const app = pkg;
|
|
43579
|
+
if (await confirm(`Open ${app} now?`, [], "Yes, open it", "Not now")) await s.run([
|
|
43580
|
+
"launch",
|
|
43581
|
+
app,
|
|
43582
|
+
"--rental",
|
|
43583
|
+
rental.id
|
|
43584
|
+
], { title: `Opening ${app}` });
|
|
43585
|
+
s.hint("Next: \"test my app\" runs a quick crash check — or say what to do on the phone in plain words.");
|
|
43586
|
+
}
|
|
43587
|
+
/** The apps you installed on the phone (not the system's). */
|
|
43588
|
+
async function listApps(s, rental) {
|
|
43589
|
+
return s.runTask((signal) => withDevice(s.input(rental.id, signal), async (device) => parsePackageList(await sh(device.adb, [
|
|
43590
|
+
"pm",
|
|
43591
|
+
"list",
|
|
43592
|
+
"packages",
|
|
43593
|
+
"-3"
|
|
43594
|
+
]))), {
|
|
43595
|
+
spinner: `Reading the apps on ${rental.deviceName}`,
|
|
43596
|
+
refresh: false
|
|
43597
|
+
});
|
|
43598
|
+
}
|
|
43599
|
+
async function pickApp(s, rental, title, extra = []) {
|
|
43600
|
+
const apps = await listApps(s, rental);
|
|
43601
|
+
if (!apps) return null;
|
|
43602
|
+
if (apps.length === 0 && extra.length === 0) {
|
|
43603
|
+
s.result(`None of your apps are on ${rental.deviceName} yet. Drag an .apk onto this window to install one.`);
|
|
43604
|
+
return null;
|
|
43605
|
+
}
|
|
43606
|
+
return select({
|
|
43607
|
+
title,
|
|
43608
|
+
body: apps.length ? [] : [`None of your apps are on ${rental.deviceName} yet.`],
|
|
43609
|
+
choices: [...apps.sort().map((a) => ({
|
|
43610
|
+
label: a,
|
|
43611
|
+
value: a
|
|
43612
|
+
})), ...extra]
|
|
43613
|
+
});
|
|
43614
|
+
}
|
|
43615
|
+
async function appsFlow(s) {
|
|
43616
|
+
const rental = await pickRental(s, "Which phone?");
|
|
43617
|
+
if (!rental) return;
|
|
43618
|
+
const apps = await listApps(s, rental);
|
|
43619
|
+
if (!apps) return;
|
|
43620
|
+
s.say(s.t.bold(`Your apps on ${rental.deviceName}`));
|
|
43621
|
+
if (apps.length === 0) s.result("None yet. Drag an .apk onto this window to install one.");
|
|
43622
|
+
apps.sort().forEach((a, i) => i === 0 ? s.result(a) : s.write(` ${a}`));
|
|
43623
|
+
}
|
|
43624
|
+
async function launchFlow(s, pkg) {
|
|
43625
|
+
const rental = await pickRental(s, "On which phone?");
|
|
43626
|
+
if (!rental) return;
|
|
43627
|
+
const app = pkg ?? await pickApp(s, rental, "Which app should I open?");
|
|
43628
|
+
if (!app) return;
|
|
43629
|
+
await s.run([
|
|
43630
|
+
"launch",
|
|
43631
|
+
app,
|
|
43632
|
+
"--rental",
|
|
43633
|
+
rental.id
|
|
43634
|
+
], { title: `Opening ${app}` });
|
|
43635
|
+
}
|
|
43636
|
+
async function uninstallFlow(s, pkg) {
|
|
43637
|
+
const rental = await pickRental(s, "On which phone?");
|
|
43638
|
+
if (!rental) return;
|
|
43639
|
+
const app = pkg ?? await pickApp(s, rental, "Which app should I remove?");
|
|
43640
|
+
if (!app) return;
|
|
43641
|
+
if (!await confirm(`Remove ${app} from ${rental.deviceName}?`, [], "Yes, remove it", "No")) return;
|
|
43642
|
+
await s.run([
|
|
43643
|
+
"uninstall",
|
|
43644
|
+
app,
|
|
43645
|
+
"--rental",
|
|
43646
|
+
rental.id
|
|
43647
|
+
], { title: `Removing ${app}` });
|
|
43648
|
+
}
|
|
43649
|
+
async function testFlow(s, target) {
|
|
43650
|
+
const rental = await pickRental(s, "Test on which phone?");
|
|
43651
|
+
if (!rental) return;
|
|
43652
|
+
let what = target ?? null;
|
|
43653
|
+
if (!what) {
|
|
43654
|
+
what = await pickApp(s, rental, "Which app should the quick test check?", [{
|
|
43655
|
+
label: "An .apk file on this computer",
|
|
43656
|
+
value: OTHER,
|
|
43657
|
+
hint: "installs it first"
|
|
43658
|
+
}]);
|
|
43659
|
+
if (what === OTHER) what = await pickApk(s, "Which app do you want to test?");
|
|
43660
|
+
}
|
|
43661
|
+
if (!what) return;
|
|
43662
|
+
s.hint("The quick test opens the app, taps around for a minute and reports any crash. No test code needed.");
|
|
43663
|
+
await s.run([
|
|
43664
|
+
"test",
|
|
43665
|
+
"quick",
|
|
43666
|
+
what,
|
|
43667
|
+
"--rental",
|
|
43668
|
+
rental.id
|
|
43669
|
+
], { title: `Quick test of ${path.basename(what)} on ${rental.deviceName}` });
|
|
43670
|
+
}
|
|
43671
|
+
async function screenshotFlow(s) {
|
|
43672
|
+
const rental = await pickRental(s, "Screenshot of which phone?");
|
|
43673
|
+
if (!rental) return;
|
|
43674
|
+
const f = await knownFolders();
|
|
43675
|
+
const dir = f.downloads ?? process.cwd();
|
|
43676
|
+
const stamp = (/* @__PURE__ */ new Date()).toISOString().slice(0, 19).replace(/[:T]/g, "-");
|
|
43677
|
+
const file = path.join(dir, `devicerent-${slug(rental.deviceName)}-${stamp}.png`);
|
|
43678
|
+
let saved = null;
|
|
43679
|
+
await s.run([
|
|
43680
|
+
"screenshot",
|
|
43681
|
+
file,
|
|
43682
|
+
"--rental",
|
|
43683
|
+
rental.id
|
|
43684
|
+
], {
|
|
43685
|
+
title: `Screenshot of ${rental.deviceName}`,
|
|
43686
|
+
spinner: "Taking the screenshot",
|
|
43687
|
+
onResult: (r) => {
|
|
43688
|
+
if (r.kind === "screenshot") saved = r.file;
|
|
43689
|
+
}
|
|
43690
|
+
});
|
|
43691
|
+
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.`);
|
|
43692
|
+
}
|
|
43693
|
+
async function logsFlow(s) {
|
|
43694
|
+
const rental = await pickRental(s, "Logs from which phone?");
|
|
43695
|
+
if (!rental) return;
|
|
43696
|
+
await s.run([
|
|
43697
|
+
"logcat",
|
|
43698
|
+
"--lines",
|
|
43699
|
+
"60",
|
|
43700
|
+
"--rental",
|
|
43701
|
+
rental.id
|
|
43702
|
+
], { title: `The last lines of ${rental.deviceName}'s log` });
|
|
43703
|
+
s.hint("Follow it live with: /logcat -f (Ctrl-C stops)");
|
|
43704
|
+
}
|
|
43705
|
+
async function balanceFlow(s) {
|
|
43706
|
+
if (!await ensureSignedIn(s)) return;
|
|
43707
|
+
const a = s.account;
|
|
43708
|
+
s.say(s.t.bold("Your account"));
|
|
43709
|
+
s.result(`${a.email} · ${a.planName} plan`);
|
|
43710
|
+
s.write(` Wallet ${s.t.bold(eur(a.wallet))} · AI credits ${s.t.bold(a.aiCredits.toLocaleString("en"))}`);
|
|
43711
|
+
if (a.standing !== "active") s.write(` ${s.t.yellow(`Account ${a.standing}`)}`);
|
|
43712
|
+
s.hint("Add money: topup · Change plan: plans · More AI credits: buy credits");
|
|
43713
|
+
}
|
|
43714
|
+
async function topupFlow(s, amount) {
|
|
43715
|
+
if (!await ensureSignedIn(s)) return;
|
|
43716
|
+
let value = amount ?? null;
|
|
43717
|
+
if (!value) {
|
|
43718
|
+
value = await select({
|
|
43719
|
+
title: "How much would you like to add?",
|
|
43720
|
+
body: [`Your wallet: ${eur(s.account.wallet)}. Payment happens on Stripe, in your browser.`],
|
|
43721
|
+
choices: [
|
|
43722
|
+
{
|
|
43723
|
+
label: "€10",
|
|
43724
|
+
value: "10"
|
|
43725
|
+
},
|
|
43726
|
+
{
|
|
43727
|
+
label: "€20",
|
|
43728
|
+
value: "20"
|
|
43729
|
+
},
|
|
43730
|
+
{
|
|
43731
|
+
label: "€50",
|
|
43732
|
+
value: "50"
|
|
43733
|
+
},
|
|
43734
|
+
{
|
|
43735
|
+
label: "Another amount",
|
|
43736
|
+
value: OTHER
|
|
43737
|
+
}
|
|
43738
|
+
]
|
|
43739
|
+
});
|
|
43740
|
+
if (value === OTHER) {
|
|
43741
|
+
const typed = await ask(" How many euros? ");
|
|
43742
|
+
if (!typed) return;
|
|
43743
|
+
const n = parseEuros(typed.replace(/\s*(?:eur|euros?)$/i, "").trim());
|
|
43744
|
+
value = Number.isFinite(n) ? String(n) : typed.trim();
|
|
43745
|
+
}
|
|
43746
|
+
}
|
|
43747
|
+
if (!value) return;
|
|
43748
|
+
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.");
|
|
43749
|
+
}
|
|
43750
|
+
async function plansFlow(s, plan) {
|
|
43751
|
+
if (!await ensureSignedIn(s)) return;
|
|
43752
|
+
const sellable = TIERS.filter((t) => t.monthlyPrice !== null && t.monthlyPrice > 0);
|
|
43753
|
+
const ids = {
|
|
43754
|
+
plus: "plus",
|
|
43755
|
+
pro: "pro",
|
|
43756
|
+
dedicated_slot: "slot"
|
|
43757
|
+
};
|
|
43758
|
+
let choice = plan ?? null;
|
|
43759
|
+
if (!choice) choice = await select({
|
|
43760
|
+
title: "Plans",
|
|
43761
|
+
body: [`You're on ${s.account.planName}.`],
|
|
43762
|
+
choices: [...sellable.map((t) => ({
|
|
43763
|
+
label: t.name,
|
|
43764
|
+
value: ids[t.id] ?? t.id,
|
|
43765
|
+
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`,
|
|
43766
|
+
disabled: t.name === s.account.planName
|
|
43767
|
+
})), {
|
|
43768
|
+
label: "Manage my plan and invoices",
|
|
43769
|
+
value: "billing",
|
|
43770
|
+
hint: "opens the Stripe portal"
|
|
43771
|
+
}]
|
|
43772
|
+
});
|
|
43773
|
+
if (!choice) return;
|
|
43774
|
+
if (choice === "billing") {
|
|
43775
|
+
await s.run(["billing"], { title: "Opening the billing portal" });
|
|
43776
|
+
return;
|
|
43777
|
+
}
|
|
43778
|
+
const yearly = await select({
|
|
43779
|
+
title: "Pay monthly or yearly?",
|
|
43780
|
+
choices: [{
|
|
43781
|
+
label: "Monthly",
|
|
43782
|
+
value: false
|
|
43783
|
+
}, {
|
|
43784
|
+
label: "Yearly",
|
|
43785
|
+
value: true,
|
|
43786
|
+
hint: `1 month free`
|
|
43787
|
+
}]
|
|
43788
|
+
});
|
|
43789
|
+
if (yearly === null) return;
|
|
43790
|
+
await s.run([
|
|
43791
|
+
"subscribe",
|
|
43792
|
+
choice,
|
|
43793
|
+
...yearly ? ["--yearly"] : []
|
|
43794
|
+
], { title: "Opening Stripe" });
|
|
43795
|
+
}
|
|
43796
|
+
async function buyCreditsFlow(s) {
|
|
43797
|
+
if (!await ensureSignedIn(s)) return;
|
|
43798
|
+
const pack = await select({
|
|
43799
|
+
title: "Buy DeviceyAI credits",
|
|
43800
|
+
body: [`You have ${s.account.aiCredits.toLocaleString("en")} credits.`],
|
|
43801
|
+
choices: AI_CREDIT_PACKS.map((p) => ({
|
|
43802
|
+
label: p.label,
|
|
43803
|
+
value: p.id,
|
|
43804
|
+
hint: `${p.credits.toLocaleString("en")} credits · ${formatEur(p.priceEur)}`
|
|
43805
|
+
}))
|
|
43806
|
+
});
|
|
43807
|
+
if (!pack) return;
|
|
43808
|
+
await s.run([
|
|
43809
|
+
"credits",
|
|
43810
|
+
"buy",
|
|
43811
|
+
pack
|
|
43812
|
+
], { title: "Opening Stripe" });
|
|
43813
|
+
}
|
|
43814
|
+
async function askAiPermission(s) {
|
|
43815
|
+
const answer = await select({
|
|
43816
|
+
title: "Let DeviceyAI use the phone?",
|
|
43817
|
+
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."],
|
|
43818
|
+
choices: [
|
|
43819
|
+
{
|
|
43820
|
+
label: "Yes — let it tap and type",
|
|
43821
|
+
value: "act"
|
|
43822
|
+
},
|
|
43823
|
+
{
|
|
43824
|
+
label: "Yes, but only let it look",
|
|
43825
|
+
value: "look",
|
|
43826
|
+
hint: "it can't touch anything"
|
|
43827
|
+
},
|
|
43828
|
+
{
|
|
43829
|
+
label: "No",
|
|
43830
|
+
value: "no"
|
|
43831
|
+
}
|
|
43832
|
+
]
|
|
43833
|
+
});
|
|
43834
|
+
return answer === "act" || answer === "look" ? answer : null;
|
|
43835
|
+
}
|
|
43836
|
+
async function aiFlow(s, task) {
|
|
43837
|
+
if (!await ensureSignedIn(s)) return;
|
|
43838
|
+
if (s.rentals.length === 0) s.result(`DeviceyAI works on a rented phone, and you don't have one yet.`);
|
|
43839
|
+
const rental = await pickRental(s, "Which phone should DeviceyAI use?");
|
|
43840
|
+
if (!rental) return;
|
|
43841
|
+
if (!s.aiPermission) {
|
|
43842
|
+
s.aiPermission = await askAiPermission(s);
|
|
43843
|
+
if (!s.aiPermission) return;
|
|
43844
|
+
}
|
|
43845
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
43846
|
+
let needsActions = false;
|
|
43847
|
+
const permission = s.aiPermission;
|
|
43848
|
+
const result = await s.runTask((signal) => runChatTask(s.input(rental.id, signal), task, permission, s.t).catch((err) => {
|
|
43849
|
+
if (!(err instanceof AiNeedsActionsError)) throw err;
|
|
43850
|
+
needsActions = true;
|
|
43851
|
+
return null;
|
|
43852
|
+
}), {
|
|
43853
|
+
raw: true,
|
|
43854
|
+
spinner: "DeviceyAI is thinking"
|
|
43855
|
+
});
|
|
43856
|
+
if (needsActions) {
|
|
43857
|
+
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;
|
|
43858
|
+
s.aiPermission = "act";
|
|
43859
|
+
continue;
|
|
43860
|
+
}
|
|
43861
|
+
if (!result) return;
|
|
43862
|
+
if (result.stopReason === "refusal") s.result("DeviceyAI declined this request.");
|
|
43863
|
+
if (result.stopReason === "stopped") s.result(s.t.dim("Stopped."));
|
|
43864
|
+
if (result.stopReason === "step_limit") s.result("It ran out of steps for one message. Try a smaller, more specific request.");
|
|
43865
|
+
const left = result.creditsRemaining ?? s.account?.aiCredits ?? null;
|
|
43866
|
+
s.hint(`${result.creditsCharged} credit${result.creditsCharged === 1 ? "" : "s"} used${left !== null ? ` · ${left.toLocaleString("en")} left` : ""}`);
|
|
43867
|
+
return;
|
|
43868
|
+
}
|
|
43869
|
+
}
|
|
43870
|
+
function helpFlow(s) {
|
|
43871
|
+
const t = s.t;
|
|
43872
|
+
const rows = [
|
|
43873
|
+
["rent", "rent a phone — you pay by the minute until you stop"],
|
|
43874
|
+
["install", "put your app (.apk) on it — or just drag the file onto this window"],
|
|
43875
|
+
["test my app", "a quick crash check: opens your app and taps around"],
|
|
43876
|
+
["screenshot", "save a picture of the phone's screen (and open it)"],
|
|
43877
|
+
["apps · logs", "what you installed · the phone's log"],
|
|
43878
|
+
["stop", "give the phone back and stop paying"],
|
|
43879
|
+
["balance · topup", "your wallet · add money"],
|
|
43880
|
+
["plans · buy credits", "change plan · more DeviceyAI credits"],
|
|
43881
|
+
["phones", "every phone and whether it's free"]
|
|
43882
|
+
];
|
|
43883
|
+
s.say(t.bold("What you can do"));
|
|
43884
|
+
rows.forEach(([cmd, what], i) => {
|
|
43885
|
+
const line = `${t.accent(cmd.padEnd(22))}${what}`;
|
|
43886
|
+
if (i === 0) s.result(line);
|
|
43887
|
+
else s.write(` ${line}`);
|
|
43888
|
+
});
|
|
43889
|
+
s.write();
|
|
43890
|
+
s.hint("Anything else, in plain words — \"open Settings and turn on dark mode\" — goes to DeviceyAI.");
|
|
43891
|
+
s.hint("Type / for every command · permissions changes what DeviceyAI may do · exit to leave");
|
|
43892
|
+
}
|
|
43893
|
+
//#endregion
|
|
43894
|
+
//#region src/interactive/session.ts
|
|
43895
|
+
/** What can change the wallet, credits, plan, rentals or who is signed in. */
|
|
43896
|
+
const CHANGES_ACCOUNT = /* @__PURE__ */ new Set([
|
|
43897
|
+
"rent",
|
|
43898
|
+
"stop",
|
|
43899
|
+
"login",
|
|
43900
|
+
"logout",
|
|
43901
|
+
"ai"
|
|
43902
|
+
]);
|
|
43903
|
+
/** True when, after the command `argv` names, the session's account view may be stale. */
|
|
43904
|
+
function refreshesAfter(argv) {
|
|
43905
|
+
const found = findCommand(argv);
|
|
43906
|
+
return found !== null && CHANGES_ACCOUNT.has(found.command.name);
|
|
43907
|
+
}
|
|
43908
|
+
/** Some next steps for refusals a person can fix themselves. */
|
|
43909
|
+
function nextStepFor(message) {
|
|
43910
|
+
const m = message.toLowerCase();
|
|
43911
|
+
if (m.includes("insufficient credit") || m.includes("top up")) return "Add money with: topup";
|
|
43912
|
+
if (m.includes("out of deviceyai credits")) return "Buy more with: buy credits";
|
|
43913
|
+
if (m.includes("not signed in")) return "Sign in with: login";
|
|
43914
|
+
if (m.includes("no active rental")) return "Rent a phone with: rent";
|
|
43915
|
+
if (m.includes("concurrent rental limit")) return "Stop one first with: stop";
|
|
43916
|
+
return null;
|
|
43917
|
+
}
|
|
43918
|
+
const LEADING_ESCAPES = /^(?:\u001b\[[0-9;?]*[ -/]*[@-~])+/;
|
|
43919
|
+
/**
|
|
43920
|
+
* Prefix every line written to stdout/stderr while `fn` runs: " ⎿ " on the
|
|
43921
|
+
* first, five spaces after. A carriage return (a progress bar or a spinner
|
|
43922
|
+
* redrawing its line) gets that line's prefix again, before its next visible
|
|
43923
|
+
* character. Returns the undo.
|
|
43924
|
+
*/
|
|
43925
|
+
function indentOutput(t) {
|
|
43926
|
+
const out = process.stdout;
|
|
43927
|
+
const err = process.stderr;
|
|
43928
|
+
const outWrite = out.write;
|
|
43929
|
+
const errWrite = err.write;
|
|
43930
|
+
const first = ` ${t.dim(t.g.elbow)} `;
|
|
43931
|
+
const rest = " ";
|
|
43932
|
+
let used = false;
|
|
43933
|
+
/** A new line has started and has no prefix yet. */
|
|
43934
|
+
let newLine = true;
|
|
43935
|
+
/** The current line was rewound with \r and needs its prefix again. */
|
|
43936
|
+
let rewound = false;
|
|
43937
|
+
let atLineStart = true;
|
|
43938
|
+
let current = first;
|
|
43939
|
+
const decoders = /* @__PURE__ */ new Map();
|
|
43940
|
+
const transform = (text) => {
|
|
43941
|
+
let res = "";
|
|
43942
|
+
for (const part of text.split(/(\r\n|\n|\r)/)) {
|
|
43943
|
+
if (part === "") continue;
|
|
43944
|
+
if (part === "\n" || part === "\r\n") {
|
|
43945
|
+
res += part;
|
|
43946
|
+
newLine = true;
|
|
43947
|
+
rewound = false;
|
|
43948
|
+
atLineStart = true;
|
|
43949
|
+
continue;
|
|
43950
|
+
}
|
|
43951
|
+
if (part === "\r") {
|
|
43952
|
+
res += "\r";
|
|
43953
|
+
if (!newLine) rewound = true;
|
|
43954
|
+
continue;
|
|
43955
|
+
}
|
|
43956
|
+
const escapes = LEADING_ESCAPES.exec(part)?.[0] ?? "";
|
|
43957
|
+
const visible = part.slice(escapes.length);
|
|
43958
|
+
res += escapes;
|
|
43959
|
+
if (!visible) continue;
|
|
43960
|
+
if (newLine) {
|
|
43961
|
+
current = used ? rest : first;
|
|
43962
|
+
used = true;
|
|
43963
|
+
res += current;
|
|
43964
|
+
} else if (rewound) res += current;
|
|
43965
|
+
newLine = false;
|
|
43966
|
+
rewound = false;
|
|
43967
|
+
atLineStart = false;
|
|
43968
|
+
res += visible;
|
|
43969
|
+
}
|
|
43970
|
+
return res;
|
|
43971
|
+
};
|
|
43972
|
+
const wrap = (stream, orig) => function(chunk, ...args) {
|
|
43973
|
+
let text;
|
|
43974
|
+
if (typeof chunk === "string") text = chunk;
|
|
43975
|
+
else if (chunk instanceof Uint8Array) {
|
|
43976
|
+
let d = decoders.get(stream);
|
|
43977
|
+
if (!d) decoders.set(stream, d = new TextDecoder());
|
|
43978
|
+
text = d.decode(chunk, { stream: true });
|
|
43979
|
+
} else text = String(chunk);
|
|
43980
|
+
return orig.call(this, transform(text), ...args);
|
|
43981
|
+
};
|
|
43982
|
+
out.write = wrap(out, outWrite);
|
|
43983
|
+
err.write = wrap(err, errWrite);
|
|
43984
|
+
let undone = false;
|
|
43985
|
+
return () => {
|
|
43986
|
+
if (undone) return;
|
|
43987
|
+
undone = true;
|
|
43988
|
+
out.write = outWrite;
|
|
43989
|
+
err.write = errWrite;
|
|
43990
|
+
if (!atLineStart && !rewound) outWrite.call(out, "\n");
|
|
43991
|
+
};
|
|
43992
|
+
}
|
|
43993
|
+
var Session = class {
|
|
43994
|
+
ctx;
|
|
43995
|
+
t;
|
|
43996
|
+
platform;
|
|
43997
|
+
account = null;
|
|
43998
|
+
rentals = [];
|
|
43999
|
+
aiPermission = null;
|
|
44000
|
+
history = [];
|
|
44001
|
+
/** A message to show before the next prompt (never over a drawn prompt). */
|
|
44002
|
+
pending = [];
|
|
44003
|
+
stoppedHere = /* @__PURE__ */ new Set();
|
|
44004
|
+
constructor(ctx, t, platform) {
|
|
44005
|
+
this.ctx = ctx;
|
|
44006
|
+
this.t = t;
|
|
44007
|
+
this.platform = platform;
|
|
44008
|
+
}
|
|
44009
|
+
write(line = "") {
|
|
44010
|
+
process.stdout.write(`${line}\n`);
|
|
44011
|
+
}
|
|
44012
|
+
/** "⏺ text" — something happening, or an answer. */
|
|
44013
|
+
say(text) {
|
|
44014
|
+
this.write(`${this.t.accent(this.t.g.dot)} ${text}`);
|
|
44015
|
+
}
|
|
44016
|
+
/** " ⎿ text" — the result of what was just said. */
|
|
44017
|
+
result(text) {
|
|
44018
|
+
this.write(` ${this.t.dim(this.t.g.elbow)} ${text}`);
|
|
44019
|
+
}
|
|
44020
|
+
hint(text) {
|
|
44021
|
+
this.write(this.t.dim(` ${text}`));
|
|
44022
|
+
}
|
|
44023
|
+
error(text) {
|
|
44024
|
+
this.result(this.t.red(text));
|
|
44025
|
+
const next = nextStepFor(text);
|
|
44026
|
+
if (next) this.hint(next);
|
|
44027
|
+
}
|
|
44028
|
+
/** Who is signed in (null if nobody), and their running rentals. */
|
|
44029
|
+
async refresh() {
|
|
44030
|
+
try {
|
|
44031
|
+
const user = await requireUser(this.ctx);
|
|
44032
|
+
const [account, rentals] = await Promise.all([loadAccount(this.ctx, user), loadActiveRentals()]);
|
|
44033
|
+
this.account = account;
|
|
44034
|
+
this.noteVanished(rentals);
|
|
44035
|
+
this.rentals = rentals;
|
|
44036
|
+
} catch (err) {
|
|
44037
|
+
if (err instanceof CliError && err.exitCode === 2) {
|
|
44038
|
+
this.account = null;
|
|
44039
|
+
this.rentals = [];
|
|
44040
|
+
return;
|
|
44041
|
+
}
|
|
44042
|
+
throw err;
|
|
44043
|
+
}
|
|
44044
|
+
}
|
|
44045
|
+
async refreshRentals() {
|
|
44046
|
+
if (!this.account) return;
|
|
44047
|
+
const rentals = await loadActiveRentals();
|
|
44048
|
+
this.noteVanished(rentals);
|
|
44049
|
+
this.rentals = rentals;
|
|
44050
|
+
}
|
|
44051
|
+
/** A rental that ended without our stop: the idle sweep, or the website. */
|
|
44052
|
+
noteVanished(next) {
|
|
44053
|
+
for (const r of this.rentals) {
|
|
44054
|
+
if (next.some((n) => n.id === r.id) || this.stoppedHere.has(r.id)) continue;
|
|
44055
|
+
this.pending.push(`${r.deviceName} is no longer rented — it was stopped elsewhere, or after 30 idle minutes.`);
|
|
44056
|
+
}
|
|
44057
|
+
}
|
|
44058
|
+
/** A CommandInput for work the session runs itself (pickers, DeviceyAI). */
|
|
44059
|
+
input(rentalId, signal) {
|
|
44060
|
+
return {
|
|
44061
|
+
ctx: this.ctx,
|
|
44062
|
+
args: [],
|
|
44063
|
+
opts: rentalId ? { rental: rentalId } : {},
|
|
44064
|
+
json: false,
|
|
44065
|
+
signal
|
|
44066
|
+
};
|
|
44067
|
+
}
|
|
44068
|
+
/**
|
|
44069
|
+
* Run `work` with Ctrl-C wired to its signal. The first Ctrl-C aborts it
|
|
44070
|
+
* and is then handed back to Node, so a second one ends the process the
|
|
44071
|
+
* ordinary way. The work is always awaited: a rent or stop already on its
|
|
44072
|
+
* way to the database is never abandoned behind the prompt.
|
|
44073
|
+
*/
|
|
44074
|
+
async runTask(work, opts = {}) {
|
|
44075
|
+
if (opts.title) this.say(this.t.bold(opts.title));
|
|
44076
|
+
cookedMode();
|
|
44077
|
+
const controller = new AbortController();
|
|
44078
|
+
const onSigint = () => {
|
|
44079
|
+
process.off("SIGINT", onSigint);
|
|
44080
|
+
controller.abort();
|
|
44081
|
+
process.stderr.write(this.t.dim("\n Stopping…\n"));
|
|
44082
|
+
};
|
|
44083
|
+
process.on("SIGINT", onSigint);
|
|
44084
|
+
const undoIndent = opts.raw ? () => {} : indentOutput(this.t);
|
|
44085
|
+
const spin = opts.spinner ? startSpinner(opts.spinner, {
|
|
44086
|
+
cancellable: true,
|
|
44087
|
+
theme: this.t
|
|
44088
|
+
}) : null;
|
|
44089
|
+
try {
|
|
44090
|
+
return await work(controller.signal);
|
|
44091
|
+
} catch (err) {
|
|
44092
|
+
spin?.stop();
|
|
44093
|
+
undoIndent();
|
|
44094
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
44095
|
+
if (err instanceof CliError && err.exitCode === 130) this.result(this.t.dim(message));
|
|
44096
|
+
else this.error(message);
|
|
44097
|
+
return null;
|
|
44098
|
+
} finally {
|
|
44099
|
+
spin?.stop();
|
|
44100
|
+
undoIndent();
|
|
44101
|
+
process.off("SIGINT", onSigint);
|
|
44102
|
+
if (opts.raw) restoreTerminal();
|
|
44103
|
+
if (opts.refresh !== false) await this.refresh().catch(() => {});
|
|
44104
|
+
}
|
|
44105
|
+
}
|
|
44106
|
+
/**
|
|
44107
|
+
* Run a command line exactly as `devicerent <argv>` would. Returns its exit
|
|
44108
|
+
* code, or null when it failed (the error has been printed).
|
|
44109
|
+
*/
|
|
44110
|
+
async run(argv, opts = {}) {
|
|
44111
|
+
const raw = opts.raw ?? (argv[0] === "shell" || argv[0] === "connect" || argv[0] === "logcat" && (argv.includes("-f") || argv.includes("--follow")));
|
|
44112
|
+
return this.runTask(async (signal) => {
|
|
44113
|
+
const outcome = await runCommandLine(async () => this.ctx, argv, {
|
|
44114
|
+
signal,
|
|
44115
|
+
interactive: true,
|
|
44116
|
+
onResult: (r) => {
|
|
44117
|
+
if (r.kind === "stopped") for (const id of r.rentalIds) this.stoppedHere.add(id);
|
|
44118
|
+
opts.onResult?.(r);
|
|
44119
|
+
}
|
|
44120
|
+
});
|
|
44121
|
+
if (outcome.kind === "unknown") {
|
|
44122
|
+
const near = nearMisses(argv[0] ?? "", {
|
|
44123
|
+
...this.platform,
|
|
44124
|
+
fileExists: () => false,
|
|
44125
|
+
commandNames: COMMANDS.map((c) => c.name)
|
|
44126
|
+
});
|
|
44127
|
+
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."}`);
|
|
44128
|
+
}
|
|
44129
|
+
return outcome.code;
|
|
44130
|
+
}, {
|
|
44131
|
+
...opts,
|
|
44132
|
+
raw,
|
|
44133
|
+
refresh: opts.refresh ?? refreshesAfter(argv)
|
|
44134
|
+
});
|
|
44135
|
+
}
|
|
44136
|
+
/** True when the command named by argv acts on one rented phone. */
|
|
44137
|
+
static isDeviceCommand(argv) {
|
|
44138
|
+
const found = findCommand(argv);
|
|
44139
|
+
return Boolean(found?.command.options && "rental" in found.command.options);
|
|
44140
|
+
}
|
|
44141
|
+
};
|
|
44142
|
+
//#endregion
|
|
44143
|
+
//#region src/interactive/repl.ts
|
|
44144
|
+
const SESSION_SUMMARIES = {
|
|
44145
|
+
help: "What you can do here, in plain words",
|
|
44146
|
+
exit: "Leave (asks first if a phone is still rented)",
|
|
44147
|
+
quit: "Leave",
|
|
44148
|
+
clear: "Clear the screen",
|
|
44149
|
+
permissions: "Change what DeviceyAI may do on the phone",
|
|
44150
|
+
phones: "Every phone and whether it's free",
|
|
44151
|
+
balance: "Your wallet, AI credits and plan",
|
|
44152
|
+
plans: "Change your plan"
|
|
44153
|
+
};
|
|
44154
|
+
/** Everything the `/` menu offers: session actions first, then commands. */
|
|
44155
|
+
function slashMenu() {
|
|
44156
|
+
return [...Object.keys(SESSION_ACTIONS).filter((k) => k !== "quit").map((name) => ({
|
|
44157
|
+
name,
|
|
44158
|
+
summary: SESSION_SUMMARIES[name] ?? ""
|
|
44159
|
+
})), ...COMMANDS.map((c) => ({
|
|
44160
|
+
name: c.name,
|
|
44161
|
+
summary: c.summary
|
|
44162
|
+
}))];
|
|
44163
|
+
}
|
|
44164
|
+
/** The welcome card's lines (pure). */
|
|
44165
|
+
function welcomeLines(account, rentals, apiBase, t, width) {
|
|
44166
|
+
const card = [
|
|
44167
|
+
`${t.accent(t.g.spark)} ${t.bold("Welcome to DeviceRent")} ${t.dim(CLI_VERSION)}`,
|
|
44168
|
+
"",
|
|
44169
|
+
" Real Android phones, rented by the minute.",
|
|
44170
|
+
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.")
|
|
44171
|
+
];
|
|
44172
|
+
if (apiBase !== "https://devicerent.net") card.push(t.dim(` ${apiBase}`));
|
|
44173
|
+
const lines = renderBox(card, {
|
|
44174
|
+
width,
|
|
44175
|
+
theme: t,
|
|
44176
|
+
border: t.accent
|
|
44177
|
+
});
|
|
44178
|
+
lines.push("");
|
|
44179
|
+
if (rentals.length > 0) lines.push(` ${t.accent(t.g.bullet)} You have ${rentals.map((r) => t.bold(r.deviceName)).join(", ")} rented right now.`, "");
|
|
44180
|
+
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`, "");
|
|
44181
|
+
return lines;
|
|
44182
|
+
}
|
|
44183
|
+
function placeholderFor(s) {
|
|
44184
|
+
if (!s.account) return "Type \"login\" to sign in";
|
|
44185
|
+
if (s.rentals.length === 0) return "Try \"rent a phone\"";
|
|
44186
|
+
return "Try \"install\", or \"open Settings and turn on dark mode\"";
|
|
44187
|
+
}
|
|
44188
|
+
/** Guided commands typed with their own name but without their arguments. */
|
|
44189
|
+
async function runCommandLineGuided(s, argv) {
|
|
44190
|
+
const found = findCommand(argv);
|
|
44191
|
+
const name = found?.command.name;
|
|
44192
|
+
const rest = found?.rest ?? [];
|
|
44193
|
+
const positional = rest.filter((w) => !w.startsWith("-"));
|
|
44194
|
+
if (name === "rent") return rentFlow(s, rest.join(" ") || void 0);
|
|
44195
|
+
if (name === "stop" && rest.length === 0) return stopFlow(s);
|
|
44196
|
+
if (name === "stop" && rest.length === 1 && rest[0] === "--all") return stopFlow(s, "all");
|
|
44197
|
+
if (name === "install" && positional.length === 0) return installFlow(s);
|
|
44198
|
+
if (name === "screenshot" && positional.length === 0) return screenshotFlow(s);
|
|
44199
|
+
if (name === "test quick" && positional.length === 0) return testFlow(s);
|
|
44200
|
+
if (name === "launch" && positional.length === 0) return launchFlow(s);
|
|
44201
|
+
if (name === "uninstall" && positional.length === 0) return uninstallFlow(s);
|
|
44202
|
+
if (name === "topup" && positional.length === 0) return topupFlow(s);
|
|
44203
|
+
if (name === "subscribe" && positional.length === 0) return plansFlow(s);
|
|
44204
|
+
if (name === "credits" && positional[0] === "buy" && positional.length === 1) return buyCreditsFlow(s);
|
|
44205
|
+
if (name === "login" && rest.length === 0) return loginFlow(s);
|
|
44206
|
+
if (name === "ai" && positional.length > 0 && rest.length === positional.length) return aiFlow(s, positional.join(" "));
|
|
44207
|
+
let line = argv;
|
|
44208
|
+
if (Session.isDeviceCommand(argv) && !argv.some((a) => a === "--rental" || a === "-r" || a.startsWith("--rental="))) {
|
|
44209
|
+
if (s.rentals.length !== 1) {
|
|
44210
|
+
const rental = await pickRental(s, "On which phone?");
|
|
44211
|
+
if (!rental) return;
|
|
44212
|
+
line = [
|
|
44213
|
+
...argv,
|
|
44214
|
+
"--rental",
|
|
44215
|
+
rental.id
|
|
44216
|
+
];
|
|
44217
|
+
}
|
|
44218
|
+
}
|
|
44219
|
+
await s.run(line);
|
|
44220
|
+
}
|
|
44221
|
+
async function runAction(s, action, arg) {
|
|
44222
|
+
switch (action) {
|
|
44223
|
+
case "help": return helpFlow(s);
|
|
44224
|
+
case "exit": return "exit";
|
|
44225
|
+
case "clear":
|
|
44226
|
+
process.stdout.write("\x1B[2J\x1B[3J\x1B[H");
|
|
44227
|
+
return;
|
|
44228
|
+
case "rent": return rentFlow(s, arg);
|
|
44229
|
+
case "stop": return stopFlow(s, arg);
|
|
44230
|
+
case "install": return installFlow(s, arg ? [arg] : void 0);
|
|
44231
|
+
case "screenshot": return screenshotFlow(s);
|
|
44232
|
+
case "test": return testFlow(s, arg);
|
|
44233
|
+
case "balance": return balanceFlow(s);
|
|
44234
|
+
case "topup": return topupFlow(s, arg);
|
|
44235
|
+
case "plans": return plansFlow(s, arg);
|
|
44236
|
+
case "buy-credits": return buyCreditsFlow(s);
|
|
44237
|
+
case "apps": return appsFlow(s);
|
|
44238
|
+
case "phones": return phonesFlow(s);
|
|
44239
|
+
case "status":
|
|
44240
|
+
await s.run(["status"], { title: "What you have rented" });
|
|
44241
|
+
return;
|
|
44242
|
+
case "login": return loginFlow(s);
|
|
44243
|
+
case "logout": return logoutFlow(s);
|
|
44244
|
+
case "launch": return launchFlow(s, arg);
|
|
44245
|
+
case "uninstall": return uninstallFlow(s, arg);
|
|
44246
|
+
case "logs": return logsFlow(s);
|
|
44247
|
+
case "permissions": {
|
|
44248
|
+
const p = await askAiPermission(s);
|
|
44249
|
+
s.aiPermission = p;
|
|
44250
|
+
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.");
|
|
44251
|
+
return;
|
|
44252
|
+
}
|
|
44253
|
+
}
|
|
44254
|
+
}
|
|
44255
|
+
async function dispatch(s, intent) {
|
|
44256
|
+
switch (intent.kind) {
|
|
44257
|
+
case "empty": return;
|
|
44258
|
+
case "install-file": return installFlow(s, intent.paths);
|
|
44259
|
+
case "action": return runAction(s, intent.action, intent.arg);
|
|
44260
|
+
case "command": return runCommandLineGuided(s, intent.argv);
|
|
44261
|
+
case "did-you-mean": {
|
|
44262
|
+
const pick = await select({
|
|
44263
|
+
title: "Did you mean…",
|
|
44264
|
+
choices: [...intent.options.map((o) => ({
|
|
44265
|
+
label: o,
|
|
44266
|
+
value: o
|
|
44267
|
+
})), {
|
|
44268
|
+
label: "None of these",
|
|
44269
|
+
value: ""
|
|
44270
|
+
}]
|
|
44271
|
+
});
|
|
44272
|
+
if (!pick) return;
|
|
44273
|
+
return dispatch(s, interpret(pick, envFor(s)));
|
|
44274
|
+
}
|
|
44275
|
+
case "ai": return aiFlow(s, intent.task);
|
|
44276
|
+
case "unknown":
|
|
44277
|
+
s.result("I didn't catch that. Type help to see what you can do, or / for every command.");
|
|
44278
|
+
return;
|
|
44279
|
+
}
|
|
44280
|
+
}
|
|
44281
|
+
function envFor(s) {
|
|
44282
|
+
return {
|
|
44283
|
+
...s.platform,
|
|
44284
|
+
commandNames: COMMANDS.map((c) => c.name),
|
|
44285
|
+
fileExists: (p) => {
|
|
44286
|
+
try {
|
|
44287
|
+
return fs.statSync(p).isFile();
|
|
44288
|
+
} catch {
|
|
44289
|
+
return false;
|
|
44290
|
+
}
|
|
44291
|
+
}
|
|
44292
|
+
};
|
|
44293
|
+
}
|
|
44294
|
+
/** Leaving with a phone still rented: ask, never assume. */
|
|
44295
|
+
async function confirmExit(s) {
|
|
44296
|
+
await s.refreshRentals().catch(() => {});
|
|
44297
|
+
if (s.rentals.length === 0) return true;
|
|
44298
|
+
const names = s.rentals.map((r) => r.deviceName).join(", ");
|
|
44299
|
+
const isAre = s.rentals.length === 1 ? "is" : "are";
|
|
44300
|
+
const choice = await select({
|
|
44301
|
+
title: `${names} ${isAre} still rented`,
|
|
44302
|
+
body: ["Billing continues until you stop it, or until it has sat idle for 30 minutes."],
|
|
44303
|
+
choices: [
|
|
44304
|
+
{
|
|
44305
|
+
label: "Stop it and leave",
|
|
44306
|
+
value: "stop"
|
|
44307
|
+
},
|
|
44308
|
+
{
|
|
44309
|
+
label: "Leave it running",
|
|
44310
|
+
value: "leave"
|
|
44311
|
+
},
|
|
44312
|
+
{
|
|
44313
|
+
label: "Stay",
|
|
44314
|
+
value: "stay"
|
|
44315
|
+
}
|
|
44316
|
+
]
|
|
44317
|
+
});
|
|
44318
|
+
if (choice === "stop") {
|
|
44319
|
+
if (await s.run(["stop", "--all"], {
|
|
44320
|
+
title: `Stopping ${names}`,
|
|
44321
|
+
spinner: "Stopping and settling the bill"
|
|
44322
|
+
}) === 0) return true;
|
|
44323
|
+
s.hint(`${names} ${isAre} still rented and billing. Try exit again, or choose "Leave it running".`);
|
|
44324
|
+
return false;
|
|
44325
|
+
}
|
|
44326
|
+
if (choice === "leave") {
|
|
44327
|
+
s.hint("It keeps running. Stop it later with: npx devicerent stop");
|
|
44328
|
+
return true;
|
|
44329
|
+
}
|
|
44330
|
+
return false;
|
|
44331
|
+
}
|
|
44332
|
+
async function startInteractive(apiBase, helpText) {
|
|
44333
|
+
const t = theme();
|
|
44334
|
+
const platform = {
|
|
44335
|
+
platform: process.platform,
|
|
44336
|
+
home: os.homedir()
|
|
44337
|
+
};
|
|
44338
|
+
const spin = startSpinner("Connecting to DeviceRent", { theme: t });
|
|
44339
|
+
let ctx;
|
|
44340
|
+
try {
|
|
44341
|
+
ctx = await startContext(apiBase);
|
|
44342
|
+
} catch (err) {
|
|
44343
|
+
spin.stop();
|
|
44344
|
+
process.stderr.write(`${t.red(err.message)}\n\n${helpText()}\n`);
|
|
44345
|
+
return 1;
|
|
44346
|
+
}
|
|
44347
|
+
const s = new Session(ctx, t, platform);
|
|
44348
|
+
try {
|
|
44349
|
+
await s.refresh();
|
|
44350
|
+
} catch (err) {
|
|
44351
|
+
spin.stop();
|
|
44352
|
+
process.stderr.write(`${t.red(err.message)}\n`);
|
|
44353
|
+
return 1;
|
|
44354
|
+
}
|
|
44355
|
+
spin.stop();
|
|
44356
|
+
for (const line of welcomeLines(s.account, s.rentals, apiBase, t, frameWidth(process.stdout.columns))) s.write(line);
|
|
44357
|
+
if (!s.account) await loginFlow(s);
|
|
44358
|
+
const poll = setInterval(() => void s.refreshRentals().catch(() => {}), 6e4);
|
|
44359
|
+
poll.unref();
|
|
44360
|
+
const commands = slashMenu();
|
|
44361
|
+
try {
|
|
44362
|
+
for (;;) {
|
|
44363
|
+
for (const p of s.pending.splice(0)) s.hint(p);
|
|
44364
|
+
const r = await readPrompt({
|
|
44365
|
+
history: s.history,
|
|
44366
|
+
commands,
|
|
44367
|
+
placeholder: placeholderFor(s),
|
|
44368
|
+
hint: "/ for commands · ? for help",
|
|
44369
|
+
status: () => statusLine(s.rentals, s.account, Date.now(), t),
|
|
44370
|
+
theme: t
|
|
44371
|
+
});
|
|
44372
|
+
if (r.kind === "exit") {
|
|
44373
|
+
if (await confirmExit(s)) break;
|
|
44374
|
+
continue;
|
|
44375
|
+
}
|
|
44376
|
+
if (r.value.trim()) s.history.push(r.value);
|
|
44377
|
+
try {
|
|
44378
|
+
if (await dispatch(s, interpret(r.value, envFor(s))) === "exit" && await confirmExit(s)) break;
|
|
44379
|
+
} catch (err) {
|
|
44380
|
+
s.error(err instanceof CliError || err instanceof Error ? err.message : String(err));
|
|
44381
|
+
}
|
|
44382
|
+
s.write();
|
|
44383
|
+
}
|
|
44384
|
+
} finally {
|
|
44385
|
+
clearInterval(poll);
|
|
44386
|
+
restoreTerminal();
|
|
44387
|
+
}
|
|
44388
|
+
s.write(t.dim(" Bye."));
|
|
44389
|
+
return 0;
|
|
44390
|
+
}
|
|
44391
|
+
//#endregion
|
|
44392
|
+
//#region src/main.ts
|
|
41156
44393
|
/**
|
|
41157
44394
|
* Global flags may come before the command, as with git and gh:
|
|
41158
44395
|
* `devicerent --api-base http://localhost:3000 keys list`. They are lifted out
|
|
@@ -41195,6 +44432,21 @@ function helpText() {
|
|
|
41195
44432
|
}
|
|
41196
44433
|
async function main(rawArgv) {
|
|
41197
44434
|
const { globals, rest: argv } = splitLeadingGlobals(rawArgv);
|
|
44435
|
+
if (shouldStartInteractive({
|
|
44436
|
+
argv,
|
|
44437
|
+
globals,
|
|
44438
|
+
stdinTTY: process.stdin.isTTY === true,
|
|
44439
|
+
stdoutTTY: process.stdout.isTTY === true,
|
|
44440
|
+
stderrTTY: process.stderr.isTTY === true,
|
|
44441
|
+
env: process.env
|
|
44442
|
+
})) {
|
|
44443
|
+
const { values } = parseArgs({
|
|
44444
|
+
args: globals,
|
|
44445
|
+
options: GLOBAL_OPTIONS,
|
|
44446
|
+
strict: true
|
|
44447
|
+
});
|
|
44448
|
+
return startInteractive(resolveApiBase(values), helpText);
|
|
44449
|
+
}
|
|
41198
44450
|
if (argv.length === 0 || argv[0] === "help" || argv[0] === "--help" || argv[0] === "-h") {
|
|
41199
44451
|
printLine(helpText());
|
|
41200
44452
|
return 0;
|
|
@@ -41203,40 +44455,12 @@ async function main(rawArgv) {
|
|
|
41203
44455
|
printLine(CLI_VERSION);
|
|
41204
44456
|
return 0;
|
|
41205
44457
|
}
|
|
41206
|
-
const
|
|
41207
|
-
if (
|
|
44458
|
+
const outcome = await runCommandLine(startContext, argv, { globals });
|
|
44459
|
+
if (outcome.kind === "unknown") {
|
|
41208
44460
|
process.stderr.write(`${red("Unknown command:")} ${argv.join(" ")}\n\n${helpText()}\n`);
|
|
41209
44461
|
return 2;
|
|
41210
44462
|
}
|
|
41211
|
-
|
|
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;
|
|
44463
|
+
return outcome.code;
|
|
41240
44464
|
}
|
|
41241
44465
|
/** Grace for stray handles before a finished command is ended by force. */
|
|
41242
44466
|
const EXIT_GRACE_MS = 3e3;
|
|
@@ -41261,4 +44485,4 @@ if (process.env.VITEST === void 0) main(process.argv.slice(2)).then(finish, (err
|
|
|
41261
44485
|
finish(err instanceof CliError ? err.exitCode : 1);
|
|
41262
44486
|
});
|
|
41263
44487
|
//#endregion
|
|
41264
|
-
export { findCommand, main, splitLeadingGlobals };
|
|
44488
|
+
export { findCommand, helpText, main, splitLeadingGlobals };
|