@lizard-build/cli 4.0.67 → 4.0.68

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,5 +1,5 @@
1
- import { execFileSync } from "node:child_process";
2
- import { realpathSync } from "node:fs";
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
3
  import { apiErrorFrom, clientHeaders, getRequestToken, APIError } from "./api.js";
4
4
 
5
5
  /**
@@ -38,6 +38,11 @@ export function gitToken(): string | null {
38
38
  return process.env.LIZARD_GIT_TOKEN || getRequestToken();
39
39
  }
40
40
 
41
+ /** One request with the Lizard Git sign-in; non-2xx answers throw. */
42
+ export function sendRaw(method: string, path: string, body?: Buffer, headers: Record<string, string> = {}): Promise<Response> {
43
+ return send(method, path, body === undefined ? undefined : new Uint8Array(body), headers);
44
+ }
45
+
41
46
  async function send(method: string, path: string, body?: BodyInit, headers: Record<string, string> = {}): Promise<Response> {
42
47
  const token = gitToken();
43
48
  const h: Record<string, string> = { ...clientHeaders(), ...headers };
@@ -93,35 +98,21 @@ export async function myLogin(): Promise<string> {
93
98
  return me.login;
94
99
  }
95
100
 
96
- /** owner/name from a Lizard Git remote URL (https or ssh-style), or null. */
97
- /** Every address a Lizard Git remote may use: LIZARD_GIT_URL, or lizard.build/git and the old one. */
101
+ /** Every address Lizard Git has had: LIZARD_GIT_URL, or lizard.build/git and the old one. */
98
102
  export function gitURLs(): string[] {
99
103
  const u = gitURL();
100
104
  return u === DEFAULT_GIT_URL ? [u, OLD_GIT_URL] : [u];
101
105
  }
102
106
 
103
- export function repoFromRemote(url: string, bases = gitURLs()): string | null {
104
- const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
105
- for (const base of bases) {
106
- const b = new URL(base);
107
- const re = new RegExp(`^https?://(?:[^@/]+@)?${esc(b.host)}${esc(b.pathname.replace(/\/+$/, ""))}/([^/]+)/([^/]+?)(?:\\.git)?/?$`);
108
- const m = url.trim().match(re);
109
- if (m && ![m[1], m[2]].some((x) => x === "." || x === "..")) return `${m[1]}/${m[2]}`;
107
+ /** owner/name of the Lizard Git checkout this directory is in (.lizard-git/checkout.json), or null. */
108
+ function checkoutRepo(): string | null {
109
+ for (let d = process.cwd(); ; d = dirname(d)) {
110
+ try {
111
+ const c = JSON.parse(readFileSync(join(d, ".lizard-git", "checkout.json"), "utf8"));
112
+ if (typeof c.repo === "string" && /^[^/\s]+\/[^/\s]+$/.test(c.repo)) return c.repo;
113
+ } catch {}
114
+ if (dirname(d) === d) return null;
110
115
  }
111
- return null;
112
- }
113
-
114
- function originRepo(): string | null {
115
- try {
116
- const remotes = execFileSync("git", ["remote", "-v"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
117
- const lines = remotes.split("\n").filter((l) => l.includes("(fetch)"));
118
- const ordered = [...lines.filter((l) => l.startsWith("origin\t")), ...lines];
119
- for (const l of ordered) {
120
- const r = repoFromRemote(l.split(/\s+/)[1] ?? "");
121
- if (r) return r;
122
- }
123
- } catch {}
124
- return null;
125
116
  }
126
117
 
127
118
  /**
@@ -136,9 +127,9 @@ export async function resolveRepo(flag?: string): Promise<string> {
136
127
  if (ok(f)) return `${await myLogin()}/${f}`;
137
128
  throw new APIError(400, `--repo is owner/name or name, got "${flag}"`);
138
129
  }
139
- const r = originRepo();
130
+ const r = checkoutRepo();
140
131
  if (r) return r;
141
- throw new APIError(400, `Which repository? Pass --repo owner/name, or run this inside a clone of a Lizard Git repository (${gitURL()}).`);
132
+ throw new APIError(400, `Which repository? Pass --repo owner/name, or run this inside a checkout (lizard git clone owner/name).`);
142
133
  }
143
134
 
144
135
  export function repoPath(repo: string): string {
@@ -146,22 +137,6 @@ export function repoPath(repo: string): string {
146
137
  return `/api/repos/${seg(owner)}/${seg(name)}`;
147
138
  }
148
139
 
149
- export function cloneURL(repo: string): string {
150
- return `${gitURL()}/${repo}.git`;
151
- }
152
-
153
- /** How git should run this CLI as its credential helper. */
154
- export function helperCommand(): string {
155
- // `npm i -g` runs us through a bin symlink with no .js name (/usr/local/bin/lizard): resolve it,
156
- // or the helper would be `node git credential`, which can't start.
157
- let script = process.argv[1] ?? "";
158
- try { script = realpathSync(script); } catch { /* keep argv[1] */ }
159
- const q = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
160
- // npm install: node + the script. Standalone binary: the binary itself.
161
- if (/\.(c|m)?js$/.test(script)) return `!${q(process.execPath)} ${q(script)} git credential`;
162
- return `!${q(process.execPath)} git credential`;
163
- }
164
-
165
140
  /** Seconds (Lizard Git) to "3m ago". */
166
141
  export function ago(sec: number | null | undefined): string {
167
142
  if (!sec) return "";
@@ -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.67";
8
+ export const CURRENT_VERSION = "4.0.68";
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
 
@@ -1,5 +1,9 @@
1
1
  import { describe, it, expect, afterEach, vi } from "vitest";
2
- import { repoFromRemote, qs, gitURL, helperCommand, resolveRepo, lg, segs } from "../../src/lib/lizard-git.js";
2
+ import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, unlinkSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { qs, gitURL, resolveRepo, lg, segs } from "../../src/lib/lizard-git.js";
6
+ import { changesOf, scanWorking, saveCheckout, findCheckout, sha256, unifiedDiff, type Checkout } from "../../src/lib/checkout.js";
3
7
 
4
8
  afterEach(() => {
5
9
  vi.unstubAllGlobals();
@@ -7,18 +11,6 @@ afterEach(() => {
7
11
  });
8
12
 
9
13
  describe("lizard git helpers", () => {
10
- it("reads owner/name from Lizard Git remotes only", () => {
11
- expect(repoFromRemote("https://lizard.build/git/alice/app.git")).toBe("alice/app");
12
- expect(repoFromRemote("https://lizard:liz_x@lizard.build/git/alice/app")).toBe("alice/app");
13
- expect(repoFromRemote("https://git.lizard.build/alice/app.git")).toBe("alice/app"); // older clones
14
- expect(repoFromRemote("https://lizard:liz_x@git.lizard.build/alice/app")).toBe("alice/app");
15
- expect(repoFromRemote("https://github.com/alice/app.git")).toBeNull();
16
- expect(repoFromRemote("git@github.com:alice/app.git")).toBeNull();
17
- expect(repoFromRemote("https://lizard.build/alice/app.git")).toBeNull();
18
- expect(repoFromRemote("https://lizard.build/gitx/alice/app.git")).toBeNull();
19
- expect(repoFromRemote("https://lizard.build/git/alice/app/extra.git")).toBeNull();
20
- });
21
-
22
14
  it("drops empty query values", () => {
23
15
  expect(qs("/x", { a: "1", b: undefined, c: "", d: false, e: 2 })).toBe("/x?a=1&e=2");
24
16
  });
@@ -26,12 +18,6 @@ describe("lizard git helpers", () => {
26
18
  it("honours LIZARD_GIT_URL", () => {
27
19
  vi.stubEnv("LIZARD_GIT_URL", "http://localhost:8080/");
28
20
  expect(gitURL()).toBe("http://localhost:8080");
29
- expect(repoFromRemote("http://localhost:8080/a/b.git")).toBe("a/b");
30
- expect(repoFromRemote("https://git.lizard.build/a/b.git")).toBeNull(); // another server: only it
31
- });
32
-
33
- it("runs itself as git's credential helper", () => {
34
- expect(helperCommand()).toMatch(/^!'.+' .*git credential$/);
35
21
  });
36
22
 
37
23
  it("takes owner/name or a bare name (yours) for --repo", async () => {
@@ -66,8 +52,63 @@ describe("lizard git safety", () => {
66
52
  await expect(resolveRepo("a/..")).rejects.toThrow();
67
53
  expect(() => segs("../../api/user/tokens")).toThrow(/bad path/);
68
54
  expect(segs("src/a b.js")).toBe("src/a%20b.js");
69
- expect(repoFromRemote("https://git.lizard.build/../x.git")).toBeNull();
70
- expect(repoFromRemote("https://lizard.build/git/../x.git")).toBeNull();
71
- expect(repoFromRemote("https://lizard.build/git/a/...git")).toBeNull();
55
+ });
56
+ });
57
+
58
+ describe("lizard git checkouts", () => {
59
+ const make = (files: Record<string, string>, state: Partial<Checkout> = {}) => {
60
+ const root = mkdtempSync(join(tmpdir(), "lzg-"));
61
+ const c: Checkout = { version: 1, url: "x", repo: "alice/app", branch: "main", base: "b".repeat(64), root: "r".repeat(64), sparse: null, files: {}, ...state };
62
+ for (const [p, body] of Object.entries(files)) {
63
+ mkdirSync(join(root, p, ".."), { recursive: true });
64
+ writeFileSync(join(root, p), body);
65
+ c.files[p] = { h: sha256(body), m: "100644" };
66
+ }
67
+ saveCheckout(root, c);
68
+ return { root, c };
69
+ };
70
+
71
+ it("finds its checkout from a subdirectory and the repository from it", async () => {
72
+ const { root } = make({ "src/a.ts": "a" });
73
+ expect(findCheckout(join(root, "src"))).toBe(root);
74
+ const cwd = process.cwd();
75
+ process.chdir(join(root, "src"));
76
+ try {
77
+ expect(await resolveRepo()).toBe("alice/app");
78
+ } finally {
79
+ process.chdir(cwd);
80
+ }
81
+ });
82
+
83
+ it("sees added, modified and deleted files, and nothing it shouldn't", () => {
84
+ const { root, c } = make({ "a.txt": "a", "src/b.txt": "b", "gone.txt": "g" });
85
+ writeFileSync(join(root, "a.txt"), "a2");
86
+ writeFileSync(join(root, "new.txt"), "n");
87
+ mkdirSync(join(root, ".lizard/workflows"), { recursive: true }); // a repository's CI: content like any other
88
+ writeFileSync(join(root, ".lizard/workflows/ci.yml"), "on: push\n");
89
+ writeFileSync(join(root, ".gitignore"), "*.log\nbuild/\n");
90
+ writeFileSync(join(root, "debug.log"), "x");
91
+ mkdirSync(join(root, "build"));
92
+ writeFileSync(join(root, "build/out.js"), "x");
93
+ mkdirSync(join(root, "node_modules/x"), { recursive: true });
94
+ writeFileSync(join(root, "node_modules/x/i.js"), "x");
95
+ symlinkSync("a.txt", join(root, "link"));
96
+ unlinkSync(join(root, "gone.txt"));
97
+ const ch = changesOf(c, scanWorking(root, c));
98
+ expect(ch).toEqual({ added: [".gitignore", ".lizard/workflows/ci.yml", "link", "new.txt"], modified: ["a.txt"], deleted: ["gone.txt"] });
99
+ });
100
+
101
+ it("a sparse checkout only looks inside its folders", () => {
102
+ const { root, c } = make({ "src/a.txt": "a" }, { sparse: ["src"] });
103
+ writeFileSync(join(root, "outside.txt"), "o");
104
+ writeFileSync(join(root, "src/b.txt"), "b");
105
+ expect(changesOf(c, scanWorking(root, c)).added).toEqual(["src/b.txt"]);
106
+ });
107
+
108
+ it("diffs lines", () => {
109
+ const d = unifiedDiff("a\nb\nc\n", "a\nB\nc\n", "f");
110
+ expect(d).toContain("@@ -1,4 +1,4 @@");
111
+ expect(d).toContain("-b");
112
+ expect(d).toContain("+B");
72
113
  });
73
114
  });