CLI: add --help to all commands, dedup shared code, add grove status

- Every command and subcommand now responds to --help with usage text
- Extract buildSlConfig to shared sl-config.ts (was duplicated in init + clone)
- Extract CI formatting (status colors, icons, duration) to shared format.ts
- Add 'grove status' command showing repo name, owner, branch, URL
- Add --branch flag to init, --private flag to repo create for consistency
Anton Kaminsky23d ago0d9d72340388parent b5baf6d
9 files changed+308-144
cli/src/cli.ts
@@ -6,6 +6,7 @@
66import { whoami } from "./commands/whoami.js";
77import { clone } from "./commands/clone.js";
88import { init } from "./commands/init.js";
9import { status } from "./commands/status.js";
910import { repoList } from "./commands/repo-list.js";
1011import { repoCreate } from "./commands/repo-create.js";
1112import { instanceCreate } from "./commands/instance-create.js";
@@ -20,6 +21,7 @@
2021Commands:
2122 init Create a Grove repo and initialize Sapling in current directory
2223 clone Clone a Grove repository
24 status Show current repository info
2325 auth login Authenticate with Grove (opens browser)
2426 auth status Show current authentication status
2527 whoami Show current user
@@ -36,11 +38,127 @@
3638 --help Show this help message
3739 --version Show version`;
3840
41const COMMAND_HELP: Record<string, string> = {
42 init: `Usage: grove init [directory] [options]
43
44Create a Grove repository and initialize Sapling in the current directory.
45If the directory contains a .git repo, imports full git history.
46
47Options:
48 --owner <org> Set the repository owner (org name)
49 --description <desc> Set the repository description
50 --private Create a private repository
51 --branch <branch> Set the default branch name`,
52
53 clone: `Usage: grove clone <owner/repo|repo> [destination]
54
55Clone a Grove repository into a new directory.
56If only a repo name is given (no owner/), looks up the owner via the API.`,
57
58 status: `Usage: grove status
59
60Show info about the current Grove repository (name, owner, branch, URL).`,
61
62 whoami: `Usage: grove whoami
63
64Show the currently authenticated user.`,
65
66 "auth login": `Usage: grove auth login [--hub <url>]
67
68Authenticate with Grove by opening the browser.
69
70Options:
71 --hub <url> Override the Grove hub URL`,
72
73 "auth status": `Usage: grove auth status
74
75Show current authentication status, token expiry, and hub URL.`,
76
77 "repo list": `Usage: grove repo list
78
79List all repositories you have access to.`,
80
81 "repo create": `Usage: grove repo create <name> [options]
82
83Create a new repository on Grove without initializing a local directory.
84Use 'grove init' to create and initialize in one step.
85
86Options:
87 --owner <org> Set the repository owner (org name)
88 --description <desc> Set the repository description
89 --branch <branch> Set the default branch name
90 --private Create a private repository
91 --no-seed Skip creating an initial commit`,
92
93 "instance create": `Usage: grove instance create --region <region> --size <size> [options]
94
95Register a new Grove instance.
96
97Options:
98 --name <name> Instance name (default: "grove")
99 --region <region> Region (required)
100 --size <size> Instance size (required)
101 --ip <ip> IP address
102 --domain <domain> Domain name`,
103
104 "ci runs": `Usage: grove ci runs [options]
105
106List pipeline runs for the current repository.
107
108Options:
109 --repo <owner/repo> Specify repository (default: inferred from .sl/config)
110 --status <status> Filter by status (passed, failed, running, pending)
111 --limit <n> Number of runs to show (default: 10)`,
112
113 "ci status": `Usage: grove ci status [run-id] [options]
114
115Show details and steps for a pipeline run.
116If no run ID is given, shows the latest run.
117
118Options:
119 --repo <owner/repo> Specify repository (default: inferred from .sl/config)`,
120
121 "ci logs": `Usage: grove ci logs <run-id> <step-index> [options]
122
123Show logs for a specific step in a pipeline run.
124
125Options:
126 --repo <owner/repo> Specify repository (default: inferred from .sl/config)`,
127
128 "ci trigger": `Usage: grove ci trigger [options]
129
130Manually trigger pipelines for the current repository.
131
132Options:
133 --repo <owner/repo> Specify repository (default: inferred from .sl/config)
134 --ref <branch> Branch to trigger on`,
135
136 "ci cancel": `Usage: grove ci cancel <run-id> [options]
137
138Cancel a running pipeline.
139
140Options:
141 --repo <owner/repo> Specify repository (default: inferred from .sl/config)`,
142};
143
144function showHelp(command: string): boolean {
145 const help = COMMAND_HELP[command];
146 if (help) {
147 console.log(help);
148 return true;
149 }
150 return false;
151}
152
39153const args = process.argv.slice(2);
40154const cmd = args[0];
41155const sub = args[1];
42156const rest = args.slice(2);
43157
158function wantsHelp(a: string[]): boolean {
159 return a.includes("--help") || a.includes("-h");
160}
161
44162async function main() {
45163 if (!cmd || cmd === "--help" || cmd === "-h") {
46164 console.log(USAGE);
@@ -58,17 +176,42 @@
58176 }
59177
60178 if (cmd === "auth") {
179 if (wantsHelp(args)) {
180 const key = sub && !sub.startsWith("-") ? `auth ${sub}` : undefined;
181 if (key && showHelp(key)) process.exit(0);
182 console.log(`Usage: grove auth <login|status>\n\nSubcommands:\n login Authenticate with Grove (opens browser)\n status Show current authentication status`);
183 process.exit(0);
184 }
61185 if (sub === "login") return authLogin(rest);
62186 if (sub === "status") return authStatus();
63187 log.error(`Unknown command: auth ${sub || ""}\nRun: grove auth --help`);
64188 process.exit(1);
65189 }
66190
67 if (cmd === "init") return init(args.slice(1));
68 if (cmd === "clone") return clone(args.slice(1));
69 if (cmd === "whoami") return whoami();
191 if (cmd === "init") {
192 if (wantsHelp(args)) { showHelp("init"); process.exit(0); }
193 return init(args.slice(1));
194 }
195 if (cmd === "clone") {
196 if (wantsHelp(args)) { showHelp("clone"); process.exit(0); }
197 return clone(args.slice(1));
198 }
199 if (cmd === "status") {
200 if (wantsHelp(args)) { showHelp("status"); process.exit(0); }
201 return status();
202 }
203 if (cmd === "whoami") {
204 if (wantsHelp(args)) { showHelp("whoami"); process.exit(0); }
205 return whoami();
206 }
70207
71208 if (cmd === "repo") {
209 if (wantsHelp(args)) {
210 const key = sub && !sub.startsWith("-") ? `repo ${sub}` : undefined;
211 if (key && showHelp(key)) process.exit(0);
212 console.log(`Usage: grove repo <list|create>\n\nSubcommands:\n list List repositories\n create Create a repository`);
213 process.exit(0);
214 }
72215 if (sub === "list" || sub === "ls") return repoList();
73216 if (sub === "create") return repoCreate(rest);
74217 log.error(`Unknown command: repo ${sub || ""}\nRun: grove repo --help`);
@@ -76,12 +219,24 @@
76219 }
77220
78221 if (cmd === "instance") {
222 if (wantsHelp(args)) {
223 const key = sub && !sub.startsWith("-") ? `instance ${sub}` : undefined;
224 if (key && showHelp(key)) process.exit(0);
225 console.log(`Usage: grove instance <create>\n\nSubcommands:\n create Register a Grove instance`);
226 process.exit(0);
227 }
79228 if (sub === "create") return instanceCreate(rest);
80229 log.error(`Unknown command: instance ${sub || ""}\nRun: grove instance --help`);
81230 process.exit(1);
82231 }
83232
84233 if (cmd === "ci") {
234 if (wantsHelp(args)) {
235 const key = sub && !sub.startsWith("-") ? `ci ${sub}` : undefined;
236 if (key && showHelp(key)) process.exit(0);
237 console.log(`Usage: grove ci <runs|status|logs|trigger|cancel>\n\nSubcommands:\n runs List pipeline runs\n status Show pipeline run details\n logs Show step logs\n trigger Manually trigger pipelines\n cancel Cancel a running pipeline`);
238 process.exit(0);
239 }
85240 if (sub === "runs" || sub === "ls") return ciRuns(rest);
86241 if (sub === "status" || sub === "show") return ciStatus(rest);
87242 if (sub === "logs" || sub === "log") return ciLogs(rest);
88243
cli/src/commands/ci-runs.ts
@@ -1,6 +1,7 @@
11import { spinner, log } from "@clack/prompts";
22import { hubRequest } from "../api.js";
33import { getRepoSlug } from "../config.js";
4import { STATUS_COLORS, colorStatus, formatDuration, formatTime } from "../format.js";
45
56interface PipelineRun {
67 id: number;
@@ -14,34 +15,6 @@
1415 created_at: string;
1516}
1617
17const STATUS_COLORS: Record<string, string> = {
18 passed: "\x1b[32m", // green
19 failed: "\x1b[31m", // red
20 running: "\x1b[33m", // yellow
21 pending: "\x1b[90m", // gray
22 cancelled: "\x1b[90m",
23 skipped: "\x1b[90m",
24};
25const RESET = "\x1b[0m";
26
27function colorStatus(status: string): string {
28 return `${STATUS_COLORS[status] ?? ""}${status}${RESET}`;
29}
30
31function formatDuration(ms: number | null): string {
32 if (!ms) return "-";
33 if (ms < 1000) return `${ms}ms`;
34 const s = Math.round(ms / 1000);
35 if (s < 60) return `${s}s`;
36 return `${Math.floor(s / 60)}m${s % 60}s`;
37}
38
39function formatTime(iso: string | null): string {
40 if (!iso) return "-";
41 const d = new Date(iso + "Z");
42 return d.toLocaleString();
43}
44
4518export async function ciRuns(args: string[]) {
4619 const slug = await getRepoSlug(args);
4720
4821
cli/src/commands/ci-status.ts
@@ -1,6 +1,7 @@
11import { spinner, log, note } from "@clack/prompts";
22import { hubRequest } from "../api.js";
33import { getRepoSlug } from "../config.js";
4import { STATUS_COLORS, STATUS_ICONS, RESET, formatDurationRange } from "../format.js";
45
56interface PipelineRun {
67 id: number;
@@ -23,36 +24,6 @@
2324 step_index: number;
2425}
2526
26const STATUS_ICONS: Record<string, string> = {
27 passed: "\x1b[32m✓\x1b[0m",
28 failed: "\x1b[31m✗\x1b[0m",
29 running: "\x1b[33m●\x1b[0m",
30 pending: "\x1b[90m○\x1b[0m",
31 cancelled: "\x1b[90m⊘\x1b[0m",
32 skipped: "\x1b[90m-\x1b[0m",
33};
34
35const STATUS_COLORS: Record<string, string> = {
36 passed: "\x1b[32m",
37 failed: "\x1b[31m",
38 running: "\x1b[33m",
39 pending: "\x1b[90m",
40 cancelled: "\x1b[90m",
41 skipped: "\x1b[90m",
42};
43const RESET = "\x1b[0m";
44
45function formatDuration(start: string | null, end: string | null): string {
46 if (!start) return "";
47 const s = new Date(start + "Z").getTime();
48 const e = end ? new Date(end + "Z").getTime() : Date.now();
49 const ms = e - s;
50 if (ms < 1000) return `${ms}ms`;
51 const secs = Math.round(ms / 1000);
52 if (secs < 60) return `${secs}s`;
53 return `${Math.floor(secs / 60)}m${secs % 60}s`;
54}
55
5627export async function ciStatus(args: string[]) {
5728 const slug = await getRepoSlug(args);
5829
@@ -93,7 +64,7 @@
9364 // Steps
9465 for (const step of steps) {
9566 const icon = STATUS_ICONS[step.status] ?? "?";
96 const dur = formatDuration(step.started_at, step.finished_at);
67 const dur = formatDurationRange(step.started_at, step.finished_at);
9768 const durStr = dur ? ` (${dur})` : "";
9869 log.message(`${icon} ${step.step_index}. ${step.name}${durStr}`);
9970 }
10071
cli/src/commands/clone.ts
@@ -1,10 +1,10 @@
11import { spinner, log } from "@clack/prompts";
22import { execSync } from "node:child_process";
3import { writeFileSync, mkdirSync, existsSync } from "node:fs";
3import { writeFileSync, existsSync } from "node:fs";
44import { join, resolve } from "node:path";
5import { homedir } from "node:os";
65import { hubRequest } from "../api.js";
76import { getHub } from "../config.js";
7import { buildSlConfig } from "../sl-config.js";
88
99interface Repo {
1010 id: number;
@@ -66,41 +66,6 @@
6666
6767 // Write proper .sl/config
6868 const hub = await getHub();
69 const tlsDir = join(homedir(), ".grove", "tls");
70 const config = `[paths]
71default = slapi:${repoName}
72
73[remotefilelog]
74reponame = ${repoName}
75
76[grove]
77owner = ${owner}
78
79[auth]
80grove.prefix = ${hub}
81grove.cert = ${tlsDir}/client.crt
82grove.key = ${tlsDir}/client.key
83grove.cacerts = ${tlsDir}/ca.crt
84
85grove-mononoke.prefix = mononoke://grove.host
86grove-mononoke.cert = ${tlsDir}/client.crt
87grove-mononoke.key = ${tlsDir}/client.key
88grove-mononoke.cacerts = ${tlsDir}/ca.crt
89
90[edenapi]
91url = ${hub.replace(/\/$/, "")}:8443/edenapi/
92
93[clone]
94use-commit-graph = true
95
96[remotenames]
97selectivepulldefault = ${repo.default_branch}
98
99[push]
100edenapi = true
101to = ${repo.default_branch}
102`;
103
104 writeFileSync(join(destPath, ".sl", "config"), config);
69 writeFileSync(join(destPath, ".sl", "config"), buildSlConfig({ name: repoName, owner_name: owner, default_branch: repo.default_branch }, hub));
10570 log.success(`Cloned ${owner}/${repoName} into ${dest}`);
10671}
10772
cli/src/commands/init.ts
@@ -2,9 +2,9 @@
22import { execSync } from "node:child_process";
33import { writeFileSync, existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
44import { join, basename, resolve } from "node:path";
5import { homedir } from "node:os";
65import { hubRequest, hubUploadStream } from "../api.js";
76import { getHub } from "../config.js";
7import { buildSlConfig } from "../sl-config.js";
88
99interface Repo {
1010 id: number;
@@ -25,43 +25,6 @@
2525 display_name: string;
2626}
2727
28function buildSlConfig(repo: Repo, hub: string) {
29 const tlsDir = join(homedir(), ".grove", "tls");
30 return `[paths]
31default = slapi:${repo.name}
32
33[remotefilelog]
34reponame = ${repo.name}
35
36[grove]
37owner = ${repo.owner_name}
38
39[auth]
40grove.prefix = ${hub}
41grove.cert = ${tlsDir}/client.crt
42grove.key = ${tlsDir}/client.key
43grove.cacerts = ${tlsDir}/ca.crt
44
45grove-mononoke.prefix = mononoke://grove.host
46grove-mononoke.cert = ${tlsDir}/client.crt
47grove-mononoke.key = ${tlsDir}/client.key
48grove-mononoke.cacerts = ${tlsDir}/ca.crt
49
50[edenapi]
51url = ${hub.replace(/\/$/, "")}:8443/edenapi/
52
53[clone]
54use-commit-graph = true
55
56[remotenames]
57selectivepulldefault = ${repo.default_branch}
58
59[push]
60edenapi = true
61to = ${repo.default_branch}
62`;
63}
64
6528async function resolveOwner(args: string[]): Promise<string | undefined> {
6629 const ownerIdx = args.indexOf("--owner");
6730 if (ownerIdx !== -1) return args[ownerIdx + 1];
@@ -99,7 +62,7 @@
9962 return undefined;
10063}
10164
102async function createRepo(name: string, owner: string | undefined, description: string | undefined, isPrivate: boolean, skipSeed: boolean) {
65async function createRepo(name: string, owner: string | undefined, description: string | undefined, isPrivate: boolean, skipSeed: boolean, defaultBranch?: string) {
10366 const s = spinner();
10467 s.start("Creating repository and provisioning Mononoke");
10568 const messages = [
@@ -122,6 +85,7 @@
12285 owner,
12386 is_private: isPrivate,
12487 skip_seed: skipSeed,
88 default_branch: defaultBranch,
12589 }),
12690 });
12791 clearInterval(ticker);
@@ -130,7 +94,7 @@
13094}
13195
13296function parsePositional(args: string[]): string | undefined {
133 const flagsWithValues = new Set(["--owner", "--description"]);
97 const flagsWithValues = new Set(["--owner", "--description", "--branch"]);
13498 for (let i = 0; i < args.length; i++) {
13599 if (flagsWithValues.has(args[i])) { i++; continue; }
136100 if (args[i].startsWith("--")) continue;
@@ -146,6 +110,8 @@
146110
147111 const descIdx = args.indexOf("--description");
148112 const description = descIdx !== -1 ? args[descIdx + 1] : undefined;
113 const branchIdx = args.indexOf("--branch");
114 const defaultBranch = branchIdx !== -1 ? args[branchIdx + 1] : undefined;
149115 const isPrivate = args.includes("--private");
150116
151117 intro(`grove init ${name}${isGitRepo ? " (importing from git)" : ""}`);
@@ -155,7 +121,7 @@
155121 if (isGitRepo) {
156122 await initFromGit(dir, name, owner, description, isPrivate);
157123 } else {
158 await initFresh(dir, name, owner, description, isPrivate);
124 await initFresh(dir, name, owner, description, isPrivate, defaultBranch);
159125 }
160126}
161127
@@ -249,8 +215,8 @@
249215 outro(`Imported ${ownerName}/${name} with full git history`);
250216}
251217
252async function initFresh(dir: string, name: string, owner: string | undefined, description: string | undefined, isPrivate: boolean) {
253 const repo = await createRepo(name, owner, description, isPrivate, false);
218async function initFresh(dir: string, name: string, owner: string | undefined, description: string | undefined, isPrivate: boolean, defaultBranch?: string) {
219 const repo = await createRepo(name, owner, description, isPrivate, false, defaultBranch);
254220
255221 // Init Sapling repo if not already one
256222 if (!existsSync(join(dir, ".sl"))) {
257223
cli/src/commands/repo-create.ts
@@ -25,13 +25,14 @@
2525 const ownerIdx = args.indexOf("--owner");
2626 const owner = ownerIdx !== -1 ? args[ownerIdx + 1] : undefined;
2727
28 const is_private = args.includes("--private");
2829 const skip_seed = args.includes("--no-seed");
2930
3031 const s = spinner();
3132 s.start(`Creating repository '${name}'`);
3233 const { repo } = await hubRequest<{ repo: Repo }>("/api/repos", {
3334 method: "POST",
34 body: JSON.stringify({ name, description, default_branch, owner, skip_seed }),
35 body: JSON.stringify({ name, description, default_branch, owner, is_private, skip_seed }),
3536 });
3637 s.stop(`Created repository: ${repo.owner_name}/${repo.name}`);
3738}
3839
cli/src/commands/status.ts
@@ -0,0 +1,44 @@
1import { log } from "@clack/prompts";
2import { existsSync, readFileSync } from "node:fs";
3import { join, dirname } from "node:path";
4import { execSync } from "node:child_process";
5
6export async function status() {
7 let dir = process.cwd();
8 let slDir: string | null = null;
9 while (dir !== dirname(dir)) {
10 if (existsSync(join(dir, ".sl", "config"))) {
11 slDir = join(dir, ".sl");
12 break;
13 }
14 dir = dirname(dir);
15 }
16
17 if (!slDir) {
18 log.error("Not in a Grove repository.");
19 process.exit(1);
20 }
21
22 const config = readFileSync(join(slDir, "config"), "utf-8");
23 const repoMatch = config.match(/reponame\s*=\s*(.+)/);
24 const ownerMatch = config.match(/owner\s*=\s*(.+)/);
25 const branchMatch = config.match(/selectivepulldefault\s*=\s*(.+)/);
26
27 const repo = repoMatch?.[1]?.trim() ?? "unknown";
28 const owner = ownerMatch?.[1]?.trim() ?? "unknown";
29 const branch = branchMatch?.[1]?.trim() ?? "main";
30
31 let current = "";
32 try {
33 current = execSync("sl log -r . --template {bookmarks}", { cwd: dir, stdio: "pipe" }).toString().trim();
34 } catch {}
35
36 const lines = [
37 `Repo: ${owner}/${repo}`,
38 `Branch: ${branch}`,
39 ];
40 if (current) lines.push(`At: ${current}`);
41 lines.push(`URL: https://grove.host/${owner}/${repo}`);
42
43 log.info(lines.join("\n"));
44}
045
cli/src/format.ts
@@ -0,0 +1,44 @@
1export const STATUS_COLORS: Record<string, string> = {
2 passed: "\x1b[32m",
3 failed: "\x1b[31m",
4 running: "\x1b[33m",
5 pending: "\x1b[90m",
6 cancelled: "\x1b[90m",
7 skipped: "\x1b[90m",
8};
9
10export const STATUS_ICONS: Record<string, string> = {
11 passed: "\x1b[32m✓\x1b[0m",
12 failed: "\x1b[31m✗\x1b[0m",
13 running: "\x1b[33m●\x1b[0m",
14 pending: "\x1b[90m○\x1b[0m",
15 cancelled: "\x1b[90m⊘\x1b[0m",
16 skipped: "\x1b[90m-\x1b[0m",
17};
18
19export const RESET = "\x1b[0m";
20
21export function colorStatus(status: string): string {
22 return `${STATUS_COLORS[status] ?? ""}${status}${RESET}`;
23}
24
25export function formatDuration(ms: number | null): string {
26 if (!ms) return "-";
27 if (ms < 1000) return `${ms}ms`;
28 const s = Math.round(ms / 1000);
29 if (s < 60) return `${s}s`;
30 return `${Math.floor(s / 60)}m${s % 60}s`;
31}
32
33export function formatDurationRange(start: string | null, end: string | null): string {
34 if (!start) return "";
35 const s = new Date(start + "Z").getTime();
36 const e = end ? new Date(end + "Z").getTime() : Date.now();
37 return formatDuration(e - s);
38}
39
40export function formatTime(iso: string | null): string {
41 if (!iso) return "-";
42 const d = new Date(iso + "Z");
43 return d.toLocaleString();
44}
045
cli/src/sl-config.ts
@@ -0,0 +1,45 @@
1import { join } from "node:path";
2import { homedir } from "node:os";
3
4interface RepoConfig {
5 name: string;
6 owner_name: string;
7 default_branch: string;
8}
9
10export function buildSlConfig(repo: RepoConfig, hub: string): string {
11 const tlsDir = join(homedir(), ".grove", "tls");
12 return `[paths]
13default = slapi:${repo.name}
14
15[remotefilelog]
16reponame = ${repo.name}
17
18[grove]
19owner = ${repo.owner_name}
20
21[auth]
22grove.prefix = ${hub}
23grove.cert = ${tlsDir}/client.crt
24grove.key = ${tlsDir}/client.key
25grove.cacerts = ${tlsDir}/ca.crt
26
27grove-mononoke.prefix = mononoke://grove.host
28grove-mononoke.cert = ${tlsDir}/client.crt
29grove-mononoke.key = ${tlsDir}/client.key
30grove-mononoke.cacerts = ${tlsDir}/ca.crt
31
32[edenapi]
33url = ${hub.replace(/\/$/, "")}:8443/edenapi/
34
35[clone]
36use-commit-graph = true
37
38[remotenames]
39selectivepulldefault = ${repo.default_branch}
40
41[push]
42edenapi = true
43to = ${repo.default_branch}
44`;
45}
046