@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/src/help.js ADDED
@@ -0,0 +1,307 @@
1
+ import { CLI_COMMANDS } from "./commands.generated.js";
2
+ import { CLI_CONFIGS, CONFIG_KEYS } from "./config.js";
3
+ import { describeFlagSyntax, toFlagName } from "./flags.js";
4
+
5
+ /**
6
+ * The runtime's own flags, described once.
7
+ *
8
+ * Prose help and machine help both render FROM this. They were written separately at first, and the
9
+ * two drifted immediately: the machine form listed no global flags at all, so an agent never
10
+ * learned that `--all`, `--max` or `TAILGLOW_API_KEY` existed and put its credential on the command
11
+ * line instead. One list is the only way that stays fixed.
12
+ *
13
+ * @type {{ flag: string; argument: string | null; env: string | null; description: string }[]}
14
+ */
15
+ export const GLOBAL_FLAGS = [
16
+ {
17
+ flag: "--json",
18
+ argument: null,
19
+ env: null,
20
+ description: "Emit JSON. Implied whenever output is piped or redirected."
21
+ },
22
+ {
23
+ flag: "--all",
24
+ argument: null,
25
+ env: null,
26
+ description:
27
+ "Follow pagination on a list command until the results run out or --max is reached. Owns paging, so it cannot be combined with --limit or --before."
28
+ },
29
+ {
30
+ flag: "--max",
31
+ argument: "<n>",
32
+ env: null,
33
+ description: `Total rows --all may return. Defaults to ${CLI_CONFIGS.DEFAULT_MAX_RECORDS}. Only valid with --all.`
34
+ },
35
+ {
36
+ flag: "--after",
37
+ argument: "<cursor>",
38
+ env: null,
39
+ description: "Resume a list from a cursor a previous run reported."
40
+ },
41
+ {
42
+ flag: "--yes",
43
+ argument: null,
44
+ env: null,
45
+ description:
46
+ "Skip the confirmation on a delete. A delete run without a terminal never prompts, so this is only needed interactively."
47
+ },
48
+ {
49
+ flag: "--project",
50
+ argument: "<id>",
51
+ env: "TAILGLOW_PROJECT",
52
+ description: "Project to act on, when a command needs one and --project-id is not given."
53
+ },
54
+ {
55
+ flag: "--team",
56
+ argument: "<id>",
57
+ env: "TAILGLOW_TEAM",
58
+ description: "Team to act on, when a command needs one and --team-id is not given."
59
+ },
60
+ {
61
+ flag: "--api-key",
62
+ argument: "<key>",
63
+ env: "TAILGLOW_API_KEY",
64
+ description:
65
+ "Credential to authenticate with. Prefer the environment variable: a key passed as a flag is visible in the process list and in shell history."
66
+ },
67
+ {
68
+ flag: "--api-url",
69
+ argument: "<url>",
70
+ env: "TAILGLOW_API_URL",
71
+ description: `API origin to call. Defaults to ${CLI_CONFIGS.DEFAULT_API_URL}.`
72
+ },
73
+ { flag: "--help", argument: null, env: null, description: "Describe the command and stop." },
74
+ { flag: "--version", argument: null, env: null, description: "Print the version and stop." }
75
+ ];
76
+
77
+ /**
78
+ * How the CLI behaves, in the places where knowing it changes what you type.
79
+ *
80
+ * Carried in BOTH help forms for the same reason as the flag list: an agent that only ever sees the
81
+ * machine form was missing all of it.
82
+ */
83
+ export const USAGE_NOTES = [
84
+ "Flags are the field name in kebab-case: a field named `time_window_minutes` is `--time-window-minutes`.",
85
+ "Path parameters are flags too, never positional: `--project-id prj_x`, not `tglow ... prj_x`.",
86
+ "A true/false flag is passed alone for true, or written attached for false: `--is-public` or `--is-public=false`.",
87
+ 'A field typed `object`, `object[]` or `Scopes` takes JSON as a single argument: --joins \'[{"view_id":"view_b"}]\'.',
88
+ "A field typed `string[]` takes a comma-separated list or a JSON array.",
89
+ "JSON output is the API response as it was sent: read `.data` for results and `.pagination.next_cursor` to continue.",
90
+ "Under --all, JSON output is instead { data, truncated, next_cursor }, so a capped run can resume with --after.",
91
+ "`--all` replaces `--limit` and `--before` rather than combining with them: use `--max` for the total, or `--limit` alone for one page.",
92
+ "Deletes are immediate and unconfirmed when there is no terminal attached. Many resources delete softly; the response says so.",
93
+ "Precedence for anything that has a default is flag, then environment variable, then `tglow config`."
94
+ ];
95
+
96
+ /** @param {import("./types.d.ts").CliCommand[]} commands */
97
+ function listResources(commands) {
98
+ return [...new Set(commands.map((command) => command.resource))].sort();
99
+ }
100
+
101
+ /** @param {(typeof GLOBAL_FLAGS)[number]} entry */
102
+ function renderGlobalFlag(entry) {
103
+ const left = entry.argument ? `${entry.flag} ${entry.argument}` : entry.flag;
104
+ return {
105
+ left,
106
+ description: entry.env ? `${entry.description} (${entry.env})` : entry.description
107
+ };
108
+ }
109
+
110
+ /**
111
+ * One command's flags, named the way they are typed rather than the way the API names them.
112
+ *
113
+ * The registry stores wire names, so handing it over unchanged tells a reader that the flag is
114
+ * `--show_onboarding_ui`. It is not; it is `--show-onboarding-ui`, and that cost a model three
115
+ * attempts to create one project.
116
+ *
117
+ * @param {import("./types.d.ts").CliCommand} command
118
+ */
119
+ export function describeCommandFlags(command) {
120
+ // A resource reachable at two paths declares the same field in two places: `project_id` is a path
121
+ // parameter on the nested form AND a body field on the flat one, so listing both showed the same
122
+ // flag twice with contradicting requiredness. What the caller needs is what THIS command will do,
123
+ // which is decided by the placeholders in the path it actually calls.
124
+ const placeholders = new Set([...command.path.matchAll(/:([a-z_]+)/g)].map(([, name]) => name));
125
+
126
+ /** @param {import("./types.d.ts").CliField[]} fields @param {string} location */
127
+ const describe = (fields, location) =>
128
+ fields.map((field) => ({
129
+ flag: `--${toFlagName(field.name)}`,
130
+ field: field.name,
131
+ in: location,
132
+ type: field.type,
133
+ required: location === "path" ? true : field.required,
134
+ description: field.description,
135
+ allowed_values: field.allowed_values,
136
+ syntax: describeFlagSyntax(toFlagName(field.name), field.type),
137
+ deprecated: field.deprecated
138
+ }));
139
+
140
+ const path_flags = describe(
141
+ command.path_params.filter((field) => placeholders.has(field.name)),
142
+ "path"
143
+ );
144
+ const claimed = new Set(path_flags.map((entry) => entry.field));
145
+
146
+ return [
147
+ ...path_flags,
148
+ ...describe(
149
+ command.query_params.filter((field) => !claimed.has(field.name)),
150
+ "query"
151
+ ),
152
+ ...describe(
153
+ command.body_fields.filter((field) => !claimed.has(field.name)),
154
+ "body"
155
+ )
156
+ ];
157
+ }
158
+
159
+ /**
160
+ * The same help, for a reader that is not a person.
161
+ *
162
+ * Not the registry entry: the registry describes the API, and what a caller needs is the COMMAND
163
+ * LINE. Flag spelling, what is required, how to encode a value, and every global flag that
164
+ * applies.
165
+ *
166
+ * @param {{ resource?: string; command?: import("./types.d.ts").CliCommand }} [subject]
167
+ */
168
+ export function describeForMachines({ resource, command } = {}) {
169
+ const globals = GLOBAL_FLAGS.map(({ flag, argument, env, description }) => ({
170
+ flag,
171
+ argument,
172
+ env,
173
+ description
174
+ }));
175
+
176
+ if (command) {
177
+ return {
178
+ resource: command.resource,
179
+ command: command.command,
180
+ usage: `tglow ${command.resource} ${command.command} [flags]`,
181
+ operation_id: command.operation_id,
182
+ summary: command.summary,
183
+ description: command.description,
184
+ method: command.method,
185
+ path: command.path,
186
+ alternate_paths: command.alternate_paths,
187
+ scopes: command.scopes,
188
+ notes: command.notes,
189
+ flags: describeCommandFlags(command),
190
+ global_flags: globals,
191
+ usage_notes: USAGE_NOTES
192
+ };
193
+ }
194
+
195
+ if (resource) {
196
+ return {
197
+ resource,
198
+ usage: `tglow ${resource} <command> [flags]`,
199
+ commands: CLI_COMMANDS.filter((entry) => entry.resource === resource).map((entry) => ({
200
+ command: entry.command,
201
+ operation_id: entry.operation_id,
202
+ summary: entry.summary,
203
+ method: entry.method,
204
+ path: entry.path
205
+ })),
206
+ global_flags: globals,
207
+ usage_notes: USAGE_NOTES
208
+ };
209
+ }
210
+
211
+ return {
212
+ usage: "tglow <resource> <command> [flags]",
213
+ resources: listResources(CLI_COMMANDS),
214
+ config: {
215
+ command: "tglow config",
216
+ path: CLI_CONFIGS.CONFIG_PATH,
217
+ keys: Object.keys(CONFIG_KEYS)
218
+ },
219
+ global_flags: globals,
220
+ usage_notes: USAGE_NOTES
221
+ };
222
+ }
223
+
224
+ export function renderRootHelp() {
225
+ const resources = listResources(CLI_COMMANDS);
226
+ const globals = GLOBAL_FLAGS.map(renderGlobalFlag);
227
+ const width = Math.max(...globals.map((entry) => entry.left.length));
228
+
229
+ return [
230
+ "tglow: the Tailglow command line",
231
+ "",
232
+ "Usage:",
233
+ " tglow <resource> <command> [flags]",
234
+ "",
235
+ "Resources:",
236
+ ...resources.map((resource) => ` ${resource}`),
237
+ " config (stored defaults and credentials)",
238
+ "",
239
+ "Run `tglow <resource>` to list its commands, or `tglow <resource> <command> --help`.",
240
+ "",
241
+ "Global flags:",
242
+ ...globals.map((entry) => ` ${entry.left.padEnd(width)} ${entry.description}`),
243
+ "",
244
+ "Notes:",
245
+ ...USAGE_NOTES.map((note) => ` - ${note}`)
246
+ ].join("\n");
247
+ }
248
+
249
+ /** @param {string} resource */
250
+ export function renderResourceHelp(resource) {
251
+ const commands = CLI_COMMANDS.filter((command) => command.resource === resource);
252
+ if (commands.length === 0) return null;
253
+
254
+ const width = Math.max(...commands.map((command) => command.command.length));
255
+
256
+ return [
257
+ `tglow ${resource}`,
258
+ "",
259
+ "Commands:",
260
+ ...commands.map(
261
+ (command) => ` ${command.command.padEnd(width)} ${command.summary} (${command.method})`
262
+ ),
263
+ "",
264
+ `Run \`tglow ${resource} <command> --help\` for its flags.`,
265
+ "Run `tglow --help` for the flags every command accepts."
266
+ ].join("\n");
267
+ }
268
+
269
+ /** @param {{ fields: import("./types.d.ts").CliField[]; title: string }} section */
270
+ function renderFields({ fields, title }) {
271
+ if (fields.length === 0) return [];
272
+
273
+ return [
274
+ "",
275
+ `${title}:`,
276
+ ...fields.map((field) => {
277
+ const flag = toFlagName(field.name);
278
+ const required = field.required ? " (required)" : "";
279
+ const allowed =
280
+ field.allowed_values.length > 0 ? ` [${field.allowed_values.join(", ")}]` : "";
281
+ const description = field.description ? ` ${field.description}` : "";
282
+
283
+ return ` ${describeFlagSyntax(flag, field.type)}${required}${allowed}${description}`;
284
+ })
285
+ ];
286
+ }
287
+
288
+ /** @param {import("./types.d.ts").CliCommand} command */
289
+ export function renderCommandHelp(command) {
290
+ const paths = [command.path, ...command.alternate_paths];
291
+
292
+ return [
293
+ `tglow ${command.resource} ${command.command}: ${command.summary}`,
294
+ "",
295
+ command.description,
296
+ "",
297
+ "Endpoint:",
298
+ ...paths.map((path) => ` ${command.method} ${path}`),
299
+ ...(command.scopes.length > 0 ? ["", `Scope: ${command.scopes.join(" + ")}`] : []),
300
+ ...renderFields({ fields: command.path_params, title: "Path parameters" }),
301
+ ...renderFields({ fields: command.query_params, title: "Query flags" }),
302
+ ...renderFields({ fields: command.body_fields, title: "Body flags" }),
303
+ ...(command.notes.length > 0 ? ["", "Notes:", ...command.notes.map((n) => ` - ${n}`)] : []),
304
+ "",
305
+ "Run `tglow --help` for the flags every command accepts."
306
+ ].join("\n");
307
+ }
package/src/index.js ADDED
@@ -0,0 +1,370 @@
1
+ #!/usr/bin/env node
2
+ import { realpathSync } from "node:fs";
3
+ import fs from "node:fs/promises";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { parseArgs } from "node:util";
7
+ import { confirm, isCancel } from "@clack/prompts";
8
+ import { CLI_COMMANDS } from "./commands.generated.js";
9
+ import { runConfigCommand } from "./config-command.js";
10
+ import { CLI_CONFIGS, loadConfig, resolveSetting } from "./config.js";
11
+ import {
12
+ assertSuppliedValuesArePresent,
13
+ buildParserOptions,
14
+ buildRequestParts,
15
+ isIntegerText,
16
+ normalizeSwitches,
17
+ toFlagName
18
+ } from "./flags.js";
19
+ import {
20
+ describeForMachines,
21
+ renderCommandHelp,
22
+ renderResourceHelp,
23
+ renderRootHelp
24
+ } from "./help.js";
25
+ import { renderPagedResult, renderResponse, sanitizeText, shouldEmitJson } from "./output.js";
26
+ import { requestAllPages, requestApi } from "./request.js";
27
+
28
+ /** Path parameters people refer to by the shorter name they actually type. */
29
+ const PATH_ALIASES = { project_id: "project", team_id: "team" };
30
+
31
+ /**
32
+ * Path values a URL parser would act on rather than carry.
33
+ *
34
+ * `encodeURIComponent` leaves a dot alone, so `--team=..` would build `/v1/teams/../users/usr_x`,
35
+ * which `new URL` resolves to `/v1/users/usr_x`: a different endpoint than the command claims to
36
+ * call. There are four such collisions in the current registry.
37
+ */
38
+ const UNSAFE_PATH_VALUES = new Set([".", ".."]);
39
+
40
+ const PARSER_OPTIONS = buildParserOptions(CLI_COMMANDS);
41
+
42
+ /** @param {string[]} argv */
43
+ function parseFlags(argv) {
44
+ return parseArgs({
45
+ args: argv,
46
+ strict: false,
47
+ allowPositionals: true,
48
+ options: PARSER_OPTIONS
49
+ });
50
+ }
51
+
52
+ /** @param {string} token */
53
+ function quoteToken(token) {
54
+ const safe = sanitizeText(token);
55
+ return /^[A-Za-z0-9._:/=-]+$/.test(safe) ? safe : `'${safe.replaceAll("'", `'\\''`)}'`;
56
+ }
57
+
58
+ /**
59
+ * Rebuilds the command as typed, minus the credential and any old cursor, plus the new one.
60
+ *
61
+ * Naming only the resource, command and cursor is not enough: `metrics list --project prj_other
62
+ * --all --max 1` would print a continuation with no `--project`, which either fails outright or,
63
+ * worse, addresses whatever project is configured using a cursor minted for a different one. The
64
+ * words someone typed are the only faithful description of what they asked for.
65
+ *
66
+ * @param {string[]} argv
67
+ * @param {string} cursor
68
+ */
69
+ export function buildResumeCommand(argv, cursor) {
70
+ /** @type {string[]} */
71
+ const parts = [];
72
+
73
+ for (let index = 0; index < argv.length; index += 1) {
74
+ const token = argv[index];
75
+ const flag = token.startsWith("--") ? token.slice(2).split("=")[0] : null;
76
+
77
+ // The key must never be echoed, and the old cursor is about to be replaced.
78
+ if (flag === "api-key" || flag === "after") {
79
+ if (!token.includes("=") && PARSER_OPTIONS[flag]?.type === "string") index += 1;
80
+ continue;
81
+ }
82
+
83
+ parts.push(quoteToken(token));
84
+ }
85
+
86
+ return `tglow ${parts.join(" ")} --after ${quoteToken(cursor)}`;
87
+ }
88
+
89
+ /**
90
+ * @param {Record<string, unknown>} flags
91
+ * @param {string} flag
92
+ */
93
+ function readFlag(flags, flag) {
94
+ const value = flags[flag];
95
+ return typeof value === "string" && value.length > 0 ? value : null;
96
+ }
97
+
98
+ /**
99
+ * Fills `:placeholders` from flags, then configuration.
100
+ *
101
+ * Falling back to configuration is what lets `tglow metrics list` work at all: nearly every path is
102
+ * project-scoped, and repeating the project on every call is the difference between a tool someone
103
+ * uses and one they wrap in a shell function.
104
+ *
105
+ * @param {import("./types.d.ts").CliCommand} command
106
+ * @param {{ flags: Record<string, unknown>; config: Record<string, string> }} sources
107
+ */
108
+ function resolvePath(command, { flags, config }) {
109
+ const placeholders = [...command.path.matchAll(/:([a-z_]+)/g)].map(([, name]) => name);
110
+ /** @type {string[]} */
111
+ const missing = [];
112
+ /** @type {string[]} */
113
+ const unsafe = [];
114
+ /** @type {Set<string>} */
115
+ const consumed = new Set();
116
+
117
+ const path = placeholders.reduce((current, name) => {
118
+ // Only project and team fall back to configuration. Resolving EVERY placeholder that way would
119
+ // make `TAILGLOW_SOURCE_ID` an implicit delete target: `tglow sources delete --project prj_x
120
+ // --yes` would find a source id nobody named on the command line and destroy it.
121
+ const alias = PATH_ALIASES[/** @type {keyof typeof PATH_ALIASES} */ (name)] ?? null;
122
+ const value =
123
+ readFlag(flags, toFlagName(name)) ??
124
+ (alias ? resolveSetting(alias, { flags, config }) : null);
125
+
126
+ if (!value) {
127
+ missing.push(name);
128
+ return current;
129
+ }
130
+ if (UNSAFE_PATH_VALUES.has(value)) {
131
+ unsafe.push(name);
132
+ return current;
133
+ }
134
+
135
+ consumed.add(name);
136
+ return current.replace(`:${name}`, encodeURIComponent(value));
137
+ }, command.path);
138
+
139
+ return { path, missing, unsafe, consumed };
140
+ }
141
+
142
+ /** @param {import("./types.d.ts").CliCommand} command */
143
+ function isPaginated(command) {
144
+ return command.method === "GET" && command.query_params.some((field) => field.name === "after");
145
+ }
146
+
147
+ async function readVersion() {
148
+ const manifest = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
149
+ const { version } = JSON.parse(await fs.readFile(manifest, "utf-8"));
150
+ return version;
151
+ }
152
+
153
+ /**
154
+ * @param {string} message
155
+ * @param {{ json?: unknown }} flags
156
+ */
157
+ function fail(message, flags) {
158
+ return { output: shouldEmitJson(flags) ? JSON.stringify({ error: message }) : message, code: 1 };
159
+ }
160
+
161
+ /**
162
+ * @param {import("./types.d.ts").CliCommand} command
163
+ * @param {Record<string, unknown>} flags
164
+ */
165
+ async function confirmDestructive(command, flags) {
166
+ // Only a terminal gets asked. A scripted `tglow projects delete --project prj_x` already said what
167
+ // it meant, and blocking it on a flag would break every caller that cannot answer a prompt.
168
+ //
169
+ // JSON mode counts as scripted even on a TTY: the prompt draws itself on stdout, which would put
170
+ // interface text in front of the document a caller is parsing.
171
+ if (command.method !== "DELETE" || flags.yes || shouldEmitJson(flags)) return true;
172
+ if (!process.stdout.isTTY) return true;
173
+
174
+ const answer = await confirm({
175
+ message: `${command.summary}. This cannot be undone. Continue?`,
176
+ initialValue: false
177
+ });
178
+
179
+ return !isCancel(answer) && answer === true;
180
+ }
181
+
182
+ /**
183
+ * @param {string[]} argv
184
+ * @returns {Promise<{ output: string; code: number }>}
185
+ */
186
+ export async function run(argv) {
187
+ const { values: flags, positionals } = parseFlags(argv);
188
+
189
+ try {
190
+ normalizeSwitches(flags);
191
+ assertSuppliedValuesArePresent(flags, PARSER_OPTIONS);
192
+
193
+ const describe = shouldEmitJson(flags);
194
+ /** @param {string} text @param {unknown} machine_readable */
195
+ const inform = (text, machine_readable) => ({
196
+ output: describe ? JSON.stringify(machine_readable, null, 2) : text,
197
+ code: 0
198
+ });
199
+
200
+ if (flags.version) {
201
+ const version = await readVersion();
202
+ return inform(version, { version });
203
+ }
204
+
205
+ const [resource, name, ...rest] = positionals;
206
+ if (!resource) return inform(renderRootHelp(), describeForMachines());
207
+ if (resource === "config") return await runConfigCommand([name, ...rest], flags);
208
+
209
+ const commands = CLI_COMMANDS.filter((entry) => entry.resource === resource);
210
+ if (commands.length === 0) {
211
+ return fail(`Unknown resource "${resource}". Run \`tglow\` for the list.`, flags);
212
+ }
213
+
214
+ if (!name) {
215
+ return inform(renderResourceHelp(resource) ?? "", describeForMachines({ resource }));
216
+ }
217
+
218
+ const command = commands.find((entry) => entry.command === name);
219
+ if (!command) {
220
+ return fail(
221
+ `Unknown command "${resource} ${name}". ` +
222
+ `Run \`tglow ${resource}\` for what ${resource} accepts.`,
223
+ flags
224
+ );
225
+ }
226
+ if (flags.help) return inform(renderCommandHelp(command), describeForMachines({ command }));
227
+
228
+ const config = await loadConfig();
229
+ const { path, missing, unsafe, consumed } = resolvePath(command, { flags, config });
230
+
231
+ if (unsafe.length > 0) {
232
+ const named = unsafe.map((entry) => `--${toFlagName(entry)}`).join(", ");
233
+ return fail(
234
+ `${named} cannot be "." or "..". Those resolve to a different endpoint than this command calls.`,
235
+ flags
236
+ );
237
+ }
238
+ if (missing.length > 0) {
239
+ // Only project and team have a stored default, so only they can be answered with `config set`.
240
+ // Suggesting it for a missing source or metric id sends the reader after a setting that does
241
+ // not exist and cannot help.
242
+ const configurable = missing.filter((entry) => entry in PATH_ALIASES);
243
+ const advice = configurable.map(
244
+ (entry) =>
245
+ `\nSet a default with \`tglow config set ${PATH_ALIASES[/** @type {keyof typeof PATH_ALIASES} */ (entry)]} <id>\` to stop repeating it.`
246
+ );
247
+
248
+ return fail(
249
+ `Missing ${missing.map((entry) => `--${toFlagName(entry)}`).join(", ")}.` + advice.join(""),
250
+ flags
251
+ );
252
+ }
253
+
254
+ const { query, body, unknown } = buildRequestParts(command, flags, {
255
+ consumed_path_fields: consumed
256
+ });
257
+ if (unknown.length > 0) {
258
+ return fail(
259
+ `Unknown flag${unknown.length === 1 ? "" : "s"}: ${unknown.map((entry) => `--${entry}`).join(", ")}.\n` +
260
+ `Run \`tglow ${resource} ${name} --help\` for the accepted flags.`,
261
+ flags
262
+ );
263
+ }
264
+
265
+ // A true/false flag never consumes the token after it, so `--is-public false` sets the flag TRUE
266
+ // and leaves `false` sitting here. Ignoring the leftover would send the opposite of what was
267
+ // typed. Checked after the unknown-flag pass, so a mistyped flag is named rather than its value.
268
+ if (rest.length > 0) {
269
+ return fail(
270
+ `Unexpected argument${rest.length === 1 ? "" : "s"}: ${rest.join(", ")}.\n` +
271
+ "A value goes after its flag (--name Acme). A true/false flag is either passed alone " +
272
+ "for true, or written as --flag=false.",
273
+ flags
274
+ );
275
+ }
276
+
277
+ if (flags.all) {
278
+ if (!isPaginated(command)) {
279
+ return fail(
280
+ `\`${resource} ${name}\` does not return a paginated list, so --all does not apply.`,
281
+ flags
282
+ );
283
+ }
284
+ // `--all` owns paging: it walks forwards from `after` and sizes each page itself. `--before`
285
+ // asks the API to page the other way at the same time, and `--limit` states a page size that
286
+ // the cap then has to override. Refusing both beats silently reinterpreting either.
287
+ if (query.before !== undefined) {
288
+ return fail(
289
+ "--before cannot be combined with --all. Use --after to choose a start.",
290
+ flags
291
+ );
292
+ }
293
+ if (query.limit !== undefined) {
294
+ return fail("--limit cannot be combined with --all. Use --max to cap the total.", flags);
295
+ }
296
+
297
+ // `--max` written bare parses as `true`, and checking `Number(raw)` rather than the text lets
298
+ // `1e-400` and `1.0000000000000001` through as whole numbers.
299
+ const raw_max = flags.max === undefined ? String(CLI_CONFIGS.DEFAULT_MAX_RECORDS) : flags.max;
300
+ if (typeof raw_max !== "string" || !isIntegerText(raw_max) || Number(raw_max) < 1) {
301
+ return fail(`--max must be a whole number of at least 1, received "${raw_max}".`, flags);
302
+ }
303
+
304
+ const result = await requestAllPages({
305
+ method: command.method,
306
+ path,
307
+ query,
308
+ flags,
309
+ config,
310
+ max: Number(raw_max)
311
+ });
312
+
313
+ return {
314
+ output: renderPagedResult(
315
+ {
316
+ ...result,
317
+ resume_command: result.next_cursor ? buildResumeCommand(argv, result.next_cursor) : ""
318
+ },
319
+ flags
320
+ ),
321
+ code: 0
322
+ };
323
+ }
324
+
325
+ // `--max` only means anything to `--all`. Accepting it alone would return one default-sized page
326
+ // while looking like it honoured the cap.
327
+ if (flags.max !== undefined) {
328
+ return fail("--max only applies with --all. Use --limit to size a single page.", flags);
329
+ }
330
+
331
+ if (!(await confirmDestructive(command, flags))) return fail("Cancelled.", flags);
332
+
333
+ const payload = await requestApi({ method: command.method, path, query, body, flags, config });
334
+ return { output: renderResponse(payload, flags), code: 0 };
335
+ } catch (err) {
336
+ return fail(err instanceof Error ? err.message : String(err), flags);
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Whether this file was run, rather than imported.
342
+ *
343
+ * `import.meta.main` would be shorter but is Bun-only until very recent Node, and this package
344
+ * installs under whatever runtime the user already has. Both sides are resolved through the real
345
+ * path because a global install invokes the command through a symlink in `node_modules/.bin`.
346
+ */
347
+ function isEntrypoint() {
348
+ if (!process.argv[1]) return false;
349
+ try {
350
+ return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
351
+ } catch {
352
+ return false;
353
+ }
354
+ }
355
+
356
+ if (isEntrypoint()) {
357
+ // `process.exit` discards whatever is still buffered for a pipe, and a pipe's buffer is 64 KB.
358
+ // `tglow records list --all --json | jq` would receive a JSON document cut off mid-value, which
359
+ // is exactly the invariant this CLI sells. Set the code and let the write drain instead.
360
+ try {
361
+ const { output, code } = await run(process.argv.slice(2));
362
+ // Anything that failed belongs on stderr, so a `--json` pipeline reading stdout never has to
363
+ // tell a result apart from an explanation of why there is none.
364
+ process.exitCode = code;
365
+ (code === 0 ? process.stdout : process.stderr).write(`${output}\n`);
366
+ } catch (err) {
367
+ process.exitCode = 1;
368
+ process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
369
+ }
370
+ }