dsh-github-flow 0.2.0 → 0.3.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 (44) hide show
  1. package/README.en.md +68 -22
  2. package/README.md +69 -23
  3. package/docs/UPGRADE-NOTES.md +93 -0
  4. package/docs/architecture.md +76 -31
  5. package/docs/images/1.png +0 -0
  6. package/docs/images/2.png +0 -0
  7. package/docs/images/3.png +0 -0
  8. package/docs/images/4.png +0 -0
  9. package/docs/npm-publish-guide.md +27 -5
  10. package/lib/api/routes-actions.d.ts +18 -4
  11. package/lib/api/routes-actions.js +143 -19
  12. package/lib/api/routes-lifecycle.d.ts +1 -1
  13. package/lib/api/routes-lifecycle.js +53 -2
  14. package/lib/api/routes-overview.js +104 -1
  15. package/lib/client.js +991 -55
  16. package/lib/commands/gh.js +11 -2
  17. package/lib/executor.d.ts +118 -1
  18. package/lib/executor.js +479 -12
  19. package/lib/main.js +10 -0
  20. package/lib/renderers/markdown.d.ts +12 -1
  21. package/lib/renderers/markdown.js +150 -16
  22. package/lib/tools/help.js +144 -11
  23. package/lib/tools/image-utils.d.ts +79 -0
  24. package/lib/tools/image-utils.js +247 -0
  25. package/lib/tools/issue.d.ts +18 -4
  26. package/lib/tools/issue.js +161 -6
  27. package/lib/tools/pr.d.ts +12 -4
  28. package/lib/tools/pr.js +128 -7
  29. package/lib/tools/repo.d.ts +20 -4
  30. package/lib/tools/repo.js +196 -4
  31. package/lib/tools/run.d.ts +8 -0
  32. package/lib/tools/run.js +104 -3
  33. package/lib/tools/secret.d.ts +5 -0
  34. package/lib/tools/secret.js +108 -23
  35. package/lib/types.d.ts +85 -1
  36. package/lib/update-settings.d.ts +18 -0
  37. package/lib/update-settings.js +60 -0
  38. package/lib/updater.d.ts +32 -1
  39. package/lib/updater.js +117 -9
  40. package/lib/upgrade-manager.d.ts +70 -1
  41. package/lib/upgrade-manager.js +346 -46
  42. package/lib/version.d.ts +1 -1
  43. package/lib/version.js +1 -1
  44. package/package.json +1 -1
@@ -45,17 +45,63 @@ function renderSecretOutput(args, value) {
45
45
  }
46
46
  return JSON.stringify(value, null, 2);
47
47
  }
48
+ /**
49
+ * 渲染 variable 工具输出——与 renderSecretOutput 物理分离(S3 D1 决策)。
50
+ * 为什么必须独立函数而非同一渲染器的分支:variable 的 value 是 CI 明文(可回显,
51
+ * 这正是 get action 的用途),secret 的 value 永不显示。若共用渲染逻辑再
52
+ * 「选择性放开」,未来任一侧的重构都可能意外污染另一侧的安全假设——
53
+ * 两个函数的边界比一个函数的分支更可审。
54
+ */
55
+ function renderVariableOutput(args, value) {
56
+ if (!value)
57
+ return '无返回内容';
58
+ if (value.status === 'AWAITING_CONFIRMATION') {
59
+ return [
60
+ `> ### ${value.warning}`,
61
+ `> - **影响评估**: ${value.impact}`,
62
+ `> - **确认指引**: ${value.requiredAction}`,
63
+ ].join('\n');
64
+ }
65
+ if (Array.isArray(value.items) && args?.action === 'list') {
66
+ const list = value.items.filter(Boolean);
67
+ if (list.length === 0)
68
+ return '📭 当前作用域尚未配置任何 Variable。';
69
+ const rows = list.map((v) => {
70
+ const name = `\`${String(v.name || '-')}\``;
71
+ const date = v.createdAt ? String(v.createdAt).slice(0, 10) : '-';
72
+ return `| ${name} | ${date} |`;
73
+ });
74
+ return [
75
+ `**当前已配置 ${list.length} 个 Variable(值为 CI 明文,可经 get 查看):**\n`,
76
+ '| 变量名 | 创建日期 |',
77
+ '| :--- | :--- |',
78
+ ...rows,
79
+ ].join('\n');
80
+ }
81
+ if (typeof value.result === 'string') {
82
+ return value.result;
83
+ }
84
+ if (typeof value.message === 'string') {
85
+ return `**${value.message}**`;
86
+ }
87
+ return JSON.stringify(value, null, 2);
88
+ }
48
89
  export function createSecretTool(executor) {
49
90
  return {
50
91
  name: 'github_secret',
51
- description: '管理 GitHub Actions 仓库密钥 (Secrets):查看已配置的密钥名称列表、设置/更新密钥值(如 NPM_TOKEN、部署密钥)。密文经标准输入安全传递,绝不出现在命令行参数中,也不会在输出中回显。删除需 confirm: true。',
92
+ description: '管理 GitHub Actions 仓库密钥 (Secrets) 与变量 (Variables):查看名称清单、设置/更新/删除值。Secret 密文经标准输入安全传递,绝不出现在命令行参数或输出中;Variable 为 CI 明文,可经 get 查看值。Secret 删除需 confirm: true。',
52
93
  parameters: {
53
94
  type: 'object',
54
95
  properties: {
55
96
  action: {
56
97
  type: 'string',
57
- enum: ['list', 'set', 'delete'],
58
- description: 'Secret 操作类型。',
98
+ enum: ['list', 'get', 'set', 'delete'],
99
+ description: '操作类型。get 仅 kind=variable 可用(secret 值写入后 GitHub 侧加密存储,物理不可读取)。',
100
+ },
101
+ kind: {
102
+ type: 'string',
103
+ enum: ['secret', 'variable'],
104
+ description: '操作对象类型,默认 secret(向后兼容)。variable 为 Actions 明文变量:set 走 -b 直传、get 可回显值;secret 走 stdin 密文通道且永不回显。',
59
105
  },
60
106
  repo: {
61
107
  type: 'string',
@@ -63,33 +109,33 @@ export function createSecretTool(executor) {
63
109
  },
64
110
  name: {
65
111
  type: 'string',
66
- description: '密钥名称(set/delete 动作使用,如 "NPM_TOKEN"、"DOCKER_PASSWORD")。',
112
+ description: '名称(set/get/delete 动作使用,如 "NPM_TOKEN"、"DEPLOY_ENV")。',
67
113
  },
68
114
  value: {
69
115
  type: 'string',
70
- description: '密钥明文内容(set 动作使用)。内部通过子进程标准输入传递,避免在进程命令行被 ps/任务管理器窃取。',
116
+ description: '值内容(set 动作使用)。secret:经子进程标准输入传递(防进程列表窃取);variable:经 -b 参数直传(CI 明文,无 stdin 红线)。',
71
117
  },
72
118
  org: {
73
119
  type: 'string',
74
- description: '组织级密钥的所属组织名(可选,映射 --org)。',
120
+ description: '组织级作用域的组织名(可选,映射 --org)。',
75
121
  },
76
122
  env: {
77
123
  type: 'string',
78
- description: '环境级密钥的部署环境名(可选,映射 --env)。',
124
+ description: '环境级作用域的部署环境名(可选,映射 --env)。',
79
125
  },
80
126
  app: {
81
127
  type: 'string',
82
128
  enum: ['actions', 'agents', 'codespaces', 'dependabot'],
83
- description: '密钥归属的应用(可选,映射 --app,默认 actions)。',
129
+ description: '密钥归属的应用(可选,仅 secret 的 --app,默认 actions;variable 无此维度)。',
84
130
  },
85
131
  visibility: {
86
132
  type: 'string',
87
133
  enum: ['all', 'private', 'selected'],
88
- description: '组织级密钥的可见范围(可选,仅 org 场景有意义)。',
134
+ description: '组织级密钥的可见范围(可选,仅 secret + org 场景有意义)。',
89
135
  },
90
136
  confirm: {
91
137
  type: 'boolean',
92
- description: '删除密钥时的确认标志。删除不可逆,未传 true 时仅输出影响评估。',
138
+ description: '删除操作的确认标志。删除不可逆,未传 true 时仅输出影响评估。',
93
139
  },
94
140
  },
95
141
  required: ['action'],
@@ -97,13 +143,16 @@ export function createSecretTool(executor) {
97
143
  output: {
98
144
  schema: { type: 'object' },
99
145
  render(args, value) {
100
- return [{ type: 'text', text: renderSecretOutput(args, value) }];
146
+ // 渲染分流即安全边界:variable 与 secret 永不共享渲染路径(见函数注释)
147
+ const renderer = args?.kind === 'variable' ? renderVariableOutput : renderSecretOutput;
148
+ return [{ type: 'text', text: renderer(args, value) }];
101
149
  },
102
150
  },
103
151
  async execute(args, execContext) {
104
152
  const cwd = execContext?.cwd || process.cwd();
105
153
  const sessionId = execContext?.sessionId || execContext?.session?.id || process.env.DSH_SESSION_ID;
106
- // org 作用域的密钥挂在组织上,与具体仓库无关:既不需要仓库识别,
154
+ const isVariable = args.kind === 'variable';
155
+ // org 作用域挂在组织上,与具体仓库无关:既不需要仓库识别,
107
156
  // 也不能把 -R 与 --org 同时传给 gh(两者语义冲突,gh 会报错)。
108
157
  const isOrgScope = Boolean(args.org);
109
158
  let repoTarget = args.repo;
@@ -114,34 +163,67 @@ export function createSecretTool(executor) {
114
163
  }
115
164
  }
116
165
  if (!repoTarget && !isOrgScope) {
117
- throw new Error('未能自动识别当前工作区关联的 GitHub 仓库。请通过 `repo` 参数显式指定目标仓库 [owner/repo],或改用 `org` 参数操作组织级密钥。');
166
+ throw new Error('未能自动识别当前工作区关联的 GitHub 仓库。请通过 `repo` 参数显式指定目标仓库 [owner/repo],或改用 `org` 参数操作组织级作用域。');
167
+ }
168
+ // app 限定符仅 secret 域存在(variable 命令无 --app);误传时明确报错而非静默忽略
169
+ if (isVariable && args.app) {
170
+ throw new Error("variable 无 --app 维度(app 仅 secret 域支持),请移除 app 参数。");
171
+ }
172
+ if (isVariable && args.visibility) {
173
+ throw new Error("variable 无 --visibility 维度(visibility 仅组织级 secret 支持),请移除 visibility 参数。");
118
174
  }
119
175
  const runOpts = { cwd, sessionId };
120
176
  // org/env/app 三类作用域标志对所有动作通用;仓库作用域时三项均不追加
121
177
  const scopeFlags = [];
122
178
  if (args.org) {
123
179
  scopeFlags.push('--org', args.org);
124
- if (args.visibility)
180
+ if (!isVariable && args.visibility)
125
181
  scopeFlags.push('--visibility', args.visibility);
126
182
  }
127
183
  else if (args.env) {
128
184
  scopeFlags.push('--env', args.env);
129
185
  }
130
- if (args.app)
186
+ if (!isVariable && args.app)
131
187
  scopeFlags.push('--app', args.app);
132
188
  const targetArgs = isOrgScope ? [] : ['-R', repoTarget];
189
+ // gh 命令域分流:secret → gh secret …;variable → gh variable …
190
+ // (两级分流:域前缀 + 每个分支内的行为差异——stdin vs -b、get 可用性)
191
+ const domain = isVariable ? 'variable' : 'secret';
133
192
  switch (args.action) {
134
193
  case 'list': {
135
- const res = await executor.run(['secret', 'list', ...targetArgs, ...scopeFlags, '--json', 'name,updatedAt,visibility'], runOpts);
194
+ // 字段集各自实测钉死:secret 有 updatedAt/visibility;variable 仅 4 字段
195
+ // (createdAt,name,numSelectedRepos,selectedReposURL——无 value,get 才返回)
196
+ const fields = isVariable ? 'name,createdAt,numSelectedRepos' : 'name,updatedAt,visibility';
197
+ const res = await executor.run([domain, 'list', ...targetArgs, ...scopeFlags, '--json', fields], runOpts);
136
198
  if (!res.ok)
137
199
  throw new Error(res.error);
138
200
  return normalizeOutput(res.data || res.rawOutput);
139
201
  }
202
+ case 'get': {
203
+ if (!args.name)
204
+ throw new Error('get 操作必须提供 name');
205
+ if (!isVariable) {
206
+ throw new Error('secret 的值写入后由 GitHub 侧加密存储,物理上不可读取(这正是 secret 与 variable 的设计区别)。请改用 kind: \'variable\' 或直接 set 覆盖。');
207
+ }
208
+ // gh variable get 输出变量值(纯文本,无 --json)
209
+ const res = await executor.run(['variable', 'get', args.name, ...targetArgs, ...scopeFlags], { ...runOpts, rawText: true });
210
+ if (!res.ok)
211
+ throw new Error(res.error);
212
+ return { name: args.name, value: res.rawOutput || '' };
213
+ }
140
214
  case 'set': {
141
215
  if (!args.name)
142
- throw new Error('set 操作必须提供 name(密钥名称)');
216
+ throw new Error('set 操作必须提供 name');
143
217
  if (typeof args.value !== 'string' || args.value.length === 0) {
144
- throw new Error('set 操作必须提供 value(密钥明文),且不允许为空字符串');
218
+ throw new Error('set 操作必须提供 value,且不允许为空字符串');
219
+ }
220
+ if (isVariable) {
221
+ // variable 是 CI 明文(workflow 中 env 直接引用可见),无 secret 的
222
+ // stdin 红线;gh variable set 原生支持 -b 直传(实测 help)。
223
+ const res = await executor.run(['variable', 'set', args.name, '--body', args.value, ...targetArgs, ...scopeFlags], runOpts);
224
+ if (!res.ok)
225
+ throw new Error(res.error);
226
+ return { message: `Variable「${args.name}」已更新(值: ${args.value})` };
145
227
  }
146
228
  // 安全红线:密文只走 stdin。严禁退化为 -b <value> —— 那会把明文
147
229
  // 留在进程列表与 shell 历史里,等于密钥泄漏。gh 本地会用仓库公钥
@@ -154,21 +236,24 @@ export function createSecretTool(executor) {
154
236
  case 'delete': {
155
237
  if (!args.name)
156
238
  throw new Error('delete 操作必须提供 name');
239
+ const kindLabel = isVariable ? 'Variable' : 'Secret';
157
240
  if (args.confirm !== true) {
158
241
  return {
159
242
  status: 'AWAITING_CONFIRMATION',
160
- warning: `⚠️ 高危操作拦截:你即将删除 Secret「${args.name}」${args.org ? `(组织 ${args.org})` : ''}。`,
161
- impact: '删除后所有引用该密钥的 CI/CD 工作流将立即无法取到值,运行中的构建会失败。',
243
+ warning: `⚠️ 高危操作拦截:你即将删除 ${kindLabel}「${args.name}」${args.org ? `(组织 ${args.org})` : ''}。`,
244
+ impact: isVariable
245
+ ? '删除后所有引用该变量的工作流将取不到值,运行中的构建立即失败。'
246
+ : '删除后所有引用该密钥的 CI/CD 工作流将立即无法取到值,运行中的构建会失败。',
162
247
  requiredAction: '如确认删除,请向用户核实并附加 `confirm: true` 重新调用。',
163
248
  };
164
249
  }
165
- const res = await executor.run(['secret', 'delete', args.name, ...targetArgs, ...scopeFlags], runOpts);
250
+ const res = await executor.run([domain, 'delete', args.name, ...targetArgs, ...scopeFlags], runOpts);
166
251
  if (!res.ok)
167
252
  throw new Error(res.error);
168
- return { message: `Secret「${args.name}」已删除` };
253
+ return { message: `${kindLabel}「${args.name}」已删除` };
169
254
  }
170
255
  default:
171
- throw new Error(`未知的 Secret 操作: ${args.action}`);
256
+ throw new Error(`未知的 Secret/Variable 操作: ${args.action}(kind: ${args.kind || 'secret'})`);
172
257
  }
173
258
  },
174
259
  };
package/lib/types.d.ts CHANGED
@@ -14,12 +14,22 @@ export interface PluginConfig {
14
14
  cacheTtlMs?: number;
15
15
  /** 查询列表默认限制条数,默认 20 */
16
16
  defaultListLimit?: number;
17
- /** 是否开启新版本自动检查提示,默认 true */
17
+ /**
18
+ * 是否开启新版本自动检查提示,默认 true。
19
+ * false 时熔断全部版本探测入口(Web 横幅/启动日志/概览数据),用户显式
20
+ * 请求升级(如一键升级按钮)不受此限制——关闭的是「提示」,不是「能力」。
21
+ */
18
22
  checkUpdate?: boolean;
19
23
  /** 检查更新间隔毫秒数,默认 86,400,000 (24小时) */
20
24
  updateCheckIntervalMs?: number;
21
25
  /** 自定义 npm Registry 镜像地址,留空则自动选用 npmmirror 与 npmjs */
22
26
  registryUrl?: string;
27
+ /**
28
+ * 是否向 gh 子进程注入 AI_AGENT='dsh' 环境变量,默认 true。
29
+ * gh ≥2.99 检测该变量后切换 agent 专属行为(text-only spinner、误用时打印完整 help);
30
+ * 关闭后恢复 gh 原生行为(用于对照实验或偏好原生输出的场景)。
31
+ */
32
+ agentEnvInjection?: boolean;
23
33
  /**
24
34
  * 代理地址预置默认值(如 "http://127.0.0.1:7890")。
25
35
  * 仅在用户未在驾驶舱保存过代理设置时生效;运行时 UI 保存的值优先级更高。
@@ -38,6 +48,11 @@ export interface GhExecutionOptions {
38
48
  env?: NodeJS.ProcessEnv;
39
49
  /** 是否强制以纯文本形式返回,不进行 JSON 尝试解析 */
40
50
  rawText?: boolean;
51
+ /**
52
+ * 二进制输出模式:stdout 保持 Buffer 原样返回(不做 UTF-8 转换与截断)。
53
+ * 用于 gh api 下载图片/附件等字节流场景;GhResult.data 此时是 Buffer。
54
+ */
55
+ binary?: boolean;
41
56
  /**
42
57
  * 写入子进程标准输入的内容。
43
58
  * 为什么需要:gh secret set 不带 -b 时从 stdin 读取密文,这是唯一不把敏感值
@@ -52,6 +67,8 @@ export interface GhResult<T = unknown> {
52
67
  error?: string;
53
68
  /** 是否因超出安全阈值而被截断 */
54
69
  truncated?: boolean;
70
+ /** 二进制模式下的字节长度(data 为 Buffer 时存在) */
71
+ binaryBytes?: number;
55
72
  }
56
73
  export interface AuthInstallGuide {
57
74
  /** 推荐包管理器命令,例如 Windows 下 "winget install --id GitHub.cli" */
@@ -85,6 +102,52 @@ export interface RepoMetadata {
85
102
  isPrivate?: boolean;
86
103
  url?: string;
87
104
  }
105
+ /**
106
+ * 本地 Git 工作区状态快照(ahead/behind 是 remote-tracking ref 的「上次 fetch」视图,
107
+ * 非实时网络状态——UI 渲染时必须带上这一语义提示,否则会误导用户)。
108
+ */
109
+ export interface GitLocalStatus {
110
+ branch: string;
111
+ /** 领先远程的提交数;无 upstream 时为 -1(未知) */
112
+ ahead: number;
113
+ /** 落后远程的提交数;无 upstream 时为 -1(未知) */
114
+ behind: number;
115
+ /** 未提交变更文件数(含 untracked) */
116
+ dirtyCount: number;
117
+ hasUpstream: boolean;
118
+ /** 未提交文件清单(porcelain 解析,截断前 20 条防快照膨胀) */
119
+ dirtyFiles: Array<{
120
+ status: string;
121
+ path: string;
122
+ }>;
123
+ }
124
+ /** 单条提交记录(结构化解析) */
125
+ export interface GitCommitEntry {
126
+ sha: string;
127
+ fullSha?: string;
128
+ author: string;
129
+ email?: string;
130
+ date?: string;
131
+ /** git 的相对时间原文(如 "2 hours ago")——本地化由渲染层处理 */
132
+ relative: string;
133
+ subject: string;
134
+ body?: string;
135
+ stat?: string;
136
+ }
137
+ /**
138
+ * 本地 Git 提交记录三段视图(与 GitLocalStatus 的 ahead/behind 计数同口径,
139
+ * 均为 remote-tracking 快照)。
140
+ */
141
+ export interface GitCommitLog {
142
+ /** 本地领先远程的提交(待 push) */
143
+ aheadCommits: GitCommitEntry[];
144
+ /** 远程领先本地的提交(待 pull,快照口径) */
145
+ behindCommits: GitCommitEntry[];
146
+ /** 最近提交(默认 15 条,支持下滚分页) */
147
+ recent: GitCommitEntry[];
148
+ /** 仓库总提交数(rev-list --count HEAD) */
149
+ totalCount?: number;
150
+ }
88
151
  export interface PrItem {
89
152
  number: number;
90
153
  title: string;
@@ -159,6 +222,25 @@ export interface GitHubOverviewData {
159
222
  error?: string;
160
223
  /** 插件版本与升级信息(由内存缓存快速读取,不阻塞首屏业务) */
161
224
  updateInfo?: UpdateInfo;
225
+ /** 本地 Git 状态(ahead/behind/dirty,remote-tracking 快照语义);非 Git 工作区为 undefined */
226
+ gitStatus?: GitLocalStatus | null;
227
+ /** 提交记录三段(本地领先/远程领先/最近);探测失败为 undefined */
228
+ commitLog?: GitCommitLog | null;
229
+ /** 最新 Release 锚定(已过滤 Draft);无 Release 仓库为 null */
230
+ releaseInfo?: {
231
+ tagName: string;
232
+ isPrerelease?: boolean;
233
+ publishedAt?: string;
234
+ url?: string;
235
+ } | null;
236
+ /** 当前分支关联的 PR;无 PR 分支为 null */
237
+ branchPr?: {
238
+ number: number;
239
+ title: string;
240
+ state?: string;
241
+ url?: string;
242
+ isDraft?: boolean;
243
+ } | null;
162
244
  }
163
245
  export interface UserRepoItem {
164
246
  name: string;
@@ -177,6 +259,8 @@ export interface WorkspaceMatrixItem {
177
259
  hasGit: boolean;
178
260
  repo?: RepoMetadata;
179
261
  sessionCount: number;
262
+ /** 本地 Git 状态摘要(可选——探测失败/非 Git 工作区不携带) */
263
+ gitStatus?: GitLocalStatus;
180
264
  }
181
265
  export interface GlobalIssueItem {
182
266
  number: number;
@@ -0,0 +1,18 @@
1
+ export type UpdateSourceMode = 'official' | 'mirror' | 'off';
2
+ /** mode 白名单校验:持久化文件可能被手改成任意串,不能成为绕过校验的后门 */
3
+ export declare function isValidUpdateMode(v: unknown): v is UpdateSourceMode;
4
+ /**
5
+ * 读取持久化设置。容灾语义:文件不存在/损坏/mode 非法 → 返回默认 'official',
6
+ * 绝不让设置读取失败阻断插件启动(默认值即官方源,与 UI 默认选中一致)。
7
+ */
8
+ export declare function loadUpdateSettings(): UpdateSourceMode;
9
+ /**
10
+ * 原子写(tmp + rename):并发/崩溃不产生半截 JSON。写盘失败不抛出——
11
+ * 内存态已生效,仅丢失重启恢复能力(尽力持久化语义,同 proxy.ts persist)。
12
+ */
13
+ export declare function saveUpdateSettings(mode: UpdateSourceMode): {
14
+ ok: boolean;
15
+ error?: string;
16
+ };
17
+ /** mode → 人类可读名(命令行输出与 UI 共用,避免文案漂移) */
18
+ export declare function describeUpdateMode(mode: UpdateSourceMode): string;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * 更新源设置轻量 store(官方源 / 国内镜像 / 关闭检查)。
3
+ *
4
+ * 为什么独立于 Cordis Config:更新源变更若走 Loader 会触发插件全量 reload
5
+ * (工具注销重注册、面板断流),而它是高频试错型设置,必须保存即生效——
6
+ * 与 proxy.json 完全同范式:~/.dsh/data/dsh-github-flow/update-settings.json。
7
+ */
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ /** 持久化路径:与 proxy.json 同目录(DSH 用户数据目录规范) */
12
+ function resolveStorePath() {
13
+ return path.join(os.homedir(), '.dsh', 'data', 'dsh-github-flow', 'update-settings.json');
14
+ }
15
+ /** mode 白名单校验:持久化文件可能被手改成任意串,不能成为绕过校验的后门 */
16
+ export function isValidUpdateMode(v) {
17
+ return v === 'official' || v === 'mirror' || v === 'off';
18
+ }
19
+ /**
20
+ * 读取持久化设置。容灾语义:文件不存在/损坏/mode 非法 → 返回默认 'official',
21
+ * 绝不让设置读取失败阻断插件启动(默认值即官方源,与 UI 默认选中一致)。
22
+ */
23
+ export function loadUpdateSettings() {
24
+ try {
25
+ const raw = JSON.parse(fs.readFileSync(resolveStorePath(), 'utf8'));
26
+ const mode = (raw && typeof raw === 'object') ? raw.mode : undefined;
27
+ if (isValidUpdateMode(mode))
28
+ return mode;
29
+ }
30
+ catch { /* 不存在/坏 JSON → 默认值 */ }
31
+ return 'official';
32
+ }
33
+ /**
34
+ * 原子写(tmp + rename):并发/崩溃不产生半截 JSON。写盘失败不抛出——
35
+ * 内存态已生效,仅丢失重启恢复能力(尽力持久化语义,同 proxy.ts persist)。
36
+ */
37
+ export function saveUpdateSettings(mode) {
38
+ if (!isValidUpdateMode(mode))
39
+ return { ok: false, error: `非法更新源模式: ${String(mode)}` };
40
+ try {
41
+ const storePath = resolveStorePath();
42
+ fs.mkdirSync(path.dirname(storePath), { recursive: true });
43
+ const tmp = `${storePath}.${process.pid}.tmp`;
44
+ fs.writeFileSync(tmp, JSON.stringify({ mode, savedAt: new Date().toISOString() }, null, 2), 'utf8');
45
+ fs.renameSync(tmp, storePath);
46
+ return { ok: true };
47
+ }
48
+ catch (e) {
49
+ console.warn(`[dsh-github-flow] 更新源设置写盘失败(本次会话仍生效): ${e?.message || e}`);
50
+ return { ok: false, error: e?.message || String(e) };
51
+ }
52
+ }
53
+ /** mode → 人类可读名(命令行输出与 UI 共用,避免文案漂移) */
54
+ export function describeUpdateMode(mode) {
55
+ if (mode === 'mirror')
56
+ return '国内镜像 (npmmirror)';
57
+ if (mode === 'off')
58
+ return '不检查更新';
59
+ return 'npm 官方源';
60
+ }
package/lib/updater.d.ts CHANGED
@@ -1,4 +1,16 @@
1
1
  import type { UpdateInfo } from './types.js';
2
+ import type { UpdateSourceMode } from './update-settings.js';
3
+ /**
4
+ * 检测当前 DSH 运行 profile 名(升级命令的 --profile 必填项,官方 CLI 语义:
5
+ * `dsh plugin --profile <name> add <pkg>` 在 profile 目录内转发给 pnpm)。
6
+ * 探测优先级(从权威到兜底,不瞎猜):
7
+ * 1. DSH_PROFILE 环境变量——launcher 启动时固定注入(桌面版=desktop,
8
+ * `dsh --profile web` 启动的 Web 版=web),是运行进程对自身 profile 的事实源;
9
+ * 2. DSH_PROFILE_DIR 路径含 desktop 时判定 desktop;
10
+ * 3. 兜底探测 ~/.dsh/profiles 下实际存在的 profile 目录(web > default 顺序),
11
+ * 两者皆无才回落 'default'(dsh CLI 的默认 profile 名)。
12
+ */
13
+ export declare function detectProfileName(): string;
2
14
  /**
3
15
  * 极简语义化版本大小比较函数 (latest > current 时返回 true)
4
16
  *
@@ -21,9 +33,17 @@ export declare class UpdateChecker {
21
33
  private inFlightPromise;
22
34
  private readonly intervalMs;
23
35
  private readonly customRegistry?;
36
+ /** 配置总开关(checkUpdate: false 时的自动探测熔断),见 check() 内注释 */
37
+ private readonly enabled;
38
+ /**
39
+ * 当前更新源模式。null = 未初始化(走 legacy 双源竞速,兼容 store 不存在
40
+ * 且无 Cordis registryUrl 的老安装);一旦 UI 保存过任何 mode 即为权威。
41
+ */
42
+ private sourceMode;
24
43
  constructor(options?: {
25
44
  intervalMs?: number;
26
45
  customRegistry?: string;
46
+ enabled?: boolean;
27
47
  });
28
48
  /**
29
49
  * 应用代理到 npm 探测的 fetch 链路。
@@ -34,7 +54,18 @@ export declare class UpdateChecker {
34
54
  applyFetchDispatcher(): boolean;
35
55
  } | null): void;
36
56
  /**
37
- * 同步获取当前已知的版本状态(0ms 纯内存读取,绝不发外网请求)
57
+ * 运行时切换更新源(UI 保存即调用,无需插件 reload)。
58
+ * 为什么必须失效缓存:24h 缓存里存的是旧源的结果,用户切源后若不清缓存,
59
+ * 首次 check() 仍返回旧源的 latestVersion——用户感知「切换无效」。
60
+ * 'off' 与 enabled 熔断独立并存:enabled 来自 Cordis 静态配置(缺省 true),
61
+ * off 来自 UI 持久化设置;任一为「关」都熔断自动探测。
62
+ */
63
+ applySettings(mode: UpdateSourceMode): void;
64
+ /** 当前生效的更新源模式(供命令行/升级链路展示与对齐) */
65
+ getSourceMode(): UpdateSourceMode | null;
66
+ /**
67
+ * 同步获取当前已知的版本状态(0ms 纯内存读取,绝不发外网请求)。
68
+ * 兜底命令同样按当前 profile 生成:网络探测失败时复制命令也必须开箱可用。
38
69
  */
39
70
  getQuickStatus(): UpdateInfo;
40
71
  /**