opencode-go-usage-tui 1.1.0 → 1.2.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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # opencode-go-usage-tui
2
2
 
3
+ <div align="center">
4
+
5
+ 🌏 **语言 / Language:** [简体中文](/README.md) | [English](/README_EN.md)
6
+
7
+ </div>
8
+
3
9
  在 OpenCode 侧边栏实时显示 OpenCode Go 额度用量(5小时/每周/每月)。
4
10
 
5
11
  支持中英双语界面(`/go-lang` 切换,首次启动自动引导选择语言)。
@@ -47,7 +53,15 @@ npm install -g opencode-go-usage-tui
47
53
  1. **workspace_id**:`wrk_` 开头,在 opencode.ai 控制台 URL 中获取
48
54
  2. **auth cookie**:浏览器 F12 → Application → Cookies → opencode.ai → 复制 `auth` 的值(`Fe26.2*` 开头)
49
55
 
50
- 配置保存在 `~/.config/opencode/go-usage-config.json`。
56
+ 配置保存在 `~/.config/opencode/`:
57
+
58
+ - `go-usage-config.json` — workspace_id(明文,非敏感)
59
+ - `go-auth-cookie.enc` — auth cookie(**AES-256-GCM 加密**)
60
+ - `.encryption-key` — 加密密钥(32 字节随机生成)
61
+
62
+ > 🔒 **安全说明**:cookie 以 AES-256-GCM 加密存储,密钥随机生成且独立保存,文件被备份/同步/日志收集不会泄露 cookie。旧版明文文件(`go-auth-cookie.txt` / config.json 内嵌 cookie)首次读取时**自动迁移**为加密存储并清理明文。
63
+ >
64
+ > ⚠️ 局限:密钥与密文同目录,能防御"被动泄露"(备份/同步),无法防御能读取该目录的本地恶意程序。如需更强保护可改用系统级凭据存储。
51
65
 
52
66
  ### 方式二:环境变量
53
67
 
package/README_EN.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # opencode-go-usage-tui
2
2
 
3
+ <div align="center">
4
+
5
+ 🌏 **Language / 语言:** [English](/README_EN.md) | [简体中文](/README.md)
6
+
7
+ </div>
8
+
3
9
  Display your OpenCode Go quota usage (5h / weekly / monthly) in the OpenCode sidebar in real time.
4
10
 
5
11
  Bilingual UI (Chinese / English) — switch with `/go-lang`, or pick a language during the first-run onboarding.
@@ -47,7 +53,15 @@ Enter two values (unique per user — get them by logging into opencode.ai):
47
53
  1. **workspace_id**: starts with `wrk_`, found in the opencode.ai console URL
48
54
  2. **auth cookie**: in your browser, F12 → Application → Cookies → opencode.ai → copy the `auth` value (starts with `Fe26.2*`)
49
55
 
50
- Config is saved to `~/.config/opencode/go-usage-config.json`.
56
+ Config is saved to `~/.config/opencode/`:
57
+
58
+ - `go-usage-config.json` — workspace_id (plain text, not sensitive)
59
+ - `go-auth-cookie.enc` — auth cookie (**AES-256-GCM encrypted**)
60
+ - `.encryption-key` — encryption key (random 32 bytes)
61
+
62
+ > 🔒 **Security**: The cookie is stored encrypted with AES-256-GCM, with a randomly generated key stored separately — backups, syncs, and logs cannot leak the cookie. Legacy plaintext files (`go-auth-cookie.txt` / cookie embedded in config.json) are **auto-migrated** to encrypted storage on first read, and the plaintext is cleaned up.
63
+ >
64
+ > ⚠️ Limitation: The key lives in the same directory as the ciphertext. This defends against passive leaks (backups/sync) but not against local malware that can read your user directory. Use system credential storage (Windows DPAPI / macOS Keychain) for stronger protection.
51
65
 
52
66
  ### Option 2: Environment variables
53
67
 
package/build.tui.mjs CHANGED
@@ -10,7 +10,7 @@ await esbuild.build({
10
10
  format: "esm",
11
11
  platform: "node",
12
12
  bundle: true,
13
- external: ["@opencode-ai/*", "@opentui/*", "solid-js", "node:fs", "node:path"],
13
+ external: ["@opencode-ai/*", "@opentui/*", "solid-js", "node:fs", "node:path", "node:crypto"],
14
14
  plugins: [solidPlugin({ solid: { moduleName: "@opentui/solid", generate: "universal" } })],
15
15
  define: { __PLUGIN_VERSION__: JSON.stringify(pkg.version) },
16
16
  logLevel: "info",
package/dist/tui.js CHANGED
@@ -9,8 +9,9 @@ import { insertNode as _$insertNode } from "@opentui/solid";
9
9
  import { insert as _$insert } from "@opentui/solid";
10
10
  import { createElement as _$createElement } from "@opentui/solid";
11
11
  import { createSignal, createEffect, onMount, onCleanup, Show } from "solid-js";
12
- import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
12
+ import { readFileSync, writeFileSync, mkdirSync, renameSync, existsSync } from "node:fs";
13
13
  import { join, dirname } from "node:path";
14
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
14
15
 
15
16
  // src/i18n.ts
16
17
  var ZH_T = {
@@ -113,44 +114,140 @@ function detectLang() {
113
114
  }
114
115
 
115
116
  // src/index.tsx
116
- var VERSION = "1.1.0";
117
+ var VERSION = "1.2.0";
117
118
  var CHECK_INTERVAL = Number(process?.env?.OPENCODE_GO_CHECK_INTERVAL ?? 6e4);
118
119
  var WARN_THRESHOLD = Number(process?.env?.OPENCODE_GO_WARN_THRESHOLD ?? 0.8);
119
120
  var CONFIG_DIR = process?.env?.OPENCODE_CONFIG_DIR || (process?.env?.USERPROFILE ? `${process.env.USERPROFILE}\\.config\\opencode` : "") || process?.env?.HOME + "/.config/opencode";
120
121
  var CONFIG_FILE = join(CONFIG_DIR, "go-usage-config.json");
122
+ var COOKIE_ENC_FILE = join(CONFIG_DIR, "go-auth-cookie.enc");
123
+ var KEY_FILE = join(CONFIG_DIR, ".encryption-key");
124
+ var COOKIE_PLAIN_FILE = `${CONFIG_DIR}\\go-auth-cookie.txt`;
121
125
  var KV_PREFIX = "go-usage";
122
- function loadConfig() {
123
- const env = process?.env ?? {};
124
- let fileCfg = {};
126
+ function loadEncryptionKey() {
127
+ try {
128
+ const hex2 = readFileSync(KEY_FILE, "utf8").trim();
129
+ if (hex2) return Buffer.from(hex2, "hex");
130
+ } catch {
131
+ }
132
+ const key = randomBytes(32);
133
+ mkdirSync(dirname(KEY_FILE), {
134
+ recursive: true
135
+ });
136
+ writeFileSync(KEY_FILE, key.toString("hex"), {
137
+ encoding: "utf8",
138
+ mode: 384
139
+ });
140
+ return key;
141
+ }
142
+ function encryptText(text) {
143
+ const key = loadEncryptionKey();
144
+ const iv = randomBytes(16);
145
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
146
+ const data = cipher.update(text, "utf8", "hex") + cipher.final("hex");
147
+ return JSON.stringify({
148
+ v: 1,
149
+ iv: iv.toString("hex"),
150
+ data,
151
+ tag: cipher.getAuthTag().toString("hex")
152
+ });
153
+ }
154
+ function decryptText(encJson) {
155
+ const {
156
+ iv,
157
+ data,
158
+ tag
159
+ } = JSON.parse(encJson);
160
+ const key = loadEncryptionKey();
161
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "hex"));
162
+ decipher.setAuthTag(Buffer.from(tag, "hex"));
163
+ return decipher.update(data, "hex", "utf8") + decipher.final("utf8");
164
+ }
165
+ function readJsonFile(file) {
125
166
  try {
126
- fileCfg = JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
167
+ return JSON.parse(readFileSync(file, "utf8"));
127
168
  } catch {
128
- fileCfg = {};
169
+ return null;
170
+ }
171
+ }
172
+ function loadConfig() {
173
+ const env = process?.env ?? {};
174
+ const fileCfg = readJsonFile(CONFIG_FILE) ?? {};
175
+ if (env.OPENCODE_GO_AUTH_COOKIE) {
176
+ return {
177
+ workspaceId: env.OPENCODE_GO_WORKSPACE_ID || fileCfg.workspace_id || "",
178
+ authCookie: env.OPENCODE_GO_AUTH_COOKIE
179
+ };
129
180
  }
130
- const cookieFile = env.OPENCODE_GO_COOKIE_FILE || fileCfg.cookie_file || `${CONFIG_DIR}\\go-auth-cookie.txt`;
131
- let fileCookie = "";
132
181
  try {
133
- let p = cookieFile;
134
- if (p === "~") p = (env.HOME || env.USERPROFILE) + "";
135
- else if (p.startsWith("~/")) p = (env.HOME || env.USERPROFILE || "") + p.slice(1);
136
- fileCookie = readFileSync(p, "utf8").trim();
182
+ if (existsSync(COOKIE_ENC_FILE)) {
183
+ const cookie = decryptText(readFileSync(COOKIE_ENC_FILE, "utf8"));
184
+ if (cookie) {
185
+ return {
186
+ workspaceId: env.OPENCODE_GO_WORKSPACE_ID || fileCfg.workspace_id || "",
187
+ authCookie: cookie
188
+ };
189
+ }
190
+ }
137
191
  } catch {
138
- fileCookie = "";
192
+ }
193
+ const legacyCookie = fileCfg.auth_cookie || fileCfg.cookie || (() => {
194
+ try {
195
+ return readFileSync(COOKIE_PLAIN_FILE, "utf8").trim();
196
+ } catch {
197
+ return "";
198
+ }
199
+ })();
200
+ if (legacyCookie) {
201
+ const enc = encryptText(legacyCookie);
202
+ mkdirSync(CONFIG_DIR, {
203
+ recursive: true
204
+ });
205
+ writeFileSync(COOKIE_ENC_FILE, enc, "utf8");
206
+ const cleanCfg = {
207
+ ...fileCfg
208
+ };
209
+ delete cleanCfg.cookie;
210
+ delete cleanCfg.auth_cookie;
211
+ try {
212
+ writeFileSync(CONFIG_FILE, JSON.stringify(cleanCfg, null, 2), "utf8");
213
+ } catch {
214
+ }
215
+ try {
216
+ if (existsSync(COOKIE_PLAIN_FILE)) renameSync(COOKIE_PLAIN_FILE, COOKIE_PLAIN_FILE + ".bak");
217
+ } catch {
218
+ }
219
+ return {
220
+ workspaceId: env.OPENCODE_GO_WORKSPACE_ID || fileCfg.workspace_id || "",
221
+ authCookie: legacyCookie
222
+ };
139
223
  }
140
224
  return {
141
225
  workspaceId: env.OPENCODE_GO_WORKSPACE_ID || fileCfg.workspace_id || "",
142
- authCookie: env.OPENCODE_GO_AUTH_COOKIE || fileCfg.auth_cookie || fileCfg.cookie || fileCookie
226
+ authCookie: ""
143
227
  };
144
228
  }
145
229
  function saveConfig(patch) {
146
- let fileCfg = {};
147
- try {
148
- fileCfg = JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
149
- } catch {
150
- fileCfg = {};
151
- }
230
+ const fileCfg = readJsonFile(CONFIG_FILE) ?? {};
152
231
  if (patch.workspace_id !== void 0) fileCfg.workspace_id = patch.workspace_id;
153
- if (patch.cookie !== void 0) fileCfg.cookie = patch.cookie;
232
+ if (patch.cookie !== void 0) {
233
+ if (patch.cookie) {
234
+ mkdirSync(CONFIG_DIR, {
235
+ recursive: true
236
+ });
237
+ writeFileSync(COOKIE_ENC_FILE, encryptText(patch.cookie), "utf8");
238
+ delete fileCfg.cookie;
239
+ delete fileCfg.auth_cookie;
240
+ try {
241
+ if (existsSync(COOKIE_PLAIN_FILE)) renameSync(COOKIE_PLAIN_FILE, COOKIE_PLAIN_FILE + ".bak");
242
+ } catch {
243
+ }
244
+ } else {
245
+ try {
246
+ if (existsSync(COOKIE_ENC_FILE)) renameSync(COOKIE_ENC_FILE, COOKIE_ENC_FILE + ".bak");
247
+ } catch {
248
+ }
249
+ }
250
+ }
154
251
  try {
155
252
  mkdirSync(dirname(CONFIG_FILE), {
156
253
  recursive: true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-go-usage-tui",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "OpenCode TUI plugin displaying OpenCode Go usage in the sidebar",
5
5
  "type": "module",
6
6
  "exports": {
package/src/index.tsx CHANGED
@@ -10,8 +10,9 @@ import type {
10
10
  } from "@opencode-ai/plugin/tui"
11
11
  import { createSignal, createEffect, onMount, onCleanup, Show } from "solid-js"
12
12
  import type { JSX } from "@opentui/solid"
13
- import { readFileSync, writeFileSync, mkdirSync } from "node:fs"
13
+ import { readFileSync, writeFileSync, mkdirSync, renameSync, existsSync } from "node:fs"
14
14
  import { join, dirname } from "node:path"
15
+ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"
15
16
  import { createT, detectLang, LANG_META } from "./i18n"
16
17
  import type { LangCode } from "./i18n"
17
18
 
@@ -24,35 +25,104 @@ const CONFIG_DIR = process?.env?.OPENCODE_CONFIG_DIR
24
25
  || (process?.env?.USERPROFILE ? `${process.env.USERPROFILE}\\.config\\opencode` : "")
25
26
  || process?.env?.HOME + "/.config/opencode"
26
27
  const CONFIG_FILE = join(CONFIG_DIR, "go-usage-config.json")
28
+ const COOKIE_ENC_FILE = join(CONFIG_DIR, "go-auth-cookie.enc")
29
+ const KEY_FILE = join(CONFIG_DIR, ".encryption-key")
30
+ const COOKIE_PLAIN_FILE = `${CONFIG_DIR}\\go-auth-cookie.txt`
27
31
  const KV_PREFIX = "go-usage"
28
32
 
33
+ // ── AES-256-GCM 加密存储 ────────────────────────────────
34
+ // cookie 以密文存 go-auth-cookie.enc,密钥存 .encryption-key。
35
+ // 注意:Windows 无 Unix 权限位,mode 0o600 仅对 macOS/Linux 生效;
36
+ // Windows 上密钥与密文同目录,防护重点是"被动泄露"(备份/云同步/日志),
37
+ // 无法抵御能读取该目录的本地恶意程序。
38
+ function loadEncryptionKey(): Buffer {
39
+ try {
40
+ const hex = readFileSync(KEY_FILE, "utf8").trim()
41
+ if (hex) return Buffer.from(hex, "hex")
42
+ } catch { /* 无密钥则新建 */ }
43
+ const key = randomBytes(32)
44
+ mkdirSync(dirname(KEY_FILE), { recursive: true })
45
+ writeFileSync(KEY_FILE, key.toString("hex"), { encoding: "utf8", mode: 0o600 })
46
+ return key
47
+ }
48
+
49
+ function encryptText(text: string): string {
50
+ const key = loadEncryptionKey()
51
+ const iv = randomBytes(16)
52
+ const cipher = createCipheriv("aes-256-gcm", key, iv)
53
+ const data = cipher.update(text, "utf8", "hex") + cipher.final("hex")
54
+ return JSON.stringify({ v: 1, iv: iv.toString("hex"), data, tag: cipher.getAuthTag().toString("hex") })
55
+ }
56
+
57
+ function decryptText(encJson: string): string {
58
+ const { iv, data, tag } = JSON.parse(encJson)
59
+ const key = loadEncryptionKey()
60
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "hex"))
61
+ decipher.setAuthTag(Buffer.from(tag, "hex"))
62
+ return decipher.update(data, "hex", "utf8") + decipher.final("utf8")
63
+ }
64
+
65
+ function readJsonFile<T>(file: string): T | null {
66
+ try { return JSON.parse(readFileSync(file, "utf8")) as T } catch { return null }
67
+ }
68
+
69
+ interface ConfigFile { workspace_id?: string; cookie?: string; auth_cookie?: string; cookie_file?: string }
70
+
29
71
  function loadConfig(): { workspaceId: string; authCookie: string } {
30
72
  const env = process?.env ?? {}
31
- let fileCfg: Record<string, string> = {}
32
- try {
33
- fileCfg = JSON.parse(readFileSync(CONFIG_FILE, "utf8"))
34
- } catch { fileCfg = {} }
35
- const cookieFile = env.OPENCODE_GO_COOKIE_FILE || fileCfg.cookie_file || `${CONFIG_DIR}\\go-auth-cookie.txt`
36
- let fileCookie = ""
73
+ const fileCfg = readJsonFile<ConfigFile>(CONFIG_FILE) ?? {}
74
+
75
+ // 1. 环境变量优先
76
+ if (env.OPENCODE_GO_AUTH_COOKIE) {
77
+ return { workspaceId: env.OPENCODE_GO_WORKSPACE_ID || fileCfg.workspace_id || "", authCookie: env.OPENCODE_GO_AUTH_COOKIE }
78
+ }
79
+
80
+ // 2. 加密存储
37
81
  try {
38
- let p = cookieFile
39
- if (p === "~") p = (env.HOME || env.USERPROFILE) + ""
40
- else if (p.startsWith("~/")) p = (env.HOME || env.USERPROFILE || "") + p.slice(1)
41
- fileCookie = readFileSync(p, "utf8").trim()
42
- } catch { fileCookie = "" }
43
- return {
44
- workspaceId: env.OPENCODE_GO_WORKSPACE_ID || fileCfg.workspace_id || "",
45
- authCookie: env.OPENCODE_GO_AUTH_COOKIE || fileCfg.auth_cookie || fileCfg.cookie || fileCookie,
82
+ if (existsSync(COOKIE_ENC_FILE)) {
83
+ const cookie = decryptText(readFileSync(COOKIE_ENC_FILE, "utf8"))
84
+ if (cookie) {
85
+ return { workspaceId: env.OPENCODE_GO_WORKSPACE_ID || fileCfg.workspace_id || "", authCookie: cookie }
86
+ }
87
+ }
88
+ } catch { /* 密文损坏时回退到明文迁移 */ }
89
+
90
+ // 3. 兼容旧明文(config.json 内嵌 cookie / auth_cookie / 独立 txt 文件)——自动迁移
91
+ const legacyCookie = fileCfg.auth_cookie || fileCfg.cookie || (() => {
92
+ try { return readFileSync(COOKIE_PLAIN_FILE, "utf8").trim() } catch { return "" }
93
+ })()
94
+ if (legacyCookie) {
95
+ const enc = encryptText(legacyCookie)
96
+ mkdirSync(CONFIG_DIR, { recursive: true })
97
+ writeFileSync(COOKIE_ENC_FILE, enc, "utf8")
98
+ // 清理旧明文
99
+ const cleanCfg: ConfigFile = { ...fileCfg }
100
+ delete cleanCfg.cookie
101
+ delete cleanCfg.auth_cookie
102
+ try { writeFileSync(CONFIG_FILE, JSON.stringify(cleanCfg, null, 2), "utf8") } catch { /* 忽略 */ }
103
+ try { if (existsSync(COOKIE_PLAIN_FILE)) renameSync(COOKIE_PLAIN_FILE, COOKIE_PLAIN_FILE + ".bak") } catch { /* 忽略 */ }
104
+ return { workspaceId: env.OPENCODE_GO_WORKSPACE_ID || fileCfg.workspace_id || "", authCookie: legacyCookie }
46
105
  }
106
+
107
+ return { workspaceId: env.OPENCODE_GO_WORKSPACE_ID || fileCfg.workspace_id || "", authCookie: "" }
47
108
  }
48
109
 
49
110
  function saveConfig(patch: { workspace_id?: string; cookie?: string }): void {
50
- let fileCfg: Record<string, string> = {}
51
- try {
52
- fileCfg = JSON.parse(readFileSync(CONFIG_FILE, "utf8"))
53
- } catch { fileCfg = {} }
111
+ const fileCfg = readJsonFile<ConfigFile>(CONFIG_FILE) ?? {}
54
112
  if (patch.workspace_id !== undefined) fileCfg.workspace_id = patch.workspace_id
55
- if (patch.cookie !== undefined) fileCfg.cookie = patch.cookie
113
+ if (patch.cookie !== undefined) {
114
+ // cookie 改为加密存储,不再写明文到 config.json
115
+ if (patch.cookie) {
116
+ mkdirSync(CONFIG_DIR, { recursive: true })
117
+ writeFileSync(COOKIE_ENC_FILE, encryptText(patch.cookie), "utf8")
118
+ delete fileCfg.cookie
119
+ delete fileCfg.auth_cookie
120
+ // 清理可能存在的旧明文 txt
121
+ try { if (existsSync(COOKIE_PLAIN_FILE)) renameSync(COOKIE_PLAIN_FILE, COOKIE_PLAIN_FILE + ".bak") } catch { /* 忽略 */ }
122
+ } else {
123
+ try { if (existsSync(COOKIE_ENC_FILE)) renameSync(COOKIE_ENC_FILE, COOKIE_ENC_FILE + ".bak") } catch { /* 忽略 */ }
124
+ }
125
+ }
56
126
  try {
57
127
  mkdirSync(dirname(CONFIG_FILE), { recursive: true })
58
128
  writeFileSync(CONFIG_FILE, JSON.stringify(fileCfg, null, 2), "utf8")