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