@promptctl/cc-candybar 1.42.1 → 1.43.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 +72 -71
- package/package.json +5 -6
- package/src/check.ts +0 -478
- package/src/cli-flags.ts +0 -8
- package/src/click/wire.ts +0 -158
- package/src/config/action.ts +0 -329
- package/src/config/cli.ts +0 -71
- package/src/config/default-dsl-config.ts +0 -1645
- package/src/config/disclosure.ts +0 -170
- package/src/config/dsl-loader.ts +0 -339
- package/src/config/dsl-types.ts +0 -581
- package/src/config/edit-chrome.ts +0 -559
- package/src/config/help.ts +0 -151
- package/src/config/ident.ts +0 -22
- package/src/config/layout-ops.ts +0 -177
- package/src/config/loader/actions.ts +0 -972
- package/src/config/loader/cache.ts +0 -206
- package/src/config/loader/cross-ref.ts +0 -714
- package/src/config/loader/cycles.ts +0 -148
- package/src/config/loader/diagnostics.ts +0 -99
- package/src/config/loader/discovery.ts +0 -182
- package/src/config/loader/edit-mode.ts +0 -137
- package/src/config/loader/emit-schema.ts +0 -68
- package/src/config/loader/globals.ts +0 -269
- package/src/config/loader/helpers.ts +0 -48
- package/src/config/loader/layout.ts +0 -693
- package/src/config/loader/looks.ts +0 -96
- package/src/config/loader/menu-synth.ts +0 -435
- package/src/config/loader/merge.ts +0 -115
- package/src/config/loader/persist-target.ts +0 -67
- package/src/config/loader/presets.ts +0 -119
- package/src/config/loader/refs.ts +0 -100
- package/src/config/loader/reserved-namespace.ts +0 -38
- package/src/config/loader/segments.ts +0 -120
- package/src/config/loader/validate-core.ts +0 -737
- package/src/config/loader/variables.ts +0 -260
- package/src/config/menu-keys.ts +0 -139
- package/src/config/option-domain.ts +0 -164
- package/src/config/presets.ts +0 -326
- package/src/config/settings-menu.ts +0 -775
- package/src/daemon/acquire.ts +0 -684
- package/src/daemon/cache/git.ts +0 -649
- package/src/daemon/cache/render.ts +0 -623
- package/src/daemon/cache/session-usage-store.ts +0 -720
- package/src/daemon/cache/watchers.ts +0 -249
- package/src/daemon/client-debug.ts +0 -120
- package/src/daemon/client-stats.ts +0 -130
- package/src/daemon/client-transport.ts +0 -273
- package/src/daemon/client.ts +0 -78
- package/src/daemon/config-overrides-store.ts +0 -663
- package/src/daemon/debug-types.ts +0 -91
- package/src/daemon/debug.ts +0 -264
- package/src/daemon/fork-bomb-breaker.ts +0 -351
- package/src/daemon/limits.ts +0 -211
- package/src/daemon/log.ts +0 -81
- package/src/daemon/parent-watchdog.ts +0 -87
- package/src/daemon/paths.ts +0 -211
- package/src/daemon/process-fingerprint.ts +0 -146
- package/src/daemon/protocol.ts +0 -292
- package/src/daemon/render-payload.ts +0 -1256
- package/src/daemon/server.ts +0 -1330
- package/src/daemon/session-state-file.ts +0 -108
- package/src/daemon/session-state.ts +0 -237
- package/src/daemon/socket-lease.ts +0 -209
- package/src/daemon/socket-ownership.ts +0 -209
- package/src/daemon/stats.ts +0 -235
- package/src/daemon/verbs/config-validators.ts +0 -250
- package/src/daemon/verbs/index.ts +0 -706
- package/src/daemon/verbs/state-validators.ts +0 -249
- package/src/daemon/verbs/validator-registry.ts +0 -457
- package/src/demo/dsl.ts +0 -143
- package/src/demo/mock-data.ts +0 -67
- package/src/demo/statusline.json5 +0 -94
- package/src/dsl/node-registry.ts +0 -374
- package/src/dsl/render.ts +0 -803
- package/src/help-text.ts +0 -90
- package/src/index.ts +0 -210
- package/src/install/currency.ts +0 -197
- package/src/install/index.ts +0 -557
- package/src/proc/launch.ts +0 -459
- package/src/proc/stats-handle.ts +0 -13
- package/src/render/action.ts +0 -883
- package/src/render/active-segment.ts +0 -78
- package/src/render/diagnostic-style.ts +0 -23
- package/src/render/diagnostic-text.ts +0 -77
- package/src/render/error-glyph.ts +0 -53
- package/src/render/menu.ts +0 -257
- package/src/render/outcome-plan.ts +0 -45
- package/src/render/picker.ts +0 -372
- package/src/render/segment-color.ts +0 -74
- package/src/render/split-lines.ts +0 -51
- package/src/render/strip.ts +0 -228
- package/src/segments/cache.ts +0 -131
- package/src/segments/context.ts +0 -190
- package/src/segments/git.ts +0 -1084
- package/src/segments/metrics.ts +0 -187
- package/src/segments/pricing.ts +0 -452
- package/src/segments/session.ts +0 -23
- package/src/segments/tmux.ts +0 -74
- package/src/template-engine/cells.ts +0 -90
- package/src/template-engine/colors.ts +0 -124
- package/src/template-engine/engine.ts +0 -108
- package/src/template-engine/funcs.ts +0 -232
- package/src/template-engine/index.ts +0 -11
- package/src/template-engine/layout.ts +0 -133
- package/src/template-engine/scope.ts +0 -62
- package/src/template-engine/sparkline.ts +0 -79
- package/src/themes/index.ts +0 -20
- package/src/themes/palette-resolvers.ts +0 -84
- package/src/themes/policy.ts +0 -393
- package/src/utils/cache.ts +0 -206
- package/src/utils/claude.ts +0 -683
- package/src/utils/color-support.ts +0 -118
- package/src/utils/formatters.ts +0 -99
- package/src/utils/logger.ts +0 -5
- package/src/utils/outcome.ts +0 -33
- package/src/utils/schema-validator.ts +0 -126
- package/src/utils/single-flight.ts +0 -57
- package/src/utils/terminal-width.ts +0 -51
- package/src/utils/terminal.ts +0 -11
- package/src/utils/transcript-fs.ts +0 -279
- package/src/var-system/index.ts +0 -24
- package/src/var-system/sources.ts +0 -1047
- package/src/var-system/store.ts +0 -223
- package/src/var-system/types.ts +0 -57
- package/src/version.ts +0 -17
|
@@ -1,663 +0,0 @@
|
|
|
1
|
-
// [LAW:one-source-of-truth] The daemon is the SOLE writer of
|
|
2
|
-
// configOverridesPath() — the hand-authored user config file is never
|
|
3
|
-
// machine-edited (candybar-config-engine-71o's binding guardrail). A
|
|
4
|
-
// persistent config write (the `persist` action, distinct from `set`'s
|
|
5
|
-
// per-session write) lands here; RenderCache merges it on top of the user
|
|
6
|
-
// file every reload (src/daemon/cache/render.ts), so this module owns only
|
|
7
|
-
// the read/write/shape of the override layer, never the merge.
|
|
8
|
-
//
|
|
9
|
-
// [LAW:single-enforcer] Read-modify-write + atomic rename, synchronous. Writes
|
|
10
|
-
// here are click-rate (rare), not render-rate (SessionState's every-render
|
|
11
|
-
// atom) — there is no debounce to coalesce and no in-memory cache to keep
|
|
12
|
-
// warm; RenderCache re-reads this file fresh on every reload, exactly as it
|
|
13
|
-
// re-reads the user config file. One source, read where it's needed.
|
|
14
|
-
//
|
|
15
|
-
// [LAW:one-source-of-truth] The file is ONE flat dict keyed by whatever
|
|
16
|
-
// string a `persist`/`reset` action names (candybar-config-engine-71o.6
|
|
17
|
-
// generalized this from Globals-only fields to also admit
|
|
18
|
-
// `segments.<name>.palette` keys — see loader/persist-target.ts, the shared
|
|
19
|
-
// parser both this module and cross-ref.ts classify a key through). Keeping
|
|
20
|
-
// ONE flat dict (rather than a nested `{globals, segments}` shape) means the
|
|
21
|
-
// read-modify-write/atomic-rename plumbing below never needed to change
|
|
22
|
-
// shape — only what keys/values count as valid grew.
|
|
23
|
-
|
|
24
|
-
import fs from "node:fs";
|
|
25
|
-
import path from "node:path";
|
|
26
|
-
import type { Globals } from "../config/dsl-types.js";
|
|
27
|
-
import { isGlobalsField } from "../config/loader/globals.js";
|
|
28
|
-
import { parsePersistTarget } from "../config/loader/persist-target.js";
|
|
29
|
-
import { debug } from "../utils/logger.js";
|
|
30
|
-
import type { DaemonLogger } from "./log.js";
|
|
31
|
-
|
|
32
|
-
const quietLogger: DaemonLogger = (_level, message) => debug(message);
|
|
33
|
-
|
|
34
|
-
// [LAW:one-source-of-truth] Re-exported so every existing importer
|
|
35
|
-
// (verbs/index.ts, verbs/config-validators.ts) keeps reading membership
|
|
36
|
-
// through this module — but the membership check itself now has exactly ONE
|
|
37
|
-
// implementation (loader/globals.ts's isGlobalsField, derived from
|
|
38
|
-
// GLOBALS_SCHEMA), not two independently-authored tables that TypeScript's
|
|
39
|
-
// per-table exhaustiveness only coincidentally kept in agreement.
|
|
40
|
-
export { isGlobalsField } from "../config/loader/globals.js";
|
|
41
|
-
|
|
42
|
-
// [LAW:types-are-the-program] Every Globals field's primitive WIRE TYPE,
|
|
43
|
-
// keyed by `keyof Globals` — TypeScript forces this map to stay total over
|
|
44
|
-
// Globals, so a field added to/removed from that interface is a compile
|
|
45
|
-
// error here until this table is updated. This is the ONE place a `persist`
|
|
46
|
-
// write's canonical string is coerced to the JS type Globals actually
|
|
47
|
-
// declares (padding: number, autoWrap: boolean, everything else: string). A
|
|
48
|
-
// segment-palette target has no matching row: it's always a NAME, so its
|
|
49
|
-
// kind is "string" unconditionally — see coercePersistValue below. Membership
|
|
50
|
-
// (which keys exist) is NOT re-declared here — see the re-exported
|
|
51
|
-
// isGlobalsField above; this table only adds the per-field KIND membership
|
|
52
|
-
// alone doesn't carry.
|
|
53
|
-
const GLOBALS_FIELD_KIND: Readonly<
|
|
54
|
-
Record<keyof Globals, "string" | "number" | "boolean">
|
|
55
|
-
> = {
|
|
56
|
-
default_bg: "string",
|
|
57
|
-
default_fg: "string",
|
|
58
|
-
default_empty_value: "string",
|
|
59
|
-
default_separator: "string",
|
|
60
|
-
default_truncate_marker: "string",
|
|
61
|
-
palette: "string",
|
|
62
|
-
look: "string",
|
|
63
|
-
// The active arrangement — a NAME like palette/look, so `persist: "preset"`
|
|
64
|
-
// makes a chosen preset the default every future session opens in.
|
|
65
|
-
preset: "string",
|
|
66
|
-
style: "string",
|
|
67
|
-
autoWrap: "boolean",
|
|
68
|
-
padding: "number",
|
|
69
|
-
charset: "string",
|
|
70
|
-
colorCompatibility: "string",
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
// [LAW:one-source-of-truth] The same four canonical boolean-ish inputs
|
|
74
|
-
// validateBoolean (state-validators.ts) accepts — a `persist` action's gate
|
|
75
|
-
// is an ALLOW-LIST (the declared `to`/`cycle` members pass through
|
|
76
|
-
// membership-checked but otherwise VERBATIM, unlike validateBoolean's own
|
|
77
|
-
// bespoke normalization), so a config author writing `cycle: ["true",
|
|
78
|
-
// "false"]` or `to: "0"` reaches this boundary with the raw member string,
|
|
79
|
-
// not a pre-canonicalized "1"/"". This is the ONE place that must accept the
|
|
80
|
-
// full accepted-input set, not just the canonical pair.
|
|
81
|
-
const BOOLEAN_TRUTHY = new Set(["1", "true"]);
|
|
82
|
-
const BOOLEAN_FALSY = new Set(["0", "false", ""]);
|
|
83
|
-
|
|
84
|
-
// [LAW:no-silent-fallbacks] The `persist` write's validator canonicalizes to
|
|
85
|
-
// a STRING (the same wire currency `set` uses) — this is the boundary that
|
|
86
|
-
// lifts it into the typed value its scope declares. An out-of-range/non-
|
|
87
|
-
// numeric string for a "number" Globals field is a caller bug (the range
|
|
88
|
-
// validator already canonicalized it), so it throws loudly rather than
|
|
89
|
-
// writing a silently-wrong type into the overrides file. Replaces the old
|
|
90
|
-
// Globals-only `coerceGlobalsValue`: a bare `string` key (not `keyof
|
|
91
|
-
// Globals`) so a caller no longer needs a type-narrowing assertion before
|
|
92
|
-
// calling this — parsePersistTarget does the classification internally.
|
|
93
|
-
export function coercePersistValue(
|
|
94
|
-
key: string,
|
|
95
|
-
raw: string,
|
|
96
|
-
): string | number | boolean {
|
|
97
|
-
const target = parsePersistTarget(key);
|
|
98
|
-
if (target === null) {
|
|
99
|
-
throw new Error(
|
|
100
|
-
`coercePersistValue: "${key}" is not a valid persist target`,
|
|
101
|
-
);
|
|
102
|
-
}
|
|
103
|
-
// [LAW:one-type-per-behavior] Both non-globals scopes are always a NAME/
|
|
104
|
-
// TOKEN string — segment-palette's value is a palette name, preset-root-ops'
|
|
105
|
-
// is one op token appended by the daemon's apply-layout-op verb handler
|
|
106
|
-
// (never a bare `persist` write — see verbs/index.ts). Neither has a
|
|
107
|
-
// GLOBALS_FIELD_KIND row because neither is a Globals field.
|
|
108
|
-
if (
|
|
109
|
-
target.scope === "segment-palette" ||
|
|
110
|
-
target.scope === "preset-root-ops"
|
|
111
|
-
) {
|
|
112
|
-
return raw;
|
|
113
|
-
}
|
|
114
|
-
const kind = GLOBALS_FIELD_KIND[target.field];
|
|
115
|
-
if (kind === "string") return raw;
|
|
116
|
-
if (kind === "number") {
|
|
117
|
-
const n = Number(raw);
|
|
118
|
-
if (!Number.isFinite(n)) {
|
|
119
|
-
throw new Error(
|
|
120
|
-
`coercePersistValue: "${key}" expects a number, got "${raw}"`,
|
|
121
|
-
);
|
|
122
|
-
}
|
|
123
|
-
return n;
|
|
124
|
-
}
|
|
125
|
-
if (BOOLEAN_TRUTHY.has(raw)) return true;
|
|
126
|
-
if (BOOLEAN_FALSY.has(raw)) return false;
|
|
127
|
-
throw new Error(
|
|
128
|
-
`coercePersistValue: "${key}" expects boolean-ish (1, 0, true, false), got "${raw}"`,
|
|
129
|
-
);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// [LAW:no-silent-failure] Missing/corrupt/wrong-shape file → the empty
|
|
133
|
-
// override set is the *defined* recovery (identical to a first-ever boot),
|
|
134
|
-
// not a hidden fallback to different data. Every present key is classified
|
|
135
|
-
// through parsePersistTarget (globals field or segment-palette) and its
|
|
136
|
-
// value checked against that target's kind; a single malformed entry drops
|
|
137
|
-
// the WHOLE file back to empty (mirrors FileSessionStorage's all-or-nothing
|
|
138
|
-
// shape check) rather than guessing which entries to keep.
|
|
139
|
-
function isValidOverrides(
|
|
140
|
-
value: unknown,
|
|
141
|
-
): value is Readonly<Record<string, string | number | boolean>> {
|
|
142
|
-
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
143
|
-
return false;
|
|
144
|
-
}
|
|
145
|
-
for (const [key, v] of Object.entries(value)) {
|
|
146
|
-
const target = parsePersistTarget(key);
|
|
147
|
-
if (target === null) return false;
|
|
148
|
-
const kind =
|
|
149
|
-
target.scope === "globals" ? GLOBALS_FIELD_KIND[target.field] : "string";
|
|
150
|
-
if (kind === "number" && typeof v !== "number") return false;
|
|
151
|
-
if (kind === "boolean" && typeof v !== "boolean") return false;
|
|
152
|
-
if (kind === "string" && typeof v !== "string") return false;
|
|
153
|
-
}
|
|
154
|
-
return true;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// [LAW:single-enforcer] The ONE reader of the on-disk shape — every scoped
|
|
158
|
-
// view (loadConfigOverrides, loadSegmentPaletteOverrides) and every writer
|
|
159
|
-
// (writeConfigOverride, clearConfigOverride) reads through this, so the
|
|
160
|
-
// flat-dict shape and its recovery-to-empty behavior are decided exactly once.
|
|
161
|
-
function loadRawOverrides(
|
|
162
|
-
filePath: string,
|
|
163
|
-
logger: DaemonLogger,
|
|
164
|
-
): Readonly<Record<string, string | number | boolean>> {
|
|
165
|
-
let raw: string;
|
|
166
|
-
try {
|
|
167
|
-
raw = fs.readFileSync(filePath, "utf8");
|
|
168
|
-
} catch (e) {
|
|
169
|
-
const code = (e as NodeJS.ErrnoException).code;
|
|
170
|
-
if (code !== "ENOENT") {
|
|
171
|
-
logger("warn", `config-overrides read failed (${code}); starting empty`);
|
|
172
|
-
}
|
|
173
|
-
return {};
|
|
174
|
-
}
|
|
175
|
-
try {
|
|
176
|
-
const parsed: unknown = JSON.parse(raw);
|
|
177
|
-
if (isValidOverrides(parsed)) return parsed;
|
|
178
|
-
logger("warn", `config-overrides load: unexpected shape, starting empty`);
|
|
179
|
-
return {};
|
|
180
|
-
} catch {
|
|
181
|
-
logger("warn", `config-overrides load: corrupt JSON, starting empty`);
|
|
182
|
-
return {};
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// [LAW:one-source-of-truth] The Globals-scoped VIEW of a raw dict — kept as
|
|
187
|
-
// `Partial<Globals>` so every existing caller (RenderCache's
|
|
188
|
-
// mergeWithDefault({globals: ...}), stepConfig's range-seed lookup) keeps its
|
|
189
|
-
// original, precisely-typed contract unchanged. Segment-palette entries in
|
|
190
|
-
// the same file are invisible here by construction (isGlobalsField filters
|
|
191
|
-
// them out) — see projectSegmentPaletteOverrides for that half. A pure
|
|
192
|
-
// projection over an already-read dict (not a filePath) so a caller wanting
|
|
193
|
-
// BOTH views (loadOverrides below) pays for exactly one read.
|
|
194
|
-
//
|
|
195
|
-
// [LAW:no-defensive-null-guards] exception: `Object.create(null)` — the key
|
|
196
|
-
// being assigned comes from the on-disk overrides file, which a `persist`
|
|
197
|
-
// write only ever populates from a real Globals field name (isGlobalsField
|
|
198
|
-
// already excludes "__proto__"), but the accumulator itself gets the same
|
|
199
|
-
// null-prototype hygiene src/dsl/render.ts's segment-keyed accumulator uses
|
|
200
|
-
// ("segment names come from user config; a null-prototype object prevents
|
|
201
|
-
// __proto__/constructor/prototype from being treated as segment data") —
|
|
202
|
-
// one guard at the object, not a per-caller property-name check.
|
|
203
|
-
function projectGlobalsOverrides(
|
|
204
|
-
raw: Readonly<Record<string, string | number | boolean>>,
|
|
205
|
-
): Partial<Globals> {
|
|
206
|
-
const out: Record<string, string | number | boolean> = Object.create(
|
|
207
|
-
null,
|
|
208
|
-
) as Record<string, string | number | boolean>;
|
|
209
|
-
for (const [key, value] of Object.entries(raw)) {
|
|
210
|
-
if (isGlobalsField(key)) out[key] = value;
|
|
211
|
-
}
|
|
212
|
-
// [LAW:no-silent-fallbacks] exception: isValidOverrides already proved every
|
|
213
|
-
// entry's runtime kind matches its target's declared kind (GLOBALS_FIELD_KIND)
|
|
214
|
-
// before it ever reached the file — this cast states that proof, it doesn't
|
|
215
|
-
// paper over an unchecked one.
|
|
216
|
-
return out as Partial<Globals>;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// [LAW:one-source-of-truth] The segment-palette-scoped VIEW of the SAME raw
|
|
220
|
-
// dict — segment name -> persisted palette name. RenderCache overlays this
|
|
221
|
-
// onto the already-merged config's `segments[name].palette` field
|
|
222
|
-
// (applySegmentPaletteOverrides in config/loader/merge.ts), never through
|
|
223
|
-
// mergeWithDefault's wholesale per-name segment replacement.
|
|
224
|
-
//
|
|
225
|
-
// [LAW:no-defensive-null-guards] exception: `Object.create(null)` — unlike
|
|
226
|
-
// projectGlobalsOverrides, the assigned key here (`target.segment`) is NOT
|
|
227
|
-
// membership-checked against any closed set before the write (any string a
|
|
228
|
-
// config declares as a segment name is legal), so a segment genuinely named
|
|
229
|
-
// `__proto__` would otherwise hit the prototype setter on `out[key] =` —
|
|
230
|
-
// the exact crash class the render.ts precedent (see above) already guards
|
|
231
|
-
// against for segment-keyed objects.
|
|
232
|
-
function projectSegmentPaletteOverrides(
|
|
233
|
-
raw: Readonly<Record<string, string | number | boolean>>,
|
|
234
|
-
): Readonly<Record<string, string>> {
|
|
235
|
-
const out: Record<string, string> = Object.create(null) as Record<
|
|
236
|
-
string,
|
|
237
|
-
string
|
|
238
|
-
>;
|
|
239
|
-
for (const [key, value] of Object.entries(raw)) {
|
|
240
|
-
const target = parsePersistTarget(key);
|
|
241
|
-
if (target?.scope === "segment-palette" && typeof value === "string") {
|
|
242
|
-
out[target.segment] = value;
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
return out;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// [LAW:one-source-of-truth] The preset-root-ops-scoped VIEW of the SAME raw
|
|
249
|
-
// dict — preset name -> the accumulated op-token LIST (brandon-layout-edit-
|
|
250
|
-
// 2gc.1's structural-edit log; see src/config/layout-ops.ts). This is a
|
|
251
|
-
// SHAPE check only (well-formed JSON array of strings) — decoding each
|
|
252
|
-
// token into a typed LayoutOp, and applying the ops to a tree, is presets.ts's
|
|
253
|
-
// job, not this storage-layer module's [LAW:decomposition]. A stored value
|
|
254
|
-
// that isn't a JSON array of strings drops for THAT preset only (a warn log,
|
|
255
|
-
// never a crash of the whole overrides file) — the identical "the world
|
|
256
|
-
// moved on since this was written" recovery projectSegmentPaletteOverrides
|
|
257
|
-
// already gets, one level narrower.
|
|
258
|
-
function projectPresetRootOpsOverrides(
|
|
259
|
-
raw: Readonly<Record<string, string | number | boolean>>,
|
|
260
|
-
logger: DaemonLogger,
|
|
261
|
-
): Readonly<Record<string, readonly string[]>> {
|
|
262
|
-
const out: Record<string, readonly string[]> = Object.create(null) as Record<
|
|
263
|
-
string,
|
|
264
|
-
readonly string[]
|
|
265
|
-
>;
|
|
266
|
-
for (const [key, value] of Object.entries(raw)) {
|
|
267
|
-
const target = parsePersistTarget(key);
|
|
268
|
-
if (target?.scope !== "preset-root-ops" || typeof value !== "string") {
|
|
269
|
-
continue;
|
|
270
|
-
}
|
|
271
|
-
try {
|
|
272
|
-
const parsed: unknown = JSON.parse(value);
|
|
273
|
-
if (Array.isArray(parsed) && parsed.every((t) => typeof t === "string")) {
|
|
274
|
-
out[target.preset] = parsed;
|
|
275
|
-
continue;
|
|
276
|
-
}
|
|
277
|
-
} catch {
|
|
278
|
-
// fall through to the warn below
|
|
279
|
-
}
|
|
280
|
-
logger(
|
|
281
|
-
"warn",
|
|
282
|
-
`config-overrides: "${key}" is not a valid op-token list, dropping`,
|
|
283
|
-
);
|
|
284
|
-
}
|
|
285
|
-
return out;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
export function loadConfigOverrides(
|
|
289
|
-
filePath: string,
|
|
290
|
-
logger: DaemonLogger = quietLogger,
|
|
291
|
-
): Partial<Globals> {
|
|
292
|
-
return projectGlobalsOverrides(loadRawOverrides(filePath, logger));
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
export function loadSegmentPaletteOverrides(
|
|
296
|
-
filePath: string,
|
|
297
|
-
logger: DaemonLogger = quietLogger,
|
|
298
|
-
): Readonly<Record<string, string>> {
|
|
299
|
-
return projectSegmentPaletteOverrides(loadRawOverrides(filePath, logger));
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
// [LAW:carrying-cost] RenderCache wants ALL THREE views on every reload
|
|
303
|
-
// (buildState merges globals overrides, overlays segment-palette overrides,
|
|
304
|
-
// then replays preset-root-ops overrides) — calling the scoped loaders back
|
|
305
|
-
// to back would read, parse, and shape-validate the same tiny file three
|
|
306
|
-
// times per reload for no reason. One read, three projections.
|
|
307
|
-
export interface Overrides {
|
|
308
|
-
readonly globals: Partial<Globals>;
|
|
309
|
-
readonly segmentPalette: Readonly<Record<string, string>>;
|
|
310
|
-
readonly presetRootOps: Readonly<Record<string, readonly string[]>>;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
export function loadOverrides(
|
|
314
|
-
filePath: string,
|
|
315
|
-
logger: DaemonLogger = quietLogger,
|
|
316
|
-
): Overrides {
|
|
317
|
-
const raw = loadRawOverrides(filePath, logger);
|
|
318
|
-
return {
|
|
319
|
-
globals: projectGlobalsOverrides(raw),
|
|
320
|
-
segmentPalette: projectSegmentPaletteOverrides(raw),
|
|
321
|
-
presetRootOps: projectPresetRootOpsOverrides(raw, logger),
|
|
322
|
-
};
|
|
323
|
-
}
|
|
324
|
-
|
|
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).
|
|
333
|
-
// Unlike session-state.json's debounced best-effort flush (no synchronous
|
|
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(
|
|
340
|
-
filePath: string,
|
|
341
|
-
label: string,
|
|
342
|
-
value: unknown,
|
|
343
|
-
logger: DaemonLogger,
|
|
344
|
-
): void {
|
|
345
|
-
try {
|
|
346
|
-
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
347
|
-
const tmp = `${filePath}.tmp`;
|
|
348
|
-
fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 });
|
|
349
|
-
fs.chmodSync(tmp, 0o600);
|
|
350
|
-
fs.renameSync(tmp, filePath);
|
|
351
|
-
} catch (e) {
|
|
352
|
-
const message = `${label} write failed: ${(e as Error).message}`;
|
|
353
|
-
logger("error", message);
|
|
354
|
-
throw new Error(message);
|
|
355
|
-
}
|
|
356
|
-
}
|
|
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]
|
|
399
|
-
export function writeConfigOverride(
|
|
400
|
-
filePath: string,
|
|
401
|
-
key: string,
|
|
402
|
-
value: string | number | boolean,
|
|
403
|
-
logger: DaemonLogger = quietLogger,
|
|
404
|
-
): void {
|
|
405
|
-
const prev = mutateOverride(filePath, key, value, logger);
|
|
406
|
-
pushHistoryEntry(filePath, { key, from: prev ?? null, to: value }, logger);
|
|
407
|
-
}
|
|
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.
|
|
413
|
-
export function clearConfigOverride(
|
|
414
|
-
filePath: string,
|
|
415
|
-
key: string,
|
|
416
|
-
logger: DaemonLogger = quietLogger,
|
|
417
|
-
): void {
|
|
418
|
-
const overrides = loadRawOverrides(filePath, logger);
|
|
419
|
-
if (!(key in overrides)) return;
|
|
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;
|
|
663
|
-
}
|