| 0fc3247 | | | 1 | import { intro, outro, select, spinner, isCancel, cancel, log } from "@clack/prompts"; |
| 8d8e815 | | | 2 | import { execSync } from "node:child_process"; |
| 0fc3247 | | | 3 | import { writeFileSync, existsSync, mkdirSync, renameSync, rmSync } from "node:fs"; |
| 8d8e815 | | | 4 | import { join, basename, resolve } from "node:path"; |
| 8d8e815 | | | 5 | import { homedir } from "node:os"; |
| 90d5eb8 | | | 6 | import { hubRequest, hubUploadStream } from "../api.js"; |
| 8d8e815 | | | 7 | import { getHub } from "../config.js"; |
| 8d8e815 | | | 8 | |
| 8d8e815 | | | 9 | interface Repo { |
| 8d8e815 | | | 10 | id: number; |
| 8d8e815 | | | 11 | owner_name: string; |
| 8d8e815 | | | 12 | name: string; |
| 8d8e815 | | | 13 | default_branch: string; |
| 8d8e815 | | | 14 | } |
| 8d8e815 | | | 15 | |
| 59e6667 | | | 16 | interface User { |
| 59e6667 | | | 17 | id: number; |
| 59e6667 | | | 18 | username: string; |
| 59e6667 | | | 19 | display_name: string; |
| 59e6667 | | | 20 | } |
| 59e6667 | | | 21 | |
| 59e6667 | | | 22 | interface Org { |
| 59e6667 | | | 23 | id: number; |
| 59e6667 | | | 24 | name: string; |
| 59e6667 | | | 25 | display_name: string; |
| 59e6667 | | | 26 | } |
| 59e6667 | | | 27 | |
| 0fc3247 | | | 28 | function buildSlConfig(repo: Repo, hub: string) { |
| 0fc3247 | | | 29 | const tlsDir = join(homedir(), ".grove", "tls"); |
| 0fc3247 | | | 30 | return `[paths] |
| 0fc3247 | | | 31 | default = slapi:${repo.name} |
| 0fc3247 | | | 32 | |
| 0fc3247 | | | 33 | [remotefilelog] |
| 0fc3247 | | | 34 | reponame = ${repo.name} |
| 0fc3247 | | | 35 | |
| 0fc3247 | | | 36 | [grove] |
| 0fc3247 | | | 37 | owner = ${repo.owner_name} |
| 0fc3247 | | | 38 | |
| 0fc3247 | | | 39 | [auth] |
| 0fc3247 | | | 40 | grove.prefix = ${hub} |
| 0fc3247 | | | 41 | grove.cert = ${tlsDir}/client.crt |
| 0fc3247 | | | 42 | grove.key = ${tlsDir}/client.key |
| 0fc3247 | | | 43 | grove.cacerts = ${tlsDir}/ca.crt |
| 0fc3247 | | | 44 | |
| 0fc3247 | | | 45 | grove-mononoke.prefix = mononoke://grove.host |
| 0fc3247 | | | 46 | grove-mononoke.cert = ${tlsDir}/client.crt |
| 0fc3247 | | | 47 | grove-mononoke.key = ${tlsDir}/client.key |
| 0fc3247 | | | 48 | grove-mononoke.cacerts = ${tlsDir}/ca.crt |
| 0fc3247 | | | 49 | |
| 0fc3247 | | | 50 | [edenapi] |
| 0fc3247 | | | 51 | url = ${hub.replace(/\/$/, "")}:8443/edenapi/ |
| 0fc3247 | | | 52 | |
| 0fc3247 | | | 53 | [clone] |
| 0fc3247 | | | 54 | use-commit-graph = true |
| 0fc3247 | | | 55 | |
| 0fc3247 | | | 56 | [remotenames] |
| 0fc3247 | | | 57 | selectivepulldefault = ${repo.default_branch} |
| 0fc3247 | | | 58 | |
| 0fc3247 | | | 59 | [push] |
| 0fc3247 | | | 60 | edenapi = true |
| 0fc3247 | | | 61 | to = ${repo.default_branch} |
| 0fc3247 | | | 62 | `; |
| 0fc3247 | | | 63 | } |
| 8d8e815 | | | 64 | |
| 0fc3247 | | | 65 | async function resolveOwner(args: string[]): Promise<string | undefined> { |
| 8d8e815 | | | 66 | const ownerIdx = args.indexOf("--owner"); |
| 0fc3247 | | | 67 | if (ownerIdx !== -1) return args[ownerIdx + 1]; |
| 8d8e815 | | | 68 | |
| 0fc3247 | | | 69 | const s = spinner(); |
| 0fc3247 | | | 70 | s.start("Fetching user and organizations from Grove"); |
| 0fc3247 | | | 71 | const [{ user }, { orgs }] = await Promise.all([ |
| 0fc3247 | | | 72 | hubRequest<{ user: User }>("/api/auth/me"), |
| 0fc3247 | | | 73 | hubRequest<{ orgs: Org[] }>("/api/orgs"), |
| 0fc3247 | | | 74 | ]); |
| 0fc3247 | | | 75 | s.stop(`Logged in as ${user.username}` + (orgs.length > 0 ? ` (${orgs.length} org${orgs.length !== 1 ? "s" : ""})` : "")); |
| 8d8e815 | | | 76 | |
| 0fc3247 | | | 77 | const choices = [user.username, ...orgs.map((o) => o.name)]; |
| 8d8e815 | | | 78 | |
| 0fc3247 | | | 79 | if (choices.length > 1) { |
| 0fc3247 | | | 80 | const selected = await select({ |
| 0fc3247 | | | 81 | message: "Where should this repository live?", |
| 0fc3247 | | | 82 | options: choices.map((c, i) => ({ |
| 0fc3247 | | | 83 | value: c, |
| 0fc3247 | | | 84 | label: i === 0 ? `${c} (personal)` : c, |
| 0fc3247 | | | 85 | })), |
| 0fc3247 | | | 86 | }); |
| 59e6667 | | | 87 | |
| 0fc3247 | | | 88 | if (isCancel(selected)) { |
| 0fc3247 | | | 89 | cancel("Operation cancelled."); |
| 0fc3247 | | | 90 | process.exit(0); |
| 0fc3247 | | | 91 | } |
| 0fc3247 | | | 92 | |
| 0fc3247 | | | 93 | // Only set owner for orgs (not the personal account) |
| 0fc3247 | | | 94 | if (selected !== choices[0]) { |
| 0fc3247 | | | 95 | return selected as string; |
| 59e6667 | | | 96 | } |
| 59e6667 | | | 97 | } |
| 59e6667 | | | 98 | |
| 0fc3247 | | | 99 | return undefined; |
| 0fc3247 | | | 100 | } |
| 0fc3247 | | | 101 | |
| 0fc3247 | | | 102 | async function createRepo(name: string, owner: string | undefined, description: string | undefined, isPrivate: boolean, skipSeed: boolean) { |
| 0fc3247 | | | 103 | const s = spinner(); |
| 0fc3247 | | | 104 | s.start("Creating repository and provisioning Mononoke"); |
| 59e6667 | | | 105 | const messages = [ |
| 59e6667 | | | 106 | "Writing repo config", |
| 59e6667 | | | 107 | "Restarting source control services", |
| 59e6667 | | | 108 | "Waiting for health check", |
| 59e6667 | | | 109 | ]; |
| 59e6667 | | | 110 | let msgIdx = 0; |
| 59e6667 | | | 111 | const ticker = setInterval(() => { |
| 59e6667 | | | 112 | if (msgIdx < messages.length) { |
| 0fc3247 | | | 113 | s.message(messages[msgIdx]); |
| 59e6667 | | | 114 | msgIdx++; |
| 59e6667 | | | 115 | } |
| 59e6667 | | | 116 | }, 3000); |
| 8d8e815 | | | 117 | const { repo } = await hubRequest<{ repo: Repo }>("/api/repos", { |
| 8d8e815 | | | 118 | method: "POST", |
| 8d8e815 | | | 119 | body: JSON.stringify({ |
| 8d8e815 | | | 120 | name, |
| 8d8e815 | | | 121 | description, |
| 8d8e815 | | | 122 | owner, |
| 8d8e815 | | | 123 | is_private: isPrivate, |
| 0fc3247 | | | 124 | skip_seed: skipSeed, |
| 8d8e815 | | | 125 | }), |
| 8d8e815 | | | 126 | }); |
| 59e6667 | | | 127 | clearInterval(ticker); |
| 0fc3247 | | | 128 | s.stop(`Created ${repo.owner_name}/${repo.name}`); |
| 0fc3247 | | | 129 | return repo; |
| 0fc3247 | | | 130 | } |
| 8d8e815 | | | 131 | |
| 0fc3247 | | | 132 | function parsePositional(args: string[]): string | undefined { |
| 0fc3247 | | | 133 | const flagsWithValues = new Set(["--owner", "--description"]); |
| 0fc3247 | | | 134 | for (let i = 0; i < args.length; i++) { |
| 0fc3247 | | | 135 | if (flagsWithValues.has(args[i])) { i++; continue; } |
| 0fc3247 | | | 136 | if (args[i].startsWith("--")) continue; |
| 0fc3247 | | | 137 | return args[i]; |
| 8d8e815 | | | 138 | } |
| 0fc3247 | | | 139 | return undefined; |
| 0fc3247 | | | 140 | } |
| 8d8e815 | | | 141 | |
| 0fc3247 | | | 142 | export async function init(args: string[]) { |
| 0fc3247 | | | 143 | const dir = resolve(parsePositional(args) ?? "."); |
| 0fc3247 | | | 144 | const name = basename(dir); |
| 0fc3247 | | | 145 | const isGitRepo = existsSync(join(dir, ".git")); |
| 8d8e815 | | | 146 | |
| 0fc3247 | | | 147 | const descIdx = args.indexOf("--description"); |
| 0fc3247 | | | 148 | const description = descIdx !== -1 ? args[descIdx + 1] : undefined; |
| 0fc3247 | | | 149 | const isPrivate = args.includes("--private"); |
| 8d8e815 | | | 150 | |
| 0fc3247 | | | 151 | intro(`grove init ${name}${isGitRepo ? " (importing from git)" : ""}`); |
| 8d8e815 | | | 152 | |
| 0fc3247 | | | 153 | const owner = await resolveOwner(args); |
| 8d8e815 | | | 154 | |
| 0fc3247 | | | 155 | if (isGitRepo) { |
| 0fc3247 | | | 156 | await initFromGit(dir, name, owner, description, isPrivate); |
| 0fc3247 | | | 157 | } else { |
| 0fc3247 | | | 158 | await initFresh(dir, name, owner, description, isPrivate); |
| 0fc3247 | | | 159 | } |
| 0fc3247 | | | 160 | } |
| 8d8e815 | | | 161 | |
| 0fc3247 | | | 162 | async function initFromGit(dir: string, name: string, owner: string | undefined, description: string | undefined, isPrivate: boolean) { |
| 0fc3247 | | | 163 | // Detect default branch from the git repo |
| 0fc3247 | | | 164 | let gitBranch = "main"; |
| 0fc3247 | | | 165 | try { |
| 0fc3247 | | | 166 | gitBranch = execSync("git symbolic-ref --short HEAD", { cwd: dir, stdio: "pipe" }).toString().trim(); |
| 0fc3247 | | | 167 | } catch { |
| 0fc3247 | | | 168 | // fall back to main |
| 0fc3247 | | | 169 | } |
| 8d8e815 | | | 170 | |
| 0fc3247 | | | 171 | // Count commits for user feedback |
| 0fc3247 | | | 172 | let commitCount = "?"; |
| 0fc3247 | | | 173 | try { |
| 0fc3247 | | | 174 | commitCount = execSync("git rev-list --count HEAD", { cwd: dir, stdio: "pipe" }).toString().trim(); |
| 0fc3247 | | | 175 | } catch {} |
| 0fc3247 | | | 176 | log.info(`Found git repository with ${commitCount} commits on ${gitBranch}`); |
| 8d8e815 | | | 177 | |
| 90d5eb8 | | | 178 | // Create Grove repo without seeding |
| 0fc3247 | | | 179 | const repo = await createRepo(name, owner, description, isPrivate, true); |
| 0fc3247 | | | 180 | const hub = await getHub(); |
| 90d5eb8 | | | 181 | const ownerName = repo.owner_name; |
| 90d5eb8 | | | 182 | |
| 90d5eb8 | | | 183 | // Create bare clone and tar it up for upload |
| 90d5eb8 | | | 184 | const tmpDir = join(dir, "..", `.grove-import-${name}-${Date.now()}`); |
| 90d5eb8 | | | 185 | const bareDir = join(tmpDir, "bare.git"); |
| 90d5eb8 | | | 186 | const tarPath = join(tmpDir, "bare.tar.gz"); |
| 8d8e815 | | | 187 | |
| 0fc3247 | | | 188 | const s1 = spinner(); |
| 90d5eb8 | | | 189 | s1.start("Preparing git history for import"); |
| 90d5eb8 | | | 190 | try { |
| 90d5eb8 | | | 191 | mkdirSync(tmpDir, { recursive: true }); |
| 90d5eb8 | | | 192 | execSync(`git clone --bare "${dir}" "${bareDir}"`, { stdio: "pipe" }); |
| 90d5eb8 | | | 193 | execSync(`tar czf "${tarPath}" -C "${tmpDir}" bare.git`, { stdio: "pipe" }); |
| 90d5eb8 | | | 194 | s1.stop("Bare clone ready"); |
| 90d5eb8 | | | 195 | } catch (e: any) { |
| 90d5eb8 | | | 196 | s1.stop("Failed to create bare clone"); |
| 90d5eb8 | | | 197 | log.error(e.stderr?.toString() || e.message); |
| 90d5eb8 | | | 198 | rmSync(tmpDir, { recursive: true, force: true }); |
| 90d5eb8 | | | 199 | process.exit(1); |
| 90d5eb8 | | | 200 | } |
| 90d5eb8 | | | 201 | |
| 90d5eb8 | | | 202 | // Upload to server and run gitimport |
| 0fc3247 | | | 203 | const s2 = spinner(); |
| 90d5eb8 | | | 204 | s2.start("Importing git history into Grove"); |
| 0fc3247 | | | 205 | try { |
| 90d5eb8 | | | 206 | await hubUploadStream( |
| 90d5eb8 | | | 207 | `/api/repos/${ownerName}/${name}/import-bundle`, |
| 90d5eb8 | | | 208 | tarPath, |
| 90d5eb8 | | | 209 | (event, data) => { |
| 90d5eb8 | | | 210 | if (event === "progress" && data.message) { |
| 90d5eb8 | | | 211 | s2.message(data.message); |
| 90d5eb8 | | | 212 | } |
| 90d5eb8 | | | 213 | }, |
| 90d5eb8 | | | 214 | ); |
| 90d5eb8 | | | 215 | s2.stop("Git history imported"); |
| 90d5eb8 | | | 216 | } catch (e: any) { |
| 90d5eb8 | | | 217 | s2.stop("Import failed"); |
| 90d5eb8 | | | 218 | log.error(e.message); |
| 90d5eb8 | | | 219 | rmSync(tmpDir, { recursive: true, force: true }); |
| 90d5eb8 | | | 220 | process.exit(1); |
| 90d5eb8 | | | 221 | } |
| 90d5eb8 | | | 222 | |
| 90d5eb8 | | | 223 | // Remove temp upload files |
| 90d5eb8 | | | 224 | rmSync(tmpDir, { recursive: true, force: true }); |
| 90d5eb8 | | | 225 | |
| 90d5eb8 | | | 226 | // Set up Sapling working copy by cloning from Grove |
| 90d5eb8 | | | 227 | const s3 = spinner(); |
| 90d5eb8 | | | 228 | s3.start("Setting up Sapling working copy"); |
| 90d5eb8 | | | 229 | const cloneTmp = join(dir, "..", `.grove-clone-${name}-${Date.now()}`); |
| 90d5eb8 | | | 230 | try { |
| 90d5eb8 | | | 231 | // Clone into a temp dir, then move .sl into the working dir |
| 90d5eb8 | | | 232 | execSync(`sl clone slapi:${name} "${cloneTmp}"`, { stdio: "pipe" }); |
| 90d5eb8 | | | 233 | |
| 90d5eb8 | | | 234 | // Remove .git and replace with .sl |
| 90d5eb8 | | | 235 | rmSync(join(dir, ".git"), { recursive: true, force: true }); |
| 90d5eb8 | | | 236 | renameSync(join(cloneTmp, ".sl"), join(dir, ".sl")); |
| 90d5eb8 | | | 237 | |
| 90d5eb8 | | | 238 | // Write config with correct remote settings |
| 90d5eb8 | | | 239 | const config = buildSlConfig({ ...repo, default_branch: gitBranch }, hub); |
| 90d5eb8 | | | 240 | writeFileSync(join(dir, ".sl", "config"), config); |
| 90d5eb8 | | | 241 | |
| 90d5eb8 | | | 242 | s3.stop("Sapling working copy ready"); |
| 0fc3247 | | | 243 | } catch (e: any) { |
| 90d5eb8 | | | 244 | s3.stop("Clone failed"); |
| 0fc3247 | | | 245 | log.error(e.stderr?.toString() || e.message); |
| 90d5eb8 | | | 246 | rmSync(cloneTmp, { recursive: true, force: true }); |
| 0fc3247 | | | 247 | process.exit(1); |
| 0fc3247 | | | 248 | } |
| 0fc3247 | | | 249 | |
| 90d5eb8 | | | 250 | // Clean up clone temp dir |
| 90d5eb8 | | | 251 | rmSync(cloneTmp, { recursive: true, force: true }); |
| 0fc3247 | | | 252 | |
| 90d5eb8 | | | 253 | outro(`Imported ${ownerName}/${name} with full git history`); |
| 0fc3247 | | | 254 | } |
| 0fc3247 | | | 255 | |
| 0fc3247 | | | 256 | async function initFresh(dir: string, name: string, owner: string | undefined, description: string | undefined, isPrivate: boolean) { |
| 0fc3247 | | | 257 | const repo = await createRepo(name, owner, description, isPrivate, false); |
| 0fc3247 | | | 258 | |
| 0fc3247 | | | 259 | // Init Sapling repo if not already one |
| 0fc3247 | | | 260 | if (!existsSync(join(dir, ".sl"))) { |
| 0fc3247 | | | 261 | mkdirSync(dir, { recursive: true }); |
| 0fc3247 | | | 262 | const s = spinner(); |
| 0fc3247 | | | 263 | s.start("Initializing Sapling repository"); |
| 0fc3247 | | | 264 | execSync(`sl init --config init.prefer-git=false --config format.use-remotefilelog=true ${dir}`, { stdio: "pipe" }); |
| 0fc3247 | | | 265 | s.stop("Sapling repository initialized"); |
| 0fc3247 | | | 266 | } |
| 0fc3247 | | | 267 | |
| 0fc3247 | | | 268 | // Configure remote, auth, and push settings |
| 0fc3247 | | | 269 | const s2 = spinner(); |
| 0fc3247 | | | 270 | s2.start("Configuring Grove remote"); |
| 0fc3247 | | | 271 | const hub = await getHub(); |
| 0fc3247 | | | 272 | writeFileSync(join(dir, ".sl", "config"), buildSlConfig(repo, hub)); |
| 0fc3247 | | | 273 | s2.stop("Remote configured"); |
| 59e6667 | | | 274 | |
| c5a8edf | | | 275 | // Pull the initial commit seeded by the API |
| 0fc3247 | | | 276 | const s3 = spinner(); |
| 0fc3247 | | | 277 | s3.start("Pulling initial commit"); |
| c5a8edf | | | 278 | try { |
| c5a8edf | | | 279 | execSync(`sl pull`, { cwd: dir, stdio: "pipe" }); |
| c5a8edf | | | 280 | execSync(`sl goto ${repo.default_branch}`, { cwd: dir, stdio: "pipe" }); |
| 0fc3247 | | | 281 | s3.stop("Pulled initial commit"); |
| c5a8edf | | | 282 | } catch { |
| c5a8edf | | | 283 | // If pull fails (e.g. seed didn't complete), create a local initial commit |
| 0fc3247 | | | 284 | s3.stop("Creating initial commit locally"); |
| c5a8edf | | | 285 | if (!existsSync(join(dir, "README.md"))) { |
| 0fc3247 | | | 286 | writeFileSync(join(dir, "README.md"), `# ${name}\n`); |
| c5a8edf | | | 287 | } |
| c5a8edf | | | 288 | execSync(`sl add`, { cwd: dir, stdio: "pipe" }); |
| c5a8edf | | | 289 | execSync(`sl commit -m "Initial commit"`, { cwd: dir, stdio: "pipe" }); |
| 0fc3247 | | | 290 | const s4 = spinner(); |
| 0fc3247 | | | 291 | s4.start(`Pushing to ${repo.default_branch}`); |
| c5a8edf | | | 292 | execSync(`sl push --to ${repo.default_branch} --create`, { cwd: dir, stdio: "pipe" }); |
| 0fc3247 | | | 293 | s4.stop(`Pushed to ${repo.default_branch}`); |
| 59e6667 | | | 294 | } |
| 59e6667 | | | 295 | |
| 59e6667 | | | 296 | outro(`Initialized ${repo.owner_name}/${repo.name}`); |
| 8d8e815 | | | 297 | } |