@chatcode/cco-market 1.45.1

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 (177) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +146 -0
  3. package/README.zh.md +143 -0
  4. package/UPDATE-API-V1.md +132 -0
  5. package/client/client.js +11534 -0
  6. package/cordis.patch.yml +5 -0
  7. package/lib/accelerate.js +184 -0
  8. package/lib/agents.js +36 -0
  9. package/lib/backup.js +572 -0
  10. package/lib/catalog-local-match.js +126 -0
  11. package/lib/catalog-npm.js +111 -0
  12. package/lib/changelog.js +242 -0
  13. package/lib/channels.js +64 -0
  14. package/lib/check.js +1067 -0
  15. package/lib/compatibility.js +198 -0
  16. package/lib/diagnostics.js +49 -0
  17. package/lib/discovery-compatibility.js +260 -0
  18. package/lib/dsh-cli.js +949 -0
  19. package/lib/dsh-install.js +108 -0
  20. package/lib/gist.js +352 -0
  21. package/lib/groups.js +97 -0
  22. package/lib/home-paths.js +42 -0
  23. package/lib/hot.js +519 -0
  24. package/lib/http.js +39 -0
  25. package/lib/index.js +97 -0
  26. package/lib/install.js +387 -0
  27. package/lib/log.js +197 -0
  28. package/lib/ndjson.js +154 -0
  29. package/lib/net.js +98 -0
  30. package/lib/order.js +284 -0
  31. package/lib/patch.js +521 -0
  32. package/lib/pnpm-compat.js +490 -0
  33. package/lib/presets.js +278 -0
  34. package/lib/profile.js +911 -0
  35. package/lib/region-probe.js +93 -0
  36. package/lib/regions.js +253 -0
  37. package/lib/registry.js +204 -0
  38. package/lib/restart.js +373 -0
  39. package/lib/routes.js +4372 -0
  40. package/lib/settings.js +109 -0
  41. package/lib/skill-market/api.js +119 -0
  42. package/lib/skill-market/config.js +29 -0
  43. package/lib/skill-market/installer.js +352 -0
  44. package/lib/skill-market/runtime.js +13 -0
  45. package/lib/skill-market/tui.js +119 -0
  46. package/lib/skill-market/types.js +1 -0
  47. package/lib/skill-market/web-routes.js +123 -0
  48. package/lib/snapshot.js +500 -0
  49. package/lib/source-migration.js +54 -0
  50. package/lib/sources.js +596 -0
  51. package/lib/store.js +91 -0
  52. package/lib/themes.js +102 -0
  53. package/lib/trial.js +116 -0
  54. package/lib/types/accelerate.d.ts +85 -0
  55. package/lib/types/agents.d.ts +22 -0
  56. package/lib/types/backup.d.ts +131 -0
  57. package/lib/types/catalog-local-match.d.ts +50 -0
  58. package/lib/types/catalog-npm.d.ts +47 -0
  59. package/lib/types/changelog.d.ts +102 -0
  60. package/lib/types/channels.d.ts +56 -0
  61. package/lib/types/check.d.ts +266 -0
  62. package/lib/types/compatibility.d.ts +64 -0
  63. package/lib/types/diagnostics.d.ts +29 -0
  64. package/lib/types/discovery-compatibility.d.ts +80 -0
  65. package/lib/types/dsh-cli.d.ts +324 -0
  66. package/lib/types/dsh-install.d.ts +46 -0
  67. package/lib/types/gist.d.ts +53 -0
  68. package/lib/types/groups.d.ts +34 -0
  69. package/lib/types/home-paths.d.ts +16 -0
  70. package/lib/types/hot.d.ts +221 -0
  71. package/lib/types/http.d.ts +12 -0
  72. package/lib/types/index.d.ts +14 -0
  73. package/lib/types/install.d.ts +172 -0
  74. package/lib/types/log.d.ts +43 -0
  75. package/lib/types/ndjson.d.ts +52 -0
  76. package/lib/types/net.d.ts +58 -0
  77. package/lib/types/order.d.ts +100 -0
  78. package/lib/types/patch.d.ts +129 -0
  79. package/lib/types/pnpm-compat.d.ts +93 -0
  80. package/lib/types/presets.d.ts +90 -0
  81. package/lib/types/profile.d.ts +253 -0
  82. package/lib/types/region-probe.d.ts +50 -0
  83. package/lib/types/regions.d.ts +122 -0
  84. package/lib/types/registry.d.ts +86 -0
  85. package/lib/types/restart.d.ts +194 -0
  86. package/lib/types/routes.d.ts +67 -0
  87. package/lib/types/settings.d.ts +78 -0
  88. package/lib/types/skill-market/api.d.ts +10 -0
  89. package/lib/types/skill-market/config.d.ts +3 -0
  90. package/lib/types/skill-market/installer.d.ts +23 -0
  91. package/lib/types/skill-market/runtime.d.ts +10 -0
  92. package/lib/types/skill-market/tui.d.ts +4 -0
  93. package/lib/types/skill-market/types.d.ts +71 -0
  94. package/lib/types/skill-market/web-routes.d.ts +19 -0
  95. package/lib/types/snapshot.d.ts +93 -0
  96. package/lib/types/source-migration.d.ts +11 -0
  97. package/lib/types/sources.d.ts +216 -0
  98. package/lib/types/store.d.ts +30 -0
  99. package/lib/types/themes.d.ts +40 -0
  100. package/lib/types/trial.d.ts +61 -0
  101. package/lib/types/update-api-v1.d.ts +66 -0
  102. package/lib/types/updates.d.ts +123 -0
  103. package/lib/types/verify.d.ts +139 -0
  104. package/lib/update-api-v1.js +215 -0
  105. package/lib/updates.js +361 -0
  106. package/lib/verify.js +453 -0
  107. package/package.json +121 -0
  108. package/src/accelerate.ts +213 -0
  109. package/src/agents.ts +43 -0
  110. package/src/backup.ts +583 -0
  111. package/src/catalog-local-match.ts +144 -0
  112. package/src/catalog-npm.ts +120 -0
  113. package/src/changelog.ts +282 -0
  114. package/src/channels.ts +70 -0
  115. package/src/check.ts +1239 -0
  116. package/src/client/CommentsModal.tsx +119 -0
  117. package/src/client/Diagnostics.tsx +907 -0
  118. package/src/client/ErrorBoundary.tsx +111 -0
  119. package/src/client/InstallToast.tsx +31 -0
  120. package/src/client/Market.module.css +830 -0
  121. package/src/client/MarketSection.tsx +5301 -0
  122. package/src/client/OperationsPanel.tsx +365 -0
  123. package/src/client/SettingsCard.tsx +621 -0
  124. package/src/client/SkillMarket.module.css +21 -0
  125. package/src/client/SkillMarketSection.tsx +162 -0
  126. package/src/client/comments.ts +54 -0
  127. package/src/client/globals.d.ts +13 -0
  128. package/src/client/index.ts +180 -0
  129. package/src/client/locales.ts +1112 -0
  130. package/src/client/market-data.ts +1321 -0
  131. package/src/client/operations.ts +201 -0
  132. package/src/client/preset-panel.tsx +263 -0
  133. package/src/client/primitives.d.ts +140 -0
  134. package/src/client/self-check.ts +147 -0
  135. package/src/client/snapshot-panel.tsx +244 -0
  136. package/src/compatibility.ts +237 -0
  137. package/src/diagnostics.ts +84 -0
  138. package/src/discovery-compatibility.ts +315 -0
  139. package/src/dsh-cli.ts +1126 -0
  140. package/src/dsh-install.ts +118 -0
  141. package/src/gist.ts +362 -0
  142. package/src/groups.ts +111 -0
  143. package/src/home-paths.ts +53 -0
  144. package/src/hot.ts +628 -0
  145. package/src/http.ts +41 -0
  146. package/src/index.ts +128 -0
  147. package/src/install.ts +420 -0
  148. package/src/log.ts +206 -0
  149. package/src/ndjson.ts +185 -0
  150. package/src/net.ts +106 -0
  151. package/src/order.ts +303 -0
  152. package/src/patch.ts +522 -0
  153. package/src/pnpm-compat.ts +527 -0
  154. package/src/presets.ts +344 -0
  155. package/src/profile.ts +940 -0
  156. package/src/region-probe.ts +97 -0
  157. package/src/regions.ts +310 -0
  158. package/src/registry.ts +250 -0
  159. package/src/restart.ts +396 -0
  160. package/src/routes.ts +4506 -0
  161. package/src/settings.ts +141 -0
  162. package/src/skill-market/api.ts +130 -0
  163. package/src/skill-market/config.ts +32 -0
  164. package/src/skill-market/installer.ts +337 -0
  165. package/src/skill-market/runtime.ts +14 -0
  166. package/src/skill-market/tui.ts +157 -0
  167. package/src/skill-market/types.ts +80 -0
  168. package/src/skill-market/web-routes.ts +132 -0
  169. package/src/snapshot.ts +532 -0
  170. package/src/source-migration.ts +61 -0
  171. package/src/sources.ts +565 -0
  172. package/src/store.ts +89 -0
  173. package/src/themes.ts +125 -0
  174. package/src/trial.ts +156 -0
  175. package/src/update-api-v1.ts +277 -0
  176. package/src/updates.ts +400 -0
  177. package/src/verify.ts +492 -0
package/lib/profile.js ADDED
@@ -0,0 +1,911 @@
1
+ /**
2
+ * Profile filesystem reads — everything the market learns from a dsh
3
+ * profile directory (manifest, lockfile, installed package trees). Pure
4
+ * functions of the directory contents; no processes, no network.
5
+ */
6
+ import { existsSync, readdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from 'node:fs';
7
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
8
+ import { isDeepStrictEqual } from 'node:util';
9
+ import { resolveChatCodeHome } from './home-paths.js';
10
+ import { githubRemoteIdentities, githubRepoIdentities } from './sources.js';
11
+ /**
12
+ * Whether a profile name follows DSH's own directory-name contract.
13
+ *
14
+ * Keep this aligned with `@deepseek-ai/dsh-app-boot`'s
15
+ * `resolveProfileDir`: dots, spaces, and Unicode are ordinary name
16
+ * characters; only empty, traversal-shaped, launcher-owned, or
17
+ * separator-bearing names are refused.
18
+ */
19
+ export function isDshProfileName(profile) {
20
+ return profile !== ''
21
+ && profile !== '.'
22
+ && profile !== '..'
23
+ && profile !== 'node_modules'
24
+ && !profile.includes('/')
25
+ && !profile.includes('\\')
26
+ && !profile.includes('\0');
27
+ }
28
+ /**
29
+ * Resolve a profile name beneath the canonical ChatCode CLI Home.
30
+ * An explicit directory is used by hosts, such as DSH Desktop, that own the
31
+ * active profile location rather than deriving it from process environment.
32
+ */
33
+ export function profileDir(profile, explicitDir) {
34
+ if (explicitDir !== undefined)
35
+ return explicitDir;
36
+ if (!isDshProfileName(profile))
37
+ throw new Error(`dsh-market: invalid profile name ${JSON.stringify(profile)}`);
38
+ return join(resolveChatCodeHome(), 'profiles', profile);
39
+ }
40
+ /**
41
+ * The in-box bundles dsh's profile templates install themselves — the ONLY
42
+ * names the market hides from the installed list. Community plugins may
43
+ * legitimately publish under the official scope (#28), so a whole-scope
44
+ * filter would make them invisible and fail install validation.
45
+ * (Diagnosis and fix proposed in #28 by @Lograthmic.)
46
+ */
47
+ export const INBOX_BUNDLES = new Set([
48
+ '@deepseek-ai/dsh-base',
49
+ '@deepseek-ai/dsh-web-app',
50
+ '@deepseek-ai/dsh-headless',
51
+ ]);
52
+ /** Community dependencies of the profile (in-box bundles filtered out). */
53
+ export function readInstalled(profile, explicitDir) {
54
+ try {
55
+ const manifest = JSON.parse(readFileSync(join(profileDir(profile, explicitDir), 'package.json'), 'utf8'));
56
+ const installed = {};
57
+ for (const [name, spec] of Object.entries(manifest.dependencies ?? {})) {
58
+ if (!INBOX_BUNDLES.has(name))
59
+ installed[name] = spec;
60
+ }
61
+ return installed;
62
+ }
63
+ catch {
64
+ return {};
65
+ }
66
+ }
67
+ /**
68
+ * RAW dependency map of the profile manifest — including the in-box bundles
69
+ * readInstalled() filters out. This is the rollback snapshot (#65): restoring
70
+ * a filtered view would delete @deepseek-ai/dsh-base and friends.
71
+ */
72
+ export function readManifestDeps(profile, explicitDir) {
73
+ try {
74
+ const manifest = JSON.parse(readFileSync(join(profileDir(profile, explicitDir), 'package.json'), 'utf8'));
75
+ return { ...manifest.dependencies };
76
+ }
77
+ catch {
78
+ return {};
79
+ }
80
+ }
81
+ function objectRecord(value) {
82
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
83
+ ? value
84
+ : undefined;
85
+ }
86
+ /** Read dependencies and the exact `dsh.profile.bundles` field before a package operation. */
87
+ export function readProfileManifestSnapshot(profile, explicitDir) {
88
+ try {
89
+ const manifest = JSON.parse(readFileSync(join(profileDir(profile, explicitDir), 'package.json'), 'utf8'));
90
+ const profileManifest = objectRecord(manifest.dsh?.profile);
91
+ const present = profileManifest !== undefined && Object.hasOwn(profileManifest, 'bundles');
92
+ return {
93
+ dependencies: { ...manifest.dependencies },
94
+ profileBundles: present
95
+ ? { present: true, value: structuredClone(profileManifest.bundles) }
96
+ : { present: false },
97
+ };
98
+ }
99
+ catch {
100
+ return { dependencies: {}, profileBundles: { present: false } };
101
+ }
102
+ }
103
+ /** String package names carried by one valid bundle-list value. */
104
+ function bundleNames(value) {
105
+ return Array.isArray(value) ? value.filter((name) => typeof name === 'string') : [];
106
+ }
107
+ /**
108
+ * Restore the profile manifest fields a package operation may mutate:
109
+ * `dependencies` and `dsh.profile.bundles`. pnpm and `dsh plugin add` can
110
+ * write both before a later fetch or build-script failure (#65, #69, #339),
111
+ * leaving either an unresolvable dependency or a bundle the next boot cannot
112
+ * activate. Every unrelated manifest field remains untouched. The lockfile is
113
+ * left as-is; pnpm reconciles it from the manifest on the next run.
114
+ *
115
+ * The write is atomic because rollback runs after another operation already
116
+ * failed; a partial repair must not turn a valid profile into invalid JSON.
117
+ * @returns names whose entries were dropped or reverted, empty when nothing changed.
118
+ */
119
+ export function restoreProfileManifest(profile, snapshot, explicitDir) {
120
+ const file = join(profileDir(profile, explicitDir), 'package.json');
121
+ let manifest;
122
+ try {
123
+ manifest = JSON.parse(readFileSync(file, 'utf8'));
124
+ }
125
+ catch {
126
+ return [];
127
+ }
128
+ const current = manifest.dependencies ?? {};
129
+ const touched = new Set();
130
+ for (const name of Object.keys(current)) {
131
+ if (current[name] !== snapshot.dependencies[name])
132
+ touched.add(name);
133
+ }
134
+ for (const name of Object.keys(snapshot.dependencies)) {
135
+ if (current[name] !== snapshot.dependencies[name])
136
+ touched.add(name);
137
+ }
138
+ const currentDsh = objectRecord(manifest.dsh);
139
+ const currentProfile = objectRecord(currentDsh?.profile);
140
+ const currentBundles = currentProfile !== undefined && Object.hasOwn(currentProfile, 'bundles')
141
+ ? { present: true, value: currentProfile.bundles }
142
+ : { present: false };
143
+ const bundlesChanged = currentBundles.present !== snapshot.profileBundles.present
144
+ || (currentBundles.present && snapshot.profileBundles.present
145
+ && !isDeepStrictEqual(currentBundles.value, snapshot.profileBundles.value));
146
+ if (bundlesChanged) {
147
+ const currentNames = new Set(currentBundles.present ? bundleNames(currentBundles.value) : []);
148
+ const snapshotNames = new Set(snapshot.profileBundles.present ? bundleNames(snapshot.profileBundles.value) : []);
149
+ let namedBundleChange = false;
150
+ for (const name of currentNames) {
151
+ if (!snapshotNames.has(name)) {
152
+ touched.add(name);
153
+ namedBundleChange = true;
154
+ }
155
+ }
156
+ for (const name of snapshotNames) {
157
+ if (!currentNames.has(name)) {
158
+ touched.add(name);
159
+ namedBundleChange = true;
160
+ }
161
+ }
162
+ // Presence, order, duplicates, or a malformed non-array value can differ
163
+ // without changing the set of package names. Still report that rollback.
164
+ if (!namedBundleChange)
165
+ touched.add('dsh.profile.bundles');
166
+ }
167
+ if (touched.size === 0)
168
+ return [];
169
+ manifest.dependencies = { ...snapshot.dependencies };
170
+ if (snapshot.profileBundles.present) {
171
+ const dsh = currentDsh ?? {};
172
+ const profileManifest = currentProfile ?? {};
173
+ manifest.dsh = dsh;
174
+ dsh.profile = profileManifest;
175
+ profileManifest.bundles = structuredClone(snapshot.profileBundles.value);
176
+ }
177
+ else if (currentProfile !== undefined) {
178
+ delete currentProfile.bundles;
179
+ }
180
+ writeManifestAtomic(file, manifest);
181
+ return [...touched];
182
+ }
183
+ /**
184
+ * Remove a package from BOTH manifest lists — dependencies and
185
+ * dsh.profile.bundles. The uninstall counterpart of restoreProfileManifest:
186
+ * pnpm can fail a remove after deleting node_modules but before saving
187
+ * package.json (the #65 write-order's mirror image — a file locked mid-
188
+ * unlink aborts the run), leaving the manifest pointing at a package that
189
+ * no longer exists on disk. The next boot then fails to activate the ghost
190
+ * dependency. When disk truth says the package is gone, this finishes the
191
+ * removal the CLI could not. Every other manifest field is untouched.
192
+ *
193
+ * Written atomically because it runs only after something already went wrong
194
+ * mid-uninstall, so it is the worst place to leave a half-written manifest.
195
+ * @returns true when either list still mentioned the package.
196
+ */
197
+ export function dropFromManifest(profile, name, explicitDir) {
198
+ const file = join(profileDir(profile, explicitDir), 'package.json');
199
+ let manifest;
200
+ try {
201
+ manifest = JSON.parse(readFileSync(file, 'utf8'));
202
+ }
203
+ catch {
204
+ return false;
205
+ }
206
+ let touched = false;
207
+ if (manifest.dependencies !== undefined && manifest.dependencies[name] !== undefined) {
208
+ delete manifest.dependencies[name];
209
+ touched = true;
210
+ }
211
+ const bundles = manifest.dsh?.profile?.bundles;
212
+ if (Array.isArray(bundles) && bundles.includes(name)) {
213
+ manifest.dsh.profile.bundles = bundles.filter(bundle => bundle !== name);
214
+ touched = true;
215
+ }
216
+ if (!touched)
217
+ return false;
218
+ writeManifestAtomic(file, manifest);
219
+ return true;
220
+ }
221
+ /** The version actually present in the profile's node_modules, or null. */
222
+ export function readInstalledVersion(profile, name, explicitDir) {
223
+ try {
224
+ const manifest = JSON.parse(readFileSync(join(profileDir(profile, explicitDir), 'node_modules', name, 'package.json'), 'utf8'));
225
+ return manifest.version ?? null;
226
+ }
227
+ catch {
228
+ return null;
229
+ }
230
+ }
231
+ /** The installed package manifest, or null when absent or malformed. */
232
+ export function readInstalledManifest(profile, name, explicitDir) {
233
+ try {
234
+ return JSON.parse(readFileSync(join(profileDir(profile, explicitDir), 'node_modules', name, 'package.json'), 'utf8'));
235
+ }
236
+ catch {
237
+ return null;
238
+ }
239
+ }
240
+ /**
241
+ * Whether a package or one of its direct dependencies ships a native addon.
242
+ *
243
+ * The question behind it: can unloading this plugin actually free its files?
244
+ * For ordinary JavaScript, yes — and on POSIX it does not even matter,
245
+ * because replacing an open file leaves the old inode to whoever holds it.
246
+ * For a native addon it is no on both counts: Node has no dlclose, so once a
247
+ * `.node` is loaded the process holds it until it exits. On Windows that
248
+ * turns "uninstall, then install again" into an EPERM on the rename, which
249
+ * is what @yandidan1 hit with node-hid (#441) — and no amount of disabling,
250
+ * unmounting or uninstalling from inside the running process can fix it.
251
+ *
252
+ * Deliberately a cheap structural check rather than a scan. Walking a
253
+ * dependency's tree for `*.node` means recursing through packages that can
254
+ * be tens of thousands of files, on the uninstall path, to answer a question
255
+ * three `existsSync` calls answer for every native module built or shipped
256
+ * the conventional way: node-gyp's `build/Release`, prebuild's `prebuilds/`,
257
+ * and the `binding.gyp` that names the addon in the first place.
258
+ *
259
+ * Direct dependencies are included because that is where these live: the
260
+ * plugin is JavaScript and the addon is a package it depends on, hoisted to
261
+ * the profile root beside it.
262
+ * @param profile - profile name.
263
+ * @param name - the installed package to ask about.
264
+ * @param explicitDir - resolved profile directory, when the caller has it.
265
+ * @returns true when a native addon is present in the package or a direct dependency.
266
+ */
267
+ export function holdsNativeAddon(profile, name, explicitDir) {
268
+ const modules = join(profileDir(profile, explicitDir), 'node_modules');
269
+ const shipsAddon = (packageName) => {
270
+ const dir = join(modules, packageName);
271
+ return existsSync(join(dir, 'build', 'Release'))
272
+ || existsSync(join(dir, 'prebuilds'))
273
+ || existsSync(join(dir, 'binding.gyp'));
274
+ };
275
+ if (shipsAddon(name))
276
+ return true;
277
+ const manifest = readInstalledManifest(profile, name, explicitDir);
278
+ if (manifest === null || typeof manifest !== 'object')
279
+ return false;
280
+ const dependencies = manifest.dependencies;
281
+ if (dependencies === null || typeof dependencies !== 'object')
282
+ return false;
283
+ return Object.keys(dependencies)
284
+ .filter(dependency => PACKAGE_NAME_RE.test(dependency))
285
+ .some(shipsAddon);
286
+ }
287
+ const PACKAGE_NAME_RE = /^(@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/i;
288
+ function localSpecDirectory(root, spec) {
289
+ const match = /^(?:link|file):(.+)$/i.exec(spec);
290
+ if (match === null)
291
+ return null;
292
+ let path = match[1];
293
+ try {
294
+ path = decodeURIComponent(path);
295
+ }
296
+ catch { /* keep the literal pnpm path */ }
297
+ // file:// URLs are uncommon in profile manifests; reject them rather than
298
+ // guessing across platforms. Normal pnpm link:/file: directory specs reach
299
+ // this code as absolute or profile-relative filesystem paths.
300
+ if (path.startsWith('//'))
301
+ return null;
302
+ const candidate = isAbsolute(path) ? path : resolve(root, path);
303
+ try {
304
+ return statSync(candidate).isDirectory() ? realpathSync(candidate) : null;
305
+ }
306
+ catch {
307
+ return null;
308
+ }
309
+ }
310
+ function installedPackageDirectory(root, name) {
311
+ try {
312
+ const candidate = join(root, 'node_modules', name);
313
+ return statSync(candidate).isDirectory() ? realpathSync(candidate) : null;
314
+ }
315
+ catch {
316
+ return null;
317
+ }
318
+ }
319
+ function manifestAt(dir) {
320
+ try {
321
+ const value = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
322
+ return typeof value === 'object' && value !== null ? value : null;
323
+ }
324
+ catch {
325
+ return null;
326
+ }
327
+ }
328
+ function manifestRepository(manifest) {
329
+ const repository = manifest?.repository;
330
+ if (typeof repository === 'string')
331
+ return { url: repository, directory: null };
332
+ if (typeof repository !== 'object' || repository === null)
333
+ return null;
334
+ const value = repository;
335
+ if (typeof value.url !== 'string')
336
+ return null;
337
+ return { url: value.url, directory: typeof value.directory === 'string' ? value.directory : null };
338
+ }
339
+ function gitConfigPath(marker, worktreeRoot) {
340
+ try {
341
+ if (statSync(marker).isDirectory()) {
342
+ const direct = join(marker, 'config');
343
+ return existsSync(direct) ? direct : null;
344
+ }
345
+ const pointer = /^gitdir:\s*(.+)$/im.exec(readFileSync(marker, 'utf8'));
346
+ if (pointer === null)
347
+ return null;
348
+ const gitDir = resolve(worktreeRoot, pointer[1].trim());
349
+ const direct = join(gitDir, 'config');
350
+ if (existsSync(direct))
351
+ return direct;
352
+ const commonDir = readFileSync(join(gitDir, 'commondir'), 'utf8').trim();
353
+ const common = join(resolve(gitDir, commonDir), 'config');
354
+ return existsSync(common) ? common : null;
355
+ }
356
+ catch {
357
+ return null;
358
+ }
359
+ }
360
+ function originFromConfig(file) {
361
+ try {
362
+ let origin = false;
363
+ for (const line of readFileSync(file, 'utf8').split(/\r?\n/)) {
364
+ const section = /^\s*\[remote\s+"([^"]+)"\]\s*$/.exec(line);
365
+ if (section !== null) {
366
+ origin = section[1] === 'origin';
367
+ continue;
368
+ }
369
+ if (!origin)
370
+ continue;
371
+ const url = /^\s*url\s*=\s*(.+?)\s*$/.exec(line);
372
+ if (url !== null)
373
+ return url[1];
374
+ }
375
+ }
376
+ catch { /* unreadable git metadata carries no identity */ }
377
+ return null;
378
+ }
379
+ function gitCheckout(start) {
380
+ let current = start;
381
+ while (true) {
382
+ const marker = join(current, '.git');
383
+ if (existsSync(marker)) {
384
+ const config = gitConfigPath(marker, current);
385
+ const origin = config === null ? null : originFromConfig(config);
386
+ return origin === null ? null : { root: current, origin };
387
+ }
388
+ const parent = dirname(current);
389
+ if (parent === current)
390
+ return null;
391
+ current = parent;
392
+ }
393
+ }
394
+ function checkoutSubpath(root, packageDir) {
395
+ const value = relative(root, packageDir).replaceAll('\\', '/');
396
+ return value === '' || value === '.' || value.startsWith('../') ? null : value;
397
+ }
398
+ /**
399
+ * Strong repository identities for a locally linked dependency (#141).
400
+ * Explicit github: specs already carry this evidence; only link:/file: need
401
+ * filesystem discovery. This compatibility wrapper returns only declared
402
+ * package.json identities; Git origins are exposed separately as hints.
403
+ */
404
+ export function readInstalledRepoIdentities(profile, name, spec, explicitDir) {
405
+ return readInstalledRepoEvidence(profile, name, spec, explicitDir).identities;
406
+ }
407
+ /**
408
+ * Discover declared repository identities and weaker local-origin hints. A
409
+ * package.json repository declaration is authoritative; Git origin is only a
410
+ * disambiguation hint because a checkout may legitimately point at a fork.
411
+ */
412
+ export function readInstalledRepoEvidence(profile, name, spec, explicitDir) {
413
+ if (!PACKAGE_NAME_RE.test(name) || !/^(?:link|file):/i.test(spec))
414
+ return { identities: [], hints: [] };
415
+ const root = profileDir(profile, explicitDir);
416
+ const sourceDir = localSpecDirectory(root, spec);
417
+ const installedDir = installedPackageDirectory(root, name);
418
+ const manifestDir = installedDir ?? sourceDir;
419
+ const manifest = manifestDir === null ? readInstalledManifest(profile, name, explicitDir) : manifestAt(manifestDir);
420
+ const repository = manifestRepository(typeof manifest === 'object' && manifest !== null ? manifest : null);
421
+ const checkoutDir = sourceDir ?? (installedDir !== null && /^(?:link):/i.test(spec) ? installedDir : null);
422
+ const checkout = checkoutDir === null ? null : gitCheckout(checkoutDir);
423
+ if (repository !== null) {
424
+ const identities = githubRepoIdentities(repository.url, repository.directory);
425
+ if (identities.length > 0)
426
+ return { identities, hints: [] };
427
+ }
428
+ if (checkout !== null) {
429
+ return { identities: [], hints: githubRemoteIdentities(checkout.origin, checkoutSubpath(checkout.root, checkoutDir)) };
430
+ }
431
+ // node_modules is intentionally not searched upward for .git: a copied
432
+ // file: package may sit inside an unrelated profile checkout. Only the
433
+ // explicit local source directory is valid Git-origin evidence.
434
+ return { identities: [], hints: [] };
435
+ }
436
+ /** Pinned commit per `owner/repo` from the profile lockfile's codeload tarball URLs. */
437
+ export function readLockCommits(profile, explicitDir) {
438
+ const commits = new Map();
439
+ try {
440
+ const lock = readFileSync(join(profileDir(profile, explicitDir), 'pnpm-lock.yaml'), 'utf8');
441
+ for (const m of lock.matchAll(/codeload\.github\.com\/([^/\s]+\/[^/\s]+)\/tar\.gz\/([0-9a-f]{40})/g)) {
442
+ commits.set(m[1].toLowerCase(), m[2]);
443
+ }
444
+ }
445
+ catch { /* no lockfile — no git installs to report */ }
446
+ return commits;
447
+ }
448
+ /**
449
+ * Commit recorded for a non-codeload git resolution (`type: git` in pnpm's
450
+ * lockfile). Matched against the install spec so a Gitea/GitLab URL can
451
+ * compare HEAD without mistaking a same-named npm package (#525).
452
+ */
453
+ export function readGitResolutionCommit(profile, spec, explicitDir) {
454
+ const want = normalizeGitRepoKey(spec);
455
+ if (want === null)
456
+ return null;
457
+ try {
458
+ const lock = readFileSync(join(profileDir(profile, explicitDir), 'pnpm-lock.yaml'), 'utf8');
459
+ for (const m of lock.matchAll(/resolution:\s*\{([^}]*)\}/g)) {
460
+ const body = m[1];
461
+ const commit = /\bcommit:\s*([0-9a-f]{40})\b/i.exec(body);
462
+ const repo = /\brepo:\s*([^\s,}]+)/.exec(body);
463
+ if (commit === null || repo === null)
464
+ continue;
465
+ if (normalizeGitRepoKey(repo[1]) === want)
466
+ return commit[1].toLowerCase();
467
+ }
468
+ }
469
+ catch { /* no lockfile */ }
470
+ return null;
471
+ }
472
+ /** Lowercased transport-agnostic key for comparing two git remote spellings. */
473
+ function normalizeGitRepoKey(spec) {
474
+ let remote = spec.trim().replace(/^git\+/i, '');
475
+ const scp = /^git@([^:]+):(.+)$/.exec(remote);
476
+ if (scp !== null)
477
+ remote = `https://${scp[1]}/${scp[2].replace(/^\/*/, '')}`;
478
+ const hash = remote.indexOf('#');
479
+ if (hash !== -1)
480
+ remote = remote.slice(0, hash);
481
+ const query = remote.indexOf('?');
482
+ if (query !== -1)
483
+ remote = remote.slice(0, query);
484
+ remote = remote.replace(/\/+$/, '').toLowerCase();
485
+ if (!/^https?:\/\//.test(remote) && !remote.includes('.git'))
486
+ return null;
487
+ return remote;
488
+ }
489
+ /** True when the installed package's manifest declares a dsh plugin surface. */
490
+ export function hasDshManifest(dir) {
491
+ try {
492
+ const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
493
+ return manifest.dsh !== undefined;
494
+ }
495
+ catch {
496
+ return false;
497
+ }
498
+ }
499
+ /**
500
+ * True when the package's declared entry artifact actually exists — github
501
+ * source checkouts of build-required plugins ship no lib/, and promoting one
502
+ * into the bundle layer bricks the next boot (ERR_MODULE_NOT_FOUND kills the
503
+ * whole profile, #18).
504
+ */
505
+ export function entryArtifactExists(dir) {
506
+ try {
507
+ const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
508
+ const candidates = [];
509
+ if (typeof manifest.main === 'string')
510
+ candidates.push(manifest.main);
511
+ const rootExport = typeof manifest.exports === 'string'
512
+ ? manifest.exports
513
+ : manifest.exports?.['.'];
514
+ if (typeof rootExport === 'string')
515
+ candidates.push(rootExport);
516
+ else if (rootExport !== null && typeof rootExport === 'object') {
517
+ for (const value of Object.values(rootExport))
518
+ if (typeof value === 'string')
519
+ candidates.push(value);
520
+ }
521
+ if (candidates.length === 0)
522
+ candidates.push('index.js');
523
+ return candidates.some(rel => existsSync(join(dir, rel)));
524
+ }
525
+ catch {
526
+ return false;
527
+ }
528
+ }
529
+ /**
530
+ * Package names a bundle patch mounts — the `name:` rows of the package's
531
+ * declared `dsh.bundle.patch` file. Line-wise on purpose: the strict
532
+ * hot-mount parser rejects config/expression rows, but for "what does this
533
+ * bundle bring in" any name row counts.
534
+ */
535
+ export function bundlePatchTargets(dir) {
536
+ return readBundlePatchRows(dir).names;
537
+ }
538
+ /**
539
+ * Loader entry ids a bundle patch inserts. Cordis refuses to boot a tree
540
+ * with a duplicate entry id ("duplicate loader entry id: storage", #122), so
541
+ * these are what two bundles can collide on.
542
+ */
543
+ export function bundlePatchEntryIds(dir) {
544
+ return readBundlePatchRows(dir).ids;
545
+ }
546
+ /**
547
+ * Loader entry ids the patch INSERTS — the rows the package owns, as opposed
548
+ * to rows of OTHER plugins it merely configures (#147).
549
+ *
550
+ * A bundle patch has two kinds of entry:
551
+ *
552
+ * - insert: ← rows this package brings into the tree
553
+ * - id: vision-router
554
+ * name: dsh-vision-router
555
+ * - id: attachment-local ← someone else's row, only reconfigured
556
+ * config: { maxImageBytes: … }
557
+ *
558
+ * Treating both as "this package's rows" made disabling one plugin write
559
+ * `disabled: true` onto the official rows it tuned — killing attachments and
560
+ * the DeepSeek model with it.
561
+ */
562
+ export function bundlePatchInsertedIds(dir) {
563
+ return readBundlePatchRows(dir).insertedIds;
564
+ }
565
+ /**
566
+ * `name:` and `id:` rows of the package's declared bundle patch. Line-wise
567
+ * on purpose: the strict hot-mount parser rejects config/expression rows,
568
+ * but for "what does this bundle bring in" any row counts. `insertedIds` is
569
+ * the subset nested under an `insert:` key (#147).
570
+ */
571
+ /**
572
+ * Rows of one patch file. Exported because a package may ship its patch at
573
+ * the conventional path INSTEAD of declaring `dsh.bundle.patch`, and the
574
+ * patch layer has to read that one by the same rules — a second hand-rolled
575
+ * scan drifted from this one and re-introduced #147 on that path (it closed
576
+ * the insert block only on `id:` lines, so `- disable:` followed by nested
577
+ * ids claimed the neighbour's rows).
578
+ */
579
+ export function parsePatchRows(text) {
580
+ const names = [];
581
+ const ids = [];
582
+ const insertedIds = [];
583
+ {
584
+ // `insert:` opens a nested list; every id below it, at deeper
585
+ // indentation, is a row this package brings in. A row at or above the
586
+ // `insert:` indentation closes the block — those target OTHER plugins.
587
+ let insertIndent = null;
588
+ // CRLF too, for consistency with the hot-mount parser, which a
589
+ // Windows-authored patch genuinely broke. Here it changes no outcome
590
+ // today — every row pattern below is `^`-anchored and a comment line
591
+ // always starts with `#`, so an unstripped comment matches nothing —
592
+ // and it is deliberately NOT covered by a spec, because a test that
593
+ // passes with or without the change tests nothing.
594
+ for (const raw of text.split(/\r?\n/)) {
595
+ const line = raw.replace(/#.*$/, '');
596
+ if (line.trim() === '')
597
+ continue;
598
+ const indent = line.length - line.trimStart().length;
599
+ if (insertIndent !== null && indent <= insertIndent && !/^\s*-?\s*(id|name|config):/u.test(line)) {
600
+ insertIndent = null;
601
+ }
602
+ if (/^\s*-?\s*insert:\s*$/u.test(line)) {
603
+ insertIndent = indent;
604
+ continue;
605
+ }
606
+ const name = /^\s*-?\s*name:\s*['"]?([^'"\s]+)/.exec(line);
607
+ if (name !== null && !names.includes(name[1]))
608
+ names.push(name[1]);
609
+ const id = /^\s*-?\s*id:\s*['"]?([^'"\s]+)/.exec(line);
610
+ if (id !== null) {
611
+ if (!ids.includes(id[1]))
612
+ ids.push(id[1]);
613
+ // A top-level `- id:` row closes any open insert block: it is a
614
+ // sibling of `- insert:`, not a member of it.
615
+ if (insertIndent !== null && indent > insertIndent) {
616
+ if (!insertedIds.includes(id[1]))
617
+ insertedIds.push(id[1]);
618
+ }
619
+ else if (indent <= (insertIndent ?? -1)) {
620
+ insertIndent = null;
621
+ }
622
+ }
623
+ }
624
+ }
625
+ return { names, ids, insertedIds };
626
+ }
627
+ /** Rows of the patch a package DECLARES through `dsh.bundle.patch`. */
628
+ function readBundlePatchRows(dir) {
629
+ const empty = { names: [], ids: [], insertedIds: [] };
630
+ try {
631
+ const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
632
+ const declared = manifest.dsh?.bundle?.patch;
633
+ if (typeof declared !== 'string' || declared === '')
634
+ return empty;
635
+ return parsePatchRows(readFileSync(join(dir, declared), 'utf8'));
636
+ }
637
+ catch {
638
+ return empty;
639
+ }
640
+ }
641
+ /** The profile manifest's `dsh.profile.bundles` — what the CLI reconciled. */
642
+ export function readProfileBundles(profileDirectory) {
643
+ try {
644
+ const manifest = JSON.parse(readFileSync(join(profileDirectory, 'package.json'), 'utf8'));
645
+ const bundles = manifest.dsh?.profile?.bundles;
646
+ return Array.isArray(bundles) ? bundles.filter((name) => typeof name === 'string') : [];
647
+ }
648
+ catch {
649
+ return [];
650
+ }
651
+ }
652
+ /**
653
+ * Write the profile manifest atomically: a temp file in the same directory is
654
+ * written first, then renamed over package.json, so a crash mid-toggle never
655
+ * leaves a half-written manifest (the same guarantee order.ts's writer gives
656
+ * the reorder path). The trailing newline + 2-space indent match how every
657
+ * other writer in this repo serializes the manifest.
658
+ */
659
+ function writeManifestAtomic(manifestPath, manifest) {
660
+ const temp = `${manifestPath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
661
+ writeFileSync(temp, `${JSON.stringify(manifest, null, 2)}\n`);
662
+ renameSync(temp, manifestPath);
663
+ }
664
+ /**
665
+ * Drop one bundle from the profile manifest's `dsh.profile.bundles`, leaving
666
+ * the package installed as a dependency. This is the carrier-bundle half of a
667
+ * toggle-off (#224): a bundle whose patch reconfigures plugins it does NOT own
668
+ * (dsh-postgres-backends disables session-persistence-jsonl and reroutes
669
+ * storage-domain) keeps applying those side-effect rows on every boot while it
670
+ * stays in the stack, and the #147 ownership rule deliberately never writes
671
+ * them — so removing the bundle from the stack is the only thing that stops
672
+ * them all at once. The package itself stays installed; enabling re-adds it.
673
+ * @returns true when the bundle was present and removed.
674
+ */
675
+ export function removeProfileBundle(profileDirectory, name) {
676
+ const manifestPath = join(profileDirectory, 'package.json');
677
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
678
+ const bundles = manifest.dsh?.profile?.bundles;
679
+ if (!Array.isArray(bundles))
680
+ return false;
681
+ const next = bundles.filter((entry) => typeof entry !== 'string' || entry !== name);
682
+ if (next.length === bundles.length)
683
+ return false;
684
+ manifest.dsh ??= {};
685
+ manifest.dsh.profile ??= {};
686
+ manifest.dsh.profile.bundles = next;
687
+ writeManifestAtomic(manifestPath, manifest);
688
+ return true;
689
+ }
690
+ /**
691
+ * Re-add a bundle to `dsh.profile.bundles` after a carrier toggle-off (#224).
692
+ * Idempotent: a bundle already present is left untouched. The name is appended
693
+ * (the install flow appends too); the loader re-validates ordering on the next
694
+ * composition, so a declared before/after rule surfaces there rather than here.
695
+ * @returns true when the bundle was added, false when it was already present.
696
+ */
697
+ export function addProfileBundle(profileDirectory, name) {
698
+ const manifestPath = join(profileDirectory, 'package.json');
699
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
700
+ manifest.dsh ??= {};
701
+ manifest.dsh.profile ??= {};
702
+ const existing = manifest.dsh.profile.bundles;
703
+ const bundles = Array.isArray(existing) ? existing.filter((entry) => typeof entry === 'string') : [];
704
+ if (bundles.includes(name))
705
+ return false;
706
+ bundles.push(name);
707
+ manifest.dsh.profile.bundles = bundles;
708
+ writeManifestAtomic(manifestPath, manifest);
709
+ return true;
710
+ }
711
+ /**
712
+ * Loader entry ids a newly added package would collide on with bundles the
713
+ * profile ALREADY loads (#122).
714
+ *
715
+ * Cordis hard-fails the whole tree on a duplicate id, so this is not a
716
+ * cosmetic conflict: installing a TUI bundle into a web profile (both
717
+ * declare `id: storage`) leaves DSH unable to start at all, with an error
718
+ * naming neither plugin. Checked against the profile's own bundle list so a
719
+ * package is never compared with itself.
720
+ * @returns colliding ids mapped to the already-installed bundle that owns them.
721
+ */
722
+ export function conflictingEntryIds(profileDirectory, candidate, installedBundles) {
723
+ // INSERTED ids on both sides, not every id in the file. What bricks the
724
+ // next boot is two entries created under one id; a row that merely
725
+ // CONFIGURES another plugin's entry creates nothing, so counting it here
726
+ // refuses a legitimate plugin outright — the same distinction #147 drew
727
+ // for the disable path, which this guard was left out of.
728
+ const mine = bundlePatchInsertedIds(join(profileDirectory, 'node_modules', candidate));
729
+ if (mine.length === 0)
730
+ return [];
731
+ const conflicts = [];
732
+ for (const bundle of installedBundles) {
733
+ if (bundle === candidate)
734
+ continue;
735
+ const theirs = new Set(bundlePatchInsertedIds(join(profileDirectory, 'node_modules', bundle)));
736
+ for (const id of mine) {
737
+ if (theirs.has(id) && !conflicts.some(hit => hit.id === id))
738
+ conflicts.push({ id, owner: bundle });
739
+ }
740
+ }
741
+ return conflicts;
742
+ }
743
+ /**
744
+ * Whether the loader has anything to load for this package: its own entry
745
+ * artifact, or — for CARRIER bundles — patch rows naming other packages that
746
+ * do have one.
747
+ *
748
+ * Carriers are why `entryArtifactExists` alone is the wrong test (#103):
749
+ * `@linxin666/dsh-skins` ships skin assets plus a patch mounting
750
+ * `@linxin666/dsh-client-ui-skin-center`, and declares no main/exports/
751
+ * index.js of its own. Judged by its own entry it looks like the
752
+ * source-only checkout the #18 guard removes — so the market both flagged it
753
+ * broken AND uninstalled it right after installing.
754
+ * @param profileDirectory - resolved profile directory (host-authoritative under Desktop).
755
+ * @param name - installed package name.
756
+ */
757
+ export function hasLoadableEntry(profileDirectory, name) {
758
+ const dir = join(profileDirectory, 'node_modules', name);
759
+ if (entryArtifactExists(dir))
760
+ return true;
761
+ // A carrier is only sound when something it mounts is itself loadable.
762
+ // Targets resolve hoisted (the dsh profile default), nested under the
763
+ // carrier, or — #203 — one level up: pnpm hoists shared/in-box packages to
764
+ // `<profiles>/node_modules` when the profile is a workspace member, the
765
+ // same workspace-root fallback readProfileVisibleVersion (check.ts) already
766
+ // uses. A carrier naming an in-box package (@deepseek-ai/dsh-mcp-client and
767
+ // similar) resolves there and nowhere this function used to look, so pnpm
768
+ // exiting 0 was immediately followed by the market removing what it had
769
+ // just, correctly, installed.
770
+ const workspaceRoot = dirname(profileDirectory);
771
+ return bundlePatchTargets(dir)
772
+ .filter(target => target !== name)
773
+ .some(target => entryArtifactExists(join(profileDirectory, 'node_modules', target))
774
+ || entryArtifactExists(join(dir, 'node_modules', target))
775
+ || entryArtifactExists(join(workspaceRoot, 'node_modules', target)));
776
+ }
777
+ /** Plugin subdirectories (depth 2) of a collection checkout, as relative paths. */
778
+ export function pluginSubdirs(root) {
779
+ const found = [];
780
+ let level1 = [];
781
+ try {
782
+ level1 = readdirSync(root, { withFileTypes: true })
783
+ .filter(dirent => dirent.isDirectory() && /^[A-Za-z0-9_.-]+$/.test(dirent.name) && dirent.name !== 'node_modules')
784
+ .map(dirent => dirent.name);
785
+ }
786
+ catch {
787
+ return found;
788
+ }
789
+ for (const sub of level1) {
790
+ if (hasDshManifest(join(root, sub))) {
791
+ found.push(sub);
792
+ continue;
793
+ }
794
+ try {
795
+ for (const inner of readdirSync(join(root, sub), { withFileTypes: true })) {
796
+ if (!inner.isDirectory() || !/^[A-Za-z0-9_.-]+$/.test(inner.name) || inner.name === 'node_modules')
797
+ continue;
798
+ if (hasDshManifest(join(root, sub, inner.name)))
799
+ found.push(`${sub}/${inner.name}`);
800
+ }
801
+ }
802
+ catch { /* unreadable level — skip */ }
803
+ if (found.length >= 8)
804
+ break;
805
+ }
806
+ return found.slice(0, 8);
807
+ }
808
+ /**
809
+ * Allow the given packages' build scripts in the profile's
810
+ * pnpm-workspace.yaml `allowBuilds` block (the key dsh profiles use),
811
+ * merging with existing entries and leaving the rest of the yaml intact.
812
+ * (#6 by @qichuang321.)
813
+ * @returns every package now allowed.
814
+ */
815
+ /**
816
+ * Quote a YAML block-mapping key when a plain scalar would be invalid.
817
+ * Scoped npm names start with `@` — a reserved YAML indicator — so an
818
+ * unquoted `@scope/pkg: true` entry breaks the whole pnpm-workspace.yaml
819
+ * for every later pnpm run in the profile (and for the market itself).
820
+ * Keys containing `: ` or ending with `:` are quoted for the same reason;
821
+ * git keys like `name@git+https://…` keep their existing plain form.
822
+ */
823
+ function quoteYamlKey(key) {
824
+ if (/^[-?:,[\]{}#&*!|>'"%@`]/.test(key) || /:(\s|$)/.test(key)) {
825
+ return `'${key.replace(/'/g, "''")}'`;
826
+ }
827
+ return key;
828
+ }
829
+ /**
830
+ * Allow the given packages' build scripts in the profile's
831
+ * pnpm-workspace.yaml `allowBuilds` block (the key dsh profiles use),
832
+ * merging with existing entries and leaving the rest of the yaml intact.
833
+ * (#6 by @qichuang321.)
834
+ * @returns every package now allowed.
835
+ */
836
+ export function setAllowBuilds(profile, packages, explicitDir) {
837
+ const file = join(profileDir(profile, explicitDir), 'pnpm-workspace.yaml');
838
+ let yaml = '';
839
+ try {
840
+ yaml = readFileSync(file, 'utf8');
841
+ }
842
+ catch { /* created below */ }
843
+ // `\r?\n`, not `\n`: a CRLF pnpm-workspace.yaml (every Windows editor, and
844
+ // git with core.autocrlf=true) put a `\r` between `allowBuilds:` and the
845
+ // newline, so the old pattern never matched an EXISTING block and appended
846
+ // a second one. Two top-level `allowBuilds:` keys is invalid YAML, and pnpm
847
+ // then refuses every install in that profile — not just the one that
848
+ // triggered it (#231 by @MichengAI).
849
+ const blockRe = /allowBuilds:[ \t]*\r?\n((?:[ \t]+[^\r\n]*\r?\n?)*)/g;
850
+ const map = {};
851
+ // Every block, not just the first: a profile already broken by the bug
852
+ // above carries two, and merging them is what repairs it — dropping the
853
+ // extra silently would also drop whatever approvals it held.
854
+ const blockMatches = [...yaml.matchAll(blockRe)];
855
+ const blockMatch = blockMatches[0] ?? null;
856
+ for (const match of blockMatches) {
857
+ for (const line of match[1].split(/\r?\n/)) {
858
+ // The key itself may contain colons: git-hosted deps are only matched
859
+ // by a `name@git+https://…` key (#68). The anchored boolean tail makes
860
+ // the split land on the LAST colon, never inside a `://` — and doubles
861
+ // as the placeholder filter: pnpm's failed-install bug (#11535, seen
862
+ // in our #56) writes a literal "set this to true or false" value,
863
+ // which breaks every later approval until the entry is dropped.
864
+ const m = /^[ \t]+(\S.*?)\s*:\s*(true|false)?\s*$/.exec(line);
865
+ if (m === null || m[1] === '')
866
+ continue;
867
+ // Entries this fix wrote may carry single/double quotes around the key
868
+ // (reserved indicators like `@` cannot start a plain scalar); strip
869
+ // them so the map key is the bare package name and a later rewrite
870
+ // never nests quotes.
871
+ let key = m[1];
872
+ if (key.length >= 2
873
+ && (key[0] === "'" && key[key.length - 1] === "'" || key[0] === '"' && key[key.length - 1] === '"')) {
874
+ key = key.slice(1, -1);
875
+ }
876
+ map[key] = m[2] ?? 'true';
877
+ }
878
+ }
879
+ // Bare package names, or the server-derived stable git form
880
+ // `name@git+https://github.com/owner/repo.git` (#68) — nothing else.
881
+ const GIT_KEY_RE = /^[A-Za-z0-9@/_.-]+@git\+https:\/\/github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\.git$/;
882
+ // The commit-pinned form pnpm below 11.21 matches instead (#285). Held to
883
+ // the same shape as the one above rather than loosened into "anything with
884
+ // a URL in it": this list is what stops a caller writing arbitrary text
885
+ // into a file pnpm parses, and a wider pattern would spend that guarantee
886
+ // to save a line.
887
+ const CODELOAD_KEY_RE = /^[A-Za-z0-9@/_.-]+@https:\/\/codeload\.github\.com\/[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+\/tar\.gz\/[0-9a-f]{40}$/;
888
+ for (const pkg of packages) {
889
+ if (/^[A-Za-z0-9@/_.-]+$/.test(pkg) || GIT_KEY_RE.test(pkg) || CODELOAD_KEY_RE.test(pkg))
890
+ map[pkg] = 'true';
891
+ }
892
+ // Write back in the file's OWN line ending. Rewriting a CRLF workspace
893
+ // file with LF would leave it mixed, which is the same class of mess this
894
+ // fix exists to clean up.
895
+ const eol = /\r\n/.test(yaml) ? '\r\n' : '\n';
896
+ const block = Object.entries(map).map(([k, v]) => ` ${quoteYamlKey(k)}: ${v}`).join(eol);
897
+ const blockText = `allowBuilds:${eol}${block}${eol}`;
898
+ let next;
899
+ if (blockMatch === null) {
900
+ next = `${yaml.replace(/\r?\n?$/, eol)}${blockText}`;
901
+ }
902
+ else {
903
+ // The merged block replaces the first occurrence; any further ones are
904
+ // the duplicates this bug created and are dropped — their entries are
905
+ // already folded into `map` above, so nothing is lost.
906
+ let seen = 0;
907
+ next = yaml.replace(blockRe, () => (seen++ === 0 ? blockText : ''));
908
+ }
909
+ writeFileSync(file, next);
910
+ return Object.keys(map);
911
+ }