@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/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: "Emit JSON. Implied whenever output is piped or redirected."
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: "Team to act on, when a command needs one and --team-id is not given."
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
- "Credential to authenticate with. Prefer the environment variable: a key passed as a flag is visible in the process list and in shell history."
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 \'[{"view_id":"view_b"}]\'.',
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.map((field) => {
278
- const flag = toFlagName(field.name);
279
- const required = field.required ? " (required)" : "";
280
- const allowed =
281
- field.allowed_values.length > 0 ? ` [${field.allowed_values.join(", ")}]` : "";
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
- const description = field.description ? ` ${field.description}` : "";
304
+ const description = field.description ? ` ${field.description}` : "";
284
305
 
285
- return ` ${describeFlagSyntax(flag, field.type)}${required}${allowed}${description}`;
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 { renderPagedResult, renderResponse, sanitizeText, shouldEmitJson } from "./output.js";
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
- const PARSER_OPTIONS = buildParserOptions(CLI_COMMANDS);
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: Record<string, string> }} sources
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 = shouldEmitJson(flags);
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 readVersion();
222
+ const version = await readCliVersion();
202
223
  return inform(version, { version });
203
224
  }
204
225
 
205
226
  const [resource, name, ...rest] = positionals;
206
- if (!resource) return inform(renderRootHelp(), describeForMachines());
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({ method: command.method, path, query, body, flags, config });
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
- return fail(err instanceof Error ? err.message : String(err), flags);
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 { 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`);
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
- process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`);
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
  }