dsh-plugin 0.1.1 → 1.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.
Files changed (64) hide show
  1. package/README.en.md +49 -26
  2. package/README.md +49 -26
  3. package/client/client.js +2148 -229
  4. package/client/client.js.map +1 -1
  5. package/lib/http/routes.js +396 -0
  6. package/lib/index.js +17 -8
  7. package/lib/services/install-types.js +11 -0
  8. package/lib/services/install.js +4 -0
  9. package/lib/services/installed-versions.js +51 -0
  10. package/lib/services/loader.js +98 -0
  11. package/lib/services/pending-restart.js +34 -0
  12. package/lib/services/preflight.js +94 -0
  13. package/lib/services/profile.js +88 -0
  14. package/lib/services/progress.js +50 -0
  15. package/lib/services/task-queue.js +417 -0
  16. package/lib/types/http/routes.d.ts +23 -0
  17. package/lib/types/index.d.ts +27 -0
  18. package/lib/types/services/install-types.d.ts +63 -0
  19. package/lib/types/services/install.d.ts +18 -0
  20. package/lib/types/services/installed-versions.d.ts +14 -0
  21. package/lib/types/services/loader.d.ts +43 -0
  22. package/lib/types/services/pending-restart.d.ts +13 -0
  23. package/lib/types/services/preflight.d.ts +6 -0
  24. package/lib/types/services/profile.d.ts +24 -0
  25. package/lib/types/services/progress.d.ts +17 -0
  26. package/lib/types/services/task-queue.d.ts +76 -0
  27. package/package.json +27 -6
  28. package/src/client/components/CatalogControls.tsx +104 -0
  29. package/src/client/components/CatalogHeader.tsx +78 -0
  30. package/src/client/components/CatalogList.tsx +75 -0
  31. package/src/client/components/CategoryTabs.tsx +35 -0
  32. package/src/client/components/Dropdown.tsx +80 -0
  33. package/src/client/components/NotificationsModal.tsx +230 -0
  34. package/src/client/components/PluginCard.tsx +104 -0
  35. package/src/client/components/PluginHubSection.tsx +339 -0
  36. package/src/client/components/ProgressView.tsx +23 -0
  37. package/src/client/components/icons.tsx +125 -0
  38. package/src/client/components/modals.tsx +365 -0
  39. package/src/client/hooks/useCatalog.ts +192 -0
  40. package/src/client/hooks/useTaskQueue.ts +405 -0
  41. package/src/client/index.tsx +8 -422
  42. package/src/client/lib/catalog.ts +204 -0
  43. package/src/client/lib/env.ts +16 -0
  44. package/src/client/lib/failures.ts +153 -0
  45. package/src/client/lib/format.ts +22 -0
  46. package/src/client/locales.ts +186 -18
  47. package/src/client/styles/Dropdown.module.css +123 -0
  48. package/src/client/styles/Header.module.css +432 -0
  49. package/src/client/styles/List.module.css +324 -0
  50. package/src/client/styles/Modal.module.css +965 -0
  51. package/src/client/styles/tokens.module.css +86 -0
  52. package/src/client/types.ts +80 -0
  53. package/src/server/http/routes.ts +397 -0
  54. package/src/server/index.ts +35 -0
  55. package/src/server/services/install-types.ts +63 -0
  56. package/src/server/services/install.ts +18 -0
  57. package/src/server/services/installed-versions.ts +65 -0
  58. package/src/server/services/loader.ts +110 -0
  59. package/src/server/services/pending-restart.ts +36 -0
  60. package/src/server/services/preflight.ts +97 -0
  61. package/src/server/services/profile.ts +89 -0
  62. package/src/server/services/progress.ts +48 -0
  63. package/src/server/services/task-queue.ts +450 -0
  64. package/src/client/Section.module.css +0 -589
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Installed-plugin version tracking.
3
+ *
4
+ * The installed table (`readInstalled` in routes.ts) only knows the manifest
5
+ * spec of each dependency (`github:<owner>/<repo>`), which carries no version.
6
+ * To let the Plugin Hub tell "an update is available", we record the catalog
7
+ * signal present at install time into a small per-profile JSON file, then
8
+ * compare it against the catalog's current signal later.
9
+ *
10
+ * Two signals cover every repo:
11
+ * - version: the catalog's latest release tag (e.g. v1.2.3). Repos that
12
+ * publish releases are compared on this.
13
+ * - updatedAt: the repo's last push time (GitHub pushed_at, `repoUpdatedAt`).
14
+ * Repos that never publish a release fall back to this: a newer push time
15
+ * means new commits → an update is available.
16
+ *
17
+ * Storage: `DSH_HOME/profiles/<profile>/gro.ngilp-hsd-versions.json`
18
+ * { "<owner>/<repo>" (lowercase): { version: string, updatedAt: string, installedAt: string } }
19
+ */
20
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
21
+ import { homedir } from 'node:os'
22
+ import { dirname, join } from 'node:path'
23
+
24
+ export interface InstalledVersionRecord {
25
+ /** 安装时的目录最新 release 版本(无 release 的仓库为空串) */
26
+ version: string
27
+ /** 安装时的仓库最近更新时间(ISO,GitHub pushed_at);无 release 仓库的更新依据 */
28
+ updatedAt: string
29
+ installedAt: string
30
+ }
31
+
32
+ export type InstalledVersionTable = Record<string, InstalledVersionRecord>
33
+
34
+ export function versionsFilePath(profile: string): string {
35
+ return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'profiles', profile, 'gro.ngilp-hsd-versions.json')
36
+ }
37
+
38
+ export function readInstalledVersions(profile: string): InstalledVersionTable {
39
+ try {
40
+ const raw = JSON.parse(readFileSync(versionsFilePath(profile), 'utf8')) as unknown
41
+ return raw !== null && typeof raw === 'object' ? raw as InstalledVersionTable : {}
42
+ } catch {
43
+ return {}
44
+ }
45
+ }
46
+
47
+ /** Record the catalog signals (version + repo updated time) present at install time for one repo. */
48
+ export function recordInstalledVersion(profile: string, repo: string, version: string, updatedAt: string): void {
49
+ const table = readInstalledVersions(profile)
50
+ table[repo.toLowerCase()] = { version, updatedAt, installedAt: new Date().toISOString() }
51
+ writeVersions(profile, table)
52
+ }
53
+
54
+ /** Forget the recorded version for one repo (called on uninstall). */
55
+ export function removeInstalledVersion(profile: string, repo: string): void {
56
+ const table = readInstalledVersions(profile)
57
+ delete table[repo.toLowerCase()]
58
+ writeVersions(profile, table)
59
+ }
60
+
61
+ function writeVersions(profile: string, table: InstalledVersionTable): void {
62
+ const file = versionsFilePath(profile)
63
+ mkdirSync(dirname(file), { recursive: true })
64
+ writeFileSync(file, JSON.stringify(table, null, 2) + '\n', 'utf8')
65
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * 运行中 loader 的读取与停用:卸载成功后对匹配条目做 live-disable,
3
+ * 使卸载立即生效(刷新页面不再加载已卸载的 client bundle,也无需重启)。
4
+ */
5
+
6
+ /**
7
+ * 运行中 loader 的最小接口:卸载成功后即时移除条目用。
8
+ * 对应官方 `ctx.loader`(cordis-plugin-loader 的 Loader 服务)的读取面与移除面。
9
+ * 移除方式:对匹配条目 live-disable(`entry.update({ disabled: true })`),
10
+ * 而不是 `loader.remove(id)` —— disable 走 Entry.update 的 disabled 分支,不触发
11
+ * tree.write()(不会把运行态条目烘焙回 cordis.yml),也不依赖嵌套子树的 id 解析。
12
+ */
13
+ export interface LoaderHandle {
14
+ /** 遍历当前插件树的全部条目(含嵌套子树)。 */
15
+ entries(): Iterable<{
16
+ id: string
17
+ options: { id?: string; name?: string; disabled?: boolean | null }
18
+ /** live-disable 一个条目(官方 Entry.update,force=true 覆盖初始化竞态)。 */
19
+ update(options: { disabled: boolean | null }, create?: boolean, force?: boolean): Promise<void>
20
+ }>
21
+ /** 停止并移除一个条目(官方 Loader API:resolve → EntryGroup.remove → tree.write)。 */
22
+ remove(id: string): Promise<void>
23
+ }
24
+
25
+ /** 运行中 loader 的全部条目(id + options.name),诊断用。 */
26
+ export function dumpLoaderEntries(loader: LoaderHandle | undefined): Array<{ id: string; name?: string }> {
27
+ if (!loader) return []
28
+ const entries: Array<{ id: string; name?: string }> = []
29
+ for (const entry of Array.from(loader.entries())) {
30
+ entries.push({ id: entry.id, name: entry.options?.name })
31
+ }
32
+ return entries
33
+ }
34
+
35
+ /**
36
+ * 卸载目标(npm 包名,如 `@scope/widget`)与 loader 条目名的匹配。
37
+ * loader 条目名由插件作者/工具写入,形式不可控,常见变体:
38
+ * - 完整 npm 名:`@scope/widget`
39
+ * - 去 `@` 前缀:`scope/widget`
40
+ * - 去 scope 的短名:`widget`
41
+ * - scope 分隔符变 `-`:`scope-widget`
42
+ * 因此归一化后比较全串与短名两个维度。profile 内包名唯一,宽松匹配不会误伤其它包。
43
+ */
44
+ function nameMatches(entryName: string | undefined, target: string): boolean {
45
+ if (!entryName) return false
46
+ // 归一化:去 @、非 [a-z0-9-] 一律换 `-`、小写
47
+ const norm = (s: string) => s.trim().replace(/^@+/, '').replace(/[^a-z0-9-]/gi, '-').toLowerCase()
48
+ const e = norm(entryName)
49
+ if (!e) return false
50
+ const t = norm(target) // @scope/pkg → scope-pkg
51
+ const tShort = norm(target.replace(/^@[^/]+\//, '')) // @scope/pkg → pkg(无 scope 时与原串一致)
52
+ // 1) 全串相等:覆盖 完整名 == 完整名 / 去@ == 去@ / scope-pkg == scope/pkg 归一化
53
+ if (e === t) return true
54
+ // 2) 条目是去 scope 的短名(bundle patch 手写短名最常见)
55
+ if (e === tShort) return true
56
+ // 3) 反向变体:目标短名可能就是条目的完整形式(条目 `scope-widget`,
57
+ // 目标短名 `widget` 是它的尾段)——用「条目名以短名结尾」收口,避免误伤。
58
+ if (tShort.length >= 2 && e.endsWith(`-${tShort}`)) return true
59
+ return false
60
+ }
61
+
62
+ /**
63
+ * 卸载成功后从运行中 loader 停用指定包的条目,使卸载立即生效(刷新页面不再
64
+ * 加载已卸载的 client bundle,也无需重启)。做法:按 name 扫描
65
+ * `loader.entries()`(含嵌套子树),对每个匹配条目
66
+ * `entry.update({ disabled: true })` 做 live-disable —— fiber 被 dispose,
67
+ * client-modules 对账后不再把它写进 `__DSH_BOOT__`,页面刷新即恢复。
68
+ * 返回 true = 无需重启(条目已停用,或本就没加载过);false = 未能停用,
69
+ * 需登记「待重启清理」兜底。宿主关键包(@deepseek-ai/* 与本插件自身)一律跳过。
70
+ */
71
+ export async function removeLoadedEntry(loader: LoaderHandle, name: string): Promise<boolean> {
72
+ if (name === 'dsh-plugin' || name.startsWith('@deepseek-ai/')) return false
73
+ // 5s 超时兜底:fiber dispose 卡住时不能让卸载任务(乃至整个队列)被拖死
74
+ const removal = (async () => {
75
+ const entries = dumpLoaderEntries(loader)
76
+ const match = entries.find((e) => e.name === name)
77
+ console.log(`[hub-uninstall] loader=${entries.length} entries; target=${name}; match=${match ? match.id : 'none'}; names=${entries.map((e) => e.name ?? '').join(',')}`)
78
+ let found = false
79
+ for (const entry of Array.from(loader.entries())) {
80
+ if (!nameMatches(entry.options?.name, name)) continue
81
+ found = true
82
+ // force=true:disable 可能落在 init 进行中,options 翻转但 fiber 仍会起来,
83
+ // 重试直到实际状态与目标一致
84
+ for (let attempt = 0; attempt < 3; attempt++) {
85
+ try {
86
+ await entry.update({ disabled: true }, false, true)
87
+ break
88
+ } catch (error) {
89
+ console.log(`[hub-uninstall] disable ${name} attempt ${attempt + 1} failed: ${error instanceof Error ? error.message : String(error)}`)
90
+ if (attempt === 2) return false
91
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 200))
92
+ }
93
+ }
94
+ }
95
+ if (!found) {
96
+ // 运行中 loader 无该条目:宿主从未加载过它(装完未重启),磁盘已干净,无需重启
97
+ console.log(`[hub-uninstall] no live entry for ${name}; nothing to disable`)
98
+ }
99
+ return true
100
+ })()
101
+ const result = await Promise.race([
102
+ removal.catch((error) => {
103
+ console.log(`[hub-uninstall] disable failed: ${error instanceof Error ? error.message : String(error)}`)
104
+ return false
105
+ }),
106
+ new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 5_000)),
107
+ ])
108
+ console.log(`[hub-uninstall] disable ${name} -> ${result ? 'ok/clean' : 'timed out or failed'}`)
109
+ return result
110
+ }
@@ -0,0 +1,36 @@
1
+ /** 待重启列表的内存态管理(宿主进程一重启自然清空)。 */
2
+ import type { PendingRestart } from './install-types.ts'
3
+
4
+ const pendingRestarts: PendingRestart[] = []
5
+
6
+ /**
7
+ * 登记一条待重启记录(同一目标去重)。
8
+ * - 卸载:目标刚装完未重启(已有待重启记录)→ 宿主从未加载过它,卸完磁盘已干净、
9
+ * loader 无残留,无需再提醒重启,直接移除该条;否则(此前已被宿主加载过)登记「卸载后待重启清理」。
10
+ * - 安装:已有记录则刷新为安装语义(如卸载后未重启又重装),否则新登记。
11
+ */
12
+ export function addPendingRestart(target: string, kind: 'install' | 'uninstall'): void {
13
+ const existing = pendingRestarts.find((p) => p.target === target)
14
+ if (kind === 'uninstall') {
15
+ if (existing) pendingRestarts.splice(pendingRestarts.indexOf(existing), 1)
16
+ else pendingRestarts.push({ target, kind, at: Date.now() })
17
+ return
18
+ }
19
+ if (existing) {
20
+ existing.kind = 'install'
21
+ existing.at = Date.now()
22
+ } else {
23
+ pendingRestarts.push({ target, kind, at: Date.now() })
24
+ }
25
+ }
26
+
27
+ /** 当前待重启列表快照(副本,消费方不会污染内部数组)。 */
28
+ export function listPendingRestarts(): PendingRestart[] {
29
+ return pendingRestarts.map((p) => ({ ...p }))
30
+ }
31
+
32
+ /** 清除某目标的待重启记录(卸载已即时生效、无需重启时调用)。 */
33
+ export function clearPendingRestart(target: string): void {
34
+ const index = pendingRestarts.findIndex((p) => p.target === target)
35
+ if (index >= 0) pendingRestarts.splice(index, 1)
36
+ }
@@ -0,0 +1,97 @@
1
+ /**
2
+ * 安装前预检:对 `github:` 源的插件,检查其分发(codeload tarball)里是否
3
+ * 真的包含 package.json 声明的入口文件(main / exports["."].default)。
4
+ * git 分发常不提交构建产物(lib/ 等),这类残缺包装完会让宿主重启加载插件树时
5
+ * ERR_MODULE_NOT_FOUND 直接崩溃 —— 预检在动手安装前就把它们拦下,避免白装一次。
6
+ * npm 包信任 registry 的完整性,直接放行;任何无法预检的情况也放行,交给装后
7
+ * 校验(verifyInstalledEntry)兜底,保证预检自身失败不会卡死安装流程。
8
+ */
9
+ import { execFile } from 'node:child_process'
10
+ import { createWriteStream, mkdtempSync, readFileSync, rmSync } from 'node:fs'
11
+ import { get } from 'node:https'
12
+ import { tmpdir } from 'node:os'
13
+ import { join } from 'node:path'
14
+ import { pipeline } from 'node:stream/promises'
15
+ import { promisify } from 'node:util'
16
+
17
+ const execFileAsync = promisify(execFile)
18
+
19
+ /** 预检结果:ok=false 表示分发改入口文件缺失,missing 为该文件在包内的相对路径。 */
20
+ export interface PreflightResult {
21
+ ok: boolean
22
+ missing: string | null
23
+ }
24
+
25
+ export async function preflightTarget(target: string): Promise<PreflightResult> {
26
+ const m = /^github:([^/]+)\/([^/]+)$/.exec(target)
27
+ if (m === null) return { ok: true, missing: null }
28
+ const [, owner, repo] = m
29
+ let commit = ''
30
+ try {
31
+ const { stdout } = await execFileAsync('git', ['ls-remote', `https://github.com/${owner}/${repo}.git`, 'HEAD'])
32
+ commit = stdout.split(/\s+/)[0] ?? ''
33
+ } catch {
34
+ return { ok: true, missing: null }
35
+ }
36
+ if (!commit) return { ok: true, missing: null }
37
+ const dir = mkdtempSync(join(tmpdir(), 'dsh-preflight-'))
38
+ try {
39
+ const tar = join(dir, 'pkg.tar.gz')
40
+ await download(`https://codeload.github.com/${owner}/${repo}/tar.gz/${commit}`, tar)
41
+ const meta = await readTarJson(tar, 'package/package.json')
42
+ if (meta === null) return { ok: true, missing: null }
43
+ const dot = (meta.exports as Record<string, unknown> | undefined)?.['.']
44
+ const resolved = typeof dot === 'string'
45
+ ? dot
46
+ : dot !== null && typeof dot === 'object'
47
+ ? (dot as Record<string, unknown>).default
48
+ : undefined
49
+ const entry = typeof resolved === 'string' ? resolved : typeof meta.main === 'string' ? meta.main : 'index.js'
50
+ const inTar = await hasTarEntry(tar, `package/${entry}`)
51
+ return { ok: inTar, missing: inTar ? null : entry }
52
+ } catch {
53
+ return { ok: true, missing: null }
54
+ } finally {
55
+ rmSync(dir, { recursive: true, force: true })
56
+ }
57
+ }
58
+
59
+ function download(url: string, dest: string): Promise<void> {
60
+ return new Promise((resolve, reject) => {
61
+ get(url, (res) => {
62
+ if (res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 303) {
63
+ res.resume()
64
+ if (res.headers.location) {
65
+ void download(res.headers.location, dest).then(resolve, reject)
66
+ return
67
+ }
68
+ }
69
+ if (res.statusCode !== 200) {
70
+ res.resume()
71
+ reject(new Error(`download failed: HTTP ${res.statusCode ?? '?'}`))
72
+ return
73
+ }
74
+ void pipeline(res, createWriteStream(dest)).then(resolve, reject)
75
+ }).on('error', reject)
76
+ })
77
+ }
78
+
79
+ /** 从 tarball 提取并解析一个 JSON 文件;文件不存在或解析失败返回 null。 */
80
+ async function readTarJson(tar: string, entry: string): Promise<Record<string, unknown> | null> {
81
+ try {
82
+ const { stdout } = await execFileAsync('tar', ['-xzOf', tar, entry], { maxBuffer: 4 * 1024 * 1024 })
83
+ return JSON.parse(stdout) as Record<string, unknown>
84
+ } catch {
85
+ return null
86
+ }
87
+ }
88
+
89
+ /** 检查 tarball 内是否存在指定 entry。 */
90
+ async function hasTarEntry(tar: string, entry: string): Promise<boolean> {
91
+ try {
92
+ const { stdout } = await execFileAsync('tar', ['-tzf', tar, entry], { maxBuffer: 1024 * 1024 })
93
+ return stdout.split(/\r?\n/).some((line) => line === entry)
94
+ } catch {
95
+ return false
96
+ }
97
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * 宿主 profile 与目标校验工具:解析当前 profile、目录定位、安装目标语法校验,
3
+ * 以及 pnpm allowBuilds 白名单的写入(失败恢复路径)。
4
+ */
5
+ import { readFileSync, writeFileSync } from 'node:fs'
6
+ import { homedir } from 'node:os'
7
+ import { join } from 'node:path'
8
+ import { PACKAGE_RE, REPO_RE } from './install-types.ts'
9
+
10
+ /** Resolve the active profile from the booted CLI args, falling back to `web`. */
11
+ export function readProfileArg(fallback = 'web'): string {
12
+ const index = process.argv.indexOf('--profile')
13
+ const candidate = index >= 0 ? process.argv[index + 1] : undefined
14
+ return candidate !== undefined && !candidate.startsWith('-') ? candidate : fallback
15
+ }
16
+
17
+ /** Resolve a profile directory (`DSH_HOME` or `~/.dsh`). */
18
+ export function profileDirectory(profile: string): string {
19
+ return join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'profiles', profile)
20
+ }
21
+
22
+ /** Build a safe `github:<owner>/<repo>` target, or null when the repo is unsafe. */
23
+ export function githubTarget(repo: string): string | null {
24
+ if (typeof repo !== 'string' || !REPO_RE.test(repo)) return null
25
+ return `github:${repo}`
26
+ }
27
+
28
+ /** Validate an npm package name (uninstall target grammar). */
29
+ export function validPackageName(name: string): boolean {
30
+ return typeof name === 'string' && PACKAGE_RE.test(name)
31
+ }
32
+
33
+ /**
34
+ * The exact `allowBuilds` key pnpm printed in its
35
+ * `ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED` hint. For `github:` installs pnpm
36
+ * matches against `name@<resolved fetch URL>` — the URL it actually fetches,
37
+ * e.g. `https://codeload.github.com/<owner>/<repo>/tar.gz/<commit>` — not the
38
+ * `git+ssh://`/branch spec. A bare name or a `git+` spec does not match, so
39
+ * the only reliable key is the one pnpm prints verbatim. Null when absent.
40
+ */
41
+ export function parseAllowBuildsKey(output: string): string | null {
42
+ const m = /^[ \t]*(\S+@\S+):[ \t]*true[ \t]*$/m.exec(output)
43
+ return m === null ? null : m[1].trim()
44
+ }
45
+
46
+ /** Quote a YAML block-mapping key when a plain scalar would be invalid. */
47
+ function quoteYamlKey(key: string): string {
48
+ if (/^[-?:,[\]{}#&*!|>'"%@`]/.test(key) || /:(\s|$)/.test(key)) {
49
+ return `'${key.replace(/'/g, "''")}'`
50
+ }
51
+ return key
52
+ }
53
+
54
+ /**
55
+ * Add one `allowBuilds` entry to the profile's pnpm-workspace.yaml, merging
56
+ * with existing entries and leaving the rest of the yaml intact. Broken
57
+ * placeholder entries (pnpm's failed-install bug writes a literal
58
+ * `set this to true or false` value) are dropped on rewrite.
59
+ */
60
+ export function addAllowBuildsKey(profile: string, key: string): void {
61
+ const file = join(profileDirectory(profile), 'pnpm-workspace.yaml')
62
+ let yaml = ''
63
+ try { yaml = readFileSync(file, 'utf8') } catch { /* created below */ }
64
+ const eol = /\r\n/.test(yaml) ? '\r\n' : '\n'
65
+ const blockRe = /allowBuilds:[ \t]*\r?\n((?:[ \t]+[^\r\n]*\r?\n?)*)/g
66
+ const map = new Map<string, string>()
67
+ for (const match of Array.from(yaml.matchAll(blockRe))) {
68
+ for (const line of match[1].split(/\r?\n/)) {
69
+ const m = /^[ \t]+(\S.*?)\s*:\s*(true|false)?\s*$/.exec(line)
70
+ if (m === null || m[1] === '') continue
71
+ let k = m[1]
72
+ if (k.length >= 2 && ((k[0] === "'" && k[k.length - 1] === "'") || (k[0] === '"' && k[k.length - 1] === '"'))) {
73
+ k = k.slice(1, -1)
74
+ }
75
+ map.set(k, m[2] ?? 'true')
76
+ }
77
+ }
78
+ map.set(key, 'true')
79
+ const block = Array.from(map).map(([k, v]) => ` ${quoteYamlKey(k)}: ${v}`).join(eol)
80
+ const blockText = `allowBuilds:${eol}${block}${eol}`
81
+ let next: string
82
+ if (/allowBuilds:[ \t]*\r?\n/.test(yaml)) {
83
+ let seen = 0
84
+ next = yaml.replace(blockRe, () => (seen++ === 0 ? blockText : ''))
85
+ } else {
86
+ next = `${yaml.replace(/\r?\n?$/, eol)}${blockText}`
87
+ }
88
+ writeFileSync(file, next)
89
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Pure progress-estimation helpers shared by the install runner.
3
+ *
4
+ * The dsh CLI wraps pnpm, which refreshes its `Progress: …` line in place
5
+ * with carriage returns, so raw output arrives as a stream of partial
6
+ * fragments. These helpers normalize one fragment into a clean terminal
7
+ * line and map it onto a 0-100 estimate; keeping them free of I/O makes the
8
+ * estimation logic directly unit-testable (see tests/progress.test.ts).
9
+ */
10
+
11
+ /** Strip the `[out]` / `[err]` source prefix and ANSI escape sequences. */
12
+ export function cleanLine(raw: string): string {
13
+ return raw.replace(/^\[(?:err|out)\]\s*/, '').replace(/\x1b\[[0-9;]*[A-Za-z]/g, '').trim()
14
+ }
15
+
16
+ /**
17
+ * Estimate 0-100 progress from one CLI output line. pnpm emits
18
+ * `Progress: resolved N, reused X, downloaded Y, added Z` during the fetch
19
+ * phase; resolution/install phase lines bump the estimate towards done.
20
+ */
21
+ export function estimateProgress(line: string): number {
22
+ const progress = line.match(/Progress:\s*(.+)/)
23
+ if (progress) {
24
+ let resolved = 0
25
+ let handled = 0
26
+ for (const part of progress[1].split(',')) {
27
+ // pnpm emits `label count` tokens, e.g. `resolved 100, reused 40`
28
+ const [label, raw] = part.trim().split(/\s+/)
29
+ const value = Number(raw)
30
+ if (label === 'resolved') resolved = value
31
+ else if (label === 'reused' || label === 'downloaded' || label === 'added' || label === 'imported') {
32
+ handled += value
33
+ }
34
+ }
35
+ // pnpm 的 resolved 是"解析出的包数",reused/downloaded/added 是"已处理的包数",
36
+ // 两者几乎总是相等,比值没有进度区分度(一出现就贴满 100%)。
37
+ // fetch 阶段只占安装总时长的一小部分(git 依赖之后还要 clone + 跑 prepare 构建),
38
+ // 因此按已处理量在低区间(8-30)缓步推进,把中段留给真正的安装/构建阶段。
39
+ if (resolved > 0 && handled > 0) {
40
+ return Math.min(30, 8 + Math.round((handled / resolved) * 20))
41
+ }
42
+ return resolved > 0 ? 8 : 0
43
+ }
44
+ if (/^dependencies:|^Packages:/i.test(line)) return 85
45
+ if (/^Running\b|prepare|build scripts/i.test(line)) return 60
46
+ if (/^Done in\b/i.test(line)) return 96
47
+ return 0
48
+ }