diff --git a/.workbench/schemas/settings.json b/.workbench/schemas/settings.json index 2bbdd6f..3dc9d58 100644 --- a/.workbench/schemas/settings.json +++ b/.workbench/schemas/settings.json @@ -110,6 +110,34 @@ "ck_hybrid_search": true } }, + "sync": { + "description": "Controls which files and directories are synced from the source workbench", + "type": "object", + "properties": { + "paths": { + "description": "Paths to sync from the source workbench (directories are merged, files are overwritten)", + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1, + "default": [ + ".opencode", + ".workbench/schemas", + "README.md" + ] + } + }, + "additionalProperties": false, + "default": { + "paths": [ + ".opencode", + ".workbench/schemas", + "README.md" + ] + } + }, "ticketer": { "description": "Settings for the ticketer workflow agent behavior", "type": "object", diff --git a/.workbench/settings.yml b/.workbench/settings.yml index afeebde..7061d98 100644 --- a/.workbench/settings.yml +++ b/.workbench/settings.yml @@ -14,3 +14,8 @@ orchestrator: tools: ck_semantic_search: false ck_hybrid_search: false +sync: + paths: + - .opencode + - .workbench/schemas + - README.md diff --git a/packages/workbench-cli/bun.lock b/packages/workbench-cli/bun.lock index aa032e3..e2b5a81 100644 --- a/packages/workbench-cli/bun.lock +++ b/packages/workbench-cli/bun.lock @@ -5,7 +5,7 @@ "": { "name": "workbench-cli", "dependencies": { - "@opentui/core": "^0.1.90", + "@opentui/core": "0.1.90", "js-yaml": "^4.1.0", }, "devDependencies": { diff --git a/packages/workbench-cli/src/args.ts b/packages/workbench-cli/src/args.ts index fec3bf1..8fc9b89 100644 --- a/packages/workbench-cli/src/args.ts +++ b/packages/workbench-cli/src/args.ts @@ -14,6 +14,7 @@ export interface CliArgs { remote: boolean name: string noTui: boolean + sync: boolean } export function parseCliArgs(): CliArgs { @@ -32,6 +33,7 @@ export function parseCliArgs(): CliArgs { remote: { type: "boolean", default: false }, name: { type: "string", default: "workbench" }, "no-tui": { type: "boolean", default: false }, + sync: { type: "boolean", default: false }, }, strict: true, allowPositionals: false, @@ -51,6 +53,7 @@ export function parseCliArgs(): CliArgs { remote: values.remote as boolean, name: values.name as string, noTui: values["no-tui"] as boolean, + sync: values.sync as boolean, } } @@ -62,6 +65,7 @@ USAGE: workbench --init --no-tui [options] workbench --org --code-repository [options] workbench --tui + workbench --sync workbench --help OPTIONS: @@ -76,6 +80,7 @@ OPTIONS: --code-branch Branch for all code repositories (default: main) --resource-branch Branch for all resource repositories (default: main) --index Run indexing after init (default: on) + --sync Sync workbench files from the source repository --tui Launch interactive TUI mode --help Display this help message @@ -87,5 +92,6 @@ EXAMPLES: workbench --init --no-tui --name my-project --remote --org myorg --code-repository https://github.com/myorg/api workbench --org myorg --code-repository https://github.com/myorg/backend workbench --tui + workbench --sync `) } diff --git a/packages/workbench-cli/src/commands/initialise.ts b/packages/workbench-cli/src/commands/initialise.ts index 12a5e94..3147be1 100644 --- a/packages/workbench-cli/src/commands/initialise.ts +++ b/packages/workbench-cli/src/commands/initialise.ts @@ -8,6 +8,7 @@ import { showExecutingScreen } from "../screens/executing.ts" import { showSourceInput } from "../screens/initSourceInput.ts" import { showRemotePrompt } from "../screens/initRemotePrompt.ts" import { showRemoteNameInput } from "../screens/initRemoteNameInput.ts" +import { writeSourceConfig } from "../utils/config.ts" import type { InitProgress } from "./init.ts" import type { CliRenderer } from "@opentui/core" import type { CliArgs } from "../args.ts" @@ -144,6 +145,9 @@ export async function executeInitialise( return reinitResult } + // Persist source info so --sync knows where to fetch from + writeSourceConfig(state.source, "main") + return result } diff --git a/packages/workbench-cli/src/commands/sync.ts b/packages/workbench-cli/src/commands/sync.ts new file mode 100644 index 0000000..d3775d1 --- /dev/null +++ b/packages/workbench-cli/src/commands/sync.ts @@ -0,0 +1,227 @@ +import { createInterface } from "readline" +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmdirSync, statSync } from "fs" +import { join, dirname } from "path" +import { tmpdir } from "os" +import { load } from "js-yaml" +import { runCommand } from "../utils/spawn.ts" +import { readConfig, type WorkbenchConfig } from "../utils/config.ts" + +// --- Types --- +export interface SyncResult { + success: boolean + error?: string + upToDate?: boolean + commitMessage?: string +} + +// --- Confirmation Prompt --- +function confirm(prompt: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }) + return new Promise((resolve) => { + rl.question(prompt, (answer) => { + rl.close() + resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes") + }) + }) +} + +// --- Sync Path Parsing --- +interface RemoteSettings { + sync?: { + paths?: string[] + } +} + +function readSettingsFromPath(dirPath: string): RemoteSettings { + const settingsPath = join(dirPath, ".workbench", "settings.yml") + if (!existsSync(settingsPath)) { + return {} + } + const raw = readFileSync(settingsPath, "utf-8") + return load(raw) as RemoteSettings +} + +// --- File Operations --- +function mergeDirectory(srcDir: string, destDir: string): void { + mkdirSync(destDir, { recursive: true }) + + const entries = readdirSync(srcDir, { recursive: true }) as string[] + for (const entry of entries) { + const srcPath = join(srcDir, entry) + const destPath = join(destDir, entry) + + if (statSync(srcPath).isDirectory()) { + mkdirSync(destPath, { recursive: true }) + } else { + mkdirSync(dirname(destPath), { recursive: true }) + copyFileSync(srcPath, destPath) + } + } +} + +function mergeFile(srcPath: string, destPath: string): void { + mkdirSync(dirname(destPath), { recursive: true }) + copyFileSync(srcPath, destPath) +} + +// --- Main Sync Function --- +export async function executeSync(): Promise { + // Step 1: Validate location + if (!existsSync(".workbench/config.yaml")) { + return { + success: false, + error: "Error: No .workbench/config.yaml found. Run `workbench --init` first to initialise a workbench." + } + } + + let config: WorkbenchConfig + try { + config = readConfig() + } catch (err) { + return { + success: false, + error: `Error: Failed to read .workbench/config.yaml: ${err}` + } + } + + if (!config.source?.repository) { + return { + success: false, + error: "Error: No source.repository found in .workbench/config.yaml. The workbench may have been initialised before this feature was available. Re-initialise to enable syncing." + } + } + + const { repository, branch } = config.source + const sourceUrl = `https://github.com/${repository}.git` + + // Step 2: Check working tree cleanliness + console.log("Checking working tree status...") + const statusLines: string[] = [] + try { + await runCommand("git", ["status", "--porcelain"], (line) => { + statusLines.push(line) + }) + } catch (err) { + return { + success: false, + error: "Error: Failed to check git status. Ensure you are in a git repository." + } + } + + const isDirty = statusLines.some((line) => line.trim().length > 0) + if (isDirty) { + return { + success: false, + error: "Error: Working tree is not clean. Please commit or stash your changes before syncing." + } + } + + // Step 3: Warn and confirm + console.log("\n⚠️ WARNING: This will overwrite local managed files with the latest") + console.log(" version from the source workbench. Any customisations to managed") + console.log(" paths will be lost.\n") + console.log(`Source: ${repository} (${branch})`) + console.log("") + + const confirmed = await confirm("Do you want to continue? [y/N] ") + if (!confirmed) { + console.log("Sync aborted.") + return { success: false } + } + + // Step 4: Fetch source into temp directory + let tempDir: string + try { + tempDir = mkdtempSync(join(tmpdir(), "workbench-sync-")) + } catch (err) { + return { + success: false, + error: `Error: Failed to create temporary directory: ${err}` + } + } + + const cleanupTempDir = () => { + try { rmdirSync(tempDir, { recursive: true }) } catch {} + } + + try { + console.log(`Fetching source from ${repository} (branch: ${branch})...`) + await runCommand("git", [ + "clone", "--depth", "1", "--single-branch", + "--branch", branch, sourceUrl, tempDir + ], () => {}) + + // Step 5: Read remote sync paths + const remoteSettings = readSettingsFromPath(tempDir) + const syncPaths = remoteSettings.sync?.paths + + if (!syncPaths || syncPaths.length === 0) { + return { + success: false, + error: "Error: No sync.paths found in the source workbench's .workbench/settings.yml. The source workbench may not support syncing yet." + } + } + + console.log(`Syncing ${syncPaths.length} path(s): ${syncPaths.join(", ")}`) + + // Step 6: Apply sync + for (const syncPath of syncPaths) { + const srcPath = join(tempDir, syncPath) + const destPath = join(process.cwd(), syncPath) + + if (!existsSync(srcPath)) { + console.log(` ⚠ Source path "${syncPath}" does not exist in the fetched source. Skipping.`) + continue + } + + const srcStat = statSync(srcPath) + if (srcStat.isDirectory()) { + mergeDirectory(srcPath, destPath) + } else { + mergeFile(srcPath, destPath) + } + } + + // Step 7: Detect changes + const diffLines: string[] = [] + await runCommand("git", ["status", "--porcelain"], (line) => { + diffLines.push(line) + }) + + const hasChanges = diffLines.some((line) => line.trim().length > 0) + if (!hasChanges) { + console.log("\n✅ Your workbench is already up to date.") + cleanupTempDir() + return { success: true, upToDate: true } + } + + // Step 8: Get source commit SHA and auto-commit + const shaOutput: string[] = [] + await runCommand("git", ["-C", tempDir, "rev-parse", "--short", "HEAD"], (line) => { + shaOutput.push(line) + }) + const shortSha = shaOutput.join("").trim() + + const commitMessage = `chore: sync workbench from ${repository}@${shortSha}` + console.log(`\nChanges detected. Committing with message: "${commitMessage}"`) + + await runCommand("git", ["add", "."], () => {}) + await runCommand("git", ["commit", "-m", commitMessage], () => {}) + + // Step 9: Clean up + cleanupTempDir() + + // Step 10: Report + console.log(`\n✅ Sync complete. Commit created:`) + console.log(` ${commitMessage}`) + + return { success: true, commitMessage } + + } catch (err) { + cleanupTempDir() + return { + success: false, + error: `Error: Sync failed: ${err}` + } + } +} diff --git a/packages/workbench-cli/src/index.ts b/packages/workbench-cli/src/index.ts index ac185d6..e58e5a8 100644 --- a/packages/workbench-cli/src/index.ts +++ b/packages/workbench-cli/src/index.ts @@ -6,6 +6,7 @@ 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, executeCreateRemote, validateInitialiseState, runInitialiseFlow, type InitialiseState } from "./commands/initialise.ts" +import { executeSync } from "./commands/sync.ts" import { parseCliArgs, printHelp, type CliArgs } from "./args.ts" import { buildRepoFromUrl } from "./utils/repo.ts" import type { Repo } from "./screens/repoSelect.ts" @@ -18,7 +19,9 @@ if (args.help || process.argv.length === 2) { process.exit(0) } -if (args.init) { +if (args.sync) { + void runSync(args) +} else if (args.init) { if (args.noTui) { void runNonInteractiveInitCmd(args) } else { @@ -30,6 +33,19 @@ if (args.init) { void runNonInteractiveInit(args) } +async function runSync(args: CliArgs): Promise { + const result = await executeSync() + + if (!result.success) { + if (result.error) { + console.error(result.error) + } + process.exit(1) + } + + process.exit(0) +} + async function runTuiMode(): Promise { checkAuth() checkRepoRoot() diff --git a/packages/workbench-cli/src/utils/config.ts b/packages/workbench-cli/src/utils/config.ts index 34625d1..f4f1087 100644 --- a/packages/workbench-cli/src/utils/config.ts +++ b/packages/workbench-cli/src/utils/config.ts @@ -1,20 +1,47 @@ -import { dump } from "js-yaml" -import { writeFileSync, mkdirSync } from "fs" +import { dump, load } from "js-yaml" +import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs" import type { Repo } from "../screens/repoSelect.ts" -interface WorkbenchConfig { +export interface WorkbenchConfig { + source: { + repository: string // e.g., "plan-and-publish/workbench" + branch: string // e.g., "main" + } github: { org: string } codes: Array> resources: Array> } +export function readConfig(): WorkbenchConfig { + const raw = readFileSync(".workbench/config.yaml", "utf-8") + return load(raw) as WorkbenchConfig +} + +export function writeSourceConfig(repository: string, branch: string): void { + mkdirSync(".workbench", { recursive: true }) + let existing: Partial = {} + if (existsSync(".workbench/config.yaml")) { + existing = load(readFileSync(".workbench/config.yaml", "utf-8")) as Partial + } + const config = { ...existing, source: { repository, branch } } + writeFileSync(".workbench/config.yaml", dump(config)) +} + export function writeConfig( org: string, codeRepos: Repo[], resourceRepos: Repo[], branches: Map ): void { + // Preserve source info if config already exists + let existingSource: WorkbenchConfig["source"] | undefined + if (existsSync(".workbench/config.yaml")) { + const existing = load(readFileSync(".workbench/config.yaml", "utf-8")) as Partial + existingSource = existing.source + } + const config: WorkbenchConfig = { + source: existingSource ?? { repository: "", branch: "" }, github: { org }, codes: codeRepos.map((r) => ({ [r.name]: { url: r.url, branch: branches.get(r.name) ?? r.defaultBranch },