localdeck 1.4.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,902 +3414,1581 @@ function stopOrder(commands) {
3414
3414
  }
3415
3415
 
3416
3416
  // ../daemon/src/index.ts
3417
- import fs11 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, randomBytes, randomUUID } from "node:crypto";
3421
+ // ../daemon/src/debugging-api.ts
3422
+ import path8 from "node:path";
3424
3423
 
3425
- // ../daemon/src/clean-urls.ts
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";
3426
3431
  import fs3 from "node:fs";
3427
- import http from "node:http";
3432
+ import { execFileSync } from "node:child_process";
3428
3433
  import path3 from "node:path";
3429
3434
 
3430
- // ../daemon/src/windows-forwarder.ts
3431
- import { execFile } from "node:child_process";
3432
- import { promisify } from "node:util";
3433
- var execute = promisify(execFile);
3434
- function windowsForwarderScript(port, remove = false) {
3435
- if (!Number.isInteger(port) || port < 1 || port > 65535 || port === 80)
3436
- throw new Error("Invalid forwarding target port.");
3437
- return String.raw`$ErrorActionPreference = 'Stop'
3438
- $ownerPath = 'HKLM:\SOFTWARE\LocalDeck\Port80'
3439
- $rulePath = 'HKLM:\SYSTEM\CurrentControlSet\Services\PortProxy\v4tov4\tcp'
3440
- $ruleName = '127.0.0.1/80'
3441
- $marker = 'LocalDeck loopback portproxy v1'
3442
- $netsh = Join-Path $env:SystemRoot 'System32\netsh.exe'
3443
- $owner = Get-ItemProperty -LiteralPath $ownerPath -ErrorAction SilentlyContinue
3444
- $rule = if (Test-Path -LiteralPath $rulePath) { (Get-Item -LiteralPath $rulePath).GetValue($ruleName) } else { $null }
3445
- $owned = $owner -and $owner.Marker -eq $marker -and $owner.TargetPort -match '^\d+$' -and [int]$owner.TargetPort -ge 1 -and [int]$owner.TargetPort -le 65535 -and [int]$owner.TargetPort -ne 80
3446
- if ($owner -and !$owned) { throw 'Refusing to change an unrecognized LocalDeck ownership record.' }
3447
- if ($rule -and (!$owned -or $rule -ne ('127.0.0.1/' + $owner.TargetPort))) { throw 'Port 80 has an unmanaged forwarding rule. It was left unchanged.' }
3448
- ${remove ? String.raw`if ($rule) {
3449
- & $netsh interface portproxy delete v4tov4 listenaddress=127.0.0.1 listenport=80 protocol=tcp | Out-Null
3450
- if ($LASTEXITCODE -ne 0) { throw 'Could not remove the LocalDeck forwarding rule.' }
3451
- }
3452
- if ($owned) { Remove-Item -LiteralPath $ownerPath }
3453
- ` : String.raw`if (!$rule) {
3454
- $listeners = @(Get-NetTCPConnection -ErrorAction Stop | Where-Object { $_.State -eq 'Listen' -and $_.LocalPort -eq 80 -and $_.LocalAddress -in @('127.0.0.1','0.0.0.0','::','::1') })
3455
- if ($listeners.Count) { throw 'Port 80 is already in use. The existing listener was left unchanged.' }
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
+ });
3456
3443
  }
3457
- $helper = Get-Service iphlpsvc
3458
- if ($helper.StartType -eq 'Disabled') { throw 'Windows IP Helper is disabled. Enable it through your system administrator to use port forwarding.' }
3459
- if ($helper.Status -ne 'Running') { Start-Service iphlpsvc }
3460
- $oldRule = $rule
3461
- $oldPort = if ($owned) { $owner.TargetPort } else { $null }
3462
- try {
3463
- New-Item -Path $ownerPath -Force | Out-Null
3464
- New-ItemProperty -LiteralPath $ownerPath -Name Marker -Value $marker -PropertyType String -Force | Out-Null
3465
- New-ItemProperty -LiteralPath $ownerPath -Name TargetPort -Value '${port}' -PropertyType String -Force | Out-Null
3466
- & $netsh interface portproxy add v4tov4 listenaddress=127.0.0.1 listenport=80 connectaddress=127.0.0.1 connectport=${port} protocol=tcp | Out-Null
3467
- if ($LASTEXITCODE -ne 0) { throw 'Windows could not install the forwarding rule.' }
3468
- $installed = (Get-Item -LiteralPath $rulePath).GetValue($ruleName)
3469
- if ($installed -ne '127.0.0.1/${port}') { throw 'The installed forwarding rule did not match the requested destination.' }
3470
- } catch {
3471
- if ($oldRule) {
3472
- & $netsh interface portproxy add v4tov4 listenaddress=127.0.0.1 listenport=80 connectaddress=127.0.0.1 "connectport=$oldPort" protocol=tcp | Out-Null
3473
- if ($LASTEXITCODE -eq 0) { Set-ItemProperty -LiteralPath $ownerPath -Name TargetPort -Value $oldPort }
3474
- } else {
3475
- & $netsh interface portproxy delete v4tov4 listenaddress=127.0.0.1 listenport=80 protocol=tcp | Out-Null
3476
- if ($LASTEXITCODE -eq 0) { Remove-Item -LiteralPath $ownerPath }
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;
3477
3448
  }
3478
- throw
3449
+ return true;
3479
3450
  }
3480
- `}`;
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
+ });
3481
3460
  }
3482
- function windowsElevationCommand(port, remove = false) {
3483
- const script = `try {
3484
- ${windowsForwarderScript(port, remove)}
3485
- exit 0
3486
- } catch { Write-Error $_ -ErrorAction Continue; exit 1 }`;
3487
- const encoded = Buffer.from(script, "utf16le").toString("base64");
3488
- return `$ErrorActionPreference = 'Stop'; $exe = Join-Path $env:SystemRoot 'System32\\WindowsPowerShell\\v1.0\\powershell.exe'; $child = Start-Process -FilePath $exe -Verb RunAs -ArgumentList '-NoLogo -NoProfile -NonInteractive -EncodedCommand ${encoded}' -PassThru -Wait; if ($child.ExitCode -ne 0) { throw 'Windows administrator setup failed.' }`;
3461
+ function serviceName(key) {
3462
+ const separator = key.lastIndexOf(":");
3463
+ return separator === -1 ? key : key.slice(separator + 1);
3489
3464
  }
3490
- async function configureWindowsForwarder(port, remove = false) {
3491
- const script = windowsElevationCommand(port, remove);
3492
- try {
3493
- await execute("powershell.exe", ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script], { windowsHide: true, timeout: 120000, maxBuffer: 8192 });
3494
- } catch {
3495
- throw new Error("Windows administrator setup was cancelled or failed. Port 80 may be occupied or reserved, or IP Helper may be disabled. Existing port-number links still work; retry from Settings.");
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;
3496
3501
  }
3502
+ throw new Error(`no free port between ${start} and ${end}`);
3497
3503
  }
3498
3504
 
3499
- // ../daemon/src/system-forwarder.ts
3500
- import { execFile as execFile2 } from "node:child_process";
3501
- import os2 from "node:os";
3502
- import { promisify as promisify2 } from "node:util";
3503
- var execute2 = promisify2(execFile2);
3504
- var label = "dev.localdeck.port80";
3505
- var SYSTEM_FORWARDER_PLIST = `/Library/LaunchDaemons/${label}.plist`;
3506
- var destination = SYSTEM_FORWARDER_PLIST;
3507
- var marker = "LocalDeck managed loopback relay v1";
3508
- function forwarderPlist(user, port) {
3509
- if (!/^[a-zA-Z_][a-zA-Z0-9_.-]*$/.test(user) || user === "root")
3510
- throw new Error("Forwarding must run as a regular macOS user.");
3511
- if (!Number.isInteger(port) || port < 1 || port > 65535 || port === 80)
3512
- throw new Error("Invalid forwarding target port.");
3513
- return `<?xml version="1.0" encoding="UTF-8"?>
3514
- <!-- ${marker} -->
3515
- <plist version="1.0"><dict>
3516
- <key>Label</key><string>${label}</string>
3517
- <key>UserName</key><string>${user}</string>
3518
- <key>ProgramArguments</key><array><string>/usr/bin/nc</string><string>127.0.0.1</string><string>${port}</string></array>
3519
- <key>inetdCompatibility</key><dict><key>Wait</key><false/></dict>
3520
- <key>Sockets</key><dict><key>Listeners</key><dict>
3521
- <key>SockNodeName</key><string>127.0.0.1</string>
3522
- <key>SockServiceName</key><string>80</string>
3523
- <key>SockFamily</key><string>IPv4</string>
3524
- <key>SockType</key><string>stream</string>
3525
- <key>SockProtocol</key><string>TCP</string>
3526
- </dict></dict>
3527
- </dict></plist>`;
3528
- }
3529
- function systemForwarderCommand(user, port, remove = false) {
3530
- const plist = forwarderPlist(user, port);
3531
- const guard = `set -eu
3532
- if [ -L '${destination}' ]; then exit 1; fi
3533
- if [ -e '${destination}' ] && ! /usr/bin/grep -q '${marker}' '${destination}'; then
3534
- echo 'Refusing to replace an unmanaged forwarding service.' >&2; exit 1
3535
- fi`;
3536
- if (remove)
3537
- return `${guard}
3538
- /bin/launchctl bootout system/${label} 2>/dev/null || true
3539
- /bin/rm -f '${destination}'`;
3540
- return `${guard}
3541
- relay_file=$(/usr/bin/mktemp /Library/LaunchDaemons/.localdeck-port80.XXXXXX)
3542
- trap '/bin/rm -f "$relay_file" "$relay_file.backup"' EXIT
3543
- /bin/cat > "$relay_file" <<'LOCALDECK_PLIST'
3544
- ${plist}
3545
- LOCALDECK_PLIST
3546
- /usr/bin/plutil -lint "$relay_file" >/dev/null
3547
- /usr/sbin/chown root:wheel "$relay_file"
3548
- /bin/chmod 644 "$relay_file"
3549
- if [ -f '${destination}' ]; then /bin/cp -p '${destination}' "$relay_file.backup"; fi
3550
- /bin/launchctl bootout system/${label} 2>/dev/null || true
3551
- /bin/mv "$relay_file" '${destination}'
3552
- if ! /bin/launchctl bootstrap system '${destination}'; then
3553
- /bin/rm -f '${destination}'
3554
- if [ -f "$relay_file.backup" ]; then
3555
- /bin/mv "$relay_file.backup" '${destination}'
3556
- /bin/launchctl bootstrap system '${destination}' || true
3557
- fi
3558
- exit 1
3559
- fi`;
3560
- }
3561
- async function configureSystemForwarder(port, remove = false) {
3562
- if (process.platform === "win32")
3563
- return configureWindowsForwarder(port, remove);
3564
- if (process.platform !== "darwin")
3565
- throw new Error("Automatic administrator setup is available on macOS and Windows.");
3566
- const command = systemForwarderCommand(os2.userInfo().username, port, remove);
3567
- try {
3568
- await execute2("/usr/bin/osascript", ["-e", `on run argv
3569
- do shell script (item 1 of argv) with administrator privileges
3570
- end run`, command], { timeout: 120000, maxBuffer: 8192 });
3571
- } catch {
3572
- throw new Error("Administrator setup was cancelled or failed. Continuing with working port-number links; retry in Settings whenever you like.");
3505
+ // ../daemon/src/projects.ts
3506
+ class ProjectError extends Error {
3507
+ code;
3508
+ constructor(code, message) {
3509
+ super(message);
3510
+ this.code = code;
3573
3511
  }
3574
3512
  }
3575
3513
 
3576
- // ../daemon/src/clean-urls.ts
3577
- class CleanUrls {
3578
- targetPort;
3579
- identity;
3580
- port;
3581
- platform;
3582
- preferenceFile;
3583
- configureSystem;
3584
- systemRelayInstalled;
3585
- bridge;
3586
- enabling;
3587
- closed = false;
3588
- constructor(targetPort, identity, port = 80, platform = process.platform, preferenceFile = path3.join(HOME_DIR, "forwarder.json"), configureSystem = configureSystemForwarder, systemRelayInstalled = () => fs3.existsSync(SYSTEM_FORWARDER_PLIST)) {
3589
- this.targetPort = targetPort;
3590
- this.identity = identity;
3591
- this.port = port;
3592
- this.platform = platform;
3593
- this.preferenceFile = preferenceFile;
3594
- this.configureSystem = configureSystem;
3595
- this.systemRelayInstalled = systemRelayInstalled;
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();
3596
3524
  }
3597
- async status() {
3598
- const ready = await new Promise((resolve) => {
3599
- const request = http.get({ hostname: "127.0.0.1", port: this.port, path: "/api/forwarder/probe", agent: false }, (response) => {
3600
- let body = "";
3601
- response.on("data", (chunk) => {
3602
- body += chunk;
3603
- if (body.length > 256)
3604
- request.destroy();
3605
- });
3606
- response.on("end", () => resolve(response.statusCode === 200 && body === this.identity));
3607
- response.on("error", () => resolve(false));
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 } : {}
3608
3554
  });
3609
- const timer = setTimeout(() => {
3610
- request.destroy();
3611
- resolve(false);
3612
- }, 1500);
3613
- request.on("close", () => clearTimeout(timer));
3614
- request.on("error", () => resolve(false));
3615
- });
3616
- return { ready: ready && !this.closed };
3555
+ }
3556
+ this.persist();
3557
+ return this.toSummary(this.byId(id));
3617
3558
  }
3618
- enable(options = {}) {
3619
- if (this.enabling)
3620
- return this.enabling;
3621
- this.enabling = this.start(options).finally(() => {
3622
- this.enabling = undefined;
3623
- });
3624
- return this.enabling;
3559
+ list() {
3560
+ return this.sorted().map((project) => this.toSummary(project));
3625
3561
  }
3626
- async start(options) {
3627
- if (this.closed)
3628
- return { ready: false, error: "LocalDeck is shutting down." };
3629
- const preference = this.preferenceFile;
3630
- const saved = readJson(preference, {});
3631
- if (saved.removed) {
3632
- if (options.automatic)
3633
- return { ready: false, error: "Clean URLs are turned off; enable them in the dashboard to use them again." };
3634
- writeJson(preference, { declined: saved.declined });
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: [] };
3635
3569
  }
3636
- if (this.platform === "win32" && options.install && this.port === 80 && !(options.automatic && readJson(preference, {}).declined)) {
3637
- if (!this.bridge && (await this.status()).ready)
3638
- return { ready: true };
3639
- if (this.bridge) {
3640
- await this.bridge.close();
3641
- this.bridge = undefined;
3642
- }
3643
- try {
3644
- writeJson(preference, { declined: true });
3645
- await this.configureSystem(this.targetPort());
3646
- let ready = false;
3647
- for (let attempt = 0;attempt < 20 && !this.closed; attempt++) {
3648
- if ((await this.status()).ready) {
3649
- ready = true;
3650
- break;
3651
- }
3652
- await new Promise((resolve) => setTimeout(resolve, 250));
3653
- }
3654
- if (!ready)
3655
- throw new Error("Windows setup completed but forwarding could not be verified. Port-number links remain available; retry in Settings.");
3656
- writeJson(preference, { declined: false });
3657
- return { ready: true };
3658
- } catch (error) {
3659
- return { ready: false, error: error instanceof Error ? error.message : "Windows setup failed." };
3660
- }
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");
3661
3584
  }
3662
- if ((await this.status()).ready)
3663
- return { ready: true };
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;
3664
3609
  try {
3665
- if (this.closed)
3666
- return { ready: false, error: "LocalDeck is shutting down." };
3667
- this.bridge ??= await startForwarder({ listenPort: this.port, targetPort: this.targetPort() });
3668
- if (this.closed) {
3669
- await this.bridge.close();
3670
- this.bridge = undefined;
3671
- return { ready: false, error: "LocalDeck is shutting down." };
3672
- }
3673
- if ((await this.status()).ready)
3674
- return { ready: true };
3675
- return { ready: false, error: "Port 80 does not reach this LocalDeck instance. Your existing links are unchanged." };
3610
+ this.persist();
3676
3611
  } catch (error) {
3677
- const message = error instanceof Error ? error.message : "Could not start port-80 forwarding.";
3678
- const preference2 = this.preferenceFile;
3679
- if (options.install && this.port === 80 && this.platform === "darwin" && message.includes("permission denied") && !(options.automatic && readJson(preference2, {}).declined)) {
3680
- try {
3681
- writeJson(preference2, { declined: true });
3682
- await this.configureSystem(this.targetPort());
3683
- const result = await this.status();
3684
- if (!result.ready)
3685
- throw new Error("Setup completed but forwarding could not be verified. Your original links are unchanged.");
3686
- writeJson(preference2, { declined: false });
3687
- return result;
3688
- } catch (setupError) {
3689
- return { ready: false, error: setupError instanceof Error ? setupError.message : "Administrator setup failed." };
3690
- }
3691
- }
3692
- return { ready: false, error: message };
3612
+ if (previous === undefined)
3613
+ delete project.nickname;
3614
+ else
3615
+ project.nickname = previous;
3616
+ throw error;
3693
3617
  }
3618
+ return this.details(projectId);
3694
3619
  }
3695
- async uninstall() {
3696
- if (this.enabling)
3697
- 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);
3698
3627
  try {
3699
- const hadBridge = Boolean(this.bridge);
3700
- if (this.bridge) {
3701
- await this.bridge.close();
3702
- this.bridge = undefined;
3703
- }
3704
- if (this.platform === "darwin" ? this.systemRelayInstalled() : this.platform === "win32" && this.port === 80 && !hadBridge)
3705
- await this.configureSystem(this.targetPort(), true);
3706
- writeJson(this.preferenceFile, { declined: true, removed: true });
3707
- return { ready: false, removed: true };
3628
+ updateProjectNickname(project.file, nickname);
3708
3629
  } catch (error) {
3709
- return { ready: false, error: error instanceof Error ? error.message : "Removal failed." };
3630
+ throw new ProjectError("invalid", errorMessage(error));
3710
3631
  }
3632
+ project.configNickname = nickname || undefined;
3633
+ return this.updateNickname(projectId, "");
3711
3634
  }
3712
- async close() {
3713
- this.closed = true;
3714
- await this.enabling;
3715
- if (this.bridge) {
3716
- await this.bridge.close();
3717
- 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));
3718
3642
  }
3719
3643
  }
3720
- }
3721
-
3722
- // ../daemon/src/api.ts
3723
- import fs7 from "node:fs";
3724
- import path7 from "node:path";
3725
-
3726
- // ../daemon/src/request-body.ts
3727
- import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
3728
- var MAX_BODY_CAPTURE_BYTES = 16384;
3729
-
3730
- class BodyCapture {
3731
- limit;
3732
- chunks = [];
3733
- retained = 0;
3734
- bytes = 0;
3735
- tailUsed = 0;
3736
- constructor(limit = MAX_BODY_CAPTURE_BYTES) {
3737
- this.limit = limit;
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
+ }
3738
3652
  }
3739
- append(chunk) {
3740
- this.bytes += chunk.length;
3741
- const count = Math.min(chunk.length, this.limit - this.retained);
3742
- let offset = 0;
3743
- while (offset < count) {
3744
- let tail = this.chunks.at(-1);
3745
- if (!tail || this.tailUsed === tail.length) {
3746
- if (tail && tail.length < 4096) {
3747
- const grown = Buffer.allocUnsafe(Math.min(4096, this.limit - this.retained + this.tailUsed, Math.max(tail.length * 2, count - offset)));
3748
- tail.copy(grown, 0, 0, this.tailUsed);
3749
- this.chunks[this.chunks.length - 1] = tail = grown;
3750
- } else {
3751
- tail = Buffer.allocUnsafe(Math.min(4096, this.limit - this.retained, Math.max(64, count - offset)));
3752
- this.chunks.push(tail);
3753
- this.tailUsed = 0;
3754
- }
3755
- }
3756
- const size = Math.min(count - offset, tail.length - this.tailUsed);
3757
- chunk.copy(tail, this.tailUsed, offset, offset + size);
3758
- this.tailUsed += size;
3759
- this.retained += size;
3760
- offset += size;
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 [];
3761
3664
  }
3762
3665
  }
3763
- snapshot(complete, contentEncoding) {
3764
- let buffer = Buffer.concat(this.chunks.map((chunk, index) => index === this.chunks.length - 1 ? chunk.subarray(0, this.tailUsed) : chunk), this.retained);
3765
- let encoded = Boolean(contentEncoding && contentEncoding !== "identity");
3766
- const truncated = this.bytes > this.retained;
3767
- if (encoded && complete && !truncated && buffer.length) {
3768
- const decode = { gzip: gunzipSync, deflate: inflateSync, br: brotliDecompressSync }[contentEncoding];
3769
- try {
3770
- if (decode) {
3771
- buffer = decode(buffer, { maxOutputLength: this.limit });
3772
- encoded = false;
3773
- }
3774
- } catch {}
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.");
3775
3669
  }
3776
- let data;
3777
- 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) {
3778
3709
  try {
3779
- if (encoded || buffer.includes(0))
3780
- throw new Error("binary body");
3781
- data = new TextDecoder("utf-8", { fatal: true }).decode(buffer);
3782
- } catch {
3783
- data = buffer.toString("base64");
3784
- 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) };
3785
3714
  }
3786
- return { data, encoding, bytes: this.bytes, truncated, complete, ...encoded ? { contentEncoding } : {} };
3715
+ }
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;
3730
+ }
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;
3736
+ }
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;
3787
3750
  }
3788
3751
  }
3789
-
3790
- // ../daemon/src/request-headers.ts
3791
- var MAX_HEADER_BYTES = 16384;
3792
- var MAX_HEADER_COUNT = 100;
3793
- function captureHeaders(raw) {
3794
- const entries = [];
3795
- let bytes = 0;
3796
- for (let i = 0;i + 1 < raw.length; i += 2) {
3797
- const name = raw[i];
3798
- const value = raw[i + 1];
3799
- bytes += Buffer.byteLength(name) + Buffer.byteLength(value);
3800
- if (bytes > MAX_HEADER_BYTES || entries.length >= MAX_HEADER_COUNT)
3801
- return { entries, truncated: true };
3802
- entries.push([name, value]);
3752
+ function projectId(realFile) {
3753
+ return createHash("sha256").update(realFile).digest("hex").slice(0, 16);
3754
+ }
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))
3790
+ return;
3791
+ return { root, repository, relativeFile };
3792
+ } catch {
3793
+ return;
3803
3794
  }
3804
- return { entries, truncated: false };
3805
3795
  }
3806
3796
 
3807
- // ../daemon/src/proxy.ts
3808
- import http2 from "node:http";
3809
- import net2 from "node:net";
3810
- var HOP_BY_HOP = new Set(["connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade"]);
3811
- function forwardHeaders(req, target) {
3812
- const out = {};
3813
- for (const [k, v] of Object.entries(req.headers)) {
3814
- if (HOP_BY_HOP.has(k) || v === undefined)
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)
3815
3816
  continue;
3816
- out[k] = v;
3817
- }
3818
- if (target.via === "share") {
3819
- const host = net2.isIP(target.upstreamHost) === 6 ? `[${target.upstreamHost}]` : target.upstreamHost;
3820
- out.host = `${host}:${target.upstreamPort}`;
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 } : {}
3848
+ });
3821
3849
  }
3822
- const remote = req.socket.remoteAddress ?? "";
3823
- out["x-forwarded-for"] = req.headers["x-forwarded-for"] ? `${req.headers["x-forwarded-for"]}, ${remote}` : remote;
3824
- out["x-forwarded-proto"] = target.via === "share" ? "https" : "http";
3825
- out["x-forwarded-host"] = req.headers.host ?? "";
3826
- out["x-localdeck-service"] = target.service;
3827
- return out;
3850
+ return { environments, repository: location.repository, relativeConfig: location.relativeFile, sharedResourceNotice };
3828
3851
  }
3829
- function waitingPage(target) {
3830
- return `<!doctype html><meta charset="utf-8"><meta http-equiv="refresh" content="2">
3831
- <title>LocalDeck — waiting for ${target.service}</title>
3832
- <style>body{font:15px/1.5 system-ui;margin:0;display:grid;place-items:center;height:100vh;background:#0f1115;color:#e6e8ef}
3833
- .c{max-width:420px;text-align:center}code{background:#1c1f27;padding:2px 6px;border-radius:4px}</style>
3834
- <div class="c"><h1 style="font-weight:600">Waiting for <code>${target.service}</code></h1>
3835
- <p>LocalDeck is holding this port. Nothing is listening on the upstream port <code>${target.upstreamPort}</code> yet.
3836
- This page reloads every 2 seconds.</p></div>`;
3837
- }
3838
- function proxyRequest(req, res, target, hooks) {
3839
- const started = Date.now();
3840
- const requestHeaders = captureHeaders(req.rawHeaders);
3841
- const requestBody = new BodyCapture;
3842
- const responseBody = new BodyCapture;
3843
- const fullResponse = hooks.responseBodies?.start(target.service);
3844
- const captureResponse = (chunk) => {
3845
- responseBody.append(chunk);
3846
- fullResponse?.append(chunk);
3847
- };
3848
- let responseComplete = false;
3849
- let responseEncoding;
3850
- let responseHeaders;
3851
- let bytes = 0;
3852
- let recorded = false;
3853
- const record = (status, error) => {
3854
- if (recorded)
3855
- return;
3856
- recorded = true;
3857
- fullResponse?.finish(responseComplete, responseEncoding);
3858
- hooks.onRequest({
3859
- ts: started,
3860
- service: target.service,
3861
- method: req.method ?? "GET",
3862
- path: req.url ?? "/",
3863
- status,
3864
- durationMs: Date.now() - started,
3865
- bytes,
3866
- via: target.via,
3867
- error,
3868
- requestHeaders,
3869
- responseHeaders,
3870
- requestBody: requestBody.snapshot(req.readableEnded, req.headers["content-encoding"]),
3871
- responseBody: responseBody.snapshot(responseComplete, responseEncoding),
3872
- responseBodyId: fullResponse?.id
3873
- });
3874
- };
3875
- if (!target.upstreamReady) {
3876
- responseHeaders = captureHeaders(["content-type", "text/html; charset=utf-8", "retry-after", "2"]);
3877
- res.writeHead(503, { "content-type": "text/html; charset=utf-8", "retry-after": "2" });
3878
- const body = waitingPage(target);
3879
- captureResponse(Buffer.from(body));
3880
- responseComplete = true;
3881
- res.end(body);
3882
- record(503, "upstream not ready");
3883
- return;
3884
- }
3885
- let responseStatus = 502;
3886
- let response;
3887
- const upstream = http2.request({ host: target.upstreamHost, port: target.upstreamPort, method: req.method, path: req.url, headers: forwardHeaders(req, target) }, (up) => {
3888
- if (res.destroyed) {
3889
- up.destroy();
3890
- return;
3891
- }
3892
- response = up;
3893
- responseHeaders = captureHeaders(up.rawHeaders);
3894
- responseEncoding = up.headers["content-encoding"];
3895
- up.on("end", () => {
3896
- responseComplete = true;
3897
- });
3898
- responseStatus = up.statusCode ?? 502;
3899
- up.on("error", (error) => {
3900
- record(502, `upstream response error: ${error.message}`);
3901
- res.destroy();
3902
- });
3903
- up.on("aborted", () => {
3904
- record(502, "upstream response aborted");
3905
- res.destroy();
3906
- });
3907
- const headers = { ...up.headers };
3908
- res.writeHead(up.statusCode ?? 502, up.statusMessage, headers);
3909
- up.on("data", (chunk) => {
3910
- bytes += chunk.length;
3911
- captureResponse(chunk);
3912
- });
3913
- up.pipe(res);
3914
- });
3915
- upstream.on("error", (err) => {
3916
- if (!res.headersSent)
3917
- responseHeaders = captureHeaders(["content-type", "text/plain; charset=utf-8"]);
3918
- if (!res.headersSent && !res.destroyed) {
3919
- res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
3920
- const body = `LocalDeck: upstream ${target.service} (${target.upstreamHost}:${target.upstreamPort}) error: ${err.code ?? err.message}`;
3921
- captureResponse(Buffer.from(body));
3922
- responseComplete = true;
3923
- res.end(body);
3924
- } else
3925
- res.destroy();
3926
- record(502, err.code ?? err.message);
3927
- });
3928
- const clientDisconnected = () => {
3929
- record(499, "client disconnected before response completed");
3930
- response?.destroy();
3931
- upstream.destroy();
3932
- };
3933
- req.on("aborted", clientDisconnected);
3934
- res.on("finish", () => record(responseStatus));
3935
- res.on("close", () => {
3936
- if (!res.writableFinished)
3937
- clientDisconnected();
3938
- });
3939
- req.on("data", (chunk) => requestBody.append(chunk));
3940
- req.pipe(upstream);
3941
- }
3942
- function proxyUpgrade(req, socket, head, target, hooks) {
3943
- const started = Date.now();
3944
- socket.on("error", () => socket.destroy());
3945
- if (!target.upstreamReady) {
3946
- socket.end(`HTTP/1.1 503 Service Unavailable\r
3947
- Connection: close\r
3948
- \r
3949
- `);
3950
- return;
3951
- }
3952
- const up = net2.connect(target.upstreamPort, target.upstreamHost, () => {
3953
- const headers = forwardHeaders(req, target);
3954
- headers["connection"] = "Upgrade";
3955
- headers["upgrade"] = req.headers["upgrade"];
3956
- const lines = [`${req.method} ${req.url} HTTP/1.1`];
3957
- for (const [k, v] of Object.entries(headers)) {
3958
- if (v === undefined)
3959
- continue;
3960
- for (const val of Array.isArray(v) ? v : [String(v)])
3961
- lines.push(`${k}: ${val}`);
3962
- }
3963
- up.write(lines.join(`\r
3964
- `) + `\r
3965
- \r
3966
- `);
3967
- if (head.length)
3968
- up.write(head);
3969
- up.pipe(socket).pipe(up);
3970
- hooks.onRequest({
3971
- ts: started,
3972
- service: target.service,
3973
- method: req.method ?? "GET",
3974
- path: req.url ?? "/",
3975
- status: 101,
3976
- durationMs: Date.now() - started,
3977
- bytes: 0,
3978
- requestHeaders: captureHeaders(req.rawHeaders),
3979
- via: target.via
3980
- });
3981
- });
3982
- const kill = () => {
3983
- up.destroy();
3984
- socket.destroy();
3985
- };
3986
- up.on("error", kill);
3987
- socket.on("error", kill);
3988
- socket.on("close", () => up.destroy());
3989
- up.on("close", () => socket.destroy());
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);
3990
3860
  }
3991
3861
 
3992
- // ../daemon/src/projects.ts
3993
- import { createHash } from "node:crypto";
3994
- import fs4 from "node:fs";
3995
- import path4 from "node:path";
3862
+ // ../daemon/src/reproductions.ts
3863
+ import path5 from "node:path";
3864
+ import fs5 from "node:fs";
3865
+ import { randomUUID } from "node:crypto";
3996
3866
 
3997
- // ../daemon/src/ports.ts
3998
- import net3 from "node:net";
3999
- function canBind(port, host, required) {
4000
- return new Promise((resolve) => {
4001
- const srv = net3.createServer();
4002
- srv.once("error", (error) => resolve(!required && error.code !== "EADDRINUSE" && error.code !== "EACCES" ? undefined : false));
4003
- srv.listen({ port, host, exclusive: true }, () => srv.close(() => resolve(true)));
4004
- });
4005
- }
4006
- async function isPortFree(port, host) {
4007
- for (const candidate of host ? [host] : ["127.0.0.1", "::1", "0.0.0.0", "::"]) {
4008
- if (await canBind(port, candidate, candidate === (host ?? "127.0.0.1")) === false)
4009
- return false;
4010
- }
4011
- return true;
4012
- }
4013
- function getFreePort(host = "127.0.0.1") {
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");
4014
3880
  return new Promise((resolve, reject) => {
4015
- const srv = net3.createServer();
4016
- srv.once("error", reject);
4017
- srv.listen(0, host, () => {
4018
- const { port } = srv.address();
4019
- srv.close(() => resolve(port));
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")));
3914
+ });
3915
+ request.on("error", (error) => {
3916
+ clearTimeout(timer);
3917
+ if (!settled) {
3918
+ settled = true;
3919
+ reject(error);
3920
+ }
4020
3921
  });
3922
+ request.end();
4021
3923
  });
4022
3924
  }
4023
- function serviceName(key) {
4024
- const separator = key.lastIndexOf(":");
4025
- return separator === -1 ? key : key.slice(separator + 1);
4026
- }
4027
- function rememberedPortFromFile(key, file) {
4028
- const map = readJson(file, {});
4029
- if (map[key] !== undefined)
4030
- return map[key];
4031
- const name = serviceName(key);
4032
- const hasOtherQualifiedKey = Object.keys(map).some((candidate) => candidate !== key && candidate.includes(":") && candidate.endsWith(`:${name}`));
4033
- return hasOtherQualifiedKey ? undefined : map[name];
4034
- }
4035
- function rememberPort(name, port, file = PORTS_FILE) {
4036
- const map = readJson(file, {});
4037
- map[name] = port;
4038
- writeJson(file, map);
4039
- }
4040
- function forgetProjectPorts(projectId, file = PORTS_FILE) {
4041
- const map = readJson(file, {});
4042
- const kept = Object.fromEntries(Object.entries(map).filter(([key]) => !key.startsWith(`${projectId}:`)));
4043
- if (Object.keys(kept).length !== Object.keys(map).length)
4044
- writeJson(file, kept);
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;
4045
3936
  }
4046
- async function pickStablePort(name, taken, start = 4000, end = 4999, file = PORTS_FILE) {
4047
- const map = readJson(file, {});
4048
- const remembered = rememberedPortFromFile(name, file);
4049
- if (remembered) {
4050
- if (taken.has(remembered) || !await isPortFree(remembered)) {
4051
- throw new Error(`remembered stable port ${remembered} for "${name}" is already in use; stop the conflicting listener or explicitly choose a different port`);
4052
- }
4053
- return remembered;
4054
- }
4055
- const bareName = serviceName(name);
4056
- const usingLegacy = map[name] === undefined && remembered === map[bareName];
4057
- const reserved = new Set(Object.entries(map).filter(([n]) => n !== name && !(usingLegacy && n === bareName)).map(([, p]) => p));
4058
- for (let p = start;p <= end; p++) {
4059
- if (taken.has(p) || reserved.has(p))
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`);
4060
3969
  continue;
4061
- if (await isPortFree(p))
4062
- return p;
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();
3993
+ } else
3994
+ omitted.push("Unsupported body format omitted");
3995
+ if (body !== undefined && body !== captured.data)
3996
+ omitted.push("Body sanitized");
4063
3997
  }
4064
- throw new Error(`no free port between ${start} and ${end}`);
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 };
4065
4002
  }
4066
-
4067
- // ../daemon/src/projects.ts
4068
- class ProjectError extends Error {
4069
- code;
4070
- constructor(code, message) {
4071
- super(message);
4072
- this.code = code;
4003
+ function decodeURIComponentSafe(value) {
4004
+ try {
4005
+ return decodeURIComponent(value);
4006
+ } catch {
4007
+ return value;
4073
4008
  }
4074
4009
  }
4075
-
4076
- class ProjectRegistry {
4077
- catalogFile;
4078
- portsFile;
4079
- now;
4080
- projects;
4081
- constructor({ catalogFile = PROJECTS_FILE, portsFile = PORTS_FILE, now = Date.now } = {}) {
4082
- this.catalogFile = catalogFile;
4083
- this.portsFile = portsFile;
4084
- this.now = now;
4085
- this.projects = this.readCatalog();
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 [];
4086
4036
  }
4087
- remember(file, now = this.now()) {
4088
- const realFile = fs4.realpathSync(file);
4089
- const config = loadProjectFile(realFile);
4090
- const id = projectId(realFile);
4091
- const name = path4.basename(path4.dirname(realFile));
4092
- const directory = path4.dirname(realFile);
4093
- const existing = this.projects.find((entry) => entry.id === id);
4094
- if (config.nickname)
4095
- this.assertNicknameAvailable(id, config.nickname);
4096
- if (existing) {
4097
- existing.name = name;
4098
- existing.file = realFile;
4099
- existing.directory = directory;
4100
- existing.lastUsedAt = now;
4101
- existing.configNickname = config.nickname;
4102
- } else {
4103
- this.projects.push({
4104
- id,
4105
- name,
4106
- slug: this.assignSlug(name, id),
4107
- file: realFile,
4108
- directory,
4109
- lastUsedAt: now,
4110
- ...config.nickname ? { configNickname: config.nickname } : {}
4111
- });
4112
- }
4113
- this.persist();
4114
- return this.toSummary(this.byId(id));
4037
+ }
4038
+
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);
4115
4046
  }
4116
- list() {
4117
- return this.sorted().map((project) => this.toSummary(project));
4047
+ list(projectId2) {
4048
+ return structuredClone(this.items.filter((item) => item.projectId === projectId2));
4118
4049
  }
4119
- details(projectId) {
4120
- 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);
4121
4069
  try {
4122
- const loaded = this.load(project);
4123
- return { ...project, nickname: this.nickname(projectId), available: true, commands: loaded.commands, ...loaded.profiles ? { profiles: loaded.profiles } : {} };
4070
+ this.persist();
4124
4071
  } catch (error) {
4125
- return { ...project, available: false, error: errorMessage(error), commands: [] };
4072
+ this.items = before;
4073
+ throw error;
4126
4074
  }
4127
- }
4128
- resolve(projectId, serviceName2) {
4129
- const details = this.details(projectId);
4130
- if (!details.available)
4131
- throw new ProjectError("invalid", details.error);
4132
- const command = details.commands.find((candidate) => candidate.name === serviceName2.toLowerCase());
4133
- if (!command)
4134
- throw new ProjectError("not_found", `Project ${projectId} has no service named ${serviceName2}`);
4135
- return command;
4136
- }
4137
- forget(projectId, activeKeys) {
4138
- this.require(projectId);
4139
- if ([...activeKeys].some((key) => key.startsWith(`${projectId}:`))) {
4140
- throw new ProjectError("active", "stop its services before forgetting it");
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);
4141
4109
  }
4142
- this.projects = this.projects.filter((project) => project.id !== projectId);
4143
- this.persist();
4144
- forgetProjectPorts(projectId, this.portsFile);
4145
- }
4146
- refresh(projectId) {
4147
- return this.details(projectId);
4148
- }
4149
- nickname(projectId) {
4150
- const project = this.byId(projectId);
4151
- return project?.nickname ?? project?.configNickname;
4152
4110
  }
4153
- updateNickname(projectId, value) {
4154
- const project = this.require(projectId);
4155
- const nickname = value.trim().toLowerCase();
4156
- if (nickname && (!validNickname(nickname) || nickname === "standalone")) {
4157
- throw new ProjectError("invalid", "Use 1–63 letters, numbers or hyphens, with no leading or trailing hyphen. 'standalone' is reserved.");
4158
- }
4159
- if (nickname)
4160
- this.assertNicknameAvailable(projectId, nickname);
4161
- const previous = project.nickname;
4162
- if (nickname)
4163
- project.nickname = nickname;
4164
- else
4165
- delete project.nickname;
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;
4166
4118
  try {
4167
4119
  this.persist();
4168
4120
  } catch (error) {
4169
- if (previous === undefined)
4170
- delete project.nickname;
4171
- else
4172
- project.nickname = previous;
4121
+ this.items = previous;
4173
4122
  throw error;
4174
4123
  }
4175
- return this.details(projectId);
4124
+ return true;
4176
4125
  }
4177
- saveConfigNickname(projectId, value) {
4178
- const project = this.require(projectId);
4179
- const nickname = value.trim().toLowerCase();
4180
- if (nickname && (!validNickname(nickname) || nickname === "standalone"))
4181
- throw new ProjectError("invalid", "Use 1–63 letters, numbers or interior hyphens; standalone is reserved.");
4182
- if (nickname)
4183
- this.assertNicknameAvailable(projectId, nickname);
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);
4184
4152
  try {
4185
- updateProjectNickname(project.file, nickname);
4186
- } catch (error) {
4187
- throw new ProjectError("invalid", errorMessage(error));
4188
- }
4189
- project.configNickname = nickname || undefined;
4190
- return this.updateNickname(projectId, "");
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 {}
4191
4166
  }
4192
- updateDependencies(projectId, serviceName2, after) {
4193
- const project = this.require(projectId);
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;
4194
4208
  try {
4195
- updateProjectDependencies(project.file, serviceName2, after);
4196
- return this.details(projectId);
4209
+ text = fs7.readFileSync(file, "utf8");
4197
4210
  } catch (error) {
4198
- throw new ProjectError("invalid", errorMessage(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 });
4633
+ }
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
+ }
4199
4659
  }
4200
- }
4201
- updateServiceConfig(projectId, serviceName2, settings) {
4202
- const project = this.require(projectId);
4660
+ if ((await this.status()).ready)
4661
+ return { ready: true };
4203
4662
  try {
4204
- updateProjectServiceConfig(project.file, serviceName2, settings);
4205
- return this.details(projectId);
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." };
4206
4674
  } catch (error) {
4207
- 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 };
4208
4691
  }
4209
4692
  }
4210
- readCatalog() {
4211
- if (!fs4.existsSync(this.catalogFile))
4212
- return [];
4693
+ async uninstall() {
4694
+ if (this.enabling)
4695
+ return { ready: false, error: "Wait for the current setup to finish." };
4213
4696
  try {
4214
- const parsed = JSON.parse(fs4.readFileSync(this.catalogFile, "utf8"));
4215
- if (!isCatalog(parsed))
4216
- throw new Error("invalid project catalog");
4217
- return parsed.projects;
4218
- } catch {
4219
- fs4.renameSync(this.catalogFile, this.nextCorruptFile());
4220
- return [];
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 };
4706
+ } catch (error) {
4707
+ return { ready: false, error: error instanceof Error ? error.message : "Removal failed." };
4221
4708
  }
4222
4709
  }
4223
- assertNicknameAvailable(projectId, nickname) {
4224
- if (this.projects.some((other) => other.id !== projectId && (other.slug === nickname || other.nickname === nickname || other.configNickname === nickname))) {
4225
- throw new ProjectError("invalid", "That project nickname is already taken. Choose another.");
4710
+ async close() {
4711
+ this.closed = true;
4712
+ await this.enabling;
4713
+ if (this.bridge) {
4714
+ await this.bridge.close();
4715
+ this.bridge = undefined;
4226
4716
  }
4227
4717
  }
4228
- nextCorruptFile() {
4229
- const base = `${this.catalogFile}.corrupt-${this.now()}`;
4230
- let candidate = base;
4231
- let suffix = 2;
4232
- while (fs4.existsSync(candidate))
4233
- candidate = `${base}-${suffix++}`;
4234
- return candidate;
4235
- }
4236
- persist() {
4237
- writeJson(this.catalogFile, { version: 1, projects: this.sortedEntries() });
4238
- }
4239
- sorted() {
4240
- return this.sortedEntries().map((project) => ({ ...project }));
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;
4241
4736
  }
4242
- sortedEntries() {
4243
- return [...this.projects].sort((a, b) => b.lastUsedAt - a.lastUsedAt);
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;
4759
+ }
4244
4760
  }
4245
- 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";
4246
4776
  try {
4247
- this.load(project);
4248
- return { ...project, nickname: this.nickname(project.id), available: true };
4249
- } catch (error) {
4250
- return { ...project, available: false, error: errorMessage(error) };
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";
4251
4783
  }
4784
+ return { data, encoding, bytes: this.bytes, truncated, complete, ...encoded ? { contentEncoding } : {} };
4252
4785
  }
4253
- load(project) {
4254
- if (!fs4.existsSync(project.file))
4255
- throw new Error(`${project.file} does not exist`);
4256
- const loaded = loadProjectFile(project.file);
4257
- if (loaded.nickname)
4258
- this.assertNicknameAvailable(project.id, loaded.nickname);
4259
- project.configNickname = loaded.nickname;
4260
- return loaded;
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]);
4261
4801
  }
4262
- require(projectId) {
4263
- const project = this.byId(projectId);
4264
- if (!project)
4265
- throw new ProjectError("not_found", `Project ${projectId} was not found`);
4266
- return project;
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;
4267
4815
  }
4268
- byId(projectId) {
4269
- return this.projects.find((project) => project.id === projectId);
4816
+ if (target.via === "share") {
4817
+ const host = net3.isIP(target.upstreamHost) === 6 ? `[${target.upstreamHost}]` : target.upstreamHost;
4818
+ out.host = `${host}:${target.upstreamPort}`;
4270
4819
  }
4271
- assignSlug(name, id) {
4272
- const base = slugify(name) || "project";
4273
- if (!this.projects.some((project) => project.slug === base || project.nickname === base || project.configNickname === base))
4274
- return base;
4275
- const suffix = id.slice(0, 6);
4276
- let slug = `${base}-${suffix}`;
4277
- let collision = 2;
4278
- while (this.projects.some((project) => project.slug === slug || project.nickname === slug || project.configNickname === slug))
4279
- slug = `${base}-${suffix}-${collision++}`;
4280
- return slug;
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;
4826
+ }
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>`;
4835
+ }
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;
4281
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);
4282
4939
  }
4283
- function projectId(realFile) {
4284
- return createHash("sha256").update(realFile).digest("hex").slice(0, 16);
4285
- }
4286
- function slugify(value) {
4287
- return value.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
4288
- }
4289
- function isCatalog(value) {
4290
- if (!value || typeof value !== "object")
4291
- return false;
4292
- const catalog = value;
4293
- return catalog.version === 1 && Array.isArray(catalog.projects) && catalog.projects.every(isRememberedProject);
4294
- }
4295
- function isRememberedProject(value) {
4296
- if (!value || typeof value !== "object")
4297
- return false;
4298
- const project = value;
4299
- if (!["id", "name", "slug", "file", "directory"].every((key) => typeof project[key] === "string"))
4300
- return false;
4301
- const { id, slug, file, directory } = project;
4302
- return path4.isAbsolute(file) && path4.normalize(file) === file && directory === path4.dirname(file) && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(slug) && /^[a-f0-9]{16}$/.test(id) && (project.nickname === undefined || typeof project.nickname === "string" && validNickname(project.nickname)) && (project.configNickname === undefined || typeof project.configNickname === "string" && validNickname(project.configNickname)) && id === projectId(file) && typeof project.lastUsedAt === "number" && Number.isFinite(project.lastUsedAt);
4303
- }
4304
- function validNickname(value) {
4305
- return value.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value);
4306
- }
4307
- function errorMessage(error) {
4308
- 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());
4309
4988
  }
4310
4989
 
4311
4990
  // ../daemon/src/share.ts
4312
- import http3 from "node:http";
4991
+ import http4 from "node:http";
4313
4992
 
4314
4993
  // ../daemon/src/share-readiness.ts
4315
4994
  import { setTimeout as delay } from "node:timers/promises";
@@ -4518,7 +5197,7 @@ class ShareManager {
4518
5197
  const publicRequest = new Promise((resolve) => {
4519
5198
  confirmPublicRequest = resolve;
4520
5199
  });
4521
- const gate = http3.createServer((req, res) => {
5200
+ const gate = http4.createServer((req, res) => {
4522
5201
  if (publicHost && req.headers.host?.toLowerCase() === publicHost)
4523
5202
  confirmPublicRequest();
4524
5203
  if (req.method === "GET" && req.url === `/__localdeck/ready/${probe}`) {
@@ -4538,7 +5217,7 @@ class ShareManager {
4538
5217
  req.resume();
4539
5218
  return;
4540
5219
  }
4541
- let body = "";
5220
+ let body2 = "";
4542
5221
  let bytes = 0;
4543
5222
  let tooLarge = false;
4544
5223
  req.on("data", (c) => {
@@ -4547,16 +5226,16 @@ class ShareManager {
4547
5226
  bytes += c.length;
4548
5227
  if (bytes > MAX_LOGIN_BYTES) {
4549
5228
  tooLarge = true;
4550
- body = "";
5229
+ body2 = "";
4551
5230
  } else
4552
- body += c;
5231
+ body2 += c;
4553
5232
  });
4554
5233
  req.on("end", () => {
4555
5234
  if (tooLarge) {
4556
5235
  res.writeHead(413, { "content-type": "text/plain; charset=utf-8" }).end("LocalDeck: login request is too large");
4557
5236
  return;
4558
5237
  }
4559
- const given = new URLSearchParams(body).get("password") ?? "";
5238
+ const given = new URLSearchParams(body2).get("password") ?? "";
4560
5239
  const ok = passwordsMatch(given, password);
4561
5240
  if (ok) {
4562
5241
  const s = this.shares.get(key);
@@ -4982,288 +5661,80 @@ function streamLines(stream, onLine) {
4982
5661
  if (remaining)
4983
5662
  onLine(remaining);
4984
5663
  };
4985
- const consume = (text) => {
4986
- buffered += text;
4987
- const lines = buffered.split(/\r?\n/);
4988
- buffered = lines.pop() ?? "";
4989
- for (const line of lines) {
4990
- emitBounded(line, partialLineEmitted);
4991
- partialLineEmitted = false;
4992
- }
4993
- buffered = emitUtf8Prefixes(buffered, (line) => {
4994
- onLine(line);
4995
- partialLineEmitted = true;
4996
- });
4997
- };
4998
- stream.on("data", (chunk) => consume(typeof chunk === "string" ? chunk : decoder.write(chunk)));
4999
- stream.on("end", () => {
5000
- consume(decoder.end());
5001
- emitBounded(buffered, partialLineEmitted);
5002
- buffered = "";
5003
- partialLineEmitted = false;
5004
- });
5005
- }
5006
- function emitUtf8Prefixes(value, onPrefix) {
5007
- if (Buffer.byteLength(value) <= MAX_STREAM_LINE_BYTES)
5008
- return value;
5009
- let bytes = 0;
5010
- let start = 0;
5011
- for (let index = 0;index < value.length; ) {
5012
- const codePoint = value.codePointAt(index);
5013
- const width = codePoint <= 127 ? 1 : codePoint <= 2047 ? 2 : codePoint <= 65535 ? 3 : 4;
5014
- if (bytes + width > MAX_STREAM_LINE_BYTES) {
5015
- onPrefix(value.slice(start, index));
5016
- start = index;
5017
- bytes = 0;
5018
- }
5019
- bytes += width;
5020
- index += codePoint > 65535 ? 2 : 1;
5021
- }
5022
- return value.slice(start);
5023
- }
5024
- async function defaultTaskkillRunner(file, args, options) {
5025
- await exec2(file, args, options);
5026
- }
5027
- async function killProcessTree(child, signal, platform = process.platform, runTaskkill = defaultTaskkillRunner) {
5028
- if (!child.pid)
5029
- return false;
5030
- if (platform === "win32") {
5031
- const args = ["/pid", String(child.pid), "/t"];
5032
- if (signal === "SIGKILL")
5033
- args.push("/f");
5034
- try {
5035
- await runTaskkill("taskkill.exe", args, { timeout: 5000, killSignal: "SIGKILL" });
5036
- return true;
5037
- } catch {
5038
- try {
5039
- return child.kill(signal);
5040
- } catch {
5041
- return false;
5042
- }
5043
- }
5044
- }
5045
- try {
5046
- return process.kill(-child.pid, signal);
5047
- } catch {
5048
- try {
5049
- return child.kill(signal);
5050
- } catch {
5051
- return false;
5052
- }
5053
- }
5054
- }
5055
- var ANSI_RE = /[\u001B\u009B][[\]()#;?]*(?:(?:(?:[a-zA-Z\d]*(?:;[-a-zA-Z\d\/#&.:=?%@~_]*)*)?\u0007)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-ntqry=><~]))/g;
5056
- function stripAnsi(value) {
5057
- return value.replace(ANSI_RE, "");
5058
- }
5059
-
5060
- // ../daemon/src/diagnostics.ts
5061
- import fs5 from "node:fs";
5062
- import os3 from "node:os";
5063
- import path5 from "node:path";
5064
- function redactDiagnostic(text, secrets = []) {
5065
- let output = text.replace(/-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/g, "[REDACTED PRIVATE KEY]").replace(/(authorization["']?\s*[:=]\s*["']?)(?:Bearer|Basic)\s+[^\s,"'}]+/gi, "$1[REDACTED]").replace(/((?:set-cookie|cookie)["']?\s*[:=]\s*)[^\r\n]+/gi, "$1[REDACTED]").replace(/(?<![a-z0-9+.-])([a-z][a-z0-9+.-]*:\/\/)([^\s/@]+)@/gi, (match, scheme, credentials) => credentials.includes(":") ? `${scheme}[REDACTED]@` : match).replace(/(?<![\w-])(?=[\w-]*(?:password|passwd|secret|token|api[_-]?key|credential))([\w-]+["']?\s*[:=]\s*)(?:"[^"\n]*"|'[^'\n]*'|[^\s,;&}\n]+)/gi, "$1[REDACTED]").replace(/((?:--)(?:password|token|secret|api-key)\s+)(?:"[^"]*"|'[^']*'|\S+)/gi, "$1[REDACTED]");
5066
- for (const secret of [...new Set(secrets)].filter(Boolean).sort((a, b) => b.length - a.length))
5067
- output = output.replaceAll(secret, "[REDACTED]");
5068
- const home = os3.homedir();
5069
- if (home.length > 1)
5070
- output = output.replaceAll(home, "<home>");
5071
- return output;
5072
- }
5073
- function privateEnvironmentValue(name, value) {
5074
- if (name === "NODE_ENV" && /^(development|production|test)$/.test(value))
5075
- return false;
5076
- if (/^(?:PORT|[A-Z_]+_PORT)$/.test(name) && /^\d{1,5}$/.test(value) && Number(value) > 0 && Number(value) <= 65535)
5077
- return false;
5078
- return true;
5079
- }
5080
- function environmentValues(directory) {
5081
- const values = [];
5082
- for (const name of [".env", ".env.local", ".env.development", ".env.development.local"]) {
5083
- const file = path5.join(directory, name);
5084
- try {
5085
- const stat = fs5.lstatSync(file);
5086
- if (!stat.isFile() || stat.size > 65536)
5087
- continue;
5088
- const text = fs5.readFileSync(file, "utf8");
5089
- for (const line of text.split(/\r?\n/)) {
5090
- const match = line.match(/^\s*(?:export\s+)?([A-Za-z_][\w]*)\s*=\s*(.*)$/);
5091
- if (match) {
5092
- const value = match[2].trim().replace(/^(['"])(.*)\1$/, "$2");
5093
- if (privateEnvironmentValue(match[1], value))
5094
- values.push(value);
5095
- }
5096
- }
5097
- } catch {}
5098
- }
5099
- return values;
5100
- }
5101
- function diagnosticReport(project, services, logs, version, privateValues = []) {
5102
- const inheritedSecrets = Object.entries(process.env).filter(([name]) => /password|passwd|secret|token|api[_-]?key|credential/i.test(name)).flatMap(([, value]) => value ? [value] : []);
5103
- const directories = new Set([project.directory, ...project.commands.map((command) => path5.resolve(project.directory, command.cwd ?? "."))]);
5104
- const secrets = [...privateValues, ...inheritedSecrets, ...[...directories].flatMap(environmentValues), ...project.commands.flatMap((command) => Object.entries(command.env ?? {}).filter(([name, value]) => privateEnvironmentValue(name, value)).map(([, value]) => value))];
5105
- const report = {
5106
- notice: "Review before sharing. Known secrets are redacted, but application logs may contain other private data.",
5107
- version,
5108
- project: { name: project.name, available: project.available, error: project.error },
5109
- configuration: project.commands.map(({ env, ...command }) => ({ ...command, environmentVariables: Object.keys(env ?? {}) })),
5110
- services: services.map((service) => ({ name: service.name, status: service.status, readiness: service.readinessStatus, message: service.readinessMessage, lastError: service.lastError, serviceType: service.serviceType ?? "http", port: service.stablePort })),
5111
- logs: logs.slice(-100).map(({ stream, line, ts, service }) => ({ service, ts, stream, line: line.slice(0, 2000) }))
5112
- };
5113
- const sanitize = (value) => typeof value === "string" ? redactDiagnostic(value, secrets) : Array.isArray(value) ? value.map(sanitize) : value && typeof value === "object" ? Object.fromEntries(Object.entries(value).map(([key, item]) => [key, sanitize(item)])) : value;
5114
- return JSON.stringify(sanitize(report), null, 2);
5115
- }
5116
-
5117
- // ../daemon/src/replay.ts
5118
- import http4 from "node:http";
5119
- function replayRequest(service, record, options = {}) {
5120
- if (record.service !== service.key || !["GET", "HEAD"].includes(record.method))
5121
- throw new Error("Only recorded GET and HEAD requests for this service can be replayed");
5122
- if (service.status !== "running" || !service.upstreamReady || service.serviceType && service.serviceType !== "http")
5123
- throw new Error("The HTTP service must be running before replay");
5124
- if (!["127.0.0.1", "::1"].includes(service.upstreamHost) || !record.path.startsWith("/") || record.path.startsWith("//") || /[\r\n\\\x00-\x20\x7f]/.test(record.path))
5125
- throw new Error("Request path must be a local HTTP path");
5126
- const timeoutMs = options.timeoutMs ?? 5000;
5127
- const maxBytes = options.maxBytes ?? 65536;
5128
- if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 5000 || !Number.isInteger(maxBytes) || maxBytes < 1 || maxBytes > 65536)
5129
- throw new Error("Replay limits must be positive integers within 5000 ms and 65536 bytes");
5130
- return new Promise((resolve, reject) => {
5131
- const started = Date.now();
5132
- let settled = false;
5133
- let bytes = 0;
5134
- const chunks = [];
5135
- const request = http4.request({ host: service.upstreamHost, port: service.upstreamPort, path: record.path, method: record.method, headers: { "x-localdeck-replay": "1" } });
5136
- const timer = setTimeout(() => {
5137
- if (!settled) {
5138
- settled = true;
5139
- reject(new Error("Replay timed out"));
5140
- }
5141
- request.destroy();
5142
- }, timeoutMs);
5143
- const finish = (status, truncated) => {
5144
- if (settled)
5145
- return;
5146
- settled = true;
5147
- clearTimeout(timer);
5148
- resolve({ status, truncated, body: Buffer.concat(chunks).toString("utf8"), durationMs: Date.now() - started });
5149
- };
5150
- request.on("response", (response) => {
5151
- response.on("data", (chunk) => {
5152
- const remaining = Math.max(0, maxBytes - bytes);
5153
- chunks.push(chunk.subarray(0, remaining));
5154
- bytes += chunk.length;
5155
- if (bytes > maxBytes) {
5156
- finish(response.statusCode ?? 502, true);
5157
- response.destroy();
5158
- request.destroy();
5159
- }
5160
- });
5161
- response.on("end", () => finish(response.statusCode ?? 502, false));
5162
- response.on("error", (error) => request.destroy(error));
5163
- response.on("aborted", () => request.destroy(new Error("Replay response was interrupted")));
5164
- });
5165
- request.on("error", (error) => {
5166
- clearTimeout(timer);
5167
- if (!settled) {
5168
- settled = true;
5169
- reject(error);
5170
- }
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;
5671
+ }
5672
+ buffered = emitUtf8Prefixes(buffered, (line) => {
5673
+ onLine(line);
5674
+ partialLineEmitted = true;
5171
5675
  });
5172
- request.end();
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;
5173
5683
  });
5174
5684
  }
5175
-
5176
- // ../daemon/src/project-secrets.ts
5177
- import fs6 from "node:fs";
5178
- import path6 from "node:path";
5179
- var ID = /^[a-f0-9]{16}$/;
5180
- var NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
5181
-
5182
- class ProjectSecretError extends Error {
5183
- }
5184
- function validateName(name) {
5185
- if (!NAME.test(name) || name === "PORT" || name.startsWith("LOCALDECK_")) {
5186
- throw new ProjectSecretError("invalid secret name");
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;
5187
5700
  }
5701
+ return value.slice(start);
5188
5702
  }
5189
- function createProjectSecretStore(home = HOME_DIR) {
5190
- const directory = path6.join(home, "secrets");
5191
- function secretPath(projectId2) {
5192
- if (!ID.test(projectId2))
5193
- throw new ProjectSecretError("invalid project ID");
5194
- return path6.join(directory, `${projectId2}.json`);
5195
- }
5196
- function read(projectId2) {
5197
- const file = secretPath(projectId2);
5198
- let text;
5199
- try {
5200
- text = fs6.readFileSync(file, "utf8");
5201
- } catch (error) {
5202
- if (error.code === "ENOENT")
5203
- return Object.create(null);
5204
- throw new ProjectSecretError("Could not read stored secrets");
5205
- }
5206
- let parsed;
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");
5207
5713
  try {
5208
- parsed = JSON.parse(text);
5714
+ await runTaskkill("taskkill.exe", args, { timeout: 5000, killSignal: "SIGKILL" });
5715
+ return true;
5209
5716
  } catch {
5210
- throw new ProjectSecretError("stored secrets are corrupt");
5211
- }
5212
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
5213
- throw new ProjectSecretError("stored secrets are corrupt");
5214
- }
5215
- const values = Object.create(null);
5216
- for (const [name, value] of Object.entries(parsed)) {
5217
- validateName(name);
5218
- if (typeof value !== "string" || !value || value.includes("\x00")) {
5219
- throw new ProjectSecretError("stored secrets are corrupt");
5717
+ try {
5718
+ return child.kill(signal);
5719
+ } catch {
5720
+ return false;
5220
5721
  }
5221
- values[name] = value;
5222
- }
5223
- return values;
5224
- }
5225
- function write(projectId2, values) {
5226
- const file = secretPath(projectId2);
5227
- fs6.mkdirSync(directory, { recursive: true, mode: 448 });
5228
- if (process.platform !== "win32")
5229
- fs6.chmodSync(directory, 448);
5230
- writePrivateJson(file, values);
5231
- }
5232
- function listProjectSecrets(projectId2) {
5233
- return Object.keys(read(projectId2)).sort();
5234
- }
5235
- function setProjectSecret(projectId2, name, value) {
5236
- validateName(name);
5237
- if (typeof value !== "string" || !value || value.includes("\x00")) {
5238
- throw new ProjectSecretError("secret value must be a nonempty string without NUL");
5239
5722
  }
5240
- const values = read(projectId2);
5241
- values[name] = value;
5242
- write(projectId2, values);
5243
- }
5244
- function deleteProjectSecret(projectId2, name) {
5245
- validateName(name);
5246
- const values = read(projectId2);
5247
- delete values[name];
5248
- write(projectId2, values);
5249
5723
  }
5250
- function resolveProjectSecrets(projectId2, names) {
5251
- const output = Object.create(null);
5252
- if (!names.length)
5253
- return output;
5254
- const values = read(projectId2);
5255
- for (const name of names) {
5256
- validateName(name);
5257
- if (!Object.prototype.hasOwnProperty.call(values, name)) {
5258
- throw new ProjectSecretError(`Add ${name} in Project → Secrets.`);
5259
- }
5260
- output[name] = values[name];
5724
+ try {
5725
+ return process.kill(-child.pid, signal);
5726
+ } catch {
5727
+ try {
5728
+ return child.kill(signal);
5729
+ } catch {
5730
+ return false;
5261
5731
  }
5262
- return output;
5263
5732
  }
5264
- return { listProjectSecrets, setProjectSecret, deleteProjectSecret, resolveProjectSecrets };
5265
5733
  }
5266
- 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
+ }
5267
5738
 
5268
5739
  // ../daemon/src/api.ts
5269
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" };
@@ -5272,9 +5743,9 @@ var MAX_ATTACH_BYTES = 65536;
5272
5743
  var MAX_ATTACH_CWD_CHARS = 4096;
5273
5744
  var MAX_ATTACH_COMMAND_CHARS = 16384;
5274
5745
  var MAX_ATTACH_LOG_CHARS = 16384;
5275
- function json(res, status, body) {
5746
+ function json(res, status, body2) {
5276
5747
  res.writeHead(status, { "content-type": "application/json" });
5277
- res.end(JSON.stringify(body));
5748
+ res.end(JSON.stringify(body2));
5278
5749
  }
5279
5750
 
5280
5751
  class HttpError extends Error {
@@ -5423,7 +5894,7 @@ function createApiServer(deps) {
5423
5894
  const { registry, store, shares } = deps;
5424
5895
  const allowedBrowserOrigins = validatedBrowserOrigins(deps.allowedBrowserOrigins);
5425
5896
  const hooks = { responseBodies: store.responseBodies, onRequest: (r) => store.request(r) };
5426
- const forwarderIdentity = randomUUID();
5897
+ const forwarderIdentity = randomUUID2();
5427
5898
  const forwarder = new CleanUrls(() => {
5428
5899
  const address = server.address();
5429
5900
  if (!address || typeof address === "string")
@@ -5463,10 +5934,10 @@ function createApiServer(deps) {
5463
5934
  if (req.method === "DELETE")
5464
5935
  return json(res, 200, await forwarder.uninstall());
5465
5936
  if (req.method === "POST") {
5466
- const body = await readBody(req);
5467
- if (!body || typeof body !== "object" || Array.isArray(body) || Object.keys(body).some((key) => !["install", "automatic"].includes(key)) || body.install !== undefined && typeof body.install !== "boolean" || body.automatic !== undefined && typeof body.automatic !== "boolean")
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")
5468
5939
  throw new HttpError(400, "invalid forwarding options");
5469
- return json(res, 200, await forwarder.enable(body));
5940
+ return json(res, 200, await forwarder.enable(body2));
5470
5941
  }
5471
5942
  return json(res, 200, await forwarder.status());
5472
5943
  }
@@ -5593,6 +6064,8 @@ async function handleApi(req, res, url, parts, deps) {
5593
6064
  stopping.catch(() => {});
5594
6065
  return;
5595
6066
  }
6067
+ if (await handleDebuggingApi(req, res, url, parts, deps))
6068
+ return;
5596
6069
  if (parts[0] === "projects") {
5597
6070
  if (parts.length === 1 && method === "GET")
5598
6071
  return json(res, 200, projects.list());
@@ -5600,11 +6073,11 @@ async function handleApi(req, res, url, parts, deps) {
5600
6073
  requireMutationAllowed(deps);
5601
6074
  if (!hasControlToken(req, deps.controlToken))
5602
6075
  throw new HttpError(403, "forbidden");
5603
- const body = await readBody(req);
6076
+ const body2 = await readBody(req);
5604
6077
  requireMutationAllowed(deps);
5605
- if (!body || typeof body !== "object" || Array.isArray(body))
6078
+ if (!body2 || typeof body2 !== "object" || Array.isArray(body2))
5606
6079
  throw new HttpError(400, "request body must be a JSON object");
5607
- const file = body.file;
6080
+ const file = body2.file;
5608
6081
  if (typeof file !== "string")
5609
6082
  throw new HttpError(400, "file must be a string");
5610
6083
  try {
@@ -5634,22 +6107,22 @@ async function handleApi(req, res, url, parts, deps) {
5634
6107
  throw new ProjectError("invalid", project.error ?? "project configuration is invalid");
5635
6108
  const name = parts[3];
5636
6109
  if (method === "PUT") {
5637
- const body = await readBody(req);
5638
- if (!body || typeof body !== "object" || Array.isArray(body) || Object.keys(body).length !== 1 || typeof body.value !== "string")
6110
+ const body2 = await readBody(req);
6111
+ if (!body2 || typeof body2 !== "object" || Array.isArray(body2) || Object.keys(body2).length !== 1 || typeof body2.value !== "string")
5639
6112
  throw new HttpError(400, 'request body must contain only a string "value"');
5640
- (deps.setProjectSecret ?? setProjectSecret)(projectId2, name, body.value);
6113
+ (deps.setProjectSecret ?? setProjectSecret)(projectId2, name, body2.value);
5641
6114
  } else
5642
6115
  (deps.deleteProjectSecret ?? deleteProjectSecret)(projectId2, name);
5643
6116
  return json(res, 200, { ok: true });
5644
6117
  }
5645
6118
  if (parts.length === 3 && parts[2] === "nickname" && method === "PATCH") {
5646
6119
  requireMutationAllowed(deps);
5647
- const body = await readBody(req);
6120
+ const body2 = await readBody(req);
5648
6121
  requireMutationAllowed(deps);
5649
- 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") {
5650
6123
  throw new HttpError(400, 'request body must contain only a string "nickname"');
5651
6124
  }
5652
- const details = projects.saveConfigNickname(projectId2, body.nickname);
6125
+ const details = projects.saveConfigNickname(projectId2, body2.nickname);
5653
6126
  registry.refreshProjectUrls(projectId2);
5654
6127
  return json(res, 200, { ...details, services: registry.list(projectId2) });
5655
6128
  }
@@ -5703,14 +6176,14 @@ async function handleApi(req, res, url, parts, deps) {
5703
6176
  }
5704
6177
  if (parts.length === 5 && parts[2] === "services" && parts[4] === "dependencies" && method === "PATCH") {
5705
6178
  requireMutationAllowed(deps);
5706
- const body = await readBody(req);
6179
+ const body2 = await readBody(req);
5707
6180
  requireMutationAllowed(deps);
5708
- if (!body || typeof body !== "object" || Array.isArray(body))
6181
+ if (!body2 || typeof body2 !== "object" || Array.isArray(body2))
5709
6182
  throw new HttpError(400, "request body must be a JSON object");
5710
- const fields = Object.keys(body);
6183
+ const fields = Object.keys(body2);
5711
6184
  if (fields.length !== 1 || fields[0] !== "after")
5712
6185
  throw new HttpError(400, 'request body may contain only "after"');
5713
- const after = body.after;
6186
+ const after = body2.after;
5714
6187
  if (!Array.isArray(after) || !after.every((dependency) => typeof dependency === "string")) {
5715
6188
  throw new HttpError(400, "after must be an array of service names");
5716
6189
  }
@@ -5768,11 +6241,11 @@ async function handleApi(req, res, url, parts, deps) {
5768
6241
  }
5769
6242
  if (sub === "replay" && method === "POST") {
5770
6243
  requireMutationAllowed(deps);
5771
- const body = await readBody(req);
6244
+ const body2 = await readBody(req);
5772
6245
  requireMutationAllowed(deps);
5773
- 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")
5774
6247
  throw new HttpError(400, "Provide only a recorded requestId");
5775
- 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);
5776
6249
  if (!record)
5777
6250
  throw new HttpError(404, "Recorded request is no longer available");
5778
6251
  try {
@@ -5814,11 +6287,11 @@ async function handleApi(req, res, url, parts, deps) {
5814
6287
  requireMutationAllowed(deps);
5815
6288
  if (svc.serviceType && svc.serviceType !== "http")
5816
6289
  throw new HttpError(422, "Database endpoints cannot be shared over HTTP");
5817
- const body = await readBody(req);
6290
+ const body2 = await readBody(req);
5818
6291
  requireMutationAllowed(deps);
5819
- if (!body || typeof body !== "object" || Array.isArray(body))
6292
+ if (!body2 || typeof body2 !== "object" || Array.isArray(body2))
5820
6293
  throw new HttpError(400, "request body must be a JSON object");
5821
- const { password, public: isPublic } = body;
6294
+ const { password, public: isPublic } = body2;
5822
6295
  if (password !== undefined && typeof password !== "string")
5823
6296
  throw new HttpError(400, "password must be a string");
5824
6297
  if (isPublic !== undefined && typeof isPublic !== "boolean")
@@ -5851,20 +6324,20 @@ function serveStatic(pathname, res, dir) {
5851
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>");
5852
6325
  return;
5853
6326
  }
5854
- let file = path7.join(dir, path7.normalize(pathname).replace(/^(\.\.[/\\])+/, ""));
6327
+ let file = path10.join(dir, path10.normalize(pathname).replace(/^(\.\.[/\\])+/, ""));
5855
6328
  if (!file.startsWith(dir))
5856
- file = path7.join(dir, "index.html");
5857
- if (!fs7.existsSync(file) || fs7.statSync(file).isDirectory())
5858
- file = path7.join(dir, "index.html");
5859
- const hashedAsset = path7.dirname(file) === path7.join(dir, "assets") && /-[A-Za-z0-9_-]{8,}\.[a-z0-9]+$/i.test(path7.basename(file));
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));
5860
6333
  res.writeHead(200, {
5861
- "content-type": MIME[path7.extname(file)] ?? "application/octet-stream",
6334
+ "content-type": MIME[path10.extname(file)] ?? "application/octet-stream",
5862
6335
  "cache-control": hashedAsset ? "public, max-age=31536000, immutable" : "no-cache",
5863
6336
  "x-frame-options": "DENY",
5864
6337
  "content-security-policy": "frame-ancestors 'none'",
5865
6338
  "x-content-type-options": "nosniff"
5866
6339
  });
5867
- fs7.createReadStream(file).pipe(res);
6340
+ fs9.createReadStream(file).pipe(res);
5868
6341
  }
5869
6342
  function handleAttach(ws, deps) {
5870
6343
  const { registry, store } = deps;
@@ -6075,7 +6548,7 @@ function buildRegistration(message, deps) {
6075
6548
  if (command.serviceType && command.serviceType !== "http" && (message.upstreamPort !== command.upstream || message.stablePort !== undefined)) {
6076
6549
  throw new Error("database registration must use its configured published upstream port");
6077
6550
  }
6078
- 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;
6079
6552
  requireDirectory(cwd);
6080
6553
  return {
6081
6554
  key: serviceKey(project.id, command.name),
@@ -6095,7 +6568,7 @@ function buildRegistration(message, deps) {
6095
6568
  }
6096
6569
  function requireDirectory(cwd) {
6097
6570
  try {
6098
- if (!fs7.statSync(cwd).isDirectory())
6571
+ if (!fs9.statSync(cwd).isDirectory())
6099
6572
  throw new Error("not a directory");
6100
6573
  } catch {
6101
6574
  throw new Error(`working directory does not exist or is not a directory: ${cwd}`);
@@ -6564,7 +7037,12 @@ class Registry {
6564
7037
  }
6565
7038
 
6566
7039
  // ../daemon/src/store.ts
6567
- import fs8 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";
6568
7046
 
6569
7047
  // ../daemon/src/history.ts
6570
7048
  function isAfterHistoryBoundary(id, boundary) {
@@ -6575,8 +7053,231 @@ function isAfterHistoryBoundary(id, boundary) {
6575
7053
  return started > boundaryStarted || started === boundaryStarted && sequence > boundarySequence;
6576
7054
  }
6577
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
+
6578
7279
  // ../daemon/src/response-bodies.ts
6579
- import { randomUUID as randomUUID2 } from "node:crypto";
7280
+ import { randomUUID as randomUUID4 } from "node:crypto";
6580
7281
  var RESPONSE_CACHE_BYTES = 64 * 1024 * 1024;
6581
7282
  var MAX_CAPTURES = 5000;
6582
7283
 
@@ -6588,7 +7289,7 @@ class ResponseBodies {
6588
7289
  this.capacity = capacity;
6589
7290
  }
6590
7291
  start(service) {
6591
- const id = randomUUID2();
7292
+ const id = randomUUID4();
6592
7293
  while (this.entries.size >= MAX_CAPTURES)
6593
7294
  this.remove(this.entries.keys().next().value);
6594
7295
  this.entries.set(id, { service, body: new BodyCapture(this.capacity), bytes: 0, finished: false, complete: false });
@@ -6643,8 +7344,8 @@ class ResponseBodies {
6643
7344
  }
6644
7345
 
6645
7346
  // ../daemon/src/store.ts
6646
- import path8 from "node:path";
6647
- import { createHash as createHash2 } from "node:crypto";
7347
+ import path12 from "node:path";
7348
+ import { createHash as createHash4 } from "node:crypto";
6648
7349
  import { EventEmitter } from "node:events";
6649
7350
  import { finished } from "node:stream/promises";
6650
7351
  import { Writable } from "node:stream";
@@ -6706,6 +7407,7 @@ function emptyStats() {
6706
7407
 
6707
7408
  class Store extends EventEmitter {
6708
7409
  responseBodies = new ResponseBodies;
7410
+ activity;
6709
7411
  buckets = new Map;
6710
7412
  logsDir;
6711
7413
  daemonStartedAt;
@@ -6721,38 +7423,39 @@ class Store extends EventEmitter {
6721
7423
  this.logsDir = options.logsDir ?? LOGS_DIR;
6722
7424
  this.daemonStartedAt = options.daemonStartedAt ?? Date.now();
6723
7425
  this.closeAfterMs = options.closeAfterMs ?? DEFAULT_CLOSE_AFTER_MS;
6724
- this.streamFactory = options.streamFactory ?? ((file, streamOptions) => fs8.createWriteStream(file, streamOptions));
7426
+ this.streamFactory = options.streamFactory ?? ((file, streamOptions) => fs11.createWriteStream(file, streamOptions));
6725
7427
  this.maxFileBytes = Math.max(1, options.maxFileBytes ?? 5 * 1024 * 1024);
6726
7428
  this.maxHistoryBytes = Math.max(1, options.maxHistoryBytes ?? 8 * 1024 * 1024);
6727
7429
  if (options.logsDir === undefined)
6728
7430
  ensureDirs();
6729
7431
  else
6730
- fs8.mkdirSync(this.logsDir, { recursive: true });
7432
+ fs11.mkdirSync(this.logsDir, { recursive: true });
7433
+ this.activity = new Activity(this, this.logsDir);
6731
7434
  }
6732
7435
  nextId() {
6733
7436
  return `${this.daemonStartedAt.toString(36)}-${(++this.sequence).toString(36)}`;
6734
7437
  }
6735
7438
  compatibilityId(type, service, offset, serialized) {
6736
- 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");
6737
7440
  return `legacy-${hash}`;
6738
7441
  }
6739
7442
  historyFile(service, type) {
6740
7443
  const suffix = type === "log" ? "log.jsonl" : "requests.jsonl";
6741
- return path8.join(this.logsDir, `${encodeURIComponent(service)}.${suffix}`);
7444
+ return path12.join(this.logsDir, `${encodeURIComponent(service)}.${suffix}`);
6742
7445
  }
6743
7446
  legacyHistoryFile(service, type) {
6744
7447
  const safe = service.replace(/[^a-z0-9_-]/gi, "_");
6745
7448
  const suffix = type === "log" ? "log.jsonl" : "requests.jsonl";
6746
- return path8.join(this.logsDir, `${safe}.${suffix}`);
7449
+ return path12.join(this.logsDir, `${safe}.${suffix}`);
6747
7450
  }
6748
7451
  loadHistory(file, type, service, ring, legacyService, budget = this.maxFileBytes) {
6749
7452
  const marker2 = `${this.historyFile(service, type)}.cleared`;
6750
- const boundary = fs8.existsSync(marker2) ? fs8.readFileSync(marker2, "utf8") : undefined;
7453
+ const boundary = fs11.existsSync(marker2) ? fs11.readFileSync(marker2, "utf8") : undefined;
6751
7454
  let fd;
6752
7455
  let size;
6753
7456
  try {
6754
- fd = fs8.openSync(file, "r");
6755
- size = fs8.fstatSync(fd).size;
7457
+ fd = fs11.openSync(file, "r");
7458
+ size = fs11.fstatSync(fd).size;
6756
7459
  } catch (error) {
6757
7460
  if (error.code === "ENOENT")
6758
7461
  return 0;
@@ -6763,9 +7466,9 @@ class Store extends EventEmitter {
6763
7466
  const bytes = Buffer.alloc(count);
6764
7467
  try {
6765
7468
  if (count)
6766
- fs8.readSync(fd, bytes, 0, count, start);
7469
+ fs11.readSync(fd, bytes, 0, count, start);
6767
7470
  } finally {
6768
- fs8.closeSync(fd);
7471
+ fs11.closeSync(fd);
6769
7472
  }
6770
7473
  let lines = bytes.toString("binary").split(`
6771
7474
  `);
@@ -6828,14 +7531,14 @@ class Store extends EventEmitter {
6828
7531
  const rotatedReq = `${reqFile}.1`;
6829
7532
  const logCurrentSize = (() => {
6830
7533
  try {
6831
- return fs8.statSync(logFile).size;
7534
+ return fs11.statSync(logFile).size;
6832
7535
  } catch {
6833
7536
  return 0;
6834
7537
  }
6835
7538
  })();
6836
7539
  const reqCurrentSize = (() => {
6837
7540
  try {
6838
- return fs8.statSync(reqFile).size;
7541
+ return fs11.statSync(reqFile).size;
6839
7542
  } catch {
6840
7543
  return 0;
6841
7544
  }
@@ -6906,7 +7609,7 @@ class Store extends EventEmitter {
6906
7609
  }
6907
7610
  historyBoundary(service, kind) {
6908
7611
  const marker2 = `${this.historyFile(service, kind === "logs" ? "log" : "request")}.cleared`;
6909
- return fs8.existsSync(marker2) ? fs8.readFileSync(marker2, "utf8") : undefined;
7612
+ return fs11.existsSync(marker2) ? fs11.readFileSync(marker2, "utf8") : undefined;
6910
7613
  }
6911
7614
  async clearHistory(service, kind) {
6912
7615
  if (this.closing)
@@ -6915,15 +7618,15 @@ class Store extends EventEmitter {
6915
7618
  const state = kind === "logs" ? bucket.logFile : bucket.reqFile;
6916
7619
  const beforeId = this.nextId();
6917
7620
  const marker2 = `${state.file}.cleared`;
6918
- fs8.writeFileSync(`${marker2}.tmp`, beforeId, { mode: 384 });
6919
- fs8.renameSync(`${marker2}.tmp`, marker2);
7621
+ fs11.writeFileSync(`${marker2}.tmp`, beforeId, { mode: 384 });
7622
+ fs11.renameSync(`${marker2}.tmp`, marker2);
6920
7623
  bucket[kind].clear();
6921
7624
  if (kind === "requests")
6922
7625
  this.responseBodies.clear(service);
6923
7626
  const operation = state.queue.then(async () => {
6924
7627
  await this.closeStream(state.stream);
6925
- fs8.writeFileSync(state.file, "", { mode: 384 });
6926
- fs8.rmSync(`${state.file}.1`, { force: true });
7628
+ fs11.writeFileSync(state.file, "", { mode: 384 });
7629
+ fs11.rmSync(`${state.file}.1`, { force: true });
6927
7630
  state.bytes = 0;
6928
7631
  state.stream = this.streamFactory(state.file, { flags: "a", mode: 384, highWaterMark: 65536 });
6929
7632
  state.disabled = false;
@@ -6942,9 +7645,24 @@ class Store extends EventEmitter {
6942
7645
  }
6943
7646
  emitEvent(ev) {
6944
7647
  const event = { ...ev, eventId: this.nextId() };
7648
+ this.activity.capture(event);
6945
7649
  this.emit("event", event);
6946
7650
  return event;
6947
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
+ }
6948
7666
  close() {
6949
7667
  if (!this.closePromise) {
6950
7668
  this.closing = true;
@@ -7010,12 +7728,12 @@ class Store extends EventEmitter {
7010
7728
  } });
7011
7729
  }
7012
7730
  try {
7013
- if (fs8.existsSync(file))
7014
- fs8.chmodSync(file, 384);
7731
+ if (fs11.existsSync(file))
7732
+ fs11.chmodSync(file, 384);
7015
7733
  } catch {}
7016
7734
  const state = { stream, file, bytes: (() => {
7017
7735
  try {
7018
- return fs8.statSync(file).size;
7736
+ return fs11.statSync(file).size;
7019
7737
  } catch {
7020
7738
  return 0;
7021
7739
  }
@@ -7089,8 +7807,8 @@ class Store extends EventEmitter {
7089
7807
  }
7090
7808
  const rotated = `${state.file}.1`;
7091
7809
  try {
7092
- fs8.rmSync(rotated, { force: true });
7093
- fs8.renameSync(state.file, rotated);
7810
+ fs11.rmSync(rotated, { force: true });
7811
+ fs11.renameSync(state.file, rotated);
7094
7812
  } catch {
7095
7813
  state.disabled = true;
7096
7814
  this.reportPersistenceFailure(service, bucket, "history rotation");
@@ -7098,7 +7816,7 @@ class Store extends EventEmitter {
7098
7816
  }
7099
7817
  state.stream = this.streamFactory(state.file, { flags: "a", mode: 384, highWaterMark: 65536 });
7100
7818
  try {
7101
- fs8.chmodSync(state.file, 384);
7819
+ fs11.chmodSync(state.file, 384);
7102
7820
  } catch {}
7103
7821
  state.bytes = 0;
7104
7822
  state.stream.on("error", () => {
@@ -7144,8 +7862,8 @@ function settlesWithin2(promise, timeoutMs) {
7144
7862
 
7145
7863
  // ../daemon/src/supervisor.ts
7146
7864
  import { spawn as spawn3 } from "node:child_process";
7147
- import path10 from "node:path";
7148
- import fs10 from "node:fs";
7865
+ import path14 from "node:path";
7866
+ import fs13 from "node:fs";
7149
7867
 
7150
7868
  // ../daemon/src/startup-guidance.ts
7151
7869
  function startupGuidance(message) {
@@ -7170,8 +7888,8 @@ function startupGuidance(message) {
7170
7888
  }
7171
7889
 
7172
7890
  // ../daemon/src/port-environment.ts
7173
- import fs9 from "node:fs";
7174
- import path9 from "node:path";
7891
+ import fs12 from "node:fs";
7892
+ import path13 from "node:path";
7175
7893
  function inferPortEnvironment(command, cwd, port) {
7176
7894
  const visited = new Set;
7177
7895
  const inspect = (input, directory) => {
@@ -7180,7 +7898,7 @@ function inferPortEnvironment(command, cwd, port) {
7180
7898
  const words = typeof input === "string" ? shellWords(input) : input;
7181
7899
  if (!words?.length)
7182
7900
  return;
7183
- const executable = path9.basename(words[0]).replace(/\.cmd$/, "");
7901
+ const executable = path13.basename(words[0]).replace(/\.cmd$/, "");
7184
7902
  if (words[0].includes("=") || ["echo", "printf"].includes(executable))
7185
7903
  return;
7186
7904
  if (["npm", "pnpm", "yarn", "bun"].includes(executable)) {
@@ -7203,7 +7921,7 @@ function inferPortEnvironment(command, cwd, port) {
7203
7921
  return;
7204
7922
  workspace = value;
7205
7923
  } else
7206
- target = path9.resolve(directory, value);
7924
+ target = path13.resolve(directory, value);
7207
7925
  } else if (word.startsWith("-") || script)
7208
7926
  return;
7209
7927
  else
@@ -7233,26 +7951,26 @@ function inferPortEnvironment(command, cwd, port) {
7233
7951
  if (name2 && name2 !== "PORT" && !name2.startsWith("LOCALDECK_"))
7234
7952
  return name2;
7235
7953
  };
7236
- const name = inspect(command, path9.resolve(cwd));
7954
+ const name = inspect(command, path13.resolve(cwd));
7237
7955
  return name ? { [name]: String(port) } : {};
7238
7956
  }
7239
7957
  function shellWords(command) {
7240
7958
  const words = [];
7241
7959
  let word = "";
7242
- let quote = "";
7960
+ let quote2 = "";
7243
7961
  let started = false;
7244
7962
  for (let i = 0;i < command.length; i++) {
7245
7963
  const char = command[i];
7246
7964
  if (char === "`" || char === "\\" || char === `
7247
7965
  ` || command.slice(i, i + 2) === "$(")
7248
7966
  return;
7249
- if (quote) {
7250
- if (char === quote)
7251
- quote = "";
7967
+ if (quote2) {
7968
+ if (char === quote2)
7969
+ quote2 = "";
7252
7970
  else
7253
- word += quote === "'" && char === "$" ? "\x00" : char;
7971
+ word += quote2 === "'" && char === "$" ? "\x00" : char;
7254
7972
  } else if (char === '"' || char === "'") {
7255
- quote = char;
7973
+ quote2 = char;
7256
7974
  started = true;
7257
7975
  } else if (/\s/.test(char)) {
7258
7976
  if (started)
@@ -7266,7 +7984,7 @@ function shellWords(command) {
7266
7984
  started = true;
7267
7985
  }
7268
7986
  }
7269
- if (quote)
7987
+ if (quote2)
7270
7988
  return;
7271
7989
  if (started)
7272
7990
  words.push(word);
@@ -7274,7 +7992,7 @@ function shellWords(command) {
7274
7992
  }
7275
7993
  function manifest(directory) {
7276
7994
  try {
7277
- const value = JSON.parse(fs9.readFileSync(path9.join(directory, "package.json"), "utf8"));
7995
+ const value = JSON.parse(fs12.readFileSync(path13.join(directory, "package.json"), "utf8"));
7278
7996
  return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
7279
7997
  } catch {
7280
7998
  return;
@@ -7287,7 +8005,7 @@ function workspaceDirectory(root, selector) {
7287
8005
  return;
7288
8006
  const directories = new Set;
7289
8007
  for (const pattern of patterns.slice(0, 100)) {
7290
- if (typeof pattern !== "string" || path9.isAbsolute(pattern))
8008
+ if (typeof pattern !== "string" || path13.isAbsolute(pattern))
7291
8009
  continue;
7292
8010
  const parts = pattern.replace(/^\.\//, "").replace(/\/$/, "").split("/");
7293
8011
  if (parts.some((part) => !part || part === ".." || /[^\w@.*-]/.test(part) || part.includes("**")))
@@ -7298,9 +8016,9 @@ function workspaceDirectory(root, selector) {
7298
8016
  const next = [];
7299
8017
  for (const candidate of candidates) {
7300
8018
  try {
7301
- for (const entry of fs9.readdirSync(candidate, { withFileTypes: true })) {
8019
+ for (const entry of fs12.readdirSync(candidate, { withFileTypes: true })) {
7302
8020
  if (entry.isDirectory() && !["node_modules", ".git"].includes(entry.name) && matcher.test(entry.name))
7303
- next.push(path9.join(candidate, entry.name));
8021
+ next.push(path13.join(candidate, entry.name));
7304
8022
  if (next.length > 100)
7305
8023
  return;
7306
8024
  }
@@ -7310,7 +8028,7 @@ function workspaceDirectory(root, selector) {
7310
8028
  }
7311
8029
  for (const candidate of candidates) {
7312
8030
  const pkg = manifest(candidate);
7313
- if (pkg && (candidate === path9.resolve(root, selector) || pkg.name === selector))
8031
+ if (pkg && (candidate === path13.resolve(root, selector) || pkg.name === selector))
7314
8032
  directories.add(candidate);
7315
8033
  }
7316
8034
  }
@@ -7347,7 +8065,7 @@ async function waitForReadiness(command, getService, options = {}) {
7347
8065
  await delay2(Math.min(command.ready.intervalMs, afterProbe), command.name, options.signal);
7348
8066
  }
7349
8067
  }
7350
- function probeHttp(service, path10, expectedStatus, remainingMs, signal) {
8068
+ function probeHttp(service, path14, expectedStatus, remainingMs, signal) {
7351
8069
  return new Promise((resolve, reject) => {
7352
8070
  if (signal?.aborted)
7353
8071
  return reject(new Error(`${service.name} readiness cancelled`));
@@ -7365,7 +8083,7 @@ function probeHttp(service, path10, expectedStatus, remainingMs, signal) {
7365
8083
  const request = http7.request({
7366
8084
  host: service.upstreamHost,
7367
8085
  port: service.upstreamPort,
7368
- path: path10,
8086
+ path: path14,
7369
8087
  method: "GET",
7370
8088
  timeout: Math.max(1, Math.min(remainingMs, 2000))
7371
8089
  }, (response) => {
@@ -7509,8 +8227,8 @@ function secretOutput(values) {
7509
8227
  return result;
7510
8228
  }
7511
8229
  function consume(final) {
7512
- const secrets = [...values].map(stripVTControlCharacters).filter(Boolean).sort((a, b) => b.length - a.length);
7513
- if (!secrets.length) {
8230
+ const secrets2 = [...values].map(stripVTControlCharacters).filter(Boolean).sort((a, b) => b.length - a.length);
8231
+ if (!secrets2.length) {
7514
8232
  const output2 = pending;
7515
8233
  pending = "";
7516
8234
  return output2;
@@ -7519,9 +8237,9 @@ function secretOutput(values) {
7519
8237
  let index = 0;
7520
8238
  while (index < pending.length) {
7521
8239
  const remaining = pending.slice(index);
7522
- 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)))
7523
8241
  break;
7524
- const match = secrets.find((value) => pending.startsWith(value, index));
8242
+ const match = secrets2.find((value) => pending.startsWith(value, index));
7525
8243
  if (match) {
7526
8244
  output += "[REDACTED]";
7527
8245
  index += match.length;
@@ -7640,11 +8358,11 @@ class ProcessSupervisor {
7640
8358
  return started;
7641
8359
  const hasDependents = project.commands.some((candidate) => candidate.after.includes(command.name));
7642
8360
  if (command.ready.type === "tcp" && !hasDependents) {
7643
- 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;
7644
8362
  this.verifyReadiness(command, key, cwd2);
7645
8363
  return started;
7646
8364
  }
7647
- 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;
7648
8366
  return this.verifyReadiness(command, key, cwd, started);
7649
8367
  })();
7650
8368
  attempts.set(command.name, attempt);
@@ -7790,9 +8508,9 @@ class ProcessSupervisor {
7790
8508
  const project = this.projects.details(projectId2);
7791
8509
  if (!project.available)
7792
8510
  throw new ProjectError("invalid", project.error);
7793
- 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;
7794
8512
  try {
7795
- if (!fs10.statSync(cwd).isDirectory())
8513
+ if (!fs13.statSync(cwd).isDirectory())
7796
8514
  throw new Error("not a directory");
7797
8515
  } catch {
7798
8516
  throw new ProjectError("invalid", `configured working directory does not exist or is not a directory: ${cwd}`);
@@ -8025,7 +8743,7 @@ class ProcessSupervisor {
8025
8743
  else {
8026
8744
  const command = this.projects.resolve(projectId2, name);
8027
8745
  const project = this.projects.details(projectId2);
8028
- 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;
8029
8747
  this.verifyReadiness(command, key, cwd);
8030
8748
  }
8031
8749
  return result;
@@ -8310,7 +9028,7 @@ async function waitForProcessGroupExit(groupId, timeoutMs) {
8310
9028
  return true;
8311
9029
  }
8312
9030
  // ../daemon/package.json
8313
- var version = "1.4.0";
9031
+ var version = "1.5.0";
8314
9032
 
8315
9033
  // ../daemon/src/index.ts
8316
9034
  var VERSION = version;
@@ -8327,8 +9045,8 @@ async function startDaemon(opts = {}) {
8327
9045
  writePrivateState: writePrivateJson,
8328
9046
  ...opts.dependencies
8329
9047
  };
8330
- const stateFile = opts.homeDir ? path11.join(opts.homeDir, "daemon.json") : STATE_FILE;
8331
- const projectsFile = opts.homeDir ? path11.join(opts.homeDir, "projects.json") : PROJECTS_FILE;
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;
8332
9050
  const apiPort = opts.apiPort ?? DEFAULT_API_PORT;
8333
9051
  const store = dependencies.createStore();
8334
9052
  const registry = dependencies.createRegistry(store, apiPort);
@@ -8388,16 +9106,16 @@ async function startDaemon(opts = {}) {
8388
9106
  if (stateCleanup === "failed-write") {
8389
9107
  if (!stateWriteAttempted)
8390
9108
  return;
8391
- fs11.rmSync(`${stateFile}.${process.pid}.tmp`, { force: true });
9109
+ fs14.rmSync(`${stateFile}.${process.pid}.tmp`, { force: true });
8392
9110
  if (priorState)
8393
9111
  restorePrivateState(stateFile, priorState.contents, priorState.mode);
8394
9112
  else
8395
- fs11.rmSync(stateFile, { force: true });
9113
+ fs14.rmSync(stateFile, { force: true });
8396
9114
  return;
8397
9115
  }
8398
9116
  const state = readJson(stateFile, null);
8399
9117
  if (state?.pid === process.pid)
8400
- fs11.unlinkSync(stateFile);
9118
+ fs14.unlinkSync(stateFile);
8401
9119
  }));
8402
9120
  return errors;
8403
9121
  })();
@@ -8423,9 +9141,9 @@ async function startDaemon(opts = {}) {
8423
9141
  });
8424
9142
  });
8425
9143
  server.on("error", (error) => console.error(`LocalDeck: API server error: ${error.message}`));
8426
- if (fs11.existsSync(stateFile)) {
8427
- const stat = fs11.statSync(stateFile);
8428
- priorState = { contents: fs11.readFileSync(stateFile), mode: stat.mode & 511 };
9144
+ if (fs14.existsSync(stateFile)) {
9145
+ const stat = fs14.statSync(stateFile);
9146
+ priorState = { contents: fs14.readFileSync(stateFile), mode: stat.mode & 511 };
8429
9147
  }
8430
9148
  stateWriteAttempted = true;
8431
9149
  dependencies.writePrivateState(stateFile, {
@@ -8446,9 +9164,9 @@ async function startDaemon(opts = {}) {
8446
9164
  }
8447
9165
  function restorePrivateState(file, contents, mode) {
8448
9166
  const temporary = `${file}.${process.pid}.restore.tmp`;
8449
- fs11.writeFileSync(temporary, contents, { mode });
8450
- fs11.chmodSync(temporary, mode);
8451
- fs11.renameSync(temporary, file);
9167
+ fs14.writeFileSync(temporary, contents, { mode });
9168
+ fs14.chmodSync(temporary, mode);
9169
+ fs14.renameSync(temporary, file);
8452
9170
  }
8453
9171
  function toError3(error) {
8454
9172
  return error instanceof Error ? error : new Error(String(error));
@@ -8469,7 +9187,7 @@ function createOperationTracker() {
8469
9187
  async function findDaemon(stateFile = STATE_FILE) {
8470
9188
  let state;
8471
9189
  try {
8472
- state = JSON.parse(fs11.readFileSync(stateFile, "utf8"));
9190
+ state = JSON.parse(fs14.readFileSync(stateFile, "utf8"));
8473
9191
  } catch (error) {
8474
9192
  const code = error.code;
8475
9193
  if (code === "EACCES" || code === "EPERM") {
@@ -8485,8 +9203,8 @@ async function findDaemon(stateFile = STATE_FILE) {
8485
9203
  const res = await fetch(`http://127.0.0.1:${state.apiPort}/api/status`, { signal: AbortSignal.timeout(1000) });
8486
9204
  if (!res.ok)
8487
9205
  return null;
8488
- const body = await res.json();
8489
- return body.pid === state.pid ? state : null;
9206
+ const body2 = await res.json();
9207
+ return body2.pid === state.pid ? state : null;
8490
9208
  } catch {
8491
9209
  return null;
8492
9210
  }
@@ -8494,24 +9212,24 @@ async function findDaemon(stateFile = STATE_FILE) {
8494
9212
 
8495
9213
  // src/client.ts
8496
9214
  import { spawn as spawn4, spawnSync } from "node:child_process";
8497
- import fs12 from "node:fs";
8498
- import path12 from "node:path";
9215
+ import fs15 from "node:fs";
9216
+ import path16 from "node:path";
8499
9217
  import { fileURLToPath } from "node:url";
8500
9218
  function dashboardDir() {
8501
- const here = path12.dirname(fileURLToPath(import.meta.url));
9219
+ const here = path16.dirname(fileURLToPath(import.meta.url));
8502
9220
  const candidates = [
8503
- path12.resolve(here, "../dashboard"),
8504
- path12.resolve(here, "../../dashboard/dist")
9221
+ path16.resolve(here, "../dashboard"),
9222
+ path16.resolve(here, "../../dashboard/dist")
8505
9223
  ];
8506
- return candidates.find((d) => fs12.existsSync(path12.join(d, "index.html")));
9224
+ return candidates.find((d) => fs15.existsSync(path16.join(d, "index.html")));
8507
9225
  }
8508
9226
  function daemonCommand() {
8509
9227
  const entry = process.argv[1];
8510
9228
  const runningUnderBun = Boolean(process.versions.bun);
8511
9229
  if (runningUnderBun) {
8512
- 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");
8513
9231
  const hasNode = spawnSync("node", ["--version"], { stdio: "ignore" }).status === 0;
8514
- if (fs12.existsSync(dist) && hasNode)
9232
+ if (fs15.existsSync(dist) && hasNode)
8515
9233
  return ["node", [dist, "daemon", "run"]];
8516
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.");
8517
9235
  }
@@ -8524,8 +9242,8 @@ async function ensureDaemon() {
8524
9242
  const running = await findDaemon();
8525
9243
  if (running)
8526
9244
  return running;
8527
- fs12.mkdirSync(path12.dirname(DAEMON_LOG), { recursive: true });
8528
- const out = fs12.openSync(DAEMON_LOG, "a");
9245
+ fs15.mkdirSync(path16.dirname(DAEMON_LOG), { recursive: true });
9246
+ const out = fs15.openSync(DAEMON_LOG, "a");
8529
9247
  const [cmd, args] = daemonCommand();
8530
9248
  const child = spawn4(cmd, args, { detached: true, stdio: ["ignore", out, out], env: process.env });
8531
9249
  child.unref();
@@ -8551,12 +9269,12 @@ class Api {
8551
9269
  get base() {
8552
9270
  return `http://127.0.0.1:${this.port}`;
8553
9271
  }
8554
- async call(method, p, body, headers) {
9272
+ async call(method, p, body2, headers) {
8555
9273
  const res = await this.fetch(this.base + p, {
8556
9274
  method,
8557
9275
  ...this.timeoutMs ? { signal: AbortSignal.timeout(this.timeoutMs) } : {},
8558
- headers: { ...body ? { "content-type": "application/json" } : {}, ...headers },
8559
- body: body ? JSON.stringify(body) : undefined
9276
+ headers: { ...body2 ? { "content-type": "application/json" } : {}, ...headers },
9277
+ body: body2 ? JSON.stringify(body2) : undefined
8560
9278
  });
8561
9279
  const data = await res.json().catch(() => ({}));
8562
9280
  if (!res.ok) {
@@ -8567,6 +9285,25 @@ class Api {
8567
9285
  }
8568
9286
  projects = () => this.call("GET", "/api/projects");
8569
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`, {});
8570
9307
  serviceAction = (projectId2, name, action) => this.call("POST", `/api/projects/${encodeURIComponent(projectId2)}/services/${encodeURIComponent(name)}/${action}`, {});
8571
9308
  shareInfo = (key) => this.call("GET", `/api/services/${encodeURIComponent(key)}/share`);
8572
9309
  replay = (key, requestId) => this.call("POST", `/api/services/${encodeURIComponent(key)}/replay`, { requestId });
@@ -8595,4 +9332,4 @@ class Api {
8595
9332
  shutdown = () => this.call("POST", "/api/shutdown");
8596
9333
  }
8597
9334
 
8598
- export { __toESM, __commonJS, __export, __require, wrapper_default, startForwarder, HOME_DIR, STATE_FILE, DEFAULT_API_PORT, assertUserRuntime, isPortFree, getFreePort, findProjectFile, loadProject, loadProjectFile, shellCommand, serviceKey, processTree, listeningPorts, chooseUpstream, unreachableListenerMessage, connectHost, streamLines, killProcessTree, stripAnsi, redactDiagnostic, environmentValues, listProjectSecrets, resolveProjectSecrets, startupGuidance, inferPortEnvironment, startupLayers, waitForReadiness, secretOutput, VERSION, startDaemon, findDaemon, dashboardDir, ensureDaemon, Api };
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 };