@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.
@@ -0,0 +1,169 @@
1
+ import {
2
+ CLI_CONFIGS,
3
+ CONFIG_KEYS,
4
+ isSecretSetting,
5
+ loadConfig,
6
+ loadConfigForWrite,
7
+ redactSetting,
8
+ saveConfig
9
+ } from "./config.js";
10
+ import { RESERVED_FLAGS } from "./flags.js";
11
+ import { shouldEmitJson } from "./output.js";
12
+
13
+ /**
14
+ * @param {string} message
15
+ * @param {{ json?: unknown }} flags
16
+ */
17
+ function fail(message, flags) {
18
+ return { output: shouldEmitJson(flags) ? JSON.stringify({ error: message }) : message, code: 1 };
19
+ }
20
+
21
+ /**
22
+ * @param {string} message
23
+ * @param {Record<string, unknown>} fields
24
+ * @param {{ json?: unknown }} flags
25
+ */
26
+ function succeed(message, fields, flags) {
27
+ return { output: shouldEmitJson(flags) ? JSON.stringify(fields, null, 2) : message, code: 0 };
28
+ }
29
+
30
+ /**
31
+ * What `tglow config` can do, described once.
32
+ *
33
+ * Both help forms render from this. The machine form used to list the SETTINGS without the verbs,
34
+ * so an agent saw four things it could store and no way to store them, and concluded the command
35
+ * did nothing. Same failure as the root help listing resources with no global flags.
36
+ */
37
+ const CONFIG_COMMANDS = [
38
+ { command: "list", usage: "tglow config list", description: "Show every stored setting." },
39
+ { command: "get", usage: "tglow config get <key>", description: "Print one setting." },
40
+ {
41
+ command: "set",
42
+ usage: "tglow config set <key> <value>",
43
+ description: "Store a setting, so commands stop needing the flag."
44
+ },
45
+ { command: "unset", usage: "tglow config unset <key>", description: "Remove a setting." }
46
+ ];
47
+
48
+ function renderConfigHelp() {
49
+ const key_width = Math.max(...Object.keys(CONFIG_KEYS).map((key) => key.length));
50
+ const usage_width = Math.max(...CONFIG_COMMANDS.map((entry) => entry.usage.length));
51
+
52
+ return [
53
+ "tglow config: stored defaults and credentials",
54
+ "",
55
+ "Commands:",
56
+ ...CONFIG_COMMANDS.map((entry) => ` ${entry.usage.padEnd(usage_width)} ${entry.description}`),
57
+ "",
58
+ "Keys:",
59
+ ...Object.entries(CONFIG_KEYS).map(([key, note]) => ` ${key.padEnd(key_width)} ${note}`),
60
+ "",
61
+ `Stored in ${CLI_CONFIGS.CONFIG_PATH}, readable only by you.`,
62
+ "A flag beats an environment variable, which beats this file."
63
+ ].join("\n");
64
+ }
65
+
66
+ /**
67
+ * `tglow config ...` is the one resource the registry does not supply, because the settings it
68
+ * holds are the CLI's own rather than the API's.
69
+ *
70
+ * @param {string[]} words
71
+ * @param {Record<string, unknown>} flags
72
+ * @returns {Promise<{ output: string; code: number }>}
73
+ */
74
+ export async function runConfigCommand(words, flags) {
75
+ const [action, key, value, ...rest] = words;
76
+ const json = shouldEmitJson(flags);
77
+
78
+ // Config dispatch happens before the command-wide checks, so it has to do its own. Without this,
79
+ // `config set project prj_x --jsoon` writes the setting and silently drops the typo, and a
80
+ // trailing word is ignored rather than questioned.
81
+ const unknown = Object.keys(flags).filter((flag) => !RESERVED_FLAGS.has(flag));
82
+ if (unknown.length > 0) {
83
+ return fail(
84
+ `Unknown flag${unknown.length === 1 ? "" : "s"}: ${unknown.map((flag) => `--${flag}`).join(", ")}.`,
85
+ flags
86
+ );
87
+ }
88
+ if (rest.length > 0) {
89
+ return fail(`Unexpected argument${rest.length === 1 ? "" : "s"}: ${rest.join(", ")}.`, flags);
90
+ }
91
+
92
+ if (!action || flags.help) {
93
+ return succeed(
94
+ renderConfigHelp(),
95
+ {
96
+ usage: "tglow config <command> [key] [value]",
97
+ commands: CONFIG_COMMANDS,
98
+ keys: Object.entries(CONFIG_KEYS).map(([name, description]) => ({ name, description })),
99
+ path: CLI_CONFIGS.CONFIG_PATH,
100
+ precedence: "A flag beats an environment variable, which beats this file."
101
+ },
102
+ flags
103
+ );
104
+ }
105
+
106
+ if (action === "list") {
107
+ const stored = await loadConfig();
108
+ const entries = Object.keys(CONFIG_KEYS)
109
+ .filter((name) => typeof stored[name] === "string")
110
+ .map((name) => [name, redactSetting(name, stored[name])]);
111
+
112
+ if (json) return { output: JSON.stringify(Object.fromEntries(entries), null, 2), code: 0 };
113
+ if (entries.length === 0) {
114
+ return { output: `Nothing stored in ${CLI_CONFIGS.CONFIG_PATH}.`, code: 0 };
115
+ }
116
+
117
+ const width = Math.max(...entries.map(([name]) => name.length));
118
+ return {
119
+ output: entries.map(([name, shown]) => `${name.padEnd(width)} ${shown}`).join("\n"),
120
+ code: 0
121
+ };
122
+ }
123
+
124
+ if (!key) return fail("Which setting? Run `tglow config` for the list.", flags);
125
+ // A plain property lookup is truthy for every inherited name, so `config set constructor x` would
126
+ // report success and persist a setting that nothing ever reads.
127
+ if (!Object.hasOwn(CONFIG_KEYS, key)) {
128
+ return fail(
129
+ `Unknown setting "${key}". Known settings: ${Object.keys(CONFIG_KEYS).join(", ")}.`,
130
+ flags
131
+ );
132
+ }
133
+
134
+ if (action === "get") {
135
+ // A key is never printed, even on request. No workflow needs it echoed that the environment
136
+ // variable does not already serve, and stdout is captured by shell history, logs and agent
137
+ // transcripts alike.
138
+ if (isSecretSetting(key)) {
139
+ return fail(
140
+ `${key} is never printed. Read ${CLI_CONFIGS.CONFIG_PATH} directly if you need the value.`,
141
+ flags
142
+ );
143
+ }
144
+
145
+ const current = (await loadConfig())[key];
146
+ if (typeof current !== "string") return fail(`${key} is not set.`, flags);
147
+ return succeed(current, { [key]: current }, flags);
148
+ }
149
+
150
+ if (action === "set") {
151
+ if (!value) return fail(`Missing a value for ${key}.`, flags);
152
+ const stored = await loadConfigForWrite();
153
+ await saveConfig({ ...stored, [key]: value });
154
+ return succeed(
155
+ `Set ${key} to ${redactSetting(key, value)}.`,
156
+ { ok: true, key, value: redactSetting(key, value) },
157
+ flags
158
+ );
159
+ }
160
+
161
+ if (action === "unset") {
162
+ const stored = await loadConfigForWrite();
163
+ const { [key]: _removed, ...rest } = stored;
164
+ await saveConfig(rest);
165
+ return succeed(`Removed ${key}.`, { ok: true, key, removed: true }, flags);
166
+ }
167
+
168
+ return fail(`Unknown config command "${action}". Run \`tglow config\` for the list.`, flags);
169
+ }
package/src/config.js ADDED
@@ -0,0 +1,182 @@
1
+ import fs from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+
5
+ export const CLI_CONFIGS = {
6
+ DEFAULT_API_URL: "https://api.tailglow.io",
7
+ CONFIG_PATH: join(homedir(), ".tailglow", "config.json"),
8
+ // The API caps a page at 200, so the default is 50 requests. Deliberately a guardrail rather than
9
+ // a ceiling: `--max` moves it, and hitting it always prints the cursor to continue from.
10
+ DEFAULT_MAX_RECORDS: 10_000,
11
+ PAGE_SIZE: 200,
12
+ // Nobody else may read the file: it holds an API key. 0700 on the directory for the same reason.
13
+ FILE_MODE: 0o600,
14
+ DIRECTORY_MODE: 0o700
15
+ };
16
+
17
+ /**
18
+ * The settings the config file may hold. An allowlist so a typo fails loudly at `config set` rather
19
+ * than writing a key nothing ever reads.
20
+ *
21
+ * @type {Record<string, string>}
22
+ */
23
+ export const CONFIG_KEYS = {
24
+ api_key: "API key used to authenticate. Create one under Team settings.",
25
+ api_url: "API origin to call. Defaults to the production API.",
26
+ project: "Project used when a command needs one and no --project is given.",
27
+ team: "Team used when a command needs one and no --team is given."
28
+ };
29
+
30
+ /** Settings whose value must never be printed back. */
31
+ const SECRET_KEYS = new Set(["api_key"]);
32
+
33
+ /** @param {string} key */
34
+ export function isSecretSetting(key) {
35
+ return SECRET_KEYS.has(key);
36
+ }
37
+
38
+ /**
39
+ * Reads the stored config, if there is one.
40
+ *
41
+ * Missing, unreadable, and malformed are all the same answer on purpose: the file is a convenience
42
+ * over environment variables, so a broken one must not stop a command that had its credentials
43
+ * another way. That includes a file holding a bare `null`, which parses fine and is not an object.
44
+ *
45
+ * @param {string} [path]
46
+ * @returns {Promise<Record<string, string>>}
47
+ */
48
+ export async function loadConfig(path = CLI_CONFIGS.CONFIG_PATH) {
49
+ try {
50
+ const parsed = JSON.parse(await fs.readFile(path, "utf-8"));
51
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
52
+ return parsed;
53
+ } catch {
54
+ return {};
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Reads the stored config, refusing to guess when the file is unreadable.
60
+ *
61
+ * `loadConfig` answers "no settings" for a broken file so a command that had its credentials
62
+ * another way still runs. A MUTATION cannot do that: it would write its own view back and erase
63
+ * whatever the file actually held, which for a file holding an API key is silent credential loss.
64
+ *
65
+ * @param {string} [path]
66
+ * @returns {Promise<Record<string, string>>}
67
+ */
68
+ export async function loadConfigForWrite(path = CLI_CONFIGS.CONFIG_PATH) {
69
+ /** @type {string} */
70
+ let contents;
71
+ try {
72
+ contents = await fs.readFile(path, "utf-8");
73
+ } catch (err) {
74
+ if (/** @type {NodeJS.ErrnoException} */ (err).code === "ENOENT") return {};
75
+ throw new Error(`Cannot read ${path}: ${err instanceof Error ? err.message : String(err)}`);
76
+ }
77
+
78
+ /** @type {unknown} */
79
+ let parsed;
80
+ try {
81
+ parsed = JSON.parse(contents);
82
+ } catch {
83
+ throw new Error(
84
+ `${path} is not valid JSON, so changing a setting would erase whatever it holds.\n` +
85
+ "Fix or delete the file, then try again."
86
+ );
87
+ }
88
+
89
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
90
+ throw new Error(
91
+ `${path} does not hold a JSON object, so changing a setting would erase it.\n` +
92
+ "Fix or delete the file, then try again."
93
+ );
94
+ }
95
+
96
+ return /** @type {Record<string, string>} */ (parsed);
97
+ }
98
+
99
+ /**
100
+ * Writes the config, never widening what can read it.
101
+ *
102
+ * The `mode` options to `mkdir` and `writeFile` only apply when they CREATE, so an existing
103
+ * world-readable directory or file keeps its permissions. Writing the key straight into such a file
104
+ * and chmodding afterwards leaves a window where the key is on disk and readable; a temp file
105
+ * created at 0600 and renamed into place never has one.
106
+ *
107
+ * @param {Record<string, string>} values
108
+ * @param {string} [path]
109
+ */
110
+ export async function saveConfig(values, path = CLI_CONFIGS.CONFIG_PATH) {
111
+ const directory = dirname(path);
112
+ await fs.mkdir(directory, { recursive: true, mode: CLI_CONFIGS.DIRECTORY_MODE });
113
+ await fs.chmod(directory, CLI_CONFIGS.DIRECTORY_MODE);
114
+
115
+ const staged = `${path}.${process.pid}.tmp`;
116
+ try {
117
+ await fs.writeFile(staged, `${JSON.stringify(values, null, 2)}\n`, {
118
+ mode: CLI_CONFIGS.FILE_MODE,
119
+ flag: "wx"
120
+ });
121
+ await fs.chmod(staged, CLI_CONFIGS.FILE_MODE);
122
+ await fs.rename(staged, path);
123
+ } catch (err) {
124
+ await fs.rm(staged, { force: true });
125
+ throw err;
126
+ }
127
+ }
128
+
129
+ /**
130
+ * @param {string} key
131
+ * @param {string} value
132
+ */
133
+ export function redactSetting(key, value) {
134
+ if (!SECRET_KEYS.has(key)) return value;
135
+ return value.length <= 8 ? "********" : `${value.slice(0, 4)}...${value.slice(-4)}`;
136
+ }
137
+
138
+ /**
139
+ * Resolves one setting.
140
+ *
141
+ * Precedence is flag, then environment, then stored config. A flag is the most specific statement
142
+ * of intent and an env var is how CI supplies things, so neither may be overridden by a file the
143
+ * user configured months ago.
144
+ *
145
+ * @param {string} name
146
+ * @param {{
147
+ * flags?: Record<string, unknown>;
148
+ * env?: NodeJS.ProcessEnv;
149
+ * config?: Record<string, string>;
150
+ * }} [sources]
151
+ * @returns {string | null}
152
+ */
153
+ export function resolveSetting(name, { flags = {}, env = process.env, config = {} } = {}) {
154
+ const flag = flags[name.replaceAll("_", "-")] ?? flags[name];
155
+ if (typeof flag === "string" && flag.length > 0) return flag;
156
+
157
+ const from_env = env[`TAILGLOW_${name.toUpperCase()}`];
158
+ if (typeof from_env === "string" && from_env.length > 0) return from_env;
159
+
160
+ const stored = config[name];
161
+ return typeof stored === "string" && stored.length > 0 ? stored : null;
162
+ }
163
+
164
+ /** @param {{ flags?: Record<string, unknown>; config?: Record<string, string> }} [options] */
165
+ export function resolveApiUrl(options = {}) {
166
+ return resolveSetting("api_url", options) ?? CLI_CONFIGS.DEFAULT_API_URL;
167
+ }
168
+
169
+ /**
170
+ * @param {{ flags?: Record<string, unknown>; config?: Record<string, string> }} [options]
171
+ * @returns {string}
172
+ */
173
+ export function resolveApiKey(options = {}) {
174
+ const key = resolveSetting("api_key", options);
175
+ if (!key) {
176
+ throw new Error(
177
+ "No API key. Set TAILGLOW_API_KEY, or run `tglow config set api_key <key>`.\n" +
178
+ "Create a key in the dashboard under Team settings."
179
+ );
180
+ }
181
+ return key;
182
+ }
package/src/flags.js ADDED
@@ -0,0 +1,299 @@
1
+ /**
2
+ * Field names are snake_case on the wire and kebab-case on a command line, so the mapping is
3
+ * mechanical in both directions and no command has to declare it.
4
+ *
5
+ * @param {string} field
6
+ */
7
+ export function toFlagName(field) {
8
+ return field.replaceAll("_", "-");
9
+ }
10
+
11
+ /** @param {string} flag */
12
+ export function toFieldName(flag) {
13
+ return flag.replaceAll("-", "_");
14
+ }
15
+
16
+ /**
17
+ * Options the runtime owns, and whether each consumes the token after it.
18
+ *
19
+ * `after` is deliberately absent: it is a real cursor query parameter on every paginated list, so
20
+ * reserving it would make the cursor that `--all` prints unusable.
21
+ *
22
+ * @type {Record<string, "string" | "boolean">}
23
+ */
24
+ const GLOBAL_OPTIONS = {
25
+ json: "boolean",
26
+ all: "boolean",
27
+ help: "boolean",
28
+ version: "boolean",
29
+ yes: "boolean",
30
+ max: "string",
31
+ "api-key": "string",
32
+ "api-url": "string",
33
+ project: "string",
34
+ team: "string"
35
+ };
36
+
37
+ /**
38
+ * Flags the runtime owns. They are refused as field names so a future API field called `json` can
39
+ * never quietly shadow the output flag.
40
+ */
41
+ export const RESERVED_FLAGS = new Set(Object.keys(GLOBAL_OPTIONS));
42
+
43
+ /**
44
+ * Declares every option `parseArgs` may encounter, so it knows which ones consume the token that
45
+ * follows them.
46
+ *
47
+ * This has to cover the WHOLE registry rather than one command's fields, because the flags are
48
+ * parsed before the command is known. Getting it wrong is not a cosmetic problem: an undeclared
49
+ * `--api-key` becomes a bare boolean and leaves the key sitting in the positionals, where it is
50
+ * read as a resource name and echoed back in an error.
51
+ *
52
+ * A field name never carries two different types across commands, so first-wins is unambiguous.
53
+ *
54
+ * @param {import("./types.d.ts").CliCommand[]} commands
55
+ */
56
+ export function buildParserOptions(commands) {
57
+ /** @type {Record<string, { type: "string" | "boolean" }>} */
58
+ const options = {};
59
+
60
+ for (const [flag, type] of Object.entries(GLOBAL_OPTIONS)) options[flag] = { type };
61
+
62
+ for (const command of commands) {
63
+ const fields = [...command.path_params, ...command.query_params, ...command.body_fields];
64
+ for (const field of fields) {
65
+ const flag = toFlagName(field.name);
66
+ if (options[flag]) continue;
67
+ options[flag] = { type: field.type === "boolean" ? "boolean" : "string" };
68
+ }
69
+ }
70
+
71
+ return options;
72
+ }
73
+
74
+ /**
75
+ * Turns the runtime's own switches into real booleans.
76
+ *
77
+ * Under `strict: false` a declared boolean written as `--yes=false` comes back as the STRING
78
+ * `"false"`, which is truthy. Left alone, `--yes=false` would skip the delete confirmation and
79
+ * `--json=false` would still emit JSON: the opposite of what was typed, on the two flags where
80
+ * being wrong costs the most.
81
+ *
82
+ * @param {Record<string, unknown>} flags
83
+ */
84
+ export function normalizeSwitches(flags) {
85
+ for (const [flag, type] of Object.entries(GLOBAL_OPTIONS)) {
86
+ if (type !== "boolean") continue;
87
+
88
+ const raw = flags[flag];
89
+ if (raw === undefined || typeof raw === "boolean") continue;
90
+ if (raw === "true" || raw === "false") {
91
+ flags[flag] = raw === "true";
92
+ continue;
93
+ }
94
+
95
+ throw new Error(`--${flag} takes no value, or =true / =false. Received "${raw}".`);
96
+ }
97
+
98
+ return flags;
99
+ }
100
+
101
+ /**
102
+ * Refuses a flag that was written but carries no value.
103
+ *
104
+ * `--project` bare parses as `true` and `--project=` as `""`; both read as "not supplied", so the
105
+ * value would come from `TAILGLOW_PROJECT` or the stored config instead. `--api-url=` would fall
106
+ * back to PRODUCTION and `--api-key=` to a stored credential. A flag someone typed must never
107
+ * resolve to ambient state.
108
+ *
109
+ * Runs over every declared string option at once, so path parameters are covered as well as the
110
+ * runtime's own flags; `coerceValue` only sees query and body fields.
111
+ *
112
+ * @param {Record<string, unknown>} flags
113
+ * @param {Record<string, { type: "string" | "boolean" }>} options
114
+ */
115
+ export function assertSuppliedValuesArePresent(flags, options) {
116
+ for (const [flag, value] of Object.entries(flags)) {
117
+ if (options[flag]?.type !== "string") continue;
118
+ if (typeof value === "string" && value.length > 0) continue;
119
+
120
+ throw new Error(`--${flag} was given no value.`);
121
+ }
122
+ }
123
+
124
+ /** @param {string} type */
125
+ function isStructuredType(type) {
126
+ return type === "Scopes" || type.startsWith("object");
127
+ }
128
+
129
+ /**
130
+ * How to actually type this flag.
131
+ *
132
+ * `--output-schema <object[]>` names a type without saying how to encode it, which is not a
133
+ * contract anyone can satisfy: a reviewer guessed JSON and guessed the member shape, and said it
134
+ * was the moment they most wanted to read the source. A boolean has the same problem from the other
135
+ * side, because the value has to be attached rather than spaced.
136
+ *
137
+ * @param {string} flag
138
+ * @param {string} type
139
+ */
140
+ export function describeFlagSyntax(flag, type) {
141
+ if (type === "boolean") return `--${flag} | --${flag}=false`;
142
+ if (isStructuredType(type)) return `--${flag} '<json ${type}>'`;
143
+ if (type.endsWith("[]")) return `--${flag} <comma,separated | json array>`;
144
+
145
+ return `--${flag} <${type}>`;
146
+ }
147
+
148
+ /**
149
+ * Whether the text spells a whole number that survives the round trip through a JSON number.
150
+ *
151
+ * `Number()` is lossy in both directions. `Number("1e-400")` is `0` and
152
+ * `Number("1.0000000000000001")` is `1`, so checking the RESULT with `Number.isInteger` accepts
153
+ * fractional input and silently sends a different value: `servers scale --count 1e-400` would send
154
+ * `count: 0` and take a fleet down. In the other direction a long-enough run of digits becomes
155
+ * `Infinity`, which passes any `< n` guard and which `JSON.stringify` writes as `null`, and
156
+ * anything past 2^53 arrives rounded.
157
+ *
158
+ * @param {string} raw
159
+ */
160
+ export function isIntegerText(raw) {
161
+ const trimmed = raw.trim();
162
+ return /^[+-]?\d+$/.test(trimmed) && Number.isSafeInteger(Number(trimmed));
163
+ }
164
+
165
+ /**
166
+ * The value is never echoed back here, unlike the scalar branches below.
167
+ *
168
+ * Structured fields are exactly the ones that carry secrets: `drains create --headers` takes
169
+ * authorization headers, and `roles create --scopes` a permission map. A typo would otherwise put
170
+ * the whole object into stderr, and from there into a CI log or an agent transcript.
171
+ *
172
+ * @param {string} raw
173
+ * @param {string} type
174
+ * @param {string} [named]
175
+ */
176
+ function parseStructuredValue(raw, type, named = "") {
177
+ /** @type {unknown} */
178
+ let value;
179
+ try {
180
+ value = JSON.parse(raw);
181
+ } catch {
182
+ throw new Error(`${named}expected JSON for a ${type} value`);
183
+ }
184
+
185
+ if (type.startsWith("object[]") && !Array.isArray(value)) {
186
+ throw new Error(`${named}expected a JSON array for a ${type} value`);
187
+ }
188
+ if (!type.startsWith("object[]") && Array.isArray(value)) {
189
+ throw new Error(`${named}expected a JSON object for a ${type} value`);
190
+ }
191
+
192
+ return value;
193
+ }
194
+
195
+ /**
196
+ * Coerces a flag string into the type the field declares.
197
+ *
198
+ * The registry gives the wire type, so a caller never has to think about quoting: `--limit 5` is a
199
+ * number and `--is-public true` is a boolean, both because the API said so. Anything unrecognised
200
+ * stays a string, which is what the API validates anyway.
201
+ *
202
+ * Numeric fields reject anything non-finite rather than passing it along. `Infinity` survives
203
+ * `Number()` but `JSON.stringify` turns it into `null`, so an unchecked value does not fail — it
204
+ * silently sends a different field than the one that was typed.
205
+ *
206
+ * @param {string | boolean} raw
207
+ * @param {string} type
208
+ * @param {string} [flag]
209
+ */
210
+ export function coerceValue(raw, type, flag = "") {
211
+ const named = flag ? `--${flag}: ` : "";
212
+
213
+ if (typeof raw === "boolean") {
214
+ // A flag written bare parses as `true`. On a boolean field that IS the value; anywhere else the
215
+ // value was left off, and sending `true` would make the API reject a request that reads as fine.
216
+ if (type.includes("boolean")) return raw;
217
+ throw new Error(`${named}expected a ${type} value after the flag, received none`);
218
+ }
219
+
220
+ if (raw === "null" && type.includes("null")) return null;
221
+
222
+ if (isStructuredType(type)) return parseStructuredValue(raw, type, named);
223
+
224
+ if (type.includes("integer")) {
225
+ if (!isIntegerText(raw)) throw new Error(`${named}expected a whole number, received "${raw}"`);
226
+ return Number(raw);
227
+ }
228
+
229
+ if (type.includes("number")) {
230
+ const value = Number(raw);
231
+ if (raw.trim() === "" || !Number.isFinite(value)) {
232
+ throw new Error(`${named}expected a number, received "${raw}"`);
233
+ }
234
+ return value;
235
+ }
236
+
237
+ if (type.includes("boolean")) {
238
+ if (raw === "true") return true;
239
+ if (raw === "false") return false;
240
+ throw new Error(`${named}expected true or false, received "${raw}"`);
241
+ }
242
+
243
+ if (type.endsWith("[]")) {
244
+ if (raw.trimStart().startsWith("[")) return parseStructuredValue(raw, "object[]");
245
+ return raw
246
+ .split(",")
247
+ .map((entry) => entry.trim())
248
+ .filter(Boolean);
249
+ }
250
+
251
+ return raw;
252
+ }
253
+
254
+ /**
255
+ * Splits the parsed flags into the three places a request can carry a value.
256
+ *
257
+ * `consumed_path_fields` is the set the resolved path actually substituted, NOT every field the
258
+ * registry lists as a path parameter. A field can be a path parameter on an alternate path only
259
+ * (`metric_id` for `monitors list`) while also being a query filter on the primary one; skipping it
260
+ * by name would drop the filter and quietly return everything.
261
+ *
262
+ * @param {import("./types.d.ts").CliCommand} command
263
+ * @param {Record<string, unknown>} flags
264
+ * @param {{ consumed_path_fields?: Set<string> }} [options]
265
+ */
266
+ export function buildRequestParts(command, flags, { consumed_path_fields = new Set() } = {}) {
267
+ /** @type {Record<string, unknown>} */
268
+ const query = {};
269
+ /** @type {Record<string, unknown>} */
270
+ const body = {};
271
+ /** @type {string[]} */
272
+ const unknown = [];
273
+
274
+ const query_by_flag = new Map(
275
+ command.query_params.map((field) => [toFlagName(field.name), field])
276
+ );
277
+ const body_by_flag = new Map(command.body_fields.map((field) => [toFlagName(field.name), field]));
278
+ const consumed_flags = new Set([...consumed_path_fields].map(toFlagName));
279
+
280
+ for (const [flag, raw] of Object.entries(flags)) {
281
+ if (RESERVED_FLAGS.has(flag) || consumed_flags.has(flag)) continue;
282
+
283
+ const query_field = query_by_flag.get(flag);
284
+ if (query_field) {
285
+ query[query_field.name] = coerceValue(/** @type {string} */ (raw), query_field.type, flag);
286
+ continue;
287
+ }
288
+
289
+ const body_field = body_by_flag.get(flag);
290
+ if (body_field) {
291
+ body[body_field.name] = coerceValue(/** @type {string} */ (raw), body_field.type, flag);
292
+ continue;
293
+ }
294
+
295
+ unknown.push(flag);
296
+ }
297
+
298
+ return { query, body, unknown };
299
+ }