@deepseek-ai/dsh-plugin-manager 0.1.6-alpha.2 → 0.1.7-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +14 -2
- package/README.zh.md +14 -2
- package/lib/index.js +373 -74
- package/lib/typert.host.js +207 -33
- package/lib/typert.remote-client.d.ts +7 -3
- package/lib/typert.remote-client.js +159 -19
- package/lib/types/index.d.ts +30 -8
- package/lib/types/index.js +210 -70
- package/lib/types/install-failure.js +1 -1
- package/lib/types/install-spec.d.ts +7 -1
- package/lib/types/install-spec.js +16 -2
- package/lib/types/operations.d.ts +27 -3
- package/lib/types/operations.js +35 -5
- package/lib/types/registry.d.ts +49 -0
- package/lib/types/registry.js +97 -0
- package/lib/types/tools.js +5 -1
- package/lib/types/types.d.ts +46 -4
- package/package.json +37 -32
package/lib/types/index.js
CHANGED
|
@@ -37,19 +37,23 @@ import { randomUUID } from 'node:crypto';
|
|
|
37
37
|
import { existsSync, readFileSync } from 'node:fs';
|
|
38
38
|
import { readFile, rm } from 'node:fs/promises';
|
|
39
39
|
import { join } from 'node:path';
|
|
40
|
+
import { pathToFileURL } from 'node:url';
|
|
40
41
|
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write';
|
|
41
42
|
import z from '@deepseek-ai/schemastery';
|
|
42
43
|
import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol';
|
|
43
44
|
import { pluginEntryId, readPluginInventory } from '@deepseek-ai/dsh-host-plugin-inventory';
|
|
44
|
-
import { readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries, reconcileProfilePatches, readProfilePatches, OPTIONAL_BUNDLES, } from '@deepseek-ai/dsh-app-boot';
|
|
45
|
-
import { bundleManifest, runProfilePnpm, saveManifest, viewProfilePackage } from "./operations.js";
|
|
45
|
+
import { readPluginMeta, readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries, reconcileProfilePatches, readProfilePatches, OPTIONAL_BUNDLES, bundlePatchPaths, } from '@deepseek-ai/dsh-app-boot';
|
|
46
|
+
import { bundleManifest, readProfileRegistry, registryArguments, runProfilePnpm, saveManifest, viewProfilePackage } from "./operations.js";
|
|
46
47
|
import { classifyInstallFailure } from "./install-failure.js";
|
|
47
48
|
import { InvalidInstallSpecError, parseInstallSpec } from "./install-spec.js";
|
|
49
|
+
import { attributeFailure, normalizeRegistry, NPMMIRROR_REGISTRY, registryPlan } from "./registry.js";
|
|
48
50
|
import { writePluginEnabled } from "./patch.js";
|
|
49
51
|
import { ManagementFailure } from "./failure.js";
|
|
50
52
|
import { approveBuilds, readPendingBuilds } from "./build-approval.js";
|
|
51
53
|
export { classifyInstallFailure } from "./install-failure.js";
|
|
52
54
|
export { InvalidInstallSpecError, parseInstallSpec } from "./install-spec.js";
|
|
55
|
+
/** An http(s) URL, as pnpm's `--registry` takes it. */
|
|
56
|
+
const REGISTRY_URL = /^https?:\/\/\S+$/;
|
|
53
57
|
const protectedModules = new Set([
|
|
54
58
|
'@deepseek-ai/dsh-plugin-manager', '@deepseek-ai/cordis-plugin-loader',
|
|
55
59
|
'@deepseek-ai/cordis-plugin-include', '@deepseek-ai/dsh-api-gateway',
|
|
@@ -88,7 +92,7 @@ function stringField(manifest, field) {
|
|
|
88
92
|
return typeof value === 'string' ? value : undefined;
|
|
89
93
|
}
|
|
90
94
|
/** What a package manifest says about the package: identity, one-liner, and whether it is a bundle. */
|
|
91
|
-
function inspectionOf(kind, manifest) {
|
|
95
|
+
function inspectionOf(kind, manifest, registry) {
|
|
92
96
|
const dsh = manifest.dsh;
|
|
93
97
|
const declared = typeof dsh === 'object' && dsh !== null ? dsh : undefined;
|
|
94
98
|
const bundle = declared !== undefined && typeof declared.bundle === 'object' && declared.bundle !== null;
|
|
@@ -96,7 +100,7 @@ function inspectionOf(kind, manifest) {
|
|
|
96
100
|
const version = stringField(manifest, 'version');
|
|
97
101
|
const description = stringField(manifest, 'description');
|
|
98
102
|
return {
|
|
99
|
-
status: 'accepted', kind, bundle,
|
|
103
|
+
status: 'accepted', kind, bundle, registry,
|
|
100
104
|
...name === undefined ? {} : { name },
|
|
101
105
|
...version === undefined ? {} : { version },
|
|
102
106
|
...description === undefined || description === '' ? {} : { description },
|
|
@@ -105,16 +109,45 @@ function inspectionOf(kind, manifest) {
|
|
|
105
109
|
function refused(problem, reason) {
|
|
106
110
|
return { status: 'refused', problem, reason };
|
|
107
111
|
}
|
|
112
|
+
/** The refusal `pnpm view --json` prints on stdout, `{ error: { code, message } }`, as one log line; empty for anything else. */
|
|
113
|
+
function printedError(printed) {
|
|
114
|
+
let parsed;
|
|
115
|
+
try {
|
|
116
|
+
parsed = JSON.parse(printed || 'null');
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return ''; /* not JSON: nothing pnpm printed as a refusal */
|
|
120
|
+
}
|
|
121
|
+
const error = typeof parsed === 'object' && parsed !== null ? parsed.error : undefined;
|
|
122
|
+
if (typeof error !== 'object' || error === null)
|
|
123
|
+
return '';
|
|
124
|
+
const { code, message } = error;
|
|
125
|
+
return [code, message].filter((part) => typeof part === 'string').join(' ');
|
|
126
|
+
}
|
|
127
|
+
/** The spec's form, for deciding whether a failed attempt was the registry's; a form the parser refuses has no host of its own. */
|
|
128
|
+
function parsedForRegistry(spec) {
|
|
129
|
+
try {
|
|
130
|
+
return parseInstallSpec(spec);
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
/* v8 ignore next -- parseInstallSpec throws nothing but its own refusal */
|
|
134
|
+
if (!(error instanceof InvalidInstallSpecError))
|
|
135
|
+
throw error;
|
|
136
|
+
return { kind: 'registry', spec, name: spec };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
108
139
|
/** Manage profile files and apply their declared reload lifecycle. */
|
|
109
140
|
let PluginManager = (() => {
|
|
110
141
|
let _classSuper = TypertRemoteService;
|
|
111
142
|
let _instanceExtraInitializers = [];
|
|
112
143
|
let _listPlugins_decorators;
|
|
113
144
|
let _listBundles_decorators;
|
|
145
|
+
let _registries_decorators;
|
|
114
146
|
let _inspect_decorators;
|
|
115
147
|
let _setPluginEnabled_decorators;
|
|
116
148
|
let _setBundleEnabled_decorators;
|
|
117
149
|
let _installBundle_decorators;
|
|
150
|
+
let _waitForInstall_decorators;
|
|
118
151
|
let _cancelInstall_decorators;
|
|
119
152
|
let _removeBundle_decorators;
|
|
120
153
|
return class PluginManager extends _classSuper {
|
|
@@ -122,18 +155,22 @@ let PluginManager = (() => {
|
|
|
122
155
|
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
123
156
|
_listPlugins_decorators = [Remote];
|
|
124
157
|
_listBundles_decorators = [Remote];
|
|
158
|
+
_registries_decorators = [Remote];
|
|
125
159
|
_inspect_decorators = [Remote];
|
|
126
160
|
_setPluginEnabled_decorators = [Remote];
|
|
127
161
|
_setBundleEnabled_decorators = [Remote];
|
|
128
162
|
_installBundle_decorators = [Remote];
|
|
163
|
+
_waitForInstall_decorators = [Remote];
|
|
129
164
|
_cancelInstall_decorators = [Remote];
|
|
130
165
|
_removeBundle_decorators = [Remote];
|
|
131
166
|
__esDecorate(this, null, _listPlugins_decorators, { kind: "method", name: "listPlugins", static: false, private: false, access: { has: obj => "listPlugins" in obj, get: obj => obj.listPlugins }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
132
167
|
__esDecorate(this, null, _listBundles_decorators, { kind: "method", name: "listBundles", static: false, private: false, access: { has: obj => "listBundles" in obj, get: obj => obj.listBundles }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
168
|
+
__esDecorate(this, null, _registries_decorators, { kind: "method", name: "registries", static: false, private: false, access: { has: obj => "registries" in obj, get: obj => obj.registries }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
133
169
|
__esDecorate(this, null, _inspect_decorators, { kind: "method", name: "inspect", static: false, private: false, access: { has: obj => "inspect" in obj, get: obj => obj.inspect }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
134
170
|
__esDecorate(this, null, _setPluginEnabled_decorators, { kind: "method", name: "setPluginEnabled", static: false, private: false, access: { has: obj => "setPluginEnabled" in obj, get: obj => obj.setPluginEnabled }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
135
171
|
__esDecorate(this, null, _setBundleEnabled_decorators, { kind: "method", name: "setBundleEnabled", static: false, private: false, access: { has: obj => "setBundleEnabled" in obj, get: obj => obj.setBundleEnabled }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
136
172
|
__esDecorate(this, null, _installBundle_decorators, { kind: "method", name: "installBundle", static: false, private: false, access: { has: obj => "installBundle" in obj, get: obj => obj.installBundle }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
173
|
+
__esDecorate(this, null, _waitForInstall_decorators, { kind: "method", name: "waitForInstall", static: false, private: false, access: { has: obj => "waitForInstall" in obj, get: obj => obj.waitForInstall }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
137
174
|
__esDecorate(this, null, _cancelInstall_decorators, { kind: "method", name: "cancelInstall", static: false, private: false, access: { has: obj => "cancelInstall" in obj, get: obj => obj.cancelInstall }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
138
175
|
__esDecorate(this, null, _removeBundle_decorators, { kind: "method", name: "removeBundle", static: false, private: false, access: { has: obj => "removeBundle" in obj, get: obj => obj.removeBundle }, metadata: _metadata }, null, _instanceExtraInitializers);
|
|
139
176
|
if (_metadata) Object.defineProperty(this, Symbol.metadata, { enumerable: true, configurable: true, writable: true, value: _metadata });
|
|
@@ -144,14 +181,19 @@ let PluginManager = (() => {
|
|
|
144
181
|
outputBytes: z.number().step(1).min(1).default(16384),
|
|
145
182
|
lockWaitMs: z.number().step(1).min(0).default(120000),
|
|
146
183
|
inspectTimeoutMs: z.number().step(1).min(1000).default(20000),
|
|
184
|
+
registry: z.string().pattern(REGISTRY_URL),
|
|
185
|
+
fallbackRegistries: z.array(z.string().pattern(REGISTRY_URL)).default([NPMMIRROR_REGISTRY]),
|
|
147
186
|
});
|
|
148
|
-
|
|
187
|
+
/** Management bundles remain protected if their files become unreadable. */
|
|
188
|
+
managementBundles = (__runInitializers(this, _instanceExtraInitializers), new Set());
|
|
189
|
+
ownerEntryId;
|
|
149
190
|
packageOperations = new Set();
|
|
150
191
|
profile;
|
|
151
192
|
outputBytes;
|
|
152
193
|
lockWaitMs;
|
|
153
194
|
inspectTimeoutMs;
|
|
154
195
|
pnpmCommand;
|
|
196
|
+
configuredRegistries;
|
|
155
197
|
ownerContext;
|
|
156
198
|
abort = new AbortController();
|
|
157
199
|
/** Installations by request id, from their call until it settles. */
|
|
@@ -161,10 +203,16 @@ let PluginManager = (() => {
|
|
|
161
203
|
this.ownerEntryId = ctx.fiber.entry?.id;
|
|
162
204
|
this.ownerContext = ctx;
|
|
163
205
|
this.profile = ctx.profileContext;
|
|
206
|
+
for (const name of this.profile.startedBundles)
|
|
207
|
+
this.protectsManager(name);
|
|
164
208
|
this.outputBytes = config.outputBytes;
|
|
165
209
|
this.lockWaitMs = config.lockWaitMs;
|
|
166
210
|
this.inspectTimeoutMs = config.inspectTimeoutMs;
|
|
167
211
|
this.pnpmCommand = config.pnpmCommand;
|
|
212
|
+
this.configuredRegistries = {
|
|
213
|
+
registry: config.registry === undefined ? null : normalizeRegistry(config.registry),
|
|
214
|
+
fallbackRegistries: config.fallbackRegistries.map(normalizeRegistry),
|
|
215
|
+
};
|
|
168
216
|
ctx.effect(() => async () => {
|
|
169
217
|
this.abort.abort();
|
|
170
218
|
await Promise.allSettled([...this.packageOperations]);
|
|
@@ -192,8 +240,8 @@ let PluginManager = (() => {
|
|
|
192
240
|
}
|
|
193
241
|
/** Read the profile's installed bundles, the bundles this dsh installation supplies, and the selected names that are not bundles.
|
|
194
242
|
* A dependency without a bundle patch is listed, as a `not-bundle` problem, only while it is selected.
|
|
195
|
-
* @returns Package versions,
|
|
196
|
-
* bundle, and removal availability.
|
|
243
|
+
* @returns Package versions, manifest descriptions, rows, optional display metadata, activation selections,
|
|
244
|
+
* whether the installation offers the bundle, and removal availability.
|
|
197
245
|
*/
|
|
198
246
|
listBundles() {
|
|
199
247
|
const manifest = readProfileManifest('dsh', this.profile.dir);
|
|
@@ -207,34 +255,51 @@ let PluginManager = (() => {
|
|
|
207
255
|
const optional = OPTIONAL_BUNDLES.includes(name);
|
|
208
256
|
const removable = installed && !Object.hasOwn(installation.dependencies ?? {}, name);
|
|
209
257
|
const enabled = selected.includes(name);
|
|
258
|
+
const readOnlyReason = this.protectsManager(name) ? 'management-required' : undefined;
|
|
210
259
|
try {
|
|
211
260
|
const info = bundleManifest(name, this.profile.dir, this.profile.installAnchor);
|
|
212
261
|
if (info === undefined) {
|
|
213
262
|
if (enabled)
|
|
214
|
-
bundles.push({ name, enabled, installed, optional, removable
|
|
263
|
+
bundles.push({ name, enabled, installed, optional, removable: removable && readOnlyReason === undefined,
|
|
264
|
+
...(readOnlyReason === undefined ? {} : { readOnlyReason }), error: { code: 'not-bundle' }, rows: [], overrides: [] });
|
|
215
265
|
continue;
|
|
216
266
|
}
|
|
217
|
-
const
|
|
267
|
+
const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir);
|
|
268
|
+
const meta = readPluginMeta(info.name ?? name, pathToFileURL(join(dir, 'package.json')).href);
|
|
218
269
|
bundles.push({ name, ...(info.version === undefined ? {} : { version: info.version }),
|
|
219
270
|
...(info.description === undefined || info.description === '' ? {} : { description: info.description }),
|
|
271
|
+
...meta === undefined ? {} : { meta },
|
|
220
272
|
enabled, installed, optional, removable: removable && readOnlyReason === undefined,
|
|
221
273
|
...(readOnlyReason === undefined ? {} : { readOnlyReason }),
|
|
222
274
|
...this.declaredRows(name, info) });
|
|
223
275
|
}
|
|
224
276
|
catch (error) {
|
|
225
277
|
if (enabled || installed) {
|
|
226
|
-
bundles.push({ name, enabled, installed, optional, removable
|
|
278
|
+
bundles.push({ name, enabled, installed, optional, removable: removable && readOnlyReason === undefined,
|
|
279
|
+
...(readOnlyReason === undefined ? {} : { readOnlyReason }), error: managementError(error), rows: [], overrides: [] });
|
|
227
280
|
}
|
|
228
281
|
}
|
|
229
282
|
}
|
|
230
283
|
return Promise.resolve(bundles);
|
|
231
284
|
}
|
|
285
|
+
/** Read the registries this manager asks: the configured first one, its fallbacks in order, and what pnpm's own configuration names.
|
|
286
|
+
* @returns The registries in pnpm's comparison form; null is the one pnpm's own configuration names, `resolved` as pnpm reads it now.
|
|
287
|
+
*/
|
|
288
|
+
async registries() {
|
|
289
|
+
return {
|
|
290
|
+
...this.configuredRegistries, fallbackRegistries: [...this.configuredRegistries.fallbackRegistries],
|
|
291
|
+
resolved: await readProfileRegistry(this.profile.dir, {
|
|
292
|
+
...this.profile.packageManager ?? { command: this.pnpmCommand }, timeoutMs: this.inspectTimeoutMs,
|
|
293
|
+
}),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
232
296
|
/** Read what a spec names before installing it.
|
|
233
297
|
* @param spec One package spec: a registry name, an absolute path, a git address, or a tarball.
|
|
298
|
+
* @param options The registry asked first.
|
|
234
299
|
* @param signal Ends a registry lookup early.
|
|
235
300
|
* @returns The package the spec names, or why it is refused.
|
|
236
301
|
*/
|
|
237
|
-
async inspect(spec, signal) {
|
|
302
|
+
async inspect(spec, options, signal) {
|
|
238
303
|
let parsed;
|
|
239
304
|
try {
|
|
240
305
|
parsed = parseInstallSpec(spec);
|
|
@@ -250,12 +315,14 @@ let PluginManager = (() => {
|
|
|
250
315
|
const known = new Set([
|
|
251
316
|
...manifest.dsh?.profile?.bundles ?? [], ...Object.keys(manifest.dependencies ?? {}), ...Object.keys(installation.dependencies ?? {}),
|
|
252
317
|
]);
|
|
318
|
+
const plan = registryPlan(options?.registry, await this.registries());
|
|
319
|
+
const registry = plan[0];
|
|
253
320
|
switch (parsed.kind) {
|
|
254
|
-
case 'git': return { status: 'accepted', kind: 'git', bundle: null };
|
|
321
|
+
case 'git': return { status: 'accepted', kind: 'git', bundle: null, registry, host: parsed.host };
|
|
255
322
|
case 'tarball':
|
|
256
323
|
if (parsed.path !== undefined && !existsSync(parsed.path))
|
|
257
324
|
return refused('not-a-package', 'the tarball does not exist');
|
|
258
|
-
return { status: 'accepted', kind: 'tarball', bundle: null };
|
|
325
|
+
return { status: 'accepted', kind: 'tarball', bundle: null, registry, ...parsed.host === undefined ? {} : { host: parsed.host } };
|
|
259
326
|
case 'path': {
|
|
260
327
|
if (!existsSync(parsed.path))
|
|
261
328
|
return refused('not-a-package', 'the path does not exist');
|
|
@@ -266,7 +333,7 @@ let PluginManager = (() => {
|
|
|
266
333
|
catch (error) {
|
|
267
334
|
return refused('not-a-package', `no readable package.json at the path: ${messageOf(error)}`);
|
|
268
335
|
}
|
|
269
|
-
const inspection = inspectionOf('path', read);
|
|
336
|
+
const inspection = inspectionOf('path', read, registry);
|
|
270
337
|
if (inspection.name === undefined)
|
|
271
338
|
return refused('not-a-package', 'the package.json names no package');
|
|
272
339
|
if (known.has(inspection.name))
|
|
@@ -278,36 +345,48 @@ let PluginManager = (() => {
|
|
|
278
345
|
case 'registry': {
|
|
279
346
|
if (known.has(parsed.name))
|
|
280
347
|
return refused('already-installed', `${parsed.name} is already installed`);
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
348
|
+
const registries = [];
|
|
349
|
+
const refusedBy = (problem, reason) => ({ status: 'refused', problem, reason, registries });
|
|
350
|
+
for (const current of plan) {
|
|
351
|
+
registries.push(current);
|
|
352
|
+
const view = await viewProfilePackage(this.profile.dir, spec.trim(), {
|
|
353
|
+
...this.profile.packageManager ?? { command: this.pnpmCommand },
|
|
354
|
+
timeoutMs: this.inspectTimeoutMs, ...signal === undefined ? {} : { signal }, registry: current,
|
|
355
|
+
});
|
|
356
|
+
const printed = view.stdout.replace(ANSI_SEQUENCE, '').trim();
|
|
357
|
+
if (view.exitCode !== 0 || view.cause !== undefined || view.timedOut) {
|
|
358
|
+
// pnpm prints a refusal as `{ error: { code, message } }` on stdout, with nothing on stderr.
|
|
359
|
+
const log = [view.stderr.trim(), printedError(printed), view.cause === undefined ? '' : messageOf(view.cause)].filter(Boolean).join('\n');
|
|
360
|
+
const kind = classifyInstallFailure({ log, timedOut: view.timedOut, ...view.cause === undefined ? {} : { cause: view.cause } });
|
|
361
|
+
// A lookup the caller dropped is not carried to the next registry.
|
|
362
|
+
if (registries.length < plan.length && signal?.aborted !== true && attributeFailure(kind, log, parsed) === 'registry')
|
|
363
|
+
continue;
|
|
364
|
+
const reason = view.timedOut ? `pnpm view timed out after ${String(this.inspectTimeoutMs)}ms` : log || printed || `pnpm view exited with ${String(view.exitCode)}`;
|
|
365
|
+
if (kind === 'not-found' || kind === 'no-matching-version')
|
|
366
|
+
return refusedBy('not-found', reason);
|
|
367
|
+
if (kind === 'network' || kind === 'timeout')
|
|
368
|
+
return refusedBy('network', reason);
|
|
369
|
+
return refusedBy('unknown', reason);
|
|
370
|
+
}
|
|
371
|
+
let answer;
|
|
372
|
+
try {
|
|
373
|
+
answer = JSON.parse(printed || 'null');
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
return refusedBy('unknown', `unreadable pnpm view output: ${messageOf(error)}`);
|
|
377
|
+
}
|
|
378
|
+
// A range answers one object per matching version, oldest first.
|
|
379
|
+
const latest = Array.isArray(answer) ? answer.at(-1) : answer;
|
|
380
|
+
if (typeof latest !== 'object' || latest === null)
|
|
381
|
+
return refusedBy('unknown', 'pnpm view answered no package');
|
|
382
|
+
const inspection = inspectionOf('registry', latest, current);
|
|
383
|
+
const named = inspection.name === undefined ? { ...inspection, name: parsed.name } : inspection;
|
|
384
|
+
if (!named.bundle)
|
|
385
|
+
return refusedBy('not-a-bundle', `${named.name} declares no dsh.bundle`);
|
|
386
|
+
return named;
|
|
301
387
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
if (typeof latest !== 'object' || latest === null)
|
|
305
|
-
return refused('unknown', 'pnpm view answered no package');
|
|
306
|
-
const inspection = inspectionOf('registry', latest);
|
|
307
|
-
const named = inspection.name === undefined ? { ...inspection, name: parsed.name } : inspection;
|
|
308
|
-
if (!named.bundle)
|
|
309
|
-
return refused('not-a-bundle', `${named.name} declares no dsh.bundle`);
|
|
310
|
-
return named;
|
|
388
|
+
/* v8 ignore next -- the plan is never empty: every attempt returns or continues to the next */
|
|
389
|
+
throw new Error('no registry was asked');
|
|
311
390
|
}
|
|
312
391
|
}
|
|
313
392
|
}
|
|
@@ -345,19 +424,19 @@ let PluginManager = (() => {
|
|
|
345
424
|
* that fails, is cancelled, or adds a package without a bundle patch restores
|
|
346
425
|
* `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.
|
|
347
426
|
* @param spec One package spec, including local paths relative to the invocation directory.
|
|
348
|
-
* @param options Whether to activate the installed bundle (defaults to true), the request id a cancellation names,
|
|
349
|
-
* the pending build scripts to allow for this profile before pnpm runs.
|
|
350
|
-
* @returns Package-manager diagnostics and observed activation outcome.
|
|
427
|
+
* @param options Whether to activate the installed bundle (defaults to true), the request id a cancellation names,
|
|
428
|
+
* the pending build scripts to allow for this profile before pnpm runs, and the registry asked first.
|
|
429
|
+
* @returns Package-manager diagnostics, the registries asked, and the observed activation outcome.
|
|
351
430
|
*/
|
|
352
431
|
installBundle(spec, options) {
|
|
353
432
|
const requestId = options?.requestId;
|
|
354
|
-
const control = { abort: new AbortController(), phase: 'installing',
|
|
433
|
+
const control = { abort: new AbortController(), phase: 'installing', result: Promise.resolve(null) };
|
|
355
434
|
const stopped = () => control.abort.signal.aborted;
|
|
356
435
|
if (requestId !== undefined)
|
|
357
436
|
this.installs.set(requestId, control);
|
|
358
|
-
const announce = (phase) => {
|
|
437
|
+
const announce = (phase, attempt) => {
|
|
359
438
|
if (requestId !== undefined)
|
|
360
|
-
this.ownerContext.emit('plugin-manager/install-state', { requestId, phase });
|
|
439
|
+
this.ownerContext.emit('plugin-manager/install-state', { requestId, phase, ...attempt === undefined ? {} : { attempt } });
|
|
361
440
|
};
|
|
362
441
|
const result = this.change(async (result) => {
|
|
363
442
|
if (spec.trim() === '' || spec.startsWith('-'))
|
|
@@ -370,13 +449,41 @@ let PluginManager = (() => {
|
|
|
370
449
|
}
|
|
371
450
|
const files = await this.readRestoredFiles();
|
|
372
451
|
const before = readProfileManifest('dsh', this.profile.dir).dependencies ?? {};
|
|
373
|
-
|
|
452
|
+
const plan = registryPlan(options?.registry, await this.registries());
|
|
374
453
|
let name;
|
|
375
454
|
try {
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
455
|
+
// The last run is the result's; the registries asked stay listed whatever the outcome.
|
|
456
|
+
let run;
|
|
457
|
+
result.registries = [];
|
|
458
|
+
for (const [index, registry] of plan.entries()) {
|
|
459
|
+
if (index > 0)
|
|
460
|
+
await this.restoreFiles(files);
|
|
461
|
+
// A stop that landed while the files went back, or before the first run, starts no run with a dead signal.
|
|
462
|
+
if (stopped())
|
|
463
|
+
throw new InstallCancelledError();
|
|
464
|
+
result.registries.push(registry);
|
|
465
|
+
announce('installing', { registry, index: index + 1, total: plan.length });
|
|
466
|
+
run = await this.runPnpm(['add', spec, ...registryArguments(registry)], control.abort.signal, requestId);
|
|
467
|
+
result.packageResult = run;
|
|
468
|
+
if (stopped())
|
|
469
|
+
throw new InstallCancelledError();
|
|
470
|
+
/* v8 ignore next 2 -- runPnpm classifies every failed run, so kind is never absent here */
|
|
471
|
+
if (run.exitCode === 0 || run.kind === undefined)
|
|
472
|
+
break;
|
|
473
|
+
const failedAt = attributeFailure(run.kind, run.output, parsedForRegistry(spec));
|
|
474
|
+
// What the last failed run could not reach; a later run that succeeds leaves nothing to say.
|
|
475
|
+
delete result.failedAt;
|
|
476
|
+
if (failedAt !== 'other')
|
|
477
|
+
result.failedAt = failedAt;
|
|
478
|
+
if (failedAt !== 'registry' || index === plan.length - 1)
|
|
479
|
+
break;
|
|
480
|
+
}
|
|
481
|
+
/* v8 ignore next -- the plan is never empty, so a run always settled */
|
|
482
|
+
if (run === undefined)
|
|
483
|
+
throw new Error('no registry was asked');
|
|
484
|
+
if (run.exitCode === 0)
|
|
485
|
+
delete result.failedAt;
|
|
486
|
+
if (run.exitCode !== 0) {
|
|
380
487
|
// pnpm-workspace.yaml is not restored, so the names pnpm left undecided there can be offered for approval.
|
|
381
488
|
try {
|
|
382
489
|
result.pendingBuilds = await readPendingBuilds(this.profile.dir);
|
|
@@ -384,7 +491,7 @@ let PluginManager = (() => {
|
|
|
384
491
|
catch (error) {
|
|
385
492
|
this.ownerContext.logger.warn('Could not read pending build approvals after pnpm failed', error);
|
|
386
493
|
}
|
|
387
|
-
throw new Error(
|
|
494
|
+
throw new Error(run.output);
|
|
388
495
|
}
|
|
389
496
|
const after = readProfileManifest('dsh', this.profile.dir).dependencies ?? {};
|
|
390
497
|
const installed = Object.keys(after).filter(name => before[name] !== after[name]);
|
|
@@ -397,9 +504,10 @@ let PluginManager = (() => {
|
|
|
397
504
|
name = target;
|
|
398
505
|
const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir);
|
|
399
506
|
const manifest = bundleManifest(name, this.profile.dir, this.profile.installAnchor);
|
|
400
|
-
if (manifest?.dsh?.bundle
|
|
507
|
+
if (manifest?.dsh?.bundle === undefined)
|
|
401
508
|
throw new ManagementFailure('not-bundle');
|
|
402
|
-
|
|
509
|
+
for (const file of bundlePatchPaths(dir, manifest.dsh.bundle))
|
|
510
|
+
loadOverlayPatches('dsh', file);
|
|
403
511
|
}
|
|
404
512
|
catch (error) {
|
|
405
513
|
// pnpm has exited by now, so the files it rewrote go back as they were.
|
|
@@ -420,11 +528,18 @@ let PluginManager = (() => {
|
|
|
420
528
|
result.warnings = await this.reload();
|
|
421
529
|
});
|
|
422
530
|
}, { stage: 'install', target: spec, enabled: options?.enabled !== false }, 'install');
|
|
423
|
-
|
|
424
|
-
control.settled = result.then(() => undefined, () => undefined);
|
|
531
|
+
control.result = result;
|
|
425
532
|
return result.finally(() => { if (requestId !== undefined)
|
|
426
533
|
this.installs.delete(requestId); });
|
|
427
534
|
}
|
|
535
|
+
/** Recover the result of an active installation without cancelling it.
|
|
536
|
+
* @param requestId The id supplied when installation started.
|
|
537
|
+
* @returns The installation's outcome after it settles, or null if no active request has that id.
|
|
538
|
+
* Completed results are not retained; null establishes neither success nor cancellation.
|
|
539
|
+
*/
|
|
540
|
+
async waitForInstall(requestId) {
|
|
541
|
+
return this.installs.get(requestId)?.result ?? null;
|
|
542
|
+
}
|
|
428
543
|
/** Stop an installation this manager owns and wait until its files are back.
|
|
429
544
|
* @param requestId The id the installation was started with.
|
|
430
545
|
* @returns `cancelled` once pnpm exited and the files are restored, `too-late` once the bundle is being
|
|
@@ -438,7 +553,8 @@ let PluginManager = (() => {
|
|
|
438
553
|
return { status: 'too-late' };
|
|
439
554
|
this.ownerContext.emit('plugin-manager/install-state', { requestId, phase: 'cancelling' });
|
|
440
555
|
control.abort.abort();
|
|
441
|
-
|
|
556
|
+
/* v8 ignore next -- change() folds every failure into its result; only a lock or disposal error rejects */
|
|
557
|
+
await control.result.then(() => undefined, () => undefined);
|
|
442
558
|
return { status: 'cancelled' };
|
|
443
559
|
}
|
|
444
560
|
/** Unload and remove a profile-owned bundle dependency through dsh plugin's pnpm path.
|
|
@@ -452,8 +568,8 @@ let PluginManager = (() => {
|
|
|
452
568
|
if (bundle === undefined || !bundle.removable)
|
|
453
569
|
throw new ManagementFailure('not-removable');
|
|
454
570
|
if (this.ownerContext.get('hmr') === undefined && (this.profile.startedBundles.includes(name)
|
|
455
|
-
|| this.bundleRows(name).some(row => [...this.ctx.loader.entries()]
|
|
456
|
-
.some(entry => entry.options.id === row.id && entry.fiber !== undefined)))) {
|
|
571
|
+
|| (bundle.error === undefined && this.bundleRows(name).some(row => [...this.ctx.loader.entries()]
|
|
572
|
+
.some(entry => entry.options.id === row.id && entry.fiber !== undefined))))) {
|
|
457
573
|
throw new ManagementFailure('stop-profile');
|
|
458
574
|
}
|
|
459
575
|
const contributions = bundle.error === undefined ? this.bundleRows(name) : [];
|
|
@@ -473,25 +589,32 @@ let PluginManager = (() => {
|
|
|
473
589
|
}
|
|
474
590
|
/** The rows a bundle's patch inserts and the existing rows it changes; an unreadable patch throws. */
|
|
475
591
|
declaredRows(name, info) {
|
|
476
|
-
const
|
|
592
|
+
const bundle = info.dsh?.bundle;
|
|
477
593
|
/* v8 ignore next -- bundleManifest answers only manifests that declare a patch */
|
|
478
|
-
if (
|
|
594
|
+
if (bundle === undefined)
|
|
479
595
|
return { rows: [], overrides: [] };
|
|
480
596
|
const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir);
|
|
481
|
-
const patches = loadOverlayPatches('dsh',
|
|
597
|
+
const patches = bundlePatchPaths(dir, bundle).flatMap(file => loadOverlayPatches('dsh', file));
|
|
482
598
|
// One entry per row id: the Loader keeps a single entry for an id, whichever layer declared it last.
|
|
483
599
|
const live = new Map();
|
|
484
600
|
for (const entry of this.ctx.loader.entries()) {
|
|
485
601
|
/* v8 ignore next -- the Loader gives every entry an id before it is listed */
|
|
486
602
|
if (typeof entry.options.id === 'string')
|
|
487
|
-
live.set(entry.options.id,
|
|
603
|
+
live.set(entry.options.id, {
|
|
604
|
+
entryId: pluginEntryId(entry.id), baseUrl: entry.parent.tree.ctx.baseUrl,
|
|
605
|
+
});
|
|
488
606
|
}
|
|
489
607
|
const rows = [];
|
|
608
|
+
const packages = this.ctx.get('pluginPackages');
|
|
490
609
|
for (const row of flatten(composeEntries([patches.filter(item => item.insert !== undefined)]))) {
|
|
491
610
|
if (typeof row.id !== 'string' || typeof row.name !== 'string')
|
|
492
611
|
continue;
|
|
493
|
-
const
|
|
494
|
-
|
|
612
|
+
const active = live.get(row.id);
|
|
613
|
+
const entryId = active?.entryId;
|
|
614
|
+
const base = active?.baseUrl ?? pathToFileURL(join(dir, 'package.json')).href;
|
|
615
|
+
const meta = packages?.metaOf(row.name, base);
|
|
616
|
+
rows.push({ rowId: row.id, moduleName: row.name,
|
|
617
|
+
...entryId === undefined ? {} : { entryId }, ...meta === undefined ? {} : { meta } });
|
|
495
618
|
}
|
|
496
619
|
const declared = new Set(rows.map(row => row.rowId));
|
|
497
620
|
const overrides = [...new Set(patches.flatMap(item => item.insert === undefined && typeof item.id === 'string' && !declared.has(item.id) ? [item.id] : []))];
|
|
@@ -551,6 +674,8 @@ let PluginManager = (() => {
|
|
|
551
674
|
if ((enabled || !previous.includes(name)) && bundleManifest(name, this.profile.dir, this.profile.installAnchor) === undefined) {
|
|
552
675
|
throw new ManagementFailure('not-bundle');
|
|
553
676
|
}
|
|
677
|
+
if (enabled)
|
|
678
|
+
this.bundleRows(name);
|
|
554
679
|
if (!enabled && previous.includes(name)) {
|
|
555
680
|
if (this.protectsManager(name))
|
|
556
681
|
throw new ManagementFailure('management-required');
|
|
@@ -560,16 +685,31 @@ let PluginManager = (() => {
|
|
|
560
685
|
return;
|
|
561
686
|
manifest.dsh = { ...manifest.dsh, profile: { ...manifest.dsh?.profile, bundles } };
|
|
562
687
|
await saveManifest(this.profile.dir, manifest);
|
|
688
|
+
if (enabled)
|
|
689
|
+
this.protectsManager(name);
|
|
563
690
|
}
|
|
564
691
|
bundleRows(name) {
|
|
565
692
|
const info = bundleManifest(name, this.profile.dir, this.profile.installAnchor);
|
|
566
693
|
if (info?.dsh?.bundle === undefined)
|
|
567
694
|
return [];
|
|
568
695
|
const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir);
|
|
569
|
-
return flatten(composeEntries([
|
|
696
|
+
return flatten(composeEntries([bundlePatchPaths(dir, info.dsh.bundle).flatMap(file => loadOverlayPatches('dsh', file))]));
|
|
570
697
|
}
|
|
571
698
|
protectsManager(name) {
|
|
572
|
-
|
|
699
|
+
if (this.managementBundles.has(name))
|
|
700
|
+
return true;
|
|
701
|
+
let rows;
|
|
702
|
+
try {
|
|
703
|
+
rows = this.bundleRows(name);
|
|
704
|
+
}
|
|
705
|
+
catch (_error) {
|
|
706
|
+
// Unreadable bundles contribute no new rows; listBundles reports their diagnostics.
|
|
707
|
+
return false;
|
|
708
|
+
}
|
|
709
|
+
const protectedBundle = rows.some(row => protectedModules.has(row.name) || `include:${row.id}` === this.ownerEntryId);
|
|
710
|
+
if (protectedBundle)
|
|
711
|
+
this.managementBundles.add(name);
|
|
712
|
+
return protectedBundle;
|
|
573
713
|
}
|
|
574
714
|
configure(operation) {
|
|
575
715
|
const hmr = this.ownerContext.get('hmr');
|
|
@@ -12,7 +12,7 @@ const LOG_KINDS = [
|
|
|
12
12
|
['disk-full', /\bENOSPC\b|no space left on device/i],
|
|
13
13
|
['permission', /\bEACCES\b|\bEPERM\b|permission denied/i],
|
|
14
14
|
['integrity', /ERR_PNPM_TARBALL_INTEGRITY|ERR_PNPM_BAD_TARBALL_SIZE|\bEINTEGRITY\b/],
|
|
15
|
-
['network', /\bENOTFOUND\b|\bECONNRESET\b|\bETIMEDOUT\b|\bECONNREFUSED\b|\bEAI_AGAIN\b|ERR_PNPM_META_FETCH_FAIL|ERR_PNPM_FETCH_5\d\d|ERR_PNPM_FETCH_TIMEOUT|socket hang up|Could not resolve host|unable to access/],
|
|
15
|
+
['network', /\bENOTFOUND\b|\bECONNRESET\b|\bETIMEDOUT\b|\bECONNREFUSED\b|\bEAI_AGAIN\b|ERR_PNPM_META_FETCH_FAIL|ERR_PNPM_FETCH_5\d\d|ERR_PNPM_FETCH_TIMEOUT|\bFETCH_ERROR\b|socket hang up|Could not resolve host|unable to access/],
|
|
16
16
|
];
|
|
17
17
|
/**
|
|
18
18
|
* Classify a failed run.
|
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
* takes, and for a registry name whether it is one the registry can accept.
|
|
4
4
|
* @module @deepseek-ai/dsh-plugin-manager/install-spec
|
|
5
5
|
*/
|
|
6
|
-
/**
|
|
6
|
+
/**
|
|
7
|
+
* One spec read into its form and the parts an inspection needs. A git spec
|
|
8
|
+
* and a tarball URL carry the `host` pnpm fetches them from, which no registry
|
|
9
|
+
* stands in for; only their dependencies come from the registry.
|
|
10
|
+
*/
|
|
7
11
|
export type ParsedInstallSpec = {
|
|
8
12
|
readonly kind: 'registry';
|
|
9
13
|
readonly spec: string;
|
|
@@ -17,9 +21,11 @@ export type ParsedInstallSpec = {
|
|
|
17
21
|
readonly kind: 'tarball';
|
|
18
22
|
readonly spec: string;
|
|
19
23
|
readonly path?: string;
|
|
24
|
+
readonly host?: string;
|
|
20
25
|
} | {
|
|
21
26
|
readonly kind: 'git';
|
|
22
27
|
readonly spec: string;
|
|
28
|
+
readonly host: string;
|
|
23
29
|
};
|
|
24
30
|
/** A spec neither pnpm nor the registry would take; `reason` is what the person reads. */
|
|
25
31
|
export declare class InvalidInstallSpecError extends Error {
|
|
@@ -8,6 +8,10 @@ import { isAbsolute } from 'node:path';
|
|
|
8
8
|
const GIT_SHORTHAND = /^(?:github|gitlab|bitbucket|gist):/i;
|
|
9
9
|
const GIT_URL = /^git(?:\+[a-z]+)?:\/\/|^git@[^:]+:/i;
|
|
10
10
|
const HOSTED_REPOSITORY_URL = /^https?:\/\/[^/]+\/[^/]+\/[^/#]+(?:\.git)?(?:#.*)?$/i;
|
|
11
|
+
/** The hosts pnpm's shorthands stand for. */
|
|
12
|
+
const GIT_SHORTHAND_HOSTS = {
|
|
13
|
+
github: 'github.com', gitlab: 'gitlab.com', bitbucket: 'bitbucket.org', gist: 'gist.github.com',
|
|
14
|
+
};
|
|
11
15
|
/** A tarball, on disk or over HTTP. */
|
|
12
16
|
const TARBALL_SPEC = /\.(?:tgz|tar\.gz)(?:#.*)?$/i;
|
|
13
17
|
/** An npm package name: lowercase URL-safe segments, an optional scope, no leading dot or underscore. */
|
|
@@ -31,6 +35,16 @@ export class InvalidInstallSpecError extends Error {
|
|
|
31
35
|
function invalid(spec, reason) {
|
|
32
36
|
return new InvalidInstallSpecError(spec, reason);
|
|
33
37
|
}
|
|
38
|
+
/** The host a git spec is cloned from: the shorthand's host, the scp-like user@host, or the URL's host with its port. */
|
|
39
|
+
function gitHost(spec) {
|
|
40
|
+
const shorthand = /^([a-z]+):/i.exec(spec)?.[1]?.toLowerCase();
|
|
41
|
+
if (shorthand !== undefined && Object.hasOwn(GIT_SHORTHAND_HOSTS, shorthand))
|
|
42
|
+
return GIT_SHORTHAND_HOSTS[shorthand];
|
|
43
|
+
const scp = /^git@([^:]+):/i.exec(spec)?.[1];
|
|
44
|
+
if (scp !== undefined)
|
|
45
|
+
return scp.toLowerCase();
|
|
46
|
+
return new URL(spec.replace(/^git\+/i, '')).host;
|
|
47
|
+
}
|
|
34
48
|
/**
|
|
35
49
|
* Read a spec into its form. A path must be absolute: the Host's working
|
|
36
50
|
* directory means nothing to the person typing into a browser, and a
|
|
@@ -54,10 +68,10 @@ export function parseInstallSpec(raw) {
|
|
|
54
68
|
throw invalid(spec, 'a local path must be absolute');
|
|
55
69
|
const git = GIT_SHORTHAND.test(spec) || GIT_URL.test(spec) || HOSTED_REPOSITORY_URL.test(spec);
|
|
56
70
|
if (git && !TARBALL_SPEC.test(spec))
|
|
57
|
-
return { kind: 'git', spec };
|
|
71
|
+
return { kind: 'git', spec, host: gitHost(spec) };
|
|
58
72
|
if (/^https?:\/\//i.test(spec)) {
|
|
59
73
|
if (TARBALL_SPEC.test(spec))
|
|
60
|
-
return { kind: 'tarball', spec };
|
|
74
|
+
return { kind: 'tarball', spec, host: new URL(spec).host };
|
|
61
75
|
throw invalid(spec, 'a URL must point at a git repository or a tarball');
|
|
62
76
|
}
|
|
63
77
|
const at = spec.indexOf('@', 1);
|