- 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
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | import { whoami } from "./commands/whoami.js"; |
| 7 | 7 | import { clone } from "./commands/clone.js"; |
| 8 | 8 | import { init } from "./commands/init.js"; |
| 9 | import { status } from "./commands/status.js"; | |
| 9 | 10 | import { repoList } from "./commands/repo-list.js"; |
| 10 | 11 | import { repoCreate } from "./commands/repo-create.js"; |
| 11 | 12 | import { instanceCreate } from "./commands/instance-create.js"; |
| @@ -20,6 +21,7 @@ | ||
| 20 | 21 | Commands: |
| 21 | 22 | init Create a Grove repo and initialize Sapling in current directory |
| 22 | 23 | clone Clone a Grove repository |
| 24 | status Show current repository info | |
| 23 | 25 | auth login Authenticate with Grove (opens browser) |
| 24 | 26 | auth status Show current authentication status |
| 25 | 27 | whoami Show current user |
| @@ -36,11 +38,127 @@ | ||
| 36 | 38 | --help Show this help message |
| 37 | 39 | --version Show version`; |
| 38 | 40 | |
| 41 | const COMMAND_HELP: Record<string, string> = { | |
| 42 | init: `Usage: grove init [directory] [options] | |
| 43 | ||
| 44 | Create a Grove repository and initialize Sapling in the current directory. | |
| 45 | If the directory contains a .git repo, imports full git history. | |
| 46 | ||
| 47 | Options: | |
| 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 | ||
| 55 | Clone a Grove repository into a new directory. | |
| 56 | If only a repo name is given (no owner/), looks up the owner via the API.`, | |
| 57 | ||
| 58 | status: `Usage: grove status | |
| 59 | ||
| 60 | Show info about the current Grove repository (name, owner, branch, URL).`, | |
| 61 | ||
| 62 | whoami: `Usage: grove whoami | |
| 63 | ||
| 64 | Show the currently authenticated user.`, | |
| 65 | ||
| 66 | "auth login": `Usage: grove auth login [--hub <url>] | |
| 67 | ||
| 68 | Authenticate with Grove by opening the browser. | |
| 69 | ||
| 70 | Options: | |
| 71 | --hub <url> Override the Grove hub URL`, | |
| 72 | ||
| 73 | "auth status": `Usage: grove auth status | |
| 74 | ||
| 75 | Show current authentication status, token expiry, and hub URL.`, | |
| 76 | ||
| 77 | "repo list": `Usage: grove repo list | |
| 78 | ||
| 79 | List all repositories you have access to.`, | |
| 80 | ||
| 81 | "repo create": `Usage: grove repo create <name> [options] | |
| 82 | ||
| 83 | Create a new repository on Grove without initializing a local directory. | |
| 84 | Use 'grove init' to create and initialize in one step. | |
| 85 | ||
| 86 | Options: | |
| 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 | ||
| 95 | Register a new Grove instance. | |
| 96 | ||
| 97 | Options: | |
| 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 | ||
| 106 | List pipeline runs for the current repository. | |
| 107 | ||
| 108 | Options: | |
| 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 | ||
| 115 | Show details and steps for a pipeline run. | |
| 116 | If no run ID is given, shows the latest run. | |
| 117 | ||
| 118 | Options: | |
| 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 | ||
| 123 | Show logs for a specific step in a pipeline run. | |
| 124 | ||
| 125 | Options: | |
| 126 | --repo <owner/repo> Specify repository (default: inferred from .sl/config)`, | |
| 127 | ||
| 128 | "ci trigger": `Usage: grove ci trigger [options] | |
| 129 | ||
| 130 | Manually trigger pipelines for the current repository. | |
| 131 | ||
| 132 | Options: | |
| 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 | ||
| 138 | Cancel a running pipeline. | |
| 139 | ||
| 140 | Options: | |
| 141 | --repo <owner/repo> Specify repository (default: inferred from .sl/config)`, | |
| 142 | }; | |
| 143 | ||
| 144 | function 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 | ||
| 39 | 153 | const args = process.argv.slice(2); |
| 40 | 154 | const cmd = args[0]; |
| 41 | 155 | const sub = args[1]; |
| 42 | 156 | const rest = args.slice(2); |
| 43 | 157 | |
| 158 | function wantsHelp(a: string[]): boolean { | |
| 159 | return a.includes("--help") || a.includes("-h"); | |
| 160 | } | |
| 161 | ||
| 44 | 162 | async function main() { |
| 45 | 163 | if (!cmd || cmd === "--help" || cmd === "-h") { |
| 46 | 164 | console.log(USAGE); |
| @@ -58,17 +176,42 @@ | ||
| 58 | 176 | } |
| 59 | 177 | |
| 60 | 178 | 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 | } | |
| 61 | 185 | if (sub === "login") return authLogin(rest); |
| 62 | 186 | if (sub === "status") return authStatus(); |
| 63 | 187 | log.error(`Unknown command: auth ${sub || ""}\nRun: grove auth --help`); |
| 64 | 188 | process.exit(1); |
| 65 | 189 | } |
| 66 | 190 | |
| 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 | } | |
| 70 | 207 | |
| 71 | 208 | 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 | } | |
| 72 | 215 | if (sub === "list" || sub === "ls") return repoList(); |
| 73 | 216 | if (sub === "create") return repoCreate(rest); |
| 74 | 217 | log.error(`Unknown command: repo ${sub || ""}\nRun: grove repo --help`); |
| @@ -76,12 +219,24 @@ | ||
| 76 | 219 | } |
| 77 | 220 | |
| 78 | 221 | 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 | } | |
| 79 | 228 | if (sub === "create") return instanceCreate(rest); |
| 80 | 229 | log.error(`Unknown command: instance ${sub || ""}\nRun: grove instance --help`); |
| 81 | 230 | process.exit(1); |
| 82 | 231 | } |
| 83 | 232 | |
| 84 | 233 | 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 | } | |
| 85 | 240 | if (sub === "runs" || sub === "ls") return ciRuns(rest); |
| 86 | 241 | if (sub === "status" || sub === "show") return ciStatus(rest); |
| 87 | 242 | if (sub === "logs" || sub === "log") return ciLogs(rest); |
| 88 | 243 | |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { spinner, log } from "@clack/prompts"; |
| 2 | 2 | import { hubRequest } from "../api.js"; |
| 3 | 3 | import { getRepoSlug } from "../config.js"; |
| 4 | import { STATUS_COLORS, colorStatus, formatDuration, formatTime } from "../format.js"; | |
| 4 | 5 | |
| 5 | 6 | interface PipelineRun { |
| 6 | 7 | id: number; |
| @@ -14,34 +15,6 @@ | ||
| 14 | 15 | created_at: string; |
| 15 | 16 | } |
| 16 | 17 | |
| 17 | const 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 | }; | |
| 25 | const RESET = "\x1b[0m"; | |
| 26 | ||
| 27 | function colorStatus(status: string): string { | |
| 28 | return `${STATUS_COLORS[status] ?? ""}${status}${RESET}`; | |
| 29 | } | |
| 30 | ||
| 31 | function 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 | ||
| 39 | function formatTime(iso: string | null): string { | |
| 40 | if (!iso) return "-"; | |
| 41 | const d = new Date(iso + "Z"); | |
| 42 | return d.toLocaleString(); | |
| 43 | } | |
| 44 | ||
| 45 | 18 | export async function ciRuns(args: string[]) { |
| 46 | 19 | const slug = await getRepoSlug(args); |
| 47 | 20 | |
| 48 | 21 | |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import { spinner, log, note } from "@clack/prompts"; |
| 2 | 2 | import { hubRequest } from "../api.js"; |
| 3 | 3 | import { getRepoSlug } from "../config.js"; |
| 4 | import { STATUS_COLORS, STATUS_ICONS, RESET, formatDurationRange } from "../format.js"; | |
| 4 | 5 | |
| 5 | 6 | interface PipelineRun { |
| 6 | 7 | id: number; |
| @@ -23,36 +24,6 @@ | ||
| 23 | 24 | step_index: number; |
| 24 | 25 | } |
| 25 | 26 | |
| 26 | const 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 | ||
| 35 | const 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 | }; | |
| 43 | const RESET = "\x1b[0m"; | |
| 44 | ||
| 45 | function 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 | ||
| 56 | 27 | export async function ciStatus(args: string[]) { |
| 57 | 28 | const slug = await getRepoSlug(args); |
| 58 | 29 | |
| @@ -93,7 +64,7 @@ | ||
| 93 | 64 | // Steps |
| 94 | 65 | for (const step of steps) { |
| 95 | 66 | 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); | |
| 97 | 68 | const durStr = dur ? ` (${dur})` : ""; |
| 98 | 69 | log.message(`${icon} ${step.step_index}. ${step.name}${durStr}`); |
| 99 | 70 | } |
| 100 | 71 | |
| @@ -1,10 +1,10 @@ | ||
| 1 | 1 | import { spinner, log } from "@clack/prompts"; |
| 2 | 2 | import { execSync } from "node:child_process"; |
| 3 | import { writeFileSync, mkdirSync, existsSync } from "node:fs"; | |
| 3 | import { writeFileSync, existsSync } from "node:fs"; | |
| 4 | 4 | import { join, resolve } from "node:path"; |
| 5 | import { homedir } from "node:os"; | |
| 6 | 5 | import { hubRequest } from "../api.js"; |
| 7 | 6 | import { getHub } from "../config.js"; |
| 7 | import { buildSlConfig } from "../sl-config.js"; | |
| 8 | 8 | |
| 9 | 9 | interface Repo { |
| 10 | 10 | id: number; |
| @@ -66,41 +66,6 @@ | ||
| 66 | 66 | |
| 67 | 67 | // Write proper .sl/config |
| 68 | 68 | const hub = await getHub(); |
| 69 | const tlsDir = join(homedir(), ".grove", "tls"); | |
| 70 | const config = `[paths] | |
| 71 | default = slapi:${repoName} | |
| 72 | ||
| 73 | [remotefilelog] | |
| 74 | reponame = ${repoName} | |
| 75 | ||
| 76 | [grove] | |
| 77 | owner = ${owner} | |
| 78 | ||
| 79 | [auth] | |
| 80 | grove.prefix = ${hub} | |
| 81 | grove.cert = ${tlsDir}/client.crt | |
| 82 | grove.key = ${tlsDir}/client.key | |
| 83 | grove.cacerts = ${tlsDir}/ca.crt | |
| 84 | ||
| 85 | grove-mononoke.prefix = mononoke://grove.host | |
| 86 | grove-mononoke.cert = ${tlsDir}/client.crt | |
| 87 | grove-mononoke.key = ${tlsDir}/client.key | |
| 88 | grove-mononoke.cacerts = ${tlsDir}/ca.crt | |
| 89 | ||
| 90 | [edenapi] | |
| 91 | url = ${hub.replace(/\/$/, "")}:8443/edenapi/ | |
| 92 | ||
| 93 | [clone] | |
| 94 | use-commit-graph = true | |
| 95 | ||
| 96 | [remotenames] | |
| 97 | selectivepulldefault = ${repo.default_branch} | |
| 98 | ||
| 99 | [push] | |
| 100 | edenapi = true | |
| 101 | to = ${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)); | |
| 105 | 70 | log.success(`Cloned ${owner}/${repoName} into ${dest}`); |
| 106 | 71 | } |
| 107 | 72 | |
| @@ -2,9 +2,9 @@ | ||
| 2 | 2 | import { execSync } from "node:child_process"; |
| 3 | 3 | import { writeFileSync, existsSync, mkdirSync, renameSync, rmSync } from "node:fs"; |
| 4 | 4 | import { join, basename, resolve } from "node:path"; |
| 5 | import { homedir } from "node:os"; | |
| 6 | 5 | import { hubRequest, hubUploadStream } from "../api.js"; |
| 7 | 6 | import { getHub } from "../config.js"; |
| 7 | import { buildSlConfig } from "../sl-config.js"; | |
| 8 | 8 | |
| 9 | 9 | interface Repo { |
| 10 | 10 | id: number; |
| @@ -25,43 +25,6 @@ | ||
| 25 | 25 | display_name: string; |
| 26 | 26 | } |
| 27 | 27 | |
| 28 | function buildSlConfig(repo: Repo, hub: string) { | |
| 29 | const tlsDir = join(homedir(), ".grove", "tls"); | |
| 30 | return `[paths] | |
| 31 | default = slapi:${repo.name} | |
| 32 | ||
| 33 | [remotefilelog] | |
| 34 | reponame = ${repo.name} | |
| 35 | ||
| 36 | [grove] | |
| 37 | owner = ${repo.owner_name} | |
| 38 | ||
| 39 | [auth] | |
| 40 | grove.prefix = ${hub} | |
| 41 | grove.cert = ${tlsDir}/client.crt | |
| 42 | grove.key = ${tlsDir}/client.key | |
| 43 | grove.cacerts = ${tlsDir}/ca.crt | |
| 44 | ||
| 45 | grove-mononoke.prefix = mononoke://grove.host | |
| 46 | grove-mononoke.cert = ${tlsDir}/client.crt | |
| 47 | grove-mononoke.key = ${tlsDir}/client.key | |
| 48 | grove-mononoke.cacerts = ${tlsDir}/ca.crt | |
| 49 | ||
| 50 | [edenapi] | |
| 51 | url = ${hub.replace(/\/$/, "")}:8443/edenapi/ | |
| 52 | ||
| 53 | [clone] | |
| 54 | use-commit-graph = true | |
| 55 | ||
| 56 | [remotenames] | |
| 57 | selectivepulldefault = ${repo.default_branch} | |
| 58 | ||
| 59 | [push] | |
| 60 | edenapi = true | |
| 61 | to = ${repo.default_branch} | |
| 62 | `; | |
| 63 | } | |
| 64 | ||
| 65 | 28 | async function resolveOwner(args: string[]): Promise<string | undefined> { |
| 66 | 29 | const ownerIdx = args.indexOf("--owner"); |
| 67 | 30 | if (ownerIdx !== -1) return args[ownerIdx + 1]; |
| @@ -99,7 +62,7 @@ | ||
| 99 | 62 | return undefined; |
| 100 | 63 | } |
| 101 | 64 | |
| 102 | async function createRepo(name: string, owner: string | undefined, description: string | undefined, isPrivate: boolean, skipSeed: boolean) { | |
| 65 | async function createRepo(name: string, owner: string | undefined, description: string | undefined, isPrivate: boolean, skipSeed: boolean, defaultBranch?: string) { | |
| 103 | 66 | const s = spinner(); |
| 104 | 67 | s.start("Creating repository and provisioning Mononoke"); |
| 105 | 68 | const messages = [ |
| @@ -122,6 +85,7 @@ | ||
| 122 | 85 | owner, |
| 123 | 86 | is_private: isPrivate, |
| 124 | 87 | skip_seed: skipSeed, |
| 88 | default_branch: defaultBranch, | |
| 125 | 89 | }), |
| 126 | 90 | }); |
| 127 | 91 | clearInterval(ticker); |
| @@ -130,7 +94,7 @@ | ||
| 130 | 94 | } |
| 131 | 95 | |
| 132 | 96 | function parsePositional(args: string[]): string | undefined { |
| 133 | const flagsWithValues = new Set(["--owner", "--description"]); | |
| 97 | const flagsWithValues = new Set(["--owner", "--description", "--branch"]); | |
| 134 | 98 | for (let i = 0; i < args.length; i++) { |
| 135 | 99 | if (flagsWithValues.has(args[i])) { i++; continue; } |
| 136 | 100 | if (args[i].startsWith("--")) continue; |
| @@ -146,6 +110,8 @@ | ||
| 146 | 110 | |
| 147 | 111 | const descIdx = args.indexOf("--description"); |
| 148 | 112 | const description = descIdx !== -1 ? args[descIdx + 1] : undefined; |
| 113 | const branchIdx = args.indexOf("--branch"); | |
| 114 | const defaultBranch = branchIdx !== -1 ? args[branchIdx + 1] : undefined; | |
| 149 | 115 | const isPrivate = args.includes("--private"); |
| 150 | 116 | |
| 151 | 117 | intro(`grove init ${name}${isGitRepo ? " (importing from git)" : ""}`); |
| @@ -155,7 +121,7 @@ | ||
| 155 | 121 | if (isGitRepo) { |
| 156 | 122 | await initFromGit(dir, name, owner, description, isPrivate); |
| 157 | 123 | } else { |
| 158 | await initFresh(dir, name, owner, description, isPrivate); | |
| 124 | await initFresh(dir, name, owner, description, isPrivate, defaultBranch); | |
| 159 | 125 | } |
| 160 | 126 | } |
| 161 | 127 | |
| @@ -249,8 +215,8 @@ | ||
| 249 | 215 | outro(`Imported ${ownerName}/${name} with full git history`); |
| 250 | 216 | } |
| 251 | 217 | |
| 252 | async function initFresh(dir: string, name: string, owner: string | undefined, description: string | undefined, isPrivate: boolean) { | |
| 253 | const repo = await createRepo(name, owner, description, isPrivate, false); | |
| 218 | async 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); | |
| 254 | 220 | |
| 255 | 221 | // Init Sapling repo if not already one |
| 256 | 222 | if (!existsSync(join(dir, ".sl"))) { |
| 257 | 223 | |
| @@ -25,13 +25,14 @@ | ||
| 25 | 25 | const ownerIdx = args.indexOf("--owner"); |
| 26 | 26 | const owner = ownerIdx !== -1 ? args[ownerIdx + 1] : undefined; |
| 27 | 27 | |
| 28 | const is_private = args.includes("--private"); | |
| 28 | 29 | const skip_seed = args.includes("--no-seed"); |
| 29 | 30 | |
| 30 | 31 | const s = spinner(); |
| 31 | 32 | s.start(`Creating repository '${name}'`); |
| 32 | 33 | const { repo } = await hubRequest<{ repo: Repo }>("/api/repos", { |
| 33 | 34 | 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 }), | |
| 35 | 36 | }); |
| 36 | 37 | s.stop(`Created repository: ${repo.owner_name}/${repo.name}`); |
| 37 | 38 | } |
| 38 | 39 | |
| @@ -0,0 +1,44 @@ | ||
| 1 | import { log } from "@clack/prompts"; | |
| 2 | import { existsSync, readFileSync } from "node:fs"; | |
| 3 | import { join, dirname } from "node:path"; | |
| 4 | import { execSync } from "node:child_process"; | |
| 5 | ||
| 6 | export 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 | } | |
| 0 | 45 | |
| @@ -0,0 +1,44 @@ | ||
| 1 | export 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 | ||
| 10 | export 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 | ||
| 19 | export const RESET = "\x1b[0m"; | |
| 20 | ||
| 21 | export function colorStatus(status: string): string { | |
| 22 | return `${STATUS_COLORS[status] ?? ""}${status}${RESET}`; | |
| 23 | } | |
| 24 | ||
| 25 | export 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 | ||
| 33 | export 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 | ||
| 40 | export function formatTime(iso: string | null): string { | |
| 41 | if (!iso) return "-"; | |
| 42 | const d = new Date(iso + "Z"); | |
| 43 | return d.toLocaleString(); | |
| 44 | } | |
| 0 | 45 | |
| @@ -0,0 +1,45 @@ | ||
| 1 | import { join } from "node:path"; | |
| 2 | import { homedir } from "node:os"; | |
| 3 | ||
| 4 | interface RepoConfig { | |
| 5 | name: string; | |
| 6 | owner_name: string; | |
| 7 | default_branch: string; | |
| 8 | } | |
| 9 | ||
| 10 | export function buildSlConfig(repo: RepoConfig, hub: string): string { | |
| 11 | const tlsDir = join(homedir(), ".grove", "tls"); | |
| 12 | return `[paths] | |
| 13 | default = slapi:${repo.name} | |
| 14 | ||
| 15 | [remotefilelog] | |
| 16 | reponame = ${repo.name} | |
| 17 | ||
| 18 | [grove] | |
| 19 | owner = ${repo.owner_name} | |
| 20 | ||
| 21 | [auth] | |
| 22 | grove.prefix = ${hub} | |
| 23 | grove.cert = ${tlsDir}/client.crt | |
| 24 | grove.key = ${tlsDir}/client.key | |
| 25 | grove.cacerts = ${tlsDir}/ca.crt | |
| 26 | ||
| 27 | grove-mononoke.prefix = mononoke://grove.host | |
| 28 | grove-mononoke.cert = ${tlsDir}/client.crt | |
| 29 | grove-mononoke.key = ${tlsDir}/client.key | |
| 30 | grove-mononoke.cacerts = ${tlsDir}/ca.crt | |
| 31 | ||
| 32 | [edenapi] | |
| 33 | url = ${hub.replace(/\/$/, "")}:8443/edenapi/ | |
| 34 | ||
| 35 | [clone] | |
| 36 | use-commit-graph = true | |
| 37 | ||
| 38 | [remotenames] | |
| 39 | selectivepulldefault = ${repo.default_branch} | |
| 40 | ||
| 41 | [push] | |
| 42 | edenapi = true | |
| 43 | to = ${repo.default_branch} | |
| 44 | `; | |
| 45 | } | |
| 0 | 46 | |