@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,244 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { inspectProjectSkillUpdate, updateProjectSkillTransaction, validateProjectSkillInstallationPreview, } from "./project-installation.js";
|
|
3
|
+
import { parseProjectManifest, ProjectManifestStore, } from "./project-manifest.js";
|
|
4
|
+
import { executeSourceInstallationMethod, parseSourceInstallationMethod, resolveSourceInstallationMethodCwd, } from "./sources.js";
|
|
5
|
+
import { previewUserMethod, } from "./user-method.js";
|
|
6
|
+
import { freezeSnapshot, immutableUpdateSnapshot, } from "./update-batch.js";
|
|
7
|
+
const immutableSnapshots = new WeakMap();
|
|
8
|
+
/** Builds a safe update preview without changing files, state, or running methods. */
|
|
9
|
+
export async function previewSkillUpdate(item, options) {
|
|
10
|
+
if (item.status !== "updateable") {
|
|
11
|
+
throw new Error(`Skill ${JSON.stringify(item.id)} is not updateable`);
|
|
12
|
+
}
|
|
13
|
+
if (!item.source || !item.currentSnapshot) {
|
|
14
|
+
throw new Error(`Skill ${JSON.stringify(item.id)} has no immutable Source snapshot`);
|
|
15
|
+
}
|
|
16
|
+
if (!item.selection.installation) {
|
|
17
|
+
throw new Error(`Skill ${JSON.stringify(item.id)} has no tracked installation`);
|
|
18
|
+
}
|
|
19
|
+
const selection = immutableSelection(item.selection);
|
|
20
|
+
const snapshot = immutableUpdateSnapshot(item) ?? freezeSnapshot(item.currentSnapshot);
|
|
21
|
+
await validateProjectSkillInstallationPreview({
|
|
22
|
+
selection,
|
|
23
|
+
source: item.source,
|
|
24
|
+
previewTree: snapshot.files,
|
|
25
|
+
portableV1: true,
|
|
26
|
+
}, options.sourceAccess);
|
|
27
|
+
const installationOptions = projectInstallationOptions(options);
|
|
28
|
+
const inspection = await inspectProjectSkillUpdate(selection, installationOptions);
|
|
29
|
+
const target = path.resolve(options.projectRoot, ...selection.installation.path.split("/"));
|
|
30
|
+
const method = await resolveUpdateMethod(selection, item.source, snapshot, options);
|
|
31
|
+
const methodPreview = method === undefined
|
|
32
|
+
? undefined
|
|
33
|
+
: previewUserMethod({
|
|
34
|
+
method,
|
|
35
|
+
projectRoot: options.projectRoot,
|
|
36
|
+
target,
|
|
37
|
+
declaredChanges: [
|
|
38
|
+
`replace ${snapshot.files.length} managed files at ${target}`,
|
|
39
|
+
`persist resolved version and content baseline for ${options.scope} scope`,
|
|
40
|
+
],
|
|
41
|
+
});
|
|
42
|
+
const plan = Object.freeze({
|
|
43
|
+
item,
|
|
44
|
+
selection,
|
|
45
|
+
source: item.source,
|
|
46
|
+
snapshot,
|
|
47
|
+
target,
|
|
48
|
+
overwriteRequired: inspection.requiresOverwriteConfirmation,
|
|
49
|
+
...(inspection.nonCanonicalPath === undefined ? {} : { nonCanonicalPath: inspection.nonCanonicalPath }),
|
|
50
|
+
...(method === undefined ? {} : { method }),
|
|
51
|
+
...(methodPreview === undefined ? {} : { methodPreview }),
|
|
52
|
+
});
|
|
53
|
+
// Typed arrays cannot be frozen safely in JavaScript. Keep a private copy so
|
|
54
|
+
// callback code cannot mutate the bytes that will actually be installed.
|
|
55
|
+
immutableSnapshots.set(plan, freezeSnapshot(snapshot));
|
|
56
|
+
return plan;
|
|
57
|
+
}
|
|
58
|
+
/** Applies one update and persists its new evidence through the selected scope seam. */
|
|
59
|
+
export async function applySkillUpdate(item, options) {
|
|
60
|
+
const plan = await previewSkillUpdate(item, options);
|
|
61
|
+
const confirmation = options.confirm ? await options.confirm(plan) : options.confirmation ?? {};
|
|
62
|
+
assertUpdateConfirmed(item, plan, confirmation);
|
|
63
|
+
if (plan.method !== undefined) {
|
|
64
|
+
// Validate the real path before staging or replacing the managed tree. The
|
|
65
|
+
// injected executor is subject to the same boundary even when it is not
|
|
66
|
+
// the default process runner.
|
|
67
|
+
await resolveSourceInstallationMethodCwd(plan.method, options.projectRoot);
|
|
68
|
+
}
|
|
69
|
+
const persistedState = await preparePersistedSelection(item.selection, options);
|
|
70
|
+
const transaction = await updateProjectSkillTransaction({
|
|
71
|
+
selection: plan.selection,
|
|
72
|
+
source: plan.source,
|
|
73
|
+
previewTree: (immutableSnapshots.get(plan) ?? freezeSnapshot(plan.snapshot)).files,
|
|
74
|
+
resolvedVersion: plan.snapshot.resolvedVersion ?? plan.selection.installation?.resolvedVersion,
|
|
75
|
+
confirmOverwrite: confirmation.overwriteModifiedInstallation === true,
|
|
76
|
+
}, projectInstallationOptions(options, confirmation.nonCanonicalPath));
|
|
77
|
+
try {
|
|
78
|
+
if (plan.method !== undefined) {
|
|
79
|
+
const context = { source: plan.source, skillPath: plan.selection.path, projectRoot: options.projectRoot };
|
|
80
|
+
if (options.executeMethod)
|
|
81
|
+
await options.executeMethod(plan.method, context);
|
|
82
|
+
else if (options.sourceOperations?.executeInstallationMethod) {
|
|
83
|
+
await options.sourceOperations.executeInstallationMethod(plan.method, context);
|
|
84
|
+
}
|
|
85
|
+
else
|
|
86
|
+
await executeSourceInstallationMethod(plan.method, context);
|
|
87
|
+
}
|
|
88
|
+
const updatedSelection = selectionWithInstallation(plan.selection, transaction.result, options.projectRoot);
|
|
89
|
+
await persistedState.apply(updatedSelection);
|
|
90
|
+
await transaction.commit();
|
|
91
|
+
return Object.freeze({ item, plan, installation: transaction.result });
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
return rollBackUpdate(error, transaction, persistedState);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function assertUpdateConfirmed(item, plan, confirmation) {
|
|
98
|
+
if (plan.overwriteRequired && confirmation.overwriteModifiedInstallation !== true) {
|
|
99
|
+
throw new Error(`Skill ${JSON.stringify(item.id)} requires explicit overwrite confirmation; no path was changed`);
|
|
100
|
+
}
|
|
101
|
+
if (plan.nonCanonicalPath !== undefined && confirmation.nonCanonicalPath !== plan.nonCanonicalPath) {
|
|
102
|
+
throw new Error(`Skill ${JSON.stringify(item.id)} is installed at the non-canonical location ${JSON.stringify(plan.nonCanonicalPath)}; explicit confirmation of that exact path is required and no path was changed`);
|
|
103
|
+
}
|
|
104
|
+
if (plan.method !== undefined && confirmation.externalMethod !== true) {
|
|
105
|
+
throw new Error(`Skill ${JSON.stringify(item.id)} has an external method; explicit method confirmation is required and no command was run`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async function rollBackUpdate(error, transaction, persistedState) {
|
|
109
|
+
const rollbackErrors = [];
|
|
110
|
+
for (const [label, rollback] of [
|
|
111
|
+
["filesystem", () => transaction.rollback()],
|
|
112
|
+
["persisted-state", () => persistedState.rollback()],
|
|
113
|
+
]) {
|
|
114
|
+
try {
|
|
115
|
+
await rollback();
|
|
116
|
+
}
|
|
117
|
+
catch (rollbackError) {
|
|
118
|
+
rollbackErrors.push(`${label} rollback failed: ${errorMessage(rollbackError)}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (rollbackErrors.length > 0) {
|
|
122
|
+
throw new Error(`${errorMessage(error)}; ${rollbackErrors.join("; ")}`);
|
|
123
|
+
}
|
|
124
|
+
throw error;
|
|
125
|
+
}
|
|
126
|
+
/** Applies selected entries independently and returns a complete per-skill summary. */
|
|
127
|
+
export async function applyUpdateBatch(selected, options) {
|
|
128
|
+
const items = "updateable" in selected ? selected.updateable : selected;
|
|
129
|
+
const results = [];
|
|
130
|
+
for (const item of items) {
|
|
131
|
+
try {
|
|
132
|
+
const result = await applySkillUpdate(item, options);
|
|
133
|
+
results.push(Object.freeze({ id: item.id, selection: item.selection, status: "updated", result }));
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
results.push(Object.freeze({
|
|
137
|
+
id: item.id,
|
|
138
|
+
selection: item.selection,
|
|
139
|
+
status: "failed",
|
|
140
|
+
error: errorMessage(error),
|
|
141
|
+
}));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const frozenResults = Object.freeze(results);
|
|
145
|
+
return Object.freeze({
|
|
146
|
+
results: frozenResults,
|
|
147
|
+
updated: Object.freeze(results.filter((result) => result.status === "updated")),
|
|
148
|
+
failed: Object.freeze(results.filter((result) => result.status === "failed")),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
/** Alias for embedders that prefer an imperative per-Skill operation name. */
|
|
152
|
+
export const updateSkill = applySkillUpdate;
|
|
153
|
+
function projectInstallationOptions(options, confirmedNonCanonicalPath) {
|
|
154
|
+
return {
|
|
155
|
+
projectRoot: options.projectRoot,
|
|
156
|
+
sourceAccess: options.sourceAccess,
|
|
157
|
+
installationTrust: options.scope === "user-global" ? "user-global-state" : "project-manifest",
|
|
158
|
+
...(options.trackedInstallationPaths === undefined ? {} : { otherInstallationPaths: options.trackedInstallationPaths }),
|
|
159
|
+
...(confirmedNonCanonicalPath === undefined ? {} : { confirmedNonCanonicalPath }),
|
|
160
|
+
...(options.installationFileSystem === undefined ? {} : { fileSystem: options.installationFileSystem }),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
async function resolveUpdateMethod(selection, source, snapshot, options) {
|
|
164
|
+
const configured = selection.methods?.update;
|
|
165
|
+
if (configured !== undefined)
|
|
166
|
+
return parseSourceInstallationMethod(configured);
|
|
167
|
+
const operations = options.sourceOperations;
|
|
168
|
+
if (!operations?.readInstallationMethod || !source)
|
|
169
|
+
return undefined;
|
|
170
|
+
const metadata = await operations.readInstallationMethod(source, selection.path, snapshot.files);
|
|
171
|
+
return metadata === undefined ? undefined : parseSourceInstallationMethod(metadata);
|
|
172
|
+
}
|
|
173
|
+
async function preparePersistedSelection(originalSelection, options) {
|
|
174
|
+
const identity = JSON.stringify([originalSelection.source, originalSelection.path]);
|
|
175
|
+
if (options.scope === "project") {
|
|
176
|
+
const store = options.projectManifestStore ?? new ProjectManifestStore(path.join(options.projectRoot, "agent-depot.json"));
|
|
177
|
+
const manifest = await store.load();
|
|
178
|
+
if (!manifest.skills.some((selection) => JSON.stringify([selection.source, selection.path]) === identity)) {
|
|
179
|
+
throw new Error(`Project manifest does not contain Skill ${JSON.stringify(originalSelection.path)}`);
|
|
180
|
+
}
|
|
181
|
+
let applied = false;
|
|
182
|
+
return {
|
|
183
|
+
apply: async (updatedSelection) => {
|
|
184
|
+
applied = true;
|
|
185
|
+
const next = parseProjectManifest({
|
|
186
|
+
version: 1,
|
|
187
|
+
skills: manifest.skills.map((selection) => JSON.stringify([selection.source, selection.path]) === identity ? updatedSelection : selection),
|
|
188
|
+
});
|
|
189
|
+
await store.save(next);
|
|
190
|
+
},
|
|
191
|
+
rollback: async () => {
|
|
192
|
+
if (applied)
|
|
193
|
+
await store.save(manifest);
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
const operations = options.sourceOperations;
|
|
198
|
+
if (!operations?.updateUserGlobalInstallation) {
|
|
199
|
+
throw new Error("Configured Source operations cannot persist user-global Skill updates");
|
|
200
|
+
}
|
|
201
|
+
const installations = await options.sourceOperations?.listUserGlobalInstallations?.();
|
|
202
|
+
const previous = installations?.find((selection) => JSON.stringify([selection.source, selection.path]) === identity) ?? originalSelection;
|
|
203
|
+
let applied = false;
|
|
204
|
+
return {
|
|
205
|
+
apply: async (updatedSelection) => {
|
|
206
|
+
applied = true;
|
|
207
|
+
await operations.updateUserGlobalInstallation(updatedSelection);
|
|
208
|
+
},
|
|
209
|
+
rollback: async () => {
|
|
210
|
+
if (applied)
|
|
211
|
+
await operations.updateUserGlobalInstallation(previous);
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
export function selectionWithInstallation(selection, result, projectRoot) {
|
|
216
|
+
return parseProjectManifest({
|
|
217
|
+
version: 1,
|
|
218
|
+
skills: [{
|
|
219
|
+
...selection,
|
|
220
|
+
installation: {
|
|
221
|
+
path: relativeProjectPath(projectRoot, result.canonicalPath),
|
|
222
|
+
adopted: result.adopted,
|
|
223
|
+
...(result.resolvedVersion === undefined ? {} : { resolvedVersion: result.resolvedVersion }),
|
|
224
|
+
baseline: result.baseline,
|
|
225
|
+
},
|
|
226
|
+
}],
|
|
227
|
+
}).skills[0];
|
|
228
|
+
}
|
|
229
|
+
export function relativeProjectPath(projectRoot, candidate) {
|
|
230
|
+
const relative = path.relative(path.resolve(projectRoot), path.resolve(candidate));
|
|
231
|
+
if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
232
|
+
throw new Error(`Installed Skill location escapes the scope root: ${candidate}`);
|
|
233
|
+
}
|
|
234
|
+
return relative.split(path.sep).join("/");
|
|
235
|
+
}
|
|
236
|
+
function immutableSelection(selection) {
|
|
237
|
+
const parsed = parseProjectManifest({ version: 1, skills: [selection] }).skills[0];
|
|
238
|
+
if (!parsed)
|
|
239
|
+
throw new Error("Skill update selection is missing");
|
|
240
|
+
return parsed;
|
|
241
|
+
}
|
|
242
|
+
function errorMessage(error) {
|
|
243
|
+
return error instanceof Error ? error.message : String(error);
|
|
244
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { mkdir, readFile, rmdir, unlink } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { writeFileAtomically } from "./atomic-file.js";
|
|
6
|
+
import { canonicalizeGitSourceUrl, sourceIdForUrl } from "./git-source.js";
|
|
7
|
+
import { parseProjectManifest } from "./project-manifest.js";
|
|
8
|
+
const EMPTY_STATE = Object.freeze({
|
|
9
|
+
version: 1,
|
|
10
|
+
gitSources: Object.freeze([]),
|
|
11
|
+
userGlobalInstallations: Object.freeze([]),
|
|
12
|
+
});
|
|
13
|
+
function isNonEmptyString(value) {
|
|
14
|
+
return typeof value === "string" && value.length > 0;
|
|
15
|
+
}
|
|
16
|
+
export class SourceStateError extends Error {
|
|
17
|
+
constructor(statePath, reason) {
|
|
18
|
+
super(`Source state at ${JSON.stringify(statePath)} is invalid: ${reason}. The file was not modified; inspect or repair it before retrying.`);
|
|
19
|
+
this.name = "SourceStateError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export class SourceStateLockError extends Error {
|
|
23
|
+
constructor(statePath) {
|
|
24
|
+
super(`Source state at ${JSON.stringify(statePath)} is busy; no changes were made. Retry after the other process finishes.`);
|
|
25
|
+
this.name = "SourceStateLockError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const LOCK_TIMEOUT_MS = 5_000;
|
|
29
|
+
const LOCK_RETRY_MS = 10;
|
|
30
|
+
function parseGitSources(items, statePath) {
|
|
31
|
+
const gitSources = [];
|
|
32
|
+
const ids = new Set();
|
|
33
|
+
for (const [index, item] of items.entries()) {
|
|
34
|
+
const source = parseGitSource(item, index, statePath);
|
|
35
|
+
if (ids.has(source.id)) {
|
|
36
|
+
throw new SourceStateError(statePath, `gitSources[${index}] duplicates another Source`);
|
|
37
|
+
}
|
|
38
|
+
ids.add(source.id);
|
|
39
|
+
gitSources.push(source);
|
|
40
|
+
}
|
|
41
|
+
return gitSources;
|
|
42
|
+
}
|
|
43
|
+
function parseGitSource(item, index, statePath) {
|
|
44
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) {
|
|
45
|
+
throw new SourceStateError(statePath, `gitSources[${index}] must be an object`);
|
|
46
|
+
}
|
|
47
|
+
const source = item;
|
|
48
|
+
const sourceKeys = Object.keys(source);
|
|
49
|
+
if (sourceKeys.some((key) => key !== "id" && key !== "kind" && key !== "url")) {
|
|
50
|
+
throw new SourceStateError(statePath, `gitSources[${index}] contains unsupported fields`);
|
|
51
|
+
}
|
|
52
|
+
if (source.kind !== "git" || !isNonEmptyString(source.id) || !isNonEmptyString(source.url)) {
|
|
53
|
+
throw new SourceStateError(statePath, `gitSources[${index}] is not a valid Git Source`);
|
|
54
|
+
}
|
|
55
|
+
let url;
|
|
56
|
+
try {
|
|
57
|
+
url = canonicalizeGitSourceUrl(source.url);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
const reason = error instanceof Error ? error.message : "the URL is invalid";
|
|
61
|
+
throw new SourceStateError(statePath, `gitSources[${index}] has an invalid URL (${reason})`);
|
|
62
|
+
}
|
|
63
|
+
const id = sourceIdForUrl(url);
|
|
64
|
+
if (source.id !== id) {
|
|
65
|
+
throw new SourceStateError(statePath, `gitSources[${index}] has an invalid identity`);
|
|
66
|
+
}
|
|
67
|
+
return Object.freeze({ id, kind: "git", url });
|
|
68
|
+
}
|
|
69
|
+
function parseUserGlobalInstallations(items, statePath) {
|
|
70
|
+
const userGlobalInstallations = [];
|
|
71
|
+
const installationIdentities = new Set();
|
|
72
|
+
for (const [index, item] of items.entries()) {
|
|
73
|
+
try {
|
|
74
|
+
const parsed = parseUserGlobalInstallation(item, statePath);
|
|
75
|
+
const identity = JSON.stringify([parsed.source, parsed.path]);
|
|
76
|
+
if (installationIdentities.has(identity)) {
|
|
77
|
+
throw new Error("the installation duplicates another user-global Skill");
|
|
78
|
+
}
|
|
79
|
+
installationIdentities.add(identity);
|
|
80
|
+
userGlobalInstallations.push(parsed);
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
const reason = error instanceof Error ? error.message : "the installation record is invalid";
|
|
84
|
+
throw new SourceStateError(statePath, `userGlobalInstallations[${index}] is invalid (${reason})`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return userGlobalInstallations;
|
|
88
|
+
}
|
|
89
|
+
function parseUserGlobalInstallation(item, statePath) {
|
|
90
|
+
const parsed = parseProjectManifest({ version: 1, skills: [item] }, statePath).skills[0];
|
|
91
|
+
if (!parsed) {
|
|
92
|
+
throw new Error("the installation record is missing");
|
|
93
|
+
}
|
|
94
|
+
if (parsed.installation === undefined) {
|
|
95
|
+
throw new Error("the installation record must include an installation location");
|
|
96
|
+
}
|
|
97
|
+
return parsed;
|
|
98
|
+
}
|
|
99
|
+
function parseState(value, statePath) {
|
|
100
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
101
|
+
throw new SourceStateError(statePath, "the root value must be an object");
|
|
102
|
+
}
|
|
103
|
+
const candidate = value;
|
|
104
|
+
const keys = Object.keys(candidate);
|
|
105
|
+
if (keys.some((key) => key !== "version" && key !== "gitSources" && key !== "userGlobalInstallations")) {
|
|
106
|
+
throw new SourceStateError(statePath, "the state contains unsupported fields");
|
|
107
|
+
}
|
|
108
|
+
if (candidate.version !== 1) {
|
|
109
|
+
throw new SourceStateError(statePath, "the state version is unsupported");
|
|
110
|
+
}
|
|
111
|
+
if (!Array.isArray(candidate.gitSources)) {
|
|
112
|
+
throw new SourceStateError(statePath, "gitSources must be an array");
|
|
113
|
+
}
|
|
114
|
+
if (candidate.userGlobalInstallations !== undefined && !Array.isArray(candidate.userGlobalInstallations)) {
|
|
115
|
+
throw new SourceStateError(statePath, "userGlobalInstallations must be an array");
|
|
116
|
+
}
|
|
117
|
+
const gitSources = parseGitSources(candidate.gitSources, statePath);
|
|
118
|
+
const userGlobalInstallations = parseUserGlobalInstallations(candidate.userGlobalInstallations ?? [], statePath);
|
|
119
|
+
return Object.freeze({
|
|
120
|
+
version: 1,
|
|
121
|
+
gitSources: Object.freeze(gitSources),
|
|
122
|
+
userGlobalInstallations: Object.freeze(userGlobalInstallations),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
export function defaultSourceStatePath(environment = process.env, platform = process.platform, homeDirectory = homedir()) {
|
|
126
|
+
const pathApi = platform === "win32" ? path.win32 : path.posix;
|
|
127
|
+
if (platform === "win32") {
|
|
128
|
+
const appData = environment.APPDATA || environment.LOCALAPPDATA;
|
|
129
|
+
return pathApi.join(appData || pathApi.join(homeDirectory, "AppData", "Roaming"), "Agent Depot", "sources.json");
|
|
130
|
+
}
|
|
131
|
+
const stateHome = environment.XDG_STATE_HOME;
|
|
132
|
+
return pathApi.join(stateHome && pathApi.isAbsolute(stateHome) ? stateHome : pathApi.join(homeDirectory, ".local", "state"), "agent-depot", "sources.json");
|
|
133
|
+
}
|
|
134
|
+
export class SourceStateStore {
|
|
135
|
+
path;
|
|
136
|
+
constructor(statePath = defaultSourceStatePath()) {
|
|
137
|
+
this.path = statePath;
|
|
138
|
+
}
|
|
139
|
+
async load() {
|
|
140
|
+
const release = await this.acquireLock();
|
|
141
|
+
try {
|
|
142
|
+
return await this.loadUnlocked();
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
await release();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
async update(mutate) {
|
|
149
|
+
const release = await this.acquireLock();
|
|
150
|
+
try {
|
|
151
|
+
const state = await this.loadUnlocked();
|
|
152
|
+
const update = await mutate(state);
|
|
153
|
+
await this.saveUnlocked(update.state);
|
|
154
|
+
return update.result;
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
await release();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
async save(state) {
|
|
161
|
+
const release = await this.acquireLock();
|
|
162
|
+
try {
|
|
163
|
+
await this.saveUnlocked(state);
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
await release();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/** Removes the persisted catalog/configuration file without touching caches. */
|
|
170
|
+
async remove() {
|
|
171
|
+
const release = await this.acquireLock();
|
|
172
|
+
try {
|
|
173
|
+
await unlink(this.path).catch((error) => {
|
|
174
|
+
if (!isMissingFile(error))
|
|
175
|
+
throw error;
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
finally {
|
|
179
|
+
await release();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
async loadUnlocked() {
|
|
183
|
+
let contents;
|
|
184
|
+
try {
|
|
185
|
+
contents = await readFile(this.path, "utf8");
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
if (isMissingFile(error)) {
|
|
189
|
+
return EMPTY_STATE;
|
|
190
|
+
}
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
let value;
|
|
194
|
+
try {
|
|
195
|
+
value = JSON.parse(contents);
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
const reason = error instanceof Error ? error.message : "the JSON could not be parsed";
|
|
199
|
+
throw new SourceStateError(this.path, `malformed JSON (${reason})`);
|
|
200
|
+
}
|
|
201
|
+
return parseState(value, this.path);
|
|
202
|
+
}
|
|
203
|
+
async saveUnlocked(state) {
|
|
204
|
+
const normalized = parseState(state, this.path);
|
|
205
|
+
await mkdir(path.dirname(this.path), { recursive: true });
|
|
206
|
+
const contents = `${JSON.stringify(normalized, null, 2)}\n`;
|
|
207
|
+
await writeFileAtomically(this.path, contents, { allowLockedReplaceFallback: true });
|
|
208
|
+
}
|
|
209
|
+
async acquireLock() {
|
|
210
|
+
const lockPath = `${this.path}.lock`;
|
|
211
|
+
await mkdir(path.dirname(this.path), { recursive: true });
|
|
212
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
213
|
+
while (true) {
|
|
214
|
+
try {
|
|
215
|
+
await mkdir(lockPath);
|
|
216
|
+
return async () => {
|
|
217
|
+
await rmdir(lockPath);
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
if (!isAlreadyExists(error)) {
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
if (Date.now() >= deadline) {
|
|
225
|
+
throw new SourceStateLockError(this.path);
|
|
226
|
+
}
|
|
227
|
+
await delay(LOCK_RETRY_MS);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
function errorCode(error) {
|
|
233
|
+
return typeof error === "object" && error !== null && "code" in error ? error.code : undefined;
|
|
234
|
+
}
|
|
235
|
+
function isMissingFile(error) {
|
|
236
|
+
return errorCode(error) === "ENOENT";
|
|
237
|
+
}
|
|
238
|
+
function isAlreadyExists(error) {
|
|
239
|
+
return errorCode(error) === "EEXIST";
|
|
240
|
+
}
|