@deepseek-ai/dsh-plugin-manager 0.2.1-alpha.2 → 0.2.1-alpha.3

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.
@@ -1,3 +1,55 @@
1
+ var __addDisposableResource = (this && this.__addDisposableResource) || function (env, value, async) {
2
+ if (value !== null && value !== void 0) {
3
+ if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
4
+ var dispose, inner;
5
+ if (async) {
6
+ if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
7
+ dispose = value[Symbol.asyncDispose];
8
+ }
9
+ if (dispose === void 0) {
10
+ if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
11
+ dispose = value[Symbol.dispose];
12
+ if (async) inner = dispose;
13
+ }
14
+ if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
15
+ if (inner) dispose = function() { try { inner.call(this); } catch (e) { return Promise.reject(e); } };
16
+ env.stack.push({ value: value, dispose: dispose, async: async });
17
+ }
18
+ else if (async) {
19
+ env.stack.push({ async: true });
20
+ }
21
+ return value;
22
+ };
23
+ var __disposeResources = (this && this.__disposeResources) || (function (SuppressedError) {
24
+ return function (env) {
25
+ function fail(e) {
26
+ env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
27
+ env.hasError = true;
28
+ }
29
+ var r, s = 0;
30
+ function next() {
31
+ while (r = env.stack.pop()) {
32
+ try {
33
+ if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
34
+ if (r.dispose) {
35
+ var result = r.dispose.call(r.value);
36
+ if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) { fail(e); return next(); });
37
+ }
38
+ else s |= 1;
39
+ }
40
+ catch (e) {
41
+ fail(e);
42
+ }
43
+ }
44
+ if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
45
+ if (env.hasError) throw env.error;
46
+ }
47
+ return next();
48
+ };
49
+ })(typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
50
+ var e = new Error(message);
51
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
52
+ });
1
53
  /** Shared profile package operations used by dsh plugin and the running manager. */
2
54
  import { once } from 'node:events';
3
55
  import { existsSync, readFileSync } from 'node:fs';
@@ -242,280 +294,293 @@ function bundleComponentManifests(manifest, dir, anchor) {
242
294
  * @param options Output, activation and cancellation policy.
243
295
  * @returns Exit status, whether the silence bound stopped the run, and the diagnostic path.
244
296
  * A compatibility denial returns exit code 1. Service output is bounded; CLI output uses inherited descriptors.
297
+ * The diagnostic file is closed on return or rejection, including preparation and spawn failures.
245
298
  */
246
299
  export async function runProfilePnpm(context, args, options) {
247
- const dir = context.dir ?? resolveProfileDir(context.profile, context.home);
248
- // Before any profile file is read: a recorded run that is still active may be rewriting them.
249
- const active = await activeRecordedRun(dir);
250
- const logRoot = join(dir, '.plugin-manager', 'logs');
251
- await mkdir(logRoot, { recursive: true, mode: 0o700 });
252
- const logDir = await mkdtemp(join(logRoot, 'operation-'));
253
- const logPath = join(logDir, 'pnpm.log');
254
- const log = await open(logPath, 'wx', 0o600);
255
- if (active !== undefined) {
256
- await log.write(active);
257
- await log.close();
258
- options.onOutput?.(active, 'stderr');
259
- const bytes = Buffer.from(active);
260
- return { exitCode: 1, output: bytes.subarray(Math.max(0, bytes.length - options.outputBytes)).toString('utf8'), truncated: bytes.length > options.outputBytes, logPath };
261
- }
262
- const before = readProfileManifest('dsh', dir);
263
- const savedFiles = ['package.json', 'pnpm-lock.yaml'].map(name => ({ path: join(dir, name), text: optionalFile(join(dir, name)) }));
264
- const beforeDependencies = directDependencies(before);
265
- const installedBefore = new Map(Object.keys(beforeDependencies).map(name => [name, optionalFile(join(dir, 'node_modules', name, 'package.json'))]));
266
- let output = Buffer.alloc(0);
267
- let truncated = false;
268
- const append = (bytes) => {
269
- output = Buffer.concat([output, bytes]);
270
- if (output.length > options.outputBytes) {
271
- truncated = true;
272
- output = output.subarray(output.length - options.outputBytes);
273
- }
274
- };
275
- const environment = { ...(options.execution === 'cli' ? process.env : scrubbedParentEnv()), ...options.env };
276
- const restore = async () => {
277
- for (const file of savedFiles) {
278
- if (file.text === undefined)
279
- await rm(file.path, { force: true });
280
- else
281
- await writeFileAtomic(file.path, file.text, { mode: 0o600 });
300
+ const env_1 = { stack: [], error: void 0, hasError: false };
301
+ try {
302
+ const dir = context.dir ?? resolveProfileDir(context.profile, context.home);
303
+ // Before any profile file is read: a recorded run that is still active may be rewriting them.
304
+ const active = await activeRecordedRun(dir);
305
+ const logRoot = join(dir, '.plugin-manager', 'logs');
306
+ await mkdir(logRoot, { recursive: true, mode: 0o700 });
307
+ const logDir = await mkdtemp(join(logRoot, 'operation-'));
308
+ const logPath = join(logDir, 'pnpm.log');
309
+ const log = __addDisposableResource(env_1, await open(logPath, 'wx', 0o600), true);
310
+ if (active !== undefined) {
311
+ await log.write(active);
312
+ await log.close();
313
+ options.onOutput?.(active, 'stderr');
314
+ const bytes = Buffer.from(active);
315
+ return { exitCode: 1, output: bytes.subarray(Math.max(0, bytes.length - options.outputBytes)).toString('utf8'), truncated: bytes.length > options.outputBytes, logPath };
282
316
  }
283
- };
284
- /** Packages a compatibility check refused; callers render them for their own surface. */
285
- const incompatible = [];
286
- const rejected = async (warnings, restoration) => {
287
- const diagnostic = `\ndsh: installation rejected: ${warnings.join('\n')}\ndsh: ${restoration}.\n`;
288
- await log.write(diagnostic);
289
- options.onOutput?.(diagnostic, 'stderr');
290
- append(Buffer.from(diagnostic));
291
- await log.close();
292
- return { exitCode: 1, output: output.toString('utf8'), truncated, logPath, incompatible };
293
- };
294
- // An install command names the packages it adds, so their manifests are read and checked before
295
- // pnpm runs: an incompatible version is never installed, and the one already in use keeps working.
296
- const preflight = [];
297
- const exemptions = readProfileVersionExemptions(dir);
298
- // The run's own registry flags, so the lookup asks the registry the installation will use.
299
- const registryFlags = args.filter(argument => argument.startsWith('--registry='));
300
- for (const raw of namedSpecs(args)) {
301
- // A spec whose manifest cannot be read or validated is left to the run itself and to the check
302
- // after installation, which reports what it could not validate.
303
- try {
304
- const manifest = await namedSpecManifest(dir, anchorPathSpec(raw, context.cwd), options, environment, registryFlags);
305
- if (manifest === undefined)
317
+ const before = readProfileManifest('dsh', dir);
318
+ const savedFiles = ['package.json', 'pnpm-lock.yaml'].map(name => ({ path: join(dir, name), text: optionalFile(join(dir, name)) }));
319
+ const beforeDependencies = directDependencies(before);
320
+ const installedBefore = new Map(Object.keys(beforeDependencies).map(name => [name, optionalFile(join(dir, 'node_modules', name, 'package.json'))]));
321
+ let output = Buffer.alloc(0);
322
+ let truncated = false;
323
+ const append = (bytes) => {
324
+ output = Buffer.concat([output, bytes]);
325
+ if (output.length > options.outputBytes) {
326
+ truncated = true;
327
+ output = output.subarray(output.length - options.outputBytes);
328
+ }
329
+ };
330
+ const environment = { ...(options.execution === 'cli' ? process.env : scrubbedParentEnv()), ...options.env };
331
+ const restore = async () => {
332
+ for (const file of savedFiles) {
333
+ if (file.text === undefined)
334
+ await rm(file.path, { force: true });
335
+ else
336
+ await writeFileAtomic(file.path, file.text, { mode: 0o600 });
337
+ }
338
+ };
339
+ /** Packages a compatibility check refused; callers render them for their own surface. */
340
+ const incompatible = [];
341
+ const rejected = async (warnings, restoration) => {
342
+ const diagnostic = `\ndsh: installation rejected: ${warnings.join('\n')}\ndsh: ${restoration}.\n`;
343
+ await log.write(diagnostic);
344
+ options.onOutput?.(diagnostic, 'stderr');
345
+ append(Buffer.from(diagnostic));
346
+ await log.close();
347
+ return { exitCode: 1, output: output.toString('utf8'), truncated, logPath, incompatible };
348
+ };
349
+ // An install command names the packages it adds, so their manifests are read and checked before
350
+ // pnpm runs: an incompatible version is never installed, and the one already in use keeps working.
351
+ const preflight = [];
352
+ const exemptions = readProfileVersionExemptions(dir);
353
+ // The run's own registry flags, so the lookup asks the registry the installation will use.
354
+ const registryFlags = args.filter(argument => argument.startsWith('--registry='));
355
+ for (const raw of namedSpecs(args)) {
356
+ // A spec whose manifest cannot be read or validated is left to the run itself and to the check
357
+ // after installation, which reports what it could not validate.
358
+ try {
359
+ const manifest = await namedSpecManifest(dir, anchorPathSpec(raw, context.cwd), options, environment, registryFlags);
360
+ if (manifest === undefined)
361
+ continue;
362
+ const issue = evaluatePluginCompatibility(manifest, exemptions);
363
+ if (issue !== undefined && !issue.exempted) {
364
+ preflight.push(pluginCompatibilityWarning(issue));
365
+ incompatible.push(incompatiblePlugin(issue));
366
+ }
367
+ }
368
+ catch (error) {
369
+ void error;
306
370
  continue;
307
- const issue = evaluatePluginCompatibility(manifest, exemptions);
308
- if (issue !== undefined && !issue.exempted) {
309
- preflight.push(pluginCompatibilityWarning(issue));
310
- incompatible.push(incompatiblePlugin(issue));
311
371
  }
312
372
  }
313
- catch (error) {
314
- void error;
315
- continue;
316
- }
317
- }
318
- if (preflight.length > 0)
319
- return rejected(preflight, 'nothing was installed');
320
- const cancellation = new AbortController();
321
- // A service run captures output, so execa terminates the tree it leads when the
322
- // run is killed: a lifecycle script outlives the pnpm process that started it.
323
- // The CLI keeps the caller's process group, so an interrupt still reaches it.
324
- const grouped = leadsOwnGroup(options.execution);
325
- const child = execa(options.command ?? 'pnpm', [...options.args ?? [], ...args.map(arg => anchorPathSpec(arg, context.cwd))], {
326
- cwd: dir, env: environment, extendEnv: false, reject: false,
327
- stdout: options.execution === 'cli' ? 'inherit' : 'pipe',
328
- stderr: options.execution === 'cli' ? 'inherit' : 'pipe',
329
- killDescendants: options.execution === 'service',
330
- buffer: false, stdin: options.execution === 'cli' ? 'inherit' : 'ignore', cancelSignal: options.signal === undefined
331
- ? cancellation.signal : AbortSignal.any([cancellation.signal, options.signal]),
332
- });
333
- // Listened for before the record is written, so an exit during that write is not missed.
334
- const exited = once(child.nodeChildProcess, 'exit').catch(() => undefined);
335
- let writes = Promise.resolve();
336
- /** `settled` records that the process outcome is known; `stalled` that the silence bound stopped the run. */
337
- const control = { settled: false, stalled: false };
338
- /** Set once this call cuts the reading short itself, so the close it causes is not read as a run failure. */
339
- let cut = false;
340
- /** The first failure a reading hit before that cut, which the run still reports. */
341
- let failure;
342
- let idleTimer;
343
- /** The silence bound: a captured run that stops printing without exiting is terminated, never awaited. */
344
- const armIdle = () => {
345
- if (control.settled || options.idleTimeoutMs === undefined)
346
- return;
347
- clearTimeout(idleTimer);
348
- idleTimer = setTimeout(() => {
349
- control.stalled = true;
350
- // execa's kill reaches the whole tree of a service run, and escalates on its own.
351
- child.kill();
352
- }, options.idleTimeoutMs);
353
- };
354
- const collect = async (stream, kind) => {
373
+ if (preflight.length > 0)
374
+ return await rejected(preflight, 'nothing was installed');
375
+ const cancellation = new AbortController();
376
+ // A service run captures output, so execa terminates the tree it leads when the
377
+ // run is killed: a lifecycle script outlives the pnpm process that started it.
378
+ // The CLI keeps the caller's process group, so an interrupt still reaches it.
379
+ const grouped = leadsOwnGroup(options.execution);
380
+ const child = execa(options.command ?? 'pnpm', [...options.args ?? [], ...args.map(arg => anchorPathSpec(arg, context.cwd))], {
381
+ cwd: dir, env: environment, extendEnv: false, reject: false,
382
+ stdout: options.execution === 'cli' ? 'inherit' : 'pipe',
383
+ stderr: options.execution === 'cli' ? 'inherit' : 'pipe',
384
+ killDescendants: options.execution === 'service',
385
+ buffer: false, stdin: options.execution === 'cli' ? 'inherit' : 'ignore', cancelSignal: options.signal === undefined
386
+ ? cancellation.signal : AbortSignal.any([cancellation.signal, options.signal]),
387
+ });
388
+ // Listened for before the record is written, so an exit during that write is not missed.
389
+ const exited = once(child.nodeChildProcess, 'exit').catch(() => undefined);
390
+ let writes = Promise.resolve();
391
+ /** `settled` records that the process outcome is known; `stalled` that the silence bound stopped the run. */
392
+ const control = { settled: false, stalled: false };
393
+ /** Set once this call cuts the reading short itself, so the close it causes is not read as a run failure. */
394
+ let cut = false;
395
+ /** The first failure a reading hit before that cut, which the run still reports. */
396
+ let failure;
397
+ let idleTimer;
398
+ /** The silence bound: a captured run that stops printing without exiting is terminated, never awaited. */
399
+ const armIdle = () => {
400
+ if (control.settled || options.idleTimeoutMs === undefined)
401
+ return;
402
+ clearTimeout(idleTimer);
403
+ idleTimer = setTimeout(() => {
404
+ control.stalled = true;
405
+ // execa's kill reaches the whole tree of a service run, and escalates on its own.
406
+ child.kill();
407
+ }, options.idleTimeoutMs);
408
+ };
409
+ const collect = async (stream, kind) => {
410
+ try {
411
+ for await (const chunk of stream) {
412
+ armIdle();
413
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
414
+ writes = writes.then(async () => { await log.write(bytes); });
415
+ await writes;
416
+ options.onOutput?.(bytes.toString('utf8'), kind);
417
+ append(bytes);
418
+ }
419
+ }
420
+ catch (error) {
421
+ // A reading this call cut short is not a failure the run hit, and the run has
422
+ // already exited, so there is nothing left for the cancellation to stop.
423
+ if (!cut) {
424
+ failure ??= error instanceof Error ? error : new Error(String(error));
425
+ cancellation.abort();
426
+ }
427
+ throw error;
428
+ }
429
+ };
430
+ const collectors = [
431
+ ...child.stdout === null ? [] : [collect(child.stdout, 'stdout')],
432
+ ...child.stderr === null ? [] : [collect(child.stderr, 'stderr')],
433
+ ];
434
+ // The drain decides whether a failure surfaces, so each reading is claimed now:
435
+ // an unclaimed rejection would be reported as unhandled while the pipes drain.
436
+ for (const collector of collectors)
437
+ void collector.catch(() => { });
438
+ // Inherited descriptors hand pnpm the terminal, so there is no captured output
439
+ // for a silence bound to observe: it would fire on a healthy run.
440
+ if (collectors.length > 0)
441
+ armIdle();
442
+ let exitCode;
355
443
  try {
356
- for await (const chunk of stream) {
357
- armIdle();
358
- const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
359
- writes = writes.then(async () => { await log.write(bytes); });
360
- await writes;
361
- options.onOutput?.(bytes.toString('utf8'), kind);
362
- append(bytes);
444
+ await recordRun(dir, { pid: child.pid, grouped });
445
+ // execa resolves its promise only once the piped stdio has ended, so the
446
+ // process's own exit — the run's completion — is read from the raw child. A
447
+ // spawn failure settles without one.
448
+ const settled = Promise.allSettled([child]);
449
+ await Promise.race([exited, settled]);
450
+ control.settled = true;
451
+ clearTimeout(idleTimer);
452
+ // A stalled run stops its whole tree first, so the caller's rollback and lock
453
+ // release happen only after the scripts it started stopped writing.
454
+ if (control.stalled)
455
+ await awaitTreeGone({ pid: child.pid, grouped });
456
+ // A descendant that inherited the pipes can hold them open past the process;
457
+ // the tail drains under a bound instead of being awaited forever.
458
+ const drained = await drainWithin(collectors, DRAIN_AFTER_EXIT_MS);
459
+ if (drained) {
460
+ for (const stream of await Promise.allSettled(collectors))
461
+ if (stream.status === 'rejected')
462
+ throw stream.reason;
363
463
  }
364
- }
365
- catch (error) {
366
- // A reading this call cut short is not a failure the run hit, and the run has
367
- // already exited, so there is nothing left for the cancellation to stop.
368
- if (!cut) {
369
- failure ??= error instanceof Error ? error : new Error(String(error));
370
- cancellation.abort();
464
+ else {
465
+ // Cutting the tail short is this call's own end, not a failure the run hit;
466
+ // a failure from before the cut still surfaces, and the cut leaves a notice
467
+ // in the log because a classification may read an incomplete tail.
468
+ cut = true;
469
+ child.stdout?.destroy();
470
+ child.stderr?.destroy();
471
+ const notice = 'dsh: pnpm output was cut short after its process exited\n';
472
+ await log.write(notice);
473
+ // A failure from before the cut is the run's own and replaces the notice a
474
+ // caller would otherwise read; a rejection the cut itself causes is its end.
475
+ if (failure !== undefined)
476
+ throw failure;
477
+ options.onOutput?.(notice, 'stderr');
478
+ append(Buffer.from(notice));
371
479
  }
372
- throw error;
373
- }
374
- };
375
- const collectors = [
376
- ...child.stdout === null ? [] : [collect(child.stdout, 'stdout')],
377
- ...child.stderr === null ? [] : [collect(child.stderr, 'stderr')],
378
- ];
379
- // The drain decides whether a failure surfaces, so each reading is claimed now:
380
- // an unclaimed rejection would be reported as unhandled while the pipes drain.
381
- for (const collector of collectors)
382
- void collector.catch(() => { });
383
- // Inherited descriptors hand pnpm the terminal, so there is no captured output
384
- // for a silence bound to observe: it would fire on a healthy run.
385
- if (collectors.length > 0)
386
- armIdle();
387
- let exitCode;
388
- try {
389
- await recordRun(dir, { pid: child.pid, grouped });
390
- // execa resolves its promise only once the piped stdio has ended, so the
391
- // process's own exit — the run's completion — is read from the raw child. A
392
- // spawn failure settles without one.
393
- const settled = Promise.allSettled([child]);
394
- await Promise.race([exited, settled]);
395
- control.settled = true;
396
- clearTimeout(idleTimer);
397
- // A stalled run stops its whole tree first, so the caller's rollback and lock
398
- // release happen only after the scripts it started stopped writing.
399
- if (control.stalled)
400
- await awaitTreeGone({ pid: child.pid, grouped });
401
- // A descendant that inherited the pipes can hold them open past the process;
402
- // the tail drains under a bound instead of being awaited forever.
403
- const drained = await drainWithin(collectors, DRAIN_AFTER_EXIT_MS);
404
- if (drained) {
405
- for (const stream of await Promise.allSettled(collectors))
406
- if (stream.status === 'rejected')
407
- throw stream.reason;
408
- }
409
- else {
410
- // Cutting the tail short is this call's own end, not a failure the run hit;
411
- // a failure from before the cut still surfaces, and the cut leaves a notice
412
- // in the log because a classification may read an incomplete tail.
413
- cut = true;
414
- child.stdout?.destroy();
415
- child.stderr?.destroy();
416
- const notice = 'dsh: pnpm output was cut short after its process exited\n';
417
- await log.write(notice);
418
- // A failure from before the cut is the run's own and replaces the notice a
419
- // caller would otherwise read; a rejection the cut itself causes is its end.
420
- if (failure !== undefined)
421
- throw failure;
422
- options.onOutput?.(notice, 'stderr');
423
- append(Buffer.from(notice));
424
- }
425
- const [completion] = await settled;
426
- if (completion.status === 'rejected')
427
- throw completion.reason;
428
- const result = completion.value;
429
- exitCode = result.exitCode ?? (result.code === 'ENOENT' ? 127 : 1);
430
- if (control.stalled) {
431
- const notice = `dsh: pnpm printed nothing for ${String(options.idleTimeoutMs)}ms and was terminated\n`;
432
- await log.write(notice);
433
- options.onOutput?.(notice, 'stderr');
434
- append(Buffer.from(notice));
435
- }
436
- if (result.failed && output.length === 0) {
437
- const diagnostic = result.shortMessage ?? 'pnpm failed';
438
- await log.write(diagnostic);
439
- truncated = Buffer.byteLength(diagnostic) > options.outputBytes;
440
- output = Buffer.from(diagnostic).subarray(0, options.outputBytes);
441
- }
442
- // A terminated run's exit status says nothing about what it wrote, so it never reconciles the selection.
443
- if (exitCode === 0 && !control.stalled) {
444
- const after = readProfileManifest('dsh', dir);
445
- const warnings = [];
446
- for (const [name, spec] of Object.entries(directDependencies(after))) {
447
- const packageDir = join(dir, 'node_modules', name);
448
- const installed = optionalFile(join(packageDir, 'package.json'));
449
- if (installed === undefined)
450
- continue;
451
- // A dependency this run did not touch never blocks an unrelated operation; profile startup denies it.
452
- const untouched = beforeDependencies[name] === spec && installedBefore.get(name) === installed;
453
- const found = [];
454
- const issues = [];
455
- try {
456
- const manifest = readProfileManifest('dsh', packageDir);
457
- for (const candidate of [manifest, ...bundleComponentManifests(manifest, packageDir, context.installAnchor)]) {
458
- const issue = evaluatePluginCompatibility(candidate, readProfileVersionExemptions(dir));
459
- if (issue !== undefined && !issue.exempted) {
460
- found.push(pluginCompatibilityWarning(issue));
461
- issues.push(incompatiblePlugin(issue));
480
+ const [completion] = await settled;
481
+ if (completion.status === 'rejected')
482
+ throw completion.reason;
483
+ const result = completion.value;
484
+ exitCode = result.exitCode ?? (result.code === 'ENOENT' ? 127 : 1);
485
+ if (control.stalled) {
486
+ const notice = `dsh: pnpm printed nothing for ${String(options.idleTimeoutMs)}ms and was terminated\n`;
487
+ await log.write(notice);
488
+ options.onOutput?.(notice, 'stderr');
489
+ append(Buffer.from(notice));
490
+ }
491
+ if (result.failed && output.length === 0) {
492
+ const diagnostic = result.shortMessage ?? 'pnpm failed';
493
+ await log.write(diagnostic);
494
+ truncated = Buffer.byteLength(diagnostic) > options.outputBytes;
495
+ output = Buffer.from(diagnostic).subarray(0, options.outputBytes);
496
+ }
497
+ // A terminated run's exit status says nothing about what it wrote, so it never reconciles the selection.
498
+ if (exitCode === 0 && !control.stalled) {
499
+ const after = readProfileManifest('dsh', dir);
500
+ const warnings = [];
501
+ for (const [name, spec] of Object.entries(directDependencies(after))) {
502
+ const packageDir = join(dir, 'node_modules', name);
503
+ const installed = optionalFile(join(packageDir, 'package.json'));
504
+ if (installed === undefined)
505
+ continue;
506
+ // A dependency this run did not touch never blocks an unrelated operation; profile startup denies it.
507
+ const untouched = beforeDependencies[name] === spec && installedBefore.get(name) === installed;
508
+ const found = [];
509
+ const issues = [];
510
+ try {
511
+ const manifest = readProfileManifest('dsh', packageDir);
512
+ for (const candidate of [manifest, ...bundleComponentManifests(manifest, packageDir, context.installAnchor)]) {
513
+ const issue = evaluatePluginCompatibility(candidate, readProfileVersionExemptions(dir));
514
+ if (issue !== undefined && !issue.exempted) {
515
+ found.push(pluginCompatibilityWarning(issue));
516
+ issues.push(incompatiblePlugin(issue));
517
+ }
462
518
  }
463
519
  }
520
+ catch (error) {
521
+ found.push(`Cannot validate installed package ${name}: ${String(error)}`);
522
+ }
523
+ if (found.length === 0)
524
+ continue;
525
+ if (!untouched) {
526
+ warnings.push(...found);
527
+ incompatible.push(...issues);
528
+ }
529
+ else {
530
+ 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`;
531
+ await log.write(notice);
532
+ options.onOutput?.(notice, 'stderr');
533
+ }
464
534
  }
465
- catch (error) {
466
- found.push(`Cannot validate installed package ${name}: ${String(error)}`);
467
- }
468
- if (found.length === 0)
469
- continue;
470
- if (!untouched) {
471
- warnings.push(...found);
472
- incompatible.push(...issues);
535
+ if (warnings.length > 0) {
536
+ // A bundle component's peers need installed contents, so this rejection lands after pnpm
537
+ // replaced the tree: restore the files, then reinstall the restored lockfile so the version
538
+ // that worked before this run keeps loading. A profile that had no lockfile is reinstalled
539
+ // from its restored manifest without creating one, which removes what this run added.
540
+ await restore();
541
+ const hadLockfile = savedFiles.some(file => file.path.endsWith('pnpm-lock.yaml') && file.text !== undefined);
542
+ const repair = ['install', hadLockfile ? '--frozen-lockfile' : '--config.lockfile=false'];
543
+ const repairing = execa(options.command ?? 'pnpm', [...options.args ?? [], ...repair], {
544
+ cwd: dir, env: environment, extendEnv: false, reject: false, stdin: 'ignore',
545
+ ...options.idleTimeoutMs === undefined ? {} : { timeout: options.idleTimeoutMs },
546
+ });
547
+ await recordRun(dir, { pid: repairing.pid, grouped: false });
548
+ const repaired = await repairing;
549
+ exitCode = 1;
550
+ const restoration = repaired.exitCode === 0
551
+ ? 'restored package.json, pnpm-lock.yaml, and node_modules'
552
+ : "restored package.json and pnpm-lock.yaml, but node_modules could not be reinstalled; run 'dsh plugin install'";
553
+ const diagnostic = `\ndsh: installation rejected: ${warnings.join('\n')}\ndsh: ${restoration}.\n`;
554
+ await log.write(diagnostic);
555
+ options.onOutput?.(diagnostic, 'stderr');
556
+ append(Buffer.from(diagnostic));
473
557
  }
474
- else {
475
- 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`;
476
- await log.write(notice);
477
- options.onOutput?.(notice, 'stderr');
558
+ else if (options.activateNewBundles !== false) {
559
+ await reconcile(before, dir, context.installAnchor, options);
478
560
  }
479
561
  }
480
- if (warnings.length > 0) {
481
- // A bundle component's peers need installed contents, so this rejection lands after pnpm
482
- // replaced the tree: restore the files, then reinstall the restored lockfile so the version
483
- // that worked before this run keeps loading. A profile that had no lockfile is reinstalled
484
- // from its restored manifest without creating one, which removes what this run added.
485
- await restore();
486
- const hadLockfile = savedFiles.some(file => file.path.endsWith('pnpm-lock.yaml') && file.text !== undefined);
487
- const repair = ['install', hadLockfile ? '--frozen-lockfile' : '--config.lockfile=false'];
488
- const repairing = execa(options.command ?? 'pnpm', [...options.args ?? [], ...repair], {
489
- cwd: dir, env: environment, extendEnv: false, reject: false, stdin: 'ignore',
490
- ...options.idleTimeoutMs === undefined ? {} : { timeout: options.idleTimeoutMs },
491
- });
492
- await recordRun(dir, { pid: repairing.pid, grouped: false });
493
- const repaired = await repairing;
494
- exitCode = 1;
495
- const restoration = repaired.exitCode === 0
496
- ? 'restored package.json, pnpm-lock.yaml, and node_modules'
497
- : "restored package.json and pnpm-lock.yaml, but node_modules could not be reinstalled; run 'dsh plugin install'";
498
- const diagnostic = `\ndsh: installation rejected: ${warnings.join('\n')}\ndsh: ${restoration}.\n`;
499
- await log.write(diagnostic);
500
- options.onOutput?.(diagnostic, 'stderr');
501
- append(Buffer.from(diagnostic));
502
- }
503
- else if (options.activateNewBundles !== false) {
504
- await reconcile(before, dir, context.installAnchor, options);
505
- }
506
562
  }
563
+ finally {
564
+ control.settled = true;
565
+ clearTimeout(idleTimer);
566
+ // This process saw the run end, so no successor has to wait for it.
567
+ await rm(runRecordPath(dir), { force: true });
568
+ await log.close();
569
+ }
570
+ return {
571
+ exitCode, output: output.toString('utf8'), truncated, logPath,
572
+ ...control.stalled ? { timedOut: true } : {}, ...incompatible.length > 0 ? { incompatible } : {},
573
+ };
574
+ }
575
+ catch (e_1) {
576
+ env_1.error = e_1;
577
+ env_1.hasError = true;
507
578
  }
508
579
  finally {
509
- control.settled = true;
510
- clearTimeout(idleTimer);
511
- // This process saw the run end, so no successor has to wait for it.
512
- await rm(runRecordPath(dir), { force: true });
513
- await log.close();
580
+ const result_1 = __disposeResources(env_1);
581
+ if (result_1)
582
+ await result_1;
514
583
  }
515
- return {
516
- exitCode, output: output.toString('utf8'), truncated, logPath,
517
- ...control.stalled ? { timedOut: true } : {}, ...incompatible.length > 0 ? { incompatible } : {},
518
- };
519
584
  }
520
585
  /** Initialize and run the dsh plugin command with the same write lock as the service.
521
586
  * @param context Launcher-owned locations.