@lizard-build/cli 4.0.59 → 4.0.60

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.
@@ -7,6 +7,7 @@ import { api, getBaseURL, streamSSE, withScope } from "../lib/api.js";
7
7
  import { openURL } from "../lib/auth.js";
8
8
  import { resolveProjectScope, resolveService } from "../lib/resolve.js";
9
9
  import { success, error, info, fail, isJSONMode, printJSON, isTTY, link } from "../lib/format.js";
10
+ import { registerLizardGit } from "./git-hosting.js";
10
11
 
11
12
  interface GitHubInstallation {
12
13
  id: number;
@@ -170,7 +171,7 @@ export function registerGit(program: Command) {
170
171
  // lizard git status
171
172
  git
172
173
  .command("status")
173
- .description("Show GitHub connection and repository status")
174
+ .description("GitHub: connection and the repositories this project's services deploy from")
174
175
  .option("-p, --project <id>", "Project name, slug, or ID")
175
176
  .action(async (opts) => {
176
177
  const { projectId, scope } = await resolveProjectScope(opts.project);
@@ -232,6 +233,7 @@ export function registerGit(program: Command) {
232
233
  );
233
234
  }
234
235
  });
236
+ registerLizardGit(git);
235
237
  }
236
238
 
237
239
  function pressEnter(question: string): Promise<void> {
package/src/index.ts CHANGED
@@ -146,6 +146,9 @@ program
146
146
  topLevel = topLevel.parent;
147
147
  }
148
148
  if (noAuth.has(topLevel.name())) return;
149
+ // git runs `lizard git credential get` as its credential helper: it answers from the
150
+ // stored sign-in or nothing, and must never start an interactive login inside git.
151
+ if (topLevel.name() === "git" && actionCommand.name() === "credential") return;
149
152
 
150
153
  // Require auth — auto-triggers login flow if not logged in
151
154
  const creds = await requireAuth();
@@ -0,0 +1,138 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { apiErrorFrom, clientHeaders, getRequestToken, APIError } from "./api.js";
3
+
4
+ /**
5
+ * Lizard Git (git.lizard.build) with the CLI's own sign-in: the session from `lizard login`,
6
+ * or LIZARD_TOKEN / LIZARD_API_KEY. Lizard Git asks the platform whose it is and acts as that
7
+ * account's user. Scoped API keys are refused there (they don't say which repositories they
8
+ * may reach); a Lizard Git token (lzg_..., `lizard git token create`) in LIZARD_GIT_TOKEN
9
+ * is used instead when set.
10
+ */
11
+
12
+ export const DEFAULT_GIT_URL = "https://git.lizard.build";
13
+
14
+ export function gitURL(): string {
15
+ return (process.env.LIZARD_GIT_URL || DEFAULT_GIT_URL).replace(/\/+$/, "");
16
+ }
17
+
18
+ /** The credential Lizard Git gets: LIZARD_GIT_TOKEN, else the CLI's sign-in. */
19
+ export function gitToken(): string | null {
20
+ return process.env.LIZARD_GIT_TOKEN || getRequestToken();
21
+ }
22
+
23
+ async function send(method: string, path: string, body?: BodyInit, headers: Record<string, string> = {}): Promise<Response> {
24
+ const token = gitToken();
25
+ const h: Record<string, string> = { ...clientHeaders(), ...headers };
26
+ if (token) h["Authorization"] = `Bearer ${token}`;
27
+ const res = await fetch(gitURL() + path, { method, headers: h, body });
28
+ if (!res.ok) {
29
+ let parsed: unknown = null;
30
+ try {
31
+ parsed = await res.json();
32
+ } catch {}
33
+ throw apiErrorFrom(res.status, res.statusText, parsed);
34
+ }
35
+ return res;
36
+ }
37
+
38
+ async function json<T>(method: string, path: string, body?: unknown, headers: Record<string, string> = {}): Promise<T> {
39
+ const res = await send(method, path, body === undefined ? undefined : JSON.stringify(body),
40
+ body === undefined ? headers : { "Content-Type": "application/json", ...headers });
41
+ const text = await res.text();
42
+ return (text ? JSON.parse(text) : undefined) as T;
43
+ }
44
+
45
+ export const lg = {
46
+ get: <T = any>(path: string) => json<T>("GET", path),
47
+ post: <T = any>(path: string, body: unknown = {}, headers?: Record<string, string>) => json<T>("POST", path, body, headers),
48
+ put: <T = any>(path: string, body: unknown = {}) => json<T>("PUT", path, body),
49
+ patch: <T = any>(path: string, body: unknown) => json<T>("PATCH", path, body),
50
+ delete: <T = any>(path: string) => json<T>("DELETE", path),
51
+ bytes: async (path: string): Promise<Buffer> => Buffer.from(await (await send("GET", path)).arrayBuffer()),
52
+ upload: async <T = any>(path: string, data: Buffer, contentType: string): Promise<T> =>
53
+ (await send("POST", path, new Uint8Array(data), { "Content-Type": contentType })).json() as Promise<T>,
54
+ };
55
+
56
+ export function qs(path: string, values: Record<string, string | number | boolean | undefined | null>): string {
57
+ const q = new URLSearchParams();
58
+ for (const [k, v] of Object.entries(values)) if (v !== undefined && v !== null && v !== "" && v !== false) q.set(k, String(v));
59
+ const s = q.toString();
60
+ return s ? `${path}?${s}` : path;
61
+ }
62
+
63
+ export const seg = encodeURIComponent;
64
+ export const segs = (p: string) => p.split("/").map(seg).join("/");
65
+
66
+ let me: { login: string } | null = null;
67
+ /** Your Lizard Git login. */
68
+ export async function myLogin(): Promise<string> {
69
+ if (!me) me = await lg.get<{ login: string }>("/api/session");
70
+ return me.login;
71
+ }
72
+
73
+ /** owner/name from a Lizard Git remote URL (https or ssh-style), or null. */
74
+ export function repoFromRemote(url: string, base = gitURL()): string | null {
75
+ const host = new URL(base).host;
76
+ const m = url.trim().match(/^(?:https?:\/\/)(?:[^@/]+@)?([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
77
+ if (!m || m[1] !== host) return null;
78
+ return `${m[2]}/${m[3]}`;
79
+ }
80
+
81
+ function originRepo(): string | null {
82
+ try {
83
+ const remotes = execFileSync("git", ["remote", "-v"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
84
+ const lines = remotes.split("\n").filter((l) => l.includes("(fetch)"));
85
+ const ordered = [...lines.filter((l) => l.startsWith("origin\t")), ...lines];
86
+ for (const l of ordered) {
87
+ const r = repoFromRemote(l.split(/\s+/)[1] ?? "");
88
+ if (r) return r;
89
+ }
90
+ } catch {}
91
+ return null;
92
+ }
93
+
94
+ /**
95
+ * The repository a command is about: `--repo owner/name` (or just `name`: yours), else the
96
+ * Lizard Git remote of the git checkout you're in.
97
+ */
98
+ export async function resolveRepo(flag?: string): Promise<string> {
99
+ if (flag) {
100
+ const f = flag.replace(/\.git$/, "");
101
+ if (/^[^/\s]+\/[^/\s]+$/.test(f)) return f;
102
+ if (/^[^/\s]+$/.test(f)) return `${await myLogin()}/${f}`;
103
+ throw new APIError(400, `--repo is owner/name or name, got "${flag}"`);
104
+ }
105
+ const r = originRepo();
106
+ if (r) return r;
107
+ throw new APIError(400, `Which repository? Pass --repo owner/name, or run this inside a clone of a Lizard Git repository (${gitURL()}).`);
108
+ }
109
+
110
+ export function repoPath(repo: string): string {
111
+ const [owner, name] = repo.split("/");
112
+ return `/api/repos/${seg(owner)}/${seg(name)}`;
113
+ }
114
+
115
+ export function cloneURL(repo: string): string {
116
+ return `${gitURL()}/${repo}.git`;
117
+ }
118
+
119
+ /** How git should run this CLI as its credential helper. */
120
+ export function helperCommand(): string {
121
+ const script = process.argv[1] ?? "";
122
+ const q = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
123
+ // npm install: node + the script. Standalone binary: the binary itself.
124
+ if (/\.(c|m)?js$/.test(script)) return `!${q(process.execPath)} ${q(script)} git credential`;
125
+ return `!${q(process.execPath)} git credential`;
126
+ }
127
+
128
+ /** Seconds (Lizard Git) to "3m ago". */
129
+ export function ago(sec: number | null | undefined): string {
130
+ if (!sec) return "";
131
+ const s = Math.max(0, Math.floor(Date.now() / 1000 - sec));
132
+ if (s < 60) return `${s}s ago`;
133
+ if (s < 3600) return `${Math.floor(s / 60)}m ago`;
134
+ if (s < 86400) return `${Math.floor(s / 3600)}h ago`;
135
+ return `${Math.floor(s / 86400)}d ago`;
136
+ }
137
+
138
+ export const ACTIVE_RUN = new Set(["queued", "running", "waiting", "pending"]);
@@ -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.59";
8
+ export const CURRENT_VERSION = "4.0.60";
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
 
@@ -0,0 +1,48 @@
1
+ import { describe, it, expect, afterEach, vi } from "vitest";
2
+ import { repoFromRemote, qs, gitURL, helperCommand, resolveRepo, lg } from "../../src/lib/lizard-git.js";
3
+
4
+ afterEach(() => {
5
+ vi.unstubAllGlobals();
6
+ vi.unstubAllEnvs();
7
+ });
8
+
9
+ describe("lizard git helpers", () => {
10
+ it("reads owner/name from Lizard Git remotes only", () => {
11
+ expect(repoFromRemote("https://git.lizard.build/alice/app.git")).toBe("alice/app");
12
+ expect(repoFromRemote("https://lizard:liz_x@git.lizard.build/alice/app")).toBe("alice/app");
13
+ expect(repoFromRemote("https://github.com/alice/app.git")).toBeNull();
14
+ expect(repoFromRemote("git@github.com:alice/app.git")).toBeNull();
15
+ });
16
+
17
+ it("drops empty query values", () => {
18
+ expect(qs("/x", { a: "1", b: undefined, c: "", d: false, e: 2 })).toBe("/x?a=1&e=2");
19
+ });
20
+
21
+ it("honours LIZARD_GIT_URL", () => {
22
+ vi.stubEnv("LIZARD_GIT_URL", "http://localhost:8080/");
23
+ expect(gitURL()).toBe("http://localhost:8080");
24
+ expect(repoFromRemote("http://localhost:8080/a/b.git", gitURL())).toBe("a/b");
25
+ });
26
+
27
+ it("runs itself as git's credential helper", () => {
28
+ expect(helperCommand()).toMatch(/^!'.+' .*git credential$/);
29
+ });
30
+
31
+ it("takes owner/name or a bare name (yours) for --repo", async () => {
32
+ vi.stubEnv("LIZARD_TOKEN", "liz_test");
33
+ const calls: string[] = [];
34
+ vi.stubGlobal("fetch", vi.fn(async (url: string, init: any) => {
35
+ calls.push(`${init.method} ${url} ${init.headers.Authorization}`);
36
+ return new Response(JSON.stringify({ login: "me" }), { status: 200 });
37
+ }));
38
+ expect(await resolveRepo("alice/app")).toBe("alice/app");
39
+ expect(await resolveRepo("app")).toBe("me/app");
40
+ expect(calls).toEqual(["GET https://git.lizard.build/api/session Bearer liz_test"]);
41
+ await expect(resolveRepo("a/b/c")).rejects.toThrow(/owner\/name/);
42
+ });
43
+
44
+ it("turns error bodies into APIError with the server's message", async () => {
45
+ vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ error: "no such repository" }), { status: 404 })));
46
+ await expect(lg.get("/api/repos/a/b")).rejects.toMatchObject({ status: 404, message: "no such repository" });
47
+ });
48
+ });