@firstpick/pi-package-webui 0.3.2 → 0.3.4
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/README.md +12 -3
- package/bin/pi-webui.mjs +1261 -16
- package/package.json +2 -1
- package/public/app.js +1536 -42
- package/public/index.html +79 -3
- package/public/service-worker.js +1 -1
- package/public/styles.css +688 -27
- package/tests/mobile-static.test.mjs +101 -9
package/bin/pi-webui.mjs
CHANGED
|
@@ -118,6 +118,30 @@ const AUTO_TAB_TITLE_STOP_WORDS = new Set([
|
|
|
118
118
|
"your",
|
|
119
119
|
]);
|
|
120
120
|
|
|
121
|
+
const APP_RUNNER_CONFIG_FILE = ".pi-webui-runners.json";
|
|
122
|
+
const APP_RUNNER_CUSTOM_LIMIT = 48;
|
|
123
|
+
const APP_RUNNER_CUSTOM_ARG_LIMIT = 32;
|
|
124
|
+
const APP_RUNNER_FILE_PICKER_LIMIT = 500;
|
|
125
|
+
const APP_RUNNER_DETECTION_TIMEOUT_MS = 1_200;
|
|
126
|
+
const APP_RUNNER_COMMAND_CACHE_TTL_MS = 30_000;
|
|
127
|
+
const APP_RUNNER_OUTPUT_LINE_LIMIT = 1_000;
|
|
128
|
+
const APP_RUNNER_OUTPUT_MAX_CHARS = 240_000;
|
|
129
|
+
const APP_RUNNER_STOP_GRACE_MS = 2_500;
|
|
130
|
+
const APP_RUNNER_PYTHON_ENTRIES = ["Main.py", "main.py", "src/main.py", "src/Main.py", "app.py", "src/app.py"];
|
|
131
|
+
const APP_RUNNER_JS_ENTRIES = ["main.js", "src/main.js", "index.js", "src/index.js", "server.js", "src/server.js", "app.js", "src/app.js"];
|
|
132
|
+
const APP_RUNNER_ZIG_ENTRIES = ["src/main.zig", "main.zig"];
|
|
133
|
+
const APP_RUNNER_C_ENTRIES = ["main.c", "src/main.c"];
|
|
134
|
+
const APP_RUNNER_CPP_ENTRIES = ["main.cpp", "src/main.cpp", "main.cc", "src/main.cc", "main.cxx", "src/main.cxx"];
|
|
135
|
+
const APP_RUNNER_DOCKER_COMPOSE_FILES = ["compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"];
|
|
136
|
+
const APP_RUNNER_SHELL_SCRIPT_DIRS = ["", "dev", "scripts", "dev/scripts"];
|
|
137
|
+
const APP_RUNNER_SHELL_SCRIPT_LIMIT = 24;
|
|
138
|
+
const APP_RUNNER_SHELL_EXTENSIONS = new Map([
|
|
139
|
+
[".sh", "bash"],
|
|
140
|
+
[".bash", "bash"],
|
|
141
|
+
[".zsh", "zsh"],
|
|
142
|
+
[".fish", "fish"],
|
|
143
|
+
]);
|
|
144
|
+
|
|
121
145
|
const MIME_TYPES = new Map([
|
|
122
146
|
[".html", "text/html; charset=utf-8"],
|
|
123
147
|
[".jsonl", "application/x-ndjson; charset=utf-8"],
|
|
@@ -188,14 +212,18 @@ Usage:
|
|
|
188
212
|
Options:
|
|
189
213
|
--host <host> HTTP bind host (default: ${DEFAULT_HOST})
|
|
190
214
|
--port <port> HTTP port (default: ${DEFAULT_PORT})
|
|
191
|
-
--cwd <path>
|
|
215
|
+
--cwd <path> Start the first Pi terminal in this working directory
|
|
192
216
|
--pi <command> Pi executable to spawn (default: bundled dependency, then "pi")
|
|
193
217
|
--no-session Start Pi RPC with --no-session
|
|
194
218
|
--name <name> Initial Web UI tab display name
|
|
195
219
|
-h, --help Show this help
|
|
196
220
|
-v, --version Print version
|
|
197
221
|
|
|
222
|
+
If --cwd is omitted, the server starts first and the browser asks for
|
|
223
|
+
the first terminal CWD.
|
|
224
|
+
|
|
198
225
|
Examples:
|
|
226
|
+
pi-webui
|
|
199
227
|
pi-webui --cwd ~/src/my-project
|
|
200
228
|
pi-webui --port 3000 -- --model anthropic/claude-sonnet-4-5:high
|
|
201
229
|
PI_WEBUI_PI_BIN=/path/to/pi pi-webui --no-session
|
|
@@ -219,6 +247,7 @@ function parseArgs(argv) {
|
|
|
219
247
|
host: process.env.PI_WEBUI_HOST || DEFAULT_HOST,
|
|
220
248
|
port: Number.parseInt(process.env.PI_WEBUI_PORT || String(DEFAULT_PORT), 10),
|
|
221
249
|
cwd: process.cwd(),
|
|
250
|
+
cwdExplicit: false,
|
|
222
251
|
piBin: process.env.PI_WEBUI_PI_BIN || "pi",
|
|
223
252
|
piBinExplicit: !!process.env.PI_WEBUI_PI_BIN,
|
|
224
253
|
noSession: false,
|
|
@@ -257,7 +286,8 @@ function parseArgs(argv) {
|
|
|
257
286
|
continue;
|
|
258
287
|
}
|
|
259
288
|
if (arg === "--cwd") {
|
|
260
|
-
options.cwd = path.resolve(takeValue(argv, i, arg));
|
|
289
|
+
options.cwd = path.resolve(expandUserPath(takeValue(argv, i, arg)));
|
|
290
|
+
options.cwdExplicit = true;
|
|
261
291
|
i++;
|
|
262
292
|
continue;
|
|
263
293
|
}
|
|
@@ -286,6 +316,24 @@ function parseArgs(argv) {
|
|
|
286
316
|
return options;
|
|
287
317
|
}
|
|
288
318
|
|
|
319
|
+
async function validateStartupCwd(cwd) {
|
|
320
|
+
const normalized = path.resolve(String(cwd || ""));
|
|
321
|
+
let info;
|
|
322
|
+
try {
|
|
323
|
+
info = await stat(normalized);
|
|
324
|
+
} catch (error) {
|
|
325
|
+
if (error?.code === "ENOENT" || error?.code === "ENOTDIR") {
|
|
326
|
+
throw new Error(`--cwd does not exist: ${normalized}`);
|
|
327
|
+
}
|
|
328
|
+
if (error?.code === "EACCES" || error?.code === "EPERM") {
|
|
329
|
+
throw new Error(`--cwd is not accessible: ${normalized}`);
|
|
330
|
+
}
|
|
331
|
+
throw new Error(`Cannot access --cwd ${normalized}: ${formatCliError(error)}`);
|
|
332
|
+
}
|
|
333
|
+
if (!info.isDirectory()) throw new Error(`--cwd is not a directory: ${normalized}`);
|
|
334
|
+
return normalized;
|
|
335
|
+
}
|
|
336
|
+
|
|
289
337
|
function isLocalHost(host) {
|
|
290
338
|
return host === "localhost" || host === "::1" || host === "[::1]" || host.startsWith("127.");
|
|
291
339
|
}
|
|
@@ -304,6 +352,12 @@ function sanitizeError(error) {
|
|
|
304
352
|
return error.stack || error.message || String(error);
|
|
305
353
|
}
|
|
306
354
|
|
|
355
|
+
function formatCliError(error) {
|
|
356
|
+
if (!error) return "Unknown error";
|
|
357
|
+
if (typeof error === "string") return error;
|
|
358
|
+
return error.message || String(error);
|
|
359
|
+
}
|
|
360
|
+
|
|
307
361
|
function delay(ms) {
|
|
308
362
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
309
363
|
}
|
|
@@ -992,6 +1046,997 @@ async function readJsonFileIfExists(filePath) {
|
|
|
992
1046
|
}
|
|
993
1047
|
}
|
|
994
1048
|
|
|
1049
|
+
const appRunnerCommandAvailability = new Map();
|
|
1050
|
+
|
|
1051
|
+
async function fileStatsIfExists(filePath) {
|
|
1052
|
+
try {
|
|
1053
|
+
return await stat(filePath);
|
|
1054
|
+
} catch (error) {
|
|
1055
|
+
if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return null;
|
|
1056
|
+
throw error;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
async function appRunnerFileExists(cwd, relativePath) {
|
|
1061
|
+
const stats = await fileStatsIfExists(path.join(cwd, relativePath));
|
|
1062
|
+
return !!stats?.isFile();
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
async function appRunnerDirectoryExists(cwd, relativePath) {
|
|
1066
|
+
const stats = await fileStatsIfExists(path.join(cwd, relativePath));
|
|
1067
|
+
return !!stats?.isDirectory();
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
async function appRunnerTextIfExists(cwd, relativePath, maxLength = 120_000) {
|
|
1071
|
+
try {
|
|
1072
|
+
const text = await readFile(path.join(cwd, relativePath), "utf8");
|
|
1073
|
+
return text.slice(0, maxLength);
|
|
1074
|
+
} catch (error) {
|
|
1075
|
+
if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return "";
|
|
1076
|
+
return "";
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
async function firstExistingRunnerFile(cwd, candidates) {
|
|
1081
|
+
for (const candidate of candidates) {
|
|
1082
|
+
if (await appRunnerFileExists(cwd, candidate)) return candidate;
|
|
1083
|
+
}
|
|
1084
|
+
return "";
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
async function appRunnerCommandAvailable(command, cwd) {
|
|
1088
|
+
const name = String(command || "").trim();
|
|
1089
|
+
if (!name) return false;
|
|
1090
|
+
const key = `${name}\0${cwd || ""}`;
|
|
1091
|
+
const cached = appRunnerCommandAvailability.get(key);
|
|
1092
|
+
const now = Date.now();
|
|
1093
|
+
if (cached && cached.expiresAt > now) return cached.available;
|
|
1094
|
+
|
|
1095
|
+
const result = await runCommand(name, ["--version"], {
|
|
1096
|
+
cwd,
|
|
1097
|
+
timeoutMs: APP_RUNNER_DETECTION_TIMEOUT_MS,
|
|
1098
|
+
maxOutputLength: 2_000,
|
|
1099
|
+
});
|
|
1100
|
+
const available = !result.error && !result.timedOut && (result.exitCode === 0 || Boolean(result.stdout || result.stderr));
|
|
1101
|
+
appRunnerCommandAvailability.set(key, { available, expiresAt: now + APP_RUNNER_COMMAND_CACHE_TTL_MS });
|
|
1102
|
+
return available;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function appRunnerPackageScripts(pkg) {
|
|
1106
|
+
return pkg && typeof pkg.scripts === "object" && pkg.scripts ? pkg.scripts : {};
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
function preferredPackageScript(pkg) {
|
|
1110
|
+
const scripts = appRunnerPackageScripts(pkg);
|
|
1111
|
+
for (const script of ["dev", "start", "serve"]) {
|
|
1112
|
+
if (typeof scripts[script] === "string" && scripts[script].trim()) return script;
|
|
1113
|
+
}
|
|
1114
|
+
return "";
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function packageDependencyNames(pkg) {
|
|
1118
|
+
return new Set([
|
|
1119
|
+
...Object.keys(pkg?.dependencies || {}),
|
|
1120
|
+
...Object.keys(pkg?.devDependencies || {}),
|
|
1121
|
+
...Object.keys(pkg?.optionalDependencies || {}),
|
|
1122
|
+
]);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
function appRunnerId(...parts) {
|
|
1126
|
+
return parts
|
|
1127
|
+
.map((part) => String(part || "").trim())
|
|
1128
|
+
.filter(Boolean)
|
|
1129
|
+
.join(":")
|
|
1130
|
+
.replace(/[^a-z0-9_.:-]+/gi, "-")
|
|
1131
|
+
.replace(/^-+|-+$/g, "")
|
|
1132
|
+
.slice(0, 160);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
function shellQuote(value) {
|
|
1136
|
+
return `'${String(value ?? "").replace(/'/g, `'\\''`)}'`;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function appRunnerCandidate({ id, label, kind, command, args = [], projectFile = "", description = "", shortDisplayCommand = "", priority = 100, cwd = "", custom = false, configFile = "" }) {
|
|
1140
|
+
return {
|
|
1141
|
+
id,
|
|
1142
|
+
label,
|
|
1143
|
+
kind,
|
|
1144
|
+
command,
|
|
1145
|
+
args,
|
|
1146
|
+
displayCommand: formatCommandForDisplay(command, args),
|
|
1147
|
+
shortDisplayCommand,
|
|
1148
|
+
projectFile,
|
|
1149
|
+
description,
|
|
1150
|
+
priority,
|
|
1151
|
+
cwd,
|
|
1152
|
+
custom,
|
|
1153
|
+
configFile,
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
function addAppRunner(runners, runner) {
|
|
1158
|
+
if (!runner?.id || !runner.command) return;
|
|
1159
|
+
if (runners.some((item) => item.id === runner.id || item.displayCommand === runner.displayCommand)) return;
|
|
1160
|
+
runners.push(runner);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function appRunnerPathInside(root, target) {
|
|
1164
|
+
const relative = path.relative(root, target);
|
|
1165
|
+
return !relative || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function normalizeProjectRelativePath(value, { allowEmpty = false } = {}) {
|
|
1169
|
+
const raw = normalizeSuggestionPath(value).replace(/\0/g, "").trim();
|
|
1170
|
+
const withoutDot = raw.replace(/^\.\/+/, "").replace(/\/+$/g, "");
|
|
1171
|
+
if (!withoutDot) {
|
|
1172
|
+
if (allowEmpty) return "";
|
|
1173
|
+
throw makeHttpError(400, "Path to file is required");
|
|
1174
|
+
}
|
|
1175
|
+
if (path.isAbsolute(withoutDot) || /^[a-z]:\//i.test(withoutDot)) throw makeHttpError(400, "Path must be relative to the project root");
|
|
1176
|
+
const parts = withoutDot.split("/").filter(Boolean);
|
|
1177
|
+
if (parts.some((part) => part === "." || part === "..")) throw makeHttpError(400, "Path cannot contain . or .. segments");
|
|
1178
|
+
return parts.join("/").slice(0, 4096);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
function resolveProjectRelativePath(projectRoot, relativePath) {
|
|
1182
|
+
const target = path.resolve(projectRoot, relativePath || ".");
|
|
1183
|
+
if (!appRunnerPathInside(projectRoot, target)) throw makeHttpError(400, "Path must stay inside the project root");
|
|
1184
|
+
return target;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
async function findAppRunnerProjectRoot(cwd) {
|
|
1188
|
+
const start = await resolveCwd(cwd || options.cwd, options.cwd);
|
|
1189
|
+
let fallback = "";
|
|
1190
|
+
for (let current = start; current; current = path.dirname(current)) {
|
|
1191
|
+
if (await appRunnerFileExists(current, APP_RUNNER_CONFIG_FILE)) return current;
|
|
1192
|
+
if (!fallback && (await appRunnerFileExists(current, "package.json") || await appRunnerDirectoryExists(current, ".git"))) fallback = current;
|
|
1193
|
+
const parent = path.dirname(current);
|
|
1194
|
+
if (parent === current) break;
|
|
1195
|
+
}
|
|
1196
|
+
return fallback || start;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
function cleanCustomRunnerCommand(value) {
|
|
1200
|
+
const command = String(value || "./").trim().replace(/\s+/g, " ") || "./";
|
|
1201
|
+
if (command.includes("\0") || /[\r\n]/.test(command)) throw makeHttpError(400, "Command cannot contain newlines or null bytes");
|
|
1202
|
+
if (command.length > 512) throw makeHttpError(400, "Command is too long");
|
|
1203
|
+
return command === "." ? "./" : command;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
function customRunnerCommandParts(command) {
|
|
1207
|
+
const clean = cleanCustomRunnerCommand(command);
|
|
1208
|
+
return clean === "./" ? ["./"] : clean.split(" ").filter(Boolean);
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
function parseCustomRunnerArgs(value) {
|
|
1212
|
+
const rawItems = Array.isArray(value)
|
|
1213
|
+
? value
|
|
1214
|
+
: String(value || "").trim()
|
|
1215
|
+
? String(value || "").trim().split(/\s+/)
|
|
1216
|
+
: [];
|
|
1217
|
+
const args = [];
|
|
1218
|
+
for (const item of rawItems) {
|
|
1219
|
+
const text = String(item || "").trim();
|
|
1220
|
+
if (!text) continue;
|
|
1221
|
+
if (text.includes("\0") || /[\r\n]/.test(text)) throw makeHttpError(400, "Args cannot contain newlines or null bytes");
|
|
1222
|
+
if (text.length > 2048) throw makeHttpError(400, "One arg is too long");
|
|
1223
|
+
args.push(text);
|
|
1224
|
+
if (args.length > APP_RUNNER_CUSTOM_ARG_LIMIT) throw makeHttpError(400, `Too many args; limit is ${APP_RUNNER_CUSTOM_ARG_LIMIT}`);
|
|
1225
|
+
}
|
|
1226
|
+
return args;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
function publicCustomRunnerDefinition(runner) {
|
|
1230
|
+
const command = cleanCustomRunnerCommand(runner.command);
|
|
1231
|
+
const args = parseCustomRunnerArgs(runner.args);
|
|
1232
|
+
const filePath = normalizeProjectRelativePath(runner.path || runner.projectFile);
|
|
1233
|
+
const commandParts = customRunnerCommandParts(command);
|
|
1234
|
+
const effectiveCommand = command === "./" ? `./${filePath}` : commandParts[0];
|
|
1235
|
+
const effectiveArgs = command === "./" ? args : [...commandParts.slice(1), filePath, ...args];
|
|
1236
|
+
return {
|
|
1237
|
+
id: runner.id,
|
|
1238
|
+
label: runner.label,
|
|
1239
|
+
command,
|
|
1240
|
+
path: filePath,
|
|
1241
|
+
args,
|
|
1242
|
+
displayCommand: formatCommandForDisplay(effectiveCommand, effectiveArgs),
|
|
1243
|
+
};
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
function normalizeCustomRunnerDefinition(raw, projectRoot, { strict = false } = {}) {
|
|
1247
|
+
const filePath = normalizeProjectRelativePath(raw?.path || raw?.projectFile);
|
|
1248
|
+
const absolutePath = resolveProjectRelativePath(projectRoot, filePath);
|
|
1249
|
+
const command = cleanCustomRunnerCommand(raw?.command);
|
|
1250
|
+
const args = parseCustomRunnerArgs(raw?.args);
|
|
1251
|
+
const label = String(raw?.label || path.basename(filePath)).trim().slice(0, 120) || path.basename(filePath);
|
|
1252
|
+
const rawId = String(raw?.id || "").trim();
|
|
1253
|
+
const id = appRunnerId(rawId || label, command, filePath) || appRunnerId(command, filePath);
|
|
1254
|
+
if (!id) throw makeHttpError(400, "Custom runner id could not be generated");
|
|
1255
|
+
if (strict && !appRunnerPathInside(projectRoot, absolutePath)) throw makeHttpError(400, "Path must stay inside the project root");
|
|
1256
|
+
return { id, label, command, path: filePath, args };
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
async function readAppRunnerConfig(projectRoot) {
|
|
1260
|
+
const configPath = path.join(projectRoot, APP_RUNNER_CONFIG_FILE);
|
|
1261
|
+
const parsed = await readJsonFileIfExists(configPath);
|
|
1262
|
+
const source = parsed && typeof parsed === "object" ? parsed : {};
|
|
1263
|
+
const rawRunners = Array.isArray(source.runners) ? source.runners : [];
|
|
1264
|
+
const runners = [];
|
|
1265
|
+
for (const raw of rawRunners) {
|
|
1266
|
+
try {
|
|
1267
|
+
const runner = normalizeCustomRunnerDefinition(raw, projectRoot);
|
|
1268
|
+
if (!runners.some((item) => item.id === runner.id)) runners.push(runner);
|
|
1269
|
+
} catch (error) {
|
|
1270
|
+
console.warn(`skipping invalid custom app runner in ${configPath}: ${sanitizeError(error)}`);
|
|
1271
|
+
}
|
|
1272
|
+
if (runners.length >= APP_RUNNER_CUSTOM_LIMIT) break;
|
|
1273
|
+
}
|
|
1274
|
+
return { projectRoot, configPath, runners };
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
async function writeAppRunnerConfig(projectRoot, runners) {
|
|
1278
|
+
const configPath = path.join(projectRoot, APP_RUNNER_CONFIG_FILE);
|
|
1279
|
+
const normalized = [];
|
|
1280
|
+
for (const runner of runners) {
|
|
1281
|
+
normalized.push(normalizeCustomRunnerDefinition(runner, projectRoot, { strict: true }));
|
|
1282
|
+
if (normalized.length >= APP_RUNNER_CUSTOM_LIMIT) break;
|
|
1283
|
+
}
|
|
1284
|
+
const tmpFile = `${configPath}.${process.pid}.${Date.now()}.tmp`;
|
|
1285
|
+
await writeFile(tmpFile, `${JSON.stringify({ version: 1, runners: normalized }, null, 2)}\n`, { mode: 0o600 });
|
|
1286
|
+
await rename(tmpFile, configPath);
|
|
1287
|
+
return { projectRoot, configPath, runners: normalized };
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
async function customAppRunnerCandidate(projectRoot, configPath, runner) {
|
|
1291
|
+
const filePath = runner.path;
|
|
1292
|
+
const absolutePath = resolveProjectRelativePath(projectRoot, filePath);
|
|
1293
|
+
const stats = await fileStatsIfExists(absolutePath);
|
|
1294
|
+
if (!stats?.isFile()) return null;
|
|
1295
|
+
const command = cleanCustomRunnerCommand(runner.command);
|
|
1296
|
+
const args = parseCustomRunnerArgs(runner.args);
|
|
1297
|
+
const commandParts = customRunnerCommandParts(command);
|
|
1298
|
+
const effectiveCommand = command === "./" ? `./${filePath}` : commandParts[0];
|
|
1299
|
+
const effectiveArgs = command === "./" ? args : [...commandParts.slice(1), filePath, ...args];
|
|
1300
|
+
if (command !== "./" && !await appRunnerCommandAvailable(commandParts[0], projectRoot)) return null;
|
|
1301
|
+
return appRunnerCandidate({
|
|
1302
|
+
id: appRunnerId("custom", runner.id),
|
|
1303
|
+
label: runner.label || path.basename(filePath),
|
|
1304
|
+
kind: "custom",
|
|
1305
|
+
command: effectiveCommand,
|
|
1306
|
+
args: effectiveArgs,
|
|
1307
|
+
projectFile: filePath,
|
|
1308
|
+
description: `Custom project runner from ${APP_RUNNER_CONFIG_FILE}`,
|
|
1309
|
+
priority: 8,
|
|
1310
|
+
cwd: projectRoot,
|
|
1311
|
+
custom: true,
|
|
1312
|
+
configFile: configPath,
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
async function addCustomAppRunners(runners, cwd) {
|
|
1317
|
+
const projectRoot = await findAppRunnerProjectRoot(cwd);
|
|
1318
|
+
const config = await readAppRunnerConfig(projectRoot);
|
|
1319
|
+
for (const runner of config.runners) {
|
|
1320
|
+
const candidate = await customAppRunnerCandidate(projectRoot, config.configPath, runner);
|
|
1321
|
+
if (candidate) addAppRunner(runners, candidate);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
async function getCustomAppRunnerConfigData(tab) {
|
|
1326
|
+
const projectRoot = await findAppRunnerProjectRoot(tab?.cwd || options.cwd);
|
|
1327
|
+
const config = await readAppRunnerConfig(projectRoot);
|
|
1328
|
+
return {
|
|
1329
|
+
projectRoot,
|
|
1330
|
+
displayProjectRoot: displayPath(projectRoot),
|
|
1331
|
+
configFile: config.configPath,
|
|
1332
|
+
displayConfigFile: displayPath(config.configPath),
|
|
1333
|
+
relativeConfigFile: APP_RUNNER_CONFIG_FILE,
|
|
1334
|
+
runners: config.runners.map(publicCustomRunnerDefinition),
|
|
1335
|
+
};
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
async function saveCustomAppRunner(tab, rawRunner) {
|
|
1339
|
+
const projectRoot = await findAppRunnerProjectRoot(tab?.cwd || options.cwd);
|
|
1340
|
+
const config = await readAppRunnerConfig(projectRoot);
|
|
1341
|
+
const normalized = normalizeCustomRunnerDefinition(rawRunner, projectRoot, { strict: true });
|
|
1342
|
+
const stats = await fileStatsIfExists(resolveProjectRelativePath(projectRoot, normalized.path));
|
|
1343
|
+
if (!stats?.isFile()) throw makeHttpError(400, `Path to file does not exist: ${normalized.path}`);
|
|
1344
|
+
const commandParts = customRunnerCommandParts(normalized.command);
|
|
1345
|
+
if (normalized.command !== "./" && !await appRunnerCommandAvailable(commandParts[0], projectRoot)) throw makeHttpError(400, `Command is not available: ${commandParts[0]}`);
|
|
1346
|
+
const runners = config.runners.filter((runner) => runner.id !== normalized.id);
|
|
1347
|
+
if (runners.length >= APP_RUNNER_CUSTOM_LIMIT) throw makeHttpError(400, `Custom runner limit reached (${APP_RUNNER_CUSTOM_LIMIT})`);
|
|
1348
|
+
runners.push(normalized);
|
|
1349
|
+
await writeAppRunnerConfig(projectRoot, runners);
|
|
1350
|
+
return getAppRunnerData(tab);
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
async function deleteCustomAppRunner(tab, runnerId) {
|
|
1354
|
+
const id = appRunnerId(String(runnerId || "").replace(/^custom:/, ""));
|
|
1355
|
+
if (!id) throw makeHttpError(400, "Custom runner id is required");
|
|
1356
|
+
const projectRoot = await findAppRunnerProjectRoot(tab?.cwd || options.cwd);
|
|
1357
|
+
const config = await readAppRunnerConfig(projectRoot);
|
|
1358
|
+
const runners = config.runners.filter((runner) => runner.id !== id);
|
|
1359
|
+
if (runners.length === config.runners.length) throw makeHttpError(404, "Custom runner not found");
|
|
1360
|
+
await writeAppRunnerConfig(projectRoot, runners);
|
|
1361
|
+
return getAppRunnerData(tab);
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
async function getAppRunnerFileBrowserData(tab, rawPath) {
|
|
1365
|
+
const projectRoot = await findAppRunnerProjectRoot(tab?.cwd || options.cwd);
|
|
1366
|
+
const relativeDir = normalizeProjectRelativePath(rawPath || "", { allowEmpty: true });
|
|
1367
|
+
const absoluteDir = resolveProjectRelativePath(projectRoot, relativeDir || ".");
|
|
1368
|
+
const stats = await fileStatsIfExists(absoluteDir);
|
|
1369
|
+
if (!stats?.isDirectory()) throw makeHttpError(400, `Not a directory inside project root: ${relativeDir || "."}`);
|
|
1370
|
+
let entries;
|
|
1371
|
+
try {
|
|
1372
|
+
entries = await readdir(absoluteDir, { withFileTypes: true });
|
|
1373
|
+
} catch (error) {
|
|
1374
|
+
throw makeHttpError(error?.code === "EACCES" ? 403 : 400, `Cannot read directory ${relativeDir || "."}: ${sanitizeError(error)}`);
|
|
1375
|
+
}
|
|
1376
|
+
const sorted = entries.sort((a, b) => {
|
|
1377
|
+
if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
|
|
1378
|
+
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" });
|
|
1379
|
+
});
|
|
1380
|
+
const directories = [];
|
|
1381
|
+
const files = [];
|
|
1382
|
+
for (const entry of sorted) {
|
|
1383
|
+
if (entry.name === ".git") continue;
|
|
1384
|
+
const entryRelativePath = normalizeSuggestionPath(relativeDir ? `${relativeDir}/${entry.name}` : entry.name);
|
|
1385
|
+
if (entry.isDirectory()) directories.push({ name: entry.name, path: entryRelativePath, hidden: entry.name.startsWith(".") });
|
|
1386
|
+
else if (entry.isFile()) files.push({ name: entry.name, path: entryRelativePath, hidden: entry.name.startsWith(".") });
|
|
1387
|
+
if (directories.length + files.length >= APP_RUNNER_FILE_PICKER_LIMIT) break;
|
|
1388
|
+
}
|
|
1389
|
+
const parent = relativeDir ? normalizeSuggestionPath(path.posix.dirname(relativeDir)) : "";
|
|
1390
|
+
return {
|
|
1391
|
+
projectRoot,
|
|
1392
|
+
displayProjectRoot: displayPath(projectRoot),
|
|
1393
|
+
relativeDir,
|
|
1394
|
+
displayRelativeDir: relativeDir || ".",
|
|
1395
|
+
parent: relativeDir && parent !== "." ? parent : relativeDir ? "" : null,
|
|
1396
|
+
directories,
|
|
1397
|
+
files,
|
|
1398
|
+
truncated: sorted.length > directories.length + files.length,
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
function packageManagerArgs(manager, script) {
|
|
1403
|
+
if (manager === "bun") return ["run", script];
|
|
1404
|
+
if (manager === "yarn") return script === "start" ? ["start"] : [script];
|
|
1405
|
+
return script === "start" ? ["start"] : ["run", script];
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
async function addPackageManagerRunners(runners, cwd, pkg) {
|
|
1409
|
+
const script = preferredPackageScript(pkg);
|
|
1410
|
+
if (!script) return;
|
|
1411
|
+
const packageManager = String(pkg?.packageManager || "").toLowerCase();
|
|
1412
|
+
const [hasBunLock, hasPnpmLock, hasYarnLock, hasPackageLock] = await Promise.all([
|
|
1413
|
+
appRunnerFileExists(cwd, "bun.lock").then((exists) => exists || appRunnerFileExists(cwd, "bun.lockb")),
|
|
1414
|
+
appRunnerFileExists(cwd, "pnpm-lock.yaml"),
|
|
1415
|
+
appRunnerFileExists(cwd, "yarn.lock"),
|
|
1416
|
+
appRunnerFileExists(cwd, "package-lock.json"),
|
|
1417
|
+
]);
|
|
1418
|
+
const managers = [
|
|
1419
|
+
{ id: "bun", command: "bun", label: "Bun", hint: hasBunLock || packageManager.startsWith("bun@"), priority: hasBunLock || packageManager.startsWith("bun@") ? 20 : 54 },
|
|
1420
|
+
{ id: "pnpm", command: "pnpm", label: "pnpm", hint: hasPnpmLock || packageManager.startsWith("pnpm@"), priority: hasPnpmLock || packageManager.startsWith("pnpm@") ? 24 : 58 },
|
|
1421
|
+
{ id: "npm", command: "npm", label: "npm", hint: hasPackageLock || packageManager.startsWith("npm@") || !packageManager, priority: hasPackageLock || packageManager.startsWith("npm@") || !packageManager ? 28 : 62 },
|
|
1422
|
+
{ id: "yarn", command: "yarn", label: "Yarn", hint: hasYarnLock || packageManager.startsWith("yarn@"), priority: hasYarnLock || packageManager.startsWith("yarn@") ? 34 : 72 },
|
|
1423
|
+
];
|
|
1424
|
+
|
|
1425
|
+
for (const manager of managers) {
|
|
1426
|
+
if (!await appRunnerCommandAvailable(manager.command, cwd)) continue;
|
|
1427
|
+
const args = packageManagerArgs(manager.id, script);
|
|
1428
|
+
addAppRunner(runners, appRunnerCandidate({
|
|
1429
|
+
id: appRunnerId("pkg", manager.id, script),
|
|
1430
|
+
label: `${manager.label} ${script}`,
|
|
1431
|
+
kind: manager.id === "bun" ? "bun" : "node",
|
|
1432
|
+
command: manager.command,
|
|
1433
|
+
args,
|
|
1434
|
+
projectFile: "package.json",
|
|
1435
|
+
description: `${manager.label} package script: ${script}`,
|
|
1436
|
+
priority: manager.priority + (manager.hint ? 0 : 12),
|
|
1437
|
+
}));
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
async function addNpxFrameworkRunners(runners, cwd, pkg) {
|
|
1442
|
+
const dependencyNames = packageDependencyNames(pkg);
|
|
1443
|
+
if (!dependencyNames.size || !await appRunnerCommandAvailable("npx", cwd)) return;
|
|
1444
|
+
const frameworks = [
|
|
1445
|
+
{ dep: "vite", label: "npx vite", args: ["--no-install", "vite"], priority: 78 },
|
|
1446
|
+
{ dep: "next", label: "npx next dev", args: ["--no-install", "next", "dev"], priority: 80 },
|
|
1447
|
+
{ dep: "astro", label: "npx astro dev", args: ["--no-install", "astro", "dev"], priority: 82 },
|
|
1448
|
+
{ dep: "@storybook/react", label: "npx storybook dev", args: ["--no-install", "storybook", "dev"], priority: 86 },
|
|
1449
|
+
{ dep: "storybook", label: "npx storybook dev", args: ["--no-install", "storybook", "dev"], priority: 86 },
|
|
1450
|
+
];
|
|
1451
|
+
for (const framework of frameworks) {
|
|
1452
|
+
if (!dependencyNames.has(framework.dep)) continue;
|
|
1453
|
+
addAppRunner(runners, appRunnerCandidate({
|
|
1454
|
+
id: appRunnerId("npx", framework.dep),
|
|
1455
|
+
label: framework.label,
|
|
1456
|
+
kind: "node",
|
|
1457
|
+
command: "npx",
|
|
1458
|
+
args: framework.args,
|
|
1459
|
+
projectFile: "package.json",
|
|
1460
|
+
description: `Detected ${framework.dep} dependency`,
|
|
1461
|
+
priority: framework.priority,
|
|
1462
|
+
}));
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
async function addNodeEntrypointRunner(runners, cwd, hasPackageJson) {
|
|
1467
|
+
if (hasPackageJson) return;
|
|
1468
|
+
const entry = await firstExistingRunnerFile(cwd, APP_RUNNER_JS_ENTRIES);
|
|
1469
|
+
if (!entry || !await appRunnerCommandAvailable("node", cwd)) return;
|
|
1470
|
+
addAppRunner(runners, appRunnerCandidate({
|
|
1471
|
+
id: appRunnerId("node", entry),
|
|
1472
|
+
label: `node ${entry}`,
|
|
1473
|
+
kind: "node",
|
|
1474
|
+
command: "node",
|
|
1475
|
+
args: [entry],
|
|
1476
|
+
projectFile: entry,
|
|
1477
|
+
description: "Detected JavaScript entry file",
|
|
1478
|
+
priority: 88,
|
|
1479
|
+
}));
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
async function addPythonRunners(runners, cwd) {
|
|
1483
|
+
const entry = await firstExistingRunnerFile(cwd, APP_RUNNER_PYTHON_ENTRIES);
|
|
1484
|
+
if (!entry) return;
|
|
1485
|
+
if (await appRunnerCommandAvailable("uv", cwd)) {
|
|
1486
|
+
addAppRunner(runners, appRunnerCandidate({
|
|
1487
|
+
id: appRunnerId("python", "uv", entry),
|
|
1488
|
+
label: `uv run ${entry}`,
|
|
1489
|
+
kind: "python",
|
|
1490
|
+
command: "uv",
|
|
1491
|
+
args: ["run", entry],
|
|
1492
|
+
projectFile: entry,
|
|
1493
|
+
description: "Detected Python entry file",
|
|
1494
|
+
priority: 36,
|
|
1495
|
+
}));
|
|
1496
|
+
}
|
|
1497
|
+
const pythonCommand = await appRunnerCommandAvailable("python3", cwd) ? "python3" : await appRunnerCommandAvailable("python", cwd) ? "python" : "";
|
|
1498
|
+
if (pythonCommand) {
|
|
1499
|
+
addAppRunner(runners, appRunnerCandidate({
|
|
1500
|
+
id: appRunnerId("python", pythonCommand, entry),
|
|
1501
|
+
label: `${pythonCommand} ${entry}`,
|
|
1502
|
+
kind: "python",
|
|
1503
|
+
command: pythonCommand,
|
|
1504
|
+
args: [entry],
|
|
1505
|
+
projectFile: entry,
|
|
1506
|
+
description: "Detected Python entry file",
|
|
1507
|
+
priority: 68,
|
|
1508
|
+
}));
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
async function addRustRunner(runners, cwd) {
|
|
1513
|
+
if (!await appRunnerFileExists(cwd, "Cargo.toml") || !await appRunnerCommandAvailable("cargo", cwd)) return;
|
|
1514
|
+
addAppRunner(runners, appRunnerCandidate({
|
|
1515
|
+
id: "rust:cargo-run",
|
|
1516
|
+
label: "cargo run",
|
|
1517
|
+
kind: "rust",
|
|
1518
|
+
command: "cargo",
|
|
1519
|
+
args: ["run"],
|
|
1520
|
+
projectFile: "Cargo.toml",
|
|
1521
|
+
description: "Detected Rust Cargo project",
|
|
1522
|
+
priority: 18,
|
|
1523
|
+
}));
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
async function goRunTarget(cwd) {
|
|
1527
|
+
if (await appRunnerFileExists(cwd, "main.go")) return ".";
|
|
1528
|
+
if (await appRunnerDirectoryExists(cwd, "cmd")) {
|
|
1529
|
+
const entries = await readdir(path.join(cwd, "cmd"), { withFileTypes: true }).catch(() => []);
|
|
1530
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
1531
|
+
if (!entry.isDirectory()) continue;
|
|
1532
|
+
if (await appRunnerFileExists(cwd, path.join("cmd", entry.name, "main.go"))) return `./cmd/${entry.name}`;
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
return await appRunnerFileExists(cwd, "go.mod") ? "." : "";
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
async function addGoRunner(runners, cwd) {
|
|
1539
|
+
const target = await goRunTarget(cwd);
|
|
1540
|
+
if (!target || !await appRunnerCommandAvailable("go", cwd)) return;
|
|
1541
|
+
addAppRunner(runners, appRunnerCandidate({
|
|
1542
|
+
id: appRunnerId("go", target),
|
|
1543
|
+
label: `go run ${target}`,
|
|
1544
|
+
kind: "go",
|
|
1545
|
+
command: "go",
|
|
1546
|
+
args: ["run", target],
|
|
1547
|
+
projectFile: await appRunnerFileExists(cwd, "go.mod") ? "go.mod" : target,
|
|
1548
|
+
description: "Detected Go/Golang app entry",
|
|
1549
|
+
priority: 46,
|
|
1550
|
+
}));
|
|
1551
|
+
}
|
|
1552
|
+
|
|
1553
|
+
function buildZigHasRunStep(text) {
|
|
1554
|
+
return /\.step\(\s*["']run["']/.test(String(text || ""));
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
async function addZigRunner(runners, cwd) {
|
|
1558
|
+
if (!await appRunnerCommandAvailable("zig", cwd)) return;
|
|
1559
|
+
const buildZig = await appRunnerTextIfExists(cwd, "build.zig");
|
|
1560
|
+
if (buildZig && buildZigHasRunStep(buildZig)) {
|
|
1561
|
+
addAppRunner(runners, appRunnerCandidate({
|
|
1562
|
+
id: "zig:build-run",
|
|
1563
|
+
label: "zig build run",
|
|
1564
|
+
kind: "zig",
|
|
1565
|
+
command: "zig",
|
|
1566
|
+
args: ["build", "run"],
|
|
1567
|
+
projectFile: "build.zig",
|
|
1568
|
+
description: "Detected Zig build.zig run step",
|
|
1569
|
+
priority: 44,
|
|
1570
|
+
}));
|
|
1571
|
+
}
|
|
1572
|
+
const entry = await firstExistingRunnerFile(cwd, APP_RUNNER_ZIG_ENTRIES);
|
|
1573
|
+
if (entry) {
|
|
1574
|
+
addAppRunner(runners, appRunnerCandidate({
|
|
1575
|
+
id: appRunnerId("zig", entry),
|
|
1576
|
+
label: `zig run ${entry}`,
|
|
1577
|
+
kind: "zig",
|
|
1578
|
+
command: "zig",
|
|
1579
|
+
args: ["run", entry],
|
|
1580
|
+
projectFile: entry,
|
|
1581
|
+
description: "Detected Zig app entry file",
|
|
1582
|
+
priority: 66,
|
|
1583
|
+
}));
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
function firstCmakeExecutableTarget(text) {
|
|
1588
|
+
const match = String(text || "").match(/add_executable\s*\(\s*([A-Za-z0-9_.+-]+)/i);
|
|
1589
|
+
return match ? match[1] : "";
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
async function addCompiledLanguageRunner(runners, cwd, { language, kind, compiler, entry, outputName, priority }) {
|
|
1593
|
+
if (!entry || !await appRunnerCommandAvailable("sh", cwd) || !await appRunnerCommandAvailable(compiler, cwd)) return;
|
|
1594
|
+
const output = `.pi-webui-runner/${outputName}`;
|
|
1595
|
+
const compileAndRun = `mkdir -p .pi-webui-runner && ${compiler} ${shellQuote(entry)} -o ${shellQuote(output)} && ${shellQuote(`./${output}`)}`;
|
|
1596
|
+
addAppRunner(runners, appRunnerCandidate({
|
|
1597
|
+
id: appRunnerId(kind, entry),
|
|
1598
|
+
label: `${compiler} ${entry}`,
|
|
1599
|
+
kind,
|
|
1600
|
+
command: "sh",
|
|
1601
|
+
args: ["-lc", compileAndRun],
|
|
1602
|
+
projectFile: entry,
|
|
1603
|
+
description: `Detected ${language} app entry file`,
|
|
1604
|
+
priority,
|
|
1605
|
+
}));
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
async function addCppRunners(runners, cwd) {
|
|
1609
|
+
const cmakeText = await appRunnerTextIfExists(cwd, "CMakeLists.txt");
|
|
1610
|
+
const cmakeTarget = firstCmakeExecutableTarget(cmakeText);
|
|
1611
|
+
const hasShell = await appRunnerCommandAvailable("sh", cwd);
|
|
1612
|
+
if (cmakeTarget && hasShell && await appRunnerCommandAvailable("cmake", cwd)) {
|
|
1613
|
+
const configureBuildRun = `cmake -S . -B build && cmake --build build --target ${shellQuote(cmakeTarget)} && ${shellQuote(`./build/${cmakeTarget}`)}`;
|
|
1614
|
+
addAppRunner(runners, appRunnerCandidate({
|
|
1615
|
+
id: appRunnerId("cmake", cmakeTarget),
|
|
1616
|
+
label: `cmake run ${cmakeTarget}`,
|
|
1617
|
+
kind: "cpp",
|
|
1618
|
+
command: "sh",
|
|
1619
|
+
args: ["-lc", configureBuildRun],
|
|
1620
|
+
projectFile: "CMakeLists.txt",
|
|
1621
|
+
description: "Detected C/C++ CMake executable target",
|
|
1622
|
+
priority: 42,
|
|
1623
|
+
}));
|
|
1624
|
+
return;
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
await Promise.all([
|
|
1628
|
+
addCompiledLanguageRunner(runners, cwd, {
|
|
1629
|
+
language: "C",
|
|
1630
|
+
kind: "c",
|
|
1631
|
+
compiler: "cc",
|
|
1632
|
+
entry: await firstExistingRunnerFile(cwd, APP_RUNNER_C_ENTRIES),
|
|
1633
|
+
outputName: "main-c",
|
|
1634
|
+
priority: 64,
|
|
1635
|
+
}),
|
|
1636
|
+
addCompiledLanguageRunner(runners, cwd, {
|
|
1637
|
+
language: "C++",
|
|
1638
|
+
kind: "cpp",
|
|
1639
|
+
compiler: "c++",
|
|
1640
|
+
entry: await firstExistingRunnerFile(cwd, APP_RUNNER_CPP_ENTRIES),
|
|
1641
|
+
outputName: "main-cpp",
|
|
1642
|
+
priority: 65,
|
|
1643
|
+
}),
|
|
1644
|
+
]);
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
async function dockerComposePluginAvailable(cwd) {
|
|
1648
|
+
const result = await runCommand("docker", ["compose", "version"], {
|
|
1649
|
+
cwd,
|
|
1650
|
+
timeoutMs: APP_RUNNER_DETECTION_TIMEOUT_MS,
|
|
1651
|
+
maxOutputLength: 2_000,
|
|
1652
|
+
});
|
|
1653
|
+
return !result.error && !result.timedOut && result.exitCode === 0;
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
async function addDockerComposeRunner(runners, cwd) {
|
|
1657
|
+
const composeFile = await firstExistingRunnerFile(cwd, APP_RUNNER_DOCKER_COMPOSE_FILES);
|
|
1658
|
+
if (!composeFile) return;
|
|
1659
|
+
if (await appRunnerCommandAvailable("docker", cwd) && await dockerComposePluginAvailable(cwd)) {
|
|
1660
|
+
addAppRunner(runners, appRunnerCandidate({
|
|
1661
|
+
id: appRunnerId("docker-compose", composeFile),
|
|
1662
|
+
label: "docker compose up",
|
|
1663
|
+
kind: "docker",
|
|
1664
|
+
command: "docker",
|
|
1665
|
+
args: ["compose", "-f", composeFile, "up"],
|
|
1666
|
+
projectFile: composeFile,
|
|
1667
|
+
description: "Detected Docker Compose file",
|
|
1668
|
+
priority: 82,
|
|
1669
|
+
}));
|
|
1670
|
+
}
|
|
1671
|
+
if (await appRunnerCommandAvailable("docker-compose", cwd)) {
|
|
1672
|
+
addAppRunner(runners, appRunnerCandidate({
|
|
1673
|
+
id: appRunnerId("docker-compose-standalone", composeFile),
|
|
1674
|
+
label: "docker-compose up",
|
|
1675
|
+
kind: "docker",
|
|
1676
|
+
command: "docker-compose",
|
|
1677
|
+
args: ["-f", composeFile, "up"],
|
|
1678
|
+
projectFile: composeFile,
|
|
1679
|
+
description: "Detected Docker Compose file",
|
|
1680
|
+
priority: 84,
|
|
1681
|
+
}));
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
function shellFromShebang(text) {
|
|
1686
|
+
const firstLine = String(text || "").split(/\r?\n/, 1)[0] || "";
|
|
1687
|
+
if (!firstLine.startsWith("#!")) return "";
|
|
1688
|
+
if (/\bfish\b/.test(firstLine)) return "fish";
|
|
1689
|
+
if (/\bzsh\b/.test(firstLine)) return "zsh";
|
|
1690
|
+
if (/\bbash\b/.test(firstLine)) return "bash";
|
|
1691
|
+
if (/\bsh\b/.test(firstLine)) return "bash";
|
|
1692
|
+
return "";
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
function shellScriptPriority(relativePath, shell) {
|
|
1696
|
+
const base = path.basename(relativePath).replace(/\.(?:sh|bash|zsh|fish)$/i, "").toLowerCase();
|
|
1697
|
+
const directory = path.dirname(relativePath).replace(/\\/g, "/");
|
|
1698
|
+
const nameRank = ["dev", "start", "run", "serve", "server", "app", "main"].indexOf(base);
|
|
1699
|
+
const dirRank = APP_RUNNER_SHELL_SCRIPT_DIRS.indexOf(directory === "." ? "" : directory);
|
|
1700
|
+
const shellRank = shell === "bash" ? 0 : shell === "zsh" ? 1 : shell === "fish" ? 2 : 3;
|
|
1701
|
+
return 70 + (nameRank === -1 ? 18 : nameRank) + (dirRank === -1 ? 8 : dirRank) + shellRank / 10;
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
async function shellScriptRunnerForFile(cwd, relativePath) {
|
|
1705
|
+
const extensionShell = APP_RUNNER_SHELL_EXTENSIONS.get(path.extname(relativePath).toLowerCase()) || "";
|
|
1706
|
+
let shell = extensionShell;
|
|
1707
|
+
if (!shell) shell = shellFromShebang(await appRunnerTextIfExists(cwd, relativePath, 256));
|
|
1708
|
+
if (!shell || !await appRunnerCommandAvailable(shell, cwd)) return null;
|
|
1709
|
+
const fileName = path.basename(relativePath);
|
|
1710
|
+
const directory = path.dirname(relativePath);
|
|
1711
|
+
return appRunnerCandidate({
|
|
1712
|
+
id: appRunnerId("shell", shell, relativePath),
|
|
1713
|
+
label: fileName,
|
|
1714
|
+
kind: "shell",
|
|
1715
|
+
command: shell,
|
|
1716
|
+
args: [relativePath],
|
|
1717
|
+
projectFile: relativePath,
|
|
1718
|
+
description: `Detected ${shell} shell script${directory && directory !== "." ? ` in ${directory}` : ""}`,
|
|
1719
|
+
priority: shellScriptPriority(relativePath, shell),
|
|
1720
|
+
});
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
async function addShellScriptRunners(runners, cwd) {
|
|
1724
|
+
const candidates = [];
|
|
1725
|
+
for (const directory of APP_RUNNER_SHELL_SCRIPT_DIRS) {
|
|
1726
|
+
const absoluteDirectory = path.join(cwd, directory || ".");
|
|
1727
|
+
const stats = await fileStatsIfExists(absoluteDirectory);
|
|
1728
|
+
if (!stats?.isDirectory()) continue;
|
|
1729
|
+
const entries = await readdir(absoluteDirectory, { withFileTypes: true }).catch(() => []);
|
|
1730
|
+
for (const entry of entries) {
|
|
1731
|
+
if (!entry.isFile()) continue;
|
|
1732
|
+
const relativePath = directory ? `${directory}/${entry.name}` : entry.name;
|
|
1733
|
+
const extension = path.extname(entry.name).toLowerCase();
|
|
1734
|
+
const explicitShellExtension = APP_RUNNER_SHELL_EXTENSIONS.has(extension);
|
|
1735
|
+
if (!explicitShellExtension && entry.name.includes(".")) continue;
|
|
1736
|
+
candidates.push(relativePath);
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
for (const relativePath of candidates.slice(0, APP_RUNNER_SHELL_SCRIPT_LIMIT * 2)) {
|
|
1741
|
+
const runner = await shellScriptRunnerForFile(cwd, relativePath);
|
|
1742
|
+
if (runner) addAppRunner(runners, runner);
|
|
1743
|
+
if (runners.filter((item) => item.kind === "shell").length >= APP_RUNNER_SHELL_SCRIPT_LIMIT) break;
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
function firstTaskFromText(text, names) {
|
|
1748
|
+
for (const name of names) {
|
|
1749
|
+
const pattern = new RegExp(`^[\\s\"']*${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\"']*[:=]`, "m");
|
|
1750
|
+
if (pattern.test(text)) return name;
|
|
1751
|
+
}
|
|
1752
|
+
return "";
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
async function addDenoRunner(runners, cwd) {
|
|
1756
|
+
const hasDenoConfig = await appRunnerFileExists(cwd, "deno.json") || await appRunnerFileExists(cwd, "deno.jsonc");
|
|
1757
|
+
if (!hasDenoConfig || !await appRunnerCommandAvailable("deno", cwd)) return;
|
|
1758
|
+
const configText = (await appRunnerTextIfExists(cwd, "deno.json")) || (await appRunnerTextIfExists(cwd, "deno.jsonc"));
|
|
1759
|
+
const task = firstTaskFromText(configText, ["dev", "start", "serve"]);
|
|
1760
|
+
if (task) {
|
|
1761
|
+
addAppRunner(runners, appRunnerCandidate({
|
|
1762
|
+
id: appRunnerId("deno", task),
|
|
1763
|
+
label: `deno task ${task}`,
|
|
1764
|
+
kind: "deno",
|
|
1765
|
+
command: "deno",
|
|
1766
|
+
args: ["task", task],
|
|
1767
|
+
projectFile: "deno.json",
|
|
1768
|
+
description: "Detected Deno task",
|
|
1769
|
+
priority: 52,
|
|
1770
|
+
}));
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
async function addTaskFileRunners(runners, cwd) {
|
|
1775
|
+
const [justText, makeText] = await Promise.all([
|
|
1776
|
+
appRunnerTextIfExists(cwd, "justfile").then((text) => text || appRunnerTextIfExists(cwd, "Justfile")),
|
|
1777
|
+
appRunnerTextIfExists(cwd, "Makefile").then((text) => text || appRunnerTextIfExists(cwd, "makefile")),
|
|
1778
|
+
]);
|
|
1779
|
+
const justTarget = firstTaskFromText(justText, ["dev", "run", "start"]);
|
|
1780
|
+
if (justTarget && await appRunnerCommandAvailable("just", cwd)) {
|
|
1781
|
+
addAppRunner(runners, appRunnerCandidate({
|
|
1782
|
+
id: appRunnerId("just", justTarget),
|
|
1783
|
+
label: `just ${justTarget}`,
|
|
1784
|
+
kind: "task",
|
|
1785
|
+
command: "just",
|
|
1786
|
+
args: [justTarget],
|
|
1787
|
+
projectFile: "Justfile",
|
|
1788
|
+
description: "Detected just recipe",
|
|
1789
|
+
priority: 74,
|
|
1790
|
+
}));
|
|
1791
|
+
}
|
|
1792
|
+
const makeTarget = firstTaskFromText(makeText, ["dev", "run", "start"]);
|
|
1793
|
+
if (makeTarget && await appRunnerCommandAvailable("make", cwd)) {
|
|
1794
|
+
addAppRunner(runners, appRunnerCandidate({
|
|
1795
|
+
id: appRunnerId("make", makeTarget),
|
|
1796
|
+
label: `make ${makeTarget}`,
|
|
1797
|
+
kind: "task",
|
|
1798
|
+
command: "make",
|
|
1799
|
+
args: [makeTarget],
|
|
1800
|
+
projectFile: "Makefile",
|
|
1801
|
+
description: "Detected Make target",
|
|
1802
|
+
priority: 76,
|
|
1803
|
+
}));
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
function publicAppRunner(runner) {
|
|
1808
|
+
if (!runner) return null;
|
|
1809
|
+
const { priority: _priority, ...publicRunner } = runner;
|
|
1810
|
+
return publicRunner;
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
async function detectAppRunners(tab) {
|
|
1814
|
+
const cwd = tab?.cwd || options.cwd;
|
|
1815
|
+
const runners = [];
|
|
1816
|
+
const pkg = await readJsonFileIfExists(path.join(cwd, "package.json"));
|
|
1817
|
+
await Promise.all([
|
|
1818
|
+
addCustomAppRunners(runners, cwd),
|
|
1819
|
+
addRustRunner(runners, cwd),
|
|
1820
|
+
pkg ? addPackageManagerRunners(runners, cwd, pkg) : Promise.resolve(),
|
|
1821
|
+
pkg ? addNpxFrameworkRunners(runners, cwd, pkg) : Promise.resolve(),
|
|
1822
|
+
addPythonRunners(runners, cwd),
|
|
1823
|
+
addGoRunner(runners, cwd),
|
|
1824
|
+
addZigRunner(runners, cwd),
|
|
1825
|
+
addCppRunners(runners, cwd),
|
|
1826
|
+
addDockerComposeRunner(runners, cwd),
|
|
1827
|
+
addShellScriptRunners(runners, cwd),
|
|
1828
|
+
addDenoRunner(runners, cwd),
|
|
1829
|
+
addTaskFileRunners(runners, cwd),
|
|
1830
|
+
addNodeEntrypointRunner(runners, cwd, !!pkg),
|
|
1831
|
+
]);
|
|
1832
|
+
return runners
|
|
1833
|
+
.sort((a, b) => a.priority - b.priority || a.label.localeCompare(b.label))
|
|
1834
|
+
.map(publicAppRunner);
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
function publicAppRunnerState(run) {
|
|
1838
|
+
if (!run) return null;
|
|
1839
|
+
return {
|
|
1840
|
+
id: run.id,
|
|
1841
|
+
runnerId: run.runnerId,
|
|
1842
|
+
kind: run.kind,
|
|
1843
|
+
label: run.label,
|
|
1844
|
+
command: run.command,
|
|
1845
|
+
args: run.args,
|
|
1846
|
+
displayCommand: run.displayCommand,
|
|
1847
|
+
cwd: run.cwd,
|
|
1848
|
+
pid: run.pid,
|
|
1849
|
+
status: run.status,
|
|
1850
|
+
startedAt: run.startedAt,
|
|
1851
|
+
endedAt: run.endedAt,
|
|
1852
|
+
exitCode: run.exitCode,
|
|
1853
|
+
signal: run.signal,
|
|
1854
|
+
stopping: run.stopping === true,
|
|
1855
|
+
truncated: run.truncated === true,
|
|
1856
|
+
lineCount: run.lineCount || run.lines?.length || 0,
|
|
1857
|
+
lines: Array.isArray(run.lines) ? [...run.lines] : [],
|
|
1858
|
+
};
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
async function getAppRunnerData(tab) {
|
|
1862
|
+
const [runners, customRunnerConfig] = await Promise.all([
|
|
1863
|
+
detectAppRunners(tab),
|
|
1864
|
+
getCustomAppRunnerConfigData(tab),
|
|
1865
|
+
]);
|
|
1866
|
+
return {
|
|
1867
|
+
cwd: tab.cwd,
|
|
1868
|
+
runners,
|
|
1869
|
+
customRunnerConfig,
|
|
1870
|
+
activeRun: publicAppRunnerState(tab.appRunner),
|
|
1871
|
+
};
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
function appendAppRunnerLine(run, line) {
|
|
1875
|
+
if (!run) return;
|
|
1876
|
+
const text = String(line ?? "");
|
|
1877
|
+
run.lines.push(text);
|
|
1878
|
+
run.lineCount = (run.lineCount || 0) + 1;
|
|
1879
|
+
run.outputChars = (run.outputChars || 0) + text.length + 1;
|
|
1880
|
+
while (run.lines.length > APP_RUNNER_OUTPUT_LINE_LIMIT || run.outputChars > APP_RUNNER_OUTPUT_MAX_CHARS) {
|
|
1881
|
+
const removed = run.lines.shift();
|
|
1882
|
+
run.outputChars -= String(removed || "").length + 1;
|
|
1883
|
+
run.truncated = true;
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
function appendAppRunnerChunk(tab, run, chunk, streamName) {
|
|
1888
|
+
if (!run || run.status !== "running") return;
|
|
1889
|
+
const key = streamName === "stderr" ? "stderrRemainder" : "stdoutRemainder";
|
|
1890
|
+
const normalized = `${run[key] || ""}${String(chunk).replace(/\r\n?/g, "\n")}`;
|
|
1891
|
+
const lines = normalized.split("\n");
|
|
1892
|
+
run[key] = lines.pop() || "";
|
|
1893
|
+
for (const line of lines) appendAppRunnerLine(run, line);
|
|
1894
|
+
scheduleAppRunnerBroadcast(tab);
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
function flushAppRunnerRemainders(run) {
|
|
1898
|
+
for (const key of ["stdoutRemainder", "stderrRemainder"]) {
|
|
1899
|
+
if (run?.[key]) {
|
|
1900
|
+
appendAppRunnerLine(run, run[key]);
|
|
1901
|
+
run[key] = "";
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
function appRunnerStatusLabel(run) {
|
|
1907
|
+
if (run?.stopping && run.status === "running") return "stopping";
|
|
1908
|
+
if (run?.status === "done") return "exit 0";
|
|
1909
|
+
if (run?.status === "failed") return run.signal ? `signal ${run.signal}` : `exit ${run.exitCode ?? "?"}`;
|
|
1910
|
+
if (run?.status === "error") return "error";
|
|
1911
|
+
return run?.status || "running";
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
function broadcastAppRunnerState(tab) {
|
|
1915
|
+
broadcastTabEvent(tab, {
|
|
1916
|
+
type: "webui_app_runner_update",
|
|
1917
|
+
tabId: tab.id,
|
|
1918
|
+
tabTitle: tab.title,
|
|
1919
|
+
cwd: tab.cwd,
|
|
1920
|
+
command: tab.appRunner?.displayCommand,
|
|
1921
|
+
activeRun: publicAppRunnerState(tab.appRunner),
|
|
1922
|
+
tabActivity: tabActivitySnapshot(tab),
|
|
1923
|
+
});
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
function scheduleAppRunnerBroadcast(tab) {
|
|
1927
|
+
if (!tab || tab.appRunnerBroadcastTimer) return;
|
|
1928
|
+
tab.appRunnerBroadcastTimer = setTimeout(() => {
|
|
1929
|
+
tab.appRunnerBroadcastTimer = null;
|
|
1930
|
+
if (tabs.has(tab.id)) broadcastAppRunnerState(tab);
|
|
1931
|
+
}, 120);
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
function terminateAppRunnerChild(run, signal = "SIGTERM") {
|
|
1935
|
+
if (!run?.child || run.child.killed) return false;
|
|
1936
|
+
try {
|
|
1937
|
+
if (process.platform !== "win32" && run.pid) process.kill(-run.pid, signal);
|
|
1938
|
+
else run.child.kill(signal);
|
|
1939
|
+
return true;
|
|
1940
|
+
} catch {
|
|
1941
|
+
try {
|
|
1942
|
+
run.child.kill(signal);
|
|
1943
|
+
return true;
|
|
1944
|
+
} catch {
|
|
1945
|
+
return false;
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
function finishAppRunner(tab, run, patch = {}) {
|
|
1951
|
+
if (!run || run.settled) return;
|
|
1952
|
+
run.settled = true;
|
|
1953
|
+
clearTimeout(run.stopTimer);
|
|
1954
|
+
flushAppRunnerRemainders(run);
|
|
1955
|
+
run.endedAt = new Date().toISOString();
|
|
1956
|
+
run.exitCode = patch.exitCode;
|
|
1957
|
+
run.signal = patch.signal;
|
|
1958
|
+
run.error = patch.error;
|
|
1959
|
+
run.status = patch.error ? "error" : patch.exitCode === 0 ? "done" : "failed";
|
|
1960
|
+
run.child = null;
|
|
1961
|
+
run.stopping = false;
|
|
1962
|
+
appendAppRunnerLine(run, `# ${appRunnerStatusLabel(run)} after ${Math.max(0, Math.round((Date.parse(run.endedAt) - Date.parse(run.startedAt)) / 1000))}s`);
|
|
1963
|
+
if (patch.error) appendAppRunnerLine(run, `# ${patch.error}`);
|
|
1964
|
+
recordEvent({ type: "webui_app_runner_exit", tabId: tab.id, tabTitle: tab.title, command: run.displayCommand, code: run.exitCode, signal: run.signal, error: run.error });
|
|
1965
|
+
clearTimeout(tab.appRunnerBroadcastTimer);
|
|
1966
|
+
tab.appRunnerBroadcastTimer = null;
|
|
1967
|
+
broadcastAppRunnerState(tab);
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
async function startAppRunner(tab, runnerId) {
|
|
1971
|
+
if (tab.appRunner?.status === "running") throw makeHttpError(409, `App runner already running: ${tab.appRunner.displayCommand}`);
|
|
1972
|
+
const runners = await detectAppRunners(tab);
|
|
1973
|
+
const runner = runners.find((item) => item.id === runnerId) || (runners.length === 1 && !runnerId ? runners[0] : null);
|
|
1974
|
+
if (!runner) throw makeHttpError(400, "Selected app runner is unavailable in this tab cwd");
|
|
1975
|
+
|
|
1976
|
+
const run = {
|
|
1977
|
+
id: randomUUID(),
|
|
1978
|
+
runnerId: runner.id,
|
|
1979
|
+
kind: runner.kind,
|
|
1980
|
+
label: runner.label,
|
|
1981
|
+
command: runner.command,
|
|
1982
|
+
args: runner.args || [],
|
|
1983
|
+
displayCommand: runner.displayCommand,
|
|
1984
|
+
cwd: runner.cwd || tab.cwd,
|
|
1985
|
+
status: "running",
|
|
1986
|
+
startedAt: new Date().toISOString(),
|
|
1987
|
+
lines: [],
|
|
1988
|
+
lineCount: 0,
|
|
1989
|
+
outputChars: 0,
|
|
1990
|
+
};
|
|
1991
|
+
appendAppRunnerLine(run, `$ ${run.displayCommand}`);
|
|
1992
|
+
const child = spawn(run.command, run.args, {
|
|
1993
|
+
cwd: run.cwd,
|
|
1994
|
+
env: process.env,
|
|
1995
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1996
|
+
windowsHide: true,
|
|
1997
|
+
detached: process.platform !== "win32",
|
|
1998
|
+
});
|
|
1999
|
+
run.child = child;
|
|
2000
|
+
run.pid = child.pid;
|
|
2001
|
+
tab.appRunner = run;
|
|
2002
|
+
|
|
2003
|
+
child.stdout.on("data", (chunk) => appendAppRunnerChunk(tab, run, chunk, "stdout"));
|
|
2004
|
+
child.stderr.on("data", (chunk) => appendAppRunnerChunk(tab, run, chunk, "stderr"));
|
|
2005
|
+
child.on("error", (error) => finishAppRunner(tab, run, { error: sanitizeError(error) }));
|
|
2006
|
+
child.on("exit", (exitCode, signal) => finishAppRunner(tab, run, { exitCode, signal }));
|
|
2007
|
+
|
|
2008
|
+
recordEvent({ type: "webui_app_runner_start", tabId: tab.id, tabTitle: tab.title, command: run.displayCommand, cwd: run.cwd, pid: run.pid });
|
|
2009
|
+
broadcastAppRunnerState(tab);
|
|
2010
|
+
return { runners, customRunnerConfig: await getCustomAppRunnerConfigData(tab), activeRun: publicAppRunnerState(run), cwd: tab.cwd };
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
function stopAppRunnerForTab(tab, reason = "stop requested", { force = false } = {}) {
|
|
2014
|
+
const run = tab?.appRunner;
|
|
2015
|
+
if (!run || run.status !== "running") return false;
|
|
2016
|
+
run.stopping = true;
|
|
2017
|
+
appendAppRunnerLine(run, `# ${reason}; sending ${force ? "SIGKILL" : "SIGTERM"}`);
|
|
2018
|
+
terminateAppRunnerChild(run, force ? "SIGKILL" : "SIGTERM");
|
|
2019
|
+
if (!force) {
|
|
2020
|
+
clearTimeout(run.stopTimer);
|
|
2021
|
+
run.stopTimer = setTimeout(() => {
|
|
2022
|
+
if (run.status === "running") {
|
|
2023
|
+
appendAppRunnerLine(run, "# app runner did not stop; sending SIGKILL");
|
|
2024
|
+
terminateAppRunnerChild(run, "SIGKILL");
|
|
2025
|
+
scheduleAppRunnerBroadcast(tab);
|
|
2026
|
+
}
|
|
2027
|
+
}, APP_RUNNER_STOP_GRACE_MS);
|
|
2028
|
+
}
|
|
2029
|
+
broadcastAppRunnerState(tab);
|
|
2030
|
+
return true;
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
function clearAppRunnerForTab(tab) {
|
|
2034
|
+
if (!tab?.appRunner || tab.appRunner.status === "running") return false;
|
|
2035
|
+
tab.appRunner = null;
|
|
2036
|
+
broadcastAppRunnerState(tab);
|
|
2037
|
+
return true;
|
|
2038
|
+
}
|
|
2039
|
+
|
|
995
2040
|
function firstDefined(...values) {
|
|
996
2041
|
return values.find((value) => value !== undefined && value !== null);
|
|
997
2042
|
}
|
|
@@ -1661,9 +2706,23 @@ function commitMessagePaths(root) {
|
|
|
1661
2706
|
return {
|
|
1662
2707
|
shortPath: path.join(root, "dev", "COMMIT", "staged-commit-short.txt"),
|
|
1663
2708
|
longPath: path.join(root, "dev", "COMMIT", "staged-commit-long.txt"),
|
|
2709
|
+
branchPath: path.join(root, "dev", "COMMIT", "staged-branch-name.txt"),
|
|
1664
2710
|
};
|
|
1665
2711
|
}
|
|
1666
2712
|
|
|
2713
|
+
async function readGitWorkflowBranchName(cwd) {
|
|
2714
|
+
const root = await getGitRoot(cwd);
|
|
2715
|
+
const { branchPath } = commitMessagePaths(root);
|
|
2716
|
+
try {
|
|
2717
|
+
const [branchText, branchStat] = await Promise.all([readFile(branchPath, "utf8"), stat(branchPath)]);
|
|
2718
|
+
const branch = branchText.split(/\r?\n/).find((line) => line.trim())?.trim() || "";
|
|
2719
|
+
if (!branch) throw new Error(`${branchPath} is empty`);
|
|
2720
|
+
return { root, branchPath, branch, mtimeMs: branchStat.mtimeMs };
|
|
2721
|
+
} catch (error) {
|
|
2722
|
+
throw new Error(`Missing generated branch name file ${branchPath}. Run /git-branch-name first. ${sanitizeError(error)}`);
|
|
2723
|
+
}
|
|
2724
|
+
}
|
|
2725
|
+
|
|
1667
2726
|
async function readGitWorkflowMessages(cwd) {
|
|
1668
2727
|
const root = await getGitRoot(cwd);
|
|
1669
2728
|
const { shortPath, longPath } = commitMessagePaths(root);
|
|
@@ -1688,19 +2747,77 @@ async function readGitWorkflowMessages(cwd) {
|
|
|
1688
2747
|
}
|
|
1689
2748
|
}
|
|
1690
2749
|
|
|
2750
|
+
function cleanGitBranchName(value) {
|
|
2751
|
+
const branch = String(value || "").trim();
|
|
2752
|
+
if (!branch) throw new Error("branch is required");
|
|
2753
|
+
if (branch.includes("\0") || branch.includes("@{") || branch.startsWith("-") || branch.startsWith("/")) throw new Error("invalid branch name");
|
|
2754
|
+
return branch;
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
async function validateGitBranchName(root, branch) {
|
|
2758
|
+
const result = await runGitWorkflowCommand(["check-ref-format", "--branch", branch], { cwd: root, timeoutMs: 5000 });
|
|
2759
|
+
if (result.exitCode !== 0 || result.timedOut || result.cancelled || result.error) {
|
|
2760
|
+
throw new Error((result.stderr || result.stdout || result.error || `Invalid branch name: ${branch}`).trim());
|
|
2761
|
+
}
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
async function currentGitBranch(root) {
|
|
2765
|
+
const result = await runGitWorkflowCommand(["branch", "--show-current"], { cwd: root, timeoutMs: 5000 });
|
|
2766
|
+
const branch = result.stdout.trim();
|
|
2767
|
+
if (result.exitCode !== 0 || !branch) throw new Error((result.stderr || result.stdout || "Cannot determine current git branch").trim());
|
|
2768
|
+
return branch;
|
|
2769
|
+
}
|
|
2770
|
+
|
|
2771
|
+
async function defaultGitRemote(root) {
|
|
2772
|
+
const result = await runGitWorkflowCommand(["remote"], { cwd: root, timeoutMs: 5000 });
|
|
2773
|
+
if (result.exitCode !== 0) throw new Error((result.stderr || result.stdout || "Cannot list git remotes").trim());
|
|
2774
|
+
const remotes = result.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
2775
|
+
if (!remotes.length) throw new Error("No git remote is configured for this repository");
|
|
2776
|
+
return remotes.includes("origin") ? "origin" : remotes[0];
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
function prDescriptionPath(root, branch) {
|
|
2780
|
+
const base = path.resolve(root, "dev", "PR");
|
|
2781
|
+
const target = path.resolve(base, `${branch}.md`);
|
|
2782
|
+
if (target !== base && !target.startsWith(`${base}${path.sep}`)) throw new Error("Resolved PR description path escapes dev/PR");
|
|
2783
|
+
return { base, prPath: target };
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
async function readGitWorkflowPrDescription(cwd) {
|
|
2787
|
+
const root = await getGitRoot(cwd);
|
|
2788
|
+
const branch = await currentGitBranch(root);
|
|
2789
|
+
const { prPath } = prDescriptionPath(root, branch);
|
|
2790
|
+
try {
|
|
2791
|
+
const [body, info] = await Promise.all([readFile(prPath, "utf8"), stat(prPath)]);
|
|
2792
|
+
return { root, branch, path: prPath, body: body.trimEnd(), mtimeMs: info.mtimeMs };
|
|
2793
|
+
} catch (error) {
|
|
2794
|
+
throw new Error(`Missing generated PR description ${prPath}. Run /pr first. ${sanitizeError(error)}`);
|
|
2795
|
+
}
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
function cleanPrTitle(value) {
|
|
2799
|
+
const title = String(value || "").replace(/\r?\n/g, " ").trim();
|
|
2800
|
+
if (!title) throw new Error("PR title is required");
|
|
2801
|
+
return title.slice(0, 300);
|
|
2802
|
+
}
|
|
2803
|
+
|
|
2804
|
+
function formatWorkflowCommand(command, args) {
|
|
2805
|
+
return [command, ...args.map((arg) => (/\s/.test(arg) ? JSON.stringify(arg) : arg))].join(" ");
|
|
2806
|
+
}
|
|
2807
|
+
|
|
1691
2808
|
function formatGitCommand(args) {
|
|
1692
|
-
return
|
|
2809
|
+
return formatWorkflowCommand("git", args);
|
|
1693
2810
|
}
|
|
1694
2811
|
|
|
1695
|
-
function
|
|
2812
|
+
function runWorkflowCommand(command, args, { cwd, label = formatWorkflowCommand(command, args), timeoutMs = 10 * 60 * 1000 } = {}) {
|
|
1696
2813
|
if (activeGitWorkflowProcess) {
|
|
1697
2814
|
return Promise.reject(new Error(`A git workflow command is already running: ${activeGitWorkflowProcess.label}`));
|
|
1698
2815
|
}
|
|
1699
2816
|
|
|
1700
2817
|
return new Promise((resolve) => {
|
|
1701
|
-
const child = spawn(
|
|
2818
|
+
const child = spawn(command, args, {
|
|
1702
2819
|
cwd,
|
|
1703
|
-
env: { ...process.env, GIT_TERMINAL_PROMPT: process.env.GIT_TERMINAL_PROMPT || "0" },
|
|
2820
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: process.env.GIT_TERMINAL_PROMPT || "0", GH_PROMPT_DISABLED: process.env.GH_PROMPT_DISABLED || "1" },
|
|
1704
2821
|
stdio: ["ignore", "pipe", "pipe"],
|
|
1705
2822
|
windowsHide: true,
|
|
1706
2823
|
});
|
|
@@ -1745,6 +2862,14 @@ function runGitWorkflowCommand(args, { cwd, label = formatGitCommand(args), time
|
|
|
1745
2862
|
});
|
|
1746
2863
|
}
|
|
1747
2864
|
|
|
2865
|
+
function runGitWorkflowCommand(args, options = {}) {
|
|
2866
|
+
return runWorkflowCommand("git", args, { ...options, label: options.label || formatGitCommand(args) });
|
|
2867
|
+
}
|
|
2868
|
+
|
|
2869
|
+
function runGitHubWorkflowCommand(args, options = {}) {
|
|
2870
|
+
return runWorkflowCommand("gh", args, { ...options, label: options.label || formatWorkflowCommand("gh", args) });
|
|
2871
|
+
}
|
|
2872
|
+
|
|
1748
2873
|
function gitWorkflowCommandPayload(result) {
|
|
1749
2874
|
const ok = result.exitCode === 0 && !result.timedOut && !result.cancelled && !result.error;
|
|
1750
2875
|
return {
|
|
@@ -1759,9 +2884,21 @@ async function handleGitWorkflowRequest(pathname, body = {}, cwd = options.cwd)
|
|
|
1759
2884
|
switch (pathname) {
|
|
1760
2885
|
case "/api/git-workflow/message":
|
|
1761
2886
|
return { ok: true, data: await readGitWorkflowMessages(cwd) };
|
|
2887
|
+
case "/api/git-workflow/branch-name":
|
|
2888
|
+
return { ok: true, data: await readGitWorkflowBranchName(cwd) };
|
|
2889
|
+
case "/api/git-workflow/pr-description":
|
|
2890
|
+
return { ok: true, data: await readGitWorkflowPrDescription(cwd) };
|
|
1762
2891
|
case "/api/git-workflow/add":
|
|
1763
2892
|
await getGitRoot(cwd);
|
|
1764
2893
|
return gitWorkflowCommandPayload(await runGitWorkflowCommand(["add", "."], { cwd }));
|
|
2894
|
+
case "/api/git-workflow/branch": {
|
|
2895
|
+
const root = await getGitRoot(cwd);
|
|
2896
|
+
const branch = cleanGitBranchName(body.branch);
|
|
2897
|
+
await validateGitBranchName(root, branch);
|
|
2898
|
+
const payload = gitWorkflowCommandPayload(await runGitWorkflowCommand(["switch", "-c", branch], { cwd: root }));
|
|
2899
|
+
if (payload.ok) payload.data.branch = branch;
|
|
2900
|
+
return payload;
|
|
2901
|
+
}
|
|
1765
2902
|
case "/api/git-workflow/commit": {
|
|
1766
2903
|
const variant = String(body.variant || "").trim();
|
|
1767
2904
|
if (!["short", "long"].includes(variant)) throw new Error("variant must be 'short' or 'long'");
|
|
@@ -1776,8 +2913,37 @@ async function handleGitWorkflowRequest(pathname, body = {}, cwd = options.cwd)
|
|
|
1776
2913
|
}
|
|
1777
2914
|
case "/api/git-workflow/push": {
|
|
1778
2915
|
const root = await getGitRoot(cwd);
|
|
2916
|
+
if (body.setUpstream) {
|
|
2917
|
+
const currentBranch = await currentGitBranch(root);
|
|
2918
|
+
const requestedBranch = body.branch ? cleanGitBranchName(body.branch) : currentBranch;
|
|
2919
|
+
if (requestedBranch !== currentBranch) throw new Error(`Current branch is ${currentBranch}, not ${requestedBranch}`);
|
|
2920
|
+
const remote = await defaultGitRemote(root);
|
|
2921
|
+
const payload = gitWorkflowCommandPayload(await runGitWorkflowCommand(["push", "-u", remote, currentBranch], { cwd: root, label: `git push -u ${remote} ${currentBranch}`, timeoutMs: 15 * 60 * 1000 }));
|
|
2922
|
+
if (payload.ok) {
|
|
2923
|
+
payload.data.branch = currentBranch;
|
|
2924
|
+
payload.data.remote = remote;
|
|
2925
|
+
}
|
|
2926
|
+
return payload;
|
|
2927
|
+
}
|
|
1779
2928
|
return gitWorkflowCommandPayload(await runGitWorkflowCommand(["push"], { cwd: root, timeoutMs: 15 * 60 * 1000 }));
|
|
1780
2929
|
}
|
|
2930
|
+
case "/api/git-workflow/create-pr": {
|
|
2931
|
+
const root = await getGitRoot(cwd);
|
|
2932
|
+
const branch = await currentGitBranch(root);
|
|
2933
|
+
const title = cleanPrTitle(body.title);
|
|
2934
|
+
const description = String(body.body || "").trimEnd();
|
|
2935
|
+
if (!description.trim()) throw new Error("PR description is required");
|
|
2936
|
+
const { base, prPath } = prDescriptionPath(root, branch);
|
|
2937
|
+
await mkdir(path.dirname(prPath), { recursive: true });
|
|
2938
|
+
await writeFile(prPath, `${description}\n`, "utf8");
|
|
2939
|
+
const payload = gitWorkflowCommandPayload(await runGitHubWorkflowCommand(["pr", "create", "--title", title, "--body-file", prPath, "--head", branch], { cwd: root, label: "gh pr create --title <title> --body-file <dev/PR/current-branch.md> --head <current-branch>", timeoutMs: 15 * 60 * 1000 }));
|
|
2940
|
+
if (payload.ok) {
|
|
2941
|
+
payload.data.branch = branch;
|
|
2942
|
+
payload.data.path = prPath;
|
|
2943
|
+
payload.data.prDirectory = base;
|
|
2944
|
+
}
|
|
2945
|
+
return payload;
|
|
2946
|
+
}
|
|
1781
2947
|
case "/api/git-workflow/cancel": {
|
|
1782
2948
|
const cancelled = !!activeGitWorkflowProcess;
|
|
1783
2949
|
if (activeGitWorkflowProcess) activeGitWorkflowProcess.cancel();
|
|
@@ -2071,7 +3237,7 @@ let options;
|
|
|
2071
3237
|
try {
|
|
2072
3238
|
options = parseArgs(process.argv.slice(2));
|
|
2073
3239
|
} catch (error) {
|
|
2074
|
-
console.error(`Error: ${
|
|
3240
|
+
console.error(`Error: ${formatCliError(error)}\n`);
|
|
2075
3241
|
usage();
|
|
2076
3242
|
process.exit(2);
|
|
2077
3243
|
}
|
|
@@ -2085,6 +3251,14 @@ if (options.version) {
|
|
|
2085
3251
|
process.exit(0);
|
|
2086
3252
|
}
|
|
2087
3253
|
|
|
3254
|
+
try {
|
|
3255
|
+
options.cwd = await validateStartupCwd(options.cwd);
|
|
3256
|
+
} catch (error) {
|
|
3257
|
+
console.error(`Error: ${formatCliError(error)}\n`);
|
|
3258
|
+
usage();
|
|
3259
|
+
process.exit(2);
|
|
3260
|
+
}
|
|
3261
|
+
|
|
2088
3262
|
const startupDelayMs = Number.parseInt(process.env.PI_WEBUI_START_DELAY_MS || "", 10);
|
|
2089
3263
|
delete process.env.PI_WEBUI_START_DELAY_MS;
|
|
2090
3264
|
if (Number.isFinite(startupDelayMs) && startupDelayMs > 0) {
|
|
@@ -2806,6 +3980,7 @@ async function updateTabCwd(id, cwd) {
|
|
|
2806
3980
|
tab.rpcUnsubscribe?.();
|
|
2807
3981
|
tab.rpcUnsubscribe = undefined;
|
|
2808
3982
|
rejectTabBashQueue(tab, new Error("Pi tab is restarting; queued bash commands were cancelled"));
|
|
3983
|
+
stopAppRunnerForTab(tab, "cwd changed", { force: true });
|
|
2809
3984
|
oldRpc.stop();
|
|
2810
3985
|
|
|
2811
3986
|
tab.cwd = nextCwd;
|
|
@@ -3811,6 +4986,7 @@ async function closeTab(id) {
|
|
|
3811
4986
|
tab.sseClients.clear();
|
|
3812
4987
|
tab.rpcUnsubscribe?.();
|
|
3813
4988
|
rejectTabBashQueue(tab, new Error("Pi tab closed; queued bash commands were cancelled"));
|
|
4989
|
+
stopAppRunnerForTab(tab, "tab closed", { force: true });
|
|
3814
4990
|
tab.rpc.stop();
|
|
3815
4991
|
tabs.delete(id);
|
|
3816
4992
|
return tab;
|
|
@@ -3851,8 +5027,14 @@ function getRequestedTab(req, url, body = {}) {
|
|
|
3851
5027
|
return tab;
|
|
3852
5028
|
}
|
|
3853
5029
|
|
|
5030
|
+
function directoryPickerActiveCwd(req, url, body = {}) {
|
|
5031
|
+
const id = requestedTabId(req, url, body);
|
|
5032
|
+
if (id) return getRequestedTab(req, url, body).cwd;
|
|
5033
|
+
return firstTab()?.cwd || options.cwd;
|
|
5034
|
+
}
|
|
5035
|
+
|
|
3854
5036
|
async function createInitialTabs() {
|
|
3855
|
-
if (!restoreTabs.length) return [await createTab()];
|
|
5037
|
+
if (!restoreTabs.length) return options.cwdExplicit ? [await createTab()] : [];
|
|
3856
5038
|
|
|
3857
5039
|
const created = [];
|
|
3858
5040
|
for (const descriptor of restoreTabs) {
|
|
@@ -3863,7 +5045,7 @@ async function createInitialTabs() {
|
|
|
3863
5045
|
}
|
|
3864
5046
|
}
|
|
3865
5047
|
|
|
3866
|
-
return created.length ? created : [await createTab()];
|
|
5048
|
+
return created.length ? created : options.cwdExplicit ? [await createTab()] : [];
|
|
3867
5049
|
}
|
|
3868
5050
|
|
|
3869
5051
|
const serverStartedAt = new Date().toISOString();
|
|
@@ -4193,6 +5375,7 @@ const server = createServer(async (req, res) => {
|
|
|
4193
5375
|
startedAt: tab.rpc.startedAt,
|
|
4194
5376
|
tabActivity: tabActivitySnapshot(tab),
|
|
4195
5377
|
pendingExtensionUiRequestCount: pendingExtensionUiRequests(tab).length,
|
|
5378
|
+
activeRun: publicAppRunnerState(tab.appRunner),
|
|
4196
5379
|
});
|
|
4197
5380
|
replayExtensionStatuses(tab, res);
|
|
4198
5381
|
replayPendingExtensionUiRequests(tab, res);
|
|
@@ -4306,21 +5489,76 @@ const server = createServer(async (req, res) => {
|
|
|
4306
5489
|
return;
|
|
4307
5490
|
}
|
|
4308
5491
|
|
|
4309
|
-
if (url.pathname === "/api/
|
|
5492
|
+
if (url.pathname === "/api/app-runners" && req.method === "GET") {
|
|
5493
|
+
const tab = getRequestedTab(req, url);
|
|
5494
|
+
sendJson(res, 200, { ok: true, data: await getAppRunnerData(tab) });
|
|
5495
|
+
return;
|
|
5496
|
+
}
|
|
5497
|
+
|
|
5498
|
+
if (url.pathname === "/api/app-runner" && req.method === "POST") {
|
|
5499
|
+
const body = await readJsonBody(req);
|
|
5500
|
+
const tab = getRequestedTab(req, url, body);
|
|
5501
|
+
sendJson(res, 200, { ok: true, data: await startAppRunner(tab, String(body.runnerId || body.id || "")) });
|
|
5502
|
+
return;
|
|
5503
|
+
}
|
|
5504
|
+
|
|
5505
|
+
if (url.pathname === "/api/app-runner/stop" && req.method === "POST") {
|
|
5506
|
+
const body = await readJsonBody(req);
|
|
5507
|
+
const tab = getRequestedTab(req, url, body);
|
|
5508
|
+
stopAppRunnerForTab(tab, "stop requested from Web UI");
|
|
5509
|
+
sendJson(res, 200, { ok: true, data: await getAppRunnerData(tab) });
|
|
5510
|
+
return;
|
|
5511
|
+
}
|
|
5512
|
+
|
|
5513
|
+
if (url.pathname === "/api/app-runner/clear" && req.method === "POST") {
|
|
5514
|
+
const body = await readJsonBody(req);
|
|
5515
|
+
const tab = getRequestedTab(req, url, body);
|
|
5516
|
+
clearAppRunnerForTab(tab);
|
|
5517
|
+
sendJson(res, 200, { ok: true, data: await getAppRunnerData(tab) });
|
|
5518
|
+
return;
|
|
5519
|
+
}
|
|
5520
|
+
|
|
5521
|
+
if (url.pathname === "/api/app-runner-config" && req.method === "GET") {
|
|
4310
5522
|
const tab = getRequestedTab(req, url);
|
|
5523
|
+
sendJson(res, 200, { ok: true, data: await getCustomAppRunnerConfigData(tab) });
|
|
5524
|
+
return;
|
|
5525
|
+
}
|
|
5526
|
+
|
|
5527
|
+
if (url.pathname === "/api/app-runner-config" && req.method === "POST") {
|
|
5528
|
+
const body = await readJsonBody(req);
|
|
5529
|
+
const tab = getRequestedTab(req, url, body);
|
|
5530
|
+
sendJson(res, 200, { ok: true, data: await saveCustomAppRunner(tab, body.runner || body) });
|
|
5531
|
+
return;
|
|
5532
|
+
}
|
|
5533
|
+
|
|
5534
|
+
if (url.pathname === "/api/app-runner-config" && req.method === "DELETE") {
|
|
5535
|
+
const body = await readJsonBody(req);
|
|
5536
|
+
const tab = getRequestedTab(req, url, body);
|
|
5537
|
+
sendJson(res, 200, { ok: true, data: await deleteCustomAppRunner(tab, body.id || body.runnerId) });
|
|
5538
|
+
return;
|
|
5539
|
+
}
|
|
5540
|
+
|
|
5541
|
+
if (url.pathname === "/api/app-runner-files" && req.method === "GET") {
|
|
5542
|
+
const tab = getRequestedTab(req, url);
|
|
5543
|
+
sendJson(res, 200, { ok: true, data: await getAppRunnerFileBrowserData(tab, url.searchParams.get("path")) });
|
|
5544
|
+
return;
|
|
5545
|
+
}
|
|
5546
|
+
|
|
5547
|
+
if (url.pathname === "/api/directories" && req.method === "GET") {
|
|
5548
|
+
const activeCwd = directoryPickerActiveCwd(req, url);
|
|
4311
5549
|
sendJson(res, 200, {
|
|
4312
5550
|
ok: true,
|
|
4313
|
-
data: await getDirectoryPickerData(url.searchParams.get("path"),
|
|
5551
|
+
data: await getDirectoryPickerData(url.searchParams.get("path"), activeCwd),
|
|
4314
5552
|
});
|
|
4315
5553
|
return;
|
|
4316
5554
|
}
|
|
4317
5555
|
|
|
4318
5556
|
if (url.pathname === "/api/directories" && req.method === "POST") {
|
|
4319
5557
|
const body = await readJsonBody(req);
|
|
4320
|
-
const
|
|
5558
|
+
const activeCwd = directoryPickerActiveCwd(req, url, body);
|
|
4321
5559
|
sendJson(res, 201, {
|
|
4322
5560
|
ok: true,
|
|
4323
|
-
data: await createDirectoryPickerDirectory(body.parent ?? body.cwd ?? body.path, body.name,
|
|
5561
|
+
data: await createDirectoryPickerDirectory(body.parent ?? body.cwd ?? body.path, body.name, activeCwd),
|
|
4324
5562
|
});
|
|
4325
5563
|
return;
|
|
4326
5564
|
}
|
|
@@ -4582,7 +5820,10 @@ server.on("error", (error) => {
|
|
|
4582
5820
|
return;
|
|
4583
5821
|
}
|
|
4584
5822
|
console.error("Web UI server failed:", sanitizeError(error));
|
|
4585
|
-
for (const tab of tabs.values())
|
|
5823
|
+
for (const tab of tabs.values()) {
|
|
5824
|
+
stopAppRunnerForTab(tab, "server error", { force: true });
|
|
5825
|
+
tab.rpc.stop();
|
|
5826
|
+
}
|
|
4586
5827
|
process.exit(1);
|
|
4587
5828
|
});
|
|
4588
5829
|
|
|
@@ -4590,7 +5831,8 @@ server.listen(options.port, currentHost, () => {
|
|
|
4590
5831
|
const urlHost = formatUrlHost(currentHost);
|
|
4591
5832
|
console.log(`Pi Web UI: http://${urlHost}:${options.port}/`);
|
|
4592
5833
|
console.log(`Working directory: ${options.cwd}`);
|
|
4593
|
-
console.log(`Pi RPC: ${initialTab.rpc.displayCommand}`);
|
|
5834
|
+
if (initialTab) console.log(`Pi RPC: ${initialTab.rpc.displayCommand}`);
|
|
5835
|
+
else console.log("Pi RPC: waiting for CWD selection in the Web UI");
|
|
4594
5836
|
if (restoreTabs.length) console.log(`Restored Web UI tabs: ${initialTabs.length}`);
|
|
4595
5837
|
if (!isLocalHost(currentHost)) {
|
|
4596
5838
|
console.warn("WARNING: Web UI has no authentication. Only expose it on trusted networks.");
|
|
@@ -4608,7 +5850,10 @@ function shutdown(signal) {
|
|
|
4608
5850
|
process.exit(0);
|
|
4609
5851
|
});
|
|
4610
5852
|
server.closeIdleConnections?.();
|
|
4611
|
-
for (const tab of tabs.values())
|
|
5853
|
+
for (const tab of tabs.values()) {
|
|
5854
|
+
stopAppRunnerForTab(tab, "server shutdown", { force: true });
|
|
5855
|
+
tab.rpc.stop();
|
|
5856
|
+
}
|
|
4612
5857
|
setTimeout(() => process.exit(0), 4000).unref();
|
|
4613
5858
|
}
|
|
4614
5859
|
|