vibeview 0.10.0 → 0.11.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.
@@ -4,7 +4,7 @@ import {
4
4
  loadConfig,
5
5
  resolveAuth,
6
6
  uploadAppBuild
7
- } from "./chunk-YD2X6TQV.js";
7
+ } from "./chunk-AG3RZCPW.js";
8
8
 
9
9
  // src/lib/dev-state.ts
10
10
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
@@ -71,7 +71,14 @@ function clearDevState(cwd) {
71
71
  // src/lib/dev-config.ts
72
72
  import { readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
73
73
  import { join as join2 } from "path";
74
- var DEV_PLATFORMS = ["ios", "android", "tvos", "androidtv"];
74
+ var DEV_PLATFORMS = ["ios", "android", "tvos", "androidtv", "roku"];
75
+ var PLATFORM_NEEDS_METRO = {
76
+ ios: true,
77
+ android: true,
78
+ tvos: true,
79
+ androidtv: true,
80
+ roku: false
81
+ };
75
82
  var FILE = "vibeview.json";
76
83
  function isDevPlatform(p) {
77
84
  return typeof p === "string" && DEV_PLATFORMS.includes(p);
@@ -137,6 +144,18 @@ function loadDevConfig(cwd) {
137
144
  );
138
145
  }
139
146
  }
147
+ if (entry.watchIgnore !== void 0) {
148
+ if (!Array.isArray(entry.watchIgnore)) {
149
+ throw new Error(
150
+ `vibeview.json: platforms.${key}.watchIgnore must be an array of paths to ignore while watching`
151
+ );
152
+ }
153
+ if (entry.watchIgnore.some((p) => typeof p !== "string" || !p)) {
154
+ throw new Error(
155
+ `vibeview.json: platforms.${key}.watchIgnore entries must be non-empty strings`
156
+ );
157
+ }
158
+ }
140
159
  if (entry.metroCommand !== void 0 && (typeof entry.metroCommand !== "string" || !entry.metroCommand)) {
141
160
  throw new Error(`vibeview.json: platforms.${key}.metroCommand must be a non-empty string`);
142
161
  }
@@ -169,10 +188,10 @@ function buildSavedConfig(input2) {
169
188
  firstRunMetro,
170
189
  firstRunBuild
171
190
  } = input2;
172
- const topLevelMetroPort = metroPortFlag ? parseInt(metroPortFlag, 10) : existing?.metroPort ?? 8081;
191
+ const topLevelMetroPort = metroPortFlag ? parseInt(metroPortFlag, 10) : existing?.metroPort ?? (PLATFORM_NEEDS_METRO[platform] ? 8081 : void 0);
173
192
  return {
174
193
  defaultPlatform: platform,
175
- metroPort: topLevelMetroPort,
194
+ ...topLevelMetroPort !== void 0 ? { metroPort: topLevelMetroPort } : {},
176
195
  ...isFirstRun && firstRunMetro ? { metroCommand: firstRunMetro } : existing?.metroCommand ? { metroCommand: existing.metroCommand } : {},
177
196
  platforms: {
178
197
  ...existing?.platforms,
@@ -181,6 +200,9 @@ function buildSavedConfig(input2) {
181
200
  deviceCategory: isTv ? void 0 : deviceCategory,
182
201
  ...existing?.platforms?.[platform]?.build ? { build: existing.platforms[platform].build } : {},
183
202
  ...isFirstRun && firstRunBuild ? { build: firstRunBuild } : {},
203
+ // Never invented — absent means "no extra ignores", and first-run
204
+ // setup does not ask for it. Only ever carried forward.
205
+ ...existing?.platforms?.[platform]?.watchIgnore ? { watchIgnore: existing.platforms[platform].watchIgnore } : {},
184
206
  ...existing?.platforms?.[platform]?.metroCommand ? { metroCommand: existing.platforms[platform].metroCommand } : {},
185
207
  ...existing?.platforms?.[platform]?.metroPort !== void 0 ? { metroPort: existing.platforms[platform].metroPort } : {}
186
208
  }
@@ -268,8 +290,22 @@ function prepareUploadArtifact(path) {
268
290
  import { input, select } from "@inquirer/prompts";
269
291
 
270
292
  // src/lib/project-detect.ts
271
- import { existsSync as existsSync3, readFileSync as readFileSync3, readdirSync as readdirSync2 } from "fs";
293
+ import { existsSync as existsSync3, readFileSync as readFileSync3, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
272
294
  import { join as join4 } from "path";
295
+ function readJson(cwd, name) {
296
+ try {
297
+ return JSON.parse(readFileSync3(join4(cwd, name), "utf-8"));
298
+ } catch {
299
+ return null;
300
+ }
301
+ }
302
+ function isFile(path) {
303
+ try {
304
+ return statSync2(path).isFile();
305
+ } catch {
306
+ return false;
307
+ }
308
+ }
273
309
  function detectFlavor(cwd) {
274
310
  let deps = {};
275
311
  try {
@@ -279,6 +315,7 @@ function detectFlavor(cwd) {
279
315
  }
280
316
  if ("expo" in deps) return "expo";
281
317
  if (existsSync3(join4(cwd, "android", "gradlew")) || iosWorkspaceName(cwd)) return "bare-rn";
318
+ if (isFile(join4(cwd, "manifest"))) return "roku";
282
319
  return "unknown";
283
320
  }
284
321
  function iosWorkspaceName(cwd) {
@@ -291,9 +328,32 @@ function iosWorkspaceName(cwd) {
291
328
  }
292
329
  var IOS_ARTIFACT = "ios/build/Build/Products/Debug-iphonesimulator/*.app";
293
330
  var ANDROID_ARTIFACT = "android/app/build/outputs/apk/debug/*.apk";
331
+ var ROKU_DEFAULT_ARTIFACT = "out/channel.zip";
332
+ var ROKU_ZIP_COMMAND = "zip -r out/channel.zip manifest source components images -x '*.DS_Store'";
333
+ function rokuDefaults(cwd) {
334
+ const pkg = readJson(cwd, "package.json");
335
+ const scripts = pkg?.scripts ?? {};
336
+ const runner = existsSync3(join4(cwd, "yarn.lock")) ? "yarn" : "npm run";
337
+ const packageScript = Object.entries(scripts).find(
338
+ ([name, body]) => !name.startsWith("deploy") && (name.startsWith("package") || body.includes("--package-only") || body.includes("zipPackage"))
339
+ );
340
+ const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
341
+ let artifact = ROKU_DEFAULT_ARTIFACT;
342
+ if ("roku-deploy" in deps) {
343
+ const rd = readJson(cwd, "rokudeploy.json");
344
+ const outDir = rd?.outDir ?? "out";
345
+ const outFile = rd?.outFile ?? "roku-deploy.zip";
346
+ artifact = `${outDir}/${outFile}`;
347
+ }
348
+ if (packageScript) {
349
+ return { buildCommand: `${runner} ${packageScript[0]}`, artifact };
350
+ }
351
+ return { buildCommand: ROKU_ZIP_COMMAND, artifact };
352
+ }
294
353
  function defaultsFor(cwd, platform) {
295
354
  const flavor = detectFlavor(cwd);
296
- if (flavor === "unknown") return {};
355
+ if (platform === "roku") return flavor === "roku" ? rokuDefaults(cwd) : {};
356
+ if (flavor === "unknown" || flavor === "roku") return {};
297
357
  const metroCommand = flavor === "expo" ? "npx expo start" : "yarn start";
298
358
  if (platform === "tvos" || platform === "androidtv") return { metroCommand };
299
359
  if (platform === "android") {
@@ -319,6 +379,16 @@ function defaultsFor(cwd, platform) {
319
379
  }
320
380
 
321
381
  // src/lib/dev-setup.ts
382
+ function platformChoices(cwd) {
383
+ const choices = [
384
+ { value: "ios", name: "iOS simulator" },
385
+ { value: "android", name: "Android emulator" },
386
+ { value: "tvos", name: "Apple TV simulator" },
387
+ { value: "androidtv", name: "Android TV emulator" }
388
+ ];
389
+ if (detectFlavor(cwd) === "roku") choices.unshift({ value: "roku", name: "Roku (beta)" });
390
+ return choices;
391
+ }
322
392
  function firstRunAppStrategy(apps, buildConfigured) {
323
393
  if (!buildConfigured) return "picker";
324
394
  return apps.length === 0 ? "build_now" : "ask";
@@ -352,7 +422,7 @@ async function promptBuildConfig(cwd, platform) {
352
422
  }
353
423
  const command = await input({ message: `Build command for ${platform}`, required: true });
354
424
  const artifact = await input({
355
- message: "Path to the built app it produces (.apk, or .app dir; * allowed)",
425
+ message: platform === "roku" ? "Path to the channel zip it produces (* allowed)" : "Path to the built app it produces (.apk, or .app dir; * allowed)",
356
426
  default: defaults.artifact,
357
427
  required: true
358
428
  });
@@ -425,12 +495,7 @@ async function buildCommand(opts) {
425
495
  }
426
496
  platform = await select2({
427
497
  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
- ]
498
+ choices: platformChoices(cwd)
434
499
  });
435
500
  }
436
501
  let build = cfg?.platforms?.[platform]?.build;
@@ -496,7 +561,7 @@ function fail(opts, message, code) {
496
561
  // src/commands/dev.ts
497
562
  import { spawn as spawn3 } from "child_process";
498
563
  import { createWriteStream, mkdirSync as mkdirSync3 } from "fs";
499
- import { dirname as dirname4 } from "path";
564
+ import { dirname as dirname5 } from "path";
500
565
  import { createInterface as createInterface3 } from "readline";
501
566
  import { select as select3 } from "@inquirer/prompts";
502
567
  import chalk2 from "chalk";
@@ -815,19 +880,163 @@ async function waitFor(cond, timeoutMs, everyMs) {
815
880
  }
816
881
  }
817
882
 
883
+ // src/lib/reload-loop.ts
884
+ import { watch } from "fs";
885
+ import { basename as basename2, dirname as dirname4 } from "path";
886
+ function createReloadLoop(deps) {
887
+ let inFlight = null;
888
+ let pendingFollowUp = false;
889
+ let debounceTimer = null;
890
+ let stopWatch = null;
891
+ let stopped = false;
892
+ async function run() {
893
+ inFlight = (async () => {
894
+ try {
895
+ await deps.push();
896
+ } catch (err) {
897
+ (deps.logError ?? deps.log)(err.message);
898
+ }
899
+ })();
900
+ try {
901
+ await inFlight;
902
+ } finally {
903
+ inFlight = null;
904
+ }
905
+ if (pendingFollowUp && !stopped) {
906
+ pendingFollowUp = false;
907
+ await run();
908
+ }
909
+ }
910
+ async function trigger() {
911
+ if (stopped) return;
912
+ if (inFlight) {
913
+ deps.log("already reloading \u2014 ignoring");
914
+ return;
915
+ }
916
+ if (debounceTimer) {
917
+ clearTimeout(debounceTimer);
918
+ debounceTimer = null;
919
+ }
920
+ await run();
921
+ }
922
+ function onChange() {
923
+ if (stopped) return;
924
+ if (inFlight) {
925
+ pendingFollowUp = true;
926
+ return;
927
+ }
928
+ if (debounceTimer) clearTimeout(debounceTimer);
929
+ debounceTimer = setTimeout(
930
+ () => {
931
+ debounceTimer = null;
932
+ void trigger();
933
+ },
934
+ (deps.watchSeconds ?? 2) * 1e3
935
+ );
936
+ }
937
+ if (deps.watch && deps.watchSeconds !== void 0) {
938
+ stopWatch = deps.watch(onChange);
939
+ }
940
+ return {
941
+ trigger,
942
+ stop() {
943
+ stopped = true;
944
+ if (debounceTimer) clearTimeout(debounceTimer);
945
+ stopWatch?.();
946
+ }
947
+ };
948
+ }
949
+ var IGNORED_SEGMENTS = ["node_modules", ".git", ".vibeview"];
950
+ var IGNORED_FILES = ["vibeview.json"];
951
+ function isUnderPath(rel, entry) {
952
+ const norm = normalizeRel(entry).replace(/\/+$/, "");
953
+ if (!norm) return false;
954
+ return rel === norm || rel.startsWith(`${norm}/`);
955
+ }
956
+ function normalizeRel(p) {
957
+ return p.replace(/\\/g, "/").replace(/^\.\//, "");
958
+ }
959
+ function isIgnoredWatchPath(relPath, artifact, userIgnore = []) {
960
+ const rel = normalizeRel(relPath);
961
+ const segments = rel.split("/");
962
+ if (segments.some((s) => IGNORED_SEGMENTS.includes(s) || s.includes("staging"))) return true;
963
+ if (IGNORED_FILES.includes(rel)) return true;
964
+ if (userIgnore.some((entry) => isUnderPath(rel, entry))) return true;
965
+ const artifactPattern = artifact.replace(/\\/g, "/").replace(/^\.\//, "");
966
+ const artifactDir = dirname4(artifactPattern);
967
+ if (artifactDir !== "." && (rel === artifactDir || rel.startsWith(`${artifactDir}/`))) {
968
+ return true;
969
+ }
970
+ if (dirname4(rel) !== artifactDir) return false;
971
+ const name = basename2(artifactPattern);
972
+ const re = new RegExp(`^${name.split("*").map(escapeRegExp2).join(".*")}$`);
973
+ return re.test(basename2(rel));
974
+ }
975
+ function escapeRegExp2(s) {
976
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
977
+ }
978
+ function watchProjectFiles(cwd, artifact, userIgnore = []) {
979
+ return (onChange) => {
980
+ const watcher = watch(cwd, { recursive: true }, (_event, filename) => {
981
+ if (filename !== null && isIgnoredWatchPath(filename.toString(), artifact, userIgnore)) {
982
+ return;
983
+ }
984
+ onChange();
985
+ });
986
+ return () => watcher.close();
987
+ };
988
+ }
989
+
990
+ // src/lib/roku-push.ts
991
+ function createRokuPush(opts) {
992
+ const runBuild = opts.runBuild ?? runBuildForPlatform;
993
+ return async () => {
994
+ const started = Date.now();
995
+ let result;
996
+ try {
997
+ result = await runBuild(
998
+ opts.cwd,
999
+ "roku",
1000
+ opts.build,
1001
+ { token: opts.token, baseUrl: opts.baseUrl },
1002
+ opts.log
1003
+ );
1004
+ } catch (err) {
1005
+ const message = err.message;
1006
+ if (/artifact not found/i.test(message)) {
1007
+ throw new Error(
1008
+ `${message}
1009
+ your build command must produce a zip \u2014 use the package/zip step, not deploy`
1010
+ );
1011
+ }
1012
+ throw err;
1013
+ }
1014
+ if (!result.build_id) {
1015
+ throw new Error(`upload returned no build id for ${result.name ?? result.public_id}`);
1016
+ }
1017
+ await opts.api.installBuild(opts.sessionId, result.build_id, true);
1018
+ opts.log(`reloaded in ${((Date.now() - started) / 1e3).toFixed(1)}s`);
1019
+ };
1020
+ }
1021
+
818
1022
  // src/commands/dev.ts
1023
+ function parseWatchSeconds(flag) {
1024
+ if (flag === void 0 || flag === false) return void 0;
1025
+ if (flag === true) return 2;
1026
+ const seconds = Number(flag);
1027
+ if (!Number.isFinite(seconds) || seconds <= 0) {
1028
+ throw new Error(`--watch expects a positive number of seconds, got "${flag}"`);
1029
+ }
1030
+ return seconds;
1031
+ }
819
1032
  function emit(json, evt) {
820
1033
  if (json) console.log(JSON.stringify(evt));
821
1034
  }
822
1035
  async function startDevSession(cfg, onEvent = () => {
823
1036
  }, onTunnelState) {
824
1037
  const api = new ApiClient(cfg.baseUrl, cfg.token);
825
- const created = await api.createSession({
826
- device_type: PLATFORM_DEVICE[cfg.platform].deviceType,
827
- device_category: cfg.deviceCategory,
828
- app_id: cfg.appId,
829
- tunnel: true
830
- });
1038
+ const usesTunnel = PLATFORM_NEEDS_METRO[cfg.platform];
1039
+ const created = await api.createSession(devSessionCreateBody(cfg));
831
1040
  const sessionId = created.session_id;
832
1041
  let stopping = false;
833
1042
  let tunnel = null;
@@ -841,52 +1050,66 @@ async function startDevSession(cfg, onEvent = () => {
841
1050
  };
842
1051
  try {
843
1052
  await api.startSession(sessionId);
844
- const ready = await pollUntilTunnelReady(api, sessionId, 18e4);
845
- for (const w of ready.warnings ?? []) {
846
- onEvent({ event: "warning", message: w });
847
- }
848
1053
  const pageUrl = `${cfg.webUrl || cfg.baseUrl}/sandbox/${sessionId}`;
849
- tunnel = new TunnelClient({
850
- url: ready.tunnel_url,
851
- metroPort: cfg.metroPort,
852
- // Re-mint the URL on each reconnect: the tunnel token has a ~1h TTL and
853
- // a worker restart drops the proxy registration, so the original URL
854
- // eventually goes stale. Every authorized GET /sessions/{id} re-signs a
855
- // fresh tunnel token. Null (session stopped/failed) ends the tunnel.
856
- refreshUrl: async () => {
857
- const info = await api.getSession(sessionId);
858
- if (info.status === "stopped" || info.status === "failed") return null;
859
- return info.tunnel_url ?? null;
860
- },
861
- onDebug: cfg.debug ? (msg) => onEvent({ event: "warning", message: `[tunnel] ${msg}` }) : void 0,
862
- onState: (s) => {
863
- onTunnelState?.(s);
864
- if (s === "connected") {
865
- onEvent({ event: "tunnel_connected", session_id: sessionId });
866
- } else if (s === "metro-unreachable") {
867
- onEvent({
868
- event: "warning",
869
- message: `can't reach Metro on localhost:${cfg.metroPort} \u2014 is it running? (yarn start)`
870
- });
871
- } else if (s === "closed" && !stopping) {
872
- void stopSession(1);
873
- }
1054
+ const reportedWarnings = /* @__PURE__ */ new Set();
1055
+ if (usesTunnel) {
1056
+ const ready = await pollUntilTunnelReady(api, sessionId, 18e4);
1057
+ for (const w of ready.warnings ?? []) {
1058
+ reportedWarnings.add(w);
1059
+ onEvent({ event: "warning", message: w });
874
1060
  }
875
- });
876
- tunnel.start();
1061
+ tunnel = new TunnelClient({
1062
+ url: ready.tunnel_url,
1063
+ metroPort: cfg.metroPort,
1064
+ // Re-mint the URL on each reconnect: the tunnel token has a ~1h TTL and
1065
+ // a worker restart drops the proxy registration, so the original URL
1066
+ // eventually goes stale. Every authorized GET /sessions/{id} re-signs a
1067
+ // fresh tunnel token. Null (session stopped/failed) ends the tunnel.
1068
+ refreshUrl: async () => {
1069
+ const info = await api.getSession(sessionId);
1070
+ if (info.status === "stopped" || info.status === "failed") return null;
1071
+ return info.tunnel_url ?? null;
1072
+ },
1073
+ onDebug: cfg.debug ? (msg) => onEvent({ event: "warning", message: `[tunnel] ${msg}` }) : void 0,
1074
+ onState: (s) => {
1075
+ onTunnelState?.(s);
1076
+ if (s === "connected") {
1077
+ onEvent({ event: "tunnel_connected", session_id: sessionId });
1078
+ } else if (s === "metro-unreachable") {
1079
+ onEvent({
1080
+ event: "warning",
1081
+ message: `can't reach Metro on localhost:${cfg.metroPort} \u2014 is it running? (yarn start)`
1082
+ });
1083
+ } else if (s === "closed" && !stopping) {
1084
+ void stopSession(1);
1085
+ }
1086
+ }
1087
+ });
1088
+ tunnel.start();
1089
+ }
877
1090
  if (cfg.open !== false) await openBrowser(pageUrl).catch(() => {
878
1091
  });
879
1092
  const active = await pollUntilActive(api, sessionId, 18e4);
880
- const seen = new Set(ready.warnings ?? []);
881
1093
  for (const w of active.warnings ?? []) {
882
- if (!seen.has(w)) onEvent({ event: "warning", message: w });
1094
+ if (reportedWarnings.has(w)) continue;
1095
+ reportedWarnings.add(w);
1096
+ onEvent({ event: "warning", message: w });
883
1097
  }
884
- return { sessionId, pageUrl, stop: () => stopSession(0) };
1098
+ return { sessionId, pageUrl, stop: () => stopSession(0), reportedWarnings };
885
1099
  } catch (err) {
886
1100
  await stopSession(1);
887
1101
  throw err;
888
1102
  }
889
1103
  }
1104
+ function devSessionCreateBody(cfg) {
1105
+ return {
1106
+ device_type: PLATFORM_DEVICE[cfg.platform].deviceType,
1107
+ device_category: cfg.deviceCategory,
1108
+ app_id: cfg.appId,
1109
+ // Roku has no JS bundle to serve, so there is nothing to tunnel.
1110
+ tunnel: PLATFORM_NEEDS_METRO[cfg.platform]
1111
+ };
1112
+ }
890
1113
  var BLOCKING_WARNING_PREFIXES = ["No debug build", "App install failed", "App not found"];
891
1114
  function findBlockingWarning(info) {
892
1115
  return (info.warnings ?? []).find((w) => BLOCKING_WARNING_PREFIXES.some((p) => w.includes(p)));
@@ -921,11 +1144,38 @@ async function pollUntilActive(api, sessionId, timeoutMs, intervalMs = 2e3) {
921
1144
  await new Promise((r) => setTimeout(r, intervalMs));
922
1145
  }
923
1146
  }
1147
+ function sessionLivenessVerdict(info) {
1148
+ if (info && (info.status === "stopped" || info.status === "failed")) {
1149
+ return { end: true, message: `session ${info.status} \u2014 ending the reload loop` };
1150
+ }
1151
+ return { end: false };
1152
+ }
1153
+ function lateWarningVerdict(info, reportedWarnings) {
1154
+ if (!info) return { toReport: [], blocking: false };
1155
+ const toReport = (info.warnings ?? []).filter((w) => !reportedWarnings.has(w));
1156
+ const blocking = findBlockingWarning({ warnings: toReport }) !== void 0;
1157
+ return { toReport, blocking };
1158
+ }
1159
+ var ROKU_LIVENESS_INTERVAL_MS = 1e4;
1160
+ function rokuKeyAction(char) {
1161
+ if (char === "") return "interrupt";
1162
+ switch (char.toLowerCase()) {
1163
+ case "r":
1164
+ return "reload";
1165
+ case "q":
1166
+ return "quit";
1167
+ default:
1168
+ return null;
1169
+ }
1170
+ }
924
1171
  var PLATFORM_DEVICE = {
925
1172
  ios: { deviceType: "ios", tv: false },
926
1173
  android: { deviceType: "android", tv: false },
927
1174
  tvos: { deviceType: "ios", tv: true },
928
- androidtv: { deviceType: "android", tv: true }
1175
+ androidtv: { deviceType: "android", tv: true },
1176
+ // Roku IS its own device type (unlike tvOS/Android TV, which are the
1177
+ // handheld type plus category tv) — the worker registers it as such.
1178
+ roku: { deviceType: "roku", tv: true }
929
1179
  };
930
1180
  function appsForPlatform(apps, platform) {
931
1181
  return apps.filter((a) => a.platform === platform);
@@ -953,7 +1203,7 @@ function requireNonInteractive(json) {
953
1203
  async function ensureConfig(api, opts) {
954
1204
  const existing = loadDevConfig(process.cwd());
955
1205
  if (opts.platform && !isDevPlatform(opts.platform)) {
956
- console.error(chalk2.red(`error: --platform must be one of ios, android, tvos, androidtv`));
1206
+ console.error(chalk2.red(`error: --platform must be one of ${DEV_PLATFORMS.join(", ")}`));
957
1207
  process.exit(2);
958
1208
  }
959
1209
  if (!opts.platform && !existing?.defaultPlatform) {
@@ -961,12 +1211,7 @@ async function ensureConfig(api, opts) {
961
1211
  }
962
1212
  const platform = opts.platform || existing?.defaultPlatform || await select3({
963
1213
  message: "Platform to run on",
964
- choices: [
965
- { value: "ios", name: "iOS simulator" },
966
- { value: "android", name: "Android emulator" },
967
- { value: "tvos", name: "Apple TV simulator" },
968
- { value: "androidtv", name: "Android TV emulator" }
969
- ]
1214
+ choices: platformChoices(process.cwd())
970
1215
  });
971
1216
  const isTv = PLATFORM_DEVICE[platform].tv;
972
1217
  if (isTv && opts.deviceCategory) {
@@ -988,7 +1233,9 @@ async function ensureConfig(api, opts) {
988
1233
  requireNonInteractive(opts.json);
989
1234
  } else if (process.stdin.isTTY) {
990
1235
  firstRunBuild = await promptBuildConfig(process.cwd(), platform);
991
- firstRunMetro = await promptMetroCommand(process.cwd(), platform);
1236
+ if (PLATFORM_NEEDS_METRO[platform]) {
1237
+ firstRunMetro = await promptMetroCommand(process.cwd(), platform);
1238
+ }
992
1239
  }
993
1240
  }
994
1241
  const entry = existing?.platforms?.[platform];
@@ -1098,7 +1345,10 @@ Run \`vibeview dev\` without --app to pick from the list.`
1098
1345
  });
1099
1346
  saveDevConfig(process.cwd(), cfg);
1100
1347
  if (isFirstRun) {
1101
- const hint = fillLaterHint({ build: !firstRunBuild, metro: !firstRunMetro }, platform);
1348
+ const hint = fillLaterHint(
1349
+ { build: !firstRunBuild, metro: PLATFORM_NEEDS_METRO[platform] && !firstRunMetro },
1350
+ platform
1351
+ );
1102
1352
  if (hint) console.error(chalk2.dim(hint));
1103
1353
  }
1104
1354
  const finalMetro = isFirstRun ? resolveMetro(cfg, platform, opts.metroPort ? parseInt(opts.metroPort, 10) : void 0) : metroResolved;
@@ -1113,6 +1363,15 @@ Run \`vibeview dev\` without --app to pick from the list.`
1113
1363
  };
1114
1364
  }
1115
1365
  async function runDevInProcess(opts) {
1366
+ let watchSeconds;
1367
+ try {
1368
+ watchSeconds = parseWatchSeconds(opts.watch);
1369
+ } catch (err) {
1370
+ const message = err.message;
1371
+ console.error(chalk2.red(`error: ${message}`));
1372
+ emit(opts.json, { event: "error", code: "invalid_flag", message });
1373
+ process.exit(2);
1374
+ }
1116
1375
  if (opts.json) {
1117
1376
  const hasToken = !!(opts.token || process.env.VIBEVIEW_API_TOKEN || loadConfig().token);
1118
1377
  if (!hasToken) {
@@ -1158,51 +1417,68 @@ async function runDevInProcess(opts) {
1158
1417
  process.exit(1);
1159
1418
  }
1160
1419
  }
1161
- const metroPort = cfg.metroPort ?? 8081;
1162
- let metroResult;
1163
- try {
1164
- let metroLogStream = null;
1165
- const captureMetroLine = (line) => {
1166
- try {
1167
- if (!metroLogStream) {
1168
- mkdirSync3(dirname4(metroLogPath(process.cwd())), { recursive: true });
1169
- metroLogStream = createWriteStream(metroLogPath(process.cwd()), { flags: "w" });
1420
+ let metroOwnedThisRun = false;
1421
+ let rokuBuild;
1422
+ let watchIgnore = [];
1423
+ if (PLATFORM_NEEDS_METRO[cfg.platform]) {
1424
+ const metroPort = cfg.metroPort ?? 8081;
1425
+ let metroResult;
1426
+ try {
1427
+ let metroLogStream = null;
1428
+ const captureMetroLine = (line) => {
1429
+ try {
1430
+ if (!metroLogStream) {
1431
+ mkdirSync3(dirname5(metroLogPath(process.cwd())), { recursive: true });
1432
+ metroLogStream = createWriteStream(metroLogPath(process.cwd()), { flags: "w" });
1433
+ }
1434
+ metroLogStream.write(line.replace(/^\[metro\] /, "") + "\n");
1435
+ } catch {
1170
1436
  }
1171
- metroLogStream.write(line.replace(/^\[metro\] /, "") + "\n");
1172
- } catch {
1173
- }
1174
- if (shouldEchoMetroLine(line, opts.debug === true)) console.error(chalk2.dim(line));
1175
- };
1176
- metroResult = await ensureMetro(
1177
- process.cwd(),
1178
- {
1179
- command: cfg.metroCommand,
1180
- port: metroPort,
1181
- platformHasOverride: cfg.platformHasMetroOverride
1182
- },
1183
- captureMetroLine
1184
- );
1185
- } catch (err) {
1186
- const message = err.message;
1187
- console.error(chalk2.red(`error: ${message}`));
1188
- emit(opts.json, { event: "error", code: "metro_down", message });
1189
- process.exit(1);
1190
- }
1191
- for (const w of metroResult.warnings) console.error(chalk2.yellow(`\u26A0 ${w}`));
1192
- if (metroResult.status === "spawned" || metroResult.status === "respawned") {
1193
- console.error(chalk2.green(`\u2713 started Metro (${cfg.metroCommand})`));
1194
- console.error(
1195
- chalk2.dim(` full Metro output: ${metroLogPath(process.cwd())} (--debug echoes it here)`)
1196
- );
1437
+ if (shouldEchoMetroLine(line, opts.debug === true)) console.error(chalk2.dim(line));
1438
+ };
1439
+ metroResult = await ensureMetro(
1440
+ process.cwd(),
1441
+ {
1442
+ command: cfg.metroCommand,
1443
+ port: metroPort,
1444
+ platformHasOverride: cfg.platformHasMetroOverride
1445
+ },
1446
+ captureMetroLine
1447
+ );
1448
+ } catch (err) {
1449
+ const message = err.message;
1450
+ console.error(chalk2.red(`error: ${message}`));
1451
+ emit(opts.json, { event: "error", code: "metro_down", message });
1452
+ process.exit(1);
1453
+ }
1454
+ for (const w of metroResult.warnings) console.error(chalk2.yellow(`\u26A0 ${w}`));
1455
+ if (metroResult.status === "spawned" || metroResult.status === "respawned") {
1456
+ console.error(chalk2.green(`\u2713 started Metro (${cfg.metroCommand})`));
1457
+ console.error(
1458
+ chalk2.dim(` full Metro output: ${metroLogPath(process.cwd())} (--debug echoes it here)`)
1459
+ );
1460
+ }
1461
+ metroOwnedThisRun = metroResult.status === "spawned" || metroResult.status === "respawned";
1462
+ } else {
1463
+ const rokuEntry = loadDevConfig(process.cwd())?.platforms?.[cfg.platform];
1464
+ rokuBuild = rokuEntry?.build;
1465
+ watchIgnore = rokuEntry?.watchIgnore ?? [];
1466
+ if (!rokuBuild) {
1467
+ const message = `no build config for ${cfg.platform} \u2014 run \`vibeview build --platform ${cfg.platform}\` once to set it up`;
1468
+ console.error(chalk2.red(`error: ${message}`));
1469
+ emit(opts.json, { event: "error", code: "config_required", message });
1470
+ process.exit(1);
1471
+ }
1197
1472
  }
1198
- const metroOwnedThisRun = metroResult.status === "spawned" || metroResult.status === "respawned";
1199
1473
  console.error(chalk2.dim(`Starting a ${cfg.platform} dev session for ${cfg.appId}...`));
1200
1474
  let stopping = false;
1201
1475
  let handle = null;
1476
+ let reloadLoop = null;
1202
1477
  const stopSession = async (code) => {
1203
1478
  if (stopping) return;
1204
1479
  stopping = true;
1205
1480
  console.error(chalk2.dim("\nStopping session..."));
1481
+ reloadLoop?.stop();
1206
1482
  await handle?.stop();
1207
1483
  if (metroOwnedThisRun) killOwnedMetro(process.cwd());
1208
1484
  clearDevState(process.cwd());
@@ -1213,6 +1489,7 @@ async function runDevInProcess(opts) {
1213
1489
  let everConnected = false;
1214
1490
  let waitingPrinted = false;
1215
1491
  let nextWarningIsMetroUnreachable = false;
1492
+ let endedEmitted = false;
1216
1493
  try {
1217
1494
  handle = await startDevSession(
1218
1495
  {
@@ -1243,10 +1520,15 @@ async function runDevInProcess(opts) {
1243
1520
  } else if (evt.event === "session_ended" && !stopping && handle) {
1244
1521
  console.error(chalk2.red("tunnel closed by server \u2014 session ended"));
1245
1522
  stopping = true;
1523
+ endedEmitted = true;
1246
1524
  emit(opts.json, evt);
1247
1525
  clearDevState(process.cwd());
1248
1526
  process.exit(1);
1249
1527
  }
1528
+ if (evt.event === "session_ended") {
1529
+ if (endedEmitted) return;
1530
+ endedEmitted = true;
1531
+ }
1250
1532
  emit(opts.json, evt);
1251
1533
  },
1252
1534
  (s) => {
@@ -1266,12 +1548,11 @@ async function runDevInProcess(opts) {
1266
1548
  }
1267
1549
  }
1268
1550
  );
1269
- console.error(
1270
- `
1551
+ const keysLine = rokuBuild ? `Press ${chalk2.bold("r")} to reload the channel, ${chalk2.bold("q")} or Ctrl-C to end the session.` : "Ctrl-C to end the session.";
1552
+ console.error(`
1271
1553
  Watch and interact: ${chalk2.cyan(handle.pageUrl)}
1272
- Ctrl-C to end the session.
1273
- `
1274
- );
1554
+ ${keysLine}
1555
+ `);
1275
1556
  writeDevState(process.cwd(), {
1276
1557
  pid: process.pid,
1277
1558
  session_id: handle.sessionId,
@@ -1289,6 +1570,86 @@ async function runDevInProcess(opts) {
1289
1570
  });
1290
1571
  if (opts.open !== false) await openBrowser(handle.pageUrl).catch(() => {
1291
1572
  });
1573
+ if (rokuBuild) {
1574
+ reloadLoop = createReloadLoop({
1575
+ push: createRokuPush({
1576
+ cwd: process.cwd(),
1577
+ sessionId: handle.sessionId,
1578
+ build: rokuBuild,
1579
+ token,
1580
+ baseUrl,
1581
+ api,
1582
+ // Build output and loop notices are human output, so they go to
1583
+ // stderr like every other line this command prints — stdout stays
1584
+ // reserved for the `--json` event stream.
1585
+ log: (line) => console.error(chalk2.dim(line))
1586
+ }),
1587
+ log: (line) => console.error(chalk2.yellow(line)),
1588
+ // A failed reload is an error, not a notice — same red as every other
1589
+ // failure this command prints, so it doesn't read like the yellow
1590
+ // "already reloading" line.
1591
+ logError: (line) => console.error(chalk2.red(`\u2717 ${line}`)),
1592
+ ...watchSeconds !== void 0 ? {
1593
+ watchSeconds,
1594
+ watch: watchProjectFiles(process.cwd(), rokuBuild.artifact, watchIgnore)
1595
+ } : {}
1596
+ });
1597
+ if (watchSeconds !== void 0) {
1598
+ console.error(
1599
+ chalk2.dim(` Watching for changes \u2014 reloading after ${watchSeconds}s of quiet.`)
1600
+ );
1601
+ }
1602
+ if (process.stdin.isTTY) {
1603
+ process.stdin.setRawMode(true);
1604
+ process.stdin.resume();
1605
+ process.stdin.setEncoding("utf8");
1606
+ const onKeyData = (chunk) => {
1607
+ for (const char of chunk) {
1608
+ const action = rokuKeyAction(char);
1609
+ if (action === "reload") void reloadLoop?.trigger();
1610
+ else if (action === "quit" || action === "interrupt") void stopSession(0);
1611
+ }
1612
+ };
1613
+ process.stdin.on("data", onKeyData);
1614
+ process.on("exit", () => {
1615
+ process.stdin.removeListener("data", onKeyData);
1616
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
1617
+ process.stdin.pause();
1618
+ });
1619
+ }
1620
+ const sessionId = handle.sessionId;
1621
+ const reportedWarnings = handle.reportedWarnings;
1622
+ const keepAlive = setInterval(() => {
1623
+ void (async () => {
1624
+ if (stopping) return;
1625
+ let info = null;
1626
+ try {
1627
+ info = await api.getSession(sessionId);
1628
+ } catch {
1629
+ }
1630
+ const verdict = sessionLivenessVerdict(info);
1631
+ if (verdict.end && !stopping) {
1632
+ console.error(chalk2.red(verdict.message));
1633
+ emit(opts.json, { event: "session_ended", session_id: sessionId, code: 1 });
1634
+ endedEmitted = true;
1635
+ await stopSession(1);
1636
+ return;
1637
+ }
1638
+ const warningVerdict = lateWarningVerdict(info, reportedWarnings);
1639
+ for (const w of warningVerdict.toReport) {
1640
+ reportedWarnings.add(w);
1641
+ console.error(chalk2.yellow(`\u26A0 ${w}`));
1642
+ emit(opts.json, { event: "warning", message: w });
1643
+ }
1644
+ if (warningVerdict.blocking && !stopping) {
1645
+ emit(opts.json, { event: "session_ended", session_id: sessionId, code: 1 });
1646
+ endedEmitted = true;
1647
+ await stopSession(1);
1648
+ }
1649
+ })();
1650
+ }, ROKU_LIVENESS_INTERVAL_MS);
1651
+ process.on("exit", () => clearInterval(keepAlive));
1652
+ }
1292
1653
  } catch (err) {
1293
1654
  const message = err.message;
1294
1655
  console.error(chalk2.red(`error: ${message}`));
@@ -2055,6 +2416,7 @@ async function runAgentVerb(cmd, positionals, opts) {
2055
2416
  export {
2056
2417
  readDevState,
2057
2418
  clearDevState,
2419
+ PLATFORM_NEEDS_METRO,
2058
2420
  isDevPlatform,
2059
2421
  loadDevConfig,
2060
2422
  checkMetro,
@@ -44,13 +44,31 @@ var ApiClient = class {
44
44
  this.baseUrl = baseUrl;
45
45
  this.token = token;
46
46
  }
47
+ /**
48
+ * fetch() rejects with undici's bare "TypeError: fetch failed" on any
49
+ * network-level failure (connection refused, DNS, TLS). Translate that
50
+ * into a message that names the base URL — a stray VIBEVIEW_BASE_URL
51
+ * pointing at a dead dev backend is indistinguishable from an outage
52
+ * otherwise.
53
+ */
54
+ async fetchOrExplain(url, init) {
55
+ try {
56
+ return await fetch(url, init);
57
+ } catch (e) {
58
+ const cause = e.cause;
59
+ const detail = cause?.code ? ` (${cause.code})` : "";
60
+ throw new Error(
61
+ `Cannot reach ${this.baseUrl}${detail}. Check the server URL (--base-url flag, VIBEVIEW_BASE_URL, or ~/.vibeview/config.json) and your network.`
62
+ );
63
+ }
64
+ }
47
65
  async request(method, path, body) {
48
66
  const url = `${this.baseUrl}/api/v1/ci${path}`;
49
67
  const headers = {
50
68
  Authorization: `Bearer ${this.token}`,
51
69
  "Content-Type": "application/json"
52
70
  };
53
- const resp = await fetch(url, {
71
+ const resp = await this.fetchOrExplain(url, {
54
72
  method,
55
73
  headers,
56
74
  body: body ? JSON.stringify(body) : void 0
@@ -122,7 +140,7 @@ var ApiClient = class {
122
140
  Authorization: `Bearer ${this.token}`,
123
141
  "Content-Type": "application/json"
124
142
  };
125
- const resp = await fetch(url, {
143
+ const resp = await this.fetchOrExplain(url, {
126
144
  method,
127
145
  headers,
128
146
  body: body ? JSON.stringify(body) : void 0
@@ -142,7 +160,7 @@ var ApiClient = class {
142
160
  Authorization: `Bearer ${this.token}`
143
161
  // Do NOT set Content-Type — let fetch set multipart boundary automatically
144
162
  };
145
- const resp = await fetch(url, {
163
+ const resp = await this.fetchOrExplain(url, {
146
164
  method,
147
165
  headers,
148
166
  body: formData
@@ -214,6 +232,15 @@ var ApiClient = class {
214
232
  async stopSession(sessionId) {
215
233
  await this.requestRaw("DELETE", `/sessions/${sessionId}`);
216
234
  }
235
+ /** Install an already-uploaded build into a running session. Used by the
236
+ * Roku dev reload loop; the worker sideloads and (with launchAfterInstall)
237
+ * relaunches the channel in place. */
238
+ async installBuild(sessionId, buildId, launchAfterInstall = true) {
239
+ return this.requestRaw("POST", `/sessions/${sessionId}/install-build`, {
240
+ build_id: buildId,
241
+ launch_after_install: launchAfterInstall
242
+ });
243
+ }
217
244
  async agentTool(sessionId, tool, args) {
218
245
  return this.requestRaw("POST", `/sessions/${sessionId}/agent/tools`, { tool, args });
219
246
  }
@@ -223,7 +250,7 @@ var ApiClient = class {
223
250
  import ora from "ora";
224
251
  function createSpinner(text) {
225
252
  const isCI = process.env.CI === "true" || process.env.CI === "1" || !process.stdout.isTTY;
226
- return ora({ text, isEnabled: !isCI, stream: process.stderr });
253
+ return ora({ text, isEnabled: !isCI, stream: process.stderr, discardStdin: false });
227
254
  }
228
255
 
229
256
  // src/lib/types.ts
package/dist/cli.js CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  readDevState,
9
9
  readMetroState,
10
10
  runAgentVerb
11
- } from "./chunk-NA3JOV5Z.js";
11
+ } from "./chunk-4UGLSIZM.js";
12
12
  import {
13
13
  ApiClient,
14
14
  EXIT_INFRA_ERROR,
@@ -19,7 +19,7 @@ import {
19
19
  resolveAuth,
20
20
  saveConfig,
21
21
  uploadApp
22
- } from "./chunk-YD2X6TQV.js";
22
+ } from "./chunk-AG3RZCPW.js";
23
23
 
24
24
  // src/cli.ts
25
25
  import { createRequire } from "module";
@@ -1022,6 +1022,37 @@ Opening browser to log in...
1022
1022
  process.exit(EXIT_INFRA_ERROR);
1023
1023
  }
1024
1024
 
1025
+ // src/commands/logout.ts
1026
+ async function logout() {
1027
+ const config = loadConfig();
1028
+ if (!config.token) {
1029
+ console.log("Not logged in (no token saved in ~/.vibeview/config.json).");
1030
+ if (process.env.VIBEVIEW_API_TOKEN) {
1031
+ console.log("Note: VIBEVIEW_API_TOKEN is set; logout does not manage env tokens.");
1032
+ }
1033
+ return;
1034
+ }
1035
+ const baseUrl = config.baseUrl || "https://vibeview.io";
1036
+ let revoked = false;
1037
+ try {
1038
+ const resp = await fetch(`${baseUrl}/api/v1/auth/cli-token/revoke`, {
1039
+ method: "POST",
1040
+ headers: { Authorization: `Bearer ${config.token}` }
1041
+ });
1042
+ revoked = resp.ok || resp.status === 401;
1043
+ } catch {
1044
+ revoked = false;
1045
+ }
1046
+ saveConfig({ ...config, token: void 0 });
1047
+ if (revoked) {
1048
+ console.log("Logged out. Token revoked and removed from ~/.vibeview/config.json.");
1049
+ } else {
1050
+ console.log("Logged out. Removed token from ~/.vibeview/config.json.");
1051
+ console.log(` Warning: could not reach ${baseUrl} to revoke the token server-side.`);
1052
+ console.log(" It stays active until revoked in the dashboard (Settings -> API Tokens).");
1053
+ }
1054
+ }
1055
+
1025
1056
  // src/commands/dev-status.ts
1026
1057
  import chalk9 from "chalk";
1027
1058
  async function devStatus(opts) {
@@ -1423,9 +1454,12 @@ program.command("list-devices").description("List devices registered with the pl
1423
1454
  program.command("upload-app <file>").description("Upload an app build (zipped simulator .app for iOS, .apk for Android)").option("--token <token>", "API token").option("--output <format>", "Output format: human, json", "human").option("--name <name>", "App name (auto-detected if not provided)").action((file, opts) => uploadApp(file, opts));
1424
1455
  program.command("create-test [name]").description("Create a standalone AI test case").option("--token <token>", "API token").option("--output <format>", "Output format: human, json", "human").option("--app <app>", "App ID (public_id) or package name (requires --platform)").option("--platform <platform>", "Platform (ios, android) when using package name for --app").option("--steps <json>", "Test steps as inline JSON array").option("--steps-file <path>", "Path to JSON file containing test steps").option("--context <text>", "Additional context for AI test execution").option("--test-name <name>", "Test case name (alternative to positional arg)").action((name, opts) => createTest({ ...opts, testName: name || opts.testName || "CLI Test" }));
1425
1456
  program.command("login").description("Log in to VibeView (interactive)").option("--base-url <url>", "VibeView server URL (defaults to https://vibeview.io)").action((opts) => login(opts));
1426
- program.command("dev").description("Stream your app from the fleet with live Metro hot-reload (RN debug builds)").option("--token <token>", "API token (or set VIBEVIEW_API_TOKEN)").option("--web-url <url>", "Dashboard URL if different from the API URL (local dev)").option(
1457
+ program.command("logout").description("Revoke the saved login token and remove it from ~/.vibeview/config.json").action(() => logout());
1458
+ program.command("dev").description(
1459
+ "Stream your app from the fleet with live Metro hot-reload (RN debug builds), or press `r` to re-package and reload a Roku channel in place"
1460
+ ).option("--token <token>", "API token (or set VIBEVIEW_API_TOKEN)").option("--web-url <url>", "Dashboard URL if different from the API URL (local dev)").option(
1427
1461
  "--platform <platform>",
1428
- "ios, android, tvos, or androidtv (first run is interactive otherwise)"
1462
+ "ios, android, tvos, androidtv, or roku (first run is interactive otherwise)"
1429
1463
  ).option("--app <app_id>", "Uploaded debug build to run (see `vibeview list-apps`)").option("--metro-port <port>", "Local Metro port (default: 8081, remembered in vibeview.json)").option(
1430
1464
  "--device-category <category>",
1431
1465
  "phone or tablet (default: phone; TV platforms always use tv)",
@@ -1438,17 +1472,20 @@ program.command("dev").description("Stream your app from the fleet with live Met
1438
1472
  "--build",
1439
1473
  "Build and upload first (uses platforms.<platform>.build from vibeview.json)",
1440
1474
  false
1475
+ ).option(
1476
+ "--watch [seconds]",
1477
+ "Roku only: auto-reload after file changes settle (default: 2 seconds of quiet)"
1441
1478
  ).action((opts) => dev(opts));
1442
1479
  program.command("build").description("Run your configured build command and upload the result (see vibeview.json)").option("--token <token>", "API token (or set VIBEVIEW_API_TOKEN)").option(
1443
1480
  "--platform <platform>",
1444
- "ios, android, tvos, or androidtv (default: vibeview.json defaultPlatform)"
1481
+ "ios, android, tvos, androidtv, or roku (default: vibeview.json defaultPlatform)"
1445
1482
  ).option("--json", "Print the result as one JSON line", false).action((opts) => buildCommand(opts));
1446
1483
  program.command("dev-status").description("Show the status of a `vibeview dev --detach` session in this project").option("--token <token>", "API token (or set VIBEVIEW_API_TOKEN)").option("--json", "Print as one JSON line", false).action((opts) => devStatus(opts));
1447
1484
  program.command("dev-stop").description("Stop a `vibeview dev --detach` session in this project").option("--token <token>", "API token (or set VIBEVIEW_API_TOKEN)").option("--json", "Print as one JSON line", false).action((opts) => devStop(opts));
1448
1485
  program.command("mcp").description(
1449
1486
  "Run an MCP stdio server exposing the agent verbs (tap, swipe, type, ...) plus dev_start/dev_stop as tools"
1450
1487
  ).option("--token <token>", "API token (or set VIBEVIEW_API_TOKEN)").action(async (opts) => {
1451
- const { runMcpServer } = await import("./server-B2PDQJLR.js");
1488
+ const { runMcpServer } = await import("./server-S6OCJERW.js");
1452
1489
  await runMcpServer(opts);
1453
1490
  });
1454
1491
  program.command("agent-setup").description(
@@ -1470,4 +1507,12 @@ for (const cmd of AGENT_COMMANDS) {
1470
1507
  return runAgentVerb(cmd, positionals, opts);
1471
1508
  });
1472
1509
  }
1473
- program.parse();
1510
+ program.parseAsync().catch((e) => {
1511
+ if (e instanceof Error && e.name === "ExitPromptError") {
1512
+ process.exit(130);
1513
+ }
1514
+ if (process.env.VIBEVIEW_DEBUG) throw e;
1515
+ process.stderr.write(`error: ${e instanceof Error ? e.message : String(e)}
1516
+ `);
1517
+ process.exit(EXIT_INFRA_ERROR);
1518
+ });
@@ -2,6 +2,7 @@
2
2
  import {
3
3
  AGENT_COMMANDS,
4
4
  PLATFORM_DEVICE,
5
+ PLATFORM_NEEDS_METRO,
5
6
  buildToolCall,
6
7
  checkMetro,
7
8
  decodeScreenshotResult,
@@ -10,11 +11,11 @@ import {
10
11
  readDevState,
11
12
  renderEnvelope,
12
13
  startDevSession
13
- } from "./chunk-NA3JOV5Z.js";
14
+ } from "./chunk-4UGLSIZM.js";
14
15
  import {
15
16
  ApiClient,
16
17
  resolveAuth
17
- } from "./chunk-YD2X6TQV.js";
18
+ } from "./chunk-AG3RZCPW.js";
18
19
 
19
20
  // src/mcp/server.ts
20
21
  import { writeFileSync } from "fs";
@@ -25,7 +26,10 @@ var SESSION_ID_PROPERTY = {
25
26
  type: "string",
26
27
  description: "Session ID to target. Optional: falls back to the MCP-server-held dev_start session, then this project's `vibeview dev --detach` state, if either is available."
27
28
  };
28
- var DEV_PLATFORMS = ["ios", "android", "tvos", "androidtv"];
29
+ var MCP_DEV_PLATFORMS = ["ios", "android", "tvos", "androidtv"];
30
+ function isMcpDevPlatform(p) {
31
+ return isDevPlatform(p) && MCP_DEV_PLATFORMS.includes(p);
32
+ }
29
33
  function paramToProperty(param) {
30
34
  return { type: param.type, description: param.description };
31
35
  }
@@ -62,7 +66,7 @@ var DEV_START_DEF = {
62
66
  platform: {
63
67
  type: "string",
64
68
  description: "Platform to run on.",
65
- enum: [...DEV_PLATFORMS]
69
+ enum: [...MCP_DEV_PLATFORMS]
66
70
  },
67
71
  app: {
68
72
  type: "string",
@@ -194,9 +198,14 @@ function optsFor(cmd, args) {
194
198
  }
195
199
  async function handleDevStart(args, deps) {
196
200
  const platform = args.platform;
197
- if (!isDevPlatform(platform)) {
201
+ if (platform === "roku") {
202
+ return errorResult(
203
+ "dev_start does not support roku \u2014 the reload loop needs a foreground terminal; run `vibeview dev --platform roku` instead"
204
+ );
205
+ }
206
+ if (!isMcpDevPlatform(platform)) {
198
207
  return errorResult(
199
- `dev_start: platform must be one of ${DEV_PLATFORMS.join(", ")}, got "${String(platform)}"`
208
+ `dev_start: platform must be one of ${MCP_DEV_PLATFORMS.join(", ")}, got "${String(platform)}"`
200
209
  );
201
210
  }
202
211
  try {
@@ -230,7 +239,7 @@ async function handleUploadApp(args, deps) {
230
239
  }
231
240
  const name = typeof args.name === "string" && args.name ? args.name : void 0;
232
241
  const upload = deps.uploadApp ?? (async (f, n) => {
233
- const { uploadAppBuild } = await import("./upload-app-GAZVQZYR.js");
242
+ const { uploadAppBuild } = await import("./upload-app-JIOFLMS3.js");
234
243
  const { result: result2 } = await uploadAppBuild(f, { ...deps.auth, name: n });
235
244
  return result2;
236
245
  });
@@ -280,11 +289,13 @@ function createDevSessionController(auth, webUrl) {
280
289
  );
281
290
  }
282
291
  const metroPort = cfg.metroPort ?? devConfig?.metroPort ?? 8081;
283
- const metro = await checkMetro(metroPort);
284
- if (metro === "down") {
285
- throw new Error(
286
- `Metro isn't running on localhost:${metroPort} \u2014 start it in the project first (yarn start), or pass metro_port if Metro uses another port`
287
- );
292
+ if (PLATFORM_NEEDS_METRO[cfg.platform]) {
293
+ const metro = await checkMetro(metroPort);
294
+ if (metro === "down") {
295
+ throw new Error(
296
+ `Metro isn't running on localhost:${metroPort} \u2014 start it in the project first (yarn start), or pass metro_port if Metro uses another port`
297
+ );
298
+ }
288
299
  }
289
300
  const isTv = PLATFORM_DEVICE[cfg.platform].tv;
290
301
  handle = await startDevSession({
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  uploadApp,
4
4
  uploadAppBuild
5
- } from "./chunk-YD2X6TQV.js";
5
+ } from "./chunk-AG3RZCPW.js";
6
6
  export {
7
7
  uploadApp,
8
8
  uploadAppBuild
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibeview",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "VibeView CLI -- run mobile tests from CI/CD pipelines",
5
5
  "type": "module",
6
6
  "bin": {
@@ -50,6 +50,11 @@ https://vibeview.io/docs/preparing-your-build.
50
50
  fires once the session accepts commands (device up, app installed) —
51
51
  no need to poll `dev-status` first. If the app ships no native changes
52
52
  since the last run, skip step 1 and just do steps 2–4.
53
+ 5. **Immediately tell the developer the session `url`** — "watch or take
54
+ over at any time: `<url>`". The page is a live, fully interactive view
55
+ of the device; the developer watching along is a feature, not a risk.
56
+ (For a session you attached to with `--session` instead of starting,
57
+ the page is `https://vibeview.io/sandbox/<session_id>`.)
53
58
 
54
59
  Running `dev` writes two things into the project root: a `.vibeview/`
55
60
  directory (live session state — local machine state, never commit) and a
@@ -150,7 +155,26 @@ it rather than letting it appear unexplained in their `git status`.
150
155
  focus directly with `tap-focused` (moves focus AND activates) / `focus`
151
156
  (moves focus only, no activation).
152
157
 
153
- ## 4. Cleanup
158
+ ## 4. When only a human can act — stop and hand off
159
+
160
+ Some screens need things you must never guess or invent: **login
161
+ credentials, 2FA/OTP codes, CAPTCHAs, destructive confirmations
162
+ ("Delete account?"), anything payment- or real-account-shaped.** When you
163
+ hit one:
164
+
165
+ 1. **Stop driving.** Do not type guessed credentials, do not tap through a
166
+ destructive confirmation to "see what happens".
167
+ 2. **Ask the developer, naming exactly what you need and where**: "I'm at
168
+ the login screen — please sign in at `<session url>` and tell me when
169
+ you're done." The session page is fully interactive; the developer can
170
+ drive the device directly while you wait.
171
+ 3. **On resume, run `ui-tree` FIRST** — the human just changed the screen,
172
+ so every ref you held is stale — and **verify the blocker is actually
173
+ gone from the tree** (e.g. the login form is absent) instead of trusting
174
+ the confirmation alone. If it's still there, say what you still see and
175
+ ask again.
176
+
177
+ ## 5. Cleanup
154
178
 
155
179
  **Always** stop the session when done verifying — a running session bills
156
180
  streaming minutes even when idle:
@@ -158,7 +182,7 @@ streaming minutes even when idle:
158
182
  vibeview dev-stop
159
183
  ```
160
184
 
161
- ## 5. Command reference
185
+ ## 6. Command reference
162
186
 
163
187
  Every verb accepts `--session <id>` to target a specific session (falls
164
188
  back to the current project's `vibeview dev --detach` session if omitted)
@@ -194,7 +218,7 @@ Dev-loop lifecycle commands (not registry verbs, but needed for every run):
194
218
  | `dev-status` | Check on the current project's detached dev session. |
195
219
  | `dev-stop` | End the current project's detached dev session. |
196
220
 
197
- ## 6. If you're calling VibeView over MCP
221
+ ## 7. If you're calling VibeView over MCP
198
222
 
199
223
  Everything above is also exposed as MCP tools by `vibeview mcp`, so an agent
200
224
  with no shell can run the whole loop. Tool names are the CLI verbs with
@@ -210,7 +234,7 @@ steps the CLI does with plain shell commands:
210
234
  | MCP tool | Args | Purpose |
211
235
  |----------|------|---------|
212
236
  | `upload_app` | `file` (required path), `name` | Upload a debug build. 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`. |
213
- | `dev_start` | `platform` (required: `ios`/`android`/`tvos`/`androidtv`), `app`, `metro_port` | Start a dev-loop session held open by the MCP server. The MCP equivalent of `vibeview dev --detach`. |
237
+ | `dev_start` | `platform` (required: `ios`/`android`/`tvos`/`androidtv`), `app`, `metro_port` | Start a dev-loop session held open by the MCP server. The MCP equivalent of `vibeview dev --detach`. Returns `page_url` — relay it to the developer immediately, same as step 5 of the loop. |
214
238
  | `dev_stop` | — | Stop the session `dev_start` started. The MCP equivalent of `vibeview dev-stop`. |
215
239
 
216
240
  **`dev_start` is stateful — it changes the default session for every later