vibeview 0.10.0 → 0.11.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-4UGLSIZM.js} +467 -105
- package/dist/{chunk-YD2X6TQV.js → chunk-AG3RZCPW.js} +31 -4
- package/dist/cli.js +52 -7
- package/dist/{server-B2PDQJLR.js → server-S6OCJERW.js} +23 -12
- package/dist/{upload-app-GAZVQZYR.js → upload-app-JIOFLMS3.js} +1 -1
- package/package.json +1 -1
- package/skills/vibeview-agent/SKILL.md +28 -4
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
loadConfig,
|
|
5
5
|
resolveAuth,
|
|
6
6
|
uploadAppBuild
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-AG3RZCPW.js";
|
|
8
8
|
|
|
9
9
|
// src/lib/dev-state.ts
|
|
10
10
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
@@ -71,7 +71,14 @@ function clearDevState(cwd) {
|
|
|
71
71
|
// src/lib/dev-config.ts
|
|
72
72
|
import { readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
73
73
|
import { join as join2 } from "path";
|
|
74
|
-
var DEV_PLATFORMS = ["ios", "android", "tvos", "androidtv"];
|
|
74
|
+
var DEV_PLATFORMS = ["ios", "android", "tvos", "androidtv", "roku"];
|
|
75
|
+
var PLATFORM_NEEDS_METRO = {
|
|
76
|
+
ios: true,
|
|
77
|
+
android: true,
|
|
78
|
+
tvos: true,
|
|
79
|
+
androidtv: true,
|
|
80
|
+
roku: false
|
|
81
|
+
};
|
|
75
82
|
var FILE = "vibeview.json";
|
|
76
83
|
function isDevPlatform(p) {
|
|
77
84
|
return typeof p === "string" && DEV_PLATFORMS.includes(p);
|
|
@@ -137,6 +144,18 @@ function loadDevConfig(cwd) {
|
|
|
137
144
|
);
|
|
138
145
|
}
|
|
139
146
|
}
|
|
147
|
+
if (entry.watchIgnore !== void 0) {
|
|
148
|
+
if (!Array.isArray(entry.watchIgnore)) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`vibeview.json: platforms.${key}.watchIgnore must be an array of paths to ignore while watching`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
if (entry.watchIgnore.some((p) => typeof p !== "string" || !p)) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`vibeview.json: platforms.${key}.watchIgnore entries must be non-empty strings`
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
140
159
|
if (entry.metroCommand !== void 0 && (typeof entry.metroCommand !== "string" || !entry.metroCommand)) {
|
|
141
160
|
throw new Error(`vibeview.json: platforms.${key}.metroCommand must be a non-empty string`);
|
|
142
161
|
}
|
|
@@ -169,10 +188,10 @@ function buildSavedConfig(input2) {
|
|
|
169
188
|
firstRunMetro,
|
|
170
189
|
firstRunBuild
|
|
171
190
|
} = input2;
|
|
172
|
-
const topLevelMetroPort = metroPortFlag ? parseInt(metroPortFlag, 10) : existing?.metroPort ?? 8081;
|
|
191
|
+
const topLevelMetroPort = metroPortFlag ? parseInt(metroPortFlag, 10) : existing?.metroPort ?? (PLATFORM_NEEDS_METRO[platform] ? 8081 : void 0);
|
|
173
192
|
return {
|
|
174
193
|
defaultPlatform: platform,
|
|
175
|
-
metroPort: topLevelMetroPort,
|
|
194
|
+
...topLevelMetroPort !== void 0 ? { metroPort: topLevelMetroPort } : {},
|
|
176
195
|
...isFirstRun && firstRunMetro ? { metroCommand: firstRunMetro } : existing?.metroCommand ? { metroCommand: existing.metroCommand } : {},
|
|
177
196
|
platforms: {
|
|
178
197
|
...existing?.platforms,
|
|
@@ -181,6 +200,9 @@ function buildSavedConfig(input2) {
|
|
|
181
200
|
deviceCategory: isTv ? void 0 : deviceCategory,
|
|
182
201
|
...existing?.platforms?.[platform]?.build ? { build: existing.platforms[platform].build } : {},
|
|
183
202
|
...isFirstRun && firstRunBuild ? { build: firstRunBuild } : {},
|
|
203
|
+
// Never invented — absent means "no extra ignores", and first-run
|
|
204
|
+
// setup does not ask for it. Only ever carried forward.
|
|
205
|
+
...existing?.platforms?.[platform]?.watchIgnore ? { watchIgnore: existing.platforms[platform].watchIgnore } : {},
|
|
184
206
|
...existing?.platforms?.[platform]?.metroCommand ? { metroCommand: existing.platforms[platform].metroCommand } : {},
|
|
185
207
|
...existing?.platforms?.[platform]?.metroPort !== void 0 ? { metroPort: existing.platforms[platform].metroPort } : {}
|
|
186
208
|
}
|
|
@@ -268,8 +290,22 @@ function prepareUploadArtifact(path) {
|
|
|
268
290
|
import { input, select } from "@inquirer/prompts";
|
|
269
291
|
|
|
270
292
|
// src/lib/project-detect.ts
|
|
271
|
-
import { existsSync as existsSync3, readFileSync as readFileSync3, readdirSync as readdirSync2 } from "fs";
|
|
293
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
|
|
272
294
|
import { join as join4 } from "path";
|
|
295
|
+
function readJson(cwd, name) {
|
|
296
|
+
try {
|
|
297
|
+
return JSON.parse(readFileSync3(join4(cwd, name), "utf-8"));
|
|
298
|
+
} catch {
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
function isFile(path) {
|
|
303
|
+
try {
|
|
304
|
+
return statSync2(path).isFile();
|
|
305
|
+
} catch {
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
273
309
|
function detectFlavor(cwd) {
|
|
274
310
|
let deps = {};
|
|
275
311
|
try {
|
|
@@ -279,6 +315,7 @@ function detectFlavor(cwd) {
|
|
|
279
315
|
}
|
|
280
316
|
if ("expo" in deps) return "expo";
|
|
281
317
|
if (existsSync3(join4(cwd, "android", "gradlew")) || iosWorkspaceName(cwd)) return "bare-rn";
|
|
318
|
+
if (isFile(join4(cwd, "manifest"))) return "roku";
|
|
282
319
|
return "unknown";
|
|
283
320
|
}
|
|
284
321
|
function iosWorkspaceName(cwd) {
|
|
@@ -291,9 +328,32 @@ function iosWorkspaceName(cwd) {
|
|
|
291
328
|
}
|
|
292
329
|
var IOS_ARTIFACT = "ios/build/Build/Products/Debug-iphonesimulator/*.app";
|
|
293
330
|
var ANDROID_ARTIFACT = "android/app/build/outputs/apk/debug/*.apk";
|
|
331
|
+
var ROKU_DEFAULT_ARTIFACT = "out/channel.zip";
|
|
332
|
+
var ROKU_ZIP_COMMAND = "zip -r out/channel.zip manifest source components images -x '*.DS_Store'";
|
|
333
|
+
function rokuDefaults(cwd) {
|
|
334
|
+
const pkg = readJson(cwd, "package.json");
|
|
335
|
+
const scripts = pkg?.scripts ?? {};
|
|
336
|
+
const runner = existsSync3(join4(cwd, "yarn.lock")) ? "yarn" : "npm run";
|
|
337
|
+
const packageScript = Object.entries(scripts).find(
|
|
338
|
+
([name, body]) => !name.startsWith("deploy") && (name.startsWith("package") || body.includes("--package-only") || body.includes("zipPackage"))
|
|
339
|
+
);
|
|
340
|
+
const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
|
|
341
|
+
let artifact = ROKU_DEFAULT_ARTIFACT;
|
|
342
|
+
if ("roku-deploy" in deps) {
|
|
343
|
+
const rd = readJson(cwd, "rokudeploy.json");
|
|
344
|
+
const outDir = rd?.outDir ?? "out";
|
|
345
|
+
const outFile = rd?.outFile ?? "roku-deploy.zip";
|
|
346
|
+
artifact = `${outDir}/${outFile}`;
|
|
347
|
+
}
|
|
348
|
+
if (packageScript) {
|
|
349
|
+
return { buildCommand: `${runner} ${packageScript[0]}`, artifact };
|
|
350
|
+
}
|
|
351
|
+
return { buildCommand: ROKU_ZIP_COMMAND, artifact };
|
|
352
|
+
}
|
|
294
353
|
function defaultsFor(cwd, platform) {
|
|
295
354
|
const flavor = detectFlavor(cwd);
|
|
296
|
-
if (
|
|
355
|
+
if (platform === "roku") return flavor === "roku" ? rokuDefaults(cwd) : {};
|
|
356
|
+
if (flavor === "unknown" || flavor === "roku") return {};
|
|
297
357
|
const metroCommand = flavor === "expo" ? "npx expo start" : "yarn start";
|
|
298
358
|
if (platform === "tvos" || platform === "androidtv") return { metroCommand };
|
|
299
359
|
if (platform === "android") {
|
|
@@ -319,6 +379,16 @@ function defaultsFor(cwd, platform) {
|
|
|
319
379
|
}
|
|
320
380
|
|
|
321
381
|
// src/lib/dev-setup.ts
|
|
382
|
+
function platformChoices(cwd) {
|
|
383
|
+
const choices = [
|
|
384
|
+
{ value: "ios", name: "iOS simulator" },
|
|
385
|
+
{ value: "android", name: "Android emulator" },
|
|
386
|
+
{ value: "tvos", name: "Apple TV simulator" },
|
|
387
|
+
{ value: "androidtv", name: "Android TV emulator" }
|
|
388
|
+
];
|
|
389
|
+
if (detectFlavor(cwd) === "roku") choices.unshift({ value: "roku", name: "Roku (beta)" });
|
|
390
|
+
return choices;
|
|
391
|
+
}
|
|
322
392
|
function firstRunAppStrategy(apps, buildConfigured) {
|
|
323
393
|
if (!buildConfigured) return "picker";
|
|
324
394
|
return apps.length === 0 ? "build_now" : "ask";
|
|
@@ -352,7 +422,7 @@ async function promptBuildConfig(cwd, platform) {
|
|
|
352
422
|
}
|
|
353
423
|
const command = await input({ message: `Build command for ${platform}`, required: true });
|
|
354
424
|
const artifact = await input({
|
|
355
|
-
message: "Path to the built app it produces (.apk, or .app dir; * allowed)",
|
|
425
|
+
message: platform === "roku" ? "Path to the channel zip it produces (* allowed)" : "Path to the built app it produces (.apk, or .app dir; * allowed)",
|
|
356
426
|
default: defaults.artifact,
|
|
357
427
|
required: true
|
|
358
428
|
});
|
|
@@ -425,12 +495,7 @@ async function buildCommand(opts) {
|
|
|
425
495
|
}
|
|
426
496
|
platform = await select2({
|
|
427
497
|
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
|
-
]
|
|
498
|
+
choices: platformChoices(cwd)
|
|
434
499
|
});
|
|
435
500
|
}
|
|
436
501
|
let build = cfg?.platforms?.[platform]?.build;
|
|
@@ -496,7 +561,7 @@ function fail(opts, message, code) {
|
|
|
496
561
|
// src/commands/dev.ts
|
|
497
562
|
import { spawn as spawn3 } from "child_process";
|
|
498
563
|
import { createWriteStream, mkdirSync as mkdirSync3 } from "fs";
|
|
499
|
-
import { dirname as
|
|
564
|
+
import { dirname as dirname5 } from "path";
|
|
500
565
|
import { createInterface as createInterface3 } from "readline";
|
|
501
566
|
import { select as select3 } from "@inquirer/prompts";
|
|
502
567
|
import chalk2 from "chalk";
|
|
@@ -815,19 +880,163 @@ async function waitFor(cond, timeoutMs, everyMs) {
|
|
|
815
880
|
}
|
|
816
881
|
}
|
|
817
882
|
|
|
883
|
+
// src/lib/reload-loop.ts
|
|
884
|
+
import { watch } from "fs";
|
|
885
|
+
import { basename as basename2, dirname as dirname4 } from "path";
|
|
886
|
+
function createReloadLoop(deps) {
|
|
887
|
+
let inFlight = null;
|
|
888
|
+
let pendingFollowUp = false;
|
|
889
|
+
let debounceTimer = null;
|
|
890
|
+
let stopWatch = null;
|
|
891
|
+
let stopped = false;
|
|
892
|
+
async function run() {
|
|
893
|
+
inFlight = (async () => {
|
|
894
|
+
try {
|
|
895
|
+
await deps.push();
|
|
896
|
+
} catch (err) {
|
|
897
|
+
(deps.logError ?? deps.log)(err.message);
|
|
898
|
+
}
|
|
899
|
+
})();
|
|
900
|
+
try {
|
|
901
|
+
await inFlight;
|
|
902
|
+
} finally {
|
|
903
|
+
inFlight = null;
|
|
904
|
+
}
|
|
905
|
+
if (pendingFollowUp && !stopped) {
|
|
906
|
+
pendingFollowUp = false;
|
|
907
|
+
await run();
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
async function trigger() {
|
|
911
|
+
if (stopped) return;
|
|
912
|
+
if (inFlight) {
|
|
913
|
+
deps.log("already reloading \u2014 ignoring");
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
if (debounceTimer) {
|
|
917
|
+
clearTimeout(debounceTimer);
|
|
918
|
+
debounceTimer = null;
|
|
919
|
+
}
|
|
920
|
+
await run();
|
|
921
|
+
}
|
|
922
|
+
function onChange() {
|
|
923
|
+
if (stopped) return;
|
|
924
|
+
if (inFlight) {
|
|
925
|
+
pendingFollowUp = true;
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
929
|
+
debounceTimer = setTimeout(
|
|
930
|
+
() => {
|
|
931
|
+
debounceTimer = null;
|
|
932
|
+
void trigger();
|
|
933
|
+
},
|
|
934
|
+
(deps.watchSeconds ?? 2) * 1e3
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
if (deps.watch && deps.watchSeconds !== void 0) {
|
|
938
|
+
stopWatch = deps.watch(onChange);
|
|
939
|
+
}
|
|
940
|
+
return {
|
|
941
|
+
trigger,
|
|
942
|
+
stop() {
|
|
943
|
+
stopped = true;
|
|
944
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
945
|
+
stopWatch?.();
|
|
946
|
+
}
|
|
947
|
+
};
|
|
948
|
+
}
|
|
949
|
+
var IGNORED_SEGMENTS = ["node_modules", ".git", ".vibeview"];
|
|
950
|
+
var IGNORED_FILES = ["vibeview.json"];
|
|
951
|
+
function isUnderPath(rel, entry) {
|
|
952
|
+
const norm = normalizeRel(entry).replace(/\/+$/, "");
|
|
953
|
+
if (!norm) return false;
|
|
954
|
+
return rel === norm || rel.startsWith(`${norm}/`);
|
|
955
|
+
}
|
|
956
|
+
function normalizeRel(p) {
|
|
957
|
+
return p.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
958
|
+
}
|
|
959
|
+
function isIgnoredWatchPath(relPath, artifact, userIgnore = []) {
|
|
960
|
+
const rel = normalizeRel(relPath);
|
|
961
|
+
const segments = rel.split("/");
|
|
962
|
+
if (segments.some((s) => IGNORED_SEGMENTS.includes(s) || s.includes("staging"))) return true;
|
|
963
|
+
if (IGNORED_FILES.includes(rel)) return true;
|
|
964
|
+
if (userIgnore.some((entry) => isUnderPath(rel, entry))) return true;
|
|
965
|
+
const artifactPattern = artifact.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
966
|
+
const artifactDir = dirname4(artifactPattern);
|
|
967
|
+
if (artifactDir !== "." && (rel === artifactDir || rel.startsWith(`${artifactDir}/`))) {
|
|
968
|
+
return true;
|
|
969
|
+
}
|
|
970
|
+
if (dirname4(rel) !== artifactDir) return false;
|
|
971
|
+
const name = basename2(artifactPattern);
|
|
972
|
+
const re = new RegExp(`^${name.split("*").map(escapeRegExp2).join(".*")}$`);
|
|
973
|
+
return re.test(basename2(rel));
|
|
974
|
+
}
|
|
975
|
+
function escapeRegExp2(s) {
|
|
976
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
977
|
+
}
|
|
978
|
+
function watchProjectFiles(cwd, artifact, userIgnore = []) {
|
|
979
|
+
return (onChange) => {
|
|
980
|
+
const watcher = watch(cwd, { recursive: true }, (_event, filename) => {
|
|
981
|
+
if (filename !== null && isIgnoredWatchPath(filename.toString(), artifact, userIgnore)) {
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
onChange();
|
|
985
|
+
});
|
|
986
|
+
return () => watcher.close();
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
// src/lib/roku-push.ts
|
|
991
|
+
function createRokuPush(opts) {
|
|
992
|
+
const runBuild = opts.runBuild ?? runBuildForPlatform;
|
|
993
|
+
return async () => {
|
|
994
|
+
const started = Date.now();
|
|
995
|
+
let result;
|
|
996
|
+
try {
|
|
997
|
+
result = await runBuild(
|
|
998
|
+
opts.cwd,
|
|
999
|
+
"roku",
|
|
1000
|
+
opts.build,
|
|
1001
|
+
{ token: opts.token, baseUrl: opts.baseUrl },
|
|
1002
|
+
opts.log
|
|
1003
|
+
);
|
|
1004
|
+
} catch (err) {
|
|
1005
|
+
const message = err.message;
|
|
1006
|
+
if (/artifact not found/i.test(message)) {
|
|
1007
|
+
throw new Error(
|
|
1008
|
+
`${message}
|
|
1009
|
+
your build command must produce a zip \u2014 use the package/zip step, not deploy`
|
|
1010
|
+
);
|
|
1011
|
+
}
|
|
1012
|
+
throw err;
|
|
1013
|
+
}
|
|
1014
|
+
if (!result.build_id) {
|
|
1015
|
+
throw new Error(`upload returned no build id for ${result.name ?? result.public_id}`);
|
|
1016
|
+
}
|
|
1017
|
+
await opts.api.installBuild(opts.sessionId, result.build_id, true);
|
|
1018
|
+
opts.log(`reloaded in ${((Date.now() - started) / 1e3).toFixed(1)}s`);
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
|
|
818
1022
|
// src/commands/dev.ts
|
|
1023
|
+
function parseWatchSeconds(flag) {
|
|
1024
|
+
if (flag === void 0 || flag === false) return void 0;
|
|
1025
|
+
if (flag === true) return 2;
|
|
1026
|
+
const seconds = Number(flag);
|
|
1027
|
+
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
1028
|
+
throw new Error(`--watch expects a positive number of seconds, got "${flag}"`);
|
|
1029
|
+
}
|
|
1030
|
+
return seconds;
|
|
1031
|
+
}
|
|
819
1032
|
function emit(json, evt) {
|
|
820
1033
|
if (json) console.log(JSON.stringify(evt));
|
|
821
1034
|
}
|
|
822
1035
|
async function startDevSession(cfg, onEvent = () => {
|
|
823
1036
|
}, onTunnelState) {
|
|
824
1037
|
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
|
-
});
|
|
1038
|
+
const usesTunnel = PLATFORM_NEEDS_METRO[cfg.platform];
|
|
1039
|
+
const created = await api.createSession(devSessionCreateBody(cfg));
|
|
831
1040
|
const sessionId = created.session_id;
|
|
832
1041
|
let stopping = false;
|
|
833
1042
|
let tunnel = null;
|
|
@@ -841,52 +1050,66 @@ async function startDevSession(cfg, onEvent = () => {
|
|
|
841
1050
|
};
|
|
842
1051
|
try {
|
|
843
1052
|
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
1053
|
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
|
-
}
|
|
1054
|
+
const reportedWarnings = /* @__PURE__ */ new Set();
|
|
1055
|
+
if (usesTunnel) {
|
|
1056
|
+
const ready = await pollUntilTunnelReady(api, sessionId, 18e4);
|
|
1057
|
+
for (const w of ready.warnings ?? []) {
|
|
1058
|
+
reportedWarnings.add(w);
|
|
1059
|
+
onEvent({ event: "warning", message: w });
|
|
874
1060
|
}
|
|
875
|
-
|
|
876
|
-
|
|
1061
|
+
tunnel = new TunnelClient({
|
|
1062
|
+
url: ready.tunnel_url,
|
|
1063
|
+
metroPort: cfg.metroPort,
|
|
1064
|
+
// Re-mint the URL on each reconnect: the tunnel token has a ~1h TTL and
|
|
1065
|
+
// a worker restart drops the proxy registration, so the original URL
|
|
1066
|
+
// eventually goes stale. Every authorized GET /sessions/{id} re-signs a
|
|
1067
|
+
// fresh tunnel token. Null (session stopped/failed) ends the tunnel.
|
|
1068
|
+
refreshUrl: async () => {
|
|
1069
|
+
const info = await api.getSession(sessionId);
|
|
1070
|
+
if (info.status === "stopped" || info.status === "failed") return null;
|
|
1071
|
+
return info.tunnel_url ?? null;
|
|
1072
|
+
},
|
|
1073
|
+
onDebug: cfg.debug ? (msg) => onEvent({ event: "warning", message: `[tunnel] ${msg}` }) : void 0,
|
|
1074
|
+
onState: (s) => {
|
|
1075
|
+
onTunnelState?.(s);
|
|
1076
|
+
if (s === "connected") {
|
|
1077
|
+
onEvent({ event: "tunnel_connected", session_id: sessionId });
|
|
1078
|
+
} else if (s === "metro-unreachable") {
|
|
1079
|
+
onEvent({
|
|
1080
|
+
event: "warning",
|
|
1081
|
+
message: `can't reach Metro on localhost:${cfg.metroPort} \u2014 is it running? (yarn start)`
|
|
1082
|
+
});
|
|
1083
|
+
} else if (s === "closed" && !stopping) {
|
|
1084
|
+
void stopSession(1);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
});
|
|
1088
|
+
tunnel.start();
|
|
1089
|
+
}
|
|
877
1090
|
if (cfg.open !== false) await openBrowser(pageUrl).catch(() => {
|
|
878
1091
|
});
|
|
879
1092
|
const active = await pollUntilActive(api, sessionId, 18e4);
|
|
880
|
-
const seen = new Set(ready.warnings ?? []);
|
|
881
1093
|
for (const w of active.warnings ?? []) {
|
|
882
|
-
if (
|
|
1094
|
+
if (reportedWarnings.has(w)) continue;
|
|
1095
|
+
reportedWarnings.add(w);
|
|
1096
|
+
onEvent({ event: "warning", message: w });
|
|
883
1097
|
}
|
|
884
|
-
return { sessionId, pageUrl, stop: () => stopSession(0) };
|
|
1098
|
+
return { sessionId, pageUrl, stop: () => stopSession(0), reportedWarnings };
|
|
885
1099
|
} catch (err) {
|
|
886
1100
|
await stopSession(1);
|
|
887
1101
|
throw err;
|
|
888
1102
|
}
|
|
889
1103
|
}
|
|
1104
|
+
function devSessionCreateBody(cfg) {
|
|
1105
|
+
return {
|
|
1106
|
+
device_type: PLATFORM_DEVICE[cfg.platform].deviceType,
|
|
1107
|
+
device_category: cfg.deviceCategory,
|
|
1108
|
+
app_id: cfg.appId,
|
|
1109
|
+
// Roku has no JS bundle to serve, so there is nothing to tunnel.
|
|
1110
|
+
tunnel: PLATFORM_NEEDS_METRO[cfg.platform]
|
|
1111
|
+
};
|
|
1112
|
+
}
|
|
890
1113
|
var BLOCKING_WARNING_PREFIXES = ["No debug build", "App install failed", "App not found"];
|
|
891
1114
|
function findBlockingWarning(info) {
|
|
892
1115
|
return (info.warnings ?? []).find((w) => BLOCKING_WARNING_PREFIXES.some((p) => w.includes(p)));
|
|
@@ -921,11 +1144,38 @@ async function pollUntilActive(api, sessionId, timeoutMs, intervalMs = 2e3) {
|
|
|
921
1144
|
await new Promise((r) => setTimeout(r, intervalMs));
|
|
922
1145
|
}
|
|
923
1146
|
}
|
|
1147
|
+
function sessionLivenessVerdict(info) {
|
|
1148
|
+
if (info && (info.status === "stopped" || info.status === "failed")) {
|
|
1149
|
+
return { end: true, message: `session ${info.status} \u2014 ending the reload loop` };
|
|
1150
|
+
}
|
|
1151
|
+
return { end: false };
|
|
1152
|
+
}
|
|
1153
|
+
function lateWarningVerdict(info, reportedWarnings) {
|
|
1154
|
+
if (!info) return { toReport: [], blocking: false };
|
|
1155
|
+
const toReport = (info.warnings ?? []).filter((w) => !reportedWarnings.has(w));
|
|
1156
|
+
const blocking = findBlockingWarning({ warnings: toReport }) !== void 0;
|
|
1157
|
+
return { toReport, blocking };
|
|
1158
|
+
}
|
|
1159
|
+
var ROKU_LIVENESS_INTERVAL_MS = 1e4;
|
|
1160
|
+
function rokuKeyAction(char) {
|
|
1161
|
+
if (char === "") return "interrupt";
|
|
1162
|
+
switch (char.toLowerCase()) {
|
|
1163
|
+
case "r":
|
|
1164
|
+
return "reload";
|
|
1165
|
+
case "q":
|
|
1166
|
+
return "quit";
|
|
1167
|
+
default:
|
|
1168
|
+
return null;
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
924
1171
|
var PLATFORM_DEVICE = {
|
|
925
1172
|
ios: { deviceType: "ios", tv: false },
|
|
926
1173
|
android: { deviceType: "android", tv: false },
|
|
927
1174
|
tvos: { deviceType: "ios", tv: true },
|
|
928
|
-
androidtv: { deviceType: "android", tv: true }
|
|
1175
|
+
androidtv: { deviceType: "android", tv: true },
|
|
1176
|
+
// Roku IS its own device type (unlike tvOS/Android TV, which are the
|
|
1177
|
+
// handheld type plus category tv) — the worker registers it as such.
|
|
1178
|
+
roku: { deviceType: "roku", tv: true }
|
|
929
1179
|
};
|
|
930
1180
|
function appsForPlatform(apps, platform) {
|
|
931
1181
|
return apps.filter((a) => a.platform === platform);
|
|
@@ -953,7 +1203,7 @@ function requireNonInteractive(json) {
|
|
|
953
1203
|
async function ensureConfig(api, opts) {
|
|
954
1204
|
const existing = loadDevConfig(process.cwd());
|
|
955
1205
|
if (opts.platform && !isDevPlatform(opts.platform)) {
|
|
956
|
-
console.error(chalk2.red(`error: --platform must be one of
|
|
1206
|
+
console.error(chalk2.red(`error: --platform must be one of ${DEV_PLATFORMS.join(", ")}`));
|
|
957
1207
|
process.exit(2);
|
|
958
1208
|
}
|
|
959
1209
|
if (!opts.platform && !existing?.defaultPlatform) {
|
|
@@ -961,12 +1211,7 @@ async function ensureConfig(api, opts) {
|
|
|
961
1211
|
}
|
|
962
1212
|
const platform = opts.platform || existing?.defaultPlatform || await select3({
|
|
963
1213
|
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
|
-
]
|
|
1214
|
+
choices: platformChoices(process.cwd())
|
|
970
1215
|
});
|
|
971
1216
|
const isTv = PLATFORM_DEVICE[platform].tv;
|
|
972
1217
|
if (isTv && opts.deviceCategory) {
|
|
@@ -988,7 +1233,9 @@ async function ensureConfig(api, opts) {
|
|
|
988
1233
|
requireNonInteractive(opts.json);
|
|
989
1234
|
} else if (process.stdin.isTTY) {
|
|
990
1235
|
firstRunBuild = await promptBuildConfig(process.cwd(), platform);
|
|
991
|
-
|
|
1236
|
+
if (PLATFORM_NEEDS_METRO[platform]) {
|
|
1237
|
+
firstRunMetro = await promptMetroCommand(process.cwd(), platform);
|
|
1238
|
+
}
|
|
992
1239
|
}
|
|
993
1240
|
}
|
|
994
1241
|
const entry = existing?.platforms?.[platform];
|
|
@@ -1098,7 +1345,10 @@ Run \`vibeview dev\` without --app to pick from the list.`
|
|
|
1098
1345
|
});
|
|
1099
1346
|
saveDevConfig(process.cwd(), cfg);
|
|
1100
1347
|
if (isFirstRun) {
|
|
1101
|
-
const hint = fillLaterHint(
|
|
1348
|
+
const hint = fillLaterHint(
|
|
1349
|
+
{ build: !firstRunBuild, metro: PLATFORM_NEEDS_METRO[platform] && !firstRunMetro },
|
|
1350
|
+
platform
|
|
1351
|
+
);
|
|
1102
1352
|
if (hint) console.error(chalk2.dim(hint));
|
|
1103
1353
|
}
|
|
1104
1354
|
const finalMetro = isFirstRun ? resolveMetro(cfg, platform, opts.metroPort ? parseInt(opts.metroPort, 10) : void 0) : metroResolved;
|
|
@@ -1113,6 +1363,15 @@ Run \`vibeview dev\` without --app to pick from the list.`
|
|
|
1113
1363
|
};
|
|
1114
1364
|
}
|
|
1115
1365
|
async function runDevInProcess(opts) {
|
|
1366
|
+
let watchSeconds;
|
|
1367
|
+
try {
|
|
1368
|
+
watchSeconds = parseWatchSeconds(opts.watch);
|
|
1369
|
+
} catch (err) {
|
|
1370
|
+
const message = err.message;
|
|
1371
|
+
console.error(chalk2.red(`error: ${message}`));
|
|
1372
|
+
emit(opts.json, { event: "error", code: "invalid_flag", message });
|
|
1373
|
+
process.exit(2);
|
|
1374
|
+
}
|
|
1116
1375
|
if (opts.json) {
|
|
1117
1376
|
const hasToken = !!(opts.token || process.env.VIBEVIEW_API_TOKEN || loadConfig().token);
|
|
1118
1377
|
if (!hasToken) {
|
|
@@ -1158,51 +1417,68 @@ async function runDevInProcess(opts) {
|
|
|
1158
1417
|
process.exit(1);
|
|
1159
1418
|
}
|
|
1160
1419
|
}
|
|
1161
|
-
|
|
1162
|
-
let
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
const
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1420
|
+
let metroOwnedThisRun = false;
|
|
1421
|
+
let rokuBuild;
|
|
1422
|
+
let watchIgnore = [];
|
|
1423
|
+
if (PLATFORM_NEEDS_METRO[cfg.platform]) {
|
|
1424
|
+
const metroPort = cfg.metroPort ?? 8081;
|
|
1425
|
+
let metroResult;
|
|
1426
|
+
try {
|
|
1427
|
+
let metroLogStream = null;
|
|
1428
|
+
const captureMetroLine = (line) => {
|
|
1429
|
+
try {
|
|
1430
|
+
if (!metroLogStream) {
|
|
1431
|
+
mkdirSync3(dirname5(metroLogPath(process.cwd())), { recursive: true });
|
|
1432
|
+
metroLogStream = createWriteStream(metroLogPath(process.cwd()), { flags: "w" });
|
|
1433
|
+
}
|
|
1434
|
+
metroLogStream.write(line.replace(/^\[metro\] /, "") + "\n");
|
|
1435
|
+
} catch {
|
|
1170
1436
|
}
|
|
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
|
-
|
|
1437
|
+
if (shouldEchoMetroLine(line, opts.debug === true)) console.error(chalk2.dim(line));
|
|
1438
|
+
};
|
|
1439
|
+
metroResult = await ensureMetro(
|
|
1440
|
+
process.cwd(),
|
|
1441
|
+
{
|
|
1442
|
+
command: cfg.metroCommand,
|
|
1443
|
+
port: metroPort,
|
|
1444
|
+
platformHasOverride: cfg.platformHasMetroOverride
|
|
1445
|
+
},
|
|
1446
|
+
captureMetroLine
|
|
1447
|
+
);
|
|
1448
|
+
} catch (err) {
|
|
1449
|
+
const message = err.message;
|
|
1450
|
+
console.error(chalk2.red(`error: ${message}`));
|
|
1451
|
+
emit(opts.json, { event: "error", code: "metro_down", message });
|
|
1452
|
+
process.exit(1);
|
|
1453
|
+
}
|
|
1454
|
+
for (const w of metroResult.warnings) console.error(chalk2.yellow(`\u26A0 ${w}`));
|
|
1455
|
+
if (metroResult.status === "spawned" || metroResult.status === "respawned") {
|
|
1456
|
+
console.error(chalk2.green(`\u2713 started Metro (${cfg.metroCommand})`));
|
|
1457
|
+
console.error(
|
|
1458
|
+
chalk2.dim(` full Metro output: ${metroLogPath(process.cwd())} (--debug echoes it here)`)
|
|
1459
|
+
);
|
|
1460
|
+
}
|
|
1461
|
+
metroOwnedThisRun = metroResult.status === "spawned" || metroResult.status === "respawned";
|
|
1462
|
+
} else {
|
|
1463
|
+
const rokuEntry = loadDevConfig(process.cwd())?.platforms?.[cfg.platform];
|
|
1464
|
+
rokuBuild = rokuEntry?.build;
|
|
1465
|
+
watchIgnore = rokuEntry?.watchIgnore ?? [];
|
|
1466
|
+
if (!rokuBuild) {
|
|
1467
|
+
const message = `no build config for ${cfg.platform} \u2014 run \`vibeview build --platform ${cfg.platform}\` once to set it up`;
|
|
1468
|
+
console.error(chalk2.red(`error: ${message}`));
|
|
1469
|
+
emit(opts.json, { event: "error", code: "config_required", message });
|
|
1470
|
+
process.exit(1);
|
|
1471
|
+
}
|
|
1197
1472
|
}
|
|
1198
|
-
const metroOwnedThisRun = metroResult.status === "spawned" || metroResult.status === "respawned";
|
|
1199
1473
|
console.error(chalk2.dim(`Starting a ${cfg.platform} dev session for ${cfg.appId}...`));
|
|
1200
1474
|
let stopping = false;
|
|
1201
1475
|
let handle = null;
|
|
1476
|
+
let reloadLoop = null;
|
|
1202
1477
|
const stopSession = async (code) => {
|
|
1203
1478
|
if (stopping) return;
|
|
1204
1479
|
stopping = true;
|
|
1205
1480
|
console.error(chalk2.dim("\nStopping session..."));
|
|
1481
|
+
reloadLoop?.stop();
|
|
1206
1482
|
await handle?.stop();
|
|
1207
1483
|
if (metroOwnedThisRun) killOwnedMetro(process.cwd());
|
|
1208
1484
|
clearDevState(process.cwd());
|
|
@@ -1213,6 +1489,7 @@ async function runDevInProcess(opts) {
|
|
|
1213
1489
|
let everConnected = false;
|
|
1214
1490
|
let waitingPrinted = false;
|
|
1215
1491
|
let nextWarningIsMetroUnreachable = false;
|
|
1492
|
+
let endedEmitted = false;
|
|
1216
1493
|
try {
|
|
1217
1494
|
handle = await startDevSession(
|
|
1218
1495
|
{
|
|
@@ -1243,10 +1520,15 @@ async function runDevInProcess(opts) {
|
|
|
1243
1520
|
} else if (evt.event === "session_ended" && !stopping && handle) {
|
|
1244
1521
|
console.error(chalk2.red("tunnel closed by server \u2014 session ended"));
|
|
1245
1522
|
stopping = true;
|
|
1523
|
+
endedEmitted = true;
|
|
1246
1524
|
emit(opts.json, evt);
|
|
1247
1525
|
clearDevState(process.cwd());
|
|
1248
1526
|
process.exit(1);
|
|
1249
1527
|
}
|
|
1528
|
+
if (evt.event === "session_ended") {
|
|
1529
|
+
if (endedEmitted) return;
|
|
1530
|
+
endedEmitted = true;
|
|
1531
|
+
}
|
|
1250
1532
|
emit(opts.json, evt);
|
|
1251
1533
|
},
|
|
1252
1534
|
(s) => {
|
|
@@ -1266,12 +1548,11 @@ async function runDevInProcess(opts) {
|
|
|
1266
1548
|
}
|
|
1267
1549
|
}
|
|
1268
1550
|
);
|
|
1269
|
-
|
|
1270
|
-
|
|
1551
|
+
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.";
|
|
1552
|
+
console.error(`
|
|
1271
1553
|
Watch and interact: ${chalk2.cyan(handle.pageUrl)}
|
|
1272
|
-
|
|
1273
|
-
`
|
|
1274
|
-
);
|
|
1554
|
+
${keysLine}
|
|
1555
|
+
`);
|
|
1275
1556
|
writeDevState(process.cwd(), {
|
|
1276
1557
|
pid: process.pid,
|
|
1277
1558
|
session_id: handle.sessionId,
|
|
@@ -1289,6 +1570,86 @@ async function runDevInProcess(opts) {
|
|
|
1289
1570
|
});
|
|
1290
1571
|
if (opts.open !== false) await openBrowser(handle.pageUrl).catch(() => {
|
|
1291
1572
|
});
|
|
1573
|
+
if (rokuBuild) {
|
|
1574
|
+
reloadLoop = createReloadLoop({
|
|
1575
|
+
push: createRokuPush({
|
|
1576
|
+
cwd: process.cwd(),
|
|
1577
|
+
sessionId: handle.sessionId,
|
|
1578
|
+
build: rokuBuild,
|
|
1579
|
+
token,
|
|
1580
|
+
baseUrl,
|
|
1581
|
+
api,
|
|
1582
|
+
// Build output and loop notices are human output, so they go to
|
|
1583
|
+
// stderr like every other line this command prints — stdout stays
|
|
1584
|
+
// reserved for the `--json` event stream.
|
|
1585
|
+
log: (line) => console.error(chalk2.dim(line))
|
|
1586
|
+
}),
|
|
1587
|
+
log: (line) => console.error(chalk2.yellow(line)),
|
|
1588
|
+
// A failed reload is an error, not a notice — same red as every other
|
|
1589
|
+
// failure this command prints, so it doesn't read like the yellow
|
|
1590
|
+
// "already reloading" line.
|
|
1591
|
+
logError: (line) => console.error(chalk2.red(`\u2717 ${line}`)),
|
|
1592
|
+
...watchSeconds !== void 0 ? {
|
|
1593
|
+
watchSeconds,
|
|
1594
|
+
watch: watchProjectFiles(process.cwd(), rokuBuild.artifact, watchIgnore)
|
|
1595
|
+
} : {}
|
|
1596
|
+
});
|
|
1597
|
+
if (watchSeconds !== void 0) {
|
|
1598
|
+
console.error(
|
|
1599
|
+
chalk2.dim(` Watching for changes \u2014 reloading after ${watchSeconds}s of quiet.`)
|
|
1600
|
+
);
|
|
1601
|
+
}
|
|
1602
|
+
if (process.stdin.isTTY) {
|
|
1603
|
+
process.stdin.setRawMode(true);
|
|
1604
|
+
process.stdin.resume();
|
|
1605
|
+
process.stdin.setEncoding("utf8");
|
|
1606
|
+
const onKeyData = (chunk) => {
|
|
1607
|
+
for (const char of chunk) {
|
|
1608
|
+
const action = rokuKeyAction(char);
|
|
1609
|
+
if (action === "reload") void reloadLoop?.trigger();
|
|
1610
|
+
else if (action === "quit" || action === "interrupt") void stopSession(0);
|
|
1611
|
+
}
|
|
1612
|
+
};
|
|
1613
|
+
process.stdin.on("data", onKeyData);
|
|
1614
|
+
process.on("exit", () => {
|
|
1615
|
+
process.stdin.removeListener("data", onKeyData);
|
|
1616
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
1617
|
+
process.stdin.pause();
|
|
1618
|
+
});
|
|
1619
|
+
}
|
|
1620
|
+
const sessionId = handle.sessionId;
|
|
1621
|
+
const reportedWarnings = handle.reportedWarnings;
|
|
1622
|
+
const keepAlive = setInterval(() => {
|
|
1623
|
+
void (async () => {
|
|
1624
|
+
if (stopping) return;
|
|
1625
|
+
let info = null;
|
|
1626
|
+
try {
|
|
1627
|
+
info = await api.getSession(sessionId);
|
|
1628
|
+
} catch {
|
|
1629
|
+
}
|
|
1630
|
+
const verdict = sessionLivenessVerdict(info);
|
|
1631
|
+
if (verdict.end && !stopping) {
|
|
1632
|
+
console.error(chalk2.red(verdict.message));
|
|
1633
|
+
emit(opts.json, { event: "session_ended", session_id: sessionId, code: 1 });
|
|
1634
|
+
endedEmitted = true;
|
|
1635
|
+
await stopSession(1);
|
|
1636
|
+
return;
|
|
1637
|
+
}
|
|
1638
|
+
const warningVerdict = lateWarningVerdict(info, reportedWarnings);
|
|
1639
|
+
for (const w of warningVerdict.toReport) {
|
|
1640
|
+
reportedWarnings.add(w);
|
|
1641
|
+
console.error(chalk2.yellow(`\u26A0 ${w}`));
|
|
1642
|
+
emit(opts.json, { event: "warning", message: w });
|
|
1643
|
+
}
|
|
1644
|
+
if (warningVerdict.blocking && !stopping) {
|
|
1645
|
+
emit(opts.json, { event: "session_ended", session_id: sessionId, code: 1 });
|
|
1646
|
+
endedEmitted = true;
|
|
1647
|
+
await stopSession(1);
|
|
1648
|
+
}
|
|
1649
|
+
})();
|
|
1650
|
+
}, ROKU_LIVENESS_INTERVAL_MS);
|
|
1651
|
+
process.on("exit", () => clearInterval(keepAlive));
|
|
1652
|
+
}
|
|
1292
1653
|
} catch (err) {
|
|
1293
1654
|
const message = err.message;
|
|
1294
1655
|
console.error(chalk2.red(`error: ${message}`));
|
|
@@ -2055,6 +2416,7 @@ async function runAgentVerb(cmd, positionals, opts) {
|
|
|
2055
2416
|
export {
|
|
2056
2417
|
readDevState,
|
|
2057
2418
|
clearDevState,
|
|
2419
|
+
PLATFORM_NEEDS_METRO,
|
|
2058
2420
|
isDevPlatform,
|
|
2059
2421
|
loadDevConfig,
|
|
2060
2422
|
checkMetro,
|
|
@@ -44,13 +44,31 @@ var ApiClient = class {
|
|
|
44
44
|
this.baseUrl = baseUrl;
|
|
45
45
|
this.token = token;
|
|
46
46
|
}
|
|
47
|
+
/**
|
|
48
|
+
* fetch() rejects with undici's bare "TypeError: fetch failed" on any
|
|
49
|
+
* network-level failure (connection refused, DNS, TLS). Translate that
|
|
50
|
+
* into a message that names the base URL — a stray VIBEVIEW_BASE_URL
|
|
51
|
+
* pointing at a dead dev backend is indistinguishable from an outage
|
|
52
|
+
* otherwise.
|
|
53
|
+
*/
|
|
54
|
+
async fetchOrExplain(url, init) {
|
|
55
|
+
try {
|
|
56
|
+
return await fetch(url, init);
|
|
57
|
+
} catch (e) {
|
|
58
|
+
const cause = e.cause;
|
|
59
|
+
const detail = cause?.code ? ` (${cause.code})` : "";
|
|
60
|
+
throw new Error(
|
|
61
|
+
`Cannot reach ${this.baseUrl}${detail}. Check the server URL (--base-url flag, VIBEVIEW_BASE_URL, or ~/.vibeview/config.json) and your network.`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
47
65
|
async request(method, path, body) {
|
|
48
66
|
const url = `${this.baseUrl}/api/v1/ci${path}`;
|
|
49
67
|
const headers = {
|
|
50
68
|
Authorization: `Bearer ${this.token}`,
|
|
51
69
|
"Content-Type": "application/json"
|
|
52
70
|
};
|
|
53
|
-
const resp = await
|
|
71
|
+
const resp = await this.fetchOrExplain(url, {
|
|
54
72
|
method,
|
|
55
73
|
headers,
|
|
56
74
|
body: body ? JSON.stringify(body) : void 0
|
|
@@ -122,7 +140,7 @@ var ApiClient = class {
|
|
|
122
140
|
Authorization: `Bearer ${this.token}`,
|
|
123
141
|
"Content-Type": "application/json"
|
|
124
142
|
};
|
|
125
|
-
const resp = await
|
|
143
|
+
const resp = await this.fetchOrExplain(url, {
|
|
126
144
|
method,
|
|
127
145
|
headers,
|
|
128
146
|
body: body ? JSON.stringify(body) : void 0
|
|
@@ -142,7 +160,7 @@ var ApiClient = class {
|
|
|
142
160
|
Authorization: `Bearer ${this.token}`
|
|
143
161
|
// Do NOT set Content-Type — let fetch set multipart boundary automatically
|
|
144
162
|
};
|
|
145
|
-
const resp = await
|
|
163
|
+
const resp = await this.fetchOrExplain(url, {
|
|
146
164
|
method,
|
|
147
165
|
headers,
|
|
148
166
|
body: formData
|
|
@@ -214,6 +232,15 @@ var ApiClient = class {
|
|
|
214
232
|
async stopSession(sessionId) {
|
|
215
233
|
await this.requestRaw("DELETE", `/sessions/${sessionId}`);
|
|
216
234
|
}
|
|
235
|
+
/** Install an already-uploaded build into a running session. Used by the
|
|
236
|
+
* Roku dev reload loop; the worker sideloads and (with launchAfterInstall)
|
|
237
|
+
* relaunches the channel in place. */
|
|
238
|
+
async installBuild(sessionId, buildId, launchAfterInstall = true) {
|
|
239
|
+
return this.requestRaw("POST", `/sessions/${sessionId}/install-build`, {
|
|
240
|
+
build_id: buildId,
|
|
241
|
+
launch_after_install: launchAfterInstall
|
|
242
|
+
});
|
|
243
|
+
}
|
|
217
244
|
async agentTool(sessionId, tool, args) {
|
|
218
245
|
return this.requestRaw("POST", `/sessions/${sessionId}/agent/tools`, { tool, args });
|
|
219
246
|
}
|
|
@@ -223,7 +250,7 @@ var ApiClient = class {
|
|
|
223
250
|
import ora from "ora";
|
|
224
251
|
function createSpinner(text) {
|
|
225
252
|
const isCI = process.env.CI === "true" || process.env.CI === "1" || !process.stdout.isTTY;
|
|
226
|
-
return ora({ text, isEnabled: !isCI, stream: process.stderr });
|
|
253
|
+
return ora({ text, isEnabled: !isCI, stream: process.stderr, discardStdin: false });
|
|
227
254
|
}
|
|
228
255
|
|
|
229
256
|
// src/lib/types.ts
|
package/dist/cli.js
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
readDevState,
|
|
9
9
|
readMetroState,
|
|
10
10
|
runAgentVerb
|
|
11
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-4UGLSIZM.js";
|
|
12
12
|
import {
|
|
13
13
|
ApiClient,
|
|
14
14
|
EXIT_INFRA_ERROR,
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
resolveAuth,
|
|
20
20
|
saveConfig,
|
|
21
21
|
uploadApp
|
|
22
|
-
} from "./chunk-
|
|
22
|
+
} from "./chunk-AG3RZCPW.js";
|
|
23
23
|
|
|
24
24
|
// src/cli.ts
|
|
25
25
|
import { createRequire } from "module";
|
|
@@ -1022,6 +1022,37 @@ Opening browser to log in...
|
|
|
1022
1022
|
process.exit(EXIT_INFRA_ERROR);
|
|
1023
1023
|
}
|
|
1024
1024
|
|
|
1025
|
+
// src/commands/logout.ts
|
|
1026
|
+
async function logout() {
|
|
1027
|
+
const config = loadConfig();
|
|
1028
|
+
if (!config.token) {
|
|
1029
|
+
console.log("Not logged in (no token saved in ~/.vibeview/config.json).");
|
|
1030
|
+
if (process.env.VIBEVIEW_API_TOKEN) {
|
|
1031
|
+
console.log("Note: VIBEVIEW_API_TOKEN is set; logout does not manage env tokens.");
|
|
1032
|
+
}
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
const baseUrl = config.baseUrl || "https://vibeview.io";
|
|
1036
|
+
let revoked = false;
|
|
1037
|
+
try {
|
|
1038
|
+
const resp = await fetch(`${baseUrl}/api/v1/auth/cli-token/revoke`, {
|
|
1039
|
+
method: "POST",
|
|
1040
|
+
headers: { Authorization: `Bearer ${config.token}` }
|
|
1041
|
+
});
|
|
1042
|
+
revoked = resp.ok || resp.status === 401;
|
|
1043
|
+
} catch {
|
|
1044
|
+
revoked = false;
|
|
1045
|
+
}
|
|
1046
|
+
saveConfig({ ...config, token: void 0 });
|
|
1047
|
+
if (revoked) {
|
|
1048
|
+
console.log("Logged out. Token revoked and removed from ~/.vibeview/config.json.");
|
|
1049
|
+
} else {
|
|
1050
|
+
console.log("Logged out. Removed token from ~/.vibeview/config.json.");
|
|
1051
|
+
console.log(` Warning: could not reach ${baseUrl} to revoke the token server-side.`);
|
|
1052
|
+
console.log(" It stays active until revoked in the dashboard (Settings -> API Tokens).");
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1025
1056
|
// src/commands/dev-status.ts
|
|
1026
1057
|
import chalk9 from "chalk";
|
|
1027
1058
|
async function devStatus(opts) {
|
|
@@ -1423,9 +1454,12 @@ program.command("list-devices").description("List devices registered with the pl
|
|
|
1423
1454
|
program.command("upload-app <file>").description("Upload an app build (zipped simulator .app for iOS, .apk for Android)").option("--token <token>", "API token").option("--output <format>", "Output format: human, json", "human").option("--name <name>", "App name (auto-detected if not provided)").action((file, opts) => uploadApp(file, opts));
|
|
1424
1455
|
program.command("create-test [name]").description("Create a standalone AI test case").option("--token <token>", "API token").option("--output <format>", "Output format: human, json", "human").option("--app <app>", "App ID (public_id) or package name (requires --platform)").option("--platform <platform>", "Platform (ios, android) when using package name for --app").option("--steps <json>", "Test steps as inline JSON array").option("--steps-file <path>", "Path to JSON file containing test steps").option("--context <text>", "Additional context for AI test execution").option("--test-name <name>", "Test case name (alternative to positional arg)").action((name, opts) => createTest({ ...opts, testName: name || opts.testName || "CLI Test" }));
|
|
1425
1456
|
program.command("login").description("Log in to VibeView (interactive)").option("--base-url <url>", "VibeView server URL (defaults to https://vibeview.io)").action((opts) => login(opts));
|
|
1426
|
-
program.command("
|
|
1457
|
+
program.command("logout").description("Revoke the saved login token and remove it from ~/.vibeview/config.json").action(() => logout());
|
|
1458
|
+
program.command("dev").description(
|
|
1459
|
+
"Stream your app from the fleet with live Metro hot-reload (RN debug builds), or press `r` to re-package and reload a Roku channel in place"
|
|
1460
|
+
).option("--token <token>", "API token (or set VIBEVIEW_API_TOKEN)").option("--web-url <url>", "Dashboard URL if different from the API URL (local dev)").option(
|
|
1427
1461
|
"--platform <platform>",
|
|
1428
|
-
"ios, android, tvos, or
|
|
1462
|
+
"ios, android, tvos, androidtv, or roku (first run is interactive otherwise)"
|
|
1429
1463
|
).option("--app <app_id>", "Uploaded debug build to run (see `vibeview list-apps`)").option("--metro-port <port>", "Local Metro port (default: 8081, remembered in vibeview.json)").option(
|
|
1430
1464
|
"--device-category <category>",
|
|
1431
1465
|
"phone or tablet (default: phone; TV platforms always use tv)",
|
|
@@ -1438,17 +1472,20 @@ program.command("dev").description("Stream your app from the fleet with live Met
|
|
|
1438
1472
|
"--build",
|
|
1439
1473
|
"Build and upload first (uses platforms.<platform>.build from vibeview.json)",
|
|
1440
1474
|
false
|
|
1475
|
+
).option(
|
|
1476
|
+
"--watch [seconds]",
|
|
1477
|
+
"Roku only: auto-reload after file changes settle (default: 2 seconds of quiet)"
|
|
1441
1478
|
).action((opts) => dev(opts));
|
|
1442
1479
|
program.command("build").description("Run your configured build command and upload the result (see vibeview.json)").option("--token <token>", "API token (or set VIBEVIEW_API_TOKEN)").option(
|
|
1443
1480
|
"--platform <platform>",
|
|
1444
|
-
"ios, android, tvos, or
|
|
1481
|
+
"ios, android, tvos, androidtv, or roku (default: vibeview.json defaultPlatform)"
|
|
1445
1482
|
).option("--json", "Print the result as one JSON line", false).action((opts) => buildCommand(opts));
|
|
1446
1483
|
program.command("dev-status").description("Show the status of a `vibeview dev --detach` session in this project").option("--token <token>", "API token (or set VIBEVIEW_API_TOKEN)").option("--json", "Print as one JSON line", false).action((opts) => devStatus(opts));
|
|
1447
1484
|
program.command("dev-stop").description("Stop a `vibeview dev --detach` session in this project").option("--token <token>", "API token (or set VIBEVIEW_API_TOKEN)").option("--json", "Print as one JSON line", false).action((opts) => devStop(opts));
|
|
1448
1485
|
program.command("mcp").description(
|
|
1449
1486
|
"Run an MCP stdio server exposing the agent verbs (tap, swipe, type, ...) plus dev_start/dev_stop as tools"
|
|
1450
1487
|
).option("--token <token>", "API token (or set VIBEVIEW_API_TOKEN)").action(async (opts) => {
|
|
1451
|
-
const { runMcpServer } = await import("./server-
|
|
1488
|
+
const { runMcpServer } = await import("./server-S6OCJERW.js");
|
|
1452
1489
|
await runMcpServer(opts);
|
|
1453
1490
|
});
|
|
1454
1491
|
program.command("agent-setup").description(
|
|
@@ -1470,4 +1507,12 @@ for (const cmd of AGENT_COMMANDS) {
|
|
|
1470
1507
|
return runAgentVerb(cmd, positionals, opts);
|
|
1471
1508
|
});
|
|
1472
1509
|
}
|
|
1473
|
-
program.
|
|
1510
|
+
program.parseAsync().catch((e) => {
|
|
1511
|
+
if (e instanceof Error && e.name === "ExitPromptError") {
|
|
1512
|
+
process.exit(130);
|
|
1513
|
+
}
|
|
1514
|
+
if (process.env.VIBEVIEW_DEBUG) throw e;
|
|
1515
|
+
process.stderr.write(`error: ${e instanceof Error ? e.message : String(e)}
|
|
1516
|
+
`);
|
|
1517
|
+
process.exit(EXIT_INFRA_ERROR);
|
|
1518
|
+
});
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import {
|
|
3
3
|
AGENT_COMMANDS,
|
|
4
4
|
PLATFORM_DEVICE,
|
|
5
|
+
PLATFORM_NEEDS_METRO,
|
|
5
6
|
buildToolCall,
|
|
6
7
|
checkMetro,
|
|
7
8
|
decodeScreenshotResult,
|
|
@@ -10,11 +11,11 @@ import {
|
|
|
10
11
|
readDevState,
|
|
11
12
|
renderEnvelope,
|
|
12
13
|
startDevSession
|
|
13
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-4UGLSIZM.js";
|
|
14
15
|
import {
|
|
15
16
|
ApiClient,
|
|
16
17
|
resolveAuth
|
|
17
|
-
} from "./chunk-
|
|
18
|
+
} from "./chunk-AG3RZCPW.js";
|
|
18
19
|
|
|
19
20
|
// src/mcp/server.ts
|
|
20
21
|
import { writeFileSync } from "fs";
|
|
@@ -25,7 +26,10 @@ var SESSION_ID_PROPERTY = {
|
|
|
25
26
|
type: "string",
|
|
26
27
|
description: "Session ID to target. Optional: falls back to the MCP-server-held dev_start session, then this project's `vibeview dev --detach` state, if either is available."
|
|
27
28
|
};
|
|
28
|
-
var
|
|
29
|
+
var MCP_DEV_PLATFORMS = ["ios", "android", "tvos", "androidtv"];
|
|
30
|
+
function isMcpDevPlatform(p) {
|
|
31
|
+
return isDevPlatform(p) && MCP_DEV_PLATFORMS.includes(p);
|
|
32
|
+
}
|
|
29
33
|
function paramToProperty(param) {
|
|
30
34
|
return { type: param.type, description: param.description };
|
|
31
35
|
}
|
|
@@ -62,7 +66,7 @@ var DEV_START_DEF = {
|
|
|
62
66
|
platform: {
|
|
63
67
|
type: "string",
|
|
64
68
|
description: "Platform to run on.",
|
|
65
|
-
enum: [...
|
|
69
|
+
enum: [...MCP_DEV_PLATFORMS]
|
|
66
70
|
},
|
|
67
71
|
app: {
|
|
68
72
|
type: "string",
|
|
@@ -194,9 +198,14 @@ function optsFor(cmd, args) {
|
|
|
194
198
|
}
|
|
195
199
|
async function handleDevStart(args, deps) {
|
|
196
200
|
const platform = args.platform;
|
|
197
|
-
if (
|
|
201
|
+
if (platform === "roku") {
|
|
202
|
+
return errorResult(
|
|
203
|
+
"dev_start does not support roku \u2014 the reload loop needs a foreground terminal; run `vibeview dev --platform roku` instead"
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
if (!isMcpDevPlatform(platform)) {
|
|
198
207
|
return errorResult(
|
|
199
|
-
`dev_start: platform must be one of ${
|
|
208
|
+
`dev_start: platform must be one of ${MCP_DEV_PLATFORMS.join(", ")}, got "${String(platform)}"`
|
|
200
209
|
);
|
|
201
210
|
}
|
|
202
211
|
try {
|
|
@@ -230,7 +239,7 @@ async function handleUploadApp(args, deps) {
|
|
|
230
239
|
}
|
|
231
240
|
const name = typeof args.name === "string" && args.name ? args.name : void 0;
|
|
232
241
|
const upload = deps.uploadApp ?? (async (f, n) => {
|
|
233
|
-
const { uploadAppBuild } = await import("./upload-app-
|
|
242
|
+
const { uploadAppBuild } = await import("./upload-app-JIOFLMS3.js");
|
|
234
243
|
const { result: result2 } = await uploadAppBuild(f, { ...deps.auth, name: n });
|
|
235
244
|
return result2;
|
|
236
245
|
});
|
|
@@ -280,11 +289,13 @@ function createDevSessionController(auth, webUrl) {
|
|
|
280
289
|
);
|
|
281
290
|
}
|
|
282
291
|
const metroPort = cfg.metroPort ?? devConfig?.metroPort ?? 8081;
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
292
|
+
if (PLATFORM_NEEDS_METRO[cfg.platform]) {
|
|
293
|
+
const metro = await checkMetro(metroPort);
|
|
294
|
+
if (metro === "down") {
|
|
295
|
+
throw new Error(
|
|
296
|
+
`Metro isn't running on localhost:${metroPort} \u2014 start it in the project first (yarn start), or pass metro_port if Metro uses another port`
|
|
297
|
+
);
|
|
298
|
+
}
|
|
288
299
|
}
|
|
289
300
|
const isTv = PLATFORM_DEVICE[cfg.platform].tv;
|
|
290
301
|
handle = await startDevSession({
|
package/package.json
CHANGED
|
@@ -50,6 +50,11 @@ https://vibeview.io/docs/preparing-your-build.
|
|
|
50
50
|
fires once the session accepts commands (device up, app installed) —
|
|
51
51
|
no need to poll `dev-status` first. If the app ships no native changes
|
|
52
52
|
since the last run, skip step 1 and just do steps 2–4.
|
|
53
|
+
5. **Immediately tell the developer the session `url`** — "watch or take
|
|
54
|
+
over at any time: `<url>`". The page is a live, fully interactive view
|
|
55
|
+
of the device; the developer watching along is a feature, not a risk.
|
|
56
|
+
(For a session you attached to with `--session` instead of starting,
|
|
57
|
+
the page is `https://vibeview.io/sandbox/<session_id>`.)
|
|
53
58
|
|
|
54
59
|
Running `dev` writes two things into the project root: a `.vibeview/`
|
|
55
60
|
directory (live session state — local machine state, never commit) and a
|
|
@@ -150,7 +155,26 @@ it rather than letting it appear unexplained in their `git status`.
|
|
|
150
155
|
focus directly with `tap-focused` (moves focus AND activates) / `focus`
|
|
151
156
|
(moves focus only, no activation).
|
|
152
157
|
|
|
153
|
-
## 4.
|
|
158
|
+
## 4. When only a human can act — stop and hand off
|
|
159
|
+
|
|
160
|
+
Some screens need things you must never guess or invent: **login
|
|
161
|
+
credentials, 2FA/OTP codes, CAPTCHAs, destructive confirmations
|
|
162
|
+
("Delete account?"), anything payment- or real-account-shaped.** When you
|
|
163
|
+
hit one:
|
|
164
|
+
|
|
165
|
+
1. **Stop driving.** Do not type guessed credentials, do not tap through a
|
|
166
|
+
destructive confirmation to "see what happens".
|
|
167
|
+
2. **Ask the developer, naming exactly what you need and where**: "I'm at
|
|
168
|
+
the login screen — please sign in at `<session url>` and tell me when
|
|
169
|
+
you're done." The session page is fully interactive; the developer can
|
|
170
|
+
drive the device directly while you wait.
|
|
171
|
+
3. **On resume, run `ui-tree` FIRST** — the human just changed the screen,
|
|
172
|
+
so every ref you held is stale — and **verify the blocker is actually
|
|
173
|
+
gone from the tree** (e.g. the login form is absent) instead of trusting
|
|
174
|
+
the confirmation alone. If it's still there, say what you still see and
|
|
175
|
+
ask again.
|
|
176
|
+
|
|
177
|
+
## 5. Cleanup
|
|
154
178
|
|
|
155
179
|
**Always** stop the session when done verifying — a running session bills
|
|
156
180
|
streaming minutes even when idle:
|
|
@@ -158,7 +182,7 @@ streaming minutes even when idle:
|
|
|
158
182
|
vibeview dev-stop
|
|
159
183
|
```
|
|
160
184
|
|
|
161
|
-
##
|
|
185
|
+
## 6. Command reference
|
|
162
186
|
|
|
163
187
|
Every verb accepts `--session <id>` to target a specific session (falls
|
|
164
188
|
back to the current project's `vibeview dev --detach` session if omitted)
|
|
@@ -194,7 +218,7 @@ Dev-loop lifecycle commands (not registry verbs, but needed for every run):
|
|
|
194
218
|
| `dev-status` | Check on the current project's detached dev session. |
|
|
195
219
|
| `dev-stop` | End the current project's detached dev session. |
|
|
196
220
|
|
|
197
|
-
##
|
|
221
|
+
## 7. If you're calling VibeView over MCP
|
|
198
222
|
|
|
199
223
|
Everything above is also exposed as MCP tools by `vibeview mcp`, so an agent
|
|
200
224
|
with no shell can run the whole loop. Tool names are the CLI verbs with
|
|
@@ -210,7 +234,7 @@ steps the CLI does with plain shell commands:
|
|
|
210
234
|
| MCP tool | Args | Purpose |
|
|
211
235
|
|----------|------|---------|
|
|
212
236
|
| `upload_app` | `file` (required path), `name` | Upload a debug build. The MCP equivalent of `vibeview upload-app`, so step 1 of the loop needs no shell. Returns the `app_id` to pass to `dev_start`. |
|
|
213
|
-
| `dev_start` | `platform` (required: `ios`/`android`/`tvos`/`androidtv`), `app`, `metro_port` | Start a dev-loop session held open by the MCP server. The MCP equivalent of `vibeview dev --detach`. |
|
|
237
|
+
| `dev_start` | `platform` (required: `ios`/`android`/`tvos`/`androidtv`), `app`, `metro_port` | Start a dev-loop session held open by the MCP server. The MCP equivalent of `vibeview dev --detach`. Returns `page_url` — relay it to the developer immediately, same as step 5 of the loop. |
|
|
214
238
|
| `dev_stop` | — | Stop the session `dev_start` started. The MCP equivalent of `vibeview dev-stop`. |
|
|
215
239
|
|
|
216
240
|
**`dev_start` is stateful — it changes the default session for every later
|