localdeck 1.4.0 → 1.6.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.
- package/CHANGELOG.md +47 -0
- package/README.md +4 -1
- package/THIRD_PARTY_NOTICES.md +69 -0
- package/dashboard/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/dashboard/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/dashboard/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- package/dashboard/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/dashboard/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
- package/dashboard/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
- package/dashboard/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
- package/dashboard/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
- package/dashboard/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
- package/dashboard/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
- package/dashboard/assets/index-C9iZV9gt.js +21 -0
- package/dashboard/assets/index-DDw1Wq-H.css +1 -0
- package/dashboard/index.html +2 -2
- package/dist/{index-316fp4p2.js → index-2zg5ahh5.js} +2289 -1337
- package/dist/index.js +98 -13
- package/dist/{mcp-h8gf5pgj.js → mcp-r1ygwec0.js} +96 -10
- package/dist/{update-w6vc3sdy.js → update-n1fnecgj.js} +1 -1
- package/docs/commands.md +15 -2
- package/docs/configuration.md +42 -6
- package/docs/dashboard.md +60 -20
- package/docs/mcp.md +29 -5
- package/package.json +1 -1
- package/dashboard/assets/index-2ZVKTtzC.css +0 -1
- package/dashboard/assets/index-D9Moyhwp.js +0 -21
|
@@ -2936,50 +2936,6 @@ var import_websocket = __toESM(require_websocket(), 1);
|
|
|
2936
2936
|
var import_websocket_server = __toESM(require_websocket_server(), 1);
|
|
2937
2937
|
var wrapper_default = import_websocket.default;
|
|
2938
2938
|
|
|
2939
|
-
// ../daemon/src/forward.ts
|
|
2940
|
-
import net from "node:net";
|
|
2941
|
-
function startForwarder(options = {}) {
|
|
2942
|
-
const listenPort = options.listenPort ?? 80;
|
|
2943
|
-
const targetPort = options.targetPort ?? 7777;
|
|
2944
|
-
if (!Number.isInteger(listenPort) || listenPort < 0 || listenPort > 65535 || !Number.isInteger(targetPort) || targetPort < 1 || targetPort > 65535)
|
|
2945
|
-
return Promise.reject(new Error("forward ports must be integers between 1 and 65535"));
|
|
2946
|
-
if (listenPort !== 0 && listenPort === targetPort)
|
|
2947
|
-
return Promise.reject(new Error("forward listen port and target port must be different"));
|
|
2948
|
-
const sockets = new Set;
|
|
2949
|
-
const server = net.createServer((client) => {
|
|
2950
|
-
sockets.add(client);
|
|
2951
|
-
client.once("close", () => sockets.delete(client));
|
|
2952
|
-
const upstream = net.connect(targetPort, "127.0.0.1");
|
|
2953
|
-
sockets.add(upstream);
|
|
2954
|
-
upstream.once("close", () => sockets.delete(upstream));
|
|
2955
|
-
client.pipe(upstream);
|
|
2956
|
-
upstream.pipe(client);
|
|
2957
|
-
const destroy = () => {
|
|
2958
|
-
client.destroy();
|
|
2959
|
-
upstream.destroy();
|
|
2960
|
-
};
|
|
2961
|
-
client.once("error", destroy);
|
|
2962
|
-
upstream.once("error", destroy);
|
|
2963
|
-
client.once("close", destroy);
|
|
2964
|
-
upstream.once("close", destroy);
|
|
2965
|
-
});
|
|
2966
|
-
return new Promise((resolve, reject) => {
|
|
2967
|
-
const onError = (error) => {
|
|
2968
|
-
server.removeAllListeners();
|
|
2969
|
-
reject(new Error(error.code === "EADDRINUSE" ? `forwarder could not listen on 127.0.0.1:${listenPort}: address already in use` : error.code === "EACCES" || error.code === "EPERM" ? `forwarder cannot listen on port ${listenPort}: permission denied (choose another port; do not run LocalDeck as root)` : `forwarder failed: ${error.message}`));
|
|
2970
|
-
};
|
|
2971
|
-
server.once("error", onError);
|
|
2972
|
-
server.listen(listenPort, "127.0.0.1", () => {
|
|
2973
|
-
server.off("error", onError);
|
|
2974
|
-
resolve({ port: server.address().port, close: () => new Promise((done) => {
|
|
2975
|
-
for (const socket of sockets)
|
|
2976
|
-
socket.destroy();
|
|
2977
|
-
server.close(() => done());
|
|
2978
|
-
}) });
|
|
2979
|
-
});
|
|
2980
|
-
});
|
|
2981
|
-
}
|
|
2982
|
-
|
|
2983
2939
|
// ../daemon/src/paths.ts
|
|
2984
2940
|
import os from "node:os";
|
|
2985
2941
|
import path from "node:path";
|
|
@@ -3021,6 +2977,7 @@ function writePrivateJson(file, value) {
|
|
|
3021
2977
|
}
|
|
3022
2978
|
|
|
3023
2979
|
// ../daemon/src/project-config.ts
|
|
2980
|
+
import { createHash } from "node:crypto";
|
|
3024
2981
|
import fs2 from "node:fs";
|
|
3025
2982
|
import path2 from "node:path";
|
|
3026
2983
|
var DEFAULT_READY_TIMEOUT_MS = 60000;
|
|
@@ -3047,7 +3004,7 @@ function loadProject(from = process.cwd()) {
|
|
|
3047
3004
|
function loadProjectFile(file) {
|
|
3048
3005
|
const raw = readProjectJson(file);
|
|
3049
3006
|
const commands = normalize(raw, file);
|
|
3050
|
-
const profiles = parseProfiles(raw, commands, file);
|
|
3007
|
+
const { profiles, legacyProfiles } = parseProfiles(raw, commands, file);
|
|
3051
3008
|
let nickname;
|
|
3052
3009
|
if (raw && typeof raw === "object" && !Array.isArray(raw) && (Array.isArray(raw.commands) || Array.isArray(raw.services))) {
|
|
3053
3010
|
const value = raw.nickname;
|
|
@@ -3057,7 +3014,7 @@ function loadProjectFile(file) {
|
|
|
3057
3014
|
nickname = value.toLowerCase();
|
|
3058
3015
|
}
|
|
3059
3016
|
}
|
|
3060
|
-
return { file, dir: path2.dirname(file), commands, ...profiles ? { profiles } : {}, ...nickname ? { nickname } : {} };
|
|
3017
|
+
return { file, dir: path2.dirname(file), commands, ...profiles ? { profiles } : {}, ...legacyProfiles ? { legacyProfiles } : {}, ...nickname ? { nickname } : {} };
|
|
3061
3018
|
}
|
|
3062
3019
|
function updateProjectNickname(file, nickname) {
|
|
3063
3020
|
const raw = readProjectJson(file);
|
|
@@ -3218,35 +3175,8 @@ function normalize(raw, file) {
|
|
|
3218
3175
|
if (serviceType && serviceType !== "http" && (entry.upstream == null || entry.port != null)) {
|
|
3219
3176
|
throw new Error(`${path2.basename(file)}: database services need an explicit upstream (published port) and must not set an HTTP proxy port`);
|
|
3220
3177
|
}
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
if (!entry.env || typeof entry.env !== "object" || Array.isArray(entry.env))
|
|
3224
|
-
throw new Error(`${path2.basename(file)}: env must be an object of strings`);
|
|
3225
|
-
env = {};
|
|
3226
|
-
for (const [key, value2] of Object.entries(entry.env)) {
|
|
3227
|
-
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || typeof value2 !== "string" || value2.includes("\x00"))
|
|
3228
|
-
throw new Error(`${path2.basename(file)}: env must contain valid variable names and string values`);
|
|
3229
|
-
if (key === "PORT" || key.startsWith("LOCALDECK_"))
|
|
3230
|
-
throw new Error(`${path2.basename(file)}: ${key} is reserved by LocalDeck`);
|
|
3231
|
-
Object.defineProperty(env, key, { value: value2, enumerable: true, writable: true, configurable: true });
|
|
3232
|
-
}
|
|
3233
|
-
}
|
|
3234
|
-
let secrets;
|
|
3235
|
-
if (entry.secrets !== undefined) {
|
|
3236
|
-
if (!Array.isArray(entry.secrets) || !entry.secrets.every((value2) => typeof value2 === "string" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(value2))) {
|
|
3237
|
-
throw new Error(`${path2.basename(file)}: secrets must contain valid environment names`);
|
|
3238
|
-
}
|
|
3239
|
-
secrets = [];
|
|
3240
|
-
for (const secret of entry.secrets) {
|
|
3241
|
-
if (secret === "PORT" || secret.startsWith("LOCALDECK_"))
|
|
3242
|
-
throw new Error(`${path2.basename(file)}: ${secret} is reserved by LocalDeck`);
|
|
3243
|
-
if (env && Object.prototype.hasOwnProperty.call(env, secret))
|
|
3244
|
-
throw new Error(`${path2.basename(file)}: ${secret} cannot be both a secret and env setting`);
|
|
3245
|
-
if (secrets.includes(secret))
|
|
3246
|
-
throw new Error(`${path2.basename(file)}: duplicate secret ${secret}`);
|
|
3247
|
-
secrets.push(secret);
|
|
3248
|
-
}
|
|
3249
|
-
}
|
|
3178
|
+
const env = entry.env === undefined ? undefined : parseEnv(entry.env, file);
|
|
3179
|
+
const secrets = entry.secrets === undefined ? undefined : parseSecrets(entry.secrets, env, file);
|
|
3250
3180
|
out.push({
|
|
3251
3181
|
name,
|
|
3252
3182
|
run,
|
|
@@ -3264,6 +3194,35 @@ function normalize(raw, file) {
|
|
|
3264
3194
|
validateDependencies(out, file);
|
|
3265
3195
|
return out;
|
|
3266
3196
|
}
|
|
3197
|
+
function parseEnv(value, file) {
|
|
3198
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
3199
|
+
throw new Error(`${path2.basename(file)}: env must be an object of strings`);
|
|
3200
|
+
const env = {};
|
|
3201
|
+
for (const [key, setting] of Object.entries(value)) {
|
|
3202
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key) || typeof setting !== "string" || setting.includes("\x00"))
|
|
3203
|
+
throw new Error(`${path2.basename(file)}: env must contain valid variable names and string values`);
|
|
3204
|
+
if (key === "PORT" || key.startsWith("LOCALDECK_"))
|
|
3205
|
+
throw new Error(`${path2.basename(file)}: ${key} is reserved by LocalDeck`);
|
|
3206
|
+
Object.defineProperty(env, key, { value: setting, enumerable: true, writable: true, configurable: true });
|
|
3207
|
+
}
|
|
3208
|
+
return env;
|
|
3209
|
+
}
|
|
3210
|
+
function parseSecrets(value, env, file) {
|
|
3211
|
+
if (!Array.isArray(value) || !value.every((name) => typeof name === "string" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(name))) {
|
|
3212
|
+
throw new Error(`${path2.basename(file)}: secrets must contain valid environment names`);
|
|
3213
|
+
}
|
|
3214
|
+
const secrets = [];
|
|
3215
|
+
for (const secret of value) {
|
|
3216
|
+
if (secret === "PORT" || secret.startsWith("LOCALDECK_"))
|
|
3217
|
+
throw new Error(`${path2.basename(file)}: ${secret} is reserved by LocalDeck`);
|
|
3218
|
+
if (env && Object.prototype.hasOwnProperty.call(env, secret))
|
|
3219
|
+
throw new Error(`${path2.basename(file)}: ${secret} cannot be both a secret and env setting`);
|
|
3220
|
+
if (secrets.includes(secret))
|
|
3221
|
+
throw new Error(`${path2.basename(file)}: duplicate secret ${secret}`);
|
|
3222
|
+
secrets.push(secret);
|
|
3223
|
+
}
|
|
3224
|
+
return secrets;
|
|
3225
|
+
}
|
|
3267
3226
|
function parseReadiness(value, file, index) {
|
|
3268
3227
|
if (value === "postgres" || value === "redis") {
|
|
3269
3228
|
return { type: "command", run: value === "postgres" ? "pg_isready -h 127.0.0.1 -p {upstream}" : "redis-cli -h 127.0.0.1 -p {upstream} ping", ...value === "redis" ? { expectedOutput: "PONG" } : {}, timeoutMs: DEFAULT_READY_TIMEOUT_MS, intervalMs: DEFAULT_READY_INTERVAL_MS };
|
|
@@ -3308,22 +3267,159 @@ function parseReadiness(value, file, index) {
|
|
|
3308
3267
|
throw new Error(`${path2.basename(file)}: expectedOutput must be a string of at most 4000 characters`);
|
|
3309
3268
|
return { type, run: ready.run, ...ready.expectedOutput === undefined ? {} : { expectedOutput: ready.expectedOutput }, timeoutMs, intervalMs };
|
|
3310
3269
|
}
|
|
3270
|
+
var OVERRIDE_FIELDS = ["run", "cwd", "env", "secrets"];
|
|
3311
3271
|
function parseProfiles(raw, commands, file) {
|
|
3312
3272
|
if (!raw || typeof raw !== "object" || Array.isArray(raw) || !Array.isArray(raw.services) && !Array.isArray(raw.commands))
|
|
3313
|
-
return;
|
|
3273
|
+
return {};
|
|
3314
3274
|
const value = raw.profiles;
|
|
3315
3275
|
if (value === undefined)
|
|
3316
|
-
return;
|
|
3276
|
+
return {};
|
|
3277
|
+
const base = path2.basename(file);
|
|
3317
3278
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
3318
|
-
throw new Error(`${
|
|
3319
|
-
const
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3279
|
+
throw new Error(`${base}: profiles must map names to profile objects`);
|
|
3280
|
+
const entries = Object.entries(value);
|
|
3281
|
+
if (!entries.length)
|
|
3282
|
+
return {};
|
|
3283
|
+
for (const [name] of entries)
|
|
3284
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,40}$/.test(name))
|
|
3285
|
+
throw new Error(`${base}: invalid profile name "${name}"`);
|
|
3286
|
+
const lists = entries.filter(([, profile]) => Array.isArray(profile));
|
|
3287
|
+
if (lists.length && lists.length !== entries.length)
|
|
3288
|
+
throw new Error(`${base}: profiles cannot mix service lists and profile objects; move service lists to tags`);
|
|
3289
|
+
const byName = new Map(commands.map((command) => [command.name, command]));
|
|
3290
|
+
if (lists.length) {
|
|
3291
|
+
const legacyProfiles = Object.create(null);
|
|
3292
|
+
for (const [name, services] of lists) {
|
|
3293
|
+
if (!services.length || !services.every((service) => typeof service === "string" && byName.has(service.toLowerCase()))) {
|
|
3294
|
+
throw new Error(`${base}: profile ${name} must contain existing service names`);
|
|
3295
|
+
}
|
|
3296
|
+
legacyProfiles[name] = [...new Set(services.map((service) => service.toLowerCase()))];
|
|
3297
|
+
for (const service of legacyProfiles[name]) {
|
|
3298
|
+
const command = byName.get(service);
|
|
3299
|
+
if (!command.tags.includes(name))
|
|
3300
|
+
command.tags.push(name);
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
3303
|
+
return { legacyProfiles };
|
|
3304
|
+
}
|
|
3305
|
+
const profiles = [];
|
|
3306
|
+
for (const [name, entry] of entries) {
|
|
3307
|
+
if (!entry || typeof entry !== "object")
|
|
3308
|
+
throw new Error(`${base}: profile ${name} must be an object`);
|
|
3309
|
+
const record = entry;
|
|
3310
|
+
const unknownKey = Object.keys(record).find((key) => key !== "description" && key !== "services");
|
|
3311
|
+
if (unknownKey)
|
|
3312
|
+
throw new Error(`${base}: profile ${name} has unknown field "${unknownKey}"; use "description" and "services"`);
|
|
3313
|
+
if (record.description !== undefined && (typeof record.description !== "string" || record.description.length > 200))
|
|
3314
|
+
throw new Error(`${base}: profile ${name} description must be a string of at most 200 characters`);
|
|
3315
|
+
const services = record.services ?? {};
|
|
3316
|
+
if (!services || typeof services !== "object" || Array.isArray(services))
|
|
3317
|
+
throw new Error(`${base}: profile ${name} services must map service names to overrides`);
|
|
3318
|
+
const overrides = Object.create(null);
|
|
3319
|
+
for (const [rawService, rawOverride] of Object.entries(services)) {
|
|
3320
|
+
const service = rawService.toLowerCase();
|
|
3321
|
+
const command = byName.get(service);
|
|
3322
|
+
if (!command)
|
|
3323
|
+
throw new Error(`${base}: profile ${name} changes unknown service "${rawService}"`);
|
|
3324
|
+
if (!rawOverride || typeof rawOverride !== "object" || Array.isArray(rawOverride))
|
|
3325
|
+
throw new Error(`${base}: profile ${name} override for ${service} must be an object`);
|
|
3326
|
+
const override = rawOverride;
|
|
3327
|
+
const field = Object.keys(override).find((key) => !OVERRIDE_FIELDS.includes(key));
|
|
3328
|
+
if (field)
|
|
3329
|
+
throw new Error(`${base}: profile ${name} cannot change ${service}.${field}; profiles can change run, cwd, env and secrets`);
|
|
3330
|
+
if (override.run !== undefined && (typeof override.run !== "string" || !override.run.trim()))
|
|
3331
|
+
throw new Error(`${base}: profile ${name} ${service}.run must be a non-empty string`);
|
|
3332
|
+
if (override.cwd !== undefined && typeof override.cwd !== "string")
|
|
3333
|
+
throw new Error(`${base}: profile ${name} ${service}.cwd must be a string`);
|
|
3334
|
+
const env = override.env === undefined ? undefined : parseEnv(override.env, file);
|
|
3335
|
+
const secrets = override.secrets === undefined ? undefined : parseSecrets(override.secrets, env, file);
|
|
3336
|
+
const parsed = {
|
|
3337
|
+
...override.run === undefined ? {} : { run: override.run },
|
|
3338
|
+
...override.cwd === undefined ? {} : { cwd: override.cwd },
|
|
3339
|
+
...env ? { env } : {},
|
|
3340
|
+
...secrets ? { secrets } : {}
|
|
3341
|
+
};
|
|
3342
|
+
checkEnvSecretOverlap(mergeOverride(command, parsed).command, `profile ${name}`, file);
|
|
3343
|
+
overrides[service] = parsed;
|
|
3344
|
+
}
|
|
3345
|
+
profiles.push({ name, ...record.description ? { description: record.description } : {}, services: overrides });
|
|
3346
|
+
}
|
|
3347
|
+
return { profiles };
|
|
3348
|
+
}
|
|
3349
|
+
function mergeOverride(command, override) {
|
|
3350
|
+
const env = override.env ? { ...command.env, ...override.env } : command.env;
|
|
3351
|
+
const secrets = override.secrets ? [...new Set([...command.secrets ?? [], ...override.secrets])] : command.secrets;
|
|
3352
|
+
return {
|
|
3353
|
+
command: {
|
|
3354
|
+
...command,
|
|
3355
|
+
...override.run === undefined ? {} : { run: override.run },
|
|
3356
|
+
...override.cwd === undefined ? {} : { cwd: override.cwd },
|
|
3357
|
+
...env ? { env } : {},
|
|
3358
|
+
...secrets ? { secrets } : {}
|
|
3323
3359
|
}
|
|
3324
|
-
|
|
3360
|
+
};
|
|
3361
|
+
}
|
|
3362
|
+
function checkEnvSecretOverlap(command, source, file) {
|
|
3363
|
+
const overlap = command.secrets?.find((secret) => command.env && Object.prototype.hasOwnProperty.call(command.env, secret));
|
|
3364
|
+
if (overlap)
|
|
3365
|
+
throw new Error(`${path2.basename(file)}: ${source} makes ${overlap} both a secret and env setting for ${command.name}`);
|
|
3366
|
+
}
|
|
3367
|
+
function applyProfiles(project, active = []) {
|
|
3368
|
+
const wanted = new Set(active);
|
|
3369
|
+
const applied = (project.profiles ?? []).filter((profile) => wanted.has(profile.name));
|
|
3370
|
+
const provenance = Object.create(null);
|
|
3371
|
+
const setters = new Map;
|
|
3372
|
+
const commands = project.commands.map((base) => {
|
|
3373
|
+
const origin = {
|
|
3374
|
+
run: "base",
|
|
3375
|
+
cwd: "base",
|
|
3376
|
+
env: Object.fromEntries(Object.keys(base.env ?? {}).map((key) => [key, "base"])),
|
|
3377
|
+
secrets: Object.fromEntries((base.secrets ?? []).map((secret) => [secret, "base"]))
|
|
3378
|
+
};
|
|
3379
|
+
let command = base;
|
|
3380
|
+
for (const profile of applied) {
|
|
3381
|
+
const override = profile.services[base.name];
|
|
3382
|
+
if (!override)
|
|
3383
|
+
continue;
|
|
3384
|
+
const note = (field) => {
|
|
3385
|
+
const key = `${base.name}\x00${field}`;
|
|
3386
|
+
setters.set(key, [...setters.get(key) ?? [], profile.name]);
|
|
3387
|
+
};
|
|
3388
|
+
if (override.run !== undefined) {
|
|
3389
|
+
origin.run = profile.name;
|
|
3390
|
+
note("run");
|
|
3391
|
+
}
|
|
3392
|
+
if (override.cwd !== undefined) {
|
|
3393
|
+
origin.cwd = profile.name;
|
|
3394
|
+
note("cwd");
|
|
3395
|
+
}
|
|
3396
|
+
for (const key of Object.keys(override.env ?? {})) {
|
|
3397
|
+
origin.env[key] = profile.name;
|
|
3398
|
+
note(`env.${key}`);
|
|
3399
|
+
}
|
|
3400
|
+
for (const secret of override.secrets ?? [])
|
|
3401
|
+
if (!(secret in origin.secrets))
|
|
3402
|
+
origin.secrets[secret] = profile.name;
|
|
3403
|
+
command = mergeOverride(command, override).command;
|
|
3404
|
+
}
|
|
3405
|
+
if (command !== base)
|
|
3406
|
+
checkEnvSecretOverlap(command, `active profiles ${applied.map((profile) => profile.name).join(" + ")}`, project.file);
|
|
3407
|
+
provenance[base.name] = origin;
|
|
3408
|
+
return command;
|
|
3409
|
+
});
|
|
3410
|
+
const clashes = [];
|
|
3411
|
+
for (const [key, profiles] of setters) {
|
|
3412
|
+
if (profiles.length < 2)
|
|
3413
|
+
continue;
|
|
3414
|
+
const [service, field] = key.split("\x00");
|
|
3415
|
+
clashes.push({ service, field, profiles, winner: profiles[profiles.length - 1] });
|
|
3325
3416
|
}
|
|
3326
|
-
return
|
|
3417
|
+
return { commands, active: applied.map((profile) => profile.name), provenance, clashes };
|
|
3418
|
+
}
|
|
3419
|
+
function commandFingerprint(command) {
|
|
3420
|
+
const env = Object.entries(command.env ?? {}).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
3421
|
+
const secrets = [...command.secrets ?? []].sort();
|
|
3422
|
+
return createHash("sha256").update(JSON.stringify([command.run, command.cwd ?? "", env, secrets])).digest("hex").slice(0, 16);
|
|
3327
3423
|
}
|
|
3328
3424
|
function parseBoundedInteger(value, fallback, min, max, label, file, index) {
|
|
3329
3425
|
if (value === undefined)
|
|
@@ -3378,6 +3474,50 @@ function shellCommand(command, platform = process.platform, comspec = process.en
|
|
|
3378
3474
|
return [process.env.SHELL || "/bin/sh", "-c", command];
|
|
3379
3475
|
}
|
|
3380
3476
|
|
|
3477
|
+
// ../daemon/src/forward.ts
|
|
3478
|
+
import net from "node:net";
|
|
3479
|
+
function startForwarder(options = {}) {
|
|
3480
|
+
const listenPort = options.listenPort ?? 80;
|
|
3481
|
+
const targetPort = options.targetPort ?? 7777;
|
|
3482
|
+
if (!Number.isInteger(listenPort) || listenPort < 0 || listenPort > 65535 || !Number.isInteger(targetPort) || targetPort < 1 || targetPort > 65535)
|
|
3483
|
+
return Promise.reject(new Error("forward ports must be integers between 1 and 65535"));
|
|
3484
|
+
if (listenPort !== 0 && listenPort === targetPort)
|
|
3485
|
+
return Promise.reject(new Error("forward listen port and target port must be different"));
|
|
3486
|
+
const sockets = new Set;
|
|
3487
|
+
const server = net.createServer((client) => {
|
|
3488
|
+
sockets.add(client);
|
|
3489
|
+
client.once("close", () => sockets.delete(client));
|
|
3490
|
+
const upstream = net.connect(targetPort, "127.0.0.1");
|
|
3491
|
+
sockets.add(upstream);
|
|
3492
|
+
upstream.once("close", () => sockets.delete(upstream));
|
|
3493
|
+
client.pipe(upstream);
|
|
3494
|
+
upstream.pipe(client);
|
|
3495
|
+
const destroy = () => {
|
|
3496
|
+
client.destroy();
|
|
3497
|
+
upstream.destroy();
|
|
3498
|
+
};
|
|
3499
|
+
client.once("error", destroy);
|
|
3500
|
+
upstream.once("error", destroy);
|
|
3501
|
+
client.once("close", destroy);
|
|
3502
|
+
upstream.once("close", destroy);
|
|
3503
|
+
});
|
|
3504
|
+
return new Promise((resolve, reject) => {
|
|
3505
|
+
const onError = (error) => {
|
|
3506
|
+
server.removeAllListeners();
|
|
3507
|
+
reject(new Error(error.code === "EADDRINUSE" ? `forwarder could not listen on 127.0.0.1:${listenPort}: address already in use` : error.code === "EACCES" || error.code === "EPERM" ? `forwarder cannot listen on port ${listenPort}: permission denied (choose another port; do not run LocalDeck as root)` : `forwarder failed: ${error.message}`));
|
|
3508
|
+
};
|
|
3509
|
+
server.once("error", onError);
|
|
3510
|
+
server.listen(listenPort, "127.0.0.1", () => {
|
|
3511
|
+
server.off("error", onError);
|
|
3512
|
+
resolve({ port: server.address().port, close: () => new Promise((done) => {
|
|
3513
|
+
for (const socket of sockets)
|
|
3514
|
+
socket.destroy();
|
|
3515
|
+
server.close(() => done());
|
|
3516
|
+
}) });
|
|
3517
|
+
});
|
|
3518
|
+
});
|
|
3519
|
+
}
|
|
3520
|
+
|
|
3381
3521
|
// ../daemon/src/dependencies.ts
|
|
3382
3522
|
function startupLayers(commands, selectedNames) {
|
|
3383
3523
|
const byName = new Map(commands.map((command) => [command.name, command]));
|
|
@@ -3414,902 +3554,1633 @@ function stopOrder(commands) {
|
|
|
3414
3554
|
}
|
|
3415
3555
|
|
|
3416
3556
|
// ../daemon/src/index.ts
|
|
3417
|
-
import
|
|
3557
|
+
import fs14 from "node:fs";
|
|
3418
3558
|
import crypto2 from "node:crypto";
|
|
3419
|
-
import
|
|
3559
|
+
import path15 from "node:path";
|
|
3420
3560
|
|
|
3421
|
-
// ../daemon/src/api.ts
|
|
3422
|
-
import
|
|
3423
|
-
import { timingSafeEqual, randomBytes, randomUUID } from "node:crypto";
|
|
3561
|
+
// ../daemon/src/debugging-api.ts
|
|
3562
|
+
import path8 from "node:path";
|
|
3424
3563
|
|
|
3425
|
-
// ../daemon/src/
|
|
3564
|
+
// ../daemon/src/worktrees.ts
|
|
3565
|
+
import fs4 from "node:fs";
|
|
3566
|
+
import path4 from "node:path";
|
|
3567
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
3568
|
+
|
|
3569
|
+
// ../daemon/src/projects.ts
|
|
3570
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
3426
3571
|
import fs3 from "node:fs";
|
|
3427
|
-
import
|
|
3572
|
+
import { execFileSync } from "node:child_process";
|
|
3428
3573
|
import path3 from "node:path";
|
|
3429
3574
|
|
|
3430
|
-
// ../daemon/src/
|
|
3431
|
-
import
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
$ownerPath = 'HKLM:\SOFTWARE\LocalDeck\Port80'
|
|
3439
|
-
$rulePath = 'HKLM:\SYSTEM\CurrentControlSet\Services\PortProxy\v4tov4\tcp'
|
|
3440
|
-
$ruleName = '127.0.0.1/80'
|
|
3441
|
-
$marker = 'LocalDeck loopback portproxy v1'
|
|
3442
|
-
$netsh = Join-Path $env:SystemRoot 'System32\netsh.exe'
|
|
3443
|
-
$owner = Get-ItemProperty -LiteralPath $ownerPath -ErrorAction SilentlyContinue
|
|
3444
|
-
$rule = if (Test-Path -LiteralPath $rulePath) { (Get-Item -LiteralPath $rulePath).GetValue($ruleName) } else { $null }
|
|
3445
|
-
$owned = $owner -and $owner.Marker -eq $marker -and $owner.TargetPort -match '^\d+$' -and [int]$owner.TargetPort -ge 1 -and [int]$owner.TargetPort -le 65535 -and [int]$owner.TargetPort -ne 80
|
|
3446
|
-
if ($owner -and !$owned) { throw 'Refusing to change an unrecognized LocalDeck ownership record.' }
|
|
3447
|
-
if ($rule -and (!$owned -or $rule -ne ('127.0.0.1/' + $owner.TargetPort))) { throw 'Port 80 has an unmanaged forwarding rule. It was left unchanged.' }
|
|
3448
|
-
${remove ? String.raw`if ($rule) {
|
|
3449
|
-
& $netsh interface portproxy delete v4tov4 listenaddress=127.0.0.1 listenport=80 protocol=tcp | Out-Null
|
|
3450
|
-
if ($LASTEXITCODE -ne 0) { throw 'Could not remove the LocalDeck forwarding rule.' }
|
|
3451
|
-
}
|
|
3452
|
-
if ($owned) { Remove-Item -LiteralPath $ownerPath }
|
|
3453
|
-
` : String.raw`if (!$rule) {
|
|
3454
|
-
$listeners = @(Get-NetTCPConnection -ErrorAction Stop | Where-Object { $_.State -eq 'Listen' -and $_.LocalPort -eq 80 -and $_.LocalAddress -in @('127.0.0.1','0.0.0.0','::','::1') })
|
|
3455
|
-
if ($listeners.Count) { throw 'Port 80 is already in use. The existing listener was left unchanged.' }
|
|
3575
|
+
// ../daemon/src/ports.ts
|
|
3576
|
+
import net2 from "node:net";
|
|
3577
|
+
function canBind(port, host, required) {
|
|
3578
|
+
return new Promise((resolve) => {
|
|
3579
|
+
const srv = net2.createServer();
|
|
3580
|
+
srv.once("error", (error) => resolve(!required && error.code !== "EADDRINUSE" && error.code !== "EACCES" ? undefined : false));
|
|
3581
|
+
srv.listen({ port, host, exclusive: true }, () => srv.close(() => resolve(true)));
|
|
3582
|
+
});
|
|
3456
3583
|
}
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
if (
|
|
3460
|
-
|
|
3461
|
-
$oldPort = if ($owned) { $owner.TargetPort } else { $null }
|
|
3462
|
-
try {
|
|
3463
|
-
New-Item -Path $ownerPath -Force | Out-Null
|
|
3464
|
-
New-ItemProperty -LiteralPath $ownerPath -Name Marker -Value $marker -PropertyType String -Force | Out-Null
|
|
3465
|
-
New-ItemProperty -LiteralPath $ownerPath -Name TargetPort -Value '${port}' -PropertyType String -Force | Out-Null
|
|
3466
|
-
& $netsh interface portproxy add v4tov4 listenaddress=127.0.0.1 listenport=80 connectaddress=127.0.0.1 connectport=${port} protocol=tcp | Out-Null
|
|
3467
|
-
if ($LASTEXITCODE -ne 0) { throw 'Windows could not install the forwarding rule.' }
|
|
3468
|
-
$installed = (Get-Item -LiteralPath $rulePath).GetValue($ruleName)
|
|
3469
|
-
if ($installed -ne '127.0.0.1/${port}') { throw 'The installed forwarding rule did not match the requested destination.' }
|
|
3470
|
-
} catch {
|
|
3471
|
-
if ($oldRule) {
|
|
3472
|
-
& $netsh interface portproxy add v4tov4 listenaddress=127.0.0.1 listenport=80 connectaddress=127.0.0.1 "connectport=$oldPort" protocol=tcp | Out-Null
|
|
3473
|
-
if ($LASTEXITCODE -eq 0) { Set-ItemProperty -LiteralPath $ownerPath -Name TargetPort -Value $oldPort }
|
|
3474
|
-
} else {
|
|
3475
|
-
& $netsh interface portproxy delete v4tov4 listenaddress=127.0.0.1 listenport=80 protocol=tcp | Out-Null
|
|
3476
|
-
if ($LASTEXITCODE -eq 0) { Remove-Item -LiteralPath $ownerPath }
|
|
3584
|
+
async function isPortFree(port, host) {
|
|
3585
|
+
for (const candidate of host ? [host] : ["127.0.0.1", "::1", "0.0.0.0", "::"]) {
|
|
3586
|
+
if (await canBind(port, candidate, candidate === (host ?? "127.0.0.1")) === false)
|
|
3587
|
+
return false;
|
|
3477
3588
|
}
|
|
3478
|
-
|
|
3589
|
+
return true;
|
|
3479
3590
|
}
|
|
3480
|
-
|
|
3591
|
+
function getFreePort(host = "127.0.0.1") {
|
|
3592
|
+
return new Promise((resolve, reject) => {
|
|
3593
|
+
const srv = net2.createServer();
|
|
3594
|
+
srv.once("error", reject);
|
|
3595
|
+
srv.listen(0, host, () => {
|
|
3596
|
+
const { port } = srv.address();
|
|
3597
|
+
srv.close(() => resolve(port));
|
|
3598
|
+
});
|
|
3599
|
+
});
|
|
3481
3600
|
}
|
|
3482
|
-
function
|
|
3483
|
-
const
|
|
3484
|
-
|
|
3485
|
-
exit 0
|
|
3486
|
-
} catch { Write-Error $_ -ErrorAction Continue; exit 1 }`;
|
|
3487
|
-
const encoded = Buffer.from(script, "utf16le").toString("base64");
|
|
3488
|
-
return `$ErrorActionPreference = 'Stop'; $exe = Join-Path $env:SystemRoot 'System32\\WindowsPowerShell\\v1.0\\powershell.exe'; $child = Start-Process -FilePath $exe -Verb RunAs -ArgumentList '-NoLogo -NoProfile -NonInteractive -EncodedCommand ${encoded}' -PassThru -Wait; if ($child.ExitCode -ne 0) { throw 'Windows administrator setup failed.' }`;
|
|
3601
|
+
function serviceName(key) {
|
|
3602
|
+
const separator = key.lastIndexOf(":");
|
|
3603
|
+
return separator === -1 ? key : key.slice(separator + 1);
|
|
3489
3604
|
}
|
|
3490
|
-
|
|
3491
|
-
const
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3605
|
+
function rememberedPortFromFile(key, file) {
|
|
3606
|
+
const map = readJson(file, {});
|
|
3607
|
+
if (map[key] !== undefined)
|
|
3608
|
+
return map[key];
|
|
3609
|
+
const name = serviceName(key);
|
|
3610
|
+
const hasOtherQualifiedKey = Object.keys(map).some((candidate) => candidate !== key && candidate.includes(":") && candidate.endsWith(`:${name}`));
|
|
3611
|
+
return hasOtherQualifiedKey ? undefined : map[name];
|
|
3612
|
+
}
|
|
3613
|
+
function rememberPort(name, port, file = PORTS_FILE) {
|
|
3614
|
+
const map = readJson(file, {});
|
|
3615
|
+
map[name] = port;
|
|
3616
|
+
writeJson(file, map);
|
|
3617
|
+
}
|
|
3618
|
+
function forgetProjectPorts(projectId, file = PORTS_FILE) {
|
|
3619
|
+
const map = readJson(file, {});
|
|
3620
|
+
const kept = Object.fromEntries(Object.entries(map).filter(([key]) => !key.startsWith(`${projectId}:`)));
|
|
3621
|
+
if (Object.keys(kept).length !== Object.keys(map).length)
|
|
3622
|
+
writeJson(file, kept);
|
|
3623
|
+
}
|
|
3624
|
+
async function pickStablePort(name, taken, start = 4000, end = 4999, file = PORTS_FILE) {
|
|
3625
|
+
const map = readJson(file, {});
|
|
3626
|
+
const remembered = rememberedPortFromFile(name, file);
|
|
3627
|
+
if (remembered) {
|
|
3628
|
+
if (taken.has(remembered) || !await isPortFree(remembered)) {
|
|
3629
|
+
throw new Error(`remembered stable port ${remembered} for "${name}" is already in use; stop the conflicting listener or explicitly choose a different port`);
|
|
3630
|
+
}
|
|
3631
|
+
return remembered;
|
|
3496
3632
|
}
|
|
3633
|
+
const bareName = serviceName(name);
|
|
3634
|
+
const usingLegacy = map[name] === undefined && remembered === map[bareName];
|
|
3635
|
+
const reserved = new Set(Object.entries(map).filter(([n]) => n !== name && !(usingLegacy && n === bareName)).map(([, p]) => p));
|
|
3636
|
+
for (let p = start;p <= end; p++) {
|
|
3637
|
+
if (taken.has(p) || reserved.has(p))
|
|
3638
|
+
continue;
|
|
3639
|
+
if (await isPortFree(p))
|
|
3640
|
+
return p;
|
|
3641
|
+
}
|
|
3642
|
+
throw new Error(`no free port between ${start} and ${end}`);
|
|
3497
3643
|
}
|
|
3498
3644
|
|
|
3499
|
-
// ../daemon/src/
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
var SYSTEM_FORWARDER_PLIST = `/Library/LaunchDaemons/${label}.plist`;
|
|
3506
|
-
var destination = SYSTEM_FORWARDER_PLIST;
|
|
3507
|
-
var marker = "LocalDeck managed loopback relay v1";
|
|
3508
|
-
function forwarderPlist(user, port) {
|
|
3509
|
-
if (!/^[a-zA-Z_][a-zA-Z0-9_.-]*$/.test(user) || user === "root")
|
|
3510
|
-
throw new Error("Forwarding must run as a regular macOS user.");
|
|
3511
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535 || port === 80)
|
|
3512
|
-
throw new Error("Invalid forwarding target port.");
|
|
3513
|
-
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
3514
|
-
<!-- ${marker} -->
|
|
3515
|
-
<plist version="1.0"><dict>
|
|
3516
|
-
<key>Label</key><string>${label}</string>
|
|
3517
|
-
<key>UserName</key><string>${user}</string>
|
|
3518
|
-
<key>ProgramArguments</key><array><string>/usr/bin/nc</string><string>127.0.0.1</string><string>${port}</string></array>
|
|
3519
|
-
<key>inetdCompatibility</key><dict><key>Wait</key><false/></dict>
|
|
3520
|
-
<key>Sockets</key><dict><key>Listeners</key><dict>
|
|
3521
|
-
<key>SockNodeName</key><string>127.0.0.1</string>
|
|
3522
|
-
<key>SockServiceName</key><string>80</string>
|
|
3523
|
-
<key>SockFamily</key><string>IPv4</string>
|
|
3524
|
-
<key>SockType</key><string>stream</string>
|
|
3525
|
-
<key>SockProtocol</key><string>TCP</string>
|
|
3526
|
-
</dict></dict>
|
|
3527
|
-
</dict></plist>`;
|
|
3528
|
-
}
|
|
3529
|
-
function systemForwarderCommand(user, port, remove = false) {
|
|
3530
|
-
const plist = forwarderPlist(user, port);
|
|
3531
|
-
const guard = `set -eu
|
|
3532
|
-
if [ -L '${destination}' ]; then exit 1; fi
|
|
3533
|
-
if [ -e '${destination}' ] && ! /usr/bin/grep -q '${marker}' '${destination}'; then
|
|
3534
|
-
echo 'Refusing to replace an unmanaged forwarding service.' >&2; exit 1
|
|
3535
|
-
fi`;
|
|
3536
|
-
if (remove)
|
|
3537
|
-
return `${guard}
|
|
3538
|
-
/bin/launchctl bootout system/${label} 2>/dev/null || true
|
|
3539
|
-
/bin/rm -f '${destination}'`;
|
|
3540
|
-
return `${guard}
|
|
3541
|
-
relay_file=$(/usr/bin/mktemp /Library/LaunchDaemons/.localdeck-port80.XXXXXX)
|
|
3542
|
-
trap '/bin/rm -f "$relay_file" "$relay_file.backup"' EXIT
|
|
3543
|
-
/bin/cat > "$relay_file" <<'LOCALDECK_PLIST'
|
|
3544
|
-
${plist}
|
|
3545
|
-
LOCALDECK_PLIST
|
|
3546
|
-
/usr/bin/plutil -lint "$relay_file" >/dev/null
|
|
3547
|
-
/usr/sbin/chown root:wheel "$relay_file"
|
|
3548
|
-
/bin/chmod 644 "$relay_file"
|
|
3549
|
-
if [ -f '${destination}' ]; then /bin/cp -p '${destination}' "$relay_file.backup"; fi
|
|
3550
|
-
/bin/launchctl bootout system/${label} 2>/dev/null || true
|
|
3551
|
-
/bin/mv "$relay_file" '${destination}'
|
|
3552
|
-
if ! /bin/launchctl bootstrap system '${destination}'; then
|
|
3553
|
-
/bin/rm -f '${destination}'
|
|
3554
|
-
if [ -f "$relay_file.backup" ]; then
|
|
3555
|
-
/bin/mv "$relay_file.backup" '${destination}'
|
|
3556
|
-
/bin/launchctl bootstrap system '${destination}' || true
|
|
3557
|
-
fi
|
|
3558
|
-
exit 1
|
|
3559
|
-
fi`;
|
|
3560
|
-
}
|
|
3561
|
-
async function configureSystemForwarder(port, remove = false) {
|
|
3562
|
-
if (process.platform === "win32")
|
|
3563
|
-
return configureWindowsForwarder(port, remove);
|
|
3564
|
-
if (process.platform !== "darwin")
|
|
3565
|
-
throw new Error("Automatic administrator setup is available on macOS and Windows.");
|
|
3566
|
-
const command = systemForwarderCommand(os2.userInfo().username, port, remove);
|
|
3567
|
-
try {
|
|
3568
|
-
await execute2("/usr/bin/osascript", ["-e", `on run argv
|
|
3569
|
-
do shell script (item 1 of argv) with administrator privileges
|
|
3570
|
-
end run`, command], { timeout: 120000, maxBuffer: 8192 });
|
|
3571
|
-
} catch {
|
|
3572
|
-
throw new Error("Administrator setup was cancelled or failed. Continuing with working port-number links; retry in Settings whenever you like.");
|
|
3645
|
+
// ../daemon/src/projects.ts
|
|
3646
|
+
class ProjectError extends Error {
|
|
3647
|
+
code;
|
|
3648
|
+
constructor(code, message) {
|
|
3649
|
+
super(message);
|
|
3650
|
+
this.code = code;
|
|
3573
3651
|
}
|
|
3574
3652
|
}
|
|
3575
3653
|
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
enabling;
|
|
3587
|
-
closed = false;
|
|
3588
|
-
constructor(targetPort, identity, port = 80, platform = process.platform, preferenceFile = path3.join(HOME_DIR, "forwarder.json"), configureSystem = configureSystemForwarder, systemRelayInstalled = () => fs3.existsSync(SYSTEM_FORWARDER_PLIST)) {
|
|
3589
|
-
this.targetPort = targetPort;
|
|
3590
|
-
this.identity = identity;
|
|
3591
|
-
this.port = port;
|
|
3592
|
-
this.platform = platform;
|
|
3593
|
-
this.preferenceFile = preferenceFile;
|
|
3594
|
-
this.configureSystem = configureSystem;
|
|
3595
|
-
this.systemRelayInstalled = systemRelayInstalled;
|
|
3654
|
+
class ProjectRegistry {
|
|
3655
|
+
catalogFile;
|
|
3656
|
+
portsFile;
|
|
3657
|
+
now;
|
|
3658
|
+
projects;
|
|
3659
|
+
constructor({ catalogFile = PROJECTS_FILE, portsFile = PORTS_FILE, now = Date.now } = {}) {
|
|
3660
|
+
this.catalogFile = catalogFile;
|
|
3661
|
+
this.portsFile = portsFile;
|
|
3662
|
+
this.now = now;
|
|
3663
|
+
this.projects = this.readCatalog();
|
|
3596
3664
|
}
|
|
3597
|
-
|
|
3598
|
-
const
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3665
|
+
remember(file, now = this.now()) {
|
|
3666
|
+
const realFile = fs3.realpathSync(file);
|
|
3667
|
+
const config = loadProjectFile(realFile);
|
|
3668
|
+
const id = projectId(realFile);
|
|
3669
|
+
const name = path3.basename(path3.dirname(realFile));
|
|
3670
|
+
const directory = path3.dirname(realFile);
|
|
3671
|
+
const existing = this.projects.find((entry) => entry.id === id);
|
|
3672
|
+
const alias = config.nickname ? this.configAlias(id, realFile, config.nickname) : undefined;
|
|
3673
|
+
if (existing) {
|
|
3674
|
+
existing.name = name;
|
|
3675
|
+
existing.file = realFile;
|
|
3676
|
+
existing.directory = directory;
|
|
3677
|
+
existing.lastUsedAt = now;
|
|
3678
|
+
existing.configNickname = config.nickname;
|
|
3679
|
+
if (alias) {
|
|
3680
|
+
existing.sharedConfigNickname = true;
|
|
3681
|
+
if (!existing.nickname)
|
|
3682
|
+
existing.nickname = alias;
|
|
3683
|
+
}
|
|
3684
|
+
} else {
|
|
3685
|
+
this.projects.push({
|
|
3686
|
+
id,
|
|
3687
|
+
name,
|
|
3688
|
+
slug: this.assignSlug(name, id),
|
|
3689
|
+
file: realFile,
|
|
3690
|
+
directory,
|
|
3691
|
+
lastUsedAt: now,
|
|
3692
|
+
...config.nickname ? { configNickname: config.nickname } : {},
|
|
3693
|
+
...alias ? { nickname: alias, sharedConfigNickname: true } : {}
|
|
3608
3694
|
});
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
}, 1500);
|
|
3613
|
-
request.on("close", () => clearTimeout(timer));
|
|
3614
|
-
request.on("error", () => resolve(false));
|
|
3615
|
-
});
|
|
3616
|
-
return { ready: ready && !this.closed };
|
|
3695
|
+
}
|
|
3696
|
+
this.persist();
|
|
3697
|
+
return this.toSummary(this.byId(id));
|
|
3617
3698
|
}
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
return this.enabling;
|
|
3621
|
-
this.enabling = this.start(options).finally(() => {
|
|
3622
|
-
this.enabling = undefined;
|
|
3623
|
-
});
|
|
3624
|
-
return this.enabling;
|
|
3699
|
+
list() {
|
|
3700
|
+
return this.sorted().map((project) => this.toSummary(project));
|
|
3625
3701
|
}
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
const preference = this.preferenceFile;
|
|
3630
|
-
const saved = readJson(preference, {});
|
|
3631
|
-
if (saved.removed) {
|
|
3632
|
-
if (options.automatic)
|
|
3633
|
-
return { ready: false, error: "Clean URLs are turned off; enable them in the dashboard to use them again." };
|
|
3634
|
-
writeJson(preference, { declined: saved.declined });
|
|
3635
|
-
}
|
|
3636
|
-
if (this.platform === "win32" && options.install && this.port === 80 && !(options.automatic && readJson(preference, {}).declined)) {
|
|
3637
|
-
if (!this.bridge && (await this.status()).ready)
|
|
3638
|
-
return { ready: true };
|
|
3639
|
-
if (this.bridge) {
|
|
3640
|
-
await this.bridge.close();
|
|
3641
|
-
this.bridge = undefined;
|
|
3642
|
-
}
|
|
3643
|
-
try {
|
|
3644
|
-
writeJson(preference, { declined: true });
|
|
3645
|
-
await this.configureSystem(this.targetPort());
|
|
3646
|
-
let ready = false;
|
|
3647
|
-
for (let attempt = 0;attempt < 20 && !this.closed; attempt++) {
|
|
3648
|
-
if ((await this.status()).ready) {
|
|
3649
|
-
ready = true;
|
|
3650
|
-
break;
|
|
3651
|
-
}
|
|
3652
|
-
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
3653
|
-
}
|
|
3654
|
-
if (!ready)
|
|
3655
|
-
throw new Error("Windows setup completed but forwarding could not be verified. Port-number links remain available; retry in Settings.");
|
|
3656
|
-
writeJson(preference, { declined: false });
|
|
3657
|
-
return { ready: true };
|
|
3658
|
-
} catch (error) {
|
|
3659
|
-
return { ready: false, error: error instanceof Error ? error.message : "Windows setup failed." };
|
|
3660
|
-
}
|
|
3661
|
-
}
|
|
3662
|
-
if ((await this.status()).ready)
|
|
3663
|
-
return { ready: true };
|
|
3702
|
+
details(projectId, profiles) {
|
|
3703
|
+
const project = this.require(projectId);
|
|
3704
|
+
let loaded;
|
|
3664
3705
|
try {
|
|
3665
|
-
|
|
3666
|
-
return { ready: false, error: "LocalDeck is shutting down." };
|
|
3667
|
-
this.bridge ??= await startForwarder({ listenPort: this.port, targetPort: this.targetPort() });
|
|
3668
|
-
if (this.closed) {
|
|
3669
|
-
await this.bridge.close();
|
|
3670
|
-
this.bridge = undefined;
|
|
3671
|
-
return { ready: false, error: "LocalDeck is shutting down." };
|
|
3672
|
-
}
|
|
3673
|
-
if ((await this.status()).ready)
|
|
3674
|
-
return { ready: true };
|
|
3675
|
-
return { ready: false, error: "Port 80 does not reach this LocalDeck instance. Your existing links are unchanged." };
|
|
3706
|
+
loaded = this.load(project);
|
|
3676
3707
|
} catch (error) {
|
|
3677
|
-
|
|
3678
|
-
const preference2 = this.preferenceFile;
|
|
3679
|
-
if (options.install && this.port === 80 && this.platform === "darwin" && message.includes("permission denied") && !(options.automatic && readJson(preference2, {}).declined)) {
|
|
3680
|
-
try {
|
|
3681
|
-
writeJson(preference2, { declined: true });
|
|
3682
|
-
await this.configureSystem(this.targetPort());
|
|
3683
|
-
const result = await this.status();
|
|
3684
|
-
if (!result.ready)
|
|
3685
|
-
throw new Error("Setup completed but forwarding could not be verified. Your original links are unchanged.");
|
|
3686
|
-
writeJson(preference2, { declined: false });
|
|
3687
|
-
return result;
|
|
3688
|
-
} catch (setupError) {
|
|
3689
|
-
return { ready: false, error: setupError instanceof Error ? setupError.message : "Administrator setup failed." };
|
|
3690
|
-
}
|
|
3691
|
-
}
|
|
3692
|
-
return { ready: false, error: message };
|
|
3708
|
+
return { ...project, available: false, error: errorMessage(error), commands: [] };
|
|
3693
3709
|
}
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
3697
|
-
|
|
3710
|
+
const wanted = profiles ?? project.activeProfiles ?? [];
|
|
3711
|
+
const { activeProfiles: _stored, ...remembered } = project;
|
|
3712
|
+
const base = { ...remembered, nickname: this.nickname(projectId), available: true, ...loaded.legacyProfiles ? { legacyProfiles: loaded.legacyProfiles } : {} };
|
|
3713
|
+
const summaries = (active) => loaded.profiles?.length ? { profiles: loaded.profiles.map((profile) => ({ name: profile.name, ...profile.description ? { description: profile.description } : {}, services: Object.keys(profile.services), active: active.includes(profile.name) })) } : {};
|
|
3698
3714
|
try {
|
|
3699
|
-
const
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3715
|
+
const resolved = applyProfiles(loaded, wanted);
|
|
3716
|
+
return {
|
|
3717
|
+
...base,
|
|
3718
|
+
...resolved.active.length ? { activeProfiles: resolved.active } : {},
|
|
3719
|
+
commands: resolved.commands,
|
|
3720
|
+
...summaries(resolved.active),
|
|
3721
|
+
...resolved.clashes.length ? { profileClashes: resolved.clashes } : {},
|
|
3722
|
+
provenance: resolved.provenance,
|
|
3723
|
+
fingerprints: Object.fromEntries(resolved.commands.map((command) => [command.name, commandFingerprint(command)]))
|
|
3724
|
+
};
|
|
3708
3725
|
} catch (error) {
|
|
3709
|
-
|
|
3726
|
+
const active = (loaded.profiles ?? []).map((profile) => profile.name).filter((name) => wanted.includes(name));
|
|
3727
|
+
return { ...base, ...active.length ? { activeProfiles: active } : {}, commands: loaded.commands, ...summaries(active), profileError: errorMessage(error) };
|
|
3710
3728
|
}
|
|
3711
3729
|
}
|
|
3712
|
-
|
|
3713
|
-
this.
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3730
|
+
resolve(projectId, serviceName2, profiles) {
|
|
3731
|
+
const details = this.details(projectId, profiles);
|
|
3732
|
+
if (!details.available)
|
|
3733
|
+
throw new ProjectError("invalid", details.error);
|
|
3734
|
+
if (details.profileError)
|
|
3735
|
+
throw new ProjectError("invalid", details.profileError);
|
|
3736
|
+
const command = details.commands.find((candidate) => candidate.name === serviceName2.toLowerCase());
|
|
3737
|
+
if (!command)
|
|
3738
|
+
throw new ProjectError("not_found", `Project ${projectId} has no service named ${serviceName2}`);
|
|
3739
|
+
return command;
|
|
3740
|
+
}
|
|
3741
|
+
forget(projectId, activeKeys) {
|
|
3742
|
+
this.require(projectId);
|
|
3743
|
+
if ([...activeKeys].some((key) => key.startsWith(`${projectId}:`))) {
|
|
3744
|
+
throw new ProjectError("active", "stop its services before forgetting it");
|
|
3718
3745
|
}
|
|
3746
|
+
this.projects = this.projects.filter((project) => project.id !== projectId);
|
|
3747
|
+
this.persist();
|
|
3748
|
+
forgetProjectPorts(projectId, this.portsFile);
|
|
3719
3749
|
}
|
|
3720
|
-
|
|
3721
|
-
|
|
3722
|
-
// ../daemon/src/api.ts
|
|
3723
|
-
import fs7 from "node:fs";
|
|
3724
|
-
import path7 from "node:path";
|
|
3725
|
-
|
|
3726
|
-
// ../daemon/src/request-body.ts
|
|
3727
|
-
import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
|
|
3728
|
-
var MAX_BODY_CAPTURE_BYTES = 16384;
|
|
3729
|
-
|
|
3730
|
-
class BodyCapture {
|
|
3731
|
-
limit;
|
|
3732
|
-
chunks = [];
|
|
3733
|
-
retained = 0;
|
|
3734
|
-
bytes = 0;
|
|
3735
|
-
tailUsed = 0;
|
|
3736
|
-
constructor(limit = MAX_BODY_CAPTURE_BYTES) {
|
|
3737
|
-
this.limit = limit;
|
|
3750
|
+
refresh(projectId) {
|
|
3751
|
+
return this.details(projectId);
|
|
3738
3752
|
}
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3753
|
+
nickname(projectId) {
|
|
3754
|
+
const project = this.byId(projectId);
|
|
3755
|
+
return project?.nickname ?? project?.configNickname;
|
|
3756
|
+
}
|
|
3757
|
+
updateNickname(projectId, value) {
|
|
3758
|
+
const project = this.require(projectId);
|
|
3759
|
+
const nickname = value.trim().toLowerCase();
|
|
3760
|
+
if (nickname && (!validNickname(nickname) || nickname === "standalone")) {
|
|
3761
|
+
throw new ProjectError("invalid", "Use 1–63 letters, numbers or hyphens, with no leading or trailing hyphen. 'standalone' is reserved.");
|
|
3762
|
+
}
|
|
3763
|
+
if (nickname)
|
|
3764
|
+
this.assertNicknameAvailable(projectId, nickname);
|
|
3765
|
+
const previous = project.nickname;
|
|
3766
|
+
if (nickname)
|
|
3767
|
+
project.nickname = nickname;
|
|
3768
|
+
else
|
|
3769
|
+
delete project.nickname;
|
|
3770
|
+
try {
|
|
3771
|
+
this.persist();
|
|
3772
|
+
} catch (error) {
|
|
3773
|
+
if (previous === undefined)
|
|
3774
|
+
delete project.nickname;
|
|
3775
|
+
else
|
|
3776
|
+
project.nickname = previous;
|
|
3777
|
+
throw error;
|
|
3761
3778
|
}
|
|
3779
|
+
return this.details(projectId);
|
|
3762
3780
|
}
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
} catch {}
|
|
3781
|
+
saveConfigNickname(projectId, value) {
|
|
3782
|
+
const project = this.require(projectId);
|
|
3783
|
+
const nickname = value.trim().toLowerCase();
|
|
3784
|
+
if (nickname && (!validNickname(nickname) || nickname === "standalone"))
|
|
3785
|
+
throw new ProjectError("invalid", "Use 1–63 letters, numbers or interior hyphens; standalone is reserved.");
|
|
3786
|
+
if (nickname)
|
|
3787
|
+
this.assertNicknameAvailable(projectId, nickname);
|
|
3788
|
+
try {
|
|
3789
|
+
updateProjectNickname(project.file, nickname);
|
|
3790
|
+
} catch (error) {
|
|
3791
|
+
throw new ProjectError("invalid", errorMessage(error));
|
|
3775
3792
|
}
|
|
3776
|
-
|
|
3777
|
-
|
|
3793
|
+
project.configNickname = nickname || undefined;
|
|
3794
|
+
return this.updateNickname(projectId, "");
|
|
3795
|
+
}
|
|
3796
|
+
setActiveProfiles(projectId, names) {
|
|
3797
|
+
const project = this.require(projectId);
|
|
3798
|
+
if (!Array.isArray(names) || !names.every((name) => typeof name === "string"))
|
|
3799
|
+
throw new ProjectError("invalid", "profiles must be an array of profile names");
|
|
3800
|
+
let defined;
|
|
3778
3801
|
try {
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
|
|
3802
|
+
defined = (this.load(project).profiles ?? []).map((profile) => profile.name);
|
|
3803
|
+
} catch (error) {
|
|
3804
|
+
throw new ProjectError("invalid", errorMessage(error));
|
|
3805
|
+
}
|
|
3806
|
+
const requested = names.map((name) => name.toLowerCase());
|
|
3807
|
+
const unknown = requested.filter((name) => !defined.includes(name));
|
|
3808
|
+
if (unknown.length)
|
|
3809
|
+
throw new ProjectError("invalid", `Unknown profile${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")}. Available profiles: ${defined.join(", ") || "(none)"}`);
|
|
3810
|
+
const previous = project.activeProfiles;
|
|
3811
|
+
const active = defined.filter((name) => requested.includes(name));
|
|
3812
|
+
if (active.length)
|
|
3813
|
+
project.activeProfiles = active;
|
|
3814
|
+
else
|
|
3815
|
+
delete project.activeProfiles;
|
|
3816
|
+
try {
|
|
3817
|
+
this.persist();
|
|
3818
|
+
} catch (error) {
|
|
3819
|
+
if (previous === undefined)
|
|
3820
|
+
delete project.activeProfiles;
|
|
3821
|
+
else
|
|
3822
|
+
project.activeProfiles = previous;
|
|
3823
|
+
throw error;
|
|
3824
|
+
}
|
|
3825
|
+
return this.details(projectId);
|
|
3826
|
+
}
|
|
3827
|
+
updateDependencies(projectId, serviceName2, after) {
|
|
3828
|
+
const project = this.require(projectId);
|
|
3829
|
+
try {
|
|
3830
|
+
updateProjectDependencies(project.file, serviceName2, after);
|
|
3831
|
+
return this.details(projectId);
|
|
3832
|
+
} catch (error) {
|
|
3833
|
+
throw new ProjectError("invalid", errorMessage(error));
|
|
3834
|
+
}
|
|
3835
|
+
}
|
|
3836
|
+
updateServiceConfig(projectId, serviceName2, settings) {
|
|
3837
|
+
const project = this.require(projectId);
|
|
3838
|
+
try {
|
|
3839
|
+
updateProjectServiceConfig(project.file, serviceName2, settings);
|
|
3840
|
+
return this.details(projectId);
|
|
3841
|
+
} catch (error) {
|
|
3842
|
+
throw new ProjectError("invalid", errorMessage(error));
|
|
3843
|
+
}
|
|
3844
|
+
}
|
|
3845
|
+
readCatalog() {
|
|
3846
|
+
if (!fs3.existsSync(this.catalogFile))
|
|
3847
|
+
return [];
|
|
3848
|
+
try {
|
|
3849
|
+
const parsed = JSON.parse(fs3.readFileSync(this.catalogFile, "utf8"));
|
|
3850
|
+
if (!isCatalog(parsed))
|
|
3851
|
+
throw new Error("invalid project catalog");
|
|
3852
|
+
return parsed.projects;
|
|
3782
3853
|
} catch {
|
|
3783
|
-
|
|
3784
|
-
|
|
3854
|
+
fs3.renameSync(this.catalogFile, this.nextCorruptFile());
|
|
3855
|
+
return [];
|
|
3785
3856
|
}
|
|
3786
|
-
|
|
3857
|
+
}
|
|
3858
|
+
assertNicknameAvailable(projectId, nickname) {
|
|
3859
|
+
if (this.projects.some((other) => other.id !== projectId && (other.slug === nickname || (other.nickname ?? other.configNickname) === nickname || !other.sharedConfigNickname && other.configNickname === nickname))) {
|
|
3860
|
+
throw new ProjectError("invalid", "That project nickname is already taken. Choose another.");
|
|
3861
|
+
}
|
|
3862
|
+
}
|
|
3863
|
+
configAlias(id, file, nickname) {
|
|
3864
|
+
const collisions = this.projects.filter((other) => other.id !== id && (other.slug === nickname || (other.nickname ?? other.configNickname) === nickname || !other.sharedConfigNickname && other.configNickname === nickname));
|
|
3865
|
+
if (!collisions.length)
|
|
3866
|
+
return;
|
|
3867
|
+
const location = repositoryLocation(file);
|
|
3868
|
+
if (!location || collisions.some((other) => {
|
|
3869
|
+
const candidate = repositoryLocation(other.file);
|
|
3870
|
+
return !candidate || candidate.repository !== location.repository || candidate.relativeFile !== location.relativeFile;
|
|
3871
|
+
}))
|
|
3872
|
+
this.assertNicknameAvailable(id, nickname);
|
|
3873
|
+
const existing = this.byId(id)?.nickname;
|
|
3874
|
+
if (existing)
|
|
3875
|
+
return existing;
|
|
3876
|
+
const base = `${nickname.slice(0, 46)}-${id.slice(0, 8)}`;
|
|
3877
|
+
let alias = base;
|
|
3878
|
+
let suffix = 2;
|
|
3879
|
+
while (this.projects.some((other) => other.id !== id && (other.slug === alias || (other.nickname ?? other.configNickname) === alias || !other.sharedConfigNickname && other.configNickname === alias)))
|
|
3880
|
+
alias = `${base}-${suffix++}`;
|
|
3881
|
+
return alias;
|
|
3882
|
+
}
|
|
3883
|
+
nextCorruptFile() {
|
|
3884
|
+
const base = `${this.catalogFile}.corrupt-${this.now()}`;
|
|
3885
|
+
let candidate = base;
|
|
3886
|
+
let suffix = 2;
|
|
3887
|
+
while (fs3.existsSync(candidate))
|
|
3888
|
+
candidate = `${base}-${suffix++}`;
|
|
3889
|
+
return candidate;
|
|
3890
|
+
}
|
|
3891
|
+
persist() {
|
|
3892
|
+
writeJson(this.catalogFile, { version: 1, projects: this.sortedEntries() });
|
|
3893
|
+
}
|
|
3894
|
+
sorted() {
|
|
3895
|
+
return this.sortedEntries().map((project) => ({ ...project }));
|
|
3896
|
+
}
|
|
3897
|
+
sortedEntries() {
|
|
3898
|
+
return [...this.projects].sort((a, b) => b.lastUsedAt - a.lastUsedAt);
|
|
3899
|
+
}
|
|
3900
|
+
toSummary(project) {
|
|
3901
|
+
try {
|
|
3902
|
+
this.load(project);
|
|
3903
|
+
return { ...project, nickname: this.nickname(project.id), available: true };
|
|
3904
|
+
} catch (error) {
|
|
3905
|
+
return { ...project, available: false, error: errorMessage(error) };
|
|
3906
|
+
}
|
|
3907
|
+
}
|
|
3908
|
+
load(project) {
|
|
3909
|
+
if (!fs3.existsSync(project.file))
|
|
3910
|
+
throw new Error(`${project.file} does not exist`);
|
|
3911
|
+
const loaded = loadProjectFile(project.file);
|
|
3912
|
+
if (loaded.nickname) {
|
|
3913
|
+
const alias = this.configAlias(project.id, project.file, loaded.nickname);
|
|
3914
|
+
if (alias) {
|
|
3915
|
+
project.sharedConfigNickname = true;
|
|
3916
|
+
if (!project.nickname)
|
|
3917
|
+
project.nickname = alias;
|
|
3918
|
+
}
|
|
3919
|
+
}
|
|
3920
|
+
project.configNickname = loaded.nickname;
|
|
3921
|
+
return loaded;
|
|
3922
|
+
}
|
|
3923
|
+
require(projectId) {
|
|
3924
|
+
const project = this.byId(projectId);
|
|
3925
|
+
if (!project)
|
|
3926
|
+
throw new ProjectError("not_found", `Project ${projectId} was not found`);
|
|
3927
|
+
return project;
|
|
3928
|
+
}
|
|
3929
|
+
byId(projectId) {
|
|
3930
|
+
return this.projects.find((project) => project.id === projectId);
|
|
3931
|
+
}
|
|
3932
|
+
assignSlug(name, id) {
|
|
3933
|
+
const base = slugify(name) || "project";
|
|
3934
|
+
if (!this.projects.some((project) => project.slug === base || project.nickname === base || project.configNickname === base))
|
|
3935
|
+
return base;
|
|
3936
|
+
const suffix = id.slice(0, 6);
|
|
3937
|
+
let slug = `${base}-${suffix}`;
|
|
3938
|
+
let collision = 2;
|
|
3939
|
+
while (this.projects.some((project) => project.slug === slug || project.nickname === slug || project.configNickname === slug))
|
|
3940
|
+
slug = `${base}-${suffix}-${collision++}`;
|
|
3941
|
+
return slug;
|
|
3787
3942
|
}
|
|
3788
3943
|
}
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3944
|
+
function projectId(realFile) {
|
|
3945
|
+
return createHash2("sha256").update(realFile).digest("hex").slice(0, 16);
|
|
3946
|
+
}
|
|
3947
|
+
function slugify(value) {
|
|
3948
|
+
return value.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
3949
|
+
}
|
|
3950
|
+
function isCatalog(value) {
|
|
3951
|
+
if (!value || typeof value !== "object")
|
|
3952
|
+
return false;
|
|
3953
|
+
const catalog = value;
|
|
3954
|
+
return catalog.version === 1 && Array.isArray(catalog.projects) && catalog.projects.every(isRememberedProject);
|
|
3955
|
+
}
|
|
3956
|
+
function isRememberedProject(value) {
|
|
3957
|
+
if (!value || typeof value !== "object")
|
|
3958
|
+
return false;
|
|
3959
|
+
const project = value;
|
|
3960
|
+
if (!["id", "name", "slug", "file", "directory"].every((key) => typeof project[key] === "string"))
|
|
3961
|
+
return false;
|
|
3962
|
+
const { id, slug, file, directory } = project;
|
|
3963
|
+
return path3.isAbsolute(file) && path3.normalize(file) === file && directory === path3.dirname(file) && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(slug) && /^[a-f0-9]{16}$/.test(id) && (project.nickname === undefined || typeof project.nickname === "string" && validNickname(project.nickname)) && (project.configNickname === undefined || typeof project.configNickname === "string" && validNickname(project.configNickname)) && (project.sharedConfigNickname === undefined || typeof project.sharedConfigNickname === "boolean") && (project.activeProfiles === undefined || Array.isArray(project.activeProfiles) && project.activeProfiles.every((name) => typeof name === "string" && /^[a-z0-9][a-z0-9_-]{0,40}$/.test(name))) && id === projectId(file) && typeof project.lastUsedAt === "number" && Number.isFinite(project.lastUsedAt);
|
|
3964
|
+
}
|
|
3965
|
+
function validNickname(value) {
|
|
3966
|
+
return value.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value);
|
|
3967
|
+
}
|
|
3968
|
+
function errorMessage(error) {
|
|
3969
|
+
return error instanceof Error ? error.message : String(error);
|
|
3970
|
+
}
|
|
3971
|
+
function gitMetadata(directory, args) {
|
|
3972
|
+
return execFileSync("git", ["-C", directory, ...args], { encoding: "utf8", timeout: 2000, maxBuffer: 1024 * 1024, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_OPTIONAL_LOCKS: "0" } });
|
|
3973
|
+
}
|
|
3974
|
+
function repositoryLocation(file) {
|
|
3975
|
+
try {
|
|
3976
|
+
const directory = path3.dirname(file);
|
|
3977
|
+
const root = fs3.realpathSync(gitMetadata(directory, ["rev-parse", "--show-toplevel"]).replace(/\r?\n$/, ""));
|
|
3978
|
+
const common = gitMetadata(directory, ["rev-parse", "--git-common-dir"]).replace(/\r?\n$/, "");
|
|
3979
|
+
const repository = fs3.realpathSync(path3.resolve(directory, common));
|
|
3980
|
+
const relativeFile = path3.relative(root, file);
|
|
3981
|
+
if (relativeFile.startsWith("../") || path3.isAbsolute(relativeFile))
|
|
3982
|
+
return;
|
|
3983
|
+
return { root, repository, relativeFile };
|
|
3984
|
+
} catch {
|
|
3985
|
+
return;
|
|
3803
3986
|
}
|
|
3804
|
-
return { entries, truncated: false };
|
|
3805
3987
|
}
|
|
3806
3988
|
|
|
3807
|
-
// ../daemon/src/
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3989
|
+
// ../daemon/src/worktrees.ts
|
|
3990
|
+
var sharedResourceNotice = "Worktrees have separate processes and automatic ports. Databases, Docker resources, secrets, and external services may still be shared by configuration.";
|
|
3991
|
+
function discoverWorktrees(projects, projectId2) {
|
|
3992
|
+
const project = projects.details(projectId2);
|
|
3993
|
+
const location = repositoryLocation(project.file);
|
|
3994
|
+
if (!location)
|
|
3995
|
+
return { environments: [], error: "Git repository metadata is unavailable for this project.", sharedResourceNotice };
|
|
3996
|
+
let output;
|
|
3997
|
+
try {
|
|
3998
|
+
output = gitMetadata(location.root, ["worktree", "list", "--porcelain", "-z"]);
|
|
3999
|
+
} catch {
|
|
4000
|
+
return { environments: [], error: "Unable to list Git worktrees.", sharedResourceNotice };
|
|
4001
|
+
}
|
|
4002
|
+
const registered = new Map(projects.list().map((p) => [p.file, p]));
|
|
4003
|
+
const environments = [];
|
|
4004
|
+
for (const record of output.split("\x00\x00")) {
|
|
4005
|
+
const fields = record.split("\x00");
|
|
4006
|
+
const directoryField = fields.find((field) => field.startsWith("worktree "));
|
|
4007
|
+
if (!directoryField)
|
|
3815
4008
|
continue;
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
4009
|
+
let directory = path4.resolve(directoryField.slice(9));
|
|
4010
|
+
try {
|
|
4011
|
+
directory = fs4.realpathSync(directory);
|
|
4012
|
+
} catch {}
|
|
4013
|
+
const candidateFile = path4.join(directory, location.relativeFile);
|
|
4014
|
+
let configFile = candidateFile;
|
|
4015
|
+
let error;
|
|
4016
|
+
try {
|
|
4017
|
+
const realFile = fs4.realpathSync(candidateFile);
|
|
4018
|
+
const candidate = repositoryLocation(realFile);
|
|
4019
|
+
if (!candidate || candidate.repository !== location.repository || candidate.relativeFile !== location.relativeFile || candidate.root !== directory)
|
|
4020
|
+
throw new Error("Configuration resolves outside this worktree.");
|
|
4021
|
+
configFile = realFile;
|
|
4022
|
+
loadProjectFile(configFile);
|
|
4023
|
+
} catch (cause) {
|
|
4024
|
+
error = cause instanceof Error ? cause.message : String(cause);
|
|
4025
|
+
}
|
|
4026
|
+
const known = registered.get(configFile);
|
|
4027
|
+
const branch = fields.find((field) => field.startsWith("branch "))?.slice(7).replace(/^refs\/heads\//, "");
|
|
4028
|
+
environments.push({
|
|
4029
|
+
id: createHash3("sha256").update(configFile).digest("hex").slice(0, 16),
|
|
4030
|
+
...known ? { projectId: known.id } : {},
|
|
4031
|
+
directory,
|
|
4032
|
+
configFile,
|
|
4033
|
+
...branch ? { branch } : {},
|
|
4034
|
+
head: fields.find((field) => field.startsWith("HEAD "))?.slice(5),
|
|
4035
|
+
detached: fields.includes("detached"),
|
|
4036
|
+
available: !error,
|
|
4037
|
+
registered: !!known,
|
|
4038
|
+
current: configFile === project.file,
|
|
4039
|
+
...error ? { error } : {}
|
|
4040
|
+
});
|
|
3821
4041
|
}
|
|
3822
|
-
|
|
3823
|
-
out["x-forwarded-for"] = req.headers["x-forwarded-for"] ? `${req.headers["x-forwarded-for"]}, ${remote}` : remote;
|
|
3824
|
-
out["x-forwarded-proto"] = target.via === "share" ? "https" : "http";
|
|
3825
|
-
out["x-forwarded-host"] = req.headers.host ?? "";
|
|
3826
|
-
out["x-localdeck-service"] = target.service;
|
|
3827
|
-
return out;
|
|
4042
|
+
return { environments, repository: location.repository, relativeConfig: location.relativeFile, sharedResourceNotice };
|
|
3828
4043
|
}
|
|
3829
|
-
function
|
|
3830
|
-
|
|
3831
|
-
|
|
3832
|
-
|
|
3833
|
-
.
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
|
|
4044
|
+
function registerWorktree(projects, projectId2, environmentId) {
|
|
4045
|
+
const discovered = discoverWorktrees(projects, projectId2);
|
|
4046
|
+
const environment = discovered.environments.find((candidate) => candidate.id === environmentId);
|
|
4047
|
+
if (!environment)
|
|
4048
|
+
throw new ProjectError("not_found", "This environment is not a discovered worktree of the selected project.");
|
|
4049
|
+
if (!environment.available)
|
|
4050
|
+
throw new ProjectError("invalid", environment.error ?? "This environment is unavailable.");
|
|
4051
|
+
return projects.remember(environment.configFile);
|
|
3837
4052
|
}
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
const
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
3891
|
-
}
|
|
3892
|
-
response = up;
|
|
3893
|
-
responseHeaders = captureHeaders(up.rawHeaders);
|
|
3894
|
-
responseEncoding = up.headers["content-encoding"];
|
|
3895
|
-
up.on("end", () => {
|
|
3896
|
-
responseComplete = true;
|
|
3897
|
-
});
|
|
3898
|
-
responseStatus = up.statusCode ?? 502;
|
|
3899
|
-
up.on("error", (error) => {
|
|
3900
|
-
record(502, `upstream response error: ${error.message}`);
|
|
3901
|
-
res.destroy();
|
|
3902
|
-
});
|
|
3903
|
-
up.on("aborted", () => {
|
|
3904
|
-
record(502, "upstream response aborted");
|
|
3905
|
-
res.destroy();
|
|
4053
|
+
|
|
4054
|
+
// ../daemon/src/reproductions.ts
|
|
4055
|
+
import path5 from "node:path";
|
|
4056
|
+
import fs5 from "node:fs";
|
|
4057
|
+
import { randomUUID } from "node:crypto";
|
|
4058
|
+
|
|
4059
|
+
// ../daemon/src/replay.ts
|
|
4060
|
+
import http from "node:http";
|
|
4061
|
+
function replayRequest(service, record, options = {}) {
|
|
4062
|
+
if (record.service !== service.key || !["GET", "HEAD"].includes(record.method))
|
|
4063
|
+
throw new Error("Only recorded GET and HEAD requests for this service can be replayed");
|
|
4064
|
+
if (service.status !== "running" || !service.upstreamReady || service.serviceType && service.serviceType !== "http")
|
|
4065
|
+
throw new Error("The HTTP service must be running before replay");
|
|
4066
|
+
if (!["127.0.0.1", "::1"].includes(service.upstreamHost) || !record.path.startsWith("/") || record.path.startsWith("//") || /[\r\n\\\x00-\x20\x7f]/.test(record.path))
|
|
4067
|
+
throw new Error("Request path must be a local HTTP path");
|
|
4068
|
+
const timeoutMs = options.timeoutMs ?? 5000;
|
|
4069
|
+
const maxBytes = options.maxBytes ?? 65536;
|
|
4070
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 5000 || !Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > 65536)
|
|
4071
|
+
throw new Error("Replay limits must be positive integers within 5000 ms and 65536 bytes");
|
|
4072
|
+
return new Promise((resolve, reject) => {
|
|
4073
|
+
const started = Date.now();
|
|
4074
|
+
let settled = false;
|
|
4075
|
+
let bytes = 0;
|
|
4076
|
+
const chunks = [];
|
|
4077
|
+
const request = http.request({ host: service.upstreamHost, port: service.upstreamPort, path: record.path, method: record.method, headers: { "x-localdeck-replay": "1" } });
|
|
4078
|
+
const timer = setTimeout(() => {
|
|
4079
|
+
if (!settled) {
|
|
4080
|
+
settled = true;
|
|
4081
|
+
reject(new Error("Replay timed out"));
|
|
4082
|
+
}
|
|
4083
|
+
request.destroy();
|
|
4084
|
+
}, timeoutMs);
|
|
4085
|
+
const finish = (status, truncated) => {
|
|
4086
|
+
if (settled)
|
|
4087
|
+
return;
|
|
4088
|
+
settled = true;
|
|
4089
|
+
clearTimeout(timer);
|
|
4090
|
+
resolve({ status, truncated, body: Buffer.concat(chunks).toString("utf8"), durationMs: Date.now() - started });
|
|
4091
|
+
};
|
|
4092
|
+
request.on("response", (response) => {
|
|
4093
|
+
response.on("data", (chunk) => {
|
|
4094
|
+
const remaining = Math.max(0, maxBytes - bytes);
|
|
4095
|
+
chunks.push(chunk.subarray(0, remaining));
|
|
4096
|
+
bytes += chunk.length;
|
|
4097
|
+
if (bytes > maxBytes) {
|
|
4098
|
+
finish(response.statusCode ?? 502, true);
|
|
4099
|
+
response.destroy();
|
|
4100
|
+
request.destroy();
|
|
4101
|
+
}
|
|
4102
|
+
});
|
|
4103
|
+
response.on("end", () => finish(response.statusCode ?? 502, false));
|
|
4104
|
+
response.on("error", (error) => request.destroy(error));
|
|
4105
|
+
response.on("aborted", () => request.destroy(new Error("Replay response was interrupted")));
|
|
3906
4106
|
});
|
|
3907
|
-
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
4107
|
+
request.on("error", (error) => {
|
|
4108
|
+
clearTimeout(timer);
|
|
4109
|
+
if (!settled) {
|
|
4110
|
+
settled = true;
|
|
4111
|
+
reject(error);
|
|
4112
|
+
}
|
|
3912
4113
|
});
|
|
3913
|
-
|
|
4114
|
+
request.end();
|
|
3914
4115
|
});
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
|
|
3918
|
-
|
|
3919
|
-
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
|
|
3923
|
-
|
|
4116
|
+
}
|
|
4117
|
+
|
|
4118
|
+
// ../daemon/src/reproductions.ts
|
|
4119
|
+
var sensitive = /authorization|cookie|token|secret|password|passwd|api[-_]?key|credential|session|signature/i;
|
|
4120
|
+
var quote = (value) => "'" + value.replace(/'/g, "'\\''") + "'";
|
|
4121
|
+
function validPath(value) {
|
|
4122
|
+
return typeof value === "string" && value.length <= 8192 && value.startsWith("/") && !value.startsWith("//") && !/[\r\n\\\x00-\x20\x7f#]/.test(value);
|
|
4123
|
+
}
|
|
4124
|
+
function redact(value, secrets) {
|
|
4125
|
+
for (const secret of secrets.filter(Boolean))
|
|
4126
|
+
value = value.split(secret).join("[REDACTED]");
|
|
4127
|
+
return value;
|
|
4128
|
+
}
|
|
4129
|
+
function sanitizeRequest(record, baseUrl, secrets = []) {
|
|
4130
|
+
if (!validPath(record.path) || !/^[A-Z]+$/.test(record.method))
|
|
4131
|
+
throw new Error("Request must have a valid local HTTP path and method");
|
|
4132
|
+
const base = new URL(baseUrl);
|
|
4133
|
+
if (!["http:", "https:"].includes(base.protocol) || base.username || base.password)
|
|
4134
|
+
throw new Error("Export requires an HTTP base URL without credentials");
|
|
4135
|
+
const omitted = [];
|
|
4136
|
+
const [pathname, query] = record.path.split(/\?(.*)/s);
|
|
4137
|
+
let safePath = redact(pathname, secrets.flatMap((secret) => [secret, encodeURIComponent(secret)]));
|
|
4138
|
+
if (safePath === pathname && secrets.some((secret) => secret && decodeURIComponentSafe(pathname).includes(secret)))
|
|
4139
|
+
safePath = "/[REDACTED]";
|
|
4140
|
+
if (query !== undefined) {
|
|
4141
|
+
let changed = false;
|
|
4142
|
+
const params = new URLSearchParams(query);
|
|
4143
|
+
const sanitized = new URLSearchParams;
|
|
4144
|
+
for (const [key, value] of [...params]) {
|
|
4145
|
+
const safe = sensitive.test(key) ? "[REDACTED]" : redact(value, secrets);
|
|
4146
|
+
const safeKey = redact(key, secrets);
|
|
4147
|
+
sanitized.append(safeKey, safe);
|
|
4148
|
+
if (safe !== value || safeKey !== key) {
|
|
4149
|
+
changed = true;
|
|
4150
|
+
omitted.push("Sensitive query values redacted");
|
|
4151
|
+
}
|
|
4152
|
+
}
|
|
4153
|
+
safePath += "?" + (changed ? sanitized.toString() : query);
|
|
4154
|
+
}
|
|
4155
|
+
if (safePath.split("?")[0] !== pathname)
|
|
4156
|
+
omitted.push("Secret in path redacted");
|
|
4157
|
+
const headers = [];
|
|
4158
|
+
for (const [key, value] of record.requestHeaders?.entries ?? []) {
|
|
4159
|
+
if (!["accept", "content-type"].includes(key.toLowerCase()) || sensitive.test(value) || redact(value, secrets) !== value || /[\r\n]/.test(value)) {
|
|
4160
|
+
omitted.push(`Header ${sensitive.test(key) ? "credential" : "non-representation"} omitted`);
|
|
4161
|
+
continue;
|
|
4162
|
+
}
|
|
4163
|
+
headers.push([key, value]);
|
|
4164
|
+
}
|
|
4165
|
+
if (record.requestHeaders?.truncated)
|
|
4166
|
+
omitted.push("Header capture incomplete");
|
|
4167
|
+
let body;
|
|
4168
|
+
const captured = record.requestBody;
|
|
4169
|
+
const hasBody = !!captured && (captured.bytes > 0 || captured.data.length > 0 || !captured.complete || captured.truncated);
|
|
4170
|
+
if (captured && hasBody) {
|
|
4171
|
+
const contentType = headers.find(([key]) => key.toLowerCase() === "content-type")?.[1] ?? "";
|
|
4172
|
+
if (!captured.complete || captured.truncated || captured.encoding !== "utf8" || captured.contentEncoding && captured.contentEncoding !== "identity")
|
|
4173
|
+
omitted.push("Incomplete, binary or encoded body omitted");
|
|
4174
|
+
else if (/application\/json/i.test(contentType)) {
|
|
4175
|
+
try {
|
|
4176
|
+
body = JSON.stringify(JSON.parse(captured.data), (key, value) => sensitive.test(key) ? "[REDACTED]" : typeof value === "string" ? redact(value, secrets) : value);
|
|
4177
|
+
} catch {
|
|
4178
|
+
omitted.push("Invalid JSON body omitted");
|
|
4179
|
+
}
|
|
4180
|
+
} else if (/application\/x-www-form-urlencoded/i.test(contentType)) {
|
|
4181
|
+
const params = new URLSearchParams(captured.data);
|
|
4182
|
+
for (const [key, value] of [...params])
|
|
4183
|
+
params.set(key, sensitive.test(key) ? "[REDACTED]" : redact(value, secrets));
|
|
4184
|
+
body = params.toString();
|
|
3924
4185
|
} else
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
};
|
|
3933
|
-
req.on("aborted", clientDisconnected);
|
|
3934
|
-
res.on("finish", () => record(responseStatus));
|
|
3935
|
-
res.on("close", () => {
|
|
3936
|
-
if (!res.writableFinished)
|
|
3937
|
-
clientDisconnected();
|
|
3938
|
-
});
|
|
3939
|
-
req.on("data", (chunk) => requestBody.append(chunk));
|
|
3940
|
-
req.pipe(upstream);
|
|
4186
|
+
omitted.push("Unsupported body format omitted");
|
|
4187
|
+
if (body !== undefined && body !== captured.data)
|
|
4188
|
+
omitted.push("Body sanitized");
|
|
4189
|
+
}
|
|
4190
|
+
const request = { method: record.method, url: base.origin + safePath, headers, ...body === undefined ? {} : { body } };
|
|
4191
|
+
const curl = ["curl", "--globoff", "--path-as-is", "--request", quote(request.method), "--url", quote(request.url), ...headers.flatMap(([key, value]) => ["--header", quote(`${key}: ${value}`)]), ...body === undefined ? [] : ["--data-raw", quote(body)]].join(" ");
|
|
4192
|
+
const executable = ["GET", "HEAD"].includes(record.method) && !hasBody && !omitted.some((item) => item.includes("query") || item.includes("path"));
|
|
4193
|
+
return { request, curl, omitted: [...new Set(omitted)], executable };
|
|
3941
4194
|
}
|
|
3942
|
-
function
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
3947
|
-
Connection: close\r
|
|
3948
|
-
\r
|
|
3949
|
-
`);
|
|
3950
|
-
return;
|
|
4195
|
+
function decodeURIComponentSafe(value) {
|
|
4196
|
+
try {
|
|
4197
|
+
return decodeURIComponent(value);
|
|
4198
|
+
} catch {
|
|
4199
|
+
return value;
|
|
3951
4200
|
}
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
|
|
4201
|
+
}
|
|
4202
|
+
function loadChecks(file) {
|
|
4203
|
+
try {
|
|
4204
|
+
if (fs5.statSync(file).size > 2000000)
|
|
4205
|
+
return [];
|
|
4206
|
+
const raw = JSON.parse(fs5.readFileSync(file, "utf8"));
|
|
4207
|
+
if (!Array.isArray(raw))
|
|
4208
|
+
return [];
|
|
4209
|
+
const seen = new Set;
|
|
4210
|
+
return raw.slice(-200).filter((item) => item && typeof item.id === "string" && item.id.length <= 100 && typeof item.projectId === "string" && item.projectId.length <= 200 && typeof item.serviceKey === "string" && item.serviceKey.startsWith(item.projectId + ":") && item.serviceKey.length <= 400 && typeof item.name === "string" && item.name.length <= 120 && validPath(item.path) && ["GET", "HEAD"].includes(item.method) && Number.isInteger(item.expectedStatus) && item.expectedStatus >= 100 && item.expectedStatus <= 599 && Number.isFinite(item.createdAt) && Array.isArray(item.runs)).filter((item) => {
|
|
4211
|
+
if (seen.has(item.id))
|
|
4212
|
+
return false;
|
|
4213
|
+
seen.add(item.id);
|
|
4214
|
+
return true;
|
|
4215
|
+
}).map((item) => ({
|
|
4216
|
+
id: item.id,
|
|
4217
|
+
projectId: item.projectId,
|
|
4218
|
+
serviceKey: item.serviceKey,
|
|
4219
|
+
name: item.name,
|
|
4220
|
+
method: item.method,
|
|
4221
|
+
path: item.path,
|
|
4222
|
+
expectedStatus: item.expectedStatus,
|
|
4223
|
+
createdAt: item.createdAt,
|
|
4224
|
+
runs: item.runs.slice(-20).filter((run, index, runs) => run && runs.findIndex((candidate) => candidate?.id === run.id) === index).filter((run) => run && typeof run.id === "string" && run.id.length <= 100 && run.projectId === item.projectId && run.serviceKey === item.serviceKey && Number.isFinite(run.ts) && Number.isFinite(run.durationMs) && run.durationMs >= 0 && run.expectedStatus === item.expectedStatus && ["passed", "status-mismatch", "transport-error"].includes(run.result) && (run.actualStatus === undefined || Number.isInteger(run.actualStatus) && run.actualStatus >= 100 && run.actualStatus <= 599) && (run.error === undefined || typeof run.error === "string" && run.error.length <= 500)).map((run) => ({ id: run.id, projectId: run.projectId, serviceKey: run.serviceKey, ts: run.ts, expectedStatus: run.expectedStatus, actualStatus: run.actualStatus, durationMs: run.durationMs, result: run.result, error: run.error }))
|
|
4225
|
+
}));
|
|
4226
|
+
} catch {
|
|
4227
|
+
return [];
|
|
4228
|
+
}
|
|
4229
|
+
}
|
|
4230
|
+
|
|
4231
|
+
class Reproductions {
|
|
4232
|
+
file;
|
|
4233
|
+
items;
|
|
4234
|
+
active = new Set;
|
|
4235
|
+
constructor(options = {}) {
|
|
4236
|
+
this.file = options.file ?? path5.join(HOME_DIR, "reproductions.json");
|
|
4237
|
+
this.items = loadChecks(this.file);
|
|
4238
|
+
}
|
|
4239
|
+
list(projectId2) {
|
|
4240
|
+
return structuredClone(this.items.filter((item) => item.projectId === projectId2));
|
|
4241
|
+
}
|
|
4242
|
+
persist() {
|
|
4243
|
+
const retained = [...this.items];
|
|
4244
|
+
while (retained.length > 1 && Buffer.byteLength(JSON.stringify(retained, null, 2)) > 2000000)
|
|
4245
|
+
retained.shift();
|
|
4246
|
+
writePrivateJson(this.file, retained);
|
|
4247
|
+
this.items = retained;
|
|
4248
|
+
}
|
|
4249
|
+
save(projectId2, record, input, secrets = []) {
|
|
4250
|
+
if (!projectId2 || projectId2.length > 200 || record.service.length > 400 || !record.service.startsWith(projectId2 + ":"))
|
|
4251
|
+
throw new Error("Request belongs to another project");
|
|
4252
|
+
if (typeof input.name !== "string" || !input.name.trim() || input.name.length > 120)
|
|
4253
|
+
throw new Error("Check name must contain 1–120 characters");
|
|
4254
|
+
if (!Number.isInteger(input.expectedStatus) || input.expectedStatus < 100 || input.expectedStatus > 599)
|
|
4255
|
+
throw new Error("Choose an expected HTTP status from 100 to 599");
|
|
4256
|
+
if (!sanitizeRequest(record, "http://localhost", secrets).executable || secrets.some((secret) => secret && decodeURIComponentSafe(record.path).includes(secret)))
|
|
4257
|
+
throw new Error("Checks require an unchanged GET/HEAD path without secret query values or a request body");
|
|
4258
|
+
const item = { id: randomUUID(), projectId: projectId2, serviceKey: record.service, name: redact(input.name.trim(), secrets).slice(0, 120), method: record.method, path: record.path, expectedStatus: input.expectedStatus, createdAt: Date.now(), runs: [] };
|
|
4259
|
+
const before = this.items;
|
|
4260
|
+
this.items = [...this.items, item].slice(-200);
|
|
4261
|
+
try {
|
|
4262
|
+
this.persist();
|
|
4263
|
+
} catch (error) {
|
|
4264
|
+
this.items = before;
|
|
4265
|
+
throw error;
|
|
3962
4266
|
}
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
if (
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
|
|
3984
|
-
|
|
4267
|
+
return structuredClone(item);
|
|
4268
|
+
}
|
|
4269
|
+
async run(projectId2, id, service) {
|
|
4270
|
+
const item = this.items.find((item2) => item2.id === id && item2.projectId === projectId2);
|
|
4271
|
+
if (!item || service.projectId !== projectId2 || service.key !== item.serviceKey)
|
|
4272
|
+
throw new Error("Check not found in the selected service and environment");
|
|
4273
|
+
if (!sanitizeRequest({ id: item.id, ts: 0, service: item.serviceKey, method: item.method, path: item.path, status: 0, bytes: 0, durationMs: 0, via: "local" }, "http://localhost").executable)
|
|
4274
|
+
throw new Error("Saved check is invalid");
|
|
4275
|
+
if (this.active.has(id))
|
|
4276
|
+
throw new Error("This check is already running");
|
|
4277
|
+
this.active.add(id);
|
|
4278
|
+
try {
|
|
4279
|
+
const ts = Date.now();
|
|
4280
|
+
const result = { id: randomUUID(), projectId: projectId2, serviceKey: service.key, ts, expectedStatus: item.expectedStatus, durationMs: 0, result: "transport-error" };
|
|
4281
|
+
try {
|
|
4282
|
+
const response = await replayRequest(service, { id: item.id, ts, service: item.serviceKey, method: item.method, path: item.path, status: 0, bytes: 0, durationMs: 0, via: "local" });
|
|
4283
|
+
result.actualStatus = response.status;
|
|
4284
|
+
result.durationMs = response.durationMs;
|
|
4285
|
+
result.result = response.status === item.expectedStatus ? "passed" : "status-mismatch";
|
|
4286
|
+
} catch {
|
|
4287
|
+
result.durationMs = Date.now() - ts;
|
|
4288
|
+
result.error = "Could not complete the request. Check that the local HTTP service is running and ready.";
|
|
4289
|
+
}
|
|
4290
|
+
const previous = item.runs;
|
|
4291
|
+
item.runs = [...item.runs, result].slice(-20);
|
|
4292
|
+
try {
|
|
4293
|
+
this.persist();
|
|
4294
|
+
} catch (error) {
|
|
4295
|
+
item.runs = previous;
|
|
4296
|
+
throw error;
|
|
4297
|
+
}
|
|
4298
|
+
return structuredClone(result);
|
|
4299
|
+
} finally {
|
|
4300
|
+
this.active.delete(id);
|
|
4301
|
+
}
|
|
4302
|
+
}
|
|
4303
|
+
delete(projectId2, id) {
|
|
4304
|
+
if (this.active.has(id))
|
|
4305
|
+
throw new Error("Wait for the check to finish before deleting it");
|
|
4306
|
+
const previous = this.items;
|
|
4307
|
+
this.items = this.items.filter((item) => !(item.id === id && item.projectId === projectId2));
|
|
4308
|
+
if (previous.length === this.items.length)
|
|
4309
|
+
return false;
|
|
4310
|
+
try {
|
|
4311
|
+
this.persist();
|
|
4312
|
+
} catch (error) {
|
|
4313
|
+
this.items = previous;
|
|
4314
|
+
throw error;
|
|
4315
|
+
}
|
|
4316
|
+
return true;
|
|
4317
|
+
}
|
|
4318
|
+
}
|
|
4319
|
+
|
|
4320
|
+
// ../daemon/src/diagnostics.ts
|
|
4321
|
+
import fs6 from "node:fs";
|
|
4322
|
+
import os2 from "node:os";
|
|
4323
|
+
import path6 from "node:path";
|
|
4324
|
+
function redactDiagnostic(text, secrets = []) {
|
|
4325
|
+
let output = text.replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/g, "[REDACTED PRIVATE KEY]").replace(/(authorization["']?\s*[:=]\s*["']?)(?:Bearer|Basic)\s+[^\s,"'}]+/gi, "$1[REDACTED]").replace(/((?:set-cookie|cookie)["']?\s*[:=]\s*)[^\r\n]+/gi, "$1[REDACTED]").replace(/(?<![a-z0-9+.-])([a-z][a-z0-9+.-]*:\/\/)([^\s/@]+)@/gi, (match, scheme, credentials) => credentials.includes(":") ? `${scheme}[REDACTED]@` : match).replace(/(?<![\w-])(?=[\w-]*(?:password|passwd|secret|token|api[_-]?key|credential))([\w-]+["']?\s*[:=]\s*)(?:"[^"\n]*"|'[^'\n]*'|[^\s,;&}\n]+)/gi, "$1[REDACTED]").replace(/((?:--)(?:password|token|secret|api-key)\s+)(?:"[^"]*"|'[^']*'|\S+)/gi, "$1[REDACTED]");
|
|
4326
|
+
for (const secret of [...new Set(secrets)].filter(Boolean).sort((a, b) => b.length - a.length))
|
|
4327
|
+
output = output.replaceAll(secret, "[REDACTED]");
|
|
4328
|
+
const home = os2.homedir();
|
|
4329
|
+
if (home.length > 1)
|
|
4330
|
+
output = output.replaceAll(home, "<home>");
|
|
4331
|
+
return output;
|
|
4332
|
+
}
|
|
4333
|
+
function privateEnvironmentValue(name, value) {
|
|
4334
|
+
if (name === "NODE_ENV" && /^(development|production|test)$/.test(value))
|
|
4335
|
+
return false;
|
|
4336
|
+
if (/^(?:PORT|[A-Z_]+_PORT)$/.test(name) && /^\d{1,5}$/.test(value) && Number(value) > 0 && Number(value) <= 65535)
|
|
4337
|
+
return false;
|
|
4338
|
+
return true;
|
|
4339
|
+
}
|
|
4340
|
+
function environmentValues(directory) {
|
|
4341
|
+
const values = [];
|
|
4342
|
+
for (const name of [".env", ".env.local", ".env.development", ".env.development.local"]) {
|
|
4343
|
+
const file = path6.join(directory, name);
|
|
4344
|
+
try {
|
|
4345
|
+
const stat = fs6.lstatSync(file);
|
|
4346
|
+
if (!stat.isFile() || stat.size > 65536)
|
|
4347
|
+
continue;
|
|
4348
|
+
const text = fs6.readFileSync(file, "utf8");
|
|
4349
|
+
for (const line of text.split(/\r?\n/)) {
|
|
4350
|
+
const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][\w]*)\s*=\s*(.*)$/);
|
|
4351
|
+
if (match) {
|
|
4352
|
+
const value = match[2].trim().replace(/^(['"])(.*)\1$/, "$2");
|
|
4353
|
+
if (privateEnvironmentValue(match[1], value))
|
|
4354
|
+
values.push(value);
|
|
4355
|
+
}
|
|
4356
|
+
}
|
|
4357
|
+
} catch {}
|
|
4358
|
+
}
|
|
4359
|
+
return values;
|
|
4360
|
+
}
|
|
4361
|
+
function diagnosticReport(project, services, logs, version, privateValues = []) {
|
|
4362
|
+
const inheritedSecrets = Object.entries(process.env).filter(([name]) => /password|passwd|secret|token|api[_-]?key|credential/i.test(name)).flatMap(([, value]) => value ? [value] : []);
|
|
4363
|
+
const directories = new Set([project.directory, ...project.commands.map((command) => path6.resolve(project.directory, command.cwd ?? "."))]);
|
|
4364
|
+
const secrets = [...privateValues, ...inheritedSecrets, ...[...directories].flatMap(environmentValues), ...project.commands.flatMap((command) => Object.entries(command.env ?? {}).filter(([name, value]) => privateEnvironmentValue(name, value)).map(([, value]) => value))];
|
|
4365
|
+
const report = {
|
|
4366
|
+
notice: "Review before sharing. Known secrets are redacted, but application logs may contain other private data.",
|
|
4367
|
+
version,
|
|
4368
|
+
project: { name: project.name, available: project.available, error: project.error },
|
|
4369
|
+
configuration: project.commands.map(({ env, ...command }) => ({ ...command, environmentVariables: Object.keys(env ?? {}) })),
|
|
4370
|
+
services: services.map((service) => ({ name: service.name, status: service.status, readiness: service.readinessStatus, message: service.readinessMessage, lastError: service.lastError, serviceType: service.serviceType ?? "http", port: service.stablePort })),
|
|
4371
|
+
logs: logs.slice(-100).map(({ stream, line, ts, service }) => ({ service, ts, stream, line: line.slice(0, 2000) }))
|
|
3985
4372
|
};
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
socket.on("close", () => up.destroy());
|
|
3989
|
-
up.on("close", () => socket.destroy());
|
|
4373
|
+
const sanitize = (value) => typeof value === "string" ? redactDiagnostic(value, secrets) : Array.isArray(value) ? value.map(sanitize) : value && typeof value === "object" ? Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sanitize(item)])) : value;
|
|
4374
|
+
return JSON.stringify(sanitize(report), null, 2);
|
|
3990
4375
|
}
|
|
3991
4376
|
|
|
3992
|
-
// ../daemon/src/
|
|
3993
|
-
import
|
|
3994
|
-
import
|
|
3995
|
-
|
|
4377
|
+
// ../daemon/src/project-secrets.ts
|
|
4378
|
+
import fs7 from "node:fs";
|
|
4379
|
+
import path7 from "node:path";
|
|
4380
|
+
var ID = /^[a-f0-9]{16}$/;
|
|
4381
|
+
var NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
3996
4382
|
|
|
3997
|
-
|
|
3998
|
-
import net3 from "node:net";
|
|
3999
|
-
function canBind(port, host, required) {
|
|
4000
|
-
return new Promise((resolve) => {
|
|
4001
|
-
const srv = net3.createServer();
|
|
4002
|
-
srv.once("error", (error) => resolve(!required && error.code !== "EADDRINUSE" && error.code !== "EACCES" ? undefined : false));
|
|
4003
|
-
srv.listen({ port, host, exclusive: true }, () => srv.close(() => resolve(true)));
|
|
4004
|
-
});
|
|
4383
|
+
class ProjectSecretError extends Error {
|
|
4005
4384
|
}
|
|
4006
|
-
|
|
4007
|
-
|
|
4008
|
-
|
|
4009
|
-
|
|
4385
|
+
function validateName(name) {
|
|
4386
|
+
if (!NAME.test(name) || name === "PORT" || name.startsWith("LOCALDECK_")) {
|
|
4387
|
+
throw new ProjectSecretError("invalid secret name");
|
|
4388
|
+
}
|
|
4389
|
+
}
|
|
4390
|
+
function createProjectSecretStore(home = HOME_DIR) {
|
|
4391
|
+
const directory = path7.join(home, "secrets");
|
|
4392
|
+
function secretPath(projectId2) {
|
|
4393
|
+
if (!ID.test(projectId2))
|
|
4394
|
+
throw new ProjectSecretError("invalid project ID");
|
|
4395
|
+
return path7.join(directory, `${projectId2}.json`);
|
|
4396
|
+
}
|
|
4397
|
+
function read(projectId2) {
|
|
4398
|
+
const file = secretPath(projectId2);
|
|
4399
|
+
let text;
|
|
4400
|
+
try {
|
|
4401
|
+
text = fs7.readFileSync(file, "utf8");
|
|
4402
|
+
} catch (error) {
|
|
4403
|
+
if (error.code === "ENOENT")
|
|
4404
|
+
return Object.create(null);
|
|
4405
|
+
throw new ProjectSecretError("Could not read stored secrets");
|
|
4406
|
+
}
|
|
4407
|
+
let parsed;
|
|
4408
|
+
try {
|
|
4409
|
+
parsed = JSON.parse(text);
|
|
4410
|
+
} catch {
|
|
4411
|
+
throw new ProjectSecretError("stored secrets are corrupt");
|
|
4412
|
+
}
|
|
4413
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
4414
|
+
throw new ProjectSecretError("stored secrets are corrupt");
|
|
4415
|
+
}
|
|
4416
|
+
const values = Object.create(null);
|
|
4417
|
+
for (const [name, value] of Object.entries(parsed)) {
|
|
4418
|
+
validateName(name);
|
|
4419
|
+
if (typeof value !== "string" || !value || value.includes("\x00")) {
|
|
4420
|
+
throw new ProjectSecretError("stored secrets are corrupt");
|
|
4421
|
+
}
|
|
4422
|
+
values[name] = value;
|
|
4423
|
+
}
|
|
4424
|
+
return values;
|
|
4425
|
+
}
|
|
4426
|
+
function write(projectId2, values) {
|
|
4427
|
+
const file = secretPath(projectId2);
|
|
4428
|
+
fs7.mkdirSync(directory, { recursive: true, mode: 448 });
|
|
4429
|
+
if (process.platform !== "win32")
|
|
4430
|
+
fs7.chmodSync(directory, 448);
|
|
4431
|
+
writePrivateJson(file, values);
|
|
4432
|
+
}
|
|
4433
|
+
function listProjectSecrets(projectId2) {
|
|
4434
|
+
return Object.keys(read(projectId2)).sort();
|
|
4435
|
+
}
|
|
4436
|
+
function setProjectSecret(projectId2, name, value) {
|
|
4437
|
+
validateName(name);
|
|
4438
|
+
if (typeof value !== "string" || !value || value.includes("\x00")) {
|
|
4439
|
+
throw new ProjectSecretError("secret value must be a nonempty string without NUL");
|
|
4440
|
+
}
|
|
4441
|
+
const values = read(projectId2);
|
|
4442
|
+
values[name] = value;
|
|
4443
|
+
write(projectId2, values);
|
|
4444
|
+
}
|
|
4445
|
+
function deleteProjectSecret(projectId2, name) {
|
|
4446
|
+
validateName(name);
|
|
4447
|
+
const values = read(projectId2);
|
|
4448
|
+
delete values[name];
|
|
4449
|
+
write(projectId2, values);
|
|
4450
|
+
}
|
|
4451
|
+
function resolveProjectSecrets(projectId2, names) {
|
|
4452
|
+
const output = Object.create(null);
|
|
4453
|
+
if (!names.length)
|
|
4454
|
+
return output;
|
|
4455
|
+
const values = read(projectId2);
|
|
4456
|
+
for (const name of names) {
|
|
4457
|
+
validateName(name);
|
|
4458
|
+
if (!Object.prototype.hasOwnProperty.call(values, name)) {
|
|
4459
|
+
throw new ProjectSecretError(`Add ${name} in Project → Secrets.`);
|
|
4460
|
+
}
|
|
4461
|
+
output[name] = values[name];
|
|
4462
|
+
}
|
|
4463
|
+
return output;
|
|
4464
|
+
}
|
|
4465
|
+
return { listProjectSecrets, setProjectSecret, deleteProjectSecret, resolveProjectSecrets };
|
|
4466
|
+
}
|
|
4467
|
+
var { listProjectSecrets, setProjectSecret, deleteProjectSecret, resolveProjectSecrets } = createProjectSecretStore();
|
|
4468
|
+
|
|
4469
|
+
// ../daemon/src/debugging-api.ts
|
|
4470
|
+
var checksByDeps = new WeakMap;
|
|
4471
|
+
function checks(deps) {
|
|
4472
|
+
let value = deps.reproductions ?? checksByDeps.get(deps);
|
|
4473
|
+
if (!value) {
|
|
4474
|
+
value = new Reproductions;
|
|
4475
|
+
checksByDeps.set(deps, value);
|
|
4010
4476
|
}
|
|
4011
|
-
return
|
|
4477
|
+
return value;
|
|
4012
4478
|
}
|
|
4013
|
-
function
|
|
4014
|
-
|
|
4015
|
-
|
|
4016
|
-
|
|
4017
|
-
srv.listen(0, host, () => {
|
|
4018
|
-
const { port } = srv.address();
|
|
4019
|
-
srv.close(() => resolve(port));
|
|
4020
|
-
});
|
|
4021
|
-
});
|
|
4479
|
+
function secrets(deps, projectId2) {
|
|
4480
|
+
const p = deps.projects.details(projectId2);
|
|
4481
|
+
const names = (deps.listProjectSecrets ?? listProjectSecrets)(projectId2);
|
|
4482
|
+
return [deps.controlToken, ...Object.values((deps.resolveProjectSecrets ?? resolveProjectSecrets)(projectId2, names)), ...Object.entries(process.env).flatMap(([key, value]) => /secret|token|password|credential|api[_-]?key/i.test(key) && value ? [value] : []), ...environmentValues(p.directory), ...p.commands.flatMap((c) => [...Object.values(c.env ?? {}), ...environmentValues(path8.resolve(p.directory, c.cwd ?? "."))])];
|
|
4022
4483
|
}
|
|
4023
|
-
function
|
|
4024
|
-
const
|
|
4025
|
-
|
|
4484
|
+
async function body(req, allowed) {
|
|
4485
|
+
const value = await readBody(req);
|
|
4486
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some((k) => !allowed.includes(k)))
|
|
4487
|
+
throw new HttpError(400, "Unexpected request fields");
|
|
4488
|
+
return value;
|
|
4026
4489
|
}
|
|
4027
|
-
function
|
|
4028
|
-
const
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
|
|
4032
|
-
|
|
4033
|
-
|
|
4490
|
+
async function handleDebuggingApi(req, res, url, parts, deps) {
|
|
4491
|
+
const method = req.method ?? "GET";
|
|
4492
|
+
const projectRoute = parts[0] === "projects" && parts.length >= 3 && ["activity", "baselines", "checks", "environments"].includes(parts[2]);
|
|
4493
|
+
const exportRoute = parts[0] === "services" && parts.length === 3 && parts[2] === "reproduction";
|
|
4494
|
+
if (!projectRoute && !exportRoute)
|
|
4495
|
+
return false;
|
|
4496
|
+
const send = (status, value) => {
|
|
4497
|
+
res.setHeader("cache-control", "no-store");
|
|
4498
|
+
json(res, status, value);
|
|
4499
|
+
return true;
|
|
4500
|
+
};
|
|
4501
|
+
try {
|
|
4502
|
+
if (method !== "GET")
|
|
4503
|
+
requireMutationAllowed(deps);
|
|
4504
|
+
if (exportRoute && method === "GET") {
|
|
4505
|
+
const key = parts[1], projectId3 = key.split(":")[0];
|
|
4506
|
+
const project2 = deps.projects.details(projectId3);
|
|
4507
|
+
if (!project2.commands.some((c) => `${projectId3}:${c.name}` === key) && !deps.registry.get(key))
|
|
4508
|
+
throw new HttpError(404, "Unknown service");
|
|
4509
|
+
const record = deps.store.requests(key, 5000).find((r) => r.id === url.searchParams.get("requestId"));
|
|
4510
|
+
if (!record)
|
|
4511
|
+
throw new HttpError(404, "Recorded request is no longer available");
|
|
4512
|
+
const service = deps.registry.get(key);
|
|
4513
|
+
if (!service)
|
|
4514
|
+
throw new HttpError(422, "Start this service before exporting so its current local address is available");
|
|
4515
|
+
const base = `http://localhost:${service.stablePort}`;
|
|
4516
|
+
return send(200, sanitizeRequest(record, base, secrets(deps, projectId3)));
|
|
4517
|
+
}
|
|
4518
|
+
if (!projectRoute)
|
|
4519
|
+
return send(405, { error: "Method not allowed" });
|
|
4520
|
+
const projectId2 = parts[1], feature = parts[2], project = deps.projects.details(projectId2);
|
|
4521
|
+
const allowed = [...new Set([...project.commands.map((c) => `${projectId2}:${c.name}`), ...deps.registry.list(projectId2).filter((s) => s.projectId === projectId2).map((s) => s.key)])];
|
|
4522
|
+
const selectServices = (value) => {
|
|
4523
|
+
const values = value === undefined ? allowed : value;
|
|
4524
|
+
if (!Array.isArray(values) || !values.length || values.length > 100 || values.some((v) => typeof v !== "string" || !allowed.includes(v)))
|
|
4525
|
+
throw new HttpError(422, "Select 1–100 configured or registered services in this project");
|
|
4526
|
+
return [...new Set(values)];
|
|
4527
|
+
};
|
|
4528
|
+
if (feature === "activity" && parts.length === 3 && method === "GET") {
|
|
4529
|
+
const raw = url.searchParams.get("services");
|
|
4530
|
+
if (!allowed.length && !raw)
|
|
4531
|
+
return send(200, { items: [], coverage: { partial: false, reasons: [] } });
|
|
4532
|
+
const options = { limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : 100, filter: url.searchParams.get("filter") ?? "all", query: url.searchParams.get("query") ?? undefined, cursor: url.searchParams.get("cursor") ?? undefined, eventId: url.searchParams.get("eventId") ?? undefined };
|
|
4533
|
+
return send(200, deps.store.activity.timeline(projectId2, selectServices(raw === null ? undefined : raw.split(",")), options));
|
|
4534
|
+
}
|
|
4535
|
+
if (feature === "activity" && parts.length === 5 && parts[4] === "evidence" && method === "GET") {
|
|
4536
|
+
const event = deps.store.activity.timeline(projectId2, selectServices(undefined), { eventId: parts[3], limit: 1 }).items[0];
|
|
4537
|
+
if (!event)
|
|
4538
|
+
throw new HttpError(404, "This event has expired or was cleared");
|
|
4539
|
+
const request = event.kind === "request" ? deps.store.requests(event.service, 5000).find((r) => r.id === event.id) : undefined;
|
|
4540
|
+
const log = event.kind === "log" ? deps.store.logs(event.service, 2000).find((r) => r.id === event.id) : undefined;
|
|
4541
|
+
return send(200, { event, request, log, service: deps.registry.get(event.service) });
|
|
4542
|
+
}
|
|
4543
|
+
if (feature === "baselines") {
|
|
4544
|
+
if (parts.length === 3 && method === "GET")
|
|
4545
|
+
return send(200, { items: deps.store.activity.markers(projectId2) });
|
|
4546
|
+
if (parts.length === 3 && method === "POST") {
|
|
4547
|
+
const input = await body(req, ["services", "label"]);
|
|
4548
|
+
requireMutationAllowed(deps);
|
|
4549
|
+
if (input.label !== undefined && typeof input.label !== "string")
|
|
4550
|
+
throw new HttpError(422, "Baseline label must be text");
|
|
4551
|
+
let git;
|
|
4552
|
+
try {
|
|
4553
|
+
git = { head: gitMetadata(project.directory, ["rev-parse", "HEAD"]).trim() };
|
|
4554
|
+
try {
|
|
4555
|
+
git.branch = gitMetadata(project.directory, ["symbolic-ref", "--short", "HEAD"]).trim();
|
|
4556
|
+
} catch {}
|
|
4557
|
+
} catch {}
|
|
4558
|
+
return send(201, deps.store.activity.mark(projectId2, selectServices(input.services), { label: input.label, git, secrets: secrets(deps, projectId2) }));
|
|
4559
|
+
}
|
|
4560
|
+
if (parts.length === 4 && method === "GET")
|
|
4561
|
+
return send(200, deps.store.activity.compare(projectId2, parts[3], Date.now(), secrets(deps, projectId2)));
|
|
4562
|
+
}
|
|
4563
|
+
if (feature === "environments") {
|
|
4564
|
+
if (parts.length === 3 && method === "GET") {
|
|
4565
|
+
const discovery = discoverWorktrees(deps.projects, projectId2);
|
|
4566
|
+
return send(200, { ...discovery, environments: discovery.environments.map((e) => ({ ...e, runningCount: e.projectId ? deps.registry.list(e.projectId).filter((s) => s.projectId === e.projectId && s.status !== "stopped").length : 0 })) });
|
|
4567
|
+
}
|
|
4568
|
+
if (parts.length === 5 && parts[4] === "register" && method === "POST") {
|
|
4569
|
+
await body(req, []);
|
|
4570
|
+
requireMutationAllowed(deps);
|
|
4571
|
+
return send(201, registerWorktree(deps.projects, projectId2, parts[3]));
|
|
4572
|
+
}
|
|
4573
|
+
}
|
|
4574
|
+
if (feature === "checks") {
|
|
4575
|
+
const repository = checks(deps);
|
|
4576
|
+
if (parts.length === 3 && method === "GET")
|
|
4577
|
+
return send(200, { items: repository.list(projectId2) });
|
|
4578
|
+
if (parts.length === 3 && method === "POST") {
|
|
4579
|
+
const input = await body(req, ["requestId", "serviceKey", "name", "expectedStatus"]);
|
|
4580
|
+
requireMutationAllowed(deps);
|
|
4581
|
+
const [key] = selectServices([input.serviceKey]);
|
|
4582
|
+
if (typeof input.requestId !== "string")
|
|
4583
|
+
throw new HttpError(422, "A recorded request ID is required");
|
|
4584
|
+
const record = deps.store.requests(key, 5000).find((r) => r.id === input.requestId);
|
|
4585
|
+
if (!record)
|
|
4586
|
+
throw new HttpError(404, "Recorded request is no longer available");
|
|
4587
|
+
return send(201, repository.save(projectId2, record, { name: input.name, expectedStatus: input.expectedStatus }, secrets(deps, projectId2)));
|
|
4588
|
+
}
|
|
4589
|
+
const check = repository.list(projectId2).find((c) => c.id === parts[3]);
|
|
4590
|
+
if (!check)
|
|
4591
|
+
throw new HttpError(404, "Check not found in this project");
|
|
4592
|
+
if (parts.length === 4 && method === "DELETE")
|
|
4593
|
+
return send(200, { ok: repository.delete(projectId2, check.id) });
|
|
4594
|
+
if (parts.length === 5 && parts[4] === "run" && method === "POST") {
|
|
4595
|
+
await body(req, []);
|
|
4596
|
+
requireMutationAllowed(deps);
|
|
4597
|
+
const service = deps.registry.get(check.serviceKey);
|
|
4598
|
+
if (!service || service.status !== "running" || !service.upstreamReady)
|
|
4599
|
+
throw new HttpError(422, "Start the check’s HTTP service and wait for readiness before running it");
|
|
4600
|
+
return send(200, await repository.run(projectId2, check.id, service));
|
|
4601
|
+
}
|
|
4602
|
+
}
|
|
4603
|
+
return send(404, { error: "Unknown debugging endpoint" });
|
|
4604
|
+
} catch (error) {
|
|
4605
|
+
if (error instanceof HttpError)
|
|
4606
|
+
throw error;
|
|
4607
|
+
return send(422, { error: error instanceof Error ? error.message : "Debugging operation failed" });
|
|
4608
|
+
}
|
|
4034
4609
|
}
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4610
|
+
|
|
4611
|
+
// ../daemon/src/api.ts
|
|
4612
|
+
import http5 from "node:http";
|
|
4613
|
+
import { timingSafeEqual, randomBytes, randomUUID as randomUUID2 } from "node:crypto";
|
|
4614
|
+
|
|
4615
|
+
// ../daemon/src/clean-urls.ts
|
|
4616
|
+
import fs8 from "node:fs";
|
|
4617
|
+
import http2 from "node:http";
|
|
4618
|
+
import path9 from "node:path";
|
|
4619
|
+
|
|
4620
|
+
// ../daemon/src/windows-forwarder.ts
|
|
4621
|
+
import { execFile } from "node:child_process";
|
|
4622
|
+
import { promisify } from "node:util";
|
|
4623
|
+
var execute = promisify(execFile);
|
|
4624
|
+
function windowsForwarderScript(port, remove = false) {
|
|
4625
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535 || port === 80)
|
|
4626
|
+
throw new Error("Invalid forwarding target port.");
|
|
4627
|
+
return String.raw`$ErrorActionPreference = 'Stop'
|
|
4628
|
+
$ownerPath = 'HKLM:\SOFTWARE\LocalDeck\Port80'
|
|
4629
|
+
$rulePath = 'HKLM:\SYSTEM\CurrentControlSet\Services\PortProxy\v4tov4\tcp'
|
|
4630
|
+
$ruleName = '127.0.0.1/80'
|
|
4631
|
+
$marker = 'LocalDeck loopback portproxy v1'
|
|
4632
|
+
$netsh = Join-Path $env:SystemRoot 'System32\netsh.exe'
|
|
4633
|
+
$owner = Get-ItemProperty -LiteralPath $ownerPath -ErrorAction SilentlyContinue
|
|
4634
|
+
$rule = if (Test-Path -LiteralPath $rulePath) { (Get-Item -LiteralPath $rulePath).GetValue($ruleName) } else { $null }
|
|
4635
|
+
$owned = $owner -and $owner.Marker -eq $marker -and $owner.TargetPort -match '^\d+$' -and [int]$owner.TargetPort -ge 1 -and [int]$owner.TargetPort -le 65535 -and [int]$owner.TargetPort -ne 80
|
|
4636
|
+
if ($owner -and !$owned) { throw 'Refusing to change an unrecognized LocalDeck ownership record.' }
|
|
4637
|
+
if ($rule -and (!$owned -or $rule -ne ('127.0.0.1/' + $owner.TargetPort))) { throw 'Port 80 has an unmanaged forwarding rule. It was left unchanged.' }
|
|
4638
|
+
${remove ? String.raw`if ($rule) {
|
|
4639
|
+
& $netsh interface portproxy delete v4tov4 listenaddress=127.0.0.1 listenport=80 protocol=tcp | Out-Null
|
|
4640
|
+
if ($LASTEXITCODE -ne 0) { throw 'Could not remove the LocalDeck forwarding rule.' }
|
|
4039
4641
|
}
|
|
4040
|
-
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
if (
|
|
4044
|
-
writeJson(file, kept);
|
|
4642
|
+
if ($owned) { Remove-Item -LiteralPath $ownerPath }
|
|
4643
|
+
` : String.raw`if (!$rule) {
|
|
4644
|
+
$listeners = @(Get-NetTCPConnection -ErrorAction Stop | Where-Object { $_.State -eq 'Listen' -and $_.LocalPort -eq 80 -and $_.LocalAddress -in @('127.0.0.1','0.0.0.0','::','::1') })
|
|
4645
|
+
if ($listeners.Count) { throw 'Port 80 is already in use. The existing listener was left unchanged.' }
|
|
4045
4646
|
}
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
}
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
|
|
4061
|
-
|
|
4062
|
-
|
|
4647
|
+
$helper = Get-Service iphlpsvc
|
|
4648
|
+
if ($helper.StartType -eq 'Disabled') { throw 'Windows IP Helper is disabled. Enable it through your system administrator to use port forwarding.' }
|
|
4649
|
+
if ($helper.Status -ne 'Running') { Start-Service iphlpsvc }
|
|
4650
|
+
$oldRule = $rule
|
|
4651
|
+
$oldPort = if ($owned) { $owner.TargetPort } else { $null }
|
|
4652
|
+
try {
|
|
4653
|
+
New-Item -Path $ownerPath -Force | Out-Null
|
|
4654
|
+
New-ItemProperty -LiteralPath $ownerPath -Name Marker -Value $marker -PropertyType String -Force | Out-Null
|
|
4655
|
+
New-ItemProperty -LiteralPath $ownerPath -Name TargetPort -Value '${port}' -PropertyType String -Force | Out-Null
|
|
4656
|
+
& $netsh interface portproxy add v4tov4 listenaddress=127.0.0.1 listenport=80 connectaddress=127.0.0.1 connectport=${port} protocol=tcp | Out-Null
|
|
4657
|
+
if ($LASTEXITCODE -ne 0) { throw 'Windows could not install the forwarding rule.' }
|
|
4658
|
+
$installed = (Get-Item -LiteralPath $rulePath).GetValue($ruleName)
|
|
4659
|
+
if ($installed -ne '127.0.0.1/${port}') { throw 'The installed forwarding rule did not match the requested destination.' }
|
|
4660
|
+
} catch {
|
|
4661
|
+
if ($oldRule) {
|
|
4662
|
+
& $netsh interface portproxy add v4tov4 listenaddress=127.0.0.1 listenport=80 connectaddress=127.0.0.1 "connectport=$oldPort" protocol=tcp | Out-Null
|
|
4663
|
+
if ($LASTEXITCODE -eq 0) { Set-ItemProperty -LiteralPath $ownerPath -Name TargetPort -Value $oldPort }
|
|
4664
|
+
} else {
|
|
4665
|
+
& $netsh interface portproxy delete v4tov4 listenaddress=127.0.0.1 listenport=80 protocol=tcp | Out-Null
|
|
4666
|
+
if ($LASTEXITCODE -eq 0) { Remove-Item -LiteralPath $ownerPath }
|
|
4063
4667
|
}
|
|
4064
|
-
throw
|
|
4668
|
+
throw
|
|
4065
4669
|
}
|
|
4066
|
-
|
|
4067
|
-
|
|
4068
|
-
|
|
4069
|
-
|
|
4070
|
-
|
|
4071
|
-
|
|
4072
|
-
|
|
4670
|
+
`}`;
|
|
4671
|
+
}
|
|
4672
|
+
function windowsElevationCommand(port, remove = false) {
|
|
4673
|
+
const script = `try {
|
|
4674
|
+
${windowsForwarderScript(port, remove)}
|
|
4675
|
+
exit 0
|
|
4676
|
+
} catch { Write-Error $_ -ErrorAction Continue; exit 1 }`;
|
|
4677
|
+
const encoded = Buffer.from(script, "utf16le").toString("base64");
|
|
4678
|
+
return `$ErrorActionPreference = 'Stop'; $exe = Join-Path $env:SystemRoot 'System32\\WindowsPowerShell\\v1.0\\powershell.exe'; $child = Start-Process -FilePath $exe -Verb RunAs -ArgumentList '-NoLogo -NoProfile -NonInteractive -EncodedCommand ${encoded}' -PassThru -Wait; if ($child.ExitCode -ne 0) { throw 'Windows administrator setup failed.' }`;
|
|
4679
|
+
}
|
|
4680
|
+
async function configureWindowsForwarder(port, remove = false) {
|
|
4681
|
+
const script = windowsElevationCommand(port, remove);
|
|
4682
|
+
try {
|
|
4683
|
+
await execute("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], { windowsHide: true, timeout: 120000, maxBuffer: 8192 });
|
|
4684
|
+
} catch {
|
|
4685
|
+
throw new Error("Windows administrator setup was cancelled or failed. Port 80 may be occupied or reserved, or IP Helper may be disabled. Existing port-number links still work; retry from Settings.");
|
|
4073
4686
|
}
|
|
4074
4687
|
}
|
|
4075
4688
|
|
|
4076
|
-
|
|
4077
|
-
|
|
4078
|
-
|
|
4079
|
-
|
|
4080
|
-
|
|
4081
|
-
|
|
4082
|
-
|
|
4083
|
-
|
|
4084
|
-
|
|
4085
|
-
|
|
4086
|
-
|
|
4087
|
-
|
|
4088
|
-
|
|
4089
|
-
|
|
4090
|
-
|
|
4091
|
-
|
|
4092
|
-
|
|
4093
|
-
|
|
4094
|
-
|
|
4095
|
-
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4104
|
-
|
|
4105
|
-
|
|
4106
|
-
|
|
4107
|
-
|
|
4108
|
-
|
|
4109
|
-
|
|
4110
|
-
|
|
4111
|
-
|
|
4112
|
-
|
|
4113
|
-
|
|
4114
|
-
return
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
|
|
4119
|
-
|
|
4120
|
-
|
|
4121
|
-
|
|
4122
|
-
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
|
|
4132
|
-
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
4689
|
+
// ../daemon/src/system-forwarder.ts
|
|
4690
|
+
import { execFile as execFile2 } from "node:child_process";
|
|
4691
|
+
import os3 from "node:os";
|
|
4692
|
+
import { promisify as promisify2 } from "node:util";
|
|
4693
|
+
var execute2 = promisify2(execFile2);
|
|
4694
|
+
var label = "dev.localdeck.port80";
|
|
4695
|
+
var SYSTEM_FORWARDER_PLIST = `/Library/LaunchDaemons/${label}.plist`;
|
|
4696
|
+
var destination = SYSTEM_FORWARDER_PLIST;
|
|
4697
|
+
var marker = "LocalDeck managed loopback relay v1";
|
|
4698
|
+
function forwarderPlist(user, port) {
|
|
4699
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_.-]*$/.test(user) || user === "root")
|
|
4700
|
+
throw new Error("Forwarding must run as a regular macOS user.");
|
|
4701
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535 || port === 80)
|
|
4702
|
+
throw new Error("Invalid forwarding target port.");
|
|
4703
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
4704
|
+
<!-- ${marker} -->
|
|
4705
|
+
<plist version="1.0"><dict>
|
|
4706
|
+
<key>Label</key><string>${label}</string>
|
|
4707
|
+
<key>UserName</key><string>${user}</string>
|
|
4708
|
+
<key>ProgramArguments</key><array><string>/usr/bin/nc</string><string>127.0.0.1</string><string>${port}</string></array>
|
|
4709
|
+
<key>inetdCompatibility</key><dict><key>Wait</key><false/></dict>
|
|
4710
|
+
<key>Sockets</key><dict><key>Listeners</key><dict>
|
|
4711
|
+
<key>SockNodeName</key><string>127.0.0.1</string>
|
|
4712
|
+
<key>SockServiceName</key><string>80</string>
|
|
4713
|
+
<key>SockFamily</key><string>IPv4</string>
|
|
4714
|
+
<key>SockType</key><string>stream</string>
|
|
4715
|
+
<key>SockProtocol</key><string>TCP</string>
|
|
4716
|
+
</dict></dict>
|
|
4717
|
+
</dict></plist>`;
|
|
4718
|
+
}
|
|
4719
|
+
function systemForwarderCommand(user, port, remove = false) {
|
|
4720
|
+
const plist = forwarderPlist(user, port);
|
|
4721
|
+
const guard = `set -eu
|
|
4722
|
+
if [ -L '${destination}' ]; then exit 1; fi
|
|
4723
|
+
if [ -e '${destination}' ] && ! /usr/bin/grep -q '${marker}' '${destination}'; then
|
|
4724
|
+
echo 'Refusing to replace an unmanaged forwarding service.' >&2; exit 1
|
|
4725
|
+
fi`;
|
|
4726
|
+
if (remove)
|
|
4727
|
+
return `${guard}
|
|
4728
|
+
/bin/launchctl bootout system/${label} 2>/dev/null || true
|
|
4729
|
+
/bin/rm -f '${destination}'`;
|
|
4730
|
+
return `${guard}
|
|
4731
|
+
relay_file=$(/usr/bin/mktemp /Library/LaunchDaemons/.localdeck-port80.XXXXXX)
|
|
4732
|
+
trap '/bin/rm -f "$relay_file" "$relay_file.backup"' EXIT
|
|
4733
|
+
/bin/cat > "$relay_file" <<'LOCALDECK_PLIST'
|
|
4734
|
+
${plist}
|
|
4735
|
+
LOCALDECK_PLIST
|
|
4736
|
+
/usr/bin/plutil -lint "$relay_file" >/dev/null
|
|
4737
|
+
/usr/sbin/chown root:wheel "$relay_file"
|
|
4738
|
+
/bin/chmod 644 "$relay_file"
|
|
4739
|
+
if [ -f '${destination}' ]; then /bin/cp -p '${destination}' "$relay_file.backup"; fi
|
|
4740
|
+
/bin/launchctl bootout system/${label} 2>/dev/null || true
|
|
4741
|
+
/bin/mv "$relay_file" '${destination}'
|
|
4742
|
+
if ! /bin/launchctl bootstrap system '${destination}'; then
|
|
4743
|
+
/bin/rm -f '${destination}'
|
|
4744
|
+
if [ -f "$relay_file.backup" ]; then
|
|
4745
|
+
/bin/mv "$relay_file.backup" '${destination}'
|
|
4746
|
+
/bin/launchctl bootstrap system '${destination}' || true
|
|
4747
|
+
fi
|
|
4748
|
+
exit 1
|
|
4749
|
+
fi`;
|
|
4750
|
+
}
|
|
4751
|
+
async function configureSystemForwarder(port, remove = false) {
|
|
4752
|
+
if (process.platform === "win32")
|
|
4753
|
+
return configureWindowsForwarder(port, remove);
|
|
4754
|
+
if (process.platform !== "darwin")
|
|
4755
|
+
throw new Error("Automatic administrator setup is available on macOS and Windows.");
|
|
4756
|
+
const command = systemForwarderCommand(os3.userInfo().username, port, remove);
|
|
4757
|
+
try {
|
|
4758
|
+
await execute2("/usr/bin/osascript", ["-e", `on run argv
|
|
4759
|
+
do shell script (item 1 of argv) with administrator privileges
|
|
4760
|
+
end run`, command], { timeout: 120000, maxBuffer: 8192 });
|
|
4761
|
+
} catch {
|
|
4762
|
+
throw new Error("Administrator setup was cancelled or failed. Continuing with working port-number links; retry in Settings whenever you like.");
|
|
4136
4763
|
}
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
|
|
4140
|
-
|
|
4141
|
-
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4764
|
+
}
|
|
4765
|
+
|
|
4766
|
+
// ../daemon/src/clean-urls.ts
|
|
4767
|
+
class CleanUrls {
|
|
4768
|
+
targetPort;
|
|
4769
|
+
identity;
|
|
4770
|
+
port;
|
|
4771
|
+
platform;
|
|
4772
|
+
preferenceFile;
|
|
4773
|
+
configureSystem;
|
|
4774
|
+
systemRelayInstalled;
|
|
4775
|
+
bridge;
|
|
4776
|
+
enabling;
|
|
4777
|
+
closed = false;
|
|
4778
|
+
constructor(targetPort, identity, port = 80, platform = process.platform, preferenceFile = path9.join(HOME_DIR, "forwarder.json"), configureSystem = configureSystemForwarder, systemRelayInstalled = () => fs8.existsSync(SYSTEM_FORWARDER_PLIST)) {
|
|
4779
|
+
this.targetPort = targetPort;
|
|
4780
|
+
this.identity = identity;
|
|
4781
|
+
this.port = port;
|
|
4782
|
+
this.platform = platform;
|
|
4783
|
+
this.preferenceFile = preferenceFile;
|
|
4784
|
+
this.configureSystem = configureSystem;
|
|
4785
|
+
this.systemRelayInstalled = systemRelayInstalled;
|
|
4145
4786
|
}
|
|
4146
|
-
|
|
4147
|
-
|
|
4787
|
+
async status() {
|
|
4788
|
+
const ready = await new Promise((resolve) => {
|
|
4789
|
+
const request = http2.get({ hostname: "127.0.0.1", port: this.port, path: "/api/forwarder/probe", agent: false }, (response) => {
|
|
4790
|
+
let body2 = "";
|
|
4791
|
+
response.on("data", (chunk) => {
|
|
4792
|
+
body2 += chunk;
|
|
4793
|
+
if (body2.length > 256)
|
|
4794
|
+
request.destroy();
|
|
4795
|
+
});
|
|
4796
|
+
response.on("end", () => resolve(response.statusCode === 200 && body2 === this.identity));
|
|
4797
|
+
response.on("error", () => resolve(false));
|
|
4798
|
+
});
|
|
4799
|
+
const timer = setTimeout(() => {
|
|
4800
|
+
request.destroy();
|
|
4801
|
+
resolve(false);
|
|
4802
|
+
}, 1500);
|
|
4803
|
+
request.on("close", () => clearTimeout(timer));
|
|
4804
|
+
request.on("error", () => resolve(false));
|
|
4805
|
+
});
|
|
4806
|
+
return { ready: ready && !this.closed };
|
|
4148
4807
|
}
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4808
|
+
enable(options = {}) {
|
|
4809
|
+
if (this.enabling)
|
|
4810
|
+
return this.enabling;
|
|
4811
|
+
this.enabling = this.start(options).finally(() => {
|
|
4812
|
+
this.enabling = undefined;
|
|
4813
|
+
});
|
|
4814
|
+
return this.enabling;
|
|
4152
4815
|
}
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
4816
|
+
async start(options) {
|
|
4817
|
+
if (this.closed)
|
|
4818
|
+
return { ready: false, error: "LocalDeck is shutting down." };
|
|
4819
|
+
const preference = this.preferenceFile;
|
|
4820
|
+
const saved = readJson(preference, {});
|
|
4821
|
+
if (saved.removed) {
|
|
4822
|
+
if (options.automatic)
|
|
4823
|
+
return { ready: false, error: "Clean URLs are turned off; enable them in the dashboard to use them again." };
|
|
4824
|
+
writeJson(preference, { declined: saved.declined });
|
|
4158
4825
|
}
|
|
4159
|
-
if (
|
|
4160
|
-
this.
|
|
4161
|
-
|
|
4162
|
-
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
|
|
4166
|
-
|
|
4167
|
-
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4826
|
+
if (this.platform === "win32" && options.install && this.port === 80 && !(options.automatic && readJson(preference, {}).declined)) {
|
|
4827
|
+
if (!this.bridge && (await this.status()).ready)
|
|
4828
|
+
return { ready: true };
|
|
4829
|
+
if (this.bridge) {
|
|
4830
|
+
await this.bridge.close();
|
|
4831
|
+
this.bridge = undefined;
|
|
4832
|
+
}
|
|
4833
|
+
try {
|
|
4834
|
+
writeJson(preference, { declined: true });
|
|
4835
|
+
await this.configureSystem(this.targetPort());
|
|
4836
|
+
let ready = false;
|
|
4837
|
+
for (let attempt = 0;attempt < 20 && !this.closed; attempt++) {
|
|
4838
|
+
if ((await this.status()).ready) {
|
|
4839
|
+
ready = true;
|
|
4840
|
+
break;
|
|
4841
|
+
}
|
|
4842
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
4843
|
+
}
|
|
4844
|
+
if (!ready)
|
|
4845
|
+
throw new Error("Windows setup completed but forwarding could not be verified. Port-number links remain available; retry in Settings.");
|
|
4846
|
+
writeJson(preference, { declined: false });
|
|
4847
|
+
return { ready: true };
|
|
4848
|
+
} catch (error) {
|
|
4849
|
+
return { ready: false, error: error instanceof Error ? error.message : "Windows setup failed." };
|
|
4850
|
+
}
|
|
4174
4851
|
}
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
saveConfigNickname(projectId, value) {
|
|
4178
|
-
const project = this.require(projectId);
|
|
4179
|
-
const nickname = value.trim().toLowerCase();
|
|
4180
|
-
if (nickname && (!validNickname(nickname) || nickname === "standalone"))
|
|
4181
|
-
throw new ProjectError("invalid", "Use 1–63 letters, numbers or interior hyphens; standalone is reserved.");
|
|
4182
|
-
if (nickname)
|
|
4183
|
-
this.assertNicknameAvailable(projectId, nickname);
|
|
4852
|
+
if ((await this.status()).ready)
|
|
4853
|
+
return { ready: true };
|
|
4184
4854
|
try {
|
|
4185
|
-
|
|
4855
|
+
if (this.closed)
|
|
4856
|
+
return { ready: false, error: "LocalDeck is shutting down." };
|
|
4857
|
+
this.bridge ??= await startForwarder({ listenPort: this.port, targetPort: this.targetPort() });
|
|
4858
|
+
if (this.closed) {
|
|
4859
|
+
await this.bridge.close();
|
|
4860
|
+
this.bridge = undefined;
|
|
4861
|
+
return { ready: false, error: "LocalDeck is shutting down." };
|
|
4862
|
+
}
|
|
4863
|
+
if ((await this.status()).ready)
|
|
4864
|
+
return { ready: true };
|
|
4865
|
+
return { ready: false, error: "Port 80 does not reach this LocalDeck instance. Your existing links are unchanged." };
|
|
4186
4866
|
} catch (error) {
|
|
4187
|
-
|
|
4867
|
+
const message = error instanceof Error ? error.message : "Could not start port-80 forwarding.";
|
|
4868
|
+
const preference2 = this.preferenceFile;
|
|
4869
|
+
if (options.install && this.port === 80 && this.platform === "darwin" && message.includes("permission denied") && !(options.automatic && readJson(preference2, {}).declined)) {
|
|
4870
|
+
try {
|
|
4871
|
+
writeJson(preference2, { declined: true });
|
|
4872
|
+
await this.configureSystem(this.targetPort());
|
|
4873
|
+
const result = await this.status();
|
|
4874
|
+
if (!result.ready)
|
|
4875
|
+
throw new Error("Setup completed but forwarding could not be verified. Your original links are unchanged.");
|
|
4876
|
+
writeJson(preference2, { declined: false });
|
|
4877
|
+
return result;
|
|
4878
|
+
} catch (setupError) {
|
|
4879
|
+
return { ready: false, error: setupError instanceof Error ? setupError.message : "Administrator setup failed." };
|
|
4880
|
+
}
|
|
4881
|
+
}
|
|
4882
|
+
return { ready: false, error: message };
|
|
4188
4883
|
}
|
|
4189
|
-
project.configNickname = nickname || undefined;
|
|
4190
|
-
return this.updateNickname(projectId, "");
|
|
4191
4884
|
}
|
|
4192
|
-
|
|
4193
|
-
|
|
4885
|
+
async uninstall() {
|
|
4886
|
+
if (this.enabling)
|
|
4887
|
+
return { ready: false, error: "Wait for the current setup to finish." };
|
|
4194
4888
|
try {
|
|
4195
|
-
|
|
4196
|
-
|
|
4889
|
+
const hadBridge = Boolean(this.bridge);
|
|
4890
|
+
if (this.bridge) {
|
|
4891
|
+
await this.bridge.close();
|
|
4892
|
+
this.bridge = undefined;
|
|
4893
|
+
}
|
|
4894
|
+
if (this.platform === "darwin" ? this.systemRelayInstalled() : this.platform === "win32" && this.port === 80 && !hadBridge)
|
|
4895
|
+
await this.configureSystem(this.targetPort(), true);
|
|
4896
|
+
writeJson(this.preferenceFile, { declined: true, removed: true });
|
|
4897
|
+
return { ready: false, removed: true };
|
|
4197
4898
|
} catch (error) {
|
|
4198
|
-
|
|
4899
|
+
return { ready: false, error: error instanceof Error ? error.message : "Removal failed." };
|
|
4199
4900
|
}
|
|
4200
4901
|
}
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
4206
|
-
|
|
4207
|
-
throw new ProjectError("invalid", errorMessage(error));
|
|
4902
|
+
async close() {
|
|
4903
|
+
this.closed = true;
|
|
4904
|
+
await this.enabling;
|
|
4905
|
+
if (this.bridge) {
|
|
4906
|
+
await this.bridge.close();
|
|
4907
|
+
this.bridge = undefined;
|
|
4208
4908
|
}
|
|
4209
4909
|
}
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
|
|
4910
|
+
}
|
|
4911
|
+
|
|
4912
|
+
// ../daemon/src/api.ts
|
|
4913
|
+
import fs9 from "node:fs";
|
|
4914
|
+
import path10 from "node:path";
|
|
4915
|
+
|
|
4916
|
+
// ../daemon/src/request-body.ts
|
|
4917
|
+
import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
|
|
4918
|
+
var MAX_BODY_CAPTURE_BYTES = 16384;
|
|
4919
|
+
|
|
4920
|
+
class BodyCapture {
|
|
4921
|
+
limit;
|
|
4922
|
+
chunks = [];
|
|
4923
|
+
retained = 0;
|
|
4924
|
+
bytes = 0;
|
|
4925
|
+
tailUsed = 0;
|
|
4926
|
+
constructor(limit = MAX_BODY_CAPTURE_BYTES) {
|
|
4927
|
+
this.limit = limit;
|
|
4222
4928
|
}
|
|
4223
|
-
|
|
4224
|
-
|
|
4225
|
-
|
|
4929
|
+
append(chunk) {
|
|
4930
|
+
this.bytes += chunk.length;
|
|
4931
|
+
const count = Math.min(chunk.length, this.limit - this.retained);
|
|
4932
|
+
let offset = 0;
|
|
4933
|
+
while (offset < count) {
|
|
4934
|
+
let tail = this.chunks.at(-1);
|
|
4935
|
+
if (!tail || this.tailUsed === tail.length) {
|
|
4936
|
+
if (tail && tail.length < 4096) {
|
|
4937
|
+
const grown = Buffer.allocUnsafe(Math.min(4096, this.limit - this.retained + this.tailUsed, Math.max(tail.length * 2, count - offset)));
|
|
4938
|
+
tail.copy(grown, 0, 0, this.tailUsed);
|
|
4939
|
+
this.chunks[this.chunks.length - 1] = tail = grown;
|
|
4940
|
+
} else {
|
|
4941
|
+
tail = Buffer.allocUnsafe(Math.min(4096, this.limit - this.retained, Math.max(64, count - offset)));
|
|
4942
|
+
this.chunks.push(tail);
|
|
4943
|
+
this.tailUsed = 0;
|
|
4944
|
+
}
|
|
4945
|
+
}
|
|
4946
|
+
const size = Math.min(count - offset, tail.length - this.tailUsed);
|
|
4947
|
+
chunk.copy(tail, this.tailUsed, offset, offset + size);
|
|
4948
|
+
this.tailUsed += size;
|
|
4949
|
+
this.retained += size;
|
|
4950
|
+
offset += size;
|
|
4226
4951
|
}
|
|
4227
4952
|
}
|
|
4228
|
-
|
|
4229
|
-
|
|
4230
|
-
let
|
|
4231
|
-
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
return [...this.projects].sort((a, b) => b.lastUsedAt - a.lastUsedAt);
|
|
4244
|
-
}
|
|
4245
|
-
toSummary(project) {
|
|
4953
|
+
snapshot(complete, contentEncoding) {
|
|
4954
|
+
let buffer = Buffer.concat(this.chunks.map((chunk, index) => index === this.chunks.length - 1 ? chunk.subarray(0, this.tailUsed) : chunk), this.retained);
|
|
4955
|
+
let encoded = Boolean(contentEncoding && contentEncoding !== "identity");
|
|
4956
|
+
const truncated = this.bytes > this.retained;
|
|
4957
|
+
if (encoded && complete && !truncated && buffer.length) {
|
|
4958
|
+
const decode = { gzip: gunzipSync, deflate: inflateSync, br: brotliDecompressSync }[contentEncoding];
|
|
4959
|
+
try {
|
|
4960
|
+
if (decode) {
|
|
4961
|
+
buffer = decode(buffer, { maxOutputLength: this.limit });
|
|
4962
|
+
encoded = false;
|
|
4963
|
+
}
|
|
4964
|
+
} catch {}
|
|
4965
|
+
}
|
|
4966
|
+
let data;
|
|
4967
|
+
let encoding = "utf8";
|
|
4246
4968
|
try {
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
|
|
4969
|
+
if (encoded || buffer.includes(0))
|
|
4970
|
+
throw new Error("binary body");
|
|
4971
|
+
data = new TextDecoder("utf-8", { fatal: true }).decode(buffer);
|
|
4972
|
+
} catch {
|
|
4973
|
+
data = buffer.toString("base64");
|
|
4974
|
+
encoding = "base64";
|
|
4251
4975
|
}
|
|
4976
|
+
return { data, encoding, bytes: this.bytes, truncated, complete, ...encoded ? { contentEncoding } : {} };
|
|
4252
4977
|
}
|
|
4253
|
-
|
|
4254
|
-
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
const
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4978
|
+
}
|
|
4979
|
+
|
|
4980
|
+
// ../daemon/src/request-headers.ts
|
|
4981
|
+
var MAX_HEADER_BYTES = 16384;
|
|
4982
|
+
var MAX_HEADER_COUNT = 100;
|
|
4983
|
+
function captureHeaders(raw) {
|
|
4984
|
+
const entries = [];
|
|
4985
|
+
let bytes = 0;
|
|
4986
|
+
for (let i = 0;i + 1 < raw.length; i += 2) {
|
|
4987
|
+
const name = raw[i];
|
|
4988
|
+
const value = raw[i + 1];
|
|
4989
|
+
bytes += Buffer.byteLength(name) + Buffer.byteLength(value);
|
|
4990
|
+
if (bytes > MAX_HEADER_BYTES || entries.length >= MAX_HEADER_COUNT)
|
|
4991
|
+
return { entries, truncated: true };
|
|
4992
|
+
entries.push([name, value]);
|
|
4267
4993
|
}
|
|
4268
|
-
|
|
4269
|
-
|
|
4994
|
+
return { entries, truncated: false };
|
|
4995
|
+
}
|
|
4996
|
+
|
|
4997
|
+
// ../daemon/src/proxy.ts
|
|
4998
|
+
import http3 from "node:http";
|
|
4999
|
+
import net3 from "node:net";
|
|
5000
|
+
var HOP_BY_HOP = new Set(["connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade"]);
|
|
5001
|
+
function forwardHeaders(req, target) {
|
|
5002
|
+
const out = {};
|
|
5003
|
+
for (const [k, v] of Object.entries(req.headers)) {
|
|
5004
|
+
if (HOP_BY_HOP.has(k) || v === undefined)
|
|
5005
|
+
continue;
|
|
5006
|
+
out[k] = v;
|
|
4270
5007
|
}
|
|
4271
|
-
|
|
4272
|
-
const
|
|
4273
|
-
|
|
4274
|
-
return base;
|
|
4275
|
-
const suffix = id.slice(0, 6);
|
|
4276
|
-
let slug = `${base}-${suffix}`;
|
|
4277
|
-
let collision = 2;
|
|
4278
|
-
while (this.projects.some((project) => project.slug === slug || project.nickname === slug || project.configNickname === slug))
|
|
4279
|
-
slug = `${base}-${suffix}-${collision++}`;
|
|
4280
|
-
return slug;
|
|
5008
|
+
if (target.via === "share") {
|
|
5009
|
+
const host = net3.isIP(target.upstreamHost) === 6 ? `[${target.upstreamHost}]` : target.upstreamHost;
|
|
5010
|
+
out.host = `${host}:${target.upstreamPort}`;
|
|
4281
5011
|
}
|
|
5012
|
+
const remote = req.socket.remoteAddress ?? "";
|
|
5013
|
+
out["x-forwarded-for"] = req.headers["x-forwarded-for"] ? `${req.headers["x-forwarded-for"]}, ${remote}` : remote;
|
|
5014
|
+
out["x-forwarded-proto"] = target.via === "share" ? "https" : "http";
|
|
5015
|
+
out["x-forwarded-host"] = req.headers.host ?? "";
|
|
5016
|
+
out["x-localdeck-service"] = target.service;
|
|
5017
|
+
return out;
|
|
4282
5018
|
}
|
|
4283
|
-
function
|
|
4284
|
-
return
|
|
4285
|
-
}
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
}
|
|
4289
|
-
|
|
4290
|
-
|
|
4291
|
-
return false;
|
|
4292
|
-
const catalog = value;
|
|
4293
|
-
return catalog.version === 1 && Array.isArray(catalog.projects) && catalog.projects.every(isRememberedProject);
|
|
4294
|
-
}
|
|
4295
|
-
function isRememberedProject(value) {
|
|
4296
|
-
if (!value || typeof value !== "object")
|
|
4297
|
-
return false;
|
|
4298
|
-
const project = value;
|
|
4299
|
-
if (!["id", "name", "slug", "file", "directory"].every((key) => typeof project[key] === "string"))
|
|
4300
|
-
return false;
|
|
4301
|
-
const { id, slug, file, directory } = project;
|
|
4302
|
-
return path4.isAbsolute(file) && path4.normalize(file) === file && directory === path4.dirname(file) && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(slug) && /^[a-f0-9]{16}$/.test(id) && (project.nickname === undefined || typeof project.nickname === "string" && validNickname(project.nickname)) && (project.configNickname === undefined || typeof project.configNickname === "string" && validNickname(project.configNickname)) && id === projectId(file) && typeof project.lastUsedAt === "number" && Number.isFinite(project.lastUsedAt);
|
|
5019
|
+
function waitingPage(target) {
|
|
5020
|
+
return `<!doctype html><meta charset="utf-8"><meta http-equiv="refresh" content="2">
|
|
5021
|
+
<title>LocalDeck — waiting for ${target.service}</title>
|
|
5022
|
+
<style>body{font:15px/1.5 system-ui;margin:0;display:grid;place-items:center;height:100vh;background:#0f1115;color:#e6e8ef}
|
|
5023
|
+
.c{max-width:420px;text-align:center}code{background:#1c1f27;padding:2px 6px;border-radius:4px}</style>
|
|
5024
|
+
<div class="c"><h1 style="font-weight:600">Waiting for <code>${target.service}</code></h1>
|
|
5025
|
+
<p>LocalDeck is holding this port. Nothing is listening on the upstream port <code>${target.upstreamPort}</code> yet.
|
|
5026
|
+
This page reloads every 2 seconds.</p></div>`;
|
|
4303
5027
|
}
|
|
4304
|
-
function
|
|
4305
|
-
|
|
5028
|
+
function proxyRequest(req, res, target, hooks) {
|
|
5029
|
+
const started = Date.now();
|
|
5030
|
+
const requestHeaders = captureHeaders(req.rawHeaders);
|
|
5031
|
+
const requestBody = new BodyCapture;
|
|
5032
|
+
const responseBody = new BodyCapture;
|
|
5033
|
+
const fullResponse = hooks.responseBodies?.start(target.service);
|
|
5034
|
+
const captureResponse = (chunk) => {
|
|
5035
|
+
responseBody.append(chunk);
|
|
5036
|
+
fullResponse?.append(chunk);
|
|
5037
|
+
};
|
|
5038
|
+
let responseComplete = false;
|
|
5039
|
+
let responseEncoding;
|
|
5040
|
+
let responseHeaders;
|
|
5041
|
+
let bytes = 0;
|
|
5042
|
+
let recorded = false;
|
|
5043
|
+
const record = (status, error) => {
|
|
5044
|
+
if (recorded)
|
|
5045
|
+
return;
|
|
5046
|
+
recorded = true;
|
|
5047
|
+
fullResponse?.finish(responseComplete, responseEncoding);
|
|
5048
|
+
hooks.onRequest({
|
|
5049
|
+
ts: started,
|
|
5050
|
+
service: target.service,
|
|
5051
|
+
method: req.method ?? "GET",
|
|
5052
|
+
path: req.url ?? "/",
|
|
5053
|
+
status,
|
|
5054
|
+
durationMs: Date.now() - started,
|
|
5055
|
+
bytes,
|
|
5056
|
+
via: target.via,
|
|
5057
|
+
error,
|
|
5058
|
+
requestHeaders,
|
|
5059
|
+
responseHeaders,
|
|
5060
|
+
requestBody: requestBody.snapshot(req.readableEnded, req.headers["content-encoding"]),
|
|
5061
|
+
responseBody: responseBody.snapshot(responseComplete, responseEncoding),
|
|
5062
|
+
responseBodyId: fullResponse?.id
|
|
5063
|
+
});
|
|
5064
|
+
};
|
|
5065
|
+
if (!target.upstreamReady) {
|
|
5066
|
+
responseHeaders = captureHeaders(["content-type", "text/html; charset=utf-8", "retry-after", "2"]);
|
|
5067
|
+
res.writeHead(503, { "content-type": "text/html; charset=utf-8", "retry-after": "2" });
|
|
5068
|
+
const body2 = waitingPage(target);
|
|
5069
|
+
captureResponse(Buffer.from(body2));
|
|
5070
|
+
responseComplete = true;
|
|
5071
|
+
res.end(body2);
|
|
5072
|
+
record(503, "upstream not ready");
|
|
5073
|
+
return;
|
|
5074
|
+
}
|
|
5075
|
+
let responseStatus = 502;
|
|
5076
|
+
let response;
|
|
5077
|
+
const upstream = http3.request({ host: target.upstreamHost, port: target.upstreamPort, method: req.method, path: req.url, headers: forwardHeaders(req, target) }, (up) => {
|
|
5078
|
+
if (res.destroyed) {
|
|
5079
|
+
up.destroy();
|
|
5080
|
+
return;
|
|
5081
|
+
}
|
|
5082
|
+
response = up;
|
|
5083
|
+
responseHeaders = captureHeaders(up.rawHeaders);
|
|
5084
|
+
responseEncoding = up.headers["content-encoding"];
|
|
5085
|
+
up.on("end", () => {
|
|
5086
|
+
responseComplete = true;
|
|
5087
|
+
});
|
|
5088
|
+
responseStatus = up.statusCode ?? 502;
|
|
5089
|
+
up.on("error", (error) => {
|
|
5090
|
+
record(502, `upstream response error: ${error.message}`);
|
|
5091
|
+
res.destroy();
|
|
5092
|
+
});
|
|
5093
|
+
up.on("aborted", () => {
|
|
5094
|
+
record(502, "upstream response aborted");
|
|
5095
|
+
res.destroy();
|
|
5096
|
+
});
|
|
5097
|
+
const headers = { ...up.headers };
|
|
5098
|
+
res.writeHead(up.statusCode ?? 502, up.statusMessage, headers);
|
|
5099
|
+
up.on("data", (chunk) => {
|
|
5100
|
+
bytes += chunk.length;
|
|
5101
|
+
captureResponse(chunk);
|
|
5102
|
+
});
|
|
5103
|
+
up.pipe(res);
|
|
5104
|
+
});
|
|
5105
|
+
upstream.on("error", (err) => {
|
|
5106
|
+
if (!res.headersSent)
|
|
5107
|
+
responseHeaders = captureHeaders(["content-type", "text/plain; charset=utf-8"]);
|
|
5108
|
+
if (!res.headersSent && !res.destroyed) {
|
|
5109
|
+
res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
|
|
5110
|
+
const body2 = `LocalDeck: upstream ${target.service} (${target.upstreamHost}:${target.upstreamPort}) error: ${err.code ?? err.message}`;
|
|
5111
|
+
captureResponse(Buffer.from(body2));
|
|
5112
|
+
responseComplete = true;
|
|
5113
|
+
res.end(body2);
|
|
5114
|
+
} else
|
|
5115
|
+
res.destroy();
|
|
5116
|
+
record(502, err.code ?? err.message);
|
|
5117
|
+
});
|
|
5118
|
+
const clientDisconnected = () => {
|
|
5119
|
+
record(499, "client disconnected before response completed");
|
|
5120
|
+
response?.destroy();
|
|
5121
|
+
upstream.destroy();
|
|
5122
|
+
};
|
|
5123
|
+
req.on("aborted", clientDisconnected);
|
|
5124
|
+
res.on("finish", () => record(responseStatus));
|
|
5125
|
+
res.on("close", () => {
|
|
5126
|
+
if (!res.writableFinished)
|
|
5127
|
+
clientDisconnected();
|
|
5128
|
+
});
|
|
5129
|
+
req.on("data", (chunk) => requestBody.append(chunk));
|
|
5130
|
+
req.pipe(upstream);
|
|
4306
5131
|
}
|
|
4307
|
-
function
|
|
4308
|
-
|
|
5132
|
+
function proxyUpgrade(req, socket, head, target, hooks) {
|
|
5133
|
+
const started = Date.now();
|
|
5134
|
+
socket.on("error", () => socket.destroy());
|
|
5135
|
+
if (!target.upstreamReady) {
|
|
5136
|
+
socket.end(`HTTP/1.1 503 Service Unavailable\r
|
|
5137
|
+
Connection: close\r
|
|
5138
|
+
\r
|
|
5139
|
+
`);
|
|
5140
|
+
return;
|
|
5141
|
+
}
|
|
5142
|
+
const up = net3.connect(target.upstreamPort, target.upstreamHost, () => {
|
|
5143
|
+
const headers = forwardHeaders(req, target);
|
|
5144
|
+
headers["connection"] = "Upgrade";
|
|
5145
|
+
headers["upgrade"] = req.headers["upgrade"];
|
|
5146
|
+
const lines = [`${req.method} ${req.url} HTTP/1.1`];
|
|
5147
|
+
for (const [k, v] of Object.entries(headers)) {
|
|
5148
|
+
if (v === undefined)
|
|
5149
|
+
continue;
|
|
5150
|
+
for (const val of Array.isArray(v) ? v : [String(v)])
|
|
5151
|
+
lines.push(`${k}: ${val}`);
|
|
5152
|
+
}
|
|
5153
|
+
up.write(lines.join(`\r
|
|
5154
|
+
`) + `\r
|
|
5155
|
+
\r
|
|
5156
|
+
`);
|
|
5157
|
+
if (head.length)
|
|
5158
|
+
up.write(head);
|
|
5159
|
+
up.pipe(socket).pipe(up);
|
|
5160
|
+
hooks.onRequest({
|
|
5161
|
+
ts: started,
|
|
5162
|
+
service: target.service,
|
|
5163
|
+
method: req.method ?? "GET",
|
|
5164
|
+
path: req.url ?? "/",
|
|
5165
|
+
status: 101,
|
|
5166
|
+
durationMs: Date.now() - started,
|
|
5167
|
+
bytes: 0,
|
|
5168
|
+
requestHeaders: captureHeaders(req.rawHeaders),
|
|
5169
|
+
via: target.via
|
|
5170
|
+
});
|
|
5171
|
+
});
|
|
5172
|
+
const kill = () => {
|
|
5173
|
+
up.destroy();
|
|
5174
|
+
socket.destroy();
|
|
5175
|
+
};
|
|
5176
|
+
up.on("error", kill);
|
|
5177
|
+
socket.on("error", kill);
|
|
5178
|
+
socket.on("close", () => up.destroy());
|
|
5179
|
+
up.on("close", () => socket.destroy());
|
|
4309
5180
|
}
|
|
4310
5181
|
|
|
4311
5182
|
// ../daemon/src/share.ts
|
|
4312
|
-
import
|
|
5183
|
+
import http4 from "node:http";
|
|
4313
5184
|
|
|
4314
5185
|
// ../daemon/src/share-readiness.ts
|
|
4315
5186
|
import { setTimeout as delay } from "node:timers/promises";
|
|
@@ -4518,7 +5389,7 @@ class ShareManager {
|
|
|
4518
5389
|
const publicRequest = new Promise((resolve) => {
|
|
4519
5390
|
confirmPublicRequest = resolve;
|
|
4520
5391
|
});
|
|
4521
|
-
const gate =
|
|
5392
|
+
const gate = http4.createServer((req, res) => {
|
|
4522
5393
|
if (publicHost && req.headers.host?.toLowerCase() === publicHost)
|
|
4523
5394
|
confirmPublicRequest();
|
|
4524
5395
|
if (req.method === "GET" && req.url === `/__localdeck/ready/${probe}`) {
|
|
@@ -4538,7 +5409,7 @@ class ShareManager {
|
|
|
4538
5409
|
req.resume();
|
|
4539
5410
|
return;
|
|
4540
5411
|
}
|
|
4541
|
-
let
|
|
5412
|
+
let body2 = "";
|
|
4542
5413
|
let bytes = 0;
|
|
4543
5414
|
let tooLarge = false;
|
|
4544
5415
|
req.on("data", (c) => {
|
|
@@ -4547,16 +5418,16 @@ class ShareManager {
|
|
|
4547
5418
|
bytes += c.length;
|
|
4548
5419
|
if (bytes > MAX_LOGIN_BYTES) {
|
|
4549
5420
|
tooLarge = true;
|
|
4550
|
-
|
|
5421
|
+
body2 = "";
|
|
4551
5422
|
} else
|
|
4552
|
-
|
|
5423
|
+
body2 += c;
|
|
4553
5424
|
});
|
|
4554
5425
|
req.on("end", () => {
|
|
4555
5426
|
if (tooLarge) {
|
|
4556
5427
|
res.writeHead(413, { "content-type": "text/plain; charset=utf-8" }).end("LocalDeck: login request is too large");
|
|
4557
5428
|
return;
|
|
4558
5429
|
}
|
|
4559
|
-
const given = new URLSearchParams(
|
|
5430
|
+
const given = new URLSearchParams(body2).get("password") ?? "";
|
|
4560
5431
|
const ok = passwordsMatch(given, password);
|
|
4561
5432
|
if (ok) {
|
|
4562
5433
|
const s = this.shares.get(key);
|
|
@@ -4931,339 +5802,131 @@ async function viaSs(pids) {
|
|
|
4931
5802
|
const match = columns[3].match(/^(.*):(\d+)$/);
|
|
4932
5803
|
if (!match)
|
|
4933
5804
|
continue;
|
|
4934
|
-
for (const pidMatch of line.matchAll(/pid=(\d+)/g)) {
|
|
4935
|
-
const pid2 = Number(pidMatch[1]);
|
|
4936
|
-
if (wanted.has(pid2))
|
|
4937
|
-
out.push({ pid: pid2, host: normalizeHost(match[1]), port: Number(match[2]) });
|
|
4938
|
-
}
|
|
4939
|
-
}
|
|
4940
|
-
return out;
|
|
4941
|
-
}
|
|
4942
|
-
function normalizeHost(host) {
|
|
4943
|
-
const normalized = host.replace(/^\[|\]$/g, "");
|
|
4944
|
-
return normalized === "*" || normalized === "0.0.0.0" || normalized === "::" || normalized === "" ? "*" : normalized;
|
|
4945
|
-
}
|
|
4946
|
-
function reachable(listener) {
|
|
4947
|
-
return listener.host === "*" || listener.host === "127.0.0.1" || listener.host === "::1";
|
|
4948
|
-
}
|
|
4949
|
-
function chooseUpstream(listeners, assignedPort) {
|
|
4950
|
-
let lowest;
|
|
4951
|
-
for (const listener of listeners) {
|
|
4952
|
-
if (IGNORED_PORTS.has(listener.port) || !reachable(listener))
|
|
4953
|
-
continue;
|
|
4954
|
-
if (listener.port === assignedPort)
|
|
4955
|
-
return listener;
|
|
4956
|
-
if (!lowest || listener.port < lowest.port)
|
|
4957
|
-
lowest = listener;
|
|
4958
|
-
}
|
|
4959
|
-
return lowest;
|
|
4960
|
-
}
|
|
4961
|
-
function unreachableListenerMessage(listeners) {
|
|
4962
|
-
const candidates = listeners.filter((listener2) => !IGNORED_PORTS.has(listener2.port));
|
|
4963
|
-
if (candidates.some(reachable))
|
|
4964
|
-
return;
|
|
4965
|
-
const listener = candidates[0];
|
|
4966
|
-
if (!listener)
|
|
4967
|
-
return;
|
|
4968
|
-
const address = listener.host.includes(":") ? `[${listener.host}]:${listener.port}` : `${listener.host}:${listener.port}`;
|
|
4969
|
-
return `the process is listening only on ${address}, which LocalDeck cannot reach. Bind it to localhost (127.0.0.1) or all interfaces (0.0.0.0) instead.`;
|
|
4970
|
-
}
|
|
4971
|
-
function connectHost(listener) {
|
|
4972
|
-
return listener.host === "*" ? "127.0.0.1" : listener.host;
|
|
4973
|
-
}
|
|
4974
|
-
function streamLines(stream, onLine) {
|
|
4975
|
-
const decoder = new StringDecoder("utf8");
|
|
4976
|
-
let buffered = "";
|
|
4977
|
-
let partialLineEmitted = false;
|
|
4978
|
-
const emitBounded = (line, force = false) => {
|
|
4979
|
-
if (!force && !line.trim())
|
|
4980
|
-
return;
|
|
4981
|
-
const remaining = emitUtf8Prefixes(line, onLine);
|
|
4982
|
-
if (remaining)
|
|
4983
|
-
onLine(remaining);
|
|
4984
|
-
};
|
|
4985
|
-
const consume = (text) => {
|
|
4986
|
-
buffered += text;
|
|
4987
|
-
const lines = buffered.split(/\r?\n/);
|
|
4988
|
-
buffered = lines.pop() ?? "";
|
|
4989
|
-
for (const line of lines) {
|
|
4990
|
-
emitBounded(line, partialLineEmitted);
|
|
4991
|
-
partialLineEmitted = false;
|
|
4992
|
-
}
|
|
4993
|
-
buffered = emitUtf8Prefixes(buffered, (line) => {
|
|
4994
|
-
onLine(line);
|
|
4995
|
-
partialLineEmitted = true;
|
|
4996
|
-
});
|
|
4997
|
-
};
|
|
4998
|
-
stream.on("data", (chunk) => consume(typeof chunk === "string" ? chunk : decoder.write(chunk)));
|
|
4999
|
-
stream.on("end", () => {
|
|
5000
|
-
consume(decoder.end());
|
|
5001
|
-
emitBounded(buffered, partialLineEmitted);
|
|
5002
|
-
buffered = "";
|
|
5003
|
-
partialLineEmitted = false;
|
|
5004
|
-
});
|
|
5005
|
-
}
|
|
5006
|
-
function emitUtf8Prefixes(value, onPrefix) {
|
|
5007
|
-
if (Buffer.byteLength(value) <= MAX_STREAM_LINE_BYTES)
|
|
5008
|
-
return value;
|
|
5009
|
-
let bytes = 0;
|
|
5010
|
-
let start = 0;
|
|
5011
|
-
for (let index = 0;index < value.length; ) {
|
|
5012
|
-
const codePoint = value.codePointAt(index);
|
|
5013
|
-
const width = codePoint <= 127 ? 1 : codePoint <= 2047 ? 2 : codePoint <= 65535 ? 3 : 4;
|
|
5014
|
-
if (bytes + width > MAX_STREAM_LINE_BYTES) {
|
|
5015
|
-
onPrefix(value.slice(start, index));
|
|
5016
|
-
start = index;
|
|
5017
|
-
bytes = 0;
|
|
5018
|
-
}
|
|
5019
|
-
bytes += width;
|
|
5020
|
-
index += codePoint > 65535 ? 2 : 1;
|
|
5021
|
-
}
|
|
5022
|
-
return value.slice(start);
|
|
5023
|
-
}
|
|
5024
|
-
async function defaultTaskkillRunner(file, args, options) {
|
|
5025
|
-
await exec2(file, args, options);
|
|
5026
|
-
}
|
|
5027
|
-
async function killProcessTree(child, signal, platform = process.platform, runTaskkill = defaultTaskkillRunner) {
|
|
5028
|
-
if (!child.pid)
|
|
5029
|
-
return false;
|
|
5030
|
-
if (platform === "win32") {
|
|
5031
|
-
const args = ["/pid", String(child.pid), "/t"];
|
|
5032
|
-
if (signal === "SIGKILL")
|
|
5033
|
-
args.push("/f");
|
|
5034
|
-
try {
|
|
5035
|
-
await runTaskkill("taskkill.exe", args, { timeout: 5000, killSignal: "SIGKILL" });
|
|
5036
|
-
return true;
|
|
5037
|
-
} catch {
|
|
5038
|
-
try {
|
|
5039
|
-
return child.kill(signal);
|
|
5040
|
-
} catch {
|
|
5041
|
-
return false;
|
|
5042
|
-
}
|
|
5043
|
-
}
|
|
5044
|
-
}
|
|
5045
|
-
try {
|
|
5046
|
-
return process.kill(-child.pid, signal);
|
|
5047
|
-
} catch {
|
|
5048
|
-
try {
|
|
5049
|
-
return child.kill(signal);
|
|
5050
|
-
} catch {
|
|
5051
|
-
return false;
|
|
5052
|
-
}
|
|
5053
|
-
}
|
|
5054
|
-
}
|
|
5055
|
-
var ANSI_RE = /[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[-a-zA-Z\d\/#&.:=?%@~_]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-ntqry=><~]))/g;
|
|
5056
|
-
function stripAnsi(value) {
|
|
5057
|
-
return value.replace(ANSI_RE, "");
|
|
5058
|
-
}
|
|
5059
|
-
|
|
5060
|
-
// ../daemon/src/diagnostics.ts
|
|
5061
|
-
import fs5 from "node:fs";
|
|
5062
|
-
import os3 from "node:os";
|
|
5063
|
-
import path5 from "node:path";
|
|
5064
|
-
function redactDiagnostic(text, secrets = []) {
|
|
5065
|
-
let output = text.replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/g, "[REDACTED PRIVATE KEY]").replace(/(authorization["']?\s*[:=]\s*["']?)(?:Bearer|Basic)\s+[^\s,"'}]+/gi, "$1[REDACTED]").replace(/((?:set-cookie|cookie)["']?\s*[:=]\s*)[^\r\n]+/gi, "$1[REDACTED]").replace(/(?<![a-z0-9+.-])([a-z][a-z0-9+.-]*:\/\/)([^\s/@]+)@/gi, (match, scheme, credentials) => credentials.includes(":") ? `${scheme}[REDACTED]@` : match).replace(/(?<![\w-])(?=[\w-]*(?:password|passwd|secret|token|api[_-]?key|credential))([\w-]+["']?\s*[:=]\s*)(?:"[^"\n]*"|'[^'\n]*'|[^\s,;&}\n]+)/gi, "$1[REDACTED]").replace(/((?:--)(?:password|token|secret|api-key)\s+)(?:"[^"]*"|'[^']*'|\S+)/gi, "$1[REDACTED]");
|
|
5066
|
-
for (const secret of [...new Set(secrets)].filter(Boolean).sort((a, b) => b.length - a.length))
|
|
5067
|
-
output = output.replaceAll(secret, "[REDACTED]");
|
|
5068
|
-
const home = os3.homedir();
|
|
5069
|
-
if (home.length > 1)
|
|
5070
|
-
output = output.replaceAll(home, "<home>");
|
|
5071
|
-
return output;
|
|
5072
|
-
}
|
|
5073
|
-
function privateEnvironmentValue(name, value) {
|
|
5074
|
-
if (name === "NODE_ENV" && /^(development|production|test)$/.test(value))
|
|
5075
|
-
return false;
|
|
5076
|
-
if (/^(?:PORT|[A-Z_]+_PORT)$/.test(name) && /^\d{1,5}$/.test(value) && Number(value) > 0 && Number(value) <= 65535)
|
|
5077
|
-
return false;
|
|
5078
|
-
return true;
|
|
5079
|
-
}
|
|
5080
|
-
function environmentValues(directory) {
|
|
5081
|
-
const values = [];
|
|
5082
|
-
for (const name of [".env", ".env.local", ".env.development", ".env.development.local"]) {
|
|
5083
|
-
const file = path5.join(directory, name);
|
|
5084
|
-
try {
|
|
5085
|
-
const stat = fs5.lstatSync(file);
|
|
5086
|
-
if (!stat.isFile() || stat.size > 65536)
|
|
5087
|
-
continue;
|
|
5088
|
-
const text = fs5.readFileSync(file, "utf8");
|
|
5089
|
-
for (const line of text.split(/\r?\n/)) {
|
|
5090
|
-
const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][\w]*)\s*=\s*(.*)$/);
|
|
5091
|
-
if (match) {
|
|
5092
|
-
const value = match[2].trim().replace(/^(['"])(.*)\1$/, "$2");
|
|
5093
|
-
if (privateEnvironmentValue(match[1], value))
|
|
5094
|
-
values.push(value);
|
|
5095
|
-
}
|
|
5096
|
-
}
|
|
5097
|
-
} catch {}
|
|
5098
|
-
}
|
|
5099
|
-
return values;
|
|
5100
|
-
}
|
|
5101
|
-
function diagnosticReport(project, services, logs, version, privateValues = []) {
|
|
5102
|
-
const inheritedSecrets = Object.entries(process.env).filter(([name]) => /password|passwd|secret|token|api[_-]?key|credential/i.test(name)).flatMap(([, value]) => value ? [value] : []);
|
|
5103
|
-
const directories = new Set([project.directory, ...project.commands.map((command) => path5.resolve(project.directory, command.cwd ?? "."))]);
|
|
5104
|
-
const secrets = [...privateValues, ...inheritedSecrets, ...[...directories].flatMap(environmentValues), ...project.commands.flatMap((command) => Object.entries(command.env ?? {}).filter(([name, value]) => privateEnvironmentValue(name, value)).map(([, value]) => value))];
|
|
5105
|
-
const report = {
|
|
5106
|
-
notice: "Review before sharing. Known secrets are redacted, but application logs may contain other private data.",
|
|
5107
|
-
version,
|
|
5108
|
-
project: { name: project.name, available: project.available, error: project.error },
|
|
5109
|
-
configuration: project.commands.map(({ env, ...command }) => ({ ...command, environmentVariables: Object.keys(env ?? {}) })),
|
|
5110
|
-
services: services.map((service) => ({ name: service.name, status: service.status, readiness: service.readinessStatus, message: service.readinessMessage, lastError: service.lastError, serviceType: service.serviceType ?? "http", port: service.stablePort })),
|
|
5111
|
-
logs: logs.slice(-100).map(({ stream, line, ts, service }) => ({ service, ts, stream, line: line.slice(0, 2000) }))
|
|
5112
|
-
};
|
|
5113
|
-
const sanitize = (value) => typeof value === "string" ? redactDiagnostic(value, secrets) : Array.isArray(value) ? value.map(sanitize) : value && typeof value === "object" ? Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sanitize(item)])) : value;
|
|
5114
|
-
return JSON.stringify(sanitize(report), null, 2);
|
|
5115
|
-
}
|
|
5116
|
-
|
|
5117
|
-
// ../daemon/src/replay.ts
|
|
5118
|
-
import http4 from "node:http";
|
|
5119
|
-
function replayRequest(service, record, options = {}) {
|
|
5120
|
-
if (record.service !== service.key || !["GET", "HEAD"].includes(record.method))
|
|
5121
|
-
throw new Error("Only recorded GET and HEAD requests for this service can be replayed");
|
|
5122
|
-
if (service.status !== "running" || !service.upstreamReady || service.serviceType && service.serviceType !== "http")
|
|
5123
|
-
throw new Error("The HTTP service must be running before replay");
|
|
5124
|
-
if (!["127.0.0.1", "::1"].includes(service.upstreamHost) || !record.path.startsWith("/") || record.path.startsWith("//") || /[\r\n\\\x00-\x20\x7f]/.test(record.path))
|
|
5125
|
-
throw new Error("Request path must be a local HTTP path");
|
|
5126
|
-
const timeoutMs = options.timeoutMs ?? 5000;
|
|
5127
|
-
const maxBytes = options.maxBytes ?? 65536;
|
|
5128
|
-
if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 5000 || !Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > 65536)
|
|
5129
|
-
throw new Error("Replay limits must be positive integers within 5000 ms and 65536 bytes");
|
|
5130
|
-
return new Promise((resolve, reject) => {
|
|
5131
|
-
const started = Date.now();
|
|
5132
|
-
let settled = false;
|
|
5133
|
-
let bytes = 0;
|
|
5134
|
-
const chunks = [];
|
|
5135
|
-
const request = http4.request({ host: service.upstreamHost, port: service.upstreamPort, path: record.path, method: record.method, headers: { "x-localdeck-replay": "1" } });
|
|
5136
|
-
const timer = setTimeout(() => {
|
|
5137
|
-
if (!settled) {
|
|
5138
|
-
settled = true;
|
|
5139
|
-
reject(new Error("Replay timed out"));
|
|
5140
|
-
}
|
|
5141
|
-
request.destroy();
|
|
5142
|
-
}, timeoutMs);
|
|
5143
|
-
const finish = (status, truncated) => {
|
|
5144
|
-
if (settled)
|
|
5145
|
-
return;
|
|
5146
|
-
settled = true;
|
|
5147
|
-
clearTimeout(timer);
|
|
5148
|
-
resolve({ status, truncated, body: Buffer.concat(chunks).toString("utf8"), durationMs: Date.now() - started });
|
|
5149
|
-
};
|
|
5150
|
-
request.on("response", (response) => {
|
|
5151
|
-
response.on("data", (chunk) => {
|
|
5152
|
-
const remaining = Math.max(0, maxBytes - bytes);
|
|
5153
|
-
chunks.push(chunk.subarray(0, remaining));
|
|
5154
|
-
bytes += chunk.length;
|
|
5155
|
-
if (bytes > maxBytes) {
|
|
5156
|
-
finish(response.statusCode ?? 502, true);
|
|
5157
|
-
response.destroy();
|
|
5158
|
-
request.destroy();
|
|
5159
|
-
}
|
|
5160
|
-
});
|
|
5161
|
-
response.on("end", () => finish(response.statusCode ?? 502, false));
|
|
5162
|
-
response.on("error", (error) => request.destroy(error));
|
|
5163
|
-
response.on("aborted", () => request.destroy(new Error("Replay response was interrupted")));
|
|
5164
|
-
});
|
|
5165
|
-
request.on("error", (error) => {
|
|
5166
|
-
clearTimeout(timer);
|
|
5167
|
-
if (!settled) {
|
|
5168
|
-
settled = true;
|
|
5169
|
-
reject(error);
|
|
5170
|
-
}
|
|
5171
|
-
});
|
|
5172
|
-
request.end();
|
|
5173
|
-
});
|
|
5805
|
+
for (const pidMatch of line.matchAll(/pid=(\d+)/g)) {
|
|
5806
|
+
const pid2 = Number(pidMatch[1]);
|
|
5807
|
+
if (wanted.has(pid2))
|
|
5808
|
+
out.push({ pid: pid2, host: normalizeHost(match[1]), port: Number(match[2]) });
|
|
5809
|
+
}
|
|
5810
|
+
}
|
|
5811
|
+
return out;
|
|
5174
5812
|
}
|
|
5175
|
-
|
|
5176
|
-
|
|
5177
|
-
|
|
5178
|
-
import path6 from "node:path";
|
|
5179
|
-
var ID = /^[a-f0-9]{16}$/;
|
|
5180
|
-
var NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
5181
|
-
|
|
5182
|
-
class ProjectSecretError extends Error {
|
|
5813
|
+
function normalizeHost(host) {
|
|
5814
|
+
const normalized = host.replace(/^\[|\]$/g, "");
|
|
5815
|
+
return normalized === "*" || normalized === "0.0.0.0" || normalized === "::" || normalized === "" ? "*" : normalized;
|
|
5183
5816
|
}
|
|
5184
|
-
function
|
|
5185
|
-
|
|
5186
|
-
throw new ProjectSecretError("invalid secret name");
|
|
5187
|
-
}
|
|
5817
|
+
function reachable(listener) {
|
|
5818
|
+
return listener.host === "*" || listener.host === "127.0.0.1" || listener.host === "::1";
|
|
5188
5819
|
}
|
|
5189
|
-
function
|
|
5190
|
-
|
|
5191
|
-
|
|
5192
|
-
if (
|
|
5193
|
-
|
|
5194
|
-
|
|
5820
|
+
function chooseUpstream(listeners, assignedPort) {
|
|
5821
|
+
let lowest;
|
|
5822
|
+
for (const listener of listeners) {
|
|
5823
|
+
if (IGNORED_PORTS.has(listener.port) || !reachable(listener))
|
|
5824
|
+
continue;
|
|
5825
|
+
if (listener.port === assignedPort)
|
|
5826
|
+
return listener;
|
|
5827
|
+
if (!lowest || listener.port < lowest.port)
|
|
5828
|
+
lowest = listener;
|
|
5195
5829
|
}
|
|
5196
|
-
|
|
5197
|
-
|
|
5198
|
-
|
|
5199
|
-
|
|
5200
|
-
|
|
5201
|
-
|
|
5202
|
-
|
|
5203
|
-
|
|
5204
|
-
|
|
5830
|
+
return lowest;
|
|
5831
|
+
}
|
|
5832
|
+
function unreachableListenerMessage(listeners) {
|
|
5833
|
+
const candidates = listeners.filter((listener2) => !IGNORED_PORTS.has(listener2.port));
|
|
5834
|
+
if (candidates.some(reachable))
|
|
5835
|
+
return;
|
|
5836
|
+
const listener = candidates[0];
|
|
5837
|
+
if (!listener)
|
|
5838
|
+
return;
|
|
5839
|
+
const address = listener.host.includes(":") ? `[${listener.host}]:${listener.port}` : `${listener.host}:${listener.port}`;
|
|
5840
|
+
return `the process is listening only on ${address}, which LocalDeck cannot reach. Bind it to localhost (127.0.0.1) or all interfaces (0.0.0.0) instead.`;
|
|
5841
|
+
}
|
|
5842
|
+
function connectHost(listener) {
|
|
5843
|
+
return listener.host === "*" ? "127.0.0.1" : listener.host;
|
|
5844
|
+
}
|
|
5845
|
+
function streamLines(stream, onLine) {
|
|
5846
|
+
const decoder = new StringDecoder("utf8");
|
|
5847
|
+
let buffered = "";
|
|
5848
|
+
let partialLineEmitted = false;
|
|
5849
|
+
const emitBounded = (line, force = false) => {
|
|
5850
|
+
if (!force && !line.trim())
|
|
5851
|
+
return;
|
|
5852
|
+
const remaining = emitUtf8Prefixes(line, onLine);
|
|
5853
|
+
if (remaining)
|
|
5854
|
+
onLine(remaining);
|
|
5855
|
+
};
|
|
5856
|
+
const consume = (text) => {
|
|
5857
|
+
buffered += text;
|
|
5858
|
+
const lines = buffered.split(/\r?\n/);
|
|
5859
|
+
buffered = lines.pop() ?? "";
|
|
5860
|
+
for (const line of lines) {
|
|
5861
|
+
emitBounded(line, partialLineEmitted);
|
|
5862
|
+
partialLineEmitted = false;
|
|
5205
5863
|
}
|
|
5206
|
-
|
|
5864
|
+
buffered = emitUtf8Prefixes(buffered, (line) => {
|
|
5865
|
+
onLine(line);
|
|
5866
|
+
partialLineEmitted = true;
|
|
5867
|
+
});
|
|
5868
|
+
};
|
|
5869
|
+
stream.on("data", (chunk) => consume(typeof chunk === "string" ? chunk : decoder.write(chunk)));
|
|
5870
|
+
stream.on("end", () => {
|
|
5871
|
+
consume(decoder.end());
|
|
5872
|
+
emitBounded(buffered, partialLineEmitted);
|
|
5873
|
+
buffered = "";
|
|
5874
|
+
partialLineEmitted = false;
|
|
5875
|
+
});
|
|
5876
|
+
}
|
|
5877
|
+
function emitUtf8Prefixes(value, onPrefix) {
|
|
5878
|
+
if (Buffer.byteLength(value) <= MAX_STREAM_LINE_BYTES)
|
|
5879
|
+
return value;
|
|
5880
|
+
let bytes = 0;
|
|
5881
|
+
let start = 0;
|
|
5882
|
+
for (let index = 0;index < value.length; ) {
|
|
5883
|
+
const codePoint = value.codePointAt(index);
|
|
5884
|
+
const width = codePoint <= 127 ? 1 : codePoint <= 2047 ? 2 : codePoint <= 65535 ? 3 : 4;
|
|
5885
|
+
if (bytes + width > MAX_STREAM_LINE_BYTES) {
|
|
5886
|
+
onPrefix(value.slice(start, index));
|
|
5887
|
+
start = index;
|
|
5888
|
+
bytes = 0;
|
|
5889
|
+
}
|
|
5890
|
+
bytes += width;
|
|
5891
|
+
index += codePoint > 65535 ? 2 : 1;
|
|
5892
|
+
}
|
|
5893
|
+
return value.slice(start);
|
|
5894
|
+
}
|
|
5895
|
+
async function defaultTaskkillRunner(file, args, options) {
|
|
5896
|
+
await exec2(file, args, options);
|
|
5897
|
+
}
|
|
5898
|
+
async function killProcessTree(child, signal, platform = process.platform, runTaskkill = defaultTaskkillRunner) {
|
|
5899
|
+
if (!child.pid)
|
|
5900
|
+
return false;
|
|
5901
|
+
if (platform === "win32") {
|
|
5902
|
+
const args = ["/pid", String(child.pid), "/t"];
|
|
5903
|
+
if (signal === "SIGKILL")
|
|
5904
|
+
args.push("/f");
|
|
5207
5905
|
try {
|
|
5208
|
-
|
|
5906
|
+
await runTaskkill("taskkill.exe", args, { timeout: 5000, killSignal: "SIGKILL" });
|
|
5907
|
+
return true;
|
|
5209
5908
|
} catch {
|
|
5210
|
-
|
|
5211
|
-
|
|
5212
|
-
|
|
5213
|
-
|
|
5214
|
-
}
|
|
5215
|
-
const values = Object.create(null);
|
|
5216
|
-
for (const [name, value] of Object.entries(parsed)) {
|
|
5217
|
-
validateName(name);
|
|
5218
|
-
if (typeof value !== "string" || !value || value.includes("\x00")) {
|
|
5219
|
-
throw new ProjectSecretError("stored secrets are corrupt");
|
|
5909
|
+
try {
|
|
5910
|
+
return child.kill(signal);
|
|
5911
|
+
} catch {
|
|
5912
|
+
return false;
|
|
5220
5913
|
}
|
|
5221
|
-
values[name] = value;
|
|
5222
|
-
}
|
|
5223
|
-
return values;
|
|
5224
|
-
}
|
|
5225
|
-
function write(projectId2, values) {
|
|
5226
|
-
const file = secretPath(projectId2);
|
|
5227
|
-
fs6.mkdirSync(directory, { recursive: true, mode: 448 });
|
|
5228
|
-
if (process.platform !== "win32")
|
|
5229
|
-
fs6.chmodSync(directory, 448);
|
|
5230
|
-
writePrivateJson(file, values);
|
|
5231
|
-
}
|
|
5232
|
-
function listProjectSecrets(projectId2) {
|
|
5233
|
-
return Object.keys(read(projectId2)).sort();
|
|
5234
|
-
}
|
|
5235
|
-
function setProjectSecret(projectId2, name, value) {
|
|
5236
|
-
validateName(name);
|
|
5237
|
-
if (typeof value !== "string" || !value || value.includes("\x00")) {
|
|
5238
|
-
throw new ProjectSecretError("secret value must be a nonempty string without NUL");
|
|
5239
5914
|
}
|
|
5240
|
-
const values = read(projectId2);
|
|
5241
|
-
values[name] = value;
|
|
5242
|
-
write(projectId2, values);
|
|
5243
|
-
}
|
|
5244
|
-
function deleteProjectSecret(projectId2, name) {
|
|
5245
|
-
validateName(name);
|
|
5246
|
-
const values = read(projectId2);
|
|
5247
|
-
delete values[name];
|
|
5248
|
-
write(projectId2, values);
|
|
5249
5915
|
}
|
|
5250
|
-
|
|
5251
|
-
|
|
5252
|
-
|
|
5253
|
-
|
|
5254
|
-
|
|
5255
|
-
|
|
5256
|
-
|
|
5257
|
-
if (!Object.prototype.hasOwnProperty.call(values, name)) {
|
|
5258
|
-
throw new ProjectSecretError(`Add ${name} in Project → Secrets.`);
|
|
5259
|
-
}
|
|
5260
|
-
output[name] = values[name];
|
|
5916
|
+
try {
|
|
5917
|
+
return process.kill(-child.pid, signal);
|
|
5918
|
+
} catch {
|
|
5919
|
+
try {
|
|
5920
|
+
return child.kill(signal);
|
|
5921
|
+
} catch {
|
|
5922
|
+
return false;
|
|
5261
5923
|
}
|
|
5262
|
-
return output;
|
|
5263
5924
|
}
|
|
5264
|
-
return { listProjectSecrets, setProjectSecret, deleteProjectSecret, resolveProjectSecrets };
|
|
5265
5925
|
}
|
|
5266
|
-
var
|
|
5926
|
+
var ANSI_RE = /[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[-a-zA-Z\d\/#&.:=?%@~_]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-ntqry=><~]))/g;
|
|
5927
|
+
function stripAnsi(value) {
|
|
5928
|
+
return value.replace(ANSI_RE, "");
|
|
5929
|
+
}
|
|
5267
5930
|
|
|
5268
5931
|
// ../daemon/src/api.ts
|
|
5269
5932
|
var MIME = { ".html": "text/html; charset=utf-8", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".json": "application/json", ".png": "image/png", ".ico": "image/x-icon", ".woff": "font/woff", ".woff2": "font/woff2" };
|
|
@@ -5272,9 +5935,9 @@ var MAX_ATTACH_BYTES = 65536;
|
|
|
5272
5935
|
var MAX_ATTACH_CWD_CHARS = 4096;
|
|
5273
5936
|
var MAX_ATTACH_COMMAND_CHARS = 16384;
|
|
5274
5937
|
var MAX_ATTACH_LOG_CHARS = 16384;
|
|
5275
|
-
function json(res, status,
|
|
5938
|
+
function json(res, status, body2) {
|
|
5276
5939
|
res.writeHead(status, { "content-type": "application/json" });
|
|
5277
|
-
res.end(JSON.stringify(
|
|
5940
|
+
res.end(JSON.stringify(body2));
|
|
5278
5941
|
}
|
|
5279
5942
|
|
|
5280
5943
|
class HttpError extends Error {
|
|
@@ -5423,7 +6086,7 @@ function createApiServer(deps) {
|
|
|
5423
6086
|
const { registry, store, shares } = deps;
|
|
5424
6087
|
const allowedBrowserOrigins = validatedBrowserOrigins(deps.allowedBrowserOrigins);
|
|
5425
6088
|
const hooks = { responseBodies: store.responseBodies, onRequest: (r) => store.request(r) };
|
|
5426
|
-
const forwarderIdentity =
|
|
6089
|
+
const forwarderIdentity = randomUUID2();
|
|
5427
6090
|
const forwarder = new CleanUrls(() => {
|
|
5428
6091
|
const address = server.address();
|
|
5429
6092
|
if (!address || typeof address === "string")
|
|
@@ -5463,10 +6126,10 @@ function createApiServer(deps) {
|
|
|
5463
6126
|
if (req.method === "DELETE")
|
|
5464
6127
|
return json(res, 200, await forwarder.uninstall());
|
|
5465
6128
|
if (req.method === "POST") {
|
|
5466
|
-
const
|
|
5467
|
-
if (!
|
|
6129
|
+
const body2 = await readBody(req);
|
|
6130
|
+
if (!body2 || typeof body2 !== "object" || Array.isArray(body2) || Object.keys(body2).some((key) => !["install", "automatic"].includes(key)) || body2.install !== undefined && typeof body2.install !== "boolean" || body2.automatic !== undefined && typeof body2.automatic !== "boolean")
|
|
5468
6131
|
throw new HttpError(400, "invalid forwarding options");
|
|
5469
|
-
return json(res, 200, await forwarder.enable(
|
|
6132
|
+
return json(res, 200, await forwarder.enable(body2));
|
|
5470
6133
|
}
|
|
5471
6134
|
return json(res, 200, await forwarder.status());
|
|
5472
6135
|
}
|
|
@@ -5593,6 +6256,8 @@ async function handleApi(req, res, url, parts, deps) {
|
|
|
5593
6256
|
stopping.catch(() => {});
|
|
5594
6257
|
return;
|
|
5595
6258
|
}
|
|
6259
|
+
if (await handleDebuggingApi(req, res, url, parts, deps))
|
|
6260
|
+
return;
|
|
5596
6261
|
if (parts[0] === "projects") {
|
|
5597
6262
|
if (parts.length === 1 && method === "GET")
|
|
5598
6263
|
return json(res, 200, projects.list());
|
|
@@ -5600,11 +6265,11 @@ async function handleApi(req, res, url, parts, deps) {
|
|
|
5600
6265
|
requireMutationAllowed(deps);
|
|
5601
6266
|
if (!hasControlToken(req, deps.controlToken))
|
|
5602
6267
|
throw new HttpError(403, "forbidden");
|
|
5603
|
-
const
|
|
6268
|
+
const body2 = await readBody(req);
|
|
5604
6269
|
requireMutationAllowed(deps);
|
|
5605
|
-
if (!
|
|
6270
|
+
if (!body2 || typeof body2 !== "object" || Array.isArray(body2))
|
|
5606
6271
|
throw new HttpError(400, "request body must be a JSON object");
|
|
5607
|
-
const file =
|
|
6272
|
+
const file = body2.file;
|
|
5608
6273
|
if (typeof file !== "string")
|
|
5609
6274
|
throw new HttpError(400, "file must be a string");
|
|
5610
6275
|
try {
|
|
@@ -5634,22 +6299,22 @@ async function handleApi(req, res, url, parts, deps) {
|
|
|
5634
6299
|
throw new ProjectError("invalid", project.error ?? "project configuration is invalid");
|
|
5635
6300
|
const name = parts[3];
|
|
5636
6301
|
if (method === "PUT") {
|
|
5637
|
-
const
|
|
5638
|
-
if (!
|
|
6302
|
+
const body2 = await readBody(req);
|
|
6303
|
+
if (!body2 || typeof body2 !== "object" || Array.isArray(body2) || Object.keys(body2).length !== 1 || typeof body2.value !== "string")
|
|
5639
6304
|
throw new HttpError(400, 'request body must contain only a string "value"');
|
|
5640
|
-
(deps.setProjectSecret ?? setProjectSecret)(projectId2, name,
|
|
6305
|
+
(deps.setProjectSecret ?? setProjectSecret)(projectId2, name, body2.value);
|
|
5641
6306
|
} else
|
|
5642
6307
|
(deps.deleteProjectSecret ?? deleteProjectSecret)(projectId2, name);
|
|
5643
6308
|
return json(res, 200, { ok: true });
|
|
5644
6309
|
}
|
|
5645
6310
|
if (parts.length === 3 && parts[2] === "nickname" && method === "PATCH") {
|
|
5646
6311
|
requireMutationAllowed(deps);
|
|
5647
|
-
const
|
|
6312
|
+
const body2 = await readBody(req);
|
|
5648
6313
|
requireMutationAllowed(deps);
|
|
5649
|
-
if (!
|
|
6314
|
+
if (!body2 || typeof body2 !== "object" || Array.isArray(body2) || Object.keys(body2).length !== 1 || typeof body2.nickname !== "string") {
|
|
5650
6315
|
throw new HttpError(400, 'request body must contain only a string "nickname"');
|
|
5651
6316
|
}
|
|
5652
|
-
const details = projects.saveConfigNickname(projectId2,
|
|
6317
|
+
const details = projects.saveConfigNickname(projectId2, body2.nickname);
|
|
5653
6318
|
registry.refreshProjectUrls(projectId2);
|
|
5654
6319
|
return json(res, 200, { ...details, services: registry.list(projectId2) });
|
|
5655
6320
|
}
|
|
@@ -5661,16 +6326,20 @@ async function handleApi(req, res, url, parts, deps) {
|
|
|
5661
6326
|
const secretValues = Object.values((deps.resolveProjectSecrets ?? resolveProjectSecrets)(projectId2, names));
|
|
5662
6327
|
return json(res, 200, { report: diagnosticReport(project, services, logs, deps.version, [deps.controlToken, ...secretValues]) });
|
|
5663
6328
|
}
|
|
5664
|
-
if (parts.length ===
|
|
6329
|
+
if (parts.length === 3 && parts[2] === "profiles" && method === "GET") {
|
|
6330
|
+
const details = requireRememberedProject(projects, projectId2);
|
|
6331
|
+
if (!details.available)
|
|
6332
|
+
throw new ProjectError("invalid", details.error ?? "project configuration is invalid");
|
|
6333
|
+
return json(res, 200, profilesResponse(details));
|
|
6334
|
+
}
|
|
6335
|
+
if (parts.length === 4 && parts[2] === "profiles" && parts[3] === "active" && method === "PUT") {
|
|
5665
6336
|
requireMutationAllowed(deps);
|
|
5666
|
-
await readBody(req);
|
|
6337
|
+
const body2 = await readBody(req);
|
|
5667
6338
|
requireMutationAllowed(deps);
|
|
5668
|
-
|
|
5669
|
-
|
|
5670
|
-
|
|
5671
|
-
|
|
5672
|
-
const results = await Promise.all(names.map((name) => supervisor.start(projectId2, name)));
|
|
5673
|
-
return json(res, 200, { ok: results.every((result) => result.ok), results });
|
|
6339
|
+
if (!body2 || typeof body2 !== "object" || Array.isArray(body2) || Object.keys(body2).some((key) => key !== "profiles"))
|
|
6340
|
+
throw new HttpError(400, 'request body must be { "profiles": [...] }');
|
|
6341
|
+
const details = projects.setActiveProfiles(projectId2, body2.profiles);
|
|
6342
|
+
return json(res, 200, profilesResponse(details));
|
|
5674
6343
|
}
|
|
5675
6344
|
if (parts.length === 2 && method === "GET") {
|
|
5676
6345
|
const details = projects.details(projectId2);
|
|
@@ -5703,14 +6372,14 @@ async function handleApi(req, res, url, parts, deps) {
|
|
|
5703
6372
|
}
|
|
5704
6373
|
if (parts.length === 5 && parts[2] === "services" && parts[4] === "dependencies" && method === "PATCH") {
|
|
5705
6374
|
requireMutationAllowed(deps);
|
|
5706
|
-
const
|
|
6375
|
+
const body2 = await readBody(req);
|
|
5707
6376
|
requireMutationAllowed(deps);
|
|
5708
|
-
if (!
|
|
6377
|
+
if (!body2 || typeof body2 !== "object" || Array.isArray(body2))
|
|
5709
6378
|
throw new HttpError(400, "request body must be a JSON object");
|
|
5710
|
-
const fields = Object.keys(
|
|
6379
|
+
const fields = Object.keys(body2);
|
|
5711
6380
|
if (fields.length !== 1 || fields[0] !== "after")
|
|
5712
6381
|
throw new HttpError(400, 'request body may contain only "after"');
|
|
5713
|
-
const after =
|
|
6382
|
+
const after = body2.after;
|
|
5714
6383
|
if (!Array.isArray(after) || !after.every((dependency) => typeof dependency === "string")) {
|
|
5715
6384
|
throw new HttpError(400, "after must be an array of service names");
|
|
5716
6385
|
}
|
|
@@ -5768,11 +6437,11 @@ async function handleApi(req, res, url, parts, deps) {
|
|
|
5768
6437
|
}
|
|
5769
6438
|
if (sub === "replay" && method === "POST") {
|
|
5770
6439
|
requireMutationAllowed(deps);
|
|
5771
|
-
const
|
|
6440
|
+
const body2 = await readBody(req);
|
|
5772
6441
|
requireMutationAllowed(deps);
|
|
5773
|
-
if (!
|
|
6442
|
+
if (!body2 || typeof body2 !== "object" || Array.isArray(body2) || Object.keys(body2).length !== 1 || typeof body2.requestId !== "string")
|
|
5774
6443
|
throw new HttpError(400, "Provide only a recorded requestId");
|
|
5775
|
-
const record = store.requests(key, 5000).find((request) => request.id ===
|
|
6444
|
+
const record = store.requests(key, 5000).find((request) => request.id === body2.requestId);
|
|
5776
6445
|
if (!record)
|
|
5777
6446
|
throw new HttpError(404, "Recorded request is no longer available");
|
|
5778
6447
|
try {
|
|
@@ -5814,11 +6483,11 @@ async function handleApi(req, res, url, parts, deps) {
|
|
|
5814
6483
|
requireMutationAllowed(deps);
|
|
5815
6484
|
if (svc.serviceType && svc.serviceType !== "http")
|
|
5816
6485
|
throw new HttpError(422, "Database endpoints cannot be shared over HTTP");
|
|
5817
|
-
const
|
|
6486
|
+
const body2 = await readBody(req);
|
|
5818
6487
|
requireMutationAllowed(deps);
|
|
5819
|
-
if (!
|
|
6488
|
+
if (!body2 || typeof body2 !== "object" || Array.isArray(body2))
|
|
5820
6489
|
throw new HttpError(400, "request body must be a JSON object");
|
|
5821
|
-
const { password, public: isPublic } =
|
|
6490
|
+
const { password, public: isPublic } = body2;
|
|
5822
6491
|
if (password !== undefined && typeof password !== "string")
|
|
5823
6492
|
throw new HttpError(400, "password must be a string");
|
|
5824
6493
|
if (isPublic !== undefined && typeof isPublic !== "boolean")
|
|
@@ -5851,20 +6520,20 @@ function serveStatic(pathname, res, dir) {
|
|
|
5851
6520
|
res.end("<h1>LocalDeck daemon</h1><p>Dashboard not built. Run <code>bun run build</code> in the repo. API is at <a href=/api/services>/api/services</a>.</p>");
|
|
5852
6521
|
return;
|
|
5853
6522
|
}
|
|
5854
|
-
let file =
|
|
6523
|
+
let file = path10.join(dir, path10.normalize(pathname).replace(/^(\.\.[/\\])+/, ""));
|
|
5855
6524
|
if (!file.startsWith(dir))
|
|
5856
|
-
file =
|
|
5857
|
-
if (!
|
|
5858
|
-
file =
|
|
5859
|
-
const hashedAsset =
|
|
6525
|
+
file = path10.join(dir, "index.html");
|
|
6526
|
+
if (!fs9.existsSync(file) || fs9.statSync(file).isDirectory())
|
|
6527
|
+
file = path10.join(dir, "index.html");
|
|
6528
|
+
const hashedAsset = path10.dirname(file) === path10.join(dir, "assets") && /-[A-Za-z0-9_-]{8,}\.[a-z0-9]+$/i.test(path10.basename(file));
|
|
5860
6529
|
res.writeHead(200, {
|
|
5861
|
-
"content-type": MIME[
|
|
6530
|
+
"content-type": MIME[path10.extname(file)] ?? "application/octet-stream",
|
|
5862
6531
|
"cache-control": hashedAsset ? "public, max-age=31536000, immutable" : "no-cache",
|
|
5863
6532
|
"x-frame-options": "DENY",
|
|
5864
6533
|
"content-security-policy": "frame-ancestors 'none'",
|
|
5865
6534
|
"x-content-type-options": "nosniff"
|
|
5866
6535
|
});
|
|
5867
|
-
|
|
6536
|
+
fs9.createReadStream(file).pipe(res);
|
|
5868
6537
|
}
|
|
5869
6538
|
function handleAttach(ws, deps) {
|
|
5870
6539
|
const { registry, store } = deps;
|
|
@@ -5986,6 +6655,8 @@ function parseAttachMessage(raw) {
|
|
|
5986
6655
|
if (input.upstreamSource !== undefined && input.upstreamSource !== "assigned" && input.upstreamSource !== "manual")
|
|
5987
6656
|
throw new Error("upstreamSource must be assigned or manual");
|
|
5988
6657
|
optionalPid(input.pid);
|
|
6658
|
+
if (input.profiles !== undefined && (!Array.isArray(input.profiles) || input.profiles.length > 64 || !input.profiles.every((name) => typeof name === "string" && /^[a-z0-9][a-z0-9_-]{0,40}$/.test(name))))
|
|
6659
|
+
throw new Error("profiles must be an array of profile names");
|
|
5989
6660
|
return input;
|
|
5990
6661
|
}
|
|
5991
6662
|
if (input.type === "log") {
|
|
@@ -6068,14 +6739,14 @@ function buildRegistration(message, deps) {
|
|
|
6068
6739
|
pid: message.pid
|
|
6069
6740
|
};
|
|
6070
6741
|
}
|
|
6071
|
-
const project = deps.projects.details(requestedProject);
|
|
6742
|
+
const project = deps.projects.details(requestedProject, message.profiles);
|
|
6072
6743
|
if (!project.available)
|
|
6073
6744
|
throw new ProjectError("invalid", project.error ?? "project configuration is invalid");
|
|
6074
|
-
const command = deps.projects.resolve(requestedProject, canonicalName);
|
|
6745
|
+
const command = deps.projects.resolve(requestedProject, canonicalName, message.profiles);
|
|
6075
6746
|
if (command.serviceType && command.serviceType !== "http" && (message.upstreamPort !== command.upstream || message.stablePort !== undefined)) {
|
|
6076
6747
|
throw new Error("database registration must use its configured published upstream port");
|
|
6077
6748
|
}
|
|
6078
|
-
const cwd = command.cwd ?
|
|
6749
|
+
const cwd = command.cwd ? path10.resolve(project.directory, command.cwd) : project.directory;
|
|
6079
6750
|
requireDirectory(cwd);
|
|
6080
6751
|
return {
|
|
6081
6752
|
key: serviceKey(project.id, command.name),
|
|
@@ -6090,12 +6761,23 @@ function buildRegistration(message, deps) {
|
|
|
6090
6761
|
upstreamSource: message.upstreamSource ?? (command.upstream === undefined ? "assigned" : "manual"),
|
|
6091
6762
|
cwd,
|
|
6092
6763
|
command: command.run,
|
|
6093
|
-
pid: message.pid
|
|
6764
|
+
pid: message.pid,
|
|
6765
|
+
...project.activeProfiles?.length ? { profiles: project.activeProfiles } : {},
|
|
6766
|
+
configFingerprint: commandFingerprint(command)
|
|
6767
|
+
};
|
|
6768
|
+
}
|
|
6769
|
+
function profilesResponse(details) {
|
|
6770
|
+
return {
|
|
6771
|
+
profiles: details.profiles ?? [],
|
|
6772
|
+
active: details.activeProfiles ?? [],
|
|
6773
|
+
clashes: details.profileClashes ?? [],
|
|
6774
|
+
...details.profileError ? { error: details.profileError } : {},
|
|
6775
|
+
...details.legacyProfiles ? { legacyProfiles: details.legacyProfiles } : {}
|
|
6094
6776
|
};
|
|
6095
6777
|
}
|
|
6096
6778
|
function requireDirectory(cwd) {
|
|
6097
6779
|
try {
|
|
6098
|
-
if (!
|
|
6780
|
+
if (!fs9.statSync(cwd).isDirectory())
|
|
6099
6781
|
throw new Error("not a directory");
|
|
6100
6782
|
} catch {
|
|
6101
6783
|
throw new Error(`working directory does not exist or is not a directory: ${cwd}`);
|
|
@@ -6330,6 +7012,8 @@ class Registry {
|
|
|
6330
7012
|
pid: opts.pid,
|
|
6331
7013
|
cwd: opts.cwd,
|
|
6332
7014
|
command: opts.command,
|
|
7015
|
+
...opts.profiles?.length ? { profiles: [...opts.profiles] } : {},
|
|
7016
|
+
...opts.configFingerprint ? { configFingerprint: opts.configFingerprint } : {},
|
|
6333
7017
|
startedAt: Date.now(),
|
|
6334
7018
|
upstreamReady: false,
|
|
6335
7019
|
urls: [
|
|
@@ -6564,7 +7248,12 @@ class Registry {
|
|
|
6564
7248
|
}
|
|
6565
7249
|
|
|
6566
7250
|
// ../daemon/src/store.ts
|
|
6567
|
-
import
|
|
7251
|
+
import fs11 from "node:fs";
|
|
7252
|
+
|
|
7253
|
+
// ../daemon/src/activity.ts
|
|
7254
|
+
import fs10 from "node:fs";
|
|
7255
|
+
import path11 from "node:path";
|
|
7256
|
+
import { randomUUID as randomUUID3, createHash as createHash4 } from "node:crypto";
|
|
6568
7257
|
|
|
6569
7258
|
// ../daemon/src/history.ts
|
|
6570
7259
|
function isAfterHistoryBoundary(id, boundary) {
|
|
@@ -6575,8 +7264,231 @@ function isAfterHistoryBoundary(id, boundary) {
|
|
|
6575
7264
|
return started > boundaryStarted || started === boundaryStarted && sequence > boundarySequence;
|
|
6576
7265
|
}
|
|
6577
7266
|
|
|
7267
|
+
// ../daemon/src/activity.ts
|
|
7268
|
+
var ERROR = /\b(error|exception|unhandled|fatal|EADDRINUSE|ECONNREFUSED)\b/i;
|
|
7269
|
+
var MAX_EVENTS = 2000;
|
|
7270
|
+
var MAX_MARKERS = 50;
|
|
7271
|
+
function compare(a, b) {
|
|
7272
|
+
if (a.ts !== b.ts)
|
|
7273
|
+
return a.ts - b.ts;
|
|
7274
|
+
if (a.id === b.id)
|
|
7275
|
+
return 0;
|
|
7276
|
+
return isAfterHistoryBoundary(a.id, b.id) ? 1 : isAfterHistoryBoundary(b.id, a.id) ? -1 : a.id.localeCompare(b.id);
|
|
7277
|
+
}
|
|
7278
|
+
function sanitizeSignature(value, secrets2) {
|
|
7279
|
+
const querySafe = value.replace(/([?&])([^=&#\s]+)=([^&#\s]*)/g, (original, separator, key, raw) => {
|
|
7280
|
+
const decoded = new URLSearchParams(`${key}=${raw}`);
|
|
7281
|
+
const pair = [...decoded][0];
|
|
7282
|
+
if (pair && /password|passwd|secret|token|api[_-]?key|credential|session|signature|authorization|cookie/i.test(pair[0]))
|
|
7283
|
+
return `${separator}${key}=[REDACTED]`;
|
|
7284
|
+
if (pair && secrets2.some((secret) => secret && pair[1].includes(secret)))
|
|
7285
|
+
return `${separator}${key}=[REDACTED]`;
|
|
7286
|
+
return original;
|
|
7287
|
+
});
|
|
7288
|
+
return redactDiagnostic(querySafe, secrets2.flatMap((secret) => [secret, encodeURIComponent(secret)]));
|
|
7289
|
+
}
|
|
7290
|
+
function boundedSignature(value) {
|
|
7291
|
+
return value.length <= 300 ? value : `${value.slice(0, 230)}… [${createHash4("sha256").update(value).digest("hex")}]`;
|
|
7292
|
+
}
|
|
7293
|
+
function validEvent(value) {
|
|
7294
|
+
if (!value || typeof value !== "object")
|
|
7295
|
+
return false;
|
|
7296
|
+
const e = value;
|
|
7297
|
+
return typeof e.id === "string" && Number.isFinite(e.ts) && typeof e.service === "string" && e.service.includes(":") && ["lifecycle", "clear"].includes(e.kind) && ["info", "error"].includes(e.severity) && typeof e.title === "string" && e.title.length <= 2000 && (e.kind !== "lifecycle" || !!e.transition && typeof e.transition.status === "string");
|
|
7298
|
+
}
|
|
7299
|
+
function validMarker(value) {
|
|
7300
|
+
if (!value || typeof value !== "object")
|
|
7301
|
+
return false;
|
|
7302
|
+
const m = value, b = m.before, c = m.coverage;
|
|
7303
|
+
return typeof m.id === "string" && typeof m.projectId === "string" && Array.isArray(m.services) && m.services.length > 0 && m.services.length <= 100 && m.services.every((s) => typeof s === "string" && s.startsWith(`${m.projectId}:`)) && typeof m.label === "string" && m.label.length <= 100 && Number.isFinite(m.ts) && typeof m.boundary === "string" && !!b && [b.from, b.to, b.requests, b.requestErrors, b.errorLines, b.restarts].every(Number.isFinite) && (b.errorRate === null || Number.isFinite(b.errorRate)) && typeof b.truncated === "boolean" && Array.isArray(b.signatures) && b.signatures.length <= 100 && b.signatures.every((s) => typeof s === "string" && s.length <= 300) && Array.isArray(b.routes) && b.routes.length <= 100 && b.routes.every((r) => r && typeof r.route === "string" && r.route.length <= 300 && [r.count, r.errors, r.avgLatencyMs].every(Number.isFinite)) && !!c && typeof c.partial === "boolean" && Array.isArray(c.reasons) && c.reasons.every((r) => typeof r === "string");
|
|
7304
|
+
}
|
|
7305
|
+
function summary(events, from, to, secrets2 = []) {
|
|
7306
|
+
const signatures = new Set, routes = new Map;
|
|
7307
|
+
let requests = 0, requestErrors = 0, errorLines = 0, restarts = 0, truncated = false;
|
|
7308
|
+
for (const event of events) {
|
|
7309
|
+
if (event.kind === "request" && event.request) {
|
|
7310
|
+
requests++;
|
|
7311
|
+
if (event.severity === "error")
|
|
7312
|
+
requestErrors++;
|
|
7313
|
+
const text = sanitizeSignature(`${event.request.method} ${event.request.path}`, secrets2);
|
|
7314
|
+
const key = boundedSignature(text);
|
|
7315
|
+
if (text.length > 300)
|
|
7316
|
+
truncated = true;
|
|
7317
|
+
if (event.severity === "error") {
|
|
7318
|
+
if (signatures.size < 100)
|
|
7319
|
+
signatures.add(key);
|
|
7320
|
+
else
|
|
7321
|
+
truncated = true;
|
|
7322
|
+
}
|
|
7323
|
+
if (!routes.has(key) && routes.size < 100)
|
|
7324
|
+
routes.set(key, { route: key, count: 0, errors: 0, total: 0 });
|
|
7325
|
+
const row = routes.get(key);
|
|
7326
|
+
if (row) {
|
|
7327
|
+
row.count++;
|
|
7328
|
+
row.errors += Number(event.severity === "error");
|
|
7329
|
+
row.total += event.request.durationMs;
|
|
7330
|
+
} else
|
|
7331
|
+
truncated = true;
|
|
7332
|
+
} else if (event.kind === "log" && event.severity === "error") {
|
|
7333
|
+
errorLines++;
|
|
7334
|
+
const text = sanitizeSignature(event.title, secrets2), signature = boundedSignature(text);
|
|
7335
|
+
if (text.length > 300)
|
|
7336
|
+
truncated = true;
|
|
7337
|
+
if (signatures.size < 100)
|
|
7338
|
+
signatures.add(signature);
|
|
7339
|
+
else
|
|
7340
|
+
truncated = true;
|
|
7341
|
+
} else if (event.kind === "lifecycle" && event.title === "Service restarted")
|
|
7342
|
+
restarts++;
|
|
7343
|
+
}
|
|
7344
|
+
return { from, to, requests, requestErrors, errorRate: requests ? requestErrors / requests : null, errorLines, restarts, signatures: [...signatures], routes: [...routes.values()].map(({ total, ...row }) => ({ ...row, avgLatencyMs: Math.round(total / row.count) })), truncated };
|
|
7345
|
+
}
|
|
7346
|
+
|
|
7347
|
+
class Activity {
|
|
7348
|
+
store;
|
|
7349
|
+
data;
|
|
7350
|
+
persistenceFailed = false;
|
|
7351
|
+
file;
|
|
7352
|
+
constructor(store, dir) {
|
|
7353
|
+
this.store = store;
|
|
7354
|
+
this.file = path11.join(dir, "activity.json");
|
|
7355
|
+
this.data = { version: 1, events: [], markers: [], startedAt: Date.now() };
|
|
7356
|
+
try {
|
|
7357
|
+
if (fs10.statSync(this.file).size > 16 * 1024 * 1024)
|
|
7358
|
+
throw new Error("Activity history too large");
|
|
7359
|
+
const saved = JSON.parse(fs10.readFileSync(this.file, "utf8"));
|
|
7360
|
+
if (saved.version !== 1 || !Array.isArray(saved.events) || !Array.isArray(saved.markers) || !Number.isFinite(saved.startedAt))
|
|
7361
|
+
throw new Error("Invalid activity history");
|
|
7362
|
+
const events = saved.events.filter(validEvent), markers = saved.markers.filter(validMarker);
|
|
7363
|
+
if (events.length !== saved.events.length || markers.length !== saved.markers.length)
|
|
7364
|
+
this.persistenceFailed = true;
|
|
7365
|
+
this.data = { ...saved, events: events.slice(-MAX_EVENTS), markers: markers.slice(-MAX_MARKERS) };
|
|
7366
|
+
} catch (error) {
|
|
7367
|
+
if (error.code !== "ENOENT")
|
|
7368
|
+
this.persistenceFailed = true;
|
|
7369
|
+
}
|
|
7370
|
+
}
|
|
7371
|
+
persist(strict = false) {
|
|
7372
|
+
try {
|
|
7373
|
+
writePrivateJson(this.file, this.data);
|
|
7374
|
+
} catch (error) {
|
|
7375
|
+
this.persistenceFailed = true;
|
|
7376
|
+
if (strict)
|
|
7377
|
+
throw new Error("Could not save baseline history");
|
|
7378
|
+
}
|
|
7379
|
+
}
|
|
7380
|
+
capture(event) {
|
|
7381
|
+
let row;
|
|
7382
|
+
if (event.type === "service") {
|
|
7383
|
+
const s = event.service;
|
|
7384
|
+
const transition = { status: s.status, readiness: s.readinessStatus, ready: s.upstreamReady, startedAt: s.startedAt };
|
|
7385
|
+
const last = [...this.data.events].reverse().find((e) => e.service === s.key && e.kind === "lifecycle");
|
|
7386
|
+
if (last && JSON.stringify(last.transition) === JSON.stringify(transition))
|
|
7387
|
+
return;
|
|
7388
|
+
const restarted = !!last?.transition?.startedAt && last.transition.startedAt !== s.startedAt && (s.status === "starting" || s.status === "running");
|
|
7389
|
+
row = { id: event.eventId, ts: Date.now(), service: s.key, kind: "lifecycle", severity: s.readinessStatus === "failed" ? "error" : "info", title: restarted ? "Service restarted" : s.readinessStatus === "failed" ? "Readiness failed" : s.status === "running" && s.upstreamReady ? "Service ready" : `Service ${s.status}`, transition };
|
|
7390
|
+
} else if (event.type === "service:removed")
|
|
7391
|
+
row = { id: event.eventId, ts: Date.now(), service: event.name, kind: "lifecycle", severity: "info", title: "Service removed", transition: { status: "removed" } };
|
|
7392
|
+
else if (event.type === "history:cleared")
|
|
7393
|
+
row = { id: event.eventId, ts: Date.now(), service: event.service, kind: "clear", severity: "info", title: `${event.kind === "logs" ? "Logs" : "Requests"} cleared` };
|
|
7394
|
+
if (!row)
|
|
7395
|
+
return;
|
|
7396
|
+
this.data.events.push(row);
|
|
7397
|
+
if (this.data.events.length > MAX_EVENTS)
|
|
7398
|
+
this.data.droppedBefore = this.data.events.shift().ts;
|
|
7399
|
+
this.persist();
|
|
7400
|
+
}
|
|
7401
|
+
scope(projectId2, services) {
|
|
7402
|
+
if (!projectId2 || !services.length || services.length > 100 || services.some((s) => !s.startsWith(`${projectId2}:`)))
|
|
7403
|
+
throw new Error("Select 1–100 services belonging to this project");
|
|
7404
|
+
}
|
|
7405
|
+
records(projectId2, services) {
|
|
7406
|
+
this.scope(projectId2, services);
|
|
7407
|
+
const keys = new Set(services);
|
|
7408
|
+
const events = this.data.events.filter((e) => keys.has(e.service));
|
|
7409
|
+
for (const service of keys) {
|
|
7410
|
+
for (const log of this.store.logs(service, 2000))
|
|
7411
|
+
events.push({ id: log.id, ts: log.ts, service, kind: "log", severity: log.stream === "stderr" || ERROR.test(log.line) ? "error" : "info", title: log.line.slice(0, 2000) });
|
|
7412
|
+
for (const r of this.store.requests(service, 5000))
|
|
7413
|
+
events.push({ id: r.id, ts: r.ts, service, kind: "request", severity: r.error || r.status >= 400 || r.status === 0 ? "error" : "info", title: `${r.method} ${r.path}`.slice(0, 2000), request: { method: r.method, path: r.path, status: r.status, durationMs: r.durationMs, via: r.via } });
|
|
7414
|
+
}
|
|
7415
|
+
return events.sort(compare);
|
|
7416
|
+
}
|
|
7417
|
+
coverage(services, events, from) {
|
|
7418
|
+
const reasons = new Set;
|
|
7419
|
+
if (this.persistenceFailed)
|
|
7420
|
+
reasons.add("Lifecycle history persistence was unavailable.");
|
|
7421
|
+
if (this.data.droppedBefore !== undefined && (from === undefined || from <= this.data.droppedBefore))
|
|
7422
|
+
reasons.add("Older lifecycle events have expired.");
|
|
7423
|
+
if (from !== undefined && from < this.data.startedAt)
|
|
7424
|
+
reasons.add("The observation window begins before lifecycle recording started.");
|
|
7425
|
+
for (const key of services) {
|
|
7426
|
+
const c = this.store.activityCoverage(key);
|
|
7427
|
+
c.reasons.forEach((reason) => reasons.add(reason));
|
|
7428
|
+
}
|
|
7429
|
+
return { partial: reasons.size > 0, reasons: [...reasons], ...events[0] ? { retainedFrom: events[0].ts } : {} };
|
|
7430
|
+
}
|
|
7431
|
+
timeline(projectId2, services, options = {}) {
|
|
7432
|
+
const limit = options.limit ?? 100;
|
|
7433
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 200)
|
|
7434
|
+
throw new Error("limit must be between 1 and 200");
|
|
7435
|
+
if (options.filter && !["all", "incidents"].includes(options.filter))
|
|
7436
|
+
throw new Error("Unknown activity filter");
|
|
7437
|
+
if (options.query && options.query.length > 200)
|
|
7438
|
+
throw new Error("Search must be at most 200 characters");
|
|
7439
|
+
const all = this.records(projectId2, services), coverage = this.coverage(services, all);
|
|
7440
|
+
let cursor;
|
|
7441
|
+
const scope = createHash4("sha256").update([...new Set(services)].sort().join(",")).digest("hex");
|
|
7442
|
+
if (options.cursor) {
|
|
7443
|
+
try {
|
|
7444
|
+
if (options.cursor.length > 4096)
|
|
7445
|
+
throw 0;
|
|
7446
|
+
cursor = JSON.parse(Buffer.from(options.cursor, "base64url").toString());
|
|
7447
|
+
if (!cursor || !Number.isFinite(cursor.ts) || typeof cursor.id !== "string" || cursor.projectId !== projectId2 || cursor.scope !== scope)
|
|
7448
|
+
throw 0;
|
|
7449
|
+
} catch {
|
|
7450
|
+
throw new Error("Invalid activity cursor for this project and service selection");
|
|
7451
|
+
}
|
|
7452
|
+
}
|
|
7453
|
+
const query2 = options.query?.toLowerCase();
|
|
7454
|
+
const matching = all.filter((e) => (!cursor || compare(e, cursor) < 0) && (!options.eventId || e.id === options.eventId) && (options.filter !== "incidents" || e.severity === "error" || e.kind === "lifecycle" || e.kind === "clear") && (!query2 || `${e.title} ${e.service}`.toLowerCase().includes(query2))).reverse();
|
|
7455
|
+
const items = matching.slice(0, limit), last = items.at(-1);
|
|
7456
|
+
return { items, coverage, ...last && matching.length > limit ? { nextCursor: Buffer.from(JSON.stringify({ ts: last.ts, id: last.id, projectId: projectId2, scope })).toString("base64url") } : {} };
|
|
7457
|
+
}
|
|
7458
|
+
markers(projectId2) {
|
|
7459
|
+
return this.data.markers.filter((m) => m.projectId === projectId2).slice().reverse();
|
|
7460
|
+
}
|
|
7461
|
+
mark(projectId2, services, options = {}) {
|
|
7462
|
+
const ts = options.now ?? Date.now(), all = this.records(projectId2, services);
|
|
7463
|
+
if (options.label !== undefined && (typeof options.label !== "string" || options.label.length > 100))
|
|
7464
|
+
throw new Error("Baseline label must be at most 100 characters");
|
|
7465
|
+
const before = all.filter((e) => e.ts >= ts - 300000 && e.ts <= ts);
|
|
7466
|
+
const marker2 = { id: randomUUID3(), projectId: projectId2, services: [...new Set(services)], label: options.label?.trim() || "Before my change", ts, boundary: this.store.activityBoundary(), before: summary(before, ts - 300000, ts, options.secrets), coverage: this.coverage(services, all, ts - 300000), ...options.git ? { git: options.git } : {} };
|
|
7467
|
+
const previous = this.data.markers;
|
|
7468
|
+
this.data.markers = [...previous, marker2].slice(-MAX_MARKERS);
|
|
7469
|
+
try {
|
|
7470
|
+
this.persist(true);
|
|
7471
|
+
} catch (error) {
|
|
7472
|
+
this.data.markers = previous;
|
|
7473
|
+
throw error;
|
|
7474
|
+
}
|
|
7475
|
+
return marker2;
|
|
7476
|
+
}
|
|
7477
|
+
compare(projectId2, id, now = Date.now(), secrets2 = []) {
|
|
7478
|
+
const marker2 = this.data.markers.find((m) => m.id === id && m.projectId === projectId2);
|
|
7479
|
+
if (!marker2)
|
|
7480
|
+
throw new Error("Baseline not found or expired");
|
|
7481
|
+
const all = this.records(projectId2, marker2.services);
|
|
7482
|
+
const after = summary(all.filter((e) => isAfterHistoryBoundary(e.id, marker2.boundary) && e.ts <= now), marker2.ts, now, secrets2);
|
|
7483
|
+
const coverage = this.coverage(marker2.services, all, marker2.ts);
|
|
7484
|
+
coverage.partial ||= marker2.coverage.partial;
|
|
7485
|
+
coverage.reasons = [...new Set([...marker2.coverage.reasons, ...coverage.reasons])];
|
|
7486
|
+
return { marker: marker2, before: marker2.before, after, newlyObserved: after.signatures.filter((s) => !marker2.before.signatures.includes(s)), notObservedAgain: marker2.before.signatures.filter((s) => !after.signatures.includes(s)), insufficientEvidence: after.requests < 5 || marker2.before.requests < 5, coverage };
|
|
7487
|
+
}
|
|
7488
|
+
}
|
|
7489
|
+
|
|
6578
7490
|
// ../daemon/src/response-bodies.ts
|
|
6579
|
-
import { randomUUID as
|
|
7491
|
+
import { randomUUID as randomUUID4 } from "node:crypto";
|
|
6580
7492
|
var RESPONSE_CACHE_BYTES = 64 * 1024 * 1024;
|
|
6581
7493
|
var MAX_CAPTURES = 5000;
|
|
6582
7494
|
|
|
@@ -6588,7 +7500,7 @@ class ResponseBodies {
|
|
|
6588
7500
|
this.capacity = capacity;
|
|
6589
7501
|
}
|
|
6590
7502
|
start(service) {
|
|
6591
|
-
const id =
|
|
7503
|
+
const id = randomUUID4();
|
|
6592
7504
|
while (this.entries.size >= MAX_CAPTURES)
|
|
6593
7505
|
this.remove(this.entries.keys().next().value);
|
|
6594
7506
|
this.entries.set(id, { service, body: new BodyCapture(this.capacity), bytes: 0, finished: false, complete: false });
|
|
@@ -6643,8 +7555,8 @@ class ResponseBodies {
|
|
|
6643
7555
|
}
|
|
6644
7556
|
|
|
6645
7557
|
// ../daemon/src/store.ts
|
|
6646
|
-
import
|
|
6647
|
-
import { createHash as
|
|
7558
|
+
import path12 from "node:path";
|
|
7559
|
+
import { createHash as createHash5 } from "node:crypto";
|
|
6648
7560
|
import { EventEmitter } from "node:events";
|
|
6649
7561
|
import { finished } from "node:stream/promises";
|
|
6650
7562
|
import { Writable } from "node:stream";
|
|
@@ -6706,6 +7618,7 @@ function emptyStats() {
|
|
|
6706
7618
|
|
|
6707
7619
|
class Store extends EventEmitter {
|
|
6708
7620
|
responseBodies = new ResponseBodies;
|
|
7621
|
+
activity;
|
|
6709
7622
|
buckets = new Map;
|
|
6710
7623
|
logsDir;
|
|
6711
7624
|
daemonStartedAt;
|
|
@@ -6721,38 +7634,39 @@ class Store extends EventEmitter {
|
|
|
6721
7634
|
this.logsDir = options.logsDir ?? LOGS_DIR;
|
|
6722
7635
|
this.daemonStartedAt = options.daemonStartedAt ?? Date.now();
|
|
6723
7636
|
this.closeAfterMs = options.closeAfterMs ?? DEFAULT_CLOSE_AFTER_MS;
|
|
6724
|
-
this.streamFactory = options.streamFactory ?? ((file, streamOptions) =>
|
|
7637
|
+
this.streamFactory = options.streamFactory ?? ((file, streamOptions) => fs11.createWriteStream(file, streamOptions));
|
|
6725
7638
|
this.maxFileBytes = Math.max(1, options.maxFileBytes ?? 5 * 1024 * 1024);
|
|
6726
7639
|
this.maxHistoryBytes = Math.max(1, options.maxHistoryBytes ?? 8 * 1024 * 1024);
|
|
6727
7640
|
if (options.logsDir === undefined)
|
|
6728
7641
|
ensureDirs();
|
|
6729
7642
|
else
|
|
6730
|
-
|
|
7643
|
+
fs11.mkdirSync(this.logsDir, { recursive: true });
|
|
7644
|
+
this.activity = new Activity(this, this.logsDir);
|
|
6731
7645
|
}
|
|
6732
7646
|
nextId() {
|
|
6733
7647
|
return `${this.daemonStartedAt.toString(36)}-${(++this.sequence).toString(36)}`;
|
|
6734
7648
|
}
|
|
6735
7649
|
compatibilityId(type, service, offset, serialized) {
|
|
6736
|
-
const hash =
|
|
7650
|
+
const hash = createHash5("sha256").update(`${type}\x00${service}\x00${offset}\x00${serialized}`).digest("hex");
|
|
6737
7651
|
return `legacy-${hash}`;
|
|
6738
7652
|
}
|
|
6739
7653
|
historyFile(service, type) {
|
|
6740
7654
|
const suffix = type === "log" ? "log.jsonl" : "requests.jsonl";
|
|
6741
|
-
return
|
|
7655
|
+
return path12.join(this.logsDir, `${encodeURIComponent(service)}.${suffix}`);
|
|
6742
7656
|
}
|
|
6743
7657
|
legacyHistoryFile(service, type) {
|
|
6744
7658
|
const safe = service.replace(/[^a-z0-9_-]/gi, "_");
|
|
6745
7659
|
const suffix = type === "log" ? "log.jsonl" : "requests.jsonl";
|
|
6746
|
-
return
|
|
7660
|
+
return path12.join(this.logsDir, `${safe}.${suffix}`);
|
|
6747
7661
|
}
|
|
6748
7662
|
loadHistory(file, type, service, ring, legacyService, budget = this.maxFileBytes) {
|
|
6749
7663
|
const marker2 = `${this.historyFile(service, type)}.cleared`;
|
|
6750
|
-
const boundary =
|
|
7664
|
+
const boundary = fs11.existsSync(marker2) ? fs11.readFileSync(marker2, "utf8") : undefined;
|
|
6751
7665
|
let fd;
|
|
6752
7666
|
let size;
|
|
6753
7667
|
try {
|
|
6754
|
-
fd =
|
|
6755
|
-
size =
|
|
7668
|
+
fd = fs11.openSync(file, "r");
|
|
7669
|
+
size = fs11.fstatSync(fd).size;
|
|
6756
7670
|
} catch (error) {
|
|
6757
7671
|
if (error.code === "ENOENT")
|
|
6758
7672
|
return 0;
|
|
@@ -6763,9 +7677,9 @@ class Store extends EventEmitter {
|
|
|
6763
7677
|
const bytes = Buffer.alloc(count);
|
|
6764
7678
|
try {
|
|
6765
7679
|
if (count)
|
|
6766
|
-
|
|
7680
|
+
fs11.readSync(fd, bytes, 0, count, start);
|
|
6767
7681
|
} finally {
|
|
6768
|
-
|
|
7682
|
+
fs11.closeSync(fd);
|
|
6769
7683
|
}
|
|
6770
7684
|
let lines = bytes.toString("binary").split(`
|
|
6771
7685
|
`);
|
|
@@ -6828,14 +7742,14 @@ class Store extends EventEmitter {
|
|
|
6828
7742
|
const rotatedReq = `${reqFile}.1`;
|
|
6829
7743
|
const logCurrentSize = (() => {
|
|
6830
7744
|
try {
|
|
6831
|
-
return
|
|
7745
|
+
return fs11.statSync(logFile).size;
|
|
6832
7746
|
} catch {
|
|
6833
7747
|
return 0;
|
|
6834
7748
|
}
|
|
6835
7749
|
})();
|
|
6836
7750
|
const reqCurrentSize = (() => {
|
|
6837
7751
|
try {
|
|
6838
|
-
return
|
|
7752
|
+
return fs11.statSync(reqFile).size;
|
|
6839
7753
|
} catch {
|
|
6840
7754
|
return 0;
|
|
6841
7755
|
}
|
|
@@ -6906,7 +7820,7 @@ class Store extends EventEmitter {
|
|
|
6906
7820
|
}
|
|
6907
7821
|
historyBoundary(service, kind) {
|
|
6908
7822
|
const marker2 = `${this.historyFile(service, kind === "logs" ? "log" : "request")}.cleared`;
|
|
6909
|
-
return
|
|
7823
|
+
return fs11.existsSync(marker2) ? fs11.readFileSync(marker2, "utf8") : undefined;
|
|
6910
7824
|
}
|
|
6911
7825
|
async clearHistory(service, kind) {
|
|
6912
7826
|
if (this.closing)
|
|
@@ -6915,15 +7829,15 @@ class Store extends EventEmitter {
|
|
|
6915
7829
|
const state = kind === "logs" ? bucket.logFile : bucket.reqFile;
|
|
6916
7830
|
const beforeId = this.nextId();
|
|
6917
7831
|
const marker2 = `${state.file}.cleared`;
|
|
6918
|
-
|
|
6919
|
-
|
|
7832
|
+
fs11.writeFileSync(`${marker2}.tmp`, beforeId, { mode: 384 });
|
|
7833
|
+
fs11.renameSync(`${marker2}.tmp`, marker2);
|
|
6920
7834
|
bucket[kind].clear();
|
|
6921
7835
|
if (kind === "requests")
|
|
6922
7836
|
this.responseBodies.clear(service);
|
|
6923
7837
|
const operation = state.queue.then(async () => {
|
|
6924
7838
|
await this.closeStream(state.stream);
|
|
6925
|
-
|
|
6926
|
-
|
|
7839
|
+
fs11.writeFileSync(state.file, "", { mode: 384 });
|
|
7840
|
+
fs11.rmSync(`${state.file}.1`, { force: true });
|
|
6927
7841
|
state.bytes = 0;
|
|
6928
7842
|
state.stream = this.streamFactory(state.file, { flags: "a", mode: 384, highWaterMark: 65536 });
|
|
6929
7843
|
state.disabled = false;
|
|
@@ -6942,9 +7856,24 @@ class Store extends EventEmitter {
|
|
|
6942
7856
|
}
|
|
6943
7857
|
emitEvent(ev) {
|
|
6944
7858
|
const event = { ...ev, eventId: this.nextId() };
|
|
7859
|
+
this.activity.capture(event);
|
|
6945
7860
|
this.emit("event", event);
|
|
6946
7861
|
return event;
|
|
6947
7862
|
}
|
|
7863
|
+
activityBoundary() {
|
|
7864
|
+
return this.nextId();
|
|
7865
|
+
}
|
|
7866
|
+
activityCoverage(service) {
|
|
7867
|
+
const b = this.bucket(service);
|
|
7868
|
+
const reasons = [];
|
|
7869
|
+
if (this.historyBoundary(service, "logs") || this.historyBoundary(service, "requests"))
|
|
7870
|
+
reasons.push("Some history was explicitly cleared.");
|
|
7871
|
+
if (b.logs.last(LOG_RING).length === LOG_RING || b.requests.last(REQ_RING).length === REQ_RING || fs11.existsSync(`${b.logFile.file}.1`) || fs11.existsSync(`${b.reqFile.file}.1`))
|
|
7872
|
+
reasons.push("Older logs or requests may have expired from bounded history.");
|
|
7873
|
+
if (b.persistenceDiagnosticEmitted)
|
|
7874
|
+
reasons.push("Some log or request history could not be persisted.");
|
|
7875
|
+
return { reasons };
|
|
7876
|
+
}
|
|
6948
7877
|
close() {
|
|
6949
7878
|
if (!this.closePromise) {
|
|
6950
7879
|
this.closing = true;
|
|
@@ -7010,12 +7939,12 @@ class Store extends EventEmitter {
|
|
|
7010
7939
|
} });
|
|
7011
7940
|
}
|
|
7012
7941
|
try {
|
|
7013
|
-
if (
|
|
7014
|
-
|
|
7942
|
+
if (fs11.existsSync(file))
|
|
7943
|
+
fs11.chmodSync(file, 384);
|
|
7015
7944
|
} catch {}
|
|
7016
7945
|
const state = { stream, file, bytes: (() => {
|
|
7017
7946
|
try {
|
|
7018
|
-
return
|
|
7947
|
+
return fs11.statSync(file).size;
|
|
7019
7948
|
} catch {
|
|
7020
7949
|
return 0;
|
|
7021
7950
|
}
|
|
@@ -7089,8 +8018,8 @@ class Store extends EventEmitter {
|
|
|
7089
8018
|
}
|
|
7090
8019
|
const rotated = `${state.file}.1`;
|
|
7091
8020
|
try {
|
|
7092
|
-
|
|
7093
|
-
|
|
8021
|
+
fs11.rmSync(rotated, { force: true });
|
|
8022
|
+
fs11.renameSync(state.file, rotated);
|
|
7094
8023
|
} catch {
|
|
7095
8024
|
state.disabled = true;
|
|
7096
8025
|
this.reportPersistenceFailure(service, bucket, "history rotation");
|
|
@@ -7098,7 +8027,7 @@ class Store extends EventEmitter {
|
|
|
7098
8027
|
}
|
|
7099
8028
|
state.stream = this.streamFactory(state.file, { flags: "a", mode: 384, highWaterMark: 65536 });
|
|
7100
8029
|
try {
|
|
7101
|
-
|
|
8030
|
+
fs11.chmodSync(state.file, 384);
|
|
7102
8031
|
} catch {}
|
|
7103
8032
|
state.bytes = 0;
|
|
7104
8033
|
state.stream.on("error", () => {
|
|
@@ -7144,8 +8073,8 @@ function settlesWithin2(promise, timeoutMs) {
|
|
|
7144
8073
|
|
|
7145
8074
|
// ../daemon/src/supervisor.ts
|
|
7146
8075
|
import { spawn as spawn3 } from "node:child_process";
|
|
7147
|
-
import
|
|
7148
|
-
import
|
|
8076
|
+
import path14 from "node:path";
|
|
8077
|
+
import fs13 from "node:fs";
|
|
7149
8078
|
|
|
7150
8079
|
// ../daemon/src/startup-guidance.ts
|
|
7151
8080
|
function startupGuidance(message) {
|
|
@@ -7170,8 +8099,8 @@ function startupGuidance(message) {
|
|
|
7170
8099
|
}
|
|
7171
8100
|
|
|
7172
8101
|
// ../daemon/src/port-environment.ts
|
|
7173
|
-
import
|
|
7174
|
-
import
|
|
8102
|
+
import fs12 from "node:fs";
|
|
8103
|
+
import path13 from "node:path";
|
|
7175
8104
|
function inferPortEnvironment(command, cwd, port) {
|
|
7176
8105
|
const visited = new Set;
|
|
7177
8106
|
const inspect = (input, directory) => {
|
|
@@ -7180,7 +8109,7 @@ function inferPortEnvironment(command, cwd, port) {
|
|
|
7180
8109
|
const words = typeof input === "string" ? shellWords(input) : input;
|
|
7181
8110
|
if (!words?.length)
|
|
7182
8111
|
return;
|
|
7183
|
-
const executable =
|
|
8112
|
+
const executable = path13.basename(words[0]).replace(/\.cmd$/, "");
|
|
7184
8113
|
if (words[0].includes("=") || ["echo", "printf"].includes(executable))
|
|
7185
8114
|
return;
|
|
7186
8115
|
if (["npm", "pnpm", "yarn", "bun"].includes(executable)) {
|
|
@@ -7203,7 +8132,7 @@ function inferPortEnvironment(command, cwd, port) {
|
|
|
7203
8132
|
return;
|
|
7204
8133
|
workspace = value;
|
|
7205
8134
|
} else
|
|
7206
|
-
target =
|
|
8135
|
+
target = path13.resolve(directory, value);
|
|
7207
8136
|
} else if (word.startsWith("-") || script)
|
|
7208
8137
|
return;
|
|
7209
8138
|
else
|
|
@@ -7233,26 +8162,26 @@ function inferPortEnvironment(command, cwd, port) {
|
|
|
7233
8162
|
if (name2 && name2 !== "PORT" && !name2.startsWith("LOCALDECK_"))
|
|
7234
8163
|
return name2;
|
|
7235
8164
|
};
|
|
7236
|
-
const name = inspect(command,
|
|
8165
|
+
const name = inspect(command, path13.resolve(cwd));
|
|
7237
8166
|
return name ? { [name]: String(port) } : {};
|
|
7238
8167
|
}
|
|
7239
8168
|
function shellWords(command) {
|
|
7240
8169
|
const words = [];
|
|
7241
8170
|
let word = "";
|
|
7242
|
-
let
|
|
8171
|
+
let quote2 = "";
|
|
7243
8172
|
let started = false;
|
|
7244
8173
|
for (let i = 0;i < command.length; i++) {
|
|
7245
8174
|
const char = command[i];
|
|
7246
8175
|
if (char === "`" || char === "\\" || char === `
|
|
7247
8176
|
` || command.slice(i, i + 2) === "$(")
|
|
7248
8177
|
return;
|
|
7249
|
-
if (
|
|
7250
|
-
if (char ===
|
|
7251
|
-
|
|
8178
|
+
if (quote2) {
|
|
8179
|
+
if (char === quote2)
|
|
8180
|
+
quote2 = "";
|
|
7252
8181
|
else
|
|
7253
|
-
word +=
|
|
8182
|
+
word += quote2 === "'" && char === "$" ? "\x00" : char;
|
|
7254
8183
|
} else if (char === '"' || char === "'") {
|
|
7255
|
-
|
|
8184
|
+
quote2 = char;
|
|
7256
8185
|
started = true;
|
|
7257
8186
|
} else if (/\s/.test(char)) {
|
|
7258
8187
|
if (started)
|
|
@@ -7266,7 +8195,7 @@ function shellWords(command) {
|
|
|
7266
8195
|
started = true;
|
|
7267
8196
|
}
|
|
7268
8197
|
}
|
|
7269
|
-
if (
|
|
8198
|
+
if (quote2)
|
|
7270
8199
|
return;
|
|
7271
8200
|
if (started)
|
|
7272
8201
|
words.push(word);
|
|
@@ -7274,7 +8203,7 @@ function shellWords(command) {
|
|
|
7274
8203
|
}
|
|
7275
8204
|
function manifest(directory) {
|
|
7276
8205
|
try {
|
|
7277
|
-
const value = JSON.parse(
|
|
8206
|
+
const value = JSON.parse(fs12.readFileSync(path13.join(directory, "package.json"), "utf8"));
|
|
7278
8207
|
return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
7279
8208
|
} catch {
|
|
7280
8209
|
return;
|
|
@@ -7287,7 +8216,7 @@ function workspaceDirectory(root, selector) {
|
|
|
7287
8216
|
return;
|
|
7288
8217
|
const directories = new Set;
|
|
7289
8218
|
for (const pattern of patterns.slice(0, 100)) {
|
|
7290
|
-
if (typeof pattern !== "string" ||
|
|
8219
|
+
if (typeof pattern !== "string" || path13.isAbsolute(pattern))
|
|
7291
8220
|
continue;
|
|
7292
8221
|
const parts = pattern.replace(/^\.\//, "").replace(/\/$/, "").split("/");
|
|
7293
8222
|
if (parts.some((part) => !part || part === ".." || /[^\w@.*-]/.test(part) || part.includes("**")))
|
|
@@ -7298,9 +8227,9 @@ function workspaceDirectory(root, selector) {
|
|
|
7298
8227
|
const next = [];
|
|
7299
8228
|
for (const candidate of candidates) {
|
|
7300
8229
|
try {
|
|
7301
|
-
for (const entry of
|
|
8230
|
+
for (const entry of fs12.readdirSync(candidate, { withFileTypes: true })) {
|
|
7302
8231
|
if (entry.isDirectory() && !["node_modules", ".git"].includes(entry.name) && matcher.test(entry.name))
|
|
7303
|
-
next.push(
|
|
8232
|
+
next.push(path13.join(candidate, entry.name));
|
|
7304
8233
|
if (next.length > 100)
|
|
7305
8234
|
return;
|
|
7306
8235
|
}
|
|
@@ -7310,7 +8239,7 @@ function workspaceDirectory(root, selector) {
|
|
|
7310
8239
|
}
|
|
7311
8240
|
for (const candidate of candidates) {
|
|
7312
8241
|
const pkg = manifest(candidate);
|
|
7313
|
-
if (pkg && (candidate ===
|
|
8242
|
+
if (pkg && (candidate === path13.resolve(root, selector) || pkg.name === selector))
|
|
7314
8243
|
directories.add(candidate);
|
|
7315
8244
|
}
|
|
7316
8245
|
}
|
|
@@ -7347,7 +8276,7 @@ async function waitForReadiness(command, getService, options = {}) {
|
|
|
7347
8276
|
await delay2(Math.min(command.ready.intervalMs, afterProbe), command.name, options.signal);
|
|
7348
8277
|
}
|
|
7349
8278
|
}
|
|
7350
|
-
function probeHttp(service,
|
|
8279
|
+
function probeHttp(service, path14, expectedStatus, remainingMs, signal) {
|
|
7351
8280
|
return new Promise((resolve, reject) => {
|
|
7352
8281
|
if (signal?.aborted)
|
|
7353
8282
|
return reject(new Error(`${service.name} readiness cancelled`));
|
|
@@ -7365,7 +8294,7 @@ function probeHttp(service, path10, expectedStatus, remainingMs, signal) {
|
|
|
7365
8294
|
const request = http7.request({
|
|
7366
8295
|
host: service.upstreamHost,
|
|
7367
8296
|
port: service.upstreamPort,
|
|
7368
|
-
path:
|
|
8297
|
+
path: path14,
|
|
7369
8298
|
method: "GET",
|
|
7370
8299
|
timeout: Math.max(1, Math.min(remainingMs, 2000))
|
|
7371
8300
|
}, (response) => {
|
|
@@ -7509,8 +8438,8 @@ function secretOutput(values) {
|
|
|
7509
8438
|
return result;
|
|
7510
8439
|
}
|
|
7511
8440
|
function consume(final) {
|
|
7512
|
-
const
|
|
7513
|
-
if (!
|
|
8441
|
+
const secrets2 = [...values].map(stripVTControlCharacters).filter(Boolean).sort((a, b) => b.length - a.length);
|
|
8442
|
+
if (!secrets2.length) {
|
|
7514
8443
|
const output2 = pending;
|
|
7515
8444
|
pending = "";
|
|
7516
8445
|
return output2;
|
|
@@ -7519,9 +8448,9 @@ function secretOutput(values) {
|
|
|
7519
8448
|
let index = 0;
|
|
7520
8449
|
while (index < pending.length) {
|
|
7521
8450
|
const remaining = pending.slice(index);
|
|
7522
|
-
if (!final &&
|
|
8451
|
+
if (!final && secrets2.some((value) => value.length > remaining.length && value.startsWith(remaining)))
|
|
7523
8452
|
break;
|
|
7524
|
-
const match =
|
|
8453
|
+
const match = secrets2.find((value) => pending.startsWith(value, index));
|
|
7525
8454
|
if (match) {
|
|
7526
8455
|
output += "[REDACTED]";
|
|
7527
8456
|
index += match.length;
|
|
@@ -7640,11 +8569,11 @@ class ProcessSupervisor {
|
|
|
7640
8569
|
return started;
|
|
7641
8570
|
const hasDependents = project.commands.some((candidate) => candidate.after.includes(command.name));
|
|
7642
8571
|
if (command.ready.type === "tcp" && !hasDependents) {
|
|
7643
|
-
const cwd2 = command.cwd ?
|
|
8572
|
+
const cwd2 = command.cwd ? path14.resolve(project.directory, command.cwd) : project.directory;
|
|
7644
8573
|
this.verifyReadiness(command, key, cwd2);
|
|
7645
8574
|
return started;
|
|
7646
8575
|
}
|
|
7647
|
-
const cwd = command.cwd ?
|
|
8576
|
+
const cwd = command.cwd ? path14.resolve(project.directory, command.cwd) : project.directory;
|
|
7648
8577
|
return this.verifyReadiness(command, key, cwd, started);
|
|
7649
8578
|
})();
|
|
7650
8579
|
attempts.set(command.name, attempt);
|
|
@@ -7790,9 +8719,9 @@ class ProcessSupervisor {
|
|
|
7790
8719
|
const project = this.projects.details(projectId2);
|
|
7791
8720
|
if (!project.available)
|
|
7792
8721
|
throw new ProjectError("invalid", project.error);
|
|
7793
|
-
const cwd = command.cwd ?
|
|
8722
|
+
const cwd = command.cwd ? path14.resolve(project.directory, command.cwd) : project.directory;
|
|
7794
8723
|
try {
|
|
7795
|
-
if (!
|
|
8724
|
+
if (!fs13.statSync(cwd).isDirectory())
|
|
7796
8725
|
throw new Error("not a directory");
|
|
7797
8726
|
} catch {
|
|
7798
8727
|
throw new ProjectError("invalid", `configured working directory does not exist or is not a directory: ${cwd}`);
|
|
@@ -7833,7 +8762,9 @@ class ProcessSupervisor {
|
|
|
7833
8762
|
upstreamSource: command.upstream === undefined ? "assigned" : "manual",
|
|
7834
8763
|
cwd,
|
|
7835
8764
|
command: command.run,
|
|
7836
|
-
serviceType: command.serviceType
|
|
8765
|
+
serviceType: command.serviceType,
|
|
8766
|
+
...project.activeProfiles?.length ? { profiles: project.activeProfiles } : {},
|
|
8767
|
+
configFingerprint: commandFingerprint(command)
|
|
7837
8768
|
}, () => void this.stop(key, "stop requested"));
|
|
7838
8769
|
} catch (error) {
|
|
7839
8770
|
this.releaseAutomaticUpstream(reservedUpstreamPort);
|
|
@@ -8025,7 +8956,7 @@ class ProcessSupervisor {
|
|
|
8025
8956
|
else {
|
|
8026
8957
|
const command = this.projects.resolve(projectId2, name);
|
|
8027
8958
|
const project = this.projects.details(projectId2);
|
|
8028
|
-
const cwd = command.cwd ?
|
|
8959
|
+
const cwd = command.cwd ? path14.resolve(project.directory, command.cwd) : project.directory;
|
|
8029
8960
|
this.verifyReadiness(command, key, cwd);
|
|
8030
8961
|
}
|
|
8031
8962
|
return result;
|
|
@@ -8310,7 +9241,7 @@ async function waitForProcessGroupExit(groupId, timeoutMs) {
|
|
|
8310
9241
|
return true;
|
|
8311
9242
|
}
|
|
8312
9243
|
// ../daemon/package.json
|
|
8313
|
-
var version = "1.
|
|
9244
|
+
var version = "1.6.1";
|
|
8314
9245
|
|
|
8315
9246
|
// ../daemon/src/index.ts
|
|
8316
9247
|
var VERSION = version;
|
|
@@ -8327,8 +9258,8 @@ async function startDaemon(opts = {}) {
|
|
|
8327
9258
|
writePrivateState: writePrivateJson,
|
|
8328
9259
|
...opts.dependencies
|
|
8329
9260
|
};
|
|
8330
|
-
const stateFile = opts.homeDir ?
|
|
8331
|
-
const projectsFile = opts.homeDir ?
|
|
9261
|
+
const stateFile = opts.homeDir ? path15.join(opts.homeDir, "daemon.json") : STATE_FILE;
|
|
9262
|
+
const projectsFile = opts.homeDir ? path15.join(opts.homeDir, "projects.json") : PROJECTS_FILE;
|
|
8332
9263
|
const apiPort = opts.apiPort ?? DEFAULT_API_PORT;
|
|
8333
9264
|
const store = dependencies.createStore();
|
|
8334
9265
|
const registry = dependencies.createRegistry(store, apiPort);
|
|
@@ -8388,16 +9319,16 @@ async function startDaemon(opts = {}) {
|
|
|
8388
9319
|
if (stateCleanup === "failed-write") {
|
|
8389
9320
|
if (!stateWriteAttempted)
|
|
8390
9321
|
return;
|
|
8391
|
-
|
|
9322
|
+
fs14.rmSync(`${stateFile}.${process.pid}.tmp`, { force: true });
|
|
8392
9323
|
if (priorState)
|
|
8393
9324
|
restorePrivateState(stateFile, priorState.contents, priorState.mode);
|
|
8394
9325
|
else
|
|
8395
|
-
|
|
9326
|
+
fs14.rmSync(stateFile, { force: true });
|
|
8396
9327
|
return;
|
|
8397
9328
|
}
|
|
8398
9329
|
const state = readJson(stateFile, null);
|
|
8399
9330
|
if (state?.pid === process.pid)
|
|
8400
|
-
|
|
9331
|
+
fs14.unlinkSync(stateFile);
|
|
8401
9332
|
}));
|
|
8402
9333
|
return errors;
|
|
8403
9334
|
})();
|
|
@@ -8423,9 +9354,9 @@ async function startDaemon(opts = {}) {
|
|
|
8423
9354
|
});
|
|
8424
9355
|
});
|
|
8425
9356
|
server.on("error", (error) => console.error(`LocalDeck: API server error: ${error.message}`));
|
|
8426
|
-
if (
|
|
8427
|
-
const stat =
|
|
8428
|
-
priorState = { contents:
|
|
9357
|
+
if (fs14.existsSync(stateFile)) {
|
|
9358
|
+
const stat = fs14.statSync(stateFile);
|
|
9359
|
+
priorState = { contents: fs14.readFileSync(stateFile), mode: stat.mode & 511 };
|
|
8429
9360
|
}
|
|
8430
9361
|
stateWriteAttempted = true;
|
|
8431
9362
|
dependencies.writePrivateState(stateFile, {
|
|
@@ -8446,9 +9377,9 @@ async function startDaemon(opts = {}) {
|
|
|
8446
9377
|
}
|
|
8447
9378
|
function restorePrivateState(file, contents, mode) {
|
|
8448
9379
|
const temporary = `${file}.${process.pid}.restore.tmp`;
|
|
8449
|
-
|
|
8450
|
-
|
|
8451
|
-
|
|
9380
|
+
fs14.writeFileSync(temporary, contents, { mode });
|
|
9381
|
+
fs14.chmodSync(temporary, mode);
|
|
9382
|
+
fs14.renameSync(temporary, file);
|
|
8452
9383
|
}
|
|
8453
9384
|
function toError3(error) {
|
|
8454
9385
|
return error instanceof Error ? error : new Error(String(error));
|
|
@@ -8469,7 +9400,7 @@ function createOperationTracker() {
|
|
|
8469
9400
|
async function findDaemon(stateFile = STATE_FILE) {
|
|
8470
9401
|
let state;
|
|
8471
9402
|
try {
|
|
8472
|
-
state = JSON.parse(
|
|
9403
|
+
state = JSON.parse(fs14.readFileSync(stateFile, "utf8"));
|
|
8473
9404
|
} catch (error) {
|
|
8474
9405
|
const code = error.code;
|
|
8475
9406
|
if (code === "EACCES" || code === "EPERM") {
|
|
@@ -8485,8 +9416,8 @@ async function findDaemon(stateFile = STATE_FILE) {
|
|
|
8485
9416
|
const res = await fetch(`http://127.0.0.1:${state.apiPort}/api/status`, { signal: AbortSignal.timeout(1000) });
|
|
8486
9417
|
if (!res.ok)
|
|
8487
9418
|
return null;
|
|
8488
|
-
const
|
|
8489
|
-
return
|
|
9419
|
+
const body2 = await res.json();
|
|
9420
|
+
return body2.pid === state.pid ? state : null;
|
|
8490
9421
|
} catch {
|
|
8491
9422
|
return null;
|
|
8492
9423
|
}
|
|
@@ -8494,24 +9425,24 @@ async function findDaemon(stateFile = STATE_FILE) {
|
|
|
8494
9425
|
|
|
8495
9426
|
// src/client.ts
|
|
8496
9427
|
import { spawn as spawn4, spawnSync } from "node:child_process";
|
|
8497
|
-
import
|
|
8498
|
-
import
|
|
9428
|
+
import fs15 from "node:fs";
|
|
9429
|
+
import path16 from "node:path";
|
|
8499
9430
|
import { fileURLToPath } from "node:url";
|
|
8500
9431
|
function dashboardDir() {
|
|
8501
|
-
const here =
|
|
9432
|
+
const here = path16.dirname(fileURLToPath(import.meta.url));
|
|
8502
9433
|
const candidates = [
|
|
8503
|
-
|
|
8504
|
-
|
|
9434
|
+
path16.resolve(here, "../dashboard"),
|
|
9435
|
+
path16.resolve(here, "../../dashboard/dist")
|
|
8505
9436
|
];
|
|
8506
|
-
return candidates.find((d) =>
|
|
9437
|
+
return candidates.find((d) => fs15.existsSync(path16.join(d, "index.html")));
|
|
8507
9438
|
}
|
|
8508
9439
|
function daemonCommand() {
|
|
8509
9440
|
const entry = process.argv[1];
|
|
8510
9441
|
const runningUnderBun = Boolean(process.versions.bun);
|
|
8511
9442
|
if (runningUnderBun) {
|
|
8512
|
-
const dist =
|
|
9443
|
+
const dist = path16.resolve(path16.dirname(fileURLToPath(import.meta.url)), "../dist/index.js");
|
|
8513
9444
|
const hasNode = spawnSync("node", ["--version"], { stdio: "ignore" }).status === 0;
|
|
8514
|
-
if (
|
|
9445
|
+
if (fs15.existsSync(dist) && hasNode)
|
|
8515
9446
|
return ["node", [dist, "daemon", "run"]];
|
|
8516
9447
|
console.warn("LocalDeck: starting daemon under Bun — WebSocket proxying (HMR) will not work. Run `bun run build` and make sure `node` is on PATH.");
|
|
8517
9448
|
}
|
|
@@ -8524,8 +9455,8 @@ async function ensureDaemon() {
|
|
|
8524
9455
|
const running = await findDaemon();
|
|
8525
9456
|
if (running)
|
|
8526
9457
|
return running;
|
|
8527
|
-
|
|
8528
|
-
const out =
|
|
9458
|
+
fs15.mkdirSync(path16.dirname(DAEMON_LOG), { recursive: true });
|
|
9459
|
+
const out = fs15.openSync(DAEMON_LOG, "a");
|
|
8529
9460
|
const [cmd, args] = daemonCommand();
|
|
8530
9461
|
const child = spawn4(cmd, args, { detached: true, stdio: ["ignore", out, out], env: process.env });
|
|
8531
9462
|
child.unref();
|
|
@@ -8551,12 +9482,12 @@ class Api {
|
|
|
8551
9482
|
get base() {
|
|
8552
9483
|
return `http://127.0.0.1:${this.port}`;
|
|
8553
9484
|
}
|
|
8554
|
-
async call(method, p,
|
|
9485
|
+
async call(method, p, body2, headers) {
|
|
8555
9486
|
const res = await this.fetch(this.base + p, {
|
|
8556
9487
|
method,
|
|
8557
9488
|
...this.timeoutMs ? { signal: AbortSignal.timeout(this.timeoutMs) } : {},
|
|
8558
|
-
headers: { ...
|
|
8559
|
-
body:
|
|
9489
|
+
headers: { ...body2 ? { "content-type": "application/json" } : {}, ...headers },
|
|
9490
|
+
body: body2 ? JSON.stringify(body2) : undefined
|
|
8560
9491
|
});
|
|
8561
9492
|
const data = await res.json().catch(() => ({}));
|
|
8562
9493
|
if (!res.ok) {
|
|
@@ -8567,10 +9498,31 @@ class Api {
|
|
|
8567
9498
|
}
|
|
8568
9499
|
projects = () => this.call("GET", "/api/projects");
|
|
8569
9500
|
project = (id) => this.call("GET", `/api/projects/${encodeURIComponent(id)}`);
|
|
9501
|
+
timeline = (projectId2, options = {}) => {
|
|
9502
|
+
const params = new URLSearchParams;
|
|
9503
|
+
if (options.services)
|
|
9504
|
+
params.set("services", options.services.join(","));
|
|
9505
|
+
for (const field of ["filter", "query", "cursor", "limit"]) {
|
|
9506
|
+
if (options[field] !== undefined)
|
|
9507
|
+
params.set(field, String(options[field]));
|
|
9508
|
+
}
|
|
9509
|
+
return this.call("GET", `/api/projects/${encodeURIComponent(projectId2)}/activity?${params}`);
|
|
9510
|
+
};
|
|
9511
|
+
markBaseline = (projectId2, body2) => this.call("POST", `/api/projects/${encodeURIComponent(projectId2)}/baselines`, body2);
|
|
9512
|
+
baselines = (projectId2) => this.call("GET", `/api/projects/${encodeURIComponent(projectId2)}/baselines`);
|
|
9513
|
+
compareBaseline = (projectId2, baselineId) => this.call("GET", `/api/projects/${encodeURIComponent(projectId2)}/baselines/${encodeURIComponent(baselineId)}`);
|
|
9514
|
+
environments = (projectId2) => this.call("GET", `/api/projects/${encodeURIComponent(projectId2)}/environments`);
|
|
9515
|
+
registerEnvironment = (projectId2, environmentId) => this.call("POST", `/api/projects/${encodeURIComponent(projectId2)}/environments/${encodeURIComponent(environmentId)}/register`, {});
|
|
9516
|
+
exportRequest = (serviceKey2, requestId) => this.call("GET", `/api/services/${encodeURIComponent(serviceKey2)}/reproduction?requestId=${encodeURIComponent(requestId)}`);
|
|
9517
|
+
saveCheck = (projectId2, body2) => this.call("POST", `/api/projects/${encodeURIComponent(projectId2)}/checks`, body2);
|
|
9518
|
+
checks = (projectId2) => this.call("GET", `/api/projects/${encodeURIComponent(projectId2)}/checks`);
|
|
9519
|
+
runCheck = (projectId2, checkId) => this.call("POST", `/api/projects/${encodeURIComponent(projectId2)}/checks/${encodeURIComponent(checkId)}/run`, {});
|
|
8570
9520
|
serviceAction = (projectId2, name, action) => this.call("POST", `/api/projects/${encodeURIComponent(projectId2)}/services/${encodeURIComponent(name)}/${action}`, {});
|
|
8571
9521
|
shareInfo = (key) => this.call("GET", `/api/services/${encodeURIComponent(key)}/share`);
|
|
8572
9522
|
replay = (key, requestId) => this.call("POST", `/api/services/${encodeURIComponent(key)}/replay`, { requestId });
|
|
8573
9523
|
services = () => this.call("GET", "/api/services");
|
|
9524
|
+
profiles = (projectId2) => this.call("GET", `/api/projects/${encodeURIComponent(projectId2)}/profiles`);
|
|
9525
|
+
setActiveProfiles = (projectId2, profiles) => this.call("PUT", `/api/projects/${encodeURIComponent(projectId2)}/profiles/active`, { profiles });
|
|
8574
9526
|
rememberProject = (file, token) => this.call("POST", "/api/projects/remember", { file }, { authorization: `Bearer ${token}` });
|
|
8575
9527
|
async servicePath(reference) {
|
|
8576
9528
|
const separator = reference.lastIndexOf("/");
|
|
@@ -8595,4 +9547,4 @@ class Api {
|
|
|
8595
9547
|
shutdown = () => this.call("POST", "/api/shutdown");
|
|
8596
9548
|
}
|
|
8597
9549
|
|
|
8598
|
-
export { __toESM, __commonJS, __export, __require, wrapper_default,
|
|
9550
|
+
export { __toESM, __commonJS, __export, __require, wrapper_default, HOME_DIR, STATE_FILE, DEFAULT_API_PORT, assertUserRuntime, isPortFree, getFreePort, findProjectFile, loadProject, loadProjectFile, applyProfiles, shellCommand, redactDiagnostic, environmentValues, listProjectSecrets, resolveProjectSecrets, startForwarder, serviceKey, processTree, listeningPorts, chooseUpstream, unreachableListenerMessage, connectHost, streamLines, killProcessTree, stripAnsi, startupGuidance, inferPortEnvironment, startupLayers, waitForReadiness, secretOutput, VERSION, startDaemon, findDaemon, dashboardDir, ensureDaemon, Api };
|