vibeview 0.31.0 → 0.33.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/dist/{chunk-ZZK7YQSP.js → chunk-4D2E2PE4.js} +13 -3
- package/dist/{chunk-P6UIJALK.js → chunk-D3OZPFJ3.js} +205 -25
- package/dist/cli.js +69 -18
- package/dist/{server-GPFOZOFL.js → server-GHCQHKYX.js} +336 -74
- package/dist/{upload-app-2LVG4LMB.js → upload-app-AKD73742.js} +1 -1
- package/package.json +1 -1
- package/skills/vibeview-agent/SKILL.md +20 -10
|
@@ -377,9 +377,19 @@ var ApiClient = class {
|
|
|
377
377
|
/** Report presence on a session and read it back in one call. A Roku dev
|
|
378
378
|
* loop has no tunnel and no browser, so this is what tells the backend
|
|
379
379
|
* someone is still using the session between device commands. Same
|
|
380
|
-
* response shape as `getSession`.
|
|
381
|
-
|
|
382
|
-
|
|
380
|
+
* response shape as `getSession`.
|
|
381
|
+
*
|
|
382
|
+
* `lease: true` also renews the caller's hold on the session: a dev loop
|
|
383
|
+
* (`vibeview dev`, `--detach`, MCP `dev_start`) renews it every
|
|
384
|
+
* `AGENT_LEASE_RENEW_MS`, and once renewals stop — the process was killed,
|
|
385
|
+
* crashed, or the computer went to sleep — the backend ends the session
|
|
386
|
+
* about a minute later instead of after the org idle timeout. */
|
|
387
|
+
async touchSession(sessionId, opts = {}) {
|
|
388
|
+
return this.requestRaw(
|
|
389
|
+
"POST",
|
|
390
|
+
`/sessions/${sessionId}/presence`,
|
|
391
|
+
opts.lease ? { lease: true } : void 0
|
|
392
|
+
);
|
|
383
393
|
}
|
|
384
394
|
async stopSession(sessionId) {
|
|
385
395
|
await this.requestRaw("DELETE", `/sessions/${sessionId}`);
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
resolveArtifact,
|
|
14
14
|
resolveAuth,
|
|
15
15
|
uploadAppBuild
|
|
16
|
-
} from "./chunk-
|
|
16
|
+
} from "./chunk-4D2E2PE4.js";
|
|
17
17
|
|
|
18
18
|
// src/lib/dev-config.ts
|
|
19
19
|
import { readFileSync, renameSync, rmSync, writeFileSync } from "fs";
|
|
@@ -360,6 +360,30 @@ function checkDevBuild(app, buildId, platform, opts = {}) {
|
|
|
360
360
|
}
|
|
361
361
|
return build;
|
|
362
362
|
}
|
|
363
|
+
function debugBuildsNewestFirst(app) {
|
|
364
|
+
const time = (b) => b.uploaded_at ? Date.parse(b.uploaded_at) || 0 : 0;
|
|
365
|
+
return app.builds.filter((b) => canUseMetro(b) === true).sort((a, b) => time(b) - time(a) || b.id - a.id);
|
|
366
|
+
}
|
|
367
|
+
function pickDevBuild(app, localVersion) {
|
|
368
|
+
const builds = debugBuildsNewestFirst(app);
|
|
369
|
+
if (builds.length === 0) return null;
|
|
370
|
+
const newest = builds[0];
|
|
371
|
+
if (!localVersion || newest.version === localVersion) return { build: newest, reason: null };
|
|
372
|
+
const match = builds.find((b) => b.version === localVersion);
|
|
373
|
+
if (!match) return { build: newest, reason: null };
|
|
374
|
+
return {
|
|
375
|
+
build: match,
|
|
376
|
+
reason: `the newest debug build (${newest.id}) is version ${newest.version ?? "unknown"}, this project is version ${localVersion}`
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
function buildLabel(b) {
|
|
380
|
+
const parts = [];
|
|
381
|
+
if (b.version) parts.push(`version ${b.version}${b.build_number ? ` (${b.build_number})` : ""}`);
|
|
382
|
+
parts.push(b.kind ?? buildKind(b));
|
|
383
|
+
if (b.uploaded_at) parts.push(`uploaded ${formatUtcTimestamp(b.uploaded_at)}`);
|
|
384
|
+
if (b.note) parts.push(JSON.stringify(b.note.length > 60 ? `${b.note.slice(0, 57)}...` : b.note));
|
|
385
|
+
return `${b.id} (${parts.join(", ")})`;
|
|
386
|
+
}
|
|
363
387
|
|
|
364
388
|
// src/commands/list-builds.ts
|
|
365
389
|
import chalk from "chalk";
|
|
@@ -461,6 +485,10 @@ function writeDevState(cwd, state) {
|
|
|
461
485
|
ensureGitignored(cwd);
|
|
462
486
|
}
|
|
463
487
|
function readDevState(cwd) {
|
|
488
|
+
const found = readDevStateFile(cwd);
|
|
489
|
+
return found && found.alive ? found.state : null;
|
|
490
|
+
}
|
|
491
|
+
function readDevStateFile(cwd) {
|
|
464
492
|
let raw;
|
|
465
493
|
try {
|
|
466
494
|
raw = readFileSync2(statePath(cwd), "utf-8");
|
|
@@ -473,10 +501,10 @@ function readDevState(cwd) {
|
|
|
473
501
|
} catch {
|
|
474
502
|
return null;
|
|
475
503
|
}
|
|
476
|
-
if (typeof parsed.pid !== "number" ||
|
|
504
|
+
if (!parsed || typeof parsed.pid !== "number" || typeof parsed.session_id !== "string") {
|
|
477
505
|
return null;
|
|
478
506
|
}
|
|
479
|
-
return parsed;
|
|
507
|
+
return { state: parsed, alive: isPidAlive(parsed.pid) };
|
|
480
508
|
}
|
|
481
509
|
function clearDevState(cwd) {
|
|
482
510
|
const path = statePath(cwd);
|
|
@@ -600,16 +628,6 @@ function describeLocalChanges(before, after) {
|
|
|
600
628
|
return changes;
|
|
601
629
|
}
|
|
602
630
|
|
|
603
|
-
// src/commands/build.ts
|
|
604
|
-
import { spawn as spawn2 } from "child_process";
|
|
605
|
-
import { createInterface } from "readline";
|
|
606
|
-
import { select as select2 } from "@inquirer/prompts";
|
|
607
|
-
import chalk3 from "chalk";
|
|
608
|
-
|
|
609
|
-
// src/lib/dev-setup.ts
|
|
610
|
-
import chalk2 from "chalk";
|
|
611
|
-
import { input, select } from "@inquirer/prompts";
|
|
612
|
-
|
|
613
631
|
// src/lib/project-detect.ts
|
|
614
632
|
import { existsSync as existsSync2, readFileSync as readFileSync4, readdirSync, statSync } from "fs";
|
|
615
633
|
import { join as join4 } from "path";
|
|
@@ -787,8 +805,72 @@ function productionDefaultsFor(cwd, platform) {
|
|
|
787
805
|
}
|
|
788
806
|
return {};
|
|
789
807
|
}
|
|
808
|
+
function uniqueMatch(text, re) {
|
|
809
|
+
const found = /* @__PURE__ */ new Set();
|
|
810
|
+
for (const m of text.matchAll(re)) {
|
|
811
|
+
const v = m[1].trim().replace(/^"|"$/g, "");
|
|
812
|
+
if (v && !v.includes("$")) found.add(v);
|
|
813
|
+
}
|
|
814
|
+
return found.size === 1 ? [...found][0] : null;
|
|
815
|
+
}
|
|
816
|
+
function readText(path) {
|
|
817
|
+
try {
|
|
818
|
+
return readFileSync4(path, "utf-8");
|
|
819
|
+
} catch {
|
|
820
|
+
return null;
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
function pbxprojText(cwd) {
|
|
824
|
+
try {
|
|
825
|
+
const proj = readdirSync(join4(cwd, "ios")).find((f) => f.endsWith(".xcodeproj"));
|
|
826
|
+
return proj ? readText(join4(cwd, "ios", proj, "project.pbxproj")) : null;
|
|
827
|
+
} catch {
|
|
828
|
+
return null;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
function localAppIdentity(cwd, platform) {
|
|
832
|
+
const rn = readJson(cwd, "node_modules/react-native/package.json");
|
|
833
|
+
const reactNative = typeof rn?.version === "string" ? rn.version : null;
|
|
834
|
+
const android = platform === "android" || platform === "androidtv";
|
|
835
|
+
const ios = platform === "ios" || platform === "tvos";
|
|
836
|
+
let version = null;
|
|
837
|
+
let bundleId = null;
|
|
838
|
+
const dynamic = existsSync2(join4(cwd, "app.config.js")) || existsSync2(join4(cwd, "app.config.ts"));
|
|
839
|
+
const raw = dynamic ? null : readJson(cwd, "app.config.json") ?? readJson(cwd, "app.json");
|
|
840
|
+
const expo = raw ? "expo" in raw ? raw.expo : raw : null;
|
|
841
|
+
if (expo && typeof expo === "object") {
|
|
842
|
+
const cfg = expo;
|
|
843
|
+
if (typeof cfg.version === "string") version = cfg.version;
|
|
844
|
+
const section = android ? cfg.android : ios ? cfg.ios : null;
|
|
845
|
+
const id = android ? section?.package : section?.bundleIdentifier;
|
|
846
|
+
if (typeof id === "string") bundleId = id;
|
|
847
|
+
}
|
|
848
|
+
if (android && (!version || !bundleId)) {
|
|
849
|
+
const gradle = readText(join4(cwd, "android", "app", "build.gradle")) ?? readText(join4(cwd, "android", "app", "build.gradle.kts"));
|
|
850
|
+
if (gradle) {
|
|
851
|
+
version ??= uniqueMatch(gradle, /versionName\s*=?\s*["']([^"']+)["']/g);
|
|
852
|
+
bundleId ??= uniqueMatch(gradle, /applicationId\s*=?\s*["']([^"']+)["']/g);
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
if (ios && (!version || !bundleId)) {
|
|
856
|
+
const pbx = pbxprojText(cwd);
|
|
857
|
+
if (pbx) {
|
|
858
|
+
version ??= uniqueMatch(pbx, /MARKETING_VERSION = ([^;]+);/g);
|
|
859
|
+
bundleId ??= uniqueMatch(pbx, /PRODUCT_BUNDLE_IDENTIFIER = ([^;]+);/g);
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
return { reactNative, version, bundleId };
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// src/commands/build.ts
|
|
866
|
+
import { spawn as spawn2 } from "child_process";
|
|
867
|
+
import { createInterface } from "readline";
|
|
868
|
+
import { select as select2 } from "@inquirer/prompts";
|
|
869
|
+
import chalk3 from "chalk";
|
|
790
870
|
|
|
791
871
|
// src/lib/dev-setup.ts
|
|
872
|
+
import chalk2 from "chalk";
|
|
873
|
+
import { input, select } from "@inquirer/prompts";
|
|
792
874
|
function platformChoices(cwd) {
|
|
793
875
|
const choices = [
|
|
794
876
|
{ value: "ios", name: "iOS simulator" },
|
|
@@ -2355,6 +2437,12 @@ function parseWatchSeconds(flag) {
|
|
|
2355
2437
|
}
|
|
2356
2438
|
return seconds;
|
|
2357
2439
|
}
|
|
2440
|
+
var AGENT_LEASE_RENEW_MS = 15e3;
|
|
2441
|
+
var AGENT_LEASE_TTL_MS = 6e4;
|
|
2442
|
+
function leaseLapsedReason(gapMs) {
|
|
2443
|
+
if (gapMs <= AGENT_LEASE_TTL_MS) return void 0;
|
|
2444
|
+
return `the session ended because this computer did not check in with VibeView for ${Math.round(gapMs / 1e3)}s (asleep, offline, or the process was paused) \u2014 start a new session to continue`;
|
|
2445
|
+
}
|
|
2358
2446
|
function queuedLine(position, reasonMessage) {
|
|
2359
2447
|
const where = position ? ` (position ${position})` : "";
|
|
2360
2448
|
const why = reasonMessage ?? "Every device is busy.";
|
|
@@ -2375,9 +2463,14 @@ async function startDevSession(cfg, onEvent = () => {
|
|
|
2375
2463
|
let stopping = false;
|
|
2376
2464
|
let tunnel = null;
|
|
2377
2465
|
let queueEntryId = null;
|
|
2466
|
+
let lastLeaseAt = Date.now();
|
|
2467
|
+
let leaseTimer = null;
|
|
2378
2468
|
const stopSession = async (code) => {
|
|
2379
2469
|
if (stopping) return;
|
|
2380
2470
|
stopping = true;
|
|
2471
|
+
if (leaseTimer) clearInterval(leaseTimer);
|
|
2472
|
+
leaseTimer = null;
|
|
2473
|
+
const reason = code !== 0 ? leaseLapsedReason(Date.now() - lastLeaseAt) : void 0;
|
|
2381
2474
|
tunnel?.stop();
|
|
2382
2475
|
if (queueEntryId !== null) {
|
|
2383
2476
|
await api.cancelQueueEntry(queueEntryId).catch(() => {
|
|
@@ -2386,8 +2479,27 @@ async function startDevSession(cfg, onEvent = () => {
|
|
|
2386
2479
|
}
|
|
2387
2480
|
await api.stopSession(sessionId).catch(() => {
|
|
2388
2481
|
});
|
|
2389
|
-
onEvent({ event: "session_ended", session_id: sessionId, code });
|
|
2482
|
+
onEvent({ event: "session_ended", session_id: sessionId, code, ...reason ? { reason } : {} });
|
|
2483
|
+
};
|
|
2484
|
+
const renewLease = async () => {
|
|
2485
|
+
if (stopping) return;
|
|
2486
|
+
let info;
|
|
2487
|
+
try {
|
|
2488
|
+
info = await api.touchSession(sessionId, { lease: true });
|
|
2489
|
+
} catch {
|
|
2490
|
+
return;
|
|
2491
|
+
}
|
|
2492
|
+
if (stopping) return;
|
|
2493
|
+
if (info.status === "stopped" || info.status === "failed") {
|
|
2494
|
+
void stopSession(1);
|
|
2495
|
+
return;
|
|
2496
|
+
}
|
|
2497
|
+
lastLeaseAt = Date.now();
|
|
2390
2498
|
};
|
|
2499
|
+
void renewLease();
|
|
2500
|
+
leaseTimer = setInterval(() => void renewLease(), AGENT_LEASE_RENEW_MS);
|
|
2501
|
+
leaseTimer.unref?.();
|
|
2502
|
+
cfg.onCreated?.({ sessionId, stop: () => stopSession(1) });
|
|
2391
2503
|
try {
|
|
2392
2504
|
const started = await api.startSession(sessionId);
|
|
2393
2505
|
if (started.status === "queued") {
|
|
@@ -2402,6 +2514,7 @@ async function startDevSession(cfg, onEvent = () => {
|
|
|
2402
2514
|
});
|
|
2403
2515
|
await pollWhileQueued(api, sessionId, QUEUE_WAIT_MS);
|
|
2404
2516
|
queueEntryId = null;
|
|
2517
|
+
cfg.onDequeued?.();
|
|
2405
2518
|
}
|
|
2406
2519
|
const pageUrl = `${cfg.webUrl || cfg.baseUrl}/sandbox/${sessionId}`;
|
|
2407
2520
|
const reportedWarnings = /* @__PURE__ */ new Set();
|
|
@@ -2454,7 +2567,14 @@ async function startDevSession(cfg, onEvent = () => {
|
|
|
2454
2567
|
reportedWarnings.add(w);
|
|
2455
2568
|
onEvent({ event: "warning", message: w });
|
|
2456
2569
|
}
|
|
2457
|
-
return {
|
|
2570
|
+
return {
|
|
2571
|
+
sessionId,
|
|
2572
|
+
pageUrl,
|
|
2573
|
+
stop: () => stopSession(0),
|
|
2574
|
+
reportedWarnings,
|
|
2575
|
+
session: active,
|
|
2576
|
+
lapsedReason: () => leaseLapsedReason(Date.now() - lastLeaseAt)
|
|
2577
|
+
};
|
|
2458
2578
|
} catch (err) {
|
|
2459
2579
|
await stopSession(1);
|
|
2460
2580
|
throw err;
|
|
@@ -2575,7 +2695,7 @@ async function pollUntilActive(api, sessionId, timeoutMs, intervalMs = 2e3, onIn
|
|
|
2575
2695
|
}
|
|
2576
2696
|
function sessionLivenessVerdict(info) {
|
|
2577
2697
|
if (info && (info.status === "stopped" || info.status === "failed")) {
|
|
2578
|
-
return { end: true, message: `session ${info.status} \u2014 ending the
|
|
2698
|
+
return { end: true, message: `session ${info.status} \u2014 ending the dev session` };
|
|
2579
2699
|
}
|
|
2580
2700
|
return { end: false };
|
|
2581
2701
|
}
|
|
@@ -3098,7 +3218,7 @@ async function runDevInProcess(opts) {
|
|
|
3098
3218
|
chalk4.dim(" Putting the channel on the Roku \u2014 the link appears once it is up.")
|
|
3099
3219
|
);
|
|
3100
3220
|
} else if (evt.event === "session_ended" && !stopping && handle) {
|
|
3101
|
-
console.error(chalk4.red("
|
|
3221
|
+
console.error(chalk4.red(evt.reason ?? "session ended by the server"));
|
|
3102
3222
|
stopping = true;
|
|
3103
3223
|
endedEmitted = true;
|
|
3104
3224
|
emit(opts.json, evt);
|
|
@@ -3197,6 +3317,8 @@ async function runDevInProcess(opts) {
|
|
|
3197
3317
|
process.stdin.pause();
|
|
3198
3318
|
});
|
|
3199
3319
|
}
|
|
3320
|
+
}
|
|
3321
|
+
if (rokuBuild || standalone) {
|
|
3200
3322
|
const sessionId = handle.sessionId;
|
|
3201
3323
|
const reportedWarnings = handle.reportedWarnings;
|
|
3202
3324
|
let keepAlive;
|
|
@@ -3211,8 +3333,14 @@ async function runDevInProcess(opts) {
|
|
|
3211
3333
|
}
|
|
3212
3334
|
const verdict = sessionLivenessVerdict(info);
|
|
3213
3335
|
if (verdict.end && !stopping) {
|
|
3214
|
-
|
|
3215
|
-
|
|
3336
|
+
const reason = handle?.lapsedReason?.();
|
|
3337
|
+
console.error(chalk4.red(reason ?? verdict.message));
|
|
3338
|
+
emit(opts.json, {
|
|
3339
|
+
event: "session_ended",
|
|
3340
|
+
session_id: sessionId,
|
|
3341
|
+
code: 1,
|
|
3342
|
+
...reason ? { reason } : {}
|
|
3343
|
+
});
|
|
3216
3344
|
endedEmitted = true;
|
|
3217
3345
|
await stopSession(1);
|
|
3218
3346
|
return;
|
|
@@ -3658,7 +3786,7 @@ var AGENT_COMMANDS = [
|
|
|
3658
3786
|
// iOS system alerts only.
|
|
3659
3787
|
verb: "alert",
|
|
3660
3788
|
tool: "alert",
|
|
3661
|
-
summary: "Inspect or respond to a visible
|
|
3789
|
+
summary: "Inspect or respond to a visible system alert on iOS or Apple TV (get, accept, or dismiss). `get` returns the message and the button labels.",
|
|
3662
3790
|
mutating: true,
|
|
3663
3791
|
params: [
|
|
3664
3792
|
{
|
|
@@ -3741,7 +3869,7 @@ var AGENT_COMMANDS = [
|
|
|
3741
3869
|
// tools.py open_url: { url: string }.
|
|
3742
3870
|
verb: "open-url",
|
|
3743
3871
|
tool: "open_url",
|
|
3744
|
-
summary: "Open a deep link
|
|
3872
|
+
summary: "Open a deep link in the app under test. The link goes to that app only, never a browser: on Android a link the app has no screen for fails; on iOS only the app's own URL scheme (e.g. myapp://settings) is accepted, and a web or system link is refused.",
|
|
3745
3873
|
mutating: true,
|
|
3746
3874
|
params: [
|
|
3747
3875
|
{
|
|
@@ -3753,6 +3881,16 @@ var AGENT_COMMANDS = [
|
|
|
3753
3881
|
}
|
|
3754
3882
|
]
|
|
3755
3883
|
},
|
|
3884
|
+
{
|
|
3885
|
+
// relaunch_app: {} — terminate the session's app and launch it again the
|
|
3886
|
+
// way the session launched it (same launch params); its data is kept.
|
|
3887
|
+
// Refused on embed sessions (their watchdog owns launching) and on Roku.
|
|
3888
|
+
verb: "relaunch-app",
|
|
3889
|
+
tool: "relaunch_app",
|
|
3890
|
+
summary: "Close the app under test and start it again: a cold start that keeps the app's data, e.g. to check that something persists. Its log keeps streaming across the restart. Not available on Roku or embed sessions.",
|
|
3891
|
+
mutating: true,
|
|
3892
|
+
params: []
|
|
3893
|
+
},
|
|
3756
3894
|
{
|
|
3757
3895
|
// set_posture: { posture: 'closed' | 'partial' | 'open' } OR { angle: 0..180 }
|
|
3758
3896
|
// — exactly one (buildToolCall enforces it CLI-side, agent_control.py's
|
|
@@ -3799,6 +3937,33 @@ var AGENT_COMMANDS = [
|
|
|
3799
3937
|
}
|
|
3800
3938
|
]
|
|
3801
3939
|
},
|
|
3940
|
+
{
|
|
3941
|
+
// set_location: { latitude: -90..90, longitude: -180..180 } — both
|
|
3942
|
+
// required (agent_control.py validate_tool_args refuses anything else).
|
|
3943
|
+
// The same control as the dashboard's Set location. Flags rather than
|
|
3944
|
+
// positionals: a western/southern coordinate starts with `-`, which a
|
|
3945
|
+
// positional would read as an option.
|
|
3946
|
+
verb: "set-location",
|
|
3947
|
+
tool: "set_location",
|
|
3948
|
+
summary: "Set the device's simulated GPS location, for location-based flows. Phones, tablets and foldables (iOS and Android); not available on TV devices.",
|
|
3949
|
+
mutating: true,
|
|
3950
|
+
params: [
|
|
3951
|
+
{
|
|
3952
|
+
name: "latitude",
|
|
3953
|
+
cli: "--lat",
|
|
3954
|
+
type: "number",
|
|
3955
|
+
required: true,
|
|
3956
|
+
description: "Latitude in decimal degrees, -90 to 90."
|
|
3957
|
+
},
|
|
3958
|
+
{
|
|
3959
|
+
name: "longitude",
|
|
3960
|
+
cli: "--lon",
|
|
3961
|
+
type: "number",
|
|
3962
|
+
required: true,
|
|
3963
|
+
description: "Longitude in decimal degrees, -180 to 180 (west is negative)."
|
|
3964
|
+
}
|
|
3965
|
+
]
|
|
3966
|
+
},
|
|
3802
3967
|
{
|
|
3803
3968
|
// tools.py wait: { seconds?: number } (default: 2). Note the wire arg is
|
|
3804
3969
|
// "seconds", NOT "ms" — tool_dispatch.py reads args.get("seconds", 2)
|
|
@@ -3943,6 +4108,14 @@ function resultLines(result) {
|
|
|
3943
4108
|
const state = deviceStateLine(r);
|
|
3944
4109
|
if (state) lines.push(state);
|
|
3945
4110
|
}
|
|
4111
|
+
if (Array.isArray(r.buttons) && r.buttons.length > 0) {
|
|
4112
|
+
const labels = r.buttons.filter((b) => typeof b === "string");
|
|
4113
|
+
if (labels.length > 0)
|
|
4114
|
+
lines.push(`buttons: ${labels.map((b) => JSON.stringify(b)).join(", ")}`);
|
|
4115
|
+
}
|
|
4116
|
+
if (r.action === "location_set" && typeof r.latitude === "number" && typeof r.longitude === "number") {
|
|
4117
|
+
lines.push(`location: ${r.latitude}, ${r.longitude}`);
|
|
4118
|
+
}
|
|
3946
4119
|
if (typeof r.note === "string" && r.note) lines.push(chalk6.dim(r.note));
|
|
3947
4120
|
if (typeof r.message === "string" && r.message) lines.push(r.message);
|
|
3948
4121
|
if (typeof r.warning === "string" && r.warning) lines.push(r.warning);
|
|
@@ -3953,8 +4126,8 @@ function deviceStateLine(r) {
|
|
|
3953
4126
|
if (r.action === "rotated") {
|
|
3954
4127
|
if (typeof r.device_orientation === "string") {
|
|
3955
4128
|
let o = `device held ${r.device_orientation}`;
|
|
3956
|
-
if (typeof r.screen_orientation === "string" && r.
|
|
3957
|
-
o += ` (picture on the lit screen: ${r.screen_orientation})`;
|
|
4129
|
+
if (typeof r.screen_orientation === "string" && !r.device_orientation.startsWith(r.screen_orientation)) {
|
|
4130
|
+
o += typeof r.posture === "string" ? ` (picture on the lit screen: ${r.screen_orientation})` : ` (screen stayed ${r.screen_orientation})`;
|
|
3958
4131
|
}
|
|
3959
4132
|
parts.push(o);
|
|
3960
4133
|
}
|
|
@@ -4190,7 +4363,9 @@ async function runAgentVerb(cmd, positionals, opts) {
|
|
|
4190
4363
|
}
|
|
4191
4364
|
writeFileSync6(outPath, imageBytes);
|
|
4192
4365
|
if (opts.json) {
|
|
4193
|
-
console.log(
|
|
4366
|
+
console.log(
|
|
4367
|
+
JSON.stringify({ ...envelope, result: { written_to: outPath, bytes: imageBytes.length } })
|
|
4368
|
+
);
|
|
4194
4369
|
} else {
|
|
4195
4370
|
process.stderr.write(renderEnvelope(envelope) + "\n");
|
|
4196
4371
|
console.log(outPath);
|
|
@@ -4216,17 +4391,23 @@ export {
|
|
|
4216
4391
|
loadDevConfig,
|
|
4217
4392
|
resolveMetro,
|
|
4218
4393
|
parseBuildId,
|
|
4394
|
+
buildKind,
|
|
4219
4395
|
checkDevBuild,
|
|
4396
|
+
debugBuildsNewestFirst,
|
|
4397
|
+
pickDevBuild,
|
|
4398
|
+
buildLabel,
|
|
4220
4399
|
buildRows,
|
|
4221
4400
|
formatBuildRowsHuman,
|
|
4222
4401
|
listBuilds,
|
|
4223
4402
|
readDevState,
|
|
4403
|
+
readDevStateFile,
|
|
4224
4404
|
clearDevState,
|
|
4225
4405
|
readMetroState,
|
|
4226
4406
|
killOwnedMetro,
|
|
4227
4407
|
checkMetro,
|
|
4228
4408
|
loadLocalDevConfig,
|
|
4229
4409
|
effectiveDevConfig,
|
|
4410
|
+
localAppIdentity,
|
|
4230
4411
|
buildCommand,
|
|
4231
4412
|
formatDuration,
|
|
4232
4413
|
BUILD_TERMINAL_STATUSES,
|
|
@@ -4236,7 +4417,6 @@ export {
|
|
|
4236
4417
|
printOutcomeLine,
|
|
4237
4418
|
createRokuPush,
|
|
4238
4419
|
startDevSession,
|
|
4239
|
-
ROKU_LIVENESS_INTERVAL_MS,
|
|
4240
4420
|
PLATFORM_DEVICE,
|
|
4241
4421
|
dev,
|
|
4242
4422
|
formatDevicesHuman,
|
package/dist/cli.js
CHANGED
|
@@ -21,9 +21,10 @@ import {
|
|
|
21
21
|
printOutcomeLine,
|
|
22
22
|
printTransition,
|
|
23
23
|
readDevState,
|
|
24
|
+
readDevStateFile,
|
|
24
25
|
readMetroState,
|
|
25
26
|
runAgentVerb
|
|
26
|
-
} from "./chunk-
|
|
27
|
+
} from "./chunk-D3OZPFJ3.js";
|
|
27
28
|
import {
|
|
28
29
|
ApiClient,
|
|
29
30
|
ApiStatusError,
|
|
@@ -44,7 +45,7 @@ import {
|
|
|
44
45
|
resolveAuth,
|
|
45
46
|
saveConfig,
|
|
46
47
|
uploadApp
|
|
47
|
-
} from "./chunk-
|
|
48
|
+
} from "./chunk-4D2E2PE4.js";
|
|
48
49
|
|
|
49
50
|
// src/cli.ts
|
|
50
51
|
import { createRequire } from "module";
|
|
@@ -543,15 +544,20 @@ function formatAction(action) {
|
|
|
543
544
|
function formatToolCall(tc) {
|
|
544
545
|
return formatAction({ tool: tc.name, args: tc.args });
|
|
545
546
|
}
|
|
547
|
+
function runStartedLine(evt) {
|
|
548
|
+
const steps = `${evt.total_steps} step(s)`;
|
|
549
|
+
const model = chalk2.bold(evt.model || "default");
|
|
550
|
+
if (evt.mode === "replay") return `${chalk2.cyan("\u25B6")} Replay started \u2014 ${steps} (no AI)`;
|
|
551
|
+
if (evt.mode === "hybrid") {
|
|
552
|
+
return `${chalk2.cyan("\u25B6")} Hybrid test started \u2014 ${steps}, replaying the recording, AI fallback model: ${model}`;
|
|
553
|
+
}
|
|
554
|
+
return `${chalk2.cyan("\u25B6")} AI test started \u2014 ${steps}, model: ${model}`;
|
|
555
|
+
}
|
|
546
556
|
function displayProgressEvent(evt, ctx) {
|
|
547
557
|
const { spinner } = ctx;
|
|
548
558
|
switch (evt.event) {
|
|
549
559
|
case "run_started":
|
|
550
|
-
printBelowSpinner(
|
|
551
|
-
spinner,
|
|
552
|
-
`${chalk2.cyan("\u25B6")} AI test started \u2014 ${evt.total_steps} step(s), model: ${chalk2.bold(evt.model || "default")}`,
|
|
553
|
-
" Starting test..."
|
|
554
|
-
);
|
|
560
|
+
printBelowSpinner(spinner, runStartedLine(evt), " Starting test...");
|
|
555
561
|
ctx.totalSteps = evt.total_steps ?? ctx.totalSteps;
|
|
556
562
|
break;
|
|
557
563
|
case "step_started": {
|
|
@@ -1348,6 +1354,9 @@ async function resolveSteps(options) {
|
|
|
1348
1354
|
}
|
|
1349
1355
|
throw new Error("No steps provided. Use --steps, --steps-file, or pipe JSON to stdin.");
|
|
1350
1356
|
}
|
|
1357
|
+
function testCaseUrl(baseUrl, publicId) {
|
|
1358
|
+
return `${baseUrl}/tests/cases/${encodeURIComponent(publicId)}`;
|
|
1359
|
+
}
|
|
1351
1360
|
function assertHasSteps(steps) {
|
|
1352
1361
|
if (!Array.isArray(steps)) {
|
|
1353
1362
|
throw new Error(
|
|
@@ -1407,7 +1416,7 @@ async function createTest(options) {
|
|
|
1407
1416
|
name: result.name,
|
|
1408
1417
|
app_id: appPublicId || null,
|
|
1409
1418
|
outcome: result.outcome ?? null,
|
|
1410
|
-
url:
|
|
1419
|
+
url: testCaseUrl(baseUrl, result.public_id)
|
|
1411
1420
|
},
|
|
1412
1421
|
null,
|
|
1413
1422
|
2
|
|
@@ -1423,7 +1432,7 @@ async function createTest(options) {
|
|
|
1423
1432
|
console.log(` ${chalk10.dim(`Platform: ${result.platform}`)}`);
|
|
1424
1433
|
}
|
|
1425
1434
|
console.log(` ${chalk10.dim(`Steps: ${steps.length}`)}`);
|
|
1426
|
-
console.log(chalk10.dim(` ${baseUrl}
|
|
1435
|
+
console.log(chalk10.dim(` ${testCaseUrl(baseUrl, result.public_id)}
|
|
1427
1436
|
`));
|
|
1428
1437
|
}
|
|
1429
1438
|
} catch (err) {
|
|
@@ -1519,8 +1528,8 @@ function buildLine(info) {
|
|
|
1519
1528
|
return `${info.app_build_id}${version}`;
|
|
1520
1529
|
}
|
|
1521
1530
|
async function devStatus(opts) {
|
|
1522
|
-
const
|
|
1523
|
-
if (!
|
|
1531
|
+
const found = readDevStateFile(process.cwd());
|
|
1532
|
+
if (!found) {
|
|
1524
1533
|
if (opts.json) {
|
|
1525
1534
|
console.log(JSON.stringify({ running: false }));
|
|
1526
1535
|
} else {
|
|
@@ -1531,6 +1540,11 @@ async function devStatus(opts) {
|
|
|
1531
1540
|
}
|
|
1532
1541
|
const { token, baseUrl } = resolveAuth(opts);
|
|
1533
1542
|
const api = new ApiClient(baseUrl, token);
|
|
1543
|
+
if (!found.alive) {
|
|
1544
|
+
await reportOrphan(api, found.state, opts);
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
const state = found.state;
|
|
1534
1548
|
let sessionStatus;
|
|
1535
1549
|
let info;
|
|
1536
1550
|
try {
|
|
@@ -1571,11 +1585,46 @@ async function devStatus(opts) {
|
|
|
1571
1585
|
if (metro) console.error(chalk11.dim(`Metro: pid ${metro.pid} on :${metro.port} (CLI-owned)`));
|
|
1572
1586
|
process.exit(0);
|
|
1573
1587
|
}
|
|
1588
|
+
var LIVE_STATUSES = /* @__PURE__ */ new Set(["pending", "queued", "starting", "active"]);
|
|
1589
|
+
async function reportOrphan(api, state, opts) {
|
|
1590
|
+
let status;
|
|
1591
|
+
try {
|
|
1592
|
+
status = (await api.getSession(state.session_id)).status;
|
|
1593
|
+
} catch {
|
|
1594
|
+
status = void 0;
|
|
1595
|
+
}
|
|
1596
|
+
const live = status !== void 0 && LIVE_STATUSES.has(status);
|
|
1597
|
+
if (!live && status !== void 0) clearDevState(process.cwd());
|
|
1598
|
+
if (opts.json) {
|
|
1599
|
+
console.log(
|
|
1600
|
+
JSON.stringify({
|
|
1601
|
+
running: false,
|
|
1602
|
+
pid: state.pid,
|
|
1603
|
+
session_id: state.session_id,
|
|
1604
|
+
session_status: status ?? null,
|
|
1605
|
+
orphaned: live
|
|
1606
|
+
})
|
|
1607
|
+
);
|
|
1608
|
+
} else if (live) {
|
|
1609
|
+
console.error(
|
|
1610
|
+
chalk11.yellow(
|
|
1611
|
+
`\u26A0 The dev process (pid ${state.pid}) has exited, but session ${state.session_id} is still ${status}.`
|
|
1612
|
+
)
|
|
1613
|
+
);
|
|
1614
|
+
console.error(` End it: vibeview stop ${state.session_id}`);
|
|
1615
|
+
} else {
|
|
1616
|
+
console.error(
|
|
1617
|
+
`No dev session running \u2014 the dev process (pid ${state.pid}) has exited` + (status ? ` and session ${state.session_id} is ${status}.` : ".")
|
|
1618
|
+
);
|
|
1619
|
+
}
|
|
1620
|
+
process.exit(1);
|
|
1621
|
+
}
|
|
1574
1622
|
|
|
1575
1623
|
// src/commands/dev-stop.ts
|
|
1576
1624
|
async function devStop(opts) {
|
|
1577
|
-
const
|
|
1578
|
-
|
|
1625
|
+
const found = readDevStateFile(process.cwd());
|
|
1626
|
+
const state = found?.state;
|
|
1627
|
+
if (!found || !state) {
|
|
1579
1628
|
if (opts.json) {
|
|
1580
1629
|
console.log(JSON.stringify({ stopped: false, reason: "no_dev_session" }));
|
|
1581
1630
|
} else {
|
|
@@ -1588,10 +1637,12 @@ async function devStop(opts) {
|
|
|
1588
1637
|
const api = new ApiClient(baseUrl, token);
|
|
1589
1638
|
await api.stopSession(state.session_id).catch(() => {
|
|
1590
1639
|
});
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1640
|
+
if (found.alive) {
|
|
1641
|
+
try {
|
|
1642
|
+
process.kill(state.pid, "SIGTERM");
|
|
1643
|
+
} catch (err) {
|
|
1644
|
+
if (err.code !== "ESRCH") throw err;
|
|
1645
|
+
}
|
|
1595
1646
|
}
|
|
1596
1647
|
clearDevState(process.cwd());
|
|
1597
1648
|
killOwnedMetro(process.cwd());
|
|
@@ -4028,7 +4079,7 @@ program.command("dev-reload").description(
|
|
|
4028
4079
|
program.command("mcp").description(
|
|
4029
4080
|
"Run an MCP stdio server exposing the agent verbs (tap, swipe, type, ...) plus dev_start/dev_stop/dev_reload as tools"
|
|
4030
4081
|
).option("--token <token>", "API token (or set VIBEVIEW_API_TOKEN)").action(async (opts) => {
|
|
4031
|
-
const { runMcpServer } = await import("./server-
|
|
4082
|
+
const { runMcpServer } = await import("./server-GHCQHKYX.js");
|
|
4032
4083
|
await runMcpServer({ ...opts, version: pkg.version });
|
|
4033
4084
|
});
|
|
4034
4085
|
program.command("agent-setup").description(
|
|
@@ -4,12 +4,14 @@ import {
|
|
|
4
4
|
DEV_PLATFORMS,
|
|
5
5
|
PLATFORM_DEVICE,
|
|
6
6
|
PLATFORM_NEEDS_METRO,
|
|
7
|
-
|
|
7
|
+
buildKind,
|
|
8
|
+
buildLabel,
|
|
8
9
|
buildRows,
|
|
9
10
|
buildToolCall,
|
|
10
11
|
checkDevBuild,
|
|
11
12
|
checkMetro,
|
|
12
13
|
createRokuPush,
|
|
14
|
+
debugBuildsNewestFirst,
|
|
13
15
|
decodeScreenshotResult,
|
|
14
16
|
effectiveDevConfig,
|
|
15
17
|
filterDeviceModels,
|
|
@@ -18,18 +20,20 @@ import {
|
|
|
18
20
|
isDevPlatform,
|
|
19
21
|
loadDevConfig,
|
|
20
22
|
loadLocalDevConfig,
|
|
23
|
+
localAppIdentity,
|
|
21
24
|
parseBuildId,
|
|
25
|
+
pickDevBuild,
|
|
22
26
|
platformWithArticle,
|
|
23
27
|
readDevState,
|
|
24
28
|
renderEnvelope,
|
|
25
29
|
resolveMetro,
|
|
26
30
|
startDevSession
|
|
27
|
-
} from "./chunk-
|
|
31
|
+
} from "./chunk-D3OZPFJ3.js";
|
|
28
32
|
import {
|
|
29
33
|
ApiClient,
|
|
30
34
|
NotFoundError,
|
|
31
35
|
resolveAuth
|
|
32
|
-
} from "./chunk-
|
|
36
|
+
} from "./chunk-4D2E2PE4.js";
|
|
33
37
|
|
|
34
38
|
// src/mcp/server.ts
|
|
35
39
|
import { writeFileSync } from "fs";
|
|
@@ -45,14 +49,25 @@ function isMcpDevPlatform(p) {
|
|
|
45
49
|
return isDevPlatform(p) && MCP_DEV_PLATFORMS.includes(p);
|
|
46
50
|
}
|
|
47
51
|
var MCP_PARAM_DESCRIPTIONS = {
|
|
48
|
-
wait: { seconds: "How long to wait, in seconds. Default: 2." }
|
|
52
|
+
wait: { seconds: "How long to wait, in seconds. Default: 2." },
|
|
53
|
+
screenshot: {
|
|
54
|
+
out: "File path to write the PNG to on the machine running this server (default: a temp file)."
|
|
55
|
+
}
|
|
49
56
|
};
|
|
50
57
|
function paramToProperty(cmd, param) {
|
|
51
58
|
return {
|
|
52
59
|
type: param.type,
|
|
53
|
-
description: MCP_PARAM_DESCRIPTIONS[cmd.verb]?.[param.name] ?? param.description
|
|
60
|
+
description: MCP_PARAM_DESCRIPTIONS[cmd.verb]?.[param.name] ?? mcpText(cmd, param.description)
|
|
54
61
|
};
|
|
55
62
|
}
|
|
63
|
+
function mcpText(cmd, text) {
|
|
64
|
+
let out = text.replace(/`?(?:vibeview )?list-devices --models`?/g, "list_device_models").replace(/`vibeview ([a-z-]+)`/g, (_, verb) => toolNameFor(verb));
|
|
65
|
+
for (const param of cmd.params) {
|
|
66
|
+
if (param.cli === "positional") continue;
|
|
67
|
+
out = out.replace(new RegExp(`${param.cli}(?![a-z-])`, "g"), param.name);
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
56
71
|
function toolNameFor(verb) {
|
|
57
72
|
return verb.replace(/-/g, "_");
|
|
58
73
|
}
|
|
@@ -66,12 +81,12 @@ function toolDefFromCommand(cmd) {
|
|
|
66
81
|
if (cmd.verb === "screenshot") {
|
|
67
82
|
properties.inline = {
|
|
68
83
|
type: "boolean",
|
|
69
|
-
description: "Return the image itself so you can see it, not just the file path. Set this to true whenever you need to judge how the screen LOOKS, or whenever you cannot open files on the machine running this server \u2014 otherwise the screenshot is only written to disk and you never see it. It costs
|
|
84
|
+
description: "Return the image itself so you can see it, not just the file path. Set this to true whenever you need to judge how the screen LOOKS, or whenever you cannot open files on the machine running this server \u2014 otherwise the screenshot is only written to disk and you never see it. It costs context, so skip it when you only need to confirm an action happened (the response already tells you what changed). The image has the same size as the screenshot (a JPEG when the PNG is large), so a point read off it is the coordinate tap and drag take."
|
|
70
85
|
};
|
|
71
86
|
}
|
|
72
87
|
return {
|
|
73
88
|
name: toolNameFor(cmd.verb),
|
|
74
|
-
description: cmd.summary,
|
|
89
|
+
description: mcpText(cmd, cmd.summary),
|
|
75
90
|
mutating: cmd.mutating,
|
|
76
91
|
properties,
|
|
77
92
|
required
|
|
@@ -205,8 +220,15 @@ function errorResult(message) {
|
|
|
205
220
|
return { content: [{ type: "text", text: forMcp(message) }], isError: true };
|
|
206
221
|
}
|
|
207
222
|
function forMcp(message) {
|
|
208
|
-
let text = message.replace(/`vibeview list-devices --models`/g, "list_device_models").replace(/`vibeview list-builds <app>`/g, "list_builds").replace(/`vibeview list-builds ([^`\s]+)`/g, 'list_builds
|
|
223
|
+
let text = message.replace(/`vibeview list-devices --models`/g, "list_device_models").replace(/`vibeview list-builds <app>`/g, "list_builds").replace(/`vibeview list-builds ([^`\s]+)`/g, 'list_builds with app "$1"').replace(
|
|
224
|
+
/use `vibeview dev --standalone --build (\d+)`/g,
|
|
225
|
+
"call dev_start with standalone: true and build_id: $1"
|
|
226
|
+
).replace(
|
|
227
|
+
/Set VIBEVIEW_API_TOKEN \(or --token\) to a full-access token for this command\./g,
|
|
228
|
+
"Start this MCP server with a full-access token (VIBEVIEW_API_TOKEN or --token)."
|
|
229
|
+
).replace(/`vibeview list-apps`/g, "list_apps").replace(/\blist-apps\b/g, "list_apps").replace(/`vibeview upload-app`/g, "upload_app").replace(/`vibeview dev` needs/g, "dev_start needs");
|
|
209
230
|
if (/No debug build|is not a debug build/.test(text) && !/standalone: true/.test(text)) {
|
|
231
|
+
text = `${text.replace(/\s+$/, "")}${/[.!?]$/.test(text.trim()) ? "" : "."}`;
|
|
210
232
|
text += " To run a release build as it is, with no Metro, call dev_start with standalone: true.";
|
|
211
233
|
}
|
|
212
234
|
return text;
|
|
@@ -244,9 +266,9 @@ async function explainInactive(sessionId, deps) {
|
|
|
244
266
|
function findCommand(toolName) {
|
|
245
267
|
return AGENT_COMMANDS.find((c) => toolNameFor(c.verb) === toolName);
|
|
246
268
|
}
|
|
247
|
-
async function handleToolCall(toolName, args, deps) {
|
|
269
|
+
async function handleToolCall(toolName, args, deps, extra = {}) {
|
|
248
270
|
if (toolName === "dev_start") {
|
|
249
|
-
return handleDevStart(args, deps);
|
|
271
|
+
return handleDevStart(args, deps, extra);
|
|
250
272
|
}
|
|
251
273
|
if (toolName === "dev_stop") {
|
|
252
274
|
return handleDevStop(deps);
|
|
@@ -287,6 +309,9 @@ async function handleToolCall(toolName, args, deps) {
|
|
|
287
309
|
const positionals = positionalsFor(cmd, rest);
|
|
288
310
|
const opts = optsFor(cmd, rest);
|
|
289
311
|
call = buildToolCall(cmd, positionals, opts);
|
|
312
|
+
if (cmd.verb === "screenshot" && args.inline === true) {
|
|
313
|
+
call = { ...call, args: { ...call.args, preview: true } };
|
|
314
|
+
}
|
|
290
315
|
} catch (err) {
|
|
291
316
|
return errorResult(`error: ${err instanceof Error ? err.message : String(err)}`);
|
|
292
317
|
}
|
|
@@ -307,9 +332,10 @@ async function handleToolCall(toolName, args, deps) {
|
|
|
307
332
|
return textResult(renderEnvelope(envelope), envelope, !envelope.ok);
|
|
308
333
|
}
|
|
309
334
|
function screenshotToolResult(envelope, args) {
|
|
335
|
+
const { image, preview } = splitScreenshotResult(envelope.result);
|
|
310
336
|
let imageBytes;
|
|
311
337
|
try {
|
|
312
|
-
imageBytes = decodeScreenshotResult(
|
|
338
|
+
imageBytes = decodeScreenshotResult(image);
|
|
313
339
|
} catch (err) {
|
|
314
340
|
return errorResult(`error: ${err instanceof Error ? err.message : String(err)}`);
|
|
315
341
|
}
|
|
@@ -325,15 +351,54 @@ function screenshotToolResult(envelope, args) {
|
|
|
325
351
|
...envelope,
|
|
326
352
|
result: { written_to: outPath, bytes: imageBytes.length }
|
|
327
353
|
};
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
];
|
|
354
|
+
const lines = [`${renderEnvelope(sanitized)}
|
|
355
|
+
screenshot written to ${outPath}`];
|
|
356
|
+
let inlineBlock = null;
|
|
332
357
|
if (args.inline === true) {
|
|
333
|
-
|
|
358
|
+
if (preview) {
|
|
359
|
+
inlineBlock = { type: "image", data: preview.data, mimeType: preview.mimeType };
|
|
360
|
+
lines.push(
|
|
361
|
+
`inline image: ${preview.mimeType === "image/jpeg" ? "JPEG" : "PNG"} ${preview.width}x${preview.height}, ${kb(preview.bytes)} \u2014 the same size as the saved PNG, so a point read off it is the coordinate tap and drag take`
|
|
362
|
+
);
|
|
363
|
+
} else if (imageBytes.length <= INLINE_PNG_MAX_BYTES) {
|
|
364
|
+
inlineBlock = { type: "image", data: imageBytes.toString("base64"), mimeType: "image/png" };
|
|
365
|
+
} else {
|
|
366
|
+
lines.push(
|
|
367
|
+
`inline image left out: the PNG is ${kb(imageBytes.length)}, too large to send inline \u2014 open the file instead`
|
|
368
|
+
);
|
|
369
|
+
}
|
|
334
370
|
}
|
|
371
|
+
const content = [{ type: "text", text: lines.join("\n") }];
|
|
372
|
+
if (inlineBlock) content.push(inlineBlock);
|
|
335
373
|
return { content, structuredContent: sanitized };
|
|
336
374
|
}
|
|
375
|
+
var INLINE_PNG_MAX_BYTES = 15e5;
|
|
376
|
+
function kb(bytes) {
|
|
377
|
+
return bytes >= 1e6 ? `${(bytes / 1e6).toFixed(1)} MB` : `${Math.round(bytes / 1e3)} KB`;
|
|
378
|
+
}
|
|
379
|
+
function splitScreenshotResult(result) {
|
|
380
|
+
if (!result || typeof result !== "object" || !("image" in result)) {
|
|
381
|
+
return { image: result, preview: null };
|
|
382
|
+
}
|
|
383
|
+
const r = result;
|
|
384
|
+
const p = r.preview;
|
|
385
|
+
const url = typeof p?.data_url === "string" ? p.data_url : "";
|
|
386
|
+
const comma = url.indexOf(",");
|
|
387
|
+
if (!p || comma < 0 || typeof p.mime_type !== "string" || typeof p.width !== "number" || typeof p.height !== "number") {
|
|
388
|
+
return { image: r.image, preview: null };
|
|
389
|
+
}
|
|
390
|
+
const data = url.slice(comma + 1);
|
|
391
|
+
return {
|
|
392
|
+
image: r.image,
|
|
393
|
+
preview: {
|
|
394
|
+
data,
|
|
395
|
+
mimeType: p.mime_type,
|
|
396
|
+
width: p.width,
|
|
397
|
+
height: p.height,
|
|
398
|
+
bytes: typeof p.bytes === "number" ? p.bytes : Math.floor(data.length * 3 / 4)
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
}
|
|
337
402
|
function positionalsFor(cmd, args) {
|
|
338
403
|
return cmd.params.filter((p) => p.cli === "positional").map((p) => args[p.name] === void 0 ? void 0 : String(args[p.name])).filter((v) => v !== void 0);
|
|
339
404
|
}
|
|
@@ -348,7 +413,7 @@ function optsFor(cmd, args) {
|
|
|
348
413
|
}
|
|
349
414
|
return opts;
|
|
350
415
|
}
|
|
351
|
-
async function handleDevStart(args, deps) {
|
|
416
|
+
async function handleDevStart(args, deps, extra = {}) {
|
|
352
417
|
const platform = args.platform;
|
|
353
418
|
if (!isMcpDevPlatform(platform)) {
|
|
354
419
|
return errorResult(
|
|
@@ -373,7 +438,8 @@ async function handleDevStart(args, deps) {
|
|
|
373
438
|
metroPort: typeof args.metro_port === "number" ? args.metro_port : void 0,
|
|
374
439
|
model: typeof args.model === "string" && args.model.trim() ? args.model : void 0,
|
|
375
440
|
...buildId !== void 0 ? { buildId } : {},
|
|
376
|
-
...standalone ? { standalone } : {}
|
|
441
|
+
...standalone ? { standalone } : {},
|
|
442
|
+
...extra.progress ? { onProgress: extra.progress } : {}
|
|
377
443
|
});
|
|
378
444
|
const warnings = result.warnings ?? [];
|
|
379
445
|
const payload = {
|
|
@@ -381,17 +447,56 @@ async function handleDevStart(args, deps) {
|
|
|
381
447
|
page_url: result.pageUrl,
|
|
382
448
|
standalone,
|
|
383
449
|
device: result.device ?? null,
|
|
450
|
+
build: result.build ?? null,
|
|
451
|
+
build_reason: result.buildReason ?? null,
|
|
452
|
+
other_debug_builds: result.otherBuilds ?? [],
|
|
453
|
+
project: result.project ?? null,
|
|
454
|
+
queued_seconds: result.queuedSeconds ?? null,
|
|
384
455
|
warnings
|
|
385
456
|
};
|
|
386
457
|
const lines = [`${standalone ? "session" : "dev session"} started: ${payload.session_id}`];
|
|
387
458
|
const device = result.device ? deviceLine(result.device) : null;
|
|
388
459
|
if (device) lines.push(device);
|
|
460
|
+
lines.push(...buildLines(result, standalone));
|
|
461
|
+
if (typeof result.queuedSeconds === "number") {
|
|
462
|
+
lines.push(`waited ${Math.round(result.queuedSeconds)} s in the queue for a device`);
|
|
463
|
+
}
|
|
389
464
|
lines.push(payload.page_url, ...warnings.map((w) => `warning: ${w}`));
|
|
390
465
|
return textResult(lines.join("\n"), payload);
|
|
391
466
|
} catch (err) {
|
|
392
467
|
return errorResult(`error: ${err instanceof Error ? err.message : String(err)}`);
|
|
393
468
|
}
|
|
394
469
|
}
|
|
470
|
+
function buildLines(result, standalone) {
|
|
471
|
+
const lines = [];
|
|
472
|
+
if (result.build) {
|
|
473
|
+
lines.push(`build: ${buildLabel(result.build)}`);
|
|
474
|
+
}
|
|
475
|
+
if (result.buildReason) lines.push(`picked that build because ${result.buildReason}`);
|
|
476
|
+
const others = result.otherBuilds ?? [];
|
|
477
|
+
if (others.length > 0) {
|
|
478
|
+
lines.push(
|
|
479
|
+
`other debug builds: ${others.map((b) => buildLabel(b)).join("; ")} \u2014 pass build_id to run one`
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
const rn = result.project?.reactNative;
|
|
483
|
+
if (!standalone && rn) {
|
|
484
|
+
lines.push(
|
|
485
|
+
`this project: react-native ${rn}. If the app shows "React Native version mismatch", the build was made with another React Native: run a build made from this project (build_id, see list_builds).`
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
return lines;
|
|
489
|
+
}
|
|
490
|
+
function startedBuild(b) {
|
|
491
|
+
return {
|
|
492
|
+
id: b.id,
|
|
493
|
+
version: b.version ?? null,
|
|
494
|
+
build_number: b.build_number ?? null,
|
|
495
|
+
kind: buildKind(b),
|
|
496
|
+
uploaded_at: b.uploaded_at ?? null,
|
|
497
|
+
note: b.note ?? null
|
|
498
|
+
};
|
|
499
|
+
}
|
|
395
500
|
function deviceLine(d) {
|
|
396
501
|
const name = d.model || d.name;
|
|
397
502
|
if (!name) return null;
|
|
@@ -542,7 +647,7 @@ async function handleUploadApp(args, deps) {
|
|
|
542
647
|
}
|
|
543
648
|
const name = typeof args.name === "string" && args.name ? args.name : void 0;
|
|
544
649
|
const upload = deps.uploadApp ?? (async (f, n) => {
|
|
545
|
-
const { uploadAppBuild } = await import("./upload-app-
|
|
650
|
+
const { uploadAppBuild } = await import("./upload-app-AKD73742.js");
|
|
546
651
|
const { result: result2 } = await uploadAppBuild(f, { ...deps.auth, name: n });
|
|
547
652
|
return result2;
|
|
548
653
|
});
|
|
@@ -575,16 +680,69 @@ async function handleUploadApp(args, deps) {
|
|
|
575
680
|
];
|
|
576
681
|
return textResult(lines.join("\n"), payload);
|
|
577
682
|
}
|
|
683
|
+
var START_PROGRESS_INTERVAL_MS = 1e4;
|
|
684
|
+
function startProgress(report) {
|
|
685
|
+
const started = Date.now();
|
|
686
|
+
let queuedAt = null;
|
|
687
|
+
let dequeuedAt = null;
|
|
688
|
+
let phase = "starting the device";
|
|
689
|
+
let timer = null;
|
|
690
|
+
const say = (message) => {
|
|
691
|
+
try {
|
|
692
|
+
report?.(message);
|
|
693
|
+
} catch {
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
if (report) {
|
|
697
|
+
timer = setInterval(() => {
|
|
698
|
+
const since = Math.round(
|
|
699
|
+
(Date.now() - (queuedAt !== null && dequeuedAt === null ? queuedAt : started)) / 1e3
|
|
700
|
+
);
|
|
701
|
+
say(`${phase} (${since} s so far)`);
|
|
702
|
+
}, START_PROGRESS_INTERVAL_MS);
|
|
703
|
+
timer.unref?.();
|
|
704
|
+
}
|
|
705
|
+
return {
|
|
706
|
+
event(evt) {
|
|
707
|
+
if (evt.event === "queued") {
|
|
708
|
+
queuedAt = Date.now();
|
|
709
|
+
const where = evt.position ? ` at position ${evt.position}` : "";
|
|
710
|
+
phase = `waiting in the queue for a device${where}`;
|
|
711
|
+
say(
|
|
712
|
+
`${evt.message ?? "Every device of this kind is busy."} Waiting in the queue${where}; dev_start returns once a device is free.`
|
|
713
|
+
);
|
|
714
|
+
} else if (evt.event === "tunnel_connected") {
|
|
715
|
+
phase = "connected to Metro; waiting for the app to be installed";
|
|
716
|
+
say("connected to Metro; the device is starting and the app is being installed");
|
|
717
|
+
} else if (evt.event === "installing") {
|
|
718
|
+
phase = "installing the app";
|
|
719
|
+
say("the device is up; installing the app");
|
|
720
|
+
}
|
|
721
|
+
},
|
|
722
|
+
dequeued() {
|
|
723
|
+
dequeuedAt = Date.now();
|
|
724
|
+
phase = "got a device; starting it";
|
|
725
|
+
if (queuedAt !== null)
|
|
726
|
+
say(
|
|
727
|
+
`got a device after ${Math.round((dequeuedAt - queuedAt) / 1e3)} s in the queue; starting it`
|
|
728
|
+
);
|
|
729
|
+
},
|
|
730
|
+
queuedSeconds() {
|
|
731
|
+
if (queuedAt === null) return null;
|
|
732
|
+
return ((dequeuedAt ?? Date.now()) - queuedAt) / 1e3;
|
|
733
|
+
},
|
|
734
|
+
done() {
|
|
735
|
+
if (timer) clearInterval(timer);
|
|
736
|
+
timer = null;
|
|
737
|
+
}
|
|
738
|
+
};
|
|
739
|
+
}
|
|
578
740
|
function createDevSessionController(auth, webUrl) {
|
|
579
741
|
let handle = null;
|
|
580
742
|
let platform = null;
|
|
581
743
|
let ended = null;
|
|
582
|
-
let presence = null;
|
|
583
744
|
let reloading = null;
|
|
584
|
-
|
|
585
|
-
if (presence) clearInterval(presence);
|
|
586
|
-
presence = null;
|
|
587
|
-
};
|
|
745
|
+
let pending = null;
|
|
588
746
|
const readRokuBuild = () => {
|
|
589
747
|
try {
|
|
590
748
|
return loadDevConfig(process.cwd())?.platforms?.roku?.build ?? null;
|
|
@@ -597,10 +755,9 @@ function createDevSessionController(auth, webUrl) {
|
|
|
597
755
|
const markEnded = (e) => {
|
|
598
756
|
if (!handle || handle.sessionId !== e.sessionId) return;
|
|
599
757
|
const dead = handle;
|
|
600
|
-
stopPresence();
|
|
601
758
|
handle = null;
|
|
602
759
|
platform = null;
|
|
603
|
-
ended = e;
|
|
760
|
+
ended = { ...e, reason: e.reason ?? dead.lapsedReason?.() ?? null };
|
|
604
761
|
void dead.stop().catch(() => {
|
|
605
762
|
});
|
|
606
763
|
};
|
|
@@ -662,71 +819,116 @@ function createDevSessionController(auth, webUrl) {
|
|
|
662
819
|
);
|
|
663
820
|
}
|
|
664
821
|
}
|
|
665
|
-
|
|
666
|
-
|
|
822
|
+
let buildId = cfg.buildId;
|
|
823
|
+
if (buildId !== void 0) {
|
|
824
|
+
checkDevBuild(app, buildId, cfg.platform, { standalone });
|
|
825
|
+
}
|
|
826
|
+
const project = usesMetro ? localAppIdentity(process.cwd(), cfg.platform) : null;
|
|
827
|
+
let buildReason = null;
|
|
828
|
+
if (buildId === void 0 && usesMetro) {
|
|
829
|
+
const picked = pickDevBuild(app, project?.version ?? null);
|
|
830
|
+
if (picked?.reason) {
|
|
831
|
+
buildId = picked.build.id;
|
|
832
|
+
buildReason = picked.reason;
|
|
833
|
+
}
|
|
667
834
|
}
|
|
668
835
|
const isTv = PLATFORM_DEVICE[cfg.platform].tv;
|
|
669
836
|
const warnings = [];
|
|
837
|
+
if (project?.bundleId && app.package_name && project.bundleId !== app.package_name) {
|
|
838
|
+
warnings.push(
|
|
839
|
+
`this project's ${isTv || cfg.platform.startsWith("android") ? "package name" : "bundle id"} is ${project.bundleId}, but app ${app.public_id} is ${app.package_name} \u2014 check that dev_start runs the app this project builds (list_apps)`
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
const progress = startProgress(cfg.onProgress);
|
|
670
843
|
let started = null;
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
844
|
+
try {
|
|
845
|
+
started = await startDevSession(
|
|
846
|
+
{
|
|
847
|
+
token: auth.token,
|
|
848
|
+
baseUrl: auth.baseUrl,
|
|
849
|
+
webUrl,
|
|
850
|
+
platform: cfg.platform,
|
|
851
|
+
appId,
|
|
852
|
+
deviceCategory: isTv ? "tv" : entry?.deviceCategory ?? "phone",
|
|
853
|
+
// A named model decides the category itself (startDevSession).
|
|
854
|
+
model: cfg.model,
|
|
855
|
+
buildId,
|
|
856
|
+
...standalone ? { standalone } : {},
|
|
857
|
+
metroPort,
|
|
858
|
+
open: false,
|
|
859
|
+
onCreated: (created) => {
|
|
860
|
+
pending = created;
|
|
861
|
+
},
|
|
862
|
+
onDequeued: () => progress.dequeued()
|
|
863
|
+
},
|
|
864
|
+
(evt) => {
|
|
865
|
+
progress.event(evt);
|
|
866
|
+
if (evt.event === "warning") warnings.push(evt.message);
|
|
867
|
+
if (evt.event === "session_ended" && evt.code !== 0 && started) {
|
|
868
|
+
const id = evt.session_id;
|
|
869
|
+
void api().getSession(id).then(
|
|
870
|
+
(info) => ({ status: info.status, reason: info.failure_reason ?? null }),
|
|
871
|
+
() => ({ status: "stopped", reason: null })
|
|
872
|
+
).then(({ status, reason }) => markEnded({ sessionId: id, status, reason }));
|
|
873
|
+
}
|
|
694
874
|
}
|
|
695
|
-
|
|
696
|
-
|
|
875
|
+
);
|
|
876
|
+
} finally {
|
|
877
|
+
progress.done();
|
|
878
|
+
pending = null;
|
|
879
|
+
}
|
|
697
880
|
handle = started;
|
|
698
881
|
platform = cfg.platform;
|
|
699
882
|
ended = null;
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
presence = setInterval(() => {
|
|
704
|
-
client.touchSession(sessionId).then(
|
|
705
|
-
(info) => {
|
|
706
|
-
if (info && isEndedStatus(info.status)) {
|
|
707
|
-
markEnded({ sessionId, status: info.status, reason: info.failure_reason ?? null });
|
|
708
|
-
}
|
|
709
|
-
},
|
|
710
|
-
() => {
|
|
711
|
-
}
|
|
712
|
-
);
|
|
713
|
-
}, ROKU_LIVENESS_INTERVAL_MS);
|
|
714
|
-
presence.unref?.();
|
|
715
|
-
}
|
|
883
|
+
const installedId = handle.session?.app_build_id ?? buildId ?? null;
|
|
884
|
+
const installed = app.builds.find((b) => b.id === installedId);
|
|
885
|
+
const others = usesMetro ? debugBuildsNewestFirst(app).filter((b) => b.id !== installedId) : [];
|
|
716
886
|
return {
|
|
717
887
|
sessionId: handle.sessionId,
|
|
718
888
|
pageUrl: handle.pageUrl,
|
|
719
889
|
device: startedDevice(handle.session),
|
|
890
|
+
build: installed ? startedBuild(installed) : typeof installedId === "number" ? {
|
|
891
|
+
id: installedId,
|
|
892
|
+
version: handle.session?.app_version ?? null,
|
|
893
|
+
build_number: handle.session?.app_build_number ?? null,
|
|
894
|
+
kind: "unknown",
|
|
895
|
+
uploaded_at: null,
|
|
896
|
+
note: null
|
|
897
|
+
} : null,
|
|
898
|
+
buildReason,
|
|
899
|
+
otherBuilds: others.slice(0, 3).map(startedBuild),
|
|
900
|
+
project,
|
|
901
|
+
queuedSeconds: progress.queuedSeconds(),
|
|
720
902
|
warnings
|
|
721
903
|
};
|
|
722
904
|
},
|
|
723
905
|
async stop() {
|
|
724
|
-
stopPresence();
|
|
725
906
|
await handle?.stop();
|
|
726
907
|
handle = null;
|
|
727
908
|
platform = null;
|
|
728
909
|
ended = null;
|
|
729
910
|
},
|
|
911
|
+
async shutdown() {
|
|
912
|
+
const stopped = [];
|
|
913
|
+
const held = handle;
|
|
914
|
+
const starting = pending;
|
|
915
|
+
handle = null;
|
|
916
|
+
platform = null;
|
|
917
|
+
pending = null;
|
|
918
|
+
await Promise.all([
|
|
919
|
+
held ? held.stop().then(
|
|
920
|
+
() => void stopped.push(held.sessionId),
|
|
921
|
+
() => {
|
|
922
|
+
}
|
|
923
|
+
) : null,
|
|
924
|
+
starting ? starting.stop().then(
|
|
925
|
+
() => void stopped.push(starting.sessionId),
|
|
926
|
+
() => {
|
|
927
|
+
}
|
|
928
|
+
) : null
|
|
929
|
+
]);
|
|
930
|
+
return stopped;
|
|
931
|
+
},
|
|
730
932
|
current() {
|
|
731
933
|
return handle ? { sessionId: handle.sessionId, pageUrl: handle.pageUrl } : null;
|
|
732
934
|
},
|
|
@@ -803,6 +1005,7 @@ var MCP_INSTRUCTIONS = [
|
|
|
803
1005
|
"VibeView runs your app on a cloud iOS simulator, Android emulator, Apple TV, Android TV or Roku (beta) device you control with these tools.",
|
|
804
1006
|
"Loop: dev_start (platform, app from list_apps; add standalone: true for a release or CI build that needs no Metro) -> ui_tree -> act with refs (tap @e5, type, swipe, press) and read the change each action reports -> dev_stop when done.",
|
|
805
1007
|
"Foldables: pick one by model in dev_start (list_device_models marks them foldable, with free/busy counts), then set_posture and rotate.",
|
|
1008
|
+
"set_location sets the simulated GPS position (phones and tablets) for location-based flows.",
|
|
806
1009
|
"screenshot writes a PNG and returns its path; pass inline: true to see the image. logs reads the app output after a crash or blank screen.",
|
|
807
1010
|
"A session stopped elsewhere is reported as ended; call dev_start again for a new one."
|
|
808
1011
|
].join("\n");
|
|
@@ -841,30 +1044,89 @@ async function createMcpServer(opts) {
|
|
|
841
1044
|
inputSchema: zodShape(def),
|
|
842
1045
|
annotations: { readOnlyHint: !def.mutating }
|
|
843
1046
|
},
|
|
844
|
-
async (args) => {
|
|
845
|
-
const result = await handleToolCall(def.name, args ?? {}, deps
|
|
1047
|
+
async (args, extra) => {
|
|
1048
|
+
const result = await handleToolCall(def.name, args ?? {}, deps, {
|
|
1049
|
+
progress: progressReporter(extra)
|
|
1050
|
+
});
|
|
846
1051
|
return result;
|
|
847
1052
|
}
|
|
848
1053
|
);
|
|
849
1054
|
}
|
|
850
|
-
return server;
|
|
1055
|
+
return { server, devSession };
|
|
1056
|
+
}
|
|
1057
|
+
function progressReporter(extra) {
|
|
1058
|
+
const token = extra?._meta?.progressToken;
|
|
1059
|
+
const send = extra?.sendNotification;
|
|
1060
|
+
if (token === void 0 || !send) return void 0;
|
|
1061
|
+
let step = 0;
|
|
1062
|
+
return (message) => {
|
|
1063
|
+
step += 1;
|
|
1064
|
+
void send({
|
|
1065
|
+
method: "notifications/progress",
|
|
1066
|
+
params: { progressToken: token, progress: step, message }
|
|
1067
|
+
}).catch(() => {
|
|
1068
|
+
});
|
|
1069
|
+
};
|
|
1070
|
+
}
|
|
1071
|
+
var SHUTDOWN_TIMEOUT_MS = 5e3;
|
|
1072
|
+
function createShutdown(devSession, opts = {}) {
|
|
1073
|
+
const exit = opts.exit ?? ((code) => process.exit(code));
|
|
1074
|
+
const log = opts.log ?? ((line) => console.error(line));
|
|
1075
|
+
const timeoutMs = opts.timeoutMs ?? SHUTDOWN_TIMEOUT_MS;
|
|
1076
|
+
let running = null;
|
|
1077
|
+
return (reason) => {
|
|
1078
|
+
running ??= (async () => {
|
|
1079
|
+
let timer;
|
|
1080
|
+
const timedOut = new Promise((resolve) => {
|
|
1081
|
+
timer = setTimeout(() => resolve("timeout"), timeoutMs);
|
|
1082
|
+
});
|
|
1083
|
+
const outcome = await Promise.race([
|
|
1084
|
+
devSession.shutdown().catch(() => []),
|
|
1085
|
+
timedOut
|
|
1086
|
+
]);
|
|
1087
|
+
clearTimeout(timer);
|
|
1088
|
+
if (outcome === "timeout") {
|
|
1089
|
+
log(
|
|
1090
|
+
`vibeview mcp: ${reason}; could not confirm the session stopped within ${timeoutMs / 1e3} s`
|
|
1091
|
+
);
|
|
1092
|
+
} else if (outcome.length > 0) {
|
|
1093
|
+
log(`vibeview mcp: ${reason}; stopped session ${outcome.join(", ")}`);
|
|
1094
|
+
}
|
|
1095
|
+
exit(0);
|
|
1096
|
+
})();
|
|
1097
|
+
return running;
|
|
1098
|
+
};
|
|
851
1099
|
}
|
|
852
1100
|
async function runMcpServer(opts) {
|
|
853
1101
|
chalk.level = 0;
|
|
854
|
-
const [server, { StdioServerTransport }] = await Promise.all([
|
|
1102
|
+
const [{ server, devSession }, { StdioServerTransport }] = await Promise.all([
|
|
855
1103
|
createMcpServer(opts),
|
|
856
1104
|
import("@modelcontextprotocol/sdk/server/stdio.js")
|
|
857
1105
|
]);
|
|
858
1106
|
const transport = new StdioServerTransport();
|
|
1107
|
+
const shutdown = createShutdown(devSession);
|
|
1108
|
+
process.stdin.once("end", () => void shutdown("the client closed the connection"));
|
|
1109
|
+
process.stdin.once("close", () => void shutdown("the client closed the connection"));
|
|
1110
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
1111
|
+
process.once(signal, () => void shutdown(`received ${signal}`));
|
|
1112
|
+
}
|
|
1113
|
+
server.server.onclose = () => void shutdown("the connection closed");
|
|
859
1114
|
await server.connect(transport);
|
|
860
1115
|
}
|
|
861
1116
|
export {
|
|
862
1117
|
MCP_INSTRUCTIONS,
|
|
1118
|
+
SHUTDOWN_TIMEOUT_MS,
|
|
1119
|
+
START_PROGRESS_INTERVAL_MS,
|
|
1120
|
+
buildLines,
|
|
863
1121
|
buildToolDefinitions,
|
|
864
1122
|
createDevSessionController,
|
|
1123
|
+
createShutdown,
|
|
865
1124
|
deviceLine,
|
|
866
1125
|
forMcp,
|
|
867
1126
|
handleToolCall,
|
|
1127
|
+
mcpText,
|
|
1128
|
+
progressReporter,
|
|
868
1129
|
runMcpServer,
|
|
1130
|
+
startProgress,
|
|
869
1131
|
startedDevice
|
|
870
1132
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibeview",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.0",
|
|
4
4
|
"description": "VibeView CLI -- run your app on cloud iOS simulators, Android emulators, Apple TV, Android TV and Roku (beta) devices: a React Native dev loop, device control for you or your coding agent (CLI or MCP), and tests from CI",
|
|
5
5
|
"mcpName": "io.github.vibeview/vibeview",
|
|
6
6
|
"type": "module",
|
|
@@ -115,7 +115,9 @@ appear unexplained in their `git status`.
|
|
|
115
115
|
vibeview screenshot --out ./check.png --session <id> # then open it
|
|
116
116
|
```
|
|
117
117
|
Over MCP, `screenshot` only writes the file and returns its path unless
|
|
118
|
-
you pass `inline: true`, which returns the image itself
|
|
118
|
+
you pass `inline: true`, which returns the image itself (under 1 MB — a
|
|
119
|
+
JPEG when the PNG is large — at the same size, so its pixels are still tap
|
|
120
|
+
coordinates). If you cannot
|
|
119
121
|
open files on the machine running the server, `inline: true` is the only
|
|
120
122
|
way you will ever see the screen. It costs a lot of context, so use it
|
|
121
123
|
when appearance matters — not to confirm a tap landed, which the
|
|
@@ -161,6 +163,9 @@ appear unexplained in their `git status`.
|
|
|
161
163
|
```
|
|
162
164
|
On Apple TV this also covers the "Open in …?" prompt a deep link raises;
|
|
163
165
|
the button is answered with the remote.
|
|
166
|
+
- Use `set-location --lat 51.5074 --lon -0.1278` to place the device before
|
|
167
|
+
a location-based flow (maps, store finders, region-gated screens); the app
|
|
168
|
+
reads it like a GPS fix. Not on TV.
|
|
164
169
|
- Use `open-url` to jump straight to a deep link instead of navigating by
|
|
165
170
|
hand:
|
|
166
171
|
```bash
|
|
@@ -190,10 +195,11 @@ appear unexplained in their `git status`.
|
|
|
190
195
|
angle can keep the inner screen lit on the way down), or the new
|
|
191
196
|
orientation. The posture names come from each device's own angle ranges,
|
|
192
197
|
so trust the reported `posture` over the angle you asked for: the Duo
|
|
193
|
-
reads
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
198
|
+
reads 0 as `closed` and 170 up as `open`; in between it depends on how the
|
|
199
|
+
hinge got there (moved in one go it switches at about 85 degrees, moved in
|
|
200
|
+
small steps it keeps the screen it had); an Android foldable uses its own
|
|
201
|
+
ranges (the Pixel 9 Pro Fold: 0-30 `closed`, 31-149 `partial`, 150 up
|
|
202
|
+
`open`) and its inner screen whenever it is not closed. An Android foldable rotates like a phone. Folding
|
|
197
203
|
switches screens of different sizes and rotating turns the picture, so run
|
|
198
204
|
`ui-tree` afterwards — old refs are stale. On other phones and tablets
|
|
199
205
|
`rotate` toggles portrait/landscape (only 90 is accepted); TV devices don't
|
|
@@ -266,9 +272,11 @@ human-readable text.
|
|
|
266
272
|
| `type` | `<text>` | Type text into the currently focused input field. On a phone or tablet a line break presses Enter and submits the field (`$'query\n'`), like `press enter`. |
|
|
267
273
|
| `clear-text` | — | Clear the text in the currently focused input field. |
|
|
268
274
|
| `press` | `<button>` | Press a device button or perform a system gesture (home, back, d-pad, etc). |
|
|
269
|
-
| `open-url` | `<url>` | Open a deep link
|
|
275
|
+
| `open-url` | `<url>` | Open a deep link in the app under test, never a browser. Android: a link the app has no screen for fails. iOS/Apple TV: only the app's own URL scheme (`myapp://...`); a web or system link is refused. |
|
|
276
|
+
| `relaunch-app` | — | Close the app and start it again (cold start, data kept) — for "does it persist after a restart" checks. The app log keeps streaming. Not on Roku or embed sessions. |
|
|
270
277
|
| `set-posture` | `<closed\|partial\|open>` or `--angle <0-180>` | Fold or unfold a foldable device (form factor `foldable`: the iPhone Duo, Android foldables) to a preset or an exact hinge angle — exactly one. Reports posture, angle and lit screen. Errors on a device without a hinge. |
|
|
271
278
|
| `rotate` | `[--degrees <90\|180\|270>]` | Rotate the device and report the new orientation. Phones/tablets and Android foldables toggle portrait/landscape (90 only); the iPhone Duo turns a quarter clockwise by default. Not on TV. |
|
|
279
|
+
| `set-location` | `--lat <n> --lon <n>` | Set the device's simulated GPS position (decimal degrees; west and south are negative) for location-based flows. Phones, tablets and foldables; not on TV. |
|
|
272
280
|
| `wait` | `[--ms <n>]` | Wait for a specified number of seconds before continuing (default: 2s). |
|
|
273
281
|
| `find` | `<text>` `[--below <text>] [--above <text>] [--near <text>]` | Find an element by text with optional spatial constraints. |
|
|
274
282
|
| `tap-focused` | `<ref>` | TV: move focus to an element and press SELECT in one step. |
|
|
@@ -279,7 +287,7 @@ Dev-loop lifecycle commands (not registry verbs, but needed for every run):
|
|
|
279
287
|
| Command | Purpose |
|
|
280
288
|
|---------|---------|
|
|
281
289
|
| `dev --detach --json` | Start a device + Metro-tunnel session in the background, emitting `session_ready`/`warning`/`error` JSON events on stdout. Add `--model "<name>"` to pick an exact device model. |
|
|
282
|
-
| `dev --build <id>` | Run that uploaded build instead of the app's newest debug build (combine with `--app`, `--platform`, `--model`, `--detach --json`). Only a debug build (kind `debug` in `list-builds`) is accepted
|
|
290
|
+
| `dev --build <id>` | Run that uploaded build instead of the app's newest debug build (combine with `--app`, `--platform`, `--model`, `--detach --json`). Only a debug build (kind `debug` in `list-builds`) is accepted, including a debug build with its JS bundle embedded; a release build is refused (run it with `--standalone`). Not on Roku. |
|
|
283
291
|
| `list-builds <app>` | List an app's uploaded builds, newest first: id, version (build number), platform, kind (`debug`/`release`/`unknown`), upload time, note. `--json` prints an array. |
|
|
284
292
|
| `list-devices --models` | List the device models you can start (the names `--model` takes); foldables are marked `foldable`. |
|
|
285
293
|
| `dev-status` | Check on the current project's detached dev session. |
|
|
@@ -304,11 +312,12 @@ steps the CLI does with plain shell commands:
|
|
|
304
312
|
|----------|------|---------|
|
|
305
313
|
| `upload_app` | `file` (required path), `name` | Upload a build (zipped iOS/tvOS simulator `.app`, Android/Android TV `.apk`, Roku channel `.zip`). The MCP equivalent of `vibeview upload-app`, so step 1 of the loop needs no shell. Returns the `app_id` to pass to `dev_start`. |
|
|
306
314
|
| `list_apps` | `platform` | List the org's apps (app id, name, platform, bundle id). The MCP equivalent of `vibeview list-apps` — use it to find an app someone else uploaded. |
|
|
307
|
-
| `dev_start` | `platform` (required: `ios`/`android`/`tvos`/`androidtv`/`roku`), `app`, `metro_port`, `model`, `build_id`, `standalone` | Start a session held open by the MCP server. The MCP equivalent of `vibeview dev --detach`; `model` picks an exact device model (e.g. `"iPhone Duo"`); `build_id` runs that build instead of the newest debug one (see `list_builds`). `standalone: true` runs any build as it is with no Metro — a release, CI or cloud build (without `build_id`: the newest release build). An app for another platform is refused before a device is taken. Returns `page_url` — relay it to the developer immediately, same as step 5 of the loop —
|
|
315
|
+
| `dev_start` | `platform` (required: `ios`/`android`/`tvos`/`androidtv`/`roku`), `app`, `metro_port`, `model`, `build_id`, `standalone` | Start a session held open by the MCP server. The MCP equivalent of `vibeview dev --detach`; `model` picks an exact device model (e.g. `"iPhone Duo"`); `build_id` runs that build instead of the newest debug one (see `list_builds`). `standalone: true` runs any build as it is with no Metro — a release, CI or cloud build (without `build_id`: the newest release build). An app for another platform is refused before a device is taken. Returns `page_url` — relay it to the developer immediately, same as step 5 of the loop — the device it got (model, OS, category; posture and lit screen on a foldable), and the build it installed (id, version, upload time) with the app's other debug builds. A red "React Native version mismatch" screen means that build was made with another React Native than the project: pick one made from this project with `build_id`. While every device is busy it waits in the queue, sending progress. |
|
|
308
316
|
| `list_device_models` | `device_type` | List the device models you can start (model, OS, platform, category, `foldable` marker, and how many are free/busy). The MCP equivalent of `vibeview list-devices --models`. |
|
|
309
317
|
| `list_builds` | `app` (required) | List an app's uploaded builds with their kind (`debug`/`release`/`unknown`). The MCP equivalent of `vibeview list-builds`. |
|
|
310
318
|
| `dev_stop` | — | Stop the session `dev_start` started. The MCP equivalent of `vibeview dev-stop`. Says "no dev session was running" when there was none. |
|
|
311
319
|
| `stop_session` | `session_id` (required) | Stop any session by id. The MCP equivalent of `vibeview stop <session-id>`. Says so when the session had already ended. |
|
|
320
|
+
| `dev_reload` | — | Roku only: re-package, upload and restart the channel in the held session (or this project's `vibeview dev --detach` session). The MCP equivalent of `vibeview dev-reload`. |
|
|
312
321
|
|
|
313
322
|
If the held session ends elsewhere (dashboard, idle timeout, failure), the
|
|
314
323
|
next tool call answers "session … has ended (…) — call dev_start"; the
|
|
@@ -319,5 +328,6 @@ call.** Once it succeeds, any tool called without an explicit `session_id`
|
|
|
319
328
|
targets that session instead of whatever `vibeview dev --detach` state exists
|
|
320
329
|
in the project directory. Only one such session can be held at a time
|
|
321
330
|
(`dev_start` fails if one is already running), and it stays live — billing
|
|
322
|
-
streaming minutes — until `dev_stop` or the MCP connection ends
|
|
323
|
-
|
|
331
|
+
streaming minutes — until `dev_stop` or the MCP connection ends (the server
|
|
332
|
+
stops it when the client quits or closes the connection; a killed server
|
|
333
|
+
can't). Always call `dev_stop` when you finish verifying.
|