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.
- package/CHANGELOG.md +65 -0
- package/MASTER_PLAN.md +173 -9
- package/README.md +57 -19
- package/catalog/discovered.json +8422 -6957
- package/catalog/packages.json +26 -0
- package/dist/src/cli.js +140 -68
- package/dist/src/core/active-policy.js +216 -42
- package/dist/src/core/active-set.js +14 -5
- package/dist/src/core/adopt.js +1 -1
- package/dist/src/core/catalog-coverage.js +2 -1
- package/dist/src/core/catalog-install.js +23 -8
- package/dist/src/core/cli-guide.js +5 -2
- package/dist/src/core/codex-mcp.js +49 -5
- package/dist/src/core/completion.js +0 -3
- package/dist/src/core/health.js +19 -11
- package/dist/src/core/install.js +6 -39
- package/dist/src/core/mcp-recipes.js +13 -4
- package/dist/src/core/mcp.js +6 -5
- package/dist/src/core/model-config.js +1 -1
- package/dist/src/core/portable.js +1 -1
- package/dist/src/core/recommend.js +129 -27
- package/dist/src/core/remove.js +28 -2
- package/dist/src/core/runtime-tool-recipe.js +2 -2
- package/dist/src/core/runtime-tools.js +15 -4
- package/dist/src/core/snapshot.js +20 -0
- package/dist/src/core/state.js +9 -0
- package/dist/src/core/sync.js +1 -1
- package/dist/src/core/target-occupancy.js +50 -0
- package/dist/src/core/transaction.js +2 -1
- package/dist/src/shared/schemas.js +1 -0
- package/docs/CATALOG.md +3 -2
- package/docs/DISCOVERED.md +253 -255
- package/docs/FEATURE_TEST_MATRIX.md +19 -32
- package/docs/GITHUB_AUTHORIZATION.md +2 -2
- package/docs/TESTING.md +3 -16
- package/docs/USER_TEST_GUIDE.md +39 -13
- package/docs/assets/loadout-workflow.png +0 -0
- package/docs/evidence/readme-claims.json +0 -8
- package/docs/superpowers/plans/2026-07-20-loadout-readme-explainer.md +116 -0
- package/docs/superpowers/plans/2026-07-20-project-activation-safety.md +469 -0
- package/docs/superpowers/specs/2026-07-20-loadout-readme-explainer-design.md +55 -0
- package/docs/superpowers/specs/2026-07-20-project-activation-safety-design.md +228 -0
- package/package.json +4 -6
- package/dashboard/app.js +0 -607
- package/dashboard/index.html +0 -249
- package/dashboard/styles.css +0 -384
- package/dist/src/core/demo.js +0 -136
- package/dist/src/dashboard.js +0 -421
package/dist/src/core/demo.js
DELETED
|
@@ -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
|
-
};
|
package/dist/src/dashboard.js
DELETED
|
@@ -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
|
-
}
|