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