@co0ontty/wand 2.1.1 → 2.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/dist/server.js CHANGED
@@ -29,6 +29,7 @@ import { canUseDetachedUpdateHelper, startDetachedUpdateHelper } from "./update-
29
29
  import { registerUploadRoutes } from "./upload-routes.js";
30
30
  import { optimizePrompt, PromptOptimizeError } from "./prompt-optimizer.js";
31
31
  import { resolveDatabasePath, WandStorage } from "./storage.js";
32
+ import { DEFAULT_BROWSER_EXTENSION_BASE_URL, buildPasswordSecurityReport, generatePassword, generateTotpCode, normalizePasswordItemType, } from "./password-manager.js";
32
33
  import { deepRepairRuntimePath, formatPathRepairSummary, repairRuntimePath } from "./path-repair.js";
33
34
  import { isLogBusActive, wandTuiLog } from "./tui/log-bus.js";
34
35
  import { EMBEDDED_WEB_ASSETS } from "./web-ui/embedded-assets.js";
@@ -384,15 +385,38 @@ async function enrichWithGitStatus(items, dirPath) {
384
385
  }
385
386
  }
386
387
  // ── Auth helpers ──
387
- function buildRequireAuth(useHttps) {
388
+ function buildRequireAuth(useHttps, storage, config) {
388
389
  return function requireAuth(req, res, next) {
389
- if (!validateSession(readSessionCookie(req, useHttps))) {
390
+ if (!validateSession(readSessionCookie(req, useHttps)) && !validateBearerAppToken(req, storage, config)) {
390
391
  res.status(401).json({ error: "未授权,请先登录。" });
391
392
  return;
392
393
  }
393
394
  next();
394
395
  };
395
396
  }
397
+ function getEffectivePassword(storage, config) {
398
+ return storage.getPassword() ?? config.password;
399
+ }
400
+ function validateBearerAppToken(req, storage, config) {
401
+ const header = firstHeaderValue(req.headers.authorization);
402
+ if (!header?.startsWith("Bearer "))
403
+ return false;
404
+ const token = header.slice("Bearer ".length).trim();
405
+ if (!token)
406
+ return false;
407
+ try {
408
+ return verifyAppToken(token, getEffectivePassword(storage, config), config.appSecret ?? "");
409
+ }
410
+ catch {
411
+ return false;
412
+ }
413
+ }
414
+ function appTokenLoginPayload(storage, config) {
415
+ return {
416
+ appToken: generateAppToken(getEffectivePassword(storage, config), config.appSecret ?? ""),
417
+ serverUrl: DEFAULT_BROWSER_EXTENSION_BASE_URL,
418
+ };
419
+ }
396
420
  // ── App connection token helpers ──
397
421
  function generateAppToken(password, secret) {
398
422
  return crypto.createHmac("sha256", secret).update(password).digest("hex");
@@ -477,6 +501,13 @@ function normalizePublicOrigin(value) {
477
501
  return undefined;
478
502
  }
479
503
  }
504
+ function isBrowserExtensionOrigin(value) {
505
+ if (!value)
506
+ return false;
507
+ return /^chrome-extension:\/\/[a-z]{32}$/i.test(value)
508
+ || /^moz-extension:\/\/[0-9a-f-]+$/i.test(value)
509
+ || /^safari-web-extension:\/\//i.test(value);
510
+ }
480
511
  function isPrivateIpv4(address) {
481
512
  if (address.startsWith("10.") || address.startsWith("192.168."))
482
513
  return true;
@@ -1027,10 +1058,25 @@ export async function startServer(config, configPath) {
1027
1058
  const structuredSessions = new StructuredSessionManager(storage, config, structuredLogger);
1028
1059
  const useHttps = config.https === true;
1029
1060
  const protocol = useHttps ? "https" : "http";
1030
- const requireAuth = buildRequireAuth(useHttps);
1061
+ const requireAuth = buildRequireAuth(useHttps, storage, config);
1031
1062
  const nodeModulesDir = path.join(RUNTIME_ROOT_DIR, "node_modules");
1032
1063
  app.use(express.json({ limit: "1mb" }));
1033
1064
  app.use(compression({ threshold: 1024 }));
1065
+ app.use((req, res, next) => {
1066
+ const origin = firstHeaderValue(req.headers.origin);
1067
+ if (origin && isBrowserExtensionOrigin(origin)) {
1068
+ res.setHeader("Access-Control-Allow-Origin", origin);
1069
+ res.setHeader("Access-Control-Allow-Credentials", "true");
1070
+ res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
1071
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
1072
+ res.setHeader("Vary", "Origin");
1073
+ }
1074
+ if (req.method === "OPTIONS" && origin && isBrowserExtensionOrigin(origin)) {
1075
+ res.status(204).end();
1076
+ return;
1077
+ }
1078
+ next();
1079
+ });
1034
1080
  const sendEmbeddedVendorAsset = (assetPath, _req, res) => {
1035
1081
  const asset = EMBEDDED_WEB_ASSETS.vendor[assetPath];
1036
1082
  res.setHeader("Cache-Control", "public, max-age=604800, immutable");
@@ -1095,9 +1141,8 @@ export async function startServer(config, configPath) {
1095
1141
  res.status(429).json({ error: "登录尝试次数过多,请在 15 分钟后再试。" });
1096
1142
  return;
1097
1143
  }
1098
- const { password, appToken } = req.body;
1099
- const dbPassword = storage.getPassword();
1100
- const effectivePassword = dbPassword ?? config.password;
1144
+ const { password, appToken, client } = req.body;
1145
+ const effectivePassword = getEffectivePassword(storage, config);
1101
1146
  // App token login — derived from password, so password change invalidates it
1102
1147
  let authenticated = false;
1103
1148
  if (appToken) {
@@ -1138,7 +1183,10 @@ export async function startServer(config, configPath) {
1138
1183
  res.cookie(SESSION_COOKIE_HTTP, token, { ...cookieOpts, secure: false });
1139
1184
  res.cookie(SESSION_COOKIE_LEGACY, token, { ...cookieOpts, secure: false });
1140
1185
  }
1141
- res.json({ ok: true });
1186
+ res.json({
1187
+ ok: true,
1188
+ ...(client === "browser-extension" ? appTokenLoginPayload(storage, config) : {}),
1189
+ });
1142
1190
  });
1143
1191
  app.post("/api/logout", (req, res) => {
1144
1192
  revokeSession(readSessionCookie(req, useHttps));
@@ -1281,6 +1329,119 @@ export async function startServer(config, configPath) {
1281
1329
  currentVersion: DISPLAY_VERSION,
1282
1330
  });
1283
1331
  });
1332
+ // ── Browser extension password vault endpoints ──
1333
+ app.get("/api/browser-extension/status", (_req, res) => {
1334
+ res.json({
1335
+ ok: true,
1336
+ serverUrl: DEFAULT_BROWSER_EXTENSION_BASE_URL,
1337
+ features: {
1338
+ loginAutofill: true,
1339
+ saveLogins: true,
1340
+ federatedLoginMemory: true,
1341
+ passwordGenerator: true,
1342
+ totp: true,
1343
+ cardsAndIdentities: true,
1344
+ vaults: true,
1345
+ securityReport: true,
1346
+ passkeys: "webauthn-proxy",
1347
+ },
1348
+ });
1349
+ });
1350
+ app.get("/api/browser-extension/vaults", (_req, res) => {
1351
+ res.json({ vaults: storage.listPasswordVaults() });
1352
+ });
1353
+ app.post("/api/browser-extension/vaults", (req, res) => {
1354
+ try {
1355
+ const vault = storage.createPasswordVault(req.body.name);
1356
+ res.status(201).json({ vault });
1357
+ }
1358
+ catch (error) {
1359
+ res.status(400).json({ error: getErrorMessage(error, "无法创建 vault。") });
1360
+ }
1361
+ });
1362
+ app.get("/api/browser-extension/items", (req, res) => {
1363
+ const filter = {
1364
+ q: firstQueryStringValue(req.query.q),
1365
+ url: firstQueryStringValue(req.query.url),
1366
+ vaultId: firstQueryStringValue(req.query.vaultId),
1367
+ type: req.query.type ? normalizePasswordItemType(firstQueryStringValue(req.query.type)) : undefined,
1368
+ limit: req.query.limit ? Number(firstQueryStringValue(req.query.limit)) : undefined,
1369
+ };
1370
+ res.json({ items: storage.listPasswordItems(filter) });
1371
+ });
1372
+ app.post("/api/browser-extension/items", (req, res) => {
1373
+ try {
1374
+ const item = storage.createPasswordItem(req.body);
1375
+ res.status(201).json({ item });
1376
+ }
1377
+ catch (error) {
1378
+ res.status(400).json({ error: getErrorMessage(error, "无法保存条目。") });
1379
+ }
1380
+ });
1381
+ app.get("/api/browser-extension/items/:id", (req, res) => {
1382
+ const item = storage.getPasswordItem(req.params.id);
1383
+ if (!item) {
1384
+ res.status(404).json({ error: "条目不存在。" });
1385
+ return;
1386
+ }
1387
+ res.json({ item });
1388
+ });
1389
+ app.put("/api/browser-extension/items/:id", (req, res) => {
1390
+ try {
1391
+ const item = storage.updatePasswordItem(req.params.id, req.body);
1392
+ if (!item) {
1393
+ res.status(404).json({ error: "条目不存在。" });
1394
+ return;
1395
+ }
1396
+ res.json({ item });
1397
+ }
1398
+ catch (error) {
1399
+ res.status(400).json({ error: getErrorMessage(error, "无法更新条目。") });
1400
+ }
1401
+ });
1402
+ app.delete("/api/browser-extension/items/:id", (req, res) => {
1403
+ if (!storage.deletePasswordItem(req.params.id)) {
1404
+ res.status(404).json({ error: "条目不存在。" });
1405
+ return;
1406
+ }
1407
+ res.json({ ok: true });
1408
+ });
1409
+ app.post("/api/browser-extension/items/:id/use", (req, res) => {
1410
+ const item = storage.touchPasswordItem(req.params.id);
1411
+ if (!item) {
1412
+ res.status(404).json({ error: "条目不存在。" });
1413
+ return;
1414
+ }
1415
+ res.json({ item });
1416
+ });
1417
+ app.get("/api/browser-extension/generator/password", (req, res) => {
1418
+ res.json({
1419
+ password: generatePassword({
1420
+ length: Number(firstQueryStringValue(req.query.length)),
1421
+ digits: firstQueryStringValue(req.query.digits) !== "false",
1422
+ symbols: firstQueryStringValue(req.query.symbols) !== "false",
1423
+ }),
1424
+ });
1425
+ });
1426
+ app.post("/api/browser-extension/totp/preview", (req, res) => {
1427
+ try {
1428
+ const { secret, digits, period } = req.body;
1429
+ if (!secret) {
1430
+ res.status(400).json({ error: "缺少 TOTP secret。" });
1431
+ return;
1432
+ }
1433
+ res.json({
1434
+ code: generateTotpCode(secret, Date.now(), digits ?? 6, period ?? 30),
1435
+ period: period ?? 30,
1436
+ });
1437
+ }
1438
+ catch (error) {
1439
+ res.status(400).json({ error: getErrorMessage(error, "无法生成 TOTP。") });
1440
+ }
1441
+ });
1442
+ app.get("/api/browser-extension/security-report", (_req, res) => {
1443
+ res.json({ report: buildPasswordSecurityReport(storage.listPasswordItems({ includeArchived: false, limit: 200 })) });
1444
+ });
1284
1445
  // ── Settings endpoints ──
1285
1446
  app.get("/api/settings", async (_req, res) => {
1286
1447
  const certPaths = {
@@ -1440,8 +1601,7 @@ export async function startServer(config, configPath) {
1440
1601
  });
1441
1602
  });
1442
1603
  app.get("/api/app-connect-code", requireAuth, (req, res) => {
1443
- const dbPassword = storage.getPassword();
1444
- const effectivePassword = dbPassword ?? config.password;
1604
+ const effectivePassword = getEffectivePassword(storage, config);
1445
1605
  const protocol = getPublicRequestProtocol(req, useHttps ? "https" : "http");
1446
1606
  const host = getPublicRequestHost(req, config);
1447
1607
  const browserOrigin = normalizePublicOrigin(firstQueryStringValue(req.query.origin));
@@ -2271,15 +2431,17 @@ export async function startServer(config, configPath) {
2271
2431
  server.once("error", onListenError);
2272
2432
  server.listen(config.port, config.host, () => {
2273
2433
  server.off("error", onListenError);
2274
- bindAddr = `${config.host}:${config.port}`;
2434
+ const address = server.address();
2435
+ const actualPort = typeof address === "object" && address ? address.port : config.port;
2436
+ bindAddr = `${config.host}:${actualPort}`;
2275
2437
  const scheme = useHttps ? "HTTPS" : "HTTP";
2276
2438
  // 主 URL:本机回环;若绑定 0.0.0.0 再补一个对外提示。
2277
- collectedUrls.push({ url: `${protocol}://127.0.0.1:${config.port}`, scheme });
2439
+ collectedUrls.push({ url: `${protocol}://127.0.0.1:${actualPort}`, scheme });
2278
2440
  if (config.host === "0.0.0.0") {
2279
- collectedUrls.push({ url: `${protocol}://0.0.0.0:${config.port}`, scheme });
2441
+ collectedUrls.push({ url: `${protocol}://0.0.0.0:${actualPort}`, scheme });
2280
2442
  }
2281
2443
  else if (config.host !== "127.0.0.1" && config.host !== "localhost") {
2282
- collectedUrls.push({ url: `${protocol}://${config.host}:${config.port}`, scheme });
2444
+ collectedUrls.push({ url: `${protocol}://${config.host}:${actualPort}`, scheme });
2283
2445
  }
2284
2446
  resolve();
2285
2447
  });
@@ -2287,10 +2449,16 @@ export async function startServer(config, configPath) {
2287
2449
  if (!storage.hasCustomPassword() && config.password === "change-me") {
2288
2450
  wandWarn("正在使用默认密码(change-me),任何能访问本机的人都可以登录。", "修改方法:在界面右上角「设置」中修改密码,或运行:node dist/cli.js config:set password <你的新密码>");
2289
2451
  }
2452
+ const testMode = process.env.WAND_TEST_MODE === "1";
2453
+ const updateChecksEnabled = !testMode && process.env.WAND_DISABLE_UPDATE_CHECK !== "1";
2290
2454
  // Start configured background sessions after the server is already reachable.
2291
- processes.runStartupCommands();
2455
+ if (!testMode) {
2456
+ processes.runStartupCommands();
2457
+ }
2292
2458
  // Pre-warm model cache (probes claude --version + codex debug models).
2293
- refreshModels().catch(() => { });
2459
+ if (!testMode) {
2460
+ refreshModels().catch(() => { });
2461
+ }
2294
2462
  // ── Auto-update endpoints ──
2295
2463
  app.get("/api/auto-update", (_req, res) => {
2296
2464
  const web = storage.getConfigValue("autoUpdateWeb") === "true";
@@ -2386,12 +2554,16 @@ export async function startServer(config, configPath) {
2386
2554
  });
2387
2555
  }
2388
2556
  }
2389
- // Background update check on startup
2390
- performAutoUpdate().catch(() => { });
2391
- // Periodic update check (every 30 minutes)
2392
- setInterval(() => {
2557
+ let updateCheckTimer = null;
2558
+ if (updateChecksEnabled) {
2559
+ // Background update check on startup
2393
2560
  performAutoUpdate().catch(() => { });
2394
- }, 30 * 60 * 1000);
2561
+ // Periodic update check (every 30 minutes)
2562
+ updateCheckTimer = setInterval(() => {
2563
+ performAutoUpdate().catch(() => { });
2564
+ }, 30 * 60 * 1000);
2565
+ updateCheckTimer.unref();
2566
+ }
2395
2567
  const close = () => new Promise((resolve) => {
2396
2568
  let done = false;
2397
2569
  const finish = () => {
@@ -2412,6 +2584,8 @@ export async function startServer(config, configPath) {
2412
2584
  wss.close();
2413
2585
  }
2414
2586
  catch { /* ignore */ }
2587
+ if (updateCheckTimer)
2588
+ clearInterval(updateCheckTimer);
2415
2589
  try {
2416
2590
  server.close(() => finish());
2417
2591
  }
package/dist/storage.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { SessionSnapshot } from "./types.js";
2
+ import { type PasswordVault, type PasswordVaultItem, type PasswordVaultItemFilter, type PasswordVaultItemInput } from "./password-manager.js";
2
3
  export declare const DEFAULT_DB_FILE = "wand.db";
3
4
  export interface PersistedAuthSession {
4
5
  token: string;
@@ -32,6 +33,16 @@ export declare class WandStorage {
32
33
  getAppSecret(): string | null;
33
34
  /** Persist appSecret in database (DB is the authoritative source after first migration) */
34
35
  setAppSecret(value: string): void;
36
+ ensureDefaultPasswordVault(): void;
37
+ listPasswordVaults(): PasswordVault[];
38
+ createPasswordVault(nameInput: unknown): PasswordVault;
39
+ getPasswordVault(id: string): PasswordVault | null;
40
+ listPasswordItems(filter?: PasswordVaultItemFilter): PasswordVaultItem[];
41
+ getPasswordItem(id: string): PasswordVaultItem | null;
42
+ createPasswordItem(input: PasswordVaultItemInput): PasswordVaultItem;
43
+ updatePasswordItem(id: string, input: PasswordVaultItemInput): PasswordVaultItem | null;
44
+ touchPasswordItem(id: string): PasswordVaultItem | null;
45
+ deletePasswordItem(id: string): boolean;
35
46
  saveAuthSession(token: string, expiresAt: number): void;
36
47
  getAuthSession(token: string): PersistedAuthSession | null;
37
48
  deleteAuthSession(token: string): void;
package/dist/storage.js CHANGED
@@ -1,6 +1,8 @@
1
+ import crypto from "node:crypto";
1
2
  import { existsSync, mkdirSync } from "node:fs";
2
3
  import path from "node:path";
3
4
  import { DatabaseSync } from "node:sqlite";
5
+ import { DEFAULT_PASSWORD_VAULT_ID, DEFAULT_PASSWORD_VAULT_NAME, itemMatchesFilter, normalizePasswordItemInput, normalizeVaultName, nowIso, } from "./password-manager.js";
4
6
  function safeJsonParse(raw) {
5
7
  if (!raw)
6
8
  return undefined;
@@ -232,6 +234,37 @@ const INIT_SQL = `
232
234
  key TEXT PRIMARY KEY,
233
235
  value TEXT NOT NULL
234
236
  );
237
+
238
+ CREATE TABLE IF NOT EXISTS password_vaults (
239
+ id TEXT PRIMARY KEY,
240
+ name TEXT NOT NULL,
241
+ created_at TEXT NOT NULL,
242
+ updated_at TEXT NOT NULL
243
+ );
244
+
245
+ CREATE TABLE IF NOT EXISTS password_items (
246
+ id TEXT PRIMARY KEY,
247
+ vault_id TEXT NOT NULL,
248
+ type TEXT NOT NULL,
249
+ title TEXT NOT NULL,
250
+ username TEXT,
251
+ password TEXT,
252
+ urls TEXT NOT NULL DEFAULT '[]',
253
+ notes TEXT,
254
+ fields TEXT NOT NULL DEFAULT '{}',
255
+ tags TEXT NOT NULL DEFAULT '[]',
256
+ favorite INTEGER NOT NULL DEFAULT 0,
257
+ archived INTEGER NOT NULL DEFAULT 0,
258
+ created_at TEXT NOT NULL,
259
+ updated_at TEXT NOT NULL,
260
+ last_used_at TEXT,
261
+ password_updated_at TEXT,
262
+ FOREIGN KEY(vault_id) REFERENCES password_vaults(id)
263
+ );
264
+
265
+ CREATE INDEX IF NOT EXISTS idx_password_items_vault ON password_items(vault_id);
266
+ CREATE INDEX IF NOT EXISTS idx_password_items_type ON password_items(type);
267
+ CREATE INDEX IF NOT EXISTS idx_password_items_updated ON password_items(updated_at);
235
268
  `;
236
269
  export function ensureDatabaseFile(dbPath) {
237
270
  mkdirSync(path.dirname(dbPath), { recursive: true });
@@ -249,6 +282,7 @@ export class WandStorage {
249
282
  this.db = new DatabaseSync(dbPath);
250
283
  this.db.exec(INIT_SQL);
251
284
  ensureCommandSessionSchema(this.db);
285
+ this.ensureDefaultPasswordVault();
252
286
  }
253
287
  close() {
254
288
  this.db.close();
@@ -321,6 +355,120 @@ export class WandStorage {
321
355
  setAppSecret(value) {
322
356
  this.setConfigValue("appSecret", value);
323
357
  }
358
+ // ============ Browser Extension Password Vault Methods ============
359
+ ensureDefaultPasswordVault() {
360
+ const now = nowIso();
361
+ this.db
362
+ .prepare(`INSERT INTO password_vaults (id, name, created_at, updated_at)
363
+ VALUES (?, ?, ?, ?)
364
+ ON CONFLICT(id) DO NOTHING`)
365
+ .run(DEFAULT_PASSWORD_VAULT_ID, DEFAULT_PASSWORD_VAULT_NAME, now, now);
366
+ }
367
+ listPasswordVaults() {
368
+ this.ensureDefaultPasswordVault();
369
+ const rows = this.db
370
+ .prepare("SELECT id, name, created_at, updated_at FROM password_vaults ORDER BY name COLLATE NOCASE ASC")
371
+ .all();
372
+ return rows.map(mapPasswordVaultRow);
373
+ }
374
+ createPasswordVault(nameInput) {
375
+ const name = normalizeVaultName(nameInput);
376
+ const now = nowIso();
377
+ const id = crypto.randomUUID();
378
+ this.db
379
+ .prepare("INSERT INTO password_vaults (id, name, created_at, updated_at) VALUES (?, ?, ?, ?)")
380
+ .run(id, name, now, now);
381
+ return { id, name, createdAt: now, updatedAt: now };
382
+ }
383
+ getPasswordVault(id) {
384
+ const row = this.db
385
+ .prepare("SELECT id, name, created_at, updated_at FROM password_vaults WHERE id = ?")
386
+ .get(id);
387
+ return row ? mapPasswordVaultRow(row) : null;
388
+ }
389
+ listPasswordItems(filter = {}) {
390
+ this.ensureDefaultPasswordVault();
391
+ const rows = this.db
392
+ .prepare(`SELECT id, vault_id, type, title, username, password, urls, notes, fields, tags, favorite, archived,
393
+ created_at, updated_at, last_used_at, password_updated_at
394
+ FROM password_items
395
+ WHERE (? = 1 OR archived = 0)
396
+ ORDER BY favorite DESC, last_used_at DESC NULLS LAST, updated_at DESC`)
397
+ .all(filter.includeArchived ? 1 : 0);
398
+ const limit = typeof filter.limit === "number" && Number.isFinite(filter.limit)
399
+ ? Math.max(1, Math.min(200, Math.floor(filter.limit)))
400
+ : 100;
401
+ return rows.map(mapPasswordItemRow).filter((item) => itemMatchesFilter(item, filter)).slice(0, limit);
402
+ }
403
+ getPasswordItem(id) {
404
+ const row = this.db
405
+ .prepare(`SELECT id, vault_id, type, title, username, password, urls, notes, fields, tags, favorite, archived,
406
+ created_at, updated_at, last_used_at, password_updated_at
407
+ FROM password_items
408
+ WHERE id = ? AND archived = 0`)
409
+ .get(id);
410
+ return row ? mapPasswordItemRow(row) : null;
411
+ }
412
+ createPasswordItem(input) {
413
+ this.ensureDefaultPasswordVault();
414
+ const normalized = normalizePasswordItemInput(input);
415
+ const vaultId = typeof input.vaultId === "string" && this.getPasswordVault(input.vaultId)
416
+ ? input.vaultId
417
+ : DEFAULT_PASSWORD_VAULT_ID;
418
+ const now = nowIso();
419
+ const id = crypto.randomUUID();
420
+ const passwordUpdatedAt = normalized.password ? now : undefined;
421
+ this.db
422
+ .prepare(`INSERT INTO password_items (
423
+ id, vault_id, type, title, username, password, urls, notes, fields, tags, favorite, archived,
424
+ created_at, updated_at, last_used_at, password_updated_at
425
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, NULL, ?)`)
426
+ .run(id, vaultId, normalized.type, normalized.title, normalized.username ?? null, normalized.password ?? null, JSON.stringify(normalized.urls), normalized.notes ?? null, JSON.stringify(normalized.fields), JSON.stringify(normalized.tags), normalized.favorite ? 1 : 0, now, now, passwordUpdatedAt ?? null);
427
+ return this.getPasswordItem(id);
428
+ }
429
+ updatePasswordItem(id, input) {
430
+ const existing = this.getPasswordItem(id);
431
+ if (!existing)
432
+ return null;
433
+ const merged = {
434
+ vaultId: input.vaultId ?? existing.vaultId,
435
+ type: input.type ?? existing.type,
436
+ title: input.title ?? existing.title,
437
+ username: input.username ?? existing.username,
438
+ password: input.password ?? existing.password,
439
+ urls: input.urls ?? existing.urls,
440
+ notes: input.notes ?? existing.notes,
441
+ fields: input.fields ?? existing.fields,
442
+ tags: input.tags ?? existing.tags,
443
+ favorite: input.favorite ?? existing.favorite,
444
+ };
445
+ const normalized = normalizePasswordItemInput(merged);
446
+ const vaultId = typeof merged.vaultId === "string" && this.getPasswordVault(merged.vaultId)
447
+ ? merged.vaultId
448
+ : existing.vaultId;
449
+ const now = nowIso();
450
+ const passwordChanged = Object.prototype.hasOwnProperty.call(input, "password") && normalized.password !== existing.password;
451
+ const passwordUpdatedAt = passwordChanged ? (normalized.password ? now : null) : (existing.passwordUpdatedAt ?? null);
452
+ this.db
453
+ .prepare(`UPDATE password_items
454
+ SET vault_id = ?, type = ?, title = ?, username = ?, password = ?, urls = ?, notes = ?,
455
+ fields = ?, tags = ?, favorite = ?, updated_at = ?, password_updated_at = ?
456
+ WHERE id = ? AND archived = 0`)
457
+ .run(vaultId, normalized.type, normalized.title, normalized.username ?? null, normalized.password ?? null, JSON.stringify(normalized.urls), normalized.notes ?? null, JSON.stringify(normalized.fields), JSON.stringify(normalized.tags), normalized.favorite ? 1 : 0, now, passwordUpdatedAt, id);
458
+ return this.getPasswordItem(id);
459
+ }
460
+ touchPasswordItem(id) {
461
+ const now = nowIso();
462
+ this.db.prepare("UPDATE password_items SET last_used_at = ?, updated_at = ? WHERE id = ? AND archived = 0").run(now, now, id);
463
+ return this.getPasswordItem(id);
464
+ }
465
+ deletePasswordItem(id) {
466
+ const now = nowIso();
467
+ const result = this.db
468
+ .prepare("UPDATE password_items SET archived = 1, updated_at = ? WHERE id = ? AND archived = 0")
469
+ .run(now, id);
470
+ return result.changes > 0;
471
+ }
324
472
  // ============ Auth Session Methods ============
325
473
  saveAuthSession(token, expiresAt) {
326
474
  this.db
@@ -422,6 +570,33 @@ export class WandStorage {
422
570
  this.db.prepare("DELETE FROM command_sessions WHERE id = ?").run(id);
423
571
  }
424
572
  }
573
+ function mapPasswordVaultRow(row) {
574
+ return {
575
+ id: row.id,
576
+ name: row.name,
577
+ createdAt: row.created_at,
578
+ updatedAt: row.updated_at,
579
+ };
580
+ }
581
+ function mapPasswordItemRow(row) {
582
+ return {
583
+ id: row.id,
584
+ vaultId: row.vault_id,
585
+ type: row.type,
586
+ title: row.title,
587
+ username: row.username ?? undefined,
588
+ password: row.password ?? undefined,
589
+ urls: safeJsonParse(row.urls)?.filter((item) => typeof item === "string") ?? [],
590
+ notes: row.notes ?? undefined,
591
+ fields: safeJsonParse(row.fields) ?? {},
592
+ tags: safeJsonParse(row.tags)?.filter((item) => typeof item === "string") ?? [],
593
+ favorite: Boolean(row.favorite),
594
+ createdAt: row.created_at,
595
+ updatedAt: row.updated_at,
596
+ lastUsedAt: row.last_used_at ?? undefined,
597
+ passwordUpdatedAt: row.password_updated_at ?? undefined,
598
+ };
599
+ }
425
600
  const SCHEMA_MIGRATIONS = [
426
601
  ["archived", "ALTER TABLE command_sessions ADD COLUMN archived INTEGER NOT NULL DEFAULT 0"],
427
602
  ["archived_at", "ALTER TABLE command_sessions ADD COLUMN archived_at TEXT"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@co0ontty/wand",
3
- "version": "2.1.1",
3
+ "version": "2.2.0",
4
4
  "description": "A web terminal for local CLI tools like Claude.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,7 +12,8 @@
12
12
  "url": ""
13
13
  },
14
14
  "files": [
15
- "dist"
15
+ "dist",
16
+ "browser-extension"
16
17
  ],
17
18
  "preferGlobal": true,
18
19
  "scripts": {
@@ -21,6 +22,7 @@
21
22
  "build:copy-content": "cp -r src/web-ui/content dist/web-ui/ && node scripts/minify-dist-content.js",
22
23
  "dev": "node scripts/bundle-browser.js && npm run generate:web-assets && tsx src/cli.ts web",
23
24
  "check": "node scripts/bundle-browser.js && npm run generate:web-assets && tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.browser.json",
25
+ "test": "node --test --import tsx tests/*.test.ts",
24
26
  "prepublishOnly": "VER=${WAND_PUBLISH_VERSION:-$(git tag --sort=-v:refname --list 'v*' | head -1)} && VER=${VER#v} && npm version \"$VER\" --no-git-tag-version --allow-same-version && npm run build"
25
27
  },
26
28
  "keywords": [