@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.
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/README.zh.md +143 -0
- package/UPDATE-API-V1.md +132 -0
- package/client/client.js +11534 -0
- package/cordis.patch.yml +5 -0
- package/lib/accelerate.js +184 -0
- package/lib/agents.js +36 -0
- package/lib/backup.js +572 -0
- package/lib/catalog-local-match.js +126 -0
- package/lib/catalog-npm.js +111 -0
- package/lib/changelog.js +242 -0
- package/lib/channels.js +64 -0
- package/lib/check.js +1067 -0
- package/lib/compatibility.js +198 -0
- package/lib/diagnostics.js +49 -0
- package/lib/discovery-compatibility.js +260 -0
- package/lib/dsh-cli.js +949 -0
- package/lib/dsh-install.js +108 -0
- package/lib/gist.js +352 -0
- package/lib/groups.js +97 -0
- package/lib/home-paths.js +42 -0
- package/lib/hot.js +519 -0
- package/lib/http.js +39 -0
- package/lib/index.js +97 -0
- package/lib/install.js +387 -0
- package/lib/log.js +197 -0
- package/lib/ndjson.js +154 -0
- package/lib/net.js +98 -0
- package/lib/order.js +284 -0
- package/lib/patch.js +521 -0
- package/lib/pnpm-compat.js +490 -0
- package/lib/presets.js +278 -0
- package/lib/profile.js +911 -0
- package/lib/region-probe.js +93 -0
- package/lib/regions.js +253 -0
- package/lib/registry.js +204 -0
- package/lib/restart.js +373 -0
- package/lib/routes.js +4372 -0
- package/lib/settings.js +109 -0
- package/lib/skill-market/api.js +119 -0
- package/lib/skill-market/config.js +29 -0
- package/lib/skill-market/installer.js +352 -0
- package/lib/skill-market/runtime.js +13 -0
- package/lib/skill-market/tui.js +119 -0
- package/lib/skill-market/types.js +1 -0
- package/lib/skill-market/web-routes.js +123 -0
- package/lib/snapshot.js +500 -0
- package/lib/source-migration.js +54 -0
- package/lib/sources.js +596 -0
- package/lib/store.js +91 -0
- package/lib/themes.js +102 -0
- package/lib/trial.js +116 -0
- package/lib/types/accelerate.d.ts +85 -0
- package/lib/types/agents.d.ts +22 -0
- package/lib/types/backup.d.ts +131 -0
- package/lib/types/catalog-local-match.d.ts +50 -0
- package/lib/types/catalog-npm.d.ts +47 -0
- package/lib/types/changelog.d.ts +102 -0
- package/lib/types/channels.d.ts +56 -0
- package/lib/types/check.d.ts +266 -0
- package/lib/types/compatibility.d.ts +64 -0
- package/lib/types/diagnostics.d.ts +29 -0
- package/lib/types/discovery-compatibility.d.ts +80 -0
- package/lib/types/dsh-cli.d.ts +324 -0
- package/lib/types/dsh-install.d.ts +46 -0
- package/lib/types/gist.d.ts +53 -0
- package/lib/types/groups.d.ts +34 -0
- package/lib/types/home-paths.d.ts +16 -0
- package/lib/types/hot.d.ts +221 -0
- package/lib/types/http.d.ts +12 -0
- package/lib/types/index.d.ts +14 -0
- package/lib/types/install.d.ts +172 -0
- package/lib/types/log.d.ts +43 -0
- package/lib/types/ndjson.d.ts +52 -0
- package/lib/types/net.d.ts +58 -0
- package/lib/types/order.d.ts +100 -0
- package/lib/types/patch.d.ts +129 -0
- package/lib/types/pnpm-compat.d.ts +93 -0
- package/lib/types/presets.d.ts +90 -0
- package/lib/types/profile.d.ts +253 -0
- package/lib/types/region-probe.d.ts +50 -0
- package/lib/types/regions.d.ts +122 -0
- package/lib/types/registry.d.ts +86 -0
- package/lib/types/restart.d.ts +194 -0
- package/lib/types/routes.d.ts +67 -0
- package/lib/types/settings.d.ts +78 -0
- package/lib/types/skill-market/api.d.ts +10 -0
- package/lib/types/skill-market/config.d.ts +3 -0
- package/lib/types/skill-market/installer.d.ts +23 -0
- package/lib/types/skill-market/runtime.d.ts +10 -0
- package/lib/types/skill-market/tui.d.ts +4 -0
- package/lib/types/skill-market/types.d.ts +71 -0
- package/lib/types/skill-market/web-routes.d.ts +19 -0
- package/lib/types/snapshot.d.ts +93 -0
- package/lib/types/source-migration.d.ts +11 -0
- package/lib/types/sources.d.ts +216 -0
- package/lib/types/store.d.ts +30 -0
- package/lib/types/themes.d.ts +40 -0
- package/lib/types/trial.d.ts +61 -0
- package/lib/types/update-api-v1.d.ts +66 -0
- package/lib/types/updates.d.ts +123 -0
- package/lib/types/verify.d.ts +139 -0
- package/lib/update-api-v1.js +215 -0
- package/lib/updates.js +361 -0
- package/lib/verify.js +453 -0
- package/package.json +121 -0
- package/src/accelerate.ts +213 -0
- package/src/agents.ts +43 -0
- package/src/backup.ts +583 -0
- package/src/catalog-local-match.ts +144 -0
- package/src/catalog-npm.ts +120 -0
- package/src/changelog.ts +282 -0
- package/src/channels.ts +70 -0
- package/src/check.ts +1239 -0
- package/src/client/CommentsModal.tsx +119 -0
- package/src/client/Diagnostics.tsx +907 -0
- package/src/client/ErrorBoundary.tsx +111 -0
- package/src/client/InstallToast.tsx +31 -0
- package/src/client/Market.module.css +830 -0
- package/src/client/MarketSection.tsx +5301 -0
- package/src/client/OperationsPanel.tsx +365 -0
- package/src/client/SettingsCard.tsx +621 -0
- package/src/client/SkillMarket.module.css +21 -0
- package/src/client/SkillMarketSection.tsx +162 -0
- package/src/client/comments.ts +54 -0
- package/src/client/globals.d.ts +13 -0
- package/src/client/index.ts +180 -0
- package/src/client/locales.ts +1112 -0
- package/src/client/market-data.ts +1321 -0
- package/src/client/operations.ts +201 -0
- package/src/client/preset-panel.tsx +263 -0
- package/src/client/primitives.d.ts +140 -0
- package/src/client/self-check.ts +147 -0
- package/src/client/snapshot-panel.tsx +244 -0
- package/src/compatibility.ts +237 -0
- package/src/diagnostics.ts +84 -0
- package/src/discovery-compatibility.ts +315 -0
- package/src/dsh-cli.ts +1126 -0
- package/src/dsh-install.ts +118 -0
- package/src/gist.ts +362 -0
- package/src/groups.ts +111 -0
- package/src/home-paths.ts +53 -0
- package/src/hot.ts +628 -0
- package/src/http.ts +41 -0
- package/src/index.ts +128 -0
- package/src/install.ts +420 -0
- package/src/log.ts +206 -0
- package/src/ndjson.ts +185 -0
- package/src/net.ts +106 -0
- package/src/order.ts +303 -0
- package/src/patch.ts +522 -0
- package/src/pnpm-compat.ts +527 -0
- package/src/presets.ts +344 -0
- package/src/profile.ts +940 -0
- package/src/region-probe.ts +97 -0
- package/src/regions.ts +310 -0
- package/src/registry.ts +250 -0
- package/src/restart.ts +396 -0
- package/src/routes.ts +4506 -0
- package/src/settings.ts +141 -0
- package/src/skill-market/api.ts +130 -0
- package/src/skill-market/config.ts +32 -0
- package/src/skill-market/installer.ts +337 -0
- package/src/skill-market/runtime.ts +14 -0
- package/src/skill-market/tui.ts +157 -0
- package/src/skill-market/types.ts +80 -0
- package/src/skill-market/web-routes.ts +132 -0
- package/src/snapshot.ts +532 -0
- package/src/source-migration.ts +61 -0
- package/src/sources.ts +565 -0
- package/src/store.ts +89 -0
- package/src/themes.ts +125 -0
- package/src/trial.ts +156 -0
- package/src/update-api-v1.ts +277 -0
- package/src/updates.ts +400 -0
- 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
|
+
}
|