From b4820ab2399b0c34422dd2918036ce78208e26ad Mon Sep 17 00:00:00 2001 From: Aboo Date: Wed, 1 Apr 2026 14:16:18 +1100 Subject: [PATCH 1/2] PAP-7015: Add workbench initialization command Introduces the --init flag to automate forking and cloning the workbench repository. This adds an interactive TUI flow for selecting organizations and naming projects, alongside a non-interactive mode for headless setup. --- README.md | 30 ++- packages/workbench-cli/README.md | 90 +++++++- packages/workbench-cli/src/args.ts | 33 ++- .../workbench-cli/src/commands/initialise.ts | 192 ++++++++++++++++++ packages/workbench-cli/src/index.ts | 149 +++++++++++++- .../src/screens/initNameInput.ts | 79 +++++++ .../src/screens/initOrgSelect.ts | 130 ++++++++++++ .../src/screens/initSetupPrompt.ts | 64 ++++++ packages/workbench-cli/src/utils/gh.ts | 33 +++ 9 files changed, 787 insertions(+), 13 deletions(-) create mode 100644 packages/workbench-cli/src/commands/initialise.ts create mode 100644 packages/workbench-cli/src/screens/initNameInput.ts create mode 100644 packages/workbench-cli/src/screens/initOrgSelect.ts create mode 100644 packages/workbench-cli/src/screens/initSetupPrompt.ts diff --git a/README.md b/README.md index 7418515..0563943 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,32 @@ bun link bun run packages/workbench-cli/src/index.ts ``` +### Quick Start + +**Interactive init** — fork, clone, and set up in one flow: + +```bash +workbench --init +``` + +**Non-interactive init** — create with defaults: + +```bash +workbench --init --no-tui --name my-project +``` + +**Init + setup combined:** + +```bash +workbench --init --no-tui --name my-project --org myorg --code-repository https://github.com/myorg/api +``` + +**Manual alternative** — fork/clone the repo yourself, then: + +```bash +workbench --tui +``` + ### Usage Run `workbench` from the workbench repository root. Select `init` to walk through: @@ -53,4 +79,6 @@ Run `workbench` from the workbench repository root. Select `init` to walk throug 4. Configure branch per repository 5. Optionally index with `ck` -After init, `.workbench/config.yaml` is written with the selected configuration. \ No newline at end of file +After init, `.workbench/config.yaml` is written with the selected configuration. + +See [packages/workbench-cli/README.md](packages/workbench-cli/README.md) for full documentation. diff --git a/packages/workbench-cli/README.md b/packages/workbench-cli/README.md index 3a85218..b73bdaa 100644 --- a/packages/workbench-cli/README.md +++ b/packages/workbench-cli/README.md @@ -26,7 +26,95 @@ You can run the CLI directly from the source without linking: bun run src/index.ts ``` -## Usage +## Quick Start + +### Interactive (TUI) + +Create a new workbench from scratch: + +```bash +workbench --init +``` + +This launches an interactive flow: select a fork target (org or personal account), name your workbench, fork and clone the template repo, then optionally run the setup wizard. + +### Non-interactive + +Create a workbench without prompts: + +```bash +workbench --init --no-tui --name my-project +``` + +### Combined (init + setup in one command) + +```bash +workbench --init --no-tui --name my-project --org myorg --code-repository https://github.com/myorg/api +``` + +### Manual Setup + +If you already have a workbench repo cloned: + +```bash +workbench --tui +``` + +## Init Flags + +| Flag | Description | Default | +|------|-------------|---------| +| `--init` | Initialize a new workbench (fork & clone) | `false` | +| `--name ` | Name for the fork and local folder | `workbench` | +| `--no-fork` | Clone without forking (read-only) | `false` | +| `--no-tui` | Skip TUI, use defaults or provided values | `false` | + +## Setup Flags + +These flags work with both `--init` and standalone usage: + +| Flag | Description | Default | +|------|-------------|---------| +| `--org ` | GitHub organization name | personal account | +| `--code-repository ` | Code repository URL (can be repeated) | - | +| `--resource-repository ` | Resource repository URL (can be repeated) | - | +| `--code-branch ` | Branch for all code repositories | `main` | +| `--resource-branch ` | Branch for all resource repositories | `main` | +| `--index ` | Run indexing after init | `on` | +| `--tui` | Launch interactive TUI mode | `false` | + +## Examples + +```bash +# Interactive init +workbench --init + +# Non-interactive init with custom name +workbench --init --no-tui --name my-project + +# Clone without forking (read-only) +workbench --init --no-tui --no-fork --name explore-wb + +# Init + setup in one command +workbench --init --no-tui --name my-project --org myorg --code-repository https://github.com/myorg/api + +# Standalone setup (existing repo) +workbench --org myorg --code-repository https://github.com/myorg/backend + +# Interactive setup (existing repo) +workbench --tui +``` + +## Error Scenarios + +| Error | Cause | Resolution | +|-------|-------|------------| +| `A repository named "X" already exists under Y` | Fork name conflict | Choose a different `--name` | +| `A folder named "X" already exists in the current directory` | Local folder conflict | Remove or rename the folder, or choose a different name | +| `gh CLI is not authenticated` | `gh auth` not set up | Run `gh auth login` | +| `Invalid name "X"` | Bad characters in name | Use only alphanumeric, `-`, `.`, `_` | + +## Usage (Existing Repo) Run the `workbench` command from the workbench repository root. Select `init` to walk through the interactive setup: diff --git a/packages/workbench-cli/src/args.ts b/packages/workbench-cli/src/args.ts index 0685f55..730b7db 100644 --- a/packages/workbench-cli/src/args.ts +++ b/packages/workbench-cli/src/args.ts @@ -9,6 +9,10 @@ export interface CliArgs { codeBranch: string resourceBranch: string index: boolean + init: boolean + noFork: boolean + name: string + noTui: boolean } export function parseCliArgs(): CliArgs { @@ -22,6 +26,10 @@ export function parseCliArgs(): CliArgs { "code-branch": { type: "string", default: "main" }, "resource-branch": { type: "string", default: "main" }, index: { type: "string", default: "on" }, + init: { type: "boolean", default: false }, + "no-fork": { type: "boolean", default: false }, + name: { type: "string", default: "workbench" }, + "no-tui": { type: "boolean", default: false }, }, strict: true, allowPositionals: false, @@ -36,6 +44,10 @@ export function parseCliArgs(): CliArgs { codeBranch: values["code-branch"] as string, resourceBranch: values["resource-branch"] as string, index: values.index === "on", + init: values.init, + noFork: values["no-fork"] as boolean, + name: values.name as string, + noTui: values["no-tui"] as boolean, } } @@ -43,13 +55,18 @@ export function printHelp(): void { console.log(`workbench - Initialize a development workbench USAGE: - workbench --org --code-repository [--code-repository ...] [options] - workbench --org --resource-repository [--resource-repository ...] [options] + workbench --init [options] + workbench --init --no-tui [options] + workbench --org --code-repository [options] workbench --tui workbench --help OPTIONS: - --org GitHub organization name (required for non-interactive) + --init Initialize a new workbench (fork & clone) + --name Name for the fork and local folder (default: workbench) + --no-fork Clone without forking (read-only) + --no-tui Skip TUI, use defaults or provided values + --org GitHub organization name --code-repository Code repository URL (can be repeated) --resource-repository Resource repository URL (can be repeated) --code-branch Branch for all code repositories (default: main) @@ -59,13 +76,11 @@ OPTIONS: --help Display this help message EXAMPLES: + workbench --init + workbench --init --no-tui --name my-project + workbench --init --no-tui --no-fork --name explore-wb + workbench --init --no-tui --name my-project --org myorg --code-repository https://github.com/myorg/api workbench --org myorg --code-repository https://github.com/myorg/backend - - workbench --org myorg --code-repository https://github.com/myorg/api \\ - --code-repository https://github.com/myorg/web \\ - --resource-repository https://github.com/myorg/docs \\ - --code-branch develop --index off - workbench --tui `) } diff --git a/packages/workbench-cli/src/commands/initialise.ts b/packages/workbench-cli/src/commands/initialise.ts new file mode 100644 index 0000000..b416b08 --- /dev/null +++ b/packages/workbench-cli/src/commands/initialise.ts @@ -0,0 +1,192 @@ +import { existsSync } from "fs" +import { runCommand } from "../utils/spawn.ts" +import { forkRepo, repoExists, validateRepoName } from "../utils/gh.ts" +import { showInitOrgSelect } from "../screens/initOrgSelect.ts" +import { showInitNameInput } from "../screens/initNameInput.ts" +import { showInitSetupPrompt } from "../screens/initSetupPrompt.ts" +import { showExecutingScreen } from "../screens/executing.ts" +import { createSpinner } from "../utils/spinner.ts" +import type { InitProgress } from "./init.ts" +import type { CliRenderer } from "@opentui/core" +import type { CliArgs } from "../args.ts" + +const SOURCE_REPO = "plan-and-publish/workbench" + +export interface InitialiseState { + name: string + noFork: boolean + targetOrg: string +} + +export interface InitialiseResult { + success: boolean + error?: string + targetDir?: string +} + +export function validateInitialiseState(state: InitialiseState): string | null { + if (!validateRepoName(state.name)) { + return `Invalid name "${state.name}". Use only alphanumeric characters, hyphens, dots, and underscores.` + } + if (existsSync(state.name)) { + return `A folder named "${state.name}" already exists in the current directory.` + } + return null +} + +export async function executeFork( + state: InitialiseState +): Promise<{ success: boolean; error?: string; cloneUrl?: string }> { + if (state.noFork) { + return { success: true, cloneUrl: `https://github.com/${SOURCE_REPO}.git` } + } + + const exists = await repoExists(state.targetOrg, state.name) + if (exists) { + return { success: false, error: `A repository named "${state.name}" already exists under ${state.targetOrg}.` } + } + + const forkResult = await forkRepo(SOURCE_REPO, state.targetOrg, state.name) + return { success: true, cloneUrl: forkResult.url } +} + +export async function executeClone( + cloneUrl: string, + name: string, + progress: InitProgress +): Promise { + const { onLine, startThrottle, stopThrottle } = progress + + try { + if (existsSync(name)) { + return { success: false, error: `A folder named "${name}" already exists in the current directory.` } + } + + onLine(`--- Cloning into ./${name}/ ---`, true, false) + startThrottle() + try { + await runCommand("git", ["clone", "--depth", "1", cloneUrl, name], (line, _, isCR) => + onLine(line, false, isCR) + ) + } finally { + stopThrottle() + } + + process.chdir(name) + onLine(`Working directory changed to ./${name}/`, false, false) + + return { success: true, targetDir: name } + } catch (error) { + return { success: false, error: String(error) } + } +} + +export async function executeInitialise( + state: InitialiseState, + progress: InitProgress +): Promise { + const validationError = validateInitialiseState(state) + if (validationError) { + return { success: false, error: validationError } + } + + const forkResult = await executeFork(state) + if (!forkResult.success) { + return { success: false, error: forkResult.error } + } + + return executeClone(forkResult.cloneUrl!, state.name, progress) +} + +export function runInitialiseFlow( + renderer: CliRenderer, + args: CliArgs, + onComplete: () => void +): void { + void showInitOrgSelect(renderer, args.org, (targetOrg) => { + setTimeout(() => { + showInitNameInput(renderer, args.name, (name) => { + const state: InitialiseState = { name, noFork: args.noFork, targetOrg } + void runTuiInitialise(renderer, state, (success) => { + if (!success) { + onComplete() + return + } + setTimeout(() => { + showInitSetupPrompt(renderer, (shouldSetup) => { + if (shouldSetup) { + void runTuiSetupAfterInit(renderer, onComplete) + } else { + console.log("\nTo set up your workbench later, run: workbench --tui") + renderer.destroy() + process.exit(0) + } + }) + }, 0) + }) + }) + }, 0) + }) +} + +async function runTuiInitialise( + renderer: CliRenderer, + state: InitialiseState, + onDone: (success: boolean) => void +): Promise { + let cloneUrl: string + + if (!state.noFork) { + const spinner = createSpinner(renderer, `Forking to ${state.targetOrg}/${state.name}...`) + spinner.start() + const forkResult = await executeFork(state) + spinner.stop() + + if (!forkResult.success) { + const { appendLine, container } = showExecutingScreen(renderer) + appendLine(`Error: ${forkResult.error}`, true) + const handler = () => { + renderer.keyInput.off("keypress", handler) + container.visible = false + onDone(false) + } + renderer.keyInput.on("keypress", handler) + return + } + cloneUrl = forkResult.cloneUrl! + } else { + cloneUrl = `https://github.com/${SOURCE_REPO}.git` + } + + const { appendLine, startThrottle, stopThrottle, container } = showExecutingScreen(renderer) + const progress: InitProgress = { + onLine: (line, isHeader, isCR) => appendLine(line, isHeader, isCR), + startThrottle, + stopThrottle, + } + + const result = await executeClone(cloneUrl, state.name, progress) + + if (!result.success) { + appendLine(`Error: ${result.error}`, true) + } else { + appendLine("--- Initialisation complete ---", true) + } + + const handler = () => { + renderer.keyInput.off("keypress", handler) + container.visible = false + onDone(result.success) + } + renderer.keyInput.on("keypress", handler) +} + +async function runTuiSetupAfterInit( + renderer: CliRenderer, + onComplete: () => void +): Promise { + const { runInitFlow } = await import("./init.ts") + runInitFlow(renderer, (_success) => { + onComplete() + }) +} diff --git a/packages/workbench-cli/src/index.ts b/packages/workbench-cli/src/index.ts index a6f6f70..49e1304 100644 --- a/packages/workbench-cli/src/index.ts +++ b/packages/workbench-cli/src/index.ts @@ -2,9 +2,10 @@ import { createCliRenderer, TextRenderable, type CliRenderer } from "@opentui/co import { existsSync, readFileSync } from "node:fs" import { fileURLToPath } from "node:url" import { dirname, join } from "node:path" -import { checkAuth, checkRepoRoot } from "./utils/gh.ts" +import { checkAuth, checkRepoRoot, getCurrentUserLogin } from "./utils/gh.ts" import { showMainMenu } from "./screens/mainMenu.ts" import { runInitFlow, executeInit, type InitState, type InitProgress } from "./commands/init.ts" +import { executeInitialise, validateInitialiseState, runInitialiseFlow, type InitialiseState } from "./commands/initialise.ts" import { parseCliArgs, printHelp, type CliArgs } from "./args.ts" import { buildRepoFromUrl } from "./utils/repo.ts" import type { Repo } from "./screens/repoSelect.ts" @@ -16,7 +17,13 @@ if (args.help || process.argv.length === 2) { process.exit(0) } -if (args.tui) { +if (args.init) { + if (args.noTui) { + void runNonInteractiveInitCmd(args) + } else { + void runTuiInitMode(args) + } +} else if (args.tui) { void runTuiMode() } else { void runNonInteractiveInit(args) @@ -162,3 +169,141 @@ async function runNonInteractiveInit(args: CliArgs): Promise { process.exit(0) } + +async function runNonInteractiveInitCmd(args: CliArgs): Promise { + const targetOrg = args.org ?? (await getCurrentUserLogin()) + const state: InitialiseState = { + name: args.name, + noFork: args.noFork, + targetOrg, + } + + const validationError = validateInitialiseState(state) + if (validationError) { + console.error(validationError) + process.exit(1) + } + + const stdoutProgress: InitProgress = { + onLine: (line, isHeader, isCR) => { + if (isCR) { + process.stdout.write(`\r${line}`) + } else { + console.log(line) + } + }, + startThrottle: () => {}, + stopThrottle: () => {}, + } + + const result = await executeInitialise(state, stdoutProgress) + + if (!result.success) { + console.error(result.error || "Initialisation failed") + process.exit(1) + } + + const hasRepos = args.codeRepositories.length > 0 || args.resourceRepositories.length > 0 + if (!hasRepos) { + console.log(`\nWorkbench initialised in ./${state.name}/`) + console.log("To set up your workbench, run: workbench --tui") + process.exit(0) + } + + if (existsSync(".workbench")) { + console.error(".workbench/ already exists") + process.exit(1) + } + + const codeRepos: Repo[] = args.codeRepositories.map((url) => + buildRepoFromUrl(url, args.codeBranch) + ) + const resourceRepos: Repo[] = args.resourceRepositories.map((url) => + buildRepoFromUrl(url, args.resourceBranch) + ) + const branches = new Map() + for (const repo of codeRepos) branches.set(repo.name, args.codeBranch) + for (const repo of resourceRepos) branches.set(repo.name, args.resourceBranch) + + const setupState: InitState = { + selectedOrg: targetOrg, + codeRepos, + resourceRepos, + branches, + shouldIndex: args.index, + } + + const setupResult = await executeInit(setupState, stdoutProgress) + + if (!setupResult.success) { + console.error(setupResult.error?.message || "Setup failed") + process.exit(1) + } + + process.exit(0) +} + +async function runTuiInitMode(args: CliArgs): Promise { + const renderer: CliRenderer = await createCliRenderer({ + exitOnCtrlC: false, + exitSignals: ["SIGTERM", "SIGQUIT", "SIGABRT", "SIGHUP"], + targetFps: 30, + }) + + const __dirname = dirname(fileURLToPath(import.meta.url)) + const { version } = JSON.parse( + readFileSync(join(__dirname, "..", "package.json"), "utf-8") + ) + + const versionBadge = new TextRenderable(renderer, { + id: "version-badge", + content: `v${version}`, + fg: "#888888", + position: "absolute", + right: 1, + bottom: 0, + zIndex: 1000, + }) + renderer.root.add(versionBadge) + + let ctrlCTimer: ReturnType | null = null + let ctrlCNode: TextRenderable | null = null + + renderer.keyInput.on("keypress", (key) => { + if (key.ctrl && key.name === "c") { + if (ctrlCTimer !== null) { + clearTimeout(ctrlCTimer) + renderer.destroy() + process.exit(0) + } else { + ctrlCNode = new TextRenderable(renderer, { + id: "ctrl-c-prompt", + content: "Press Ctrl+C again to exit", + fg: "#FFFF00", + }) + renderer.root.add(ctrlCNode) + ctrlCTimer = setTimeout(() => { + if (ctrlCNode) { + renderer.root.remove(ctrlCNode.id) + ctrlCNode = null + } + ctrlCTimer = null + }, 3000) + } + } + }) + + process.on("uncaughtException", () => { + renderer.destroy() + process.exit(1) + }) + process.on("unhandledRejection", () => { + renderer.destroy() + process.exit(1) + }) + + runInitialiseFlow(renderer, args, () => { + renderer.destroy() + process.exit(0) + }) +} diff --git a/packages/workbench-cli/src/screens/initNameInput.ts b/packages/workbench-cli/src/screens/initNameInput.ts new file mode 100644 index 0000000..1185aa1 --- /dev/null +++ b/packages/workbench-cli/src/screens/initNameInput.ts @@ -0,0 +1,79 @@ +import { + InputRenderable, + TextRenderable, + BoxRenderable, + type CliRenderer, + type KeyEvent, +} from "@opentui/core" +import { validateRepoName } from "../utils/gh.ts" + +const SCREEN_ID = "init-name-input-screen" + +export function showInitNameInput( + renderer: CliRenderer, + prefilledName: string, + onConfirm: (name: string) => void +): void { + const existing = renderer.root.getRenderable(SCREEN_ID) + if (existing) { + renderer.root.remove(SCREEN_ID) + } + + const container = new BoxRenderable(renderer, { + id: SCREEN_ID, + flexDirection: "column", + padding: 1, + }) + + const title = new TextRenderable(renderer, { + id: "init-name-title", + content: "Name Your Workbench", + fg: "#00FFFF", + }) + container.add(title) + + const hint = new TextRenderable(renderer, { + id: "init-name-hint", + content: "Enter a name (alphanumeric, -, ., _) | Enter to confirm", + fg: "#888888", + }) + container.add(hint) + + const nameInput = new InputRenderable(renderer, { + id: "init-name-input", + width: 50, + value: prefilledName, + backgroundColor: "#1a1a1a", + textColor: "#FFFFFF", + focusedBackgroundColor: "#2a2a2a", + }) + + const errorText = new TextRenderable(renderer, { + id: "init-name-error", + content: "", + fg: "#FF4444", + }) + errorText.visible = false + + container.add(nameInput) + container.add(errorText) + renderer.root.add(container) + + const keypressHandler = (key: KeyEvent) => { + if (key.name === "return" || key.name === "enter") { + const name = nameInput.value.trim() + if (!validateRepoName(name)) { + errorText.content = `Invalid name "${name}". Use only alphanumeric characters, hyphens, dots, and underscores.` + errorText.visible = true + return + } + renderer.keyInput.off("keypress", keypressHandler) + container.visible = false + onConfirm(name) + } + } + + renderer.keyInput.on("keypress", keypressHandler) + + nameInput.focus() +} diff --git a/packages/workbench-cli/src/screens/initOrgSelect.ts b/packages/workbench-cli/src/screens/initOrgSelect.ts new file mode 100644 index 0000000..6ba51fe --- /dev/null +++ b/packages/workbench-cli/src/screens/initOrgSelect.ts @@ -0,0 +1,130 @@ +import { + SelectRenderable, + SelectRenderableEvents, + InputRenderable, + InputRenderableEvents, + TextRenderable, + BoxRenderable, + type CliRenderer, + type KeyEvent, +} from "@opentui/core" +import { getOrgs, type GhOrg } from "../utils/gh.ts" +import { createSpinner } from "../utils/spinner.ts" + +const SCREEN_ID = "init-org-select-screen" + +export async function showInitOrgSelect( + renderer: CliRenderer, + preselectedOrg: string | undefined, + onSelect: (orgLogin: string) => void +): Promise { + const existing = renderer.root.getRenderable(SCREEN_ID) + if (existing) { + renderer.root.remove(SCREEN_ID) + } + + const container = new BoxRenderable(renderer, { + id: SCREEN_ID, + flexDirection: "column", + padding: 1, + }) + renderer.root.add(container) + + const spinner = createSpinner(renderer, "Loading organizations...") + spinner.start() + + let orgs: GhOrg[] + try { + orgs = await getOrgs() + } catch (err) { + spinner.stop() + const errText = new TextRenderable(renderer, { + id: "init-org-error", + content: `Error fetching orgs: ${err}`, + fg: "#FF4444", + }) + container.add(errText) + return + } + spinner.stop() + + const title = new TextRenderable(renderer, { + id: "init-org-title", + content: "Select Fork Target", + fg: "#00FFFF", + }) + container.add(title) + + const hint = new TextRenderable(renderer, { + id: "init-org-hint", + content: "Type to filter | Tab to switch focus | Enter to select", + fg: "#888888", + }) + container.add(hint) + + const filterInput = new InputRenderable(renderer, { + id: "init-org-filter", + width: 50, + placeholder: "Type to filter...", + backgroundColor: "#1a1a1a", + textColor: "#FFFFFF", + focusedBackgroundColor: "#2a2a2a", + }) + + const allOptions = orgs.map((o) => ({ + name: o.login, + description: o.description || "", + value: o.login, + })) + + const defaultIndex = preselectedOrg + ? allOptions.findIndex((o) => o.value === preselectedOrg) + : 0 + + const selectList = new SelectRenderable(renderer, { + id: "init-org-select-list", + width: 50, + height: 15, + options: allOptions, + selectedIndex: defaultIndex >= 0 ? defaultIndex : 0, + }) + + filterInput.on(InputRenderableEvents.CHANGE, (value: string) => { + const filtered = allOptions.filter((o) => + o.name.toLowerCase().includes(value.toLowerCase()) + ) + selectList.options = filtered.length > 0 ? filtered : allOptions + }) + + let listFocused = false + + const keypressHandler = (key: KeyEvent) => { + if (key.name === "tab") { + if (!listFocused) { + filterInput.blur() + selectList.focus() + listFocused = true + } else { + selectList.blur() + filterInput.focus() + listFocused = false + } + } + } + + selectList.on( + SelectRenderableEvents.ITEM_SELECTED, + (_index: number, option: { value: string }) => { + renderer.keyInput.off("keypress", keypressHandler) + container.visible = false + onSelect(option.value) + } + ) + + container.add(filterInput) + container.add(selectList) + + renderer.keyInput.on("keypress", keypressHandler) + + filterInput.focus() +} diff --git a/packages/workbench-cli/src/screens/initSetupPrompt.ts b/packages/workbench-cli/src/screens/initSetupPrompt.ts new file mode 100644 index 0000000..deeca4a --- /dev/null +++ b/packages/workbench-cli/src/screens/initSetupPrompt.ts @@ -0,0 +1,64 @@ +import { + SelectRenderable, + SelectRenderableEvents, + TextRenderable, + BoxRenderable, + type CliRenderer, +} from "@opentui/core" + +const SCREEN_ID = "init-setup-prompt-screen" + +export function showInitSetupPrompt( + renderer: CliRenderer, + onAnswer: (shouldSetup: boolean) => void +): void { + const existing = renderer.root.getRenderable(SCREEN_ID) + if (existing) { + renderer.root.remove(SCREEN_ID) + } + + const container = new BoxRenderable(renderer, { + id: SCREEN_ID, + flexDirection: "column", + padding: 1, + }) + + const title = new TextRenderable(renderer, { + id: "init-setup-title", + content: "Set up your workbench now?", + fg: "#00FFFF", + }) + container.add(title) + + const hint = new TextRenderable(renderer, { + id: "init-setup-hint", + content: "Running setup configures git submodules for your project", + fg: "#888888", + }) + container.add(hint) + + const options = [ + { name: "yes", description: "Run the setup wizard", value: "yes" }, + { name: "no", description: "Set up later with workbench --tui", value: "no" }, + ] + + const select = new SelectRenderable(renderer, { + id: "init-setup-select", + width: 40, + height: 4, + options, + selectedIndex: 0, + }) + + select.on( + SelectRenderableEvents.ITEM_SELECTED, + (_index: number, option: { value: string }) => { + container.visible = false + onAnswer(option.value === "yes") + } + ) + + container.add(select) + renderer.root.add(container) + select.focus() +} diff --git a/packages/workbench-cli/src/utils/gh.ts b/packages/workbench-cli/src/utils/gh.ts index 57f9684..fd9f089 100644 --- a/packages/workbench-cli/src/utils/gh.ts +++ b/packages/workbench-cli/src/utils/gh.ts @@ -63,3 +63,36 @@ export async function getRepos(orgLogin: string): Promise { defaultBranch: r.defaultBranchRef?.name ?? "main", })) } + +export function validateRepoName(name: string): boolean { + return name.length > 0 && name.length <= 100 && /^[a-zA-Z0-9._-]+$/.test(name) +} + +export async function repoExists(owner: string, repo: string): Promise { + try { + await execFileAsync("gh", ["api", `/repos/${owner}/${repo}`], { encoding: "utf8" }) + return true + } catch { + return false + } +} + +export async function forkRepo( + sourceRepo: string, + targetOrg: string, + forkName: string +): Promise<{ url: string; name: string }> { + const { stdout } = await execFileAsync("gh", [ + "repo", "fork", sourceRepo, + "--org", targetOrg, + "--fork-name", forkName, + "--clone=false", + "--json", "url,name", + ], { encoding: "utf8" }) + return JSON.parse(stdout) +} + +export async function getCurrentUserLogin(): Promise { + const { stdout } = await execFileAsync("gh", ["api", "/user", "--jq", ".login"], { encoding: "utf8" }) + return stdout.trim() +} From 45db543be894baf53fbbda76df99a5eb67cb1f6d Mon Sep 17 00:00:00 2001 From: Aboo Date: Wed, 1 Apr 2026 16:45:57 +1100 Subject: [PATCH 2/2] PAP-7020: Streamline repository initialization and fork progress Refactors the initialization flow to use `gh repo fork --clone`, enabling real-time progress output within the TUI instead of a static spinner. This update also adds support for personal GitHub accounts, optimizes cloning with `--single-branch`, and automatically changes the working directory to the newly created project folder upon completion. --- .../workbench-cli/src/commands/initialise.ts | 92 +++++++++++-------- packages/workbench-cli/src/index.ts | 1 + .../src/screens/initOrgSelect.ts | 6 +- packages/workbench-cli/src/utils/gh.ts | 22 +++-- 4 files changed, 71 insertions(+), 50 deletions(-) diff --git a/packages/workbench-cli/src/commands/initialise.ts b/packages/workbench-cli/src/commands/initialise.ts index b416b08..a77ef94 100644 --- a/packages/workbench-cli/src/commands/initialise.ts +++ b/packages/workbench-cli/src/commands/initialise.ts @@ -5,7 +5,6 @@ import { showInitOrgSelect } from "../screens/initOrgSelect.ts" import { showInitNameInput } from "../screens/initNameInput.ts" import { showInitSetupPrompt } from "../screens/initSetupPrompt.ts" import { showExecutingScreen } from "../screens/executing.ts" -import { createSpinner } from "../utils/spinner.ts" import type { InitProgress } from "./init.ts" import type { CliRenderer } from "@opentui/core" import type { CliArgs } from "../args.ts" @@ -16,6 +15,7 @@ export interface InitialiseState { name: string noFork: boolean targetOrg: string + isPersonalAccount: boolean } export interface InitialiseResult { @@ -35,10 +35,11 @@ export function validateInitialiseState(state: InitialiseState): string | null { } export async function executeFork( - state: InitialiseState -): Promise<{ success: boolean; error?: string; cloneUrl?: string }> { + state: InitialiseState, + progress: InitProgress +): Promise<{ success: boolean; error?: string }> { if (state.noFork) { - return { success: true, cloneUrl: `https://github.com/${SOURCE_REPO}.git` } + return { success: true } } const exists = await repoExists(state.targetOrg, state.name) @@ -46,8 +47,21 @@ export async function executeFork( return { success: false, error: `A repository named "${state.name}" already exists under ${state.targetOrg}.` } } - const forkResult = await forkRepo(SOURCE_REPO, state.targetOrg, state.name) - return { success: true, cloneUrl: forkResult.url } + const { onLine, startThrottle, stopThrottle } = progress + onLine(`--- Forking and cloning into ./${state.name}/ ---`, true, false) + startThrottle() + try { + await forkRepo( + SOURCE_REPO, + state.isPersonalAccount ? undefined : state.targetOrg, + state.name, + (line, isStderr, isCR) => onLine(line, false, isCR) + ) + } finally { + stopThrottle() + } + + return { success: true } } export async function executeClone( @@ -65,7 +79,7 @@ export async function executeClone( onLine(`--- Cloning into ./${name}/ ---`, true, false) startThrottle() try { - await runCommand("git", ["clone", "--depth", "1", cloneUrl, name], (line, _, isCR) => + await runCommand("git", ["clone", "--depth", "1", "--single-branch", cloneUrl, name], (line, _, isCR) => onLine(line, false, isCR) ) } finally { @@ -90,12 +104,19 @@ export async function executeInitialise( return { success: false, error: validationError } } - const forkResult = await executeFork(state) + if (state.noFork) { + const cloneUrl = `https://github.com/${SOURCE_REPO}.git` + return executeClone(cloneUrl, state.name, progress) + } + + const forkResult = await executeFork(state, progress) if (!forkResult.success) { return { success: false, error: forkResult.error } } - return executeClone(forkResult.cloneUrl!, state.name, progress) + process.chdir(state.name) + progress.onLine(`Working directory changed to ./${state.name}/`, false, false) + return { success: true, targetDir: state.name } } export function runInitialiseFlow( @@ -103,10 +124,10 @@ export function runInitialiseFlow( args: CliArgs, onComplete: () => void ): void { - void showInitOrgSelect(renderer, args.org, (targetOrg) => { + void showInitOrgSelect(renderer, args.org, (targetOrg, isPersonalAccount) => { setTimeout(() => { showInitNameInput(renderer, args.name, (name) => { - const state: InitialiseState = { name, noFork: args.noFork, targetOrg } + const state: InitialiseState = { name, noFork: args.noFork, targetOrg, isPersonalAccount } void runTuiInitialise(renderer, state, (success) => { if (!success) { onComplete() @@ -134,30 +155,6 @@ async function runTuiInitialise( state: InitialiseState, onDone: (success: boolean) => void ): Promise { - let cloneUrl: string - - if (!state.noFork) { - const spinner = createSpinner(renderer, `Forking to ${state.targetOrg}/${state.name}...`) - spinner.start() - const forkResult = await executeFork(state) - spinner.stop() - - if (!forkResult.success) { - const { appendLine, container } = showExecutingScreen(renderer) - appendLine(`Error: ${forkResult.error}`, true) - const handler = () => { - renderer.keyInput.off("keypress", handler) - container.visible = false - onDone(false) - } - renderer.keyInput.on("keypress", handler) - return - } - cloneUrl = forkResult.cloneUrl! - } else { - cloneUrl = `https://github.com/${SOURCE_REPO}.git` - } - const { appendLine, startThrottle, stopThrottle, container } = showExecutingScreen(renderer) const progress: InitProgress = { onLine: (line, isHeader, isCR) => appendLine(line, isHeader, isCR), @@ -165,18 +162,35 @@ async function runTuiInitialise( stopThrottle, } - const result = await executeClone(cloneUrl, state.name, progress) + let success: boolean - if (!result.success) { - appendLine(`Error: ${result.error}`, true) + if (!state.noFork) { + const forkResult = await executeFork(state, progress) + if (!forkResult.success) { + appendLine(`Error: ${forkResult.error}`, true) + success = false + } else { + process.chdir(state.name) + progress.onLine(`Working directory changed to ./${state.name}/`, false, false) + success = true + } } else { + const cloneUrl = `https://github.com/${SOURCE_REPO}.git` + const result = await executeClone(cloneUrl, state.name, progress) + success = result.success + if (!result.success) { + appendLine(`Error: ${result.error}`, true) + } + } + + if (success) { appendLine("--- Initialisation complete ---", true) } const handler = () => { renderer.keyInput.off("keypress", handler) container.visible = false - onDone(result.success) + onDone(success) } renderer.keyInput.on("keypress", handler) } diff --git a/packages/workbench-cli/src/index.ts b/packages/workbench-cli/src/index.ts index 49e1304..97f786a 100644 --- a/packages/workbench-cli/src/index.ts +++ b/packages/workbench-cli/src/index.ts @@ -176,6 +176,7 @@ async function runNonInteractiveInitCmd(args: CliArgs): Promise { name: args.name, noFork: args.noFork, targetOrg, + isPersonalAccount: !args.org, } const validationError = validateInitialiseState(state) diff --git a/packages/workbench-cli/src/screens/initOrgSelect.ts b/packages/workbench-cli/src/screens/initOrgSelect.ts index 6ba51fe..427dab7 100644 --- a/packages/workbench-cli/src/screens/initOrgSelect.ts +++ b/packages/workbench-cli/src/screens/initOrgSelect.ts @@ -16,7 +16,7 @@ const SCREEN_ID = "init-org-select-screen" export async function showInitOrgSelect( renderer: CliRenderer, preselectedOrg: string | undefined, - onSelect: (orgLogin: string) => void + onSelect: (orgLogin: string, isPersonalAccount: boolean) => void ): Promise { const existing = renderer.root.getRenderable(SCREEN_ID) if (existing) { @@ -117,7 +117,9 @@ export async function showInitOrgSelect( (_index: number, option: { value: string }) => { renderer.keyInput.off("keypress", keypressHandler) container.visible = false - onSelect(option.value) + const selectedOrg = orgs.find((o) => o.login === option.value) + const isPersonalAccount = selectedOrg?.description === "Personal account" + onSelect(option.value, isPersonalAccount) } ) diff --git a/packages/workbench-cli/src/utils/gh.ts b/packages/workbench-cli/src/utils/gh.ts index fd9f089..8986729 100644 --- a/packages/workbench-cli/src/utils/gh.ts +++ b/packages/workbench-cli/src/utils/gh.ts @@ -2,6 +2,7 @@ import { execSync, execFile } from "child_process" import { promisify } from "util" import { existsSync } from "fs" import { join } from "path" +import { runCommand, type LineHandler } from "./spawn.ts" const execFileAsync = promisify(execFile) @@ -79,17 +80,20 @@ export async function repoExists(owner: string, repo: string): Promise export async function forkRepo( sourceRepo: string, - targetOrg: string, - forkName: string -): Promise<{ url: string; name: string }> { - const { stdout } = await execFileAsync("gh", [ + targetOrg: string | undefined, + forkName: string, + onLine: LineHandler +): Promise { + const args = [ "repo", "fork", sourceRepo, - "--org", targetOrg, "--fork-name", forkName, - "--clone=false", - "--json", "url,name", - ], { encoding: "utf8" }) - return JSON.parse(stdout) + "--clone", + "--default-branch-only", + ] + if (targetOrg !== undefined) { + args.push("--org", targetOrg) + } + await runCommand("gh", args, onLine) } export async function getCurrentUserLogin(): Promise {