vibeview 0.35.0 → 0.36.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -585,6 +585,73 @@ function clearDevState(cwd) {
|
|
|
585
585
|
if (existsSync(path)) {
|
|
586
586
|
rmSync2(path, { force: true });
|
|
587
587
|
}
|
|
588
|
+
rmSync2(join2(cwd, STATE_DIR, "dev-use.json"), { force: true });
|
|
589
|
+
}
|
|
590
|
+
function clearDevStateIfOwned(cwd, pid) {
|
|
591
|
+
const found = readDevStateFile(cwd);
|
|
592
|
+
if (found && found.state.pid === pid) clearDevState(cwd);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// src/lib/dev-use.ts
|
|
596
|
+
import {
|
|
597
|
+
existsSync as existsSync2,
|
|
598
|
+
mkdirSync as mkdirSync2,
|
|
599
|
+
readFileSync as readFileSync3,
|
|
600
|
+
readdirSync,
|
|
601
|
+
rmSync as rmSync3,
|
|
602
|
+
statSync,
|
|
603
|
+
writeFileSync as writeFileSync3
|
|
604
|
+
} from "fs";
|
|
605
|
+
import { homedir } from "os";
|
|
606
|
+
import { join as join3 } from "path";
|
|
607
|
+
var STALE_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
608
|
+
function useDir() {
|
|
609
|
+
return join3(homedir(), ".vibeview", "dev-use");
|
|
610
|
+
}
|
|
611
|
+
function notePath(sessionId) {
|
|
612
|
+
if (!/^[A-Za-z0-9_-]{1,128}$/.test(sessionId)) return null;
|
|
613
|
+
return join3(useDir(), `${sessionId}.json`);
|
|
614
|
+
}
|
|
615
|
+
function registerDevUse(sessionId) {
|
|
616
|
+
const path = notePath(sessionId);
|
|
617
|
+
if (!path) return;
|
|
618
|
+
try {
|
|
619
|
+
mkdirSync2(useDir(), { recursive: true });
|
|
620
|
+
writeFileSync3(path, JSON.stringify({ at: 0 }) + "\n");
|
|
621
|
+
const now = Date.now();
|
|
622
|
+
for (const name of readdirSync(useDir())) {
|
|
623
|
+
const p = join3(useDir(), name);
|
|
624
|
+
try {
|
|
625
|
+
if (now - statSync(p).mtimeMs > STALE_MS) rmSync3(p, { force: true });
|
|
626
|
+
} catch {
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
} catch {
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
function recordDevUse(sessionId, at = Date.now()) {
|
|
633
|
+
const path = notePath(sessionId);
|
|
634
|
+
if (!path) return;
|
|
635
|
+
try {
|
|
636
|
+
if (!existsSync2(path)) return;
|
|
637
|
+
writeFileSync3(path, JSON.stringify({ at }) + "\n");
|
|
638
|
+
} catch {
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
function readDevUse(sessionId) {
|
|
642
|
+
const path = notePath(sessionId);
|
|
643
|
+
if (!path) return null;
|
|
644
|
+
try {
|
|
645
|
+
const parsed = JSON.parse(readFileSync3(path, "utf-8"));
|
|
646
|
+
return typeof parsed?.at === "number" ? parsed.at : null;
|
|
647
|
+
} catch {
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
function clearDevUse(sessionId) {
|
|
652
|
+
const path = notePath(sessionId);
|
|
653
|
+
if (!path) return;
|
|
654
|
+
rmSync3(path, { force: true });
|
|
588
655
|
}
|
|
589
656
|
|
|
590
657
|
// src/lib/metro-check.ts
|
|
@@ -601,17 +668,17 @@ async function checkMetro(port, timeoutMs = 1500) {
|
|
|
601
668
|
}
|
|
602
669
|
|
|
603
670
|
// src/lib/local-config.ts
|
|
604
|
-
import { mkdirSync as
|
|
605
|
-
import { join as
|
|
671
|
+
import { mkdirSync as mkdirSync3, readFileSync as readFileSync4, renameSync as renameSync2, rmSync as rmSync4, writeFileSync as writeFileSync4 } from "fs";
|
|
672
|
+
import { join as join4 } from "path";
|
|
606
673
|
var DIR = ".vibeview";
|
|
607
674
|
var FILE2 = "local.json";
|
|
608
675
|
function localConfigPath(cwd) {
|
|
609
|
-
return
|
|
676
|
+
return join4(cwd, DIR, FILE2);
|
|
610
677
|
}
|
|
611
678
|
function loadLocalDevConfig(cwd) {
|
|
612
679
|
let parsed;
|
|
613
680
|
try {
|
|
614
|
-
parsed = JSON.parse(
|
|
681
|
+
parsed = JSON.parse(readFileSync4(localConfigPath(cwd), "utf-8"));
|
|
615
682
|
} catch {
|
|
616
683
|
return {};
|
|
617
684
|
}
|
|
@@ -651,16 +718,16 @@ function saveLocalDevConfig(cwd, cfg) {
|
|
|
651
718
|
const pruned = prune(cfg);
|
|
652
719
|
const target = localConfigPath(cwd);
|
|
653
720
|
if (!pruned.defaultPlatform && !pruned.platforms) {
|
|
654
|
-
|
|
721
|
+
rmSync4(target, { force: true });
|
|
655
722
|
return;
|
|
656
723
|
}
|
|
657
|
-
|
|
724
|
+
mkdirSync3(join4(cwd, DIR), { recursive: true });
|
|
658
725
|
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
659
726
|
try {
|
|
660
|
-
|
|
727
|
+
writeFileSync4(tmp, JSON.stringify(pruned, null, 2) + "\n");
|
|
661
728
|
renameSync2(tmp, target);
|
|
662
729
|
} catch (err) {
|
|
663
|
-
|
|
730
|
+
rmSync4(tmp, { force: true });
|
|
664
731
|
throw err;
|
|
665
732
|
}
|
|
666
733
|
ensureGitignored(cwd);
|
|
@@ -714,19 +781,19 @@ import { input, select } from "@inquirer/prompts";
|
|
|
714
781
|
|
|
715
782
|
// src/lib/project-detect.ts
|
|
716
783
|
import { execFile } from "child_process";
|
|
717
|
-
import { existsSync as
|
|
718
|
-
import { join as
|
|
784
|
+
import { existsSync as existsSync3, readFileSync as readFileSync5, readdirSync as readdirSync2, statSync as statSync2 } from "fs";
|
|
785
|
+
import { join as join5 } from "path";
|
|
719
786
|
import ignore from "ignore";
|
|
720
787
|
function readJson(cwd, name) {
|
|
721
788
|
try {
|
|
722
|
-
return JSON.parse(
|
|
789
|
+
return JSON.parse(readFileSync5(join5(cwd, name), "utf-8"));
|
|
723
790
|
} catch {
|
|
724
791
|
return null;
|
|
725
792
|
}
|
|
726
793
|
}
|
|
727
794
|
function isFile(path) {
|
|
728
795
|
try {
|
|
729
|
-
return
|
|
796
|
+
return statSync2(path).isFile();
|
|
730
797
|
} catch {
|
|
731
798
|
return false;
|
|
732
799
|
}
|
|
@@ -734,19 +801,19 @@ function isFile(path) {
|
|
|
734
801
|
function detectFlavor(cwd) {
|
|
735
802
|
let deps = {};
|
|
736
803
|
try {
|
|
737
|
-
const pkg = JSON.parse(
|
|
804
|
+
const pkg = JSON.parse(readFileSync5(join5(cwd, "package.json"), "utf-8"));
|
|
738
805
|
deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
739
806
|
} catch {
|
|
740
807
|
}
|
|
741
808
|
if ("expo" in deps) return "expo";
|
|
742
|
-
if (
|
|
743
|
-
if (isFile(
|
|
809
|
+
if (existsSync3(join5(cwd, "android", "gradlew")) || iosWorkspaceName(cwd)) return "bare-rn";
|
|
810
|
+
if (isFile(join5(cwd, "manifest"))) return "roku";
|
|
744
811
|
return "unknown";
|
|
745
812
|
}
|
|
746
813
|
function nativeDirShipped(cwd, dir) {
|
|
747
|
-
if (!
|
|
814
|
+
if (!existsSync3(join5(cwd, dir))) return false;
|
|
748
815
|
try {
|
|
749
|
-
const ig = ignore().add(
|
|
816
|
+
const ig = ignore().add(readFileSync5(join5(cwd, ".gitignore"), "utf-8"));
|
|
750
817
|
return !ig.ignores(`${dir}/`);
|
|
751
818
|
} catch {
|
|
752
819
|
return true;
|
|
@@ -762,7 +829,7 @@ function isManagedExpo(cwd, platform) {
|
|
|
762
829
|
}
|
|
763
830
|
function iosWorkspaceName(cwd) {
|
|
764
831
|
try {
|
|
765
|
-
const found =
|
|
832
|
+
const found = readdirSync2(join5(cwd, "ios")).find((f) => f.endsWith(".xcworkspace"));
|
|
766
833
|
return found ? found.replace(/\.xcworkspace$/, "") : null;
|
|
767
834
|
} catch {
|
|
768
835
|
return null;
|
|
@@ -771,9 +838,9 @@ function iosWorkspaceName(cwd) {
|
|
|
771
838
|
var IOS_ARTIFACT = "ios/build/Build/Products/Debug-iphonesimulator/*.app";
|
|
772
839
|
var ANDROID_ARTIFACT = "android/app/build/outputs/apk/debug/*.apk";
|
|
773
840
|
function installCommand(cwd) {
|
|
774
|
-
if (
|
|
775
|
-
if (
|
|
776
|
-
if (
|
|
841
|
+
if (existsSync3(join5(cwd, "yarn.lock"))) return "yarn install --immutable";
|
|
842
|
+
if (existsSync3(join5(cwd, "pnpm-lock.yaml"))) return "pnpm install --frozen-lockfile";
|
|
843
|
+
if (existsSync3(join5(cwd, "package-lock.json"))) return "npm ci";
|
|
777
844
|
return "yarn install --immutable";
|
|
778
845
|
}
|
|
779
846
|
function managedExpoAndroidCommand(cwd) {
|
|
@@ -787,7 +854,7 @@ var ROKU_ZIP_COMMAND = "zip -r out/channel.zip manifest source components images
|
|
|
787
854
|
function rokuDefaults(cwd) {
|
|
788
855
|
const pkg = readJson(cwd, "package.json");
|
|
789
856
|
const scripts = pkg?.scripts ?? {};
|
|
790
|
-
const runner =
|
|
857
|
+
const runner = existsSync3(join5(cwd, "yarn.lock")) ? "yarn" : "npm run";
|
|
791
858
|
const packageScript = Object.entries(scripts).find(
|
|
792
859
|
([name, body]) => !name.startsWith("deploy") && (name.startsWith("package") || body.includes("--package-only") || body.includes("zipPackage"))
|
|
793
860
|
);
|
|
@@ -845,7 +912,7 @@ function expoSanitizedName(name) {
|
|
|
845
912
|
return name.replace(/[\W_]+/g, "").normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
|
846
913
|
}
|
|
847
914
|
function managedExpoIosName(cwd) {
|
|
848
|
-
if (
|
|
915
|
+
if (existsSync3(join5(cwd, "app.config.js")) || existsSync3(join5(cwd, "app.config.ts"))) {
|
|
849
916
|
return null;
|
|
850
917
|
}
|
|
851
918
|
const raw = readJson(cwd, "app.config.json") ?? readJson(cwd, "app.json");
|
|
@@ -900,22 +967,22 @@ function uniqueMatch(text, re) {
|
|
|
900
967
|
}
|
|
901
968
|
function readText(path) {
|
|
902
969
|
try {
|
|
903
|
-
return
|
|
970
|
+
return readFileSync5(path, "utf-8");
|
|
904
971
|
} catch {
|
|
905
972
|
return null;
|
|
906
973
|
}
|
|
907
974
|
}
|
|
908
975
|
function pbxprojText(cwd) {
|
|
909
976
|
try {
|
|
910
|
-
const proj =
|
|
911
|
-
return proj ? readText(
|
|
977
|
+
const proj = readdirSync2(join5(cwd, "ios")).find((f) => f.endsWith(".xcodeproj"));
|
|
978
|
+
return proj ? readText(join5(cwd, "ios", proj, "project.pbxproj")) : null;
|
|
912
979
|
} catch {
|
|
913
980
|
return null;
|
|
914
981
|
}
|
|
915
982
|
}
|
|
916
983
|
var readExpoConfig = (cwd) => {
|
|
917
|
-
const cli =
|
|
918
|
-
if (!
|
|
984
|
+
const cli = join5(cwd, "node_modules", "expo", "bin", "cli");
|
|
985
|
+
if (!existsSync3(cli)) {
|
|
919
986
|
return Promise.reject(new Error("Expo is not installed in this project (node_modules/expo)"));
|
|
920
987
|
}
|
|
921
988
|
return new Promise((resolve, reject) => {
|
|
@@ -954,12 +1021,12 @@ async function localAppIdentity(cwd, platform, readConfig = readExpoConfig) {
|
|
|
954
1021
|
let bundleId = null;
|
|
955
1022
|
let versionUnreadable = null;
|
|
956
1023
|
const dynamicName = ["app.config.ts", "app.config.js", "app.config.mjs", "app.config.cjs"].find(
|
|
957
|
-
(f) =>
|
|
1024
|
+
(f) => existsSync3(join5(cwd, f))
|
|
958
1025
|
);
|
|
959
1026
|
const staticRaw = readJson(cwd, "app.config.json") ?? readJson(cwd, "app.json");
|
|
960
|
-
const isExpo = Boolean(dynamicName) || staticRaw !== null && "expo" in staticRaw ||
|
|
1027
|
+
const isExpo = Boolean(dynamicName) || staticRaw !== null && "expo" in staticRaw || existsSync3(join5(cwd, "node_modules", "expo", "package.json"));
|
|
961
1028
|
let expo = null;
|
|
962
|
-
if (isExpo && (dynamicName ||
|
|
1029
|
+
if (isExpo && (dynamicName || existsSync3(join5(cwd, "node_modules", "expo", "bin", "cli")))) {
|
|
963
1030
|
try {
|
|
964
1031
|
expo = await readConfig(cwd);
|
|
965
1032
|
} catch (err) {
|
|
@@ -979,7 +1046,7 @@ async function localAppIdentity(cwd, platform, readConfig = readExpoConfig) {
|
|
|
979
1046
|
if (typeof id === "string") bundleId = id;
|
|
980
1047
|
}
|
|
981
1048
|
if (android && (!version || !bundleId)) {
|
|
982
|
-
const gradle = readText(
|
|
1049
|
+
const gradle = readText(join5(cwd, "android", "app", "build.gradle")) ?? readText(join5(cwd, "android", "app", "build.gradle.kts"));
|
|
983
1050
|
if (gradle) {
|
|
984
1051
|
version ??= uniqueMatch(gradle, /versionName\s*=?\s*["']([^"']+)["']/g);
|
|
985
1052
|
bundleId ??= uniqueMatch(gradle, /applicationId\s*=?\s*["']([^"']+)["']/g);
|
|
@@ -1003,7 +1070,7 @@ function platformChoices(cwd) {
|
|
|
1003
1070
|
{ value: "tvos", name: "Apple TV simulator" },
|
|
1004
1071
|
{ value: "androidtv", name: "Android TV emulator" }
|
|
1005
1072
|
];
|
|
1006
|
-
if (detectFlavor(cwd) === "roku") choices.unshift({ value: "roku", name: "Roku
|
|
1073
|
+
if (detectFlavor(cwd) === "roku") choices.unshift({ value: "roku", name: "Roku" });
|
|
1007
1074
|
return choices;
|
|
1008
1075
|
}
|
|
1009
1076
|
function appsForPlatform(apps, platform) {
|
|
@@ -1094,9 +1161,9 @@ async function promptMetroCommand(cwd, platform) {
|
|
|
1094
1161
|
|
|
1095
1162
|
// src/lib/source-pack.ts
|
|
1096
1163
|
import { execFile as execFile2, spawn } from "child_process";
|
|
1097
|
-
import { lstatSync, mkdtempSync, readFileSync as
|
|
1164
|
+
import { lstatSync, mkdtempSync, readFileSync as readFileSync6, rmSync as rmSync5, statSync as statSync3, writeFileSync as writeFileSync5 } from "fs";
|
|
1098
1165
|
import { tmpdir } from "os";
|
|
1099
|
-
import { join as
|
|
1166
|
+
import { join as join6 } from "path";
|
|
1100
1167
|
import ignore2 from "ignore";
|
|
1101
1168
|
var NotAGitRepoError = class extends Error {
|
|
1102
1169
|
constructor(message) {
|
|
@@ -1122,15 +1189,15 @@ async function listSourceFiles(projectRoot) {
|
|
|
1122
1189
|
const stdout = await runGitLsFiles(projectRoot);
|
|
1123
1190
|
const rawPaths = stdout.length ? stdout.split("\0").filter((p) => p.length > 0) : [];
|
|
1124
1191
|
let filtered = rawPaths;
|
|
1125
|
-
const vvignorePath =
|
|
1192
|
+
const vvignorePath = join6(projectRoot, ".vvignore");
|
|
1126
1193
|
if (statOrNull(vvignorePath)?.isFile()) {
|
|
1127
|
-
const ig = ignore2().add(
|
|
1194
|
+
const ig = ignore2().add(readFileSync6(vvignorePath, "utf-8"));
|
|
1128
1195
|
filtered = filtered.filter((p) => !ig.ignores(p));
|
|
1129
1196
|
}
|
|
1130
1197
|
const result = [];
|
|
1131
1198
|
for (const relPath of filtered) {
|
|
1132
1199
|
if (isForceExcluded(relPath)) continue;
|
|
1133
|
-
const st = statOrNull(
|
|
1200
|
+
const st = statOrNull(join6(projectRoot, relPath));
|
|
1134
1201
|
if (!st) continue;
|
|
1135
1202
|
result.push(relPath);
|
|
1136
1203
|
}
|
|
@@ -1180,7 +1247,7 @@ async function packSource(projectRoot, files, opts = {}) {
|
|
|
1180
1247
|
let totalBytes = 0;
|
|
1181
1248
|
const sized = [];
|
|
1182
1249
|
for (const relPath of files) {
|
|
1183
|
-
const bytes = lstatSync(
|
|
1250
|
+
const bytes = lstatSync(join6(projectRoot, relPath)).size;
|
|
1184
1251
|
totalBytes += bytes;
|
|
1185
1252
|
sized.push({ path: relPath, bytes });
|
|
1186
1253
|
}
|
|
@@ -1191,14 +1258,14 @@ async function packSource(projectRoot, files, opts = {}) {
|
|
|
1191
1258
|
largest
|
|
1192
1259
|
);
|
|
1193
1260
|
}
|
|
1194
|
-
const tmpDir = mkdtempSync(
|
|
1195
|
-
const cleanup = () =>
|
|
1261
|
+
const tmpDir = mkdtempSync(join6(tmpdir(), "vibeview-source-pack-"));
|
|
1262
|
+
const cleanup = () => rmSync5(tmpDir, { recursive: true, force: true });
|
|
1196
1263
|
try {
|
|
1197
1264
|
const supportsNull = await probeTarNullSupport();
|
|
1198
|
-
const tarPath =
|
|
1265
|
+
const tarPath = join6(tmpDir, "source.tgz");
|
|
1199
1266
|
if (supportsNull) {
|
|
1200
|
-
const listPath =
|
|
1201
|
-
|
|
1267
|
+
const listPath = join6(tmpDir, "files.list");
|
|
1268
|
+
writeFileSync5(listPath, files.map((f) => `${f}\0`).join(""));
|
|
1202
1269
|
await runTar(["-czf", tarPath, "-C", projectRoot, "--null", "-T", listPath]);
|
|
1203
1270
|
} else {
|
|
1204
1271
|
const badName = files.find((f) => f.includes("\n"));
|
|
@@ -1207,12 +1274,12 @@ async function packSource(projectRoot, files, opts = {}) {
|
|
|
1207
1274
|
`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`
|
|
1208
1275
|
);
|
|
1209
1276
|
}
|
|
1210
|
-
const listPath =
|
|
1211
|
-
|
|
1277
|
+
const listPath = join6(tmpDir, "files.list");
|
|
1278
|
+
writeFileSync5(listPath, files.map((f) => `${f}
|
|
1212
1279
|
`).join(""));
|
|
1213
1280
|
await runTar(["-czf", tarPath, "-C", projectRoot, "-T", listPath]);
|
|
1214
1281
|
}
|
|
1215
|
-
const sizeBytes =
|
|
1282
|
+
const sizeBytes = statSync3(tarPath).size;
|
|
1216
1283
|
return { tarPath, sizeBytes, cleanup };
|
|
1217
1284
|
} catch (err) {
|
|
1218
1285
|
cleanup();
|
|
@@ -1228,12 +1295,12 @@ var nullSupportCache = null;
|
|
|
1228
1295
|
function probeTarNullSupport() {
|
|
1229
1296
|
if (!nullSupportCache) {
|
|
1230
1297
|
nullSupportCache = new Promise((resolve) => {
|
|
1231
|
-
const tmp = mkdtempSync(
|
|
1232
|
-
const listPath =
|
|
1233
|
-
|
|
1234
|
-
const out =
|
|
1298
|
+
const tmp = mkdtempSync(join6(tmpdir(), "vibeview-tar-probe-"));
|
|
1299
|
+
const listPath = join6(tmp, "empty.list");
|
|
1300
|
+
writeFileSync5(listPath, "");
|
|
1301
|
+
const out = join6(tmp, "probe.tgz");
|
|
1235
1302
|
execFile2("tar", ["-czf", out, "-C", tmp, "--null", "-T", listPath], (err) => {
|
|
1236
|
-
|
|
1303
|
+
rmSync5(tmp, { recursive: true, force: true });
|
|
1237
1304
|
resolve(!err);
|
|
1238
1305
|
});
|
|
1239
1306
|
});
|
|
@@ -2105,7 +2172,7 @@ your build command must produce a zip \u2014 use the package/zip step, not deplo
|
|
|
2105
2172
|
|
|
2106
2173
|
// src/commands/dev.ts
|
|
2107
2174
|
import { spawn as spawn4 } from "child_process";
|
|
2108
|
-
import { createWriteStream, mkdirSync as
|
|
2175
|
+
import { createWriteStream, mkdirSync as mkdirSync5 } from "fs";
|
|
2109
2176
|
import { dirname as dirname4 } from "path";
|
|
2110
2177
|
import { createInterface as createInterface3 } from "readline";
|
|
2111
2178
|
import { select as select3 } from "@inquirer/prompts";
|
|
@@ -2156,7 +2223,8 @@ function redactSecrets(text) {
|
|
|
2156
2223
|
}
|
|
2157
2224
|
|
|
2158
2225
|
// src/lib/tunnel-client.ts
|
|
2159
|
-
var
|
|
2226
|
+
var TUNNEL_REPLACED_CLOSE_CODE = 4008;
|
|
2227
|
+
var FATAL_CLOSE_CODES = /* @__PURE__ */ new Set([TUNNEL_REPLACED_CLOSE_CODE, 4009]);
|
|
2160
2228
|
var MAX_BACKOFF_MS = 8e3;
|
|
2161
2229
|
var STABLE_MS = 2e3;
|
|
2162
2230
|
var TunnelClient = class {
|
|
@@ -2222,12 +2290,18 @@ var TunnelClient = class {
|
|
|
2222
2290
|
const heldMs = Date.now() - openedAt;
|
|
2223
2291
|
const why = reason?.length ? ` reason="${reason.toString()}"` : "";
|
|
2224
2292
|
this.debug(`ws#${conn} close code=${code}${why} heldMs=${heldMs}`);
|
|
2293
|
+
if (this.ws !== ws) return;
|
|
2225
2294
|
if (this.stableTimer) {
|
|
2226
2295
|
clearTimeout(this.stableTimer);
|
|
2227
2296
|
this.stableTimer = null;
|
|
2228
2297
|
}
|
|
2229
2298
|
for (const s of this.sockets.values()) s.destroy();
|
|
2230
2299
|
this.sockets.clear();
|
|
2300
|
+
if (!this.stopped && code === TUNNEL_REPLACED_CLOSE_CODE) {
|
|
2301
|
+
this.stopped = true;
|
|
2302
|
+
this.setState("replaced");
|
|
2303
|
+
return;
|
|
2304
|
+
}
|
|
2231
2305
|
if (this.stopped || FATAL_CLOSE_CODES.has(code)) {
|
|
2232
2306
|
this.stopped = true;
|
|
2233
2307
|
this.setState("closed");
|
|
@@ -2301,15 +2375,15 @@ import { spawn as spawn3 } from "child_process";
|
|
|
2301
2375
|
import { createInterface as createInterface2 } from "readline";
|
|
2302
2376
|
|
|
2303
2377
|
// src/lib/metro-state.ts
|
|
2304
|
-
import { existsSync as
|
|
2305
|
-
import { dirname as dirname2, join as
|
|
2378
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync7, rmSync as rmSync6, writeFileSync as writeFileSync6 } from "fs";
|
|
2379
|
+
import { dirname as dirname2, join as join7 } from "path";
|
|
2306
2380
|
var STATE_DIR2 = ".vibeview";
|
|
2307
2381
|
var STATE_FILE2 = "metro-state.json";
|
|
2308
2382
|
function statePath2(cwd) {
|
|
2309
|
-
return
|
|
2383
|
+
return join7(cwd, STATE_DIR2, STATE_FILE2);
|
|
2310
2384
|
}
|
|
2311
2385
|
function metroLogPath(cwd) {
|
|
2312
|
-
return
|
|
2386
|
+
return join7(cwd, STATE_DIR2, "metro.log");
|
|
2313
2387
|
}
|
|
2314
2388
|
function isPidAlive2(pid) {
|
|
2315
2389
|
try {
|
|
@@ -2321,13 +2395,13 @@ function isPidAlive2(pid) {
|
|
|
2321
2395
|
}
|
|
2322
2396
|
function writeMetroState(cwd, state) {
|
|
2323
2397
|
const path = statePath2(cwd);
|
|
2324
|
-
|
|
2325
|
-
|
|
2398
|
+
mkdirSync4(dirname2(path), { recursive: true });
|
|
2399
|
+
writeFileSync6(path, JSON.stringify(state, null, 2) + "\n");
|
|
2326
2400
|
}
|
|
2327
2401
|
function readMetroState(cwd) {
|
|
2328
2402
|
let parsed;
|
|
2329
2403
|
try {
|
|
2330
|
-
parsed = JSON.parse(
|
|
2404
|
+
parsed = JSON.parse(readFileSync7(statePath2(cwd), "utf-8"));
|
|
2331
2405
|
} catch {
|
|
2332
2406
|
return null;
|
|
2333
2407
|
}
|
|
@@ -2339,10 +2413,11 @@ function readMetroState(cwd) {
|
|
|
2339
2413
|
}
|
|
2340
2414
|
function clearMetroState(cwd) {
|
|
2341
2415
|
const path = statePath2(cwd);
|
|
2342
|
-
if (
|
|
2416
|
+
if (existsSync4(path)) rmSync6(path, { force: true });
|
|
2343
2417
|
}
|
|
2344
|
-
function killOwnedMetro(cwd) {
|
|
2418
|
+
function killOwnedMetro(cwd, expectedPid) {
|
|
2345
2419
|
const state = readMetroState(cwd);
|
|
2420
|
+
if (expectedPid !== void 0 && state?.pid !== expectedPid) return false;
|
|
2346
2421
|
clearMetroState(cwd);
|
|
2347
2422
|
if (!state) return false;
|
|
2348
2423
|
try {
|
|
@@ -2352,6 +2427,18 @@ function killOwnedMetro(cwd) {
|
|
|
2352
2427
|
return false;
|
|
2353
2428
|
}
|
|
2354
2429
|
}
|
|
2430
|
+
function handOffMetro(cwd, metroPid, sessionId) {
|
|
2431
|
+
const state = readMetroState(cwd);
|
|
2432
|
+
if (!state || state.pid !== metroPid) return;
|
|
2433
|
+
try {
|
|
2434
|
+
writeMetroState(cwd, { ...state, handed_off_session: sessionId });
|
|
2435
|
+
} catch {
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
function metroHandedOffTo(cwd, metroPid, sessionId) {
|
|
2439
|
+
const state = readMetroState(cwd);
|
|
2440
|
+
return state !== null && state.pid === metroPid && state.handed_off_session === sessionId;
|
|
2441
|
+
}
|
|
2355
2442
|
|
|
2356
2443
|
// src/lib/metro-manager.ts
|
|
2357
2444
|
var TAIL_LINES2 = 20;
|
|
@@ -2367,12 +2454,12 @@ async function ensureMetro(cwd, metro, log, timeoutMs = 9e4) {
|
|
|
2367
2454
|
const ownHoldsPort = own !== null && own.port === metro.port;
|
|
2368
2455
|
if (status !== "down") {
|
|
2369
2456
|
if (ownHoldsPort && metro.command && own.command !== metro.command) {
|
|
2370
|
-
killOwnedMetro(cwd);
|
|
2457
|
+
killOwnedMetro(cwd, own.pid);
|
|
2371
2458
|
await waitFor(async () => await checkMetro(metro.port) === "down", 1e4, 250);
|
|
2372
|
-
await spawnMetro(cwd, metro.command, metro.port, log, timeoutMs);
|
|
2373
|
-
return { status: "respawned", warnings };
|
|
2459
|
+
const pid2 = await spawnMetro(cwd, metro.command, metro.port, log, timeoutMs);
|
|
2460
|
+
return { status: "respawned", warnings, pid: pid2 };
|
|
2374
2461
|
}
|
|
2375
|
-
if (ownHoldsPort) return { status: "reused-own", warnings };
|
|
2462
|
+
if (ownHoldsPort) return { status: "reused-own", warnings, pid: own.pid };
|
|
2376
2463
|
if (status === "listening") {
|
|
2377
2464
|
warnings.push(
|
|
2378
2465
|
`something is listening on localhost:${metro.port} but it doesn't look like Metro \u2014 continuing anyway`
|
|
@@ -2393,8 +2480,8 @@ Or set "metroCommand" in vibeview.json and vibeview dev will start it for you.
|
|
|
2393
2480
|
If Metro uses another port, pass --metro-port <port>.`
|
|
2394
2481
|
);
|
|
2395
2482
|
}
|
|
2396
|
-
await spawnMetro(cwd, metro.command, metro.port, log, timeoutMs);
|
|
2397
|
-
return { status: "spawned", warnings };
|
|
2483
|
+
const pid = await spawnMetro(cwd, metro.command, metro.port, log, timeoutMs);
|
|
2484
|
+
return { status: "spawned", warnings, pid };
|
|
2398
2485
|
}
|
|
2399
2486
|
async function spawnMetro(cwd, command, port, log, timeoutMs) {
|
|
2400
2487
|
const child = spawn3(command, {
|
|
@@ -2422,16 +2509,16 @@ async function spawnMetro(cwd, command, port, log, timeoutMs) {
|
|
|
2422
2509
|
const deadline = Date.now() + timeoutMs;
|
|
2423
2510
|
for (; ; ) {
|
|
2424
2511
|
if (exited !== null) {
|
|
2425
|
-
killOwnedMetro(cwd);
|
|
2512
|
+
killOwnedMetro(cwd, child.pid);
|
|
2426
2513
|
throw new Error(
|
|
2427
2514
|
`Metro command exited (code ${exited}) before localhost:${port} came up.
|
|
2428
2515
|
` + (tail.length ? `Last output:
|
|
2429
2516
|
${tail.join("\n ")}` : "")
|
|
2430
2517
|
);
|
|
2431
2518
|
}
|
|
2432
|
-
if (await checkMetro(port, 1e3) === "running") return;
|
|
2519
|
+
if (await checkMetro(port, 1e3) === "running") return child.pid;
|
|
2433
2520
|
if (Date.now() > deadline) {
|
|
2434
|
-
killOwnedMetro(cwd);
|
|
2521
|
+
killOwnedMetro(cwd, child.pid);
|
|
2435
2522
|
throw new Error(`timed out waiting for Metro on localhost:${port} (command: ${command})`);
|
|
2436
2523
|
}
|
|
2437
2524
|
await new Promise((r) => setTimeout(r, 500));
|
|
@@ -2568,6 +2655,60 @@ function leaseLapsedReason(gapMs) {
|
|
|
2568
2655
|
if (gapMs <= AGENT_LEASE_TTL_MS) return void 0;
|
|
2569
2656
|
return `the session ended because this computer did not check in with VibeView for ${Math.round(gapMs / 1e3)}s (asleep, offline, or the process was paused) \u2014 start a new session to continue`;
|
|
2570
2657
|
}
|
|
2658
|
+
var TUNNEL_REPLACED_REASON = "another VibeView dev connection took over this session \u2014 it keeps running there";
|
|
2659
|
+
var SessionReplacedError = class extends Error {
|
|
2660
|
+
/** The session the newer client took over (it keeps running there). */
|
|
2661
|
+
sessionId;
|
|
2662
|
+
constructor(sessionId) {
|
|
2663
|
+
super(TUNNEL_REPLACED_REASON);
|
|
2664
|
+
this.name = "SessionReplacedError";
|
|
2665
|
+
this.sessionId = sessionId;
|
|
2666
|
+
}
|
|
2667
|
+
};
|
|
2668
|
+
var IDLE_WARNING_EPISODE_SLACK_MS = 5e3;
|
|
2669
|
+
function parseIdleWarningSeconds(raw) {
|
|
2670
|
+
if (typeof raw !== "number" || !Number.isFinite(raw)) return null;
|
|
2671
|
+
const seconds = Math.round(raw);
|
|
2672
|
+
return seconds > 0 ? seconds : null;
|
|
2673
|
+
}
|
|
2674
|
+
function createIdleWarningTracker() {
|
|
2675
|
+
let endsAt = null;
|
|
2676
|
+
let issued = 0;
|
|
2677
|
+
let accepted = 0;
|
|
2678
|
+
return {
|
|
2679
|
+
begin: () => ++issued,
|
|
2680
|
+
observe(info, now = Date.now(), stamp) {
|
|
2681
|
+
if (!info || info.idle_warning === void 0) return null;
|
|
2682
|
+
if (stamp !== void 0) {
|
|
2683
|
+
if (stamp < accepted) return null;
|
|
2684
|
+
accepted = stamp;
|
|
2685
|
+
}
|
|
2686
|
+
const w = info.idle_warning;
|
|
2687
|
+
if (w === null) {
|
|
2688
|
+
if (endsAt === null) return null;
|
|
2689
|
+
endsAt = null;
|
|
2690
|
+
return { kind: "cleared" };
|
|
2691
|
+
}
|
|
2692
|
+
if (typeof w !== "object") return null;
|
|
2693
|
+
const seconds = parseIdleWarningSeconds(w.seconds_remaining);
|
|
2694
|
+
if (seconds === null) return null;
|
|
2695
|
+
const at = now + seconds * 1e3;
|
|
2696
|
+
if (endsAt !== null && at <= endsAt + IDLE_WARNING_EPISODE_SLACK_MS) return null;
|
|
2697
|
+
endsAt = at;
|
|
2698
|
+
return { kind: "warn", seconds_remaining: seconds };
|
|
2699
|
+
},
|
|
2700
|
+
noteUse() {
|
|
2701
|
+
accepted = ++issued;
|
|
2702
|
+
if (endsAt === null) return null;
|
|
2703
|
+
endsAt = null;
|
|
2704
|
+
return { kind: "cleared" };
|
|
2705
|
+
}
|
|
2706
|
+
};
|
|
2707
|
+
}
|
|
2708
|
+
function idleWarningLine(seconds, mode) {
|
|
2709
|
+
const keep = mode === "metro" ? "use the device or save a file" : mode === "roku" ? "use the device or reload the channel" : "use the device";
|
|
2710
|
+
return `Session ends in ${seconds} s: ${keep} to keep it`;
|
|
2711
|
+
}
|
|
2571
2712
|
function queuedLine(position, reasonMessage) {
|
|
2572
2713
|
const where = position ? ` (position ${position})` : "";
|
|
2573
2714
|
const why = reasonMessage ?? "Every device is busy.";
|
|
@@ -2580,8 +2721,28 @@ function devBuildLines(choice, standalone) {
|
|
|
2580
2721
|
listBuilds: "vibeview list-builds"
|
|
2581
2722
|
});
|
|
2582
2723
|
}
|
|
2724
|
+
var stdoutClosed = false;
|
|
2725
|
+
var stdoutGuarded = false;
|
|
2726
|
+
function guardStdout() {
|
|
2727
|
+
if (stdoutGuarded) return;
|
|
2728
|
+
stdoutGuarded = true;
|
|
2729
|
+
process.stdout.on("error", (err) => {
|
|
2730
|
+
if (err?.code === "EPIPE") {
|
|
2731
|
+
stdoutClosed = true;
|
|
2732
|
+
return;
|
|
2733
|
+
}
|
|
2734
|
+
throw err;
|
|
2735
|
+
});
|
|
2736
|
+
}
|
|
2583
2737
|
function emit(json, evt) {
|
|
2584
|
-
if (json)
|
|
2738
|
+
if (!json || stdoutClosed) return;
|
|
2739
|
+
guardStdout();
|
|
2740
|
+
try {
|
|
2741
|
+
console.log(JSON.stringify(evt));
|
|
2742
|
+
} catch (err) {
|
|
2743
|
+
if (err?.code === "EPIPE") stdoutClosed = true;
|
|
2744
|
+
else throw err;
|
|
2745
|
+
}
|
|
2585
2746
|
}
|
|
2586
2747
|
async function startDevSession(cfg, onEvent = () => {
|
|
2587
2748
|
}, onTunnelState) {
|
|
@@ -2619,6 +2780,43 @@ async function startDevSession(cfg, onEvent = () => {
|
|
|
2619
2780
|
let queueEntryId = null;
|
|
2620
2781
|
let lastLeaseAt = Date.now();
|
|
2621
2782
|
let leaseTimer = null;
|
|
2783
|
+
const idle = createIdleWarningTracker();
|
|
2784
|
+
let lastOutsideUse = Date.now();
|
|
2785
|
+
registerDevUse(sessionId);
|
|
2786
|
+
const noteOutsideUse = () => {
|
|
2787
|
+
const at = readDevUse(sessionId);
|
|
2788
|
+
if (at === null || at <= lastOutsideUse) return;
|
|
2789
|
+
lastOutsideUse = at;
|
|
2790
|
+
reportIdleChange(idle.noteUse());
|
|
2791
|
+
};
|
|
2792
|
+
const noteSessionInfo = (info, stamp) => {
|
|
2793
|
+
if (stopping) return;
|
|
2794
|
+
noteOutsideUse();
|
|
2795
|
+
reportIdleChange(idle.observe(info, void 0, stamp));
|
|
2796
|
+
};
|
|
2797
|
+
const noteUse = () => {
|
|
2798
|
+
if (stopping) return;
|
|
2799
|
+
reportIdleChange(idle.noteUse());
|
|
2800
|
+
};
|
|
2801
|
+
const reportIdleChange = (change) => {
|
|
2802
|
+
if (!change) return;
|
|
2803
|
+
try {
|
|
2804
|
+
if (change.kind === "cleared") {
|
|
2805
|
+
cfg.onIdleWarning?.(sessionId, null);
|
|
2806
|
+
return;
|
|
2807
|
+
}
|
|
2808
|
+
cfg.onIdleWarning?.(sessionId, { seconds_remaining: change.seconds_remaining });
|
|
2809
|
+
onEvent({
|
|
2810
|
+
event: "idle_warning",
|
|
2811
|
+
session_id: sessionId,
|
|
2812
|
+
seconds_remaining: change.seconds_remaining
|
|
2813
|
+
});
|
|
2814
|
+
} catch (err) {
|
|
2815
|
+
console.error(
|
|
2816
|
+
`idle warning hook failed: ${err instanceof Error ? err.message : String(err)}`
|
|
2817
|
+
);
|
|
2818
|
+
}
|
|
2819
|
+
};
|
|
2622
2820
|
const stopSession = async (code) => {
|
|
2623
2821
|
if (stopping) return;
|
|
2624
2822
|
stopping = true;
|
|
@@ -2633,11 +2831,29 @@ async function startDevSession(cfg, onEvent = () => {
|
|
|
2633
2831
|
}
|
|
2634
2832
|
await api.stopSession(sessionId).catch(() => {
|
|
2635
2833
|
});
|
|
2834
|
+
clearDevUse(sessionId);
|
|
2636
2835
|
onEvent({ event: "session_ended", session_id: sessionId, code, ...reason ? { reason } : {} });
|
|
2637
2836
|
};
|
|
2837
|
+
let replaced = false;
|
|
2838
|
+
const releaseSession = () => {
|
|
2839
|
+
if (stopping) return;
|
|
2840
|
+
stopping = true;
|
|
2841
|
+
replaced = true;
|
|
2842
|
+
if (leaseTimer) clearInterval(leaseTimer);
|
|
2843
|
+
leaseTimer = null;
|
|
2844
|
+
tunnel?.stop();
|
|
2845
|
+
onEvent({
|
|
2846
|
+
event: "session_ended",
|
|
2847
|
+
session_id: sessionId,
|
|
2848
|
+
code: 0,
|
|
2849
|
+
reason: TUNNEL_REPLACED_REASON,
|
|
2850
|
+
replaced: true
|
|
2851
|
+
});
|
|
2852
|
+
};
|
|
2638
2853
|
const renewLease = async () => {
|
|
2639
2854
|
if (stopping) return;
|
|
2640
2855
|
let info;
|
|
2856
|
+
const stamp = idle.begin();
|
|
2641
2857
|
try {
|
|
2642
2858
|
info = await api.touchSession(sessionId, { lease: true });
|
|
2643
2859
|
} catch {
|
|
@@ -2649,6 +2865,7 @@ async function startDevSession(cfg, onEvent = () => {
|
|
|
2649
2865
|
return;
|
|
2650
2866
|
}
|
|
2651
2867
|
lastLeaseAt = Date.now();
|
|
2868
|
+
noteSessionInfo(info, stamp);
|
|
2652
2869
|
};
|
|
2653
2870
|
void renewLease();
|
|
2654
2871
|
leaseTimer = setInterval(() => void renewLease(), AGENT_LEASE_RENEW_MS);
|
|
@@ -2700,6 +2917,8 @@ async function startDevSession(cfg, onEvent = () => {
|
|
|
2700
2917
|
event: "warning",
|
|
2701
2918
|
message: `can't reach Metro on localhost:${cfg.metroPort} \u2014 is it running? (yarn start)`
|
|
2702
2919
|
});
|
|
2920
|
+
} else if (s === "replaced") {
|
|
2921
|
+
releaseSession();
|
|
2703
2922
|
} else if (s === "closed" && !stopping) {
|
|
2704
2923
|
void stopSession(1);
|
|
2705
2924
|
}
|
|
@@ -2714,8 +2933,10 @@ async function startDevSession(cfg, onEvent = () => {
|
|
|
2714
2933
|
sessionId,
|
|
2715
2934
|
18e4,
|
|
2716
2935
|
2e3,
|
|
2717
|
-
() => onEvent({ event: "installing", session_id: sessionId })
|
|
2936
|
+
() => onEvent({ event: "installing", session_id: sessionId }),
|
|
2937
|
+
() => replaced
|
|
2718
2938
|
);
|
|
2939
|
+
if (replaced) throw new SessionReplacedError(sessionId);
|
|
2719
2940
|
for (const w of active.warnings ?? []) {
|
|
2720
2941
|
if (reportedWarnings.has(w)) continue;
|
|
2721
2942
|
reportedWarnings.add(w);
|
|
@@ -2728,9 +2949,13 @@ async function startDevSession(cfg, onEvent = () => {
|
|
|
2728
2949
|
reportedWarnings,
|
|
2729
2950
|
session: active,
|
|
2730
2951
|
lapsedReason: () => leaseLapsedReason(Date.now() - lastLeaseAt),
|
|
2952
|
+
noteSessionInfo,
|
|
2953
|
+
beginPresence: () => idle.begin(),
|
|
2954
|
+
noteUse,
|
|
2731
2955
|
build: usesTunnel && cfg.projectDir ? describeDevBuild(app, active, { ...pick, pinned: cfg.buildId !== void 0 }, project) : null
|
|
2732
2956
|
};
|
|
2733
2957
|
} catch (err) {
|
|
2958
|
+
if (replaced) throw new SessionReplacedError(sessionId);
|
|
2734
2959
|
await stopSession(1);
|
|
2735
2960
|
throw err;
|
|
2736
2961
|
}
|
|
@@ -2827,10 +3052,11 @@ async function pollUntilTunnelReady(api, sessionId, timeoutMs, intervalMs = 2e3)
|
|
|
2827
3052
|
await new Promise((r) => setTimeout(r, intervalMs));
|
|
2828
3053
|
}
|
|
2829
3054
|
}
|
|
2830
|
-
async function pollUntilActive(api, sessionId, timeoutMs, intervalMs = 2e3, onInstallPending) {
|
|
3055
|
+
async function pollUntilActive(api, sessionId, timeoutMs, intervalMs = 2e3, onInstallPending, shouldAbort) {
|
|
2831
3056
|
const deadline = Date.now() + timeoutMs;
|
|
2832
3057
|
let pendingReported = false;
|
|
2833
3058
|
for (; ; ) {
|
|
3059
|
+
if (shouldAbort?.()) throw new Error("wait for the session aborted");
|
|
2834
3060
|
const info = await api.touchSession(sessionId);
|
|
2835
3061
|
const blocking = findBlockingWarning(info);
|
|
2836
3062
|
if (blocking) throw new Error(blocking);
|
|
@@ -3253,6 +3479,7 @@ async function runDevInProcess(opts) {
|
|
|
3253
3479
|
}
|
|
3254
3480
|
}
|
|
3255
3481
|
let metroOwnedThisRun = false;
|
|
3482
|
+
let metroPid = null;
|
|
3256
3483
|
let rokuBuild;
|
|
3257
3484
|
let watchIgnore = [];
|
|
3258
3485
|
if (standalone && cfg.platform === "roku") {
|
|
@@ -3270,7 +3497,7 @@ async function runDevInProcess(opts) {
|
|
|
3270
3497
|
const captureMetroLine = (line) => {
|
|
3271
3498
|
try {
|
|
3272
3499
|
if (!metroLogStream) {
|
|
3273
|
-
|
|
3500
|
+
mkdirSync5(dirname4(metroLogPath(process.cwd())), { recursive: true });
|
|
3274
3501
|
metroLogStream = createWriteStream(metroLogPath(process.cwd()), { flags: "w" });
|
|
3275
3502
|
}
|
|
3276
3503
|
metroLogStream.write(line.replace(/^\[metro\] /, "") + "\n");
|
|
@@ -3301,6 +3528,7 @@ async function runDevInProcess(opts) {
|
|
|
3301
3528
|
);
|
|
3302
3529
|
}
|
|
3303
3530
|
metroOwnedThisRun = metroResult.status === "spawned" || metroResult.status === "respawned";
|
|
3531
|
+
metroPid = metroResult.pid ?? null;
|
|
3304
3532
|
} else {
|
|
3305
3533
|
const rokuEntry = loadDevConfig(process.cwd())?.platforms?.[cfg.platform];
|
|
3306
3534
|
rokuBuild = rokuEntry?.build;
|
|
@@ -3320,14 +3548,38 @@ async function runDevInProcess(opts) {
|
|
|
3320
3548
|
let stopping = false;
|
|
3321
3549
|
let handle = null;
|
|
3322
3550
|
let reloadLoop = null;
|
|
3323
|
-
const
|
|
3551
|
+
const ownsMetro = (sessionId) => metroPid !== null && (metroOwnedThisRun || sessionId !== void 0 && metroHandedOffTo(process.cwd(), metroPid, sessionId));
|
|
3552
|
+
const stopOwnedMetro = () => {
|
|
3553
|
+
if (metroPid === null) return;
|
|
3554
|
+
const other = readDevStateFile(process.cwd());
|
|
3555
|
+
if (other?.alive && other.state.pid !== process.pid && other.state.metro_port === (cfg.metroPort ?? 8081) && PLATFORM_NEEDS_METRO[other.state.platform]) {
|
|
3556
|
+
handOffMetro(process.cwd(), metroPid, other.state.session_id);
|
|
3557
|
+
console.error(chalk4.dim(" Metro keeps running for the other vibeview dev in this project."));
|
|
3558
|
+
return;
|
|
3559
|
+
}
|
|
3560
|
+
killOwnedMetro(process.cwd(), metroPid);
|
|
3561
|
+
};
|
|
3562
|
+
const handOffOwnedMetro = (sessionId) => {
|
|
3563
|
+
if (metroPid !== null && sessionId !== void 0 && ownsMetro(sessionId)) {
|
|
3564
|
+
handOffMetro(process.cwd(), metroPid, sessionId);
|
|
3565
|
+
}
|
|
3566
|
+
};
|
|
3567
|
+
const stopSession = async (code, how = {}) => {
|
|
3324
3568
|
if (stopping) return;
|
|
3325
3569
|
stopping = true;
|
|
3570
|
+
if (how.replaced) {
|
|
3571
|
+
reloadLoop?.stop();
|
|
3572
|
+
handOffOwnedMetro(how.sessionId);
|
|
3573
|
+
clearDevStateIfOwned(process.cwd(), process.pid);
|
|
3574
|
+
process.exit(code);
|
|
3575
|
+
return;
|
|
3576
|
+
}
|
|
3326
3577
|
console.error(chalk4.dim("\nStopping session..."));
|
|
3327
3578
|
reloadLoop?.stop();
|
|
3579
|
+
const ownMetro = ownsMetro(handle?.sessionId);
|
|
3328
3580
|
await handle?.stop();
|
|
3329
|
-
if (
|
|
3330
|
-
|
|
3581
|
+
if (ownMetro) stopOwnedMetro();
|
|
3582
|
+
clearDevStateIfOwned(process.cwd(), process.pid);
|
|
3331
3583
|
process.exit(code);
|
|
3332
3584
|
};
|
|
3333
3585
|
process.on("SIGINT", () => void stopSession(0));
|
|
@@ -3336,6 +3588,7 @@ async function runDevInProcess(opts) {
|
|
|
3336
3588
|
let waitingPrinted = false;
|
|
3337
3589
|
let nextWarningIsMetroUnreachable = false;
|
|
3338
3590
|
let endedEmitted = false;
|
|
3591
|
+
const idleMode = cfg.platform === "roku" ? "roku" : standalone ? "standalone" : "metro";
|
|
3339
3592
|
try {
|
|
3340
3593
|
handle = await startDevSession(
|
|
3341
3594
|
{
|
|
@@ -3373,12 +3626,29 @@ async function runDevInProcess(opts) {
|
|
|
3373
3626
|
console.error(
|
|
3374
3627
|
chalk4.dim(" Putting the channel on the Roku \u2014 the link appears once it is up.")
|
|
3375
3628
|
);
|
|
3629
|
+
} else if (evt.event === "idle_warning") {
|
|
3630
|
+
console.error(chalk4.yellow(`\u26A0 ${idleWarningLine(evt.seconds_remaining, idleMode)}`));
|
|
3631
|
+
} else if (evt.event === "session_ended" && evt.replaced && !stopping && handle) {
|
|
3632
|
+
console.error(chalk4.dim(evt.reason ?? TUNNEL_REPLACED_REASON));
|
|
3633
|
+
if (ownsMetro(evt.session_id)) {
|
|
3634
|
+
console.error(
|
|
3635
|
+
chalk4.dim(" Metro keeps running for that connection, which stops it when it exits.")
|
|
3636
|
+
);
|
|
3637
|
+
}
|
|
3638
|
+
stopping = true;
|
|
3639
|
+
endedEmitted = true;
|
|
3640
|
+
emit(opts.json, evt);
|
|
3641
|
+
handOffOwnedMetro(evt.session_id);
|
|
3642
|
+
clearDevStateIfOwned(process.cwd(), process.pid);
|
|
3643
|
+
process.exit(0);
|
|
3376
3644
|
} else if (evt.event === "session_ended" && !stopping && handle) {
|
|
3377
3645
|
console.error(chalk4.red(evt.reason ?? "session ended by the server"));
|
|
3378
3646
|
stopping = true;
|
|
3379
3647
|
endedEmitted = true;
|
|
3380
3648
|
emit(opts.json, evt);
|
|
3381
|
-
|
|
3649
|
+
reloadLoop?.stop();
|
|
3650
|
+
if (ownsMetro(evt.session_id)) stopOwnedMetro();
|
|
3651
|
+
clearDevStateIfOwned(process.cwd(), process.pid);
|
|
3382
3652
|
process.exit(1);
|
|
3383
3653
|
}
|
|
3384
3654
|
if (evt.event === "session_ended") {
|
|
@@ -3430,19 +3700,26 @@ async function runDevInProcess(opts) {
|
|
|
3430
3700
|
if (opts.open !== false) await openBrowser(handle.pageUrl).catch(() => {
|
|
3431
3701
|
});
|
|
3432
3702
|
if (rokuBuild) {
|
|
3703
|
+
const rokuPush = createRokuPush({
|
|
3704
|
+
cwd: process.cwd(),
|
|
3705
|
+
sessionId: handle.sessionId,
|
|
3706
|
+
build: rokuBuild,
|
|
3707
|
+
token,
|
|
3708
|
+
baseUrl,
|
|
3709
|
+
api,
|
|
3710
|
+
// Build output and loop notices are human output, so they go to
|
|
3711
|
+
// stderr like every other line this command prints — stdout stays
|
|
3712
|
+
// reserved for the `--json` event stream.
|
|
3713
|
+
log: (line) => console.error(chalk4.dim(line))
|
|
3714
|
+
});
|
|
3715
|
+
const live = handle;
|
|
3433
3716
|
reloadLoop = createReloadLoop({
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
api,
|
|
3441
|
-
// Build output and loop notices are human output, so they go to
|
|
3442
|
-
// stderr like every other line this command prints — stdout stays
|
|
3443
|
-
// reserved for the `--json` event stream.
|
|
3444
|
-
log: (line) => console.error(chalk4.dim(line))
|
|
3445
|
-
}),
|
|
3717
|
+
// A reloaded channel is use: once the push succeeds, drop an idle
|
|
3718
|
+
// warning from a presence request begun before it.
|
|
3719
|
+
push: async () => {
|
|
3720
|
+
await rokuPush();
|
|
3721
|
+
live.noteUse?.();
|
|
3722
|
+
},
|
|
3446
3723
|
log: (line) => console.error(chalk4.yellow(line)),
|
|
3447
3724
|
// A failed reload is an error, not a notice — same red as every other
|
|
3448
3725
|
// failure this command prints, so it doesn't read like the yellow
|
|
@@ -3486,6 +3763,7 @@ async function runDevInProcess(opts) {
|
|
|
3486
3763
|
void (async () => {
|
|
3487
3764
|
if (stopping) return;
|
|
3488
3765
|
let info = null;
|
|
3766
|
+
const stamp = handle?.beginPresence?.();
|
|
3489
3767
|
try {
|
|
3490
3768
|
info = await api.touchSession(sessionId);
|
|
3491
3769
|
} catch {
|
|
@@ -3504,6 +3782,7 @@ async function runDevInProcess(opts) {
|
|
|
3504
3782
|
await stopSession(1);
|
|
3505
3783
|
return;
|
|
3506
3784
|
}
|
|
3785
|
+
handle?.noteSessionInfo?.(info, stamp);
|
|
3507
3786
|
const warningVerdict = lateWarningVerdict(info, reportedWarnings);
|
|
3508
3787
|
for (const w of warningVerdict.toReport) {
|
|
3509
3788
|
reportedWarnings.add(w);
|
|
@@ -3527,7 +3806,10 @@ async function runDevInProcess(opts) {
|
|
|
3527
3806
|
const message = err.message;
|
|
3528
3807
|
console.error(chalk4.red(`error: ${message}`));
|
|
3529
3808
|
emit(opts.json, { event: "error", message });
|
|
3530
|
-
await stopSession(
|
|
3809
|
+
await stopSession(
|
|
3810
|
+
1,
|
|
3811
|
+
err instanceof SessionReplacedError ? { replaced: true, sessionId: err.sessionId } : {}
|
|
3812
|
+
);
|
|
3531
3813
|
}
|
|
3532
3814
|
}
|
|
3533
3815
|
async function dev(opts) {
|
|
@@ -3717,7 +3999,7 @@ var PRESS_PLATFORM_LABELS = [
|
|
|
3717
3999
|
["android", "Android phone / tablet"],
|
|
3718
4000
|
["appleTv", "Apple TV"],
|
|
3719
4001
|
["androidTv", "Android TV"],
|
|
3720
|
-
["roku", "Roku
|
|
4002
|
+
["roku", "Roku"]
|
|
3721
4003
|
];
|
|
3722
4004
|
var PRESS_BUTTONS_BY_PLATFORM = PRESS_PLATFORM_LABELS.map(([key, label]) => [label, PRESS_BUTTONS[key]]);
|
|
3723
4005
|
function pressButtonLines(indent) {
|
|
@@ -4312,7 +4594,7 @@ function agentExitCode(env) {
|
|
|
4312
4594
|
}
|
|
4313
4595
|
|
|
4314
4596
|
// src/commands/agent.ts
|
|
4315
|
-
import { writeFileSync as
|
|
4597
|
+
import { writeFileSync as writeFileSync7 } from "fs";
|
|
4316
4598
|
var REF_RE = /^@?(e\d+)$/;
|
|
4317
4599
|
var COORDS_RE = /^(-?\d+),\s*(-?\d+)$/;
|
|
4318
4600
|
var FlagError = class extends Error {
|
|
@@ -4482,6 +4764,16 @@ function decodeScreenshotResult(result) {
|
|
|
4482
4764
|
}
|
|
4483
4765
|
return Buffer.from(b64, "base64");
|
|
4484
4766
|
}
|
|
4767
|
+
var NON_USE_TOOLS = /* @__PURE__ */ new Set([
|
|
4768
|
+
"ui_tree",
|
|
4769
|
+
"screenshot",
|
|
4770
|
+
"logs",
|
|
4771
|
+
"find_element",
|
|
4772
|
+
"wait"
|
|
4773
|
+
]);
|
|
4774
|
+
function countsAsUse(tool) {
|
|
4775
|
+
return !NON_USE_TOOLS.has(tool);
|
|
4776
|
+
}
|
|
4485
4777
|
async function runAgentVerb(cmd, positionals, opts) {
|
|
4486
4778
|
const { token, baseUrl } = resolveAuth(opts);
|
|
4487
4779
|
const sessionId = opts.session || readDevState(process.cwd())?.session_id;
|
|
@@ -4512,6 +4804,7 @@ async function runAgentVerb(cmd, positionals, opts) {
|
|
|
4512
4804
|
}
|
|
4513
4805
|
process.exit(errorExitCode(err));
|
|
4514
4806
|
}
|
|
4807
|
+
if (countsAsUse(call.tool)) recordDevUse(sessionId);
|
|
4515
4808
|
if (cmd.verb === "screenshot" && envelope.ok && envelope.result) {
|
|
4516
4809
|
const outPath = opts.out || defaultScreenshotPath();
|
|
4517
4810
|
let imageBytes;
|
|
@@ -4522,7 +4815,7 @@ async function runAgentVerb(cmd, positionals, opts) {
|
|
|
4522
4815
|
`);
|
|
4523
4816
|
process.exit(1);
|
|
4524
4817
|
}
|
|
4525
|
-
|
|
4818
|
+
writeFileSync7(outPath, imageBytes);
|
|
4526
4819
|
if (opts.json) {
|
|
4527
4820
|
console.log(
|
|
4528
4821
|
JSON.stringify({ ...envelope, result: { written_to: outPath, bytes: imageBytes.length } })
|
|
@@ -4563,6 +4856,7 @@ export {
|
|
|
4563
4856
|
clearDevState,
|
|
4564
4857
|
readMetroState,
|
|
4565
4858
|
killOwnedMetro,
|
|
4859
|
+
recordDevUse,
|
|
4566
4860
|
checkMetro,
|
|
4567
4861
|
loadLocalDevConfig,
|
|
4568
4862
|
effectiveDevConfig,
|
|
@@ -4574,6 +4868,7 @@ export {
|
|
|
4574
4868
|
printTransition,
|
|
4575
4869
|
printOutcomeLine,
|
|
4576
4870
|
createRokuPush,
|
|
4871
|
+
parseIdleWarningSeconds,
|
|
4577
4872
|
startDevSession,
|
|
4578
4873
|
PLATFORM_DEVICE,
|
|
4579
4874
|
dev,
|
|
@@ -4585,5 +4880,6 @@ export {
|
|
|
4585
4880
|
renderEnvelope,
|
|
4586
4881
|
buildToolCall,
|
|
4587
4882
|
decodeScreenshotResult,
|
|
4883
|
+
countsAsUse,
|
|
4588
4884
|
runAgentVerb
|
|
4589
4885
|
};
|
package/dist/cli.js
CHANGED
|
@@ -23,8 +23,9 @@ import {
|
|
|
23
23
|
readDevState,
|
|
24
24
|
readDevStateFile,
|
|
25
25
|
readMetroState,
|
|
26
|
+
recordDevUse,
|
|
26
27
|
runAgentVerb
|
|
27
|
-
} from "./chunk-
|
|
28
|
+
} from "./chunk-VFCG4U7V.js";
|
|
28
29
|
import {
|
|
29
30
|
ApiClient,
|
|
30
31
|
ApiStatusError,
|
|
@@ -1787,6 +1788,7 @@ async function devReload(opts) {
|
|
|
1787
1788
|
} catch (err) {
|
|
1788
1789
|
fail(opts, "reload_failed", err instanceof Error ? err.message : String(err), 1);
|
|
1789
1790
|
}
|
|
1791
|
+
recordDevUse(state.session_id);
|
|
1790
1792
|
if (opts.json) {
|
|
1791
1793
|
console.log(
|
|
1792
1794
|
JSON.stringify({
|
|
@@ -3660,8 +3662,10 @@ var CLAUDE_MD_SUGGESTION = "After changing UI or app behavior, verify it on a li
|
|
|
3660
3662
|
function claudeCliAvailable() {
|
|
3661
3663
|
return spawnSync("claude", ["--version"], { stdio: "ignore" }).error === void 0;
|
|
3662
3664
|
}
|
|
3665
|
+
var MCP_COMMAND = ["npx", "-y", "vibeview", "mcp"];
|
|
3666
|
+
var MCP_ADD_HINT = `claude mcp add vibeview -- ${MCP_COMMAND.join(" ")}`;
|
|
3663
3667
|
function registerMcp() {
|
|
3664
|
-
const res = spawnSync("claude", ["mcp", "add", "vibeview", "--",
|
|
3668
|
+
const res = spawnSync("claude", ["mcp", "add", "vibeview", "--", ...MCP_COMMAND], {
|
|
3665
3669
|
encoding: "utf8"
|
|
3666
3670
|
});
|
|
3667
3671
|
if (res.status === 0) {
|
|
@@ -3674,7 +3678,7 @@ function registerMcp() {
|
|
|
3674
3678
|
return;
|
|
3675
3679
|
}
|
|
3676
3680
|
console.error(chalk21.yellow(`\u26A0 could not register the MCP server: ${out.trim() || res.error}`));
|
|
3677
|
-
console.error(chalk21.yellow(
|
|
3681
|
+
console.error(chalk21.yellow(` register it manually: ${MCP_ADD_HINT}`));
|
|
3678
3682
|
}
|
|
3679
3683
|
async function agentSetup(opts) {
|
|
3680
3684
|
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
@@ -3700,9 +3704,7 @@ async function agentSetup(opts) {
|
|
|
3700
3704
|
if (!claudeCliAvailable()) {
|
|
3701
3705
|
if (opts.mcp === true) {
|
|
3702
3706
|
console.error(
|
|
3703
|
-
chalk21.yellow(
|
|
3704
|
-
"\u26A0 claude CLI not found \u2014 install Claude Code, then run: claude mcp add vibeview -- vibeview mcp"
|
|
3705
|
-
)
|
|
3707
|
+
chalk21.yellow(`\u26A0 claude CLI not found \u2014 install Claude Code, then run: ${MCP_ADD_HINT}`)
|
|
3706
3708
|
);
|
|
3707
3709
|
}
|
|
3708
3710
|
} else if (opts.mcp === true || process.stdin.isTTY && await confirm({
|
|
@@ -3835,7 +3837,7 @@ function resolveMetadata(opts) {
|
|
|
3835
3837
|
}
|
|
3836
3838
|
var program = new Command();
|
|
3837
3839
|
program.name("vibeview").description(
|
|
3838
|
-
"VibeView CLI -- run your app on cloud iOS simulators, Android emulators, Apple TV, Android TV and Roku
|
|
3840
|
+
"VibeView CLI -- run your app on cloud iOS simulators, Android emulators, Apple TV, Android TV and Roku devices: a React Native dev loop (dev), device control for you or your coding agent (ui-tree, tap, mcp), and tests from CI (run-test, run-suites)"
|
|
3839
3841
|
).version(pkg.version);
|
|
3840
3842
|
program.command("run-suite <suite-id>").description("Run all test cases in a suite").option("--token <token>", "API token (or set VIBEVIEW_API_TOKEN)").option("--output <format>", "Output format: human, json, junit", "human").option("--verbose", "Show per-case breakdown", false).option("--timeout <seconds>", "Max wait time in seconds (queue wait plus execution)", "1800").option("--commit-sha <sha>", "Git commit SHA for GitHub status checks").option("--model <model>", "AI model to use").option("--provider <provider>", "AI provider").option("--mode <mode>", "Execution mode: hybrid, ai, replay").option(
|
|
3841
3843
|
"--device-category <category>",
|
|
@@ -4145,7 +4147,7 @@ program.command("dev-reload").description(
|
|
|
4145
4147
|
program.command("mcp").description(
|
|
4146
4148
|
"Run an MCP stdio server exposing the agent verbs (tap, swipe, type, ...) plus dev_start/dev_stop/dev_reload as tools"
|
|
4147
4149
|
).option("--token <token>", "API token (or set VIBEVIEW_API_TOKEN)").action(async (opts) => {
|
|
4148
|
-
const { runMcpServer } = await import("./server-
|
|
4150
|
+
const { runMcpServer } = await import("./server-2CDIRTBV.js");
|
|
4149
4151
|
await runMcpServer({ ...opts, version: pkg.version });
|
|
4150
4152
|
});
|
|
4151
4153
|
program.command("agent-setup").description(
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
buildToolCall,
|
|
10
10
|
checkDevBuild,
|
|
11
11
|
checkMetro,
|
|
12
|
+
countsAsUse,
|
|
12
13
|
createRokuPush,
|
|
13
14
|
decodeScreenshotResult,
|
|
14
15
|
describeDevBuild,
|
|
@@ -20,12 +21,14 @@ import {
|
|
|
20
21
|
loadDevConfig,
|
|
21
22
|
loadLocalDevConfig,
|
|
22
23
|
parseBuildId,
|
|
24
|
+
parseIdleWarningSeconds,
|
|
23
25
|
platformWithArticle,
|
|
24
26
|
readDevState,
|
|
27
|
+
recordDevUse,
|
|
25
28
|
renderEnvelope,
|
|
26
29
|
resolveMetro,
|
|
27
30
|
startDevSession
|
|
28
|
-
} from "./chunk-
|
|
31
|
+
} from "./chunk-VFCG4U7V.js";
|
|
29
32
|
import {
|
|
30
33
|
ApiClient,
|
|
31
34
|
NotFoundError,
|
|
@@ -241,9 +244,15 @@ function capitalize(text) {
|
|
|
241
244
|
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
242
245
|
}
|
|
243
246
|
function endedLine(ended) {
|
|
247
|
+
if (ended.status === "replaced") {
|
|
248
|
+
return `session ${ended.sessionId} was taken over by another VibeView dev connection and is no longer held here (it keeps running there)`;
|
|
249
|
+
}
|
|
244
250
|
const why = ended.reason ? `: ${ended.reason}` : "";
|
|
245
251
|
return `session ${ended.sessionId} has ended (${ended.status}${why})`;
|
|
246
252
|
}
|
|
253
|
+
function idleNoticeText(sessionId, seconds) {
|
|
254
|
+
return `Session ${sessionId} ends in ${seconds} s for inactivity: a device action (tap, type, swipe, press, \u2026) keeps it; ui_tree, screenshot, logs, find and wait do not.`;
|
|
255
|
+
}
|
|
247
256
|
async function explainInactive(sessionId, deps) {
|
|
248
257
|
let info;
|
|
249
258
|
try {
|
|
@@ -266,7 +275,7 @@ async function explainInactive(sessionId, deps) {
|
|
|
266
275
|
function findCommand(toolName) {
|
|
267
276
|
return AGENT_COMMANDS.find((c) => toolNameFor(c.verb) === toolName);
|
|
268
277
|
}
|
|
269
|
-
async function
|
|
278
|
+
async function dispatchToolCall(toolName, args, deps, extra = {}) {
|
|
270
279
|
if (toolName === "dev_start") {
|
|
271
280
|
return handleDevStart(args, deps, extra);
|
|
272
281
|
}
|
|
@@ -331,6 +340,55 @@ async function handleToolCall(toolName, args, deps, extra = {}) {
|
|
|
331
340
|
}
|
|
332
341
|
return textResult(renderEnvelope(envelope, toolNameFor(cmd.verb)), envelope, !envelope.ok);
|
|
333
342
|
}
|
|
343
|
+
async function handleToolCall(toolName, args, deps, extra = {}) {
|
|
344
|
+
const held = deps.devSession.current?.()?.sessionId;
|
|
345
|
+
const dispatched = typeof args.session_id === "string" ? args.session_id.trim() : deps.resolveSessionId();
|
|
346
|
+
const usesBefore = deps.devSession.useGeneration?.();
|
|
347
|
+
const result = await dispatchToolCall(toolName, args, deps, extra);
|
|
348
|
+
noteDeviceUse(toolName, result, deps, held, dispatched);
|
|
349
|
+
const usedSince = usesBefore !== void 0 && (deps.devSession.useGeneration?.() ?? usesBefore) > usesBefore;
|
|
350
|
+
return withIdleNotice(toolName, args, result, deps, held, dispatched, usedSince);
|
|
351
|
+
}
|
|
352
|
+
function noteDeviceUse(toolName, result, deps, held, dispatched) {
|
|
353
|
+
const cmd = findCommand(toolName);
|
|
354
|
+
if (!cmd || !dispatched || !result.structuredContent) return;
|
|
355
|
+
if (!countsAsUse(cmd.tool)) return;
|
|
356
|
+
if (dispatched === held) deps.devSession.noteUse?.(dispatched);
|
|
357
|
+
else recordDevUse(dispatched);
|
|
358
|
+
}
|
|
359
|
+
var IDLE_NOTICE_DISCARD = /* @__PURE__ */ new Set(["dev_start", "dev_stop", "dev_reload"]);
|
|
360
|
+
function withIdleNotice(toolName, args, result, deps, held, dispatched, usedSince = false) {
|
|
361
|
+
const dev = deps.devSession;
|
|
362
|
+
const stopsHeld = toolName === "stop_session" && held !== void 0 && (typeof args.session_id === "string" ? args.session_id.trim() : held) === held;
|
|
363
|
+
if ((IDLE_NOTICE_DISCARD.has(toolName) || stopsHeld) && !result.isError) {
|
|
364
|
+
const about = toolName === "dev_start" ? dev.current?.()?.sessionId : held;
|
|
365
|
+
if (about !== void 0) dev.takeIdleWarning?.(about);
|
|
366
|
+
return result;
|
|
367
|
+
}
|
|
368
|
+
if (findCommand(toolName) && result.structuredContent && typeof result.structuredContent === "object") {
|
|
369
|
+
const own = result.structuredContent.idle_warning;
|
|
370
|
+
if (own !== void 0) {
|
|
371
|
+
const target = dispatched;
|
|
372
|
+
if (target !== void 0 && target === held) dev.takeIdleWarning?.(target);
|
|
373
|
+
const seconds = parseIdleWarningSeconds(own?.seconds_remaining);
|
|
374
|
+
if (usedSince && target === held) return result;
|
|
375
|
+
if (target && seconds !== null) return appendNotice(result, idleNoticeText(target, seconds));
|
|
376
|
+
return result;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const notice = dev.takeIdleWarning?.() ?? null;
|
|
380
|
+
return notice ? appendNotice(result, notice) : result;
|
|
381
|
+
}
|
|
382
|
+
function appendNotice(result, notice) {
|
|
383
|
+
const i = result.content.findIndex((b) => b.type === "text");
|
|
384
|
+
if (i === -1) return { ...result, content: [...result.content, { type: "text", text: notice }] };
|
|
385
|
+
const content = result.content.slice();
|
|
386
|
+
const block = content[i];
|
|
387
|
+
content[i] = { type: "text", text: `${block.text}
|
|
388
|
+
|
|
389
|
+
${notice}` };
|
|
390
|
+
return { ...result, content };
|
|
391
|
+
}
|
|
334
392
|
function screenshotToolResult(envelope, args) {
|
|
335
393
|
const { image, preview } = splitScreenshotResult(envelope.result);
|
|
336
394
|
let imageBytes;
|
|
@@ -613,7 +671,7 @@ async function handleDevStop(deps) {
|
|
|
613
671
|
}
|
|
614
672
|
if (!current) {
|
|
615
673
|
return textResult(
|
|
616
|
-
ended ? `no dev session was running \u2014 it had already ended. ${capitalize(endedLine(ended))}` : "no dev session was running",
|
|
674
|
+
ended ? ended.status === "replaced" ? `no dev session was running here. ${capitalize(endedLine(ended))}` : `no dev session was running \u2014 it had already ended. ${capitalize(endedLine(ended))}` : "no dev session was running",
|
|
617
675
|
{ session_id: null, stopped: false }
|
|
618
676
|
);
|
|
619
677
|
}
|
|
@@ -725,6 +783,7 @@ function createDevSessionController(auth, webUrl) {
|
|
|
725
783
|
let ended = null;
|
|
726
784
|
let reloading = null;
|
|
727
785
|
let pending = null;
|
|
786
|
+
let idle = null;
|
|
728
787
|
const readRokuBuild = () => {
|
|
729
788
|
try {
|
|
730
789
|
return loadDevConfig(process.cwd())?.platforms?.roku?.build ?? null;
|
|
@@ -739,10 +798,25 @@ function createDevSessionController(auth, webUrl) {
|
|
|
739
798
|
const dead = handle;
|
|
740
799
|
handle = null;
|
|
741
800
|
platform = null;
|
|
801
|
+
idle = null;
|
|
742
802
|
ended = { ...e, reason: e.reason ?? dead.lapsedReason?.() ?? null };
|
|
743
803
|
void dead.stop().catch(() => {
|
|
744
804
|
});
|
|
745
805
|
};
|
|
806
|
+
const markReplaced = (sessionId) => {
|
|
807
|
+
if (!handle || handle.sessionId !== sessionId) return;
|
|
808
|
+
handle = null;
|
|
809
|
+
platform = null;
|
|
810
|
+
idle = null;
|
|
811
|
+
ended = { sessionId, status: "replaced", reason: null };
|
|
812
|
+
};
|
|
813
|
+
let uses = 0;
|
|
814
|
+
const noteHeldUse = (sessionId) => {
|
|
815
|
+
if (!handle || handle.sessionId !== sessionId) return;
|
|
816
|
+
uses++;
|
|
817
|
+
handle.noteUse?.();
|
|
818
|
+
if (idle?.sessionId === sessionId) idle = null;
|
|
819
|
+
};
|
|
746
820
|
const api = () => new ApiClient(auth.baseUrl, auth.token);
|
|
747
821
|
return {
|
|
748
822
|
async start(cfg) {
|
|
@@ -832,11 +906,18 @@ function createDevSessionController(auth, webUrl) {
|
|
|
832
906
|
onCreated: (created) => {
|
|
833
907
|
pending = created;
|
|
834
908
|
},
|
|
835
|
-
onDequeued: () => progress.dequeued()
|
|
909
|
+
onDequeued: () => progress.dequeued(),
|
|
910
|
+
onIdleWarning: (id, warning) => {
|
|
911
|
+
idle = warning ? { sessionId: id, endsAt: Date.now() + warning.seconds_remaining * 1e3 } : null;
|
|
912
|
+
}
|
|
836
913
|
},
|
|
837
914
|
(evt) => {
|
|
838
915
|
progress.event(evt);
|
|
839
916
|
if (evt.event === "warning") warnings.push(evt.message);
|
|
917
|
+
if (evt.event === "session_ended" && evt.replaced && started) {
|
|
918
|
+
markReplaced(evt.session_id);
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
840
921
|
if (evt.event === "session_ended" && evt.code !== 0 && started) {
|
|
841
922
|
const id = evt.session_id;
|
|
842
923
|
void api().getSession(id).then(
|
|
@@ -882,6 +963,7 @@ function createDevSessionController(auth, webUrl) {
|
|
|
882
963
|
await handle?.stop();
|
|
883
964
|
handle = null;
|
|
884
965
|
platform = null;
|
|
966
|
+
idle = null;
|
|
885
967
|
ended = null;
|
|
886
968
|
},
|
|
887
969
|
async shutdown() {
|
|
@@ -890,6 +972,7 @@ function createDevSessionController(auth, webUrl) {
|
|
|
890
972
|
const starting = pending;
|
|
891
973
|
handle = null;
|
|
892
974
|
platform = null;
|
|
975
|
+
idle = null;
|
|
893
976
|
pending = null;
|
|
894
977
|
await Promise.all([
|
|
895
978
|
held ? held.stop().then(
|
|
@@ -912,6 +995,17 @@ function createDevSessionController(auth, webUrl) {
|
|
|
912
995
|
lastEnded() {
|
|
913
996
|
return ended;
|
|
914
997
|
},
|
|
998
|
+
noteUse: noteHeldUse,
|
|
999
|
+
useGeneration: () => uses,
|
|
1000
|
+
takeIdleWarning(sessionId) {
|
|
1001
|
+
const w = idle;
|
|
1002
|
+
if (sessionId !== void 0 && w?.sessionId !== sessionId) return null;
|
|
1003
|
+
idle = null;
|
|
1004
|
+
if (!w || !handle || handle.sessionId !== w.sessionId) return null;
|
|
1005
|
+
const seconds = Math.ceil((w.endsAt - Date.now()) / 1e3);
|
|
1006
|
+
if (seconds <= 0) return null;
|
|
1007
|
+
return idleNoticeText(w.sessionId, seconds);
|
|
1008
|
+
},
|
|
915
1009
|
async reload(requested) {
|
|
916
1010
|
if (reloading) {
|
|
917
1011
|
throw new Error(
|
|
@@ -970,6 +1064,8 @@ function createDevSessionController(auth, webUrl) {
|
|
|
970
1064
|
log: (line) => console.error(line)
|
|
971
1065
|
});
|
|
972
1066
|
await push();
|
|
1067
|
+
if (handle?.sessionId === target.sessionId) noteHeldUse(target.sessionId);
|
|
1068
|
+
else recordDevUse(target.sessionId);
|
|
973
1069
|
return { sessionId: target.sessionId, seconds: (Date.now() - started) / 1e3 };
|
|
974
1070
|
}
|
|
975
1071
|
}
|
|
@@ -978,12 +1074,13 @@ function makeSessionResolver(devSession) {
|
|
|
978
1074
|
return () => devSession.current()?.sessionId ?? readDevState(process.cwd())?.session_id;
|
|
979
1075
|
}
|
|
980
1076
|
var MCP_INSTRUCTIONS = [
|
|
981
|
-
"VibeView runs your app on a cloud iOS simulator, Android emulator, Apple TV, Android TV or Roku
|
|
1077
|
+
"VibeView runs your app on a cloud iOS simulator, Android emulator, Apple TV, Android TV or Roku device you control with these tools.",
|
|
982
1078
|
"Loop: dev_start (platform, app from list_apps; add standalone: true for a release or CI build that needs no Metro) -> ui_tree -> act with refs (tap @e5, type, swipe, press) and read the change each action reports -> dev_stop when done.",
|
|
983
1079
|
"Foldables: pick one by model in dev_start (list_device_models marks them foldable, with free/busy counts), then set_posture and rotate.",
|
|
984
1080
|
"set_location sets the simulated GPS position (phones and tablets) for location-based flows.",
|
|
985
1081
|
"screenshot writes a PNG and returns its path; pass inline: true to see the image. logs reads the app output after a crash or blank screen.",
|
|
986
|
-
"A session stopped elsewhere is reported as ended; call dev_start again for a new one."
|
|
1082
|
+
"A session stopped elsewhere is reported as ended; call dev_start again for a new one.",
|
|
1083
|
+
'A session nobody uses ends after the idle timeout. A result that ends with "Session \u2026 ends in N s for inactivity" means act (tap, type, swipe, press) to keep it, or dev_stop if you are done; ui_tree, screenshot, logs, find and wait do not count as use.'
|
|
987
1084
|
].join("\n");
|
|
988
1085
|
async function createMcpServer(opts) {
|
|
989
1086
|
const [{ McpServer }, { z }] = await Promise.all([
|
|
@@ -1100,6 +1197,7 @@ export {
|
|
|
1100
1197
|
deviceLine,
|
|
1101
1198
|
forMcp,
|
|
1102
1199
|
handleToolCall,
|
|
1200
|
+
idleNoticeText,
|
|
1103
1201
|
mcpText,
|
|
1104
1202
|
progressReporter,
|
|
1105
1203
|
runMcpServer,
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibeview",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "VibeView CLI -- run your app on cloud iOS simulators, Android emulators, Apple TV, Android TV and Roku
|
|
3
|
+
"version": "0.36.1",
|
|
4
|
+
"description": "VibeView CLI -- run your app on cloud iOS simulators, Android emulators, Apple TV, Android TV and Roku devices: a React Native dev loop, device control for you or your coding agent (CLI or MCP), and tests from CI",
|
|
5
5
|
"mcpName": "io.github.vibeview/vibeview",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -211,7 +211,7 @@ appear unexplained in their `git status`.
|
|
|
211
211
|
`ui-tree` afterwards — old refs are stale. On other phones and tablets
|
|
212
212
|
`rotate` toggles portrait/landscape (only 90 is accepted); TV devices don't
|
|
213
213
|
rotate.
|
|
214
|
-
- **Roku channels
|
|
214
|
+
- **Roku channels**: start the session with
|
|
215
215
|
`vibeview dev --platform roku --detach` (or `dev_start` with platform
|
|
216
216
|
`roku` over MCP); after an edit run `vibeview dev-reload` (MCP:
|
|
217
217
|
`dev_reload`) to re-package, upload and restart the channel in the same
|
|
@@ -254,6 +254,10 @@ vibeview dev-stop
|
|
|
254
254
|
If the `dev --detach` process is killed or the computer sleeps, its session
|
|
255
255
|
ends on its own within about 90 seconds (unless a browser tab is watching
|
|
256
256
|
it) — but don't rely on that; stop it.
|
|
257
|
+
A session nobody uses also ends on its own after the organization's idle
|
|
258
|
+
timeout (30 minutes by default). Only device actions (tap, type, swipe,
|
|
259
|
+
press, …) and app reloads count as use; `ui-tree`, `screenshot`, `logs`,
|
|
260
|
+
`find` and `wait` do not, and neither does keeping `dev --detach` running.
|
|
257
261
|
A session you did not start with `dev` (one created through the API, the
|
|
258
262
|
sandbox, or a collaboration link) is stopped by id:
|
|
259
263
|
```bash
|
|
@@ -332,6 +336,10 @@ steps the CLI does with plain shell commands:
|
|
|
332
336
|
If the held session ends elsewhere (dashboard, idle timeout, failure), the
|
|
333
337
|
next tool call answers "session … has ended (…) — call dev_start"; the
|
|
334
338
|
server has already let it go, so `dev_start` starts a fresh one.
|
|
339
|
+
A tool result that ends with `Session … ends in N s for inactivity` means
|
|
340
|
+
the held session is about to time out: act on the device to keep it, or call
|
|
341
|
+
`dev_stop` if you are done. A session another VibeView dev connection took
|
|
342
|
+
over is reported as taken over; it keeps running there, so don't stop it.
|
|
335
343
|
|
|
336
344
|
**`dev_start` is stateful — it changes the default session for every later
|
|
337
345
|
call.** Once it succeeds, any tool called without an explicit `session_id`
|