@dsh-plus/actual 0.1.0 → 0.1.1

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/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { pluginDataPath, unwrapVolatile } from "@dsh-plus/shared";
2
2
  import z from "@deepseek-ai/schemastery";
3
- import { ActualCli, DEFAULT_ALWAYS_ASK, DEFAULT_ALWAYS_ASK as DEFAULT_ALWAYS_ASK$1, DEFAULT_READ_ACTIONS, DEFAULT_READ_ACTIONS as DEFAULT_READ_ACTIONS$1, DEFAULT_READ_TOOLS, DEFAULT_READ_TOOLS as DEFAULT_READ_TOOLS$1, actionOf, askDisplayReason, classifyAction, computeDrift, createActualMcpServer, createEntryInvoker, createNodeDeps, discover, parseJsonOrText, sanitize, toSettings, versionStatusOf } from "@dsh-plus/actual-mcp";
3
+ import { ActualCli, DEFAULT_ALWAYS_ASK, DEFAULT_ALWAYS_ASK as DEFAULT_ALWAYS_ASK$1, DEFAULT_READ_ACTIONS, DEFAULT_READ_ACTIONS as DEFAULT_READ_ACTIONS$1, DEFAULT_READ_TOOLS, DEFAULT_READ_TOOLS as DEFAULT_READ_TOOLS$1, actionOf, askDisplayReason, classifyAction, computeDrift, configSecretsOf, createActualMcpServer, createEntryInvoker, createNodeDeps, discover, parseJsonOrText, sanitize, toSettings, versionStatusOf } from "@dsh-plus/actual-mcp";
4
4
  import { Service } from "@deepseek-ai/cordis";
5
5
  import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
6
6
  //#region src/ns.ts
@@ -26,10 +26,12 @@ const SETTINGS_NS = "dsh-plus-actual";
26
26
  * loader 以活动引用原位提交热更新——消费端必须 `unwrapVolatile` 现取即热
27
27
  * (见 service.ts 的 `current()`)。
28
28
  *
29
- * **密钥不进用户层**:`password` / `sessionToken` / `encryptionPassword` 标
30
- * `.role('secret')`,describe 视图剥除其值、写入用户层也会被遮蔽,因此配置卡片
31
- * 不提供编辑器——它们只允许 profile 行级注入(`!!js process.env.…`)。
32
- * 见《插件存储规范》:密钥走环境变量/官方凭据,不落 settings。
29
+ * **密钥不落 settings**:`password` / `sessionToken` / `encryptionPassword` 标
30
+ * `.role('secret')`,describe 视图剥除其值、写入用户层也会被遮蔽,因此不提供
31
+ * settings 编辑器。它们的落点有二:① 配置卡片的「凭据」小节(走官方
32
+ * credentials seam,写 `$DSH_HOME/.credentials.yaml`);② profile 行级注入
33
+ * (`!!js process.env.…`)。行级声明了任一种认证即整组生效,否则回落到 seam。
34
+ * 见《插件存储规范》与 credentials.ts。
33
35
  * @module @dsh-plus/actual/config
34
36
  */
35
37
  const Config = z.object({
@@ -39,9 +41,9 @@ const Config = z.object({
39
41
  order: z.number().description("预设名册排序位").default(11).volatile(),
40
42
  serverUrl: z.string().description("Actual 同步服务器基址(服务端版本探测 <serverUrl>/info)").default("http://127.0.0.1:5006").volatile(),
41
43
  syncId: z.string().description("预算 Sync ID(Actual 客户端 → 设置 → 显示高级设置 → Sync ID;空 = 用 ACTUAL_SYNC_ID)").default("").volatile(),
42
- password: z.string().role("secret").description("服务器口令(空 = 用 ACTUAL_PASSWORD / ACTUAL_PASSWORD_FILE;与 sessionToken 二选一)——密钥不进用户层,仅 profile 行级注入").default("").volatile(),
43
- sessionToken: z.string().role("secret").description("会话令牌(优先于 password;空 = 用 ACTUAL_SESSION_TOKEN)——仅 profile 行级注入").default("").volatile(),
44
- encryptionPassword: z.string().role("secret").description("端到端加密口令(仅加密预算需要;空 = 用 ACTUAL_ENCRYPTION_PASSWORD)——仅 profile 行级注入").default("").volatile(),
44
+ password: z.string().role("secret").description("服务器口令(与 sessionToken 二选一)——不落 settings:卡片「凭据」小节写凭据文件,或在此行级注入").default("").volatile(),
45
+ sessionToken: z.string().role("secret").description("会话令牌(优先于 password)——同上,卡片的「凭据」小节或行级注入").default("").volatile(),
46
+ encryptionPassword: z.string().role("secret").description("端到端加密口令(仅加密预算需要)——同上,卡片的「凭据」小节或行级注入").default("").volatile(),
45
47
  dataDir: z.string().description("CLI 本地缓存目录(空 = 插件数据目录 cli-data,与用户自有 CLI 隔离)").default("").volatile(),
46
48
  cacheTtl: z.number().description("CLI 缓存有效期(秒,官方默认 60)").default(60).volatile(),
47
49
  lockTimeout: z.number().description("预算目录锁等待上限(秒,官方默认 10)").default(10).volatile(),
@@ -175,6 +177,49 @@ function actualPresetDefinition(config) {
175
177
  };
176
178
  }
177
179
  //#endregion
180
+ //#region src/refs.ts
181
+ /**
182
+ * 凭据引用名字面量(纯常量,零依赖)。
183
+ *
184
+ * 宿主半(credentials.ts 的 seam 解析)与浏览器半(配置卡片的
185
+ * `remote.credentials` 读写)共用同一份取值;浏览器包不得经此路径拖入
186
+ * `@dsh-plus/actual-mcp` 的 node 侧实现(child_process 等),故字面量独立成模块。
187
+ *
188
+ * **必须与 `@dsh-plus/actual-mcp` 的 `SECRET_ENV` 逐字一致**:引用名与官方 CLI
189
+ * 原生读取的环境变量名同形(`ACTUAL_` 前缀亦符合《插件存储规范》的
190
+ * `<PLUGIN_ID>_` 大写约定),凭据文件里的一处配置才能同时被 seam 解析、
191
+ * 又被 CLI 自身继承环境读到。该一致性由 `tests/refs.test.ts` 钉住。
192
+ * @module @dsh-plus/actual/refs
193
+ */
194
+ /** 服务器口令。 */
195
+ const REF_PASSWORD = "ACTUAL_PASSWORD";
196
+ /** 会话令牌(官方 CLI 优先于口令)。 */
197
+ const REF_SESSION_TOKEN = "ACTUAL_SESSION_TOKEN";
198
+ /** 端到端加密口令(仅加密预算需要)。 */
199
+ const REF_ENCRYPTION_PASSWORD = "ACTUAL_ENCRYPTION_PASSWORD";
200
+ //#endregion
201
+ //#region src/credentials.ts
202
+ /** 解析单个引用;seam 缺席或未配置一律视为空串。 */
203
+ async function resolveOne(seam, ref) {
204
+ if (seam === null) return "";
205
+ const found = await seam.resolve(ref);
206
+ return found === void 0 ? "" : found.value;
207
+ }
208
+ /**
209
+ * 解析本次调用使用的密钥三元组。
210
+ * @param seam - 凭据 seam;缺席(profile 未挂 dsh-credentials)时只用行级配置。
211
+ * @param declared - 行级 Config 声明的密钥。
212
+ * @returns 已定妥优先级的密钥三元组。
213
+ */
214
+ async function resolveSecrets(seam, declared) {
215
+ const authDeclared = declared.password !== "" || declared.sessionToken !== "";
216
+ return {
217
+ password: declared.password !== "" ? declared.password : authDeclared ? "" : await resolveOne(seam, REF_PASSWORD),
218
+ sessionToken: declared.sessionToken !== "" ? declared.sessionToken : authDeclared ? "" : await resolveOne(seam, REF_SESSION_TOKEN),
219
+ encryptionPassword: declared.encryptionPassword !== "" ? declared.encryptionPassword : await resolveOne(seam, REF_ENCRYPTION_PASSWORD)
220
+ };
221
+ }
222
+ //#endregion
178
223
  //#region src/mcp-session.ts
179
224
  /**
180
225
  * 进程内 MCP 会话:把 `@dsh-plus/actual-mcp` 的 MCP 服务端用 InMemoryTransport
@@ -642,23 +687,27 @@ var ActualService = class extends Service {
642
687
  /** 按当前配置装配一个运行时,并把清单变更转发给服务级订阅者。 */
643
688
  build() {
644
689
  const config = this.current();
690
+ const cli = {
691
+ cliCommand: [...config.cliCommand],
692
+ serverUrl: config.serverUrl,
693
+ password: config.password,
694
+ sessionToken: config.sessionToken,
695
+ syncId: config.syncId,
696
+ dataDir: config.dataDir !== "" ? config.dataDir : pluginDataPath("actual", "cli-data"),
697
+ encryptionPassword: config.encryptionPassword,
698
+ cacheTtl: config.cacheTtl,
699
+ lockTimeout: config.lockTimeout
700
+ };
645
701
  const runtime = new ActualRuntime({
646
- cli: {
647
- cliCommand: [...config.cliCommand],
648
- serverUrl: config.serverUrl,
649
- password: config.password,
650
- sessionToken: config.sessionToken,
651
- syncId: config.syncId,
652
- dataDir: config.dataDir !== "" ? config.dataDir : pluginDataPath("actual", "cli-data"),
653
- encryptionPassword: config.encryptionPassword,
654
- cacheTtl: config.cacheTtl,
655
- lockTimeout: config.lockTimeout
656
- },
702
+ cli,
657
703
  allow: [...config.allow],
658
704
  deny: [...config.deny],
659
705
  cliVersionPolicy: config.cliVersionPolicy,
660
706
  toolCallTimeoutMs: config.toolCallTimeoutMs
661
- }, this.deps, this.logger);
707
+ }, {
708
+ ...this.deps,
709
+ resolveSecrets: () => resolveSecrets(this.credentialsSeam(), configSecretsOf(cli))
710
+ }, this.logger);
662
711
  return {
663
712
  runtime,
664
713
  detach: runtime.onChange((manifest) => {
@@ -705,6 +754,17 @@ var ActualService = class extends Service {
705
754
  this.listeners.add(cb);
706
755
  return () => this.listeners.delete(cb);
707
756
  }
757
+ /**
758
+ * credentials seam:**可选探测**而非硬 inject。
759
+ *
760
+ * 本插件在没挂 `dsh-credentials` 的 profile(headless/dev 精简组合)里也要能
761
+ * 起来,只是密钥退回「行级 Config + CLI 自身继承环境」;且探测发生在每次操作,
762
+ * seam 比本插件晚激活也能自然接上,不必声明时序。
763
+ */
764
+ credentialsSeam() {
765
+ const found = this.ctx.get("credentials");
766
+ return found === void 0 || found === null ? null : found;
767
+ }
708
768
  dispose() {
709
769
  this.listeners.clear();
710
770
  this.detach();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-plus/actual",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "dsh-plus service + UI plugin: Actual Budget 能力发现(@actual-app/cli help 树运行时派生 × 进程内 MCP tools/list)、actual agent 预设注册与配置卡片——零手工工具清单",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -32,8 +32,8 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "@modelcontextprotocol/client": "2.3.0",
35
- "@dsh-plus/shared": "0.1.34",
36
- "@dsh-plus/actual-mcp": "0.1.0"
35
+ "@dsh-plus/actual-mcp": "0.1.1",
36
+ "@dsh-plus/shared": "0.1.35"
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@deepseek-ai/cordis": "^4.0.5-alpha.1",
@@ -2,11 +2,12 @@
2
2
  * 「Actual Budget 连接」配置卡片:经 injectPluginConfigCard 注册(插件页
3
3
  * plugins.row.config / plugins.bundle.config,view 分发 summary/page)。
4
4
  *
5
- * 编辑范围只覆盖**非密钥**字段。密钥与高级字段没有编辑器,卡片内给出明确落点
6
- * (见 i18n 的 secretsBody / advancedBody)——按《插件开发指南》,可配置项要么
7
- * 有编辑器、要么写明为什么没有与改在哪里配。
5
+ * 编辑范围覆盖全部可公开字段与三行密钥:非密钥字段走 settings 草稿(脏判定 +
6
+ * 保存按钮),密钥走官方 credentials seam(各自即时保存,与草稿互不干扰)。
7
+ * 剩余无编辑器的字段在卡片内给出明确落点(见 i18n 的 advancedBody)——按
8
+ * 《插件开发指南》,可配置项要么有编辑器、要么写明为什么没有与改在哪里配。
8
9
  *
9
- * 字段按主题拆成两个小节组件,卡片本体只负责外壳、状态与保存动作。
10
+ * 字段按主题拆成小节组件,卡片本体只负责外壳、状态与保存动作。
10
11
  * @module @dsh-plus/actual/client/card
11
12
  */
12
13
 
@@ -32,11 +33,14 @@ import {
32
33
  validateDraft,
33
34
  } from './draft.ts'
34
35
  import type { Translate } from './i18n.ts'
36
+ import type { CredentialsRemoteFace } from './secrets.ts'
37
+ import { SecretsSection } from './secrets-section.tsx'
35
38
 
36
39
  export interface CardProps extends PluginConfigViewProps {
37
40
  t: Translate
38
41
  scope: Scope
39
42
  api: NamespaceSettingsApi
43
+ credentials: CredentialsRemoteFace
40
44
  }
41
45
 
42
46
  /** 小节共用的 props。 */
@@ -69,18 +73,11 @@ function violationProps(
69
73
  }
70
74
 
71
75
  /** 说明段落(无编辑器的字段在此交代落点)。 */
72
- function Note(props: {
73
- title: string
74
- body: string
75
- snippet?: string
76
- tail?: string
77
- }): ReactElement {
76
+ function Note(props: { title: string; body: string }): ReactElement {
78
77
  return (
79
78
  <div className="act-note">
80
79
  <p className="act-noteTitle">{props.title}</p>
81
80
  <p className="act-noteBody">{props.body}</p>
82
- {props.snippet !== undefined ? <code className="act-snippet">{props.snippet}</code> : null}
83
- {props.tail !== undefined ? <p className="act-noteBody">{props.tail}</p> : null}
84
81
  </div>
85
82
  )
86
83
  }
@@ -174,7 +171,7 @@ function PolicySection(props: SectionProps): ReactElement {
174
171
  }
175
172
 
176
173
  export function ActualCard(props: CardProps): ReactElement | string | null {
177
- const { t, scope, api } = props
174
+ const { t, scope, api, credentials } = props
178
175
  const { draft, dirty, saving, status, disabled, edit, reset, save } = useNamespaceDraft<
179
176
  ConfigValue,
180
177
  Draft
@@ -217,13 +214,8 @@ export function ActualCard(props: CardProps): ReactElement | string | null {
217
214
  />
218
215
  <p className="act-noteBody">{t('enabledHint')}</p>
219
216
  <ConnectionSection {...section} />
217
+ <SecretsSection t={t} face={credentials} />
220
218
  <PolicySection {...section} />
221
- <Note
222
- title={t('secretsTitle')}
223
- body={t('secretsBody')}
224
- snippet={t('secretsSnippet')}
225
- tail={t('secretsTail')}
226
- />
227
219
  <Note title={t('advancedTitle')} body={t('advancedBody')} />
228
220
  </CardChrome>
229
221
  )
@@ -4,7 +4,8 @@
4
4
  * plugins.bundle.config)。
5
5
  * 构建产物为 window.__ModuleLoader__.load({id, factory}) 形式的 CJS factory
6
6
  * (包装见 tsdown.config.ts);样式沿用官方 data-plugin-css 约定,HMR 据此卸载。
7
- * 配置读写经 ctx.remote.settings 直连(scope/api 来自 @dsh-plus/shared/client)。
7
+ * 配置读写经 ctx.remote.settings 直连(scope/api 来自 @dsh-plus/shared/client),
8
+ * 密钥读写经 ctx.remote.credentials(官方 settings-controller 的凭据命名空间)。
8
9
  * @module @dsh-plus/actual/client
9
10
  */
10
11
  import type { Context } from '@deepseek-ai/cordis'
@@ -13,21 +14,56 @@ import {
13
14
  createSettingsScope,
14
15
  injectPluginConfigCard,
15
16
  type PluginClientContext,
17
+ type RemoteLike,
16
18
  } from '@dsh-plus/shared/client'
17
19
 
18
20
  import { SETTINGS_NS } from '../ns.ts'
19
21
  import { ActualCard } from './card.tsx'
20
22
  import { type DictKey, en, NS, zh } from './i18n.ts'
23
+ import type { CredentialsRemoteFace } from './secrets.ts'
21
24
  import { injectStyle } from './styles.ts'
22
25
 
23
26
  export const name = 'dsh-plus-actual'
24
27
 
25
- /** 浏览器半需要的 cordis 服务 key(loader 据此注入;package.json 的 dsh.client.inject 管包加载顺序)。 */
26
- export const inject = ['slots', 'locale', 'remote', 'remote.settings'] as const
28
+ /**
29
+ * 浏览器半需要的 cordis 服务 key(loader 据此注入;package.json 的 dsh.client.inject 管包加载顺序)。
30
+ * `remote.credentials` 为硬依赖:卡片是密钥的唯一配置面,命名空间缺席时整张卡片
31
+ * 都不该挂(官方设置页同样如此声明)。
32
+ */
33
+ export const inject = [
34
+ 'slots',
35
+ 'locale',
36
+ 'remote',
37
+ 'remote.settings',
38
+ 'remote.credentials',
39
+ ] as const
27
40
 
28
41
  /** 宿主窄面收编在 @dsh-plus/shared/client;TKey 传入本包 DictKey 使键名受检。 */
29
42
  type ClientContext = PluginClientContext<DictKey>
30
43
 
44
+ /** `remote` 服务加上凭据命名空间(inject 已声明,故按存在处理)。 */
45
+ type RemoteWithCredentials = RemoteLike & {
46
+ credentials: Omit<CredentialsRemoteFace, 'onChange'>
47
+ }
48
+
49
+ /**
50
+ * 把官方 `remote.credentials` 收成窄面:只补一个把宿主事件包成订阅的方法,
51
+ * 让状态层不必认识 `$on` 的事件名与载荷形状。
52
+ */
53
+ function credentialsFace(c: ClientContext): CredentialsRemoteFace {
54
+ const remote = c.get('remote') as RemoteWithCredentials
55
+ const { credentials } = remote
56
+ return {
57
+ describe: (refs) => credentials.describe(refs),
58
+ set: (ref, value) => credentials.set(ref, value),
59
+ unset: (ref) => credentials.unset(ref),
60
+ onChange: (listener) =>
61
+ remote.$on('credentials/reference-updated', (ref) => {
62
+ if (typeof ref === 'string') listener(ref)
63
+ }),
64
+ }
65
+ }
66
+
31
67
  export function apply(ctx: Context): void {
32
68
  const c = ctx as unknown as ClientContext
33
69
  const tag = injectStyle()
@@ -40,11 +76,12 @@ export function apply(ctx: Context): void {
40
76
  c.effect(() => c.locale.register(NS, { zh, en }), 'actual: locale')
41
77
  const scope = createSettingsScope(c, SETTINGS_NS, 'actual: settings scope')
42
78
  const api = createNamespaceApi(c.get('remote').settings, SETTINGS_NS)
79
+ const credentials = credentialsFace(c)
43
80
  injectPluginConfigCard(c.slots, {
44
81
  ns: SETTINGS_NS,
45
82
  rowId: 'dsh-plus-actual',
46
83
  pkg: '@dsh-plus/actual',
47
84
  component: ActualCard,
48
- inject: () => ({ t: c.locale.bind(NS), scope, api }),
85
+ inject: () => ({ t: c.locale.bind(NS), scope, api, credentials }),
49
86
  })
50
87
  }
@@ -50,13 +50,21 @@ const ownZh = {
50
50
  '模型可见的工具名 = 前缀 + 命令族名(如 actual_accounts)。留空则直接用裸族名——query、server 等过于通用,不建议。',
51
51
  namePrefixInvalid: '只允许字母、数字、下划线与连字符',
52
52
 
53
- secretsTitle: '服务器口令',
53
+ secretsTitle: '凭据',
54
54
  secretsBody:
55
- '口令属密钥:它不进用户层配置,卡片也不提供输入框(写进来会被脱敏遮蔽,等于白填)。请在 profile 的 cordis.patch.yml 里做行级注入,口令本身放环境变量:',
56
- secretsSnippet:
57
- '- id: dsh-plus-actual\n config:\n syncId: !!js process.env.ACTUAL_SYNC_ID\n password: !!js process.env.ACTUAL_PASSWORD',
58
- secretsTail:
59
- '把 ACTUAL_PASSWORD 放进 dsh 服务进程的环境(systemd 的 EnvironmentFile)。官方 CLI 还支持 ACTUAL_PASSWORD_FILE——更适合密钥文件;encrypted 预算另需 ACTUAL_ENCRYPTION_PASSWORD。改动环境变量后需重启 dsh 服务。',
55
+ '密钥单独存放,不进入会话、提示词与配置流,保存后立即生效(下一次工具调用即用新值)。三行都可留空:口令与会话令牌二选一即可,加密口令仅加密预算需要。',
56
+ secretPassword: '服务器口令',
57
+ secretPasswordHint: '自托管部署的登录口令。ACTUAL_LOGIN_METHOD=password 时用它换会话令牌。',
58
+ secretToken: '会话令牌',
59
+ secretTokenHint: '已有的会话令牌(优先于口令)。从浏览器开发者工具的 X-Actual-Token 头可取。',
60
+ secretEncryption: '端到端加密口令',
61
+ secretEncryptionHint: '仅当预算启用了端到端加密时需要;普通预算留空。',
62
+ secretSet: '已配置',
63
+ secretUnset: '未配置',
64
+ secretClear: '清除',
65
+ secretShadowed:
66
+ '该引用由 dsh 进程环境提供(只读层优先于凭据文件),卡片改不了:请改环境来源并重启 dsh。',
67
+ secretFailed: '凭据操作失败:',
60
68
 
61
69
  advancedTitle: '其余字段',
62
70
  advancedBody:
@@ -105,13 +113,24 @@ const ownEn = {
105
113
  'Model-visible tool name = prefix + command family (e.g. actual_accounts). Empty uses bare family names — query, server and tags are too generic, so a prefix is advised.',
106
114
  namePrefixInvalid: 'Letters, digits, underscore and hyphen only',
107
115
 
108
- secretsTitle: 'Server password',
116
+ secretsTitle: 'Credentials',
109
117
  secretsBody:
110
- 'The password is a secret: it never enters the user-layer configuration, and this card offers no input for it (a value written here is masked away — filling it in would do nothing). Inject it at the profile row in cordis.patch.yml and keep the value in the environment:',
111
- secretsSnippet:
112
- '- id: dsh-plus-actual\n config:\n syncId: !!js process.env.ACTUAL_SYNC_ID\n password: !!js process.env.ACTUAL_PASSWORD',
113
- secretsTail:
114
- 'Put ACTUAL_PASSWORD into the dsh service process environment (systemd EnvironmentFile). The official CLI also supports ACTUAL_PASSWORD_FILE, which suits secret files better; an encrypted budget additionally needs ACTUAL_ENCRYPTION_PASSWORD. Restart the dsh service after changing environment variables.',
118
+ 'Secrets live separately — they never enter the conversation, the prompt or the settings stream, and a save applies immediately (the next tool call uses it). All three may stay empty: password and session token are alternatives, and the encryption password is only needed for encrypted budgets.',
119
+ secretPassword: 'Server password',
120
+ secretPasswordHint:
121
+ 'The login password of your self-hosted deployment. Exchanged for a session token when ACTUAL_LOGIN_METHOD=password.',
122
+ secretToken: 'Session token',
123
+ secretTokenHint:
124
+ 'An existing session token; it takes precedence over the password. Read it from the X-Actual-Token header in devtools.',
125
+ secretEncryption: 'End-to-end encryption password',
126
+ secretEncryptionHint:
127
+ 'Only needed when the budget has end-to-end encryption enabled; leave empty otherwise.',
128
+ secretSet: 'Configured',
129
+ secretUnset: 'Not configured',
130
+ secretClear: 'Clear',
131
+ secretShadowed:
132
+ 'This reference comes from the dsh process environment, which shadows the credentials file and cannot be edited here. Change the environment source and restart dsh.',
133
+ secretFailed: 'Credential operation failed: ',
115
134
 
116
135
  advancedTitle: 'Remaining fields',
117
136
  advancedBody:
@@ -0,0 +1,126 @@
1
+ /**
2
+ * 「凭据」小节:三行密钥(服务器口令 / 会话令牌 / 端到端加密口令),
3
+ * 每行可写可清,右侧徽标显示「已配置 / 未配置」。
4
+ *
5
+ * 行内只做呈现与暂存:值一经保存即从输入框清空,不回显、不驻留 DOM;
6
+ * 引用被进程环境遮蔽(`writable:false`)时禁用编辑并说明原因——这正是
7
+ * 部署方把口令放进 systemd/shell 环境的场景,必须让用户看懂为什么改不了。
8
+ * @module @dsh-plus/actual/client/secrets-section
9
+ */
10
+ import { Button, TextField } from '@dsh-plus/shared/client'
11
+ import { type ReactElement, useState } from 'react'
12
+
13
+ import { REF_ENCRYPTION_PASSWORD, REF_PASSWORD, REF_SESSION_TOKEN } from '../refs.ts'
14
+ import type { DictKey, Translate } from './i18n.ts'
15
+ import {
16
+ type CredentialsRemoteFace,
17
+ type CredentialView,
18
+ useCredentials,
19
+ viewOf,
20
+ } from './secrets.ts'
21
+
22
+ /** 一行密钥的定义:引用名 + 文案键。 */
23
+ interface SecretRowSpec {
24
+ ref: string
25
+ labelKey: DictKey
26
+ hintKey: DictKey
27
+ }
28
+
29
+ /** 三行密钥的固定顺序(口令 → 令牌 → 加密口令)。 */
30
+ export const SECRET_ROWS: readonly SecretRowSpec[] = [
31
+ { ref: REF_PASSWORD, labelKey: 'secretPassword', hintKey: 'secretPasswordHint' },
32
+ { ref: REF_SESSION_TOKEN, labelKey: 'secretToken', hintKey: 'secretTokenHint' },
33
+ {
34
+ ref: REF_ENCRYPTION_PASSWORD,
35
+ labelKey: 'secretEncryption',
36
+ hintKey: 'secretEncryptionHint',
37
+ },
38
+ ]
39
+
40
+ interface SecretRowProps {
41
+ t: Translate
42
+ row: SecretRowSpec
43
+ view: CredentialView
44
+ busy: boolean
45
+ onSave(ref: string, value: string): Promise<boolean>
46
+ onClear(ref: string): Promise<boolean>
47
+ }
48
+
49
+ /** 单行密钥:输入框 + 保存/清除,保存成功后清空输入。 */
50
+ function SecretRow(props: SecretRowProps): ReactElement {
51
+ const { t, row, view, busy } = props
52
+ const [text, setText] = useState('')
53
+ const locked = busy || !view.writable
54
+
55
+ return (
56
+ <div className="act-secret">
57
+ <TextField
58
+ prefix="act"
59
+ id={`act-secret-${row.ref}`}
60
+ label={t(row.labelKey)}
61
+ hint={t(row.hintKey)}
62
+ password
63
+ value={text}
64
+ disabled={locked}
65
+ badge={{ text: t(view.configured ? 'secretSet' : 'secretUnset'), set: view.configured }}
66
+ onEdit={setText}
67
+ />
68
+ <div className="act-secretActions">
69
+ <Button
70
+ prefix="act"
71
+ variant="primary"
72
+ label={t('save')}
73
+ disabled={locked || text === ''}
74
+ onClick={() => {
75
+ void props.onSave(row.ref, text).then((ok) => {
76
+ if (ok) setText('')
77
+ })
78
+ }}
79
+ />
80
+ <Button
81
+ prefix="act"
82
+ variant="ghost"
83
+ label={t('secretClear')}
84
+ disabled={locked || !view.configured}
85
+ onClick={() => {
86
+ void props.onClear(row.ref)
87
+ }}
88
+ />
89
+ </div>
90
+ {view.writable ? null : <p className="act-warn">{t('secretShadowed')}</p>}
91
+ </div>
92
+ )
93
+ }
94
+
95
+ /** 凭据小节(读写经官方 `remote.credentials`)。 */
96
+ export function SecretsSection(props: { t: Translate; face: CredentialsRemoteFace }): ReactElement {
97
+ const { t, face } = props
98
+ const { views, busy, error, save, clear } = useCredentials(face)
99
+ return (
100
+ <div className="act-section">
101
+ <div className="act-sectionHead">
102
+ <h3 className="act-sectionTitle">{t('secretsTitle')}</h3>
103
+ </div>
104
+ <p className="act-sectionDesc">{t('secretsBody')}</p>
105
+ <div className="act-sectionBody">
106
+ {SECRET_ROWS.map((row) => (
107
+ <SecretRow
108
+ key={row.ref}
109
+ t={t}
110
+ row={row}
111
+ view={viewOf(views, row.ref)}
112
+ busy={busy}
113
+ onSave={save}
114
+ onClear={clear}
115
+ />
116
+ ))}
117
+ </div>
118
+ {error === '' ? null : (
119
+ <p className="act-warn">
120
+ {t('secretFailed')}
121
+ {error}
122
+ </p>
123
+ )}
124
+ </div>
125
+ )
126
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * 浏览器半的凭据面:官方 settings-controller 的 `remote.credentials` 命名空间
3
+ * (describe/set/unset),复用官方 credentials seam,不另开 HTTP 端点。
4
+ * **值只写不读**——describe 只回「已配置 / 来源 / 可写」,没有任何方法回传值。
5
+ *
6
+ * 凭据与 settings 草稿相互独立:它不进命名空间、不参与脏判定,保存即落
7
+ * `$DSH_HOME/.credentials.yaml` 并热生效(宿主每次工具调用重新 resolve),
8
+ * 因此改口令不需要保存卡片、也不需要 /reload 或重启。
9
+ * @module @dsh-plus/actual/client/secrets
10
+ */
11
+ import type { RemoteResult } from '@dsh-plus/shared/client'
12
+ import { useCallback, useEffect, useState } from 'react'
13
+
14
+ import { CREDENTIAL_REFS } from '../refs.ts'
15
+
16
+ /** 单个引用的安全视图:没有能承载值的字段。 */
17
+ export interface CredentialView {
18
+ configured: boolean
19
+ source?: string
20
+ writable: boolean
21
+ }
22
+
23
+ /** 卡片用到的 `remote.credentials` 方法面。 */
24
+ export interface CredentialsRemoteFace {
25
+ describe(refs: readonly string[]): Promise<RemoteResult<Record<string, CredentialView>>>
26
+ set(ref: string, value: string): Promise<RemoteResult<unknown>>
27
+ unset(ref: string): Promise<RemoteResult<unknown>>
28
+ /** 订阅引用变更(宿主提交写入或观察到凭据文件被外部编辑时触发)。 */
29
+ onChange(listener: (ref: string) => void): () => void
30
+ }
31
+
32
+ /** 未答(或应答缺项)时的视图:按「未配置、可写」呈现。 */
33
+ export const ABSENT_VIEW: CredentialView = { configured: false, writable: true }
34
+
35
+ /** 取某引用的视图;应答缺项回落为 {@link ABSENT_VIEW}。 */
36
+ export function viewOf(views: Record<string, CredentialView>, ref: string): CredentialView {
37
+ return views[ref] ?? ABSENT_VIEW
38
+ }
39
+
40
+ /** 应答信封的错误文案(成功为空串)。 */
41
+ export function failureOf(result: RemoteResult<unknown>): string {
42
+ return result.ok ? '' : (result.error.message ?? '')
43
+ }
44
+
45
+ /** 提取异常文本。 */
46
+ function messageOf(error: unknown): string {
47
+ return error instanceof Error ? error.message : String(error)
48
+ }
49
+
50
+ /** 凭据读写的卡片状态。 */
51
+ export interface CredentialsState {
52
+ views: Record<string, CredentialView>
53
+ /** 有写入在途(按钮禁用用)。 */
54
+ busy: boolean
55
+ /** 最近一次失败文案(空串 = 无)。 */
56
+ error: string
57
+ /** 写入成功返回 true(调用方据此决定是否清空输入框)。 */
58
+ save(ref: string, value: string): Promise<boolean>
59
+ clear(ref: string): Promise<boolean>
60
+ }
61
+
62
+ /**
63
+ * 订阅宿主凭据变更:只认本插件关心的引用,其余(别的插件写自己的密钥)
64
+ * 不触发重读。
65
+ */
66
+ function useCredentialChanges(face: CredentialsRemoteFace, read: () => void): void {
67
+ useEffect(
68
+ () =>
69
+ face.onChange((ref) => {
70
+ if ((CREDENTIAL_REFS as readonly string[]).includes(ref)) read()
71
+ }),
72
+ [face, read],
73
+ )
74
+ }
75
+
76
+ /**
77
+ * 凭据行状态:挂载即 describe 一次,写入后重新 describe;
78
+ * 外部改动经 `credentials/reference-updated` 触发同一路径刷新。
79
+ */
80
+ export function useCredentials(face: CredentialsRemoteFace): CredentialsState {
81
+ const [views, setViews] = useState<Record<string, CredentialView>>({})
82
+ const [busy, setBusy] = useState(false)
83
+ const [error, setError] = useState('')
84
+
85
+ const read = useCallback(async (): Promise<void> => {
86
+ const result = await face.describe([...CREDENTIAL_REFS])
87
+ if (!result.ok) {
88
+ setError(failureOf(result))
89
+ return
90
+ }
91
+ setError('')
92
+ setViews(result.value)
93
+ }, [face])
94
+
95
+ useEffect(() => {
96
+ void read()
97
+ }, [read])
98
+ useCredentialChanges(face, read)
99
+
100
+ const write = useCallback(
101
+ async (run: () => Promise<RemoteResult<unknown>>): Promise<boolean> => {
102
+ setBusy(true)
103
+ try {
104
+ const result = await run()
105
+ if (!result.ok) {
106
+ setError(failureOf(result))
107
+ return false
108
+ }
109
+ setError('')
110
+ await read()
111
+ return true
112
+ } catch (thrown: unknown) {
113
+ setError(messageOf(thrown))
114
+ return false
115
+ } finally {
116
+ setBusy(false)
117
+ }
118
+ },
119
+ [read],
120
+ )
121
+
122
+ const save = useCallback(
123
+ (ref: string, value: string): Promise<boolean> => write(() => face.set(ref, value)),
124
+ [face, write],
125
+ )
126
+ const clear = useCallback(
127
+ (ref: string): Promise<boolean> => write(() => face.unset(ref)),
128
+ [face, write],
129
+ )
130
+
131
+ return { views, busy, error, save, clear }
132
+ }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * 配置卡片样式:基础规则走 @dsh-plus/shared/client 的 cardCss('act'),
3
- * 本文件只补插件特有规则(说明段落与代码片段)。
3
+ * 本文件只补插件特有规则(说明段落与凭据行)。
4
4
  * 沿用官方 data-plugin / data-plugin-css 约定(HMR 据此卸载)。
5
5
  * @module @dsh-plus/actual/client/styles
6
6
  */
@@ -12,7 +12,8 @@ const extraCss = `
12
12
  .act-note{margin:14px 0 0;padding:10px 12px;border-radius:8px;background:var(--dsw-alias-bg-base);border:1px solid var(--dsw-alias-border-l2)}
13
13
  .act-noteTitle{margin:0 0 6px;font-size:13px;font-weight:600}
14
14
  .act-noteBody{margin:0;color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.6}
15
- .act-snippet{display:block;margin:8px 0 0;padding:8px 10px;border-radius:6px;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family-mono,monospace);font-size:11px;line-height:1.5;white-space:pre;overflow-x:auto}
15
+ .act-secret{border-top:1px solid var(--dsw-alias-border-l2)}
16
+ .act-secretActions{display:flex;gap:8px;padding:0 0 12px}
16
17
  `
17
18
 
18
19
  export const cardCssAll = cardCss('act', extraCss)