dsh-plugin-manager-companion 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +144 -0
  3. package/README.md +142 -0
  4. package/cordis.patch.yml +9 -0
  5. package/dist/about.d.ts +77 -0
  6. package/dist/about.js +179 -0
  7. package/dist/cli.d.ts +226 -0
  8. package/dist/cli.js +856 -0
  9. package/dist/client/AboutPage.d.ts +75 -0
  10. package/dist/client/ConsolePage.d.ts +79 -0
  11. package/dist/client/KindsPage.d.ts +21 -0
  12. package/dist/client/MarketplacePage.d.ts +36 -0
  13. package/dist/client/OfficialSlots.d.ts +35 -0
  14. package/dist/client/UpgradeRow.d.ts +108 -0
  15. package/dist/client/index.d.ts +26 -0
  16. package/dist/client/locales.d.ts +475 -0
  17. package/dist/client/pmSelect.d.ts +38 -0
  18. package/dist/client/shared.d.ts +928 -0
  19. package/dist/client/upgradeView.d.ts +278 -0
  20. package/dist/client/wire.d.ts +401 -0
  21. package/dist/client.js +9194 -0
  22. package/dist/diagnostics.d.ts +332 -0
  23. package/dist/diagnostics.js +2631 -0
  24. package/dist/envManager.d.ts +1047 -0
  25. package/dist/envManager.js +3214 -0
  26. package/dist/fix.d.ts +60 -0
  27. package/dist/fix.js +168 -0
  28. package/dist/guard.d.ts +133 -0
  29. package/dist/guard.js +232 -0
  30. package/dist/index.d.ts +121 -0
  31. package/dist/index.js +1150 -0
  32. package/dist/installSession.d.ts +111 -0
  33. package/dist/installSession.js +150 -0
  34. package/dist/kinds.d.ts +464 -0
  35. package/dist/kinds.js +1029 -0
  36. package/dist/marketView.d.ts +261 -0
  37. package/dist/marketView.js +406 -0
  38. package/dist/marketplace.d.ts +248 -0
  39. package/dist/marketplace.js +500 -0
  40. package/dist/match.d.ts +67 -0
  41. package/dist/match.js +203 -0
  42. package/dist/net.d.ts +108 -0
  43. package/dist/net.js +163 -0
  44. package/dist/official.d.ts +145 -0
  45. package/dist/official.js +205 -0
  46. package/dist/paths.d.ts +108 -0
  47. package/dist/paths.js +236 -0
  48. package/dist/presets.d.ts +299 -0
  49. package/dist/presets.js +578 -0
  50. package/dist/qualityGate.d.ts +66 -0
  51. package/dist/qualityGate.js +247 -0
  52. package/dist/rank.d.ts +88 -0
  53. package/dist/rank.js +164 -0
  54. package/dist/registry.d.ts +295 -0
  55. package/dist/registry.js +686 -0
  56. package/dist/rest.d.ts +122 -0
  57. package/dist/rest.js +219 -0
  58. package/dist/scan.d.ts +134 -0
  59. package/dist/scan.js +396 -0
  60. package/dist/settings.d.ts +447 -0
  61. package/dist/settings.js +263 -0
  62. package/dist/tags.d.ts +119 -0
  63. package/dist/tags.js +166 -0
  64. package/dist/tools.d.ts +131 -0
  65. package/dist/tools.js +377 -0
  66. package/dist/types.d.ts +651 -0
  67. package/dist/types.js +13 -0
  68. package/dist/upgrade.d.ts +428 -0
  69. package/dist/upgrade.js +1100 -0
  70. package/dist/upgradeView.d.ts +313 -0
  71. package/dist/upgradeView.js +273 -0
  72. package/docs/images/readme/01-console-health.png +0 -0
  73. package/docs/images/readme/02-console-envs.png +0 -0
  74. package/docs/images/readme/03-marketplace.png +0 -0
  75. package/docs/images/readme/04-official-plugin-page.png +0 -0
  76. package/package.json +104 -0
@@ -0,0 +1,205 @@
1
+ /**
2
+ * 官方适配层:本插件与 DSH 官方能力之间的**唯一**接口。
3
+ *
4
+ * 归属:A 类·重写(新代码;旧仓库没有这一层,它直接自建了 REST + patch 写入)。
5
+ * 官方复用:pluginManager 服务(当前环境写操作)、host-plugin-inventory
6
+ * (运行时事实)、app-boot 的 profile 读取与 operations 的 pnpm 通道。
7
+ * 前提检查:旧仓库假设"官方只有只读清单,所以必须自建写路径"。0.1.6 之后
8
+ * 该前提消失——官方提供了完整的当前环境写面。本层的职责就是让其余模块
9
+ * **不必知道**官方长什么样,同时把"官方不可用"如实降级而不是崩溃。
10
+ *
11
+ * 三条硬约束(写在类型与运行时里,不靠约定):
12
+ * 1. 当前环境的写操作只走官方 —— 本模块不实现任何文件写入。
13
+ * 2. 服务名绝不用 'pluginManager' —— 官方已占用,同名注册会让整个
14
+ * profile 起不来(旧仓库实测踩过)。
15
+ * 3. 任何官方能力缺失都降级为"能力不可用 + 原因",绝不静默假装成功。
16
+ */
17
+ import { configState } from "./settings.js";
18
+ /**
19
+ * 官方能力的探针。
20
+ *
21
+ * 为什么用 `ctx.get(name)` 而不是声明式 `inject`:
22
+ * 官方 pluginManager 行在 base bundle 里带
23
+ * `disabled: !!js "!ctx.get('profileContext')"`,即**没有 profile 时它不装配**。
24
+ * 若我们声明式 inject 它,本插件会永远停在 PENDING 而不做任何事——用户看到
25
+ * 一个"装了但没反应"的插件,且没有任何可读的错误。探针 + 降级让插件在
26
+ * 任何宿主上都能加载并如实说明自己不能做什么。
27
+ *
28
+ * @param ctx - 本插件的 host 上下文。
29
+ * @returns 当前可用能力;缺失原因在 `missing` 里逐条列出。
30
+ */
31
+ export function probeOfficialCapabilities(ctx) {
32
+ const missing = [];
33
+ const profileContext = ctx.get('profileContext');
34
+ const profileBacked = profileContext !== undefined;
35
+ // 官方服务名是 'pluginManager';我们只读它,绝不提供同名服务。
36
+ const managerService = ctx.get('pluginManager');
37
+ const manager = managerService !== undefined;
38
+ if (!manager) {
39
+ missing.push(profileBacked
40
+ ? '官方 pluginManager 服务未装配:本环境无法执行安装/启停(请确认 @deepseek-ai/dsh-plugin-manager 行未被禁用)'
41
+ : '当前进程不是以 dsh profile 启动的,官方插件管理能力不适用');
42
+ }
43
+ const inventory = ctx.get('loader') !== undefined;
44
+ if (!inventory)
45
+ missing.push('Loader 服务不可用:运行时事实类检查已跳过');
46
+ // 配置面降级也要出现在这里:用户看到的是"我改的设置没生效",必须能在同一处读到原因。
47
+ const config = configState();
48
+ if (!config.writable)
49
+ missing.push('配置不可写(' + (config.detail ?? config.reason ?? '原因未知') + ')');
50
+ return {
51
+ profileBacked,
52
+ manager,
53
+ inventory,
54
+ environmentName: profileBacked ? profileContext.name : null,
55
+ missing,
56
+ };
57
+ }
58
+ /** 官方能力调用失败的统一错误。 */
59
+ export class OfficialUnavailableError extends Error {
60
+ capability;
61
+ /**
62
+ * @param capability - 缺失的能力名(用于 UI 分组)。
63
+ * @param reason - 面向用户的原因说明。
64
+ */
65
+ constructor(capability, reason) {
66
+ super(reason);
67
+ this.capability = capability;
68
+ this.name = 'OfficialUnavailableError';
69
+ }
70
+ }
71
+ /**
72
+ * 我们真正调用过的 pluginManager 方法(缺任何一个都让这项能力不可用)。
73
+ *
74
+ * 为什么是显式清单而不是"把接口上的方法全列一遍":接口是契约面,方法清单是**实际使用**面。
75
+ * 只有后者才能在官方改方法时给出准确的"我们用到的那一个不见了",也不会因为官方增删
76
+ * 我们没用的方法而误报。清单与 OfficialManagerLike 必须同步(下面有编译期校验)。
77
+ */
78
+ const REQUIRED_MANAGER_METHODS = [
79
+ 'inspect', 'setPluginEnabled', 'setBundleEnabled', 'installBundle', 'removeBundle',
80
+ ];
81
+ /** 取该对象上名为 name 的成员是否可调用(含原型链上的方法)。 */
82
+ function isCallableMethod(service, name) {
83
+ let current = service;
84
+ while (current !== null) {
85
+ const value = current[name];
86
+ if (value !== undefined)
87
+ return typeof value === 'function';
88
+ current = Object.getPrototypeOf(current);
89
+ }
90
+ return false;
91
+ }
92
+ /**
93
+ * 取官方 pluginManager,缺失时抛出可读错误。
94
+ *
95
+ * 每个调用点都必须经过它——这样"官方不可用"永远以一个**具名错误**出现,
96
+ * 而不是 `undefined.listBundles is not a function`。
97
+ *
98
+ * 两层校验:服务存在(ctx.get)与**我们用到的方法存在**。第二层是 2026-09-19 补的:
99
+ * 官方改/删一个 Remote 方法时,用户原本看到调用点的 TypeError(读不懂);现在得到的是
100
+ * "缺哪个方法"+"所以这项能力不可用"。
101
+ *
102
+ * @param ctx - 本插件的 host 上下文。
103
+ * @returns 官方管理器(结构式视图)。
104
+ * @throws {OfficialUnavailableError} 官方管理器未装配,或缺少我们调用到的方法时。
105
+ */
106
+ export function requireManager(ctx) {
107
+ const service = ctx.get('pluginManager');
108
+ if (service === undefined) {
109
+ throw new OfficialUnavailableError('pluginManager', probeOfficialCapabilities(ctx).missing[0]
110
+ ?? '官方 pluginManager 服务不可用');
111
+ }
112
+ const absent = REQUIRED_MANAGER_METHODS.filter(name => !isCallableMethod(service, name));
113
+ if (absent.length > 0) {
114
+ throw new OfficialUnavailableError('pluginManager', '官方 pluginManager 缺少我们用到的' + (absent.length > 1 ? '这些方法:' : '方法:') + absent.join('、')
115
+ + ':说明官方依赖的接口与预期不一致(官方改过或删过这个方法),'
116
+ + '因此"当前环境的插件管理"这项能力不可用——升级/回退 @deepseek-ai/dsh-plugin-manager,'
117
+ + '或改用官方 CLI(dsh plugin)完成同一步操作。');
118
+ }
119
+ return service;
120
+ }
121
+ /**
122
+ * 读运行时事实。
123
+ *
124
+ * 优先走官方 `readPluginInventory`(它对 Loader 的投影有自己的一致性保证),
125
+ * 拿不到时退回直接读 `ctx.loader.entries()`——用户明确要求"最大化利用能力,
126
+ * 但同时保证不出错",所以两条路都要有,且**降级是显式的**。
127
+ *
128
+ * 返回的 `source` 让诊断页能如实标注数据来源:官方投影口径与直接读 Loader
129
+ * 口径在字段上一致,但前者会跳过 group 行,后者不会。
130
+ *
131
+ * @param ctx - 本插件的 host 上下文。
132
+ * @returns 运行时条目与数据来源;两者都不可用时 `entries` 为空且给出原因。
133
+ */
134
+ export async function readRuntimeInventory(ctx) {
135
+ // 官方投影优先。
136
+ try {
137
+ const mod = await import('@deepseek-ai/dsh-host-plugin-inventory');
138
+ const snapshot = await mod.readPluginInventory(ctx);
139
+ return { entries: snapshot.entries, agentPresets: snapshot.agentPresets, source: 'official' };
140
+ }
141
+ catch (error) {
142
+ const reason = error instanceof Error ? error.message : String(error);
143
+ // 降级:直接读 Loader,拿得到就标注来源为 loader。
144
+ const loader = ctx.get('loader');
145
+ if (loader?.entries === undefined) {
146
+ return { entries: [], agentPresets: undefined, source: 'unavailable', reason };
147
+ }
148
+ try {
149
+ const entries = [];
150
+ for (const raw of loader.entries()) {
151
+ const entry = raw;
152
+ if (entry.options?.group)
153
+ continue;
154
+ entries.push({
155
+ entryId: String(entry.id ?? ''),
156
+ moduleName: String(entry.options?.name ?? ''),
157
+ enabled: entry.disabled !== true,
158
+ fiberPhase: fiberPhaseOf(entry.fiber?.state),
159
+ });
160
+ }
161
+ return { entries, agentPresets: undefined, source: 'loader' };
162
+ }
163
+ catch (loaderError) {
164
+ return {
165
+ entries: [], agentPresets: undefined, source: 'unavailable',
166
+ reason: reason + '; loader fallback failed: ' + (loaderError instanceof Error ? loaderError.message : String(loaderError)),
167
+ };
168
+ }
169
+ }
170
+ }
171
+ /**
172
+ * Cordis FiberState 到官方相位标签的映射。
173
+ *
174
+ * 数值取自 `@deepseek-ai/cordis` 的 `FiberState` 枚举(PENDING=0 …
175
+ * UNLOADING=5)。旧仓库自己定义了一套映射且与本表不一致,是实际缺陷;
176
+ * 这里逐项对齐官方 `plugin-inventory` 的 `FIBER_PHASE`。
177
+ *
178
+ * @param state - fiber.state 的原始数值;undefined 表示无存活 fiber。
179
+ * @returns 官方相位标签,或 null。
180
+ */
181
+ export function fiberPhaseOf(state) {
182
+ switch (state) {
183
+ case 0: return 'pending';
184
+ case 1: return 'loading';
185
+ case 2: return 'active';
186
+ case 3: return 'failed';
187
+ case 4: return null; // DISPOSED
188
+ case 5: return 'unloading';
189
+ default: return null;
190
+ }
191
+ }
192
+ /**
193
+ * 官方能力缺失时是否应该继续。
194
+ *
195
+ * 只读检查(诊断、盘点)在能力缺失时**继续**,但把缺失记进报告的 `skipped`;
196
+ * 写操作必须**失败**,因为静默跳过写会让用户以为改了而实际没改。
197
+ *
198
+ * @param capabilities - 探针结果。
199
+ * @returns 可执行的只读检查项与不可执行的原因。
200
+ */
201
+ export function readOnlyAvailability(capabilities) {
202
+ return capabilities.inventory
203
+ ? { canReadRuntime: true }
204
+ : { canReadRuntime: false, reason: 'Loader 服务不可用,运行时检查无法执行' };
205
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * 基础层:Harness home / profile 路径、manifest 读取、安全校验、全局互斥队列。
3
+ * 本模块不 import 任何兄弟模块,是其余一切的地基。
4
+ *
5
+ * 归属:A 类·重写(旧 src/paths.ts 仅作意图参考,未复制代码)。
6
+ * 官方复用:ctx.profileContext(当前环境事实)、readProfileManifest。
7
+ * 前提检查:旧实现靠扫描 process.argv 猜"当前 profile",脆弱且有历史 bug
8
+ * (issue #1:nvm 下 argv[1] 是脚本路径,被当成 profile 名)。官方
9
+ * 0.1.6 提供 ctx.profileContext.name —— 直接、权威、无需猜测。
10
+ * 本模块把"当前环境"改为注入式:由 apply() 传入官方事实,探测仅作兜底。
11
+ */
12
+ import type { ManifestField } from './types.ts';
13
+ /** 本包名(用于行 id、缓存目录、自识别)。 */
14
+ export declare const OUR_PACKAGE_NAME = "dsh-plugin-manager-companion";
15
+ /** 本插件的 Loader 行 id。**绝不用 'plugin-manager'**——官方 base bundle 已占用该 id。 */
16
+ export declare const OUR_ROW_ID = "dsh-plugin-manager-companion";
17
+ /** 解析 Harness home(DSH_HOME 优先,否则 ~/.dsh)。 */
18
+ export declare function dshHome(): string;
19
+ /** profiles 根目录。 */
20
+ export declare function profilesRoot(): string;
21
+ /**
22
+ * 环境名安全规则。
23
+ * `.` / `..` 会逃出 profiles 根(join(profiles,'..') === dshHome),
24
+ * 旧实现在这里被删过整个 Harness home,必须保留此校验。
25
+ */
26
+ export declare function isSafeEnvironmentName(name: string): boolean;
27
+ /** 解析一个环境的目录,拒绝路径穿越。 */
28
+ export declare function environmentDir(name: string): string;
29
+ /** 环境 manifest 的解析结果。 */
30
+ export interface EnvironmentManifest {
31
+ /** `dsh.profile.bundles` 层栈;读不懂时为空数组,**必须**配合 unknownFields 判断。 */
32
+ readonly bundles: readonly string[];
33
+ /** 直接依赖名列表;读不懂时为空数组,**必须**配合 unknownFields 判断。 */
34
+ readonly dependencies: readonly string[];
35
+ /** 原始解析对象,供诊断读取任意字段。 */
36
+ readonly raw: Record<string, unknown>;
37
+ /** 无法解析(JSON 坏掉)时的原因;成功时为 undefined。 */
38
+ readonly broken?: string;
39
+ /**
40
+ * 这份 manifest 里我们**读不出来**的派生字段(缺省 = 全部读得出来)。
41
+ *
42
+ * 存在的理由:这一整轮都在反对"把不知道说成知道"。读取器原本会把两种完全不同的
43
+ * 事实读成同一个结果(空数组):
44
+ * · 确实没有声明层栈 / 确实没有依赖 → 空数组是**事实**;
45
+ * · 官方把 `dsh.profile.bundles` 改名、或把 `dependencies` 写成数组 → 空数组是**假阴性**
46
+ * (后者还会被读成一个名叫 "0" 的依赖:Object.keys(['a']) === ['0'])。
47
+ * 现在后者进这份清单(配 unknownReason),调用方据此少说那句话。
48
+ */
49
+ readonly unknownFields?: readonly ManifestField[];
50
+ /** 读不出来的原因(面向用户的一句话);全部读得出来时为 undefined。 */
51
+ readonly unknownReason?: string;
52
+ }
53
+ /**
54
+ * 读取一个环境的 manifest。
55
+ *
56
+ * 与旧实现的差别:**解析失败不再静默返回空**。旧实现在 manifest 损坏时
57
+ * 返回 `{}`,让"零依赖零 bundle"看起来像真实状态,掩盖了真正的问题。
58
+ * 这里把失败原因显式带回,交给诊断层报告。
59
+ *
60
+ * 同一条原则也适用于"格式读不懂":官方若改了 `dsh.profile.bundles` 的名字或位置,
61
+ * 旧写法会读出空数组且不报错——诊断会把"我不知道"说成"这个环境没有层栈"。
62
+ * 现在这种形态返回 `unknownFields` + `unknownReason`(见字段注释):**按字段**表达,
63
+ * 以后再加派生字段不需要第三个布尔。
64
+ *
65
+ * @param dir - 环境目录。
66
+ * @returns 解析结果;**读派生字段前先看 unknownFields**。
67
+ */
68
+ export declare function readEnvironmentManifest(dir: string): EnvironmentManifest;
69
+ /** 一个环境的 patch 文件路径。 */
70
+ export declare function environmentPatchPath(dir: string): string;
71
+ export declare function enqueueMutation<T>(task: () => Promise<T>): Promise<T>;
72
+ /**
73
+ * 当前运行环境的名称。
74
+ *
75
+ * 优先官方事实:`ctx.profileContext.name`(由 apply 注入)。
76
+ * 兜底:解析 argv 的 `--profile <name>`。
77
+ * 两者都拿不到时返回 null——调用方必须把它当作"未知"而非"没有环境",
78
+ * 不做任何破坏性推断。
79
+ */
80
+ export declare function detectCurrentEnvironmentName(argv?: readonly string[]): string | null;
81
+ /** 官方内置环境:只读,环境管理不修改它们的层栈。 */
82
+ export declare const BUILTIN_ENVIRONMENTS: readonly ["web", "headless"];
83
+ /**
84
+ * 是否为官方内置环境。
85
+ *
86
+ * **必须大小写不敏感**:Windows 与 macOS 的默认文件系统大小写不敏感,
87
+ * 于是 `WEB` 与 `web` 指向**同一个目录**。若这里按精确大小写比较,
88
+ * `removeEnvironment('WEB')` 会删掉官方内置 web 环境的目录,还返回成功——
89
+ * 不可逆、且 Linux 门禁永远测不出来(审计 W-01,已在真 win32 Node 上复现)。
90
+ * 判据取"这个名字在这台机器上会落到哪个目录",而不是"字符串是否逐字相等"。
91
+ *
92
+ * @param name - 环境名(调用方给的原始大小写)。
93
+ * @returns 是否指向官方内置环境。
94
+ */
95
+ export declare function isBuiltinEnvironment(name: string): boolean;
96
+ /**
97
+ * 两个环境名在这台机器上是否指向**同一个目录**。
98
+ *
99
+ * Windows 与 macOS 默认文件系统大小写不敏感:`web` 与 `WEB` 是同一个目录。
100
+ * 所有"拒绝在某个环境上操作"的护栏(当前环境不可删/不可停、内置环境只读)都必须用它,
101
+ * 否则非规范大小写就能绕过护栏——审计 W-01 是它的最坏形态(删掉官方内置环境并报成功)。
102
+ * Linux 是大小写敏感的,那里两个名字确实是两个目录,所以不能无条件忽略大小写。
103
+ *
104
+ * @param a - 环境名之一。
105
+ * @param b - 环境名之二。
106
+ * @returns 是否指向同一个环境。
107
+ */
108
+ export declare function sameEnvironment(a: string | null, b: string | null): boolean;
package/dist/paths.js ADDED
@@ -0,0 +1,236 @@
1
+ /**
2
+ * 基础层:Harness home / profile 路径、manifest 读取、安全校验、全局互斥队列。
3
+ * 本模块不 import 任何兄弟模块,是其余一切的地基。
4
+ *
5
+ * 归属:A 类·重写(旧 src/paths.ts 仅作意图参考,未复制代码)。
6
+ * 官方复用:ctx.profileContext(当前环境事实)、readProfileManifest。
7
+ * 前提检查:旧实现靠扫描 process.argv 猜"当前 profile",脆弱且有历史 bug
8
+ * (issue #1:nvm 下 argv[1] 是脚本路径,被当成 profile 名)。官方
9
+ * 0.1.6 提供 ctx.profileContext.name —— 直接、权威、无需猜测。
10
+ * 本模块把"当前环境"改为注入式:由 apply() 传入官方事实,探测仅作兜底。
11
+ */
12
+ import { existsSync, readFileSync } from 'node:fs';
13
+ import { join, resolve, sep } from 'node:path';
14
+ import { homedir } from 'node:os';
15
+ /** 本包名(用于行 id、缓存目录、自识别)。 */
16
+ export const OUR_PACKAGE_NAME = 'dsh-plugin-manager-companion';
17
+ /** 本插件的 Loader 行 id。**绝不用 'plugin-manager'**——官方 base bundle 已占用该 id。 */
18
+ export const OUR_ROW_ID = 'dsh-plugin-manager-companion';
19
+ /** 解析 Harness home(DSH_HOME 优先,否则 ~/.dsh)。 */
20
+ export function dshHome() {
21
+ return process.env.DSH_HOME ?? join(homedir(), '.dsh');
22
+ }
23
+ /** profiles 根目录。 */
24
+ export function profilesRoot() {
25
+ return join(dshHome(), 'profiles');
26
+ }
27
+ /**
28
+ * 环境名安全规则。
29
+ * `.` / `..` 会逃出 profiles 根(join(profiles,'..') === dshHome),
30
+ * 旧实现在这里被删过整个 Harness home,必须保留此校验。
31
+ */
32
+ export function isSafeEnvironmentName(name) {
33
+ return /^[A-Za-z0-9._-]+$/.test(name) && name !== '.' && name !== '..' && name.length <= 120;
34
+ }
35
+ /** 解析一个环境的目录,拒绝路径穿越。 */
36
+ export function environmentDir(name) {
37
+ if (!isSafeEnvironmentName(name)) {
38
+ throw new Error('unsafe environment name: ' + JSON.stringify(name));
39
+ }
40
+ const dir = join(profilesRoot(), name);
41
+ // 纵深防御:解析后的路径必须仍在 profiles 根之内。
42
+ if (!resolve(dir).startsWith(resolve(profilesRoot()) + sep)) {
43
+ throw new Error('unsafe environment name: ' + JSON.stringify(name));
44
+ }
45
+ return dir;
46
+ }
47
+ /**
48
+ * 读取一个环境的 manifest。
49
+ *
50
+ * 与旧实现的差别:**解析失败不再静默返回空**。旧实现在 manifest 损坏时
51
+ * 返回 `{}`,让"零依赖零 bundle"看起来像真实状态,掩盖了真正的问题。
52
+ * 这里把失败原因显式带回,交给诊断层报告。
53
+ *
54
+ * 同一条原则也适用于"格式读不懂":官方若改了 `dsh.profile.bundles` 的名字或位置,
55
+ * 旧写法会读出空数组且不报错——诊断会把"我不知道"说成"这个环境没有层栈"。
56
+ * 现在这种形态返回 `unknownFields` + `unknownReason`(见字段注释):**按字段**表达,
57
+ * 以后再加派生字段不需要第三个布尔。
58
+ *
59
+ * @param dir - 环境目录。
60
+ * @returns 解析结果;**读派生字段前先看 unknownFields**。
61
+ */
62
+ export function readEnvironmentManifest(dir) {
63
+ const path = join(dir, 'package.json');
64
+ // 没有 manifest 文件 = 确实没有声明层栈(不是"读不懂")。
65
+ if (!existsSync(path))
66
+ return { bundles: [], dependencies: [], raw: {} };
67
+ let parsed;
68
+ try {
69
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
70
+ }
71
+ catch (error) {
72
+ return {
73
+ bundles: [], dependencies: [], raw: {},
74
+ broken: error instanceof Error ? error.message : String(error),
75
+ };
76
+ }
77
+ // 顶层不是对象:整份 manifest 都不可用——两个派生字段都读不出来(不是"为空")。
78
+ if (!isRecord(parsed)) {
79
+ return {
80
+ bundles: [], dependencies: [], raw: {},
81
+ unknownFields: ['bundles', 'dependencies'],
82
+ unknownReason: 'package.json 的顶层不是 JSON 对象(是 ' + typeofOf(parsed) + ')',
83
+ };
84
+ }
85
+ const raw = parsed;
86
+ const unknownFields = [];
87
+ let reason;
88
+ // dependencies:只有普通对象才读得出依赖名。数组会走 Object.keys → ['0', '1'],
89
+ // 那是把 `dependencies: ['a']` 读成一个名叫 "0" 的依赖(同一族的假阴性)。
90
+ const depsValue = raw['dependencies'];
91
+ if (depsValue !== undefined && depsValue !== null && !isRecord(depsValue)) {
92
+ unknownFields.push('dependencies');
93
+ // 原因要指名是哪个字段、当前是什么类型——用户读了才知道该去看什么。
94
+ reason = 'dependencies 不是普通对象(当前类型:' + typeofOf(depsValue) + '),读不出依赖名';
95
+ }
96
+ const dependencies = isRecord(depsValue) ? Object.keys(depsValue) : [];
97
+ let bundles = [];
98
+ const dshValue = raw['dsh'];
99
+ if (dshValue === undefined) {
100
+ // dsh 整段缺失 = 这个环境没有声明 profile 段,层栈确实是空的。
101
+ }
102
+ else if (!isRecord(dshValue)) {
103
+ reason = 'dsh 段不是对象(官方可能改了这一层的结构)';
104
+ unknownFields.push('bundles');
105
+ }
106
+ else {
107
+ const profileValue = dshValue['profile'];
108
+ if (profileValue === undefined) {
109
+ // dsh.profile 缺失 = 没有声明层栈。
110
+ }
111
+ else if (!isRecord(profileValue)) {
112
+ reason = 'dsh.profile 不是对象(官方可能改了这一层的结构)';
113
+ unknownFields.push('bundles');
114
+ }
115
+ else {
116
+ const bundlesValue = profileValue['bundles'];
117
+ if (bundlesValue === undefined) {
118
+ // 关键区分:profile 段**存在**、但没有 bundles 字段、却带着别的键(例如 layers / list)
119
+ // ——这是"官方改了字段名"的形态,不是"没有声明层栈"。真空层栈也会写 bundles: [];
120
+ // 只有 profile 段完全为空 {} 才当成确定的空。
121
+ const unknownKeys = Object.keys(profileValue).filter(key => key !== 'bundles');
122
+ if (unknownKeys.length > 0) {
123
+ reason = 'dsh.profile 里没有 bundles 字段,却有 ' + unknownKeys.join('、')
124
+ + '(官方可能把层栈字段改名或移位了)';
125
+ unknownFields.push('bundles');
126
+ }
127
+ }
128
+ else if (!Array.isArray(bundlesValue)) {
129
+ reason = 'dsh.profile.bundles 存在但不是数组(当前类型:' + typeofOf(bundlesValue) + ')';
130
+ unknownFields.push('bundles');
131
+ }
132
+ else if (!bundlesValue.every(item => typeof item === 'string')) {
133
+ reason = 'dsh.profile.bundles 里有非字符串项';
134
+ unknownFields.push('bundles');
135
+ }
136
+ else {
137
+ bundles = [...bundlesValue];
138
+ }
139
+ }
140
+ }
141
+ const fields = [...new Set(unknownFields)];
142
+ if (fields.length === 0)
143
+ return { bundles, dependencies, raw };
144
+ return {
145
+ bundles, dependencies, raw,
146
+ unknownFields: fields,
147
+ unknownReason: reason ?? '这份 manifest 的字段形态与预期不一致(原因未知)',
148
+ };
149
+ }
150
+ /** 是不是普通对象(数组与 null 都不算)。 */
151
+ function isRecord(value) {
152
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
153
+ }
154
+ /** 供原因文案使用的类型名。 */
155
+ function typeofOf(value) {
156
+ if (Array.isArray(value))
157
+ return 'array';
158
+ if (value === null)
159
+ return 'null';
160
+ return typeof value;
161
+ }
162
+ /** 一个环境的 patch 文件路径。 */
163
+ export function environmentPatchPath(dir) {
164
+ return join(dir, 'cordis.patch.yml');
165
+ }
166
+ /**
167
+ * 全局变更互斥队列(进程内)。
168
+ *
169
+ * 环境变更操作串行执行:并发 pnpm 调用会各自基于自己的快照重写 manifest
170
+ * (丢依赖),并发文件编辑会丢行。进程内仅覆盖本进程;跨进程由官方
171
+ * withFileLock 在 package.json 上兜底。
172
+ */
173
+ let mutationQueue = Promise.resolve();
174
+ export function enqueueMutation(task) {
175
+ const run = mutationQueue.then(task, task);
176
+ mutationQueue = run.catch(() => { });
177
+ return run;
178
+ }
179
+ /**
180
+ * 当前运行环境的名称。
181
+ *
182
+ * 优先官方事实:`ctx.profileContext.name`(由 apply 注入)。
183
+ * 兜底:解析 argv 的 `--profile <name>`。
184
+ * 两者都拿不到时返回 null——调用方必须把它当作"未知"而非"没有环境",
185
+ * 不做任何破坏性推断。
186
+ */
187
+ export function detectCurrentEnvironmentName(argv = process.argv) {
188
+ const flagIndex = argv.indexOf('--profile');
189
+ if (flagIndex >= 0) {
190
+ const flagged = argv[flagIndex + 1];
191
+ if (flagged !== undefined && isSafeEnvironmentName(flagged))
192
+ return flagged;
193
+ }
194
+ return null;
195
+ }
196
+ /** 官方内置环境:只读,环境管理不修改它们的层栈。 */
197
+ export const BUILTIN_ENVIRONMENTS = ['web', 'headless'];
198
+ /**
199
+ * 是否为官方内置环境。
200
+ *
201
+ * **必须大小写不敏感**:Windows 与 macOS 的默认文件系统大小写不敏感,
202
+ * 于是 `WEB` 与 `web` 指向**同一个目录**。若这里按精确大小写比较,
203
+ * `removeEnvironment('WEB')` 会删掉官方内置 web 环境的目录,还返回成功——
204
+ * 不可逆、且 Linux 门禁永远测不出来(审计 W-01,已在真 win32 Node 上复现)。
205
+ * 判据取"这个名字在这台机器上会落到哪个目录",而不是"字符串是否逐字相等"。
206
+ *
207
+ * @param name - 环境名(调用方给的原始大小写)。
208
+ * @returns 是否指向官方内置环境。
209
+ */
210
+ export function isBuiltinEnvironment(name) {
211
+ const lower = name.toLowerCase();
212
+ return BUILTIN_ENVIRONMENTS.some((builtin) => builtin.toLowerCase() === lower);
213
+ }
214
+ /**
215
+ * 两个环境名在这台机器上是否指向**同一个目录**。
216
+ *
217
+ * Windows 与 macOS 默认文件系统大小写不敏感:`web` 与 `WEB` 是同一个目录。
218
+ * 所有"拒绝在某个环境上操作"的护栏(当前环境不可删/不可停、内置环境只读)都必须用它,
219
+ * 否则非规范大小写就能绕过护栏——审计 W-01 是它的最坏形态(删掉官方内置环境并报成功)。
220
+ * Linux 是大小写敏感的,那里两个名字确实是两个目录,所以不能无条件忽略大小写。
221
+ *
222
+ * @param a - 环境名之一。
223
+ * @param b - 环境名之二。
224
+ * @returns 是否指向同一个环境。
225
+ */
226
+ export function sameEnvironment(a, b) {
227
+ if (a === null || b === null)
228
+ return a === b;
229
+ if (a === b)
230
+ return true;
231
+ return caseInsensitiveFs() && a.toLowerCase() === b.toLowerCase();
232
+ }
233
+ /** 默认文件系统是否大小写不敏感(保守取向:这里判"是"只会让人被多拒绝一次,判"否"可能删错东西)。 */
234
+ function caseInsensitiveFs() {
235
+ return process.platform === 'win32' || process.platform === 'darwin';
236
+ }