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.
Files changed (49) hide show
  1. package/README.md +152 -12
  2. package/dist/accounts.d.ts +22 -2
  3. package/dist/index.d.ts +7 -12
  4. package/dist/index.js +46026 -14563
  5. package/dist/legacy.d.ts +25 -0
  6. package/dist/login-health.d.ts +12 -0
  7. package/dist/notify.d.ts +6 -0
  8. package/dist/pairing.d.ts +22 -0
  9. package/dist/pending/coordinator.d.ts +5 -1
  10. package/dist/pending/v2.d.ts +3 -0
  11. package/dist/pool/paths.d.ts +8 -0
  12. package/dist/providers/anthropic/constants.d.ts +21 -0
  13. package/dist/providers/anthropic/oauth.d.ts +9 -0
  14. package/dist/providers/anthropic/setup-token.d.ts +14 -0
  15. package/dist/providers/anthropic/usage.d.ts +13 -0
  16. package/dist/providers/types.d.ts +27 -0
  17. package/dist/refresh.d.ts +12 -0
  18. package/dist/status.d.ts +6 -0
  19. package/dist/sync/apply.d.ts +66 -0
  20. package/dist/sync/crypto.d.ts +19 -0
  21. package/dist/sync/cycle.d.ts +19 -0
  22. package/dist/sync/engine.d.ts +44 -0
  23. package/dist/sync/errors.d.ts +9 -0
  24. package/dist/sync/gist.d.ts +33 -0
  25. package/dist/sync/github-auth.d.ts +15 -0
  26. package/dist/sync/intent.d.ts +29 -0
  27. package/dist/sync/loop.d.ts +40 -0
  28. package/dist/sync/merge.d.ts +49 -0
  29. package/dist/sync/origin.d.ts +2 -0
  30. package/dist/sync/own.d.ts +31 -0
  31. package/dist/sync/payload.d.ts +50 -0
  32. package/dist/sync/start.d.ts +5 -0
  33. package/dist/sync/state.d.ts +79 -0
  34. package/dist/sync/timing.d.ts +4 -0
  35. package/dist/token-mint.d.ts +25 -0
  36. package/dist/types.d.ts +75 -0
  37. package/dist/util.d.ts +7 -0
  38. package/dist/v1.d.ts +1 -0
  39. package/dist/v1.js +18393 -0
  40. package/dist/v2-rpc.d.ts +62 -0
  41. package/dist/v2.d.ts +2 -0
  42. package/index.js +2 -0
  43. package/package.json +46 -8
  44. package/tui/auth-load-balancer-rpc.ts +60 -0
  45. package/tui/auth-load-balancer-tui.logic.ts +363 -7
  46. package/tui/auth-load-balancer-tui.view.tsx +299 -107
  47. package/tui/auth-load-balancer-v2.ts +10 -0
  48. package/tui/auth-load-balancer-v2.view.tsx +242 -0
  49. package/tui.ts +2 -0
@@ -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
@@ -0,0 +1,2 @@
1
+ import type { Plugin } from '@opencode/plugin';
2
+ export declare function setupV2(ctx: Plugin.Context): Promise<Plugin.Cleanup>;
package/index.js ADDED
@@ -0,0 +1,2 @@
1
+ // OpenCode V2 resolves local plugin directories through their root index file.
2
+ export { default } from './dist/index.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-auth-load-balancer",
3
- "version": "0.5.1",
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
- "@opentui/core": "0.5.10",
49
- "@opentui/solid": "0.5.10",
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 API-key row (Kimi Code), which re-logs in with a new key. */
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 an API-key login (Kimi
282
- * Code), so `apiKey` picks the kind that matches how the row was added.
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
+ }