vibeview 0.11.0 → 0.12.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.
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  ApiClient,
4
+ createSpinner,
4
5
  loadConfig,
5
6
  resolveAuth,
6
7
  uploadAppBuild
7
- } from "./chunk-AG3RZCPW.js";
8
+ } from "./chunk-OHRTLUBD.js";
8
9
 
9
10
  // src/lib/dev-state.ts
10
11
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
@@ -72,6 +73,7 @@ function clearDevState(cwd) {
72
73
  import { readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
73
74
  import { join as join2 } from "path";
74
75
  var DEV_PLATFORMS = ["ios", "android", "tvos", "androidtv", "roku"];
76
+ var ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
75
77
  var PLATFORM_NEEDS_METRO = {
76
78
  ios: true,
77
79
  android: true,
@@ -165,6 +167,26 @@ function loadDevConfig(cwd) {
165
167
  }
166
168
  return cfg;
167
169
  }
170
+ function validateBuildEnv(env, platformKey) {
171
+ if (env === void 0) return;
172
+ if (typeof env !== "object" || env === null || Array.isArray(env)) {
173
+ throw new Error(
174
+ `vibeview.json: platforms.${platformKey}.build.env must be an object of string values`
175
+ );
176
+ }
177
+ for (const [envKey, envValue] of Object.entries(env)) {
178
+ if (typeof envValue !== "string") {
179
+ throw new Error(
180
+ `vibeview.json: platforms.${platformKey}.build.env must be an object of string values`
181
+ );
182
+ }
183
+ if (!ENV_KEY_RE.test(envKey)) {
184
+ throw new Error(
185
+ `vibeview.json: platforms.${platformKey}.build.env key "${envKey}" is not a valid environment variable name (must match ${ENV_KEY_RE})`
186
+ );
187
+ }
188
+ }
189
+ }
168
190
  function saveDevConfig(cwd, cfg) {
169
191
  writeFileSync2(join2(cwd, FILE), JSON.stringify(cfg, null, 2) + "\n");
170
192
  }
@@ -198,7 +220,7 @@ function buildSavedConfig(input2) {
198
220
  [platform]: {
199
221
  appId,
200
222
  deviceCategory: isTv ? void 0 : deviceCategory,
201
- ...existing?.platforms?.[platform]?.build ? { build: existing.platforms[platform].build } : {},
223
+ ...existing?.platforms?.[platform]?.build ? { build: { ...existing.platforms[platform].build } } : {},
202
224
  ...isFirstRun && firstRunBuild ? { build: firstRunBuild } : {},
203
225
  // Never invented — absent means "no extra ignores", and first-run
204
226
  // setup does not ask for it. Only ever carried forward.
@@ -224,7 +246,7 @@ async function checkMetro(port, timeoutMs = 1500) {
224
246
  }
225
247
 
226
248
  // src/commands/build.ts
227
- import { spawn } from "child_process";
249
+ import { spawn as spawn2 } from "child_process";
228
250
  import { createInterface } from "readline";
229
251
  import { select as select2 } from "@inquirer/prompts";
230
252
  import chalk from "chalk";
@@ -436,7 +458,187 @@ async function promptMetroCommand(cwd, platform) {
436
458
  return input({ message: "Metro command", required: true });
437
459
  }
438
460
 
461
+ // src/lib/source-pack.ts
462
+ import { execFile, spawn } from "child_process";
463
+ import { lstatSync, mkdtempSync as mkdtempSync2, readFileSync as readFileSync4, rmSync as rmSync3, statSync as statSync3, writeFileSync as writeFileSync3 } from "fs";
464
+ import { tmpdir as tmpdir2 } from "os";
465
+ import { join as join5 } from "path";
466
+ import ignore from "ignore";
467
+ var NotAGitRepoError = class extends Error {
468
+ constructor(message) {
469
+ super(message);
470
+ this.name = "NotAGitRepoError";
471
+ }
472
+ };
473
+ var SourceTooLargeError = class extends Error {
474
+ constructor(message, largest) {
475
+ super(message);
476
+ this.largest = largest;
477
+ this.name = "SourceTooLargeError";
478
+ }
479
+ };
480
+ var FORCE_EXCLUDE_PREFIXES = ["node_modules/", "ios/Pods/", ".git/"];
481
+ function isForceExcluded(relPath) {
482
+ for (const prefix of FORCE_EXCLUDE_PREFIXES) {
483
+ if (relPath.startsWith(prefix) || relPath.includes(`/${prefix}`)) return true;
484
+ }
485
+ return false;
486
+ }
487
+ async function listSourceFiles(projectRoot) {
488
+ const stdout = await runGitLsFiles(projectRoot);
489
+ const rawPaths = stdout.length ? stdout.split("\0").filter((p) => p.length > 0) : [];
490
+ let filtered = rawPaths;
491
+ const vvignorePath = join5(projectRoot, ".vvignore");
492
+ if (statOrNull(vvignorePath)?.isFile()) {
493
+ const ig = ignore().add(readFileSync4(vvignorePath, "utf-8"));
494
+ filtered = filtered.filter((p) => !ig.ignores(p));
495
+ }
496
+ const result = [];
497
+ for (const relPath of filtered) {
498
+ if (isForceExcluded(relPath)) continue;
499
+ const st = statOrNull(join5(projectRoot, relPath));
500
+ if (!st) continue;
501
+ result.push(relPath);
502
+ }
503
+ return result;
504
+ }
505
+ function statOrNull(path) {
506
+ try {
507
+ return lstatSync(path);
508
+ } catch {
509
+ return null;
510
+ }
511
+ }
512
+ function runGitLsFiles(projectRoot) {
513
+ return new Promise((resolve, reject) => {
514
+ const child = spawn("git", ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], {
515
+ cwd: projectRoot,
516
+ stdio: ["ignore", "pipe", "pipe"]
517
+ });
518
+ const stdoutChunks = [];
519
+ const stderrChunks = [];
520
+ child.stdout.on("data", (c) => stdoutChunks.push(c));
521
+ child.stderr.on("data", (c) => stderrChunks.push(c));
522
+ child.on("error", (err) => {
523
+ reject(
524
+ new NotAGitRepoError(
525
+ `failed to run git in ${projectRoot}: ${err.message} \u2014 is git installed and on PATH?`
526
+ )
527
+ );
528
+ });
529
+ child.on("close", (code) => {
530
+ const stderr = Buffer.concat(stderrChunks).toString("utf-8");
531
+ if (code !== 0) {
532
+ if (/not a git repository/i.test(stderr)) {
533
+ reject(new NotAGitRepoError(`${projectRoot} is not a git repository`));
534
+ return;
535
+ }
536
+ reject(new Error(`git ls-files failed (exit ${code}) in ${projectRoot}: ${stderr.trim()}`));
537
+ return;
538
+ }
539
+ resolve(Buffer.concat(stdoutChunks).toString("utf-8"));
540
+ });
541
+ });
542
+ }
543
+ var DEFAULT_CAP_BYTES = 2 * 1024 * 1024 * 1024;
544
+ async function packSource(projectRoot, files, opts = {}) {
545
+ const capBytes = opts.capBytes ?? DEFAULT_CAP_BYTES;
546
+ let totalBytes = 0;
547
+ const sized = [];
548
+ for (const relPath of files) {
549
+ const bytes = lstatSync(join5(projectRoot, relPath)).size;
550
+ totalBytes += bytes;
551
+ sized.push({ path: relPath, bytes });
552
+ }
553
+ if (totalBytes > capBytes) {
554
+ const largest = sized.slice().sort((a, b) => b.bytes - a.bytes).slice(0, 5);
555
+ throw new SourceTooLargeError(
556
+ `source exceeds the ${formatBytes(capBytes)} pack limit (${formatBytes(totalBytes)} across ${files.length} files)`,
557
+ largest
558
+ );
559
+ }
560
+ const tmpDir = mkdtempSync2(join5(tmpdir2(), "vibeview-source-pack-"));
561
+ const cleanup = () => rmSync3(tmpDir, { recursive: true, force: true });
562
+ try {
563
+ const supportsNull = await probeTarNullSupport();
564
+ const tarPath = join5(tmpDir, "source.tgz");
565
+ if (supportsNull) {
566
+ const listPath = join5(tmpDir, "files.list");
567
+ writeFileSync3(listPath, files.map((f) => `${f}\0`).join(""));
568
+ await runTar(["-czf", tarPath, "-C", projectRoot, "--null", "-T", listPath]);
569
+ } else {
570
+ const badName = files.find((f) => f.includes("\n"));
571
+ if (badName) {
572
+ throw new Error(
573
+ `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`
574
+ );
575
+ }
576
+ const listPath = join5(tmpDir, "files.list");
577
+ writeFileSync3(listPath, files.map((f) => `${f}
578
+ `).join(""));
579
+ await runTar(["-czf", tarPath, "-C", projectRoot, "-T", listPath]);
580
+ }
581
+ const sizeBytes = statSync3(tarPath).size;
582
+ return { tarPath, sizeBytes, cleanup };
583
+ } catch (err) {
584
+ cleanup();
585
+ throw err;
586
+ }
587
+ }
588
+ function formatBytes(bytes) {
589
+ const mb = bytes / (1024 * 1024);
590
+ if (mb >= 1024) return `${(mb / 1024).toFixed(2)} GiB`;
591
+ return `${mb.toFixed(1)} MiB`;
592
+ }
593
+ var nullSupportCache = null;
594
+ function probeTarNullSupport() {
595
+ if (!nullSupportCache) {
596
+ nullSupportCache = new Promise((resolve) => {
597
+ const tmp = mkdtempSync2(join5(tmpdir2(), "vibeview-tar-probe-"));
598
+ const listPath = join5(tmp, "empty.list");
599
+ writeFileSync3(listPath, "");
600
+ const out = join5(tmp, "probe.tgz");
601
+ execFile("tar", ["-czf", out, "-C", tmp, "--null", "-T", listPath], (err) => {
602
+ rmSync3(tmp, { recursive: true, force: true });
603
+ resolve(!err);
604
+ });
605
+ });
606
+ }
607
+ return nullSupportCache;
608
+ }
609
+ function runTar(args) {
610
+ return new Promise((resolve, reject) => {
611
+ const child = spawn("tar", args, {
612
+ stdio: ["ignore", "pipe", "pipe"],
613
+ env: { ...process.env, COPYFILE_DISABLE: "1" }
614
+ });
615
+ const stderrChunks = [];
616
+ child.stderr.on("data", (c) => stderrChunks.push(c));
617
+ child.on("error", (err) => {
618
+ reject(new Error(`failed to run tar: ${err.message} \u2014 is tar installed and on PATH?`));
619
+ });
620
+ child.on("close", (code) => {
621
+ if (code !== 0) {
622
+ const stderr = Buffer.concat(stderrChunks).toString("utf-8").trim();
623
+ reject(new Error(`tar failed (exit ${code}): ${stderr}`));
624
+ return;
625
+ }
626
+ resolve();
627
+ });
628
+ });
629
+ }
630
+
439
631
  // src/commands/build.ts
632
+ var CLOUD_PLATFORMS = ["ios", "android", "tvos", "androidtv"];
633
+ function isCloudPlatform(p) {
634
+ return CLOUD_PLATFORMS.includes(p);
635
+ }
636
+ var BuildFailError = class extends Error {
637
+ constructor(message) {
638
+ super(message);
639
+ this.name = "BuildFailError";
640
+ }
641
+ };
440
642
  var TAIL_LINES = 30;
441
643
  async function runBuildForPlatform(cwd, platform, build, opts, log, deps = { upload: uploadAppBuild }) {
442
644
  const tail = await runShell(build.command, cwd, log);
@@ -463,7 +665,7 @@ async function runBuildForPlatform(cwd, platform, build, opts, log, deps = { upl
463
665
  }
464
666
  function runShell(command, cwd, log) {
465
667
  return new Promise((resolve) => {
466
- const child = spawn(command, { cwd, shell: true, stdio: ["ignore", "pipe", "pipe"] });
668
+ const child = spawn2(command, { cwd, shell: true, stdio: ["ignore", "pipe", "pipe"] });
467
669
  const lines = [];
468
670
  const onLine = (line) => {
469
671
  lines.push(line);
@@ -476,9 +678,7 @@ function runShell(command, cwd, log) {
476
678
  child.on("error", () => resolve({ code: 127, lines }));
477
679
  });
478
680
  }
479
- async function buildCommand(opts) {
480
- const cwd = process.cwd();
481
- const cfg = loadDevConfig(cwd);
681
+ async function resolveBarePlatform(cwd, cfg, opts) {
482
682
  if (opts.platform && !isDevPlatform(opts.platform)) {
483
683
  console.error(chalk.red(`error: --platform must be one of ${DEV_PLATFORMS.join(", ")}`));
484
684
  process.exit(2);
@@ -491,35 +691,42 @@ async function buildCommand(opts) {
491
691
  "no platform: pass --platform or run `vibeview dev` once to set up vibeview.json",
492
692
  "config_required"
493
693
  );
494
- return;
495
694
  }
496
695
  platform = await select2({
497
696
  message: "Platform to run on",
498
697
  choices: platformChoices(cwd)
499
698
  });
500
699
  }
501
- let build = cfg?.platforms?.[platform]?.build;
502
- if (!build) {
503
- if (opts.json || !process.stdin.isTTY) {
504
- fail(
505
- opts,
506
- `no build config for ${platform}: add platforms.${platform}.build {command, artifact} to vibeview.json`,
507
- "config_required"
508
- );
509
- return;
510
- }
511
- const answered = await promptBuildConfig(cwd, platform);
512
- if (!answered) {
513
- fail(opts, "build config is required to run vibeview build", "config_required");
700
+ return platform;
701
+ }
702
+ async function buildCommand(opts, cloudDeps) {
703
+ const cwd = process.cwd();
704
+ const cfg = loadDevConfig(cwd);
705
+ try {
706
+ if (opts.cloud) {
707
+ await runCloudBuildCommand(cwd, cfg, opts, cloudDeps);
514
708
  return;
515
709
  }
516
- build = answered;
517
- if (cfg?.platforms?.[platform]) {
518
- cfg.platforms[platform].build = build;
519
- saveDevConfig(cwd, cfg);
710
+ const platform = await resolveBarePlatform(cwd, cfg, opts);
711
+ let build = cfg?.platforms?.[platform]?.build;
712
+ if (!build) {
713
+ if (opts.json || !process.stdin.isTTY) {
714
+ fail(
715
+ opts,
716
+ `no build config for ${platform}: add platforms.${platform}.build {command, artifact} to vibeview.json`,
717
+ "config_required"
718
+ );
719
+ }
720
+ const answered = await promptBuildConfig(cwd, platform);
721
+ if (!answered) {
722
+ fail(opts, "build config is required to run vibeview build", "config_required");
723
+ }
724
+ build = answered;
725
+ if (cfg?.platforms?.[platform]) {
726
+ cfg.platforms[platform].build = build;
727
+ saveDevConfig(cwd, cfg);
728
+ }
520
729
  }
521
- }
522
- try {
523
730
  const result = await runBuildForPlatform(
524
731
  cwd,
525
732
  platform,
@@ -549,17 +756,333 @@ async function buildCommand(opts) {
549
756
  );
550
757
  }
551
758
  } catch (err) {
552
- fail(opts, err instanceof Error ? err.message : String(err));
759
+ if (!(err instanceof BuildFailError)) {
760
+ printFailure(opts, err instanceof Error ? err.message : String(err));
761
+ }
762
+ process.exitCode = 1;
553
763
  }
554
764
  }
555
- function fail(opts, message, code) {
765
+ function printFailure(opts, message, code) {
556
766
  if (opts.json) console.log(JSON.stringify({ event: "error", code, message }));
557
767
  console.error(chalk.red(`error: ${message}`));
558
- process.exit(1);
768
+ }
769
+ function fail(opts, message, code) {
770
+ printFailure(opts, message, code);
771
+ throw new BuildFailError(message);
772
+ }
773
+ function emitCloud(json, evt) {
774
+ if (json) console.log(JSON.stringify(evt));
775
+ }
776
+ async function resolveCloudPlatforms(cwd, cfg, opts) {
777
+ if (opts.platform === "all") {
778
+ const configured = Object.entries(cfg?.platforms ?? {});
779
+ const withBuild = configured.filter(([p, entry]) => isCloudPlatform(p) && entry?.build);
780
+ if (withBuild.length === 0) {
781
+ fail(
782
+ opts,
783
+ `--platform all: ${describeAllExclusions(configured)} \u2014 add platforms.<platform>.build {command, artifact} to vibeview.json`,
784
+ "config_required"
785
+ );
786
+ }
787
+ return withBuild.map(([p]) => p);
788
+ }
789
+ const platform = opts.platform ? validateExplicitPlatform(opts.platform) : await resolveBarePlatform(cwd, cfg, opts);
790
+ requireCloudCapable(opts, platform);
791
+ requireBuildBlock(opts, cfg, platform);
792
+ return [platform];
793
+ }
794
+ function validateExplicitPlatform(raw) {
795
+ if (!isDevPlatform(raw)) {
796
+ console.error(
797
+ chalk.red(`error: --platform must be one of ${DEV_PLATFORMS.join(", ")}, or "all"`)
798
+ );
799
+ process.exit(2);
800
+ }
801
+ return raw;
802
+ }
803
+ function requireCloudCapable(opts, platform) {
804
+ if (!isCloudPlatform(platform)) {
805
+ fail(
806
+ opts,
807
+ `cloud builds don't support ${platform} \u2014 pass one of ${CLOUD_PLATFORMS.join(", ")}`
808
+ );
809
+ }
810
+ }
811
+ function requireBuildBlock(opts, cfg, platform) {
812
+ if (!cfg?.platforms?.[platform]?.build) {
813
+ fail(
814
+ opts,
815
+ `no build config for ${platform}: add platforms.${platform}.build {command, artifact} to vibeview.json`,
816
+ "config_required"
817
+ );
818
+ }
819
+ }
820
+ function describeAllExclusions(configured) {
821
+ if (configured.length === 0) return "no platforms are configured";
822
+ const unsupported = configured.filter(([p]) => !isCloudPlatform(p)).map(([p]) => p);
823
+ const missingBuild = configured.filter(([p, entry]) => isCloudPlatform(p) && !entry?.build).map(([p]) => p);
824
+ const parts = [];
825
+ if (unsupported.length > 0) {
826
+ parts.push(
827
+ `${unsupported.join(", ")} ${unsupported.length === 1 ? "isn't" : "aren't"} supported for cloud builds`
828
+ );
829
+ }
830
+ if (missingBuild.length > 0) {
831
+ parts.push(
832
+ `${missingBuild.join(", ")} ${missingBuild.length === 1 ? "has" : "have"} no build block`
833
+ );
834
+ }
835
+ return parts.join("; ");
836
+ }
837
+ var POLL_INTERVAL_MS = 2e3;
838
+ var POLL_SAFETY_MARGIN_MS = 10 * 60 * 1e3;
839
+ function defaultCloudBuildDeps(client) {
840
+ return {
841
+ listSourceFiles,
842
+ packSource,
843
+ createCloudBuilds: client.createCloudBuilds.bind(client),
844
+ getCloudBuildLogs: client.getCloudBuildLogs.bind(client),
845
+ getCloudBuild: client.getCloudBuild.bind(client),
846
+ downloadCloudBuildArtifact: client.downloadCloudBuildArtifact.bind(client)
847
+ };
848
+ }
849
+ function formatMB(bytes) {
850
+ return (bytes / (1024 * 1024)).toFixed(1);
851
+ }
852
+ function formatDuration(startedAt, finishedAt) {
853
+ if (!startedAt || !finishedAt) return null;
854
+ const ms = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
855
+ if (!Number.isFinite(ms) || ms < 0) return null;
856
+ if (ms < 1e3) return `${ms}ms`;
857
+ return `${(ms / 1e3).toFixed(1)}s`;
858
+ }
859
+ function formatCost(costMicro) {
860
+ return `$${(costMicro / 1e6).toFixed(2)}`;
861
+ }
862
+ async function runCloudBuildCommand(cwd, cfg, opts, deps) {
863
+ const platforms = await resolveCloudPlatforms(cwd, cfg, opts);
864
+ for (const p of platforms) {
865
+ try {
866
+ validateBuildEnv(cfg?.platforms?.[p]?.build?.env, p);
867
+ } catch (err) {
868
+ fail(opts, err instanceof Error ? err.message : String(err));
869
+ }
870
+ }
871
+ const { token, baseUrl } = resolveAuth(opts);
872
+ const client = new ApiClient(baseUrl, token);
873
+ const d = deps ?? defaultCloudBuildDeps(client);
874
+ let cleanupTar;
875
+ try {
876
+ let files;
877
+ try {
878
+ files = await d.listSourceFiles(cwd);
879
+ } catch (err) {
880
+ if (err instanceof NotAGitRepoError) {
881
+ fail(
882
+ opts,
883
+ `${err.message} \u2014 cloud builds pack your project source via git; run this from inside a git repository`
884
+ );
885
+ }
886
+ throw err;
887
+ }
888
+ const packSpinner = !opts.json ? createSpinner(`packing source (${files.length} files)`) : null;
889
+ packSpinner?.start();
890
+ let tarPath;
891
+ let sizeBytes;
892
+ try {
893
+ const packed = await d.packSource(cwd, files);
894
+ tarPath = packed.tarPath;
895
+ sizeBytes = packed.sizeBytes;
896
+ cleanupTar = packed.cleanup;
897
+ } catch (err) {
898
+ packSpinner?.stop();
899
+ if (err instanceof SourceTooLargeError) {
900
+ const offenders = err.largest.map((f) => ` ${f.path} (${formatMB(f.bytes)} MB)`).join("\n");
901
+ fail(opts, `${err.message}
902
+ Largest files:
903
+ ${offenders}`);
904
+ }
905
+ throw err;
906
+ }
907
+ packSpinner?.succeed(`packed source (${files.length} files, ${formatMB(sizeBytes)} MB)`);
908
+ const manifestEntries = platforms.map((p) => {
909
+ const entry = cfg.platforms[p];
910
+ const build = entry.build;
911
+ return {
912
+ platform: p,
913
+ app_public_id: entry.appId,
914
+ command: build.command,
915
+ artifact_path: build.artifact,
916
+ ...build.env ? { env: build.env } : {}
917
+ };
918
+ });
919
+ const uploadSpinner = !opts.json ? createSpinner("uploading source...") : null;
920
+ uploadSpinner?.start();
921
+ let created;
922
+ try {
923
+ const resp = await d.createCloudBuilds({ builds: manifestEntries }, tarPath);
924
+ created = resp.builds;
925
+ } catch (err) {
926
+ uploadSpinner?.stop();
927
+ const message = err instanceof Error ? err.message : String(err);
928
+ fail(opts, message);
929
+ }
930
+ uploadSpinner?.succeed(`uploaded \u2014 ${created.length} build(s) started`);
931
+ for (const b of created) {
932
+ emitCloud(opts.json, { event: "build_created", id: b.id, platform: b.platform });
933
+ }
934
+ if (!opts.json) {
935
+ for (const b of created) {
936
+ console.error(` ${chalk.cyan(b.id)} ${chalk.dim(`[${b.platform}]`)}`);
937
+ }
938
+ }
939
+ const shouldWait = opts.wait !== false || opts.download;
940
+ if (!shouldWait) {
941
+ process.exitCode = 0;
942
+ return;
943
+ }
944
+ const finished = await waitForCloudBuilds(created, opts, d);
945
+ let downloadFailed = false;
946
+ if (opts.download) {
947
+ for (const b of finished.values()) {
948
+ if (b.status !== "succeeded") continue;
949
+ try {
950
+ const result = await d.downloadCloudBuildArtifact(b.id, cwd);
951
+ emitCloud(opts.json, {
952
+ event: "artifact_saved",
953
+ id: b.id,
954
+ filename: result.filename,
955
+ path: result.path
956
+ });
957
+ if (!opts.json) {
958
+ console.error(chalk.green(`\u2713 saved ${result.filename}`));
959
+ }
960
+ } catch (err) {
961
+ downloadFailed = true;
962
+ const message = err instanceof Error ? err.message : String(err);
963
+ emitCloud(opts.json, {
964
+ event: "error",
965
+ message: `error downloading ${b.id}: ${message}`
966
+ });
967
+ if (!opts.json) console.error(chalk.red(`error downloading ${b.id}: ${message}`));
968
+ }
969
+ }
970
+ }
971
+ const allSucceeded = Array.from(finished.values()).every((b) => b.status === "succeeded");
972
+ process.exitCode = allSucceeded && !downloadFailed ? 0 : 1;
973
+ } finally {
974
+ cleanupTar?.();
975
+ }
976
+ }
977
+ var BUILD_TERMINAL_STATUSES = /* @__PURE__ */ new Set([
978
+ "succeeded",
979
+ "failed",
980
+ "canceled",
981
+ "timed_out",
982
+ "infra_failed"
983
+ ]);
984
+ var MAX_CONSECUTIVE_POLL_FAILURES = 5;
985
+ async function waitForCloudBuilds(builds, opts, deps) {
986
+ const now = deps.now ?? Date.now;
987
+ const pollIntervalMs = deps.pollIntervalMs ?? POLL_INTERVAL_MS;
988
+ const multi = builds.length > 1;
989
+ const state = new Map(builds.map((b) => [b.id, b]));
990
+ const offsets = new Map(builds.map((b) => [b.id, 0]));
991
+ const failures = new Map(builds.map((b) => [b.id, 0]));
992
+ const abandoned = /* @__PURE__ */ new Set();
993
+ let lastError;
994
+ const maxTimeoutS = Math.max(...builds.map((b) => b.timeout_s), 0);
995
+ const deadline = now() + maxTimeoutS * 1e3 + POLL_SAFETY_MARGIN_MS;
996
+ const printLine = (id, platform, line) => {
997
+ const prefix = multi ? `[${platform}] ` : "";
998
+ if (!opts.json) console.error(prefix + line);
999
+ };
1000
+ const stillWaiting = () => Array.from(state.values()).some(
1001
+ (b) => !BUILD_TERMINAL_STATUSES.has(b.status) && !abandoned.has(b.id)
1002
+ );
1003
+ while (stillWaiting()) {
1004
+ if (now() > deadline) {
1005
+ throw new Error(
1006
+ `cloud build poll timed out after exceeding the ${Math.round((maxTimeoutS * 1e3 + POLL_SAFETY_MARGIN_MS) / 1e3)}s safety limit \u2014 the backend never reported a terminal status`
1007
+ );
1008
+ }
1009
+ for (const build of state.values()) {
1010
+ if (BUILD_TERMINAL_STATUSES.has(build.status) || abandoned.has(build.id)) continue;
1011
+ try {
1012
+ const offset = offsets.get(build.id) ?? 0;
1013
+ const logs = await deps.getCloudBuildLogs(build.id, offset);
1014
+ if (logs.content) {
1015
+ emitCloud(opts.json, { event: "build_log", id: build.id, content: logs.content });
1016
+ for (const line of logs.content.split("\n")) {
1017
+ if (line.length === 0) continue;
1018
+ printLine(build.id, build.platform, line);
1019
+ }
1020
+ }
1021
+ offsets.set(build.id, logs.offset);
1022
+ const updated = { ...build, status: logs.status };
1023
+ state.set(build.id, updated);
1024
+ failures.set(build.id, 0);
1025
+ if (BUILD_TERMINAL_STATUSES.has(logs.status)) {
1026
+ const final = await fetchFinalSummary(build.id, updated, deps);
1027
+ state.set(build.id, final);
1028
+ emitCloud(opts.json, {
1029
+ event: "build_finished",
1030
+ id: final.id,
1031
+ status: final.status,
1032
+ cost_micro: final.cost_micro,
1033
+ error_summary: final.error_summary
1034
+ });
1035
+ if (!opts.json) {
1036
+ printOutcomeLine(final, multi);
1037
+ }
1038
+ }
1039
+ } catch (err) {
1040
+ lastError = err;
1041
+ const count = (failures.get(build.id) ?? 0) + 1;
1042
+ failures.set(build.id, count);
1043
+ if (count >= MAX_CONSECUTIVE_POLL_FAILURES) {
1044
+ abandoned.add(build.id);
1045
+ const message = err instanceof Error ? err.message : String(err);
1046
+ const giveUp = `giving up polling ${build.id} after ${count} consecutive failures: ${message} \u2014 the build may still be running server-side; resume watching it with \`vibeview builds logs ${build.id} --follow\``;
1047
+ emitCloud(opts.json, { event: "error", message: giveUp });
1048
+ if (!opts.json) console.error(chalk.red(giveUp));
1049
+ }
1050
+ }
1051
+ }
1052
+ if (stillWaiting()) {
1053
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
1054
+ }
1055
+ }
1056
+ if (abandoned.size > 0 && !Array.from(state.values()).some((b) => BUILD_TERMINAL_STATUSES.has(b.status))) {
1057
+ const message = lastError instanceof Error ? lastError.message : String(lastError);
1058
+ throw new Error(
1059
+ `cloud build poll failed ${MAX_CONSECUTIVE_POLL_FAILURES} times in a row, giving up: ${message}
1060
+ The build may still be running server-side \u2014 resume watching it with \`vibeview builds logs <id> --follow\`.`
1061
+ );
1062
+ }
1063
+ return state;
1064
+ }
1065
+ async function fetchFinalSummary(id, fallback, deps) {
1066
+ try {
1067
+ return await deps.getCloudBuild(id);
1068
+ } catch {
1069
+ return fallback;
1070
+ }
1071
+ }
1072
+ function printOutcomeLine(build, multi) {
1073
+ const prefix = multi ? `[${build.platform}] ` : "";
1074
+ const ok = build.status === "succeeded";
1075
+ const mark = ok ? chalk.green("\u2713") : chalk.red("\u2717");
1076
+ const duration = formatDuration(build.started_at, build.finished_at);
1077
+ let line = `${prefix}${mark} ${build.status}`;
1078
+ if (duration) line += ` (${duration})`;
1079
+ if (build.error_summary) line += ` \u2014 ${build.error_summary}`;
1080
+ if (build.cost_micro > 0) line += ` \u2014 cost: ${formatCost(build.cost_micro)} usage credit`;
1081
+ console.error(line);
559
1082
  }
560
1083
 
561
1084
  // src/commands/dev.ts
562
- import { spawn as spawn3 } from "child_process";
1085
+ import { spawn as spawn4 } from "child_process";
563
1086
  import { createWriteStream, mkdirSync as mkdirSync3 } from "fs";
564
1087
  import { dirname as dirname5 } from "path";
565
1088
  import { createInterface as createInterface3 } from "readline";
@@ -732,19 +1255,19 @@ var TunnelClient = class {
732
1255
  };
733
1256
 
734
1257
  // src/lib/metro-manager.ts
735
- import { spawn as spawn2 } from "child_process";
1258
+ import { spawn as spawn3 } from "child_process";
736
1259
  import { createInterface as createInterface2 } from "readline";
737
1260
 
738
1261
  // src/lib/metro-state.ts
739
- import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync4, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "fs";
740
- import { dirname as dirname3, join as join5 } from "path";
1262
+ import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync5, rmSync as rmSync4, writeFileSync as writeFileSync4 } from "fs";
1263
+ import { dirname as dirname3, join as join6 } from "path";
741
1264
  var STATE_DIR2 = ".vibeview";
742
1265
  var STATE_FILE2 = "metro-state.json";
743
1266
  function statePath2(cwd) {
744
- return join5(cwd, STATE_DIR2, STATE_FILE2);
1267
+ return join6(cwd, STATE_DIR2, STATE_FILE2);
745
1268
  }
746
1269
  function metroLogPath(cwd) {
747
- return join5(cwd, STATE_DIR2, "metro.log");
1270
+ return join6(cwd, STATE_DIR2, "metro.log");
748
1271
  }
749
1272
  function isPidAlive2(pid) {
750
1273
  try {
@@ -757,12 +1280,12 @@ function isPidAlive2(pid) {
757
1280
  function writeMetroState(cwd, state) {
758
1281
  const path = statePath2(cwd);
759
1282
  mkdirSync2(dirname3(path), { recursive: true });
760
- writeFileSync3(path, JSON.stringify(state, null, 2) + "\n");
1283
+ writeFileSync4(path, JSON.stringify(state, null, 2) + "\n");
761
1284
  }
762
1285
  function readMetroState(cwd) {
763
1286
  let parsed;
764
1287
  try {
765
- parsed = JSON.parse(readFileSync4(statePath2(cwd), "utf-8"));
1288
+ parsed = JSON.parse(readFileSync5(statePath2(cwd), "utf-8"));
766
1289
  } catch {
767
1290
  return null;
768
1291
  }
@@ -774,7 +1297,7 @@ function readMetroState(cwd) {
774
1297
  }
775
1298
  function clearMetroState(cwd) {
776
1299
  const path = statePath2(cwd);
777
- if (existsSync4(path)) rmSync3(path, { force: true });
1300
+ if (existsSync4(path)) rmSync4(path, { force: true });
778
1301
  }
779
1302
  function killOwnedMetro(cwd) {
780
1303
  const state = readMetroState(cwd);
@@ -832,7 +1355,7 @@ If Metro uses another port, pass --metro-port <port>.`
832
1355
  return { status: "spawned", warnings };
833
1356
  }
834
1357
  async function spawnMetro(cwd, command, port, log, timeoutMs) {
835
- const child = spawn2(command, {
1358
+ const child = spawn3(command, {
836
1359
  cwd,
837
1360
  shell: true,
838
1361
  detached: true,
@@ -1157,6 +1680,10 @@ function lateWarningVerdict(info, reportedWarnings) {
1157
1680
  return { toReport, blocking };
1158
1681
  }
1159
1682
  var ROKU_LIVENESS_INTERVAL_MS = 1e4;
1683
+ var ROKU_INSTALL_PENDING_INTERVAL_MS = 2e3;
1684
+ function rokuLivenessDelayMs(info) {
1685
+ return info?.app_install_pending ? ROKU_INSTALL_PENDING_INTERVAL_MS : ROKU_LIVENESS_INTERVAL_MS;
1686
+ }
1160
1687
  function rokuKeyAction(char) {
1161
1688
  if (char === "") return "interrupt";
1162
1689
  switch (char.toLowerCase()) {
@@ -1619,36 +2146,42 @@ async function runDevInProcess(opts) {
1619
2146
  }
1620
2147
  const sessionId = handle.sessionId;
1621
2148
  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));
2149
+ let keepAlive;
2150
+ const scheduleLiveness = (delayMs) => {
2151
+ keepAlive = setTimeout(() => {
2152
+ void (async () => {
2153
+ if (stopping) return;
2154
+ let info = null;
2155
+ try {
2156
+ info = await api.getSession(sessionId);
2157
+ } catch {
2158
+ }
2159
+ const verdict = sessionLivenessVerdict(info);
2160
+ if (verdict.end && !stopping) {
2161
+ console.error(chalk2.red(verdict.message));
2162
+ emit(opts.json, { event: "session_ended", session_id: sessionId, code: 1 });
2163
+ endedEmitted = true;
2164
+ await stopSession(1);
2165
+ return;
2166
+ }
2167
+ const warningVerdict = lateWarningVerdict(info, reportedWarnings);
2168
+ for (const w of warningVerdict.toReport) {
2169
+ reportedWarnings.add(w);
2170
+ console.error(chalk2.yellow(`\u26A0 ${w}`));
2171
+ emit(opts.json, { event: "warning", message: w });
2172
+ }
2173
+ if (warningVerdict.blocking && !stopping) {
2174
+ emit(opts.json, { event: "session_ended", session_id: sessionId, code: 1 });
2175
+ endedEmitted = true;
2176
+ await stopSession(1);
2177
+ return;
2178
+ }
2179
+ if (!stopping) scheduleLiveness(rokuLivenessDelayMs(info));
2180
+ })();
2181
+ }, delayMs);
2182
+ };
2183
+ scheduleLiveness(ROKU_INSTALL_PENDING_INTERVAL_MS);
2184
+ process.on("exit", () => clearTimeout(keepAlive));
1652
2185
  }
1653
2186
  } catch (err) {
1654
2187
  const message = err.message;
@@ -1662,7 +2195,7 @@ async function dev(opts) {
1662
2195
  return runDevInProcess(opts);
1663
2196
  }
1664
2197
  const rawArgs = process.argv.slice(2).filter((a) => a !== "--detach");
1665
- const child = spawn3(process.execPath, [process.argv[1], ...rawArgs, "--json", "--no-open"], {
2198
+ const child = spawn4(process.execPath, [process.argv[1], ...rawArgs, "--json", "--no-open"], {
1666
2199
  detached: true,
1667
2200
  stdio: ["ignore", "pipe", "ignore"]
1668
2201
  });
@@ -2194,7 +2727,7 @@ function agentExitCode(env) {
2194
2727
  }
2195
2728
 
2196
2729
  // src/commands/agent.ts
2197
- import { writeFileSync as writeFileSync4 } from "fs";
2730
+ import { writeFileSync as writeFileSync5 } from "fs";
2198
2731
  var REF_RE = /^@?(e\d+)$/;
2199
2732
  var COORDS_RE = /^(-?\d+),\s*(-?\d+)$/;
2200
2733
  var FlagError = class extends Error {
@@ -2395,7 +2928,7 @@ async function runAgentVerb(cmd, positionals, opts) {
2395
2928
  `);
2396
2929
  process.exit(1);
2397
2930
  }
2398
- writeFileSync4(outPath, imageBytes);
2931
+ writeFileSync5(outPath, imageBytes);
2399
2932
  if (opts.json) {
2400
2933
  console.log(JSON.stringify(envelope));
2401
2934
  } else {
@@ -2423,6 +2956,9 @@ export {
2423
2956
  readMetroState,
2424
2957
  killOwnedMetro,
2425
2958
  buildCommand,
2959
+ BUILD_TERMINAL_STATUSES,
2960
+ fetchFinalSummary,
2961
+ printOutcomeLine,
2426
2962
  startDevSession,
2427
2963
  PLATFORM_DEVICE,
2428
2964
  dev,