ccdeck 3.29.4 → 3.29.7

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-GYpcFZ76.js"></script>
65
+ <script type="module" crossorigin src="/assets/index-BVujS2uz.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.4",
3
+ "version": "3.29.7",
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": {
@@ -54,7 +54,9 @@
54
54
  "3.28.0",
55
55
  "3.28.1",
56
56
  "3.29.3",
57
- "3.29.4"
57
+ "3.29.4",
58
+ "3.29.6",
59
+ "3.29.7"
58
60
  ],
59
61
  "3.29.1": [
60
62
  {
@@ -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,6 +304,40 @@ 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
+
306
341
  /** The read errors that can mean "not yours" rather than "broken". Only these
307
342
  * send a read to setAsideForeign; EISDIR, EIO and EMFILE never do. */
308
343
  const DENIED = new Set(["EACCES", "EPERM"]);
@@ -335,12 +370,15 @@ export const foreignPath = (home = deckDataDir(), at = Date.now()) =>
335
370
  * is, and so is anything on Windows, where there is no uid to compare and
336
371
  * EPERM is usually another program holding the file.
337
372
  *
338
- * Answers `{ moved, owner }`, `{ gone }` when the file vanished under it, or
339
- * `{ why }` — a sentence for the log, possibly empty — when it stayed.
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.
340
378
  */
341
379
  async function setAsideForeign(path, home, deps) {
342
380
  const uid = deps.getuid ? deps.getuid() : process.getuid?.();
343
- if (uid === undefined) return { why: "" };
381
+ if (uid === undefined) return { why: "", owner: "unknown", on: "file" };
344
382
  const look = deps.stat ?? stat;
345
383
  let st;
346
384
  try {
@@ -351,19 +389,23 @@ async function setAsideForeign(path, home, deps) {
351
389
  // alone moved. Name the folder and its owner, which is what a person needs.
352
390
  const folder = prefsDir(home);
353
391
  const owner = await look(folder).then(s => s.uid, () => undefined);
354
- return owner !== undefined && owner !== uid
355
- ? { 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.` }
356
- : { why: "" };
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
+ };
357
399
  }
358
- if (st.uid === uid) return { why: "" };
400
+ if (st.uid === uid) return { why: "", owner: "you", on: "file" };
359
401
  const to = foreignPath(home);
360
402
  try {
361
403
  await (deps.rename ?? renameWithRetry)(path, to);
362
404
  } catch (err) {
363
405
  if (err?.code === "ENOENT") return { gone: true };
364
- return { why: ` It belongs to another user (uid ${st.uid}) and could not be moved aside either: ${err?.message ?? err}.` };
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" };
365
407
  }
366
- return { moved: to, owner: st.uid };
408
+ return { moved: to, uid: st.uid };
367
409
  }
368
410
 
369
411
  /**
@@ -391,7 +433,8 @@ async function setAsideForeign(path, home, deps) {
391
433
  * "unreadable" the read itself failed for some reason other than absence — a
392
434
  * permission on a file this user owns, a directory in the way,
393
435
  * a folder another user owns. The file is still there and still
394
- * unread, which is exactly when a write must not land.
436
+ * unread, which is exactly when a write must not land. `blocked`
437
+ * says what stopped it; see prefsRefusalDetail.
395
438
  *
396
439
  * A BYTE-ORDER MARK IS NOT DAMAGE. Notepad and `Set-Content` write one, and
397
440
  * `JSON.parse` throws on it — so stripping it here is what keeps a perfectly
@@ -414,11 +457,12 @@ export async function loadPrefs(home = deckDataDir(), deps = {}) {
414
457
  // this machine doing this same thing. What is at `path` now is nothing.
415
458
  if (aside.gone) return { prefs: defaults(), source: "missing", quarantined: "" };
416
459
  if (aside.moved) {
417
- warn(`${PRODUCT}: ${path} belongs to another user (uid ${aside.owner}) — 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}"`);
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}"`);
418
461
  return { prefs: defaults(), source: "foreign", quarantined: aside.moved };
419
462
  }
420
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.`);
421
- return { prefs: defaults(), source: "unreadable", quarantined: "" };
464
+ const blocked = { code: err?.code ?? "", owner: aside.owner ?? "unknown", on: aside.on ?? "file" };
465
+ return { prefs: defaults(), source: "unreadable", quarantined: "", blocked };
422
466
  }
423
467
 
424
468
  try {
@@ -582,10 +626,10 @@ async function save(mutate, home, deps) {
582
626
  // stick. POSIX rename(2) has no such rule, which is why this shipped green.
583
627
  const mv = deps.rename ?? renameWithRetry;
584
628
  const target = prefsPath(home);
585
- const { prefs: prev, source, quarantined } = await loadPrefs(home, deps);
586
- 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);
587
631
  if (source === "corrupt" && !quarantined) {
588
- 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);
589
633
  }
590
634
  // INSIDE THE JOB, after the read above and after the two guards that
591
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