dsh-plugin 0.1.1 → 1.1.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 (67) hide show
  1. package/README.en.md +56 -28
  2. package/README.md +56 -28
  3. package/client/client.js +2282 -230
  4. package/client/client.js.map +1 -1
  5. package/lib/http/routes.js +432 -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 +66 -0
  10. package/lib/services/loader.js +98 -0
  11. package/lib/services/npm-resolve.js +67 -0
  12. package/lib/services/pending-restart.js +34 -0
  13. package/lib/services/preflight.js +94 -0
  14. package/lib/services/profile.js +88 -0
  15. package/lib/services/progress.js +50 -0
  16. package/lib/services/task-queue.js +457 -0
  17. package/lib/types/http/routes.d.ts +23 -0
  18. package/lib/types/index.d.ts +27 -0
  19. package/lib/types/services/install-types.d.ts +69 -0
  20. package/lib/types/services/install.d.ts +18 -0
  21. package/lib/types/services/installed-versions.d.ts +24 -0
  22. package/lib/types/services/loader.d.ts +43 -0
  23. package/lib/types/services/npm-resolve.d.ts +6 -0
  24. package/lib/types/services/pending-restart.d.ts +13 -0
  25. package/lib/types/services/preflight.d.ts +6 -0
  26. package/lib/types/services/profile.d.ts +24 -0
  27. package/lib/types/services/progress.d.ts +17 -0
  28. package/lib/types/services/task-queue.d.ts +82 -0
  29. package/package.json +27 -6
  30. package/src/client/components/CatalogControls.tsx +104 -0
  31. package/src/client/components/CatalogHeader.tsx +78 -0
  32. package/src/client/components/CatalogList.tsx +75 -0
  33. package/src/client/components/CategoryTabs.tsx +35 -0
  34. package/src/client/components/Dropdown.tsx +80 -0
  35. package/src/client/components/NotificationsModal.tsx +243 -0
  36. package/src/client/components/PluginCard.tsx +104 -0
  37. package/src/client/components/PluginHubSection.tsx +342 -0
  38. package/src/client/components/ProgressView.tsx +23 -0
  39. package/src/client/components/icons.tsx +125 -0
  40. package/src/client/components/modals.tsx +368 -0
  41. package/src/client/hooks/useCatalog.ts +202 -0
  42. package/src/client/hooks/useTaskQueue.ts +502 -0
  43. package/src/client/index.tsx +8 -422
  44. package/src/client/lib/catalog.ts +240 -0
  45. package/src/client/lib/env.ts +16 -0
  46. package/src/client/lib/failures.ts +165 -0
  47. package/src/client/lib/format.ts +22 -0
  48. package/src/client/locales.ts +188 -18
  49. package/src/client/styles/Dropdown.module.css +123 -0
  50. package/src/client/styles/Header.module.css +432 -0
  51. package/src/client/styles/List.module.css +324 -0
  52. package/src/client/styles/Modal.module.css +1006 -0
  53. package/src/client/styles/tokens.module.css +91 -0
  54. package/src/client/types.ts +80 -0
  55. package/src/server/http/routes.ts +432 -0
  56. package/src/server/index.ts +35 -0
  57. package/src/server/services/install-types.ts +69 -0
  58. package/src/server/services/install.ts +18 -0
  59. package/src/server/services/installed-versions.ts +84 -0
  60. package/src/server/services/loader.ts +110 -0
  61. package/src/server/services/npm-resolve.ts +67 -0
  62. package/src/server/services/pending-restart.ts +36 -0
  63. package/src/server/services/preflight.ts +97 -0
  64. package/src/server/services/profile.ts +89 -0
  65. package/src/server/services/progress.ts +48 -0
  66. package/src/server/services/task-queue.ts +492 -0
  67. package/src/client/Section.module.css +0 -589
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Catalog constants and normalizers: site URLs, category metadata, sort keys
3
+ * and the mapping from the online API's short-key payloads to HubPlugin.
4
+ */
5
+ import type { EnvInfo, HubPlugin, LocaleId } from '../types.ts'
6
+ import { MAX_CORE_CHARS, classifyFailure, coreErrorCode, summarizeError } from './failures.ts'
7
+
8
+ /** 插件中心官网地址。 */
9
+ export const SITE_URL = 'https://dsh-plugin.org/'
10
+
11
+ /** 插件中心源码仓库:头部右上角 GitHub 图标的跳转地址 */
12
+ export const GITHUB_URL = 'https://github.com/dshplugin/dsh-plugin-hub'
13
+
14
+ /** 构建时由 tsdown 从 package.json 注入的插件版本号(见 tsdown.config.ts define)。 */
15
+ declare const __PLUGIN_VERSION__: string
16
+
17
+ /**
18
+ * 插件当前版本号,供头部标题展示「DSH-Plugin Hub v0.1.1」。
19
+ * tsdown 构建时用 define 把 __PLUGIN_VERSION__ 替换成 package.json 的版本号;
20
+ * node --test 直接 import 本模块时该标识符不存在,typeof 守卫兜底为空串。
21
+ */
22
+ export const PLUGIN_VERSION: string =
23
+ typeof __PLUGIN_VERSION__ === 'string' ? __PLUGIN_VERSION__ : ''
24
+
25
+ /** dsh-plugin.org 中文页挂在 /zh/ 前缀下,英文在根路径。 */
26
+ export function langPathOf(lang: LocaleId): string {
27
+ return lang === 'zh' ? 'zh/' : ''
28
+ }
29
+
30
+ /**
31
+ * 官网详情页两级路径:/plugins/{ownerSlug}/{slug};
32
+ * 旧数据缺 ownerSlug 时从 repo 推导(卡片详情按钮与弹窗来源行共用)。
33
+ */
34
+ export function pluginDetailUrl(plugin: HubPlugin, langPath: string): string {
35
+ const repo = plugin.source?.repo ?? ''
36
+ return `${SITE_URL}${langPath}plugins/${plugin.ownerSlug ?? repo.split('/')[0]?.toLowerCase() ?? ''}/${plugin.slug}`
37
+ }
38
+
39
+ /**
40
+ * 失败弹窗里仓库地址的跳转目标:官网详情页(含插件收录信息),
41
+ * 与 issue 正文的 Catalog 链接同一形式(单级 /plugins/{repo})。
42
+ */
43
+ export function pluginSiteUrl(repo: string): string {
44
+ return `${SITE_URL}plugins/${repo}`
45
+ }
46
+
47
+ /**
48
+ * 一键反馈 GitHub Issue 的预填链接:标题带「来自 dsh-plugin.org」标识,正文只带
49
+ * 核心信息 —— 原因判定 + 关键错误代码 + 尝试过的安装方式 + 宿主机器环境快照 +
50
+ * 错误核心摘要(完整日志太长,塞进 URL 会被 GitHub 以「request URL too long」拒绝,
51
+ * 故只收集重点)。错误弹窗、失败记录共用此逻辑。
52
+ * env 取不到时为 null,链接照常生成、只是少环境段。
53
+ * attempts(尝试过的安装方式,npm 反查 + 实际执行命令):作者看到我们查过/试过的命令,
54
+ * 组织 scope 与 GitHub 用户名不一致时也能直接指认正确的 npm 包名。
55
+ */
56
+ export function pluginIssueUrl(repo: string, message: string, env?: EnvInfo | null, command?: string, attempts?: string[]): string {
57
+ const title = `[dsh-plugin.org | dsh-plugin-hub] Install/Remove failed: ${repo}`
58
+ // 原因判定:让作者一眼知道这是不是自己插件的问题。
59
+ // [packaging](预检/装后校验拦截)单独列:无论走 npm 还是 git 通道,都是分发物不完整
60
+ // (package.json 声明的入口文件在发布物里缺失),作者照此补齐即可。
61
+ const kind = classifyFailure(message)
62
+ const reason = /\[packaging\]/i.test(message)
63
+ ? 'plugin distribution is incomplete — the entry file declared in package.json is missing from the published package (github tarball or npm package); please commit build output or publish a complete package'
64
+ : kind === 'pluginPrepare'
65
+ ? 'plugin prepare/build script failed during install (packaging/distribution issue)'
66
+ : kind === 'pnpmIgnoredBuild'
67
+ ? 'plugin depends on a native module whose build script pnpm blocks by default (use a prebuilt variant)'
68
+ : 'plugin-side install failure'
69
+ const code = coreErrorCode(message)
70
+ // 按给定核心摘要预算构建完整预填 URL(含 URL 编码);预算可调,供超长时逐档缩小
71
+ const build = (coreChars: number): string => {
72
+ const body = [
73
+ '## Summary',
74
+ `- Cause: ${reason}`,
75
+ ...(code ? [`- Key error: \`${code}\``] : []),
76
+ '',
77
+ // 来源说明:标题(链官网)+ 一句来源(链仓库)+ 实际执行的安装命令(含 --profile)与执行结果,链接由常量动态拼接
78
+ `## [DSH-Plugin 插件中心](${SITE_URL}) · 安装 Plugin 失败错误信息`,
79
+ `本错误信息由 [dsh-plugin-hub](${GITHUB_URL}) 插件中心的安装程序自动生成,随本次安装失败一并提交。`,
80
+ `- 实际执行的安装命令:\`${command ?? `dsh plugin${env?.profile ? ` --profile ${env.profile}` : ''} add github:${repo}`}\``,
81
+ `- 执行结果:安装失败,未能安装该插件。`,
82
+ // 尝试过的安装方式(npm 反查 + 实际执行命令,按先后顺序):作者据此反推正确的
83
+ // npm 包名 —— 组织 scope 与 GitHub 用户名不一致时仅凭仓库名猜不到,作者看到我们
84
+ // 查过/试过的命令就能直接指认
85
+ ...(attempts && attempts.length > 0
86
+ ? ['', '## Attempted install channels(已尝试的安装方式)', ...attempts.map((a) => `- ${a}`)]
87
+ : []),
88
+ // [packaging] 场景:说明两种官方安装通道(npm 包 / git 直装),当前插件在对应通道下分发不完整
89
+ ...(/\[packaging\]/i.test(message)
90
+ ? [
91
+ '',
92
+ '## 安装方式说明',
93
+ 'DSH 插件支持两种官方安装通道:`dsh plugin add <npm-package>`(npm 分发,需发布完整构建产物)与 `dsh plugin add github:owner/repo`(git 直装,仓库需提交构建产物或在 package.json 提供 `prepare` 脚本)。当前插件的分发物缺少 package.json 声明的入口文件,请按所用通道补齐后重新发布。',
94
+ ]
95
+ : []),
96
+ '',
97
+ '## Environment',
98
+ `- Plugin: \`${repo}\``,
99
+ // 宿主机器环境快照:便于作者本地复现;某字段缺失时如实写 unknown,不编造
100
+ ...(env
101
+ ? [
102
+ `- DSH: ${env.dshVersion ? `v${env.dshVersion}` : 'unknown'}`,
103
+ `- Plugin Hub: v${PLUGIN_VERSION}`,
104
+ `- Node: ${env.nodeVersion}`,
105
+ `- OS: ${env.platform} ${env.arch} (${env.release})`,
106
+ `- Profile: \`${env.profile}\``,
107
+ `- DSH Home: \`${env.dshHome}\``,
108
+ ]
109
+ : []),
110
+ // 该插件在插件中心的收录位置(详情页链接)
111
+ `- Catalog: [${pluginSiteUrl(repo)}](${pluginSiteUrl(repo)})`,
112
+ '',
113
+ '## Error (core)',
114
+ '',
115
+ '```',
116
+ // 核心错误收集器:只挑关键行(错误代码/构建失败/退出信息),去重截断,URL 才不至于超长
117
+ summarizeError(message, coreChars),
118
+ '```',
119
+ '',
120
+ '---',
121
+ // 底部署名:报错来自内置在 DeepSeek Harness 里的 dsh-plugin-hub 安装程序
122
+ `This error was produced by the [dsh-plugin-hub](${GITHUB_URL}) installer bundled with DeepSeek Harness.`,
123
+ ].join('\n')
124
+ return `https://github.com/${repo}/issues/new?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`
125
+ }
126
+ // GitHub 请求行上限 8192 字节:先用宽松预算(最多 ~5K 核心错误)构建,超长再逐档缩小,保证最终 URL 不超限
127
+ const MAX_URL_CHARS = 7600
128
+ let url = build(MAX_CORE_CHARS)
129
+ if (url.length > MAX_URL_CHARS) {
130
+ for (const budget of [4000, 2500, 1400, 800, 400]) {
131
+ url = build(budget)
132
+ if (url.length <= MAX_URL_CHARS) break
133
+ }
134
+ }
135
+ return url
136
+ }
137
+
138
+ export const CATEGORY_ORDER = [
139
+ 'interface', 'session', 'memory', 'tools', 'agent', 'workflow',
140
+ 'integration', 'model', 'dev', 'knowledge', 'fun',
141
+ ]
142
+
143
+ /** 分类标签按界面语言取词;未知 key 原样返回。 */
144
+ export function categoryLabel(
145
+ map: Record<string, { zh: string; en: string }>,
146
+ key: string,
147
+ lang: LocaleId,
148
+ ): string {
149
+ return map[key]?.[lang] ?? key
150
+ }
151
+
152
+ /**
153
+ * 归一化在线 API(dsh-plugin.org/api/plugins.{lang}.json)返回的短 key 结构
154
+ * (s/o/n/vr/c/t/f/d/r/v/u/a/sg/fk),统一为 HubPlugin,保证渲染逻辑只认一种结构。
155
+ */
156
+ export function normalize(raw: Record<string, unknown>): HubPlugin {
157
+ if (typeof raw.s === 'string') {
158
+ return {
159
+ slug: raw.s,
160
+ ownerSlug: typeof raw.o === 'string' ? raw.o : undefined,
161
+ displayName: typeof raw.n === 'string' ? raw.n : undefined,
162
+ version: typeof raw.vr === 'string' ? raw.vr : undefined,
163
+ category: typeof raw.c === 'string' ? raw.c : undefined,
164
+ topics: Array.isArray(raw.t) ? (raw.t as string[]) : undefined,
165
+ features: Array.isArray(raw.f) ? (raw.f as string[]) : undefined,
166
+ description: typeof raw.d === 'string' ? raw.d : undefined,
167
+ // 投影输出两种形态:老数据 source 是 repo 字符串;新数据是 { repo, npmPackage }
168
+ source: typeof raw.r === 'string'
169
+ ? { repo: raw.r }
170
+ : raw.r !== null && typeof raw.r === 'object'
171
+ ? {
172
+ repo: typeof (raw.r as { repo?: unknown }).repo === 'string' ? (raw.r as { repo: string }).repo : undefined,
173
+ npmPackage: typeof (raw.r as { npmPackage?: unknown }).npmPackage === 'string' ? (raw.r as { npmPackage: string }).npmPackage : undefined,
174
+ }
175
+ : undefined,
176
+ compatibility: typeof raw.v === 'string' ? { status: raw.v } : undefined,
177
+ dates: {
178
+ repoUpdatedAt: typeof raw.u === 'string' ? raw.u : undefined,
179
+ addedAt: typeof raw.a === 'string' ? raw.a : undefined,
180
+ },
181
+ stats: {
182
+ stargazers_count: typeof raw.sg === 'number' ? raw.sg : undefined,
183
+ forks_count: typeof raw.fk === 'number' ? raw.fk : undefined,
184
+ },
185
+ }
186
+ }
187
+ return raw as unknown as HubPlugin
188
+ }
189
+
190
+ /**
191
+ * 安装通道决策(用户无感知):目录探测到 npm 包名 → 用 npm 包名安装
192
+ * (走 npm registry tarball,更快、与 GitHub 网络无关);无 npm 包名 → git 直装。
193
+ * 返回值 target 即传给后端 /install 的安装目标(npm 包名 或 owner/repo)。
194
+ */
195
+ export function installTargetOf(p: HubPlugin): { target: string; via: 'npm' | 'github' } {
196
+ const pkg = (p.source?.npmPackage ?? '').trim()
197
+ const repo = (p.source?.repo ?? '').trim()
198
+ if (pkg && repo) return { target: pkg, via: 'npm' }
199
+ return { target: repo, via: 'github' }
200
+ }
201
+
202
+ /** 展示用安装命令(复制/弹窗):npm 通道显示包名,git 通道显示 github: 源。 */
203
+ export function installCommandOf(p: HubPlugin, withProfile = false): string {
204
+ const { target, via } = installTargetOf(p)
205
+ return via === 'npm'
206
+ ? `dsh plugin${withProfile ? ' --profile web' : ''} add ${target}`
207
+ : `dsh plugin${withProfile ? ' --profile web' : ''} add github:${target}`
208
+ }
209
+
210
+ export const CATEGORY_LABELS: Record<string, { zh: string; en: string }> = {
211
+ interface: { zh: '界面与体验', en: 'UI & Experience' },
212
+ session: { zh: '会话与消息', en: 'Sessions & Messages' },
213
+ memory: { zh: '记忆与上下文', en: 'Memory & Context' },
214
+ tools: { zh: '工具与能力', en: 'Tools & Capabilities' },
215
+ agent: { zh: '技能与智能体', en: 'Skills & Agents' },
216
+ workflow: { zh: '工作流与自动化', en: 'Workflow & Automation' },
217
+ integration: { zh: '集成与连接', en: 'Integrations & Connections' },
218
+ model: { zh: '模型与推理', en: 'Models & Reasoning' },
219
+ dev: { zh: '开发与运维', en: 'Development & Operations' },
220
+ knowledge: { zh: '数据与知识', en: 'Data & Knowledge' },
221
+ fun: { zh: '娱乐', en: 'Entertainment' },
222
+ }
223
+
224
+ /** 分类 Tab 的紧凑标签(英文模式空间受限时使用)。 */
225
+ export const CATEGORY_SHORT_LABELS: Record<string, { zh: string; en: string }> = {
226
+ interface: { zh: '界面体验', en: 'UI Exp' },
227
+ session: { zh: '会话消息', en: 'Sessions' },
228
+ memory: { zh: '记忆上下文', en: 'Memory' },
229
+ tools: { zh: '工具能力', en: 'Tools' },
230
+ agent: { zh: '技能智能体', en: 'Agents' },
231
+ workflow: { zh: '工作流', en: 'Workflow' },
232
+ integration: { zh: '集成连接', en: 'Integrations' },
233
+ model: { zh: '模型推理', en: 'Models' },
234
+ dev: { zh: '开发运维', en: 'Dev' },
235
+ knowledge: { zh: '数据知识', en: 'Knowledge' },
236
+ fun: { zh: '娱乐', en: 'Fun' },
237
+ }
238
+
239
+ export const SORTS = ['sortStars', 'sortForks', 'sortUpdated', 'sortNewest'] as const
240
+ export type SortKey = (typeof SORTS)[number]
@@ -0,0 +1,16 @@
1
+ /**
2
+ * One-shot host environment snapshot for bug reports. Fetched lazily and
3
+ * cached at module level: issue URLs embed this info, and the environment
4
+ * does not change during a session. A failed fetch degrades to null so the
5
+ * issue links still work — they just ship without the environment block.
6
+ */
7
+ import type { EnvInfo } from '../types.ts'
8
+
9
+ let envPromise: Promise<EnvInfo | null> | null = null
10
+
11
+ export function getEnv(): Promise<EnvInfo | null> {
12
+ envPromise ??= fetch('/dsh-plugin-hub/env', { cache: 'no-store' })
13
+ .then((res) => (res.ok ? (res.json() as Promise<EnvInfo>) : null))
14
+ .catch(() => null)
15
+ return envPromise
16
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Persistent install/remove notifications.
3
+ *
4
+ * Every settled task — success or failure — is appended to localStorage the
5
+ * moment the backend reports it, so a result is never lost even when the
6
+ * dialog was dismissed or nobody was watching the progress strip. Newest
7
+ * first, capped at MAX entries. Failures keep their full output for the
8
+ * issue-filing flow; successes are lightweight (no log).
9
+ */
10
+ export interface NotificationRecord {
11
+ id: number
12
+ kind: 'install' | 'uninstall'
13
+ /** true = 成功(轻量记录);false = 失败(message 携带完整错误日志) */
14
+ ok: boolean
15
+ /** owner/repo(可能为空:请求层失败但拿不到仓库时) */
16
+ repo: string
17
+ /** 失败时的完整错误日志;成功记录为空串 */
18
+ message: string
19
+ /** 实际执行的安装/卸载命令(issue 预填时如实展示);历史记录可能缺失 */
20
+ command?: string
21
+ /** 尝试过的安装方式(npm registry 反查 + 实际执行命令,按先后顺序):失败提 Issue 时贴给作者,便于反推正确的 npm 包名 */
22
+ attempts?: string[]
23
+ /** 结束时间(epoch ms) */
24
+ at: number
25
+ }
26
+
27
+ const KEY = 'gro.ngilp-hsd.failure-records'
28
+ const MAX = 50
29
+
30
+ export type FailureKind = 'pnpmIgnoredBuild' | 'pluginPrepare' | 'repo'
31
+
32
+ /**
33
+ * 失败归类,三态。无论底层机制如何(pnpm 白名单拦截 / 构建脚本被忽略 / prepare 失败),
34
+ * 对用户而言结果都一样 —— 当前安装通道(npm 或 git)装不上,就是插件分发/依赖的问题,
35
+ * 一律引导提 Issue:
36
+ * - pnpmIgnoredBuild:插件依赖里的原生模块构建脚本被 pnpm 默认拦截(如 node-pty,
37
+ * `ERR_PNPM_IGNORED_BUILDS`)。只影响带原生模块的插件,其他插件不受影响 —— 差异在
38
+ * 插件的依赖选择,属插件依赖/打包问题 → 引导去仓库提 Issue(建议改用预编译版本)
39
+ * - pluginPrepare:插件的 prepare/构建脚本实际执行失败(git tarball 常因缺失子模块或
40
+ * 构建产物导致)—— 属插件打包/分发问题,应引导去仓库提 Issue
41
+ * - repo:其余失败(含 git prepare 被 pnpm 白名单拦截等),默认按插件仓库问题引导提 Issue
42
+ */
43
+ export function classifyFailure(message: string): FailureKind {
44
+ // 装后校验拦截(服务端 verifyInstalledEntry 标记):入口文件缺失 = git 分发缺构建产物,
45
+ // 与 pluginPrepare 同类(插件打包/分发问题),引导去仓库提 Issue
46
+ if (/\[packaging\]|entry file missing/i.test(message)) return 'pluginPrepare'
47
+ // 原生模块构建被忽略(IGNORED_BUILDS)要在 prepare 判定之前:dsh 尾部固定提示语带
48
+ // allowBuilds/pnpm-workspace.yaml,不能据此把插件自身问题误判成通用失败
49
+ if (/ERR_PNPM_IGNORED_BUILDS|Ignored build scripts:/i.test(message)) return 'pnpmIgnoredBuild'
50
+ // 再判 prepare 实际执行失败:只有构建脚本真的跑挂了才是插件问题
51
+ if (/ERR_PNPM_PREPARE_PACKAGE|ELIFECYCLE|Command failed|prepare-guard/i.test(message)) return 'pluginPrepare'
52
+ // 其余失败(含 git prepare 被 pnpm 白名单拦截):当前通道装不上 = 插件分发/依赖的问题,一律提 Issue
53
+ return 'repo'
54
+ }
55
+
56
+ /** 核心行特征:错误代码 / 生命周期脚本失败 / prepare 失败 / 描述性报错(子模块缺失、找不到等)/ 退出与宿主提示信息。 */
57
+ const CORE_LINE_RE = /ERR_[A-Z_]+|ELIFECYCLE|Command failed|prepare-guard|Failed to prepare|exit code|\bprepare\b|pnpm failed in profile|git-hosted plugins build|submodule|not found|cannot find|no such|unable to|fatal|missing|error/i
58
+ /** 提交 issue 时正文里错误摘要的上限字符数。GitHub 请求行上限 8192 字节,
59
+ * 固定模板与 URL 编码开销约 1~2K,核心错误(以 ASCII 日志为主)可安全带到 ~5K;
60
+ * 仍超长时 pluginIssueUrl 会逐档缩小核心预算,最终 URL 不会超限。 */
61
+ export const MAX_CORE_CHARS = 5000
62
+ /** 摘要里单行上限:允许构建 key 等长行也被截短。 */
63
+ const MAX_LINE_CHARS = 400
64
+
65
+ /**
66
+ * 核心错误收集器:从完整安装输出里挑出真正说明问题的行(错误代码、构建脚本失败、
67
+ * 退出与宿主提示),去重后拼接,单行与总量都截断 —— 只把「重点 + 原因」带进 issue 正文,
68
+ * 避免完整日志塞进 URL 导致请求过长。无关键行时退化为「头部 + 尾部」快照。
69
+ * maxChars 可由调用方按最终 URL 长度收紧(pluginIssueUrl 超限时逐档缩小)。
70
+ */
71
+ export function summarizeError(message: string, maxChars: number = MAX_CORE_CHARS): string {
72
+ const seen = new Set<string>()
73
+ const core: string[] = []
74
+ for (const raw of message.split(/\r?\n/)) {
75
+ const line = raw.trim()
76
+ if (!line || !CORE_LINE_RE.test(line)) continue
77
+ const short = line.length > MAX_LINE_CHARS ? `${line.slice(0, MAX_LINE_CHARS)}…` : line
78
+ if (!seen.has(short)) {
79
+ seen.add(short)
80
+ core.push(short)
81
+ }
82
+ }
83
+ let out: string
84
+ if (core.length === 0) {
85
+ // 无关键行时退化为「头部 + 尾部」快照:优先保尾部完整,头部按预算压缩,总长不超上限
86
+ const tail = message.trimEnd().slice(-1000)
87
+ const sep = '\n…\n'
88
+ const headBudget = Math.max(maxChars - tail.length - sep.length, 0)
89
+ const head = message.slice(0, 500).trim()
90
+ out = `${head.length > headBudget ? head.slice(0, headBudget) : head}${sep}${tail}`
91
+ } else {
92
+ out = core.join('\n')
93
+ }
94
+ return out.length > maxChars ? `${out.slice(0, maxChars)}\n… (truncated)` : out
95
+ }
96
+
97
+ /** 提取首个错误代码(如 ERR_PNPM_PREPARE_PACKAGE),无则 null。 */
98
+ export function coreErrorCode(message: string): string | null {
99
+ const m = message.match(/\[?ERR_[A-Z_]+\]?/)
100
+ return m ? m[0].replace(/^\[|\]$/g, '') : null
101
+ }
102
+
103
+ /** 运行时 localStorage 访问:类型上不依赖 DOM lib(Node 测试环境也能编译),浏览器里取 window。 */
104
+ const storage = (): Storage | undefined =>
105
+ (globalThis as { localStorage?: Storage }).localStorage
106
+
107
+ /** 读取本地通知记录(损坏/不可用时返回空列表,不抛错)。列表最新在前,全部保留,只由「清空」按钮手动移除。 */
108
+ export function loadNotifications(): NotificationRecord[] {
109
+ try {
110
+ const raw = storage()?.getItem(KEY)
111
+ if (!raw) return []
112
+ const list = JSON.parse(raw) as unknown
113
+ if (!Array.isArray(list)) return []
114
+ return list
115
+ .filter((r): r is NotificationRecord =>
116
+ !!r && typeof r === 'object' && typeof (r as { message?: unknown }).message === 'string')
117
+ // 历史失败记录没有 ok 字段:缺省按失败渲染,保证旧数据照常可查
118
+ .map((r) => ({ ...r, ok: r.ok === true }))
119
+ } catch {
120
+ return []
121
+ }
122
+ }
123
+
124
+ function save(list: NotificationRecord[]): void {
125
+ try {
126
+ storage()?.setItem(KEY, JSON.stringify(list))
127
+ } catch {
128
+ /* storage full / unavailable:仅保留内存态,界面仍可查看 */
129
+ }
130
+ }
131
+
132
+ /** 追加一条通知记录并持久化,返回更新后的完整列表(最新在前,超上限裁剪)。
133
+ * 每次成功/失败都各自留痕:同一插件的安装/卸载记录都完整保留、只由「清空」按钮手动移除,
134
+ * 不会因后续操作被自动覆盖清除。 */
135
+ export function addNotification(record: Omit<NotificationRecord, 'id' | 'at'>): NotificationRecord[] {
136
+ const prev = loadNotifications()
137
+ let id = Date.now()
138
+ while (prev.some((r) => r.id === id)) id += 1
139
+ const next = [{ ...record, id, at: id }, ...prev].slice(0, MAX)
140
+ save(next)
141
+ return next
142
+ }
143
+
144
+ /** 记录一次失败:携带完整错误日志,供通知中心查看/复制/提 Issue。 */
145
+ export function addFailure(record: Omit<NotificationRecord, 'id' | 'at' | 'ok'>): NotificationRecord[] {
146
+ return addNotification({ ...record, ok: false })
147
+ }
148
+
149
+ /** 记录一次成功:轻量记录,不带日志。 */
150
+ export function addSuccess(record: Omit<NotificationRecord, 'id' | 'at' | 'ok' | 'message'>): NotificationRecord[] {
151
+ return addNotification({ ...record, ok: true, message: '' })
152
+ }
153
+
154
+ /** 清空全部通知记录,返回空列表。 */
155
+ export function clearNotifications(): NotificationRecord[] {
156
+ save([])
157
+ return []
158
+ }
159
+
160
+ /** 按 id 删除单条通知记录,返回更新后的列表(仅移除该条,不影响其余记录)。 */
161
+ export function removeNotification(id: number): NotificationRecord[] {
162
+ const next = loadNotifications().filter((r) => r.id !== id)
163
+ save(next)
164
+ return next
165
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Presentation formatters shared across the catalog list and the cards.
3
+ */
4
+ import type { Translate } from '../types.ts'
5
+
6
+ /** 星数/分支数的紧凑展示:<1k 原样;千级 1.2k;十万级取整。 */
7
+ export function fmtStars(count?: number): string {
8
+ if (!count || count <= 0) return '0'
9
+ if (count < 1000) return String(count)
10
+ const k = count / 1000
11
+ return `${k >= 100 ? Math.round(k) : k.toFixed(1).replace(/\.0$/, '')}k`
12
+ }
13
+
14
+ /** 相对时间:今天 / N 天前 / N 个月前 / N 年前。 */
15
+ export function relTime(iso: string | undefined, t: Translate): string {
16
+ if (!iso) return ''
17
+ const days = Math.floor((Date.now() - new Date(iso).getTime()) / 86400000)
18
+ if (days <= 0) return t('today')
19
+ if (days < 30) return t('daysAgo', { days })
20
+ if (days < 365) return t('monthsAgo', { months: Math.floor(days / 30) })
21
+ return t('yearsAgo', { years: Math.floor(days / 365) })
22
+ }