opencode-auth-load-balancer 0.5.1 → 0.7.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 +152 -12
- package/dist/accounts.d.ts +22 -2
- package/dist/index.d.ts +7 -12
- package/dist/index.js +46026 -14563
- package/dist/legacy.d.ts +25 -0
- package/dist/login-health.d.ts +12 -0
- package/dist/notify.d.ts +6 -0
- package/dist/pairing.d.ts +22 -0
- package/dist/pending/coordinator.d.ts +5 -1
- package/dist/pending/v2.d.ts +3 -0
- package/dist/pool/paths.d.ts +8 -0
- package/dist/providers/anthropic/constants.d.ts +21 -0
- package/dist/providers/anthropic/oauth.d.ts +9 -0
- package/dist/providers/anthropic/setup-token.d.ts +14 -0
- package/dist/providers/anthropic/usage.d.ts +13 -0
- package/dist/providers/types.d.ts +27 -0
- package/dist/refresh.d.ts +12 -0
- package/dist/status.d.ts +6 -0
- package/dist/sync/apply.d.ts +66 -0
- package/dist/sync/crypto.d.ts +19 -0
- package/dist/sync/cycle.d.ts +19 -0
- package/dist/sync/engine.d.ts +44 -0
- package/dist/sync/errors.d.ts +9 -0
- package/dist/sync/gist.d.ts +33 -0
- package/dist/sync/github-auth.d.ts +15 -0
- package/dist/sync/intent.d.ts +29 -0
- package/dist/sync/loop.d.ts +40 -0
- package/dist/sync/merge.d.ts +49 -0
- package/dist/sync/origin.d.ts +2 -0
- package/dist/sync/own.d.ts +31 -0
- package/dist/sync/payload.d.ts +50 -0
- package/dist/sync/start.d.ts +5 -0
- package/dist/sync/state.d.ts +79 -0
- package/dist/sync/timing.d.ts +4 -0
- package/dist/token-mint.d.ts +25 -0
- package/dist/types.d.ts +75 -0
- package/dist/util.d.ts +7 -0
- package/dist/v1.d.ts +1 -0
- package/dist/v1.js +18393 -0
- package/dist/v2-rpc.d.ts +62 -0
- package/dist/v2.d.ts +2 -0
- package/index.js +2 -0
- package/package.json +46 -8
- package/tui/auth-load-balancer-rpc.ts +60 -0
- package/tui/auth-load-balancer-tui.logic.ts +363 -7
- package/tui/auth-load-balancer-tui.view.tsx +299 -107
- package/tui/auth-load-balancer-v2.ts +10 -0
- package/tui/auth-load-balancer-v2.view.tsx +242 -0
- package/tui.ts +2 -0
package/dist/v2-rpc.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/** Display and management API; its status output never contains credentials. */
|
|
3
|
+
export declare const loadBalancerRpc: {
|
|
4
|
+
readonly id: 'auth-load-balancer';
|
|
5
|
+
readonly methods: {
|
|
6
|
+
readonly status: {
|
|
7
|
+
readonly input: z.ZodObject<{
|
|
8
|
+
sessionID: z.ZodOptional<z.ZodString>;
|
|
9
|
+
}, z.core.$strip>;
|
|
10
|
+
readonly output: z.ZodArray<z.ZodObject<{
|
|
11
|
+
providerID: z.ZodString;
|
|
12
|
+
accounts: z.ZodArray<z.ZodObject<{
|
|
13
|
+
id: z.ZodString;
|
|
14
|
+
label: z.ZodString;
|
|
15
|
+
weeklyUtil: z.ZodNullable<z.ZodNumber>;
|
|
16
|
+
hourlyUtil: z.ZodNullable<z.ZodNumber>;
|
|
17
|
+
weeklyResetAt: z.ZodNumber;
|
|
18
|
+
available: z.ZodBoolean;
|
|
19
|
+
cooldownUntil: z.ZodNumber;
|
|
20
|
+
modelCooldownsUntil: z.ZodRecord<z.ZodString, z.ZodNumber>;
|
|
21
|
+
disabledReason: z.ZodNullable<z.ZodString>;
|
|
22
|
+
login: z.ZodString;
|
|
23
|
+
warnings: z.ZodArray<z.ZodString>;
|
|
24
|
+
lostNotes: z.ZodArray<z.ZodString>;
|
|
25
|
+
current: z.ZodBoolean;
|
|
26
|
+
rank: z.ZodNumber;
|
|
27
|
+
}, z.core.$strip>>;
|
|
28
|
+
}, z.core.$strip>>;
|
|
29
|
+
};
|
|
30
|
+
readonly manage: {
|
|
31
|
+
readonly input: z.ZodObject<{
|
|
32
|
+
account: z.ZodString;
|
|
33
|
+
action: z.ZodEnum<{
|
|
34
|
+
"clear-relogin": "clear-relogin";
|
|
35
|
+
delete: "delete";
|
|
36
|
+
disable: "disable";
|
|
37
|
+
enable: "enable";
|
|
38
|
+
relogin: "relogin";
|
|
39
|
+
rename: "rename";
|
|
40
|
+
}>;
|
|
41
|
+
name: z.ZodOptional<z.ZodString>;
|
|
42
|
+
}, z.core.$strip>;
|
|
43
|
+
readonly output: z.ZodString;
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
readonly events: {
|
|
47
|
+
readonly toast: {
|
|
48
|
+
readonly schema: z.ZodObject<{
|
|
49
|
+
title: z.ZodOptional<z.ZodString>;
|
|
50
|
+
message: z.ZodString;
|
|
51
|
+
variant: z.ZodEnum<{
|
|
52
|
+
error: "error";
|
|
53
|
+
info: "info";
|
|
54
|
+
success: "success";
|
|
55
|
+
warning: "warning";
|
|
56
|
+
}>;
|
|
57
|
+
duration: z.ZodOptional<z.ZodNumber>;
|
|
58
|
+
}, z.core.$strip>;
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
export type ProviderStatus = z.infer<typeof loadBalancerRpc.methods.status.output>[number];
|
package/dist/v2.d.ts
ADDED
package/index.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-auth-load-balancer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Load-balance opencode requests across multiple Anthropic (Claude) and OpenAI (Codex) OAuth accounts and Kimi Code subscriptions, weighted primarily by weekly usage with drain-before-reset scheduling.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -20,16 +20,25 @@
|
|
|
20
20
|
".": {
|
|
21
21
|
"types": "./dist/index.d.ts",
|
|
22
22
|
"default": "./dist/index.js"
|
|
23
|
-
}
|
|
23
|
+
},
|
|
24
|
+
"./v1": {
|
|
25
|
+
"types": "./dist/v1.d.ts",
|
|
26
|
+
"default": "./dist/v1.js"
|
|
27
|
+
},
|
|
28
|
+
"./server": "./dist/index.js",
|
|
29
|
+
"./tui": "./tui.ts",
|
|
30
|
+
"./tui-v1": "./tui/auth-load-balancer-tui.ts"
|
|
24
31
|
},
|
|
25
32
|
"files": [
|
|
33
|
+
"index.js",
|
|
34
|
+
"tui.ts",
|
|
26
35
|
"dist",
|
|
27
36
|
"tui"
|
|
28
37
|
],
|
|
29
38
|
"scripts": {
|
|
30
|
-
"sync:tui": "bun -e \"await Bun.write('tui/auth-load-balancer-scoring.ts', Bun.file('src/scheduler/score-core.ts'))\"",
|
|
31
|
-
"build": "bun run sync:tui && bun build ./src/index.ts --outdir dist --target node --format esm && tsc -p tsconfig.build.json",
|
|
32
|
-
"dev": "bun build ./src/index.ts --outdir dist --target node --format esm --watch",
|
|
39
|
+
"sync:tui": "bun -e \"await Bun.write('tui/auth-load-balancer-scoring.ts', Bun.file('src/scheduler/score-core.ts')); await Bun.write('tui/auth-load-balancer-rpc.ts', Bun.file('src/v2-rpc.ts'))\"",
|
|
40
|
+
"build": "bun run sync:tui && bun build ./src/index.ts ./src/v1.ts --outdir dist --target node --format esm --external @opencode/plugin --external effect --external 'effect/*' && tsc -p tsconfig.build.json",
|
|
41
|
+
"dev": "bun build ./src/index.ts ./src/v1.ts --outdir dist --target node --format esm --external @opencode/plugin --external effect --external 'effect/*' --watch",
|
|
33
42
|
"typecheck": "tsc --noEmit && tsc -p tsconfig.tui.json --noEmit",
|
|
34
43
|
"typecheck:tui": "tsc -p tsconfig.tui.json --noEmit",
|
|
35
44
|
"lint": "oxlint",
|
|
@@ -41,12 +50,35 @@
|
|
|
41
50
|
"prepare": "husky"
|
|
42
51
|
},
|
|
43
52
|
"peerDependencies": {
|
|
44
|
-
"@opencode-ai/plugin": ">=1.18.26"
|
|
53
|
+
"@opencode-ai/plugin": ">=1.18.26 <2",
|
|
54
|
+
"@opencode/plugin": "^2.0.27",
|
|
55
|
+
"@opentui/core": ">=0.5.17",
|
|
56
|
+
"@opentui/solid": ">=0.5.17",
|
|
57
|
+
"solid-js": ">=1.9.0"
|
|
58
|
+
},
|
|
59
|
+
"peerDependenciesMeta": {
|
|
60
|
+
"@opencode-ai/plugin": {
|
|
61
|
+
"optional": true
|
|
62
|
+
},
|
|
63
|
+
"@opencode/plugin": {
|
|
64
|
+
"optional": true
|
|
65
|
+
},
|
|
66
|
+
"@opentui/core": {
|
|
67
|
+
"optional": true
|
|
68
|
+
},
|
|
69
|
+
"@opentui/solid": {
|
|
70
|
+
"optional": true
|
|
71
|
+
},
|
|
72
|
+
"solid-js": {
|
|
73
|
+
"optional": true
|
|
74
|
+
}
|
|
45
75
|
},
|
|
46
76
|
"devDependencies": {
|
|
47
77
|
"@opencode-ai/plugin": "^1.18.26",
|
|
48
|
-
"@
|
|
49
|
-
"@
|
|
78
|
+
"@opencode/plugin": "2.0.27",
|
|
79
|
+
"@opencode/theme": "2.0.27",
|
|
80
|
+
"@opentui/core": "0.5.17",
|
|
81
|
+
"@opentui/solid": "0.5.17",
|
|
50
82
|
"@types/bun": "^1.4.0",
|
|
51
83
|
"@types/node": "^26.4.1",
|
|
52
84
|
"eslint-plugin-devup": "^2.1.0",
|
|
@@ -73,5 +105,11 @@
|
|
|
73
105
|
},
|
|
74
106
|
"publishConfig": {
|
|
75
107
|
"access": "public"
|
|
108
|
+
},
|
|
109
|
+
"dependencies": {
|
|
110
|
+
"@ai-sdk/anthropic": "3.0.129",
|
|
111
|
+
"@ai-sdk/openai": "3.0.125",
|
|
112
|
+
"@ai-sdk/openai-compatible": "2.0.81",
|
|
113
|
+
"zod": "^4.3"
|
|
76
114
|
}
|
|
77
115
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
|
|
3
|
+
const accountStatus = z.object({
|
|
4
|
+
id: z.string(),
|
|
5
|
+
label: z.string(),
|
|
6
|
+
weeklyUtil: z.number().nullable(),
|
|
7
|
+
hourlyUtil: z.number().nullable(),
|
|
8
|
+
weeklyResetAt: z.number(),
|
|
9
|
+
available: z.boolean(),
|
|
10
|
+
cooldownUntil: z.number(),
|
|
11
|
+
modelCooldownsUntil: z.record(z.string(), z.number()),
|
|
12
|
+
disabledReason: z.string().nullable(),
|
|
13
|
+
login: z.string(),
|
|
14
|
+
warnings: z.array(z.string()),
|
|
15
|
+
lostNotes: z.array(z.string()),
|
|
16
|
+
current: z.boolean(),
|
|
17
|
+
rank: z.number(),
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
/** Display and management API; its status output never contains credentials. */
|
|
21
|
+
export const loadBalancerRpc = {
|
|
22
|
+
id: 'auth-load-balancer',
|
|
23
|
+
methods: {
|
|
24
|
+
status: {
|
|
25
|
+
input: z.object({ sessionID: z.string().optional() }),
|
|
26
|
+
output: z.array(
|
|
27
|
+
z.object({ providerID: z.string(), accounts: z.array(accountStatus) }),
|
|
28
|
+
),
|
|
29
|
+
},
|
|
30
|
+
manage: {
|
|
31
|
+
input: z.object({
|
|
32
|
+
account: z.string(),
|
|
33
|
+
action: z.enum([
|
|
34
|
+
'rename',
|
|
35
|
+
'disable',
|
|
36
|
+
'enable',
|
|
37
|
+
'delete',
|
|
38
|
+
'relogin',
|
|
39
|
+
'clear-relogin',
|
|
40
|
+
]),
|
|
41
|
+
name: z.string().optional(),
|
|
42
|
+
}),
|
|
43
|
+
output: z.string(),
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
events: {
|
|
47
|
+
toast: {
|
|
48
|
+
schema: z.object({
|
|
49
|
+
title: z.string().optional(),
|
|
50
|
+
message: z.string(),
|
|
51
|
+
variant: z.enum(['info', 'success', 'warning', 'error']),
|
|
52
|
+
duration: z.number().optional(),
|
|
53
|
+
}),
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
} as const
|
|
57
|
+
|
|
58
|
+
export type ProviderStatus = z.infer<
|
|
59
|
+
typeof loadBalancerRpc.methods.status.output
|
|
60
|
+
>[number]
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import { readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
13
13
|
import { homedir } from 'node:os'
|
|
14
|
-
import { join } from 'node:path'
|
|
14
|
+
import { dirname, join } from 'node:path'
|
|
15
15
|
|
|
16
16
|
import {
|
|
17
17
|
displayUtil,
|
|
@@ -62,8 +62,18 @@ export interface PoolAccount {
|
|
|
62
62
|
/** LEGACY pre-tier-map field (folded into `modelCooldownsUntil.opus` for display until the server migrates the file). */
|
|
63
63
|
opusCooldownUntil?: number
|
|
64
64
|
disabledReason?: string | null
|
|
65
|
-
/** Empty on a static
|
|
65
|
+
/** Empty on a static-credential row (Kimi Code API key, Claude setup-token), which re-logs in with a new one. */
|
|
66
66
|
refresh?: string
|
|
67
|
+
/** A Claude setup-token serving the row's inference; only its presence is read. */
|
|
68
|
+
inferenceToken?: string
|
|
69
|
+
/** Logins refused for good, with when and why (`src/types.ts` `LostLogins`). */
|
|
70
|
+
lostLogins?: { oauth?: LostLogin | null; token?: LostLogin | null } | null
|
|
71
|
+
/** epoch ms the row's OAuth login itself expires. */
|
|
72
|
+
refreshExpires?: number
|
|
73
|
+
}
|
|
74
|
+
export interface LostLogin {
|
|
75
|
+
at?: number
|
|
76
|
+
reason?: string
|
|
67
77
|
}
|
|
68
78
|
/** Loosely-typed view of the on-disk pool JSON (one shape for reads AND read-modify-writes). */
|
|
69
79
|
export interface PoolShape {
|
|
@@ -278,8 +288,9 @@ export function clearReloginTargetInPool(path: string = POOL_FILE): void {
|
|
|
278
288
|
* opencode aggregates auth methods from every plugin registered for a provider,
|
|
279
289
|
* so this plugin's position is not stable. Prefer its pooled-login label, then
|
|
280
290
|
* fall back to the provider's first OAuth method when no such label is present.
|
|
281
|
-
* A provider can offer both an account login and
|
|
282
|
-
* Code), so `apiKey` picks the
|
|
291
|
+
* A provider can offer both an account login and a static-credential login
|
|
292
|
+
* (Kimi Code's API key, Claude's `claude setup-token`), so `apiKey` picks the
|
|
293
|
+
* kind that matches how the row was added.
|
|
283
294
|
*/
|
|
284
295
|
export function pickAuthMethodIndex(
|
|
285
296
|
methods: readonly { type?: string; label?: string }[] | undefined,
|
|
@@ -288,10 +299,12 @@ export function pickAuthMethodIndex(
|
|
|
288
299
|
const pooled = (method: { type?: string; label?: string }): boolean =>
|
|
289
300
|
method.type === 'oauth' &&
|
|
290
301
|
(method.label?.toLowerCase().includes('load balancer') ?? false)
|
|
302
|
+
const staticLogin = (method: { label?: string }): boolean => {
|
|
303
|
+
const label = method.label?.toLowerCase() ?? ''
|
|
304
|
+
return label.includes('api key') || label.includes('setup-token')
|
|
305
|
+
}
|
|
291
306
|
const sameKind = methods?.findIndex(
|
|
292
|
-
(method) =>
|
|
293
|
-
pooled(method) &&
|
|
294
|
-
(method.label?.toLowerCase().includes('api key') ?? false) === apiKey,
|
|
307
|
+
(method) => pooled(method) && staticLogin(method) === apiKey,
|
|
295
308
|
)
|
|
296
309
|
if (sameKind !== undefined && sameKind >= 0) return sameKind
|
|
297
310
|
const anyPooled = methods?.findIndex(pooled)
|
|
@@ -300,6 +313,70 @@ export function pickAuthMethodIndex(
|
|
|
300
313
|
return oauth !== undefined && oauth >= 0 ? oauth : null
|
|
301
314
|
}
|
|
302
315
|
|
|
316
|
+
/**
|
|
317
|
+
* A setup-token row's credentials — `token`, or `token+oauth` once paired
|
|
318
|
+
* with its OAuth login — and '' for any other row. Mirrors `src/status.ts`.
|
|
319
|
+
*/
|
|
320
|
+
export function credentialTag(a: PoolAccount): string {
|
|
321
|
+
if (typeof a.inferenceToken !== 'string' || a.inferenceToken === '') return ''
|
|
322
|
+
return a.refresh ? 'token+oauth' : 'token'
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const DAY_MS = 24 * 60 * 60 * 1000
|
|
326
|
+
/** NOTE: copy of `src/status.ts`'s threshold — Claude Code's own 3-day login-expiry warning. */
|
|
327
|
+
const LOGIN_EXPIRY_WARNING_MS = 3 * DAY_MS
|
|
328
|
+
|
|
329
|
+
/** A well-formed lost-login record from the RAW pool file, or undefined. */
|
|
330
|
+
function lostLoginOf(
|
|
331
|
+
a: PoolAccount,
|
|
332
|
+
login: 'oauth' | 'token',
|
|
333
|
+
): { at: number; reason: string } | undefined {
|
|
334
|
+
const lost = a.lostLogins?.[login]
|
|
335
|
+
return lost && isFiniteNumber(lost.at) && typeof lost.reason === 'string'
|
|
336
|
+
? { at: lost.at, reason: lost.reason }
|
|
337
|
+
: undefined
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export function hasLostLogin(
|
|
341
|
+
a: PoolAccount,
|
|
342
|
+
login: 'oauth' | 'token',
|
|
343
|
+
): boolean {
|
|
344
|
+
return lostLoginOf(a, login) !== undefined
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Login trouble on a row still in service — `oauth re-login` / `token
|
|
349
|
+
* re-login` for a lost half of a paired row, `oauth expires Nd` for an OAuth
|
|
350
|
+
* login in its last days. A parked row shows none: its state already reads
|
|
351
|
+
* `re-login` / `disabled`. Mirrors `src/status.ts`.
|
|
352
|
+
*/
|
|
353
|
+
export function rowWarnings(a: PoolAccount, now: number): string[] {
|
|
354
|
+
if (a.disabledReason) return []
|
|
355
|
+
const warnings: string[] = []
|
|
356
|
+
if (hasLostLogin(a, 'oauth')) warnings.push('oauth re-login')
|
|
357
|
+
if (hasLostLogin(a, 'token')) warnings.push('token re-login')
|
|
358
|
+
const left = (isFiniteNumber(a.refreshExpires) ? a.refreshExpires : 0) - now
|
|
359
|
+
if (a.refresh && left > 0 && left <= LOGIN_EXPIRY_WARNING_MS)
|
|
360
|
+
warnings.push(`oauth expires ${Math.ceil(left / DAY_MS)}d`)
|
|
361
|
+
return warnings
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Why each lost login stopped working, one line apiece (the dashboard's `!` lines). */
|
|
365
|
+
export function lostLoginLines(a: PoolAccount, now: number): string[] {
|
|
366
|
+
const lines: string[] = []
|
|
367
|
+
for (const login of ['oauth', 'token'] as const) {
|
|
368
|
+
const lost = lostLoginOf(a, login)
|
|
369
|
+
if (lost) {
|
|
370
|
+
const ago =
|
|
371
|
+
now - lost.at < 60_000
|
|
372
|
+
? 'just now'
|
|
373
|
+
: `${until(2 * now - lost.at, now)} ago`
|
|
374
|
+
lines.push(`${login} lost ${ago}: ${lost.reason}`)
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return lines
|
|
378
|
+
}
|
|
379
|
+
|
|
303
380
|
export function renameInPool(
|
|
304
381
|
id: string,
|
|
305
382
|
label: string,
|
|
@@ -501,3 +578,282 @@ export function stateOf(
|
|
|
501
578
|
return tiers.map(([tier, at]) => `${tier} ${until(at, now)}`).join(' · ')
|
|
502
579
|
return ''
|
|
503
580
|
}
|
|
581
|
+
|
|
582
|
+
// ---------------------------------------------------------------------------
|
|
583
|
+
// Gist sync. The server runs it; the TUI only asks (an intent file beside the
|
|
584
|
+
// pool, which the server claims and deletes) and shows what comes back. None
|
|
585
|
+
// of this may put the share link anywhere but the dialog the user asked for.
|
|
586
|
+
// ---------------------------------------------------------------------------
|
|
587
|
+
|
|
588
|
+
const DATA_DIR = dirname(POOL_FILE)
|
|
589
|
+
export const SYNC_STATE_FILE = join(DATA_DIR, 'auth-load-balancer-sync.json')
|
|
590
|
+
export const SYNC_STATUS_FILE = join(
|
|
591
|
+
DATA_DIR,
|
|
592
|
+
'auth-load-balancer-sync-status.json',
|
|
593
|
+
)
|
|
594
|
+
export const SYNC_INTENT_FILE = join(
|
|
595
|
+
DATA_DIR,
|
|
596
|
+
'auth-load-balancer-sync-intent.json',
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
export type SyncAction =
|
|
600
|
+
'upload' | 'upload-new' | 'subscribe' | 'sync' | 'forget'
|
|
601
|
+
export type SyncMenuId = SyncAction | 'show-link'
|
|
602
|
+
|
|
603
|
+
export interface SyncStatusView {
|
|
604
|
+
at: number
|
|
605
|
+
ok: boolean
|
|
606
|
+
message: string
|
|
607
|
+
reqAt?: number
|
|
608
|
+
}
|
|
609
|
+
/** What this machine can do besides downloading, as the server last found it. */
|
|
610
|
+
export type SyncWriteView = 'ok' | 'no-token' | 'denied' | 'unreadable'
|
|
611
|
+
const WRITE_VIEWS: readonly string[] = [
|
|
612
|
+
'ok',
|
|
613
|
+
'no-token',
|
|
614
|
+
'denied',
|
|
615
|
+
'unreadable',
|
|
616
|
+
]
|
|
617
|
+
|
|
618
|
+
export interface SyncView {
|
|
619
|
+
/** This machine has joined a gist (created it, or followed its link). */
|
|
620
|
+
joined: boolean
|
|
621
|
+
/** It created the gist: only then may it show the link. */
|
|
622
|
+
creator: boolean
|
|
623
|
+
write?: SyncWriteView
|
|
624
|
+
syncedAt?: number
|
|
625
|
+
status?: SyncStatusView
|
|
626
|
+
}
|
|
627
|
+
function readJsonFile(path: string): Record<string, unknown> | undefined {
|
|
628
|
+
try {
|
|
629
|
+
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
|
|
630
|
+
return isPlainRecordValue(parsed)
|
|
631
|
+
? (parsed as Record<string, unknown>)
|
|
632
|
+
: undefined
|
|
633
|
+
} catch {
|
|
634
|
+
return undefined
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** The latest outcome the server wrote, or undefined. */
|
|
639
|
+
export function readSyncStatus(
|
|
640
|
+
path: string = SYNC_STATUS_FILE,
|
|
641
|
+
): SyncStatusView | undefined {
|
|
642
|
+
const raw = readJsonFile(path)
|
|
643
|
+
if (
|
|
644
|
+
!raw ||
|
|
645
|
+
!isFiniteNumber(raw.at) ||
|
|
646
|
+
typeof raw.ok !== 'boolean' ||
|
|
647
|
+
typeof raw.message !== 'string'
|
|
648
|
+
)
|
|
649
|
+
return undefined
|
|
650
|
+
return {
|
|
651
|
+
at: raw.at,
|
|
652
|
+
ok: raw.ok,
|
|
653
|
+
message: raw.message,
|
|
654
|
+
...(isFiniteNumber(raw.reqAt) ? { reqAt: raw.reqAt } : {}),
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/** Whether this machine joined a gist, what it can do, the last sync time and the latest outcome: everything the sidebar shows, and no key. */
|
|
659
|
+
export function readSyncView(
|
|
660
|
+
statePath: string = SYNC_STATE_FILE,
|
|
661
|
+
statusPath: string = SYNC_STATUS_FILE,
|
|
662
|
+
): SyncView {
|
|
663
|
+
const state = readJsonFile(statePath)
|
|
664
|
+
const joined =
|
|
665
|
+
typeof state?.gistId === 'string' && typeof state.key === 'string'
|
|
666
|
+
const status = readSyncStatus(statusPath)
|
|
667
|
+
const write = state?.write
|
|
668
|
+
return {
|
|
669
|
+
joined,
|
|
670
|
+
creator: joined && state?.creator === true,
|
|
671
|
+
...(joined && typeof write === 'string' && WRITE_VIEWS.includes(write)
|
|
672
|
+
? { write: write as SyncWriteView }
|
|
673
|
+
: {}),
|
|
674
|
+
...(joined && isFiniteNumber(state?.syncedAt)
|
|
675
|
+
? { syncedAt: state.syncedAt }
|
|
676
|
+
: {}),
|
|
677
|
+
...(status ? { status } : {}),
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
/** The share link, read only when the user asks to see it; only the machine that created the gist has it to show. */
|
|
681
|
+
export function readShareLink(
|
|
682
|
+
statePath: string = SYNC_STATE_FILE,
|
|
683
|
+
): string | undefined {
|
|
684
|
+
const state = readJsonFile(statePath)
|
|
685
|
+
if (
|
|
686
|
+
state?.creator !== true ||
|
|
687
|
+
typeof state.gistId !== 'string' ||
|
|
688
|
+
typeof state.key !== 'string'
|
|
689
|
+
)
|
|
690
|
+
return undefined
|
|
691
|
+
const owner = typeof state.owner === 'string' ? `${state.owner}/` : ''
|
|
692
|
+
return `https://gist.github.com/${owner}${state.gistId}#${state.key}`
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/** Mirror of the server's link check, so a typo is caught before it is sent. */
|
|
696
|
+
export function isGistLinkShape(text: string): boolean {
|
|
697
|
+
return /^https:\/\/gist\.github\.com\/(?:[\w-]{1,39}\/)?[\da-f]{20,40}#[\w-]{43}$/i.test(
|
|
698
|
+
text.trim(),
|
|
699
|
+
)
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Ask the server to do `action`. Written atomically (temp + rename) with an
|
|
704
|
+
* owner-only mode; a `subscribe` carries the link, so the server deletes the
|
|
705
|
+
* file the moment it reads it. Returns the request's timestamp, which the
|
|
706
|
+
* server echoes in its outcome.
|
|
707
|
+
*/
|
|
708
|
+
export function writeSyncIntent(
|
|
709
|
+
action: SyncAction,
|
|
710
|
+
link?: string,
|
|
711
|
+
now: number = Date.now(),
|
|
712
|
+
path: string = SYNC_INTENT_FILE,
|
|
713
|
+
ops: FsOps = realFsOps,
|
|
714
|
+
): number {
|
|
715
|
+
const tmp = `${path}.${process.pid}.${now}.tmp`
|
|
716
|
+
try {
|
|
717
|
+
ops.writeFileSync(
|
|
718
|
+
tmp,
|
|
719
|
+
JSON.stringify({
|
|
720
|
+
action,
|
|
721
|
+
at: now,
|
|
722
|
+
...(link ? { link: link.trim() } : {}),
|
|
723
|
+
}),
|
|
724
|
+
{ mode: 0o600 },
|
|
725
|
+
)
|
|
726
|
+
ops.renameSync(tmp, path)
|
|
727
|
+
} catch {
|
|
728
|
+
try {
|
|
729
|
+
ops.unlinkSync(tmp)
|
|
730
|
+
} catch {
|
|
731
|
+
/* ignore — best-effort cleanup */
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
return now
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/** The outcome answering the request stamped `since`, or null when the server does not answer in time. */
|
|
738
|
+
export async function awaitSyncResult(
|
|
739
|
+
since: number,
|
|
740
|
+
read: () => SyncStatusView | undefined = readSyncStatus,
|
|
741
|
+
sleep: (ms: number) => Promise<unknown> = (ms) => Bun.sleep(ms),
|
|
742
|
+
timeoutMs = 25_000,
|
|
743
|
+
stepMs = 500,
|
|
744
|
+
): Promise<SyncStatusView | null> {
|
|
745
|
+
for (let waited = 0; waited <= timeoutMs; waited += stepMs) {
|
|
746
|
+
const status = read()
|
|
747
|
+
if (status?.reqAt === since) return status
|
|
748
|
+
await sleep(stepMs)
|
|
749
|
+
}
|
|
750
|
+
return null
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Withdraw the request stamped `at` when nobody answered it: it may hold the
|
|
755
|
+
* link and key, and a file nobody serves would otherwise linger. A different
|
|
756
|
+
* request (another `at`) is left alone. Nothing to do when the server already
|
|
757
|
+
* claimed it.
|
|
758
|
+
*/
|
|
759
|
+
export function discardSyncIntent(
|
|
760
|
+
at: number,
|
|
761
|
+
path: string = SYNC_INTENT_FILE,
|
|
762
|
+
ops: FsOps = realFsOps,
|
|
763
|
+
): void {
|
|
764
|
+
try {
|
|
765
|
+
const parsed: unknown = JSON.parse(ops.readFileSync(path, 'utf8'))
|
|
766
|
+
if (isPlainRecordValue(parsed) && (parsed as { at?: unknown }).at === at)
|
|
767
|
+
ops.unlinkSync(path)
|
|
768
|
+
} catch {
|
|
769
|
+
/* absent, unreadable, or already claimed */
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* A label made safe to draw: control, bidi and zero-width characters become
|
|
775
|
+
* spaces. NOTE: copy of `src/sync/payload.ts`'s `printable` by design — the
|
|
776
|
+
* TUI cannot import `src/`.
|
|
777
|
+
*/
|
|
778
|
+
export function cleanLabel(label: string): string {
|
|
779
|
+
return Array.from(label, (ch) => {
|
|
780
|
+
const code = ch.codePointAt(0) ?? 0
|
|
781
|
+
const hidden =
|
|
782
|
+
code < 0x20 ||
|
|
783
|
+
(code >= 0x7f && code <= 0x9f) ||
|
|
784
|
+
code === 0x61c ||
|
|
785
|
+
(code >= 0x200b && code <= 0x200f) ||
|
|
786
|
+
(code >= 0x2028 && code <= 0x202e) ||
|
|
787
|
+
(code >= 0x2060 && code <= 0x2069) ||
|
|
788
|
+
code === 0xfeff
|
|
789
|
+
return hidden ? ' ' : ch
|
|
790
|
+
})
|
|
791
|
+
.join('')
|
|
792
|
+
.trim()
|
|
793
|
+
}
|
|
794
|
+
function ago(at: number, now: number): string {
|
|
795
|
+
return now - at < 60_000 ? 'just now' : `${until(2 * now - at, now)} ago`
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const WRITE_LINES: Record<SyncWriteView, string> = {
|
|
799
|
+
ok: 'syncing (upload + download)',
|
|
800
|
+
'no-token': 'syncing (download only: no GitHub login)',
|
|
801
|
+
denied: 'syncing (download only: this GitHub account cannot update the gist)',
|
|
802
|
+
unreadable: 'syncing (download only: update this plugin to upload)',
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/** The sidebar's sync lines. */
|
|
806
|
+
export function syncLines(view: SyncView, now: number): string[] {
|
|
807
|
+
const lines: string[] = []
|
|
808
|
+
if (!view.joined) lines.push('off')
|
|
809
|
+
else {
|
|
810
|
+
const verb = view.write ? WRITE_LINES[view.write] : 'syncing'
|
|
811
|
+
lines.push(
|
|
812
|
+
view.syncedAt === undefined
|
|
813
|
+
? verb
|
|
814
|
+
: `${verb} · synced ${ago(view.syncedAt, now)}`,
|
|
815
|
+
)
|
|
816
|
+
}
|
|
817
|
+
if (view.status)
|
|
818
|
+
lines.push(`${view.status.ok ? '' : '! '}${view.status.message}`)
|
|
819
|
+
return lines
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
export interface SyncMenuItem {
|
|
823
|
+
id: SyncMenuId
|
|
824
|
+
title: string
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* The sync menu: what this machine can do. A machine that cannot upload (no
|
|
829
|
+
* GitHub login, an account that cannot update the gist, a gist from a newer
|
|
830
|
+
* version) is offered no upload action; the sidebar says why.
|
|
831
|
+
*/
|
|
832
|
+
export function syncMenu(view: SyncView): SyncMenuItem[] {
|
|
833
|
+
if (!view.joined)
|
|
834
|
+
return [
|
|
835
|
+
{ id: 'subscribe', title: 'Follow a gist link' },
|
|
836
|
+
{
|
|
837
|
+
id: 'upload-new',
|
|
838
|
+
title: "Create a new secret gist from this pool's static credentials",
|
|
839
|
+
},
|
|
840
|
+
]
|
|
841
|
+
const canWrite = view.write === undefined || view.write === 'ok'
|
|
842
|
+
return [
|
|
843
|
+
...(view.creator
|
|
844
|
+
? [{ id: 'show-link' as const, title: 'Show the share link' }]
|
|
845
|
+
: []),
|
|
846
|
+
...(canWrite ? [{ id: 'upload' as const, title: 'Upload now' }] : []),
|
|
847
|
+
{ id: 'sync', title: 'Sync now' },
|
|
848
|
+
{ id: 'subscribe', title: 'Follow a different link' },
|
|
849
|
+
...(view.write === 'no-token'
|
|
850
|
+
? []
|
|
851
|
+
: [
|
|
852
|
+
{
|
|
853
|
+
id: 'upload-new' as const,
|
|
854
|
+
title: 'Create a new gist (new link and key)',
|
|
855
|
+
},
|
|
856
|
+
]),
|
|
857
|
+
{ id: 'forget', title: 'Stop syncing' },
|
|
858
|
+
]
|
|
859
|
+
}
|