@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
|
@@ -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
|
+
}
|