@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/output.js CHANGED
@@ -1,3 +1,6 @@
1
+ import { log } from "@clack/prompts";
2
+ import { LOGIN_CONFIGS } from "./config.js";
3
+
1
4
  /**
2
5
  * Whether to emit machine-readable output.
3
6
  *
@@ -147,3 +150,28 @@ export function renderPagedResult(
147
150
 
148
151
  return `${table}\n\nStopped at the result cap. Continue with:\n ${sanitizeText(resume_command)}`;
149
152
  }
153
+
154
+ /**
155
+ * Writes human status messages with the same guide as prompts. Machine output stays byte-for-byte
156
+ * intact, and failures stay on stderr even when only that stream is redirected.
157
+ *
158
+ * @param {{ output: string; code: number }} result
159
+ * @param {{ json: boolean; message?: boolean }} view
160
+ * @param {{ stdout?: NodeJS.WriteStream; stderr?: NodeJS.WriteStream }} [streams]
161
+ */
162
+ export function writeCommandResult(
163
+ { output, code },
164
+ { json, message = false },
165
+ { stdout = process.stdout, stderr = process.stderr } = {}
166
+ ) {
167
+ if (!output) return;
168
+ const stream = code === 0 ? stdout : stderr;
169
+ if (!json && stream.isTTY && (code !== 0 || message)) {
170
+ const text = output.split("\n").map(sanitizeText).join("\n");
171
+ const write =
172
+ code === LOGIN_CONFIGS.EXIT_PENDING ? log.warn : code === 0 ? log.success : log.error;
173
+ write(text, { output: stream });
174
+ return;
175
+ }
176
+ stream.write(`${output}\n`);
177
+ }
package/src/request.js CHANGED
@@ -1,6 +1,86 @@
1
- import { CLI_CONFIGS, resolveApiKey, resolveApiUrl } from "./config.js";
1
+ import {
2
+ CLI_CONFIGS,
3
+ readCliVersion,
4
+ resolveApiUrl,
5
+ resolveCredential,
6
+ resolveSetting
7
+ } from "./config.js";
2
8
  import { sanitizeText } from "./output.js";
3
9
 
10
+ /**
11
+ * What to do next when the API refuses a `tglow login` credential, by error code. An API key's
12
+ * refusals already say what they mean; a CLI session's need the step that fixes them.
13
+ *
14
+ * @type {Record<string, string>}
15
+ */
16
+ const LOGIN_REFUSAL_ADVICE = {
17
+ auth_invalid_credentials:
18
+ "The API did not accept the stored sign-in. If it keeps happening, run `tglow login` again.",
19
+ auth_mfa_required:
20
+ "This needs a sign-in verified with MFA. Run `tglow login` and approve it from a browser session that has completed MFA.",
21
+ auth_account_disabled: "The account or team is disabled. Check it in the Tailglow app.",
22
+ auth_missing_permissions:
23
+ "Your role does not allow this, or it can only be done in the Tailglow app."
24
+ };
25
+
26
+ /**
27
+ * Headers every request carries. The team header picks which of a person's teams a CLI session acts
28
+ * on; an API key belongs to one team and ignores it.
29
+ *
30
+ * @param {{ secret: string | null; team_id: string | null }} options
31
+ */
32
+ export async function buildRequestHeaders({ secret, team_id }) {
33
+ /** @type {Record<string, string>} */
34
+ const headers = {
35
+ "Content-Type": "application/json",
36
+ "x-tailglow-client": "cli",
37
+ "x-tailglow-client-version": await readCliVersion()
38
+ };
39
+ if (secret) headers.Authorization = `Bearer ${secret}`;
40
+ if (team_id) headers["x-tailglow-team-id"] = team_id;
41
+ return headers;
42
+ }
43
+
44
+ /**
45
+ * One request and its parsed envelope, without deciding what a status means. `tglow login` uses it
46
+ * where a refusal is an answer rather than a failure.
47
+ *
48
+ * @param {{
49
+ * method: string;
50
+ * url: URL;
51
+ * headers: Record<string, string>;
52
+ * body?: Record<string, unknown>;
53
+ * fetch_impl?: typeof fetch;
54
+ * timeout_ms?: number;
55
+ * }} options
56
+ * @returns {Promise<{
57
+ * status: number;
58
+ * ok: boolean;
59
+ * payload: import("./types.d.ts").ApiResponse | null;
60
+ * }>}
61
+ */
62
+ export async function sendRequest({ method, url, headers, body, fetch_impl = fetch, timeout_ms }) {
63
+ const response = await fetch_impl(url, {
64
+ method,
65
+ headers,
66
+ body: method === "GET" || body === undefined ? undefined : JSON.stringify(body),
67
+ signal: timeout_ms ? AbortSignal.timeout(timeout_ms) : undefined
68
+ });
69
+
70
+ const text = await response.text();
71
+
72
+ /** @type {import("./types.d.ts").ApiResponse | null} */
73
+ let payload = null;
74
+ if (text.trim() !== "") {
75
+ try {
76
+ payload = readEnvelope(text);
77
+ } catch {
78
+ payload = null;
79
+ }
80
+ }
81
+ return { status: response.status, ok: response.ok, payload };
82
+ }
83
+
4
84
  /**
5
85
  * Renders a field-level validation failure as lines instead of a wall of escaped JSON.
6
86
  *
@@ -47,9 +127,16 @@ export function formatValidationIssues(message) {
47
127
  * most on nested paths where either the parent or the child could be the miss. The address answers
48
128
  * that without the CLI having to know anything about id formats.
49
129
  *
50
- * @param {{ status: number; message: unknown; method: string; url: URL }} failure
130
+ * @param {{
131
+ * status: number;
132
+ * message: unknown;
133
+ * method: string;
134
+ * url: URL;
135
+ * code?: string | null;
136
+ * is_login?: boolean;
137
+ * }} failure
51
138
  */
52
- function describeFailure({ status, message, method, url }) {
139
+ function describeFailure({ status, message, method, url, code = null, is_login = false }) {
53
140
  // Parsed BEFORE sanitizing: escaping the newlines the API embedded would stop it parsing at all,
54
141
  // which is how the first attempt at this silently fell through to the raw dump.
55
142
  const issues = formatValidationIssues(String(message));
@@ -60,6 +147,9 @@ function describeFailure({ status, message, method, url }) {
60
147
  return `${status} ${text}\nNothing answers ${method} ${sanitizeText(url.pathname)}.`;
61
148
  }
62
149
 
150
+ const advice = is_login && code ? LOGIN_REFUSAL_ADVICE[code] : null;
151
+ if (advice && (status === 401 || status === 403)) return `${status} ${text}\n${advice}`;
152
+
63
153
  return `${status} ${text}`;
64
154
  }
65
155
 
@@ -87,23 +177,34 @@ function readEnvelope(text) {
87
177
  * query?: Record<string, unknown>;
88
178
  * body?: Record<string, unknown>;
89
179
  * flags?: Record<string, unknown>;
90
- * config?: Record<string, string>;
180
+ * config?: import("./types.d.ts").CliConfig;
181
+ * path_team_id?: string | null;
91
182
  * }} options
183
+ * `path_team_id` is the team a command names in its path; it acts in that team.
92
184
  * @returns {Promise<import("./types.d.ts").ApiResponse | null>}
93
185
  */
94
- export async function requestApi({ method, path, query = {}, body = {}, flags = {}, config = {} }) {
186
+ export async function requestApi({
187
+ method,
188
+ path,
189
+ query = {},
190
+ body = {},
191
+ flags = {},
192
+ config = {},
193
+ path_team_id = null
194
+ }) {
95
195
  const url = new URL(path, resolveApiUrl({ flags, config }));
96
196
  for (const [key, value] of Object.entries(query)) {
97
197
  if (value === undefined || value === null) continue;
98
198
  url.searchParams.set(key, typeof value === "object" ? JSON.stringify(value) : String(value));
99
199
  }
100
200
 
201
+ const credential = resolveCredential({ flags, config });
202
+ const team_id =
203
+ path_team_id ?? resolveSetting("team", { flags, config }) ?? credential.login?.team_id ?? null;
204
+
101
205
  const response = await fetch(url, {
102
206
  method,
103
- headers: {
104
- Authorization: `Bearer ${resolveApiKey({ flags, config })}`,
105
- "Content-Type": "application/json"
106
- },
207
+ headers: await buildRequestHeaders({ secret: credential.secret, team_id }),
107
208
  body: method === "GET" ? undefined : JSON.stringify(body)
108
209
  });
109
210
 
@@ -122,7 +223,16 @@ export async function requestApi({ method, path, query = {}, body = {}, flags =
122
223
 
123
224
  if (!response.ok) {
124
225
  const message = payload?.error?.message || payload?.message || response.statusText;
125
- throw new Error(describeFailure({ status: response.status, message, method, url }));
226
+ throw new Error(
227
+ describeFailure({
228
+ status: response.status,
229
+ message,
230
+ method,
231
+ url,
232
+ code: payload?.error?.code ?? null,
233
+ is_login: credential.source === "login"
234
+ })
235
+ );
126
236
  }
127
237
 
128
238
  // A 2xx body that is not JSON means something between here and the API answered instead: a proxy
@@ -156,7 +266,8 @@ export async function requestApi({ method, path, query = {}, body = {}, flags =
156
266
  * path: string;
157
267
  * query?: Record<string, unknown>;
158
268
  * flags?: Record<string, unknown>;
159
- * config?: Record<string, string>;
269
+ * config?: import("./types.d.ts").CliConfig;
270
+ * path_team_id?: string | null;
160
271
  * max?: number;
161
272
  * }} options
162
273
  * @returns {Promise<{ data: unknown[]; truncated: boolean; next_cursor: string | null }>}
@@ -167,6 +278,7 @@ export async function requestAllPages({
167
278
  query = {},
168
279
  flags = {},
169
280
  config = {},
281
+ path_team_id = null,
170
282
  max = CLI_CONFIGS.DEFAULT_MAX_RECORDS
171
283
  }) {
172
284
  /** @type {unknown[]} */
@@ -186,7 +298,8 @@ export async function requestAllPages({
186
298
  ...(cursor ? { after: cursor } : {})
187
299
  },
188
300
  flags,
189
- config
301
+ config,
302
+ path_team_id
190
303
  });
191
304
 
192
305
  const page = Array.isArray(payload?.data) ? payload.data : [];
package/src/types.d.ts CHANGED
@@ -16,7 +16,45 @@ export interface ApiResponse {
16
16
  data?: unknown;
17
17
  message?: string;
18
18
  pagination?: { next_cursor?: string | null } | null;
19
- error?: { message?: string } | null;
19
+ error?: { message?: string; code?: string } | null;
20
+ }
21
+
22
+ /**
23
+ * What `~/.tailglow/config.json` holds: the string settings `tglow config` manages, plus what
24
+ * `tglow login` stores under `login` and `pending_login`. Every reader checks the shape it needs.
25
+ */
26
+ export type CliConfig = Record<string, unknown>;
27
+
28
+ /** The credential `tglow login` collected. `id` is the session or key id, for `tglow logout`. */
29
+ export interface StoredLogin {
30
+ type: "session" | "api_key";
31
+ secret: string;
32
+ id: string;
33
+ /** The API that issued the credential, and the only one it is ever sent to. */
34
+ api_url: string;
35
+ team_id: string;
36
+ user_id: string;
37
+ email: string;
38
+ }
39
+
40
+ /** A sign-in started by `tglow login` and not yet collected. */
41
+ export interface PendingLogin {
42
+ id: string;
43
+ poll_secret: string;
44
+ user_code: string;
45
+ verification_url: string;
46
+ method: "account" | "api_key";
47
+ api_url: string;
48
+ /** Epoch milliseconds. Only a safety net: the server decides when a sign-in has ended. */
49
+ started_at: number;
50
+ /** API expiry in epoch milliseconds. Absent in older saved sign-ins. */
51
+ expires_at?: number;
52
+ }
53
+
54
+ export interface ResolvedCredential {
55
+ secret: string;
56
+ source: "flag" | "env" | "login" | "config";
57
+ login: StoredLogin | null;
20
58
  }
21
59
 
22
60
  /** One value an operation accepts, in whichever of the three places it belongs. */
@@ -32,6 +70,8 @@ export interface CliField {
32
70
  allowed_values: string[];
33
71
  /** The replacement to use instead, when the field is on its way out. */
34
72
  deprecated: string | null;
73
+ /** The keys a JSON field takes: inside the object, or inside each item of an object array. */
74
+ fields?: CliField[];
35
75
  }
36
76
 
37
77
  /** One API operation, addressed as `<resource> <command>`. */