loadout-ai 0.2.3 → 0.4.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 +71 -0
- package/MASTER_PLAN.md +141 -33
- package/README.md +160 -255
- package/dashboard/app.js +4 -4
- package/dashboard/index.html +4 -4
- package/dist/src/cli.js +189 -32
- package/dist/src/core/active-set.js +37 -2
- package/dist/src/core/adapters.js +10 -0
- package/dist/src/core/adopt.js +165 -32
- package/dist/src/core/agent-health-score.js +2 -2
- package/dist/src/core/catalog-coverage.js +2 -1
- package/dist/src/core/catalog-install.js +8 -1
- package/dist/src/core/catalog-release.js +2 -1
- package/dist/src/core/cli-guide.js +101 -0
- package/dist/src/core/completion.js +3 -0
- package/dist/src/core/conformance.js +74 -0
- package/dist/src/core/install.js +36 -3
- package/dist/src/core/mcp-recipes.js +21 -0
- package/dist/src/core/profile-state.js +101 -0
- package/dist/src/core/profiles.js +9 -4
- package/dist/src/core/ranking.js +1 -1
- package/dist/src/core/readme-claims.js +10 -0
- package/dist/src/core/readme-facts.js +40 -0
- package/dist/src/core/recommend.js +9 -3
- package/dist/src/core/remove.js +6 -7
- package/dist/src/core/runtime-tools.js +10 -2
- package/dist/src/core/scheduler.js +4 -3
- package/dist/src/core/snapshot.js +58 -13
- package/dist/src/core/state.js +13 -3
- package/dist/src/core/transaction.js +2 -1
- package/dist/src/core/uninstall.js +133 -0
- package/dist/src/core/update.js +87 -58
- package/dist/src/dashboard.js +5 -2
- package/dist/src/shared/schemas.js +69 -0
- package/docs/FEATURE_TEST_MATRIX.md +16 -0
- package/docs/README_RESEARCH.md +36 -0
- package/docs/RELEASE_REVIEW.md +31 -5
- package/docs/REPOSITORY_STABILIZATION.md +190 -0
- package/docs/TESTING.md +75 -2
- package/docs/USER_TEST_GUIDE.md +202 -0
- package/docs/assets/loadout-hero.svg +259 -0
- package/docs/assets/loadout-mark.svg +54 -0
- package/docs/evidence/live-checks-2026-07-19.json +22 -0
- package/docs/evidence/live-checks.schema.json +28 -0
- package/docs/evidence/readme-claims.json +286 -0
- package/docs/superpowers/plans/2026-07-19-relatable-readme-hero.md +283 -0
- package/docs/superpowers/specs/2026-07-19-relatable-readme-hero-design.md +80 -0
- package/package.json +8 -4
- package/SIMPLE_PLAN.md +0 -44
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { resolveCatalogProfile } from "./profiles.js";
|
|
2
|
+
import { readInstallState, writeInstallState } from "./state.js";
|
|
3
|
+
export async function recordInstalledProfile(prepared) {
|
|
4
|
+
const profile = {
|
|
5
|
+
mode: prepared.selection.mode,
|
|
6
|
+
...(prepared.selection.packageIds
|
|
7
|
+
? { packageIds: [...prepared.selection.packageIds] }
|
|
8
|
+
: {}),
|
|
9
|
+
agents: prepared.agents.map((agent) => agent.id),
|
|
10
|
+
catalogPackages: prepared.resolution.packages
|
|
11
|
+
.filter((pkg) => pkg.components?.includes("skill"))
|
|
12
|
+
.map((pkg) => ({
|
|
13
|
+
packageId: pkg.id,
|
|
14
|
+
...(pkg.source?.commit ? { reviewedCommit: pkg.source.commit } : {}),
|
|
15
|
+
})),
|
|
16
|
+
appliedAt: new Date().toISOString(),
|
|
17
|
+
};
|
|
18
|
+
const state = await readInstallState();
|
|
19
|
+
await writeInstallState({ ...state, profile });
|
|
20
|
+
return profile;
|
|
21
|
+
}
|
|
22
|
+
export function evaluateInstalledProfileState(state, catalog) {
|
|
23
|
+
const evaluatedAt = new Date().toISOString();
|
|
24
|
+
const boundary = "Checks the current signed/reviewed catalog. Newly discovered candidates stay recommendations until review evidence promotes them.";
|
|
25
|
+
if (!state.profile)
|
|
26
|
+
return {
|
|
27
|
+
installed: false,
|
|
28
|
+
expectedPackages: [],
|
|
29
|
+
missingPackages: [],
|
|
30
|
+
reviewedRevisionChanges: [],
|
|
31
|
+
needsRefresh: false,
|
|
32
|
+
evaluatedAt,
|
|
33
|
+
boundary,
|
|
34
|
+
};
|
|
35
|
+
const resolution = resolveCatalogProfile(catalog, {
|
|
36
|
+
mode: state.profile.mode,
|
|
37
|
+
...(state.profile.packageIds
|
|
38
|
+
? { packageIds: state.profile.packageIds }
|
|
39
|
+
: {}),
|
|
40
|
+
});
|
|
41
|
+
const expected = resolution.packages.filter((pkg) => pkg.components?.includes("skill"));
|
|
42
|
+
const installed = new Map(state.installs.map((record) => [record.packageId, record]));
|
|
43
|
+
const previousCatalog = new Map(state.profile.catalogPackages.map((record) => [record.packageId, record]));
|
|
44
|
+
const missingPackages = expected
|
|
45
|
+
.filter((pkg) => !installed.has(pkg.id))
|
|
46
|
+
.map((pkg) => pkg.id);
|
|
47
|
+
const reviewedRevisionChanges = expected.flatMap((pkg) => {
|
|
48
|
+
const previous = previousCatalog.get(pkg.id);
|
|
49
|
+
if (!previous ||
|
|
50
|
+
!pkg.source?.commit ||
|
|
51
|
+
previous.reviewedCommit?.toLowerCase() === pkg.source.commit.toLowerCase())
|
|
52
|
+
return [];
|
|
53
|
+
return [
|
|
54
|
+
{
|
|
55
|
+
packageId: pkg.id,
|
|
56
|
+
previousReviewedCommit: previous.reviewedCommit,
|
|
57
|
+
reviewedCommit: pkg.source.commit,
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
});
|
|
61
|
+
const oldIds = state.profile.catalogPackages
|
|
62
|
+
.map((item) => item.packageId)
|
|
63
|
+
.sort();
|
|
64
|
+
const expectedPackages = expected.map((pkg) => pkg.id).sort();
|
|
65
|
+
const selectionChanged = oldIds.join("\0") !== expectedPackages.join("\0");
|
|
66
|
+
return {
|
|
67
|
+
installed: true,
|
|
68
|
+
mode: state.profile.mode,
|
|
69
|
+
appliedAt: state.profile.appliedAt,
|
|
70
|
+
expectedPackages,
|
|
71
|
+
missingPackages,
|
|
72
|
+
reviewedRevisionChanges,
|
|
73
|
+
needsRefresh: selectionChanged ||
|
|
74
|
+
missingPackages.length > 0 ||
|
|
75
|
+
reviewedRevisionChanges.length > 0,
|
|
76
|
+
evaluatedAt,
|
|
77
|
+
boundary,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export async function evaluateInstalledProfile(catalog) {
|
|
81
|
+
return evaluateInstalledProfileState(await readInstallState(), catalog);
|
|
82
|
+
}
|
|
83
|
+
export function formatInstalledProfileStatus(status) {
|
|
84
|
+
if (!status.installed)
|
|
85
|
+
return "PROFILE No saved Stable, Power, Maximum, or Custom profile yet.";
|
|
86
|
+
return [
|
|
87
|
+
`PROFILE ${status.mode?.toUpperCase()}: ${status.needsRefresh ? "reviewed changes available" : "current against reviewed catalog"}`,
|
|
88
|
+
`Expected repositories: ${status.expectedPackages.length}`,
|
|
89
|
+
...(status.missingPackages.length
|
|
90
|
+
? [`Missing: ${status.missingPackages.join(", ")}`]
|
|
91
|
+
: []),
|
|
92
|
+
...(status.reviewedRevisionChanges.length
|
|
93
|
+
? [
|
|
94
|
+
`Reviewed revisions changed: ${status.reviewedRevisionChanges
|
|
95
|
+
.map((item) => item.packageId)
|
|
96
|
+
.join(", ")}`,
|
|
97
|
+
]
|
|
98
|
+
: []),
|
|
99
|
+
`Trust boundary: ${status.boundary}`,
|
|
100
|
+
].join("\n");
|
|
101
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { compareCatalogPackages } from "./ranking.js";
|
|
2
2
|
/**
|
|
3
|
-
* Stable is Loadout's
|
|
3
|
+
* Stable is Loadout's bounded policy-selected daily driver: broad enough to improve normal
|
|
4
4
|
* engineering work immediately, but bounded at skill granularity and restricted
|
|
5
5
|
* to catalog sources with an identified SPDX license. Maximum remains the
|
|
6
6
|
* explicit broad-library mode.
|
|
@@ -45,7 +45,8 @@ export const STABLE_BOOST_PACKAGE_IDS = Object.freeze(Object.keys(STABLE_SKILL_A
|
|
|
45
45
|
/**
|
|
46
46
|
* Trust is deliberately separate from popularity and publisher tier. No
|
|
47
47
|
* bundled record is labelled human-reviewed or benchmarked until that evidence
|
|
48
|
-
* is actually stored; Stable is the policy-
|
|
48
|
+
* is actually stored; Stable is the policy-selected subset. The stored
|
|
49
|
+
* `recommended` value remains for schema compatibility.
|
|
49
50
|
*/
|
|
50
51
|
export function catalogTrustStage(pkg) {
|
|
51
52
|
if (STABLE_BOOST_PACKAGE_IDS.includes(pkg.id) &&
|
|
@@ -58,6 +59,10 @@ export function catalogTrustStage(pkg) {
|
|
|
58
59
|
return "inspected";
|
|
59
60
|
return "discovered";
|
|
60
61
|
}
|
|
62
|
+
/** Keep stored `recommended` values compatible while naming their evidence honestly. */
|
|
63
|
+
export function formatCatalogTrustStage(stage) {
|
|
64
|
+
return stage === "recommended" ? "policy-selected" : stage;
|
|
65
|
+
}
|
|
61
66
|
export function isStableSkillSelected(packageId, skillName, targetName) {
|
|
62
67
|
const selected = STABLE_SKILL_ALLOWLIST[packageId];
|
|
63
68
|
if (!selected)
|
|
@@ -233,7 +238,7 @@ export function resolveCatalogProfile(packages, selection, families = CATALOG_CO
|
|
|
233
238
|
selected.delete(secondary.id);
|
|
234
239
|
deferred.push(secondary);
|
|
235
240
|
}
|
|
236
|
-
warnings.push(`Stable
|
|
241
|
+
warnings.push(`Loadout policy selection for Stable chose ${primary.displayName}; deferred ${members
|
|
237
242
|
.slice(1)
|
|
238
243
|
.map((pkg) => pkg.displayName)
|
|
239
244
|
.join(", ")} because they overlap in ${family.label}.`);
|
|
@@ -242,7 +247,7 @@ export function resolveCatalogProfile(packages, selection, families = CATALOG_CO
|
|
|
242
247
|
warnings.push(`Custom selection retains the soft overlap in ${family.label}. Review ${names} before installation.`);
|
|
243
248
|
}
|
|
244
249
|
else {
|
|
245
|
-
warnings.push(`${selection.mode === "power" ? "Power Boost" : "Maximum Library"} retains the soft overlap in ${family.label}. ${primary.displayName}
|
|
250
|
+
warnings.push(`${selection.mode === "power" ? "Power Boost" : "Maximum Library"} retains the soft overlap in ${family.label}. Loadout policy orders ${primary.displayName} first; review each package before installation.`);
|
|
246
251
|
}
|
|
247
252
|
}
|
|
248
253
|
return {
|
package/dist/src/core/ranking.js
CHANGED
|
@@ -96,7 +96,7 @@ export function explainCatalogScore(pkg, now = new Date()) {
|
|
|
96
96
|
],
|
|
97
97
|
};
|
|
98
98
|
}
|
|
99
|
-
/** A deterministic ordering:
|
|
99
|
+
/** A deterministic policy ordering: declared tier, bounded evidence score, then id. */
|
|
100
100
|
export function compareCatalogPackages(a, b) {
|
|
101
101
|
return (TIER_ORDER[b.tier] - TIER_ORDER[a.tier] ||
|
|
102
102
|
explainCatalogScore(b).score - explainCatalogScore(a).score ||
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { formatSchemaError, readmeClaimManifestSchema, } from "../shared/schemas.js";
|
|
2
|
+
export { readmeClaimManifestSchema };
|
|
3
|
+
/** Parse the versioned index of evidence for material README statements. */
|
|
4
|
+
export function parseReadmeClaimManifest(value) {
|
|
5
|
+
const result = readmeClaimManifestSchema.safeParse(value);
|
|
6
|
+
if (!result.success) {
|
|
7
|
+
throw new Error(`README claim manifest is invalid: ${formatSchemaError(result.error)}`);
|
|
8
|
+
}
|
|
9
|
+
return result.data;
|
|
10
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { supportedAdapterNames } from "./adapters.js";
|
|
2
|
+
import { buildCatalogCoverage } from "./catalog-coverage.js";
|
|
3
|
+
function profileFacts(allowlist) {
|
|
4
|
+
return {
|
|
5
|
+
sources: Object.keys(allowlist).length,
|
|
6
|
+
skillDirectories: Object.values(allowlist).reduce((count, skills) => count + skills.length, 0),
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Derive all changeable README facts from passed authoritative source data.
|
|
11
|
+
* This module intentionally neither reads nor parses README text.
|
|
12
|
+
*/
|
|
13
|
+
export function deriveReadmeFacts({ catalog, packageJson, agents, profiles, }) {
|
|
14
|
+
const coverage = buildCatalogCoverage(catalog);
|
|
15
|
+
return {
|
|
16
|
+
catalog: {
|
|
17
|
+
records: coverage.records,
|
|
18
|
+
categories: coverage.categoryCount,
|
|
19
|
+
components: coverage.components,
|
|
20
|
+
installShapes: coverage.installShapes,
|
|
21
|
+
assertedLicenses: coverage.assertedLicenses,
|
|
22
|
+
noAssertionLicenses: coverage.noAssertionLicenses,
|
|
23
|
+
},
|
|
24
|
+
profiles: {
|
|
25
|
+
stable: profileFacts(profiles.stable),
|
|
26
|
+
power: profileFacts(profiles.power),
|
|
27
|
+
},
|
|
28
|
+
agents: {
|
|
29
|
+
supportedNames: supportedAdapterNames(agents),
|
|
30
|
+
},
|
|
31
|
+
package: {
|
|
32
|
+
name: packageJson.name,
|
|
33
|
+
version: packageJson.version,
|
|
34
|
+
bin: { ...packageJson.bin },
|
|
35
|
+
},
|
|
36
|
+
runtime: {
|
|
37
|
+
node: packageJson.engines.node,
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -17,6 +17,11 @@ const SIGNAL_FILES = new Set([
|
|
|
17
17
|
"playwright.config.ts",
|
|
18
18
|
".git",
|
|
19
19
|
]);
|
|
20
|
+
/** Additive machine-readable boundary for every rule-selected recommendation list. */
|
|
21
|
+
export const RECOMMENDATION_BOUNDARY = Object.freeze({
|
|
22
|
+
selectionMethod: "deterministic-project-signal-rules",
|
|
23
|
+
qualityEvidence: "not-established",
|
|
24
|
+
});
|
|
20
25
|
export async function scanProject(root = process.cwd()) {
|
|
21
26
|
const absolute = resolve(root);
|
|
22
27
|
const entries = await readdir(absolute, { withFileTypes: true });
|
|
@@ -132,7 +137,7 @@ export function personalizeRecommendations(recommendations, signals, outcomes, a
|
|
|
132
137
|
}
|
|
133
138
|
export const TESTED_PROFILES = {
|
|
134
139
|
stable: {
|
|
135
|
-
description: "
|
|
140
|
+
description: "Loadout policy selection: 30 skills from four pinned, SPDX-identified sources with no extra static-risk approvals.",
|
|
136
141
|
packages: [...STABLE_BOOST_PACKAGE_IDS],
|
|
137
142
|
},
|
|
138
143
|
web: {
|
|
@@ -144,7 +149,7 @@ export const TESTED_PROFILES = {
|
|
|
144
149
|
packages: ["superpowers", "context7", "github-mcp-server"],
|
|
145
150
|
},
|
|
146
151
|
maximum: {
|
|
147
|
-
description: "Broad
|
|
152
|
+
description: "Broad inspected toolkit; always review MCP permissions before applying.",
|
|
148
153
|
packages: [
|
|
149
154
|
"superpowers",
|
|
150
155
|
"context7",
|
|
@@ -171,7 +176,8 @@ export function formatRecommendations(signals, recommendations) {
|
|
|
171
176
|
`Project: ${basename(signals.root)}`,
|
|
172
177
|
`Detected: ${[...signals.languages, ...signals.frameworks].join(", ") || "no known project signals"}`,
|
|
173
178
|
"",
|
|
174
|
-
"
|
|
179
|
+
"Rule-based project suggestions:",
|
|
180
|
+
"Rules use detected project signals and catalog membership; they do not prove package quality.",
|
|
175
181
|
];
|
|
176
182
|
if (!recommendations.length)
|
|
177
183
|
lines.push(" No matching catalog packages found.");
|
package/dist/src/core/remove.js
CHANGED
|
@@ -4,10 +4,12 @@ import { forgetInstall, installStatePath, readInstallState } from "./state.js";
|
|
|
4
4
|
import { writeMcpConfigPlan } from "./mcp.js";
|
|
5
5
|
import { runMutationTransaction } from "./transaction.js";
|
|
6
6
|
export async function planRemove(packageId) {
|
|
7
|
-
const
|
|
8
|
-
|
|
7
|
+
const state = await readInstallState();
|
|
8
|
+
const record = state.installs.find((entry) => entry.packageId === packageId);
|
|
9
|
+
const trackedMcp = (state.mcpInstalls ?? []).filter((entry) => entry.packageId === packageId);
|
|
10
|
+
if (!record && !trackedMcp.length)
|
|
9
11
|
throw new Error(`Package is not managed by Loadout: ${packageId}`);
|
|
10
|
-
const files = await Promise.all(record
|
|
12
|
+
const files = await Promise.all((record?.files ?? []).map(async (file) => {
|
|
11
13
|
try {
|
|
12
14
|
const digest = createHash("sha256")
|
|
13
15
|
.update(await readFile(file.path))
|
|
@@ -24,10 +26,7 @@ export async function planRemove(packageId) {
|
|
|
24
26
|
}
|
|
25
27
|
}));
|
|
26
28
|
const modified = files.filter((file) => file.status === "modified");
|
|
27
|
-
const
|
|
28
|
-
const mcpServers = await Promise.all((state.mcpInstalls ?? [])
|
|
29
|
-
.filter((entry) => entry.packageId === packageId)
|
|
30
|
-
.map(async (entry) => {
|
|
29
|
+
const mcpServers = await Promise.all(trackedMcp.map(async (entry) => {
|
|
31
30
|
try {
|
|
32
31
|
const config = JSON.parse(await readFile(entry.configPath, "utf8"));
|
|
33
32
|
if (!config.mcpServers || !(entry.serverName in config.mcpServers))
|
|
@@ -5,7 +5,7 @@ import { promisify } from "node:util";
|
|
|
5
5
|
import { writeFileAtomically } from "./atomic-file.js";
|
|
6
6
|
import { detectAgents, loadoutHome, userHome } from "./paths.js";
|
|
7
7
|
import { parseRuntimeToolRecipe, renderRuntimeRecipeValue, resolveRuntimeRecipePath, } from "./runtime-tool-recipe.js";
|
|
8
|
-
import { createSnapshot, readSnapshot, restoreSnapshot } from "./snapshot.js";
|
|
8
|
+
import { createSnapshot, readSnapshot, recordSnapshotPostMutationState, restoreSnapshot, } from "./snapshot.js";
|
|
9
9
|
const execFileAsync = promisify(execFile);
|
|
10
10
|
function deepFreeze(value) {
|
|
11
11
|
if (value && typeof value === "object" && !Object.isFrozen(value)) {
|
|
@@ -217,6 +217,11 @@ async function readState(stateHome) {
|
|
|
217
217
|
throw error;
|
|
218
218
|
}
|
|
219
219
|
}
|
|
220
|
+
/** Return runtime tools currently installed and owned by Loadout. */
|
|
221
|
+
export async function listInstalledRuntimeTools(stateHome = loadoutHome()) {
|
|
222
|
+
const state = await readState(stateHome);
|
|
223
|
+
return Object.keys(state.tools).sort();
|
|
224
|
+
}
|
|
220
225
|
async function writeState(state, stateHome) {
|
|
221
226
|
await writeFileAtomically(statePath(stateHome), `${JSON.stringify(state, null, 2)}\n`);
|
|
222
227
|
}
|
|
@@ -394,7 +399,9 @@ export async function applyRuntimeToolPlan(plan, options) {
|
|
|
394
399
|
if (!installed)
|
|
395
400
|
throw new Error(`${plan.recipe.displayName} is not managed by Loadout`);
|
|
396
401
|
const snapshot = await readSnapshot(installed.snapshotId);
|
|
397
|
-
await restoreSnapshot(snapshot
|
|
402
|
+
await restoreSnapshot(snapshot, {
|
|
403
|
+
requireUnchangedPostMutationState: true,
|
|
404
|
+
});
|
|
398
405
|
delete state.tools[plan.recipe.id];
|
|
399
406
|
await writeState(state, plan.stateHome);
|
|
400
407
|
return { action: "remove", snapshotId: snapshot.id };
|
|
@@ -439,6 +446,7 @@ export async function applyRuntimeToolPlan(plan, options) {
|
|
|
439
446
|
agents: plan.agents.map((agent) => agent.id),
|
|
440
447
|
runtimeRoot: plan.runtimeRoot,
|
|
441
448
|
};
|
|
449
|
+
await recordSnapshotPostMutationState(snapshot);
|
|
442
450
|
await writeState(state, plan.stateHome);
|
|
443
451
|
return { action: "install", snapshotId: snapshot.id };
|
|
444
452
|
}
|
|
@@ -4,7 +4,7 @@ import { dirname, win32, join } from "node:path";
|
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
import { writeFileAtomically } from "./atomic-file.js";
|
|
6
6
|
import { ensureDirectory, loadoutHome, userHome } from "./paths.js";
|
|
7
|
-
import { createSnapshot } from "./snapshot.js";
|
|
7
|
+
import { createSnapshot, recordSnapshotPostMutationState } from "./snapshot.js";
|
|
8
8
|
import { beginTransaction, completeTransaction, markTransactionCommitting, recoverPendingTransactions, rollbackTransaction, } from "./transaction.js";
|
|
9
9
|
const execFileAsync = promisify(execFile);
|
|
10
10
|
function parseTime(value) {
|
|
@@ -41,7 +41,7 @@ export function planNativeScheduler(action, options = {}) {
|
|
|
41
41
|
const job = options.job ?? "updates";
|
|
42
42
|
const nativeId = `loadout-daily-${job}`;
|
|
43
43
|
const command = job === "updates"
|
|
44
|
-
? [...launcher, "
|
|
44
|
+
? [...launcher, "update", "--json"]
|
|
45
45
|
: [...launcher, "discover", "--source", "all", "--queue", "--json"];
|
|
46
46
|
if (selectedPlatform === "darwin") {
|
|
47
47
|
const label = `com.loadout.daily.${job}`;
|
|
@@ -238,6 +238,7 @@ export async function applyNativeSchedulerBundle(plans, runner = defaultRunner)
|
|
|
238
238
|
if (plans[0].action === "unschedule")
|
|
239
239
|
for (const file of plans.flatMap((plan) => plan.files))
|
|
240
240
|
await rm(file.path, { force: true });
|
|
241
|
+
await recordSnapshotPostMutationState(snapshot);
|
|
241
242
|
await completeTransaction(transaction);
|
|
242
243
|
}
|
|
243
244
|
catch (error) {
|
|
@@ -259,6 +260,6 @@ export function formatNativeScheduler(plan) {
|
|
|
259
260
|
`Command: ${plan.command.join(" ")}`,
|
|
260
261
|
...plan.files.map((file) => `File: ${file.path}`),
|
|
261
262
|
...plan.applyCommands.map((item) => `Native action: ${item.command} ${item.args.join(" ")}`),
|
|
262
|
-
`Guarantee:
|
|
263
|
+
`Guarantee: no --yes flag is scheduled; this job can only report updates or queue candidates and cannot apply changes.`,
|
|
263
264
|
].join("\n");
|
|
264
265
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { readFile, writeFile, mkdir, readdir, rm, lstat, } from "node:fs/promises";
|
|
2
|
+
import { readFile, writeFile, mkdir, readdir, rename, rm, lstat, } from "node:fs/promises";
|
|
3
3
|
import { dirname, join, resolve, sep } from "node:path";
|
|
4
4
|
import { loadoutHome, ensureDirectory, userHome } from "./paths.js";
|
|
5
5
|
export async function createSnapshot(paths, options = {}) {
|
|
@@ -41,7 +41,7 @@ export async function createSnapshot(paths, options = {}) {
|
|
|
41
41
|
if (!info.isDirectory())
|
|
42
42
|
throw new Error(`Refusing unsupported snapshot target: ${path}`);
|
|
43
43
|
snapshot.files.push({ path, existed: true, directory: true });
|
|
44
|
-
const entries = await readdir(path, { withFileTypes: true });
|
|
44
|
+
const entries = (await readdir(path, { withFileTypes: true })).sort((left, right) => left.name.localeCompare(right.name));
|
|
45
45
|
for (const entry of entries) {
|
|
46
46
|
const child = join(path, entry.name);
|
|
47
47
|
if (entry.isSymbolicLink())
|
|
@@ -55,6 +55,8 @@ export async function createSnapshot(paths, options = {}) {
|
|
|
55
55
|
content: (await readFile(child)).toString("base64"),
|
|
56
56
|
encoding: "base64",
|
|
57
57
|
});
|
|
58
|
+
else
|
|
59
|
+
throw new Error(`Refusing unsupported snapshot target: ${child}`);
|
|
58
60
|
}
|
|
59
61
|
}
|
|
60
62
|
for (const path of snapshot.roots)
|
|
@@ -67,8 +69,10 @@ export async function createSnapshot(paths, options = {}) {
|
|
|
67
69
|
}
|
|
68
70
|
return snapshot;
|
|
69
71
|
}
|
|
70
|
-
export async function restoreSnapshot(snapshot) {
|
|
72
|
+
export async function restoreSnapshot(snapshot, options = {}) {
|
|
71
73
|
validateSnapshot(snapshot);
|
|
74
|
+
if (options.requireUnchangedPostMutationState)
|
|
75
|
+
await assertUnchangedPostMutationState(snapshot);
|
|
72
76
|
for (const root of snapshot.roots)
|
|
73
77
|
await rm(root, { recursive: true, force: true });
|
|
74
78
|
for (const directory of snapshot.files
|
|
@@ -84,6 +88,39 @@ export async function restoreSnapshot(snapshot) {
|
|
|
84
88
|
: (file.content ?? ""));
|
|
85
89
|
}
|
|
86
90
|
}
|
|
91
|
+
/** Attach the committed state used to make later user-requested rollback safe. */
|
|
92
|
+
export async function recordSnapshotPostMutationState(snapshot) {
|
|
93
|
+
const postMutation = await createSnapshot(snapshot.roots, { persist: false });
|
|
94
|
+
snapshot.postMutationFiles = postMutation.files;
|
|
95
|
+
validateSnapshot(snapshot);
|
|
96
|
+
const directory = join(loadoutHome(), "snapshots");
|
|
97
|
+
await ensureDirectory(directory);
|
|
98
|
+
const target = join(directory, `${snapshot.id}.json`);
|
|
99
|
+
const temporary = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
100
|
+
await writeFile(temporary, JSON.stringify(snapshot, null, 2), {
|
|
101
|
+
mode: 0o600,
|
|
102
|
+
flag: "wx",
|
|
103
|
+
});
|
|
104
|
+
await rename(temporary, target);
|
|
105
|
+
}
|
|
106
|
+
async function assertUnchangedPostMutationState(snapshot) {
|
|
107
|
+
if (!snapshot.postMutationFiles)
|
|
108
|
+
throw new Error("Explicit rollback refused: this legacy snapshot has no post-mutation evidence. Preserve current files and use a newer snapshot.");
|
|
109
|
+
let current;
|
|
110
|
+
try {
|
|
111
|
+
current = (await createSnapshot(snapshot.roots, { persist: false })).files;
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
throw new Error(`Explicit rollback refused because the current filesystem cannot be verified: ${error instanceof Error ? error.message : String(error)}`);
|
|
115
|
+
}
|
|
116
|
+
const expected = new Map(snapshot.postMutationFiles.map((file) => [file.path, file]));
|
|
117
|
+
const actual = new Map(current.map((file) => [file.path, file]));
|
|
118
|
+
const changed = [...new Set([...expected.keys(), ...actual.keys()])]
|
|
119
|
+
.filter((path) => JSON.stringify(expected.get(path)) !== JSON.stringify(actual.get(path)))
|
|
120
|
+
.sort();
|
|
121
|
+
if (changed.length)
|
|
122
|
+
throw new Error(`Explicit rollback refused because files changed after the snapshot: ${changed.slice(0, 10).join(", ")}. Preserve or review these changes before rollback.`);
|
|
123
|
+
}
|
|
87
124
|
export async function readSnapshot(id) {
|
|
88
125
|
if (!isSnapshotId(id))
|
|
89
126
|
throw new Error(`Invalid snapshot id: ${id}`);
|
|
@@ -167,42 +204,50 @@ export function validateSnapshot(value) {
|
|
|
167
204
|
if (roots.some((candidate, candidateIndex) => candidateIndex !== index && isInside(candidate, root)))
|
|
168
205
|
throw new Error("Snapshot roots must be unique and non-overlapping");
|
|
169
206
|
}
|
|
207
|
+
validateSnapshotFiles(value.files, roots, "Snapshot");
|
|
208
|
+
if (value.postMutationFiles !== undefined) {
|
|
209
|
+
if (!Array.isArray(value.postMutationFiles))
|
|
210
|
+
throw new Error("Snapshot post-mutation files are invalid");
|
|
211
|
+
validateSnapshotFiles(value.postMutationFiles, roots, "Snapshot post-mutation");
|
|
212
|
+
}
|
|
213
|
+
return value;
|
|
214
|
+
}
|
|
215
|
+
function validateSnapshotFiles(files, roots, label) {
|
|
170
216
|
const paths = new Set();
|
|
171
|
-
for (const [index, file] of
|
|
217
|
+
for (const [index, file] of files.entries()) {
|
|
172
218
|
if (!isRecord(file) ||
|
|
173
219
|
typeof file.path !== "string" ||
|
|
174
220
|
typeof file.existed !== "boolean" ||
|
|
175
221
|
(file.directory !== undefined && typeof file.directory !== "boolean") ||
|
|
176
222
|
(file.content !== undefined && typeof file.content !== "string") ||
|
|
177
223
|
(file.encoding !== undefined && file.encoding !== "base64"))
|
|
178
|
-
throw new Error(
|
|
224
|
+
throw new Error(`${label} file ${index} is invalid`);
|
|
179
225
|
const filePath = file.path;
|
|
180
226
|
if (resolve(filePath) !== filePath)
|
|
181
|
-
throw new Error(
|
|
227
|
+
throw new Error(`${label} file ${index} path must be absolute and normalized`);
|
|
182
228
|
if (paths.has(filePath))
|
|
183
|
-
throw new Error(
|
|
229
|
+
throw new Error(`${label} file ${index} duplicates another path`);
|
|
184
230
|
paths.add(filePath);
|
|
185
231
|
if (!roots.some((root) => isInside(root, filePath)))
|
|
186
|
-
throw new Error(
|
|
232
|
+
throw new Error(`${label} file ${index} escapes its declared roots`);
|
|
187
233
|
if (!file.existed) {
|
|
188
234
|
if (file.directory !== undefined ||
|
|
189
235
|
file.content !== undefined ||
|
|
190
236
|
file.encoding !== undefined)
|
|
191
|
-
throw new Error(`Missing
|
|
237
|
+
throw new Error(`Missing ${label.toLowerCase()} file ${index} must not contain data`);
|
|
192
238
|
}
|
|
193
239
|
else if (file.directory) {
|
|
194
240
|
if (file.content !== undefined || file.encoding !== undefined)
|
|
195
|
-
throw new Error(
|
|
241
|
+
throw new Error(`${label} directory ${index} must not contain bytes`);
|
|
196
242
|
}
|
|
197
243
|
else if (typeof file.content !== "string" ||
|
|
198
244
|
file.encoding !== "base64" ||
|
|
199
245
|
!isCanonicalBase64(file.content))
|
|
200
|
-
throw new Error(
|
|
246
|
+
throw new Error(`${label} file ${index} bytes are invalid`);
|
|
201
247
|
}
|
|
202
248
|
for (const [index, root] of roots.entries())
|
|
203
249
|
if (!paths.has(root))
|
|
204
|
-
throw new Error(
|
|
205
|
-
return value;
|
|
250
|
+
throw new Error(`${label} root ${index} has no matching file record`);
|
|
206
251
|
}
|
|
207
252
|
function isCanonicalBase64(value) {
|
|
208
253
|
if (value.length % 4 !== 0)
|
package/dist/src/core/state.js
CHANGED
|
@@ -123,7 +123,7 @@ export async function hashDirectory(root) {
|
|
|
123
123
|
await visit(root);
|
|
124
124
|
return files;
|
|
125
125
|
}
|
|
126
|
-
export async function recordInstall(plan, snapshotId, metadata = {}) {
|
|
126
|
+
export async function recordInstall(plan, snapshotId, metadata = {}, options = {}) {
|
|
127
127
|
const record = await createInstallRecord(plan, snapshotId, metadata);
|
|
128
128
|
const state = await readInstallState();
|
|
129
129
|
state.installs = [
|
|
@@ -131,6 +131,13 @@ export async function recordInstall(plan, snapshotId, metadata = {}) {
|
|
|
131
131
|
record,
|
|
132
132
|
];
|
|
133
133
|
state.activations = mergeActivationRecords(state.activations, activationRecordsForPlan(plan, metadata));
|
|
134
|
+
await options.verifyBeforeWrite?.();
|
|
135
|
+
if (options.expectedFiles) {
|
|
136
|
+
const actual = [...record.files].sort((left, right) => left.path.localeCompare(right.path));
|
|
137
|
+
const expected = [...options.expectedFiles].sort((left, right) => left.path.localeCompare(right.path));
|
|
138
|
+
if (JSON.stringify(actual) !== JSON.stringify(expected))
|
|
139
|
+
throw new Error("Installed files changed before ownership could be recorded.");
|
|
140
|
+
}
|
|
134
141
|
await writeInstallState(state);
|
|
135
142
|
return record;
|
|
136
143
|
}
|
|
@@ -298,12 +305,15 @@ export async function recordInstallTransaction(entries, mcpEntries, snapshotId)
|
|
|
298
305
|
export async function forgetInstall(packageId) {
|
|
299
306
|
const state = await readInstallState();
|
|
300
307
|
const installs = state.installs.filter((entry) => entry.packageId !== packageId);
|
|
301
|
-
|
|
308
|
+
const mcpInstalls = (state.mcpInstalls ?? []).filter((entry) => entry.packageId !== packageId);
|
|
309
|
+
if (installs.length === state.installs.length &&
|
|
310
|
+
mcpInstalls.length === (state.mcpInstalls ?? []).length)
|
|
302
311
|
throw new Error(`Package is not managed by Loadout: ${packageId}`);
|
|
303
312
|
await writeInstallState({
|
|
304
313
|
version: 1,
|
|
305
314
|
installs,
|
|
306
|
-
|
|
315
|
+
...(state.profile ? { profile: state.profile } : {}),
|
|
316
|
+
mcpInstalls,
|
|
307
317
|
activations: (state.activations ?? []).map((entry) => entry.packageId === packageId
|
|
308
318
|
? {
|
|
309
319
|
...entry,
|
|
@@ -2,7 +2,7 @@ import { lstat, mkdir, readdir, readFile, rename, rm, writeFile, } from "node:fs
|
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { ensureDirectory, loadoutHome } from "./paths.js";
|
|
5
|
-
import { createSnapshot, readSnapshot, restoreSnapshot } from "./snapshot.js";
|
|
5
|
+
import { createSnapshot, readSnapshot, recordSnapshotPostMutationState, restoreSnapshot, } from "./snapshot.js";
|
|
6
6
|
import { acquireFileLock, withFileLock, } from "./file-lock.js";
|
|
7
7
|
export const transactionRoot = () => join(loadoutHome(), "staging");
|
|
8
8
|
const transactionPreparingRoot = () => join(loadoutHome(), "staging-preparing");
|
|
@@ -136,6 +136,7 @@ export async function runMutationTransaction(prepare, mutate) {
|
|
|
136
136
|
});
|
|
137
137
|
await markTransactionCommitting(transaction);
|
|
138
138
|
const result = await mutate(prepared.value, snapshot);
|
|
139
|
+
await recordSnapshotPostMutationState(snapshot);
|
|
139
140
|
await completeTransaction(transaction);
|
|
140
141
|
return { snapshotId: snapshot.id, result };
|
|
141
142
|
}
|