@griffin-app/griffin-cli 1.0.39 → 1.0.40

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 (50) hide show
  1. package/dist/cli.js +0 -0
  2. package/dist/commands/apply.d.ts +9 -0
  3. package/dist/commands/apply.js +76 -0
  4. package/dist/commands/config.d.ts +36 -0
  5. package/dist/commands/config.js +144 -0
  6. package/dist/commands/configure-runner-host.d.ts +2 -0
  7. package/dist/commands/configure-runner-host.js +41 -0
  8. package/dist/commands/deploy.d.ts +1 -0
  9. package/dist/commands/deploy.js +36 -0
  10. package/dist/commands/execute-remote.d.ts +1 -0
  11. package/dist/commands/execute-remote.js +33 -0
  12. package/dist/commands/hub/config.d.ts +27 -0
  13. package/dist/commands/hub/config.js +102 -0
  14. package/dist/commands/hub/plan.d.ts +8 -0
  15. package/dist/commands/hub/plan.js +75 -0
  16. package/dist/commands/local/config.d.ts +28 -0
  17. package/dist/commands/local/config.js +82 -0
  18. package/dist/commands/logs.d.ts +1 -0
  19. package/dist/commands/logs.js +20 -0
  20. package/dist/commands/plan.d.ts +8 -0
  21. package/dist/commands/plan.js +58 -0
  22. package/dist/commands/run-remote.d.ts +11 -0
  23. package/dist/commands/run-remote.js +98 -0
  24. package/dist/commands/run.d.ts +4 -0
  25. package/dist/commands/run.js +86 -0
  26. package/dist/commands/runner.d.ts +12 -0
  27. package/dist/commands/runner.js +53 -0
  28. package/dist/commands/status.d.ts +8 -0
  29. package/dist/commands/status.js +75 -0
  30. package/dist/core/plan-diff.d.ts +41 -0
  31. package/dist/core/plan-diff.js +257 -0
  32. package/dist/output/context.d.ts +18 -0
  33. package/dist/output/context.js +22 -0
  34. package/dist/output/index.d.ts +3 -0
  35. package/dist/output/index.js +2 -0
  36. package/dist/output/renderer.d.ts +6 -0
  37. package/dist/output/renderer.js +348 -0
  38. package/dist/output/types.d.ts +153 -0
  39. package/dist/output/types.js +1 -0
  40. package/dist/providers/registry.d.ts +24 -0
  41. package/dist/providers/registry.js +109 -0
  42. package/dist/schemas/payload.d.ts +6 -0
  43. package/dist/schemas/payload.js +8 -0
  44. package/dist/test-discovery.d.ts +4 -0
  45. package/dist/test-discovery.js +25 -0
  46. package/dist/test-runner.d.ts +6 -0
  47. package/dist/test-runner.js +56 -0
  48. package/dist/utils/console.d.ts +5 -0
  49. package/dist/utils/console.js +5 -0
  50. package/package.json +3 -3
@@ -0,0 +1,257 @@
1
+ import objectHash from "object-hash";
2
+ import { NodeType } from "@griffin-app/griffin-ts/schema";
3
+ /**
4
+ * Compare two test monitors and return granular changes.
5
+ * Local monitor should be resolved (variables replaced with actual values).
6
+ */
7
+ export function compareMonitors(local, remote) {
8
+ const nodeChanges = compareNodes(local.nodes, remote.nodes);
9
+ const edgeChanges = compareEdges(local.edges, remote.edges);
10
+ const topLevelChanges = compareTopLevel(local, remote);
11
+ const hasChanges = nodeChanges.length > 0 ||
12
+ edgeChanges.length > 0 ||
13
+ topLevelChanges.length > 0;
14
+ return {
15
+ hasChanges,
16
+ nodes: nodeChanges,
17
+ edges: edgeChanges,
18
+ topLevel: topLevelChanges,
19
+ };
20
+ }
21
+ /**
22
+ * Compare nodes between local and remote monitors
23
+ */
24
+ function compareNodes(localNodes, remoteNodes) {
25
+ const changes = [];
26
+ // Build map of remote nodes by id
27
+ const remoteByID = new Map();
28
+ for (const node of remoteNodes) {
29
+ remoteByID.set(node.id, node);
30
+ }
31
+ const localIDs = new Set();
32
+ for (const node of localNodes) {
33
+ localIDs.add(node.id);
34
+ }
35
+ // Check local nodes
36
+ for (const local of localNodes) {
37
+ const remote = remoteByID.get(local.id);
38
+ if (!remote) {
39
+ // Node added
40
+ changes.push({
41
+ type: "add",
42
+ nodeId: local.id,
43
+ nodeType: local.type,
44
+ summary: getNodeSummary(local),
45
+ fieldChanges: [],
46
+ });
47
+ }
48
+ else {
49
+ // Node exists - check for modifications
50
+ const fieldChanges = compareNodeFields(local, remote);
51
+ if (fieldChanges.length > 0) {
52
+ changes.push({
53
+ type: "modify",
54
+ nodeId: local.id,
55
+ nodeType: local.type,
56
+ summary: getNodeSummary(local),
57
+ fieldChanges,
58
+ });
59
+ }
60
+ }
61
+ }
62
+ // Check for removed nodes
63
+ for (const remote of remoteNodes) {
64
+ if (!localIDs.has(remote.id)) {
65
+ changes.push({
66
+ type: "remove",
67
+ nodeId: remote.id,
68
+ nodeType: remote.type,
69
+ summary: getNodeSummary(remote),
70
+ fieldChanges: [],
71
+ });
72
+ }
73
+ }
74
+ return changes;
75
+ }
76
+ /**
77
+ * Get a human-readable summary of a node
78
+ */
79
+ function getNodeSummary(node) {
80
+ switch (node.type) {
81
+ case "HTTP_REQUEST":
82
+ return `${node.method} ${formatValue(node.path)}`;
83
+ case "WAIT":
84
+ return `wait ${node.duration_ms}ms`;
85
+ case "ASSERTION":
86
+ return `${node.assertions.length} assertion(s)`;
87
+ }
88
+ }
89
+ /**
90
+ * Format a value for display (handle VariableRef objects)
91
+ */
92
+ function formatValue(value) {
93
+ if (typeof value === "string") {
94
+ return value;
95
+ }
96
+ if (value &&
97
+ typeof value === "object" &&
98
+ "$variable" in value &&
99
+ typeof value.$variable === "object") {
100
+ return `$\{${value.$variable.key}}`;
101
+ }
102
+ return JSON.stringify(value);
103
+ }
104
+ /**
105
+ * Compare fields within two nodes of the same type
106
+ */
107
+ function compareNodeFields(local, remote) {
108
+ const changes = [];
109
+ // Type should match, but check anyway
110
+ if (local.type !== remote.type) {
111
+ changes.push({
112
+ field: "type",
113
+ oldValue: remote.type,
114
+ newValue: local.type,
115
+ });
116
+ return changes;
117
+ }
118
+ switch (local.type) {
119
+ case NodeType.HTTP_REQUEST:
120
+ compareHttpRequestFields(local, remote, changes);
121
+ break;
122
+ case NodeType.WAIT:
123
+ compareWaitFields(local, remote, changes);
124
+ break;
125
+ case NodeType.ASSERTION:
126
+ compareAssertionFields(local, remote, changes);
127
+ break;
128
+ }
129
+ return changes;
130
+ }
131
+ /**
132
+ * Compare fields specific to HttpRequest nodes
133
+ */
134
+ function compareHttpRequestFields(local, remote, changes) {
135
+ const fields = [
136
+ "method",
137
+ "path",
138
+ "base",
139
+ "headers",
140
+ "body",
141
+ "response_format",
142
+ ];
143
+ for (const field of fields) {
144
+ const localVal = local[field];
145
+ const remoteVal = remote[field];
146
+ if (!deepEqual(localVal, remoteVal)) {
147
+ changes.push({
148
+ field: field,
149
+ oldValue: remoteVal,
150
+ newValue: localVal,
151
+ });
152
+ }
153
+ }
154
+ }
155
+ /**
156
+ * Compare fields specific to Wait nodes
157
+ */
158
+ function compareWaitFields(local, remote, changes) {
159
+ if (local.duration_ms !== remote.duration_ms) {
160
+ changes.push({
161
+ field: "duration_ms",
162
+ oldValue: remote.duration_ms,
163
+ newValue: local.duration_ms,
164
+ });
165
+ }
166
+ }
167
+ /**
168
+ * Compare fields specific to Assertion nodes
169
+ */
170
+ function compareAssertionFields(local, remote, changes) {
171
+ if (!deepEqual(local.assertions, remote.assertions)) {
172
+ changes.push({
173
+ field: "assertions",
174
+ oldValue: remote.assertions,
175
+ newValue: local.assertions,
176
+ });
177
+ }
178
+ }
179
+ /**
180
+ * Compare edges between local and remote monitors
181
+ */
182
+ function compareEdges(localEdges, remoteEdges) {
183
+ const changes = [];
184
+ // Build map of remote edges by "from:to" key
185
+ const remoteByKey = new Map();
186
+ for (const edge of remoteEdges) {
187
+ remoteByKey.set(`${edge.from}:${edge.to}`, edge);
188
+ }
189
+ const localKeys = new Set();
190
+ for (const edge of localEdges) {
191
+ localKeys.add(`${edge.from}:${edge.to}`);
192
+ }
193
+ // Check local edges
194
+ for (const local of localEdges) {
195
+ const key = `${local.from}:${local.to}`;
196
+ if (!remoteByKey.has(key)) {
197
+ changes.push({
198
+ type: "add",
199
+ from: local.from,
200
+ to: local.to,
201
+ });
202
+ }
203
+ }
204
+ // Check for removed edges
205
+ for (const remote of remoteEdges) {
206
+ const key = `${remote.from}:${remote.to}`;
207
+ if (!localKeys.has(key)) {
208
+ changes.push({
209
+ type: "remove",
210
+ from: remote.from,
211
+ to: remote.to,
212
+ });
213
+ }
214
+ }
215
+ return changes;
216
+ }
217
+ /**
218
+ * Compare top-level fields: frequency, version, locations
219
+ */
220
+ function compareTopLevel(local, remote) {
221
+ const changes = [];
222
+ // Compare frequency
223
+ if (!deepEqual(local.frequency, remote.frequency)) {
224
+ changes.push({
225
+ field: "frequency",
226
+ oldValue: remote.frequency,
227
+ newValue: local.frequency,
228
+ });
229
+ }
230
+ // Compare version
231
+ if (local.version !== remote.version) {
232
+ changes.push({
233
+ field: "version",
234
+ oldValue: remote.version,
235
+ newValue: local.version,
236
+ });
237
+ }
238
+ // Compare locations (normalize empty array to undefined)
239
+ const localLocations = local.locations;
240
+ const remoteLocations = remote.locations && remote.locations.length > 0
241
+ ? remote.locations
242
+ : undefined;
243
+ if (!deepEqual(localLocations, remoteLocations)) {
244
+ changes.push({
245
+ field: "locations",
246
+ oldValue: remoteLocations,
247
+ newValue: localLocations,
248
+ });
249
+ }
250
+ return changes;
251
+ }
252
+ /**
253
+ * Deep equality check using object-hash
254
+ */
255
+ function deepEqual(a, b) {
256
+ return objectHash(a ?? null) === objectHash(b ?? null);
257
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Output format for CLI commands.
3
+ * - human: formatted, colored, spinners and progress
4
+ * - json: single JSON object to stdout, no progress output
5
+ */
6
+ export type OutputFormat = "human" | "json";
7
+ /**
8
+ * Set the output format (called from cli.ts when parsing --output).
9
+ */
10
+ export declare function setOutputFormat(format: OutputFormat): void;
11
+ /**
12
+ * Get the current output format.
13
+ */
14
+ export declare function getOutputFormat(): OutputFormat;
15
+ /**
16
+ * True when output should be JSON-only (no spinners, no progress).
17
+ */
18
+ export declare function isJsonOutput(): boolean;
@@ -0,0 +1,22 @@
1
+ let currentFormat = "human";
2
+ /**
3
+ * Set the output format (called from cli.ts when parsing --output).
4
+ */
5
+ export function setOutputFormat(format) {
6
+ if (format !== "human" && format !== "json") {
7
+ throw new Error(`Invalid output format: ${format}. Use 'human' or 'json'.`);
8
+ }
9
+ currentFormat = format;
10
+ }
11
+ /**
12
+ * Get the current output format.
13
+ */
14
+ export function getOutputFormat() {
15
+ return currentFormat;
16
+ }
17
+ /**
18
+ * True when output should be JSON-only (no spinners, no progress).
19
+ */
20
+ export function isJsonOutput() {
21
+ return currentFormat === "json";
22
+ }
@@ -0,0 +1,3 @@
1
+ export { type CommandResult, type InitResultData, type ConnectResultData, type LoginResultData, type LogoutResultData, type GenerateKeyResultData, type ValidateResultData, type ValidateMonitorInfo, type EnvListResultData, type StatusResultData, type RunSummary, type RunsResultData, type MetricsResultData, type MonitorResultData, type ApplyResultData, type LocalRunResultData, type HubRunResultData, type NotificationRuleSummary, type NotificationsListResultData, type NotificationsAddResultData, type NotificationsDeleteResultData, type NotificationsTestResultData, } from "./types.js";
2
+ export { setOutputFormat, getOutputFormat, isJsonOutput, type OutputFormat, } from "./context.js";
3
+ export { render, type CommandName } from "./renderer.js";
@@ -0,0 +1,2 @@
1
+ export { setOutputFormat, getOutputFormat, isJsonOutput, } from "./context.js";
2
+ export { render } from "./renderer.js";
@@ -0,0 +1,6 @@
1
+ import type { CommandResult } from "./types.js";
2
+ export type CommandName = "init" | "validate" | "generate-key" | "env-list" | "local-run" | "hub-connect" | "hub-status" | "hub-metrics" | "hub-runs" | "hub-monitor" | "hub-apply" | "hub-run" | "hub-login" | "hub-logout" | "notifications-list" | "notifications-add" | "notifications-delete" | "notifications-test";
3
+ /**
4
+ * Render command result to stdout (human or JSON based on --output).
5
+ */
6
+ export declare function render<T>(result: CommandResult<T>, command: CommandName): void;
@@ -0,0 +1,348 @@
1
+ import pc from "picocolors";
2
+ import { getOutputFormat } from "./context.js";
3
+ import { terminal } from "../utils/terminal.js";
4
+ import { formatDiff } from "../core/diff.js";
5
+ import { formatApplyResult } from "../core/apply.js";
6
+ function toJsonPayload(result) {
7
+ if (result.success) {
8
+ return {
9
+ success: true,
10
+ ...result.data,
11
+ ...(result.warnings?.length ? { warnings: result.warnings } : {}),
12
+ };
13
+ }
14
+ return {
15
+ success: false,
16
+ error: result.error,
17
+ ...(result.hint ? { hint: result.hint } : {}),
18
+ };
19
+ }
20
+ /**
21
+ * Render command result to stdout (human or JSON based on --output).
22
+ */
23
+ export function render(result, command) {
24
+ if (getOutputFormat() === "json") {
25
+ console.log(JSON.stringify(toJsonPayload(result), null, 2));
26
+ return;
27
+ }
28
+ renderHuman(result, command);
29
+ }
30
+ function renderHuman(result, command) {
31
+ if (!result.success) {
32
+ terminal.error(result.error);
33
+ if (result.hint) {
34
+ terminal.dim(result.hint);
35
+ }
36
+ return;
37
+ }
38
+ const data = result.data;
39
+ switch (command) {
40
+ case "init":
41
+ renderInit(data);
42
+ break;
43
+ case "hub-connect":
44
+ renderConnect(data);
45
+ break;
46
+ case "hub-login":
47
+ case "hub-logout":
48
+ terminal.success("Success.");
49
+ terminal.blank();
50
+ break;
51
+ case "generate-key":
52
+ renderGenerateKey(data);
53
+ break;
54
+ case "validate":
55
+ renderValidate(data);
56
+ break;
57
+ case "env-list":
58
+ renderEnvList(data);
59
+ break;
60
+ case "hub-status":
61
+ renderStatus(data);
62
+ break;
63
+ case "hub-runs":
64
+ renderRuns(data);
65
+ break;
66
+ case "hub-metrics":
67
+ renderMetrics(data);
68
+ break;
69
+ case "hub-monitor":
70
+ renderMonitor(data);
71
+ break;
72
+ case "hub-apply":
73
+ renderApply(data);
74
+ break;
75
+ case "local-run":
76
+ renderLocalRun(data);
77
+ break;
78
+ case "hub-run":
79
+ renderHubRun(data);
80
+ break;
81
+ case "notifications-list":
82
+ renderNotificationsList(data);
83
+ break;
84
+ case "notifications-add":
85
+ renderNotificationsAdd(data);
86
+ break;
87
+ case "notifications-delete":
88
+ renderNotificationsDelete(data);
89
+ break;
90
+ case "notifications-test":
91
+ renderNotificationsTest(data);
92
+ break;
93
+ default:
94
+ terminal.success("Done.");
95
+ }
96
+ }
97
+ function renderInit(d) {
98
+ terminal.blank();
99
+ terminal.success("Initialization complete!");
100
+ terminal.blank();
101
+ terminal.info("Next steps:");
102
+ for (const step of d.nextSteps) {
103
+ terminal.dim(` ${step}`);
104
+ }
105
+ terminal.blank();
106
+ }
107
+ function renderConnect(d) {
108
+ terminal.success("Hub connection configured");
109
+ terminal.log(` URL: ${pc.cyan(d.url)}`);
110
+ if (d.tokenSet) {
111
+ terminal.log(` API Token: ${pc.dim("***")} (saved to user credentials)`);
112
+ }
113
+ terminal.blank();
114
+ }
115
+ function renderGenerateKey(d) {
116
+ terminal.blank();
117
+ terminal.success("Generated API key:");
118
+ terminal.blank();
119
+ terminal.log(` ${pc.cyan(d.apiKey)}`);
120
+ terminal.blank();
121
+ terminal.warn("Store this key securely - it cannot be retrieved later.");
122
+ terminal.blank();
123
+ terminal.info("To use this key:");
124
+ terminal.dim(" 1. Add it to your runner's AUTH_API_KEYS environment variable:");
125
+ terminal.dim(` AUTH_API_KEYS=${d.apiKey}`);
126
+ terminal.dim(" 2. Or add it to your .griffinrc.json:");
127
+ terminal.dim(` { "runner": { "apiToken": "${d.apiKey}" } }`);
128
+ terminal.dim(" 3. Or pass it via environment variable:");
129
+ terminal.dim(` GRIFFIN_API_TOKEN=${d.apiKey}`);
130
+ terminal.blank();
131
+ }
132
+ function renderValidate(d) {
133
+ terminal.blank();
134
+ for (const m of d.monitors) {
135
+ const exportInfo = m.exportName === "default" ? "" : pc.dim(` (${m.exportName})`);
136
+ terminal.log(` ${pc.green("●")} ${pc.cyan(m.name)}${exportInfo}`);
137
+ terminal.dim(` ${m.filePath}`);
138
+ terminal.dim(` Nodes: ${m.nodes}, Edges: ${m.edges}`);
139
+ if (m.frequency) {
140
+ terminal.dim(` Schedule: Every ${m.frequency.every} ${m.frequency.unit}`);
141
+ }
142
+ terminal.blank();
143
+ }
144
+ terminal.success("All monitors are valid");
145
+ }
146
+ function renderEnvList(d) {
147
+ terminal.info("Available environments:");
148
+ terminal.blank();
149
+ for (const envName of d.environments) {
150
+ const isDefault = d.default === envName;
151
+ const marker = isDefault ? pc.green("●") : pc.dim("○");
152
+ const envDisplay = isDefault
153
+ ? pc.cyan(envName) + pc.dim(" (default)")
154
+ : envName;
155
+ terminal.log(` ${marker} ${envDisplay}`);
156
+ }
157
+ terminal.blank();
158
+ }
159
+ function renderStatus(d) {
160
+ terminal.info("Hub connection:");
161
+ terminal.log(` URL: ${pc.cyan(d.url)}`);
162
+ if (d.token) {
163
+ terminal.log(` API Token: ${pc.dim(d.token.substring(0, 8) + "...")}`);
164
+ if (d.updatedAt) {
165
+ terminal.log(` Updated: ${pc.dim(new Date(d.updatedAt).toLocaleString())}`);
166
+ }
167
+ }
168
+ else {
169
+ terminal.log(` API Token: ${pc.dim("(not set)")}`);
170
+ }
171
+ terminal.blank();
172
+ }
173
+ function renderRuns(d) {
174
+ if (d.runs.length === 0) {
175
+ terminal.info("No runs found.");
176
+ return;
177
+ }
178
+ terminal.blank();
179
+ const table = terminal.table({
180
+ head: ["Status", "Monitor", "Duration", "Started"],
181
+ });
182
+ for (const run of d.runs) {
183
+ const statusIcon = getStatusIcon(run.status, run.success);
184
+ const duration = run.duration_ms
185
+ ? `${(run.duration_ms / 1000).toFixed(2)}s`
186
+ : "-";
187
+ const started = new Date(run.startedAt).toLocaleString();
188
+ table.push([statusIcon, run.monitorId || "-", duration, started]);
189
+ }
190
+ terminal.log(table.toString());
191
+ const runsWithErrors = d.runs.filter((r) => r.errors && r.errors.length > 0);
192
+ if (runsWithErrors.length > 0) {
193
+ terminal.blank();
194
+ terminal.warn("Runs with errors:");
195
+ terminal.blank();
196
+ for (const run of runsWithErrors) {
197
+ terminal.log(`${pc.red("✗")} ${pc.cyan(run.monitorId || run.id)}`);
198
+ if (run.errors) {
199
+ for (const err of run.errors.slice(0, 3)) {
200
+ terminal.dim(` - ${err}`);
201
+ }
202
+ if (run.errors.length > 3) {
203
+ terminal.dim(` ... and ${run.errors.length - 3} more`);
204
+ }
205
+ }
206
+ terminal.blank();
207
+ }
208
+ }
209
+ }
210
+ function getStatusIcon(status, success) {
211
+ switch (status) {
212
+ case "pending":
213
+ return "⏳";
214
+ case "running":
215
+ return "🏃";
216
+ case "completed":
217
+ return success ? "✅" : "❌";
218
+ case "failed":
219
+ return "❌";
220
+ default:
221
+ return "•";
222
+ }
223
+ }
224
+ function renderMetrics(d) {
225
+ const period = d.period;
226
+ terminal.info(`Metrics (${period})`);
227
+ terminal.dim(`${new Date(d.periodStart).toLocaleString()} – ${new Date(d.periodEnd).toLocaleString()}`);
228
+ terminal.blank();
229
+ terminal.log("Monitors:");
230
+ terminal.log(` Total: ${d.monitors.total} Passing: ${pc.green(String(d.monitors.passing))} Failing: ${d.monitors.failing > 0 ? pc.red(String(d.monitors.failing)) : "0"} No recent runs: ${d.monitors.noRecentRuns}`);
231
+ terminal.blank();
232
+ terminal.log("Runs:");
233
+ terminal.log(` Total: ${d.runs.total} Success rate: ${d.runs.successRate.toFixed(1)}%`);
234
+ terminal.blank();
235
+ if (d.latency.p50DurationMs != null ||
236
+ d.latency.p95DurationMs != null ||
237
+ d.latency.p99DurationMs != null) {
238
+ terminal.log("Latency (completed runs):");
239
+ terminal.log(` p50: ${d.latency.p50DurationMs ?? "-"} ms p95: ${d.latency.p95DurationMs ?? "-"} ms p99: ${d.latency.p99DurationMs ?? "-"} ms`);
240
+ terminal.blank();
241
+ }
242
+ terminal.log(`Uptime: ${d.uptimePercent.toFixed(1)}%`);
243
+ terminal.blank();
244
+ if (d.failingMonitors.length > 0) {
245
+ terminal.warn("Failing monitors:");
246
+ for (const m of d.failingMonitors) {
247
+ terminal.log(` ${pc.red("✗")} ${m.monitorName} (${m.monitorId}) – ${m.consecutiveFailures} consecutive failure(s), last at ${new Date(m.lastFailureAt).toLocaleString()}`);
248
+ }
249
+ }
250
+ }
251
+ function renderMonitor(d) {
252
+ terminal.blank();
253
+ terminal.log(formatDiff(d.diff));
254
+ }
255
+ function renderApply(d) {
256
+ terminal.blank();
257
+ terminal.log(formatApplyResult(d.applied));
258
+ }
259
+ function renderLocalRun(d) {
260
+ terminal.blank();
261
+ if (d.failed === 0) {
262
+ terminal.success(`All tests passed (${pc.bold(String(d.passed))} / ${d.total})`);
263
+ }
264
+ else {
265
+ terminal.error(`${pc.bold(String(d.failed))} test(s) failed, ${pc.bold(String(d.passed))} passed`);
266
+ }
267
+ }
268
+ function renderHubRun(d) {
269
+ terminal.blank();
270
+ terminal.log(`Run ID: ${pc.dim(d.runId)}`);
271
+ terminal.log(`Status: ${pc.cyan(d.status)}`);
272
+ terminal.log(`Started: ${pc.dim(new Date(d.startedAt).toLocaleString())}`);
273
+ if (d.duration_ms != null) {
274
+ terminal.log(`Duration: ${pc.dim((d.duration_ms / 1000).toFixed(2) + "s")}`);
275
+ }
276
+ if (d.success !== undefined) {
277
+ const successText = d.success ? pc.green("Yes") : pc.red("No");
278
+ terminal.log(`Success: ${successText}`);
279
+ }
280
+ if (d.errors && d.errors.length > 0) {
281
+ terminal.blank();
282
+ terminal.error("Errors:");
283
+ for (const err of d.errors) {
284
+ terminal.dim(` - ${err}`);
285
+ }
286
+ }
287
+ if (!d.waited) {
288
+ terminal.blank();
289
+ terminal.dim("Run started. Use 'griffin hub runs' to check progress.");
290
+ }
291
+ }
292
+ function renderNotificationsList(d) {
293
+ if (d.rules.length === 0) {
294
+ terminal.info("No notification rules found.");
295
+ return;
296
+ }
297
+ terminal.info("Notification Rules");
298
+ terminal.blank();
299
+ const table = terminal.table({
300
+ head: ["ID", "Name", "Monitor", "Trigger", "Channels", "Enabled"],
301
+ });
302
+ for (const rule of d.rules) {
303
+ const triggerDesc = formatTriggerDesc(rule.trigger);
304
+ table.push([
305
+ rule.id.substring(0, 8) + "...",
306
+ rule.name,
307
+ rule.monitorId ?? "all monitors",
308
+ triggerDesc,
309
+ rule.channels.join(", "),
310
+ rule.enabled ? "✓" : "✗",
311
+ ]);
312
+ }
313
+ terminal.log(table.toString());
314
+ }
315
+ function formatTriggerDesc(trigger) {
316
+ if (trigger && typeof trigger === "object" && "type" in trigger) {
317
+ const t = trigger;
318
+ switch (t.type) {
319
+ case "run_failed":
320
+ return "Run failed";
321
+ case "run_recovered":
322
+ return "Run recovered";
323
+ case "consecutive_failures":
324
+ return `${t.threshold} consecutive failures`;
325
+ case "success_rate_below":
326
+ return `Success rate < ${t.threshold}% (${t.window_minutes}m)`;
327
+ case "latency_above":
328
+ return `${t.percentile} latency > ${t.threshold_ms}ms (${t.window_minutes}m)`;
329
+ default:
330
+ return String(t.type);
331
+ }
332
+ }
333
+ return String(trigger);
334
+ }
335
+ function renderNotificationsAdd(d) {
336
+ terminal.success(`Created notification rule: ${d.id}`);
337
+ }
338
+ function renderNotificationsDelete(d) {
339
+ terminal.success(`Deleted notification rule: ${d.id}`);
340
+ }
341
+ function renderNotificationsTest(d) {
342
+ if (d.success) {
343
+ terminal.success(d.message || "Test notification delivered successfully");
344
+ }
345
+ else {
346
+ terminal.error("Test notification failed");
347
+ }
348
+ }