@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/index.ts ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * dsh-market host entry: mounts the market's HTTP routes once the profile
3
+ * composes the webServer and shell services.
4
+ */
5
+
6
+ import type { Context } from '@deepseek-ai/cordis'
7
+ import { createDesktopPluginRuntime, type DesktopPnpmLike } from './dsh-cli.ts'
8
+ import { mountMarketRoutes, type MarketConfig, type MarketHost } from './routes.ts'
9
+ import { installMarketSettings } from './settings.ts'
10
+ import type { AgentsServiceLike } from './agents.ts'
11
+ import { resolveSkillMarketConfig } from './skill-market/config.ts'
12
+ import { SkillMarketRuntime } from './skill-market/runtime.ts'
13
+ import { mountSkillMarketRoutes } from './skill-market/web-routes.ts'
14
+ import { installSkillMarketTui } from './skill-market/tui.ts'
15
+ import type { SkillMarketConfig } from './skill-market/types.ts'
16
+
17
+ export const name = 'dsh-market'
18
+
19
+ /** Optional cordis.yml configuration; profile defaults to `web`. */
20
+ export type Config = Partial<Pick<MarketConfig, 'profile' | 'allowRestart' | 'maxSnapshots'>> & {
21
+ /** Shared Web and terminal skill-market settings. */
22
+ readonly skillMarket?: SkillMarketConfig
23
+ }
24
+
25
+ /** Structural subset of DSH Desktop's public `desktopProfiles` contract. */
26
+ interface DesktopProfilesLike {
27
+ readonly current: {
28
+ readonly name: string
29
+ readonly dir: string
30
+ }
31
+ }
32
+
33
+ interface MarketEffectHost extends MarketHost {
34
+ effect(
35
+ callback: () => (() => void | Promise<void>),
36
+ label: string,
37
+ ): void
38
+ }
39
+
40
+ /**
41
+ * Register the market against the host context.
42
+ * @param ctx - Host context that may acquire webServer and shell services.
43
+ * @param config - Optional profile override from the loader.
44
+ */
45
+ /**
46
+ * The profile this host process actually booted (`--profile <name>` on the
47
+ * dsh CLI invocation). Without it the market would default to `web` and
48
+ * installs from a test/secondary profile would mutate the real one.
49
+ */
50
+ function argvProfile(): string | undefined {
51
+ const argv = process.argv
52
+ const flag = argv.indexOf('--profile')
53
+ if (flag !== -1 && flag + 1 < argv.length && !argv[flag + 1].startsWith('-')) return argv[flag + 1]
54
+ return undefined
55
+ }
56
+
57
+ /**
58
+ * Resolve the host's `agents` inventory lazily — at request time, not at
59
+ * market startup, so the guard sees whichever agents exist by the time an
60
+ * update is asked for. Hosts without the service return undefined and the
61
+ * update route stays open (see src/agents.ts).
62
+ */
63
+ function agentsLookupOf(ctx: Context): () => AgentsServiceLike | undefined {
64
+ return () => ctx.get('agents') as AgentsServiceLike | undefined
65
+ }
66
+
67
+ export function apply(ctx: Context, config?: Config): void {
68
+ const skillMarketConfig = resolveSkillMarketConfig(config?.skillMarket)
69
+ const skillMarket = new SkillMarketRuntime(skillMarketConfig)
70
+
71
+ ctx.inject(['webServer', 'loader'], (hostCtx: Context) => {
72
+ const host = hostCtx as unknown as MarketEffectHost
73
+ const desktopProfiles = ctx.get('desktopProfiles') as DesktopProfilesLike | undefined
74
+ if (desktopProfiles === undefined) {
75
+ const resolved: MarketConfig = {
76
+ profile: config?.profile ?? argvProfile() ?? 'web',
77
+ // Left UNDEFINED when unconfigured, deliberately: `?? true` here
78
+ // would turn "the operator said nothing" into "the operator said
79
+ // yes", and restartAllowed() could no longer tell them apart — which
80
+ // is exactly the distinction supervisor detection needs (#229).
81
+ allowRestart: config?.allowRestart,
82
+ maxSnapshots: config?.maxSnapshots,
83
+ }
84
+ // Offer allowRestart as a switch on the settings page. Deliberately
85
+ // NOT in the Desktop branch below: there the shell owns the process
86
+ // lifecycle and the value is forced false, so it is not the user's to
87
+ // choose. No-ops on a host without a settings service.
88
+ installMarketSettings(ctx, resolved)
89
+ host.effect(() => {
90
+ return mountMarketRoutes(host, resolved, undefined, agentsLookupOf(ctx))
91
+ }, 'dsh-market: http routes')
92
+ return
93
+ }
94
+
95
+ // Desktop's supported cross-environment contract guarantees that
96
+ // desktopProfiles exists before Loader entries mount, and prescribes this
97
+ // presence check plus a nested desktopPnpm injection:
98
+ // https://github.com/anywhere-labs/deepseek-harness-desktop/blob/4f68147091e585aaa1d815f99d30a657b3842d7c/dsh-plugin-desktop/docs/plugin-services.md#L190-L243
99
+ // Ordinary DSH keeps the existing CLI path above.
100
+ hostCtx.inject(['desktopPnpm'], (desktopCtx: Context) => {
101
+ const current = desktopProfiles.current
102
+ const service = (desktopCtx as unknown as { desktopPnpm: DesktopPnpmLike }).desktopPnpm
103
+ const runtime = createDesktopPluginRuntime(service, current.dir)
104
+ const resolved: MarketConfig = {
105
+ profile: current.name,
106
+ profileDirectory: current.dir,
107
+ // Relaunching a raw Electron process would bypass Desktop's launcher
108
+ // lifecycle. The shell remains responsible for restart in this mode.
109
+ allowRestart: false,
110
+ maxSnapshots: config?.maxSnapshots,
111
+ }
112
+ const desktopHost = desktopCtx as unknown as MarketEffectHost
113
+ desktopHost.effect(() => {
114
+ const disposeRoutes = mountMarketRoutes(host, resolved, runtime, agentsLookupOf(ctx))
115
+ return async () => {
116
+ disposeRoutes()
117
+ await runtime.dispose()
118
+ }
119
+ }, 'dsh-market: Desktop http routes and package operations')
120
+ })
121
+ })
122
+
123
+ installSkillMarketTui(ctx, skillMarket)
124
+ ctx.inject(['webServer', 'sessions'], (hostCtx: Context) => {
125
+ const host = hostCtx as unknown as Parameters<typeof mountSkillMarketRoutes>[0] & MarketEffectHost
126
+ host.effect(() => mountSkillMarketRoutes(host, skillMarket), 'dsh-market: skill market http routes')
127
+ })
128
+ }
package/src/install.ts ADDED
@@ -0,0 +1,420 @@
1
+ /**
2
+ * Install orchestration: collection-repo retargeting, post-install
3
+ * validation that keeps broken pieces from bricking the next boot, and
4
+ * update staleness detection. Every function takes the plugin runner as a
5
+ * parameter so tests can substitute a recording fake.
6
+ */
7
+
8
+ import { existsSync } from 'node:fs'
9
+ import { join } from 'node:path'
10
+ import type { InstallResult, PluginRunner } from './dsh-cli.ts'
11
+ import { classifyPnpmFailure, HOST_NAMESPACE_RE, isTransientPnpmFailure } from './pnpm-compat.ts'
12
+ import { conflictingEntryIds, dropFromManifest, hasDshManifest, hasLoadableEntry, pluginSubdirs, profileDir, readInstalled, readManifestDeps, readProfileBundles } from './profile.ts'
13
+ import { logEvent } from './log.ts'
14
+ import { cleanOrphanedStore } from './store.ts'
15
+
16
+ /** One-shot bypass for pnpm's fresh-release hold; scoped to a single command. */
17
+ export const RELEASE_AGE_OVERRIDE = '--config.minimumReleaseAge=0'
18
+
19
+ /**
20
+ * Longer per-request fetch timeout for one retried command. pnpm's default
21
+ * 60-second limit aborts large tarball downloads (github: sources fetch the
22
+ * WHOLE repo even for a `#path:` subdirectory plugin) on slow networks; a
23
+ * plain retry fails again at the same limit, so the recovery re-runs with
24
+ * this override once. Scoped to a single command like RELEASE_AGE_OVERRIDE.
25
+ */
26
+ export const FETCH_TIMEOUT_OVERRIDE = '--config.fetchTimeout=600000'
27
+
28
+ /**
29
+ * Stop pnpm downloading a plugin's peer dependencies (#289 by @00080000).
30
+ *
31
+ * The last resort for a peer that cannot be downloaded because it does not
32
+ * exist on any registry: the dsh runtime injects several `@deepseek-ai/*`
33
+ * packages and never publishes them, and since pnpm 8 `auto-install-peers`
34
+ * defaults on, so pnpm walks the peer list and 404s on one.
35
+ *
36
+ * Only on the retry, never by default. Turning it off wholesale would also
37
+ * stop pnpm installing the peers a plugin legitimately needs from npm, and
38
+ * that failure would surface much later — as a missing module at runtime
39
+ * rather than a clear error at install time. Narrow beats early here.
40
+ *
41
+ * Verified against pnpm 10.29.3: `peerDependencyRules.ignoreMissing` does
42
+ * NOT prevent the fetch (it only silences the warning), so this flag is the
43
+ * only lever that actually works.
44
+ */
45
+ export const AUTO_INSTALL_PEERS_OFF = '--config.auto-install-peers=false'
46
+
47
+ /**
48
+ * Whether an unresolvable package is a host peer pnpm went looking for on
49
+ * its own, rather than something the profile actually asks for.
50
+ *
51
+ * The same 404 means two different things and wants two different answers.
52
+ * A `@deepseek-ai/*` package that IS in the profile manifest is a ghost
53
+ * entry — left by an earlier failed operation, or hand-added — and the user
54
+ * has to remove that line; retrying would only fail again. One that is NOT
55
+ * in the manifest was never asked for by anybody: pnpm reached it by walking
56
+ * an installed plugin's peerDependencies, which in this ecosystem name what
57
+ * the runtime provides rather than what npm carries.
58
+ *
59
+ * Reading the manifest is what separates them, so this cannot live in the
60
+ * pure classifier.
61
+ */
62
+ export function isUnpublishedHostPeer(
63
+ pkg: string | undefined,
64
+ profile: string,
65
+ explicitDir?: string,
66
+ ): boolean {
67
+ if (pkg === undefined || !HOST_NAMESPACE_RE.test(pkg)) return false
68
+ return !Object.hasOwn(readManifestDeps(profile, explicitDir), pkg)
69
+ }
70
+
71
+ /**
72
+ * Run one plugin command with automatic recovery from three known pnpm traps:
73
+ *
74
+ * - pnpm-major drift (#20 bug 2): a modules directory built by a different
75
+ * pnpm major fails mutation; pnpm's documented remedy is one `install` to
76
+ * recreate it — do that silently and retry the original command once.
77
+ * - release-age lockfile lock (#39): once a too-young release is in the
78
+ * lockfile, pnpm 11 rejects EVERY later add/remove during verification —
79
+ * retry once with the one-shot minimumReleaseAge bypass (safe: the young
80
+ * package is already installed; the bypass only lets pnpm touch the
81
+ * lockfile again).
82
+ * - per-request fetch timeout: large tarballs (github: sources fetch the
83
+ * whole repo even for a `#path:` subdirectory) on slow networks blow
84
+ * pnpm's default 60-second limit; a plain retry fails again at the same
85
+ * limit, so retry once with a longer fetchTimeout.
86
+ *
87
+ * Any recognized failure that survives gets its bilingual explanation
88
+ * appended to stderr so the UI shows an actionable message instead of a
89
+ * wall of text (#20 bug 3). Cancelled runs are never recovered.
90
+ */
91
+ export async function withHoistRecovery(
92
+ run: PluginRunner,
93
+ profile: string,
94
+ pluginArgs: string[],
95
+ profileDirectory?: string,
96
+ ): Promise<InstallResult> {
97
+ let result = await run(profile, pluginArgs)
98
+ const ok = (r: InstallResult): boolean => r.exitCode === 0 && !r.timedOut && !r.cancelled
99
+ if (!ok(result) && !result.cancelled) {
100
+ const failure = classifyPnpmFailure(`${result.stderr}\n${result.stdout}`, result.exitCode)
101
+ if (failure?.code === 'hoist-pattern-diff') {
102
+ logEvent('warn', 'install', `modules dir was built by a different pnpm major — rebuilding (pnpm install) and retrying once`)
103
+ // --no-frozen-lockfile: the market runs pnpm with CI=true (TTY hangs),
104
+ // where a lockfile written by the old major would otherwise be refused.
105
+ const rebuild = await run(profile, ['install', '--no-frozen-lockfile'])
106
+ if (ok(rebuild)) result = await run(profile, pluginArgs)
107
+ } else if (
108
+ failure?.code === 'release-age-violation'
109
+ && (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')
110
+ && !pluginArgs.includes(RELEASE_AGE_OVERRIDE)
111
+ ) {
112
+ logEvent('warn', 'install', `a too-young release blocks pnpm's lockfile verification (#39) — retrying once with ${RELEASE_AGE_OVERRIDE}`)
113
+ result = await run(profile, [pluginArgs[0], RELEASE_AGE_OVERRIDE, ...pluginArgs.slice(1)])
114
+ } else if (
115
+ failure?.code === 'fetch-404'
116
+ && isUnpublishedHostPeer(failure.pkg, profile, profileDirectory)
117
+ && (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')
118
+ && !pluginArgs.includes(AUTO_INSTALL_PEERS_OFF)
119
+ ) {
120
+ // The plugin is fine; pnpm went looking for a package the host injects
121
+ // and npm has never carried. Every fresh profile hits this, whatever
122
+ // the plugin, so failing here would be failing for something the user
123
+ // cannot fix and did not cause.
124
+ logEvent('warn', 'install', `${failure.pkg ?? 'a host package'} is a peer the runtime provides and npm does not carry (#289) — retrying once with ${AUTO_INSTALL_PEERS_OFF}`)
125
+ result = await run(profile, [pluginArgs[0], AUTO_INSTALL_PEERS_OFF, ...pluginArgs.slice(1)])
126
+ } else if (
127
+ failure?.code === 'transient-network'
128
+ && (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')
129
+ ) {
130
+ // #83: pnpm replays the whole tree, so any existing dependency's
131
+ // momentary network hiccup fails the run — and a plain retry succeeds.
132
+ // Do that retry ourselves instead of reporting a false failure.
133
+ logEvent('warn', 'install', `transient network failure while pnpm replayed the dependency tree (#83) — retrying once`)
134
+ result = await run(profile, pluginArgs)
135
+ } else if (
136
+ failure?.code === 'fetch-timeout'
137
+ && (pluginArgs[0] === 'add' || pluginArgs[0] === 'remove')
138
+ && !pluginArgs.includes(FETCH_TIMEOUT_OVERRIDE)
139
+ ) {
140
+ // Large tarball / slow network: pnpm's default 60s per-request limit
141
+ // aborted the download. A plain retry fails again at the same limit,
142
+ // so retry once with a longer fetchTimeout.
143
+ logEvent('warn', 'install', `pnpm's per-request fetch timeout aborted a large download — retrying once with ${FETCH_TIMEOUT_OVERRIDE}`)
144
+ result = await run(profile, [pluginArgs[0], FETCH_TIMEOUT_OVERRIDE, ...pluginArgs.slice(1)])
145
+ }
146
+ }
147
+ if (!ok(result) && !result.cancelled) {
148
+ // A failed, timed-out, or killed run never finishes pnpm's staging, so
149
+ // its store tmp dirs (the WHOLE repo tarball for github: sources) are
150
+ // orphaned — reclaim them now that no pnpm is running. Safe by
151
+ // construction: directories are only removed when their owning pid is
152
+ // gone (the name carries it), so a live download is never touched.
153
+ await cleanOrphanedStore(run, profile)
154
+ const failure = classifyPnpmFailure(`${result.stderr}\n${result.stdout}`, result.exitCode)
155
+ if (failure !== null) {
156
+ result = {
157
+ ...result,
158
+ stderr: failure.replaceOutput === true ? failure.message : `${result.stderr}\n\n${failure.message}`,
159
+ ...(failure.replaceOutput === true ? { stdout: '' } : {}),
160
+ }
161
+ } else if (result.pnpmError !== undefined && result.pnpmError !== '') {
162
+ // Nothing matched, but pnpm DID say what went wrong — in its ndjson
163
+ // stream, which never reaches stderr. Without this the user is shown
164
+ // the tail of dsh's wrapper output ("pnpm failed in profile
165
+ // directory …"), which is byte-identical for every possible cause and
166
+ // is why #244, #192 and #138 all read as "the UI shows a stack tail".
167
+ //
168
+ // An unrecognized error is exactly the case where the raw text is
169
+ // worth the most: a classified one has a written explanation, this one
170
+ // has only pnpm's own words, and hiding them leaves nothing at all.
171
+ const code = result.pnpmErrorCode === undefined ? '' : `${result.pnpmErrorCode}: `
172
+ result = { ...result, stderr: `${result.stderr}\n\n${code}${result.pnpmError}` }
173
+ }
174
+ }
175
+ return result
176
+ }
177
+
178
+ /**
179
+ * Whether pnpm never started at all, so the profile cannot have been touched.
180
+ *
181
+ * Worth its own question because the update route answers a failed run by
182
+ * reinstalling the previous build and reporting loudly when it cannot verify
183
+ * that (#502 by @Ztyss): three updates in a row told the user their profile
184
+ * might be broken and to inspect it before restarting, when in fact nothing
185
+ * had been written — the command line could not launch pnpm, so package.json
186
+ * and node_modules were exactly as they had been.
187
+ * @param result - the failed run.
188
+ * @returns true when the failure happened before pnpm could run.
189
+ */
190
+ export function pnpmNeverStarted(result: InstallResult): boolean {
191
+ return classifyPnpmFailure(`${result.stderr}\n${result.stdout}`, result.exitCode)?.code === 'pnpm-unusable'
192
+ }
193
+
194
+ /**
195
+ * The most specific description of a failed run available, for logs.
196
+ *
197
+ * pnpm's structured error beats the stderr tail whenever there is one — see
198
+ * withHoistRecovery above for why the tail is nearly worthless here.
199
+ */
200
+ export function failureDetail(result: InstallResult, limit = 300): string {
201
+ if (result.pnpmError !== undefined && result.pnpmError !== '') {
202
+ const code = result.pnpmErrorCode === undefined ? '' : `${result.pnpmErrorCode}: `
203
+ return `${code}${result.pnpmError}`.slice(0, limit)
204
+ }
205
+ return (result.stderr || result.stdout).slice(-limit)
206
+ }
207
+
208
+ /**
209
+ * Some registry entries point at collection repos whose actual plugin lives
210
+ * in a subdirectory — the root has no package.json (or a workspace root with
211
+ * no dsh surface), and pnpm installs the bare fileset with exit 0. Detect
212
+ * that junk install, drop it, and re-add each plugin subdirectory through
213
+ * pnpm's `#path:` selector (#18).
214
+ * @returns overall success (true when nothing needed retargeting).
215
+ */
216
+ export async function retargetCollections(
217
+ run: PluginRunner, profile: string, before: Set<string>, target: string, explicitDir?: string,
218
+ ): Promise<boolean> {
219
+ if (!target.startsWith('github:')) return true
220
+ const dir = profileDir(profile, explicitDir)
221
+ const junk = Object.keys(readInstalled(profile, dir)).filter((name) => {
222
+ if (before.has(name)) return false
223
+ const root = join(dir, 'node_modules', name)
224
+ if (!existsSync(join(root, 'package.json'))) return true
225
+ return !hasDshManifest(root)
226
+ })
227
+ let allOk = true
228
+ for (const name of junk) {
229
+ const root = join(dir, 'node_modules', name)
230
+ const candidates = pluginSubdirs(root)
231
+ logEvent('info', 'install', `${name}: collection repo (root declares no dsh manifest); plugins inside: ${candidates.join(', ') || 'none'}`)
232
+ await run(profile, ['remove', name])
233
+ if (candidates.length === 0) {
234
+ allOk = false
235
+ continue
236
+ }
237
+ for (const sub of candidates) {
238
+ // A China-region root may already carry the commit resolved through
239
+ // the mirror (`#<sha>`). pnpm's fragment grammar joins the subpath as
240
+ // another selector with `&`; a second `#` produces an invalid target.
241
+ const subTarget = `${target}${target.includes('#') ? '&' : '#'}path:/${sub}`
242
+ const result = await run(profile, ['add', subTarget])
243
+ if (result.exitCode !== 0 || result.timedOut) {
244
+ allOk = false
245
+ logEvent('error', 'install',
246
+ `${subTarget}: exit=${String(result.exitCode)}${result.timedOut ? ' TIMEOUT' : ''} — ${(result.stderr || result.stdout).slice(-220)}`)
247
+ }
248
+ }
249
+ }
250
+ return allOk
251
+ }
252
+
253
+ /**
254
+ * Fake-success guard (#18): validate every package the install added. A
255
+ * piece without a dsh manifest or without its declared entry artifact
256
+ * (source-only checkout, build blocked by pnpm allowBuilds) would brick the
257
+ * next boot, so it is removed on the spot.
258
+ *
259
+ * Since #122 this also covers duplicate loader entry ids: cordis refuses to
260
+ * load a tree containing two entries with one id, so a TUI bundle landing in
261
+ * a web profile (both declare `id: storage`) leaves DSH unable to START —
262
+ * an error naming neither plugin, from which the market's own page is
263
+ * unreachable. Such a package is removed like any other bricking piece.
264
+ * @returns names added by this run, names kept, names removed as broken,
265
+ * and the id conflicts found. `added` is reported separately from `keep`
266
+ * because an EMPTY `added` is a different failure from "everything added was
267
+ * unloadable": it means the install reported success without touching the
268
+ * profile at all, which is a broken plugin-command channel rather than
269
+ * anything wrong with the plugin (#258).
270
+ */
271
+ export async function validateAddedPlugins(
272
+ run: PluginRunner, profile: string, before: Set<string>, explicitDir?: string,
273
+ ): Promise<{ added: string[]; keep: string[]; removedBroken: string[]; conflicts: { name: string; id: string; owner: string }[] }> {
274
+ const dir = profileDir(profile, explicitDir)
275
+ const addedNow = Object.keys(readInstalled(profile, dir)).filter(n => !before.has(n))
276
+ const keep: string[] = []
277
+ const removedBroken: string[] = []
278
+ const conflicts: { name: string; id: string; owner: string }[] = []
279
+ // Compare against what the profile already loads, minus this install's own
280
+ // additions — two pieces of one plugin are not "already installed".
281
+ const existingBundles = readProfileBundles(dir).filter(name => !addedNow.includes(name))
282
+ for (const n of addedNow) {
283
+ const packageDir = join(dir, 'node_modules', n)
284
+ // hasLoadableEntry, not entryArtifactExists: carrier bundles legitimately
285
+ // ship no entry of their own (#103) and must not be uninstalled here.
286
+ if (!hasDshManifest(packageDir) || !hasLoadableEntry(dir, n)) {
287
+ removedBroken.push(n)
288
+ await removeAndReconcile(run, profile, dir, n)
289
+ continue
290
+ }
291
+ const clash = conflictingEntryIds(dir, n, existingBundles)
292
+ if (clash.length > 0) {
293
+ // Keeping it would make the NEXT BOOT fail outright (#122).
294
+ conflicts.push(...clash.map(hit => ({ name: n, ...hit })))
295
+ removedBroken.push(n)
296
+ logEvent('error', 'install',
297
+ `${n}: loader entry id conflict with ${clash[0].owner} (${clash.map(hit => hit.id).join(', ')}) — removing, it would break the next boot`)
298
+ await removeAndReconcile(run, profile, dir, n)
299
+ continue
300
+ }
301
+ keep.push(n)
302
+ }
303
+ return { added: addedNow, keep, removedBroken, conflicts }
304
+ }
305
+
306
+ /**
307
+ * Run one removal this validation triggered and reconcile the manifest by
308
+ * disk truth afterwards.
309
+ *
310
+ * The plugin command reconciles `dsh.profile.bundles` only when pnpm exits
311
+ * 0, and a remove can fail AFTER completing every persistent step — the #65
312
+ * write-order family — or exit 0 with the reconcile still not reflected in
313
+ * the manifest. Either way the bundle row left behind names a package the
314
+ * next boot cannot resolve, and the loader dies on the first such row: the
315
+ * whole profile, not just this plugin, refuses to start, with the market's
316
+ * own page unreachable. Disk truth decides the repair, deliberately: a
317
+ * package that is gone gets its manifest rows dropped so the boot stays
318
+ * loadable, while a package still on disk keeps them, because a retry needs
319
+ * something to retry against.
320
+ * @param run - the plugin runner, as the caller received it.
321
+ * @param profile - the profile name for manifest writes.
322
+ * @param dir - the profile directory the validation reads.
323
+ * @param name - the package being removed.
324
+ */
325
+ async function removeAndReconcile(run: PluginRunner, profile: string, dir: string, name: string): Promise<void> {
326
+ const result = await run(profile, ['remove', name])
327
+ const gone = !existsSync(join(dir, 'node_modules', name, 'package.json'))
328
+ if (gone) {
329
+ if (dropFromManifest(profile, name, dir)) {
330
+ logEvent('error', 'install',
331
+ `${name}: remove ${result.exitCode === 0 ? 'skipped the manifest reconcile' : `failed (exit ${String(result.exitCode)})`} but the package is gone from disk — dropped its dependency/bundle rows so the next boot stays loadable`)
332
+ }
333
+ return
334
+ }
335
+ if (result.exitCode !== 0 || result.timedOut || result.cancelled) {
336
+ logEvent('error', 'install',
337
+ `${name}: remove failed (exit ${String(result.exitCode)})${result.timedOut ? ' timed out' : ''}${result.cancelled ? ' cancelled' : ''} and the package is still installed — its rows stay in the manifest; retry the uninstall`)
338
+ }
339
+ }
340
+
341
+ /**
342
+ * Group flat `{id, owner}` conflict hits by the installed plugin that owns
343
+ * them. What the user has to decide is which PLUGINS to uninstall, not which
344
+ * ids to resolve, so one row per owner is the unit the market renders and
345
+ * acts on. Flattening the other way (one row per id) also misattributes when
346
+ * a candidate clashes with several installed plugins at once.
347
+ * @param conflicts flat hits as returned by {@link validateAddedPlugins}.
348
+ * @returns one entry per owner, owners and ids both in first-seen order.
349
+ */
350
+ export function groupConflictsByOwner(
351
+ conflicts: readonly { id: string; owner: string }[],
352
+ ): { owner: string; ids: string[] }[] {
353
+ const byOwner = new Map<string, string[]>()
354
+ for (const hit of conflicts) {
355
+ const ids = byOwner.get(hit.owner)
356
+ if (ids === undefined) byOwner.set(hit.owner, [hit.id])
357
+ else if (!ids.includes(hit.id)) ids.push(hit.id)
358
+ }
359
+ return [...byOwner].map(([owner, ids]) => ({ owner, ids }))
360
+ }
361
+
362
+ /**
363
+ * Whether a clean-exit update actually changed nothing — pnpm's
364
+ * minimumReleaseAge silently keeps the old version and exits 0 when the new
365
+ * release is "too young" (#13, #22), so a clean exit alone does not mean the
366
+ * update happened.
367
+ */
368
+ export function isStaleUpdate(check: {
369
+ isGit: boolean
370
+ beforeVersion: string | null
371
+ afterVersion: string | null
372
+ beforeCommit: string | null
373
+ afterCommit: string | null
374
+ }): boolean {
375
+ return check.isGit
376
+ ? check.beforeCommit !== null && check.afterCommit === check.beforeCommit
377
+ : check.beforeVersion !== null && check.afterVersion === check.beforeVersion
378
+ }
379
+
380
+ /**
381
+ * The package pnpm's fetcher refused to prepare because its build script is
382
+ * not allowlisted — `The git-hosted package "name@2.8.0" needs to execute
383
+ * build scripts but is not in the "allowBuilds" allowlist.` Null when the
384
+ * output is not this failure. Unlike ignored-builds, the package is NOT in
385
+ * node_modules yet (the fetcher rejects before materialization, #68).
386
+ */
387
+ export function parsePrepareNotAllowed(stdout: string, stderr: string): string | null {
388
+ // The market always runs pnpm with --reporter=ndjson, so this sentence
389
+ // usually arrives inside a JSON string with its quotes escaped (#113):
390
+ // … git-hosted package \"pkg@1.0.0\" needs to execute build scripts …
391
+ // Unescape before matching, or the ndjson path — the ONLY path in
392
+ // production — silently returns null and the approve banner never shows.
393
+ const text = `${stdout}\n${stderr}`.replace(/\\"/g, '"')
394
+ const m = /git-hosted package "([^"]+)" needs to execute build scripts/.exec(text)
395
+ if (m === null) return null
396
+ // Strip the trailing @version — the name itself may be scoped (@scope/pkg).
397
+ const raw = m[1].trim()
398
+ const at = raw.lastIndexOf('@')
399
+ return at > 0 ? raw.slice(0, at) : raw
400
+ }
401
+
402
+ /**
403
+ * Package names pnpm reported as having their build scripts ignored
404
+ * ("Ignored build scripts: esbuild, koffi."). Empty when none.
405
+ * (#6 by @qichuang321.)
406
+ */
407
+ export function parseIgnoredBuilds(stdout: string, stderr: string): string[] {
408
+ const m = /Ignored build scripts:?\s*([^\n]+)/i.exec(`${stdout}\n${stderr}`)
409
+ if (m === null) return []
410
+ const found: string[] = []
411
+ for (const chunk of m[1].split(',')) {
412
+ // Entries may carry a version suffix and the sentence's final period.
413
+ const trimmed = chunk.trim().replace(/\.$/, '')
414
+ if (trimmed === '') continue
415
+ const at = trimmed.lastIndexOf('@')
416
+ const name = at > 0 ? trimmed.slice(0, at) : trimmed
417
+ if (name !== '' && !found.includes(name)) found.push(name)
418
+ }
419
+ return found
420
+ }