@lizard-build/cli 4.0.34 → 4.0.36

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.
@@ -0,0 +1,320 @@
1
+ import chalk from "chalk";
2
+ import * as p from "@clack/prompts";
3
+ import { api, APIError, clientHeaders, getBaseURL } from "./api.js";
4
+ import {
5
+ clearCredentials,
6
+ clearPendingAuth,
7
+ envToken,
8
+ loadPendingAuth,
9
+ openURL,
10
+ savePendingAuth,
11
+ saveSessionLogin,
12
+ validToken,
13
+ } from "./auth.js";
14
+
15
+ // Browser sign-in, shared by `lizard login` and the auth gate every other
16
+ // command passes (requireAuth).
17
+ //
18
+ // A person at a terminal picks GitHub or Google, the browser opens at that
19
+ // provider, and the CLI waits for the approval, then carries on. A coding
20
+ // agent (no terminal, or --json) gets the links and an instruction instead:
21
+ // no browser, no waiting. The session stays on disk, and the next run picks
22
+ // it up once the user has approved it.
23
+
24
+ /** The sign-in methods the /auth/cli page can send a person to. */
25
+ export type AuthProvider = "github" | "google";
26
+ export const AUTH_PROVIDERS: AuthProvider[] = ["github", "google"];
27
+ const PROVIDER_LABEL: Record<AuthProvider, string> = { github: "GitHub", google: "Google" };
28
+
29
+ /** How long the platform keeps a CLI session, for sessions saved without `expiresAt`. */
30
+ const SESSION_TTL_MS = 300_000;
31
+ /** The platform allows 60 polls per session over its 5 minutes: one every 5 s. */
32
+ const POLL_MS = 5_000;
33
+
34
+ interface SessionResponse {
35
+ sessionId: string;
36
+ sessionSecret: string;
37
+ expiresIn: number;
38
+ }
39
+
40
+ export interface CheckResponse {
41
+ status: "pending" | "complete" | "expired";
42
+ accessToken?: string;
43
+ user?: {
44
+ id: string;
45
+ username: string;
46
+ email?: string;
47
+ avatarUrl?: string;
48
+ };
49
+ }
50
+
51
+ export interface Me {
52
+ id: string;
53
+ username: string;
54
+ email?: string | null;
55
+ scoped?: boolean;
56
+ }
57
+
58
+ export interface Pending {
59
+ sessionId: string;
60
+ sessionSecret: string;
61
+ expiresAt: number;
62
+ }
63
+
64
+ export type Resumed =
65
+ | { kind: "none" }
66
+ | { kind: "complete" }
67
+ | { kind: "pending"; pending: Pending }
68
+ | { kind: "expired" };
69
+
70
+ /**
71
+ * The page that approves a CLI session. With a provider it signs a signed-out
72
+ * browser in with that method; without one it uses GitHub, as it always has.
73
+ */
74
+ export function authUrlFor(sessionId: string, provider?: AuthProvider): string {
75
+ const url = `${getBaseURL()}/auth/cli?session=${sessionId}`;
76
+ return provider ? `${url}&provider=${provider}` : url;
77
+ }
78
+
79
+ /** Which sign-in methods the platform offers. Both, if it cannot be asked. */
80
+ export async function fetchAuthProviders(): Promise<AuthProvider[]> {
81
+ try {
82
+ const res = await fetch(`${getBaseURL()}/api/auth/providers`, { headers: clientHeaders() });
83
+ if (!res.ok) return AUTH_PROVIDERS;
84
+ const body = (await res.json()) as Partial<Record<AuthProvider, boolean>>;
85
+ const on = AUTH_PROVIDERS.filter((m) => body[m]);
86
+ return on.length ? on : AUTH_PROVIDERS;
87
+ } catch {
88
+ return AUTH_PROVIDERS;
89
+ }
90
+ }
91
+
92
+ /** `--github` / `--google`, or undefined when neither is given. */
93
+ export function providerFlag(opts: { github?: boolean; google?: boolean }): AuthProvider | undefined {
94
+ if (opts.github && opts.google) throw new Error("Pass --github or --google, not both.");
95
+ return opts.google ? "google" : opts.github ? "github" : undefined;
96
+ }
97
+
98
+ /** Create a CLI login session on the server */
99
+ export async function createSession(): Promise<SessionResponse> {
100
+ const res = await fetch(`${getBaseURL()}/api/auth/cli/session`, {
101
+ method: "POST",
102
+ headers: { ...clientHeaders(), "Content-Type": "application/json" },
103
+ });
104
+ if (!res.ok) throw new Error(`Failed to create login session: ${res.statusText}`);
105
+ return res.json() as Promise<SessionResponse>;
106
+ }
107
+
108
+ /** Check once if the user has completed authentication (no polling loop) */
109
+ export async function checkSession(sessionId: string, sessionSecret: string): Promise<CheckResponse> {
110
+ const res = await fetch(`${getBaseURL()}/api/auth/cli/poll`, {
111
+ method: "POST",
112
+ // The platform records the login from this request; its User-Agent is
113
+ // what files it under the CLI and the agent running it.
114
+ headers: { ...clientHeaders(), "Content-Type": "application/json" },
115
+ body: JSON.stringify({ sessionId, sessionSecret }),
116
+ });
117
+ if (!res.ok) throw new Error(`Auth check failed: ${res.statusText}`);
118
+ return res.json() as Promise<CheckResponse>;
119
+ }
120
+
121
+ /** The signed-in user, or null when this machine needs a sign-in. */
122
+ export async function currentUser(): Promise<Me | null> {
123
+ if (!validToken()) return null;
124
+ try {
125
+ return await api.get<Me>("/api/auth/me");
126
+ } catch (err) {
127
+ // A saved token the platform no longer accepts is as good as none. A token
128
+ // from the environment is the caller's to fix, so that error stands.
129
+ if (err instanceof APIError && err.status === 401 && !envToken()) {
130
+ clearCredentials();
131
+ return null;
132
+ }
133
+ throw err;
134
+ }
135
+ }
136
+
137
+ /** Finish a session an earlier run left on disk, if the browser approved it. */
138
+ export async function resumePending(): Promise<Resumed> {
139
+ const saved = loadPendingAuth();
140
+ if (!saved) return { kind: "none" };
141
+ const pending: Pending = {
142
+ sessionId: saved.sessionId,
143
+ sessionSecret: saved.sessionSecret,
144
+ expiresAt: saved.expiresAt ?? saved.createdAt + SESSION_TTL_MS,
145
+ };
146
+ let result: CheckResponse;
147
+ try {
148
+ result = await checkSession(pending.sessionId, pending.sessionSecret);
149
+ } catch {
150
+ // A dropped request or the poll limit says nothing about the session.
151
+ if (Date.now() < pending.expiresAt) return { kind: "pending", pending };
152
+ clearPendingAuth();
153
+ return { kind: "expired" };
154
+ }
155
+ if (result.status === "complete" && result.accessToken && result.user) {
156
+ saveSessionLogin({ accessToken: result.accessToken, user: result.user });
157
+ return { kind: "complete" };
158
+ }
159
+ if (result.status === "expired") {
160
+ clearPendingAuth();
161
+ return { kind: "expired" };
162
+ }
163
+ return { kind: "pending", pending };
164
+ }
165
+
166
+ export async function startSession(provider?: AuthProvider): Promise<Pending> {
167
+ const session = await createSession();
168
+ const now = Date.now();
169
+ const pending: Pending = {
170
+ sessionId: session.sessionId,
171
+ sessionSecret: session.sessionSecret,
172
+ expiresAt: now + session.expiresIn * 1000,
173
+ };
174
+ savePendingAuth({ ...pending, authUrl: authUrlFor(session.sessionId, provider), createdAt: now });
175
+ return pending;
176
+ }
177
+
178
+ /** Poll until the browser approves the session. Null if it expires first. */
179
+ async function waitForApproval(pending: Pending): Promise<CheckResponse | null> {
180
+ while (Date.now() < pending.expiresAt) {
181
+ await new Promise((resolve) => setTimeout(resolve, POLL_MS));
182
+ try {
183
+ const result = await checkSession(pending.sessionId, pending.sessionSecret);
184
+ if (result.status === "complete") return result;
185
+ if (result.status === "expired") return null;
186
+ } catch {
187
+ // A dropped request or the poll limit: try again on the next tick.
188
+ }
189
+ }
190
+ return null;
191
+ }
192
+
193
+ // ── For agents ──────────────────────────────────────────────────────────
194
+
195
+ /**
196
+ * What an agent needs to get the user signed in: the links and what to do
197
+ * with them. `status` and `authUrl` are what `lizard login --json` has always
198
+ * printed; `authUrl` without a chosen method is the old link, which opens
199
+ * GitHub.
200
+ */
201
+ export function pendingPayload(
202
+ pending: Pending,
203
+ methods: AuthProvider[],
204
+ why: "new" | "waiting" | "expired",
205
+ opts: { flag?: AuthProvider; nextCommand?: string } = {},
206
+ now = Date.now(),
207
+ ) {
208
+ const authUrls = Object.fromEntries(methods.map((m) => [m, authUrlFor(pending.sessionId, m)])) as Partial<
209
+ Record<AuthProvider, string>
210
+ >;
211
+ const single = methods.length === 1 ? methods[0] : undefined;
212
+ const minutes = Math.max(1, Math.round((pending.expiresAt - now) / 60_000));
213
+ const window = minutes === 1 ? "1 more minute" : `${minutes} more minutes`;
214
+ const then = opts.nextCommand ? "run nextCommand" : "run the same command again";
215
+ const lead =
216
+ why === "waiting"
217
+ ? "The user has not finished signing in yet. "
218
+ : why === "expired"
219
+ ? "The last sign-in link expired; this is a new one. "
220
+ : "";
221
+ const instruction = single
222
+ ? `${lead}Sign-in is needed. Give the user authUrl to open. Once the browser says "CLI authorized", ${then}. ` +
223
+ `The link works for ${window}; after that a new one is printed.`
224
+ : `${lead}Sign-in is needed. Ask the user how they sign in to Lizard and give them that link from authUrls. ` +
225
+ "Someone who already has an account must use the same method as before: another method opens a second, empty account " +
226
+ "unless both share a verified email. A new user can pick either. " +
227
+ `Once the browser says "CLI authorized", ${then}. The links work for ${window}; after that new ones are printed.`;
228
+ return {
229
+ status: "pending" as const,
230
+ authUrl: single ? authUrls[single]! : authUrlFor(pending.sessionId, opts.flag),
231
+ authUrls,
232
+ expiresAt: new Date(pending.expiresAt).toISOString(),
233
+ ...(opts.nextCommand ? { nextCommand: opts.nextCommand } : {}),
234
+ instruction,
235
+ };
236
+ }
237
+
238
+ /**
239
+ * The error a command fails with when nobody is signed in and nobody can be
240
+ * asked. Its body carries the sign-in links, so an agent can hand them to the
241
+ * user and run the command again, without a separate `lizard login`.
242
+ */
243
+ export async function loginRequiredError(resumed: Resumed, hadLogin: boolean): Promise<APIError> {
244
+ const base = hadLogin ? "The Lizard sign-in on this machine expired." : "Not signed in to Lizard.";
245
+ const plain = new APIError(
246
+ 401,
247
+ `${base} Run \`lizard login\`, or set LIZARD_TOKEN (or LIZARD_API_KEY).`,
248
+ "NOT_AUTHENTICATED",
249
+ );
250
+ // CI has nobody to open a link: say what to set and stop there.
251
+ if (process.env.CI) return plain;
252
+ try {
253
+ const pending = resumed.kind === "pending" ? resumed.pending : await startSession();
254
+ const why = resumed.kind === "pending" ? "waiting" : resumed.kind === "expired" ? "expired" : "new";
255
+ const login = pendingPayload(pending, await fetchAuthProviders(), why);
256
+ return new APIError(401, `${base} ${login.instruction}`, "NOT_AUTHENTICATED", { login });
257
+ } catch {
258
+ return plain;
259
+ }
260
+ }
261
+
262
+ // ── For people ──────────────────────────────────────────────────────────
263
+
264
+ function cancelled(): never {
265
+ p.cancel("Cancelled.");
266
+ process.exit(5);
267
+ }
268
+
269
+ async function askProvider(): Promise<AuthProvider> {
270
+ const methods = await fetchAuthProviders();
271
+ if (methods.length === 1) return methods[0];
272
+ p.log.message(chalk.dim("Already have an account? Pick the method you signed up with."));
273
+ const choice = await p.select({
274
+ message: "How do you sign in to Lizard?",
275
+ options: methods.map((m) => ({ value: m, label: PROVIDER_LABEL[m] })),
276
+ });
277
+ if (p.isCancel(choice)) cancelled();
278
+ return choice as AuthProvider;
279
+ }
280
+
281
+ /**
282
+ * Sign in at a terminal: ask the method (when stdin can answer), open the
283
+ * browser there, wait for the approval, save the login. Throws if the link
284
+ * expires first.
285
+ */
286
+ export async function signInInteractive(flag?: AuthProvider): Promise<Me> {
287
+ const provider = flag ?? (process.stdin.isTTY ? await askProvider() : undefined);
288
+ const pending = await startSession(provider);
289
+ if (provider) {
290
+ const url = authUrlFor(pending.sessionId, provider);
291
+ const opened = await openURL(url);
292
+ p.log.info(`${opened ? "Opened your browser. If it did not open, use this link" : "Open this link to sign in"}:\n${chalk.cyan(url)}`);
293
+ } else {
294
+ const methods = await fetchAuthProviders();
295
+ p.log.info(
296
+ ["Open one of these links to sign in:", ...methods.map((m) => `${PROVIDER_LABEL[m]}: ${chalk.cyan(authUrlFor(pending.sessionId, m))}`)].join("\n"),
297
+ );
298
+ }
299
+
300
+ // Ctrl+C at a terminal never reaches onCancel: clack holds stdin raw while it
301
+ // spins and exits on the keypress itself, printing cancelMessage on the way
302
+ // out. So the message carries the hint, and the session stays on disk for
303
+ // whichever lizard command runs next.
304
+ const spin = p.spinner({
305
+ cancelMessage: "Stopped waiting. The link works for a few more minutes: finish in the browser, then run the command again",
306
+ onCancel: () => process.exit(5),
307
+ });
308
+ spin.start("Waiting for you to authorize Lizard CLI in the browser");
309
+ const result = await waitForApproval(pending);
310
+ if (!result?.accessToken || !result.user) {
311
+ spin.error("The sign-in link expired");
312
+ clearPendingAuth();
313
+ throw new Error("The sign-in link expired. Run the command again for a new one.");
314
+ }
315
+ saveSessionLogin({ accessToken: result.accessToken, user: result.user });
316
+ spin.stop("Authorized");
317
+ const me = await api.get<Me>("/api/auth/me");
318
+ p.log.success(`Signed in as ${chalk.bold(me.username)}`);
319
+ return me;
320
+ }
@@ -5,7 +5,7 @@ import { join, dirname } from "node:path";
5
5
  import os from "node:os";
6
6
  import { spawn } from "node:child_process";
7
7
 
8
- export const CURRENT_VERSION = "4.0.34";
8
+ export const CURRENT_VERSION = "4.0.36";
9
9
  const RELEASES_API = "https://api.github.com/repos/lizard-build/lizard-cli/releases/latest";
10
10
  const RELEASE_BASE = "https://github.com/lizard-build/lizard-cli/releases/latest/download";
11
11
 
@@ -44,6 +44,9 @@ function run(args: string[], extra: { cwd?: string; env?: Record<string, string>
44
44
  // Belt-and-suspenders: even if a leaked LIZARD_TOKEN sits in the
45
45
  // parent env, drop it so unauth tests can't pass by accident.
46
46
  LIZARD_TOKEN: "",
47
+ // Signed-out commands start a browser sign-in session for the links in
48
+ // their error. Point them at a closed port so the suite stays offline.
49
+ LIZARD_API_URL: "http://127.0.0.1:9",
47
50
  ...(extra.env ?? {}),
48
51
  },
49
52
  });