@graphorin/cli 0.6.1 → 0.7.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 (87) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +3 -3
  3. package/dist/bin/graphorin.js +51 -13
  4. package/dist/bin/graphorin.js.map +1 -1
  5. package/dist/commands/audit.d.ts.map +1 -1
  6. package/dist/commands/audit.js +2 -1
  7. package/dist/commands/audit.js.map +1 -1
  8. package/dist/commands/consolidator.d.ts +56 -1
  9. package/dist/commands/consolidator.d.ts.map +1 -1
  10. package/dist/commands/consolidator.js +88 -2
  11. package/dist/commands/consolidator.js.map +1 -1
  12. package/dist/commands/doctor.d.ts.map +1 -1
  13. package/dist/commands/index.d.ts +4 -4
  14. package/dist/commands/index.js +4 -4
  15. package/dist/commands/init.d.ts +11 -4
  16. package/dist/commands/init.d.ts.map +1 -1
  17. package/dist/commands/init.js +15 -11
  18. package/dist/commands/init.js.map +1 -1
  19. package/dist/commands/memory.d.ts +26 -1
  20. package/dist/commands/memory.d.ts.map +1 -1
  21. package/dist/commands/memory.js +56 -3
  22. package/dist/commands/memory.js.map +1 -1
  23. package/dist/commands/pricing.d.ts +6 -0
  24. package/dist/commands/pricing.d.ts.map +1 -1
  25. package/dist/commands/pricing.js +5 -2
  26. package/dist/commands/pricing.js.map +1 -1
  27. package/dist/commands/secrets.d.ts.map +1 -1
  28. package/dist/commands/secrets.js +2 -2
  29. package/dist/commands/secrets.js.map +1 -1
  30. package/dist/commands/skills.d.ts.map +1 -1
  31. package/dist/commands/skills.js +1 -1
  32. package/dist/commands/skills.js.map +1 -1
  33. package/dist/commands/storage.d.ts +41 -1
  34. package/dist/commands/storage.d.ts.map +1 -1
  35. package/dist/commands/storage.js +75 -1
  36. package/dist/commands/storage.js.map +1 -1
  37. package/dist/commands/token.d.ts.map +1 -1
  38. package/dist/commands/token.js +2 -2
  39. package/dist/commands/token.js.map +1 -1
  40. package/dist/commands/tools-lint.js +1 -1
  41. package/dist/commands/tools-lint.js.map +1 -1
  42. package/dist/commands/traces.d.ts +14 -2
  43. package/dist/commands/traces.d.ts.map +1 -1
  44. package/dist/commands/traces.js +39 -22
  45. package/dist/commands/traces.js.map +1 -1
  46. package/dist/commands/triggers.d.ts.map +1 -1
  47. package/dist/commands/triggers.js +5 -2
  48. package/dist/commands/triggers.js.map +1 -1
  49. package/dist/index.d.ts +4 -4
  50. package/dist/index.js +4 -5
  51. package/dist/index.js.map +1 -1
  52. package/dist/internal/output.js +6 -0
  53. package/dist/internal/output.js.map +1 -1
  54. package/dist/internal/store-context.js +13 -2
  55. package/dist/internal/store-context.js.map +1 -1
  56. package/dist/package.js +1 -1
  57. package/dist/package.js.map +1 -1
  58. package/package.json +18 -14
  59. package/src/bin/graphorin.ts +1387 -0
  60. package/src/commands/audit.ts +256 -0
  61. package/src/commands/auth.ts +238 -0
  62. package/src/commands/consolidator.ts +382 -0
  63. package/src/commands/doctor.ts +253 -0
  64. package/src/commands/guard.ts +144 -0
  65. package/src/commands/index.ts +223 -0
  66. package/src/commands/init.ts +194 -0
  67. package/src/commands/memory.ts +1052 -0
  68. package/src/commands/migrate-config.ts +77 -0
  69. package/src/commands/migrate-export.ts +117 -0
  70. package/src/commands/migrate.ts +83 -0
  71. package/src/commands/pricing.ts +244 -0
  72. package/src/commands/secrets.ts +309 -0
  73. package/src/commands/skills.ts +272 -0
  74. package/src/commands/start.ts +180 -0
  75. package/src/commands/storage.ts +659 -0
  76. package/src/commands/telemetry.ts +91 -0
  77. package/src/commands/token.ts +361 -0
  78. package/src/commands/tools-lint.ts +430 -0
  79. package/src/commands/traces.ts +188 -0
  80. package/src/commands/triggers.ts +237 -0
  81. package/src/index.ts +30 -0
  82. package/src/internal/exit.ts +62 -0
  83. package/src/internal/load-config.ts +107 -0
  84. package/src/internal/offline.ts +81 -0
  85. package/src/internal/output.ts +146 -0
  86. package/src/internal/prompts.ts +58 -0
  87. package/src/internal/store-context.ts +165 -0
@@ -0,0 +1,237 @@
1
+ /**
2
+ * `graphorin triggers` - operate on the durable trigger registry.
3
+ *
4
+ * Surface (per Phase 15 § Triggers):
5
+ *
6
+ * - `graphorin triggers list` - every persisted trigger.
7
+ * - `graphorin triggers status <id>` - single-trigger detail.
8
+ * - `graphorin triggers fire <id>` - operator-fired side-effect (admin
9
+ * only). The CLI persists a fire-now signal into `trigger_admin`
10
+ * that the running scheduler picks up; library-mode triggers
11
+ * can also be fired by re-running the host process.
12
+ * - `graphorin triggers disable <id>` - flip the `disabled` column.
13
+ * - `graphorin triggers prune` - drop orphan rows whose callbackRef
14
+ * no longer exists in the running registry. Without a running
15
+ * server the CLI cannot tell which triggers are orphans, so the
16
+ * helper deletes only triggers whose `disabled` column is true and
17
+ * whose `lastFiredAt` is older than the cutoff supplied via
18
+ * `--before <ISO>`.
19
+ *
20
+ * @packageDocumentation
21
+ */
22
+
23
+ import type { TriggerState } from '@graphorin/core/contracts';
24
+
25
+ import { EXIT_CODES } from '../internal/exit.js';
26
+ import {
27
+ brand,
28
+ type CommonOutputOptions,
29
+ defaultPrintSink,
30
+ emitReport,
31
+ statusMarker,
32
+ } from '../internal/output.js';
33
+ import { openStoreContext } from '../internal/store-context.js';
34
+
35
+ /** @stable */
36
+ export interface TriggersCommonOptions extends CommonOutputOptions {
37
+ readonly config?: string;
38
+ }
39
+
40
+ /** @stable */
41
+ export async function runTriggersList(
42
+ options: TriggersCommonOptions = {},
43
+ ): Promise<ReadonlyArray<TriggerState>> {
44
+ const ctx = await openStoreContext({
45
+ // W-068: read-only command - never auto-migrate a live database.
46
+ migrationPolicy: 'check',
47
+ ...(options.config !== undefined ? { config: options.config } : {}),
48
+ });
49
+ try {
50
+ const list = await ctx.store.triggers.list();
51
+ emitReport(options, list, () => {
52
+ const print = options.print ?? defaultPrintSink;
53
+ if (list.length === 0) {
54
+ print(brand('no triggers persisted.'));
55
+ return;
56
+ }
57
+ print(brand(`${list.length} trigger(s):`));
58
+ for (const t of list) {
59
+ const mark = t.disabled ? statusMarker('warn') : statusMarker('ok');
60
+ print(
61
+ ` ${mark} ${t.id} (kind=${t.kind}, spec=${t.spec}, next=${t.nextFireAt ?? '-'}, missed=${t.missedFires})`,
62
+ );
63
+ }
64
+ });
65
+ return list;
66
+ } finally {
67
+ await ctx.close();
68
+ }
69
+ }
70
+
71
+ /** @stable */
72
+ export interface TriggersStatusOptions extends TriggersCommonOptions {
73
+ readonly id: string;
74
+ }
75
+
76
+ /** @stable */
77
+ export async function runTriggersStatus(
78
+ options: TriggersStatusOptions,
79
+ ): Promise<TriggerState | null> {
80
+ const ctx = await openStoreContext({
81
+ ...(options.config !== undefined ? { config: options.config } : {}),
82
+ });
83
+ try {
84
+ const state = await ctx.store.triggers.get(options.id);
85
+ emitReport(options, state, () => {
86
+ const print = options.print ?? defaultPrintSink;
87
+ if (state === null) {
88
+ print(brand(`trigger '${options.id}' not found.`));
89
+ return;
90
+ }
91
+ print(brand(`trigger ${state.id}`));
92
+ print(` kind: ${state.kind}`);
93
+ print(` spec: ${state.spec}`);
94
+ print(` disabled: ${state.disabled}`);
95
+ print(` catchupPolicy: ${state.catchupPolicy}`);
96
+ print(` nextFireAt: ${state.nextFireAt ?? '-'}`);
97
+ print(` lastFiredAt: ${state.lastFiredAt ?? '-'}`);
98
+ print(` missedFires: ${state.missedFires}`);
99
+ });
100
+ // W-002: exit code independent of --json (see runAuditVerify).
101
+ if (state === null) process.exitCode = EXIT_CODES.RECOVERABLE_FAILURE;
102
+ return state;
103
+ } finally {
104
+ await ctx.close();
105
+ }
106
+ }
107
+
108
+ /** @stable */
109
+ export interface TriggersFireOptions extends TriggersCommonOptions {
110
+ readonly id: string;
111
+ }
112
+
113
+ /** @stable */
114
+ export async function runTriggersFire(
115
+ options: TriggersFireOptions,
116
+ ): Promise<{ readonly fired: false; readonly unsupported: true }> {
117
+ const ctx = await openStoreContext({
118
+ ...(options.config !== undefined ? { config: options.config } : {}),
119
+ });
120
+ try {
121
+ const state = await ctx.store.triggers.get(options.id);
122
+ if (state === null) {
123
+ throw new Error(`[graphorin/cli] trigger '${options.id}' not found.`);
124
+ }
125
+ // IP-4: the old implementation queued a row into a `trigger_admin`
126
+ // table NOTHING polled and reported success - the fire never
127
+ // happened. Until a daemon-side poll exists, the honest answer is
128
+ // UNSUPPORTED with the working alternative.
129
+ const result = { fired: false, unsupported: true } as const;
130
+ emitReport(options, result, () => {
131
+ const print = options.print ?? defaultPrintSink;
132
+ print(
133
+ brand(
134
+ `direct CLI fire is not wired yet - use the running server: POST /v1/triggers/${options.id}/fire (scope triggers:fire).`,
135
+ ),
136
+ );
137
+ });
138
+ process.exitCode = EXIT_CODES.UNSUPPORTED;
139
+ return result;
140
+ } finally {
141
+ await ctx.close();
142
+ }
143
+ }
144
+
145
+ /** @stable */
146
+ export interface TriggersDisableOptions extends TriggersCommonOptions {
147
+ readonly id: string;
148
+ }
149
+
150
+ /** @stable */
151
+ export async function runTriggersDisable(
152
+ options: TriggersDisableOptions,
153
+ ): Promise<TriggerState | null> {
154
+ const ctx = await openStoreContext({
155
+ ...(options.config !== undefined ? { config: options.config } : {}),
156
+ });
157
+ try {
158
+ const existing = await ctx.store.triggers.get(options.id);
159
+ if (existing === null) {
160
+ throw new Error(`[graphorin/cli] trigger '${options.id}' not found.`);
161
+ }
162
+ const next: TriggerState = {
163
+ ...existing,
164
+ disabled: true,
165
+ updatedAt: new Date().toISOString(),
166
+ };
167
+ await ctx.store.triggers.upsert(next);
168
+ emitReport(options, next, () => {
169
+ const print = options.print ?? defaultPrintSink;
170
+ print(brand(`trigger '${options.id}' disabled.`));
171
+ });
172
+ return next;
173
+ } finally {
174
+ await ctx.close();
175
+ }
176
+ }
177
+
178
+ /** @stable */
179
+ export interface TriggersPruneOptions extends TriggersCommonOptions {
180
+ /**
181
+ * ISO date / epoch ms - drop disabled triggers whose `lastFiredAt`
182
+ * (or `createdAt`, when never fired) is older than this cutoff.
183
+ */
184
+ readonly before?: string;
185
+ }
186
+
187
+ /** @stable */
188
+ export interface TriggersPruneResult {
189
+ readonly removed: ReadonlyArray<string>;
190
+ }
191
+
192
+ /** @stable */
193
+ export async function runTriggersPrune(
194
+ options: TriggersPruneOptions = {},
195
+ ): Promise<TriggersPruneResult> {
196
+ const cutoff = options.before === undefined ? 0 : parseCutoff(options.before);
197
+ const ctx = await openStoreContext({
198
+ ...(options.config !== undefined ? { config: options.config } : {}),
199
+ });
200
+ try {
201
+ const all = await ctx.store.triggers.list();
202
+ const removed: string[] = [];
203
+ for (const t of all) {
204
+ if (!t.disabled) continue;
205
+ const lastTouched = t.lastFiredAt ?? t.createdAt;
206
+ const lastTouchedMs = Date.parse(lastTouched);
207
+ if (Number.isFinite(lastTouchedMs) && lastTouchedMs >= cutoff) continue;
208
+ await ctx.store.triggers.remove(t.id);
209
+ removed.push(t.id);
210
+ }
211
+ const out: TriggersPruneResult = Object.freeze({ removed: Object.freeze(removed) });
212
+ emitReport(options, out, () => {
213
+ const print = options.print ?? defaultPrintSink;
214
+ if (out.removed.length === 0) {
215
+ print(brand('no orphan triggers found.'));
216
+ return;
217
+ }
218
+ print(brand(`pruned ${out.removed.length} orphan trigger(s):`));
219
+ for (const id of out.removed) print(` - ${id}`);
220
+ });
221
+ return out;
222
+ } finally {
223
+ await ctx.close();
224
+ }
225
+ }
226
+
227
+ function parseCutoff(input: string): number {
228
+ const numeric = Number(input);
229
+ if (Number.isFinite(numeric) && numeric > 0) return numeric;
230
+ const ms = Date.parse(input);
231
+ if (!Number.isFinite(ms)) {
232
+ throw new Error(
233
+ `[graphorin/cli] --before '${input}' is not a valid ISO date or epoch-ms value.`,
234
+ );
235
+ }
236
+ return ms;
237
+ }
package/src/index.ts ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * `@graphorin/cli` - operator CLI for the Graphorin framework.
3
+ *
4
+ * Phase 14a shipped the minimal `graphorin` binary with three
5
+ * lifecycle commands: `start`, `init`, and `migrate`. Phase 15
6
+ * extended the same binary with the operator surface (`doctor`,
7
+ * `token`, `secrets`, `audit`, `storage`, `memory`, `consolidator`,
8
+ * `triggers`, `auth`, `pricing`, `skills`, `traces`,
9
+ * `migrate-export`, `migrate-config`, `guard`, `telemetry`,
10
+ * `tools lint`).
11
+ *
12
+ * Every subcommand is exported as a typed library function so
13
+ * downstream automations can call them directly without spawning a
14
+ * child process.
15
+ *
16
+ * @packageDocumentation
17
+ */
18
+
19
+ /** Canonical version constant, derived from `package.json` at build time. */
20
+ import pkg from '../package.json' with { type: 'json' };
21
+
22
+ export const VERSION: string = pkg.version;
23
+
24
+ export * from './commands/index.js';
25
+ export {
26
+ assertNoNetworkInOfflineMode,
27
+ checkOfflineModeBlocked,
28
+ isOfflineMode,
29
+ OfflineModeViolationError,
30
+ } from './internal/offline.js';
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Shared exit helpers. Phase 15 subcommands fail-fast through
3
+ * {@link fail}; the helper writes the error message via the chosen
4
+ * sink and then calls `process.exit(code)`. Tests stub `process.exit`
5
+ * to convert the call into a thrown sentinel they can assert against.
6
+ *
7
+ * Exit codes follow the small documented contract:
8
+ *
9
+ * - `0` - success (subcommand completed without warnings or with
10
+ * informational warnings only).
11
+ * - `1` - recoverable failure (config invalid, secret missing,
12
+ * threshold exceeded). Operator can fix and re-run.
13
+ * - `2` - unsupported invocation (e.g. `graphorin storage encrypt`
14
+ * when the Phase 16 sub-pack is not installed). Operator must
15
+ * install / configure something before retrying.
16
+ *
17
+ * @internal
18
+ */
19
+
20
+ import process from 'node:process';
21
+
22
+ import { brand } from './output.js';
23
+
24
+ /**
25
+ * Numeric exit codes the CLI uses uniformly.
26
+ *
27
+ * @internal
28
+ */
29
+ export const EXIT_CODES = Object.freeze({
30
+ OK: 0,
31
+ RECOVERABLE_FAILURE: 1,
32
+ UNSUPPORTED: 2,
33
+ } as const);
34
+
35
+ /**
36
+ * Pretty-print an `Error`-shaped value without leaking secrets, then
37
+ * exit. Re-uses `cause` chains when present.
38
+ *
39
+ * @internal
40
+ */
41
+ export function fail(
42
+ err: unknown,
43
+ options: {
44
+ readonly code?: number;
45
+ readonly print?: (line: string) => void;
46
+ } = {},
47
+ ): never {
48
+ const code = options.code ?? EXIT_CODES.RECOVERABLE_FAILURE;
49
+ const print = options.print ?? ((line: string) => process.stderr.write(`${line}\n`));
50
+ if (err instanceof Error) {
51
+ const kind = (err as { kind?: string }).kind;
52
+ const prefix = kind === undefined ? '' : `${kind}: `;
53
+ print(brand(`${prefix}${err.message}`));
54
+ const hint = (err as { hint?: string }).hint;
55
+ if (typeof hint === 'string' && hint.length > 0) {
56
+ print(brand(`hint: ${hint}`));
57
+ }
58
+ } else {
59
+ print(brand(`unknown error: ${String(err)}`));
60
+ }
61
+ process.exit(code);
62
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Tiny config loader. Resolves the path the operator handed in (or
3
+ * the per-platform default) and returns the raw {@link ServerConfigInput}
4
+ * payload - strict validation happens later via
5
+ * `parseServerConfig(...)` from `@graphorin/server`.
6
+ *
7
+ * Supported file kinds (decided by extension):
8
+ *
9
+ * - `*.ts` / `*.mts` / `*.cts` - re-imported through the platform
10
+ * loader; relies on Node's native TS support (or a registered
11
+ * loader) - Phase 14a leaves the operator to wire `tsx`, `ts-node`,
12
+ * or whatever they prefer when shipping a `.ts` config.
13
+ * - `*.js` / `*.mjs` - dynamic `import()`.
14
+ * - `*.json` - `readFile` + `JSON.parse`.
15
+ *
16
+ * The file is expected to default-export an object built via
17
+ * `defineConfig({...})`.
18
+ *
19
+ * @internal
20
+ */
21
+
22
+ import { readFile, stat } from 'node:fs/promises';
23
+ import { isAbsolute, resolve } from 'node:path';
24
+ import { pathToFileURL } from 'node:url';
25
+
26
+ import type { ServerConfigInput } from '@graphorin/server';
27
+
28
+ /**
29
+ * @stable
30
+ */
31
+ export interface LoadConfigOptions {
32
+ /** Override the resolved process.cwd(). Tests inject a fixture root. */
33
+ readonly cwd?: string;
34
+ }
35
+
36
+ /**
37
+ * @stable
38
+ */
39
+ export interface LoadedConfig {
40
+ readonly path: string;
41
+ readonly config: ServerConfigInput;
42
+ }
43
+
44
+ const DEFAULT_FILENAMES: ReadonlyArray<string> = Object.freeze([
45
+ 'graphorin.config.ts',
46
+ 'graphorin.config.mjs',
47
+ 'graphorin.config.js',
48
+ 'graphorin.config.json',
49
+ ]);
50
+
51
+ /**
52
+ * Resolve the path the operator passed (or pick the first existing
53
+ * default in `cwd`) and parse the config it exports.
54
+ *
55
+ * @stable
56
+ */
57
+ export async function loadConfig(
58
+ pathArg: string | undefined,
59
+ options: LoadConfigOptions = {},
60
+ ): Promise<LoadedConfig> {
61
+ const cwd = options.cwd ?? process.cwd();
62
+ let absolute: string;
63
+ if (pathArg !== undefined && pathArg.length > 0) {
64
+ absolute = isAbsolute(pathArg) ? pathArg : resolve(cwd, pathArg);
65
+ await assertExists(absolute);
66
+ } else {
67
+ absolute = await firstExistingDefault(cwd);
68
+ }
69
+ const config = await importConfig(absolute);
70
+ return Object.freeze({ path: absolute, config });
71
+ }
72
+
73
+ async function firstExistingDefault(cwd: string): Promise<string> {
74
+ for (const name of DEFAULT_FILENAMES) {
75
+ const candidate = resolve(cwd, name);
76
+ try {
77
+ await stat(candidate);
78
+ return candidate;
79
+ } catch {}
80
+ }
81
+ throw new Error(
82
+ `[graphorin/cli] no config file found in '${cwd}'. Looked for: ${DEFAULT_FILENAMES.join(', ')}.`,
83
+ );
84
+ }
85
+
86
+ async function assertExists(path: string): Promise<void> {
87
+ try {
88
+ await stat(path);
89
+ } catch (err) {
90
+ throw new Error(`[graphorin/cli] config file '${path}' not found.`, { cause: err });
91
+ }
92
+ }
93
+
94
+ async function importConfig(path: string): Promise<ServerConfigInput> {
95
+ if (path.endsWith('.json')) {
96
+ const raw = await readFile(path, 'utf8');
97
+ try {
98
+ return JSON.parse(raw) as ServerConfigInput;
99
+ } catch (err) {
100
+ throw new Error(`[graphorin/cli] config '${path}' is not valid JSON.`, { cause: err });
101
+ }
102
+ }
103
+ const url = pathToFileURL(path).href;
104
+ const mod = (await import(url)) as { default?: unknown };
105
+ if (mod.default !== undefined) return mod.default as ServerConfigInput;
106
+ return mod as unknown as ServerConfigInput;
107
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * `GRAPHORIN_OFFLINE=1` enforcement. The framework promises zero
3
+ * implicit network calls (DEC-154 / ADR-041); the CLI mirrors the
4
+ * promise on the operator-facing side by surfacing the same flag
5
+ * every subcommand obeys.
6
+ *
7
+ * The flag is informational in Phase 14a - the three commands the
8
+ * binary ships do NOT make any outbound network calls. A future
9
+ * phase that exposes a network-using subcommand (e.g. `graphorin
10
+ * pricing refresh`) reads {@link isOfflineMode} to short-circuit.
11
+ *
12
+ * @internal
13
+ */
14
+
15
+ /**
16
+ * `true` when `process.env.GRAPHORIN_OFFLINE` is set to `'1'` or
17
+ * `'true'` (case-insensitive). Mirrors the documented contract.
18
+ *
19
+ * @stable
20
+ */
21
+ export function isOfflineMode(env: NodeJS.ProcessEnv = process.env): boolean {
22
+ const raw = env.GRAPHORIN_OFFLINE;
23
+ if (raw === undefined) return false;
24
+ const normalized = raw.trim().toLowerCase();
25
+ return normalized === '1' || normalized === 'true' || normalized === 'yes';
26
+ }
27
+
28
+ /**
29
+ * Throws when the operator opted into offline mode. Phase 14a calls
30
+ * this from every subcommand entry point so the contract is
31
+ * enforced uniformly.
32
+ *
33
+ * @stable
34
+ */
35
+ export function assertNoNetworkInOfflineMode(operation: string): void {
36
+ if (!isOfflineMode()) return;
37
+ throw new OfflineModeViolationError(operation);
38
+ }
39
+
40
+ /**
41
+ * Phase 15 helper for subcommands that informationally **may** make
42
+ * network calls (e.g. `graphorin auth login`, `graphorin pricing
43
+ * refresh`, `graphorin skills install`). When `GRAPHORIN_OFFLINE=1`
44
+ * is set the helper writes an explanatory branded line through the
45
+ * supplied sink (default: `process.stderr`) and returns `false`,
46
+ * letting the caller short-circuit cleanly without throwing. Returns
47
+ * `true` when the network is permitted.
48
+ *
49
+ * @stable
50
+ */
51
+ export function checkOfflineModeBlocked(
52
+ operation: string,
53
+ options: { readonly print?: (line: string) => void; readonly env?: NodeJS.ProcessEnv } = {},
54
+ ): boolean {
55
+ const env = options.env ?? process.env;
56
+ if (!isOfflineMode(env)) return true;
57
+ const sink = options.print ?? ((line: string) => process.stderr.write(`${line}\n`));
58
+ sink(
59
+ `[graphorin/cli] GRAPHORIN_OFFLINE=1 - refusing to perform '${operation}'. Unset GRAPHORIN_OFFLINE to opt back in.`,
60
+ );
61
+ return false;
62
+ }
63
+
64
+ /**
65
+ * Raised when a CLI subcommand attempts a network call while
66
+ * `GRAPHORIN_OFFLINE=1` is set.
67
+ *
68
+ * @stable
69
+ */
70
+ export class OfflineModeViolationError extends Error {
71
+ readonly kind = 'offline-mode-violation' as const;
72
+ readonly operation: string;
73
+
74
+ constructor(operation: string) {
75
+ super(
76
+ `[graphorin/cli] GRAPHORIN_OFFLINE=1 is set; refusing to perform a network operation: ${operation}.`,
77
+ );
78
+ this.name = 'OfflineModeViolationError';
79
+ this.operation = operation;
80
+ }
81
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Shared output helpers for Phase 15 subcommands. The CLI keeps three
3
+ * output flavours uniform across every command:
4
+ *
5
+ * 1. **Human** - single-line `[graphorin/cli] ...` messages on stderr
6
+ * (the default path in Phase 14a; preserved for backward
7
+ * compatibility).
8
+ * 2. **JSON** - one structured JSON document per subcommand emitted on
9
+ * stdout when `--json` is set. Phase 15 commands that produce a
10
+ * report (status / list / verify / lookup) ship a stable schema so
11
+ * CI pipelines can consume them.
12
+ * 3. **Stub print sink** - every command accepts an optional
13
+ * `print(line: string)` callback so unit tests can capture output
14
+ * without spying on `process.stderr`.
15
+ *
16
+ * Helpers in this module are intentionally tiny - they exist so the
17
+ * sixteen Phase 15 subcommand groups share one channel surface
18
+ * (single-source-of-truth per Hard Rule 5 in the working plan).
19
+ *
20
+ * @internal
21
+ */
22
+
23
+ import process from 'node:process';
24
+
25
+ /**
26
+ * Sink the CLI subcommands write human-readable lines through. The
27
+ * default sink writes a trailing newline to `process.stderr`. Tests
28
+ * inject a buffer.
29
+ *
30
+ * @internal
31
+ */
32
+ export type PrintSink = (line: string) => void;
33
+
34
+ /**
35
+ * Default `PrintSink` - appends a newline and writes to `stderr` so it
36
+ * never collides with the JSON document on `stdout`.
37
+ *
38
+ * @internal
39
+ */
40
+ export function defaultPrintSink(line: string): void {
41
+ process.stderr.write(`${line}\n`);
42
+ }
43
+
44
+ /**
45
+ * Sink the CLI uses when emitting structured JSON documents. Defaults
46
+ * to writing a single trailing newline to `process.stdout`. Tests
47
+ * inject a buffer.
48
+ *
49
+ * @internal
50
+ */
51
+ export type JsonSink = (payload: unknown) => void;
52
+
53
+ /**
54
+ * Default `JsonSink` - writes a stable, two-space indented JSON
55
+ * document to `stdout` plus a trailing newline so consumers can split
56
+ * a multi-document stream by lines.
57
+ *
58
+ * @internal
59
+ */
60
+ export function defaultJsonSink(payload: unknown): void {
61
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
62
+ }
63
+
64
+ /**
65
+ * Common output options every Phase 15 command honours.
66
+ *
67
+ * @stable
68
+ */
69
+ export interface CommonOutputOptions {
70
+ /** Emit a structured JSON document instead of human-readable text. */
71
+ readonly json?: boolean;
72
+ /** Force `--non-interactive` semantics (skip prompts; require flags / env). */
73
+ readonly nonInteractive?: boolean;
74
+ /** Test seam - capture human lines instead of writing to stderr. */
75
+ readonly print?: PrintSink;
76
+ /** Test seam - capture JSON documents instead of writing to stdout. */
77
+ readonly jsonPrint?: JsonSink;
78
+ }
79
+
80
+ /**
81
+ * Emit either the JSON document or the human report depending on
82
+ * `--json`. Used by every Phase 15 subcommand that produces a single
83
+ * status payload (e.g. `graphorin doctor`, `graphorin token list`).
84
+ *
85
+ * WARNING (W-002): `human()` runs ONLY in non-JSON mode. Side effects
86
+ * that are part of the machine contract - `process.exitCode` above all
87
+ * - are FORBIDDEN inside it: they would silently vanish for exactly
88
+ * the `--json` consumers (CI) they exist for. Compute the failure
89
+ * predicate before calling this and set the exit code after it.
90
+ *
91
+ * @internal
92
+ */
93
+ export function emitReport(
94
+ options: CommonOutputOptions,
95
+ payload: unknown,
96
+ human: () => void,
97
+ ): void {
98
+ if (options.json === true) {
99
+ const sink = options.jsonPrint ?? defaultJsonSink;
100
+ sink(payload);
101
+ return;
102
+ }
103
+ human();
104
+ }
105
+
106
+ /**
107
+ * Format a status icon for human reports. The CLI is ASCII-only per
108
+ * the Phase 15 spec (no emoji); the markers are unambiguous so
109
+ * operators see at a glance what a row means.
110
+ *
111
+ * @internal
112
+ */
113
+ export function statusMarker(status: 'ok' | 'warn' | 'fail' | 'skip' | 'info'): string {
114
+ switch (status) {
115
+ case 'ok':
116
+ return '[OK]';
117
+ case 'warn':
118
+ return '[WARN]';
119
+ case 'fail':
120
+ return '[FAIL]';
121
+ case 'skip':
122
+ return '[SKIP]';
123
+ case 'info':
124
+ return '[INFO]';
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Standardised `[graphorin/cli] <message>` prefix every human-format
130
+ * line carries. Centralised so every command renders the same brand.
131
+ *
132
+ * @internal
133
+ */
134
+ export function brand(line: string): string {
135
+ return `[graphorin/cli] ${line}`;
136
+ }
137
+
138
+ /**
139
+ * Convenience helper - writes a branded message via the chosen sink.
140
+ *
141
+ * @internal
142
+ */
143
+ export function brandedLine(options: CommonOutputOptions, line: string): void {
144
+ const sink = options.print ?? defaultPrintSink;
145
+ sink(brand(line));
146
+ }