loadout-ai 0.2.2 → 0.3.1
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 +37 -0
- package/MASTER_PLAN.md +68 -33
- package/README.md +33 -1
- package/dist/src/cli.js +168 -17
- package/dist/src/core/active-set.js +37 -2
- package/dist/src/core/cli-guide.js +101 -0
- package/dist/src/core/completion.js +3 -0
- package/dist/src/core/mcp-recipes.js +21 -0
- package/dist/src/core/profile-state.js +101 -0
- package/dist/src/core/remove.js +6 -7
- package/dist/src/core/runtime-tool-recipe.js +10 -1
- package/dist/src/core/runtime-tools.js +24 -1
- package/dist/src/core/scheduler.js +2 -2
- package/dist/src/core/snapshot.js +15 -2
- package/dist/src/core/state.js +5 -2
- package/dist/src/core/uninstall.js +109 -0
- package/dist/src/core/update.js +87 -58
- package/dist/src/shared/schemas.js +12 -0
- package/docs/TESTING.md +25 -2
- package/docs/USER_TEST_GUIDE.md +176 -0
- package/docs/plans/2026-07-18-release-0.3.md +42 -0
- package/docs/superpowers/plans/2026-07-18-cli-ux-polish.md +86 -0
- package/package.json +1 -1
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/** Plain-language entry points for people using the CLI, not maintaining it. */
|
|
2
|
+
export const BEGINNER_GUIDE = `
|
|
3
|
+
START HERE
|
|
4
|
+
|
|
5
|
+
1. See what Loadout currently manages
|
|
6
|
+
loadout library
|
|
7
|
+
|
|
8
|
+
2. Preview the recommended everyday setup
|
|
9
|
+
loadout setup --mode stable
|
|
10
|
+
|
|
11
|
+
3. Find additions that fit the project in this folder
|
|
12
|
+
loadout recommend --project .
|
|
13
|
+
loadout optimize --project .
|
|
14
|
+
|
|
15
|
+
4. Check for safer updates and new discoveries
|
|
16
|
+
loadout health
|
|
17
|
+
loadout alerts
|
|
18
|
+
loadout candidate list --limit 10
|
|
19
|
+
|
|
20
|
+
5. If you want a visual view, start the private local dashboard
|
|
21
|
+
loadout dashboard
|
|
22
|
+
|
|
23
|
+
If you decide to install something, Loadout shows a preview first and creates a
|
|
24
|
+
snapshot before changing managed files. Recover with: loadout rollback
|
|
25
|
+
|
|
26
|
+
Nothing above changes your agents. For the full maintainer/tooling surface, run:
|
|
27
|
+
loadout advanced
|
|
28
|
+
`.trim();
|
|
29
|
+
export const ADVANCED_GUIDE = [
|
|
30
|
+
"ADVANCED COMMANDS",
|
|
31
|
+
"",
|
|
32
|
+
"These remain available, but are hidden from the first screen so daily use stays simple.",
|
|
33
|
+
"",
|
|
34
|
+
"Discovery and evidence: candidate, discover, review-queue, intelligence, compatibility, benchmark.",
|
|
35
|
+
"Packages and sharing: init, add, sync, lock, export, import, audit, create, pack, publish, registry-serve.",
|
|
36
|
+
"Integrations and safety: mcp-recipe, mcp-config, codex-mcp-config, credentials, models, sandbox-run, canary.",
|
|
37
|
+
"Automation and release: watch, schedule, unschedule, catalog-sign, catalog-verify, catalog-update, claims.",
|
|
38
|
+
"",
|
|
39
|
+
"Use `loadout <command> --help` for exact options. Every mutation-capable command previews first or requires --yes.",
|
|
40
|
+
].join("\n");
|
|
41
|
+
/** Commands retained for specialist workflows but omitted from beginner help. */
|
|
42
|
+
export const HIDDEN_FROM_FIRST_SCREEN = new Set([
|
|
43
|
+
"init",
|
|
44
|
+
"lock",
|
|
45
|
+
"export",
|
|
46
|
+
"import",
|
|
47
|
+
"audit",
|
|
48
|
+
"create",
|
|
49
|
+
"pack",
|
|
50
|
+
"publish",
|
|
51
|
+
"registry-serve",
|
|
52
|
+
"search",
|
|
53
|
+
"report",
|
|
54
|
+
"outcomes",
|
|
55
|
+
"outcome",
|
|
56
|
+
"share",
|
|
57
|
+
"card",
|
|
58
|
+
"compare-loadouts",
|
|
59
|
+
"badge",
|
|
60
|
+
"claims",
|
|
61
|
+
"alert-ignore",
|
|
62
|
+
"alert-pin",
|
|
63
|
+
"alert-unpin",
|
|
64
|
+
"alert-pins",
|
|
65
|
+
"improve",
|
|
66
|
+
"improve-feedback",
|
|
67
|
+
"adopt",
|
|
68
|
+
"intelligence",
|
|
69
|
+
"compatibility",
|
|
70
|
+
"skill-audit",
|
|
71
|
+
"interop",
|
|
72
|
+
"benchmark",
|
|
73
|
+
"capabilities",
|
|
74
|
+
"candidate",
|
|
75
|
+
"catalog-update",
|
|
76
|
+
"discover",
|
|
77
|
+
"review-queue",
|
|
78
|
+
"review",
|
|
79
|
+
"credentials",
|
|
80
|
+
"models",
|
|
81
|
+
"keygen",
|
|
82
|
+
"catalog-sign",
|
|
83
|
+
"catalog-verify",
|
|
84
|
+
"completion",
|
|
85
|
+
"mcp-recipe",
|
|
86
|
+
"mcp",
|
|
87
|
+
"inspect",
|
|
88
|
+
"evaluate",
|
|
89
|
+
"head-to-head",
|
|
90
|
+
"watch",
|
|
91
|
+
"schedule",
|
|
92
|
+
"unschedule",
|
|
93
|
+
"sandbox-run",
|
|
94
|
+
"mcp-config",
|
|
95
|
+
"codex-mcp-config",
|
|
96
|
+
"plan",
|
|
97
|
+
"install",
|
|
98
|
+
"convert",
|
|
99
|
+
"canary",
|
|
100
|
+
"serve",
|
|
101
|
+
]);
|
|
@@ -16,6 +16,7 @@ export const REVIEWED_MCP_RECIPES = [
|
|
|
16
16
|
command: "npx",
|
|
17
17
|
args: ["-y", "@playwright/mcp@0.0.78"],
|
|
18
18
|
environment: [],
|
|
19
|
+
modelApiProviders: [],
|
|
19
20
|
fixedEnvironment: {},
|
|
20
21
|
permissions: [
|
|
21
22
|
"browser automation",
|
|
@@ -26,6 +27,25 @@ export const REVIEWED_MCP_RECIPES = [
|
|
|
26
27
|
reviewedAt: "2026-07-16T00:00:00Z",
|
|
27
28
|
artifact: "npm:@playwright/mcp@0.0.78#sha512-XLTUeA6mEN9sQ+hJ4dfG8EIkDbxS0K3Trc2RBkUJuf02TgE2FQRNTMtq/aJfhyRMINsRl/Ybc4sxcWLtFn4/TQ==",
|
|
28
29
|
},
|
|
30
|
+
{
|
|
31
|
+
id: "chrome-devtools",
|
|
32
|
+
displayName: "Chrome DevTools MCP",
|
|
33
|
+
source: "https://github.com/ChromeDevTools/chrome-devtools-mcp/tree/f621d0052fd241dca76e7f615b20e9d320ba965f",
|
|
34
|
+
serverName: "chrome-devtools",
|
|
35
|
+
command: "npx",
|
|
36
|
+
args: ["-y", "chrome-devtools-mcp@1.6.0"],
|
|
37
|
+
environment: [],
|
|
38
|
+
modelApiProviders: [],
|
|
39
|
+
fixedEnvironment: {},
|
|
40
|
+
permissions: [
|
|
41
|
+
"control a local Chrome browser",
|
|
42
|
+
"inspect pages, network activity, performance, and console output",
|
|
43
|
+
],
|
|
44
|
+
connection: "stdio",
|
|
45
|
+
reviewedCommit: "f621d0052fd241dca76e7f615b20e9d320ba965f",
|
|
46
|
+
reviewedAt: "2026-07-18T00:00:00Z",
|
|
47
|
+
artifact: "npm:chrome-devtools-mcp@1.6.0#sha512-VZX6f/OjQSYhy2BGGRs+y3LsrsAQAz/HwZCWKBLVyST/4r/3zjVEjjVW7gMCVbRDuspnVdcp5hQDPrQ5UFrdZw==",
|
|
48
|
+
},
|
|
29
49
|
{
|
|
30
50
|
id: "github-readonly",
|
|
31
51
|
displayName: "GitHub MCP Server (read-only)",
|
|
@@ -43,6 +63,7 @@ export const REVIEWED_MCP_RECIPES = [
|
|
|
43
63
|
"ghcr.io/github/github-mcp-server@sha256:7b1384cdd6d025c09256af2fb6cb79bc5e87aedc957c8826b5e50d8cb82f0be3",
|
|
44
64
|
],
|
|
45
65
|
environment: ["GITHUB_PERSONAL_ACCESS_TOKEN"],
|
|
66
|
+
modelApiProviders: [],
|
|
46
67
|
fixedEnvironment: { GITHUB_READ_ONLY: "1" },
|
|
47
68
|
permissions: ["read GitHub repositories, issues, pull requests, and users"],
|
|
48
69
|
connection: "stdio",
|
|
@@ -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
|
+
}
|
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))
|
|
@@ -118,7 +118,14 @@ const generatedFilesSchema = z
|
|
|
118
118
|
.object({
|
|
119
119
|
fileExtension: z.string().regex(/^\.[A-Za-z0-9]+$/),
|
|
120
120
|
from: nonControlTextSchema,
|
|
121
|
-
to: nonControlTextSchema.refine((value) =>
|
|
121
|
+
to: nonControlTextSchema.refine((value) => {
|
|
122
|
+
const rendered = [
|
|
123
|
+
"{artifactRequirement}",
|
|
124
|
+
"{artifactUrl}",
|
|
125
|
+
"{artifactSha256}",
|
|
126
|
+
].reduce((current, placeholder) => current.replaceAll(placeholder, ""), value);
|
|
127
|
+
return !/[{}]/u.test(rendered);
|
|
128
|
+
}, "unknown rewrite template variable"),
|
|
122
129
|
mustEliminate: z.boolean(),
|
|
123
130
|
})
|
|
124
131
|
.strict()),
|
|
@@ -306,6 +313,8 @@ export function runtimeArtifactRequirement(recipe) {
|
|
|
306
313
|
export function renderRuntimeRecipeValue(value, recipe) {
|
|
307
314
|
return value
|
|
308
315
|
.replaceAll("{artifactRequirement}", runtimeArtifactRequirement(recipe))
|
|
316
|
+
.replaceAll("{artifactUrl}", recipe.artifactUrl)
|
|
317
|
+
.replaceAll("{artifactSha256}", recipe.artifactSha256)
|
|
309
318
|
.replaceAll("{version}", recipe.version);
|
|
310
319
|
}
|
|
311
320
|
/** Resolve reviewed path segments without ever accepting a recipe-owned root. */
|
|
@@ -162,6 +162,24 @@ export const GRAPHIFY_RECIPE = deepFreeze(parseRuntimeToolRecipe({
|
|
|
162
162
|
to: "--from '{artifactRequirement}'",
|
|
163
163
|
mustEliminate: true,
|
|
164
164
|
},
|
|
165
|
+
{
|
|
166
|
+
fileExtension: ".md",
|
|
167
|
+
from: "install --upgrade graphifyy",
|
|
168
|
+
to: "install '{artifactRequirement}' --exclude-newer 2026-07-17T00:00:00Z",
|
|
169
|
+
mustEliminate: true,
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
fileExtension: ".md",
|
|
173
|
+
from: "pip install graphifyy",
|
|
174
|
+
to: "pip install '{artifactRequirement}'",
|
|
175
|
+
mustEliminate: true,
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
fileExtension: ".md",
|
|
179
|
+
from: "pip install 'graphifyy[gemini]'",
|
|
180
|
+
to: "pip install 'graphifyy[gemini] @ {artifactUrl}#sha256={artifactSha256}'",
|
|
181
|
+
mustEliminate: true,
|
|
182
|
+
},
|
|
165
183
|
],
|
|
166
184
|
},
|
|
167
185
|
guarantees: [
|
|
@@ -169,7 +187,7 @@ export const GRAPHIFY_RECIPE = deepFreeze(parseRuntimeToolRecipe({
|
|
|
169
187
|
"exact top-level wheel URL and SHA-256; dependency uploads bounded by the reviewed cutoff",
|
|
170
188
|
"no API keys or provider credentials inherited by installer subprocesses",
|
|
171
189
|
"agent skill targets and isolated runtime are snapshotted for rollback/removal",
|
|
172
|
-
"generated
|
|
190
|
+
"generated top-level Graphify lookups and repair commands are rewritten to the same pinned artifact",
|
|
173
191
|
],
|
|
174
192
|
}));
|
|
175
193
|
export const REVIEWED_RUNTIME_TOOLS = [GRAPHIFY_RECIPE];
|
|
@@ -199,6 +217,11 @@ async function readState(stateHome) {
|
|
|
199
217
|
throw error;
|
|
200
218
|
}
|
|
201
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
|
+
}
|
|
202
225
|
async function writeState(state, stateHome) {
|
|
203
226
|
await writeFileAtomically(statePath(stateHome), `${JSON.stringify(state, null, 2)}\n`);
|
|
204
227
|
}
|
|
@@ -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}`;
|
|
@@ -259,6 +259,6 @@ export function formatNativeScheduler(plan) {
|
|
|
259
259
|
`Command: ${plan.command.join(" ")}`,
|
|
260
260
|
...plan.files.map((file) => `File: ${file.path}`),
|
|
261
261
|
...plan.applyCommands.map((item) => `Native action: ${item.command} ${item.args.join(" ")}`),
|
|
262
|
-
`Guarantee:
|
|
262
|
+
`Guarantee: no --yes flag is scheduled; this job can only report updates or queue candidates and cannot apply changes.`,
|
|
263
263
|
].join("\n");
|
|
264
264
|
}
|
|
@@ -205,6 +205,19 @@ export function validateSnapshot(value) {
|
|
|
205
205
|
return value;
|
|
206
206
|
}
|
|
207
207
|
function isCanonicalBase64(value) {
|
|
208
|
-
|
|
209
|
-
|
|
208
|
+
if (value.length % 4 !== 0)
|
|
209
|
+
return false;
|
|
210
|
+
const padding = value.endsWith("==") ? 2 : value.endsWith("=") ? 1 : 0;
|
|
211
|
+
const contentLength = value.length - padding;
|
|
212
|
+
for (let index = 0; index < contentLength; index += 1) {
|
|
213
|
+
const code = value.charCodeAt(index);
|
|
214
|
+
const valid = (code >= 65 && code <= 90) ||
|
|
215
|
+
(code >= 97 && code <= 122) ||
|
|
216
|
+
(code >= 48 && code <= 57) ||
|
|
217
|
+
code === 43 ||
|
|
218
|
+
code === 47;
|
|
219
|
+
if (!valid)
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
return value.slice(contentLength) === "=".repeat(padding);
|
|
210
223
|
}
|
package/dist/src/core/state.js
CHANGED
|
@@ -298,12 +298,15 @@ export async function recordInstallTransaction(entries, mcpEntries, snapshotId)
|
|
|
298
298
|
export async function forgetInstall(packageId) {
|
|
299
299
|
const state = await readInstallState();
|
|
300
300
|
const installs = state.installs.filter((entry) => entry.packageId !== packageId);
|
|
301
|
-
|
|
301
|
+
const mcpInstalls = (state.mcpInstalls ?? []).filter((entry) => entry.packageId !== packageId);
|
|
302
|
+
if (installs.length === state.installs.length &&
|
|
303
|
+
mcpInstalls.length === (state.mcpInstalls ?? []).length)
|
|
302
304
|
throw new Error(`Package is not managed by Loadout: ${packageId}`);
|
|
303
305
|
await writeInstallState({
|
|
304
306
|
version: 1,
|
|
305
307
|
installs,
|
|
306
|
-
|
|
308
|
+
...(state.profile ? { profile: state.profile } : {}),
|
|
309
|
+
mcpInstalls,
|
|
307
310
|
activations: (state.activations ?? []).map((entry) => entry.packageId === packageId
|
|
308
311
|
? {
|
|
309
312
|
...entry,
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { readdir, rm, rmdir } from "node:fs/promises";
|
|
3
|
+
import { dirname, parse, resolve } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { loadoutHome } from "./paths.js";
|
|
6
|
+
import { applyRemove, planRemove } from "./remove.js";
|
|
7
|
+
import { applyRuntimeToolPlan, listInstalledRuntimeTools, planRuntimeTool, } from "./runtime-tools.js";
|
|
8
|
+
import { applyNativeSchedulerBundle, planNativeScheduler, } from "./scheduler.js";
|
|
9
|
+
import { readInstallState } from "./state.js";
|
|
10
|
+
export async function buildUninstallPlan(dependencies = {}) {
|
|
11
|
+
const stateHome = loadoutHome();
|
|
12
|
+
const state = await readInstallState();
|
|
13
|
+
const packageIds = [
|
|
14
|
+
...new Set([
|
|
15
|
+
...state.installs.map((install) => install.packageId),
|
|
16
|
+
...(state.mcpInstalls ?? []).map((install) => install.packageId),
|
|
17
|
+
]),
|
|
18
|
+
];
|
|
19
|
+
const packages = await Promise.all(packageIds.map(planRemove));
|
|
20
|
+
const runtimeTools = await (dependencies.runtimeTools ?? listInstalledRuntimeTools)(stateHome);
|
|
21
|
+
const schedulers = dependencies.schedulerPlans?.() ??
|
|
22
|
+
["updates", "discovery"].map((job) => planNativeScheduler("unschedule", { job }));
|
|
23
|
+
const warnings = packages.flatMap((entry) => entry.warnings);
|
|
24
|
+
if (runtimeTools.length)
|
|
25
|
+
warnings.push(`${runtimeTools.length} Loadout-managed runtime tool(s) will be restored to their pre-install snapshots.`);
|
|
26
|
+
warnings.push("Loadout's cache, disabled library, history, and rollback snapshots will be deleted. This final state cleanup cannot itself be rolled back.");
|
|
27
|
+
return {
|
|
28
|
+
stateHome,
|
|
29
|
+
packages,
|
|
30
|
+
runtimeTools,
|
|
31
|
+
schedulers,
|
|
32
|
+
disabledLibraryRecords: (state.activations ?? []).filter((entry) => entry.activationState === "disabled").length,
|
|
33
|
+
blocked: packages.some((entry) => entry.blocked),
|
|
34
|
+
warnings,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
async function removeEmptyManagedDirectories(plans) {
|
|
38
|
+
const candidates = [
|
|
39
|
+
...new Set(plans.flatMap((plan) => plan.files.map((file) => dirname(file.path)))),
|
|
40
|
+
].sort((left, right) => right.length - left.length);
|
|
41
|
+
for (const directory of candidates) {
|
|
42
|
+
try {
|
|
43
|
+
if ((await readdir(directory)).length === 0)
|
|
44
|
+
await rmdir(directory);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Missing and non-empty directories are both safe to leave alone.
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function assertSafeStateHome(stateHome) {
|
|
52
|
+
const expected = resolve(loadoutHome());
|
|
53
|
+
const selected = resolve(stateHome);
|
|
54
|
+
const root = parse(selected).root;
|
|
55
|
+
if (selected !== expected || selected === root)
|
|
56
|
+
throw new Error(`Refusing unsafe Loadout state deletion target: ${selected}`);
|
|
57
|
+
}
|
|
58
|
+
export async function applyUninstall(plan, dependencies = {}, options = {}) {
|
|
59
|
+
assertSafeStateHome(plan.stateHome);
|
|
60
|
+
const fresh = await buildUninstallPlan(dependencies);
|
|
61
|
+
if (fresh.blocked && !options.force)
|
|
62
|
+
throw new Error(`Complete uninstall is blocked because managed files were modified. Review them, or re-run with --force. ${fresh.warnings.join(" ")}`);
|
|
63
|
+
for (const id of fresh.runtimeTools) {
|
|
64
|
+
const runtimePlan = await planRuntimeTool(id, {
|
|
65
|
+
action: "remove",
|
|
66
|
+
stateHome: fresh.stateHome,
|
|
67
|
+
});
|
|
68
|
+
await applyRuntimeToolPlan(runtimePlan, { approveRisk: true });
|
|
69
|
+
}
|
|
70
|
+
for (const packagePlan of fresh.packages)
|
|
71
|
+
await applyRemove(packagePlan, { force: options.force });
|
|
72
|
+
await removeEmptyManagedDirectories(fresh.packages);
|
|
73
|
+
if (fresh.schedulers.length)
|
|
74
|
+
await (dependencies.unschedule
|
|
75
|
+
? dependencies.unschedule(fresh.schedulers)
|
|
76
|
+
: applyNativeSchedulerBundle(fresh.schedulers));
|
|
77
|
+
await rm(fresh.stateHome, { recursive: true, force: true });
|
|
78
|
+
return {
|
|
79
|
+
removedPackages: fresh.packages.length,
|
|
80
|
+
removedRuntimeTools: fresh.runtimeTools.length,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const execFileAsync = promisify(execFile);
|
|
84
|
+
/** Remove the globally installed npm launcher after managed data is gone. */
|
|
85
|
+
export async function uninstallGlobalCli() {
|
|
86
|
+
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
87
|
+
await execFileAsync(npm, ["uninstall", "--global", "loadout-ai"], {
|
|
88
|
+
windowsHide: true,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
export function formatUninstallPlan(plan) {
|
|
92
|
+
return [
|
|
93
|
+
"Complete Loadout uninstall preview",
|
|
94
|
+
"",
|
|
95
|
+
`Managed packages: ${plan.packages.length}`,
|
|
96
|
+
`Managed runtime tools: ${plan.runtimeTools.length ? plan.runtimeTools.join(", ") : "none"}`,
|
|
97
|
+
`Disabled library records: ${plan.disabledLibraryRecords}`,
|
|
98
|
+
`Daily jobs to remove: ${plan.schedulers.map((item) => item.job).join(", ") || "none"}`,
|
|
99
|
+
`State and cache: ${plan.stateHome}`,
|
|
100
|
+
...(plan.blocked
|
|
101
|
+
? ["", "BLOCKED: managed files were changed outside Loadout."]
|
|
102
|
+
: []),
|
|
103
|
+
"",
|
|
104
|
+
...plan.warnings.map((warning) => `Warning: ${warning}`),
|
|
105
|
+
"",
|
|
106
|
+
"Dry run only. Re-run with `loadout uninstall --yes` to remove Loadout-managed data.",
|
|
107
|
+
"Add `--remove-cli` to also uninstall the global npm command.",
|
|
108
|
+
].join("\n");
|
|
109
|
+
}
|