@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.
Files changed (126) hide show
  1. package/dist/index.mjs +72 -71
  2. package/package.json +5 -6
  3. package/src/check.ts +0 -478
  4. package/src/cli-flags.ts +0 -8
  5. package/src/click/wire.ts +0 -158
  6. package/src/config/action.ts +0 -329
  7. package/src/config/cli.ts +0 -71
  8. package/src/config/default-dsl-config.ts +0 -1645
  9. package/src/config/disclosure.ts +0 -170
  10. package/src/config/dsl-loader.ts +0 -339
  11. package/src/config/dsl-types.ts +0 -581
  12. package/src/config/edit-chrome.ts +0 -559
  13. package/src/config/help.ts +0 -151
  14. package/src/config/ident.ts +0 -22
  15. package/src/config/layout-ops.ts +0 -177
  16. package/src/config/loader/actions.ts +0 -972
  17. package/src/config/loader/cache.ts +0 -206
  18. package/src/config/loader/cross-ref.ts +0 -714
  19. package/src/config/loader/cycles.ts +0 -148
  20. package/src/config/loader/diagnostics.ts +0 -99
  21. package/src/config/loader/discovery.ts +0 -182
  22. package/src/config/loader/edit-mode.ts +0 -137
  23. package/src/config/loader/emit-schema.ts +0 -68
  24. package/src/config/loader/globals.ts +0 -269
  25. package/src/config/loader/helpers.ts +0 -48
  26. package/src/config/loader/layout.ts +0 -693
  27. package/src/config/loader/looks.ts +0 -96
  28. package/src/config/loader/menu-synth.ts +0 -435
  29. package/src/config/loader/merge.ts +0 -115
  30. package/src/config/loader/persist-target.ts +0 -67
  31. package/src/config/loader/presets.ts +0 -119
  32. package/src/config/loader/refs.ts +0 -100
  33. package/src/config/loader/reserved-namespace.ts +0 -38
  34. package/src/config/loader/segments.ts +0 -120
  35. package/src/config/loader/validate-core.ts +0 -737
  36. package/src/config/loader/variables.ts +0 -260
  37. package/src/config/menu-keys.ts +0 -139
  38. package/src/config/option-domain.ts +0 -164
  39. package/src/config/presets.ts +0 -326
  40. package/src/config/settings-menu.ts +0 -775
  41. package/src/daemon/acquire.ts +0 -684
  42. package/src/daemon/cache/git.ts +0 -649
  43. package/src/daemon/cache/render.ts +0 -623
  44. package/src/daemon/cache/session-usage-store.ts +0 -720
  45. package/src/daemon/cache/watchers.ts +0 -249
  46. package/src/daemon/client-debug.ts +0 -120
  47. package/src/daemon/client-stats.ts +0 -130
  48. package/src/daemon/client-transport.ts +0 -273
  49. package/src/daemon/client.ts +0 -78
  50. package/src/daemon/config-overrides-store.ts +0 -663
  51. package/src/daemon/debug-types.ts +0 -91
  52. package/src/daemon/debug.ts +0 -264
  53. package/src/daemon/fork-bomb-breaker.ts +0 -351
  54. package/src/daemon/limits.ts +0 -211
  55. package/src/daemon/log.ts +0 -81
  56. package/src/daemon/parent-watchdog.ts +0 -87
  57. package/src/daemon/paths.ts +0 -211
  58. package/src/daemon/process-fingerprint.ts +0 -146
  59. package/src/daemon/protocol.ts +0 -292
  60. package/src/daemon/render-payload.ts +0 -1256
  61. package/src/daemon/server.ts +0 -1330
  62. package/src/daemon/session-state-file.ts +0 -108
  63. package/src/daemon/session-state.ts +0 -237
  64. package/src/daemon/socket-lease.ts +0 -209
  65. package/src/daemon/socket-ownership.ts +0 -209
  66. package/src/daemon/stats.ts +0 -235
  67. package/src/daemon/verbs/config-validators.ts +0 -250
  68. package/src/daemon/verbs/index.ts +0 -706
  69. package/src/daemon/verbs/state-validators.ts +0 -249
  70. package/src/daemon/verbs/validator-registry.ts +0 -457
  71. package/src/demo/dsl.ts +0 -143
  72. package/src/demo/mock-data.ts +0 -67
  73. package/src/demo/statusline.json5 +0 -94
  74. package/src/dsl/node-registry.ts +0 -374
  75. package/src/dsl/render.ts +0 -803
  76. package/src/help-text.ts +0 -90
  77. package/src/index.ts +0 -210
  78. package/src/install/currency.ts +0 -197
  79. package/src/install/index.ts +0 -557
  80. package/src/proc/launch.ts +0 -459
  81. package/src/proc/stats-handle.ts +0 -13
  82. package/src/render/action.ts +0 -883
  83. package/src/render/active-segment.ts +0 -78
  84. package/src/render/diagnostic-style.ts +0 -23
  85. package/src/render/diagnostic-text.ts +0 -77
  86. package/src/render/error-glyph.ts +0 -53
  87. package/src/render/menu.ts +0 -257
  88. package/src/render/outcome-plan.ts +0 -45
  89. package/src/render/picker.ts +0 -372
  90. package/src/render/segment-color.ts +0 -74
  91. package/src/render/split-lines.ts +0 -51
  92. package/src/render/strip.ts +0 -228
  93. package/src/segments/cache.ts +0 -131
  94. package/src/segments/context.ts +0 -190
  95. package/src/segments/git.ts +0 -1084
  96. package/src/segments/metrics.ts +0 -187
  97. package/src/segments/pricing.ts +0 -452
  98. package/src/segments/session.ts +0 -23
  99. package/src/segments/tmux.ts +0 -74
  100. package/src/template-engine/cells.ts +0 -90
  101. package/src/template-engine/colors.ts +0 -124
  102. package/src/template-engine/engine.ts +0 -108
  103. package/src/template-engine/funcs.ts +0 -232
  104. package/src/template-engine/index.ts +0 -11
  105. package/src/template-engine/layout.ts +0 -133
  106. package/src/template-engine/scope.ts +0 -62
  107. package/src/template-engine/sparkline.ts +0 -79
  108. package/src/themes/index.ts +0 -20
  109. package/src/themes/palette-resolvers.ts +0 -84
  110. package/src/themes/policy.ts +0 -393
  111. package/src/utils/cache.ts +0 -206
  112. package/src/utils/claude.ts +0 -683
  113. package/src/utils/color-support.ts +0 -118
  114. package/src/utils/formatters.ts +0 -99
  115. package/src/utils/logger.ts +0 -5
  116. package/src/utils/outcome.ts +0 -33
  117. package/src/utils/schema-validator.ts +0 -126
  118. package/src/utils/single-flight.ts +0 -57
  119. package/src/utils/terminal-width.ts +0 -51
  120. package/src/utils/terminal.ts +0 -11
  121. package/src/utils/transcript-fs.ts +0 -279
  122. package/src/var-system/index.ts +0 -24
  123. package/src/var-system/sources.ts +0 -1047
  124. package/src/var-system/store.ts +0 -223
  125. package/src/var-system/types.ts +0 -57
  126. package/src/version.ts +0 -17
package/src/help-text.ts DELETED
@@ -1,90 +0,0 @@
1
- import { DISCLOSURE_GLYPH_CLOSED } from "./config/disclosure";
2
- import { HELP_GLYPH_CLOSED } from "./config/help";
3
- import { NODE_FLAGS } from "./cli-flags";
4
-
5
- // [LAW:effects-at-boundaries] Pure data, no I/O — index.ts owns the console.log
6
- // effect. Kept as its own module so the text is importable (and testable) without
7
- // pulling in index.ts's top-level `main()` call.
8
- // [LAW:one-source-of-truth] The disclosure glyph comes from config/disclosure.ts
9
- // (the same constant the theme/look picker itself renders with), so this text
10
- // can't drift from what a user actually sees on the bar.
11
- // [LAW:one-source-of-truth] THE help corpus, as data. `--help` and the bar's
12
- // own `(?)` disclosures are two RENDERINGS of these arrays, never two copies of
13
- // the sentences: a `(?)` segment's template IS one of these strings, and the
14
- // paragraphs below interpolate the same values. A help sentence typed into a
15
- // segment template — where nothing would ever notice it drifting from the CLI's
16
- // wording — is the defect this shape exists to make unrepresentable.
17
- //
18
- // [LAW:representation] One line is one CELL on the bar, so each is a complete
19
- // thought that stands alone and each stays short: `(?)` bodies drop below their
20
- // row, and a body that overflows `term.cols` wraps into more rows than the fact
21
- // it explains is worth. Every line leads with the glyph it explains, so the
22
- // reader matches text to affordance by shape rather than by reading order.
23
- export const EDIT_MODE_HELP = [
24
- "+ inserts here",
25
- "- removes the one left of it",
26
- "↺ undoes edits",
27
- ] as const;
28
-
29
- export const PERSIST_HELP = [
30
- "☐ this session only",
31
- "☑ default for every session",
32
- ] as const;
33
-
34
- export const HELP_TEXT = `
35
- cc-candybar - Beautiful powerline statusline for Claude Code
36
-
37
- Usage: cc-candybar [options]
38
-
39
- Standalone Commands:
40
- ${NODE_FLAGS.help.join(", ").padEnd(25)}Show this help
41
- ${NODE_FLAGS.version.join(", ").padEnd(25)}Print the version of this runtime (cc-candybar <version>)
42
-
43
- Debugging:
44
- CC_CANDYBAR_DEBUG=1 Enable debug logging for troubleshooting
45
-
46
- Configuration:
47
- Layout and segment options are defined in .cc-candybar.json5 (place in your
48
- project dir, cwd, or ~/.config/cc-candybar/config.json5). Use CC_CANDYBAR_CONFIG
49
- to point at a specific file. See the default config for all available options:
50
- node dist/index.mjs debug --project-dir . --cwd .
51
-
52
- Every bar carries a settings menu, whatever your config says — no config
53
- needed, and writing your own \`root\` cannot delete it. Click
54
- ☰ ${DISCLOSURE_GLYPH_CLOSED} on the bar for preset switching, edit mode, and a config menu
55
- of clickable theme/look/style/wrap/padding controls. The \`persist?\`
56
- checkbox there chooses where a change lands: ${PERSIST_HELP.join(", ")}.
57
-
58
- Anywhere the bar shows ${HELP_GLYPH_CLOSED}, clicking it reveals these same instructions
59
- in place. In edit mode: ${EDIT_MODE_HELP.join(", ")}.
60
-
61
- Subcommands:
62
- install One-shot setup: stages the runtime (native render
63
- binary + dist bundle) at a stable path, creates the
64
- URL handler app + cc-candybar:// scheme (macOS), and
65
- writes the staged entry as the statusLine command in
66
- ~/.claude/settings.json. Re-run to update.
67
- install-url-handler Just stage the runtime and create + register the URL
68
- handler app (macOS only).
69
- url-handle URL Internal — invoked by the URL handler app on
70
- cmd-click. Parses cc-candybar://<verb>/<value> and
71
- dispatches (currently: copy to clipboard).
72
- daemon-stats [--json] Query the running daemon for runtime stats:
73
- uptime, RSS, cache hit rates, watcher count,
74
- request totals. Does not spawn a daemon.
75
-
76
- Config tooling:
77
- check [config-file] Validate a config on the full render pipeline (parse
78
- → merge → validate → register → render) with no
79
- daemon. With no path, checks the same file the daemon
80
- would load from here. Exit 0 clean (warnings on
81
- stderr), 1 invalid, 2 unreadable. "lint" is an alias.
82
- schema Print the JSON Schema for the config file shape
83
- (.cc-candybar.json5). Point an editor's $schema at it
84
- for autocomplete + structural validation.
85
- vars [--json] Declared variables: source kind, value, last error.
86
- segments [--json] Segment templates and their last rendered output.
87
- config [--json] The effective merged config. (All three query the
88
- running daemon; none spawn one.)
89
-
90
- `;
package/src/index.ts DELETED
@@ -1,210 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import type { ClaudeHookData } from "./utils/claude";
4
-
5
- import process from "node:process";
6
- import { json } from "node:stream/consumers";
7
- import { debug } from "./utils/logger";
8
- import { runInstall, runInstallUrlHandler, runUrlHandle } from "./install";
9
- import { runDaemon } from "./daemon/server";
10
- import { tryRenderViaDaemon } from "./daemon/client";
11
- import { runDaemonStats } from "./daemon/client-stats";
12
- import { runDebug } from "./daemon/client-debug";
13
- import { isDebugWhat } from "./daemon/debug-types";
14
- import { runSchema } from "./config/cli";
15
- import { runCheck } from "./check";
16
- import { obtainDaemonKick } from "./daemon/acquire";
17
- import { planOutcome } from "./render/outcome-plan";
18
- import { HELP_TEXT } from "./help-text";
19
- import { NODE_FLAGS } from "./cli-flags";
20
- import { PACKAGE_VERSION } from "./version";
21
-
22
- // Read terminal width from the live shell context (no subprocess). Returns
23
- // undefined when nothing reliable is available; the daemon falls back to its
24
- // own pure lookup chain in that case. Always-COLUMNS-first because Bash
25
- // exports it on resize and Claude Code propagates it to hook commands.
26
- // stderr (not stdout) is the TTY-side fallback: when invoked as a Claude
27
- // statusline hook, stdin is the hook JSON pipe and stdout is the captured
28
- // statusline pipe, leaving stderr as the only stream still attached to the
29
- // parent terminal. Mirrors the Rust client's TIOCGWINSZ-on-STDERR_FILENO.
30
- function detectTermCols(): number | undefined {
31
- const env = process.env.COLUMNS;
32
- if (env) {
33
- const n = parseInt(env, 10);
34
- if (!isNaN(n) && n > 0) return n;
35
- }
36
- const cols = process.stderr.columns;
37
- if (cols && cols > 0) return cols;
38
- return undefined;
39
- }
40
-
41
- // The env vars an SSH login shell inherits from sshd. Any one of them present
42
- // and non-empty means this session arrived over the network.
43
- //
44
- // [LAW:one-source-of-truth] This vocabulary is mirrored by the Rust client
45
- // (rust-client/src/main.rs) and diffed by scripts/check-protocol.mjs, which
46
- // anchors on the declaration below — keep it a named const holding string
47
- // literals, or repoint the CHECKS row in the same commit. Both runtimes must
48
- // agree on what "SSH" means or the fast path and the fallback path would
49
- // disagree about the same session.
50
- //
51
- // All three are checked, not just SSH_CONNECTION: SSH_CLIENT is what older
52
- // sshd builds (and the user's git-taculous zsh theme) key on, and SSH_TTY is
53
- // the one that survives some `sudo` env_keep policies. Extra names can only
54
- // widen recall of a fact that is otherwise reported as a plain `false`.
55
- const SSH_ENV_VARS = ["SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY"] as const;
56
-
57
- const hasFlag = (flags: readonly string[]): boolean =>
58
- flags.some((f) => process.argv.includes(f));
59
-
60
- // [LAW:dataflow-not-control-flow] A fold over the vocabulary, not a chain of
61
- // ifs — adding a name is a data edit.
62
- //
63
- // Unlike detectTermCols this is TOTAL: the client reads its own environment, so
64
- // "no SSH var set" is the affirmative answer "local", never a failure to
65
- // determine. It therefore always reports, and the daemon reads an ABSENT `ssh`
66
- // hint as "this client is too old to answer" rather than as "local".
67
- function detectSsh(): boolean {
68
- return SSH_ENV_VARS.some((name) => (process.env[name] ?? "") !== "");
69
- }
70
-
71
- function showHelpText(): void {
72
- console.log(HELP_TEXT);
73
- }
74
-
75
- async function main(): Promise<void> {
76
- try {
77
- if (hasFlag(NODE_FLAGS.help)) {
78
- showHelpText();
79
- process.exit(0);
80
- }
81
- // [LAW:one-type-per-behavior] Answers "what is THIS binary" from the baked
82
- // stamp alone — never a daemon probe, which would fail exactly when the
83
- // flag is most needed (no working daemon). Daemon skew is the stats
84
- // snapshot's `version` field.
85
- if (hasFlag(NODE_FLAGS.version)) {
86
- console.log(`cc-candybar ${PACKAGE_VERSION}`);
87
- process.exit(0);
88
- }
89
-
90
- // [LAW:dataflow-not-control-flow] Subcommand dispatch is data: argv[2]
91
- // selects the handler. Each handler short-circuits via process.exit().
92
- // Default fallthrough = the existing stdin-driven render flow.
93
- const subcommand = process.argv[2];
94
- if (subcommand === "install") {
95
- await runInstall(process.argv.slice(3));
96
- process.exit(0);
97
- }
98
- if (subcommand === "install-url-handler") {
99
- runInstallUrlHandler();
100
- process.exit(0);
101
- }
102
- if (subcommand === "url-handle") {
103
- await runUrlHandle(process.argv[3]);
104
- return;
105
- }
106
- if (subcommand === "daemon") {
107
- runDaemon();
108
- return; // daemon owns its own lifecycle
109
- }
110
- if (subcommand === "daemon-stats") {
111
- await runDaemonStats(process.argv.slice(3));
112
- process.exit(0);
113
- }
114
- // [LAW:one-type-per-behavior] `lint` is an alias of `check` — one config
115
- // verdict, one pipeline, one exit-code contract (0/1/2). check subsumes the
116
- // old lint (same loader, plus register + render coverage).
117
- if (subcommand === "check" || subcommand === "lint") {
118
- runCheck(process.argv.slice(3)); // owns its own exit code (0/1/2)
119
- return;
120
- }
121
- if (subcommand === "schema") {
122
- runSchema(); // owns its own exit code
123
- return;
124
- }
125
- // [LAW:dataflow-not-control-flow] vars/segments/config are ONE handler
126
- // parameterized by `what` — the subcommand name IS the DebugWhat. The guard
127
- // is the canonical list (debug-types), so a new debug projection is reachable
128
- // here with no second-site edit.
129
- if (isDebugWhat(subcommand)) {
130
- await runDebug(subcommand, process.argv.slice(3));
131
- process.exit(0);
132
- }
133
-
134
- if (process.stdin.isTTY === true) {
135
- console.error(`Error: This tool requires input from Claude Code
136
-
137
- cc-candybar is designed to be used as a Claude Code statusLine command.
138
- It reads hook data from stdin and outputs formatted statusline.
139
-
140
- Add to ~/.claude/settings.json:
141
- {
142
- "statusLine": {
143
- "type": "command",
144
- "command": "cc-candybar --style=powerline"
145
- }
146
- }
147
-
148
- Run with --help for more options.
149
-
150
- To test output manually:
151
- echo '{"session_id":"test-session","workspace":{"project_dir":"/path/to/project"},"model":{"id":"claude-sonnet-4-5","display_name":"Claude"}}' | cc-candybar --style=powerline`);
152
- process.exit(1);
153
- }
154
-
155
- debug(`Working directory: ${process.cwd()}`);
156
- debug(`Process args:`, process.argv);
157
-
158
- const hookData = (await json(process.stdin)) as ClaudeHookData;
159
- debug(`Received hook data:`, JSON.stringify(hookData, null, 2));
160
-
161
- if (!hookData) {
162
- console.error("Error: No input data received from stdin");
163
- showHelpText();
164
- process.exit(1);
165
- }
166
-
167
- // [LAW:one-source-of-truth] The daemon is the *only* renderer. The CLI is
168
- // a dumb relay: forward stdin to the daemon, print whatever comes back.
169
- // There is no inline render path — two renderers would drift (the CLI has
170
- // no shared gitService/usageProvider, no per-session state, no warm
171
- // caches). On daemon miss we spawn detached and emit empty output; the
172
- // next status-line refresh hits the warm daemon and renders for real.
173
- //
174
- // [LAW:single-enforcer] Client hints are captured here, in the user's
175
- // shell environment, then trusted by the daemon. The daemon's own env
176
- // reflects whichever shell launched it minutes/hours ago, so it can
177
- // measure neither the active terminal nor whether THIS session came in
178
- // over SSH — only the live client can. One daemon serves a local session
179
- // and an SSH session at the same time, so the answer genuinely differs per
180
- // request.
181
- const outcome = await tryRenderViaDaemon(
182
- hookData,
183
- process.argv,
184
- process.cwd(),
185
- { termCols: detectTermCols(), ssh: detectSsh() },
186
- );
187
- // [LAW:types-are-the-program] Three variants, one per outcome kind. The
188
- // "kick on every failure" pattern was the load-bearing half of the
189
- // 452-corpse spiral (kz8.5) — kicking on `permanent` failures keeps
190
- // respawning a daemon that will refuse the next request identically.
191
- // [LAW:dataflow-not-control-flow] planOutcome maps each variant to a
192
- // plan value (output, kick, debug); the side effects below run against
193
- // the plan in fixed order. Variability lives in the data.
194
- const plan = planOutcome(outcome);
195
- if (plan.debug !== null) {
196
- debug(plan.debug);
197
- }
198
- if (plan.kick) {
199
- obtainDaemonKick();
200
- }
201
- process.stdout.write(plan.output);
202
- process.exit(0);
203
- } catch (error) {
204
- const errorMessage = error instanceof Error ? error.message : String(error);
205
- console.error("Error generating statusline:", errorMessage);
206
- process.exit(1);
207
- }
208
- }
209
-
210
- main();
@@ -1,197 +0,0 @@
1
- // Is the runtime `install` just staged the newest published release? pnpm
2
- // resolves `@latest` through its dlx cache and its release-age gate
3
- // (`minimumReleaseAge`), either of which can hand back an older release with
4
- // no error — so `pnpm dlx @promptctl/cc-candybar@latest install` can stage a
5
- // version several releases behind and report success. This module answers the
6
- // currency question as data: a strict version parser at the border, the
7
- // registry lookup as an `Outcome`, one total fold into a `Currency`, and one
8
- // formatter that describes the report for `runInstall` to perform.
9
-
10
- import { ABSENT, failed, type Outcome } from "../utils/outcome";
11
-
12
- // [LAW:types-are-the-program] A release version is exactly MAJOR.MINOR.PATCH.
13
- // semantic-release on `main` mints nothing else, and the ordering the currency
14
- // verdict needs is total only over that shape — so the parser refuses anything
15
- // else rather than admitting a prerelease it could not order.
16
- export type Version = readonly [major: number, minor: number, patch: number];
17
-
18
- const RELEASE_VERSION = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
19
-
20
- // A parse either produces a value or fails with a reason; "absent" is not a
21
- // shape text can have.
22
- type Parsed<T> = Exclude<Outcome<T>, { kind: "absent" }>;
23
-
24
- // [LAW:parse-dont-validate] The one crossing from text to `Version`. Both the
25
- // baked stamp and the registry's dist-tag pass through here; downstream code
26
- // takes `Version` and never re-checks the shape. The failure is typed, not
27
- // thrown: on either side it flows into the `unchecked` verdict, so the
28
- // advisory check can never take the install down with it.
29
- export function parseReleaseVersion(text: string): Parsed<Version> {
30
- const m = RELEASE_VERSION.exec(text);
31
- return m
32
- ? { kind: "ok", value: [Number(m[1]), Number(m[2]), Number(m[3])] }
33
- : {
34
- kind: "failed",
35
- reason: `"${text}" is not a release version (expected MAJOR.MINOR.PATCH)`,
36
- };
37
- }
38
-
39
- export function formatVersion(v: Version): string {
40
- return v.join(".");
41
- }
42
-
43
- // Lexicographic over the triple: the first differing component decides.
44
- function compareVersions(
45
- [aMajor, aMinor, aPatch]: Version,
46
- [bMajor, bMinor, bPatch]: Version,
47
- ): number {
48
- return aMajor - bMajor || aMinor - bMinor || aPatch - bPatch;
49
- }
50
-
51
- export const REGISTRY_URL = "https://registry.npmjs.org";
52
-
53
- // The check rides at the end of an install; it may not hang one. A registry
54
- // that answers slower than this is reported as unreachable, not waited on.
55
- export const REGISTRY_TIMEOUT_MS = 5_000;
56
-
57
- // [LAW:effects-at-boundaries] The one network effect in the install path. It
58
- // takes `fetch` as a parameter so the pure core above and the tests never
59
- // touch the wire; the caller hands in the global. Every way the lookup can
60
- // fail to answer — refused, timed out, non-2xx, unparseable body, dist-tag not
61
- // a release — collapses to `failed` with its reason preserved, and a registry
62
- // that lists no `latest` tag at all is `absent`. [LAW:no-silent-failure] None
63
- // of those is ever reported as "current".
64
- export async function fetchLatestVersion(
65
- packageName: string,
66
- fetchImpl: typeof fetch,
67
- ): Promise<Outcome<Version>> {
68
- const url = `${REGISTRY_URL}/-/package/${encodeURIComponent(packageName)}/dist-tags`;
69
- try {
70
- const res = await fetchImpl(url, {
71
- signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS),
72
- });
73
- if (!res.ok) {
74
- return failed(`registry responded ${res.status} for ${url}`);
75
- }
76
- const tags = (await res.json()) as { latest?: unknown };
77
- if (typeof tags.latest !== "string") {
78
- return ABSENT;
79
- }
80
- return parseReleaseVersion(tags.latest);
81
- } catch (err) {
82
- return failed(err instanceof Error ? err.message : String(err));
83
- }
84
- }
85
-
86
- // [LAW:types-are-the-program] The verdict. `unchecked` is its own arm rather
87
- // than a `current` with a flag: an install that could not compare has no
88
- // currency, and no consumer may read it as having one. It carries the stamp
89
- // as text because the stamp itself may be what failed to parse.
90
- export type Currency =
91
- | { readonly kind: "current"; readonly installed: Version }
92
- | {
93
- readonly kind: "stale";
94
- readonly installed: Version;
95
- readonly latest: Version;
96
- }
97
- | {
98
- readonly kind: "ahead";
99
- readonly installed: Version;
100
- readonly latest: Version;
101
- }
102
- | {
103
- readonly kind: "unchecked";
104
- readonly installed: string;
105
- readonly reason: string;
106
- };
107
-
108
- // [LAW:dataflow-not-control-flow] One total fold: the stamp, the lookup
109
- // outcome, and the version ordering all flow in as values, and every
110
- // combination lands in exactly one arm.
111
- export function assessCurrency(
112
- stamp: string,
113
- latest: Outcome<Version>,
114
- ): Currency {
115
- const installed = parseReleaseVersion(stamp);
116
- if (installed.kind === "failed") {
117
- return { kind: "unchecked", installed: stamp, reason: installed.reason };
118
- }
119
- switch (latest.kind) {
120
- case "failed":
121
- return { kind: "unchecked", installed: stamp, reason: latest.reason };
122
- case "absent":
123
- return {
124
- kind: "unchecked",
125
- installed: stamp,
126
- reason: "the registry lists no `latest` dist-tag",
127
- };
128
- case "ok": {
129
- const order = compareVersions(installed.value, latest.value);
130
- if (order < 0) {
131
- return {
132
- kind: "stale",
133
- installed: installed.value,
134
- latest: latest.value,
135
- };
136
- }
137
- if (order > 0) {
138
- return {
139
- kind: "ahead",
140
- installed: installed.value,
141
- latest: latest.value,
142
- };
143
- }
144
- return { kind: "current", installed: installed.value };
145
- }
146
- }
147
- }
148
-
149
- // A description of the report, not the report itself: `runInstall` owns the
150
- // write. Warnings go to stderr, confirmations to stdout — the CLI's stream
151
- // contract, so a scripted install can separate the two.
152
- export interface CurrencyReport {
153
- readonly stream: "stdout" | "stderr";
154
- readonly text: string;
155
- }
156
-
157
- // [LAW:no-silent-failure] The stale arm names the cause and the exact command
158
- // that gets the current release now; the unchecked arm says the check was
159
- // skipped and why, and never implies the install is current.
160
- export function currencyReport(
161
- packageName: string,
162
- currency: Currency,
163
- ): CurrencyReport {
164
- switch (currency.kind) {
165
- case "current":
166
- return {
167
- stream: "stdout",
168
- text: `✓ cc-candybar ${formatVersion(currency.installed)} is the latest release.\n`,
169
- };
170
- case "ahead":
171
- return {
172
- stream: "stdout",
173
- text:
174
- `cc-candybar ${formatVersion(currency.installed)} is newer than the registry's latest release ` +
175
- `(${formatVersion(currency.latest)}): an unpublished build.\n`,
176
- };
177
- case "stale": {
178
- const latest = formatVersion(currency.latest);
179
- return {
180
- stream: "stderr",
181
- text:
182
- `⚠ cc-candybar ${formatVersion(currency.installed)} was staged, but the latest release is ${latest}.\n` +
183
- ` pnpm's release-age gate (minimumReleaseAge) and its dlx cache can both\n` +
184
- ` resolve \`@latest\` to an older release without saying so.\n` +
185
- ` To install ${latest} now, name it explicitly:\n` +
186
- ` pnpm dlx ${packageName}@${latest} install\n`,
187
- };
188
- }
189
- case "unchecked":
190
- return {
191
- stream: "stderr",
192
- text:
193
- `⚠ Could not check for a cc-candybar release newer than ${currency.installed} ` +
194
- `(${currency.reason}); the registry check was skipped.\n`,
195
- };
196
- }
197
- }