@justsilver/opencode-providers 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,21 @@
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-10-07
11
+
12
+ ### Added
13
+
14
+ - `/connect-providers` 账号管理支持**重命名**(`ctrl+r`,弹窗预填当前名称)与**删除**(`ctrl+d`,二次确认后删除);
15
+ 删除最后一个账号时提示 `Disconnected <name>` 并关闭弹窗(该供应商随之从 `/models` 消失)。
16
+ 动作触发后**留在弹窗**并原地刷新(与内置 `/connect` 一致);选中已激活账号不再重复调用 activate
17
+ - 强制刷新改为**阻塞式进度模态**(`dialog.alert`,刷新结束自动 `clear`):刷新期间输入被模态捕获、无法重复触发;
18
+ `forceRefresh` 增加**单飞**(并发调用共用同一次刷新)
19
+
20
+ ### Changed
21
+
22
+ - **移除安装脚本**:不再提供 `install.sh` / `install.ps1` / `uninstall.sh` / `uninstall.ps1`,本插件**只通过 npm 包分发**;
23
+ 本地开发改为在 `plugins` 里指向工作树的**绝对路径目录**(opencode 按「本地目录插件」加载,改完自动热重载)
24
+
10
25
  ## [0.2.1] - 2026-10-07
11
26
 
12
27
  ### Fixed
@@ -121,7 +136,8 @@
121
136
  - 插件源码从 `.opencode/plugins/opencode-providers/` 移到 `plugin/opencode-providers/`:仓库自身不再是插件发现根,避免在仓库里跑 opencode 时与全局安装的同 id 副本相撞(`Duplicate plugin ID` → 面板里一条 `failed`)
122
137
  - 安装后**通常无需重启**(插件目录被文件监视热重载),不再要求 `opencode service restart`
123
138
 
124
- [Unreleased]: https://github.com/Just-Silver/opencode-providers/compare/v0.1.0...HEAD
139
+ [Unreleased]: https://github.com/Just-Silver/opencode-providers/compare/v0.3.0...HEAD
140
+ [0.3.0]: https://github.com/Just-Silver/opencode-providers/releases/tag/v0.3.0
125
141
  [0.1.0]: https://github.com/Just-Silver/opencode-providers/releases/tag/v0.1.0
126
142
  [0.1.0-beta.2]: https://github.com/Just-Silver/opencode-providers/releases/tag/v0.1.0-beta.2
127
143
  [0.1.0-beta.1]: https://github.com/Just-Silver/opencode-providers/releases/tag/v0.1.0-beta.1
package/README.md CHANGED
@@ -7,14 +7,15 @@
7
7
  registry/index.json ← manifest:schemaVersion / revision / providers[](CLI 自动维护)
8
8
  registry/providers/<id>/ ← 每家:provider.json(供应商参数)+ models.json(该家模型)
9
9
  registry/models/<lab>/<model>.json ← 顶层共享模型(多家用 base 引用)
10
- plugin/opencode-providers/ ← 插件源码(安装时整目录复制到 ~/.config/opencode/plugins/)
10
+ plugin/opencode-providers/ ← 插件源码(npm 包的 server / tui 入口所在)
11
11
  ├── index.ts ← server 入口:拉注册表 → 注册 integration/provider/models
12
12
  ├── tui.ts ← TUI 入口:注册 /connect-providers 命令(不写 JSX,见下)
13
13
  ├── registry/ ← 纯逻辑:schema 校验 / 元数据映射 / 拉取缓存
14
14
  └── view/ ← /connect-providers 交互
15
15
  ```
16
16
 
17
- > 源码**故意不放在 `.opencode/plugins/` 下**:那样在本仓库里跑 opencode 时,仓库副本会和全局安装副本撞同一个插件 id,
17
+ > 源码**故意不放在 `.opencode/plugins/` 下**:opencode 会自动发现该目录下的插件,
18
+ > 那样在本仓库里跑 opencode 时就会和 npm/全局那份撞同一个插件 id,
18
19
  > 被 supervisor 判为 `Duplicate plugin ID: opencode-providers`,在 `/plugins` 面板里显示成一条 `failed`(实际加载的是另一条,功能正常)。
19
20
 
20
21
  ## 为什么需要它
@@ -25,8 +26,7 @@ OpenCode 的供应商清单来自 models.dev 目录,**目录里没有的供应
25
26
 
26
27
  ## 安装
27
28
 
28
- ### 方式 A:npm 包(推荐)
29
-
29
+ 本插件**只通过 npm 包分发**(走 opencode 的 `plugins` 配置或 `opencode plugin add`),没有安装脚本。
30
30
  在 `opencode.json(c)` 里写一行(不需要手写 provider/模型):
31
31
 
32
32
  ```jsonc
@@ -58,35 +58,24 @@ OpenCode 的供应商清单来自 models.dev 目录,**目录里没有的供应
58
58
  }
59
59
  ```
60
60
 
61
- ### 方式 B:安装脚本(零依赖备用路径)
61
+ 装完**通常无需重启**(插件被文件监视,覆盖后自动热重载);必要时 `opencode service restart`。
62
+ 卸载:`opencode plugin remove @justsilver/opencode-providers`(或把 `plugins` 里那一行删掉)。
62
63
 
63
- ```bash
64
- # Linux / macOS
65
- ./install.sh
66
- ```
64
+ ### 本地开发:直接跑工作树
67
65
 
68
- ```powershell
69
- # Windows
70
- .\install.ps1
71
- ```
66
+ 不想先发版也能让 opencode 加载**当前工作树**——`plugins` 里给一个**绝对路径目录**即可
67
+ (opencode 对绝对路径按「本地目录插件」处理;该目录需含 `index.ts` 与 `tui.ts`):
72
68
 
73
- 脚本把 `plugin/opencode-providers/` 整目录装到:
74
-
75
- ```
76
- ~/.config/opencode/plugins/opencode-providers/ # $XDG_CONFIG_HOME 优先
69
+ ```jsonc
70
+ {
71
+ "plugins": [
72
+ { "package": "E:/Code/Projects/Agent/opencode-providers/plugin/opencode-providers" }
73
+ ]
74
+ }
77
75
  ```
78
76
 
79
- 装完**通常无需重启**(插件目录被文件监视,覆盖后自动热重载);必要时 `opencode service restart`。
80
- 卸载:`./uninstall.sh` / `.\uninstall.ps1`。
81
-
82
- 开发时把**工作树**(含未提交改动)直接部署到全局插件目录:
83
-
84
- ```bash
85
- bash install.sh --local
86
- ```
87
- ```powershell
88
- pwsh -NoProfile -File .\install.ps1 -Local
89
- ```
77
+ 这样改完源码会被文件监视热重载,无需发布、也无需 `npm install`。
78
+ (它和 npm 包**同 id**,别同时留着两条,否则 `/plugins` 面板会出现一条 `failed`。)
90
79
 
91
80
  ## 使用
92
81
 
@@ -187,7 +176,7 @@ registry/
187
176
  - **不做参数推断**:注册表写什么就是什么,插件不会去猜 `limit`/`cost`。
188
177
  - **不调供应商 API**:不访问 `/v1/models`,也就没有运行期的额外网络与失败面。
189
178
  - **不声明 `env` 认证**:本插件只走 `/connect`-式交互;opencode 的 env 是静默旁路且不在 `/connect` 里展示。
190
- - 账号重命名/删除请用内置 `/connect`(同一个 integration,同一个凭据表)。
179
+ - 不改注册表来源时**不需要** `opencode.json` 里的 `providers` 声明(那是另一条路线)。
191
180
 
192
181
  ## 开发
193
182
 
@@ -231,9 +220,9 @@ push tag `vX.Y.Z-beta.n`(或 Actions → Release → Run workflow 勾 `publish
231
220
 
232
221
  | 现象 | 原因 / 处理 |
233
222
  |---|---|
234
- | `/plugins` 面板里本插件有**一条 `failed`** | 同一插件 id 被发现两次(例:脚本装到 `~/.config/opencode/plugins/` + 又在 `opencode.json` 里配了 npm 包,或某个项目的 `.opencode/plugins/` 里也有一份)。supervisor 保留首个、把后来者标成 `failed`,错误信息是 `Duplicate plugin ID: opencode-providers`。删掉多余的那份即可 |
223
+ | `/plugins` 面板里本插件有**一条 `failed`** | 同一插件 id 被发现两次(例:`opencode.json` 里配了 npm 包,某个项目的 `.opencode/plugins/` 里又放了一份;或本地绝对路径与 npm 包同时留着)。supervisor 保留首个、把后来者标成 `failed`,错误信息是 `Duplicate plugin ID: opencode-providers`。删掉多余的那份即可 |
235
224
  | `/connect-providers` 说没有可用供应商 | 注册表没加载成功。看 server 日志里的 `[opencode-providers]`,并确认 `/api/plugin` 里自己那条 `state.status` 是 `active` |
236
225
  | `/models` 里看不到新供应商 | 正常:`activation: "auto"`,在 `/connect-providers` 里存过 key 之后才会出现 |
237
- | 改了插件代码没生效 | 覆盖的是**全局**那份(`~/.config/opencode/plugins/opencode-providers/`)。开发时用 `install.ps1 -Local` / `install.sh --local` 把工作树复制过去 |
226
+ | 改了插件代码没生效 | 改的是**工作树**,但 opencode 加载的是 npm 缓存里那份(`~/.cache/opencode/npm/@justsilver/opencode-providers@…/`)。开发时改用上面的「本地开发:直接跑工作树」把 `plugins` 指到工作树目录 |
238
227
 
239
228
  验证命令见上面的「开发」小节。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@justsilver/opencode-providers",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Register providers missing from the models.dev catalog in OpenCode V2 from a self-hosted registry — no opencode.json needed",
6
6
  "license": "MIT",
@@ -2,13 +2,16 @@
2
2
  * `/connect-providers` flow.
3
3
  *
4
4
  * Mirrors the built-in `/connect` interaction for the providers this plugin
5
- * registers: pick a provider, then either add an account (paste an API key) or
6
- * activate one that already exists. Everything goes through the normal server
7
- * API, so credentials land in opencode's own store.
5
+ * registers: pick a provider, then either add an account (paste an API key),
6
+ * activate one that already exists, rename it, or delete it. Everything goes
7
+ * through the normal server API, so credentials land in opencode's own store.
8
8
  *
9
- * Out of scope on purpose (use the built-in `/connect` for these): renaming and
10
- * deleting accounts. Renaming needs a bound footer action; deletion needs a
11
- * confirm binding. Both are cosmetic actions on the same records.
9
+ * Why this looks a bit different from the built-in dialog: the plugin API
10
+ * (`ctx.ui.dialog.select`) is a one-shot, non-reactive wrapper. Options are
11
+ * frozen when the prompt opens and actions cannot be hidden or disabled, so we
12
+ * cannot mutate a row in place the way the core `DialogIntegration` does. This
13
+ * flow instead re-opens the account manager after every mutation to show fresh
14
+ * state, and uses `dialog.confirm` for the delete confirmation.
12
15
  */
13
16
 
14
17
  import type { IntegrationInfo } from "@opencode/client"
@@ -18,52 +21,88 @@ import { registryRpc } from "../rpc.ts"
18
21
 
19
22
  type Context = Plugin.Context
20
23
  type Connection = IntegrationInfo["connections"][number]
24
+ type CredentialConnection = Extract<Connection, { type: "credential" }>
21
25
 
22
26
  const INTEGRATION_SOURCE = "opencode-providers"
23
27
  const ADD_ACCOUNT = "\u0000add-account"
24
28
 
25
29
  export async function connectProviders(ctx: Context): Promise<void> {
26
- const integrations = ownIntegrations(ctx).toSorted((a, b) => a.name.localeCompare(b.name))
30
+ while (true) {
31
+ const integrations = ownIntegrations(ctx).toSorted((a, b) => a.name.localeCompare(b.name))
27
32
 
28
- if (integrations.length === 0) {
29
- // Nothing to connect yet — offer the refresh here rather than making the user
30
- // restart the service and wait out the 6h TTL.
31
- const confirmed = await ctx.ui.dialog.confirm({
33
+ if (integrations.length === 0) {
34
+ // Nothing to connect yet — offer the refresh here rather than making the user
35
+ // restart the service and wait out the 6h TTL.
36
+ const confirmed = await ctx.ui.dialog.confirm({
37
+ title: "Connect providers",
38
+ message:
39
+ "No providers from the registry are available. Force a refresh to re-fetch the registry, then run /connect-providers again.",
40
+ label: { confirm: "Force refresh" },
41
+ })
42
+ if (confirmed) {
43
+ if (await refreshWithProgress(ctx)) {
44
+ ctx.ui.toast.show({
45
+ variant: "info",
46
+ message: "Reloaded. Run /connect-providers again to connect a provider.",
47
+ })
48
+ }
49
+ }
50
+ return
51
+ }
52
+
53
+ let refreshRequested = false
54
+ const selected = await ctx.ui.dialog.select<string>({
32
55
  title: "Connect providers",
33
- message:
34
- "No providers from the registry are available. Force a refresh to re-fetch the registry, then run /connect-providers again.",
35
- label: { confirm: "Force refresh" },
56
+ options: integrations.map((integration) => ({
57
+ title: integration.name,
58
+ value: integration.id,
59
+ footer: footer(integration),
60
+ })),
61
+ actions: [
62
+ {
63
+ title: "Force refresh",
64
+ bind: "ctrl+r",
65
+ selection: "none",
66
+ onTrigger: () => {
67
+ refreshRequested = true
68
+ ctx.ui.dialog.clear()
69
+ },
70
+ },
71
+ ],
36
72
  })
37
- if (confirmed) {
38
- if (await forceRefresh(ctx)) {
39
- ctx.ui.toast.show({ variant: "info", message: "Reloaded. Run /connect-providers again to connect a provider." })
40
- }
73
+
74
+ // The dialog is static, so a triggered action only records intent and closes
75
+ // the prompt; the work (and the re-open with fresh data) happens out here.
76
+ if (refreshRequested) {
77
+ await refreshWithProgress(ctx)
78
+ continue
41
79
  }
42
- return
43
- }
80
+ if (selected === undefined) return
44
81
 
45
- const selected = await ctx.ui.dialog.select<string>({
46
- title: "Connect providers",
47
- options: integrations.map((integration) => ({
48
- title: integration.name,
49
- value: integration.id,
50
- footer: footer(integration),
51
- })),
52
- actions: [
53
- {
54
- title: "Force refresh",
55
- bind: "ctrl+r",
56
- selection: "none",
57
- onTrigger: () => void forceRefresh(ctx),
58
- },
59
- ],
60
- })
61
- if (selected === undefined) return
82
+ const integration = integrations.find((item) => item.id === selected)
83
+ if (integration === undefined) return
62
84
 
63
- const integration = integrations.find((item) => item.id === selected)
64
- if (integration === undefined) return
85
+ await openProvider(ctx, integration)
86
+ return
87
+ }
88
+ }
65
89
 
66
- await openProvider(ctx, integration)
90
+ /**
91
+ * Re-fetch the registry behind a blocking modal, then drop the modal.
92
+ *
93
+ * `dialog.alert` is the only JSX-free primitive that actually blocks: while it
94
+ * is open the host pushes the `modal` keymap mode, so the prompt (and therefore
95
+ * `/connect-providers` and the `ctrl+r` binding that lives on the list dialog)
96
+ * is unreachable. The modal has `ok`/`esc` affordances, but we always close it
97
+ * ourselves in `finally` — the user never has to.
98
+ */
99
+ async function refreshWithProgress(ctx: Context): Promise<boolean> {
100
+ void ctx.ui.dialog.alert({ title: "Connect providers", message: "Refreshing registry…" })
101
+ try {
102
+ return await forceRefresh(ctx)
103
+ } finally {
104
+ ctx.ui.dialog.clear()
105
+ }
67
106
  }
68
107
 
69
108
  /**
@@ -71,8 +110,23 @@ export async function connectProviders(ctx: Context): Promise<void> {
71
110
  * provider list. Returns whether it succeeded. A failure only surfaces as an
72
111
  * error toast: the previously registered providers stay usable, so nothing is
73
112
  * invalidated.
113
+ *
114
+ * Single-flight: concurrent callers share one refresh. The blocking modal makes
115
+ * a second trigger hard to reach, but the command palette is `mode: "global"`
116
+ * and can still re-enter this flow, so the guard keeps network work idempotent.
74
117
  */
75
- export async function forceRefresh(ctx: Context): Promise<boolean> {
118
+ let refreshInFlight: Promise<boolean> | undefined
119
+
120
+ export function forceRefresh(ctx: Context): Promise<boolean> {
121
+ if (refreshInFlight) return refreshInFlight
122
+ const pending = doRefresh(ctx).finally(() => {
123
+ if (refreshInFlight === pending) refreshInFlight = undefined
124
+ })
125
+ refreshInFlight = pending
126
+ return pending
127
+ }
128
+
129
+ async function doRefresh(ctx: Context): Promise<boolean> {
76
130
  try {
77
131
  const result = await ctx.client.rpc(registryRpc).refresh({})
78
132
  if (!result.ok) {
@@ -92,45 +146,141 @@ export async function forceRefresh(ctx: Context): Promise<boolean> {
92
146
  }
93
147
  }
94
148
 
149
+ /**
150
+ * Account manager. Loops so mutations (add / activate / rename / delete) land on
151
+ * a freshly re-read account list while the prompt stays open, matching the
152
+ * built-in behaviour. Returns when the user backs out.
153
+ */
95
154
  async function openProvider(ctx: Context, integration: IntegrationInfo): Promise<void> {
96
- const credentials = credentialConnections(integration)
97
- const active = credentials[0]
155
+ while (true) {
156
+ const current = integrationByID(ctx, integration.id) ?? integration
157
+ const credentials = credentialConnections(current)
158
+ const active = credentials[0]
98
159
 
99
- if (credentials.length === 0) {
100
- await connectKey(ctx, integration)
101
- return
160
+ if (credentials.length === 0) {
161
+ await connectKey(ctx, current)
162
+ return
163
+ }
164
+
165
+ let rename: string | undefined
166
+ let remove: string | undefined
167
+ const choice = await ctx.ui.dialog.select<string>({
168
+ title: current.name,
169
+ current: active?.id,
170
+ options: [
171
+ {
172
+ title: "Add account",
173
+ value: ADD_ACCOUNT,
174
+ description: "Paste another API key; it becomes the active account",
175
+ },
176
+ ...credentials.toSorted((a, b) => a.label.localeCompare(b.label)).map((credential) => ({
177
+ title: credential.label,
178
+ value: credential.id,
179
+ category: "Accounts",
180
+ footer: credential.id === active?.id ? "active" : undefined,
181
+ })),
182
+ ],
183
+ actions: [
184
+ {
185
+ title: "rename",
186
+ bind: "ctrl+r",
187
+ selection: "required",
188
+ onTrigger: (value) => {
189
+ // Actions cannot be hidden while "Add account" is focused, so guard here.
190
+ if (value === ADD_ACCOUNT) return
191
+ rename = value
192
+ ctx.ui.dialog.clear()
193
+ },
194
+ },
195
+ {
196
+ title: "delete",
197
+ bind: "ctrl+d",
198
+ selection: "required",
199
+ onTrigger: (value) => {
200
+ if (value === ADD_ACCOUNT) return
201
+ remove = value
202
+ ctx.ui.dialog.clear()
203
+ },
204
+ },
205
+ ],
206
+ })
207
+
208
+ if (rename !== undefined) {
209
+ await renameAccount(ctx, current, rename)
210
+ continue
211
+ }
212
+ if (remove !== undefined) {
213
+ if (await deleteAccount(ctx, current, remove)) return
214
+ continue
215
+ }
216
+
217
+ if (choice === undefined) return
218
+
219
+ if (choice === ADD_ACCOUNT) {
220
+ await connectKey(ctx, current)
221
+ continue
222
+ }
223
+
224
+ if (choice === active?.id) continue
225
+ const credential = credentials.find((item) => item.id === choice)
226
+ try {
227
+ await ctx.client.credential.activate({ credentialID: choice })
228
+ await reloadIntegrations(ctx)
229
+ ctx.ui.toast.show({ variant: "success", message: `Activated ${credential?.label ?? choice}` })
230
+ } catch (error) {
231
+ ctx.ui.toast.show({ variant: "error", message: message(error) })
232
+ }
102
233
  }
234
+ }
103
235
 
104
- const choice = await ctx.ui.dialog.select<string>({
105
- title: integration.name,
106
- current: active?.id,
107
- options: [
108
- {
109
- title: "Add account",
110
- value: ADD_ACCOUNT,
111
- description: "Paste another API key; it becomes the active account",
112
- },
113
- ...credentials.toSorted((a, b) => a.label.localeCompare(b.label)).map((credential) => ({
114
- title: credential.label,
115
- value: credential.id,
116
- category: "Accounts",
117
- footer: credential.id === active?.id ? "active" : undefined,
118
- })),
119
- ],
236
+ async function renameAccount(ctx: Context, integration: IntegrationInfo, credentialID: string): Promise<void> {
237
+ const credential = credentialConnections(integration).find((item) => item.id === credentialID)
238
+ if (credential === undefined) return
239
+
240
+ const value = await ctx.ui.dialog.prompt({
241
+ title: "Rename account",
242
+ placeholder: "Account name",
243
+ value: credential.label,
120
244
  })
121
- if (choice === undefined) return
245
+ if (value === undefined) return
122
246
 
123
- if (choice === ADD_ACCOUNT) {
124
- await connectKey(ctx, integration)
125
- return
247
+ const label = value.trim()
248
+ if (label === "" || label === credential.label) return
249
+
250
+ try {
251
+ await ctx.client.credential.update({ credentialID, label })
252
+ await reloadIntegrations(ctx)
253
+ ctx.ui.toast.show({ variant: "success", message: `Renamed to ${label}` })
254
+ } catch (error) {
255
+ ctx.ui.toast.show({ variant: "error", message: message(error) })
126
256
  }
257
+ }
258
+
259
+ /** Returns whether the manager should close (the last account was removed). */
260
+ async function deleteAccount(ctx: Context, integration: IntegrationInfo, credentialID: string): Promise<boolean> {
261
+ const credentials = credentialConnections(integration)
262
+ const credential = credentials.find((item) => item.id === credentialID)
263
+ if (credential === undefined) return false
264
+
265
+ const confirmed = await ctx.ui.dialog.confirm({
266
+ title: "Delete account",
267
+ message: `Delete "${credential.label}" from ${integration.name}?`,
268
+ label: { confirm: "Delete", cancel: "Cancel" },
269
+ })
270
+ if (confirmed !== true) return false
127
271
 
128
- const credential = credentials.find((item) => item.id === choice)
272
+ const last = credentials.length === 1
129
273
  try {
130
- await ctx.client.credential.activate({ credentialID: choice })
131
- ctx.ui.toast.show({ variant: "success", message: `Activated ${credential?.label ?? choice}` })
274
+ await ctx.client.credential.remove({ credentialID })
275
+ await reloadIntegrations(ctx)
276
+ if (last) {
277
+ ctx.ui.toast.show({ variant: "success", message: `Disconnected ${integration.name}` })
278
+ return true
279
+ }
280
+ return false
132
281
  } catch (error) {
133
282
  ctx.ui.toast.show({ variant: "error", message: message(error) })
283
+ return false
134
284
  }
135
285
  }
136
286
 
@@ -147,22 +297,36 @@ async function connectKey(ctx: Context, integration: IntegrationInfo): Promise<v
147
297
 
148
298
  try {
149
299
  await ctx.client.integration.connect.key({ integrationID: integration.id, key: key.trim() })
150
- ctx.data.location.integration.invalidate(ctx.location)
300
+ await reloadIntegrations(ctx)
151
301
  ctx.ui.toast.show({ variant: "success", message: `${integration.name} connected` })
152
302
  } catch (error) {
153
303
  ctx.ui.toast.show({ variant: "error", message: message(error) })
154
304
  }
155
305
  }
156
306
 
307
+ /** Drop the cached integration list and re-read it so the loop sees fresh accounts. */
308
+ async function reloadIntegrations(ctx: Context): Promise<void> {
309
+ ctx.data.location.integration.invalidate(ctx.location)
310
+ try {
311
+ await ctx.data.location.integration.sync(ctx.location)
312
+ } catch {
313
+ // A failed sync still leaves `list()` readable; the next action retries.
314
+ }
315
+ }
316
+
157
317
  function ownIntegrations(ctx: Context): IntegrationInfo[] {
158
318
  return (ctx.data.location.integration.list(ctx.location) ?? []).filter(
159
319
  (integration) => integration.metadata?.source === INTEGRATION_SOURCE,
160
320
  )
161
321
  }
162
322
 
163
- function credentialConnections(integration: IntegrationInfo): Array<Extract<Connection, { type: "credential" }>> {
323
+ function integrationByID(ctx: Context, id: string): IntegrationInfo | undefined {
324
+ return ownIntegrations(ctx).find((integration) => integration.id === id)
325
+ }
326
+
327
+ function credentialConnections(integration: IntegrationInfo): CredentialConnection[] {
164
328
  return integration.connections.filter(
165
- (connection): connection is Extract<Connection, { type: "credential" }> => connection.type === "credential",
329
+ (connection): connection is CredentialConnection => connection.type === "credential",
166
330
  )
167
331
  }
168
332