dsh-plugin-shop 0.7.4 → 0.8.0-beta.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/client.js +251 -123
- package/lib/index.js +648 -142
- package/lib/typert.host.js +19 -13
- package/lib/typert.remote-client.js +16 -10
- package/lib/types/client/index.d.ts +3 -0
- package/lib/types/client/locales.d.ts +2 -0
- package/lib/types/client/present.d.ts +4 -2
- package/lib/types/client/useInstall.d.ts +18 -0
- package/lib/types/host/executor.d.ts +18 -0
- package/lib/types/host/hot.d.ts +5 -0
- package/lib/types/host/index.d.ts +44 -10
- package/lib/types/host/install.d.ts +9 -6
- package/lib/types/host/npm-origin.d.ts +4 -0
- package/lib/types/host/origin.d.ts +7 -1
- package/lib/types/host/peers.d.ts +75 -4
- package/lib/types/host/repo-pins.d.ts +1 -1
- package/lib/types/host/restart.d.ts +22 -2
- package/lib/types/host/self-update.d.ts +12 -4
- package/lib/types/own-version.d.ts +10 -0
- package/lib/types/shared/identity.d.ts +27 -0
- package/package.json +9 -9
package/lib/index.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
3
3
|
import { loadOptionalPatches, readProfileManifest, resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
|
|
4
|
-
import { lt, minVersion, valid } from "semver";
|
|
4
|
+
import { lt, minVersion, satisfies, valid, validRange } from "semver";
|
|
5
5
|
import { createHash, randomUUID } from "node:crypto";
|
|
6
|
-
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
8
8
|
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import { z } from "zod";
|
|
11
11
|
import { gunzipSync } from "node:zlib";
|
|
12
|
-
import { spawn } from "node:child_process";
|
|
13
|
-
import {
|
|
12
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
13
|
+
import { StringDecoder } from "node:string_decoder";
|
|
14
|
+
import { DEFAULT_SCHEMA, JSON_SCHEMA, Type, dump, load } from "js-yaml";
|
|
14
15
|
//#region src/own-version.ts
|
|
15
16
|
/** The shop's own published version, read from the package.json that ships
|
|
16
17
|
* next to this package — the RUNNING version, not the manifest's range
|
|
@@ -18,8 +19,27 @@ import { JSON_SCHEMA, Type, dump, load } from "js-yaml";
|
|
|
18
19
|
* both the source tree (tests) and the bundled `lib/index.js` sit exactly
|
|
19
20
|
* one level below the package root, so the same relative URL resolves in
|
|
20
21
|
* both. */
|
|
22
|
+
function ownManifest() {
|
|
23
|
+
return JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"));
|
|
24
|
+
}
|
|
21
25
|
function ownVersion() {
|
|
22
|
-
return
|
|
26
|
+
return ownManifest().version;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The peer ranges this build declares — the input to the load-time harness
|
|
30
|
+
* self-check. Read from the same shipped manifest as `ownVersion`, so the
|
|
31
|
+
* ranges checked are the ones this build was actually published with, never
|
|
32
|
+
* a second copy that can drift from them.
|
|
33
|
+
*
|
|
34
|
+
* A non-string range is dropped rather than repaired: only what we can
|
|
35
|
+
* compare against is worth a verdict.
|
|
36
|
+
*/
|
|
37
|
+
function ownPeerRanges() {
|
|
38
|
+
const declared = ownManifest().peerDependencies;
|
|
39
|
+
if (declared === null || typeof declared !== "object") return {};
|
|
40
|
+
const ranges = {};
|
|
41
|
+
for (const [spec, range] of Object.entries(declared)) if (typeof range === "string") ranges[spec] = range;
|
|
42
|
+
return ranges;
|
|
23
43
|
}
|
|
24
44
|
//#endregion
|
|
25
45
|
//#region src/host/origin.ts
|
|
@@ -51,6 +71,39 @@ function resolveDataUrl(baseUrl, url) {
|
|
|
51
71
|
if (resolved.origin !== new URL(baseUrl).origin) throw new Error("catalog data url must be relative to the catalog base");
|
|
52
72
|
return resolved.href;
|
|
53
73
|
}
|
|
74
|
+
/** The largest catalog body this host reads into memory. */
|
|
75
|
+
const MAX_BODY_BYTES = 67108864;
|
|
76
|
+
/** Read a response body through a byte cap, converting transport failures. */
|
|
77
|
+
async function readCappedBytes(response, label, maxBytes = MAX_BODY_BYTES) {
|
|
78
|
+
if (response.body === null) throw new TransportError(`${label} returned no body`);
|
|
79
|
+
const reader = response.body.getReader();
|
|
80
|
+
const chunks = [];
|
|
81
|
+
let bytes = 0;
|
|
82
|
+
try {
|
|
83
|
+
for (;;) {
|
|
84
|
+
const { done, value } = await reader.read();
|
|
85
|
+
if (done) break;
|
|
86
|
+
bytes += value.byteLength;
|
|
87
|
+
if (bytes > maxBytes) {
|
|
88
|
+
try {
|
|
89
|
+
await reader.cancel();
|
|
90
|
+
} catch {}
|
|
91
|
+
throw new TransportError(`${label} exceeded the ${maxBytes}-byte cap`);
|
|
92
|
+
}
|
|
93
|
+
chunks.push(Buffer.from(value));
|
|
94
|
+
}
|
|
95
|
+
} catch (error) {
|
|
96
|
+
if (error instanceof TransportError) throw error;
|
|
97
|
+
throw new TransportError(`${label} body read failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
98
|
+
} finally {
|
|
99
|
+
reader.releaseLock();
|
|
100
|
+
}
|
|
101
|
+
return Buffer.concat(chunks);
|
|
102
|
+
}
|
|
103
|
+
/** Read a capped response body as UTF-8 text. */
|
|
104
|
+
async function readCappedText(response, label, maxBytes = MAX_BODY_BYTES) {
|
|
105
|
+
return (await readCappedBytes(response, label, maxBytes)).toString("utf8");
|
|
106
|
+
}
|
|
54
107
|
/** The transport this project has always used: a static `v1/` tree. */
|
|
55
108
|
function httpOrigin(baseUrl, fetchImpl) {
|
|
56
109
|
const id = `http:${baseUrl}`;
|
|
@@ -65,21 +118,21 @@ function httpOrigin(baseUrl, fetchImpl) {
|
|
|
65
118
|
throw new TransportError(`catalog pointer fetch failed for ${id}: ${detail}`, { cause: error });
|
|
66
119
|
}
|
|
67
120
|
if (!response.ok) throw new TransportError(`catalog pointer returned ${response.status} for ${id}`);
|
|
68
|
-
const pointerText = await response
|
|
121
|
+
const pointerText = await readCappedText(response, `catalog pointer fetch for ${id}`);
|
|
69
122
|
return {
|
|
70
123
|
id,
|
|
71
124
|
pointer: async () => pointerText,
|
|
72
|
-
file: async (url) => {
|
|
125
|
+
file: async (url, signal) => {
|
|
73
126
|
const resolved = resolveDataUrl(baseUrl, url);
|
|
74
127
|
let dataResponse;
|
|
75
128
|
try {
|
|
76
|
-
dataResponse = await fetchImpl(resolved);
|
|
129
|
+
dataResponse = await fetchImpl(resolved, signal === void 0 ? void 0 : { signal });
|
|
77
130
|
} catch (error) {
|
|
78
131
|
const detail = error instanceof Error ? error.message : String(error);
|
|
79
132
|
throw new TransportError(`catalog data fetch failed for ${id}: ${detail}`, { cause: error });
|
|
80
133
|
}
|
|
81
134
|
if (!dataResponse.ok) throw new TransportError(`catalog data returned ${dataResponse.status} for ${id}`);
|
|
82
|
-
return dataResponse
|
|
135
|
+
return readCappedText(dataResponse, `catalog data fetch for ${id}`);
|
|
83
136
|
}
|
|
84
137
|
};
|
|
85
138
|
}
|
|
@@ -158,6 +211,10 @@ const latestSchema = z.object({
|
|
|
158
211
|
});
|
|
159
212
|
/** Where the published package keeps the catalog tree (design §2). */
|
|
160
213
|
const PACKAGE_ROOT = "package/v1/";
|
|
214
|
+
/** Maximum compressed package bytes read from a registry. */
|
|
215
|
+
const MAX_PACKAGE_BYTES = 33554432;
|
|
216
|
+
/** Maximum bytes allowed after gzip inflation. */
|
|
217
|
+
const MAX_INFLATED_BYTES = MAX_BODY_BYTES;
|
|
161
218
|
/** Verify tarball bytes against npm's own Subresource-Integrity string.
|
|
162
219
|
* `dist.integrity` may carry several space-separated digests; npm publishes
|
|
163
220
|
* one, and the first is the one we check. */
|
|
@@ -216,7 +273,7 @@ function npmOrigin(rawRegistryUrl, packageName, fetchImpl) {
|
|
|
216
273
|
throw new TransportError(`npm origin ${registryUrl} returned an unparsable manifest: ${detail}`, { cause: error });
|
|
217
274
|
}
|
|
218
275
|
let files = null;
|
|
219
|
-
const load = async () => {
|
|
276
|
+
const load = async (loadSignal) => {
|
|
220
277
|
if (files !== null) return files;
|
|
221
278
|
let tarballUrl;
|
|
222
279
|
try {
|
|
@@ -228,41 +285,38 @@ function npmOrigin(rawRegistryUrl, packageName, fetchImpl) {
|
|
|
228
285
|
if (tarballUrl.origin !== new URL(registryUrl).origin) throw new TransportError(`npm origin ${registryUrl}: dist.tarball host ${tarballUrl.origin} is not the registry's`);
|
|
229
286
|
let tarballResponse;
|
|
230
287
|
try {
|
|
231
|
-
tarballResponse = await fetchImpl(tarballUrl.href);
|
|
288
|
+
tarballResponse = await fetchImpl(tarballUrl.href, loadSignal === void 0 ? void 0 : { signal: loadSignal });
|
|
232
289
|
} catch (error) {
|
|
233
290
|
const detail = error instanceof Error ? error.message : String(error);
|
|
234
291
|
throw new TransportError(`npm origin ${registryUrl} tarball fetch failed: ${detail}`, { cause: error });
|
|
235
292
|
}
|
|
236
293
|
if (!tarballResponse.ok) throw new TransportError(`npm origin ${registryUrl} tarball returned ${tarballResponse.status}`);
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
} catch (error) {
|
|
241
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
242
|
-
throw new TransportError(`npm origin ${registryUrl} tarball body read failed: ${detail}`, { cause: error });
|
|
243
|
-
}
|
|
294
|
+
const declaredLength = Number(tarballResponse.headers.get("content-length"));
|
|
295
|
+
if (Number.isFinite(declaredLength) && declaredLength > 33554432) throw new TransportError(`npm origin ${registryUrl} tarball exceeded the ${MAX_PACKAGE_BYTES}-byte cap`);
|
|
296
|
+
const bytes = await readCappedBytes(tarballResponse, `npm origin ${registryUrl} tarball`, MAX_PACKAGE_BYTES);
|
|
244
297
|
verifyIntegrity(bytes, manifest.dist.integrity, registryUrl);
|
|
245
298
|
let parsed;
|
|
246
299
|
try {
|
|
247
|
-
parsed = readTar(gunzipSync(bytes));
|
|
300
|
+
parsed = readTar(gunzipSync(bytes, { maxOutputLength: MAX_INFLATED_BYTES }));
|
|
248
301
|
} catch (error) {
|
|
249
302
|
const detail = error instanceof Error ? error.message : String(error);
|
|
303
|
+
if (error.code === "ERR_BUFFER_TOO_LARGE" || /larger than|too large/i.test(detail)) throw new TransportError(`npm origin ${registryUrl}: the tarball inflates past the ${MAX_INFLATED_BYTES}-byte cap; refusing to read it`, { cause: error });
|
|
250
304
|
throw new TransportError(`npm origin ${registryUrl} served an unparsable tarball: ${detail}`, { cause: error });
|
|
251
305
|
}
|
|
252
306
|
files = parsed;
|
|
253
307
|
return files;
|
|
254
308
|
};
|
|
255
|
-
const read = async (name) => {
|
|
256
|
-
const entry = (await load()).get(`${PACKAGE_ROOT}${name}`);
|
|
309
|
+
const read = async (name, readSignal) => {
|
|
310
|
+
const entry = (await load(readSignal)).get(`${PACKAGE_ROOT}${name}`);
|
|
257
311
|
if (entry === void 0) throw new TransportError(`npm origin ${registryUrl}: ${name} is not in the catalog package`);
|
|
258
312
|
return entry.toString("utf8");
|
|
259
313
|
};
|
|
260
314
|
return {
|
|
261
315
|
id,
|
|
262
316
|
pointer: async () => read("index.json"),
|
|
263
|
-
file: async (url) => {
|
|
317
|
+
file: async (url, fileSignal) => {
|
|
264
318
|
if (url.includes("/") || url.startsWith(".")) throw new Error(`npm origin ${registryUrl}: ${JSON.stringify(url)} must be a plain file name`);
|
|
265
|
-
return read(url);
|
|
319
|
+
return read(url, fileSignal);
|
|
266
320
|
}
|
|
267
321
|
};
|
|
268
322
|
}
|
|
@@ -318,6 +372,38 @@ function inCompletionOrder(promises) {
|
|
|
318
372
|
}
|
|
319
373
|
})();
|
|
320
374
|
}
|
|
375
|
+
//#endregion
|
|
376
|
+
//#region src/shared/identity.ts
|
|
377
|
+
/** The registry's uniqueness rule, shared with the presentation layer. */
|
|
378
|
+
function identityKey(identity) {
|
|
379
|
+
return identity.source === "npm" ? `npm:${identity.name}` : `github:${identity.repo ?? identity.name}#${identity.subdir ?? ""}`;
|
|
380
|
+
}
|
|
381
|
+
/** A GitHub commit pin. */
|
|
382
|
+
const COMMIT_SHA = /^[0-9a-f]{40}$/;
|
|
383
|
+
/** A GitHub release tag accepted as the version/pin of a rescued entry. */
|
|
384
|
+
const RELEASE_TAG = /^[A-Za-z0-9][A-Za-z0-9._+/-]{0,127}$/;
|
|
385
|
+
/** `owner/slug`, lowercased, or null when this is not a repository reference. */
|
|
386
|
+
const REPO = "([\\w.-]+)\\/([\\w.-]+?)";
|
|
387
|
+
const GITHUB_SHORTHAND = new RegExp(`^github:${REPO}(?:[#&].*)?$`);
|
|
388
|
+
const GITHUB_URL = new RegExp(`^(?:git\\+)?https?:\\/\\/(?:www\\.)?github\\.com\\/${REPO}(?:\\.git)?(?:[\\/?#].*)?$`);
|
|
389
|
+
/**
|
|
390
|
+
* Read the repository out of a profile dependency spec, or return null for
|
|
391
|
+
* an npm range/spec that does not identify a GitHub repository.
|
|
392
|
+
*/
|
|
393
|
+
function parseRepoSpec(spec) {
|
|
394
|
+
const shorthand = GITHUB_SHORTHAND.exec(spec);
|
|
395
|
+
if (shorthand !== null && shorthand[1] !== void 0 && shorthand[2] !== void 0) return `${shorthand[1]}/${shorthand[2]}`.toLowerCase();
|
|
396
|
+
const url = GITHUB_URL.exec(spec);
|
|
397
|
+
if (url !== null && url[1] !== void 0 && url[2] !== void 0) return `${url[1]}/${url[2]}`.toLowerCase();
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
/** Whether an installed dependency spec names this catalog entry. */
|
|
401
|
+
function installedSpecMatches(entry, spec) {
|
|
402
|
+
const repo = parseRepoSpec(spec);
|
|
403
|
+
if (entry.source === "npm") return repo === null;
|
|
404
|
+
if (entry.repo === void 0) return false;
|
|
405
|
+
return repo === entry.repo.toLowerCase();
|
|
406
|
+
}
|
|
321
407
|
/** A cached catalog younger than this is served without touching the network. */
|
|
322
408
|
const FRESH_MS = 3e5;
|
|
323
409
|
/** How long a probe may take before the race gives up on that origin. Long
|
|
@@ -332,27 +418,35 @@ const PROBE_TIMEOUT_MS = 1e4;
|
|
|
332
418
|
* exists to prevent. Generous against every measured npm origin (12.53 MB/s
|
|
333
419
|
* mirror -> 0.12 s for 1.5 MB; npmjs direct 1.99 MB/s -> 0.75 s). */
|
|
334
420
|
const COMMIT_TIMEOUT_MS = 3e4;
|
|
335
|
-
/**
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
* and a stalled origin we have already abandoned costs nothing but its own
|
|
339
|
-
* socket. */
|
|
340
|
-
async function withCommitTimeout(work, id) {
|
|
421
|
+
/** Run one committed-origin read under a deadline and abort it on expiry. */
|
|
422
|
+
async function withCommitBudget(id, what, start) {
|
|
423
|
+
const controller = new AbortController();
|
|
341
424
|
let timer;
|
|
342
425
|
try {
|
|
343
|
-
return await Promise.race([
|
|
344
|
-
timer = setTimeout(() =>
|
|
426
|
+
return await Promise.race([start(controller.signal), new Promise((_resolve, reject) => {
|
|
427
|
+
timer = setTimeout(() => {
|
|
428
|
+
const expired = new TransportError(`${id} did not ${what} within ${COMMIT_TIMEOUT_MS} ms`);
|
|
429
|
+
controller.abort(expired);
|
|
430
|
+
reject(expired);
|
|
431
|
+
}, COMMIT_TIMEOUT_MS);
|
|
345
432
|
})]);
|
|
346
433
|
} finally {
|
|
347
434
|
clearTimeout(timer);
|
|
348
435
|
}
|
|
349
436
|
}
|
|
437
|
+
/** npm's package-name grammar. The value becomes half of an install spec, so
|
|
438
|
+
* aliases and shell punctuation must not reach the CLI. */
|
|
439
|
+
const NPM_NAME = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
|
|
440
|
+
/** Canonical SemVer 2.0.0, including legal prerelease/build metadata. */
|
|
441
|
+
const NPM_VERSION = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(?:\+[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)?$/;
|
|
442
|
+
/** The repository binding used to build a GitHub install spec. */
|
|
443
|
+
const REPO_FULL_NAME = /^[\w.-]+\/[\w.-]+$/;
|
|
350
444
|
/** Records when the loader itself wrote the cache; the pointer's `builtAt` is
|
|
351
445
|
* the catalog's build time, not the cache's fetch time. */
|
|
352
446
|
const META_FILE = "index.meta.json";
|
|
353
447
|
const entrySchema = z.object({
|
|
354
|
-
name: z.string(),
|
|
355
|
-
version: z.string(),
|
|
448
|
+
name: z.string().min(1).max(214).regex(/^[^\u0000-\u001f\u007f]+$/, "entry name carries a control character"),
|
|
449
|
+
version: z.string().min(1).max(256),
|
|
356
450
|
integrity: z.string().nullable(),
|
|
357
451
|
publishedAt: z.string().nullable(),
|
|
358
452
|
repository: z.string().nullable(),
|
|
@@ -388,15 +482,39 @@ const entrySchema = z.object({
|
|
|
388
482
|
capabilities: z.array(z.string())
|
|
389
483
|
}).optional(),
|
|
390
484
|
source: z.enum(["npm", "github"]).default("npm"),
|
|
391
|
-
repo: z.string().optional(),
|
|
485
|
+
repo: z.string().regex(REPO_FULL_NAME, "repo must be owner/slug").optional(),
|
|
392
486
|
subdir: z.string().regex(/^(?!.*(^|\/)\.\.?(\/|$))[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/).optional(),
|
|
393
487
|
added: z.string().optional(),
|
|
394
488
|
tarball: z.object({
|
|
395
489
|
url: z.string(),
|
|
396
|
-
sha256: z.string()
|
|
490
|
+
sha256: z.string().regex(/^[0-9a-f]{64}$/)
|
|
397
491
|
}).optional(),
|
|
398
492
|
publisher: z.string().optional(),
|
|
399
493
|
peers: z.array(z.string()).optional()
|
|
494
|
+
}).superRefine((entry, ctx) => {
|
|
495
|
+
if (entry.source === "npm") {
|
|
496
|
+
if (!NPM_NAME.test(entry.name)) ctx.addIssue({
|
|
497
|
+
code: "custom",
|
|
498
|
+
path: ["name"],
|
|
499
|
+
message: `npm entry name ${JSON.stringify(entry.name)} is outside npm package-name grammar`
|
|
500
|
+
});
|
|
501
|
+
if (!NPM_VERSION.test(entry.version)) ctx.addIssue({
|
|
502
|
+
code: "custom",
|
|
503
|
+
path: ["version"],
|
|
504
|
+
message: `npm entry version ${JSON.stringify(entry.version)} is not a plain semver version`
|
|
505
|
+
});
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
if (entry.repo === void 0) ctx.addIssue({
|
|
509
|
+
code: "custom",
|
|
510
|
+
path: ["repo"],
|
|
511
|
+
message: "a github entry must carry its repo — it is the entry's identity and the spec is built from it"
|
|
512
|
+
});
|
|
513
|
+
if (!COMMIT_SHA.test(entry.version) && !RELEASE_TAG.test(entry.version)) ctx.addIssue({
|
|
514
|
+
code: "custom",
|
|
515
|
+
path: ["version"],
|
|
516
|
+
message: `github entry version ${JSON.stringify(entry.version)} is neither a 40-character commit sha nor a release tag`
|
|
517
|
+
});
|
|
400
518
|
});
|
|
401
519
|
const dataSchema = z.object({
|
|
402
520
|
schemaVersion: z.number(),
|
|
@@ -429,6 +547,7 @@ function validateEntryCoherence(entries) {
|
|
|
429
547
|
throw new Error(`catalog entry ${entry.name}: tarball url is unparseable`);
|
|
430
548
|
}
|
|
431
549
|
if (parsed.protocol !== "https:" || parsed.hostname !== "github.com") throw new Error(`catalog entry ${entry.name}: tarball url must be https on github.com`);
|
|
550
|
+
if (parsed.search !== "" || parsed.hash !== "") throw new Error(`catalog entry ${entry.name}: tarball url must carry no query or fragment`);
|
|
432
551
|
const segments = parsed.pathname.split("/").filter((s) => s !== "");
|
|
433
552
|
if (`${segments[0] ?? ""}/${segments[1] ?? ""}`.toLowerCase() !== entry.repo.toLowerCase() || segments[2] !== "releases") throw new Error(`catalog entry ${entry.name}: tarball url is not a release of ${entry.repo}`);
|
|
434
553
|
}
|
|
@@ -460,7 +579,7 @@ function parseStarsText(text) {
|
|
|
460
579
|
try {
|
|
461
580
|
const parsed = JSON.parse(text);
|
|
462
581
|
if (typeof parsed.stars !== "object" || parsed.stars === null) return {};
|
|
463
|
-
const out =
|
|
582
|
+
const out = Object.create(null);
|
|
464
583
|
for (const [key, value] of Object.entries(parsed.stars)) if (typeof value === "number") out[key] = value;
|
|
465
584
|
return out;
|
|
466
585
|
} catch {
|
|
@@ -553,7 +672,7 @@ async function loadCatalog(options) {
|
|
|
553
672
|
continue;
|
|
554
673
|
}
|
|
555
674
|
try {
|
|
556
|
-
pointerText = await
|
|
675
|
+
pointerText = await withCommitBudget(settled.value.id, "produce a pointer", () => settled.value.pointer());
|
|
557
676
|
handle = settled.value;
|
|
558
677
|
break;
|
|
559
678
|
} catch (error) {
|
|
@@ -566,7 +685,7 @@ async function loadCatalog(options) {
|
|
|
566
685
|
if (pointer.schemaVersion > 6) throw new Error(`catalog schemaVersion ${pointer.schemaVersion} is newer than this build supports (6)`);
|
|
567
686
|
let dataText;
|
|
568
687
|
try {
|
|
569
|
-
dataText = await handle.file(pointer.plugins.url);
|
|
688
|
+
dataText = await withCommitBudget(handle.id, `serve ${pointer.plugins.url}`, (signal) => handle.file(pointer.plugins.url, signal));
|
|
570
689
|
} catch (error) {
|
|
571
690
|
if (!(error instanceof TransportError)) throw error;
|
|
572
691
|
return cachedOrThrow(error);
|
|
@@ -577,13 +696,16 @@ async function loadCatalog(options) {
|
|
|
577
696
|
if (data.schemaVersion > 6) throw new Error(`catalog schemaVersion ${data.schemaVersion} is newer than this build supports (6)`);
|
|
578
697
|
validateEntryCoherence(data.plugins);
|
|
579
698
|
let stars = {};
|
|
580
|
-
if (pointer.stars !== void 0)
|
|
581
|
-
const
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
699
|
+
if (pointer.stars !== void 0) {
|
|
700
|
+
const sidecar = pointer.stars;
|
|
701
|
+
try {
|
|
702
|
+
const starsText = await withCommitBudget(handle.id, `serve ${sidecar.url}`, (signal) => handle.file(sidecar.url, signal));
|
|
703
|
+
if (createHash("sha256").update(starsText).digest("hex") === sidecar.sha256) {
|
|
704
|
+
stars = parseStarsText(starsText);
|
|
705
|
+
fsImpl.write(join(cacheDir, basename(sidecar.url)), starsText);
|
|
706
|
+
}
|
|
707
|
+
} catch {}
|
|
708
|
+
}
|
|
587
709
|
const snapshot = {
|
|
588
710
|
schemaVersion: pointer.schemaVersion,
|
|
589
711
|
builtAt: pointer.builtAt,
|
|
@@ -672,11 +794,7 @@ function npmrcRegistry(readFile, home) {
|
|
|
672
794
|
}
|
|
673
795
|
//#endregion
|
|
674
796
|
//#region src/host/install.ts
|
|
675
|
-
/**
|
|
676
|
-
* Decide whether one install request may proceed, against the Host's own
|
|
677
|
-
* snapshot (§5.3). The browser sends a name; nothing the browser says about
|
|
678
|
-
* the package is trusted.
|
|
679
|
-
*/
|
|
797
|
+
/** Decide whether an install request may proceed and return the resolved row. */
|
|
680
798
|
function validateInstall(snapshot, args) {
|
|
681
799
|
const denied = snapshot.denied.find((d) => d.name === args.name);
|
|
682
800
|
if (denied !== void 0) return {
|
|
@@ -684,7 +802,29 @@ function validateInstall(snapshot, args) {
|
|
|
684
802
|
code: "denied",
|
|
685
803
|
detail: `dsh-plugin-shop: ${args.name} is denied: ${denied.detail}`
|
|
686
804
|
};
|
|
687
|
-
|
|
805
|
+
let entry;
|
|
806
|
+
if (args.source === void 0) {
|
|
807
|
+
const named = snapshot.entries.filter((e) => e.name === args.name);
|
|
808
|
+
if (named.length > 1) return {
|
|
809
|
+
ok: false,
|
|
810
|
+
code: "ambiguous-identity",
|
|
811
|
+
detail: `dsh-plugin-shop: the catalog holds ${named.length} entries named ${args.name}, and this request does not say which one; refresh the shop and try again`
|
|
812
|
+
};
|
|
813
|
+
entry = named[0];
|
|
814
|
+
} else {
|
|
815
|
+
const wanted = identityKey({
|
|
816
|
+
source: args.source,
|
|
817
|
+
name: args.name,
|
|
818
|
+
repo: args.repo,
|
|
819
|
+
subdir: args.subdir
|
|
820
|
+
});
|
|
821
|
+
entry = snapshot.entries.find((candidate) => identityKey(candidate) === wanted);
|
|
822
|
+
if (entry === void 0) return {
|
|
823
|
+
ok: false,
|
|
824
|
+
code: "not-in-catalog",
|
|
825
|
+
detail: `dsh-plugin-shop: ${wanted} is not in the catalog`
|
|
826
|
+
};
|
|
827
|
+
}
|
|
688
828
|
if (entry === void 0) return {
|
|
689
829
|
ok: false,
|
|
690
830
|
code: "not-in-catalog",
|
|
@@ -700,7 +840,10 @@ function validateInstall(snapshot, args) {
|
|
|
700
840
|
code: "needs-acknowledgement",
|
|
701
841
|
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`
|
|
702
842
|
};
|
|
703
|
-
return {
|
|
843
|
+
return {
|
|
844
|
+
ok: true,
|
|
845
|
+
entry
|
|
846
|
+
};
|
|
704
847
|
}
|
|
705
848
|
//#endregion
|
|
706
849
|
//#region src/host/dsh-cli.ts
|
|
@@ -817,6 +960,11 @@ function resolveDshScript(fs, options) {
|
|
|
817
960
|
* post-exit manifest confirmation. */
|
|
818
961
|
const MAX_LOG_LINES = 200;
|
|
819
962
|
const MAX_LOG_BYTES = 65536;
|
|
963
|
+
/** Bound one dsh command so a stalled install cannot hold the profile queue
|
|
964
|
+
* forever. Tests can pass a shorter value; production stays generous. */
|
|
965
|
+
const INSTALL_TIMEOUT_MS = Number(process.env.DSH_SHOP_INSTALL_TIMEOUT_MS) || 9e5;
|
|
966
|
+
/** Grace period for output already buffered after the child exits. */
|
|
967
|
+
const PIPE_DRAIN_MS = 500;
|
|
820
968
|
const profileQueues = /* @__PURE__ */ new Map();
|
|
821
969
|
function chain(profile, task) {
|
|
822
970
|
const next = (profileQueues.get(profile) ?? Promise.resolve()).then(task, task);
|
|
@@ -909,6 +1057,54 @@ function spawnFailureDetail(code, message, dshBin, platform) {
|
|
|
909
1057
|
if (code === "ENOENT") return "dsh not found on PATH — install the dsh CLI to manage profile plugins";
|
|
910
1058
|
return `dsh spawn failed: ${message}`;
|
|
911
1059
|
}
|
|
1060
|
+
/** The failure detail for a command stopped by the shop's deadline. */
|
|
1061
|
+
function installTimeoutDetail(profile, timeoutMs) {
|
|
1062
|
+
return `dsh-plugin-shop: the command did not finish within ${Math.max(1, Math.round(timeoutMs / 1e3))}s and was stopped. Run it yourself to see what it is waiting on: dsh plugin --profile ${profile} install`;
|
|
1063
|
+
}
|
|
1064
|
+
const nodeKills = {
|
|
1065
|
+
killGroup: (pid) => process.kill(-pid, "SIGKILL"),
|
|
1066
|
+
killPid: (pid) => process.kill(pid, "SIGKILL"),
|
|
1067
|
+
taskkill: (pid) => {
|
|
1068
|
+
spawnSync("taskkill", [
|
|
1069
|
+
"/PID",
|
|
1070
|
+
String(pid),
|
|
1071
|
+
"/T",
|
|
1072
|
+
"/F"
|
|
1073
|
+
], { stdio: "ignore" });
|
|
1074
|
+
}
|
|
1075
|
+
};
|
|
1076
|
+
/** Kill a child and its descendants (process group on POSIX, taskkill tree on Windows). */
|
|
1077
|
+
function killTree(pid, platform, kills = nodeKills) {
|
|
1078
|
+
if (pid === void 0) return;
|
|
1079
|
+
if (platform === "win32") {
|
|
1080
|
+
kills.taskkill(pid);
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
try {
|
|
1084
|
+
kills.killGroup(pid);
|
|
1085
|
+
} catch {
|
|
1086
|
+
try {
|
|
1087
|
+
kills.killPid(pid);
|
|
1088
|
+
} catch {}
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
function lineSink(emit) {
|
|
1092
|
+
const decoder = new StringDecoder("utf8");
|
|
1093
|
+
let pending = "";
|
|
1094
|
+
return {
|
|
1095
|
+
write(chunk) {
|
|
1096
|
+
pending += decoder.write(chunk);
|
|
1097
|
+
const parts = pending.split(/\r?\n/);
|
|
1098
|
+
pending = parts.pop() ?? "";
|
|
1099
|
+
for (const line of parts) if (line !== "") emit(line);
|
|
1100
|
+
},
|
|
1101
|
+
flush() {
|
|
1102
|
+
pending += decoder.end();
|
|
1103
|
+
if (pending !== "") emit(pending);
|
|
1104
|
+
pending = "";
|
|
1105
|
+
}
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
912
1108
|
/** Read-only filesystem seam for the CLI lookup; the same shape as `pinFs`. */
|
|
913
1109
|
const nodeFs$1 = {
|
|
914
1110
|
exists: (path) => existsSync(path),
|
|
@@ -926,6 +1122,12 @@ function dshScript() {
|
|
|
926
1122
|
});
|
|
927
1123
|
return cachedScript;
|
|
928
1124
|
}
|
|
1125
|
+
/** Shell punctuation that must never reach the downstream CLI as an operand.
|
|
1126
|
+
* The downstream dsh invokes pnpm with shell mode on Windows, where these
|
|
1127
|
+
* characters alter the command line. `&` is intentionally allowed here: the
|
|
1128
|
+
* legitimate monorepo spec uses it as `&path:<subdir>`, and catalog.ts
|
|
1129
|
+
* validates each component before it reaches this layer. */
|
|
1130
|
+
const UNSAFE_TARGET = /[\s"'`|<>^$();\\{}]|[\u0000-\u001f\u007f]/;
|
|
929
1131
|
/** Run one `dsh plugin --profile <profile> <verb> <target>` and track it.
|
|
930
1132
|
* Never rolls back; a failure surfaces stderr verbatim plus the recovery hint
|
|
931
1133
|
* (§10). The shop never passes build-script flags: `allowBuilds` stays the
|
|
@@ -941,9 +1143,10 @@ function dshScript() {
|
|
|
941
1143
|
* must settle before it. A throwing callback never fails the install — the
|
|
942
1144
|
* package IS installed; it reports `done` with the restart fallback. */
|
|
943
1145
|
function spawnPluginCli(options) {
|
|
944
|
-
const { profile, argv, dshBin, env, confirm, afterDone, onStatus } = options;
|
|
1146
|
+
const { profile, argv, dshBin, env, confirm, afterDone, onStatus, timeoutMs = INSTALL_TIMEOUT_MS } = options;
|
|
945
1147
|
const target = argv[1];
|
|
946
1148
|
if (target === void 0 || target.startsWith("-")) throw new Error(`dsh-plugin-shop: refusing to spawn with a flag-like operand: ${target ?? "(none)"}`);
|
|
1149
|
+
if (UNSAFE_TARGET.test(target)) throw new Error(`dsh-plugin-shop: refusing to spawn with an unsafe operand: ${JSON.stringify(target)}`);
|
|
947
1150
|
const installId = randomUUID();
|
|
948
1151
|
const log = [];
|
|
949
1152
|
let logBytes = 0;
|
|
@@ -961,6 +1164,7 @@ function spawnPluginCli(options) {
|
|
|
961
1164
|
...detail !== void 0 ? { detail } : {}
|
|
962
1165
|
});
|
|
963
1166
|
const append = (line) => {
|
|
1167
|
+
if (state !== "running") return;
|
|
964
1168
|
log.push(line);
|
|
965
1169
|
logBytes += Buffer.byteLength(line);
|
|
966
1170
|
while ((log.length > MAX_LOG_LINES || logBytes > MAX_LOG_BYTES) && log.length > 1) {
|
|
@@ -999,24 +1203,33 @@ function spawnPluginCli(options) {
|
|
|
999
1203
|
"pipe",
|
|
1000
1204
|
"pipe"
|
|
1001
1205
|
],
|
|
1002
|
-
env: env ?? process.env
|
|
1206
|
+
env: env ?? process.env,
|
|
1207
|
+
detached: process.platform !== "win32"
|
|
1003
1208
|
});
|
|
1004
1209
|
} catch (error) {
|
|
1005
1210
|
resolve(failToStart(error));
|
|
1006
1211
|
return;
|
|
1007
1212
|
}
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
child.on("close", async (exitCode) => {
|
|
1213
|
+
let exited = false;
|
|
1214
|
+
let closed = false;
|
|
1215
|
+
let exitCode = null;
|
|
1216
|
+
let timedOut = false;
|
|
1217
|
+
let drainTimer;
|
|
1218
|
+
let deadlineTimer;
|
|
1219
|
+
const outLines = lineSink(append);
|
|
1220
|
+
const errLines = lineSink(append);
|
|
1221
|
+
const settle = async () => {
|
|
1018
1222
|
if (state !== "running") return;
|
|
1019
|
-
|
|
1223
|
+
clearTimeout(drainTimer);
|
|
1224
|
+
clearTimeout(deadlineTimer);
|
|
1225
|
+
outLines.flush();
|
|
1226
|
+
errLines.flush();
|
|
1227
|
+
child.stdout.destroy();
|
|
1228
|
+
child.stderr.destroy();
|
|
1229
|
+
if (timedOut) {
|
|
1230
|
+
state = "failed";
|
|
1231
|
+
detail = installTimeoutDetail(profile, timeoutMs);
|
|
1232
|
+
} else if (exitCode === 0) {
|
|
1020
1233
|
const confirmDetail = confirm?.(env?.DSH_HOME);
|
|
1021
1234
|
if (confirmDetail != null) {
|
|
1022
1235
|
state = "failed";
|
|
@@ -1038,7 +1251,41 @@ function spawnPluginCli(options) {
|
|
|
1038
1251
|
}
|
|
1039
1252
|
onStatus?.(status());
|
|
1040
1253
|
resolve(status());
|
|
1254
|
+
};
|
|
1255
|
+
const drainThenSettle = () => {
|
|
1256
|
+
clearTimeout(drainTimer);
|
|
1257
|
+
drainTimer = setTimeout(() => {
|
|
1258
|
+
settle();
|
|
1259
|
+
}, PIPE_DRAIN_MS);
|
|
1260
|
+
};
|
|
1261
|
+
child.stdout.on("data", (chunk) => {
|
|
1262
|
+
outLines.write(chunk);
|
|
1263
|
+
});
|
|
1264
|
+
child.stderr.on("data", (chunk) => {
|
|
1265
|
+
errLines.write(chunk);
|
|
1266
|
+
});
|
|
1267
|
+
child.on("error", (error) => {
|
|
1268
|
+
if (state !== "running") return;
|
|
1269
|
+
clearTimeout(drainTimer);
|
|
1270
|
+
clearTimeout(deadlineTimer);
|
|
1271
|
+
resolve(failToStart(error));
|
|
1272
|
+
});
|
|
1273
|
+
child.on("exit", (code) => {
|
|
1274
|
+
exited = true;
|
|
1275
|
+
exitCode = code;
|
|
1276
|
+
if (closed) settle();
|
|
1277
|
+
else drainThenSettle();
|
|
1041
1278
|
});
|
|
1279
|
+
child.on("close", () => {
|
|
1280
|
+
closed = true;
|
|
1281
|
+
if (exited) settle();
|
|
1282
|
+
});
|
|
1283
|
+
deadlineTimer = setTimeout(() => {
|
|
1284
|
+
if (state !== "running") return;
|
|
1285
|
+
timedOut = true;
|
|
1286
|
+
killTree(child.pid, process.platform);
|
|
1287
|
+
drainThenSettle();
|
|
1288
|
+
}, timeoutMs);
|
|
1042
1289
|
}))
|
|
1043
1290
|
};
|
|
1044
1291
|
}
|
|
@@ -1049,7 +1296,7 @@ function spawnPluginCli(options) {
|
|
|
1049
1296
|
* is given, the terminal `done` waits for it to settle (§D hot mount).
|
|
1050
1297
|
*/
|
|
1051
1298
|
function startInstall(options) {
|
|
1052
|
-
const { profile, spec, dshBin = "dsh", env, expectedName, afterDone, onStatus } = options;
|
|
1299
|
+
const { profile, spec, dshBin = "dsh", env, expectedName, afterDone, onStatus, timeoutMs } = options;
|
|
1053
1300
|
return spawnPluginCli({
|
|
1054
1301
|
profile,
|
|
1055
1302
|
argv: ["add", spec],
|
|
@@ -1057,7 +1304,8 @@ function startInstall(options) {
|
|
|
1057
1304
|
env,
|
|
1058
1305
|
confirm: expectedName !== void 0 ? (home) => confirmBundleActivation(profile, home, expectedName) : void 0,
|
|
1059
1306
|
afterDone,
|
|
1060
|
-
onStatus
|
|
1307
|
+
onStatus,
|
|
1308
|
+
timeoutMs
|
|
1061
1309
|
});
|
|
1062
1310
|
}
|
|
1063
1311
|
/**
|
|
@@ -1068,7 +1316,7 @@ function startInstall(options) {
|
|
|
1068
1316
|
* waits for it to settle (§D hot mount).
|
|
1069
1317
|
*/
|
|
1070
1318
|
function startUninstall(options) {
|
|
1071
|
-
const { profile, name, dshBin = "dsh", env, expectedName, afterDone, onStatus } = options;
|
|
1319
|
+
const { profile, name, dshBin = "dsh", env, expectedName, afterDone, onStatus, timeoutMs } = options;
|
|
1072
1320
|
return spawnPluginCli({
|
|
1073
1321
|
profile,
|
|
1074
1322
|
argv: ["remove", name],
|
|
@@ -1076,7 +1324,8 @@ function startUninstall(options) {
|
|
|
1076
1324
|
env,
|
|
1077
1325
|
confirm: expectedName !== void 0 ? (home) => confirmBundleRemoval(profile, home, expectedName) : void 0,
|
|
1078
1326
|
afterDone,
|
|
1079
|
-
onStatus
|
|
1327
|
+
onStatus,
|
|
1328
|
+
timeoutMs
|
|
1080
1329
|
});
|
|
1081
1330
|
}
|
|
1082
1331
|
//#endregion
|
|
@@ -1181,7 +1430,13 @@ function parseSimplePatch(patchText) {
|
|
|
1181
1430
|
* the output is itself a simple patch (round-trips through
|
|
1182
1431
|
* parseSimplePatch). */
|
|
1183
1432
|
function renderRows(rows, prefix) {
|
|
1184
|
-
return rows.map((row) =>
|
|
1433
|
+
return dump(rows.map((row) => ({
|
|
1434
|
+
id: `${prefix}${row.id}`,
|
|
1435
|
+
name: row.name
|
|
1436
|
+
})), {
|
|
1437
|
+
noRefs: true,
|
|
1438
|
+
lineWidth: -1
|
|
1439
|
+
});
|
|
1185
1440
|
}
|
|
1186
1441
|
/** The next free `hot-<n>` number: one past the highest existing file, or 1
|
|
1187
1442
|
* when the directory does not exist yet (the write recreates it). */
|
|
@@ -1246,9 +1501,18 @@ async function hotMount(ctx, profileDir, packageName, deps = {}) {
|
|
|
1246
1501
|
const { fs = nodeFs, dir = join(profileDir, HOT_DIR), timeoutMs = Number(process.env.DSH_SHOP_HOT_MOUNT_TIMEOUT_MS) || 1e4, now = Date.now } = deps;
|
|
1247
1502
|
const packageDir = join(profileDir, "node_modules", packageName);
|
|
1248
1503
|
const dsh = readPkgDsh(fs, packageDir);
|
|
1504
|
+
const patchFile = resolve(packageDir, dsh?.patch ?? "cordis.patch.yml");
|
|
1505
|
+
const inside = relative(packageDir, patchFile);
|
|
1506
|
+
if (inside === "" || inside.startsWith("..") || isAbsolute(inside)) {
|
|
1507
|
+
ctx.logger?.warn(`hot-mount ${packageName}: the declared bundle patch is outside the package directory — restart will activate it`);
|
|
1508
|
+
return {
|
|
1509
|
+
ok: false,
|
|
1510
|
+
reason: "no-patch"
|
|
1511
|
+
};
|
|
1512
|
+
}
|
|
1249
1513
|
let patchText;
|
|
1250
1514
|
try {
|
|
1251
|
-
patchText = fs.read(
|
|
1515
|
+
patchText = fs.read(patchFile);
|
|
1252
1516
|
} catch {
|
|
1253
1517
|
ctx.logger?.warn(`hot-mount ${packageName}: no patch file to mount — restart will activate it`);
|
|
1254
1518
|
return {
|
|
@@ -1382,17 +1646,41 @@ function cleanHotDir(profileDir) {
|
|
|
1382
1646
|
* function has already returned and the caller has committed — so the gateway
|
|
1383
1647
|
* refuses on Windows before reaching here rather than exiting into nothing
|
|
1384
1648
|
* (index.ts `restartPlatformSupported`). */
|
|
1649
|
+
/**
|
|
1650
|
+
* Resolve the command line that re-runs this process.
|
|
1651
|
+
*
|
|
1652
|
+
* A bare `dsh` from PATH is not necessarily the process serving the current
|
|
1653
|
+
* port: a dsh launched through `node`, `npx`, or `pnpm dlx` carries its own
|
|
1654
|
+
* script and `execArgv`. Reconstructing that exact node invocation preserves
|
|
1655
|
+
* the entry point and runtime flags. An explicitly named binary remains an
|
|
1656
|
+
* explicit choice and is passed through unchanged.
|
|
1657
|
+
*/
|
|
1658
|
+
function restartCommand(options) {
|
|
1659
|
+
const { dshBin, argv, execPath, execArgv, script } = options;
|
|
1660
|
+
if (dshBin === "dsh" && script !== void 0) return {
|
|
1661
|
+
command: execPath,
|
|
1662
|
+
args: [
|
|
1663
|
+
...execArgv,
|
|
1664
|
+
script,
|
|
1665
|
+
...argv
|
|
1666
|
+
]
|
|
1667
|
+
};
|
|
1668
|
+
return {
|
|
1669
|
+
command: dshBin,
|
|
1670
|
+
args: [...argv]
|
|
1671
|
+
};
|
|
1672
|
+
}
|
|
1385
1673
|
function startRestart(options) {
|
|
1386
|
-
const {
|
|
1674
|
+
const { command, args, parentPid, logFile, env } = options;
|
|
1387
1675
|
const logFd = openSync(logFile, "a");
|
|
1388
1676
|
try {
|
|
1389
|
-
spawn("sh", [
|
|
1677
|
+
const helper = spawn("sh", [
|
|
1390
1678
|
"-c",
|
|
1391
1679
|
"while kill -0 \"$1\" 2>/dev/null; do sleep 0.2; done; shift; exec \"$@\"",
|
|
1392
1680
|
"sh",
|
|
1393
1681
|
String(parentPid),
|
|
1394
|
-
|
|
1395
|
-
...
|
|
1682
|
+
command,
|
|
1683
|
+
...args
|
|
1396
1684
|
], {
|
|
1397
1685
|
stdio: [
|
|
1398
1686
|
"ignore",
|
|
@@ -1401,7 +1689,13 @@ function startRestart(options) {
|
|
|
1401
1689
|
],
|
|
1402
1690
|
env: env ?? process.env,
|
|
1403
1691
|
detached: true
|
|
1404
|
-
})
|
|
1692
|
+
});
|
|
1693
|
+
helper.on("error", (error) => {
|
|
1694
|
+
try {
|
|
1695
|
+
appendFileSync(logFile, `dsh-plugin-shop: the restart helper could not start: ${error.message}\n`);
|
|
1696
|
+
} catch {}
|
|
1697
|
+
});
|
|
1698
|
+
helper.unref();
|
|
1405
1699
|
} finally {
|
|
1406
1700
|
closeSync(logFd);
|
|
1407
1701
|
}
|
|
@@ -1415,12 +1709,22 @@ function startRestart(options) {
|
|
|
1415
1709
|
* harvested into its own catalog. */
|
|
1416
1710
|
/** The npm package the shop itself is published as. */
|
|
1417
1711
|
const SHOP_PACKAGE = "dsh-plugin-shop";
|
|
1418
|
-
/**
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1712
|
+
/** The registry that always gets asked, last. */
|
|
1713
|
+
const DEFAULT_REGISTRY = "https://registry.npmjs.org/";
|
|
1714
|
+
/** How long one registry gets to answer. `shop/version` is warmed at every
|
|
1715
|
+
* web boot, so a silent socket must not hold the boot indefinitely. */
|
|
1716
|
+
const VERSION_CHECK_TIMEOUT_MS = 5e3;
|
|
1717
|
+
/** Ask one registry. Any failure — network, non-2xx, unexpected payload,
|
|
1718
|
+
* deadline — is the same outcome: no answer from this registry. */
|
|
1719
|
+
async function askRegistry(fetchFn, registry, timeoutMs) {
|
|
1720
|
+
const url = `${registry}${SHOP_PACKAGE}`;
|
|
1721
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
1422
1722
|
try {
|
|
1423
|
-
const response = await fetchFn(
|
|
1723
|
+
const response = await Promise.race([fetchFn(url, { signal }), new Promise((_resolve, reject) => {
|
|
1724
|
+
signal.addEventListener("abort", () => {
|
|
1725
|
+
reject(/* @__PURE__ */ new Error(`the version check did not answer within ${timeoutMs} ms`));
|
|
1726
|
+
}, { once: true });
|
|
1727
|
+
})]);
|
|
1424
1728
|
if (!response.ok) return null;
|
|
1425
1729
|
const latest = (await response.json())["dist-tags"]?.latest;
|
|
1426
1730
|
return typeof latest === "string" ? latest : null;
|
|
@@ -1428,6 +1732,24 @@ async function fetchLatestVersion(fetchFn = fetch) {
|
|
|
1428
1732
|
return null;
|
|
1429
1733
|
}
|
|
1430
1734
|
}
|
|
1735
|
+
/**
|
|
1736
|
+
* Fetch the shop's `latest` dist-tag, or `null` when no registry can answer.
|
|
1737
|
+
* The user's own registry is tried first when known; npmjs is always the
|
|
1738
|
+
* fallback so a mirror that lags cannot hide a release.
|
|
1739
|
+
*/
|
|
1740
|
+
async function fetchLatestVersion(fetchFn = fetch, options = {}) {
|
|
1741
|
+
const { registry = null, timeoutMs = VERSION_CHECK_TIMEOUT_MS } = options;
|
|
1742
|
+
const asked = /* @__PURE__ */ new Set();
|
|
1743
|
+
const registries = registry === null ? [DEFAULT_REGISTRY] : [registry, DEFAULT_REGISTRY];
|
|
1744
|
+
for (const base of registries) {
|
|
1745
|
+
const normalized = base.endsWith("/") ? base : `${base}/`;
|
|
1746
|
+
if (asked.has(normalized)) continue;
|
|
1747
|
+
asked.add(normalized);
|
|
1748
|
+
const latest = await askRegistry(fetchFn, normalized, timeoutMs);
|
|
1749
|
+
if (latest !== null) return latest;
|
|
1750
|
+
}
|
|
1751
|
+
return null;
|
|
1752
|
+
}
|
|
1431
1753
|
//#endregion
|
|
1432
1754
|
//#region src/host/supervisor.ts
|
|
1433
1755
|
function detectSupervisor(env, proc) {
|
|
@@ -1435,14 +1757,25 @@ function detectSupervisor(env, proc) {
|
|
|
1435
1757
|
}
|
|
1436
1758
|
//#endregion
|
|
1437
1759
|
//#region src/host/repo-pins.ts
|
|
1760
|
+
/**
|
|
1761
|
+
* The Host's memory of which commit it installed for each github entry.
|
|
1762
|
+
*
|
|
1763
|
+
* pnpm writes the manifest dependency as `github:owner/slug` — no commit —
|
|
1764
|
+
* and the resolved commit lives only in the lockfile. The shop records the
|
|
1765
|
+
* commit it pinned at install time in its own cache so `installed()` can
|
|
1766
|
+
* report `outdated` honestly without parsing pnpm's lockfile. The profile
|
|
1767
|
+
* manifest remains the source of truth for *presence*: a pin whose bundle
|
|
1768
|
+
* is not a manifest dependency is a leftover from a failed install and is
|
|
1769
|
+
* never reported.
|
|
1770
|
+
*/
|
|
1438
1771
|
/** Read the pins file; any irregularity degrades to an empty record. */
|
|
1439
1772
|
function readRepoPins(fs, path) {
|
|
1440
1773
|
if (!fs.exists(path)) return {};
|
|
1441
1774
|
try {
|
|
1442
1775
|
const parsed = JSON.parse(fs.read(path));
|
|
1443
1776
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
|
|
1444
|
-
const out =
|
|
1445
|
-
for (const [name,
|
|
1777
|
+
const out = Object.create(null);
|
|
1778
|
+
for (const [name, pin] of Object.entries(parsed)) if (typeof pin === "string" && (COMMIT_SHA.test(pin) || RELEASE_TAG.test(pin))) out[name] = pin;
|
|
1446
1779
|
return out;
|
|
1447
1780
|
} catch {
|
|
1448
1781
|
return {};
|
|
@@ -1455,6 +1788,18 @@ function writeRepoPins(fs, path, pins) {
|
|
|
1455
1788
|
//#endregion
|
|
1456
1789
|
//#region src/host/profile.ts
|
|
1457
1790
|
/** Profile directory discovery and user-layer writes (§8: hot enable/disable). */
|
|
1791
|
+
function isJsExpr(value) {
|
|
1792
|
+
return typeof value === "object" && value !== null && typeof value.__jsExpr === "string";
|
|
1793
|
+
}
|
|
1794
|
+
const JS_EXPR_TYPE = new Type("tag:yaml.org,2002:js", {
|
|
1795
|
+
kind: "scalar",
|
|
1796
|
+
resolve: () => true,
|
|
1797
|
+
construct: (data) => ({ __jsExpr: data }),
|
|
1798
|
+
predicate: isJsExpr,
|
|
1799
|
+
represent: (value) => value.__jsExpr
|
|
1800
|
+
});
|
|
1801
|
+
/** js-yaml's default schema plus the user-layer `!!js` round trip. */
|
|
1802
|
+
const USER_LAYER_SCHEMA = DEFAULT_SCHEMA.extend([JS_EXPR_TYPE]);
|
|
1458
1803
|
/**
|
|
1459
1804
|
* Find the profile directory that owns `startPath`.
|
|
1460
1805
|
*
|
|
@@ -1523,7 +1868,10 @@ function setUserLayerRows(options) {
|
|
|
1523
1868
|
disabled: true
|
|
1524
1869
|
}))];
|
|
1525
1870
|
const tmp = `${file}.tmp`;
|
|
1526
|
-
writeFileSync(tmp, dump(next, {
|
|
1871
|
+
writeFileSync(tmp, dump(next, {
|
|
1872
|
+
noRefs: true,
|
|
1873
|
+
schema: USER_LAYER_SCHEMA
|
|
1874
|
+
}));
|
|
1527
1875
|
renameSync(tmp, file);
|
|
1528
1876
|
}
|
|
1529
1877
|
/**
|
|
@@ -1618,7 +1966,9 @@ function ownsEntryId(owned, entryId) {
|
|
|
1618
1966
|
//#endregion
|
|
1619
1967
|
//#region src/host/peers.ts
|
|
1620
1968
|
/** Harness compatibility: which declared peers the running installation does
|
|
1621
|
-
* not provide (design 2026-09-01-harness-compatibility)
|
|
1969
|
+
* not provide (design 2026-09-01-harness-compatibility), and — for the
|
|
1970
|
+
* shop's OWN declared peers — which ones it provides at a version outside
|
|
1971
|
+
* the declared range. */
|
|
1622
1972
|
/**
|
|
1623
1973
|
* The production resolver: the same question the harness's own
|
|
1624
1974
|
* ClientModuleRegistry asks, through a require anchored at the profile. Asking
|
|
@@ -1632,7 +1982,9 @@ function nodeResolver(baseUrl) {
|
|
|
1632
1982
|
require.resolve(`${spec}/package.json`);
|
|
1633
1983
|
return true;
|
|
1634
1984
|
} catch (error) {
|
|
1635
|
-
|
|
1985
|
+
const code = error.code;
|
|
1986
|
+
if (code === "MODULE_NOT_FOUND") return false;
|
|
1987
|
+
if (code === "ERR_PACKAGE_PATH_NOT_EXPORTED") return true;
|
|
1636
1988
|
throw error;
|
|
1637
1989
|
}
|
|
1638
1990
|
};
|
|
@@ -1669,10 +2021,105 @@ function incompatibilityMap(entries, resolve) {
|
|
|
1669
2021
|
}
|
|
1670
2022
|
if (!present) missing.push(spec);
|
|
1671
2023
|
}
|
|
1672
|
-
if (usable && missing.length > 0) out[entry
|
|
2024
|
+
if (usable && missing.length > 0) out[identityKey(entry)] = missing;
|
|
1673
2025
|
}
|
|
1674
2026
|
return out;
|
|
1675
2027
|
}
|
|
2028
|
+
/**
|
|
2029
|
+
* The production resolver: `nodeResolver`'s own resolution, kept this time
|
|
2030
|
+
* instead of collapsed to a boolean. Anything that leaves no version to read
|
|
2031
|
+
* answers null — a peer that is absent, one that restricts `./package.json`
|
|
2032
|
+
* in its exports, an unreadable or malformed manifest, a manifest with no
|
|
2033
|
+
* `version`. Absence is deliberately NOT reported as a version violation:
|
|
2034
|
+
* `incompatibilityMap` is what covers a missing peer.
|
|
2035
|
+
*/
|
|
2036
|
+
function nodeVersionResolver(baseUrl) {
|
|
2037
|
+
const require = createRequire(baseUrl);
|
|
2038
|
+
return (spec) => {
|
|
2039
|
+
let manifestPath;
|
|
2040
|
+
try {
|
|
2041
|
+
manifestPath = require.resolve(`${spec}/package.json`);
|
|
2042
|
+
} catch {
|
|
2043
|
+
return null;
|
|
2044
|
+
}
|
|
2045
|
+
try {
|
|
2046
|
+
const version = JSON.parse(readFileSync(manifestPath, "utf8")).version;
|
|
2047
|
+
return typeof version === "string" ? version : null;
|
|
2048
|
+
} catch {
|
|
2049
|
+
return null;
|
|
2050
|
+
}
|
|
2051
|
+
};
|
|
2052
|
+
}
|
|
2053
|
+
/**
|
|
2054
|
+
* The declared peers whose provided version is outside the declared range,
|
|
2055
|
+
* ordered by peer name. Pure: the module graph arrives through `resolve`.
|
|
2056
|
+
*
|
|
2057
|
+
* `includePrerelease` is load-bearing, not a convenience. The harness ships
|
|
2058
|
+
* nothing but `-rc` versions, so under strict semver `^0.1.1-rc.2` excludes
|
|
2059
|
+
* `0.1.2-rc.1` — the version that is installed and works — and every future
|
|
2060
|
+
* rc bump would raise a false alarm. With it on, the range still excludes an
|
|
2061
|
+
* older prerelease (`0.1.1-rc.1`) and a minor- or major-line move
|
|
2062
|
+
* (`0.2.0-rc.1`, `1.0.0`), which are the moves that actually break a plugin
|
|
2063
|
+
* path. Discrimination on both sides is the whole point: one false warning
|
|
2064
|
+
* teaches a reader to ignore every warning.
|
|
2065
|
+
*
|
|
2066
|
+
* Anything unreadable yields NO verdict for that peer and never a violation:
|
|
2067
|
+
* a resolver that answers null or throws, a found version that is not
|
|
2068
|
+
* semver, a declared range semver cannot parse. The other peers are still
|
|
2069
|
+
* judged.
|
|
2070
|
+
*/
|
|
2071
|
+
function peerVersionMismatches(ranges, resolve) {
|
|
2072
|
+
const mismatches = [];
|
|
2073
|
+
for (const spec of Object.keys(ranges).sort()) {
|
|
2074
|
+
const range = ranges[spec];
|
|
2075
|
+
if (range === void 0 || validRange(range) === null) continue;
|
|
2076
|
+
let found;
|
|
2077
|
+
try {
|
|
2078
|
+
found = resolve(spec);
|
|
2079
|
+
} catch {
|
|
2080
|
+
continue;
|
|
2081
|
+
}
|
|
2082
|
+
if (found === null || valid(found) === null) continue;
|
|
2083
|
+
if (satisfies(found, range, { includePrerelease: true })) continue;
|
|
2084
|
+
mismatches.push({
|
|
2085
|
+
spec,
|
|
2086
|
+
range,
|
|
2087
|
+
found
|
|
2088
|
+
});
|
|
2089
|
+
}
|
|
2090
|
+
return mismatches;
|
|
2091
|
+
}
|
|
2092
|
+
/**
|
|
2093
|
+
* The load-time message for a set of mismatches, or null when there is
|
|
2094
|
+
* nothing to say. Every mismatch is named with its declared range and the
|
|
2095
|
+
* version found, in ONE message: the reader needs the whole picture in the
|
|
2096
|
+
* line they will scroll past, and the message says the shop still loads
|
|
2097
|
+
* because refusing to load would cost them the shop over a diagnostic.
|
|
2098
|
+
*/
|
|
2099
|
+
function peerVersionWarning(mismatches) {
|
|
2100
|
+
if (mismatches.length === 0) return null;
|
|
2101
|
+
return `dsh-plugin-shop: the harness does not provide the peer versions this shop declares — ${mismatches.map(({ spec, range, found }) => `${spec} ${range}, found ${found}`).join("; ")}. The shop still loads; if a path misbehaves, check this first.`;
|
|
2102
|
+
}
|
|
2103
|
+
/**
|
|
2104
|
+
* A self-check that warns AT MOST ONCE, however many times it is called.
|
|
2105
|
+
* The guard is what keeps a diagnostic a diagnostic: a message repeated per
|
|
2106
|
+
* call is noise a reader learns to skip, and the shop's load path is not the
|
|
2107
|
+
* only place that could reasonably ask this question.
|
|
2108
|
+
*
|
|
2109
|
+
* It never throws. A check that could fail a load would be worse than the
|
|
2110
|
+
* mismatch it reports — the user would lose the whole shop over a warning.
|
|
2111
|
+
*/
|
|
2112
|
+
function createPeerVersionCheck(deps) {
|
|
2113
|
+
let spoken = false;
|
|
2114
|
+
return () => {
|
|
2115
|
+
if (spoken) return;
|
|
2116
|
+
spoken = true;
|
|
2117
|
+
try {
|
|
2118
|
+
const message = peerVersionWarning(peerVersionMismatches(deps.ranges, deps.resolve));
|
|
2119
|
+
if (message !== null) deps.warn(message);
|
|
2120
|
+
} catch {}
|
|
2121
|
+
};
|
|
2122
|
+
}
|
|
1676
2123
|
//#endregion
|
|
1677
2124
|
//#region src/host/index.ts
|
|
1678
2125
|
/** ShopGateway: the Host half of dsh-plugin-shop (§5.1). */
|
|
@@ -1905,6 +2352,8 @@ let ShopGateway = (() => {
|
|
|
1905
2352
|
/** The argv `shop/restart` re-spawns: the real process argv minus node and
|
|
1906
2353
|
* the CLI script path, or a test-provided substitute. */
|
|
1907
2354
|
restartArgv;
|
|
2355
|
+
/** The script the restart re-runs — this process's own entry. */
|
|
2356
|
+
restartScript;
|
|
1908
2357
|
/** The exit the restart calls once the response is out; `process.exit` in
|
|
1909
2358
|
* production, a spy in tests. */
|
|
1910
2359
|
exit;
|
|
@@ -1934,9 +2383,14 @@ let ShopGateway = (() => {
|
|
|
1934
2383
|
/** The install gate runs against the last loaded snapshot, never a fresh
|
|
1935
2384
|
* fetch per request (§7.2: the Host's cached snapshot is the truth). */
|
|
1936
2385
|
lastSnapshot = null;
|
|
2386
|
+
/** A cold-cache catalog load shared by concurrent RPC calls. */
|
|
2387
|
+
inFlightLoad = null;
|
|
1937
2388
|
/** The origin list built for the last-seen `catalogUrl`, memoised so the
|
|
1938
2389
|
* user's npmrc is read at most once per gateway (see `originsFor`). */
|
|
1939
2390
|
originCache = null;
|
|
2391
|
+
/** The user's own `registry=` from `~/.npmrc`, read at most once per
|
|
2392
|
+
* gateway. A wrapper distinguishes a genuine `null` from an unread value. */
|
|
2393
|
+
npmRegistryCache = null;
|
|
1940
2394
|
/** The incompatibility map already computed for `lastSnapshot`, keyed by
|
|
1941
2395
|
* that snapshot's own object identity. Design §3 asks for the verdict
|
|
1942
2396
|
* once per loaded snapshot, not once per RPC call: `loadCatalog` serves
|
|
@@ -1961,10 +2415,11 @@ let ShopGateway = (() => {
|
|
|
1961
2415
|
this.loaderEntriesInjected = options.loaderEntries;
|
|
1962
2416
|
this.dshBin = options.dshBin ?? "dsh";
|
|
1963
2417
|
this.restartArgv = options.restartArgv ?? process.argv.slice(2);
|
|
2418
|
+
this.restartScript = options.restartScript ?? process.argv[1];
|
|
1964
2419
|
this.exit = options.exit ?? ((code) => process.exit(code));
|
|
1965
2420
|
this.restartExitDelayMs = options.restartExitDelayMs ?? ShopGateway.RESTART_EXIT_DELAY_MS;
|
|
1966
2421
|
this.restartParentPid = options.restartParentPid ?? process.pid;
|
|
1967
|
-
this.latestVersion = options.fetchLatestVersion ?? (() => fetchLatestVersion());
|
|
2422
|
+
this.latestVersion = options.fetchLatestVersion ?? (() => fetchLatestVersion(fetch, { registry: this.npmRegistry() }));
|
|
1968
2423
|
this.pinFs = options.pinFs ?? {
|
|
1969
2424
|
exists: (path) => existsSync(path),
|
|
1970
2425
|
read: (path) => readFileSync(path, "utf8"),
|
|
@@ -1981,6 +2436,26 @@ let ShopGateway = (() => {
|
|
|
1981
2436
|
try {
|
|
1982
2437
|
cleanHotDir(this.profileDirResolved());
|
|
1983
2438
|
} catch {}
|
|
2439
|
+
this.checkPeerVersions();
|
|
2440
|
+
}
|
|
2441
|
+
/**
|
|
2442
|
+
* Warn once, at load, about any declared peer the harness provides outside
|
|
2443
|
+
* its declared range. Advisory by construction: it cannot throw, and it
|
|
2444
|
+
* never refuses the load — losing the whole shop is a worse outcome than
|
|
2445
|
+
* running against a harness that moved.
|
|
2446
|
+
*/
|
|
2447
|
+
checkPeerVersions() {
|
|
2448
|
+
try {
|
|
2449
|
+
createPeerVersionCheck({
|
|
2450
|
+
ranges: this.options.peerRanges ?? ownPeerRanges(),
|
|
2451
|
+
resolve: this.options.resolvePeerVersion ?? nodeVersionResolver(pathToFileURL(join(this.profileDirResolved(), "cordis.yml")).href),
|
|
2452
|
+
warn: (message) => {
|
|
2453
|
+
const logger = this.ctx.logger;
|
|
2454
|
+
if (logger === void 0) console.warn(message);
|
|
2455
|
+
else logger.warn(message);
|
|
2456
|
+
}
|
|
2457
|
+
})();
|
|
2458
|
+
} catch {}
|
|
1984
2459
|
}
|
|
1985
2460
|
/** The pins file lives in the shop's own cache, next to the catalog cache. */
|
|
1986
2461
|
pinsPath() {
|
|
@@ -2123,25 +2598,59 @@ let ShopGateway = (() => {
|
|
|
2123
2598
|
cacheDir
|
|
2124
2599
|
};
|
|
2125
2600
|
}
|
|
2126
|
-
/** The
|
|
2127
|
-
*
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
if (
|
|
2131
|
-
const
|
|
2601
|
+
/** The user's own registry, read once. The self-update check and catalog
|
|
2602
|
+
* race share this preference so neither path repeatedly touches ~/.npmrc. */
|
|
2603
|
+
npmRegistry() {
|
|
2604
|
+
const cached = this.npmRegistryCache;
|
|
2605
|
+
if (cached !== null) return cached.value;
|
|
2606
|
+
const value = npmrcRegistry((path) => {
|
|
2132
2607
|
try {
|
|
2133
2608
|
return readFileSync(path, "utf8");
|
|
2134
2609
|
} catch {
|
|
2135
2610
|
return null;
|
|
2136
2611
|
}
|
|
2137
2612
|
}, homedir());
|
|
2138
|
-
|
|
2613
|
+
this.npmRegistryCache = { value };
|
|
2614
|
+
return value;
|
|
2615
|
+
}
|
|
2616
|
+
/** The origins to race for this row's catalog. Read once per gateway: the
|
|
2617
|
+
* user's npmrc does not change under a running dsh, and re-reading it on
|
|
2618
|
+
* every catalog call would put a filesystem read on the hot path. */
|
|
2619
|
+
originsFor(catalogUrl) {
|
|
2620
|
+
if (this.originCache?.catalogUrl === catalogUrl) return this.originCache.origins;
|
|
2621
|
+
const origins = catalogOrigins(catalogUrl, fetch, this.npmRegistry());
|
|
2139
2622
|
this.originCache = {
|
|
2140
2623
|
catalogUrl,
|
|
2141
2624
|
origins
|
|
2142
2625
|
};
|
|
2143
2626
|
return origins;
|
|
2144
2627
|
}
|
|
2628
|
+
/** Join an in-flight load; refresh requests always start a new load. */
|
|
2629
|
+
loadCatalogOnce(refresh) {
|
|
2630
|
+
const existing = this.inFlightLoad;
|
|
2631
|
+
if (!refresh && existing !== null) return existing;
|
|
2632
|
+
const { catalogUrl, cacheDir } = this.rowConfig();
|
|
2633
|
+
const started = (this.options.loadCatalog ?? loadCatalog)({
|
|
2634
|
+
origins: this.originsFor(catalogUrl),
|
|
2635
|
+
cacheDir,
|
|
2636
|
+
refresh
|
|
2637
|
+
}).then((result) => {
|
|
2638
|
+
this.lastSnapshot = result.snapshot;
|
|
2639
|
+
return result;
|
|
2640
|
+
});
|
|
2641
|
+
this.inFlightLoad = started;
|
|
2642
|
+
const forget = () => {
|
|
2643
|
+
if (this.inFlightLoad === started) this.inFlightLoad = null;
|
|
2644
|
+
};
|
|
2645
|
+
started.then(forget, forget);
|
|
2646
|
+
return started;
|
|
2647
|
+
}
|
|
2648
|
+
/** Return the current snapshot, loading it once when needed. */
|
|
2649
|
+
async snapshotNow() {
|
|
2650
|
+
if (this.lastSnapshot !== null) return this.lastSnapshot;
|
|
2651
|
+
const { snapshot } = await this.loadCatalogOnce(false);
|
|
2652
|
+
return snapshot;
|
|
2653
|
+
}
|
|
2145
2654
|
/** The explicit restart override. Only the row's `config:` sub-object is
|
|
2146
2655
|
* passed to a plugin — a top-level `allowRestart:` beside `name:` would be
|
|
2147
2656
|
* silently ignored by the loader (dsh-market README, #227). */
|
|
@@ -2156,13 +2665,7 @@ let ShopGateway = (() => {
|
|
|
2156
2665
|
}
|
|
2157
2666
|
/** Browse the catalog (§7.3): cached snapshot, refreshed on demand. */
|
|
2158
2667
|
async catalog(args) {
|
|
2159
|
-
const {
|
|
2160
|
-
const { snapshot, stale } = await (this.options.loadCatalog ?? loadCatalog)({
|
|
2161
|
-
origins: this.originsFor(catalogUrl),
|
|
2162
|
-
cacheDir,
|
|
2163
|
-
refresh: args?.refresh ?? false
|
|
2164
|
-
});
|
|
2165
|
-
this.lastSnapshot = snapshot;
|
|
2668
|
+
const { snapshot, stale } = await this.loadCatalogOnce(args?.refresh ?? false);
|
|
2166
2669
|
let incompatible;
|
|
2167
2670
|
if (this.incompatibleCache !== null && this.incompatibleCache.snapshot === snapshot) incompatible = this.incompatibleCache.map;
|
|
2168
2671
|
else {
|
|
@@ -2194,26 +2697,13 @@ let ShopGateway = (() => {
|
|
|
2194
2697
|
* passing request reaches the executor.
|
|
2195
2698
|
*/
|
|
2196
2699
|
async install(args) {
|
|
2197
|
-
|
|
2198
|
-
const { catalogUrl, cacheDir } = this.rowConfig();
|
|
2199
|
-
const { snapshot } = await (this.options.loadCatalog ?? loadCatalog)({
|
|
2200
|
-
origins: this.originsFor(catalogUrl),
|
|
2201
|
-
cacheDir
|
|
2202
|
-
});
|
|
2203
|
-
this.lastSnapshot = snapshot;
|
|
2204
|
-
}
|
|
2205
|
-
const verdict = validateInstall(this.lastSnapshot, args);
|
|
2700
|
+
const verdict = validateInstall(await this.snapshotNow(), args);
|
|
2206
2701
|
if (!verdict.ok) return {
|
|
2207
2702
|
ok: false,
|
|
2208
2703
|
code: verdict.code,
|
|
2209
2704
|
detail: verdict.detail
|
|
2210
2705
|
};
|
|
2211
|
-
const entry =
|
|
2212
|
-
if (entry === void 0) return {
|
|
2213
|
-
ok: false,
|
|
2214
|
-
code: "not-in-catalog",
|
|
2215
|
-
detail: `dsh-plugin-shop: ${args.name} is not in the catalog`
|
|
2216
|
-
};
|
|
2706
|
+
const entry = verdict.entry;
|
|
2217
2707
|
let spec;
|
|
2218
2708
|
if (entry.source === "github" && entry.tarball !== void 0) {
|
|
2219
2709
|
const integrity = await verifyTarballSha256(this.fetchTarball, entry.tarball.url, entry.tarball.sha256);
|
|
@@ -2255,7 +2745,7 @@ let ShopGateway = (() => {
|
|
|
2255
2745
|
const pins = readRepoPins(this.pinFs, this.pinsPath());
|
|
2256
2746
|
writeRepoPins(this.pinFs, this.pinsPath(), {
|
|
2257
2747
|
...pins,
|
|
2258
|
-
[
|
|
2748
|
+
[identityKey(entry)]: entry.version
|
|
2259
2749
|
});
|
|
2260
2750
|
}
|
|
2261
2751
|
this.installs.set(running.installId, running);
|
|
@@ -2278,6 +2768,11 @@ let ShopGateway = (() => {
|
|
|
2278
2768
|
const excess = Math.max(0, finishedIds.length - ShopGateway.MAX_FINISHED_INSTALLS);
|
|
2279
2769
|
for (const id of finishedIds.slice(0, excess)) this.installs.delete(id);
|
|
2280
2770
|
}
|
|
2771
|
+
/** Whether any command this gateway started is still running. */
|
|
2772
|
+
hasRunningCommand() {
|
|
2773
|
+
for (const record of this.installs.values()) if (record.status().state === "running") return true;
|
|
2774
|
+
return false;
|
|
2775
|
+
}
|
|
2281
2776
|
/** Poll one install's progress (§7.2); unknown ids report `found: false`. */
|
|
2282
2777
|
installStatus(args) {
|
|
2283
2778
|
const running = this.installs.get(args.installId);
|
|
@@ -2297,14 +2792,7 @@ let ShopGateway = (() => {
|
|
|
2297
2792
|
* attached. The tab's shelf cards and its installed section both derive
|
|
2298
2793
|
* from this one list. */
|
|
2299
2794
|
async installed() {
|
|
2300
|
-
|
|
2301
|
-
const { catalogUrl, cacheDir } = this.rowConfig();
|
|
2302
|
-
const { snapshot } = await (this.options.loadCatalog ?? loadCatalog)({
|
|
2303
|
-
origins: this.originsFor(catalogUrl),
|
|
2304
|
-
cacheDir
|
|
2305
|
-
});
|
|
2306
|
-
this.lastSnapshot = snapshot;
|
|
2307
|
-
}
|
|
2795
|
+
const snapshot = await this.snapshotNow();
|
|
2308
2796
|
const dependencies = readProfileManifest("dsh-plugin-shop", this.profileDirResolved()).dependencies ?? {};
|
|
2309
2797
|
const pins = readRepoPins(this.pinFs, this.pinsPath());
|
|
2310
2798
|
const live = /* @__PURE__ */ new Map();
|
|
@@ -2332,13 +2820,20 @@ let ShopGateway = (() => {
|
|
|
2332
2820
|
return present.length === 0 || present.every(([, enabled]) => enabled);
|
|
2333
2821
|
};
|
|
2334
2822
|
const installed = [];
|
|
2335
|
-
for (const entry of
|
|
2823
|
+
for (const entry of snapshot.entries) {
|
|
2336
2824
|
const spec = dependencies[entry.name];
|
|
2337
2825
|
if (spec === void 0) continue;
|
|
2826
|
+
if (!installedSpecMatches(entry, spec)) continue;
|
|
2827
|
+
const identity = {
|
|
2828
|
+
source: entry.source,
|
|
2829
|
+
repo: entry.repo,
|
|
2830
|
+
subdir: entry.subdir
|
|
2831
|
+
};
|
|
2338
2832
|
if (entry.source === "github") {
|
|
2339
|
-
const pin = pins[entry.name];
|
|
2833
|
+
const pin = pins[identityKey(entry)] ?? pins[entry.name];
|
|
2340
2834
|
installed.push({
|
|
2341
2835
|
name: entry.name,
|
|
2836
|
+
...identity,
|
|
2342
2837
|
installed: pin ?? spec,
|
|
2343
2838
|
latest: entry.version,
|
|
2344
2839
|
outdated: pin !== void 0 && pin !== entry.version,
|
|
@@ -2346,6 +2841,7 @@ let ShopGateway = (() => {
|
|
|
2346
2841
|
});
|
|
2347
2842
|
} else installed.push({
|
|
2348
2843
|
name: entry.name,
|
|
2844
|
+
...identity,
|
|
2349
2845
|
installed: spec,
|
|
2350
2846
|
latest: entry.version,
|
|
2351
2847
|
outdated: this.isBehind(spec, entry.version),
|
|
@@ -2375,22 +2871,17 @@ let ShopGateway = (() => {
|
|
|
2375
2871
|
* dependencies the shop does not manage (the base bundle, the shop
|
|
2376
2872
|
* itself). The same install records/polling serve the client. */
|
|
2377
2873
|
async uninstall(args) {
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
const { snapshot } = await (this.options.loadCatalog ?? loadCatalog)({
|
|
2381
|
-
origins: this.originsFor(catalogUrl),
|
|
2382
|
-
cacheDir
|
|
2383
|
-
});
|
|
2384
|
-
this.lastSnapshot = snapshot;
|
|
2385
|
-
}
|
|
2386
|
-
if (!this.lastSnapshot.entries.some((entry) => entry.name === args.name)) return {
|
|
2874
|
+
const named = (await this.snapshotNow()).entries.filter((entry) => entry.name === args.name);
|
|
2875
|
+
if (named.length === 0) return {
|
|
2387
2876
|
ok: false,
|
|
2388
2877
|
detail: `dsh-plugin-shop: ${args.name} is not in the catalog`
|
|
2389
2878
|
};
|
|
2390
|
-
|
|
2879
|
+
const spec = (readProfileManifest("dsh-plugin-shop", this.profileDirResolved()).dependencies ?? {})[args.name];
|
|
2880
|
+
if (spec === void 0) return {
|
|
2391
2881
|
ok: false,
|
|
2392
2882
|
detail: `dsh-plugin-shop: ${args.name} is not installed`
|
|
2393
2883
|
};
|
|
2884
|
+
const installedEntry = named.find((entry) => installedSpecMatches(entry, spec));
|
|
2394
2885
|
const priorEntryIds = this.ownedEntryIdsOrNone(args.name);
|
|
2395
2886
|
const running = startUninstall({
|
|
2396
2887
|
profile: this.profile,
|
|
@@ -2406,10 +2897,14 @@ let ShopGateway = (() => {
|
|
|
2406
2897
|
}
|
|
2407
2898
|
});
|
|
2408
2899
|
const pins = readRepoPins(this.pinFs, this.pinsPath());
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2900
|
+
const stalePins = [args.name, ...installedEntry === void 0 ? [] : [identityKey(installedEntry)]];
|
|
2901
|
+
let forgot = false;
|
|
2902
|
+
for (const key of stalePins) {
|
|
2903
|
+
if (pins[key] === void 0) continue;
|
|
2904
|
+
delete pins[key];
|
|
2905
|
+
forgot = true;
|
|
2412
2906
|
}
|
|
2907
|
+
if (forgot) writeRepoPins(this.pinFs, this.pinsPath(), pins);
|
|
2413
2908
|
this.installs.set(running.installId, running);
|
|
2414
2909
|
this.installOrder.push(running.installId);
|
|
2415
2910
|
this.evictFinishedInstalls();
|
|
@@ -2424,6 +2919,10 @@ let ShopGateway = (() => {
|
|
|
2424
2919
|
* response is out. The browser monitors the origin and refreshes when the
|
|
2425
2920
|
* new server answers. Refusals are issued before anything is torn down. */
|
|
2426
2921
|
async restart() {
|
|
2922
|
+
if (this.hasRunningCommand()) return {
|
|
2923
|
+
ok: false,
|
|
2924
|
+
detail: "dsh-plugin-shop: an install is still running in this profile; a restart now would boot the new dsh against a half-written profile. Wait for it to finish and try again."
|
|
2925
|
+
};
|
|
2427
2926
|
if (!this.restartPlatformSupported()) return {
|
|
2428
2927
|
ok: false,
|
|
2429
2928
|
detail: "dsh-plugin-shop: restart is not supported on Windows yet; restart dsh manually to apply the change"
|
|
@@ -2439,9 +2938,16 @@ let ShopGateway = (() => {
|
|
|
2439
2938
|
};
|
|
2440
2939
|
try {
|
|
2441
2940
|
const { cacheDir } = this.rowConfig();
|
|
2442
|
-
|
|
2941
|
+
const { command, args } = restartCommand({
|
|
2443
2942
|
dshBin: this.dshBin,
|
|
2444
2943
|
argv: this.restartArgv,
|
|
2944
|
+
execPath: process.execPath,
|
|
2945
|
+
execArgv: process.execArgv,
|
|
2946
|
+
script: this.restartScript
|
|
2947
|
+
});
|
|
2948
|
+
startRestart({
|
|
2949
|
+
command,
|
|
2950
|
+
args,
|
|
2445
2951
|
parentPid: this.restartParentPid,
|
|
2446
2952
|
logFile: join(cacheDir, "restart.log"),
|
|
2447
2953
|
env: process.env
|