loadout-ai 0.1.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/LICENSE +21 -0
- package/MASTER_PLAN.md +1711 -0
- package/README.md +453 -0
- package/SECURITY.md +18 -0
- package/SIMPLE_PLAN.md +44 -0
- package/catalog/discovered.json +10469 -0
- package/catalog/packages.json +1150 -0
- package/dashboard/app.js +607 -0
- package/dashboard/index.html +249 -0
- package/dashboard/styles.css +384 -0
- package/dist/src/cli.js +2735 -0
- package/dist/src/core/active-policy.js +233 -0
- package/dist/src/core/active-set.js +382 -0
- package/dist/src/core/adapters.js +254 -0
- package/dist/src/core/adopt.js +92 -0
- package/dist/src/core/agent-health-score.js +475 -0
- package/dist/src/core/agent-inspection.js +152 -0
- package/dist/src/core/agent-versions.js +131 -0
- package/dist/src/core/api.js +90 -0
- package/dist/src/core/atomic-file.js +22 -0
- package/dist/src/core/audit.js +174 -0
- package/dist/src/core/benchmark-campaign.js +496 -0
- package/dist/src/core/benchmark-evidence.js +458 -0
- package/dist/src/core/benchmark-fixtures.js +1157 -0
- package/dist/src/core/benchmark-runner.js +301 -0
- package/dist/src/core/benchmark-trust.js +795 -0
- package/dist/src/core/canary.js +77 -0
- package/dist/src/core/candidate-intelligence.js +661 -0
- package/dist/src/core/catalog-coverage.js +85 -0
- package/dist/src/core/catalog-install.js +186 -0
- package/dist/src/core/catalog-release.js +286 -0
- package/dist/src/core/catalog.js +327 -0
- package/dist/src/core/codex-mcp.js +122 -0
- package/dist/src/core/community.js +86 -0
- package/dist/src/core/compatibility-intelligence.js +465 -0
- package/dist/src/core/completion.js +160 -0
- package/dist/src/core/components.js +138 -0
- package/dist/src/core/conversion.js +56 -0
- package/dist/src/core/credentials.js +377 -0
- package/dist/src/core/demo.js +136 -0
- package/dist/src/core/diff.js +83 -0
- package/dist/src/core/discovery-connector.js +165 -0
- package/dist/src/core/doctor.js +95 -0
- package/dist/src/core/ecosystem-import.js +1072 -0
- package/dist/src/core/evaluate.js +72 -0
- package/dist/src/core/file-lock.js +140 -0
- package/dist/src/core/freshness-alerts.js +230 -0
- package/dist/src/core/github-discovery.js +97 -0
- package/dist/src/core/github.js +148 -0
- package/dist/src/core/head-to-head.js +437 -0
- package/dist/src/core/health-score-evidence.js +169 -0
- package/dist/src/core/health.js +129 -0
- package/dist/src/core/improve.js +223 -0
- package/dist/src/core/install.js +233 -0
- package/dist/src/core/intelligence-feed-build.js +59 -0
- package/dist/src/core/intelligence-feed.js +353 -0
- package/dist/src/core/loadout-badge.js +55 -0
- package/dist/src/core/loadout-card.js +87 -0
- package/dist/src/core/manifest.js +307 -0
- package/dist/src/core/mcp-recipes.js +419 -0
- package/dist/src/core/mcp-registry-discovery.js +455 -0
- package/dist/src/core/mcp.js +287 -0
- package/dist/src/core/model-config.js +102 -0
- package/dist/src/core/observations.js +87 -0
- package/dist/src/core/outcomes.js +164 -0
- package/dist/src/core/package.js +225 -0
- package/dist/src/core/paths.js +214 -0
- package/dist/src/core/portable.js +130 -0
- package/dist/src/core/private-discovery.js +45 -0
- package/dist/src/core/profiles.js +255 -0
- package/dist/src/core/provenance.js +278 -0
- package/dist/src/core/ranking.js +104 -0
- package/dist/src/core/recommend.js +181 -0
- package/dist/src/core/registry-api.js +97 -0
- package/dist/src/core/registry.js +316 -0
- package/dist/src/core/release-claims.js +175 -0
- package/dist/src/core/remove.js +122 -0
- package/dist/src/core/review-queue.js +200 -0
- package/dist/src/core/runtime-tool-recipe.js +315 -0
- package/dist/src/core/runtime-tools.js +453 -0
- package/dist/src/core/safety.js +234 -0
- package/dist/src/core/sandbox.js +78 -0
- package/dist/src/core/scheduler.js +264 -0
- package/dist/src/core/share-report.js +85 -0
- package/dist/src/core/signing.js +115 -0
- package/dist/src/core/skill-compare.js +239 -0
- package/dist/src/core/skill-inventory.js +193 -0
- package/dist/src/core/skill-security.js +340 -0
- package/dist/src/core/skills-sh-discovery.js +336 -0
- package/dist/src/core/skills.js +208 -0
- package/dist/src/core/snapshot.js +210 -0
- package/dist/src/core/source.js +305 -0
- package/dist/src/core/state.js +300 -0
- package/dist/src/core/sync.js +348 -0
- package/dist/src/core/terminal.js +53 -0
- package/dist/src/core/transaction.js +211 -0
- package/dist/src/core/update-watch.js +39 -0
- package/dist/src/core/update.js +216 -0
- package/dist/src/core/upgrade.js +207 -0
- package/dist/src/dashboard.js +418 -0
- package/dist/src/shared/schemas.js +336 -0
- package/dist/src/shared/types.js +1 -0
- package/docs/ACTIVE_SET.md +53 -0
- package/docs/ACTIVE_SET_POLICY.md +45 -0
- package/docs/CANDIDATE_INTELLIGENCE.md +139 -0
- package/docs/CATALOG.md +78 -0
- package/docs/CATALOG_POLICY.md +41 -0
- package/docs/COMMUNITY_DISCOVERY.md +14 -0
- package/docs/COMPATIBILITY_POLICY.md +22 -0
- package/docs/CONVERSION_AND_SANDBOX.md +27 -0
- package/docs/CREDENTIAL_AND_UPDATE_POLICY.md +31 -0
- package/docs/DISCOVERED.md +280 -0
- package/docs/EVALUATION_PROTOCOL.md +40 -0
- package/docs/EVALUATION_PROTOCOL_V1.md +300 -0
- package/docs/FEATURE_TEST_MATRIX.md +802 -0
- package/docs/GITHUB_AUTHORIZATION.md +38 -0
- package/docs/HEAD_TO_HEAD_EVALUATION.md +79 -0
- package/docs/PROVENANCE_AND_COMPARISON.md +72 -0
- package/docs/PROVIDER_CONFIGURATION.md +45 -0
- package/docs/RELEASE_REVIEW.md +117 -0
- package/docs/SAFE_UPDATE_DEMO.md +25 -0
- package/docs/SCHEMA_DECISIONS.md +25 -0
- package/docs/TEAM_POLICY.md +18 -0
- package/docs/TESTING.md +190 -0
- package/package.json +82 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { createServer, } from "node:http";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { detectAgents } from "./core/paths.js";
|
|
7
|
+
import { inspectAgents } from "./core/agent-inspection.js";
|
|
8
|
+
import { loadEffectiveCatalog, rankCatalog } from "./core/catalog.js";
|
|
9
|
+
import { buildUpdatePlan } from "./core/update.js";
|
|
10
|
+
import { buildHealthReport } from "./core/health.js";
|
|
11
|
+
import { recommendPackages, scanProject, TESTED_PROFILES, } from "./core/recommend.js";
|
|
12
|
+
import { searchLocalRegistry } from "./core/registry.js";
|
|
13
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
14
|
+
import { applySyncPlan, buildSyncPlan } from "./core/sync.js";
|
|
15
|
+
import { withMutationLock } from "./core/transaction.js";
|
|
16
|
+
import { readSnapshot, restoreSnapshot } from "./core/snapshot.js";
|
|
17
|
+
import { readInstallState } from "./core/state.js";
|
|
18
|
+
import { adapterCapabilities } from "./core/adapters.js";
|
|
19
|
+
function dashboardDirectory() {
|
|
20
|
+
if (process.env.LOADOUT_DASHBOARD_DIR)
|
|
21
|
+
return process.env.LOADOUT_DASHBOARD_DIR;
|
|
22
|
+
const moduleDirectory = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const candidates = [
|
|
24
|
+
join(moduleDirectory, "..", "dashboard"),
|
|
25
|
+
join(moduleDirectory, "..", "..", "dashboard"),
|
|
26
|
+
join(process.cwd(), "dashboard"),
|
|
27
|
+
];
|
|
28
|
+
return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0];
|
|
29
|
+
}
|
|
30
|
+
const publicDirectory = dashboardDirectory();
|
|
31
|
+
async function sendJson(response, status, body) {
|
|
32
|
+
response.writeHead(status, {
|
|
33
|
+
"content-type": "application/json; charset=utf-8",
|
|
34
|
+
"cache-control": "no-store",
|
|
35
|
+
"x-content-type-options": "nosniff",
|
|
36
|
+
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
|
|
37
|
+
});
|
|
38
|
+
response.end(JSON.stringify(body));
|
|
39
|
+
}
|
|
40
|
+
function safeSyncPlan(plan) {
|
|
41
|
+
return {
|
|
42
|
+
manifest: plan.manifest,
|
|
43
|
+
packages: plan.packages.map((entry) => ({
|
|
44
|
+
packageId: entry.plan.packageId,
|
|
45
|
+
targetAgents: entry.plan.targetAgents,
|
|
46
|
+
files: entry.plan.files.map((file) => ({
|
|
47
|
+
target: file.target,
|
|
48
|
+
componentType: file.componentType,
|
|
49
|
+
compatibility: file.compatibility,
|
|
50
|
+
})),
|
|
51
|
+
warnings: entry.plan.warnings,
|
|
52
|
+
safety: entry.safety,
|
|
53
|
+
})),
|
|
54
|
+
mcpChanges: plan.mcpPlans.map((entry) => ({
|
|
55
|
+
packageId: entry.packageId,
|
|
56
|
+
path: entry.plan.path,
|
|
57
|
+
changes: entry.plan.changes,
|
|
58
|
+
warnings: entry.plan.warnings,
|
|
59
|
+
})),
|
|
60
|
+
skipped: plan.skipped,
|
|
61
|
+
policyViolations: plan.policyViolations,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
function authorized(request, token) {
|
|
65
|
+
const provided = request.headers["x-loadout-token"];
|
|
66
|
+
if (typeof provided !== "string")
|
|
67
|
+
return false;
|
|
68
|
+
const a = Buffer.from(provided);
|
|
69
|
+
const b = Buffer.from(token);
|
|
70
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
71
|
+
}
|
|
72
|
+
function isLoopbackPeer(request) {
|
|
73
|
+
const address = request.socket.remoteAddress;
|
|
74
|
+
return (address === "127.0.0.1" ||
|
|
75
|
+
address === "::1" ||
|
|
76
|
+
address === "::ffff:127.0.0.1");
|
|
77
|
+
}
|
|
78
|
+
async function body(request) {
|
|
79
|
+
const chunks = [];
|
|
80
|
+
let size = 0;
|
|
81
|
+
for await (const chunk of request) {
|
|
82
|
+
const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
83
|
+
size += value.length;
|
|
84
|
+
if (size > 8_192)
|
|
85
|
+
throw new Error("Request body is too large");
|
|
86
|
+
chunks.push(value);
|
|
87
|
+
}
|
|
88
|
+
if (!chunks.length)
|
|
89
|
+
return {};
|
|
90
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
91
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
92
|
+
throw new Error("Request body must be a JSON object");
|
|
93
|
+
return parsed;
|
|
94
|
+
}
|
|
95
|
+
async function installedPayload() {
|
|
96
|
+
const [state, agents, catalog] = await Promise.all([
|
|
97
|
+
readInstallState(),
|
|
98
|
+
detectAgents(),
|
|
99
|
+
loadEffectiveCatalog(),
|
|
100
|
+
]);
|
|
101
|
+
const catalogById = new Map(catalog.map((entry) => [entry.id, entry]));
|
|
102
|
+
const agentById = new Map(agents.map((agent) => [agent.id, agent]));
|
|
103
|
+
const packages = state.installs
|
|
104
|
+
.map((entry) => {
|
|
105
|
+
const catalogEntry = catalogById.get(entry.packageId);
|
|
106
|
+
return {
|
|
107
|
+
packageId: entry.packageId,
|
|
108
|
+
displayName: catalogEntry?.displayName ?? entry.packageId,
|
|
109
|
+
repository: entry.repository ?? catalogEntry?.repository,
|
|
110
|
+
resolvedCommit: entry.resolvedCommit,
|
|
111
|
+
installedAt: entry.installedAt,
|
|
112
|
+
snapshotId: entry.snapshotId,
|
|
113
|
+
fileCount: entry.files.length,
|
|
114
|
+
targetAgents: entry.targetAgents.map((agentId) => {
|
|
115
|
+
const agent = agentById.get(agentId);
|
|
116
|
+
return {
|
|
117
|
+
id: agentId,
|
|
118
|
+
displayName: agent?.displayName ?? agentId,
|
|
119
|
+
detected: agent?.installed ?? false,
|
|
120
|
+
skillCompatibility: adapterCapabilities(agentId).components.skill,
|
|
121
|
+
};
|
|
122
|
+
}),
|
|
123
|
+
};
|
|
124
|
+
})
|
|
125
|
+
.sort((a, b) => b.installedAt.localeCompare(a.installedAt));
|
|
126
|
+
const lastKnownGoodSnapshot = packages[0]?.snapshotId;
|
|
127
|
+
return {
|
|
128
|
+
generatedAt: new Date().toISOString(),
|
|
129
|
+
platform: process.platform,
|
|
130
|
+
packages,
|
|
131
|
+
mcpServers: (state.mcpInstalls ?? []).map((entry) => ({
|
|
132
|
+
packageId: entry.packageId,
|
|
133
|
+
serverName: entry.serverName,
|
|
134
|
+
configPath: entry.configPath,
|
|
135
|
+
snapshotId: entry.snapshotId,
|
|
136
|
+
installedAt: entry.installedAt,
|
|
137
|
+
})),
|
|
138
|
+
lastKnownGoodSnapshot,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function operationPayload(operation) {
|
|
142
|
+
return operation ?? { status: "idle" };
|
|
143
|
+
}
|
|
144
|
+
async function route(request, response, context) {
|
|
145
|
+
const pathname = new URL(request.url ?? "/", "http://localhost").pathname;
|
|
146
|
+
try {
|
|
147
|
+
// Host headers are attacker-controlled. Check the actual peer too so an
|
|
148
|
+
// accidentally externally bound server cannot be reached by spoofing
|
|
149
|
+
// `Host: localhost`.
|
|
150
|
+
if (!isLoopbackPeer(request)) {
|
|
151
|
+
await sendJson(response, 403, {
|
|
152
|
+
error: "Dashboard accepts loopback peers only",
|
|
153
|
+
});
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const host = request.headers.host?.split(":")[0];
|
|
157
|
+
if (host &&
|
|
158
|
+
host !== "127.0.0.1" &&
|
|
159
|
+
host !== "localhost" &&
|
|
160
|
+
host !== "[::1]") {
|
|
161
|
+
await sendJson(response, 403, {
|
|
162
|
+
error: "Dashboard accepts loopback hosts only",
|
|
163
|
+
});
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (request.method === "POST" && request.headers.origin) {
|
|
167
|
+
let sameOrigin = false;
|
|
168
|
+
try {
|
|
169
|
+
sameOrigin =
|
|
170
|
+
new URL(request.headers.origin).host === request.headers.host;
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
/* invalid origins are denied */
|
|
174
|
+
}
|
|
175
|
+
if (!sameOrigin) {
|
|
176
|
+
await sendJson(response, 403, {
|
|
177
|
+
error: "Cross-origin dashboard mutation denied",
|
|
178
|
+
});
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (pathname === "/api/session" && request.method === "GET") {
|
|
183
|
+
await sendJson(response, 200, { token: context.token });
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if ((pathname === "/api/sync-plan" || pathname === "/api/plan") &&
|
|
187
|
+
request.method === "GET") {
|
|
188
|
+
await sendJson(response, 200, {
|
|
189
|
+
plan: safeSyncPlan(await context.buildSync(context.manifestPath)),
|
|
190
|
+
});
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if ((pathname === "/api/sync" || pathname === "/api/apply") &&
|
|
194
|
+
request.method === "POST") {
|
|
195
|
+
if (!authorized(request, context.token)) {
|
|
196
|
+
await sendJson(response, 403, { error: "Invalid dashboard session" });
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
context.operation = {
|
|
200
|
+
kind: "sync",
|
|
201
|
+
status: "running",
|
|
202
|
+
startedAt: new Date().toISOString(),
|
|
203
|
+
};
|
|
204
|
+
try {
|
|
205
|
+
const input = await body(request);
|
|
206
|
+
const plan = await context.buildSync(context.manifestPath);
|
|
207
|
+
const result = await context.applySync(plan, context.lockPath, {
|
|
208
|
+
approveRisk: input.approveRisk === true,
|
|
209
|
+
});
|
|
210
|
+
context.operation = {
|
|
211
|
+
...context.operation,
|
|
212
|
+
status: "completed",
|
|
213
|
+
finishedAt: new Date().toISOString(),
|
|
214
|
+
snapshotId: result.snapshotId,
|
|
215
|
+
};
|
|
216
|
+
await sendJson(response, 200, { result });
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
220
|
+
context.operation = {
|
|
221
|
+
...context.operation,
|
|
222
|
+
status: "failed",
|
|
223
|
+
finishedAt: new Date().toISOString(),
|
|
224
|
+
error: message,
|
|
225
|
+
};
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (pathname === "/api/rollback" && request.method === "POST") {
|
|
231
|
+
if (!authorized(request, context.token)) {
|
|
232
|
+
await sendJson(response, 403, { error: "Invalid dashboard session" });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
context.operation = {
|
|
236
|
+
kind: "rollback",
|
|
237
|
+
status: "running",
|
|
238
|
+
startedAt: new Date().toISOString(),
|
|
239
|
+
};
|
|
240
|
+
try {
|
|
241
|
+
const input = await body(request);
|
|
242
|
+
if (typeof input.snapshotId !== "string" ||
|
|
243
|
+
!/^[A-Za-z0-9-]+$/.test(input.snapshotId))
|
|
244
|
+
throw new Error("A valid snapshotId is required");
|
|
245
|
+
await context.rollback(input.snapshotId);
|
|
246
|
+
context.operation = {
|
|
247
|
+
...context.operation,
|
|
248
|
+
status: "completed",
|
|
249
|
+
finishedAt: new Date().toISOString(),
|
|
250
|
+
snapshotId: input.snapshotId,
|
|
251
|
+
};
|
|
252
|
+
await sendJson(response, 200, { restored: input.snapshotId });
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
256
|
+
context.operation = {
|
|
257
|
+
...context.operation,
|
|
258
|
+
status: "failed",
|
|
259
|
+
finishedAt: new Date().toISOString(),
|
|
260
|
+
error: message,
|
|
261
|
+
};
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (pathname.startsWith("/api/") && request.method !== "GET") {
|
|
267
|
+
await sendJson(response, 405, { error: "Method not allowed" });
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (pathname === "/api/status") {
|
|
271
|
+
const agents = await detectAgents();
|
|
272
|
+
await sendJson(response, 200, {
|
|
273
|
+
agents,
|
|
274
|
+
inventory: await inspectAgents(agents),
|
|
275
|
+
platform: process.platform,
|
|
276
|
+
});
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (pathname === "/api/catalog") {
|
|
280
|
+
const catalog = rankCatalog(await loadEffectiveCatalog());
|
|
281
|
+
await sendJson(response, 200, { packages: catalog });
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (pathname.startsWith("/api/catalog/")) {
|
|
285
|
+
const id = decodeURIComponent(pathname.slice("/api/catalog/".length));
|
|
286
|
+
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(id)) {
|
|
287
|
+
await sendJson(response, 404, { error: "Package not found" });
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const catalog = await loadEffectiveCatalog();
|
|
291
|
+
const entry = catalog.find((item) => item.id === id);
|
|
292
|
+
if (!entry) {
|
|
293
|
+
await sendJson(response, 404, { error: "Package not found" });
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
await sendJson(response, 200, { package: entry });
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (pathname === "/api/installed") {
|
|
300
|
+
await sendJson(response, 200, await installedPayload());
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (pathname === "/api/progress") {
|
|
304
|
+
await sendJson(response, 200, {
|
|
305
|
+
operation: operationPayload(context.operation),
|
|
306
|
+
});
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (pathname === "/api/health") {
|
|
310
|
+
await sendJson(response, 200, { health: await buildHealthReport() });
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (pathname === "/api/profiles") {
|
|
314
|
+
await sendJson(response, 200, { profiles: TESTED_PROFILES });
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (pathname === "/api/recommendations") {
|
|
318
|
+
const signals = await scanProject(process.cwd());
|
|
319
|
+
await sendJson(response, 200, {
|
|
320
|
+
signals,
|
|
321
|
+
recommendations: recommendPackages(signals, await loadEffectiveCatalog()),
|
|
322
|
+
});
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (pathname === "/api/registry") {
|
|
326
|
+
await sendJson(response, 200, { packages: await searchLocalRegistry() });
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (pathname === "/api/update" || pathname === "/api/updates") {
|
|
330
|
+
const updates = await buildUpdatePlan();
|
|
331
|
+
await sendJson(response, 200, { updates });
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const asset = pathname === "/" ? "index.html" : pathname.slice(1);
|
|
335
|
+
if (request.method !== "GET") {
|
|
336
|
+
await sendJson(response, 405, { error: "Method not allowed" });
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (!/^[a-z0-9._-]+$/i.test(asset)) {
|
|
340
|
+
await sendJson(response, 404, { error: "Not found" });
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const content = await readFile(join(publicDirectory, asset));
|
|
344
|
+
const type = asset.endsWith(".css")
|
|
345
|
+
? "text/css; charset=utf-8"
|
|
346
|
+
: asset.endsWith(".js")
|
|
347
|
+
? "text/javascript; charset=utf-8"
|
|
348
|
+
: "text/html; charset=utf-8";
|
|
349
|
+
response.writeHead(200, {
|
|
350
|
+
"content-type": type,
|
|
351
|
+
"cache-control": "no-store",
|
|
352
|
+
"x-content-type-options": "nosniff",
|
|
353
|
+
"x-frame-options": "DENY",
|
|
354
|
+
"content-security-policy": "default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'none'",
|
|
355
|
+
});
|
|
356
|
+
response.end(content);
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
await sendJson(response, 500, {
|
|
360
|
+
error: error instanceof Error ? error.message : String(error),
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
export function createDashboardServer(options = {}) {
|
|
365
|
+
const context = {
|
|
366
|
+
manifestPath: options.manifestPath ?? join(process.cwd(), "loadout.json"),
|
|
367
|
+
lockPath: options.lockPath ?? join(process.cwd(), "loadout.lock"),
|
|
368
|
+
buildSync: options.buildSync ?? buildSyncPlan,
|
|
369
|
+
applySync: options.applySync ?? applySyncPlan,
|
|
370
|
+
rollback: options.rollback ??
|
|
371
|
+
(async (snapshotId) => withMutationLock(async () => restoreSnapshot(await readSnapshot(snapshotId)))),
|
|
372
|
+
token: randomBytes(32).toString("hex"),
|
|
373
|
+
};
|
|
374
|
+
return createServer((request, response) => {
|
|
375
|
+
void route(request, response, context);
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
/** Start the dashboard only on loopback. Port zero requests an OS-assigned ephemeral port. */
|
|
379
|
+
export async function startDashboardServer(options = {}, port = 0) {
|
|
380
|
+
if (!Number.isInteger(port) || port < 0 || port > 65_535)
|
|
381
|
+
throw new Error("Dashboard port must be an integer between 0 and 65535");
|
|
382
|
+
const server = createDashboardServer(options);
|
|
383
|
+
await new Promise((resolve, reject) => {
|
|
384
|
+
const onError = (error) => {
|
|
385
|
+
server.off("listening", onListening);
|
|
386
|
+
reject(error);
|
|
387
|
+
};
|
|
388
|
+
const onListening = () => {
|
|
389
|
+
server.off("error", onError);
|
|
390
|
+
resolve();
|
|
391
|
+
};
|
|
392
|
+
server.once("error", onError);
|
|
393
|
+
server.once("listening", onListening);
|
|
394
|
+
server.listen(port, "127.0.0.1");
|
|
395
|
+
});
|
|
396
|
+
const address = server.address();
|
|
397
|
+
if (!address || typeof address === "string") {
|
|
398
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
399
|
+
throw new Error("Could not determine dashboard address");
|
|
400
|
+
}
|
|
401
|
+
return {
|
|
402
|
+
server,
|
|
403
|
+
host: "127.0.0.1",
|
|
404
|
+
port: address.port,
|
|
405
|
+
close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))),
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
|
409
|
+
const port = process.env.LOADOUT_PORT === undefined
|
|
410
|
+
? 0
|
|
411
|
+
: Number(process.env.LOADOUT_PORT);
|
|
412
|
+
void startDashboardServer({}, port)
|
|
413
|
+
.then((handle) => console.log(`Loadout dashboard: http://127.0.0.1:${handle.port}`))
|
|
414
|
+
.catch((error) => {
|
|
415
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
416
|
+
process.exitCode = 1;
|
|
417
|
+
});
|
|
418
|
+
}
|