@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/client.js +400 -141
- package/lib/index.d.ts +8 -0
- package/lib/index.js +80 -20
- package/package.json +3 -3
- package/src/client/card.tsx +11 -19
- package/src/client/client.ts +41 -4
- package/src/client/i18n.ts +31 -12
- package/src/client/secrets-section.tsx +126 -0
- package/src/client/secrets.ts +132 -0
- package/src/client/styles.ts +3 -2
- package/src/config.ts +9 -9
- package/src/credentials.ts +77 -0
- package/src/refs.ts +23 -0
- package/src/service.ts +24 -2
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
|
-
*
|
|
30
|
-
* `.role('secret')`,describe
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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("
|
|
43
|
-
sessionToken: z.string().role("secret").description("会话令牌(优先于 password
|
|
44
|
-
encryptionPassword: z.string().role("secret").description("
|
|
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
|
-
},
|
|
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.
|
|
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/
|
|
36
|
-
"@dsh-plus/
|
|
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",
|
package/src/client/card.tsx
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
2
2
|
* 「Actual Budget 连接」配置卡片:经 injectPluginConfigCard 注册(插件页
|
|
3
3
|
* plugins.row.config / plugins.bundle.config,view 分发 summary/page)。
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
)
|
package/src/client/client.ts
CHANGED
|
@@ -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
|
-
/**
|
|
26
|
-
|
|
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
|
}
|
package/src/client/i18n.ts
CHANGED
|
@@ -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
|
-
'
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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: '
|
|
116
|
+
secretsTitle: 'Credentials',
|
|
109
117
|
secretsBody:
|
|
110
|
-
'
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
+
}
|
package/src/client/styles.ts
CHANGED
|
@@ -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-
|
|
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)
|