@noob-stupid/dsh-plugin-console 0.3.16 → 0.3.19
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/README.md +1 -1
- package/README.zh.md +1 -1
- package/lib/client.js +35 -4
- package/lib/index.js +203 -22
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -166,7 +166,7 @@ Requires: DSH ≥ 0.1.0-rc.6 (web profile, with `dsh-client-modules` / `dsh-host
|
|
|
166
166
|
- **Real-time progress**: a `DSH-Upgrade` console window pops up showing live pnpm download
|
|
167
167
|
progress; the in-panel progress card shows the waiting time;
|
|
168
168
|
- **Upgrade protection**: failed installs **auto-rollback** (robocopy, backup verified before
|
|
169
|
-
upgrade), version check catches fake success,
|
|
169
|
+
upgrade), version check catches fake success, 15-min hard timeout, stall detection
|
|
170
170
|
(no debug-log updates → auto switch registry), global trap fallback, and 15-min stale-state
|
|
171
171
|
cleanup — the framework is never left broken;
|
|
172
172
|
- **pnpm channel**: npm-cli.js freezes at startup in the schtasks task environment (0-byte
|
package/README.zh.md
CHANGED
|
@@ -152,7 +152,7 @@ git clone https://github.com/Noob-stupid/dsh-plugin-hub /tmp/dsh-plugin-console
|
|
|
152
152
|
- **实时进度**:升级时弹出 `DSH-Upgrade` 窗口,实时显示 pnpm 下载进度;面板内进度卡片
|
|
153
153
|
同步显示等待时长;
|
|
154
154
|
- **升级保护**:失败**自动回滚**(robocopy,升级前校验回滚点有效)、版本校验防假成功、
|
|
155
|
-
|
|
155
|
+
15 分钟硬超时、卡死检测(debug 日志无更新自动切换 registry)、全局异常兜底、
|
|
156
156
|
15 分钟残留状态清理——框架绝不处于损坏状态;
|
|
157
157
|
- **pnpm 通道**:npm-cli.js 在 schtasks 任务环境启动即卡死(debug 日志 0 字节、网络请求
|
|
158
158
|
都发不出);升级改用 `corepack pnpm`(实测 0.4s 秒启动、11.5s 装完 rc.8)+ **国内源
|
package/lib/client.js
CHANGED
|
@@ -136,6 +136,7 @@ window.__ModuleLoader__.load({
|
|
|
136
136
|
loadingDetails: "正在读取详情…",
|
|
137
137
|
githubLoggedIn: "已登录 GitHub:",
|
|
138
138
|
githubCornerOut: "未登录 GitHub",
|
|
139
|
+
hubUpdateAvailable: "下载更新",
|
|
139
140
|
searchInstalledPlaceholder: "搜索已安装插件(名称或 id)",
|
|
140
141
|
extraFilter: "已装",
|
|
141
142
|
extraFilterAll: "全部",
|
|
@@ -370,6 +371,7 @@ window.__ModuleLoader__.load({
|
|
|
370
371
|
readmeLabel: "Readme summary",
|
|
371
372
|
loadingDetails: "Reading details…",
|
|
372
373
|
githubLoggedIn: "GitHub signed in: ",
|
|
374
|
+
hubUpdateAvailable: "Download update",
|
|
373
375
|
githubCornerOut: "Not signed in to GitHub",
|
|
374
376
|
searchInstalledPlaceholder: "Search installed plugins (name or id)",
|
|
375
377
|
extraFilter: "Installed",
|
|
@@ -763,7 +765,7 @@ window.__ModuleLoader__.load({
|
|
|
763
765
|
/** monorepo 子包列表:git trees 递归列出 packages 目录下的 package.json,逐个取 npm 包名(上限 24 个)。 */
|
|
764
766
|
async function fetchSubpackages(repo, branch) {
|
|
765
767
|
const data = await githubFetch(`https://api.github.com/repos/${encodeURIComponent(repo)}/git/trees/${encodeURIComponent(branch)}?recursive=1`);
|
|
766
|
-
const paths = (data.tree ?? []).filter((node) => node.type === "blob" && /^packages\/[^/]+\/package\.json$/u.test(node.path)).map((node) => node.path);
|
|
768
|
+
const paths = (data.tree ?? []).filter((node) => node.type === "blob" && /^(?:packages|examples|plugins|skills|apps|extensions|src|lib)\/[^/]+\/package\.json$/u.test(node.path)).map((node) => node.path);
|
|
767
769
|
if (paths.length === 0) return [];
|
|
768
770
|
const out = [];
|
|
769
771
|
for (const path of paths.slice(0, 24)) {
|
|
@@ -771,7 +773,7 @@ window.__ModuleLoader__.load({
|
|
|
771
773
|
if (text === null) continue;
|
|
772
774
|
try {
|
|
773
775
|
const pkg = JSON.parse(text);
|
|
774
|
-
if (pkg && typeof pkg.name === "string") out.push({ dir: path.split("/")[1], name: pkg.name });
|
|
776
|
+
if (pkg && typeof pkg.name === "string") out.push({ dir: path.split("/")[1], path: path.split("/").slice(0, -1).join("/"), name: pkg.name });
|
|
775
777
|
} catch {}
|
|
776
778
|
}
|
|
777
779
|
return out;
|
|
@@ -858,6 +860,8 @@ window.__ModuleLoader__.load({
|
|
|
858
860
|
const [frameworkUpgrading, setFrameworkUpgrading] = react.useState(false);
|
|
859
861
|
// 框架升级进度(重连后恢复显示):{status, message}
|
|
860
862
|
const [frameworkStatus, setFrameworkStatus] = react.useState(null);
|
|
863
|
+
// Hub 自身更新:仅在检测到远程新版时显示下载/更新按钮
|
|
864
|
+
const [selfUpdate, setSelfUpdate] = react.useState(null);
|
|
861
865
|
// 进度条步骤(与 host 脚本 SetState 的阶段对应)
|
|
862
866
|
const FW_STEPS = [
|
|
863
867
|
{ key: "starting", label: t("fwStepBackup") },
|
|
@@ -1066,6 +1070,22 @@ window.__ModuleLoader__.load({
|
|
|
1066
1070
|
};
|
|
1067
1071
|
run();
|
|
1068
1072
|
}, [market, state]);
|
|
1073
|
+
// Hub 自身更新检测:只在远程 npm 有更新时显示按钮
|
|
1074
|
+
react.useEffect(() => {
|
|
1075
|
+
if (state.status !== "ready") return;
|
|
1076
|
+
const current = state.data.selfVersion ?? null;
|
|
1077
|
+
if (!current) return;
|
|
1078
|
+
call("/plugin-console/check-update", { packageName: "@noob-stupid/dsh-plugin-console" }).then(
|
|
1079
|
+
(data) => {
|
|
1080
|
+
if (data && data.error === null && typeof data.latest === "string" && data.latest !== current) {
|
|
1081
|
+
setSelfUpdate({ latest: data.latest });
|
|
1082
|
+
} else {
|
|
1083
|
+
setSelfUpdate(null);
|
|
1084
|
+
}
|
|
1085
|
+
},
|
|
1086
|
+
() => setSelfUpdate(null),
|
|
1087
|
+
);
|
|
1088
|
+
}, [state]);
|
|
1069
1089
|
// 搜索源菜单:点击外部收起
|
|
1070
1090
|
react.useEffect(() => {
|
|
1071
1091
|
if (!sourceMenuOpen) return;
|
|
@@ -1629,6 +1649,11 @@ window.__ModuleLoader__.load({
|
|
|
1629
1649
|
startSuiteJob(item);
|
|
1630
1650
|
return;
|
|
1631
1651
|
}
|
|
1652
|
+
if (item.subpackagePath && typeof item.packageName === "string" && item.packageName !== "") {
|
|
1653
|
+
// monorepo 子包:搜索结果带 packageName,按 npm 包名安装
|
|
1654
|
+
startJob(item.fullName, item.packageName, item.source ?? "github");
|
|
1655
|
+
return;
|
|
1656
|
+
}
|
|
1632
1657
|
startJob(item.fullName, null, item.source ?? "github");
|
|
1633
1658
|
};
|
|
1634
1659
|
/** 停用/启用已安装技能(写入官方调用策略 frontmatter,可逆)。 */
|
|
@@ -2073,8 +2098,8 @@ window.__ModuleLoader__.load({
|
|
|
2073
2098
|
onClick: () => doToggleSkill(detailSkillName, detailSkillDisabled),
|
|
2074
2099
|
}, togglingSkill === detailSkillName ? t("installingLocal") : (detailSkillDisabled ? t("skillEnable") : t("skillDisable")))
|
|
2075
2100
|
: null,
|
|
2076
|
-
info.privateRoot ? el("div", null,
|
|
2077
|
-
el("p", { className: styles.status, "data-error": "true" }, t("privateRootHint")),
|
|
2101
|
+
(info.privateRoot || !info.hasPackageJson) ? el("div", null,
|
|
2102
|
+
info.privateRoot ? el("p", { className: styles.status, "data-error": "true" }, t("privateRootHint")) : null,
|
|
2078
2103
|
el("p", { className: styles.status }, t("subpackagesTitle") + ":"),
|
|
2079
2104
|
subpackages === null ? null
|
|
2080
2105
|
: subpackages.status === "loading"
|
|
@@ -2159,6 +2184,12 @@ window.__ModuleLoader__.load({
|
|
|
2159
2184
|
? t("marketTitle")
|
|
2160
2185
|
: t("marketTitleOther").replace("{source}", sourceDisplayName(searchSource)))),
|
|
2161
2186
|
el("div", { id: "pc-ghwrap", className: styles.ghwrap },
|
|
2187
|
+
selfUpdate !== null ? el("button", {
|
|
2188
|
+
type: "button",
|
|
2189
|
+
className: styles.ghpill,
|
|
2190
|
+
title: t("hubUpdateAvailable") + " v" + selfUpdate.latest,
|
|
2191
|
+
onClick: () => window.open(`https://github.com/Noob-stupid/dsh-plugin-hub/releases/tag/v${selfUpdate.latest}`, "_blank")
|
|
2192
|
+
}, "⬇ " + t("hubUpdateAvailable") + " v" + selfUpdate.latest) : null,
|
|
2162
2193
|
el("button", {
|
|
2163
2194
|
type: "button",
|
|
2164
2195
|
className: styles.ghpill,
|
package/lib/index.js
CHANGED
|
@@ -236,7 +236,6 @@ function postJsonUrl(url, formBody) {
|
|
|
236
236
|
'user-agent': GITHUB_UA,
|
|
237
237
|
accept: 'application/json',
|
|
238
238
|
},
|
|
239
|
-
rejectUnauthorized: false,
|
|
240
239
|
timeout: 20000,
|
|
241
240
|
}, (res) => {
|
|
242
241
|
const chunks = []
|
|
@@ -377,6 +376,8 @@ function cleanupStalePackageDir(profileDir, packageName) {
|
|
|
377
376
|
|
|
378
377
|
/** 软件源配置文件(registry 列表,安装链按主→备依次尝试)。 */
|
|
379
378
|
const SOURCES_FILE = join(dshHome(), 'plugin-console-sources.json')
|
|
379
|
+
/** 敏感凭据单独落盘(避免与可分享配置混存):自定义搜索源 headers + Gitee secret/token。 */
|
|
380
|
+
const SOURCES_SECRETS_FILE = join(dshHome(), 'plugin-console-sources.secrets.json')
|
|
380
381
|
const DEFAULT_SOURCES = {
|
|
381
382
|
registries: [
|
|
382
383
|
{ id: 'npmmirror', name: 'npmmirror(国内镜像)', url: 'https://registry.npmmirror.com', primary: true },
|
|
@@ -425,6 +426,7 @@ function readSources() {
|
|
|
425
426
|
const defaults = JSON.parse(JSON.stringify(DEFAULT_SOURCES))
|
|
426
427
|
try {
|
|
427
428
|
const data = JSON.parse(readFileSync(SOURCES_FILE, 'utf8'))
|
|
429
|
+
const secrets = readSourceSecrets()
|
|
428
430
|
const registries = (Array.isArray(data.registries) ? data.registries : [])
|
|
429
431
|
.filter((r) => r && typeof r.url === 'string' && isAllowedSourceUrl(r.url))
|
|
430
432
|
.map((r) => ({
|
|
@@ -439,13 +441,18 @@ function readSources() {
|
|
|
439
441
|
return { id: String(s.id), name: String(s.name ?? s.id), type: 'builtin' }
|
|
440
442
|
}
|
|
441
443
|
if (s && typeof s.url === 'string' && s.url.includes('{q}')) {
|
|
442
|
-
|
|
443
|
-
|
|
444
|
+
let headers = {}
|
|
445
|
+
const secretHeaders = secrets.headers?.[s.id]
|
|
446
|
+
if (secretHeaders && typeof secretHeaders === 'object') {
|
|
447
|
+
headers = { ...secretHeaders }
|
|
448
|
+
} else if (Array.isArray(s.headers)) {
|
|
444
449
|
for (const h of s.headers) {
|
|
445
450
|
if (h && typeof h.name === 'string' && h.name !== '' && typeof h.value === 'string') {
|
|
446
451
|
headers[h.name] = h.value
|
|
447
452
|
}
|
|
448
453
|
}
|
|
454
|
+
} else if (s.headers && typeof s.headers === 'object') {
|
|
455
|
+
headers = { ...s.headers }
|
|
449
456
|
}
|
|
450
457
|
return {
|
|
451
458
|
id: String(s.id ?? `search-${Math.random().toString(36).slice(2, 8)}`).slice(0, 40),
|
|
@@ -458,16 +465,58 @@ function readSources() {
|
|
|
458
465
|
return null
|
|
459
466
|
})
|
|
460
467
|
.filter((s) => s !== null)
|
|
468
|
+
const giteeBase = readGiteeConfig(data)
|
|
469
|
+
const gitee = {
|
|
470
|
+
...giteeBase,
|
|
471
|
+
clientSecret: secrets.gitee?.clientSecret ?? giteeBase.clientSecret,
|
|
472
|
+
token: secrets.gitee?.token ?? giteeBase.token,
|
|
473
|
+
}
|
|
461
474
|
if (registries.length > 0) {
|
|
462
475
|
if (!registries.some((r) => r.primary)) registries[0].primary = true
|
|
463
|
-
return { registries, searchSources, gitee
|
|
476
|
+
return { registries, searchSources, gitee }
|
|
464
477
|
}
|
|
465
478
|
} catch {}
|
|
466
479
|
return defaults
|
|
467
480
|
}
|
|
468
481
|
|
|
482
|
+
function readSourceSecrets() {
|
|
483
|
+
try {
|
|
484
|
+
const data = JSON.parse(readFileSync(SOURCES_SECRETS_FILE, 'utf8'))
|
|
485
|
+
return data && typeof data === 'object' ? data : {}
|
|
486
|
+
} catch {
|
|
487
|
+
return {}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async function writeSourceSecrets(secrets) {
|
|
492
|
+
const gitee = secrets.gitee ?? {}
|
|
493
|
+
const headers = secrets.headers ?? {}
|
|
494
|
+
const hasGitee = typeof gitee.clientSecret === 'string' && gitee.clientSecret !== '' || typeof gitee.token === 'string' && gitee.token !== ''
|
|
495
|
+
const hasHeaders = Object.keys(headers).some((id) => { const h = headers[id]; return h && typeof h === 'object' && Object.keys(h).length > 0 })
|
|
496
|
+
if (!hasGitee && !hasHeaders) {
|
|
497
|
+
try { rmSync(SOURCES_SECRETS_FILE, { force: true }) } catch {}
|
|
498
|
+
return
|
|
499
|
+
}
|
|
500
|
+
await writeFile(SOURCES_SECRETS_FILE, JSON.stringify({ gitee, headers }, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 })
|
|
501
|
+
}
|
|
469
502
|
async function writeSources(sources) {
|
|
470
|
-
|
|
503
|
+
const secrets = { gitee: {}, headers: {} }
|
|
504
|
+
const cleanSearch = (sources.searchSources ?? []).map((s) => {
|
|
505
|
+
if (!s) return s
|
|
506
|
+
if (s.headers && typeof s.headers === 'object' && Object.keys(s.headers).length > 0) {
|
|
507
|
+
secrets.headers[s.id] = { ...s.headers }
|
|
508
|
+
const { headers, ...rest } = s
|
|
509
|
+
return rest
|
|
510
|
+
}
|
|
511
|
+
return s
|
|
512
|
+
})
|
|
513
|
+
const gitee = readGiteeConfig(sources)
|
|
514
|
+
if (typeof gitee.clientSecret === 'string' && gitee.clientSecret !== '') secrets.gitee.clientSecret = gitee.clientSecret
|
|
515
|
+
if (typeof gitee.token === 'string' && gitee.token !== '') secrets.gitee.token = gitee.token
|
|
516
|
+
const cleanGitee = { ...(sources.gitee ?? {}), clientSecret: undefined, token: undefined }
|
|
517
|
+
const main = { ...sources, searchSources: cleanSearch, gitee: cleanGitee }
|
|
518
|
+
await writeFile(SOURCES_FILE, JSON.stringify(main, null, 2) + '\n', 'utf8')
|
|
519
|
+
await writeSourceSecrets(secrets)
|
|
471
520
|
}
|
|
472
521
|
|
|
473
522
|
/**
|
|
@@ -900,6 +949,25 @@ function isLoopback(address) {
|
|
|
900
949
|
return address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1'
|
|
901
950
|
}
|
|
902
951
|
|
|
952
|
+
/** 写请求跨站防护:浏览器同源策略不阻止跨站发送,必须校验 Origin / Sec-Fetch-Site。
|
|
953
|
+
* 非浏览器客户端(无 Origin/Sec-Fetch-Site 头)仍允许,避免 curl/脚本/测试被误伤。 */
|
|
954
|
+
function isAllowedWriteOrigin(req, port) {
|
|
955
|
+
const origin = typeof req.headers?.origin === 'string' ? req.headers.origin : null
|
|
956
|
+
const site = typeof req.headers?.['sec-fetch-site'] === 'string' ? req.headers['sec-fetch-site'] : null
|
|
957
|
+
if (origin !== null && origin !== '' && !ALLOWED_LOCAL_ORIGINS(port).has(origin)) return false
|
|
958
|
+
if (site !== null && site !== 'same-origin' && site !== 'none') return false
|
|
959
|
+
return true
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function ALLOWED_LOCAL_ORIGINS(port) {
|
|
963
|
+
return new Set([
|
|
964
|
+
`http://127.0.0.1:${port}`,
|
|
965
|
+
`http://localhost:${port}`,
|
|
966
|
+
`http://[::1]:${port}`,
|
|
967
|
+
])
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
|
|
903
971
|
function sendJson(res, status, body) {
|
|
904
972
|
const payload = JSON.stringify(body)
|
|
905
973
|
res.writeHead(status, {
|
|
@@ -939,7 +1007,6 @@ function githubRequestOnce(url, { signal, accept, token, timeout = 20000 } = {})
|
|
|
939
1007
|
accept: accept ?? 'application/vnd.github+json',
|
|
940
1008
|
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
941
1009
|
},
|
|
942
|
-
rejectUnauthorized: false,
|
|
943
1010
|
}, resolve)
|
|
944
1011
|
req.on('error', reject)
|
|
945
1012
|
req.setTimeout(timeout, () => req.destroy(new Error('GitHub 请求超时')))
|
|
@@ -1124,7 +1191,7 @@ async function githubText(url, signal, token = null) {
|
|
|
1124
1191
|
/** 用 curl 子进程请求 JSON(绕过本机中间设备对 node:https TLS 指纹的拦截;curl 走系统网络栈)。 */
|
|
1125
1192
|
async function curlJson(url, timeout = 15000, headers = {}) {
|
|
1126
1193
|
const bin = process.platform === 'win32' ? 'curl.exe' : 'curl'
|
|
1127
|
-
const args = ['-s', '-m', String(Math.max(5, Math.ceil(timeout / 1000))), '
|
|
1194
|
+
const args = ['-s', '-m', String(Math.max(5, Math.ceil(timeout / 1000))), '-H', 'accept: application/json', '-w', '\n__HTTP__%{http_code}']
|
|
1128
1195
|
for (const [key, value] of Object.entries(headers)) args.push('-H', `${key}: ${value}`)
|
|
1129
1196
|
args.push(url)
|
|
1130
1197
|
const { stdout } = await execFileAsync(bin, args, { timeout: timeout + 3000, windowsHide: true, maxBuffer: 8 * 1024 * 1024 })
|
|
@@ -1140,7 +1207,7 @@ async function curlText(url, timeout = 10000) {
|
|
|
1140
1207
|
const bin = process.platform === 'win32' ? 'curl.exe' : 'curl'
|
|
1141
1208
|
const { stdout } = await execFileAsync(
|
|
1142
1209
|
bin,
|
|
1143
|
-
['-s', '-m', String(Math.max(3, Math.ceil(timeout / 1000))), '
|
|
1210
|
+
['-s', '-m', String(Math.max(3, Math.ceil(timeout / 1000))), '-H', 'accept: text/plain', '-w', '\n__HTTP__%{http_code}', url],
|
|
1144
1211
|
{ timeout: timeout + 3000, windowsHide: true, maxBuffer: 8 * 1024 * 1024 },
|
|
1145
1212
|
)
|
|
1146
1213
|
const m = String(stdout).match(/\n__HTTP__(\d+)\s*$/u)
|
|
@@ -1383,7 +1450,7 @@ async function fetchRepoPackage(repo, branch) {
|
|
|
1383
1450
|
* profile 目录由 pnpm 管理;若用 npm 写入会与 pnpm 的目录重建互相破坏
|
|
1384
1451
|
* (曾导致入口链接丢失、DSH 启动崩溃)。registry 走国内镜像。
|
|
1385
1452
|
*/
|
|
1386
|
-
async function pnpmInstall(profileDir, spec, registry = 'https://registry.npmmirror.com', timeout = 90000) {
|
|
1453
|
+
async function pnpmInstall(profileDir, spec, registry = 'https://registry.npmmirror.com', timeout = 90000, signal = null) {
|
|
1387
1454
|
const args = ['pnpm', 'add', spec, '--registry', registry]
|
|
1388
1455
|
const opts = {
|
|
1389
1456
|
cwd: profileDir,
|
|
@@ -1419,7 +1486,7 @@ async function pnpmInstall(profileDir, spec, registry = 'https://registry.npmmir
|
|
|
1419
1486
|
* 零依赖包可完整安装;带依赖包记录未补齐列表(不阻塞,供面板提示)。
|
|
1420
1487
|
* 返回 { version, missingDeps };失败抛错由调用方落入 next 通道。
|
|
1421
1488
|
*/
|
|
1422
|
-
async function curlManualInstall(profileDir, packageName, registries) {
|
|
1489
|
+
async function curlManualInstall(profileDir, packageName, registries, signal = null) {
|
|
1423
1490
|
let meta = null
|
|
1424
1491
|
let metaError = null
|
|
1425
1492
|
for (const reg of registries) {
|
|
@@ -1442,8 +1509,8 @@ async function curlManualInstall(profileDir, packageName, registries) {
|
|
|
1442
1509
|
mkdirSync(tmp, { recursive: true })
|
|
1443
1510
|
try {
|
|
1444
1511
|
const tgz = join(tmp, 'pkg.tgz')
|
|
1445
|
-
await execFileAsync(bin, ['-s', '-L', '-m', '60', '-o', tgz, tarball], { timeout: 70000, windowsHide: true })
|
|
1446
|
-
await execFileAsync('tar', ['-xzf', tgz, '-C', tmp], { timeout: 30000, windowsHide: true })
|
|
1512
|
+
await execFileAsync(bin, ['-s', '-L', '-m', '60', '-o', tgz, tarball], { timeout: 70000, windowsHide: true, ...(signal ? { signal } : {}) })
|
|
1513
|
+
await execFileAsync('tar', ['-xzf', tgz, '-C', tmp], { timeout: 30000, windowsHide: true, ...(signal ? { signal } : {}) })
|
|
1447
1514
|
const pkgPath = join(tmp, 'package')
|
|
1448
1515
|
// 盒子实验:解压后先验证,通过才覆盖正式位置(失败保留旧版本,服务不中断)
|
|
1449
1516
|
const box = verifyPackageBox(pkgPath, profileDir, packageName)
|
|
@@ -1465,6 +1532,34 @@ async function curlManualInstall(profileDir, packageName, registries) {
|
|
|
1465
1532
|
}
|
|
1466
1533
|
}
|
|
1467
1534
|
|
|
1535
|
+
/** 安装通道并行竞速:pnpm 与 curl 同时尝试,先成功者生效;失败方被 abort 不干扰。 */
|
|
1536
|
+
async function raceInstallChannels(profileDir, name, registries) {
|
|
1537
|
+
const controller = new AbortController()
|
|
1538
|
+
const signal = controller.signal
|
|
1539
|
+
const pnpmTask = (async () => {
|
|
1540
|
+
let lastError = null
|
|
1541
|
+
for (const registry of registries) {
|
|
1542
|
+
try {
|
|
1543
|
+
await pnpmInstall(profileDir, name, registry, 90000, signal)
|
|
1544
|
+
return { channel: 'pnpm', info: null }
|
|
1545
|
+
} catch (error) {
|
|
1546
|
+
lastError = error
|
|
1547
|
+
if (signal.aborted) throw error
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
throw lastError ?? new Error('pnpm 通道失败')
|
|
1551
|
+
})()
|
|
1552
|
+
const curlTask = (async () => {
|
|
1553
|
+
const info = await curlManualInstall(profileDir, name, registries, signal)
|
|
1554
|
+
return { channel: 'curl', info }
|
|
1555
|
+
})()
|
|
1556
|
+
const waitSuccess = (promise) => promise.then((value) => ({ value }), () => new Promise(() => {}))
|
|
1557
|
+
const timeout = new Promise((resolve) => setTimeout(() => resolve(null), 120000))
|
|
1558
|
+
const winner = await Promise.race([waitSuccess(pnpmTask), waitSuccess(curlTask), timeout])
|
|
1559
|
+
controller.abort()
|
|
1560
|
+
return winner ? winner.value : null
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1468
1563
|
/**
|
|
1469
1564
|
* 盒子实验验证:解压后的包目录先通过静态校验再允许覆盖正式位置。
|
|
1470
1565
|
* - package.json 必须可解析且 name 与安装目标一致
|
|
@@ -2141,7 +2236,7 @@ async function fetchSubpackageNames(repo, branch, auth) {
|
|
|
2141
2236
|
try {
|
|
2142
2237
|
const data = await githubJson(`${GITHUB_API}/repos/${repo}/git/trees/${encodeURIComponent(branch)}?recursive=1`, undefined, auth)
|
|
2143
2238
|
const paths = (data.tree ?? [])
|
|
2144
|
-
.filter((node) => node.type === 'blob' && /^packages\/[^/]+\/package\.json$/u.test(node.path))
|
|
2239
|
+
.filter((node) => node.type === 'blob' && /^(?:packages|examples|plugins|skills|apps|extensions|src|lib)\/[^/]+\/package\.json$/u.test(node.path))
|
|
2145
2240
|
.map((node) => node.path)
|
|
2146
2241
|
// 并行读取:黑洞期单条最坏 40s,24 条串行会拖到十几分钟
|
|
2147
2242
|
const results = await Promise.all(paths.slice(0, 24).map(async (path) => {
|
|
@@ -2149,7 +2244,7 @@ async function fetchSubpackageNames(repo, branch, auth) {
|
|
|
2149
2244
|
if (bodyText === null) return null
|
|
2150
2245
|
try {
|
|
2151
2246
|
const pkg = JSON.parse(bodyText)
|
|
2152
|
-
if (pkg && typeof pkg.name === 'string') return { dir: path.split('/')[1], name: pkg.name }
|
|
2247
|
+
if (pkg && typeof pkg.name === 'string') return { dir: path.split('/')[1], path: path.split('/').slice(0, -1).join('/'), name: pkg.name }
|
|
2153
2248
|
} catch {}
|
|
2154
2249
|
return null
|
|
2155
2250
|
}))
|
|
@@ -2364,6 +2459,32 @@ async function runInstallJob(job, ctx) {
|
|
|
2364
2459
|
for (let index = 0; index < candidates.length && installedName === null; index += 1) {
|
|
2365
2460
|
if (Date.now() > deadline) break
|
|
2366
2461
|
const name = candidates[index]
|
|
2462
|
+
// 加法优化:包已在 node_modules 且名字匹配时,不再重复安装/触发 EPERM,直接进入启用流程
|
|
2463
|
+
if (installedName === null) {
|
|
2464
|
+
const existingTarget = join(profileDir, 'node_modules', name, 'package.json')
|
|
2465
|
+
if (existsSync(existingTarget)) {
|
|
2466
|
+
try {
|
|
2467
|
+
const existingPkg = JSON.parse(readFileSync(existingTarget, 'utf8'))
|
|
2468
|
+
if (existingPkg && existingPkg.name === name) {
|
|
2469
|
+
installedName = name
|
|
2470
|
+
job.curlNote = `已检测到本地已安装 ${name}@${existingPkg.version ?? '?'},跳过重复下载`
|
|
2471
|
+
}
|
|
2472
|
+
} catch {}
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
// 加法并行竞速:pnpm 与 curl 同时启动,先成功者生效;失败方 abort,不影响后续串行通道
|
|
2476
|
+
if (installedName === null && !subpackageMode && !expanded) {
|
|
2477
|
+
const raced = await raceInstallChannels(profileDir, name, registries)
|
|
2478
|
+
if (raced) {
|
|
2479
|
+
installedName = name
|
|
2480
|
+
if (raced.channel === 'curl') {
|
|
2481
|
+
const racedInfo = raced.info
|
|
2482
|
+
const stillMissing = await backfillMissingDeps(profileDir, racedInfo.missingDeps, registries)
|
|
2483
|
+
job.curlNote = `已通过并行 curl 通道安装 v${racedInfo.version}${stillMissing.length > 0 ? `(依赖仍未补齐:${stillMissing.join('、')},网络恢复后建议重新安装)` : '(捆绑依赖已补齐)'}`
|
|
2484
|
+
if (racedInfo.boxNote) job.curlNote += `(盒子验证:${racedInfo.boxNote})`
|
|
2485
|
+
}
|
|
2486
|
+
}
|
|
2487
|
+
}
|
|
2367
2488
|
// 通道 1..n:配置的软件源依次尝试(每源 90 秒封顶)
|
|
2368
2489
|
for (let ri = 0; ri < registries.length && installedName === null; ri += 1) {
|
|
2369
2490
|
try {
|
|
@@ -2559,6 +2680,18 @@ export function apply(ctx) {
|
|
|
2559
2680
|
sendError(res, 403, '仅允许本机访问')
|
|
2560
2681
|
return
|
|
2561
2682
|
}
|
|
2683
|
+
const port = webPort(ctx)
|
|
2684
|
+
const host = typeof req.headers?.host === 'string' ? req.headers.host : ''
|
|
2685
|
+
if (![`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`].includes(host)) {
|
|
2686
|
+
sendError(res, 403, 'Host 校验失败(仅允许本机访问)')
|
|
2687
|
+
return
|
|
2688
|
+
}
|
|
2689
|
+
// 安全护栏(issue #9):写路由必须是本机同源请求,防止恶意网页跨站驱动敏感操作。
|
|
2690
|
+
const method = req.method ?? 'GET'
|
|
2691
|
+
if (method !== 'GET' && method !== 'HEAD' && !isAllowedWriteOrigin(req, port)) {
|
|
2692
|
+
sendError(res, 403, '跨站请求被拒绝(Origin/Sec-Fetch-Site 校验)')
|
|
2693
|
+
return
|
|
2694
|
+
}
|
|
2562
2695
|
try {
|
|
2563
2696
|
await handle(ctx, req, res)
|
|
2564
2697
|
} catch (error) {
|
|
@@ -2598,7 +2731,13 @@ async function handle(ctx, req, res) {
|
|
|
2598
2731
|
// 框架升级检测与适配(备份快照 + 重打框架补丁),try 包裹不阻塞 state 返回
|
|
2599
2732
|
let framework = null
|
|
2600
2733
|
try { framework = detectFrameworkUpgrade(ctx) } catch {}
|
|
2601
|
-
|
|
2734
|
+
let selfVersion = null
|
|
2735
|
+
try {
|
|
2736
|
+
const selfRequire = createRequire(import.meta.url)
|
|
2737
|
+
const selfPkg = JSON.parse(readFileSync(selfRequire.resolve('../package.json'), 'utf8'))
|
|
2738
|
+
selfVersion = typeof selfPkg.version === 'string' ? selfPkg.version : null
|
|
2739
|
+
} catch {}
|
|
2740
|
+
sendJson(res, 200, { ok: true, entries, patchPath, compat, installJobs: jobs, recentFailures, github: { loggedIn: auth.loggedIn, login: auth.login }, patch: { disables: patch.disables, forced: patch.forced, inserts: patch.inserts }, framework, selfVersion })
|
|
2602
2741
|
return
|
|
2603
2742
|
}
|
|
2604
2743
|
|
|
@@ -2956,6 +3095,48 @@ async function handle(ctx, req, res) {
|
|
|
2956
3095
|
)
|
|
2957
3096
|
items = normalizePlatformItems(data.items ?? [], 'main').map((item) => ({ ...item, source: 'github' }))
|
|
2958
3097
|
items = await enrichItems(items)
|
|
3098
|
+
// monorepo 子包增强(OpenViking/examples/dsh-memory-plugin 等可按子包名搜到)
|
|
3099
|
+
if (raw !== '' && !body.skills) {
|
|
3100
|
+
try {
|
|
3101
|
+
const codeData = await githubJson(
|
|
3102
|
+
`${GITHUB_API}/search/code?q=${encodeURIComponent(`${raw} filename:package.json`)}`,
|
|
3103
|
+
req.signal,
|
|
3104
|
+
auth.token,
|
|
3105
|
+
)
|
|
3106
|
+
const subItems = []
|
|
3107
|
+
for (const hit of (codeData.items ?? []).slice(0, 10)) {
|
|
3108
|
+
const hitPath = typeof hit.path === 'string' ? hit.path : ''
|
|
3109
|
+
if (!/^(?:packages|examples|plugins|skills|apps|extensions|src|lib)\/[^/]+\/package\.json$/u.test(hitPath)) continue
|
|
3110
|
+
const repoName = hit.repository?.full_name ?? ''
|
|
3111
|
+
if (!repoName) continue
|
|
3112
|
+
const dir = hitPath.split('/').slice(0, -1).join('/')
|
|
3113
|
+
let packageName = dir.split('/').slice(-1)[0]
|
|
3114
|
+
try {
|
|
3115
|
+
const pkgText = await rawTextWithFallback(repoName, 'main', hitPath)
|
|
3116
|
+
if (pkgText !== null) {
|
|
3117
|
+
const pkg = JSON.parse(pkgText)
|
|
3118
|
+
if (pkg && typeof pkg.name === 'string') packageName = pkg.name
|
|
3119
|
+
}
|
|
3120
|
+
} catch {}
|
|
3121
|
+
subItems.push({
|
|
3122
|
+
fullName: repoName,
|
|
3123
|
+
description: `子包:${dir}`,
|
|
3124
|
+
htmlUrl: `https://github.com/${repoName}/tree/main/${dir}`,
|
|
3125
|
+
stars: 0,
|
|
3126
|
+
updatedAt: '',
|
|
3127
|
+
defaultBranch: 'main',
|
|
3128
|
+
topics: [],
|
|
3129
|
+
source: 'github',
|
|
3130
|
+
subpackagePath: dir,
|
|
3131
|
+
packageName,
|
|
3132
|
+
})
|
|
3133
|
+
if (subItems.length >= 5) break
|
|
3134
|
+
}
|
|
3135
|
+
for (const sub of subItems) {
|
|
3136
|
+
if (!items.some((x) => x.fullName === sub.fullName)) items.push(sub)
|
|
3137
|
+
}
|
|
3138
|
+
} catch {}
|
|
3139
|
+
}
|
|
2959
3140
|
}
|
|
2960
3141
|
items = items.filter((item) => item.fullName !== '')
|
|
2961
3142
|
sendJson(res, 200, { ok: true, query, items, authenticated: auth.loggedIn, source })
|
|
@@ -3445,18 +3626,18 @@ async function handle(ctx, req, res) {
|
|
|
3445
3626
|
`Set-Location -Path ${ps(dirname(dshDir))}`,
|
|
3446
3627
|
// npm 安装函数:Start-Process 跑 npm-cli.js(唯一正确入口——lib/cli.js 是函数模块,
|
|
3447
3628
|
// 直跑只加载不执行,假成功 exit 0)+ --force(显式指定版本但已在 package.json 的
|
|
3448
|
-
// semver 范围内时 npm 会跳过安装,假成功)+
|
|
3629
|
+
// semver 范围内时 npm 会跳过安装,假成功)+ 15 分钟总时长硬超时。注意:**不能加
|
|
3449
3630
|
// -RedirectStandardOutput/-RedirectStandardError**——schtasks 任务环境下带重定向的
|
|
3450
3631
|
// Start-Process 子进程会启动即异常退出(ExitCode 读不到、输出 0 字节,实测);
|
|
3451
3632
|
// 无重定向时 ExitCode 正常(实测 exit=0)。npm 自身 debug 日志(cache\_logs)留档。
|
|
3452
|
-
// 超时策略:总时长
|
|
3453
|
-
// 快速失败换 registry,不用干等
|
|
3633
|
+
// 超时策略:总时长 15 分钟硬上限 + npm debug 日志 5 分钟无更新判定卡死(网络黑洞/挂起时
|
|
3634
|
+
// 快速失败换 registry,不用干等 15 分钟);等待期间每 15 秒更新进度状态(客户端可见)。
|
|
3454
3635
|
// 成功后校验 package.json 版本 === target(防 npm 假成功)。
|
|
3455
3636
|
// 另注意:npm install 在 schtasks 环境启动慢(约 1-2 分钟 0 日志,初始化/编译缓存),
|
|
3456
3637
|
// 属正常,5 分钟卡死阈值不会误杀。
|
|
3457
3638
|
`function Install-Framework($reg) {`,
|
|
3458
3639
|
` $startAt = Get-Date`,
|
|
3459
|
-
` $deadline = $startAt.AddMinutes(
|
|
3640
|
+
` $deadline = $startAt.AddMinutes(15)`,
|
|
3460
3641
|
` $stallDeadline = $startAt.AddMinutes(5)`,
|
|
3461
3642
|
` $lastLogM = (Get-Date)`,
|
|
3462
3643
|
` $logSeen = $false`,
|
|
@@ -3493,7 +3674,7 @@ async function handle(ctx, req, res) {
|
|
|
3493
3674
|
` }`,
|
|
3494
3675
|
` }`,
|
|
3495
3676
|
` if (-not $done) {`,
|
|
3496
|
-
` Log 'pnpm 超过
|
|
3677
|
+
` Log 'pnpm 超过 15 分钟未完成,判定超时'`,
|
|
3497
3678
|
` try { taskkill /F /FI "WINDOWTITLE eq DSH-Upgrade" 2>$null | Out-Null } catch {}`,
|
|
3498
3679
|
` return 1`,
|
|
3499
3680
|
` }`,
|
|
@@ -3609,10 +3790,10 @@ async function handle(ctx, req, res) {
|
|
|
3609
3790
|
` $tgz = Join-Path $tmpDir 'pkg.tgz'`,
|
|
3610
3791
|
` $encName = $name.Replace('/','%2f')`,
|
|
3611
3792
|
` $url = 'https://registry.npmmirror.com/' + $encName + '/-/' + $short + '-' + $refVersion + '.tgz'`,
|
|
3612
|
-
` curl.exe -s -L -m 60
|
|
3793
|
+
` curl.exe -s -L -m 60 -o $tgz $url`,
|
|
3613
3794
|
` if (-not (Test-Path $tgz) -or (Get-Item $tgz).Length -lt 1000) {`,
|
|
3614
3795
|
` $url = 'https://registry.npmjs.org/' + $encName + '/-/' + $short + '-' + $refVersion + '.tgz'`,
|
|
3615
|
-
` curl.exe -s -L -m 60
|
|
3796
|
+
` curl.exe -s -L -m 60 -o $tgz $url`,
|
|
3616
3797
|
` }`,
|
|
3617
3798
|
` if (-not (Test-Path $tgz) -or (Get-Item $tgz).Length -lt 1000) { Remove-Item $tmpDir -Recurse -Force -ErrorAction SilentlyContinue; continue }`,
|
|
3618
3799
|
` tar -xzf $tgz -C $tmpDir`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@noob-stupid/dsh-plugin-console",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.19",
|
|
4
4
|
"description": "DSH plugin management panel & marketplace: one-click enable/disable, multi-source market (GitHub/Gitee/custom), static-index market (500+ plugins / 300 skills), skills, suites, and one-click framework upgrade.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|