Files
deepseek-harness/packages/boot/plugin-manager/src/install-spec.ts
T
Yichen Jiang 4d1adb8541 feat(web): manage plugins over the #4182 manager from the sidebar page
The sidebar Plugins page now drives `ctx.pluginManager` from #4182 and the
settings Plugin list tab goes back to read-only: its bundle form and row
checkboxes are removed, and the page joins `listBundles` with
`listPlugins` into one view per bundle, decides availability from the
inventory's `managementAvailable`, switches bundles and rows through
`setBundleEnabled` and `setPluginEnabled`, and confirms uninstalls.

The manager gains what the guided install dialog needs: `inspect(spec)`
reads a spec through the registry or a directory before anything installs
and refuses specs without a bundle; `installBundle` takes a request id,
streams each pnpm run as `plugin-manager/install-log`, reports its phases
as `plugin-manager/install-state`, classifies a failed run into
`packageResult.kind`, names the bundle it added, and restores
`package.json` and `pnpm-lock.yaml` when the run fails, is cancelled, or
adds a package without a bundle patch; `cancelInstall(requestId)` answers
only after pnpm exited and the files are back; `listBundles` carries
titles, one-liners, declared rows with their live entries, and the
built-in rows a bundle overrides; every completed operation and every
patch generation applied outside the manager emits
`plugin-manager/changed`. The config gains `pnpmCommand` and
`inspectTimeoutMs`. App boot emits `profile/reconciled` after each applied
patch generation and finds a root Include mounted under its pinned id by
another copy of the module.

Row diagnostics, dependency checks, and the non-bundle dependency group
are not carried over; the web e2e scaffold mounts a launcher-style
managed profile instead of the removed profile runtime.
2026-09-15 16:36:31 +08:00

75 lines
3.6 KiB
TypeScript

/**
* Reading an install spec before pnpm sees it: which of pnpm's spec forms it
* takes, and for a registry name whether it is one the registry can accept.
* @module @deepseek-ai/dsh-plugin-manager/install-spec
*/
import { isAbsolute } from 'node:path'
/** One spec read into its form and the parts an inspection needs. */
export type ParsedInstallSpec =
| { readonly kind: 'registry'; readonly spec: string; readonly name: string; readonly range?: string }
| { readonly kind: 'path'; readonly spec: string; readonly path: string }
| { readonly kind: 'tarball'; readonly spec: string; readonly path?: string }
| { readonly kind: 'git'; readonly spec: string }
/** The forms pnpm resolves through a git host: a host shorthand, a git URL, or a hosted repository URL. */
const GIT_SHORTHAND = /^(?:github|gitlab|bitbucket|gist):/i
const GIT_URL = /^git(?:\+[a-z]+)?:\/\/|^git@[^:]+:/i
const HOSTED_REPOSITORY_URL = /^https?:\/\/[^/]+\/[^/]+\/[^/#]+(?:\.git)?(?:#.*)?$/i
/** A tarball, on disk or over HTTP. */
const TARBALL_SPEC = /\.(?:tgz|tar\.gz)(?:#.*)?$/i
/** An npm package name: lowercase URL-safe segments, an optional scope, no leading dot or underscore. */
const PACKAGE_NAME = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/
const PACKAGE_NAME_MAX_LENGTH = 214
/** A spec neither pnpm nor the registry would take; `reason` is what the person reads. */
export class InvalidInstallSpecError extends Error {
/**
* @param spec - the spec as typed, trimmed.
* @param reason - why it is refused, as one sentence.
*/
constructor(readonly spec: string, readonly reason: string) {
super(`plugin-manager: ${reason}: ${spec}`)
this.name = 'InvalidInstallSpecError'
}
}
function invalid(spec: string, reason: string): InvalidInstallSpecError {
return new InvalidInstallSpecError(spec, reason)
}
/**
* Read a spec into its form. A path must be absolute: the Host's working
* directory means nothing to the person typing into a browser, and a
* relative path resolved against the profile would point inside it.
* @param raw - the spec as typed.
* @returns the parsed spec.
* @throws {InvalidInstallSpecError} for an empty spec, a relative path, a name the
* registry would refuse, or a URL that is neither a git host nor a tarball.
*/
export function parseInstallSpec(raw: string): ParsedInstallSpec {
const spec = raw.trim()
if (spec === '') throw invalid(spec, 'the package spec must not be empty')
const path = spec.replace(/^(?:file|link):/, '')
if (path !== spec || isAbsolute(path)) {
if (!isAbsolute(path)) throw invalid(spec, 'a local path must be absolute')
return TARBALL_SPEC.test(path) ? { kind: 'tarball', spec, path } : { kind: 'path', spec, path }
}
if (/^\.{1,2}(?:[\\/]|$)/.test(spec)) throw invalid(spec, 'a local path must be absolute')
const git = GIT_SHORTHAND.test(spec) || GIT_URL.test(spec) || HOSTED_REPOSITORY_URL.test(spec)
if (git && !TARBALL_SPEC.test(spec)) return { kind: 'git', spec }
if (/^https?:\/\//i.test(spec)) {
if (TARBALL_SPEC.test(spec)) return { kind: 'tarball', spec }
throw invalid(spec, 'a URL must point at a git repository or a tarball')
}
const at = spec.indexOf('@', 1)
const name = at === -1 ? spec : spec.slice(0, at)
const range = at === -1 ? undefined : spec.slice(at + 1)
if (name.length > PACKAGE_NAME_MAX_LENGTH || !PACKAGE_NAME.test(name)) {
throw invalid(spec, 'not a package name the registry accepts')
}
if (range === '') throw invalid(spec, 'a version after @ must not be empty')
return range === undefined ? { kind: 'registry', spec, name } : { kind: 'registry', spec, name, range }
}