vibeview 0.11.0 → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,474 @@ 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 QUEUED_POLL_INTERVAL_MS = 1e4;
839
+ function nextPollDelayMs(pendingStatuses, baseMs, queuedMs) {
840
+ if (pendingStatuses.length > 0 && pendingStatuses.every((s) => s === "queued")) {
841
+ return queuedMs;
842
+ }
843
+ return baseMs;
844
+ }
845
+ var POLL_SAFETY_MARGIN_MS = 10 * 60 * 1e3;
846
+ function runningCapDeadline(startedAt, timeoutS) {
847
+ return startedAt + timeoutS * 1e3 + POLL_SAFETY_MARGIN_MS;
848
+ }
849
+ var DEFAULT_QUEUE_WAIT_MS = 30 * 60 * 1e3;
850
+ function queueWaitMs() {
851
+ const raw = process.env.VIBEVIEW_QUEUE_WAIT_MS;
852
+ if (!raw) return DEFAULT_QUEUE_WAIT_MS;
853
+ const parsed = Number(raw);
854
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_QUEUE_WAIT_MS;
855
+ }
856
+ function defaultCloudBuildDeps(client) {
857
+ return {
858
+ listSourceFiles,
859
+ packSource,
860
+ createCloudBuilds: client.createCloudBuilds.bind(client),
861
+ getCloudBuildLogs: client.getCloudBuildLogs.bind(client),
862
+ getCloudBuild: client.getCloudBuild.bind(client),
863
+ downloadCloudBuildArtifact: client.downloadCloudBuildArtifact.bind(client)
864
+ };
865
+ }
866
+ function formatMB(bytes) {
867
+ return (bytes / (1024 * 1024)).toFixed(1);
868
+ }
869
+ function formatDuration(startedAt, finishedAt) {
870
+ if (!startedAt || !finishedAt) return null;
871
+ const ms = new Date(finishedAt).getTime() - new Date(startedAt).getTime();
872
+ if (!Number.isFinite(ms) || ms < 0) return null;
873
+ if (ms < 1e3) return `${ms}ms`;
874
+ return `${(ms / 1e3).toFixed(1)}s`;
875
+ }
876
+ function formatCost(costMicro) {
877
+ return `$${(costMicro / 1e6).toFixed(2)}`;
878
+ }
879
+ async function runCloudBuildCommand(cwd, cfg, opts, deps) {
880
+ const platforms = await resolveCloudPlatforms(cwd, cfg, opts);
881
+ for (const p of platforms) {
882
+ try {
883
+ validateBuildEnv(cfg?.platforms?.[p]?.build?.env, p);
884
+ } catch (err) {
885
+ fail(opts, err instanceof Error ? err.message : String(err));
886
+ }
887
+ }
888
+ const { token, baseUrl } = resolveAuth(opts);
889
+ const client = new ApiClient(baseUrl, token);
890
+ const d = deps ?? defaultCloudBuildDeps(client);
891
+ let cleanupTar;
892
+ try {
893
+ let files;
894
+ try {
895
+ files = await d.listSourceFiles(cwd);
896
+ } catch (err) {
897
+ if (err instanceof NotAGitRepoError) {
898
+ fail(
899
+ opts,
900
+ `${err.message} \u2014 cloud builds pack your project source via git; run this from inside a git repository`
901
+ );
902
+ }
903
+ throw err;
904
+ }
905
+ const packSpinner = !opts.json ? createSpinner(`packing source (${files.length} files)`) : null;
906
+ packSpinner?.start();
907
+ let tarPath;
908
+ let sizeBytes;
909
+ try {
910
+ const packed = await d.packSource(cwd, files);
911
+ tarPath = packed.tarPath;
912
+ sizeBytes = packed.sizeBytes;
913
+ cleanupTar = packed.cleanup;
914
+ } catch (err) {
915
+ packSpinner?.stop();
916
+ if (err instanceof SourceTooLargeError) {
917
+ const offenders = err.largest.map((f) => ` ${f.path} (${formatMB(f.bytes)} MB)`).join("\n");
918
+ fail(opts, `${err.message}
919
+ Largest files:
920
+ ${offenders}`);
921
+ }
922
+ throw err;
923
+ }
924
+ packSpinner?.succeed(`packed source (${files.length} files, ${formatMB(sizeBytes)} MB)`);
925
+ const manifestEntries = platforms.map((p) => {
926
+ const entry = cfg.platforms[p];
927
+ const build = entry.build;
928
+ return {
929
+ platform: p,
930
+ app_public_id: entry.appId,
931
+ command: build.command,
932
+ artifact_path: build.artifact,
933
+ ...build.env ? { env: build.env } : {}
934
+ };
935
+ });
936
+ const uploadSpinner = !opts.json ? createSpinner("uploading source...") : null;
937
+ uploadSpinner?.start();
938
+ let created;
939
+ try {
940
+ const resp = await d.createCloudBuilds({ builds: manifestEntries }, tarPath);
941
+ created = resp.builds;
942
+ } catch (err) {
943
+ uploadSpinner?.stop();
944
+ const message = err instanceof Error ? err.message : String(err);
945
+ fail(opts, message);
946
+ }
947
+ uploadSpinner?.succeed(`uploaded \u2014 ${created.length} build(s) submitted`);
948
+ for (const b of created) {
949
+ emitCloud(opts.json, { event: "build_created", id: b.id, platform: b.platform });
950
+ }
951
+ if (!opts.json) {
952
+ for (const b of created) {
953
+ console.error(` ${chalk.cyan(b.id)} ${chalk.dim(`[${b.platform}]`)}`);
954
+ }
955
+ }
956
+ const shouldWait = opts.wait !== false || opts.download;
957
+ if (!shouldWait) {
958
+ process.exitCode = 0;
959
+ return;
960
+ }
961
+ const finished = await waitForCloudBuilds(created, opts, d);
962
+ let downloadFailed = false;
963
+ if (opts.download) {
964
+ for (const b of finished.values()) {
965
+ if (b.status !== "succeeded") continue;
966
+ try {
967
+ const result = await d.downloadCloudBuildArtifact(b.id, cwd);
968
+ emitCloud(opts.json, {
969
+ event: "artifact_saved",
970
+ id: b.id,
971
+ filename: result.filename,
972
+ path: result.path
973
+ });
974
+ if (!opts.json) {
975
+ console.error(chalk.green(`\u2713 saved ${result.filename}`));
976
+ }
977
+ } catch (err) {
978
+ downloadFailed = true;
979
+ const message = err instanceof Error ? err.message : String(err);
980
+ emitCloud(opts.json, {
981
+ event: "error",
982
+ message: `error downloading ${b.id}: ${message}`
983
+ });
984
+ if (!opts.json) console.error(chalk.red(`error downloading ${b.id}: ${message}`));
985
+ }
986
+ }
987
+ }
988
+ const allSucceeded = Array.from(finished.values()).every((b) => b.status === "succeeded");
989
+ process.exitCode = allSucceeded && !downloadFailed ? 0 : 1;
990
+ } finally {
991
+ cleanupTar?.();
992
+ }
993
+ }
994
+ var BUILD_TERMINAL_STATUSES = /* @__PURE__ */ new Set([
995
+ "succeeded",
996
+ "failed",
997
+ "canceled",
998
+ "timed_out",
999
+ "infra_failed"
1000
+ ]);
1001
+ var MAX_CONSECUTIVE_POLL_FAILURES = 5;
1002
+ async function waitForCloudBuilds(builds, opts, deps) {
1003
+ const now = deps.now ?? Date.now;
1004
+ const pollIntervalMs = deps.pollIntervalMs ?? POLL_INTERVAL_MS;
1005
+ const queuedPollIntervalMs = deps.queuedPollIntervalMs ?? (deps.pollIntervalMs !== void 0 ? pollIntervalMs : QUEUED_POLL_INTERVAL_MS);
1006
+ const multi = builds.length > 1;
1007
+ const state = new Map(builds.map((b) => [b.id, b]));
1008
+ const offsets = new Map(builds.map((b) => [b.id, 0]));
1009
+ const failures = new Map(builds.map((b) => [b.id, 0]));
1010
+ const finalized = /* @__PURE__ */ new Set();
1011
+ const abandoned = /* @__PURE__ */ new Set();
1012
+ const pollAbandoned = /* @__PURE__ */ new Set();
1013
+ const transitions = new Map(
1014
+ builds.map((b) => [b.id, newTransitionState()])
1015
+ );
1016
+ let lastError;
1017
+ const runningStartedAt = /* @__PURE__ */ new Map();
1018
+ const queueStartedAt = new Map(builds.map((b) => [b.id, now()]));
1019
+ const queueCapMs = queueWaitMs();
1020
+ const waitStartedAt = now();
1021
+ const maxTimeoutMs = Math.max(0, ...builds.map((b) => b.timeout_s * 1e3));
1022
+ const initialBackstopDeadline = waitStartedAt + queueCapMs + maxTimeoutMs + POLL_SAFETY_MARGIN_MS;
1023
+ const backstopDeadlineFor = (id, timeoutS) => {
1024
+ const startedAt = runningStartedAt.get(id);
1025
+ if (startedAt === void 0) return initialBackstopDeadline;
1026
+ return Math.max(initialBackstopDeadline, runningCapDeadline(startedAt, timeoutS));
1027
+ };
1028
+ const printLine = (id, platform, line) => {
1029
+ const prefix = multi ? `[${platform}] ` : "";
1030
+ if (!opts.json) console.error(prefix + line);
1031
+ };
1032
+ const stillWaiting = () => Array.from(state.values()).some((b) => !finalized.has(b.id) && !abandoned.has(b.id));
1033
+ const buildDeadline = (build) => {
1034
+ const startedAt = runningStartedAt.get(build.id);
1035
+ if (startedAt === void 0) return null;
1036
+ return runningCapDeadline(startedAt, build.timeout_s);
1037
+ };
1038
+ const giveUpLocally = (id, message) => {
1039
+ abandoned.add(id);
1040
+ emitCloud(opts.json, { event: "error", message });
1041
+ if (!opts.json) console.error(chalk.red(message));
1042
+ };
1043
+ const pollAndProcess = async (build) => {
1044
+ const offset = offsets.get(build.id) ?? 0;
1045
+ const logs = await deps.getCloudBuildLogs(build.id, offset);
1046
+ const updated = { ...build, status: logs.status };
1047
+ state.set(build.id, updated);
1048
+ if (logs.status === "running" && !runningStartedAt.has(build.id)) {
1049
+ runningStartedAt.set(build.id, now());
1050
+ }
1051
+ offsets.set(build.id, logs.offset);
1052
+ const transitionState = transitions.get(build.id);
1053
+ printTransition(build.id, build.platform, logs.status, transitionState, opts.json, printLine);
1054
+ if (logs.content) {
1055
+ emitCloud(opts.json, { event: "build_log", id: build.id, content: logs.content });
1056
+ for (const line of logs.content.split("\n")) {
1057
+ if (line.length === 0) continue;
1058
+ printLine(build.id, build.platform, line);
1059
+ }
1060
+ }
1061
+ if (BUILD_TERMINAL_STATUSES.has(logs.status)) {
1062
+ const final = await fetchFinalSummary(build.id, updated, deps);
1063
+ state.set(build.id, final);
1064
+ if (!opts.json) {
1065
+ printOutcomeLine(final, multi);
1066
+ }
1067
+ emitCloud(opts.json, {
1068
+ event: "build_finished",
1069
+ id: final.id,
1070
+ status: final.status,
1071
+ cost_micro: final.cost_micro,
1072
+ error_summary: final.error_summary
1073
+ });
1074
+ finalized.add(build.id);
1075
+ }
1076
+ failures.set(build.id, 0);
1077
+ return logs.status;
1078
+ };
1079
+ const finalPollBeforeGivingUp = async (build) => {
1080
+ try {
1081
+ const status = await pollAndProcess(build);
1082
+ return BUILD_TERMINAL_STATUSES.has(status) ? "terminal" : "nonterminal";
1083
+ } catch {
1084
+ return "poll_failed";
1085
+ }
1086
+ };
1087
+ while (stillWaiting()) {
1088
+ for (const build of state.values()) {
1089
+ if (abandoned.has(build.id)) continue;
1090
+ if (finalized.has(build.id)) continue;
1091
+ if (BUILD_TERMINAL_STATUSES.has(build.status)) {
1092
+ try {
1093
+ await pollAndProcess(build);
1094
+ } catch (err) {
1095
+ lastError = err;
1096
+ const count = (failures.get(build.id) ?? 0) + 1;
1097
+ failures.set(build.id, count);
1098
+ if (count >= MAX_CONSECUTIVE_POLL_FAILURES) {
1099
+ pollAbandoned.add(build.id);
1100
+ const message = err instanceof Error ? err.message : String(err);
1101
+ giveUpLocally(
1102
+ build.id,
1103
+ `giving up finishing ${build.id} after ${count} consecutive failures: ${message} \u2014 the build likely finished server-side; check its outcome with \`vibeview builds logs ${build.id}\``
1104
+ );
1105
+ }
1106
+ }
1107
+ continue;
1108
+ }
1109
+ if (build.status === "queued") {
1110
+ const queuedSince = queueStartedAt.get(build.id) ?? now();
1111
+ if (now() - queuedSince > queueCapMs) {
1112
+ const rescue = await finalPollBeforeGivingUp(build);
1113
+ if (state.get(build.id).status !== "queued") continue;
1114
+ giveUpLocally(
1115
+ build.id,
1116
+ rescue === "poll_failed" ? (
1117
+ // PR-445 review F6: the rescue poll failed, so "queued" is the
1118
+ // last status observed BEFORE the gap — not verified server
1119
+ // truth. Say so instead of asserting the build stayed queued.
1120
+ `${build.id} still queued after ${Math.round(queueCapMs / 6e4)}m \u2014 builders busy; giving up waiting on it. Could not re-check its final status (the last poll failed) \u2014 it may have started or finished; check on it with \`vibeview builds\``
1121
+ ) : `${build.id} still queued after ${Math.round(queueCapMs / 6e4)}m \u2014 builders busy; giving up waiting on it. The build stays queued server-side \u2014 check on it later with \`vibeview builds\``
1122
+ );
1123
+ continue;
1124
+ }
1125
+ } else {
1126
+ const deadline = buildDeadline(build);
1127
+ if (deadline !== null && now() > deadline) {
1128
+ if (await finalPollBeforeGivingUp(build) === "terminal") continue;
1129
+ giveUpLocally(
1130
+ build.id,
1131
+ `${build.id} exceeded its ${Math.round((build.timeout_s * 1e3 + POLL_SAFETY_MARGIN_MS) / 1e3)}s time limit client-side \u2014 giving up waiting on it. The build may still be running server-side \u2014 check its outcome with \`vibeview builds logs ${build.id}\``
1132
+ );
1133
+ continue;
1134
+ }
1135
+ }
1136
+ try {
1137
+ await pollAndProcess(build);
1138
+ } catch (err) {
1139
+ lastError = err;
1140
+ const count = (failures.get(build.id) ?? 0) + 1;
1141
+ failures.set(build.id, count);
1142
+ if (count >= MAX_CONSECUTIVE_POLL_FAILURES) {
1143
+ pollAbandoned.add(build.id);
1144
+ const message = err instanceof Error ? err.message : String(err);
1145
+ giveUpLocally(
1146
+ build.id,
1147
+ `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\``
1148
+ );
1149
+ }
1150
+ }
1151
+ }
1152
+ if (stillWaiting()) {
1153
+ for (const build of state.values()) {
1154
+ if (BUILD_TERMINAL_STATUSES.has(build.status) || abandoned.has(build.id)) continue;
1155
+ const deadline = backstopDeadlineFor(build.id, build.timeout_s);
1156
+ if (now() <= deadline) continue;
1157
+ if (await finalPollBeforeGivingUp(build) === "terminal") continue;
1158
+ const postRescueDeadline = backstopDeadlineFor(build.id, build.timeout_s);
1159
+ if (now() <= postRescueDeadline) continue;
1160
+ const fresh = state.get(build.id);
1161
+ const capMinutes = Math.round((postRescueDeadline - waitStartedAt) / 6e4);
1162
+ giveUpLocally(
1163
+ build.id,
1164
+ `giving up waiting on ${build.id}: still \`${fresh.status}\` with no terminal status after the overall ${capMinutes}m safety limit \u2014 the backend never reported the build finished; check on it later with \`vibeview builds\``
1165
+ );
1166
+ }
1167
+ }
1168
+ if (stillWaiting()) {
1169
+ const pendingStatuses = Array.from(state.values()).filter(
1170
+ (b) => !abandoned.has(b.id) && !finalized.has(b.id) && !BUILD_TERMINAL_STATUSES.has(b.status)
1171
+ ).map((b) => b.status);
1172
+ await new Promise(
1173
+ (r) => setTimeout(r, nextPollDelayMs(pendingStatuses, pollIntervalMs, queuedPollIntervalMs))
1174
+ );
1175
+ }
1176
+ }
1177
+ if (pollAbandoned.size > 0 && pollAbandoned.size === abandoned.size && !Array.from(state.values()).some((b) => BUILD_TERMINAL_STATUSES.has(b.status))) {
1178
+ const message = lastError instanceof Error ? lastError.message : String(lastError);
1179
+ throw new Error(
1180
+ `cloud build poll failed ${MAX_CONSECUTIVE_POLL_FAILURES} times in a row, giving up: ${message}
1181
+ The build may still be running server-side \u2014 resume watching it with \`vibeview builds logs <id> --follow\`.`
1182
+ );
1183
+ }
1184
+ return state;
1185
+ }
1186
+ async function fetchFinalSummary(id, fallback, deps) {
1187
+ try {
1188
+ return await deps.getCloudBuild(id);
1189
+ } catch {
1190
+ return fallback;
1191
+ }
1192
+ }
1193
+ function newTransitionState() {
1194
+ return { printedQueued: false, printedRunning: false };
1195
+ }
1196
+ function printTransition(id, platform, status, state, json, printLine) {
1197
+ if (status === "queued" && !state.printedQueued) {
1198
+ state.printedQueued = true;
1199
+ if (json) {
1200
+ emitCloud(json, { event: "status", id, status: "queued" });
1201
+ } else {
1202
+ printLine(id, platform, chalk.dim("\u23F3 queued \u2014 waiting for a build slot"));
1203
+ }
1204
+ } else if (status === "running" && !state.printedRunning) {
1205
+ state.printedRunning = true;
1206
+ if (json) {
1207
+ emitCloud(json, { event: "status", id, status: "running" });
1208
+ } else {
1209
+ printLine(id, platform, `${chalk.cyan("\u25B6")} building`);
1210
+ }
1211
+ }
1212
+ }
1213
+ function printOutcomeLine(build, multi) {
1214
+ const prefix = multi ? `[${build.platform}] ` : "";
1215
+ const ok = build.status === "succeeded";
1216
+ const mark = ok ? chalk.green("\u2713") : chalk.red("\u2717");
1217
+ const duration = formatDuration(build.started_at, build.finished_at);
1218
+ let line = `${prefix}${mark} ${build.status}`;
1219
+ if (duration) line += ` (${duration})`;
1220
+ if (build.error_summary) line += ` \u2014 ${build.error_summary}`;
1221
+ if (build.cost_micro > 0) line += ` \u2014 cost: ${formatCost(build.cost_micro)} usage credit`;
1222
+ console.error(line);
559
1223
  }
560
1224
 
561
1225
  // src/commands/dev.ts
562
- import { spawn as spawn3 } from "child_process";
1226
+ import { spawn as spawn4 } from "child_process";
563
1227
  import { createWriteStream, mkdirSync as mkdirSync3 } from "fs";
564
1228
  import { dirname as dirname5 } from "path";
565
1229
  import { createInterface as createInterface3 } from "readline";
@@ -732,19 +1396,19 @@ var TunnelClient = class {
732
1396
  };
733
1397
 
734
1398
  // src/lib/metro-manager.ts
735
- import { spawn as spawn2 } from "child_process";
1399
+ import { spawn as spawn3 } from "child_process";
736
1400
  import { createInterface as createInterface2 } from "readline";
737
1401
 
738
1402
  // 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";
1403
+ import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync5, rmSync as rmSync4, writeFileSync as writeFileSync4 } from "fs";
1404
+ import { dirname as dirname3, join as join6 } from "path";
741
1405
  var STATE_DIR2 = ".vibeview";
742
1406
  var STATE_FILE2 = "metro-state.json";
743
1407
  function statePath2(cwd) {
744
- return join5(cwd, STATE_DIR2, STATE_FILE2);
1408
+ return join6(cwd, STATE_DIR2, STATE_FILE2);
745
1409
  }
746
1410
  function metroLogPath(cwd) {
747
- return join5(cwd, STATE_DIR2, "metro.log");
1411
+ return join6(cwd, STATE_DIR2, "metro.log");
748
1412
  }
749
1413
  function isPidAlive2(pid) {
750
1414
  try {
@@ -757,12 +1421,12 @@ function isPidAlive2(pid) {
757
1421
  function writeMetroState(cwd, state) {
758
1422
  const path = statePath2(cwd);
759
1423
  mkdirSync2(dirname3(path), { recursive: true });
760
- writeFileSync3(path, JSON.stringify(state, null, 2) + "\n");
1424
+ writeFileSync4(path, JSON.stringify(state, null, 2) + "\n");
761
1425
  }
762
1426
  function readMetroState(cwd) {
763
1427
  let parsed;
764
1428
  try {
765
- parsed = JSON.parse(readFileSync4(statePath2(cwd), "utf-8"));
1429
+ parsed = JSON.parse(readFileSync5(statePath2(cwd), "utf-8"));
766
1430
  } catch {
767
1431
  return null;
768
1432
  }
@@ -774,7 +1438,7 @@ function readMetroState(cwd) {
774
1438
  }
775
1439
  function clearMetroState(cwd) {
776
1440
  const path = statePath2(cwd);
777
- if (existsSync4(path)) rmSync3(path, { force: true });
1441
+ if (existsSync4(path)) rmSync4(path, { force: true });
778
1442
  }
779
1443
  function killOwnedMetro(cwd) {
780
1444
  const state = readMetroState(cwd);
@@ -832,7 +1496,7 @@ If Metro uses another port, pass --metro-port <port>.`
832
1496
  return { status: "spawned", warnings };
833
1497
  }
834
1498
  async function spawnMetro(cwd, command, port, log, timeoutMs) {
835
- const child = spawn2(command, {
1499
+ const child = spawn3(command, {
836
1500
  cwd,
837
1501
  shell: true,
838
1502
  detached: true,
@@ -1157,6 +1821,10 @@ function lateWarningVerdict(info, reportedWarnings) {
1157
1821
  return { toReport, blocking };
1158
1822
  }
1159
1823
  var ROKU_LIVENESS_INTERVAL_MS = 1e4;
1824
+ var ROKU_INSTALL_PENDING_INTERVAL_MS = 2e3;
1825
+ function rokuLivenessDelayMs(info) {
1826
+ return info?.app_install_pending ? ROKU_INSTALL_PENDING_INTERVAL_MS : ROKU_LIVENESS_INTERVAL_MS;
1827
+ }
1160
1828
  function rokuKeyAction(char) {
1161
1829
  if (char === "") return "interrupt";
1162
1830
  switch (char.toLowerCase()) {
@@ -1619,36 +2287,42 @@ async function runDevInProcess(opts) {
1619
2287
  }
1620
2288
  const sessionId = handle.sessionId;
1621
2289
  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));
2290
+ let keepAlive;
2291
+ const scheduleLiveness = (delayMs) => {
2292
+ keepAlive = setTimeout(() => {
2293
+ void (async () => {
2294
+ if (stopping) return;
2295
+ let info = null;
2296
+ try {
2297
+ info = await api.getSession(sessionId);
2298
+ } catch {
2299
+ }
2300
+ const verdict = sessionLivenessVerdict(info);
2301
+ if (verdict.end && !stopping) {
2302
+ console.error(chalk2.red(verdict.message));
2303
+ emit(opts.json, { event: "session_ended", session_id: sessionId, code: 1 });
2304
+ endedEmitted = true;
2305
+ await stopSession(1);
2306
+ return;
2307
+ }
2308
+ const warningVerdict = lateWarningVerdict(info, reportedWarnings);
2309
+ for (const w of warningVerdict.toReport) {
2310
+ reportedWarnings.add(w);
2311
+ console.error(chalk2.yellow(`\u26A0 ${w}`));
2312
+ emit(opts.json, { event: "warning", message: w });
2313
+ }
2314
+ if (warningVerdict.blocking && !stopping) {
2315
+ emit(opts.json, { event: "session_ended", session_id: sessionId, code: 1 });
2316
+ endedEmitted = true;
2317
+ await stopSession(1);
2318
+ return;
2319
+ }
2320
+ if (!stopping) scheduleLiveness(rokuLivenessDelayMs(info));
2321
+ })();
2322
+ }, delayMs);
2323
+ };
2324
+ scheduleLiveness(ROKU_INSTALL_PENDING_INTERVAL_MS);
2325
+ process.on("exit", () => clearTimeout(keepAlive));
1652
2326
  }
1653
2327
  } catch (err) {
1654
2328
  const message = err.message;
@@ -1662,7 +2336,7 @@ async function dev(opts) {
1662
2336
  return runDevInProcess(opts);
1663
2337
  }
1664
2338
  const rawArgs = process.argv.slice(2).filter((a) => a !== "--detach");
1665
- const child = spawn3(process.execPath, [process.argv[1], ...rawArgs, "--json", "--no-open"], {
2339
+ const child = spawn4(process.execPath, [process.argv[1], ...rawArgs, "--json", "--no-open"], {
1666
2340
  detached: true,
1667
2341
  stdio: ["ignore", "pipe", "ignore"]
1668
2342
  });
@@ -2194,7 +2868,7 @@ function agentExitCode(env) {
2194
2868
  }
2195
2869
 
2196
2870
  // src/commands/agent.ts
2197
- import { writeFileSync as writeFileSync4 } from "fs";
2871
+ import { writeFileSync as writeFileSync5 } from "fs";
2198
2872
  var REF_RE = /^@?(e\d+)$/;
2199
2873
  var COORDS_RE = /^(-?\d+),\s*(-?\d+)$/;
2200
2874
  var FlagError = class extends Error {
@@ -2395,7 +3069,7 @@ async function runAgentVerb(cmd, positionals, opts) {
2395
3069
  `);
2396
3070
  process.exit(1);
2397
3071
  }
2398
- writeFileSync4(outPath, imageBytes);
3072
+ writeFileSync5(outPath, imageBytes);
2399
3073
  if (opts.json) {
2400
3074
  console.log(JSON.stringify(envelope));
2401
3075
  } else {
@@ -2423,6 +3097,11 @@ export {
2423
3097
  readMetroState,
2424
3098
  killOwnedMetro,
2425
3099
  buildCommand,
3100
+ BUILD_TERMINAL_STATUSES,
3101
+ fetchFinalSummary,
3102
+ newTransitionState,
3103
+ printTransition,
3104
+ printOutcomeLine,
2426
3105
  startDevSession,
2427
3106
  PLATFORM_DEVICE,
2428
3107
  dev,