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/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 };