vibeview 0.10.1 → 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-YD2X6TQV.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";
@@ -71,7 +72,15 @@ function clearDevState(cwd) {
71
72
  // src/lib/dev-config.ts
72
73
  import { readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
73
74
  import { join as join2 } from "path";
74
- var DEV_PLATFORMS = ["ios", "android", "tvos", "androidtv"];
75
+ var DEV_PLATFORMS = ["ios", "android", "tvos", "androidtv", "roku"];
76
+ var ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
77
+ var PLATFORM_NEEDS_METRO = {
78
+ ios: true,
79
+ android: true,
80
+ tvos: true,
81
+ androidtv: true,
82
+ roku: false
83
+ };
75
84
  var FILE = "vibeview.json";
76
85
  function isDevPlatform(p) {
77
86
  return typeof p === "string" && DEV_PLATFORMS.includes(p);
@@ -137,6 +146,18 @@ function loadDevConfig(cwd) {
137
146
  );
138
147
  }
139
148
  }
149
+ if (entry.watchIgnore !== void 0) {
150
+ if (!Array.isArray(entry.watchIgnore)) {
151
+ throw new Error(
152
+ `vibeview.json: platforms.${key}.watchIgnore must be an array of paths to ignore while watching`
153
+ );
154
+ }
155
+ if (entry.watchIgnore.some((p) => typeof p !== "string" || !p)) {
156
+ throw new Error(
157
+ `vibeview.json: platforms.${key}.watchIgnore entries must be non-empty strings`
158
+ );
159
+ }
160
+ }
140
161
  if (entry.metroCommand !== void 0 && (typeof entry.metroCommand !== "string" || !entry.metroCommand)) {
141
162
  throw new Error(`vibeview.json: platforms.${key}.metroCommand must be a non-empty string`);
142
163
  }
@@ -146,6 +167,26 @@ function loadDevConfig(cwd) {
146
167
  }
147
168
  return cfg;
148
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
+ }
149
190
  function saveDevConfig(cwd, cfg) {
150
191
  writeFileSync2(join2(cwd, FILE), JSON.stringify(cfg, null, 2) + "\n");
151
192
  }
@@ -169,18 +210,21 @@ function buildSavedConfig(input2) {
169
210
  firstRunMetro,
170
211
  firstRunBuild
171
212
  } = input2;
172
- const topLevelMetroPort = metroPortFlag ? parseInt(metroPortFlag, 10) : existing?.metroPort ?? 8081;
213
+ const topLevelMetroPort = metroPortFlag ? parseInt(metroPortFlag, 10) : existing?.metroPort ?? (PLATFORM_NEEDS_METRO[platform] ? 8081 : void 0);
173
214
  return {
174
215
  defaultPlatform: platform,
175
- metroPort: topLevelMetroPort,
216
+ ...topLevelMetroPort !== void 0 ? { metroPort: topLevelMetroPort } : {},
176
217
  ...isFirstRun && firstRunMetro ? { metroCommand: firstRunMetro } : existing?.metroCommand ? { metroCommand: existing.metroCommand } : {},
177
218
  platforms: {
178
219
  ...existing?.platforms,
179
220
  [platform]: {
180
221
  appId,
181
222
  deviceCategory: isTv ? void 0 : deviceCategory,
182
- ...existing?.platforms?.[platform]?.build ? { build: existing.platforms[platform].build } : {},
223
+ ...existing?.platforms?.[platform]?.build ? { build: { ...existing.platforms[platform].build } } : {},
183
224
  ...isFirstRun && firstRunBuild ? { build: firstRunBuild } : {},
225
+ // Never invented — absent means "no extra ignores", and first-run
226
+ // setup does not ask for it. Only ever carried forward.
227
+ ...existing?.platforms?.[platform]?.watchIgnore ? { watchIgnore: existing.platforms[platform].watchIgnore } : {},
184
228
  ...existing?.platforms?.[platform]?.metroCommand ? { metroCommand: existing.platforms[platform].metroCommand } : {},
185
229
  ...existing?.platforms?.[platform]?.metroPort !== void 0 ? { metroPort: existing.platforms[platform].metroPort } : {}
186
230
  }
@@ -202,7 +246,7 @@ async function checkMetro(port, timeoutMs = 1500) {
202
246
  }
203
247
 
204
248
  // src/commands/build.ts
205
- import { spawn } from "child_process";
249
+ import { spawn as spawn2 } from "child_process";
206
250
  import { createInterface } from "readline";
207
251
  import { select as select2 } from "@inquirer/prompts";
208
252
  import chalk from "chalk";
@@ -268,8 +312,22 @@ function prepareUploadArtifact(path) {
268
312
  import { input, select } from "@inquirer/prompts";
269
313
 
270
314
  // src/lib/project-detect.ts
271
- import { existsSync as existsSync3, readFileSync as readFileSync3, readdirSync as readdirSync2 } from "fs";
315
+ import { existsSync as existsSync3, readFileSync as readFileSync3, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
272
316
  import { join as join4 } from "path";
317
+ function readJson(cwd, name) {
318
+ try {
319
+ return JSON.parse(readFileSync3(join4(cwd, name), "utf-8"));
320
+ } catch {
321
+ return null;
322
+ }
323
+ }
324
+ function isFile(path) {
325
+ try {
326
+ return statSync2(path).isFile();
327
+ } catch {
328
+ return false;
329
+ }
330
+ }
273
331
  function detectFlavor(cwd) {
274
332
  let deps = {};
275
333
  try {
@@ -279,6 +337,7 @@ function detectFlavor(cwd) {
279
337
  }
280
338
  if ("expo" in deps) return "expo";
281
339
  if (existsSync3(join4(cwd, "android", "gradlew")) || iosWorkspaceName(cwd)) return "bare-rn";
340
+ if (isFile(join4(cwd, "manifest"))) return "roku";
282
341
  return "unknown";
283
342
  }
284
343
  function iosWorkspaceName(cwd) {
@@ -291,9 +350,32 @@ function iosWorkspaceName(cwd) {
291
350
  }
292
351
  var IOS_ARTIFACT = "ios/build/Build/Products/Debug-iphonesimulator/*.app";
293
352
  var ANDROID_ARTIFACT = "android/app/build/outputs/apk/debug/*.apk";
353
+ var ROKU_DEFAULT_ARTIFACT = "out/channel.zip";
354
+ var ROKU_ZIP_COMMAND = "zip -r out/channel.zip manifest source components images -x '*.DS_Store'";
355
+ function rokuDefaults(cwd) {
356
+ const pkg = readJson(cwd, "package.json");
357
+ const scripts = pkg?.scripts ?? {};
358
+ const runner = existsSync3(join4(cwd, "yarn.lock")) ? "yarn" : "npm run";
359
+ const packageScript = Object.entries(scripts).find(
360
+ ([name, body]) => !name.startsWith("deploy") && (name.startsWith("package") || body.includes("--package-only") || body.includes("zipPackage"))
361
+ );
362
+ const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
363
+ let artifact = ROKU_DEFAULT_ARTIFACT;
364
+ if ("roku-deploy" in deps) {
365
+ const rd = readJson(cwd, "rokudeploy.json");
366
+ const outDir = rd?.outDir ?? "out";
367
+ const outFile = rd?.outFile ?? "roku-deploy.zip";
368
+ artifact = `${outDir}/${outFile}`;
369
+ }
370
+ if (packageScript) {
371
+ return { buildCommand: `${runner} ${packageScript[0]}`, artifact };
372
+ }
373
+ return { buildCommand: ROKU_ZIP_COMMAND, artifact };
374
+ }
294
375
  function defaultsFor(cwd, platform) {
295
376
  const flavor = detectFlavor(cwd);
296
- if (flavor === "unknown") return {};
377
+ if (platform === "roku") return flavor === "roku" ? rokuDefaults(cwd) : {};
378
+ if (flavor === "unknown" || flavor === "roku") return {};
297
379
  const metroCommand = flavor === "expo" ? "npx expo start" : "yarn start";
298
380
  if (platform === "tvos" || platform === "androidtv") return { metroCommand };
299
381
  if (platform === "android") {
@@ -319,6 +401,16 @@ function defaultsFor(cwd, platform) {
319
401
  }
320
402
 
321
403
  // src/lib/dev-setup.ts
404
+ function platformChoices(cwd) {
405
+ const choices = [
406
+ { value: "ios", name: "iOS simulator" },
407
+ { value: "android", name: "Android emulator" },
408
+ { value: "tvos", name: "Apple TV simulator" },
409
+ { value: "androidtv", name: "Android TV emulator" }
410
+ ];
411
+ if (detectFlavor(cwd) === "roku") choices.unshift({ value: "roku", name: "Roku (beta)" });
412
+ return choices;
413
+ }
322
414
  function firstRunAppStrategy(apps, buildConfigured) {
323
415
  if (!buildConfigured) return "picker";
324
416
  return apps.length === 0 ? "build_now" : "ask";
@@ -352,7 +444,7 @@ async function promptBuildConfig(cwd, platform) {
352
444
  }
353
445
  const command = await input({ message: `Build command for ${platform}`, required: true });
354
446
  const artifact = await input({
355
- message: "Path to the built app it produces (.apk, or .app dir; * allowed)",
447
+ message: platform === "roku" ? "Path to the channel zip it produces (* allowed)" : "Path to the built app it produces (.apk, or .app dir; * allowed)",
356
448
  default: defaults.artifact,
357
449
  required: true
358
450
  });
@@ -366,7 +458,187 @@ async function promptMetroCommand(cwd, platform) {
366
458
  return input({ message: "Metro command", required: true });
367
459
  }
368
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
+
369
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
+ };
370
642
  var TAIL_LINES = 30;
371
643
  async function runBuildForPlatform(cwd, platform, build, opts, log, deps = { upload: uploadAppBuild }) {
372
644
  const tail = await runShell(build.command, cwd, log);
@@ -393,7 +665,7 @@ async function runBuildForPlatform(cwd, platform, build, opts, log, deps = { upl
393
665
  }
394
666
  function runShell(command, cwd, log) {
395
667
  return new Promise((resolve) => {
396
- const child = spawn(command, { cwd, shell: true, stdio: ["ignore", "pipe", "pipe"] });
668
+ const child = spawn2(command, { cwd, shell: true, stdio: ["ignore", "pipe", "pipe"] });
397
669
  const lines = [];
398
670
  const onLine = (line) => {
399
671
  lines.push(line);
@@ -406,9 +678,7 @@ function runShell(command, cwd, log) {
406
678
  child.on("error", () => resolve({ code: 127, lines }));
407
679
  });
408
680
  }
409
- async function buildCommand(opts) {
410
- const cwd = process.cwd();
411
- const cfg = loadDevConfig(cwd);
681
+ async function resolveBarePlatform(cwd, cfg, opts) {
412
682
  if (opts.platform && !isDevPlatform(opts.platform)) {
413
683
  console.error(chalk.red(`error: --platform must be one of ${DEV_PLATFORMS.join(", ")}`));
414
684
  process.exit(2);
@@ -421,40 +691,42 @@ async function buildCommand(opts) {
421
691
  "no platform: pass --platform or run `vibeview dev` once to set up vibeview.json",
422
692
  "config_required"
423
693
  );
424
- return;
425
694
  }
426
695
  platform = await select2({
427
696
  message: "Platform to run on",
428
- choices: [
429
- { value: "ios", name: "iOS simulator" },
430
- { value: "android", name: "Android emulator" },
431
- { value: "tvos", name: "Apple TV simulator" },
432
- { value: "androidtv", name: "Android TV emulator" }
433
- ]
697
+ choices: platformChoices(cwd)
434
698
  });
435
699
  }
436
- let build = cfg?.platforms?.[platform]?.build;
437
- if (!build) {
438
- if (opts.json || !process.stdin.isTTY) {
439
- fail(
440
- opts,
441
- `no build config for ${platform}: add platforms.${platform}.build {command, artifact} to vibeview.json`,
442
- "config_required"
443
- );
444
- return;
445
- }
446
- const answered = await promptBuildConfig(cwd, platform);
447
- if (!answered) {
448
- fail(opts, "build config is required to run vibeview build", "config_required");
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);
449
708
  return;
450
709
  }
451
- build = answered;
452
- if (cfg?.platforms?.[platform]) {
453
- cfg.platforms[platform].build = build;
454
- 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
+ }
455
729
  }
456
- }
457
- try {
458
730
  const result = await runBuildForPlatform(
459
731
  cwd,
460
732
  platform,
@@ -484,19 +756,335 @@ async function buildCommand(opts) {
484
756
  );
485
757
  }
486
758
  } catch (err) {
487
- 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;
488
763
  }
489
764
  }
490
- function fail(opts, message, code) {
765
+ function printFailure(opts, message, code) {
491
766
  if (opts.json) console.log(JSON.stringify({ event: "error", code, message }));
492
767
  console.error(chalk.red(`error: ${message}`));
493
- 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);
494
1082
  }
495
1083
 
496
1084
  // src/commands/dev.ts
497
- import { spawn as spawn3 } from "child_process";
1085
+ import { spawn as spawn4 } from "child_process";
498
1086
  import { createWriteStream, mkdirSync as mkdirSync3 } from "fs";
499
- import { dirname as dirname4 } from "path";
1087
+ import { dirname as dirname5 } from "path";
500
1088
  import { createInterface as createInterface3 } from "readline";
501
1089
  import { select as select3 } from "@inquirer/prompts";
502
1090
  import chalk2 from "chalk";
@@ -667,19 +1255,19 @@ var TunnelClient = class {
667
1255
  };
668
1256
 
669
1257
  // src/lib/metro-manager.ts
670
- import { spawn as spawn2 } from "child_process";
1258
+ import { spawn as spawn3 } from "child_process";
671
1259
  import { createInterface as createInterface2 } from "readline";
672
1260
 
673
1261
  // src/lib/metro-state.ts
674
- import { existsSync as existsSync4, mkdirSync as mkdirSync2, readFileSync as readFileSync4, rmSync as rmSync3, writeFileSync as writeFileSync3 } from "fs";
675
- 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";
676
1264
  var STATE_DIR2 = ".vibeview";
677
1265
  var STATE_FILE2 = "metro-state.json";
678
1266
  function statePath2(cwd) {
679
- return join5(cwd, STATE_DIR2, STATE_FILE2);
1267
+ return join6(cwd, STATE_DIR2, STATE_FILE2);
680
1268
  }
681
1269
  function metroLogPath(cwd) {
682
- return join5(cwd, STATE_DIR2, "metro.log");
1270
+ return join6(cwd, STATE_DIR2, "metro.log");
683
1271
  }
684
1272
  function isPidAlive2(pid) {
685
1273
  try {
@@ -692,12 +1280,12 @@ function isPidAlive2(pid) {
692
1280
  function writeMetroState(cwd, state) {
693
1281
  const path = statePath2(cwd);
694
1282
  mkdirSync2(dirname3(path), { recursive: true });
695
- writeFileSync3(path, JSON.stringify(state, null, 2) + "\n");
1283
+ writeFileSync4(path, JSON.stringify(state, null, 2) + "\n");
696
1284
  }
697
1285
  function readMetroState(cwd) {
698
1286
  let parsed;
699
1287
  try {
700
- parsed = JSON.parse(readFileSync4(statePath2(cwd), "utf-8"));
1288
+ parsed = JSON.parse(readFileSync5(statePath2(cwd), "utf-8"));
701
1289
  } catch {
702
1290
  return null;
703
1291
  }
@@ -709,7 +1297,7 @@ function readMetroState(cwd) {
709
1297
  }
710
1298
  function clearMetroState(cwd) {
711
1299
  const path = statePath2(cwd);
712
- if (existsSync4(path)) rmSync3(path, { force: true });
1300
+ if (existsSync4(path)) rmSync4(path, { force: true });
713
1301
  }
714
1302
  function killOwnedMetro(cwd) {
715
1303
  const state = readMetroState(cwd);
@@ -767,7 +1355,7 @@ If Metro uses another port, pass --metro-port <port>.`
767
1355
  return { status: "spawned", warnings };
768
1356
  }
769
1357
  async function spawnMetro(cwd, command, port, log, timeoutMs) {
770
- const child = spawn2(command, {
1358
+ const child = spawn3(command, {
771
1359
  cwd,
772
1360
  shell: true,
773
1361
  detached: true,
@@ -815,19 +1403,163 @@ async function waitFor(cond, timeoutMs, everyMs) {
815
1403
  }
816
1404
  }
817
1405
 
1406
+ // src/lib/reload-loop.ts
1407
+ import { watch } from "fs";
1408
+ import { basename as basename2, dirname as dirname4 } from "path";
1409
+ function createReloadLoop(deps) {
1410
+ let inFlight = null;
1411
+ let pendingFollowUp = false;
1412
+ let debounceTimer = null;
1413
+ let stopWatch = null;
1414
+ let stopped = false;
1415
+ async function run() {
1416
+ inFlight = (async () => {
1417
+ try {
1418
+ await deps.push();
1419
+ } catch (err) {
1420
+ (deps.logError ?? deps.log)(err.message);
1421
+ }
1422
+ })();
1423
+ try {
1424
+ await inFlight;
1425
+ } finally {
1426
+ inFlight = null;
1427
+ }
1428
+ if (pendingFollowUp && !stopped) {
1429
+ pendingFollowUp = false;
1430
+ await run();
1431
+ }
1432
+ }
1433
+ async function trigger() {
1434
+ if (stopped) return;
1435
+ if (inFlight) {
1436
+ deps.log("already reloading \u2014 ignoring");
1437
+ return;
1438
+ }
1439
+ if (debounceTimer) {
1440
+ clearTimeout(debounceTimer);
1441
+ debounceTimer = null;
1442
+ }
1443
+ await run();
1444
+ }
1445
+ function onChange() {
1446
+ if (stopped) return;
1447
+ if (inFlight) {
1448
+ pendingFollowUp = true;
1449
+ return;
1450
+ }
1451
+ if (debounceTimer) clearTimeout(debounceTimer);
1452
+ debounceTimer = setTimeout(
1453
+ () => {
1454
+ debounceTimer = null;
1455
+ void trigger();
1456
+ },
1457
+ (deps.watchSeconds ?? 2) * 1e3
1458
+ );
1459
+ }
1460
+ if (deps.watch && deps.watchSeconds !== void 0) {
1461
+ stopWatch = deps.watch(onChange);
1462
+ }
1463
+ return {
1464
+ trigger,
1465
+ stop() {
1466
+ stopped = true;
1467
+ if (debounceTimer) clearTimeout(debounceTimer);
1468
+ stopWatch?.();
1469
+ }
1470
+ };
1471
+ }
1472
+ var IGNORED_SEGMENTS = ["node_modules", ".git", ".vibeview"];
1473
+ var IGNORED_FILES = ["vibeview.json"];
1474
+ function isUnderPath(rel, entry) {
1475
+ const norm = normalizeRel(entry).replace(/\/+$/, "");
1476
+ if (!norm) return false;
1477
+ return rel === norm || rel.startsWith(`${norm}/`);
1478
+ }
1479
+ function normalizeRel(p) {
1480
+ return p.replace(/\\/g, "/").replace(/^\.\//, "");
1481
+ }
1482
+ function isIgnoredWatchPath(relPath, artifact, userIgnore = []) {
1483
+ const rel = normalizeRel(relPath);
1484
+ const segments = rel.split("/");
1485
+ if (segments.some((s) => IGNORED_SEGMENTS.includes(s) || s.includes("staging"))) return true;
1486
+ if (IGNORED_FILES.includes(rel)) return true;
1487
+ if (userIgnore.some((entry) => isUnderPath(rel, entry))) return true;
1488
+ const artifactPattern = artifact.replace(/\\/g, "/").replace(/^\.\//, "");
1489
+ const artifactDir = dirname4(artifactPattern);
1490
+ if (artifactDir !== "." && (rel === artifactDir || rel.startsWith(`${artifactDir}/`))) {
1491
+ return true;
1492
+ }
1493
+ if (dirname4(rel) !== artifactDir) return false;
1494
+ const name = basename2(artifactPattern);
1495
+ const re = new RegExp(`^${name.split("*").map(escapeRegExp2).join(".*")}$`);
1496
+ return re.test(basename2(rel));
1497
+ }
1498
+ function escapeRegExp2(s) {
1499
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1500
+ }
1501
+ function watchProjectFiles(cwd, artifact, userIgnore = []) {
1502
+ return (onChange) => {
1503
+ const watcher = watch(cwd, { recursive: true }, (_event, filename) => {
1504
+ if (filename !== null && isIgnoredWatchPath(filename.toString(), artifact, userIgnore)) {
1505
+ return;
1506
+ }
1507
+ onChange();
1508
+ });
1509
+ return () => watcher.close();
1510
+ };
1511
+ }
1512
+
1513
+ // src/lib/roku-push.ts
1514
+ function createRokuPush(opts) {
1515
+ const runBuild = opts.runBuild ?? runBuildForPlatform;
1516
+ return async () => {
1517
+ const started = Date.now();
1518
+ let result;
1519
+ try {
1520
+ result = await runBuild(
1521
+ opts.cwd,
1522
+ "roku",
1523
+ opts.build,
1524
+ { token: opts.token, baseUrl: opts.baseUrl },
1525
+ opts.log
1526
+ );
1527
+ } catch (err) {
1528
+ const message = err.message;
1529
+ if (/artifact not found/i.test(message)) {
1530
+ throw new Error(
1531
+ `${message}
1532
+ your build command must produce a zip \u2014 use the package/zip step, not deploy`
1533
+ );
1534
+ }
1535
+ throw err;
1536
+ }
1537
+ if (!result.build_id) {
1538
+ throw new Error(`upload returned no build id for ${result.name ?? result.public_id}`);
1539
+ }
1540
+ await opts.api.installBuild(opts.sessionId, result.build_id, true);
1541
+ opts.log(`reloaded in ${((Date.now() - started) / 1e3).toFixed(1)}s`);
1542
+ };
1543
+ }
1544
+
818
1545
  // src/commands/dev.ts
1546
+ function parseWatchSeconds(flag) {
1547
+ if (flag === void 0 || flag === false) return void 0;
1548
+ if (flag === true) return 2;
1549
+ const seconds = Number(flag);
1550
+ if (!Number.isFinite(seconds) || seconds <= 0) {
1551
+ throw new Error(`--watch expects a positive number of seconds, got "${flag}"`);
1552
+ }
1553
+ return seconds;
1554
+ }
819
1555
  function emit(json, evt) {
820
1556
  if (json) console.log(JSON.stringify(evt));
821
1557
  }
822
1558
  async function startDevSession(cfg, onEvent = () => {
823
1559
  }, onTunnelState) {
824
1560
  const api = new ApiClient(cfg.baseUrl, cfg.token);
825
- const created = await api.createSession({
826
- device_type: PLATFORM_DEVICE[cfg.platform].deviceType,
827
- device_category: cfg.deviceCategory,
828
- app_id: cfg.appId,
829
- tunnel: true
830
- });
1561
+ const usesTunnel = PLATFORM_NEEDS_METRO[cfg.platform];
1562
+ const created = await api.createSession(devSessionCreateBody(cfg));
831
1563
  const sessionId = created.session_id;
832
1564
  let stopping = false;
833
1565
  let tunnel = null;
@@ -841,52 +1573,66 @@ async function startDevSession(cfg, onEvent = () => {
841
1573
  };
842
1574
  try {
843
1575
  await api.startSession(sessionId);
844
- const ready = await pollUntilTunnelReady(api, sessionId, 18e4);
845
- for (const w of ready.warnings ?? []) {
846
- onEvent({ event: "warning", message: w });
847
- }
848
1576
  const pageUrl = `${cfg.webUrl || cfg.baseUrl}/sandbox/${sessionId}`;
849
- tunnel = new TunnelClient({
850
- url: ready.tunnel_url,
851
- metroPort: cfg.metroPort,
852
- // Re-mint the URL on each reconnect: the tunnel token has a ~1h TTL and
853
- // a worker restart drops the proxy registration, so the original URL
854
- // eventually goes stale. Every authorized GET /sessions/{id} re-signs a
855
- // fresh tunnel token. Null (session stopped/failed) ends the tunnel.
856
- refreshUrl: async () => {
857
- const info = await api.getSession(sessionId);
858
- if (info.status === "stopped" || info.status === "failed") return null;
859
- return info.tunnel_url ?? null;
860
- },
861
- onDebug: cfg.debug ? (msg) => onEvent({ event: "warning", message: `[tunnel] ${msg}` }) : void 0,
862
- onState: (s) => {
863
- onTunnelState?.(s);
864
- if (s === "connected") {
865
- onEvent({ event: "tunnel_connected", session_id: sessionId });
866
- } else if (s === "metro-unreachable") {
867
- onEvent({
868
- event: "warning",
869
- message: `can't reach Metro on localhost:${cfg.metroPort} \u2014 is it running? (yarn start)`
870
- });
871
- } else if (s === "closed" && !stopping) {
872
- void stopSession(1);
873
- }
1577
+ const reportedWarnings = /* @__PURE__ */ new Set();
1578
+ if (usesTunnel) {
1579
+ const ready = await pollUntilTunnelReady(api, sessionId, 18e4);
1580
+ for (const w of ready.warnings ?? []) {
1581
+ reportedWarnings.add(w);
1582
+ onEvent({ event: "warning", message: w });
874
1583
  }
875
- });
876
- tunnel.start();
1584
+ tunnel = new TunnelClient({
1585
+ url: ready.tunnel_url,
1586
+ metroPort: cfg.metroPort,
1587
+ // Re-mint the URL on each reconnect: the tunnel token has a ~1h TTL and
1588
+ // a worker restart drops the proxy registration, so the original URL
1589
+ // eventually goes stale. Every authorized GET /sessions/{id} re-signs a
1590
+ // fresh tunnel token. Null (session stopped/failed) ends the tunnel.
1591
+ refreshUrl: async () => {
1592
+ const info = await api.getSession(sessionId);
1593
+ if (info.status === "stopped" || info.status === "failed") return null;
1594
+ return info.tunnel_url ?? null;
1595
+ },
1596
+ onDebug: cfg.debug ? (msg) => onEvent({ event: "warning", message: `[tunnel] ${msg}` }) : void 0,
1597
+ onState: (s) => {
1598
+ onTunnelState?.(s);
1599
+ if (s === "connected") {
1600
+ onEvent({ event: "tunnel_connected", session_id: sessionId });
1601
+ } else if (s === "metro-unreachable") {
1602
+ onEvent({
1603
+ event: "warning",
1604
+ message: `can't reach Metro on localhost:${cfg.metroPort} \u2014 is it running? (yarn start)`
1605
+ });
1606
+ } else if (s === "closed" && !stopping) {
1607
+ void stopSession(1);
1608
+ }
1609
+ }
1610
+ });
1611
+ tunnel.start();
1612
+ }
877
1613
  if (cfg.open !== false) await openBrowser(pageUrl).catch(() => {
878
1614
  });
879
1615
  const active = await pollUntilActive(api, sessionId, 18e4);
880
- const seen = new Set(ready.warnings ?? []);
881
1616
  for (const w of active.warnings ?? []) {
882
- if (!seen.has(w)) onEvent({ event: "warning", message: w });
1617
+ if (reportedWarnings.has(w)) continue;
1618
+ reportedWarnings.add(w);
1619
+ onEvent({ event: "warning", message: w });
883
1620
  }
884
- return { sessionId, pageUrl, stop: () => stopSession(0) };
1621
+ return { sessionId, pageUrl, stop: () => stopSession(0), reportedWarnings };
885
1622
  } catch (err) {
886
1623
  await stopSession(1);
887
1624
  throw err;
888
1625
  }
889
1626
  }
1627
+ function devSessionCreateBody(cfg) {
1628
+ return {
1629
+ device_type: PLATFORM_DEVICE[cfg.platform].deviceType,
1630
+ device_category: cfg.deviceCategory,
1631
+ app_id: cfg.appId,
1632
+ // Roku has no JS bundle to serve, so there is nothing to tunnel.
1633
+ tunnel: PLATFORM_NEEDS_METRO[cfg.platform]
1634
+ };
1635
+ }
890
1636
  var BLOCKING_WARNING_PREFIXES = ["No debug build", "App install failed", "App not found"];
891
1637
  function findBlockingWarning(info) {
892
1638
  return (info.warnings ?? []).find((w) => BLOCKING_WARNING_PREFIXES.some((p) => w.includes(p)));
@@ -921,11 +1667,42 @@ async function pollUntilActive(api, sessionId, timeoutMs, intervalMs = 2e3) {
921
1667
  await new Promise((r) => setTimeout(r, intervalMs));
922
1668
  }
923
1669
  }
1670
+ function sessionLivenessVerdict(info) {
1671
+ if (info && (info.status === "stopped" || info.status === "failed")) {
1672
+ return { end: true, message: `session ${info.status} \u2014 ending the reload loop` };
1673
+ }
1674
+ return { end: false };
1675
+ }
1676
+ function lateWarningVerdict(info, reportedWarnings) {
1677
+ if (!info) return { toReport: [], blocking: false };
1678
+ const toReport = (info.warnings ?? []).filter((w) => !reportedWarnings.has(w));
1679
+ const blocking = findBlockingWarning({ warnings: toReport }) !== void 0;
1680
+ return { toReport, blocking };
1681
+ }
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
+ }
1687
+ function rokuKeyAction(char) {
1688
+ if (char === "") return "interrupt";
1689
+ switch (char.toLowerCase()) {
1690
+ case "r":
1691
+ return "reload";
1692
+ case "q":
1693
+ return "quit";
1694
+ default:
1695
+ return null;
1696
+ }
1697
+ }
924
1698
  var PLATFORM_DEVICE = {
925
1699
  ios: { deviceType: "ios", tv: false },
926
1700
  android: { deviceType: "android", tv: false },
927
1701
  tvos: { deviceType: "ios", tv: true },
928
- androidtv: { deviceType: "android", tv: true }
1702
+ androidtv: { deviceType: "android", tv: true },
1703
+ // Roku IS its own device type (unlike tvOS/Android TV, which are the
1704
+ // handheld type plus category tv) — the worker registers it as such.
1705
+ roku: { deviceType: "roku", tv: true }
929
1706
  };
930
1707
  function appsForPlatform(apps, platform) {
931
1708
  return apps.filter((a) => a.platform === platform);
@@ -953,7 +1730,7 @@ function requireNonInteractive(json) {
953
1730
  async function ensureConfig(api, opts) {
954
1731
  const existing = loadDevConfig(process.cwd());
955
1732
  if (opts.platform && !isDevPlatform(opts.platform)) {
956
- console.error(chalk2.red(`error: --platform must be one of ios, android, tvos, androidtv`));
1733
+ console.error(chalk2.red(`error: --platform must be one of ${DEV_PLATFORMS.join(", ")}`));
957
1734
  process.exit(2);
958
1735
  }
959
1736
  if (!opts.platform && !existing?.defaultPlatform) {
@@ -961,12 +1738,7 @@ async function ensureConfig(api, opts) {
961
1738
  }
962
1739
  const platform = opts.platform || existing?.defaultPlatform || await select3({
963
1740
  message: "Platform to run on",
964
- choices: [
965
- { value: "ios", name: "iOS simulator" },
966
- { value: "android", name: "Android emulator" },
967
- { value: "tvos", name: "Apple TV simulator" },
968
- { value: "androidtv", name: "Android TV emulator" }
969
- ]
1741
+ choices: platformChoices(process.cwd())
970
1742
  });
971
1743
  const isTv = PLATFORM_DEVICE[platform].tv;
972
1744
  if (isTv && opts.deviceCategory) {
@@ -988,7 +1760,9 @@ async function ensureConfig(api, opts) {
988
1760
  requireNonInteractive(opts.json);
989
1761
  } else if (process.stdin.isTTY) {
990
1762
  firstRunBuild = await promptBuildConfig(process.cwd(), platform);
991
- firstRunMetro = await promptMetroCommand(process.cwd(), platform);
1763
+ if (PLATFORM_NEEDS_METRO[platform]) {
1764
+ firstRunMetro = await promptMetroCommand(process.cwd(), platform);
1765
+ }
992
1766
  }
993
1767
  }
994
1768
  const entry = existing?.platforms?.[platform];
@@ -1098,7 +1872,10 @@ Run \`vibeview dev\` without --app to pick from the list.`
1098
1872
  });
1099
1873
  saveDevConfig(process.cwd(), cfg);
1100
1874
  if (isFirstRun) {
1101
- const hint = fillLaterHint({ build: !firstRunBuild, metro: !firstRunMetro }, platform);
1875
+ const hint = fillLaterHint(
1876
+ { build: !firstRunBuild, metro: PLATFORM_NEEDS_METRO[platform] && !firstRunMetro },
1877
+ platform
1878
+ );
1102
1879
  if (hint) console.error(chalk2.dim(hint));
1103
1880
  }
1104
1881
  const finalMetro = isFirstRun ? resolveMetro(cfg, platform, opts.metroPort ? parseInt(opts.metroPort, 10) : void 0) : metroResolved;
@@ -1113,6 +1890,15 @@ Run \`vibeview dev\` without --app to pick from the list.`
1113
1890
  };
1114
1891
  }
1115
1892
  async function runDevInProcess(opts) {
1893
+ let watchSeconds;
1894
+ try {
1895
+ watchSeconds = parseWatchSeconds(opts.watch);
1896
+ } catch (err) {
1897
+ const message = err.message;
1898
+ console.error(chalk2.red(`error: ${message}`));
1899
+ emit(opts.json, { event: "error", code: "invalid_flag", message });
1900
+ process.exit(2);
1901
+ }
1116
1902
  if (opts.json) {
1117
1903
  const hasToken = !!(opts.token || process.env.VIBEVIEW_API_TOKEN || loadConfig().token);
1118
1904
  if (!hasToken) {
@@ -1158,51 +1944,68 @@ async function runDevInProcess(opts) {
1158
1944
  process.exit(1);
1159
1945
  }
1160
1946
  }
1161
- const metroPort = cfg.metroPort ?? 8081;
1162
- let metroResult;
1163
- try {
1164
- let metroLogStream = null;
1165
- const captureMetroLine = (line) => {
1166
- try {
1167
- if (!metroLogStream) {
1168
- mkdirSync3(dirname4(metroLogPath(process.cwd())), { recursive: true });
1169
- metroLogStream = createWriteStream(metroLogPath(process.cwd()), { flags: "w" });
1947
+ let metroOwnedThisRun = false;
1948
+ let rokuBuild;
1949
+ let watchIgnore = [];
1950
+ if (PLATFORM_NEEDS_METRO[cfg.platform]) {
1951
+ const metroPort = cfg.metroPort ?? 8081;
1952
+ let metroResult;
1953
+ try {
1954
+ let metroLogStream = null;
1955
+ const captureMetroLine = (line) => {
1956
+ try {
1957
+ if (!metroLogStream) {
1958
+ mkdirSync3(dirname5(metroLogPath(process.cwd())), { recursive: true });
1959
+ metroLogStream = createWriteStream(metroLogPath(process.cwd()), { flags: "w" });
1960
+ }
1961
+ metroLogStream.write(line.replace(/^\[metro\] /, "") + "\n");
1962
+ } catch {
1170
1963
  }
1171
- metroLogStream.write(line.replace(/^\[metro\] /, "") + "\n");
1172
- } catch {
1173
- }
1174
- if (shouldEchoMetroLine(line, opts.debug === true)) console.error(chalk2.dim(line));
1175
- };
1176
- metroResult = await ensureMetro(
1177
- process.cwd(),
1178
- {
1179
- command: cfg.metroCommand,
1180
- port: metroPort,
1181
- platformHasOverride: cfg.platformHasMetroOverride
1182
- },
1183
- captureMetroLine
1184
- );
1185
- } catch (err) {
1186
- const message = err.message;
1187
- console.error(chalk2.red(`error: ${message}`));
1188
- emit(opts.json, { event: "error", code: "metro_down", message });
1189
- process.exit(1);
1190
- }
1191
- for (const w of metroResult.warnings) console.error(chalk2.yellow(`\u26A0 ${w}`));
1192
- if (metroResult.status === "spawned" || metroResult.status === "respawned") {
1193
- console.error(chalk2.green(`\u2713 started Metro (${cfg.metroCommand})`));
1194
- console.error(
1195
- chalk2.dim(` full Metro output: ${metroLogPath(process.cwd())} (--debug echoes it here)`)
1196
- );
1964
+ if (shouldEchoMetroLine(line, opts.debug === true)) console.error(chalk2.dim(line));
1965
+ };
1966
+ metroResult = await ensureMetro(
1967
+ process.cwd(),
1968
+ {
1969
+ command: cfg.metroCommand,
1970
+ port: metroPort,
1971
+ platformHasOverride: cfg.platformHasMetroOverride
1972
+ },
1973
+ captureMetroLine
1974
+ );
1975
+ } catch (err) {
1976
+ const message = err.message;
1977
+ console.error(chalk2.red(`error: ${message}`));
1978
+ emit(opts.json, { event: "error", code: "metro_down", message });
1979
+ process.exit(1);
1980
+ }
1981
+ for (const w of metroResult.warnings) console.error(chalk2.yellow(`\u26A0 ${w}`));
1982
+ if (metroResult.status === "spawned" || metroResult.status === "respawned") {
1983
+ console.error(chalk2.green(`\u2713 started Metro (${cfg.metroCommand})`));
1984
+ console.error(
1985
+ chalk2.dim(` full Metro output: ${metroLogPath(process.cwd())} (--debug echoes it here)`)
1986
+ );
1987
+ }
1988
+ metroOwnedThisRun = metroResult.status === "spawned" || metroResult.status === "respawned";
1989
+ } else {
1990
+ const rokuEntry = loadDevConfig(process.cwd())?.platforms?.[cfg.platform];
1991
+ rokuBuild = rokuEntry?.build;
1992
+ watchIgnore = rokuEntry?.watchIgnore ?? [];
1993
+ if (!rokuBuild) {
1994
+ const message = `no build config for ${cfg.platform} \u2014 run \`vibeview build --platform ${cfg.platform}\` once to set it up`;
1995
+ console.error(chalk2.red(`error: ${message}`));
1996
+ emit(opts.json, { event: "error", code: "config_required", message });
1997
+ process.exit(1);
1998
+ }
1197
1999
  }
1198
- const metroOwnedThisRun = metroResult.status === "spawned" || metroResult.status === "respawned";
1199
2000
  console.error(chalk2.dim(`Starting a ${cfg.platform} dev session for ${cfg.appId}...`));
1200
2001
  let stopping = false;
1201
2002
  let handle = null;
2003
+ let reloadLoop = null;
1202
2004
  const stopSession = async (code) => {
1203
2005
  if (stopping) return;
1204
2006
  stopping = true;
1205
2007
  console.error(chalk2.dim("\nStopping session..."));
2008
+ reloadLoop?.stop();
1206
2009
  await handle?.stop();
1207
2010
  if (metroOwnedThisRun) killOwnedMetro(process.cwd());
1208
2011
  clearDevState(process.cwd());
@@ -1213,6 +2016,7 @@ async function runDevInProcess(opts) {
1213
2016
  let everConnected = false;
1214
2017
  let waitingPrinted = false;
1215
2018
  let nextWarningIsMetroUnreachable = false;
2019
+ let endedEmitted = false;
1216
2020
  try {
1217
2021
  handle = await startDevSession(
1218
2022
  {
@@ -1243,10 +2047,15 @@ async function runDevInProcess(opts) {
1243
2047
  } else if (evt.event === "session_ended" && !stopping && handle) {
1244
2048
  console.error(chalk2.red("tunnel closed by server \u2014 session ended"));
1245
2049
  stopping = true;
2050
+ endedEmitted = true;
1246
2051
  emit(opts.json, evt);
1247
2052
  clearDevState(process.cwd());
1248
2053
  process.exit(1);
1249
2054
  }
2055
+ if (evt.event === "session_ended") {
2056
+ if (endedEmitted) return;
2057
+ endedEmitted = true;
2058
+ }
1250
2059
  emit(opts.json, evt);
1251
2060
  },
1252
2061
  (s) => {
@@ -1266,12 +2075,11 @@ async function runDevInProcess(opts) {
1266
2075
  }
1267
2076
  }
1268
2077
  );
1269
- console.error(
1270
- `
2078
+ const keysLine = rokuBuild ? `Press ${chalk2.bold("r")} to reload the channel, ${chalk2.bold("q")} or Ctrl-C to end the session.` : "Ctrl-C to end the session.";
2079
+ console.error(`
1271
2080
  Watch and interact: ${chalk2.cyan(handle.pageUrl)}
1272
- Ctrl-C to end the session.
1273
- `
1274
- );
2081
+ ${keysLine}
2082
+ `);
1275
2083
  writeDevState(process.cwd(), {
1276
2084
  pid: process.pid,
1277
2085
  session_id: handle.sessionId,
@@ -1289,6 +2097,92 @@ async function runDevInProcess(opts) {
1289
2097
  });
1290
2098
  if (opts.open !== false) await openBrowser(handle.pageUrl).catch(() => {
1291
2099
  });
2100
+ if (rokuBuild) {
2101
+ reloadLoop = createReloadLoop({
2102
+ push: createRokuPush({
2103
+ cwd: process.cwd(),
2104
+ sessionId: handle.sessionId,
2105
+ build: rokuBuild,
2106
+ token,
2107
+ baseUrl,
2108
+ api,
2109
+ // Build output and loop notices are human output, so they go to
2110
+ // stderr like every other line this command prints — stdout stays
2111
+ // reserved for the `--json` event stream.
2112
+ log: (line) => console.error(chalk2.dim(line))
2113
+ }),
2114
+ log: (line) => console.error(chalk2.yellow(line)),
2115
+ // A failed reload is an error, not a notice — same red as every other
2116
+ // failure this command prints, so it doesn't read like the yellow
2117
+ // "already reloading" line.
2118
+ logError: (line) => console.error(chalk2.red(`\u2717 ${line}`)),
2119
+ ...watchSeconds !== void 0 ? {
2120
+ watchSeconds,
2121
+ watch: watchProjectFiles(process.cwd(), rokuBuild.artifact, watchIgnore)
2122
+ } : {}
2123
+ });
2124
+ if (watchSeconds !== void 0) {
2125
+ console.error(
2126
+ chalk2.dim(` Watching for changes \u2014 reloading after ${watchSeconds}s of quiet.`)
2127
+ );
2128
+ }
2129
+ if (process.stdin.isTTY) {
2130
+ process.stdin.setRawMode(true);
2131
+ process.stdin.resume();
2132
+ process.stdin.setEncoding("utf8");
2133
+ const onKeyData = (chunk) => {
2134
+ for (const char of chunk) {
2135
+ const action = rokuKeyAction(char);
2136
+ if (action === "reload") void reloadLoop?.trigger();
2137
+ else if (action === "quit" || action === "interrupt") void stopSession(0);
2138
+ }
2139
+ };
2140
+ process.stdin.on("data", onKeyData);
2141
+ process.on("exit", () => {
2142
+ process.stdin.removeListener("data", onKeyData);
2143
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
2144
+ process.stdin.pause();
2145
+ });
2146
+ }
2147
+ const sessionId = handle.sessionId;
2148
+ const reportedWarnings = handle.reportedWarnings;
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));
2185
+ }
1292
2186
  } catch (err) {
1293
2187
  const message = err.message;
1294
2188
  console.error(chalk2.red(`error: ${message}`));
@@ -1301,7 +2195,7 @@ async function dev(opts) {
1301
2195
  return runDevInProcess(opts);
1302
2196
  }
1303
2197
  const rawArgs = process.argv.slice(2).filter((a) => a !== "--detach");
1304
- 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"], {
1305
2199
  detached: true,
1306
2200
  stdio: ["ignore", "pipe", "ignore"]
1307
2201
  });
@@ -1833,7 +2727,7 @@ function agentExitCode(env) {
1833
2727
  }
1834
2728
 
1835
2729
  // src/commands/agent.ts
1836
- import { writeFileSync as writeFileSync4 } from "fs";
2730
+ import { writeFileSync as writeFileSync5 } from "fs";
1837
2731
  var REF_RE = /^@?(e\d+)$/;
1838
2732
  var COORDS_RE = /^(-?\d+),\s*(-?\d+)$/;
1839
2733
  var FlagError = class extends Error {
@@ -2034,7 +2928,7 @@ async function runAgentVerb(cmd, positionals, opts) {
2034
2928
  `);
2035
2929
  process.exit(1);
2036
2930
  }
2037
- writeFileSync4(outPath, imageBytes);
2931
+ writeFileSync5(outPath, imageBytes);
2038
2932
  if (opts.json) {
2039
2933
  console.log(JSON.stringify(envelope));
2040
2934
  } else {
@@ -2055,12 +2949,16 @@ async function runAgentVerb(cmd, positionals, opts) {
2055
2949
  export {
2056
2950
  readDevState,
2057
2951
  clearDevState,
2952
+ PLATFORM_NEEDS_METRO,
2058
2953
  isDevPlatform,
2059
2954
  loadDevConfig,
2060
2955
  checkMetro,
2061
2956
  readMetroState,
2062
2957
  killOwnedMetro,
2063
2958
  buildCommand,
2959
+ BUILD_TERMINAL_STATUSES,
2960
+ fetchFinalSummary,
2961
+ printOutcomeLine,
2064
2962
  startDevSession,
2065
2963
  PLATFORM_DEVICE,
2066
2964
  dev,