@promptctl/cc-candybar 1.0.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/LICENSE +21 -0
- package/README.md +145 -0
- package/bin/cc-candybar +6 -0
- package/dist/index.mjs +185 -0
- package/package.json +99 -0
- package/plugin/.claude-plugin/plugin.json +11 -0
- package/plugin/bin/preview.sh +305 -0
- package/plugin/commands/candybar.md +403 -0
- package/plugin/templates/config-essential.json +36 -0
- package/plugin/templates/config-full.json +55 -0
- package/plugin/templates/config-standard.json +39 -0
- package/plugin/templates/config-tui-compact.json +48 -0
- package/plugin/templates/config-tui-full.json +89 -0
- package/plugin/templates/config-tui-standard.json +56 -0
- package/plugin/templates/config-tui.json +18 -0
- package/plugin/templates/nerd-fonts-sample.txt +5 -0
- package/schema/cc-candybar.schema.json +1379 -0
- package/src/click/wire.ts +113 -0
- package/src/config/action.ts +91 -0
- package/src/config/cli.ts +170 -0
- package/src/config/default-dsl-config.ts +661 -0
- package/src/config/dsl-loader.ts +265 -0
- package/src/config/dsl-types.ts +425 -0
- package/src/config/loader/actions.ts +530 -0
- package/src/config/loader/cache.ts +206 -0
- package/src/config/loader/cross-ref.ts +326 -0
- package/src/config/loader/cycles.ts +148 -0
- package/src/config/loader/diagnostics.ts +99 -0
- package/src/config/loader/discovery.ts +182 -0
- package/src/config/loader/emit-schema.ts +63 -0
- package/src/config/loader/globals.ts +42 -0
- package/src/config/loader/helpers.ts +48 -0
- package/src/config/loader/layout.ts +688 -0
- package/src/config/loader/merge.ts +40 -0
- package/src/config/loader/refs.ts +96 -0
- package/src/config/loader/segments.ts +120 -0
- package/src/config/loader/validate-core.ts +674 -0
- package/src/config/loader/variables.ts +260 -0
- package/src/daemon/acquire.ts +411 -0
- package/src/daemon/cache/git.ts +553 -0
- package/src/daemon/cache/render.ts +449 -0
- package/src/daemon/cache/session-usage-store.ts +446 -0
- package/src/daemon/cache/watchers.ts +245 -0
- package/src/daemon/client-debug.ts +120 -0
- package/src/daemon/client-stats.ts +129 -0
- package/src/daemon/client-transport.ts +273 -0
- package/src/daemon/client.ts +75 -0
- package/src/daemon/debug-types.ts +91 -0
- package/src/daemon/debug.ts +264 -0
- package/src/daemon/limits.ts +154 -0
- package/src/daemon/log.ts +69 -0
- package/src/daemon/parent-watchdog.ts +80 -0
- package/src/daemon/paths.ts +127 -0
- package/src/daemon/protocol.ts +235 -0
- package/src/daemon/render-payload.ts +611 -0
- package/src/daemon/server.ts +1103 -0
- package/src/daemon/session-state-file.ts +108 -0
- package/src/daemon/session-state.ts +237 -0
- package/src/daemon/stats.ts +229 -0
- package/src/daemon/verbs/index.ts +458 -0
- package/src/daemon/verbs/state-validators.ts +708 -0
- package/src/demo/dsl.ts +117 -0
- package/src/demo/mock-data.ts +67 -0
- package/src/demo/statusline.json5 +92 -0
- package/src/dsl/node-registry.ts +281 -0
- package/src/dsl/render.ts +558 -0
- package/src/index.ts +206 -0
- package/src/install/index.ts +410 -0
- package/src/proc/launch.ts +451 -0
- package/src/proc/stats-handle.ts +13 -0
- package/src/render/action.ts +458 -0
- package/src/render/diagnostic-style.ts +23 -0
- package/src/render/diagnostic-text.ts +77 -0
- package/src/render/error-glyph.ts +53 -0
- package/src/render/outcome-plan.ts +45 -0
- package/src/render/picker.ts +231 -0
- package/src/render/split-lines.ts +51 -0
- package/src/render/strip.ts +103 -0
- package/src/segments/cache.ts +131 -0
- package/src/segments/context.ts +190 -0
- package/src/segments/git.ts +561 -0
- package/src/segments/metrics.ts +101 -0
- package/src/segments/pricing.ts +452 -0
- package/src/segments/session.ts +188 -0
- package/src/segments/tmux.ts +74 -0
- package/src/template-engine/cells.ts +90 -0
- package/src/template-engine/colors.ts +102 -0
- package/src/template-engine/engine.ts +108 -0
- package/src/template-engine/funcs.ts +216 -0
- package/src/template-engine/index.ts +11 -0
- package/src/template-engine/layout.ts +112 -0
- package/src/template-engine/scope.ts +62 -0
- package/src/themes/index.ts +19 -0
- package/src/themes/palette-resolvers.ts +86 -0
- package/src/themes/policy.ts +79 -0
- package/src/themes/session-random.ts +88 -0
- package/src/utils/cache.ts +206 -0
- package/src/utils/claude.ts +616 -0
- package/src/utils/color-support.ts +118 -0
- package/src/utils/formatters.ts +77 -0
- package/src/utils/logger.ts +5 -0
- package/src/utils/outcome.ts +33 -0
- package/src/utils/schema-validator.ts +126 -0
- package/src/utils/single-flight.ts +57 -0
- package/src/utils/terminal-width.ts +43 -0
- package/src/utils/terminal.ts +11 -0
- package/src/utils/transcript-fs.ts +162 -0
- package/src/var-system/index.ts +24 -0
- package/src/var-system/sources.ts +1038 -0
- package/src/var-system/store.ts +223 -0
- package/src/var-system/types.ts +57 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// [LAW:single-enforcer] THE click-wire codec. A click is an ordered list of
|
|
2
|
+
// effects; this module is the one place that serializes that list to a URL and
|
|
3
|
+
// parses it back. The renderer (every click emitter) calls effectsUrl; the
|
|
4
|
+
// daemon's `dispatch` verb calls parseEffects. Encode and decode live together
|
|
5
|
+
// so the format cannot drift between the two halves [LAW:one-source-of-truth].
|
|
6
|
+
//
|
|
7
|
+
// [LAW:dataflow-not-control-flow] N effects ride one URL the SAME way for N=1 and
|
|
8
|
+
// N=100 — a lone click is the degenerate one-element list. There is no
|
|
9
|
+
// plain-vs-compound mode: every URL effectsUrl emits is `dispatch/e=…`, and the
|
|
10
|
+
// effect COUNT is data the dispatcher folds over, never a branch that selects a
|
|
11
|
+
// wire. (The wire still ACCEPTS direct `cc-candybar://<verb>/…` URLs — old
|
|
12
|
+
// scrollback links, a hand-authored `link` template — so a direct verb is the
|
|
13
|
+
// degenerate one-effect case on the parse side; only emission is unified here.)
|
|
14
|
+
//
|
|
15
|
+
// Why query params (not slashes, not base64): the value handed to the daemon is
|
|
16
|
+
// passed RAW (parseHandlerUrl decodes only the verb), so each `e` param survives
|
|
17
|
+
// exactly one URLSearchParams decode and an effect's own slash-bearing value
|
|
18
|
+
// (a path, a set-state key/value tail) round-trips untouched. base64 was
|
|
19
|
+
// rejected as opaque; a slash-nested payload is unsafe under any single
|
|
20
|
+
// whole-value decode (a `%2F` would un-escape into a structural separator). The
|
|
21
|
+
// `e=…&e=…` payload follows the verb after a `/` (`dispatch/e=…`), NOT a `?`, so
|
|
22
|
+
// `/` stays the one verb delimiter and `?` remains ordinary data in a bare-copy
|
|
23
|
+
// value (`cc-candybar://hello?world`).
|
|
24
|
+
|
|
25
|
+
import { URLSearchParams } from "node:url";
|
|
26
|
+
|
|
27
|
+
// [LAW:one-source-of-truth] The scheme string lives here, with the codec that
|
|
28
|
+
// emits it; install/ (Launch Services registration) imports it.
|
|
29
|
+
export const URL_SCHEME = "cc-candybar";
|
|
30
|
+
|
|
31
|
+
// [LAW:one-source-of-truth] The verb vocabulary. The daemon's VERBS registry
|
|
32
|
+
// keys off these and every emitter builds effects with them, so the emitted
|
|
33
|
+
// verb and the dispatched handler cannot name-drift.
|
|
34
|
+
export const VERB_DISPATCH = "dispatch";
|
|
35
|
+
export const VERB_SET_STATE = "set-state";
|
|
36
|
+
// [LAW:types-are-the-program] A RELATIVE state nudge: its args are
|
|
37
|
+
// `[sessionId, key, by]` where `by` is the signed integer delta. Distinct from
|
|
38
|
+
// set-state because the click intent is "step from whatever the value IS now",
|
|
39
|
+
// not "set to this fixed value" — the absolute target is computed at APPLY time
|
|
40
|
+
// from live state, so the link carries no `current` snapshot and N rapid clicks
|
|
41
|
+
// each re-read-and-write. Additive: old set-state links still resolve.
|
|
42
|
+
export const VERB_STEP_STATE = "step-state";
|
|
43
|
+
export const VERB_COPY = "copy";
|
|
44
|
+
export const VERB_OPEN_VSCODE = "open-vscode";
|
|
45
|
+
export const VERB_TOOLBAR_TOGGLE = "toolbar-toggle";
|
|
46
|
+
export const VERB_SHOW_CONFIG_ERROR = "show-config-error";
|
|
47
|
+
export const VERB_SHOW_CONFIG_WARNING = "show-config-warning";
|
|
48
|
+
// [LAW:effects-at-boundaries] A daemon-global config override: the verb writes
|
|
49
|
+
// the override path (or clears it with an empty value); the render pipeline
|
|
50
|
+
// reads it at the cache-lookup boundary. Clicking a different config is a
|
|
51
|
+
// side-effect isolated to the verb handler; the renderer only sees the result.
|
|
52
|
+
export const VERB_LOAD_CONFIG = "load-config";
|
|
53
|
+
|
|
54
|
+
// [LAW:types-are-the-program] An effect to EMIT: a verb plus its raw (unencoded)
|
|
55
|
+
// positional args. The wire owns all encoding — callers never percent-encode.
|
|
56
|
+
// set-state's args are `[sessionId, key, value, …]`; copy/open carry one arg.
|
|
57
|
+
export interface Effect {
|
|
58
|
+
readonly verb: string;
|
|
59
|
+
readonly args: readonly string[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// [LAW:types-are-the-program] A parsed effect as the dispatcher sees it: the verb
|
|
63
|
+
// and the still-encoded segment tail. The tail stays encoded because the target
|
|
64
|
+
// verb's handler decodes its own segments at its boundary (single-enforcer per
|
|
65
|
+
// verb) — the same contract a direct (non-dispatch) click URL hands a handler.
|
|
66
|
+
export interface ParsedEffect {
|
|
67
|
+
readonly verb: string;
|
|
68
|
+
readonly value: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// [LAW:single-enforcer] The segment codec. A verb's args serialize to a
|
|
72
|
+
// slash-joined run of percent-encoded segments; the handler decodes the inverse.
|
|
73
|
+
// Encoding each segment means a segment's own `/` becomes `%2F` and never reads
|
|
74
|
+
// as a separator — the slash-safety the old whole-value decode could not give.
|
|
75
|
+
export function encodeSegments(parts: readonly string[]): string {
|
|
76
|
+
return parts.map(encodeURIComponent).join("/");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function decodeSegments(value: string): string[] {
|
|
80
|
+
return value.length === 0 ? [] : value.split("/").map(decodeURIComponent);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Serialize an effect list to its dispatch URL. Each effect becomes one ordered
|
|
84
|
+
// `e` query param carrying `verb/<encoded-args>`, percent-encoded whole so its
|
|
85
|
+
// internal `/`, `&`, `=` survive as data. The payload follows `dispatch/` (not
|
|
86
|
+
// `dispatch?`) so `/` is the only verb delimiter parseHandlerUrl needs.
|
|
87
|
+
export function effectsUrl(effects: readonly Effect[]): string {
|
|
88
|
+
const qs = effects
|
|
89
|
+
.map(
|
|
90
|
+
(e) => `e=${encodeURIComponent(`${e.verb}/${encodeSegments(e.args)}`)}`,
|
|
91
|
+
)
|
|
92
|
+
.join("&");
|
|
93
|
+
return `${URL_SCHEME}://${VERB_DISPATCH}/${qs}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// [LAW:dataflow-not-control-flow] Parse the dispatch verb's raw value (an
|
|
97
|
+
// `e=…&e=…` query string) into the ordered effect list. URLSearchParams decodes
|
|
98
|
+
// each param exactly once and preserves insertion order; splitting each on the
|
|
99
|
+
// FIRST `/` recovers (verb, still-encoded tail) — the same split parseHandlerUrl
|
|
100
|
+
// applies at the top level, one level down.
|
|
101
|
+
export function parseEffects(rawValue: string): ParsedEffect[] {
|
|
102
|
+
return new URLSearchParams(rawValue).getAll("e").map(splitVerb);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// [LAW:types-are-the-program] Split a `verb/tail` string at the first `/`. A
|
|
106
|
+
// verb with no args (no slash) yields an empty tail — the degenerate case, not a
|
|
107
|
+
// guard. The tail keeps its slashes (further segments) for the handler to decode.
|
|
108
|
+
export function splitVerb(s: string): ParsedEffect {
|
|
109
|
+
const i = s.indexOf("/");
|
|
110
|
+
return i === -1
|
|
111
|
+
? { verb: s, value: "" }
|
|
112
|
+
: { verb: s.slice(0, i), value: s.slice(i + 1) };
|
|
113
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// [LAW:types-are-the-program] The author-facing SHAPE of a decoupled, named
|
|
2
|
+
// ACTION — the config-file schema for `DslConfig.actions`. Pure data (no engine,
|
|
3
|
+
// no rich-js): the strongest theorem about what a user can declare. The loader
|
|
4
|
+
// narrows `unknown` to these; the runtime (render/action.ts) and the validator
|
|
5
|
+
// derivation (daemon/verbs/state-validators.ts) consume them.
|
|
6
|
+
//
|
|
7
|
+
// [LAW:locality-or-seam] An action is the SEAM between the clickable
|
|
8
|
+
// REPRESENTATION (a template region, `{{ action "name" … }}`) and the BEHAVIOR
|
|
9
|
+
// (what the click does). They are joined by NAME: re-glyph a button without
|
|
10
|
+
// touching behavior; re-target an action without touching the template. This is
|
|
11
|
+
// the successor to the widget surface — a widget couples representation and
|
|
12
|
+
// behavior in one declaration; an action splits them so one template expresses
|
|
13
|
+
// anything (text, state-driven display, clickable regions) and the action table
|
|
14
|
+
// is the single, statically-enumerable set of effects those regions can fire.
|
|
15
|
+
//
|
|
16
|
+
// [LAW:one-source-of-truth] Because a `set` action carries its key and value
|
|
17
|
+
// SOURCE as literal data (a literal `to`, an option domain, or numeric bounds),
|
|
18
|
+
// the writable-key gate DERIVES from the action table (deriveActionValidators).
|
|
19
|
+
// A template references a NAME; it cannot smuggle an un-gated write. The rendered
|
|
20
|
+
// click and the gate share ONE source — the action declaration.
|
|
21
|
+
//
|
|
22
|
+
// [LAW:one-source-of-truth] The option-domain + effect-verb vocabulary lives
|
|
23
|
+
// HERE — action.ts is the surviving home now that the widget surface is gone.
|
|
24
|
+
// These are the shapes a picker draws options from and the set/copy/open/int
|
|
25
|
+
// discriminator the loader and the validator-derivation match on.
|
|
26
|
+
|
|
27
|
+
// [LAW:one-source-of-truth] The domain lists a picker draws options from. Same
|
|
28
|
+
// canonical sources the `themes()`/`styles()` bindings and the set-state
|
|
29
|
+
// validators consult — the rendered options and the derived gate cannot diverge
|
|
30
|
+
// because there is no second enumeration.
|
|
31
|
+
export type OptionSource = "themes" | "styles";
|
|
32
|
+
export const OPTION_SOURCES: readonly OptionSource[] = ["themes", "styles"];
|
|
33
|
+
|
|
34
|
+
// [LAW:types-are-the-program] The top-level discriminator of an ActionDecl — the
|
|
35
|
+
// click effect is keyed by which of these is present. The loader proves
|
|
36
|
+
// exactly-one-of; the renderer and validator-derivation match with no fallthrough.
|
|
37
|
+
export const ACTION_KEYS = ["set", "copy", "open"] as const;
|
|
38
|
+
export type ActionKey = (typeof ACTION_KEYS)[number];
|
|
39
|
+
|
|
40
|
+
// [LAW:types-are-the-program] An ActionDecl is the click effect a named action
|
|
41
|
+
// binds to. The top-level discriminator is which of `set`/`copy`/`open` is
|
|
42
|
+
// present (the CacheDecl/widget-Action pattern); a `set` is sub-discriminated by
|
|
43
|
+
// its value SOURCE — `to` (literal), `from` (option-bound), or `min/max/by`
|
|
44
|
+
// (bounded step). The loader proves exactly-one-of at each level; the renderer
|
|
45
|
+
// and the validator-derivation match on the present key with no fallthrough.
|
|
46
|
+
//
|
|
47
|
+
// set + to — write a literal value -> allow-list {to}
|
|
48
|
+
// set + from — write the option the template binds at render
|
|
49
|
+
// (a picker ranges the domain) -> allow-list {options}
|
|
50
|
+
// set + min/max/by — write wrap(current ± by) clamped to [min,max]
|
|
51
|
+
// (a stepper affordance) -> range [min,max]
|
|
52
|
+
// set + int — write any integer the render binds (a paged cursor:
|
|
53
|
+
// -1 closed / 0..N pages, clamp owned by the renderer)
|
|
54
|
+
// -> int gate (unbounded). The missing primitive a
|
|
55
|
+
// width-paginated picker needs — its page key accepts any
|
|
56
|
+
// integer, which no bounded/literal arm can express.
|
|
57
|
+
// set + cycle — write the SUCCESSOR of the current value in the
|
|
58
|
+
// member list, wrapping; a current value outside the
|
|
59
|
+
// domain counts as the first member (so the second
|
|
60
|
+
// member is the "first click" target — order members
|
|
61
|
+
// default-state-first). The bounded stepper's sibling:
|
|
62
|
+
// a stepper steps a range, a cycle steps an enumerated
|
|
63
|
+
// domain (toggles, N-state cyclers, accordion paths)
|
|
64
|
+
// -> allow-list {members}
|
|
65
|
+
// copy — copy templated text to the clipboard -> no gate
|
|
66
|
+
// open — open a templated target in the editor -> no gate
|
|
67
|
+
//
|
|
68
|
+
// [LAW:one-source-of-truth] Only `set` writes SessionState, so only `set`
|
|
69
|
+
// derives a validator. copy/open write nothing — they derive nothing. The
|
|
70
|
+
// vocabulary grows by arms (a future `run`/`open-url`), not by validator plumbing.
|
|
71
|
+
export type ActionDecl =
|
|
72
|
+
| { readonly set: string; readonly to: string }
|
|
73
|
+
| { readonly set: string; readonly from: OptionSource }
|
|
74
|
+
| {
|
|
75
|
+
readonly set: string;
|
|
76
|
+
readonly min: number;
|
|
77
|
+
readonly max: number;
|
|
78
|
+
readonly by: number;
|
|
79
|
+
}
|
|
80
|
+
| { readonly set: string; readonly int: true }
|
|
81
|
+
| { readonly set: string; readonly cycle: readonly string[] }
|
|
82
|
+
| { readonly copy: string }
|
|
83
|
+
| { readonly open: string };
|
|
84
|
+
|
|
85
|
+
// [LAW:dataflow-not-control-flow] Does this action write a SessionState key? A
|
|
86
|
+
// `set` action composes a set-state click URL whose first segment is session.id;
|
|
87
|
+
// copy/open embed none. One predicate the loader's session.id requirement folds
|
|
88
|
+
// over — no per-arm branching at the callsite.
|
|
89
|
+
export function actionBindsSet(a: ActionDecl): boolean {
|
|
90
|
+
return "set" in a;
|
|
91
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
// [LAW:single-enforcer] Config-tooling CLI entry points. `lint` and `schema`
|
|
2
|
+
// share one module because they share one change-reason: the config grammar.
|
|
3
|
+
// Neither carries validation or schema LOGIC — `lint` is a second entry point
|
|
4
|
+
// into the loader (the single config-validation enforcer the daemon also uses),
|
|
5
|
+
// and `schema` serves the build-generated artifact (derived from the config
|
|
6
|
+
// types). Both are bindings, not reimplementations [LAW:one-source-of-truth].
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import process from "node:process";
|
|
11
|
+
import { loadConfig, validateConfig } from "./dsl-loader.js";
|
|
12
|
+
import { ConfigError } from "./loader/diagnostics.js";
|
|
13
|
+
|
|
14
|
+
// Exit codes are a contract (the CLI guideline: not just 0/1), so scripts and
|
|
15
|
+
// editors can distinguish "your config is wrong" from "I couldn't run":
|
|
16
|
+
// 0 — config is valid
|
|
17
|
+
// 1 — config is invalid (ConfigError: structural, cross-ref, or cycle)
|
|
18
|
+
// 2 — usage error or the file could not be read
|
|
19
|
+
const EXIT_VALID = 0;
|
|
20
|
+
const EXIT_INVALID = 1;
|
|
21
|
+
const EXIT_USAGE = 2;
|
|
22
|
+
|
|
23
|
+
// [LAW:dataflow-not-control-flow] The lint result is DATA — a pure function of
|
|
24
|
+
// the target file's contents — discriminated into the three outcomes the exit-
|
|
25
|
+
// code contract projects. `lintConfig` carries the decision; `runLint` only maps
|
|
26
|
+
// it to (stream, exit). The decision is unit-testable without spawning a process
|
|
27
|
+
// or stubbing process.exit, which is what makes the exit-code goal verifiable.
|
|
28
|
+
export type LintOutcome =
|
|
29
|
+
| { readonly kind: "valid"; readonly path: string }
|
|
30
|
+
| { readonly kind: "invalid"; readonly message: string }
|
|
31
|
+
| {
|
|
32
|
+
readonly kind: "unreadable";
|
|
33
|
+
readonly path: string;
|
|
34
|
+
readonly message: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// Run the real loader (parse → merge-with-default → cross-ref + cycle validation)
|
|
38
|
+
// against an arbitrary file. No daemon: the loader imports only fs, JSON5, and
|
|
39
|
+
// pure validators, so the same errors the daemon would surface at render time are
|
|
40
|
+
// surfaced here ahead of time.
|
|
41
|
+
//
|
|
42
|
+
// [LAW:single-enforcer] loadConfig + validateConfig is the identical pipeline
|
|
43
|
+
// RenderCache.reloadInto runs in the daemon — the validation authority is one
|
|
44
|
+
// function, so lint cannot drift from production. The source we read is passed to
|
|
45
|
+
// validateConfig only to sharpen line numbers (semantic issues map to lines).
|
|
46
|
+
export function lintConfig(target: string): LintOutcome {
|
|
47
|
+
const resolved = path.resolve(target);
|
|
48
|
+
|
|
49
|
+
let source: string;
|
|
50
|
+
try {
|
|
51
|
+
source = fs.readFileSync(resolved, "utf-8");
|
|
52
|
+
} catch (e) {
|
|
53
|
+
return {
|
|
54
|
+
kind: "unreadable",
|
|
55
|
+
path: target,
|
|
56
|
+
message: e instanceof Error ? e.message : String(e),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const { config } = loadConfig(resolved);
|
|
62
|
+
validateConfig(config, resolved, source);
|
|
63
|
+
} catch (e) {
|
|
64
|
+
if (e instanceof ConfigError)
|
|
65
|
+
return { kind: "invalid", message: e.message };
|
|
66
|
+
throw e;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return { kind: "valid", path: target };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// `cc-candybar lint <path>` — the argv binding. Missing-arg is a usage concern
|
|
73
|
+
// (not a lint outcome), handled here; everything else is the projection of
|
|
74
|
+
// lintConfig's outcome onto the stream + exit-code contract.
|
|
75
|
+
export function runLint(args: readonly string[]): void {
|
|
76
|
+
const target = args[0];
|
|
77
|
+
if (target === undefined || target === "") {
|
|
78
|
+
process.stderr.write(
|
|
79
|
+
"lint: missing <path>\nUsage: cc-candybar lint <config-file>\n",
|
|
80
|
+
);
|
|
81
|
+
process.exit(EXIT_USAGE);
|
|
82
|
+
}
|
|
83
|
+
applyLintOutcome(lintConfig(target));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// [LAW:dataflow-not-control-flow] The outcome → (stream, text, exit-code) mapping
|
|
87
|
+
// is DATA. `lintPlan` is a total fold returning that descriptor (a non-returning
|
|
88
|
+
// arm fails the typecheck, so the projection stays exhaustive over LintOutcome);
|
|
89
|
+
// `applyLintOutcome` runs the single write + exit against it. The side effects
|
|
90
|
+
// are unconditional; the data decides their content.
|
|
91
|
+
interface LintPlan {
|
|
92
|
+
readonly stream: NodeJS.WriteStream;
|
|
93
|
+
readonly text: string;
|
|
94
|
+
readonly code: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function lintPlan(o: LintOutcome): LintPlan {
|
|
98
|
+
switch (o.kind) {
|
|
99
|
+
case "valid":
|
|
100
|
+
return {
|
|
101
|
+
stream: process.stdout,
|
|
102
|
+
text: `✓ ${o.path}: config valid\n`,
|
|
103
|
+
code: EXIT_VALID,
|
|
104
|
+
};
|
|
105
|
+
case "invalid":
|
|
106
|
+
return {
|
|
107
|
+
stream: process.stderr,
|
|
108
|
+
text: o.message + "\n",
|
|
109
|
+
code: EXIT_INVALID,
|
|
110
|
+
};
|
|
111
|
+
case "unreadable":
|
|
112
|
+
return {
|
|
113
|
+
stream: process.stderr,
|
|
114
|
+
text: `lint: cannot read ${o.path}: ${o.message}\n`,
|
|
115
|
+
code: EXIT_USAGE,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function applyLintOutcome(o: LintOutcome): never {
|
|
121
|
+
const plan = lintPlan(o);
|
|
122
|
+
plan.stream.write(plan.text);
|
|
123
|
+
process.exit(plan.code);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Read the build-generated JSON Schema for the config file shape (RawDslConfig),
|
|
127
|
+
// or null when the artifact is absent. Pure read — `runSchema` owns the side
|
|
128
|
+
// effects, so the locate-and-read path is testable.
|
|
129
|
+
export function loadSchemaText(): string | null {
|
|
130
|
+
const schemaPath = locateSchema();
|
|
131
|
+
return schemaPath === null ? null : fs.readFileSync(schemaPath, "utf-8");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// `cc-candybar schema` — print the schema so an editor can annotate a config with
|
|
135
|
+
// `"$schema": "<this output, saved somewhere>"` (or the stable published URL) for
|
|
136
|
+
// autocomplete + structural validation. Emitted from the loader schemas at build
|
|
137
|
+
// time (scripts/gen-schema.ts → emitConfigSchema); served verbatim here (the
|
|
138
|
+
// emitter runs at build, not ship time).
|
|
139
|
+
export function runSchema(): void {
|
|
140
|
+
const text = loadSchemaText();
|
|
141
|
+
if (text === null) {
|
|
142
|
+
process.stderr.write(
|
|
143
|
+
"schema: bundled schema not found (expected schema/cc-candybar.schema.json). " +
|
|
144
|
+
"Run `pnpm gen:schema` from a source checkout.\n",
|
|
145
|
+
);
|
|
146
|
+
process.exit(EXIT_USAGE);
|
|
147
|
+
}
|
|
148
|
+
process.stdout.write(text);
|
|
149
|
+
process.exit(EXIT_VALID);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// [LAW:locality-or-seam] The schema sits at `<package-root>/schema/...`. Anchor
|
|
153
|
+
// on the running CLI entry (argv[1] — `dist/index.mjs` in prod, since every
|
|
154
|
+
// subcommand execs the Node fallback) and walk up to the nearest ancestor that
|
|
155
|
+
// holds the artifact, so the prod and dev layouts aren't special-cased. argv[1]
|
|
156
|
+
// (not import.meta) keeps this resolvable identically under the ESM bundle and
|
|
157
|
+
// any module setting.
|
|
158
|
+
function locateSchema(): string | null {
|
|
159
|
+
const rel = path.join("schema", "cc-candybar.schema.json");
|
|
160
|
+
const anchor = process.argv[1];
|
|
161
|
+
if (anchor === undefined) return null;
|
|
162
|
+
let dir = path.dirname(path.resolve(anchor));
|
|
163
|
+
for (;;) {
|
|
164
|
+
const candidate = path.join(dir, rel);
|
|
165
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
166
|
+
const parent = path.dirname(dir);
|
|
167
|
+
if (parent === dir) return null;
|
|
168
|
+
dir = parent;
|
|
169
|
+
}
|
|
170
|
+
}
|