@co0ontty/wand 1.29.0 → 1.29.1

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/dist/cli.js CHANGED
@@ -78,14 +78,20 @@ async function main() {
78
78
  break;
79
79
  }
80
80
  case "config:show": {
81
- // 展示合并后的视图(JSON 部署字段 + DB 偏好字段)。
81
+ // 展示合并后的视图(JSON 部署字段 + DB 偏好字段)。password 脱敏:
82
+ // 显示是否已自定义("<set>" / "change-me"),避免误把真密码截图分享出去;
83
+ // 想看真值就直接读 DB(sqlite3 wand.db "SELECT * FROM app_config WHERE key='password'")。
82
84
  const { ensureDatabaseFile, resolveDatabasePath, WandStorage } = await import("./storage.js");
83
85
  const dbPath = resolveDatabasePath(configPath);
84
86
  ensureDatabaseFile(dbPath);
85
87
  const storage = new WandStorage(dbPath);
86
88
  try {
87
89
  const config = await loadConfigWithStorage(configPath, storage);
88
- process.stdout.write(`${JSON.stringify(config, null, 2)}\n`);
90
+ const display = {
91
+ ...config,
92
+ password: config.password === "change-me" ? "change-me" : "<set>",
93
+ };
94
+ process.stdout.write(`${JSON.stringify(display, null, 2)}\n`);
89
95
  }
90
96
  finally {
91
97
  storage.close();
@@ -109,6 +115,16 @@ async function main() {
109
115
  writePreferenceToStorage(config, storage, key, value);
110
116
  process.stdout.write(`[wand] Updated preference ${key} in ${dbPath}\n`);
111
117
  }
118
+ else if (key === "password") {
119
+ // password 走 SQLite,和 Web UI 设置面板保持同一个源。
120
+ // 历史上 setConfigValue("password") 只写 config.json,但登录用 dbPassword ?? config.password,
121
+ // 一旦 DB 里有值,写 JSON 完全不生效,命令静默返回成功 → 用户以为改了密码其实没改。
122
+ if (typeof value !== "string" || value.length < 6) {
123
+ throw new Error("password 长度至少为 6 个字符");
124
+ }
125
+ storage.setPassword(value);
126
+ process.stdout.write(`[wand] Updated password in ${dbPath}\n`);
127
+ }
112
128
  else {
113
129
  const nextConfig = setConfigValue(config, key, value);
114
130
  await saveConfig(configPath, nextConfig);
@@ -296,10 +312,10 @@ async function runAttach(live, configPath, useTui) {
296
312
  process.on("SIGTERM", onSignal);
297
313
  }
298
314
  function setConfigValue(config, key, value) {
299
- // 偏好字段(defaultMode/defaultCwd/...)由调用方分流到 storage,这里只处理 JSON 字段。
315
+ // 偏好字段(defaultMode/defaultCwd/...)由调用方分流到 storage,password 也走 DB
316
+ // (由 case "config:set" 直接处理),这里只剩纯 JSON 字段。
300
317
  switch (key) {
301
318
  case "host":
302
- case "password":
303
319
  case "shell":
304
320
  return {
305
321
  ...config,
package/dist/config.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import crypto from "node:crypto";
2
2
  import { existsSync } from "node:fs";
3
- import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import process from "node:process";
6
6
  const DEFAULT_CONFIG_DIR = ".wand";
@@ -86,6 +86,27 @@ export function resolveConfigDir(configPath) {
86
86
  export function hasConfigFile(configPath) {
87
87
  return existsSync(configPath);
88
88
  }
89
+ /**
90
+ * 原子写入:先写 `<dir>/.<file>.tmp-<rand>`,再 rename 覆盖目标。
91
+ * 防止 kill -9 / 断电 / 磁盘满导致 config.json 半截损坏 → 下次启动 catch 路径
92
+ * 把 defaults 写回去 → appSecret 重生成 → 已分发的 APK appToken 全部作废。
93
+ */
94
+ async function atomicWriteFile(filePath, content) {
95
+ const dir = path.dirname(filePath);
96
+ const base = path.basename(filePath);
97
+ const tmpPath = path.join(dir, `.${base}.tmp-${crypto.randomBytes(6).toString("hex")}`);
98
+ await writeFile(tmpPath, content, "utf8");
99
+ try {
100
+ await rename(tmpPath, filePath);
101
+ }
102
+ catch (err) {
103
+ try {
104
+ await unlink(tmpPath);
105
+ }
106
+ catch { /* noop */ }
107
+ throw err;
108
+ }
109
+ }
89
110
  export async function ensureConfig(configPath) {
90
111
  const dir = path.dirname(configPath);
91
112
  await mkdir(dir, { recursive: true });
@@ -95,20 +116,20 @@ export async function ensureConfig(configPath) {
95
116
  const normalized = `${JSON.stringify(merged, null, 2)}\n`;
96
117
  // Only write if the file content actually changed
97
118
  if (raw.trimEnd() !== normalized.trimEnd()) {
98
- await writeFile(configPath, normalized, "utf8");
119
+ await atomicWriteFile(configPath, normalized);
99
120
  }
100
121
  return merged;
101
122
  }
102
123
  catch {
103
124
  const config = defaultConfig();
104
- await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
125
+ await atomicWriteFile(configPath, `${JSON.stringify(config, null, 2)}\n`);
105
126
  return config;
106
127
  }
107
128
  }
108
129
  /** saveConfig 写出时去掉偏好字段——这些已经移到 SQLite。 */
109
130
  export async function saveConfig(configPath, config) {
110
131
  await mkdir(path.dirname(configPath), { recursive: true });
111
- await writeFile(configPath, `${JSON.stringify(stripPreferenceFields(config), null, 2)}\n`, "utf8");
132
+ await atomicWriteFile(configPath, `${JSON.stringify(stripPreferenceFields(config), null, 2)}\n`);
112
133
  }
113
134
  /**
114
135
  * 启动期合并 JSON + DB 偏好。语义:
@@ -129,11 +150,21 @@ export async function loadConfigWithStorage(configPath, storage) {
129
150
  hadFile = true;
130
151
  }
131
152
  catch {
153
+ // 文件缺失 或 JSON 损坏:保留空 rawInput;reconcileAppSecret 会优先用 DB 里那份,
154
+ // 避免已分发 APK 被踢下线。
132
155
  rawInput = {};
133
156
  }
134
157
  migrateLegacyPreferencesToDb(rawInput, storage);
158
+ migrateLegacyPasswordToDb(rawInput, storage);
135
159
  const config = mergeWithDefaults(rawInput);
136
160
  applyStoragePreferences(config, storage);
161
+ // appSecret: DB 是权威源。
162
+ // - DB 有 → 直接覆盖 runtime config(即使 mergeWithDefaults 临时生成了新的,也以 DB 为准)
163
+ // - DB 无、config 有 → 老用户首次升级,把 config.json 里的 appSecret 落到 DB
164
+ // - 都没 → 用 config 当前那个(mergeWithDefaults 已经生成),落到 DB
165
+ reconcileAppSecret(config, storage);
166
+ // password: DB 优先映射到 runtime config,让 config:show 与 server.ts 都看到真值
167
+ applyStoragePassword(config, storage);
137
168
  // 如果 JSON 里有偏好字段(说明是老版本配置或刚迁移),重写一次干净版本
138
169
  const hasLegacyPrefs = PREFERENCE_KEYS.some((key) => key in rawInput);
139
170
  if (!hadFile || hasLegacyPrefs) {
@@ -141,6 +172,46 @@ export async function loadConfigWithStorage(configPath, storage) {
141
172
  }
142
173
  return config;
143
174
  }
175
+ /**
176
+ * 把 DB 里的 appSecret 同步到 runtime config,缺失时反向回填,保证 DB 永远有备份。
177
+ * 这条路径修掉了之前的 bug:mergeWithDefaults 在缺失 appSecret 时会随机生成一个新的,
178
+ * 一旦 config.json 因为任何原因丢字段(损坏/手动编辑/catch fallback),所有 APK appToken 立刻作废。
179
+ */
180
+ function reconcileAppSecret(config, storage) {
181
+ const dbSecret = storage.getAppSecret();
182
+ if (dbSecret && dbSecret.length >= 32) {
183
+ config.appSecret = dbSecret;
184
+ return;
185
+ }
186
+ if (config.appSecret && config.appSecret.length >= 32) {
187
+ storage.setAppSecret(config.appSecret);
188
+ return;
189
+ }
190
+ const fresh = crypto.randomBytes(32).toString("hex");
191
+ config.appSecret = fresh;
192
+ storage.setAppSecret(fresh);
193
+ }
194
+ /** 把 DB 里设置过的 password 映射到 runtime config 字段,让 config:show 不再展示 "change-me" 假象。 */
195
+ function applyStoragePassword(config, storage) {
196
+ const dbPassword = storage.getPassword();
197
+ if (dbPassword !== null) {
198
+ config.password = dbPassword;
199
+ }
200
+ }
201
+ /**
202
+ * 老用户如果曾经手动编辑 config.json 设过非默认密码,但从没用 Web UI 改过密码
203
+ * (DB 里没有 password 行),那个 JSON 字段就是当前生效密码。升级到 DB 权威源之前
204
+ * 必须把它搬到 DB,否则后续如果 saveConfig 路径剥离 password 字段或 JSON 被截断,
205
+ * 用户会被锁在门外。DB 已经有 password 行的情况下不覆盖。
206
+ */
207
+ function migrateLegacyPasswordToDb(rawInput, storage) {
208
+ if (storage.hasCustomPassword())
209
+ return;
210
+ const legacy = rawInput.password;
211
+ if (typeof legacy === "string" && legacy.length >= 6 && legacy !== "change-me") {
212
+ storage.setPassword(legacy);
213
+ }
214
+ }
144
215
  /** Build a JSON-safe view of WandConfig that excludes preference fields (which live in DB). */
145
216
  function stripPreferenceFields(config) {
146
217
  const out = { ...config };
package/dist/storage.d.ts CHANGED
@@ -28,6 +28,11 @@ export declare class WandStorage {
28
28
  setPassword(password: string): void;
29
29
  /** Check if password has been set (not default) */
30
30
  hasCustomPassword(): boolean;
31
+ /** Get appSecret from database (used to mint Android appTokens) */
32
+ getAppSecret(): string | null;
33
+ /** Persist appSecret in database (DB is the authoritative source after first migration) */
34
+ setAppSecret(value: string): void;
35
+ hasAppSecret(): boolean;
31
36
  saveAuthSession(token: string, expiresAt: number): void;
32
37
  getAuthSession(token: string): PersistedAuthSession | null;
33
38
  deleteAuthSession(token: string): void;
package/dist/storage.js CHANGED
@@ -302,6 +302,17 @@ export class WandStorage {
302
302
  hasCustomPassword() {
303
303
  return this.getPassword() !== null;
304
304
  }
305
+ /** Get appSecret from database (used to mint Android appTokens) */
306
+ getAppSecret() {
307
+ return this.getConfigValue("appSecret");
308
+ }
309
+ /** Persist appSecret in database (DB is the authoritative source after first migration) */
310
+ setAppSecret(value) {
311
+ this.setConfigValue("appSecret", value);
312
+ }
313
+ hasAppSecret() {
314
+ return this.getAppSecret() !== null;
315
+ }
305
316
  // ============ Auth Session Methods ============
306
317
  saveAuthSession(token, expiresAt) {
307
318
  this.db
@@ -1467,15 +1467,6 @@
1467
1467
  '</button>' +
1468
1468
  '</div>' +
1469
1469
  '</div>' +
1470
- '<div class="sidebar-rail" id="sidebar-rail" aria-hidden="true">' +
1471
- '<button class="sidebar-rail-expand" id="sidebar-rail-expand" type="button" title="展开侧栏" aria-label="展开侧栏">' +
1472
- '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="10 6 16 12 10 18"/><line x1="20" y1="5" x2="20" y2="19"/></svg>' +
1473
- '</button>' +
1474
- '<div class="sidebar-rail-list" id="sidebar-rail-list">' + renderRailSessions() + '</div>' +
1475
- '<button class="sidebar-rail-new" id="sidebar-rail-new" type="button" title="新会话" aria-label="新会话">' +
1476
- '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>' +
1477
- '</button>' +
1478
- '</div>' +
1479
1470
  '</aside>' +
1480
1471
  '<main class="main-content">' +
1481
1472
  '<div class="main-header-row">' +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@co0ontty/wand",
3
- "version": "1.29.0",
3
+ "version": "1.29.1",
4
4
  "description": "A web terminal for local CLI tools like Claude.",
5
5
  "type": "module",
6
6
  "bin": {