@coldtea/pr-lens-cli 0.6.1 → 0.8.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 +31 -3
- package/dist/account.d.ts +76 -0
- package/dist/account.d.ts.map +1 -0
- package/dist/account.js +211 -0
- package/dist/account.js.map +1 -0
- package/dist/auth.d.ts +40 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +121 -0
- package/dist/auth.js.map +1 -0
- package/dist/canvas/api.d.ts +35 -1
- package/dist/canvas/api.d.ts.map +1 -1
- package/dist/canvas/api.js +57 -1
- package/dist/canvas/api.js.map +1 -1
- package/dist/canvas/claim.d.ts +3 -0
- package/dist/canvas/claim.d.ts.map +1 -0
- package/dist/canvas/claim.js +84 -0
- package/dist/canvas/claim.js.map +1 -0
- package/dist/canvas/delete.js +3 -3
- package/dist/canvas/delete.js.map +1 -1
- package/dist/canvas/registry.d.ts +8 -2
- package/dist/canvas/registry.d.ts.map +1 -1
- package/dist/canvas/registry.js +15 -2
- package/dist/canvas/registry.js.map +1 -1
- package/dist/canvas/write.d.ts +11 -2
- package/dist/canvas/write.d.ts.map +1 -1
- package/dist/canvas/write.js +27 -8
- package/dist/canvas/write.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +8 -2
- package/dist/cli.js.map +1 -1
- package/dist/commands/auth.d.ts +9 -0
- package/dist/commands/auth.d.ts.map +1 -0
- package/dist/commands/auth.js +369 -0
- package/dist/commands/auth.js.map +1 -0
- package/dist/commands/canvas.d.ts.map +1 -1
- package/dist/commands/canvas.js +162 -21
- package/dist/commands/canvas.js.map +1 -1
- package/dist/commands/render.d.ts +1 -1
- package/dist/commands/render.d.ts.map +1 -1
- package/dist/commands/render.js +6 -3
- package/dist/commands/render.js.map +1 -1
- package/dist/config-home.d.ts +4 -0
- package/dist/config-home.d.ts.map +1 -0
- package/dist/config-home.js +38 -0
- package/dist/config-home.js.map +1 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/install.d.ts +5 -0
- package/dist/install.d.ts.map +1 -0
- package/dist/install.js +83 -0
- package/dist/install.js.map +1 -0
- package/dist/skill-content.generated.d.ts +2 -2
- package/dist/skill-content.generated.d.ts.map +1 -1
- package/dist/skill-content.generated.js +2 -2
- package/dist/skill-content.generated.js.map +1 -1
- package/dist/slug.d.ts +7 -0
- package/dist/slug.d.ts.map +1 -0
- package/dist/slug.js +23 -0
- package/dist/slug.js.map +1 -0
- package/dist/terminal.d.ts +5 -0
- package/dist/terminal.d.ts.map +1 -1
- package/dist/terminal.js +7 -0
- package/dist/terminal.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/workspace.d.ts +4 -0
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +26 -4
- package/dist/workspace.js.map +1 -1
- package/package.json +3 -3
- package/src/account.ts +302 -0
- package/src/auth.ts +183 -0
- package/src/canvas/api.ts +114 -2
- package/src/canvas/claim.ts +127 -0
- package/src/canvas/delete.ts +3 -3
- package/src/canvas/registry.ts +24 -2
- package/src/canvas/write.ts +37 -6
- package/src/cli.ts +8 -2
- package/src/commands/auth.ts +560 -0
- package/src/commands/canvas.ts +238 -18
- package/src/commands/render.ts +7 -3
- package/src/config-home.ts +45 -0
- package/src/errors.ts +5 -1
- package/src/install.ts +107 -0
- package/src/skill-content.generated.ts +2 -2
- package/src/slug.ts +26 -0
- package/src/terminal.ts +14 -0
- package/src/version.ts +1 -1
- package/src/workspace.ts +31 -4
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
import { hostname } from "node:os";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { assertNever } from "@coldtea/pr-lens-schema";
|
|
4
|
+
|
|
5
|
+
import { CLI_VERSION } from "../version.js";
|
|
6
|
+
import { readInstallId, readStoredInstallId } from "../install.js";
|
|
7
|
+
import type { Terminal } from "../terminal.js";
|
|
8
|
+
import { PrLensCliError, usageError } from "../errors.js";
|
|
9
|
+
import { parseOptions, readBoolean } from "../args.js";
|
|
10
|
+
import { API_ENV, DEFAULT_API, readApi } from "../canvas/write.js";
|
|
11
|
+
import {
|
|
12
|
+
forgetCredential,
|
|
13
|
+
readToken,
|
|
14
|
+
readCredential,
|
|
15
|
+
TOKEN_ENV,
|
|
16
|
+
writeCredential,
|
|
17
|
+
type CredentialRead,
|
|
18
|
+
} from "../auth.js";
|
|
19
|
+
import {
|
|
20
|
+
checkSignIn,
|
|
21
|
+
endSession,
|
|
22
|
+
pollSignIn,
|
|
23
|
+
startSignIn,
|
|
24
|
+
checkSession,
|
|
25
|
+
whoAmI,
|
|
26
|
+
type MachineState,
|
|
27
|
+
type StartedSignIn,
|
|
28
|
+
} from "../account.js";
|
|
29
|
+
|
|
30
|
+
const SUBCOMMANDS = ["login", "status", "logout"] as const;
|
|
31
|
+
type Subcommand = (typeof SUBCOMMANDS)[number];
|
|
32
|
+
|
|
33
|
+
const isSubcommand = (value: string): value is Subcommand =>
|
|
34
|
+
SUBCOMMANDS.some((subcommand) => subcommand === value);
|
|
35
|
+
|
|
36
|
+
export const USAGE = `pr-lens auth <login | status | logout> [options]
|
|
37
|
+
|
|
38
|
+
Signs this machine in to the PR Lens app, so the canvases it pushes belong to
|
|
39
|
+
your account. Nothing else needs it: pushing, pulling and rendering
|
|
40
|
+
all work signed out, and always will.
|
|
41
|
+
|
|
42
|
+
pr-lens auth login approve this machine in a browser
|
|
43
|
+
pr-lens auth login --force sign in again even if this machine already is
|
|
44
|
+
--no-browser print the link instead of opening one
|
|
45
|
+
|
|
46
|
+
pr-lens auth status which app this machine is signed in to
|
|
47
|
+
--json the same answer, for scripts
|
|
48
|
+
|
|
49
|
+
pr-lens auth logout forget the sign-in kept on this machine
|
|
50
|
+
|
|
51
|
+
--api <url> the PR Lens app (default $${API_ENV}, else ${DEFAULT_API})
|
|
52
|
+
|
|
53
|
+
The sign-in is kept per app, so one machine can be signed in to prlens.dev and
|
|
54
|
+
to a private store at once. $${TOKEN_ENV} overrides it, which is how CI signs in
|
|
55
|
+
without a browser.`;
|
|
56
|
+
|
|
57
|
+
const CLIENT = `pr-lens-cli/${CLI_VERSION}`;
|
|
58
|
+
|
|
59
|
+
/** RFC 8628 §3.5: each `slow_down` adds five seconds. */
|
|
60
|
+
const SLOW_DOWN_STEP_SECONDS = 5;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Adds the step rather than taking the served interval: the app serves the
|
|
64
|
+
* same five-second floor on `slow_down`, which alone would never back off.
|
|
65
|
+
*/
|
|
66
|
+
export const nextInterval = (seconds: number, served: number | undefined): number =>
|
|
67
|
+
Math.max(seconds + SLOW_DOWN_STEP_SECONDS, served ?? 0);
|
|
68
|
+
|
|
69
|
+
const sleep = (ms: number): Promise<void> =>
|
|
70
|
+
new Promise((resolve) => setTimeout(resolve, ms));
|
|
71
|
+
|
|
72
|
+
const hostOf = (api: string): string => new URL(api).host;
|
|
73
|
+
|
|
74
|
+
/** True means asked, not opened (over SSH it "succeeds" on no screen), so the link is printed either way. */
|
|
75
|
+
const askToOpen = (url: string): boolean => {
|
|
76
|
+
// The URL came off the wire: only http(s), and never through a shell
|
|
77
|
+
// (hence `rundll32` over `cmd /c start`).
|
|
78
|
+
const scheme = ((): string => {
|
|
79
|
+
try {
|
|
80
|
+
return new URL(url).protocol;
|
|
81
|
+
} catch {
|
|
82
|
+
return "";
|
|
83
|
+
}
|
|
84
|
+
})();
|
|
85
|
+
if (scheme !== "http:" && scheme !== "https:") return false;
|
|
86
|
+
|
|
87
|
+
const [command, leading] =
|
|
88
|
+
process.platform === "darwin"
|
|
89
|
+
? ["open", []]
|
|
90
|
+
: process.platform === "win32"
|
|
91
|
+
? ["rundll32", ["url.dll,FileProtocolHandler"]]
|
|
92
|
+
: ["xdg-open", []];
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const child = spawn(command, [...leading, url], {
|
|
96
|
+
detached: true,
|
|
97
|
+
stdio: "ignore",
|
|
98
|
+
});
|
|
99
|
+
// A missing opener fails asynchronously; unhandled, it would crash the sign-in.
|
|
100
|
+
child.on("error", () => undefined);
|
|
101
|
+
child.unref();
|
|
102
|
+
return true;
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const minutes = (seconds: number): string => {
|
|
109
|
+
const whole = Math.round(seconds / 60);
|
|
110
|
+
return whole <= 1 ? "a minute" : `${whole} minutes`;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const tellHowToApprove = (
|
|
114
|
+
started: StartedSignIn,
|
|
115
|
+
opening: boolean,
|
|
116
|
+
terminal: Terminal,
|
|
117
|
+
): void => {
|
|
118
|
+
terminal.out(
|
|
119
|
+
opening
|
|
120
|
+
? ` Code ${started.userCode} · opening ${started.approveUrl}`
|
|
121
|
+
: ` Code ${started.userCode} · open ${started.approveUrl}`,
|
|
122
|
+
);
|
|
123
|
+
terminal.out(
|
|
124
|
+
` Check the page shows the same code, then approve. The code lasts ${minutes(started.expiresInSeconds)}.`,
|
|
125
|
+
);
|
|
126
|
+
// On a TTY the ticker shows this instead.
|
|
127
|
+
if (terminal.status === undefined) terminal.out(" Waiting for it…");
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const refused = (): PrLensCliError =>
|
|
131
|
+
new PrLensCliError(
|
|
132
|
+
"AUTH_REQUIRED",
|
|
133
|
+
"the sign-in was turned down in the browser",
|
|
134
|
+
"nothing was signed in; run pr-lens auth login to ask again",
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
const ranOut = (): PrLensCliError =>
|
|
138
|
+
new PrLensCliError(
|
|
139
|
+
"AUTH_REQUIRED",
|
|
140
|
+
"the code ran out before anyone approved it",
|
|
141
|
+
[
|
|
142
|
+
"run pr-lens auth login for a fresh one",
|
|
143
|
+
"a code also runs out when the browser refused to link the machine; the page says so, but this terminal only sees the wait",
|
|
144
|
+
].join("\n"),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
type Approval = { token: string; claimed: number | undefined };
|
|
148
|
+
|
|
149
|
+
const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
150
|
+
const TICK_MS = 120;
|
|
151
|
+
|
|
152
|
+
const remaining = (deadline: number): string => {
|
|
153
|
+
const left = Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
|
|
154
|
+
return `${Math.floor(left / 60)}:${String(left % 60).padStart(2, "0")}`;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/** Only on a TTY, so pipes and CI logs don't collect spinner frames. */
|
|
158
|
+
const ticker = (terminal: Terminal, deadline: number): (() => void) => {
|
|
159
|
+
if (terminal.status === undefined) return () => undefined;
|
|
160
|
+
|
|
161
|
+
let frame = 0;
|
|
162
|
+
const timer = setInterval(() => {
|
|
163
|
+
frame = (frame + 1) % FRAMES.length;
|
|
164
|
+
terminal.status?.(` ${FRAMES[frame]} Waiting for approval · ${remaining(deadline)} left`);
|
|
165
|
+
}, TICK_MS);
|
|
166
|
+
|
|
167
|
+
// Must not hold the event loop open after the command finishes.
|
|
168
|
+
timer.unref?.();
|
|
169
|
+
|
|
170
|
+
return () => {
|
|
171
|
+
clearInterval(timer);
|
|
172
|
+
terminal.status?.(undefined);
|
|
173
|
+
};
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The deadline is fixed up front, so a slept laptop never polls a dead grant.
|
|
178
|
+
* An unreachable app is retried and reported only if the window closes on it.
|
|
179
|
+
*/
|
|
180
|
+
const waitForApproval = async (
|
|
181
|
+
api: string,
|
|
182
|
+
started: StartedSignIn,
|
|
183
|
+
terminal: Terminal,
|
|
184
|
+
): Promise<Approval> => {
|
|
185
|
+
const deadline = Date.now() + started.expiresInSeconds * 1000;
|
|
186
|
+
let seconds = started.intervalSeconds;
|
|
187
|
+
let unreached: PrLensCliError | undefined;
|
|
188
|
+
|
|
189
|
+
const stop = ticker(terminal, deadline);
|
|
190
|
+
try {
|
|
191
|
+
while (Date.now() < deadline) {
|
|
192
|
+
await sleep(seconds * 1000);
|
|
193
|
+
|
|
194
|
+
const outcome = await pollSignIn(api, started.deviceCode).catch(
|
|
195
|
+
(error: unknown) => {
|
|
196
|
+
if (!(error instanceof PrLensCliError) || error.code !== "APP_UNAVAILABLE")
|
|
197
|
+
throw error;
|
|
198
|
+
unreached = error;
|
|
199
|
+
return undefined;
|
|
200
|
+
},
|
|
201
|
+
);
|
|
202
|
+
if (outcome === undefined) continue;
|
|
203
|
+
unreached = undefined;
|
|
204
|
+
|
|
205
|
+
switch (outcome.type) {
|
|
206
|
+
case "token":
|
|
207
|
+
return { token: outcome.token, claimed: outcome.claimed };
|
|
208
|
+
case "pending":
|
|
209
|
+
break;
|
|
210
|
+
case "slow_down":
|
|
211
|
+
seconds = nextInterval(seconds, outcome.intervalSeconds);
|
|
212
|
+
break;
|
|
213
|
+
case "denied":
|
|
214
|
+
throw refused();
|
|
215
|
+
case "expired":
|
|
216
|
+
throw ranOut();
|
|
217
|
+
default:
|
|
218
|
+
return assertNever(outcome, "Unhandled sign-in answer");
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
throw unreached ?? ranOut();
|
|
223
|
+
} finally {
|
|
224
|
+
// Every exit clears the spinner, so none is left above an error.
|
|
225
|
+
stop();
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const login = async (
|
|
230
|
+
args: readonly string[],
|
|
231
|
+
terminal: Terminal,
|
|
232
|
+
env: Record<string, string | undefined>,
|
|
233
|
+
): Promise<void> => {
|
|
234
|
+
const { values, positionals } = parseOptions(args, {
|
|
235
|
+
api: { type: "string" },
|
|
236
|
+
"no-browser": { type: "boolean" },
|
|
237
|
+
force: { type: "boolean" },
|
|
238
|
+
});
|
|
239
|
+
if (positionals.length > 0)
|
|
240
|
+
throw usageError(
|
|
241
|
+
`login takes no positional arguments, got ${positionals.join(" ")}`,
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
const api = readApi(values.api, env);
|
|
245
|
+
|
|
246
|
+
// Only an active session skips the flow; an unreachable store falls
|
|
247
|
+
// through so the flow below reports the outage.
|
|
248
|
+
if (!readBoolean(values.force)) {
|
|
249
|
+
const stored = await readToken(env, api);
|
|
250
|
+
const session = stored === undefined ? undefined : await checkSession(api, stored);
|
|
251
|
+
|
|
252
|
+
if (session?.type === "active") {
|
|
253
|
+
terminal.out(`✓ Already signed in to ${hostOf(api)} as ${session.email}`);
|
|
254
|
+
terminal.out(" pr-lens auth login --force signs in again, as somebody else or on a new token.");
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// The id mints already carry, so approval links the machine that pushed.
|
|
260
|
+
const installId = await readInstallId(env, api);
|
|
261
|
+
if (installId === undefined)
|
|
262
|
+
throw new PrLensCliError(
|
|
263
|
+
"UNREADABLE_FILE",
|
|
264
|
+
"this machine has nowhere to keep a sign-in",
|
|
265
|
+
"set HOME, or XDG_CONFIG_HOME, to a directory this machine can write to",
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const started = await startSignIn(api, installId, CLIENT, hostname());
|
|
269
|
+
const opening = readBoolean(values["no-browser"])
|
|
270
|
+
? false
|
|
271
|
+
: askToOpen(started.approveUrl);
|
|
272
|
+
tellHowToApprove(started, opening, terminal);
|
|
273
|
+
|
|
274
|
+
const { token, claimed } = await waitForApproval(api, started, terminal);
|
|
275
|
+
await writeCredential(env, api, token, new Date().toISOString());
|
|
276
|
+
|
|
277
|
+
// After the write, so a failed greeting never looks like a failed sign-in.
|
|
278
|
+
const who = await whoAmI(api, token);
|
|
279
|
+
terminal.out(
|
|
280
|
+
who === undefined ? `✓ Signed in to ${hostOf(api)}` : `✓ Signed in to ${hostOf(api)} as ${who}`,
|
|
281
|
+
);
|
|
282
|
+
// Absent and zero stay apart: "0 canvases" is worth saying, "unknown" is not.
|
|
283
|
+
terminal.out(
|
|
284
|
+
claimed === undefined
|
|
285
|
+
? " This machine is linked, so every canvas it has pushed is yours, and so is every one it pushes next."
|
|
286
|
+
: ` This machine is linked. ${claimed === 1 ? "1 canvas is" : `${claimed} canvases are`} now yours, and so is every one it pushes next.`,
|
|
287
|
+
);
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
type State =
|
|
291
|
+
| "signed_in"
|
|
292
|
+
| "unconfirmed"
|
|
293
|
+
| "rejected"
|
|
294
|
+
| "signed_out"
|
|
295
|
+
| "unreadable";
|
|
296
|
+
|
|
297
|
+
type Status = {
|
|
298
|
+
api: string;
|
|
299
|
+
state: State;
|
|
300
|
+
/** Never the credential itself. */
|
|
301
|
+
source: "file" | "environment" | "none";
|
|
302
|
+
signedInAt: string | undefined;
|
|
303
|
+
machine: MachineState;
|
|
304
|
+
why: string | undefined;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const sourceOf = (
|
|
308
|
+
stored: CredentialRead,
|
|
309
|
+
fromEnvironment: boolean,
|
|
310
|
+
): Status["source"] =>
|
|
311
|
+
fromEnvironment
|
|
312
|
+
? "environment"
|
|
313
|
+
: stored.type === "credential"
|
|
314
|
+
? "file"
|
|
315
|
+
: "none";
|
|
316
|
+
|
|
317
|
+
const statusOf = async (
|
|
318
|
+
api: string,
|
|
319
|
+
env: Record<string, string | undefined>,
|
|
320
|
+
): Promise<Status> => {
|
|
321
|
+
const given = env[TOKEN_ENV];
|
|
322
|
+
const stored = await readCredential(env, api);
|
|
323
|
+
const token = given || (stored.type === "credential" ? stored.credential.token : undefined);
|
|
324
|
+
|
|
325
|
+
const base: Omit<Status, "state" | "why"> = {
|
|
326
|
+
api,
|
|
327
|
+
source: sourceOf(stored, Boolean(given)),
|
|
328
|
+
signedInAt:
|
|
329
|
+
stored.type === "credential" && !given ? stored.credential.signedInAt : undefined,
|
|
330
|
+
machine: "unknown",
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
if (token === undefined)
|
|
334
|
+
return stored.type === "unreadable"
|
|
335
|
+
? { ...base, state: "unreadable", why: `${stored.path}: ${stored.why}` }
|
|
336
|
+
: { ...base, state: "signed_out", why: undefined };
|
|
337
|
+
|
|
338
|
+
// Read, never minted: a status check must not create a machine identity.
|
|
339
|
+
const checked = await checkSignIn(api, token, await readStoredInstallId(env, api));
|
|
340
|
+
switch (checked.type) {
|
|
341
|
+
case "live":
|
|
342
|
+
return { ...base, state: "signed_in", machine: checked.machine, why: undefined };
|
|
343
|
+
case "rejected":
|
|
344
|
+
return { ...base, state: "rejected", why: undefined };
|
|
345
|
+
case "unknown":
|
|
346
|
+
return { ...base, state: "unconfirmed", why: checked.why };
|
|
347
|
+
default:
|
|
348
|
+
return assertNever(checked, "Unhandled sign-in check");
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const tellMachine = (machine: MachineState, terminal: Terminal): void => {
|
|
353
|
+
switch (machine) {
|
|
354
|
+
case "linked":
|
|
355
|
+
terminal.out(" This machine is linked.");
|
|
356
|
+
return;
|
|
357
|
+
case "unlinked":
|
|
358
|
+
terminal.out(
|
|
359
|
+
" This machine is not linked to that account, so what it pushes stays unattributed.",
|
|
360
|
+
);
|
|
361
|
+
return;
|
|
362
|
+
case "revoked":
|
|
363
|
+
case "unknown":
|
|
364
|
+
return;
|
|
365
|
+
default:
|
|
366
|
+
return assertNever(machine, "Unhandled machine state");
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const tellSource = (status: Status, terminal: Terminal): void => {
|
|
371
|
+
switch (status.source) {
|
|
372
|
+
case "environment":
|
|
373
|
+
terminal.out(` Signed in with $${TOKEN_ENV}.`);
|
|
374
|
+
return;
|
|
375
|
+
case "file":
|
|
376
|
+
terminal.out(
|
|
377
|
+
status.signedInAt === undefined
|
|
378
|
+
? " Signed in on this machine."
|
|
379
|
+
: ` Signed in on this machine on ${status.signedInAt.slice(0, 10)}.`,
|
|
380
|
+
);
|
|
381
|
+
return;
|
|
382
|
+
case "none":
|
|
383
|
+
return;
|
|
384
|
+
default:
|
|
385
|
+
return assertNever(status.source, "Unhandled credential source");
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
/** Separate from printing so `--json` still exits on the verdict. */
|
|
390
|
+
const refusalFor = (status: Status): PrLensCliError | undefined => {
|
|
391
|
+
const host = hostOf(status.api);
|
|
392
|
+
|
|
393
|
+
switch (status.state) {
|
|
394
|
+
case "signed_in":
|
|
395
|
+
// A CI token still works on a removed machine, but nothing it pushes is attributed.
|
|
396
|
+
return status.machine === "revoked"
|
|
397
|
+
? new PrLensCliError(
|
|
398
|
+
"MACHINE_REVOKED",
|
|
399
|
+
`this machine was removed from the account on ${host}`,
|
|
400
|
+
"the sign-in still works, but nothing this machine pushes is attributed; pr-lens auth login --force links it again",
|
|
401
|
+
)
|
|
402
|
+
: undefined;
|
|
403
|
+
|
|
404
|
+
case "unconfirmed":
|
|
405
|
+
return undefined;
|
|
406
|
+
|
|
407
|
+
case "rejected":
|
|
408
|
+
return new PrLensCliError(
|
|
409
|
+
"AUTH_REQUIRED",
|
|
410
|
+
`${host} no longer accepts this sign-in`,
|
|
411
|
+
"it was signed out, or the machine was removed; run pr-lens auth login to sign in again",
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
case "signed_out":
|
|
415
|
+
return new PrLensCliError(
|
|
416
|
+
"AUTH_REQUIRED",
|
|
417
|
+
`not signed in to ${host}`,
|
|
418
|
+
"pr-lens auth login",
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
case "unreadable":
|
|
422
|
+
return new PrLensCliError(
|
|
423
|
+
"UNREADABLE_FILE",
|
|
424
|
+
`the sign-in for ${host} cannot be read`,
|
|
425
|
+
[status.why ?? "", "pr-lens auth logout clears it, and then auth login writes a fresh one"]
|
|
426
|
+
.filter((line) => line !== "")
|
|
427
|
+
.join("\n"),
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
default:
|
|
431
|
+
return assertNever(status.state, "Unhandled sign-in state");
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
const tellStatus = (status: Status, terminal: Terminal): void => {
|
|
436
|
+
const host = hostOf(status.api);
|
|
437
|
+
|
|
438
|
+
switch (status.state) {
|
|
439
|
+
case "signed_in":
|
|
440
|
+
terminal.out(`✓ Signed in to ${host}`);
|
|
441
|
+
tellMachine(status.machine, terminal);
|
|
442
|
+
tellSource(status, terminal);
|
|
443
|
+
return;
|
|
444
|
+
|
|
445
|
+
case "unconfirmed":
|
|
446
|
+
terminal.out(`✓ A sign-in for ${host} is kept on this machine`);
|
|
447
|
+
tellSource(status, terminal);
|
|
448
|
+
terminal.out(` ${host} could not be asked to confirm it: ${status.why ?? "no reason given"}`);
|
|
449
|
+
return;
|
|
450
|
+
|
|
451
|
+
// `refusalFor` reports these.
|
|
452
|
+
case "rejected":
|
|
453
|
+
case "signed_out":
|
|
454
|
+
case "unreadable":
|
|
455
|
+
return;
|
|
456
|
+
|
|
457
|
+
default:
|
|
458
|
+
return assertNever(status.state, "Unhandled sign-in state");
|
|
459
|
+
}
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
const status = async (
|
|
463
|
+
args: readonly string[],
|
|
464
|
+
terminal: Terminal,
|
|
465
|
+
env: Record<string, string | undefined>,
|
|
466
|
+
): Promise<void> => {
|
|
467
|
+
const { values, positionals } = parseOptions(args, {
|
|
468
|
+
api: { type: "string" },
|
|
469
|
+
json: { type: "boolean" },
|
|
470
|
+
});
|
|
471
|
+
if (positionals.length > 0)
|
|
472
|
+
throw usageError(
|
|
473
|
+
`status takes no positional arguments, got ${positionals.join(" ")}`,
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
const found = await statusOf(readApi(values.api, env), env);
|
|
477
|
+
|
|
478
|
+
const refusal = refusalFor(found);
|
|
479
|
+
|
|
480
|
+
// Print first, so `--json` answers in every state and the exit code still carries the verdict.
|
|
481
|
+
if (readBoolean(values.json))
|
|
482
|
+
terminal.out(
|
|
483
|
+
JSON.stringify(
|
|
484
|
+
{
|
|
485
|
+
api: found.api,
|
|
486
|
+
state: found.state,
|
|
487
|
+
source: found.source,
|
|
488
|
+
signedInAt: found.signedInAt ?? null,
|
|
489
|
+
machine: found.machine,
|
|
490
|
+
},
|
|
491
|
+
null,
|
|
492
|
+
2,
|
|
493
|
+
),
|
|
494
|
+
);
|
|
495
|
+
else tellStatus(found, terminal);
|
|
496
|
+
|
|
497
|
+
if (refusal !== undefined) throw refusal;
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
const logout = async (
|
|
501
|
+
args: readonly string[],
|
|
502
|
+
terminal: Terminal,
|
|
503
|
+
env: Record<string, string | undefined>,
|
|
504
|
+
): Promise<void> => {
|
|
505
|
+
const { values, positionals } = parseOptions(args, { api: { type: "string" } });
|
|
506
|
+
if (positionals.length > 0)
|
|
507
|
+
throw usageError(
|
|
508
|
+
`logout takes no positional arguments, got ${positionals.join(" ")}`,
|
|
509
|
+
);
|
|
510
|
+
|
|
511
|
+
const api = readApi(values.api, env);
|
|
512
|
+
|
|
513
|
+
// Forgotten locally whatever the app answers; an unreachable store must
|
|
514
|
+
// not keep somebody signed in.
|
|
515
|
+
const stored = await readCredential(env, api);
|
|
516
|
+
const ended =
|
|
517
|
+
stored.type === "credential" ? await endSession(api, stored.credential.token) : undefined;
|
|
518
|
+
|
|
519
|
+
const forgotten = await forgetCredential(env, api);
|
|
520
|
+
|
|
521
|
+
const host = hostOf(api);
|
|
522
|
+
if (!forgotten) {
|
|
523
|
+
terminal.out(`Nothing was signed in to ${host}`);
|
|
524
|
+
if (env[TOKEN_ENV])
|
|
525
|
+
terminal.out(` $${TOKEN_ENV} is set, and this machine still signs in with it.`);
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
terminal.out(`✓ Signed out of ${host}`);
|
|
530
|
+
if (ended !== undefined && typeof ended !== "string")
|
|
531
|
+
terminal.out(
|
|
532
|
+
` ${host} did not end the session, so the token is forgotten here but stays live there until it is used: ${ended.why}`,
|
|
533
|
+
);
|
|
534
|
+
terminal.out(
|
|
535
|
+
" The machine stays linked, so the canvases it pushed stay yours. Remove it in the app's settings to cut it off.",
|
|
536
|
+
);
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
export const authCommand = async (
|
|
540
|
+
args: readonly string[],
|
|
541
|
+
terminal: Terminal,
|
|
542
|
+
env: Record<string, string | undefined>,
|
|
543
|
+
): Promise<void> => {
|
|
544
|
+
const [name, ...rest] = args;
|
|
545
|
+
if (name === undefined)
|
|
546
|
+
throw usageError("auth needs a subcommand: login, status or logout");
|
|
547
|
+
if (!isSubcommand(name))
|
|
548
|
+
throw usageError(`unknown auth subcommand ${JSON.stringify(name)}`);
|
|
549
|
+
|
|
550
|
+
switch (name) {
|
|
551
|
+
case "login":
|
|
552
|
+
return login(rest, terminal, env);
|
|
553
|
+
case "status":
|
|
554
|
+
return status(rest, terminal, env);
|
|
555
|
+
case "logout":
|
|
556
|
+
return logout(rest, terminal, env);
|
|
557
|
+
default:
|
|
558
|
+
return assertNever(name, "Unhandled auth subcommand");
|
|
559
|
+
}
|
|
560
|
+
};
|