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.
@@ -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
- let env;
3222
- if (entry.env !== undefined) {
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(`${path2.basename(file)}: profiles must map names to arrays of service names`);
3319
- const profiles = Object.create(null);
3320
- for (const [name, services] of Object.entries(value)) {
3321
- if (!/^[a-z0-9][a-z0-9_-]{0,40}$/.test(name) || !Array.isArray(services) || !services.length || !services.every((service) => typeof service === "string" && commands.some((command) => command.name === service.toLowerCase()))) {
3322
- throw new Error(`${path2.basename(file)}: profile ${name} must contain existing service names`);
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
- profiles[name] = [...new Set(services.map((service) => service.toLowerCase()))];
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 profiles;
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 fs11 from "node:fs";
3557
+ import fs14 from "node:fs";
3418
3558
  import crypto2 from "node:crypto";
3419
- import path11 from "node:path";
3559
+ import path15 from "node:path";
3420
3560
 
3421
- // ../daemon/src/api.ts
3422
- import http5 from "node:http";
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/clean-urls.ts
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 http from "node:http";
3572
+ import { execFileSync } from "node:child_process";
3428
3573
  import path3 from "node:path";
3429
3574
 
3430
- // ../daemon/src/windows-forwarder.ts
3431
- import { execFile } from "node:child_process";
3432
- import { promisify } from "node:util";
3433
- var execute = promisify(execFile);
3434
- function windowsForwarderScript(port, remove = false) {
3435
- if (!Number.isInteger(port) || port < 1 || port > 65535 || port === 80)
3436
- throw new Error("Invalid forwarding target port.");
3437
- return String.raw`$ErrorActionPreference = 'Stop'
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
- $helper = Get-Service iphlpsvc
3458
- if ($helper.StartType -eq 'Disabled') { throw 'Windows IP Helper is disabled. Enable it through your system administrator to use port forwarding.' }
3459
- if ($helper.Status -ne 'Running') { Start-Service iphlpsvc }
3460
- $oldRule = $rule
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
- throw
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 windowsElevationCommand(port, remove = false) {
3483
- const script = `try {
3484
- ${windowsForwarderScript(port, remove)}
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
- async function configureWindowsForwarder(port, remove = false) {
3491
- const script = windowsElevationCommand(port, remove);
3492
- try {
3493
- await execute("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], { windowsHide: true, timeout: 120000, maxBuffer: 8192 });
3494
- } catch {
3495
- 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.");
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/system-forwarder.ts
3500
- import { execFile as execFile2 } from "node:child_process";
3501
- import os2 from "node:os";
3502
- import { promisify as promisify2 } from "node:util";
3503
- var execute2 = promisify2(execFile2);
3504
- var label = "dev.localdeck.port80";
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
- // ../daemon/src/clean-urls.ts
3577
- class CleanUrls {
3578
- targetPort;
3579
- identity;
3580
- port;
3581
- platform;
3582
- preferenceFile;
3583
- configureSystem;
3584
- systemRelayInstalled;
3585
- bridge;
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
- async status() {
3598
- const ready = await new Promise((resolve) => {
3599
- const request = http.get({ hostname: "127.0.0.1", port: this.port, path: "/api/forwarder/probe", agent: false }, (response) => {
3600
- let body = "";
3601
- response.on("data", (chunk) => {
3602
- body += chunk;
3603
- if (body.length > 256)
3604
- request.destroy();
3605
- });
3606
- response.on("end", () => resolve(response.statusCode === 200 && body === this.identity));
3607
- response.on("error", () => resolve(false));
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
- const timer = setTimeout(() => {
3610
- request.destroy();
3611
- resolve(false);
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
- enable(options = {}) {
3619
- if (this.enabling)
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
- async start(options) {
3627
- if (this.closed)
3628
- return { ready: false, error: "LocalDeck is shutting down." };
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
- if (this.closed)
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
- const message = error instanceof Error ? error.message : "Could not start port-80 forwarding.";
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
- async uninstall() {
3696
- if (this.enabling)
3697
- return { ready: false, error: "Wait for the current setup to finish." };
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 hadBridge = Boolean(this.bridge);
3700
- if (this.bridge) {
3701
- await this.bridge.close();
3702
- this.bridge = undefined;
3703
- }
3704
- if (this.platform === "darwin" ? this.systemRelayInstalled() : this.platform === "win32" && this.port === 80 && !hadBridge)
3705
- await this.configureSystem(this.targetPort(), true);
3706
- writeJson(this.preferenceFile, { declined: true, removed: true });
3707
- return { ready: false, removed: true };
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
- return { ready: false, error: error instanceof Error ? error.message : "Removal failed." };
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
- async close() {
3713
- this.closed = true;
3714
- await this.enabling;
3715
- if (this.bridge) {
3716
- await this.bridge.close();
3717
- this.bridge = undefined;
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
- append(chunk) {
3740
- this.bytes += chunk.length;
3741
- const count = Math.min(chunk.length, this.limit - this.retained);
3742
- let offset = 0;
3743
- while (offset < count) {
3744
- let tail = this.chunks.at(-1);
3745
- if (!tail || this.tailUsed === tail.length) {
3746
- if (tail && tail.length < 4096) {
3747
- const grown = Buffer.allocUnsafe(Math.min(4096, this.limit - this.retained + this.tailUsed, Math.max(tail.length * 2, count - offset)));
3748
- tail.copy(grown, 0, 0, this.tailUsed);
3749
- this.chunks[this.chunks.length - 1] = tail = grown;
3750
- } else {
3751
- tail = Buffer.allocUnsafe(Math.min(4096, this.limit - this.retained, Math.max(64, count - offset)));
3752
- this.chunks.push(tail);
3753
- this.tailUsed = 0;
3754
- }
3755
- }
3756
- const size = Math.min(count - offset, tail.length - this.tailUsed);
3757
- chunk.copy(tail, this.tailUsed, offset, offset + size);
3758
- this.tailUsed += size;
3759
- this.retained += size;
3760
- offset += size;
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
- snapshot(complete, contentEncoding) {
3764
- let buffer = Buffer.concat(this.chunks.map((chunk, index) => index === this.chunks.length - 1 ? chunk.subarray(0, this.tailUsed) : chunk), this.retained);
3765
- let encoded = Boolean(contentEncoding && contentEncoding !== "identity");
3766
- const truncated = this.bytes > this.retained;
3767
- if (encoded && complete && !truncated && buffer.length) {
3768
- const decode = { gzip: gunzipSync, deflate: inflateSync, br: brotliDecompressSync }[contentEncoding];
3769
- try {
3770
- if (decode) {
3771
- buffer = decode(buffer, { maxOutputLength: this.limit });
3772
- encoded = false;
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
- let data;
3777
- let encoding = "utf8";
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
- if (encoded || buffer.includes(0))
3780
- throw new Error("binary body");
3781
- data = new TextDecoder("utf-8", { fatal: true }).decode(buffer);
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
- data = buffer.toString("base64");
3784
- encoding = "base64";
3854
+ fs3.renameSync(this.catalogFile, this.nextCorruptFile());
3855
+ return [];
3785
3856
  }
3786
- return { data, encoding, bytes: this.bytes, truncated, complete, ...encoded ? { contentEncoding } : {} };
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
- // ../daemon/src/request-headers.ts
3791
- var MAX_HEADER_BYTES = 16384;
3792
- var MAX_HEADER_COUNT = 100;
3793
- function captureHeaders(raw) {
3794
- const entries = [];
3795
- let bytes = 0;
3796
- for (let i = 0;i + 1 < raw.length; i += 2) {
3797
- const name = raw[i];
3798
- const value = raw[i + 1];
3799
- bytes += Buffer.byteLength(name) + Buffer.byteLength(value);
3800
- if (bytes > MAX_HEADER_BYTES || entries.length >= MAX_HEADER_COUNT)
3801
- return { entries, truncated: true };
3802
- entries.push([name, value]);
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/proxy.ts
3808
- import http2 from "node:http";
3809
- import net2 from "node:net";
3810
- var HOP_BY_HOP = new Set(["connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade"]);
3811
- function forwardHeaders(req, target) {
3812
- const out = {};
3813
- for (const [k, v] of Object.entries(req.headers)) {
3814
- if (HOP_BY_HOP.has(k) || v === undefined)
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
- out[k] = v;
3817
- }
3818
- if (target.via === "share") {
3819
- const host = net2.isIP(target.upstreamHost) === 6 ? `[${target.upstreamHost}]` : target.upstreamHost;
3820
- out.host = `${host}:${target.upstreamPort}`;
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
- const remote = req.socket.remoteAddress ?? "";
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 waitingPage(target) {
3830
- return `<!doctype html><meta charset="utf-8"><meta http-equiv="refresh" content="2">
3831
- <title>LocalDeck — waiting for ${target.service}</title>
3832
- <style>body{font:15px/1.5 system-ui;margin:0;display:grid;place-items:center;height:100vh;background:#0f1115;color:#e6e8ef}
3833
- .c{max-width:420px;text-align:center}code{background:#1c1f27;padding:2px 6px;border-radius:4px}</style>
3834
- <div class="c"><h1 style="font-weight:600">Waiting for <code>${target.service}</code></h1>
3835
- <p>LocalDeck is holding this port. Nothing is listening on the upstream port <code>${target.upstreamPort}</code> yet.
3836
- This page reloads every 2 seconds.</p></div>`;
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
- function proxyRequest(req, res, target, hooks) {
3839
- const started = Date.now();
3840
- const requestHeaders = captureHeaders(req.rawHeaders);
3841
- const requestBody = new BodyCapture;
3842
- const responseBody = new BodyCapture;
3843
- const fullResponse = hooks.responseBodies?.start(target.service);
3844
- const captureResponse = (chunk) => {
3845
- responseBody.append(chunk);
3846
- fullResponse?.append(chunk);
3847
- };
3848
- let responseComplete = false;
3849
- let responseEncoding;
3850
- let responseHeaders;
3851
- let bytes = 0;
3852
- let recorded = false;
3853
- const record = (status, error) => {
3854
- if (recorded)
3855
- return;
3856
- recorded = true;
3857
- fullResponse?.finish(responseComplete, responseEncoding);
3858
- hooks.onRequest({
3859
- ts: started,
3860
- service: target.service,
3861
- method: req.method ?? "GET",
3862
- path: req.url ?? "/",
3863
- status,
3864
- durationMs: Date.now() - started,
3865
- bytes,
3866
- via: target.via,
3867
- error,
3868
- requestHeaders,
3869
- responseHeaders,
3870
- requestBody: requestBody.snapshot(req.readableEnded, req.headers["content-encoding"]),
3871
- responseBody: responseBody.snapshot(responseComplete, responseEncoding),
3872
- responseBodyId: fullResponse?.id
3873
- });
3874
- };
3875
- if (!target.upstreamReady) {
3876
- responseHeaders = captureHeaders(["content-type", "text/html; charset=utf-8", "retry-after", "2"]);
3877
- res.writeHead(503, { "content-type": "text/html; charset=utf-8", "retry-after": "2" });
3878
- const body = waitingPage(target);
3879
- captureResponse(Buffer.from(body));
3880
- responseComplete = true;
3881
- res.end(body);
3882
- record(503, "upstream not ready");
3883
- return;
3884
- }
3885
- let responseStatus = 502;
3886
- let response;
3887
- const upstream = http2.request({ host: target.upstreamHost, port: target.upstreamPort, method: req.method, path: req.url, headers: forwardHeaders(req, target) }, (up) => {
3888
- if (res.destroyed) {
3889
- up.destroy();
3890
- return;
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
- const headers = { ...up.headers };
3908
- res.writeHead(up.statusCode ?? 502, up.statusMessage, headers);
3909
- up.on("data", (chunk) => {
3910
- bytes += chunk.length;
3911
- captureResponse(chunk);
4107
+ request.on("error", (error) => {
4108
+ clearTimeout(timer);
4109
+ if (!settled) {
4110
+ settled = true;
4111
+ reject(error);
4112
+ }
3912
4113
  });
3913
- up.pipe(res);
4114
+ request.end();
3914
4115
  });
3915
- upstream.on("error", (err) => {
3916
- if (!res.headersSent)
3917
- responseHeaders = captureHeaders(["content-type", "text/plain; charset=utf-8"]);
3918
- if (!res.headersSent && !res.destroyed) {
3919
- res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
3920
- const body = `LocalDeck: upstream ${target.service} (${target.upstreamHost}:${target.upstreamPort}) error: ${err.code ?? err.message}`;
3921
- captureResponse(Buffer.from(body));
3922
- responseComplete = true;
3923
- res.end(body);
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
- res.destroy();
3926
- record(502, err.code ?? err.message);
3927
- });
3928
- const clientDisconnected = () => {
3929
- record(499, "client disconnected before response completed");
3930
- response?.destroy();
3931
- upstream.destroy();
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 proxyUpgrade(req, socket, head, target, hooks) {
3943
- const started = Date.now();
3944
- socket.on("error", () => socket.destroy());
3945
- if (!target.upstreamReady) {
3946
- socket.end(`HTTP/1.1 503 Service Unavailable\r
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
- const up = net2.connect(target.upstreamPort, target.upstreamHost, () => {
3953
- const headers = forwardHeaders(req, target);
3954
- headers["connection"] = "Upgrade";
3955
- headers["upgrade"] = req.headers["upgrade"];
3956
- const lines = [`${req.method} ${req.url} HTTP/1.1`];
3957
- for (const [k, v] of Object.entries(headers)) {
3958
- if (v === undefined)
3959
- continue;
3960
- for (const val of Array.isArray(v) ? v : [String(v)])
3961
- lines.push(`${k}: ${val}`);
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
- up.write(lines.join(`\r
3964
- `) + `\r
3965
- \r
3966
- `);
3967
- if (head.length)
3968
- up.write(head);
3969
- up.pipe(socket).pipe(up);
3970
- hooks.onRequest({
3971
- ts: started,
3972
- service: target.service,
3973
- method: req.method ?? "GET",
3974
- path: req.url ?? "/",
3975
- status: 101,
3976
- durationMs: Date.now() - started,
3977
- bytes: 0,
3978
- requestHeaders: captureHeaders(req.rawHeaders),
3979
- via: target.via
3980
- });
3981
- });
3982
- const kill = () => {
3983
- up.destroy();
3984
- socket.destroy();
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
- up.on("error", kill);
3987
- socket.on("error", kill);
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/projects.ts
3993
- import { createHash } from "node:crypto";
3994
- import fs4 from "node:fs";
3995
- import path4 from "node:path";
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
- // ../daemon/src/ports.ts
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
- async function isPortFree(port, host) {
4007
- for (const candidate of host ? [host] : ["127.0.0.1", "::1", "0.0.0.0", "::"]) {
4008
- if (await canBind(port, candidate, candidate === (host ?? "127.0.0.1")) === false)
4009
- return false;
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 true;
4477
+ return value;
4012
4478
  }
4013
- function getFreePort(host = "127.0.0.1") {
4014
- return new Promise((resolve, reject) => {
4015
- const srv = net3.createServer();
4016
- srv.once("error", reject);
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 serviceName(key) {
4024
- const separator = key.lastIndexOf(":");
4025
- return separator === -1 ? key : key.slice(separator + 1);
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 rememberedPortFromFile(key, file) {
4028
- const map = readJson(file, {});
4029
- if (map[key] !== undefined)
4030
- return map[key];
4031
- const name = serviceName(key);
4032
- const hasOtherQualifiedKey = Object.keys(map).some((candidate) => candidate !== key && candidate.includes(":") && candidate.endsWith(`:${name}`));
4033
- return hasOtherQualifiedKey ? undefined : map[name];
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
- function rememberPort(name, port, file = PORTS_FILE) {
4036
- const map = readJson(file, {});
4037
- map[name] = port;
4038
- writeJson(file, map);
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
- function forgetProjectPorts(projectId, file = PORTS_FILE) {
4041
- const map = readJson(file, {});
4042
- const kept = Object.fromEntries(Object.entries(map).filter(([key]) => !key.startsWith(`${projectId}:`)));
4043
- if (Object.keys(kept).length !== Object.keys(map).length)
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
- async function pickStablePort(name, taken, start = 4000, end = 4999, file = PORTS_FILE) {
4047
- const map = readJson(file, {});
4048
- const remembered = rememberedPortFromFile(name, file);
4049
- if (remembered) {
4050
- if (taken.has(remembered) || !await isPortFree(remembered)) {
4051
- throw new Error(`remembered stable port ${remembered} for "${name}" is already in use; stop the conflicting listener or explicitly choose a different port`);
4052
- }
4053
- return remembered;
4054
- }
4055
- const bareName = serviceName(name);
4056
- const usingLegacy = map[name] === undefined && remembered === map[bareName];
4057
- const reserved = new Set(Object.entries(map).filter(([n]) => n !== name && !(usingLegacy && n === bareName)).map(([, p]) => p));
4058
- for (let p = start;p <= end; p++) {
4059
- if (taken.has(p) || reserved.has(p))
4060
- continue;
4061
- if (await isPortFree(p))
4062
- return p;
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 new Error(`no free port between ${start} and ${end}`);
4668
+ throw
4065
4669
  }
4066
-
4067
- // ../daemon/src/projects.ts
4068
- class ProjectError extends Error {
4069
- code;
4070
- constructor(code, message) {
4071
- super(message);
4072
- this.code = code;
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
- class ProjectRegistry {
4077
- catalogFile;
4078
- portsFile;
4079
- now;
4080
- projects;
4081
- constructor({ catalogFile = PROJECTS_FILE, portsFile = PORTS_FILE, now = Date.now } = {}) {
4082
- this.catalogFile = catalogFile;
4083
- this.portsFile = portsFile;
4084
- this.now = now;
4085
- this.projects = this.readCatalog();
4086
- }
4087
- remember(file, now = this.now()) {
4088
- const realFile = fs4.realpathSync(file);
4089
- const config = loadProjectFile(realFile);
4090
- const id = projectId(realFile);
4091
- const name = path4.basename(path4.dirname(realFile));
4092
- const directory = path4.dirname(realFile);
4093
- const existing = this.projects.find((entry) => entry.id === id);
4094
- if (config.nickname)
4095
- this.assertNicknameAvailable(id, config.nickname);
4096
- if (existing) {
4097
- existing.name = name;
4098
- existing.file = realFile;
4099
- existing.directory = directory;
4100
- existing.lastUsedAt = now;
4101
- existing.configNickname = config.nickname;
4102
- } else {
4103
- this.projects.push({
4104
- id,
4105
- name,
4106
- slug: this.assignSlug(name, id),
4107
- file: realFile,
4108
- directory,
4109
- lastUsedAt: now,
4110
- ...config.nickname ? { configNickname: config.nickname } : {}
4111
- });
4112
- }
4113
- this.persist();
4114
- return this.toSummary(this.byId(id));
4115
- }
4116
- list() {
4117
- return this.sorted().map((project) => this.toSummary(project));
4118
- }
4119
- details(projectId) {
4120
- const project = this.require(projectId);
4121
- try {
4122
- const loaded = this.load(project);
4123
- return { ...project, nickname: this.nickname(projectId), available: true, commands: loaded.commands, ...loaded.profiles ? { profiles: loaded.profiles } : {} };
4124
- } catch (error) {
4125
- return { ...project, available: false, error: errorMessage(error), commands: [] };
4126
- }
4127
- }
4128
- resolve(projectId, serviceName2) {
4129
- const details = this.details(projectId);
4130
- if (!details.available)
4131
- throw new ProjectError("invalid", details.error);
4132
- const command = details.commands.find((candidate) => candidate.name === serviceName2.toLowerCase());
4133
- if (!command)
4134
- throw new ProjectError("not_found", `Project ${projectId} has no service named ${serviceName2}`);
4135
- return command;
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
- forget(projectId, activeKeys) {
4138
- this.require(projectId);
4139
- if ([...activeKeys].some((key) => key.startsWith(`${projectId}:`))) {
4140
- throw new ProjectError("active", "stop its services before forgetting it");
4141
- }
4142
- this.projects = this.projects.filter((project) => project.id !== projectId);
4143
- this.persist();
4144
- forgetProjectPorts(projectId, this.portsFile);
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
- refresh(projectId) {
4147
- return this.details(projectId);
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
- nickname(projectId) {
4150
- const project = this.byId(projectId);
4151
- return project?.nickname ?? project?.configNickname;
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
- updateNickname(projectId, value) {
4154
- const project = this.require(projectId);
4155
- const nickname = value.trim().toLowerCase();
4156
- if (nickname && (!validNickname(nickname) || nickname === "standalone")) {
4157
- throw new ProjectError("invalid", "Use 1–63 letters, numbers or hyphens, with no leading or trailing hyphen. 'standalone' is reserved.");
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 (nickname)
4160
- this.assertNicknameAvailable(projectId, nickname);
4161
- const previous = project.nickname;
4162
- if (nickname)
4163
- project.nickname = nickname;
4164
- else
4165
- delete project.nickname;
4166
- try {
4167
- this.persist();
4168
- } catch (error) {
4169
- if (previous === undefined)
4170
- delete project.nickname;
4171
- else
4172
- project.nickname = previous;
4173
- throw error;
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
- return this.details(projectId);
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
- updateProjectNickname(project.file, nickname);
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
- throw new ProjectError("invalid", errorMessage(error));
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
- updateDependencies(projectId, serviceName2, after) {
4193
- const project = this.require(projectId);
4885
+ async uninstall() {
4886
+ if (this.enabling)
4887
+ return { ready: false, error: "Wait for the current setup to finish." };
4194
4888
  try {
4195
- updateProjectDependencies(project.file, serviceName2, after);
4196
- return this.details(projectId);
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
- throw new ProjectError("invalid", errorMessage(error));
4899
+ return { ready: false, error: error instanceof Error ? error.message : "Removal failed." };
4199
4900
  }
4200
4901
  }
4201
- updateServiceConfig(projectId, serviceName2, settings) {
4202
- const project = this.require(projectId);
4203
- try {
4204
- updateProjectServiceConfig(project.file, serviceName2, settings);
4205
- return this.details(projectId);
4206
- } catch (error) {
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
- readCatalog() {
4211
- if (!fs4.existsSync(this.catalogFile))
4212
- return [];
4213
- try {
4214
- const parsed = JSON.parse(fs4.readFileSync(this.catalogFile, "utf8"));
4215
- if (!isCatalog(parsed))
4216
- throw new Error("invalid project catalog");
4217
- return parsed.projects;
4218
- } catch {
4219
- fs4.renameSync(this.catalogFile, this.nextCorruptFile());
4220
- return [];
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
- assertNicknameAvailable(projectId, nickname) {
4224
- if (this.projects.some((other) => other.id !== projectId && (other.slug === nickname || other.nickname === nickname || other.configNickname === nickname))) {
4225
- throw new ProjectError("invalid", "That project nickname is already taken. Choose another.");
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
- nextCorruptFile() {
4229
- const base = `${this.catalogFile}.corrupt-${this.now()}`;
4230
- let candidate = base;
4231
- let suffix = 2;
4232
- while (fs4.existsSync(candidate))
4233
- candidate = `${base}-${suffix++}`;
4234
- return candidate;
4235
- }
4236
- persist() {
4237
- writeJson(this.catalogFile, { version: 1, projects: this.sortedEntries() });
4238
- }
4239
- sorted() {
4240
- return this.sortedEntries().map((project) => ({ ...project }));
4241
- }
4242
- sortedEntries() {
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
- this.load(project);
4248
- return { ...project, nickname: this.nickname(project.id), available: true };
4249
- } catch (error) {
4250
- return { ...project, available: false, error: errorMessage(error) };
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
- load(project) {
4254
- if (!fs4.existsSync(project.file))
4255
- throw new Error(`${project.file} does not exist`);
4256
- const loaded = loadProjectFile(project.file);
4257
- if (loaded.nickname)
4258
- this.assertNicknameAvailable(project.id, loaded.nickname);
4259
- project.configNickname = loaded.nickname;
4260
- return loaded;
4261
- }
4262
- require(projectId) {
4263
- const project = this.byId(projectId);
4264
- if (!project)
4265
- throw new ProjectError("not_found", `Project ${projectId} was not found`);
4266
- return project;
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
- byId(projectId) {
4269
- return this.projects.find((project) => project.id === projectId);
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
- assignSlug(name, id) {
4272
- const base = slugify(name) || "project";
4273
- if (!this.projects.some((project) => project.slug === base || project.nickname === base || project.configNickname === base))
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 projectId(realFile) {
4284
- return createHash("sha256").update(realFile).digest("hex").slice(0, 16);
4285
- }
4286
- function slugify(value) {
4287
- return value.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
4288
- }
4289
- function isCatalog(value) {
4290
- if (!value || typeof value !== "object")
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 validNickname(value) {
4305
- return value.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value);
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 errorMessage(error) {
4308
- return error instanceof Error ? error.message : String(error);
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 http3 from "node:http";
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 = http3.createServer((req, res) => {
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 body = "";
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
- body = "";
5421
+ body2 = "";
4551
5422
  } else
4552
- body += c;
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(body).get("password") ?? "";
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
- // ../daemon/src/project-secrets.ts
5177
- import fs6 from "node:fs";
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 validateName(name) {
5185
- if (!NAME.test(name) || name === "PORT" || name.startsWith("LOCALDECK_")) {
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 createProjectSecretStore(home = HOME_DIR) {
5190
- const directory = path6.join(home, "secrets");
5191
- function secretPath(projectId2) {
5192
- if (!ID.test(projectId2))
5193
- throw new ProjectSecretError("invalid project ID");
5194
- return path6.join(directory, `${projectId2}.json`);
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
- function read(projectId2) {
5197
- const file = secretPath(projectId2);
5198
- let text;
5199
- try {
5200
- text = fs6.readFileSync(file, "utf8");
5201
- } catch (error) {
5202
- if (error.code === "ENOENT")
5203
- return Object.create(null);
5204
- throw new ProjectSecretError("Could not read stored secrets");
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
- let parsed;
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
- parsed = JSON.parse(text);
5906
+ await runTaskkill("taskkill.exe", args, { timeout: 5000, killSignal: "SIGKILL" });
5907
+ return true;
5209
5908
  } catch {
5210
- throw new ProjectSecretError("stored secrets are corrupt");
5211
- }
5212
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
5213
- throw new ProjectSecretError("stored secrets are corrupt");
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
- function resolveProjectSecrets(projectId2, names) {
5251
- const output = Object.create(null);
5252
- if (!names.length)
5253
- return output;
5254
- const values = read(projectId2);
5255
- for (const name of names) {
5256
- validateName(name);
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 { listProjectSecrets, setProjectSecret, deleteProjectSecret, resolveProjectSecrets } = createProjectSecretStore();
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, body) {
5938
+ function json(res, status, body2) {
5276
5939
  res.writeHead(status, { "content-type": "application/json" });
5277
- res.end(JSON.stringify(body));
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 = randomUUID();
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 body = await readBody(req);
5467
- if (!body || typeof body !== "object" || Array.isArray(body) || Object.keys(body).some((key) => !["install", "automatic"].includes(key)) || body.install !== undefined && typeof body.install !== "boolean" || body.automatic !== undefined && typeof body.automatic !== "boolean")
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(body));
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 body = await readBody(req);
6268
+ const body2 = await readBody(req);
5604
6269
  requireMutationAllowed(deps);
5605
- if (!body || typeof body !== "object" || Array.isArray(body))
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 = body.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 body = await readBody(req);
5638
- if (!body || typeof body !== "object" || Array.isArray(body) || Object.keys(body).length !== 1 || typeof body.value !== "string")
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, body.value);
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 body = await readBody(req);
6312
+ const body2 = await readBody(req);
5648
6313
  requireMutationAllowed(deps);
5649
- if (!body || typeof body !== "object" || Array.isArray(body) || Object.keys(body).length !== 1 || typeof body.nickname !== "string") {
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, body.nickname);
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 === 4 && parts[2] === "profiles" && method === "POST") {
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
- const project = projects.details(projectId2);
5669
- const names = project.profiles?.[parts[3]];
5670
- if (!project.available || !names)
5671
- throw new HttpError(422, "Unknown or unavailable startup profile");
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 body = await readBody(req);
6375
+ const body2 = await readBody(req);
5707
6376
  requireMutationAllowed(deps);
5708
- if (!body || typeof body !== "object" || Array.isArray(body))
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(body);
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 = body.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 body = await readBody(req);
6440
+ const body2 = await readBody(req);
5772
6441
  requireMutationAllowed(deps);
5773
- if (!body || typeof body !== "object" || Array.isArray(body) || Object.keys(body).length !== 1 || typeof body.requestId !== "string")
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 === body.requestId);
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 body = await readBody(req);
6486
+ const body2 = await readBody(req);
5818
6487
  requireMutationAllowed(deps);
5819
- if (!body || typeof body !== "object" || Array.isArray(body))
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 } = body;
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 = path7.join(dir, path7.normalize(pathname).replace(/^(\.\.[/\\])+/, ""));
6523
+ let file = path10.join(dir, path10.normalize(pathname).replace(/^(\.\.[/\\])+/, ""));
5855
6524
  if (!file.startsWith(dir))
5856
- file = path7.join(dir, "index.html");
5857
- if (!fs7.existsSync(file) || fs7.statSync(file).isDirectory())
5858
- file = path7.join(dir, "index.html");
5859
- const hashedAsset = path7.dirname(file) === path7.join(dir, "assets") && /-[A-Za-z0-9_-]{8,}\.[a-z0-9]+$/i.test(path7.basename(file));
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[path7.extname(file)] ?? "application/octet-stream",
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
- fs7.createReadStream(file).pipe(res);
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 ? path7.resolve(project.directory, command.cwd) : project.directory;
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 (!fs7.statSync(cwd).isDirectory())
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 fs8 from "node:fs";
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 randomUUID2 } from "node:crypto";
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 = randomUUID2();
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 path8 from "node:path";
6647
- import { createHash as createHash2 } from "node:crypto";
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) => fs8.createWriteStream(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
- fs8.mkdirSync(this.logsDir, { recursive: true });
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 = createHash2("sha256").update(`${type}\x00${service}\x00${offset}\x00${serialized}`).digest("hex");
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 path8.join(this.logsDir, `${encodeURIComponent(service)}.${suffix}`);
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 path8.join(this.logsDir, `${safe}.${suffix}`);
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 = fs8.existsSync(marker2) ? fs8.readFileSync(marker2, "utf8") : undefined;
7664
+ const boundary = fs11.existsSync(marker2) ? fs11.readFileSync(marker2, "utf8") : undefined;
6751
7665
  let fd;
6752
7666
  let size;
6753
7667
  try {
6754
- fd = fs8.openSync(file, "r");
6755
- size = fs8.fstatSync(fd).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
- fs8.readSync(fd, bytes, 0, count, start);
7680
+ fs11.readSync(fd, bytes, 0, count, start);
6767
7681
  } finally {
6768
- fs8.closeSync(fd);
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 fs8.statSync(logFile).size;
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 fs8.statSync(reqFile).size;
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 fs8.existsSync(marker2) ? fs8.readFileSync(marker2, "utf8") : undefined;
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
- fs8.writeFileSync(`${marker2}.tmp`, beforeId, { mode: 384 });
6919
- fs8.renameSync(`${marker2}.tmp`, marker2);
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
- fs8.writeFileSync(state.file, "", { mode: 384 });
6926
- fs8.rmSync(`${state.file}.1`, { force: true });
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 (fs8.existsSync(file))
7014
- fs8.chmodSync(file, 384);
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 fs8.statSync(file).size;
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
- fs8.rmSync(rotated, { force: true });
7093
- fs8.renameSync(state.file, rotated);
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
- fs8.chmodSync(state.file, 384);
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 path10 from "node:path";
7148
- import fs10 from "node:fs";
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 fs9 from "node:fs";
7174
- import path9 from "node:path";
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 = path9.basename(words[0]).replace(/\.cmd$/, "");
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 = path9.resolve(directory, value);
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, path9.resolve(cwd));
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 quote = "";
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 (quote) {
7250
- if (char === quote)
7251
- quote = "";
8178
+ if (quote2) {
8179
+ if (char === quote2)
8180
+ quote2 = "";
7252
8181
  else
7253
- word += quote === "'" && char === "$" ? "\x00" : char;
8182
+ word += quote2 === "'" && char === "$" ? "\x00" : char;
7254
8183
  } else if (char === '"' || char === "'") {
7255
- quote = char;
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 (quote)
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(fs9.readFileSync(path9.join(directory, "package.json"), "utf8"));
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" || path9.isAbsolute(pattern))
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 fs9.readdirSync(candidate, { withFileTypes: true })) {
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(path9.join(candidate, entry.name));
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 === path9.resolve(root, selector) || pkg.name === selector))
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, path10, expectedStatus, remainingMs, signal) {
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: path10,
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 secrets = [...values].map(stripVTControlCharacters).filter(Boolean).sort((a, b) => b.length - a.length);
7513
- if (!secrets.length) {
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 && secrets.some((value) => value.length > remaining.length && value.startsWith(remaining)))
8451
+ if (!final && secrets2.some((value) => value.length > remaining.length && value.startsWith(remaining)))
7523
8452
  break;
7524
- const match = secrets.find((value) => pending.startsWith(value, index));
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 ? path10.resolve(project.directory, command.cwd) : project.directory;
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 ? path10.resolve(project.directory, command.cwd) : project.directory;
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 ? path10.resolve(project.directory, command.cwd) : project.directory;
8722
+ const cwd = command.cwd ? path14.resolve(project.directory, command.cwd) : project.directory;
7794
8723
  try {
7795
- if (!fs10.statSync(cwd).isDirectory())
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 ? path10.resolve(project.directory, command.cwd) : project.directory;
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.4.0";
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 ? path11.join(opts.homeDir, "daemon.json") : STATE_FILE;
8331
- const projectsFile = opts.homeDir ? path11.join(opts.homeDir, "projects.json") : PROJECTS_FILE;
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
- fs11.rmSync(`${stateFile}.${process.pid}.tmp`, { force: true });
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
- fs11.rmSync(stateFile, { force: true });
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
- fs11.unlinkSync(stateFile);
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 (fs11.existsSync(stateFile)) {
8427
- const stat = fs11.statSync(stateFile);
8428
- priorState = { contents: fs11.readFileSync(stateFile), mode: stat.mode & 511 };
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
- fs11.writeFileSync(temporary, contents, { mode });
8450
- fs11.chmodSync(temporary, mode);
8451
- fs11.renameSync(temporary, file);
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(fs11.readFileSync(stateFile, "utf8"));
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 body = await res.json();
8489
- return body.pid === state.pid ? state : null;
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 fs12 from "node:fs";
8498
- import path12 from "node:path";
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 = path12.dirname(fileURLToPath(import.meta.url));
9432
+ const here = path16.dirname(fileURLToPath(import.meta.url));
8502
9433
  const candidates = [
8503
- path12.resolve(here, "../dashboard"),
8504
- path12.resolve(here, "../../dashboard/dist")
9434
+ path16.resolve(here, "../dashboard"),
9435
+ path16.resolve(here, "../../dashboard/dist")
8505
9436
  ];
8506
- return candidates.find((d) => fs12.existsSync(path12.join(d, "index.html")));
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 = path12.resolve(path12.dirname(fileURLToPath(import.meta.url)), "../dist/index.js");
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 (fs12.existsSync(dist) && hasNode)
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
- fs12.mkdirSync(path12.dirname(DAEMON_LOG), { recursive: true });
8528
- const out = fs12.openSync(DAEMON_LOG, "a");
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, body, headers) {
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: { ...body ? { "content-type": "application/json" } : {}, ...headers },
8559
- body: body ? JSON.stringify(body) : undefined
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, startForwarder, HOME_DIR, STATE_FILE, DEFAULT_API_PORT, assertUserRuntime, isPortFree, getFreePort, findProjectFile, loadProject, loadProjectFile, shellCommand, serviceKey, processTree, listeningPorts, chooseUpstream, unreachableListenerMessage, connectHost, streamLines, killProcessTree, stripAnsi, redactDiagnostic, environmentValues, listProjectSecrets, resolveProjectSecrets, startupGuidance, inferPortEnvironment, startupLayers, waitForReadiness, secretOutput, VERSION, startDaemon, findDaemon, dashboardDir, ensureDaemon, Api };
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 };