dsh-plugin 0.1.0 → 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.
- package/README.en.md +52 -27
- package/README.md +52 -27
- package/client/client.js +2148 -229
- package/client/client.js.map +1 -1
- package/lib/http/routes.js +396 -0
- package/lib/index.js +17 -8
- package/lib/services/install-types.js +11 -0
- package/lib/services/install.js +4 -0
- package/lib/services/installed-versions.js +51 -0
- package/lib/services/loader.js +98 -0
- package/lib/services/pending-restart.js +34 -0
- package/lib/services/preflight.js +94 -0
- package/lib/services/profile.js +88 -0
- package/lib/services/progress.js +50 -0
- package/lib/services/task-queue.js +417 -0
- package/lib/types/http/routes.d.ts +23 -0
- package/lib/types/index.d.ts +27 -0
- package/lib/types/services/install-types.d.ts +63 -0
- package/lib/types/services/install.d.ts +18 -0
- package/lib/types/services/installed-versions.d.ts +14 -0
- package/lib/types/services/loader.d.ts +43 -0
- package/lib/types/services/pending-restart.d.ts +13 -0
- package/lib/types/services/preflight.d.ts +6 -0
- package/lib/types/services/profile.d.ts +24 -0
- package/lib/types/services/progress.d.ts +17 -0
- package/lib/types/services/task-queue.d.ts +76 -0
- package/package.json +27 -6
- package/src/client/components/CatalogControls.tsx +104 -0
- package/src/client/components/CatalogHeader.tsx +78 -0
- package/src/client/components/CatalogList.tsx +75 -0
- package/src/client/components/CategoryTabs.tsx +35 -0
- package/src/client/components/Dropdown.tsx +80 -0
- package/src/client/components/NotificationsModal.tsx +230 -0
- package/src/client/components/PluginCard.tsx +104 -0
- package/src/client/components/PluginHubSection.tsx +339 -0
- package/src/client/components/ProgressView.tsx +23 -0
- package/src/client/components/icons.tsx +125 -0
- package/src/client/components/modals.tsx +365 -0
- package/src/client/hooks/useCatalog.ts +192 -0
- package/src/client/hooks/useTaskQueue.ts +405 -0
- package/src/client/index.tsx +8 -422
- package/src/client/lib/catalog.ts +204 -0
- package/src/client/lib/env.ts +16 -0
- package/src/client/lib/failures.ts +153 -0
- package/src/client/lib/format.ts +22 -0
- package/src/client/locales.ts +186 -18
- package/src/client/styles/Dropdown.module.css +123 -0
- package/src/client/styles/Header.module.css +432 -0
- package/src/client/styles/List.module.css +324 -0
- package/src/client/styles/Modal.module.css +965 -0
- package/src/client/styles/tokens.module.css +86 -0
- package/src/client/types.ts +80 -0
- package/src/server/http/routes.ts +397 -0
- package/src/server/index.ts +35 -0
- package/src/server/services/install-types.ts +63 -0
- package/src/server/services/install.ts +18 -0
- package/src/server/services/installed-versions.ts +65 -0
- package/src/server/services/loader.ts +110 -0
- package/src/server/services/pending-restart.ts +36 -0
- package/src/server/services/preflight.ts +97 -0
- package/src/server/services/profile.ts +89 -0
- package/src/server/services/progress.ts +48 -0
- package/src/server/services/task-queue.ts +450 -0
- package/src/client/Section.module.css +0 -569
|
@@ -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
|
+
}
|