dsh-plugin-shop 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -0
- package/README.zh.md +57 -0
- package/cordis.patch.yml +9 -0
- package/lib/client.js +5246 -0
- package/lib/index.js +725 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +312 -0
- package/lib/typert.remote-client.d.ts +30 -0
- package/lib/typert.remote-client.js +200 -0
- package/lib/types/client/StoreTab.d.ts +30 -0
- package/lib/types/client/index.d.ts +30 -0
- package/lib/types/client/locales.d.ts +103 -0
- package/lib/types/client/present.d.ts +78 -0
- package/lib/types/client/useInstall.d.ts +14 -0
- package/lib/types/host/catalog.d.ts +37 -0
- package/lib/types/host/executor.d.ts +32 -0
- package/lib/types/host/index.d.ts +130 -0
- package/lib/types/host/install.d.ts +21 -0
- package/lib/types/host/profile.d.ts +30 -0
- package/lib/types/host/types.d.ts +31 -0
- package/lib/types/index.d.ts +3 -0
- package/package.json +120 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,725 @@
|
|
|
1
|
+
import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
2
|
+
import { loadOptionalPatches, readProfileManifest, resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
|
|
3
|
+
import { lt, minVersion } from "semver";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { basename, dirname, join } from "node:path";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { dump } from "js-yaml";
|
|
11
|
+
/** A cached catalog younger than this is served without touching the network. */
|
|
12
|
+
const FRESH_MS = 3e5;
|
|
13
|
+
/** Records when the loader itself wrote the cache; the pointer's `builtAt` is
|
|
14
|
+
* the catalog's build time, not the cache's fetch time. */
|
|
15
|
+
const META_FILE = "index.meta.json";
|
|
16
|
+
const entrySchema = z.object({
|
|
17
|
+
name: z.string(),
|
|
18
|
+
version: z.string(),
|
|
19
|
+
integrity: z.string().nullable(),
|
|
20
|
+
publishedAt: z.string().nullable(),
|
|
21
|
+
repository: z.string().nullable(),
|
|
22
|
+
license: z.string().nullable(),
|
|
23
|
+
tier: z.enum([
|
|
24
|
+
"verified",
|
|
25
|
+
"verified-stale",
|
|
26
|
+
"community"
|
|
27
|
+
]),
|
|
28
|
+
metadata: z.enum(["declared", "derived"]),
|
|
29
|
+
review: z.object({
|
|
30
|
+
reviewedVersion: z.string(),
|
|
31
|
+
reviewer: z.string(),
|
|
32
|
+
reviewCommit: z.string(),
|
|
33
|
+
notes: z.string()
|
|
34
|
+
}).optional(),
|
|
35
|
+
catalog: z.object({
|
|
36
|
+
category: z.enum([
|
|
37
|
+
"tool",
|
|
38
|
+
"provider",
|
|
39
|
+
"ui",
|
|
40
|
+
"workflow",
|
|
41
|
+
"integration",
|
|
42
|
+
"other"
|
|
43
|
+
]),
|
|
44
|
+
summary: z.object({
|
|
45
|
+
en: z.string(),
|
|
46
|
+
zh: z.string().optional()
|
|
47
|
+
}),
|
|
48
|
+
capabilities: z.array(z.string())
|
|
49
|
+
}).optional()
|
|
50
|
+
});
|
|
51
|
+
const dataSchema = z.object({
|
|
52
|
+
schemaVersion: z.number(),
|
|
53
|
+
plugins: z.array(entrySchema),
|
|
54
|
+
denied: z.array(z.object({
|
|
55
|
+
name: z.string(),
|
|
56
|
+
detail: z.string()
|
|
57
|
+
})).default([])
|
|
58
|
+
});
|
|
59
|
+
const pointerSchema = z.object({
|
|
60
|
+
schemaVersion: z.number(),
|
|
61
|
+
builtAt: z.string(),
|
|
62
|
+
count: z.number(),
|
|
63
|
+
plugins: z.object({
|
|
64
|
+
url: z.string(),
|
|
65
|
+
sha256: z.string()
|
|
66
|
+
})
|
|
67
|
+
});
|
|
68
|
+
const nodeFs = {
|
|
69
|
+
exists: (path) => existsSync(path),
|
|
70
|
+
read: (path) => readFileSync(path, "utf8"),
|
|
71
|
+
write: (path, data) => {
|
|
72
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
73
|
+
writeFileSync(path, data);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
/** Resolve the pointer's data URL against the catalog base. An absolute URL —
|
|
77
|
+
* any scheme, or a protocol-relative `//host/...` — would hand the pointer a
|
|
78
|
+
* fetch primitive to arbitrary hosts, so it is refused loudly before any
|
|
79
|
+
* fetch (§9.2). The guard is the resolved origin, not the raw string: WHATWG
|
|
80
|
+
* normalization strips leading whitespace and accepts backslash spellings
|
|
81
|
+
* before the string could be inspected, so only comparing the resolved URL's
|
|
82
|
+
* origin to the base's closes every spelling class. */
|
|
83
|
+
function resolveDataUrl(baseUrl, url) {
|
|
84
|
+
const resolved = new URL(url, baseUrl);
|
|
85
|
+
if (resolved.origin !== new URL(baseUrl).origin) throw new Error("catalog data url must be relative to the catalog base");
|
|
86
|
+
return resolved.href;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Load the catalog snapshot: fetch the pointer, verify the data file's sha256
|
|
90
|
+
* against it, cache both on disk, and serve the cached copy with `stale: true`
|
|
91
|
+
* only when the transport itself failed — the fetch threw or returned a
|
|
92
|
+
* non-2xx (§10). A schemaVersion higher than this build supports, a malformed
|
|
93
|
+
* pointer or data file, an absolute data URL, or a sha256 mismatch — fresh or
|
|
94
|
+
* cached — throws even when a cache exists; never silently degraded.
|
|
95
|
+
*/
|
|
96
|
+
async function loadCatalog(options) {
|
|
97
|
+
const { baseUrl, cacheDir, refresh = false, fetchImpl = fetch, now = () => /* @__PURE__ */ new Date(), fsImpl = nodeFs } = options;
|
|
98
|
+
const indexPath = join(cacheDir, "index.json");
|
|
99
|
+
const metaPath = join(cacheDir, META_FILE);
|
|
100
|
+
/** The timestamp freshness is measured from: the sidecar's fetch time when
|
|
101
|
+
* it exists and parses, else the pointer's builtAt when that parses, else
|
|
102
|
+
* null ("no usable freshness fact"). */
|
|
103
|
+
const freshnessTimeOf = (builtAt) => {
|
|
104
|
+
if (fsImpl.exists(metaPath)) try {
|
|
105
|
+
const fetchedAt = Date.parse(JSON.parse(fsImpl.read(metaPath)).fetchedAt);
|
|
106
|
+
if (!Number.isNaN(fetchedAt)) return fetchedAt;
|
|
107
|
+
} catch {}
|
|
108
|
+
const built = Date.parse(builtAt);
|
|
109
|
+
return Number.isNaN(built) ? null : built;
|
|
110
|
+
};
|
|
111
|
+
const readCached = () => {
|
|
112
|
+
try {
|
|
113
|
+
const pointer = pointerSchema.parse(JSON.parse(fsImpl.read(indexPath)));
|
|
114
|
+
if (pointer.schemaVersion > 2) throw new Error(`catalog schemaVersion ${pointer.schemaVersion} is newer than this build supports (2)`);
|
|
115
|
+
const dataPath = join(cacheDir, basename(pointer.plugins.url));
|
|
116
|
+
const dataText = fsImpl.read(dataPath);
|
|
117
|
+
const actual = createHash("sha256").update(dataText).digest("hex");
|
|
118
|
+
if (actual !== pointer.plugins.sha256) throw new Error(`cached catalog data failed integrity check: expected ${pointer.plugins.sha256}, got ${actual}`);
|
|
119
|
+
const data = dataSchema.parse(JSON.parse(dataText));
|
|
120
|
+
if (data.schemaVersion > 2) throw new Error(`catalog schemaVersion ${data.schemaVersion} is newer than this build supports (2)`);
|
|
121
|
+
return {
|
|
122
|
+
schemaVersion: pointer.schemaVersion,
|
|
123
|
+
builtAt: pointer.builtAt,
|
|
124
|
+
entries: data.plugins,
|
|
125
|
+
denied: data.denied
|
|
126
|
+
};
|
|
127
|
+
} catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
if (!refresh && fsImpl.exists(indexPath)) {
|
|
132
|
+
const cached = readCached();
|
|
133
|
+
if (cached !== null) {
|
|
134
|
+
const fetchedAt = freshnessTimeOf(cached.builtAt);
|
|
135
|
+
if (fetchedAt !== null && now().getTime() - fetchedAt < FRESH_MS) return {
|
|
136
|
+
snapshot: cached,
|
|
137
|
+
stale: false
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
let pointerText;
|
|
142
|
+
try {
|
|
143
|
+
const response = await fetchImpl(new URL("index.json", baseUrl).href);
|
|
144
|
+
if (!response.ok) throw new Error(`catalog pointer returned ${response.status}`);
|
|
145
|
+
pointerText = await response.text();
|
|
146
|
+
} catch (error) {
|
|
147
|
+
const cached = readCached();
|
|
148
|
+
if (cached !== null) return {
|
|
149
|
+
snapshot: cached,
|
|
150
|
+
stale: true
|
|
151
|
+
};
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
|
154
|
+
const pointer = pointerSchema.parse(JSON.parse(pointerText));
|
|
155
|
+
if (pointer.schemaVersion > 2) throw new Error(`catalog schemaVersion ${pointer.schemaVersion} is newer than this build supports (2)`);
|
|
156
|
+
const dataUrl = resolveDataUrl(baseUrl, pointer.plugins.url);
|
|
157
|
+
let dataText;
|
|
158
|
+
try {
|
|
159
|
+
const dataResponse = await fetchImpl(dataUrl);
|
|
160
|
+
if (!dataResponse.ok) throw new Error(`catalog data returned ${dataResponse.status}`);
|
|
161
|
+
dataText = await dataResponse.text();
|
|
162
|
+
} catch (error) {
|
|
163
|
+
const cached = readCached();
|
|
164
|
+
if (cached !== null) return {
|
|
165
|
+
snapshot: cached,
|
|
166
|
+
stale: true
|
|
167
|
+
};
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
const actual = createHash("sha256").update(dataText).digest("hex");
|
|
171
|
+
if (actual !== pointer.plugins.sha256) throw new Error(`catalog data failed integrity check: expected ${pointer.plugins.sha256}, got ${actual}`);
|
|
172
|
+
const data = dataSchema.parse(JSON.parse(dataText));
|
|
173
|
+
if (data.schemaVersion > 2) throw new Error(`catalog schemaVersion ${data.schemaVersion} is newer than this build supports (2)`);
|
|
174
|
+
const snapshot = {
|
|
175
|
+
schemaVersion: pointer.schemaVersion,
|
|
176
|
+
builtAt: pointer.builtAt,
|
|
177
|
+
entries: data.plugins,
|
|
178
|
+
denied: data.denied
|
|
179
|
+
};
|
|
180
|
+
fsImpl.write(indexPath, JSON.stringify(pointer));
|
|
181
|
+
fsImpl.write(join(cacheDir, basename(pointer.plugins.url)), dataText);
|
|
182
|
+
fsImpl.write(metaPath, JSON.stringify({ fetchedAt: now().toISOString() }));
|
|
183
|
+
return {
|
|
184
|
+
snapshot,
|
|
185
|
+
stale: false
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
//#endregion
|
|
189
|
+
//#region src/host/install.ts
|
|
190
|
+
/**
|
|
191
|
+
* Decide whether one install request may proceed, against the Host's own
|
|
192
|
+
* snapshot (§5.3). The browser sends a name; nothing the browser says about
|
|
193
|
+
* the package is trusted.
|
|
194
|
+
*/
|
|
195
|
+
function validateInstall(snapshot, args) {
|
|
196
|
+
const denied = snapshot.denied.find((d) => d.name === args.name);
|
|
197
|
+
if (denied !== void 0) return {
|
|
198
|
+
ok: false,
|
|
199
|
+
code: "denied",
|
|
200
|
+
detail: `dsh-plugin-shop: ${args.name} is denied: ${denied.detail}`
|
|
201
|
+
};
|
|
202
|
+
const entry = snapshot.entries.find((e) => e.name === args.name);
|
|
203
|
+
if (entry === void 0) return {
|
|
204
|
+
ok: false,
|
|
205
|
+
code: "not-in-catalog",
|
|
206
|
+
detail: `dsh-plugin-shop: ${args.name} is not in the catalog`
|
|
207
|
+
};
|
|
208
|
+
if (entry.version !== args.version) return {
|
|
209
|
+
ok: false,
|
|
210
|
+
code: "version-mismatch",
|
|
211
|
+
detail: `dsh-plugin-shop: ${args.name}@${args.version} is not the cataloged version (${entry.version})`
|
|
212
|
+
};
|
|
213
|
+
if (entry.tier !== "verified" && !args.acknowledged) return {
|
|
214
|
+
ok: false,
|
|
215
|
+
code: "needs-acknowledgement",
|
|
216
|
+
detail: entry.tier === "verified-stale" ? `dsh-plugin-shop: ${args.name} is verified-stale: a newer version than the review is current and has not been reviewed; acknowledgement is required` : `dsh-plugin-shop: ${args.name} is ${entry.tier}-tier and has not been reviewed; acknowledgement is required`
|
|
217
|
+
};
|
|
218
|
+
return { ok: true };
|
|
219
|
+
}
|
|
220
|
+
//#endregion
|
|
221
|
+
//#region src/host/executor.ts
|
|
222
|
+
/** Install executor: spawn the dsh CLI, stream its output, serialize per profile. */
|
|
223
|
+
const MAX_LOG_LINES = 200;
|
|
224
|
+
const MAX_LOG_BYTES = 65536;
|
|
225
|
+
const profileQueues = /* @__PURE__ */ new Map();
|
|
226
|
+
function chain(profile, task) {
|
|
227
|
+
const next = (profileQueues.get(profile) ?? Promise.resolve()).then(task, task);
|
|
228
|
+
profileQueues.set(profile, next.catch(() => {}));
|
|
229
|
+
return next;
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* The §7.2 step-6 confirm: after a zero exit, re-read the profile manifest and
|
|
233
|
+
* verify the bundle actually landed in `dsh.profile.bundles`. Exit 0 alone is
|
|
234
|
+
* not success — a library-that-looked-like-a-plugin, or a stale catalog,
|
|
235
|
+
* exits 0 while changing nothing (§10). The store cannot force a client
|
|
236
|
+
* refresh in P1, so the detail carries the signal. A manifest that cannot be
|
|
237
|
+
* read or parsed is the same outcome, naming the file: the install's result
|
|
238
|
+
* is then unknown, and a bare `done` would be plausible-but-wrong. `home` is
|
|
239
|
+
* the DSH_HOME the child was spawned with — the parent's own DSH_HOME may
|
|
240
|
+
* differ when `env` pinned it.
|
|
241
|
+
*/
|
|
242
|
+
function confirmBundleActivation(profile, home, expectedName) {
|
|
243
|
+
const profileDir = resolveProfileDir(profile, home);
|
|
244
|
+
try {
|
|
245
|
+
if (readProfileManifest("dsh-plugin-shop", profileDir).dsh?.profile?.bundles?.includes(expectedName)) return null;
|
|
246
|
+
return "installed but dsh.profile.bundles did not change — the catalog may be stale; refresh it";
|
|
247
|
+
} catch {
|
|
248
|
+
return `installed but the profile manifest could not be read (${join(profileDir, "package.json")}) — the catalog may be stale; refresh it`;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Run one `dsh plugin --profile <profile> add <spec>` and track it.
|
|
253
|
+
* Never rolls back; a failure surfaces stderr verbatim plus the recovery hint
|
|
254
|
+
* (§10). The store never passes build-script flags: `allowBuilds` stays the
|
|
255
|
+
* user's explicit decision in the CLI (§7.2).
|
|
256
|
+
* The child inherits the current environment unless `env` is given — the
|
|
257
|
+
* real-install test pins DSH_HOME to a temporary directory this way.
|
|
258
|
+
* When `expectedName` is given, a zero exit is confirmed against the profile
|
|
259
|
+
* manifest (§7.2 step 6) before the install reports `done`.
|
|
260
|
+
*/
|
|
261
|
+
function startInstall(options) {
|
|
262
|
+
const { profile, spec, dshBin = "dsh", env, expectedName, onStatus } = options;
|
|
263
|
+
const installId = randomUUID();
|
|
264
|
+
const log = [];
|
|
265
|
+
let logBytes = 0;
|
|
266
|
+
let state = "running";
|
|
267
|
+
let detail;
|
|
268
|
+
const status = () => ({
|
|
269
|
+
state,
|
|
270
|
+
log: [...log],
|
|
271
|
+
...state === "done" ? { needsRestart: true } : {},
|
|
272
|
+
...detail !== void 0 ? { detail } : {}
|
|
273
|
+
});
|
|
274
|
+
const append = (line) => {
|
|
275
|
+
log.push(line);
|
|
276
|
+
logBytes += Buffer.byteLength(line);
|
|
277
|
+
while ((log.length > MAX_LOG_LINES || logBytes > MAX_LOG_BYTES) && log.length > 1) {
|
|
278
|
+
const oldest = log.shift();
|
|
279
|
+
if (oldest !== void 0) logBytes -= Buffer.byteLength(oldest);
|
|
280
|
+
}
|
|
281
|
+
onStatus?.(status());
|
|
282
|
+
};
|
|
283
|
+
return {
|
|
284
|
+
installId,
|
|
285
|
+
status,
|
|
286
|
+
finished: chain(profile, () => new Promise((resolve) => {
|
|
287
|
+
const child = spawn(dshBin, [
|
|
288
|
+
"plugin",
|
|
289
|
+
"--profile",
|
|
290
|
+
profile,
|
|
291
|
+
"add",
|
|
292
|
+
spec
|
|
293
|
+
], {
|
|
294
|
+
stdio: [
|
|
295
|
+
"ignore",
|
|
296
|
+
"pipe",
|
|
297
|
+
"pipe"
|
|
298
|
+
],
|
|
299
|
+
env: env ?? process.env
|
|
300
|
+
});
|
|
301
|
+
child.stdout.on("data", (chunk) => {
|
|
302
|
+
for (const line of chunk.toString().split("\n")) if (line !== "") append(line);
|
|
303
|
+
});
|
|
304
|
+
child.stderr.on("data", (chunk) => {
|
|
305
|
+
for (const line of chunk.toString().split("\n")) if (line !== "") append(line);
|
|
306
|
+
});
|
|
307
|
+
child.on("error", (error) => {
|
|
308
|
+
const code = error.code;
|
|
309
|
+
state = "failed";
|
|
310
|
+
detail = code === "ENOENT" ? "dsh not found on PATH — install the dsh CLI to manage profile plugins" : `dsh spawn failed: ${error.message}`;
|
|
311
|
+
onStatus?.(status());
|
|
312
|
+
resolve(status());
|
|
313
|
+
});
|
|
314
|
+
child.on("close", (exitCode) => {
|
|
315
|
+
if (state !== "running") return;
|
|
316
|
+
if (exitCode === 0) {
|
|
317
|
+
state = "done";
|
|
318
|
+
if (expectedName !== void 0) {
|
|
319
|
+
const confirmDetail = confirmBundleActivation(profile, env?.DSH_HOME, expectedName);
|
|
320
|
+
if (confirmDetail !== null) {
|
|
321
|
+
state = "failed";
|
|
322
|
+
detail = confirmDetail;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
} else {
|
|
326
|
+
state = "failed";
|
|
327
|
+
const lastLogLine = log[log.length - 1] ?? "";
|
|
328
|
+
detail = `pnpm failed in the profile. Run: dsh plugin --profile ${profile} install — ${lastLogLine}`;
|
|
329
|
+
}
|
|
330
|
+
onStatus?.(status());
|
|
331
|
+
resolve(status());
|
|
332
|
+
});
|
|
333
|
+
}))
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
//#endregion
|
|
337
|
+
//#region src/host/profile.ts
|
|
338
|
+
/** Profile directory discovery and user-layer writes (§8: hot enable/disable). */
|
|
339
|
+
/**
|
|
340
|
+
* Find the profile directory that owns `startPath`.
|
|
341
|
+
*
|
|
342
|
+
* `baseDir` is the boot-provided profile directory (the Loader root's own
|
|
343
|
+
* directory, `ctx.baseUrl`) and is authoritative when it is a profile. The
|
|
344
|
+
* walk-up from `startPath` covers the case where the package is materialized
|
|
345
|
+
* inside the profile's node_modules — but a `link:` install keeps the package
|
|
346
|
+
* at its source location, so no ancestor of the module path is a profile at
|
|
347
|
+
* all. In that case only `baseDir` can answer.
|
|
348
|
+
*/
|
|
349
|
+
function discoverProfile(startPath, baseDir) {
|
|
350
|
+
if (baseDir !== void 0 && isProfileDir(baseDir)) return {
|
|
351
|
+
name: basename(baseDir),
|
|
352
|
+
dir: baseDir
|
|
353
|
+
};
|
|
354
|
+
let dir = realpathNearestExisting(startPath);
|
|
355
|
+
for (;;) {
|
|
356
|
+
if (isProfileDir(dir)) return {
|
|
357
|
+
name: basename(dir),
|
|
358
|
+
dir
|
|
359
|
+
};
|
|
360
|
+
const parent = dirname(dir);
|
|
361
|
+
if (parent === dir) break;
|
|
362
|
+
dir = parent;
|
|
363
|
+
}
|
|
364
|
+
throw new Error(`dsh-plugin-shop: no profile directory found above ${startPath}`);
|
|
365
|
+
}
|
|
366
|
+
/** A directory is a profile when it holds the Loader root next to the bundle
|
|
367
|
+
* manifest the store's own package.json declares itself part of. */
|
|
368
|
+
function isProfileDir(dir) {
|
|
369
|
+
if (!existsSync(join(dir, "cordis.yml"))) return false;
|
|
370
|
+
try {
|
|
371
|
+
const manifest = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
372
|
+
return Array.isArray(manifest.dsh?.profile?.bundles);
|
|
373
|
+
} catch {}
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
/** Resolve symlinks through the deepest ancestor of `startPath` that exists.
|
|
377
|
+
* The leaf — the store's own module file — need not be present yet for
|
|
378
|
+
* discovery to know where it lives. */
|
|
379
|
+
function realpathNearestExisting(startPath) {
|
|
380
|
+
let current = startPath;
|
|
381
|
+
for (;;) try {
|
|
382
|
+
return realpathSync(current);
|
|
383
|
+
} catch (error) {
|
|
384
|
+
if (error.code !== "ENOENT") throw error;
|
|
385
|
+
const parent = dirname(current);
|
|
386
|
+
if (parent === current) return current;
|
|
387
|
+
current = parent;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Upsert one row of the profile's user layer (`cordis.patch.yml`). Enabling
|
|
392
|
+
* removes the row so the bundle default rules again; disabling writes
|
|
393
|
+
* `{ id, disabled: true }` (§8: the CLI's watchUserPatches applies the change
|
|
394
|
+
* hot through HMR). Existing rows for other ids are preserved verbatim.
|
|
395
|
+
*/
|
|
396
|
+
function setUserLayerRow(options) {
|
|
397
|
+
const file = join(options.profileDir, "cordis.patch.yml");
|
|
398
|
+
const others = (loadOptionalPatches("dsh-plugin-shop", file) ?? []).filter((row) => row.id !== options.row.id);
|
|
399
|
+
const next = options.row.disabled ? [...others, {
|
|
400
|
+
id: options.row.id,
|
|
401
|
+
disabled: true
|
|
402
|
+
}] : others;
|
|
403
|
+
const tmp = `${file}.tmp`;
|
|
404
|
+
writeFileSync(tmp, dump(next, { noRefs: true }));
|
|
405
|
+
renameSync(tmp, file);
|
|
406
|
+
}
|
|
407
|
+
//#endregion
|
|
408
|
+
//#region src/host/index.ts
|
|
409
|
+
/** StoreGateway: the Host half of dsh-plugin-shop (§5.1). */
|
|
410
|
+
var __runInitializers = function(thisArg, initializers, value) {
|
|
411
|
+
var useValue = arguments.length > 2;
|
|
412
|
+
for (var i = 0; i < initializers.length; i++) value = useValue ? initializers[i].call(thisArg, value) : initializers[i].call(thisArg);
|
|
413
|
+
return useValue ? value : void 0;
|
|
414
|
+
};
|
|
415
|
+
var __esDecorate = function(ctor, descriptorIn, decorators, contextIn, initializers, extraInitializers) {
|
|
416
|
+
function accept(f) {
|
|
417
|
+
if (f !== void 0 && typeof f !== "function") throw new TypeError("Function expected");
|
|
418
|
+
return f;
|
|
419
|
+
}
|
|
420
|
+
var kind = contextIn.kind, key = kind === "getter" ? "get" : kind === "setter" ? "set" : "value";
|
|
421
|
+
var target = !descriptorIn && ctor ? contextIn["static"] ? ctor : ctor.prototype : null;
|
|
422
|
+
var descriptor = descriptorIn || (target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
|
|
423
|
+
var _, done = false;
|
|
424
|
+
for (var i = decorators.length - 1; i >= 0; i--) {
|
|
425
|
+
var context = {};
|
|
426
|
+
for (var p in contextIn) context[p] = p === "access" ? {} : contextIn[p];
|
|
427
|
+
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
|
|
428
|
+
context.addInitializer = function(f) {
|
|
429
|
+
if (done) throw new TypeError("Cannot add initializers after decoration has completed");
|
|
430
|
+
extraInitializers.push(accept(f || null));
|
|
431
|
+
};
|
|
432
|
+
var result = (0, decorators[i])(kind === "accessor" ? {
|
|
433
|
+
get: descriptor.get,
|
|
434
|
+
set: descriptor.set
|
|
435
|
+
} : descriptor[key], context);
|
|
436
|
+
if (kind === "accessor") {
|
|
437
|
+
if (result === void 0) continue;
|
|
438
|
+
if (result === null || typeof result !== "object") throw new TypeError("Object expected");
|
|
439
|
+
if (_ = accept(result.get)) descriptor.get = _;
|
|
440
|
+
if (_ = accept(result.set)) descriptor.set = _;
|
|
441
|
+
if (_ = accept(result.init)) initializers.unshift(_);
|
|
442
|
+
} else if (_ = accept(result)) {
|
|
443
|
+
if (kind === "field") initializers.unshift(_);
|
|
444
|
+
else descriptor[key] = _;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (target) Object.defineProperty(target, contextIn.name, descriptor);
|
|
448
|
+
done = true;
|
|
449
|
+
};
|
|
450
|
+
/** Remote-only service exposing the store Remote methods of §7.3.
|
|
451
|
+
*
|
|
452
|
+
* @typert service store */
|
|
453
|
+
let StoreGateway = (() => {
|
|
454
|
+
let _classSuper = TypertRemoteService;
|
|
455
|
+
let _instanceExtraInitializers = [];
|
|
456
|
+
let _setEnabled_decorators;
|
|
457
|
+
let _catalog_decorators;
|
|
458
|
+
let _install_decorators;
|
|
459
|
+
let _installStatus_decorators;
|
|
460
|
+
let _outdated_decorators;
|
|
461
|
+
return class StoreGateway extends _classSuper {
|
|
462
|
+
static {
|
|
463
|
+
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
464
|
+
_setEnabled_decorators = [Remote("setEnabled")];
|
|
465
|
+
_catalog_decorators = [Remote("catalog")];
|
|
466
|
+
_install_decorators = [Remote("installStart")];
|
|
467
|
+
_installStatus_decorators = [Remote("installStatus")];
|
|
468
|
+
_outdated_decorators = [Remote("outdated")];
|
|
469
|
+
__esDecorate(this, null, _setEnabled_decorators, {
|
|
470
|
+
kind: "method",
|
|
471
|
+
name: "setEnabled",
|
|
472
|
+
static: false,
|
|
473
|
+
private: false,
|
|
474
|
+
access: {
|
|
475
|
+
has: (obj) => "setEnabled" in obj,
|
|
476
|
+
get: (obj) => obj.setEnabled
|
|
477
|
+
},
|
|
478
|
+
metadata: _metadata
|
|
479
|
+
}, null, _instanceExtraInitializers);
|
|
480
|
+
__esDecorate(this, null, _catalog_decorators, {
|
|
481
|
+
kind: "method",
|
|
482
|
+
name: "catalog",
|
|
483
|
+
static: false,
|
|
484
|
+
private: false,
|
|
485
|
+
access: {
|
|
486
|
+
has: (obj) => "catalog" in obj,
|
|
487
|
+
get: (obj) => obj.catalog
|
|
488
|
+
},
|
|
489
|
+
metadata: _metadata
|
|
490
|
+
}, null, _instanceExtraInitializers);
|
|
491
|
+
__esDecorate(this, null, _install_decorators, {
|
|
492
|
+
kind: "method",
|
|
493
|
+
name: "install",
|
|
494
|
+
static: false,
|
|
495
|
+
private: false,
|
|
496
|
+
access: {
|
|
497
|
+
has: (obj) => "install" in obj,
|
|
498
|
+
get: (obj) => obj.install
|
|
499
|
+
},
|
|
500
|
+
metadata: _metadata
|
|
501
|
+
}, null, _instanceExtraInitializers);
|
|
502
|
+
__esDecorate(this, null, _installStatus_decorators, {
|
|
503
|
+
kind: "method",
|
|
504
|
+
name: "installStatus",
|
|
505
|
+
static: false,
|
|
506
|
+
private: false,
|
|
507
|
+
access: {
|
|
508
|
+
has: (obj) => "installStatus" in obj,
|
|
509
|
+
get: (obj) => obj.installStatus
|
|
510
|
+
},
|
|
511
|
+
metadata: _metadata
|
|
512
|
+
}, null, _instanceExtraInitializers);
|
|
513
|
+
__esDecorate(this, null, _outdated_decorators, {
|
|
514
|
+
kind: "method",
|
|
515
|
+
name: "outdated",
|
|
516
|
+
static: false,
|
|
517
|
+
private: false,
|
|
518
|
+
access: {
|
|
519
|
+
has: (obj) => "outdated" in obj,
|
|
520
|
+
get: (obj) => obj.outdated
|
|
521
|
+
},
|
|
522
|
+
metadata: _metadata
|
|
523
|
+
}, null, _instanceExtraInitializers);
|
|
524
|
+
if (_metadata) Object.defineProperty(this, Symbol.metadata, {
|
|
525
|
+
enumerable: true,
|
|
526
|
+
configurable: true,
|
|
527
|
+
writable: true,
|
|
528
|
+
value: _metadata
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
options = __runInitializers(this, _instanceExtraInitializers);
|
|
532
|
+
/** The profile dsh installs into; discovered from this module's own
|
|
533
|
+
* location when the caller does not supply one. */
|
|
534
|
+
profile;
|
|
535
|
+
profileDir;
|
|
536
|
+
inventory;
|
|
537
|
+
dshBin;
|
|
538
|
+
/** The install gate runs against the last loaded snapshot, never a fresh
|
|
539
|
+
* fetch per request (§7.2: the Host's cached snapshot is the truth). */
|
|
540
|
+
/** Finished install records retained, so a poll sees the true terminal
|
|
541
|
+
* state (§8: done / needsRestart / failure detail). Oldest evicted on add. */
|
|
542
|
+
static MAX_FINISHED_INSTALLS = 32;
|
|
543
|
+
/** The install gate runs against the last loaded snapshot, never a fresh
|
|
544
|
+
* fetch per request (§7.2: the Host's cached snapshot is the truth). */
|
|
545
|
+
lastSnapshot = null;
|
|
546
|
+
/** Install records, running and finished; a poll finds one here or reports not found. */
|
|
547
|
+
installs = /* @__PURE__ */ new Map();
|
|
548
|
+
/** Every install id in insertion order, oldest first; finished-record eviction walks this from the front. */
|
|
549
|
+
installOrder = [];
|
|
550
|
+
constructor(ctx, options = {}) {
|
|
551
|
+
super(ctx, "store");
|
|
552
|
+
this.options = options;
|
|
553
|
+
this.profile = options.profile ?? discoverProfile(fileURLToPath(import.meta.url), this.bootBaseDir()).name;
|
|
554
|
+
this.profileDir = options.profileDir;
|
|
555
|
+
this.inventory = options.inventory;
|
|
556
|
+
this.dshBin = options.dshBin ?? "dsh";
|
|
557
|
+
}
|
|
558
|
+
/** The boot's Loader root directory (the active profile's `cordis.yml`
|
|
559
|
+
* directory, carried on `ctx.baseUrl`), when present. A `link:` install
|
|
560
|
+
* keeps this package at its source location, so the walk-up from
|
|
561
|
+
* `import.meta.url` finds the repo rather than a profile; `ctx.baseUrl`
|
|
562
|
+
* is the boot-provided authoritative answer. */
|
|
563
|
+
bootBaseDir() {
|
|
564
|
+
const baseUrl = this.ctx.baseUrl;
|
|
565
|
+
if (typeof baseUrl !== "string" || !baseUrl.startsWith("file:")) return void 0;
|
|
566
|
+
try {
|
|
567
|
+
return fileURLToPath(baseUrl);
|
|
568
|
+
} catch {}
|
|
569
|
+
}
|
|
570
|
+
/** The profile directory the user layer lives in — the discovered default
|
|
571
|
+
* stays lazy so `setEnabled` works in tests via the `profileDir` option
|
|
572
|
+
* without requiring a real profile above this module. */
|
|
573
|
+
profileDirResolved() {
|
|
574
|
+
if (this.profileDir !== void 0) return this.profileDir;
|
|
575
|
+
return discoverProfile(fileURLToPath(import.meta.url), this.bootBaseDir()).dir;
|
|
576
|
+
}
|
|
577
|
+
listInventory() {
|
|
578
|
+
if (this.inventory !== void 0) return this.inventory.list();
|
|
579
|
+
const inventory = this.ctx.get?.("pluginInventory");
|
|
580
|
+
if (inventory === void 0) throw new Error("dsh-plugin-shop: pluginInventory service is not mounted");
|
|
581
|
+
return inventory.list();
|
|
582
|
+
}
|
|
583
|
+
/** Enable or disable one installed plugin, hot (§8): a disable writes the
|
|
584
|
+
* row to the user layer, an enable drops it again so the bundle default
|
|
585
|
+
* rules — the CLI's watchUserPatches applies either through HMR. */
|
|
586
|
+
setEnabled(args) {
|
|
587
|
+
const entry = this.listInventory().find((entry) => entry.moduleName === args.name);
|
|
588
|
+
if (entry === void 0) return {
|
|
589
|
+
ok: false,
|
|
590
|
+
detail: `dsh-plugin-shop: ${args.name} is not installed`
|
|
591
|
+
};
|
|
592
|
+
setUserLayerRow({
|
|
593
|
+
profileDir: this.profileDirResolved(),
|
|
594
|
+
row: {
|
|
595
|
+
id: entry.entryId,
|
|
596
|
+
disabled: !args.enabled
|
|
597
|
+
}
|
|
598
|
+
});
|
|
599
|
+
return { ok: true };
|
|
600
|
+
}
|
|
601
|
+
rowConfig() {
|
|
602
|
+
if (this.options.catalogUrl !== void 0 && this.options.cacheDir !== void 0) return {
|
|
603
|
+
catalogUrl: this.options.catalogUrl,
|
|
604
|
+
cacheDir: this.options.cacheDir
|
|
605
|
+
};
|
|
606
|
+
const config = (this.ctx.loader?.entries().find((entry) => entry.options.name === "dsh-plugin-shop"))?.options.config;
|
|
607
|
+
const catalogUrl = config?.catalogUrl;
|
|
608
|
+
const cacheDir = config?.cacheDir;
|
|
609
|
+
if (typeof catalogUrl !== "string" || typeof cacheDir !== "string") throw new Error("dsh-plugin-shop: the store row is missing catalogUrl or cacheDir config");
|
|
610
|
+
return {
|
|
611
|
+
catalogUrl,
|
|
612
|
+
cacheDir
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
/** Browse the catalog (§7.3): cached snapshot, refreshed on demand. */
|
|
616
|
+
async catalog(args) {
|
|
617
|
+
const { catalogUrl, cacheDir } = this.rowConfig();
|
|
618
|
+
const { snapshot, stale } = await (this.options.loadCatalog ?? loadCatalog)({
|
|
619
|
+
baseUrl: catalogUrl,
|
|
620
|
+
cacheDir,
|
|
621
|
+
refresh: args?.refresh ?? false
|
|
622
|
+
});
|
|
623
|
+
this.lastSnapshot = snapshot;
|
|
624
|
+
return {
|
|
625
|
+
schemaVersion: snapshot.schemaVersion,
|
|
626
|
+
builtAt: snapshot.builtAt,
|
|
627
|
+
stale,
|
|
628
|
+
plugins: snapshot.entries,
|
|
629
|
+
denied: snapshot.denied
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Install one cataloged version into the profile (§7.2). The four rejection
|
|
634
|
+
* paths run against this Host's snapshot before anything is spawned; only a
|
|
635
|
+
* passing request reaches the executor.
|
|
636
|
+
*/
|
|
637
|
+
async install(args) {
|
|
638
|
+
if (this.lastSnapshot === null) {
|
|
639
|
+
const { catalogUrl, cacheDir } = this.rowConfig();
|
|
640
|
+
const { snapshot } = await (this.options.loadCatalog ?? loadCatalog)({
|
|
641
|
+
baseUrl: catalogUrl,
|
|
642
|
+
cacheDir
|
|
643
|
+
});
|
|
644
|
+
this.lastSnapshot = snapshot;
|
|
645
|
+
}
|
|
646
|
+
const verdict = validateInstall(this.lastSnapshot, args);
|
|
647
|
+
if (!verdict.ok) return {
|
|
648
|
+
ok: false,
|
|
649
|
+
code: verdict.code,
|
|
650
|
+
detail: verdict.detail
|
|
651
|
+
};
|
|
652
|
+
const running = startInstall({
|
|
653
|
+
profile: this.profile,
|
|
654
|
+
spec: `${args.name}@${args.version}`,
|
|
655
|
+
dshBin: this.dshBin,
|
|
656
|
+
expectedName: args.name
|
|
657
|
+
});
|
|
658
|
+
this.installs.set(running.installId, running);
|
|
659
|
+
this.installOrder.push(running.installId);
|
|
660
|
+
this.evictFinishedInstalls();
|
|
661
|
+
return {
|
|
662
|
+
ok: true,
|
|
663
|
+
installId: running.installId
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
/** Bound retained finished records at MAX_FINISHED_INSTALLS, evicting the
|
|
667
|
+
* oldest finished ones (insertion order, oldest first). Running records
|
|
668
|
+
* are never evicted; an id absent from the map reports `found: false`. */
|
|
669
|
+
evictFinishedInstalls() {
|
|
670
|
+
const finishedIds = [];
|
|
671
|
+
for (const id of this.installOrder) {
|
|
672
|
+
const record = this.installs.get(id);
|
|
673
|
+
if (record !== void 0 && record.status().state !== "running") finishedIds.push(id);
|
|
674
|
+
}
|
|
675
|
+
const excess = Math.max(0, finishedIds.length - StoreGateway.MAX_FINISHED_INSTALLS);
|
|
676
|
+
for (const id of finishedIds.slice(0, excess)) this.installs.delete(id);
|
|
677
|
+
}
|
|
678
|
+
/** Poll one install's progress (§7.2); unknown ids report `found: false`. */
|
|
679
|
+
installStatus(args) {
|
|
680
|
+
const running = this.installs.get(args.installId);
|
|
681
|
+
if (running === void 0) return {
|
|
682
|
+
found: false,
|
|
683
|
+
state: "failed",
|
|
684
|
+
log: [],
|
|
685
|
+
detail: `unknown installId: ${args.installId}`
|
|
686
|
+
};
|
|
687
|
+
return {
|
|
688
|
+
found: true,
|
|
689
|
+
...running.status()
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
/** Installed plugins whose installed version is older than the catalog's (§7.3). */
|
|
693
|
+
async outdated() {
|
|
694
|
+
if (this.lastSnapshot === null) {
|
|
695
|
+
const { catalogUrl, cacheDir } = this.rowConfig();
|
|
696
|
+
const { snapshot } = await (this.options.loadCatalog ?? loadCatalog)({
|
|
697
|
+
baseUrl: catalogUrl,
|
|
698
|
+
cacheDir
|
|
699
|
+
});
|
|
700
|
+
this.lastSnapshot = snapshot;
|
|
701
|
+
}
|
|
702
|
+
const dependencies = readProfileManifest("dsh-plugin-shop", this.profileDirResolved()).dependencies ?? {};
|
|
703
|
+
const outdated = [];
|
|
704
|
+
for (const entry of this.lastSnapshot.entries) {
|
|
705
|
+
const installed = dependencies[entry.name];
|
|
706
|
+
if (installed === void 0) continue;
|
|
707
|
+
let floor;
|
|
708
|
+
if (installed === entry.version) floor = installed;
|
|
709
|
+
else try {
|
|
710
|
+
floor = minVersion(installed)?.version ?? null;
|
|
711
|
+
} catch {
|
|
712
|
+
floor = null;
|
|
713
|
+
}
|
|
714
|
+
if (floor !== null && lt(floor, entry.version)) outdated.push({
|
|
715
|
+
name: entry.name,
|
|
716
|
+
installed,
|
|
717
|
+
latest: entry.version
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
return outdated;
|
|
721
|
+
}
|
|
722
|
+
};
|
|
723
|
+
})();
|
|
724
|
+
//#endregion
|
|
725
|
+
export { StoreGateway, StoreGateway as default };
|