dsh-crate-web 0.1.0 → 0.1.2-preview.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +11 -290
- package/README.zh-CN.md +2 -303
- package/THIRD-PARTY-LICENSE.txt +21 -0
- package/cordis.patch.yml +1 -6
- package/core/dsh_pack/_version.py +1 -0
- package/core/dsh_pack/bundle.py +74 -0
- package/core/dsh_pack/cli.py +81 -18
- package/core/dsh_pack/desktop_verify.py +304 -0
- package/core/dsh_pack/diagnostics.py +1170 -0
- package/core/dsh_pack/errors.py +16 -1
- package/core/dsh_pack/export.py +86 -103
- package/core/dsh_pack/export_plan.py +96 -0
- package/core/dsh_pack/identity.py +25 -0
- package/core/dsh_pack/importer.py +1598 -1347
- package/core/dsh_pack/network.py +5 -4
- package/core/dsh_pack/pack.py +14 -2
- package/core/dsh_pack/portability.py +392 -0
- package/core/dsh_pack/preflight.py +63 -16
- package/core/dsh_pack/troubleshooting.py +94 -0
- package/core/dsh_pack/user_cli.py +16 -0
- package/core/dsh_pack/validation.py +64 -4
- package/core/dsh_pack/verify.py +1485 -1336
- package/core/dsh_pack/windows_verify.py +177 -0
- package/desktop-patch-helper.mjs +140 -0
- package/lib/client.js +292 -102
- package/lib/index.d.ts +7 -1
- package/lib/index.js +1121 -229
- package/package.json +13 -5
- package/restart-helper.mjs +121 -121
- package/runtime/python/LICENSE.txt +676 -676
- package/runtime/python/__install__.json +81 -81
- package/runtime/python/python314._pth +6 -6
- package/skills/dsh-crate-troubleshooting/SKILL.md +172 -0
- package/skills/dsh-crate-troubleshooting/compatibility.json +11 -0
- package/skills/dsh-crate-troubleshooting/reference/diagnostics.generated.md +96 -0
- package/skills/dsh-crate-troubleshooting/reference/repair-boundaries.md +60 -0
- package/skills/dsh-crate-troubleshooting/reference/stages.generated.md +22 -0
- package/verify-desktop.mjs +314 -0
|
@@ -1,1347 +1,1598 @@
|
|
|
1
|
-
"""Phase 4 Pack import into a named, isolated DSH Profile.
|
|
2
|
-
|
|
3
|
-
The importer deliberately has no overwrite or merge path. It prepares one new
|
|
4
|
-
Profile directory under ``DSH_HOME/profiles`` and keeps Pack metadata outside
|
|
5
|
-
the runtime Profile under ``DSH_HOME/.dsh-pack/imports``. Reference-only
|
|
6
|
-
plugins are supplied by an explicit local source first. If enabled and no
|
|
7
|
-
local source is available, npm resolves recorded registry/GitHub/tarball
|
|
8
|
-
metadata inside the temporary Profile; no shell is used and lifecycle scripts
|
|
9
|
-
are disabled.
|
|
10
|
-
"""
|
|
11
|
-
|
|
12
|
-
from __future__ import annotations
|
|
13
|
-
|
|
14
|
-
import json
|
|
15
|
-
import
|
|
16
|
-
import
|
|
17
|
-
import
|
|
18
|
-
import
|
|
19
|
-
import
|
|
20
|
-
import
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
from
|
|
25
|
-
|
|
26
|
-
from
|
|
27
|
-
from
|
|
28
|
-
|
|
29
|
-
from .
|
|
30
|
-
from .
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
141
|
-
"
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
)
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
388
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
"
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
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
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
code="
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
)
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
code=
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
)
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
)
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
)
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1
|
+
"""Phase 4 Pack import into a named, isolated DSH Profile.
|
|
2
|
+
|
|
3
|
+
The importer deliberately has no overwrite or merge path. It prepares one new
|
|
4
|
+
Profile directory under ``DSH_HOME/profiles`` and keeps Pack metadata outside
|
|
5
|
+
the runtime Profile under ``DSH_HOME/.dsh-pack/imports``. Reference-only
|
|
6
|
+
plugins are supplied by an explicit local source first. If enabled and no
|
|
7
|
+
local source is available, npm resolves recorded registry/GitHub/tarball
|
|
8
|
+
metadata inside the temporary Profile; no shell is used and lifecycle scripts
|
|
9
|
+
are disabled.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import base64
|
|
16
|
+
import os
|
|
17
|
+
import posixpath
|
|
18
|
+
import re
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
import tarfile
|
|
22
|
+
import tempfile
|
|
23
|
+
import uuid
|
|
24
|
+
from dataclasses import dataclass, field, replace
|
|
25
|
+
from io import BytesIO
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Callable, Mapping
|
|
28
|
+
|
|
29
|
+
from .errors import DshPackError, PackImportError
|
|
30
|
+
from .pack import PackData, read, sha256_bytes
|
|
31
|
+
from .network import npm_install_specifier
|
|
32
|
+
from .identity import expected_install_version, unknown_source
|
|
33
|
+
from .preflight import PreflightContext, PreflightResult, inspect_pack
|
|
34
|
+
from .validation import validate_archive_path, bundle_patch_paths
|
|
35
|
+
from .bundle import check_patch_declaration, read_bundle_patches
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
ReferenceInstaller = Callable[[Path, Mapping[str, Any]], None]
|
|
39
|
+
_REFERENCE_SOURCE_SKIP_DIRS = {"node_modules", ".git", "cache", "logs"}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class ImportOptions:
|
|
44
|
+
"""Facts and explicit installation adapters used by one import."""
|
|
45
|
+
|
|
46
|
+
current_environment: Mapping[str, Any] = field(default_factory=dict)
|
|
47
|
+
available_secrets: set[str] | None = None
|
|
48
|
+
reference_plugin_sources: Mapping[str, str | os.PathLike[str]] = field(default_factory=dict)
|
|
49
|
+
reference_plugin_versions: Mapping[str, str | None] = field(default_factory=dict)
|
|
50
|
+
reference_installer: ReferenceInstaller | None = None
|
|
51
|
+
allow_network_reference_install: bool = True
|
|
52
|
+
npm_command: str | None = None
|
|
53
|
+
pnpm_command: tuple[str, ...] | None = None
|
|
54
|
+
host_bundles: Mapping[str, Any] | None = None
|
|
55
|
+
patch_adapter_command: tuple[str, ...] | None = None
|
|
56
|
+
network_install_timeout: int = 180
|
|
57
|
+
target_profile_name: str | None = None
|
|
58
|
+
overwrite_existing_profile: bool = False
|
|
59
|
+
overwrite_confirmation: str | None = None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class ImportPlan:
|
|
64
|
+
"""A read-only plan produced before any target filesystem write."""
|
|
65
|
+
|
|
66
|
+
pack_path: Path
|
|
67
|
+
dsh_home: Path
|
|
68
|
+
requested_profile_name: str
|
|
69
|
+
profile_name: str
|
|
70
|
+
profile_path: Path
|
|
71
|
+
metadata_path: Path
|
|
72
|
+
overwrite: bool
|
|
73
|
+
target_exists: bool
|
|
74
|
+
embedded_plugins: tuple[dict[str, Any], ...]
|
|
75
|
+
reference_plugins: tuple[dict[str, Any], ...]
|
|
76
|
+
preflight: PreflightResult
|
|
77
|
+
portable_data: tuple[str, ...] = ()
|
|
78
|
+
host_provided_dependencies: tuple[dict[str, Any], ...] = ()
|
|
79
|
+
|
|
80
|
+
def as_dict(self) -> dict[str, Any]:
|
|
81
|
+
return {
|
|
82
|
+
"packPath": str(self.pack_path),
|
|
83
|
+
"dshHome": str(self.dsh_home),
|
|
84
|
+
"requestedProfileName": self.requested_profile_name,
|
|
85
|
+
"profileName": self.profile_name,
|
|
86
|
+
"profilePath": str(self.profile_path),
|
|
87
|
+
"metadataPath": str(self.metadata_path),
|
|
88
|
+
"overwrite": self.overwrite,
|
|
89
|
+
"targetExists": self.target_exists,
|
|
90
|
+
"embeddedPlugins": list(self.embedded_plugins),
|
|
91
|
+
"referencePlugins": list(self.reference_plugins),
|
|
92
|
+
"preflight": self.preflight.as_dict(),
|
|
93
|
+
"portableData": list(self.portable_data),
|
|
94
|
+
"hostProvidedDependencies": list(self.host_provided_dependencies),
|
|
95
|
+
"decision": "READY_TO_IMPORT" if self.preflight.status == "READY" else "NOT_READY",
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class ImportResult:
|
|
101
|
+
"""A successfully prepared new Profile."""
|
|
102
|
+
|
|
103
|
+
status: str
|
|
104
|
+
profile_path: Path
|
|
105
|
+
metadata_path: Path
|
|
106
|
+
plan: ImportPlan
|
|
107
|
+
installed_plugins: tuple[dict[str, Any], ...]
|
|
108
|
+
network_installs: tuple[dict[str, Any], ...] = ()
|
|
109
|
+
warnings: tuple[dict[str, Any], ...] = ()
|
|
110
|
+
|
|
111
|
+
def as_dict(self) -> dict[str, Any]:
|
|
112
|
+
return {
|
|
113
|
+
"status": self.status,
|
|
114
|
+
"profilePath": str(self.profile_path),
|
|
115
|
+
"metadataPath": str(self.metadata_path),
|
|
116
|
+
"installedPlugins": list(self.installed_plugins),
|
|
117
|
+
"networkInstalls": list(self.network_installs),
|
|
118
|
+
"warnings": list(self.warnings),
|
|
119
|
+
"plan": self.plan.as_dict(),
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass(frozen=True)
|
|
124
|
+
class DeleteResult:
|
|
125
|
+
"""Result of an explicitly confirmed Profile deletion.
|
|
126
|
+
|
|
127
|
+
``metadata_status`` reports what happened to the paired Import metadata
|
|
128
|
+
directory: ``removed``, ``no-metadata`` (nothing was stored for this
|
|
129
|
+
Profile), or ``failed`` (the Profile is deleted but metadata cleanup did
|
|
130
|
+
not complete).
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
status: str
|
|
134
|
+
profile_name: str
|
|
135
|
+
profile_path: Path
|
|
136
|
+
metadata_path: Path | None = None
|
|
137
|
+
metadata_status: str = "no-metadata"
|
|
138
|
+
|
|
139
|
+
def as_dict(self) -> dict[str, Any]:
|
|
140
|
+
return {
|
|
141
|
+
"status": self.status,
|
|
142
|
+
"profileName": self.profile_name,
|
|
143
|
+
"profilePath": str(self.profile_path),
|
|
144
|
+
"metadataPath": str(self.metadata_path) if self.metadata_path is not None else None,
|
|
145
|
+
"metadataStatus": self.metadata_status,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _profile_name(manifest: Mapping[str, Any]) -> str:
|
|
150
|
+
profile = manifest.get("profile")
|
|
151
|
+
if not isinstance(profile, Mapping) or not isinstance(profile.get("name"), str):
|
|
152
|
+
raise PackImportError(
|
|
153
|
+
"Pack manifest has no valid profile name",
|
|
154
|
+
stage="planning",
|
|
155
|
+
code="PROFILE_NAME_MISSING",
|
|
156
|
+
)
|
|
157
|
+
return profile["name"]
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _validate_profile_name(name: str, *, label: str = "Profile name") -> str:
|
|
161
|
+
if (
|
|
162
|
+
not isinstance(name, str)
|
|
163
|
+
or not name.strip()
|
|
164
|
+
or name in {".", ".."}
|
|
165
|
+
or "/" in name
|
|
166
|
+
or "\\" in name
|
|
167
|
+
or "\x00" in name
|
|
168
|
+
):
|
|
169
|
+
raise PackImportError(
|
|
170
|
+
f"{label} must be a single safe directory name",
|
|
171
|
+
stage="planning",
|
|
172
|
+
code="PROFILE_NAME_INVALID",
|
|
173
|
+
details={"item": name},
|
|
174
|
+
)
|
|
175
|
+
return name
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _select_profile_name(
|
|
179
|
+
dsh_home: Path,
|
|
180
|
+
requested_name: str,
|
|
181
|
+
*,
|
|
182
|
+
target_name: str | None = None,
|
|
183
|
+
overwrite: bool = False,
|
|
184
|
+
) -> str:
|
|
185
|
+
"""Choose a new Profile directory without ever reusing an occupied name.
|
|
186
|
+
|
|
187
|
+
Import has no overwrite or merge mode. When a Pack's original Profile name
|
|
188
|
+
is already present in the destination DSH_HOME, planning fails with
|
|
189
|
+
``PROFILE_EXISTS`` instead of allocating a suffix: the original Profile is
|
|
190
|
+
left untouched and no files are written. The metadata directory is checked
|
|
191
|
+
as well so a stale prepared record cannot be mistaken for an unused target.
|
|
192
|
+
``overwrite=True`` is a separate explicit path that requires the target to
|
|
193
|
+
already exist and refuses symlink or non-directory targets.
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
requested_name = _validate_profile_name(requested_name, label="Pack Profile name")
|
|
197
|
+
selected_name = _validate_profile_name(target_name or requested_name, label="Target Profile name")
|
|
198
|
+
profiles_root = dsh_home / "profiles"
|
|
199
|
+
metadata_root = dsh_home / ".dsh-pack" / "imports"
|
|
200
|
+
|
|
201
|
+
def occupied(name: str) -> bool:
|
|
202
|
+
return (profiles_root / name).exists() or (metadata_root / name).exists()
|
|
203
|
+
|
|
204
|
+
if overwrite:
|
|
205
|
+
profile_path = profiles_root / selected_name
|
|
206
|
+
if profile_path.is_symlink() or (profile_path.exists() and not profile_path.is_dir()):
|
|
207
|
+
raise PackImportError(
|
|
208
|
+
f"target Profile is not a safe directory: {profile_path}",
|
|
209
|
+
stage="planning",
|
|
210
|
+
code="PROFILE_TARGET_INVALID",
|
|
211
|
+
details={"profilePath": str(profile_path)},
|
|
212
|
+
)
|
|
213
|
+
if not profile_path.exists():
|
|
214
|
+
raise PackImportError(
|
|
215
|
+
f"target Profile does not exist for overwrite: {profile_path}",
|
|
216
|
+
stage="planning",
|
|
217
|
+
code="PROFILE_TARGET_MISSING",
|
|
218
|
+
details={"profilePath": str(profile_path)},
|
|
219
|
+
)
|
|
220
|
+
return selected_name
|
|
221
|
+
if occupied(selected_name):
|
|
222
|
+
raise PackImportError(
|
|
223
|
+
f"target Profile already exists: {profiles_root / selected_name}",
|
|
224
|
+
stage="planning",
|
|
225
|
+
code="PROFILE_EXISTS",
|
|
226
|
+
details={"profilePath": str(profiles_root / selected_name), "requestedProfileName": selected_name},
|
|
227
|
+
)
|
|
228
|
+
return selected_name
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _package_install_path(profile_path: Path, package_name: str) -> Path:
|
|
232
|
+
if not package_name or "\\" in package_name or package_name.startswith("/"):
|
|
233
|
+
raise PackImportError(
|
|
234
|
+
f"unsafe package name: {package_name!r}",
|
|
235
|
+
stage="planning",
|
|
236
|
+
code="PACKAGE_NAME_INVALID",
|
|
237
|
+
)
|
|
238
|
+
parts = package_name.split("/")
|
|
239
|
+
if any(not part or part in {".", ".."} for part in parts):
|
|
240
|
+
raise PackImportError(
|
|
241
|
+
f"unsafe package name: {package_name!r}",
|
|
242
|
+
stage="planning",
|
|
243
|
+
code="PACKAGE_NAME_INVALID",
|
|
244
|
+
)
|
|
245
|
+
return profile_path / "node_modules" / Path(*parts)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _package_manifest(package_path: Path, *, stage: str) -> dict[str, Any]:
|
|
249
|
+
manifest_path = package_path / "package.json"
|
|
250
|
+
try:
|
|
251
|
+
value = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
252
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
253
|
+
raise PackImportError(
|
|
254
|
+
f"cannot read installed package manifest: {manifest_path}",
|
|
255
|
+
stage=stage,
|
|
256
|
+
code="PACKAGE_MANIFEST_INVALID",
|
|
257
|
+
) from error
|
|
258
|
+
if not isinstance(value, dict) or not isinstance(value.get("name"), str) or not isinstance(value.get("version"), str):
|
|
259
|
+
raise PackImportError(
|
|
260
|
+
f"installed package manifest lacks name/version: {manifest_path}",
|
|
261
|
+
stage=stage,
|
|
262
|
+
code="PACKAGE_MANIFEST_INVALID",
|
|
263
|
+
)
|
|
264
|
+
return value
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _reference_inventory(options: ImportOptions) -> dict[str, str | None]:
|
|
268
|
+
inventory = dict(options.reference_plugin_versions)
|
|
269
|
+
for name, source in options.reference_plugin_sources.items():
|
|
270
|
+
manifest = _package_manifest(Path(source), stage="planning")
|
|
271
|
+
inventory[name] = manifest["version"]
|
|
272
|
+
return inventory
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _reference_plugin_requirements(data: PackData) -> dict[str, str | None]:
|
|
276
|
+
requirements: dict[str, str | None] = {}
|
|
277
|
+
for plugin in data.plugins_lock.get("plugins", []):
|
|
278
|
+
if not isinstance(plugin, Mapping):
|
|
279
|
+
continue
|
|
280
|
+
artifact = plugin.get("artifact")
|
|
281
|
+
name = plugin.get("name")
|
|
282
|
+
if not isinstance(artifact, Mapping) or artifact.get("mode") != "reference-only" or not isinstance(name, str):
|
|
283
|
+
continue
|
|
284
|
+
if unknown_source(plugin):
|
|
285
|
+
continue # Unknown source requires an explicit, checked local path.
|
|
286
|
+
requirements[name] = expected_install_version(plugin)
|
|
287
|
+
return requirements
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _reference_search_roots(dsh_home: Path) -> tuple[Path, ...]:
|
|
291
|
+
"""Return local DSH package roots used for automatic reference discovery.
|
|
292
|
+
|
|
293
|
+
Only shared installation roots are eligible: the per-home anchor
|
|
294
|
+
(``profiles/node_modules``) and the home-level ``node_modules``. Per-Profile
|
|
295
|
+
package directories are excluded because npm hoists a Profile's transitive
|
|
296
|
+
dependencies to that Profile's own ``node_modules`` top level. Copying a bare
|
|
297
|
+
package out of there cannot resolve those dependencies in the new Profile (a
|
|
298
|
+
sibling Profile is not an ancestor in Node's resolution walk), which yields an
|
|
299
|
+
installed Profile that fails to boot. Explicit ``reference_plugin_sources``
|
|
300
|
+
overrides remain the way to reuse a package from a specific location.
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
profiles_root = dsh_home / "profiles"
|
|
304
|
+
roots: list[Path] = [profiles_root / "node_modules", dsh_home / "node_modules"]
|
|
305
|
+
return tuple(dict.fromkeys(roots))
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _auto_reference_sources(
|
|
309
|
+
data: PackData,
|
|
310
|
+
dsh_home: Path,
|
|
311
|
+
options: ImportOptions,
|
|
312
|
+
) -> ImportOptions:
|
|
313
|
+
"""Resolve reference-only packages already installed in this DSH_HOME.
|
|
314
|
+
|
|
315
|
+
A Pack never stores machine-specific absolute paths. During Import, use
|
|
316
|
+
DSH's shared installation anchor first, then existing Profile package
|
|
317
|
+
directories, and keep an explicitly supplied source as the override.
|
|
318
|
+
This is local discovery only; it does not contact a registry or run a
|
|
319
|
+
package manager.
|
|
320
|
+
"""
|
|
321
|
+
|
|
322
|
+
sources = dict(options.reference_plugin_sources)
|
|
323
|
+
requirements = _reference_plugin_requirements(data)
|
|
324
|
+
roots = _reference_search_roots(dsh_home)
|
|
325
|
+
for name, expected_version in requirements.items():
|
|
326
|
+
if name in sources:
|
|
327
|
+
continue
|
|
328
|
+
candidates: list[tuple[Path, str | None]] = []
|
|
329
|
+
for root in roots:
|
|
330
|
+
package_path = root.joinpath(*name.split("/"))
|
|
331
|
+
manifest_path = package_path / "package.json"
|
|
332
|
+
if not manifest_path.is_file():
|
|
333
|
+
continue
|
|
334
|
+
try:
|
|
335
|
+
manifest = _package_manifest(package_path, stage="planning")
|
|
336
|
+
except PackImportError:
|
|
337
|
+
candidates.append((package_path, None))
|
|
338
|
+
continue
|
|
339
|
+
if manifest.get("name") != name:
|
|
340
|
+
continue
|
|
341
|
+
candidates.append((package_path, manifest.get("version")))
|
|
342
|
+
|
|
343
|
+
if not candidates:
|
|
344
|
+
continue
|
|
345
|
+
exact = next(
|
|
346
|
+
(path for path, version in candidates if expected_version is not None and version == expected_version),
|
|
347
|
+
None,
|
|
348
|
+
)
|
|
349
|
+
sources[name] = exact or candidates[0][0]
|
|
350
|
+
|
|
351
|
+
if sources == options.reference_plugin_sources:
|
|
352
|
+
return options
|
|
353
|
+
return replace(options, reference_plugin_sources=sources)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _network_reference_plugins(data: PackData, options: ImportOptions) -> tuple[dict[str, Any], ...]:
|
|
357
|
+
"""Return reference-only entries that still need a network install."""
|
|
358
|
+
|
|
359
|
+
result: list[dict[str, Any]] = []
|
|
360
|
+
for raw_plugin in data.plugins_lock.get("plugins", []):
|
|
361
|
+
if not isinstance(raw_plugin, Mapping):
|
|
362
|
+
continue
|
|
363
|
+
artifact = raw_plugin.get("artifact")
|
|
364
|
+
name = raw_plugin.get("name")
|
|
365
|
+
if not isinstance(artifact, Mapping) or artifact.get("mode") != "reference-only" or not isinstance(name, str):
|
|
366
|
+
continue
|
|
367
|
+
if name in options.reference_plugin_sources or name in options.reference_plugin_versions:
|
|
368
|
+
continue
|
|
369
|
+
if npm_install_specifier(raw_plugin) is None:
|
|
370
|
+
continue
|
|
371
|
+
result.append(dict(raw_plugin))
|
|
372
|
+
return tuple(result)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _npm_command(configured: str | None) -> str:
|
|
376
|
+
if configured:
|
|
377
|
+
return configured
|
|
378
|
+
if os.name == "nt":
|
|
379
|
+
return shutil.which("npm.cmd") or shutil.which("npm") or "npm.cmd"
|
|
380
|
+
return shutil.which("npm") or "npm"
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def _network_error_output(value: str, limit: int = 4000) -> str:
|
|
384
|
+
"""Keep command diagnostics bounded and avoid dumping a whole npm log."""
|
|
385
|
+
|
|
386
|
+
value = value.strip()
|
|
387
|
+
return value[-limit:] if len(value) > limit else value
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _install_network_reference(profile_path: Path, plugin: Mapping[str, Any], options: ImportOptions) -> dict[str, Any]:
|
|
391
|
+
"""Install one recorded network source into the temporary Profile.
|
|
392
|
+
|
|
393
|
+
The command is argv-based and lifecycle scripts are disabled. The package
|
|
394
|
+
manager therefore owns registry/GitHub/tarball resolution while Pack keeps
|
|
395
|
+
ownership of target isolation and exact package identity verification.
|
|
396
|
+
"""
|
|
397
|
+
|
|
398
|
+
name = str(plugin.get("name"))
|
|
399
|
+
specifier = npm_install_specifier(plugin)
|
|
400
|
+
if specifier is None:
|
|
401
|
+
raise PackImportError(
|
|
402
|
+
f"no supported network source is recorded for {name}",
|
|
403
|
+
stage="network-install",
|
|
404
|
+
code="NETWORK_SOURCE_UNSUPPORTED",
|
|
405
|
+
details={"plugin": name},
|
|
406
|
+
)
|
|
407
|
+
command = [
|
|
408
|
+
_npm_command(options.npm_command),
|
|
409
|
+
"install",
|
|
410
|
+
"--ignore-scripts",
|
|
411
|
+
# DSH host packages are peer dependencies supplied by the runtime
|
|
412
|
+
# installation anchor. Do not make npm fetch a second host tree from
|
|
413
|
+
# the public registry while installing a reference-only plugin.
|
|
414
|
+
"--legacy-peer-deps",
|
|
415
|
+
"--no-save",
|
|
416
|
+
"--no-package-lock",
|
|
417
|
+
"--no-audit",
|
|
418
|
+
"--no-fund",
|
|
419
|
+
"--prefix",
|
|
420
|
+
str(profile_path),
|
|
421
|
+
"--",
|
|
422
|
+
specifier,
|
|
423
|
+
]
|
|
424
|
+
try:
|
|
425
|
+
completed = subprocess.run(
|
|
426
|
+
command,
|
|
427
|
+
cwd=profile_path,
|
|
428
|
+
capture_output=True,
|
|
429
|
+
text=True,
|
|
430
|
+
check=False,
|
|
431
|
+
timeout=max(1, int(options.network_install_timeout)),
|
|
432
|
+
)
|
|
433
|
+
except FileNotFoundError as error:
|
|
434
|
+
raise PackImportError(
|
|
435
|
+
f"npm command is unavailable while installing {name}",
|
|
436
|
+
stage="network-install",
|
|
437
|
+
code="NETWORK_INSTALL_FAILED",
|
|
438
|
+
details={"plugin": name, "command": command[0], "source": specifier, "error": str(error)},
|
|
439
|
+
) from error
|
|
440
|
+
except subprocess.TimeoutExpired as error:
|
|
441
|
+
raise PackImportError(
|
|
442
|
+
f"network install timed out for {name}",
|
|
443
|
+
stage="network-install",
|
|
444
|
+
code="NETWORK_INSTALL_TIMEOUT",
|
|
445
|
+
details={"plugin": name, "source": specifier, "timeoutSeconds": options.network_install_timeout},
|
|
446
|
+
) from error
|
|
447
|
+
except OSError as error:
|
|
448
|
+
raise PackImportError(
|
|
449
|
+
f"cannot start npm while installing {name}",
|
|
450
|
+
stage="network-install",
|
|
451
|
+
code="NETWORK_INSTALL_FAILED",
|
|
452
|
+
details={"plugin": name, "command": command[0], "source": specifier, "error": str(error)},
|
|
453
|
+
) from error
|
|
454
|
+
if completed.returncode != 0:
|
|
455
|
+
raise PackImportError(
|
|
456
|
+
f"npm install failed for {name} with exit code {completed.returncode}",
|
|
457
|
+
stage="network-install",
|
|
458
|
+
code="NETWORK_INSTALL_FAILED",
|
|
459
|
+
details={
|
|
460
|
+
"plugin": name,
|
|
461
|
+
"source": specifier,
|
|
462
|
+
"command": command,
|
|
463
|
+
"exitCode": completed.returncode,
|
|
464
|
+
"stdout": _network_error_output(completed.stdout),
|
|
465
|
+
"stderr": _network_error_output(completed.stderr),
|
|
466
|
+
},
|
|
467
|
+
)
|
|
468
|
+
target = _package_install_path(profile_path, name)
|
|
469
|
+
if not (target / "package.json").is_file():
|
|
470
|
+
raise PackImportError(
|
|
471
|
+
f"npm install completed but package is missing: {name}",
|
|
472
|
+
stage="network-install",
|
|
473
|
+
code="NETWORK_PACKAGE_MISSING",
|
|
474
|
+
details={"plugin": name, "source": specifier, "target": str(target)},
|
|
475
|
+
)
|
|
476
|
+
return {
|
|
477
|
+
"name": name,
|
|
478
|
+
"source": specifier,
|
|
479
|
+
"resolver": "npm",
|
|
480
|
+
"scripts": "ignored",
|
|
481
|
+
"stdout": _network_error_output(completed.stdout),
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _context(options: ImportOptions) -> PreflightContext:
|
|
486
|
+
verified_local_plugins = {}
|
|
487
|
+
for name, source in options.reference_plugin_sources.items():
|
|
488
|
+
manifest = _package_manifest(Path(source), stage="planning")
|
|
489
|
+
if manifest["name"] == name:
|
|
490
|
+
verified_local_plugins[name] = manifest["version"]
|
|
491
|
+
return PreflightContext(
|
|
492
|
+
current_environment=dict(options.current_environment),
|
|
493
|
+
available_secrets=set() if options.available_secrets is None else set(options.available_secrets),
|
|
494
|
+
available_plugins=_reference_inventory(options),
|
|
495
|
+
allow_network_reference_install=options.allow_network_reference_install,
|
|
496
|
+
verified_local_plugins=verified_local_plugins,
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _plugin_rows(lock: Mapping[str, Any], mode: str) -> tuple[dict[str, Any], ...]:
|
|
501
|
+
result: list[dict[str, Any]] = []
|
|
502
|
+
for plugin in lock.get("plugins", []):
|
|
503
|
+
if not isinstance(plugin, Mapping):
|
|
504
|
+
continue
|
|
505
|
+
artifact = plugin.get("artifact")
|
|
506
|
+
if not isinstance(artifact, Mapping) or artifact.get("mode") != mode:
|
|
507
|
+
continue
|
|
508
|
+
result.append(
|
|
509
|
+
{
|
|
510
|
+
"name": plugin.get("name"),
|
|
511
|
+
"version": expected_install_version(plugin),
|
|
512
|
+
"required": plugin.get("required") is True,
|
|
513
|
+
"artifact": dict(artifact),
|
|
514
|
+
}
|
|
515
|
+
)
|
|
516
|
+
return tuple(result)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def create_import_plan(
|
|
520
|
+
source: str | os.PathLike[str],
|
|
521
|
+
dsh_home: str | os.PathLike[str],
|
|
522
|
+
*,
|
|
523
|
+
options: ImportOptions | None = None,
|
|
524
|
+
) -> ImportPlan:
|
|
525
|
+
"""Run Preflight and create a no-write Import Plan."""
|
|
526
|
+
|
|
527
|
+
options = options or ImportOptions()
|
|
528
|
+
source_path = Path(source).resolve()
|
|
529
|
+
home_path = Path(dsh_home).resolve()
|
|
530
|
+
try:
|
|
531
|
+
pack_data = read(source_path)
|
|
532
|
+
except DshPackError:
|
|
533
|
+
pack_data = None
|
|
534
|
+
if pack_data is not None:
|
|
535
|
+
options = _auto_reference_sources(pack_data, home_path, options)
|
|
536
|
+
try:
|
|
537
|
+
preflight = inspect_pack(source_path, context=_context(options))
|
|
538
|
+
except (DshPackError, OSError) as error:
|
|
539
|
+
if isinstance(error, PackImportError):
|
|
540
|
+
raise
|
|
541
|
+
raise PackImportError(
|
|
542
|
+
str(error),
|
|
543
|
+
stage="preflight",
|
|
544
|
+
code="PREFLIGHT_ERROR",
|
|
545
|
+
) from error
|
|
546
|
+
if preflight.blockers:
|
|
547
|
+
raise PackImportError(
|
|
548
|
+
"Preflight blocked Import",
|
|
549
|
+
stage="preflight",
|
|
550
|
+
code="PREFLIGHT_BLOCKED",
|
|
551
|
+
details={"findings": [finding.as_dict() for finding in preflight.blockers]},
|
|
552
|
+
)
|
|
553
|
+
try:
|
|
554
|
+
data = pack_data if pack_data is not None else read(source_path)
|
|
555
|
+
except DshPackError as error:
|
|
556
|
+
raise PackImportError(str(error), stage="preflight", code="PACK_READ_ERROR") from error
|
|
557
|
+
|
|
558
|
+
from .portability import check_targets
|
|
559
|
+
check_targets(home_path, data.user_data, overwrite=options.overwrite_existing_profile)
|
|
560
|
+
requested_profile_name = _profile_name(data.manifest)
|
|
561
|
+
profile_name = _select_profile_name(
|
|
562
|
+
home_path,
|
|
563
|
+
requested_profile_name,
|
|
564
|
+
target_name=options.target_profile_name,
|
|
565
|
+
overwrite=options.overwrite_existing_profile,
|
|
566
|
+
)
|
|
567
|
+
profile_path = home_path / "profiles" / profile_name
|
|
568
|
+
metadata_path = home_path / ".dsh-pack" / "imports" / profile_name
|
|
569
|
+
target_exists = profile_path.exists() or metadata_path.exists()
|
|
570
|
+
# _select_profile_name checks both locations. Keep these guards for a
|
|
571
|
+
# clear diagnostic if the filesystem changes between selection and return.
|
|
572
|
+
if target_exists and not options.overwrite_existing_profile:
|
|
573
|
+
raise PackImportError(
|
|
574
|
+
f"no safe target Profile name is available for {requested_profile_name!r}",
|
|
575
|
+
stage="planning",
|
|
576
|
+
code="PROFILE_NAME_UNAVAILABLE",
|
|
577
|
+
details={"requestedProfileName": requested_profile_name, "profilePath": str(profile_path), "metadataPath": str(metadata_path)},
|
|
578
|
+
)
|
|
579
|
+
if "package.json" not in data.profile_files:
|
|
580
|
+
raise PackImportError(
|
|
581
|
+
"Pack profile/ is missing package.json",
|
|
582
|
+
stage="planning",
|
|
583
|
+
code="PROFILE_CONFIG_MISSING",
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
_, host_dependencies = _host_dependency_overrides(data, options)
|
|
587
|
+
return ImportPlan(
|
|
588
|
+
pack_path=source_path,
|
|
589
|
+
dsh_home=home_path,
|
|
590
|
+
requested_profile_name=requested_profile_name,
|
|
591
|
+
profile_name=profile_name,
|
|
592
|
+
profile_path=profile_path,
|
|
593
|
+
metadata_path=metadata_path,
|
|
594
|
+
overwrite=options.overwrite_existing_profile,
|
|
595
|
+
target_exists=target_exists,
|
|
596
|
+
embedded_plugins=_plugin_rows(data.plugins_lock, "embedded"),
|
|
597
|
+
reference_plugins=_plugin_rows(data.plugins_lock, "reference-only"),
|
|
598
|
+
preflight=preflight,
|
|
599
|
+
portable_data=tuple(data.user_data),
|
|
600
|
+
host_provided_dependencies=tuple(host_dependencies),
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _write_file(root: Path, relative: str, content: bytes) -> None:
|
|
605
|
+
archive_path = validate_archive_path(f"profile/{relative}", expected_root="profile")
|
|
606
|
+
relative_path = archive_path[len("profile/"):]
|
|
607
|
+
destination = root.joinpath(*relative_path.split("/"))
|
|
608
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
609
|
+
if destination.exists() or destination.is_symlink():
|
|
610
|
+
raise PackImportError(
|
|
611
|
+
f"Profile target file already exists: {destination}",
|
|
612
|
+
stage="configuration",
|
|
613
|
+
code="PROFILE_FILE_EXISTS",
|
|
614
|
+
)
|
|
615
|
+
destination.write_bytes(content)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _extract_embedded_artifact(profile_path: Path, plugin: Mapping[str, Any], content: bytes) -> None:
|
|
619
|
+
name = str(plugin["name"])
|
|
620
|
+
target = _package_install_path(profile_path, name)
|
|
621
|
+
if target.exists():
|
|
622
|
+
raise PackImportError(
|
|
623
|
+
f"embedded plugin target already exists: {target}",
|
|
624
|
+
stage="embedded-install",
|
|
625
|
+
code="PLUGIN_TARGET_EXISTS",
|
|
626
|
+
)
|
|
627
|
+
target.mkdir(parents=True, exist_ok=False)
|
|
628
|
+
seen: set[str] = set()
|
|
629
|
+
try:
|
|
630
|
+
with tarfile.open(fileobj=BytesIO(content), mode="r:gz") as archive:
|
|
631
|
+
for member in archive.getmembers():
|
|
632
|
+
raw_name = member.name.replace("\\", "/")
|
|
633
|
+
if raw_name.endswith("/"):
|
|
634
|
+
raw_name = raw_name[:-1]
|
|
635
|
+
if raw_name == "package":
|
|
636
|
+
continue
|
|
637
|
+
canonical = validate_archive_path(raw_name)
|
|
638
|
+
if not canonical.startswith("package/"):
|
|
639
|
+
raise PackImportError(
|
|
640
|
+
f"artifact member is outside package/: {member.name!r}",
|
|
641
|
+
stage="embedded-install",
|
|
642
|
+
code="ARTIFACT_PATH_INVALID",
|
|
643
|
+
)
|
|
644
|
+
relative = canonical[len("package/"):]
|
|
645
|
+
if not relative or relative in seen:
|
|
646
|
+
raise PackImportError(
|
|
647
|
+
f"duplicate artifact member: {member.name!r}",
|
|
648
|
+
stage="embedded-install",
|
|
649
|
+
code="ARTIFACT_MEMBER_DUPLICATE",
|
|
650
|
+
)
|
|
651
|
+
seen.add(relative)
|
|
652
|
+
destination = target.joinpath(*relative.split("/"))
|
|
653
|
+
if member.issym():
|
|
654
|
+
raise PackImportError(
|
|
655
|
+
f"unsupported artifact symlink member: {member.name!r}",
|
|
656
|
+
stage="embedded-install",
|
|
657
|
+
code="ARTIFACT_MEMBER_TYPE_INVALID",
|
|
658
|
+
)
|
|
659
|
+
if member.isdir():
|
|
660
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
661
|
+
continue
|
|
662
|
+
if member.islnk():
|
|
663
|
+
# npm tarballs can contain hardlink members. Resolve the
|
|
664
|
+
# target inside the same archive and materialize a regular
|
|
665
|
+
# file so the installed package is a plain directory tree.
|
|
666
|
+
content_bytes = _read_artifact_hardlink(archive, member)
|
|
667
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
668
|
+
destination.write_bytes(content_bytes)
|
|
669
|
+
continue
|
|
670
|
+
if not member.isfile():
|
|
671
|
+
raise PackImportError(
|
|
672
|
+
f"unsupported artifact member type: {member.name!r}",
|
|
673
|
+
stage="embedded-install",
|
|
674
|
+
code="ARTIFACT_MEMBER_TYPE_INVALID",
|
|
675
|
+
)
|
|
676
|
+
extracted = archive.extractfile(member)
|
|
677
|
+
if extracted is None:
|
|
678
|
+
raise PackImportError(
|
|
679
|
+
f"cannot read artifact member: {member.name!r}",
|
|
680
|
+
stage="embedded-install",
|
|
681
|
+
code="ARTIFACT_MEMBER_READ_ERROR",
|
|
682
|
+
)
|
|
683
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
684
|
+
destination.write_bytes(extracted.read())
|
|
685
|
+
except PackImportError:
|
|
686
|
+
raise
|
|
687
|
+
except DshPackError as error:
|
|
688
|
+
raise PackImportError(
|
|
689
|
+
f"embedded artifact is unsafe for {name}",
|
|
690
|
+
stage="embedded-install",
|
|
691
|
+
code="ARTIFACT_PATH_INVALID",
|
|
692
|
+
details={"error": str(error)},
|
|
693
|
+
) from error
|
|
694
|
+
except (OSError, tarfile.TarError) as error:
|
|
695
|
+
raise PackImportError(
|
|
696
|
+
f"cannot unpack embedded artifact for {name}",
|
|
697
|
+
stage="embedded-install",
|
|
698
|
+
code="ARTIFACT_UNPACK_FAILED",
|
|
699
|
+
details={
|
|
700
|
+
"item": name,
|
|
701
|
+
"expected": {"artifact": "valid gzip tar archive", "package": name},
|
|
702
|
+
"observed": {"error": str(error)},
|
|
703
|
+
"evidence": {"package": name, "stage": "embedded-install"},
|
|
704
|
+
"error": str(error),
|
|
705
|
+
},
|
|
706
|
+
) from error
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _read_artifact_hardlink(archive: tarfile.TarFile, member: tarfile.TarInfo) -> bytes:
|
|
711
|
+
"""Read the file content a tar hardlink member points to.
|
|
712
|
+
|
|
713
|
+
npm preserves hardlinks in packed tarballs. The link target is resolved
|
|
714
|
+
within the same archive only; symlinks, directory targets, missing
|
|
715
|
+
members, and cycles are rejected.
|
|
716
|
+
"""
|
|
717
|
+
|
|
718
|
+
resolved: set[str] = set()
|
|
719
|
+
current = member.linkname.replace("\\", "/")
|
|
720
|
+
for _ in range(16):
|
|
721
|
+
canonical = validate_archive_path(current)
|
|
722
|
+
if not canonical.startswith("package/"):
|
|
723
|
+
raise PackImportError(
|
|
724
|
+
f"artifact hardlink target is outside package/: {member.linkname!r}",
|
|
725
|
+
stage="embedded-install",
|
|
726
|
+
code="ARTIFACT_PATH_INVALID",
|
|
727
|
+
)
|
|
728
|
+
if canonical in resolved:
|
|
729
|
+
raise PackImportError(
|
|
730
|
+
f"artifact hardlink target cycle: {member.linkname!r}",
|
|
731
|
+
stage="embedded-install",
|
|
732
|
+
code="ARTIFACT_MEMBER_TYPE_INVALID",
|
|
733
|
+
)
|
|
734
|
+
resolved.add(canonical)
|
|
735
|
+
try:
|
|
736
|
+
target_member = archive.getmember(canonical)
|
|
737
|
+
except KeyError:
|
|
738
|
+
raise PackImportError(
|
|
739
|
+
f"artifact hardlink target is missing: {canonical!r}",
|
|
740
|
+
stage="embedded-install",
|
|
741
|
+
code="ARTIFACT_MEMBER_READ_ERROR",
|
|
742
|
+
) from None
|
|
743
|
+
if target_member.issym() or target_member.isdir():
|
|
744
|
+
raise PackImportError(
|
|
745
|
+
f"artifact hardlink target is not a regular file: {canonical!r}",
|
|
746
|
+
stage="embedded-install",
|
|
747
|
+
code="ARTIFACT_MEMBER_TYPE_INVALID",
|
|
748
|
+
)
|
|
749
|
+
if target_member.islnk():
|
|
750
|
+
current = target_member.linkname.replace("\\", "/")
|
|
751
|
+
continue
|
|
752
|
+
extracted = archive.extractfile(target_member)
|
|
753
|
+
if extracted is None:
|
|
754
|
+
raise PackImportError(
|
|
755
|
+
f"cannot read artifact hardlink target: {canonical!r}",
|
|
756
|
+
stage="embedded-install",
|
|
757
|
+
code="ARTIFACT_MEMBER_READ_ERROR",
|
|
758
|
+
)
|
|
759
|
+
return extracted.read()
|
|
760
|
+
raise PackImportError(
|
|
761
|
+
f"artifact hardlink chain is too long: {member.name!r}",
|
|
762
|
+
stage="embedded-install",
|
|
763
|
+
code="ARTIFACT_MEMBER_TYPE_INVALID",
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _copy_reference_source(profile_path: Path, plugin: Mapping[str, Any], source: Path) -> None:
|
|
768
|
+
name = str(plugin["name"])
|
|
769
|
+
target = _package_install_path(profile_path, name)
|
|
770
|
+
if not source.is_dir():
|
|
771
|
+
raise PackImportError(
|
|
772
|
+
f"reference plugin source is not a directory: {source}",
|
|
773
|
+
stage="reference-install",
|
|
774
|
+
code="PLUGIN_SOURCE_MISSING",
|
|
775
|
+
)
|
|
776
|
+
source = source.resolve()
|
|
777
|
+
if target.exists():
|
|
778
|
+
raise PackImportError(
|
|
779
|
+
f"reference plugin target already exists: {target}",
|
|
780
|
+
stage="reference-install",
|
|
781
|
+
code="PLUGIN_TARGET_EXISTS",
|
|
782
|
+
)
|
|
783
|
+
target.mkdir(parents=True, exist_ok=False)
|
|
784
|
+
|
|
785
|
+
def is_directory_link(path: Path) -> bool:
|
|
786
|
+
junction_check = getattr(path, "is_junction", None)
|
|
787
|
+
return path.is_symlink() or (callable(junction_check) and junction_check())
|
|
788
|
+
|
|
789
|
+
for current_root, directory_names, file_names in os.walk(source, topdown=True, followlinks=False):
|
|
790
|
+
current_path = Path(current_root)
|
|
791
|
+
retained_directories: list[str] = []
|
|
792
|
+
for directory_name in directory_names:
|
|
793
|
+
directory_path = current_path / directory_name
|
|
794
|
+
relative_parts = directory_path.relative_to(source).parts
|
|
795
|
+
if any(part in _REFERENCE_SOURCE_SKIP_DIRS for part in relative_parts):
|
|
796
|
+
continue
|
|
797
|
+
if is_directory_link(directory_path):
|
|
798
|
+
raise PackImportError(
|
|
799
|
+
f"reference plugin source contains symlink or junction: {directory_path}",
|
|
800
|
+
stage="reference-install",
|
|
801
|
+
code="PLUGIN_SOURCE_UNSAFE",
|
|
802
|
+
)
|
|
803
|
+
retained_directories.append(directory_name)
|
|
804
|
+
directory_names[:] = retained_directories
|
|
805
|
+
|
|
806
|
+
for file_name in file_names:
|
|
807
|
+
path = current_path / file_name
|
|
808
|
+
relative = path.relative_to(source).as_posix()
|
|
809
|
+
validate_archive_path(f"package/{relative}", expected_root="package")
|
|
810
|
+
if path.is_symlink():
|
|
811
|
+
raise PackImportError(
|
|
812
|
+
f"reference plugin source contains symlink: {path}",
|
|
813
|
+
stage="reference-install",
|
|
814
|
+
code="PLUGIN_SOURCE_UNSAFE",
|
|
815
|
+
)
|
|
816
|
+
destination = target / Path(*path.relative_to(source).parts)
|
|
817
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
818
|
+
shutil.copy2(path, destination)
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _verify_identity(profile_path: Path, plugin: Mapping[str, Any]) -> dict[str, Any]:
|
|
822
|
+
name = str(plugin["name"])
|
|
823
|
+
expected_version = expected_install_version(plugin)
|
|
824
|
+
installed = _package_manifest(_package_install_path(profile_path, name), stage="version-confirmation")
|
|
825
|
+
if installed["name"] != name or installed["version"] != expected_version:
|
|
826
|
+
raise PackImportError(
|
|
827
|
+
f"installed {name}@{installed.get('version')} does not match Pack {name}@{expected_version}",
|
|
828
|
+
stage="version-confirmation",
|
|
829
|
+
code="PLUGIN_VERSION_MISMATCH",
|
|
830
|
+
details={
|
|
831
|
+
"plugin": name,
|
|
832
|
+
"expected": {"name": name, "version": expected_version},
|
|
833
|
+
"observed": {"name": installed.get("name"), "version": installed.get("version")},
|
|
834
|
+
},
|
|
835
|
+
)
|
|
836
|
+
bundle = plugin.get("bundle")
|
|
837
|
+
if isinstance(bundle, Mapping) and bundle.get("enabled") is True:
|
|
838
|
+
patch = bundle.get("patch")
|
|
839
|
+
package_root = _package_install_path(profile_path, name)
|
|
840
|
+
try:
|
|
841
|
+
paths = bundle_patch_paths(patch)
|
|
842
|
+
for relative in paths:
|
|
843
|
+
package_root.joinpath(*relative.split("/")).resolve().relative_to(package_root.resolve())
|
|
844
|
+
except (DshPackError, ValueError) as error:
|
|
845
|
+
raise PackImportError(
|
|
846
|
+
f"installed Bundle has an unsafe patch path: {name}",
|
|
847
|
+
stage="version-confirmation", code="BUNDLE_PATCH_INVALID",
|
|
848
|
+
details={"plugin": name, "patch": patch},
|
|
849
|
+
) from error
|
|
850
|
+
try:
|
|
851
|
+
check_patch_declaration(installed, patch)
|
|
852
|
+
except DshPackError as error:
|
|
853
|
+
raise PackImportError(
|
|
854
|
+
f"installed Bundle patch declaration differs from Crate: {name}",
|
|
855
|
+
stage="version-confirmation", code="BUNDLE_PATCH_MISMATCH",
|
|
856
|
+
details={"plugin": name, "expected": patch},
|
|
857
|
+
) from error
|
|
858
|
+
for relative in paths:
|
|
859
|
+
if not package_root.joinpath(*relative.split("/")).is_file():
|
|
860
|
+
raise PackImportError(
|
|
861
|
+
f"installed Bundle patch is missing: {name}/{relative}",
|
|
862
|
+
stage="version-confirmation", code="BUNDLE_PATCH_MISSING",
|
|
863
|
+
details={"plugin": name, "patch": relative},
|
|
864
|
+
)
|
|
865
|
+
try:
|
|
866
|
+
read_bundle_patches(package_root, patch)
|
|
867
|
+
except DshPackError as error:
|
|
868
|
+
raise PackImportError(
|
|
869
|
+
f"installed Bundle patch content is unsafe: {name}",
|
|
870
|
+
stage="version-confirmation", code="BUNDLE_PATCH_INVALID",
|
|
871
|
+
details={"plugin": name, "patch": patch},
|
|
872
|
+
) from error
|
|
873
|
+
return {"name": name, "version": installed["version"], "mode": plugin["artifact"]["mode"]}
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _remove_staged_package(profile_path: Path, name: str, *, stage: str) -> None:
|
|
877
|
+
"""Replace only a package inside the temporary Profile, without following links."""
|
|
878
|
+
target = _package_install_path(profile_path, name)
|
|
879
|
+
# A package manager can create scoped directories or links. Reject linked
|
|
880
|
+
# ancestors before removing anything; never resolve through them for deletion.
|
|
881
|
+
for parent in target.parents:
|
|
882
|
+
if parent == profile_path:
|
|
883
|
+
break
|
|
884
|
+
if parent.is_symlink() or (getattr(parent, "is_junction", lambda: False)()):
|
|
885
|
+
raise PackImportError(
|
|
886
|
+
f"staged package ancestor is a link: {name}",
|
|
887
|
+
stage=stage, code="PLUGIN_TARGET_UNSAFE",
|
|
888
|
+
details={"plugin": name},
|
|
889
|
+
)
|
|
890
|
+
if target.is_symlink():
|
|
891
|
+
target.unlink()
|
|
892
|
+
elif getattr(target, "is_junction", lambda: False)():
|
|
893
|
+
target.rmdir()
|
|
894
|
+
elif target.is_dir():
|
|
895
|
+
shutil.rmtree(target)
|
|
896
|
+
elif target.exists():
|
|
897
|
+
target.unlink()
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
def _write_network_manifest(profile_path: Path, dependencies: Mapping[str, str]) -> None:
|
|
901
|
+
# The exported Profile can contain local paths, embedded package registry
|
|
902
|
+
# requests, or optional sources that failed. npm must only see successful
|
|
903
|
+
# network references; the original Profile manifest is restored afterwards.
|
|
904
|
+
(profile_path / "package.json").write_text(
|
|
905
|
+
json.dumps({"name": "dsh-crate-import-staging", "private": True,
|
|
906
|
+
"dependencies": dict(dependencies)}), encoding="utf-8",
|
|
907
|
+
)
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _network_dependency_spec(plugin: Mapping[str, Any]) -> str:
|
|
911
|
+
specifier = npm_install_specifier(plugin)
|
|
912
|
+
assert specifier is not None # the network plan already checked support
|
|
913
|
+
prefix = f"{plugin['name']}@"
|
|
914
|
+
return specifier[len(prefix):] if specifier.startswith(prefix) else specifier
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def _host_dependency_overrides(data: PackData, options: ImportOptions) -> tuple[dict[str, str], list[dict[str, Any]]]:
|
|
918
|
+
"""Use dependencies from a verified installation anchor, never by name alone.
|
|
919
|
+
|
|
920
|
+
Desktop supplies public manifest/file evidence read from its actual ASAR.
|
|
921
|
+
The Bundle artifact itself remains installed and managed by pnpm. Only its
|
|
922
|
+
already-present host dependencies are omitted from the Profile's pnpm tree,
|
|
923
|
+
where a second copy of host/native internals could shadow the official host.
|
|
924
|
+
"""
|
|
925
|
+
if options.host_bundles is None:
|
|
926
|
+
return {}, []
|
|
927
|
+
overrides: dict[str, str] = {}
|
|
928
|
+
provided: list[dict[str, Any]] = []
|
|
929
|
+
for plugin in data.plugins_lock['plugins']:
|
|
930
|
+
if not plugin.get('bundle', {}).get('enabled') or plugin.get('runtime', {}).get('source') != 'installation-anchor':
|
|
931
|
+
continue
|
|
932
|
+
artifact = plugin.get('artifact', {})
|
|
933
|
+
if artifact.get('mode') != 'embedded':
|
|
934
|
+
continue
|
|
935
|
+
name = str(plugin['name'])
|
|
936
|
+
host = options.host_bundles.get(name)
|
|
937
|
+
def mismatch(message):
|
|
938
|
+
return PackImportError(message, stage='planning', code='HOST_BUNDLE_MISMATCH', details={
|
|
939
|
+
'plugin': name, 'suggestedNextStep': '请使用与 Crate 实际制品一致的官方桌面安装锚点,或重新导出当前环境;不要用另一份宿主依赖覆盖原生运行时。'})
|
|
940
|
+
if not isinstance(host, Mapping) or not isinstance(host.get('manifest'), Mapping) or not isinstance(host.get('files'), Mapping):
|
|
941
|
+
raise mismatch('the target host has no verified evidence for an installation-anchor Bundle')
|
|
942
|
+
content = data.plugin_artifacts[artifact['path'][len('plugins/'):]]
|
|
943
|
+
try:
|
|
944
|
+
archive_context = tarfile.open(fileobj=BytesIO(content), mode='r:gz')
|
|
945
|
+
except (tarfile.TarError, OSError) as error:
|
|
946
|
+
raise mismatch('the host Bundle artifact cannot be read') from error
|
|
947
|
+
with archive_context as archive:
|
|
948
|
+
try:
|
|
949
|
+
manifest_member = archive.getmember('package/package.json')
|
|
950
|
+
if not manifest_member.isfile():
|
|
951
|
+
raise mismatch('the host Bundle manifest must be a regular file')
|
|
952
|
+
manifest = json.loads(archive.extractfile(manifest_member).read())
|
|
953
|
+
except (tarfile.TarError, KeyError, ValueError, OSError) as error:
|
|
954
|
+
raise mismatch('the host Bundle artifact has no readable package manifest') from error
|
|
955
|
+
if manifest != host['manifest'] or manifest.get('version') != expected_install_version(plugin):
|
|
956
|
+
raise mismatch('the host Bundle manifest differs from the embedded artifact')
|
|
957
|
+
for member in archive:
|
|
958
|
+
if member.isdir():
|
|
959
|
+
continue
|
|
960
|
+
relative = member.name.removeprefix('package/')
|
|
961
|
+
if relative == 'package.json':
|
|
962
|
+
continue # compared semantically above, including dependencies
|
|
963
|
+
if member.isfile():
|
|
964
|
+
payload = archive.extractfile(member).read()
|
|
965
|
+
elif member.islnk():
|
|
966
|
+
payload = _read_artifact_hardlink(archive, member)
|
|
967
|
+
else:
|
|
968
|
+
raise mismatch('the host Bundle artifact contains unsupported members')
|
|
969
|
+
if host['files'].get(relative) != sha256_bytes(payload):
|
|
970
|
+
raise mismatch('the host Bundle files differ from the embedded artifact')
|
|
971
|
+
installed_dependencies = host.get('dependencies', {})
|
|
972
|
+
if not isinstance(installed_dependencies, Mapping):
|
|
973
|
+
raise mismatch('the host dependency evidence is invalid')
|
|
974
|
+
for field in ('dependencies', 'optionalDependencies'):
|
|
975
|
+
for dependency, requested in manifest.get(field, {}).items():
|
|
976
|
+
version = installed_dependencies.get(dependency)
|
|
977
|
+
if not isinstance(version, str) or not version:
|
|
978
|
+
if field == 'optionalDependencies':
|
|
979
|
+
continue
|
|
980
|
+
raise mismatch('a declared Bundle dependency is missing from the target host')
|
|
981
|
+
overrides[name + '>' + dependency] = '-'
|
|
982
|
+
provided.append({'bundle': name, 'name': dependency, 'requested': requested,
|
|
983
|
+
'runtimeVersion': version, 'source': 'verified-installation-anchor'})
|
|
984
|
+
return overrides, provided
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def _adapt_profile_patch(profile: Path, options: ImportOptions) -> dict[str, Any] | None:
|
|
988
|
+
if not options.patch_adapter_command:
|
|
989
|
+
return None
|
|
990
|
+
path = profile / 'cordis.patch.yml'
|
|
991
|
+
original = path.read_bytes() if path.is_file() else None
|
|
992
|
+
command = [*options.patch_adapter_command, str(profile)]
|
|
993
|
+
completed = None
|
|
994
|
+
def safe_output(value: Any) -> str:
|
|
995
|
+
text = value.decode('utf-8', errors='replace') if isinstance(value, bytes) else str(value or '')
|
|
996
|
+
for name, secret in os.environ.items():
|
|
997
|
+
if secret and re.search(r'KEY|TOKEN|PASSWORD|SECRET|COOKIE|AUTH|PRIVATE|CREDENTIAL', name, re.I):
|
|
998
|
+
text = text.replace(secret, '[REDACTED]')
|
|
999
|
+
text = re.sub(r'(?i)(Bearer\s+)[^\s"\'<>]+', r'\1[REDACTED]', text)
|
|
1000
|
+
return re.sub(r'(?i)([?&](?:token|access_token|auth|api_key)=)[^\s&#"\'<>]+', r'\1[REDACTED]', text)
|
|
1001
|
+
try:
|
|
1002
|
+
completed = subprocess.run(command, cwd=profile,
|
|
1003
|
+
env=dict(os.environ, ELECTRON_RUN_AS_NODE='1'), capture_output=True,
|
|
1004
|
+
text=True, timeout=max(1, options.network_install_timeout), check=False)
|
|
1005
|
+
result = json.loads(completed.stdout)
|
|
1006
|
+
if completed.returncode or not isinstance(result, dict) or result.get('error'):
|
|
1007
|
+
raise ValueError('the official patch equivalence adapter failed')
|
|
1008
|
+
if type(result.get('changed')) is not bool:
|
|
1009
|
+
raise ValueError('patch adapter changed flag is invalid')
|
|
1010
|
+
if result.get('changed') is not True:
|
|
1011
|
+
if not isinstance(result.get('reason'), str) or not result['reason']:
|
|
1012
|
+
raise ValueError('patch adapter unchanged evidence is invalid')
|
|
1013
|
+
return {'changed': False, 'reason': result.get('reason', 'unchanged')}
|
|
1014
|
+
content = base64.b64decode(result['contentBase64'], validate=True)
|
|
1015
|
+
if original is None or sha256_bytes(original) != result['originalSha256'] or sha256_bytes(content) != result['runtimeSha256']:
|
|
1016
|
+
raise ValueError('patch adapter content identity mismatch')
|
|
1017
|
+
if path.read_bytes() != original:
|
|
1018
|
+
raise ValueError('patch changed while the equivalence adapter was running')
|
|
1019
|
+
changes = result.get('removedNames')
|
|
1020
|
+
tree_hash = result.get('loaderTreeSha256')
|
|
1021
|
+
if (not isinstance(changes, list) or not changes or any(not isinstance(row, dict) or set(row) != {'id', 'name'}
|
|
1022
|
+
or not all(isinstance(value, str) and value for value in row.values()) for row in changes)
|
|
1023
|
+
or not isinstance(tree_hash, str) or not re.fullmatch('[a-f0-9]{64}', tree_hash)):
|
|
1024
|
+
raise ValueError('patch adapter equivalence evidence is missing')
|
|
1025
|
+
backup = profile / '.dsh-crate/original-profile-config/cordis.patch.yml'
|
|
1026
|
+
backup.write_bytes(original)
|
|
1027
|
+
path.write_bytes(content)
|
|
1028
|
+
return {'changed': True, 'originalSha256': sha256_bytes(original),
|
|
1029
|
+
'runtimeSha256': sha256_bytes(content), 'loaderTreeSha256': tree_hash,
|
|
1030
|
+
'removedNames': changes, 'adapter': 'official-desktop-composition-equivalence-v1'}
|
|
1031
|
+
except (OSError, ValueError, KeyError, TypeError, subprocess.TimeoutExpired) as error:
|
|
1032
|
+
raise PackImportError('cannot preserve official Loader composition while adapting the target patch',
|
|
1033
|
+
stage='configuration', code='PROFILE_PATCH_ADAPTATION_FAILED',
|
|
1034
|
+
details={'command': command, 'reason': safe_output(str(error)),
|
|
1035
|
+
'exitCode': completed.returncode if completed is not None else None,
|
|
1036
|
+
'stdout': safe_output(completed.stdout if completed is not None else getattr(error, 'stdout', '')),
|
|
1037
|
+
'stderr': safe_output(completed.stderr if completed is not None else getattr(error, 'stderr', '')),
|
|
1038
|
+
'suggestedNextStep': '保留源 Profile 和原始 Crate,检查官方解析器及完整诊断;不要删除必需插件或绕过配置等价校验。'}) from error
|
|
1039
|
+
|
|
1040
|
+
|
|
1041
|
+
def _pnpm_rebuild(profile: Path, data: PackData, plugins: list[Mapping[str, Any]], options: ImportOptions) -> dict[str, Any]:
|
|
1042
|
+
"""Reconstruct manager-owned state from verified artifacts, never copy it.
|
|
1043
|
+
|
|
1044
|
+
Source SPEC remains in the Pack and original-package.json. The restored
|
|
1045
|
+
Profile deliberately requests stable local artifacts so later official
|
|
1046
|
+
manager operations do not depend on paths on the exporting machine.
|
|
1047
|
+
"""
|
|
1048
|
+
command = options.pnpm_command
|
|
1049
|
+
assert command
|
|
1050
|
+
overrides, host_dependencies = _host_dependency_overrides(data, options)
|
|
1051
|
+
artifacts = profile / '.dsh-crate' / 'artifacts'
|
|
1052
|
+
artifacts.mkdir(parents=True, exist_ok=False)
|
|
1053
|
+
original = data.profile_files['package.json']
|
|
1054
|
+
(artifacts.parent / 'original-package.json').write_bytes(original)
|
|
1055
|
+
manifest = json.loads(original)
|
|
1056
|
+
dependencies: dict[str, str] = {}
|
|
1057
|
+
for plugin in plugins:
|
|
1058
|
+
name = str(plugin['name'])
|
|
1059
|
+
artifact = plugin['artifact']
|
|
1060
|
+
if artifact['mode'] == 'embedded':
|
|
1061
|
+
content = data.plugin_artifacts[artifact['path'][len('plugins/'):]]
|
|
1062
|
+
else:
|
|
1063
|
+
# The existing installer resolved and verified this reference.
|
|
1064
|
+
# Turn its portable package files into a local rebuild artifact.
|
|
1065
|
+
source = _package_install_path(profile, name)
|
|
1066
|
+
buffer = BytesIO()
|
|
1067
|
+
with tarfile.open(fileobj=buffer, mode='w:gz') as archive:
|
|
1068
|
+
for item in sorted(source.rglob('*')):
|
|
1069
|
+
relative = item.relative_to(source)
|
|
1070
|
+
if any(part in _REFERENCE_SOURCE_SKIP_DIRS for part in relative.parts):
|
|
1071
|
+
continue
|
|
1072
|
+
if item.is_symlink() or getattr(item, 'is_junction', lambda: False)():
|
|
1073
|
+
raise PackImportError('reference artifact contains a link', stage='configuration', code='PLUGIN_TARGET_UNSAFE', details={'plugin': name})
|
|
1074
|
+
if item.is_file():
|
|
1075
|
+
archive.add(item, arcname='package/' + relative.as_posix(), recursive=False)
|
|
1076
|
+
content = buffer.getvalue()
|
|
1077
|
+
filename = sha256_bytes(content) + '.tgz'
|
|
1078
|
+
(artifacts / filename).write_bytes(content)
|
|
1079
|
+
dependencies[name] = 'file:./.dsh-crate/artifacts/' + filename
|
|
1080
|
+
_remove_staged_package(profile, name, stage='configuration')
|
|
1081
|
+
manifest['dependencies'] = dependencies
|
|
1082
|
+
# Imported optional packages are represented once in the rebuilt tree.
|
|
1083
|
+
manifest.pop('optionalDependencies', None)
|
|
1084
|
+
for field in ('pnpm', 'scripts', 'devDependencies', 'packageManager'):
|
|
1085
|
+
manifest.pop(field, None)
|
|
1086
|
+
(profile / 'package.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
|
|
1087
|
+
# Source workspace/auth/hook configuration must not control the rebuild or
|
|
1088
|
+
# later official manager operations. Keep the originals as inert evidence.
|
|
1089
|
+
originals = artifacts.parent / 'original-profile-config'
|
|
1090
|
+
originals.mkdir()
|
|
1091
|
+
for name in ('pnpm-workspace.yaml', '.npmrc', '.pnpmfile.cjs', '.pnpmfile.mjs'):
|
|
1092
|
+
path = profile / name
|
|
1093
|
+
if path.exists():
|
|
1094
|
+
path.rename(originals / name)
|
|
1095
|
+
(profile / 'pnpm-workspace.yaml').write_text(
|
|
1096
|
+
'packages:\n - .\nnodeLinker: hoisted\nautoInstallPeers: false\nignorePnpmfile: true\nignoreScripts: true\nrecursiveInstall: false\npackageImportMethod: copy\n'
|
|
1097
|
+
+ 'overrides: ' + json.dumps(overrides,ensure_ascii=False) + '\n', encoding='utf-8')
|
|
1098
|
+
# The source lock describes a different machine and different requests.
|
|
1099
|
+
(profile / 'pnpm-lock.yaml').unlink(missing_ok=True)
|
|
1100
|
+
args = [*command, 'install', '--ignore-scripts', '--prod', '--no-frozen-lockfile',
|
|
1101
|
+
'--node-linker=hoisted', '--config.auto-install-peers=false',
|
|
1102
|
+
'--config.manage-package-manager-versions=false', '--config.ignore-pnpmfile=true',
|
|
1103
|
+
'--config.recursive-install=false', '--config.lockfile-dir=' + str(profile),
|
|
1104
|
+
'--config.modules-dir=' + str(profile / 'node_modules'), '--config.virtual-store-dir=' + str(profile / 'node_modules/.pnpm'),
|
|
1105
|
+
# Hard-linking a cached package into another Profile changes the
|
|
1106
|
+
# existing file's ctime. DSH uses ctime in client module identities,
|
|
1107
|
+
# so that can remount the source UI during its own Import request.
|
|
1108
|
+
'--config.package-import-method=copy',
|
|
1109
|
+
'--config.enable-global-virtual-store=false', '--config.shared-workspace-lockfile=false']
|
|
1110
|
+
if not options.allow_network_reference_install:
|
|
1111
|
+
args.append('--offline')
|
|
1112
|
+
environment = dict(os.environ, ELECTRON_RUN_AS_NODE='1')
|
|
1113
|
+
try:
|
|
1114
|
+
completed = subprocess.run(args, cwd=profile, env=environment, capture_output=True, text=True,
|
|
1115
|
+
timeout=max(1, options.network_install_timeout), check=False)
|
|
1116
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
1117
|
+
raise PackImportError('cannot rebuild the target with pnpm', stage='configuration', code='PACKAGE_MANAGER_FAILED', details={'error': str(error), 'command': args}) from error
|
|
1118
|
+
if completed.returncode:
|
|
1119
|
+
raise PackImportError('pnpm could not rebuild the target installation', stage='configuration', code='PACKAGE_MANAGER_FAILED', details={
|
|
1120
|
+
'command': args, 'exitCode': completed.returncode,
|
|
1121
|
+
'stdout': _network_error_output(completed.stdout), 'stderr': _network_error_output(completed.stderr)})
|
|
1122
|
+
if not (profile / 'node_modules/.modules.yaml').is_file() or not (profile / 'pnpm-lock.yaml').is_file():
|
|
1123
|
+
raise PackImportError('pnpm did not create the required installation records', stage='configuration', code='PACKAGE_MANAGER_STATE_MISSING')
|
|
1124
|
+
# pnpm 11 writes this record as JSON, even with its legacy .yaml suffix.
|
|
1125
|
+
# Its reader supports a virtualStoreDir relative to node_modules on all
|
|
1126
|
+
# platforms, while its Windows writer emits an absolute staging path.
|
|
1127
|
+
# Only normalize the generated hoisted record we just installed; never
|
|
1128
|
+
# copy machine installation state out of a Pack or alter user profiles.
|
|
1129
|
+
modules_path = profile / 'node_modules/.modules.yaml'
|
|
1130
|
+
try:
|
|
1131
|
+
modules = json.loads(modules_path.read_text(encoding='utf-8'))
|
|
1132
|
+
virtual_store = Path(modules['virtualStoreDir'])
|
|
1133
|
+
if not virtual_store.is_absolute():
|
|
1134
|
+
virtual_store = profile / 'node_modules' / virtual_store
|
|
1135
|
+
if modules.get('nodeLinker') != 'hoisted' or virtual_store.resolve() != (profile / 'node_modules/.pnpm').resolve():
|
|
1136
|
+
raise ValueError(f"unexpected generated virtual store: {modules.get('nodeLinker')} / {modules.get('virtualStoreDir')}")
|
|
1137
|
+
modules['virtualStoreDir'] = '.pnpm'
|
|
1138
|
+
modules_path.write_text(json.dumps(modules, indent=2) + '\n', encoding='utf-8')
|
|
1139
|
+
except (ValueError, KeyError, TypeError) as error:
|
|
1140
|
+
raise PackImportError('pnpm installation records cannot be safely relocated', stage='configuration', code='PACKAGE_MANAGER_STATE_UNSUPPORTED', details={'error': str(error)}) from error
|
|
1141
|
+
adaptation = _adapt_profile_patch(profile, options)
|
|
1142
|
+
return {'resolver': 'pnpm', 'scripts': 'ignored', 'packageImportMethod': 'copy', 'requests': dependencies,
|
|
1143
|
+
**({'profilePatchAdaptation': adaptation} if adaptation is not None else {}),
|
|
1144
|
+
'hostProvidedDependencies': host_dependencies,
|
|
1145
|
+
'workspaceSha256': sha256_bytes((profile / 'pnpm-workspace.yaml').read_bytes()),
|
|
1146
|
+
'originalManifest': '.dsh-crate/original-package.json',
|
|
1147
|
+
'originalManifestSha256': sha256_bytes(original),
|
|
1148
|
+
'stdout': _network_error_output(completed.stdout)}
|
|
1149
|
+
|
|
1150
|
+
|
|
1151
|
+
def _metadata_payload(plan: ImportPlan, data: PackData, installed: tuple[dict[str, Any], ...], pack_digest: str, installation: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
|
1152
|
+
return {
|
|
1153
|
+
"schemaVersion": 1,
|
|
1154
|
+
"status": "prepared",
|
|
1155
|
+
"pack": {"sha256": pack_digest, "path": str(plan.pack_path)},
|
|
1156
|
+
"profile": {"name": plan.profile_name, "path": str(plan.profile_path)},
|
|
1157
|
+
"overwrite": plan.overwrite,
|
|
1158
|
+
"plugins": list(installed),
|
|
1159
|
+
"requiredSecrets": list(data.manifest.get("requiredSecrets", [])),
|
|
1160
|
+
**({'installation': {key: value for key, value in installation.items() if key != 'stdout'}} if installation else {}),
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
def _cleanup(path: Path | None) -> None:
|
|
1165
|
+
if path is None or not path.exists():
|
|
1166
|
+
return
|
|
1167
|
+
if path.is_dir():
|
|
1168
|
+
shutil.rmtree(path)
|
|
1169
|
+
else:
|
|
1170
|
+
path.unlink()
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
def _cleanup_empty(path: Path | None) -> None:
|
|
1174
|
+
"""Remove a newly-created directory only when it is empty."""
|
|
1175
|
+
|
|
1176
|
+
if path is None or not path.is_dir():
|
|
1177
|
+
return
|
|
1178
|
+
try:
|
|
1179
|
+
next(path.iterdir())
|
|
1180
|
+
except StopIteration:
|
|
1181
|
+
path.rmdir()
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def import_pack(
|
|
1185
|
+
source: str | os.PathLike[str],
|
|
1186
|
+
dsh_home: str | os.PathLike[str],
|
|
1187
|
+
*,
|
|
1188
|
+
options: ImportOptions | None = None,
|
|
1189
|
+
) -> ImportResult:
|
|
1190
|
+
"""Import one Pack as a prepared Profile with an explicit safe mode.
|
|
1191
|
+
|
|
1192
|
+
The default mode creates a new Profile and refuses duplicate names. An
|
|
1193
|
+
overwrite is accepted only when the caller supplies the exact confirmation
|
|
1194
|
+
token produced after a user-facing confirmation dialog.
|
|
1195
|
+
"""
|
|
1196
|
+
|
|
1197
|
+
options = options or ImportOptions()
|
|
1198
|
+
if options.overwrite_existing_profile and options.overwrite_confirmation != "OVERWRITE":
|
|
1199
|
+
raise PackImportError(
|
|
1200
|
+
"overwriting an existing Profile requires explicit confirmation",
|
|
1201
|
+
stage="planning",
|
|
1202
|
+
code="OVERWRITE_CONFIRMATION_REQUIRED",
|
|
1203
|
+
details={
|
|
1204
|
+
"expected": "OVERWRITE",
|
|
1205
|
+
"observed": "confirmation missing or invalid",
|
|
1206
|
+
"impact": "No Profile was changed.",
|
|
1207
|
+
"suggestedNextStep": "Confirm the destructive overwrite in the user interface and retry.",
|
|
1208
|
+
},
|
|
1209
|
+
)
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
if options.overwrite_confirmation is not None and not options.overwrite_existing_profile:
|
|
1213
|
+
raise PackImportError(
|
|
1214
|
+
"overwrite confirmation cannot be used without overwrite mode",
|
|
1215
|
+
stage="planning",
|
|
1216
|
+
code="OVERWRITE_MODE_REQUIRED",
|
|
1217
|
+
details={"impact": "No Profile was changed."},
|
|
1218
|
+
)
|
|
1219
|
+
source_path = Path(source).resolve()
|
|
1220
|
+
home_path = Path(dsh_home).resolve()
|
|
1221
|
+
try:
|
|
1222
|
+
pack_data = read(source_path)
|
|
1223
|
+
except DshPackError:
|
|
1224
|
+
pack_data = None
|
|
1225
|
+
if pack_data is not None:
|
|
1226
|
+
options = _auto_reference_sources(pack_data, home_path, options)
|
|
1227
|
+
plan = create_import_plan(source_path, home_path, options=options)
|
|
1228
|
+
try:
|
|
1229
|
+
data = read(plan.pack_path)
|
|
1230
|
+
except DshPackError as error:
|
|
1231
|
+
raise PackImportError(str(error), stage="preflight", code="PACK_READ_ERROR") from error
|
|
1232
|
+
|
|
1233
|
+
from .portability import check_targets, restore_files
|
|
1234
|
+
check_targets(plan.dsh_home, data.user_data, overwrite=plan.overwrite)
|
|
1235
|
+
data_created: list[Path] = []
|
|
1236
|
+
data_directories: list[Path] = []
|
|
1237
|
+
profiles_root = plan.dsh_home / "profiles"
|
|
1238
|
+
temp_profile: Path | None = None
|
|
1239
|
+
final_created = False
|
|
1240
|
+
metadata_created = False
|
|
1241
|
+
original_profile_backup: Path | None = None
|
|
1242
|
+
original_metadata_backup: Path | None = None
|
|
1243
|
+
backup_token = uuid.uuid4().hex
|
|
1244
|
+
network_plugins = {str(plugin.get("name")): plugin for plugin in _network_reference_plugins(data, options)}
|
|
1245
|
+
network_installs: list[dict[str, Any]] = []
|
|
1246
|
+
warnings: list[dict[str, Any]] = []
|
|
1247
|
+
metadata_parent = plan.metadata_path.parent
|
|
1248
|
+
metadata_root = metadata_parent.parent
|
|
1249
|
+
metadata_parent_existed = metadata_parent.exists()
|
|
1250
|
+
metadata_root_existed = metadata_root.exists()
|
|
1251
|
+
try:
|
|
1252
|
+
profiles_root.mkdir(parents=True, exist_ok=True)
|
|
1253
|
+
temp_profile = Path(tempfile.mkdtemp(prefix=f".{plan.profile_name}.dsh-pack-", dir=profiles_root))
|
|
1254
|
+
for relative, content in data.profile_files.items():
|
|
1255
|
+
# Older Crate exporters included their own target installation
|
|
1256
|
+
# records. Never restore these as authoritative state; rebuild
|
|
1257
|
+
# them from the verified plugin artifacts below.
|
|
1258
|
+
if relative.split('/')[0].casefold() == '.dsh-crate':
|
|
1259
|
+
continue
|
|
1260
|
+
_write_file(temp_profile, relative, content)
|
|
1261
|
+
|
|
1262
|
+
installed: list[dict[str, Any]] = []
|
|
1263
|
+
lock_plugins = [plugin for plugin in data.plugins_lock.get("plugins", []) if isinstance(plugin, Mapping)]
|
|
1264
|
+
installed_names: set[str] = set()
|
|
1265
|
+
|
|
1266
|
+
# npm reconciles the entire tree and can prune previously extracted
|
|
1267
|
+
# packages. Complete every npm operation before materializing embedded
|
|
1268
|
+
# artifacts and explicit local sources.
|
|
1269
|
+
network_dependencies: dict[str, str] = {}
|
|
1270
|
+
if network_plugins:
|
|
1271
|
+
_write_network_manifest(temp_profile, network_dependencies)
|
|
1272
|
+
for plugin in lock_plugins:
|
|
1273
|
+
artifact = plugin.get("artifact")
|
|
1274
|
+
if not isinstance(artifact, Mapping) or artifact.get("mode") != "reference-only":
|
|
1275
|
+
continue
|
|
1276
|
+
name = str(plugin["name"])
|
|
1277
|
+
if options.reference_installer is not None or name in options.reference_plugin_sources:
|
|
1278
|
+
continue
|
|
1279
|
+
if not (options.allow_network_reference_install and name in network_plugins):
|
|
1280
|
+
continue
|
|
1281
|
+
try:
|
|
1282
|
+
network_installs.append(_install_network_reference(temp_profile, plugin, options))
|
|
1283
|
+
except PackImportError as error:
|
|
1284
|
+
if plugin.get("required") is True:
|
|
1285
|
+
raise
|
|
1286
|
+
_remove_staged_package(temp_profile, name, stage="network-install")
|
|
1287
|
+
warnings.append(error.as_dict())
|
|
1288
|
+
continue
|
|
1289
|
+
installed_names.add(name)
|
|
1290
|
+
network_dependencies[name] = _network_dependency_spec(plugin)
|
|
1291
|
+
_write_network_manifest(temp_profile, network_dependencies)
|
|
1292
|
+
if network_plugins:
|
|
1293
|
+
(temp_profile / "package.json").write_bytes(data.profile_files["package.json"])
|
|
1294
|
+
|
|
1295
|
+
for plugin in lock_plugins:
|
|
1296
|
+
artifact = plugin.get("artifact")
|
|
1297
|
+
if not isinstance(artifact, Mapping) or artifact.get("mode") != "embedded":
|
|
1298
|
+
continue
|
|
1299
|
+
path = artifact.get("path")
|
|
1300
|
+
if not isinstance(path, str):
|
|
1301
|
+
raise PackImportError(
|
|
1302
|
+
f"embedded artifact path missing for {plugin.get('name')}",
|
|
1303
|
+
stage="embedded-install",
|
|
1304
|
+
code="ARTIFACT_PATH_MISSING",
|
|
1305
|
+
)
|
|
1306
|
+
content = data.plugin_artifacts.get(path[len("plugins/"):])
|
|
1307
|
+
if content is None:
|
|
1308
|
+
raise PackImportError(
|
|
1309
|
+
f"embedded artifact missing for {plugin.get('name')}: {path}",
|
|
1310
|
+
stage="embedded-install",
|
|
1311
|
+
code="REQUIRED_ARTIFACT_MISSING",
|
|
1312
|
+
)
|
|
1313
|
+
_remove_staged_package(temp_profile, str(plugin["name"]), stage="embedded-install")
|
|
1314
|
+
_extract_embedded_artifact(temp_profile, plugin, content)
|
|
1315
|
+
installed_names.add(str(plugin["name"]))
|
|
1316
|
+
|
|
1317
|
+
for plugin in lock_plugins:
|
|
1318
|
+
artifact = plugin.get("artifact")
|
|
1319
|
+
if not isinstance(artifact, Mapping) or artifact.get("mode") != "reference-only":
|
|
1320
|
+
continue
|
|
1321
|
+
name = str(plugin["name"])
|
|
1322
|
+
if options.reference_installer is not None:
|
|
1323
|
+
try:
|
|
1324
|
+
options.reference_installer(temp_profile, plugin)
|
|
1325
|
+
except PackImportError:
|
|
1326
|
+
raise
|
|
1327
|
+
except Exception as error:
|
|
1328
|
+
raise PackImportError(
|
|
1329
|
+
f"reference-only plugin installation failed: {name}",
|
|
1330
|
+
stage="reference-install",
|
|
1331
|
+
code="PLUGIN_INSTALL_FAILED",
|
|
1332
|
+
details={"plugin": name, "error": str(error)},
|
|
1333
|
+
) from error
|
|
1334
|
+
installed_names.add(name)
|
|
1335
|
+
continue
|
|
1336
|
+
if name in options.reference_plugin_sources:
|
|
1337
|
+
_remove_staged_package(temp_profile, name, stage="reference-install")
|
|
1338
|
+
_copy_reference_source(temp_profile, plugin, Path(options.reference_plugin_sources[name]))
|
|
1339
|
+
installed_names.add(name)
|
|
1340
|
+
continue
|
|
1341
|
+
if options.allow_network_reference_install and name in network_plugins:
|
|
1342
|
+
continue # final identity verification follows all installs
|
|
1343
|
+
if plugin.get("required") is True:
|
|
1344
|
+
raise PackImportError(
|
|
1345
|
+
f"required reference-only plugin was not installed: {name}",
|
|
1346
|
+
stage="reference-install",
|
|
1347
|
+
code="PLUGIN_MISSING",
|
|
1348
|
+
details={"plugin": name},
|
|
1349
|
+
)
|
|
1350
|
+
|
|
1351
|
+
# A later npm operation or installer can replace an earlier package.
|
|
1352
|
+
# Confirm the final tree, not stale per-install observations, before any
|
|
1353
|
+
# portable Home data is written or the prepared Profile is committed.
|
|
1354
|
+
if options.pnpm_command:
|
|
1355
|
+
selected = [plugin for plugin in lock_plugins if str(plugin['name']) in installed_names]
|
|
1356
|
+
# Validate the inputs before letting the manager consume them.
|
|
1357
|
+
for plugin in selected:
|
|
1358
|
+
_verify_identity(temp_profile, plugin)
|
|
1359
|
+
network_installs.append(_pnpm_rebuild(temp_profile, data, selected, options))
|
|
1360
|
+
installed = [_verify_identity(temp_profile, plugin) for plugin in lock_plugins
|
|
1361
|
+
if str(plugin["name"]) in installed_names]
|
|
1362
|
+
|
|
1363
|
+
restore_files(plan.dsh_home, data.user_data, data_created, data_directories)
|
|
1364
|
+
pack_digest = sha256_bytes(plan.pack_path.read_bytes())
|
|
1365
|
+
final_profile = plan.profile_path
|
|
1366
|
+
if final_profile.exists():
|
|
1367
|
+
if not plan.overwrite:
|
|
1368
|
+
raise PackImportError(
|
|
1369
|
+
f"target Profile appeared during Import: {final_profile}",
|
|
1370
|
+
stage="commit",
|
|
1371
|
+
code="PROFILE_EXISTS",
|
|
1372
|
+
)
|
|
1373
|
+
original_profile_backup = profiles_root / f".{plan.profile_name}.dsh-pack-backup-{backup_token}"
|
|
1374
|
+
final_profile.rename(original_profile_backup)
|
|
1375
|
+
if plan.metadata_path.exists():
|
|
1376
|
+
if not plan.overwrite:
|
|
1377
|
+
raise PackImportError(
|
|
1378
|
+
f"target Import metadata appeared during Import: {plan.metadata_path}",
|
|
1379
|
+
stage="commit",
|
|
1380
|
+
code="PROFILE_EXISTS",
|
|
1381
|
+
)
|
|
1382
|
+
original_metadata_backup = metadata_parent / f".{plan.profile_name}.dsh-pack-backup-{backup_token}"
|
|
1383
|
+
plan.metadata_path.rename(original_metadata_backup)
|
|
1384
|
+
temp_profile.rename(final_profile)
|
|
1385
|
+
temp_profile = None
|
|
1386
|
+
final_created = True
|
|
1387
|
+
|
|
1388
|
+
metadata_parent.mkdir(parents=True, exist_ok=True)
|
|
1389
|
+
plan.metadata_path.mkdir(parents=False, exist_ok=False)
|
|
1390
|
+
metadata_created = True
|
|
1391
|
+
(plan.metadata_path / "manifest.json").write_bytes(json.dumps(data.manifest, ensure_ascii=False, indent=2, sort_keys=True).encode("utf-8") + b"\n")
|
|
1392
|
+
(plan.metadata_path / "plugins.lock.json").write_bytes(json.dumps(data.plugins_lock, ensure_ascii=False, indent=2, sort_keys=True).encode("utf-8") + b"\n")
|
|
1393
|
+
(plan.metadata_path / "prepared.json").write_bytes(
|
|
1394
|
+
json.dumps(_metadata_payload(plan, data, tuple(installed), pack_digest,
|
|
1395
|
+
network_installs[-1] if options.pnpm_command else None), ensure_ascii=False, indent=2, sort_keys=True).encode("utf-8") + b"\n"
|
|
1396
|
+
)
|
|
1397
|
+
for backup in (original_profile_backup, original_metadata_backup):
|
|
1398
|
+
if backup is None:
|
|
1399
|
+
continue
|
|
1400
|
+
try:
|
|
1401
|
+
_cleanup(backup)
|
|
1402
|
+
except OSError as error:
|
|
1403
|
+
warnings.append(
|
|
1404
|
+
{
|
|
1405
|
+
"code": "OVERWRITE_BACKUP_RETAINED",
|
|
1406
|
+
"stage": "commit",
|
|
1407
|
+
"item": str(backup),
|
|
1408
|
+
"message": "previous Profile data was retained as a recovery backup",
|
|
1409
|
+
"details": {"path": str(backup), "error": str(error)},
|
|
1410
|
+
}
|
|
1411
|
+
)
|
|
1412
|
+
return ImportResult(
|
|
1413
|
+
status="prepared",
|
|
1414
|
+
profile_path=final_profile,
|
|
1415
|
+
metadata_path=plan.metadata_path,
|
|
1416
|
+
plan=plan,
|
|
1417
|
+
installed_plugins=tuple(installed),
|
|
1418
|
+
network_installs=tuple(network_installs),
|
|
1419
|
+
warnings=tuple(warnings),
|
|
1420
|
+
)
|
|
1421
|
+
except (DshPackError, OSError, tarfile.TarError, ValueError) as error:
|
|
1422
|
+
# Preserve the first failure before compensation. A Windows sharing
|
|
1423
|
+
# lock may reject both the commit rename and subsequent cleanup.
|
|
1424
|
+
# Cleanup must not replace that error or claim an unobserved rollback.
|
|
1425
|
+
observed = {"error": str(error), "errorType": type(error).__name__}
|
|
1426
|
+
if isinstance(error, OSError):
|
|
1427
|
+
observed.update({key: getattr(error, key, None)
|
|
1428
|
+
for key in ("errno", "winerror", "filename", "filename2")})
|
|
1429
|
+
failure = error if isinstance(error, DshPackError) else PackImportError(
|
|
1430
|
+
"Import failed unexpectedly",
|
|
1431
|
+
stage="commit",
|
|
1432
|
+
code="IMPORT_FAILED",
|
|
1433
|
+
details={
|
|
1434
|
+
"item": "prepared metadata / Profile commit",
|
|
1435
|
+
"expected": {"operation": "write prepared metadata and commit the new Profile"},
|
|
1436
|
+
"observed": observed,
|
|
1437
|
+
"evidence": {"profilePath": str(plan.profile_path), "metadataPath": str(plan.metadata_path)},
|
|
1438
|
+
"impact": "Import failed; inspect the recorded rollback outcome",
|
|
1439
|
+
"error": str(error),
|
|
1440
|
+
},
|
|
1441
|
+
)
|
|
1442
|
+
rollback_errors: list[dict[str, Any]] = []
|
|
1443
|
+
|
|
1444
|
+
def compensate(operation: str, path: Path | None, action: Callable[[], None]) -> None:
|
|
1445
|
+
try:
|
|
1446
|
+
action()
|
|
1447
|
+
except OSError as cleanup_error:
|
|
1448
|
+
rollback_errors.append({
|
|
1449
|
+
"operation": operation, "path": str(path) if path is not None else None,
|
|
1450
|
+
"error": str(cleanup_error), "errorType": type(cleanup_error).__name__,
|
|
1451
|
+
"errno": cleanup_error.errno, "winerror": getattr(cleanup_error, "winerror", None),
|
|
1452
|
+
})
|
|
1453
|
+
|
|
1454
|
+
# Portable files are independent cleanup actions: one locked file must
|
|
1455
|
+
# not prevent removing another file created by this Import.
|
|
1456
|
+
for created_path in reversed(data_created):
|
|
1457
|
+
compensate("remove-portable-file", created_path, lambda path=created_path: path.unlink(missing_ok=True))
|
|
1458
|
+
for created_directory in reversed(data_directories):
|
|
1459
|
+
compensate("remove-portable-directory", created_directory, lambda path=created_directory: _cleanup_empty(path))
|
|
1460
|
+
compensate("remove-temporary-profile", temp_profile, lambda: _cleanup(temp_profile))
|
|
1461
|
+
if final_created:
|
|
1462
|
+
compensate("remove-failed-profile", plan.profile_path, lambda: _cleanup(plan.profile_path))
|
|
1463
|
+
if metadata_created:
|
|
1464
|
+
compensate("remove-failed-metadata", plan.metadata_path, lambda: _cleanup(plan.metadata_path))
|
|
1465
|
+
for operation, backup, destination in (
|
|
1466
|
+
("restore-original-profile", original_profile_backup, plan.profile_path),
|
|
1467
|
+
("restore-original-metadata", original_metadata_backup, plan.metadata_path),
|
|
1468
|
+
):
|
|
1469
|
+
if backup is None or not backup.exists():
|
|
1470
|
+
continue
|
|
1471
|
+
if destination.exists():
|
|
1472
|
+
rollback_errors.append({"operation": operation, "path": str(backup),
|
|
1473
|
+
"error": "恢复目标仍被占用,原始备份已保留,未覆盖该路径。", "destination": str(destination)})
|
|
1474
|
+
else:
|
|
1475
|
+
compensate(operation, backup, lambda saved=backup, target=destination: saved.rename(target))
|
|
1476
|
+
if not metadata_parent_existed:
|
|
1477
|
+
compensate("remove-empty-metadata-parent", metadata_parent, lambda: _cleanup_empty(metadata_parent))
|
|
1478
|
+
if not metadata_root_existed:
|
|
1479
|
+
compensate("remove-empty-metadata-root", metadata_root, lambda: _cleanup_empty(metadata_root))
|
|
1480
|
+
if isinstance(failure, PackImportError):
|
|
1481
|
+
retained_temp = temp_profile is not None and temp_profile.exists()
|
|
1482
|
+
original_retained = original_profile_backup is not None and original_profile_backup.exists()
|
|
1483
|
+
original_metadata_retained = original_metadata_backup is not None and original_metadata_backup.exists()
|
|
1484
|
+
failure.details.update({
|
|
1485
|
+
"temporaryProfileStatus": "retained" if retained_temp else "cleaned",
|
|
1486
|
+
"failedProfileStatus": "retained" if final_created and plan.profile_path.exists() else "cleaned",
|
|
1487
|
+
"originalProfileStatus": "restore-failed" if original_retained else "unchanged",
|
|
1488
|
+
"rollback": {
|
|
1489
|
+
"status": "FAIL" if rollback_errors else "PASS", "errors": rollback_errors,
|
|
1490
|
+
"temporaryProfilePath": str(temp_profile) if retained_temp else None,
|
|
1491
|
+
"originalProfileBackup": str(original_profile_backup) if original_retained else None,
|
|
1492
|
+
"originalMetadataBackup": str(original_metadata_backup) if original_metadata_retained else None,
|
|
1493
|
+
},
|
|
1494
|
+
})
|
|
1495
|
+
if rollback_errors:
|
|
1496
|
+
failure.details.update({
|
|
1497
|
+
"canRetry": False,
|
|
1498
|
+
"impact": "导入失败,部分回滚未完成;请保留并检查诊断中列出的残留和备份路径。",
|
|
1499
|
+
"suggestedNextStep": "先检查原始错误与回滚错误,解除占用并核实残留及原 Profile 状态,再手动重新导入。",
|
|
1500
|
+
})
|
|
1501
|
+
if failure is error:
|
|
1502
|
+
raise
|
|
1503
|
+
raise failure from error
|
|
1504
|
+
|
|
1505
|
+
|
|
1506
|
+
def delete_profile(
|
|
1507
|
+
dsh_home: str | os.PathLike[str],
|
|
1508
|
+
profile_name: str,
|
|
1509
|
+
*,
|
|
1510
|
+
confirmation: str | None = None,
|
|
1511
|
+
) -> DeleteResult:
|
|
1512
|
+
"""Delete exactly one Profile and its Import metadata after explicit confirmation.
|
|
1513
|
+
|
|
1514
|
+
Deleting a Profile also removes ``DSH_HOME/.dsh-pack/imports/<name>`` so
|
|
1515
|
+
the name is not left permanently reserved by a stale prepared record. If
|
|
1516
|
+
metadata cleanup fails, the Profile itself stays deleted and the failure is
|
|
1517
|
+
reported through ``DeleteResult.metadata_status``.
|
|
1518
|
+
"""
|
|
1519
|
+
|
|
1520
|
+
if confirmation != "DELETE":
|
|
1521
|
+
raise PackImportError(
|
|
1522
|
+
"deleting a Profile requires explicit confirmation",
|
|
1523
|
+
stage="planning",
|
|
1524
|
+
code="DELETE_CONFIRMATION_REQUIRED",
|
|
1525
|
+
details={
|
|
1526
|
+
"expected": "DELETE",
|
|
1527
|
+
"observed": "confirmation missing or invalid",
|
|
1528
|
+
"impact": "No Profile was changed.",
|
|
1529
|
+
"suggestedNextStep": "Confirm the destructive deletion in the user interface and retry.",
|
|
1530
|
+
},
|
|
1531
|
+
)
|
|
1532
|
+
safe_name = _validate_profile_name(profile_name, label="Target Profile name")
|
|
1533
|
+
home = Path(dsh_home).resolve()
|
|
1534
|
+
profiles_root = home / "profiles"
|
|
1535
|
+
target = profiles_root / safe_name
|
|
1536
|
+
if target.is_symlink() or (target.exists() and not target.is_dir()):
|
|
1537
|
+
raise PackImportError(
|
|
1538
|
+
f"target Profile is not a safe directory: {target}",
|
|
1539
|
+
stage="planning",
|
|
1540
|
+
code="PROFILE_TARGET_INVALID",
|
|
1541
|
+
details={"profilePath": str(target)},
|
|
1542
|
+
)
|
|
1543
|
+
if not target.exists():
|
|
1544
|
+
raise PackImportError(
|
|
1545
|
+
f"target Profile does not exist: {target}",
|
|
1546
|
+
stage="planning",
|
|
1547
|
+
code="PROFILE_NOT_FOUND",
|
|
1548
|
+
details={"profilePath": str(target)},
|
|
1549
|
+
)
|
|
1550
|
+
metadata_target = home / ".dsh-pack" / "imports" / safe_name
|
|
1551
|
+
if metadata_target.is_symlink() or (metadata_target.exists() and not metadata_target.is_dir()):
|
|
1552
|
+
raise PackImportError(
|
|
1553
|
+
f"Import metadata for the target Profile is not a safe directory: {metadata_target}",
|
|
1554
|
+
stage="planning",
|
|
1555
|
+
code="PROFILE_TARGET_INVALID",
|
|
1556
|
+
details={"metadataPath": str(metadata_target)},
|
|
1557
|
+
)
|
|
1558
|
+
backup = profiles_root / f".{safe_name}.dsh-pack-delete-{uuid.uuid4().hex}"
|
|
1559
|
+
try:
|
|
1560
|
+
target.rename(backup)
|
|
1561
|
+
shutil.rmtree(backup)
|
|
1562
|
+
except OSError as error:
|
|
1563
|
+
if backup.exists() and not target.exists():
|
|
1564
|
+
try:
|
|
1565
|
+
backup.rename(target)
|
|
1566
|
+
except OSError:
|
|
1567
|
+
pass
|
|
1568
|
+
raise PackImportError(
|
|
1569
|
+
f"failed to delete Profile: {target}",
|
|
1570
|
+
stage="delete",
|
|
1571
|
+
code="PROFILE_DELETE_FAILED",
|
|
1572
|
+
details={
|
|
1573
|
+
"profilePath": str(target),
|
|
1574
|
+
"error": str(error),
|
|
1575
|
+
"originalProfileStatus": "restored" if target.exists() else "unknown",
|
|
1576
|
+
},
|
|
1577
|
+
) from error
|
|
1578
|
+
metadata_status = "no-metadata"
|
|
1579
|
+
if metadata_target.exists():
|
|
1580
|
+
metadata_backup = metadata_target.parent / f".{safe_name}.dsh-pack-delete-{uuid.uuid4().hex}"
|
|
1581
|
+
try:
|
|
1582
|
+
metadata_target.rename(metadata_backup)
|
|
1583
|
+
shutil.rmtree(metadata_backup)
|
|
1584
|
+
metadata_status = "removed"
|
|
1585
|
+
except OSError as error:
|
|
1586
|
+
metadata_status = "failed"
|
|
1587
|
+
if metadata_backup.exists() and not metadata_target.exists():
|
|
1588
|
+
try:
|
|
1589
|
+
metadata_backup.rename(metadata_target)
|
|
1590
|
+
except OSError:
|
|
1591
|
+
pass
|
|
1592
|
+
return DeleteResult(
|
|
1593
|
+
status="deleted",
|
|
1594
|
+
profile_name=safe_name,
|
|
1595
|
+
profile_path=target,
|
|
1596
|
+
metadata_path=metadata_target,
|
|
1597
|
+
metadata_status=metadata_status,
|
|
1598
|
+
)
|