@lizard-build/cli 4.0.34 → 4.0.35
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 +5 -0
- package/dist/commands/billing.d.ts +1 -0
- package/dist/commands/billing.js +1 -1
- package/dist/commands/billing.js.map +1 -1
- package/dist/commands/login.d.ts +17 -26
- package/dist/commands/login.js +207 -69
- package/dist/commands/login.js.map +1 -1
- package/dist/lib/auth.d.ts +19 -8
- package/dist/lib/auth.js +38 -67
- package/dist/lib/auth.js.map +1 -1
- package/dist/lib/config.d.ts +2 -0
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/signin.d.ts +91 -0
- package/dist/lib/signin.js +249 -0
- package/dist/lib/signin.js.map +1 -0
- package/dist/lib/skills-data.generated.js +1 -1
- package/dist/lib/skills-data.generated.js.map +1 -1
- package/dist/lib/updater.d.ts +1 -1
- package/dist/lib/updater.js +1 -1
- package/install.cmd +15 -2
- package/install.ps1 +14 -2
- package/install.sh +22 -2
- package/package.json +1 -1
- package/skill-data/core/SKILL.md +11 -2
- package/src/commands/billing.ts +1 -1
- package/src/commands/login.ts +247 -92
- package/src/lib/auth.ts +41 -71
- package/src/lib/config.ts +2 -0
- package/src/lib/signin.ts +320 -0
- package/src/lib/updater.ts +1 -1
- package/test/unit/json.test.ts +3 -0
- package/test/unit/login.test.ts +398 -0
package/src/commands/login.ts
CHANGED
|
@@ -1,121 +1,276 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
|
+
import * as p from "@clack/prompts";
|
|
2
3
|
import { Command } from "commander";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
openURL,
|
|
7
|
-
jwtExpiryMs,
|
|
8
|
-
type Credentials,
|
|
9
|
-
} from "../lib/auth.js";
|
|
10
|
-
import { getBaseURL, clientHeaders } from "../lib/api.js";
|
|
4
|
+
import { saveCredentials, jwtExpiryMs, envToken, openURL } from "../lib/auth.js";
|
|
5
|
+
import { api, getBaseURL } from "../lib/api.js";
|
|
6
|
+
import { getProjectLink } from "../lib/config.js";
|
|
11
7
|
import { success, isJSONMode, printJSON } from "../lib/format.js";
|
|
8
|
+
import {
|
|
9
|
+
currentUser,
|
|
10
|
+
fetchAuthProviders,
|
|
11
|
+
pendingPayload,
|
|
12
|
+
providerFlag,
|
|
13
|
+
resumePending,
|
|
14
|
+
signInInteractive,
|
|
15
|
+
startSession,
|
|
16
|
+
type AuthProvider,
|
|
17
|
+
type Me,
|
|
18
|
+
} from "../lib/signin.js";
|
|
19
|
+
import { CREDITS_END, billingUrl, daysLeft, describeSubscription, usd, type Subscription } from "./billing.js";
|
|
20
|
+
|
|
21
|
+
// `lizard login` signs the machine in, then does only what the account still
|
|
22
|
+
// needs: an account with no plan is offered Pro. The install scripts run it as
|
|
23
|
+
// their last step, and it is safe to re-run: a signed-in machine skips to the
|
|
24
|
+
// plan. Every other command signs in through requireAuth instead, without the
|
|
25
|
+
// plan step, and carries on.
|
|
26
|
+
//
|
|
27
|
+
// At a terminal it asks GitHub or Google, opens the browser and waits. For a
|
|
28
|
+
// coding agent (no terminal, or --json) it prints one JSON object: the links
|
|
29
|
+
// and an instruction while sign-in is pending, the account and its plan once
|
|
30
|
+
// it is done. It never waits there; the next run picks up the sign-in.
|
|
12
31
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
32
|
+
export type AccountState =
|
|
33
|
+
| "unknown"
|
|
34
|
+
| "not_owner"
|
|
35
|
+
| "enterprise"
|
|
36
|
+
| "credits"
|
|
37
|
+
| "past_due"
|
|
38
|
+
| "trialing"
|
|
39
|
+
| "active"
|
|
40
|
+
| "trial_available"
|
|
41
|
+
| "subscription_required"
|
|
42
|
+
| "checkout_unavailable";
|
|
43
|
+
|
|
44
|
+
/** What the account still needs, from `GET /api/billing/subscription` (null when it could not be read). */
|
|
45
|
+
export function accountState(sub: Subscription | null): AccountState {
|
|
46
|
+
if (!sub) return "unknown";
|
|
47
|
+
if (sub.isOwner === false) return "not_owner";
|
|
48
|
+
if (sub.plan === "enterprise") return "enterprise";
|
|
49
|
+
if (sub.plan === "payg") return "credits";
|
|
50
|
+
if (sub.plan === "pro") {
|
|
51
|
+
if (sub.pastDue || sub.status === "past_due") return "past_due";
|
|
52
|
+
return sub.status === "trialing" ? "trialing" : "active";
|
|
53
|
+
}
|
|
54
|
+
if (!sub.checkoutAvailable) return "checkout_unavailable";
|
|
55
|
+
return sub.trial.eligible && sub.trial.days ? "trial_available" : "subscription_required";
|
|
17
56
|
}
|
|
18
57
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
58
|
+
/** What an agent should do about the plan. Spells out the don'ts, since an agent takes a link as a hint to open it. */
|
|
59
|
+
export function accountInstruction(state: AccountState, sub: Subscription | null, now = Date.now()): string {
|
|
60
|
+
const price = usd(sub?.priceCents ?? 1900);
|
|
61
|
+
const start =
|
|
62
|
+
"Ask before starting it. If they agree, run `lizard billing start --json` and give them the url it prints to finish in Stripe Checkout.";
|
|
63
|
+
switch (state) {
|
|
64
|
+
case "unknown":
|
|
65
|
+
return "Could not read this account's plan, so nothing about it is known. Do NOT start billing. The user is signed in: carry on with their task.";
|
|
66
|
+
case "not_owner":
|
|
67
|
+
return (
|
|
68
|
+
"This account deploys on its workspace owner's plan. Do NOT start or change a subscription: only the owner can." +
|
|
69
|
+
(sub?.plan === "none" ? " Deploys stay blocked until the owner starts Pro." : "")
|
|
70
|
+
);
|
|
71
|
+
case "enterprise":
|
|
72
|
+
return "Enterprise plan, invoiced monthly. Nothing to set up.";
|
|
73
|
+
case "credits":
|
|
74
|
+
return `This account runs on prepaid credits, which end on ${CREDITS_END}. Deploys work until then. Tell the user that Pro replaces them (\`lizard billing start\`), but do not run it unless they ask.`;
|
|
75
|
+
case "past_due":
|
|
76
|
+
return "A payment failed, so new resources are blocked. Send the user to payUrl to pay the open invoice. Do NOT start a second subscription.";
|
|
77
|
+
case "trialing": {
|
|
78
|
+
const ends = sub?.trial.endsAt;
|
|
79
|
+
const left = sub?.trial.remainingCents;
|
|
80
|
+
const parts = [ends ? daysLeft(ends, now) : null, left != null ? `${usd(left)} of trial credits left` : null].filter(Boolean);
|
|
81
|
+
return `The Pro trial is on${parts.length ? ` (${parts.join(", ")})` : ""}. Do NOT open billing or start a subscription.`;
|
|
82
|
+
}
|
|
83
|
+
case "active":
|
|
84
|
+
return "Pro is active. Do NOT open billing or start a subscription.";
|
|
85
|
+
case "trial_available":
|
|
86
|
+
return (
|
|
87
|
+
`No plan yet, and deploys need one. Offer the user the ${sub?.trial.days}-day Pro trial with ${usd(sub?.trial.creditCents ?? 0)} in credits: ` +
|
|
88
|
+
`no charge today, then ${price}/month, taxes included. ${start}`
|
|
89
|
+
);
|
|
90
|
+
case "subscription_required":
|
|
91
|
+
return (
|
|
92
|
+
"No plan yet, and deploys need one. This account has had its trial, so do NOT promise one. " +
|
|
93
|
+
`Pro is ${price}/month, taxes included, with ${usd(sub?.includedCents ?? 1900)} in credits each month. ${start}`
|
|
94
|
+
);
|
|
95
|
+
case "checkout_unavailable":
|
|
96
|
+
return "No plan, and Pro is not open for this account yet. Do NOT try to start billing.";
|
|
97
|
+
}
|
|
28
98
|
}
|
|
29
99
|
|
|
30
|
-
/**
|
|
31
|
-
export
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
100
|
+
/** Commands an agent can run next. */
|
|
101
|
+
export function nextSteps(linked: boolean): Array<{ command: string; why: string }> {
|
|
102
|
+
return [
|
|
103
|
+
{
|
|
104
|
+
command: "lizard skills get core",
|
|
105
|
+
why: "The guide to every command, matched to this CLI version. Read it before running others.",
|
|
106
|
+
},
|
|
107
|
+
...(linked ? [] : [{ command: "lizard init", why: "Create a project and link the current folder to it." }]),
|
|
108
|
+
{ command: "lizard up", why: linked ? "Deploy the linked folder." : "Deploy the folder once it is linked." },
|
|
109
|
+
];
|
|
38
110
|
}
|
|
39
111
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
sessionId: string,
|
|
43
|
-
sessionSecret: string,
|
|
44
|
-
): Promise<CheckResponse> {
|
|
45
|
-
const res = await fetch(`${getBaseURL()}/api/auth/cli/poll`, {
|
|
46
|
-
method: "POST",
|
|
47
|
-
// The platform records the login from this request; its User-Agent is
|
|
48
|
-
// what files it under the CLI and the agent running it.
|
|
49
|
-
headers: { ...clientHeaders(), "Content-Type": "application/json" },
|
|
50
|
-
body: JSON.stringify({ sessionId, sessionSecret }),
|
|
51
|
-
});
|
|
52
|
-
if (!res.ok) throw new Error(`Auth check failed: ${res.statusText}`);
|
|
53
|
-
return res.json() as Promise<CheckResponse>;
|
|
112
|
+
async function fetchSubscription(): Promise<Subscription | null> {
|
|
113
|
+
return api.get<Subscription>("/api/billing/subscription").catch(() => null);
|
|
54
114
|
}
|
|
55
115
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
116
|
+
// ── For agents ──────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
async function loginForAgent(flag?: AuthProvider): Promise<void> {
|
|
119
|
+
let source: "env" | "saved" | "browser" = envToken() ? "env" : "saved";
|
|
120
|
+
let me = await currentUser();
|
|
121
|
+
if (!me) {
|
|
122
|
+
const resumed = await resumePending();
|
|
123
|
+
if (resumed.kind !== "complete") {
|
|
124
|
+
const pending = resumed.kind === "pending" ? resumed.pending : await startSession(flag);
|
|
125
|
+
const methods = flag ? [flag] : await fetchAuthProviders();
|
|
126
|
+
const why = resumed.kind === "pending" ? "waiting" : resumed.kind === "expired" ? "expired" : "new";
|
|
127
|
+
printJSON(pendingPayload(pending, methods, why, { flag, nextCommand: flag ? `lizard login --${flag}` : "lizard login" }));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
source = "browser";
|
|
131
|
+
me = await api.get<Me>("/api/auth/me");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const sub = await fetchSubscription();
|
|
135
|
+
const state = accountState(sub);
|
|
136
|
+
printJSON({
|
|
137
|
+
status: "complete",
|
|
138
|
+
username: me.username,
|
|
139
|
+
...(me.email ? { email: me.email } : {}),
|
|
140
|
+
...(me.scoped ? { scoped: true } : {}),
|
|
141
|
+
source,
|
|
142
|
+
account: {
|
|
143
|
+
state,
|
|
144
|
+
plan: sub?.plan ?? null,
|
|
145
|
+
status: sub?.status ?? null,
|
|
146
|
+
billingUrl: billingUrl(),
|
|
147
|
+
...(state === "past_due" ? { payUrl: sub?.openInvoiceUrl || billingUrl() } : {}),
|
|
148
|
+
instruction: accountInstruction(state, sub),
|
|
149
|
+
},
|
|
150
|
+
next: nextSteps(Boolean(getProjectLink())),
|
|
72
151
|
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── For people ──────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
async function offerPlan(canAsk: boolean): Promise<void> {
|
|
157
|
+
const sub = await fetchSubscription();
|
|
158
|
+
const state = accountState(sub);
|
|
159
|
+
if (!sub) {
|
|
160
|
+
p.log.message(chalk.dim("Could not read the plan. Run `lizard billing` to see it."));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (state !== "trial_available" && state !== "subscription_required") {
|
|
164
|
+
p.log.message(describeSubscription(sub).join("\n"));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const trial = state === "trial_available";
|
|
169
|
+
const offer = trial
|
|
170
|
+
? `Pro trial: ${sub.trial.days} days with ${usd(sub.trial.creditCents ?? 0)} in credits. No charge today, then ${usd(sub.priceCents)}/month, taxes included.`
|
|
171
|
+
: `Pro: ${usd(sub.priceCents)}/month, taxes included, with ${usd(sub.includedCents)} in credits each month.`;
|
|
172
|
+
p.log.info(`No plan yet. Deploys need one.\n${offer}`);
|
|
73
173
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
174
|
+
const go = canAsk ? await p.confirm({ message: trial ? "Start the trial now?" : "Start Pro now?" }) : false;
|
|
175
|
+
if (p.isCancel(go) || !go) {
|
|
176
|
+
p.log.message(`Start it later: ${chalk.cyan("lizard billing start")}`);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
const out = await api.post<{ url: string }>("/api/billing/subscription/checkout", {});
|
|
181
|
+
const opened = await openURL(out.url);
|
|
182
|
+
p.log.info(`${opened ? "Opened Stripe Checkout. If it did not open, use this link" : "Finish in Stripe Checkout"}:\n${chalk.cyan(out.url)}`);
|
|
183
|
+
} catch (err: any) {
|
|
184
|
+
p.log.warn(`Could not open Stripe Checkout: ${err?.message || err}\nTry again with ${chalk.cyan("lizard billing start")}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function loginInteractive(flag?: AuthProvider): Promise<void> {
|
|
189
|
+
// The install script hands the terminal over as stdin. Without it there is
|
|
190
|
+
// nothing to answer with, so skip the questions and print the links.
|
|
191
|
+
const canAsk = Boolean(process.stdin.isTTY);
|
|
192
|
+
p.intro(chalk.bold("Lizard CLI"));
|
|
193
|
+
|
|
194
|
+
const me = await currentUser();
|
|
195
|
+
if (me) {
|
|
196
|
+
p.log.success(`Signed in as ${chalk.bold(me.username)}`);
|
|
197
|
+
p.log.message(chalk.dim("To use another account: lizard logout, then lizard login."));
|
|
198
|
+
} else if ((await resumePending()).kind === "complete") {
|
|
199
|
+
const resumed = await api.get<Me>("/api/auth/me");
|
|
200
|
+
p.log.success(`Signed in as ${chalk.bold(resumed.username)}`);
|
|
201
|
+
} else {
|
|
202
|
+
await signInInteractive(flag);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
await offerPlan(canAsk);
|
|
206
|
+
|
|
207
|
+
const linked = Boolean(getProjectLink());
|
|
208
|
+
p.note(
|
|
209
|
+
(linked
|
|
210
|
+
? [`${chalk.cyan("lizard up")} Deploy this folder`]
|
|
211
|
+
: [`${chalk.cyan("lizard init")} Create a project and link this folder`, `${chalk.cyan("lizard up")} Deploy it`]
|
|
212
|
+
).join("\n"),
|
|
213
|
+
"Next",
|
|
214
|
+
);
|
|
215
|
+
p.outro("You're all set");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Validate a token, save it as this machine's login, and say who it belongs to. */
|
|
219
|
+
async function loginWithToken(token: string): Promise<void> {
|
|
220
|
+
const res = await fetch(`${getBaseURL()}/api/auth/me`, {
|
|
221
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
222
|
+
});
|
|
223
|
+
if (!res.ok) throw new Error("Invalid token");
|
|
224
|
+
const user = (await res.json()) as any;
|
|
225
|
+
const expMs = jwtExpiryMs(token);
|
|
226
|
+
saveCredentials({
|
|
227
|
+
accessToken: token,
|
|
228
|
+
expiresAt: expMs ? new Date(expMs).toISOString() : undefined,
|
|
229
|
+
userId: user.id,
|
|
230
|
+
username: user.username,
|
|
231
|
+
email: user.email,
|
|
232
|
+
avatarUrl: user.avatarUrl,
|
|
233
|
+
});
|
|
77
234
|
if (isJSONMode()) {
|
|
78
|
-
printJSON({ status: "
|
|
235
|
+
printJSON({ status: "complete", username: user.username });
|
|
79
236
|
} else {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
237
|
+
success(`Logged in as ${chalk.bold(user.username)}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The token piped into `--token-stdin`. A token in `--token` shows up in the
|
|
243
|
+
* process list and the shell history; one on stdin does not. A terminal on
|
|
244
|
+
* stdin would sit waiting for an end-of-file nobody knows to type, so refuse.
|
|
245
|
+
*/
|
|
246
|
+
export async function readTokenFromStdin(stdin: NodeJS.ReadableStream & { isTTY?: boolean } = process.stdin): Promise<string> {
|
|
247
|
+
if (stdin.isTTY) {
|
|
248
|
+
throw new Error('--token-stdin reads a piped token, e.g. printf \'%s\' "$LIZARD_TOKEN" | lizard login --token-stdin');
|
|
84
249
|
}
|
|
85
|
-
|
|
250
|
+
let raw = "";
|
|
251
|
+
for await (const chunk of stdin) raw += chunk.toString();
|
|
252
|
+
const token = raw.trim();
|
|
253
|
+
if (!token) throw new Error("--token-stdin got nothing on stdin.");
|
|
254
|
+
return token;
|
|
86
255
|
}
|
|
87
256
|
|
|
88
257
|
export function registerLogin(program: Command) {
|
|
89
258
|
program
|
|
90
259
|
.command("login")
|
|
91
|
-
.description("
|
|
260
|
+
.description("Sign in to Lizard, then offer Pro if the account has no plan. Safe to re-run")
|
|
92
261
|
.option("--token <token>", "Authenticate with an API token")
|
|
262
|
+
.option("--token-stdin", "Read the API token from stdin, which keeps it out of the process list and shell history")
|
|
263
|
+
.option("--github", "Sign in with GitHub")
|
|
264
|
+
.option("--google", "Sign in with Google")
|
|
93
265
|
.action(async (opts) => {
|
|
94
|
-
const
|
|
95
|
-
if (token)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
99
|
-
});
|
|
100
|
-
if (!res.ok) throw new Error("Invalid token");
|
|
101
|
-
const user = (await res.json()) as any;
|
|
102
|
-
const expMs = jwtExpiryMs(token);
|
|
103
|
-
saveCredentials({
|
|
104
|
-
accessToken: token,
|
|
105
|
-
expiresAt: expMs ? new Date(expMs).toISOString() : undefined,
|
|
106
|
-
userId: user.id,
|
|
107
|
-
username: user.username,
|
|
108
|
-
email: user.email,
|
|
109
|
-
avatarUrl: user.avatarUrl,
|
|
110
|
-
});
|
|
111
|
-
if (isJSONMode()) {
|
|
112
|
-
printJSON({ status: "complete", username: user.username });
|
|
113
|
-
} else {
|
|
114
|
-
success(`Logged in as ${chalk.bold(user.username)}`);
|
|
115
|
-
}
|
|
266
|
+
const flag = providerFlag(opts);
|
|
267
|
+
if (opts.token && opts.tokenStdin) throw new Error("Pass --token or --token-stdin, not both.");
|
|
268
|
+
if (opts.token || opts.tokenStdin) {
|
|
269
|
+
await loginWithToken(opts.token ?? (await readTokenFromStdin()));
|
|
116
270
|
return;
|
|
117
271
|
}
|
|
118
272
|
|
|
119
|
-
await
|
|
273
|
+
if (isJSONMode()) await loginForAgent(flag);
|
|
274
|
+
else await loginInteractive(flag);
|
|
120
275
|
});
|
|
121
276
|
}
|
package/src/lib/auth.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import open from "open";
|
|
2
|
-
import
|
|
2
|
+
import { isJSONMode } from "./format.js";
|
|
3
3
|
import {
|
|
4
4
|
loadConfig,
|
|
5
5
|
saveConfig,
|
|
@@ -61,6 +61,32 @@ export function isLoggedIn(): boolean {
|
|
|
61
61
|
return getToken() !== null;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
/** The token a command would use right now, or null when a sign-in is needed. */
|
|
65
|
+
export function validToken(): string | null {
|
|
66
|
+
const fromEnv = envToken();
|
|
67
|
+
if (fromEnv) return fromEnv;
|
|
68
|
+
const creds = loadCredentials();
|
|
69
|
+
return creds && !isExpired(creds) ? creds.accessToken : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Store what an approved CLI session returned, and drop the pending session. */
|
|
73
|
+
export function saveSessionLogin(result: {
|
|
74
|
+
accessToken: string;
|
|
75
|
+
user: { id: string; username: string; email?: string; avatarUrl?: string };
|
|
76
|
+
}): Credentials {
|
|
77
|
+
const expMs = jwtExpiryMs(result.accessToken);
|
|
78
|
+
saveCredentials({
|
|
79
|
+
accessToken: result.accessToken,
|
|
80
|
+
expiresAt: expMs ? new Date(expMs).toISOString() : undefined,
|
|
81
|
+
userId: result.user.id,
|
|
82
|
+
username: result.user.username,
|
|
83
|
+
email: result.user.email,
|
|
84
|
+
avatarUrl: result.user.avatarUrl,
|
|
85
|
+
});
|
|
86
|
+
clearPendingAuth();
|
|
87
|
+
return loadCredentials()!;
|
|
88
|
+
}
|
|
89
|
+
|
|
64
90
|
function isTTY(): boolean {
|
|
65
91
|
return Boolean(process.stdout.isTTY);
|
|
66
92
|
}
|
|
@@ -92,14 +118,13 @@ function isExpired(creds: Credentials): boolean {
|
|
|
92
118
|
/**
|
|
93
119
|
* Ensure the user is authenticated.
|
|
94
120
|
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* in the
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* the
|
|
101
|
-
*
|
|
102
|
-
* expired, starts a fresh one.
|
|
121
|
+
* A token from the environment or a saved login that has not expired wins.
|
|
122
|
+
* Otherwise a session the browser already approved finishes here. Failing
|
|
123
|
+
* that, a person at a terminal signs in on the spot (pick GitHub or Google,
|
|
124
|
+
* the browser opens, the CLI waits) and the command carries on. Anything else
|
|
125
|
+
* (an agent, a pipe, --json) gets a NOT_AUTHENTICATED error whose body holds
|
|
126
|
+
* the sign-in links; once the user approves, the same command run again
|
|
127
|
+
* picks the session up.
|
|
103
128
|
*/
|
|
104
129
|
export async function requireAuth(): Promise<Credentials> {
|
|
105
130
|
const fromEnv = envToken();
|
|
@@ -110,70 +135,15 @@ export async function requireAuth(): Promise<Credentials> {
|
|
|
110
135
|
const creds = loadCredentials();
|
|
111
136
|
if (creds && !isExpired(creds)) return creds;
|
|
112
137
|
|
|
113
|
-
|
|
114
|
-
const
|
|
115
|
-
if (
|
|
116
|
-
const { checkSession } = await import("../commands/login.js");
|
|
117
|
-
try {
|
|
118
|
-
const result = await checkSession(pending.sessionId, pending.sessionSecret);
|
|
119
|
-
if (result.status === "complete" && result.accessToken && result.user) {
|
|
120
|
-
const expMs = jwtExpiryMs(result.accessToken);
|
|
121
|
-
saveCredentials({
|
|
122
|
-
accessToken: result.accessToken,
|
|
123
|
-
expiresAt: expMs ? new Date(expMs).toISOString() : undefined,
|
|
124
|
-
userId: result.user.id,
|
|
125
|
-
username: result.user.username,
|
|
126
|
-
email: result.user.email,
|
|
127
|
-
avatarUrl: result.user.avatarUrl,
|
|
128
|
-
});
|
|
129
|
-
clearPendingAuth();
|
|
130
|
-
return loadCredentials()!;
|
|
131
|
-
}
|
|
132
|
-
if (result.status === "expired") {
|
|
133
|
-
clearPendingAuth();
|
|
134
|
-
// Fall through to start a fresh session below
|
|
135
|
-
} else {
|
|
136
|
-
// Still pending — user hasn't authenticated yet
|
|
137
|
-
printAuthPrompt(pending.authUrl);
|
|
138
|
-
process.exit(0);
|
|
139
|
-
}
|
|
140
|
-
} catch {
|
|
141
|
-
// Network error — assume still pending, show URL
|
|
142
|
-
printAuthPrompt(pending.authUrl);
|
|
143
|
-
process.exit(0);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
138
|
+
const signin = await import("./signin.js");
|
|
139
|
+
const resumed = await signin.resumePending();
|
|
140
|
+
if (resumed.kind === "complete") return loadCredentials()!;
|
|
146
141
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
creds
|
|
151
|
-
? "Session expired. Run `lizard login` again or set LIZARD_TOKEN / LIZARD_API_KEY."
|
|
152
|
-
: "Not authenticated. Set LIZARD_TOKEN (or LIZARD_API_KEY) or run `lizard login` first.",
|
|
153
|
-
) as Error & { code: string };
|
|
154
|
-
err.code = "NOT_AUTHENTICATED";
|
|
155
|
-
throw err;
|
|
142
|
+
if (isTTY() && !isJSONMode()) {
|
|
143
|
+
await signin.signInInteractive();
|
|
144
|
+
return loadCredentials()!;
|
|
156
145
|
}
|
|
157
|
-
|
|
158
|
-
const { createSession } = await import("../commands/login.js");
|
|
159
|
-
const { getBaseURL } = await import("./api.js");
|
|
160
|
-
const session = await createSession();
|
|
161
|
-
const authUrl = `${getBaseURL()}/auth/cli?session=${session.sessionId}`;
|
|
162
|
-
savePendingAuth({
|
|
163
|
-
sessionId: session.sessionId,
|
|
164
|
-
sessionSecret: session.sessionSecret,
|
|
165
|
-
authUrl,
|
|
166
|
-
createdAt: Date.now(),
|
|
167
|
-
});
|
|
168
|
-
await openURL(authUrl);
|
|
169
|
-
printAuthPrompt(authUrl);
|
|
170
|
-
process.exit(0);
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function printAuthPrompt(authUrl: string) {
|
|
174
|
-
process.stderr.write(
|
|
175
|
-
`\nAuthenticate with Lizard:\n ${chalk.cyan(authUrl)}\n\nThen run your command again.\n\n`,
|
|
176
|
-
);
|
|
146
|
+
throw await signin.loginRequiredError(resumed, Boolean(creds));
|
|
177
147
|
}
|
|
178
148
|
|
|
179
149
|
/** Open a URL in the default browser, or print it if headless. */
|
package/src/lib/config.ts
CHANGED