dsh-agent-plugins-market 0.5.3 → 0.6.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/README.md +29 -3
- package/README.zh.md +29 -3
- package/client/client.js +1378 -202
- package/client/client.js.map +1 -1
- package/lib/application/catalog.js +537 -71
- package/lib/application/details.js +39 -12
- package/lib/catalog/archive.js +341 -0
- package/lib/catalog/git.js +85 -12
- package/lib/catalog/legacy-root-migration.js +51 -0
- package/lib/catalog/lsp-spec.js +87 -0
- package/lib/catalog/manifests.js +84 -32
- package/lib/catalog/paths.js +65 -7
- package/lib/catalog/scan-pipeline.js +50 -0
- package/lib/catalog/scan-resolvers.js +402 -0
- package/lib/catalog/source-catalog.js +22 -8
- package/lib/catalog/suite-scanner.js +5 -176
- package/lib/catalog/surfaces.js +80 -30
- package/lib/contracts/lsp-status.js +2 -0
- package/lib/contracts/market.js +8 -1
- package/lib/index.js +118 -13
- package/lib/model/state.js +14 -1
- package/lib/model/types.js +21 -2
- package/lib/routes.js +88 -6
- package/lib/runtime/commands-mounts.js +6 -2
- package/lib/runtime/hooks-mounts.js +9 -6
- package/lib/runtime/lsp-direct-config.js +44 -0
- package/lib/runtime/lsp-mounts.js +236 -0
- package/lib/runtime/lsp-status.js +96 -0
- package/lib/runtime/mcp-backend.js +60 -0
- package/lib/runtime/mcp-client/bridge.js +126 -0
- package/lib/runtime/mcp-client/config.js +42 -0
- package/lib/runtime/mcp-client/connection.js +380 -0
- package/lib/runtime/mcp-client/host-seams.js +88 -0
- package/lib/runtime/mcp-client/json-schema-subset.js +181 -0
- package/lib/runtime/mcp-client/oauth.js +379 -0
- package/lib/runtime/mcp-client/tools.js +432 -0
- package/lib/runtime/mcp-client/transport.js +103 -0
- package/lib/runtime/mcp-config.js +196 -54
- package/lib/runtime/mcp-credentials.js +20 -0
- package/lib/runtime/mcp-mounts.js +237 -27
- package/lib/runtime/mcp-overrides.js +64 -6
- package/lib/runtime/mcp-redaction.js +92 -0
- package/lib/runtime/mcp-status.js +76 -32
- package/lib/runtime/reconciler.js +35 -1
- package/lib/runtime/regions.js +56 -0
- package/lib/types/application/catalog.d.ts +191 -14
- package/lib/types/application/details.d.ts +1 -6
- package/lib/types/application/queries.d.ts +28 -1
- package/lib/types/catalog/archive.d.ts +48 -0
- package/lib/types/catalog/git.d.ts +27 -3
- package/lib/types/catalog/legacy-root-migration.d.ts +14 -0
- package/lib/types/catalog/lsp-spec.d.ts +14 -0
- package/lib/types/catalog/manifests.d.ts +16 -1
- package/lib/types/catalog/paths.d.ts +23 -2
- package/lib/types/catalog/scan-pipeline.d.ts +76 -0
- package/lib/types/catalog/scan-resolvers.d.ts +81 -0
- package/lib/types/catalog/source-catalog.d.ts +5 -0
- package/lib/types/catalog/suite-scanner.d.ts +14 -7
- package/lib/types/catalog/surfaces.d.ts +3 -3
- package/lib/types/contracts/lsp-status.d.ts +53 -0
- package/lib/types/contracts/market.d.ts +55 -1
- package/lib/types/contracts/mcp-status.d.ts +14 -2
- package/lib/types/index.d.ts +24 -8
- package/lib/types/model/types.d.ts +52 -3
- package/lib/types/runtime/lsp-direct-config.d.ts +12 -0
- package/lib/types/runtime/lsp-mounts.d.ts +89 -0
- package/lib/types/runtime/lsp-status.d.ts +30 -0
- package/lib/types/runtime/mcp-backend.d.ts +49 -0
- package/lib/types/runtime/mcp-client/bridge.d.ts +37 -0
- package/lib/types/runtime/mcp-client/config.d.ts +123 -0
- package/lib/types/runtime/mcp-client/connection.d.ts +82 -0
- package/lib/types/runtime/mcp-client/host-seams.d.ts +89 -0
- package/lib/types/runtime/mcp-client/json-schema-subset.d.ts +58 -0
- package/lib/types/runtime/mcp-client/oauth.d.ts +121 -0
- package/lib/types/runtime/mcp-client/tools.d.ts +136 -0
- package/lib/types/runtime/mcp-client/transport.d.ts +36 -0
- package/lib/types/runtime/mcp-config.d.ts +29 -12
- package/lib/types/runtime/mcp-credentials.d.ts +9 -0
- package/lib/types/runtime/mcp-mounts.d.ts +56 -6
- package/lib/types/runtime/mcp-overrides.d.ts +23 -4
- package/lib/types/runtime/mcp-redaction.d.ts +7 -0
- package/lib/types/runtime/mcp-status.d.ts +4 -2
- package/lib/types/runtime/reconciler.d.ts +22 -4
- package/lib/types/runtime/regions.d.ts +37 -0
- package/package.json +15 -11
- package/schemas/1.0.0/mcp.schema.json +22 -0
|
@@ -7,18 +7,26 @@
|
|
|
7
7
|
* project snapshots use their own persisted state files and share the same
|
|
8
8
|
* source-selection and suite-scanning rules.
|
|
9
9
|
*/
|
|
10
|
-
import { mkdir } from 'node:fs/promises';
|
|
10
|
+
import { mkdir, readdir, rename } from 'node:fs/promises';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import { repoName } from '../catalog/manifests.js';
|
|
13
|
-
import {
|
|
13
|
+
import { canonicalGitUrl } from '../catalog/scan-resolvers.js';
|
|
14
|
+
import { discoverSourceListWithNotes } from '../catalog/source-catalog.js';
|
|
14
15
|
import { discoverSuitesInSource } from '../catalog/suite-scanner.js';
|
|
15
|
-
import {
|
|
16
|
+
import { archiveInstall } from '../catalog/archive.js';
|
|
17
|
+
import { gitClone, gitCurrentBranch, gitHead, gitRemoteUrl, gitRemove, gitSetRemoteUrl, gitSync } from '../catalog/git.js';
|
|
16
18
|
import { buildMcpStatus } from '../runtime/mcp-status.js';
|
|
17
|
-
import {
|
|
19
|
+
import { buildLspStatus } from '../runtime/lsp-status.js';
|
|
20
|
+
import { loadLspServers, saveLspServers } from '../runtime/lsp-direct-config.js';
|
|
21
|
+
import { loadSuiteOverrides, mergeOverridePatch, saveSuiteOverrides } from '../runtime/mcp-overrides.js';
|
|
22
|
+
import { probeHostMcpClient } from '../runtime/mcp-backend.js';
|
|
23
|
+
import { githubCloneUrl, resolveRegion } from '../runtime/regions.js';
|
|
24
|
+
import { readLocalePreference } from '../runtime/host-locale.js';
|
|
25
|
+
import { redactMcpOverrides } from '../runtime/mcp-redaction.js';
|
|
18
26
|
import { buildSuiteDetail, readSkillContent } from './details.js';
|
|
19
|
-
import {
|
|
27
|
+
import { deriveSourceIdCandidates, expandHome, isDirectory, pathExists, qualifiedSuiteId, resolveProjectRoot, sanitizeId, sourceCheckoutDir, sourcesDir, STATE_FILE_NAME } from '../catalog/paths.js';
|
|
20
28
|
import { loadState, saveState, EMPTY_STATE } from '../model/state.js';
|
|
21
|
-
import { effectiveSurfaces, SUITE_SURFACE_KEYS } from '../model/types.js';
|
|
29
|
+
import { effectiveSurfaces, SUITE_SURFACE_KEYS, resolveSourceKind } from '../model/types.js';
|
|
22
30
|
export class Catalog {
|
|
23
31
|
options;
|
|
24
32
|
state = EMPTY_STATE;
|
|
@@ -28,23 +36,102 @@ export class Catalog {
|
|
|
28
36
|
listeners = new Set();
|
|
29
37
|
revision = 0;
|
|
30
38
|
userSnapshot;
|
|
39
|
+
userSnapshotExpiresAt = 0;
|
|
31
40
|
userSnapshotPromise;
|
|
41
|
+
/**
|
|
42
|
+
* Discovery scan cache: per-sources-fingerprint scan results with a TTL.
|
|
43
|
+
* Install / enable / surface toggles re-derive the snapshot from cached
|
|
44
|
+
* discovery (cheap mapping) instead of re-walking every checkout — a full
|
|
45
|
+
* rescan of a 2,500-suite catalog costs ~1s and UI mutations happen in
|
|
46
|
+
* bursts. Content-changing mutations (add / update / remove / adopt /
|
|
47
|
+
* refresh / acquire) set {@link scanCacheDirty}, which bypasses the cache
|
|
48
|
+
* until the next scan; the TTL bounds staleness for in-place working-tree
|
|
49
|
+
* edits of local sources.
|
|
50
|
+
*/
|
|
51
|
+
scanCache = new Map();
|
|
52
|
+
scanCacheDirty = true;
|
|
53
|
+
static SCAN_CACHE_TTL_MS = 30_000;
|
|
54
|
+
static SCAN_CACHE_MAX_ENTRIES = 8;
|
|
55
|
+
/**
|
|
56
|
+
* User-dimension snapshot TTL: bounded staleness for edits made outside the
|
|
57
|
+
* plugin (a new skill dropped into a local source's working tree, a hand
|
|
58
|
+
* edit in a project layout) becomes visible on the next read past the TTL
|
|
59
|
+
* instead of living until the next mutation. Configurable; a 0 value
|
|
60
|
+
* disables snapshot caching entirely.
|
|
61
|
+
*/
|
|
62
|
+
userSnapshotTtlMs;
|
|
32
63
|
/** Project-dimension snapshots keyed by project root, with TTL freshness. */
|
|
33
64
|
projectSnapshots = new Map();
|
|
34
65
|
projectSnapshotPromises = new Map();
|
|
35
66
|
projectSnapshotTtlMs;
|
|
36
67
|
/** Latest MCP mount diagnostics (suiteId -> reasons), fed by host reconcile. */
|
|
37
68
|
mcpDiagnostics = [];
|
|
69
|
+
/** Latest LSP mount diagnostics source, wired by the host entry. */
|
|
70
|
+
lspStatusSource;
|
|
71
|
+
/** Credential-store seam for dropping a grant record (MCP re-authorize). */
|
|
72
|
+
credentialsStore;
|
|
38
73
|
toolSnapshotProvider = () => [];
|
|
74
|
+
/** Mount-registry seam: flags a mount key for an explicit rebuild (re-authorize). */
|
|
75
|
+
mcpRemount = () => { };
|
|
76
|
+
/** Resolves the mount key owning one derived serverName; absent when the server is not mounted. */
|
|
77
|
+
mcpServerOwner = () => undefined;
|
|
78
|
+
/** Backend source (the host settings namespace scope); default is the built-in client. */
|
|
79
|
+
mcpBackendProvider = async () => 'builtin';
|
|
80
|
+
/** Backend writer (a scope update); absent until the settings service resolves. */
|
|
81
|
+
mcpBackendWriter = async () => {
|
|
82
|
+
throw new Error('the settings service is not mounted');
|
|
83
|
+
};
|
|
84
|
+
/** Download-region source (the host settings namespace scope). */
|
|
85
|
+
downloadRegionProvider = async () => 'auto';
|
|
39
86
|
constructor(options) {
|
|
40
87
|
this.options = options;
|
|
41
88
|
this.statePath = join(options.userRoot, STATE_FILE_NAME);
|
|
42
89
|
this.projectSnapshotTtlMs = options.projectSnapshotTtlMs ?? 5_000;
|
|
90
|
+
this.userSnapshotTtlMs = options.userSnapshotTtlMs ?? Catalog.SCAN_CACHE_TTL_MS;
|
|
43
91
|
}
|
|
44
92
|
/** Install the host tool snapshot provider used by the MCP status surface. */
|
|
45
93
|
setMcpToolSnapshotProvider(provider) {
|
|
46
94
|
this.toolSnapshotProvider = provider;
|
|
47
95
|
}
|
|
96
|
+
/** Install the credential-store seam backing the MCP re-authorize action. */
|
|
97
|
+
setCredentialsStore(store) {
|
|
98
|
+
this.credentialsStore = store;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Drop one MCP server's OAuth grant record so the next mount re-runs the
|
|
102
|
+
* browser authorization — the path for "I picked too narrow a scope".
|
|
103
|
+
* @param serverName - the derived serverName whose folded form keys the record.
|
|
104
|
+
* @throws when the credentials service is not mounted.
|
|
105
|
+
*/
|
|
106
|
+
async reauthorizeMcpServer(serverName) {
|
|
107
|
+
await this.enqueue(async () => {
|
|
108
|
+
await this.credentialsStore?.deleteGrantRecord(serverName);
|
|
109
|
+
// Dropping a grant changes no resolved config field, so a fingerprint
|
|
110
|
+
// reconcile would keep the live bridge — with its in-memory tokens —
|
|
111
|
+
// untouched. The serverName maps back to exactly one mount key (the
|
|
112
|
+
// registry reserved the name), so flag it for an explicit rebuild.
|
|
113
|
+
const key = this.mcpServerOwner(serverName);
|
|
114
|
+
if (key !== undefined)
|
|
115
|
+
this.mcpRemount?.(key.suiteId, key.serverKey);
|
|
116
|
+
await this.notifyChanged(true);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Install the MCP remount seam used by re-authorize: the registry flags the
|
|
121
|
+
* mount so the next reconcile tears the live bridge down and rebuilds it.
|
|
122
|
+
*/
|
|
123
|
+
setMcpRemountHook(hook, serverOwner) {
|
|
124
|
+
this.mcpRemount = hook;
|
|
125
|
+
this.mcpServerOwner = serverOwner;
|
|
126
|
+
}
|
|
127
|
+
/** Whether the re-authorize action can run in this composition. */
|
|
128
|
+
mcpReauthorizeAvailable() {
|
|
129
|
+
return this.credentialsStore !== undefined;
|
|
130
|
+
}
|
|
131
|
+
/** Install the LSP mount status source used by the LSP status surface. */
|
|
132
|
+
setLspStatusSource(source) {
|
|
133
|
+
this.lspStatusSource = source;
|
|
134
|
+
}
|
|
48
135
|
/** Subscribe to completed catalog mutations; returns an unsubscribe function. */
|
|
49
136
|
subscribe(listener) {
|
|
50
137
|
this.listeners.add(listener);
|
|
@@ -55,12 +142,31 @@ export class Catalog {
|
|
|
55
142
|
const snapshot = await this.readUserCatalog();
|
|
56
143
|
return buildMcpStatus(snapshot.suites, this.mcpDiagnostics, this.toolSnapshotProvider(), await this.allMcpOverrides());
|
|
57
144
|
}
|
|
58
|
-
/**
|
|
59
|
-
async
|
|
145
|
+
/** The LSP status surface: declared servers merged with mount diagnostics. */
|
|
146
|
+
async lspStatus() {
|
|
60
147
|
const snapshot = await this.readUserCatalog();
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
148
|
+
const direct = await loadLspServers(this.options.dataRoot);
|
|
149
|
+
return buildLspStatus(snapshot.suites, this.lspStatusSource ?? { diagnosticsSnapshot: () => new Map(), hasLiveMounts: () => false }, direct);
|
|
150
|
+
}
|
|
151
|
+
/** The user's direct LSP server table (normalized specs). */
|
|
152
|
+
async lspServers() {
|
|
153
|
+
return (await loadLspServers(this.options.dataRoot)).servers;
|
|
154
|
+
}
|
|
155
|
+
/** Validate and persist the user's direct LSP server table. */
|
|
156
|
+
async setLspServers(raw) {
|
|
157
|
+
return this.enqueue(async () => {
|
|
158
|
+
const { servers } = await saveLspServers(this.options.dataRoot, raw);
|
|
159
|
+
await this.notifyChanged(true);
|
|
160
|
+
return servers;
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
/** One suite's persisted MCP overrides, addressed by qualified suite id. */
|
|
164
|
+
async mcpOverrides(sourceId, suiteId) {
|
|
165
|
+
const suiteKey = qualifiedSuiteId(sourceId, suiteId);
|
|
166
|
+
const snapshot = await this.readUserCatalog();
|
|
167
|
+
if (!snapshot.suites.some(suite => suite.sourceId === sourceId && suite.id === suiteId))
|
|
168
|
+
throw new Error(`suite "${suiteKey}" not found`);
|
|
169
|
+
return redactMcpOverrides(await loadSuiteOverrides(this.options.dataRoot, suiteKey));
|
|
64
170
|
}
|
|
65
171
|
/**
|
|
66
172
|
* Set or clear one server's MCP override and remount it. `override === null`
|
|
@@ -69,43 +175,93 @@ export class Catalog {
|
|
|
69
175
|
*/
|
|
70
176
|
async setMcpOverride(sourceId, suiteId, serverKey, override) {
|
|
71
177
|
return this.enqueue(async () => {
|
|
178
|
+
const suiteKey = qualifiedSuiteId(sourceId, suiteId);
|
|
72
179
|
const source = this.state.sources.find(entry => entry.id === sourceId);
|
|
73
180
|
if (source === undefined)
|
|
74
181
|
throw new Error(`unknown source "${sourceId}"`);
|
|
75
182
|
const checkout = this.sourceCheckoutPath(source);
|
|
76
183
|
if (!(await isDirectory(checkout)))
|
|
77
|
-
await this.
|
|
78
|
-
const suites = await discoverSuitesInSource(checkout, sourceId, 'user');
|
|
184
|
+
await this.acquire(source);
|
|
185
|
+
const suites = await discoverSuitesInSource(checkout, sourceId, 'user', source.url);
|
|
79
186
|
const suite = suites.find(entry => entry.id === suiteId);
|
|
80
187
|
if (suite === undefined)
|
|
81
188
|
throw new Error(`suite "${suiteId}" not found in source "${sourceId}"`);
|
|
82
189
|
if (suite.mcp?.servers[serverKey] === undefined)
|
|
83
190
|
throw new Error(`server "${serverKey}" is not defined by suite "${suiteId}"`);
|
|
84
|
-
|
|
191
|
+
// Override files are keyed by the qualified id so two sources' same-named
|
|
192
|
+
// suites never share one override record.
|
|
193
|
+
const overrides = await loadSuiteOverrides(this.options.dataRoot, suiteKey);
|
|
85
194
|
if (override === null) {
|
|
86
195
|
delete overrides[serverKey];
|
|
87
196
|
}
|
|
88
197
|
else {
|
|
89
|
-
|
|
198
|
+
// Merge onto the stored record: the UI never receives literal secret
|
|
199
|
+
// values, so a verbatim write would drop keys it simply redacted.
|
|
200
|
+
overrides[serverKey] = mergeOverridePatch(overrides[serverKey] ?? {}, override);
|
|
90
201
|
}
|
|
91
|
-
await saveSuiteOverrides(this.options.dataRoot,
|
|
92
|
-
this.notifyChanged();
|
|
202
|
+
await saveSuiteOverrides(this.options.dataRoot, suiteKey, overrides);
|
|
203
|
+
await this.notifyChanged(true);
|
|
93
204
|
});
|
|
94
205
|
}
|
|
95
|
-
/**
|
|
206
|
+
/**
|
|
207
|
+
* Re-run the MCP reconcile pass: retries failed mounts and clears residual
|
|
208
|
+
* tools, without changing any catalog state.
|
|
209
|
+
*/
|
|
210
|
+
async retryMounts() {
|
|
211
|
+
await this.notifyChanged(true);
|
|
212
|
+
}
|
|
213
|
+
/** The persisted backend choice without the host-client probe (mount-time provider). */
|
|
214
|
+
async mcpBackend() {
|
|
215
|
+
return this.mcpBackendProvider();
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* The active MCP mount backend plus a live probe of the host client, and
|
|
219
|
+
* the download-region setting with its locale-resolved effective route —
|
|
220
|
+
* everything the plugin-config card renders.
|
|
221
|
+
*/
|
|
222
|
+
async mcpBackendInfo() {
|
|
223
|
+
const [backend, hostClient, regionSetting, locale] = await Promise.all([this.mcpBackendProvider(), probeHostMcpClient(), this.downloadRegionProvider(), readLocalePreference()]);
|
|
224
|
+
return { backend, hostClient, downloadRegion: { setting: regionSetting, effective: resolveRegion(regionSetting, locale) } };
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Switch the MCP mount backend and remount every suite server through it.
|
|
228
|
+
* A host-client switch takes effect only where the host package resolves;
|
|
229
|
+
* unresolvable or unsupported transports surface as per-server diagnostics.
|
|
230
|
+
*/
|
|
231
|
+
async setMcpBackend(backend) {
|
|
232
|
+
return this.enqueue(async () => {
|
|
233
|
+
await this.mcpBackendWriter(backend);
|
|
234
|
+
await this.notifyChanged(true);
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
/** Install the backend source (the host settings namespace's scope). */
|
|
238
|
+
setMcpBackendProvider(provider) {
|
|
239
|
+
this.mcpBackendProvider = provider;
|
|
240
|
+
}
|
|
241
|
+
/** Install the backend writer (a scope update on the same namespace). */
|
|
242
|
+
setMcpBackendWriter(writer) {
|
|
243
|
+
this.mcpBackendWriter = writer;
|
|
244
|
+
}
|
|
245
|
+
/** Install the download-region source (the host settings namespace scope). */
|
|
246
|
+
setDownloadRegionProvider(provider) {
|
|
247
|
+
this.downloadRegionProvider = provider;
|
|
248
|
+
}
|
|
249
|
+
/** All persisted MCP overrides keyed by qualified suite id (mount-time provider). */
|
|
96
250
|
async allMcpOverrides() {
|
|
97
251
|
const snapshot = await this.readUserCatalog();
|
|
98
252
|
const map = new Map();
|
|
99
253
|
for (const suite of snapshot.suites) {
|
|
100
|
-
const
|
|
254
|
+
const suiteKey = qualifiedSuiteId(suite.sourceId, suite.id);
|
|
255
|
+
const overrides = await loadSuiteOverrides(this.options.dataRoot, suiteKey);
|
|
101
256
|
if (Object.keys(overrides).length > 0)
|
|
102
|
-
map.set(
|
|
257
|
+
map.set(suiteKey, overrides);
|
|
103
258
|
}
|
|
104
259
|
return map;
|
|
105
260
|
}
|
|
106
261
|
/** Load persisted user state once at plugin activation. */
|
|
107
262
|
async load() {
|
|
108
263
|
this.state = await loadState(this.statePath);
|
|
264
|
+
this.scanCacheDirty = true;
|
|
109
265
|
this.invalidateSnapshot(false);
|
|
110
266
|
}
|
|
111
267
|
get sources() {
|
|
@@ -117,11 +273,16 @@ export class Catalog {
|
|
|
117
273
|
}
|
|
118
274
|
/** Read one coherent user-dimension snapshot, reusing in-flight discovery. */
|
|
119
275
|
async readUserCatalog() {
|
|
120
|
-
|
|
276
|
+
// TTL 0 disables snapshot caching: every read observes fresh discovery,
|
|
277
|
+
// mirroring the project dimension's caching-disabled semantics.
|
|
278
|
+
if (this.userSnapshotTtlMs <= 0)
|
|
279
|
+
return this.buildSnapshot(this.state, 'user', this.options.userRoot);
|
|
280
|
+
if (this.userSnapshot !== undefined && Date.now() < this.userSnapshotExpiresAt)
|
|
121
281
|
return this.userSnapshot;
|
|
122
282
|
this.userSnapshotPromise ??= this.buildSnapshot(this.state, 'user', this.options.userRoot)
|
|
123
283
|
.then(snapshot => {
|
|
124
284
|
this.userSnapshot = snapshot;
|
|
285
|
+
this.userSnapshotExpiresAt = Date.now() + this.userSnapshotTtlMs;
|
|
125
286
|
return snapshot;
|
|
126
287
|
})
|
|
127
288
|
.finally(() => {
|
|
@@ -153,7 +314,9 @@ export class Catalog {
|
|
|
153
314
|
}
|
|
154
315
|
async buildProjectSnapshot(projectRoot) {
|
|
155
316
|
const state = await loadState(join(projectRoot, STATE_FILE_NAME));
|
|
156
|
-
|
|
317
|
+
// TTL 0 means caching is disabled for this dimension: bypass the scan
|
|
318
|
+
// cache so every read observes the working tree as it stands.
|
|
319
|
+
return this.buildSnapshot(state, 'project', projectRoot, this.projectSnapshotTtlMs <= 0);
|
|
157
320
|
}
|
|
158
321
|
/** One suite's full detail for the market detail modal. */
|
|
159
322
|
async suiteDetail(sourceId, suiteId) {
|
|
@@ -161,7 +324,8 @@ export class Catalog {
|
|
|
161
324
|
const suite = snapshot.suites.find(entry => entry.sourceId === sourceId && entry.id === suiteId);
|
|
162
325
|
if (suite === undefined)
|
|
163
326
|
throw new Error(`suite "${suiteId}" not found in source "${sourceId}"`);
|
|
164
|
-
|
|
327
|
+
const suiteKey = qualifiedSuiteId(sourceId, suiteId);
|
|
328
|
+
return buildSuiteDetail(suite, this.state.installed[installKey(sourceId, suiteId)], this.mcpDiagnostics, await loadSuiteOverrides(this.options.dataRoot, suiteKey));
|
|
165
329
|
}
|
|
166
330
|
/** One skill's full SKILL.md text for the market detail modal. */
|
|
167
331
|
async skillContent(sourceId, suiteId, skillName) {
|
|
@@ -178,14 +342,19 @@ export class Catalog {
|
|
|
178
342
|
for (const source of snapshot.sources) {
|
|
179
343
|
const inFlight = this.currentSourceState?.sourceId === source.id ? this.currentSourceState : undefined;
|
|
180
344
|
const checkout = this.sourceCheckoutPath(source);
|
|
345
|
+
const kind = resolveSourceKind(source);
|
|
181
346
|
let cloned = false;
|
|
182
347
|
let lockCommit;
|
|
183
348
|
let error;
|
|
184
|
-
if (
|
|
349
|
+
if (kind === 'local') {
|
|
185
350
|
cloned = await isDirectory(checkout);
|
|
186
351
|
if (!cloned)
|
|
187
352
|
error = `local source directory ${checkout} is missing`;
|
|
188
353
|
}
|
|
354
|
+
else if (kind === 'archive') {
|
|
355
|
+
cloned = await isDirectory(checkout);
|
|
356
|
+
lockCommit = this.headCache.get(source.id);
|
|
357
|
+
}
|
|
189
358
|
else if (inFlight !== undefined) {
|
|
190
359
|
// A mutation owns this source right now: do not race git against its checkout.
|
|
191
360
|
cloned = inFlight.cloned || (await isDirectory(checkout));
|
|
@@ -209,14 +378,18 @@ export class Catalog {
|
|
|
209
378
|
}
|
|
210
379
|
}
|
|
211
380
|
const sourceSuites = snapshot.suites.filter(suite => suite.sourceId === source.id);
|
|
381
|
+
const scanNotes = snapshot.scanNotes?.[source.id];
|
|
212
382
|
sourceRows.push({
|
|
213
383
|
id: source.id,
|
|
214
384
|
url: source.url,
|
|
215
385
|
...(source.branch === undefined ? {} : { branch: source.branch }),
|
|
216
386
|
...(source.local === true ? { local: true } : {}),
|
|
387
|
+
kind,
|
|
388
|
+
...(source.adopted === true ? { adopted: true } : {}),
|
|
217
389
|
cloned,
|
|
218
390
|
...(lockCommit === undefined ? {} : { lockCommit }),
|
|
219
391
|
...(error === undefined ? {} : { error }),
|
|
392
|
+
...(scanNotes === undefined ? {} : { scanNotes }),
|
|
220
393
|
suiteIds: sourceSuites.map(suite => suite.id)
|
|
221
394
|
});
|
|
222
395
|
}
|
|
@@ -246,7 +419,8 @@ export class Catalog {
|
|
|
246
419
|
installed: cards.filter(card => card.installed).length,
|
|
247
420
|
enabled: cards.filter(card => card.enabled).length
|
|
248
421
|
},
|
|
249
|
-
roots: { user: this.options.userRoot, data: this.options.dataRoot }
|
|
422
|
+
roots: { user: this.options.userRoot, data: this.options.dataRoot },
|
|
423
|
+
unmanaged: await this.unmanagedSources()
|
|
250
424
|
};
|
|
251
425
|
}
|
|
252
426
|
/** Enabled user-dimension suites from one user snapshot. */
|
|
@@ -263,25 +437,57 @@ export class Catalog {
|
|
|
263
437
|
const state = dimension === 'user' && dimensionRoot === this.options.userRoot ? this.state : await loadState(join(dimensionRoot, STATE_FILE_NAME));
|
|
264
438
|
return (await this.buildSnapshot(state, dimension, dimensionRoot)).suites;
|
|
265
439
|
}
|
|
266
|
-
/**
|
|
440
|
+
/**
|
|
441
|
+
* Add a source and acquire it immediately (clone, download, or in-place).
|
|
442
|
+
*
|
|
443
|
+
* Git sources with an already-present checkout whose `origin` matches the
|
|
444
|
+
* input URL are adopted in place — the manual-clone repair path — so no
|
|
445
|
+
* second clone is made and no `-2` suffixed id is invented. Adopted
|
|
446
|
+
* checkouts are never deleted on source removal.
|
|
447
|
+
*/
|
|
267
448
|
async addSource(input) {
|
|
268
449
|
return this.enqueue(async () => {
|
|
269
|
-
const
|
|
450
|
+
const kind = input.local === true ? 'local' : resolveSourceKind({ url: input.url, kind: input.kind });
|
|
451
|
+
if (kind === 'git') {
|
|
452
|
+
// Adoption first: a pre-existing checkout with a matching origin URL
|
|
453
|
+
// is registered as-is, skipping the clone entirely.
|
|
454
|
+
const adoptable = await this.findAdoptableCheckout(input.url, deriveSourceIdCandidates(input.url));
|
|
455
|
+
if (adoptable !== undefined) {
|
|
456
|
+
const source = {
|
|
457
|
+
id: adoptable,
|
|
458
|
+
url: input.url,
|
|
459
|
+
...(input.branch === undefined ? {} : { branch: input.branch }),
|
|
460
|
+
kind: 'git',
|
|
461
|
+
adopted: true
|
|
462
|
+
};
|
|
463
|
+
const head = await tryHead(sourceCheckoutDir(this.options.userRoot, adoptable));
|
|
464
|
+
if (head !== undefined)
|
|
465
|
+
this.headCache.set(source.id, head);
|
|
466
|
+
this.state = { ...this.state, sources: [...this.state.sources, source] };
|
|
467
|
+
await saveState(this.statePath, this.state);
|
|
468
|
+
await this.notifyChanged();
|
|
469
|
+
return source;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
const baseId = await this.pickSourceId(deriveSourceIdCandidates(input.url), kind === 'local');
|
|
270
473
|
const source = {
|
|
271
474
|
id: baseId,
|
|
272
475
|
url: input.url,
|
|
273
476
|
...(input.branch === undefined ? {} : { branch: input.branch }),
|
|
274
|
-
...(
|
|
477
|
+
...(kind === 'local' ? { local: true } : { kind }),
|
|
478
|
+
...(input.kind === 'archive' && input.sha256 !== undefined && input.sha256 !== '' ? { sha256: input.sha256 } : {})
|
|
275
479
|
};
|
|
276
|
-
|
|
277
|
-
if (
|
|
480
|
+
let checkout = this.sourceCheckoutPath(source);
|
|
481
|
+
if (kind === 'local') {
|
|
278
482
|
if (!(await isDirectory(checkout)))
|
|
279
483
|
throw new Error(`local source directory ${checkout} is missing`);
|
|
280
484
|
}
|
|
281
485
|
else {
|
|
282
|
-
this.beginSourceState(baseId, 'cloning', false);
|
|
486
|
+
this.beginSourceState(baseId, kind === 'archive' ? 'downloading' : 'cloning', false);
|
|
283
487
|
try {
|
|
284
|
-
await this.
|
|
488
|
+
const lock = await this.acquire(source);
|
|
489
|
+
if (lock !== undefined)
|
|
490
|
+
this.headCache.set(source.id, lock);
|
|
285
491
|
}
|
|
286
492
|
catch (error) {
|
|
287
493
|
this.endSourceState();
|
|
@@ -289,17 +495,112 @@ export class Catalog {
|
|
|
289
495
|
}
|
|
290
496
|
this.updateSourceStep('reading');
|
|
291
497
|
}
|
|
292
|
-
source
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
498
|
+
// The repo's own manifest names the source; move the checkout along so
|
|
499
|
+
// the registered id and its `.sources/` directory stay coherent. URL
|
|
500
|
+
// derived candidates stay as readable fallbacks before numeric suffixes.
|
|
501
|
+
const named = [...new Set([sanitizeId(await repoName(checkout)), ...deriveSourceIdCandidates(input.url)])];
|
|
502
|
+
const finalId = await this.pickSourceId(named, kind === 'local', kind === 'local' ? undefined : checkout);
|
|
503
|
+
if (kind !== 'local' && finalId !== baseId) {
|
|
504
|
+
const targetDir = sourceCheckoutDir(this.options.userRoot, finalId);
|
|
505
|
+
await rename(checkout, targetDir);
|
|
506
|
+
checkout = targetDir;
|
|
507
|
+
}
|
|
508
|
+
source.id = finalId;
|
|
509
|
+
if (resolveSourceKind(source) === 'git') {
|
|
510
|
+
const head = await tryHead(checkout);
|
|
511
|
+
if (head !== undefined)
|
|
512
|
+
this.headCache.set(source.id, head);
|
|
513
|
+
}
|
|
296
514
|
this.endSourceState();
|
|
297
515
|
this.state = { ...this.state, sources: [...this.state.sources, source] };
|
|
298
516
|
await saveState(this.statePath, this.state);
|
|
299
|
-
this.notifyChanged();
|
|
517
|
+
await this.notifyChanged();
|
|
300
518
|
return source;
|
|
301
519
|
});
|
|
302
520
|
}
|
|
521
|
+
/**
|
|
522
|
+
* First candidate id whose `.sources/` checkout already exists and whose
|
|
523
|
+
* `origin` remote matches the input URL (canonical-form equality) — the
|
|
524
|
+
* signature of a manual clone of the very same repository. Registered ids
|
|
525
|
+
* and checkouts of differently-origined directories never adopt.
|
|
526
|
+
*/
|
|
527
|
+
async findAdoptableCheckout(url, candidates) {
|
|
528
|
+
const registered = new Set(this.state.sources.map(source => source.id));
|
|
529
|
+
for (const candidate of candidates) {
|
|
530
|
+
if (registered.has(candidate))
|
|
531
|
+
continue;
|
|
532
|
+
const dir = sourceCheckoutDir(this.options.userRoot, candidate);
|
|
533
|
+
if (!(await isDirectory(dir)))
|
|
534
|
+
continue;
|
|
535
|
+
let origin;
|
|
536
|
+
try {
|
|
537
|
+
origin = await gitRemoteUrl(dir);
|
|
538
|
+
}
|
|
539
|
+
catch {
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
if (origin !== '' && canonicalGitUrl(origin) === canonicalGitUrl(url))
|
|
543
|
+
return candidate;
|
|
544
|
+
}
|
|
545
|
+
return undefined;
|
|
546
|
+
}
|
|
547
|
+
/** Register one unmanaged `.sources/` checkout as a source without touching its files. */
|
|
548
|
+
async adoptSource(id) {
|
|
549
|
+
return this.enqueue(async () => {
|
|
550
|
+
if (this.state.sources.some(source => source.id === id))
|
|
551
|
+
throw new Error(`source "${id}" is already registered`);
|
|
552
|
+
// The id becomes one path segment under `.sources/`: only flat safe
|
|
553
|
+
// names qualify. Anything else (separators, `..`, leading dots) could
|
|
554
|
+
// traverse out of the checkouts root and register an outside directory.
|
|
555
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(id) || id.includes('..')) {
|
|
556
|
+
throw new Error(`invalid checkout id "${id}" — use letters, digits, dots, dashes, or underscores`);
|
|
557
|
+
}
|
|
558
|
+
const dir = sourceCheckoutDir(this.options.userRoot, id);
|
|
559
|
+
if (!(await isDirectory(dir)))
|
|
560
|
+
throw new Error(`no checkout directory at ${dir}`);
|
|
561
|
+
let source;
|
|
562
|
+
try {
|
|
563
|
+
const origin = await gitRemoteUrl(dir);
|
|
564
|
+
source = { id, url: origin, kind: 'git', adopted: true };
|
|
565
|
+
const head = await tryHead(dir);
|
|
566
|
+
if (head !== undefined)
|
|
567
|
+
this.headCache.set(id, head);
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
// Not a git checkout: read it in place, like a local source.
|
|
571
|
+
source = { id, url: dir, local: true, adopted: true };
|
|
572
|
+
}
|
|
573
|
+
this.state = { ...this.state, sources: [...this.state.sources, source] };
|
|
574
|
+
await saveState(this.statePath, this.state);
|
|
575
|
+
await this.notifyChanged();
|
|
576
|
+
return source;
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
/** Unmanaged `.sources/` checkouts: present on disk, absent from state. */
|
|
580
|
+
async unmanagedSources() {
|
|
581
|
+
const checkoutRoot = sourcesDir(this.options.userRoot);
|
|
582
|
+
let entries;
|
|
583
|
+
try {
|
|
584
|
+
entries = await readdir(checkoutRoot, { withFileTypes: true });
|
|
585
|
+
}
|
|
586
|
+
catch {
|
|
587
|
+
return [];
|
|
588
|
+
}
|
|
589
|
+
const registered = new Set(this.state.sources.map(source => source.id));
|
|
590
|
+
const unmanaged = [];
|
|
591
|
+
for (const entry of entries) {
|
|
592
|
+
if (!entry.isDirectory() || entry.name.startsWith('.') || registered.has(entry.name))
|
|
593
|
+
continue;
|
|
594
|
+
const dir = join(checkoutRoot, entry.name);
|
|
595
|
+
try {
|
|
596
|
+
unmanaged.push({ id: entry.name, url: await gitRemoteUrl(dir) });
|
|
597
|
+
}
|
|
598
|
+
catch {
|
|
599
|
+
unmanaged.push({ id: entry.name });
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return unmanaged;
|
|
603
|
+
}
|
|
303
604
|
/** Progress snapshot of the source mutation currently in flight. */
|
|
304
605
|
currentSourceState;
|
|
305
606
|
/** Begin reporting progress for a source mutation. */
|
|
@@ -320,35 +621,64 @@ export class Catalog {
|
|
|
320
621
|
const state = this.currentSourceState;
|
|
321
622
|
return state === undefined ? { active: false, sourceId: '', step: '' } : { active: true, sourceId: state.sourceId, step: state.step };
|
|
322
623
|
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
624
|
+
/**
|
|
625
|
+
* First candidate id not claimed by a registered source; for git candidates
|
|
626
|
+
* the checkout directory must also be free, so a stale or foreign directory
|
|
627
|
+
* under `.sources/` is never cloned over (it would fail `git clone` with a
|
|
628
|
+
* confusing "destination exists" error). `ownedCheckout` marks the directory
|
|
629
|
+
* the in-flight mutation already controls, keeping a rename onto it legal.
|
|
630
|
+
* Local sources skip the disk check: they read their own path in place and
|
|
631
|
+
* never occupy `.sources/`.
|
|
632
|
+
*/
|
|
633
|
+
async pickSourceId(candidates, skipDisk, ownedCheckout) {
|
|
634
|
+
const free = async (id) => {
|
|
635
|
+
if (this.state.sources.some(source => source.id === id))
|
|
636
|
+
return false;
|
|
637
|
+
if (skipDisk)
|
|
638
|
+
return true;
|
|
639
|
+
const dir = sourceCheckoutDir(this.options.userRoot, id);
|
|
640
|
+
return dir === ownedCheckout || !(await pathExists(dir));
|
|
641
|
+
};
|
|
642
|
+
for (const candidate of candidates) {
|
|
643
|
+
if (await free(candidate))
|
|
644
|
+
return candidate;
|
|
645
|
+
}
|
|
326
646
|
for (let suffix = 2;; suffix++) {
|
|
327
|
-
const candidate = `${
|
|
328
|
-
if (
|
|
647
|
+
const candidate = `${candidates[0]}-${suffix}`;
|
|
648
|
+
if (await free(candidate))
|
|
329
649
|
return candidate;
|
|
330
650
|
}
|
|
331
651
|
}
|
|
332
|
-
/** Update one source's URL, branch, or
|
|
652
|
+
/** Update one source's URL, branch, kind, or archive digest. */
|
|
333
653
|
async updateSource(sourceId, patch) {
|
|
334
654
|
return this.enqueue(async () => {
|
|
335
655
|
const index = this.state.sources.findIndex(source => source.id === sourceId);
|
|
336
656
|
if (index === -1)
|
|
337
657
|
throw new Error(`unknown source "${sourceId}"`);
|
|
338
658
|
const current = this.state.sources[index];
|
|
659
|
+
const nextLocal = patch.local !== undefined ? patch.local : current.local === true;
|
|
660
|
+
// A local source carries the legacy flag only; `kind` stays unwritten
|
|
661
|
+
// so the persisted shape matches the pre-kind records.
|
|
662
|
+
const nextKind = nextLocal ? undefined : (patch.kind ?? current.kind);
|
|
339
663
|
const next = {
|
|
340
664
|
id: sourceId,
|
|
341
665
|
url: patch.url ?? current.url,
|
|
342
666
|
...(patch.branch !== undefined ? { branch: patch.branch } : current.branch === undefined ? {} : { branch: current.branch }),
|
|
343
|
-
...(
|
|
667
|
+
...(nextLocal ? { local: true } : {}),
|
|
668
|
+
...(nextKind === undefined ? {} : { kind: nextKind }),
|
|
669
|
+
...(patch.sha256 !== undefined ? (patch.sha256 === '' ? {} : { sha256: patch.sha256 }) : current.sha256 === undefined ? {} : { sha256: current.sha256 }),
|
|
670
|
+
...(current.adopted === true ? { adopted: true } : {})
|
|
344
671
|
};
|
|
345
|
-
|
|
672
|
+
const urlChanged = patch.url !== undefined && patch.url !== current.url;
|
|
673
|
+
// A URL change invalidates the old checkout — except for adopted (user
|
|
674
|
+
// cloned) and local sources, whose directories we never own.
|
|
675
|
+
if (urlChanged && current.local !== true && current.adopted !== true) {
|
|
346
676
|
this.headCache.delete(sourceId);
|
|
347
677
|
await gitRemove(sourceCheckoutDir(this.options.userRoot, sourceId));
|
|
348
678
|
}
|
|
349
679
|
this.state = { ...this.state, sources: this.state.sources.map((source, i) => (i === index ? next : source)) };
|
|
350
680
|
await saveState(this.statePath, this.state);
|
|
351
|
-
this.notifyChanged();
|
|
681
|
+
await this.notifyChanged();
|
|
352
682
|
});
|
|
353
683
|
}
|
|
354
684
|
/** Remove a source, its install entries, and its managed checkout. */
|
|
@@ -362,9 +692,12 @@ export class Catalog {
|
|
|
362
692
|
};
|
|
363
693
|
await saveState(this.statePath, this.state);
|
|
364
694
|
this.headCache.delete(sourceId);
|
|
365
|
-
|
|
695
|
+
// Adopted checkouts (manual clones) and local paths are user-owned
|
|
696
|
+
// directories; only self-acquired checkouts are deleted.
|
|
697
|
+
const userOwned = source !== undefined && (source.local === true || source.adopted === true);
|
|
698
|
+
if (!userOwned)
|
|
366
699
|
await gitRemove(sourceCheckoutDir(this.options.userRoot, sourceId));
|
|
367
|
-
this.notifyChanged();
|
|
700
|
+
await this.notifyChanged();
|
|
368
701
|
});
|
|
369
702
|
}
|
|
370
703
|
/** Refresh one source checkout, or every source when sourceId is omitted. */
|
|
@@ -372,18 +705,26 @@ export class Catalog {
|
|
|
372
705
|
return this.enqueue(async () => {
|
|
373
706
|
const targets = sourceId === undefined ? this.state.sources : this.state.sources.filter(source => source.id === sourceId);
|
|
374
707
|
for (const source of targets) {
|
|
375
|
-
|
|
708
|
+
const kind = resolveSourceKind(source);
|
|
709
|
+
if (kind === 'local') {
|
|
376
710
|
if (!(await isDirectory(expandHome(source.url))))
|
|
377
711
|
throw new Error(`local source directory ${expandHome(source.url)} is missing`);
|
|
378
712
|
continue;
|
|
379
713
|
}
|
|
380
714
|
const checkout = sourceCheckoutDir(this.options.userRoot, source.id);
|
|
381
715
|
this.headCache.delete(source.id);
|
|
382
|
-
|
|
383
|
-
|
|
716
|
+
if (kind === 'archive') {
|
|
717
|
+
// Re-download and swap; the fresh digest becomes the lock value.
|
|
718
|
+
const lock = await this.acquire(source);
|
|
719
|
+
if (lock !== undefined)
|
|
720
|
+
this.headCache.set(source.id, lock);
|
|
721
|
+
continue;
|
|
384
722
|
}
|
|
385
|
-
|
|
386
|
-
|
|
723
|
+
// Adopted checkouts are user-owned working trees: no `reset --hard`
|
|
724
|
+
// (it would destroy uncommitted work), and no re-acquire either —
|
|
725
|
+
// a broken HEAD means the user repairs the checkout, not that the
|
|
726
|
+
// manager may clone or extract over their directory.
|
|
727
|
+
if (source.adopted === true) {
|
|
387
728
|
try {
|
|
388
729
|
this.headCache.set(source.id, await gitHead(checkout));
|
|
389
730
|
}
|
|
@@ -392,7 +733,44 @@ export class Catalog {
|
|
|
392
733
|
}
|
|
393
734
|
continue;
|
|
394
735
|
}
|
|
395
|
-
|
|
736
|
+
try {
|
|
737
|
+
await gitHead(checkout);
|
|
738
|
+
}
|
|
739
|
+
catch {
|
|
740
|
+
const lock = await this.acquire(source);
|
|
741
|
+
if (lock !== undefined) {
|
|
742
|
+
this.headCache.set(source.id, lock);
|
|
743
|
+
}
|
|
744
|
+
else {
|
|
745
|
+
try {
|
|
746
|
+
this.headCache.set(source.id, await gitHead(checkout));
|
|
747
|
+
}
|
|
748
|
+
catch {
|
|
749
|
+
// A failed HEAD read means the next overview re-probes.
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
// Shallow-friendly sync: fetch depth 1 into FETCH_HEAD, hard reset.
|
|
755
|
+
const branch = source.branch ?? (await gitCurrentBranch(checkout).catch(() => undefined));
|
|
756
|
+
// Keep origin on the region's route: a clone made before a region
|
|
757
|
+
// switch (or an explicit flip) re-points origin so the fetch follows.
|
|
758
|
+
// Only origins this plugin itself wrote (the direct or proxied
|
|
759
|
+
// source.url) are re-pointed — a foreign remote is left untouched.
|
|
760
|
+
if (source.kind === 'git' || (source.kind === undefined && source.local !== true)) {
|
|
761
|
+
try {
|
|
762
|
+
const region = resolveRegion(await this.downloadRegionProvider(), await readLocalePreference());
|
|
763
|
+
const routed = githubCloneUrl(region, source.url);
|
|
764
|
+
const origin = await gitRemoteUrl(checkout);
|
|
765
|
+
if (origin !== routed && (origin === source.url || origin === githubCloneUrl('china', source.url))) {
|
|
766
|
+
await gitSetRemoteUrl(checkout, routed);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
catch {
|
|
770
|
+
// Origin reconciliation is best effort; plain sync still runs.
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
await gitSync(checkout, branch, this.options.git ?? {});
|
|
396
774
|
try {
|
|
397
775
|
this.headCache.set(source.id, await gitHead(checkout));
|
|
398
776
|
}
|
|
@@ -400,7 +778,7 @@ export class Catalog {
|
|
|
400
778
|
// A failed HEAD read means the next overview re-probes.
|
|
401
779
|
}
|
|
402
780
|
}
|
|
403
|
-
this.notifyChanged();
|
|
781
|
+
await this.notifyChanged();
|
|
404
782
|
});
|
|
405
783
|
}
|
|
406
784
|
/** Install a suite from a source and enable it. The client confirms the
|
|
@@ -412,16 +790,20 @@ export class Catalog {
|
|
|
412
790
|
if (source === undefined)
|
|
413
791
|
throw new Error(`unknown source "${sourceId}"`);
|
|
414
792
|
const checkout = this.sourceCheckoutPath(source);
|
|
793
|
+
let freshLock;
|
|
415
794
|
if (!(await isDirectory(checkout)))
|
|
416
|
-
await this.
|
|
417
|
-
const suites = await discoverSuitesInSource(checkout, sourceId, 'user');
|
|
795
|
+
freshLock = await this.acquire(source);
|
|
796
|
+
const suites = await discoverSuitesInSource(checkout, sourceId, 'user', source.url);
|
|
418
797
|
const suite = suites.find(entry => entry.id === suiteId);
|
|
419
798
|
if (suite === undefined)
|
|
420
799
|
throw new Error(`suite "${suiteId}" not found in source "${sourceId}"`);
|
|
421
800
|
if (suite.remote !== undefined)
|
|
422
801
|
throw new Error(`suite "${suiteId}" is a remote reference (${suite.remote.url}); add its repository as a source before installing`);
|
|
423
|
-
|
|
424
|
-
|
|
802
|
+
// Archive (or tarball-fallback) sources have no git HEAD; their digest
|
|
803
|
+
// is the lock value.
|
|
804
|
+
const lockCommit = (await tryHead(checkout)) ?? freshLock ?? this.headCache.get(sourceId);
|
|
805
|
+
await this.setInstalled(sourceId, suiteId, { enabled: true, installedAt: new Date().toISOString(), lockCommit });
|
|
806
|
+
await this.notifyChanged(true);
|
|
425
807
|
});
|
|
426
808
|
}
|
|
427
809
|
/** Uninstall a suite while retaining the source checkout. */
|
|
@@ -433,7 +815,7 @@ export class Catalog {
|
|
|
433
815
|
const rest = Object.fromEntries(Object.entries(this.state.installed).filter(([entryKey]) => entryKey !== key));
|
|
434
816
|
this.state = { ...this.state, installed: rest };
|
|
435
817
|
await saveState(this.statePath, this.state);
|
|
436
|
-
this.notifyChanged();
|
|
818
|
+
await this.notifyChanged(true);
|
|
437
819
|
});
|
|
438
820
|
}
|
|
439
821
|
/** Enable or disable an installed suite. */
|
|
@@ -444,7 +826,7 @@ export class Catalog {
|
|
|
444
826
|
if (entry === undefined)
|
|
445
827
|
throw new Error(`suite "${suiteId}" is not installed`);
|
|
446
828
|
await this.setInstalled(sourceId, suiteId, { ...entry, enabled });
|
|
447
|
-
this.notifyChanged();
|
|
829
|
+
await this.notifyChanged(true);
|
|
448
830
|
});
|
|
449
831
|
}
|
|
450
832
|
/** Enable or disable one runtime surface of an installed suite. */
|
|
@@ -458,7 +840,7 @@ export class Catalog {
|
|
|
458
840
|
throw new Error(`surface "${surface}" is not toggleable`);
|
|
459
841
|
const surfaces = { ...(entry.surfaces ?? {}), [surface]: enabled };
|
|
460
842
|
await this.setInstalled(sourceId, suiteId, { ...entry, surfaces });
|
|
461
|
-
this.notifyChanged();
|
|
843
|
+
await this.notifyChanged(true);
|
|
462
844
|
});
|
|
463
845
|
}
|
|
464
846
|
/** Append config-seeded sources missing from user state and persist them. */
|
|
@@ -469,28 +851,88 @@ export class Catalog {
|
|
|
469
851
|
return;
|
|
470
852
|
this.state = { ...this.state, sources: [...this.state.sources, ...additions] };
|
|
471
853
|
await saveState(this.statePath, this.state);
|
|
854
|
+
// New sources change the fingerprint, but their content arrives through
|
|
855
|
+
// an acquire, so mark the scan cache dirty conservatively.
|
|
856
|
+
this.scanCacheDirty = true;
|
|
472
857
|
this.invalidateSnapshot(true);
|
|
473
858
|
}
|
|
474
859
|
async setInstalled(sourceId, suiteId, entry) {
|
|
475
860
|
this.state = { ...this.state, installed: { ...this.state.installed, [installKey(sourceId, suiteId)]: entry } };
|
|
476
861
|
await saveState(this.statePath, this.state);
|
|
477
862
|
}
|
|
478
|
-
|
|
479
|
-
|
|
863
|
+
/**
|
|
864
|
+
* Acquire one source's content by kind: clone (git), download+extract
|
|
865
|
+
* (archive), or an in-place existence check (local). Returns a lock value
|
|
866
|
+
* when the acquisition yields one (archive SHA-256, tarball fallback
|
|
867
|
+
* digest); git HEAD is read separately by the callers.
|
|
868
|
+
*/
|
|
869
|
+
async acquire(source) {
|
|
870
|
+
const kind = resolveSourceKind(source);
|
|
871
|
+
if (kind === 'local') {
|
|
480
872
|
if (!(await isDirectory(expandHome(source.url))))
|
|
481
873
|
throw new Error(`local source directory ${expandHome(source.url)} is missing`);
|
|
482
|
-
return;
|
|
874
|
+
return undefined;
|
|
483
875
|
}
|
|
484
876
|
const checkout = sourceCheckoutDir(this.options.userRoot, source.id);
|
|
485
877
|
await mkdir(sourcesDir(this.options.userRoot), { recursive: true });
|
|
486
|
-
|
|
878
|
+
if (kind === 'archive') {
|
|
879
|
+
const { sha256 } = await archiveInstall(source.url, checkout, {
|
|
880
|
+
...(source.sha256 === undefined ? {} : { sha256: source.sha256 }),
|
|
881
|
+
...(this.options.git?.timeoutMs === undefined ? {} : { timeoutMs: this.options.git.timeoutMs }),
|
|
882
|
+
...(this.options.git?.allowHttpArchives === true ? { allowHttp: true } : {})
|
|
883
|
+
});
|
|
884
|
+
return sha256;
|
|
885
|
+
}
|
|
886
|
+
try {
|
|
887
|
+
// The region routes github.com clones through the China mirror prefix;
|
|
888
|
+
// the proxied URL becomes `origin`, so refreshes follow the same route.
|
|
889
|
+
const region = resolveRegion(await this.downloadRegionProvider(), await readLocalePreference());
|
|
890
|
+
await gitClone(githubCloneUrl(region, source.url), source.branch, checkout, this.options.git ?? {});
|
|
891
|
+
return undefined;
|
|
892
|
+
}
|
|
893
|
+
catch (error) {
|
|
894
|
+
if (this.options.git?.fallbackTarball !== true)
|
|
895
|
+
throw error;
|
|
896
|
+
const tarballUrl = codeloadTarballUrl(source.url, source.branch);
|
|
897
|
+
if (tarballUrl === undefined)
|
|
898
|
+
throw error;
|
|
899
|
+
// The codeload URL ends in a branch path, not `.tar.gz`, so the format
|
|
900
|
+
// must be stated explicitly — extension detection cannot see it.
|
|
901
|
+
const { sha256 } = await archiveInstall(tarballUrl, checkout, {
|
|
902
|
+
...(this.options.git?.timeoutMs === undefined ? {} : { timeoutMs: this.options.git.timeoutMs }),
|
|
903
|
+
format: 'targz'
|
|
904
|
+
});
|
|
905
|
+
return sha256;
|
|
906
|
+
}
|
|
487
907
|
}
|
|
488
908
|
/** The filesystem location of one source. */
|
|
489
909
|
sourceCheckoutPath(source) {
|
|
490
910
|
return source.local === true ? expandHome(source.url) : sourceCheckoutDir(this.options.userRoot, source.id);
|
|
491
911
|
}
|
|
492
|
-
async buildSnapshot(state, dimension, dimensionRoot) {
|
|
493
|
-
const
|
|
912
|
+
async buildSnapshot(state, dimension, dimensionRoot, skipScanCache = false) {
|
|
913
|
+
const fingerprint = JSON.stringify([dimension, dimensionRoot, state.sources]);
|
|
914
|
+
const cached = this.scanCache.get(fingerprint);
|
|
915
|
+
// The user snapshot's TTL also bounds scan reuse: a snapshot rebuild that
|
|
916
|
+
// replays a 30s scan cache would silently outlive its own staleness bound.
|
|
917
|
+
const scanCacheTtl = dimension === 'user' ? Math.min(this.userSnapshotTtlMs, Catalog.SCAN_CACHE_TTL_MS) : Catalog.SCAN_CACHE_TTL_MS;
|
|
918
|
+
const cacheFresh = !skipScanCache && !this.scanCacheDirty && cached !== undefined && Date.now() - cached.at < scanCacheTtl;
|
|
919
|
+
let discovered;
|
|
920
|
+
let scanNotes;
|
|
921
|
+
if (cacheFresh && cached !== undefined) {
|
|
922
|
+
discovered = cached.discovered;
|
|
923
|
+
scanNotes = cached.scanNotes;
|
|
924
|
+
}
|
|
925
|
+
else {
|
|
926
|
+
const result = await discoverSourceListWithNotes(state.sources, dimension, dimensionRoot);
|
|
927
|
+
discovered = result.suites;
|
|
928
|
+
scanNotes = result.scanNotes;
|
|
929
|
+
// The fresh scan satisfies the invalidation: content mutations mark the
|
|
930
|
+
// cache dirty, and the next scan (this one) makes it clean again.
|
|
931
|
+
this.scanCacheDirty = false;
|
|
932
|
+
if (this.scanCache.size >= Catalog.SCAN_CACHE_MAX_ENTRIES)
|
|
933
|
+
this.scanCache.clear();
|
|
934
|
+
this.scanCache.set(fingerprint, { at: Date.now(), discovered, scanNotes });
|
|
935
|
+
}
|
|
494
936
|
const suites = discovered.map(suite => {
|
|
495
937
|
const installed = state.installed[installKey(suite.sourceId, suite.id)];
|
|
496
938
|
// Native project layouts (`.claude/`, `.agents/`) are the repository's
|
|
@@ -508,19 +950,29 @@ export class Catalog {
|
|
|
508
950
|
revision: this.revision,
|
|
509
951
|
sources: [...state.sources],
|
|
510
952
|
suites,
|
|
511
|
-
enabledSuites: suites.filter(suite => suite.enabled)
|
|
953
|
+
enabledSuites: suites.filter(suite => suite.enabled),
|
|
954
|
+
...(Object.keys(scanNotes).length > 0 ? { scanNotes } : {})
|
|
512
955
|
};
|
|
513
956
|
}
|
|
514
957
|
invalidateSnapshot(increment) {
|
|
515
958
|
if (increment)
|
|
516
959
|
this.revision++;
|
|
517
960
|
this.userSnapshot = undefined;
|
|
961
|
+
this.userSnapshotExpiresAt = 0;
|
|
518
962
|
this.userSnapshotPromise = undefined;
|
|
519
963
|
this.projectSnapshots.clear();
|
|
520
964
|
}
|
|
521
|
-
|
|
965
|
+
/**
|
|
966
|
+
* Invalidate snapshots and run the change pipeline. `keepScanCache` marks
|
|
967
|
+
* state-only mutations (install, enable, surface toggles, MCP overrides)
|
|
968
|
+
* whose inputs leave every checkout untouched, so the next snapshot
|
|
969
|
+
* re-derives from cached discovery instead of rescanning the filesystem.
|
|
970
|
+
*/
|
|
971
|
+
async notifyChanged(keepScanCache = false) {
|
|
972
|
+
if (!keepScanCache)
|
|
973
|
+
this.scanCacheDirty = true;
|
|
522
974
|
this.invalidateSnapshot(true);
|
|
523
|
-
this.options.onChanged();
|
|
975
|
+
await this.options.onChanged();
|
|
524
976
|
for (const listener of this.listeners)
|
|
525
977
|
listener();
|
|
526
978
|
}
|
|
@@ -542,3 +994,17 @@ async function tryHead(dir) {
|
|
|
542
994
|
return undefined;
|
|
543
995
|
}
|
|
544
996
|
}
|
|
997
|
+
/**
|
|
998
|
+
* The codeload tarball URL of a GitHub repository, or undefined for any
|
|
999
|
+
* other host. `https://github.com/<owner>/<repo>` maps to
|
|
1000
|
+
* `https://codeload.github.com/<owner>/<repo>/tar.gz/refs/heads/<branch>`
|
|
1001
|
+
* (or `/tar.gz/HEAD` for the default branch) — the git-protocol fallback
|
|
1002
|
+
* when a clone cannot get through but plain HTTPS file download can.
|
|
1003
|
+
*/
|
|
1004
|
+
export function codeloadTarballUrl(url, branch) {
|
|
1005
|
+
const match = /^https:\/\/github\.com\/([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/i.exec(url.trim());
|
|
1006
|
+
if (match === null)
|
|
1007
|
+
return undefined;
|
|
1008
|
+
const [, owner, repo] = match;
|
|
1009
|
+
return `https://codeload.github.com/${owner}/${repo}/tar.gz/${branch === undefined ? 'HEAD' : `refs/heads/${branch}`}`;
|
|
1010
|
+
}
|