@lizard-build/cli 4.0.66 → 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.
@@ -24,6 +24,22 @@ interface DnsRecord { name: string; type: DnsType; value: string; ttl: number; p
24
24
  const DNS_TYPES: DnsType[] = ["A", "AAAA", "CNAME", "ALIAS", "MX", "TXT", "URL301"];
25
25
 
26
26
  const SESSION_HINT = "Domains bought on Lizard need `lizard login` or an API key without scopes (a scoped key cannot reach them).";
27
+ /** Changing a bought domain needs a login: an API key can read it but not repoint it, so a
28
+ * leaked key cannot take the domain or its email. */
29
+ const WRITE_HINT = "Changing a domain bought on Lizard needs `lizard login` (an API key can read its records but not change them).";
30
+
31
+ function writeError(err: unknown): never {
32
+ if (err instanceof APIError && err.status === 401) throw new Error(WRITE_HINT);
33
+ throw err;
34
+ }
35
+
36
+ const POINTING: DnsType[] = ["A", "AAAA", "CNAME", "ALIAS", "URL301"];
37
+ /** Records connecting `names` would replace, leaving out ones nobody would miss: the
38
+ * registrar's parking page and a previous Lizard target. */
39
+ export function recordsToReplace(records: DnsRecord[], names: string[]): DnsRecord[] {
40
+ return records.filter((r) => names.includes(r.name) && POINTING.includes(r.type)
41
+ && !/(^|\.)parkingpage\.namecheap\.com\.?$/i.test(r.value) && !/(^|\.)onlizard\.com\.?$/i.test(r.value));
42
+ }
27
43
 
28
44
  async function listOwned(): Promise<OwnedDomain[]> {
29
45
  const r = await api.get<{ orders: OwnedDomain[] }>("/api/domains/orders").catch((err: unknown) => {
@@ -43,13 +59,37 @@ export async function findOwnedDomain(hostname: string): Promise<OwnedDomain | n
43
59
  .sort((a, b) => b.domain.length - a.domain.length)[0] ?? null;
44
60
  }
45
61
 
46
- /** Points `hostname` (the bought domain, or a subdomain of it) at a service: DNS and attach in one call. */
47
- export async function connectOwned(order: OwnedDomain, hostname: string, service: { id: string; name: string }) {
62
+ /** Points `hostname` (the bought domain, or a subdomain of it) at a service: DNS and attach
63
+ * in one call. Records it would replace, and a hostname attached to another service, need
64
+ * a yes at the terminal or --force, so one command cannot quietly take a working site down. */
65
+ export async function connectOwned(order: OwnedDomain, hostname: string, service: { id: string; name: string }, force: boolean) {
48
66
  const host = hostname.toLowerCase().replace(/\.$/, "");
49
- const r = await api.post<{ ok: boolean; attached: string[]; note?: string }>(
67
+ const names = host === order.domain ? ["@", "www"] : [host.slice(0, -(order.domain.length + 1))];
68
+ let move = force;
69
+ if (!force) {
70
+ const dns = await api.get<{ records: DnsRecord[] }>(`/api/domains/orders/${encodeURIComponent(order.id)}/dns`);
71
+ const replaced = recordsToReplace(dns.records, names);
72
+ if (replaced.length) {
73
+ const list = replaced.map((r) => `${r.name} ${r.type} ${r.value}`).join(", ");
74
+ if (!isTTY() || isJSONMode()) throw new Error(`This replaces existing records on ${order.domain}: ${list}. Run again with --force to replace them.`);
75
+ printRecords(order.domain, replaced);
76
+ const ok = await p.confirm({ message: `Replace ${replaced.length === 1 ? "this record" : "these records"} so ${host} points at ${service.name}?` });
77
+ if (p.isCancel(ok) || !ok) process.exit(5);
78
+ }
79
+ }
80
+ const connect = (forceMove: boolean) => api.post<{ ok: boolean; attached: string[]; note?: string }>(
50
81
  `/api/domains/orders/${encodeURIComponent(order.id)}/connect`,
51
- { appId: service.id, hostname: host },
82
+ { appId: service.id, hostname: host, force: forceMove },
52
83
  );
84
+ const r = await connect(move).catch(async (err: unknown) => {
85
+ if (err instanceof APIError && err.status === 409 && !move && isTTY() && !isJSONMode()) {
86
+ const ok = await p.confirm({ message: `${err.message} Move it to ${service.name}?` });
87
+ if (p.isCancel(ok) || !ok) process.exit(5);
88
+ move = true;
89
+ return connect(true).catch(writeError);
90
+ }
91
+ return writeError(err);
92
+ });
53
93
  if (isJSONMode()) {
54
94
  printJSON({ ...r, domain: order.domain, hostname: host, service: service.name });
55
95
  return;
@@ -193,7 +233,7 @@ To point a name at a Lizard service, use: lizard domain api.example.com --servic
193
233
  throw new Error(`Unknown action "${action}". Use set, add or rm (or nothing to list).`);
194
234
  }
195
235
 
196
- const saved = await api.put<{ records: DnsRecord[]; version: string }>(path, { version: current.version, records });
236
+ const saved = await api.put<{ records: DnsRecord[]; version: string }>(path, { version: current.version, records }).catch(writeError);
197
237
  if (isJSONMode()) return printJSON({ domain: order.domain, ...saved });
198
238
  success(`${summary} on ${order.domain}`);
199
239
  printRecords(order.domain, saved.records);
@@ -46,7 +46,7 @@ export function registerDomain(program: Command) {
46
46
  .option("-s, --service <name>", "Service name or ID")
47
47
  .option("-p, --project <id>", "Project name, slug, or ID")
48
48
  .option("--port <n>", "Port to expose", parseIntOption)
49
- .option("-f, --force", "Move the domain here if it's attached to another of your services")
49
+ .option("-f, --force", "Move the domain here if it's attached to another of your services; on a domain bought on Lizard, also replace its existing records without asking")
50
50
  .action(async (hostname: string | undefined, opts, _cmd) => {
51
51
  const projectId = await resolveProjectId(opts.project);
52
52
  const service = await getActiveService(opts.service, projectId);
@@ -108,7 +108,7 @@ export function registerDomain(program: Command) {
108
108
  // Bought on Lizard: set the DNS record and attach, nothing for the user to copy.
109
109
  const owned = await findOwnedDomain(hostname);
110
110
  if (owned) {
111
- await connectOwned(owned, hostname, service);
111
+ await connectOwned(owned, hostname, service, !!opts.force);
112
112
  return;
113
113
  }
114
114
 
@@ -4,16 +4,18 @@ import { spawnSync } from "node:child_process";
4
4
  import { readFileSync, writeFileSync, existsSync, statSync } from "node:fs";
5
5
  import { basename } from "node:path";
6
6
  import { APIError } from "../lib/api.js";
7
- import { validToken } from "../lib/auth.js";
8
7
  import { isJSONMode, printJSON, table, success, info, warn, link } from "../lib/format.js";
9
8
  import {
10
- lg, qs, seg, segs, resolveRepo, repoPath, myLogin, cloneURL, gitURL, gitURLs, helperCommand, ago, ACTIVE_RUN,
9
+ lg, qs, seg, segs, resolveRepo, repoPath, myLogin, gitURL, ago, ACTIVE_RUN,
11
10
  } from "../lib/lizard-git.js";
11
+ import * as wc from "../lib/checkout.js";
12
12
 
13
13
  /**
14
14
  * Lizard Git (lizard.build/git) from the terminal: repositories, issues, pull requests,
15
15
  * reviews, CI runs, releases, secrets, collaborators, webhooks, branch rules, labels,
16
- * notifications, tokens, and the credential helper that lets plain `git` push and pull.
16
+ * notifications, tokens; and working copies: clone, status, diff, commit, sync, switch. Lizard
17
+ * Git is Scion-style (no git protocol): a checkout holds files and .lizard-git/checkout.json, and
18
+ * every commit is made on the server (lib/checkout.ts).
17
19
  *
18
20
  * Registered under `lizard git`, next to the GitHub integration commands.
19
21
  */
@@ -55,69 +57,163 @@ function bodyFrom(opts: { body?: string; bodyFile?: string }): string | undefine
55
57
 
56
58
  const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
57
59
 
60
+ /** The checkout this directory is in, or a clear error. */
61
+ function needCheckout(): string {
62
+ const root = wc.findCheckout();
63
+ if (!root) throw new APIError(400, "not in a Lizard Git checkout (lizard git clone owner/name)");
64
+ return root;
65
+ }
66
+
67
+ /** `lizard git status` inside a checkout: your changes. */
68
+ export function checkoutStatus(): boolean {
69
+ const root = wc.findCheckout();
70
+ if (!root) return false;
71
+ const c = wc.loadCheckout(root);
72
+ const ch = wc.changesOf(c, wc.scanWorking(root, c));
73
+ out({ repo: c.repo, branch: c.branch, commit: c.base, sparse: c.sparse, ...ch }, () => {
74
+ info(`${chalk.bold(c.repo)} on ${chalk.cyan(c.branch)} at ${c.base.slice(0, 12)}${c.sparse ? chalk.dim(` (sparse: ${c.sparse.join(", ")})`) : ""}`);
75
+ const n = ch.added.length + ch.modified.length + ch.deleted.length;
76
+ if (!n) return info(chalk.dim("no changes"));
77
+ for (const p of ch.modified) info(` ${chalk.yellow("modified")} ${p}`);
78
+ for (const p of ch.added) info(` ${chalk.green("added")} ${p}`);
79
+ for (const p of ch.deleted) info(` ${chalk.red("deleted")} ${p}`);
80
+ info(chalk.dim(` commit them: lizard git commit -m "..."`));
81
+ });
82
+ return true;
83
+ }
84
+
58
85
  function repoOpt(c: Command): Command {
59
- return c.option("-R, --repo <owner/name>", "Repository (default: the Lizard Git remote of this checkout)");
86
+ return c.option("-R, --repo <owner/name>", "Repository (default: the one this checkout is of)");
60
87
  }
61
88
 
62
89
  export function registerLizardGit(git: Command) {
63
90
  git.description("Lizard Git (lizard.build/git) repositories, pull requests, CI; and GitHub integration");
64
91
 
65
- // ── Sign-in for plain git ────────────────────────────────────────────
92
+ // ── Working copies ───────────────────────────────────────────────────
66
93
  git
67
- .command("setup")
68
- .description("Let git push and pull Lizard Git repositories with your Lizard sign-in (credential helper)")
69
- .option("--local", "Only for the repository in this directory")
70
- .action(async (opts: { local?: boolean }) => {
71
- const scope = opts.local ? "--local" : "--global";
72
- // For lizard.build/git only (git matches the path: the rest of lizard.build never gets
73
- // the helper), and the old git.lizard.build for clones made before.
74
- for (const url of gitURLs()) {
75
- // An empty value first resets helpers inherited for this address; then ours.
76
- for (const value of ["", helperCommand()]) {
77
- const r = spawnSync("git", ["config", scope, value ? "--add" : "--replace-all", `credential.${url}.helper`, value], { encoding: "utf8" });
78
- if (r.status !== 0) throw new APIError(500, `git config failed: ${r.stderr || r.error?.message}`);
79
- }
80
- spawnSync("git", ["config", scope, `credential.${url}.username`, "lizard"]);
81
- }
82
- const who = await myLogin();
83
- out({ helper: helperCommand(), scope: opts.local ? "local" : "global", host: gitURL(), login: who }, () => {
84
- success(`git now signs in to ${gitURL()} as ${chalk.bold(who)} (${opts.local ? "this repository" : "every repository"})`);
85
- info(chalk.dim(` Try: lizard git clone ${who}/<repo>`));
86
- });
94
+ .command("clone")
95
+ .description("Check out a Lizard Git repository: only files, fetched on demand by hash (no history to download)")
96
+ .argument("<repo>", "owner/name, or name for one of yours")
97
+ .argument("[dir]", "Directory (default: the repository name)")
98
+ .option("-b, --branch <name>", "Branch (default: the repository's default branch)")
99
+ .option("--sparse <paths>", "Only these folders or files, comma-separated (a sparse checkout fetches nothing else)")
100
+ .action(async (repoArg: string, dir: string | undefined, opts: { branch?: string; sparse?: string }) => {
101
+ const repo = await resolveRepo(repoArg);
102
+ const target = dir ?? repo.split("/")[1];
103
+ const sparse = list(opts.sparse)?.map((x) => x.replace(/^\/+|\/+$/g, "")) ?? null;
104
+ const c = await wc.clone(repo, target, opts.branch, sparse, (m) => !isJSONMode() && process.stderr.write(chalk.dim(` ${m}\r`)));
105
+ if (!isJSONMode()) process.stderr.write("\x1b[2K");
106
+ done({ repo, dir: target, branch: c.branch, commit: c.base, files: Object.keys(c.files).length, sparse },
107
+ `Checked out ${repo} (${c.branch} at ${c.base.slice(0, 12)}, ${Object.keys(c.files).length} files) into ${target}`);
87
108
  });
88
109
 
89
110
  git
90
- .command("credential")
91
- .description("git credential helper (used by `lizard git setup`; not run by hand)")
92
- .argument("[operation]", "get, store or erase")
93
- .action(async (op?: string) => {
94
- if (op !== "get") return; // store/erase: nothing kept, nothing to forget
95
- const input = readFileSync(0, "utf8");
96
- const fields = Object.fromEntries(input.split("\n").filter(Boolean).map((l) => l.split(/=(.*)/s).slice(0, 2)));
97
- // Only for Lizard Git itself, over the same protocol (git always says both).
98
- if (!gitURLs().some((u) => { const w = new URL(u); return fields.host === w.host && fields.protocol === w.protocol.replace(":", ""); })) return;
99
- const token = process.env.LIZARD_GIT_TOKEN || validToken();
100
- if (!token) {
101
- process.stderr.write("lizard: not signed in; run `lizard login`\n");
111
+ .command("init")
112
+ .description("Make this folder the checkout of an empty repository (then lizard git commit)")
113
+ .argument("<repo>", "owner/name, or name for one of yours")
114
+ .action(async (repoArg: string) => {
115
+ const repo = await resolveRepo(repoArg);
116
+ const c = await wc.initHere(repo, process.cwd());
117
+ done({ repo, branch: c.branch }, `This folder is now ${repo} (${c.branch}); commit with lizard git commit -m "..."`);
118
+ });
119
+
120
+ git
121
+ .command("diff")
122
+ .description("Your changes in this checkout against the commit it's at")
123
+ .argument("[paths...]", "Only these files or folders")
124
+ .option("--name-only", "Only the changed paths")
125
+ .action(async (paths: string[], opts: { nameOnly?: boolean }) => {
126
+ const root = needCheckout();
127
+ const c = wc.loadCheckout(root);
128
+ const local = wc.scanWorking(root, c);
129
+ const ch = wc.changesOf(c, local);
130
+ const pick = (p: string) => !paths.length || paths.some((x) => p === x || p.startsWith(x.replace(/\/+$/, "") + "/"));
131
+ const all = [...ch.added.map((p) => ["added", p]), ...ch.modified.map((p) => ["modified", p]), ...ch.deleted.map((p) => ["deleted", p])].filter(([, p]) => pick(p));
132
+ if (isJSONMode() || opts.nameOnly) {
133
+ out(all.map(([status, path]) => ({ status, path })), () => all.forEach(([, p]) => console.log(p)));
102
134
  return;
103
135
  }
104
- process.stdout.write(`username=lizard\npassword=${token}\n`);
136
+ const before = await wc.getBlobs(c.repo, all.filter(([k]) => k !== "added").map(([, p]) => c.files[p].h));
137
+ for (const [kind, p] of all) {
138
+ const old = kind === "added" ? "" : before.get(c.files[p].h)!.toString();
139
+ const cur = kind === "deleted" ? "" : readFileSync(`${root}/${p}`, "utf8");
140
+ if (old.includes("\0") || cur.includes("\0")) {
141
+ console.log(chalk.bold(`Binary ${p} ${kind}`));
142
+ continue;
143
+ }
144
+ if (old.length + cur.length > 4 << 20) {
145
+ console.log(chalk.bold(`${p}: ${kind} (too large to show)`));
146
+ continue;
147
+ }
148
+ for (const line of wc.unifiedDiff(old, cur, p).split("\n")) {
149
+ console.log(line.startsWith("+") && !line.startsWith("+++") ? chalk.green(line) : line.startsWith("-") && !line.startsWith("---") ? chalk.red(line) : line.startsWith("@@") ? chalk.cyan(line) : line);
150
+ }
151
+ }
105
152
  });
106
153
 
107
154
  git
108
- .command("clone")
109
- .description("Clone a Lizard Git repository (signed in with your Lizard account)")
110
- .argument("<repo>", "owner/name, or name for one of yours")
111
- .argument("[dir]", "Directory (default: the repository name)")
112
- .action(async (repoArg: string, dir?: string) => {
113
- const repo = await resolveRepo(repoArg);
114
- // Kept in the clone's config (--config applies before the fetch too): an empty helper
115
- // first, so helpers set globally for this host don't answer instead.
116
- const args = ["clone", "--config", `credential.${gitURL()}.helper=`, "--config", `credential.${gitURL()}.helper=${helperCommand()}`, cloneURL(repo)];
117
- if (dir) args.push(dir);
118
- const r = spawnSync("git", args, { stdio: isJSONMode() ? ["ignore", "ignore", "inherit"] : "inherit" });
119
- if (r.status !== 0) throw new APIError(500, `git clone failed (exit ${r.status})`);
120
- done({ repo, dir: dir ?? repo.split("/")[1], url: cloneURL(repo) }, `Cloned ${repo}`);
155
+ .command("commit")
156
+ .description("Commit your changes on the server (nothing to push): if the branch moved, the server puts them on top")
157
+ .argument("[paths...]", "Only these files or folders (default: every change)")
158
+ .requiredOption("-m, --message <text>", "Commit message")
159
+ .option("-b, --branch <name>", "Commit to a new branch, starting at this checkout's commit (and switch to it)")
160
+ .action(async (paths: string[], opts: { message: string; branch?: string }) => {
161
+ const root = needCheckout();
162
+ const c = wc.loadCheckout(root);
163
+ const r = await wc.commit(root, c, opts.message.endsWith("\n") ? opts.message : opts.message + "\n", paths, opts.branch);
164
+ const extra = r.sync.updated.length + r.sync.removed.length;
165
+ done(r, `Committed ${r.files} file(s) to ${r.branch}: ${r.sha.slice(0, 12)}${r.uploaded ? chalk.dim(` (${r.uploaded} uploaded)`) : ""}${extra ? chalk.dim(` · ${extra} file(s) from others' commits updated`) : ""}`);
166
+ });
167
+
168
+ git
169
+ .command("sync")
170
+ .alias("pull")
171
+ .description("Bring this checkout to the branch's latest commit (keeps your changes)")
172
+ .action(async () => {
173
+ const root = needCheckout();
174
+ const c = wc.loadCheckout(root);
175
+ const r = await wc.sync(root, c);
176
+ done(r, r.from === r.to ? `Already at ${c.branch}'s latest (${r.to.slice(0, 12)})` :
177
+ `${c.branch}: ${r.from.slice(0, 12)} -> ${r.to.slice(0, 12)} (${r.updated.length} updated, ${r.removed.length} removed)`);
178
+ });
179
+
180
+ git
181
+ .command("switch")
182
+ .description("Switch this checkout to another branch (keeps your changes)")
183
+ .argument("<branch>")
184
+ .option("-c, --create", "Create the branch at this checkout's commit first")
185
+ .action(async (branch: string, opts: { create?: boolean }) => {
186
+ const root = needCheckout();
187
+ const c = wc.loadCheckout(root);
188
+ if (opts.create) await lg.post(`${repoPath(c.repo)}/branches`, { name: branch, from: c.base });
189
+ const r = await wc.sync(root, c, branch);
190
+ done({ ...r, branch }, `On ${branch} at ${r.to.slice(0, 12)} (${r.updated.length} updated, ${r.removed.length} removed)`);
191
+ });
192
+
193
+ git
194
+ .command("sparse")
195
+ .description("Change which folders this checkout has (comma-separated; \"all\" for everything)")
196
+ .argument("<paths>")
197
+ .action(async (paths: string) => {
198
+ const root = needCheckout();
199
+ const c = wc.loadCheckout(root);
200
+ const local = wc.scanWorking(root, c);
201
+ const ch = wc.changesOf(c, local);
202
+ if (ch.added.length + ch.modified.length + ch.deleted.length) throw new APIError(409, "commit your changes first (lizard git commit)");
203
+ const next = paths === "all" ? null : list(paths)!.map((x) => x.replace(/^\/+|\/+$/g, ""));
204
+ // Drop what leaves the set, then sync with the new set (fetches what joins it).
205
+ const keep = (p: string) => !next || next.some((x) => p === x || p.startsWith(x + "/"));
206
+ for (const p of Object.keys(c.files)) {
207
+ if (!keep(p)) {
208
+ wc.removeFileAt(root, p);
209
+ delete c.files[p];
210
+ }
211
+ }
212
+ c.sparse = next;
213
+ const before = c.base;
214
+ c.base = ""; // make sync list and fetch everything in the new set
215
+ const r = await wc.sync(root, c);
216
+ done({ sparse: next, files: Object.keys(c.files).length, commit: r.to }, `Sparse: ${next ? next.join(", ") : "everything"} (${Object.keys(c.files).length} files at ${(r.to || before).slice(0, 12)})`);
121
217
  });
122
218
 
123
219
  // ── Repositories ─────────────────────────────────────────────────────
@@ -147,16 +243,16 @@ export function registerLizardGit(git: Command) {
147
243
  .argument("<name>")
148
244
  .option("--private", "Only you and collaborators can see it")
149
245
  .option("-d, --description <text>")
150
- .option("--readme", "Start with a README (otherwise empty, ready for a push)")
246
+ .option("--readme", "Start with a README (otherwise empty)")
151
247
  .option("--default-branch <name>", "Default branch (main)")
152
248
  .action(async (name: string, opts) => {
153
249
  const r = await lg.post<{ owner: string; name: string }>("/api/repos", {
154
250
  name, private: !!opts.private, description: opts.description, readme: !!opts.readme, defaultBranch: opts.defaultBranch,
155
251
  });
156
252
  const full = `${r.owner}/${r.name}`;
157
- out({ ...r, url: `${gitURL()}/${full}`, cloneUrl: cloneURL(full) }, () => {
253
+ out({ ...r, url: `${gitURL()}/${full}` }, () => {
158
254
  success(`Created ${chalk.bold(full)} ${link(`${gitURL()}/${full}`)}`);
159
- info(chalk.dim(` Clone: lizard git clone ${full} Push: git remote add origin ${cloneURL(full)}`));
255
+ info(chalk.dim(` Check out: lizard git clone ${full} then: lizard git commit -m "..."`));
160
256
  });
161
257
  });
162
258
  repoOpt(repo.command("view").description("A repository's details").argument("[repo]"))
@@ -168,7 +264,7 @@ export function registerLizardGit(git: Command) {
168
264
  if (m.repo.description) info(m.repo.description);
169
265
  if (m.forkOf) info(chalk.dim(`forked from ${m.forkOf}`));
170
266
  info(`default branch ${m.repo.defaultBranch} · ${m.branches} branches · ${m.tags} tags · ${m.openIssues} open issues · ${m.openPulls} open pull requests · ${m.repo.stars} stars`);
171
- info(`${link(`${gitURL()}/${r}`)}\nclone: ${m.cloneUrl}`);
267
+ info(`${link(`${gitURL()}/${r}`)}\ncheck out: lizard git clone ${r}`);
172
268
  });
173
269
  });
174
270
  repoOpt(repo.command("edit").description("Change name, description, visibility or default branch").argument("[repo]"))
@@ -176,13 +272,11 @@ export function registerLizardGit(git: Command) {
176
272
  .option("-d, --description <text>")
177
273
  .option("--private").option("--public")
178
274
  .option("--default-branch <name>")
179
- .option("--api-commits", "Also accept commits written through the API by agents without a checkout")
180
- .option("--no-api-commits", "Changes only by git push and merged pull requests (the default for new repositories)")
181
275
  .action(async (arg: string | undefined, opts) => {
182
276
  const r = await resolveRepo(arg ?? opts.repo);
183
277
  await lg.patch(repoPath(r), {
184
278
  name: opts.name, description: opts.description, defaultBranch: opts.defaultBranch,
185
- private: opts.private ? true : opts.public ? false : undefined, apiCommits: opts.apiCommits,
279
+ private: opts.private ? true : opts.public ? false : undefined,
186
280
  });
187
281
  done({ ok: true, repo: opts.name ? `${r.split("/")[0]}/${opts.name}` : r }, `Updated ${r}`);
188
282
  });
@@ -201,7 +295,7 @@ export function registerLizardGit(git: Command) {
201
295
  const r = await resolveRepo(arg ?? opts.repo);
202
296
  const f = await lg.post<{ owner: string; name: string }>(`${repoPath(r)}/forks`, { name: opts.name });
203
297
  const full = `${f.owner}/${f.name}`;
204
- if (opts.clone) spawnSync("git", ["clone", "--config", `credential.${gitURL()}.helper=${helperCommand()}`, cloneURL(full)], { stdio: "inherit" });
298
+ if (opts.clone) await wc.clone(full, f.name, undefined, null);
205
299
  done({ ...f, url: `${gitURL()}/${full}` }, `Forked ${r} to ${chalk.bold(full)}`);
206
300
  });
207
301
  repoOpt(repo.command("forks").description("Forks of a repository").argument("[repo]"))
@@ -339,9 +433,9 @@ export function registerLizardGit(git: Command) {
339
433
  if (pulls) {
340
434
  let head = opts.head;
341
435
  if (!head) {
342
- const b = spawnSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" });
343
- head = b.status === 0 ? b.stdout.trim() : "";
344
- if (!head || head === "HEAD") throw new APIError(400, "pass --head <branch>");
436
+ const root = wc.findCheckout();
437
+ head = root ? wc.loadCheckout(root).branch : "";
438
+ if (!head) throw new APIError(400, "pass --head <branch>");
345
439
  }
346
440
  Object.assign(body, { head, base: opts.base, headRepo: opts.headRepo, draft: !!opts.draft, reviewers: opts.reviewer.length ? opts.reviewer : undefined });
347
441
  }
@@ -530,20 +624,15 @@ export function registerLizardGit(git: Command) {
530
624
  });
531
625
  done(m, `Merged #${n} (${String(m.sha).slice(0, 7)})`);
532
626
  });
533
- repoOpt(grp.command("checkout").description("Check out a pull request's branch in this clone").argument("<number>", "", int))
627
+ repoOpt(grp.command("checkout").description("Switch this checkout to a pull request's branch").argument("<number>", "", int))
534
628
  .action(async (n: number, opts) => {
535
- const r = await resolveRepo(opts.repo);
536
- const d = await lg.get<any>(`${repoPath(r)}/issues/${n}`);
537
- const local = d.issue.headRepo && d.issue.headRepo !== r ? `pr-${n}` : d.issue.headRef;
538
- const remoteRef = d.issue.headRepo && d.issue.headRepo !== r ? `refs/pull/${n}/head` : `refs/heads/${d.issue.headRef}`;
539
- const run = (args: string[]) => {
540
- const x = spawnSync("git", args, { stdio: isJSONMode() ? ["ignore", "ignore", "inherit"] : "inherit" });
541
- if (x.status !== 0) throw new APIError(500, `git ${args[0]} failed`);
542
- };
543
- run(["fetch", "origin", `+${remoteRef}:refs/remotes/origin/${local}`]);
544
- const exists = spawnSync("git", ["rev-parse", "--verify", "--quiet", `refs/heads/${local}`]).status === 0;
545
- run(exists ? ["checkout", local] : ["checkout", "-b", local, "--track", `origin/${local}`]);
546
- done({ branch: local }, `On branch ${local} (#${n})`);
629
+ const root = needCheckout();
630
+ const c = wc.loadCheckout(root);
631
+ const d = await lg.get<any>(`${repoPath(c.repo)}/issues/${n}`);
632
+ // A fork's branch is read here under refs/pull/<n>/head: look, don't commit.
633
+ const target = d.issue.headRepo && d.issue.headRepo !== c.repo ? `pull/${n}/head` : d.issue.headRef;
634
+ const r = await wc.sync(root, c, target);
635
+ done({ branch: target, commit: r.to }, `On ${target} (#${n}) at ${r.to.slice(0, 12)}`);
547
636
  });
548
637
  }
549
638
 
@@ -7,7 +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
+ import { registerLizardGit, checkoutStatus } from "./git-hosting.js";
11
11
 
12
12
  interface GitHubInstallation {
13
13
  id: number;
@@ -171,9 +171,10 @@ export function registerGit(program: Command) {
171
171
  // lizard git status
172
172
  git
173
173
  .command("status")
174
- .description("GitHub: connection and the repositories this project's services deploy from")
174
+ .description("In a Lizard Git checkout: your changes. Elsewhere: GitHub connection and the repositories this project's services deploy from")
175
175
  .option("-p, --project <id>", "Project name, slug, or ID")
176
176
  .action(async (opts) => {
177
+ if (!opts.project && checkoutStatus()) return;
177
178
  const { projectId, scope } = await resolveProjectScope(opts.project);
178
179
 
179
180
  const [githubStatus, services] = await Promise.all([
package/src/index.ts CHANGED
@@ -146,9 +146,8 @@ 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
+ // Lizard Git with its own token (LIZARD_GIT_TOKEN: CI, agents): no Lizard sign-in needed.
150
+ if (topLevel.name() === "git" && process.env.LIZARD_GIT_TOKEN) return;
152
151
 
153
152
  // Require auth — auto-triggers login flow if not logged in
154
153
  const creds = await requireAuth();