localdeck 1.0.3 → 1.2.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 +36 -0
- package/README.md +12 -0
- package/dashboard/assets/index-B2HB87hg.js +20 -0
- package/dashboard/assets/index-Tp7Rr3xY.css +1 -0
- package/dashboard/index.html +2 -2
- package/dist/{index-3qpxpxwq.js → index-zmpw8b4a.js} +486 -88
- package/dist/index.js +28 -10
- package/dist/{mcp-tgg2n05x.js → mcp-dwx4ja4k.js} +6 -2
- package/dist/{update-2f1pgdst.js → update-sfxqqdjp.js} +1 -1
- package/docs/configuration.md +6 -1
- package/docs/dashboard.md +12 -2
- package/package.json +1 -1
- package/dashboard/assets/index-qPKQBj-D.js +0 -20
- package/dashboard/assets/index-sKTai3O1.css +0 -1
|
@@ -3226,6 +3226,22 @@ function normalize(raw, file) {
|
|
|
3226
3226
|
Object.defineProperty(env, key, { value: value2, enumerable: true, writable: true, configurable: true });
|
|
3227
3227
|
}
|
|
3228
3228
|
}
|
|
3229
|
+
let secrets;
|
|
3230
|
+
if (entry.secrets !== undefined) {
|
|
3231
|
+
if (!Array.isArray(entry.secrets) || !entry.secrets.every((value2) => typeof value2 === "string" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(value2))) {
|
|
3232
|
+
throw new Error(`${path2.basename(file)}: secrets must contain valid environment names`);
|
|
3233
|
+
}
|
|
3234
|
+
secrets = [];
|
|
3235
|
+
for (const secret of entry.secrets) {
|
|
3236
|
+
if (secret === "PORT" || secret.startsWith("LOCALDECK_"))
|
|
3237
|
+
throw new Error(`${path2.basename(file)}: ${secret} is reserved by LocalDeck`);
|
|
3238
|
+
if (env && Object.prototype.hasOwnProperty.call(env, secret))
|
|
3239
|
+
throw new Error(`${path2.basename(file)}: ${secret} cannot be both a secret and env setting`);
|
|
3240
|
+
if (secrets.includes(secret))
|
|
3241
|
+
throw new Error(`${path2.basename(file)}: duplicate secret ${secret}`);
|
|
3242
|
+
secrets.push(secret);
|
|
3243
|
+
}
|
|
3244
|
+
}
|
|
3229
3245
|
out.push({
|
|
3230
3246
|
name,
|
|
3231
3247
|
run,
|
|
@@ -3236,7 +3252,8 @@ function normalize(raw, file) {
|
|
|
3236
3252
|
after: [...new Set(after)],
|
|
3237
3253
|
ready: parseReadiness(entry.ready ?? (serviceType === "postgres" || serviceType === "redis" ? serviceType : undefined), file, index),
|
|
3238
3254
|
...serviceType ? { serviceType } : {},
|
|
3239
|
-
...env ? { env } : {}
|
|
3255
|
+
...env ? { env } : {},
|
|
3256
|
+
...secrets ? { secrets } : {}
|
|
3240
3257
|
});
|
|
3241
3258
|
}
|
|
3242
3259
|
validateDependencies(out, file);
|
|
@@ -3392,9 +3409,9 @@ function stopOrder(commands) {
|
|
|
3392
3409
|
}
|
|
3393
3410
|
|
|
3394
3411
|
// ../daemon/src/index.ts
|
|
3395
|
-
import
|
|
3412
|
+
import fs9 from "node:fs";
|
|
3396
3413
|
import crypto2 from "node:crypto";
|
|
3397
|
-
import
|
|
3414
|
+
import path10 from "node:path";
|
|
3398
3415
|
|
|
3399
3416
|
// ../daemon/src/api.ts
|
|
3400
3417
|
import http5 from "node:http";
|
|
@@ -3687,34 +3704,55 @@ class CleanUrls {
|
|
|
3687
3704
|
}
|
|
3688
3705
|
|
|
3689
3706
|
// ../daemon/src/api.ts
|
|
3690
|
-
import
|
|
3691
|
-
import
|
|
3707
|
+
import fs6 from "node:fs";
|
|
3708
|
+
import path7 from "node:path";
|
|
3692
3709
|
|
|
3693
3710
|
// ../daemon/src/request-body.ts
|
|
3694
3711
|
import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
|
|
3695
3712
|
var MAX_BODY_CAPTURE_BYTES = 16384;
|
|
3696
3713
|
|
|
3697
3714
|
class BodyCapture {
|
|
3715
|
+
limit;
|
|
3698
3716
|
chunks = [];
|
|
3699
3717
|
retained = 0;
|
|
3700
3718
|
bytes = 0;
|
|
3719
|
+
tailUsed = 0;
|
|
3720
|
+
constructor(limit = MAX_BODY_CAPTURE_BYTES) {
|
|
3721
|
+
this.limit = limit;
|
|
3722
|
+
}
|
|
3701
3723
|
append(chunk) {
|
|
3702
3724
|
this.bytes += chunk.length;
|
|
3703
|
-
const count = Math.min(chunk.length,
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
this.
|
|
3725
|
+
const count = Math.min(chunk.length, this.limit - this.retained);
|
|
3726
|
+
let offset = 0;
|
|
3727
|
+
while (offset < count) {
|
|
3728
|
+
let tail = this.chunks.at(-1);
|
|
3729
|
+
if (!tail || this.tailUsed === tail.length) {
|
|
3730
|
+
if (tail && tail.length < 4096) {
|
|
3731
|
+
const grown = Buffer.allocUnsafe(Math.min(4096, this.limit - this.retained + this.tailUsed, Math.max(tail.length * 2, count - offset)));
|
|
3732
|
+
tail.copy(grown, 0, 0, this.tailUsed);
|
|
3733
|
+
this.chunks[this.chunks.length - 1] = tail = grown;
|
|
3734
|
+
} else {
|
|
3735
|
+
tail = Buffer.allocUnsafe(Math.min(4096, this.limit - this.retained, Math.max(64, count - offset)));
|
|
3736
|
+
this.chunks.push(tail);
|
|
3737
|
+
this.tailUsed = 0;
|
|
3738
|
+
}
|
|
3739
|
+
}
|
|
3740
|
+
const size = Math.min(count - offset, tail.length - this.tailUsed);
|
|
3741
|
+
chunk.copy(tail, this.tailUsed, offset, offset + size);
|
|
3742
|
+
this.tailUsed += size;
|
|
3743
|
+
this.retained += size;
|
|
3744
|
+
offset += size;
|
|
3707
3745
|
}
|
|
3708
3746
|
}
|
|
3709
3747
|
snapshot(complete, contentEncoding) {
|
|
3710
|
-
let buffer = Buffer.concat(this.chunks, this.retained);
|
|
3748
|
+
let buffer = Buffer.concat(this.chunks.map((chunk, index) => index === this.chunks.length - 1 ? chunk.subarray(0, this.tailUsed) : chunk), this.retained);
|
|
3711
3749
|
let encoded = Boolean(contentEncoding && contentEncoding !== "identity");
|
|
3712
3750
|
const truncated = this.bytes > this.retained;
|
|
3713
3751
|
if (encoded && complete && !truncated && buffer.length) {
|
|
3714
3752
|
const decode = { gzip: gunzipSync, deflate: inflateSync, br: brotliDecompressSync }[contentEncoding];
|
|
3715
3753
|
try {
|
|
3716
3754
|
if (decode) {
|
|
3717
|
-
buffer = decode(buffer, { maxOutputLength:
|
|
3755
|
+
buffer = decode(buffer, { maxOutputLength: this.limit });
|
|
3718
3756
|
encoded = false;
|
|
3719
3757
|
}
|
|
3720
3758
|
} catch {}
|
|
@@ -3786,6 +3824,11 @@ function proxyRequest(req, res, target, hooks) {
|
|
|
3786
3824
|
const requestHeaders = captureHeaders(req.rawHeaders);
|
|
3787
3825
|
const requestBody = new BodyCapture;
|
|
3788
3826
|
const responseBody = new BodyCapture;
|
|
3827
|
+
const fullResponse = hooks.responseBodies?.start(target.service);
|
|
3828
|
+
const captureResponse = (chunk) => {
|
|
3829
|
+
responseBody.append(chunk);
|
|
3830
|
+
fullResponse?.append(chunk);
|
|
3831
|
+
};
|
|
3789
3832
|
let responseComplete = false;
|
|
3790
3833
|
let responseEncoding;
|
|
3791
3834
|
let responseHeaders;
|
|
@@ -3795,6 +3838,7 @@ function proxyRequest(req, res, target, hooks) {
|
|
|
3795
3838
|
if (recorded)
|
|
3796
3839
|
return;
|
|
3797
3840
|
recorded = true;
|
|
3841
|
+
fullResponse?.finish(responseComplete, responseEncoding);
|
|
3798
3842
|
hooks.onRequest({
|
|
3799
3843
|
ts: started,
|
|
3800
3844
|
service: target.service,
|
|
@@ -3808,14 +3852,15 @@ function proxyRequest(req, res, target, hooks) {
|
|
|
3808
3852
|
requestHeaders,
|
|
3809
3853
|
responseHeaders,
|
|
3810
3854
|
requestBody: requestBody.snapshot(req.readableEnded, req.headers["content-encoding"]),
|
|
3811
|
-
responseBody: responseBody.snapshot(responseComplete, responseEncoding)
|
|
3855
|
+
responseBody: responseBody.snapshot(responseComplete, responseEncoding),
|
|
3856
|
+
responseBodyId: fullResponse?.id
|
|
3812
3857
|
});
|
|
3813
3858
|
};
|
|
3814
3859
|
if (!target.upstreamReady) {
|
|
3815
3860
|
responseHeaders = captureHeaders(["content-type", "text/html; charset=utf-8", "retry-after", "2"]);
|
|
3816
3861
|
res.writeHead(503, { "content-type": "text/html; charset=utf-8", "retry-after": "2" });
|
|
3817
3862
|
const body = waitingPage(target);
|
|
3818
|
-
|
|
3863
|
+
captureResponse(Buffer.from(body));
|
|
3819
3864
|
responseComplete = true;
|
|
3820
3865
|
res.end(body);
|
|
3821
3866
|
record(503, "upstream not ready");
|
|
@@ -3847,7 +3892,7 @@ function proxyRequest(req, res, target, hooks) {
|
|
|
3847
3892
|
res.writeHead(up.statusCode ?? 502, up.statusMessage, headers);
|
|
3848
3893
|
up.on("data", (chunk) => {
|
|
3849
3894
|
bytes += chunk.length;
|
|
3850
|
-
|
|
3895
|
+
captureResponse(chunk);
|
|
3851
3896
|
});
|
|
3852
3897
|
up.pipe(res);
|
|
3853
3898
|
});
|
|
@@ -3857,7 +3902,7 @@ function proxyRequest(req, res, target, hooks) {
|
|
|
3857
3902
|
if (!res.headersSent && !res.destroyed) {
|
|
3858
3903
|
res.writeHead(502, { "content-type": "text/plain; charset=utf-8" });
|
|
3859
3904
|
const body = `LocalDeck: upstream ${target.service} (${target.upstreamHost}:${target.upstreamPort}) error: ${err.code ?? err.message}`;
|
|
3860
|
-
|
|
3905
|
+
captureResponse(Buffer.from(body));
|
|
3861
3906
|
responseComplete = true;
|
|
3862
3907
|
res.end(body);
|
|
3863
3908
|
} else
|
|
@@ -4423,13 +4468,13 @@ class ShareManager {
|
|
|
4423
4468
|
res.writeHead(401, { "content-type": "text/html; charset=utf-8" }).end(loginPage(name, false));
|
|
4424
4469
|
return;
|
|
4425
4470
|
}
|
|
4426
|
-
proxyRequest(req, res, target, { onRequest: (r) => this.store.request(r) });
|
|
4471
|
+
proxyRequest(req, res, target, { responseBodies: this.store.responseBodies, onRequest: (r) => this.store.request(r) });
|
|
4427
4472
|
});
|
|
4428
4473
|
gate.on("upgrade", (req, socket, head) => {
|
|
4429
4474
|
const target = this.registry.target(key, "share");
|
|
4430
4475
|
if (!target || !authed(req))
|
|
4431
4476
|
return socket.destroy();
|
|
4432
|
-
proxyUpgrade(req, socket, head, target, { onRequest: (r) => this.store.request(r) });
|
|
4477
|
+
proxyUpgrade(req, socket, head, target, { responseBodies: this.store.responseBodies, onRequest: (r) => this.store.request(r) });
|
|
4433
4478
|
});
|
|
4434
4479
|
const sockets = new Set;
|
|
4435
4480
|
gate.on("connection", (socket) => {
|
|
@@ -5005,6 +5050,98 @@ function replayRequest(service, record, options = {}) {
|
|
|
5005
5050
|
});
|
|
5006
5051
|
}
|
|
5007
5052
|
|
|
5053
|
+
// ../daemon/src/project-secrets.ts
|
|
5054
|
+
import fs5 from "node:fs";
|
|
5055
|
+
import path6 from "node:path";
|
|
5056
|
+
var ID = /^[a-f0-9]{16}$/;
|
|
5057
|
+
var NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
5058
|
+
|
|
5059
|
+
class ProjectSecretError extends Error {
|
|
5060
|
+
}
|
|
5061
|
+
function validateName(name) {
|
|
5062
|
+
if (!NAME.test(name) || name === "PORT" || name.startsWith("LOCALDECK_")) {
|
|
5063
|
+
throw new ProjectSecretError("invalid secret name");
|
|
5064
|
+
}
|
|
5065
|
+
}
|
|
5066
|
+
function createProjectSecretStore(home = HOME_DIR) {
|
|
5067
|
+
const directory = path6.join(home, "secrets");
|
|
5068
|
+
function secretPath(projectId2) {
|
|
5069
|
+
if (!ID.test(projectId2))
|
|
5070
|
+
throw new ProjectSecretError("invalid project ID");
|
|
5071
|
+
return path6.join(directory, `${projectId2}.json`);
|
|
5072
|
+
}
|
|
5073
|
+
function read(projectId2) {
|
|
5074
|
+
const file = secretPath(projectId2);
|
|
5075
|
+
let text;
|
|
5076
|
+
try {
|
|
5077
|
+
text = fs5.readFileSync(file, "utf8");
|
|
5078
|
+
} catch (error) {
|
|
5079
|
+
if (error.code === "ENOENT")
|
|
5080
|
+
return Object.create(null);
|
|
5081
|
+
throw new ProjectSecretError("Could not read stored secrets");
|
|
5082
|
+
}
|
|
5083
|
+
let parsed;
|
|
5084
|
+
try {
|
|
5085
|
+
parsed = JSON.parse(text);
|
|
5086
|
+
} catch {
|
|
5087
|
+
throw new ProjectSecretError("stored secrets are corrupt");
|
|
5088
|
+
}
|
|
5089
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
5090
|
+
throw new ProjectSecretError("stored secrets are corrupt");
|
|
5091
|
+
}
|
|
5092
|
+
const values = Object.create(null);
|
|
5093
|
+
for (const [name, value] of Object.entries(parsed)) {
|
|
5094
|
+
validateName(name);
|
|
5095
|
+
if (typeof value !== "string" || !value || value.includes("\x00")) {
|
|
5096
|
+
throw new ProjectSecretError("stored secrets are corrupt");
|
|
5097
|
+
}
|
|
5098
|
+
values[name] = value;
|
|
5099
|
+
}
|
|
5100
|
+
return values;
|
|
5101
|
+
}
|
|
5102
|
+
function write(projectId2, values) {
|
|
5103
|
+
const file = secretPath(projectId2);
|
|
5104
|
+
fs5.mkdirSync(directory, { recursive: true, mode: 448 });
|
|
5105
|
+
if (process.platform !== "win32")
|
|
5106
|
+
fs5.chmodSync(directory, 448);
|
|
5107
|
+
writePrivateJson(file, values);
|
|
5108
|
+
}
|
|
5109
|
+
function listProjectSecrets(projectId2) {
|
|
5110
|
+
return Object.keys(read(projectId2)).sort();
|
|
5111
|
+
}
|
|
5112
|
+
function setProjectSecret(projectId2, name, value) {
|
|
5113
|
+
validateName(name);
|
|
5114
|
+
if (typeof value !== "string" || !value || value.includes("\x00")) {
|
|
5115
|
+
throw new ProjectSecretError("secret value must be a nonempty string without NUL");
|
|
5116
|
+
}
|
|
5117
|
+
const values = read(projectId2);
|
|
5118
|
+
values[name] = value;
|
|
5119
|
+
write(projectId2, values);
|
|
5120
|
+
}
|
|
5121
|
+
function deleteProjectSecret(projectId2, name) {
|
|
5122
|
+
validateName(name);
|
|
5123
|
+
const values = read(projectId2);
|
|
5124
|
+
delete values[name];
|
|
5125
|
+
write(projectId2, values);
|
|
5126
|
+
}
|
|
5127
|
+
function resolveProjectSecrets(projectId2, names) {
|
|
5128
|
+
const output = Object.create(null);
|
|
5129
|
+
if (!names.length)
|
|
5130
|
+
return output;
|
|
5131
|
+
const values = read(projectId2);
|
|
5132
|
+
for (const name of names) {
|
|
5133
|
+
validateName(name);
|
|
5134
|
+
if (!Object.prototype.hasOwnProperty.call(values, name)) {
|
|
5135
|
+
throw new ProjectSecretError(`Add ${name} in Project → Secrets.`);
|
|
5136
|
+
}
|
|
5137
|
+
output[name] = values[name];
|
|
5138
|
+
}
|
|
5139
|
+
return output;
|
|
5140
|
+
}
|
|
5141
|
+
return { listProjectSecrets, setProjectSecret, deleteProjectSecret, resolveProjectSecrets };
|
|
5142
|
+
}
|
|
5143
|
+
var { listProjectSecrets, setProjectSecret, deleteProjectSecret, resolveProjectSecrets } = createProjectSecretStore();
|
|
5144
|
+
|
|
5008
5145
|
// ../daemon/src/api.ts
|
|
5009
5146
|
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" };
|
|
5010
5147
|
var MAX_BODY_BYTES = 65536;
|
|
@@ -5162,7 +5299,7 @@ function localRequestError(req, allowedBrowserOrigins) {
|
|
|
5162
5299
|
function createApiServer(deps) {
|
|
5163
5300
|
const { registry, store, shares } = deps;
|
|
5164
5301
|
const allowedBrowserOrigins = validatedBrowserOrigins(deps.allowedBrowserOrigins);
|
|
5165
|
-
const hooks = { onRequest: (r) => store.request(r) };
|
|
5302
|
+
const hooks = { responseBodies: store.responseBodies, onRequest: (r) => store.request(r) };
|
|
5166
5303
|
const forwarderIdentity = randomUUID();
|
|
5167
5304
|
const forwarder = new CleanUrls(() => {
|
|
5168
5305
|
const address = server.address();
|
|
@@ -5216,6 +5353,8 @@ function createApiServer(deps) {
|
|
|
5216
5353
|
} catch (error) {
|
|
5217
5354
|
if (error instanceof HttpError)
|
|
5218
5355
|
return json(res, error.status, { error: error.message });
|
|
5356
|
+
if (error instanceof ProjectSecretError)
|
|
5357
|
+
return json(res, 422, { error: error.message });
|
|
5219
5358
|
if (error instanceof ProjectError)
|
|
5220
5359
|
return json(res, projectStatus(error.code), { error: error.message });
|
|
5221
5360
|
return json(res, 500, { error: "internal server error" });
|
|
@@ -5354,6 +5493,31 @@ async function handleApi(req, res, url, parts, deps) {
|
|
|
5354
5493
|
}
|
|
5355
5494
|
if (parts.length >= 2 && parts[1] !== "remember") {
|
|
5356
5495
|
const projectId2 = parts[1];
|
|
5496
|
+
if (parts.length === 3 && parts[2] === "secrets" && method === "GET") {
|
|
5497
|
+
const project = requireRememberedProject(projects, projectId2);
|
|
5498
|
+
if (!project.available)
|
|
5499
|
+
throw new ProjectError("invalid", project.error ?? "project configuration is invalid");
|
|
5500
|
+
const list = deps.listProjectSecrets ?? listProjectSecrets;
|
|
5501
|
+
const declared = project.commands.flatMap((command) => command.secrets ?? []);
|
|
5502
|
+
const stored = new Set(list(projectId2));
|
|
5503
|
+
const names = [...new Set([...declared, ...stored])].sort();
|
|
5504
|
+
return json(res, 200, { secrets: names.map((name) => ({ name, configured: stored.has(name) })) });
|
|
5505
|
+
}
|
|
5506
|
+
if (parts.length === 4 && parts[2] === "secrets" && (method === "PUT" || method === "DELETE")) {
|
|
5507
|
+
requireMutationAllowed(deps);
|
|
5508
|
+
const project = requireRememberedProject(projects, projectId2);
|
|
5509
|
+
if (!project.available)
|
|
5510
|
+
throw new ProjectError("invalid", project.error ?? "project configuration is invalid");
|
|
5511
|
+
const name = parts[3];
|
|
5512
|
+
if (method === "PUT") {
|
|
5513
|
+
const body = await readBody(req);
|
|
5514
|
+
if (!body || typeof body !== "object" || Array.isArray(body) || Object.keys(body).length !== 1 || typeof body.value !== "string")
|
|
5515
|
+
throw new HttpError(400, 'request body must contain only a string "value"');
|
|
5516
|
+
(deps.setProjectSecret ?? setProjectSecret)(projectId2, name, body.value);
|
|
5517
|
+
} else
|
|
5518
|
+
(deps.deleteProjectSecret ?? deleteProjectSecret)(projectId2, name);
|
|
5519
|
+
return json(res, 200, { ok: true });
|
|
5520
|
+
}
|
|
5357
5521
|
if (parts.length === 3 && parts[2] === "nickname" && method === "PATCH") {
|
|
5358
5522
|
requireMutationAllowed(deps);
|
|
5359
5523
|
const body = await readBody(req);
|
|
@@ -5369,7 +5533,9 @@ async function handleApi(req, res, url, parts, deps) {
|
|
|
5369
5533
|
const project = projects.details(projectId2);
|
|
5370
5534
|
const services = registry.list(projectId2);
|
|
5371
5535
|
const logs = services.flatMap((service) => store.logs(service.key, 100)).sort((a, b) => a.ts - b.ts);
|
|
5372
|
-
|
|
5536
|
+
const names = (deps.listProjectSecrets ?? listProjectSecrets)(projectId2);
|
|
5537
|
+
const secretValues = Object.values((deps.resolveProjectSecrets ?? resolveProjectSecrets)(projectId2, names));
|
|
5538
|
+
return json(res, 200, { report: diagnosticReport(project, services, logs, deps.version, [deps.controlToken, ...secretValues]) });
|
|
5373
5539
|
}
|
|
5374
5540
|
if (parts.length === 4 && parts[2] === "profiles" && method === "POST") {
|
|
5375
5541
|
requireMutationAllowed(deps);
|
|
@@ -5466,6 +5632,16 @@ async function handleApi(req, res, url, parts, deps) {
|
|
|
5466
5632
|
return json(res, 404, { error: `unknown service "${reference}"` });
|
|
5467
5633
|
const { key, service: svc } = resolved;
|
|
5468
5634
|
const sub = parts[2];
|
|
5635
|
+
if (sub === "response" && method === "GET") {
|
|
5636
|
+
const record = store.requests(key, 5000).find((request) => request.id === url.searchParams.get("requestId"));
|
|
5637
|
+
if (!record)
|
|
5638
|
+
throw new HttpError(404, "Recorded request is no longer available");
|
|
5639
|
+
const captured = record.responseBodyId ? store.responseBodies.get(record.responseBodyId, key) : undefined;
|
|
5640
|
+
if (!captured)
|
|
5641
|
+
throw new HttpError(404, "Full response is no longer cached. Make a new request through the LocalDeck URL. The cache holds up to 64 MiB and resets when the daemon restarts.");
|
|
5642
|
+
res.setHeader("cache-control", "no-store");
|
|
5643
|
+
return json(res, 200, captured);
|
|
5644
|
+
}
|
|
5469
5645
|
if (sub === "replay" && method === "POST") {
|
|
5470
5646
|
requireMutationAllowed(deps);
|
|
5471
5647
|
const body = await readBody(req);
|
|
@@ -5483,6 +5659,15 @@ async function handleApi(req, res, url, parts, deps) {
|
|
|
5483
5659
|
}
|
|
5484
5660
|
if (!sub && method === "GET")
|
|
5485
5661
|
return json(res, 200, svc);
|
|
5662
|
+
if ((sub === "logs" || sub === "requests") && method === "DELETE") {
|
|
5663
|
+
requireMutationAllowed(deps);
|
|
5664
|
+
return json(res, 200, await store.clearHistory(key, sub));
|
|
5665
|
+
}
|
|
5666
|
+
if ((sub === "logs" || sub === "requests") && method === "GET") {
|
|
5667
|
+
const boundary = store.historyBoundary(key, sub);
|
|
5668
|
+
if (boundary)
|
|
5669
|
+
res.setHeader("x-localdeck-history-boundary", boundary);
|
|
5670
|
+
}
|
|
5486
5671
|
if (sub === "logs" && method === "GET")
|
|
5487
5672
|
return json(res, 200, store.logs(key, readLimit(url)));
|
|
5488
5673
|
if (sub === "requests" && method === "GET")
|
|
@@ -5537,17 +5722,17 @@ function serveStatic(pathname, res, dir) {
|
|
|
5537
5722
|
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>");
|
|
5538
5723
|
return;
|
|
5539
5724
|
}
|
|
5540
|
-
let file =
|
|
5725
|
+
let file = path7.join(dir, path7.normalize(pathname).replace(/^(\.\.[/\\])+/, ""));
|
|
5541
5726
|
if (!file.startsWith(dir))
|
|
5542
|
-
file =
|
|
5543
|
-
if (!
|
|
5544
|
-
file =
|
|
5545
|
-
const hashedAsset =
|
|
5727
|
+
file = path7.join(dir, "index.html");
|
|
5728
|
+
if (!fs6.existsSync(file) || fs6.statSync(file).isDirectory())
|
|
5729
|
+
file = path7.join(dir, "index.html");
|
|
5730
|
+
const hashedAsset = path7.dirname(file) === path7.join(dir, "assets") && /-[A-Za-z0-9_-]{8,}\.[a-z0-9]+$/i.test(path7.basename(file));
|
|
5546
5731
|
res.writeHead(200, {
|
|
5547
|
-
"content-type": MIME[
|
|
5732
|
+
"content-type": MIME[path7.extname(file)] ?? "application/octet-stream",
|
|
5548
5733
|
"cache-control": hashedAsset ? "public, max-age=31536000, immutable" : "no-cache"
|
|
5549
5734
|
});
|
|
5550
|
-
|
|
5735
|
+
fs6.createReadStream(file).pipe(res);
|
|
5551
5736
|
}
|
|
5552
5737
|
function handleAttach(ws, deps) {
|
|
5553
5738
|
const { registry, store } = deps;
|
|
@@ -5758,7 +5943,7 @@ function buildRegistration(message, deps) {
|
|
|
5758
5943
|
if (command.serviceType && command.serviceType !== "http" && (message.upstreamPort !== command.upstream || message.stablePort !== undefined)) {
|
|
5759
5944
|
throw new Error("database registration must use its configured published upstream port");
|
|
5760
5945
|
}
|
|
5761
|
-
const cwd = command.cwd ?
|
|
5946
|
+
const cwd = command.cwd ? path7.resolve(project.directory, command.cwd) : project.directory;
|
|
5762
5947
|
requireDirectory(cwd);
|
|
5763
5948
|
return {
|
|
5764
5949
|
key: serviceKey(project.id, command.name),
|
|
@@ -5778,7 +5963,7 @@ function buildRegistration(message, deps) {
|
|
|
5778
5963
|
}
|
|
5779
5964
|
function requireDirectory(cwd) {
|
|
5780
5965
|
try {
|
|
5781
|
-
if (!
|
|
5966
|
+
if (!fs6.statSync(cwd).isDirectory())
|
|
5782
5967
|
throw new Error("not a directory");
|
|
5783
5968
|
} catch {
|
|
5784
5969
|
throw new Error(`working directory does not exist or is not a directory: ${cwd}`);
|
|
@@ -6038,7 +6223,7 @@ class Registry {
|
|
|
6038
6223
|
res.writeHead(503).end("LocalDeck: service stopped");
|
|
6039
6224
|
return;
|
|
6040
6225
|
}
|
|
6041
|
-
proxyRequest(req, res, t, { onRequest: (r) => this.store.request(r) });
|
|
6226
|
+
proxyRequest(req, res, t, { responseBodies: this.store.responseBodies, onRequest: (r) => this.store.request(r) });
|
|
6042
6227
|
});
|
|
6043
6228
|
entry.sockets = new Set;
|
|
6044
6229
|
entry.server.on("connection", (socket) => {
|
|
@@ -6049,7 +6234,7 @@ class Registry {
|
|
|
6049
6234
|
const t = this.target(key);
|
|
6050
6235
|
if (!t)
|
|
6051
6236
|
return socket.destroy();
|
|
6052
|
-
proxyUpgrade(req, socket, head, t, { onRequest: (r) => this.store.request(r) });
|
|
6237
|
+
proxyUpgrade(req, socket, head, t, { responseBodies: this.store.responseBodies, onRequest: (r) => this.store.request(r) });
|
|
6053
6238
|
});
|
|
6054
6239
|
await new Promise((resolve, reject) => {
|
|
6055
6240
|
entry.server.once("error", reject);
|
|
@@ -6243,8 +6428,86 @@ class Registry {
|
|
|
6243
6428
|
}
|
|
6244
6429
|
|
|
6245
6430
|
// ../daemon/src/store.ts
|
|
6246
|
-
import
|
|
6247
|
-
|
|
6431
|
+
import fs7 from "node:fs";
|
|
6432
|
+
|
|
6433
|
+
// ../daemon/src/history.ts
|
|
6434
|
+
function isAfterHistoryBoundary(id, boundary) {
|
|
6435
|
+
if (id.startsWith("legacy-"))
|
|
6436
|
+
return false;
|
|
6437
|
+
const [started, sequence] = id.split("-").map((value) => Number.parseInt(value, 36));
|
|
6438
|
+
const [boundaryStarted, boundarySequence] = boundary.split("-").map((value) => Number.parseInt(value, 36));
|
|
6439
|
+
return started > boundaryStarted || started === boundaryStarted && sequence > boundarySequence;
|
|
6440
|
+
}
|
|
6441
|
+
|
|
6442
|
+
// ../daemon/src/response-bodies.ts
|
|
6443
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
6444
|
+
var RESPONSE_CACHE_BYTES = 64 * 1024 * 1024;
|
|
6445
|
+
var MAX_CAPTURES = 5000;
|
|
6446
|
+
|
|
6447
|
+
class ResponseBodies {
|
|
6448
|
+
capacity;
|
|
6449
|
+
entries = new Map;
|
|
6450
|
+
bytes = 0;
|
|
6451
|
+
constructor(capacity = RESPONSE_CACHE_BYTES) {
|
|
6452
|
+
this.capacity = capacity;
|
|
6453
|
+
}
|
|
6454
|
+
start(service) {
|
|
6455
|
+
const id = randomUUID2();
|
|
6456
|
+
while (this.entries.size >= MAX_CAPTURES)
|
|
6457
|
+
this.remove(this.entries.keys().next().value);
|
|
6458
|
+
this.entries.set(id, { service, body: new BodyCapture(this.capacity), bytes: 0, finished: false, complete: false });
|
|
6459
|
+
return {
|
|
6460
|
+
id,
|
|
6461
|
+
append: (chunk) => {
|
|
6462
|
+
const entry = this.entries.get(id);
|
|
6463
|
+
if (!entry || entry.finished)
|
|
6464
|
+
return;
|
|
6465
|
+
if (entry.bytes + chunk.length > this.capacity) {
|
|
6466
|
+
this.remove(id);
|
|
6467
|
+
return;
|
|
6468
|
+
}
|
|
6469
|
+
while (this.bytes + chunk.length > this.capacity) {
|
|
6470
|
+
const oldest = this.entries.keys().next().value;
|
|
6471
|
+
this.remove(oldest);
|
|
6472
|
+
if (oldest === id)
|
|
6473
|
+
return;
|
|
6474
|
+
}
|
|
6475
|
+
entry.body.append(chunk);
|
|
6476
|
+
entry.bytes += chunk.length;
|
|
6477
|
+
this.bytes += chunk.length;
|
|
6478
|
+
},
|
|
6479
|
+
finish: (complete, encoding) => {
|
|
6480
|
+
const entry = this.entries.get(id);
|
|
6481
|
+
if (entry) {
|
|
6482
|
+
entry.finished = true;
|
|
6483
|
+
entry.complete = complete;
|
|
6484
|
+
entry.encoding = encoding;
|
|
6485
|
+
}
|
|
6486
|
+
}
|
|
6487
|
+
};
|
|
6488
|
+
}
|
|
6489
|
+
get(id, service) {
|
|
6490
|
+
const entry = this.entries.get(id);
|
|
6491
|
+
if (!entry?.finished || entry.service !== service)
|
|
6492
|
+
return;
|
|
6493
|
+
return entry.body.snapshot(entry.complete, entry.encoding);
|
|
6494
|
+
}
|
|
6495
|
+
clear(service) {
|
|
6496
|
+
for (const [id, entry] of this.entries)
|
|
6497
|
+
if (!service || entry.service === service)
|
|
6498
|
+
this.remove(id);
|
|
6499
|
+
}
|
|
6500
|
+
remove(id) {
|
|
6501
|
+
const entry = this.entries.get(id);
|
|
6502
|
+
if (entry) {
|
|
6503
|
+
this.bytes -= entry.bytes;
|
|
6504
|
+
this.entries.delete(id);
|
|
6505
|
+
}
|
|
6506
|
+
}
|
|
6507
|
+
}
|
|
6508
|
+
|
|
6509
|
+
// ../daemon/src/store.ts
|
|
6510
|
+
import path8 from "node:path";
|
|
6248
6511
|
import { createHash as createHash2 } from "node:crypto";
|
|
6249
6512
|
import { EventEmitter } from "node:events";
|
|
6250
6513
|
import { finished } from "node:stream/promises";
|
|
@@ -6288,6 +6551,7 @@ function emptyStats() {
|
|
|
6288
6551
|
}
|
|
6289
6552
|
|
|
6290
6553
|
class Store extends EventEmitter {
|
|
6554
|
+
responseBodies = new ResponseBodies;
|
|
6291
6555
|
buckets = new Map;
|
|
6292
6556
|
logsDir;
|
|
6293
6557
|
daemonStartedAt;
|
|
@@ -6302,12 +6566,12 @@ class Store extends EventEmitter {
|
|
|
6302
6566
|
this.logsDir = options.logsDir ?? LOGS_DIR;
|
|
6303
6567
|
this.daemonStartedAt = options.daemonStartedAt ?? Date.now();
|
|
6304
6568
|
this.closeAfterMs = options.closeAfterMs ?? DEFAULT_CLOSE_AFTER_MS;
|
|
6305
|
-
this.streamFactory = options.streamFactory ?? ((file, streamOptions) =>
|
|
6569
|
+
this.streamFactory = options.streamFactory ?? ((file, streamOptions) => fs7.createWriteStream(file, streamOptions));
|
|
6306
6570
|
this.maxFileBytes = Math.max(1, options.maxFileBytes ?? 5 * 1024 * 1024);
|
|
6307
6571
|
if (options.logsDir === undefined)
|
|
6308
6572
|
ensureDirs();
|
|
6309
6573
|
else
|
|
6310
|
-
|
|
6574
|
+
fs7.mkdirSync(this.logsDir, { recursive: true });
|
|
6311
6575
|
}
|
|
6312
6576
|
nextId() {
|
|
6313
6577
|
return `${this.daemonStartedAt.toString(36)}-${(++this.sequence).toString(36)}`;
|
|
@@ -6318,19 +6582,21 @@ class Store extends EventEmitter {
|
|
|
6318
6582
|
}
|
|
6319
6583
|
historyFile(service, type) {
|
|
6320
6584
|
const suffix = type === "log" ? "log.jsonl" : "requests.jsonl";
|
|
6321
|
-
return
|
|
6585
|
+
return path8.join(this.logsDir, `${encodeURIComponent(service)}.${suffix}`);
|
|
6322
6586
|
}
|
|
6323
6587
|
legacyHistoryFile(service, type) {
|
|
6324
6588
|
const safe = service.replace(/[^a-z0-9_-]/gi, "_");
|
|
6325
6589
|
const suffix = type === "log" ? "log.jsonl" : "requests.jsonl";
|
|
6326
|
-
return
|
|
6590
|
+
return path8.join(this.logsDir, `${safe}.${suffix}`);
|
|
6327
6591
|
}
|
|
6328
6592
|
loadHistory(file, type, service, ring, legacyService, budget = this.maxFileBytes) {
|
|
6593
|
+
const marker2 = `${this.historyFile(service, type)}.cleared`;
|
|
6594
|
+
const boundary = fs7.existsSync(marker2) ? fs7.readFileSync(marker2, "utf8") : undefined;
|
|
6329
6595
|
let fd;
|
|
6330
6596
|
let size;
|
|
6331
6597
|
try {
|
|
6332
|
-
fd =
|
|
6333
|
-
size =
|
|
6598
|
+
fd = fs7.openSync(file, "r");
|
|
6599
|
+
size = fs7.fstatSync(fd).size;
|
|
6334
6600
|
} catch (error) {
|
|
6335
6601
|
if (error.code === "ENOENT")
|
|
6336
6602
|
return 0;
|
|
@@ -6341,9 +6607,9 @@ class Store extends EventEmitter {
|
|
|
6341
6607
|
const bytes = Buffer.alloc(count);
|
|
6342
6608
|
try {
|
|
6343
6609
|
if (count)
|
|
6344
|
-
|
|
6610
|
+
fs7.readSync(fd, bytes, 0, count, start);
|
|
6345
6611
|
} finally {
|
|
6346
|
-
|
|
6612
|
+
fs7.closeSync(fd);
|
|
6347
6613
|
}
|
|
6348
6614
|
let lines = bytes.toString("binary").split(`
|
|
6349
6615
|
`);
|
|
@@ -6363,7 +6629,8 @@ class Store extends EventEmitter {
|
|
|
6363
6629
|
const input = parsed;
|
|
6364
6630
|
if (input.service === service || input.service === legacyService) {
|
|
6365
6631
|
const id = typeof input.id === "string" ? input.id : this.compatibilityId(type, service, offset, serialized);
|
|
6366
|
-
|
|
6632
|
+
if (!boundary || isAfterHistoryBoundary(id, boundary))
|
|
6633
|
+
ring.push({ ...input, service, id });
|
|
6367
6634
|
}
|
|
6368
6635
|
}
|
|
6369
6636
|
} catch {}
|
|
@@ -6405,14 +6672,14 @@ class Store extends EventEmitter {
|
|
|
6405
6672
|
const rotatedReq = `${reqFile}.1`;
|
|
6406
6673
|
const logCurrentSize = (() => {
|
|
6407
6674
|
try {
|
|
6408
|
-
return
|
|
6675
|
+
return fs7.statSync(logFile).size;
|
|
6409
6676
|
} catch {
|
|
6410
6677
|
return 0;
|
|
6411
6678
|
}
|
|
6412
6679
|
})();
|
|
6413
6680
|
const reqCurrentSize = (() => {
|
|
6414
6681
|
try {
|
|
6415
|
-
return
|
|
6682
|
+
return fs7.statSync(reqFile).size;
|
|
6416
6683
|
} catch {
|
|
6417
6684
|
return 0;
|
|
6418
6685
|
}
|
|
@@ -6479,6 +6746,42 @@ class Store extends EventEmitter {
|
|
|
6479
6746
|
requests(service, limit = 200) {
|
|
6480
6747
|
return this.bucket(service).requests.last(limit);
|
|
6481
6748
|
}
|
|
6749
|
+
historyBoundary(service, kind) {
|
|
6750
|
+
const marker2 = `${this.historyFile(service, kind === "logs" ? "log" : "request")}.cleared`;
|
|
6751
|
+
return fs7.existsSync(marker2) ? fs7.readFileSync(marker2, "utf8") : undefined;
|
|
6752
|
+
}
|
|
6753
|
+
async clearHistory(service, kind) {
|
|
6754
|
+
if (this.closing)
|
|
6755
|
+
throw new Error("History is unavailable during shutdown");
|
|
6756
|
+
const bucket = this.bucket(service);
|
|
6757
|
+
const state = kind === "logs" ? bucket.logFile : bucket.reqFile;
|
|
6758
|
+
const beforeId = this.nextId();
|
|
6759
|
+
const marker2 = `${state.file}.cleared`;
|
|
6760
|
+
fs7.writeFileSync(`${marker2}.tmp`, beforeId, { mode: 384 });
|
|
6761
|
+
fs7.renameSync(`${marker2}.tmp`, marker2);
|
|
6762
|
+
bucket[kind].clear();
|
|
6763
|
+
if (kind === "requests")
|
|
6764
|
+
this.responseBodies.clear(service);
|
|
6765
|
+
const operation = state.queue.then(async () => {
|
|
6766
|
+
await this.closeStream(state.stream);
|
|
6767
|
+
fs7.writeFileSync(state.file, "", { mode: 384 });
|
|
6768
|
+
fs7.rmSync(`${state.file}.1`, { force: true });
|
|
6769
|
+
state.bytes = 0;
|
|
6770
|
+
state.stream = this.streamFactory(state.file, { flags: "a", mode: 384, highWaterMark: 65536 });
|
|
6771
|
+
state.disabled = false;
|
|
6772
|
+
state.stream.on("error", () => {
|
|
6773
|
+
state.disabled = true;
|
|
6774
|
+
this.reportPersistenceFailure(service, bucket, "history");
|
|
6775
|
+
});
|
|
6776
|
+
});
|
|
6777
|
+
state.queue = operation.catch(() => {
|
|
6778
|
+
state.disabled = true;
|
|
6779
|
+
this.reportPersistenceFailure(service, bucket, "history clear");
|
|
6780
|
+
});
|
|
6781
|
+
const event = this.emitEvent({ type: "history:cleared", service, kind, beforeId });
|
|
6782
|
+
await operation;
|
|
6783
|
+
return event;
|
|
6784
|
+
}
|
|
6482
6785
|
emitEvent(ev) {
|
|
6483
6786
|
const event = { ...ev, eventId: this.nextId() };
|
|
6484
6787
|
this.emit("event", event);
|
|
@@ -6487,6 +6790,7 @@ class Store extends EventEmitter {
|
|
|
6487
6790
|
close() {
|
|
6488
6791
|
if (!this.closePromise) {
|
|
6489
6792
|
this.closing = true;
|
|
6793
|
+
this.responseBodies.clear();
|
|
6490
6794
|
const buckets = [...this.buckets.values()];
|
|
6491
6795
|
this.closePromise = (async () => {
|
|
6492
6796
|
const states = buckets.flatMap((bucket) => [bucket.logFile, bucket.reqFile]);
|
|
@@ -6548,12 +6852,12 @@ class Store extends EventEmitter {
|
|
|
6548
6852
|
} });
|
|
6549
6853
|
}
|
|
6550
6854
|
try {
|
|
6551
|
-
if (
|
|
6552
|
-
|
|
6855
|
+
if (fs7.existsSync(file))
|
|
6856
|
+
fs7.chmodSync(file, 384);
|
|
6553
6857
|
} catch {}
|
|
6554
6858
|
const state = { stream, file, bytes: (() => {
|
|
6555
6859
|
try {
|
|
6556
|
-
return
|
|
6860
|
+
return fs7.statSync(file).size;
|
|
6557
6861
|
} catch {
|
|
6558
6862
|
return 0;
|
|
6559
6863
|
}
|
|
@@ -6627,8 +6931,8 @@ class Store extends EventEmitter {
|
|
|
6627
6931
|
}
|
|
6628
6932
|
const rotated = `${state.file}.1`;
|
|
6629
6933
|
try {
|
|
6630
|
-
|
|
6631
|
-
|
|
6934
|
+
fs7.rmSync(rotated, { force: true });
|
|
6935
|
+
fs7.renameSync(state.file, rotated);
|
|
6632
6936
|
} catch {
|
|
6633
6937
|
state.disabled = true;
|
|
6634
6938
|
this.reportPersistenceFailure(service, bucket, "history rotation");
|
|
@@ -6636,7 +6940,7 @@ class Store extends EventEmitter {
|
|
|
6636
6940
|
}
|
|
6637
6941
|
state.stream = this.streamFactory(state.file, { flags: "a", mode: 384, highWaterMark: 65536 });
|
|
6638
6942
|
try {
|
|
6639
|
-
|
|
6943
|
+
fs7.chmodSync(state.file, 384);
|
|
6640
6944
|
} catch {}
|
|
6641
6945
|
state.bytes = 0;
|
|
6642
6946
|
state.stream.on("error", () => {
|
|
@@ -6682,8 +6986,8 @@ function settlesWithin2(promise, timeoutMs) {
|
|
|
6682
6986
|
|
|
6683
6987
|
// ../daemon/src/supervisor.ts
|
|
6684
6988
|
import { spawn as spawn3 } from "node:child_process";
|
|
6685
|
-
import
|
|
6686
|
-
import
|
|
6989
|
+
import path9 from "node:path";
|
|
6990
|
+
import fs8 from "node:fs";
|
|
6687
6991
|
|
|
6688
6992
|
// ../daemon/src/startup-guidance.ts
|
|
6689
6993
|
function startupGuidance(message) {
|
|
@@ -6737,7 +7041,7 @@ async function waitForReadiness(command, getService, options = {}) {
|
|
|
6737
7041
|
await delay2(Math.min(command.ready.intervalMs, afterProbe), command.name, options.signal);
|
|
6738
7042
|
}
|
|
6739
7043
|
}
|
|
6740
|
-
function probeHttp(service,
|
|
7044
|
+
function probeHttp(service, path9, expectedStatus, remainingMs, signal) {
|
|
6741
7045
|
return new Promise((resolve, reject) => {
|
|
6742
7046
|
if (signal?.aborted)
|
|
6743
7047
|
return reject(new Error(`${service.name} readiness cancelled`));
|
|
@@ -6755,7 +7059,7 @@ function probeHttp(service, path8, expectedStatus, remainingMs, signal) {
|
|
|
6755
7059
|
const request = http7.request({
|
|
6756
7060
|
host: service.upstreamHost,
|
|
6757
7061
|
port: service.upstreamPort,
|
|
6758
|
-
path:
|
|
7062
|
+
path: path9,
|
|
6759
7063
|
method: "GET",
|
|
6760
7064
|
timeout: Math.max(1, Math.min(remainingMs, 2000))
|
|
6761
7065
|
}, (response) => {
|
|
@@ -6859,6 +7163,81 @@ function assertNotCancelled(name, signal) {
|
|
|
6859
7163
|
throw new Error(`${name} readiness cancelled`);
|
|
6860
7164
|
}
|
|
6861
7165
|
|
|
7166
|
+
// ../daemon/src/secret-output.ts
|
|
7167
|
+
import { Transform } from "node:stream";
|
|
7168
|
+
import { StringDecoder as StringDecoder2 } from "node:string_decoder";
|
|
7169
|
+
import { stripVTControlCharacters } from "node:util";
|
|
7170
|
+
function secretOutput(values) {
|
|
7171
|
+
const decoder = new StringDecoder2("utf8");
|
|
7172
|
+
let pending = "";
|
|
7173
|
+
let control = "text";
|
|
7174
|
+
function normalize2(text) {
|
|
7175
|
+
if (!values.size)
|
|
7176
|
+
return text;
|
|
7177
|
+
let result = "";
|
|
7178
|
+
for (const character of text) {
|
|
7179
|
+
if (control === "text") {
|
|
7180
|
+
if (character === "\x1B")
|
|
7181
|
+
control = "escape";
|
|
7182
|
+
else if (character === "")
|
|
7183
|
+
control = "csi";
|
|
7184
|
+
else
|
|
7185
|
+
result += character;
|
|
7186
|
+
} else if (control === "escape") {
|
|
7187
|
+
control = character === "[" ? "csi" : character === "]" ? "osc" : character >= " " && character <= "/" ? "intermediate" : "text";
|
|
7188
|
+
} else if (control === "intermediate") {
|
|
7189
|
+
if (character >= "0" && character <= "~")
|
|
7190
|
+
control = "text";
|
|
7191
|
+
} else if (control === "csi") {
|
|
7192
|
+
if (character >= "@" && character <= "~")
|
|
7193
|
+
control = "text";
|
|
7194
|
+
} else if (control === "osc") {
|
|
7195
|
+
if (character === "\x07")
|
|
7196
|
+
control = "text";
|
|
7197
|
+
else if (character === "\x1B")
|
|
7198
|
+
control = "osc-escape";
|
|
7199
|
+
} else {
|
|
7200
|
+
control = character === "\\" || character === "\x07" ? "text" : character === "\x1B" ? "osc-escape" : "osc";
|
|
7201
|
+
}
|
|
7202
|
+
}
|
|
7203
|
+
return result;
|
|
7204
|
+
}
|
|
7205
|
+
function consume(final) {
|
|
7206
|
+
const secrets = [...values].map(stripVTControlCharacters).filter(Boolean).sort((a, b) => b.length - a.length);
|
|
7207
|
+
if (!secrets.length) {
|
|
7208
|
+
const output2 = pending;
|
|
7209
|
+
pending = "";
|
|
7210
|
+
return output2;
|
|
7211
|
+
}
|
|
7212
|
+
let output = "";
|
|
7213
|
+
let index = 0;
|
|
7214
|
+
while (index < pending.length) {
|
|
7215
|
+
const remaining = pending.slice(index);
|
|
7216
|
+
if (!final && secrets.some((value) => value.length > remaining.length && value.startsWith(remaining)))
|
|
7217
|
+
break;
|
|
7218
|
+
const match = secrets.find((value) => pending.startsWith(value, index));
|
|
7219
|
+
if (match) {
|
|
7220
|
+
output += "[REDACTED]";
|
|
7221
|
+
index += match.length;
|
|
7222
|
+
} else {
|
|
7223
|
+
output += pending[index++];
|
|
7224
|
+
}
|
|
7225
|
+
}
|
|
7226
|
+
pending = pending.slice(index);
|
|
7227
|
+
return output;
|
|
7228
|
+
}
|
|
7229
|
+
return new Transform({
|
|
7230
|
+
transform(chunk, _encoding, callback) {
|
|
7231
|
+
pending += normalize2(decoder.write(chunk));
|
|
7232
|
+
callback(null, consume(false));
|
|
7233
|
+
},
|
|
7234
|
+
flush(callback) {
|
|
7235
|
+
pending += normalize2(decoder.end());
|
|
7236
|
+
callback(null, consume(true));
|
|
7237
|
+
}
|
|
7238
|
+
});
|
|
7239
|
+
}
|
|
7240
|
+
|
|
6862
7241
|
// ../daemon/src/supervisor.ts
|
|
6863
7242
|
var DEFAULT_FORCE_AFTER_MS = 5000;
|
|
6864
7243
|
var DEFAULT_DETECT_AFTER_MS = 300;
|
|
@@ -6878,8 +7257,10 @@ class ProcessSupervisor {
|
|
|
6878
7257
|
killTree;
|
|
6879
7258
|
shareLifecycle;
|
|
6880
7259
|
allocatePort;
|
|
7260
|
+
resolveSecrets;
|
|
6881
7261
|
upstreamReservations = new Set;
|
|
6882
7262
|
retries = new Map;
|
|
7263
|
+
redactionValues = new Set;
|
|
6883
7264
|
retryDelayMs;
|
|
6884
7265
|
shuttingDown = false;
|
|
6885
7266
|
shutdownPromise;
|
|
@@ -6894,6 +7275,7 @@ class ProcessSupervisor {
|
|
|
6894
7275
|
this.killTree = options.killProcessTree ?? killProcessTree;
|
|
6895
7276
|
this.shareLifecycle = options.shareLifecycle;
|
|
6896
7277
|
this.allocatePort = options.getFreePort ?? getFreePort;
|
|
7278
|
+
this.resolveSecrets = options.resolveProjectSecrets ?? resolveProjectSecrets;
|
|
6897
7279
|
}
|
|
6898
7280
|
start(projectId2, serviceName2) {
|
|
6899
7281
|
const name = serviceName2.toLowerCase();
|
|
@@ -6952,11 +7334,11 @@ class ProcessSupervisor {
|
|
|
6952
7334
|
return started;
|
|
6953
7335
|
const hasDependents = project.commands.some((candidate) => candidate.after.includes(command.name));
|
|
6954
7336
|
if (command.ready.type === "tcp" && !hasDependents) {
|
|
6955
|
-
const cwd2 = command.cwd ?
|
|
7337
|
+
const cwd2 = command.cwd ? path9.resolve(project.directory, command.cwd) : project.directory;
|
|
6956
7338
|
this.verifyReadiness(command, key, cwd2);
|
|
6957
7339
|
return started;
|
|
6958
7340
|
}
|
|
6959
|
-
const cwd = command.cwd ?
|
|
7341
|
+
const cwd = command.cwd ? path9.resolve(project.directory, command.cwd) : project.directory;
|
|
6960
7342
|
return this.verifyReadiness(command, key, cwd, started);
|
|
6961
7343
|
})();
|
|
6962
7344
|
attempts.set(command.name, attempt);
|
|
@@ -6981,7 +7363,14 @@ class ProcessSupervisor {
|
|
|
6981
7363
|
this.setReadiness(key, "checking", command.ready.type === "tcp" ? "Waiting for the service to listen" : "Running configured readiness check");
|
|
6982
7364
|
this.store.log({ ts: Date.now(), service: key, stream: "system", line: `LocalDeck: checking readiness for ${command.name}` });
|
|
6983
7365
|
try {
|
|
6984
|
-
|
|
7366
|
+
let commandEnv = processGeneration?.commandEnv;
|
|
7367
|
+
if (!commandEnv && generation) {
|
|
7368
|
+
const secretEnv = this.resolveSecrets(generation.projectId, command.secrets ?? []);
|
|
7369
|
+
for (const value of Object.values(secretEnv))
|
|
7370
|
+
this.redactionValues.add(value);
|
|
7371
|
+
commandEnv = { ...this.expandCommandEnv(command.env, command.after, generation.projectId), ...secretEnv };
|
|
7372
|
+
}
|
|
7373
|
+
await waitForReadiness(command, () => isCurrent() ? generation : undefined, { cwd, env: commandEnv });
|
|
6985
7374
|
if (!isCurrent() || generation.status === "stopped" || generation.status === "stopping")
|
|
6986
7375
|
return failure(key, command.name, "readiness cancelled");
|
|
6987
7376
|
const message = command.ready.type === "tcp" ? "Listening on its upstream port" : "Configured readiness check passed";
|
|
@@ -7095,13 +7484,21 @@ class ProcessSupervisor {
|
|
|
7095
7484
|
const project = this.projects.details(projectId2);
|
|
7096
7485
|
if (!project.available)
|
|
7097
7486
|
throw new ProjectError("invalid", project.error);
|
|
7098
|
-
const cwd = command.cwd ?
|
|
7487
|
+
const cwd = command.cwd ? path9.resolve(project.directory, command.cwd) : project.directory;
|
|
7099
7488
|
try {
|
|
7100
|
-
if (!
|
|
7489
|
+
if (!fs8.statSync(cwd).isDirectory())
|
|
7101
7490
|
throw new Error("not a directory");
|
|
7102
7491
|
} catch {
|
|
7103
7492
|
throw new ProjectError("invalid", `configured working directory does not exist or is not a directory: ${cwd}`);
|
|
7104
7493
|
}
|
|
7494
|
+
let secretEnv;
|
|
7495
|
+
try {
|
|
7496
|
+
secretEnv = this.resolveSecrets(projectId2, command.secrets ?? []);
|
|
7497
|
+
for (const value of Object.values(secretEnv))
|
|
7498
|
+
this.redactionValues.add(value);
|
|
7499
|
+
} catch (error) {
|
|
7500
|
+
return failure(key, name, errorMessage2(error));
|
|
7501
|
+
}
|
|
7105
7502
|
let upstreamPort;
|
|
7106
7503
|
try {
|
|
7107
7504
|
upstreamPort = command.upstream ?? await this.reserveAutomaticUpstream();
|
|
@@ -7111,7 +7508,7 @@ class ProcessSupervisor {
|
|
|
7111
7508
|
const reservedUpstreamPort = command.upstream === undefined ? upstreamPort : undefined;
|
|
7112
7509
|
let commandEnv;
|
|
7113
7510
|
try {
|
|
7114
|
-
commandEnv = this.expandCommandEnv(command.env, command.after, projectId2);
|
|
7511
|
+
commandEnv = { ...this.expandCommandEnv(command.env, command.after, projectId2), ...secretEnv };
|
|
7115
7512
|
} catch (error) {
|
|
7116
7513
|
this.releaseAutomaticUpstream(reservedUpstreamPort);
|
|
7117
7514
|
return failure(key, name, errorMessage2(error));
|
|
@@ -7170,7 +7567,8 @@ class ProcessSupervisor {
|
|
|
7170
7567
|
completion: new Promise((resolve) => {
|
|
7171
7568
|
complete = resolve;
|
|
7172
7569
|
}),
|
|
7173
|
-
reservedUpstreamPort
|
|
7570
|
+
reservedUpstreamPort,
|
|
7571
|
+
commandEnv
|
|
7174
7572
|
};
|
|
7175
7573
|
this.active.set(key, managed);
|
|
7176
7574
|
let finalized = false;
|
|
@@ -7209,7 +7607,7 @@ class ProcessSupervisor {
|
|
|
7209
7607
|
ts: Date.now(),
|
|
7210
7608
|
service: key,
|
|
7211
7609
|
stream: "system",
|
|
7212
|
-
line: `process exited (${launchError2 ? launchError2.message : signal ?? `code ${code}`})
|
|
7610
|
+
line: redactDiagnostic(`process exited (${launchError2 ? launchError2.message : signal ?? `code ${code}`})`, [...this.redactionValues])
|
|
7213
7611
|
});
|
|
7214
7612
|
if (retainForCleanup) {
|
|
7215
7613
|
const current = this.registry.get(key);
|
|
@@ -7232,10 +7630,10 @@ class ProcessSupervisor {
|
|
|
7232
7630
|
}
|
|
7233
7631
|
}
|
|
7234
7632
|
};
|
|
7235
|
-
streamLines(child.stdout, (line) => {
|
|
7633
|
+
streamLines(child.stdout.pipe(secretOutput(this.redactionValues)), (line) => {
|
|
7236
7634
|
this.store.log({ ts: Date.now(), service: key, stream: "stdout", line: stripAnsi(line) });
|
|
7237
7635
|
});
|
|
7238
|
-
streamLines(child.stderr, (line) => {
|
|
7636
|
+
streamLines(child.stderr.pipe(secretOutput(this.redactionValues)), (line) => {
|
|
7239
7637
|
const clean = stripAnsi(line);
|
|
7240
7638
|
managed.stderr.push(clean);
|
|
7241
7639
|
if (managed.stderr.length > STDERR_SUMMARY_LINES)
|
|
@@ -7315,7 +7713,7 @@ class ProcessSupervisor {
|
|
|
7315
7713
|
else {
|
|
7316
7714
|
const command = this.projects.resolve(projectId2, name);
|
|
7317
7715
|
const project = this.projects.details(projectId2);
|
|
7318
|
-
const cwd = command.cwd ?
|
|
7716
|
+
const cwd = command.cwd ? path9.resolve(project.directory, command.cwd) : project.directory;
|
|
7319
7717
|
this.verifyReadiness(command, key, cwd);
|
|
7320
7718
|
}
|
|
7321
7719
|
return result;
|
|
@@ -7591,7 +7989,7 @@ async function waitForProcessGroupExit(groupId, timeoutMs) {
|
|
|
7591
7989
|
return true;
|
|
7592
7990
|
}
|
|
7593
7991
|
// ../daemon/package.json
|
|
7594
|
-
var version = "1.0
|
|
7992
|
+
var version = "1.2.0";
|
|
7595
7993
|
|
|
7596
7994
|
// ../daemon/src/index.ts
|
|
7597
7995
|
var VERSION = version;
|
|
@@ -7607,8 +8005,8 @@ async function startDaemon(opts = {}) {
|
|
|
7607
8005
|
writePrivateState: writePrivateJson,
|
|
7608
8006
|
...opts.dependencies
|
|
7609
8007
|
};
|
|
7610
|
-
const stateFile = opts.homeDir ?
|
|
7611
|
-
const projectsFile = opts.homeDir ?
|
|
8008
|
+
const stateFile = opts.homeDir ? path10.join(opts.homeDir, "daemon.json") : STATE_FILE;
|
|
8009
|
+
const projectsFile = opts.homeDir ? path10.join(opts.homeDir, "projects.json") : PROJECTS_FILE;
|
|
7612
8010
|
const apiPort = opts.apiPort ?? DEFAULT_API_PORT;
|
|
7613
8011
|
const store = dependencies.createStore();
|
|
7614
8012
|
const registry = dependencies.createRegistry(store, apiPort);
|
|
@@ -7668,16 +8066,16 @@ async function startDaemon(opts = {}) {
|
|
|
7668
8066
|
if (stateCleanup === "failed-write") {
|
|
7669
8067
|
if (!stateWriteAttempted)
|
|
7670
8068
|
return;
|
|
7671
|
-
|
|
8069
|
+
fs9.rmSync(`${stateFile}.${process.pid}.tmp`, { force: true });
|
|
7672
8070
|
if (priorState)
|
|
7673
8071
|
restorePrivateState(stateFile, priorState.contents, priorState.mode);
|
|
7674
8072
|
else
|
|
7675
|
-
|
|
8073
|
+
fs9.rmSync(stateFile, { force: true });
|
|
7676
8074
|
return;
|
|
7677
8075
|
}
|
|
7678
8076
|
const state = readJson(stateFile, null);
|
|
7679
8077
|
if (state?.pid === process.pid)
|
|
7680
|
-
|
|
8078
|
+
fs9.unlinkSync(stateFile);
|
|
7681
8079
|
}));
|
|
7682
8080
|
return errors;
|
|
7683
8081
|
})();
|
|
@@ -7693,15 +8091,15 @@ async function startDaemon(opts = {}) {
|
|
|
7693
8091
|
stopPromise.then(resolveStopped, rejectStopped);
|
|
7694
8092
|
return stopPromise;
|
|
7695
8093
|
};
|
|
7696
|
-
server = dependencies.createApiServer({ registry, store, shares, projects, supervisor, controlToken, isShuttingDown: () => shuttingDown, trackAttachOperation: attachOperations.track, dashboardDir: opts.dashboardDir, allowedBrowserOrigins: opts.allowedBrowserOrigins, version: VERSION, startedAt, shutdown: stop });
|
|
8094
|
+
server = dependencies.createApiServer({ registry, store, shares, projects, supervisor, controlToken, isShuttingDown: () => shuttingDown, trackAttachOperation: attachOperations.track, dashboardDir: opts.dashboardDir, allowedBrowserOrigins: opts.allowedBrowserOrigins, version: VERSION, startedAt, shutdown: stop, listProjectSecrets, setProjectSecret, deleteProjectSecret, resolveProjectSecrets });
|
|
7697
8095
|
try {
|
|
7698
8096
|
await new Promise((resolve, reject) => {
|
|
7699
8097
|
server.once("error", reject);
|
|
7700
8098
|
server.listen(apiPort, "127.0.0.1", resolve);
|
|
7701
8099
|
});
|
|
7702
|
-
if (
|
|
7703
|
-
const stat =
|
|
7704
|
-
priorState = { contents:
|
|
8100
|
+
if (fs9.existsSync(stateFile)) {
|
|
8101
|
+
const stat = fs9.statSync(stateFile);
|
|
8102
|
+
priorState = { contents: fs9.readFileSync(stateFile), mode: stat.mode & 511 };
|
|
7705
8103
|
}
|
|
7706
8104
|
stateWriteAttempted = true;
|
|
7707
8105
|
dependencies.writePrivateState(stateFile, {
|
|
@@ -7722,9 +8120,9 @@ async function startDaemon(opts = {}) {
|
|
|
7722
8120
|
}
|
|
7723
8121
|
function restorePrivateState(file, contents, mode) {
|
|
7724
8122
|
const temporary = `${file}.${process.pid}.restore.tmp`;
|
|
7725
|
-
|
|
7726
|
-
|
|
7727
|
-
|
|
8123
|
+
fs9.writeFileSync(temporary, contents, { mode });
|
|
8124
|
+
fs9.chmodSync(temporary, mode);
|
|
8125
|
+
fs9.renameSync(temporary, file);
|
|
7728
8126
|
}
|
|
7729
8127
|
function toError3(error) {
|
|
7730
8128
|
return error instanceof Error ? error : new Error(String(error));
|
|
@@ -7759,24 +8157,24 @@ async function findDaemon() {
|
|
|
7759
8157
|
|
|
7760
8158
|
// src/client.ts
|
|
7761
8159
|
import { spawn as spawn4, spawnSync } from "node:child_process";
|
|
7762
|
-
import
|
|
7763
|
-
import
|
|
8160
|
+
import fs10 from "node:fs";
|
|
8161
|
+
import path11 from "node:path";
|
|
7764
8162
|
import { fileURLToPath } from "node:url";
|
|
7765
8163
|
function dashboardDir() {
|
|
7766
|
-
const here =
|
|
8164
|
+
const here = path11.dirname(fileURLToPath(import.meta.url));
|
|
7767
8165
|
const candidates = [
|
|
7768
|
-
|
|
7769
|
-
|
|
8166
|
+
path11.resolve(here, "../dashboard"),
|
|
8167
|
+
path11.resolve(here, "../../dashboard/dist")
|
|
7770
8168
|
];
|
|
7771
|
-
return candidates.find((d) =>
|
|
8169
|
+
return candidates.find((d) => fs10.existsSync(path11.join(d, "index.html")));
|
|
7772
8170
|
}
|
|
7773
8171
|
function daemonCommand() {
|
|
7774
8172
|
const entry = process.argv[1];
|
|
7775
8173
|
const runningUnderBun = Boolean(process.versions.bun);
|
|
7776
8174
|
if (runningUnderBun) {
|
|
7777
|
-
const dist =
|
|
8175
|
+
const dist = path11.resolve(path11.dirname(fileURLToPath(import.meta.url)), "../dist/index.js");
|
|
7778
8176
|
const hasNode = spawnSync("node", ["--version"], { stdio: "ignore" }).status === 0;
|
|
7779
|
-
if (
|
|
8177
|
+
if (fs10.existsSync(dist) && hasNode)
|
|
7780
8178
|
return ["node", [dist, "daemon", "run"]];
|
|
7781
8179
|
console.warn("LocalDeck: starting daemon under Bun — WebSocket proxying (HMR) will not work. Run `bun run build` and make sure `node` is on PATH.");
|
|
7782
8180
|
}
|
|
@@ -7788,8 +8186,8 @@ async function ensureDaemon() {
|
|
|
7788
8186
|
const running = await findDaemon();
|
|
7789
8187
|
if (running)
|
|
7790
8188
|
return running;
|
|
7791
|
-
|
|
7792
|
-
const out =
|
|
8189
|
+
fs10.mkdirSync(path11.dirname(DAEMON_LOG), { recursive: true });
|
|
8190
|
+
const out = fs10.openSync(DAEMON_LOG, "a");
|
|
7793
8191
|
const [cmd, args] = daemonCommand();
|
|
7794
8192
|
const child = spawn4(cmd, args, { detached: true, stdio: ["ignore", out, out], env: process.env });
|
|
7795
8193
|
child.unref();
|
|
@@ -7858,4 +8256,4 @@ class Api {
|
|
|
7858
8256
|
shutdown = () => this.call("POST", "/api/shutdown");
|
|
7859
8257
|
}
|
|
7860
8258
|
|
|
7861
|
-
export { __toESM, __commonJS, __export, __require, wrapper_default, startForwarder, HOME_DIR, STATE_FILE, DEFAULT_API_PORT, findProjectFile, loadProject, loadProjectFile, shellCommand, isPortFree, getFreePort, serviceKey, processTree, listeningPorts, chooseUpstream, connectHost, streamLines, killProcessTree, stripAnsi, redactDiagnostic, environmentValues, startupGuidance, startupLayers, waitForReadiness, VERSION, startDaemon, findDaemon, dashboardDir, ensureDaemon, Api };
|
|
8259
|
+
export { __toESM, __commonJS, __export, __require, wrapper_default, startForwarder, HOME_DIR, STATE_FILE, DEFAULT_API_PORT, findProjectFile, loadProject, loadProjectFile, shellCommand, isPortFree, getFreePort, serviceKey, processTree, listeningPorts, chooseUpstream, connectHost, streamLines, killProcessTree, stripAnsi, redactDiagnostic, environmentValues, listProjectSecrets, resolveProjectSecrets, startupGuidance, startupLayers, waitForReadiness, secretOutput, VERSION, startDaemon, findDaemon, dashboardDir, ensureDaemon, Api };
|