vibeview 0.29.0 → 0.30.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.
@@ -13,7 +13,7 @@ import {
13
13
  resolveArtifact,
14
14
  resolveAuth,
15
15
  uploadAppBuild
16
- } from "./chunk-PBMB6TG4.js";
16
+ } from "./chunk-ZZK7YQSP.js";
17
17
 
18
18
  // src/lib/dev-config.ts
19
19
  import { readFileSync, renameSync, rmSync, writeFileSync } from "fs";
@@ -479,6 +479,108 @@ async function checkMetro(port, timeoutMs = 1500) {
479
479
  }
480
480
  }
481
481
 
482
+ // src/lib/local-config.ts
483
+ import { mkdirSync as mkdirSync2, readFileSync as readFileSync3, renameSync as renameSync2, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "fs";
484
+ import { join as join3 } from "path";
485
+ var DIR = ".vibeview";
486
+ var FILE2 = "local.json";
487
+ function localConfigPath(cwd) {
488
+ return join3(cwd, DIR, FILE2);
489
+ }
490
+ function loadLocalDevConfig(cwd) {
491
+ let parsed;
492
+ try {
493
+ parsed = JSON.parse(readFileSync3(localConfigPath(cwd), "utf-8"));
494
+ } catch {
495
+ return {};
496
+ }
497
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
498
+ const raw = parsed;
499
+ const out = {};
500
+ if (isDevPlatform(raw.defaultPlatform)) out.defaultPlatform = raw.defaultPlatform;
501
+ if (raw.platforms && typeof raw.platforms === "object") {
502
+ for (const [key, value] of Object.entries(raw.platforms)) {
503
+ if (!isDevPlatform(key) || !value || typeof value !== "object") continue;
504
+ const v = value;
505
+ const entry = {};
506
+ if (typeof v.appId === "string" && v.appId) entry.appId = v.appId;
507
+ if (typeof v.deviceCategory === "string" && v.deviceCategory) {
508
+ entry.deviceCategory = v.deviceCategory;
509
+ }
510
+ if (typeof v.metroPort === "number" && Number.isInteger(v.metroPort) && v.metroPort > 0) {
511
+ entry.metroPort = v.metroPort;
512
+ }
513
+ if (Object.keys(entry).length) (out.platforms ??= {})[key] = entry;
514
+ }
515
+ }
516
+ return out;
517
+ }
518
+ function prune(cfg) {
519
+ const out = {};
520
+ if (cfg.defaultPlatform) out.defaultPlatform = cfg.defaultPlatform;
521
+ for (const [key, entry] of Object.entries(cfg.platforms ?? {})) {
522
+ const kept = Object.fromEntries(
523
+ Object.entries(entry ?? {}).filter(([, v]) => v !== void 0)
524
+ );
525
+ if (Object.keys(kept).length) (out.platforms ??= {})[key] = kept;
526
+ }
527
+ return out;
528
+ }
529
+ function saveLocalDevConfig(cwd, cfg) {
530
+ const pruned = prune(cfg);
531
+ const target = localConfigPath(cwd);
532
+ if (!pruned.defaultPlatform && !pruned.platforms) {
533
+ rmSync3(target, { force: true });
534
+ return;
535
+ }
536
+ mkdirSync2(join3(cwd, DIR), { recursive: true });
537
+ const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
538
+ try {
539
+ writeFileSync3(tmp, JSON.stringify(pruned, null, 2) + "\n");
540
+ renameSync2(tmp, target);
541
+ } catch (err) {
542
+ rmSync3(tmp, { force: true });
543
+ throw err;
544
+ }
545
+ ensureGitignored(cwd);
546
+ }
547
+ function effectiveDevConfig(committed, local) {
548
+ if (!committed && !local.defaultPlatform && !local.platforms) return null;
549
+ const base = committed ? { ...committed, platforms: { ...committed.platforms } } : { defaultPlatform: local.defaultPlatform ?? "ios", platforms: {} };
550
+ if (local.defaultPlatform) base.defaultPlatform = local.defaultPlatform;
551
+ for (const [key, entry] of Object.entries(local.platforms ?? {})) {
552
+ const platform = key;
553
+ const merged = { ...base.platforms[platform] };
554
+ if (entry?.appId) merged.appId = entry.appId;
555
+ if (entry?.deviceCategory) merged.deviceCategory = entry.deviceCategory;
556
+ if (entry?.metroPort !== void 0) merged.metroPort = entry.metroPort;
557
+ base.platforms[platform] = merged;
558
+ }
559
+ return base;
560
+ }
561
+ function describeLocalChanges(before, after) {
562
+ const changes = [];
563
+ const a = prune(before);
564
+ const b = prune(after);
565
+ if (a.defaultPlatform !== b.defaultPlatform) {
566
+ changes.push(
567
+ b.defaultPlatform ? `defaultPlatform ${b.defaultPlatform}` : `defaultPlatform (removed, was ${a.defaultPlatform})`
568
+ );
569
+ }
570
+ const keys = /* @__PURE__ */ new Set([...Object.keys(a.platforms ?? {}), ...Object.keys(b.platforms ?? {})]);
571
+ for (const key of keys) {
572
+ const pa = a.platforms?.[key] ?? {};
573
+ const pb = b.platforms?.[key] ?? {};
574
+ for (const field of ["appId", "deviceCategory", "metroPort"]) {
575
+ if (pa[field] === pb[field]) continue;
576
+ changes.push(
577
+ pb[field] === void 0 ? `${key}.${field} (removed, was ${String(pa[field])})` : `${key}.${field} ${String(pb[field])}`
578
+ );
579
+ }
580
+ }
581
+ return changes;
582
+ }
583
+
482
584
  // src/commands/build.ts
483
585
  import { spawn as spawn2 } from "child_process";
484
586
  import { createInterface } from "readline";
@@ -490,12 +592,12 @@ import chalk2 from "chalk";
490
592
  import { input, select } from "@inquirer/prompts";
491
593
 
492
594
  // src/lib/project-detect.ts
493
- import { existsSync as existsSync2, readFileSync as readFileSync3, readdirSync, statSync } from "fs";
494
- import { join as join3 } from "path";
595
+ import { existsSync as existsSync2, readFileSync as readFileSync4, readdirSync, statSync } from "fs";
596
+ import { join as join4 } from "path";
495
597
  import ignore from "ignore";
496
598
  function readJson(cwd, name) {
497
599
  try {
498
- return JSON.parse(readFileSync3(join3(cwd, name), "utf-8"));
600
+ return JSON.parse(readFileSync4(join4(cwd, name), "utf-8"));
499
601
  } catch {
500
602
  return null;
501
603
  }
@@ -510,19 +612,19 @@ function isFile(path) {
510
612
  function detectFlavor(cwd) {
511
613
  let deps = {};
512
614
  try {
513
- const pkg = JSON.parse(readFileSync3(join3(cwd, "package.json"), "utf-8"));
615
+ const pkg = JSON.parse(readFileSync4(join4(cwd, "package.json"), "utf-8"));
514
616
  deps = { ...pkg.dependencies, ...pkg.devDependencies };
515
617
  } catch {
516
618
  }
517
619
  if ("expo" in deps) return "expo";
518
- if (existsSync2(join3(cwd, "android", "gradlew")) || iosWorkspaceName(cwd)) return "bare-rn";
519
- if (isFile(join3(cwd, "manifest"))) return "roku";
620
+ if (existsSync2(join4(cwd, "android", "gradlew")) || iosWorkspaceName(cwd)) return "bare-rn";
621
+ if (isFile(join4(cwd, "manifest"))) return "roku";
520
622
  return "unknown";
521
623
  }
522
624
  function nativeDirShipped(cwd, dir) {
523
- if (!existsSync2(join3(cwd, dir))) return false;
625
+ if (!existsSync2(join4(cwd, dir))) return false;
524
626
  try {
525
- const ig = ignore().add(readFileSync3(join3(cwd, ".gitignore"), "utf-8"));
627
+ const ig = ignore().add(readFileSync4(join4(cwd, ".gitignore"), "utf-8"));
526
628
  return !ig.ignores(`${dir}/`);
527
629
  } catch {
528
630
  return true;
@@ -538,7 +640,7 @@ function isManagedExpo(cwd, platform) {
538
640
  }
539
641
  function iosWorkspaceName(cwd) {
540
642
  try {
541
- const found = readdirSync(join3(cwd, "ios")).find((f) => f.endsWith(".xcworkspace"));
643
+ const found = readdirSync(join4(cwd, "ios")).find((f) => f.endsWith(".xcworkspace"));
542
644
  return found ? found.replace(/\.xcworkspace$/, "") : null;
543
645
  } catch {
544
646
  return null;
@@ -547,9 +649,9 @@ function iosWorkspaceName(cwd) {
547
649
  var IOS_ARTIFACT = "ios/build/Build/Products/Debug-iphonesimulator/*.app";
548
650
  var ANDROID_ARTIFACT = "android/app/build/outputs/apk/debug/*.apk";
549
651
  function installCommand(cwd) {
550
- if (existsSync2(join3(cwd, "yarn.lock"))) return "yarn install --immutable";
551
- if (existsSync2(join3(cwd, "pnpm-lock.yaml"))) return "pnpm install --frozen-lockfile";
552
- if (existsSync2(join3(cwd, "package-lock.json"))) return "npm ci";
652
+ if (existsSync2(join4(cwd, "yarn.lock"))) return "yarn install --immutable";
653
+ if (existsSync2(join4(cwd, "pnpm-lock.yaml"))) return "pnpm install --frozen-lockfile";
654
+ if (existsSync2(join4(cwd, "package-lock.json"))) return "npm ci";
553
655
  return "yarn install --immutable";
554
656
  }
555
657
  function managedExpoAndroidCommand(cwd) {
@@ -563,7 +665,7 @@ var ROKU_ZIP_COMMAND = "zip -r out/channel.zip manifest source components images
563
665
  function rokuDefaults(cwd) {
564
666
  const pkg = readJson(cwd, "package.json");
565
667
  const scripts = pkg?.scripts ?? {};
566
- const runner = existsSync2(join3(cwd, "yarn.lock")) ? "yarn" : "npm run";
668
+ const runner = existsSync2(join4(cwd, "yarn.lock")) ? "yarn" : "npm run";
567
669
  const packageScript = Object.entries(scripts).find(
568
670
  ([name, body]) => !name.startsWith("deploy") && (name.startsWith("package") || body.includes("--package-only") || body.includes("zipPackage"))
569
671
  );
@@ -621,7 +723,7 @@ function expoSanitizedName(name) {
621
723
  return name.replace(/[\W_]+/g, "").normalize("NFD").replace(/[\u0300-\u036f]/g, "");
622
724
  }
623
725
  function managedExpoIosName(cwd) {
624
- if (existsSync2(join3(cwd, "app.config.js")) || existsSync2(join3(cwd, "app.config.ts"))) {
726
+ if (existsSync2(join4(cwd, "app.config.js")) || existsSync2(join4(cwd, "app.config.ts"))) {
625
727
  return null;
626
728
  }
627
729
  const raw = readJson(cwd, "app.config.json") ?? readJson(cwd, "app.json");
@@ -766,9 +868,9 @@ async function promptMetroCommand(cwd, platform) {
766
868
 
767
869
  // src/lib/source-pack.ts
768
870
  import { execFile, spawn } from "child_process";
769
- import { lstatSync, mkdtempSync, readFileSync as readFileSync4, rmSync as rmSync3, statSync as statSync2, writeFileSync as writeFileSync3 } from "fs";
871
+ import { lstatSync, mkdtempSync, readFileSync as readFileSync5, rmSync as rmSync4, statSync as statSync2, writeFileSync as writeFileSync4 } from "fs";
770
872
  import { tmpdir } from "os";
771
- import { join as join4 } from "path";
873
+ import { join as join5 } from "path";
772
874
  import ignore2 from "ignore";
773
875
  var NotAGitRepoError = class extends Error {
774
876
  constructor(message) {
@@ -794,15 +896,15 @@ async function listSourceFiles(projectRoot) {
794
896
  const stdout = await runGitLsFiles(projectRoot);
795
897
  const rawPaths = stdout.length ? stdout.split("\0").filter((p) => p.length > 0) : [];
796
898
  let filtered = rawPaths;
797
- const vvignorePath = join4(projectRoot, ".vvignore");
899
+ const vvignorePath = join5(projectRoot, ".vvignore");
798
900
  if (statOrNull(vvignorePath)?.isFile()) {
799
- const ig = ignore2().add(readFileSync4(vvignorePath, "utf-8"));
901
+ const ig = ignore2().add(readFileSync5(vvignorePath, "utf-8"));
800
902
  filtered = filtered.filter((p) => !ig.ignores(p));
801
903
  }
802
904
  const result = [];
803
905
  for (const relPath of filtered) {
804
906
  if (isForceExcluded(relPath)) continue;
805
- const st = statOrNull(join4(projectRoot, relPath));
907
+ const st = statOrNull(join5(projectRoot, relPath));
806
908
  if (!st) continue;
807
909
  result.push(relPath);
808
910
  }
@@ -852,7 +954,7 @@ async function packSource(projectRoot, files, opts = {}) {
852
954
  let totalBytes = 0;
853
955
  const sized = [];
854
956
  for (const relPath of files) {
855
- const bytes = lstatSync(join4(projectRoot, relPath)).size;
957
+ const bytes = lstatSync(join5(projectRoot, relPath)).size;
856
958
  totalBytes += bytes;
857
959
  sized.push({ path: relPath, bytes });
858
960
  }
@@ -863,14 +965,14 @@ async function packSource(projectRoot, files, opts = {}) {
863
965
  largest
864
966
  );
865
967
  }
866
- const tmpDir = mkdtempSync(join4(tmpdir(), "vibeview-source-pack-"));
867
- const cleanup = () => rmSync3(tmpDir, { recursive: true, force: true });
968
+ const tmpDir = mkdtempSync(join5(tmpdir(), "vibeview-source-pack-"));
969
+ const cleanup = () => rmSync4(tmpDir, { recursive: true, force: true });
868
970
  try {
869
971
  const supportsNull = await probeTarNullSupport();
870
- const tarPath = join4(tmpDir, "source.tgz");
972
+ const tarPath = join5(tmpDir, "source.tgz");
871
973
  if (supportsNull) {
872
- const listPath = join4(tmpDir, "files.list");
873
- writeFileSync3(listPath, files.map((f) => `${f}\0`).join(""));
974
+ const listPath = join5(tmpDir, "files.list");
975
+ writeFileSync4(listPath, files.map((f) => `${f}\0`).join(""));
874
976
  await runTar(["-czf", tarPath, "-C", projectRoot, "--null", "-T", listPath]);
875
977
  } else {
876
978
  const badName = files.find((f) => f.includes("\n"));
@@ -879,8 +981,8 @@ async function packSource(projectRoot, files, opts = {}) {
879
981
  `cannot pack source: filename contains a newline (${JSON.stringify(badName)}) and the installed tar doesn't support --null -T; rename the file or install a tar that supports --null`
880
982
  );
881
983
  }
882
- const listPath = join4(tmpDir, "files.list");
883
- writeFileSync3(listPath, files.map((f) => `${f}
984
+ const listPath = join5(tmpDir, "files.list");
985
+ writeFileSync4(listPath, files.map((f) => `${f}
884
986
  `).join(""));
885
987
  await runTar(["-czf", tarPath, "-C", projectRoot, "-T", listPath]);
886
988
  }
@@ -900,12 +1002,12 @@ var nullSupportCache = null;
900
1002
  function probeTarNullSupport() {
901
1003
  if (!nullSupportCache) {
902
1004
  nullSupportCache = new Promise((resolve) => {
903
- const tmp = mkdtempSync(join4(tmpdir(), "vibeview-tar-probe-"));
904
- const listPath = join4(tmp, "empty.list");
905
- writeFileSync3(listPath, "");
906
- const out = join4(tmp, "probe.tgz");
1005
+ const tmp = mkdtempSync(join5(tmpdir(), "vibeview-tar-probe-"));
1006
+ const listPath = join5(tmp, "empty.list");
1007
+ writeFileSync4(listPath, "");
1008
+ const out = join5(tmp, "probe.tgz");
907
1009
  execFile("tar", ["-czf", out, "-C", tmp, "--null", "-T", listPath], (err) => {
908
- rmSync3(tmp, { recursive: true, force: true });
1010
+ rmSync4(tmp, { recursive: true, force: true });
909
1011
  resolve(!err);
910
1012
  });
911
1013
  });
@@ -1777,7 +1879,7 @@ your build command must produce a zip \u2014 use the package/zip step, not deplo
1777
1879
 
1778
1880
  // src/commands/dev.ts
1779
1881
  import { spawn as spawn4 } from "child_process";
1780
- import { createWriteStream, mkdirSync as mkdirSync3 } from "fs";
1882
+ import { createWriteStream, mkdirSync as mkdirSync4 } from "fs";
1781
1883
  import { dirname as dirname4 } from "path";
1782
1884
  import { createInterface as createInterface3 } from "readline";
1783
1885
  import { select as select3 } from "@inquirer/prompts";
@@ -1973,15 +2075,15 @@ import { spawn as spawn3 } from "child_process";
1973
2075
  import { createInterface as createInterface2 } from "readline";
1974
2076
 
1975
2077
  // src/lib/metro-state.ts
1976
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, readFileSync as readFileSync5, rmSync as rmSync4, writeFileSync as writeFileSync4 } from "fs";
1977
- import { dirname as dirname2, join as join5 } from "path";
2078
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync6, rmSync as rmSync5, writeFileSync as writeFileSync5 } from "fs";
2079
+ import { dirname as dirname2, join as join6 } from "path";
1978
2080
  var STATE_DIR2 = ".vibeview";
1979
2081
  var STATE_FILE2 = "metro-state.json";
1980
2082
  function statePath2(cwd) {
1981
- return join5(cwd, STATE_DIR2, STATE_FILE2);
2083
+ return join6(cwd, STATE_DIR2, STATE_FILE2);
1982
2084
  }
1983
2085
  function metroLogPath(cwd) {
1984
- return join5(cwd, STATE_DIR2, "metro.log");
2086
+ return join6(cwd, STATE_DIR2, "metro.log");
1985
2087
  }
1986
2088
  function isPidAlive2(pid) {
1987
2089
  try {
@@ -1993,13 +2095,13 @@ function isPidAlive2(pid) {
1993
2095
  }
1994
2096
  function writeMetroState(cwd, state) {
1995
2097
  const path = statePath2(cwd);
1996
- mkdirSync2(dirname2(path), { recursive: true });
1997
- writeFileSync4(path, JSON.stringify(state, null, 2) + "\n");
2098
+ mkdirSync3(dirname2(path), { recursive: true });
2099
+ writeFileSync5(path, JSON.stringify(state, null, 2) + "\n");
1998
2100
  }
1999
2101
  function readMetroState(cwd) {
2000
2102
  let parsed;
2001
2103
  try {
2002
- parsed = JSON.parse(readFileSync5(statePath2(cwd), "utf-8"));
2104
+ parsed = JSON.parse(readFileSync6(statePath2(cwd), "utf-8"));
2003
2105
  } catch {
2004
2106
  return null;
2005
2107
  }
@@ -2011,7 +2113,7 @@ function readMetroState(cwd) {
2011
2113
  }
2012
2114
  function clearMetroState(cwd) {
2013
2115
  const path = statePath2(cwd);
2014
- if (existsSync3(path)) rmSync4(path, { force: true });
2116
+ if (existsSync3(path)) rmSync5(path, { force: true });
2015
2117
  }
2016
2118
  function killOwnedMetro(cwd) {
2017
2119
  const state = readMetroState(cwd);
@@ -2524,17 +2626,19 @@ function requireNonInteractive(json, message) {
2524
2626
  }
2525
2627
  async function ensureConfig(api, opts) {
2526
2628
  const existing = loadDevConfig(process.cwd());
2629
+ const local = loadLocalDevConfig(process.cwd());
2630
+ const effective = effectiveDevConfig(existing, local);
2527
2631
  if (opts.platform && !isDevPlatform(opts.platform)) {
2528
2632
  console.error(chalk4.red(`error: --platform must be one of ${DEV_PLATFORMS.join(", ")}`));
2529
2633
  process.exit(2);
2530
2634
  }
2531
- if (!opts.platform && !existing?.defaultPlatform) {
2635
+ if (!opts.platform && !effective?.defaultPlatform) {
2532
2636
  requireNonInteractive(
2533
2637
  opts.json,
2534
2638
  `--platform is required (no vibeview.json default yet) \u2014 pass --platform <${DEV_PLATFORMS.join("|")}>, or run once interactively to set a default.`
2535
2639
  );
2536
2640
  }
2537
- const platform = opts.platform || existing?.defaultPlatform || await select3({
2641
+ const platform = opts.platform || effective?.defaultPlatform || await select3({
2538
2642
  message: "Platform to run on",
2539
2643
  choices: platformChoices(process.cwd())
2540
2644
  });
@@ -2571,7 +2675,7 @@ async function ensureConfig(api, opts) {
2571
2675
  }
2572
2676
  }
2573
2677
  }
2574
- const entry = existing?.platforms?.[platform];
2678
+ const entry = effective?.platforms?.[platform];
2575
2679
  let allApps;
2576
2680
  try {
2577
2681
  allApps = await api.listApps();
@@ -2591,6 +2695,7 @@ ${hint}`);
2591
2695
  process.exit(2);
2592
2696
  }
2593
2697
  let appId = choice.appId;
2698
+ const appFromFlag = Boolean(opts.app) && appId === opts.app;
2594
2699
  let builtThisRun = false;
2595
2700
  if (choice.staleCached) {
2596
2701
  console.error(
@@ -2663,22 +2768,51 @@ ${hint}`);
2663
2768
  });
2664
2769
  }
2665
2770
  const deviceCategory = isTv ? "tv" : opts.deviceCategory || entry?.deviceCategory || "phone";
2666
- const metroResolved = resolveMetro(
2667
- existing,
2668
- platform,
2669
- opts.metroPort ? parseInt(opts.metroPort, 10) : void 0
2670
- );
2771
+ const metroPortFlag = opts.metroPort ? parseInt(opts.metroPort, 10) : void 0;
2772
+ const metroResolved = resolveMetro(effective, platform, metroPortFlag);
2773
+ const committedEntry = existing?.platforms?.[platform];
2774
+ const nextLocal = JSON.parse(JSON.stringify(local));
2775
+ const localEntry = (nextLocal.platforms ??= {})[platform] ??= {};
2776
+ let committedAppId = committedEntry?.appId;
2777
+ if (!committedAppId) {
2778
+ committedAppId = appId;
2779
+ } else if (appId !== committedAppId) {
2780
+ if (appFromFlag || local.platforms?.[platform]?.appId !== void 0) {
2781
+ localEntry.appId = appId;
2782
+ } else {
2783
+ committedAppId = appId;
2784
+ }
2785
+ }
2786
+ if (appId === committedAppId) delete localEntry.appId;
2787
+ let committedCategory = committedEntry?.deviceCategory;
2788
+ if (!isTv) {
2789
+ if (!committedCategory) committedCategory = deviceCategory;
2790
+ if (deviceCategory !== committedCategory) localEntry.deviceCategory = deviceCategory;
2791
+ else delete localEntry.deviceCategory;
2792
+ }
2793
+ const committedDefault = existing?.defaultPlatform ?? platform;
2794
+ if (opts.platform) {
2795
+ if (platform !== committedDefault) nextLocal.defaultPlatform = platform;
2796
+ else delete nextLocal.defaultPlatform;
2797
+ }
2671
2798
  const cfg = buildSavedConfig({
2672
2799
  existing,
2673
2800
  platform,
2674
- appId,
2675
- deviceCategory,
2801
+ appId: committedAppId,
2802
+ deviceCategory: committedCategory ?? deviceCategory,
2676
2803
  isTv,
2677
- metroPortFlag: opts.metroPort,
2804
+ // Never the flag: vibeview.json keeps (or, on the first run, seeds) the
2805
+ // shared default; the flag's port is saved locally below.
2806
+ metroPortFlag: void 0,
2678
2807
  isFirstRun,
2679
2808
  firstRunMetro,
2680
2809
  firstRunBuild
2681
2810
  });
2811
+ cfg.defaultPlatform = committedDefault;
2812
+ if (metroPortFlag !== void 0 && PLATFORM_NEEDS_METRO[platform]) {
2813
+ if (metroPortFlag !== resolveMetro(cfg, platform).port) localEntry.metroPort = metroPortFlag;
2814
+ else delete localEntry.metroPort;
2815
+ }
2682
2816
  const commitConfig = () => {
2683
2817
  updateDevConfig(process.cwd(), platform, (fresh) => {
2684
2818
  applyConfigChanges(fresh, existing, cfg);
@@ -2689,6 +2823,15 @@ ${hint}`);
2689
2823
  console.error(chalk4.dim(`Updated vibeview.json: ${changes.join(", ")}`));
2690
2824
  }
2691
2825
  }
2826
+ const localChanges = describeLocalChanges(local, nextLocal);
2827
+ if (localChanges.length) {
2828
+ saveLocalDevConfig(process.cwd(), nextLocal);
2829
+ console.error(
2830
+ chalk4.dim(
2831
+ `Saved to .vibeview/local.json (yours only, not committed): ${localChanges.join(", ")}`
2832
+ )
2833
+ );
2834
+ }
2692
2835
  if (isFirstRun) {
2693
2836
  console.error(chalk4.dim(`Created vibeview.json (platform: ${platform}, app: ${appId}).`));
2694
2837
  const hint = fillLaterHint(
@@ -2698,7 +2841,7 @@ ${hint}`);
2698
2841
  if (hint) console.error(chalk4.dim(hint));
2699
2842
  }
2700
2843
  };
2701
- const finalMetro = isFirstRun ? resolveMetro(cfg, platform, opts.metroPort ? parseInt(opts.metroPort, 10) : void 0) : metroResolved;
2844
+ const finalMetro = isFirstRun ? resolveMetro(effectiveDevConfig(cfg, nextLocal), platform, metroPortFlag) : metroResolved;
2702
2845
  return {
2703
2846
  platform,
2704
2847
  appId,
@@ -2793,12 +2936,21 @@ async function runDevInProcess(opts) {
2793
2936
  (l) => console.error(chalk4.dim(l))
2794
2937
  );
2795
2938
  cfg.appId = result.public_id;
2796
- updateDevConfig(process.cwd(), cfg.platform, (fresh) => {
2797
- const entry = fresh.platforms[cfg.platform];
2798
- if (!entry || entry.appId === result.public_id) return false;
2799
- entry.appId = result.public_id;
2800
- return true;
2801
- });
2939
+ const localNow = loadLocalDevConfig(process.cwd());
2940
+ const localApp = localNow.platforms?.[cfg.platform];
2941
+ if (localApp?.appId !== void 0) {
2942
+ const committedApp = loadDevConfig(process.cwd())?.platforms?.[cfg.platform]?.appId;
2943
+ if (result.public_id === committedApp) delete localApp.appId;
2944
+ else localApp.appId = result.public_id;
2945
+ saveLocalDevConfig(process.cwd(), localNow);
2946
+ } else {
2947
+ updateDevConfig(process.cwd(), cfg.platform, (fresh) => {
2948
+ const entry = fresh.platforms[cfg.platform];
2949
+ if (!entry || entry.appId === result.public_id) return false;
2950
+ entry.appId = result.public_id;
2951
+ return true;
2952
+ });
2953
+ }
2802
2954
  } catch (err) {
2803
2955
  const message = err.message;
2804
2956
  console.error(chalk4.red(`error: ${message}`));
@@ -2824,7 +2976,7 @@ async function runDevInProcess(opts) {
2824
2976
  const captureMetroLine = (line) => {
2825
2977
  try {
2826
2978
  if (!metroLogStream) {
2827
- mkdirSync3(dirname4(metroLogPath(process.cwd())), { recursive: true });
2979
+ mkdirSync4(dirname4(metroLogPath(process.cwd())), { recursive: true });
2828
2980
  metroLogStream = createWriteStream(metroLogPath(process.cwd()), { flags: "w" });
2829
2981
  }
2830
2982
  metroLogStream.write(line.replace(/^\[metro\] /, "") + "\n");
@@ -3807,7 +3959,7 @@ function agentExitCode(env) {
3807
3959
  }
3808
3960
 
3809
3961
  // src/commands/agent.ts
3810
- import { writeFileSync as writeFileSync5 } from "fs";
3962
+ import { writeFileSync as writeFileSync6 } from "fs";
3811
3963
  var REF_RE = /^@?(e\d+)$/;
3812
3964
  var COORDS_RE = /^(-?\d+),\s*(-?\d+)$/;
3813
3965
  var FlagError = class extends Error {
@@ -4017,7 +4169,7 @@ async function runAgentVerb(cmd, positionals, opts) {
4017
4169
  `);
4018
4170
  process.exit(1);
4019
4171
  }
4020
- writeFileSync5(outPath, imageBytes);
4172
+ writeFileSync6(outPath, imageBytes);
4021
4173
  if (opts.json) {
4022
4174
  console.log(JSON.stringify(envelope));
4023
4175
  } else {
@@ -4040,8 +4192,10 @@ export {
4040
4192
  DEV_PLATFORMS,
4041
4193
  PLATFORM_NEEDS_METRO,
4042
4194
  isDevPlatform,
4195
+ platformLabel,
4043
4196
  platformWithArticle,
4044
4197
  loadDevConfig,
4198
+ resolveMetro,
4045
4199
  parseBuildId,
4046
4200
  checkDevBuild,
4047
4201
  buildRows,
@@ -4052,6 +4206,8 @@ export {
4052
4206
  readMetroState,
4053
4207
  killOwnedMetro,
4054
4208
  checkMetro,
4209
+ loadLocalDevConfig,
4210
+ effectiveDevConfig,
4055
4211
  buildCommand,
4056
4212
  formatDuration,
4057
4213
  BUILD_TERMINAL_STATUSES,
@@ -74,6 +74,25 @@ var AuthError = class extends Error {
74
74
  this.name = "AuthError";
75
75
  }
76
76
  };
77
+ var TokenScopeError = class extends AuthError {
78
+ constructor(message) {
79
+ super(message);
80
+ this.name = "TokenScopeError";
81
+ }
82
+ };
83
+ var SCOPE_REFUSAL_PREFIX = "This API token has the 'ci' scope";
84
+ function credentialError(status, text) {
85
+ const detail = extractDetail(text);
86
+ if (status === 401) {
87
+ return detail && /expired/i.test(detail) ? new AuthError(detail) : new AuthError();
88
+ }
89
+ if (status === 403 && detail?.startsWith(SCOPE_REFUSAL_PREFIX)) {
90
+ return new TokenScopeError(
91
+ `${detail} Set VIBEVIEW_API_TOKEN (or --token) to a full-access token for this command.`
92
+ );
93
+ }
94
+ return void 0;
95
+ }
77
96
  function errorExitCode(err, otherwise = 1) {
78
97
  return err instanceof AuthError ? 2 : otherwise;
79
98
  }
@@ -168,7 +187,8 @@ var ApiClient = class {
168
187
  });
169
188
  if (!resp.ok) {
170
189
  const text = await resp.text().catch(() => "");
171
- if (resp.status === 401) throw new AuthError();
190
+ const credErr = credentialError(resp.status, text);
191
+ if (credErr) throw credErr;
172
192
  if (resp.status === 404) throw new NotFoundError(`Not found: ${extractDetail(text) ?? path}`);
173
193
  throw new ApiStatusError(
174
194
  resp.status,
@@ -197,7 +217,9 @@ var ApiClient = class {
197
217
  device_category: options?.device ? void 0 : options?.deviceCategory,
198
218
  device_id: options?.device,
199
219
  build_id: options?.build,
200
- metadata: options?.metadata
220
+ metadata: options?.metadata,
221
+ platform: options?.platform,
222
+ app_id: options?.app
201
223
  });
202
224
  }
203
225
  async runTest(testId, options) {
@@ -210,7 +232,9 @@ var ApiClient = class {
210
232
  device_category: options?.device ? void 0 : options?.deviceCategory,
211
233
  device_id: options?.device,
212
234
  build_id: options?.build,
213
- metadata: options?.metadata
235
+ metadata: options?.metadata,
236
+ platform: options?.platform,
237
+ app_id: options?.app
214
238
  });
215
239
  }
216
240
  /** Cancel a still-queued CI run by its queue entry id. Idempotent best-effort. */
@@ -244,7 +268,8 @@ var ApiClient = class {
244
268
  });
245
269
  if (!resp.ok) {
246
270
  const text = await resp.text().catch(() => "");
247
- if (resp.status === 401) throw new AuthError();
271
+ const credErr = credentialError(resp.status, text);
272
+ if (credErr) throw credErr;
248
273
  if (resp.status === 404) throw new NotFoundError(`Not found: ${extractDetail(text) ?? path}`);
249
274
  throw new ApiStatusError(
250
275
  resp.status,
@@ -267,7 +292,8 @@ var ApiClient = class {
267
292
  });
268
293
  if (!resp.ok) {
269
294
  const text = await resp.text().catch(() => "");
270
- if (resp.status === 401) throw new AuthError();
295
+ const credErr = credentialError(resp.status, text);
296
+ if (credErr) throw credErr;
271
297
  if (resp.status === 404) throw new NotFoundError(`Not found: ${extractDetail(text) ?? path}`);
272
298
  throw new ApiStatusError(
273
299
  resp.status,
@@ -429,7 +455,8 @@ var ApiClient = class {
429
455
  const resp = await this.fetchOrExplain(url, { method: "GET", headers });
430
456
  if (!resp.ok) {
431
457
  const text = await resp.text().catch(() => "");
432
- if (resp.status === 401) throw new AuthError();
458
+ const credErr = credentialError(resp.status, text);
459
+ if (credErr) throw credErr;
433
460
  if (resp.status === 404)
434
461
  throw new NotFoundError(`Not found: ${extractDetail(text) ?? `/builds/${id}/artifact`}`);
435
462
  throw new ApiStatusError(
package/dist/cli.js CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  listBuilds,
16
16
  loadDevConfig,
17
17
  newTransitionState,
18
+ platformLabel,
18
19
  platformWithArticle,
19
20
  pressButtonsHelp,
20
21
  printOutcomeLine,
@@ -22,7 +23,7 @@ import {
22
23
  readDevState,
23
24
  readMetroState,
24
25
  runAgentVerb
25
- } from "./chunk-T6EQQV6E.js";
26
+ } from "./chunk-NOZGZT3G.js";
26
27
  import {
27
28
  ApiClient,
28
29
  ApiStatusError,
@@ -43,7 +44,7 @@ import {
43
44
  resolveAuth,
44
45
  saveConfig,
45
46
  uploadApp
46
- } from "./chunk-PBMB6TG4.js";
47
+ } from "./chunk-ZZK7YQSP.js";
47
48
 
48
49
  // src/cli.ts
49
50
  import { createRequire } from "module";
@@ -570,6 +571,29 @@ function mapExitCode(status) {
570
571
  return EXIT_INFRA_ERROR;
571
572
  }
572
573
 
574
+ // src/lib/run-target.ts
575
+ var RUN_PLATFORMS = ["ios", "android", "tvos", "androidtv"];
576
+ function parseRunPlatform(value) {
577
+ if (value === void 0) return void 0;
578
+ if (!RUN_PLATFORMS.includes(value)) {
579
+ throw new Error(`--platform must be one of ${RUN_PLATFORMS.join(", ")}, got "${value}"`);
580
+ }
581
+ return value;
582
+ }
583
+ function describeRunTarget(response, options) {
584
+ const asked = Boolean(options.platform || options.app);
585
+ if (!response.app_id) {
586
+ if (asked) {
587
+ throw new Error(
588
+ "this VibeView server does not support --platform/--app on test runs yet; the run was stopped rather than run on the test\u2019s own platform"
589
+ );
590
+ }
591
+ return null;
592
+ }
593
+ if (!asked) return null;
594
+ return `${response.app_id} (${platformLabel(response.platform ?? "")})`;
595
+ }
596
+
573
597
  // src/commands/run-suite.ts
574
598
  async function runSuite(suiteId, options) {
575
599
  const { token, baseUrl } = resolveAuth(options);
@@ -635,14 +659,18 @@ ${signal} received.
635
659
  deviceCategory: options.deviceCategory,
636
660
  device: options.device,
637
661
  build: options.build,
638
- metadata: options.metadata
662
+ metadata: options.metadata,
663
+ platform: options.platform,
664
+ app: options.app
639
665
  });
640
666
  sessionId = response.session_id;
641
667
  runId = response.run_id;
668
+ const target = describeRunTarget(response, options);
642
669
  spinner.stop();
643
670
  process.stderr.write(
644
671
  `${chalk3.dim("Suite:")} ${suiteId}
645
- ${chalk3.dim("Run:")} ${response.run_id}
672
+ ` + (target ? `${chalk3.dim("App:")} ${target}
673
+ ` : "") + `${chalk3.dim("Run:")} ${response.run_id}
646
674
  ` + (sessionId ? `${chalk3.dim("Session:")} ${sessionId}
647
675
  ` : "")
648
676
  );
@@ -1009,16 +1037,20 @@ ${signal} received.
1009
1037
  deviceCategory: options.deviceCategory,
1010
1038
  device: options.device,
1011
1039
  build: options.build,
1012
- metadata: options.metadata
1040
+ metadata: options.metadata,
1041
+ platform: options.platform,
1042
+ app: options.app
1013
1043
  });
1014
1044
  sessionId = response.session_id;
1015
1045
  runId = response.run_id;
1046
+ const target = describeRunTarget(response, options);
1016
1047
  spinner.stop();
1017
1048
  process.stderr.write(
1018
1049
  `${chalk5.dim("Session:")} ${sessionId || "unknown"}
1019
1050
  ${chalk5.dim("Device:")} ${response.device_name || "allocating..."}
1020
1051
  ${chalk5.dim("Test:")} ${testId}
1021
- ${chalk5.dim("Run:")} ${response.run_id}
1052
+ ` + (target ? `${chalk5.dim("App:")} ${target}
1053
+ ` : "") + `${chalk5.dim("Run:")} ${response.run_id}
1022
1054
  `
1023
1055
  );
1024
1056
  spinner.start("Waiting for device...");
@@ -3568,11 +3600,24 @@ program.command("run-suite <suite-id>").description("Run all test cases in a sui
3568
3600
  ).option(
3569
3601
  "--device <device_id>",
3570
3602
  "Pin run to a specific device (use `vibeview list-devices` to find one). Conflicts with --device-category."
3603
+ ).option(
3604
+ "--platform <platform>",
3605
+ "Run the suite on another platform (ios, android, tvos, androidtv): runs your app there with the same bundle id / package"
3606
+ ).option(
3607
+ "--app <app-id>",
3608
+ "Run the suite against this app instead of its own (e.g. another platform build with a different bundle id)"
3571
3609
  ).option("--build <id>", "Specific AppBuild id to run against (defaults to latest)").option("--metadata <json>", "JSON object passed through to webhooks (e.g. PR context)").option("--metadata-file <path>", "Read JSON object from a file (use for nested JSON in CI)").action((suiteId, opts) => {
3572
3610
  if (opts.device && opts.deviceCategory) {
3573
3611
  process.stderr.write("error: --device cannot be combined with --device-category\n");
3574
3612
  process.exit(2);
3575
3613
  }
3614
+ try {
3615
+ parseRunPlatform(opts.platform);
3616
+ } catch (err) {
3617
+ process.stderr.write(`error: ${err.message}
3618
+ `);
3619
+ process.exit(2);
3620
+ }
3576
3621
  const metadata = resolveMetadata(opts);
3577
3622
  return runSuite(suiteId, {
3578
3623
  ...opts,
@@ -3601,6 +3646,13 @@ program.command("run-suites").description("Run all suites matching a tag filter"
3601
3646
  process.stderr.write("error: --device cannot be combined with --device-category\n");
3602
3647
  process.exit(2);
3603
3648
  }
3649
+ try {
3650
+ parseRunPlatform(opts.platform);
3651
+ } catch (err) {
3652
+ process.stderr.write(`error: ${err.message}
3653
+ `);
3654
+ process.exit(2);
3655
+ }
3604
3656
  const metadata = resolveMetadata(opts);
3605
3657
  return runSuites({
3606
3658
  ...opts,
@@ -3616,11 +3668,24 @@ program.command("run-test <test-id>").description("Run a single test case").opti
3616
3668
  ).option(
3617
3669
  "--device <device_id>",
3618
3670
  "Pin run to a specific device (use `vibeview list-devices` to find one). Conflicts with --device-category."
3671
+ ).option(
3672
+ "--platform <platform>",
3673
+ "Run the test on another platform (ios, android, tvos, androidtv): runs your app there with the same bundle id / package"
3674
+ ).option(
3675
+ "--app <app-id>",
3676
+ "Run the test against this app instead of its own (e.g. another platform build with a different bundle id)"
3619
3677
  ).option("--build <id>", "Specific AppBuild id to run against (defaults to latest)").option("--metadata <json>", "JSON object passed through to webhooks (e.g. PR context)").option("--metadata-file <path>", "Read JSON object from a file (use for nested JSON in CI)").action((testId, opts) => {
3620
3678
  if (opts.device && opts.deviceCategory) {
3621
3679
  process.stderr.write("error: --device cannot be combined with --device-category\n");
3622
3680
  process.exit(2);
3623
3681
  }
3682
+ try {
3683
+ parseRunPlatform(opts.platform);
3684
+ } catch (err) {
3685
+ process.stderr.write(`error: ${err.message}
3686
+ `);
3687
+ process.exit(2);
3688
+ }
3624
3689
  const metadata = resolveMetadata(opts);
3625
3690
  return runTest(testId, {
3626
3691
  ...opts,
@@ -3669,7 +3734,10 @@ program.command("dev").description(
3669
3734
  ).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(
3670
3735
  "--platform <platform>",
3671
3736
  "ios, android, tvos, androidtv, or roku (first run is interactive otherwise)"
3672
- ).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(
3737
+ ).option("--app <app_id>", "Uploaded debug build to run (see `vibeview list-apps`)").option(
3738
+ "--metro-port <port>",
3739
+ "Local Metro port (default: 8081, or vibeview.json); remembered for you in .vibeview/local.json"
3740
+ ).option(
3673
3741
  "--device-category <category>",
3674
3742
  "phone or tablet (default: phone; TV platforms always use tv)",
3675
3743
  parseDeviceCategory
@@ -3831,7 +3899,7 @@ program.command("dev-reload").description(
3831
3899
  program.command("mcp").description(
3832
3900
  "Run an MCP stdio server exposing the agent verbs (tap, swipe, type, ...) plus dev_start/dev_stop/dev_reload as tools"
3833
3901
  ).option("--token <token>", "API token (or set VIBEVIEW_API_TOKEN)").action(async (opts) => {
3834
- const { runMcpServer } = await import("./server-IGY2VPYT.js");
3902
+ const { runMcpServer } = await import("./server-2JNYWF5L.js");
3835
3903
  await runMcpServer({ ...opts, version: pkg.version });
3836
3904
  });
3837
3905
  program.command("agent-setup").description(
@@ -11,22 +11,25 @@ import {
11
11
  checkMetro,
12
12
  createRokuPush,
13
13
  decodeScreenshotResult,
14
+ effectiveDevConfig,
14
15
  filterDeviceModels,
15
16
  formatBuildRowsHuman,
16
17
  formatDeviceModelsHuman,
17
18
  isDevPlatform,
18
19
  loadDevConfig,
20
+ loadLocalDevConfig,
19
21
  parseBuildId,
20
22
  platformWithArticle,
21
23
  readDevState,
22
24
  renderEnvelope,
25
+ resolveMetro,
23
26
  startDevSession
24
- } from "./chunk-T6EQQV6E.js";
27
+ } from "./chunk-NOZGZT3G.js";
25
28
  import {
26
29
  ApiClient,
27
30
  NotFoundError,
28
31
  resolveAuth
29
- } from "./chunk-PBMB6TG4.js";
32
+ } from "./chunk-ZZK7YQSP.js";
30
33
 
31
34
  // src/mcp/server.ts
32
35
  import { writeFileSync } from "fs";
@@ -539,7 +542,7 @@ async function handleUploadApp(args, deps) {
539
542
  }
540
543
  const name = typeof args.name === "string" && args.name ? args.name : void 0;
541
544
  const upload = deps.uploadApp ?? (async (f, n) => {
542
- const { uploadAppBuild } = await import("./upload-app-FEZG23NU.js");
545
+ const { uploadAppBuild } = await import("./upload-app-2LVG4LMB.js");
543
546
  const { result: result2 } = await uploadAppBuild(f, { ...deps.auth, name: n });
544
547
  return result2;
545
548
  });
@@ -624,6 +627,7 @@ function createDevSessionController(auth, webUrl) {
624
627
  } catch {
625
628
  devConfig = null;
626
629
  }
630
+ devConfig = effectiveDevConfig(devConfig, loadLocalDevConfig(process.cwd()));
627
631
  const entry = devConfig?.platforms?.[cfg.platform];
628
632
  const appId = cfg.app || entry?.appId;
629
633
  if (!appId) {
@@ -649,7 +653,7 @@ function createDevSessionController(auth, webUrl) {
649
653
  }
650
654
  const standalone = cfg.standalone === true;
651
655
  const usesMetro = PLATFORM_NEEDS_METRO[cfg.platform] && !standalone;
652
- const metroPort = cfg.metroPort ?? devConfig?.metroPort ?? 8081;
656
+ const metroPort = resolveMetro(devConfig, cfg.platform, cfg.metroPort).port;
653
657
  if (usesMetro) {
654
658
  const metro = await checkMetro(metroPort);
655
659
  if (metro === "down") {
@@ -3,7 +3,7 @@ import {
3
3
  describeBuild,
4
4
  uploadApp,
5
5
  uploadAppBuild
6
- } from "./chunk-PBMB6TG4.js";
6
+ } from "./chunk-ZZK7YQSP.js";
7
7
  export {
8
8
  describeBuild,
9
9
  uploadApp,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibeview",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "VibeView CLI -- run your app on cloud iOS simulators, Android emulators, Apple TV, Android TV and Roku (beta) devices: a React Native dev loop, device control for you or your coding agent (CLI or MCP), and tests from CI",
5
5
  "mcpName": "io.github.vibeview/vibeview",
6
6
  "type": "module",
@@ -64,11 +64,15 @@ https://vibeview.io/docs/preparing-your-build.
64
64
  the page is `https://vibeview.io/sandbox/<session_id>`.)
65
65
 
66
66
  Running `dev` writes two things into the project root: a `.vibeview/`
67
- directory (live session state — local machine state, never commit) and a
68
- `vibeview.json` (your platform/app/build choices, which teammates DO want).
69
- Make sure `.vibeview/` is in the project's `.gitignore`, adding it if
70
- missing. Leave `vibeview.json` tracked, but tell the developer you created
71
- it rather than letting it appear unexplained in their `git status`.
67
+ directory (live session state, plus `local.json` with this developer's own
68
+ flag overrides such as `--metro-port` — local machine state, never commit)
69
+ and a `vibeview.json` (the platform/app/build setup, which teammates DO
70
+ want). Flags never rewrite a value `vibeview.json` already has; they are
71
+ saved to `.vibeview/local.json` instead, and the CLI prints what it saved.
72
+ Make sure the project's `.gitignore` has `.vibeview/*` and
73
+ `!.vibeview/test-spec.json`, adding them if missing. Leave `vibeview.json`
74
+ tracked, but tell the developer you created it rather than letting it
75
+ appear unexplained in their `git status`.
72
76
 
73
77
  ## 3. Verifying
74
78