ccdeck 3.29.3 → 3.29.6

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.
@@ -62,7 +62,7 @@
62
62
  stored === "light" || (stored !== "dark" && light) ? "light" : "dark");
63
63
  })();
64
64
  </script>
65
- <script type="module" crossorigin src="/assets/index-C7djiYSc.js"></script>
65
+ <script type="module" crossorigin src="/assets/index-6Mi0L2Ks.js"></script>
66
66
  <link rel="stylesheet" crossorigin href="/assets/index-FeNTTJ0x.css">
67
67
  </head>
68
68
  <body>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccdeck",
3
- "version": "3.29.3",
3
+ "version": "3.29.6",
4
4
  "description": "Live dashboard for Claude Code and Codex — which agent is waiting on you, tool calls, token spend and every Claude Code subagent on one calm canvas. Run it with npx ccdeck.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -53,7 +53,9 @@
53
53
  "3.27.7",
54
54
  "3.28.0",
55
55
  "3.28.1",
56
- "3.29.3"
56
+ "3.29.3",
57
+ "3.29.4",
58
+ "3.29.6"
57
59
  ],
58
60
  "3.29.1": [
59
61
  {
@@ -33,7 +33,7 @@
33
33
  // write defaults over it, destroying the LAN key every paired machine had
34
34
  // pinned. A file that cannot be parsed is now moved aside and said out loud;
35
35
  // only a genuinely ABSENT file starts clean. See loadPrefs.
36
- import { chmod, mkdir, readFile, unlink } from "node:fs/promises";
36
+ import { chmod, mkdir, readFile, stat, unlink } from "node:fs/promises";
37
37
  // The rename, with the Windows retry ladder installer.mjs wrote for exactly
38
38
  // this call. See the note over the write below (#786). `stripBom` and
39
39
  // `createTemp` come from the same module for the reason its export block gives:
@@ -273,7 +273,7 @@ export const quarantinePath = (home = deckDataDir(), at = Date.now()) =>
273
273
  * not the user's. Shaped like installer.mjs's SETTINGS_UNREADABLE, which is
274
274
  * the same policy on the other file this deck rewrites: a file we cannot
275
275
  * reproduce is never treated as an empty one. */
276
- function unreadablePrefs(path, why) {
276
+ function unreadablePrefs(path, why, blocked) {
277
277
  const err = new Error(
278
278
  `${path} could not be read (${why}). Refusing to overwrite it — this deck's ` +
279
279
  `LAN key and its pairings are in there and cannot be re-derived. Fix the ` +
@@ -282,6 +282,7 @@ function unreadablePrefs(path, why) {
282
282
  err.code = "PREFS_UNREADABLE";
283
283
  err.prefsPath = path;
284
284
  err.why = why;
285
+ err.blocked = blocked;
285
286
  return err;
286
287
  }
287
288
 
@@ -303,8 +304,112 @@ export function prefsWriteRefusal(err) {
303
304
  return null;
304
305
  }
305
306
 
307
+ /** What blocked a prefs.json that is not JSON and could not be moved aside —
308
+ * no errno for it, so a name of the deck's own in the same shape. */
309
+ const NOT_JSON = Object.freeze({ code: "BADJSON", owner: "unknown", on: "file" });
310
+
311
+ const OWNERS = new Set(["you", "other", "unknown"]);
312
+ /** An errno as Node spells one, or the deck's BADJSON. Anything else — which
313
+ * nothing here produces — is dropped rather than echoed to the page. */
314
+ const ERRNO = /^E?[A-Z][A-Z0-9]{1,15}$/;
315
+
316
+ /**
317
+ * The same refusal, told precisely enough to act on from a screenshot (#1335).
318
+ *
319
+ * 3.29.3's panel could only guess — "the file may belong to another user" — and
320
+ * the one Mac it was written for turned out not to be the case 3.29.4 repairs,
321
+ * with nothing on the screen to say which case it was. So the page now gets the
322
+ * errno, who owns what blocked the read (`you`, `other`, `unknown`), and whether
323
+ * that is the `file` or its `folder`.
324
+ *
325
+ * Every field is picked from a closed set. Still no path and no uid: the route's
326
+ * body is readable by a DNS-rebound page (see prefsWriteRefusal), and a path
327
+ * names the user. Null when there is nothing precise to add.
328
+ */
329
+ export function prefsRefusalDetail(err) {
330
+ const blocked = err?.code === "PREFS_UNREADABLE" ? err.blocked
331
+ : NOT_WRITABLE.has(err?.code) ? { code: err.code, owner: "unknown", on: "folder" }
332
+ : null;
333
+ if (!blocked || typeof blocked !== "object") return null;
334
+ return {
335
+ code: ERRNO.test(blocked.code ?? "") ? blocked.code : "",
336
+ owner: OWNERS.has(blocked.owner) ? blocked.owner : "unknown",
337
+ on: blocked.on === "folder" ? "folder" : "file",
338
+ };
339
+ }
340
+
341
+ /** The read errors that can mean "not yours" rather than "broken". Only these
342
+ * send a read to setAsideForeign; EISDIR, EIO and EMFILE never do. */
343
+ const DENIED = new Set(["EACCES", "EPERM"]);
344
+
345
+ /** Where a prefs.json another user owns is put. Beside the file, like
346
+ * quarantinePath, and stamped in milliseconds for the same Windows reason —
347
+ * but a name of its own, because nothing is wrong with these bytes. */
348
+ export const foreignPath = (home = deckDataDir(), at = Date.now()) =>
349
+ `${prefsPath(home)}.foreign-${at}`;
350
+
306
351
  /**
307
- * Read prefs.json, and say WHICH of four things happened — because three of
352
+ * Move a prefs.json that ANOTHER USER OWNS out of the way, or say why not.
353
+ *
354
+ * #1335. A `sudo ccdeck` that kept the user's HOME writes a root-owned 0600
355
+ * prefs.json into the user's own folder. From then on every
356
+ * deck that user starts is refused the read, refuses every settings write on
357
+ * top of it — rightly — and so can never keep anything: a new LAN identity on
358
+ * every start, a share tick that says "Could not share that account", and no
359
+ * way out from inside the deck.
360
+ *
361
+ * WHY MOVING IT IS SAFE HERE WHEN IT IS NOT FOR "unreadable". The refusal
362
+ * exists so the deck never loses a key it could still have read. A file owned
363
+ * by somebody else is not one this user's deck will ever read — waiting does
364
+ * not help, and the deck is already running on defaults. A rename reads and
365
+ * changes nothing: owner and mode travel with the file, so the key in it stays
366
+ * private and whole, and `sudo chown` plus a move puts it back.
367
+ *
368
+ * Only when the owner is KNOWN to be somebody else. A file this user owns and
369
+ * still cannot read (a mode bit, a lock, a sandbox) is left exactly where it
370
+ * is, and so is anything on Windows, where there is no uid to compare and
371
+ * EPERM is usually another program holding the file.
372
+ *
373
+ * Answers `{ moved, uid }`, `{ gone }` when the file vanished under it, or,
374
+ * when it stayed, `{ why, owner, on }`: a sentence for the log, possibly empty,
375
+ * and who owns what blocked the read — "you", "other" or "unknown" — and
376
+ * whether that is the "file" or its "folder". The last two are what the panel
377
+ * is told (see prefsRefusalDetail); the sentence, which has the path, is not.
378
+ */
379
+ async function setAsideForeign(path, home, deps) {
380
+ const uid = deps.getuid ? deps.getuid() : process.getuid?.();
381
+ if (uid === undefined) return { why: "", owner: "unknown", on: "file" };
382
+ const look = deps.stat ?? stat;
383
+ let st;
384
+ try {
385
+ st = await look(path);
386
+ } catch (err) {
387
+ if (err?.code === "ENOENT") return { gone: true };
388
+ // The folder itself is out of reach, so nothing in it can be looked at, let
389
+ // alone moved. Name the folder and its owner, which is what a person needs.
390
+ const folder = prefsDir(home);
391
+ const owner = await look(folder).then(s => s.uid, () => undefined);
392
+ if (owner === undefined) return { why: "", owner: "unknown", on: "folder" };
393
+ if (owner === uid) return { why: "", owner: "you", on: "folder" };
394
+ return {
395
+ why: ` The folder ${folder} belongs to another user (uid ${owner}), most likely from a run with sudo; sudo chown -R "$(id -un)" "${folder}" gives it back.`,
396
+ owner: "other",
397
+ on: "folder",
398
+ };
399
+ }
400
+ if (st.uid === uid) return { why: "", owner: "you", on: "file" };
401
+ const to = foreignPath(home);
402
+ try {
403
+ await (deps.rename ?? renameWithRetry)(path, to);
404
+ } catch (err) {
405
+ if (err?.code === "ENOENT") return { gone: true };
406
+ return { why: ` It belongs to another user (uid ${st.uid}) and could not be moved aside either: ${err?.message ?? err}.`, owner: "other", on: "file" };
407
+ }
408
+ return { moved: to, uid: st.uid };
409
+ }
410
+
411
+ /**
412
+ * Read prefs.json, and say WHICH of five things happened — because four of
308
413
  * them hand back the same object and only one of them means it.
309
414
  *
310
415
  * WHY THE SOURCE IS PART OF THE ANSWER. The old read collapsed "no file yet"
@@ -322,9 +427,14 @@ export function prefsWriteRefusal(err) {
322
427
  * defaults, silently, which is what a first start is.
323
428
  * "corrupt" bytes that are not JSON. Moved aside to `quarantined` BEFORE
324
429
  * this returns, so nothing can merge over them.
430
+ * "foreign" a file ANOTHER USER owns and this one may not read — what a
431
+ * `sudo` run leaves (#1335). Moved aside to `quarantined`, still
432
+ * theirs and unread, BEFORE this returns. See setAsideForeign.
325
433
  * "unreadable" the read itself failed for some reason other than absence — a
326
- * permission, a directory in the way. The file is still there
327
- * and still unread, which is exactly when a write must not land.
434
+ * permission on a file this user owns, a directory in the way,
435
+ * a folder another user owns. The file is still there and still
436
+ * unread, which is exactly when a write must not land. `blocked`
437
+ * says what stopped it; see prefsRefusalDetail.
328
438
  *
329
439
  * A BYTE-ORDER MARK IS NOT DAMAGE. Notepad and `Set-Content` write one, and
330
440
  * `JSON.parse` throws on it — so stripping it here is what keeps a perfectly
@@ -342,8 +452,17 @@ export async function loadPrefs(home = deckDataDir(), deps = {}) {
342
452
  raw = await read(path, "utf8");
343
453
  } catch (err) {
344
454
  if (err?.code === "ENOENT") return { prefs: defaults(), source: "missing", quarantined: "" };
345
- warn(`${PRODUCT}: could not read ${path}: ${err?.message ?? err}. Leaving it alone — settings will not be saved until it can be read.`);
346
- return { prefs: defaults(), source: "unreadable", quarantined: "" };
455
+ const aside = DENIED.has(err?.code) ? await setAsideForeign(path, home, deps) : {};
456
+ // Somebody else moved it between the read and the look — a second deck on
457
+ // this machine doing this same thing. What is at `path` now is nothing.
458
+ if (aside.gone) return { prefs: defaults(), source: "missing", quarantined: "" };
459
+ if (aside.moved) {
460
+ warn(`${PRODUCT}: ${path} belongs to another user (uid ${aside.uid}) — most likely left by a run with sudo — so this deck could not read it. It has been kept as ${aside.moved}, unread and still theirs, and this deck is starting with fresh settings: paired decks will need to accept it again. To have the old key back instead, stop the deck and run: sudo chown "$(id -un)" "${aside.moved}" && mv "${aside.moved}" "${path}"`);
461
+ return { prefs: defaults(), source: "foreign", quarantined: aside.moved };
462
+ }
463
+ warn(`${PRODUCT}: could not read ${path}: ${err?.message ?? err}.${aside.why ?? ""} Leaving it alone — settings will not be saved until it can be read.`);
464
+ const blocked = { code: err?.code ?? "", owner: aside.owner ?? "unknown", on: aside.on ?? "file" };
465
+ return { prefs: defaults(), source: "unreadable", quarantined: "", blocked };
347
466
  }
348
467
 
349
468
  try {
@@ -507,10 +626,10 @@ async function save(mutate, home, deps) {
507
626
  // stick. POSIX rename(2) has no such rule, which is why this shipped green.
508
627
  const mv = deps.rename ?? renameWithRetry;
509
628
  const target = prefsPath(home);
510
- const { prefs: prev, source, quarantined } = await loadPrefs(home, deps);
511
- if (source === "unreadable") throw unreadablePrefs(target, "the read failed");
629
+ const { prefs: prev, source, quarantined, blocked } = await loadPrefs(home, deps);
630
+ if (source === "unreadable") throw unreadablePrefs(target, "the read failed", blocked);
512
631
  if (source === "corrupt" && !quarantined) {
513
- throw unreadablePrefs(target, "it is not JSON and could not be moved aside");
632
+ throw unreadablePrefs(target, "it is not JSON and could not be moved aside", NOT_JSON);
514
633
  }
515
634
  // INSIDE THE JOB, after the read above and after the two guards that
516
635
  // decide whether this file may be written at all — see `updatePrefs`.
@@ -37,7 +37,7 @@ import { createOutputWatch } from "./output-watch.mjs";
37
37
  import { RECAP_MARK, foldRecapLine } from "./session-recap.mjs";
38
38
  import { AWAY_BOOT_GRACE_MS, AWAY_RECHECK_MS, AWAY_TICK_MS, awayGate, awayUpdateStep } from "./auto-update.mjs";
39
39
  import { createPresence } from "./presence.mjs";
40
- import { DEFAULTS as PREF_DEFAULTS, cleanAlias, isAliasKey, lanEnabled, notificationsOn, notificationsVetoed, prefsWriteRefusal, publicPrefs, readPrefs, updatePrefs, withAlias, withManualEntry, withShared, writePrefs } from "./deck-prefs.mjs";
40
+ import { DEFAULTS as PREF_DEFAULTS, cleanAlias, isAliasKey, lanEnabled, notificationsOn, notificationsVetoed, prefsRefusalDetail, prefsWriteRefusal, publicPrefs, readPrefs, updatePrefs, withAlias, withManualEntry, withShared, writePrefs } from "./deck-prefs.mjs";
41
41
  import { createEngine, defaultName } from "./lan-engine.mjs";
42
42
  import { createTailnet, IDLE_MS as TAILNET_IDLE_MS } from "./tailscale.mjs";
43
43
  import { portHolder } from "./port-holder.mjs";
@@ -3830,7 +3830,10 @@ async function handlePrefsWrite(req, res) {
3830
3830
  const reason = prefsWriteRefusal(err);
3831
3831
  if (!reason) throw err;
3832
3832
  console.error(`${PRODUCT}: settings were not saved:`, err?.message ?? err);
3833
- return send(res, 500, { ok: false, reason });
3833
+ // What blocked it, from closed sets and without the path, so a screenshot
3834
+ // of the panel is enough to tell the cases apart (#1335).
3835
+ const detail = prefsRefusalDetail(err);
3836
+ return send(res, 500, detail ? { ok: false, reason, detail } : { ok: false, reason });
3834
3837
  }
3835
3838
  // The engine reads its settings from here rather than holding its own copy,
3836
3839
  // so turning the switch off in the panel really does stop the sockets rather