Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
823 changes: 429 additions & 394 deletions client/client.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion client/client.js.map

Large diffs are not rendered by default.

23 changes: 13 additions & 10 deletions lib/http/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { dirname, join, resolve } from 'node:path';
import { spawn, spawnSync } from 'node:child_process';
import { fetchViaCurl, gitLsRemote, probeUrl, systemProxy } from '../services/probe.js';
import { activeTask, cancelTask, dumpLoaderEntries, getTask, githubRepoOf, githubTarget, globalNpmPackagesOf, hasQueuedTarget, installTargetOf, listPendingRestarts, readProfileArg, startPluginMutation, validPackageName } from '../services/install/install.js';
import { githubReleaseTarget } from '../services/install/release-target.js';
import { recordInstalledVersion, recordResolvedNpmPackage, readInstalledVersions, removeInstalledVersion } from '../services/profile/installed-versions.js';
import { resolveNpmPackage } from '../services/install/npm-resolve.js';
import { preflightTarget } from '../services/install/preflight.js';
Expand Down Expand Up @@ -801,12 +802,13 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
sendJson(response, 200, { ok: true, task: task.id });
return;
}
// 目标语法两态:GitHub 地址(owner/repo、github:、https/ssh 链接等任意写法)→ 显式
// 固定 GitHub release .tgz 保留其包根目录;GitHub 仓库地址 → 显式
// HTTPS Git 源(走装前预检);npm 包名(@scope/name 或 name)→ 信任 registry 直接安装。
// githubRepoOf 先把各种 GitHub 地址归一成 owner/repo,让「输入地址即装」兼容粘贴完整链接。
const gitRepo = githubRepoOf(rawRepo);
const repoTarget = gitRepo !== null ? githubTarget(gitRepo) : null;
let target = repoTarget ?? (validPackageName(rawRepo) ? rawRepo : null);
const release = githubReleaseTarget(rawRepo);
const repoTarget = gitRepo !== null && release === null ? githubTarget(gitRepo) : null;
let target = release?.target ?? repoTarget ?? (validPackageName(rawRepo) ? rawRepo : null);
if (target === null) {
sendJson(response, 400, { error: 'unsupported install target' });
return;
Expand Down Expand Up @@ -836,9 +838,9 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
}
// 命令行安装(目录外)按通道门禁:GitHub 源码通道受「启用 GitHub 源码安装」控制,
// npm 通道受「启用 NPM 安装」控制 —— 目录插件安装走目录白名单,不受开关影响。
// 通道判定看反查后的最终 target:仍是 git 目标 = 走 GitHub 源码,否则走 npm。
// release 包与 git 目标均受现有 GitHub 安装开关控制;npm 反查命中后走 npm。
if (source !== 'catalog') {
const isGitChannel = repoTarget !== null && target === repoTarget;
const isGitChannel = release !== null || (repoTarget !== null && target === repoTarget);
if (isGitChannel && !settings.enableGitInstall) {
sendJson(response, 403, { error: 'git installs are disabled by the security settings' });
return;
Expand All @@ -863,7 +865,7 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
sendJson(response, 409, { error: `already queued: ${target}` });
return;
}
// 安装前网络连通性预检:npm 通道探 registry、git 通道探 github.com 连通性。
// 安装前网络连通性预检:npm 探 registry,release 探 github.com,git 探克隆握手。
// 探测目标固定为通道的稳定入口(registry 根 / github.com 主页),不探具体包或仓库
// 页 —— 404 表示「目标不存在」而非网络不通,不该被当成网络故障拦截。
// 仅新安装预检(更新是已信任目标的覆盖重装,跳过);不通直接 400 拦下并打
Expand All @@ -874,8 +876,9 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
const effectiveProxy = settings.proxy !== ''
? settings.proxy
: (systemProxy() ?? process.env.HTTPS_PROXY ?? process.env.https_proxy ?? '');
const isGitChannel = repoTarget !== null && target === repoTarget;
const npmProbeTarget = `${(settings.npmRegistry.replace(/\/+$/, '') || 'https://registry.npmjs.org')}/`;
const isGitChannel = release !== null || (repoTarget !== null && target === repoTarget);
const httpProbeTarget = release !== null ? 'https://github.com/'
: `${(settings.npmRegistry.replace(/\/+$/, '') || 'https://registry.npmjs.org')}/`;
// git 通道的探针换成克隆握手本身:git ls-remote 走的就是 pnpm 克隆前的同一套
// https 传输。curl 打 github.com 主页会被按协议/端口区分的防火墙与 TLS 复检
// 拦截(能 git 克隆、打不开网页),把可装的仓库误判成 [network] 而中止
Expand All @@ -893,9 +896,9 @@ export function mountPluginHubRoutes(webServer, profile, loader) {
}
}
else {
net = await probeUrl(npmProbeTarget, effectiveProxy, 6000);
net = await probeUrl(httpProbeTarget, effectiveProxy, 6000);
}
const probeTarget = gitProbeTarget ?? npmProbeTarget;
const probeTarget = gitProbeTarget ?? httpProbeTarget;
if (!net.ok) {
// 错误消息按客户端界面语言提示:中文界面给中文、英文界面给英文,
// 让用户一眼看懂是网络问题而非插件问题
Expand Down
6 changes: 6 additions & 0 deletions lib/services/install/preflight.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,17 @@ import { pipeline } from 'node:stream/promises';
import { promisify } from 'node:util';
import { githubRepoOf } from '../profile/profile.js';
import { resolvePackageEntry } from './package-entry.js';
import { githubReleaseTarget } from './release-target.js';
const execFileAsync = promisify(execFile);
/** 单次网络操作超时(ms):git ls-remote / codeload 下载。网络不可达时不能无限挂起,
* 否则 `/install` 请求永远不返回、任务不入队、前端进度卡 0%。超时后放行(交给装后校验)。 */
const PREFLIGHT_TIMEOUT_MS = 15_000;
export async function preflightTarget(target) {
// Release packages have their own package root. Checking monorepo HEAD
// would reject a complete artifact using an unrelated source distribution.
// The real installer and existing verifyInstalledEntry validate the package.
if (githubReleaseTarget(target) !== null)
return { ok: true, missing: null, name: null };
const source = githubRepoOf(target);
if (source === null)
return { ok: true, missing: null, name: null };
Expand Down
13 changes: 13 additions & 0 deletions lib/services/install/release-target.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/** Safe prebuilt GitHub release targets for the existing catalog command contract. */
export function githubReleaseTarget(value) {
const input = value.trim();
// Catalog commands are display data, never shell code. Accept only one
// official DSH target, with no trailing flags or additional commands.
const command = /^dsh[ \t]+plugin[ \t]+--profile(?:[ \t]+|=)[A-Za-z0-9_-]+[ \t]+(?:add|update)[ \t]+(.+)$/i.exec(input);
const raw = command?.[1] ?? input;
const target = raw.startsWith('"') && raw.endsWith('"') ? raw.slice(1, -1) : raw;
const match = /^https:\/\/github\.com\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/releases\/download\/([A-Za-z0-9._+-]+)\/([A-Za-z0-9._+-]+\.tgz)$/.exec(target);
if (match === null || match.slice(1).some(part => part === '.' || part === '..'))
return null;
return { target, repo: `${match[1]}/${match[2]}` };
}
4 changes: 4 additions & 0 deletions lib/services/profile/profile.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { basename, dirname, join } from 'node:path';
import { PACKAGE_RE, REPO_RE } from '../install/install-types.js';
import { githubReleaseTarget } from '../install/release-target.js';
/** Resolve the active profile from the booted CLI args, falling back to `web`. */
export function readProfileArg(fallback = 'web') {
return profileFromArgv(process.argv, fallback);
Expand Down Expand Up @@ -90,6 +91,9 @@ export function githubRepoOf(value) {
if (typeof value !== 'string')
return null;
const input = value.trim();
const release = githubReleaseTarget(input);
if (release !== null)
return release.repo;
if (REPO_RE.test(input))
return input;
const patterns = [
Expand Down
5 changes: 5 additions & 0 deletions lib/types/services/install/release-target.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/** Safe prebuilt GitHub release targets for the existing catalog command contract. */
export declare function githubReleaseTarget(value: string): {
target: string;
repo: string;
} | null;
18 changes: 14 additions & 4 deletions src/client/logic/install-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,23 @@
* GitHub: https://github.com/dshplugin/dsh-plugin-hub
*
* Install target/command helpers: decide the install channel (npm package
* vs explicit-HTTPS GitHub), build the display command and normalize a raw
* vs prebuilt release vs explicit-HTTPS GitHub), build the display command and normalize a raw
* install spec back to its owner/repo identity.
*/
import type { HubPlugin } from '../types.ts'
import { githubReleaseTarget } from '../../server/services/install/release-target.ts'

/**
* 安装通道决策(用户无感知):目录探测到 npm 包名 → 用 npm 包名安装
* (走 npm registry tarball,更快、与 GitHub 网络无关);无 npm 包名 → git 直装。
* 返回值 target 即传给后端 /install 的安装目标(npm 包名 或 owner/repo)。
* (走 npm registry tarball,更快、与 GitHub 网络无关);其次使用同仓库的权威 release 包命令,最后 git 直装。
* 返回值 target 即传给后端 /install 的安装目标(npm 包名、release URL 或 owner/repo)。
*/
export function installTargetOf(p: HubPlugin): { target: string; via: 'npm' | 'github' } {
export function installTargetOf(p: HubPlugin): { target: string; via: 'npm' | 'github' | 'release' } {
const pkg = (p.source?.npmPackage ?? '').trim()
const repo = (p.source?.repo ?? '').trim()
if (pkg && repo) return { target: pkg, via: 'npm' }
const release = githubReleaseTarget(p.install?.githubCommand ?? '')
if (release?.repo.toLowerCase() === repo.toLowerCase()) return { target: release.target, via: 'release' }
return { target: repo, via: 'github' }
}

Expand All @@ -26,6 +29,11 @@ export function installTargetOf(p: HubPlugin): { target: string; via: 'npm' | 'g
* 常规插件无目录命令时按通道回退生成:npm 显示包名,git 显示显式 HTTPS URL。 */
export function installCommandOf(p: HubPlugin, withProfile = false): string {
const { target, via } = installTargetOf(p)
if (via === 'release') {
const command = p.install?.githubCommand?.trim()
return command && /^dsh[ \t]+plugin[ \t]/i.test(command)
? command : `dsh plugin --profile web add ${target}`
}
if (via === 'npm') {
const cmd = p.install?.command
if (cmd) return cmd
Expand All @@ -41,6 +49,8 @@ export function installCommandOf(p: HubPlugin, withProfile = false): string {
/** Normalize a task/install target to its owner/repo display identity. */
export function repoFromInstallTarget(value: string): string {
const input = value.trim()
const release = githubReleaseTarget(input)
if (release !== null) return release.repo
if (/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(input)) return input
const patterns = [
/^github:([^/]+)\/([^/]+)$/i,
Expand Down
23 changes: 13 additions & 10 deletions src/server/http/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { dirname, join, resolve } from 'node:path'
import { spawn, spawnSync } from 'node:child_process'
import { fetchViaCurl, gitLsRemote, probeUrl, systemProxy } from '../services/probe.ts'
import { activeTask, cancelTask, dumpLoaderEntries, getTask, githubRepoOf, githubTarget, globalNpmPackagesOf, hasQueuedTarget, installTargetOf, listPendingRestarts, readProfileArg, startPluginMutation, validPackageName, type LoaderHandle } from '../services/install/install.ts'
import { githubReleaseTarget } from '../services/install/release-target.ts'
import { recordInstalledVersion, recordResolvedNpmPackage, readInstalledVersions, removeInstalledVersion } from '../services/profile/installed-versions.ts'
import { resolveNpmPackage } from '../services/install/npm-resolve.ts'
import { preflightTarget } from '../services/install/preflight.ts'
Expand Down Expand Up @@ -791,12 +792,13 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
sendJson(response, 200, { ok: true, task: task.id })
return
}
// 目标语法两态:GitHub 地址(owner/repo、github:、https/ssh 链接等任意写法)→ 显式
// 固定 GitHub release .tgz 保留其包根目录;GitHub 仓库地址 → 显式
// HTTPS Git 源(走装前预检);npm 包名(@scope/name 或 name)→ 信任 registry 直接安装。
// githubRepoOf 先把各种 GitHub 地址归一成 owner/repo,让「输入地址即装」兼容粘贴完整链接。
const gitRepo = githubRepoOf(rawRepo)
const repoTarget = gitRepo !== null ? githubTarget(gitRepo) : null
let target: string | null = repoTarget ?? (validPackageName(rawRepo) ? rawRepo : null)
const release = githubReleaseTarget(rawRepo)
const repoTarget = gitRepo !== null && release === null ? githubTarget(gitRepo) : null
let target: string | null = release?.target ?? repoTarget ?? (validPackageName(rawRepo) ? rawRepo : null)
if (target === null) {
sendJson(response, 400, { error: 'unsupported install target' })
return
Expand Down Expand Up @@ -825,9 +827,9 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
}
// 命令行安装(目录外)按通道门禁:GitHub 源码通道受「启用 GitHub 源码安装」控制,
// npm 通道受「启用 NPM 安装」控制 —— 目录插件安装走目录白名单,不受开关影响。
// 通道判定看反查后的最终 target:仍是 git 目标 = 走 GitHub 源码,否则走 npm。
// release 包与 git 目标均受现有 GitHub 安装开关控制;npm 反查命中后走 npm。
if (source !== 'catalog') {
const isGitChannel = repoTarget !== null && target === repoTarget
const isGitChannel = release !== null || (repoTarget !== null && target === repoTarget)
if (isGitChannel && !settings.enableGitInstall) {
sendJson(response, 403, { error: 'git installs are disabled by the security settings' })
return
Expand All @@ -852,7 +854,7 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
sendJson(response, 409, { error: `already queued: ${target}` })
return
}
// 安装前网络连通性预检:npm 通道探 registry、git 通道探 github.com 连通性。
// 安装前网络连通性预检:npm 探 registry,release 探 github.com,git 探克隆握手。
// 探测目标固定为通道的稳定入口(registry 根 / github.com 主页),不探具体包或仓库
// 页 —— 404 表示「目标不存在」而非网络不通,不该被当成网络故障拦截。
// 仅新安装预检(更新是已信任目标的覆盖重装,跳过);不通直接 400 拦下并打
Expand All @@ -863,8 +865,9 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
const effectiveProxy = settings.proxy !== ''
? settings.proxy
: (systemProxy() ?? process.env.HTTPS_PROXY ?? process.env.https_proxy ?? '')
const isGitChannel = repoTarget !== null && target === repoTarget
const npmProbeTarget = `${(settings.npmRegistry.replace(/\/+$/, '') || 'https://registry.npmjs.org')}/`
const isGitChannel = release !== null || (repoTarget !== null && target === repoTarget)
const httpProbeTarget = release !== null ? 'https://github.com/'
: `${(settings.npmRegistry.replace(/\/+$/, '') || 'https://registry.npmjs.org')}/`
// git 通道的探针换成克隆握手本身:git ls-remote 走的就是 pnpm 克隆前的同一套
// https 传输。curl 打 github.com 主页会被按协议/端口区分的防火墙与 TLS 复检
// 拦截(能 git 克隆、打不开网页),把可装的仓库误判成 [network] 而中止
Expand All @@ -880,9 +883,9 @@ export function mountPluginHubRoutes(webServer: WebServerService, profile: strin
if (reachable.ok) net = reachable
}
} else {
net = await probeUrl(npmProbeTarget, effectiveProxy, 6000)
net = await probeUrl(httpProbeTarget, effectiveProxy, 6000)
}
const probeTarget = gitProbeTarget ?? npmProbeTarget
const probeTarget = gitProbeTarget ?? httpProbeTarget
if (!net.ok) {
// 错误消息按客户端界面语言提示:中文界面给中文、英文界面给英文,
// 让用户一眼看懂是网络问题而非插件问题
Expand Down
5 changes: 5 additions & 0 deletions src/server/services/install/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { pipeline } from 'node:stream/promises'
import { promisify } from 'node:util'
import { githubRepoOf } from '../profile/profile.ts'
import { resolvePackageEntry } from './package-entry.ts'
import { githubReleaseTarget } from './release-target.ts'

const execFileAsync = promisify(execFile)

Expand All @@ -37,6 +38,10 @@ export interface PreflightResult {
}

export async function preflightTarget(target: string): Promise<PreflightResult> {
// Release packages have their own package root. Checking monorepo HEAD
// would reject a complete artifact using an unrelated source distribution.
// The real installer and existing verifyInstalledEntry validate the package.
if (githubReleaseTarget(target) !== null) return { ok: true, missing: null, name: null }
const source = githubRepoOf(target)
if (source === null) return { ok: true, missing: null, name: null }
const [owner, repo] = source.split('/')
Expand Down
12 changes: 12 additions & 0 deletions src/server/services/install/release-target.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/** Safe prebuilt GitHub release targets for the existing catalog command contract. */
export function githubReleaseTarget(value: string): { target: string; repo: string } | null {
const input = value.trim()
// Catalog commands are display data, never shell code. Accept only one
// official DSH target, with no trailing flags or additional commands.
const command = /^dsh[ \t]+plugin[ \t]+--profile(?:[ \t]+|=)[A-Za-z0-9_-]+[ \t]+(?:add|update)[ \t]+(.+)$/i.exec(input)
const raw = command?.[1] ?? input
const target = raw.startsWith('"') && raw.endsWith('"') ? raw.slice(1, -1) : raw
const match = /^https:\/\/github\.com\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)\/releases\/download\/([A-Za-z0-9._+-]+)\/([A-Za-z0-9._+-]+\.tgz)$/.exec(target)
if (match === null || match.slice(1).some(part => part === '.' || part === '..')) return null
return { target, repo: `${match[1]}/${match[2]}` }
}
Loading