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