diff --git a/docs/subsystems/boot.i18n.yaml b/docs/subsystems/boot.i18n.yaml index 3b36b08a50..4d5b515bea 100644 --- a/docs/subsystems/boot.i18n.yaml +++ b/docs/subsystems/boot.i18n.yaml @@ -6,5 +6,5 @@ en: 962b4398c9052959 zh: 14471c340a9f6e60 /profile-management/management-records: - en: 6295412615ed2d56 - zh: f02b40d4ad5fc7e4 + en: 6929d1001684c024 + zh: 06d3d3f6eea848ec diff --git a/docs/subsystems/boot.md b/docs/subsystems/boot.md index dc118bc5c7..142ea14b59 100644 --- a/docs/subsystems/boot.md +++ b/docs/subsystems/boot.md @@ -10,7 +10,7 @@ The [boot package group](../../packages/boot/README.md) owns launcher-provided p `PluginInfo` carries module identity, effective enablement, fiber phase and optional display `meta`, plus a unique `patchId` or a `readOnlyReason`. -`BundleInfo` carries the package name, optional installed version, selected enablement, removal availability, optional resolution error, and, for a bundle the profile's own dependency supplies, `source`: that dependency as a spec `pnpm add` accepts. Its optional `meta` and each `BundleRowInfo.meta` contain display text or a metadata diagnostic; Clients select a language at render time. +`BundleInfo` carries the package name, optional installed version, selected enablement, removal availability, optional resolution error, and, for a bundle the profile's own dependency supplies and the installation does not, `source`: that dependency as a spec `pnpm add` accepts. Its optional `meta` and each `BundleRowInfo.meta` contain display text or a metadata diagnostic; Clients select a language at render time. `InstallBundleOptions.enabled` defaults to true. False installs without selecting the bundle layer. `approvedBuilds` grants persistent script permission to the supplied pending package names before installation. `registry` names the registry asked first; absent, the configured one. diff --git a/docs/subsystems/boot.zh.md b/docs/subsystems/boot.zh.md index 22d8fe2378..4495ccbd73 100644 --- a/docs/subsystems/boot.zh.md +++ b/docs/subsystems/boot.zh.md @@ -10,7 +10,7 @@ `PluginInfo` 包含模块标识、实际启停状态、fiber 阶段和可选的展示 `meta`,以及唯一的 `patchId` 或 `readOnlyReason`。 -`BundleInfo` 包含包名、可选的安装版本、组合层选择状态、删除可用性、可选的解析错误;由 profile 自身依赖提供的组合包还带有 `source`,即该依赖在 `pnpm add` 中可用的 spec。它的可选 `meta` 与各行的 `BundleRowInfo.meta` 包含展示文本或元信息诊断;Client 在渲染时选择语言。 +`BundleInfo` 包含包名、可选的安装版本、组合层选择状态、删除可用性、可选的解析错误;由 profile 自身依赖提供、且安装不提供的组合包还带有 `source`,即该依赖在 `pnpm add` 中可用的 spec。它的可选 `meta` 与各行的 `BundleRowInfo.meta` 包含展示文本或元信息诊断;Client 在渲染时选择语言。 `InstallBundleOptions.enabled` 默认为 true,false 表示安装但不选择组合包层。`approvedBuilds` 在安装前向指定的待审批包名授予持久脚本权限。`registry` 指定首先询问的注册表;缺省为配置的那个。 diff --git a/packages/boot/plugin-manager/README.i18n.yaml b/packages/boot/plugin-manager/README.i18n.yaml index bea031538a..7b503ceb6a 100644 --- a/packages/boot/plugin-manager/README.i18n.yaml +++ b/packages/boot/plugin-manager/README.i18n.yaml @@ -15,8 +15,8 @@ en: 87679c51aa3693f5 zh: 11684a48a5806afb /deepseek-ai-dsh-plugin-manager/use-this-package: - en: 22d07e11dba5e843 - zh: b7d548b6a3f70b98 + en: d0f7d42dffe6d3c7 + zh: b4bf81958cda876d /deepseek-ai-dsh-plugin-manager/use-this-package/version-compatibility-and-exemptions: en: 920c0acb3b9feaf3 zh: ba6e832a256880a7 diff --git a/packages/boot/plugin-manager/README.md b/packages/boot/plugin-manager/README.md index a9f4a6b2a9..eabc124c92 100644 --- a/packages/boot/plugin-manager/README.md +++ b/packages/boot/plugin-manager/README.md @@ -41,7 +41,7 @@ A plugin toggle updates only `disabled` in the last matching override in the pro A selected bundle that cannot load remains in `listBundles` with an `error`; `enabled` records the saved selection, not successful loading. The plugin page shows the error and allows deselection. A broken bundle cannot be enabled. Management bundles remain protected if their files become unreadable. -`listBundles` includes optional display `meta` for each bundle and its declared plugin rows, including disabled bundles. The Client selects a language from these values. The separate `description` field is the bundle's raw `package.json.description`; a metadata diagnostic does not block management. `source` gives a profile dependency as a spec `pnpm add` accepts: a git address or URL as recorded without the credentials of an http(s) URL, a `file:` or `link:` path made absolute against the profile or the home directory for `~`, and a registry range, tag, or alias after the package name; a bundle the installation supplies has none, even when the profile also depends on it, because the installation's copy is the one that loads. The `plugin_manager` tool omits UI display metadata from list results. +`listBundles` includes optional display `meta` for each bundle and its declared plugin rows, including disabled bundles. The Client selects a language from these values. The separate `description` field is the bundle's raw `package.json.description`; a metadata diagnostic does not block management. `source` gives a profile dependency as a spec `pnpm add` accepts: a git address or URL as recorded without the user information of an http(s) URL (a query string stays as recorded), a `file:` or `link:` path made absolute against the profile or the home directory for `~`, and a registry range, tag, or alias after the package name, as is any spec of a package installed under another name; a bundle the installation supplies has none, even when the profile also depends on it, because the installation's copy is the one that loads. The `plugin_manager` tool omits UI display metadata from list results. `inspect(spec, options)` reads what a spec names before anything installs: a registry name is asked of the registry through `pnpm view`, run in the profile directory so the same proxy and authentication settings apply as to the install; an absolute path has its `package.json` read; a git address or tarball answers only its form and the `host` it is fetched from. The answer carries the name, version, description, whether the package declares a bundle, and the `registry` that answered, or a `problem`: `invalid-spec`, `already-installed`, `not-found`, `not-a-package`, `not-a-bundle`, `network`, or `unknown`, with the `registries` asked. A caller's `signal` or `inspectTimeoutMs` ends the lookup. diff --git a/packages/boot/plugin-manager/README.zh.md b/packages/boot/plugin-manager/README.zh.md index 912982ed6d..c715052d8f 100644 --- a/packages/boot/plugin-manager/README.zh.md +++ b/packages/boot/plugin-manager/README.zh.md @@ -41,7 +41,7 @@ kind: "package-reference" 已选择但无法加载的组合包仍会出现在 `listBundles` 中,并携带 `error`;`enabled` 表示保存的选择,不代表加载成功。插件页面显示错误并允许取消选择。损坏的组合包无法启用。管理组合包的文件变得不可读后仍受保护。 -`listBundles` 为各组合包及其声明的插件行提供可选的展示 `meta`,包括已禁用的组合包。Client 从这些值中选择语言。单独的 `description` 字段是该组合包原始的 `package.json.description`;元信息诊断不会阻止管理操作。`source` 以 `pnpm add` 接受的 spec 给出 profile 依赖:git 地址或 URL 按记录给出,但去掉 http(s) URL 中的凭据;`file:` 或 `link:` 路径按 profile 目录解析为绝对路径,`~` 展开为主目录;注册表版本范围、标签或别名接在包名的 `@` 之后。安装提供的组合包没有该字段,即使 profile 也依赖它,因为实际加载的是安装中的副本。`plugin_manager` 工具的列表结果不包含 UI 展示元信息。 +`listBundles` 为各组合包及其声明的插件行提供可选的展示 `meta`,包括已禁用的组合包。Client 从这些值中选择语言。单独的 `description` 字段是该组合包原始的 `package.json.description`;元信息诊断不会阻止管理操作。`source` 以 `pnpm add` 接受的 spec 给出 profile 依赖:git 地址或 URL 按记录给出,但去掉 http(s) URL 的 user information(查询字符串保持原样);`file:` 或 `link:` 路径按 profile 目录解析为绝对路径,`~` 展开为主目录;注册表版本范围、标签或别名接在包名的 `@` 之后,以别名安装的包的 spec 也是如此。安装提供的组合包没有该字段,即使 profile 也依赖它,因为实际加载的是安装中的副本。`plugin_manager` 工具的列表结果不包含 UI 展示元信息。 `inspect(spec, options)` 在任何东西安装之前读出 spec 指向什么:注册表包名通过 `pnpm view` 询问注册表,在 profile 目录中运行,因而与安装使用同样的代理与认证设置;绝对路径读取其 `package.json`;git 地址或 tarball 只答复自己的形式和它被拉取的 `host`。答复携带名称、版本、描述、该包是否声明组合包,以及作答的 `registry`,否则给出 `problem`:`invalid-spec`、`already-installed`、`not-found`、`not-a-package`、`not-a-bundle`、`network` 或 `unknown`,并附上问过的 `registries`。调用方的 `signal` 或 `inspectTimeoutMs` 会结束查询。 diff --git a/packages/boot/plugin-manager/src/index.ts b/packages/boot/plugin-manager/src/index.ts index b63273b956..10ca06c160 100644 --- a/packages/boot/plugin-manager/src/index.ts +++ b/packages/boot/plugin-manager/src/index.ts @@ -290,14 +290,17 @@ export class PluginManager extends TypertRemoteService { const installed = dependencies.includes(name) const optional = OPTIONAL_BUNDLES.includes(name) const removable = installed && !Object.hasOwn(installation.dependencies ?? {}, name) - // Bundle resolution reads the installation first, so a profile dependency it also supplies is not the loaded copy. - const source = removable ? { source: dependencySpec(name, recorded[name] as string, this.profile.dir) } : {} + // Bundle resolution reads the installation first, so a profile dependency the installation manifest also names, + // like one it forbids removing, is not the loaded copy. + const sourceOf = (packageName?: string): { source?: string } => + removable ? { source: dependencySpec(name, recorded[name] as string, this.profile.dir, packageName) } : {} const enabled = selected.includes(name) const readOnlyReason = this.protectsManager(name) ? 'management-required' as const : undefined try { const info = bundleManifest(name, this.profile.dir, this.profile.installAnchor) if (info === undefined) { - if (enabled) bundles.push({ name, ...source, enabled, installed, optional, removable: removable && readOnlyReason === undefined, + if (enabled) bundles.push({ name, ...sourceOf(), enabled, installed, optional, + removable: removable && readOnlyReason === undefined, ...(readOnlyReason === undefined ? {} : { readOnlyReason }), error: { code: 'not-bundle' }, rows: [], overrides: [] }) continue } @@ -308,12 +311,12 @@ export class PluginManager extends TypertRemoteService { bundles.push({ name, ...(info.version === undefined ? {} : { version: info.version }), ...(info.description === undefined || info.description === '' ? {} : { description: info.description }), ...meta === undefined ? {} : { meta }, - ...source, enabled, installed, optional, removable: removable && readOnlyReason === undefined, + ...sourceOf(info.name), enabled, installed, optional, removable: removable && readOnlyReason === undefined, ...(readOnlyReason === undefined ? {} : { readOnlyReason }), ...this.declaredRows(name, info) }) } catch (error) { if (enabled || installed) { - bundles.push({ name, ...source, enabled, installed, optional, removable: removable && readOnlyReason === undefined, + bundles.push({ name, ...sourceOf(), enabled, installed, optional, removable: removable && readOnlyReason === undefined, ...(readOnlyReason === undefined ? {} : { readOnlyReason }), error: managementError(error), rows: [], overrides: [] }) } } diff --git a/packages/boot/plugin-manager/src/install-spec.ts b/packages/boot/plugin-manager/src/install-spec.ts index 59b9ce55f7..4b76f1925e 100644 --- a/packages/boot/plugin-manager/src/install-spec.ts +++ b/packages/boot/plugin-manager/src/install-spec.ts @@ -92,25 +92,32 @@ export function parseInstallSpec(raw: string): ParsedInstallSpec { } /** A recorded dependency value pnpm resolves outside a registry: a protocol other than a registry alias, or an scp-like git address. */ -const NON_REGISTRY_VALUE = /^(?!(?:npm|jsr|workspace|catalog):)[a-z][a-z0-9+.-]*:|^git@/i -/** The user information of an http(s) URL, which holds a password or access token when present. */ -const HTTP_CREDENTIALS = /^((?:git\+)?https?:\/\/)[^/@]*@/i +const NON_REGISTRY_VALUE = /^(?!(?:npm|jsr|workspace|catalog):)[a-z][a-z0-9+.-]*:|^[^@/:\s]+@[^:/\s]+:/i +/** The user information of an http(s) URL, up to the last `@` before the path, which holds a password or access token when present. */ +const HTTP_USER_INFO = /^((?:git\+)?https?:\/\/)[^/]*@/i /** * The spec `pnpm add` accepts for a dependency the profile manifest records. A git address, URL, or other - * non-registry protocol is the recorded value without the credentials of an http(s) URL; a `file:` or `link:` - * path, which pnpm records relative to the profile or with `~` for the home directory, becomes absolute; a - * registry range, tag, or alias follows the dependency name after `@`. + * non-registry protocol is the recorded value without the user information of an http(s) URL, whose query string + * is kept; a `file:` or `link:` path, which pnpm records relative to the profile or with `~` for the home + * directory, becomes absolute. A registry range, tag, or alias follows the dependency name after `@`, as does a + * non-registry value installed under a name other than the package's own. * @param name - the dependency name. * @param recorded - the value the profile manifest records for it. * @param profileDir - the profile directory a relative local path is resolved against. + * @param packageName - the name the installed package's manifest declares, when it could be read. * @returns the installable spec. */ -export function dependencySpec(name: string, recorded: string, profileDir: string): string { +export function dependencySpec(name: string, recorded: string, profileDir: string, packageName = name): string { const local = /^(file|link):(.*)$/s.exec(recorded) + let spec: string if (local !== null) { const path = (local[2] as string).replace(/^~(?=$|[\\/])/, () => homedir()) - return `${local[1]}:${resolve(profileDir, path)}` + spec = `${local[1]}:${resolve(profileDir, path)}` + } else if (NON_REGISTRY_VALUE.test(recorded)) { + spec = recorded.replace(HTTP_USER_INFO, '$1') + } else { + return `${name}@${recorded}` } - return NON_REGISTRY_VALUE.test(recorded) ? recorded.replace(HTTP_CREDENTIALS, '$1') : `${name}@${recorded}` + return packageName === name ? spec : `${name}@${spec}` } diff --git a/packages/boot/plugin-manager/src/types.ts b/packages/boot/plugin-manager/src/types.ts index de74865beb..dbc20d9598 100644 --- a/packages/boot/plugin-manager/src/types.ts +++ b/packages/boot/plugin-manager/src/types.ts @@ -57,7 +57,7 @@ export interface BundleInfo { installed: boolean /** * Present for a profile dependency the installation does not also supply: the spec `pnpm add` accepts, with local - * paths made absolute and http(s) credentials removed. + * paths made absolute and the user information of an http(s) URL removed. */ source?: string /** diff --git a/packages/boot/plugin-manager/tests/manager.spec.ts b/packages/boot/plugin-manager/tests/manager.spec.ts index 164ce038a6..fa8b926bae 100644 --- a/packages/boot/plugin-manager/tests/manager.spec.ts +++ b/packages/boot/plugin-manager/tests/manager.spec.ts @@ -241,7 +241,7 @@ it.each(['missing package', 'invalid manifest', 'not a bundle', 'missing patch', }) expect([...ctx.loader.entries()].some(entry => entry.id === 'include:managed')).toBe(false) expect((await manager.listBundles()).find(row => row.name === 'extra')).toMatchObject({ - enabled: true, error: { code: failure === 'not a bundle' ? 'not-bundle' : 'operation-error' }, rows: [], + enabled: true, source: 'extra@1.0.0', error: { code: failure === 'not a bundle' ? 'not-bundle' : 'operation-error' }, rows: [], }) expect(await manager.setBundleEnabled('extra', false)).toMatchObject({ changed: true, application: 'applied' }) expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toEqual(['core']) @@ -340,13 +340,16 @@ it('names where each installed bundle comes from as a spec pnpm installs', async const { manager, dir, bundle, profile } = await fixture() const recorded: Record = { extra: '^1.0.0', tagged: 'latest', aliased: 'npm:@acme/aliased@2', jsr: 'jsr:@acme/jsr@^1', github: 'github:someone/dsh-plugin#v1', - ssh: 'git@github.com:someone/dsh-plugin.git', sshUrl: 'git+ssh://git@github.com/someone/dsh-plugin.git', + ssh: 'git@github.com:someone/dsh-plugin.git', deploy: 'deploy@git.corp:team/dsh-plugin.git', + sshUrl: 'git+ssh://git@github.com/someone/dsh-plugin.git', email: 'git+http://user@example.com:secret@git.example.com/repo.git', tarball: 'https://cdn.example.com/dsh-x-1.0.0.tgz', token: 'https://ghp_secret@cdn.example.com/dsh-x-1.0.0.tgz', password: 'git+https://someone:secret@git.example.com/someone/dsh-plugin.git#main', relative: 'file:../plugins/relative', linked: 'link:/plugins/linked', home: 'file:~/plugins/home', - shadowed: '1.0.0', + aliasLocal: 'file:../plugins/original', shadowed: '1.0.0', } for (const name of Object.keys(recorded)) bundle(name, []) + // Installed under a name other than its own, the package keeps that name only when the spec carries it. + writeFileSync(join(dir, 'node_modules', 'aliasLocal', 'package.json'), JSON.stringify({ name: 'original', version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } } })) const manifest = readProfileManifest('test', dir) manifest.dependencies = recorded writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest)) @@ -354,11 +357,12 @@ it('names where each installed bundle comes from as a spec pnpm installs', async writeFileSync(profile.installAnchor, JSON.stringify({ name: 'installation', dependencies: { shadowed: '1.0.0' } })) expect(Object.fromEntries((await manager.listBundles()).map(row => [row.name, row.source]))).toEqual({ core: undefined, extra: 'extra@^1.0.0', tagged: 'tagged@latest', aliased: 'aliased@npm:@acme/aliased@2', jsr: 'jsr@jsr:@acme/jsr@^1', - github: 'github:someone/dsh-plugin#v1', ssh: 'git@github.com:someone/dsh-plugin.git', sshUrl: 'git+ssh://git@github.com/someone/dsh-plugin.git', + github: 'github:someone/dsh-plugin#v1', ssh: 'git@github.com:someone/dsh-plugin.git', deploy: 'deploy@git.corp:team/dsh-plugin.git', + sshUrl: 'git+ssh://git@github.com/someone/dsh-plugin.git', email: 'git+http://git.example.com/repo.git', tarball: 'https://cdn.example.com/dsh-x-1.0.0.tgz', token: 'https://cdn.example.com/dsh-x-1.0.0.tgz', password: 'git+https://git.example.com/someone/dsh-plugin.git#main', relative: `file:${resolve(dir, '../plugins/relative')}`, linked: `link:${resolve(dir, '/plugins/linked')}`, - home: `file:${resolve(homedir(), 'plugins/home')}`, shadowed: undefined, + home: `file:${resolve(homedir(), 'plugins/home')}`, aliasLocal: `aliasLocal@file:${resolve(dir, '../plugins/original')}`, shadowed: undefined, }) }) diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 9fe9d099b5..b5ed263122 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -271,11 +271,12 @@ function RowsSection({ rows, t, resolveText, toggle, configure }: { /** * Where a bundle comes from: the spec that installs it elsewhere, or built in - * for an official one the installation ships, and its version. A selected - * bundle that neither the profile nor the installation holds has no section. + * for one whose loaded copy the installation supplies, and its version. A + * selected bundle that neither the profile nor the installation holds has no + * section. */ function SourceSection({ pkg, t }: { readonly pkg: PackageView; readonly t: Translate }): ReactNode { - if (pkg.source === undefined && !pkg.optional) return null + if (pkg.source === undefined && !pkg.installed && !pkg.optional) return null return (

{t('sourceTitle')}

diff --git a/packages/client/ui-plugin-manager/src/client/manager-store.ts b/packages/client/ui-plugin-manager/src/client/manager-store.ts index c87690b767..0841ef440a 100644 --- a/packages/client/ui-plugin-manager/src/client/manager-store.ts +++ b/packages/client/ui-plugin-manager/src/client/manager-store.ts @@ -85,7 +85,7 @@ export interface PackageView { readonly meta?: PluginLocalizedMeta /** Whether the profile's own dependencies hold the package; false for a bundle the installation supplies. */ readonly installed: boolean - /** Present when `installed`: the profile dependency as a spec `pnpm add` accepts. */ + /** Present for a profile dependency the installation does not also supply: the spec `pnpm add` accepts. */ readonly source?: string /** Whether the installation ships the bundle for the person to switch on: official, off until selected, never removable. */ readonly optional: boolean diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index 2b14b5afc4..06089013e6 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -205,6 +205,7 @@ describe('PluginManagerPage', () => { const b = renderTab({ packages: [ pkg({ source: 'github:someone/dsh-better-sidebar' }), { name: 'dsh-official', installed: false, optional: true, enabled: false, rows: [] }, + { name: 'dsh-shadowed', installed: true, optional: false, enabled: true, rows: [] }, { name: 'dsh-missing', installed: false, optional: false, enabled: true, error: { code: 'unknown-plugin' }, rows: [] }, ] }) const facts = (): string[] => [...document.querySelectorAll('[data-plugin-source] dt, [data-plugin-source] dd')].map(node => node.textContent) @@ -213,6 +214,9 @@ describe('PluginManagerPage', () => { expect(facts()).toEqual([en.sourceSpec, 'github:someone/dsh-better-sidebar', en.sourceVersion, '0.16.0']) act(() => { b.navigation.actions.setView({ kind: 'package', name: 'dsh-official' }) }) expect(facts()).toEqual([en.sourceSpec, en.sourceBuiltIn]) + // A profile dependency the installation also supplies loads the installation's copy. + act(() => { b.navigation.actions.setView({ kind: 'package', name: 'dsh-shadowed' }) }) + expect(facts()).toEqual([en.sourceSpec, en.sourceBuiltIn]) act(() => { b.navigation.actions.setView({ kind: 'package', name: 'dsh-missing' }) }) expect(document.querySelector('[data-plugin-source]')).toBeNull() })