@1e0zj/dsh-plugin-mall 0.1.16 → 0.1.18
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/LICENSE +21 -21
- package/README.md +215 -194
- package/cordis.patch.yml +25 -25
- package/package.json +50 -50
- package/src/client.js +694 -610
- package/src/github.js +625 -541
- package/src/index.js +581 -568
- package/src/installer.js +912 -851
package/src/installer.js
CHANGED
|
@@ -1,854 +1,915 @@
|
|
|
1
|
-
// Installation backend: run `pnpm add <spec>` inside a profile directory,
|
|
2
|
-
// reconcile the profile's `dsh.profile.bundles` layer list, and auto-allow
|
|
3
|
-
// blocked build scripts (git-hosted plugins) exactly once.
|
|
4
|
-
//
|
|
5
|
-
// This mirrors what the official `dsh plugin --profile <name> add <spec>`
|
|
6
|
-
// command does (see @deepseek-ai/dsh/lib/plugin-*.js), reusing the public
|
|
7
|
-
// @deepseek-ai/dsh-app-boot APIs for profile resolution and initialization.
|
|
8
|
-
|
|
9
|
-
import { spawn } from "node:child_process";
|
|
10
|
-
import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
|
11
|
-
import { join } from "node:path";
|
|
12
|
-
import { createRequire } from "node:module";
|
|
13
|
-
import { load } from "js-yaml";
|
|
14
|
-
import { DEFAULT_PROFILE_BUNDLES, PROFILE_TEMPLATES, initProfile, resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
if (spec.
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if (
|
|
30
|
-
if (/^[
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
*
|
|
52
|
-
* @param
|
|
53
|
-
* @param
|
|
54
|
-
* @param
|
|
55
|
-
* @
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
*
|
|
132
|
-
* `
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
if (manifest.dsh?.
|
|
141
|
-
return "
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
* @param
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const
|
|
160
|
-
const
|
|
161
|
-
const
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
//
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
let
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
proc.on("
|
|
261
|
-
proc.on("
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
*
|
|
273
|
-
* @
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
const
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
*
|
|
341
|
-
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
const
|
|
350
|
-
const
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
if (lines[index
|
|
355
|
-
if (lines[index +
|
|
356
|
-
lines
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
/**
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
const
|
|
388
|
-
if (
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
406
|
-
*
|
|
407
|
-
*
|
|
408
|
-
*
|
|
409
|
-
*
|
|
410
|
-
*
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
const
|
|
438
|
-
const
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
const
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
const
|
|
452
|
-
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
*/
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
proc
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
};
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
const
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
const
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
}
|
|
1
|
+
// Installation backend: run `pnpm add <spec>` inside a profile directory,
|
|
2
|
+
// reconcile the profile's `dsh.profile.bundles` layer list, and auto-allow
|
|
3
|
+
// blocked build scripts (git-hosted plugins) exactly once.
|
|
4
|
+
//
|
|
5
|
+
// This mirrors what the official `dsh plugin --profile <name> add <spec>`
|
|
6
|
+
// command does (see @deepseek-ai/dsh/lib/plugin-*.js), reusing the public
|
|
7
|
+
// @deepseek-ai/dsh-app-boot APIs for profile resolution and initialization.
|
|
8
|
+
|
|
9
|
+
import { spawn } from "node:child_process";
|
|
10
|
+
import { existsSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { createRequire } from "node:module";
|
|
13
|
+
import { load } from "js-yaml";
|
|
14
|
+
import { DEFAULT_PROFILE_BUNDLES, PROFILE_TEMPLATES, initProfile, resolveProfileDir } from "@deepseek-ai/dsh-app-boot";
|
|
15
|
+
import { describeBuildScripts, npmNameOf } from "./github.js";
|
|
16
|
+
|
|
17
|
+
// ── spec normalization ──────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Normalize a user-supplied install spec into a pnpm-ready argument.
|
|
21
|
+
* "owner/repo" and GitHub URLs become `github:owner/repo`; scoped npm names,
|
|
22
|
+
* schemes, and absolute file/link paths pass through untouched.
|
|
23
|
+
*/
|
|
24
|
+
export function normalizeSpec(raw) {
|
|
25
|
+
const spec = String(raw ?? "").trim();
|
|
26
|
+
if (spec.length === 0) throw new Error("empty install spec: pass \"owner/repo\", a GitHub URL, an npm package name, or a tarball URL");
|
|
27
|
+
if (spec.startsWith("@")) return spec; // scoped npm package like @scope/name[@ver]
|
|
28
|
+
const githubUrl = /^https?:\/\/(?:www\.)?github\.com\/([^/\s]+\/[^/\s]+?)(?:\.git)?(?:\/.*)?$/i.exec(spec);
|
|
29
|
+
if (githubUrl) return `github:${githubUrl[1]}`;
|
|
30
|
+
if (/^(?:github:|git\+|git:|ssh:|npm:|file:|link:|https?:\/\/|\.{1,2}(?:[/\\]|$))/i.test(spec)) return spec;
|
|
31
|
+
if (/^[^@/\s]+\/[^@/\s]+$/.test(spec)) return `github:${spec}`;
|
|
32
|
+
return spec; // bare npm package name
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── guarded config writes ───────────────────────────────────────────────────
|
|
36
|
+
//
|
|
37
|
+
// Installing someone else's plugin must never be able to leave a profile that
|
|
38
|
+
// dsh or pnpm refuses to load. Every write to a shared profile config goes
|
|
39
|
+
// through here: the new bytes are parsed back, and anything that does not
|
|
40
|
+
// parse is rolled back to the previous bytes before the error propagates. A
|
|
41
|
+
// bug in our own editing then costs a failed install, not a profile the user
|
|
42
|
+
// has to repair by hand.
|
|
43
|
+
//
|
|
44
|
+
// This is not hypothetical. Editing `allowBuilds` as a YAML sequence while
|
|
45
|
+
// pnpm had already written its own mapping stub produced a file no parser
|
|
46
|
+
// accepted, and every later pnpm operation in that profile — install,
|
|
47
|
+
// uninstall, update, any plugin at all — failed until the file was fixed
|
|
48
|
+
// manually.
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Write a config file only if the result still parses.
|
|
52
|
+
* @param filePath - the file to write.
|
|
53
|
+
* @param nextContent - the full new contents.
|
|
54
|
+
* @param parse - throws when the content is not valid.
|
|
55
|
+
* @param label - file name used in error messages.
|
|
56
|
+
* @returns a rollback function restoring the pre-write bytes.
|
|
57
|
+
*/
|
|
58
|
+
function writeChecked(filePath, nextContent, parse, label) {
|
|
59
|
+
const previous = existsSync(filePath) ? readFileSync(filePath, "utf8") : undefined;
|
|
60
|
+
const rollback = () => {
|
|
61
|
+
if (previous === undefined) rmSync(filePath, { force: true });
|
|
62
|
+
else writeFileSync(filePath, previous);
|
|
63
|
+
};
|
|
64
|
+
writeFileSync(filePath, nextContent);
|
|
65
|
+
try {
|
|
66
|
+
parse(nextContent);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
rollback();
|
|
69
|
+
throw new Error(`${label} would not parse after the edit and was restored unchanged (${error.message}) — this is a bug in dsh-plugin-mall, please report it`);
|
|
70
|
+
}
|
|
71
|
+
return rollback;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** writeChecked for YAML profile configs. */
|
|
75
|
+
function writeYamlChecked(filePath, nextContent, label) {
|
|
76
|
+
return writeChecked(filePath, nextContent, (text) => load(text), label);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** writeChecked for JSON profile configs. */
|
|
80
|
+
function writeJsonChecked(filePath, nextContent, label) {
|
|
81
|
+
return writeChecked(filePath, nextContent, (text) => JSON.parse(text), label);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* writeChecked for cordis.patch.yml — the loader patch layer. Beyond parsing,
|
|
86
|
+
* the contract is a top-level array; anything else and dsh fails to boot, so
|
|
87
|
+
* that is checked here too rather than discovered at the next start.
|
|
88
|
+
*/
|
|
89
|
+
function writePatchChecked(filePath, nextContent) {
|
|
90
|
+
return writeChecked(filePath, nextContent, (text) => {
|
|
91
|
+
const doc = load(text);
|
|
92
|
+
if (doc !== null && doc !== undefined && !Array.isArray(doc)) {
|
|
93
|
+
throw new Error("expected a top-level array of patch entries");
|
|
94
|
+
}
|
|
95
|
+
}, "cordis.patch.yml");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── profile management ──────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
/** Resolve and initialize (on first use) the target profile directory. */
|
|
101
|
+
export function ensureProfile(profile) {
|
|
102
|
+
const dir = resolveProfileDir(profile); // throws on invalid names
|
|
103
|
+
if (!existsSync(join(dir, "package.json"))) {
|
|
104
|
+
initProfile(dir, PROFILE_TEMPLATES[profile] ?? DEFAULT_PROFILE_BUNDLES);
|
|
105
|
+
}
|
|
106
|
+
return dir;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Read the profile's package.json manifest. */
|
|
110
|
+
function readManifest(profileDir) {
|
|
111
|
+
return JSON.parse(readFileSync(join(profileDir, "package.json"), "utf8"));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Locate an installed dependency's package.json under the profile tree. */
|
|
115
|
+
function packageJsonPathOf(packageName, profileDir) {
|
|
116
|
+
const direct = join(profileDir, "node_modules", ...packageName.split("/"), "package.json");
|
|
117
|
+
if (existsSync(direct)) return direct;
|
|
118
|
+
try {
|
|
119
|
+
return createRequire(join(profileDir, "noop.js")).resolve(`${packageName}/package.json`);
|
|
120
|
+
} catch {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Whether an installed dependency declares `dsh.bundle.patch` (is a plugin bundle). */
|
|
126
|
+
function isBundlePackage(packageName, profileDir) {
|
|
127
|
+
return classifyPackage(packageName, profileDir) === "bundle";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Classify an installed package by its `dsh` declaration:
|
|
132
|
+
* `bundle` (a profile patch layer), `client` (a browser-side UI plugin),
|
|
133
|
+
* `plain` (a plain dependency), or `missing` (not resolvable).
|
|
134
|
+
*/
|
|
135
|
+
export function classifyPackage(packageName, profileDir) {
|
|
136
|
+
const path = packageJsonPathOf(packageName, profileDir);
|
|
137
|
+
if (path === undefined) return "missing";
|
|
138
|
+
try {
|
|
139
|
+
const manifest = JSON.parse(readFileSync(path, "utf8"));
|
|
140
|
+
if (typeof manifest.dsh?.bundle?.patch === "string") return "bundle";
|
|
141
|
+
if (manifest.dsh?.client !== undefined) return "client";
|
|
142
|
+
return "plain";
|
|
143
|
+
} catch {
|
|
144
|
+
return "missing";
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Reconcile `dsh.profile.bundles` against installed dependencies: a
|
|
150
|
+
* dependency that declares a dsh bundle joins the layer stack (in dependency
|
|
151
|
+
* order); an entry that was a dependency but no longer is one — removed, or
|
|
152
|
+
* the installed version dropped its bundle declaration — leaves it. In-box
|
|
153
|
+
* template bundles are not dependencies and are NEVER touched (mirroring the
|
|
154
|
+
* official `dsh plugin` reconcile).
|
|
155
|
+
* @param profileDir - the profile directory.
|
|
156
|
+
* @param beforeDeps - dependency keys as they were before `pnpm add` ran.
|
|
157
|
+
*/
|
|
158
|
+
export function reconcileBundles(profileDir, beforeDeps = new Set()) {
|
|
159
|
+
const manifestPath = join(profileDir, "package.json");
|
|
160
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
161
|
+
const dependencies = new Set(Object.keys(manifest.dependencies ?? {}));
|
|
162
|
+
const bundles = [...(manifest.dsh?.profile?.bundles ?? [])];
|
|
163
|
+
const result = [];
|
|
164
|
+
for (const bundleName of bundles) {
|
|
165
|
+
const wasDependency = beforeDeps.has(bundleName) || dependencies.has(bundleName);
|
|
166
|
+
const stillBundle = dependencies.has(bundleName) && isBundlePackage(bundleName, profileDir);
|
|
167
|
+
// Keep template bundles and dependency-bundles; drop only entries that
|
|
168
|
+
// were dependencies and stopped being bundles.
|
|
169
|
+
if (!wasDependency || stillBundle) result.push(bundleName);
|
|
170
|
+
}
|
|
171
|
+
for (const dependencyName of dependencies) {
|
|
172
|
+
if (!result.includes(dependencyName) && isBundlePackage(dependencyName, profileDir)) result.push(dependencyName);
|
|
173
|
+
}
|
|
174
|
+
if (JSON.stringify(result) !== JSON.stringify(bundles)) {
|
|
175
|
+
manifest.dsh = {
|
|
176
|
+
...manifest.dsh,
|
|
177
|
+
profile: {
|
|
178
|
+
...manifest.dsh?.profile,
|
|
179
|
+
bundles: result,
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
writeJsonChecked(manifestPath, JSON.stringify(manifest, undefined, 2) + "\n", "package.json");
|
|
183
|
+
}
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** List a profile's installed plugins (dependencies with classification + version). */
|
|
188
|
+
export function listInstalled(profile) {
|
|
189
|
+
const dir = resolveProfileDir(profile);
|
|
190
|
+
const manifestPath = join(dir, "package.json");
|
|
191
|
+
if (!existsSync(manifestPath)) return { dir, deps: [] };
|
|
192
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
193
|
+
const deps = Object.keys(manifest.dependencies ?? {}).map((name) => {
|
|
194
|
+
const path = packageJsonPathOf(name, dir);
|
|
195
|
+
let version = "?";
|
|
196
|
+
let kind = "missing";
|
|
197
|
+
if (path !== undefined) {
|
|
198
|
+
try {
|
|
199
|
+
const installed = JSON.parse(readFileSync(path, "utf8"));
|
|
200
|
+
version = installed.version ?? "?";
|
|
201
|
+
kind = classifyPackage(name, dir);
|
|
202
|
+
} catch {
|
|
203
|
+
/* keep defaults */
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return { name, version, kind };
|
|
207
|
+
});
|
|
208
|
+
return { dir, deps };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── npm registry resolution ─────────────────────────────────────────────────
|
|
212
|
+
//
|
|
213
|
+
// Registry lookups (anti-squatting, update checks, the host-shadow guard) have
|
|
214
|
+
// to hit the same registry pnpm installs from. Hardcoding npmjs while the user
|
|
215
|
+
// is on a mirror breaks all three silently — see the header comment in
|
|
216
|
+
// github.js. Resolution order mirrors pnpm's own: the profile's .npmrc, then
|
|
217
|
+
// `pnpm config get registry` (which folds in the user and global .npmrc
|
|
218
|
+
// chain), then npmjs. Cached per profile for the process lifetime; changing a
|
|
219
|
+
// registry needs a dsh restart anyway, like every other profile setting.
|
|
220
|
+
|
|
221
|
+
const DEFAULT_NPM_REGISTRY = "https://registry.npmjs.org";
|
|
222
|
+
const registryCache = new Map(); // profile -> Promise<string>
|
|
223
|
+
|
|
224
|
+
/** The `registry=` value from a profile-local .npmrc, if it sets one. */
|
|
225
|
+
function registryFromNpmrc(profileDir) {
|
|
226
|
+
const npmrcPath = join(profileDir, ".npmrc");
|
|
227
|
+
if (!existsSync(npmrcPath)) return undefined;
|
|
228
|
+
try {
|
|
229
|
+
for (const line of readFileSync(npmrcPath, "utf8").split("\n")) {
|
|
230
|
+
const match = /^\s*registry\s*=\s*(\S+)\s*$/.exec(line);
|
|
231
|
+
if (match !== null) return match[1];
|
|
232
|
+
}
|
|
233
|
+
} catch {
|
|
234
|
+
/* unreadable .npmrc — fall through to pnpm */
|
|
235
|
+
}
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** `pnpm config get registry`, or undefined if pnpm is missing, slow, or unset. */
|
|
240
|
+
function registryFromPnpm() {
|
|
241
|
+
return new Promise((resolve) => {
|
|
242
|
+
let proc;
|
|
243
|
+
try {
|
|
244
|
+
proc = spawn("pnpm", ["config", "get", "registry"], {
|
|
245
|
+
env: process.env,
|
|
246
|
+
shell: process.platform === "win32",
|
|
247
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
248
|
+
windowsHide: true,
|
|
249
|
+
});
|
|
250
|
+
} catch {
|
|
251
|
+
resolve(undefined);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
let out = "";
|
|
255
|
+
// 一次安装不该被一个探测子进程拖住:5s 没结果就当没有,走兜底。
|
|
256
|
+
const timer = setTimeout(() => {
|
|
257
|
+
proc.kill();
|
|
258
|
+
resolve(undefined);
|
|
259
|
+
}, 5000);
|
|
260
|
+
proc.stdout?.on("data", (data) => { out += data.toString(); });
|
|
261
|
+
proc.on("error", () => { clearTimeout(timer); resolve(undefined); });
|
|
262
|
+
proc.on("close", (code) => {
|
|
263
|
+
clearTimeout(timer);
|
|
264
|
+
const value = out.trim();
|
|
265
|
+
// pnpm prints "undefined" for an unset key — only take a real URL.
|
|
266
|
+
resolve(code === 0 && /^https?:\/\//i.test(value) ? value : undefined);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* The registry pnpm installs from for this profile.
|
|
273
|
+
* @param profile - profile name.
|
|
274
|
+
* @returns a promise for the registry base URL, without a trailing slash.
|
|
275
|
+
*/
|
|
276
|
+
export function resolveRegistry(profile) {
|
|
277
|
+
const key = String(profile ?? "");
|
|
278
|
+
const cached = registryCache.get(key);
|
|
279
|
+
if (cached !== undefined) return cached;
|
|
280
|
+
const pending = (async () => {
|
|
281
|
+
let dir;
|
|
282
|
+
try {
|
|
283
|
+
dir = resolveProfileDir(key);
|
|
284
|
+
} catch {
|
|
285
|
+
dir = undefined; // invalid profile name — the caller reports it, we just fall back
|
|
286
|
+
}
|
|
287
|
+
const fromNpmrc = dir === undefined ? undefined : registryFromNpmrc(dir);
|
|
288
|
+
if (fromNpmrc !== undefined) return fromNpmrc.replace(/\/+$/, "");
|
|
289
|
+
const fromPnpm = await registryFromPnpm();
|
|
290
|
+
return (fromPnpm ?? DEFAULT_NPM_REGISTRY).replace(/\/+$/, "");
|
|
291
|
+
})();
|
|
292
|
+
registryCache.set(key, pending);
|
|
293
|
+
return pending;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ── client-plugin row registration ──────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
/** Profile patch file name (the user's own layer, applied after bundle layers). */
|
|
299
|
+
const PROFILE_PATCH_FILENAME = "cordis.patch.yml";
|
|
300
|
+
|
|
301
|
+
/** Derive a friendly row id from a package name: "@s/dsh-client-ui-aqua" -> "ui-aqua". */
|
|
302
|
+
function clientRowId(packageName) {
|
|
303
|
+
const last = packageName.split("/").pop() ?? packageName;
|
|
304
|
+
const trimmed = last.replace(/^dsh-/, "").replace(/^client-ui-/, "").replace(/^client-/, "");
|
|
305
|
+
return trimmed.length > 0 ? trimmed : last;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Idempotently register a `dsh.client` package as a loader row in the
|
|
310
|
+
* profile's cordis.patch.yml (client packages are discovered from the loader
|
|
311
|
+
* entry tree, so the dependency alone does not activate them).
|
|
312
|
+
* A row whose `name` already exists (any id) is left untouched.
|
|
313
|
+
* @returns {{ added: boolean, rowId?: string }}
|
|
314
|
+
*/
|
|
315
|
+
export function ensureClientRow(profileDir, packageName) {
|
|
316
|
+
const patchPath = join(profileDir, PROFILE_PATCH_FILENAME);
|
|
317
|
+
const content = existsSync(patchPath) ? readFileSync(patchPath, "utf8") : "[]\n";
|
|
318
|
+
let parsed = null;
|
|
319
|
+
try {
|
|
320
|
+
parsed = load(content);
|
|
321
|
+
} catch {
|
|
322
|
+
/* a text-level name check still runs below */
|
|
323
|
+
}
|
|
324
|
+
const alreadyByName = (Array.isArray(parsed) && parsed.some((entry) =>
|
|
325
|
+
Array.isArray(entry?.insert) && entry.insert.some((row) => row?.name === packageName)
|
|
326
|
+
)) || new RegExp(`name:\\s*["']${packageName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}["']`).test(content);
|
|
327
|
+
if (alreadyByName) return { added: false };
|
|
328
|
+
const rowId = clientRowId(packageName);
|
|
329
|
+
const block = `- insert:\n - id: ${rowId}\n name: '${packageName}'\n`;
|
|
330
|
+
const next = (Array.isArray(parsed) && parsed.length === 0)
|
|
331
|
+
// The stock template is a comment plus `[]`; replace it wholesale.
|
|
332
|
+
? block
|
|
333
|
+
: content.endsWith("\n") ? `${content}${block}` : `${content}\n${block}`;
|
|
334
|
+
// 这个文件是 dsh 的装配补丁层:写坏了宿主直接起不来。
|
|
335
|
+
writePatchChecked(patchPath, next);
|
|
336
|
+
return { added: true, rowId };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Remove the cordis.patch.yml loader row `ensureClientRow` registered for a
|
|
341
|
+
* package. Textual and idempotent: only the exact block the register emitted
|
|
342
|
+
* is spliced out, so user-authored rows survive byte-for-byte. An emptied
|
|
343
|
+
* file falls back to the stock `[]` template.
|
|
344
|
+
* @returns {{ removed: boolean, rowId?: string }}
|
|
345
|
+
*/
|
|
346
|
+
export function removeClientRow(profileDir, packageName) {
|
|
347
|
+
const patchPath = join(profileDir, PROFILE_PATCH_FILENAME);
|
|
348
|
+
if (!existsSync(patchPath)) return { removed: false };
|
|
349
|
+
const content = readFileSync(patchPath, "utf8");
|
|
350
|
+
const rowId = clientRowId(packageName);
|
|
351
|
+
const lines = content.split("\n");
|
|
352
|
+
let removed = false;
|
|
353
|
+
for (let index = 0; index < lines.length - 2; index++) {
|
|
354
|
+
if (lines[index] !== "- insert:") continue;
|
|
355
|
+
if (lines[index + 1] !== ` - id: ${rowId}`) continue;
|
|
356
|
+
if (lines[index + 2] !== ` name: '${packageName}'`) continue;
|
|
357
|
+
lines.splice(index, 3);
|
|
358
|
+
removed = true;
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
if (!removed) return { removed: false };
|
|
362
|
+
const next = lines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
363
|
+
writePatchChecked(patchPath, next.length === 0 ? "[]\n" : `${next}\n`);
|
|
364
|
+
return { removed: true, rowId };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ── build-script allow-listing ──────────────────────────────────────────────
|
|
368
|
+
|
|
369
|
+
/** Extract package names from pnpm's "Ignored build scripts: ..." output. */
|
|
370
|
+
/** Valid npm package name (scoped or bare) — anything else is not a name. */
|
|
371
|
+
const NPM_NAME_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i;
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* @returns `{name, version}` per blocked package. The version matters: the
|
|
375
|
+
* disclosure has to show the scripts of the version that would actually run,
|
|
376
|
+
* not whatever `latest` happens to be today.
|
|
377
|
+
*/
|
|
378
|
+
function parseIgnoredBuilds(output) {
|
|
379
|
+
const found = new Map();
|
|
380
|
+
// Only pnpm's own notice line is a parsing source. "allowBuilds" also
|
|
381
|
+
// appears in pnpm's advice/error text (never followed by a name list), and
|
|
382
|
+
// matching it fed error echoes into the allow-list, corrupting the YAML.
|
|
383
|
+
const pattern = /(?:Ignored build scripts|onlyBuiltDependencies)\s*:\s*([^\n]+)/gi;
|
|
384
|
+
let match;
|
|
385
|
+
while ((match = pattern.exec(output)) !== null) {
|
|
386
|
+
for (const raw of match[1].split(",")) {
|
|
387
|
+
const candidate = raw.trim();
|
|
388
|
+
if (candidate.length === 0) continue;
|
|
389
|
+
// Split a trailing @version or @tarball-url so `foo@1.2.3` -> `foo` +
|
|
390
|
+
// `1.2.3`, `@s/n@1.0.0` -> `@s/n` + `1.0.0`. Whatever remains must be a
|
|
391
|
+
// valid npm name; pnpm error echoes ("9 | - pkg", advice sentences) are
|
|
392
|
+
// dropped instead of being written to the YAML.
|
|
393
|
+
const suffix = /@(?:([\w.+-]+)|https?:\/\/\S+|file:\S+|link:\S+|github:\S+)$/.exec(candidate);
|
|
394
|
+
const name = suffix === null ? candidate : candidate.slice(0, suffix.index);
|
|
395
|
+
if (NPM_NAME_RE.test(name) && !found.has(name)) found.set(name, { name, version: suffix?.[1] });
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return [...found.values()];
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Merge names into the profile's `pnpm-workspace.yaml` `allowBuilds`.
|
|
403
|
+
*
|
|
404
|
+
* pnpm accepts both shapes — a sequence (`- name`) and a mapping
|
|
405
|
+
* (`name: true`) — but never both under one key, and pnpm writes a mapping
|
|
406
|
+
* stub of its own (`name: set this to true or false`) when it blocks a build.
|
|
407
|
+
* Appending a sequence item to that stub is what produced an unparseable file.
|
|
408
|
+
* So: read the current shape through a real YAML parse, match it, and default
|
|
409
|
+
* to pnpm's own mapping shape when there is nothing to match, which keeps our
|
|
410
|
+
* edits and pnpm's from ever colliding again.
|
|
411
|
+
*
|
|
412
|
+
* A name already present as a mapping entry whose value is NOT `true` (pnpm's
|
|
413
|
+
* undecided stub) is rewritten rather than skipped — treating the stub as
|
|
414
|
+
* "already allowed" would leave the build still blocked on retry.
|
|
415
|
+
*
|
|
416
|
+
* Pure: takes the current file contents, returns the new contents (or
|
|
417
|
+
* undefined when nothing needs changing). The fs half is ensureAllowBuilds.
|
|
418
|
+
* Split out because this text surgery is the part that broke a profile, so it
|
|
419
|
+
* is the part the fixtures at the bottom of this file have to pin.
|
|
420
|
+
*
|
|
421
|
+
* @param content - current pnpm-workspace.yaml contents.
|
|
422
|
+
* @param names - package names to allow.
|
|
423
|
+
* @returns the new contents, or undefined when already satisfied.
|
|
424
|
+
* @throws when `content` does not parse as YAML.
|
|
425
|
+
*/
|
|
426
|
+
export function mergeAllowBuilds(content, names) {
|
|
427
|
+
const valid = (Array.isArray(names) ? names : []).filter((name) => NPM_NAME_RE.test(name));
|
|
428
|
+
if (valid.length === 0) return undefined;
|
|
429
|
+
// A file that is already broken is not ours to edit — we could only make it
|
|
430
|
+
// worse, and the user needs to see the real reason.
|
|
431
|
+
let parsed;
|
|
432
|
+
try {
|
|
433
|
+
parsed = load(content);
|
|
434
|
+
} catch (error) {
|
|
435
|
+
throw new Error(`pnpm-workspace.yaml does not parse, refusing to edit it: ${error.message}`);
|
|
436
|
+
}
|
|
437
|
+
const current = parsed?.allowBuilds;
|
|
438
|
+
const asSequence = Array.isArray(current);
|
|
439
|
+
const asMapping = !asSequence && current !== null && typeof current === "object";
|
|
440
|
+
const allowed = new Set(asSequence
|
|
441
|
+
? current.map((entry) => String(entry))
|
|
442
|
+
: asMapping ? Object.entries(current).filter(([, value]) => value === true).map(([key]) => key) : []);
|
|
443
|
+
// pnpm 的未决占位符(值不是 true)要改写,不能当成已放行跳过。
|
|
444
|
+
const stubs = asMapping ? valid.filter((name) => name in current && current[name] !== true) : [];
|
|
445
|
+
const additions = valid.filter((name) => !allowed.has(name) && !stubs.includes(name));
|
|
446
|
+
if (additions.length === 0 && stubs.length === 0) return undefined;
|
|
447
|
+
|
|
448
|
+
// Quote sequence entries: a bare `@scope/name` opens with YAML's reserved
|
|
449
|
+
// `@` indicator. Mapping keys do not need it.
|
|
450
|
+
const render = (name) => (asSequence ? ` - '${name}'` : ` ${name}: true`);
|
|
451
|
+
const lines = content.split("\n");
|
|
452
|
+
for (const name of stubs) {
|
|
453
|
+
const pattern = new RegExp(`^(\\s*)(['"]?)${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\2\\s*:.*$`);
|
|
454
|
+
const index = lines.findIndex((line) => pattern.test(line));
|
|
455
|
+
if (index !== -1) lines[index] = ` ${name}: true`;
|
|
456
|
+
}
|
|
457
|
+
let next;
|
|
458
|
+
const keyIndex = lines.findIndex((line) => /^allowBuilds\s*:/.test(line));
|
|
459
|
+
if (keyIndex === -1) {
|
|
460
|
+
const base = lines.join("\n");
|
|
461
|
+
next = `${base.endsWith("\n") ? base : `${base}\n`}\nallowBuilds:\n${additions.map(render).join("\n")}\n`;
|
|
462
|
+
} else {
|
|
463
|
+
// 块内 = 缩进行;空行不终止块;顶格行是下一个 key。
|
|
464
|
+
let insertIndex = keyIndex + 1;
|
|
465
|
+
for (let index = keyIndex + 1; index < lines.length; index++) {
|
|
466
|
+
if (lines[index].trim().length === 0) continue;
|
|
467
|
+
if (/^\s/.test(lines[index])) { insertIndex = index + 1; continue; }
|
|
468
|
+
break;
|
|
469
|
+
}
|
|
470
|
+
if (additions.length > 0) lines.splice(insertIndex, 0, ...additions.map(render));
|
|
471
|
+
next = lines.join("\n");
|
|
472
|
+
}
|
|
473
|
+
return next;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** Default pnpm-workspace.yaml for a profile that has none yet. */
|
|
477
|
+
const DEFAULT_WORKSPACE_YAML = "packages:\n - .\n\nnodeLinker: hoisted\n";
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Apply mergeAllowBuilds to the profile's pnpm-workspace.yaml through the
|
|
481
|
+
* guarded writer.
|
|
482
|
+
* @returns a rollback function, or undefined when nothing needed changing.
|
|
483
|
+
*/
|
|
484
|
+
function ensureAllowBuilds(profileDir, names) {
|
|
485
|
+
const workspacePath = join(profileDir, "pnpm-workspace.yaml");
|
|
486
|
+
const content = existsSync(workspacePath) ? readFileSync(workspacePath, "utf8") : DEFAULT_WORKSPACE_YAML;
|
|
487
|
+
const next = mergeAllowBuilds(content, names);
|
|
488
|
+
if (next === undefined) return undefined;
|
|
489
|
+
return writeYamlChecked(workspacePath, next, "pnpm-workspace.yaml");
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ── in-process install tracker (browser RPC surface) ────────────────────────
|
|
493
|
+
//
|
|
494
|
+
// The web host plane has no job controller (dsh-tool-jobs mounts per agent
|
|
495
|
+
// session), so ctx.jobs refuses background installs started outside an agent
|
|
496
|
+
// turn. The /market RPC channel therefore tracks its own installs in-process:
|
|
497
|
+
// same producer shape as the jobs registry ({cancel, done, readOutput}), just
|
|
498
|
+
// an independent registry.
|
|
499
|
+
|
|
500
|
+
let trackerCounter = 0;
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Create a tracker for browser-started install/uninstall jobs (see runInstall
|
|
504
|
+
* and runRemove). Same producer shape as the jobs registry ({cancel, done,
|
|
505
|
+
* readOutput}), just an independent registry.
|
|
506
|
+
*/
|
|
507
|
+
export function createJobTracker() {
|
|
508
|
+
const records = new Map();
|
|
509
|
+
const prune = () => {
|
|
510
|
+
const now = Date.now();
|
|
511
|
+
for (const [id, record] of records) {
|
|
512
|
+
const terminal = record.status !== "running";
|
|
513
|
+
if (terminal && record.finishedAt !== undefined && now - record.finishedAt > 3600000) records.delete(id);
|
|
514
|
+
}
|
|
515
|
+
if (records.size > 20) {
|
|
516
|
+
const ordered = [...records.entries()].sort((a, b) => a[1].startedAt - b[1].startedAt);
|
|
517
|
+
for (const [id, record] of ordered) {
|
|
518
|
+
if (records.size <= 20) break;
|
|
519
|
+
if (record.status !== "running") records.delete(id);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
return {
|
|
524
|
+
start({ profile, spec, verb = "add", allowBuildScripts }) {
|
|
525
|
+
const id = `market-${++trackerCounter}`;
|
|
526
|
+
const kind = verb === "remove" ? "dsh-plugin-uninstall" : "dsh-plugin-install";
|
|
527
|
+
const producer = verb === "remove" ? runRemove({ profile, packageName: spec }) : runInstall({ profile, spec, allowBuildScripts });
|
|
528
|
+
const record = {
|
|
529
|
+
id,
|
|
530
|
+
kind,
|
|
531
|
+
label: `dsh plugin --profile ${profile} ${verb} ${spec}`,
|
|
532
|
+
profile,
|
|
533
|
+
spec,
|
|
534
|
+
status: "running",
|
|
535
|
+
detail: undefined,
|
|
536
|
+
startedAt: Date.now(),
|
|
537
|
+
finishedAt: undefined,
|
|
538
|
+
producer,
|
|
539
|
+
};
|
|
540
|
+
producer.done.then((outcome) => {
|
|
541
|
+
record.status = outcome.status ?? "failed";
|
|
542
|
+
record.detail = outcome.detail;
|
|
543
|
+
// 待批准的构建脚本清单:浏览器侧据此渲染「允许并继续」,没有它就只有
|
|
544
|
+
// 一段文本,用户看不出要批准的到底是什么。
|
|
545
|
+
record.needsApproval = outcome.needsApproval;
|
|
546
|
+
record.finishedAt = Date.now();
|
|
547
|
+
});
|
|
548
|
+
records.set(id, record);
|
|
549
|
+
prune();
|
|
550
|
+
return id;
|
|
551
|
+
},
|
|
552
|
+
get(jobId) {
|
|
553
|
+
const record = records.get(String(jobId));
|
|
554
|
+
if (record === undefined) throw new Error(`unknown install job ${JSON.stringify(String(jobId))}`);
|
|
555
|
+
return {
|
|
556
|
+
snapshot: {
|
|
557
|
+
id: record.id,
|
|
558
|
+
kind: record.kind,
|
|
559
|
+
label: record.label,
|
|
560
|
+
status: record.status,
|
|
561
|
+
detail: record.detail,
|
|
562
|
+
needsApproval: record.needsApproval,
|
|
563
|
+
spec: record.spec,
|
|
564
|
+
startedAt: record.startedAt,
|
|
565
|
+
finishedAt: record.finishedAt,
|
|
566
|
+
},
|
|
567
|
+
output: record.producer.readOutput(),
|
|
568
|
+
};
|
|
569
|
+
},
|
|
570
|
+
cancel(jobId) {
|
|
571
|
+
const record = records.get(String(jobId));
|
|
572
|
+
if (record === undefined) throw new Error(`unknown install job ${JSON.stringify(String(jobId))}`);
|
|
573
|
+
record.producer.cancel();
|
|
574
|
+
return "requested";
|
|
575
|
+
},
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// ── spec shape guard ────────────────────────────────────────────────────────
|
|
580
|
+
|
|
581
|
+
// Windows spawn 走 shell,spec 会被拼进 cmd 行;agent 传入的参数不可信。
|
|
582
|
+
// 合法的 npm 名 / github:owner\/repo / git·file·link·URL spec 都不含这些
|
|
583
|
+
// shell 元字符——出现即拒绝,宁可误杀不放开命令注入面。
|
|
584
|
+
const UNSAFE_SPEC_RE = /[;&|`$()<>^"!*\n\r]/;
|
|
585
|
+
|
|
586
|
+
/** Reject install/remove specs carrying shell metacharacters. */
|
|
587
|
+
export function assertSafeSpec(spec) {
|
|
588
|
+
const value = String(spec ?? "");
|
|
589
|
+
if (UNSAFE_SPEC_RE.test(value)) {
|
|
590
|
+
throw new Error(`spec contains characters that are not allowed in an install spec: ${JSON.stringify(value)}`);
|
|
591
|
+
}
|
|
592
|
+
// Windows 下 pnpm 走 shell,而 Node 只是把参数用空格 join 后交给 cmd,
|
|
593
|
+
// 不逐参加引号——带空格的本地路径会被拆成两个参数,pnpm 报一个和路径
|
|
594
|
+
// 毫无关系的错。用户也没法自己加引号绕过:`"` 就在上面的黑名单里。
|
|
595
|
+
// 与其让它以看不懂的方式失败,不如在这里说清楚。
|
|
596
|
+
if (process.platform === "win32" && /^(?:file:|link:)/i.test(value) && /\s/.test(value)) {
|
|
597
|
+
throw new Error(`local path specs cannot contain spaces on Windows — pnpm is spawned through cmd, which would split the path into two arguments: ${JSON.stringify(value)}`);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ── pnpm self-heal (corepack) ───────────────────────────────────────────────
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Try to provision pnpm once via `corepack enable pnpm` (corepack ships with
|
|
605
|
+
* Node). Output lands in the caller's job log; returns whether a retry of the
|
|
606
|
+
* pnpm spawn is worth attempting.
|
|
607
|
+
*/
|
|
608
|
+
async function enablePnpmViaCorepack(push) {
|
|
609
|
+
push("\n[dsh-plugin-mall] pnpm not found on PATH — trying `corepack enable pnpm` once\n");
|
|
610
|
+
return await new Promise((resolve) => {
|
|
611
|
+
let proc;
|
|
612
|
+
try {
|
|
613
|
+
proc = spawn("corepack", ["enable", "pnpm"], {
|
|
614
|
+
env: process.env,
|
|
615
|
+
shell: process.platform === "win32",
|
|
616
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
617
|
+
windowsHide: true,
|
|
618
|
+
});
|
|
619
|
+
} catch {
|
|
620
|
+
resolve(false);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
proc.on("error", () => resolve(false));
|
|
624
|
+
proc.stdout?.on("data", (data) => push(data.toString()));
|
|
625
|
+
proc.stderr?.on("data", (data) => push(data.toString()));
|
|
626
|
+
proc.on("close", (code) => resolve(code === 0));
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ── the background install job ──────────────────────────────────────────────
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Run `pnpm add <spec>` in the profile directory as a job producer with the
|
|
634
|
+
* shape `ctx.jobs.start` expects: `{ cancel, done: Promise<outcome>, readOutput: () => string }`.
|
|
635
|
+
* A failure whose output lists ignored build scripts gets one automatic
|
|
636
|
+
* retry after merging those names into `allowBuilds`.
|
|
637
|
+
*/
|
|
638
|
+
/**
|
|
639
|
+
* Render "here is exactly what you would be approving". Deliberately avoids
|
|
640
|
+
* any wording like "security check passed" — approving these scripts says
|
|
641
|
+
* nothing about what the plugin does once it is loaded.
|
|
642
|
+
*/
|
|
643
|
+
function renderApprovalNeeded(spec, disclosure) {
|
|
644
|
+
const lines = [
|
|
645
|
+
`installing ${spec} requires running install-time code — approval needed.`,
|
|
646
|
+
"No install script ran and no plugin code loaded. pnpm did leave the downloaded",
|
|
647
|
+
"files and a dependency entry in the profile (unusable until the scripts run);",
|
|
648
|
+
"approving continues from there, and market_uninstall removes them if you cancel.",
|
|
649
|
+
"",
|
|
650
|
+
];
|
|
651
|
+
for (const entry of disclosure) {
|
|
652
|
+
const origin = entry.direct
|
|
653
|
+
? "the plugin itself"
|
|
654
|
+
: "a transitive dependency — NOT the package you asked for";
|
|
655
|
+
lines.push(` ${entry.name}${entry.version ? `@${entry.version}` : ""} (${origin})`);
|
|
656
|
+
for (const [key, command] of Object.entries(entry.scripts ?? {})) lines.push(` ${key}: ${command}`);
|
|
657
|
+
const facts = [];
|
|
658
|
+
if (typeof entry.weeklyDownloads === "number") facts.push(`${entry.weeklyDownloads.toLocaleString()} weekly downloads`);
|
|
659
|
+
facts.push(entry.provenance === true ? "has provenance" : "no provenance");
|
|
660
|
+
if (typeof entry.unpackedSize === "number") facts.push(`${Math.round(entry.unpackedSize / 104857.6) / 10} MB unpacked`);
|
|
661
|
+
lines.push(` ${facts.join(" · ")}`);
|
|
662
|
+
}
|
|
663
|
+
lines.push("");
|
|
664
|
+
lines.push("These commands run on your machine, with your privileges, before any plugin code loads.");
|
|
665
|
+
lines.push(`To proceed, install again with allowBuildScripts: [${disclosure.map((entry) => JSON.stringify(entry.name)).join(", ")}]`);
|
|
666
|
+
return lines.join("\n");
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
export function runInstall({ profile, spec, allowBuildScripts }) {
|
|
670
|
+
const profileDir = ensureProfile(profile);
|
|
671
|
+
// Dependency keys BEFORE pnpm add, so reconcile only manages entries that
|
|
672
|
+
// were (or became) dependencies and never touches template bundles.
|
|
673
|
+
const beforeDeps = new Set(Object.keys(readManifest(profileDir).dependencies ?? {}));
|
|
674
|
+
const collected = [];
|
|
675
|
+
const deltaQueue = [];
|
|
676
|
+
const push = (text) => {
|
|
677
|
+
collected.push(text);
|
|
678
|
+
deltaQueue.push(text);
|
|
679
|
+
};
|
|
680
|
+
let current = undefined;
|
|
681
|
+
let pnpmSelfHealed = false;
|
|
682
|
+
|
|
683
|
+
const spawnAdd = () => {
|
|
684
|
+
const proc = spawn("pnpm", ["add", spec, "--reporter=append-only"], {
|
|
685
|
+
cwd: profileDir,
|
|
686
|
+
env: process.env,
|
|
687
|
+
shell: process.platform === "win32",
|
|
688
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
689
|
+
windowsHide: true,
|
|
690
|
+
});
|
|
691
|
+
const done = new Promise((resolve) => {
|
|
692
|
+
proc.on("error", (error) => resolve({ spawnError: error }));
|
|
693
|
+
proc.on("close", (exitCode) => resolve({ exitCode, signal: proc.signalCode }));
|
|
694
|
+
});
|
|
695
|
+
proc.stdout?.on("data", (data) => push(data.toString()));
|
|
696
|
+
proc.stderr?.on("data", (data) => push(data.toString()));
|
|
697
|
+
return { proc, done };
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
/** Post-success accounting: reconcile bundles, register client rows, summarize. */
|
|
701
|
+
const finalizeSuccess = () => {
|
|
702
|
+
const bundles = reconcileBundles(profileDir, beforeDeps);
|
|
703
|
+
const manifest = readManifest(profileDir);
|
|
704
|
+
const currentDeps = new Set(Object.keys(manifest.dependencies ?? {}));
|
|
705
|
+
const clientRows = [];
|
|
706
|
+
const plainDeps = [];
|
|
707
|
+
for (const depName of currentDeps) {
|
|
708
|
+
if (beforeDeps.has(depName)) continue;
|
|
709
|
+
const kind = classifyPackage(depName, profileDir);
|
|
710
|
+
if (kind === "client") {
|
|
711
|
+
const row = ensureClientRow(profileDir, depName);
|
|
712
|
+
if (row.added) clientRows.push(row.rowId);
|
|
713
|
+
} else if (kind !== "bundle") {
|
|
714
|
+
plainDeps.push(depName);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
const notes = [];
|
|
718
|
+
const activeBundles = bundles.filter((name) => currentDeps.has(name));
|
|
719
|
+
if (activeBundles.length > 0) notes.push(`bundle layer(s): ${activeBundles.join(", ")}`);
|
|
720
|
+
if (clientRows.length > 0) notes.push(`registered client row(s) in cordis.patch.yml: ${clientRows.join(", ")}`);
|
|
721
|
+
if (plainDeps.length > 0) notes.push(`plain dependency (no dsh.bundle/dsh.client): ${plainDeps.join(", ")}`);
|
|
722
|
+
const noteText = notes.length > 0 ? ` — ${notes.join("; ")}` : " — installed as a plain dependency (declares no dsh.bundle)";
|
|
723
|
+
return { status: "completed", detail: `installed ${spec} into profile "${profile}"${noteText}. Restart dsh for plugin code to load.` };
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
const settle = async (outcome) => {
|
|
727
|
+
if (outcome.spawnError !== undefined) {
|
|
728
|
+
// pnpm 缺失时先尝试 corepack 自愈一次,成功则重跑安装。
|
|
729
|
+
if (outcome.spawnError.code === "ENOENT" && !pnpmSelfHealed) {
|
|
730
|
+
pnpmSelfHealed = true;
|
|
731
|
+
const healed = await enablePnpmViaCorepack(push);
|
|
732
|
+
if (healed) {
|
|
733
|
+
const retry = spawnAdd();
|
|
734
|
+
current = retry.proc;
|
|
735
|
+
return settle(await retry.done);
|
|
736
|
+
}
|
|
737
|
+
return { status: "failed", detail: "pnpm not found on PATH and `corepack enable pnpm` could not provision it — install pnpm (e.g. `npm i -g pnpm`) to manage profile plugins" };
|
|
738
|
+
}
|
|
739
|
+
const hint = outcome.spawnError.code === "ENOENT"
|
|
740
|
+
? "pnpm not found on PATH — install pnpm (e.g. `corepack enable pnpm`) to manage profile plugins"
|
|
741
|
+
: `could not start pnpm: ${outcome.spawnError.message}`;
|
|
742
|
+
return { status: "failed", detail: hint };
|
|
743
|
+
}
|
|
744
|
+
if (outcome.exitCode === null) {
|
|
745
|
+
return { status: "killed", detail: outcome.signal ? `signal: ${outcome.signal}` : "killed before exit" };
|
|
746
|
+
}
|
|
747
|
+
if (outcome.exitCode === 0) {
|
|
748
|
+
return finalizeSuccess();
|
|
749
|
+
}
|
|
750
|
+
const log = collected.join("");
|
|
751
|
+
const ignored = parseIgnoredBuilds(log);
|
|
752
|
+
if (ignored.length === 0) {
|
|
753
|
+
return { status: "failed", detail: `pnpm add ${spec} failed (exit code ${outcome.exitCode}). See job output.` };
|
|
754
|
+
}
|
|
755
|
+
// 放行构建脚本 = 让这些包在用户机器上、以用户的权限、在任何插件代码加载
|
|
756
|
+
// 之前执行任意命令。这个决定属于用户,不属于我们。所以没有点名同意时就停
|
|
757
|
+
// 在这里——pnpm 拦截的位置恰好在「已下载」与「已执行」之间,此刻什么都还
|
|
758
|
+
// 没跑,profile 也一个字节没动。
|
|
759
|
+
const consented = new Set((Array.isArray(allowBuildScripts) ? allowBuildScripts : []).map((name) => String(name)));
|
|
760
|
+
const missing = ignored.filter((entry) => !consented.has(entry.name));
|
|
761
|
+
if (missing.length > 0) {
|
|
762
|
+
push(`\n[dsh-plugin-mall] pnpm blocked install scripts for: ${ignored.map((entry) => entry.name).join(", ")}\n`);
|
|
763
|
+
push("[dsh-plugin-mall] stopping for approval — no install script ran, nothing is loadable yet.\n");
|
|
764
|
+
let disclosure;
|
|
765
|
+
try {
|
|
766
|
+
disclosure = await describeBuildScripts(missing, {
|
|
767
|
+
registry: await resolveRegistry(profile),
|
|
768
|
+
installedName: npmNameOf(spec) ?? undefined,
|
|
769
|
+
});
|
|
770
|
+
} catch {
|
|
771
|
+
disclosure = missing.map((entry) => ({ ...entry, direct: false }));
|
|
772
|
+
}
|
|
773
|
+
return { status: "failed", detail: renderApprovalNeeded(spec, disclosure), needsApproval: disclosure };
|
|
774
|
+
}
|
|
775
|
+
push(`\n[dsh-plugin-mall] approved install scripts: ${ignored.map((entry) => entry.name).join(", ")}\n`);
|
|
776
|
+
push("[dsh-plugin-mall] allowing them in the profile's pnpm-workspace.yaml and retrying once.\n");
|
|
777
|
+
let rollbackAllowBuilds;
|
|
778
|
+
try {
|
|
779
|
+
rollbackAllowBuilds = ensureAllowBuilds(profileDir, ignored.map((entry) => entry.name));
|
|
780
|
+
} catch (error) {
|
|
781
|
+
return { status: "failed", detail: `could not allow the blocked build scripts: ${error.message}. The profile was left untouched — approve them yourself with \`pnpm approve-builds\` in ${profileDir}, then retry.` };
|
|
782
|
+
}
|
|
783
|
+
// allowBuilds 是持久化的安全配置。为一次没装成的插件单向放宽它,等于以后
|
|
784
|
+
// 这个包名再出现(哪怕是别人的传递依赖)就静默放行——失败必须收回。
|
|
785
|
+
const revert = () => {
|
|
786
|
+
if (rollbackAllowBuilds === undefined) return;
|
|
787
|
+
try {
|
|
788
|
+
rollbackAllowBuilds();
|
|
789
|
+
push("[dsh-plugin-mall] install failed — reverted the allowBuilds change, the profile is as it was\n");
|
|
790
|
+
} catch {
|
|
791
|
+
/* 还原失败不该盖掉真正的失败原因 */
|
|
792
|
+
}
|
|
793
|
+
};
|
|
794
|
+
const retry = spawnAdd();
|
|
795
|
+
current = retry.proc;
|
|
796
|
+
const retryOutcome = await retry.done;
|
|
797
|
+
if (retryOutcome.spawnError !== undefined) {
|
|
798
|
+
revert();
|
|
799
|
+
return { status: "failed", detail: `retry could not start pnpm: ${retryOutcome.spawnError.message}` };
|
|
800
|
+
}
|
|
801
|
+
if (retryOutcome.exitCode === 0) {
|
|
802
|
+
return finalizeSuccess();
|
|
803
|
+
}
|
|
804
|
+
revert();
|
|
805
|
+
return { status: "failed", detail: `pnpm add ${spec} still failed after allowing build scripts (exit code ${retryOutcome.exitCode}). See job output. The allowBuilds change was reverted; pnpm may still have left the dependency in the profile's package.json — market_uninstall removes it.` };
|
|
806
|
+
};
|
|
807
|
+
|
|
808
|
+
const first = spawnAdd();
|
|
809
|
+
current = first.proc;
|
|
810
|
+
const done = first.done.then((outcome) => settle(outcome));
|
|
811
|
+
|
|
812
|
+
return {
|
|
813
|
+
cancel: () => {
|
|
814
|
+
current?.kill();
|
|
815
|
+
},
|
|
816
|
+
done,
|
|
817
|
+
readOutput: () => {
|
|
818
|
+
if (deltaQueue.length === 0) return "";
|
|
819
|
+
return deltaQueue.splice(0).join("");
|
|
820
|
+
},
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// ── the background uninstall job ────────────────────────────────────────────
|
|
825
|
+
|
|
826
|
+
/** A terminal producer for fast-fail cases (no pnpm spawn needed). */
|
|
827
|
+
function failedNow(detail) {
|
|
828
|
+
return {
|
|
829
|
+
cancel: () => {},
|
|
830
|
+
done: Promise.resolve({ status: "failed", detail }),
|
|
831
|
+
readOutput: () => "",
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* Run `pnpm remove <package>` in the profile directory as a job producer with
|
|
837
|
+
* the same shape as `runInstall`. On success it reconciles
|
|
838
|
+
* `dsh.profile.bundles` (the removed dependency's bundle entry drops out) and
|
|
839
|
+
* deletes the client loader row `ensureClientRow` had registered for it.
|
|
840
|
+
*/
|
|
841
|
+
export function runRemove({ profile, packageName }, selfHealed = false) {
|
|
842
|
+
let profileDir;
|
|
843
|
+
try {
|
|
844
|
+
profileDir = resolveProfileDir(profile);
|
|
845
|
+
} catch (error) {
|
|
846
|
+
return failedNow(`invalid profile: ${error.message}`);
|
|
847
|
+
}
|
|
848
|
+
const manifestPath = join(profileDir, "package.json");
|
|
849
|
+
if (!existsSync(manifestPath)) {
|
|
850
|
+
return failedNow(`profile "${profile}" has no package.json — nothing installed to remove`);
|
|
851
|
+
}
|
|
852
|
+
const beforeDeps = new Set(Object.keys(readManifest(profileDir).dependencies ?? {}));
|
|
853
|
+
if (!beforeDeps.has(packageName)) {
|
|
854
|
+
return failedNow(`"${packageName}" is not a dependency of profile "${profile}" (installed: ${[...beforeDeps].join(", ") || "none"})`);
|
|
855
|
+
}
|
|
856
|
+
const deltaQueue = [];
|
|
857
|
+
const push = (text) => {
|
|
858
|
+
deltaQueue.push(text);
|
|
859
|
+
};
|
|
860
|
+
let current = undefined;
|
|
861
|
+
|
|
862
|
+
const proc = spawn("pnpm", ["remove", packageName, "--reporter=append-only"], {
|
|
863
|
+
cwd: profileDir,
|
|
864
|
+
env: process.env,
|
|
865
|
+
shell: process.platform === "win32",
|
|
866
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
867
|
+
windowsHide: true,
|
|
868
|
+
});
|
|
869
|
+
current = proc;
|
|
870
|
+
const done = new Promise((resolve) => {
|
|
871
|
+
proc.on("error", (error) => resolve({ spawnError: error }));
|
|
872
|
+
proc.on("close", (exitCode) => resolve({ exitCode, signal: proc.signalCode }));
|
|
873
|
+
}).then(async (outcome) => {
|
|
874
|
+
if (outcome.spawnError !== undefined) {
|
|
875
|
+
// pnpm 缺失时先 corepack 自愈一次再重试(重试在新 producer 里跑,
|
|
876
|
+
// 这里必须返回它的 done outcome——返回 producer 本体会让 tracker
|
|
877
|
+
// 把成功任务记成 failed)。
|
|
878
|
+
if (outcome.spawnError.code === "ENOENT" && selfHealed !== true) {
|
|
879
|
+
const healed = await enablePnpmViaCorepack(push);
|
|
880
|
+
if (healed) return await runRemove({ profile, packageName }, true).done;
|
|
881
|
+
}
|
|
882
|
+
const hint = outcome.spawnError.code === "ENOENT"
|
|
883
|
+
? "pnpm not found on PATH — install pnpm (e.g. `corepack enable pnpm`) to manage profile plugins"
|
|
884
|
+
: `could not start pnpm: ${outcome.spawnError.message}`;
|
|
885
|
+
return { status: "failed", detail: hint };
|
|
886
|
+
}
|
|
887
|
+
if (outcome.exitCode === null) {
|
|
888
|
+
return { status: "killed", detail: outcome.signal ? `signal: ${outcome.signal}` : "killed before exit" };
|
|
889
|
+
}
|
|
890
|
+
if (outcome.exitCode !== 0) {
|
|
891
|
+
return { status: "failed", detail: `pnpm remove ${packageName} failed (exit code ${outcome.exitCode}). See job output.` };
|
|
892
|
+
}
|
|
893
|
+
const bundles = reconcileBundles(profileDir, beforeDeps);
|
|
894
|
+
const clientRow = removeClientRow(profileDir, packageName);
|
|
895
|
+
const notes = [`bundle layer(s) now: ${bundles.join(", ") || "none (template only)"}`];
|
|
896
|
+
if (clientRow.removed) notes.push(`removed client loader row "${clientRow.rowId}" from cordis.patch.yml`);
|
|
897
|
+
return { status: "completed", detail: `removed ${packageName} from profile "${profile}" — ${notes.join("; ")}. Restart dsh for the change to take effect.` };
|
|
898
|
+
});
|
|
899
|
+
proc.stdout?.on("data", (data) => push(data.toString()));
|
|
900
|
+
proc.stderr?.on("data", (data) => push(data.toString()));
|
|
901
|
+
|
|
902
|
+
return {
|
|
903
|
+
cancel: () => {
|
|
904
|
+
current?.kill();
|
|
905
|
+
},
|
|
906
|
+
done,
|
|
907
|
+
readOutput: () => {
|
|
908
|
+
if (deltaQueue.length === 0) return "";
|
|
909
|
+
return deltaQueue.splice(0).join("");
|
|
910
|
+
},
|
|
911
|
+
};
|
|
912
|
+
}
|
|
852
913
|
|
|
853
914
|
// ── offline fixtures ────────────────────────────────────────────────────────
|
|
854
915
|
//
|