@tailglow/cli 0.5.1 → 0.6.1
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
|
@@ -0,0 +1,957 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { hostname } from "node:os";
|
|
3
|
+
import { isCancel, log, select, spinner } from "@clack/prompts";
|
|
4
|
+
import {
|
|
5
|
+
CLI_CONFIGS,
|
|
6
|
+
isSameApiUrl,
|
|
7
|
+
loadConfig,
|
|
8
|
+
LOGIN_CONFIGS,
|
|
9
|
+
readPendingLogin,
|
|
10
|
+
readStoredLogin,
|
|
11
|
+
resolveApiUrl,
|
|
12
|
+
resolveCredential,
|
|
13
|
+
resolveSetting,
|
|
14
|
+
saveConfig,
|
|
15
|
+
updateConfig,
|
|
16
|
+
withConfigLock
|
|
17
|
+
} from "./config.js";
|
|
18
|
+
import { sanitizeText, shouldEmitJson } from "./output.js";
|
|
19
|
+
import { buildRequestHeaders, sendRequest } from "./request.js";
|
|
20
|
+
|
|
21
|
+
/** Hosts an approval link may use plain http on: a local API and app during development. */
|
|
22
|
+
const LOCAL_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]);
|
|
23
|
+
|
|
24
|
+
/** `--method` values as typed, mapped to the API's names. */
|
|
25
|
+
const METHOD_FLAGS = { account: "account", "api-key": "api_key" };
|
|
26
|
+
|
|
27
|
+
/** @type {Record<string, string>} */
|
|
28
|
+
const WITHDRAWN_MESSAGES = {
|
|
29
|
+
approving_session_ended: "the browser session that approved it ended",
|
|
30
|
+
user_removed: "the account that approved it no longer exists",
|
|
31
|
+
account_disabled: "the account that approved it is disabled",
|
|
32
|
+
team_access_lost: "the approver no longer has that access in the team",
|
|
33
|
+
team_unavailable: "the team is archived or blocked",
|
|
34
|
+
team_has_no_project:
|
|
35
|
+
"an API key needs a team with a project. Create one, or sign in with your account instead"
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @typedef {Object} LoginContext
|
|
40
|
+
* @property {string} config_path
|
|
41
|
+
* @property {typeof fetch} fetch_impl
|
|
42
|
+
* @property {() => boolean} is_interactive Whether a person is at the terminal.
|
|
43
|
+
* @property {(flags: Record<string, unknown>) => boolean} emits_json
|
|
44
|
+
* @property {() => Promise<"account" | "api_key" | null>} choose_method
|
|
45
|
+
* @property {(url: string) => void} open_browser
|
|
46
|
+
* @property {(ms: number) => Promise<void>} sleep
|
|
47
|
+
* @property {() => number} now
|
|
48
|
+
* @property {(text: string) => void} say Progress for a person, before the command's result.
|
|
49
|
+
* @property {{
|
|
50
|
+
* start: (text: string, expires_at?: number) => void;
|
|
51
|
+
* stop: (text: string, outcome?: "success" | "cancel" | "error") => void;
|
|
52
|
+
* }} progress
|
|
53
|
+
* @property {string} device_label
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Opens a URL without a shell. On Windows `start` goes through cmd.exe, which cuts the URL at the
|
|
58
|
+
* first `&` and drops the code; `rundll32` takes it whole.
|
|
59
|
+
*
|
|
60
|
+
* @param {string} url
|
|
61
|
+
*/
|
|
62
|
+
function openBrowser(url) {
|
|
63
|
+
let command = "xdg-open";
|
|
64
|
+
let args = [url];
|
|
65
|
+
if (process.platform === "darwin") command = "open";
|
|
66
|
+
if (process.platform === "win32") {
|
|
67
|
+
command = "rundll32";
|
|
68
|
+
args = ["url.dll,FileProtocolHandler", url];
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
|
72
|
+
child.on("error", () => {});
|
|
73
|
+
child.unref();
|
|
74
|
+
} catch {
|
|
75
|
+
// The link is printed as well, so a machine without a browser loses nothing.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The approval link, checked before it is stored, printed or opened. Only an https address (http on
|
|
81
|
+
* this machine) is accepted, so an API the CLI was pointed at cannot make it launch a file or
|
|
82
|
+
* another program.
|
|
83
|
+
*
|
|
84
|
+
* @param {unknown} value
|
|
85
|
+
* @returns {string}
|
|
86
|
+
*/
|
|
87
|
+
function readVerificationUrl(value) {
|
|
88
|
+
/** @type {URL | null} */
|
|
89
|
+
let url = null;
|
|
90
|
+
try {
|
|
91
|
+
url = new URL(String(value));
|
|
92
|
+
} catch {
|
|
93
|
+
url = null;
|
|
94
|
+
}
|
|
95
|
+
const is_secure = url?.protocol === "https:";
|
|
96
|
+
const is_local = url?.protocol === "http:" && LOCAL_HOSTNAMES.has(url.hostname);
|
|
97
|
+
if (!url || !(is_secure || is_local)) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
"The API answered with a sign-in link that is not an https address. Check --api-url."
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return url.href;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** @returns {LoginContext} */
|
|
106
|
+
export function createLoginContext() {
|
|
107
|
+
/** @type {ReturnType<typeof spinner> | null} */
|
|
108
|
+
let indicator = null;
|
|
109
|
+
/** @type {ReturnType<typeof setInterval> | undefined} */
|
|
110
|
+
let countdown;
|
|
111
|
+
return {
|
|
112
|
+
config_path: CLI_CONFIGS.CONFIG_PATH,
|
|
113
|
+
fetch_impl: fetch,
|
|
114
|
+
is_interactive: () => Boolean(process.stdout.isTTY && process.stdin.isTTY),
|
|
115
|
+
emits_json: (flags) => shouldEmitJson(flags),
|
|
116
|
+
choose_method: async () => {
|
|
117
|
+
log.info("Use `tglow help` to browse commands without signing in.");
|
|
118
|
+
const answer = await select({
|
|
119
|
+
message: "How do you want to sign in?",
|
|
120
|
+
options: [
|
|
121
|
+
{
|
|
122
|
+
value: "account",
|
|
123
|
+
label: "Sign in with your Tailglow account"
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
value: "api_key",
|
|
127
|
+
label: "Sign in with an API key"
|
|
128
|
+
},
|
|
129
|
+
{ value: "cancel", label: "Cancel" }
|
|
130
|
+
]
|
|
131
|
+
});
|
|
132
|
+
if (isCancel(answer) || answer === "cancel") return null;
|
|
133
|
+
return answer === "api_key" ? "api_key" : "account";
|
|
134
|
+
},
|
|
135
|
+
open_browser: openBrowser,
|
|
136
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
137
|
+
now: () => Date.now(),
|
|
138
|
+
say: (text) => log.step(text),
|
|
139
|
+
progress: {
|
|
140
|
+
start: (text, expires_at) => {
|
|
141
|
+
indicator ??= spinner();
|
|
142
|
+
const message = () => {
|
|
143
|
+
if (expires_at === undefined) return text;
|
|
144
|
+
const seconds = Math.max(0, Math.ceil((expires_at - Date.now()) / 1_000));
|
|
145
|
+
return `${text} (code expires in ${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")})`;
|
|
146
|
+
};
|
|
147
|
+
indicator.start(message());
|
|
148
|
+
clearInterval(countdown);
|
|
149
|
+
countdown = setInterval(() => indicator?.message(message()), 1_000);
|
|
150
|
+
},
|
|
151
|
+
stop: (text, outcome = "success") => {
|
|
152
|
+
clearInterval(countdown);
|
|
153
|
+
if (outcome === "cancel") indicator?.cancel(text);
|
|
154
|
+
else if (outcome === "error") indicator?.error(text);
|
|
155
|
+
else indicator?.stop(text);
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
device_label: hostname()
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Whether a bare command should offer sign-in. This checks local credentials without a request.
|
|
164
|
+
*
|
|
165
|
+
* @param {Record<string, unknown>} flags
|
|
166
|
+
* @param {LoginContext} context
|
|
167
|
+
*/
|
|
168
|
+
export async function shouldOfferLogin(flags, context) {
|
|
169
|
+
if (flags.help || context.emits_json(flags) || !context.is_interactive()) return false;
|
|
170
|
+
const config = await loadConfig(context.config_path);
|
|
171
|
+
if (resolveSetting("api_key", { flags, config })) return false;
|
|
172
|
+
const login = readStoredLogin(config);
|
|
173
|
+
return !login || !isSameApiUrl(login.api_url, resolveApiUrl({ flags, config }));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* @param {Record<string, unknown>} flags
|
|
178
|
+
* @param {string} name
|
|
179
|
+
*/
|
|
180
|
+
function readSwitch(flags, name) {
|
|
181
|
+
const value = flags[name];
|
|
182
|
+
if (value === undefined || value === false || value === "false") return false;
|
|
183
|
+
if (value === true || value === "true") return true;
|
|
184
|
+
throw new Error(`--${name} takes no value, or =true / =false. Received "${String(value)}".`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Login commands parse only their own flags: `--api-key` means "authenticate with this key"
|
|
189
|
+
* everywhere else, which is not something a sign-in can use.
|
|
190
|
+
*
|
|
191
|
+
* @param {string[]} words
|
|
192
|
+
* @param {Record<string, unknown>} flags
|
|
193
|
+
* @param {Set<string>} allowed
|
|
194
|
+
*/
|
|
195
|
+
function refuseUnexpected(words, flags, allowed) {
|
|
196
|
+
const unknown = Object.keys(flags).filter((flag) => !allowed.has(flag));
|
|
197
|
+
if (unknown.length > 0) {
|
|
198
|
+
throw new Error(
|
|
199
|
+
`Unknown flag${unknown.length === 1 ? "" : "s"}: ${unknown.map((flag) => `--${flag}`).join(", ")}.`
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
const extra = words.filter((word) => word !== undefined);
|
|
203
|
+
if (extra.length > 0) {
|
|
204
|
+
throw new Error(`Unexpected argument${extra.length === 1 ? "" : "s"}: ${extra.join(", ")}.`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* @param {{ json: boolean }} view
|
|
210
|
+
* @param {{ text: string; fields: Record<string, unknown>; code?: number }} result
|
|
211
|
+
*/
|
|
212
|
+
function respond(view, { text, fields, code = 0 }) {
|
|
213
|
+
return { output: view.json ? JSON.stringify(fields, null, 2) : text, code };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** @param {import("./types.d.ts").ApiResponse | null} payload @param {number} status */
|
|
217
|
+
function describeRefusal(payload, status) {
|
|
218
|
+
const message = payload?.error?.message || payload?.message || `HTTP ${status}`;
|
|
219
|
+
return `${status} ${sanitizeText(String(message))}`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* @param {string} method
|
|
224
|
+
* @param {LoginContext} context
|
|
225
|
+
* @param {string} api_url
|
|
226
|
+
* @returns {Promise<import("./types.d.ts").PendingLogin>}
|
|
227
|
+
*/
|
|
228
|
+
async function startLogin(method, context, api_url) {
|
|
229
|
+
const { ok, status, payload } = await sendRequest({
|
|
230
|
+
method: "POST",
|
|
231
|
+
url: new URL("/v1/auth/cli_logins", api_url),
|
|
232
|
+
headers: await buildRequestHeaders({ secret: null, team_id: null }),
|
|
233
|
+
body: { method, device_label: context.device_label },
|
|
234
|
+
fetch_impl: context.fetch_impl,
|
|
235
|
+
timeout_ms: LOGIN_CONFIGS.REQUEST_TIMEOUT_MS
|
|
236
|
+
});
|
|
237
|
+
if (!ok) throw new Error(`Could not start signing in: ${describeRefusal(payload, status)}`);
|
|
238
|
+
|
|
239
|
+
const data = /** @type {Record<string, unknown>} */ (payload?.data ?? {});
|
|
240
|
+
for (const name of ["id", "user_code", "verification_url", "poll_secret"]) {
|
|
241
|
+
if (typeof data[name] !== "string") {
|
|
242
|
+
throw new Error(`The API answered without a ${name}. Check --api-url.`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const expires_at = Date.parse(String(data.expires_at));
|
|
247
|
+
if (!Number.isFinite(expires_at)) {
|
|
248
|
+
throw new Error("The API answered without a valid sign-in expiry. Check --api-url.");
|
|
249
|
+
}
|
|
250
|
+
/** @type {import("./types.d.ts").PendingLogin} */
|
|
251
|
+
const pending = {
|
|
252
|
+
id: String(data.id),
|
|
253
|
+
poll_secret: String(data.poll_secret),
|
|
254
|
+
user_code: String(data.user_code),
|
|
255
|
+
verification_url: readVerificationUrl(data.verification_url),
|
|
256
|
+
method: method === "api_key" ? "api_key" : "account",
|
|
257
|
+
api_url,
|
|
258
|
+
started_at: context.now(),
|
|
259
|
+
expires_at
|
|
260
|
+
};
|
|
261
|
+
await updateConfig((current) => ({ ...current, pending_login: pending }), context.config_path);
|
|
262
|
+
return pending;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* One poll. Failures that retrying can fix (a network error, a 5xx while the API restarts) come
|
|
267
|
+
* back as `retry`; everything else is the server's answer.
|
|
268
|
+
*
|
|
269
|
+
* @param {import("./types.d.ts").PendingLogin} pending
|
|
270
|
+
* @param {LoginContext} context
|
|
271
|
+
* @returns {Promise<{
|
|
272
|
+
* status: string;
|
|
273
|
+
* status_reason: string | null;
|
|
274
|
+
* interval_ms: number;
|
|
275
|
+
* credential: Record<string, unknown> | null;
|
|
276
|
+
* }>}
|
|
277
|
+
*/
|
|
278
|
+
async function pollLogin(pending, context) {
|
|
279
|
+
/** @param {string} status */
|
|
280
|
+
const answer = (status) => ({
|
|
281
|
+
status,
|
|
282
|
+
status_reason: null,
|
|
283
|
+
interval_ms: LOGIN_CONFIGS.DEFAULT_INTERVAL_MS,
|
|
284
|
+
credential: null
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
/** @type {Awaited<ReturnType<typeof sendRequest>>} */
|
|
288
|
+
let response;
|
|
289
|
+
try {
|
|
290
|
+
response = await sendRequest({
|
|
291
|
+
method: "POST",
|
|
292
|
+
url: new URL(`/v1/auth/cli_logins/${encodeURIComponent(pending.id)}/token`, pending.api_url),
|
|
293
|
+
headers: await buildRequestHeaders({ secret: null, team_id: null }),
|
|
294
|
+
body: { poll_secret: pending.poll_secret },
|
|
295
|
+
fetch_impl: context.fetch_impl,
|
|
296
|
+
timeout_ms: LOGIN_CONFIGS.REQUEST_TIMEOUT_MS
|
|
297
|
+
});
|
|
298
|
+
} catch {
|
|
299
|
+
return answer("retry");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// The login's key expired in Redis.
|
|
303
|
+
if (response.status === 404) return answer("expired");
|
|
304
|
+
if (response.status >= 500) return answer("retry");
|
|
305
|
+
if (!response.ok) {
|
|
306
|
+
throw new Error(`Signing in failed: ${describeRefusal(response.payload, response.status)}`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const data = /** @type {Record<string, unknown>} */ (response.payload?.data ?? {});
|
|
310
|
+
const interval_seconds = Number(data.interval_seconds);
|
|
311
|
+
const credential =
|
|
312
|
+
data.credential && typeof data.credential === "object"
|
|
313
|
+
? /** @type {Record<string, unknown>} */ (data.credential)
|
|
314
|
+
: null;
|
|
315
|
+
return {
|
|
316
|
+
status: typeof data.status === "string" ? data.status : "retry",
|
|
317
|
+
status_reason: typeof data.status_reason === "string" ? data.status_reason : null,
|
|
318
|
+
interval_ms:
|
|
319
|
+
Number.isFinite(interval_seconds) && interval_seconds > 0
|
|
320
|
+
? interval_seconds * 1_000
|
|
321
|
+
: LOGIN_CONFIGS.DEFAULT_INTERVAL_MS,
|
|
322
|
+
credential
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Polls until the server gives a final answer or the waiting budget ends. Interactive login makes
|
|
328
|
+
* one final scheduled poll at expiry to collect a last-moment approval. An in-flight poll is never
|
|
329
|
+
* cut short: it may be collecting the credential.
|
|
330
|
+
*
|
|
331
|
+
* @param {import("./types.d.ts").PendingLogin} pending
|
|
332
|
+
* @param {LoginContext} context
|
|
333
|
+
* @param {{ deadline_ms: number; poll_first: boolean; poll_at_deadline?: boolean }} options
|
|
334
|
+
*/
|
|
335
|
+
async function pollUntilAnswered(
|
|
336
|
+
pending,
|
|
337
|
+
context,
|
|
338
|
+
{ deadline_ms, poll_first, poll_at_deadline = false }
|
|
339
|
+
) {
|
|
340
|
+
let interval_ms = LOGIN_CONFIGS.DEFAULT_INTERVAL_MS;
|
|
341
|
+
let wait_ms = poll_first ? 0 : interval_ms;
|
|
342
|
+
let last_status = null;
|
|
343
|
+
while (true) {
|
|
344
|
+
if (context.now() + wait_ms >= deadline_ms) {
|
|
345
|
+
if (poll_at_deadline) {
|
|
346
|
+
// Keep the server's polling interval even when the expiry falls between two polls.
|
|
347
|
+
if (wait_ms > 0) await context.sleep(wait_ms);
|
|
348
|
+
const result = await pollLogin(pending, context);
|
|
349
|
+
if (!["pending", "slow_down", "retry"].includes(result.status)) return result;
|
|
350
|
+
last_status = result.status;
|
|
351
|
+
}
|
|
352
|
+
const status = last_status === "retry" ? "unreachable" : "pending";
|
|
353
|
+
return { status, status_reason: null, interval_ms, credential: null };
|
|
354
|
+
}
|
|
355
|
+
if (wait_ms > 0) await context.sleep(wait_ms);
|
|
356
|
+
|
|
357
|
+
const result = await pollLogin(pending, context);
|
|
358
|
+
last_status = result.status;
|
|
359
|
+
interval_ms = Math.max(interval_ms, result.interval_ms);
|
|
360
|
+
if (result.status === "slow_down") interval_ms += LOGIN_CONFIGS.SLOW_DOWN_STEP_MS;
|
|
361
|
+
if (!["pending", "slow_down", "retry"].includes(result.status)) return result;
|
|
362
|
+
wait_ms = interval_ms;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Removes a credential `tglow login` issued, from the API that issued it. Only "that credential no
|
|
368
|
+
* longer exists" counts as already gone; any other refusal leaves it alive on the server.
|
|
369
|
+
*
|
|
370
|
+
* @param {import("./types.d.ts").StoredLogin} login
|
|
371
|
+
* @param {LoginContext} context
|
|
372
|
+
* @returns {Promise<"removed" | "already_gone" | "refused" | "unreachable">}
|
|
373
|
+
*/
|
|
374
|
+
async function removeIssuedCredential(login, context) {
|
|
375
|
+
const is_session = login.type === "session";
|
|
376
|
+
try {
|
|
377
|
+
const { ok, status, payload } = await sendRequest({
|
|
378
|
+
method: is_session ? "POST" : "DELETE",
|
|
379
|
+
url: new URL(
|
|
380
|
+
is_session ? "/v1/auth/logout" : `/v1/keys/${encodeURIComponent(login.id)}`,
|
|
381
|
+
login.api_url
|
|
382
|
+
),
|
|
383
|
+
headers: await buildRequestHeaders({ secret: login.secret, team_id: login.team_id }),
|
|
384
|
+
body: is_session ? {} : undefined,
|
|
385
|
+
fetch_impl: context.fetch_impl,
|
|
386
|
+
timeout_ms: LOGIN_CONFIGS.REQUEST_TIMEOUT_MS
|
|
387
|
+
});
|
|
388
|
+
if (ok) return "removed";
|
|
389
|
+
if (payload?.error?.code === "auth_invalid_credentials") return "already_gone";
|
|
390
|
+
if (status >= 500) return "unreachable";
|
|
391
|
+
return "refused";
|
|
392
|
+
} catch {
|
|
393
|
+
return "unreachable";
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** @param {import("./types.d.ts").StoredLogin} login */
|
|
398
|
+
function describeCleanupPlace(login) {
|
|
399
|
+
return login.type === "session"
|
|
400
|
+
? "Profile > Security in the Tailglow app"
|
|
401
|
+
: "Team settings > API keys in the Tailglow app";
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Stores the collected credential, then removes the one it replaces. Saving first means a failed
|
|
406
|
+
* cleanup can never leave the machine signed out.
|
|
407
|
+
*
|
|
408
|
+
* @param {import("./types.d.ts").PendingLogin} pending
|
|
409
|
+
* @param {Record<string, unknown>} credential
|
|
410
|
+
* @param {{ json: boolean }} view
|
|
411
|
+
* @param {LoginContext} context
|
|
412
|
+
*/
|
|
413
|
+
async function finishLogin(pending, credential, view, context) {
|
|
414
|
+
for (const name of ["secret", "id", "team_id", "user_id", "email"]) {
|
|
415
|
+
if (typeof credential[name] !== "string") {
|
|
416
|
+
throw new Error(
|
|
417
|
+
`The API returned a credential without a ${name}. Run \`tglow login --new\` to start again.`
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** @type {import("./types.d.ts").StoredLogin} */
|
|
423
|
+
const login = {
|
|
424
|
+
type: credential.type === "api_key" ? "api_key" : "session",
|
|
425
|
+
secret: String(credential.secret),
|
|
426
|
+
id: String(credential.id),
|
|
427
|
+
api_url: pending.api_url,
|
|
428
|
+
team_id: String(credential.team_id),
|
|
429
|
+
user_id: String(credential.user_id),
|
|
430
|
+
email: String(credential.email)
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
// A holder rather than a `let`: the assignment happens inside the callback, where TypeScript's
|
|
434
|
+
// narrowing of a local would lose it.
|
|
435
|
+
/** @type {{ previous: import("./types.d.ts").StoredLogin | null; is_current: boolean }} */
|
|
436
|
+
const swap = { previous: null, is_current: false };
|
|
437
|
+
try {
|
|
438
|
+
await updateConfig((current) => {
|
|
439
|
+
// `tglow logout` or `tglow login --new` discarded this sign-in while it waited.
|
|
440
|
+
if (readPendingLogin(current)?.id !== pending.id) return current;
|
|
441
|
+
swap.is_current = true;
|
|
442
|
+
swap.previous = readStoredLogin(current);
|
|
443
|
+
const { pending_login: _collected, ...rest } = current;
|
|
444
|
+
return { ...rest, login };
|
|
445
|
+
}, context.config_path);
|
|
446
|
+
} catch (err) {
|
|
447
|
+
// The server has already handed the credential out; ending it beats leaving one nobody holds.
|
|
448
|
+
const outcome = await removeIssuedCredential(login, context);
|
|
449
|
+
const fate =
|
|
450
|
+
outcome === "removed" || outcome === "already_gone"
|
|
451
|
+
? "so it was ended"
|
|
452
|
+
: `and it is still active: remove it in ${describeCleanupPlace(login)}`;
|
|
453
|
+
throw new Error(
|
|
454
|
+
`${err instanceof Error ? err.message : String(err)}\n` +
|
|
455
|
+
`The sign-in was approved, but its credential could not be stored here, ${fate}. Run \`tglow login --new\` to start again.`
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (!swap.is_current) {
|
|
460
|
+
const outcome = await removeIssuedCredential(login, context);
|
|
461
|
+
const is_removed = outcome === "removed" || outcome === "already_gone";
|
|
462
|
+
const text = is_removed
|
|
463
|
+
? "This sign-in was cancelled here while it waited, so its credential was ended instead of stored."
|
|
464
|
+
: `This sign-in was cancelled here while it waited, so its credential was not stored. Remove it in ${describeCleanupPlace(login)}.`;
|
|
465
|
+
return respond(view, {
|
|
466
|
+
text,
|
|
467
|
+
fields: { status: "cancelled", credential_id: login.id, removed: outcome, error: text },
|
|
468
|
+
code: 1
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const replaced = swap.previous;
|
|
473
|
+
let cleanup = "none";
|
|
474
|
+
if (replaced && replaced.secret !== login.secret) {
|
|
475
|
+
cleanup = await removeIssuedCredential(replaced, context);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const kind = login.type === "session" ? "CLI session" : "API key";
|
|
479
|
+
const lines = [
|
|
480
|
+
`Signed in as ${sanitizeText(login.email)} (team ${sanitizeText(login.team_id)}, ${kind}).`
|
|
481
|
+
];
|
|
482
|
+
if (replaced && (cleanup === "refused" || cleanup === "unreachable")) {
|
|
483
|
+
lines.push(
|
|
484
|
+
`Could not remove the credential this replaced. Remove it in ${describeCleanupPlace(replaced)}.`
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
lines.push(`Stored in ${context.config_path}, readable only by you.`);
|
|
488
|
+
|
|
489
|
+
return respond(view, {
|
|
490
|
+
text: lines.join("\n"),
|
|
491
|
+
fields: {
|
|
492
|
+
status: "signed_in",
|
|
493
|
+
method: login.type === "session" ? "account" : "api_key",
|
|
494
|
+
email: login.email,
|
|
495
|
+
user_id: login.user_id,
|
|
496
|
+
team_id: login.team_id,
|
|
497
|
+
credential_id: login.id,
|
|
498
|
+
replaced_credential: replaced ? cleanup : "none"
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Clears the pending sign-in, but only if it is still the one this process was working on.
|
|
505
|
+
*
|
|
506
|
+
* @param {string} pending_id
|
|
507
|
+
* @param {LoginContext} context
|
|
508
|
+
*/
|
|
509
|
+
async function forgetPendingLogin(pending_id, context) {
|
|
510
|
+
await updateConfig((current) => {
|
|
511
|
+
if (readPendingLogin(current)?.id !== pending_id) return current;
|
|
512
|
+
const { pending_login: _forgotten, ...rest } = current;
|
|
513
|
+
return rest;
|
|
514
|
+
}, context.config_path);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** @param {{ status: string; status_reason: string | null }} result */
|
|
518
|
+
function describeEnded(result) {
|
|
519
|
+
if (result.status === "denied") return "The sign-in was denied in the browser.";
|
|
520
|
+
if (result.status === "expired")
|
|
521
|
+
return "The sign-in expired before it was approved and collected.";
|
|
522
|
+
if (result.status === "consumed") {
|
|
523
|
+
return (
|
|
524
|
+
"This sign-in was already collected, and a credential is only ever handed out once. " +
|
|
525
|
+
"If this terminal never received it, remove the unused one in the Tailglow app."
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
if (result.status === "revoked") {
|
|
529
|
+
const why = WITHDRAWN_MESSAGES[result.status_reason ?? ""] ?? "its approval no longer holds";
|
|
530
|
+
return `The approval was withdrawn: ${why}.`;
|
|
531
|
+
}
|
|
532
|
+
return `The sign-in ended (${result.status}).`;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* @param {{ json: boolean }} view
|
|
537
|
+
* @param {{ status: string; status_reason: string | null }} result
|
|
538
|
+
*/
|
|
539
|
+
function respondEnded(view, result) {
|
|
540
|
+
// A collected sign-in stays pending for the process that collected it, so only --new replaces it.
|
|
541
|
+
const next = result.status === "consumed" ? "tglow login --new" : "tglow login";
|
|
542
|
+
const message = `${describeEnded(result)} Run \`${next}\` to start again.`;
|
|
543
|
+
return respond(view, {
|
|
544
|
+
text: message,
|
|
545
|
+
fields: { status: result.status, status_reason: result.status_reason, error: message },
|
|
546
|
+
code: 1
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* @param {{ json: boolean }} view
|
|
552
|
+
* @param {import("./types.d.ts").PendingLogin} pending
|
|
553
|
+
*/
|
|
554
|
+
function respondUnreachable(view, pending) {
|
|
555
|
+
const text = `Could not reach ${sanitizeText(pending.api_url)}. The sign-in is kept: check the connection, then run \`tglow login --wait\`.`;
|
|
556
|
+
return respond(view, {
|
|
557
|
+
text,
|
|
558
|
+
fields: { status: "unreachable", api_url: pending.api_url, error: text },
|
|
559
|
+
code: 1
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* @param {{ json: boolean }} view
|
|
565
|
+
* @param {import("./types.d.ts").PendingLogin} pending
|
|
566
|
+
* @param {{ code?: number; is_waiting?: boolean }} [options]
|
|
567
|
+
*/
|
|
568
|
+
function respondPending(view, pending, { code = 0, is_waiting = false } = {}) {
|
|
569
|
+
const next = "tglow login --wait";
|
|
570
|
+
const opening = is_waiting ? "Still waiting for approval." : "To sign in, open this link:";
|
|
571
|
+
const link = sanitizeText(pending.verification_url);
|
|
572
|
+
return respond(view, {
|
|
573
|
+
text: [
|
|
574
|
+
opening,
|
|
575
|
+
...(is_waiting ? [`Approve it at: ${link}`] : [` ${link}`]),
|
|
576
|
+
`Check the page shows the code ${sanitizeText(formatUserCode(pending.user_code))}, then approve it.`,
|
|
577
|
+
`Then run \`${next}\` to finish. It waits up to ${LOGIN_CONFIGS.WAIT_BUDGET_MS / 1_000} seconds and can be run again.`
|
|
578
|
+
].join("\n"),
|
|
579
|
+
fields: {
|
|
580
|
+
status: "pending",
|
|
581
|
+
verification_url: pending.verification_url,
|
|
582
|
+
user_code: pending.user_code,
|
|
583
|
+
method: pending.method,
|
|
584
|
+
next_command: next
|
|
585
|
+
},
|
|
586
|
+
code
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** @param {string} code */
|
|
591
|
+
function formatUserCode(code) {
|
|
592
|
+
return code.length === 8 ? `${code.slice(0, 4)}-${code.slice(4)}` : code;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
export function renderLoginHelp() {
|
|
596
|
+
return [
|
|
597
|
+
"tglow login: sign in from this machine",
|
|
598
|
+
"",
|
|
599
|
+
"Usage:",
|
|
600
|
+
" tglow login [--method account|api-key] Start signing in, in the browser.",
|
|
601
|
+
" tglow login --wait Finish a sign-in started without a terminal.",
|
|
602
|
+
" tglow logout Remove the credential tglow login stored.",
|
|
603
|
+
" tglow whoami Show who the CLI is acting as.",
|
|
604
|
+
"",
|
|
605
|
+
"Methods:",
|
|
606
|
+
" account A CLI session that acts as you, in the team you choose. The default.",
|
|
607
|
+
" api-key An API key for one team, with the permissions you choose. The team needs a project.",
|
|
608
|
+
"",
|
|
609
|
+
"In a terminal, login opens the approval page and counts down to the code's expiry (ten minutes",
|
|
610
|
+
"after creation). Expiry ends the interactive wait cleanly; run `tglow login` to try again.",
|
|
611
|
+
"Without a terminal (an agent, a script), it prints the link and exits; approve it, then run",
|
|
612
|
+
"`tglow login --wait`.",
|
|
613
|
+
"",
|
|
614
|
+
"Flags:",
|
|
615
|
+
" --method <account|api-key> Skip the question.",
|
|
616
|
+
" --wait Collect a pending sign-in. Exits 0 once signed in, 75 while it is",
|
|
617
|
+
" still waiting (run it again), 1 if it was denied, expired, withdrawn",
|
|
618
|
+
" or the API could not be reached.",
|
|
619
|
+
" --new Discard a pending sign-in and start another.",
|
|
620
|
+
" --api-url <url> Sign in to another API. The credential is only ever sent there.",
|
|
621
|
+
"",
|
|
622
|
+
`The credential is stored in ${CLI_CONFIGS.CONFIG_PATH}, readable only by you.`,
|
|
623
|
+
"--api-key and TAILGLOW_API_KEY take precedence over it."
|
|
624
|
+
].join("\n");
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
export function describeLoginForMachines() {
|
|
628
|
+
return {
|
|
629
|
+
login: {
|
|
630
|
+
usage: "tglow login [--method account|api-key] [--wait] [--new] [--api-url <url>]",
|
|
631
|
+
description:
|
|
632
|
+
"Sign in through the browser. Without a terminal it prints { status: pending, verification_url, user_code } and exits 0; approve the link, then run `tglow login --wait`.",
|
|
633
|
+
methods: {
|
|
634
|
+
account: "A CLI session acting as the person, in the team they choose. The default.",
|
|
635
|
+
"api-key": "An API key for one team with chosen permissions. The team needs a project."
|
|
636
|
+
},
|
|
637
|
+
exit_codes: {
|
|
638
|
+
0: "Signed in, a pending link was printed, or an interactive wait ended normally.",
|
|
639
|
+
[LOGIN_CONFIGS.EXIT_PENDING]:
|
|
640
|
+
"The --wait budget ended while approval was pending. Run `tglow login --wait` to resume.",
|
|
641
|
+
1: "Denied, cancelled, withdrawn or failed; expired when using --wait."
|
|
642
|
+
}
|
|
643
|
+
},
|
|
644
|
+
logout: { usage: "tglow logout", description: "Remove the credential `tglow login` stored." },
|
|
645
|
+
whoami: {
|
|
646
|
+
usage: "tglow whoami",
|
|
647
|
+
description: "Show the person or key, team and credential in use."
|
|
648
|
+
}
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
const LOGIN_FLAGS = new Set(["json", "help", "api-url", "method", "wait", "new"]);
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* @param {string[]} words
|
|
656
|
+
* @param {Record<string, unknown>} flags
|
|
657
|
+
* @param {LoginContext} [context]
|
|
658
|
+
* @returns {Promise<{ output: string; code: number }>}
|
|
659
|
+
*/
|
|
660
|
+
export async function runLoginCommand(words, flags, context = createLoginContext()) {
|
|
661
|
+
refuseUnexpected(words, flags, LOGIN_FLAGS);
|
|
662
|
+
const view = { json: context.emits_json(flags) };
|
|
663
|
+
if (readSwitch(flags, "help")) {
|
|
664
|
+
return respond(view, { text: renderLoginHelp(), fields: describeLoginForMachines() });
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
const is_waiting = readSwitch(flags, "wait");
|
|
668
|
+
const is_new = readSwitch(flags, "new");
|
|
669
|
+
if (is_waiting && (is_new || flags.method !== undefined)) {
|
|
670
|
+
throw new Error("--wait finishes the pending sign-in, so it takes no --new or --method.");
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** @type {"account" | "api_key" | null} */
|
|
674
|
+
let requested_method = null;
|
|
675
|
+
if (flags.method !== undefined) {
|
|
676
|
+
const method = METHOD_FLAGS[/** @type {keyof typeof METHOD_FLAGS} */ (String(flags.method))];
|
|
677
|
+
if (!method) {
|
|
678
|
+
throw new Error(`--method must be account or api-key, received "${String(flags.method)}".`);
|
|
679
|
+
}
|
|
680
|
+
requested_method = method === "api_key" ? "api_key" : "account";
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const config = await loadConfig(context.config_path);
|
|
684
|
+
const api_url = resolveApiUrl({ flags, config });
|
|
685
|
+
const interactive = !view.json && context.is_interactive();
|
|
686
|
+
let pending = readPendingLogin(config);
|
|
687
|
+
if (pending && context.now() - pending.started_at > LOGIN_CONFIGS.PENDING_FORGET_MS) {
|
|
688
|
+
await forgetPendingLogin(pending.id, context);
|
|
689
|
+
pending = null;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
if (is_waiting) {
|
|
693
|
+
if (!pending) throw new Error("No sign-in is waiting. Run `tglow login` first.");
|
|
694
|
+
// The pending sign-in names its own API; only an explicit, different --api-url disagrees.
|
|
695
|
+
if (typeof flags["api-url"] === "string" && !isSameApiUrl(pending.api_url, api_url)) {
|
|
696
|
+
throw new Error(
|
|
697
|
+
`The waiting sign-in is for ${pending.api_url}. Pass --api-url ${pending.api_url}.`
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
const result = await pollUntilAnswered(pending, context, {
|
|
701
|
+
deadline_ms: context.now() + LOGIN_CONFIGS.WAIT_BUDGET_MS,
|
|
702
|
+
poll_first: true
|
|
703
|
+
});
|
|
704
|
+
if (result.status === "pending") {
|
|
705
|
+
return respondPending(view, pending, { code: LOGIN_CONFIGS.EXIT_PENDING, is_waiting: true });
|
|
706
|
+
}
|
|
707
|
+
if (result.status === "unreachable") return respondUnreachable(view, pending);
|
|
708
|
+
if (result.status === "approved" && result.credential) {
|
|
709
|
+
return finishLogin(pending, result.credential, view, context);
|
|
710
|
+
}
|
|
711
|
+
// Another process holding this sign-in collected it, and that process clears it.
|
|
712
|
+
if (result.status !== "consumed") await forgetPendingLogin(pending.id, context);
|
|
713
|
+
return respondEnded(view, result);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// A sign-in already waiting is picked up rather than replaced, so a re-run prints the same link
|
|
717
|
+
// the person may already have open. The server, not the local clock, decides whether it ended.
|
|
718
|
+
const reusable =
|
|
719
|
+
pending &&
|
|
720
|
+
!is_new &&
|
|
721
|
+
isSameApiUrl(pending.api_url, api_url) &&
|
|
722
|
+
(requested_method === null || requested_method === pending.method);
|
|
723
|
+
if (pending && reusable) {
|
|
724
|
+
let result = await pollLogin(pending, context);
|
|
725
|
+
// A poll inside the server's interval answers slow_down without reading the sign-in.
|
|
726
|
+
if (result.status === "slow_down") {
|
|
727
|
+
await context.sleep(result.interval_ms);
|
|
728
|
+
result = await pollLogin(pending, context);
|
|
729
|
+
}
|
|
730
|
+
if (result.status === "approved" && result.credential) {
|
|
731
|
+
return finishLogin(pending, result.credential, view, context);
|
|
732
|
+
}
|
|
733
|
+
if (["pending", "slow_down", "retry"].includes(result.status)) {
|
|
734
|
+
if (!interactive) return respondPending(view, pending);
|
|
735
|
+
} else if (result.status === "consumed") {
|
|
736
|
+
return respondEnded(view, result);
|
|
737
|
+
} else {
|
|
738
|
+
await forgetPendingLogin(pending.id, context);
|
|
739
|
+
pending = null;
|
|
740
|
+
}
|
|
741
|
+
} else {
|
|
742
|
+
pending = null;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
if (!pending) {
|
|
746
|
+
let method = requested_method;
|
|
747
|
+
if (!method) method = interactive ? await context.choose_method() : "account";
|
|
748
|
+
if (!method) return { output: "Cancelled.", code: 1 };
|
|
749
|
+
pending = await startLogin(method, context, api_url);
|
|
750
|
+
if (!interactive) return respondPending(view, pending);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const verification_url = readVerificationUrl(pending.verification_url);
|
|
754
|
+
context.say(`Opening browser\n${verification_url}`);
|
|
755
|
+
context.say(`Check the page shows the code ${sanitizeText(formatUserCode(pending.user_code))}.`);
|
|
756
|
+
context.open_browser(verification_url);
|
|
757
|
+
const expires_at = Number.isFinite(pending.expires_at)
|
|
758
|
+
? Number(pending.expires_at)
|
|
759
|
+
: pending.started_at + LOGIN_CONFIGS.INTERACTIVE_BUDGET_MS;
|
|
760
|
+
context.progress.start("Waiting for approval in the browser", expires_at);
|
|
761
|
+
let result;
|
|
762
|
+
try {
|
|
763
|
+
result = await pollUntilAnswered(pending, context, {
|
|
764
|
+
deadline_ms: expires_at,
|
|
765
|
+
poll_first: false,
|
|
766
|
+
poll_at_deadline: true
|
|
767
|
+
});
|
|
768
|
+
} finally {
|
|
769
|
+
let message = "Stopped waiting";
|
|
770
|
+
if (result?.status === "approved") message = "Approved";
|
|
771
|
+
else if (result?.status === "pending")
|
|
772
|
+
message = "Stopped waiting. Run `tglow login --wait` to resume.";
|
|
773
|
+
else if (result?.status === "expired")
|
|
774
|
+
message = "Sign-in code expired. Run `tglow login` to try again.";
|
|
775
|
+
else if (result?.status === "unreachable") message = "Could not reach the API";
|
|
776
|
+
const outcome =
|
|
777
|
+
result?.status === "approved"
|
|
778
|
+
? "success"
|
|
779
|
+
: ["pending", "expired"].includes(result?.status ?? "")
|
|
780
|
+
? "cancel"
|
|
781
|
+
: "error";
|
|
782
|
+
context.progress.stop(message, outcome);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
if (result.status === "approved" && result.credential) {
|
|
786
|
+
return finishLogin(pending, result.credential, view, context);
|
|
787
|
+
}
|
|
788
|
+
if (result.status === "pending") return { output: "", code: 0 };
|
|
789
|
+
if (result.status === "unreachable") return respondUnreachable(view, pending);
|
|
790
|
+
if (result.status !== "consumed") await forgetPendingLogin(pending.id, context);
|
|
791
|
+
if (result.status === "expired") return { output: "", code: 0 };
|
|
792
|
+
return respondEnded(view, result);
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* @param {string[]} words
|
|
797
|
+
* @param {Record<string, unknown>} flags
|
|
798
|
+
* @param {LoginContext} [context]
|
|
799
|
+
* @returns {Promise<{ output: string; code: number }>}
|
|
800
|
+
*/
|
|
801
|
+
export async function runLogoutCommand(words, flags, context = createLoginContext()) {
|
|
802
|
+
refuseUnexpected(words, flags, new Set(["json", "help"]));
|
|
803
|
+
const view = { json: context.emits_json(flags) };
|
|
804
|
+
if (readSwitch(flags, "help")) {
|
|
805
|
+
return respond(view, { text: renderLoginHelp(), fields: describeLoginForMachines() });
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// One lock covers reading the login and discarding a pending sign-in, so a `tglow login --wait`
|
|
809
|
+
// finishing at the same moment is either stored before this read or cancelled by it.
|
|
810
|
+
const config = await withConfigLock(async () => {
|
|
811
|
+
const stored = await loadConfig(context.config_path);
|
|
812
|
+
if (!readPendingLogin(stored)) return stored;
|
|
813
|
+
const { pending_login: _discarded, ...rest } = stored;
|
|
814
|
+
await saveConfig(rest, context.config_path);
|
|
815
|
+
return rest;
|
|
816
|
+
}, context.config_path);
|
|
817
|
+
|
|
818
|
+
const login = readStoredLogin(config);
|
|
819
|
+
|
|
820
|
+
if (!login) {
|
|
821
|
+
const note =
|
|
822
|
+
typeof config.api_key === "string" || process.env.TAILGLOW_API_KEY
|
|
823
|
+
? " An API key from TAILGLOW_API_KEY or `tglow config` is still used; logout removes only what `tglow login` stored."
|
|
824
|
+
: "";
|
|
825
|
+
return respond(view, {
|
|
826
|
+
text: `Not signed in with \`tglow login\`.${note}`,
|
|
827
|
+
fields: { status: "signed_out", removed: "none" }
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const outcome = await removeIssuedCredential(login, context);
|
|
832
|
+
if (outcome === "unreachable") {
|
|
833
|
+
return respond(view, {
|
|
834
|
+
text: `Could not reach ${login.api_url} to end the credential, so it is still stored. Run \`tglow logout\` again.`,
|
|
835
|
+
fields: { status: "signed_in", removed: outcome, error: "unreachable" },
|
|
836
|
+
code: 1
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
await updateConfig((current) => {
|
|
841
|
+
if (readStoredLogin(current)?.secret !== login.secret) return current;
|
|
842
|
+
const { login: _removed, ...rest } = current;
|
|
843
|
+
return rest;
|
|
844
|
+
}, context.config_path);
|
|
845
|
+
|
|
846
|
+
if (outcome === "refused") {
|
|
847
|
+
return respond(view, {
|
|
848
|
+
text: `Signed out here, but the API refused to end the credential. Remove it in ${describeCleanupPlace(login)}.`,
|
|
849
|
+
fields: { status: "signed_out", removed: outcome },
|
|
850
|
+
code: 1
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
const kind = login.type === "session" ? "CLI session" : "API key";
|
|
854
|
+
let text = `Signed out. The ${kind} has been ended.`;
|
|
855
|
+
if (outcome === "already_gone") {
|
|
856
|
+
text = `Signed out. The API no longer accepted this ${kind}, so it was removed here only.`;
|
|
857
|
+
}
|
|
858
|
+
return respond(view, { text, fields: { status: "signed_out", removed: outcome } });
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* @param {string[]} words
|
|
863
|
+
* @param {Record<string, unknown>} flags
|
|
864
|
+
* @param {LoginContext} [context]
|
|
865
|
+
* @returns {Promise<{ output: string; code: number }>}
|
|
866
|
+
*/
|
|
867
|
+
export async function runWhoamiCommand(words, flags, context = createLoginContext()) {
|
|
868
|
+
refuseUnexpected(words, flags, new Set(["json", "help", "api-key", "api-url", "team"]));
|
|
869
|
+
const view = { json: context.emits_json(flags) };
|
|
870
|
+
if (readSwitch(flags, "help")) {
|
|
871
|
+
return respond(view, { text: renderLoginHelp(), fields: describeLoginForMachines() });
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
const config = await loadConfig(context.config_path);
|
|
875
|
+
const credential = resolveCredential({ flags, config });
|
|
876
|
+
const api_url = resolveApiUrl({ flags, config });
|
|
877
|
+
const team_id = resolveSetting("team", { flags, config }) ?? credential.login?.team_id ?? null;
|
|
878
|
+
|
|
879
|
+
/** @param {string | null} selected_team_id */
|
|
880
|
+
async function readAuth(selected_team_id) {
|
|
881
|
+
return sendRequest({
|
|
882
|
+
method: "GET",
|
|
883
|
+
url: new URL("/v1/auth", api_url),
|
|
884
|
+
headers: await buildRequestHeaders({ secret: credential.secret, team_id: selected_team_id }),
|
|
885
|
+
fetch_impl: context.fetch_impl,
|
|
886
|
+
timeout_ms: LOGIN_CONFIGS.REQUEST_TIMEOUT_MS
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
let response = await readAuth(team_id);
|
|
891
|
+
let team_refused = false;
|
|
892
|
+
// A CLI session removed from its team still identifies the person, which is what they need to
|
|
893
|
+
// know before signing in again.
|
|
894
|
+
const is_refused = response.status === 401 || response.status === 403;
|
|
895
|
+
if (is_refused && credential.login?.type === "session" && team_id) {
|
|
896
|
+
const identity = await readAuth(null);
|
|
897
|
+
if (identity.ok) {
|
|
898
|
+
response = identity;
|
|
899
|
+
team_refused = true;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
if (!response.ok) throw new Error(describeRefusal(response.payload, response.status));
|
|
903
|
+
|
|
904
|
+
const auth = /** @type {Record<string, unknown>} */ (response.payload?.data ?? {});
|
|
905
|
+
const auth_team_id = typeof auth.team_id === "string" ? auth.team_id : null;
|
|
906
|
+
|
|
907
|
+
let team_name = null;
|
|
908
|
+
if (auth_team_id) {
|
|
909
|
+
const team = await sendRequest({
|
|
910
|
+
method: "GET",
|
|
911
|
+
url: new URL(`/v1/teams/${encodeURIComponent(auth_team_id)}`, api_url),
|
|
912
|
+
headers: await buildRequestHeaders({ secret: credential.secret, team_id: auth_team_id }),
|
|
913
|
+
fetch_impl: context.fetch_impl,
|
|
914
|
+
timeout_ms: LOGIN_CONFIGS.REQUEST_TIMEOUT_MS
|
|
915
|
+
}).catch(() => null);
|
|
916
|
+
|
|
917
|
+
const team_data = /** @type {Record<string, unknown> | undefined} */ (team?.payload?.data);
|
|
918
|
+
if (team?.ok && typeof team_data?.name === "string") team_name = team_data.name;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
const is_user = auth.type === "user";
|
|
922
|
+
let credential_label = "API key";
|
|
923
|
+
if (credential.login?.type === "session") credential_label = "CLI session (tglow login)";
|
|
924
|
+
if (credential.login?.type === "api_key") credential_label = "API key (tglow login)";
|
|
925
|
+
if (credential.source === "env") credential_label = "API key (TAILGLOW_API_KEY)";
|
|
926
|
+
if (credential.source === "flag") credential_label = "API key (--api-key)";
|
|
927
|
+
|
|
928
|
+
const who = is_user
|
|
929
|
+
? `${sanitizeText(String(auth.name ?? ""))} <${sanitizeText(String(auth.email ?? ""))}>`
|
|
930
|
+
: `API key ${sanitizeText(String(auth.name ?? auth.key_id ?? ""))}`;
|
|
931
|
+
|
|
932
|
+
let team_line = "No team selected.";
|
|
933
|
+
if (auth_team_id)
|
|
934
|
+
team_line = `Team: ${team_name ? `${sanitizeText(team_name)} ` : ""}(${sanitizeText(auth_team_id)})`;
|
|
935
|
+
if (team_refused)
|
|
936
|
+
team_line = `No longer a member of team ${sanitizeText(String(team_id))}. Run \`tglow login\` to choose a team.`;
|
|
937
|
+
|
|
938
|
+
return respond(view, {
|
|
939
|
+
text: [
|
|
940
|
+
`Signed in as ${who}`,
|
|
941
|
+
team_line,
|
|
942
|
+
`Credential: ${credential_label}`,
|
|
943
|
+
`API: ${api_url}`
|
|
944
|
+
].join("\n"),
|
|
945
|
+
fields: {
|
|
946
|
+
type: is_user ? "user" : "key",
|
|
947
|
+
user_id: auth.user_id ?? null,
|
|
948
|
+
email: auth.email ?? null,
|
|
949
|
+
name: auth.name ?? null,
|
|
950
|
+
key_id: auth.key_id ?? null,
|
|
951
|
+
team: auth_team_id ? { id: auth_team_id, name: team_name } : null,
|
|
952
|
+
team_refused,
|
|
953
|
+
credential: { source: credential.source, type: credential.login?.type ?? "api_key" },
|
|
954
|
+
api_url
|
|
955
|
+
}
|
|
956
|
+
});
|
|
957
|
+
}
|