opencode-ufr 0.2.2 → 0.2.4
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 +25 -0
- package/package.json +1 -1
- package/src/cli/index.ts +5 -1
- package/src/cli/keys.ts +36 -1
- package/src/plugin/connect.ts +61 -16
- package/src/plugin/index.ts +85 -43
- package/src/shared/connect.ts +13 -4
package/README.md
CHANGED
|
@@ -23,6 +23,23 @@ opencode plugin add opencode-ufr # registers the plugin
|
|
|
23
23
|
|
|
24
24
|
(or add `"plugins": ["opencode-ufr"]` to your `opencode.json` manually.)
|
|
25
25
|
|
|
26
|
+
## Updating
|
|
27
|
+
|
|
28
|
+
opencode loads plugins inside a background server (`opencode serve --service`)
|
|
29
|
+
that keeps running after you close the TUI, and it keeps using the plugin
|
|
30
|
+
version it loaded when it started — a new release only arrives once that server
|
|
31
|
+
restarts:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
bun add -g opencode-ufr@latest # update the `ufr` CLI
|
|
35
|
+
pkill -f "opencode serve --service" # stop opencode's background server
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The next opencode start installs the latest plugin. If it still loads the old
|
|
39
|
+
one, delete opencode's cached copy (`~/.cache/opencode/npm/opencode-ufr@latest`)
|
|
40
|
+
and start opencode again. On Windows, end the `opencode` server process in Task
|
|
41
|
+
Manager instead of `pkill`.
|
|
42
|
+
|
|
26
43
|
## Setup
|
|
27
44
|
|
|
28
45
|
**Inside opencode — the normal way:** open `/connect`, pick **Uni Freiburg**,
|
|
@@ -60,8 +77,16 @@ ufr stats # requests, tokens and cost
|
|
|
60
77
|
ufr keys list # stored aliases
|
|
61
78
|
ufr keys test # check your keys against UFR
|
|
62
79
|
ufr login show # the stored uni login
|
|
80
|
+
ufr disconnect # remove everything: keys, uni login, config, gateway
|
|
63
81
|
```
|
|
64
82
|
|
|
83
|
+
Removing the **unifreiburg** integration in opencode's `/connect` panel does the
|
|
84
|
+
same as `ufr disconnect`: the stored keys and uni login are wiped, the gateway
|
|
85
|
+
stops, and the models disappear from the picker on the next opencode start.
|
|
86
|
+
While opencode runs, connecting and removing are noticed within seconds. If
|
|
87
|
+
opencode was closed when you removed the integration, run `ufr disconnect` once
|
|
88
|
+
to wipe what is left.
|
|
89
|
+
|
|
65
90
|
## The built-in VPN
|
|
66
91
|
|
|
67
92
|
On the campus network (or the VPN you already run) UFR is reached directly and
|
package/package.json
CHANGED
package/src/cli/index.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { KeyringStore, type SecretStore } from "../shared/secrets"
|
|
|
5
5
|
import { cmdCatalogDiff } from "./catalog"
|
|
6
6
|
import { cmdConnect } from "./connect"
|
|
7
7
|
import { type Io, terminalIo } from "./io"
|
|
8
|
-
import { cmdKeys, cmdLogin } from "./keys"
|
|
8
|
+
import { cmdDisconnect, cmdKeys, cmdLogin } from "./keys"
|
|
9
9
|
import { cmdStats } from "./stats"
|
|
10
10
|
import { cmdStatus } from "./status"
|
|
11
11
|
import { cmdStop } from "./stop"
|
|
@@ -25,6 +25,8 @@ const HELP = `ufr — opencode-ufr gateway
|
|
|
25
25
|
[--keys "<k1>,<k2>"] (comma/newline separated,
|
|
26
26
|
whitespace filtered), optional uni login for the
|
|
27
27
|
built-in VPN [--login <u> --password <p>]
|
|
28
|
+
ufr disconnect remove everything: stored keys, uni login,
|
|
29
|
+
config and the running gateway
|
|
28
30
|
ufr keys list | remove <alias> | test [alias] manage stored keys
|
|
29
31
|
ufr login show | remove the uni login for the built-in VPN
|
|
30
32
|
ufr status gateway, keys, limits, breakers, spend today
|
|
@@ -55,6 +57,8 @@ export async function main(argv: string[], partial: Partial<CliDeps> = {}): Prom
|
|
|
55
57
|
switch (cmd) {
|
|
56
58
|
case "connect":
|
|
57
59
|
return await cmdConnect(d, rest)
|
|
60
|
+
case "disconnect":
|
|
61
|
+
return await cmdDisconnect(d)
|
|
58
62
|
case "keys":
|
|
59
63
|
return await cmdKeys(d, rest)
|
|
60
64
|
case "login":
|
package/src/cli/keys.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { loadConfig, saveConfig } from "../shared/config"
|
|
2
2
|
import type { Paths } from "../shared/paths"
|
|
3
|
-
import { VPN_PASS, VPN_USER } from "../shared/secrets"
|
|
3
|
+
import { VPN_PASS, VPN_USER, type SecretStore } from "../shared/secrets"
|
|
4
4
|
import { daemonRequest } from "./daemon-client"
|
|
5
5
|
import type { FetchLike } from "../daemon/catalog-source"
|
|
6
6
|
import type { CliDeps } from "./index"
|
|
@@ -29,6 +29,41 @@ export async function applyKeyChange(d: { paths: Paths; fetch: FetchLike; log: (
|
|
|
29
29
|
: "could not stop the gateway — run `ufr stop` so it picks up the new keys")
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
export type DisconnectResult = { removed: string[]; loginRemoved: boolean }
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Provider-removal cleanup: delete every stored key alias and the uni login
|
|
36
|
+
* from the keyring, clear config.keys, and stop an idle gateway (it reads its
|
|
37
|
+
* keys at start, so a later start serves nothing). Idempotent.
|
|
38
|
+
*/
|
|
39
|
+
export async function disconnectAll(d: {
|
|
40
|
+
paths: Paths
|
|
41
|
+
secrets: SecretStore
|
|
42
|
+
fetch: FetchLike
|
|
43
|
+
log: (m: string) => void
|
|
44
|
+
}): Promise<DisconnectResult> {
|
|
45
|
+
const cfg = await loadConfig(d.paths.configFile)
|
|
46
|
+
const removed: string[] = []
|
|
47
|
+
for (const alias of cfg.keys) {
|
|
48
|
+
if (await d.secrets.delete(alias)) removed.push(alias)
|
|
49
|
+
}
|
|
50
|
+
const userRemoved = await d.secrets.delete(VPN_USER)
|
|
51
|
+
const passRemoved = await d.secrets.delete(VPN_PASS)
|
|
52
|
+
const loginRemoved = userRemoved || passRemoved
|
|
53
|
+
cfg.keys = []
|
|
54
|
+
await saveConfig(d.paths.configFile, cfg)
|
|
55
|
+
await applyKeyChange(d)
|
|
56
|
+
return { removed, loginRemoved }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function cmdDisconnect(d: CliDeps): Promise<number> {
|
|
60
|
+
const r = await disconnectAll({ paths: d.paths, secrets: d.secrets, fetch: d.fetch, log: (m) => d.io.out(m + "\n") })
|
|
61
|
+
d.io.out(r.removed.length > 0 ? `removed from keyring: ${r.removed.join(", ")}\n` : "nothing was stored\n")
|
|
62
|
+
if (r.loginRemoved) d.io.out("removed the uni login\n")
|
|
63
|
+
d.io.out("done — reconnect any time with `ufr connect` or /connect in opencode\n")
|
|
64
|
+
return 0
|
|
65
|
+
}
|
|
66
|
+
|
|
32
67
|
export async function cmdKeys(d: CliDeps, args: string[]): Promise<number> {
|
|
33
68
|
const [sub, ...rest] = args
|
|
34
69
|
const cfg = await loadConfig(d.paths.configFile)
|
package/src/plugin/connect.ts
CHANGED
|
@@ -9,15 +9,25 @@
|
|
|
9
9
|
|
|
10
10
|
import { applyConnect, splitKeys } from "../shared/connect"
|
|
11
11
|
import type { Paths } from "../shared/paths"
|
|
12
|
-
import { applyKeyChange } from "../cli/keys"
|
|
12
|
+
import { applyKeyChange, disconnectAll } from "../cli/keys"
|
|
13
13
|
|
|
14
14
|
export const UFR_INTEGRATION_ID = "unifreiburg"
|
|
15
15
|
|
|
16
|
+
/** opencode has no connection events for plugins, so the watcher polls; seconds keep /connect feeling immediate. */
|
|
17
|
+
export const CONNECT_POLL_MS = 5_000
|
|
18
|
+
|
|
16
19
|
export type ConnectDeps = {
|
|
17
20
|
ctx: any // opencode plugin context (integration + storage domains)
|
|
18
21
|
paths: Paths
|
|
19
22
|
secrets: { get(alias: string): Promise<string | null>; set(alias: string, value: string): Promise<void>; delete(alias: string): Promise<boolean> }
|
|
20
23
|
log: (m: string) => void
|
|
24
|
+
/** (Re-)registers the provider with opencode — called on first connect and after a removal+reconnect.
|
|
25
|
+
* Returns false when the registration did not succeed (e.g. no models yet); the watcher retries. */
|
|
26
|
+
register?: () => Promise<boolean | void> | boolean | void
|
|
27
|
+
/** Whether setup() already registered the provider (it does when a connection exists at startup). */
|
|
28
|
+
providerRegistered?: boolean
|
|
29
|
+
/** Poll interval override (tests). */
|
|
30
|
+
pollMs?: number
|
|
21
31
|
}
|
|
22
32
|
|
|
23
33
|
type CredentialValue = { type?: string; key?: string; configuration?: Record<string, unknown> } | undefined
|
|
@@ -73,28 +83,52 @@ export async function registerConnect(d: ConnectDeps): Promise<ConnectRegistrati
|
|
|
73
83
|
})
|
|
74
84
|
})
|
|
75
85
|
|
|
76
|
-
// Apply a submitted credential once, then watch for changes.
|
|
86
|
+
// Apply a submitted credential once, then watch for changes. Phases:
|
|
87
|
+
// "registered" (provider live), "unregistered" (no provider yet), and
|
|
88
|
+
// "disconnected" (the connection was removed in opencode — everything
|
|
89
|
+
// wiped; a later connection re-applies and re-registers).
|
|
90
|
+
type Phase = "registered" | "unregistered" | "disconnected"
|
|
91
|
+
let phase: Phase = d.providerRegistered ? "registered" : "unregistered"
|
|
77
92
|
let lastApplied = ""
|
|
78
93
|
const applyOnce = async (): Promise<boolean> => {
|
|
79
94
|
try {
|
|
80
95
|
const connection = await ctx.integration.connection.active(UFR_INTEGRATION_ID)
|
|
81
|
-
if (!connection)
|
|
96
|
+
if (!connection) {
|
|
97
|
+
if (phase === "registered") {
|
|
98
|
+
phase = "disconnected"
|
|
99
|
+
lastApplied = ""
|
|
100
|
+
const r = await disconnectAll({ paths: d.paths, secrets: d.secrets, fetch: (u, i) => fetch(u, i), log: d.log })
|
|
101
|
+
d.log(`unifreiburg removed in opencode — wiped from keyring: ${r.removed.length > 0 ? r.removed.join(", ") : "nothing"}${r.loginRemoved ? " + uni login" : ""}`)
|
|
102
|
+
return true // the disconnect cleaned something up — a state change
|
|
103
|
+
}
|
|
104
|
+
return false
|
|
105
|
+
}
|
|
82
106
|
const cred = (await ctx.integration.connection.resolve(connection)) as CredentialValue
|
|
83
107
|
const sig = JSON.stringify(cred?.configuration ?? {}) + "|" + (cred?.key ?? "")
|
|
84
|
-
|
|
85
|
-
lastApplied
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
108
|
+
let changed = false
|
|
109
|
+
if (sig !== lastApplied) {
|
|
110
|
+
lastApplied = sig
|
|
111
|
+
const input = credentialToInput(cred)
|
|
112
|
+
if (!input) {
|
|
113
|
+
d.log("/connect credential has no keys — nothing applied")
|
|
114
|
+
return false
|
|
115
|
+
}
|
|
116
|
+
const r = await applyConnect(input, { paths: d.paths, secrets: d.secrets, log: d.log })
|
|
117
|
+
if (r.errors.length > 0) {
|
|
118
|
+
for (const e of r.errors) d.log(`/connect: ${e}`)
|
|
119
|
+
lastApplied = "" // retry on the next poll
|
|
120
|
+
return false
|
|
121
|
+
}
|
|
122
|
+
if (r.changed) await applyKeyChange({ paths: d.paths, fetch: (u, i) => fetch(u, i), log: d.log })
|
|
123
|
+
changed = true
|
|
90
124
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
125
|
+
if (phase !== "registered") {
|
|
126
|
+
// first connect mid-session, or a reconnect after a removal
|
|
127
|
+
const ok = await d.register?.()
|
|
128
|
+
if (ok !== false) phase = "registered" // false = registration failed — retry on the next poll
|
|
129
|
+
changed = true
|
|
95
130
|
}
|
|
96
|
-
|
|
97
|
-
return true
|
|
131
|
+
return changed
|
|
98
132
|
} catch (e) {
|
|
99
133
|
d.log(`/connect apply failed: ${(e as Error).message}`)
|
|
100
134
|
return false
|
|
@@ -102,7 +136,18 @@ export async function registerConnect(d: ConnectDeps): Promise<ConnectRegistrati
|
|
|
102
136
|
}
|
|
103
137
|
|
|
104
138
|
await applyOnce()
|
|
105
|
-
|
|
139
|
+
// A registration can take ~40 s (gateway boot) — never start a second one while it runs.
|
|
140
|
+
let busy = false
|
|
141
|
+
const tick = async () => {
|
|
142
|
+
if (busy) return
|
|
143
|
+
busy = true
|
|
144
|
+
try {
|
|
145
|
+
await applyOnce()
|
|
146
|
+
} finally {
|
|
147
|
+
busy = false
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const timer = setInterval(() => void tick(), d.pollMs ?? CONNECT_POLL_MS)
|
|
106
151
|
;(timer as { unref?: () => void }).unref?.()
|
|
107
152
|
return {
|
|
108
153
|
stop: () => clearInterval(timer),
|
package/src/plugin/index.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { fileURLToPath } from "node:url"
|
|
2
2
|
import type { OpencodeStamp } from "../daemon/catalog"
|
|
3
|
+
import { daemonRequest } from "../cli/daemon-client"
|
|
3
4
|
import { type Paths, resolvePaths } from "../shared/paths"
|
|
4
5
|
import { KeyringStore } from "../shared/secrets"
|
|
5
6
|
import { VERSION } from "../shared/version"
|
|
6
7
|
import { type Conn, ensureDaemon, spawnDaemon } from "./ensure-daemon"
|
|
7
|
-
import { registerConnect } from "./connect"
|
|
8
|
+
import { registerConnect, UFR_INTEGRATION_ID } from "./connect"
|
|
8
9
|
import { toModelInfo } from "./model-info"
|
|
9
10
|
|
|
10
11
|
const DAEMON_ENTRY = fileURLToPath(new URL("../daemon/main.ts", import.meta.url))
|
|
@@ -17,13 +18,69 @@ async function connect(paths: Paths): Promise<Conn> {
|
|
|
17
18
|
return ensureDaemon({ paths, version: VERSION, spawn: () => spawnDaemon(DAEMON_ENTRY, paths.stateDir) })
|
|
18
19
|
}
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
/** Whether this opencode exposes the integration API (needed to see connection state). */
|
|
22
|
+
function hasIntegration(ctx: any): boolean {
|
|
23
|
+
return typeof ctx?.integration?.connection?.active === "function"
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Starts the daemon if needed, fetches its models and registers the provider with opencode. */
|
|
27
|
+
async function registerProvider(
|
|
28
|
+
ctx: any,
|
|
29
|
+
providerId: string,
|
|
30
|
+
providerName: string,
|
|
31
|
+
paths: Paths,
|
|
32
|
+
): Promise<boolean> {
|
|
33
|
+
let conn: Conn
|
|
34
|
+
try {
|
|
35
|
+
conn = await connect(paths)
|
|
36
|
+
} catch (e) {
|
|
37
|
+
log(`${(e as Error).message} — run \`ufr status\``)
|
|
38
|
+
return false
|
|
39
|
+
}
|
|
40
|
+
const list = await fetch(`http://127.0.0.1:${conn.port}/v1/models`, {
|
|
23
41
|
headers: { Authorization: `Bearer ${conn.token}` },
|
|
24
|
-
signal: AbortSignal.timeout(
|
|
42
|
+
signal: AbortSignal.timeout(10_000),
|
|
43
|
+
})
|
|
44
|
+
.then(async (r) => (r.ok ? ((await r.json()) as { data: { id: string; opencode: OpencodeStamp }[] }).data : []))
|
|
45
|
+
.catch(() => [])
|
|
46
|
+
if (list.length === 0) {
|
|
47
|
+
// Never register an empty provider: it breaks the whole model picker.
|
|
48
|
+
log("no models available — run `ufr setup`, connect to the uni VPN, then `ufr status` for details")
|
|
49
|
+
return false
|
|
50
|
+
}
|
|
51
|
+
if (typeof ctx?.provider?.transform !== "function") {
|
|
52
|
+
log("this opencode version has no ctx.provider.transform — cannot register the provider")
|
|
53
|
+
return false
|
|
54
|
+
}
|
|
55
|
+
const models = list.map((m) => toModelInfo(providerId, m.id, m.opencode))
|
|
56
|
+
await ctx.provider.transform((editor: any) => {
|
|
57
|
+
editor.add({
|
|
58
|
+
info: {
|
|
59
|
+
id: providerId,
|
|
60
|
+
name: providerName,
|
|
61
|
+
activation: "enabled",
|
|
62
|
+
package: "@opencode/ai/providers/openai-compatible",
|
|
63
|
+
settings: { baseURL: `http://127.0.0.1:${conn.port}/v1`, apiKey: conn.token },
|
|
64
|
+
},
|
|
65
|
+
models,
|
|
66
|
+
})
|
|
25
67
|
})
|
|
26
|
-
|
|
68
|
+
log(`registered ${providerId} with ${models.length} models`)
|
|
69
|
+
return true
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Keeps the daemon alive while the provider is connected. When the connection
|
|
74
|
+
* was removed in opencode, the daemon must stay down — the watcher wipes the
|
|
75
|
+
* keys and opencode no longer offers the provider.
|
|
76
|
+
*/
|
|
77
|
+
export async function heartbeat(ctx: any, paths: Paths): Promise<void> {
|
|
78
|
+
if (hasIntegration(ctx)) {
|
|
79
|
+
const active = await ctx.integration.connection.active(UFR_INTEGRATION_ID).catch(() => undefined)
|
|
80
|
+
if (!active) return
|
|
81
|
+
}
|
|
82
|
+
const ok = await daemonRequest(paths, (u, i) => fetch(u, i), "/v1/_client/heartbeat", "POST")
|
|
83
|
+
.then((r) => r?.ok ?? false)
|
|
27
84
|
.catch(() => false)
|
|
28
85
|
// Port and token are stable, so a restarted daemon serves the provider we already registered.
|
|
29
86
|
if (!ok) await connect(paths).catch((e) => log(`gateway restart failed: ${(e as Error).message}`))
|
|
@@ -37,53 +94,38 @@ export default {
|
|
|
37
94
|
const providerName = typeof options.providerName === "string" ? options.providerName : "Uni Freiburg"
|
|
38
95
|
const paths = resolvePaths()
|
|
39
96
|
|
|
40
|
-
let
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return
|
|
46
|
-
}
|
|
47
|
-
const list = await fetch(`http://127.0.0.1:${conn.port}/v1/models`, {
|
|
48
|
-
headers: { Authorization: `Bearer ${conn.token}` },
|
|
49
|
-
signal: AbortSignal.timeout(10_000),
|
|
50
|
-
})
|
|
51
|
-
.then(async (r) => (r.ok ? ((await r.json()) as { data: { id: string; opencode: OpencodeStamp }[] }).data : []))
|
|
52
|
-
.catch(() => [])
|
|
53
|
-
if (list.length === 0) {
|
|
54
|
-
// Never register an empty provider: it breaks the whole model picker.
|
|
55
|
-
log("no models available — run `ufr setup`, connect to the uni VPN, then `ufr status` for details")
|
|
56
|
-
return
|
|
57
|
-
}
|
|
58
|
-
if (typeof ctx?.provider?.transform !== "function") {
|
|
59
|
-
log("this opencode version has no ctx.provider.transform — cannot register the provider")
|
|
60
|
-
return
|
|
97
|
+
let registered = false
|
|
98
|
+
const registerProviderFn = async (): Promise<boolean> => {
|
|
99
|
+
if (registered) return true
|
|
100
|
+
registered = await registerProvider(ctx, providerId, providerName, paths)
|
|
101
|
+
return registered
|
|
61
102
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
info: {
|
|
66
|
-
id: providerId,
|
|
67
|
-
name: providerName,
|
|
68
|
-
activation: "enabled",
|
|
69
|
-
package: "@opencode/ai/providers/openai-compatible",
|
|
70
|
-
settings: { baseURL: `http://127.0.0.1:${conn.port}/v1`, apiKey: conn.token },
|
|
71
|
-
},
|
|
72
|
-
models,
|
|
73
|
-
})
|
|
74
|
-
})
|
|
75
|
-
log(`registered ${providerId} with ${models.length} models`)
|
|
76
|
-
// /connect integration: uni login (optional) form on the unifreiburg entry
|
|
103
|
+
|
|
104
|
+
// /connect integration first: it is the only way to connect when no
|
|
105
|
+
// provider is registered yet, so it must exist even while disconnected.
|
|
77
106
|
const stopConnect = await registerConnect({
|
|
78
107
|
ctx,
|
|
79
108
|
paths,
|
|
80
109
|
secrets: new KeyringStore(),
|
|
81
110
|
log,
|
|
111
|
+
register: registerProviderFn,
|
|
112
|
+
providerRegistered: false,
|
|
82
113
|
}).catch((e) => {
|
|
83
114
|
log(`/connect integration failed: ${(e as Error).message}`)
|
|
84
115
|
return { stop: () => {}, applyNow: async () => false }
|
|
85
116
|
})
|
|
86
|
-
|
|
117
|
+
|
|
118
|
+
// The provider is registered only while the integration is connected:
|
|
119
|
+
// removing it in opencode must remove the models too.
|
|
120
|
+
if (!hasIntegration(ctx)) {
|
|
121
|
+
await registerProviderFn() // old opencode without the integration API — register as before
|
|
122
|
+
} else {
|
|
123
|
+
const connection = await ctx.integration.connection.active(UFR_INTEGRATION_ID).catch(() => undefined)
|
|
124
|
+
if (connection) await registerProviderFn()
|
|
125
|
+
else log(`${providerId} not connected — open /connect in opencode to set it up`)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const beat = setInterval(() => void heartbeat(ctx, paths), 60_000)
|
|
87
129
|
;(beat as { unref?: () => void }).unref?.()
|
|
88
130
|
return () => {
|
|
89
131
|
clearInterval(beat)
|
package/src/shared/connect.ts
CHANGED
|
@@ -43,6 +43,8 @@ export type ConnectApplied = {
|
|
|
43
43
|
aliases: { alias: string; key: string }[]
|
|
44
44
|
removedAliases: string[]
|
|
45
45
|
loginStored: boolean
|
|
46
|
+
/** False when the submission was byte-identical to what is already stored — no gateway restart needed. */
|
|
47
|
+
changed: boolean
|
|
46
48
|
errors: string[]
|
|
47
49
|
}
|
|
48
50
|
|
|
@@ -60,15 +62,16 @@ export async function applyConnect(
|
|
|
60
62
|
const keys = splitKeys(input.keys)
|
|
61
63
|
if (keys.length === 0) {
|
|
62
64
|
errors.push("no keys given")
|
|
63
|
-
return { aliases: [], removedAliases: [], loginStored: false, errors }
|
|
65
|
+
return { aliases: [], removedAliases: [], loginStored: false, changed: false, errors }
|
|
64
66
|
}
|
|
65
67
|
if (input.login && !EMAIL_RE.test(input.login.trim())) {
|
|
66
68
|
errors.push(`"${input.login}" does not look like a uni login`)
|
|
67
|
-
return { aliases: [], removedAliases: [], loginStored: false, errors }
|
|
69
|
+
return { aliases: [], removedAliases: [], loginStored: false, changed: false, errors }
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
const cfg = await loadConfig(o.paths.configFile)
|
|
71
73
|
const custom = cfg.keys.filter((k) => !/^key\d+$/.test(k))
|
|
74
|
+
const beforeKeys = cfg.keys.join("\n")
|
|
72
75
|
|
|
73
76
|
// replace the managed key1…keyN aliases
|
|
74
77
|
const removedAliases: string[] = []
|
|
@@ -80,15 +83,21 @@ export async function applyConnect(
|
|
|
80
83
|
}
|
|
81
84
|
}
|
|
82
85
|
const aliases: { alias: string; key: string }[] = []
|
|
86
|
+
let changed = beforeKeys !== [...custom, ...wanted].join("\n") || removedAliases.length > 0
|
|
83
87
|
for (let i = 0; i < keys.length; i++) {
|
|
84
88
|
const alias = wanted[i]!
|
|
89
|
+
if ((await o.secrets.get(alias)) !== keys[i]!) changed = true
|
|
85
90
|
await o.secrets.set(alias, keys[i]!)
|
|
86
91
|
aliases.push({ alias, key: keys[i]! })
|
|
87
92
|
}
|
|
88
93
|
|
|
89
94
|
let loginStored = false
|
|
90
95
|
if (input.login?.trim()) {
|
|
91
|
-
|
|
96
|
+
const login = input.login.trim()
|
|
97
|
+
if ((await o.secrets.get(VPN_USER)) !== login || (input.password && (await o.secrets.get(VPN_PASS)) !== input.password)) {
|
|
98
|
+
changed = true
|
|
99
|
+
}
|
|
100
|
+
await o.secrets.set(VPN_USER, login)
|
|
92
101
|
if (input.password) await o.secrets.set(VPN_PASS, input.password)
|
|
93
102
|
loginStored = true
|
|
94
103
|
}
|
|
@@ -96,5 +105,5 @@ export async function applyConnect(
|
|
|
96
105
|
cfg.keys = [...custom, ...wanted]
|
|
97
106
|
await saveConfig(o.paths.configFile, cfg)
|
|
98
107
|
log(`connect applied: ${aliases.length} key(s), login ${loginStored ? "stored" : "unchanged"}`)
|
|
99
|
-
return { aliases, removedAliases, loginStored, errors }
|
|
108
|
+
return { aliases, removedAliases, loginStored, changed, errors }
|
|
100
109
|
}
|