@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 +17 -1
- package/README.md +20 -31
- package/package.json +1 -1
- package/plugin/opencode-providers/view/connect.ts +236 -72
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.
|
|
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/ ←
|
|
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/`
|
|
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
|
-
|
|
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
|
-
|
|
61
|
+
装完**通常无需重启**(插件被文件监视,覆盖后自动热重载);必要时 `opencode service restart`。
|
|
62
|
+
卸载:`opencode plugin remove @justsilver/opencode-providers`(或把 `plugins` 里那一行删掉)。
|
|
62
63
|
|
|
63
|
-
|
|
64
|
-
# Linux / macOS
|
|
65
|
-
./install.sh
|
|
66
|
-
```
|
|
64
|
+
### 本地开发:直接跑工作树
|
|
67
65
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
.\install.ps1
|
|
71
|
-
```
|
|
66
|
+
不想先发版也能让 opencode 加载**当前工作树**——`plugins` 里给一个**绝对路径目录**即可
|
|
67
|
+
(opencode 对绝对路径按「本地目录插件」处理;该目录需含 `index.ts` 与 `tui.ts`):
|
|
72
68
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
69
|
+
```jsonc
|
|
70
|
+
{
|
|
71
|
+
"plugins": [
|
|
72
|
+
{ "package": "E:/Code/Projects/Agent/opencode-providers/plugin/opencode-providers" }
|
|
73
|
+
]
|
|
74
|
+
}
|
|
77
75
|
```
|
|
78
76
|
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
-
|
|
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
|
|
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
|
-
| 改了插件代码没生效 |
|
|
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.
|
|
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)
|
|
6
|
-
* activate one that already exists
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
|
|
30
|
+
while (true) {
|
|
31
|
+
const integrations = ownIntegrations(ctx).toSorted((a, b) => a.name.localeCompare(b.name))
|
|
27
32
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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
|
-
|
|
64
|
-
|
|
85
|
+
await openProvider(ctx, integration)
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
}
|
|
65
89
|
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
155
|
+
while (true) {
|
|
156
|
+
const current = integrationByID(ctx, integration.id) ?? integration
|
|
157
|
+
const credentials = credentialConnections(current)
|
|
158
|
+
const active = credentials[0]
|
|
98
159
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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 (
|
|
245
|
+
if (value === undefined) return
|
|
122
246
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
272
|
+
const last = credentials.length === 1
|
|
129
273
|
try {
|
|
130
|
-
await ctx.client.credential.
|
|
131
|
-
ctx
|
|
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
|
-
|
|
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
|
|
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
|
|
329
|
+
(connection): connection is CredentialConnection => connection.type === "credential",
|
|
166
330
|
)
|
|
167
331
|
}
|
|
168
332
|
|