loadout-ai 0.4.0 → 0.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/MASTER_PLAN.md +173 -9
  3. package/README.md +57 -19
  4. package/catalog/discovered.json +8422 -6957
  5. package/catalog/packages.json +26 -0
  6. package/dist/src/cli.js +140 -68
  7. package/dist/src/core/active-policy.js +216 -42
  8. package/dist/src/core/active-set.js +14 -5
  9. package/dist/src/core/adopt.js +1 -1
  10. package/dist/src/core/catalog-coverage.js +2 -1
  11. package/dist/src/core/catalog-install.js +23 -8
  12. package/dist/src/core/cli-guide.js +5 -2
  13. package/dist/src/core/codex-mcp.js +49 -5
  14. package/dist/src/core/completion.js +0 -3
  15. package/dist/src/core/health.js +19 -11
  16. package/dist/src/core/install.js +6 -39
  17. package/dist/src/core/mcp-recipes.js +13 -4
  18. package/dist/src/core/mcp.js +6 -5
  19. package/dist/src/core/model-config.js +1 -1
  20. package/dist/src/core/portable.js +1 -1
  21. package/dist/src/core/recommend.js +129 -27
  22. package/dist/src/core/remove.js +28 -2
  23. package/dist/src/core/runtime-tool-recipe.js +2 -2
  24. package/dist/src/core/runtime-tools.js +15 -4
  25. package/dist/src/core/snapshot.js +20 -0
  26. package/dist/src/core/state.js +9 -0
  27. package/dist/src/core/sync.js +1 -1
  28. package/dist/src/core/target-occupancy.js +50 -0
  29. package/dist/src/core/transaction.js +2 -1
  30. package/dist/src/shared/schemas.js +1 -0
  31. package/docs/CATALOG.md +3 -2
  32. package/docs/DISCOVERED.md +253 -255
  33. package/docs/FEATURE_TEST_MATRIX.md +19 -32
  34. package/docs/GITHUB_AUTHORIZATION.md +2 -2
  35. package/docs/TESTING.md +3 -16
  36. package/docs/USER_TEST_GUIDE.md +39 -13
  37. package/docs/assets/loadout-workflow.png +0 -0
  38. package/docs/evidence/readme-claims.json +0 -8
  39. package/docs/superpowers/plans/2026-07-20-loadout-readme-explainer.md +116 -0
  40. package/docs/superpowers/plans/2026-07-20-project-activation-safety.md +469 -0
  41. package/docs/superpowers/specs/2026-07-20-loadout-readme-explainer-design.md +55 -0
  42. package/docs/superpowers/specs/2026-07-20-project-activation-safety-design.md +228 -0
  43. package/package.json +4 -6
  44. package/dashboard/app.js +0 -607
  45. package/dashboard/index.html +0 -249
  46. package/dashboard/styles.css +0 -384
  47. package/dist/src/core/demo.js +0 -136
  48. package/dist/src/dashboard.js +0 -421
@@ -1,136 +0,0 @@
1
- import { mkdtemp, readFile, rm } from "node:fs/promises";
2
- import { tmpdir } from "node:os";
3
- import { join, resolve, sep } from "node:path";
4
- import { buildSkillPlan, applySkillInstall } from "./install.js";
5
- import { loadEffectiveCatalog } from "./catalog.js";
6
- import { agentSkillsDirectory } from "./paths.js";
7
- import { fetchRepositorySnapshot } from "./source.js";
8
- import { readInstallState } from "./state.js";
9
- import { restoreSnapshot } from "./snapshot.js";
10
- import { withMutationLock } from "./transaction.js";
11
- const DEFAULT_REPOSITORY = "obra/superpowers";
12
- const DEFAULT_PACKAGE_ID = "obra-superpowers";
13
- function isWithin(root, path) {
14
- const resolvedRoot = resolve(root);
15
- const resolvedPath = resolve(path);
16
- return (resolvedPath === resolvedRoot ||
17
- resolvedPath.startsWith(`${resolvedRoot}${sep}`));
18
- }
19
- /**
20
- * Run a real install and rollback inside a disposable profile. This function
21
- * deliberately creates virtual agent targets instead of detecting local
22
- * binaries: the demo proves the installer while never treating a user's real
23
- * Codex, Claude, or other agent profile as a demo target.
24
- */
25
- export async function runIsolatedDemo(options = {}) {
26
- if (options.source && options.repository)
27
- throw new Error("Demo accepts either a local source or a repository, not both");
28
- const profile = await mkdtemp(join(tmpdir(), "loadout-demo-"));
29
- const demoLoadoutHome = join(profile, ".loadout");
30
- const packageId = options.packageId ?? DEFAULT_PACKAGE_ID;
31
- const agentIds = options.agents?.length
32
- ? options.agents
33
- : ["codex"];
34
- const previousUserHome = process.env.LOADOUT_USER_HOME;
35
- const previousLoadoutHome = process.env.LOADOUT_HOME;
36
- let completed = false;
37
- process.env.LOADOUT_USER_HOME = profile;
38
- process.env.LOADOUT_HOME = demoLoadoutHome;
39
- try {
40
- const requestedRepository = options.repository ?? DEFAULT_REPOSITORY;
41
- const catalog = options.source
42
- ? []
43
- : (options.catalog ?? (await loadEffectiveCatalog()));
44
- const reviewedPackage = catalog.find((pkg) => pkg.repository.toLowerCase() === requestedRepository.toLowerCase() &&
45
- Boolean(pkg.source?.commit));
46
- if (!options.source && !options.repository && !reviewedPackage)
47
- throw new Error(`The default demo repository ${DEFAULT_REPOSITORY} is missing its reviewed catalog commit`);
48
- const fetched = options.source
49
- ? undefined
50
- : await (options.fetchSnapshot ?? fetchRepositorySnapshot)(requestedRepository, reviewedPackage?.source?.commit
51
- ? { ref: reviewedPackage.source.commit }
52
- : undefined);
53
- if (reviewedPackage?.source?.commit &&
54
- fetched.commit.toLowerCase() !==
55
- reviewedPackage.source.commit.toLowerCase())
56
- throw new Error(`Demo resolved ${fetched.commit}, expected reviewed commit ${reviewedPackage.source.commit}`);
57
- const source = options.source ?? fetched.path;
58
- const repository = fetched?.repository ?? `local source: ${resolve(source)}`;
59
- const targets = agentIds.map((id) => ({
60
- id,
61
- displayName: `${id} (isolated demo target)`,
62
- installed: true,
63
- skillsDirectory: agentSkillsDirectory(id, profile),
64
- }));
65
- const plan = await buildSkillPlan(source, packageId, targets);
66
- if (plan.files.some((file) => !isWithin(profile, file.target))) {
67
- throw new Error("Demo safety check failed: a planned target escaped the isolated profile");
68
- }
69
- const snapshotId = await applySkillInstall(plan, fetched
70
- ? { repository: fetched.repository, resolvedCommit: fetched.commit }
71
- : undefined);
72
- const state = await readInstallState();
73
- const record = state.installs.find((item) => item.packageId === packageId && item.snapshotId === snapshotId);
74
- if (!record)
75
- throw new Error("Demo safety check failed: the isolated install was not recorded");
76
- if (record.files.some((file) => !isWithin(profile, file.path))) {
77
- throw new Error("Demo safety check failed: managed state escaped the isolated profile");
78
- }
79
- let rolledBack = false;
80
- if (!options.keep) {
81
- const snapshotPath = join(demoLoadoutHome, "snapshots", `${snapshotId}.json`);
82
- const snapshot = JSON.parse(await readFile(snapshotPath, "utf8"));
83
- await withMutationLock(() => restoreSnapshot(snapshot));
84
- rolledBack = true;
85
- }
86
- completed = true;
87
- return {
88
- profile,
89
- loadoutHome: demoLoadoutHome,
90
- packageId,
91
- repository,
92
- resolvedCommit: fetched?.commit,
93
- reviewed: Boolean(reviewedPackage),
94
- targetAgents: agentIds,
95
- plannedFiles: plan.files.length,
96
- installedFiles: record.files.length,
97
- snapshotId,
98
- rolledBack,
99
- cleanedUp: !options.keep,
100
- };
101
- }
102
- finally {
103
- if (previousUserHome === undefined)
104
- delete process.env.LOADOUT_USER_HOME;
105
- else
106
- process.env.LOADOUT_USER_HOME = previousUserHome;
107
- if (previousLoadoutHome === undefined)
108
- delete process.env.LOADOUT_HOME;
109
- else
110
- process.env.LOADOUT_HOME = previousLoadoutHome;
111
- // The default mode has completed its rollback before removal. On an error
112
- // we also remove the partial profile; source repositories are never deleted.
113
- if (!options.keep || !completed)
114
- await rm(profile, { recursive: true, force: true });
115
- }
116
- }
117
- export function formatDemoResult(result) {
118
- const source = result.resolvedCommit
119
- ? `${result.repository} @ ${result.resolvedCommit}`
120
- : result.repository;
121
- const ending = result.rolledBack
122
- ? "Rollback verified and the temporary profile was removed."
123
- : `Isolated profile retained at ${result.profile}. Delete it when finished.`;
124
- return [
125
- "Loadout safe demo complete.",
126
- `${result.reviewed ? "Reviewed source" : "Source"}: ${source}`,
127
- `Virtual targets: ${result.targetAgents.join(", ")}`,
128
- `Installed ${result.plannedFiles} planned skill directory(ies); tracking ${result.installedFiles} file(s).`,
129
- `Snapshot: ${result.snapshotId}`,
130
- ending,
131
- ].join("\n");
132
- }
133
- export const demoDefaults = {
134
- repository: DEFAULT_REPOSITORY,
135
- packageId: DEFAULT_PACKAGE_ID,
136
- };
@@ -1,421 +0,0 @@
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, RECOMMENDATION_BOUNDARY, 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
- recommendationBoundary: RECOMMENDATION_BOUNDARY,
322
- recommendations: recommendPackages(signals, await loadEffectiveCatalog()),
323
- });
324
- return;
325
- }
326
- if (pathname === "/api/registry") {
327
- await sendJson(response, 200, { packages: await searchLocalRegistry() });
328
- return;
329
- }
330
- if (pathname === "/api/update" || pathname === "/api/updates") {
331
- const updates = await buildUpdatePlan();
332
- await sendJson(response, 200, { updates });
333
- return;
334
- }
335
- const asset = pathname === "/" ? "index.html" : pathname.slice(1);
336
- if (request.method !== "GET") {
337
- await sendJson(response, 405, { error: "Method not allowed" });
338
- return;
339
- }
340
- if (!/^[a-z0-9._-]+$/i.test(asset)) {
341
- await sendJson(response, 404, { error: "Not found" });
342
- return;
343
- }
344
- const content = await readFile(join(publicDirectory, asset));
345
- const type = asset.endsWith(".css")
346
- ? "text/css; charset=utf-8"
347
- : asset.endsWith(".js")
348
- ? "text/javascript; charset=utf-8"
349
- : "text/html; charset=utf-8";
350
- response.writeHead(200, {
351
- "content-type": type,
352
- "cache-control": "no-store",
353
- "x-content-type-options": "nosniff",
354
- "x-frame-options": "DENY",
355
- "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'",
356
- });
357
- response.end(content);
358
- }
359
- catch (error) {
360
- await sendJson(response, 500, {
361
- error: error instanceof Error ? error.message : String(error),
362
- });
363
- }
364
- }
365
- export function createDashboardServer(options = {}) {
366
- const context = {
367
- manifestPath: options.manifestPath ?? join(process.cwd(), "loadout.json"),
368
- lockPath: options.lockPath ?? join(process.cwd(), "loadout.lock"),
369
- buildSync: options.buildSync ?? buildSyncPlan,
370
- applySync: options.applySync ?? applySyncPlan,
371
- rollback: options.rollback ??
372
- (async (snapshotId) => withMutationLock(async () => restoreSnapshot(await readSnapshot(snapshotId), {
373
- requireUnchangedPostMutationState: true,
374
- }))),
375
- token: randomBytes(32).toString("hex"),
376
- };
377
- return createServer((request, response) => {
378
- void route(request, response, context);
379
- });
380
- }
381
- /** Start the dashboard only on loopback. Port zero requests an OS-assigned ephemeral port. */
382
- export async function startDashboardServer(options = {}, port = 0) {
383
- if (!Number.isInteger(port) || port < 0 || port > 65_535)
384
- throw new Error("Dashboard port must be an integer between 0 and 65535");
385
- const server = createDashboardServer(options);
386
- await new Promise((resolve, reject) => {
387
- const onError = (error) => {
388
- server.off("listening", onListening);
389
- reject(error);
390
- };
391
- const onListening = () => {
392
- server.off("error", onError);
393
- resolve();
394
- };
395
- server.once("error", onError);
396
- server.once("listening", onListening);
397
- server.listen(port, "127.0.0.1");
398
- });
399
- const address = server.address();
400
- if (!address || typeof address === "string") {
401
- await new Promise((resolve) => server.close(() => resolve()));
402
- throw new Error("Could not determine dashboard address");
403
- }
404
- return {
405
- server,
406
- host: "127.0.0.1",
407
- port: address.port,
408
- close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))),
409
- };
410
- }
411
- if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
412
- const port = process.env.LOADOUT_PORT === undefined
413
- ? 0
414
- : Number(process.env.LOADOUT_PORT);
415
- void startDashboardServer({}, port)
416
- .then((handle) => console.log(`Loadout dashboard: http://127.0.0.1:${handle.port}`))
417
- .catch((error) => {
418
- console.error(error instanceof Error ? error.message : String(error));
419
- process.exitCode = 1;
420
- });
421
- }