@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.
- package/CHANGELOG.md +63 -0
- package/README.md +3 -3
- package/dist/bin/graphorin.js +51 -13
- package/dist/bin/graphorin.js.map +1 -1
- package/dist/commands/audit.d.ts.map +1 -1
- package/dist/commands/audit.js +2 -1
- package/dist/commands/audit.js.map +1 -1
- package/dist/commands/consolidator.d.ts +56 -1
- package/dist/commands/consolidator.d.ts.map +1 -1
- package/dist/commands/consolidator.js +88 -2
- package/dist/commands/consolidator.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/index.d.ts +4 -4
- package/dist/commands/index.js +4 -4
- package/dist/commands/init.d.ts +11 -4
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +15 -11
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/memory.d.ts +26 -1
- package/dist/commands/memory.d.ts.map +1 -1
- package/dist/commands/memory.js +56 -3
- package/dist/commands/memory.js.map +1 -1
- package/dist/commands/pricing.d.ts +6 -0
- package/dist/commands/pricing.d.ts.map +1 -1
- package/dist/commands/pricing.js +5 -2
- package/dist/commands/pricing.js.map +1 -1
- package/dist/commands/secrets.d.ts.map +1 -1
- package/dist/commands/secrets.js +2 -2
- package/dist/commands/secrets.js.map +1 -1
- package/dist/commands/skills.d.ts.map +1 -1
- package/dist/commands/skills.js +1 -1
- package/dist/commands/skills.js.map +1 -1
- package/dist/commands/storage.d.ts +41 -1
- package/dist/commands/storage.d.ts.map +1 -1
- package/dist/commands/storage.js +75 -1
- package/dist/commands/storage.js.map +1 -1
- package/dist/commands/token.d.ts.map +1 -1
- package/dist/commands/token.js +2 -2
- package/dist/commands/token.js.map +1 -1
- package/dist/commands/tools-lint.js +1 -1
- package/dist/commands/tools-lint.js.map +1 -1
- package/dist/commands/traces.d.ts +14 -2
- package/dist/commands/traces.d.ts.map +1 -1
- package/dist/commands/traces.js +39 -22
- package/dist/commands/traces.js.map +1 -1
- package/dist/commands/triggers.d.ts.map +1 -1
- package/dist/commands/triggers.js +5 -2
- package/dist/commands/triggers.js.map +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/index.js +4 -5
- package/dist/index.js.map +1 -1
- package/dist/internal/output.js +6 -0
- package/dist/internal/output.js.map +1 -1
- package/dist/internal/store-context.js +13 -2
- package/dist/internal/store-context.js.map +1 -1
- package/dist/package.js +1 -1
- package/dist/package.js.map +1 -1
- package/package.json +18 -14
- package/src/bin/graphorin.ts +1387 -0
- package/src/commands/audit.ts +256 -0
- package/src/commands/auth.ts +238 -0
- package/src/commands/consolidator.ts +382 -0
- package/src/commands/doctor.ts +253 -0
- package/src/commands/guard.ts +144 -0
- package/src/commands/index.ts +223 -0
- package/src/commands/init.ts +194 -0
- package/src/commands/memory.ts +1052 -0
- package/src/commands/migrate-config.ts +77 -0
- package/src/commands/migrate-export.ts +117 -0
- package/src/commands/migrate.ts +83 -0
- package/src/commands/pricing.ts +244 -0
- package/src/commands/secrets.ts +309 -0
- package/src/commands/skills.ts +272 -0
- package/src/commands/start.ts +180 -0
- package/src/commands/storage.ts +659 -0
- package/src/commands/telemetry.ts +91 -0
- package/src/commands/token.ts +361 -0
- package/src/commands/tools-lint.ts +430 -0
- package/src/commands/traces.ts +188 -0
- package/src/commands/triggers.ts +237 -0
- package/src/index.ts +30 -0
- package/src/internal/exit.ts +62 -0
- package/src/internal/load-config.ts +107 -0
- package/src/internal/offline.ts +81 -0
- package/src/internal/output.ts +146 -0
- package/src/internal/prompts.ts +58 -0
- 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
|
+
}
|