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.
- package/dist/{chunk-NA3JOV5Z.js → chunk-NJU4AKHN.js} +1045 -147
- package/dist/{chunk-YD2X6TQV.js → chunk-OHRTLUBD.js} +154 -12
- package/dist/cli.js +294 -24
- package/dist/{server-B2PDQJLR.js → server-LZRNKGVA.js} +23 -12
- package/dist/{upload-app-GAZVQZYR.js → upload-app-G5SEYBTU.js} +1 -1
- package/package.json +2 -1
|
@@ -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-
|
|
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 (
|
|
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 =
|
|
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
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1085
|
+
import { spawn as spawn4 } from "child_process";
|
|
498
1086
|
import { createWriteStream, mkdirSync as mkdirSync3 } from "fs";
|
|
499
|
-
import { dirname as
|
|
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
|
|
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
|
|
675
|
-
import { dirname as dirname3, join as
|
|
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
|
|
1267
|
+
return join6(cwd, STATE_DIR2, STATE_FILE2);
|
|
680
1268
|
}
|
|
681
1269
|
function metroLogPath(cwd) {
|
|
682
|
-
return
|
|
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
|
-
|
|
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(
|
|
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))
|
|
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 =
|
|
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
|
|
826
|
-
|
|
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
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
1162
|
-
let
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
const
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
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
|
-
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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,
|