ccdeck 3.29.3 → 3.29.4

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-GYpcFZ76.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.4",
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,8 @@
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"
57
58
  ],
58
59
  "3.29.1": [
59
60
  {
@@ -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:
@@ -303,8 +303,71 @@ export function prefsWriteRefusal(err) {
303
303
  return null;
304
304
  }
305
305
 
306
+ /** The read errors that can mean "not yours" rather than "broken". Only these
307
+ * send a read to setAsideForeign; EISDIR, EIO and EMFILE never do. */
308
+ const DENIED = new Set(["EACCES", "EPERM"]);
309
+
310
+ /** Where a prefs.json another user owns is put. Beside the file, like
311
+ * quarantinePath, and stamped in milliseconds for the same Windows reason —
312
+ * but a name of its own, because nothing is wrong with these bytes. */
313
+ export const foreignPath = (home = deckDataDir(), at = Date.now()) =>
314
+ `${prefsPath(home)}.foreign-${at}`;
315
+
306
316
  /**
307
- * Read prefs.json, and say WHICH of four things happened — because three of
317
+ * Move a prefs.json that ANOTHER USER OWNS out of the way, or say why not.
318
+ *
319
+ * #1335. A `sudo ccdeck` that kept the user's HOME writes a root-owned 0600
320
+ * prefs.json into the user's own folder. From then on every
321
+ * deck that user starts is refused the read, refuses every settings write on
322
+ * top of it — rightly — and so can never keep anything: a new LAN identity on
323
+ * every start, a share tick that says "Could not share that account", and no
324
+ * way out from inside the deck.
325
+ *
326
+ * WHY MOVING IT IS SAFE HERE WHEN IT IS NOT FOR "unreadable". The refusal
327
+ * exists so the deck never loses a key it could still have read. A file owned
328
+ * by somebody else is not one this user's deck will ever read — waiting does
329
+ * not help, and the deck is already running on defaults. A rename reads and
330
+ * changes nothing: owner and mode travel with the file, so the key in it stays
331
+ * private and whole, and `sudo chown` plus a move puts it back.
332
+ *
333
+ * Only when the owner is KNOWN to be somebody else. A file this user owns and
334
+ * still cannot read (a mode bit, a lock, a sandbox) is left exactly where it
335
+ * is, and so is anything on Windows, where there is no uid to compare and
336
+ * EPERM is usually another program holding the file.
337
+ *
338
+ * Answers `{ moved, owner }`, `{ gone }` when the file vanished under it, or
339
+ * `{ why }` — a sentence for the log, possibly empty — when it stayed.
340
+ */
341
+ async function setAsideForeign(path, home, deps) {
342
+ const uid = deps.getuid ? deps.getuid() : process.getuid?.();
343
+ if (uid === undefined) return { why: "" };
344
+ const look = deps.stat ?? stat;
345
+ let st;
346
+ try {
347
+ st = await look(path);
348
+ } catch (err) {
349
+ if (err?.code === "ENOENT") return { gone: true };
350
+ // The folder itself is out of reach, so nothing in it can be looked at, let
351
+ // alone moved. Name the folder and its owner, which is what a person needs.
352
+ const folder = prefsDir(home);
353
+ 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: "" };
357
+ }
358
+ if (st.uid === uid) return { why: "" };
359
+ const to = foreignPath(home);
360
+ try {
361
+ await (deps.rename ?? renameWithRetry)(path, to);
362
+ } catch (err) {
363
+ 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}.` };
365
+ }
366
+ return { moved: to, owner: st.uid };
367
+ }
368
+
369
+ /**
370
+ * Read prefs.json, and say WHICH of five things happened — because four of
308
371
  * them hand back the same object and only one of them means it.
309
372
  *
310
373
  * WHY THE SOURCE IS PART OF THE ANSWER. The old read collapsed "no file yet"
@@ -322,9 +385,13 @@ export function prefsWriteRefusal(err) {
322
385
  * defaults, silently, which is what a first start is.
323
386
  * "corrupt" bytes that are not JSON. Moved aside to `quarantined` BEFORE
324
387
  * this returns, so nothing can merge over them.
388
+ * "foreign" a file ANOTHER USER owns and this one may not read — what a
389
+ * `sudo` run leaves (#1335). Moved aside to `quarantined`, still
390
+ * theirs and unread, BEFORE this returns. See setAsideForeign.
325
391
  * "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.
392
+ * permission on a file this user owns, a directory in the way,
393
+ * a folder another user owns. The file is still there and still
394
+ * unread, which is exactly when a write must not land.
328
395
  *
329
396
  * A BYTE-ORDER MARK IS NOT DAMAGE. Notepad and `Set-Content` write one, and
330
397
  * `JSON.parse` throws on it — so stripping it here is what keeps a perfectly
@@ -342,7 +409,15 @@ export async function loadPrefs(home = deckDataDir(), deps = {}) {
342
409
  raw = await read(path, "utf8");
343
410
  } catch (err) {
344
411
  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.`);
412
+ const aside = DENIED.has(err?.code) ? await setAsideForeign(path, home, deps) : {};
413
+ // Somebody else moved it between the read and the look — a second deck on
414
+ // this machine doing this same thing. What is at `path` now is nothing.
415
+ if (aside.gone) return { prefs: defaults(), source: "missing", quarantined: "" };
416
+ 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}"`);
418
+ return { prefs: defaults(), source: "foreign", quarantined: aside.moved };
419
+ }
420
+ warn(`${PRODUCT}: could not read ${path}: ${err?.message ?? err}.${aside.why ?? ""} Leaving it alone — settings will not be saved until it can be read.`);
346
421
  return { prefs: defaults(), source: "unreadable", quarantined: "" };
347
422
  }
348
423