@xmanrui/dsh-im 3.0.8 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +23 -2
- package/README.md +23 -2
- package/THIRD_PARTY_NOTICES.md +20 -0
- package/lib/client.js +542 -47
- package/lib/index.js +231 -224
- package/package.json +3 -2
- package/plugin-src/client/i18n.js +56 -0
- package/plugin-src/client/index.js +19 -2
- package/plugin-src/client/styles.js +34 -1
- package/plugin-src/client/update-panel.js +367 -0
- package/plugin-src/host/index.mjs +9 -0
- package/plugin-src/host/update-rpc.mjs +50 -0
- package/plugin-src/host/update-runtime.mjs +338 -0
- package/plugin-src/host/update-service.mjs +385 -0
- package/scripts/verify-package.mjs +12 -0
- package/src/channels/dingtalk/dingtalk-bridge.mjs +5 -1
- package/src/channels/feishu/bridge.mjs +11 -3
- package/src/channels/feishu/feishu-cards.mjs +2 -0
- package/src/channels/qq/qq-bridge.mjs +5 -1
- package/src/channels/shared/bot-workspace-store.mjs +3 -0
- package/src/channels/shared/harness-client.mjs +13 -0
- package/src/channels/shared/history-command.mjs +193 -0
- package/src/channels/shared/i18n-en/shared-b.mjs +1 -0
- package/src/channels/shared/i18n-en/shared-c.mjs +40 -0
- package/src/channels/shared/text-harness-bridge.mjs +5 -1
- package/src/channels/shared/workspace-command.mjs +1 -0
- package/src/channels/wecom/wecom-bridge.mjs +5 -1
- package/src/channels/weixin/weixin-bridge.mjs +5 -1
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createUpdateRuntime } from './update-runtime.mjs';
|
|
2
|
+
import { createUpdateService } from './update-service.mjs';
|
|
3
|
+
|
|
4
|
+
export const UPDATE_RPC_CHANNEL = '/dsh-im';
|
|
5
|
+
export const UPDATE_ENDPOINTS = Object.freeze(['update.status', 'update.check', 'update.install']);
|
|
6
|
+
|
|
7
|
+
function validPayload(endpoint, payload) {
|
|
8
|
+
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return false;
|
|
9
|
+
const keys = Object.keys(payload);
|
|
10
|
+
if (endpoint !== 'update.install') return keys.length === 0;
|
|
11
|
+
return keys.length === 2 && keys.every((key) => key === 'checkId' || key === 'requestId')
|
|
12
|
+
&& ['checkId', 'requestId'].every((key) => typeof payload[key] === 'string'
|
|
13
|
+
&& /^[A-Za-z0-9_-]{1,128}$/.test(payload[key]));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const PUBLIC_ERRORS = new Set([
|
|
17
|
+
'check-failed', 'invalid-release', 'check-expired', 'installation-changed', 'update-busy',
|
|
18
|
+
'install-failed', 'verify-failed', 'state-unavailable', 'interrupted', 'disposed',
|
|
19
|
+
'source-install', 'unknown-profile', 'unsupported-runtime', 'registry-conflict',
|
|
20
|
+
'incompatible-node', 'pending-restart', 'recovery-required', 'executor-unavailable',
|
|
21
|
+
'invalid-installation', 'registry-check-failed', 'install-timeout', 'install-interrupted', 'invalid-version',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
export function createUpdateRpcHandler(service) {
|
|
25
|
+
return async (endpoint, payload, signal) => {
|
|
26
|
+
if (!UPDATE_ENDPOINTS.includes(endpoint) || !validPayload(endpoint, payload)) {
|
|
27
|
+
return { ok: false, error: { code: 'bad-request', message: 'Invalid update request.' } };
|
|
28
|
+
}
|
|
29
|
+
if (signal?.aborted) return { ok: false, error: { code: 'cancelled', message: 'Request cancelled.' } };
|
|
30
|
+
try {
|
|
31
|
+
// The submitted install belongs to the Host, not the lifetime of this browser request.
|
|
32
|
+
const value = endpoint === 'update.install' ? await service.install(payload)
|
|
33
|
+
: endpoint === 'update.check' ? await service.check() : await service.status();
|
|
34
|
+
return { ok: true, value };
|
|
35
|
+
} catch (error) {
|
|
36
|
+
const code = PUBLIC_ERRORS.has(error?.code) ? error.code : 'update-failed';
|
|
37
|
+
return { ok: false, error: { code, message: code } };
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function installUpdateRpc(ctx, options = {}) {
|
|
43
|
+
const runtime = options.runtime ?? createUpdateRuntime({ ctx, moduleUrl: import.meta.url });
|
|
44
|
+
const service = options.service ?? createUpdateService({ runtime });
|
|
45
|
+
const dispose = ctx.connection.rpc.handle(UPDATE_RPC_CHANNEL, createUpdateRpcHandler(service), {
|
|
46
|
+
authority: 'loopback',
|
|
47
|
+
});
|
|
48
|
+
ctx.effect(() => () => service.close(), 'dsh-im: close update installer');
|
|
49
|
+
return dispose;
|
|
50
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFile, realpath, stat } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
import semver from 'semver';
|
|
8
|
+
|
|
9
|
+
export const PACKAGE_NAME = '@xmanrui/dsh-im';
|
|
10
|
+
export const NPM_REGISTRY = 'https://registry.npmjs.org/';
|
|
11
|
+
|
|
12
|
+
const INSTALL_TIMEOUT_MS = 15 * 60_000;
|
|
13
|
+
const CONFIG_TIMEOUT_MS = 10_000;
|
|
14
|
+
const OUTPUT_LIMIT = 16 * 1024;
|
|
15
|
+
|
|
16
|
+
function failure(code, extra = {}) {
|
|
17
|
+
return Object.assign(new Error(code), { code, ...extra });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Cordis 4's get() is the supported optional-service lookup. Putting these
|
|
21
|
+
// Desktop-only services in the plugin's inject array would disable web Hosts.
|
|
22
|
+
function service(ctx, name) {
|
|
23
|
+
return typeof ctx?.get === 'function' ? ctx.get(name) : ctx?.[name];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function inside(directory, filename) {
|
|
27
|
+
const suffix = relative(directory, filename);
|
|
28
|
+
return suffix !== '..' && !suffix.startsWith(`..${sep}`) && !isAbsolute(suffix);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function readOptional(filename) {
|
|
32
|
+
try {
|
|
33
|
+
return await readFile(filename, 'utf8');
|
|
34
|
+
} catch (error) {
|
|
35
|
+
if (error.code === 'ENOENT') return '';
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function packageAt(directory) {
|
|
41
|
+
const contents = await readFile(join(directory, 'package.json'), 'utf8');
|
|
42
|
+
return { directory: await realpath(directory), manifest: JSON.parse(contents), contents };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function containingPackage(filename, name) {
|
|
46
|
+
let directory = dirname(await realpath(filename));
|
|
47
|
+
while (true) {
|
|
48
|
+
try {
|
|
49
|
+
const found = await packageAt(directory);
|
|
50
|
+
return found.manifest?.name === name ? found : null;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (error.code !== 'ENOENT') throw error;
|
|
53
|
+
}
|
|
54
|
+
const parent = dirname(directory);
|
|
55
|
+
if (parent === directory) return null;
|
|
56
|
+
directory = parent;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function profileNameValid(name) {
|
|
61
|
+
return typeof name === 'string' && name.length > 0 && Buffer.byteLength(name) <= 255
|
|
62
|
+
&& !name.startsWith('-') && !['.', '..', 'node_modules'].includes(name)
|
|
63
|
+
&& !/[\\/\x00-\x1f\x7f<>:"|?*]/u.test(name);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function cliProfile(args) {
|
|
67
|
+
if (args[0] === 'web') return 'web';
|
|
68
|
+
let name;
|
|
69
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
70
|
+
const token = args[index];
|
|
71
|
+
if (token === '--profile') name = args[++index];
|
|
72
|
+
else if (token.startsWith('--profile=')) name = token.slice('--profile='.length);
|
|
73
|
+
else if (token === '--patch') index += 1;
|
|
74
|
+
else if (!token.startsWith('--patch=')) break;
|
|
75
|
+
}
|
|
76
|
+
return name;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function dshHome(env, osHome) {
|
|
80
|
+
let selected = env.DSH_HOME?.trim() ? env.DSH_HOME : join(osHome, '.dsh');
|
|
81
|
+
if (selected === '~') selected = osHome;
|
|
82
|
+
else if (/^~[\\/]/u.test(selected)) selected = join(osHome, selected.slice(2));
|
|
83
|
+
return resolve(selected);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function registrySpec(spec) {
|
|
87
|
+
return typeof spec === 'string' && spec.trim().length > 0
|
|
88
|
+
&& (semver.validRange(spec) !== null || /^[A-Za-z][A-Za-z0-9._-]*$/u.test(spec));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function validPackage(pkg) {
|
|
92
|
+
if (pkg.manifest?.name !== PACKAGE_NAME || semver.valid(pkg.manifest.version) === null) return false;
|
|
93
|
+
const entries = [
|
|
94
|
+
pkg.manifest.main,
|
|
95
|
+
pkg.manifest.exports?.['./client'],
|
|
96
|
+
pkg.manifest.dsh?.bundle?.patch,
|
|
97
|
+
];
|
|
98
|
+
for (const entry of entries) {
|
|
99
|
+
if (typeof entry !== 'string' || !entry || isAbsolute(entry) || entry.includes('\0')) return false;
|
|
100
|
+
const filename = resolve(pkg.directory, entry);
|
|
101
|
+
if (!inside(pkg.directory, filename) || !inside(pkg.directory, await realpath(filename))) return false;
|
|
102
|
+
if (!(await stat(filename)).isFile()) return false;
|
|
103
|
+
}
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function officialRegistry(value) {
|
|
108
|
+
if (value === undefined || value === null || value === '') return true;
|
|
109
|
+
if (typeof value !== 'string') return false;
|
|
110
|
+
try {
|
|
111
|
+
const url = new URL(value);
|
|
112
|
+
return url.href === NPM_REGISTRY;
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Drain bounded output; raw subprocess diagnostics never cross the RPC boundary. */
|
|
119
|
+
async function run(start, { signal, timeoutMs, errorCode, capture = false }) {
|
|
120
|
+
if (signal?.aborted) throw failure('install-interrupted');
|
|
121
|
+
const controller = new AbortController();
|
|
122
|
+
let interrupted;
|
|
123
|
+
let operation;
|
|
124
|
+
const cancel = (code) => {
|
|
125
|
+
interrupted ??= code;
|
|
126
|
+
controller.abort();
|
|
127
|
+
operation?.cancel?.();
|
|
128
|
+
};
|
|
129
|
+
const onAbort = () => cancel('install-interrupted');
|
|
130
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
131
|
+
const timer = setTimeout(() => cancel('install-timeout'), timeoutMs);
|
|
132
|
+
let stdout = '';
|
|
133
|
+
let stderr = '';
|
|
134
|
+
try {
|
|
135
|
+
operation = start(controller.signal);
|
|
136
|
+
// The Host services own process-tree termination and wait for actual exit.
|
|
137
|
+
// Do not race their completion: a timed-out installer may still hold files.
|
|
138
|
+
operation.stdout?.on('data', (chunk) => {
|
|
139
|
+
if (capture) stdout = (stdout + chunk.toString()).slice(-OUTPUT_LIMIT);
|
|
140
|
+
});
|
|
141
|
+
operation.stderr?.on('data', (chunk) => {
|
|
142
|
+
stderr = (stderr + chunk.toString()).slice(-OUTPUT_LIMIT);
|
|
143
|
+
});
|
|
144
|
+
operation.stdout?.on('error', () => cancel(errorCode));
|
|
145
|
+
operation.stderr?.on('error', () => cancel(errorCode));
|
|
146
|
+
const outcome = await operation.done;
|
|
147
|
+
if (interrupted) throw failure(interrupted);
|
|
148
|
+
if (outcome.exitCode !== 0 || outcome.signal) {
|
|
149
|
+
throw failure(errorCode, {
|
|
150
|
+
exitCode: outcome.exitCode,
|
|
151
|
+
diagnosticCode: stderr.match(/\bERR_PNPM_[A-Z0-9_]+\b/u)?.[0],
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return { exitCode: 0, signal: null, ...(capture ? { stdout } : {}) };
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if (interrupted) throw failure(interrupted);
|
|
157
|
+
if (error.code === errorCode) throw error;
|
|
158
|
+
throw failure(errorCode);
|
|
159
|
+
} finally {
|
|
160
|
+
clearTimeout(timer);
|
|
161
|
+
signal?.removeEventListener('abort', onAbort);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Adapt the running Host's existing package-management capabilities. Options
|
|
167
|
+
* replace process facts in tests only; no runtime path comes from RPC input.
|
|
168
|
+
*/
|
|
169
|
+
export function createUpdateRuntime(options = {}) {
|
|
170
|
+
const ctx = options.ctx;
|
|
171
|
+
const env = options.env ?? process.env;
|
|
172
|
+
const argv = options.argv ?? process.argv;
|
|
173
|
+
const execArgv = options.execArgv ?? process.execArgv;
|
|
174
|
+
const execPath = options.execPath ?? process.execPath;
|
|
175
|
+
const cwd = options.cwd ?? process.cwd();
|
|
176
|
+
const platform = options.platform ?? process.platform;
|
|
177
|
+
const osHome = options.osHome ?? homedir();
|
|
178
|
+
const electron = options.electron ?? process.versions.electron;
|
|
179
|
+
const moduleUrl = options.moduleUrl ?? import.meta.url;
|
|
180
|
+
const loadedPackage = containingPackage(fileURLToPath(moduleUrl), PACKAGE_NAME).catch(() => null);
|
|
181
|
+
let boundProfile;
|
|
182
|
+
|
|
183
|
+
async function environment() {
|
|
184
|
+
const profiles = service(ctx, 'desktopProfiles');
|
|
185
|
+
const desktop = service(ctx, 'desktopPnpm');
|
|
186
|
+
const bootstrap = service(ctx, 'desktopPnpmBootstrap');
|
|
187
|
+
const isDesktop = Boolean(electron || profiles || desktop || bootstrap);
|
|
188
|
+
const current = profiles?.current;
|
|
189
|
+
const profileName = isDesktop ? current?.name : cliProfile(argv.slice(2));
|
|
190
|
+
const homeDir = isDesktop && bootstrap?.homeDir ? resolve(bootstrap.homeDir) : dshHome(env, osHome);
|
|
191
|
+
const result = { environmentKind: isDesktop ? 'desktop' : 'cli', homeDir, profileName };
|
|
192
|
+
if (!profileNameValid(profileName)) return { ...result, blockedReason: 'unknown-profile' };
|
|
193
|
+
const profileDir = await realpath(join(homeDir, 'profiles', profileName));
|
|
194
|
+
const home = await realpath(homeDir);
|
|
195
|
+
const base = { ...result, homeDir: home, profileDir };
|
|
196
|
+
|
|
197
|
+
if (isDesktop) {
|
|
198
|
+
try {
|
|
199
|
+
if (typeof desktop?.runPlugin !== 'function' || typeof desktop?.run !== 'function' || !bootstrap
|
|
200
|
+
|| !current?.dir || bootstrap.activeProfileName !== profileName
|
|
201
|
+
|| await realpath(current.dir) !== profileDir
|
|
202
|
+
|| await realpath(bootstrap.activeProfileDir) !== profileDir
|
|
203
|
+
|| await realpath(bootstrap.appExecutable) !== await realpath(execPath)) {
|
|
204
|
+
return { ...base, blockedReason: 'executor-unavailable' };
|
|
205
|
+
}
|
|
206
|
+
const desktopPackage = await containingPackage(bootstrap.dshBootstrapPath, 'dsh-plugin-desktop');
|
|
207
|
+
if (!desktopPackage || basename(bootstrap.dshBootstrapPath) !== 'desktop-cli.js'
|
|
208
|
+
|| dirname(await realpath(bootstrap.dshBootstrapPath)) !== join(desktopPackage.directory, 'lib')) {
|
|
209
|
+
return { ...base, blockedReason: 'executor-unavailable' };
|
|
210
|
+
}
|
|
211
|
+
return { ...base, desktop, executable: execPath, cliEntry: bootstrap.dshBootstrapPath };
|
|
212
|
+
} catch {
|
|
213
|
+
return { ...base, blockedReason: 'executor-unavailable' };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const subprocess = service(ctx, 'subprocess');
|
|
218
|
+
if (typeof subprocess?.spawn !== 'function' || typeof argv[1] !== 'string' || !isAbsolute(argv[1]) || platform === 'win32') {
|
|
219
|
+
return { ...base, blockedReason: 'executor-unavailable' };
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
const cli = await containingPackage(argv[1], '@deepseek-ai/dsh');
|
|
223
|
+
if (!cli) return { ...base, blockedReason: 'executor-unavailable' };
|
|
224
|
+
const cliEntry = await realpath(argv[1]);
|
|
225
|
+
const declared = typeof cli.manifest.bin === 'string' ? cli.manifest.bin : cli.manifest.bin?.dsh;
|
|
226
|
+
const publishedEntry = typeof declared === 'string' ? resolve(cli.directory, declared) : '';
|
|
227
|
+
if (cliEntry !== publishedEntry && cliEntry !== join(cli.directory, 'src', 'bin.ts')) {
|
|
228
|
+
return { ...base, blockedReason: 'executor-unavailable' };
|
|
229
|
+
}
|
|
230
|
+
return { ...base, subprocess, executable: execPath, cliEntry };
|
|
231
|
+
} catch {
|
|
232
|
+
return { ...base, blockedReason: 'executor-unavailable' };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function cliOperation(runtime, args, signal, directPnpm = false) {
|
|
237
|
+
const child = runtime.subprocess.spawn({
|
|
238
|
+
argv: directPnpm ? ['pnpm', ...args] : [execPath, ...execArgv, runtime.cliEntry, ...args],
|
|
239
|
+
cwd: directPnpm ? runtime.profileDir : cwd,
|
|
240
|
+
env: { DSH_HOME: runtime.homeDir, CI: 'true' },
|
|
241
|
+
stdio: { stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' },
|
|
242
|
+
graceMs: 3_000,
|
|
243
|
+
signal,
|
|
244
|
+
});
|
|
245
|
+
return {
|
|
246
|
+
stdout: child.stdout,
|
|
247
|
+
stderr: child.stderr,
|
|
248
|
+
cancel: () => child.terminate(),
|
|
249
|
+
done: (async () => {
|
|
250
|
+
try { return await child.done; }
|
|
251
|
+
finally { await child.waitForExit(); }
|
|
252
|
+
})(),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function checkRegistry(runtime, signal) {
|
|
257
|
+
const args = ['config', 'get', '@xmanrui:registry', '--json'];
|
|
258
|
+
const result = await run(
|
|
259
|
+
(childSignal) => runtime.desktop
|
|
260
|
+
? runtime.desktop.run(args, childSignal)
|
|
261
|
+
: cliOperation(runtime, args, childSignal, true),
|
|
262
|
+
{ signal, timeoutMs: options.configTimeoutMs ?? CONFIG_TIMEOUT_MS, errorCode: 'registry-check-failed', capture: true },
|
|
263
|
+
);
|
|
264
|
+
const output = result.stdout.trim();
|
|
265
|
+
let value;
|
|
266
|
+
try { value = output === '' || output === 'undefined' ? undefined : JSON.parse(output); }
|
|
267
|
+
catch { throw failure('registry-check-failed'); }
|
|
268
|
+
if (!officialRegistry(value)) throw failure('registry-conflict');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function inspect({ preflight = false } = {}) {
|
|
272
|
+
let runtime;
|
|
273
|
+
const result = { installedVersion: null, packageValid: false, eligible: false, installationKey: null };
|
|
274
|
+
try {
|
|
275
|
+
runtime = await environment();
|
|
276
|
+
for (const key of ['homeDir', 'profileDir', 'profileName', 'environmentKind', 'executable', 'cliEntry']) {
|
|
277
|
+
result[key] = runtime[key] ?? null;
|
|
278
|
+
}
|
|
279
|
+
if (!runtime.profileDir) return { ...result, blockedReason: runtime.blockedReason ?? 'unknown-profile' };
|
|
280
|
+
|
|
281
|
+
const profile = await packageAt(runtime.profileDir);
|
|
282
|
+
const installed = await packageAt(join(runtime.profileDir, 'node_modules', PACKAGE_NAME));
|
|
283
|
+
result.installedVersion = typeof installed.manifest.version === 'string' ? installed.manifest.version : null;
|
|
284
|
+
result.packageValid = await validPackage(installed);
|
|
285
|
+
const loaded = await loadedPackage;
|
|
286
|
+
const identity = `${runtime.homeDir}\0${runtime.profileDir}\0${runtime.profileName}`;
|
|
287
|
+
const sameLoadedPackage = loaded?.directory === installed.directory
|
|
288
|
+
&& loaded?.manifest.version === installed.manifest.version;
|
|
289
|
+
if (boundProfile === undefined && sameLoadedPackage && result.packageValid) boundProfile = identity;
|
|
290
|
+
|
|
291
|
+
const stateFiles = await Promise.all(['pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package-lock.json']
|
|
292
|
+
.map((filename) => readOptional(join(runtime.profileDir, filename))));
|
|
293
|
+
result.installationKey = createHash('sha256').update(JSON.stringify([
|
|
294
|
+
identity, profile.contents, installed.directory, installed.contents,
|
|
295
|
+
runtime.cliEntry, runtime.executable, ...stateFiles,
|
|
296
|
+
])).digest('hex');
|
|
297
|
+
|
|
298
|
+
if (boundProfile !== undefined && boundProfile !== identity) result.blockedReason = 'installation-changed';
|
|
299
|
+
else if (!sameLoadedPackage && boundProfile === undefined) result.blockedReason = 'installation-changed';
|
|
300
|
+
else if (!result.packageValid) result.blockedReason = 'invalid-installation';
|
|
301
|
+
else if (!registrySpec(profile.manifest.dependencies?.[PACKAGE_NAME])
|
|
302
|
+
|| !inside(join(runtime.profileDir, 'node_modules'), installed.directory)) result.blockedReason = 'source-install';
|
|
303
|
+
else if (runtime.blockedReason) result.blockedReason = runtime.blockedReason;
|
|
304
|
+
else if (!sameLoadedPackage) result.blockedReason = 'pending-restart';
|
|
305
|
+
else if (preflight) await checkRegistry(runtime);
|
|
306
|
+
|
|
307
|
+
result.eligible = !result.blockedReason;
|
|
308
|
+
return { ...result, blockedReason: result.blockedReason ?? null };
|
|
309
|
+
} catch (error) {
|
|
310
|
+
const known = ['registry-conflict', 'registry-check-failed', 'install-timeout', 'install-interrupted'];
|
|
311
|
+
return { ...result, blockedReason: known.includes(error.code) ? error.code : runtime?.profileDir ? 'invalid-installation' : 'unknown-profile' };
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function install(version, { signal, expectedInstallationKey } = {}) {
|
|
316
|
+
if (semver.valid(version) !== version || semver.prerelease(version)) throw failure('invalid-version');
|
|
317
|
+
const before = await inspect();
|
|
318
|
+
if (expectedInstallationKey !== undefined && before.installationKey !== expectedInstallationKey) {
|
|
319
|
+
throw failure('installation-changed');
|
|
320
|
+
}
|
|
321
|
+
if (!before.eligible) throw failure(before.blockedReason);
|
|
322
|
+
const runtime = await environment();
|
|
323
|
+
await checkRegistry(runtime, signal);
|
|
324
|
+
// Config validation is asynchronous: do not replace a package another
|
|
325
|
+
// package manager changed while it ran.
|
|
326
|
+
const checked = await inspect();
|
|
327
|
+
if (!checked.eligible || checked.installationKey !== before.installationKey) throw failure('installation-changed');
|
|
328
|
+
const args = ['add', '-w', '--save-exact', `${PACKAGE_NAME}@${version}`, `--registry=${NPM_REGISTRY}`];
|
|
329
|
+
return run(
|
|
330
|
+
(childSignal) => runtime.desktop
|
|
331
|
+
? runtime.desktop.runPlugin(args, runtime.profileDir, childSignal)
|
|
332
|
+
: cliOperation(runtime, ['plugin', '--profile', runtime.profileName, ...args], childSignal),
|
|
333
|
+
{ signal, timeoutMs: options.installTimeoutMs ?? INSTALL_TIMEOUT_MS, errorCode: 'install-failed' },
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return Object.freeze({ inspect, install });
|
|
338
|
+
}
|