@cpm-lhj/dsh-feishu 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,81 @@
1
- # Temporary Holding Version
1
+ # @cpm-lhj/dsh-feishu
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 飞书长连接插件。白名单里的人发消息后,常驻的 dsh 在固定目录里执行;进度和批准显示在触发消息下的卡片上,最终回答回复在那条消息下面。
4
+
5
+ 适配 `@larksuiteoapi/node-sdk` 1.76。对照当前安装包,`im.message.receive_v1`、`card.action.trigger`、`client.im.message.reply`(含 `reply_in_thread`)、`client.im.message.patch` 和 `GET /open-apis/bot/v3/info` 的字段一致。话题 id 没有用一条真实群消息核对。键规则按设计实现:私聊是 `p2p:<chat_id>`;群里有 `thread_id` 时是 `group:<chat_id>:thread:<thread_id>`;否则是 `group:<chat_id>:root:<root_id 或 message_id>`。线上字段若不同,只改 `src/transport.ts` 和 `src/keys.ts`。
6
+
7
+ ## 安装
8
+
9
+ 运行中的 dsh 需要满足 `engines.dsh`:`>=0.2.2-rc.1 <0.3.0`。Node.js 需要 `>=24.9.0`。
10
+
11
+ ```sh
12
+ dsh plugin --profile <name> add <本包>
13
+ ```
14
+
15
+ `<本包>` 可以是本地目录、`github:` 地址、npm 包或 `pnpm pack` 打出的 tarball。profile 以 `@deepseek-ai/dsh-base` 为第一层,再追加本包。内置组合包从那台 dsh 的安装目录解析,不从本仓库解析。
16
+
17
+ 本包的 `cordis.patch.yml` 只插入自己:
18
+
19
+ ```yaml
20
+ - insert:
21
+ - id: feishu
22
+ name: '@cpm-lhj/dsh-feishu'
23
+ ```
24
+
25
+ 装好之后,用带 Web 界面的同一个 profile 打开**插件**,进入 `@cpm-lhj/dsh-feishu`,点 `feishu` 这一行的**配置**。页面上填写工作目录、每行一个 open_id、App ID 和 App Secret,然后保存。App ID 和 App Secret 写入凭证库,配置里只留下 `FEISHU_APP_ID` 和 `FEISHU_APP_SECRET` 这两个名字。留空的凭证不会覆盖已经保存的值。目录必须是已存在的绝对路径;open_id 留空时没有人可以触发。保存后插件按新配置重新连接。
26
+
27
+ 这个配置页只出现在加载了 Web 客户端的 profile 里。没打开 Web 的常驻进程上看不到它。
28
+
29
+ 关掉 `tool-working-directory` 仍写在该 profile 自己的 `cordis.patch.yml`。后一层按整行替换,不是深合并:
30
+
31
+ ```yaml
32
+ - id: tool-working-directory
33
+ disabled: true
34
+ ```
35
+
36
+ 不改 `sandbox-policy` 的 `workspaceRoot`。
37
+
38
+ 常驻 profile 只叠 base 和本插件。不要用 headless 当常驻进程,也不要在同一个 profile 上挂 Web 批准界面。同一套飞书应用和 domain 只跑一个进程;两个进程会各自处理消息。
39
+
40
+ `@deepseek-ai/cordis`、`@deepseek-ai/schemastery` 和 `@deepseek-ai/dsh-llm` 是 peer,由正在运行的 dsh 提供。不要再装一份。本包直接依赖 `@larksuiteoapi/node-sdk` 和 `zod`。
41
+
42
+ ## 构建脚本
43
+
44
+ 优先发布 npm 包或 tarball。包内已经带有 `lib/`,安装时不必跑 `prepare`。
45
+
46
+ 从 git 安装时,`prepare` 用 Node 自带的类型剥离把 `src/` 转成 `lib/`,不需要旁边有一份 harness checkout。pnpm 会拦住依赖的构建脚本:
47
+
48
+ | pnpm | 授权方式 |
49
+ | --- | --- |
50
+ | 10 | `pnpm approve-builds`,或在 profile 的 `pnpm-workspace.yaml` 里写 `onlyBuiltDependencies`。`allowBuilds` 在 pnpm 10 不生效 |
51
+ | 11 | 安装失败时提示的 `allowBuilds`。Web 插件页可以用 “Allow these scripts and retry” |
52
+
53
+ 授权后重新执行一次 `dsh plugin add`。`prepare` 没跑时没有 `lib/index.js`,插件起不来。
54
+
55
+ ## 升级 dsh
56
+
57
+ 本包声明 `engines.dsh` 上限 `<0.3.0`。把 dsh 升到 0.3.x 之后,已安装的 profile 在启动时会静默跳过这个 bundle:结果记在 `skippedBundles`,终端不打印。机器人不再响应,仍处于执行中的卡片也不会被恢复。升级前先确认本包的兼容范围。安装和更新路径会直接拒绝范围外的 dsh;已经装好的机器才是静默跳过。
58
+
59
+ ## 固定文案
60
+
61
+ 这些句子不做成配置:
62
+
63
+ | 场景 | 文字 |
64
+ | --- | --- |
65
+ | 不在名单 | 你不在可触发名单里,这条消息不会执行。 |
66
+ | 这条线还在执行 | 这条会话还在执行,新消息不会执行。 |
67
+ | 没有正文 | 请写下要处理的问题。 |
68
+ | 卡片 | 执行中、已停止、同意、拒绝、停止、成功、失败 |
69
+
70
+ ## 开发
71
+
72
+ ```sh
73
+ npm install
74
+ npm test
75
+ npm run typecheck
76
+ npm run prepare
77
+ ```
78
+
79
+ `npm test` 用 `node --experimental-strip-types` 跑 `tests/`。这些测试不启动父仓库的 agent loop,也不代替飞书客户端上的 AC-01 到 AC-16。`lib/` 和 `node_modules/` 不入库。
80
+
81
+ `@deepseek-ai/dsh-llm` 只列为 peer。npm 上的该包版本不是 harness 的 `0.2.2-rc.1`,所以这里不把它装进 `devDependencies`。类型检查用 `types/dsh-llm.d.ts`。
@@ -0,0 +1,3 @@
1
+ - insert:
2
+ - id: feishu
3
+ name: '@cpm-lhj/dsh-feishu'
@@ -0,0 +1,165 @@
1
+
2
+
3
+
4
+
5
+
6
+
7
+
8
+
9
+
10
+
11
+
12
+
13
+
14
+
15
+ /** One open card approval: the visible reason and the click that settles it. */
16
+ export class ApprovalBook {
17
+ waiters = new Map ()
18
+ views = new Map ()
19
+
20
+ /**
21
+ * Approval currently drawn on this session's card.
22
+ * @param sessionId - dsh session id.
23
+ * @returns The open approval, or `undefined` when the card should not show buttons.
24
+ */
25
+ view(sessionId ) {
26
+ return this.views.get(sessionId)
27
+ }
28
+
29
+ /**
30
+ * Remember the approval to draw. Arm the waiter before the card is shown.
31
+ * @param sessionId - dsh session id.
32
+ * @param callId - Tool call this click is allowed to settle.
33
+ * @param reason - Explanation shown on the card. Not copied onto the button.
34
+ */
35
+ setView(sessionId , callId , reason ) {
36
+ this.views.set(sessionId, { callId, reason })
37
+ }
38
+
39
+ /**
40
+ * Drop the visible approval without settling a waiter.
41
+ * @param sessionId - dsh session id.
42
+ */
43
+ clear(sessionId ) {
44
+ this.views.delete(sessionId)
45
+ }
46
+
47
+ /**
48
+ * Wait for the click that matches this session and call. Abort settles `cancelled`.
49
+ * The waiter accepts clicks only after {@link open}; an earlier click is kept and
50
+ * applied then. A second arm for the same pair cancels the previous promise.
51
+ * A failed show must call {@link unavailable} so a buffered click is not granted.
52
+ * @param sessionId - dsh session id whose view is cleared when this settles.
53
+ * @param callId - Button id. Unique only together with `sessionId`.
54
+ * @param signal - Tool signal. Aborting withdraws the question.
55
+ * @returns The single outcome for this request.
56
+ */
57
+ arm(sessionId , callId , signal ) {
58
+ this.waiters.get(sessionId)?.get(callId)?.cancel(true)
59
+ const byCall = this.bucket(sessionId)
60
+ return new Promise((resolve) => {
61
+ let settled = false
62
+ let opened = false
63
+ let buffered
64
+ const settle = (outcome , keepView = false) => {
65
+ if (settled) return
66
+ settled = true
67
+ const current = this.waiters.get(sessionId)?.get(callId)
68
+ if (current === waiter) {
69
+ byCall.delete(callId)
70
+ if (byCall.size === 0) this.waiters.delete(sessionId)
71
+ }
72
+ if (!keepView && this.views.get(sessionId)?.callId === callId) this.views.delete(sessionId)
73
+ signal?.removeEventListener('abort', onAbort)
74
+ resolve(outcome)
75
+ }
76
+ const onAbort = () => settle('cancelled')
77
+ const waiter = {
78
+ allow: () => settle('allowed-once'),
79
+ reject: () => settle('rejected'),
80
+ cancel: (keepView) => settle('cancelled', keepView === true),
81
+ unavailable: () => settle('unavailable'),
82
+ open: () => {
83
+ if (settled || opened) return
84
+ opened = true
85
+ if (buffered === 'approve') settle('allowed-once')
86
+ else if (buffered === 'reject') settle('rejected')
87
+ },
88
+ buffer: (kind) => {
89
+ if (settled || opened || buffered !== undefined) return false
90
+ buffered = kind
91
+ return true
92
+ },
93
+ isOpen: () => opened,
94
+ }
95
+ byCall.set(callId, waiter)
96
+ if (signal?.aborted) {
97
+ settle('cancelled')
98
+ return
99
+ }
100
+ signal?.addEventListener('abort', onAbort, { once: true })
101
+ })
102
+ }
103
+
104
+ /**
105
+ * Apply a click that was buffered while the card patch was in flight.
106
+ * Does nothing when the waiter is missing or already settled.
107
+ * @param sessionId - dsh session id.
108
+ * @param callId - Button id.
109
+ */
110
+ open(sessionId , callId ) {
111
+ this.waiters.get(sessionId)?.get(callId)?.open()
112
+ }
113
+
114
+ /**
115
+ * Settle this show as `unavailable` and drop a click buffered during the failed patch.
116
+ * A waiter already settled by abort stays as it is.
117
+ * @param sessionId - dsh session id.
118
+ * @param callId - Button id.
119
+ */
120
+ unavailable(sessionId , callId ) {
121
+ this.waiters.get(sessionId)?.get(callId)?.unavailable()
122
+ }
123
+
124
+ /**
125
+ * Settle the waiter for this session and call. A missing or already settled waiter is a late click.
126
+ * A click that arrives before {@link open} is kept for that open.
127
+ * @param sessionId - Session that owns the card.
128
+ * @param callId - Id from the button.
129
+ * @param kind - Approve or reject. Stop is not an approval outcome.
130
+ * @returns Whether a waiter accepted the click.
131
+ */
132
+ click(sessionId , callId , kind ) {
133
+ const waiter = this.waiters.get(sessionId)?.get(callId)
134
+ if (waiter === undefined) return false
135
+ if (!waiter.isOpen()) return waiter.buffer(kind)
136
+ if (kind === 'approve') waiter.allow()
137
+ else waiter.reject()
138
+ return true
139
+ }
140
+
141
+ /**
142
+ * Cancel every waiter on this session and drop its view.
143
+ * @param sessionId - dsh session id.
144
+ */
145
+ cancelSession(sessionId ) {
146
+ this.views.delete(sessionId)
147
+ const byCall = this.waiters.get(sessionId)
148
+ if (byCall === undefined) return
149
+ for (const waiter of [...byCall.values()]) waiter.cancel()
150
+ }
151
+
152
+ /** Settle every open approval as cancelled. Used when the plugin unloads. */
153
+ cancelAll() {
154
+ this.views.clear()
155
+ for (const sessionId of [...this.waiters.keys()]) this.cancelSession(sessionId)
156
+ }
157
+
158
+ bucket(sessionId ) {
159
+ const existing = this.waiters.get(sessionId)
160
+ if (existing !== undefined) return existing
161
+ const created = new Map ()
162
+ this.waiters.set(sessionId, created)
163
+ return created
164
+ }
165
+ }
package/lib/card.js ADDED
@@ -0,0 +1,57 @@
1
+ import { COPY } from './copy.js'
2
+
3
+
4
+ /**
5
+ * Render one progress card. Button values carry the action and id only.
6
+ * Settled cards keep the rows and omit the running and stopped status.
7
+ * @param doc - Card content derived from the line and the open approval, if any.
8
+ * @returns Feishu interactive card JSON.
9
+ */
10
+ export function renderCard(doc ) {
11
+ const elements = []
12
+ for (const row of doc.rows) elements.push(textElement(rowText(row)))
13
+ if (doc.approval !== undefined) {
14
+ if (doc.approval.reason !== '') elements.push(textElement(doc.approval.reason))
15
+ elements.push(actions([
16
+ button(COPY.approve, 'primary', { action: 'approve', callId: doc.approval.callId }),
17
+ button(COPY.reject, 'danger', { action: 'reject', callId: doc.approval.callId }),
18
+ ]))
19
+ }
20
+ if (doc.showStop) elements.push(actions([button(COPY.stop, 'default', { action: 'stop' })]))
21
+ const card = {
22
+ config: { wide_screen_mode: true, update_multi: true },
23
+ elements,
24
+ }
25
+ if (doc.status === 'running' || doc.status === 'stopped') {
26
+ card.header = {
27
+ title: {
28
+ tag: 'plain_text',
29
+ content: doc.status === 'running' ? COPY.running : COPY.stopped,
30
+ },
31
+ }
32
+ }
33
+ return card
34
+ }
35
+
36
+ function rowText(row ) {
37
+ if (row.result === 'pending') return `${row.toolName} ${row.objectLabel}`
38
+ const result = row.result === 'success' ? COPY.success : COPY.failure
39
+ return `${row.toolName} ${row.objectLabel} ${result}`
40
+ }
41
+
42
+ function textElement(content ) {
43
+ return { tag: 'div', text: { tag: 'plain_text', content } }
44
+ }
45
+
46
+ function actions(items ) {
47
+ return { tag: 'action', actions: items }
48
+ }
49
+
50
+ function button(label , type , value ) {
51
+ return {
52
+ tag: 'button',
53
+ text: { tag: 'plain_text', content: label },
54
+ type,
55
+ value,
56
+ }
57
+ }
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Browser half. The Web page loads this file, and it registers the Feishu
3
+ * row's configuration form on the Plugins page.
4
+ */
5
+
6
+ import { draftFromValue, planSave, ROW_CONFIG_KEY, } from './save.js'
7
+
8
+ const NS = 'feishu'
9
+
10
+ const en = {
11
+ summary: 'Set the working directory, who can start a session, and the Feishu app credentials.',
12
+ unavailable: 'This plugin is not running, so this page cannot save yet.',
13
+ directory: 'Working directory',
14
+ directoryHint: 'An existing absolute path. Every session runs in this directory.',
15
+ allow: 'People who can start a session',
16
+ allowHint: 'One Feishu open_id per line. An empty list means nobody can start one.',
17
+ appId: 'App ID',
18
+ appSecret: 'App Secret',
19
+ secretHint: 'Leave a credential blank to keep the saved value.',
20
+ save: 'Save',
21
+ saving: 'Saving…',
22
+ saved: 'Saved. The plugin reconnects with these settings.',
23
+ saveFailed: 'The settings were not saved.',
24
+ readOnly: 'This profile cannot change settings.',
25
+ }
26
+
27
+ const zh = {
28
+ summary: '填写工作目录、可以发起会话的人,以及飞书应用凭证。',
29
+ unavailable: '插件还没在运行,这个页面暂时不能保存。',
30
+ directory: '工作目录',
31
+ directoryHint: '一个已经存在的绝对路径。所有会话都在这个目录里执行。',
32
+ allow: '可以发起会话的人',
33
+ allowHint: '每行一个飞书 open_id。留空则没有人可以发起。',
34
+ appId: 'App ID',
35
+ appSecret: 'App Secret',
36
+ secretHint: '凭证留空表示保留已经保存的值。',
37
+ save: '保存',
38
+ saving: '正在保存…',
39
+ saved: '已保存。插件会用这些设置重新连接。',
40
+ saveFailed: '没有保存成功。',
41
+ readOnly: '这个 profile 不能修改设置。',
42
+ }
43
+
44
+
45
+
46
+
47
+
48
+
49
+
50
+
51
+
52
+
53
+
54
+
55
+
56
+
57
+
58
+
59
+
60
+
61
+
62
+
63
+
64
+
65
+
66
+
67
+
68
+
69
+
70
+
71
+
72
+
73
+
74
+
75
+
76
+
77
+
78
+
79
+ /** Services this page waits for. */
80
+ export const inject = ['slots', 'locale', 'remote', 'remote.credentials']
81
+
82
+ /**
83
+ * Register the configuration page while the Plugins page is on screen.
84
+ * @param ctx - Browser plugin context.
85
+ */
86
+ export function apply(ctx ) {
87
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }))
88
+ ctx.effect(() => ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
89
+ name: 'plugins.row.config',
90
+ key: ROW_CONFIG_KEY,
91
+ locale: NS,
92
+ }, props => {
93
+ const React = require('react')
94
+ return React.createElement(FeishuPage, { ...props, credentials: ctx.remote.credentials })
95
+ })))
96
+ }
97
+
98
+
99
+
100
+
101
+
102
+ /** The row's summary line, or the form. */
103
+ function FeishuPage(props ) {
104
+ const { t } = props
105
+ if (props.view === 'summary') return t('summary')
106
+ const form = props.form
107
+ if (form === undefined || form.state.status !== 'ready') return t('unavailable')
108
+ const React = require('react')
109
+ return React.createElement(FeishuForm, { ...props, form })
110
+ }
111
+
112
+ /** Directory, allow list, and the two credential fields. */
113
+ function FeishuForm(props ) {
114
+ const React = require('react')
115
+ const initial = draftFromValue(props.form.state.value)
116
+ const [directory, setDirectory] = React.useState(initial.directory)
117
+ const [allowText, setAllowText] = React.useState(initial.allowText)
118
+ const [appId, setAppId] = React.useState('')
119
+ const [appSecret, setAppSecret] = React.useState('')
120
+ const [status, setStatus] = React.useState ('idle')
121
+ const [detail, setDetail] = React.useState('')
122
+ const disabled = !props.form.state.writable || status === 'saving'
123
+
124
+ const onSave = () => {
125
+ const draft = { directory, allowText, appId, appSecret }
126
+ setStatus('saving')
127
+ setDetail('')
128
+ void save(props, draft).then(() => {
129
+ setAppId('')
130
+ setAppSecret('')
131
+ setStatus('saved')
132
+ }, (error ) => {
133
+ setStatus('failed')
134
+ setDetail(error instanceof Error && error.message.length > 0 ? error.message : props.t('saveFailed'))
135
+ })
136
+ }
137
+
138
+ return React.createElement('form', {
139
+ onSubmit: (event ) => {
140
+ event.preventDefault()
141
+ onSave()
142
+ },
143
+ style: { display: 'grid', gap: '16px', maxWidth: '36rem' },
144
+ },
145
+ textField(props, 'feishu-directory', 'directory', 'directoryHint', directory, setDirectory, disabled),
146
+ textField(props, 'feishu-allow', 'allow', 'allowHint', allowText, setAllowText, disabled, 4),
147
+ secretField(props, 'feishu-app-id', 'appId', appId, setAppId, disabled),
148
+ secretField(props, 'feishu-app-secret', 'appSecret', appSecret, setAppSecret, disabled),
149
+ React.createElement('p', null, props.t('secretHint')),
150
+ props.form.state.writable ? null : React.createElement('p', { role: 'status' }, props.t('readOnly')),
151
+ React.createElement('button', { type: 'submit', disabled }, props.t(status === 'saving' ? 'saving' : 'save')),
152
+ status === 'saved' ? React.createElement('p', { role: 'status' }, props.t('saved')) : null,
153
+ status === 'failed' ? React.createElement('p', { role: 'status' }, detail || props.t('saveFailed')) : null,
154
+ )
155
+ }
156
+
157
+ function textField(
158
+ props ,
159
+ id ,
160
+ label ,
161
+ hint ,
162
+ value ,
163
+ onChange ,
164
+ disabled ,
165
+ rows = 1,
166
+ ) {
167
+ const React = require('react')
168
+ return React.createElement('label', { htmlFor: id, style: { display: 'grid', gap: '4px' } },
169
+ props.t(label),
170
+ React.createElement('textarea', {
171
+ id,
172
+ value,
173
+ rows,
174
+ disabled,
175
+ spellCheck: false,
176
+ onChange: (event ) => { onChange(event.target.value) },
177
+ }),
178
+ React.createElement('span', null, props.t(hint)),
179
+ )
180
+ }
181
+
182
+ function secretField(
183
+ props ,
184
+ id ,
185
+ label ,
186
+ value ,
187
+ onChange ,
188
+ disabled ,
189
+ ) {
190
+ const React = require('react')
191
+ return React.createElement('label', { htmlFor: id, style: { display: 'grid', gap: '4px' } },
192
+ props.t(label),
193
+ React.createElement('input', {
194
+ id,
195
+ type: 'password',
196
+ autoComplete: 'new-password',
197
+ value,
198
+ disabled,
199
+ spellCheck: false,
200
+ onChange: (event ) => { onChange(event.target.value) },
201
+ }),
202
+ )
203
+ }
204
+
205
+ async function save(props , draft ) {
206
+ const plan = planSave(draft)
207
+ for (const secret of plan.secrets) {
208
+ const response = await props.credentials.set(secret.ref, secret.value)
209
+ if (!response.ok) throw new Error(response.error?.message ?? props.t('saveFailed'))
210
+ }
211
+ const accepted = await props.form.mutate(plan.ops, props.form.state.revision)
212
+ if (!accepted) throw new Error(props.t('saveFailed'))
213
+ }
@@ -0,0 +1,76 @@
1
+ /** Credential ref the Plugins page writes the Feishu App ID under. */
2
+ export const APP_ID_REF = 'FEISHU_APP_ID'
3
+
4
+ /** Credential ref the Plugins page writes the Feishu App Secret under. */
5
+ export const APP_SECRET_REF = 'FEISHU_APP_SECRET'
6
+
7
+ /** `plugins.row.config` key: package name, then the row id from cordis.patch.yml. */
8
+ export const ROW_CONFIG_KEY = '@cpm-lhj/dsh-feishu#feishu'
9
+
10
+ /** One settings-form edit. */
11
+
12
+
13
+
14
+
15
+
16
+
17
+ /** Values the page is about to save. Secret fields are empty when the user left them unchanged. */
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+ /** Credential writes plus the volatile config fields the page owns. */
26
+
27
+
28
+
29
+
30
+
31
+ /**
32
+ * Read the directory and allow list out of a settings snapshot.
33
+ * App ID and App Secret are not in that snapshot.
34
+ * @param value - Host config section, when one has been accepted.
35
+ * @returns Directory text and one open_id per line.
36
+ */
37
+ export function draftFromValue(value ) {
38
+ const record = value !== null && typeof value === 'object' ? value : {}
39
+ const directory = typeof record.directory === 'string' ? record.directory : ''
40
+ const allow = Array.isArray(record.allowOpenIds)
41
+ ? record.allowOpenIds.filter((item) => typeof item === 'string')
42
+ : []
43
+ return { directory, allowText: allow.join('\n') }
44
+ }
45
+
46
+ /**
47
+ * Split the allow-list textarea into open_ids.
48
+ * @param text - One open_id per line.
49
+ * @returns Trimmed, non-empty ids.
50
+ */
51
+ export function allowList(text ) {
52
+ return text.split(/\r?\n/).map(line => line.trim()).filter(line => line.length > 0)
53
+ }
54
+
55
+ /**
56
+ * Plan a save. Blank secret fields are left untouched. The config always
57
+ * stores the fixed credential ref names, never the secret values.
58
+ * @param draft - Current form fields.
59
+ * @returns Credential writes and config operations.
60
+ */
61
+ export function planSave(draft ) {
62
+ const secrets = []
63
+ const appId = draft.appId.trim()
64
+ const appSecret = draft.appSecret.trim()
65
+ if (appId.length > 0) secrets.push({ ref: APP_ID_REF, value: appId })
66
+ if (appSecret.length > 0) secrets.push({ ref: APP_SECRET_REF, value: appSecret })
67
+ return {
68
+ secrets,
69
+ ops: [
70
+ { op: 'set', path: ['directory'], value: draft.directory.trim() },
71
+ { op: 'set', path: ['allowOpenIds'], value: allowList(draft.allowText) },
72
+ { op: 'set', path: ['appIdRef'], value: APP_ID_REF },
73
+ { op: 'set', path: ['appSecretRef'], value: APP_SECRET_REF },
74
+ ],
75
+ }
76
+ }