@tailglow/cli 0.5.1 → 0.6.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 +46 -8
- package/package.json +1 -1
- package/src/commands.generated.js +531 -46
- package/src/config-command.js +14 -10
- package/src/config.js +210 -16
- package/src/docs-command.js +139 -0
- package/src/docs.generated.js +73 -0
- package/src/help.js +59 -14
- package/src/index.js +96 -28
- package/src/login-command.js +957 -0
- package/src/output.js +28 -0
- package/src/request.js +125 -12
- package/src/types.d.ts +41 -1
package/src/help.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CLI_COMMANDS } from "./commands.generated.js";
|
|
2
2
|
import { CLI_CONFIGS, CONFIG_KEYS } from "./config.js";
|
|
3
3
|
import { describeFlagSyntax, toFlagName } from "./flags.js";
|
|
4
|
+
import { describeLoginForMachines } from "./login-command.js";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* The runtime's own flags, described once.
|
|
@@ -17,7 +18,8 @@ export const GLOBAL_FLAGS = [
|
|
|
17
18
|
flag: "--json",
|
|
18
19
|
argument: null,
|
|
19
20
|
env: null,
|
|
20
|
-
description:
|
|
21
|
+
description:
|
|
22
|
+
"Emit JSON. Implied whenever output is piped or redirected, except by `tglow docs`, which prints markdown unless this is given."
|
|
21
23
|
},
|
|
22
24
|
{
|
|
23
25
|
flag: "--all",
|
|
@@ -55,14 +57,15 @@ export const GLOBAL_FLAGS = [
|
|
|
55
57
|
flag: "--team",
|
|
56
58
|
argument: "<id>",
|
|
57
59
|
env: "TAILGLOW_TEAM",
|
|
58
|
-
description:
|
|
60
|
+
description:
|
|
61
|
+
"Team to act on, when a command needs one and --team-id is not given. With a `tglow login` session, also the team it acts in."
|
|
59
62
|
},
|
|
60
63
|
{
|
|
61
64
|
flag: "--api-key",
|
|
62
65
|
argument: "<key>",
|
|
63
66
|
env: "TAILGLOW_API_KEY",
|
|
64
67
|
description:
|
|
65
|
-
"
|
|
68
|
+
"API key to authenticate with, ahead of the environment variable and `tglow login`. Prefer the environment variable: a key passed as a flag is visible in the process list and in shell history."
|
|
66
69
|
},
|
|
67
70
|
{
|
|
68
71
|
flag: "--api-url",
|
|
@@ -81,16 +84,19 @@ export const GLOBAL_FLAGS = [
|
|
|
81
84
|
* machine form was missing all of it.
|
|
82
85
|
*/
|
|
83
86
|
export const USAGE_NOTES = [
|
|
87
|
+
"In a terminal, `tglow` offers sign-in when no credential is configured. `tglow help` always shows help.",
|
|
84
88
|
"Flags are the field name in kebab-case: a field named `time_window_minutes` is `--time-window-minutes`.",
|
|
85
89
|
"Path parameters are flags too, never positional: `--project-id prj_x`, not `tglow ... prj_x`.",
|
|
86
90
|
"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 \'[{"
|
|
91
|
+
'A field typed `object`, `object[]` or `Scopes` takes JSON as a single argument: --joins \'[{"lookup_view_id":"view_b","alias":"b","base_field":"user_id","lookup_field":"id"}]\'. Its keys are listed under it in the command\'s help.',
|
|
88
92
|
"A field typed `string[]` takes a comma-separated list or a JSON array.",
|
|
89
93
|
"JSON output is the API response as it was sent: read `.data` for results and `.pagination.next_cursor` to continue.",
|
|
90
94
|
"Under --all, JSON output is instead { data, truncated, next_cursor }, so a capped run can resume with --after.",
|
|
91
95
|
"`--all` replaces `--limit` and `--before` rather than combining with them: use `--max` for the total, or `--limit` alone for one page.",
|
|
92
96
|
"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`."
|
|
97
|
+
"Precedence for anything that has a default is flag, then environment variable, then `tglow config`.",
|
|
98
|
+
"Credentials are tried in order: --api-key, TAILGLOW_API_KEY, the `tglow login` credential, then `tglow config set api_key`.",
|
|
99
|
+
"Without a terminal, `tglow login` prints a link and exits 0. Approve it, then `tglow login --wait` finishes (exit 75 means still waiting: run it again)."
|
|
94
100
|
];
|
|
95
101
|
|
|
96
102
|
/** @param {import("./types.d.ts").CliCommand[]} commands */
|
|
@@ -134,7 +140,8 @@ export function describeCommandFlags(command) {
|
|
|
134
140
|
description: field.description,
|
|
135
141
|
allowed_values: field.allowed_values,
|
|
136
142
|
syntax: describeFlagSyntax(toFlagName(field.name), field.type),
|
|
137
|
-
deprecated: field.deprecated
|
|
143
|
+
deprecated: field.deprecated,
|
|
144
|
+
...(field.fields ? { fields: field.fields } : {})
|
|
138
145
|
}));
|
|
139
146
|
|
|
140
147
|
const path_flags = describe(
|
|
@@ -217,6 +224,12 @@ export function describeForMachines({ resource, command } = {}) {
|
|
|
217
224
|
path: CLI_CONFIGS.CONFIG_PATH,
|
|
218
225
|
keys: Object.keys(CONFIG_KEYS)
|
|
219
226
|
},
|
|
227
|
+
docs: {
|
|
228
|
+
command: "tglow docs [path]",
|
|
229
|
+
description:
|
|
230
|
+
"Every guide and API reference page as markdown, shipped with this CLI. Start with `tglow docs guides/agents`."
|
|
231
|
+
},
|
|
232
|
+
account: describeLoginForMachines(),
|
|
220
233
|
global_flags: globals,
|
|
221
234
|
usage_notes: USAGE_NOTES
|
|
222
235
|
};
|
|
@@ -236,8 +249,15 @@ export function renderRootHelp() {
|
|
|
236
249
|
"Resources:",
|
|
237
250
|
...resources.map((resource) => ` ${resource}`),
|
|
238
251
|
" config (stored defaults and credentials)",
|
|
252
|
+
" docs (every guide and API reference page, as markdown)",
|
|
253
|
+
"",
|
|
254
|
+
"Account:",
|
|
255
|
+
" tglow login Sign in through the browser. `tglow login --help` for agents and scripts.",
|
|
256
|
+
" tglow logout Remove the credential tglow login stored.",
|
|
257
|
+
" tglow whoami Show who the CLI is acting as.",
|
|
239
258
|
"",
|
|
240
259
|
"Run `tglow <resource>` to list its commands, or `tglow <resource> <command> --help`.",
|
|
260
|
+
"New to Tailglow? Run `tglow docs guides/agents`.",
|
|
241
261
|
"",
|
|
242
262
|
"Global flags:",
|
|
243
263
|
...globals.map((entry) => ` ${entry.left.padEnd(width)} ${entry.description}`),
|
|
@@ -274,19 +294,44 @@ function renderFields({ fields, title }) {
|
|
|
274
294
|
return [
|
|
275
295
|
"",
|
|
276
296
|
`${title}:`,
|
|
277
|
-
...fields
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
297
|
+
...fields
|
|
298
|
+
.map((field) => {
|
|
299
|
+
const flag = toFlagName(field.name);
|
|
300
|
+
const required = field.required ? " (required)" : "";
|
|
301
|
+
const allowed =
|
|
302
|
+
field.allowed_values.length > 0 ? ` [${field.allowed_values.join(", ")}]` : "";
|
|
282
303
|
|
|
283
|
-
|
|
304
|
+
const description = field.description ? ` ${field.description}` : "";
|
|
284
305
|
|
|
285
|
-
|
|
286
|
-
|
|
306
|
+
return [
|
|
307
|
+
` ${describeFlagSyntax(flag, field.type)}${required}${allowed}${description}`,
|
|
308
|
+
...renderNestedFields(field.fields ?? [], 2)
|
|
309
|
+
];
|
|
310
|
+
})
|
|
311
|
+
.flat()
|
|
287
312
|
];
|
|
288
313
|
}
|
|
289
314
|
|
|
315
|
+
/**
|
|
316
|
+
* The keys a JSON flag takes, indented under it one level per nesting.
|
|
317
|
+
*
|
|
318
|
+
* @param {import("./types.d.ts").CliField[]} fields
|
|
319
|
+
* @param {number} depth
|
|
320
|
+
* @returns {string[]}
|
|
321
|
+
*/
|
|
322
|
+
function renderNestedFields(fields, depth) {
|
|
323
|
+
const indent = " ".repeat(depth + 1);
|
|
324
|
+
return fields.flatMap((field) => {
|
|
325
|
+
const required = field.required ? " (required)" : "";
|
|
326
|
+
const allowed = field.allowed_values.length > 0 ? ` [${field.allowed_values.join(", ")}]` : "";
|
|
327
|
+
const description = field.description ? ` ${field.description}` : "";
|
|
328
|
+
return [
|
|
329
|
+
`${indent}${field.name} <${field.type}>${required}${allowed}${description}`,
|
|
330
|
+
...renderNestedFields(field.fields ?? [], depth + 1)
|
|
331
|
+
];
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
290
335
|
/** @param {import("./types.d.ts").CliCommand} command */
|
|
291
336
|
export function renderCommandHelp(command) {
|
|
292
337
|
const paths = [command.path, ...command.alternate_paths];
|
package/src/index.js
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { realpathSync } from "node:fs";
|
|
3
|
-
import fs from "node:fs/promises";
|
|
4
|
-
import { dirname, join } from "node:path";
|
|
5
3
|
import { fileURLToPath } from "node:url";
|
|
6
4
|
import { parseArgs } from "node:util";
|
|
7
5
|
import { confirm, isCancel } from "@clack/prompts";
|
|
8
6
|
import { CLI_COMMANDS } from "./commands.generated.js";
|
|
9
7
|
import { runConfigCommand } from "./config-command.js";
|
|
10
|
-
import { CLI_CONFIGS, loadConfig, resolveSetting } from "./config.js";
|
|
8
|
+
import { CLI_CONFIGS, loadConfig, readCliVersion, resolveSetting } from "./config.js";
|
|
11
9
|
import {
|
|
12
10
|
assertSuppliedValuesArePresent,
|
|
13
11
|
buildParserOptions,
|
|
@@ -22,7 +20,20 @@ import {
|
|
|
22
20
|
renderResourceHelp,
|
|
23
21
|
renderRootHelp
|
|
24
22
|
} from "./help.js";
|
|
25
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
createLoginContext,
|
|
25
|
+
runLoginCommand,
|
|
26
|
+
runLogoutCommand,
|
|
27
|
+
runWhoamiCommand,
|
|
28
|
+
shouldOfferLogin
|
|
29
|
+
} from "./login-command.js";
|
|
30
|
+
import {
|
|
31
|
+
renderPagedResult,
|
|
32
|
+
renderResponse,
|
|
33
|
+
sanitizeText,
|
|
34
|
+
shouldEmitJson,
|
|
35
|
+
writeCommandResult
|
|
36
|
+
} from "./output.js";
|
|
26
37
|
import { requestAllPages, requestApi } from "./request.js";
|
|
27
38
|
|
|
28
39
|
/** Path parameters people refer to by the shorter name they actually type. */
|
|
@@ -37,10 +48,22 @@ const PATH_ALIASES = { project_id: "project", team_id: "team" };
|
|
|
37
48
|
*/
|
|
38
49
|
const UNSAFE_PATH_VALUES = new Set([".", ".."]);
|
|
39
50
|
|
|
40
|
-
|
|
51
|
+
/**
|
|
52
|
+
* `tglow login`'s own flags, declared so `--method api-key` keeps its value instead of the value
|
|
53
|
+
* becoming a positional. The registry's own `method` field is a string too, so they agree.
|
|
54
|
+
*
|
|
55
|
+
* @type {Record<string, { type: "string" | "boolean" }>}
|
|
56
|
+
*/
|
|
57
|
+
const LOGIN_PARSER_OPTIONS = {
|
|
58
|
+
method: { type: "string" },
|
|
59
|
+
wait: { type: "boolean" },
|
|
60
|
+
new: { type: "boolean" }
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const PARSER_OPTIONS = { ...LOGIN_PARSER_OPTIONS, ...buildParserOptions(CLI_COMMANDS) };
|
|
41
64
|
|
|
42
65
|
/** @param {string[]} argv */
|
|
43
|
-
function parseFlags(argv) {
|
|
66
|
+
export function parseFlags(argv) {
|
|
44
67
|
return parseArgs({
|
|
45
68
|
args: argv,
|
|
46
69
|
strict: false,
|
|
@@ -103,7 +126,7 @@ function readFlag(flags, flag) {
|
|
|
103
126
|
* uses and one they wrap in a shell function.
|
|
104
127
|
*
|
|
105
128
|
* @param {import("./types.d.ts").CliCommand} command
|
|
106
|
-
* @param {{ flags: Record<string, unknown>; config:
|
|
129
|
+
* @param {{ flags: Record<string, unknown>; config: import("./types.d.ts").CliConfig }} sources
|
|
107
130
|
*/
|
|
108
131
|
function resolvePath(command, { flags, config }) {
|
|
109
132
|
const placeholders = [...command.path.matchAll(/:([a-z_]+)/g)].map(([, name]) => name);
|
|
@@ -113,6 +136,8 @@ function resolvePath(command, { flags, config }) {
|
|
|
113
136
|
const unsafe = [];
|
|
114
137
|
/** @type {Set<string>} */
|
|
115
138
|
const consumed = new Set();
|
|
139
|
+
/** @type {Record<string, string>} */
|
|
140
|
+
const values = {};
|
|
116
141
|
|
|
117
142
|
const path = placeholders.reduce((current, name) => {
|
|
118
143
|
// Only project and team fall back to configuration. Resolving EVERY placeholder that way would
|
|
@@ -133,10 +158,11 @@ function resolvePath(command, { flags, config }) {
|
|
|
133
158
|
}
|
|
134
159
|
|
|
135
160
|
consumed.add(name);
|
|
161
|
+
values[name] = value;
|
|
136
162
|
return current.replace(`:${name}`, encodeURIComponent(value));
|
|
137
163
|
}, command.path);
|
|
138
164
|
|
|
139
|
-
return { path, missing, unsafe, consumed };
|
|
165
|
+
return { path, missing, unsafe, consumed, values };
|
|
140
166
|
}
|
|
141
167
|
|
|
142
168
|
/** @param {import("./types.d.ts").CliCommand} command */
|
|
@@ -144,12 +170,6 @@ function isPaginated(command) {
|
|
|
144
170
|
return command.method === "GET" && command.query_params.some((field) => field.name === "after");
|
|
145
171
|
}
|
|
146
172
|
|
|
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
173
|
/**
|
|
154
174
|
* @param {string} message
|
|
155
175
|
* @param {{ json?: unknown }} flags
|
|
@@ -181,16 +201,17 @@ async function confirmDestructive(command, flags) {
|
|
|
181
201
|
|
|
182
202
|
/**
|
|
183
203
|
* @param {string[]} argv
|
|
204
|
+
* @param {import("./login-command.js").LoginContext} [login_context]
|
|
184
205
|
* @returns {Promise<{ output: string; code: number }>}
|
|
185
206
|
*/
|
|
186
|
-
export async function run(argv) {
|
|
207
|
+
export async function run(argv, login_context = createLoginContext()) {
|
|
187
208
|
const { values: flags, positionals } = parseFlags(argv);
|
|
188
209
|
|
|
189
210
|
try {
|
|
190
211
|
normalizeSwitches(flags);
|
|
191
212
|
assertSuppliedValuesArePresent(flags, PARSER_OPTIONS);
|
|
192
213
|
|
|
193
|
-
const describe =
|
|
214
|
+
const describe = login_context.emits_json(flags);
|
|
194
215
|
/** @param {string} text @param {unknown} machine_readable */
|
|
195
216
|
const inform = (text, machine_readable) => ({
|
|
196
217
|
output: describe ? JSON.stringify(machine_readable, null, 2) : text,
|
|
@@ -198,17 +219,34 @@ export async function run(argv) {
|
|
|
198
219
|
});
|
|
199
220
|
|
|
200
221
|
if (flags.version) {
|
|
201
|
-
const version = await
|
|
222
|
+
const version = await readCliVersion();
|
|
202
223
|
return inform(version, { version });
|
|
203
224
|
}
|
|
204
225
|
|
|
205
226
|
const [resource, name, ...rest] = positionals;
|
|
206
|
-
if (
|
|
227
|
+
if (resource === "help") {
|
|
228
|
+
if (name) return fail("Run `tglow <resource> <command> --help` for command help.", flags);
|
|
229
|
+
return inform(renderRootHelp(), describeForMachines());
|
|
230
|
+
}
|
|
231
|
+
if (!resource) {
|
|
232
|
+
if (await shouldOfferLogin(flags, login_context)) {
|
|
233
|
+
return await runLoginCommand([], flags, login_context);
|
|
234
|
+
}
|
|
235
|
+
return inform(renderRootHelp(), describeForMachines());
|
|
236
|
+
}
|
|
207
237
|
if (resource === "config") return await runConfigCommand([name, ...rest], flags);
|
|
238
|
+
if (resource === "login") return await runLoginCommand([name, ...rest], flags, login_context);
|
|
239
|
+
if (resource === "logout") return await runLogoutCommand([name, ...rest], flags);
|
|
240
|
+
if (resource === "whoami") return await runWhoamiCommand([name, ...rest], flags);
|
|
241
|
+
if (resource === "docs") {
|
|
242
|
+
// Loaded only here: the bundled docs are about 600 KB that every other command would parse.
|
|
243
|
+
const { runDocsCommand } = await import("./docs-command.js");
|
|
244
|
+
return runDocsCommand([name, ...rest], flags);
|
|
245
|
+
}
|
|
208
246
|
|
|
209
247
|
const commands = CLI_COMMANDS.filter((entry) => entry.resource === resource);
|
|
210
248
|
if (commands.length === 0) {
|
|
211
|
-
return fail(`Unknown resource "${resource}". Run \`tglow\` for the list.`, flags);
|
|
249
|
+
return fail(`Unknown resource "${resource}". Run \`tglow help\` for the list.`, flags);
|
|
212
250
|
}
|
|
213
251
|
|
|
214
252
|
if (!name) {
|
|
@@ -226,7 +264,8 @@ export async function run(argv) {
|
|
|
226
264
|
if (flags.help) return inform(renderCommandHelp(command), describeForMachines({ command }));
|
|
227
265
|
|
|
228
266
|
const config = await loadConfig();
|
|
229
|
-
const { path, missing, unsafe, consumed } = resolvePath(command, { flags, config });
|
|
267
|
+
const { path, missing, unsafe, consumed, values } = resolvePath(command, { flags, config });
|
|
268
|
+
const path_team_id = values.team_id ?? null;
|
|
230
269
|
|
|
231
270
|
if (unsafe.length > 0) {
|
|
232
271
|
const named = unsafe.map((entry) => `--${toFlagName(entry)}`).join(", ");
|
|
@@ -307,6 +346,7 @@ export async function run(argv) {
|
|
|
307
346
|
query,
|
|
308
347
|
flags,
|
|
309
348
|
config,
|
|
349
|
+
path_team_id,
|
|
310
350
|
max: Number(raw_max)
|
|
311
351
|
});
|
|
312
352
|
|
|
@@ -330,10 +370,27 @@ export async function run(argv) {
|
|
|
330
370
|
|
|
331
371
|
if (!(await confirmDestructive(command, flags))) return fail("Cancelled.", flags);
|
|
332
372
|
|
|
333
|
-
const payload = await requestApi({
|
|
373
|
+
const payload = await requestApi({
|
|
374
|
+
method: command.method,
|
|
375
|
+
path,
|
|
376
|
+
query,
|
|
377
|
+
body,
|
|
378
|
+
flags,
|
|
379
|
+
config,
|
|
380
|
+
path_team_id
|
|
381
|
+
});
|
|
334
382
|
return { output: renderResponse(payload, flags), code: 0 };
|
|
335
383
|
} catch (err) {
|
|
336
|
-
|
|
384
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
385
|
+
// `tglow docs` answers in text unless --json was given, including for a flag the shared checks
|
|
386
|
+
// above rejected before it was dispatched.
|
|
387
|
+
if (positionals[0] === "docs") {
|
|
388
|
+
return {
|
|
389
|
+
output: flags.json === true ? JSON.stringify({ error: message }) : message,
|
|
390
|
+
code: 1
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
return fail(message, flags);
|
|
337
394
|
}
|
|
338
395
|
}
|
|
339
396
|
|
|
@@ -357,14 +414,25 @@ if (isEntrypoint()) {
|
|
|
357
414
|
// `process.exit` discards whatever is still buffered for a pipe, and a pipe's buffer is 64 KB.
|
|
358
415
|
// `tglow records list --all --json | jq` would receive a JSON document cut off mid-value, which
|
|
359
416
|
// is exactly the invariant this CLI sells. Set the code and let the write drain instead.
|
|
417
|
+
let json = shouldEmitJson({});
|
|
360
418
|
try {
|
|
361
|
-
const
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
419
|
+
const argv = process.argv.slice(2);
|
|
420
|
+
const { values: flags, positionals } = parseFlags(argv);
|
|
421
|
+
json = shouldEmitJson({ json: flags.json === true || flags.json === "true" });
|
|
422
|
+
const result = await run(argv);
|
|
423
|
+
const message =
|
|
424
|
+
!flags.help &&
|
|
425
|
+
((!positionals[0] && result.output !== renderRootHelp()) ||
|
|
426
|
+
["login", "logout", "whoami"].includes(positionals[0]) ||
|
|
427
|
+
(positionals[0] === "config" && ["set", "unset"].includes(positionals[1])));
|
|
428
|
+
process.exitCode = result.code;
|
|
429
|
+
writeCommandResult(result, { json, message });
|
|
366
430
|
} catch (err) {
|
|
367
431
|
process.exitCode = 1;
|
|
368
|
-
|
|
432
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
433
|
+
writeCommandResult(
|
|
434
|
+
{ output: json ? JSON.stringify({ error: message }) : message, code: 1 },
|
|
435
|
+
{ json }
|
|
436
|
+
);
|
|
369
437
|
}
|
|
370
438
|
}
|