Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .workbench/schemas/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions .workbench/settings.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,8 @@ orchestrator:
tools:
ck_semantic_search: false
ck_hybrid_search: false
sync:
paths:
- .opencode
- .workbench/schemas
- README.md
2 changes: 1 addition & 1 deletion packages/workbench-cli/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions packages/workbench-cli/src/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface CliArgs {
remote: boolean
name: string
noTui: boolean
sync: boolean
}

export function parseCliArgs(): CliArgs {
Expand All @@ -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,
Expand All @@ -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,
}
}

Expand All @@ -62,6 +65,7 @@ USAGE:
workbench --init --no-tui [options]
workbench --org <name> --code-repository <url> [options]
workbench --tui
workbench --sync
workbench --help

OPTIONS:
Expand All @@ -76,6 +80,7 @@ OPTIONS:
--code-branch <name> Branch for all code repositories (default: main)
--resource-branch <name> Branch for all resource repositories (default: main)
--index <on|off> Run indexing after init (default: on)
--sync Sync workbench files from the source repository
--tui Launch interactive TUI mode
--help Display this help message

Expand All @@ -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
`)
}
4 changes: 4 additions & 0 deletions packages/workbench-cli/src/commands/initialise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
}

Expand Down
227 changes: 227 additions & 0 deletions packages/workbench-cli/src/commands/sync.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<SyncResult> {
// 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}`
}
}
}
18 changes: 17 additions & 1 deletion packages/workbench-cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand All @@ -30,6 +33,19 @@ if (args.init) {
void runNonInteractiveInit(args)
}

async function runSync(args: CliArgs): Promise<void> {
const result = await executeSync()

if (!result.success) {
if (result.error) {
console.error(result.error)
}
process.exit(1)
}

process.exit(0)
}

async function runTuiMode(): Promise<void> {
checkAuth()
checkRepoRoot()
Expand Down
Loading
Loading