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.
- package/dist/web/assets/{BrowserWatchModal-RXK8WXaU.js → BrowserWatchModal-UR0ISjic.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-Dk5zag6g.js → UsageHistoryModal-B8dnL7pR.js} +1 -1
- package/dist/web/assets/{index-C7djiYSc.js → index-6Mi0L2Ks.js} +7 -7
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/release-notes.json +3 -1
- package/src/server/deck-prefs.mjs +129 -10
- package/src/server/index.mjs +5 -2
package/dist/web/index.html
CHANGED
|
@@ -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-
|
|
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
|
+
"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": {
|
package/release-notes.json
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
327
|
-
*
|
|
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
|
-
|
|
346
|
-
|
|
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`.
|
package/src/server/index.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|