@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/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 {
|
|
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 {{
|
|
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?:
|
|
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({
|
|
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(
|
|
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?:
|
|
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>`. */
|