loadout-ai 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/LICENSE +21 -0
  2. package/MASTER_PLAN.md +1711 -0
  3. package/README.md +453 -0
  4. package/SECURITY.md +18 -0
  5. package/SIMPLE_PLAN.md +44 -0
  6. package/catalog/discovered.json +10469 -0
  7. package/catalog/packages.json +1150 -0
  8. package/dashboard/app.js +607 -0
  9. package/dashboard/index.html +249 -0
  10. package/dashboard/styles.css +384 -0
  11. package/dist/src/cli.js +2735 -0
  12. package/dist/src/core/active-policy.js +233 -0
  13. package/dist/src/core/active-set.js +382 -0
  14. package/dist/src/core/adapters.js +254 -0
  15. package/dist/src/core/adopt.js +92 -0
  16. package/dist/src/core/agent-health-score.js +475 -0
  17. package/dist/src/core/agent-inspection.js +152 -0
  18. package/dist/src/core/agent-versions.js +131 -0
  19. package/dist/src/core/api.js +90 -0
  20. package/dist/src/core/atomic-file.js +22 -0
  21. package/dist/src/core/audit.js +174 -0
  22. package/dist/src/core/benchmark-campaign.js +496 -0
  23. package/dist/src/core/benchmark-evidence.js +458 -0
  24. package/dist/src/core/benchmark-fixtures.js +1157 -0
  25. package/dist/src/core/benchmark-runner.js +301 -0
  26. package/dist/src/core/benchmark-trust.js +795 -0
  27. package/dist/src/core/canary.js +77 -0
  28. package/dist/src/core/candidate-intelligence.js +661 -0
  29. package/dist/src/core/catalog-coverage.js +85 -0
  30. package/dist/src/core/catalog-install.js +186 -0
  31. package/dist/src/core/catalog-release.js +286 -0
  32. package/dist/src/core/catalog.js +327 -0
  33. package/dist/src/core/codex-mcp.js +122 -0
  34. package/dist/src/core/community.js +86 -0
  35. package/dist/src/core/compatibility-intelligence.js +465 -0
  36. package/dist/src/core/completion.js +160 -0
  37. package/dist/src/core/components.js +138 -0
  38. package/dist/src/core/conversion.js +56 -0
  39. package/dist/src/core/credentials.js +377 -0
  40. package/dist/src/core/demo.js +136 -0
  41. package/dist/src/core/diff.js +83 -0
  42. package/dist/src/core/discovery-connector.js +165 -0
  43. package/dist/src/core/doctor.js +95 -0
  44. package/dist/src/core/ecosystem-import.js +1072 -0
  45. package/dist/src/core/evaluate.js +72 -0
  46. package/dist/src/core/file-lock.js +140 -0
  47. package/dist/src/core/freshness-alerts.js +230 -0
  48. package/dist/src/core/github-discovery.js +97 -0
  49. package/dist/src/core/github.js +148 -0
  50. package/dist/src/core/head-to-head.js +437 -0
  51. package/dist/src/core/health-score-evidence.js +169 -0
  52. package/dist/src/core/health.js +129 -0
  53. package/dist/src/core/improve.js +223 -0
  54. package/dist/src/core/install.js +233 -0
  55. package/dist/src/core/intelligence-feed-build.js +59 -0
  56. package/dist/src/core/intelligence-feed.js +353 -0
  57. package/dist/src/core/loadout-badge.js +55 -0
  58. package/dist/src/core/loadout-card.js +87 -0
  59. package/dist/src/core/manifest.js +307 -0
  60. package/dist/src/core/mcp-recipes.js +419 -0
  61. package/dist/src/core/mcp-registry-discovery.js +455 -0
  62. package/dist/src/core/mcp.js +287 -0
  63. package/dist/src/core/model-config.js +102 -0
  64. package/dist/src/core/observations.js +87 -0
  65. package/dist/src/core/outcomes.js +164 -0
  66. package/dist/src/core/package.js +225 -0
  67. package/dist/src/core/paths.js +214 -0
  68. package/dist/src/core/portable.js +130 -0
  69. package/dist/src/core/private-discovery.js +45 -0
  70. package/dist/src/core/profiles.js +255 -0
  71. package/dist/src/core/provenance.js +278 -0
  72. package/dist/src/core/ranking.js +104 -0
  73. package/dist/src/core/recommend.js +181 -0
  74. package/dist/src/core/registry-api.js +97 -0
  75. package/dist/src/core/registry.js +316 -0
  76. package/dist/src/core/release-claims.js +175 -0
  77. package/dist/src/core/remove.js +122 -0
  78. package/dist/src/core/review-queue.js +200 -0
  79. package/dist/src/core/runtime-tool-recipe.js +315 -0
  80. package/dist/src/core/runtime-tools.js +453 -0
  81. package/dist/src/core/safety.js +234 -0
  82. package/dist/src/core/sandbox.js +78 -0
  83. package/dist/src/core/scheduler.js +264 -0
  84. package/dist/src/core/share-report.js +85 -0
  85. package/dist/src/core/signing.js +115 -0
  86. package/dist/src/core/skill-compare.js +239 -0
  87. package/dist/src/core/skill-inventory.js +193 -0
  88. package/dist/src/core/skill-security.js +340 -0
  89. package/dist/src/core/skills-sh-discovery.js +336 -0
  90. package/dist/src/core/skills.js +208 -0
  91. package/dist/src/core/snapshot.js +210 -0
  92. package/dist/src/core/source.js +305 -0
  93. package/dist/src/core/state.js +300 -0
  94. package/dist/src/core/sync.js +348 -0
  95. package/dist/src/core/terminal.js +53 -0
  96. package/dist/src/core/transaction.js +211 -0
  97. package/dist/src/core/update-watch.js +39 -0
  98. package/dist/src/core/update.js +216 -0
  99. package/dist/src/core/upgrade.js +207 -0
  100. package/dist/src/dashboard.js +418 -0
  101. package/dist/src/shared/schemas.js +336 -0
  102. package/dist/src/shared/types.js +1 -0
  103. package/docs/ACTIVE_SET.md +53 -0
  104. package/docs/ACTIVE_SET_POLICY.md +45 -0
  105. package/docs/CANDIDATE_INTELLIGENCE.md +139 -0
  106. package/docs/CATALOG.md +78 -0
  107. package/docs/CATALOG_POLICY.md +41 -0
  108. package/docs/COMMUNITY_DISCOVERY.md +14 -0
  109. package/docs/COMPATIBILITY_POLICY.md +22 -0
  110. package/docs/CONVERSION_AND_SANDBOX.md +27 -0
  111. package/docs/CREDENTIAL_AND_UPDATE_POLICY.md +31 -0
  112. package/docs/DISCOVERED.md +280 -0
  113. package/docs/EVALUATION_PROTOCOL.md +40 -0
  114. package/docs/EVALUATION_PROTOCOL_V1.md +300 -0
  115. package/docs/FEATURE_TEST_MATRIX.md +802 -0
  116. package/docs/GITHUB_AUTHORIZATION.md +38 -0
  117. package/docs/HEAD_TO_HEAD_EVALUATION.md +79 -0
  118. package/docs/PROVENANCE_AND_COMPARISON.md +72 -0
  119. package/docs/PROVIDER_CONFIGURATION.md +45 -0
  120. package/docs/RELEASE_REVIEW.md +117 -0
  121. package/docs/SAFE_UPDATE_DEMO.md +25 -0
  122. package/docs/SCHEMA_DECISIONS.md +25 -0
  123. package/docs/TEAM_POLICY.md +18 -0
  124. package/docs/TESTING.md +190 -0
  125. package/package.json +82 -0
@@ -0,0 +1,348 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFile } from "node:fs/promises";
3
+ import { dirname, resolve, sep } from "node:path";
4
+ import { loadEffectiveCatalog } from "./catalog.js";
5
+ import { installedAgents } from "./install.js";
6
+ import { orderManifestPackages, readManifest, writeLockfile, } from "./manifest.js";
7
+ import { detectAgents, userHome } from "./paths.js";
8
+ import { fetchGitSnapshot, fetchRepositorySnapshot } from "./source.js";
9
+ import { addRootFileExports, buildUniversalPackagePlan } from "./components.js";
10
+ import { analyzeInstallPlanSafety, } from "./safety.js";
11
+ import { fetchRemoteRegistryPackage, packPackage, resolveRegistryPackage, } from "./registry.js";
12
+ import { discoverMcpManifests, planMcpConfigBatch, writeMcpConfigPlan, } from "./mcp.js";
13
+ import { applySkillPlan, detectInstallConflicts } from "./skills.js";
14
+ import { installStatePath, readInstallState, recordInstallTransaction, } from "./state.js";
15
+ import { runMutationTransaction } from "./transaction.js";
16
+ function registrySourceKey(source) {
17
+ if (source.type === "registry")
18
+ return `local:${source.name}@${source.version}`;
19
+ if (source.type === "remote-registry")
20
+ return `${source.registry}:${source.name}@${source.version}`;
21
+ return undefined;
22
+ }
23
+ function dependencySource(parent, name, version) {
24
+ if (parent.type === "registry")
25
+ return { type: "registry", name, version };
26
+ if (parent.type === "remote-registry")
27
+ return {
28
+ type: "remote-registry",
29
+ registry: parent.registry,
30
+ name,
31
+ version,
32
+ };
33
+ throw new Error("Only registry packages can resolve package-owned dependencies");
34
+ }
35
+ async function expandRegistryDependencies(manifest) {
36
+ const explicit = new Map(manifest.packages.map((pkg) => [pkg.id, pkg]));
37
+ const expanded = new Map();
38
+ const visiting = new Set();
39
+ const packageCache = new Map();
40
+ const requestedVersions = new Map();
41
+ async function visit(input, chain) {
42
+ const key = registrySourceKey(input.source);
43
+ if (!key) {
44
+ if (!expanded.has(input.id))
45
+ expanded.set(input.id, input);
46
+ return;
47
+ }
48
+ if (visiting.has(key))
49
+ throw new Error(`Registry dependency cycle: ${[...chain, input.id].join(" -> ")}`);
50
+ const existing = expanded.get(input.id);
51
+ if (existing) {
52
+ if (JSON.stringify(existing.source) !== JSON.stringify(input.source))
53
+ throw new Error(`Registry dependency id '${input.id}' resolves to conflicting sources`);
54
+ const agents = existing.agents === undefined || input.agents === undefined
55
+ ? undefined
56
+ : [...new Set([...existing.agents, ...input.agents])];
57
+ expanded.set(input.id, {
58
+ ...existing,
59
+ ...(agents ? { agents } : { agents: undefined }),
60
+ });
61
+ return;
62
+ }
63
+ visiting.add(key);
64
+ let packed = packageCache.get(key);
65
+ if (!packed) {
66
+ const source = await resolvePackage(input);
67
+ packed = await packPackage(source.path);
68
+ packageCache.set(key, packed);
69
+ }
70
+ const dependencies = Object.entries({
71
+ ...(packed.descriptor.dependencies ?? {}),
72
+ ...(input.includeDevDependencies
73
+ ? (packed.descriptor.devDependencies ?? {})
74
+ : {}),
75
+ }).sort(([left], [right]) => left.localeCompare(right));
76
+ const dependencyIds = [];
77
+ for (const [name, version] of dependencies) {
78
+ const previous = requestedVersions.get(name);
79
+ if (previous && previous !== version)
80
+ throw new Error(`Registry dependency version conflict for '${name}': ${previous} versus ${version}`);
81
+ requestedVersions.set(name, version);
82
+ const source = dependencySource(input.source, name, version);
83
+ const configured = explicit.get(name);
84
+ if (configured &&
85
+ JSON.stringify(configured.source) !== JSON.stringify(source))
86
+ throw new Error(`Registry dependency '${name}@${version}' conflicts with explicitly configured package '${name}'`);
87
+ await visit(configured ?? {
88
+ id: name,
89
+ source,
90
+ ...(input.agents ? { agents: input.agents } : {}),
91
+ }, [...chain, input.id]);
92
+ dependencyIds.push(name);
93
+ }
94
+ visiting.delete(key);
95
+ const dependsOn = [
96
+ ...new Set([...(input.dependsOn ?? []), ...dependencyIds]),
97
+ ];
98
+ expanded.set(input.id, {
99
+ ...input,
100
+ ...(dependsOn.length ? { dependsOn } : {}),
101
+ });
102
+ }
103
+ for (const pkg of manifest.packages)
104
+ await visit(pkg, []);
105
+ return { ...manifest, packages: [...expanded.values()] };
106
+ }
107
+ function policyViolations(manifest, packages) {
108
+ const violations = [];
109
+ const blockedDomains = new Set((manifest.policy?.blockedDomains ?? []).map((domain) => domain.toLowerCase()));
110
+ const blockedCommands = new Set((manifest.policy?.blockedCommands ?? []).map((command) => command.toLowerCase()));
111
+ const allowPackages = new Set(manifest.policy?.allowPackages ?? []);
112
+ const allowRepositories = new Set((manifest.policy?.allowRepositories ?? []).map((repository) => repository.toLowerCase()));
113
+ const deniedPackages = new Set(manifest.policy?.deniedPackages ?? []);
114
+ const deniedRepositories = new Set((manifest.policy?.deniedRepositories ?? []).map((repository) => repository.toLowerCase()));
115
+ for (const entry of packages) {
116
+ const packageId = entry.plan.packageId;
117
+ const repository = entry.metadata?.repository?.toLowerCase();
118
+ if (allowPackages.size && !allowPackages.has(packageId))
119
+ violations.push(`${packageId} is not on the package allowlist`);
120
+ if (allowRepositories.size &&
121
+ (!repository || !allowRepositories.has(repository)))
122
+ violations.push(`${packageId} is not on the repository allowlist`);
123
+ if (deniedPackages.has(packageId))
124
+ violations.push(`${packageId} is on the package denylist`);
125
+ if (repository && deniedRepositories.has(repository))
126
+ violations.push(`${packageId} is on the repository denylist`);
127
+ for (const finding of entry.safety.findings) {
128
+ if (finding.category === "domain")
129
+ for (const domain of finding.names ?? [])
130
+ if (blockedDomains.has(domain.toLowerCase()))
131
+ violations.push(`${entry.plan.packageId} references blocked domain '${domain}'`);
132
+ }
133
+ for (const file of entry.plan.files) {
134
+ const name = file.source
135
+ .split(/[\\/]/)
136
+ .at(-1)
137
+ ?.replace(/\.[^.]+$/, "")
138
+ .toLowerCase();
139
+ if (name && blockedCommands.has(name))
140
+ violations.push(`${entry.plan.packageId} contains blocked command '${name}'`);
141
+ }
142
+ }
143
+ return [...new Set(violations)];
144
+ }
145
+ async function resolvePackage(pkg) {
146
+ if (pkg.source.type === "local")
147
+ return { path: resolve(pkg.source.path) };
148
+ if (pkg.source.type === "github") {
149
+ const fetched = await fetchRepositorySnapshot(pkg.source.repository, {
150
+ ref: pkg.source.ref,
151
+ });
152
+ const selected = pkg.source.path
153
+ ? resolve(fetched.path, pkg.source.path)
154
+ : fetched.path;
155
+ if (selected !== fetched.path &&
156
+ !selected.startsWith(`${fetched.path}${sep}`))
157
+ throw new Error(`Package subpath escapes fetched repository: ${pkg.source.path}`);
158
+ return {
159
+ path: selected,
160
+ repository: fetched.repository,
161
+ commit: fetched.commit,
162
+ };
163
+ }
164
+ if (pkg.source.type === "git") {
165
+ const fetched = await fetchGitSnapshot(pkg.source.url, {
166
+ ref: pkg.source.ref,
167
+ });
168
+ const selected = pkg.source.path
169
+ ? resolve(fetched.path, pkg.source.path)
170
+ : fetched.path;
171
+ if (selected !== fetched.path &&
172
+ !selected.startsWith(`${fetched.path}${sep}`))
173
+ throw new Error(`Package subpath escapes fetched Git repository: ${pkg.source.path}`);
174
+ return { path: selected, commit: fetched.commit };
175
+ }
176
+ if (pkg.source.type === "registry") {
177
+ const resolved = await resolveRegistryPackage(pkg.source.name, pkg.source.version);
178
+ return { path: resolved.path, commit: resolved.digest };
179
+ }
180
+ if (pkg.source.type === "remote-registry") {
181
+ const resolved = await fetchRemoteRegistryPackage(pkg.source.registry, pkg.source.name, pkg.source.version);
182
+ return { path: resolved.path, commit: resolved.digest };
183
+ }
184
+ const catalogId = pkg.source.id;
185
+ const catalog = await loadEffectiveCatalog();
186
+ const found = catalog.find((item) => item.id === catalogId);
187
+ if (!found)
188
+ throw new Error(`Unknown catalog package '${catalogId}'`);
189
+ const fetched = await fetchRepositorySnapshot(found.repository);
190
+ return {
191
+ path: fetched.path,
192
+ repository: fetched.repository,
193
+ commit: fetched.commit,
194
+ };
195
+ }
196
+ export async function buildSyncPlan(manifestPath = "loadout.json") {
197
+ const manifestSha256 = createHash("sha256")
198
+ .update(await readFile(resolve(manifestPath)))
199
+ .digest("hex");
200
+ const manifest = await expandRegistryDependencies(await readManifest(manifestPath));
201
+ const disabled = new Set(manifest.packages
202
+ .filter((pkg) => pkg.enabled === false)
203
+ .map((pkg) => pkg.id));
204
+ for (const pkg of manifest.packages.filter((item) => item.enabled !== false)) {
205
+ const unavailable = (pkg.dependsOn ?? []).filter((id) => disabled.has(id));
206
+ if (unavailable.length)
207
+ throw new Error(`Enabled package '${pkg.id}' depends on disabled package(s): ${unavailable.join(", ")}`);
208
+ }
209
+ const detected = await detectAgents();
210
+ const rootFileTarget = manifest.scope === "project" ? dirname(resolve(manifestPath)) : userHome();
211
+ const packages = [];
212
+ const mcpPlans = [];
213
+ const skipped = [];
214
+ for (const pkg of orderManifestPackages(manifest.packages).filter((item) => item.enabled !== false)) {
215
+ const requested = pkg.agents ?? manifest.agents;
216
+ const agents = installedAgents(detected, requested);
217
+ const source = await resolvePackage(pkg);
218
+ let plan;
219
+ try {
220
+ plan = await buildUniversalPackagePlan(source.path, pkg.id, agents);
221
+ }
222
+ catch (error) {
223
+ if (error instanceof Error && error.message.startsWith("No supported"))
224
+ plan = {
225
+ packageId: pkg.id,
226
+ files: [],
227
+ targetAgents: agents.map((agent) => agent.id),
228
+ warnings: [],
229
+ };
230
+ else
231
+ throw error;
232
+ }
233
+ if (pkg.rootFiles?.length)
234
+ await addRootFileExports(plan, source.path, rootFileTarget, pkg.rootFiles);
235
+ const manifests = pkg.mcp ? await discoverMcpManifests(source.path) : [];
236
+ const allServers = manifests.flatMap((manifest) => manifest.servers);
237
+ const requestedServers = pkg.mcp?.servers ?? allServers.map((server) => server.name);
238
+ const selectedServers = requestedServers.map((name) => {
239
+ const matches = allServers.filter((server) => server.name === name);
240
+ if (matches.length === 0)
241
+ throw new Error(`MCP server '${name}' was not found in package '${pkg.id}'`);
242
+ if (matches.length > 1)
243
+ throw new Error(`MCP server '${name}' is ambiguous in package '${pkg.id}'`);
244
+ return matches[0];
245
+ });
246
+ if (pkg.mcp && selectedServers.length === 0)
247
+ throw new Error(`Package '${pkg.id}' has MCP configuration enabled but contains no MCP servers`);
248
+ if (pkg.mcp)
249
+ mcpPlans.push({
250
+ packageId: pkg.id,
251
+ plan: await planMcpConfigBatch(resolve(pkg.mcp.config), selectedServers.map((server) => ({ server }))),
252
+ });
253
+ if (plan.files.length === 0 && selectedServers.length === 0) {
254
+ skipped.push({
255
+ packageId: pkg.id,
256
+ reason: "No supported skill, rule, command, agent, or configured MCP component was found.",
257
+ });
258
+ continue;
259
+ }
260
+ const safetyPlan = {
261
+ ...plan,
262
+ files: [
263
+ ...plan.files,
264
+ ...manifests.map((manifest) => ({
265
+ source: manifest.path,
266
+ target: manifest.path,
267
+ componentType: "mcp",
268
+ })),
269
+ ],
270
+ };
271
+ const safety = await analyzeInstallPlanSafety(safetyPlan);
272
+ if (pkg.mcp) {
273
+ safety.approvalRequired = true;
274
+ safety.findings.push({
275
+ severity: "blocking",
276
+ category: "mcp",
277
+ message: "Package changes MCP configuration and requires explicit approval.",
278
+ paths: [resolve(pkg.mcp.config)],
279
+ names: selectedServers.map((server) => server.name),
280
+ });
281
+ }
282
+ packages.push({
283
+ plan,
284
+ metadata: {
285
+ repository: source.repository,
286
+ resolvedCommit: source.commit,
287
+ },
288
+ safety,
289
+ });
290
+ }
291
+ const installStateSha256 = createHash("sha256")
292
+ .update(JSON.stringify(await readInstallState()))
293
+ .digest("hex");
294
+ return {
295
+ manifest: manifestPath,
296
+ manifestSha256,
297
+ installStateSha256,
298
+ resolvedManifest: manifest,
299
+ packages,
300
+ mcpPlans,
301
+ skipped,
302
+ policyViolations: policyViolations(manifest, packages),
303
+ };
304
+ }
305
+ export async function applySyncPlan(plan, lockPath = "loadout.lock", options = {}) {
306
+ if (plan.skipped.length)
307
+ throw new Error(`Synchronization cannot produce a reproducible lockfile because package(s) were skipped: ${plan.skipped.map((entry) => `${entry.packageId} (${entry.reason})`).join("; ")}`);
308
+ if (plan.policyViolations.length)
309
+ throw new Error(`Synchronization violates manifest policy: ${plan.policyViolations.join("; ")}`);
310
+ const blocked = plan.packages.filter((entry) => entry.safety.approvalRequired);
311
+ if (blocked.length && !options.approveRisk)
312
+ throw new Error(`Synchronization requires explicit risk approval for: ${blocked.map((entry) => entry.plan.packageId).join(", ")}`);
313
+ const conflicts = detectInstallConflicts(plan.packages.map((entry) => entry.plan));
314
+ const blockingConflicts = conflicts.filter((conflict) => conflict.severity === "blocking");
315
+ if (blockingConflicts.length)
316
+ throw new Error(`Synchronization has blocking target conflicts: ${blockingConflicts.map((conflict) => conflict.message).join("; ")}`);
317
+ const applied = await runMutationTransaction(async () => {
318
+ const currentManifestSha256 = createHash("sha256")
319
+ .update(await readFile(resolve(plan.manifest)))
320
+ .digest("hex");
321
+ if (plan.manifestSha256 && currentManifestSha256 !== plan.manifestSha256)
322
+ throw new Error("Manifest changed after synchronization preview; build the plan again.");
323
+ const currentStateSha256 = createHash("sha256")
324
+ .update(JSON.stringify(await readInstallState()))
325
+ .digest("hex");
326
+ if (plan.installStateSha256 &&
327
+ currentStateSha256 !== plan.installStateSha256)
328
+ throw new Error("Installed state changed after synchronization preview; build the plan again.");
329
+ return {
330
+ targets: [
331
+ ...plan.packages.flatMap((entry) => entry.plan.files.map((file) => file.target)),
332
+ ...plan.mcpPlans.map((entry) => entry.plan.path),
333
+ installStatePath(),
334
+ resolve(lockPath),
335
+ ],
336
+ value: plan,
337
+ };
338
+ }, async (freshPlan, snapshot) => {
339
+ for (const entry of freshPlan.packages)
340
+ if (entry.plan.files.length)
341
+ await applySkillPlan(entry.plan);
342
+ for (const entry of freshPlan.mcpPlans)
343
+ await writeMcpConfigPlan(entry.plan);
344
+ await recordInstallTransaction(freshPlan.packages, freshPlan.mcpPlans, snapshot.id);
345
+ await writeLockfile(freshPlan.resolvedManifest ?? (await readManifest(freshPlan.manifest)), lockPath);
346
+ });
347
+ return { snapshotId: applied.snapshotId, lockfile: lockPath };
348
+ }
@@ -0,0 +1,53 @@
1
+ const MIN_WIDTH = 80;
2
+ const MAX_WIDTH = 200;
3
+ /** Strip terminal control/escape sequences from untrusted human-readable text. */
4
+ export function safeTerminalText(value) {
5
+ return (value
6
+ // eslint-disable-next-line no-control-regex
7
+ .replace(/\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g, "")
8
+ // eslint-disable-next-line no-control-regex
9
+ .replace(/\u001b(?:\[[0-?]*[ -/]*[@-~]|[@-_])/g, "")
10
+ // eslint-disable-next-line no-control-regex
11
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/g, "")
12
+ .replace(/[\r\n\t]+/g, " ")
13
+ .trim());
14
+ }
15
+ export function terminalWidth(value = process.stdout.columns) {
16
+ if (!Number.isFinite(value))
17
+ return 120;
18
+ return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, Math.floor(value)));
19
+ }
20
+ function fit(value, width) {
21
+ if (value.length <= width)
22
+ return value.padEnd(width);
23
+ if (width <= 1)
24
+ return value.slice(0, width);
25
+ return `${value.slice(0, width - 1)}…`;
26
+ }
27
+ /** Render a deterministic ANSI-free table bounded to an 80-200 column terminal. */
28
+ export function formatTerminalTable(headers, rows, requestedWidth) {
29
+ if (!headers.length)
30
+ return "";
31
+ if (rows.some((row) => row.length !== headers.length))
32
+ throw new Error("Terminal table rows must match the header width");
33
+ const width = terminalWidth(requestedWidth);
34
+ const separatorWidth = (headers.length - 1) * 3;
35
+ const available = Math.max(headers.length, width - separatorWidth);
36
+ const columns = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => row[index].length), Math.min(4, available)));
37
+ while (columns.reduce((sum, column) => sum + column, 0) > available) {
38
+ const largest = Math.max(...columns);
39
+ const index = columns.findIndex((column) => column === largest);
40
+ if (columns[index] <= 1)
41
+ break;
42
+ columns[index]--;
43
+ }
44
+ const line = (cells) => cells
45
+ .map((cell, index) => fit(cell, columns[index]))
46
+ .join(" | ")
47
+ .trimEnd();
48
+ return [
49
+ line(headers),
50
+ columns.map((column) => "-".repeat(column)).join("-|-"),
51
+ ...rows.map(line),
52
+ ].join("\n");
53
+ }
@@ -0,0 +1,211 @@
1
+ import { lstat, mkdir, readdir, readFile, rename, rm, writeFile, } from "node:fs/promises";
2
+ import { randomUUID } from "node:crypto";
3
+ import { join } from "node:path";
4
+ import { ensureDirectory, loadoutHome } from "./paths.js";
5
+ import { createSnapshot, readSnapshot, restoreSnapshot } from "./snapshot.js";
6
+ import { acquireFileLock, withFileLock, } from "./file-lock.js";
7
+ export const transactionRoot = () => join(loadoutHome(), "staging");
8
+ const transactionPreparingRoot = () => join(loadoutHome(), "staging-preparing");
9
+ export const mutationLockPath = () => join(loadoutHome(), "mutation.lock");
10
+ const journalPath = (directory) => join(directory, "transaction.json");
11
+ const validId = (id) => /^\d+-[a-f0-9]{12}$/.test(id);
12
+ async function writeJournal(directory, journal) {
13
+ const path = journalPath(directory);
14
+ const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
15
+ await writeFile(temporary, `${JSON.stringify(journal, null, 2)}\n`, {
16
+ encoding: "utf8",
17
+ flag: "wx",
18
+ mode: 0o600,
19
+ });
20
+ await rename(temporary, path);
21
+ }
22
+ function parseJournal(value, directory) {
23
+ if (!value || typeof value !== "object" || Array.isArray(value))
24
+ throw new Error(`Corrupt Loadout transaction journal in ${directory}`);
25
+ const journal = value;
26
+ if (journal.version !== 1 ||
27
+ typeof journal.id !== "string" ||
28
+ !validId(journal.id) ||
29
+ typeof journal.snapshotId !== "string" ||
30
+ !validId(journal.snapshotId) ||
31
+ !Array.isArray(journal.targets) ||
32
+ !journal.targets.every((target) => typeof target === "string") ||
33
+ typeof journal.createdAt !== "string" ||
34
+ (journal.status !== "prepared" && journal.status !== "committing")) {
35
+ throw new Error(`Corrupt Loadout transaction journal in ${directory}`);
36
+ }
37
+ if (journal.id !== directory.split(/[\\/]/).at(-1))
38
+ throw new Error(`Corrupt Loadout transaction journal in ${directory}`);
39
+ return journal;
40
+ }
41
+ async function readJournal(directory) {
42
+ try {
43
+ return parseJournal(JSON.parse(await readFile(journalPath(directory), "utf8")), directory);
44
+ }
45
+ catch (error) {
46
+ if (error instanceof Error &&
47
+ error.message.startsWith("Corrupt Loadout transaction journal"))
48
+ throw error;
49
+ throw new Error(`Corrupt Loadout transaction journal in ${directory}`);
50
+ }
51
+ }
52
+ export async function beginTransaction(snapshot, targets, options = {}) {
53
+ const mutationLock = options.mutationLock ?? (await acquireMutationLock());
54
+ const root = transactionRoot();
55
+ const preparingRoot = transactionPreparingRoot();
56
+ let preparingDirectory;
57
+ try {
58
+ // Callers build a preview snapshot before this boundary. Refresh it while
59
+ // holding the global mutation lock so a concurrent completed mutation can
60
+ // never be rolled back by stale pre-lock bytes.
61
+ const freshSnapshot = await createSnapshot(targets);
62
+ Object.assign(snapshot, freshSnapshot);
63
+ await Promise.all([ensureDirectory(root), ensureDirectory(preparingRoot)]);
64
+ const directory = join(root, snapshot.id);
65
+ preparingDirectory = join(preparingRoot, `${snapshot.id}-${randomUUID()}`);
66
+ await mkdir(preparingDirectory, { recursive: false });
67
+ const handle = {
68
+ id: snapshot.id,
69
+ directory,
70
+ snapshotId: snapshot.id,
71
+ mutationLock,
72
+ };
73
+ await writeJournal(preparingDirectory, {
74
+ version: 1,
75
+ id: handle.id,
76
+ snapshotId: handle.snapshotId,
77
+ targets: [...new Set(targets)],
78
+ createdAt: new Date().toISOString(),
79
+ status: "prepared",
80
+ });
81
+ await rename(preparingDirectory, directory);
82
+ preparingDirectory = undefined;
83
+ return handle;
84
+ }
85
+ catch (error) {
86
+ if (preparingDirectory)
87
+ await rm(preparingDirectory, { recursive: true, force: true });
88
+ await mutationLock.release();
89
+ throw error;
90
+ }
91
+ }
92
+ /** Acquire the global mutation boundary and recover abandoned work before use. */
93
+ export async function acquireMutationLock() {
94
+ const mutationLock = await acquireFileLock(mutationLockPath(), {
95
+ timeoutMs: 30_000,
96
+ });
97
+ try {
98
+ // No live transaction can be preparing while this lock is held. Any
99
+ // unpublished directory is crash debris and was never visible to recovery.
100
+ await rm(transactionPreparingRoot(), { recursive: true, force: true });
101
+ // Recovery and the next mutation share one acquisition. Otherwise a
102
+ // process could die between a caller's recovery pass and its state checks.
103
+ await recoverPendingTransactionsUnlocked();
104
+ return mutationLock;
105
+ }
106
+ catch (error) {
107
+ await mutationLock.release();
108
+ throw error;
109
+ }
110
+ }
111
+ /** Serialize a direct mutation or restore that does not need a new journal. */
112
+ export async function withMutationLock(operation) {
113
+ const mutationLock = await acquireMutationLock();
114
+ try {
115
+ return await operation();
116
+ }
117
+ finally {
118
+ await mutationLock.release();
119
+ }
120
+ }
121
+ /**
122
+ * Prepare from current state, snapshot, journal, mutate, and commit under one
123
+ * global lock. Network/source preparation should happen before this boundary;
124
+ * every state-dependent precondition belongs in `prepare`.
125
+ */
126
+ export async function runMutationTransaction(prepare, mutate) {
127
+ const mutationLock = await acquireMutationLock();
128
+ let transaction;
129
+ try {
130
+ const prepared = await prepare();
131
+ const snapshot = await createSnapshot(prepared.targets, {
132
+ persist: false,
133
+ });
134
+ transaction = await beginTransaction(snapshot, prepared.targets, {
135
+ mutationLock,
136
+ });
137
+ await markTransactionCommitting(transaction);
138
+ const result = await mutate(prepared.value, snapshot);
139
+ await completeTransaction(transaction);
140
+ return { snapshotId: snapshot.id, result };
141
+ }
142
+ catch (error) {
143
+ if (transaction)
144
+ await rollbackTransaction(transaction);
145
+ else
146
+ await mutationLock.release();
147
+ throw error;
148
+ }
149
+ }
150
+ export async function markTransactionCommitting(handle) {
151
+ const journal = await readJournal(handle.directory);
152
+ await writeJournal(handle.directory, { ...journal, status: "committing" });
153
+ }
154
+ export async function completeTransaction(handle, options = {}) {
155
+ try {
156
+ await rm(handle.directory, { recursive: true, force: true });
157
+ }
158
+ finally {
159
+ if (options.releaseLock !== false)
160
+ await handle.mutationLock.release();
161
+ }
162
+ }
163
+ export async function rollbackTransaction(handle) {
164
+ try {
165
+ await restoreSnapshot(await readSnapshot(handle.snapshotId));
166
+ }
167
+ catch (error) {
168
+ // Preserve the journal for a later recovery attempt, but never strand the
169
+ // process-global mutation lock when restoration itself fails.
170
+ await handle.mutationLock.release();
171
+ throw error;
172
+ }
173
+ await completeTransaction(handle);
174
+ }
175
+ /**
176
+ * Restore every complete, readable pending transaction. Corrupt journals are
177
+ * deliberately preserved for inspection and reported instead of guessing at
178
+ * paths to delete or restore.
179
+ */
180
+ export async function recoverPendingTransactions() {
181
+ return withFileLock(mutationLockPath(), recoverPendingTransactionsUnlocked, {
182
+ timeoutMs: 30_000,
183
+ });
184
+ }
185
+ async function recoverPendingTransactionsUnlocked() {
186
+ const root = transactionRoot();
187
+ let entries;
188
+ try {
189
+ entries = await readdir(root);
190
+ }
191
+ catch (error) {
192
+ if (error &&
193
+ typeof error === "object" &&
194
+ "code" in error &&
195
+ error.code === "ENOENT")
196
+ return [];
197
+ throw error;
198
+ }
199
+ const recovered = [];
200
+ for (const entry of entries.sort()) {
201
+ const directory = join(root, entry);
202
+ const info = await lstat(directory);
203
+ if (!info.isDirectory() || info.isSymbolicLink())
204
+ throw new Error(`Unsafe Loadout transaction staging entry: ${directory}`);
205
+ const journal = await readJournal(directory);
206
+ await restoreSnapshot(await readSnapshot(journal.snapshotId));
207
+ await rm(directory, { recursive: true, force: true });
208
+ recovered.push(journal.id);
209
+ }
210
+ return recovered;
211
+ }
@@ -0,0 +1,39 @@
1
+ import { buildUpdatePlan } from "./update.js";
2
+ export async function checkForUpdates(check = buildUpdatePlan) {
3
+ const updates = await check();
4
+ const pending = updates.filter((item) => item.status === "update-available");
5
+ return {
6
+ checkedAt: new Date().toISOString(),
7
+ updates,
8
+ message: pending.length
9
+ ? `${pending.length} update(s) available; review the diff before applying.`
10
+ : "No tracked updates require action.",
11
+ };
12
+ }
13
+ /** Start a read-only watcher. It never applies updates or changes agent files. */
14
+ export function startUpdateWatcher(options) {
15
+ if (!Number.isFinite(options.intervalMs) || options.intervalMs < 1_000)
16
+ throw new Error("Update watcher interval must be at least 1000ms");
17
+ let stopped = false;
18
+ const run = async () => {
19
+ if (stopped)
20
+ return;
21
+ try {
22
+ options.notify(await checkForUpdates(options.check));
23
+ }
24
+ catch (error) {
25
+ options.notify({
26
+ checkedAt: new Date().toISOString(),
27
+ updates: [],
28
+ message: `Update check failed: ${error instanceof Error ? error.message : String(error)}`,
29
+ });
30
+ }
31
+ };
32
+ void run();
33
+ const timer = setInterval(() => void run(), options.intervalMs);
34
+ timer.unref?.();
35
+ return () => {
36
+ stopped = true;
37
+ clearInterval(timer);
38
+ };
39
+ }