@promptctl/cc-candybar 1.30.0 → 1.31.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/index.mjs +80 -80
- package/package.json +5 -5
- package/schema/cc-candybar.schema.json +24 -0
- package/src/click/wire.ts +9 -0
- package/src/config/action.ts +40 -2
- package/src/config/loader/actions.ts +45 -0
- package/src/config/loader/cross-ref.ts +16 -7
- package/src/daemon/config-overrides-store.ts +308 -17
- package/src/daemon/verbs/index.ts +40 -2
- package/src/render/action.ts +27 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@promptctl/cc-candybar",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.31.0",
|
|
4
4
|
"description": "Statusline renderer for Claude Code — a JSON5-configurable DSL with daemon-cached data sources, byte-clean palette-aware composition, and OSC8 click verbs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.mjs",
|
|
@@ -91,9 +91,9 @@
|
|
|
91
91
|
"mobx": "^6.15.0"
|
|
92
92
|
},
|
|
93
93
|
"optionalDependencies": {
|
|
94
|
-
"@promptctl/cc-candybar-darwin-arm64": "1.
|
|
95
|
-
"@promptctl/cc-candybar-darwin-x64": "1.
|
|
96
|
-
"@promptctl/cc-candybar-linux-x64": "1.
|
|
97
|
-
"@promptctl/cc-candybar-linux-arm64": "1.
|
|
94
|
+
"@promptctl/cc-candybar-darwin-arm64": "1.31.0",
|
|
95
|
+
"@promptctl/cc-candybar-darwin-x64": "1.31.0",
|
|
96
|
+
"@promptctl/cc-candybar-linux-x64": "1.31.0",
|
|
97
|
+
"@promptctl/cc-candybar-linux-arm64": "1.31.0"
|
|
98
98
|
}
|
|
99
99
|
}
|
|
@@ -1399,6 +1399,30 @@
|
|
|
1399
1399
|
"reset"
|
|
1400
1400
|
],
|
|
1401
1401
|
"additionalProperties": false
|
|
1402
|
+
},
|
|
1403
|
+
{
|
|
1404
|
+
"type": "object",
|
|
1405
|
+
"properties": {
|
|
1406
|
+
"undo": {
|
|
1407
|
+
"const": true
|
|
1408
|
+
}
|
|
1409
|
+
},
|
|
1410
|
+
"required": [
|
|
1411
|
+
"undo"
|
|
1412
|
+
],
|
|
1413
|
+
"additionalProperties": false
|
|
1414
|
+
},
|
|
1415
|
+
{
|
|
1416
|
+
"type": "object",
|
|
1417
|
+
"properties": {
|
|
1418
|
+
"redo": {
|
|
1419
|
+
"const": true
|
|
1420
|
+
}
|
|
1421
|
+
},
|
|
1422
|
+
"required": [
|
|
1423
|
+
"redo"
|
|
1424
|
+
],
|
|
1425
|
+
"additionalProperties": false
|
|
1402
1426
|
}
|
|
1403
1427
|
]
|
|
1404
1428
|
}
|
package/src/click/wire.ts
CHANGED
|
@@ -77,6 +77,15 @@ export const VERB_RESET_CONFIG = "reset-config";
|
|
|
77
77
|
// differs, which is exactly why this is its own verb rather than another
|
|
78
78
|
// VERB_SET_CONFIG value.
|
|
79
79
|
export const VERB_APPLY_LAYOUT_OP = "apply-layout-op";
|
|
80
|
+
// [LAW:one-source-of-truth] brandon-layout-edit-2gc.2's global history step
|
|
81
|
+
// over the config-overrides layer — the fine-grained sibling of
|
|
82
|
+
// VERB_RESET_CONFIG's coarse "clear one key". Args: `[sessionId]` — there is
|
|
83
|
+
// no key: the history is ONE stack over every persist/reset write ever made
|
|
84
|
+
// to the overrides file (config-overrides-store.ts), not a per-key log. An
|
|
85
|
+
// empty stack is a loud BAD_REQUEST surfaced through click.error like any
|
|
86
|
+
// other verb failure, never a silent no-op.
|
|
87
|
+
export const VERB_UNDO = "undo";
|
|
88
|
+
export const VERB_REDO = "redo";
|
|
80
89
|
|
|
81
90
|
// [LAW:types-are-the-program] An effect to EMIT: a verb plus its raw (unencoded)
|
|
82
91
|
// positional args. The wire owns all encoding — callers never percent-encode.
|
package/src/config/action.ts
CHANGED
|
@@ -43,7 +43,24 @@ export type { OptionDomain } from "./option-domain.js";
|
|
|
43
43
|
// overrides layer (never the hand-authored config file). `reset` clears one
|
|
44
44
|
// persisted override — the gated undo `persist` needs, since a machine-owned
|
|
45
45
|
// write with no way back would be a one-way ratchet.
|
|
46
|
-
|
|
46
|
+
//
|
|
47
|
+
// [LAW:one-source-of-truth] `undo`/`redo` (brandon-layout-edit-2gc.2) are
|
|
48
|
+
// `reset`'s FINE-GRAINED siblings: `reset` clears one named key outright
|
|
49
|
+
// (the coarse "forget this override" case); `undo`/`redo` step ONE GLOBAL
|
|
50
|
+
// history of every `persist`/`reset` write ever made to the overrides layer
|
|
51
|
+
// — every key, not just structural layout edits — back and forth. Neither
|
|
52
|
+
// carries a key: the history is a single stack over the whole overrides
|
|
53
|
+
// file (config-overrides-store.ts owns it), so the action is a bare marker,
|
|
54
|
+
// like `int: true` is for a set-int cursor.
|
|
55
|
+
export const ACTION_KEYS = [
|
|
56
|
+
"set",
|
|
57
|
+
"persist",
|
|
58
|
+
"copy",
|
|
59
|
+
"open",
|
|
60
|
+
"reset",
|
|
61
|
+
"undo",
|
|
62
|
+
"redo",
|
|
63
|
+
] as const;
|
|
47
64
|
export type ActionKey = (typeof ACTION_KEYS)[number];
|
|
48
65
|
|
|
49
66
|
// [LAW:types-are-the-program] An ActionDecl is the click effect a named action
|
|
@@ -75,6 +92,14 @@ export type ActionKey = (typeof ACTION_KEYS)[number];
|
|
|
75
92
|
// -> allow-list {members}
|
|
76
93
|
// copy — copy templated text to the clipboard -> no gate
|
|
77
94
|
// open — open a templated target in the editor -> no gate
|
|
95
|
+
// undo — step the config-overrides layer's GLOBAL history one
|
|
96
|
+
// entry back (any persist/reset write, not just a
|
|
97
|
+
// layout op) -> no gate, no key: there is nothing a
|
|
98
|
+
// template could smuggle, since the value restored is
|
|
99
|
+
// whatever the daemon's own history recorded, never
|
|
100
|
+
// wire input
|
|
101
|
+
// redo — the inverse of undo: re-apply the most recently
|
|
102
|
+
// undone entry -> no gate, no key
|
|
78
103
|
// removeSegment — (persist only) remove the named segment from the
|
|
79
104
|
// preset-root the `persist` key addresses
|
|
80
105
|
// (`presets.<name>.rootOps`) -> allow-list {one op
|
|
@@ -133,7 +158,9 @@ export type ActionDecl =
|
|
|
133
158
|
}
|
|
134
159
|
| { readonly copy: string }
|
|
135
160
|
| { readonly open: string }
|
|
136
|
-
| { readonly reset: string }
|
|
161
|
+
| { readonly reset: string }
|
|
162
|
+
| { readonly undo: true }
|
|
163
|
+
| { readonly redo: true };
|
|
137
164
|
|
|
138
165
|
// [LAW:dataflow-not-control-flow] Does this action write a SessionState key? A
|
|
139
166
|
// `set` action composes a set-state click URL whose first segment is session.id;
|
|
@@ -155,3 +182,14 @@ export function actionBindsPersist(a: ActionDecl): boolean {
|
|
|
155
182
|
export function actionBindsReset(a: ActionDecl): boolean {
|
|
156
183
|
return "reset" in a;
|
|
157
184
|
}
|
|
185
|
+
|
|
186
|
+
// [LAW:dataflow-not-control-flow] Does this action step the config-overrides
|
|
187
|
+
// history? `undo`/`redo` carry session.id on the wire too — same reason as
|
|
188
|
+
// `reset`: an empty stack is a loud, session-scoped click.error, not a
|
|
189
|
+
// silent no-op (the ticket's own done-gate).
|
|
190
|
+
export function actionBindsUndo(a: ActionDecl): boolean {
|
|
191
|
+
return "undo" in a;
|
|
192
|
+
}
|
|
193
|
+
export function actionBindsRedo(a: ActionDecl): boolean {
|
|
194
|
+
return "redo" in a;
|
|
195
|
+
}
|
|
@@ -145,6 +145,8 @@ const ACTION_ARMS: Record<ActionKey, ArmParse<ActionDecl>> = {
|
|
|
145
145
|
copy: templateArm("copy"),
|
|
146
146
|
open: templateArm("open"),
|
|
147
147
|
reset: resetArm,
|
|
148
|
+
undo: markerArm("undo"),
|
|
149
|
+
redo: markerArm("redo"),
|
|
148
150
|
};
|
|
149
151
|
|
|
150
152
|
// [LAW:one-source-of-truth] A copy/open action emits the closed single-key
|
|
@@ -170,6 +172,8 @@ function actionDeclJson(): JsonNode {
|
|
|
170
172
|
templateArmJson("copy"),
|
|
171
173
|
templateArmJson("open"),
|
|
172
174
|
templateArmJson("reset"),
|
|
175
|
+
markerArmJson("undo"),
|
|
176
|
+
markerArmJson("redo"),
|
|
173
177
|
],
|
|
174
178
|
};
|
|
175
179
|
}
|
|
@@ -228,6 +232,47 @@ function resetArm(
|
|
|
228
232
|
return key === null ? null : { reset: key };
|
|
229
233
|
}
|
|
230
234
|
|
|
235
|
+
// [LAW:one-type-per-behavior] `undo`/`redo` are copy/open/reset's shape one
|
|
236
|
+
// step further reduced: a single required key whose only legal VALUE is the
|
|
237
|
+
// literal `true` (mirrors intMarkerSpec — a marker, not data), because there
|
|
238
|
+
// is no key to name: the history they step is one global stack over the
|
|
239
|
+
// whole overrides layer, not a per-target write. `function`, not a const
|
|
240
|
+
// arrow, so ACTION_ARMS above (built before this declaration in source
|
|
241
|
+
// order) can reference it directly via hoisting.
|
|
242
|
+
function markerArm(key: "undo" | "redo"): ArmParse<ActionDecl> {
|
|
243
|
+
return (ctx, path, raw) => {
|
|
244
|
+
for (const k of Object.keys(raw)) {
|
|
245
|
+
if (k !== key)
|
|
246
|
+
issue(
|
|
247
|
+
ctx,
|
|
248
|
+
`${path}.${k}`,
|
|
249
|
+
`Unknown key "${k}" on a ${key} action. Expected only: ${key}`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
if (raw[key] !== true) {
|
|
253
|
+
issue(
|
|
254
|
+
ctx,
|
|
255
|
+
`${path}.${key}`,
|
|
256
|
+
`${key} must be the literal true (it takes no key — it steps the ONE global history over the whole overrides layer), got ${describeValue(raw[key])}`,
|
|
257
|
+
);
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
return { [key]: true } as unknown as ActionDecl;
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// [LAW:one-source-of-truth] Mirrors templateArmJson's shape one level
|
|
265
|
+
// narrower: the value schema is `const: true`, not `type: string` — a
|
|
266
|
+
// marker action carries no data, on the wire or in the schema.
|
|
267
|
+
function markerArmJson(key: "undo" | "redo"): JsonNode {
|
|
268
|
+
return {
|
|
269
|
+
type: "object",
|
|
270
|
+
properties: { [key]: { const: true } },
|
|
271
|
+
required: [key],
|
|
272
|
+
additionalProperties: false,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
231
276
|
// ─── The `set` value-source sub-union ────────────────────────────────────────
|
|
232
277
|
|
|
233
278
|
// [LAW:single-enforcer] A set-state URL path segment must be a non-empty,
|
|
@@ -15,8 +15,10 @@ import {
|
|
|
15
15
|
} from "../dsl-types.js";
|
|
16
16
|
import {
|
|
17
17
|
actionBindsPersist,
|
|
18
|
+
actionBindsRedo,
|
|
18
19
|
actionBindsReset,
|
|
19
20
|
actionBindsSet,
|
|
21
|
+
actionBindsUndo,
|
|
20
22
|
type ActionDecl,
|
|
21
23
|
} from "../action.js";
|
|
22
24
|
import {
|
|
@@ -442,15 +444,22 @@ function hasStateKind(cfg: DslConfig): boolean {
|
|
|
442
444
|
return false;
|
|
443
445
|
}
|
|
444
446
|
|
|
445
|
-
// [LAW:dataflow-not-control-flow] A config emits a set-state, set-config,
|
|
446
|
-
// reset-config click — and so needs session.id — when any
|
|
447
|
-
// a `set` (literal/option/bounded/cycle), a `persist`
|
|
448
|
-
// twin),
|
|
449
|
-
// the
|
|
450
|
-
//
|
|
447
|
+
// [LAW:dataflow-not-control-flow] A config emits a set-state, set-config,
|
|
448
|
+
// reset-config, undo, OR redo click — and so needs session.id — when any
|
|
449
|
+
// declared action is a `set` (literal/option/bounded/cycle), a `persist`
|
|
450
|
+
// (its config-overrides twin), a `reset` (persist's gated undo), or an
|
|
451
|
+
// `undo`/`redo` (the overrides layer's global history step) — all five
|
|
452
|
+
// carry session.id on the wire for click-error surfacing (an empty history
|
|
453
|
+
// stack is a loud, session-scoped miss, not a silent no-op). copy/open
|
|
454
|
+
// actions write nothing, so they embed no session.id.
|
|
451
455
|
function hasActionSetAction(cfg: DslConfig): boolean {
|
|
452
456
|
return Object.values(cfg.actions).some(
|
|
453
|
-
(a) =>
|
|
457
|
+
(a) =>
|
|
458
|
+
actionBindsSet(a) ||
|
|
459
|
+
actionBindsPersist(a) ||
|
|
460
|
+
actionBindsReset(a) ||
|
|
461
|
+
actionBindsUndo(a) ||
|
|
462
|
+
actionBindsRedo(a),
|
|
454
463
|
);
|
|
455
464
|
}
|
|
456
465
|
|
|
@@ -322,43 +322,94 @@ export function loadOverrides(
|
|
|
322
322
|
};
|
|
323
323
|
}
|
|
324
324
|
|
|
325
|
-
// [LAW:no-silent-failure]
|
|
326
|
-
//
|
|
327
|
-
//
|
|
325
|
+
// [LAW:no-silent-failure] The atomic write/rename dance, generalized over ANY
|
|
326
|
+
// JSON-serializable value — both this module's flat overrides dict and its
|
|
327
|
+
// history stack (below) go through this one primitive rather than each
|
|
328
|
+
// re-implementing mkdir+tmp+chmod+rename. Owner-only mode, matching every
|
|
329
|
+
// other daemon runtime file (session-state.json, pid, lease). `label` names
|
|
330
|
+
// the failure in the log/thrown message (the caller's own vocabulary —
|
|
331
|
+
// "config-overrides"/"config-overrides-history" — not derived from the path,
|
|
332
|
+
// so the wording a test might match on stays stable across either file).
|
|
328
333
|
// Unlike session-state.json's debounced best-effort flush (no synchronous
|
|
329
|
-
// caller waiting on it), a `persist` write is directly caused
|
|
330
|
-
// expects a truthful ack — a swallowed failure here would let
|
|
331
|
-
// handler log
|
|
332
|
-
//
|
|
333
|
-
//
|
|
334
|
-
function
|
|
334
|
+
// caller waiting on it), a `persist`/`undo`/`redo` write is directly caused
|
|
335
|
+
// by a click that expects a truthful ack — a swallowed failure here would let
|
|
336
|
+
// the verb handler log success for a write that didn't land. Logs at "error"
|
|
337
|
+
// for the daemon-log breadcrumb, then RETHROWS so the caller (the click)
|
|
338
|
+
// fails loudly instead of claiming a success that didn't happen.
|
|
339
|
+
function writeJsonAtomic(
|
|
335
340
|
filePath: string,
|
|
336
|
-
|
|
341
|
+
label: string,
|
|
342
|
+
value: unknown,
|
|
337
343
|
logger: DaemonLogger,
|
|
338
344
|
): void {
|
|
339
345
|
try {
|
|
340
346
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
341
347
|
const tmp = `${filePath}.tmp`;
|
|
342
|
-
fs.writeFileSync(tmp, JSON.stringify(
|
|
348
|
+
fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 });
|
|
343
349
|
fs.chmodSync(tmp, 0o600);
|
|
344
350
|
fs.renameSync(tmp, filePath);
|
|
345
351
|
} catch (e) {
|
|
346
|
-
const message =
|
|
352
|
+
const message = `${label} write failed: ${(e as Error).message}`;
|
|
347
353
|
logger("error", message);
|
|
348
354
|
throw new Error(message);
|
|
349
355
|
}
|
|
350
356
|
}
|
|
351
357
|
|
|
358
|
+
function writeOverrides(
|
|
359
|
+
filePath: string,
|
|
360
|
+
overrides: Readonly<Record<string, string | number | boolean>>,
|
|
361
|
+
logger: DaemonLogger,
|
|
362
|
+
): void {
|
|
363
|
+
writeJsonAtomic(filePath, "config-overrides", overrides, logger);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// [LAW:one-source-of-truth] The one place a key's value in the flat dict
|
|
367
|
+
// changes (set-or-delete) — writeConfigOverride/clearConfigOverride/
|
|
368
|
+
// restoreConfigOverrideValue all fold through here, so "what was the value
|
|
369
|
+
// BEFORE this write" (the fact history needs) is captured at the one site
|
|
370
|
+
// that reads-then-writes it, never re-derived. `value: undefined` deletes;
|
|
371
|
+
// any other value sets. Returns the previous value (or undefined if the key
|
|
372
|
+
// was absent) — the caller decides whether that fact matters.
|
|
373
|
+
function mutateOverride(
|
|
374
|
+
filePath: string,
|
|
375
|
+
key: string,
|
|
376
|
+
value: string | number | boolean | undefined,
|
|
377
|
+
logger: DaemonLogger,
|
|
378
|
+
): string | number | boolean | undefined {
|
|
379
|
+
const overrides = loadRawOverrides(filePath, logger);
|
|
380
|
+
const prev = overrides[key];
|
|
381
|
+
if (value === undefined) {
|
|
382
|
+
if (!(key in overrides)) return prev;
|
|
383
|
+
const next = { ...overrides };
|
|
384
|
+
delete next[key];
|
|
385
|
+
writeOverrides(filePath, next, logger);
|
|
386
|
+
} else {
|
|
387
|
+
writeOverrides(filePath, { ...overrides, [key]: value }, logger);
|
|
388
|
+
}
|
|
389
|
+
return prev;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// [LAW:one-source-of-truth] `persist`'s write, TRACKED: mutate the key, then
|
|
393
|
+
// record the transition on the SAME global history undo/redo step
|
|
394
|
+
// (brandon-layout-edit-2gc.2). This is the ONE enforcement point — every
|
|
395
|
+
// current and future caller of writeConfigOverride (setConfig, stepConfig,
|
|
396
|
+
// apply-layout-op's append) gets history for free, with zero edits to those
|
|
397
|
+
// verb handlers, because the recording lives here rather than at each call
|
|
398
|
+
// site. [LAW:locality-or-seam]
|
|
352
399
|
export function writeConfigOverride(
|
|
353
400
|
filePath: string,
|
|
354
401
|
key: string,
|
|
355
402
|
value: string | number | boolean,
|
|
356
403
|
logger: DaemonLogger = quietLogger,
|
|
357
404
|
): void {
|
|
358
|
-
const
|
|
359
|
-
|
|
405
|
+
const prev = mutateOverride(filePath, key, value, logger);
|
|
406
|
+
pushHistoryEntry(filePath, { key, from: prev ?? null, to: value }, logger);
|
|
360
407
|
}
|
|
361
408
|
|
|
409
|
+
// [LAW:one-source-of-truth] `reset`'s write, TRACKED — mirrors
|
|
410
|
+
// writeConfigOverride above. A clear that touches nothing (the key was
|
|
411
|
+
// already absent) records no entry: nothing changed, so there is nothing to
|
|
412
|
+
// undo back to.
|
|
362
413
|
export function clearConfigOverride(
|
|
363
414
|
filePath: string,
|
|
364
415
|
key: string,
|
|
@@ -366,7 +417,247 @@ export function clearConfigOverride(
|
|
|
366
417
|
): void {
|
|
367
418
|
const overrides = loadRawOverrides(filePath, logger);
|
|
368
419
|
if (!(key in overrides)) return;
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
|
|
420
|
+
const prev = mutateOverride(filePath, key, undefined, logger);
|
|
421
|
+
pushHistoryEntry(filePath, { key, from: prev ?? null, to: null }, logger);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// [LAW:one-source-of-truth] The UNTRACKED twin — restores a key to EXACTLY
|
|
425
|
+
// `value` (or clears it, for `null`) without recording a new history entry.
|
|
426
|
+
// The only legitimate callers are popPastEntry/popFutureEntry below: undo and
|
|
427
|
+
// redo already know they're moving an entry between the past/future stacks,
|
|
428
|
+
// so routing their own restoration back through the tracked writers would
|
|
429
|
+
// record the undo/redo AS a new forward edit — burying the entry it just
|
|
430
|
+
// popped and making the OTHER stack unreachable. This is a structurally
|
|
431
|
+
// distinct function, not a boolean flag on the tracked ones
|
|
432
|
+
// [LAW:no-mode-explosion] — its contract ("apply this exact value, no
|
|
433
|
+
// bookkeeping") is different from theirs ("write this value, remember how to
|
|
434
|
+
// undo it"), not a variant of the same one.
|
|
435
|
+
function restoreConfigOverrideValue(
|
|
436
|
+
filePath: string,
|
|
437
|
+
key: string,
|
|
438
|
+
value: string | number | boolean | null,
|
|
439
|
+
logger: DaemonLogger,
|
|
440
|
+
): void {
|
|
441
|
+
mutateOverride(filePath, key, value === null ? undefined : value, logger);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ─── Undo/redo history (brandon-layout-edit-2gc.2) ────────────────────────
|
|
445
|
+
|
|
446
|
+
// [LAW:types-are-the-program] ONE entry shape covers every scope the
|
|
447
|
+
// overrides file holds — a globals field's snapshot overwrite (setConfig), a
|
|
448
|
+
// segment-palette snapshot overwrite (same verb, different key shape), AND a
|
|
449
|
+
// preset-root-ops APPEND (apply-layout-op's read-current-append-write) —
|
|
450
|
+
// because at the STORAGE layer every one of those is indistinguishable from
|
|
451
|
+
// "the value at `key` changed from `from` to `to`". apply-layout-op computes
|
|
452
|
+
// its new array-of-tokens string by reading-then-appending one level up
|
|
453
|
+
// (verbs/index.ts); by the time that string reaches writeConfigOverride, it
|
|
454
|
+
// is just the next value at that key. Undo restoring `from` verbatim is
|
|
455
|
+
// therefore ALSO the correct "pop the last op token" behavior for a rootOps
|
|
456
|
+
// key, with no rootOps-specific code anywhere in this module — the ticket's
|
|
457
|
+
// "one history over the overrides layer, not a layout-specific feature" falls
|
|
458
|
+
// out of the shape, it isn't special-cased into it. `null` is the ABSENT
|
|
459
|
+
// sentinel (a key with no prior/no resulting value): safe because no real
|
|
460
|
+
// override value is ever `null` — see isValidOverrides's kind table.
|
|
461
|
+
export interface HistoryEntry {
|
|
462
|
+
readonly key: string;
|
|
463
|
+
readonly from: string | number | boolean | null;
|
|
464
|
+
readonly to: string | number | boolean | null;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
interface HistoryState {
|
|
468
|
+
readonly past: readonly HistoryEntry[];
|
|
469
|
+
readonly future: readonly HistoryEntry[];
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const EMPTY_HISTORY: HistoryState = { past: [], future: [] };
|
|
473
|
+
|
|
474
|
+
// [LAW:carrying-cost] Resolves the ticket's "depth of the ring" question:
|
|
475
|
+
// bounded so a long-running daemon's history file cannot grow without limit,
|
|
476
|
+
// generous enough that no realistic editing session bumps into it. Oldest
|
|
477
|
+
// entries fall off first (capPush below) — a silent, documented trim, not a
|
|
478
|
+
// failure.
|
|
479
|
+
const MAX_HISTORY_DEPTH = 50;
|
|
480
|
+
|
|
481
|
+
// [LAW:one-source-of-truth] Resolves the ticket's "where it lives relative to
|
|
482
|
+
// the overrides file" question: a SIBLING file in the same directory, derived
|
|
483
|
+
// as a pure function of the overrides path already passed in — no reach to
|
|
484
|
+
// paths.ts/global state, so every existing call site (and every existing
|
|
485
|
+
// test's XDG_STATE_HOME isolation, which already isolates configOverridesPath())
|
|
486
|
+
// isolates this file too, with zero additional test-harness surface. Kept
|
|
487
|
+
// SEPARATE from the overrides file itself (rather than nesting it inside a
|
|
488
|
+
// wrapper shape) so the overrides file's own on-disk shape — asserted by
|
|
489
|
+
// name in existing tests and callers — never changes
|
|
490
|
+
// [LAW:locality-or-seam]: a change to history storage must not ripple into
|
|
491
|
+
// every existing reader of the flat overrides dict.
|
|
492
|
+
function historyPathFor(overridesFilePath: string): string {
|
|
493
|
+
return path.join(
|
|
494
|
+
path.dirname(overridesFilePath),
|
|
495
|
+
"config-overrides-history.json",
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function isValidHistoryValue(
|
|
500
|
+
v: unknown,
|
|
501
|
+
): v is string | number | boolean | null {
|
|
502
|
+
return (
|
|
503
|
+
v === null ||
|
|
504
|
+
typeof v === "string" ||
|
|
505
|
+
typeof v === "number" ||
|
|
506
|
+
typeof v === "boolean"
|
|
507
|
+
);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function isValidHistoryEntry(v: unknown): v is HistoryEntry {
|
|
511
|
+
if (v === null || typeof v !== "object") return false;
|
|
512
|
+
const obj = v as Record<string, unknown>;
|
|
513
|
+
return (
|
|
514
|
+
typeof obj.key === "string" &&
|
|
515
|
+
isValidHistoryValue(obj.from) &&
|
|
516
|
+
isValidHistoryValue(obj.to)
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// [LAW:no-silent-failure] Missing/corrupt/wrong-shape file → the empty
|
|
521
|
+
// history is the DEFINED recovery (mirrors isValidOverrides/loadRawOverrides'
|
|
522
|
+
// identical "first-ever boot" treatment for the sibling file) — a single
|
|
523
|
+
// malformed entry drops the WHOLE history, never a guess at which entries to
|
|
524
|
+
// salvage.
|
|
525
|
+
function isValidHistoryState(v: unknown): v is HistoryState {
|
|
526
|
+
if (v === null || typeof v !== "object" || Array.isArray(v)) return false;
|
|
527
|
+
const obj = v as Record<string, unknown>;
|
|
528
|
+
return (
|
|
529
|
+
Array.isArray(obj.past) &&
|
|
530
|
+
obj.past.every(isValidHistoryEntry) &&
|
|
531
|
+
Array.isArray(obj.future) &&
|
|
532
|
+
obj.future.every(isValidHistoryEntry)
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function loadHistoryState(
|
|
537
|
+
overridesFilePath: string,
|
|
538
|
+
logger: DaemonLogger,
|
|
539
|
+
): HistoryState {
|
|
540
|
+
const filePath = historyPathFor(overridesFilePath);
|
|
541
|
+
let raw: string;
|
|
542
|
+
try {
|
|
543
|
+
raw = fs.readFileSync(filePath, "utf8");
|
|
544
|
+
} catch (e) {
|
|
545
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
546
|
+
if (code !== "ENOENT") {
|
|
547
|
+
logger(
|
|
548
|
+
"warn",
|
|
549
|
+
`config-overrides-history read failed (${code}); starting empty`,
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
return EMPTY_HISTORY;
|
|
553
|
+
}
|
|
554
|
+
try {
|
|
555
|
+
const parsed: unknown = JSON.parse(raw);
|
|
556
|
+
if (isValidHistoryState(parsed)) return parsed;
|
|
557
|
+
logger(
|
|
558
|
+
"warn",
|
|
559
|
+
`config-overrides-history load: unexpected shape, starting empty`,
|
|
560
|
+
);
|
|
561
|
+
return EMPTY_HISTORY;
|
|
562
|
+
} catch {
|
|
563
|
+
logger(
|
|
564
|
+
"warn",
|
|
565
|
+
`config-overrides-history load: corrupt JSON, starting empty`,
|
|
566
|
+
);
|
|
567
|
+
return EMPTY_HISTORY;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function writeHistoryState(
|
|
572
|
+
overridesFilePath: string,
|
|
573
|
+
state: HistoryState,
|
|
574
|
+
logger: DaemonLogger,
|
|
575
|
+
): void {
|
|
576
|
+
writeJsonAtomic(
|
|
577
|
+
historyPathFor(overridesFilePath),
|
|
578
|
+
"config-overrides-history",
|
|
579
|
+
state,
|
|
580
|
+
logger,
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// [LAW:no-mode-explosion] Bounded push, oldest-drops-first, shared by both
|
|
585
|
+
// stacks (past grows on a fresh edit or a redo; future grows on an undo) —
|
|
586
|
+
// one shape, not two near-duplicate arms.
|
|
587
|
+
function capPush<T>(arr: readonly T[], entry: T, max: number): readonly T[] {
|
|
588
|
+
const next = [...arr, entry];
|
|
589
|
+
return next.length > max ? next.slice(next.length - max) : next;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
// [LAW:one-source-of-truth] The ONLY caller is writeConfigOverride/
|
|
593
|
+
// clearConfigOverride above — every tracked write lands here, so recording
|
|
594
|
+
// cannot drift from mutation. A fresh edit TRUNCATES `future`: the classic
|
|
595
|
+
// undo/redo branch — diverging from history by doing something NEW abandons
|
|
596
|
+
// whatever was undone, rather than silently keeping it reachable from a
|
|
597
|
+
// history state the new edit has already invalidated.
|
|
598
|
+
function pushHistoryEntry(
|
|
599
|
+
overridesFilePath: string,
|
|
600
|
+
entry: HistoryEntry,
|
|
601
|
+
logger: DaemonLogger,
|
|
602
|
+
): void {
|
|
603
|
+
const state = loadHistoryState(overridesFilePath, logger);
|
|
604
|
+
writeHistoryState(
|
|
605
|
+
overridesFilePath,
|
|
606
|
+
{ past: capPush(state.past, entry, MAX_HISTORY_DEPTH), future: [] },
|
|
607
|
+
logger,
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// [LAW:one-source-of-truth] The daemon-GLOBAL history is ONE stack, not
|
|
612
|
+
// per-session: config-overrides.json already has exactly one writer (the
|
|
613
|
+
// daemon) and no session-scoping (candybar-config-engine-71o's own binding
|
|
614
|
+
// guardrail — a `persist` write is daemon-global by design), so undo/redo
|
|
615
|
+
// stepping that SAME single-writer file inherits the same scope rather than
|
|
616
|
+
// inventing a session axis the storage layer doesn't otherwise have. Two
|
|
617
|
+
// sessions clicking undo do see each other's edits — a real, DELIBERATE
|
|
618
|
+
// consequence of there being one bar default, not a bug: the alternative
|
|
619
|
+
// (per-session history over daemon-global state) would let one session's
|
|
620
|
+
// "undo" silently fail to undo what another session's click actually did.
|
|
621
|
+
//
|
|
622
|
+
// [LAW:no-silent-failure] Returns `null` at the bottom of the stack — the
|
|
623
|
+
// verb handler (verbs/index.ts) turns that into a loud BadVerbArgs surfaced
|
|
624
|
+
// through click.error, never a silent no-op.
|
|
625
|
+
export function undoLastOverride(
|
|
626
|
+
overridesFilePath: string,
|
|
627
|
+
logger: DaemonLogger = quietLogger,
|
|
628
|
+
): HistoryEntry | null {
|
|
629
|
+
const state = loadHistoryState(overridesFilePath, logger);
|
|
630
|
+
const entry = state.past[state.past.length - 1];
|
|
631
|
+
if (entry === undefined) return null;
|
|
632
|
+
restoreConfigOverrideValue(overridesFilePath, entry.key, entry.from, logger);
|
|
633
|
+
writeHistoryState(
|
|
634
|
+
overridesFilePath,
|
|
635
|
+
{
|
|
636
|
+
past: state.past.slice(0, -1),
|
|
637
|
+
future: capPush(state.future, entry, MAX_HISTORY_DEPTH),
|
|
638
|
+
},
|
|
639
|
+
logger,
|
|
640
|
+
);
|
|
641
|
+
return entry;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// [LAW:no-silent-failure] Redo's mirror of undo above — `null` at the top of
|
|
645
|
+
// the stack, same loud surfacing contract.
|
|
646
|
+
export function redoLastOverride(
|
|
647
|
+
overridesFilePath: string,
|
|
648
|
+
logger: DaemonLogger = quietLogger,
|
|
649
|
+
): HistoryEntry | null {
|
|
650
|
+
const state = loadHistoryState(overridesFilePath, logger);
|
|
651
|
+
const entry = state.future[state.future.length - 1];
|
|
652
|
+
if (entry === undefined) return null;
|
|
653
|
+
restoreConfigOverrideValue(overridesFilePath, entry.key, entry.to, logger);
|
|
654
|
+
writeHistoryState(
|
|
655
|
+
overridesFilePath,
|
|
656
|
+
{
|
|
657
|
+
past: capPush(state.past, entry, MAX_HISTORY_DEPTH),
|
|
658
|
+
future: state.future.slice(0, -1),
|
|
659
|
+
},
|
|
660
|
+
logger,
|
|
661
|
+
);
|
|
662
|
+
return entry;
|
|
372
663
|
}
|