dsh-plugin 1.6.12 → 2.0.0
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.en.md +1 -1
- package/README.md +1 -1
- package/client/client.js +615 -741
- package/client/client.js.map +1 -1
- package/lib/http/catalog.js +2 -3
- package/lib/http/diagnostics.js +6 -7
- package/lib/http/install.js +47 -213
- package/lib/http/logs.js +1 -2
- package/lib/http/npm-release-age.js +42 -0
- package/lib/http/routes.js +8 -7
- package/lib/http/settings.js +2 -2
- package/lib/http/shared.js +2 -2
- package/lib/http/task.js +1 -2
- package/lib/index.js +5 -6
- package/lib/services/install/install-types.js +4 -4
- package/lib/services/install/install.js +1 -1
- package/lib/services/install/npm-check.js +3 -2
- package/lib/services/install/npm-resolve.js +9 -96
- package/lib/services/install/package-entry.js +6 -11
- package/lib/services/install/registry-check.js +2 -2
- package/lib/services/install/release-target.js +8 -9
- package/lib/services/install/task-queue.js +40 -48
- package/lib/services/npm-release-age.js +119 -0
- package/lib/services/probe.js +4 -4
- package/lib/services/profile/installed-versions.js +15 -21
- package/lib/services/profile/profile.js +16 -44
- package/lib/services/profile/progress.js +8 -11
- package/lib/services/settings.js +2 -5
- package/lib/types/http/catalog.d.ts +1 -1
- package/lib/types/http/install.d.ts +2 -2
- package/lib/types/http/npm-release-age.d.ts +2 -0
- package/lib/types/http/routes.d.ts +10 -12
- package/lib/types/http/shared.d.ts +5 -6
- package/lib/types/index.d.ts +7 -8
- package/lib/types/services/install/install-types.d.ts +12 -13
- package/lib/types/services/install/install.d.ts +1 -1
- package/lib/types/services/install/npm-resolve.d.ts +4 -16
- package/lib/types/services/install/package-entry.d.ts +6 -11
- package/lib/types/services/install/release-target.d.ts +7 -8
- package/lib/types/services/install/task-queue.d.ts +25 -30
- package/lib/types/services/npm-release-age.d.ts +15 -0
- package/lib/types/services/probe.d.ts +4 -4
- package/lib/types/services/profile/installed-versions.d.ts +5 -7
- package/lib/types/services/profile/profile.d.ts +14 -18
- package/lib/types/services/profile/progress.d.ts +7 -10
- package/lib/types/services/settings.d.ts +0 -4
- package/package.json +1 -1
- package/src/client/components/PluginHubSection.tsx +17 -22
- package/src/client/components/catalog/CatalogList.tsx +2 -4
- package/src/client/components/catalog/PluginCard.tsx +2 -3
- package/src/client/components/layout/CatalogControls.tsx +5 -6
- package/src/client/components/layout/CatalogHeader.tsx +1 -2
- package/src/client/components/layout/CategoryTabs.tsx +2 -3
- package/src/client/components/layout/SectionTabs.tsx +3 -5
- package/src/client/components/modals/ConfirmDialog.tsx +4 -6
- package/src/client/components/modals/InstalledDetailModal.tsx +3 -6
- package/src/client/components/modals/LogsModal.tsx +7 -12
- package/src/client/components/modals/NotificationsModal.tsx +35 -16
- package/src/client/components/modals/ProgressView.tsx +3 -4
- package/src/client/components/modals/modals.tsx +35 -15
- package/src/client/components/ui/Dropdown.tsx +4 -6
- package/src/client/components/ui/Toggle.tsx +2 -3
- package/src/client/components/ui/icons.tsx +1 -2
- package/src/client/components/views/CustomInstallView.tsx +20 -178
- package/src/client/components/views/DiagnosticsView.tsx +3 -6
- package/src/client/components/views/InstalledView.tsx +6 -11
- package/src/client/components/views/MarketView.tsx +2 -4
- package/src/client/components/views/SettingsView.tsx +9 -32
- package/src/client/css-modules.d.ts +1 -1
- package/src/client/data/catalog.ts +2 -3
- package/src/client/data/host.ts +26 -3
- package/src/client/data/hub.ts +2 -3
- package/src/client/hooks/useCatalog.ts +79 -24
- package/src/client/hooks/useIncrementalList.ts +3 -5
- package/src/client/hooks/useLanguage.ts +4 -6
- package/src/client/hooks/useSettings.ts +4 -11
- package/src/client/hooks/useTaskQueue.ts +36 -42
- package/src/client/index.tsx +2 -3
- package/src/client/locales.ts +75 -114
- package/src/client/logic/constants.ts +1 -2
- package/src/client/logic/ensure-plugin-css.ts +8 -12
- package/src/client/logic/failures.ts +16 -18
- package/src/client/logic/format.ts +1 -1
- package/src/client/logic/install-command.ts +14 -35
- package/src/client/logic/installed.ts +15 -10
- package/src/client/logic/normalize.ts +3 -3
- package/src/client/logic/release-age.ts +19 -0
- package/src/client/logic/urls.ts +20 -20
- package/src/client/styles/CustomInstallView.module.css +3 -30
- package/src/client/styles/DiagnosticsView.module.css +3 -5
- package/src/client/styles/Dropdown.module.css +2 -4
- package/src/client/styles/Header.module.css +6 -7
- package/src/client/styles/InstalledView.module.css +4 -7
- package/src/client/styles/List.module.css +4 -5
- package/src/client/styles/LogsView.module.css +2 -3
- package/src/client/styles/Modal.module.css +4 -5
- package/src/client/styles/SectionTabs.module.css +2 -4
- package/src/client/styles/SettingsView.module.css +3 -5
- package/src/client/styles/Toggle.module.css +2 -4
- package/src/client/styles/tokens.module.css +14 -18
- package/src/client/types.ts +11 -11
- package/src/server/http/catalog.ts +2 -3
- package/src/server/http/diagnostics.ts +6 -7
- package/src/server/http/install.ts +47 -208
- package/src/server/http/logs.ts +1 -2
- package/src/server/http/npm-release-age.ts +42 -0
- package/src/server/http/routes.ts +12 -12
- package/src/server/http/settings.ts +2 -2
- package/src/server/http/shared.ts +5 -6
- package/src/server/http/task.ts +1 -2
- package/src/server/index.ts +7 -8
- package/src/server/services/install/install-types.ts +12 -13
- package/src/server/services/install/install.ts +0 -1
- package/src/server/services/install/npm-check.ts +3 -2
- package/src/server/services/install/npm-resolve.ts +9 -99
- package/src/server/services/install/package-entry.ts +6 -11
- package/src/server/services/install/registry-check.ts +2 -2
- package/src/server/services/install/release-target.ts +8 -9
- package/src/server/services/install/task-queue.ts +47 -54
- package/src/server/services/npm-release-age.ts +107 -0
- package/src/server/services/probe.ts +4 -4
- package/src/server/services/profile/installed-versions.ts +16 -22
- package/src/server/services/profile/profile.ts +16 -42
- package/src/server/services/profile/progress.ts +8 -11
- package/src/server/services/settings.ts +2 -9
- package/lib/services/install/preflight.js +0 -178
- package/lib/types/services/install/preflight.d.ts +0 -23
- package/src/server/services/install/preflight.ts +0 -184
|
@@ -15,7 +15,7 @@ import type { InstallResult, InstallTask, Invocation, QueueItem } from './instal
|
|
|
15
15
|
import { CAPTURE_LIMIT_BYTES, MAX_TASKS, MAX_TASK_LINES } from './install-types.ts'
|
|
16
16
|
import { npmTooLowMarker } from './npm-check.ts'
|
|
17
17
|
import { registryMissingMarker } from './registry-check.ts'
|
|
18
|
-
import { backfillOperationLog } from './operation-log.ts'
|
|
18
|
+
import { backfillOperationLog, operationLogPathOf } from './operation-log.ts'
|
|
19
19
|
import { cleanLine, estimateProgress } from '../profile/progress.ts'
|
|
20
20
|
import type { LoaderHandle } from '../loader.ts'
|
|
21
21
|
import { hasClientUi, isDshPlugin, mountLoadedEntry, removeLoadedEntry } from '../loader.ts'
|
|
@@ -23,15 +23,15 @@ import { addPendingRestart, clearPendingRestart } from '../profile/pending-resta
|
|
|
23
23
|
import { addAllowBuildsKey, githubRepoOf, parseAllowBuildsKey, profileDirectory } from '../profile/profile.ts'
|
|
24
24
|
import { appendLog } from '../log.ts'
|
|
25
25
|
|
|
26
|
-
/**
|
|
26
|
+
/** 内存任务注册表,按任务 id 索引。 */
|
|
27
27
|
const tasks = new Map<number, InstallTask>()
|
|
28
28
|
let nextTaskId = 1
|
|
29
|
-
/**
|
|
29
|
+
/** 等待轮到的排队任务(FIFO)。队列 worker 同一时刻最多跑一个变更。 */
|
|
30
30
|
const queue: QueueItem[] = []
|
|
31
|
-
/**
|
|
31
|
+
/** 当前运行任务的子进程(用于取消)。 */
|
|
32
32
|
const runningChildren = new Map<number, ReturnType<typeof spawn>>()
|
|
33
33
|
|
|
34
|
-
/**
|
|
34
|
+
/** 任务快照(避免消费方改动实时对象)。 */
|
|
35
35
|
export function getTask(id: number): InstallTask | undefined {
|
|
36
36
|
const task = tasks.get(id)
|
|
37
37
|
if (!task) return undefined
|
|
@@ -45,17 +45,17 @@ export interface ActiveTaskInfo {
|
|
|
45
45
|
status: 'pending' | 'running'
|
|
46
46
|
progress: number
|
|
47
47
|
lines: string[]
|
|
48
|
-
/**
|
|
48
|
+
/** 尝试过的安装方式(实际执行过的命令):失败时前端 issue 预填需要 */
|
|
49
49
|
attempts: string[]
|
|
50
|
-
/** 自定义安装入口渠道(NPM
|
|
51
|
-
installChannel?: 'npm'
|
|
50
|
+
/** 自定义安装入口渠道(NPM 包卡片):前端刷新恢复后仍能精准溯源 */
|
|
51
|
+
installChannel?: 'npm'
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
/**
|
|
54
|
+
/** 全部未终态任务,按队列顺序(running 在前、pending 在后)。供客户端刷新页面后恢复队列。 */
|
|
55
55
|
export function activeTask(): ActiveTaskInfo[] {
|
|
56
56
|
const found: ActiveTaskInfo[] = []
|
|
57
|
-
// 展示目标用 displayTarget(owner/repo):安装 target
|
|
58
|
-
//
|
|
57
|
+
// 展示目标用 displayTarget(owner/repo):安装 target 是 npm 包名,
|
|
58
|
+
// 队列恢复/待重启展示对用户保持仓库名无感知
|
|
59
59
|
const show = (task: InstallTask): string => task.displayTarget ?? task.target
|
|
60
60
|
for (const item of queue) {
|
|
61
61
|
const task = item.task
|
|
@@ -90,26 +90,24 @@ export function activeTask(): ActiveTaskInfo[] {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
/**
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
* (the close event lands later) — the slot must stay held until then, otherwise
|
|
97
|
-
* a new task would start while the old pnpm still runs in the same profile dir.
|
|
93
|
+
* 变更子进程仍存活时为 true。并发占位以存活子进程集合为准,而非 task.status:
|
|
94
|
+
* 取消运行中任务会立即翻转其状态,但子进程可能仍在退出中(close 事件稍后才到)——
|
|
95
|
+
* 占位必须维持到那时,否则旧 pnpm 还在同一 profile 目录跑,新任务就开始了。
|
|
98
96
|
*/
|
|
99
97
|
export function hasRunningTask(): boolean {
|
|
100
98
|
return runningChildren.size > 0
|
|
101
99
|
}
|
|
102
100
|
|
|
103
|
-
/**
|
|
101
|
+
/** 目标在队列里是否已有未终态任务(安装/卸载去重)。 */
|
|
104
102
|
export function hasQueuedTarget(target: string): boolean {
|
|
105
103
|
return queue.some((it) => it.task.target === target && (it.task.status === 'pending' || it.task.status === 'running'))
|
|
106
104
|
}
|
|
107
105
|
|
|
108
106
|
/**
|
|
109
|
-
*
|
|
110
|
-
* - pending
|
|
111
|
-
* - running
|
|
112
|
-
*
|
|
107
|
+
* 取消排队中或运行中的任务。
|
|
108
|
+
* - pending:立即移出队列(从未 spawn)。
|
|
109
|
+
* - running:杀掉子进程;队列 worker 随后取下一个。
|
|
110
|
+
* 任务不存在或已结束时返回 false。
|
|
113
111
|
*/
|
|
114
112
|
export function cancelTask(id: number): boolean {
|
|
115
113
|
const task = tasks.get(id)
|
|
@@ -260,17 +258,14 @@ function mutationVerb(action: 'add' | 'remove' | 'update'): string {
|
|
|
260
258
|
|
|
261
259
|
/** 系统日志入口渠道后缀:自定义安装(带渠道)在日志里体现「从哪张卡片发起」,
|
|
262
260
|
* 目录插件安装(无渠道)返回空串。日志中文语境,渠道名用中文口语。 */
|
|
263
|
-
function channelLabel(channel: 'npm' |
|
|
264
|
-
if (channel === 'git') return '(GitHub 源码入口)'
|
|
265
|
-
if (channel === 'dsh') return '(DSH 命令入口)'
|
|
261
|
+
function channelLabel(channel: 'npm' | undefined): string {
|
|
266
262
|
if (channel === 'npm') return '(NPM 包入口)'
|
|
267
263
|
return ''
|
|
268
264
|
}
|
|
269
265
|
|
|
270
266
|
/**
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
* Progress is visible through `getTask(id)` / `activeTask()` until done.
|
|
267
|
+
* 插件变更入队并返回其任务。任务严格串行:队列 worker 只在上一个结束后才启动下一个。
|
|
268
|
+
* 完成前可通过 `getTask(id)` / `activeTask()` 查看进度。
|
|
274
269
|
*/
|
|
275
270
|
export function startPluginMutation(options: {
|
|
276
271
|
action: 'add' | 'remove' | 'update'
|
|
@@ -285,13 +280,13 @@ export function startPluginMutation(options: {
|
|
|
285
280
|
/** 运行中 loader:安装成功后热挂新条目、卸载成功后停用旧条目,两类操作都尽量立即生效
|
|
286
281
|
* (缺失时安装/卸载仍需重启) */
|
|
287
282
|
loader?: LoaderHandle
|
|
288
|
-
/**
|
|
283
|
+
/** 入队前已尝试的安装方式:失败提 Issue 时如实展示;实际执行的命令由 spawn 时追加 */
|
|
289
284
|
attempts?: string[]
|
|
290
285
|
/** 更新已安装的 npm 包(mode=update):命令显式带 @latest,让 pnpm 真正解析最新版本 */
|
|
291
286
|
updateNpm?: boolean
|
|
292
|
-
/**
|
|
293
|
-
*
|
|
294
|
-
installChannel?: 'npm'
|
|
287
|
+
/** 自定义安装入口渠道(NPM 包卡片):任务输出行与系统日志据此溯源「从哪个入口发起」;
|
|
288
|
+
* 目录插件安装不传 */
|
|
289
|
+
installChannel?: 'npm'
|
|
295
290
|
}): InstallTask {
|
|
296
291
|
// 卸载入队时先快照「该包是否带客户端 UI」:卸载完成后包已从磁盘删除,读不到 package.json,
|
|
297
292
|
// 只能在此刻取;卸载成功且曾存活时据此决定是否要刷新页面摘除插件面板
|
|
@@ -341,7 +336,7 @@ export function startPluginMutation(options: {
|
|
|
341
336
|
return task
|
|
342
337
|
}
|
|
343
338
|
|
|
344
|
-
/**
|
|
339
|
+
/** 队列 worker:同一时刻最多跑一个变更;占位空出时取下一个 pending 任务。 */
|
|
345
340
|
function pumpQueue(): void {
|
|
346
341
|
if (hasRunningTask()) return
|
|
347
342
|
const item = queue.find((it) => it.task.status === 'pending')
|
|
@@ -399,9 +394,8 @@ function pumpQueue(): void {
|
|
|
399
394
|
}
|
|
400
395
|
|
|
401
396
|
/**
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
* Never rejects; failures surface through the result / task state.
|
|
397
|
+
* 执行一次 `dsh plugin --profile <profile> <action> <target>` spawn,把输出流式写到
|
|
398
|
+
* 跟踪的任务上,并以捕获结果 resolve。绝不 reject;失败通过结果/任务状态体现。
|
|
405
399
|
*/
|
|
406
400
|
function spawnMutation(options: {
|
|
407
401
|
action: 'add' | 'remove' | 'update'
|
|
@@ -438,8 +432,7 @@ function spawnMutation(options: {
|
|
|
438
432
|
const args = isGlobalNpm
|
|
439
433
|
? ['install', '-g', ...(globalNpm ?? [])]
|
|
440
434
|
: [...invocation.prefixArgs, 'plugin', '--profile', profile, cliAction, npmUpdateTarget]
|
|
441
|
-
//
|
|
442
|
-
// 失败提 Issue 时作为「已尝试的安装方式」随附
|
|
435
|
+
// 记录实际执行的命令(显示包名),失败提 Issue 时作为「已尝试的安装方式」随附
|
|
443
436
|
const executed = isGlobalNpm
|
|
444
437
|
? `npm install -g ${(globalNpm ?? []).join(' ')}`
|
|
445
438
|
: `dsh plugin --profile ${profile} ${cliAction} ${npmUpdateTarget}`
|
|
@@ -655,7 +648,7 @@ function spawnMutation(options: {
|
|
|
655
648
|
|
|
656
649
|
/**
|
|
657
650
|
* 安装后校验已装包里 package.json 声明的文件(main / exports["."].default 与
|
|
658
|
-
* dsh.bundle.patch)是否真实存在。
|
|
651
|
+
* dsh.bundle.patch)是否真实存在。npm 包也可能漏发构建产物(lib/ 等),pnpm 装完没有
|
|
659
652
|
* 报错,但宿主重启加载插件树时会 ERR_MODULE_NOT_FOUND 直接崩溃、网页打不开 —— 这里在
|
|
660
653
|
* 登记「待重启」前就把这类残缺包拦下。没有声明过的文件不在这里追究:没声明入口的
|
|
661
654
|
* bundle 包(只挂技能)本来就不需要入口,宿主读的是它的 package.json 与声明的补丁。
|
|
@@ -754,13 +747,11 @@ export function hostSpawnMarker(invocation: Invocation, error: unknown): string
|
|
|
754
747
|
}
|
|
755
748
|
|
|
756
749
|
/**
|
|
757
|
-
*
|
|
758
|
-
*
|
|
759
|
-
*
|
|
760
|
-
*
|
|
761
|
-
*
|
|
762
|
-
* exact keys into the profile's pnpm-workspace.yaml and retry once.
|
|
763
|
-
* Other failures fall through to the single-spawn result.
|
|
750
|
+
* 执行插件变更,带一条恢复路径:当 `add` 因 pnpm 的 allowBuilds 门禁拦下构建脚本而失败 ——
|
|
751
|
+
* 或是 git 源包自己的 `prepare`(`ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED`,key 取自打印的
|
|
752
|
+
* key@url 提示),或是某依赖的原生构建(`ERR_PNPM_IGNORED_BUILDS`,key = 列出的 name@version)——
|
|
753
|
+
* 就把确切的 key 写进 profile 的 pnpm-workspace.yaml 并重试一次。
|
|
754
|
+
* 其它失败一律落到单次 spawn 的结果。
|
|
764
755
|
*/
|
|
765
756
|
export async function runPluginMutation(options: {
|
|
766
757
|
action: 'add' | 'remove' | 'update'
|
|
@@ -787,8 +778,7 @@ export async function runPluginMutation(options: {
|
|
|
787
778
|
const key = parseAllowBuildsKey(output)
|
|
788
779
|
if (key !== null) {
|
|
789
780
|
if (options.task) {
|
|
790
|
-
//
|
|
791
|
-
// retry streams progress as a running task again.
|
|
781
|
+
// 首次尝试已把任务标为失败;重置它,让重试重新以 running 任务形式流式输出进度。
|
|
792
782
|
options.task.status = 'running'
|
|
793
783
|
options.task.exitCode = null
|
|
794
784
|
pushLine(options.task, `[allow build] ${key}`)
|
|
@@ -815,14 +805,14 @@ export async function runPluginMutation(options: {
|
|
|
815
805
|
}
|
|
816
806
|
}
|
|
817
807
|
}
|
|
818
|
-
// pnpm
|
|
808
|
+
// pnpm 安装本身成功,但目标包声明分发的文件缺失(分发物缺构建产物 / 缺声明的补丁)→ 判为失败:
|
|
819
809
|
// 撤销刚登记的待重启,避免用户重启时宿主加载残缺包直接崩溃。
|
|
820
810
|
// 全局 npm 安装不进 profile(profile deps 里没有这些包),跳过入口校验
|
|
821
811
|
if (options.action !== 'remove' && result.exitCode === 0 && !result.timedOut && (options.globalNpm ?? []).length === 0) {
|
|
822
812
|
const verified = verifyInstalledEntry(options.profile, options.target)
|
|
823
813
|
if (verified.name !== null && verified.missing !== null) {
|
|
824
814
|
clearPendingRestart(options.displayTarget ?? options.target)
|
|
825
|
-
const detail = `[packaging] ${verified.name}: declared file missing: ${verified.missing} (
|
|
815
|
+
const detail = `[packaging] ${verified.name}: declared file missing: ${verified.missing} (the published package is missing this file — report it to the author)`
|
|
826
816
|
if (options.task && options.task.status !== 'cancelled') {
|
|
827
817
|
options.task.status = 'failed'
|
|
828
818
|
options.task.exitCode = null
|
|
@@ -832,18 +822,21 @@ export async function runPluginMutation(options: {
|
|
|
832
822
|
return { exitCode: 1, timedOut: false, error: `declared file missing: ${verified.missing}`, stdout: result.stdout, stderr: `${result.stderr}\n${detail}` }
|
|
833
823
|
}
|
|
834
824
|
}
|
|
835
|
-
//
|
|
836
|
-
//
|
|
837
|
-
//
|
|
838
|
-
// 只在本就不含任何错误码时回捞 —— pnpm 已把报错回显出来时无需多读一次文件。
|
|
825
|
+
// 宿主只把一行摘要(`dsh: plugin command failed; diagnostics: <path>`)回显到控制台、真正的报错
|
|
826
|
+
// 写进该日志文件时,前端拿不到任何错误特征、只能归为「未知」。按该路径回捞日志尾部补进失败信息。
|
|
827
|
+
// 只在输出本就不含错误码时回捞 —— pnpm 已回显报错时无需多读一次文件。
|
|
839
828
|
if (result.exitCode !== 0 && !result.timedOut && !/\bERR_[A-Z0-9_]+\b/.test(`${result.stderr}\n${result.stdout}`)) {
|
|
840
|
-
const
|
|
829
|
+
const output = `${result.stderr}\n${result.stdout}`
|
|
830
|
+
const backfilled = backfillOperationLog(output)
|
|
841
831
|
if (backfilled !== null) {
|
|
842
832
|
if (options.task && options.task.status !== 'cancelled') {
|
|
843
833
|
pushLine(options.task, '[pnpm log] DSH did not echo pnpm output — recovered the operation log from disk')
|
|
844
834
|
for (const line of backfilled.split(/\r?\n/)) if (line.trim() !== '') pushLine(options.task, line.trimEnd())
|
|
845
835
|
}
|
|
846
836
|
result = { ...result, stderr: `${result.stderr}\n${backfilled}` }
|
|
837
|
+
} else if (options.task && options.task.status !== 'cancelled' && operationLogPathOf(output) !== null) {
|
|
838
|
+
// 诊断路径在、日志却取不到:留一行证据,否则下一次同类上报又只剩宿主那句摘要、看不出根因
|
|
839
|
+
pushLine(options.task, '[pnpm log] the host-reported diagnostics log could not be read from disk (missing, empty, or unreadable)')
|
|
847
840
|
}
|
|
848
841
|
}
|
|
849
842
|
// 包在 registry 不存在(404)→ 官方源复查,区分「作者的依赖缺失/包被删」与「本机镜像源滞后」
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DSH Plugin Hub — the community plugin marketplace for DeepSeek Harness.
|
|
3
|
+
* Website: https://dsh-plugin.org
|
|
4
|
+
* GitHub: https://github.com/dshplugin/dsh-plugin-hub
|
|
5
|
+
*
|
|
6
|
+
* npm 包最新版本的发布时间查询:为「已安装插件是否有更新」提供供应链门禁依据。
|
|
7
|
+
* pnpm 对发布不满 24h(dsh profile minimumReleaseAge=1440)的版本会静默回退到旧版本,
|
|
8
|
+
* 因此发布未满门槛的版本不该提示更新(否则用户点了也装不上)。
|
|
9
|
+
* 结果按包名内存缓存,同一次会话内不重复请求;失败/超时/响应过大一律返回 null,绝不抛错、绝不阻塞。
|
|
10
|
+
*/
|
|
11
|
+
import { get as httpsGet } from 'node:https'
|
|
12
|
+
import { validPackageName } from './profile/profile.ts'
|
|
13
|
+
import { fetchViaCurl } from './probe.ts'
|
|
14
|
+
|
|
15
|
+
export interface NpmRelease {
|
|
16
|
+
version: string
|
|
17
|
+
publishedAt: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** 请求体包名数量上限:只为已安装插件按需查询,几十个足矣,封顶防滥用。 */
|
|
21
|
+
export const MAX_RELEASE_AGE_PACKAGES = 50
|
|
22
|
+
/** 单包查询超时(ms):慢网络下失败即返回 null,不拖住整个响应。 */
|
|
23
|
+
export const RELEASE_AGE_TIMEOUT_MS = 8000
|
|
24
|
+
/** 响应体上限:完整 packument 可能达数 MB,超限直接放弃,避免异常大包拖垮服务。 */
|
|
25
|
+
const MAX_PACKUMENT_BYTES = 10 * 1024 * 1024
|
|
26
|
+
export const DEFAULT_NPM_REGISTRY = 'https://registry.npmjs.org'
|
|
27
|
+
|
|
28
|
+
/** 会话内缓存(包名 → 版本与发布时间):只存成功结果,一次网络抖动不会把判断永久粘住。 */
|
|
29
|
+
const releaseCache = new Map<string, NpmRelease>()
|
|
30
|
+
|
|
31
|
+
/** 从完整 packument 取 dist-tags.latest 对应版本及其发布时间;缺任一字段返回 null。 */
|
|
32
|
+
export function latestReleaseOf(packument: unknown): NpmRelease | null {
|
|
33
|
+
if (packument === null || typeof packument !== 'object') return null
|
|
34
|
+
const doc = packument as { 'dist-tags'?: unknown; time?: unknown }
|
|
35
|
+
const tags = doc['dist-tags']
|
|
36
|
+
const version = tags !== null && typeof tags === 'object' ? (tags as { latest?: unknown }).latest : undefined
|
|
37
|
+
if (typeof version !== 'string' || version === '') return null
|
|
38
|
+
const time = doc.time
|
|
39
|
+
const publishedAt = time !== null && typeof time === 'object' ? (time as Record<string, unknown>)[version] : undefined
|
|
40
|
+
if (typeof publishedAt !== 'string' || publishedAt === '') return null
|
|
41
|
+
return { version, publishedAt }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 请求体 → 去重后的合法包名列表;非数组 / 超量 / 含非法包名一律 null(拒绝非法输入)。 */
|
|
45
|
+
export function parseReleaseAgeRequest(body: unknown): string[] | null {
|
|
46
|
+
if (body === null || typeof body !== 'object') return null
|
|
47
|
+
const raw = (body as { packages?: unknown }).packages
|
|
48
|
+
if (!Array.isArray(raw) || raw.length > MAX_RELEASE_AGE_PACKAGES) return null
|
|
49
|
+
const names = new Set<string>()
|
|
50
|
+
for (const item of raw) {
|
|
51
|
+
if (typeof item !== 'string' || item === '' || item.startsWith('-') || !validPackageName(item)) return null
|
|
52
|
+
names.add(item)
|
|
53
|
+
}
|
|
54
|
+
return [...names]
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** node:https 直连 GET,仅接受 200;超时/超限/网络失败返回 null。 */
|
|
58
|
+
function getJson(url: string, timeoutMs: number): Promise<string | null> {
|
|
59
|
+
return new Promise((resolve) => {
|
|
60
|
+
let done = false
|
|
61
|
+
const finish = (value: string | null) => { if (!done) { done = true; resolve(value) } }
|
|
62
|
+
const req = httpsGet(url, { timeout: timeoutMs, headers: { accept: 'application/json' } }, (res) => {
|
|
63
|
+
if (res.statusCode !== 200) { res.resume(); finish(null); return }
|
|
64
|
+
const declared = Number(res.headers['content-length'])
|
|
65
|
+
if (Number.isFinite(declared) && declared > MAX_PACKUMENT_BYTES) { res.destroy(); finish(null); return }
|
|
66
|
+
let body = ''
|
|
67
|
+
let bytes = 0
|
|
68
|
+
res.on('data', (chunk: Buffer) => {
|
|
69
|
+
bytes += chunk.length
|
|
70
|
+
if (bytes > MAX_PACKUMENT_BYTES) { res.destroy(); finish(null); return }
|
|
71
|
+
body += chunk.toString()
|
|
72
|
+
})
|
|
73
|
+
res.on('end', () => finish(body))
|
|
74
|
+
res.on('error', () => finish(null))
|
|
75
|
+
})
|
|
76
|
+
req.on('timeout', () => req.destroy())
|
|
77
|
+
req.on('error', () => finish(null))
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 拉取一个包的完整 packument 正文;配了代理时复用与安装/目录同一套代理口径(curl 子进程注入 proxy env)。 */
|
|
82
|
+
async function fetchPackument(url: string, proxy: string, timeoutMs: number): Promise<string | null> {
|
|
83
|
+
if (proxy !== '') {
|
|
84
|
+
const r = await fetchViaCurl(url, proxy, timeoutMs)
|
|
85
|
+
return r.ok && r.body !== '' && Buffer.byteLength(r.body) <= MAX_PACKUMENT_BYTES ? r.body : null
|
|
86
|
+
}
|
|
87
|
+
return getJson(url, timeoutMs)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 查询一个包的最新版本与发布时间(成功结果缓存,同会话内同包不重复请求);registry 为空回退官方源,失败返回 null。 */
|
|
91
|
+
export async function lookupReleaseAge(
|
|
92
|
+
packageName: string,
|
|
93
|
+
registry: string,
|
|
94
|
+
proxy: string,
|
|
95
|
+
timeoutMs = RELEASE_AGE_TIMEOUT_MS,
|
|
96
|
+
): Promise<NpmRelease | null> {
|
|
97
|
+
const cached = releaseCache.get(packageName)
|
|
98
|
+
if (cached !== undefined) return cached
|
|
99
|
+
const base = registry.replace(/\/+$/, '') || DEFAULT_NPM_REGISTRY
|
|
100
|
+
const body = await fetchPackument(`${base}/${encodeURIComponent(packageName)}`, proxy, timeoutMs)
|
|
101
|
+
if (body === null) return null
|
|
102
|
+
let parsed: unknown
|
|
103
|
+
try { parsed = JSON.parse(body) } catch { return null }
|
|
104
|
+
const release = latestReleaseOf(parsed)
|
|
105
|
+
if (release !== null) releaseCache.set(packageName, release)
|
|
106
|
+
return release
|
|
107
|
+
}
|
|
@@ -99,7 +99,7 @@ function macSystemProxy(): string | null {
|
|
|
99
99
|
* `http=host:port;https=host:port`(按协议分别配置)。
|
|
100
100
|
* 字符类必须同时排除 `/` 与 `=`:只排除 `=` 时,带 scheme 的值会让「主机名」吞掉 `//`,
|
|
101
101
|
* 拼出 `http:////127.0.0.1:10793` 这种立刻失败的非法地址 —— 代理本来是通的,却把诊断、
|
|
102
|
-
*
|
|
102
|
+
* 目录拉取、安装前网络探测全判成「不可达」。
|
|
103
103
|
*/
|
|
104
104
|
export function parseWinProxyServer(stdout: string): string | null {
|
|
105
105
|
const m = /([^:\s=/]+):(\d+)/.exec(stdout)
|
|
@@ -220,13 +220,13 @@ export function probeUrl(url: string, proxy: string, timeoutMs: number): Promise
|
|
|
220
220
|
}
|
|
221
221
|
|
|
222
222
|
/**
|
|
223
|
-
* git
|
|
223
|
+
* git 真实克隆握手探测:spawn git ls-remote(https 传输,与 pnpm 克隆前的
|
|
224
224
|
* ref 握手一致),注入代理 env,GIT_TERMINAL_PROMPT=0 防凭据提示挂起。
|
|
225
225
|
*
|
|
226
226
|
* 为什么要真实 git 而非 curl 打网页:网页「能打开」和 git「能克隆」是两码事 ——
|
|
227
227
|
* 防火墙/代理常按端口与协议区分,HTTP 页可达不代表 git 传输可达。这里测的就是
|
|
228
|
-
*
|
|
229
|
-
* 供系统诊断 GitHub
|
|
228
|
+
* 克隆握手本身,回答「能不能连上 github.com」。
|
|
229
|
+
* 供系统诊断 GitHub 可达性使用;探测目标用 dshplugin/hello-dsh 小仓库(秒级完成,
|
|
230
230
|
* 不打 17MB 的 dsh-plugin-hub 主页)。
|
|
231
231
|
*/
|
|
232
232
|
export function gitLsRemote(url: string, proxy: string, timeoutMs: number): Promise<ProbeResult> {
|
|
@@ -3,23 +3,19 @@
|
|
|
3
3
|
* Website: https://dsh-plugin.org
|
|
4
4
|
* GitHub: https://github.com/dshplugin/dsh-plugin-hub
|
|
5
5
|
*
|
|
6
|
-
*
|
|
6
|
+
* 已安装插件版本追踪。
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* signal present at install time into a small per-profile JSON file, then
|
|
12
|
-
* compare it against the catalog's current signal later.
|
|
8
|
+
* 已安装表(routes.ts 的 `readInstalled`)只知道每个 Git 依赖的 manifest spec,不含版本号。
|
|
9
|
+
* 为让插件中心能判断「有更新」,我们把安装时的目录信号记进一个 per-profile 的小 JSON 文件,
|
|
10
|
+
* 之后再与目录当前信号比对。
|
|
13
11
|
*
|
|
14
|
-
*
|
|
15
|
-
* - version
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* Repos that never publish a release fall back to this: a newer push time
|
|
19
|
-
* means new commits → an update is available.
|
|
12
|
+
* 两个信号覆盖所有仓库:
|
|
13
|
+
* - version:目录最新 release tag(如 v1.2.3),发 release 的仓库按它比对。
|
|
14
|
+
* - updatedAt:仓库最近 push 时间(GitHub pushed_at,即 `repoUpdatedAt`),
|
|
15
|
+
* 从不发 release 的仓库回退用它:push 时间变新即有新提交 → 有更新。
|
|
20
16
|
*
|
|
21
|
-
*
|
|
22
|
-
* { "<owner>/<repo>"
|
|
17
|
+
* 存储:`DSH_HOME/profiles/<profile>/gro.ngilp-hsd-versions.json`
|
|
18
|
+
* { "<owner>/<repo>"(小写): { version, updatedAt, installedAt } }
|
|
23
19
|
*/
|
|
24
20
|
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
25
21
|
import { homedir } from 'node:os'
|
|
@@ -31,7 +27,7 @@ export interface InstalledVersionRecord {
|
|
|
31
27
|
/** 安装时的仓库最近更新时间(ISO,GitHub pushed_at);无 release 仓库的更新依据 */
|
|
32
28
|
updatedAt: string
|
|
33
29
|
installedAt: string
|
|
34
|
-
/**
|
|
30
|
+
/** 安装时记录在案的官方 npm 包名(repo → 包名映射):目录数据可能未下发
|
|
35
31
|
* npmPackage(组织 scope 与 GitHub 用户名不一致),客户端据此把依赖 key 匹配回仓库 */
|
|
36
32
|
npmPackage?: string
|
|
37
33
|
}
|
|
@@ -51,21 +47,19 @@ export function readInstalledVersions(profile: string): InstalledVersionTable {
|
|
|
51
47
|
}
|
|
52
48
|
}
|
|
53
49
|
|
|
54
|
-
/**
|
|
50
|
+
/** 记录某仓库安装时的目录信号(版本 + 仓库更新时间)。 */
|
|
55
51
|
export function recordInstalledVersion(profile: string, repo: string, version: string, updatedAt: string): void {
|
|
56
52
|
const table = readInstalledVersions(profile)
|
|
57
53
|
const key = repo.toLowerCase()
|
|
58
|
-
// 合并写入:保留已记录的 npmPackage(
|
|
54
|
+
// 合并写入:保留已记录的 npmPackage(repo → 包名映射),
|
|
59
55
|
// 客户端同步版本信号时不能把它覆盖掉
|
|
60
56
|
table[key] = { ...table[key], version, updatedAt, installedAt: new Date().toISOString() }
|
|
61
57
|
writeVersions(profile, table)
|
|
62
58
|
}
|
|
63
59
|
|
|
64
60
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* harmless when the repo is not (yet) installed — the client only treats a repo
|
|
68
|
-
* as installed when its dependency key actually exists.
|
|
61
|
+
* 记录某仓库解析出的 npm 包名。在 `/install` 时调用,让映射在任务完成前就存在;
|
|
62
|
+
* 仓库尚未安装时也无害 —— 客户端只在依赖 key 真实存在时才把仓库当作已安装。
|
|
69
63
|
*/
|
|
70
64
|
export function recordResolvedNpmPackage(profile: string, repo: string, npmPackage: string): void {
|
|
71
65
|
const table = readInstalledVersions(profile)
|
|
@@ -74,7 +68,7 @@ export function recordResolvedNpmPackage(profile: string, repo: string, npmPacka
|
|
|
74
68
|
writeVersions(profile, table)
|
|
75
69
|
}
|
|
76
70
|
|
|
77
|
-
/**
|
|
71
|
+
/** 清除某仓库记录的版本(卸载时调用)。 */
|
|
78
72
|
export function removeInstalledVersion(profile: string, repo: string): void {
|
|
79
73
|
const table = readInstalledVersions(profile)
|
|
80
74
|
delete table[repo.toLowerCase()]
|
|
@@ -12,7 +12,7 @@ import { basename, dirname, join } from 'node:path'
|
|
|
12
12
|
import { PACKAGE_RE, REPO_RE } from '../install/install-types.ts'
|
|
13
13
|
import { githubReleaseTarget } from '../install/release-target.ts'
|
|
14
14
|
|
|
15
|
-
/**
|
|
15
|
+
/** 从启动参数解析当前 profile,取不到落回 `web`。 */
|
|
16
16
|
export function readProfileArg(fallback = 'web'): string {
|
|
17
17
|
return profileFromArgv(process.argv, fallback)
|
|
18
18
|
}
|
|
@@ -44,43 +44,19 @@ export function profileFromArgv(argv: readonly string[], fallback = 'web'): stri
|
|
|
44
44
|
return fallback
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
/**
|
|
47
|
+
/** 解析 profile 目录(`DSH_HOME` 或 `~/.dsh`)。 */
|
|
48
48
|
export function profileDirectory(profile: string): string {
|
|
49
49
|
return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'profiles', profile)
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
/**
|
|
53
|
-
*
|
|
54
|
-
* explicitly pinned revision reaches the installer instead of being dropped. */
|
|
52
|
+
/** 把仓库身份转成规范的 GitHub 目标(git+https),仓库不合法时返回 null。
|
|
53
|
+
* 带 ref 时按 Git ref(`#tag` / `#commit`)拼接;安装通道已不再使用 git 目标,此函数仅做身份规范化。 */
|
|
55
54
|
export function githubTarget(repo: string, ref?: string | null): string | null {
|
|
56
55
|
if (typeof repo !== 'string' || !REPO_RE.test(repo)) return null
|
|
57
56
|
const url = `git+https://github.com/${repo}.git`
|
|
58
57
|
return ref === undefined || ref === null || ref === '' ? url : `${url}#${ref}`
|
|
59
58
|
}
|
|
60
59
|
|
|
61
|
-
/**
|
|
62
|
-
* Extract a validated explicit Git ref (`#<ref>`) from a GitHub spec, or null.
|
|
63
|
-
* Only the fragment of a Git-style address qualifies — a pinned tag/commit is
|
|
64
|
-
* user intent and must survive target normalization, whereas `#…` on a plain
|
|
65
|
-
* web URL (`https://github.com/o/r#readme`) is a page anchor, not a ref.
|
|
66
|
-
* Git-style = `git+…` / `github:` / `git@…` / `….git` / bare `owner/repo`.
|
|
67
|
-
* Grammar mirrors a Git ref: no leading `.`/`-`, no `#`/whitespace.
|
|
68
|
-
*/
|
|
69
|
-
const GIT_REF_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/
|
|
70
|
-
|
|
71
|
-
export function githubRefOf(value: string): string | null {
|
|
72
|
-
if (typeof value !== 'string') return null
|
|
73
|
-
const input = value.trim()
|
|
74
|
-
const hash = input.indexOf('#')
|
|
75
|
-
if (hash <= 0) return null
|
|
76
|
-
const base = input.slice(0, hash)
|
|
77
|
-
if (githubRepoOf(base) === null) return null
|
|
78
|
-
const gitStyle = /^(?:git\+|github:|git@)/i.test(base) || /\.git$/i.test(base) || !base.includes('://')
|
|
79
|
-
if (!gitStyle) return null
|
|
80
|
-
const ref = input.slice(hash + 1)
|
|
81
|
-
return GIT_REF_RE.test(ref) ? ref : null
|
|
82
|
-
}
|
|
83
|
-
|
|
84
60
|
/**
|
|
85
61
|
* DSH 命令行前缀(官方唯一形式 `dsh plugin --profile <name> <add|update> <target>`)—— 提取其中的安装目标;
|
|
86
62
|
* 非命令输入原样返回。兼容「直接粘贴完整命令」(防用户输错):--profile 必填且只有长选项
|
|
@@ -113,7 +89,7 @@ export function globalNpmPackagesOf(value: string): string[] | null {
|
|
|
113
89
|
return packages
|
|
114
90
|
}
|
|
115
91
|
|
|
116
|
-
/**
|
|
92
|
+
/** 从目录值或已装 Git spec 中提取 owner/repo 身份。 */
|
|
117
93
|
export function githubRepoOf(value: string): string | null {
|
|
118
94
|
if (typeof value !== 'string') return null
|
|
119
95
|
const input = value.trim()
|
|
@@ -137,25 +113,24 @@ export function githubRepoOf(value: string): string | null {
|
|
|
137
113
|
return null
|
|
138
114
|
}
|
|
139
115
|
|
|
140
|
-
/**
|
|
116
|
+
/** 校验 npm 包名(卸载目标的语法)。 */
|
|
141
117
|
export function validPackageName(name: string): boolean {
|
|
142
118
|
return typeof name === 'string' && PACKAGE_RE.test(name)
|
|
143
119
|
}
|
|
144
120
|
|
|
145
121
|
/**
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
* the only reliable key is the one pnpm prints verbatim. Null when absent.
|
|
122
|
+
* 取 pnpm 在 `ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED` 提示里原样打印的 `allowBuilds` key。
|
|
123
|
+
* Git 安装时 pnpm 按 `name@<实际 fetch URL>`(如
|
|
124
|
+
* `https://codeload.github.com/<owner>/<repo>/tar.gz/<commit>`)匹配,而不是 `git+ssh://`
|
|
125
|
+
* 或分支 spec —— 裸包名与 `git+` spec 都匹配不上,唯一可靠的 key 就是 pnpm 原样输出的那个。
|
|
126
|
+
* 无则返回 null。
|
|
152
127
|
*/
|
|
153
128
|
export function parseAllowBuildsKey(output: string): string | null {
|
|
154
129
|
const m = /^[ \t]*(\S+@\S+):[ \t]*true[ \t]*$/m.exec(output)
|
|
155
130
|
return m === null ? null : m[1].trim()
|
|
156
131
|
}
|
|
157
132
|
|
|
158
|
-
/**
|
|
133
|
+
/** 当裸标量非法时,为 YAML 块映射 key 加引号。 */
|
|
159
134
|
function quoteYamlKey(key: string): string {
|
|
160
135
|
if (/^[-?:,[\]{}#&*!|>'"%@`]/.test(key) || /:(\s|$)/.test(key)) {
|
|
161
136
|
return `'${key.replace(/'/g, "''")}'`
|
|
@@ -164,15 +139,14 @@ function quoteYamlKey(key: string): string {
|
|
|
164
139
|
}
|
|
165
140
|
|
|
166
141
|
/**
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* `set this to true or false` value) are dropped on rewrite.
|
|
142
|
+
* 向 profile 的 pnpm-workspace.yaml 追加一条 `allowBuilds` 记录:与已有条目合并,
|
|
143
|
+
* 其余 yaml 保持不变;重写时丢弃损坏的占位条目(pnpm 安装失败的 bug 会写入
|
|
144
|
+
* 字面量 `set this to true or false`)。
|
|
171
145
|
*/
|
|
172
146
|
export function addAllowBuildsKey(profile: string, key: string): void {
|
|
173
147
|
const file = join(profileDirectory(profile), 'pnpm-workspace.yaml')
|
|
174
148
|
let yaml = ''
|
|
175
|
-
try { yaml = readFileSync(file, 'utf8') } catch { /*
|
|
149
|
+
try { yaml = readFileSync(file, 'utf8') } catch { /* 文件不存在,下方创建 */ }
|
|
176
150
|
const eol = /\r\n/.test(yaml) ? '\r\n' : '\n'
|
|
177
151
|
const blockRe = /allowBuilds:[ \t]*\r?\n((?:[ \t]+[^\r\n]*\r?\n?)*)/g
|
|
178
152
|
const map = new Map<string, string>()
|
|
@@ -3,24 +3,21 @@
|
|
|
3
3
|
* Website: https://dsh-plugin.org
|
|
4
4
|
* GitHub: https://github.com/dshplugin/dsh-plugin-hub
|
|
5
5
|
*
|
|
6
|
-
*
|
|
6
|
+
* 安装执行器共用的纯进度估算工具。
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* line and map it onto a 0-100 estimate; keeping them free of I/O makes the
|
|
12
|
-
* estimation logic directly unit-testable (see tests/progress.test.ts).
|
|
8
|
+
* dsh CLI 包装了 pnpm,pnpm 用回车原地刷新 `Progress: …` 行,因此原始输出是一串碎片。
|
|
9
|
+
* 这些工具把单个碎片归一化成干净的终端行,并映射到 0-100 估算值;不碰 I/O
|
|
10
|
+
* 让估算逻辑可直接单测(见 tests/progress.test.ts)。
|
|
13
11
|
*/
|
|
14
12
|
|
|
15
|
-
/**
|
|
13
|
+
/** 剥掉 `[out]` / `[err]` 来源前缀与 ANSI 转义序列。 */
|
|
16
14
|
export function cleanLine(raw: string): string {
|
|
17
15
|
return raw.replace(/^\[(?:err|out)\]\s*/, '').replace(/\x1b\[[0-9;]*[A-Za-z]/g, '').trim()
|
|
18
16
|
}
|
|
19
17
|
|
|
20
18
|
/**
|
|
21
|
-
*
|
|
22
|
-
* `Progress: resolved N, reused X, downloaded Y, added Z
|
|
23
|
-
* phase; resolution/install phase lines bump the estimate towards done.
|
|
19
|
+
* 从单行 CLI 输出估算 0-100 进度。pnpm 在 fetch 阶段输出
|
|
20
|
+
* `Progress: resolved N, reused X, downloaded Y, added Z`;解析/安装阶段的行把估算值推向完成。
|
|
24
21
|
*/
|
|
25
22
|
export function estimateProgress(line: string): number {
|
|
26
23
|
const progress = line.match(/Progress:\s*(.+)/)
|
|
@@ -28,7 +25,7 @@ export function estimateProgress(line: string): number {
|
|
|
28
25
|
let resolved = 0
|
|
29
26
|
let handled = 0
|
|
30
27
|
for (const part of progress[1].split(',')) {
|
|
31
|
-
// pnpm
|
|
28
|
+
// pnpm 输出 `label count` 形式的 token,如 `resolved 100, reused 40`
|
|
32
29
|
const [label, raw] = part.trim().split(/\s+/)
|
|
33
30
|
const value = Number(raw)
|
|
34
31
|
if (label === 'resolved') resolved = value
|