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,85 @@
|
|
|
1
|
+
import { catalogTrustStage } from "./profiles.js";
|
|
2
|
+
const componentTypes = [
|
|
3
|
+
"skill",
|
|
4
|
+
"rule",
|
|
5
|
+
"command",
|
|
6
|
+
"agent",
|
|
7
|
+
"mcp",
|
|
8
|
+
"plugin",
|
|
9
|
+
"root",
|
|
10
|
+
];
|
|
11
|
+
const operatingSystems = ["windows", "macos", "linux"];
|
|
12
|
+
const trustStages = [
|
|
13
|
+
"discovered",
|
|
14
|
+
"inspected",
|
|
15
|
+
"human-reviewed",
|
|
16
|
+
"benchmarked",
|
|
17
|
+
"recommended",
|
|
18
|
+
];
|
|
19
|
+
function tally(values, keys) {
|
|
20
|
+
return Object.fromEntries(keys.map((key) => [key, values.filter((value) => value === key).length]));
|
|
21
|
+
}
|
|
22
|
+
function technicallyScreened(pkg) {
|
|
23
|
+
return Boolean(pkg.license &&
|
|
24
|
+
pkg.components?.length &&
|
|
25
|
+
pkg.operatingSystems?.length === operatingSystems.length &&
|
|
26
|
+
operatingSystems.every((system) => pkg.operatingSystems?.includes(system)) &&
|
|
27
|
+
pkg.source?.commit.match(/^[a-f0-9]{40}$/) &&
|
|
28
|
+
pkg.source.evidencePaths.length &&
|
|
29
|
+
pkg.source.verifiedAt);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Explain catalog breadth without pretending repository count equals quality.
|
|
33
|
+
* All metrics are derived from technically screened records; live activity remains explicit
|
|
34
|
+
* and absent until `catalog --refresh` observes it.
|
|
35
|
+
*/
|
|
36
|
+
export function buildCatalogCoverage(catalog, targetRecords = 50) {
|
|
37
|
+
const categories = catalog.reduce((result, pkg) => {
|
|
38
|
+
result[pkg.category] = (result[pkg.category] ?? 0) + 1;
|
|
39
|
+
return result;
|
|
40
|
+
}, {});
|
|
41
|
+
const components = tally(catalog.flatMap((pkg) => pkg.components ?? []), componentTypes);
|
|
42
|
+
const platforms = tally(catalog.flatMap((pkg) => pkg.operatingSystems ?? []), operatingSystems);
|
|
43
|
+
return {
|
|
44
|
+
records: catalog.length,
|
|
45
|
+
targetRecords,
|
|
46
|
+
technicallyScreenedRecords: catalog.filter(technicallyScreened).length,
|
|
47
|
+
recommendedRecords: catalog.filter((pkg) => catalogTrustStage(pkg) === "recommended").length,
|
|
48
|
+
trustStages: tally(catalog.map(catalogTrustStage), trustStages),
|
|
49
|
+
immutablePins: catalog.filter((pkg) => pkg.source?.commit.match(/^[a-f0-9]{40}$/)).length,
|
|
50
|
+
assertedLicenses: catalog.filter((pkg) => pkg.license && pkg.license !== "NOASSERTION").length,
|
|
51
|
+
noAssertionLicenses: catalog.filter((pkg) => pkg.license === "NOASSERTION")
|
|
52
|
+
.length,
|
|
53
|
+
activityObserved: catalog.filter((pkg) => pkg.pushedAt || pkg.lastUpdatedAt)
|
|
54
|
+
.length,
|
|
55
|
+
evaluationReady: catalog.filter((pkg) => pkg.source?.evidencePaths.length &&
|
|
56
|
+
pkg.components?.some((type) => type === "skill" || type === "mcp")).length,
|
|
57
|
+
categories,
|
|
58
|
+
components,
|
|
59
|
+
operatingSystems: platforms,
|
|
60
|
+
installShapes: {
|
|
61
|
+
skills: catalog.filter((pkg) => pkg.components?.includes("skill") && !pkg.components.includes("mcp")).length,
|
|
62
|
+
mcpOnly: catalog.filter((pkg) => pkg.components?.includes("mcp") && !pkg.components.includes("skill")).length,
|
|
63
|
+
mixed: catalog.filter((pkg) => pkg.components?.includes("skill") && pkg.components.includes("mcp")).length,
|
|
64
|
+
},
|
|
65
|
+
duplicateCapabilityGroups: Object.entries(categories)
|
|
66
|
+
.filter(([, records]) => records > 1)
|
|
67
|
+
.map(([category, records]) => ({ category, records }))
|
|
68
|
+
.sort((left, right) => right.records - left.records ||
|
|
69
|
+
left.category.localeCompare(right.category)),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export function formatCatalogCoverage(report) {
|
|
73
|
+
const categories = Object.entries(report.categories)
|
|
74
|
+
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
|
|
75
|
+
.map(([category, count]) => `${category}:${count}`)
|
|
76
|
+
.join(", ");
|
|
77
|
+
return [
|
|
78
|
+
`Screened catalog: ${report.technicallyScreenedRecords}/${report.records} technically complete records (target ${report.targetRecords})`,
|
|
79
|
+
`Recommendation trust: ${report.recommendedRecords} Stable sources · human license approval still required before release`,
|
|
80
|
+
`Evidence: ${report.immutablePins} immutable pins · ${report.assertedLicenses} asserted licenses · ${report.noAssertionLicenses} NOASSERTION`,
|
|
81
|
+
`Coverage: ${Object.values(report.categories).length} categories · ${report.evaluationReady} evaluation-ready · ${report.activityObserved} with refreshed activity`,
|
|
82
|
+
`Install shape: ${report.installShapes.skills} skill · ${report.installShapes.mcpOnly} MCP-only · ${report.installShapes.mixed} mixed`,
|
|
83
|
+
`Categories: ${categories}`,
|
|
84
|
+
].join("\n");
|
|
85
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { loadEffectiveCatalog } from "./catalog.js";
|
|
2
|
+
import { detectAgents } from "./paths.js";
|
|
3
|
+
import { isStableSkillSelected, isPowerSkillSelected, resolveCatalogProfile, } from "./profiles.js";
|
|
4
|
+
import { applySkillLibraryBatch, applySkillInstallBatch, buildSkillPlan, installedAgents, } from "./install.js";
|
|
5
|
+
import { fetchRepositorySnapshot, } from "./source.js";
|
|
6
|
+
import { analyzeInstallPlanSafety, } from "./safety.js";
|
|
7
|
+
export const RECOMMENDED_ACTIVE_SKILL_LIMIT = 30;
|
|
8
|
+
async function parallelMap(values, concurrency, worker) {
|
|
9
|
+
const results = new Array(values.length);
|
|
10
|
+
let cursor = 0;
|
|
11
|
+
async function run() {
|
|
12
|
+
while (cursor < values.length) {
|
|
13
|
+
const index = cursor++;
|
|
14
|
+
results[index] = await worker(values[index], index);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
await Promise.all(Array.from({ length: Math.min(Math.max(1, concurrency), values.length) }, () => run()));
|
|
18
|
+
return results;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Prepare a broad catalog loadout without mutating agent files. Only packages
|
|
22
|
+
* with reviewed skill evidence are fetched automatically; MCP servers and
|
|
23
|
+
* executables need agent-specific configuration or credentials and remain
|
|
24
|
+
* explicit customization steps.
|
|
25
|
+
*/
|
|
26
|
+
export async function prepareCatalogInstall(selection, options = {}) {
|
|
27
|
+
const catalog = options.catalog ?? (await loadEffectiveCatalog());
|
|
28
|
+
const resolution = resolveCatalogProfile(catalog, selection);
|
|
29
|
+
const agents = installedAgents(options.detectedAgents ?? (await detectAgents()), options.requestedAgents);
|
|
30
|
+
if (!agents.length)
|
|
31
|
+
throw new Error("No supported AI coding agent was detected. Install Codex, Claude Code, Cursor, Gemini CLI, OpenCode, or Hermes first.");
|
|
32
|
+
const skipped = resolution.packages
|
|
33
|
+
.filter((pkg) => !pkg.components?.includes("skill"))
|
|
34
|
+
.map((pkg) => ({
|
|
35
|
+
packageId: pkg.id,
|
|
36
|
+
kind: "explicit-setup",
|
|
37
|
+
reason: "Not auto-installed: this catalog record has no reviewed skill component; MCP/executable setup requires explicit configuration.",
|
|
38
|
+
}));
|
|
39
|
+
const installable = resolution.packages.filter((pkg) => pkg.components?.includes("skill"));
|
|
40
|
+
const fetchSnapshot = options.fetchSnapshot ?? fetchRepositorySnapshot;
|
|
41
|
+
let completed = 0;
|
|
42
|
+
const prepared = await parallelMap(installable, options.concurrency ?? 4, async (pkg) => {
|
|
43
|
+
options.onProgress?.({
|
|
44
|
+
packageId: pkg.id,
|
|
45
|
+
completed,
|
|
46
|
+
total: installable.length,
|
|
47
|
+
status: "fetching",
|
|
48
|
+
message: `Fetching reviewed ${pkg.displayName} snapshot`,
|
|
49
|
+
});
|
|
50
|
+
try {
|
|
51
|
+
if (!pkg.source?.commit)
|
|
52
|
+
throw new Error("catalog record has no reviewed commit");
|
|
53
|
+
const fetched = await fetchSnapshot(pkg.repository, {
|
|
54
|
+
ref: pkg.source.commit,
|
|
55
|
+
});
|
|
56
|
+
if (fetched.commit.toLowerCase() !== pkg.source.commit.toLowerCase())
|
|
57
|
+
throw new Error(`resolved ${fetched.commit}, expected reviewed commit ${pkg.source.commit}`);
|
|
58
|
+
const include = selection.mode === "stable"
|
|
59
|
+
? (skill) => isStableSkillSelected(pkg.id, skill.name, skill.targetName)
|
|
60
|
+
: selection.mode === "power"
|
|
61
|
+
? (skill) => isPowerSkillSelected(pkg.id, skill.name, skill.targetName)
|
|
62
|
+
: undefined;
|
|
63
|
+
const plan = await buildSkillPlan(fetched.path, pkg.id, agents, include ? { include } : {});
|
|
64
|
+
if (selection.mode === "stable")
|
|
65
|
+
plan.files = plan.files.filter((file) => isStableSkillSelected(pkg.id, file.skillName, file.target.split(/[\\/]/).at(-1) ?? pkg.id));
|
|
66
|
+
if (selection.mode === "power")
|
|
67
|
+
plan.files = plan.files.filter((file) => isPowerSkillSelected(pkg.id, file.skillName, file.target.split(/[\\/]/).at(-1) ?? pkg.id));
|
|
68
|
+
if (!plan.files.length)
|
|
69
|
+
throw new Error(`no skills from this collection are selected by the ${selection.mode === "stable" ? "Stable" : "Power"} profile`);
|
|
70
|
+
const safety = await analyzeInstallPlanSafety(plan);
|
|
71
|
+
completed += 1;
|
|
72
|
+
options.onProgress?.({
|
|
73
|
+
packageId: pkg.id,
|
|
74
|
+
completed,
|
|
75
|
+
total: installable.length,
|
|
76
|
+
status: "ready",
|
|
77
|
+
message: `${pkg.displayName} is ready (${plan.files.length} target directories)`,
|
|
78
|
+
});
|
|
79
|
+
return {
|
|
80
|
+
package: pkg,
|
|
81
|
+
plan,
|
|
82
|
+
metadata: {
|
|
83
|
+
repository: fetched.repository,
|
|
84
|
+
resolvedCommit: fetched.commit,
|
|
85
|
+
reviewed: true,
|
|
86
|
+
},
|
|
87
|
+
safety,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
completed += 1;
|
|
92
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
93
|
+
options.onProgress?.({
|
|
94
|
+
packageId: pkg.id,
|
|
95
|
+
completed,
|
|
96
|
+
total: installable.length,
|
|
97
|
+
status: "skipped",
|
|
98
|
+
message: `${pkg.displayName} could not be prepared: ${reason}`,
|
|
99
|
+
});
|
|
100
|
+
return { packageId: pkg.id, reason, kind: "preparation-failed" };
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
const entries = prepared.filter((item) => "plan" in item);
|
|
104
|
+
skipped.push(...prepared.filter((item) => !("plan" in item)));
|
|
105
|
+
// Broad collections frequently publish the same conventional skill name.
|
|
106
|
+
// Resolution order is already deterministic and evidence-ranked, so keep
|
|
107
|
+
// the first source for a target and defer only the lower-ranked duplicate
|
|
108
|
+
// instead of overwriting it or blocking every other useful skill.
|
|
109
|
+
const claimedTargets = new Map();
|
|
110
|
+
const collisions = [];
|
|
111
|
+
for (const entry of entries) {
|
|
112
|
+
entry.plan.files = entry.plan.files.filter((file) => {
|
|
113
|
+
const keptPackageId = claimedTargets.get(file.target);
|
|
114
|
+
if (!keptPackageId) {
|
|
115
|
+
claimedTargets.set(file.target, entry.plan.packageId);
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
collisions.push({
|
|
119
|
+
target: file.target,
|
|
120
|
+
keptPackageId,
|
|
121
|
+
deferredPackageId: entry.plan.packageId,
|
|
122
|
+
});
|
|
123
|
+
return false;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
const usableEntries = entries.filter((entry) => {
|
|
127
|
+
if (entry.plan.files.length)
|
|
128
|
+
return true;
|
|
129
|
+
skipped.push({
|
|
130
|
+
packageId: entry.plan.packageId,
|
|
131
|
+
kind: "overlap",
|
|
132
|
+
reason: "Every discovered skill overlaps a higher-ranked reviewed package.",
|
|
133
|
+
});
|
|
134
|
+
return false;
|
|
135
|
+
});
|
|
136
|
+
return {
|
|
137
|
+
selection,
|
|
138
|
+
resolution,
|
|
139
|
+
agents,
|
|
140
|
+
entries: usableEntries,
|
|
141
|
+
skipped,
|
|
142
|
+
collisions,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
export function formatPreparedCatalogInstall(prepared) {
|
|
146
|
+
const targetDirectories = prepared.entries.reduce((total, entry) => total + entry.plan.files.length, 0);
|
|
147
|
+
const directoriesPerAgent = prepared.agents.length
|
|
148
|
+
? Math.ceil(targetDirectories / prepared.agents.length)
|
|
149
|
+
: 0;
|
|
150
|
+
const risky = prepared.entries.filter((entry) => entry.safety.approvalRequired);
|
|
151
|
+
const explicit = prepared.skipped.filter((item) => item.kind === "explicit-setup");
|
|
152
|
+
const failures = prepared.skipped.filter((item) => item.kind === "preparation-failed");
|
|
153
|
+
const lines = [
|
|
154
|
+
`Loadout: ${prepared.selection.mode === "maximum" ? "Maximum Library" : prepared.selection.mode === "power" ? "Power Boost" : prepared.selection.mode === "stable" ? "Stable Boost" : "Custom"}`,
|
|
155
|
+
`Detected agents: ${prepared.agents.map((agent) => agent.displayName).join(", ")}`,
|
|
156
|
+
`Catalog selection: ${prepared.resolution.packages.length} repositories`,
|
|
157
|
+
`Ready to install: ${prepared.entries.length} skill repositories (${targetDirectories} agent skill directories)`,
|
|
158
|
+
`Explicit setup later: ${explicit.length} repository/repositories`,
|
|
159
|
+
];
|
|
160
|
+
if (directoriesPerAgent > RECOMMENDED_ACTIVE_SKILL_LIMIT)
|
|
161
|
+
lines.push(`Capacity warning: about ${directoriesPerAgent} skill directories per agent exceeds the recommended active-set limit of ${RECOMMENDED_ACTIVE_SKILL_LIMIT}.${prepared.selection.mode === "maximum" ? " Maximum stores these in the disabled library; use project activation to choose the working set." : " Prefer Stable or project-aware activation for smaller context."}`);
|
|
162
|
+
if (failures.length)
|
|
163
|
+
lines.push(`Preparation failures (installation will remain blocked): ${failures.map((item) => item.packageId).join(", ")}`);
|
|
164
|
+
if (prepared.collisions.length)
|
|
165
|
+
lines.push(`Overlapping skill targets resolved: ${prepared.collisions.length} lower-ranked duplicate directories deferred`);
|
|
166
|
+
if (risky.length)
|
|
167
|
+
lines.push(`Additional risk approval required: ${risky.map((entry) => entry.package.id).join(", ")}`);
|
|
168
|
+
for (const warning of prepared.resolution.warnings)
|
|
169
|
+
lines.push(`Warning: ${warning}`);
|
|
170
|
+
for (const item of prepared.skipped)
|
|
171
|
+
lines.push(`${item.kind === "preparation-failed" ? "Failed" : "Deferred"} ${item.packageId}: ${item.reason}`);
|
|
172
|
+
return lines.join("\n");
|
|
173
|
+
}
|
|
174
|
+
export async function applyPreparedCatalogInstall(prepared, options = {}) {
|
|
175
|
+
if (!prepared.entries.length)
|
|
176
|
+
throw new Error("No reviewed skill packages could be prepared for installation");
|
|
177
|
+
const failures = prepared.skipped.filter((item) => item.kind === "preparation-failed");
|
|
178
|
+
if (failures.length)
|
|
179
|
+
throw new Error(`Setup is incomplete because reviewed packages failed to prepare: ${failures.map((item) => item.packageId).join(", ")}. Retry when GitHub is reachable; no partial loadout was installed.`);
|
|
180
|
+
const risky = prepared.entries.filter((entry) => entry.safety.approvalRequired);
|
|
181
|
+
if (risky.length && !options.approveRisk)
|
|
182
|
+
throw new Error(`Additional risk approval is required for: ${risky.map((entry) => entry.package.id).join(", ")}. Review the plan, then use --approve-risk.`);
|
|
183
|
+
return prepared.selection.mode === "maximum"
|
|
184
|
+
? applySkillLibraryBatch(prepared.entries)
|
|
185
|
+
: applySkillInstallBatch(prepared.entries);
|
|
186
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { readFile, rm, stat } from "node:fs/promises";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { isDeepStrictEqual } from "node:util";
|
|
5
|
+
import { writeFileAtomically } from "./atomic-file.js";
|
|
6
|
+
import { catalogTrustPath, loadEffectiveCatalog, readCatalogTrustState, readTrustedCatalogState, trustedCatalogPath, validateCatalog, } from "./catalog.js";
|
|
7
|
+
import { ensureDirectory } from "./paths.js";
|
|
8
|
+
import { loadoutHome } from "./paths.js";
|
|
9
|
+
import { withFileLock } from "./file-lock.js";
|
|
10
|
+
import { createSnapshot } from "./snapshot.js";
|
|
11
|
+
import { verifyEnvelope } from "./signing.js";
|
|
12
|
+
import { beginTransaction, completeTransaction, markTransactionCommitting, rollbackTransaction, recoverPendingTransactions, } from "./transaction.js";
|
|
13
|
+
const MAX_RELEASE_BYTES = 5 * 1024 * 1024;
|
|
14
|
+
const MAX_PUBLIC_KEY_BYTES = 64 * 1024;
|
|
15
|
+
const catalogReleaseLockPath = () => join(loadoutHome(), "catalog-releases", "apply.lock");
|
|
16
|
+
const envelopeDigest = (envelope) => createHash("sha256").update(envelope.signature).digest("hex");
|
|
17
|
+
function parseEnvelope(value) {
|
|
18
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
19
|
+
throw new Error("Signed catalog snapshot must be an object");
|
|
20
|
+
const envelope = value;
|
|
21
|
+
if (envelope.schemaVersion !== 1 ||
|
|
22
|
+
envelope.algorithm !== "Ed25519" ||
|
|
23
|
+
typeof envelope.createdAt !== "string" ||
|
|
24
|
+
Number.isNaN(Date.parse(envelope.createdAt)) ||
|
|
25
|
+
typeof envelope.publicKeyFingerprint !== "string" ||
|
|
26
|
+
typeof envelope.signature !== "string" ||
|
|
27
|
+
envelope.payload === undefined)
|
|
28
|
+
throw new Error("Signed catalog envelope schema is invalid");
|
|
29
|
+
return envelope;
|
|
30
|
+
}
|
|
31
|
+
async function readReleaseSource(source, fetcher = fetch, requestTimeoutMs = 15_000) {
|
|
32
|
+
if (/^https:\/\//i.test(source)) {
|
|
33
|
+
if (!Number.isInteger(requestTimeoutMs) ||
|
|
34
|
+
requestTimeoutMs < 1 ||
|
|
35
|
+
requestTimeoutMs > 120_000)
|
|
36
|
+
throw new Error("Catalog release timeout must be 1-120000 milliseconds");
|
|
37
|
+
let current = new URL(source);
|
|
38
|
+
const signal = AbortSignal.timeout(requestTimeoutMs);
|
|
39
|
+
let response;
|
|
40
|
+
for (let redirects = 0; redirects <= 5; redirects++) {
|
|
41
|
+
try {
|
|
42
|
+
response = await fetcher(current, {
|
|
43
|
+
headers: { accept: "application/json" },
|
|
44
|
+
redirect: "manual",
|
|
45
|
+
signal,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
if (signal.aborted)
|
|
50
|
+
throw new Error(`Catalog release request timed out after ${requestTimeoutMs}ms`, { cause: error });
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
if (response.status < 300 || response.status >= 400)
|
|
54
|
+
break;
|
|
55
|
+
const location = response.headers.get("location");
|
|
56
|
+
if (!location)
|
|
57
|
+
throw new Error("Catalog release redirect has no Location");
|
|
58
|
+
if (redirects === 5)
|
|
59
|
+
throw new Error("Catalog release exceeded five redirects");
|
|
60
|
+
const next = new URL(location, current);
|
|
61
|
+
if (next.protocol !== "https:" || next.username || next.password)
|
|
62
|
+
throw new Error("Catalog release redirected to a non-HTTPS source");
|
|
63
|
+
current = next;
|
|
64
|
+
}
|
|
65
|
+
if (!response)
|
|
66
|
+
throw new Error("Catalog release request did not run");
|
|
67
|
+
if (!response.ok)
|
|
68
|
+
throw new Error(`Catalog release request failed with HTTP ${response.status}`);
|
|
69
|
+
if (response.url && new URL(response.url).protocol !== "https:")
|
|
70
|
+
throw new Error("Catalog release redirected to a non-HTTPS source");
|
|
71
|
+
const length = Number(response.headers.get("content-length") ?? 0);
|
|
72
|
+
if (length > MAX_RELEASE_BYTES)
|
|
73
|
+
throw new Error("Catalog release exceeds the 5 MiB limit");
|
|
74
|
+
if (!response.body)
|
|
75
|
+
throw new Error("Catalog release response has no body");
|
|
76
|
+
const reader = response.body.getReader();
|
|
77
|
+
const chunks = [];
|
|
78
|
+
let bytes = 0;
|
|
79
|
+
while (true) {
|
|
80
|
+
const { done, value } = await reader.read();
|
|
81
|
+
if (done)
|
|
82
|
+
break;
|
|
83
|
+
bytes += value.byteLength;
|
|
84
|
+
if (bytes > MAX_RELEASE_BYTES) {
|
|
85
|
+
await reader.cancel();
|
|
86
|
+
throw new Error("Catalog release exceeds the 5 MiB limit");
|
|
87
|
+
}
|
|
88
|
+
chunks.push(value);
|
|
89
|
+
}
|
|
90
|
+
return Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString("utf8");
|
|
91
|
+
}
|
|
92
|
+
if (/^[a-z]+:\/\//i.test(source))
|
|
93
|
+
throw new Error("Remote catalog sources must use HTTPS");
|
|
94
|
+
return readBoundedFile(resolve(source), MAX_RELEASE_BYTES, "catalog release");
|
|
95
|
+
}
|
|
96
|
+
async function readBoundedFile(path, maximum, label) {
|
|
97
|
+
const info = await stat(path);
|
|
98
|
+
if (!info.isFile() || info.size > maximum)
|
|
99
|
+
throw new Error(`${label} exceeds the ${maximum} byte limit`);
|
|
100
|
+
const content = await readFile(path, "utf8");
|
|
101
|
+
if (Buffer.byteLength(content) > maximum)
|
|
102
|
+
throw new Error(`${label} exceeds the ${maximum} byte limit`);
|
|
103
|
+
return content;
|
|
104
|
+
}
|
|
105
|
+
function diffCatalogs(current, candidate) {
|
|
106
|
+
const currentById = new Map(current.map((item) => [item.id, item]));
|
|
107
|
+
const candidateById = new Map(candidate.map((item) => [item.id, item]));
|
|
108
|
+
const added = candidate
|
|
109
|
+
.filter((item) => !currentById.has(item.id))
|
|
110
|
+
.map((item) => item.id)
|
|
111
|
+
.sort();
|
|
112
|
+
const removed = current
|
|
113
|
+
.filter((item) => !candidateById.has(item.id))
|
|
114
|
+
.map((item) => item.id)
|
|
115
|
+
.sort();
|
|
116
|
+
const updated = candidate
|
|
117
|
+
.filter((item) => {
|
|
118
|
+
const previous = currentById.get(item.id);
|
|
119
|
+
return previous && !isDeepStrictEqual(previous, item);
|
|
120
|
+
})
|
|
121
|
+
.map((item) => item.id)
|
|
122
|
+
.sort();
|
|
123
|
+
return {
|
|
124
|
+
added,
|
|
125
|
+
updated,
|
|
126
|
+
removed,
|
|
127
|
+
unchanged: candidate.length - added.length - updated.length,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
export async function previewCatalogRelease(options) {
|
|
131
|
+
const [raw, publicKeyPem, trusted, trust] = await Promise.all([
|
|
132
|
+
readReleaseSource(options.source, options.fetcher, options.requestTimeoutMs),
|
|
133
|
+
readBoundedFile(resolve(options.publicKeyPath), MAX_PUBLIC_KEY_BYTES, "public key"),
|
|
134
|
+
readTrustedCatalogState(),
|
|
135
|
+
readCatalogTrustState(),
|
|
136
|
+
]);
|
|
137
|
+
let parsed;
|
|
138
|
+
try {
|
|
139
|
+
parsed = JSON.parse(raw);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
throw new Error("Catalog release is not valid JSON");
|
|
143
|
+
}
|
|
144
|
+
const envelope = parseEnvelope(parsed);
|
|
145
|
+
const verification = verifyEnvelope(envelope, publicKeyPem);
|
|
146
|
+
if (!verification.valid)
|
|
147
|
+
throw new Error(`Catalog release signature is invalid for trusted key ${verification.fingerprint}`);
|
|
148
|
+
const pendingIsActive = Boolean(trust?.pendingCreatedAt &&
|
|
149
|
+
trusted?.envelope.createdAt === trust.pendingCreatedAt &&
|
|
150
|
+
envelopeDigest(trusted.envelope) === trust.pendingEnvelopeSha256);
|
|
151
|
+
const effectiveTrust = trust?.pendingFirstPin && !pendingIsActive ? undefined : trust;
|
|
152
|
+
if (effectiveTrust && verification.fingerprint !== effectiveTrust.fingerprint)
|
|
153
|
+
throw new Error(`Catalog signer ${verification.fingerprint} does not match pinned key ${effectiveTrust.fingerprint}; key rotation requires an explicit trust reset`);
|
|
154
|
+
validateCatalog(envelope.payload, { requireEvidence: true });
|
|
155
|
+
const catalogEnvelope = envelope;
|
|
156
|
+
const previousTime = Math.max(trusted ? Date.parse(trusted.envelope.createdAt) : -Infinity, effectiveTrust ? Date.parse(effectiveTrust.highWaterCreatedAt) : -Infinity);
|
|
157
|
+
return {
|
|
158
|
+
source: options.source,
|
|
159
|
+
createdAt: catalogEnvelope.createdAt,
|
|
160
|
+
fingerprint: verification.fingerprint,
|
|
161
|
+
packageCount: catalogEnvelope.payload.length,
|
|
162
|
+
diff: diffCatalogs(options.currentCatalog, catalogEnvelope.payload),
|
|
163
|
+
replay: Date.parse(catalogEnvelope.createdAt) <= previousTime,
|
|
164
|
+
envelope: catalogEnvelope,
|
|
165
|
+
publicKeyPem,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
export async function applyCatalogRelease(preview, options = {}) {
|
|
169
|
+
return withFileLock(catalogReleaseLockPath(), async () => {
|
|
170
|
+
await recoverPendingTransactions();
|
|
171
|
+
const verification = verifyEnvelope(preview.envelope, preview.publicKeyPem);
|
|
172
|
+
if (!verification.valid || verification.fingerprint !== preview.fingerprint)
|
|
173
|
+
throw new Error("Catalog release preview no longer has a valid trusted signature");
|
|
174
|
+
validateCatalog(preview.envelope.payload, { requireEvidence: true });
|
|
175
|
+
let trust = await readCatalogTrustState();
|
|
176
|
+
const trusted = await readTrustedCatalogState();
|
|
177
|
+
let exactPending;
|
|
178
|
+
if (trust?.pendingCreatedAt) {
|
|
179
|
+
const pendingIsActive = trusted?.envelope.createdAt === trust.pendingCreatedAt &&
|
|
180
|
+
envelopeDigest(trusted.envelope) === trust.pendingEnvelopeSha256;
|
|
181
|
+
if (pendingIsActive &&
|
|
182
|
+
envelopeDigest(preview.envelope) === trust.pendingEnvelopeSha256)
|
|
183
|
+
exactPending = trusted;
|
|
184
|
+
const base = { ...trust };
|
|
185
|
+
delete base.pendingCreatedAt;
|
|
186
|
+
delete base.pendingEnvelopeSha256;
|
|
187
|
+
delete base.pendingFirstPin;
|
|
188
|
+
if (!pendingIsActive && trust.pendingFirstPin) {
|
|
189
|
+
await rm(catalogTrustPath(), { force: true });
|
|
190
|
+
trust = undefined;
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
trust = {
|
|
194
|
+
...base,
|
|
195
|
+
...(pendingIsActive
|
|
196
|
+
? {
|
|
197
|
+
highWaterCreatedAt: new Date(Math.max(Date.parse(trust.highWaterCreatedAt), Date.parse(trust.pendingCreatedAt))).toISOString(),
|
|
198
|
+
}
|
|
199
|
+
: {}),
|
|
200
|
+
};
|
|
201
|
+
await writeFileAtomically(catalogTrustPath(), `${JSON.stringify(trust, null, 2)}\n`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
if (exactPending) {
|
|
205
|
+
if (!exactPending.snapshotId)
|
|
206
|
+
throw new Error("Finalized the active signed catalog, but its legacy state has no rollback snapshot id");
|
|
207
|
+
return {
|
|
208
|
+
path: trustedCatalogPath(),
|
|
209
|
+
snapshotId: exactPending.snapshotId,
|
|
210
|
+
diff: diffCatalogs(await loadEffectiveCatalog(), preview.envelope.payload),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
if (trust && verification.fingerprint !== trust.fingerprint)
|
|
214
|
+
throw new Error(`Catalog signer ${verification.fingerprint} does not match pinned key ${trust.fingerprint}; key rotation requires an explicit trust reset`);
|
|
215
|
+
const previousTime = Math.max(trusted ? Date.parse(trusted.envelope.createdAt) : -Infinity, trust ? Date.parse(trust.highWaterCreatedAt) : -Infinity);
|
|
216
|
+
if (Date.parse(preview.envelope.createdAt) <= previousTime)
|
|
217
|
+
throw new Error("Refusing an older or already-applied signed catalog release");
|
|
218
|
+
const diff = diffCatalogs(await loadEffectiveCatalog(), preview.envelope.payload);
|
|
219
|
+
if (diff.removed.length && !options.allowRemovals)
|
|
220
|
+
throw new Error(`Release removes ${diff.removed.length} package(s); review and pass --allow-removals explicitly`);
|
|
221
|
+
const now = options.now ?? new Date();
|
|
222
|
+
const target = trustedCatalogPath();
|
|
223
|
+
const pinned = trust ??
|
|
224
|
+
{
|
|
225
|
+
schemaVersion: 1,
|
|
226
|
+
fingerprint: verification.fingerprint,
|
|
227
|
+
publicKeyPem: preview.publicKeyPem,
|
|
228
|
+
highWaterCreatedAt: new Date(0).toISOString(),
|
|
229
|
+
pinnedAt: now.toISOString(),
|
|
230
|
+
};
|
|
231
|
+
await ensureDirectory(dirname(target));
|
|
232
|
+
const snapshot = await createSnapshot([target], { persist: false });
|
|
233
|
+
const transaction = await beginTransaction(snapshot, [target]);
|
|
234
|
+
const state = {
|
|
235
|
+
schemaVersion: 1,
|
|
236
|
+
appliedAt: now.toISOString(),
|
|
237
|
+
source: preview.source,
|
|
238
|
+
publicKeyPem: pinned.publicKeyPem,
|
|
239
|
+
envelope: preview.envelope,
|
|
240
|
+
snapshotId: snapshot.id,
|
|
241
|
+
};
|
|
242
|
+
try {
|
|
243
|
+
await writeFileAtomically(catalogTrustPath(), `${JSON.stringify({
|
|
244
|
+
...pinned,
|
|
245
|
+
pendingCreatedAt: preview.envelope.createdAt,
|
|
246
|
+
pendingEnvelopeSha256: envelopeDigest(preview.envelope),
|
|
247
|
+
...(!trust ? { pendingFirstPin: true } : {}),
|
|
248
|
+
}, null, 2)}\n`);
|
|
249
|
+
await markTransactionCommitting(transaction);
|
|
250
|
+
await writeFileAtomically(target, `${JSON.stringify(state, null, 2)}\n`);
|
|
251
|
+
await completeTransaction(transaction, { releaseLock: false });
|
|
252
|
+
await writeFileAtomically(catalogTrustPath(), `${JSON.stringify({
|
|
253
|
+
...pinned,
|
|
254
|
+
highWaterCreatedAt: preview.envelope.createdAt,
|
|
255
|
+
}, null, 2)}\n`);
|
|
256
|
+
await transaction.mutationLock.release();
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
await rollbackTransaction(transaction);
|
|
260
|
+
if (!trust)
|
|
261
|
+
await rm(catalogTrustPath(), { force: true });
|
|
262
|
+
throw error;
|
|
263
|
+
}
|
|
264
|
+
return { path: target, snapshotId: snapshot.id, diff };
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
export function formatCatalogReleasePreview(preview) {
|
|
268
|
+
return [
|
|
269
|
+
`Signed catalog: ${preview.packageCount} package(s)`,
|
|
270
|
+
`Created: ${preview.createdAt}`,
|
|
271
|
+
`Trusted key: ${preview.fingerprint}`,
|
|
272
|
+
`Diff: +${preview.diff.added.length} ~${preview.diff.updated.length} -${preview.diff.removed.length} =${preview.diff.unchanged}`,
|
|
273
|
+
...(preview.diff.added.length
|
|
274
|
+
? [`Added: ${preview.diff.added.join(", ")}`]
|
|
275
|
+
: []),
|
|
276
|
+
...(preview.diff.updated.length
|
|
277
|
+
? [`Updated: ${preview.diff.updated.join(", ")}`]
|
|
278
|
+
: []),
|
|
279
|
+
...(preview.diff.removed.length
|
|
280
|
+
? [`Removed: ${preview.diff.removed.join(", ")}`]
|
|
281
|
+
: []),
|
|
282
|
+
preview.replay
|
|
283
|
+
? "Status: older or already applied; apply will be refused"
|
|
284
|
+
: "Status: signature and evidence valid; no changes applied yet",
|
|
285
|
+
].join("\n");
|
|
286
|
+
}
|