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