loadout-ai 0.2.3 → 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 +29 -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-tools.js +5 -0
- package/dist/src/core/scheduler.js +2 -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))
|
|
@@ -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
|
}
|
|
@@ -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
|
}
|
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
|
+
}
|
package/dist/src/core/update.js
CHANGED
|
@@ -85,66 +85,89 @@ async function analyzeManagedUpdate(oldRoot, newRoot, unitIds) {
|
|
|
85
85
|
};
|
|
86
86
|
}
|
|
87
87
|
/** Builds a read-only update plan from persisted installs and live GitHub snapshots. */
|
|
88
|
-
export async function buildUpdatePlan(resolver = async (repository) => fetchRepositorySnapshot(repository)) {
|
|
88
|
+
export async function buildUpdatePlan(resolver = async (repository) => fetchRepositorySnapshot(repository, { timeoutMs: 30_000 }), options = {}) {
|
|
89
89
|
const state = await readInstallState();
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
90
|
+
const records = options.packageId
|
|
91
|
+
? state.installs.filter((record) => record.packageId === options.packageId)
|
|
92
|
+
: state.installs;
|
|
93
|
+
const results = new Array(records.length);
|
|
94
|
+
let cursor = 0;
|
|
95
|
+
let completed = 0;
|
|
96
|
+
const workers = Array.from({
|
|
97
|
+
length: Math.min(Math.max(1, options.concurrency ?? 4), Math.max(1, records.length)),
|
|
98
|
+
}, async () => {
|
|
99
|
+
while (cursor < records.length) {
|
|
100
|
+
const index = cursor++;
|
|
101
|
+
const record = records[index];
|
|
102
|
+
results[index] = await (async () => {
|
|
103
|
+
const disabledAgents = (state.activations ?? [])
|
|
104
|
+
.filter((activation) => activation.packageId === record.packageId &&
|
|
105
|
+
activation.installationState === "installed" &&
|
|
106
|
+
activation.activationState === "disabled")
|
|
107
|
+
.map((activation) => activation.agent);
|
|
108
|
+
const base = {
|
|
109
|
+
packageId: record.packageId,
|
|
110
|
+
repository: record.repository,
|
|
111
|
+
installedCommit: record.resolvedCommit,
|
|
112
|
+
targetAgents: record.targetAgents,
|
|
113
|
+
...(disabledAgents.length ? { disabledAgents } : {}),
|
|
114
|
+
};
|
|
115
|
+
if (!record.repository || !record.resolvedCommit) {
|
|
116
|
+
return {
|
|
117
|
+
...base,
|
|
118
|
+
status: "untracked",
|
|
119
|
+
action: "Reinstall from the original source to begin update tracking.",
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const current = await resolver(record.repository);
|
|
124
|
+
const same = current.commit.toLowerCase() ===
|
|
125
|
+
record.resolvedCommit.toLowerCase();
|
|
126
|
+
let diff;
|
|
127
|
+
let safetyFindings;
|
|
128
|
+
let approvalRequired = false;
|
|
129
|
+
if (!same && current.path) {
|
|
130
|
+
const oldPath = repositoryCachePath(record.repository, record.resolvedCommit);
|
|
131
|
+
const analysis = await analyzeManagedUpdate(oldPath, current.path, managedUnitIds(state, record.packageId));
|
|
132
|
+
diff = analysis.diff;
|
|
133
|
+
safetyFindings = analysis.safetyFindings;
|
|
134
|
+
approvalRequired = analysis.approvalRequired;
|
|
135
|
+
}
|
|
136
|
+
return {
|
|
137
|
+
...base,
|
|
138
|
+
availableCommit: current.commit,
|
|
139
|
+
status: same ? "up-to-date" : "update-available",
|
|
140
|
+
action: same
|
|
141
|
+
? "No action required."
|
|
142
|
+
: disabledAgents.length
|
|
143
|
+
? `Enable ${record.packageId} for ${disabledAgents.join(", ")} before applying an update; planning remains read-only.`
|
|
144
|
+
: approvalRequired
|
|
145
|
+
? `Approval required: review safety warnings before updating ${record.packageId}.`
|
|
146
|
+
: `Run loadout update --package ${record.packageId} --yes after review.`,
|
|
147
|
+
...(approvalRequired ? { approvalRequired: true } : {}),
|
|
148
|
+
...(safetyFindings?.length ? { safetyFindings } : {}),
|
|
149
|
+
...(diff ? { diff } : {}),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
return {
|
|
154
|
+
...base,
|
|
155
|
+
status: "error",
|
|
156
|
+
action: "Retry when GitHub is reachable; the installed version was not changed.",
|
|
157
|
+
error: error instanceof Error ? error.message : String(error),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
})();
|
|
161
|
+
completed += 1;
|
|
162
|
+
options.onProgress?.({
|
|
163
|
+
completed,
|
|
164
|
+
total: records.length,
|
|
165
|
+
packageId: record.packageId,
|
|
166
|
+
});
|
|
146
167
|
}
|
|
147
|
-
})
|
|
168
|
+
});
|
|
169
|
+
await Promise.all(workers);
|
|
170
|
+
return results;
|
|
148
171
|
}
|
|
149
172
|
export function formatUpdatePlan(plans) {
|
|
150
173
|
if (plans.length === 0)
|
|
@@ -160,6 +183,12 @@ export function formatUpdatePlan(plans) {
|
|
|
160
183
|
})
|
|
161
184
|
.join("\n");
|
|
162
185
|
}
|
|
186
|
+
/** Updates safe enough for an explicit whole-profile `update --yes` apply. */
|
|
187
|
+
export function selectSafeAutomaticUpdates(plans) {
|
|
188
|
+
return plans.filter((plan) => plan.status === "update-available" &&
|
|
189
|
+
!plan.approvalRequired &&
|
|
190
|
+
!plan.disabledAgents?.length);
|
|
191
|
+
}
|
|
163
192
|
function quarantineRoot() {
|
|
164
193
|
return join(loadoutHome(), "quarantine");
|
|
165
194
|
}
|
|
@@ -304,6 +304,18 @@ export const installStateSchema = z
|
|
|
304
304
|
installs: z.array(installRecordSchema),
|
|
305
305
|
mcpInstalls: z.array(mcpInstallRecordSchema).default([]),
|
|
306
306
|
activations: z.array(managedActivationRecordSchema).default([]),
|
|
307
|
+
profile: z
|
|
308
|
+
.object({
|
|
309
|
+
mode: z.enum(["stable", "power", "maximum", "custom"]),
|
|
310
|
+
packageIds: z.array(text).optional(),
|
|
311
|
+
agents: z.array(agentIdSchema),
|
|
312
|
+
catalogPackages: z.array(z.object({
|
|
313
|
+
packageId: text,
|
|
314
|
+
reviewedCommit: optionalText,
|
|
315
|
+
})),
|
|
316
|
+
appliedAt: text,
|
|
317
|
+
})
|
|
318
|
+
.optional(),
|
|
307
319
|
})
|
|
308
320
|
.passthrough();
|
|
309
321
|
const lockedPackageSchema = z
|