@tailglow/cli 0.2.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/README.md +104 -0
- package/package.json +41 -0
- package/src/commands.generated.js +8554 -0
- package/src/config-command.js +169 -0
- package/src/config.js +182 -0
- package/src/flags.js +299 -0
- package/src/help.js +307 -0
- package/src/index.js +370 -0
- package/src/output.js +148 -0
- package/src/request.js +212 -0
- package/src/types.d.ts +55 -0
package/src/output.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether to emit machine-readable output.
|
|
3
|
+
*
|
|
4
|
+
* `--json` is explicit, but a pipe is treated as the same request: `tglow metrics list | jq` should
|
|
5
|
+
* work without anyone remembering a flag, and an agent capturing stdout gets JSON by default rather
|
|
6
|
+
* than a table it has to unpick. A TTY is the only place a human is watching, so it is the only
|
|
7
|
+
* place a table is the right answer.
|
|
8
|
+
*
|
|
9
|
+
* @param {{ json?: unknown }} flags
|
|
10
|
+
* @param {{ isTTY?: boolean }} [stream]
|
|
11
|
+
*/
|
|
12
|
+
export function shouldEmitJson(flags, stream = process.stdout) {
|
|
13
|
+
return Boolean(flags.json) || !stream.isTTY;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Renders control characters visibly instead of letting a terminal act on them.
|
|
18
|
+
*
|
|
19
|
+
* Every string here came from the API, which means it came from whoever named the resource. A
|
|
20
|
+
* project named with an embedded newline forges a row in someone elses table, and one carrying an
|
|
21
|
+
* ANSI erase-display sequence clears their screen. JSON output escapes these already; the human
|
|
22
|
+
* table is the path that has to do it itself.
|
|
23
|
+
*
|
|
24
|
+
* The range covers C1 as well as C0, because a terminal in 8-bit mode reads U+009B as CSI and will
|
|
25
|
+
* act on it exactly as it acts on the two-character escape form.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} value
|
|
28
|
+
*/
|
|
29
|
+
export function sanitizeText(value) {
|
|
30
|
+
/** @type {Record<string, string>} */
|
|
31
|
+
const escapes = { "\n": "\\n", "\r": "\\r", "\t": "\\t" };
|
|
32
|
+
|
|
33
|
+
return value.replaceAll(/[\u0000-\u001f\u007f-\u009f]/g, (character) => {
|
|
34
|
+
if (escapes[character]) return escapes[character];
|
|
35
|
+
return `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** @param {unknown} value */
|
|
40
|
+
function toCell(value) {
|
|
41
|
+
if (value === null || value === undefined) return "";
|
|
42
|
+
if (typeof value === "string") return sanitizeText(value);
|
|
43
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
44
|
+
if (Array.isArray(value)) return `${value.length} item${value.length === 1 ? "" : "s"}`;
|
|
45
|
+
return "{...}";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Columns worth showing first, when a row has them. A generated CLI has no per-resource opinion, so
|
|
50
|
+
* this is the one piece of presentation the registry cannot supply: every resource shares an id and
|
|
51
|
+
* most share a name, and those are what someone scanning a list is looking for.
|
|
52
|
+
*/
|
|
53
|
+
const PREFERRED_COLUMNS = ["id", "name", "slug", "status", "object"];
|
|
54
|
+
|
|
55
|
+
/** @param {Record<string, unknown>[]} rows */
|
|
56
|
+
function selectColumns(rows) {
|
|
57
|
+
const present = new Set(rows.flatMap((row) => Object.keys(row)));
|
|
58
|
+
const preferred = PREFERRED_COLUMNS.filter((column) => present.has(column));
|
|
59
|
+
const remaining = [...present].filter(
|
|
60
|
+
(column) => !preferred.includes(column) && !column.endsWith("_at")
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
return [...preferred, ...remaining].slice(0, 6);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* @param {unknown} data
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
export function formatTable(data) {
|
|
71
|
+
const rows = Array.isArray(data) ? data : [data];
|
|
72
|
+
if (rows.length === 0) return "No results.";
|
|
73
|
+
if (rows.some((row) => typeof row !== "object" || row === null)) {
|
|
74
|
+
return rows.map((row) => sanitizeText(String(row))).join("\n");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const columns = selectColumns(/** @type {Record<string, unknown>[]} */ (rows));
|
|
78
|
+
const header = columns.map((column) => sanitizeText(column.toUpperCase()));
|
|
79
|
+
const body = rows.map((row) =>
|
|
80
|
+
columns.map((column) => toCell(/** @type {Record<string, unknown>} */ (row)[column]))
|
|
81
|
+
);
|
|
82
|
+
const widths = header.map((_, index) =>
|
|
83
|
+
Math.max(header[index].length, ...body.map((cells) => cells[index].length))
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
return [header, ...body]
|
|
87
|
+
.map((cells) =>
|
|
88
|
+
cells
|
|
89
|
+
.map((cell, index) => cell.padEnd(widths[index]))
|
|
90
|
+
.join(" ")
|
|
91
|
+
.trimEnd()
|
|
92
|
+
)
|
|
93
|
+
.join("\n");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Renders one API response.
|
|
98
|
+
*
|
|
99
|
+
* JSON mode emits the payload exactly as the API sent it, envelope and all. That is what makes a
|
|
100
|
+
* scripted follow-up possible: the pagination cursors and the message travel with the data instead
|
|
101
|
+
* of being thrown away on the way to stdout. A terminal gets the part a person came for, or the
|
|
102
|
+
* message when the call returns no data at all, which is how a delete reports itself.
|
|
103
|
+
*
|
|
104
|
+
* @param {{ data?: unknown; message?: unknown } | null} payload
|
|
105
|
+
* @param {{ json?: unknown }} flags
|
|
106
|
+
* @param {{ isTTY?: boolean }} [stream]
|
|
107
|
+
*/
|
|
108
|
+
export function renderResponse(payload, flags, stream = process.stdout) {
|
|
109
|
+
if (shouldEmitJson(flags, stream)) return JSON.stringify(payload, null, 2);
|
|
110
|
+
|
|
111
|
+
const data = payload && "data" in payload ? payload.data : payload;
|
|
112
|
+
if (data === null || data === undefined) {
|
|
113
|
+
return typeof payload?.message === "string" ? sanitizeText(payload.message) : "Done.";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return formatTable(data);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Renders a `--all` run, including whether it stopped early.
|
|
121
|
+
*
|
|
122
|
+
* The truncation notice cannot be appended to JSON as prose: `--all --max=1 | jq` would receive a
|
|
123
|
+
* valid document followed by a sentence, and fail exactly when a caller pulling large results needs
|
|
124
|
+
* the continuation cursor most. So machine output carries it as fields instead.
|
|
125
|
+
*
|
|
126
|
+
* @param {{
|
|
127
|
+
* data: unknown[];
|
|
128
|
+
* truncated: boolean;
|
|
129
|
+
* next_cursor: string | null;
|
|
130
|
+
* resume_command: string;
|
|
131
|
+
* }} result
|
|
132
|
+
* @param {{ json?: unknown }} flags
|
|
133
|
+
* @param {{ isTTY?: boolean }} [stream]
|
|
134
|
+
*/
|
|
135
|
+
export function renderPagedResult(
|
|
136
|
+
{ data, truncated, next_cursor, resume_command },
|
|
137
|
+
flags,
|
|
138
|
+
stream = process.stdout
|
|
139
|
+
) {
|
|
140
|
+
if (shouldEmitJson(flags, stream)) {
|
|
141
|
+
return JSON.stringify({ data, truncated, next_cursor }, null, 2);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const table = formatTable(data);
|
|
145
|
+
if (!truncated) return table;
|
|
146
|
+
|
|
147
|
+
return `${table}\n\nStopped at the result cap. Continue with:\n ${sanitizeText(resume_command)}`;
|
|
148
|
+
}
|
package/src/request.js
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import { CLI_CONFIGS, resolveApiKey, resolveApiUrl } from "./config.js";
|
|
2
|
+
import { sanitizeText } from "./output.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Renders a field-level validation failure as lines instead of a wall of escaped JSON.
|
|
6
|
+
*
|
|
7
|
+
* The API reports these as a zod issue array serialised INTO the message string, so the default
|
|
8
|
+
* rendering is a single line of `[\n {\n \"code\": ...` that a reader has to decode by eye. Every
|
|
9
|
+
* reviewer who hit one said the same thing: the information was right there and unreadable.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} message
|
|
12
|
+
* @returns {string | null}
|
|
13
|
+
*/
|
|
14
|
+
export function formatValidationIssues(message) {
|
|
15
|
+
if (!message.trimStart().startsWith("[")) return null;
|
|
16
|
+
|
|
17
|
+
/** @type {unknown} */
|
|
18
|
+
let issues;
|
|
19
|
+
try {
|
|
20
|
+
issues = JSON.parse(message);
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
if (!Array.isArray(issues) || issues.length === 0) return null;
|
|
25
|
+
|
|
26
|
+
// Each piece is sanitized on its own, because the assembled string carries real newlines that the
|
|
27
|
+
// reader is meant to see. Sanitizing the whole thing afterwards would escape those too.
|
|
28
|
+
const lines = issues.map((issue) => {
|
|
29
|
+
const where =
|
|
30
|
+
Array.isArray(issue?.path) && issue.path.length > 0
|
|
31
|
+
? sanitizeText(issue.path.join("."))
|
|
32
|
+
: null;
|
|
33
|
+
const detail = sanitizeText(
|
|
34
|
+
typeof issue?.message === "string" ? issue.message : JSON.stringify(issue)
|
|
35
|
+
);
|
|
36
|
+
return where ? ` ${where}: ${detail}` : ` ${detail}`;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return ["The API rejected these fields:", ...lines].join("\n");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What went wrong, said in a way that names the next move.
|
|
44
|
+
*
|
|
45
|
+
* A bare "The requested resource was not found" cannot tell you WHICH id was wrong, which matters
|
|
46
|
+
* most on nested paths where either the parent or the child could be the miss. The address answers
|
|
47
|
+
* that without the CLI having to know anything about id formats.
|
|
48
|
+
*
|
|
49
|
+
* @param {{ status: number; message: unknown; method: string; url: URL }} failure
|
|
50
|
+
*/
|
|
51
|
+
function describeFailure({ status, message, method, url }) {
|
|
52
|
+
// Parsed BEFORE sanitizing: escaping the newlines the API embedded would stop it parsing at all,
|
|
53
|
+
// which is how the first attempt at this silently fell through to the raw dump.
|
|
54
|
+
const issues = formatValidationIssues(String(message));
|
|
55
|
+
if (issues) return `${status} ${issues}`;
|
|
56
|
+
|
|
57
|
+
const text = sanitizeText(String(message));
|
|
58
|
+
if (status === 404) {
|
|
59
|
+
return `${status} ${text}\nNothing answers ${method} ${sanitizeText(url.pathname)}.`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return `${status} ${text}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Reads a response body as the API's envelope.
|
|
67
|
+
*
|
|
68
|
+
* `JSON.parse` returns an unknown shape, so the runtime check below is what makes the type true: an
|
|
69
|
+
* array or a bare scalar is not an envelope and reads as no body at all.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} text
|
|
72
|
+
* @returns {import("./types.d.ts").ApiResponse | null}
|
|
73
|
+
*/
|
|
74
|
+
function readEnvelope(text) {
|
|
75
|
+
/** @type {unknown} */
|
|
76
|
+
const value = JSON.parse(text);
|
|
77
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
78
|
+
|
|
79
|
+
return /** @type {import("./types.d.ts").ApiResponse} */ (value);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {{
|
|
84
|
+
* method: string;
|
|
85
|
+
* path: string;
|
|
86
|
+
* query?: Record<string, unknown>;
|
|
87
|
+
* body?: Record<string, unknown>;
|
|
88
|
+
* flags?: Record<string, unknown>;
|
|
89
|
+
* config?: Record<string, string>;
|
|
90
|
+
* }} options
|
|
91
|
+
* @returns {Promise<import("./types.d.ts").ApiResponse | null>}
|
|
92
|
+
*/
|
|
93
|
+
export async function requestApi({ method, path, query = {}, body = {}, flags = {}, config = {} }) {
|
|
94
|
+
const url = new URL(path, resolveApiUrl({ flags, config }));
|
|
95
|
+
for (const [key, value] of Object.entries(query)) {
|
|
96
|
+
if (value === undefined || value === null) continue;
|
|
97
|
+
url.searchParams.set(key, typeof value === "object" ? JSON.stringify(value) : String(value));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const response = await fetch(url, {
|
|
101
|
+
method,
|
|
102
|
+
headers: {
|
|
103
|
+
Authorization: `Bearer ${resolveApiKey({ flags, config })}`,
|
|
104
|
+
"Content-Type": "application/json"
|
|
105
|
+
},
|
|
106
|
+
body: method === "GET" ? undefined : JSON.stringify(body)
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const body_text = await response.text();
|
|
110
|
+
|
|
111
|
+
/** @type {import("./types.d.ts").ApiResponse | null} */
|
|
112
|
+
let payload = null;
|
|
113
|
+
let parsed = true;
|
|
114
|
+
if (body_text.trim() !== "") {
|
|
115
|
+
try {
|
|
116
|
+
payload = readEnvelope(body_text);
|
|
117
|
+
} catch {
|
|
118
|
+
parsed = false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!response.ok) {
|
|
123
|
+
const message = payload?.error?.message || payload?.message || response.statusText;
|
|
124
|
+
throw new Error(describeFailure({ status: response.status, message, method, url }));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// A 2xx body that is not JSON means something between here and the API answered instead: a proxy
|
|
128
|
+
// error page, a truncated response. Treating it as `null` would print an empty result and exit 0,
|
|
129
|
+
// which under `--all` reads as "that is everything".
|
|
130
|
+
if (!parsed) {
|
|
131
|
+
throw new Error(`${response.status} response was not JSON. Check --api-url.`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return payload;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Follows `pagination.next_cursor` until the API runs out or the cap is reached.
|
|
139
|
+
*
|
|
140
|
+
* The cap is the point. Records and collection documents are unbounded in this product, so an
|
|
141
|
+
* uncapped follow would happily pull millions of rows into a terminal. It is deliberately NOT a
|
|
142
|
+
* per-resource rule: the registry does not know which resources are large, and a hand-maintained
|
|
143
|
+
* list of dangerous ones is exactly the sort of thing that drifts out of date.
|
|
144
|
+
*
|
|
145
|
+
* Every request asks for at most the rows still allowed under the cap, so the run always stops on a
|
|
146
|
+
* page boundary. That is what makes the reported cursor exact: fetching a full page and discarding
|
|
147
|
+
* its tail would hand back a cursor positioned before rows that were never shown, and resuming from
|
|
148
|
+
* it would repeat them.
|
|
149
|
+
*
|
|
150
|
+
* Stopping is always reported with the cursor to resume from, because a silent truncation reads as
|
|
151
|
+
* "this is everything", which is how a partial set becomes a wrong conclusion.
|
|
152
|
+
*
|
|
153
|
+
* @param {{
|
|
154
|
+
* method: string;
|
|
155
|
+
* path: string;
|
|
156
|
+
* query?: Record<string, unknown>;
|
|
157
|
+
* flags?: Record<string, unknown>;
|
|
158
|
+
* config?: Record<string, string>;
|
|
159
|
+
* max?: number;
|
|
160
|
+
* }} options
|
|
161
|
+
* @returns {Promise<{ data: unknown[]; truncated: boolean; next_cursor: string | null }>}
|
|
162
|
+
*/
|
|
163
|
+
export async function requestAllPages({
|
|
164
|
+
method,
|
|
165
|
+
path,
|
|
166
|
+
query = {},
|
|
167
|
+
flags = {},
|
|
168
|
+
config = {},
|
|
169
|
+
max = CLI_CONFIGS.DEFAULT_MAX_RECORDS
|
|
170
|
+
}) {
|
|
171
|
+
/** @type {unknown[]} */
|
|
172
|
+
const collected = [];
|
|
173
|
+
/** @type {string | null} */
|
|
174
|
+
let cursor = typeof query.after === "string" ? query.after : null;
|
|
175
|
+
/** @type {Set<string>} */
|
|
176
|
+
const seen_cursors = new Set(cursor ? [cursor] : []);
|
|
177
|
+
|
|
178
|
+
for (;;) {
|
|
179
|
+
const payload = await requestApi({
|
|
180
|
+
method,
|
|
181
|
+
path,
|
|
182
|
+
query: {
|
|
183
|
+
...query,
|
|
184
|
+
limit: Math.min(max - collected.length, CLI_CONFIGS.PAGE_SIZE),
|
|
185
|
+
...(cursor ? { after: cursor } : {})
|
|
186
|
+
},
|
|
187
|
+
flags,
|
|
188
|
+
config
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const page = Array.isArray(payload?.data) ? payload.data : [];
|
|
192
|
+
collected.push(...page);
|
|
193
|
+
|
|
194
|
+
const next = payload?.pagination?.next_cursor ?? null;
|
|
195
|
+
if (!next || page.length === 0) return { data: collected, truncated: false, next_cursor: null };
|
|
196
|
+
|
|
197
|
+
// Checked BEFORE the cap. A page that both reaches the cap and hands back a cursor already used
|
|
198
|
+
// would otherwise be reported as resumable, and the continuation printed for it re-fetches the
|
|
199
|
+
// same rows forever.
|
|
200
|
+
if (seen_cursors.has(next)) {
|
|
201
|
+
throw new Error(
|
|
202
|
+
`Pagination stopped advancing after ${collected.length} results: the API returned a cursor ` +
|
|
203
|
+
"that was already used. Nothing was lost, but the run is incomplete."
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (collected.length >= max) return { data: collected, truncated: true, next_cursor: next };
|
|
208
|
+
|
|
209
|
+
seen_cursors.add(next);
|
|
210
|
+
cursor = next;
|
|
211
|
+
}
|
|
212
|
+
}
|
package/src/types.d.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shape of `commands.generated.js`.
|
|
3
|
+
*
|
|
4
|
+
* Declared by hand and asserted onto the generated module, so a change to the generator's output
|
|
5
|
+
* fails the type check here rather than at the first flag someone tries to pass.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The envelope every API route returns.
|
|
10
|
+
*
|
|
11
|
+
* Declared rather than inferred from `Response.json()`, whose type differs between the `@types/bun`
|
|
12
|
+
* versions this package is checked under: on its own, and again through `apps/api`'s config when
|
|
13
|
+
* the end-to-end test imports it.
|
|
14
|
+
*/
|
|
15
|
+
export interface ApiResponse {
|
|
16
|
+
data?: unknown;
|
|
17
|
+
message?: string;
|
|
18
|
+
pagination?: { next_cursor?: string | null } | null;
|
|
19
|
+
error?: { message?: string } | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** One value an operation accepts, in whichever of the three places it belongs. */
|
|
23
|
+
export interface CliField {
|
|
24
|
+
/** Snake_case, as the API names it. `--flag-name` is this with underscores swapped for hyphens. */
|
|
25
|
+
name: string;
|
|
26
|
+
/** The wire type, which decides how a flag string is coerced. `object`-prefixed types take JSON. */
|
|
27
|
+
type: string;
|
|
28
|
+
required: boolean;
|
|
29
|
+
/** Customer-facing copy from the reference. Empty when the field documents itself. */
|
|
30
|
+
description: string;
|
|
31
|
+
/** Populated for enum-typed fields. Empty when the field accepts open values. */
|
|
32
|
+
allowed_values: string[];
|
|
33
|
+
/** The replacement to use instead, when the field is on its way out. */
|
|
34
|
+
deprecated: string | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** One API operation, addressed as `<resource> <command>`. */
|
|
38
|
+
export interface CliCommand {
|
|
39
|
+
resource: string;
|
|
40
|
+
command: string;
|
|
41
|
+
/** Stable machine identity, unique across the whole API. */
|
|
42
|
+
operation_id: string;
|
|
43
|
+
summary: string;
|
|
44
|
+
description: string;
|
|
45
|
+
method: string;
|
|
46
|
+
path: string;
|
|
47
|
+
/** Equivalent paths the same operation answers on, usually a nested form of `path`. */
|
|
48
|
+
alternate_paths: string[];
|
|
49
|
+
scopes: string[];
|
|
50
|
+
/** Behaviour the field table cannot express, from the route's `@docs` tags. */
|
|
51
|
+
notes: string[];
|
|
52
|
+
path_params: CliField[];
|
|
53
|
+
query_params: CliField[];
|
|
54
|
+
body_fields: CliField[];
|
|
55
|
+
}
|