@lizard-build/cli 4.0.33 → 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.
@@ -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
- saveCredentials,
5
- savePendingAuth,
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
- interface SessionResponse {
14
- sessionId: string;
15
- sessionSecret: string;
16
- expiresIn: number;
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
- export interface CheckResponse {
20
- status: "pending" | "complete" | "expired";
21
- accessToken?: string;
22
- user?: {
23
- id: string;
24
- username: string;
25
- email?: string;
26
- avatarUrl?: string;
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
- /** Create a CLI login session on the server */
31
- export async function createSession(): Promise<SessionResponse> {
32
- const res = await fetch(`${getBaseURL()}/api/auth/cli/session`, {
33
- method: "POST",
34
- headers: { ...clientHeaders(), "Content-Type": "application/json" },
35
- });
36
- if (!res.ok) throw new Error(`Failed to create login session: ${res.statusText}`);
37
- return res.json() as Promise<SessionResponse>;
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
- /** Check once if the user has completed authentication (no polling loop) */
41
- export async function checkSession(
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
- * Start the login flow: creates a session, saves it to disk, surfaces the
58
- * auth URL, then exits. In human mode it opens the browser and prints the
59
- * URL; in JSON mode it emits the URL as JSON and never opens a browser. The
60
- * user authenticates and re-runs their original command — requireAuth will
61
- * pick up the pending session.
62
- */
63
- export async function performLogin(): Promise<never> {
64
- const session = await createSession();
65
- const authUrl = `${getBaseURL()}/auth/cli?session=${session.sessionId}`;
66
-
67
- savePendingAuth({
68
- sessionId: session.sessionId,
69
- sessionSecret: session.sessionSecret,
70
- authUrl,
71
- createdAt: Date.now(),
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
- // In JSON mode emit the URL as machine-readable output and never spawn a
75
- // browser — agents drive this flow and popping open a browser on a
76
- // headless/agent host is wrong. In human mode, open it and print the URL.
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: "pending", authUrl });
235
+ printJSON({ status: "complete", username: user.username });
79
236
  } else {
80
- await openURL(authUrl);
81
- process.stderr.write(
82
- `\nAuthenticate with Lizard:\n ${chalk.cyan(authUrl)}\n\nOnce authenticated, run your command again.\n\n`,
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
- process.exit(0);
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("Log in to Lizard")
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 token = opts.token;
95
- if (token) {
96
- // Direct token auth — validate it
97
- const res = await fetch(`${getBaseURL()}/api/auth/me`, {
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 performLogin();
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 chalk from "chalk";
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
- * On first call with no credentials: creates a CLI auth session, saves it to
96
- * disk, opens the browser, prints the URL, and exits. The user authenticates
97
- * in the browser, then re-runs their command.
98
- *
99
- * On subsequent calls while a session is pending: checks once (no loop) if
100
- * the user has completed authentication. If yes, stores credentials and
101
- * returns them. If still pending, prints the URL and exits. If the session
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
- // Check pending session before the TTY guard — completing auth doesn't need interactivity
114
- const pending = loadPendingAuth();
115
- if (pending) {
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
- // No credentials and no pending session — need to start a new auth flow
148
- if (!isTTY()) {
149
- const err = new Error(
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
@@ -46,6 +46,8 @@ export interface PendingAuth {
46
46
  sessionSecret: string;
47
47
  authUrl: string;
48
48
  createdAt: number;
49
+ /** When the platform drops the session. Older CLIs did not save it. */
50
+ expiresAt?: number;
49
51
  }
50
52
 
51
53
  export interface Config {