vibeview 0.7.1 → 0.8.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.
@@ -0,0 +1,2011 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ ApiClient,
4
+ loadConfig,
5
+ resolveAuth,
6
+ uploadAppBuild
7
+ } from "./chunk-YD2X6TQV.js";
8
+
9
+ // src/lib/dev-state.ts
10
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
11
+ import { dirname, join } from "path";
12
+ var STATE_DIR = ".vibeview";
13
+ var STATE_FILE = "dev-state.json";
14
+ function statePath(cwd) {
15
+ return join(cwd, STATE_DIR, STATE_FILE);
16
+ }
17
+ function isPidAlive(pid) {
18
+ try {
19
+ process.kill(pid, 0);
20
+ return true;
21
+ } catch (err) {
22
+ return err.code === "EPERM";
23
+ }
24
+ }
25
+ function ensureGitignored(cwd) {
26
+ try {
27
+ const gitignore = join(cwd, ".gitignore");
28
+ if (!existsSync(gitignore)) return;
29
+ const content = readFileSync(gitignore, "utf-8");
30
+ const covered = content.split("\n").map((l) => l.trim()).some((l) => l === ".vibeview" || l === ".vibeview/" || l === "/.vibeview");
31
+ if (covered) return;
32
+ const prefix = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
33
+ writeFileSync(gitignore, `${content}${prefix}
34
+ # VibeView local session state
35
+ .vibeview/
36
+ `);
37
+ } catch {
38
+ }
39
+ }
40
+ function writeDevState(cwd, state) {
41
+ const path = statePath(cwd);
42
+ mkdirSync(dirname(path), { recursive: true });
43
+ writeFileSync(path, JSON.stringify(state, null, 2) + "\n");
44
+ ensureGitignored(cwd);
45
+ }
46
+ function readDevState(cwd) {
47
+ let raw;
48
+ try {
49
+ raw = readFileSync(statePath(cwd), "utf-8");
50
+ } catch {
51
+ return null;
52
+ }
53
+ let parsed;
54
+ try {
55
+ parsed = JSON.parse(raw);
56
+ } catch {
57
+ return null;
58
+ }
59
+ if (typeof parsed.pid !== "number" || !isPidAlive(parsed.pid)) {
60
+ return null;
61
+ }
62
+ return parsed;
63
+ }
64
+ function clearDevState(cwd) {
65
+ const path = statePath(cwd);
66
+ if (existsSync(path)) {
67
+ rmSync(path, { force: true });
68
+ }
69
+ }
70
+
71
+ // src/lib/dev-config.ts
72
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
73
+ import { join as join2 } from "path";
74
+ var DEV_PLATFORMS = ["ios", "android", "tvos", "androidtv"];
75
+ var FILE = "vibeview.json";
76
+ function isDevPlatform(p) {
77
+ return typeof p === "string" && DEV_PLATFORMS.includes(p);
78
+ }
79
+ function loadDevConfig(cwd) {
80
+ let raw;
81
+ try {
82
+ raw = readFileSync2(join2(cwd, FILE), "utf-8");
83
+ } catch {
84
+ return null;
85
+ }
86
+ const parsed = JSON.parse(raw);
87
+ if (!("platforms" in parsed) && typeof parsed.platform === "string") {
88
+ const legacy = parsed;
89
+ if (!isDevPlatform(legacy.platform)) {
90
+ throw new Error(
91
+ `vibeview.json: "platform" must be one of ${DEV_PLATFORMS.join(", ")}, got "${legacy.platform}"`
92
+ );
93
+ }
94
+ if (!legacy.appId || typeof legacy.appId !== "string") {
95
+ throw new Error('vibeview.json: "appId" is required \u2014 run `vibeview dev` to set it up');
96
+ }
97
+ return {
98
+ defaultPlatform: legacy.platform,
99
+ metroPort: legacy.metroPort,
100
+ platforms: {
101
+ [legacy.platform]: { appId: legacy.appId, deviceCategory: legacy.deviceCategory }
102
+ }
103
+ };
104
+ }
105
+ const cfg = parsed;
106
+ if (!isDevPlatform(cfg.defaultPlatform)) {
107
+ throw new Error(
108
+ `vibeview.json: "defaultPlatform" must be one of ${DEV_PLATFORMS.join(", ")}, got "${String(cfg.defaultPlatform)}"`
109
+ );
110
+ }
111
+ if (!cfg.platforms || typeof cfg.platforms !== "object") {
112
+ throw new Error(
113
+ 'vibeview.json: "platforms" object is required \u2014 run `vibeview dev` to set it up'
114
+ );
115
+ }
116
+ for (const [key, entry] of Object.entries(cfg.platforms)) {
117
+ if (!isDevPlatform(key)) {
118
+ throw new Error(
119
+ `vibeview.json: unknown platform "${key}" (expected one of ${DEV_PLATFORMS.join(", ")})`
120
+ );
121
+ }
122
+ if (!entry || typeof entry.appId !== "string" || !entry.appId) {
123
+ throw new Error(`vibeview.json: platforms.${key}.appId is required`);
124
+ }
125
+ if (entry.build !== void 0) {
126
+ if (typeof entry.build !== "object" || entry.build === null) {
127
+ throw new Error(
128
+ `vibeview.json: platforms.${key}.build must be an object with command and artifact`
129
+ );
130
+ }
131
+ if (typeof entry.build.command !== "string" || !entry.build.command) {
132
+ throw new Error(`vibeview.json: platforms.${key}.build.command must be a non-empty string`);
133
+ }
134
+ if (typeof entry.build.artifact !== "string" || !entry.build.artifact) {
135
+ throw new Error(
136
+ `vibeview.json: platforms.${key}.build.artifact must be a non-empty string`
137
+ );
138
+ }
139
+ }
140
+ if (entry.metroCommand !== void 0 && (typeof entry.metroCommand !== "string" || !entry.metroCommand)) {
141
+ throw new Error(`vibeview.json: platforms.${key}.metroCommand must be a non-empty string`);
142
+ }
143
+ }
144
+ if (cfg.metroCommand !== void 0 && (typeof cfg.metroCommand !== "string" || !cfg.metroCommand)) {
145
+ throw new Error('vibeview.json: "metroCommand" must be a non-empty string');
146
+ }
147
+ return cfg;
148
+ }
149
+ function saveDevConfig(cwd, cfg) {
150
+ writeFileSync2(join2(cwd, FILE), JSON.stringify(cfg, null, 2) + "\n");
151
+ }
152
+ function resolveMetro(cfg, platform, portFlag) {
153
+ const entry = cfg?.platforms?.[platform];
154
+ return {
155
+ command: entry?.metroCommand ?? cfg?.metroCommand,
156
+ port: portFlag ?? entry?.metroPort ?? cfg?.metroPort ?? 8081,
157
+ platformHasOverride: entry?.metroCommand !== void 0 || entry?.metroPort !== void 0
158
+ };
159
+ }
160
+ function buildSavedConfig(input2) {
161
+ const {
162
+ existing,
163
+ platform,
164
+ appId,
165
+ deviceCategory,
166
+ isTv,
167
+ metroPortFlag,
168
+ isFirstRun,
169
+ firstRunMetro,
170
+ firstRunBuild
171
+ } = input2;
172
+ const topLevelMetroPort = metroPortFlag ? parseInt(metroPortFlag, 10) : existing?.metroPort ?? 8081;
173
+ return {
174
+ defaultPlatform: platform,
175
+ metroPort: topLevelMetroPort,
176
+ ...isFirstRun && firstRunMetro ? { metroCommand: firstRunMetro } : existing?.metroCommand ? { metroCommand: existing.metroCommand } : {},
177
+ platforms: {
178
+ ...existing?.platforms,
179
+ [platform]: {
180
+ appId,
181
+ deviceCategory: isTv ? void 0 : deviceCategory,
182
+ ...existing?.platforms?.[platform]?.build ? { build: existing.platforms[platform].build } : {},
183
+ ...isFirstRun && firstRunBuild ? { build: firstRunBuild } : {},
184
+ ...existing?.platforms?.[platform]?.metroCommand ? { metroCommand: existing.platforms[platform].metroCommand } : {},
185
+ ...existing?.platforms?.[platform]?.metroPort !== void 0 ? { metroPort: existing.platforms[platform].metroPort } : {}
186
+ }
187
+ }
188
+ };
189
+ }
190
+
191
+ // src/lib/metro-check.ts
192
+ async function checkMetro(port, timeoutMs = 1500) {
193
+ try {
194
+ const res = await fetch(`http://127.0.0.1:${port}/status`, {
195
+ signal: AbortSignal.timeout(timeoutMs)
196
+ });
197
+ const body = await res.text().catch(() => "");
198
+ return body.includes("packager-status:running") ? "running" : "listening";
199
+ } catch {
200
+ return "down";
201
+ }
202
+ }
203
+
204
+ // src/commands/build.ts
205
+ import { spawn } from "child_process";
206
+ import { createInterface } from "readline";
207
+ import { select as select2 } from "@inquirer/prompts";
208
+ import chalk from "chalk";
209
+
210
+ // src/lib/artifact.ts
211
+ import { existsSync as existsSync2, mkdtempSync, readdirSync, rmSync as rmSync2, statSync } from "fs";
212
+ import { spawnSync } from "child_process";
213
+ import { tmpdir } from "os";
214
+ import { basename, dirname as dirname2, isAbsolute, join as join3 } from "path";
215
+ function resolveArtifact(cwd, pattern) {
216
+ const abs = isAbsolute(pattern) ? pattern : join3(cwd, pattern);
217
+ if (!basename(abs).includes("*")) {
218
+ if (!existsSync2(abs)) {
219
+ throw new Error(
220
+ `artifact not found: ${pattern} (looked at ${abs}) \u2014 did the build produce it?`
221
+ );
222
+ }
223
+ return abs;
224
+ }
225
+ const dir = dirname2(abs);
226
+ const re = new RegExp("^" + basename(abs).split("*").map(escapeRegExp).join(".*") + "$");
227
+ let entries;
228
+ try {
229
+ entries = readdirSync(dir);
230
+ } catch {
231
+ throw new Error(`artifact not found: ${pattern} (directory ${dir} does not exist)`);
232
+ }
233
+ const matches = entries.filter((e) => re.test(e)).map((e) => join3(dir, e)).sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
234
+ if (matches.length === 0) {
235
+ throw new Error(`artifact not found: no match for ${pattern} in ${dir}`);
236
+ }
237
+ return matches[0];
238
+ }
239
+ function escapeRegExp(s) {
240
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
241
+ }
242
+ function prepareUploadArtifact(path) {
243
+ if (!(statSync(path).isDirectory() && path.endsWith(".app"))) {
244
+ return { uploadPath: path, cleanup: () => {
245
+ } };
246
+ }
247
+ const tmp = mkdtempSync(join3(tmpdir(), "vibeview-zip-"));
248
+ const out = join3(tmp, `${basename(path)}.zip`);
249
+ const res = spawnSync("zip", ["-qry", out, basename(path)], { cwd: dirname2(path) });
250
+ if (res.error) {
251
+ rmSync2(tmp, { recursive: true, force: true });
252
+ const errorCode = res.error.code;
253
+ const errorMsg = res.error.message;
254
+ throw new Error(
255
+ `failed to zip ${path}: spawn zip ${errorCode} \u2014 ${errorMsg} \u2014 is the \`zip\` command installed?`
256
+ );
257
+ }
258
+ if (res.status !== 0) {
259
+ rmSync2(tmp, { recursive: true, force: true });
260
+ throw new Error(
261
+ `failed to zip ${path}: ${res.stderr?.toString().trim() || `zip exited ${res.status}`}`
262
+ );
263
+ }
264
+ return { uploadPath: out, cleanup: () => rmSync2(tmp, { recursive: true, force: true }) };
265
+ }
266
+
267
+ // src/lib/dev-setup.ts
268
+ import { input, select } from "@inquirer/prompts";
269
+
270
+ // src/lib/project-detect.ts
271
+ import { existsSync as existsSync3, readFileSync as readFileSync3, readdirSync as readdirSync2 } from "fs";
272
+ import { join as join4 } from "path";
273
+ function detectFlavor(cwd) {
274
+ let deps = {};
275
+ try {
276
+ const pkg = JSON.parse(readFileSync3(join4(cwd, "package.json"), "utf-8"));
277
+ deps = { ...pkg.dependencies, ...pkg.devDependencies };
278
+ } catch {
279
+ }
280
+ if ("expo" in deps) return "expo";
281
+ if (existsSync3(join4(cwd, "android", "gradlew")) || iosWorkspaceName(cwd)) return "bare-rn";
282
+ return "unknown";
283
+ }
284
+ function iosWorkspaceName(cwd) {
285
+ try {
286
+ const found = readdirSync2(join4(cwd, "ios")).find((f) => f.endsWith(".xcworkspace"));
287
+ return found ? found.replace(/\.xcworkspace$/, "") : null;
288
+ } catch {
289
+ return null;
290
+ }
291
+ }
292
+ var IOS_ARTIFACT = "ios/build/Build/Products/Debug-iphonesimulator/*.app";
293
+ var ANDROID_ARTIFACT = "android/app/build/outputs/apk/debug/*.apk";
294
+ function defaultsFor(cwd, platform) {
295
+ const flavor = detectFlavor(cwd);
296
+ if (flavor === "unknown") return {};
297
+ const metroCommand = flavor === "expo" ? "npx expo start" : "yarn start";
298
+ if (platform === "tvos" || platform === "androidtv") return { metroCommand };
299
+ if (platform === "android") {
300
+ return {
301
+ buildCommand: flavor === "expo" ? "npx expo run:android --variant debug" : "cd android && ./gradlew assembleDebug",
302
+ artifact: ANDROID_ARTIFACT,
303
+ metroCommand
304
+ };
305
+ }
306
+ if (flavor === "expo") {
307
+ return {
308
+ buildCommand: "npx expo run:ios --configuration Debug",
309
+ artifact: IOS_ARTIFACT,
310
+ metroCommand
311
+ };
312
+ }
313
+ const ws = iosWorkspaceName(cwd);
314
+ return {
315
+ buildCommand: ws ? `xcodebuild -workspace ios/${ws}.xcworkspace -scheme ${ws} -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build` : void 0,
316
+ artifact: IOS_ARTIFACT,
317
+ metroCommand
318
+ };
319
+ }
320
+
321
+ // src/lib/dev-setup.ts
322
+ function firstRunAppStrategy(apps, buildConfigured) {
323
+ if (!buildConfigured) return "picker";
324
+ return apps.length === 0 ? "build_now" : "ask";
325
+ }
326
+ function fillLaterHint(skipped, platform) {
327
+ if (!skipped.build && !skipped.metro) return "";
328
+ const lines = ["You can enable one-command builds later \u2014 add to vibeview.json:"];
329
+ if (skipped.build) {
330
+ lines.push(
331
+ ` platforms.${platform}.build = { "command": "<your build command>", "artifact": "<path to .app/.apk>" }`
332
+ );
333
+ }
334
+ if (skipped.metro) {
335
+ lines.push(' "metroCommand": "<command that starts Metro>"');
336
+ }
337
+ return lines.join("\n");
338
+ }
339
+ async function commandSelect(message, detected) {
340
+ const choices = [];
341
+ if (detected) choices.push({ value: "default", name: `Use detected default: ${detected}` });
342
+ choices.push({ value: "custom", name: "Enter custom command" });
343
+ choices.push({ value: "skip", name: "Skip for now" });
344
+ return select({ message, choices });
345
+ }
346
+ async function promptBuildConfig(cwd, platform) {
347
+ const defaults = defaultsFor(cwd, platform);
348
+ const choice = await commandSelect(`Build command for ${platform}`, defaults.buildCommand);
349
+ if (choice === "skip") return null;
350
+ if (choice === "default") {
351
+ return { command: defaults.buildCommand, artifact: defaults.artifact };
352
+ }
353
+ const command = await input({ message: `Build command for ${platform}`, required: true });
354
+ const artifact = await input({
355
+ message: "Path to the built app it produces (.apk, or .app dir; * allowed)",
356
+ default: defaults.artifact,
357
+ required: true
358
+ });
359
+ return { command, artifact };
360
+ }
361
+ async function promptMetroCommand(cwd, platform) {
362
+ const defaults = defaultsFor(cwd, platform);
363
+ const choice = await commandSelect("Command that starts Metro", defaults.metroCommand);
364
+ if (choice === "skip") return null;
365
+ if (choice === "default") return defaults.metroCommand;
366
+ return input({ message: "Metro command", required: true });
367
+ }
368
+
369
+ // src/commands/build.ts
370
+ var TAIL_LINES = 30;
371
+ async function runBuildForPlatform(cwd, platform, build, opts, log, deps = { upload: uploadAppBuild }) {
372
+ const tail = await runShell(build.command, cwd, log);
373
+ if (tail.code !== 0) {
374
+ throw new Error(
375
+ `build command failed (exit ${tail.code}): ${build.command}
376
+ ` + (tail.lines.length ? `Last output:
377
+ ${tail.lines.join("\n ")}` : "")
378
+ );
379
+ }
380
+ const artifactPath = resolveArtifact(cwd, build.artifact);
381
+ const { uploadPath, cleanup } = prepareUploadArtifact(artifactPath);
382
+ try {
383
+ const { result } = await deps.upload(uploadPath, opts);
384
+ if (result.platform !== platform) {
385
+ throw new Error(
386
+ `you built an ${result.platform} app (${result.name}) but the target platform is ${platform} \u2014 check platforms.${platform}.build in vibeview.json`
387
+ );
388
+ }
389
+ return result;
390
+ } finally {
391
+ cleanup();
392
+ }
393
+ }
394
+ function runShell(command, cwd, log) {
395
+ return new Promise((resolve) => {
396
+ const child = spawn(command, { cwd, shell: true, stdio: ["ignore", "pipe", "pipe"] });
397
+ const lines = [];
398
+ const onLine = (line) => {
399
+ lines.push(line);
400
+ if (lines.length > TAIL_LINES) lines.shift();
401
+ log(line);
402
+ };
403
+ createInterface({ input: child.stdout }).on("line", onLine);
404
+ createInterface({ input: child.stderr }).on("line", onLine);
405
+ child.on("close", (code) => resolve({ code: code ?? 1, lines }));
406
+ child.on("error", () => resolve({ code: 127, lines }));
407
+ });
408
+ }
409
+ async function buildCommand(opts) {
410
+ const cwd = process.cwd();
411
+ const cfg = loadDevConfig(cwd);
412
+ if (opts.platform && !isDevPlatform(opts.platform)) {
413
+ console.error(chalk.red(`error: --platform must be one of ${DEV_PLATFORMS.join(", ")}`));
414
+ process.exit(2);
415
+ }
416
+ let platform = opts.platform || cfg?.defaultPlatform;
417
+ if (!platform) {
418
+ if (opts.json || !process.stdin.isTTY) {
419
+ fail(
420
+ opts,
421
+ "no platform: pass --platform or run `vibeview dev` once to set up vibeview.json",
422
+ "config_required"
423
+ );
424
+ return;
425
+ }
426
+ platform = await select2({
427
+ message: "Platform to run on",
428
+ choices: [
429
+ { value: "ios", name: "iOS simulator" },
430
+ { value: "android", name: "Android emulator" },
431
+ { value: "tvos", name: "Apple TV simulator" },
432
+ { value: "androidtv", name: "Android TV emulator" }
433
+ ]
434
+ });
435
+ }
436
+ let build = cfg?.platforms?.[platform]?.build;
437
+ if (!build) {
438
+ if (opts.json || !process.stdin.isTTY) {
439
+ fail(
440
+ opts,
441
+ `no build config for ${platform}: add platforms.${platform}.build {command, artifact} to vibeview.json`,
442
+ "config_required"
443
+ );
444
+ return;
445
+ }
446
+ const answered = await promptBuildConfig(cwd, platform);
447
+ if (!answered) {
448
+ fail(opts, "build config is required to run vibeview build", "config_required");
449
+ return;
450
+ }
451
+ build = answered;
452
+ if (cfg?.platforms?.[platform]) {
453
+ cfg.platforms[platform].build = build;
454
+ saveDevConfig(cwd, cfg);
455
+ }
456
+ }
457
+ try {
458
+ const result = await runBuildForPlatform(
459
+ cwd,
460
+ platform,
461
+ build,
462
+ opts,
463
+ (l) => console.error(chalk.dim(l))
464
+ );
465
+ for (const w of result.warnings ?? []) console.error(chalk.yellow(`\u26A0 ${w}`));
466
+ const nextCfg = cfg ?? { defaultPlatform: platform, platforms: {} };
467
+ const entry = nextCfg.platforms[platform];
468
+ if (!entry) {
469
+ nextCfg.platforms[platform] = { appId: result.public_id, build };
470
+ saveDevConfig(cwd, nextCfg);
471
+ } else if (entry.appId !== result.public_id || !entry.build) {
472
+ entry.appId = result.public_id;
473
+ entry.build = build;
474
+ saveDevConfig(cwd, nextCfg);
475
+ }
476
+ if (opts.json) {
477
+ console.log(
478
+ JSON.stringify({ app_id: result.public_id, name: result.name, platform: result.platform })
479
+ );
480
+ } else {
481
+ console.error(
482
+ chalk.green(`\u2713 built and uploaded ${result.name}`) + `
483
+ ${chalk.cyan(result.public_id)} ${chalk.dim(`[${result.platform}]`)}`
484
+ );
485
+ }
486
+ } catch (err) {
487
+ fail(opts, err instanceof Error ? err.message : String(err));
488
+ }
489
+ }
490
+ function fail(opts, message, code) {
491
+ if (opts.json) console.log(JSON.stringify({ event: "error", code, message }));
492
+ console.error(chalk.red(`error: ${message}`));
493
+ process.exit(1);
494
+ }
495
+
496
+ // src/commands/dev.ts
497
+ import { spawn as spawn3 } from "child_process";
498
+ import { createInterface as createInterface3 } from "readline";
499
+ import { select as select3 } from "@inquirer/prompts";
500
+ import chalk2 from "chalk";
501
+ import openBrowser from "open";
502
+
503
+ // src/lib/tunnel-client.ts
504
+ import net from "net";
505
+ import WebSocket from "ws";
506
+
507
+ // src/lib/mux.ts
508
+ var FRAME_OPEN = 1;
509
+ var FRAME_DATA = 2;
510
+ var FRAME_CLOSE = 3;
511
+ var HEADER_LEN = 5;
512
+ var KNOWN = /* @__PURE__ */ new Set([FRAME_OPEN, FRAME_DATA, FRAME_CLOSE]);
513
+ function encodeFrame(type, streamId, payload = Buffer.alloc(0)) {
514
+ const header = Buffer.alloc(HEADER_LEN);
515
+ header.writeUInt8(type, 0);
516
+ header.writeUInt32BE(streamId, 1);
517
+ return Buffer.concat([header, payload]);
518
+ }
519
+ function decodeFrame(data) {
520
+ if (data.length < HEADER_LEN) throw new Error(`tunnel frame too short: ${data.length}`);
521
+ const type = data.readUInt8(0);
522
+ if (!KNOWN.has(type)) throw new Error(`unknown tunnel frame type: ${type}`);
523
+ return { type, streamId: data.readUInt32BE(1), payload: data.subarray(HEADER_LEN) };
524
+ }
525
+
526
+ // src/lib/tunnel-client.ts
527
+ var FATAL_CLOSE_CODES = /* @__PURE__ */ new Set([4008, 4009]);
528
+ var MAX_BACKOFF_MS = 8e3;
529
+ var STABLE_MS = 2e3;
530
+ var TunnelClient = class {
531
+ constructor(opts) {
532
+ this.opts = opts;
533
+ this.url = opts.url;
534
+ }
535
+ ws = null;
536
+ sockets = /* @__PURE__ */ new Map();
537
+ stopped = false;
538
+ backoffMs = 1e3;
539
+ metroWarned = false;
540
+ reconnectTimer = null;
541
+ stableTimer = null;
542
+ url;
543
+ connCount = 0;
544
+ debug(msg) {
545
+ this.opts.onDebug?.(msg);
546
+ }
547
+ start() {
548
+ this.connect();
549
+ }
550
+ stop() {
551
+ if (this.reconnectTimer) {
552
+ clearTimeout(this.reconnectTimer);
553
+ this.reconnectTimer = null;
554
+ }
555
+ if (this.stableTimer) {
556
+ clearTimeout(this.stableTimer);
557
+ this.stableTimer = null;
558
+ }
559
+ this.stopped = true;
560
+ this.setState("closed");
561
+ this.ws?.close();
562
+ for (const s of this.sockets.values()) s.destroy();
563
+ this.sockets.clear();
564
+ }
565
+ setState(s) {
566
+ this.opts.onState?.(s);
567
+ }
568
+ connect() {
569
+ if (this.stopped) return;
570
+ this.setState("connecting");
571
+ const conn = ++this.connCount;
572
+ const openedAt = Date.now();
573
+ this.debug(`ws#${conn} connecting url=${this.url}`);
574
+ const ws = new WebSocket(this.url);
575
+ this.ws = ws;
576
+ ws.on("open", () => {
577
+ this.backoffMs = 1e3;
578
+ this.debug(`ws#${conn} open`);
579
+ this.stableTimer = setTimeout(() => {
580
+ this.stableTimer = null;
581
+ if (this.ws === ws && !this.stopped) this.setState("connected");
582
+ }, STABLE_MS);
583
+ });
584
+ ws.on("message", (data) => this.onFrame(data));
585
+ ws.on("error", (err) => {
586
+ this.debug(`ws#${conn} error: ${err.message}`);
587
+ });
588
+ ws.on("close", (code, reason) => {
589
+ if (this.ws !== ws) this.debug(`ws#${conn} close code=${code} \u2014 SUPERSEDED socket`);
590
+ const heldMs = Date.now() - openedAt;
591
+ const why = reason?.length ? ` reason="${reason.toString()}"` : "";
592
+ this.debug(`ws#${conn} close code=${code}${why} heldMs=${heldMs}`);
593
+ if (this.stableTimer) {
594
+ clearTimeout(this.stableTimer);
595
+ this.stableTimer = null;
596
+ }
597
+ for (const s of this.sockets.values()) s.destroy();
598
+ this.sockets.clear();
599
+ if (this.stopped || FATAL_CLOSE_CODES.has(code)) {
600
+ this.stopped = true;
601
+ this.setState("closed");
602
+ return;
603
+ }
604
+ this.setState("reconnecting");
605
+ const delay = this.backoffMs;
606
+ this.backoffMs = Math.min(this.backoffMs * 2, MAX_BACKOFF_MS);
607
+ this.debug(`ws#${conn} scheduling reconnect in ${delay}ms`);
608
+ this.reconnectTimer = setTimeout(() => {
609
+ this.reconnectTimer = null;
610
+ void this.reconnect();
611
+ }, delay);
612
+ });
613
+ }
614
+ async reconnect() {
615
+ if (this.stopped) return;
616
+ if (this.opts.refreshUrl) {
617
+ let fresh;
618
+ try {
619
+ fresh = await this.opts.refreshUrl();
620
+ } catch (err) {
621
+ this.debug(`refreshUrl threw: ${err.message}`);
622
+ fresh = null;
623
+ }
624
+ if (this.stopped) return;
625
+ if (!fresh) {
626
+ this.debug("refreshUrl returned null \u2014 session gone, closing");
627
+ this.stopped = true;
628
+ this.setState("closed");
629
+ return;
630
+ }
631
+ this.debug("refreshUrl ok \u2014 reconnecting with fresh url");
632
+ this.url = fresh;
633
+ }
634
+ this.connect();
635
+ }
636
+ onFrame(data) {
637
+ let frame;
638
+ try {
639
+ frame = decodeFrame(Buffer.isBuffer(data) ? data : Buffer.from(data));
640
+ } catch {
641
+ return;
642
+ }
643
+ if (frame.type === FRAME_OPEN) return this.openStream(frame.streamId);
644
+ const sock = this.sockets.get(frame.streamId);
645
+ if (!sock) return;
646
+ if (frame.type === FRAME_DATA) sock.write(frame.payload);
647
+ else if (frame.type === FRAME_CLOSE) sock.destroy();
648
+ }
649
+ openStream(id) {
650
+ const sock = net.createConnection({ port: this.opts.metroPort, host: "127.0.0.1" });
651
+ this.sockets.set(id, sock);
652
+ sock.on("data", (chunk) => this.ws?.send(encodeFrame(FRAME_DATA, id, chunk)));
653
+ sock.on("error", () => {
654
+ if (!this.metroWarned) {
655
+ this.metroWarned = true;
656
+ this.setState("metro-unreachable");
657
+ }
658
+ });
659
+ sock.on("close", () => {
660
+ if (this.sockets.delete(id) && this.ws?.readyState === WebSocket.OPEN) {
661
+ this.ws.send(encodeFrame(FRAME_CLOSE, id));
662
+ }
663
+ });
664
+ }
665
+ };
666
+
667
+ // src/lib/metro-manager.ts
668
+ import { spawn as spawn2 } from "child_process";
669
+ import { createInterface as createInterface2 } from "readline";
670
+
671
+ // src/lib/metro-state.ts
672
+ import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync4, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "fs";
673
+ import { dirname as dirname3, join as join5 } from "path";
674
+ var STATE_DIR2 = ".vibeview";
675
+ var STATE_FILE2 = "metro-state.json";
676
+ function statePath2(cwd) {
677
+ return join5(cwd, STATE_DIR2, STATE_FILE2);
678
+ }
679
+ function isPidAlive2(pid) {
680
+ try {
681
+ process.kill(pid, 0);
682
+ return true;
683
+ } catch (err) {
684
+ return err.code === "EPERM";
685
+ }
686
+ }
687
+ function writeMetroState(cwd, state) {
688
+ const path = statePath2(cwd);
689
+ mkdirSync2(dirname3(path), { recursive: true });
690
+ writeFileSync3(path, JSON.stringify(state, null, 2) + "\n");
691
+ }
692
+ function readMetroState(cwd) {
693
+ let parsed;
694
+ try {
695
+ parsed = JSON.parse(readFileSync4(statePath2(cwd), "utf-8"));
696
+ } catch {
697
+ return null;
698
+ }
699
+ if (typeof parsed.pid !== "number" || !isPidAlive2(parsed.pid)) {
700
+ clearMetroState(cwd);
701
+ return null;
702
+ }
703
+ return parsed;
704
+ }
705
+ function clearMetroState(cwd) {
706
+ const path = statePath2(cwd);
707
+ if (existsSync4(path)) rmSync3(path, { force: true });
708
+ }
709
+ function killOwnedMetro(cwd) {
710
+ const state = readMetroState(cwd);
711
+ clearMetroState(cwd);
712
+ if (!state) return false;
713
+ try {
714
+ process.kill(-state.pid, "SIGTERM");
715
+ return true;
716
+ } catch {
717
+ return false;
718
+ }
719
+ }
720
+
721
+ // src/lib/metro-manager.ts
722
+ var TAIL_LINES2 = 20;
723
+ async function ensureMetro(cwd, metro, log, timeoutMs = 9e4) {
724
+ const warnings = [];
725
+ const status = await checkMetro(metro.port);
726
+ const own = readMetroState(cwd);
727
+ const ownHoldsPort = own !== null && own.port === metro.port;
728
+ if (status !== "down") {
729
+ if (ownHoldsPort && metro.command && own.command !== metro.command) {
730
+ killOwnedMetro(cwd);
731
+ await waitFor(async () => await checkMetro(metro.port) === "down", 1e4, 250);
732
+ await spawnMetro(cwd, metro.command, metro.port, log, timeoutMs);
733
+ return { status: "respawned", warnings };
734
+ }
735
+ if (ownHoldsPort) return { status: "reused-own", warnings };
736
+ if (status === "listening") {
737
+ warnings.push(
738
+ `something is listening on localhost:${metro.port} but it doesn't look like Metro \u2014 continuing anyway`
739
+ );
740
+ }
741
+ if (metro.platformHasOverride) {
742
+ warnings.push(
743
+ `a Metro we didn't start is already running on localhost:${metro.port} \u2014 using it; make sure it was started with the right command for this platform`
744
+ );
745
+ }
746
+ return { status: "reused-external", warnings };
747
+ }
748
+ if (!metro.command) {
749
+ throw new Error(
750
+ `Metro isn't running on localhost:${metro.port}.
751
+ Start it in your project first (yarn start), then re-run vibeview dev.
752
+ Or set "metroCommand" in vibeview.json and vibeview dev will start it for you.
753
+ If Metro uses another port, pass --metro-port <port>.`
754
+ );
755
+ }
756
+ await spawnMetro(cwd, metro.command, metro.port, log, timeoutMs);
757
+ return { status: "spawned", warnings };
758
+ }
759
+ async function spawnMetro(cwd, command, port, log, timeoutMs) {
760
+ const child = spawn2(command, {
761
+ cwd,
762
+ shell: true,
763
+ detached: true,
764
+ stdio: ["ignore", "pipe", "pipe"]
765
+ });
766
+ const tail = [];
767
+ const onLine = (line) => {
768
+ tail.push(line);
769
+ if (tail.length > TAIL_LINES2) tail.shift();
770
+ log(`[metro] ${line}`);
771
+ };
772
+ createInterface2({ input: child.stdout }).on("line", onLine);
773
+ createInterface2({ input: child.stderr }).on("line", onLine);
774
+ let exited = null;
775
+ child.on("exit", (code) => exited = code ?? 1);
776
+ writeMetroState(cwd, {
777
+ pid: child.pid,
778
+ command,
779
+ port,
780
+ started_at: (/* @__PURE__ */ new Date()).toISOString()
781
+ });
782
+ const deadline = Date.now() + timeoutMs;
783
+ for (; ; ) {
784
+ if (exited !== null) {
785
+ killOwnedMetro(cwd);
786
+ throw new Error(
787
+ `Metro command exited (code ${exited}) before localhost:${port} came up.
788
+ ` + (tail.length ? `Last output:
789
+ ${tail.join("\n ")}` : "")
790
+ );
791
+ }
792
+ if (await checkMetro(port, 1e3) === "running") return;
793
+ if (Date.now() > deadline) {
794
+ killOwnedMetro(cwd);
795
+ throw new Error(`timed out waiting for Metro on localhost:${port} (command: ${command})`);
796
+ }
797
+ await new Promise((r) => setTimeout(r, 500));
798
+ }
799
+ }
800
+ async function waitFor(cond, timeoutMs, everyMs) {
801
+ const deadline = Date.now() + timeoutMs;
802
+ while (Date.now() < deadline) {
803
+ if (await cond()) return;
804
+ await new Promise((r) => setTimeout(r, everyMs));
805
+ }
806
+ }
807
+
808
+ // src/commands/dev.ts
809
+ function emit(json, evt) {
810
+ if (json) console.log(JSON.stringify(evt));
811
+ }
812
+ async function startDevSession(cfg, onEvent = () => {
813
+ }, onTunnelState) {
814
+ const api = new ApiClient(cfg.baseUrl, cfg.token);
815
+ const created = await api.createSession({
816
+ device_type: PLATFORM_DEVICE[cfg.platform].deviceType,
817
+ device_category: cfg.deviceCategory,
818
+ app_id: cfg.appId,
819
+ tunnel: true
820
+ });
821
+ const sessionId = created.session_id;
822
+ let stopping = false;
823
+ let tunnel = null;
824
+ const stopSession = async (code) => {
825
+ if (stopping) return;
826
+ stopping = true;
827
+ tunnel?.stop();
828
+ await api.stopSession(sessionId).catch(() => {
829
+ });
830
+ onEvent({ event: "session_ended", session_id: sessionId, code });
831
+ };
832
+ try {
833
+ await api.startSession(sessionId);
834
+ const ready = await pollUntilTunnelReady(api, sessionId, 18e4);
835
+ for (const w of ready.warnings ?? []) {
836
+ onEvent({ event: "warning", message: w });
837
+ }
838
+ const pageUrl = `${cfg.webUrl || cfg.baseUrl}/sandbox/${sessionId}`;
839
+ tunnel = new TunnelClient({
840
+ url: ready.tunnel_url,
841
+ metroPort: cfg.metroPort,
842
+ // Re-mint the URL on each reconnect: the tunnel token has a ~1h TTL and
843
+ // a worker restart drops the proxy registration, so the original URL
844
+ // eventually goes stale. Every authorized GET /sessions/{id} re-signs a
845
+ // fresh tunnel token. Null (session stopped/failed) ends the tunnel.
846
+ refreshUrl: async () => {
847
+ const info = await api.getSession(sessionId);
848
+ if (info.status === "stopped" || info.status === "failed") return null;
849
+ return info.tunnel_url ?? null;
850
+ },
851
+ onDebug: cfg.debug ? (msg) => onEvent({ event: "warning", message: `[tunnel] ${msg}` }) : void 0,
852
+ onState: (s) => {
853
+ onTunnelState?.(s);
854
+ if (s === "connected") {
855
+ onEvent({ event: "tunnel_connected", session_id: sessionId });
856
+ } else if (s === "metro-unreachable") {
857
+ onEvent({
858
+ event: "warning",
859
+ message: `can't reach Metro on localhost:${cfg.metroPort} \u2014 is it running? (yarn start)`
860
+ });
861
+ } else if (s === "closed" && !stopping) {
862
+ void stopSession(1);
863
+ }
864
+ }
865
+ });
866
+ tunnel.start();
867
+ if (cfg.open !== false) await openBrowser(pageUrl).catch(() => {
868
+ });
869
+ const active = await pollUntilActive(api, sessionId, 18e4);
870
+ const seen = new Set(ready.warnings ?? []);
871
+ for (const w of active.warnings ?? []) {
872
+ if (!seen.has(w)) onEvent({ event: "warning", message: w });
873
+ }
874
+ return { sessionId, pageUrl, stop: () => stopSession(0) };
875
+ } catch (err) {
876
+ await stopSession(1);
877
+ throw err;
878
+ }
879
+ }
880
+ var BLOCKING_WARNING_PREFIXES = ["No debug build", "App install failed", "App not found"];
881
+ function findBlockingWarning(info) {
882
+ return (info.warnings ?? []).find((w) => BLOCKING_WARNING_PREFIXES.some((p) => w.includes(p)));
883
+ }
884
+ async function pollUntilTunnelReady(api, sessionId, timeoutMs, intervalMs = 2e3) {
885
+ const deadline = Date.now() + timeoutMs;
886
+ for (; ; ) {
887
+ const info = await api.getSession(sessionId);
888
+ const blocking = findBlockingWarning(info);
889
+ if (blocking) throw new Error(blocking);
890
+ if (info.tunnel_url) return info;
891
+ if (info.status === "stopped" || info.status === "failed") {
892
+ throw new Error(`session ${info.status} before the tunnel came up`);
893
+ }
894
+ if (Date.now() > deadline) throw new Error("timed out waiting for the device to start");
895
+ await new Promise((r) => setTimeout(r, intervalMs));
896
+ }
897
+ }
898
+ async function pollUntilActive(api, sessionId, timeoutMs, intervalMs = 2e3) {
899
+ const deadline = Date.now() + timeoutMs;
900
+ for (; ; ) {
901
+ const info = await api.getSession(sessionId);
902
+ const blocking = findBlockingWarning(info);
903
+ if (blocking) throw new Error(blocking);
904
+ if (info.status === "active") return info;
905
+ if (info.status === "stopped" || info.status === "failed") {
906
+ throw new Error(`session ${info.status} before it became active`);
907
+ }
908
+ if (Date.now() > deadline) {
909
+ throw new Error("timed out waiting for the session to become active");
910
+ }
911
+ await new Promise((r) => setTimeout(r, intervalMs));
912
+ }
913
+ }
914
+ var PLATFORM_DEVICE = {
915
+ ios: { deviceType: "ios", tv: false },
916
+ android: { deviceType: "android", tv: false },
917
+ tvos: { deviceType: "ios", tv: true },
918
+ androidtv: { deviceType: "android", tv: true }
919
+ };
920
+ function appsForPlatform(apps, platform) {
921
+ return apps.filter((a) => a.platform === platform);
922
+ }
923
+ function validateAppChoice(requested, fromFlag, apps) {
924
+ if (!requested) return {};
925
+ if (apps.some((a) => a.public_id === requested)) return { appId: requested };
926
+ return fromFlag ? { staleFlag: requested } : { staleCached: requested };
927
+ }
928
+ function noAppsMessage(platform, hasBuildConfig) {
929
+ if (hasBuildConfig) {
930
+ return `No ${platform} apps found \u2014 run \`vibeview dev --build\` (or \`vibeview build\`) to build and upload one.`;
931
+ }
932
+ return `No ${platform} apps found. Run \`vibeview build --platform ${platform}\` to build and upload one (it will ask for your build command), or upload a build with \`vibeview upload-app\`.`;
933
+ }
934
+ function requireNonInteractive(json) {
935
+ if (!json) return;
936
+ emit(json, {
937
+ event: "error",
938
+ code: "config_required",
939
+ message: "pass --platform and run once interactively, or commit vibeview.json"
940
+ });
941
+ process.exit(1);
942
+ }
943
+ async function ensureConfig(api, opts) {
944
+ const existing = loadDevConfig(process.cwd());
945
+ if (opts.platform && !isDevPlatform(opts.platform)) {
946
+ console.error(chalk2.red(`error: --platform must be one of ios, android, tvos, androidtv`));
947
+ process.exit(2);
948
+ }
949
+ if (!opts.platform && !existing?.defaultPlatform) {
950
+ requireNonInteractive(opts.json);
951
+ }
952
+ const platform = opts.platform || existing?.defaultPlatform || await select3({
953
+ message: "Platform to run on",
954
+ choices: [
955
+ { value: "ios", name: "iOS simulator" },
956
+ { value: "android", name: "Android emulator" },
957
+ { value: "tvos", name: "Apple TV simulator" },
958
+ { value: "androidtv", name: "Android TV emulator" }
959
+ ]
960
+ });
961
+ const isTv = PLATFORM_DEVICE[platform].tv;
962
+ if (isTv && opts.deviceCategory) {
963
+ console.error(
964
+ chalk2.red(`error: --device-category does not apply to ${platform} (always a tv device)`)
965
+ );
966
+ process.exit(2);
967
+ }
968
+ if (!isTv && opts.deviceCategory === "tv") {
969
+ console.error(chalk2.red(`error: use --platform tvos or --platform androidtv for TV devices`));
970
+ process.exit(2);
971
+ }
972
+ let firstRunBuild = null;
973
+ let firstRunMetro = null;
974
+ let isFirstRun = false;
975
+ if (!existing) {
976
+ isFirstRun = true;
977
+ if (opts.json) {
978
+ requireNonInteractive(opts.json);
979
+ } else if (process.stdin.isTTY) {
980
+ firstRunBuild = await promptBuildConfig(process.cwd(), platform);
981
+ firstRunMetro = await promptMetroCommand(process.cwd(), platform);
982
+ }
983
+ }
984
+ const entry = existing?.platforms?.[platform];
985
+ let allApps;
986
+ try {
987
+ allApps = await api.listApps();
988
+ } catch (err) {
989
+ const message = `could not fetch your app list \u2014 ${err instanceof Error ? err.message : String(err)}`;
990
+ console.error(chalk2.red(`error: ${message}`));
991
+ emit(opts.json, { event: "error", message });
992
+ process.exit(1);
993
+ }
994
+ const apps = appsForPlatform(allApps, platform);
995
+ const choice = validateAppChoice(opts.app || entry?.appId, Boolean(opts.app), apps);
996
+ if (choice.staleFlag) {
997
+ const message = `app "${choice.staleFlag}" not found \u2014 it may have been deleted.`;
998
+ console.error(
999
+ chalk2.red(`error: ${message}`) + `
1000
+ Run \`vibeview dev\` without --app to pick from the list.`
1001
+ );
1002
+ emit(opts.json, { event: "error", message });
1003
+ process.exit(2);
1004
+ }
1005
+ let appId = choice.appId;
1006
+ let builtThisRun = false;
1007
+ if (choice.staleCached) {
1008
+ console.error(
1009
+ chalk2.yellow(`\u26A0 app "${choice.staleCached}" no longer exists \u2014 pick your debug build again`)
1010
+ );
1011
+ }
1012
+ if (isFirstRun && !appId) {
1013
+ const strategy = firstRunAppStrategy(apps, firstRunBuild !== null);
1014
+ let action = strategy === "build_now" ? "build" : "pick";
1015
+ if (strategy === "ask") {
1016
+ action = await select3({
1017
+ message: "Run against which build?",
1018
+ choices: [
1019
+ { value: "build", name: "Build fresh now (runs your build command)" },
1020
+ { value: "pick", name: "Use an already-uploaded app" }
1021
+ ]
1022
+ });
1023
+ }
1024
+ if (action === "build") {
1025
+ console.error(chalk2.dim(`Building ${platform}...`));
1026
+ try {
1027
+ const result = await runBuildForPlatform(
1028
+ process.cwd(),
1029
+ platform,
1030
+ firstRunBuild,
1031
+ opts,
1032
+ (l) => console.error(chalk2.dim(l))
1033
+ );
1034
+ appId = result.public_id;
1035
+ builtThisRun = true;
1036
+ } catch (err) {
1037
+ const message = err.message;
1038
+ console.error(chalk2.red(`error: ${message}`));
1039
+ process.exit(1);
1040
+ }
1041
+ }
1042
+ }
1043
+ if (!appId) {
1044
+ if (apps.length === 0) {
1045
+ const message = noAppsMessage(platform, entry?.build !== void 0);
1046
+ console.error(chalk2.red(message));
1047
+ emit(opts.json, { event: "error", code: "no_apps", message });
1048
+ process.exit(1);
1049
+ }
1050
+ if (choice.staleCached) {
1051
+ const message = `app "${choice.staleCached}" for ${platform} no longer exists \u2014 pass --app <id> (see \`vibeview list-apps\`) or run interactively to pick one.`;
1052
+ console.error(chalk2.red(`error: ${message}`));
1053
+ emit(opts.json, { event: "error", code: "app_deleted", message });
1054
+ process.exit(1);
1055
+ }
1056
+ requireNonInteractive(opts.json);
1057
+ if (!process.stdin.isTTY) {
1058
+ console.error(
1059
+ chalk2.red(
1060
+ `error: no valid app configured for ${platform} \u2014 run interactively to pick one, or pass --app <id>`
1061
+ )
1062
+ );
1063
+ process.exit(1);
1064
+ }
1065
+ appId = await select3({
1066
+ message: "Which uploaded app is your debug build? (upload one with `vibeview upload-app`)",
1067
+ // Platform suffix dropped: the picker is already filtered to one platform
1068
+ // (appsForPlatform), so `(ios)` on every row is redundant noise.
1069
+ choices: apps.map((a) => ({ value: a.public_id, name: a.name }))
1070
+ });
1071
+ }
1072
+ const deviceCategory = isTv ? "tv" : opts.deviceCategory || entry?.deviceCategory || "phone";
1073
+ const metroResolved = resolveMetro(
1074
+ existing,
1075
+ platform,
1076
+ opts.metroPort ? parseInt(opts.metroPort, 10) : void 0
1077
+ );
1078
+ const cfg = buildSavedConfig({
1079
+ existing,
1080
+ platform,
1081
+ appId,
1082
+ deviceCategory,
1083
+ isTv,
1084
+ metroPortFlag: opts.metroPort,
1085
+ isFirstRun,
1086
+ firstRunMetro,
1087
+ firstRunBuild
1088
+ });
1089
+ saveDevConfig(process.cwd(), cfg);
1090
+ if (isFirstRun) {
1091
+ const hint = fillLaterHint({ build: !firstRunBuild, metro: !firstRunMetro }, platform);
1092
+ if (hint) console.error(chalk2.dim(hint));
1093
+ }
1094
+ const finalMetro = isFirstRun ? resolveMetro(cfg, platform, opts.metroPort ? parseInt(opts.metroPort, 10) : void 0) : metroResolved;
1095
+ return {
1096
+ platform,
1097
+ appId,
1098
+ deviceCategory,
1099
+ metroPort: finalMetro.port,
1100
+ metroCommand: finalMetro.command,
1101
+ platformHasMetroOverride: finalMetro.platformHasOverride,
1102
+ builtThisRun
1103
+ };
1104
+ }
1105
+ async function runDevInProcess(opts) {
1106
+ if (opts.json) {
1107
+ const hasToken = !!(opts.token || process.env.VIBEVIEW_API_TOKEN || loadConfig().token);
1108
+ if (!hasToken) {
1109
+ emit(opts.json, {
1110
+ event: "error",
1111
+ code: "config_required",
1112
+ message: "No API token found. Set VIBEVIEW_API_TOKEN, use --token, or run: vibeview login"
1113
+ });
1114
+ process.exit(1);
1115
+ }
1116
+ }
1117
+ const { token, baseUrl } = resolveAuth(opts);
1118
+ console.error(chalk2.dim(`Using ${baseUrl}`));
1119
+ const api = new ApiClient(baseUrl, token);
1120
+ const cfg = await ensureConfig(api, opts);
1121
+ if (opts.build && !cfg.builtThisRun) {
1122
+ const reloaded = loadDevConfig(process.cwd());
1123
+ const build = reloaded?.platforms?.[cfg.platform]?.build;
1124
+ if (!build) {
1125
+ const message = `no build config for ${cfg.platform} \u2014 run vibeview build once to set it up`;
1126
+ console.error(chalk2.red(`error: ${message}`));
1127
+ emit(opts.json, { event: "error", code: "config_required", message });
1128
+ process.exit(1);
1129
+ }
1130
+ console.error(chalk2.dim(`Building ${cfg.platform}...`));
1131
+ try {
1132
+ const result = await runBuildForPlatform(
1133
+ process.cwd(),
1134
+ cfg.platform,
1135
+ build,
1136
+ { token, baseUrl },
1137
+ (l) => console.error(chalk2.dim(l))
1138
+ );
1139
+ cfg.appId = result.public_id;
1140
+ if (reloaded && reloaded.platforms[cfg.platform] && reloaded.platforms[cfg.platform].appId !== result.public_id) {
1141
+ reloaded.platforms[cfg.platform].appId = result.public_id;
1142
+ saveDevConfig(process.cwd(), reloaded);
1143
+ }
1144
+ } catch (err) {
1145
+ const message = err.message;
1146
+ console.error(chalk2.red(`error: ${message}`));
1147
+ emit(opts.json, { event: "error", message });
1148
+ process.exit(1);
1149
+ }
1150
+ }
1151
+ const metroPort = cfg.metroPort ?? 8081;
1152
+ let metroResult;
1153
+ try {
1154
+ metroResult = await ensureMetro(
1155
+ process.cwd(),
1156
+ {
1157
+ command: cfg.metroCommand,
1158
+ port: metroPort,
1159
+ platformHasOverride: cfg.platformHasMetroOverride
1160
+ },
1161
+ (line) => console.error(chalk2.dim(line))
1162
+ );
1163
+ } catch (err) {
1164
+ const message = err.message;
1165
+ console.error(chalk2.red(`error: ${message}`));
1166
+ emit(opts.json, { event: "error", code: "metro_down", message });
1167
+ process.exit(1);
1168
+ }
1169
+ for (const w of metroResult.warnings) console.error(chalk2.yellow(`\u26A0 ${w}`));
1170
+ if (metroResult.status === "spawned" || metroResult.status === "respawned") {
1171
+ console.error(chalk2.green(`\u2713 started Metro (${cfg.metroCommand})`));
1172
+ }
1173
+ const metroOwnedThisRun = metroResult.status === "spawned" || metroResult.status === "respawned";
1174
+ console.error(chalk2.dim(`Starting a ${cfg.platform} dev session for ${cfg.appId}...`));
1175
+ let stopping = false;
1176
+ let handle = null;
1177
+ const stopSession = async (code) => {
1178
+ if (stopping) return;
1179
+ stopping = true;
1180
+ console.error(chalk2.dim("\nStopping session..."));
1181
+ await handle?.stop();
1182
+ if (metroOwnedThisRun) killOwnedMetro(process.cwd());
1183
+ clearDevState(process.cwd());
1184
+ process.exit(code);
1185
+ };
1186
+ process.on("SIGINT", () => void stopSession(0));
1187
+ process.on("SIGTERM", () => void stopSession(0));
1188
+ let everConnected = false;
1189
+ let waitingPrinted = false;
1190
+ let nextWarningIsMetroUnreachable = false;
1191
+ try {
1192
+ handle = await startDevSession(
1193
+ {
1194
+ token,
1195
+ baseUrl,
1196
+ webUrl: opts.webUrl || process.env.VIBEVIEW_WEB_URL,
1197
+ platform: cfg.platform,
1198
+ appId: cfg.appId,
1199
+ deviceCategory: cfg.deviceCategory,
1200
+ metroPort: cfg.metroPort ?? 8081,
1201
+ // startDevSession's own browser-open happens before we've written the
1202
+ // state file / emitted session_ready — open it ourselves afterward
1203
+ // instead, preserving the original event order.
1204
+ open: false,
1205
+ debug: opts.debug
1206
+ },
1207
+ (evt) => {
1208
+ if (evt.event === "warning") {
1209
+ if (nextWarningIsMetroUnreachable) {
1210
+ nextWarningIsMetroUnreachable = false;
1211
+ console.error(chalk2.red(`\u2717 ${evt.message}`));
1212
+ } else {
1213
+ console.error(chalk2.yellow(`\u26A0 ${evt.message}`));
1214
+ }
1215
+ } else if (evt.event === "tunnel_connected") {
1216
+ everConnected = true;
1217
+ console.error(chalk2.green("\u2713 tunnel connected \u2014 edit away, Fast Refresh is live"));
1218
+ } else if (evt.event === "session_ended" && !stopping && handle) {
1219
+ console.error(chalk2.red("tunnel closed by server \u2014 session ended"));
1220
+ stopping = true;
1221
+ emit(opts.json, evt);
1222
+ clearDevState(process.cwd());
1223
+ process.exit(1);
1224
+ }
1225
+ emit(opts.json, evt);
1226
+ },
1227
+ (s) => {
1228
+ if (s === "reconnecting") {
1229
+ if (!everConnected) {
1230
+ if (!waitingPrinted) {
1231
+ waitingPrinted = true;
1232
+ console.error(
1233
+ chalk2.dim("\u2026 waiting for the device to come up (tunnel not ready yet)")
1234
+ );
1235
+ }
1236
+ return;
1237
+ }
1238
+ console.error(chalk2.yellow("\u2026 tunnel dropped, reconnecting"));
1239
+ } else if (s === "metro-unreachable") {
1240
+ nextWarningIsMetroUnreachable = true;
1241
+ }
1242
+ }
1243
+ );
1244
+ console.error(
1245
+ `
1246
+ Watch and interact: ${chalk2.cyan(handle.pageUrl)}
1247
+ Ctrl-C to end the session.
1248
+ `
1249
+ );
1250
+ writeDevState(process.cwd(), {
1251
+ pid: process.pid,
1252
+ session_id: handle.sessionId,
1253
+ page_url: handle.pageUrl,
1254
+ platform: cfg.platform,
1255
+ app_id: cfg.appId,
1256
+ metro_port: cfg.metroPort ?? 8081,
1257
+ started_at: (/* @__PURE__ */ new Date()).toISOString()
1258
+ });
1259
+ emit(opts.json, {
1260
+ event: "session_ready",
1261
+ session_id: handle.sessionId,
1262
+ url: handle.pageUrl,
1263
+ pid: process.pid
1264
+ });
1265
+ if (opts.open !== false) await openBrowser(handle.pageUrl).catch(() => {
1266
+ });
1267
+ } catch (err) {
1268
+ const message = err.message;
1269
+ console.error(chalk2.red(`error: ${message}`));
1270
+ emit(opts.json, { event: "error", message });
1271
+ await stopSession(1);
1272
+ }
1273
+ }
1274
+ async function dev(opts) {
1275
+ if (!opts.detach) {
1276
+ return runDevInProcess(opts);
1277
+ }
1278
+ const rawArgs = process.argv.slice(2).filter((a) => a !== "--detach");
1279
+ const child = spawn3(process.execPath, [process.argv[1], ...rawArgs, "--json", "--no-open"], {
1280
+ detached: true,
1281
+ stdio: ["ignore", "pipe", "ignore"]
1282
+ });
1283
+ const rl = createInterface3({ input: child.stdout });
1284
+ let settled = false;
1285
+ await new Promise((resolve, reject) => {
1286
+ rl.on("line", (line) => {
1287
+ let parsed;
1288
+ try {
1289
+ parsed = JSON.parse(line);
1290
+ } catch {
1291
+ return;
1292
+ }
1293
+ if (parsed.event === "session_ready") {
1294
+ settled = true;
1295
+ rl.close();
1296
+ if (opts.json) {
1297
+ console.log(JSON.stringify(parsed));
1298
+ } else {
1299
+ console.error(chalk2.dim(`Using ${resolveAuth(opts).baseUrl}`));
1300
+ console.error(chalk2.green(`\u2713 dev session started in the background (pid ${parsed.pid})`));
1301
+ console.error(`
1302
+ Watch and interact: ${chalk2.cyan(parsed.url)}`);
1303
+ console.error(chalk2.dim(` vibeview dev-status \u2014 check on it`));
1304
+ console.error(chalk2.dim(` vibeview dev-stop \u2014 end it
1305
+ `));
1306
+ }
1307
+ child.unref();
1308
+ child.stdout.destroy();
1309
+ resolve();
1310
+ } else if (parsed.event === "error") {
1311
+ settled = true;
1312
+ rl.close();
1313
+ emit(opts.json, parsed);
1314
+ if (!opts.json) console.error(chalk2.red(`error: ${parsed.message}`));
1315
+ reject(new Error(parsed.message));
1316
+ } else if (parsed.event === "warning") {
1317
+ if (opts.json) {
1318
+ console.log(JSON.stringify(parsed));
1319
+ } else {
1320
+ console.error(chalk2.yellow(`\u26A0 ${parsed.message}`));
1321
+ }
1322
+ }
1323
+ });
1324
+ child.on("exit", (code) => {
1325
+ if (settled) return;
1326
+ rl.close();
1327
+ const message = `dev session exited before starting (code ${code ?? "unknown"})`;
1328
+ emit(opts.json, { event: "error", message });
1329
+ if (!opts.json) console.error(chalk2.red(`error: ${message}`));
1330
+ reject(new Error(message));
1331
+ });
1332
+ child.on("error", (err) => {
1333
+ if (settled) return;
1334
+ rl.close();
1335
+ emit(opts.json, { event: "error", message: err.message });
1336
+ if (!opts.json) console.error(chalk2.red(`error: ${err.message}`));
1337
+ reject(err);
1338
+ });
1339
+ }).catch(() => {
1340
+ process.exitCode = 1;
1341
+ return;
1342
+ });
1343
+ if (process.exitCode === 1) return;
1344
+ }
1345
+
1346
+ // src/agent/registry.ts
1347
+ var AGENT_COMMANDS = [
1348
+ {
1349
+ verb: "ui-tree",
1350
+ tool: "ui_tree",
1351
+ summary: "Fetch the ref-annotated UI tree for the current screen.",
1352
+ mutating: false,
1353
+ params: []
1354
+ },
1355
+ {
1356
+ verb: "screenshot",
1357
+ tool: "screenshot",
1358
+ summary: "Capture a screenshot of the current screen. Use it to check how something LOOKS \u2014 the UI tree carries no colour, contrast, or stacking information, so a screen can pass every check the tree can express and still be visibly broken. Saved to disk; over MCP, pass inline: true to receive the image itself.",
1359
+ mutating: false,
1360
+ params: [
1361
+ {
1362
+ name: "out",
1363
+ cli: "--out",
1364
+ type: "string",
1365
+ required: false,
1366
+ description: "File path to write the PNG to (CLI-side only, not sent to the backend).",
1367
+ cliOnly: true
1368
+ }
1369
+ ]
1370
+ },
1371
+ {
1372
+ // Fans out to `tap` (wire arg: ref) when the target is an `@ref`, or
1373
+ // `tap_coordinates` (wire args: x, y) when it's `x,y` — see tools.py
1374
+ // `tap`/`tap_coordinates` schemas. Dispatch logic lives in buildToolCall
1375
+ // (Task 4); this descriptor documents the shared surface.
1376
+ verb: "tap",
1377
+ tool: "tap",
1378
+ summary: "Tap an element by ref (e.g. @e5), or raw coordinates (e.g. 100,200).",
1379
+ mutating: true,
1380
+ params: [
1381
+ {
1382
+ name: "target",
1383
+ cli: "positional",
1384
+ type: "string",
1385
+ required: true,
1386
+ description: 'Element ref (e.g. @e5 or e5) or "x,y" coordinates.'
1387
+ }
1388
+ ]
1389
+ },
1390
+ {
1391
+ // tools.py long_press: { ref: string, duration_ms?: integer }. Only a
1392
+ // ref target is supported (no raw-coordinate long-press tool exists),
1393
+ // so the positional here always resolves to `ref`.
1394
+ verb: "long-press",
1395
+ tool: "long_press",
1396
+ summary: "Long-press (touch and hold) an element by its ref.",
1397
+ mutating: true,
1398
+ params: [
1399
+ {
1400
+ name: "ref",
1401
+ cli: "positional",
1402
+ type: "string",
1403
+ required: true,
1404
+ description: "Element ref from the UI tree (e.g. @e5 or e5)."
1405
+ },
1406
+ {
1407
+ name: "duration_ms",
1408
+ cli: "--ms",
1409
+ type: "number",
1410
+ required: false,
1411
+ description: "Hold duration in milliseconds (default: 800)."
1412
+ }
1413
+ ]
1414
+ },
1415
+ {
1416
+ // tools.py swipe: { direction: "up"|"down"|"left"|"right", distance?: "short"|"medium"|"long" }.
1417
+ // tool_dispatch.py's `swipe` branch reads ONLY args["direction"] (default
1418
+ // "down") and args["distance"] (default "medium") — it does not accept
1419
+ // x1/y1/x2/y2 or from/to coordinates. Raw two-point dragging is the
1420
+ // separate `drag` verb below — the only precise two-point gesture on this
1421
+ // surface.
1422
+ verb: "swipe",
1423
+ tool: "swipe",
1424
+ summary: "Perform a scrollbar-semantic swipe gesture in one of four directions.",
1425
+ mutating: true,
1426
+ params: [
1427
+ {
1428
+ name: "direction",
1429
+ cli: "positional",
1430
+ type: "string",
1431
+ required: true,
1432
+ description: "Direction to reveal content: up, down, left, or right."
1433
+ },
1434
+ {
1435
+ name: "distance",
1436
+ cli: "--distance",
1437
+ type: "string",
1438
+ required: false,
1439
+ description: "Swipe distance: short, medium, or long (default: medium)."
1440
+ }
1441
+ ]
1442
+ },
1443
+ {
1444
+ // tools.py scroll_until_visible: { text (required), element_type?,
1445
+ // direction?, max_scrolls? }. tool_dispatch.py's branch reads exactly
1446
+ // args["text"], args["element_type"], args["max_scrolls"] (default 8) and
1447
+ // args["direction"] — direction is scrollbar-semantic here and inverted to
1448
+ // finger semantics inside the dispatcher, so pass up/down/left/right as the
1449
+ // content you want revealed, same convention as `swipe`. Omitting direction
1450
+ // is meaningful: the runner auto-detects it.
1451
+ verb: "scroll-to",
1452
+ tool: "scroll_until_visible",
1453
+ summary: "Scroll until an element matching the given text becomes visible.",
1454
+ mutating: true,
1455
+ params: [
1456
+ {
1457
+ name: "text",
1458
+ cli: "positional",
1459
+ type: "string",
1460
+ required: true,
1461
+ description: "Case-insensitive substring matched against an element's label, value, or accessibility id."
1462
+ },
1463
+ {
1464
+ name: "direction",
1465
+ cli: "--direction",
1466
+ type: "string",
1467
+ required: false,
1468
+ description: "Scroll direction in scrollbar semantics (up, down, left, right). Omit to auto-detect; required for horizontal searches."
1469
+ },
1470
+ {
1471
+ name: "max_scrolls",
1472
+ cli: "--max-scrolls",
1473
+ type: "number",
1474
+ required: false,
1475
+ description: "Max swipe attempts before giving up (default: 8)."
1476
+ },
1477
+ {
1478
+ name: "element_type",
1479
+ cli: "--element-type",
1480
+ type: "string",
1481
+ required: false,
1482
+ description: "iOS-only element type filter (Button, StaticText, TextField, SecureTextField, Cell, Image, Link, Switch, TextView, SearchField). Ignored on Android."
1483
+ }
1484
+ ]
1485
+ },
1486
+ {
1487
+ // tools.py drag: { from_ref?, to_ref?, x?, y?, x2?, y2?, velocity?,
1488
+ // press_duration?, hold_duration? } (nothing required in the schema; the
1489
+ // dispatcher enforces "(from_ref or x/y) and (to_ref or x2/y2)"). Note
1490
+ // tool_dispatch.py reads args["press_duration"] and passes it to the bridge
1491
+ // as `duration_ms` — the WIRE name is press_duration, not duration_ms.
1492
+ //
1493
+ // Like `tap`, the CLI fans out: each positional accepts either an `@ref`
1494
+ // (-> from_ref/to_ref) or `x,y` (-> x/y and x2/y2). The two ends are
1495
+ // independent — `drag @e3 200,400` is valid. See buildToolCall.
1496
+ verb: "drag",
1497
+ tool: "drag",
1498
+ summary: "Drag between two points, each an element ref (@e5) or raw coordinates (100,200).",
1499
+ mutating: true,
1500
+ params: [
1501
+ {
1502
+ name: "from",
1503
+ cli: "positional",
1504
+ type: "string",
1505
+ required: true,
1506
+ description: 'Drag start: element ref (e.g. @e5 or e5) or "x,y" coordinates.'
1507
+ },
1508
+ {
1509
+ name: "to",
1510
+ cli: "positional",
1511
+ type: "string",
1512
+ required: true,
1513
+ description: 'Drag end: element ref (e.g. @e7 or e7) or "x,y" coordinates.'
1514
+ },
1515
+ {
1516
+ name: "velocity",
1517
+ cli: "--velocity",
1518
+ type: "number",
1519
+ required: false,
1520
+ description: "Drag speed in points/sec (default: 500). 100-250 for slow/precise, 1000+ for fast."
1521
+ },
1522
+ {
1523
+ name: "press_duration",
1524
+ cli: "--press-ms",
1525
+ type: "number",
1526
+ required: false,
1527
+ description: "Milliseconds to press before the drag starts (default: 60). Use 500+ for long-press reorder."
1528
+ },
1529
+ {
1530
+ name: "hold_duration",
1531
+ cli: "--hold-ms",
1532
+ type: "number",
1533
+ required: false,
1534
+ description: "Milliseconds to hold at the destination after the drag (default: 0). Use 500+ for drop confirmation."
1535
+ }
1536
+ ]
1537
+ },
1538
+ {
1539
+ // tools.py alert: { action (required, enum get|accept|dismiss), button? }.
1540
+ // tool_dispatch.py reads args.get("action", "get") and args.get("button").
1541
+ // iOS system alerts only.
1542
+ verb: "alert",
1543
+ tool: "alert",
1544
+ summary: "Inspect or respond to a visible iOS system alert (get, accept, or dismiss).",
1545
+ mutating: true,
1546
+ params: [
1547
+ {
1548
+ name: "action",
1549
+ cli: "positional",
1550
+ type: "string",
1551
+ required: true,
1552
+ description: "'get' returns the alert message and button labels; 'accept' taps a button (first by default); 'dismiss' taps a button (last by default)."
1553
+ },
1554
+ {
1555
+ name: "button",
1556
+ cli: "--button",
1557
+ type: "string",
1558
+ required: false,
1559
+ description: "Specific button label to tap (case-insensitive). Omit to use the default for the action."
1560
+ }
1561
+ ]
1562
+ },
1563
+ {
1564
+ // tools.py gesture_preset: { name: "scroll_up"|"scroll_down"|"scroll_left"|"scroll_right"|"back_swipe"|"pull_to_refresh" }.
1565
+ // The CLI only exposes the four scroll_* presets under this verb; the
1566
+ // positional direction is prefixed with "scroll_" to build the wire arg.
1567
+ verb: "scroll",
1568
+ tool: "gesture_preset",
1569
+ summary: "Scroll the current view up, down, left, or right using a gesture preset.",
1570
+ mutating: true,
1571
+ params: [
1572
+ {
1573
+ name: "name",
1574
+ cli: "positional",
1575
+ type: "string",
1576
+ required: true,
1577
+ description: 'Scroll direction: up, down, left, or right (sent as gesture_preset "scroll_<direction>").'
1578
+ }
1579
+ ]
1580
+ },
1581
+ {
1582
+ // tools.py type_text: { text: string }.
1583
+ verb: "type",
1584
+ tool: "type_text",
1585
+ summary: "Type text into the currently focused input field.",
1586
+ mutating: true,
1587
+ params: [
1588
+ {
1589
+ name: "text",
1590
+ cli: "positional",
1591
+ type: "string",
1592
+ required: true,
1593
+ description: "Text to type into the focused field."
1594
+ }
1595
+ ]
1596
+ },
1597
+ {
1598
+ // tools.py clear_text: {} (no params).
1599
+ verb: "clear-text",
1600
+ tool: "clear_text",
1601
+ summary: "Clear the text in the currently focused input field.",
1602
+ mutating: true,
1603
+ params: []
1604
+ },
1605
+ {
1606
+ // tools.py press_button: { button: enum }. The enum is the union of the
1607
+ // iOS/Android/TV button sets across tools.py and the session-specific
1608
+ // press_button_tool() builder.
1609
+ verb: "press",
1610
+ tool: "press_button",
1611
+ summary: "Press a device button or perform a system gesture (home, back, d-pad, etc).",
1612
+ mutating: true,
1613
+ params: [
1614
+ {
1615
+ name: "button",
1616
+ cli: "positional",
1617
+ type: "string",
1618
+ required: true,
1619
+ description: "Button to press: home, back, lock, siri, dpad_up, dpad_down, dpad_left, dpad_right, or dpad_center."
1620
+ }
1621
+ ]
1622
+ },
1623
+ {
1624
+ // tools.py open_url: { url: string }.
1625
+ verb: "open-url",
1626
+ tool: "open_url",
1627
+ summary: "Open a deep link or URL in the app under test.",
1628
+ mutating: true,
1629
+ params: [
1630
+ {
1631
+ name: "url",
1632
+ cli: "positional",
1633
+ type: "string",
1634
+ required: true,
1635
+ description: "Full URI including scheme, e.g. 'myapp://settings' (max 2048 chars)."
1636
+ }
1637
+ ]
1638
+ },
1639
+ {
1640
+ // tools.py wait: { seconds?: number } (default: 2). Note the wire arg is
1641
+ // "seconds", NOT "ms" — tool_dispatch.py reads args.get("seconds", 2)
1642
+ // and passes it straight to asyncio.sleep().
1643
+ verb: "wait",
1644
+ tool: "wait",
1645
+ summary: "Wait for a specified number of seconds before continuing.",
1646
+ mutating: false,
1647
+ params: [
1648
+ {
1649
+ name: "seconds",
1650
+ cli: "--ms",
1651
+ type: "number",
1652
+ required: false,
1653
+ description: "How long to wait, in milliseconds on the CLI (--ms); the MCP tool takes seconds. Default: 2 seconds."
1654
+ }
1655
+ ]
1656
+ },
1657
+ {
1658
+ // tools.py find_element: { text: string, below?: string, above?: string, near?: string }.
1659
+ verb: "find",
1660
+ tool: "find_element",
1661
+ summary: "Find an element by text with optional spatial constraints (below/above/near).",
1662
+ mutating: false,
1663
+ params: [
1664
+ {
1665
+ name: "text",
1666
+ cli: "positional",
1667
+ type: "string",
1668
+ required: true,
1669
+ description: "Text of the target element."
1670
+ },
1671
+ {
1672
+ name: "below",
1673
+ cli: "--below",
1674
+ type: "string",
1675
+ required: false,
1676
+ description: "Target must be below this element (optional)."
1677
+ },
1678
+ {
1679
+ name: "above",
1680
+ cli: "--above",
1681
+ type: "string",
1682
+ required: false,
1683
+ description: "Target must be above this element (optional)."
1684
+ },
1685
+ {
1686
+ name: "near",
1687
+ cli: "--near",
1688
+ type: "string",
1689
+ required: false,
1690
+ description: "Target must be near this element (optional)."
1691
+ }
1692
+ ]
1693
+ },
1694
+ {
1695
+ // tools.py tap_focused_tv: { ref: string }. TV-only. Activates the
1696
+ // element (presses SELECT), so this is mutating like `tap`.
1697
+ verb: "tap-focused",
1698
+ tool: "tap_focused_tv",
1699
+ summary: "TV: move focus to an element and press SELECT in one step.",
1700
+ mutating: true,
1701
+ params: [
1702
+ {
1703
+ name: "ref",
1704
+ cli: "positional",
1705
+ type: "string",
1706
+ required: true,
1707
+ description: "Ref from the UI tree (e.g. @e5)."
1708
+ }
1709
+ ]
1710
+ },
1711
+ {
1712
+ // tools.py focus_element_tv: { ref: string }. TV-only.
1713
+ verb: "focus",
1714
+ tool: "focus_element_tv",
1715
+ summary: "TV: move focus to an element without activating it.",
1716
+ mutating: false,
1717
+ params: [
1718
+ {
1719
+ name: "ref",
1720
+ cli: "positional",
1721
+ type: "string",
1722
+ required: true,
1723
+ description: "Ref from the UI tree (e.g. @e5)."
1724
+ }
1725
+ ]
1726
+ }
1727
+ ];
1728
+
1729
+ // src/agent/render.ts
1730
+ import chalk3 from "chalk";
1731
+ function renderEnvelope(env) {
1732
+ const lines = [];
1733
+ if (env.ok) {
1734
+ lines.push(chalk3.green(`ok`) + chalk3.dim(` (${env.tool})`));
1735
+ } else {
1736
+ lines.push(chalk3.red(`error`) + chalk3.dim(` (${env.tool})`));
1737
+ }
1738
+ lines.push(...resultLines(env.result));
1739
+ if (env.observation) {
1740
+ if (env.observation.kind === "unavailable") {
1741
+ lines.push(chalk3.yellow(`warning: ${env.observation.text}`));
1742
+ } else {
1743
+ lines.push(env.observation.text);
1744
+ }
1745
+ }
1746
+ return lines.join("\n");
1747
+ }
1748
+ function resultLines(result) {
1749
+ if (!result || typeof result !== "object") return [];
1750
+ const r = result;
1751
+ const lines = [];
1752
+ if (r.found === true && typeof r.ref === "string" && r.ref) {
1753
+ let found = `found: @${r.ref}`;
1754
+ if (typeof r.text === "string" && r.text) found += ` ${JSON.stringify(r.text)}`;
1755
+ lines.push(found);
1756
+ if (Array.isArray(r.alternatives) && r.alternatives.length > 0) {
1757
+ const alts = r.alternatives.filter((a) => !!a && typeof a === "object").map((a) => {
1758
+ const ref = typeof a.ref === "string" && a.ref ? `@${a.ref}` : "?";
1759
+ const text = typeof a.text === "string" && a.text ? ` ${JSON.stringify(a.text)}` : "";
1760
+ return `${ref}${text}`;
1761
+ });
1762
+ if (alts.length > 0) lines.push(chalk3.dim(`alternatives: ${alts.join(", ")}`));
1763
+ }
1764
+ }
1765
+ if (typeof r.note === "string" && r.note) lines.push(chalk3.dim(r.note));
1766
+ if (typeof r.message === "string" && r.message) lines.push(r.message);
1767
+ if (typeof r.warning === "string" && r.warning) lines.push(r.warning);
1768
+ return lines;
1769
+ }
1770
+ function agentExitCode(env) {
1771
+ return env.ok ? 0 : 1;
1772
+ }
1773
+
1774
+ // src/commands/agent.ts
1775
+ import { writeFileSync as writeFileSync4 } from "fs";
1776
+ var REF_RE = /^@?(e\d+)$/;
1777
+ var COORDS_RE = /^(-?\d+),\s*(-?\d+)$/;
1778
+ var FlagError = class extends Error {
1779
+ };
1780
+ function parseNumberFlag(raw, flagName) {
1781
+ const n = Number(raw);
1782
+ if (!Number.isFinite(n)) {
1783
+ throw new FlagError(`--${flagName} must be a number (got "${String(raw)}")`);
1784
+ }
1785
+ return n;
1786
+ }
1787
+ function stripRef(raw) {
1788
+ return raw.startsWith("@") ? raw.slice(1) : raw;
1789
+ }
1790
+ function parseCoords(raw) {
1791
+ const m = COORDS_RE.exec(raw);
1792
+ if (!m) return null;
1793
+ return { x: parseInt(m[1], 10), y: parseInt(m[2], 10) };
1794
+ }
1795
+ function flagKey(cli) {
1796
+ const name = cli.replace(/^--/, "");
1797
+ return name.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
1798
+ }
1799
+ function buildToolCall(cmd, positionals, opts) {
1800
+ if (cmd.verb === "tap") {
1801
+ const target = positionals[0];
1802
+ if (!target) throw new Error('tap requires a target: an element ref (@e5) or "x,y"');
1803
+ const coords = parseCoords(target);
1804
+ if (coords) {
1805
+ return { tool: "tap_coordinates", args: { x: coords.x, y: coords.y } };
1806
+ }
1807
+ if (target.includes(",")) {
1808
+ throw new Error(`tap: "${target}" looks like coordinates but is not two integers "x,y"`);
1809
+ }
1810
+ if (!REF_RE.test(target)) {
1811
+ throw new Error(`tap: "${target}" is not a valid ref (@e5) or "x,y" coordinate pair`);
1812
+ }
1813
+ return { tool: "tap", args: { ref: stripRef(target) } };
1814
+ }
1815
+ if (cmd.verb === "long-press") {
1816
+ const target = positionals[0];
1817
+ if (!target) throw new Error("long-press requires a ref, e.g. @e5");
1818
+ if (parseCoords(target) || target.includes(",") && !REF_RE.test(target)) {
1819
+ throw new Error(
1820
+ `long-press only accepts an element ref (e.g. @e5) \u2014 "${target}" looks like coordinates, and there is no coordinate-based long-press tool`
1821
+ );
1822
+ }
1823
+ const args2 = { ref: stripRef(target) };
1824
+ if (opts.ms !== void 0) {
1825
+ args2.duration_ms = parseNumberFlag(opts.ms, "ms");
1826
+ }
1827
+ return { tool: cmd.tool, args: args2 };
1828
+ }
1829
+ if (cmd.verb === "drag") {
1830
+ const from = positionals[0];
1831
+ const to = positionals[1];
1832
+ if (!from || !to) {
1833
+ throw new Error('drag requires two targets: <from> <to>, each an element ref (@e5) or "x,y"');
1834
+ }
1835
+ const args2 = {};
1836
+ const endpoints = [
1837
+ { raw: from, refArg: "from_ref", xArg: "x", yArg: "y" },
1838
+ { raw: to, refArg: "to_ref", xArg: "x2", yArg: "y2" }
1839
+ ];
1840
+ for (const { raw, refArg, xArg, yArg } of endpoints) {
1841
+ const coords = parseCoords(raw);
1842
+ if (coords) {
1843
+ args2[xArg] = coords.x;
1844
+ args2[yArg] = coords.y;
1845
+ continue;
1846
+ }
1847
+ if (raw.includes(",")) {
1848
+ throw new Error(`drag: "${raw}" looks like coordinates but is not two integers "x,y"`);
1849
+ }
1850
+ if (!REF_RE.test(raw)) {
1851
+ throw new Error(`drag: "${raw}" is not a valid ref (@e5) or "x,y" coordinate pair`);
1852
+ }
1853
+ args2[refArg] = stripRef(raw);
1854
+ }
1855
+ for (const param of cmd.params) {
1856
+ if (param.cli === "positional") continue;
1857
+ const raw = opts[flagKey(param.cli)];
1858
+ if (raw === void 0 || raw === "") continue;
1859
+ args2[param.name] = parseNumberFlag(raw, param.cli.replace(/^--/, ""));
1860
+ }
1861
+ return { tool: cmd.tool, args: args2 };
1862
+ }
1863
+ if (cmd.verb === "wait") {
1864
+ const args2 = {};
1865
+ if (opts.ms !== void 0) {
1866
+ const ms = parseNumberFlag(opts.ms, "ms");
1867
+ args2.seconds = ms / 1e3;
1868
+ }
1869
+ return { tool: cmd.tool, args: args2 };
1870
+ }
1871
+ if (cmd.verb === "scroll") {
1872
+ const direction = positionals[0];
1873
+ const valid = ["up", "down", "left", "right"];
1874
+ if (!direction || !valid.includes(direction)) {
1875
+ throw new Error(`scroll: direction must be one of ${valid.join(", ")}, got "${direction}"`);
1876
+ }
1877
+ return { tool: cmd.tool, args: { name: `scroll_${direction}` } };
1878
+ }
1879
+ if (cmd.verb === "swipe") {
1880
+ const direction = positionals[0];
1881
+ const valid = ["up", "down", "left", "right"];
1882
+ if (!direction || !valid.includes(direction)) {
1883
+ throw new Error(`swipe: direction must be one of ${valid.join(", ")}, got "${direction}"`);
1884
+ }
1885
+ const args2 = { direction };
1886
+ if (opts.distance !== void 0) {
1887
+ args2.distance = opts.distance;
1888
+ }
1889
+ return { tool: cmd.tool, args: args2 };
1890
+ }
1891
+ const args = {};
1892
+ let positionalIndex = 0;
1893
+ for (const param of cmd.params) {
1894
+ let raw;
1895
+ if (param.cli === "positional") {
1896
+ raw = positionals[positionalIndex];
1897
+ positionalIndex += 1;
1898
+ } else {
1899
+ const key = flagKey(param.cli);
1900
+ raw = opts[key];
1901
+ }
1902
+ if (param.cliOnly) continue;
1903
+ if (raw === void 0 || raw === "") {
1904
+ if (param.required) {
1905
+ throw new Error(
1906
+ `${cmd.verb} requires ${param.cli === "positional" ? param.name : param.cli}`
1907
+ );
1908
+ }
1909
+ continue;
1910
+ }
1911
+ if (param.name === "ref" && typeof raw === "string") {
1912
+ args[param.name] = stripRef(raw);
1913
+ } else if (param.type === "number") {
1914
+ const flagName = param.cli === "positional" ? param.name : param.cli.replace(/^--/, "");
1915
+ args[param.name] = parseNumberFlag(raw, flagName);
1916
+ } else {
1917
+ args[param.name] = raw;
1918
+ }
1919
+ }
1920
+ return { tool: cmd.tool, args };
1921
+ }
1922
+ function defaultScreenshotPath() {
1923
+ const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
1924
+ return `./vibeview-screenshot-${ts}.png`;
1925
+ }
1926
+ function decodeScreenshotResult(result) {
1927
+ if (typeof result !== "string" || result.length === 0) {
1928
+ throw new Error("screenshot: backend returned no image data");
1929
+ }
1930
+ const b64 = result.startsWith("data:") ? result.split(",", 2)[1] : result;
1931
+ if (!b64) {
1932
+ throw new Error("screenshot: malformed data URL (no base64 payload after comma)");
1933
+ }
1934
+ return Buffer.from(b64, "base64");
1935
+ }
1936
+ async function runAgentVerb(cmd, positionals, opts) {
1937
+ const { token, baseUrl } = resolveAuth(opts);
1938
+ const sessionId = opts.session || readDevState(process.cwd())?.session_id;
1939
+ if (!sessionId) {
1940
+ process.stderr.write("error: no session \u2014 pass --session or run vibeview dev --detach\n");
1941
+ process.exit(1);
1942
+ }
1943
+ let call;
1944
+ try {
1945
+ call = buildToolCall(cmd, positionals, opts);
1946
+ } catch (err) {
1947
+ process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}
1948
+ `);
1949
+ process.exit(err instanceof FlagError ? 2 : 1);
1950
+ }
1951
+ const client = new ApiClient(baseUrl, token);
1952
+ let envelope;
1953
+ try {
1954
+ envelope = await client.agentTool(sessionId, call.tool, call.args);
1955
+ } catch (err) {
1956
+ const message = err instanceof Error ? err.message : String(err);
1957
+ if (opts.json) {
1958
+ const code = /^authentication failed/i.test(message) ? "auth_failed" : /^not found:/i.test(message) ? "not_found" : void 0;
1959
+ console.log(JSON.stringify({ event: "error", code, message }));
1960
+ } else {
1961
+ process.stderr.write(`error: ${message}
1962
+ `);
1963
+ }
1964
+ process.exit(1);
1965
+ }
1966
+ if (cmd.verb === "screenshot" && envelope.ok && envelope.result) {
1967
+ const outPath = opts.out || defaultScreenshotPath();
1968
+ let imageBytes;
1969
+ try {
1970
+ imageBytes = decodeScreenshotResult(envelope.result);
1971
+ } catch (err) {
1972
+ process.stderr.write(`error: ${err instanceof Error ? err.message : String(err)}
1973
+ `);
1974
+ process.exit(1);
1975
+ }
1976
+ writeFileSync4(outPath, imageBytes);
1977
+ if (opts.json) {
1978
+ console.log(JSON.stringify(envelope));
1979
+ } else {
1980
+ process.stderr.write(renderEnvelope(envelope) + "\n");
1981
+ console.log(outPath);
1982
+ }
1983
+ process.exit(agentExitCode(envelope));
1984
+ return;
1985
+ }
1986
+ if (opts.json) {
1987
+ console.log(JSON.stringify(envelope));
1988
+ } else {
1989
+ console.log(renderEnvelope(envelope));
1990
+ }
1991
+ process.exit(agentExitCode(envelope));
1992
+ }
1993
+
1994
+ export {
1995
+ readDevState,
1996
+ clearDevState,
1997
+ isDevPlatform,
1998
+ loadDevConfig,
1999
+ checkMetro,
2000
+ readMetroState,
2001
+ killOwnedMetro,
2002
+ buildCommand,
2003
+ startDevSession,
2004
+ PLATFORM_DEVICE,
2005
+ dev,
2006
+ AGENT_COMMANDS,
2007
+ renderEnvelope,
2008
+ buildToolCall,
2009
+ decodeScreenshotResult,
2010
+ runAgentVerb
2011
+ };