localdeck 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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";
@@ -3378,6 +3334,50 @@ function shellCommand(command, platform = process.platform, comspec = process.en
3378
3334
  return [process.env.SHELL || "/bin/sh", "-c", command];
3379
3335
  }
3380
3336
 
3337
+ // ../daemon/src/forward.ts
3338
+ import net from "node:net";
3339
+ function startForwarder(options = {}) {
3340
+ const listenPort = options.listenPort ?? 80;
3341
+ const targetPort = options.targetPort ?? 7777;
3342
+ if (!Number.isInteger(listenPort) || listenPort < 0 || listenPort > 65535 || !Number.isInteger(targetPort) || targetPort < 1 || targetPort > 65535)
3343
+ return Promise.reject(new Error("forward ports must be integers between 1 and 65535"));
3344
+ if (listenPort !== 0 && listenPort === targetPort)
3345
+ return Promise.reject(new Error("forward listen port and target port must be different"));
3346
+ const sockets = new Set;
3347
+ const server = net.createServer((client) => {
3348
+ sockets.add(client);
3349
+ client.once("close", () => sockets.delete(client));
3350
+ const upstream = net.connect(targetPort, "127.0.0.1");
3351
+ sockets.add(upstream);
3352
+ upstream.once("close", () => sockets.delete(upstream));
3353
+ client.pipe(upstream);
3354
+ upstream.pipe(client);
3355
+ const destroy = () => {
3356
+ client.destroy();
3357
+ upstream.destroy();
3358
+ };
3359
+ client.once("error", destroy);
3360
+ upstream.once("error", destroy);
3361
+ client.once("close", destroy);
3362
+ upstream.once("close", destroy);
3363
+ });
3364
+ return new Promise((resolve, reject) => {
3365
+ const onError = (error) => {
3366
+ server.removeAllListeners();
3367
+ 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}`));
3368
+ };
3369
+ server.once("error", onError);
3370
+ server.listen(listenPort, "127.0.0.1", () => {
3371
+ server.off("error", onError);
3372
+ resolve({ port: server.address().port, close: () => new Promise((done) => {
3373
+ for (const socket of sockets)
3374
+ socket.destroy();
3375
+ server.close(() => done());
3376
+ }) });
3377
+ });
3378
+ });
3379
+ }
3380
+
3381
3381
  // ../daemon/src/dependencies.ts
3382
3382
  function startupLayers(commands, selectedNames) {
3383
3383
  const byName = new Map(commands.map((command) => [command.name, command]));
@@ -3414,815 +3414,1581 @@ function stopOrder(commands) {
3414
3414
  }
3415
3415
 
3416
3416
  // ../daemon/src/index.ts
3417
- import fs10 from "node:fs";
3417
+ import fs14 from "node:fs";
3418
3418
  import crypto2 from "node:crypto";
3419
- import path11 from "node:path";
3419
+ import path15 from "node:path";
3420
3420
 
3421
- // ../daemon/src/api.ts
3422
- import http5 from "node:http";
3423
- import { timingSafeEqual, randomUUID } from "node:crypto";
3421
+ // ../daemon/src/debugging-api.ts
3422
+ import path8 from "node:path";
3424
3423
 
3425
- // ../daemon/src/clean-urls.ts
3426
- import http from "node:http";
3424
+ // ../daemon/src/worktrees.ts
3425
+ import fs4 from "node:fs";
3426
+ import path4 from "node:path";
3427
+ import { createHash as createHash2 } from "node:crypto";
3428
+
3429
+ // ../daemon/src/projects.ts
3430
+ import { createHash } from "node:crypto";
3431
+ import fs3 from "node:fs";
3432
+ import { execFileSync } from "node:child_process";
3427
3433
  import path3 from "node:path";
3428
3434
 
3429
- // ../daemon/src/windows-forwarder.ts
3430
- import { execFile } from "node:child_process";
3431
- import { promisify } from "node:util";
3432
- var execute = promisify(execFile);
3433
- function windowsForwarderScript(port, remove = false) {
3434
- if (!Number.isInteger(port) || port < 1 || port > 65535 || port === 80)
3435
- throw new Error("Invalid forwarding target port.");
3436
- return String.raw`$ErrorActionPreference = 'Stop'
3437
- $ownerPath = 'HKLM:\SOFTWARE\LocalDeck\Port80'
3438
- $rulePath = 'HKLM:\SYSTEM\CurrentControlSet\Services\PortProxy\v4tov4\tcp'
3439
- $ruleName = '127.0.0.1/80'
3440
- $marker = 'LocalDeck loopback portproxy v1'
3441
- $netsh = Join-Path $env:SystemRoot 'System32\netsh.exe'
3442
- $owner = Get-ItemProperty -LiteralPath $ownerPath -ErrorAction SilentlyContinue
3443
- $rule = if (Test-Path -LiteralPath $rulePath) { (Get-Item -LiteralPath $rulePath).GetValue($ruleName) } else { $null }
3444
- $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
3445
- if ($owner -and !$owned) { throw 'Refusing to change an unrecognized LocalDeck ownership record.' }
3446
- 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.' }
3447
- ${remove ? String.raw`if ($rule) {
3448
- & $netsh interface portproxy delete v4tov4 listenaddress=127.0.0.1 listenport=80 protocol=tcp | Out-Null
3449
- if ($LASTEXITCODE -ne 0) { throw 'Could not remove the LocalDeck forwarding rule.' }
3450
- }
3451
- if ($owned) { Remove-Item -LiteralPath $ownerPath }
3452
- ` : String.raw`if (!$rule) {
3453
- $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') })
3454
- if ($listeners.Count) { throw 'Port 80 is already in use. The existing listener was left unchanged.' }
3435
+ // ../daemon/src/ports.ts
3436
+ import net2 from "node:net";
3437
+ function canBind(port, host, required) {
3438
+ return new Promise((resolve) => {
3439
+ const srv = net2.createServer();
3440
+ srv.once("error", (error) => resolve(!required && error.code !== "EADDRINUSE" && error.code !== "EACCES" ? undefined : false));
3441
+ srv.listen({ port, host, exclusive: true }, () => srv.close(() => resolve(true)));
3442
+ });
3455
3443
  }
3456
- $helper = Get-Service iphlpsvc
3457
- if ($helper.StartType -eq 'Disabled') { throw 'Windows IP Helper is disabled. Enable it through your system administrator to use port forwarding.' }
3458
- if ($helper.Status -ne 'Running') { Start-Service iphlpsvc }
3459
- $oldRule = $rule
3460
- $oldPort = if ($owned) { $owner.TargetPort } else { $null }
3461
- try {
3462
- New-Item -Path $ownerPath -Force | Out-Null
3463
- New-ItemProperty -LiteralPath $ownerPath -Name Marker -Value $marker -PropertyType String -Force | Out-Null
3464
- New-ItemProperty -LiteralPath $ownerPath -Name TargetPort -Value '${port}' -PropertyType String -Force | Out-Null
3465
- & $netsh interface portproxy add v4tov4 listenaddress=127.0.0.1 listenport=80 connectaddress=127.0.0.1 connectport=${port} protocol=tcp | Out-Null
3466
- if ($LASTEXITCODE -ne 0) { throw 'Windows could not install the forwarding rule.' }
3467
- $installed = (Get-Item -LiteralPath $rulePath).GetValue($ruleName)
3468
- if ($installed -ne '127.0.0.1/${port}') { throw 'The installed forwarding rule did not match the requested destination.' }
3469
- } catch {
3470
- if ($oldRule) {
3471
- & $netsh interface portproxy add v4tov4 listenaddress=127.0.0.1 listenport=80 connectaddress=127.0.0.1 "connectport=$oldPort" protocol=tcp | Out-Null
3472
- if ($LASTEXITCODE -eq 0) { Set-ItemProperty -LiteralPath $ownerPath -Name TargetPort -Value $oldPort }
3473
- } else {
3474
- & $netsh interface portproxy delete v4tov4 listenaddress=127.0.0.1 listenport=80 protocol=tcp | Out-Null
3475
- if ($LASTEXITCODE -eq 0) { Remove-Item -LiteralPath $ownerPath }
3444
+ async function isPortFree(port, host) {
3445
+ for (const candidate of host ? [host] : ["127.0.0.1", "::1", "0.0.0.0", "::"]) {
3446
+ if (await canBind(port, candidate, candidate === (host ?? "127.0.0.1")) === false)
3447
+ return false;
3476
3448
  }
3477
- throw
3449
+ return true;
3478
3450
  }
3479
- `}`;
3451
+ function getFreePort(host = "127.0.0.1") {
3452
+ return new Promise((resolve, reject) => {
3453
+ const srv = net2.createServer();
3454
+ srv.once("error", reject);
3455
+ srv.listen(0, host, () => {
3456
+ const { port } = srv.address();
3457
+ srv.close(() => resolve(port));
3458
+ });
3459
+ });
3480
3460
  }
3481
- function windowsElevationCommand(port, remove = false) {
3482
- const script = `try {
3483
- ${windowsForwarderScript(port, remove)}
3484
- exit 0
3485
- } catch { Write-Error $_ -ErrorAction Continue; exit 1 }`;
3486
- const encoded = Buffer.from(script, "utf16le").toString("base64");
3487
- 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.' }`;
3461
+ function serviceName(key) {
3462
+ const separator = key.lastIndexOf(":");
3463
+ return separator === -1 ? key : key.slice(separator + 1);
3488
3464
  }
3489
- async function configureWindowsForwarder(port, remove = false) {
3490
- const script = windowsElevationCommand(port, remove);
3491
- try {
3492
- await execute("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], { windowsHide: true, timeout: 120000, maxBuffer: 8192 });
3493
- } catch {
3494
- 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.");
3465
+ function rememberedPortFromFile(key, file) {
3466
+ const map = readJson(file, {});
3467
+ if (map[key] !== undefined)
3468
+ return map[key];
3469
+ const name = serviceName(key);
3470
+ const hasOtherQualifiedKey = Object.keys(map).some((candidate) => candidate !== key && candidate.includes(":") && candidate.endsWith(`:${name}`));
3471
+ return hasOtherQualifiedKey ? undefined : map[name];
3472
+ }
3473
+ function rememberPort(name, port, file = PORTS_FILE) {
3474
+ const map = readJson(file, {});
3475
+ map[name] = port;
3476
+ writeJson(file, map);
3477
+ }
3478
+ function forgetProjectPorts(projectId, file = PORTS_FILE) {
3479
+ const map = readJson(file, {});
3480
+ const kept = Object.fromEntries(Object.entries(map).filter(([key]) => !key.startsWith(`${projectId}:`)));
3481
+ if (Object.keys(kept).length !== Object.keys(map).length)
3482
+ writeJson(file, kept);
3483
+ }
3484
+ async function pickStablePort(name, taken, start = 4000, end = 4999, file = PORTS_FILE) {
3485
+ const map = readJson(file, {});
3486
+ const remembered = rememberedPortFromFile(name, file);
3487
+ if (remembered) {
3488
+ if (taken.has(remembered) || !await isPortFree(remembered)) {
3489
+ throw new Error(`remembered stable port ${remembered} for "${name}" is already in use; stop the conflicting listener or explicitly choose a different port`);
3490
+ }
3491
+ return remembered;
3492
+ }
3493
+ const bareName = serviceName(name);
3494
+ const usingLegacy = map[name] === undefined && remembered === map[bareName];
3495
+ const reserved = new Set(Object.entries(map).filter(([n]) => n !== name && !(usingLegacy && n === bareName)).map(([, p]) => p));
3496
+ for (let p = start;p <= end; p++) {
3497
+ if (taken.has(p) || reserved.has(p))
3498
+ continue;
3499
+ if (await isPortFree(p))
3500
+ return p;
3495
3501
  }
3502
+ throw new Error(`no free port between ${start} and ${end}`);
3496
3503
  }
3497
3504
 
3498
- // ../daemon/src/system-forwarder.ts
3499
- import { execFile as execFile2 } from "node:child_process";
3500
- import os2 from "node:os";
3501
- import { promisify as promisify2 } from "node:util";
3502
- var execute2 = promisify2(execFile2);
3503
- var label = "dev.localdeck.port80";
3504
- var destination = `/Library/LaunchDaemons/${label}.plist`;
3505
- var marker = "LocalDeck managed loopback relay v1";
3506
- function forwarderPlist(user, port) {
3507
- if (!/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(user) || user === "root")
3508
- throw new Error("Forwarding must run as a regular macOS user.");
3509
- if (!Number.isInteger(port) || port < 1 || port > 65535 || port === 80)
3510
- throw new Error("Invalid forwarding target port.");
3511
- return `<?xml version="1.0" encoding="UTF-8"?>
3512
- <!-- ${marker} -->
3513
- <plist version="1.0"><dict>
3514
- <key>Label</key><string>${label}</string>
3515
- <key>UserName</key><string>${user}</string>
3516
- <key>ProgramArguments</key><array><string>/usr/bin/nc</string><string>127.0.0.1</string><string>${port}</string></array>
3517
- <key>inetdCompatibility</key><dict><key>Wait</key><false/></dict>
3518
- <key>Sockets</key><dict><key>Listeners</key><dict>
3519
- <key>SockNodeName</key><string>127.0.0.1</string>
3520
- <key>SockServiceName</key><string>80</string>
3521
- <key>SockFamily</key><string>IPv4</string>
3522
- <key>SockType</key><string>stream</string>
3523
- <key>SockProtocol</key><string>TCP</string>
3524
- </dict></dict>
3525
- </dict></plist>`;
3526
- }
3527
- function systemForwarderCommand(user, port, remove = false) {
3528
- const plist = forwarderPlist(user, port);
3529
- const guard = `set -eu
3530
- if [ -L '${destination}' ]; then exit 1; fi
3531
- if [ -e '${destination}' ] && ! /usr/bin/grep -q '${marker}' '${destination}'; then
3532
- echo 'Refusing to replace an unmanaged forwarding service.' >&2; exit 1
3533
- fi`;
3534
- if (remove)
3535
- return `${guard}
3536
- /bin/launchctl bootout system/${label} 2>/dev/null || true
3537
- /bin/rm -f '${destination}'`;
3538
- return `${guard}
3539
- relay_file=$(/usr/bin/mktemp /Library/LaunchDaemons/.localdeck-port80.XXXXXX)
3540
- trap '/bin/rm -f "$relay_file" "$relay_file.backup"' EXIT
3541
- /bin/cat > "$relay_file" <<'LOCALDECK_PLIST'
3542
- ${plist}
3543
- LOCALDECK_PLIST
3544
- /usr/bin/plutil -lint "$relay_file" >/dev/null
3545
- /usr/sbin/chown root:wheel "$relay_file"
3546
- /bin/chmod 644 "$relay_file"
3547
- if [ -f '${destination}' ]; then /bin/cp -p '${destination}' "$relay_file.backup"; fi
3548
- /bin/launchctl bootout system/${label} 2>/dev/null || true
3549
- /bin/mv "$relay_file" '${destination}'
3550
- if ! /bin/launchctl bootstrap system '${destination}'; then
3551
- /bin/rm -f '${destination}'
3552
- if [ -f "$relay_file.backup" ]; then
3553
- /bin/mv "$relay_file.backup" '${destination}'
3554
- /bin/launchctl bootstrap system '${destination}' || true
3555
- fi
3556
- exit 1
3557
- fi`;
3558
- }
3559
- async function configureSystemForwarder(port, remove = false) {
3560
- if (process.platform === "win32")
3561
- return configureWindowsForwarder(port, remove);
3562
- if (process.platform !== "darwin")
3563
- throw new Error("Automatic administrator setup is available on macOS and Windows.");
3564
- const command = systemForwarderCommand(os2.userInfo().username, port, remove);
3565
- try {
3566
- await execute2("/usr/bin/osascript", ["-e", `on run argv
3567
- do shell script (item 1 of argv) with administrator privileges
3568
- end run`, command], { timeout: 120000, maxBuffer: 8192 });
3569
- } catch {
3570
- throw new Error("Administrator setup was cancelled or failed. Continuing with working port-number links; retry in Settings whenever you like.");
3571
- }
3505
+ // ../daemon/src/projects.ts
3506
+ class ProjectError extends Error {
3507
+ code;
3508
+ constructor(code, message) {
3509
+ super(message);
3510
+ this.code = code;
3511
+ }
3572
3512
  }
3573
3513
 
3574
- // ../daemon/src/clean-urls.ts
3575
- class CleanUrls {
3576
- targetPort;
3577
- identity;
3578
- port;
3579
- platform;
3580
- preferenceFile;
3581
- configureSystem;
3582
- bridge;
3583
- enabling;
3584
- closed = false;
3585
- constructor(targetPort, identity, port = 80, platform = process.platform, preferenceFile = path3.join(HOME_DIR, "forwarder.json"), configureSystem = configureSystemForwarder) {
3586
- this.targetPort = targetPort;
3587
- this.identity = identity;
3588
- this.port = port;
3589
- this.platform = platform;
3590
- this.preferenceFile = preferenceFile;
3591
- this.configureSystem = configureSystem;
3514
+ class ProjectRegistry {
3515
+ catalogFile;
3516
+ portsFile;
3517
+ now;
3518
+ projects;
3519
+ constructor({ catalogFile = PROJECTS_FILE, portsFile = PORTS_FILE, now = Date.now } = {}) {
3520
+ this.catalogFile = catalogFile;
3521
+ this.portsFile = portsFile;
3522
+ this.now = now;
3523
+ this.projects = this.readCatalog();
3592
3524
  }
3593
- async status() {
3594
- const ready = await new Promise((resolve) => {
3595
- const request = http.get({ hostname: "127.0.0.1", port: this.port, path: "/api/forwarder/probe", agent: false }, (response) => {
3596
- let body = "";
3597
- response.on("data", (chunk) => {
3598
- body += chunk;
3599
- if (body.length > 256)
3600
- request.destroy();
3601
- });
3602
- response.on("end", () => resolve(response.statusCode === 200 && body === this.identity));
3603
- response.on("error", () => resolve(false));
3525
+ remember(file, now = this.now()) {
3526
+ const realFile = fs3.realpathSync(file);
3527
+ const config = loadProjectFile(realFile);
3528
+ const id = projectId(realFile);
3529
+ const name = path3.basename(path3.dirname(realFile));
3530
+ const directory = path3.dirname(realFile);
3531
+ const existing = this.projects.find((entry) => entry.id === id);
3532
+ const alias = config.nickname ? this.configAlias(id, realFile, config.nickname) : undefined;
3533
+ if (existing) {
3534
+ existing.name = name;
3535
+ existing.file = realFile;
3536
+ existing.directory = directory;
3537
+ existing.lastUsedAt = now;
3538
+ existing.configNickname = config.nickname;
3539
+ if (alias) {
3540
+ existing.sharedConfigNickname = true;
3541
+ if (!existing.nickname)
3542
+ existing.nickname = alias;
3543
+ }
3544
+ } else {
3545
+ this.projects.push({
3546
+ id,
3547
+ name,
3548
+ slug: this.assignSlug(name, id),
3549
+ file: realFile,
3550
+ directory,
3551
+ lastUsedAt: now,
3552
+ ...config.nickname ? { configNickname: config.nickname } : {},
3553
+ ...alias ? { nickname: alias, sharedConfigNickname: true } : {}
3604
3554
  });
3605
- const timer = setTimeout(() => {
3606
- request.destroy();
3607
- resolve(false);
3608
- }, 1500);
3609
- request.on("close", () => clearTimeout(timer));
3610
- request.on("error", () => resolve(false));
3611
- });
3612
- return { ready: ready && !this.closed };
3555
+ }
3556
+ this.persist();
3557
+ return this.toSummary(this.byId(id));
3613
3558
  }
3614
- enable(options = {}) {
3615
- if (this.enabling)
3616
- return this.enabling;
3617
- this.enabling = this.start(options).finally(() => {
3618
- this.enabling = undefined;
3619
- });
3620
- return this.enabling;
3559
+ list() {
3560
+ return this.sorted().map((project) => this.toSummary(project));
3621
3561
  }
3622
- async start(options) {
3623
- if (this.closed)
3624
- return { ready: false, error: "LocalDeck is shutting down." };
3625
- const preference = this.preferenceFile;
3626
- if (this.platform === "win32" && options.install && this.port === 80 && !(options.automatic && readJson(preference, {}).declined)) {
3627
- if (!this.bridge && (await this.status()).ready)
3628
- return { ready: true };
3629
- if (this.bridge) {
3630
- await this.bridge.close();
3631
- this.bridge = undefined;
3632
- }
3633
- try {
3634
- writeJson(preference, { declined: true });
3635
- await this.configureSystem(this.targetPort());
3636
- let ready = false;
3637
- for (let attempt = 0;attempt < 20 && !this.closed; attempt++) {
3638
- if ((await this.status()).ready) {
3639
- ready = true;
3640
- break;
3641
- }
3642
- await new Promise((resolve) => setTimeout(resolve, 250));
3643
- }
3644
- if (!ready)
3645
- throw new Error("Windows setup completed but forwarding could not be verified. Port-number links remain available; retry in Settings.");
3646
- writeJson(preference, { declined: false });
3647
- return { ready: true };
3648
- } catch (error) {
3649
- return { ready: false, error: error instanceof Error ? error.message : "Windows setup failed." };
3650
- }
3562
+ details(projectId) {
3563
+ const project = this.require(projectId);
3564
+ try {
3565
+ const loaded = this.load(project);
3566
+ return { ...project, nickname: this.nickname(projectId), available: true, commands: loaded.commands, ...loaded.profiles ? { profiles: loaded.profiles } : {} };
3567
+ } catch (error) {
3568
+ return { ...project, available: false, error: errorMessage(error), commands: [] };
3651
3569
  }
3652
- if ((await this.status()).ready)
3653
- return { ready: true };
3570
+ }
3571
+ resolve(projectId, serviceName2) {
3572
+ const details = this.details(projectId);
3573
+ if (!details.available)
3574
+ throw new ProjectError("invalid", details.error);
3575
+ const command = details.commands.find((candidate) => candidate.name === serviceName2.toLowerCase());
3576
+ if (!command)
3577
+ throw new ProjectError("not_found", `Project ${projectId} has no service named ${serviceName2}`);
3578
+ return command;
3579
+ }
3580
+ forget(projectId, activeKeys) {
3581
+ this.require(projectId);
3582
+ if ([...activeKeys].some((key) => key.startsWith(`${projectId}:`))) {
3583
+ throw new ProjectError("active", "stop its services before forgetting it");
3584
+ }
3585
+ this.projects = this.projects.filter((project) => project.id !== projectId);
3586
+ this.persist();
3587
+ forgetProjectPorts(projectId, this.portsFile);
3588
+ }
3589
+ refresh(projectId) {
3590
+ return this.details(projectId);
3591
+ }
3592
+ nickname(projectId) {
3593
+ const project = this.byId(projectId);
3594
+ return project?.nickname ?? project?.configNickname;
3595
+ }
3596
+ updateNickname(projectId, value) {
3597
+ const project = this.require(projectId);
3598
+ const nickname = value.trim().toLowerCase();
3599
+ if (nickname && (!validNickname(nickname) || nickname === "standalone")) {
3600
+ throw new ProjectError("invalid", "Use 1–63 letters, numbers or hyphens, with no leading or trailing hyphen. 'standalone' is reserved.");
3601
+ }
3602
+ if (nickname)
3603
+ this.assertNicknameAvailable(projectId, nickname);
3604
+ const previous = project.nickname;
3605
+ if (nickname)
3606
+ project.nickname = nickname;
3607
+ else
3608
+ delete project.nickname;
3654
3609
  try {
3655
- if (this.closed)
3656
- return { ready: false, error: "LocalDeck is shutting down." };
3657
- this.bridge ??= await startForwarder({ listenPort: this.port, targetPort: this.targetPort() });
3658
- if (this.closed) {
3659
- await this.bridge.close();
3660
- this.bridge = undefined;
3661
- return { ready: false, error: "LocalDeck is shutting down." };
3662
- }
3663
- if ((await this.status()).ready)
3664
- return { ready: true };
3665
- return { ready: false, error: "Port 80 does not reach this LocalDeck instance. Your existing links are unchanged." };
3610
+ this.persist();
3666
3611
  } catch (error) {
3667
- const message = error instanceof Error ? error.message : "Could not start port-80 forwarding.";
3668
- const preference2 = this.preferenceFile;
3669
- if (options.install && this.port === 80 && this.platform === "darwin" && message.includes("permission denied") && !(options.automatic && readJson(preference2, {}).declined)) {
3670
- try {
3671
- writeJson(preference2, { declined: true });
3672
- await this.configureSystem(this.targetPort());
3673
- const result = await this.status();
3674
- if (!result.ready)
3675
- throw new Error("Setup completed but forwarding could not be verified. Your original links are unchanged.");
3676
- writeJson(preference2, { declined: false });
3677
- return result;
3678
- } catch (setupError) {
3679
- return { ready: false, error: setupError instanceof Error ? setupError.message : "Administrator setup failed." };
3680
- }
3681
- }
3682
- return { ready: false, error: message };
3612
+ if (previous === undefined)
3613
+ delete project.nickname;
3614
+ else
3615
+ project.nickname = previous;
3616
+ throw error;
3683
3617
  }
3618
+ return this.details(projectId);
3684
3619
  }
3685
- async uninstall() {
3686
- if (this.enabling)
3687
- return { ready: false, error: "Wait for the current setup to finish." };
3620
+ saveConfigNickname(projectId, value) {
3621
+ const project = this.require(projectId);
3622
+ const nickname = value.trim().toLowerCase();
3623
+ if (nickname && (!validNickname(nickname) || nickname === "standalone"))
3624
+ throw new ProjectError("invalid", "Use 1–63 letters, numbers or interior hyphens; standalone is reserved.");
3625
+ if (nickname)
3626
+ this.assertNicknameAvailable(projectId, nickname);
3688
3627
  try {
3689
- if (this.platform === "darwin" || this.platform === "win32" && this.port === 80 && !this.bridge)
3690
- await this.configureSystem(this.targetPort(), true);
3691
- if (this.bridge) {
3692
- await this.bridge.close();
3693
- this.bridge = undefined;
3694
- }
3695
- writeJson(this.preferenceFile, { declined: true });
3696
- return { ready: false, removed: true };
3628
+ updateProjectNickname(project.file, nickname);
3697
3629
  } catch (error) {
3698
- return { ready: false, error: error instanceof Error ? error.message : "Removal failed." };
3630
+ throw new ProjectError("invalid", errorMessage(error));
3699
3631
  }
3632
+ project.configNickname = nickname || undefined;
3633
+ return this.updateNickname(projectId, "");
3700
3634
  }
3701
- async close() {
3702
- this.closed = true;
3703
- await this.enabling;
3704
- if (this.bridge) {
3705
- await this.bridge.close();
3706
- this.bridge = undefined;
3635
+ updateDependencies(projectId, serviceName2, after) {
3636
+ const project = this.require(projectId);
3637
+ try {
3638
+ updateProjectDependencies(project.file, serviceName2, after);
3639
+ return this.details(projectId);
3640
+ } catch (error) {
3641
+ throw new ProjectError("invalid", errorMessage(error));
3707
3642
  }
3708
3643
  }
3709
- }
3710
-
3711
- // ../daemon/src/api.ts
3712
- import fs6 from "node:fs";
3713
- import path7 from "node:path";
3714
-
3715
- // ../daemon/src/request-body.ts
3716
- import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
3717
- var MAX_BODY_CAPTURE_BYTES = 16384;
3718
-
3719
- class BodyCapture {
3720
- limit;
3721
- chunks = [];
3722
- retained = 0;
3723
- bytes = 0;
3724
- tailUsed = 0;
3725
- constructor(limit = MAX_BODY_CAPTURE_BYTES) {
3726
- this.limit = limit;
3644
+ updateServiceConfig(projectId, serviceName2, settings) {
3645
+ const project = this.require(projectId);
3646
+ try {
3647
+ updateProjectServiceConfig(project.file, serviceName2, settings);
3648
+ return this.details(projectId);
3649
+ } catch (error) {
3650
+ throw new ProjectError("invalid", errorMessage(error));
3651
+ }
3727
3652
  }
3728
- append(chunk) {
3729
- this.bytes += chunk.length;
3730
- const count = Math.min(chunk.length, this.limit - this.retained);
3731
- let offset = 0;
3732
- while (offset < count) {
3733
- let tail = this.chunks.at(-1);
3734
- if (!tail || this.tailUsed === tail.length) {
3735
- if (tail && tail.length < 4096) {
3736
- const grown = Buffer.allocUnsafe(Math.min(4096, this.limit - this.retained + this.tailUsed, Math.max(tail.length * 2, count - offset)));
3737
- tail.copy(grown, 0, 0, this.tailUsed);
3738
- this.chunks[this.chunks.length - 1] = tail = grown;
3739
- } else {
3740
- tail = Buffer.allocUnsafe(Math.min(4096, this.limit - this.retained, Math.max(64, count - offset)));
3741
- this.chunks.push(tail);
3742
- this.tailUsed = 0;
3743
- }
3744
- }
3745
- const size = Math.min(count - offset, tail.length - this.tailUsed);
3746
- chunk.copy(tail, this.tailUsed, offset, offset + size);
3747
- this.tailUsed += size;
3748
- this.retained += size;
3749
- offset += size;
3653
+ readCatalog() {
3654
+ if (!fs3.existsSync(this.catalogFile))
3655
+ return [];
3656
+ try {
3657
+ const parsed = JSON.parse(fs3.readFileSync(this.catalogFile, "utf8"));
3658
+ if (!isCatalog(parsed))
3659
+ throw new Error("invalid project catalog");
3660
+ return parsed.projects;
3661
+ } catch {
3662
+ fs3.renameSync(this.catalogFile, this.nextCorruptFile());
3663
+ return [];
3750
3664
  }
3751
3665
  }
3752
- snapshot(complete, contentEncoding) {
3753
- let buffer = Buffer.concat(this.chunks.map((chunk, index) => index === this.chunks.length - 1 ? chunk.subarray(0, this.tailUsed) : chunk), this.retained);
3754
- let encoded = Boolean(contentEncoding && contentEncoding !== "identity");
3755
- const truncated = this.bytes > this.retained;
3756
- if (encoded && complete && !truncated && buffer.length) {
3757
- const decode = { gzip: gunzipSync, deflate: inflateSync, br: brotliDecompressSync }[contentEncoding];
3758
- try {
3759
- if (decode) {
3760
- buffer = decode(buffer, { maxOutputLength: this.limit });
3761
- encoded = false;
3762
- }
3763
- } catch {}
3666
+ assertNicknameAvailable(projectId, nickname) {
3667
+ if (this.projects.some((other) => other.id !== projectId && (other.slug === nickname || (other.nickname ?? other.configNickname) === nickname || !other.sharedConfigNickname && other.configNickname === nickname))) {
3668
+ throw new ProjectError("invalid", "That project nickname is already taken. Choose another.");
3764
3669
  }
3765
- let data;
3766
- let encoding = "utf8";
3670
+ }
3671
+ configAlias(id, file, nickname) {
3672
+ const collisions = this.projects.filter((other) => other.id !== id && (other.slug === nickname || (other.nickname ?? other.configNickname) === nickname || !other.sharedConfigNickname && other.configNickname === nickname));
3673
+ if (!collisions.length)
3674
+ return;
3675
+ const location = repositoryLocation(file);
3676
+ if (!location || collisions.some((other) => {
3677
+ const candidate = repositoryLocation(other.file);
3678
+ return !candidate || candidate.repository !== location.repository || candidate.relativeFile !== location.relativeFile;
3679
+ }))
3680
+ this.assertNicknameAvailable(id, nickname);
3681
+ const existing = this.byId(id)?.nickname;
3682
+ if (existing)
3683
+ return existing;
3684
+ const base = `${nickname.slice(0, 46)}-${id.slice(0, 8)}`;
3685
+ let alias = base;
3686
+ let suffix = 2;
3687
+ while (this.projects.some((other) => other.id !== id && (other.slug === alias || (other.nickname ?? other.configNickname) === alias || !other.sharedConfigNickname && other.configNickname === alias)))
3688
+ alias = `${base}-${suffix++}`;
3689
+ return alias;
3690
+ }
3691
+ nextCorruptFile() {
3692
+ const base = `${this.catalogFile}.corrupt-${this.now()}`;
3693
+ let candidate = base;
3694
+ let suffix = 2;
3695
+ while (fs3.existsSync(candidate))
3696
+ candidate = `${base}-${suffix++}`;
3697
+ return candidate;
3698
+ }
3699
+ persist() {
3700
+ writeJson(this.catalogFile, { version: 1, projects: this.sortedEntries() });
3701
+ }
3702
+ sorted() {
3703
+ return this.sortedEntries().map((project) => ({ ...project }));
3704
+ }
3705
+ sortedEntries() {
3706
+ return [...this.projects].sort((a, b) => b.lastUsedAt - a.lastUsedAt);
3707
+ }
3708
+ toSummary(project) {
3767
3709
  try {
3768
- if (encoded || buffer.includes(0))
3769
- throw new Error("binary body");
3770
- data = new TextDecoder("utf-8", { fatal: true }).decode(buffer);
3771
- } catch {
3772
- data = buffer.toString("base64");
3773
- encoding = "base64";
3710
+ this.load(project);
3711
+ return { ...project, nickname: this.nickname(project.id), available: true };
3712
+ } catch (error) {
3713
+ return { ...project, available: false, error: errorMessage(error) };
3774
3714
  }
3775
- return { data, encoding, bytes: this.bytes, truncated, complete, ...encoded ? { contentEncoding } : {} };
3776
3715
  }
3777
- }
3778
-
3779
- // ../daemon/src/request-headers.ts
3780
- var MAX_HEADER_BYTES = 16384;
3781
- var MAX_HEADER_COUNT = 100;
3782
- function captureHeaders(raw) {
3783
- const entries = [];
3784
- let bytes = 0;
3785
- for (let i = 0;i + 1 < raw.length; i += 2) {
3786
- const name = raw[i];
3787
- const value = raw[i + 1];
3788
- bytes += Buffer.byteLength(name) + Buffer.byteLength(value);
3789
- if (bytes > MAX_HEADER_BYTES || entries.length >= MAX_HEADER_COUNT)
3790
- return { entries, truncated: true };
3791
- entries.push([name, value]);
3716
+ load(project) {
3717
+ if (!fs3.existsSync(project.file))
3718
+ throw new Error(`${project.file} does not exist`);
3719
+ const loaded = loadProjectFile(project.file);
3720
+ if (loaded.nickname) {
3721
+ const alias = this.configAlias(project.id, project.file, loaded.nickname);
3722
+ if (alias) {
3723
+ project.sharedConfigNickname = true;
3724
+ if (!project.nickname)
3725
+ project.nickname = alias;
3726
+ }
3727
+ }
3728
+ project.configNickname = loaded.nickname;
3729
+ return loaded;
3792
3730
  }
3793
- return { entries, truncated: false };
3794
- }
3795
-
3796
- // ../daemon/src/proxy.ts
3797
- import http2 from "node:http";
3798
- import net2 from "node:net";
3799
- var HOP_BY_HOP = new Set(["connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade"]);
3800
- function forwardHeaders(req, target) {
3801
- const out = {};
3802
- for (const [k, v] of Object.entries(req.headers)) {
3803
- if (HOP_BY_HOP.has(k) || v === undefined)
3804
- continue;
3805
- out[k] = v;
3731
+ require(projectId) {
3732
+ const project = this.byId(projectId);
3733
+ if (!project)
3734
+ throw new ProjectError("not_found", `Project ${projectId} was not found`);
3735
+ return project;
3806
3736
  }
3807
- if (target.via === "share") {
3808
- const host = net2.isIP(target.upstreamHost) === 6 ? `[${target.upstreamHost}]` : target.upstreamHost;
3809
- out.host = `${host}:${target.upstreamPort}`;
3737
+ byId(projectId) {
3738
+ return this.projects.find((project) => project.id === projectId);
3739
+ }
3740
+ assignSlug(name, id) {
3741
+ const base = slugify(name) || "project";
3742
+ if (!this.projects.some((project) => project.slug === base || project.nickname === base || project.configNickname === base))
3743
+ return base;
3744
+ const suffix = id.slice(0, 6);
3745
+ let slug = `${base}-${suffix}`;
3746
+ let collision = 2;
3747
+ while (this.projects.some((project) => project.slug === slug || project.nickname === slug || project.configNickname === slug))
3748
+ slug = `${base}-${suffix}-${collision++}`;
3749
+ return slug;
3810
3750
  }
3811
- const remote = req.socket.remoteAddress ?? "";
3812
- out["x-forwarded-for"] = req.headers["x-forwarded-for"] ? `${req.headers["x-forwarded-for"]}, ${remote}` : remote;
3813
- out["x-forwarded-proto"] = target.via === "share" ? "https" : "http";
3814
- out["x-forwarded-host"] = req.headers.host ?? "";
3815
- out["x-localdeck-service"] = target.service;
3816
- return out;
3817
3751
  }
3818
- function waitingPage(target) {
3819
- return `<!doctype html><meta charset="utf-8"><meta http-equiv="refresh" content="2">
3820
- <title>LocalDeck — waiting for ${target.service}</title>
3821
- <style>body{font:15px/1.5 system-ui;margin:0;display:grid;place-items:center;height:100vh;background:#0f1115;color:#e6e8ef}
3822
- .c{max-width:420px;text-align:center}code{background:#1c1f27;padding:2px 6px;border-radius:4px}</style>
3823
- <div class="c"><h1 style="font-weight:600">Waiting for <code>${target.service}</code></h1>
3824
- <p>LocalDeck is holding this port. Nothing is listening on the upstream port <code>${target.upstreamPort}</code> yet.
3825
- This page reloads every 2 seconds.</p></div>`;
3752
+ function projectId(realFile) {
3753
+ return createHash("sha256").update(realFile).digest("hex").slice(0, 16);
3826
3754
  }
3827
- function proxyRequest(req, res, target, hooks) {
3828
- const started = Date.now();
3829
- const requestHeaders = captureHeaders(req.rawHeaders);
3830
- const requestBody = new BodyCapture;
3831
- const responseBody = new BodyCapture;
3832
- const fullResponse = hooks.responseBodies?.start(target.service);
3833
- const captureResponse = (chunk) => {
3834
- responseBody.append(chunk);
3835
- fullResponse?.append(chunk);
3836
- };
3837
- let responseComplete = false;
3838
- let responseEncoding;
3839
- let responseHeaders;
3840
- let bytes = 0;
3841
- let recorded = false;
3842
- const record = (status, error) => {
3843
- if (recorded)
3755
+ function slugify(value) {
3756
+ return value.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
3757
+ }
3758
+ function isCatalog(value) {
3759
+ if (!value || typeof value !== "object")
3760
+ return false;
3761
+ const catalog = value;
3762
+ return catalog.version === 1 && Array.isArray(catalog.projects) && catalog.projects.every(isRememberedProject);
3763
+ }
3764
+ function isRememberedProject(value) {
3765
+ if (!value || typeof value !== "object")
3766
+ return false;
3767
+ const project = value;
3768
+ if (!["id", "name", "slug", "file", "directory"].every((key) => typeof project[key] === "string"))
3769
+ return false;
3770
+ const { id, slug, file, directory } = project;
3771
+ 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") && id === projectId(file) && typeof project.lastUsedAt === "number" && Number.isFinite(project.lastUsedAt);
3772
+ }
3773
+ function validNickname(value) {
3774
+ return value.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value);
3775
+ }
3776
+ function errorMessage(error) {
3777
+ return error instanceof Error ? error.message : String(error);
3778
+ }
3779
+ function gitMetadata(directory, args) {
3780
+ 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" } });
3781
+ }
3782
+ function repositoryLocation(file) {
3783
+ try {
3784
+ const directory = path3.dirname(file);
3785
+ const root = fs3.realpathSync(gitMetadata(directory, ["rev-parse", "--show-toplevel"]).replace(/\r?\n$/, ""));
3786
+ const common = gitMetadata(directory, ["rev-parse", "--git-common-dir"]).replace(/\r?\n$/, "");
3787
+ const repository = fs3.realpathSync(path3.resolve(directory, common));
3788
+ const relativeFile = path3.relative(root, file);
3789
+ if (relativeFile.startsWith("../") || path3.isAbsolute(relativeFile))
3844
3790
  return;
3845
- recorded = true;
3846
- fullResponse?.finish(responseComplete, responseEncoding);
3847
- hooks.onRequest({
3848
- ts: started,
3849
- service: target.service,
3850
- method: req.method ?? "GET",
3851
- path: req.url ?? "/",
3852
- status,
3853
- durationMs: Date.now() - started,
3854
- bytes,
3855
- via: target.via,
3856
- error,
3857
- requestHeaders,
3858
- responseHeaders,
3859
- requestBody: requestBody.snapshot(req.readableEnded, req.headers["content-encoding"]),
3860
- responseBody: responseBody.snapshot(responseComplete, responseEncoding),
3861
- responseBodyId: fullResponse?.id
3862
- });
3863
- };
3864
- if (!target.upstreamReady) {
3865
- responseHeaders = captureHeaders(["content-type", "text/html; charset=utf-8", "retry-after", "2"]);
3866
- res.writeHead(503, { "content-type": "text/html; charset=utf-8", "retry-after": "2" });
3867
- const body = waitingPage(target);
3868
- captureResponse(Buffer.from(body));
3869
- responseComplete = true;
3870
- res.end(body);
3871
- record(503, "upstream not ready");
3791
+ return { root, repository, relativeFile };
3792
+ } catch {
3872
3793
  return;
3873
3794
  }
3874
- let responseStatus = 502;
3875
- let response;
3876
- const upstream = http2.request({ host: target.upstreamHost, port: target.upstreamPort, method: req.method, path: req.url, headers: forwardHeaders(req, target) }, (up) => {
3877
- if (res.destroyed) {
3878
- up.destroy();
3879
- return;
3880
- }
3881
- response = up;
3882
- responseHeaders = captureHeaders(up.rawHeaders);
3883
- responseEncoding = up.headers["content-encoding"];
3884
- up.on("end", () => {
3885
- responseComplete = true;
3795
+ }
3796
+
3797
+ // ../daemon/src/worktrees.ts
3798
+ var sharedResourceNotice = "Worktrees have separate processes and automatic ports. Databases, Docker resources, secrets, and external services may still be shared by configuration.";
3799
+ function discoverWorktrees(projects, projectId2) {
3800
+ const project = projects.details(projectId2);
3801
+ const location = repositoryLocation(project.file);
3802
+ if (!location)
3803
+ return { environments: [], error: "Git repository metadata is unavailable for this project.", sharedResourceNotice };
3804
+ let output;
3805
+ try {
3806
+ output = gitMetadata(location.root, ["worktree", "list", "--porcelain", "-z"]);
3807
+ } catch {
3808
+ return { environments: [], error: "Unable to list Git worktrees.", sharedResourceNotice };
3809
+ }
3810
+ const registered = new Map(projects.list().map((p) => [p.file, p]));
3811
+ const environments = [];
3812
+ for (const record of output.split("\x00\x00")) {
3813
+ const fields = record.split("\x00");
3814
+ const directoryField = fields.find((field) => field.startsWith("worktree "));
3815
+ if (!directoryField)
3816
+ continue;
3817
+ let directory = path4.resolve(directoryField.slice(9));
3818
+ try {
3819
+ directory = fs4.realpathSync(directory);
3820
+ } catch {}
3821
+ const candidateFile = path4.join(directory, location.relativeFile);
3822
+ let configFile = candidateFile;
3823
+ let error;
3824
+ try {
3825
+ const realFile = fs4.realpathSync(candidateFile);
3826
+ const candidate = repositoryLocation(realFile);
3827
+ if (!candidate || candidate.repository !== location.repository || candidate.relativeFile !== location.relativeFile || candidate.root !== directory)
3828
+ throw new Error("Configuration resolves outside this worktree.");
3829
+ configFile = realFile;
3830
+ loadProjectFile(configFile);
3831
+ } catch (cause) {
3832
+ error = cause instanceof Error ? cause.message : String(cause);
3833
+ }
3834
+ const known = registered.get(configFile);
3835
+ const branch = fields.find((field) => field.startsWith("branch "))?.slice(7).replace(/^refs\/heads\//, "");
3836
+ environments.push({
3837
+ id: createHash2("sha256").update(configFile).digest("hex").slice(0, 16),
3838
+ ...known ? { projectId: known.id } : {},
3839
+ directory,
3840
+ configFile,
3841
+ ...branch ? { branch } : {},
3842
+ head: fields.find((field) => field.startsWith("HEAD "))?.slice(5),
3843
+ detached: fields.includes("detached"),
3844
+ available: !error,
3845
+ registered: !!known,
3846
+ current: configFile === project.file,
3847
+ ...error ? { error } : {}
3886
3848
  });
3887
- responseStatus = up.statusCode ?? 502;
3888
- up.on("error", (error) => {
3889
- record(502, `upstream response error: ${error.message}`);
3890
- res.destroy();
3891
- });
3892
- up.on("aborted", () => {
3893
- record(502, "upstream response aborted");
3894
- res.destroy();
3849
+ }
3850
+ return { environments, repository: location.repository, relativeConfig: location.relativeFile, sharedResourceNotice };
3851
+ }
3852
+ function registerWorktree(projects, projectId2, environmentId) {
3853
+ const discovered = discoverWorktrees(projects, projectId2);
3854
+ const environment = discovered.environments.find((candidate) => candidate.id === environmentId);
3855
+ if (!environment)
3856
+ throw new ProjectError("not_found", "This environment is not a discovered worktree of the selected project.");
3857
+ if (!environment.available)
3858
+ throw new ProjectError("invalid", environment.error ?? "This environment is unavailable.");
3859
+ return projects.remember(environment.configFile);
3860
+ }
3861
+
3862
+ // ../daemon/src/reproductions.ts
3863
+ import path5 from "node:path";
3864
+ import fs5 from "node:fs";
3865
+ import { randomUUID } from "node:crypto";
3866
+
3867
+ // ../daemon/src/replay.ts
3868
+ import http from "node:http";
3869
+ function replayRequest(service, record, options = {}) {
3870
+ if (record.service !== service.key || !["GET", "HEAD"].includes(record.method))
3871
+ throw new Error("Only recorded GET and HEAD requests for this service can be replayed");
3872
+ if (service.status !== "running" || !service.upstreamReady || service.serviceType && service.serviceType !== "http")
3873
+ throw new Error("The HTTP service must be running before replay");
3874
+ if (!["127.0.0.1", "::1"].includes(service.upstreamHost) || !record.path.startsWith("/") || record.path.startsWith("//") || /[\r\n\\\x00-\x20\x7f]/.test(record.path))
3875
+ throw new Error("Request path must be a local HTTP path");
3876
+ const timeoutMs = options.timeoutMs ?? 5000;
3877
+ const maxBytes = options.maxBytes ?? 65536;
3878
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 5000 || !Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > 65536)
3879
+ throw new Error("Replay limits must be positive integers within 5000 ms and 65536 bytes");
3880
+ return new Promise((resolve, reject) => {
3881
+ const started = Date.now();
3882
+ let settled = false;
3883
+ let bytes = 0;
3884
+ const chunks = [];
3885
+ const request = http.request({ host: service.upstreamHost, port: service.upstreamPort, path: record.path, method: record.method, headers: { "x-localdeck-replay": "1" } });
3886
+ const timer = setTimeout(() => {
3887
+ if (!settled) {
3888
+ settled = true;
3889
+ reject(new Error("Replay timed out"));
3890
+ }
3891
+ request.destroy();
3892
+ }, timeoutMs);
3893
+ const finish = (status, truncated) => {
3894
+ if (settled)
3895
+ return;
3896
+ settled = true;
3897
+ clearTimeout(timer);
3898
+ resolve({ status, truncated, body: Buffer.concat(chunks).toString("utf8"), durationMs: Date.now() - started });
3899
+ };
3900
+ request.on("response", (response) => {
3901
+ response.on("data", (chunk) => {
3902
+ const remaining = Math.max(0, maxBytes - bytes);
3903
+ chunks.push(chunk.subarray(0, remaining));
3904
+ bytes += chunk.length;
3905
+ if (bytes > maxBytes) {
3906
+ finish(response.statusCode ?? 502, true);
3907
+ response.destroy();
3908
+ request.destroy();
3909
+ }
3910
+ });
3911
+ response.on("end", () => finish(response.statusCode ?? 502, false));
3912
+ response.on("error", (error) => request.destroy(error));
3913
+ response.on("aborted", () => request.destroy(new Error("Replay response was interrupted")));
3895
3914
  });
3896
- const headers = { ...up.headers };
3897
- res.writeHead(up.statusCode ?? 502, up.statusMessage, headers);
3898
- up.on("data", (chunk) => {
3899
- bytes += chunk.length;
3900
- captureResponse(chunk);
3915
+ request.on("error", (error) => {
3916
+ clearTimeout(timer);
3917
+ if (!settled) {
3918
+ settled = true;
3919
+ reject(error);
3920
+ }
3901
3921
  });
3902
- up.pipe(res);
3922
+ request.end();
3903
3923
  });
3904
- upstream.on("error", (err) => {
3905
- if (!res.headersSent)
3906
- responseHeaders = captureHeaders(["content-type", "text/plain; charset=utf-8"]);
3907
- if (!res.headersSent && !res.destroyed) {
3908
- res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
3909
- const body = `LocalDeck: upstream ${target.service} (${target.upstreamHost}:${target.upstreamPort}) error: ${err.code ?? err.message}`;
3910
- captureResponse(Buffer.from(body));
3911
- responseComplete = true;
3912
- res.end(body);
3924
+ }
3925
+
3926
+ // ../daemon/src/reproductions.ts
3927
+ var sensitive = /authorization|cookie|token|secret|password|passwd|api[-_]?key|credential|session|signature/i;
3928
+ var quote = (value) => "'" + value.replace(/'/g, "'\\''") + "'";
3929
+ function validPath(value) {
3930
+ return typeof value === "string" && value.length <= 8192 && value.startsWith("/") && !value.startsWith("//") && !/[\r\n\\\x00-\x20\x7f#]/.test(value);
3931
+ }
3932
+ function redact(value, secrets) {
3933
+ for (const secret of secrets.filter(Boolean))
3934
+ value = value.split(secret).join("[REDACTED]");
3935
+ return value;
3936
+ }
3937
+ function sanitizeRequest(record, baseUrl, secrets = []) {
3938
+ if (!validPath(record.path) || !/^[A-Z]+$/.test(record.method))
3939
+ throw new Error("Request must have a valid local HTTP path and method");
3940
+ const base = new URL(baseUrl);
3941
+ if (!["http:", "https:"].includes(base.protocol) || base.username || base.password)
3942
+ throw new Error("Export requires an HTTP base URL without credentials");
3943
+ const omitted = [];
3944
+ const [pathname, query] = record.path.split(/\?(.*)/s);
3945
+ let safePath = redact(pathname, secrets.flatMap((secret) => [secret, encodeURIComponent(secret)]));
3946
+ if (safePath === pathname && secrets.some((secret) => secret && decodeURIComponentSafe(pathname).includes(secret)))
3947
+ safePath = "/[REDACTED]";
3948
+ if (query !== undefined) {
3949
+ let changed = false;
3950
+ const params = new URLSearchParams(query);
3951
+ const sanitized = new URLSearchParams;
3952
+ for (const [key, value] of [...params]) {
3953
+ const safe = sensitive.test(key) ? "[REDACTED]" : redact(value, secrets);
3954
+ const safeKey = redact(key, secrets);
3955
+ sanitized.append(safeKey, safe);
3956
+ if (safe !== value || safeKey !== key) {
3957
+ changed = true;
3958
+ omitted.push("Sensitive query values redacted");
3959
+ }
3960
+ }
3961
+ safePath += "?" + (changed ? sanitized.toString() : query);
3962
+ }
3963
+ if (safePath.split("?")[0] !== pathname)
3964
+ omitted.push("Secret in path redacted");
3965
+ const headers = [];
3966
+ for (const [key, value] of record.requestHeaders?.entries ?? []) {
3967
+ if (!["accept", "content-type"].includes(key.toLowerCase()) || sensitive.test(value) || redact(value, secrets) !== value || /[\r\n]/.test(value)) {
3968
+ omitted.push(`Header ${sensitive.test(key) ? "credential" : "non-representation"} omitted`);
3969
+ continue;
3970
+ }
3971
+ headers.push([key, value]);
3972
+ }
3973
+ if (record.requestHeaders?.truncated)
3974
+ omitted.push("Header capture incomplete");
3975
+ let body;
3976
+ const captured = record.requestBody;
3977
+ const hasBody = !!captured && (captured.bytes > 0 || captured.data.length > 0 || !captured.complete || captured.truncated);
3978
+ if (captured && hasBody) {
3979
+ const contentType = headers.find(([key]) => key.toLowerCase() === "content-type")?.[1] ?? "";
3980
+ if (!captured.complete || captured.truncated || captured.encoding !== "utf8" || captured.contentEncoding && captured.contentEncoding !== "identity")
3981
+ omitted.push("Incomplete, binary or encoded body omitted");
3982
+ else if (/application\/json/i.test(contentType)) {
3983
+ try {
3984
+ body = JSON.stringify(JSON.parse(captured.data), (key, value) => sensitive.test(key) ? "[REDACTED]" : typeof value === "string" ? redact(value, secrets) : value);
3985
+ } catch {
3986
+ omitted.push("Invalid JSON body omitted");
3987
+ }
3988
+ } else if (/application\/x-www-form-urlencoded/i.test(contentType)) {
3989
+ const params = new URLSearchParams(captured.data);
3990
+ for (const [key, value] of [...params])
3991
+ params.set(key, sensitive.test(key) ? "[REDACTED]" : redact(value, secrets));
3992
+ body = params.toString();
3913
3993
  } else
3914
- res.destroy();
3915
- record(502, err.code ?? err.message);
3916
- });
3917
- const clientDisconnected = () => {
3918
- record(499, "client disconnected before response completed");
3919
- response?.destroy();
3920
- upstream.destroy();
3921
- };
3922
- req.on("aborted", clientDisconnected);
3923
- res.on("finish", () => record(responseStatus));
3924
- res.on("close", () => {
3925
- if (!res.writableFinished)
3926
- clientDisconnected();
3927
- });
3928
- req.on("data", (chunk) => requestBody.append(chunk));
3929
- req.pipe(upstream);
3994
+ omitted.push("Unsupported body format omitted");
3995
+ if (body !== undefined && body !== captured.data)
3996
+ omitted.push("Body sanitized");
3997
+ }
3998
+ const request = { method: record.method, url: base.origin + safePath, headers, ...body === undefined ? {} : { body } };
3999
+ 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(" ");
4000
+ const executable = ["GET", "HEAD"].includes(record.method) && !hasBody && !omitted.some((item) => item.includes("query") || item.includes("path"));
4001
+ return { request, curl, omitted: [...new Set(omitted)], executable };
3930
4002
  }
3931
- function proxyUpgrade(req, socket, head, target, hooks) {
3932
- const started = Date.now();
3933
- if (!target.upstreamReady) {
3934
- socket.end(`HTTP/1.1 503 Service Unavailable\r
3935
- Connection: close\r
3936
- \r
3937
- `);
3938
- return;
4003
+ function decodeURIComponentSafe(value) {
4004
+ try {
4005
+ return decodeURIComponent(value);
4006
+ } catch {
4007
+ return value;
3939
4008
  }
3940
- const up = net2.connect(target.upstreamPort, target.upstreamHost, () => {
3941
- const headers = forwardHeaders(req, target);
3942
- headers["connection"] = "Upgrade";
3943
- headers["upgrade"] = req.headers["upgrade"];
3944
- const lines = [`${req.method} ${req.url} HTTP/1.1`];
3945
- for (const [k, v] of Object.entries(headers)) {
3946
- if (v === undefined)
3947
- continue;
3948
- for (const val of Array.isArray(v) ? v : [String(v)])
3949
- lines.push(`${k}: ${val}`);
3950
- }
3951
- up.write(lines.join(`\r
3952
- `) + `\r
3953
- \r
3954
- `);
3955
- if (head.length)
3956
- up.write(head);
3957
- up.pipe(socket).pipe(up);
3958
- hooks.onRequest({
3959
- ts: started,
3960
- service: target.service,
3961
- method: req.method ?? "GET",
3962
- path: req.url ?? "/",
3963
- status: 101,
3964
- durationMs: Date.now() - started,
3965
- bytes: 0,
3966
- requestHeaders: captureHeaders(req.rawHeaders),
3967
- via: target.via
3968
- });
3969
- });
3970
- const kill = () => {
3971
- up.destroy();
3972
- socket.destroy();
3973
- };
3974
- up.on("error", kill);
3975
- socket.on("error", kill);
3976
- socket.on("close", () => up.destroy());
3977
- up.on("close", () => socket.destroy());
3978
4009
  }
3979
-
3980
- // ../daemon/src/projects.ts
3981
- import { createHash } from "node:crypto";
3982
- import fs3 from "node:fs";
3983
- import path4 from "node:path";
3984
- class ProjectError extends Error {
3985
- code;
3986
- constructor(code, message) {
3987
- super(message);
3988
- this.code = code;
4010
+ function loadChecks(file) {
4011
+ try {
4012
+ if (fs5.statSync(file).size > 2000000)
4013
+ return [];
4014
+ const raw = JSON.parse(fs5.readFileSync(file, "utf8"));
4015
+ if (!Array.isArray(raw))
4016
+ return [];
4017
+ const seen = new Set;
4018
+ 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) => {
4019
+ if (seen.has(item.id))
4020
+ return false;
4021
+ seen.add(item.id);
4022
+ return true;
4023
+ }).map((item) => ({
4024
+ id: item.id,
4025
+ projectId: item.projectId,
4026
+ serviceKey: item.serviceKey,
4027
+ name: item.name,
4028
+ method: item.method,
4029
+ path: item.path,
4030
+ expectedStatus: item.expectedStatus,
4031
+ createdAt: item.createdAt,
4032
+ 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 }))
4033
+ }));
4034
+ } catch {
4035
+ return [];
3989
4036
  }
3990
4037
  }
3991
4038
 
3992
- class ProjectRegistry {
3993
- catalogFile;
3994
- now;
3995
- projects;
3996
- constructor({ catalogFile = PROJECTS_FILE, now = Date.now } = {}) {
3997
- this.catalogFile = catalogFile;
3998
- this.now = now;
3999
- this.projects = this.readCatalog();
4000
- }
4001
- remember(file, now = this.now()) {
4002
- const realFile = fs3.realpathSync(file);
4003
- const config = loadProjectFile(realFile);
4004
- const id = projectId(realFile);
4005
- const name = path4.basename(path4.dirname(realFile));
4006
- const directory = path4.dirname(realFile);
4007
- const existing = this.projects.find((entry) => entry.id === id);
4008
- if (config.nickname)
4009
- this.assertNicknameAvailable(id, config.nickname);
4010
- if (existing) {
4011
- existing.name = name;
4012
- existing.file = realFile;
4013
- existing.directory = directory;
4014
- existing.lastUsedAt = now;
4015
- existing.configNickname = config.nickname;
4016
- } else {
4017
- this.projects.push({
4018
- id,
4019
- name,
4020
- slug: this.assignSlug(name, id),
4021
- file: realFile,
4022
- directory,
4023
- lastUsedAt: now,
4024
- ...config.nickname ? { configNickname: config.nickname } : {}
4025
- });
4026
- }
4027
- this.persist();
4028
- return this.toSummary(this.byId(id));
4039
+ class Reproductions {
4040
+ file;
4041
+ items;
4042
+ active = new Set;
4043
+ constructor(options = {}) {
4044
+ this.file = options.file ?? path5.join(HOME_DIR, "reproductions.json");
4045
+ this.items = loadChecks(this.file);
4029
4046
  }
4030
- list() {
4031
- return this.sorted().map((project) => this.toSummary(project));
4047
+ list(projectId2) {
4048
+ return structuredClone(this.items.filter((item) => item.projectId === projectId2));
4032
4049
  }
4033
- details(projectId) {
4034
- const project = this.require(projectId);
4050
+ persist() {
4051
+ const retained = [...this.items];
4052
+ while (retained.length > 1 && Buffer.byteLength(JSON.stringify(retained, null, 2)) > 2000000)
4053
+ retained.shift();
4054
+ writePrivateJson(this.file, retained);
4055
+ this.items = retained;
4056
+ }
4057
+ save(projectId2, record, input, secrets = []) {
4058
+ if (!projectId2 || projectId2.length > 200 || record.service.length > 400 || !record.service.startsWith(projectId2 + ":"))
4059
+ throw new Error("Request belongs to another project");
4060
+ if (typeof input.name !== "string" || !input.name.trim() || input.name.length > 120)
4061
+ throw new Error("Check name must contain 1–120 characters");
4062
+ if (!Number.isInteger(input.expectedStatus) || input.expectedStatus < 100 || input.expectedStatus > 599)
4063
+ throw new Error("Choose an expected HTTP status from 100 to 599");
4064
+ if (!sanitizeRequest(record, "http://localhost", secrets).executable || secrets.some((secret) => secret && decodeURIComponentSafe(record.path).includes(secret)))
4065
+ throw new Error("Checks require an unchanged GET/HEAD path without secret query values or a request body");
4066
+ 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: [] };
4067
+ const before = this.items;
4068
+ this.items = [...this.items, item].slice(-200);
4035
4069
  try {
4036
- const loaded = this.load(project);
4037
- return { ...project, nickname: this.nickname(projectId), available: true, commands: loaded.commands, ...loaded.profiles ? { profiles: loaded.profiles } : {} };
4070
+ this.persist();
4038
4071
  } catch (error) {
4039
- return { ...project, available: false, error: errorMessage(error), commands: [] };
4072
+ this.items = before;
4073
+ throw error;
4040
4074
  }
4041
- }
4042
- resolve(projectId, serviceName) {
4043
- const details = this.details(projectId);
4044
- if (!details.available)
4045
- throw new ProjectError("invalid", details.error);
4046
- const command = details.commands.find((candidate) => candidate.name === serviceName.toLowerCase());
4047
- if (!command)
4048
- throw new ProjectError("not_found", `Project ${projectId} has no service named ${serviceName}`);
4049
- return command;
4050
- }
4051
- forget(projectId, activeKeys) {
4052
- this.require(projectId);
4053
- if ([...activeKeys].some((key) => key.startsWith(`${projectId}:`))) {
4054
- throw new ProjectError("active", "stop its services before forgetting it");
4075
+ return structuredClone(item);
4076
+ }
4077
+ async run(projectId2, id, service) {
4078
+ const item = this.items.find((item2) => item2.id === id && item2.projectId === projectId2);
4079
+ if (!item || service.projectId !== projectId2 || service.key !== item.serviceKey)
4080
+ throw new Error("Check not found in the selected service and environment");
4081
+ 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)
4082
+ throw new Error("Saved check is invalid");
4083
+ if (this.active.has(id))
4084
+ throw new Error("This check is already running");
4085
+ this.active.add(id);
4086
+ try {
4087
+ const ts = Date.now();
4088
+ const result = { id: randomUUID(), projectId: projectId2, serviceKey: service.key, ts, expectedStatus: item.expectedStatus, durationMs: 0, result: "transport-error" };
4089
+ try {
4090
+ 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" });
4091
+ result.actualStatus = response.status;
4092
+ result.durationMs = response.durationMs;
4093
+ result.result = response.status === item.expectedStatus ? "passed" : "status-mismatch";
4094
+ } catch {
4095
+ result.durationMs = Date.now() - ts;
4096
+ result.error = "Could not complete the request. Check that the local HTTP service is running and ready.";
4097
+ }
4098
+ const previous = item.runs;
4099
+ item.runs = [...item.runs, result].slice(-20);
4100
+ try {
4101
+ this.persist();
4102
+ } catch (error) {
4103
+ item.runs = previous;
4104
+ throw error;
4105
+ }
4106
+ return structuredClone(result);
4107
+ } finally {
4108
+ this.active.delete(id);
4055
4109
  }
4056
- this.projects = this.projects.filter((project) => project.id !== projectId);
4057
- this.persist();
4058
4110
  }
4059
- refresh(projectId) {
4060
- return this.details(projectId);
4061
- }
4062
- nickname(projectId) {
4063
- const project = this.byId(projectId);
4064
- return project?.nickname ?? project?.configNickname;
4111
+ delete(projectId2, id) {
4112
+ if (this.active.has(id))
4113
+ throw new Error("Wait for the check to finish before deleting it");
4114
+ const previous = this.items;
4115
+ this.items = this.items.filter((item) => !(item.id === id && item.projectId === projectId2));
4116
+ if (previous.length === this.items.length)
4117
+ return false;
4118
+ try {
4119
+ this.persist();
4120
+ } catch (error) {
4121
+ this.items = previous;
4122
+ throw error;
4123
+ }
4124
+ return true;
4065
4125
  }
4066
- updateNickname(projectId, value) {
4067
- const project = this.require(projectId);
4068
- const nickname = value.trim().toLowerCase();
4069
- if (nickname && (!validNickname(nickname) || nickname === "standalone")) {
4070
- throw new ProjectError("invalid", "Use 1–63 letters, numbers or hyphens, with no leading or trailing hyphen. 'standalone' is reserved.");
4126
+ }
4127
+
4128
+ // ../daemon/src/diagnostics.ts
4129
+ import fs6 from "node:fs";
4130
+ import os2 from "node:os";
4131
+ import path6 from "node:path";
4132
+ function redactDiagnostic(text, secrets = []) {
4133
+ 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]");
4134
+ for (const secret of [...new Set(secrets)].filter(Boolean).sort((a, b) => b.length - a.length))
4135
+ output = output.replaceAll(secret, "[REDACTED]");
4136
+ const home = os2.homedir();
4137
+ if (home.length > 1)
4138
+ output = output.replaceAll(home, "<home>");
4139
+ return output;
4140
+ }
4141
+ function privateEnvironmentValue(name, value) {
4142
+ if (name === "NODE_ENV" && /^(development|production|test)$/.test(value))
4143
+ return false;
4144
+ if (/^(?:PORT|[A-Z_]+_PORT)$/.test(name) && /^\d{1,5}$/.test(value) && Number(value) > 0 && Number(value) <= 65535)
4145
+ return false;
4146
+ return true;
4147
+ }
4148
+ function environmentValues(directory) {
4149
+ const values = [];
4150
+ for (const name of [".env", ".env.local", ".env.development", ".env.development.local"]) {
4151
+ const file = path6.join(directory, name);
4152
+ try {
4153
+ const stat = fs6.lstatSync(file);
4154
+ if (!stat.isFile() || stat.size > 65536)
4155
+ continue;
4156
+ const text = fs6.readFileSync(file, "utf8");
4157
+ for (const line of text.split(/\r?\n/)) {
4158
+ const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][\w]*)\s*=\s*(.*)$/);
4159
+ if (match) {
4160
+ const value = match[2].trim().replace(/^(['"])(.*)\1$/, "$2");
4161
+ if (privateEnvironmentValue(match[1], value))
4162
+ values.push(value);
4163
+ }
4164
+ }
4165
+ } catch {}
4166
+ }
4167
+ return values;
4168
+ }
4169
+ function diagnosticReport(project, services, logs, version, privateValues = []) {
4170
+ const inheritedSecrets = Object.entries(process.env).filter(([name]) => /password|passwd|secret|token|api[_-]?key|credential/i.test(name)).flatMap(([, value]) => value ? [value] : []);
4171
+ const directories = new Set([project.directory, ...project.commands.map((command) => path6.resolve(project.directory, command.cwd ?? "."))]);
4172
+ const secrets = [...privateValues, ...inheritedSecrets, ...[...directories].flatMap(environmentValues), ...project.commands.flatMap((command) => Object.entries(command.env ?? {}).filter(([name, value]) => privateEnvironmentValue(name, value)).map(([, value]) => value))];
4173
+ const report = {
4174
+ notice: "Review before sharing. Known secrets are redacted, but application logs may contain other private data.",
4175
+ version,
4176
+ project: { name: project.name, available: project.available, error: project.error },
4177
+ configuration: project.commands.map(({ env, ...command }) => ({ ...command, environmentVariables: Object.keys(env ?? {}) })),
4178
+ 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 })),
4179
+ logs: logs.slice(-100).map(({ stream, line, ts, service }) => ({ service, ts, stream, line: line.slice(0, 2000) }))
4180
+ };
4181
+ 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;
4182
+ return JSON.stringify(sanitize(report), null, 2);
4183
+ }
4184
+
4185
+ // ../daemon/src/project-secrets.ts
4186
+ import fs7 from "node:fs";
4187
+ import path7 from "node:path";
4188
+ var ID = /^[a-f0-9]{16}$/;
4189
+ var NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
4190
+
4191
+ class ProjectSecretError extends Error {
4192
+ }
4193
+ function validateName(name) {
4194
+ if (!NAME.test(name) || name === "PORT" || name.startsWith("LOCALDECK_")) {
4195
+ throw new ProjectSecretError("invalid secret name");
4196
+ }
4197
+ }
4198
+ function createProjectSecretStore(home = HOME_DIR) {
4199
+ const directory = path7.join(home, "secrets");
4200
+ function secretPath(projectId2) {
4201
+ if (!ID.test(projectId2))
4202
+ throw new ProjectSecretError("invalid project ID");
4203
+ return path7.join(directory, `${projectId2}.json`);
4204
+ }
4205
+ function read(projectId2) {
4206
+ const file = secretPath(projectId2);
4207
+ let text;
4208
+ try {
4209
+ text = fs7.readFileSync(file, "utf8");
4210
+ } catch (error) {
4211
+ if (error.code === "ENOENT")
4212
+ return Object.create(null);
4213
+ throw new ProjectSecretError("Could not read stored secrets");
4214
+ }
4215
+ let parsed;
4216
+ try {
4217
+ parsed = JSON.parse(text);
4218
+ } catch {
4219
+ throw new ProjectSecretError("stored secrets are corrupt");
4220
+ }
4221
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
4222
+ throw new ProjectSecretError("stored secrets are corrupt");
4223
+ }
4224
+ const values = Object.create(null);
4225
+ for (const [name, value] of Object.entries(parsed)) {
4226
+ validateName(name);
4227
+ if (typeof value !== "string" || !value || value.includes("\x00")) {
4228
+ throw new ProjectSecretError("stored secrets are corrupt");
4229
+ }
4230
+ values[name] = value;
4231
+ }
4232
+ return values;
4233
+ }
4234
+ function write(projectId2, values) {
4235
+ const file = secretPath(projectId2);
4236
+ fs7.mkdirSync(directory, { recursive: true, mode: 448 });
4237
+ if (process.platform !== "win32")
4238
+ fs7.chmodSync(directory, 448);
4239
+ writePrivateJson(file, values);
4240
+ }
4241
+ function listProjectSecrets(projectId2) {
4242
+ return Object.keys(read(projectId2)).sort();
4243
+ }
4244
+ function setProjectSecret(projectId2, name, value) {
4245
+ validateName(name);
4246
+ if (typeof value !== "string" || !value || value.includes("\x00")) {
4247
+ throw new ProjectSecretError("secret value must be a nonempty string without NUL");
4248
+ }
4249
+ const values = read(projectId2);
4250
+ values[name] = value;
4251
+ write(projectId2, values);
4252
+ }
4253
+ function deleteProjectSecret(projectId2, name) {
4254
+ validateName(name);
4255
+ const values = read(projectId2);
4256
+ delete values[name];
4257
+ write(projectId2, values);
4258
+ }
4259
+ function resolveProjectSecrets(projectId2, names) {
4260
+ const output = Object.create(null);
4261
+ if (!names.length)
4262
+ return output;
4263
+ const values = read(projectId2);
4264
+ for (const name of names) {
4265
+ validateName(name);
4266
+ if (!Object.prototype.hasOwnProperty.call(values, name)) {
4267
+ throw new ProjectSecretError(`Add ${name} in Project → Secrets.`);
4268
+ }
4269
+ output[name] = values[name];
4270
+ }
4271
+ return output;
4272
+ }
4273
+ return { listProjectSecrets, setProjectSecret, deleteProjectSecret, resolveProjectSecrets };
4274
+ }
4275
+ var { listProjectSecrets, setProjectSecret, deleteProjectSecret, resolveProjectSecrets } = createProjectSecretStore();
4276
+
4277
+ // ../daemon/src/debugging-api.ts
4278
+ var checksByDeps = new WeakMap;
4279
+ function checks(deps) {
4280
+ let value = deps.reproductions ?? checksByDeps.get(deps);
4281
+ if (!value) {
4282
+ value = new Reproductions;
4283
+ checksByDeps.set(deps, value);
4284
+ }
4285
+ return value;
4286
+ }
4287
+ function secrets(deps, projectId2) {
4288
+ const p = deps.projects.details(projectId2);
4289
+ const names = (deps.listProjectSecrets ?? listProjectSecrets)(projectId2);
4290
+ 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 ?? "."))])];
4291
+ }
4292
+ async function body(req, allowed) {
4293
+ const value = await readBody(req);
4294
+ if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some((k) => !allowed.includes(k)))
4295
+ throw new HttpError(400, "Unexpected request fields");
4296
+ return value;
4297
+ }
4298
+ async function handleDebuggingApi(req, res, url, parts, deps) {
4299
+ const method = req.method ?? "GET";
4300
+ const projectRoute = parts[0] === "projects" && parts.length >= 3 && ["activity", "baselines", "checks", "environments"].includes(parts[2]);
4301
+ const exportRoute = parts[0] === "services" && parts.length === 3 && parts[2] === "reproduction";
4302
+ if (!projectRoute && !exportRoute)
4303
+ return false;
4304
+ const send = (status, value) => {
4305
+ res.setHeader("cache-control", "no-store");
4306
+ json(res, status, value);
4307
+ return true;
4308
+ };
4309
+ try {
4310
+ if (method !== "GET")
4311
+ requireMutationAllowed(deps);
4312
+ if (exportRoute && method === "GET") {
4313
+ const key = parts[1], projectId3 = key.split(":")[0];
4314
+ const project2 = deps.projects.details(projectId3);
4315
+ if (!project2.commands.some((c) => `${projectId3}:${c.name}` === key) && !deps.registry.get(key))
4316
+ throw new HttpError(404, "Unknown service");
4317
+ const record = deps.store.requests(key, 5000).find((r) => r.id === url.searchParams.get("requestId"));
4318
+ if (!record)
4319
+ throw new HttpError(404, "Recorded request is no longer available");
4320
+ const service = deps.registry.get(key);
4321
+ if (!service)
4322
+ throw new HttpError(422, "Start this service before exporting so its current local address is available");
4323
+ const base = `http://localhost:${service.stablePort}`;
4324
+ return send(200, sanitizeRequest(record, base, secrets(deps, projectId3)));
4325
+ }
4326
+ if (!projectRoute)
4327
+ return send(405, { error: "Method not allowed" });
4328
+ const projectId2 = parts[1], feature = parts[2], project = deps.projects.details(projectId2);
4329
+ const allowed = [...new Set([...project.commands.map((c) => `${projectId2}:${c.name}`), ...deps.registry.list(projectId2).filter((s) => s.projectId === projectId2).map((s) => s.key)])];
4330
+ const selectServices = (value) => {
4331
+ const values = value === undefined ? allowed : value;
4332
+ if (!Array.isArray(values) || !values.length || values.length > 100 || values.some((v) => typeof v !== "string" || !allowed.includes(v)))
4333
+ throw new HttpError(422, "Select 1–100 configured or registered services in this project");
4334
+ return [...new Set(values)];
4335
+ };
4336
+ if (feature === "activity" && parts.length === 3 && method === "GET") {
4337
+ const raw = url.searchParams.get("services");
4338
+ if (!allowed.length && !raw)
4339
+ return send(200, { items: [], coverage: { partial: false, reasons: [] } });
4340
+ 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 };
4341
+ return send(200, deps.store.activity.timeline(projectId2, selectServices(raw === null ? undefined : raw.split(",")), options));
4342
+ }
4343
+ if (feature === "activity" && parts.length === 5 && parts[4] === "evidence" && method === "GET") {
4344
+ const event = deps.store.activity.timeline(projectId2, selectServices(undefined), { eventId: parts[3], limit: 1 }).items[0];
4345
+ if (!event)
4346
+ throw new HttpError(404, "This event has expired or was cleared");
4347
+ const request = event.kind === "request" ? deps.store.requests(event.service, 5000).find((r) => r.id === event.id) : undefined;
4348
+ const log = event.kind === "log" ? deps.store.logs(event.service, 2000).find((r) => r.id === event.id) : undefined;
4349
+ return send(200, { event, request, log, service: deps.registry.get(event.service) });
4350
+ }
4351
+ if (feature === "baselines") {
4352
+ if (parts.length === 3 && method === "GET")
4353
+ return send(200, { items: deps.store.activity.markers(projectId2) });
4354
+ if (parts.length === 3 && method === "POST") {
4355
+ const input = await body(req, ["services", "label"]);
4356
+ requireMutationAllowed(deps);
4357
+ if (input.label !== undefined && typeof input.label !== "string")
4358
+ throw new HttpError(422, "Baseline label must be text");
4359
+ let git;
4360
+ try {
4361
+ git = { head: gitMetadata(project.directory, ["rev-parse", "HEAD"]).trim() };
4362
+ try {
4363
+ git.branch = gitMetadata(project.directory, ["symbolic-ref", "--short", "HEAD"]).trim();
4364
+ } catch {}
4365
+ } catch {}
4366
+ return send(201, deps.store.activity.mark(projectId2, selectServices(input.services), { label: input.label, git, secrets: secrets(deps, projectId2) }));
4367
+ }
4368
+ if (parts.length === 4 && method === "GET")
4369
+ return send(200, deps.store.activity.compare(projectId2, parts[3], Date.now(), secrets(deps, projectId2)));
4370
+ }
4371
+ if (feature === "environments") {
4372
+ if (parts.length === 3 && method === "GET") {
4373
+ const discovery = discoverWorktrees(deps.projects, projectId2);
4374
+ 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 })) });
4375
+ }
4376
+ if (parts.length === 5 && parts[4] === "register" && method === "POST") {
4377
+ await body(req, []);
4378
+ requireMutationAllowed(deps);
4379
+ return send(201, registerWorktree(deps.projects, projectId2, parts[3]));
4380
+ }
4381
+ }
4382
+ if (feature === "checks") {
4383
+ const repository = checks(deps);
4384
+ if (parts.length === 3 && method === "GET")
4385
+ return send(200, { items: repository.list(projectId2) });
4386
+ if (parts.length === 3 && method === "POST") {
4387
+ const input = await body(req, ["requestId", "serviceKey", "name", "expectedStatus"]);
4388
+ requireMutationAllowed(deps);
4389
+ const [key] = selectServices([input.serviceKey]);
4390
+ if (typeof input.requestId !== "string")
4391
+ throw new HttpError(422, "A recorded request ID is required");
4392
+ const record = deps.store.requests(key, 5000).find((r) => r.id === input.requestId);
4393
+ if (!record)
4394
+ throw new HttpError(404, "Recorded request is no longer available");
4395
+ return send(201, repository.save(projectId2, record, { name: input.name, expectedStatus: input.expectedStatus }, secrets(deps, projectId2)));
4396
+ }
4397
+ const check = repository.list(projectId2).find((c) => c.id === parts[3]);
4398
+ if (!check)
4399
+ throw new HttpError(404, "Check not found in this project");
4400
+ if (parts.length === 4 && method === "DELETE")
4401
+ return send(200, { ok: repository.delete(projectId2, check.id) });
4402
+ if (parts.length === 5 && parts[4] === "run" && method === "POST") {
4403
+ await body(req, []);
4404
+ requireMutationAllowed(deps);
4405
+ const service = deps.registry.get(check.serviceKey);
4406
+ if (!service || service.status !== "running" || !service.upstreamReady)
4407
+ throw new HttpError(422, "Start the check’s HTTP service and wait for readiness before running it");
4408
+ return send(200, await repository.run(projectId2, check.id, service));
4409
+ }
4410
+ }
4411
+ return send(404, { error: "Unknown debugging endpoint" });
4412
+ } catch (error) {
4413
+ if (error instanceof HttpError)
4414
+ throw error;
4415
+ return send(422, { error: error instanceof Error ? error.message : "Debugging operation failed" });
4416
+ }
4417
+ }
4418
+
4419
+ // ../daemon/src/api.ts
4420
+ import http5 from "node:http";
4421
+ import { timingSafeEqual, randomBytes, randomUUID as randomUUID2 } from "node:crypto";
4422
+
4423
+ // ../daemon/src/clean-urls.ts
4424
+ import fs8 from "node:fs";
4425
+ import http2 from "node:http";
4426
+ import path9 from "node:path";
4427
+
4428
+ // ../daemon/src/windows-forwarder.ts
4429
+ import { execFile } from "node:child_process";
4430
+ import { promisify } from "node:util";
4431
+ var execute = promisify(execFile);
4432
+ function windowsForwarderScript(port, remove = false) {
4433
+ if (!Number.isInteger(port) || port < 1 || port > 65535 || port === 80)
4434
+ throw new Error("Invalid forwarding target port.");
4435
+ return String.raw`$ErrorActionPreference = 'Stop'
4436
+ $ownerPath = 'HKLM:\SOFTWARE\LocalDeck\Port80'
4437
+ $rulePath = 'HKLM:\SYSTEM\CurrentControlSet\Services\PortProxy\v4tov4\tcp'
4438
+ $ruleName = '127.0.0.1/80'
4439
+ $marker = 'LocalDeck loopback portproxy v1'
4440
+ $netsh = Join-Path $env:SystemRoot 'System32\netsh.exe'
4441
+ $owner = Get-ItemProperty -LiteralPath $ownerPath -ErrorAction SilentlyContinue
4442
+ $rule = if (Test-Path -LiteralPath $rulePath) { (Get-Item -LiteralPath $rulePath).GetValue($ruleName) } else { $null }
4443
+ $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
4444
+ if ($owner -and !$owned) { throw 'Refusing to change an unrecognized LocalDeck ownership record.' }
4445
+ 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.' }
4446
+ ${remove ? String.raw`if ($rule) {
4447
+ & $netsh interface portproxy delete v4tov4 listenaddress=127.0.0.1 listenport=80 protocol=tcp | Out-Null
4448
+ if ($LASTEXITCODE -ne 0) { throw 'Could not remove the LocalDeck forwarding rule.' }
4449
+ }
4450
+ if ($owned) { Remove-Item -LiteralPath $ownerPath }
4451
+ ` : String.raw`if (!$rule) {
4452
+ $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') })
4453
+ if ($listeners.Count) { throw 'Port 80 is already in use. The existing listener was left unchanged.' }
4454
+ }
4455
+ $helper = Get-Service iphlpsvc
4456
+ if ($helper.StartType -eq 'Disabled') { throw 'Windows IP Helper is disabled. Enable it through your system administrator to use port forwarding.' }
4457
+ if ($helper.Status -ne 'Running') { Start-Service iphlpsvc }
4458
+ $oldRule = $rule
4459
+ $oldPort = if ($owned) { $owner.TargetPort } else { $null }
4460
+ try {
4461
+ New-Item -Path $ownerPath -Force | Out-Null
4462
+ New-ItemProperty -LiteralPath $ownerPath -Name Marker -Value $marker -PropertyType String -Force | Out-Null
4463
+ New-ItemProperty -LiteralPath $ownerPath -Name TargetPort -Value '${port}' -PropertyType String -Force | Out-Null
4464
+ & $netsh interface portproxy add v4tov4 listenaddress=127.0.0.1 listenport=80 connectaddress=127.0.0.1 connectport=${port} protocol=tcp | Out-Null
4465
+ if ($LASTEXITCODE -ne 0) { throw 'Windows could not install the forwarding rule.' }
4466
+ $installed = (Get-Item -LiteralPath $rulePath).GetValue($ruleName)
4467
+ if ($installed -ne '127.0.0.1/${port}') { throw 'The installed forwarding rule did not match the requested destination.' }
4468
+ } catch {
4469
+ if ($oldRule) {
4470
+ & $netsh interface portproxy add v4tov4 listenaddress=127.0.0.1 listenport=80 connectaddress=127.0.0.1 "connectport=$oldPort" protocol=tcp | Out-Null
4471
+ if ($LASTEXITCODE -eq 0) { Set-ItemProperty -LiteralPath $ownerPath -Name TargetPort -Value $oldPort }
4472
+ } else {
4473
+ & $netsh interface portproxy delete v4tov4 listenaddress=127.0.0.1 listenport=80 protocol=tcp | Out-Null
4474
+ if ($LASTEXITCODE -eq 0) { Remove-Item -LiteralPath $ownerPath }
4475
+ }
4476
+ throw
4477
+ }
4478
+ `}`;
4479
+ }
4480
+ function windowsElevationCommand(port, remove = false) {
4481
+ const script = `try {
4482
+ ${windowsForwarderScript(port, remove)}
4483
+ exit 0
4484
+ } catch { Write-Error $_ -ErrorAction Continue; exit 1 }`;
4485
+ const encoded = Buffer.from(script, "utf16le").toString("base64");
4486
+ 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.' }`;
4487
+ }
4488
+ async function configureWindowsForwarder(port, remove = false) {
4489
+ const script = windowsElevationCommand(port, remove);
4490
+ try {
4491
+ await execute("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], { windowsHide: true, timeout: 120000, maxBuffer: 8192 });
4492
+ } catch {
4493
+ 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.");
4494
+ }
4495
+ }
4496
+
4497
+ // ../daemon/src/system-forwarder.ts
4498
+ import { execFile as execFile2 } from "node:child_process";
4499
+ import os3 from "node:os";
4500
+ import { promisify as promisify2 } from "node:util";
4501
+ var execute2 = promisify2(execFile2);
4502
+ var label = "dev.localdeck.port80";
4503
+ var SYSTEM_FORWARDER_PLIST = `/Library/LaunchDaemons/${label}.plist`;
4504
+ var destination = SYSTEM_FORWARDER_PLIST;
4505
+ var marker = "LocalDeck managed loopback relay v1";
4506
+ function forwarderPlist(user, port) {
4507
+ if (!/^[a-zA-Z_][a-zA-Z0-9_.-]*$/.test(user) || user === "root")
4508
+ throw new Error("Forwarding must run as a regular macOS user.");
4509
+ if (!Number.isInteger(port) || port < 1 || port > 65535 || port === 80)
4510
+ throw new Error("Invalid forwarding target port.");
4511
+ return `<?xml version="1.0" encoding="UTF-8"?>
4512
+ <!-- ${marker} -->
4513
+ <plist version="1.0"><dict>
4514
+ <key>Label</key><string>${label}</string>
4515
+ <key>UserName</key><string>${user}</string>
4516
+ <key>ProgramArguments</key><array><string>/usr/bin/nc</string><string>127.0.0.1</string><string>${port}</string></array>
4517
+ <key>inetdCompatibility</key><dict><key>Wait</key><false/></dict>
4518
+ <key>Sockets</key><dict><key>Listeners</key><dict>
4519
+ <key>SockNodeName</key><string>127.0.0.1</string>
4520
+ <key>SockServiceName</key><string>80</string>
4521
+ <key>SockFamily</key><string>IPv4</string>
4522
+ <key>SockType</key><string>stream</string>
4523
+ <key>SockProtocol</key><string>TCP</string>
4524
+ </dict></dict>
4525
+ </dict></plist>`;
4526
+ }
4527
+ function systemForwarderCommand(user, port, remove = false) {
4528
+ const plist = forwarderPlist(user, port);
4529
+ const guard = `set -eu
4530
+ if [ -L '${destination}' ]; then exit 1; fi
4531
+ if [ -e '${destination}' ] && ! /usr/bin/grep -q '${marker}' '${destination}'; then
4532
+ echo 'Refusing to replace an unmanaged forwarding service.' >&2; exit 1
4533
+ fi`;
4534
+ if (remove)
4535
+ return `${guard}
4536
+ /bin/launchctl bootout system/${label} 2>/dev/null || true
4537
+ /bin/rm -f '${destination}'`;
4538
+ return `${guard}
4539
+ relay_file=$(/usr/bin/mktemp /Library/LaunchDaemons/.localdeck-port80.XXXXXX)
4540
+ trap '/bin/rm -f "$relay_file" "$relay_file.backup"' EXIT
4541
+ /bin/cat > "$relay_file" <<'LOCALDECK_PLIST'
4542
+ ${plist}
4543
+ LOCALDECK_PLIST
4544
+ /usr/bin/plutil -lint "$relay_file" >/dev/null
4545
+ /usr/sbin/chown root:wheel "$relay_file"
4546
+ /bin/chmod 644 "$relay_file"
4547
+ if [ -f '${destination}' ]; then /bin/cp -p '${destination}' "$relay_file.backup"; fi
4548
+ /bin/launchctl bootout system/${label} 2>/dev/null || true
4549
+ /bin/mv "$relay_file" '${destination}'
4550
+ if ! /bin/launchctl bootstrap system '${destination}'; then
4551
+ /bin/rm -f '${destination}'
4552
+ if [ -f "$relay_file.backup" ]; then
4553
+ /bin/mv "$relay_file.backup" '${destination}'
4554
+ /bin/launchctl bootstrap system '${destination}' || true
4555
+ fi
4556
+ exit 1
4557
+ fi`;
4558
+ }
4559
+ async function configureSystemForwarder(port, remove = false) {
4560
+ if (process.platform === "win32")
4561
+ return configureWindowsForwarder(port, remove);
4562
+ if (process.platform !== "darwin")
4563
+ throw new Error("Automatic administrator setup is available on macOS and Windows.");
4564
+ const command = systemForwarderCommand(os3.userInfo().username, port, remove);
4565
+ try {
4566
+ await execute2("/usr/bin/osascript", ["-e", `on run argv
4567
+ do shell script (item 1 of argv) with administrator privileges
4568
+ end run`, command], { timeout: 120000, maxBuffer: 8192 });
4569
+ } catch {
4570
+ throw new Error("Administrator setup was cancelled or failed. Continuing with working port-number links; retry in Settings whenever you like.");
4571
+ }
4572
+ }
4573
+
4574
+ // ../daemon/src/clean-urls.ts
4575
+ class CleanUrls {
4576
+ targetPort;
4577
+ identity;
4578
+ port;
4579
+ platform;
4580
+ preferenceFile;
4581
+ configureSystem;
4582
+ systemRelayInstalled;
4583
+ bridge;
4584
+ enabling;
4585
+ closed = false;
4586
+ constructor(targetPort, identity, port = 80, platform = process.platform, preferenceFile = path9.join(HOME_DIR, "forwarder.json"), configureSystem = configureSystemForwarder, systemRelayInstalled = () => fs8.existsSync(SYSTEM_FORWARDER_PLIST)) {
4587
+ this.targetPort = targetPort;
4588
+ this.identity = identity;
4589
+ this.port = port;
4590
+ this.platform = platform;
4591
+ this.preferenceFile = preferenceFile;
4592
+ this.configureSystem = configureSystem;
4593
+ this.systemRelayInstalled = systemRelayInstalled;
4594
+ }
4595
+ async status() {
4596
+ const ready = await new Promise((resolve) => {
4597
+ const request = http2.get({ hostname: "127.0.0.1", port: this.port, path: "/api/forwarder/probe", agent: false }, (response) => {
4598
+ let body2 = "";
4599
+ response.on("data", (chunk) => {
4600
+ body2 += chunk;
4601
+ if (body2.length > 256)
4602
+ request.destroy();
4603
+ });
4604
+ response.on("end", () => resolve(response.statusCode === 200 && body2 === this.identity));
4605
+ response.on("error", () => resolve(false));
4606
+ });
4607
+ const timer = setTimeout(() => {
4608
+ request.destroy();
4609
+ resolve(false);
4610
+ }, 1500);
4611
+ request.on("close", () => clearTimeout(timer));
4612
+ request.on("error", () => resolve(false));
4613
+ });
4614
+ return { ready: ready && !this.closed };
4615
+ }
4616
+ enable(options = {}) {
4617
+ if (this.enabling)
4618
+ return this.enabling;
4619
+ this.enabling = this.start(options).finally(() => {
4620
+ this.enabling = undefined;
4621
+ });
4622
+ return this.enabling;
4623
+ }
4624
+ async start(options) {
4625
+ if (this.closed)
4626
+ return { ready: false, error: "LocalDeck is shutting down." };
4627
+ const preference = this.preferenceFile;
4628
+ const saved = readJson(preference, {});
4629
+ if (saved.removed) {
4630
+ if (options.automatic)
4631
+ return { ready: false, error: "Clean URLs are turned off; enable them in the dashboard to use them again." };
4632
+ writeJson(preference, { declined: saved.declined });
4071
4633
  }
4072
- if (nickname)
4073
- this.assertNicknameAvailable(projectId, nickname);
4074
- const previous = project.nickname;
4075
- if (nickname)
4076
- project.nickname = nickname;
4077
- else
4078
- delete project.nickname;
4079
- try {
4080
- this.persist();
4081
- } catch (error) {
4082
- if (previous === undefined)
4083
- delete project.nickname;
4084
- else
4085
- project.nickname = previous;
4086
- throw error;
4634
+ if (this.platform === "win32" && options.install && this.port === 80 && !(options.automatic && readJson(preference, {}).declined)) {
4635
+ if (!this.bridge && (await this.status()).ready)
4636
+ return { ready: true };
4637
+ if (this.bridge) {
4638
+ await this.bridge.close();
4639
+ this.bridge = undefined;
4640
+ }
4641
+ try {
4642
+ writeJson(preference, { declined: true });
4643
+ await this.configureSystem(this.targetPort());
4644
+ let ready = false;
4645
+ for (let attempt = 0;attempt < 20 && !this.closed; attempt++) {
4646
+ if ((await this.status()).ready) {
4647
+ ready = true;
4648
+ break;
4649
+ }
4650
+ await new Promise((resolve) => setTimeout(resolve, 250));
4651
+ }
4652
+ if (!ready)
4653
+ throw new Error("Windows setup completed but forwarding could not be verified. Port-number links remain available; retry in Settings.");
4654
+ writeJson(preference, { declined: false });
4655
+ return { ready: true };
4656
+ } catch (error) {
4657
+ return { ready: false, error: error instanceof Error ? error.message : "Windows setup failed." };
4658
+ }
4087
4659
  }
4088
- return this.details(projectId);
4089
- }
4090
- saveConfigNickname(projectId, value) {
4091
- const project = this.require(projectId);
4092
- const nickname = value.trim().toLowerCase();
4093
- if (nickname && (!validNickname(nickname) || nickname === "standalone"))
4094
- throw new ProjectError("invalid", "Use 1–63 letters, numbers or interior hyphens; standalone is reserved.");
4095
- if (nickname)
4096
- this.assertNicknameAvailable(projectId, nickname);
4660
+ if ((await this.status()).ready)
4661
+ return { ready: true };
4097
4662
  try {
4098
- updateProjectNickname(project.file, nickname);
4663
+ if (this.closed)
4664
+ return { ready: false, error: "LocalDeck is shutting down." };
4665
+ this.bridge ??= await startForwarder({ listenPort: this.port, targetPort: this.targetPort() });
4666
+ if (this.closed) {
4667
+ await this.bridge.close();
4668
+ this.bridge = undefined;
4669
+ return { ready: false, error: "LocalDeck is shutting down." };
4670
+ }
4671
+ if ((await this.status()).ready)
4672
+ return { ready: true };
4673
+ return { ready: false, error: "Port 80 does not reach this LocalDeck instance. Your existing links are unchanged." };
4099
4674
  } catch (error) {
4100
- throw new ProjectError("invalid", errorMessage(error));
4675
+ const message = error instanceof Error ? error.message : "Could not start port-80 forwarding.";
4676
+ const preference2 = this.preferenceFile;
4677
+ if (options.install && this.port === 80 && this.platform === "darwin" && message.includes("permission denied") && !(options.automatic && readJson(preference2, {}).declined)) {
4678
+ try {
4679
+ writeJson(preference2, { declined: true });
4680
+ await this.configureSystem(this.targetPort());
4681
+ const result = await this.status();
4682
+ if (!result.ready)
4683
+ throw new Error("Setup completed but forwarding could not be verified. Your original links are unchanged.");
4684
+ writeJson(preference2, { declined: false });
4685
+ return result;
4686
+ } catch (setupError) {
4687
+ return { ready: false, error: setupError instanceof Error ? setupError.message : "Administrator setup failed." };
4688
+ }
4689
+ }
4690
+ return { ready: false, error: message };
4101
4691
  }
4102
- project.configNickname = nickname || undefined;
4103
- return this.updateNickname(projectId, "");
4104
4692
  }
4105
- updateDependencies(projectId, serviceName, after) {
4106
- const project = this.require(projectId);
4693
+ async uninstall() {
4694
+ if (this.enabling)
4695
+ return { ready: false, error: "Wait for the current setup to finish." };
4107
4696
  try {
4108
- updateProjectDependencies(project.file, serviceName, after);
4109
- return this.details(projectId);
4697
+ const hadBridge = Boolean(this.bridge);
4698
+ if (this.bridge) {
4699
+ await this.bridge.close();
4700
+ this.bridge = undefined;
4701
+ }
4702
+ if (this.platform === "darwin" ? this.systemRelayInstalled() : this.platform === "win32" && this.port === 80 && !hadBridge)
4703
+ await this.configureSystem(this.targetPort(), true);
4704
+ writeJson(this.preferenceFile, { declined: true, removed: true });
4705
+ return { ready: false, removed: true };
4110
4706
  } catch (error) {
4111
- throw new ProjectError("invalid", errorMessage(error));
4707
+ return { ready: false, error: error instanceof Error ? error.message : "Removal failed." };
4112
4708
  }
4113
4709
  }
4114
- updateServiceConfig(projectId, serviceName, settings) {
4115
- const project = this.require(projectId);
4116
- try {
4117
- updateProjectServiceConfig(project.file, serviceName, settings);
4118
- return this.details(projectId);
4119
- } catch (error) {
4120
- throw new ProjectError("invalid", errorMessage(error));
4710
+ async close() {
4711
+ this.closed = true;
4712
+ await this.enabling;
4713
+ if (this.bridge) {
4714
+ await this.bridge.close();
4715
+ this.bridge = undefined;
4121
4716
  }
4122
4717
  }
4123
- readCatalog() {
4124
- if (!fs3.existsSync(this.catalogFile))
4125
- return [];
4126
- try {
4127
- const parsed = JSON.parse(fs3.readFileSync(this.catalogFile, "utf8"));
4128
- if (!isCatalog(parsed))
4129
- throw new Error("invalid project catalog");
4130
- return parsed.projects;
4131
- } catch {
4132
- fs3.renameSync(this.catalogFile, this.nextCorruptFile());
4133
- return [];
4134
- }
4718
+ }
4719
+
4720
+ // ../daemon/src/api.ts
4721
+ import fs9 from "node:fs";
4722
+ import path10 from "node:path";
4723
+
4724
+ // ../daemon/src/request-body.ts
4725
+ import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
4726
+ var MAX_BODY_CAPTURE_BYTES = 16384;
4727
+
4728
+ class BodyCapture {
4729
+ limit;
4730
+ chunks = [];
4731
+ retained = 0;
4732
+ bytes = 0;
4733
+ tailUsed = 0;
4734
+ constructor(limit = MAX_BODY_CAPTURE_BYTES) {
4735
+ this.limit = limit;
4135
4736
  }
4136
- assertNicknameAvailable(projectId, nickname) {
4137
- if (this.projects.some((other) => other.id !== projectId && (other.slug === nickname || other.nickname === nickname || other.configNickname === nickname))) {
4138
- throw new ProjectError("invalid", "That project nickname is already taken. Choose another.");
4737
+ append(chunk) {
4738
+ this.bytes += chunk.length;
4739
+ const count = Math.min(chunk.length, this.limit - this.retained);
4740
+ let offset = 0;
4741
+ while (offset < count) {
4742
+ let tail = this.chunks.at(-1);
4743
+ if (!tail || this.tailUsed === tail.length) {
4744
+ if (tail && tail.length < 4096) {
4745
+ const grown = Buffer.allocUnsafe(Math.min(4096, this.limit - this.retained + this.tailUsed, Math.max(tail.length * 2, count - offset)));
4746
+ tail.copy(grown, 0, 0, this.tailUsed);
4747
+ this.chunks[this.chunks.length - 1] = tail = grown;
4748
+ } else {
4749
+ tail = Buffer.allocUnsafe(Math.min(4096, this.limit - this.retained, Math.max(64, count - offset)));
4750
+ this.chunks.push(tail);
4751
+ this.tailUsed = 0;
4752
+ }
4753
+ }
4754
+ const size = Math.min(count - offset, tail.length - this.tailUsed);
4755
+ chunk.copy(tail, this.tailUsed, offset, offset + size);
4756
+ this.tailUsed += size;
4757
+ this.retained += size;
4758
+ offset += size;
4139
4759
  }
4140
4760
  }
4141
- nextCorruptFile() {
4142
- const base = `${this.catalogFile}.corrupt-${this.now()}`;
4143
- let candidate = base;
4144
- let suffix = 2;
4145
- while (fs3.existsSync(candidate))
4146
- candidate = `${base}-${suffix++}`;
4147
- return candidate;
4148
- }
4149
- persist() {
4150
- writeJson(this.catalogFile, { version: 1, projects: this.sortedEntries() });
4151
- }
4152
- sorted() {
4153
- return this.sortedEntries().map((project) => ({ ...project }));
4154
- }
4155
- sortedEntries() {
4156
- return [...this.projects].sort((a, b) => b.lastUsedAt - a.lastUsedAt);
4157
- }
4158
- toSummary(project) {
4761
+ snapshot(complete, contentEncoding) {
4762
+ let buffer = Buffer.concat(this.chunks.map((chunk, index) => index === this.chunks.length - 1 ? chunk.subarray(0, this.tailUsed) : chunk), this.retained);
4763
+ let encoded = Boolean(contentEncoding && contentEncoding !== "identity");
4764
+ const truncated = this.bytes > this.retained;
4765
+ if (encoded && complete && !truncated && buffer.length) {
4766
+ const decode = { gzip: gunzipSync, deflate: inflateSync, br: brotliDecompressSync }[contentEncoding];
4767
+ try {
4768
+ if (decode) {
4769
+ buffer = decode(buffer, { maxOutputLength: this.limit });
4770
+ encoded = false;
4771
+ }
4772
+ } catch {}
4773
+ }
4774
+ let data;
4775
+ let encoding = "utf8";
4159
4776
  try {
4160
- this.load(project);
4161
- return { ...project, nickname: this.nickname(project.id), available: true };
4162
- } catch (error) {
4163
- return { ...project, available: false, error: errorMessage(error) };
4777
+ if (encoded || buffer.includes(0))
4778
+ throw new Error("binary body");
4779
+ data = new TextDecoder("utf-8", { fatal: true }).decode(buffer);
4780
+ } catch {
4781
+ data = buffer.toString("base64");
4782
+ encoding = "base64";
4164
4783
  }
4784
+ return { data, encoding, bytes: this.bytes, truncated, complete, ...encoded ? { contentEncoding } : {} };
4165
4785
  }
4166
- load(project) {
4167
- if (!fs3.existsSync(project.file))
4168
- throw new Error(`${project.file} does not exist`);
4169
- const loaded = loadProjectFile(project.file);
4170
- if (loaded.nickname)
4171
- this.assertNicknameAvailable(project.id, loaded.nickname);
4172
- project.configNickname = loaded.nickname;
4173
- return loaded;
4174
- }
4175
- require(projectId) {
4176
- const project = this.byId(projectId);
4177
- if (!project)
4178
- throw new ProjectError("not_found", `Project ${projectId} was not found`);
4179
- return project;
4180
- }
4181
- byId(projectId) {
4182
- return this.projects.find((project) => project.id === projectId);
4786
+ }
4787
+
4788
+ // ../daemon/src/request-headers.ts
4789
+ var MAX_HEADER_BYTES = 16384;
4790
+ var MAX_HEADER_COUNT = 100;
4791
+ function captureHeaders(raw) {
4792
+ const entries = [];
4793
+ let bytes = 0;
4794
+ for (let i = 0;i + 1 < raw.length; i += 2) {
4795
+ const name = raw[i];
4796
+ const value = raw[i + 1];
4797
+ bytes += Buffer.byteLength(name) + Buffer.byteLength(value);
4798
+ if (bytes > MAX_HEADER_BYTES || entries.length >= MAX_HEADER_COUNT)
4799
+ return { entries, truncated: true };
4800
+ entries.push([name, value]);
4183
4801
  }
4184
- assignSlug(name, id) {
4185
- const base = slugify(name) || "project";
4186
- if (!this.projects.some((project) => project.slug === base || project.nickname === base || project.configNickname === base))
4187
- return base;
4188
- const suffix = id.slice(0, 6);
4189
- let slug = `${base}-${suffix}`;
4190
- let collision = 2;
4191
- while (this.projects.some((project) => project.slug === slug || project.nickname === slug || project.configNickname === slug))
4192
- slug = `${base}-${suffix}-${collision++}`;
4193
- return slug;
4802
+ return { entries, truncated: false };
4803
+ }
4804
+
4805
+ // ../daemon/src/proxy.ts
4806
+ import http3 from "node:http";
4807
+ import net3 from "node:net";
4808
+ var HOP_BY_HOP = new Set(["connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade"]);
4809
+ function forwardHeaders(req, target) {
4810
+ const out = {};
4811
+ for (const [k, v] of Object.entries(req.headers)) {
4812
+ if (HOP_BY_HOP.has(k) || v === undefined)
4813
+ continue;
4814
+ out[k] = v;
4194
4815
  }
4816
+ if (target.via === "share") {
4817
+ const host = net3.isIP(target.upstreamHost) === 6 ? `[${target.upstreamHost}]` : target.upstreamHost;
4818
+ out.host = `${host}:${target.upstreamPort}`;
4819
+ }
4820
+ const remote = req.socket.remoteAddress ?? "";
4821
+ out["x-forwarded-for"] = req.headers["x-forwarded-for"] ? `${req.headers["x-forwarded-for"]}, ${remote}` : remote;
4822
+ out["x-forwarded-proto"] = target.via === "share" ? "https" : "http";
4823
+ out["x-forwarded-host"] = req.headers.host ?? "";
4824
+ out["x-localdeck-service"] = target.service;
4825
+ return out;
4195
4826
  }
4196
- function projectId(realFile) {
4197
- return createHash("sha256").update(realFile).digest("hex").slice(0, 16);
4198
- }
4199
- function slugify(value) {
4200
- return value.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
4201
- }
4202
- function isCatalog(value) {
4203
- if (!value || typeof value !== "object")
4204
- return false;
4205
- const catalog = value;
4206
- return catalog.version === 1 && Array.isArray(catalog.projects) && catalog.projects.every(isRememberedProject);
4207
- }
4208
- function isRememberedProject(value) {
4209
- if (!value || typeof value !== "object")
4210
- return false;
4211
- const project = value;
4212
- if (!["id", "name", "slug", "file", "directory"].every((key) => typeof project[key] === "string"))
4213
- return false;
4214
- const { id, slug, file, directory } = project;
4215
- 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);
4827
+ function waitingPage(target) {
4828
+ return `<!doctype html><meta charset="utf-8"><meta http-equiv="refresh" content="2">
4829
+ <title>LocalDeck — waiting for ${target.service}</title>
4830
+ <style>body{font:15px/1.5 system-ui;margin:0;display:grid;place-items:center;height:100vh;background:#0f1115;color:#e6e8ef}
4831
+ .c{max-width:420px;text-align:center}code{background:#1c1f27;padding:2px 6px;border-radius:4px}</style>
4832
+ <div class="c"><h1 style="font-weight:600">Waiting for <code>${target.service}</code></h1>
4833
+ <p>LocalDeck is holding this port. Nothing is listening on the upstream port <code>${target.upstreamPort}</code> yet.
4834
+ This page reloads every 2 seconds.</p></div>`;
4216
4835
  }
4217
- function validNickname(value) {
4218
- return value.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value);
4836
+ function proxyRequest(req, res, target, hooks) {
4837
+ const started = Date.now();
4838
+ const requestHeaders = captureHeaders(req.rawHeaders);
4839
+ const requestBody = new BodyCapture;
4840
+ const responseBody = new BodyCapture;
4841
+ const fullResponse = hooks.responseBodies?.start(target.service);
4842
+ const captureResponse = (chunk) => {
4843
+ responseBody.append(chunk);
4844
+ fullResponse?.append(chunk);
4845
+ };
4846
+ let responseComplete = false;
4847
+ let responseEncoding;
4848
+ let responseHeaders;
4849
+ let bytes = 0;
4850
+ let recorded = false;
4851
+ const record = (status, error) => {
4852
+ if (recorded)
4853
+ return;
4854
+ recorded = true;
4855
+ fullResponse?.finish(responseComplete, responseEncoding);
4856
+ hooks.onRequest({
4857
+ ts: started,
4858
+ service: target.service,
4859
+ method: req.method ?? "GET",
4860
+ path: req.url ?? "/",
4861
+ status,
4862
+ durationMs: Date.now() - started,
4863
+ bytes,
4864
+ via: target.via,
4865
+ error,
4866
+ requestHeaders,
4867
+ responseHeaders,
4868
+ requestBody: requestBody.snapshot(req.readableEnded, req.headers["content-encoding"]),
4869
+ responseBody: responseBody.snapshot(responseComplete, responseEncoding),
4870
+ responseBodyId: fullResponse?.id
4871
+ });
4872
+ };
4873
+ if (!target.upstreamReady) {
4874
+ responseHeaders = captureHeaders(["content-type", "text/html; charset=utf-8", "retry-after", "2"]);
4875
+ res.writeHead(503, { "content-type": "text/html; charset=utf-8", "retry-after": "2" });
4876
+ const body2 = waitingPage(target);
4877
+ captureResponse(Buffer.from(body2));
4878
+ responseComplete = true;
4879
+ res.end(body2);
4880
+ record(503, "upstream not ready");
4881
+ return;
4882
+ }
4883
+ let responseStatus = 502;
4884
+ let response;
4885
+ const upstream = http3.request({ host: target.upstreamHost, port: target.upstreamPort, method: req.method, path: req.url, headers: forwardHeaders(req, target) }, (up) => {
4886
+ if (res.destroyed) {
4887
+ up.destroy();
4888
+ return;
4889
+ }
4890
+ response = up;
4891
+ responseHeaders = captureHeaders(up.rawHeaders);
4892
+ responseEncoding = up.headers["content-encoding"];
4893
+ up.on("end", () => {
4894
+ responseComplete = true;
4895
+ });
4896
+ responseStatus = up.statusCode ?? 502;
4897
+ up.on("error", (error) => {
4898
+ record(502, `upstream response error: ${error.message}`);
4899
+ res.destroy();
4900
+ });
4901
+ up.on("aborted", () => {
4902
+ record(502, "upstream response aborted");
4903
+ res.destroy();
4904
+ });
4905
+ const headers = { ...up.headers };
4906
+ res.writeHead(up.statusCode ?? 502, up.statusMessage, headers);
4907
+ up.on("data", (chunk) => {
4908
+ bytes += chunk.length;
4909
+ captureResponse(chunk);
4910
+ });
4911
+ up.pipe(res);
4912
+ });
4913
+ upstream.on("error", (err) => {
4914
+ if (!res.headersSent)
4915
+ responseHeaders = captureHeaders(["content-type", "text/plain; charset=utf-8"]);
4916
+ if (!res.headersSent && !res.destroyed) {
4917
+ res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
4918
+ const body2 = `LocalDeck: upstream ${target.service} (${target.upstreamHost}:${target.upstreamPort}) error: ${err.code ?? err.message}`;
4919
+ captureResponse(Buffer.from(body2));
4920
+ responseComplete = true;
4921
+ res.end(body2);
4922
+ } else
4923
+ res.destroy();
4924
+ record(502, err.code ?? err.message);
4925
+ });
4926
+ const clientDisconnected = () => {
4927
+ record(499, "client disconnected before response completed");
4928
+ response?.destroy();
4929
+ upstream.destroy();
4930
+ };
4931
+ req.on("aborted", clientDisconnected);
4932
+ res.on("finish", () => record(responseStatus));
4933
+ res.on("close", () => {
4934
+ if (!res.writableFinished)
4935
+ clientDisconnected();
4936
+ });
4937
+ req.on("data", (chunk) => requestBody.append(chunk));
4938
+ req.pipe(upstream);
4219
4939
  }
4220
- function errorMessage(error) {
4221
- return error instanceof Error ? error.message : String(error);
4940
+ function proxyUpgrade(req, socket, head, target, hooks) {
4941
+ const started = Date.now();
4942
+ socket.on("error", () => socket.destroy());
4943
+ if (!target.upstreamReady) {
4944
+ socket.end(`HTTP/1.1 503 Service Unavailable\r
4945
+ Connection: close\r
4946
+ \r
4947
+ `);
4948
+ return;
4949
+ }
4950
+ const up = net3.connect(target.upstreamPort, target.upstreamHost, () => {
4951
+ const headers = forwardHeaders(req, target);
4952
+ headers["connection"] = "Upgrade";
4953
+ headers["upgrade"] = req.headers["upgrade"];
4954
+ const lines = [`${req.method} ${req.url} HTTP/1.1`];
4955
+ for (const [k, v] of Object.entries(headers)) {
4956
+ if (v === undefined)
4957
+ continue;
4958
+ for (const val of Array.isArray(v) ? v : [String(v)])
4959
+ lines.push(`${k}: ${val}`);
4960
+ }
4961
+ up.write(lines.join(`\r
4962
+ `) + `\r
4963
+ \r
4964
+ `);
4965
+ if (head.length)
4966
+ up.write(head);
4967
+ up.pipe(socket).pipe(up);
4968
+ hooks.onRequest({
4969
+ ts: started,
4970
+ service: target.service,
4971
+ method: req.method ?? "GET",
4972
+ path: req.url ?? "/",
4973
+ status: 101,
4974
+ durationMs: Date.now() - started,
4975
+ bytes: 0,
4976
+ requestHeaders: captureHeaders(req.rawHeaders),
4977
+ via: target.via
4978
+ });
4979
+ });
4980
+ const kill = () => {
4981
+ up.destroy();
4982
+ socket.destroy();
4983
+ };
4984
+ up.on("error", kill);
4985
+ socket.on("error", kill);
4986
+ socket.on("close", () => up.destroy());
4987
+ up.on("close", () => socket.destroy());
4222
4988
  }
4223
4989
 
4224
4990
  // ../daemon/src/share.ts
4225
- import http3 from "node:http";
4991
+ import http4 from "node:http";
4226
4992
 
4227
4993
  // ../daemon/src/share-readiness.ts
4228
4994
  import { setTimeout as delay } from "node:timers/promises";
@@ -4250,72 +5016,59 @@ async function waitForShare(url, nonce, signal, options = {}) {
4250
5016
  // ../daemon/src/share.ts
4251
5017
  import crypto from "node:crypto";
4252
5018
  import { spawn } from "node:child_process";
4253
-
4254
- // ../daemon/src/ports.ts
4255
- import net3 from "node:net";
4256
- function isPortFree(port, host = "127.0.0.1") {
4257
- return new Promise((resolve) => {
4258
- const srv = net3.createServer();
4259
- srv.once("error", () => resolve(false));
4260
- srv.listen(port, host, () => srv.close(() => resolve(true)));
4261
- });
4262
- }
4263
- function getFreePort(host = "127.0.0.1") {
4264
- return new Promise((resolve, reject) => {
4265
- const srv = net3.createServer();
4266
- srv.once("error", reject);
4267
- srv.listen(0, host, () => {
4268
- const { port } = srv.address();
4269
- srv.close(() => resolve(port));
4270
- });
4271
- });
4272
- }
4273
- function serviceName(key) {
4274
- const separator = key.lastIndexOf(":");
4275
- return separator === -1 ? key : key.slice(separator + 1);
4276
- }
4277
- function rememberedPortFromFile(key, file) {
4278
- const map = readJson(file, {});
4279
- if (map[key] !== undefined)
4280
- return map[key];
4281
- const name = serviceName(key);
4282
- const hasOtherQualifiedKey = Object.keys(map).some((candidate) => candidate !== key && candidate.includes(":") && candidate.endsWith(`:${name}`));
4283
- return hasOtherQualifiedKey ? undefined : map[name];
4284
- }
4285
- function rememberPort(name, port, file = PORTS_FILE) {
4286
- const map = readJson(file, {});
4287
- map[name] = port;
4288
- writeJson(file, map);
4289
- }
4290
- async function pickStablePort(name, taken, start = 4000, end = 4999, file = PORTS_FILE) {
4291
- const map = readJson(file, {});
4292
- const remembered = rememberedPortFromFile(name, file);
4293
- if (remembered) {
4294
- if (taken.has(remembered) || !await isPortFree(remembered)) {
4295
- throw new Error(`remembered stable port ${remembered} for "${name}" is already in use; stop the conflicting listener or explicitly choose a different port`);
4296
- }
4297
- return remembered;
4298
- }
4299
- const bareName = serviceName(name);
4300
- const usingLegacy = map[name] === undefined && remembered === map[bareName];
4301
- const reserved = new Set(Object.entries(map).filter(([n]) => n !== name && !(usingLegacy && n === bareName)).map(([, p]) => p));
4302
- for (let p = start;p <= end; p++) {
4303
- if (taken.has(p) || reserved.has(p))
4304
- continue;
4305
- if (await isPortFree(p))
4306
- return p;
4307
- }
4308
- throw new Error(`no free port between ${start} and ${end}`);
4309
- }
4310
-
4311
- // ../daemon/src/share.ts
4312
5019
  class ShareStartError extends Error {
4313
5020
  }
4314
5021
  var COOKIE = "localdeck_share";
4315
5022
  var URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
4316
5023
  var MAX_LOGIN_BYTES = 65536;
4317
5024
  var DEFAULT_CLEANUP_AFTER_MS = 1000;
4318
- function loginPage(name, wrong) {
5025
+ var HTML_ESCAPES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
5026
+ function escapeHtml(value) {
5027
+ return value.replace(/[&<>"']/g, (char) => HTML_ESCAPES[char]);
5028
+ }
5029
+
5030
+ class LoginThrottle {
5031
+ now;
5032
+ clients = new Map;
5033
+ all = [];
5034
+ windowMs;
5035
+ perClient;
5036
+ perShare;
5037
+ constructor(now = Date.now, limits = {}) {
5038
+ this.now = now;
5039
+ this.windowMs = limits.windowMs ?? 15 * 60000;
5040
+ this.perClient = limits.perClient ?? 10;
5041
+ this.perShare = limits.perShare ?? 100;
5042
+ }
5043
+ retryAfterSeconds(client) {
5044
+ this.prune();
5045
+ const own = this.clients.get(client) ?? [];
5046
+ const blockedUntil = Math.max(own.length >= this.perClient ? own[own.length - this.perClient] + this.windowMs : 0, this.all.length >= this.perShare ? this.all[this.all.length - this.perShare] + this.windowMs : 0);
5047
+ return blockedUntil ? Math.max(1, Math.ceil((blockedUntil - this.now()) / 1000)) : 0;
5048
+ }
5049
+ fail(client) {
5050
+ const at = this.now();
5051
+ this.all.push(at);
5052
+ this.clients.set(client, [...this.clients.get(client) ?? [], at]);
5053
+ }
5054
+ prune() {
5055
+ const cutoff = this.now() - this.windowMs;
5056
+ this.all = this.all.filter((at) => at > cutoff);
5057
+ for (const [client, times] of this.clients) {
5058
+ const recent = times.filter((at) => at > cutoff);
5059
+ if (recent.length)
5060
+ this.clients.set(client, recent);
5061
+ else
5062
+ this.clients.delete(client);
5063
+ }
5064
+ }
5065
+ }
5066
+ function loginClient(req) {
5067
+ const forwarded = req.headers["cf-connecting-ip"];
5068
+ return typeof forwarded === "string" && forwarded || req.socket.remoteAddress || "unknown";
5069
+ }
5070
+ function loginPage(rawName, wrong, retryAfter = 0) {
5071
+ const name = escapeHtml(rawName);
4319
5072
  return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width">
4320
5073
  <title>${name} — LocalDeck</title>
4321
5074
  <style>body{font:15px/1.5 system-ui;margin:0;display:grid;place-items:center;height:100vh;background:#0f1115;color:#e6e8ef}
@@ -4323,7 +5076,7 @@ form{background:#171a21;padding:28px;border-radius:12px;width:300px}input{width:
4323
5076
  button{width:100%;padding:10px;border:0;border-radius:8px;background:#5b8cff;color:#fff;font-weight:600}.e{color:#ff7b7b;margin:0 0 8px}</style>
4324
5077
  <form method="post" action="/__localdeck/login"><h2 style="margin:0 0 6px;font-weight:600">${name}</h2>
4325
5078
  <p style="margin:0;opacity:.7">Shared with LocalDeck. Enter the password you were given.</p>
4326
- ${wrong ? '<p class="e">Wrong password.</p>' : ""}
5079
+ ${retryAfter ? `<p class="e">Too many attempts. Try again in ${Math.ceil(retryAfter / 60)} min.</p>` : wrong ? '<p class="e">Wrong password.</p>' : ""}
4327
5080
  <input type="password" name="password" autofocus placeholder="Password"><button>Open</button></form>`;
4328
5081
  }
4329
5082
  function parseCookies(header) {
@@ -4338,6 +5091,25 @@ function parseCookies(header) {
4338
5091
  }
4339
5092
  return out;
4340
5093
  }
5094
+ function stripShareCookie(req) {
5095
+ const strip = (value) => value.split(";").map((part) => part.trim()).filter((part) => part && part.split("=", 1)[0] !== COOKIE).join("; ");
5096
+ if (typeof req.headers.cookie === "string") {
5097
+ const cookie = strip(req.headers.cookie);
5098
+ if (cookie)
5099
+ req.headers.cookie = cookie;
5100
+ else
5101
+ delete req.headers.cookie;
5102
+ }
5103
+ const raw = [];
5104
+ for (let i = 0;i + 1 < req.rawHeaders.length; i += 2) {
5105
+ const name = req.rawHeaders[i];
5106
+ const isCookie = name.toLowerCase() === "cookie";
5107
+ const value = isCookie ? strip(req.rawHeaders[i + 1]) : req.rawHeaders[i + 1];
5108
+ if (value || !isCookie)
5109
+ raw.push(name, value);
5110
+ }
5111
+ req.rawHeaders.splice(0, req.rawHeaders.length, ...raw);
5112
+ }
4341
5113
  function passwordsMatch(given, expected) {
4342
5114
  const a = Buffer.from(given);
4343
5115
  const b = Buffer.from(expected);
@@ -4418,13 +5190,14 @@ class ShareManager {
4418
5190
  const startupSignal = AbortSignal.any([signal, deadline, disconnected.signal]);
4419
5191
  const gatePort = await getFreePort();
4420
5192
  signal.throwIfAborted();
4421
- const authed = (req) => !password || parseCookies(req.headers.cookie)[COOKIE] === token;
5193
+ const authed = (req) => !password || passwordsMatch(parseCookies(req.headers.cookie)[COOKIE] ?? "", token);
5194
+ const throttle = new LoginThrottle;
4422
5195
  let publicHost;
4423
5196
  let confirmPublicRequest;
4424
5197
  const publicRequest = new Promise((resolve) => {
4425
5198
  confirmPublicRequest = resolve;
4426
5199
  });
4427
- const gate = http3.createServer((req, res) => {
5200
+ const gate = http4.createServer((req, res) => {
4428
5201
  if (publicHost && req.headers.host?.toLowerCase() === publicHost)
4429
5202
  confirmPublicRequest();
4430
5203
  if (req.method === "GET" && req.url === `/__localdeck/ready/${probe}`) {
@@ -4437,7 +5210,14 @@ class ShareManager {
4437
5210
  return;
4438
5211
  }
4439
5212
  if (password && req.url === "/__localdeck/login" && req.method === "POST") {
4440
- let body = "";
5213
+ const client = loginClient(req);
5214
+ const retryAfter = throttle.retryAfterSeconds(client);
5215
+ if (retryAfter) {
5216
+ res.writeHead(429, { "content-type": "text/html; charset=utf-8", "retry-after": String(retryAfter) }).end(loginPage(name, false, retryAfter));
5217
+ req.resume();
5218
+ return;
5219
+ }
5220
+ let body2 = "";
4441
5221
  let bytes = 0;
4442
5222
  let tooLarge = false;
4443
5223
  req.on("data", (c) => {
@@ -4446,16 +5226,16 @@ class ShareManager {
4446
5226
  bytes += c.length;
4447
5227
  if (bytes > MAX_LOGIN_BYTES) {
4448
5228
  tooLarge = true;
4449
- body = "";
5229
+ body2 = "";
4450
5230
  } else
4451
- body += c;
5231
+ body2 += c;
4452
5232
  });
4453
5233
  req.on("end", () => {
4454
5234
  if (tooLarge) {
4455
5235
  res.writeHead(413, { "content-type": "text/plain; charset=utf-8" }).end("LocalDeck: login request is too large");
4456
5236
  return;
4457
5237
  }
4458
- const given = new URLSearchParams(body).get("password") ?? "";
5238
+ const given = new URLSearchParams(body2).get("password") ?? "";
4459
5239
  const ok = passwordsMatch(given, password);
4460
5240
  if (ok) {
4461
5241
  const s = this.shares.get(key);
@@ -4464,8 +5244,10 @@ class ShareManager {
4464
5244
  this.registry.setShare(key, s.info);
4465
5245
  }
4466
5246
  res.writeHead(303, { "set-cookie": `${COOKIE}=${token}; Path=/; HttpOnly; SameSite=Lax; Secure`, location: "/" }).end();
4467
- } else
5247
+ } else {
5248
+ throttle.fail(client);
4468
5249
  res.writeHead(401, { "content-type": "text/html; charset=utf-8" }).end(loginPage(name, true));
5250
+ }
4469
5251
  });
4470
5252
  return;
4471
5253
  }
@@ -4473,12 +5255,14 @@ class ShareManager {
4473
5255
  res.writeHead(401, { "content-type": "text/html; charset=utf-8" }).end(loginPage(name, false));
4474
5256
  return;
4475
5257
  }
5258
+ stripShareCookie(req);
4476
5259
  proxyRequest(req, res, target, { responseBodies: this.store.responseBodies, onRequest: (r) => this.store.request(r) });
4477
5260
  });
4478
5261
  gate.on("upgrade", (req, socket, head) => {
4479
5262
  const target = this.registry.target(key, "share");
4480
5263
  if (!target || !authed(req))
4481
5264
  return socket.destroy();
5265
+ stripShareCookie(req);
4482
5266
  proxyUpgrade(req, socket, head, target, { responseBodies: this.store.responseBodies, onRequest: (r) => this.store.request(r) });
4483
5267
  });
4484
5268
  const sockets = new Set;
@@ -4803,349 +5587,154 @@ async function viaLsof(pids) {
4803
5587
  const out = [];
4804
5588
  let pid2 = 0;
4805
5589
  for (const line of stdout.split(`
4806
- `)) {
4807
- if (line.startsWith("p"))
4808
- pid2 = Number(line.slice(1));
4809
- else if (line.startsWith("n")) {
4810
- const match = line.slice(1).match(/^(\*|\[[^\]]+\]|[\d.]+):(\d+)$/);
4811
- if (match)
4812
- out.push({ pid: pid2, host: normalizeHost(match[1]), port: Number(match[2]) });
4813
- }
4814
- }
4815
- return out;
4816
- }
4817
- async function viaSs(pids) {
4818
- const { stdout } = await exec2("ss", ["-Hltnp"]);
4819
- const wanted = new Set(pids);
4820
- const out = [];
4821
- for (const line of stdout.split(`
4822
- `)) {
4823
- const columns = line.trim().split(/\s+/);
4824
- if (columns.length < 4)
4825
- continue;
4826
- const match = columns[3].match(/^(.*):(\d+)$/);
4827
- if (!match)
4828
- continue;
4829
- for (const pidMatch of line.matchAll(/pid=(\d+)/g)) {
4830
- const pid2 = Number(pidMatch[1]);
4831
- if (wanted.has(pid2))
4832
- out.push({ pid: pid2, host: normalizeHost(match[1]), port: Number(match[2]) });
4833
- }
4834
- }
4835
- return out;
4836
- }
4837
- function normalizeHost(host) {
4838
- const normalized = host.replace(/^\[|\]$/g, "");
4839
- return normalized === "*" || normalized === "0.0.0.0" || normalized === "::" || normalized === "" ? "*" : normalized;
4840
- }
4841
- function chooseUpstream(listeners, assignedPort) {
4842
- let lowest;
4843
- for (const listener of listeners) {
4844
- if (IGNORED_PORTS.has(listener.port))
4845
- continue;
4846
- if (listener.port === assignedPort)
4847
- return listener;
4848
- if (!lowest || listener.port < lowest.port)
4849
- lowest = listener;
4850
- }
4851
- return lowest;
4852
- }
4853
- function connectHost(listener) {
4854
- return listener.host === "*" ? "127.0.0.1" : listener.host;
4855
- }
4856
- function streamLines(stream, onLine) {
4857
- const decoder = new StringDecoder("utf8");
4858
- let buffered = "";
4859
- let partialLineEmitted = false;
4860
- const emitBounded = (line, force = false) => {
4861
- if (!force && !line.trim())
4862
- return;
4863
- const remaining = emitUtf8Prefixes(line, onLine);
4864
- if (remaining)
4865
- onLine(remaining);
4866
- };
4867
- const consume = (text) => {
4868
- buffered += text;
4869
- const lines = buffered.split(/\r?\n/);
4870
- buffered = lines.pop() ?? "";
4871
- for (const line of lines) {
4872
- emitBounded(line, partialLineEmitted);
4873
- partialLineEmitted = false;
4874
- }
4875
- buffered = emitUtf8Prefixes(buffered, (line) => {
4876
- onLine(line);
4877
- partialLineEmitted = true;
4878
- });
4879
- };
4880
- stream.on("data", (chunk) => consume(typeof chunk === "string" ? chunk : decoder.write(chunk)));
4881
- stream.on("end", () => {
4882
- consume(decoder.end());
4883
- emitBounded(buffered, partialLineEmitted);
4884
- buffered = "";
4885
- partialLineEmitted = false;
4886
- });
4887
- }
4888
- function emitUtf8Prefixes(value, onPrefix) {
4889
- if (Buffer.byteLength(value) <= MAX_STREAM_LINE_BYTES)
4890
- return value;
4891
- let bytes = 0;
4892
- let start = 0;
4893
- for (let index = 0;index < value.length; ) {
4894
- const codePoint = value.codePointAt(index);
4895
- const width = codePoint <= 127 ? 1 : codePoint <= 2047 ? 2 : codePoint <= 65535 ? 3 : 4;
4896
- if (bytes + width > MAX_STREAM_LINE_BYTES) {
4897
- onPrefix(value.slice(start, index));
4898
- start = index;
4899
- bytes = 0;
4900
- }
4901
- bytes += width;
4902
- index += codePoint > 65535 ? 2 : 1;
4903
- }
4904
- return value.slice(start);
4905
- }
4906
- async function defaultTaskkillRunner(file, args, options) {
4907
- await exec2(file, args, options);
4908
- }
4909
- async function killProcessTree(child, signal, platform = process.platform, runTaskkill = defaultTaskkillRunner) {
4910
- if (!child.pid)
4911
- return false;
4912
- if (platform === "win32") {
4913
- const args = ["/pid", String(child.pid), "/t"];
4914
- if (signal === "SIGKILL")
4915
- args.push("/f");
4916
- try {
4917
- await runTaskkill("taskkill.exe", args, { timeout: 5000, killSignal: "SIGKILL" });
4918
- return true;
4919
- } catch {
4920
- try {
4921
- return child.kill(signal);
4922
- } catch {
4923
- return false;
4924
- }
4925
- }
4926
- }
4927
- try {
4928
- return process.kill(-child.pid, signal);
4929
- } catch {
4930
- try {
4931
- return child.kill(signal);
4932
- } catch {
4933
- return false;
4934
- }
4935
- }
4936
- }
4937
- 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;
4938
- function stripAnsi(value) {
4939
- return value.replace(ANSI_RE, "");
4940
- }
4941
-
4942
- // ../daemon/src/diagnostics.ts
4943
- import fs4 from "node:fs";
4944
- import os3 from "node:os";
4945
- import path5 from "node:path";
4946
- function redactDiagnostic(text, secrets = []) {
4947
- 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]");
4948
- for (const secret of [...new Set(secrets)].filter(Boolean).sort((a, b) => b.length - a.length))
4949
- output = output.replaceAll(secret, "[REDACTED]");
4950
- const home = os3.homedir();
4951
- if (home.length > 1)
4952
- output = output.replaceAll(home, "<home>");
4953
- return output;
4954
- }
4955
- function privateEnvironmentValue(name, value) {
4956
- if (name === "NODE_ENV" && /^(development|production|test)$/.test(value))
4957
- return false;
4958
- if (/^(?:PORT|[A-Z_]+_PORT)$/.test(name) && /^\d{1,5}$/.test(value) && Number(value) > 0 && Number(value) <= 65535)
4959
- return false;
4960
- return true;
4961
- }
4962
- function environmentValues(directory) {
4963
- const values = [];
4964
- for (const name of [".env", ".env.local", ".env.development", ".env.development.local"]) {
4965
- const file = path5.join(directory, name);
4966
- try {
4967
- const stat = fs4.lstatSync(file);
4968
- if (!stat.isFile() || stat.size > 65536)
4969
- continue;
4970
- const text = fs4.readFileSync(file, "utf8");
4971
- for (const line of text.split(/\r?\n/)) {
4972
- const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][\w]*)\s*=\s*(.*)$/);
4973
- if (match) {
4974
- const value = match[2].trim().replace(/^(['"])(.*)\1$/, "$2");
4975
- if (privateEnvironmentValue(match[1], value))
4976
- values.push(value);
4977
- }
4978
- }
4979
- } catch {}
4980
- }
4981
- return values;
4982
- }
4983
- function diagnosticReport(project, services, logs, version, privateValues = []) {
4984
- const inheritedSecrets = Object.entries(process.env).filter(([name]) => /password|passwd|secret|token|api[_-]?key|credential/i.test(name)).flatMap(([, value]) => value ? [value] : []);
4985
- const directories = new Set([project.directory, ...project.commands.map((command) => path5.resolve(project.directory, command.cwd ?? "."))]);
4986
- const secrets = [...privateValues, ...inheritedSecrets, ...[...directories].flatMap(environmentValues), ...project.commands.flatMap((command) => Object.entries(command.env ?? {}).filter(([name, value]) => privateEnvironmentValue(name, value)).map(([, value]) => value))];
4987
- const report = {
4988
- notice: "Review before sharing. Known secrets are redacted, but application logs may contain other private data.",
4989
- version,
4990
- project: { name: project.name, available: project.available, error: project.error },
4991
- configuration: project.commands.map(({ env, ...command }) => ({ ...command, environmentVariables: Object.keys(env ?? {}) })),
4992
- 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 })),
4993
- logs: logs.slice(-100).map(({ stream, line, ts, service }) => ({ service, ts, stream, line: line.slice(0, 2000) }))
4994
- };
4995
- 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;
4996
- return JSON.stringify(sanitize(report), null, 2);
4997
- }
4998
-
4999
- // ../daemon/src/replay.ts
5000
- import http4 from "node:http";
5001
- function replayRequest(service, record, options = {}) {
5002
- if (record.service !== service.key || !["GET", "HEAD"].includes(record.method))
5003
- throw new Error("Only recorded GET and HEAD requests for this service can be replayed");
5004
- if (service.status !== "running" || !service.upstreamReady || service.serviceType && service.serviceType !== "http")
5005
- throw new Error("The HTTP service must be running before replay");
5006
- if (!["127.0.0.1", "::1"].includes(service.upstreamHost) || !record.path.startsWith("/") || record.path.startsWith("//") || /[\r\n\\\x00-\x20\x7f]/.test(record.path))
5007
- throw new Error("Request path must be a local HTTP path");
5008
- const timeoutMs = options.timeoutMs ?? 5000;
5009
- const maxBytes = options.maxBytes ?? 65536;
5010
- if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 5000 || !Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > 65536)
5011
- throw new Error("Replay limits must be positive integers within 5000 ms and 65536 bytes");
5012
- return new Promise((resolve, reject) => {
5013
- const started = Date.now();
5014
- let settled = false;
5015
- let bytes = 0;
5016
- const chunks = [];
5017
- const request = http4.request({ host: service.upstreamHost, port: service.upstreamPort, path: record.path, method: record.method, headers: { "x-localdeck-replay": "1" } });
5018
- const timer = setTimeout(() => {
5019
- if (!settled) {
5020
- settled = true;
5021
- reject(new Error("Replay timed out"));
5022
- }
5023
- request.destroy();
5024
- }, timeoutMs);
5025
- const finish = (status, truncated) => {
5026
- if (settled)
5027
- return;
5028
- settled = true;
5029
- clearTimeout(timer);
5030
- resolve({ status, truncated, body: Buffer.concat(chunks).toString("utf8"), durationMs: Date.now() - started });
5031
- };
5032
- request.on("response", (response) => {
5033
- response.on("data", (chunk) => {
5034
- const remaining = Math.max(0, maxBytes - bytes);
5035
- chunks.push(chunk.subarray(0, remaining));
5036
- bytes += chunk.length;
5037
- if (bytes > maxBytes) {
5038
- finish(response.statusCode ?? 502, true);
5039
- response.destroy();
5040
- request.destroy();
5041
- }
5042
- });
5043
- response.on("end", () => finish(response.statusCode ?? 502, false));
5044
- response.on("error", (error) => request.destroy(error));
5045
- response.on("aborted", () => request.destroy(new Error("Replay response was interrupted")));
5046
- });
5047
- request.on("error", (error) => {
5048
- clearTimeout(timer);
5049
- if (!settled) {
5050
- settled = true;
5051
- reject(error);
5052
- }
5053
- });
5054
- request.end();
5055
- });
5056
- }
5057
-
5058
- // ../daemon/src/project-secrets.ts
5059
- import fs5 from "node:fs";
5060
- import path6 from "node:path";
5061
- var ID = /^[a-f0-9]{16}$/;
5062
- var NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
5063
-
5064
- class ProjectSecretError extends Error {
5590
+ `)) {
5591
+ if (line.startsWith("p"))
5592
+ pid2 = Number(line.slice(1));
5593
+ else if (line.startsWith("n")) {
5594
+ const match = line.slice(1).match(/^(\*|\[[^\]]+\]|[\d.]+):(\d+)$/);
5595
+ if (match)
5596
+ out.push({ pid: pid2, host: normalizeHost(match[1]), port: Number(match[2]) });
5597
+ }
5598
+ }
5599
+ return out;
5065
5600
  }
5066
- function validateName(name) {
5067
- if (!NAME.test(name) || name === "PORT" || name.startsWith("LOCALDECK_")) {
5068
- throw new ProjectSecretError("invalid secret name");
5601
+ async function viaSs(pids) {
5602
+ const { stdout } = await exec2("ss", ["-Hltnp"]);
5603
+ const wanted = new Set(pids);
5604
+ const out = [];
5605
+ for (const line of stdout.split(`
5606
+ `)) {
5607
+ const columns = line.trim().split(/\s+/);
5608
+ if (columns.length < 4)
5609
+ continue;
5610
+ const match = columns[3].match(/^(.*):(\d+)$/);
5611
+ if (!match)
5612
+ continue;
5613
+ for (const pidMatch of line.matchAll(/pid=(\d+)/g)) {
5614
+ const pid2 = Number(pidMatch[1]);
5615
+ if (wanted.has(pid2))
5616
+ out.push({ pid: pid2, host: normalizeHost(match[1]), port: Number(match[2]) });
5617
+ }
5069
5618
  }
5619
+ return out;
5070
5620
  }
5071
- function createProjectSecretStore(home = HOME_DIR) {
5072
- const directory = path6.join(home, "secrets");
5073
- function secretPath(projectId2) {
5074
- if (!ID.test(projectId2))
5075
- throw new ProjectSecretError("invalid project ID");
5076
- return path6.join(directory, `${projectId2}.json`);
5621
+ function normalizeHost(host) {
5622
+ const normalized = host.replace(/^\[|\]$/g, "");
5623
+ return normalized === "*" || normalized === "0.0.0.0" || normalized === "::" || normalized === "" ? "*" : normalized;
5624
+ }
5625
+ function reachable(listener) {
5626
+ return listener.host === "*" || listener.host === "127.0.0.1" || listener.host === "::1";
5627
+ }
5628
+ function chooseUpstream(listeners, assignedPort) {
5629
+ let lowest;
5630
+ for (const listener of listeners) {
5631
+ if (IGNORED_PORTS.has(listener.port) || !reachable(listener))
5632
+ continue;
5633
+ if (listener.port === assignedPort)
5634
+ return listener;
5635
+ if (!lowest || listener.port < lowest.port)
5636
+ lowest = listener;
5077
5637
  }
5078
- function read(projectId2) {
5079
- const file = secretPath(projectId2);
5080
- let text;
5081
- try {
5082
- text = fs5.readFileSync(file, "utf8");
5083
- } catch (error) {
5084
- if (error.code === "ENOENT")
5085
- return Object.create(null);
5086
- throw new ProjectSecretError("Could not read stored secrets");
5638
+ return lowest;
5639
+ }
5640
+ function unreachableListenerMessage(listeners) {
5641
+ const candidates = listeners.filter((listener2) => !IGNORED_PORTS.has(listener2.port));
5642
+ if (candidates.some(reachable))
5643
+ return;
5644
+ const listener = candidates[0];
5645
+ if (!listener)
5646
+ return;
5647
+ const address = listener.host.includes(":") ? `[${listener.host}]:${listener.port}` : `${listener.host}:${listener.port}`;
5648
+ 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.`;
5649
+ }
5650
+ function connectHost(listener) {
5651
+ return listener.host === "*" ? "127.0.0.1" : listener.host;
5652
+ }
5653
+ function streamLines(stream, onLine) {
5654
+ const decoder = new StringDecoder("utf8");
5655
+ let buffered = "";
5656
+ let partialLineEmitted = false;
5657
+ const emitBounded = (line, force = false) => {
5658
+ if (!force && !line.trim())
5659
+ return;
5660
+ const remaining = emitUtf8Prefixes(line, onLine);
5661
+ if (remaining)
5662
+ onLine(remaining);
5663
+ };
5664
+ const consume = (text) => {
5665
+ buffered += text;
5666
+ const lines = buffered.split(/\r?\n/);
5667
+ buffered = lines.pop() ?? "";
5668
+ for (const line of lines) {
5669
+ emitBounded(line, partialLineEmitted);
5670
+ partialLineEmitted = false;
5087
5671
  }
5088
- let parsed;
5672
+ buffered = emitUtf8Prefixes(buffered, (line) => {
5673
+ onLine(line);
5674
+ partialLineEmitted = true;
5675
+ });
5676
+ };
5677
+ stream.on("data", (chunk) => consume(typeof chunk === "string" ? chunk : decoder.write(chunk)));
5678
+ stream.on("end", () => {
5679
+ consume(decoder.end());
5680
+ emitBounded(buffered, partialLineEmitted);
5681
+ buffered = "";
5682
+ partialLineEmitted = false;
5683
+ });
5684
+ }
5685
+ function emitUtf8Prefixes(value, onPrefix) {
5686
+ if (Buffer.byteLength(value) <= MAX_STREAM_LINE_BYTES)
5687
+ return value;
5688
+ let bytes = 0;
5689
+ let start = 0;
5690
+ for (let index = 0;index < value.length; ) {
5691
+ const codePoint = value.codePointAt(index);
5692
+ const width = codePoint <= 127 ? 1 : codePoint <= 2047 ? 2 : codePoint <= 65535 ? 3 : 4;
5693
+ if (bytes + width > MAX_STREAM_LINE_BYTES) {
5694
+ onPrefix(value.slice(start, index));
5695
+ start = index;
5696
+ bytes = 0;
5697
+ }
5698
+ bytes += width;
5699
+ index += codePoint > 65535 ? 2 : 1;
5700
+ }
5701
+ return value.slice(start);
5702
+ }
5703
+ async function defaultTaskkillRunner(file, args, options) {
5704
+ await exec2(file, args, options);
5705
+ }
5706
+ async function killProcessTree(child, signal, platform = process.platform, runTaskkill = defaultTaskkillRunner) {
5707
+ if (!child.pid)
5708
+ return false;
5709
+ if (platform === "win32") {
5710
+ const args = ["/pid", String(child.pid), "/t"];
5711
+ if (signal === "SIGKILL")
5712
+ args.push("/f");
5089
5713
  try {
5090
- parsed = JSON.parse(text);
5714
+ await runTaskkill("taskkill.exe", args, { timeout: 5000, killSignal: "SIGKILL" });
5715
+ return true;
5091
5716
  } catch {
5092
- throw new ProjectSecretError("stored secrets are corrupt");
5093
- }
5094
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
5095
- throw new ProjectSecretError("stored secrets are corrupt");
5096
- }
5097
- const values = Object.create(null);
5098
- for (const [name, value] of Object.entries(parsed)) {
5099
- validateName(name);
5100
- if (typeof value !== "string" || !value || value.includes("\x00")) {
5101
- throw new ProjectSecretError("stored secrets are corrupt");
5717
+ try {
5718
+ return child.kill(signal);
5719
+ } catch {
5720
+ return false;
5102
5721
  }
5103
- values[name] = value;
5104
- }
5105
- return values;
5106
- }
5107
- function write(projectId2, values) {
5108
- const file = secretPath(projectId2);
5109
- fs5.mkdirSync(directory, { recursive: true, mode: 448 });
5110
- if (process.platform !== "win32")
5111
- fs5.chmodSync(directory, 448);
5112
- writePrivateJson(file, values);
5113
- }
5114
- function listProjectSecrets(projectId2) {
5115
- return Object.keys(read(projectId2)).sort();
5116
- }
5117
- function setProjectSecret(projectId2, name, value) {
5118
- validateName(name);
5119
- if (typeof value !== "string" || !value || value.includes("\x00")) {
5120
- throw new ProjectSecretError("secret value must be a nonempty string without NUL");
5121
5722
  }
5122
- const values = read(projectId2);
5123
- values[name] = value;
5124
- write(projectId2, values);
5125
5723
  }
5126
- function deleteProjectSecret(projectId2, name) {
5127
- validateName(name);
5128
- const values = read(projectId2);
5129
- delete values[name];
5130
- write(projectId2, values);
5131
- }
5132
- function resolveProjectSecrets(projectId2, names) {
5133
- const output = Object.create(null);
5134
- if (!names.length)
5135
- return output;
5136
- const values = read(projectId2);
5137
- for (const name of names) {
5138
- validateName(name);
5139
- if (!Object.prototype.hasOwnProperty.call(values, name)) {
5140
- throw new ProjectSecretError(`Add ${name} in Project → Secrets.`);
5141
- }
5142
- output[name] = values[name];
5724
+ try {
5725
+ return process.kill(-child.pid, signal);
5726
+ } catch {
5727
+ try {
5728
+ return child.kill(signal);
5729
+ } catch {
5730
+ return false;
5143
5731
  }
5144
- return output;
5145
5732
  }
5146
- return { listProjectSecrets, setProjectSecret, deleteProjectSecret, resolveProjectSecrets };
5147
5733
  }
5148
- var { listProjectSecrets, setProjectSecret, deleteProjectSecret, resolveProjectSecrets } = createProjectSecretStore();
5734
+ 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;
5735
+ function stripAnsi(value) {
5736
+ return value.replace(ANSI_RE, "");
5737
+ }
5149
5738
 
5150
5739
  // ../daemon/src/api.ts
5151
5740
  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" };
@@ -5154,9 +5743,9 @@ var MAX_ATTACH_BYTES = 65536;
5154
5743
  var MAX_ATTACH_CWD_CHARS = 4096;
5155
5744
  var MAX_ATTACH_COMMAND_CHARS = 16384;
5156
5745
  var MAX_ATTACH_LOG_CHARS = 16384;
5157
- function json(res, status, body) {
5746
+ function json(res, status, body2) {
5158
5747
  res.writeHead(status, { "content-type": "application/json" });
5159
- res.end(JSON.stringify(body));
5748
+ res.end(JSON.stringify(body2));
5160
5749
  }
5161
5750
 
5162
5751
  class HttpError extends Error {
@@ -5305,7 +5894,7 @@ function createApiServer(deps) {
5305
5894
  const { registry, store, shares } = deps;
5306
5895
  const allowedBrowserOrigins = validatedBrowserOrigins(deps.allowedBrowserOrigins);
5307
5896
  const hooks = { responseBodies: store.responseBodies, onRequest: (r) => store.request(r) };
5308
- const forwarderIdentity = randomUUID();
5897
+ const forwarderIdentity = randomUUID2();
5309
5898
  const forwarder = new CleanUrls(() => {
5310
5899
  const address = server.address();
5311
5900
  if (!address || typeof address === "string")
@@ -5345,10 +5934,10 @@ function createApiServer(deps) {
5345
5934
  if (req.method === "DELETE")
5346
5935
  return json(res, 200, await forwarder.uninstall());
5347
5936
  if (req.method === "POST") {
5348
- const body = await readBody(req);
5349
- 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")
5937
+ const body2 = await readBody(req);
5938
+ 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")
5350
5939
  throw new HttpError(400, "invalid forwarding options");
5351
- return json(res, 200, await forwarder.enable(body));
5940
+ return json(res, 200, await forwarder.enable(body2));
5352
5941
  }
5353
5942
  return json(res, 200, await forwarder.status());
5354
5943
  }
@@ -5384,6 +5973,7 @@ function createApiServer(deps) {
5384
5973
  });
5385
5974
  attach.on("connection", (ws) => handleAttach(ws, deps));
5386
5975
  server.on("upgrade", (req, socket, head) => {
5976
+ socket.on("error", () => socket.destroy());
5387
5977
  const svc = serviceFromHost(req.headers.host);
5388
5978
  if (svc) {
5389
5979
  const resolved = registry.resolveHostname(svc);
@@ -5474,6 +6064,8 @@ async function handleApi(req, res, url, parts, deps) {
5474
6064
  stopping.catch(() => {});
5475
6065
  return;
5476
6066
  }
6067
+ if (await handleDebuggingApi(req, res, url, parts, deps))
6068
+ return;
5477
6069
  if (parts[0] === "projects") {
5478
6070
  if (parts.length === 1 && method === "GET")
5479
6071
  return json(res, 200, projects.list());
@@ -5481,11 +6073,11 @@ async function handleApi(req, res, url, parts, deps) {
5481
6073
  requireMutationAllowed(deps);
5482
6074
  if (!hasControlToken(req, deps.controlToken))
5483
6075
  throw new HttpError(403, "forbidden");
5484
- const body = await readBody(req);
6076
+ const body2 = await readBody(req);
5485
6077
  requireMutationAllowed(deps);
5486
- if (!body || typeof body !== "object" || Array.isArray(body))
6078
+ if (!body2 || typeof body2 !== "object" || Array.isArray(body2))
5487
6079
  throw new HttpError(400, "request body must be a JSON object");
5488
- const file = body.file;
6080
+ const file = body2.file;
5489
6081
  if (typeof file !== "string")
5490
6082
  throw new HttpError(400, "file must be a string");
5491
6083
  try {
@@ -5515,22 +6107,22 @@ async function handleApi(req, res, url, parts, deps) {
5515
6107
  throw new ProjectError("invalid", project.error ?? "project configuration is invalid");
5516
6108
  const name = parts[3];
5517
6109
  if (method === "PUT") {
5518
- const body = await readBody(req);
5519
- if (!body || typeof body !== "object" || Array.isArray(body) || Object.keys(body).length !== 1 || typeof body.value !== "string")
6110
+ const body2 = await readBody(req);
6111
+ if (!body2 || typeof body2 !== "object" || Array.isArray(body2) || Object.keys(body2).length !== 1 || typeof body2.value !== "string")
5520
6112
  throw new HttpError(400, 'request body must contain only a string "value"');
5521
- (deps.setProjectSecret ?? setProjectSecret)(projectId2, name, body.value);
6113
+ (deps.setProjectSecret ?? setProjectSecret)(projectId2, name, body2.value);
5522
6114
  } else
5523
6115
  (deps.deleteProjectSecret ?? deleteProjectSecret)(projectId2, name);
5524
6116
  return json(res, 200, { ok: true });
5525
6117
  }
5526
6118
  if (parts.length === 3 && parts[2] === "nickname" && method === "PATCH") {
5527
6119
  requireMutationAllowed(deps);
5528
- const body = await readBody(req);
6120
+ const body2 = await readBody(req);
5529
6121
  requireMutationAllowed(deps);
5530
- if (!body || typeof body !== "object" || Array.isArray(body) || Object.keys(body).length !== 1 || typeof body.nickname !== "string") {
6122
+ if (!body2 || typeof body2 !== "object" || Array.isArray(body2) || Object.keys(body2).length !== 1 || typeof body2.nickname !== "string") {
5531
6123
  throw new HttpError(400, 'request body must contain only a string "nickname"');
5532
6124
  }
5533
- const details = projects.saveConfigNickname(projectId2, body.nickname);
6125
+ const details = projects.saveConfigNickname(projectId2, body2.nickname);
5534
6126
  registry.refreshProjectUrls(projectId2);
5535
6127
  return json(res, 200, { ...details, services: registry.list(projectId2) });
5536
6128
  }
@@ -5584,14 +6176,14 @@ async function handleApi(req, res, url, parts, deps) {
5584
6176
  }
5585
6177
  if (parts.length === 5 && parts[2] === "services" && parts[4] === "dependencies" && method === "PATCH") {
5586
6178
  requireMutationAllowed(deps);
5587
- const body = await readBody(req);
6179
+ const body2 = await readBody(req);
5588
6180
  requireMutationAllowed(deps);
5589
- if (!body || typeof body !== "object" || Array.isArray(body))
6181
+ if (!body2 || typeof body2 !== "object" || Array.isArray(body2))
5590
6182
  throw new HttpError(400, "request body must be a JSON object");
5591
- const fields = Object.keys(body);
6183
+ const fields = Object.keys(body2);
5592
6184
  if (fields.length !== 1 || fields[0] !== "after")
5593
6185
  throw new HttpError(400, 'request body may contain only "after"');
5594
- const after = body.after;
6186
+ const after = body2.after;
5595
6187
  if (!Array.isArray(after) || !after.every((dependency) => typeof dependency === "string")) {
5596
6188
  throw new HttpError(400, "after must be an array of service names");
5597
6189
  }
@@ -5649,11 +6241,11 @@ async function handleApi(req, res, url, parts, deps) {
5649
6241
  }
5650
6242
  if (sub === "replay" && method === "POST") {
5651
6243
  requireMutationAllowed(deps);
5652
- const body = await readBody(req);
6244
+ const body2 = await readBody(req);
5653
6245
  requireMutationAllowed(deps);
5654
- if (!body || typeof body !== "object" || Array.isArray(body) || Object.keys(body).length !== 1 || typeof body.requestId !== "string")
6246
+ if (!body2 || typeof body2 !== "object" || Array.isArray(body2) || Object.keys(body2).length !== 1 || typeof body2.requestId !== "string")
5655
6247
  throw new HttpError(400, "Provide only a recorded requestId");
5656
- const record = store.requests(key, 5000).find((request) => request.id === body.requestId);
6248
+ const record = store.requests(key, 5000).find((request) => request.id === body2.requestId);
5657
6249
  if (!record)
5658
6250
  throw new HttpError(404, "Recorded request is no longer available");
5659
6251
  try {
@@ -5695,15 +6287,20 @@ async function handleApi(req, res, url, parts, deps) {
5695
6287
  requireMutationAllowed(deps);
5696
6288
  if (svc.serviceType && svc.serviceType !== "http")
5697
6289
  throw new HttpError(422, "Database endpoints cannot be shared over HTTP");
5698
- const body = await readBody(req);
6290
+ const body2 = await readBody(req);
5699
6291
  requireMutationAllowed(deps);
5700
- if (!body || typeof body !== "object" || Array.isArray(body))
6292
+ if (!body2 || typeof body2 !== "object" || Array.isArray(body2))
5701
6293
  throw new HttpError(400, "request body must be a JSON object");
5702
- const password = body.password;
6294
+ const { password, public: isPublic } = body2;
5703
6295
  if (password !== undefined && typeof password !== "string")
5704
6296
  throw new HttpError(400, "password must be a string");
6297
+ if (isPublic !== undefined && typeof isPublic !== "boolean")
6298
+ throw new HttpError(400, "public must be a boolean");
6299
+ if (isPublic && password)
6300
+ throw new HttpError(400, "a public link cannot have a password");
6301
+ const sharePassword = isPublic ? undefined : password || randomBytes(12).toString("base64url");
5705
6302
  try {
5706
- return json(res, 200, await shares.start(key, password || undefined, { exclusive: createShare }));
6303
+ return json(res, 200, await shares.start(key, sharePassword, { exclusive: createShare }));
5707
6304
  } catch (error) {
5708
6305
  if (error instanceof ShareStartError)
5709
6306
  return json(res, 503, { error: error.message });
@@ -5727,17 +6324,20 @@ function serveStatic(pathname, res, dir) {
5727
6324
  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>");
5728
6325
  return;
5729
6326
  }
5730
- let file = path7.join(dir, path7.normalize(pathname).replace(/^(\.\.[/\\])+/, ""));
6327
+ let file = path10.join(dir, path10.normalize(pathname).replace(/^(\.\.[/\\])+/, ""));
5731
6328
  if (!file.startsWith(dir))
5732
- file = path7.join(dir, "index.html");
5733
- if (!fs6.existsSync(file) || fs6.statSync(file).isDirectory())
5734
- file = path7.join(dir, "index.html");
5735
- const hashedAsset = path7.dirname(file) === path7.join(dir, "assets") && /-[A-Za-z0-9_-]{8,}\.[a-z0-9]+$/i.test(path7.basename(file));
6329
+ file = path10.join(dir, "index.html");
6330
+ if (!fs9.existsSync(file) || fs9.statSync(file).isDirectory())
6331
+ file = path10.join(dir, "index.html");
6332
+ const hashedAsset = path10.dirname(file) === path10.join(dir, "assets") && /-[A-Za-z0-9_-]{8,}\.[a-z0-9]+$/i.test(path10.basename(file));
5736
6333
  res.writeHead(200, {
5737
- "content-type": MIME[path7.extname(file)] ?? "application/octet-stream",
5738
- "cache-control": hashedAsset ? "public, max-age=31536000, immutable" : "no-cache"
6334
+ "content-type": MIME[path10.extname(file)] ?? "application/octet-stream",
6335
+ "cache-control": hashedAsset ? "public, max-age=31536000, immutable" : "no-cache",
6336
+ "x-frame-options": "DENY",
6337
+ "content-security-policy": "frame-ancestors 'none'",
6338
+ "x-content-type-options": "nosniff"
5739
6339
  });
5740
- fs6.createReadStream(file).pipe(res);
6340
+ fs9.createReadStream(file).pipe(res);
5741
6341
  }
5742
6342
  function handleAttach(ws, deps) {
5743
6343
  const { registry, store } = deps;
@@ -5948,7 +6548,7 @@ function buildRegistration(message, deps) {
5948
6548
  if (command.serviceType && command.serviceType !== "http" && (message.upstreamPort !== command.upstream || message.stablePort !== undefined)) {
5949
6549
  throw new Error("database registration must use its configured published upstream port");
5950
6550
  }
5951
- const cwd = command.cwd ? path7.resolve(project.directory, command.cwd) : project.directory;
6551
+ const cwd = command.cwd ? path10.resolve(project.directory, command.cwd) : project.directory;
5952
6552
  requireDirectory(cwd);
5953
6553
  return {
5954
6554
  key: serviceKey(project.id, command.name),
@@ -5968,7 +6568,7 @@ function buildRegistration(message, deps) {
5968
6568
  }
5969
6569
  function requireDirectory(cwd) {
5970
6570
  try {
5971
- if (!fs6.statSync(cwd).isDirectory())
6571
+ if (!fs9.statSync(cwd).isDirectory())
5972
6572
  throw new Error("not a directory");
5973
6573
  } catch {
5974
6574
  throw new Error(`working directory does not exist or is not a directory: ${cwd}`);
@@ -6243,8 +6843,12 @@ class Registry {
6243
6843
  });
6244
6844
  await new Promise((resolve, reject) => {
6245
6845
  entry.server.once("error", reject);
6246
- entry.server.listen(stablePort, "127.0.0.1", resolve);
6846
+ entry.server.listen(stablePort, "127.0.0.1", () => {
6847
+ entry.server.off("error", reject);
6848
+ resolve();
6849
+ });
6247
6850
  });
6851
+ entry.server.on("error", (error) => this.store.log({ ts: Date.now(), service: key, stream: "system", line: `LocalDeck: proxy listener error: ${error.message}` }));
6248
6852
  rememberPort(key, stablePort);
6249
6853
  this.store.log({ ts: Date.now(), service: key, stream: "system", line: `LocalDeck: ${name} on http://localhost:${stablePort} → waiting for the process to open a port (assigned PORT=${opts.upstreamPort})` });
6250
6854
  this.startHealthPolling(entry);
@@ -6433,7 +7037,12 @@ class Registry {
6433
7037
  }
6434
7038
 
6435
7039
  // ../daemon/src/store.ts
6436
- import fs7 from "node:fs";
7040
+ import fs11 from "node:fs";
7041
+
7042
+ // ../daemon/src/activity.ts
7043
+ import fs10 from "node:fs";
7044
+ import path11 from "node:path";
7045
+ import { randomUUID as randomUUID3, createHash as createHash3 } from "node:crypto";
6437
7046
 
6438
7047
  // ../daemon/src/history.ts
6439
7048
  function isAfterHistoryBoundary(id, boundary) {
@@ -6444,8 +7053,231 @@ function isAfterHistoryBoundary(id, boundary) {
6444
7053
  return started > boundaryStarted || started === boundaryStarted && sequence > boundarySequence;
6445
7054
  }
6446
7055
 
7056
+ // ../daemon/src/activity.ts
7057
+ var ERROR = /\b(error|exception|unhandled|fatal|EADDRINUSE|ECONNREFUSED)\b/i;
7058
+ var MAX_EVENTS = 2000;
7059
+ var MAX_MARKERS = 50;
7060
+ function compare(a, b) {
7061
+ if (a.ts !== b.ts)
7062
+ return a.ts - b.ts;
7063
+ if (a.id === b.id)
7064
+ return 0;
7065
+ return isAfterHistoryBoundary(a.id, b.id) ? 1 : isAfterHistoryBoundary(b.id, a.id) ? -1 : a.id.localeCompare(b.id);
7066
+ }
7067
+ function sanitizeSignature(value, secrets2) {
7068
+ const querySafe = value.replace(/([?&])([^=&#\s]+)=([^&#\s]*)/g, (original, separator, key, raw) => {
7069
+ const decoded = new URLSearchParams(`${key}=${raw}`);
7070
+ const pair = [...decoded][0];
7071
+ if (pair && /password|passwd|secret|token|api[_-]?key|credential|session|signature|authorization|cookie/i.test(pair[0]))
7072
+ return `${separator}${key}=[REDACTED]`;
7073
+ if (pair && secrets2.some((secret) => secret && pair[1].includes(secret)))
7074
+ return `${separator}${key}=[REDACTED]`;
7075
+ return original;
7076
+ });
7077
+ return redactDiagnostic(querySafe, secrets2.flatMap((secret) => [secret, encodeURIComponent(secret)]));
7078
+ }
7079
+ function boundedSignature(value) {
7080
+ return value.length <= 300 ? value : `${value.slice(0, 230)}… [${createHash3("sha256").update(value).digest("hex")}]`;
7081
+ }
7082
+ function validEvent(value) {
7083
+ if (!value || typeof value !== "object")
7084
+ return false;
7085
+ const e = value;
7086
+ 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");
7087
+ }
7088
+ function validMarker(value) {
7089
+ if (!value || typeof value !== "object")
7090
+ return false;
7091
+ const m = value, b = m.before, c = m.coverage;
7092
+ 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");
7093
+ }
7094
+ function summary(events, from, to, secrets2 = []) {
7095
+ const signatures = new Set, routes = new Map;
7096
+ let requests = 0, requestErrors = 0, errorLines = 0, restarts = 0, truncated = false;
7097
+ for (const event of events) {
7098
+ if (event.kind === "request" && event.request) {
7099
+ requests++;
7100
+ if (event.severity === "error")
7101
+ requestErrors++;
7102
+ const text = sanitizeSignature(`${event.request.method} ${event.request.path}`, secrets2);
7103
+ const key = boundedSignature(text);
7104
+ if (text.length > 300)
7105
+ truncated = true;
7106
+ if (event.severity === "error") {
7107
+ if (signatures.size < 100)
7108
+ signatures.add(key);
7109
+ else
7110
+ truncated = true;
7111
+ }
7112
+ if (!routes.has(key) && routes.size < 100)
7113
+ routes.set(key, { route: key, count: 0, errors: 0, total: 0 });
7114
+ const row = routes.get(key);
7115
+ if (row) {
7116
+ row.count++;
7117
+ row.errors += Number(event.severity === "error");
7118
+ row.total += event.request.durationMs;
7119
+ } else
7120
+ truncated = true;
7121
+ } else if (event.kind === "log" && event.severity === "error") {
7122
+ errorLines++;
7123
+ const text = sanitizeSignature(event.title, secrets2), signature = boundedSignature(text);
7124
+ if (text.length > 300)
7125
+ truncated = true;
7126
+ if (signatures.size < 100)
7127
+ signatures.add(signature);
7128
+ else
7129
+ truncated = true;
7130
+ } else if (event.kind === "lifecycle" && event.title === "Service restarted")
7131
+ restarts++;
7132
+ }
7133
+ 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 };
7134
+ }
7135
+
7136
+ class Activity {
7137
+ store;
7138
+ data;
7139
+ persistenceFailed = false;
7140
+ file;
7141
+ constructor(store, dir) {
7142
+ this.store = store;
7143
+ this.file = path11.join(dir, "activity.json");
7144
+ this.data = { version: 1, events: [], markers: [], startedAt: Date.now() };
7145
+ try {
7146
+ if (fs10.statSync(this.file).size > 16 * 1024 * 1024)
7147
+ throw new Error("Activity history too large");
7148
+ const saved = JSON.parse(fs10.readFileSync(this.file, "utf8"));
7149
+ if (saved.version !== 1 || !Array.isArray(saved.events) || !Array.isArray(saved.markers) || !Number.isFinite(saved.startedAt))
7150
+ throw new Error("Invalid activity history");
7151
+ const events = saved.events.filter(validEvent), markers = saved.markers.filter(validMarker);
7152
+ if (events.length !== saved.events.length || markers.length !== saved.markers.length)
7153
+ this.persistenceFailed = true;
7154
+ this.data = { ...saved, events: events.slice(-MAX_EVENTS), markers: markers.slice(-MAX_MARKERS) };
7155
+ } catch (error) {
7156
+ if (error.code !== "ENOENT")
7157
+ this.persistenceFailed = true;
7158
+ }
7159
+ }
7160
+ persist(strict = false) {
7161
+ try {
7162
+ writePrivateJson(this.file, this.data);
7163
+ } catch (error) {
7164
+ this.persistenceFailed = true;
7165
+ if (strict)
7166
+ throw new Error("Could not save baseline history");
7167
+ }
7168
+ }
7169
+ capture(event) {
7170
+ let row;
7171
+ if (event.type === "service") {
7172
+ const s = event.service;
7173
+ const transition = { status: s.status, readiness: s.readinessStatus, ready: s.upstreamReady, startedAt: s.startedAt };
7174
+ const last = [...this.data.events].reverse().find((e) => e.service === s.key && e.kind === "lifecycle");
7175
+ if (last && JSON.stringify(last.transition) === JSON.stringify(transition))
7176
+ return;
7177
+ const restarted = !!last?.transition?.startedAt && last.transition.startedAt !== s.startedAt && (s.status === "starting" || s.status === "running");
7178
+ 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 };
7179
+ } else if (event.type === "service:removed")
7180
+ row = { id: event.eventId, ts: Date.now(), service: event.name, kind: "lifecycle", severity: "info", title: "Service removed", transition: { status: "removed" } };
7181
+ else if (event.type === "history:cleared")
7182
+ row = { id: event.eventId, ts: Date.now(), service: event.service, kind: "clear", severity: "info", title: `${event.kind === "logs" ? "Logs" : "Requests"} cleared` };
7183
+ if (!row)
7184
+ return;
7185
+ this.data.events.push(row);
7186
+ if (this.data.events.length > MAX_EVENTS)
7187
+ this.data.droppedBefore = this.data.events.shift().ts;
7188
+ this.persist();
7189
+ }
7190
+ scope(projectId2, services) {
7191
+ if (!projectId2 || !services.length || services.length > 100 || services.some((s) => !s.startsWith(`${projectId2}:`)))
7192
+ throw new Error("Select 1–100 services belonging to this project");
7193
+ }
7194
+ records(projectId2, services) {
7195
+ this.scope(projectId2, services);
7196
+ const keys = new Set(services);
7197
+ const events = this.data.events.filter((e) => keys.has(e.service));
7198
+ for (const service of keys) {
7199
+ for (const log of this.store.logs(service, 2000))
7200
+ 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) });
7201
+ for (const r of this.store.requests(service, 5000))
7202
+ 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 } });
7203
+ }
7204
+ return events.sort(compare);
7205
+ }
7206
+ coverage(services, events, from) {
7207
+ const reasons = new Set;
7208
+ if (this.persistenceFailed)
7209
+ reasons.add("Lifecycle history persistence was unavailable.");
7210
+ if (this.data.droppedBefore !== undefined && (from === undefined || from <= this.data.droppedBefore))
7211
+ reasons.add("Older lifecycle events have expired.");
7212
+ if (from !== undefined && from < this.data.startedAt)
7213
+ reasons.add("The observation window begins before lifecycle recording started.");
7214
+ for (const key of services) {
7215
+ const c = this.store.activityCoverage(key);
7216
+ c.reasons.forEach((reason) => reasons.add(reason));
7217
+ }
7218
+ return { partial: reasons.size > 0, reasons: [...reasons], ...events[0] ? { retainedFrom: events[0].ts } : {} };
7219
+ }
7220
+ timeline(projectId2, services, options = {}) {
7221
+ const limit = options.limit ?? 100;
7222
+ if (!Number.isInteger(limit) || limit < 1 || limit > 200)
7223
+ throw new Error("limit must be between 1 and 200");
7224
+ if (options.filter && !["all", "incidents"].includes(options.filter))
7225
+ throw new Error("Unknown activity filter");
7226
+ if (options.query && options.query.length > 200)
7227
+ throw new Error("Search must be at most 200 characters");
7228
+ const all = this.records(projectId2, services), coverage = this.coverage(services, all);
7229
+ let cursor;
7230
+ const scope = createHash3("sha256").update([...new Set(services)].sort().join(",")).digest("hex");
7231
+ if (options.cursor) {
7232
+ try {
7233
+ if (options.cursor.length > 4096)
7234
+ throw 0;
7235
+ cursor = JSON.parse(Buffer.from(options.cursor, "base64url").toString());
7236
+ if (!cursor || !Number.isFinite(cursor.ts) || typeof cursor.id !== "string" || cursor.projectId !== projectId2 || cursor.scope !== scope)
7237
+ throw 0;
7238
+ } catch {
7239
+ throw new Error("Invalid activity cursor for this project and service selection");
7240
+ }
7241
+ }
7242
+ const query2 = options.query?.toLowerCase();
7243
+ 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();
7244
+ const items = matching.slice(0, limit), last = items.at(-1);
7245
+ return { items, coverage, ...last && matching.length > limit ? { nextCursor: Buffer.from(JSON.stringify({ ts: last.ts, id: last.id, projectId: projectId2, scope })).toString("base64url") } : {} };
7246
+ }
7247
+ markers(projectId2) {
7248
+ return this.data.markers.filter((m) => m.projectId === projectId2).slice().reverse();
7249
+ }
7250
+ mark(projectId2, services, options = {}) {
7251
+ const ts = options.now ?? Date.now(), all = this.records(projectId2, services);
7252
+ if (options.label !== undefined && (typeof options.label !== "string" || options.label.length > 100))
7253
+ throw new Error("Baseline label must be at most 100 characters");
7254
+ const before = all.filter((e) => e.ts >= ts - 300000 && e.ts <= ts);
7255
+ 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 } : {} };
7256
+ const previous = this.data.markers;
7257
+ this.data.markers = [...previous, marker2].slice(-MAX_MARKERS);
7258
+ try {
7259
+ this.persist(true);
7260
+ } catch (error) {
7261
+ this.data.markers = previous;
7262
+ throw error;
7263
+ }
7264
+ return marker2;
7265
+ }
7266
+ compare(projectId2, id, now = Date.now(), secrets2 = []) {
7267
+ const marker2 = this.data.markers.find((m) => m.id === id && m.projectId === projectId2);
7268
+ if (!marker2)
7269
+ throw new Error("Baseline not found or expired");
7270
+ const all = this.records(projectId2, marker2.services);
7271
+ const after = summary(all.filter((e) => isAfterHistoryBoundary(e.id, marker2.boundary) && e.ts <= now), marker2.ts, now, secrets2);
7272
+ const coverage = this.coverage(marker2.services, all, marker2.ts);
7273
+ coverage.partial ||= marker2.coverage.partial;
7274
+ coverage.reasons = [...new Set([...marker2.coverage.reasons, ...coverage.reasons])];
7275
+ 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 };
7276
+ }
7277
+ }
7278
+
6447
7279
  // ../daemon/src/response-bodies.ts
6448
- import { randomUUID as randomUUID2 } from "node:crypto";
7280
+ import { randomUUID as randomUUID4 } from "node:crypto";
6449
7281
  var RESPONSE_CACHE_BYTES = 64 * 1024 * 1024;
6450
7282
  var MAX_CAPTURES = 5000;
6451
7283
 
@@ -6457,7 +7289,7 @@ class ResponseBodies {
6457
7289
  this.capacity = capacity;
6458
7290
  }
6459
7291
  start(service) {
6460
- const id = randomUUID2();
7292
+ const id = randomUUID4();
6461
7293
  while (this.entries.size >= MAX_CAPTURES)
6462
7294
  this.remove(this.entries.keys().next().value);
6463
7295
  this.entries.set(id, { service, body: new BodyCapture(this.capacity), bytes: 0, finished: false, complete: false });
@@ -6512,8 +7344,8 @@ class ResponseBodies {
6512
7344
  }
6513
7345
 
6514
7346
  // ../daemon/src/store.ts
6515
- import path8 from "node:path";
6516
- import { createHash as createHash2 } from "node:crypto";
7347
+ import path12 from "node:path";
7348
+ import { createHash as createHash4 } from "node:crypto";
6517
7349
  import { EventEmitter } from "node:events";
6518
7350
  import { finished } from "node:stream/promises";
6519
7351
  import { Writable } from "node:stream";
@@ -6524,17 +7356,33 @@ var ERROR_RE = /\b(error|exception|unhandled|fatal|EADDRINUSE|ECONNREFUSED)\b/i;
6524
7356
 
6525
7357
  class Ring {
6526
7358
  cap;
7359
+ maxBytes;
6527
7360
  buf;
7361
+ sizes;
6528
7362
  size = 0;
6529
7363
  next = 0;
6530
- constructor(cap) {
7364
+ bytes = 0;
7365
+ constructor(cap, maxBytes = Infinity) {
6531
7366
  this.cap = cap;
7367
+ this.maxBytes = maxBytes;
6532
7368
  this.buf = new Array(cap);
7369
+ this.sizes = new Array(cap).fill(0);
6533
7370
  }
6534
- push(v) {
7371
+ push(v, bytes = 0) {
7372
+ if (this.size === this.cap)
7373
+ this.bytes -= this.sizes[this.next];
6535
7374
  this.buf[this.next] = v;
7375
+ this.sizes[this.next] = bytes;
7376
+ this.bytes += bytes;
6536
7377
  this.next = (this.next + 1) % this.cap;
6537
7378
  this.size = Math.min(this.size + 1, this.cap);
7379
+ while (this.bytes > this.maxBytes && this.size > 1) {
7380
+ const oldest = (this.next - this.size + this.cap) % this.cap;
7381
+ this.bytes -= this.sizes[oldest];
7382
+ this.buf[oldest] = undefined;
7383
+ this.sizes[oldest] = 0;
7384
+ this.size--;
7385
+ }
6538
7386
  }
6539
7387
  last(n) {
6540
7388
  const count = Math.min(Math.max(0, n), this.size);
@@ -6547,8 +7395,10 @@ class Ring {
6547
7395
  }
6548
7396
  clear() {
6549
7397
  this.buf.fill(undefined);
7398
+ this.sizes.fill(0);
6550
7399
  this.size = 0;
6551
7400
  this.next = 0;
7401
+ this.bytes = 0;
6552
7402
  }
6553
7403
  }
6554
7404
  function emptyStats() {
@@ -6557,6 +7407,7 @@ function emptyStats() {
6557
7407
 
6558
7408
  class Store extends EventEmitter {
6559
7409
  responseBodies = new ResponseBodies;
7410
+ activity;
6560
7411
  buckets = new Map;
6561
7412
  logsDir;
6562
7413
  daemonStartedAt;
@@ -6566,42 +7417,45 @@ class Store extends EventEmitter {
6566
7417
  closeAfterMs;
6567
7418
  streamFactory;
6568
7419
  maxFileBytes;
7420
+ maxHistoryBytes;
6569
7421
  constructor(options = {}) {
6570
7422
  super();
6571
7423
  this.logsDir = options.logsDir ?? LOGS_DIR;
6572
7424
  this.daemonStartedAt = options.daemonStartedAt ?? Date.now();
6573
7425
  this.closeAfterMs = options.closeAfterMs ?? DEFAULT_CLOSE_AFTER_MS;
6574
- this.streamFactory = options.streamFactory ?? ((file, streamOptions) => fs7.createWriteStream(file, streamOptions));
7426
+ this.streamFactory = options.streamFactory ?? ((file, streamOptions) => fs11.createWriteStream(file, streamOptions));
6575
7427
  this.maxFileBytes = Math.max(1, options.maxFileBytes ?? 5 * 1024 * 1024);
7428
+ this.maxHistoryBytes = Math.max(1, options.maxHistoryBytes ?? 8 * 1024 * 1024);
6576
7429
  if (options.logsDir === undefined)
6577
7430
  ensureDirs();
6578
7431
  else
6579
- fs7.mkdirSync(this.logsDir, { recursive: true });
7432
+ fs11.mkdirSync(this.logsDir, { recursive: true });
7433
+ this.activity = new Activity(this, this.logsDir);
6580
7434
  }
6581
7435
  nextId() {
6582
7436
  return `${this.daemonStartedAt.toString(36)}-${(++this.sequence).toString(36)}`;
6583
7437
  }
6584
7438
  compatibilityId(type, service, offset, serialized) {
6585
- const hash = createHash2("sha256").update(`${type}\x00${service}\x00${offset}\x00${serialized}`).digest("hex");
7439
+ const hash = createHash4("sha256").update(`${type}\x00${service}\x00${offset}\x00${serialized}`).digest("hex");
6586
7440
  return `legacy-${hash}`;
6587
7441
  }
6588
7442
  historyFile(service, type) {
6589
7443
  const suffix = type === "log" ? "log.jsonl" : "requests.jsonl";
6590
- return path8.join(this.logsDir, `${encodeURIComponent(service)}.${suffix}`);
7444
+ return path12.join(this.logsDir, `${encodeURIComponent(service)}.${suffix}`);
6591
7445
  }
6592
7446
  legacyHistoryFile(service, type) {
6593
7447
  const safe = service.replace(/[^a-z0-9_-]/gi, "_");
6594
7448
  const suffix = type === "log" ? "log.jsonl" : "requests.jsonl";
6595
- return path8.join(this.logsDir, `${safe}.${suffix}`);
7449
+ return path12.join(this.logsDir, `${safe}.${suffix}`);
6596
7450
  }
6597
7451
  loadHistory(file, type, service, ring, legacyService, budget = this.maxFileBytes) {
6598
7452
  const marker2 = `${this.historyFile(service, type)}.cleared`;
6599
- const boundary = fs7.existsSync(marker2) ? fs7.readFileSync(marker2, "utf8") : undefined;
7453
+ const boundary = fs11.existsSync(marker2) ? fs11.readFileSync(marker2, "utf8") : undefined;
6600
7454
  let fd;
6601
7455
  let size;
6602
7456
  try {
6603
- fd = fs7.openSync(file, "r");
6604
- size = fs7.fstatSync(fd).size;
7457
+ fd = fs11.openSync(file, "r");
7458
+ size = fs11.fstatSync(fd).size;
6605
7459
  } catch (error) {
6606
7460
  if (error.code === "ENOENT")
6607
7461
  return 0;
@@ -6612,9 +7466,9 @@ class Store extends EventEmitter {
6612
7466
  const bytes = Buffer.alloc(count);
6613
7467
  try {
6614
7468
  if (count)
6615
- fs7.readSync(fd, bytes, 0, count, start);
7469
+ fs11.readSync(fd, bytes, 0, count, start);
6616
7470
  } finally {
6617
- fs7.closeSync(fd);
7471
+ fs11.closeSync(fd);
6618
7472
  }
6619
7473
  let lines = bytes.toString("binary").split(`
6620
7474
  `);
@@ -6635,7 +7489,7 @@ class Store extends EventEmitter {
6635
7489
  if (input.service === service || input.service === legacyService) {
6636
7490
  const id = typeof input.id === "string" ? input.id : this.compatibilityId(type, service, offset, serialized);
6637
7491
  if (!boundary || isAfterHistoryBoundary(id, boundary))
6638
- ring.push({ ...input, service, id });
7492
+ ring.push({ ...input, service, id }, serialized.length);
6639
7493
  }
6640
7494
  }
6641
7495
  } catch {}
@@ -6652,8 +7506,8 @@ class Store extends EventEmitter {
6652
7506
  const legacyLogFile = this.legacyHistoryFile(service, "log");
6653
7507
  const legacyReqFile = this.legacyHistoryFile(service, "request");
6654
7508
  b = {
6655
- logs: new Ring(LOG_RING),
6656
- requests: new Ring(REQ_RING),
7509
+ logs: new Ring(LOG_RING, this.maxHistoryBytes),
7510
+ requests: new Ring(REQ_RING, this.maxHistoryBytes),
6657
7511
  stats: emptyStats(),
6658
7512
  latencyTotal: 0,
6659
7513
  logFile: undefined,
@@ -6677,14 +7531,14 @@ class Store extends EventEmitter {
6677
7531
  const rotatedReq = `${reqFile}.1`;
6678
7532
  const logCurrentSize = (() => {
6679
7533
  try {
6680
- return fs7.statSync(logFile).size;
7534
+ return fs11.statSync(logFile).size;
6681
7535
  } catch {
6682
7536
  return 0;
6683
7537
  }
6684
7538
  })();
6685
7539
  const reqCurrentSize = (() => {
6686
7540
  try {
6687
- return fs7.statSync(reqFile).size;
7541
+ return fs11.statSync(reqFile).size;
6688
7542
  } catch {
6689
7543
  return 0;
6690
7544
  }
@@ -6714,11 +7568,12 @@ class Store extends EventEmitter {
6714
7568
  log(log) {
6715
7569
  const record = { ...log, id: this.nextId() };
6716
7570
  const b = this.bucket(record.service);
6717
- b.logs.push(record);
7571
+ const serialized = JSON.stringify(record);
7572
+ b.logs.push(record, serialized.length);
6718
7573
  b.stats.logLines++;
6719
7574
  if (record.stream === "stderr" || ERROR_RE.test(record.line))
6720
7575
  b.stats.errorLines++;
6721
- this.persist(record.service, b, b.logFile, JSON.stringify(record) + `
7576
+ this.persist(record.service, b, b.logFile, serialized + `
6722
7577
  `);
6723
7578
  this.emitEvent({ type: "log", log: record });
6724
7579
  return record;
@@ -6726,7 +7581,8 @@ class Store extends EventEmitter {
6726
7581
  request(request) {
6727
7582
  const record = { ...request, id: this.nextId() };
6728
7583
  const b = this.bucket(record.service);
6729
- b.requests.push(record);
7584
+ const serialized = JSON.stringify(record);
7585
+ b.requests.push(record, serialized.length);
6730
7586
  const s = b.stats;
6731
7587
  s.requests++;
6732
7588
  s.lastRequestAt = record.ts;
@@ -6740,7 +7596,7 @@ class Store extends EventEmitter {
6740
7596
  s.ok++;
6741
7597
  b.latencyTotal += record.durationMs;
6742
7598
  s.avgLatencyMs = Math.round(b.latencyTotal / s.requests);
6743
- this.persist(record.service, b, b.reqFile, JSON.stringify(record) + `
7599
+ this.persist(record.service, b, b.reqFile, serialized + `
6744
7600
  `);
6745
7601
  this.emitEvent({ type: "request", request: record });
6746
7602
  return record;
@@ -6753,7 +7609,7 @@ class Store extends EventEmitter {
6753
7609
  }
6754
7610
  historyBoundary(service, kind) {
6755
7611
  const marker2 = `${this.historyFile(service, kind === "logs" ? "log" : "request")}.cleared`;
6756
- return fs7.existsSync(marker2) ? fs7.readFileSync(marker2, "utf8") : undefined;
7612
+ return fs11.existsSync(marker2) ? fs11.readFileSync(marker2, "utf8") : undefined;
6757
7613
  }
6758
7614
  async clearHistory(service, kind) {
6759
7615
  if (this.closing)
@@ -6762,15 +7618,15 @@ class Store extends EventEmitter {
6762
7618
  const state = kind === "logs" ? bucket.logFile : bucket.reqFile;
6763
7619
  const beforeId = this.nextId();
6764
7620
  const marker2 = `${state.file}.cleared`;
6765
- fs7.writeFileSync(`${marker2}.tmp`, beforeId, { mode: 384 });
6766
- fs7.renameSync(`${marker2}.tmp`, marker2);
7621
+ fs11.writeFileSync(`${marker2}.tmp`, beforeId, { mode: 384 });
7622
+ fs11.renameSync(`${marker2}.tmp`, marker2);
6767
7623
  bucket[kind].clear();
6768
7624
  if (kind === "requests")
6769
7625
  this.responseBodies.clear(service);
6770
7626
  const operation = state.queue.then(async () => {
6771
7627
  await this.closeStream(state.stream);
6772
- fs7.writeFileSync(state.file, "", { mode: 384 });
6773
- fs7.rmSync(`${state.file}.1`, { force: true });
7628
+ fs11.writeFileSync(state.file, "", { mode: 384 });
7629
+ fs11.rmSync(`${state.file}.1`, { force: true });
6774
7630
  state.bytes = 0;
6775
7631
  state.stream = this.streamFactory(state.file, { flags: "a", mode: 384, highWaterMark: 65536 });
6776
7632
  state.disabled = false;
@@ -6789,9 +7645,24 @@ class Store extends EventEmitter {
6789
7645
  }
6790
7646
  emitEvent(ev) {
6791
7647
  const event = { ...ev, eventId: this.nextId() };
7648
+ this.activity.capture(event);
6792
7649
  this.emit("event", event);
6793
7650
  return event;
6794
7651
  }
7652
+ activityBoundary() {
7653
+ return this.nextId();
7654
+ }
7655
+ activityCoverage(service) {
7656
+ const b = this.bucket(service);
7657
+ const reasons = [];
7658
+ if (this.historyBoundary(service, "logs") || this.historyBoundary(service, "requests"))
7659
+ reasons.push("Some history was explicitly cleared.");
7660
+ 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`))
7661
+ reasons.push("Older logs or requests may have expired from bounded history.");
7662
+ if (b.persistenceDiagnosticEmitted)
7663
+ reasons.push("Some log or request history could not be persisted.");
7664
+ return { reasons };
7665
+ }
6795
7666
  close() {
6796
7667
  if (!this.closePromise) {
6797
7668
  this.closing = true;
@@ -6857,12 +7728,12 @@ class Store extends EventEmitter {
6857
7728
  } });
6858
7729
  }
6859
7730
  try {
6860
- if (fs7.existsSync(file))
6861
- fs7.chmodSync(file, 384);
7731
+ if (fs11.existsSync(file))
7732
+ fs11.chmodSync(file, 384);
6862
7733
  } catch {}
6863
7734
  const state = { stream, file, bytes: (() => {
6864
7735
  try {
6865
- return fs7.statSync(file).size;
7736
+ return fs11.statSync(file).size;
6866
7737
  } catch {
6867
7738
  return 0;
6868
7739
  }
@@ -6936,8 +7807,8 @@ class Store extends EventEmitter {
6936
7807
  }
6937
7808
  const rotated = `${state.file}.1`;
6938
7809
  try {
6939
- fs7.rmSync(rotated, { force: true });
6940
- fs7.renameSync(state.file, rotated);
7810
+ fs11.rmSync(rotated, { force: true });
7811
+ fs11.renameSync(state.file, rotated);
6941
7812
  } catch {
6942
7813
  state.disabled = true;
6943
7814
  this.reportPersistenceFailure(service, bucket, "history rotation");
@@ -6945,7 +7816,7 @@ class Store extends EventEmitter {
6945
7816
  }
6946
7817
  state.stream = this.streamFactory(state.file, { flags: "a", mode: 384, highWaterMark: 65536 });
6947
7818
  try {
6948
- fs7.chmodSync(state.file, 384);
7819
+ fs11.chmodSync(state.file, 384);
6949
7820
  } catch {}
6950
7821
  state.bytes = 0;
6951
7822
  state.stream.on("error", () => {
@@ -6991,8 +7862,8 @@ function settlesWithin2(promise, timeoutMs) {
6991
7862
 
6992
7863
  // ../daemon/src/supervisor.ts
6993
7864
  import { spawn as spawn3 } from "node:child_process";
6994
- import path10 from "node:path";
6995
- import fs9 from "node:fs";
7865
+ import path14 from "node:path";
7866
+ import fs13 from "node:fs";
6996
7867
 
6997
7868
  // ../daemon/src/startup-guidance.ts
6998
7869
  function startupGuidance(message) {
@@ -7017,8 +7888,8 @@ function startupGuidance(message) {
7017
7888
  }
7018
7889
 
7019
7890
  // ../daemon/src/port-environment.ts
7020
- import fs8 from "node:fs";
7021
- import path9 from "node:path";
7891
+ import fs12 from "node:fs";
7892
+ import path13 from "node:path";
7022
7893
  function inferPortEnvironment(command, cwd, port) {
7023
7894
  const visited = new Set;
7024
7895
  const inspect = (input, directory) => {
@@ -7027,7 +7898,7 @@ function inferPortEnvironment(command, cwd, port) {
7027
7898
  const words = typeof input === "string" ? shellWords(input) : input;
7028
7899
  if (!words?.length)
7029
7900
  return;
7030
- const executable = path9.basename(words[0]).replace(/\.cmd$/, "");
7901
+ const executable = path13.basename(words[0]).replace(/\.cmd$/, "");
7031
7902
  if (words[0].includes("=") || ["echo", "printf"].includes(executable))
7032
7903
  return;
7033
7904
  if (["npm", "pnpm", "yarn", "bun"].includes(executable)) {
@@ -7050,7 +7921,7 @@ function inferPortEnvironment(command, cwd, port) {
7050
7921
  return;
7051
7922
  workspace = value;
7052
7923
  } else
7053
- target = path9.resolve(directory, value);
7924
+ target = path13.resolve(directory, value);
7054
7925
  } else if (word.startsWith("-") || script)
7055
7926
  return;
7056
7927
  else
@@ -7080,26 +7951,26 @@ function inferPortEnvironment(command, cwd, port) {
7080
7951
  if (name2 && name2 !== "PORT" && !name2.startsWith("LOCALDECK_"))
7081
7952
  return name2;
7082
7953
  };
7083
- const name = inspect(command, path9.resolve(cwd));
7954
+ const name = inspect(command, path13.resolve(cwd));
7084
7955
  return name ? { [name]: String(port) } : {};
7085
7956
  }
7086
7957
  function shellWords(command) {
7087
7958
  const words = [];
7088
7959
  let word = "";
7089
- let quote = "";
7960
+ let quote2 = "";
7090
7961
  let started = false;
7091
7962
  for (let i = 0;i < command.length; i++) {
7092
7963
  const char = command[i];
7093
7964
  if (char === "`" || char === "\\" || char === `
7094
7965
  ` || command.slice(i, i + 2) === "$(")
7095
7966
  return;
7096
- if (quote) {
7097
- if (char === quote)
7098
- quote = "";
7967
+ if (quote2) {
7968
+ if (char === quote2)
7969
+ quote2 = "";
7099
7970
  else
7100
- word += quote === "'" && char === "$" ? "\x00" : char;
7971
+ word += quote2 === "'" && char === "$" ? "\x00" : char;
7101
7972
  } else if (char === '"' || char === "'") {
7102
- quote = char;
7973
+ quote2 = char;
7103
7974
  started = true;
7104
7975
  } else if (/\s/.test(char)) {
7105
7976
  if (started)
@@ -7113,7 +7984,7 @@ function shellWords(command) {
7113
7984
  started = true;
7114
7985
  }
7115
7986
  }
7116
- if (quote)
7987
+ if (quote2)
7117
7988
  return;
7118
7989
  if (started)
7119
7990
  words.push(word);
@@ -7121,7 +7992,7 @@ function shellWords(command) {
7121
7992
  }
7122
7993
  function manifest(directory) {
7123
7994
  try {
7124
- const value = JSON.parse(fs8.readFileSync(path9.join(directory, "package.json"), "utf8"));
7995
+ const value = JSON.parse(fs12.readFileSync(path13.join(directory, "package.json"), "utf8"));
7125
7996
  return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
7126
7997
  } catch {
7127
7998
  return;
@@ -7134,7 +8005,7 @@ function workspaceDirectory(root, selector) {
7134
8005
  return;
7135
8006
  const directories = new Set;
7136
8007
  for (const pattern of patterns.slice(0, 100)) {
7137
- if (typeof pattern !== "string" || path9.isAbsolute(pattern))
8008
+ if (typeof pattern !== "string" || path13.isAbsolute(pattern))
7138
8009
  continue;
7139
8010
  const parts = pattern.replace(/^\.\//, "").replace(/\/$/, "").split("/");
7140
8011
  if (parts.some((part) => !part || part === ".." || /[^\w@.*-]/.test(part) || part.includes("**")))
@@ -7145,9 +8016,9 @@ function workspaceDirectory(root, selector) {
7145
8016
  const next = [];
7146
8017
  for (const candidate of candidates) {
7147
8018
  try {
7148
- for (const entry of fs8.readdirSync(candidate, { withFileTypes: true })) {
8019
+ for (const entry of fs12.readdirSync(candidate, { withFileTypes: true })) {
7149
8020
  if (entry.isDirectory() && !["node_modules", ".git"].includes(entry.name) && matcher.test(entry.name))
7150
- next.push(path9.join(candidate, entry.name));
8021
+ next.push(path13.join(candidate, entry.name));
7151
8022
  if (next.length > 100)
7152
8023
  return;
7153
8024
  }
@@ -7157,7 +8028,7 @@ function workspaceDirectory(root, selector) {
7157
8028
  }
7158
8029
  for (const candidate of candidates) {
7159
8030
  const pkg = manifest(candidate);
7160
- if (pkg && (candidate === path9.resolve(root, selector) || pkg.name === selector))
8031
+ if (pkg && (candidate === path13.resolve(root, selector) || pkg.name === selector))
7161
8032
  directories.add(candidate);
7162
8033
  }
7163
8034
  }
@@ -7194,7 +8065,7 @@ async function waitForReadiness(command, getService, options = {}) {
7194
8065
  await delay2(Math.min(command.ready.intervalMs, afterProbe), command.name, options.signal);
7195
8066
  }
7196
8067
  }
7197
- function probeHttp(service, path10, expectedStatus, remainingMs, signal) {
8068
+ function probeHttp(service, path14, expectedStatus, remainingMs, signal) {
7198
8069
  return new Promise((resolve, reject) => {
7199
8070
  if (signal?.aborted)
7200
8071
  return reject(new Error(`${service.name} readiness cancelled`));
@@ -7212,7 +8083,7 @@ function probeHttp(service, path10, expectedStatus, remainingMs, signal) {
7212
8083
  const request = http7.request({
7213
8084
  host: service.upstreamHost,
7214
8085
  port: service.upstreamPort,
7215
- path: path10,
8086
+ path: path14,
7216
8087
  method: "GET",
7217
8088
  timeout: Math.max(1, Math.min(remainingMs, 2000))
7218
8089
  }, (response) => {
@@ -7356,8 +8227,8 @@ function secretOutput(values) {
7356
8227
  return result;
7357
8228
  }
7358
8229
  function consume(final) {
7359
- const secrets = [...values].map(stripVTControlCharacters).filter(Boolean).sort((a, b) => b.length - a.length);
7360
- if (!secrets.length) {
8230
+ const secrets2 = [...values].map(stripVTControlCharacters).filter(Boolean).sort((a, b) => b.length - a.length);
8231
+ if (!secrets2.length) {
7361
8232
  const output2 = pending;
7362
8233
  pending = "";
7363
8234
  return output2;
@@ -7366,9 +8237,9 @@ function secretOutput(values) {
7366
8237
  let index = 0;
7367
8238
  while (index < pending.length) {
7368
8239
  const remaining = pending.slice(index);
7369
- if (!final && secrets.some((value) => value.length > remaining.length && value.startsWith(remaining)))
8240
+ if (!final && secrets2.some((value) => value.length > remaining.length && value.startsWith(remaining)))
7370
8241
  break;
7371
- const match = secrets.find((value) => pending.startsWith(value, index));
8242
+ const match = secrets2.find((value) => pending.startsWith(value, index));
7372
8243
  if (match) {
7373
8244
  output += "[REDACTED]";
7374
8245
  index += match.length;
@@ -7487,11 +8358,11 @@ class ProcessSupervisor {
7487
8358
  return started;
7488
8359
  const hasDependents = project.commands.some((candidate) => candidate.after.includes(command.name));
7489
8360
  if (command.ready.type === "tcp" && !hasDependents) {
7490
- const cwd2 = command.cwd ? path10.resolve(project.directory, command.cwd) : project.directory;
8361
+ const cwd2 = command.cwd ? path14.resolve(project.directory, command.cwd) : project.directory;
7491
8362
  this.verifyReadiness(command, key, cwd2);
7492
8363
  return started;
7493
8364
  }
7494
- const cwd = command.cwd ? path10.resolve(project.directory, command.cwd) : project.directory;
8365
+ const cwd = command.cwd ? path14.resolve(project.directory, command.cwd) : project.directory;
7495
8366
  return this.verifyReadiness(command, key, cwd, started);
7496
8367
  })();
7497
8368
  attempts.set(command.name, attempt);
@@ -7637,9 +8508,9 @@ class ProcessSupervisor {
7637
8508
  const project = this.projects.details(projectId2);
7638
8509
  if (!project.available)
7639
8510
  throw new ProjectError("invalid", project.error);
7640
- const cwd = command.cwd ? path10.resolve(project.directory, command.cwd) : project.directory;
8511
+ const cwd = command.cwd ? path14.resolve(project.directory, command.cwd) : project.directory;
7641
8512
  try {
7642
- if (!fs9.statSync(cwd).isDirectory())
8513
+ if (!fs13.statSync(cwd).isDirectory())
7643
8514
  throw new Error("not a directory");
7644
8515
  } catch {
7645
8516
  throw new ProjectError("invalid", `configured working directory does not exist or is not a directory: ${cwd}`);
@@ -7872,7 +8743,7 @@ class ProcessSupervisor {
7872
8743
  else {
7873
8744
  const command = this.projects.resolve(projectId2, name);
7874
8745
  const project = this.projects.details(projectId2);
7875
- const cwd = command.cwd ? path10.resolve(project.directory, command.cwd) : project.directory;
8746
+ const cwd = command.cwd ? path14.resolve(project.directory, command.cwd) : project.directory;
7876
8747
  this.verifyReadiness(command, key, cwd);
7877
8748
  }
7878
8749
  return result;
@@ -8002,6 +8873,7 @@ class ProcessSupervisor {
8002
8873
  }
8003
8874
  scheduleDetection(managed, assignedPort) {
8004
8875
  let reported;
8876
+ let unreachable;
8005
8877
  let idle = 0;
8006
8878
  const tick = async () => {
8007
8879
  if (this.active.get(managed.key) !== managed || managed.child.exitCode !== null || managed.child.signalCode)
@@ -8009,6 +8881,14 @@ class ProcessSupervisor {
8009
8881
  try {
8010
8882
  const listeners = await listeningPorts(await processTree(managed.child.pid));
8011
8883
  const listener = chooseUpstream(listeners, assignedPort);
8884
+ const problem = listener ? undefined : unreachableListenerMessage(listeners);
8885
+ if (problem && problem !== unreachable) {
8886
+ this.store.log({ ts: Date.now(), service: managed.key, stream: "system", line: `LocalDeck: ${problem}` });
8887
+ this.setLastError(managed.key, problem);
8888
+ } else if (listener && unreachable && this.registry.get(managed.key)?.lastError === unreachable) {
8889
+ this.setLastError(managed.key, undefined);
8890
+ }
8891
+ unreachable = problem;
8012
8892
  if (listener) {
8013
8893
  const host = connectHost(listener);
8014
8894
  const target = `${host}:${listener.port}`;
@@ -8148,7 +9028,7 @@ async function waitForProcessGroupExit(groupId, timeoutMs) {
8148
9028
  return true;
8149
9029
  }
8150
9030
  // ../daemon/package.json
8151
- var version = "1.3.0";
9031
+ var version = "1.5.0";
8152
9032
 
8153
9033
  // ../daemon/src/index.ts
8154
9034
  var VERSION = version;
@@ -8165,8 +9045,8 @@ async function startDaemon(opts = {}) {
8165
9045
  writePrivateState: writePrivateJson,
8166
9046
  ...opts.dependencies
8167
9047
  };
8168
- const stateFile = opts.homeDir ? path11.join(opts.homeDir, "daemon.json") : STATE_FILE;
8169
- const projectsFile = opts.homeDir ? path11.join(opts.homeDir, "projects.json") : PROJECTS_FILE;
9048
+ const stateFile = opts.homeDir ? path15.join(opts.homeDir, "daemon.json") : STATE_FILE;
9049
+ const projectsFile = opts.homeDir ? path15.join(opts.homeDir, "projects.json") : PROJECTS_FILE;
8170
9050
  const apiPort = opts.apiPort ?? DEFAULT_API_PORT;
8171
9051
  const store = dependencies.createStore();
8172
9052
  const registry = dependencies.createRegistry(store, apiPort);
@@ -8226,16 +9106,16 @@ async function startDaemon(opts = {}) {
8226
9106
  if (stateCleanup === "failed-write") {
8227
9107
  if (!stateWriteAttempted)
8228
9108
  return;
8229
- fs10.rmSync(`${stateFile}.${process.pid}.tmp`, { force: true });
9109
+ fs14.rmSync(`${stateFile}.${process.pid}.tmp`, { force: true });
8230
9110
  if (priorState)
8231
9111
  restorePrivateState(stateFile, priorState.contents, priorState.mode);
8232
9112
  else
8233
- fs10.rmSync(stateFile, { force: true });
9113
+ fs14.rmSync(stateFile, { force: true });
8234
9114
  return;
8235
9115
  }
8236
9116
  const state = readJson(stateFile, null);
8237
9117
  if (state?.pid === process.pid)
8238
- fs10.unlinkSync(stateFile);
9118
+ fs14.unlinkSync(stateFile);
8239
9119
  }));
8240
9120
  return errors;
8241
9121
  })();
@@ -8255,11 +9135,15 @@ async function startDaemon(opts = {}) {
8255
9135
  try {
8256
9136
  await new Promise((resolve, reject) => {
8257
9137
  server.once("error", reject);
8258
- server.listen(apiPort, "127.0.0.1", resolve);
9138
+ server.listen(apiPort, "127.0.0.1", () => {
9139
+ server.off("error", reject);
9140
+ resolve();
9141
+ });
8259
9142
  });
8260
- if (fs10.existsSync(stateFile)) {
8261
- const stat = fs10.statSync(stateFile);
8262
- priorState = { contents: fs10.readFileSync(stateFile), mode: stat.mode & 511 };
9143
+ server.on("error", (error) => console.error(`LocalDeck: API server error: ${error.message}`));
9144
+ if (fs14.existsSync(stateFile)) {
9145
+ const stat = fs14.statSync(stateFile);
9146
+ priorState = { contents: fs14.readFileSync(stateFile), mode: stat.mode & 511 };
8263
9147
  }
8264
9148
  stateWriteAttempted = true;
8265
9149
  dependencies.writePrivateState(stateFile, {
@@ -8280,9 +9164,9 @@ async function startDaemon(opts = {}) {
8280
9164
  }
8281
9165
  function restorePrivateState(file, contents, mode) {
8282
9166
  const temporary = `${file}.${process.pid}.restore.tmp`;
8283
- fs10.writeFileSync(temporary, contents, { mode });
8284
- fs10.chmodSync(temporary, mode);
8285
- fs10.renameSync(temporary, file);
9167
+ fs14.writeFileSync(temporary, contents, { mode });
9168
+ fs14.chmodSync(temporary, mode);
9169
+ fs14.renameSync(temporary, file);
8286
9170
  }
8287
9171
  function toError3(error) {
8288
9172
  return error instanceof Error ? error : new Error(String(error));
@@ -8303,7 +9187,7 @@ function createOperationTracker() {
8303
9187
  async function findDaemon(stateFile = STATE_FILE) {
8304
9188
  let state;
8305
9189
  try {
8306
- state = JSON.parse(fs10.readFileSync(stateFile, "utf8"));
9190
+ state = JSON.parse(fs14.readFileSync(stateFile, "utf8"));
8307
9191
  } catch (error) {
8308
9192
  const code = error.code;
8309
9193
  if (code === "EACCES" || code === "EPERM") {
@@ -8319,8 +9203,8 @@ async function findDaemon(stateFile = STATE_FILE) {
8319
9203
  const res = await fetch(`http://127.0.0.1:${state.apiPort}/api/status`, { signal: AbortSignal.timeout(1000) });
8320
9204
  if (!res.ok)
8321
9205
  return null;
8322
- const body = await res.json();
8323
- return body.pid === state.pid ? state : null;
9206
+ const body2 = await res.json();
9207
+ return body2.pid === state.pid ? state : null;
8324
9208
  } catch {
8325
9209
  return null;
8326
9210
  }
@@ -8328,24 +9212,24 @@ async function findDaemon(stateFile = STATE_FILE) {
8328
9212
 
8329
9213
  // src/client.ts
8330
9214
  import { spawn as spawn4, spawnSync } from "node:child_process";
8331
- import fs11 from "node:fs";
8332
- import path12 from "node:path";
9215
+ import fs15 from "node:fs";
9216
+ import path16 from "node:path";
8333
9217
  import { fileURLToPath } from "node:url";
8334
9218
  function dashboardDir() {
8335
- const here = path12.dirname(fileURLToPath(import.meta.url));
9219
+ const here = path16.dirname(fileURLToPath(import.meta.url));
8336
9220
  const candidates = [
8337
- path12.resolve(here, "../dashboard"),
8338
- path12.resolve(here, "../../dashboard/dist")
9221
+ path16.resolve(here, "../dashboard"),
9222
+ path16.resolve(here, "../../dashboard/dist")
8339
9223
  ];
8340
- return candidates.find((d) => fs11.existsSync(path12.join(d, "index.html")));
9224
+ return candidates.find((d) => fs15.existsSync(path16.join(d, "index.html")));
8341
9225
  }
8342
9226
  function daemonCommand() {
8343
9227
  const entry = process.argv[1];
8344
9228
  const runningUnderBun = Boolean(process.versions.bun);
8345
9229
  if (runningUnderBun) {
8346
- const dist = path12.resolve(path12.dirname(fileURLToPath(import.meta.url)), "../dist/index.js");
9230
+ const dist = path16.resolve(path16.dirname(fileURLToPath(import.meta.url)), "../dist/index.js");
8347
9231
  const hasNode = spawnSync("node", ["--version"], { stdio: "ignore" }).status === 0;
8348
- if (fs11.existsSync(dist) && hasNode)
9232
+ if (fs15.existsSync(dist) && hasNode)
8349
9233
  return ["node", [dist, "daemon", "run"]];
8350
9234
  console.warn("LocalDeck: starting daemon under Bun — WebSocket proxying (HMR) will not work. Run `bun run build` and make sure `node` is on PATH.");
8351
9235
  }
@@ -8358,8 +9242,8 @@ async function ensureDaemon() {
8358
9242
  const running = await findDaemon();
8359
9243
  if (running)
8360
9244
  return running;
8361
- fs11.mkdirSync(path12.dirname(DAEMON_LOG), { recursive: true });
8362
- const out = fs11.openSync(DAEMON_LOG, "a");
9245
+ fs15.mkdirSync(path16.dirname(DAEMON_LOG), { recursive: true });
9246
+ const out = fs15.openSync(DAEMON_LOG, "a");
8363
9247
  const [cmd, args] = daemonCommand();
8364
9248
  const child = spawn4(cmd, args, { detached: true, stdio: ["ignore", out, out], env: process.env });
8365
9249
  child.unref();
@@ -8385,12 +9269,12 @@ class Api {
8385
9269
  get base() {
8386
9270
  return `http://127.0.0.1:${this.port}`;
8387
9271
  }
8388
- async call(method, p, body, headers) {
9272
+ async call(method, p, body2, headers) {
8389
9273
  const res = await this.fetch(this.base + p, {
8390
9274
  method,
8391
9275
  ...this.timeoutMs ? { signal: AbortSignal.timeout(this.timeoutMs) } : {},
8392
- headers: { ...body ? { "content-type": "application/json" } : {}, ...headers },
8393
- body: body ? JSON.stringify(body) : undefined
9276
+ headers: { ...body2 ? { "content-type": "application/json" } : {}, ...headers },
9277
+ body: body2 ? JSON.stringify(body2) : undefined
8394
9278
  });
8395
9279
  const data = await res.json().catch(() => ({}));
8396
9280
  if (!res.ok) {
@@ -8401,6 +9285,25 @@ class Api {
8401
9285
  }
8402
9286
  projects = () => this.call("GET", "/api/projects");
8403
9287
  project = (id) => this.call("GET", `/api/projects/${encodeURIComponent(id)}`);
9288
+ timeline = (projectId2, options = {}) => {
9289
+ const params = new URLSearchParams;
9290
+ if (options.services)
9291
+ params.set("services", options.services.join(","));
9292
+ for (const field of ["filter", "query", "cursor", "limit"]) {
9293
+ if (options[field] !== undefined)
9294
+ params.set(field, String(options[field]));
9295
+ }
9296
+ return this.call("GET", `/api/projects/${encodeURIComponent(projectId2)}/activity?${params}`);
9297
+ };
9298
+ markBaseline = (projectId2, body2) => this.call("POST", `/api/projects/${encodeURIComponent(projectId2)}/baselines`, body2);
9299
+ baselines = (projectId2) => this.call("GET", `/api/projects/${encodeURIComponent(projectId2)}/baselines`);
9300
+ compareBaseline = (projectId2, baselineId) => this.call("GET", `/api/projects/${encodeURIComponent(projectId2)}/baselines/${encodeURIComponent(baselineId)}`);
9301
+ environments = (projectId2) => this.call("GET", `/api/projects/${encodeURIComponent(projectId2)}/environments`);
9302
+ registerEnvironment = (projectId2, environmentId) => this.call("POST", `/api/projects/${encodeURIComponent(projectId2)}/environments/${encodeURIComponent(environmentId)}/register`, {});
9303
+ exportRequest = (serviceKey2, requestId) => this.call("GET", `/api/services/${encodeURIComponent(serviceKey2)}/reproduction?requestId=${encodeURIComponent(requestId)}`);
9304
+ saveCheck = (projectId2, body2) => this.call("POST", `/api/projects/${encodeURIComponent(projectId2)}/checks`, body2);
9305
+ checks = (projectId2) => this.call("GET", `/api/projects/${encodeURIComponent(projectId2)}/checks`);
9306
+ runCheck = (projectId2, checkId) => this.call("POST", `/api/projects/${encodeURIComponent(projectId2)}/checks/${encodeURIComponent(checkId)}/run`, {});
8404
9307
  serviceAction = (projectId2, name, action) => this.call("POST", `/api/projects/${encodeURIComponent(projectId2)}/services/${encodeURIComponent(name)}/${action}`, {});
8405
9308
  shareInfo = (key) => this.call("GET", `/api/services/${encodeURIComponent(key)}/share`);
8406
9309
  replay = (key, requestId) => this.call("POST", `/api/services/${encodeURIComponent(key)}/replay`, { requestId });
@@ -8424,9 +9327,9 @@ class Api {
8424
9327
  response = (key, requestId) => this.call("GET", `/api/services/${encodeURIComponent(key)}/response?requestId=${encodeURIComponent(requestId)}`);
8425
9328
  requests = async (reference, limit) => this.call("GET", `/api/services/${await this.servicePath(reference)}/requests?limit=${limit}`);
8426
9329
  stop = async (reference) => this.call("POST", `/api/services/${await this.servicePath(reference)}/stop`);
8427
- share = async (reference, password, exclusive = false) => this.call("POST", `/api/services/${await this.servicePath(reference)}/share${exclusive ? "/new" : ""}`, { password });
9330
+ share = async (reference, password, exclusive = false, isPublic = false) => this.call("POST", `/api/services/${await this.servicePath(reference)}/share${exclusive ? "/new" : ""}`, isPublic ? { public: true } : { password });
8428
9331
  unshare = async (reference) => this.call("DELETE", `/api/services/${await this.servicePath(reference)}/share`);
8429
9332
  shutdown = () => this.call("POST", "/api/shutdown");
8430
9333
  }
8431
9334
 
8432
- export { __toESM, __commonJS, __export, __require, wrapper_default, startForwarder, HOME_DIR, STATE_FILE, DEFAULT_API_PORT, assertUserRuntime, findProjectFile, loadProject, loadProjectFile, shellCommand, isPortFree, getFreePort, serviceKey, processTree, listeningPorts, chooseUpstream, connectHost, streamLines, killProcessTree, stripAnsi, redactDiagnostic, environmentValues, listProjectSecrets, resolveProjectSecrets, startupGuidance, inferPortEnvironment, startupLayers, waitForReadiness, secretOutput, VERSION, startDaemon, findDaemon, dashboardDir, ensureDaemon, Api };
9335
+ export { __toESM, __commonJS, __export, __require, wrapper_default, HOME_DIR, STATE_FILE, DEFAULT_API_PORT, assertUserRuntime, isPortFree, getFreePort, findProjectFile, loadProject, loadProjectFile, 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 };