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