@rezti/dsh-rez-suite 0.1.5 → 0.1.6

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,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
+ import { REZ_CREDENTIAL_REFS, REZ_DEFAULT_CONNECTIONS, REZ_SYSTEMS, mergeConnections, secretConfigured, writeManagedCredential } from "@rezti/dsh-rez-sso";
4
5
  import { mkdirSync, renameSync, writeFileSync } from "node:fs";
5
6
  import { DatabaseSync } from "node:sqlite";
6
7
  //#region ../../node_modules/.pnpm/@deepseek-ai+cosmokit@1.8.2/node_modules/@deepseek-ai/cosmokit/lib/index.js
@@ -2865,13 +2866,14 @@ const FORMAT_VERSION = 1;
2865
2866
  function rezStorePath() {
2866
2867
  return join(homedir(), ".dsh", "dsh-rez-suite.json");
2867
2868
  }
2868
- /** Defaults: panel-only. Company MCP rows live in cordis.patch.yml. */
2869
+ /** Defaults: company MCP endpoints. Secrets live in credentials.yaml / env. */
2869
2870
  function defaultConfig() {
2870
2871
  return {
2871
2872
  enabled: true,
2872
2873
  announceToAgent: true,
2873
2874
  role: "all",
2874
2875
  servers: {},
2876
+ connections: JSON.parse(JSON.stringify(REZ_DEFAULT_CONNECTIONS)),
2875
2877
  billing: {
2876
2878
  inputCostPer1k: .001,
2877
2879
  outputCostPer1k: .002,
@@ -2896,10 +2898,37 @@ function maskServer(server) {
2896
2898
  function publicConfig(config) {
2897
2899
  const servers = {};
2898
2900
  for (const [id, server] of Object.entries(config.servers)) servers[id] = maskServer(server);
2901
+ const connections = mergeConnections(config.connections);
2899
2902
  return {
2900
2903
  enabled: config.enabled,
2901
2904
  role: config.role,
2902
2905
  servers,
2906
+ connections: {
2907
+ odoo: {
2908
+ enabled: connections.odoo.enabled,
2909
+ url: connections.odoo.url,
2910
+ secretConfigured: secretConfigured("odoo"),
2911
+ secretRef: REZ_CREDENTIAL_REFS.odoo
2912
+ },
2913
+ nextcloud: {
2914
+ enabled: connections.nextcloud.enabled,
2915
+ url: connections.nextcloud.url,
2916
+ username: connections.nextcloud.username,
2917
+ secretConfigured: secretConfigured("nextcloud"),
2918
+ secretRef: REZ_CREDENTIAL_REFS.nextcloud
2919
+ },
2920
+ wechat: {
2921
+ enabled: connections.wechat.enabled,
2922
+ secretConfigured: secretConfigured("wechat"),
2923
+ secretRef: REZ_CREDENTIAL_REFS.wechat
2924
+ },
2925
+ homeassistant: {
2926
+ enabled: connections.homeassistant.enabled,
2927
+ url: connections.homeassistant.url,
2928
+ secretConfigured: secretConfigured("homeassistant"),
2929
+ secretRef: REZ_CREDENTIAL_REFS.homeassistant
2930
+ }
2931
+ },
2903
2932
  billing: { ...config.billing }
2904
2933
  };
2905
2934
  }
@@ -2911,6 +2940,7 @@ function mergeConfig(base, override) {
2911
2940
  if (typeof value.enabled === "boolean") result.enabled = value.enabled;
2912
2941
  if (typeof value.announceToAgent === "boolean") result.announceToAgent = value.announceToAgent;
2913
2942
  if (value.role === "engineer" || value.role === "sales" || value.role === "operations" || value.role === "all") result.role = value.role;
2943
+ result.connections = mergeConnections(value.connections ?? result.connections);
2914
2944
  if (typeof value.servers === "object" && value.servers !== null) for (const [id, raw] of Object.entries(value.servers)) {
2915
2945
  if (typeof raw !== "object" || raw === null) continue;
2916
2946
  const incoming = raw;
@@ -3019,8 +3049,25 @@ function mergeMaskedRecord(base, incoming) {
3019
3049
  /** Apply a browser-safe public patch without overwriting masked secrets. */
3020
3050
  function applyPublicPatch(current, patch) {
3021
3051
  const next = JSON.parse(JSON.stringify(current));
3052
+ const secrets = [];
3022
3053
  if (typeof patch.enabled === "boolean") next.enabled = patch.enabled;
3023
3054
  if (patch.role === "engineer" || patch.role === "sales" || patch.role === "operations" || patch.role === "all") next.role = patch.role;
3055
+ if (typeof patch.connections === "object" && patch.connections !== null) {
3056
+ const incoming = patch.connections;
3057
+ next.connections = mergeConnections({
3058
+ ...next.connections,
3059
+ ...incoming
3060
+ });
3061
+ for (const system of Object.keys(REZ_CREDENTIAL_REFS)) {
3062
+ const row = incoming[system];
3063
+ if (typeof row !== "object" || row === null) continue;
3064
+ const secret = row.secret;
3065
+ if (typeof secret === "string" && secret.length > 0 && secret !== SECRET_MASK) secrets.push({
3066
+ system,
3067
+ value: secret
3068
+ });
3069
+ }
3070
+ }
3024
3071
  if (typeof patch.servers === "object" && patch.servers !== null) for (const [id, raw] of Object.entries(patch.servers)) {
3025
3072
  if (typeof raw !== "object" || raw === null) continue;
3026
3073
  const incoming = raw;
@@ -3046,11 +3093,14 @@ function applyPublicPatch(current, patch) {
3046
3093
  if (typeof value.outputCostPer1k === "number") next.billing.outputCostPer1k = value.outputCostPer1k;
3047
3094
  if (typeof value.monthlyBudget === "number") next.billing.monthlyBudget = value.monthlyBudget;
3048
3095
  }
3049
- return next;
3096
+ return {
3097
+ next,
3098
+ secrets
3099
+ };
3050
3100
  }
3051
3101
  /** Build every /api/dsh-rez-suite route (exact paths). */
3052
3102
  function makeRoutes(deps) {
3053
- const { getConfig, updateConfig, host, tokens } = deps;
3103
+ const { getConfig, updateConfig, host, tokens, onCredentialWritten } = deps;
3054
3104
  const guard = (req, res, method) => {
3055
3105
  if (!isLoopbackRequest(req)) {
3056
3106
  writeJson(res, 403, { error: "forbidden: loopback-only" });
@@ -3077,8 +3127,11 @@ function makeRoutes(deps) {
3077
3127
  writeJson(res, 400, { error: "invalid JSON body" });
3078
3128
  return;
3079
3129
  }
3080
- const next = applyPublicPatch(getConfig(), body);
3081
- writeJson(res, 200, { config: publicConfig(await updateConfig(next)) });
3130
+ const { next, secrets } = applyPublicPatch(getConfig(), body);
3131
+ const saved = await updateConfig(next);
3132
+ for (const item of secrets) writeManagedCredential(REZ_CREDENTIAL_REFS[item.system], item.value);
3133
+ for (const system of REZ_SYSTEMS) onCredentialWritten?.(REZ_CREDENTIAL_REFS[system]);
3134
+ writeJson(res, 200, { config: publicConfig(saved) });
3082
3135
  }
3083
3136
  },
3084
3137
  {
@@ -7537,6 +7590,22 @@ const Config = Schema.object({
7537
7590
  "all"
7538
7591
  ]).default("all"),
7539
7592
  servers: Schema.dict(serverSchema).default({}),
7593
+ connections: Schema.object({
7594
+ odoo: Schema.object({
7595
+ enabled: Schema.boolean().default(true),
7596
+ url: Schema.string().default(REZ_DEFAULT_CONNECTIONS.odoo.url)
7597
+ }).default(REZ_DEFAULT_CONNECTIONS.odoo),
7598
+ nextcloud: Schema.object({
7599
+ enabled: Schema.boolean().default(true),
7600
+ url: Schema.string().default(REZ_DEFAULT_CONNECTIONS.nextcloud.url),
7601
+ username: Schema.string().default(REZ_DEFAULT_CONNECTIONS.nextcloud.username)
7602
+ }).default(REZ_DEFAULT_CONNECTIONS.nextcloud),
7603
+ wechat: Schema.object({ enabled: Schema.boolean().default(true) }).default(REZ_DEFAULT_CONNECTIONS.wechat),
7604
+ homeassistant: Schema.object({
7605
+ enabled: Schema.boolean().default(true),
7606
+ url: Schema.string().default(REZ_DEFAULT_CONNECTIONS.homeassistant.url)
7607
+ }).default(REZ_DEFAULT_CONNECTIONS.homeassistant)
7608
+ }).default(REZ_DEFAULT_CONNECTIONS),
7540
7609
  billing: Schema.object({
7541
7610
  inputCostPer1k: Schema.number().default(.001),
7542
7611
  outputCostPer1k: Schema.number().default(.002),
@@ -7572,13 +7641,19 @@ function delegatedHost() {
7572
7641
  state: "disconnected",
7573
7642
  toolCount: 0,
7574
7643
  registeredTools: 0,
7575
- lastError: "owned by @deepseek-ai/dsh-mcp-client"
7576
- }));
7577
- const test = async () => COMPOSED_SERVERS.map((server) => ({
7578
- server,
7579
- ok: false,
7580
- error: "MCP is composed by dsh-mcp-client; set REZ_* credentials and check dsh logs"
7644
+ lastError: secretConfigured(server) ? "owned by @deepseek-ai/dsh-mcp-client" : "missing " + REZ_CREDENTIAL_REFS[server]
7581
7645
  }));
7646
+ const test = async () => COMPOSED_SERVERS.map((server) => {
7647
+ return secretConfigured(server) ? {
7648
+ server,
7649
+ ok: true,
7650
+ serverInfo: REZ_CREDENTIAL_REFS[server] + " configured"
7651
+ } : {
7652
+ server,
7653
+ ok: false,
7654
+ error: "missing " + REZ_CREDENTIAL_REFS[server]
7655
+ };
7656
+ });
7582
7657
  return {
7583
7658
  status,
7584
7659
  test,
@@ -7609,7 +7684,11 @@ function apply(ctx, config) {
7609
7684
  return value;
7610
7685
  },
7611
7686
  host,
7612
- tokens
7687
+ tokens,
7688
+ onCredentialWritten: (ref) => {
7689
+ const emit = ctx.emit;
7690
+ emit("credentials/updated", ref);
7691
+ }
7613
7692
  });
7614
7693
  let disposeRoutes;
7615
7694
  let disposeTools;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rezti/dsh-rez-suite",
3
3
  "description": "ReZ-TI employee plugin for DeepSeek Harness: one install enables company MCP (Odoo / Nextcloud / WeCom / Weixin-ClawBot / Home Assistant) and work identity.",
4
- "version": "0.1.5",
4
+ "version": "0.1.6",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -38,13 +38,13 @@
38
38
  "dependencies": {
39
39
  "@modelcontextprotocol/sdk": "^1.30.0",
40
40
  "zod": "^4.4.3",
41
- "@rezti/dsh-rez-ha-bridge": "0.1.4",
42
- "@rezti/dsh-rez-sso": "0.1.4",
43
- "@rezti/dsh-rez-nextcloud": "0.1.4",
44
- "@rezti/dsh-rez-intent": "0.1.3",
45
- "@rezti/dsh-rez-odoo": "0.1.4",
41
+ "@rezti/dsh-rez-nextcloud": "0.1.5",
42
+ "@rezti/dsh-rez-odoo": "0.1.5",
43
+ "@rezti/dsh-rez-sso": "0.1.5",
44
+ "@rezti/dsh-rez-ha-bridge": "0.1.5",
45
+ "@rezti/dsh-rez-wechat": "0.1.5",
46
46
  "@rezti/dsh-rez-token-manager": "0.1.3",
47
- "@rezti/dsh-rez-wechat": "0.1.4"
47
+ "@rezti/dsh-rez-intent": "0.1.3"
48
48
  },
49
49
  "peerDependencies": {
50
50
  "react": "^18.2.0",
@@ -27,16 +27,23 @@ export const zh = {
27
27
  'config.env': '环境变量 (JSON)',
28
28
  'config.headers': 'Headers (JSON)',
29
29
  'config.jsonInvalid': 'JSON 格式错误:{error}',
30
+ 'config.mcpHint': '四个公司 MCP 在这里配。密钥写入 ~/.dsh/.credentials.yaml(REZ_*)。新填的密钥会立刻拉起连接;改 URL 后如果已经连上,需要重启 dsh web。',
31
+ 'config.secretConfigured': '已配置(留空则保持)',
32
+ 'config.secretMissing': '未配置',
30
33
  'config.odoo': 'Odoo',
31
- 'config.odoo.url': 'Odoo URL',
32
- 'config.odoo.apiKey': 'API Key',
34
+ 'config.odoo.url': 'Odoo MCP URL',
35
+ 'config.odoo.apiKey': 'API Token',
33
36
  'config.nextcloud': 'Nextcloud',
34
- 'config.nextcloud.url': 'Nextcloud URL',
37
+ 'config.nextcloud.url': 'Nextcloud 主机',
35
38
  'config.nextcloud.username': '用户名',
36
39
  'config.nextcloud.appPassword': 'App Password',
37
40
  'config.wecom': '企业微信',
38
41
  'config.wecom.botId': 'Bot ID',
39
42
  'config.wecom.secret': 'Secret',
43
+ 'config.wecom.webhook': '群机器人 Webhook',
44
+ 'config.ha': 'Home Assistant',
45
+ 'config.ha.url': 'HA MCP URL',
46
+ 'config.ha.token': '长期访问令牌',
40
47
  'config.fs': '本地文件 (fs-mcp-server)',
41
48
  'config.fs.root': '根目录',
42
49
  'config.sqlite': 'SQLite (sqlite-mcp-server)',
@@ -122,16 +129,23 @@ export const en: Record<RezKey, string> = {
122
129
  'config.env': 'Env vars (JSON)',
123
130
  'config.headers': 'Headers (JSON)',
124
131
  'config.jsonInvalid': 'Invalid JSON: {error}',
132
+ 'config.mcpHint': 'Company MCP lives here. Secrets go to ~/.dsh/.credentials.yaml (REZ_*). A newly saved secret starts the connection immediately; URL changes after a live connection need a dsh web restart.',
133
+ 'config.secretConfigured': 'Configured (leave blank to keep)',
134
+ 'config.secretMissing': 'Not configured',
125
135
  'config.odoo': 'Odoo',
126
- 'config.odoo.url': 'Odoo URL',
127
- 'config.odoo.apiKey': 'API Key',
136
+ 'config.odoo.url': 'Odoo MCP URL',
137
+ 'config.odoo.apiKey': 'API token',
128
138
  'config.nextcloud': 'Nextcloud',
129
- 'config.nextcloud.url': 'Nextcloud URL',
139
+ 'config.nextcloud.url': 'Nextcloud host',
130
140
  'config.nextcloud.username': 'Username',
131
- 'config.nextcloud.appPassword': 'App Password',
141
+ 'config.nextcloud.appPassword': 'App password',
132
142
  'config.wecom': 'WeCom',
133
143
  'config.wecom.botId': 'Bot ID',
134
144
  'config.wecom.secret': 'Secret',
145
+ 'config.wecom.webhook': 'Group bot webhook',
146
+ 'config.ha': 'Home Assistant',
147
+ 'config.ha.url': 'HA MCP URL',
148
+ 'config.ha.token': 'Long-lived access token',
135
149
  'config.fs': 'Local files (fs-mcp-server)',
136
150
  'config.fs.root': 'Root directory',
137
151
  'config.sqlite': 'SQLite (sqlite-mcp-server)',
@@ -1,11 +1,11 @@
1
1
  /**
2
- * Config tab: role, billing rates, and a generic editor for every MCP server
3
- * in config.servers (stdio and streamable-http), with one-click tests.
2
+ * Config tab: company MCP connections (Odoo / Nextcloud / WeCom / HA),
3
+ * role, and billing. Secrets are write-only and land in credentials.yaml.
4
4
  */
5
5
 
6
6
  import { useEffect, useState, type ReactNode } from 'react'
7
7
  import type { RezApi } from '../api.ts'
8
- import type { RezMcpServerSpec, RezPublicConfig, RezRoleId, RezTestResult } from '../../protocol.ts'
8
+ import type { RezPublicConfig, RezPublicConnection, RezRoleId, RezTestResult } from '../../protocol.ts'
9
9
  import { errorMessage, tt } from './helpers.ts'
10
10
  import css from './panel.module.css'
11
11
 
@@ -16,6 +16,15 @@ const ROLES: Array<{ value: RezRoleId; label: string }> = [
16
16
  { value: 'operations', label: 'role.operations' },
17
17
  ]
18
18
 
19
+ type SecretDrafts = {
20
+ odoo: string
21
+ nextcloud: string
22
+ wechat: string
23
+ homeassistant: string
24
+ }
25
+
26
+ const EMPTY_SECRETS: SecretDrafts = { odoo: '', nextcloud: '', wechat: '', homeassistant: '' }
27
+
19
28
  function Field({ label, children }: { label: string; children: ReactNode }) {
20
29
  return (
21
30
  <div className={css.field}>
@@ -34,47 +43,88 @@ function Section({ title, children }: { title: string; children: ReactNode }) {
34
43
  )
35
44
  }
36
45
 
37
- function parseRecord(text: string): Record<string, string> {
38
- const value: unknown = JSON.parse(text)
39
- if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error('expected a JSON object')
40
- const out: Record<string, string> = {}
41
- for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
42
- out[key] = String(item)
43
- }
44
- return out
46
+ function SecretField({
47
+ label,
48
+ configured,
49
+ value,
50
+ onChange,
51
+ }: {
52
+ label: string
53
+ configured: boolean
54
+ value: string
55
+ onChange: (value: string) => void
56
+ }) {
57
+ return (
58
+ <Field label={label}>
59
+ <input
60
+ className={css.input}
61
+ type="password"
62
+ autoComplete="off"
63
+ value={value}
64
+ placeholder={configured ? tt('config.secretConfigured') : tt('config.secretMissing')}
65
+ onChange={event => { onChange(event.target.value) }}
66
+ />
67
+ </Field>
68
+ )
69
+ }
70
+
71
+ function McpSection({
72
+ title,
73
+ row,
74
+ secret,
75
+ onEnabled,
76
+ onUrl,
77
+ onUsername,
78
+ onSecret,
79
+ urlLabel,
80
+ secretLabel,
81
+ usernameLabel,
82
+ }: {
83
+ title: string
84
+ row: RezPublicConnection
85
+ secret: string
86
+ onEnabled: (enabled: boolean) => void
87
+ onUrl?: (url: string) => void
88
+ onUsername?: (username: string) => void
89
+ onSecret: (secret: string) => void
90
+ urlLabel?: string
91
+ secretLabel: string
92
+ usernameLabel?: string
93
+ }) {
94
+ return (
95
+ <Section title={title}>
96
+ <Field label={tt('config.enabled')}>
97
+ <input className={css.checkbox} type="checkbox" checked={row.enabled} onChange={event => { onEnabled(event.target.checked) }} />
98
+ </Field>
99
+ {onUrl !== undefined && urlLabel !== undefined && (
100
+ <Field label={urlLabel}>
101
+ <input className={css.input} value={row.url ?? ''} onChange={event => { onUrl(event.target.value) }} />
102
+ </Field>
103
+ )}
104
+ {onUsername !== undefined && usernameLabel !== undefined && (
105
+ <Field label={usernameLabel}>
106
+ <input className={css.input} value={row.username ?? ''} onChange={event => { onUsername(event.target.value) }} />
107
+ </Field>
108
+ )}
109
+ <SecretField label={secretLabel} configured={row.secretConfigured} value={secret} onChange={onSecret} />
110
+ <p className={css.message}>{row.secretRef}</p>
111
+ </Section>
112
+ )
45
113
  }
46
114
 
47
115
  export function ConfigTab({ api }: { api: RezApi }) {
48
116
  const [config, setConfig] = useState<RezPublicConfig | null>(null)
117
+ const [secrets, setSecrets] = useState<SecretDrafts>(EMPTY_SECRETS)
49
118
  const [loading, setLoading] = useState(true)
50
119
  const [saving, setSaving] = useState(false)
51
120
  const [testing, setTesting] = useState(false)
52
121
  const [message, setMessage] = useState('')
53
122
  const [error, setError] = useState('')
54
123
  const [results, setResults] = useState<RezTestResult[]>([])
55
- const [argsText, setArgsText] = useState<Record<string, string>>({})
56
- const [envText, setEnvText] = useState<Record<string, string>>({})
57
- const [headerText, setHeaderText] = useState<Record<string, string>>({})
58
-
59
- const initTexts = (next: RezPublicConfig): void => {
60
- const args: Record<string, string> = {}
61
- const envs: Record<string, string> = {}
62
- const headers: Record<string, string> = {}
63
- for (const [id, server] of Object.entries(next.servers)) {
64
- args[id] = (server.args ?? []).join('\n')
65
- envs[id] = JSON.stringify(server.env ?? {}, null, 2)
66
- headers[id] = JSON.stringify(server.headers ?? {}, null, 2)
67
- }
68
- setArgsText(args)
69
- setEnvText(envs)
70
- setHeaderText(headers)
71
- }
72
124
 
73
125
  const load = async (): Promise<void> => {
74
126
  try {
75
- const next = await api.getConfig()
76
- setConfig(next)
77
- initTexts(next)
127
+ setConfig(await api.getConfig())
78
128
  } catch (err) {
79
129
  setError(errorMessage(err))
80
130
  } finally {
@@ -91,10 +141,10 @@ export function ConfigTab({ api }: { api: RezApi }) {
91
141
  setConfig(prev => prev === null ? prev : { ...prev, [key]: value })
92
142
  }
93
143
 
94
- const updateServer = (id: string, patch: Partial<RezMcpServerSpec>): void => {
144
+ const patchConnection = (id: keyof RezPublicConfig['connections'], patch: Partial<RezPublicConnection>): void => {
95
145
  setConfig(prev => prev === null ? prev : {
96
146
  ...prev,
97
- servers: { ...prev.servers, [id]: { ...prev.servers[id], ...patch } },
147
+ connections: { ...prev.connections, [id]: { ...prev.connections[id], ...patch } },
98
148
  })
99
149
  }
100
150
 
@@ -103,9 +153,20 @@ export function ConfigTab({ api }: { api: RezApi }) {
103
153
  setMessage('')
104
154
  setError('')
105
155
  try {
106
- const saved = await api.saveConfig(config)
156
+ const payload = {
157
+ enabled: config.enabled,
158
+ role: config.role,
159
+ billing: config.billing,
160
+ connections: {
161
+ odoo: { ...config.connections.odoo, secret: secrets.odoo || undefined },
162
+ nextcloud: { ...config.connections.nextcloud, secret: secrets.nextcloud || undefined },
163
+ wechat: { ...config.connections.wechat, secret: secrets.wechat || undefined },
164
+ homeassistant: { ...config.connections.homeassistant, secret: secrets.homeassistant || undefined },
165
+ },
166
+ }
167
+ const saved = await api.saveConfig(payload)
107
168
  setConfig(saved)
108
- initTexts(saved)
169
+ setSecrets(EMPTY_SECRETS)
109
170
  setMessage(tt('config.saved'))
110
171
  } catch (err) {
111
172
  setError(errorMessage(err))
@@ -129,27 +190,16 @@ export function ConfigTab({ api }: { api: RezApi }) {
129
190
  }
130
191
 
131
192
  const renderResult = (result: RezTestResult): string => {
132
- if (result.ok) return tt('config.testOk', { tools: result.toolCount ?? 0, latency: result.latencyMs ?? 0 })
133
- return tt('config.testFail', { error: result.error ?? '' })
134
- }
135
-
136
- const commitArgs = (id: string): void => {
137
- updateServer(id, { args: argsText[id] === '' ? [] : argsText[id].split('\n').map(line => line.trim()).filter(Boolean) })
138
- }
139
-
140
- const commitRecord = (id: string, kind: 'env' | 'headers'): void => {
141
- const text = kind === 'env' ? envText[id] : headerText[id]
142
- try {
143
- const record = parseRecord(text ?? '{}')
144
- updateServer(id, kind === 'env' ? { env: record } : { headers: record })
145
- setError('')
146
- } catch (err) {
147
- setError(tt('config.jsonInvalid', { error: errorMessage(err) }))
193
+ if (result.ok) {
194
+ if (result.serverInfo !== undefined) return result.serverInfo
195
+ return tt('config.testOk', { tools: result.toolCount ?? 0, latency: result.latencyMs ?? 0 })
148
196
  }
197
+ return tt('config.testFail', { error: result.error ?? '' })
149
198
  }
150
199
 
151
200
  return (
152
201
  <div className={css.tabBody}>
202
+ <p className={css.message}>{tt('config.mcpHint')}</p>
153
203
  <Section title={tt('config.role')}>
154
204
  <Field label={tt('config.role')}>
155
205
  <select className={css.select} value={config.role} onChange={event => { setTop('role', event.target.value as RezRoleId) }}>
@@ -161,41 +211,46 @@ export function ConfigTab({ api }: { api: RezApi }) {
161
211
  </Field>
162
212
  </Section>
163
213
 
164
- {Object.entries(config.servers).map(([id, server]) => (
165
- <Section key={id} title={id}>
166
- <Field label={tt('config.enabled')}>
167
- <input className={css.checkbox} type="checkbox" checked={server.enabled} onChange={event => { updateServer(id, { enabled: event.target.checked }) }} />
168
- </Field>
169
- <Field label={tt('config.transport')}>
170
- <select className={css.select} value={server.transport} onChange={event => { updateServer(id, { transport: event.target.value as RezMcpServerSpec['transport'] }) }}>
171
- <option value="stdio">stdio</option>
172
- <option value="streamable-http">streamable-http</option>
173
- </select>
174
- </Field>
175
- {server.transport === 'stdio' ? (
176
- <>
177
- <Field label={tt('config.command')}>
178
- <input className={css.input} value={server.command ?? ''} onChange={event => { updateServer(id, { command: event.target.value }) }} />
179
- </Field>
180
- <Field label={tt('config.args')}>
181
- <textarea className={css.input} rows={3} value={argsText[id] ?? ''} onChange={event => { setArgsText(prev => ({ ...prev, [id]: event.target.value })) }} onBlur={() => { commitArgs(id) }} />
182
- </Field>
183
- <Field label={tt('config.env')}>
184
- <textarea className={css.input} rows={4} value={envText[id] ?? '{}'} onChange={event => { setEnvText(prev => ({ ...prev, [id]: event.target.value })) }} onBlur={() => { commitRecord(id, 'env') }} />
185
- </Field>
186
- </>
187
- ) : (
188
- <>
189
- <Field label={tt('config.url')}>
190
- <input className={css.input} value={server.url ?? ''} onChange={event => { updateServer(id, { url: event.target.value }) }} />
191
- </Field>
192
- <Field label={tt('config.headers')}>
193
- <textarea className={css.input} rows={4} value={headerText[id] ?? '{}'} onChange={event => { setHeaderText(prev => ({ ...prev, [id]: event.target.value })) }} onBlur={() => { commitRecord(id, 'headers') }} />
194
- </Field>
195
- </>
196
- )}
197
- </Section>
198
- ))}
214
+ <McpSection
215
+ title={tt('config.odoo')}
216
+ row={config.connections.odoo}
217
+ secret={secrets.odoo}
218
+ onEnabled={enabled => { patchConnection('odoo', { enabled }) }}
219
+ onUrl={url => { patchConnection('odoo', { url }) }}
220
+ onSecret={value => { setSecrets(prev => ({ ...prev, odoo: value })) }}
221
+ urlLabel={tt('config.odoo.url')}
222
+ secretLabel={tt('config.odoo.apiKey')}
223
+ />
224
+ <McpSection
225
+ title={tt('config.nextcloud')}
226
+ row={config.connections.nextcloud}
227
+ secret={secrets.nextcloud}
228
+ onEnabled={enabled => { patchConnection('nextcloud', { enabled }) }}
229
+ onUrl={url => { patchConnection('nextcloud', { url }) }}
230
+ onUsername={username => { patchConnection('nextcloud', { username }) }}
231
+ onSecret={value => { setSecrets(prev => ({ ...prev, nextcloud: value })) }}
232
+ urlLabel={tt('config.nextcloud.url')}
233
+ usernameLabel={tt('config.nextcloud.username')}
234
+ secretLabel={tt('config.nextcloud.appPassword')}
235
+ />
236
+ <McpSection
237
+ title={tt('config.wecom')}
238
+ row={config.connections.wechat}
239
+ secret={secrets.wechat}
240
+ onEnabled={enabled => { patchConnection('wechat', { enabled }) }}
241
+ onSecret={value => { setSecrets(prev => ({ ...prev, wechat: value })) }}
242
+ secretLabel={tt('config.wecom.webhook')}
243
+ />
244
+ <McpSection
245
+ title={tt('config.ha')}
246
+ row={config.connections.homeassistant}
247
+ secret={secrets.homeassistant}
248
+ onEnabled={enabled => { patchConnection('homeassistant', { enabled }) }}
249
+ onUrl={url => { patchConnection('homeassistant', { url }) }}
250
+ onSecret={value => { setSecrets(prev => ({ ...prev, homeassistant: value })) }}
251
+ urlLabel={tt('config.ha.url')}
252
+ secretLabel={tt('config.ha.token')}
253
+ />
199
254
 
200
255
  <Section title={tt('config.billing')}>
201
256
  <Field label={tt('config.billing.input')}>
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ import z from '@deepseek-ai/schemastery'
15
15
  import type {} from '@deepseek-ai/dsh-host-webserver'
16
16
  import type {} from '@deepseek-ai/dsh-system-prompt'
17
17
  import type {} from '@deepseek-ai/dsh-tools'
18
+ import { REZ_CREDENTIAL_REFS, REZ_DEFAULT_CONNECTIONS, secretConfigured } from '@rezti/dsh-rez-sso'
18
19
  import type { McpHost } from './mcp-host.ts'
19
20
  import { makeRoutes } from './routes.ts'
20
21
  import { defaultConfig, mergeConfig, saveConfig } from './store.ts'
@@ -45,6 +46,24 @@ export const Config: z<RezConfig> = z.object({
45
46
  announceToAgent: z.boolean().default(true),
46
47
  role: z.union(['engineer', 'sales', 'operations', 'all'] as const).default('all'),
47
48
  servers: z.dict(serverSchema).default({}),
49
+ connections: z.object({
50
+ odoo: z.object({
51
+ enabled: z.boolean().default(true),
52
+ url: z.string().default(REZ_DEFAULT_CONNECTIONS.odoo.url),
53
+ }).default(REZ_DEFAULT_CONNECTIONS.odoo),
54
+ nextcloud: z.object({
55
+ enabled: z.boolean().default(true),
56
+ url: z.string().default(REZ_DEFAULT_CONNECTIONS.nextcloud.url),
57
+ username: z.string().default(REZ_DEFAULT_CONNECTIONS.nextcloud.username),
58
+ }).default(REZ_DEFAULT_CONNECTIONS.nextcloud),
59
+ wechat: z.object({
60
+ enabled: z.boolean().default(true),
61
+ }).default(REZ_DEFAULT_CONNECTIONS.wechat),
62
+ homeassistant: z.object({
63
+ enabled: z.boolean().default(true),
64
+ url: z.string().default(REZ_DEFAULT_CONNECTIONS.homeassistant.url),
65
+ }).default(REZ_DEFAULT_CONNECTIONS.homeassistant),
66
+ }).default(REZ_DEFAULT_CONNECTIONS),
48
67
  billing: z.object({
49
68
  inputCostPer1k: z.number().default(0.001),
50
69
  outputCostPer1k: z.number().default(0.002),
@@ -73,16 +92,19 @@ function delegatedHost(): McpHost {
73
92
  const status = (): RezServerStatus[] => COMPOSED_SERVERS.map(server => ({
74
93
  server,
75
94
  enabled: true,
76
- state: 'disconnected',
95
+ state: 'disconnected' as const,
77
96
  toolCount: 0,
78
97
  registeredTools: 0,
79
- lastError: 'owned by @deepseek-ai/dsh-mcp-client',
80
- }))
81
- const test = async (): Promise<RezTestResult[]> => COMPOSED_SERVERS.map(server => ({
82
- server,
83
- ok: false,
84
- error: 'MCP is composed by dsh-mcp-client; set REZ_* credentials and check dsh logs',
98
+ lastError: secretConfigured(server)
99
+ ? 'owned by @deepseek-ai/dsh-mcp-client'
100
+ : 'missing ' + REZ_CREDENTIAL_REFS[server],
85
101
  }))
102
+ const test = async (): Promise<RezTestResult[]> => COMPOSED_SERVERS.map(server => {
103
+ const ok = secretConfigured(server)
104
+ return ok
105
+ ? { server, ok: true, serverInfo: REZ_CREDENTIAL_REFS[server] + ' configured' }
106
+ : { server, ok: false, error: 'missing ' + REZ_CREDENTIAL_REFS[server] }
107
+ })
86
108
  return {
87
109
  status,
88
110
  test,
@@ -119,6 +141,10 @@ export function apply(ctx: Context, config?: RezConfig): void {
119
141
  },
120
142
  host,
121
143
  tokens,
144
+ onCredentialWritten: (ref) => {
145
+ const emit = ctx.emit as unknown as (event: string, value: string) => void
146
+ emit('credentials/updated', ref)
147
+ },
122
148
  })
123
149
 
124
150
  let disposeRoutes: (() => void) | undefined