@deepseek-ai/dsh-plugin-manager 0.1.7-alpha.2 → 0.1.7-rc.1
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 +17 -2
- package/README.zh.md +17 -2
- package/lib/index.js +688 -153
- package/lib/typert.host.js +237 -26
- package/lib/typert.remote-client.d.ts +4 -0
- package/lib/typert.remote-client.js +215 -22
- package/lib/types/failure.d.ts +15 -3
- package/lib/types/failure.js +19 -2
- package/lib/types/github-connection.d.ts +21 -0
- package/lib/types/github-connection.js +61 -0
- package/lib/types/index.d.ts +28 -4
- package/lib/types/index.js +111 -21
- package/lib/types/install-failure.js +1 -0
- package/lib/types/operations.d.ts +15 -1
- package/lib/types/operations.js +341 -21
- package/lib/types/run-tree.d.ts +37 -0
- package/lib/types/run-tree.js +51 -0
- package/lib/types/tools.js +13 -3
- package/lib/types/types.d.ts +17 -3
- package/package.json +27 -27
package/lib/index.js
CHANGED
|
@@ -7,11 +7,200 @@ import { withFileLock, writeFileAtomic } from "@deepseek-ai/dsh-atomic-write";
|
|
|
7
7
|
import z from "@deepseek-ai/schemastery";
|
|
8
8
|
import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
9
9
|
import { pluginEntryId, readPluginInventory } from "@deepseek-ai/dsh-host-plugin-inventory";
|
|
10
|
-
import { OPTIONAL_BUNDLES, bundlePatchPaths, composeEntries, loadOptionalPatches, loadOverlayPatches, readPluginMeta, readProfileManifest, readProfilePatches, reconcileProfilePatches, resolveBundleDir, resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
|
|
10
|
+
import { OPTIONAL_BUNDLES, PROFILE_COMPATIBILITY_FILENAME, bundlePatchPaths, composeEntries, evaluatePluginCompatibility, loadOptionalPatches, loadOverlayPatches, pluginCompatibilityWarning, readPluginMeta, readProfileCompatibility, readProfileManifest, readProfilePatches, readProfileVersionExemptions, reconcileProfilePatches, resolveBundleDir, resolveProfileDir, setProfileVersionExemption } from "@deepseek-ai/dsh-app-boot";
|
|
11
|
+
import { once } from "node:events";
|
|
11
12
|
import { execa } from "execa";
|
|
12
13
|
import { scrubbedParentEnv } from "@deepseek-ai/dsh-subprocess";
|
|
13
14
|
import { isAlias, isMap, isNode, isScalar, isSeq, parseDocument, visit } from "yaml";
|
|
15
|
+
//#region lib/types/install-spec.js
|
|
16
|
+
/**
|
|
17
|
+
* Reading an install spec before pnpm sees it: which of pnpm's spec forms it
|
|
18
|
+
* takes, and for a registry name whether it is one the registry can accept.
|
|
19
|
+
* @module @deepseek-ai/dsh-plugin-manager/install-spec
|
|
20
|
+
*/
|
|
21
|
+
/** The forms pnpm resolves through a git host: a host shorthand, a git URL, or a hosted repository URL. */
|
|
22
|
+
const GIT_SHORTHAND = /^(?:github|gitlab|bitbucket|gist):/i;
|
|
23
|
+
const GIT_URL = /^git(?:\+[a-z]+)?:\/\/|^git@[^:]+:/i;
|
|
24
|
+
const HOSTED_REPOSITORY_URL = /^https?:\/\/[^/]+\/[^/]+\/[^/#]+(?:\.git)?(?:#.*)?$/i;
|
|
25
|
+
/** The hosts pnpm's shorthands stand for. */
|
|
26
|
+
const GIT_SHORTHAND_HOSTS = {
|
|
27
|
+
github: "github.com",
|
|
28
|
+
gitlab: "gitlab.com",
|
|
29
|
+
bitbucket: "bitbucket.org",
|
|
30
|
+
gist: "gist.github.com"
|
|
31
|
+
};
|
|
32
|
+
/** A tarball, on disk or over HTTP. */
|
|
33
|
+
const TARBALL_SPEC = /\.(?:tgz|tar\.gz)(?:#.*)?$/i;
|
|
34
|
+
/** An npm package name: lowercase URL-safe segments, an optional scope, no leading dot or underscore. */
|
|
35
|
+
const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;
|
|
36
|
+
const PACKAGE_NAME_MAX_LENGTH = 214;
|
|
37
|
+
/** A spec neither pnpm nor the registry would take; `reason` is what the person reads. */
|
|
38
|
+
var InvalidInstallSpecError = class extends Error {
|
|
39
|
+
spec;
|
|
40
|
+
reason;
|
|
41
|
+
/**
|
|
42
|
+
* @param spec - the spec as typed, trimmed.
|
|
43
|
+
* @param reason - why it is refused, as one sentence.
|
|
44
|
+
*/
|
|
45
|
+
constructor(spec, reason) {
|
|
46
|
+
super(`plugin-manager: ${reason}: ${spec}`);
|
|
47
|
+
this.spec = spec;
|
|
48
|
+
this.reason = reason;
|
|
49
|
+
this.name = "InvalidInstallSpecError";
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
function invalid(spec, reason) {
|
|
53
|
+
return new InvalidInstallSpecError(spec, reason);
|
|
54
|
+
}
|
|
55
|
+
/** 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. */
|
|
56
|
+
function gitHost(spec) {
|
|
57
|
+
const shorthand = /^([a-z]+):/i.exec(spec)?.[1]?.toLowerCase();
|
|
58
|
+
if (shorthand !== void 0 && Object.hasOwn(GIT_SHORTHAND_HOSTS, shorthand)) return GIT_SHORTHAND_HOSTS[shorthand];
|
|
59
|
+
const scp = /^git@([^:]+):/i.exec(spec)?.[1];
|
|
60
|
+
if (scp !== void 0) return scp.toLowerCase();
|
|
61
|
+
return new URL(spec.replace(/^git\+/i, "")).host;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Read a spec into its form. A path must be absolute: the Host's working
|
|
65
|
+
* directory means nothing to the person typing into a browser, and a
|
|
66
|
+
* relative path resolved against the profile would point inside it.
|
|
67
|
+
* @param raw - the spec as typed.
|
|
68
|
+
* @returns the parsed spec.
|
|
69
|
+
* @throws {InvalidInstallSpecError} for an empty spec, a relative path, a name the
|
|
70
|
+
* registry would refuse, or a URL that is neither a git host nor a tarball.
|
|
71
|
+
*/
|
|
72
|
+
function parseInstallSpec(raw) {
|
|
73
|
+
const spec = raw.trim();
|
|
74
|
+
if (spec === "") throw invalid(spec, "the package spec must not be empty");
|
|
75
|
+
const path = spec.replace(/^(?:file|link):/, "");
|
|
76
|
+
if (path !== spec || isAbsolute(path)) {
|
|
77
|
+
if (!isAbsolute(path)) throw invalid(spec, "a local path must be absolute");
|
|
78
|
+
return TARBALL_SPEC.test(path) ? {
|
|
79
|
+
kind: "tarball",
|
|
80
|
+
spec,
|
|
81
|
+
path
|
|
82
|
+
} : {
|
|
83
|
+
kind: "path",
|
|
84
|
+
spec,
|
|
85
|
+
path
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
if (/^\.{1,2}(?:[\\/]|$)/.test(spec)) throw invalid(spec, "a local path must be absolute");
|
|
89
|
+
if ((GIT_SHORTHAND.test(spec) || GIT_URL.test(spec) || HOSTED_REPOSITORY_URL.test(spec)) && !TARBALL_SPEC.test(spec)) return {
|
|
90
|
+
kind: "git",
|
|
91
|
+
spec,
|
|
92
|
+
host: gitHost(spec)
|
|
93
|
+
};
|
|
94
|
+
if (/^https?:\/\//i.test(spec)) {
|
|
95
|
+
if (TARBALL_SPEC.test(spec)) return {
|
|
96
|
+
kind: "tarball",
|
|
97
|
+
spec,
|
|
98
|
+
host: new URL(spec).host
|
|
99
|
+
};
|
|
100
|
+
throw invalid(spec, "a URL must point at a git repository or a tarball");
|
|
101
|
+
}
|
|
102
|
+
const at = spec.indexOf("@", 1);
|
|
103
|
+
const name = at === -1 ? spec : spec.slice(0, at);
|
|
104
|
+
const range = at === -1 ? void 0 : spec.slice(at + 1);
|
|
105
|
+
if (name.length > PACKAGE_NAME_MAX_LENGTH || !PACKAGE_NAME.test(name)) throw invalid(spec, "not a package name the registry accepts");
|
|
106
|
+
if (range === "") throw invalid(spec, "a version after @ must not be empty");
|
|
107
|
+
return range === void 0 ? {
|
|
108
|
+
kind: "registry",
|
|
109
|
+
spec,
|
|
110
|
+
name
|
|
111
|
+
} : {
|
|
112
|
+
kind: "registry",
|
|
113
|
+
spec,
|
|
114
|
+
name,
|
|
115
|
+
range
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
//#endregion
|
|
119
|
+
//#region lib/types/run-tree.js
|
|
120
|
+
/** Waiting for one package run's process tree to disappear before its caller touches the profile. */
|
|
121
|
+
/** How long a terminated tree may take to disappear before the caller stops waiting. */
|
|
122
|
+
const TREE_WAIT_MS = 5e3;
|
|
123
|
+
/** Poll cadence while waiting for a terminated tree to disappear. */
|
|
124
|
+
const TREE_POLL_MS = 15;
|
|
125
|
+
/**
|
|
126
|
+
* Whether one run leads its own process group, which is the target a POSIX
|
|
127
|
+
* liveness probe addresses for the whole tree. A run that captures output is
|
|
128
|
+
* spawned as its own group leader, because its tree is terminated as a unit; a
|
|
129
|
+
* run that inherits the caller's descriptors keeps the caller's group, so an
|
|
130
|
+
* interrupt still reaches it.
|
|
131
|
+
* @param execution Whether the run captures output or inherits the caller's descriptors.
|
|
132
|
+
* @param platform Host platform deciding how a tree is addressed.
|
|
133
|
+
* @returns True when the run leads its own process group.
|
|
134
|
+
*/
|
|
135
|
+
function leadsOwnGroup(execution, platform = process.platform) {
|
|
136
|
+
return execution === "service" && platform !== "win32";
|
|
137
|
+
}
|
|
138
|
+
/** The target a tree probe addresses: a POSIX group when the run leads one, else the process itself. */
|
|
139
|
+
function targetOf(pid, grouped, platform) {
|
|
140
|
+
return platform !== "win32" && grouped ? -pid : pid;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Wait until a terminated run's tree is gone, so the caller restores and
|
|
144
|
+
* unlocks the profile only after the scripts it started stopped writing.
|
|
145
|
+
* @param tree The run's process id and whether it leads its own group.
|
|
146
|
+
* @param internals Injectable process operations.
|
|
147
|
+
* @returns Fulfillment once no member remains, or the wait bound elapsed.
|
|
148
|
+
*/
|
|
149
|
+
async function awaitTreeGone(tree, internals = {}) {
|
|
150
|
+
const pid = tree.pid;
|
|
151
|
+
if (pid === void 0) return;
|
|
152
|
+
const platform = internals.platform ?? process.platform;
|
|
153
|
+
const alive = internals.alive ?? ((target) => {
|
|
154
|
+
process.kill(target, 0);
|
|
155
|
+
return true;
|
|
156
|
+
});
|
|
157
|
+
const remaining = () => {
|
|
158
|
+
try {
|
|
159
|
+
return alive(targetOf(pid, tree.grouped, platform));
|
|
160
|
+
} catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
const deadline = Date.now() + (internals.waitMs ?? TREE_WAIT_MS);
|
|
165
|
+
while (remaining()) {
|
|
166
|
+
if (Date.now() >= deadline) return;
|
|
167
|
+
await new Promise((resolve) => setTimeout(resolve, TREE_POLL_MS));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
//#endregion
|
|
171
|
+
//#region lib/types/failure.js
|
|
172
|
+
/** Expected management rejection; presentation belongs to the caller's locale. */
|
|
173
|
+
var ManagementFailure = class extends Error {
|
|
174
|
+
/** Code rendered by the caller's locale dictionary. */
|
|
175
|
+
code;
|
|
176
|
+
/** The packages an `incompatible-version` rejection names. */
|
|
177
|
+
incompatible;
|
|
178
|
+
/**
|
|
179
|
+
* @param code Localizable management rejection.
|
|
180
|
+
* @param incompatible Packages the running DSH version rejects, for `incompatible-version`.
|
|
181
|
+
*/
|
|
182
|
+
constructor(code, incompatible) {
|
|
183
|
+
super(code);
|
|
184
|
+
this.code = code;
|
|
185
|
+
this.incompatible = incompatible;
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
/**
|
|
189
|
+
* Drop the exemption status from an unexempted compatibility result.
|
|
190
|
+
* @param issue Result whose exemption is not active.
|
|
191
|
+
* @returns The package, runtime, and rejected peer ranges.
|
|
192
|
+
*/
|
|
193
|
+
function incompatiblePlugin(issue) {
|
|
194
|
+
return {
|
|
195
|
+
name: issue.name,
|
|
196
|
+
version: issue.version,
|
|
197
|
+
runtimeVersion: issue.runtimeVersion,
|
|
198
|
+
peers: issue.peers
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
//#endregion
|
|
14
202
|
//#region lib/types/operations.js
|
|
203
|
+
/** Shared profile package operations used by dsh plugin and the running manager. */
|
|
15
204
|
/** Resolve relative package specs against the caller's directory.
|
|
16
205
|
* @param argument One pnpm argument.
|
|
17
206
|
* @param cwd Invocation directory, never the profile directory.
|
|
@@ -69,15 +258,148 @@ async function reconcile(before, dir, anchor, options) {
|
|
|
69
258
|
};
|
|
70
259
|
await saveManifest(dir, after);
|
|
71
260
|
}
|
|
261
|
+
/**
|
|
262
|
+
* How long the pipes keep draining after their process exited, as a fixed part of
|
|
263
|
+
* finishing a run rather than a deployment knob: a descendant that inherited them
|
|
264
|
+
* holds them open, and the tail a failure classification reads is written by then.
|
|
265
|
+
*/
|
|
266
|
+
const DRAIN_AFTER_EXIT_MS = 2e3;
|
|
267
|
+
/** Whether every collector finished within `ms`.
|
|
268
|
+
* @param collectors The pipe readers racing the bound.
|
|
269
|
+
* @param ms The longest wait, in milliseconds.
|
|
270
|
+
* @returns True when all collectors settled in time.
|
|
271
|
+
*/
|
|
272
|
+
async function drainWithin(collectors, ms) {
|
|
273
|
+
if (collectors.length === 0) return true;
|
|
274
|
+
let timer;
|
|
275
|
+
try {
|
|
276
|
+
return await Promise.race([Promise.allSettled(collectors).then(() => true), new Promise((resolve) => {
|
|
277
|
+
timer = setTimeout(() => {
|
|
278
|
+
resolve(false);
|
|
279
|
+
}, ms);
|
|
280
|
+
})]);
|
|
281
|
+
} finally {
|
|
282
|
+
clearTimeout(timer);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/** Install commands that take the packages to install as positionals. */
|
|
286
|
+
const INSTALL_COMMANDS = new Set([
|
|
287
|
+
"add",
|
|
288
|
+
"install",
|
|
289
|
+
"i"
|
|
290
|
+
]);
|
|
291
|
+
/** Bound on a pre-install registry lookup when the caller names none. */
|
|
292
|
+
const LOOKUP_TIMEOUT_MS = 2e4;
|
|
293
|
+
/** Package specs an install command names explicitly, in order. */
|
|
294
|
+
function namedSpecs(args) {
|
|
295
|
+
const index = args.findIndex((argument) => !argument.startsWith("-"));
|
|
296
|
+
const command = index < 0 ? void 0 : args[index];
|
|
297
|
+
if (command === void 0 || !INSTALL_COMMANDS.has(command)) return [];
|
|
298
|
+
return args.slice(index + 1).filter((argument) => !argument.startsWith("-"));
|
|
299
|
+
}
|
|
300
|
+
/** The manifest a named spec would install, read without installing it.
|
|
301
|
+
* A path spec is read from disk. A registry spec asks pnpm's own configuration for the version the
|
|
302
|
+
* range selects and its peer requirements. A git or tarball spec needs the fetch itself, so the
|
|
303
|
+
* check after installation is what judges it.
|
|
304
|
+
* @param dir Profile directory the lookup runs in.
|
|
305
|
+
* @param spec Anchored install spec.
|
|
306
|
+
* @param options Pnpm executable, prefix arguments, the caller's bound and signal.
|
|
307
|
+
* @param environment Environment of the caller's pnpm invocations.
|
|
308
|
+
* @param flags Flags of the run itself, so the lookup asks the registry that run will use.
|
|
309
|
+
* @returns The package manifest, or undefined when reading it would need the installation itself.
|
|
310
|
+
*/
|
|
311
|
+
async function namedSpecManifest(dir, spec, options, environment, flags) {
|
|
312
|
+
const parsed = parseInstallSpec(spec);
|
|
313
|
+
if (parsed.kind === "path") {
|
|
314
|
+
const filename = join(parsed.path, "package.json");
|
|
315
|
+
return existsSync(filename) ? JSON.parse(readFileSync(filename, "utf8")) : void 0;
|
|
316
|
+
}
|
|
317
|
+
if (parsed.kind !== "registry") return void 0;
|
|
318
|
+
const viewed = await execa(options.command ?? "pnpm", [
|
|
319
|
+
...options.args ?? [],
|
|
320
|
+
"view",
|
|
321
|
+
parsed.spec,
|
|
322
|
+
"name",
|
|
323
|
+
"version",
|
|
324
|
+
"peerDependencies",
|
|
325
|
+
"--json",
|
|
326
|
+
...flags,
|
|
327
|
+
"--config.fetch-retries=0"
|
|
328
|
+
], {
|
|
329
|
+
cwd: dir,
|
|
330
|
+
env: environment,
|
|
331
|
+
extendEnv: false,
|
|
332
|
+
reject: false,
|
|
333
|
+
stdin: "ignore",
|
|
334
|
+
...options.signal === void 0 ? {} : { cancelSignal: options.signal },
|
|
335
|
+
timeout: options.lookupTimeoutMs ?? LOOKUP_TIMEOUT_MS
|
|
336
|
+
});
|
|
337
|
+
if (viewed.exitCode !== 0) return void 0;
|
|
338
|
+
const value = JSON.parse(viewed.stdout);
|
|
339
|
+
return Array.isArray(value) ? value.at(-1) : value;
|
|
340
|
+
}
|
|
341
|
+
/** Missing installed packages are repairable; their absence is part of the before/after comparison. */
|
|
342
|
+
function optionalFile(path) {
|
|
343
|
+
try {
|
|
344
|
+
return readFileSync(path, "utf8");
|
|
345
|
+
} catch (error) {
|
|
346
|
+
if (error.code === "ENOENT") return void 0;
|
|
347
|
+
throw error;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
/** pnpm can install plugins through any direct-dependency field. */
|
|
351
|
+
function directDependencies(manifest) {
|
|
352
|
+
const extra = manifest;
|
|
353
|
+
return {
|
|
354
|
+
...extra.devDependencies,
|
|
355
|
+
...manifest.dependencies,
|
|
356
|
+
...extra.optionalDependencies
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
/** Inspect only plugin rows contributed by the changed bundle, not its dependency closure. */
|
|
360
|
+
function bundleComponentManifests(manifest, dir, anchor) {
|
|
361
|
+
const bundle = manifest.dsh?.bundle;
|
|
362
|
+
if (bundle === void 0) return [];
|
|
363
|
+
const patches = bundlePatchPaths(dir, bundle).flatMap((file) => loadOverlayPatches("dsh", file));
|
|
364
|
+
const names = /* @__PURE__ */ new Set();
|
|
365
|
+
const visit = (rows) => {
|
|
366
|
+
for (const row of rows) {
|
|
367
|
+
if (row.group && Array.isArray(row.config)) visit(row.config);
|
|
368
|
+
if (typeof row.name !== "string" || row.name.startsWith(".") || row.name.startsWith("/") || row.name.includes(":")) continue;
|
|
369
|
+
const parts = row.name.split("/");
|
|
370
|
+
names.add(parts.slice(0, row.name.startsWith("@") ? 2 : 1).join("/"));
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
visit(composeEntries([patches.filter((patch) => patch.insert !== void 0)]));
|
|
374
|
+
return [...names].flatMap((name) => {
|
|
375
|
+
let packageDir;
|
|
376
|
+
try {
|
|
377
|
+
packageDir = resolveBundleDir("dsh", name, anchor, dir);
|
|
378
|
+
} catch (error) {
|
|
379
|
+
return [];
|
|
380
|
+
}
|
|
381
|
+
return [readProfileManifest("dsh", packageDir)];
|
|
382
|
+
});
|
|
383
|
+
}
|
|
72
384
|
/** Execute pnpm inside a profile whose caller already holds the profile write lock.
|
|
385
|
+
* Newly installed or updated direct dependencies are checked even when activation is disabled;
|
|
386
|
+
* an untouched dependency never blocks an unrelated operation and stays denied at startup.
|
|
387
|
+
* Compatibility denial restores the profile manifest and lockfile, but leaves downloaded modules on disk.
|
|
73
388
|
* @param context Launcher-owned profile and resolution locations.
|
|
74
389
|
* @param args Pnpm arguments, before relative path anchoring.
|
|
75
390
|
* @param options Output, activation and cancellation policy.
|
|
76
|
-
* @returns Exit status
|
|
391
|
+
* @returns Exit status, whether the silence bound stopped the run, and the diagnostic path.
|
|
392
|
+
* A compatibility denial returns exit code 1. Service output is bounded; CLI output uses inherited descriptors.
|
|
77
393
|
*/
|
|
78
394
|
async function runProfilePnpm(context, args, options) {
|
|
79
395
|
const dir = context.dir ?? resolveProfileDir(context.profile, context.home);
|
|
80
396
|
const before = readProfileManifest("dsh", dir);
|
|
397
|
+
const savedFiles = ["package.json", "pnpm-lock.yaml"].map((name) => ({
|
|
398
|
+
path: join(dir, name),
|
|
399
|
+
text: optionalFile(join(dir, name))
|
|
400
|
+
}));
|
|
401
|
+
const beforeDependencies = directDependencies(before);
|
|
402
|
+
const installedBefore = new Map(Object.keys(beforeDependencies).map((name) => [name, optionalFile(join(dir, "node_modules", name, "package.json"))]));
|
|
81
403
|
const logRoot = join(dir, ".plugin-manager", "logs");
|
|
82
404
|
await mkdir(logRoot, {
|
|
83
405
|
recursive: true,
|
|
@@ -87,68 +409,210 @@ async function runProfilePnpm(context, args, options) {
|
|
|
87
409
|
const log = await open(logPath, "wx", 384);
|
|
88
410
|
let output = Buffer.alloc(0);
|
|
89
411
|
let truncated = false;
|
|
412
|
+
const append = (bytes) => {
|
|
413
|
+
output = Buffer.concat([output, bytes]);
|
|
414
|
+
if (output.length > options.outputBytes) {
|
|
415
|
+
truncated = true;
|
|
416
|
+
output = output.subarray(output.length - options.outputBytes);
|
|
417
|
+
}
|
|
418
|
+
};
|
|
419
|
+
const environment = {
|
|
420
|
+
...options.execution === "cli" ? process.env : scrubbedParentEnv(),
|
|
421
|
+
...options.env
|
|
422
|
+
};
|
|
423
|
+
const restore = async () => {
|
|
424
|
+
for (const file of savedFiles) if (file.text === void 0) await rm(file.path, { force: true });
|
|
425
|
+
else await writeFileAtomic(file.path, file.text, { mode: 384 });
|
|
426
|
+
};
|
|
427
|
+
/** Packages a compatibility check refused; callers render them for their own surface. */
|
|
428
|
+
const incompatible = [];
|
|
429
|
+
const rejected = async (warnings, restoration) => {
|
|
430
|
+
const diagnostic = `\ndsh: installation rejected: ${warnings.join("\n")}\ndsh: ${restoration}.\n`;
|
|
431
|
+
await log.write(diagnostic);
|
|
432
|
+
options.onOutput?.(diagnostic, "stderr");
|
|
433
|
+
append(Buffer.from(diagnostic));
|
|
434
|
+
await log.close();
|
|
435
|
+
return {
|
|
436
|
+
exitCode: 1,
|
|
437
|
+
output: output.toString("utf8"),
|
|
438
|
+
truncated,
|
|
439
|
+
logPath,
|
|
440
|
+
incompatible
|
|
441
|
+
};
|
|
442
|
+
};
|
|
443
|
+
const preflight = [];
|
|
444
|
+
const exemptions = readProfileVersionExemptions(dir);
|
|
445
|
+
const registryFlags = args.filter((argument) => argument.startsWith("--registry="));
|
|
446
|
+
for (const raw of namedSpecs(args)) try {
|
|
447
|
+
const manifest = await namedSpecManifest(dir, anchorPathSpec(raw, context.cwd), options, environment, registryFlags);
|
|
448
|
+
if (manifest === void 0) continue;
|
|
449
|
+
const issue = evaluatePluginCompatibility(manifest, exemptions);
|
|
450
|
+
if (issue !== void 0 && !issue.exempted) {
|
|
451
|
+
preflight.push(pluginCompatibilityWarning(issue));
|
|
452
|
+
incompatible.push(incompatiblePlugin(issue));
|
|
453
|
+
}
|
|
454
|
+
} catch (error) {
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
if (preflight.length > 0) return rejected(preflight, "nothing was installed");
|
|
90
458
|
const cancellation = new AbortController();
|
|
459
|
+
const grouped = leadsOwnGroup(options.execution);
|
|
91
460
|
const child = execa(options.command ?? "pnpm", [...options.args ?? [], ...args.map((arg) => anchorPathSpec(arg, context.cwd))], {
|
|
92
461
|
cwd: dir,
|
|
93
|
-
env:
|
|
94
|
-
...options.execution === "cli" ? process.env : scrubbedParentEnv(),
|
|
95
|
-
...options.env
|
|
96
|
-
},
|
|
462
|
+
env: environment,
|
|
97
463
|
extendEnv: false,
|
|
98
464
|
reject: false,
|
|
99
465
|
stdout: options.execution === "cli" ? "inherit" : "pipe",
|
|
100
466
|
stderr: options.execution === "cli" ? "inherit" : "pipe",
|
|
467
|
+
killDescendants: options.execution === "service",
|
|
101
468
|
buffer: false,
|
|
102
469
|
stdin: options.execution === "cli" ? "inherit" : "ignore",
|
|
103
470
|
cancelSignal: options.signal === void 0 ? cancellation.signal : AbortSignal.any([cancellation.signal, options.signal])
|
|
104
471
|
});
|
|
105
472
|
let writes = Promise.resolve();
|
|
473
|
+
/** `settled` records that the process outcome is known; `stalled` that the silence bound stopped the run. */
|
|
474
|
+
const control = {
|
|
475
|
+
settled: false,
|
|
476
|
+
stalled: false
|
|
477
|
+
};
|
|
478
|
+
/** Set once this call cuts the reading short itself, so the close it causes is not read as a run failure. */
|
|
479
|
+
let cut = false;
|
|
480
|
+
/** The first failure a reading hit before that cut, which the run still reports. */
|
|
481
|
+
let failure;
|
|
482
|
+
let idleTimer;
|
|
483
|
+
/** The silence bound: a captured run that stops printing without exiting is terminated, never awaited. */
|
|
484
|
+
const armIdle = () => {
|
|
485
|
+
if (control.settled || options.idleTimeoutMs === void 0) return;
|
|
486
|
+
clearTimeout(idleTimer);
|
|
487
|
+
idleTimer = setTimeout(() => {
|
|
488
|
+
control.stalled = true;
|
|
489
|
+
child.kill();
|
|
490
|
+
}, options.idleTimeoutMs);
|
|
491
|
+
};
|
|
106
492
|
const collect = async (stream, kind) => {
|
|
107
493
|
try {
|
|
108
494
|
for await (const chunk of stream) {
|
|
495
|
+
armIdle();
|
|
109
496
|
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
110
497
|
writes = writes.then(async () => {
|
|
111
498
|
await log.write(bytes);
|
|
112
499
|
});
|
|
113
500
|
await writes;
|
|
114
501
|
options.onOutput?.(bytes.toString("utf8"), kind);
|
|
115
|
-
|
|
116
|
-
if (output.length > options.outputBytes) {
|
|
117
|
-
truncated = true;
|
|
118
|
-
output = output.subarray(output.length - options.outputBytes);
|
|
119
|
-
}
|
|
502
|
+
append(bytes);
|
|
120
503
|
}
|
|
121
504
|
} catch (error) {
|
|
122
|
-
|
|
505
|
+
if (!cut) {
|
|
506
|
+
failure ??= error instanceof Error ? error : new Error(String(error));
|
|
507
|
+
cancellation.abort();
|
|
508
|
+
}
|
|
123
509
|
throw error;
|
|
124
510
|
}
|
|
125
511
|
};
|
|
512
|
+
const collectors = [...child.stdout === null ? [] : [collect(child.stdout, "stdout")], ...child.stderr === null ? [] : [collect(child.stderr, "stderr")]];
|
|
513
|
+
for (const collector of collectors) collector.catch(() => {});
|
|
514
|
+
if (collectors.length > 0) armIdle();
|
|
126
515
|
let exitCode;
|
|
127
516
|
try {
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
517
|
+
const settled = Promise.allSettled([child]);
|
|
518
|
+
await Promise.race([once(child.nodeChildProcess, "exit").catch(() => void 0), settled]);
|
|
519
|
+
control.settled = true;
|
|
520
|
+
clearTimeout(idleTimer);
|
|
521
|
+
if (control.stalled) await awaitTreeGone({
|
|
522
|
+
pid: child.pid,
|
|
523
|
+
grouped
|
|
524
|
+
});
|
|
525
|
+
if (await drainWithin(collectors, DRAIN_AFTER_EXIT_MS)) {
|
|
526
|
+
for (const stream of await Promise.allSettled(collectors)) if (stream.status === "rejected") throw stream.reason;
|
|
527
|
+
} else {
|
|
528
|
+
cut = true;
|
|
529
|
+
child.stdout?.destroy();
|
|
530
|
+
child.stderr?.destroy();
|
|
531
|
+
const notice = "dsh: pnpm output was cut short after its process exited\n";
|
|
532
|
+
await log.write(notice);
|
|
533
|
+
if (failure !== void 0) throw failure;
|
|
534
|
+
options.onOutput?.(notice, "stderr");
|
|
535
|
+
append(Buffer.from(notice));
|
|
536
|
+
}
|
|
537
|
+
const [completion] = await settled;
|
|
134
538
|
if (completion.status === "rejected") throw completion.reason;
|
|
135
539
|
const result = completion.value;
|
|
136
540
|
exitCode = result.exitCode ?? (result.code === "ENOENT" ? 127 : 1);
|
|
541
|
+
if (control.stalled) {
|
|
542
|
+
const notice = `dsh: pnpm printed nothing for ${String(options.idleTimeoutMs)}ms and was terminated\n`;
|
|
543
|
+
await log.write(notice);
|
|
544
|
+
options.onOutput?.(notice, "stderr");
|
|
545
|
+
append(Buffer.from(notice));
|
|
546
|
+
}
|
|
137
547
|
if (result.failed && output.length === 0) {
|
|
138
548
|
const diagnostic = result.shortMessage ?? "pnpm failed";
|
|
139
549
|
await log.write(diagnostic);
|
|
140
550
|
truncated = Buffer.byteLength(diagnostic) > options.outputBytes;
|
|
141
551
|
output = Buffer.from(diagnostic).subarray(0, options.outputBytes);
|
|
142
552
|
}
|
|
143
|
-
if (exitCode === 0 &&
|
|
553
|
+
if (exitCode === 0 && !control.stalled) {
|
|
554
|
+
const after = readProfileManifest("dsh", dir);
|
|
555
|
+
const warnings = [];
|
|
556
|
+
for (const [name, spec] of Object.entries(directDependencies(after))) {
|
|
557
|
+
const packageDir = join(dir, "node_modules", name);
|
|
558
|
+
const installed = optionalFile(join(packageDir, "package.json"));
|
|
559
|
+
if (installed === void 0) continue;
|
|
560
|
+
const untouched = beforeDependencies[name] === spec && installedBefore.get(name) === installed;
|
|
561
|
+
const found = [];
|
|
562
|
+
const issues = [];
|
|
563
|
+
try {
|
|
564
|
+
const manifest = readProfileManifest("dsh", packageDir);
|
|
565
|
+
for (const candidate of [manifest, ...bundleComponentManifests(manifest, packageDir, context.installAnchor)]) {
|
|
566
|
+
const issue = evaluatePluginCompatibility(candidate, readProfileVersionExemptions(dir));
|
|
567
|
+
if (issue !== void 0 && !issue.exempted) {
|
|
568
|
+
found.push(pluginCompatibilityWarning(issue));
|
|
569
|
+
issues.push(incompatiblePlugin(issue));
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
} catch (error) {
|
|
573
|
+
found.push(`Cannot validate installed package ${name}: ${String(error)}`);
|
|
574
|
+
}
|
|
575
|
+
if (found.length === 0) continue;
|
|
576
|
+
if (!untouched) {
|
|
577
|
+
warnings.push(...found);
|
|
578
|
+
incompatible.push(...issues);
|
|
579
|
+
} else {
|
|
580
|
+
const notice = `\ndsh: warning: ${found.join("\n")}\ndsh: it stays installed but profile startup denies it until you grant an exemption for those exact versions.\n`;
|
|
581
|
+
await log.write(notice);
|
|
582
|
+
options.onOutput?.(notice, "stderr");
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
if (warnings.length > 0) {
|
|
586
|
+
await restore();
|
|
587
|
+
const repair = ["install", savedFiles.some((file) => file.path.endsWith("pnpm-lock.yaml") && file.text !== void 0) ? "--frozen-lockfile" : "--config.lockfile=false"];
|
|
588
|
+
const repaired = await execa(options.command ?? "pnpm", [...options.args ?? [], ...repair], {
|
|
589
|
+
cwd: dir,
|
|
590
|
+
env: environment,
|
|
591
|
+
extendEnv: false,
|
|
592
|
+
reject: false,
|
|
593
|
+
stdin: "ignore",
|
|
594
|
+
...options.idleTimeoutMs === void 0 ? {} : { timeout: options.idleTimeoutMs }
|
|
595
|
+
});
|
|
596
|
+
exitCode = 1;
|
|
597
|
+
const restoration = repaired.exitCode === 0 ? "restored package.json, pnpm-lock.yaml, and node_modules" : "restored package.json and pnpm-lock.yaml, but node_modules could not be reinstalled; run 'dsh plugin install'";
|
|
598
|
+
const diagnostic = `\ndsh: installation rejected: ${warnings.join("\n")}\ndsh: ${restoration}.\n`;
|
|
599
|
+
await log.write(diagnostic);
|
|
600
|
+
options.onOutput?.(diagnostic, "stderr");
|
|
601
|
+
append(Buffer.from(diagnostic));
|
|
602
|
+
} else if (options.activateNewBundles !== false) await reconcile(before, dir, context.installAnchor, options);
|
|
603
|
+
}
|
|
144
604
|
} finally {
|
|
605
|
+
control.settled = true;
|
|
606
|
+
clearTimeout(idleTimer);
|
|
145
607
|
await log.close();
|
|
146
608
|
}
|
|
147
609
|
return {
|
|
148
610
|
exitCode,
|
|
149
611
|
output: output.toString("utf8"),
|
|
150
612
|
truncated,
|
|
151
|
-
logPath
|
|
613
|
+
logPath,
|
|
614
|
+
...control.stalled ? { timedOut: true } : {},
|
|
615
|
+
...incompatible.length > 0 ? { incompatible } : {}
|
|
152
616
|
};
|
|
153
617
|
}
|
|
154
618
|
/**
|
|
@@ -246,6 +710,7 @@ const LOG_KINDS = [
|
|
|
246
710
|
["disk-full", /\bENOSPC\b|no space left on device/i],
|
|
247
711
|
["permission", /\bEACCES\b|\bEPERM\b|permission denied/i],
|
|
248
712
|
["integrity", /ERR_PNPM_TARBALL_INTEGRITY|ERR_PNPM_BAD_TARBALL_SIZE|\bEINTEGRITY\b/],
|
|
713
|
+
["unknown", /The requested URL returned error: 40[134]\b/],
|
|
249
714
|
["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/]
|
|
250
715
|
];
|
|
251
716
|
/**
|
|
@@ -259,110 +724,6 @@ function classifyInstallFailure(facts) {
|
|
|
259
724
|
for (const [kind, pattern] of LOG_KINDS) if (pattern.test(facts.log)) return kind;
|
|
260
725
|
return "unknown";
|
|
261
726
|
}
|
|
262
|
-
//#endregion
|
|
263
|
-
//#region lib/types/install-spec.js
|
|
264
|
-
/**
|
|
265
|
-
* Reading an install spec before pnpm sees it: which of pnpm's spec forms it
|
|
266
|
-
* takes, and for a registry name whether it is one the registry can accept.
|
|
267
|
-
* @module @deepseek-ai/dsh-plugin-manager/install-spec
|
|
268
|
-
*/
|
|
269
|
-
/** The forms pnpm resolves through a git host: a host shorthand, a git URL, or a hosted repository URL. */
|
|
270
|
-
const GIT_SHORTHAND = /^(?:github|gitlab|bitbucket|gist):/i;
|
|
271
|
-
const GIT_URL = /^git(?:\+[a-z]+)?:\/\/|^git@[^:]+:/i;
|
|
272
|
-
const HOSTED_REPOSITORY_URL = /^https?:\/\/[^/]+\/[^/]+\/[^/#]+(?:\.git)?(?:#.*)?$/i;
|
|
273
|
-
/** The hosts pnpm's shorthands stand for. */
|
|
274
|
-
const GIT_SHORTHAND_HOSTS = {
|
|
275
|
-
github: "github.com",
|
|
276
|
-
gitlab: "gitlab.com",
|
|
277
|
-
bitbucket: "bitbucket.org",
|
|
278
|
-
gist: "gist.github.com"
|
|
279
|
-
};
|
|
280
|
-
/** A tarball, on disk or over HTTP. */
|
|
281
|
-
const TARBALL_SPEC = /\.(?:tgz|tar\.gz)(?:#.*)?$/i;
|
|
282
|
-
/** An npm package name: lowercase URL-safe segments, an optional scope, no leading dot or underscore. */
|
|
283
|
-
const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;
|
|
284
|
-
const PACKAGE_NAME_MAX_LENGTH = 214;
|
|
285
|
-
/** A spec neither pnpm nor the registry would take; `reason` is what the person reads. */
|
|
286
|
-
var InvalidInstallSpecError = class extends Error {
|
|
287
|
-
spec;
|
|
288
|
-
reason;
|
|
289
|
-
/**
|
|
290
|
-
* @param spec - the spec as typed, trimmed.
|
|
291
|
-
* @param reason - why it is refused, as one sentence.
|
|
292
|
-
*/
|
|
293
|
-
constructor(spec, reason) {
|
|
294
|
-
super(`plugin-manager: ${reason}: ${spec}`);
|
|
295
|
-
this.spec = spec;
|
|
296
|
-
this.reason = reason;
|
|
297
|
-
this.name = "InvalidInstallSpecError";
|
|
298
|
-
}
|
|
299
|
-
};
|
|
300
|
-
function invalid(spec, reason) {
|
|
301
|
-
return new InvalidInstallSpecError(spec, reason);
|
|
302
|
-
}
|
|
303
|
-
/** 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. */
|
|
304
|
-
function gitHost(spec) {
|
|
305
|
-
const shorthand = /^([a-z]+):/i.exec(spec)?.[1]?.toLowerCase();
|
|
306
|
-
if (shorthand !== void 0 && Object.hasOwn(GIT_SHORTHAND_HOSTS, shorthand)) return GIT_SHORTHAND_HOSTS[shorthand];
|
|
307
|
-
const scp = /^git@([^:]+):/i.exec(spec)?.[1];
|
|
308
|
-
if (scp !== void 0) return scp.toLowerCase();
|
|
309
|
-
return new URL(spec.replace(/^git\+/i, "")).host;
|
|
310
|
-
}
|
|
311
|
-
/**
|
|
312
|
-
* Read a spec into its form. A path must be absolute: the Host's working
|
|
313
|
-
* directory means nothing to the person typing into a browser, and a
|
|
314
|
-
* relative path resolved against the profile would point inside it.
|
|
315
|
-
* @param raw - the spec as typed.
|
|
316
|
-
* @returns the parsed spec.
|
|
317
|
-
* @throws {InvalidInstallSpecError} for an empty spec, a relative path, a name the
|
|
318
|
-
* registry would refuse, or a URL that is neither a git host nor a tarball.
|
|
319
|
-
*/
|
|
320
|
-
function parseInstallSpec(raw) {
|
|
321
|
-
const spec = raw.trim();
|
|
322
|
-
if (spec === "") throw invalid(spec, "the package spec must not be empty");
|
|
323
|
-
const path = spec.replace(/^(?:file|link):/, "");
|
|
324
|
-
if (path !== spec || isAbsolute(path)) {
|
|
325
|
-
if (!isAbsolute(path)) throw invalid(spec, "a local path must be absolute");
|
|
326
|
-
return TARBALL_SPEC.test(path) ? {
|
|
327
|
-
kind: "tarball",
|
|
328
|
-
spec,
|
|
329
|
-
path
|
|
330
|
-
} : {
|
|
331
|
-
kind: "path",
|
|
332
|
-
spec,
|
|
333
|
-
path
|
|
334
|
-
};
|
|
335
|
-
}
|
|
336
|
-
if (/^\.{1,2}(?:[\\/]|$)/.test(spec)) throw invalid(spec, "a local path must be absolute");
|
|
337
|
-
if ((GIT_SHORTHAND.test(spec) || GIT_URL.test(spec) || HOSTED_REPOSITORY_URL.test(spec)) && !TARBALL_SPEC.test(spec)) return {
|
|
338
|
-
kind: "git",
|
|
339
|
-
spec,
|
|
340
|
-
host: gitHost(spec)
|
|
341
|
-
};
|
|
342
|
-
if (/^https?:\/\//i.test(spec)) {
|
|
343
|
-
if (TARBALL_SPEC.test(spec)) return {
|
|
344
|
-
kind: "tarball",
|
|
345
|
-
spec,
|
|
346
|
-
host: new URL(spec).host
|
|
347
|
-
};
|
|
348
|
-
throw invalid(spec, "a URL must point at a git repository or a tarball");
|
|
349
|
-
}
|
|
350
|
-
const at = spec.indexOf("@", 1);
|
|
351
|
-
const name = at === -1 ? spec : spec.slice(0, at);
|
|
352
|
-
const range = at === -1 ? void 0 : spec.slice(at + 1);
|
|
353
|
-
if (name.length > PACKAGE_NAME_MAX_LENGTH || !PACKAGE_NAME.test(name)) throw invalid(spec, "not a package name the registry accepts");
|
|
354
|
-
if (range === "") throw invalid(spec, "a version after @ must not be empty");
|
|
355
|
-
return range === void 0 ? {
|
|
356
|
-
kind: "registry",
|
|
357
|
-
spec,
|
|
358
|
-
name
|
|
359
|
-
} : {
|
|
360
|
-
kind: "registry",
|
|
361
|
-
spec,
|
|
362
|
-
name,
|
|
363
|
-
range
|
|
364
|
-
};
|
|
365
|
-
}
|
|
366
727
|
/** Public npmmirror URL shared by the fallback configuration and public-registry comparison. */
|
|
367
728
|
const NPMMIRROR_REGISTRY = "https://registry.npmmirror.com/";
|
|
368
729
|
/**
|
|
@@ -482,18 +843,6 @@ async function writePluginEnabled(filename, id, name, enabled) {
|
|
|
482
843
|
return true;
|
|
483
844
|
}
|
|
484
845
|
//#endregion
|
|
485
|
-
//#region lib/types/failure.js
|
|
486
|
-
/** Expected management rejection; presentation belongs to the caller's locale. */
|
|
487
|
-
var ManagementFailure = class extends Error {
|
|
488
|
-
/** Code rendered by the caller's locale dictionary. */
|
|
489
|
-
code;
|
|
490
|
-
/** @param code Localizable management rejection. */
|
|
491
|
-
constructor(code) {
|
|
492
|
-
super(code);
|
|
493
|
-
this.code = code;
|
|
494
|
-
}
|
|
495
|
-
};
|
|
496
|
-
//#endregion
|
|
497
846
|
//#region lib/types/build-approval.js
|
|
498
847
|
/** Approve pnpm's pending dependency scripts in the current profile's workspace settings. */
|
|
499
848
|
async function readPolicy(dir) {
|
|
@@ -537,6 +886,84 @@ async function approveBuilds(dir, names) {
|
|
|
537
886
|
await writeFileAtomic(join(dir, "pnpm-workspace.yaml"), String(document), { mode: 384 });
|
|
538
887
|
}
|
|
539
888
|
//#endregion
|
|
889
|
+
//#region lib/types/github-connection.js
|
|
890
|
+
/** Bounded GitHub repository checks using the installer's Git configuration and environment. */
|
|
891
|
+
/**
|
|
892
|
+
* Check a GitHub repository before pnpm starts, without downloading or building its package.
|
|
893
|
+
* Git reads the profile's Git and proxy configuration without invoking credential helpers or prompting.
|
|
894
|
+
* Timeout and cancellation terminate its descendants too; pnpm owns authentication and transport fallback.
|
|
895
|
+
* @param spec The parsed installation address; npm packages, local paths and other hosts are not checked.
|
|
896
|
+
* @param dir The profile directory where installation runs.
|
|
897
|
+
* @param options The connection deadline, output bound and operation cancellation.
|
|
898
|
+
* @returns A failed check with bounded output and a complete log at logPath, or undefined for a reachable repository or an unhandled spec.
|
|
899
|
+
*/
|
|
900
|
+
async function checkGithubConnection(spec, dir, options) {
|
|
901
|
+
if (spec.kind !== "git") return void 0;
|
|
902
|
+
const host = spec.host.toLowerCase().replace(/:.*$/, "");
|
|
903
|
+
if (host !== "github.com" && !host.endsWith(".github.com")) return void 0;
|
|
904
|
+
const repository = spec.spec.replace(/#.*$/s, "").replace(/^github:/i, "https://github.com/").replace(/^gist:/i, "https://gist.github.com/").replace(/^git\+/i, "");
|
|
905
|
+
const logRoot = join(dir, ".plugin-manager", "logs");
|
|
906
|
+
await mkdir(logRoot, {
|
|
907
|
+
recursive: true,
|
|
908
|
+
mode: 448
|
|
909
|
+
});
|
|
910
|
+
const logPath = join(await mkdtemp(join(logRoot, "github-connection-")), "git.log");
|
|
911
|
+
const log = await open(logPath, "ax+", 384);
|
|
912
|
+
try {
|
|
913
|
+
const result = await execa("git", [
|
|
914
|
+
"-c",
|
|
915
|
+
"credential.helper=",
|
|
916
|
+
"ls-remote",
|
|
917
|
+
"--",
|
|
918
|
+
repository,
|
|
919
|
+
"HEAD"
|
|
920
|
+
], {
|
|
921
|
+
cwd: dir,
|
|
922
|
+
env: {
|
|
923
|
+
...scrubbedParentEnv(),
|
|
924
|
+
...options.env,
|
|
925
|
+
LC_ALL: "C",
|
|
926
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
927
|
+
GIT_ASKPASS: "",
|
|
928
|
+
SSH_ASKPASS: "",
|
|
929
|
+
SSH_ASKPASS_REQUIRE: "never"
|
|
930
|
+
},
|
|
931
|
+
extendEnv: false,
|
|
932
|
+
stdin: "ignore",
|
|
933
|
+
stdout: "ignore",
|
|
934
|
+
stderr: {
|
|
935
|
+
file: logPath,
|
|
936
|
+
append: true
|
|
937
|
+
},
|
|
938
|
+
buffer: false,
|
|
939
|
+
reject: false,
|
|
940
|
+
timeout: options.timeoutMs,
|
|
941
|
+
cancelSignal: options.signal,
|
|
942
|
+
killDescendants: true,
|
|
943
|
+
killSignal: "SIGKILL"
|
|
944
|
+
});
|
|
945
|
+
if (!result.failed) return void 0;
|
|
946
|
+
if (result.timedOut) await log.write(`dsh: connection to ${spec.host} timed out after ${String(options.timeoutMs)}ms\n`);
|
|
947
|
+
if ((await log.stat()).size === 0) await log.write(result.shortMessage ?? "GitHub connection check failed");
|
|
948
|
+
const { size } = await log.stat();
|
|
949
|
+
const bytes = Buffer.alloc(Math.min(size, options.outputBytes));
|
|
950
|
+
await log.read(bytes, 0, bytes.length, size - bytes.length);
|
|
951
|
+
const output = bytes.toString("utf8");
|
|
952
|
+
return {
|
|
953
|
+
exitCode: result.exitCode ?? (result.code === "ENOENT" ? 127 : 1),
|
|
954
|
+
output,
|
|
955
|
+
truncated: size > options.outputBytes,
|
|
956
|
+
logPath,
|
|
957
|
+
kind: classifyInstallFailure({
|
|
958
|
+
log: output,
|
|
959
|
+
timedOut: result.timedOut
|
|
960
|
+
})
|
|
961
|
+
};
|
|
962
|
+
} finally {
|
|
963
|
+
await log.close();
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
//#endregion
|
|
540
967
|
//#region lib/types/index.js
|
|
541
968
|
/** Current-profile plugin and bundle management over shared dsh plugin operations. */
|
|
542
969
|
var __runInitializers = function(thisArg, initializers, value) {
|
|
@@ -611,10 +1038,14 @@ function messageOf(error) {
|
|
|
611
1038
|
}
|
|
612
1039
|
/** An expected refusal keeps its code; anything else becomes an operation error carrying its exact diagnostic. */
|
|
613
1040
|
function managementError(error) {
|
|
614
|
-
|
|
1041
|
+
if (!(error instanceof ManagementFailure)) return {
|
|
615
1042
|
code: "operation-error",
|
|
616
1043
|
diagnostic: messageOf(error)
|
|
617
1044
|
};
|
|
1045
|
+
return {
|
|
1046
|
+
code: error.code,
|
|
1047
|
+
...error.incompatible === void 0 ? {} : { incompatible: error.incompatible }
|
|
1048
|
+
};
|
|
618
1049
|
}
|
|
619
1050
|
/** The caller stopped an installation; its files are restored before this is thrown. */
|
|
620
1051
|
var InstallCancelledError = class extends Error {
|
|
@@ -684,6 +1115,8 @@ function parsedForRegistry(spec) {
|
|
|
684
1115
|
let PluginManager = (() => {
|
|
685
1116
|
let _classSuper = TypertRemoteService;
|
|
686
1117
|
let _instanceExtraInitializers = [];
|
|
1118
|
+
let _listVersionExemptions_decorators;
|
|
1119
|
+
let _setVersionExemption_decorators;
|
|
687
1120
|
let _listPlugins_decorators;
|
|
688
1121
|
let _listBundles_decorators;
|
|
689
1122
|
let _registries_decorators;
|
|
@@ -697,6 +1130,8 @@ let PluginManager = (() => {
|
|
|
697
1130
|
return class PluginManager extends _classSuper {
|
|
698
1131
|
static {
|
|
699
1132
|
const _metadata = typeof Symbol === "function" && Symbol.metadata ? Object.create(_classSuper[Symbol.metadata] ?? null) : void 0;
|
|
1133
|
+
_listVersionExemptions_decorators = [Remote];
|
|
1134
|
+
_setVersionExemption_decorators = [Remote];
|
|
700
1135
|
_listPlugins_decorators = [Remote];
|
|
701
1136
|
_listBundles_decorators = [Remote];
|
|
702
1137
|
_registries_decorators = [Remote];
|
|
@@ -707,6 +1142,28 @@ let PluginManager = (() => {
|
|
|
707
1142
|
_waitForInstall_decorators = [Remote];
|
|
708
1143
|
_cancelInstall_decorators = [Remote];
|
|
709
1144
|
_removeBundle_decorators = [Remote];
|
|
1145
|
+
__esDecorate(this, null, _listVersionExemptions_decorators, {
|
|
1146
|
+
kind: "method",
|
|
1147
|
+
name: "listVersionExemptions",
|
|
1148
|
+
static: false,
|
|
1149
|
+
private: false,
|
|
1150
|
+
access: {
|
|
1151
|
+
has: (obj) => "listVersionExemptions" in obj,
|
|
1152
|
+
get: (obj) => obj.listVersionExemptions
|
|
1153
|
+
},
|
|
1154
|
+
metadata: _metadata
|
|
1155
|
+
}, null, _instanceExtraInitializers);
|
|
1156
|
+
__esDecorate(this, null, _setVersionExemption_decorators, {
|
|
1157
|
+
kind: "method",
|
|
1158
|
+
name: "setVersionExemption",
|
|
1159
|
+
static: false,
|
|
1160
|
+
private: false,
|
|
1161
|
+
access: {
|
|
1162
|
+
has: (obj) => "setVersionExemption" in obj,
|
|
1163
|
+
get: (obj) => obj.setVersionExemption
|
|
1164
|
+
},
|
|
1165
|
+
metadata: _metadata
|
|
1166
|
+
}, null, _instanceExtraInitializers);
|
|
710
1167
|
__esDecorate(this, null, _listPlugins_decorators, {
|
|
711
1168
|
kind: "method",
|
|
712
1169
|
name: "listPlugins",
|
|
@@ -830,6 +1287,8 @@ let PluginManager = (() => {
|
|
|
830
1287
|
outputBytes: z.number().step(1).min(1).default(16384),
|
|
831
1288
|
lockWaitMs: z.number().step(1).min(0).default(12e4),
|
|
832
1289
|
inspectTimeoutMs: z.number().step(1).min(1e3).default(2e4),
|
|
1290
|
+
githubConnectionTimeoutMs: z.number().step(1).min(1e3).default(5e3),
|
|
1291
|
+
idleTimeoutMs: z.number().step(1).min(1e3).default(6e5),
|
|
833
1292
|
registry: z.string().pattern(REGISTRY_URL),
|
|
834
1293
|
fallbackRegistries: z.array(z.string().pattern(REGISTRY_URL)).default([NPMMIRROR_REGISTRY])
|
|
835
1294
|
});
|
|
@@ -841,6 +1300,8 @@ let PluginManager = (() => {
|
|
|
841
1300
|
outputBytes;
|
|
842
1301
|
lockWaitMs;
|
|
843
1302
|
inspectTimeoutMs;
|
|
1303
|
+
githubConnectionTimeoutMs;
|
|
1304
|
+
idleTimeoutMs;
|
|
844
1305
|
pnpmCommand;
|
|
845
1306
|
configuredRegistries;
|
|
846
1307
|
ownerContext;
|
|
@@ -856,6 +1317,8 @@ let PluginManager = (() => {
|
|
|
856
1317
|
this.outputBytes = config.outputBytes;
|
|
857
1318
|
this.lockWaitMs = config.lockWaitMs;
|
|
858
1319
|
this.inspectTimeoutMs = config.inspectTimeoutMs;
|
|
1320
|
+
this.githubConnectionTimeoutMs = config.githubConnectionTimeoutMs;
|
|
1321
|
+
this.idleTimeoutMs = config.idleTimeoutMs;
|
|
859
1322
|
this.pnpmCommand = config.pnpmCommand;
|
|
860
1323
|
this.configuredRegistries = {
|
|
861
1324
|
registry: config.registry === void 0 ? null : normalizeRegistry(config.registry),
|
|
@@ -866,6 +1329,34 @@ let PluginManager = (() => {
|
|
|
866
1329
|
await Promise.allSettled([...this.packageOperations]);
|
|
867
1330
|
}, "plugin-manager: package cancellation");
|
|
868
1331
|
}
|
|
1332
|
+
/** Read exact plugin-version exemptions saved in this profile.
|
|
1333
|
+
* @returns Accepted package-name@version keys with the runtime versions they may run on, and any
|
|
1334
|
+
* record or file problem the reader rejected, which the caller reports instead of failing.
|
|
1335
|
+
*/
|
|
1336
|
+
listVersionExemptions() {
|
|
1337
|
+
const { exemptions, warnings } = readProfileCompatibility(this.profile.dir);
|
|
1338
|
+
return {
|
|
1339
|
+
exemptions,
|
|
1340
|
+
warnings
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
/** Grant or revoke one exact plugin/runtime exemption and reevaluate live plugins.
|
|
1344
|
+
* @param packageVersion Exact manifest package name followed by @ and its version; never an installation spec or alias.
|
|
1345
|
+
* @param runtimeVersion Exact current DSH version for grants; revocation may name a previous runtime.
|
|
1346
|
+
* @param enabled Whether to grant rather than revoke the exemption.
|
|
1347
|
+
* @param acceptRisk Required true for grants after the user accepts possible crashes and data loss.
|
|
1348
|
+
* @returns Saved and runtime outcomes. Startup-only profiles require restart.
|
|
1349
|
+
*/
|
|
1350
|
+
setVersionExemption(packageVersion, runtimeVersion, enabled, acceptRisk) {
|
|
1351
|
+
return this.change((result) => this.configure(async () => {
|
|
1352
|
+
await setProfileVersionExemption(this.profile.dir, packageVersion, runtimeVersion, enabled, acceptRisk === true);
|
|
1353
|
+
result.warnings = await this.reload();
|
|
1354
|
+
}), {
|
|
1355
|
+
stage: "enable",
|
|
1356
|
+
target: packageVersion,
|
|
1357
|
+
enabled
|
|
1358
|
+
}, "bundle");
|
|
1359
|
+
}
|
|
869
1360
|
/** Read current plugins, including why a row cannot be changed through the profile patch.
|
|
870
1361
|
* @returns Current runtime entries with persistent patch targets.
|
|
871
1362
|
*/
|
|
@@ -896,6 +1387,7 @@ let PluginManager = (() => {
|
|
|
896
1387
|
*/
|
|
897
1388
|
listBundles() {
|
|
898
1389
|
const manifest = readProfileManifest("dsh", this.profile.dir);
|
|
1390
|
+
const exemptions = readProfileVersionExemptions(this.profile.dir);
|
|
899
1391
|
const selected = manifest.dsh?.profile?.bundles ?? [];
|
|
900
1392
|
const dependencies = Object.keys(manifest.dependencies ?? {});
|
|
901
1393
|
const installation = JSON.parse(readFileSync(this.profile.installAnchor, "utf8"));
|
|
@@ -927,6 +1419,8 @@ let PluginManager = (() => {
|
|
|
927
1419
|
});
|
|
928
1420
|
continue;
|
|
929
1421
|
}
|
|
1422
|
+
const compatibility = evaluatePluginCompatibility(info, exemptions);
|
|
1423
|
+
if (compatibility !== void 0 && !compatibility.exempted) throw new ManagementFailure("incompatible-version", [incompatiblePlugin(compatibility)]);
|
|
930
1424
|
const dir = resolveBundleDir("dsh", name, this.profile.installAnchor, this.profile.dir);
|
|
931
1425
|
const meta = readPluginMeta(info.name ?? name, pathToFileURL(join(dir, "package.json")).href);
|
|
932
1426
|
bundles.push({
|
|
@@ -1116,7 +1610,9 @@ let PluginManager = (() => {
|
|
|
1116
1610
|
}, "bundle");
|
|
1117
1611
|
}
|
|
1118
1612
|
/**
|
|
1119
|
-
* Install a package using the same pnpm implementation as dsh plugin.
|
|
1613
|
+
* Install a package using the same pnpm implementation as dsh plugin. GitHub
|
|
1614
|
+
* repositories get a connection check bounded by githubConnectionTimeoutMs before pnpm starts;
|
|
1615
|
+
* only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. A run
|
|
1120
1616
|
* that fails, is cancelled, or adds a package without a bundle patch restores
|
|
1121
1617
|
* `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.
|
|
1122
1618
|
* @param spec One package spec, including local paths relative to the invocation directory.
|
|
@@ -1131,7 +1627,7 @@ let PluginManager = (() => {
|
|
|
1131
1627
|
phase: "installing",
|
|
1132
1628
|
result: Promise.resolve(null)
|
|
1133
1629
|
};
|
|
1134
|
-
const stopped = () => control.abort.signal.aborted;
|
|
1630
|
+
const stopped = () => control.abort.signal.aborted || this.abort.signal.aborted;
|
|
1135
1631
|
if (requestId !== void 0) this.installs.set(requestId, control);
|
|
1136
1632
|
const announce = (phase, attempt) => {
|
|
1137
1633
|
if (requestId !== void 0) this.ownerContext.emit("plugin-manager/install-state", {
|
|
@@ -1149,11 +1645,30 @@ let PluginManager = (() => {
|
|
|
1149
1645
|
}
|
|
1150
1646
|
const files = await this.readRestoredFiles();
|
|
1151
1647
|
const before = readProfileManifest("dsh", this.profile.dir).dependencies ?? {};
|
|
1152
|
-
const plan = registryPlan(options?.registry, await this.registries());
|
|
1153
1648
|
let name;
|
|
1154
1649
|
try {
|
|
1155
|
-
let run;
|
|
1156
1650
|
result.registries = [];
|
|
1651
|
+
const connection = checkGithubConnection(parsedForRegistry(spec), this.profile.dir, {
|
|
1652
|
+
timeoutMs: this.githubConnectionTimeoutMs,
|
|
1653
|
+
outputBytes: this.outputBytes,
|
|
1654
|
+
signal: AbortSignal.any([this.abort.signal, control.abort.signal]),
|
|
1655
|
+
...this.profile.packageManager?.env === void 0 ? {} : { env: this.profile.packageManager.env }
|
|
1656
|
+
});
|
|
1657
|
+
this.packageOperations.add(connection);
|
|
1658
|
+
let connectionFailure;
|
|
1659
|
+
try {
|
|
1660
|
+
connectionFailure = await connection;
|
|
1661
|
+
} finally {
|
|
1662
|
+
this.packageOperations.delete(connection);
|
|
1663
|
+
}
|
|
1664
|
+
if (stopped()) throw new InstallCancelledError();
|
|
1665
|
+
if (connectionFailure?.kind === "network" || connectionFailure?.kind === "timeout") {
|
|
1666
|
+
result.packageResult = connectionFailure;
|
|
1667
|
+
result.failedAt = "spec-host";
|
|
1668
|
+
throw new Error(connectionFailure.output);
|
|
1669
|
+
}
|
|
1670
|
+
const plan = registryPlan(options?.registry, await this.registries());
|
|
1671
|
+
let run;
|
|
1157
1672
|
for (const [index, registry] of plan.entries()) {
|
|
1158
1673
|
if (index > 0) await this.restoreFiles(files);
|
|
1159
1674
|
if (stopped()) throw new InstallCancelledError();
|
|
@@ -1170,17 +1685,21 @@ let PluginManager = (() => {
|
|
|
1170
1685
|
], control.abort.signal, requestId);
|
|
1171
1686
|
result.packageResult = run;
|
|
1172
1687
|
if (stopped()) throw new InstallCancelledError();
|
|
1173
|
-
|
|
1174
|
-
if (run.exitCode === 0
|
|
1175
|
-
|
|
1688
|
+
if (run.incompatible !== void 0) throw new ManagementFailure("incompatible-version", run.incompatible);
|
|
1689
|
+
if (run.exitCode === 0 && run.timedOut !== true) break;
|
|
1690
|
+
/* v8 ignore next 2 -- runPnpm classifies every run it does not report as succeeded */
|
|
1691
|
+
if (run.kind === void 0) break;
|
|
1176
1692
|
delete result.failedAt;
|
|
1693
|
+
if (run.timedOut === true) break;
|
|
1694
|
+
const failedAt = attributeFailure(run.kind, run.output, parsedForRegistry(spec));
|
|
1177
1695
|
if (failedAt !== "other") result.failedAt = failedAt;
|
|
1178
1696
|
if (failedAt !== "registry" || index === plan.length - 1) break;
|
|
1179
1697
|
}
|
|
1180
1698
|
/* v8 ignore next -- the plan is never empty, so a run always settled */
|
|
1181
1699
|
if (run === void 0) throw new Error("no registry was asked");
|
|
1182
|
-
|
|
1183
|
-
if (
|
|
1700
|
+
const succeeded = run.exitCode === 0 && run.timedOut !== true;
|
|
1701
|
+
if (succeeded) delete result.failedAt;
|
|
1702
|
+
if (!succeeded) {
|
|
1184
1703
|
try {
|
|
1185
1704
|
result.pendingBuilds = await readPendingBuilds(this.profile.dir);
|
|
1186
1705
|
} catch (error) {
|
|
@@ -1197,6 +1716,8 @@ let PluginManager = (() => {
|
|
|
1197
1716
|
const dir = resolveBundleDir("dsh", name, this.profile.installAnchor, this.profile.dir);
|
|
1198
1717
|
const manifest = bundleManifest(name, this.profile.dir, this.profile.installAnchor);
|
|
1199
1718
|
if (manifest?.dsh?.bundle === void 0) throw new ManagementFailure("not-bundle");
|
|
1719
|
+
const compatibility = evaluatePluginCompatibility(manifest, readProfileVersionExemptions(this.profile.dir));
|
|
1720
|
+
if (compatibility !== void 0 && !compatibility.exempted) throw new ManagementFailure("incompatible-version", [incompatiblePlugin(compatibility)]);
|
|
1200
1721
|
for (const file of bundlePatchPaths(dir, manifest.dsh.bundle)) loadOverlayPatches("dsh", file);
|
|
1201
1722
|
} catch (error) {
|
|
1202
1723
|
await this.restoreFiles(files);
|
|
@@ -1232,7 +1753,7 @@ let PluginManager = (() => {
|
|
|
1232
1753
|
}
|
|
1233
1754
|
/** Stop an installation this manager owns and wait until its files are back.
|
|
1234
1755
|
* @param requestId The id the installation was started with.
|
|
1235
|
-
* @returns `cancelled` once pnpm exited and the files are restored, `too-late` once the bundle is being
|
|
1756
|
+
* @returns `cancelled` once the Git check or pnpm exited and the files are restored, `too-late` once the bundle is being
|
|
1236
1757
|
* applied, `not-running` for any other id.
|
|
1237
1758
|
*/
|
|
1238
1759
|
async cancelInstall(requestId) {
|
|
@@ -1266,7 +1787,7 @@ let PluginManager = (() => {
|
|
|
1266
1787
|
if ([...this.ctx.loader.entries()].some((entry) => entry.fiber?.uid != null && contributions.some((row) => row.id === entry.options.id && row.name === entry.options.name))) throw new ManagementFailure("bundle-in-use");
|
|
1267
1788
|
});
|
|
1268
1789
|
result.packageResult = await this.runPnpm(["remove", name]);
|
|
1269
|
-
if (result.packageResult.exitCode !== 0) throw new Error(result.packageResult.output);
|
|
1790
|
+
if (result.packageResult.exitCode !== 0 || result.packageResult.timedOut === true) throw new Error(result.packageResult.output);
|
|
1270
1791
|
}, {
|
|
1271
1792
|
stage: "remove",
|
|
1272
1793
|
target: name
|
|
@@ -1325,6 +1846,8 @@ let PluginManager = (() => {
|
|
|
1325
1846
|
signal: signal === void 0 ? this.abort.signal : AbortSignal.any([this.abort.signal, signal]),
|
|
1326
1847
|
outputBytes: this.outputBytes,
|
|
1327
1848
|
activateNewBundles: false,
|
|
1849
|
+
idleTimeoutMs: this.idleTimeoutMs,
|
|
1850
|
+
lookupTimeoutMs: this.inspectTimeoutMs,
|
|
1328
1851
|
onOutput: (text, stream) => {
|
|
1329
1852
|
this.ownerContext.emit("plugin-manager/install-log", {
|
|
1330
1853
|
...identity,
|
|
@@ -1348,9 +1871,13 @@ let PluginManager = (() => {
|
|
|
1348
1871
|
text: "",
|
|
1349
1872
|
exitCode: signal?.aborted === true ? null : result.exitCode
|
|
1350
1873
|
});
|
|
1351
|
-
|
|
1874
|
+
if (result.exitCode === 0 && result.timedOut !== true) return result;
|
|
1875
|
+
return {
|
|
1352
1876
|
...result,
|
|
1353
|
-
kind: classifyInstallFailure({
|
|
1877
|
+
kind: classifyInstallFailure({
|
|
1878
|
+
log: result.output,
|
|
1879
|
+
...result.timedOut === true ? { timedOut: true } : {}
|
|
1880
|
+
})
|
|
1354
1881
|
};
|
|
1355
1882
|
} catch (error) {
|
|
1356
1883
|
this.ownerContext.emit("plugin-manager/install-log", {
|
|
@@ -1384,8 +1911,15 @@ let PluginManager = (() => {
|
|
|
1384
1911
|
async selectBundle(name, enabled) {
|
|
1385
1912
|
const manifest = readProfileManifest("dsh", this.profile.dir);
|
|
1386
1913
|
const previous = manifest.dsh?.profile?.bundles ?? [];
|
|
1387
|
-
if (
|
|
1388
|
-
|
|
1914
|
+
if (enabled || !previous.includes(name)) {
|
|
1915
|
+
const metadata = bundleManifest(name, this.profile.dir, this.profile.installAnchor);
|
|
1916
|
+
if (metadata === void 0) throw new ManagementFailure("not-bundle");
|
|
1917
|
+
if (enabled) {
|
|
1918
|
+
const compatibility = evaluatePluginCompatibility(metadata, readProfileVersionExemptions(this.profile.dir));
|
|
1919
|
+
if (compatibility !== void 0 && !compatibility.exempted) throw new ManagementFailure("incompatible-version", [incompatiblePlugin(compatibility)]);
|
|
1920
|
+
this.bundleRows(name);
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1389
1923
|
if (!enabled && previous.includes(name)) {
|
|
1390
1924
|
if (this.protectsManager(name)) throw new ManagementFailure("management-required");
|
|
1391
1925
|
}
|
|
@@ -1457,7 +1991,8 @@ let PluginManager = (() => {
|
|
|
1457
1991
|
return [
|
|
1458
1992
|
"package.json",
|
|
1459
1993
|
"cordis.patch.yml",
|
|
1460
|
-
"pnpm-workspace.yaml"
|
|
1994
|
+
"pnpm-workspace.yaml",
|
|
1995
|
+
PROFILE_COMPATIBILITY_FILENAME
|
|
1461
1996
|
].map((file) => {
|
|
1462
1997
|
try {
|
|
1463
1998
|
return readFileSync(join(this.profile.dir, file), "utf8");
|