@iseoane/agent-depot 0.2.0 → 0.3.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 +47 -0
- package/README.md +117 -3
- package/dist/src/app-cli.js +172 -0
- package/dist/src/app-flow.js +457 -0
- package/dist/src/app-latest.js +63 -0
- package/dist/src/app-owned-skills.js +42 -0
- package/dist/src/app-recipes.js +116 -0
- package/dist/src/app-schema.js +45 -0
- package/dist/src/app-version.js +46 -0
- package/dist/src/atomic-file.js +26 -0
- package/dist/src/cli.js +1256 -0
- package/dist/src/git-source.js +409 -0
- package/dist/src/path-safety.js +31 -0
- package/dist/src/process-runner.js +41 -0
- package/dist/src/project-installation.js +1411 -0
- package/dist/src/project-manifest.js +511 -0
- package/dist/src/skill-adoption.js +77 -0
- package/dist/src/skill-discovery.js +467 -0
- package/dist/src/skill-hosts.js +103 -0
- package/dist/src/skill-install.js +374 -0
- package/dist/src/skill-removal.js +119 -0
- package/dist/src/skill-update.js +244 -0
- package/dist/src/source-state.js +240 -0
- package/dist/src/sources.js +391 -0
- package/dist/src/tui/adoption-actions.js +144 -0
- package/dist/src/tui/app-actions.js +157 -0
- package/dist/src/tui/app.js +63 -0
- package/dist/src/tui/batch.js +17 -0
- package/dist/src/tui/bulk-actions.js +243 -0
- package/dist/src/tui/catalog-actions.js +9 -0
- package/dist/src/tui/catalog-browse.js +59 -0
- package/dist/src/tui/catalog-filter.js +20 -0
- package/dist/src/tui/catalog-flow.js +107 -0
- package/dist/src/tui/catalog-installs.js +193 -0
- package/dist/src/tui/catalog-panel.js +47 -0
- package/dist/src/tui/catalog-state.js +92 -0
- package/dist/src/tui/catalog-tree.js +35 -0
- package/dist/src/tui/catalog-view.js +89 -0
- package/dist/src/tui/environment.js +1 -0
- package/dist/src/tui/installations-browse.js +136 -0
- package/dist/src/tui/installations-mode.js +1 -0
- package/dist/src/tui/installations-panel.js +27 -0
- package/dist/src/tui/installations-rows.js +11 -0
- package/dist/src/tui/installations-tree.js +80 -0
- package/dist/src/tui/installations-view.js +154 -0
- package/dist/src/tui/installations.js +172 -0
- package/dist/src/tui/keys.js +31 -0
- package/dist/src/tui/manage-actions.js +174 -0
- package/dist/src/tui/manage-panel.js +65 -0
- package/dist/src/tui/mode-keys.js +61 -0
- package/dist/src/tui/panel-parts.js +23 -0
- package/dist/src/tui/render.js +8 -0
- package/dist/src/tui/sources-mode.js +17 -0
- package/dist/src/tui/sources-view.js +276 -0
- package/dist/src/tui/theme.js +29 -0
- package/dist/src/tui/tree-navigation.js +68 -0
- package/dist/src/tui/tree.js +53 -0
- package/dist/src/tui/unmanaged-actions.js +178 -0
- package/dist/src/tui/updates-flow.js +89 -0
- package/dist/src/tui/updates-keys.js +104 -0
- package/dist/src/tui/updates-mode.js +1 -0
- package/dist/src/tui/updates-model.js +48 -0
- package/dist/src/tui/updates-panel.js +44 -0
- package/dist/src/tui/updates-view.js +101 -0
- package/dist/src/tui/updates.js +230 -0
- package/dist/src/tui/view-state.js +42 -0
- package/dist/src/tui/window.js +31 -0
- package/dist/src/unmanaged-removal.js +60 -0
- package/dist/src/update-batch.js +376 -0
- package/dist/src/update-flow.js +135 -0
- package/dist/src/usage-error.js +7 -0
- package/dist/src/user-global-skill-inventory.js +565 -0
- package/dist/src/user-method.js +14 -0
- package/package.json +7 -4
- package/skills/agent-depot-apprecipe/SKILL.md +44 -0
- package/skills/agent-depot-apprecipe/assets/example.md +25 -0
- package/skills/doctor-md-agents/scripts/test_collect_sessions.py +0 -780
- package/skills/doctor-md-agents/scripts/test_extract_rules.py +0 -530
- package/skills/doctor-md-agents/scripts/test_render_report.py +0 -383
- package/skills/doctor-md-agents/scripts/test_scan_corrections.py +0 -343
- package/skills/doctor-md-agents/scripts/test_verify_citations.py +0 -236
- package/skills/doctor-md-agents/scripts/testdata/old_extract_rules_pre_t1.py.txt +0 -507
- package/skills/doctor-md-skill/scripts/test_collect_sessions.py +0 -593
- package/skills/doctor-md-skill/scripts/test_detect_machine_sessions.py +0 -181
- package/skills/doctor-md-skill/scripts/test_render_report.py +0 -241
- package/skills/doctor-md-skill/scripts/test_verify_citations.py +0 -196
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
import { access, chmod, mkdir, readFile, readdir, realpath, unlink } from "node:fs/promises";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { compareAppVersions, sameAppVersion } from "./app-version.js";
|
|
7
|
+
import { fetchAppLatest } from "./app-latest.js";
|
|
8
|
+
import { writeFileAtomically } from "./atomic-file.js";
|
|
9
|
+
import { parseAppRecipe } from "./app-recipes.js";
|
|
10
|
+
import { runProcess } from "./process-runner.js";
|
|
11
|
+
import { defaultSourceStatePath } from "./source-state.js";
|
|
12
|
+
const APP_OUTPUT_LIMIT = 1024 * 1024;
|
|
13
|
+
const DIAGNOSTIC_BYTES = 4096;
|
|
14
|
+
const WINDOWS_EXECUTABLE_WARNING = "will not run (Windows executable on WSL)";
|
|
15
|
+
export function createAppOperations(environment = {}) {
|
|
16
|
+
const platform = environment.platform ?? process.platform;
|
|
17
|
+
const home = environment.homeDirectory ?? homedir();
|
|
18
|
+
const stateDirectory = path.dirname(defaultSourceStatePath(process.env, platform, home));
|
|
19
|
+
const directory = environment.recipesDirectory ?? path.join(stateDirectory, "apps");
|
|
20
|
+
const receipts = path.join(path.dirname(directory), "app-approvals");
|
|
21
|
+
const isWsl = environment.isWsl ?? (platform === "linux" && Boolean(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP));
|
|
22
|
+
const mountRoot = environment.wslMountRoot ?? "/mnt";
|
|
23
|
+
const runner = environment.runner ?? runProcess;
|
|
24
|
+
function isWindowsExecutable(executable) {
|
|
25
|
+
const relative = path.posix.relative(mountRoot, executable);
|
|
26
|
+
return isWsl && /^[a-z]\//iu.test(relative);
|
|
27
|
+
}
|
|
28
|
+
async function resolve(name) {
|
|
29
|
+
if (environment.resolveExecutable) {
|
|
30
|
+
const executable = await environment.resolveExecutable(name);
|
|
31
|
+
const target = executable ? await realpath(executable).catch(() => executable) : undefined;
|
|
32
|
+
return { executable, blocked: target !== undefined && isWindowsExecutable(target) };
|
|
33
|
+
}
|
|
34
|
+
let blockedExecutable;
|
|
35
|
+
const suffixes = platform === "win32"
|
|
36
|
+
? ["", ...(process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";")]
|
|
37
|
+
: [""];
|
|
38
|
+
for (const dir of (process.env.PATH ?? "").split(path.delimiter)) {
|
|
39
|
+
if (!path.isAbsolute(dir))
|
|
40
|
+
continue;
|
|
41
|
+
for (const suffix of suffixes) {
|
|
42
|
+
const candidate = path.join(dir, name + suffix);
|
|
43
|
+
try {
|
|
44
|
+
await access(candidate, platform === "win32" ? constants.F_OK : constants.X_OK);
|
|
45
|
+
const target = await realpath(candidate);
|
|
46
|
+
if (isWindowsExecutable(target)) {
|
|
47
|
+
blockedExecutable ??= candidate;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
// Preserve shim/multicall identity when spawning; realpath is only a safety check.
|
|
51
|
+
return { executable: candidate, blocked: false };
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Try the next PATH entry.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { executable: blockedExecutable, blocked: blockedExecutable !== undefined };
|
|
59
|
+
}
|
|
60
|
+
const receiptPath = (entry) => path.join(receipts, createHash("sha256").update(entry.canonicalFile).digest("hex") + ".json");
|
|
61
|
+
async function read(file) {
|
|
62
|
+
try {
|
|
63
|
+
const canonicalFile = await realpath(file);
|
|
64
|
+
const content = await readFile(canonicalFile);
|
|
65
|
+
const recipe = parseAppRecipe(JSON.parse(content.toString("utf8")));
|
|
66
|
+
const currentPlatform = platform === "win32" ? "windows" : platform;
|
|
67
|
+
return {
|
|
68
|
+
file, canonicalFile, recipe, hash: createHash("sha256").update(content).digest("hex"),
|
|
69
|
+
applicable: recipe.platform === undefined || recipe.platform === currentPlatform,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
return { file, applicable: true, error: error instanceof Error ? error.message : String(error) };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function rejectDuplicates(entries) {
|
|
77
|
+
const names = new Map();
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
if (entry.applicable && entry.recipe) {
|
|
80
|
+
names.set(entry.recipe.name, (names.get(entry.recipe.name) ?? 0) + 1);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return entries.map((entry) => (entry.applicable && entry.recipe && names.get(entry.recipe.name) > 1
|
|
84
|
+
? { ...entry, error: `Duplicate applicable App name: ${entry.recipe.name}` }
|
|
85
|
+
: entry));
|
|
86
|
+
}
|
|
87
|
+
async function load(files) {
|
|
88
|
+
if (files) {
|
|
89
|
+
const selected = await Promise.all(files.map(read));
|
|
90
|
+
const registered = files.some(isInRecipeDirectory) ? await load() : [];
|
|
91
|
+
return rejectDuplicates(selected.map((entry) => (isInRecipeDirectory(entry.file)
|
|
92
|
+
? registered.find((item) => path.resolve(item.file) === path.resolve(entry.file)) ?? entry
|
|
93
|
+
: entry)));
|
|
94
|
+
}
|
|
95
|
+
let names;
|
|
96
|
+
try {
|
|
97
|
+
names = await readdir(directory);
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (error.code === "ENOENT")
|
|
101
|
+
return [];
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
const filesInDirectory = names.filter((name) => name.endsWith(".json")).sort();
|
|
105
|
+
return rejectDuplicates(await Promise.all(filesInDirectory.map((name) => read(path.join(directory, name)))));
|
|
106
|
+
}
|
|
107
|
+
async function preview(entry) {
|
|
108
|
+
if (!entry.recipe || entry.error)
|
|
109
|
+
throw new Error(entry.error ?? "Invalid recipe");
|
|
110
|
+
const recipe = entry.recipe;
|
|
111
|
+
const steps = [
|
|
112
|
+
...Object.entries({
|
|
113
|
+
install: recipe.install, update: recipe.update, uninstall: recipe.uninstall, version: recipe.version,
|
|
114
|
+
}),
|
|
115
|
+
...(recipe.latest && "argv" in recipe.latest ? [["latest", recipe.latest]] : []),
|
|
116
|
+
...Object.entries(recipe.setup ?? {}).map(([host, step]) => [`setup.${host}`, step]),
|
|
117
|
+
...Object.entries(recipe.teardown ?? {}).map(([host, step]) => [`teardown.${host}`, step]),
|
|
118
|
+
];
|
|
119
|
+
return Promise.all(steps.map(async ([step, value]) => {
|
|
120
|
+
const resolution = value.argv ? await resolve(value.argv[0]) : undefined;
|
|
121
|
+
return {
|
|
122
|
+
step, ...value, executable: resolution?.executable, cwd: home,
|
|
123
|
+
warning: resolution?.blocked ? WINDOWS_EXECUTABLE_WARNING : undefined,
|
|
124
|
+
};
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
function isInRecipeDirectory(file) {
|
|
128
|
+
return path.dirname(path.resolve(file)) === path.resolve(directory);
|
|
129
|
+
}
|
|
130
|
+
async function approve(entry) {
|
|
131
|
+
const [current] = await load([entry.file]);
|
|
132
|
+
if (entry.error || current.error || !current.applicable || !current.hash
|
|
133
|
+
|| current.hash !== entry.hash || current.canonicalFile !== entry.canonicalFile) {
|
|
134
|
+
throw new Error("Recipe changed, invalid or not applicable; review it again");
|
|
135
|
+
}
|
|
136
|
+
await mkdir(receipts, { recursive: true, mode: 0o700 });
|
|
137
|
+
await chmod(receipts, 0o700);
|
|
138
|
+
await writeFileAtomically(receiptPath(entry), JSON.stringify({ hash: entry.hash }), { mode: 0o600 });
|
|
139
|
+
}
|
|
140
|
+
async function approvalProblem(entry) {
|
|
141
|
+
if (entry.error || !entry.recipe)
|
|
142
|
+
return { status: "invalid", reason: entry.error };
|
|
143
|
+
if (!entry.applicable)
|
|
144
|
+
return { status: "not applicable here" };
|
|
145
|
+
const [current] = await load([entry.file]);
|
|
146
|
+
if (current.error)
|
|
147
|
+
return { status: "invalid", reason: current.error };
|
|
148
|
+
let approved = false;
|
|
149
|
+
try {
|
|
150
|
+
approved = JSON.parse(await readFile(receiptPath(entry), "utf8")).hash === entry.hash
|
|
151
|
+
&& current.hash === entry.hash && current.canonicalFile === entry.canonicalFile;
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
if (error.code !== "ENOENT")
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
if (!approved) {
|
|
158
|
+
const { executable } = await resolve(entry.recipe.version.argv[0]);
|
|
159
|
+
return { status: "needs approval", executable };
|
|
160
|
+
}
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
/** A supplied cache checks recipe approval without running version; omit it for a live check. */
|
|
164
|
+
async function inspect(entry, cached) {
|
|
165
|
+
if (entry.error || !entry.recipe)
|
|
166
|
+
return { status: "invalid", reason: entry.error };
|
|
167
|
+
if (!entry.applicable)
|
|
168
|
+
return { status: "not applicable here" };
|
|
169
|
+
const approval = await approvalProblem(entry);
|
|
170
|
+
if (approval)
|
|
171
|
+
return approval;
|
|
172
|
+
if (cached)
|
|
173
|
+
return cached.installedVersion
|
|
174
|
+
? { status: "installed", installedVersion: cached.installedVersion } : { status: "not installed" };
|
|
175
|
+
const { executable, blocked } = await resolve(entry.recipe.version.argv[0]);
|
|
176
|
+
if (!executable || blocked) {
|
|
177
|
+
return { status: "not installed", executable, reason: blocked ? WINDOWS_EXECUTABLE_WARNING : undefined };
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
const result = await runner(executable, entry.recipe.version.argv.slice(1), {
|
|
181
|
+
cwd: home, captureStdout: true, maxOutputBytes: APP_OUTPUT_LIMIT, maxStderrBytes: APP_OUTPUT_LIMIT,
|
|
182
|
+
});
|
|
183
|
+
const installedVersion = result.code === 0 && !result.signal && !result.outputTooLarge
|
|
184
|
+
? new RegExp(entry.recipe.version.pattern).exec(result.stdout.toString("utf8"))?.[1]
|
|
185
|
+
: undefined;
|
|
186
|
+
return installedVersion
|
|
187
|
+
? { status: "installed", executable, installedVersion }
|
|
188
|
+
: { status: "not installed", executable };
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return { status: "not installed", executable };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
async function checkUpdate(entry) {
|
|
195
|
+
const inspection = await inspect(entry);
|
|
196
|
+
let latestVersion;
|
|
197
|
+
let reason = inspection.status === "installed" ? "latest not declared" : inspection.reason ?? inspection.status;
|
|
198
|
+
if (inspection.status === "installed" && entry.recipe?.latest) {
|
|
199
|
+
try {
|
|
200
|
+
const latest = entry.recipe.latest;
|
|
201
|
+
reason = "latest executable unavailable";
|
|
202
|
+
if ("argv" in latest) {
|
|
203
|
+
const approval = await approvalProblem(entry);
|
|
204
|
+
if (approval)
|
|
205
|
+
reason = approval.reason ?? approval.status;
|
|
206
|
+
if (!approval) {
|
|
207
|
+
const { executable, blocked } = await resolve(latest.argv[0]);
|
|
208
|
+
if (executable && !blocked) {
|
|
209
|
+
reason = "latest command failed";
|
|
210
|
+
const result = await runner(executable, latest.argv.slice(1), {
|
|
211
|
+
cwd: home, captureStdout: true, maxOutputBytes: APP_OUTPUT_LIMIT, maxStderrBytes: APP_OUTPUT_LIMIT,
|
|
212
|
+
});
|
|
213
|
+
if (result.code === 0 && !result.signal && !result.outputTooLarge) {
|
|
214
|
+
latestVersion = new RegExp(latest.pattern).exec(result.stdout.toString("utf8"))?.[1];
|
|
215
|
+
reason = "invalid response";
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
const lookup = await fetchAppLatest(latest, environment.fetch ?? globalThis.fetch);
|
|
222
|
+
latestVersion = lookup.latestVersion;
|
|
223
|
+
reason = lookup.reason ?? "invalid response";
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
reason = "latest lookup failed";
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const comparison = latestVersion && inspection.installedVersion
|
|
231
|
+
? compareAppVersions(inspection.installedVersion, latestVersion) : undefined;
|
|
232
|
+
const status = !latestVersion || !inspection.installedVersion ? "unknown"
|
|
233
|
+
: (comparison !== undefined ? comparison >= 0 : sameAppVersion(latestVersion, inspection.installedVersion)) ? "current"
|
|
234
|
+
: "update available";
|
|
235
|
+
return { entry, inspection, installedVersion: inspection.installedVersion, status, latestVersion,
|
|
236
|
+
reason: status === "unknown" ? reason : undefined };
|
|
237
|
+
}
|
|
238
|
+
const installations = path.join(path.dirname(directory), "app-installations");
|
|
239
|
+
const installationPath = (name) => path.join(installations, createHash("sha256").update(name).digest("hex") + ".json");
|
|
240
|
+
async function trackedApps(reportWarning = warning => process.emitWarning(warning)) {
|
|
241
|
+
let files;
|
|
242
|
+
try {
|
|
243
|
+
files = await readdir(installations);
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
if (error.code === "ENOENT")
|
|
247
|
+
return [];
|
|
248
|
+
throw error;
|
|
249
|
+
}
|
|
250
|
+
const records = [];
|
|
251
|
+
for (const file of files.filter(file => file.endsWith(".json")).sort()) {
|
|
252
|
+
try {
|
|
253
|
+
const record = JSON.parse(await readFile(path.join(installations, file), "utf8"));
|
|
254
|
+
if (!record || typeof record !== "object"
|
|
255
|
+
|| !("name" in record) || typeof record.name !== "string" || !record.name
|
|
256
|
+
|| !("recipeFile" in record) || typeof record.recipeFile !== "string" || !record.recipeFile
|
|
257
|
+
|| !("installedVersion" in record) || typeof record.installedVersion !== "string" || !record.installedVersion
|
|
258
|
+
|| path.basename(installationPath(record.name)) !== file) {
|
|
259
|
+
throw new Error("invalid record fields or identity");
|
|
260
|
+
}
|
|
261
|
+
records.push({ name: record.name, recipeFile: record.recipeFile, installedVersion: record.installedVersion });
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
reportWarning(`Skipping App installation record ${file}: ${error instanceof Error ? error.message : String(error)}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return records;
|
|
268
|
+
}
|
|
269
|
+
async function previewForget(name) {
|
|
270
|
+
const file = installationPath(name);
|
|
271
|
+
try {
|
|
272
|
+
await access(file);
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
if (error.code === "ENOENT")
|
|
276
|
+
throw new Error("No tracked App found");
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
return { name, file };
|
|
280
|
+
}
|
|
281
|
+
async function planStep(entry, action, host) {
|
|
282
|
+
const approval = await approvalProblem(entry);
|
|
283
|
+
if (approval)
|
|
284
|
+
throw new Error(approval.reason ?? approval.status);
|
|
285
|
+
const hostAction = action === "setup" || action === "teardown";
|
|
286
|
+
const step = hostAction ? (host ? entry.recipe[action]?.[host] : undefined) : entry.recipe[action];
|
|
287
|
+
if (!step || (host && !hostAction))
|
|
288
|
+
throw new Error("No declared App step for the requested Host/action");
|
|
289
|
+
const resolution = step.argv ? await resolve(step.argv[0]) : undefined;
|
|
290
|
+
return Object.freeze({ entry, action, host, ...step, executable: resolution?.executable,
|
|
291
|
+
warning: resolution?.blocked ? WINDOWS_EXECUTABLE_WARNING : undefined,
|
|
292
|
+
cwd: home, environment: isWsl ? "linux (WSL)" : platform });
|
|
293
|
+
}
|
|
294
|
+
async function planLifecycle(entry, action, host, loadedUpdate) {
|
|
295
|
+
const plan = await planStep(entry, action, host);
|
|
296
|
+
if (action !== "update")
|
|
297
|
+
return plan;
|
|
298
|
+
if (loadedUpdate && (loadedUpdate.entry.canonicalFile !== entry.canonicalFile || loadedUpdate.entry.hash !== entry.hash)) {
|
|
299
|
+
throw new Error("Update check belongs to a different recipe; check again");
|
|
300
|
+
}
|
|
301
|
+
const update = loadedUpdate ?? await checkUpdate(entry);
|
|
302
|
+
if (!update.installedVersion)
|
|
303
|
+
throw new Error("Update requires a successful installed version check");
|
|
304
|
+
return Object.freeze({ ...plan, previousVersion: update.installedVersion, latestVersion: update.latestVersion });
|
|
305
|
+
}
|
|
306
|
+
async function recheckPlan(plan) {
|
|
307
|
+
const current = await planStep(plan.entry, plan.action, plan.host);
|
|
308
|
+
const sameArgv = current.argv?.length === plan.argv?.length
|
|
309
|
+
&& current.argv?.every((arg, index) => arg === plan.argv?.[index]) !== false;
|
|
310
|
+
if (!sameArgv || current.executable !== plan.executable || current.warning !== plan.warning
|
|
311
|
+
|| current.manual !== plan.manual || current.entry.hash !== plan.entry.hash
|
|
312
|
+
|| current.cwd !== plan.cwd || current.environment !== plan.environment) {
|
|
313
|
+
throw new Error("App step changed; review the preview again");
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
async function recordOutcome(plan, manual = false) {
|
|
317
|
+
if (plan.action === "setup" || plan.action === "teardown") {
|
|
318
|
+
if (manual) {
|
|
319
|
+
const inspection = await inspect(plan.entry);
|
|
320
|
+
if (inspection.status !== "installed") {
|
|
321
|
+
return { status: "failed", reason: `Version did not confirm installation (${inspection.status}); Host wiring is not verified` };
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return { status: "completed" };
|
|
325
|
+
}
|
|
326
|
+
const inspection = await inspect(plan.entry);
|
|
327
|
+
const name = plan.entry.recipe.name;
|
|
328
|
+
if ((plan.action === "install" || plan.action === "update") && inspection.status === "installed") {
|
|
329
|
+
if (plan.action === "update" && (!plan.previousVersion || sameAppVersion(inspection.installedVersion, plan.previousVersion))) {
|
|
330
|
+
return { status: "failed", reason: `version unchanged (${plan.previousVersion ?? "unknown"}); expected ${plan.latestVersion ?? "unknown"}` };
|
|
331
|
+
}
|
|
332
|
+
await mkdir(installations, { recursive: true, mode: 0o700 });
|
|
333
|
+
await chmod(installations, 0o700);
|
|
334
|
+
await writeFileAtomically(installationPath(name), JSON.stringify({
|
|
335
|
+
name, recipeFile: plan.entry.canonicalFile, installedVersion: inspection.installedVersion,
|
|
336
|
+
}), { mode: 0o600 });
|
|
337
|
+
if (plan.action === "update")
|
|
338
|
+
await clearPendingUpdate(name);
|
|
339
|
+
return { status: "installed", previousVersion: plan.previousVersion,
|
|
340
|
+
installedVersion: inspection.installedVersion, latestVersion: plan.latestVersion };
|
|
341
|
+
}
|
|
342
|
+
if (plan.action === "uninstall" && inspection.status === "not installed") {
|
|
343
|
+
await forgetApp(name, true);
|
|
344
|
+
return { status: "untracked" };
|
|
345
|
+
}
|
|
346
|
+
return { status: "failed", reason: plan.action !== "uninstall"
|
|
347
|
+
? `Version did not confirm installation (${inspection.status}); tracking unchanged`
|
|
348
|
+
: `Version still succeeds or cannot be checked (${inspection.status}); tracking retained` };
|
|
349
|
+
}
|
|
350
|
+
async function executeLifecycle(plan, confirmed) {
|
|
351
|
+
if (!confirmed)
|
|
352
|
+
throw new Error("App step not confirmed; review and confirm first");
|
|
353
|
+
await recheckPlan(plan);
|
|
354
|
+
if (!plan.argv || !plan.executable || plan.warning)
|
|
355
|
+
return manualFallback(plan);
|
|
356
|
+
let result;
|
|
357
|
+
try {
|
|
358
|
+
result = await runner(plan.executable, plan.argv.slice(1), {
|
|
359
|
+
cwd: home, captureStdout: true, maxOutputBytes: APP_OUTPUT_LIMIT, maxStderrBytes: APP_OUTPUT_LIMIT,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
const code = error?.code;
|
|
364
|
+
if (!code || !["ENOENT", "EACCES", "ENOEXEC", "EPERM"].includes(code))
|
|
365
|
+
throw error;
|
|
366
|
+
return manualFallback(plan, error instanceof Error ? error.message : String(error));
|
|
367
|
+
}
|
|
368
|
+
if (result.code !== 0 || result.signal || result.outputTooLarge) {
|
|
369
|
+
const reason = result.outputTooLarge ? `output exceeded ${APP_OUTPUT_LIMIT} bytes`
|
|
370
|
+
: `exit ${result.code}, signal ${result.signal}`;
|
|
371
|
+
const stdout = result.stdout.subarray(-DIAGNOSTIC_BYTES).toString("utf8");
|
|
372
|
+
const stderr = Buffer.from(result.stderr).subarray(-DIAGNOSTIC_BYTES).toString("utf8");
|
|
373
|
+
return { status: "failed", reason: `App step failed (${reason}): ${stdout} ${stderr}` };
|
|
374
|
+
}
|
|
375
|
+
return recordOutcome(plan);
|
|
376
|
+
}
|
|
377
|
+
const pendingUpdates = path.join(path.dirname(directory), "app-pending-updates");
|
|
378
|
+
const pendingUpdatePath = (name) => path.join(pendingUpdates, path.basename(installationPath(name)));
|
|
379
|
+
async function clearPendingUpdate(name) {
|
|
380
|
+
await unlink(pendingUpdatePath(name)).catch((error) => {
|
|
381
|
+
if (error.code !== "ENOENT")
|
|
382
|
+
throw error;
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
async function manualFallback(plan, reason = "Executable unavailable") {
|
|
386
|
+
if (plan.action === "update" && plan.manual) {
|
|
387
|
+
await mkdir(pendingUpdates, { recursive: true, mode: 0o700 });
|
|
388
|
+
await chmod(pendingUpdates, 0o700);
|
|
389
|
+
await writeFileAtomically(pendingUpdatePath(plan.entry.recipe.name), JSON.stringify({
|
|
390
|
+
recipeFile: plan.entry.canonicalFile, hash: plan.entry.hash,
|
|
391
|
+
previousVersion: plan.previousVersion, latestVersion: plan.latestVersion,
|
|
392
|
+
}), { mode: 0o600 });
|
|
393
|
+
}
|
|
394
|
+
return plan.manual ? { status: "manual required", manual: plan.manual, reason }
|
|
395
|
+
: { status: "failed", reason };
|
|
396
|
+
}
|
|
397
|
+
/** Cancels manual update evidence without attesting completion or changing tracking. */
|
|
398
|
+
async function cancelManual(plan) {
|
|
399
|
+
if (plan.action === "update")
|
|
400
|
+
await clearPendingUpdate(plan.entry.recipe.name);
|
|
401
|
+
}
|
|
402
|
+
async function completeManual(plan, done) {
|
|
403
|
+
if (!done)
|
|
404
|
+
throw new Error("Manual step completion not confirmed");
|
|
405
|
+
try {
|
|
406
|
+
// Installation/removal may legitimately change PATH resolution during manual work.
|
|
407
|
+
const approval = await approvalProblem(plan.entry);
|
|
408
|
+
if (approval)
|
|
409
|
+
throw new Error(approval.reason ?? approval.status);
|
|
410
|
+
if (!plan.manual)
|
|
411
|
+
throw new Error("No manual step declared");
|
|
412
|
+
if (plan.action === "update") {
|
|
413
|
+
try {
|
|
414
|
+
const pending = JSON.parse(await readFile(pendingUpdatePath(plan.entry.recipe.name), "utf8"));
|
|
415
|
+
if (pending.recipeFile !== plan.entry.canonicalFile || pending.hash !== plan.entry.hash
|
|
416
|
+
|| typeof pending.previousVersion !== "string" || !pending.previousVersion
|
|
417
|
+
|| (pending.latestVersion !== undefined && typeof pending.latestVersion !== "string")) {
|
|
418
|
+
throw new Error("Manual update baseline changed; preview the update again");
|
|
419
|
+
}
|
|
420
|
+
plan = { ...plan, previousVersion: pending.previousVersion, latestVersion: pending.latestVersion };
|
|
421
|
+
}
|
|
422
|
+
catch (error) {
|
|
423
|
+
if (error.code !== "ENOENT")
|
|
424
|
+
throw error;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return await recordOutcome(plan, true);
|
|
428
|
+
}
|
|
429
|
+
finally {
|
|
430
|
+
// Attesting completion consumes the baseline even when verification fails.
|
|
431
|
+
if (plan.action === "update")
|
|
432
|
+
await clearPendingUpdate(plan.entry.recipe.name);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
async function forgetApp(name, confirmed) {
|
|
436
|
+
if (!confirmed)
|
|
437
|
+
throw new Error("Forgetting an App requires explicit confirmation");
|
|
438
|
+
await unlink(installationPath(name)).catch((error) => {
|
|
439
|
+
if (error.code !== "ENOENT")
|
|
440
|
+
throw error;
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
return { checkUpdate, load, preview, approve, inspect, planLifecycle, executeLifecycle, completeManual, cancelManual, trackedApps, previewForget, forgetApp };
|
|
444
|
+
}
|
|
445
|
+
/** Concurrent independent checks; one broken App must not hide the rest of a batch. */
|
|
446
|
+
export async function checkAppUpdates(apps) {
|
|
447
|
+
const entries = (await apps.load()).filter(entry => entry.applicable || entry.error);
|
|
448
|
+
return Promise.all(entries.map(async (entry) => {
|
|
449
|
+
try {
|
|
450
|
+
return await apps.checkUpdate(entry);
|
|
451
|
+
}
|
|
452
|
+
catch (error) {
|
|
453
|
+
const reason = `App check failed: ${error instanceof Error ? error.message : String(error)}`;
|
|
454
|
+
return { entry, status: "unknown", inspection: { status: "invalid", reason }, reason };
|
|
455
|
+
}
|
|
456
|
+
}));
|
|
457
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { AGENT_DEPOT_PACKAGE_VERSION } from "./project-manifest.js";
|
|
2
|
+
/** Public registries only: no ambient credentials, redirects, or unbounded bodies. */
|
|
3
|
+
export async function fetchAppLatest(latest, fetcher) {
|
|
4
|
+
const url = "github" in latest
|
|
5
|
+
? `https://api.github.com/repos/${latest.github.split("/").map(encodeURIComponent).join("/")}/releases/latest`
|
|
6
|
+
: `https://registry.npmjs.org/${encodeURIComponent(latest.npm).replace(/^%40/u, "@")}`;
|
|
7
|
+
const controller = new AbortController();
|
|
8
|
+
const timer = setTimeout(() => controller.abort(), 5000);
|
|
9
|
+
let receivedResponse = false;
|
|
10
|
+
let reader;
|
|
11
|
+
try {
|
|
12
|
+
// Race also protects callers whose injected fetch/body does not honor abort.
|
|
13
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
14
|
+
controller.signal.addEventListener("abort", () => reject(new Error("Latest lookup timed out")), { once: true });
|
|
15
|
+
});
|
|
16
|
+
void timeout.catch(() => { });
|
|
17
|
+
const response = await Promise.race([fetcher(url, {
|
|
18
|
+
signal: controller.signal, credentials: "omit", redirect: "manual", headers: "github" in latest ? {
|
|
19
|
+
Accept: "application/vnd.github+json", "User-Agent": `agent-depot/${AGENT_DEPOT_PACKAGE_VERSION ?? "unknown"}`,
|
|
20
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
21
|
+
} : { Accept: "application/vnd.npm.install-v1+json" },
|
|
22
|
+
}), timeout]);
|
|
23
|
+
receivedResponse = true;
|
|
24
|
+
if (response.status >= 300 && response.status < 400) {
|
|
25
|
+
return { reason: `redirected — update latest.${"github" in latest ? "github" : "npm"}` };
|
|
26
|
+
}
|
|
27
|
+
if (response.status === 429 || (response.status === 403 && response.headers.get("X-RateLimit-Remaining") === "0")) {
|
|
28
|
+
return { reason: "rate-limited" };
|
|
29
|
+
}
|
|
30
|
+
if (!response.ok)
|
|
31
|
+
return { reason: `HTTP ${response.status}` };
|
|
32
|
+
if (!response.body)
|
|
33
|
+
return { reason: "invalid response" };
|
|
34
|
+
reader = response.body.getReader();
|
|
35
|
+
const chunks = [];
|
|
36
|
+
let size = 0;
|
|
37
|
+
for (;;) {
|
|
38
|
+
const chunk = await Promise.race([reader.read(), timeout]);
|
|
39
|
+
if (chunk.done)
|
|
40
|
+
break;
|
|
41
|
+
size += chunk.value.byteLength;
|
|
42
|
+
if (size > 1024 * 1024)
|
|
43
|
+
return { reason: "too large" };
|
|
44
|
+
chunks.push(chunk.value);
|
|
45
|
+
}
|
|
46
|
+
const data = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
47
|
+
if (!data || typeof data !== "object")
|
|
48
|
+
return { reason: "invalid response" };
|
|
49
|
+
const value = "github" in latest
|
|
50
|
+
? ("tag_name" in data ? data.tag_name : undefined)
|
|
51
|
+
: ("dist-tags" in data && data["dist-tags"] && typeof data["dist-tags"] === "object"
|
|
52
|
+
&& "latest" in data["dist-tags"] ? data["dist-tags"].latest : undefined);
|
|
53
|
+
return typeof value === "string" && value.trim() ? { latestVersion: value.trim() } : { reason: "invalid response" };
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return { reason: controller.signal.aborted ? "timeout" : receivedResponse ? "invalid response" : "network error" };
|
|
57
|
+
}
|
|
58
|
+
finally {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
controller.abort();
|
|
61
|
+
void reader?.cancel().catch(() => { });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createAppOperations } from "./app-flow.js";
|
|
2
|
+
import { APP_SKILL_PATTERN } from "./app-recipes.js";
|
|
3
|
+
/**
|
|
4
|
+
* Loads declarative ownership without running commands or inspecting Skill paths.
|
|
5
|
+
* Invalid recipes/patterns warn and are skipped; a directory load failure yields no exclusions.
|
|
6
|
+
*/
|
|
7
|
+
export async function loadAppOwnedSkillFilter(options) {
|
|
8
|
+
const warn = options.reportWarning ?? (warning => process.emitWarning(warning));
|
|
9
|
+
try {
|
|
10
|
+
const entries = await createAppOperations({
|
|
11
|
+
homeDirectory: options.homeDirectory, ...options.appEnvironment,
|
|
12
|
+
}).load();
|
|
13
|
+
const matchers = [];
|
|
14
|
+
for (const entry of entries) {
|
|
15
|
+
if (entry.error) {
|
|
16
|
+
warn(`WARNING: Skipping App-owned Skill exclusions for ${entry.file}: ${entry.error}`);
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (!entry.applicable)
|
|
20
|
+
continue;
|
|
21
|
+
for (const pattern of entry.recipe?.skills ?? []) {
|
|
22
|
+
// Only * is special; all other characters are literal. Separators are forbidden.
|
|
23
|
+
if (!new RegExp(APP_SKILL_PATTERN, "u").test(pattern)) {
|
|
24
|
+
warn(`WARNING: Skipping owned-skill pattern ${JSON.stringify(pattern)} in ${entry.file}: invalid name pattern`);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
matchers.push(new RegExp(`^${pattern.split("*").map(part => part.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")).join("[^/\\\\]*")}$`, "u"));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return name => matchers.some(matcher => matcher.test(name));
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
warn(`WARNING: App-owned Skill exclusions unavailable; no exclusions applied: ${error instanceof Error ? error.message : String(error)}`);
|
|
34
|
+
return () => false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Reloads declarations and refuses unmanaged adoption/removal of an App-owned name. */
|
|
38
|
+
export async function assertSkillNotAppOwned(name, options) {
|
|
39
|
+
if ((await loadAppOwnedSkillFilter(options))(name)) {
|
|
40
|
+
throw new Error(`App-owned Skill ${JSON.stringify(name)} cannot be adopted or removed through unmanaged flows; no path was changed`);
|
|
41
|
+
}
|
|
42
|
+
}
|