@theholocron/kyber 5.0.0-alpha.341 → 5.0.0-alpha.342

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/README.md CHANGED
@@ -10,16 +10,17 @@ Published, so a second CLI can import it. `@theholocron/cli` is its first consum
10
10
 
11
11
  ## Subpaths
12
12
 
13
- | Subpath | What it gives a CLI |
14
- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
15
- | `/ui` | Status glyphs and colours, padded status rows, a depth-limited JSON view, a spinner that only shows on a terminal, wait-for-Enter, open-in-file-browser, the error for "a prompt was needed but there is no terminal", and the cleaning of error text before it reaches a terminal (`describeError`, `commandFailureMessage`, `logReason`, `sanitizeDetail`, `stripControl`). |
16
- | `/notifier` | A cached, best-effort "a newer version exists" notice for an npm package: `checkForUpdates({ name, currentVersion, cacheDir })` returns a function that prints a boxed notice to stderr, or `null`. Silent in CI and with `NO_UPDATE_NOTIFIER`. |
17
- | `/secrets` | Handling tokens safely, with no terminal UI: owner-only (`0600`) files, shredding a file that held tokens, "does git ignore this path", dotenv lines that survive quoting, reading a `.env` at the repo root, the clipboard, the OS keyring (`createKeyring({ service })`, best-effort, `@napi-rs/keyring` optional), an in-process store for credentials a provider returned, `--token` / `--token vendor=value` parsing, and resolving a token from the flag, the environment, that store and the keyring (`createResolveToken`, `createFeatureResolver`, `AuthError`). |
18
- | `/prompts` | A searchable menu (`pickMenuItem`), and prompting for the positionals and options a command left out (`promptForPositionals`, `promptForOptions`): required options always asked, the rest behind one checkbox, mutually exclusive `modes`, secret options masked, and a value that is asked last (`askLast`). They take small structural types (`PositionalCommand`, `OptionsCommand`), not the CLI's command registry. |
19
- | `/menu` | The interactive menu of a CLI, driven by data: `launchMenu(entries, argv, { bin, envPrefix })` shows a searchable, nested picker over a list of `MenuEntry`, asks for the positionals and options the command needs, then spawns the CLI again with the answers. A command that supports `--dry-run` is previewed first and then confirmed (`--skip-dry-run` opts out); a preview that reports `DRY_RUN_NOOP_EXIT_CODE` skips the question. Secret options never reach argv: they travel as `<envPrefix>_<OPTION>` env vars, and a preview child gets `dryRunPreviewEnvVar(envPrefix)`. Also `menuItems`, `buildChildArgv`, `forwardedFlags`, `secretEnv`, `shouldPreviewDryRun`. |
20
- | `/testing` | A test harness for a CLI: `createCliRunner` runs a built binary in a fresh directory with an environment built from nothing, `runCommand` runs one yargs command module through a real parse, `ensureBuilt` is a Vitest global setup that builds only when `dist` is missing or stale, and `hermeticCreateRequire` resolves modules only from one directory. |
21
- | `/config` | A `<name>.config.{ts,js,mjs,cjs,json}` for a new CLI: `createConfigSystem({ name, derive?, resolve, merge })` returns `defineConfig`, `loadConfig` (walks up to `.git`) and `compose()` (dedupe by `id`, all unmet `requires` in one `ConfigError`, per-key merge). Strategies: `lastWins`, `shallowMerge`, `union`, `mergeByKey`. `@theholocron/datapad` is an optional peer. |
22
- | `/flags` | The options every command of a tool takes: `addGlobalOptions(parser, { describe?, cwd? })` declares `--dry-run`, a repeatable `--token` and `--cwd` once on the root; `tokenContext(values, report)` parses the `--token` values and reports a bad one without a stack trace; `groupMenu(parent, open)` is the hidden default command that makes a bare group (`tool repo`) do something useful; `InferArgs` types a command's handler arguments from its builder. |
13
+ | Subpath | What it gives a CLI |
14
+ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
15
+ | `/ui` | Status glyphs and colours, padded status rows, a depth-limited JSON view, a spinner that only shows on a terminal (and `createRetryNotice(subscribe)`, which says on it when a request is retried, given the HTTP client's retry hook), wait-for-Enter, open-in-file-browser, the error for "a prompt was needed but there is no terminal", and the cleaning of error text before it reaches a terminal (`describeError`, `commandFailureMessage`, `logReason`, `sanitizeDetail`, `stripControl`). |
16
+ | `/notifier` | A cached, best-effort "a newer version exists" notice for an npm package: `checkForUpdates({ name, currentVersion, cacheDir })` returns a function that prints a boxed notice to stderr, or `null`. Silent in CI and with `NO_UPDATE_NOTIFIER`. |
17
+ | `/secrets` | Handling tokens safely, with no terminal UI: owner-only (`0600`) files, shredding a file that held tokens, "does git ignore this path", dotenv lines that survive quoting, reading a `.env` at the repo root, the clipboard, the OS keyring (`createKeyring({ service })`, best-effort, `@napi-rs/keyring` optional), an in-process store for credentials a provider returned, `--token` / `--token vendor=value` parsing, and resolving a token from the flag, the environment, that store and the keyring (`createResolveToken`, `createFeatureResolver`, `AuthError`). |
18
+ | `/prompts` | A searchable menu (`pickMenuItem`), and prompting for the positionals and options a command left out (`promptForPositionals`, `promptForOptions`): required options always asked, the rest behind one checkbox, mutually exclusive `modes`, secret options masked, and a value that is asked last (`askLast`). They take small structural types (`PositionalCommand`, `OptionsCommand`), not the CLI's command registry. |
19
+ | `/menu` | The interactive menu of a CLI, driven by data: `launchMenu(entries, argv, { bin, envPrefix })` shows a searchable, nested picker over a list of `MenuEntry`, asks for the positionals and options the command needs, then spawns the CLI again with the answers. A command that supports `--dry-run` is previewed first and then confirmed (`--skip-dry-run` opts out); a preview that reports `DRY_RUN_NOOP_EXIT_CODE` skips the question. Secret options never reach argv: they travel as `<envPrefix>_<OPTION>` env vars, and a preview child gets `dryRunPreviewEnvVar(envPrefix)`. `createMenu({ bin, envPrefix })` binds it once (returning `launchMenu(entries, argv, options?)` and `dryRunPreviewEnvVar`). Also `menuItems`, `buildChildArgv`, `forwardedFlags`, `secretEnv`, `shouldPreviewDryRun`. |
20
+ | `/testing` | A test harness for a CLI: `createCliRunner` runs a built binary in a fresh directory with an environment built from nothing, `runCommand` runs one yargs command module through a real parse, `ensureBuilt` is a Vitest global setup that builds only when `dist` is missing or stale, and `hermeticCreateRequire` resolves modules only from one directory. |
21
+ | `/observability` | The logging and telemetry wiring of a CLI: `createCliLogger({ envPrefix, resolveAxiom? })` returns a memoized root logger (`buildCliLogger`, `getLogger`, `reportError`, `getRunId`, `resolveLogLevel`) whose level comes from `--log` / `--verbose` / `--quiet`, then `<PREFIX>_LOG_LEVEL`, then the config file, and is quiet on a terminal unless asked; `createCliTelemetry({ envPrefix, dsn, posthogKey, posthogHost })` returns Sentry error reporting and PostHog analytics behind `<PREFIX>_TELEMETRY=false`. `@theholocron/observability` is an optional peer. |
22
+ | `/config` | A `<name>.config.{ts,js,mjs,cjs,json}` for a new CLI: `createConfigSystem({ name, derive?, resolve, merge })` returns `defineConfig`, `loadConfig` (walks up to `.git`) and `compose()` (dedupe by `id`, all unmet `requires` in one `ConfigError`, per-key merge). Strategies: `lastWins`, `shallowMerge`, `union`, `mergeByKey`. `@theholocron/datapad` is an optional peer. |
23
+ | `/flags` | The options every command of a tool takes: `addGlobalOptions(parser, { describe?, cwd? })` declares `--dry-run`, a repeatable `--token` and `--cwd` once on the root; `tokenContext(values, report)` parses the `--token` values and reports a bad one without a stack trace; `groupMenu(parent, open)` is the hidden default command that makes a bare group (`tool repo`) do something useful; `InferArgs` types a command's handler arguments from its builder. |
23
24
 
24
25
  ## Usage
25
26
 
@@ -49,7 +50,7 @@ const tokens = tokenContext(argv.token, console.error); // null (and exit code 1
49
50
  const keyring = createKeyring({ service: "com.example.tool" }); // best-effort: never throws
50
51
  ```
51
52
 
52
- Each subpath's heavy libraries (`chalk`, `ora`, `@inquirer/prompts`, `yargs`, `dotenv`, `clipboardy`, `@napi-rs/keyring`, `@theholocron/datapad`) are
53
+ Each subpath's heavy libraries (`chalk`, `ora`, `@inquirer/prompts`, `yargs`, `dotenv`, `clipboardy`, `@napi-rs/keyring`, `@theholocron/datapad`, `@theholocron/observability`) are
53
54
  optional peer dependencies: a CLI installs the ones for the subpaths it imports.
54
55
 
55
56
  `createProgress(print, enabled?, listen?)` takes an optional `listen` hook that can change the spinner's text while a step
package/dist/menu.d.mts CHANGED
@@ -75,35 +75,6 @@ interface MenuEntry {
75
75
  * shape identically with no need to know which kind of command it came from.
76
76
  */
77
77
  export declare function buildChildArgv(entry: MenuEntry, positionals: string[], resolved: ResolvedOptions, parentArgv: Record<string, unknown>, dryRunOverride?: boolean): string[];
78
- /**
79
- * Reserved exit code a dry-run-capable handler may use — only when the
80
- * {@link dryRunPreviewEnvVar} is set — to report "ran clean, nothing would
81
- * change." {@link launchMenu} treats it as success but skips the "proceed with
82
- * the real run?" question, since there's nothing for the real run to do
83
- * either.
84
- */
85
- export declare const DRY_RUN_NOOP_EXIT_CODE = 2;
86
- /**
87
- * The env var {@link launchMenu} sets on a dry-run preview child (`<ENV_PREFIX>_DRY_RUN_PREVIEW`), so its
88
- * handler knows this specific `--dry-run` came from the menu's own preview-then-ask flow, not a direct
89
- * `<bin> <cmd> --dry-run` invocation. A handler that supports it uses this — never bare `argv.dryRun` — to
90
- * decide whether to report {@link DRY_RUN_NOOP_EXIT_CODE}: that exit code only has special meaning to
91
- * `launchMenu` itself, so a direct/scripted `--dry-run` call must never produce it — doing so unconditionally
92
- * would silently change the public exit-code contract for a successful dry run.
93
- */
94
- export declare function dryRunPreviewEnvVar(envPrefix: string): string;
95
- /**
96
- * `--token` (repeatable), `--org`, `--cwd`, `--dry-run` — the global flags a
97
- * menu-launched child should inherit from the parent invocation. Everything
98
- * else (command-specific options) was never captured by the top-level `$0`
99
- * handler in the first place, so there's nothing else to forward.
100
- *
101
- * `dryRunOverride`, when passed, wins over `argv.dryRun` — {@link launchMenu}'s
102
- * dry-run offer needs to force `--dry-run` on for the preview spawn and
103
- * force it back off for the real-run spawn that follows, regardless of
104
- * what was on the original invocation.
105
- */
106
- export declare function forwardedFlags(argv: Record<string, unknown>, dryRunOverride?: boolean): string[];
107
78
  /** How {@link launchMenu} is bound to one particular CLI. */
108
79
  interface LaunchMenuConfig {
109
80
  /** The CLI's binary name, used in the default non-interactive hint (`Run \`<bin> --help\` …`). */
@@ -143,6 +114,52 @@ interface LaunchMenuConfig {
143
114
  * @param config - Binds the menu to one CLI: its binary name, env prefix and optional messages.
144
115
  */
145
116
  export declare function launchMenu(entries: MenuEntry[], parentArgv: Record<string, unknown>, config: LaunchMenuConfig): Promise<void>;
117
+ /** The per-call knobs of a bound menu: everything in {@link LaunchMenuConfig} except what {@link createMenu} already fixed. */
118
+ export type BoundMenuOptions = Omit<LaunchMenuConfig, "bin" | "envPrefix">;
119
+ /** A menu engine bound to one CLI by {@link createMenu}. */
120
+ export interface Menu {
121
+ /** {@link launchMenu} with the CLI's `bin` and `envPrefix` already applied. */
122
+ launchMenu: (entries: MenuEntry[], parentArgv: Record<string, unknown>, options?: BoundMenuOptions) => Promise<void>;
123
+ /** The env var a dry-run preview child gets (`<envPrefix>_DRY_RUN_PREVIEW`); see {@link dryRunPreviewEnvVar}. */
124
+ dryRunPreviewEnvVar: string;
125
+ }
126
+ /**
127
+ * Bind the menu engine to one CLI once, so callers stop repeating `bin` and `envPrefix`: the returned
128
+ * `launchMenu(entries, parentArgv, options?)` is {@link launchMenu} with both applied, and `dryRunPreviewEnvVar` is the
129
+ * matching env var name a dry-run preview child receives.
130
+ *
131
+ * @param binding - The CLI's binary name and env prefix.
132
+ */
133
+ export declare function createMenu({ bin, envPrefix }: Pick<LaunchMenuConfig, "bin" | "envPrefix">): Menu;
134
+ /**
135
+ * Reserved exit code a dry-run-capable handler may use — only when the
136
+ * {@link dryRunPreviewEnvVar} is set — to report "ran clean, nothing would
137
+ * change." {@link launchMenu} treats it as success but skips the "proceed with
138
+ * the real run?" question, since there's nothing for the real run to do
139
+ * either.
140
+ */
141
+ export declare const DRY_RUN_NOOP_EXIT_CODE = 2;
142
+ /**
143
+ * The env var {@link launchMenu} sets on a dry-run preview child (`<ENV_PREFIX>_DRY_RUN_PREVIEW`), so its
144
+ * handler knows this specific `--dry-run` came from the menu's own preview-then-ask flow, not a direct
145
+ * `<bin> <cmd> --dry-run` invocation. A handler that supports it uses this — never bare `argv.dryRun` — to
146
+ * decide whether to report {@link DRY_RUN_NOOP_EXIT_CODE}: that exit code only has special meaning to
147
+ * `launchMenu` itself, so a direct/scripted `--dry-run` call must never produce it — doing so unconditionally
148
+ * would silently change the public exit-code contract for a successful dry run.
149
+ */
150
+ export declare function dryRunPreviewEnvVar(envPrefix: string): string;
151
+ /**
152
+ * `--token` (repeatable), `--org`, `--cwd`, `--dry-run` — the global flags a
153
+ * menu-launched child should inherit from the parent invocation. Everything
154
+ * else (command-specific options) was never captured by the top-level `$0`
155
+ * handler in the first place, so there's nothing else to forward.
156
+ *
157
+ * `dryRunOverride`, when passed, wins over `argv.dryRun` — {@link launchMenu}'s
158
+ * dry-run offer needs to force `--dry-run` on for the preview spawn and
159
+ * force it back off for the real-run spawn that follows, regardless of
160
+ * what was on the original invocation.
161
+ */
162
+ export declare function forwardedFlags(argv: Record<string, unknown>, dryRunOverride?: boolean): string[];
146
163
  /** Prefix of a menu item's `value` that opens a submenu rather than running a command. */
147
164
  export declare const GROUP = "group:";
148
165
  /** The "← back" item's value; only offered below the first level. */
package/dist/menu.mjs CHANGED
@@ -75,15 +75,6 @@ function buildChildArgv(entry, positionals, resolved, parentArgv, dryRunOverride
75
75
  ...forwardedFlags(parentArgv, dryRunOverride)
76
76
  ];
77
77
  }
78
- // src/menu/dry-run-noop-exit-code/dry-run-noop-exit-code.ts
79
- /**
80
- * Reserved exit code a dry-run-capable handler may use — only when the
81
- * {@link dryRunPreviewEnvVar} is set — to report "ran clean, nothing would
82
- * change." {@link launchMenu} treats it as success but skips the "proceed with
83
- * the real run?" question, since there's nothing for the real run to do
84
- * either.
85
- */
86
- const DRY_RUN_NOOP_EXIT_CODE = 2;
87
78
  // src/menu/dry-run-preview-env-var/dry-run-preview-env-var.ts
88
79
  /**
89
80
  * The env var {@link launchMenu} sets on a dry-run preview child (`<ENV_PREFIX>_DRY_RUN_PREVIEW`), so its
@@ -96,6 +87,15 @@ const DRY_RUN_NOOP_EXIT_CODE = 2;
96
87
  function dryRunPreviewEnvVar(envPrefix) {
97
88
  return `${envPrefix}_DRY_RUN_PREVIEW`;
98
89
  }
90
+ // src/menu/dry-run-noop-exit-code/dry-run-noop-exit-code.ts
91
+ /**
92
+ * Reserved exit code a dry-run-capable handler may use — only when the
93
+ * {@link dryRunPreviewEnvVar} is set — to report "ran clean, nothing would
94
+ * change." {@link launchMenu} treats it as success but skips the "proceed with
95
+ * the real run?" question, since there's nothing for the real run to do
96
+ * either.
97
+ */
98
+ const DRY_RUN_NOOP_EXIT_CODE = 2;
99
99
  // src/menu/menu-items/menu-items.ts
100
100
  /** Prefix of a menu item's `value` that opens a submenu rather than running a command. */
101
101
  const GROUP = "group:";
@@ -301,4 +301,22 @@ async function launchMenu(entries, parentArgv, config) {
301
301
  const forReal = await resolveForRealSpawn(resolvedOptions);
302
302
  process.exitCode = await spawnChild(buildChildArgv(picked, positionals, forReal, parentArgv), secretEnv(forReal, envPrefix));
303
303
  }
304
- export { BACK, DRY_RUN_NOOP_EXIT_CODE, GROUP, buildChildArgv, dryRunPreviewEnvVar, forwardedFlags, launchMenu, menuItems, secretEnv, shouldPreviewDryRun };
304
+ // src/menu/create-menu/create-menu.ts
305
+ /**
306
+ * Bind the menu engine to one CLI once, so callers stop repeating `bin` and `envPrefix`: the returned
307
+ * `launchMenu(entries, parentArgv, options?)` is {@link launchMenu} with both applied, and `dryRunPreviewEnvVar` is the
308
+ * matching env var name a dry-run preview child receives.
309
+ *
310
+ * @param binding - The CLI's binary name and env prefix.
311
+ */
312
+ function createMenu({ bin, envPrefix }) {
313
+ return {
314
+ launchMenu: (entries, parentArgv, options = {}) => launchMenu(entries, parentArgv, {
315
+ bin,
316
+ envPrefix,
317
+ ...options
318
+ }),
319
+ dryRunPreviewEnvVar: dryRunPreviewEnvVar(envPrefix)
320
+ };
321
+ }
322
+ export { BACK, DRY_RUN_NOOP_EXIT_CODE, GROUP, buildChildArgv, createMenu, dryRunPreviewEnvVar, forwardedFlags, launchMenu, menuItems, secretEnv, shouldPreviewDryRun };
@@ -0,0 +1,156 @@
1
+ import { AxiomTransportConfig } from "@theholocron/observability/logger";
2
+ import { LogLevel, Logger } from "@theholocron/observability/core";
3
+ /** The global logging flags a CLI parses (`--log`, `--verbose`, `--quiet`). */
4
+ interface LogFlags {
5
+ verbose?: boolean;
6
+ quiet?: boolean;
7
+ log?: LogLevel;
8
+ }
9
+ /** What a handler can supply once the CLI's config file is loaded. */
10
+ interface BuildCliLoggerOpts {
11
+ /** Active command name, bound on the root as `command` for every line. */
12
+ command?: string;
13
+ /** The config file's log level, from a handler that has loaded a config. */
14
+ configLevel?: LogLevel;
15
+ /** The config file's non-secret Axiom dataset name. */
16
+ configAxiomDataset?: string;
17
+ /** The resolved org, for a namespaced credential lookup. */
18
+ org?: string;
19
+ }
20
+ /** The command name in effect, resolved for {@link CreateCliLoggerOptions.resolveAxiom}. */
21
+ interface CliLoggerContext {
22
+ /** `opts.command`, else the command name remembered from an earlier build. */
23
+ command: string | undefined;
24
+ }
25
+ interface CreateCliLoggerOptions {
26
+ /** Upper-case env prefix of the CLI, e.g. `"HOLOCRON"` (reads `<PREFIX>_LOG_LEVEL`). */
27
+ envPrefix: string;
28
+ /**
29
+ * Resolve Axiom credentials for a build. Defaults to the package's
30
+ * `resolveAxiomFromEnv`. Return `undefined` to ship nowhere.
31
+ */
32
+ resolveAxiom?: (opts: BuildCliLoggerOpts, ctx: CliLoggerContext) => AxiomTransportConfig | undefined;
33
+ }
34
+ /** The bound logging functions a CLI re-exports. */
35
+ interface CliLogger {
36
+ /**
37
+ * Build (or return the memoized) process-wide root logger. Rebuilt at most
38
+ * once more when a handler supplies config the flag/env-only first pass could
39
+ * not have known (the level, or a new Axiom dataset).
40
+ */
41
+ buildCliLogger: (argv: LogFlags, opts?: BuildCliLoggerOpts) => {
42
+ logger: Logger;
43
+ runId: string;
44
+ };
45
+ /** Lazily-memoized root `Logger` for call sites with no `argv` in scope. */
46
+ getLogger: () => Logger;
47
+ /** Print a fatal error with `style.fail` and also log it at error level. */
48
+ reportError: (message: string, log?: Logger) => void;
49
+ /** The current root logger's correlation id, if a root has been built. */
50
+ getRunId: () => string | undefined;
51
+ /** Test hook: drop the memoized root so the next call rebuilds it. */
52
+ resetCliLogger: () => void;
53
+ /** {@link resolveLogLevel} bound to this CLI's env prefix. */
54
+ resolveLogLevel: (argv: LogFlags, configLevel?: LogLevel) => LogLevel | undefined;
55
+ }
56
+ /**
57
+ * Create a CLI's logging functions around a memoized root `Logger`.
58
+ *
59
+ * Level comes from `--log` / `--verbose` / `--quiet`, then
60
+ * `<PREFIX>_LOG_LEVEL`, then the config file. Console output is quiet unless
61
+ * CI or a flag asks for it. `--quiet` and `--log` also turn the progress
62
+ * spinner off (`allowProgress`), as they own the terminal.
63
+ *
64
+ * @example
65
+ * const { buildCliLogger, getLogger, reportError } = createCliLogger({ envPrefix: "ACME" });
66
+ */
67
+ export declare function createCliLogger(options: CreateCliLoggerOptions): CliLogger;
68
+ /** The `telemetry` block of a CLI's config file: the committed override layer. */
69
+ interface TelemetryConfig {
70
+ /** `false` installs the no-op sinks for the whole run. Default on. */
71
+ enabled?: boolean;
72
+ /** `"none"` disables usage analytics but leaves error reporting on. Default `"posthog"`. */
73
+ analytics?: "posthog" | "none";
74
+ }
75
+ interface CreateCliTelemetryOptions {
76
+ /**
77
+ * Upper-case env prefix of the CLI, e.g. `"HOLOCRON"`. Reads
78
+ * `<PREFIX>_TELEMETRY` (`false` opts out), the legacy `NO_<PREFIX>_TELEMETRY`
79
+ * and `<PREFIX>_ORG`; the Sentry release is `<prefix lower-cased>@<version>`.
80
+ */
81
+ envPrefix: string;
82
+ /** Resolve the Sentry DSN at `init` time. `""` disables error reporting. */
83
+ dsn: () => string;
84
+ /** Resolve the PostHog project key at `init` time. `""` disables usage analytics. */
85
+ posthogKey: () => string;
86
+ /** Resolve the PostHog ingest host at `init` time. */
87
+ posthogHost: () => string;
88
+ /** The logger's correlation id, attached to events as `runId`. */
89
+ getRunId?: () => string | undefined;
90
+ }
91
+ /** The bound telemetry functions a CLI re-exports. */
92
+ interface CliTelemetry {
93
+ /** Install the live sinks unless the opt-out is set or a credential is empty. */
94
+ init: (version: string) => void;
95
+ /** Apply the config block: it only ever narrows what `init` turned on. */
96
+ applyConfig: (cfg: TelemetryConfig | undefined) => void;
97
+ /** Capture a usage event; properties run through the secret scrubber. */
98
+ event: (name: string, properties?: Record<string, unknown>) => void;
99
+ /** Start a command span; call the returned function with the outcome. */
100
+ startCommand: (name: string) => (ok: boolean) => void;
101
+ /** Report an error to the error sink. */
102
+ captureException: (err: unknown) => void;
103
+ /** End the error-reporting session. */
104
+ endSession: () => void;
105
+ /** Drain both sinks and return to the no-ops. */
106
+ flush: () => Promise<void>;
107
+ /** Test hook: drop sinks and command state back to the no-op defaults. */
108
+ resetTelemetry: () => void;
109
+ }
110
+ /**
111
+ * Create a CLI's self-telemetry: error reporting (Sentry) and usage analytics
112
+ * (PostHog) behind the `ErrorSink` / `AnalyticsSink` seam of
113
+ * `@theholocron/observability`. No-op sinks are installed until `init` finds
114
+ * telemetry enabled, and the single kill switch is `<PREFIX>_TELEMETRY=false`
115
+ * (legacy alias `NO_<PREFIX>_TELEMETRY`). The credentials are passed in, since
116
+ * this reads no environment for them.
117
+ *
118
+ * @example
119
+ * const telemetry = createCliTelemetry({
120
+ * envPrefix: "ACME",
121
+ * dsn: () => "https://key@o1.ingest.sentry.io/1",
122
+ * posthogKey: () => "phc_key",
123
+ * posthogHost: () => "https://us.i.posthog.com",
124
+ * });
125
+ */
126
+ export declare function createCliTelemetry(options: CreateCliTelemetryOptions): CliTelemetry;
127
+ /**
128
+ * Resolve whether human-visible console output (pretty or NDJSON) should run
129
+ * at all, independent of level: the `@theholocron/observability`
130
+ * `consoleOutput` override.
131
+ *
132
+ * Local and interactive runs are quiet by default: structured logger output
133
+ * stays off regardless of level, unless the user asked to see it with
134
+ * `--log <level>`, `--verbose` or `--quiet`. In CI it returns `undefined`,
135
+ * which keeps `createLogger`'s own `ci`/`tty` auto-detection untouched.
136
+ *
137
+ * @param argv - The parsed global logging flags.
138
+ */
139
+ export declare function resolveConsoleOutput(argv: LogFlags): boolean | undefined;
140
+ /**
141
+ * Resolve the explicit level to hand to `createLogger`, in priority order:
142
+ *
143
+ * 1. `--log <level>` 2. `--verbose` → `"debug"` 3. `--quiet` → `"error"`
144
+ * 4. `<PREFIX>_LOG_LEVEL` env var
145
+ * 5. the config file's log level (`configLevel`)
146
+ *
147
+ * Returns `undefined` when nothing applies, so `createLogger` falls back to
148
+ * `"info"`. Resolving the whole chain here (rather than passing `configLevel`
149
+ * straight through) keeps config below the env var.
150
+ *
151
+ * @param envPrefix - Upper-case env prefix of the CLI, e.g. `"HOLOCRON"`.
152
+ * @param argv - The parsed global logging flags.
153
+ * @param configLevel - The level from the CLI's config file, if one was loaded.
154
+ */
155
+ export declare function resolveLogLevel(envPrefix: string, argv: LogFlags, configLevel?: LogLevel): LogLevel | undefined;
156
+ export type { BuildCliLoggerOpts, CliLogger, CliLoggerContext, CliTelemetry, CreateCliLoggerOptions, CreateCliTelemetryOptions, LogFlags, TelemetryConfig };
@@ -0,0 +1,254 @@
1
+ import { allowProgress, style } from "./ui.mjs";
2
+ import { createLogger, isCI, parseLogLevel, resolveAxiomFromEnv } from "@theholocron/observability/logger";
3
+ import { createEnvLookup } from "@theholocron/env-utils/lookup";
4
+ import { createHash } from "node:crypto";
5
+ import { hostname, userInfo } from "node:os";
6
+ import { PostHogSink } from "@theholocron/observability/analytics";
7
+ import { NoopAnalyticsSink, NoopErrorSink, redactObject } from "@theholocron/observability/core";
8
+ import { SentrySink } from "@theholocron/observability/errors";
9
+ // src/observability/resolve-console-output/resolve-console-output.ts
10
+ /**
11
+ * Resolve whether human-visible console output (pretty or NDJSON) should run
12
+ * at all, independent of level: the `@theholocron/observability`
13
+ * `consoleOutput` override.
14
+ *
15
+ * Local and interactive runs are quiet by default: structured logger output
16
+ * stays off regardless of level, unless the user asked to see it with
17
+ * `--log <level>`, `--verbose` or `--quiet`. In CI it returns `undefined`,
18
+ * which keeps `createLogger`'s own `ci`/`tty` auto-detection untouched.
19
+ *
20
+ * @param argv - The parsed global logging flags.
21
+ */
22
+ function resolveConsoleOutput(argv) {
23
+ if (argv.log || argv.verbose || argv.quiet) return true;
24
+ return isCI() ? void 0 : false;
25
+ }
26
+ // src/observability/resolve-log-level/resolve-log-level.ts
27
+ const env$1 = createEnvLookup();
28
+ /**
29
+ * Resolve the explicit level to hand to `createLogger`, in priority order:
30
+ *
31
+ * 1. `--log <level>` 2. `--verbose` → `"debug"` 3. `--quiet` → `"error"`
32
+ * 4. `<PREFIX>_LOG_LEVEL` env var
33
+ * 5. the config file's log level (`configLevel`)
34
+ *
35
+ * Returns `undefined` when nothing applies, so `createLogger` falls back to
36
+ * `"info"`. Resolving the whole chain here (rather than passing `configLevel`
37
+ * straight through) keeps config below the env var.
38
+ *
39
+ * @param envPrefix - Upper-case env prefix of the CLI, e.g. `"HOLOCRON"`.
40
+ * @param argv - The parsed global logging flags.
41
+ * @param configLevel - The level from the CLI's config file, if one was loaded.
42
+ */
43
+ function resolveLogLevel(envPrefix, argv, configLevel) {
44
+ if (argv.log) return argv.log;
45
+ if (argv.verbose) return "debug";
46
+ if (argv.quiet) return "error";
47
+ return parseLogLevel(env$1.get(`${envPrefix}_LOG_LEVEL`)) ?? configLevel;
48
+ }
49
+ // src/observability/create-cli-logger/create-cli-logger.ts
50
+ /**
51
+ * Create a CLI's logging functions around a memoized root `Logger`.
52
+ *
53
+ * Level comes from `--log` / `--verbose` / `--quiet`, then
54
+ * `<PREFIX>_LOG_LEVEL`, then the config file. Console output is quiet unless
55
+ * CI or a flag asks for it. `--quiet` and `--log` also turn the progress
56
+ * spinner off (`allowProgress`), as they own the terminal.
57
+ *
58
+ * @example
59
+ * const { buildCliLogger, getLogger, reportError } = createCliLogger({ envPrefix: "ACME" });
60
+ */
61
+ function createCliLogger(options) {
62
+ const { envPrefix, resolveAxiom = () => resolveAxiomFromEnv() } = options;
63
+ let root;
64
+ let rootLevel;
65
+ let rootCommand;
66
+ let rootAxiomKey;
67
+ const levelFor = (argv, configLevel) => resolveLogLevel(envPrefix, argv, configLevel);
68
+ function buildCliLogger(argv, opts = {}) {
69
+ const { command, configLevel } = opts;
70
+ allowProgress(!argv.quiet && !argv.log);
71
+ if (command) rootCommand = command;
72
+ const level = levelFor(argv, configLevel);
73
+ const consoleOutput = resolveConsoleOutput(argv);
74
+ const axiom = resolveAxiom(opts, { command: command ?? rootCommand });
75
+ const axiomKey = axiom?.dataset;
76
+ const rebuildForConfig = configLevel !== void 0 && level !== rootLevel && !argv.verbose && !argv.quiet && !argv.log;
77
+ if (!root || rebuildForConfig || axiomKey !== void 0 && axiomKey !== rootAxiomKey) {
78
+ const built = createLogger({
79
+ ...level ? { level } : {},
80
+ ...axiom ? { axiom } : {},
81
+ ...consoleOutput !== void 0 ? { consoleOutput } : {}
82
+ });
83
+ root = {
84
+ logger: rootCommand ? built.logger.child({ command: rootCommand }) : built.logger,
85
+ runId: built.runId
86
+ };
87
+ rootLevel = level;
88
+ rootAxiomKey = axiomKey;
89
+ }
90
+ return root;
91
+ }
92
+ function getLogger() {
93
+ return (root ??= createLogger({ consoleOutput: resolveConsoleOutput({}) })).logger;
94
+ }
95
+ return {
96
+ buildCliLogger,
97
+ getLogger,
98
+ reportError(message, log = getLogger()) {
99
+ console.error(style.fail(message));
100
+ log.error(message);
101
+ },
102
+ getRunId: () => root?.runId,
103
+ resetCliLogger() {
104
+ root = void 0;
105
+ rootLevel = void 0;
106
+ rootCommand = void 0;
107
+ rootAxiomKey = void 0;
108
+ },
109
+ resolveLogLevel: levelFor
110
+ };
111
+ }
112
+ // src/observability/create-cli-telemetry/create-cli-telemetry.ts
113
+ const env = createEnvLookup();
114
+ /**
115
+ * Anonymous, stable per-machine id: `sha256(hostname + username)` truncated.
116
+ * One-way, so no raw hostname or username is ever transmitted.
117
+ */
118
+ function machineId() {
119
+ try {
120
+ return createHash("sha256").update(`${hostname()} ${userInfo().username}`).digest("hex").slice(0, 32);
121
+ } catch {
122
+ return "unknown";
123
+ }
124
+ }
125
+ /**
126
+ * Create a CLI's self-telemetry: error reporting (Sentry) and usage analytics
127
+ * (PostHog) behind the `ErrorSink` / `AnalyticsSink` seam of
128
+ * `@theholocron/observability`. No-op sinks are installed until `init` finds
129
+ * telemetry enabled, and the single kill switch is `<PREFIX>_TELEMETRY=false`
130
+ * (legacy alias `NO_<PREFIX>_TELEMETRY`). The credentials are passed in, since
131
+ * this reads no environment for them.
132
+ *
133
+ * @example
134
+ * const telemetry = createCliTelemetry({
135
+ * envPrefix: "ACME",
136
+ * dsn: () => "https://key@o1.ingest.sentry.io/1",
137
+ * posthogKey: () => "phc_key",
138
+ * posthogHost: () => "https://us.i.posthog.com",
139
+ * });
140
+ */
141
+ function createCliTelemetry(options) {
142
+ const { envPrefix, dsn, posthogKey, posthogHost, getRunId = () => void 0 } = options;
143
+ let errors = new NoopErrorSink();
144
+ let analytics = new NoopAnalyticsSink();
145
+ let commandStartedAt = 0;
146
+ let currentCommand = "unknown";
147
+ let lastErrorType;
148
+ let currentSpan;
149
+ const isEnabled = () => env.get(`${envPrefix}_TELEMETRY`) !== "false" && !env.get(`NO_${envPrefix}_TELEMETRY`);
150
+ function baseProps() {
151
+ const runId = getRunId();
152
+ return {
153
+ command: currentCommand,
154
+ ci: Boolean(env.get("CI")),
155
+ ...runId ? { runId } : {}
156
+ };
157
+ }
158
+ function event(name, properties = {}) {
159
+ analytics.capture(machineId(), name, redactObject({
160
+ ...baseProps(),
161
+ ...properties
162
+ }));
163
+ }
164
+ function resetSinks() {
165
+ errors = new NoopErrorSink();
166
+ analytics = new NoopAnalyticsSink();
167
+ }
168
+ return {
169
+ init(version) {
170
+ const resolvedDsn = dsn();
171
+ if (isEnabled() && resolvedDsn !== "") {
172
+ errors = new SentrySink();
173
+ errors.init({
174
+ dsn: resolvedDsn,
175
+ release: `${envPrefix.toLowerCase()}@${version}`,
176
+ environment: env.get("CI") ? "ci" : "local",
177
+ tags: {
178
+ os: process.platform,
179
+ node: process.version,
180
+ ci: String(Boolean(env.get("CI")))
181
+ }
182
+ });
183
+ }
184
+ const resolvedKey = posthogKey();
185
+ if (isEnabled() && resolvedKey !== "") {
186
+ analytics = new PostHogSink({
187
+ key: resolvedKey,
188
+ host: posthogHost()
189
+ });
190
+ const org = env.get(`${envPrefix}_ORG`);
191
+ analytics.identify(machineId(), {
192
+ ci: Boolean(env.get("CI")),
193
+ os: process.platform,
194
+ node: process.version,
195
+ cli: version,
196
+ ...org ? { org } : {}
197
+ });
198
+ }
199
+ },
200
+ applyConfig(cfg) {
201
+ if (!cfg) return;
202
+ const drain = (p) => {
203
+ p.catch(() => {});
204
+ };
205
+ if (cfg.enabled === false) {
206
+ drain(errors.flush());
207
+ drain(analytics.shutdown());
208
+ resetSinks();
209
+ return;
210
+ }
211
+ if (cfg.analytics === "none") {
212
+ drain(analytics.shutdown());
213
+ analytics = new NoopAnalyticsSink();
214
+ }
215
+ },
216
+ event,
217
+ startCommand(name) {
218
+ currentCommand = name;
219
+ commandStartedAt = Date.now();
220
+ lastErrorType = void 0;
221
+ currentSpan = errors.startSpan(name);
222
+ event("command_started", { dry_run: process.argv.includes("--dry-run") });
223
+ return (ok) => {
224
+ currentSpan?.setStatus(ok);
225
+ currentSpan?.end();
226
+ event(ok ? "command_completed" : "command_failed", {
227
+ status: ok ? "ok" : "fail",
228
+ duration_ms: Date.now() - commandStartedAt,
229
+ ...ok || !lastErrorType ? {} : { error_type: lastErrorType }
230
+ });
231
+ };
232
+ },
233
+ captureException(err) {
234
+ lastErrorType = err instanceof Error ? err.constructor.name : "Error";
235
+ errors.captureException(err);
236
+ },
237
+ endSession() {
238
+ errors.endSession();
239
+ },
240
+ async flush() {
241
+ await errors.flush();
242
+ await analytics.shutdown();
243
+ resetSinks();
244
+ },
245
+ resetTelemetry() {
246
+ resetSinks();
247
+ commandStartedAt = 0;
248
+ currentCommand = "unknown";
249
+ lastErrorType = void 0;
250
+ currentSpan = void 0;
251
+ }
252
+ };
253
+ }
254
+ export { createCliLogger, createCliTelemetry, resolveConsoleOutput, resolveLogLevel };
package/dist/testing.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  import { execFileSync, spawn } from "node:child_process";
2
2
  import { existsSync, readdirSync, realpathSync, statSync } from "node:fs";
3
3
  import { dirname, join, sep } from "node:path";
4
- import { mkdtemp, rm, writeFile } from "node:fs/promises";
5
4
  import { tmpdir } from "node:os";
5
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import yargs from "yargs";
8
8
  // src/testing/cli-runner/cli-runner.ts
package/dist/ui.d.mts CHANGED
@@ -132,6 +132,23 @@ export declare function progressEnabled(): boolean;
132
132
  export declare function createProgress(print: (line: string) => void, enabled?: () => boolean, listen?: ProgressListener): Progress;
133
133
  /** `fn` under a cleared-when-done spinner named `label`, on a terminal only. */
134
134
  export declare function withProgress<T>(label: string, fn: () => Promise<T>, listen?: ProgressListener): Promise<T>;
135
+ /** What {@link createRetryNotice} reads from one retry. Structurally compatible with `@theholocron/http-client`'s `RetryEvent`. */
136
+ interface RetryNoticeEvent {
137
+ /** The service being called, when the client knows it. */
138
+ vendor?: string | undefined;
139
+ /** The attempt about to be made. */
140
+ attempt: number;
141
+ /** How many attempts there are in all. */
142
+ of: number;
143
+ }
144
+ /** Registers a handler for retries and returns a function that removes it. */
145
+ type RetrySubscribe = (handler: (event: RetryNoticeEvent) => void) => () => void;
146
+ /**
147
+ * Builds a {@link ProgressListener} that says on the spinner when a request is being sent again, so a slow step is
148
+ * not mistaken for a stuck one. `subscribe` is the HTTP client's retry hook (e.g. `onRetry`), which keeps kyber free
149
+ * of any client dependency.
150
+ */
151
+ export declare function createRetryNotice(subscribe: RetrySubscribe): ProgressListener;
135
152
  /**
136
153
  * {@link stripControl}, then cut to 200 characters with an ellipsis. For a vendor's response body or any other text of
137
154
  * unknown size that lands in the terminal.
@@ -185,4 +202,4 @@ export declare const style: {
185
202
  * streams are parameters so a test can answer for the user.
186
203
  */
187
204
  export declare function waitForEnter(message: string, input?: NodeJS.ReadableStream, output?: NodeJS.WritableStream): Promise<"enter" | "closed">;
188
- export type { Container, Key, Line, Path, Progress, ProgressListener, RenderOptions, RowStatus, ViewJsonOptions, ViewState };
205
+ export type { Container, Key, Line, Path, Progress, ProgressListener, RenderOptions, RetryNoticeEvent, RetrySubscribe, RowStatus, ViewJsonOptions, ViewState };
package/dist/ui.mjs CHANGED
@@ -391,6 +391,17 @@ function createProgress(print, enabled = progressEnabled, listen) {
391
391
  function withProgress(label, fn, listen) {
392
392
  return createProgress(() => {}, progressEnabled, listen).run(label, fn);
393
393
  }
394
+ // src/ui/retry-notice/retry-notice.ts
395
+ /**
396
+ * Builds a {@link ProgressListener} that says on the spinner when a request is being sent again, so a slow step is
397
+ * not mistaken for a stuck one. `subscribe` is the HTTP client's retry hook (e.g. `onRetry`), which keeps kyber free
398
+ * of any client dependency.
399
+ */
400
+ function createRetryNotice(subscribe) {
401
+ return (label, setText) => subscribe((e) => {
402
+ setText(`${label} (retrying ${e.vendor ?? "the request"}, attempt ${e.attempt} of ${e.of})`);
403
+ });
404
+ }
394
405
  // src/ui/style/style.ts
395
406
  /** Formats a message with a status marker and colour: one helper per kind of line a command prints. */
396
407
  const style = {
@@ -453,4 +464,4 @@ function waitForEnter(message, input = process.stdin, output = process.stderr) {
453
464
  });
454
465
  });
455
466
  }
456
- export { NonInteractiveError, allowProgress, commandFailureMessage, createProgress, describeError, expandToDepth, logReason, openPath, pad, progressEnabled, reduceKey, renderTree, sanitizeDetail, setProgressListener, statusRow, stripControl, style, viewJson, waitForEnter, withProgress, withSpinner };
467
+ export { NonInteractiveError, allowProgress, commandFailureMessage, createProgress, createRetryNotice, describeError, expandToDepth, logReason, openPath, pad, progressEnabled, reduceKey, renderTree, sanitizeDetail, setProgressListener, statusRow, stripControl, style, viewJson, waitForEnter, withProgress, withSpinner };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theholocron/kyber",
3
- "version": "5.0.0-alpha.341",
3
+ "version": "5.0.0-alpha.342",
4
4
  "description": "The reusable parts of a command-line tool: terminal output, prompts, the interactive menu, the update notifier, safe handling of tokens, a test harness and the global flags.",
5
5
  "keywords": [
6
6
  "cli",
@@ -40,6 +40,11 @@
40
40
  "import": "./dist/notifier.mjs",
41
41
  "default": "./dist/notifier.mjs"
42
42
  },
43
+ "./observability": {
44
+ "types": "./dist/observability.d.mts",
45
+ "import": "./dist/observability.mjs",
46
+ "default": "./dist/observability.mjs"
47
+ },
43
48
  "./prompts": {
44
49
  "types": "./dist/prompts.d.mts",
45
50
  "import": "./dist/prompts.mjs",
@@ -64,6 +69,9 @@
64
69
  "files": [
65
70
  "dist"
66
71
  ],
72
+ "dependencies": {
73
+ "@theholocron/env-utils": "^1.17.0-alpha.1"
74
+ },
67
75
  "devDependencies": {
68
76
  "@inquirer/prompts": "^8.7.5",
69
77
  "@inquirer/search": "^4.3.6",
@@ -82,11 +90,12 @@
82
90
  "typescript": "^5.9.3",
83
91
  "vitest": "^5.0.5",
84
92
  "yargs": "^18.1.0",
85
- "@theholocron/datapad": "5.0.0-alpha.341",
93
+ "@theholocron/tsconfig": "^8.9.0-alpha.8",
94
+ "@theholocron/eslint-config": "^8.9.0-alpha.8",
95
+ "@theholocron/observability": "0.7.0-alpha.1",
86
96
  "@theholocron/tsdown-config": "^8.9.0-alpha.8",
87
97
  "@theholocron/vitest-config": "^8.9.0-alpha.8",
88
- "@theholocron/eslint-config": "^8.9.0-alpha.8",
89
- "@theholocron/tsconfig": "^8.9.0-alpha.8"
98
+ "@theholocron/datapad": "5.0.0-alpha.342"
90
99
  },
91
100
  "peerDependencies": {
92
101
  "@inquirer/prompts": "^8.0.0",
@@ -97,7 +106,8 @@
97
106
  "dotenv": "^17.0.0",
98
107
  "ora": "^9.0.0",
99
108
  "yargs": "^18.0.0",
100
- "@theholocron/datapad": "^5.0.0-alpha.341"
109
+ "@theholocron/datapad": "^5.0.0-alpha.342",
110
+ "@theholocron/observability": "^0.7.0-alpha.1"
101
111
  },
102
112
  "peerDependenciesMeta": {
103
113
  "@inquirer/prompts": {
@@ -112,6 +122,9 @@
112
122
  "@theholocron/datapad": {
113
123
  "optional": true
114
124
  },
125
+ "@theholocron/observability": {
126
+ "optional": true
127
+ },
115
128
  "chalk": {
116
129
  "optional": true
117
130
  },