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.
Files changed (125) hide show
  1. package/LICENSE +21 -0
  2. package/MASTER_PLAN.md +1711 -0
  3. package/README.md +453 -0
  4. package/SECURITY.md +18 -0
  5. package/SIMPLE_PLAN.md +44 -0
  6. package/catalog/discovered.json +10469 -0
  7. package/catalog/packages.json +1150 -0
  8. package/dashboard/app.js +607 -0
  9. package/dashboard/index.html +249 -0
  10. package/dashboard/styles.css +384 -0
  11. package/dist/src/cli.js +2735 -0
  12. package/dist/src/core/active-policy.js +233 -0
  13. package/dist/src/core/active-set.js +382 -0
  14. package/dist/src/core/adapters.js +254 -0
  15. package/dist/src/core/adopt.js +92 -0
  16. package/dist/src/core/agent-health-score.js +475 -0
  17. package/dist/src/core/agent-inspection.js +152 -0
  18. package/dist/src/core/agent-versions.js +131 -0
  19. package/dist/src/core/api.js +90 -0
  20. package/dist/src/core/atomic-file.js +22 -0
  21. package/dist/src/core/audit.js +174 -0
  22. package/dist/src/core/benchmark-campaign.js +496 -0
  23. package/dist/src/core/benchmark-evidence.js +458 -0
  24. package/dist/src/core/benchmark-fixtures.js +1157 -0
  25. package/dist/src/core/benchmark-runner.js +301 -0
  26. package/dist/src/core/benchmark-trust.js +795 -0
  27. package/dist/src/core/canary.js +77 -0
  28. package/dist/src/core/candidate-intelligence.js +661 -0
  29. package/dist/src/core/catalog-coverage.js +85 -0
  30. package/dist/src/core/catalog-install.js +186 -0
  31. package/dist/src/core/catalog-release.js +286 -0
  32. package/dist/src/core/catalog.js +327 -0
  33. package/dist/src/core/codex-mcp.js +122 -0
  34. package/dist/src/core/community.js +86 -0
  35. package/dist/src/core/compatibility-intelligence.js +465 -0
  36. package/dist/src/core/completion.js +160 -0
  37. package/dist/src/core/components.js +138 -0
  38. package/dist/src/core/conversion.js +56 -0
  39. package/dist/src/core/credentials.js +377 -0
  40. package/dist/src/core/demo.js +136 -0
  41. package/dist/src/core/diff.js +83 -0
  42. package/dist/src/core/discovery-connector.js +165 -0
  43. package/dist/src/core/doctor.js +95 -0
  44. package/dist/src/core/ecosystem-import.js +1072 -0
  45. package/dist/src/core/evaluate.js +72 -0
  46. package/dist/src/core/file-lock.js +140 -0
  47. package/dist/src/core/freshness-alerts.js +230 -0
  48. package/dist/src/core/github-discovery.js +97 -0
  49. package/dist/src/core/github.js +148 -0
  50. package/dist/src/core/head-to-head.js +437 -0
  51. package/dist/src/core/health-score-evidence.js +169 -0
  52. package/dist/src/core/health.js +129 -0
  53. package/dist/src/core/improve.js +223 -0
  54. package/dist/src/core/install.js +233 -0
  55. package/dist/src/core/intelligence-feed-build.js +59 -0
  56. package/dist/src/core/intelligence-feed.js +353 -0
  57. package/dist/src/core/loadout-badge.js +55 -0
  58. package/dist/src/core/loadout-card.js +87 -0
  59. package/dist/src/core/manifest.js +307 -0
  60. package/dist/src/core/mcp-recipes.js +419 -0
  61. package/dist/src/core/mcp-registry-discovery.js +455 -0
  62. package/dist/src/core/mcp.js +287 -0
  63. package/dist/src/core/model-config.js +102 -0
  64. package/dist/src/core/observations.js +87 -0
  65. package/dist/src/core/outcomes.js +164 -0
  66. package/dist/src/core/package.js +225 -0
  67. package/dist/src/core/paths.js +214 -0
  68. package/dist/src/core/portable.js +130 -0
  69. package/dist/src/core/private-discovery.js +45 -0
  70. package/dist/src/core/profiles.js +255 -0
  71. package/dist/src/core/provenance.js +278 -0
  72. package/dist/src/core/ranking.js +104 -0
  73. package/dist/src/core/recommend.js +181 -0
  74. package/dist/src/core/registry-api.js +97 -0
  75. package/dist/src/core/registry.js +316 -0
  76. package/dist/src/core/release-claims.js +175 -0
  77. package/dist/src/core/remove.js +122 -0
  78. package/dist/src/core/review-queue.js +200 -0
  79. package/dist/src/core/runtime-tool-recipe.js +315 -0
  80. package/dist/src/core/runtime-tools.js +453 -0
  81. package/dist/src/core/safety.js +234 -0
  82. package/dist/src/core/sandbox.js +78 -0
  83. package/dist/src/core/scheduler.js +264 -0
  84. package/dist/src/core/share-report.js +85 -0
  85. package/dist/src/core/signing.js +115 -0
  86. package/dist/src/core/skill-compare.js +239 -0
  87. package/dist/src/core/skill-inventory.js +193 -0
  88. package/dist/src/core/skill-security.js +340 -0
  89. package/dist/src/core/skills-sh-discovery.js +336 -0
  90. package/dist/src/core/skills.js +208 -0
  91. package/dist/src/core/snapshot.js +210 -0
  92. package/dist/src/core/source.js +305 -0
  93. package/dist/src/core/state.js +300 -0
  94. package/dist/src/core/sync.js +348 -0
  95. package/dist/src/core/terminal.js +53 -0
  96. package/dist/src/core/transaction.js +211 -0
  97. package/dist/src/core/update-watch.js +39 -0
  98. package/dist/src/core/update.js +216 -0
  99. package/dist/src/core/upgrade.js +207 -0
  100. package/dist/src/dashboard.js +418 -0
  101. package/dist/src/shared/schemas.js +336 -0
  102. package/dist/src/shared/types.js +1 -0
  103. package/docs/ACTIVE_SET.md +53 -0
  104. package/docs/ACTIVE_SET_POLICY.md +45 -0
  105. package/docs/CANDIDATE_INTELLIGENCE.md +139 -0
  106. package/docs/CATALOG.md +78 -0
  107. package/docs/CATALOG_POLICY.md +41 -0
  108. package/docs/COMMUNITY_DISCOVERY.md +14 -0
  109. package/docs/COMPATIBILITY_POLICY.md +22 -0
  110. package/docs/CONVERSION_AND_SANDBOX.md +27 -0
  111. package/docs/CREDENTIAL_AND_UPDATE_POLICY.md +31 -0
  112. package/docs/DISCOVERED.md +280 -0
  113. package/docs/EVALUATION_PROTOCOL.md +40 -0
  114. package/docs/EVALUATION_PROTOCOL_V1.md +300 -0
  115. package/docs/FEATURE_TEST_MATRIX.md +802 -0
  116. package/docs/GITHUB_AUTHORIZATION.md +38 -0
  117. package/docs/HEAD_TO_HEAD_EVALUATION.md +79 -0
  118. package/docs/PROVENANCE_AND_COMPARISON.md +72 -0
  119. package/docs/PROVIDER_CONFIGURATION.md +45 -0
  120. package/docs/RELEASE_REVIEW.md +117 -0
  121. package/docs/SAFE_UPDATE_DEMO.md +25 -0
  122. package/docs/SCHEMA_DECISIONS.md +25 -0
  123. package/docs/TEAM_POLICY.md +18 -0
  124. package/docs/TESTING.md +190 -0
  125. package/package.json +82 -0
@@ -0,0 +1,181 @@
1
+ import { readFile, readdir } from "node:fs/promises";
2
+ import { basename, resolve } from "node:path";
3
+ import { STABLE_BOOST_PACKAGE_IDS } from "./profiles.js";
4
+ import { packageOutcomeAdjustment, projectTaskFamilies, } from "./outcomes.js";
5
+ const SIGNAL_FILES = new Set([
6
+ "package.json",
7
+ "pyproject.toml",
8
+ "requirements.txt",
9
+ "go.mod",
10
+ "Cargo.toml",
11
+ "Gemfile",
12
+ "pom.xml",
13
+ "build.gradle",
14
+ "next.config.js",
15
+ "next.config.mjs",
16
+ "vite.config.ts",
17
+ "playwright.config.ts",
18
+ ".git",
19
+ ]);
20
+ export async function scanProject(root = process.cwd()) {
21
+ const absolute = resolve(root);
22
+ const entries = await readdir(absolute, { withFileTypes: true });
23
+ const files = entries
24
+ .map((entry) => entry.name)
25
+ .filter((name) => SIGNAL_FILES.has(name) || name.endsWith(".sln"))
26
+ .sort();
27
+ const languages = new Set();
28
+ const frameworks = new Set();
29
+ if (files.includes("package.json")) {
30
+ languages.add("javascript/typescript");
31
+ try {
32
+ const pkg = JSON.parse(await readFile(resolve(absolute, "package.json"), "utf8"));
33
+ const deps = { ...pkg.dependencies, ...pkg.devDependencies };
34
+ if (deps.next)
35
+ frameworks.add("next.js");
36
+ if (deps.react)
37
+ frameworks.add("react");
38
+ if (deps.vue)
39
+ frameworks.add("vue");
40
+ if (deps.svelte)
41
+ frameworks.add("svelte");
42
+ if (deps.playwright || deps["@playwright/test"])
43
+ frameworks.add("playwright");
44
+ }
45
+ catch {
46
+ /* malformed project metadata is reported through an empty framework set */
47
+ }
48
+ }
49
+ if (files.includes("pyproject.toml") || files.includes("requirements.txt"))
50
+ languages.add("python");
51
+ if (files.includes("go.mod"))
52
+ languages.add("go");
53
+ if (files.includes("Cargo.toml"))
54
+ languages.add("rust");
55
+ if (files.includes("Gemfile"))
56
+ languages.add("ruby");
57
+ if (files.includes("pom.xml") || files.includes("build.gradle"))
58
+ languages.add("java");
59
+ if (files.some((file) => file.endsWith(".sln")))
60
+ languages.add(".net");
61
+ if (files.some((file) => file.startsWith("next.config")))
62
+ frameworks.add("next.js");
63
+ if (files.includes("playwright.config.ts"))
64
+ frameworks.add("playwright");
65
+ return {
66
+ root: absolute,
67
+ languages: [...languages],
68
+ frameworks: [...frameworks],
69
+ files,
70
+ };
71
+ }
72
+ export function recommendPackages(signals, catalog) {
73
+ const ids = new Set(catalog.map((pkg) => pkg.id));
74
+ const result = [];
75
+ const add = (packageId, reason, confidence) => {
76
+ if (ids.has(packageId) &&
77
+ !result.some((item) => item.packageId === packageId))
78
+ result.push({ packageId, reason, confidence });
79
+ };
80
+ add("superpowers", "Useful engineering planning, testing, and review workflows for most repositories.", "high");
81
+ add("context7", "Current library documentation helps agents avoid outdated APIs.", signals.languages.length ? "high" : "medium");
82
+ if (signals.frameworks.some((item) => ["react", "next.js", "vue", "svelte"].includes(item)))
83
+ add("ui-ux-pro-max", `Frontend framework detected: ${signals.frameworks.join(", ")}.`, "high");
84
+ if (signals.frameworks.includes("playwright") ||
85
+ signals.languages.includes("javascript/typescript"))
86
+ add("playwright-mcp", "Browser verification is useful for this web-capable project.", signals.frameworks.includes("playwright") ? "high" : "medium");
87
+ if (signals.files.includes(".git"))
88
+ add("github-mcp-server", "A Git repository was detected; GitHub tools may help with issues and pull requests.", "medium");
89
+ return result;
90
+ }
91
+ function confidenceRank(confidence) {
92
+ return { low: 0, medium: 1, high: 2 }[confidence];
93
+ }
94
+ function confidenceAt(rank) {
95
+ return ["low", "medium", "high"][Math.max(0, Math.min(2, rank))];
96
+ }
97
+ /**
98
+ * Personalize the explainable baseline with local outcomes only. A negative
99
+ * outcome can lower or reorder a recommendation, but it cannot introduce an
100
+ * unreviewed discovery candidate into the catalog.
101
+ */
102
+ export function personalizeRecommendations(recommendations, signals, outcomes, agent) {
103
+ const taskFamilies = projectTaskFamilies(signals);
104
+ return recommendations
105
+ .map((recommendation, originalIndex) => {
106
+ const adjustment = packageOutcomeAdjustment(outcomes, recommendation.packageId, agent, taskFamilies);
107
+ const confidenceShift = adjustment.score >= 20 ? 1 : adjustment.score <= -20 ? -1 : 0;
108
+ return {
109
+ ...recommendation,
110
+ confidence: confidenceAt(confidenceRank(recommendation.confidence) + confidenceShift),
111
+ ...(adjustment.evidence.length
112
+ ? {
113
+ localOutcomeAdjustment: adjustment.score,
114
+ evidence: adjustment.evidence,
115
+ reason: `${recommendation.reason} Local outcomes adjusted confidence for ${agent}; they are not global quality evidence.`,
116
+ }
117
+ : {}),
118
+ _order: originalIndex,
119
+ _adjustment: adjustment.score,
120
+ };
121
+ })
122
+ .sort((left, right) => right._adjustment - left._adjustment || left._order - right._order)
123
+ .map((item) => ({
124
+ packageId: item.packageId,
125
+ reason: item.reason,
126
+ confidence: item.confidence,
127
+ ...(item.localOutcomeAdjustment !== undefined
128
+ ? { localOutcomeAdjustment: item.localOutcomeAdjustment }
129
+ : {}),
130
+ ...(item.evidence ? { evidence: item.evidence } : {}),
131
+ }));
132
+ }
133
+ export const TESTED_PROFILES = {
134
+ stable: {
135
+ description: "Recommended 30-skill daily driver from four pinned, SPDX-identified sources with no extra static-risk approvals.",
136
+ packages: [...STABLE_BOOST_PACKAGE_IDS],
137
+ },
138
+ web: {
139
+ description: "Planning, documentation, interface guidance, and browser verification for web projects.",
140
+ packages: ["superpowers", "context7", "ui-ux-pro-max", "playwright-mcp"],
141
+ },
142
+ collaboration: {
143
+ description: "Engineering workflow plus GitHub collaboration tools.",
144
+ packages: ["superpowers", "context7", "github-mcp-server"],
145
+ },
146
+ maximum: {
147
+ description: "Broad reviewed toolkit; always review MCP permissions before applying.",
148
+ packages: [
149
+ "superpowers",
150
+ "context7",
151
+ "playwright-mcp",
152
+ "ui-ux-pro-max",
153
+ "github-mcp-server",
154
+ ],
155
+ },
156
+ };
157
+ export function profileManifestPackages(profile, catalog) {
158
+ const selected = TESTED_PROFILES[profile];
159
+ if (!selected)
160
+ throw new Error(`Unknown profile '${profile}'. Choose: ${Object.keys(TESTED_PROFILES).join(", ")}`);
161
+ const byId = new Map(catalog.map((pkg) => [pkg.id, pkg]));
162
+ return selected.packages.map((id) => {
163
+ const pkg = byId.get(id);
164
+ if (!pkg)
165
+ throw new Error(`Profile '${profile}' references missing catalog package '${id}'`);
166
+ return { id, repository: pkg.repository };
167
+ });
168
+ }
169
+ export function formatRecommendations(signals, recommendations) {
170
+ const lines = [
171
+ `Project: ${basename(signals.root)}`,
172
+ `Detected: ${[...signals.languages, ...signals.frameworks].join(", ") || "no known project signals"}`,
173
+ "",
174
+ "Recommendations:",
175
+ ];
176
+ if (!recommendations.length)
177
+ lines.push(" No matching catalog packages found.");
178
+ for (const item of recommendations)
179
+ lines.push(` ${item.packageId} [${item.confidence}] — ${item.reason}`);
180
+ return lines.join("\n");
181
+ }
@@ -0,0 +1,97 @@
1
+ import { createServer, } from "node:http";
2
+ import { randomBytes, timingSafeEqual } from "node:crypto";
3
+ import { mkdtemp, rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { createRegistryBundle, importRegistryBundle, publishLocalPackage, resolveRegistryPackage, } from "./registry.js";
7
+ function send(response, status, value) {
8
+ const body = JSON.stringify(value);
9
+ response.writeHead(status, {
10
+ "content-type": "application/json; charset=utf-8",
11
+ "content-length": Buffer.byteLength(body),
12
+ "cache-control": "no-store",
13
+ "x-content-type-options": "nosniff",
14
+ });
15
+ response.end(body);
16
+ }
17
+ function tokenMatches(request, token) {
18
+ const header = request.headers.authorization;
19
+ if (!header?.startsWith("Bearer "))
20
+ return false;
21
+ const provided = Buffer.from(header.slice(7));
22
+ const expected = Buffer.from(token);
23
+ return (provided.length === expected.length && timingSafeEqual(provided, expected));
24
+ }
25
+ async function requestJson(request) {
26
+ const chunks = [];
27
+ let size = 0;
28
+ for await (const chunk of request) {
29
+ const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
30
+ size += value.length;
31
+ if (size > 35_000_000)
32
+ throw new Error("Request exceeds registry limit");
33
+ chunks.push(value);
34
+ }
35
+ return JSON.parse(Buffer.concat(chunks).toString("utf8"));
36
+ }
37
+ export async function startRegistryServer(options = {}) {
38
+ const host = options.host ?? "127.0.0.1";
39
+ const port = options.port ?? 0;
40
+ const token = options.token ?? randomBytes(32).toString("hex");
41
+ if (host !== "127.0.0.1" && host !== "::1" && !options.token)
42
+ throw new Error("A publish token is required when binding beyond loopback");
43
+ const server = createServer(async (request, response) => {
44
+ try {
45
+ const url = new URL(request.url ?? "/", "http://registry.local");
46
+ const match = url.pathname.match(/^\/v1\/packages\/([a-z0-9][a-z0-9._-]*)\/([^/]+)$/);
47
+ if (request.method === "GET" && match) {
48
+ const resolved = await resolveRegistryPackage(match[1], decodeURIComponent(match[2]));
49
+ send(response, 200, await createRegistryBundle(resolved.path));
50
+ return;
51
+ }
52
+ if (request.method === "POST" && url.pathname === "/v1/packages") {
53
+ if (!tokenMatches(request, token)) {
54
+ request.resume();
55
+ await new Promise((resolve) => request.once("end", resolve));
56
+ send(response, 401, { error: "Invalid registry token" });
57
+ return;
58
+ }
59
+ const temporary = await mkdtemp(join(tmpdir(), "loadout-registry-upload-"));
60
+ try {
61
+ const packed = await importRegistryBundle(await requestJson(request), join(temporary, "package"));
62
+ const published = await publishLocalPackage(packed.root, {
63
+ approveRisk: request.headers["x-loadout-approve-risk"] === "true",
64
+ });
65
+ send(response, 201, {
66
+ name: published.descriptor.name,
67
+ version: published.descriptor.version,
68
+ digest: published.digest,
69
+ });
70
+ }
71
+ finally {
72
+ await rm(temporary, { recursive: true, force: true });
73
+ }
74
+ return;
75
+ }
76
+ send(response, 404, { error: "Not found" });
77
+ }
78
+ catch (error) {
79
+ send(response, 400, {
80
+ error: error instanceof Error ? error.message : String(error),
81
+ });
82
+ }
83
+ });
84
+ await new Promise((resolve, reject) => {
85
+ server.once("error", reject);
86
+ server.listen(port, host, resolve);
87
+ });
88
+ const address = server.address();
89
+ if (!address || typeof address === "string")
90
+ throw new Error("Could not determine registry address");
91
+ return {
92
+ server,
93
+ host,
94
+ port: address.port,
95
+ close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))),
96
+ };
97
+ }
@@ -0,0 +1,316 @@
1
+ import { createHash } from "node:crypto";
2
+ import { cp, lstat, mkdir, readFile, readdir, rm, writeFile, } from "node:fs/promises";
3
+ import { join, relative, resolve, sep } from "node:path";
4
+ import { loadoutHome } from "./paths.js";
5
+ import { analyzeUpdateSafety } from "./safety.js";
6
+ const NAME = /^[a-z0-9][a-z0-9._-]*$/;
7
+ const VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/;
8
+ export function parsePackageDescriptor(value) {
9
+ if (!value || typeof value !== "object")
10
+ throw new Error("Package descriptor must be an object");
11
+ const item = value;
12
+ if (item.schemaVersion !== 1)
13
+ throw new Error("Package schemaVersion must be 1");
14
+ if (typeof item.name !== "string" || !NAME.test(item.name))
15
+ throw new Error("Package name must use lowercase letters, numbers, dots, underscores, or dashes");
16
+ if (typeof item.version !== "string" || !VERSION.test(item.version))
17
+ throw new Error("Package version must be semantic version such as 1.0.0");
18
+ if (typeof item.description !== "string" || !item.description.trim())
19
+ throw new Error("Package description is required");
20
+ for (const field of ["dependencies", "devDependencies"]) {
21
+ const dependencies = item[field];
22
+ if (dependencies !== undefined &&
23
+ (!dependencies ||
24
+ typeof dependencies !== "object" ||
25
+ Array.isArray(dependencies) ||
26
+ Object.entries(dependencies).some(([name, version]) => !NAME.test(name) ||
27
+ typeof version !== "string" ||
28
+ !VERSION.test(version))))
29
+ throw new Error(`Package ${field} must map names to exact semantic versions`);
30
+ }
31
+ return {
32
+ schemaVersion: 1,
33
+ name: item.name,
34
+ version: item.version,
35
+ description: item.description.trim(),
36
+ ...(typeof item.license === "string" ? { license: item.license } : {}),
37
+ ...(item.dependencies
38
+ ? { dependencies: item.dependencies }
39
+ : {}),
40
+ ...(item.devDependencies
41
+ ? { devDependencies: item.devDependencies }
42
+ : {}),
43
+ };
44
+ }
45
+ export async function createPackage(root, options) {
46
+ const directory = resolve(root);
47
+ const descriptor = parsePackageDescriptor({
48
+ schemaVersion: 1,
49
+ name: options.name,
50
+ version: options.version ?? "0.1.0",
51
+ description: options.description ?? `${options.name} Loadout package`,
52
+ });
53
+ await mkdir(directory, { recursive: false });
54
+ await Promise.all([
55
+ mkdir(join(directory, "skills", descriptor.name), { recursive: true }),
56
+ ...["rules", "commands", "agents"].map((name) => mkdir(join(directory, name))),
57
+ ]);
58
+ await writeFile(join(directory, "loadout-package.json"), `${JSON.stringify(descriptor, null, 2)}\n`);
59
+ await writeFile(join(directory, "README.md"), `# ${descriptor.name}\n\n${descriptor.description}\n`);
60
+ await writeFile(join(directory, "skills", descriptor.name, "SKILL.md"), `---\nname: ${descriptor.name}\ndescription: ${JSON.stringify(descriptor.description)}\n---\n\n# ${descriptor.name}\n\nReplace this paragraph with the focused workflow this skill should follow.\n`);
61
+ return descriptor;
62
+ }
63
+ async function inventory(root) {
64
+ const base = resolve(root);
65
+ const files = [];
66
+ async function visit(directory) {
67
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
68
+ if (entry.name === ".git" ||
69
+ entry.name === "node_modules" ||
70
+ entry.name === ".loadout-record.json")
71
+ continue;
72
+ const absolute = join(directory, entry.name);
73
+ if (entry.isSymbolicLink())
74
+ throw new Error(`Refusing symlink in package: ${absolute}`);
75
+ if (entry.isDirectory())
76
+ await visit(absolute);
77
+ else if (entry.isFile()) {
78
+ const content = await readFile(absolute);
79
+ files.push({
80
+ path: relative(base, absolute).split(sep).join("/"),
81
+ sha256: createHash("sha256").update(content).digest("hex"),
82
+ size: content.length,
83
+ });
84
+ }
85
+ }
86
+ }
87
+ await visit(base);
88
+ return files.sort((a, b) => a.path.localeCompare(b.path));
89
+ }
90
+ export async function packPackage(root) {
91
+ const directory = resolve(root);
92
+ const info = await lstat(directory);
93
+ if (!info.isDirectory() || info.isSymbolicLink())
94
+ throw new Error("Package root must be a real directory");
95
+ const descriptor = parsePackageDescriptor(JSON.parse(await readFile(join(directory, "loadout-package.json"), "utf8")));
96
+ const files = await inventory(directory);
97
+ const digest = createHash("sha256")
98
+ .update(files
99
+ .map((file) => `${file.path}\0${file.sha256}\0${file.size}`)
100
+ .join("\n"))
101
+ .digest("hex");
102
+ return { descriptor, root: directory, digest, files };
103
+ }
104
+ const registryRoot = () => join(loadoutHome(), "registry");
105
+ export async function publishLocalPackage(root, options = {}) {
106
+ const packed = await packPackage(root);
107
+ const safety = await analyzeUpdateSafety(undefined, packed.root);
108
+ if (safety.approvalRequired && !options.approveRisk)
109
+ throw new Error(`Publishing requires explicit risk approval: ${safety.findings
110
+ .filter((finding) => finding.severity === "blocking")
111
+ .map((finding) => finding.message)
112
+ .join(" ")}`);
113
+ const target = join(registryRoot(), packed.descriptor.name, packed.descriptor.version);
114
+ try {
115
+ const existing = JSON.parse(await readFile(join(target, ".loadout-record.json"), "utf8"));
116
+ if (existing.digest === packed.digest)
117
+ return packed;
118
+ throw new Error(`Registry version ${packed.descriptor.name}@${packed.descriptor.version} already exists with different content`);
119
+ }
120
+ catch (error) {
121
+ if (!(error &&
122
+ typeof error === "object" &&
123
+ "code" in error &&
124
+ error.code === "ENOENT"))
125
+ throw error;
126
+ }
127
+ const temporary = `${target}.staging-${process.pid}-${Date.now()}`;
128
+ await mkdir(temporary, { recursive: true });
129
+ try {
130
+ for (const file of packed.files) {
131
+ const source = resolve(packed.root, file.path);
132
+ const destination = resolve(temporary, file.path);
133
+ if (!destination.startsWith(`${resolve(temporary)}${sep}`))
134
+ throw new Error(`Package file escapes registry target: ${file.path}`);
135
+ await mkdir(resolve(destination, ".."), { recursive: true });
136
+ await cp(source, destination);
137
+ }
138
+ await writeFile(join(temporary, ".loadout-record.json"), `${JSON.stringify({ name: packed.descriptor.name, version: packed.descriptor.version, digest: packed.digest, publishedAt: new Date().toISOString(), safety }, null, 2)}\n`);
139
+ await mkdir(join(registryRoot(), packed.descriptor.name), {
140
+ recursive: true,
141
+ });
142
+ await cp(temporary, target, { recursive: true, errorOnExist: true });
143
+ }
144
+ finally {
145
+ await rm(temporary, { recursive: true, force: true });
146
+ }
147
+ return packed;
148
+ }
149
+ export async function resolveRegistryPackage(name, version) {
150
+ if (!NAME.test(name) || !VERSION.test(version))
151
+ throw new Error("Invalid registry package name or version");
152
+ const path = join(registryRoot(), name, version);
153
+ try {
154
+ const record = JSON.parse(await readFile(join(path, ".loadout-record.json"), "utf8"));
155
+ if (!record.digest)
156
+ throw new Error("missing digest");
157
+ const packed = await packPackage(path);
158
+ if (packed.digest !== record.digest)
159
+ throw new Error("registry package content does not match its immutable digest");
160
+ return { path, digest: record.digest };
161
+ }
162
+ catch (error) {
163
+ throw new Error(`Could not resolve registry package ${name}@${version}: ${error instanceof Error ? error.message : String(error)}`);
164
+ }
165
+ }
166
+ export async function searchLocalRegistry(query = "") {
167
+ const results = [];
168
+ let names = [];
169
+ try {
170
+ names = await readdir(registryRoot());
171
+ }
172
+ catch {
173
+ return results;
174
+ }
175
+ for (const name of names.sort()) {
176
+ let versions = [];
177
+ try {
178
+ versions = await readdir(join(registryRoot(), name));
179
+ }
180
+ catch {
181
+ continue;
182
+ }
183
+ for (const version of versions.sort()) {
184
+ try {
185
+ const descriptor = parsePackageDescriptor(JSON.parse(await readFile(join(registryRoot(), name, version, "loadout-package.json"), "utf8")));
186
+ if (!query ||
187
+ `${descriptor.name} ${descriptor.description}`
188
+ .toLowerCase()
189
+ .includes(query.toLowerCase()))
190
+ results.push({ name, version, description: descriptor.description });
191
+ }
192
+ catch {
193
+ /* ignore corrupt entries; resolve reports exact errors */
194
+ }
195
+ }
196
+ }
197
+ return results;
198
+ }
199
+ export async function createRegistryBundle(root) {
200
+ const packed = await packPackage(root);
201
+ const files = await Promise.all(packed.files.map(async (file) => ({
202
+ ...file,
203
+ content: (await readFile(resolve(packed.root, file.path))).toString("base64"),
204
+ })));
205
+ return {
206
+ schemaVersion: 1,
207
+ descriptor: packed.descriptor,
208
+ digest: packed.digest,
209
+ files,
210
+ };
211
+ }
212
+ export async function importRegistryBundle(bundle, destination) {
213
+ if (!bundle || bundle.schemaVersion !== 1 || !Array.isArray(bundle.files))
214
+ throw new Error("Registry bundle schema is invalid");
215
+ parsePackageDescriptor(bundle.descriptor);
216
+ if (bundle.files.length > 10_000 ||
217
+ bundle.files.reduce((total, file) => total + (file.size ?? 0), 0) >
218
+ 25_000_000)
219
+ throw new Error("Registry bundle exceeds package limits");
220
+ const root = resolve(destination);
221
+ await mkdir(root, { recursive: false });
222
+ try {
223
+ for (const file of bundle.files) {
224
+ if (!file ||
225
+ typeof file.path !== "string" ||
226
+ typeof file.sha256 !== "string" ||
227
+ typeof file.size !== "number" ||
228
+ typeof file.content !== "string")
229
+ throw new Error("Registry bundle contains an invalid file record");
230
+ const target = resolve(root, file.path);
231
+ if (target === root || !target.startsWith(`${root}${sep}`))
232
+ throw new Error(`Registry bundle path escapes destination: ${file.path}`);
233
+ const content = Buffer.from(file.content, "base64");
234
+ if (content.length !== file.size ||
235
+ createHash("sha256").update(content).digest("hex") !== file.sha256)
236
+ throw new Error(`Registry bundle file verification failed: ${file.path}`);
237
+ await mkdir(resolve(target, ".."), { recursive: true });
238
+ await writeFile(target, content);
239
+ }
240
+ const packed = await packPackage(root);
241
+ if (packed.digest !== bundle.digest ||
242
+ packed.descriptor.name !== bundle.descriptor.name ||
243
+ packed.descriptor.version !== bundle.descriptor.version)
244
+ throw new Error("Registry bundle digest or identity verification failed");
245
+ return packed;
246
+ }
247
+ catch (error) {
248
+ await rm(root, { recursive: true, force: true });
249
+ throw error;
250
+ }
251
+ }
252
+ function registryUrl(input) {
253
+ const url = new URL(input);
254
+ const loopback = url.hostname === "127.0.0.1" ||
255
+ url.hostname === "localhost" ||
256
+ url.hostname === "::1";
257
+ if (url.protocol !== "https:" && !(loopback && url.protocol === "http:"))
258
+ throw new Error("Remote registries require HTTPS outside loopback development");
259
+ url.pathname = url.pathname.replace(/\/$/, "");
260
+ return url;
261
+ }
262
+ function registryEndpoint(base, path) {
263
+ const endpoint = new URL(base);
264
+ endpoint.pathname = `${base.pathname.replace(/\/$/, "")}${path}`;
265
+ endpoint.search = "";
266
+ endpoint.hash = "";
267
+ return endpoint;
268
+ }
269
+ export async function fetchRemoteRegistryPackage(registry, name, version) {
270
+ if (!NAME.test(name) || !VERSION.test(version))
271
+ throw new Error("Invalid remote registry package name or version");
272
+ const base = registryUrl(registry);
273
+ const response = await fetch(registryEndpoint(base, `/v1/packages/${encodeURIComponent(name)}/${encodeURIComponent(version)}`));
274
+ if (!response.ok)
275
+ throw new Error(`Remote registry returned ${response.status}`);
276
+ const bundle = (await response.json());
277
+ const key = createHash("sha256")
278
+ .update(base.origin + base.pathname)
279
+ .digest("hex");
280
+ const target = join(loadoutHome(), "cache", "registry", key, name, version, bundle.digest);
281
+ try {
282
+ const packed = await packPackage(target);
283
+ if (packed.digest === bundle.digest)
284
+ return { path: target, digest: bundle.digest };
285
+ }
286
+ catch {
287
+ /* import below */
288
+ }
289
+ await mkdir(resolve(target, ".."), { recursive: true });
290
+ await rm(target, { recursive: true, force: true });
291
+ await importRegistryBundle(bundle, target);
292
+ return { path: target, digest: bundle.digest };
293
+ }
294
+ export async function publishRemotePackage(root, registry, token, options = {}) {
295
+ if (!token)
296
+ throw new Error("Remote registry token is required");
297
+ const bundle = await createRegistryBundle(root);
298
+ const base = registryUrl(registry);
299
+ const response = await fetch(registryEndpoint(base, "/v1/packages"), {
300
+ method: "POST",
301
+ headers: {
302
+ authorization: `Bearer ${token}`,
303
+ "content-type": "application/json",
304
+ ...(options.approveRisk ? { "x-loadout-approve-risk": "true" } : {}),
305
+ },
306
+ body: JSON.stringify(bundle),
307
+ });
308
+ const result = (await response.json());
309
+ if (!response.ok)
310
+ throw new Error(result.error ?? `Remote registry returned ${response.status}`);
311
+ return {
312
+ name: result.name,
313
+ version: result.version,
314
+ digest: result.digest,
315
+ };
316
+ }