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