diff --git a/README.md b/README.md index 3cd344f..3de6944 100644 --- a/README.md +++ b/README.md @@ -44,10 +44,23 @@ bunx open-workflows [OPTIONS] OPTIONS --skills Install skills only --workflows Install workflows only + --force, -f Override existing files without prompts --version, -v Display version --help, -h Display help ``` +### Override Behavior + +By default, the CLI will prompt you to confirm overriding each existing file. Use `--force` to skip prompts and override all existing files automatically. + +```bash +# Override all existing files +bunx open-workflows --force + +# Interactive mode - prompts for each existing file +bunx open-workflows +``` + ## Plugin Installation Add to your `opencode.json`: diff --git a/docs/setup.md b/docs/setup.md index 7cf087e..8d6665e 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -20,6 +20,29 @@ This interactive installer will: 3. Install GitHub Actions to `.github/workflows/` 4. Create/update `.opencode/opencode.json` +### CLI Options + +```bash +bunx open-workflows [OPTIONS] + +OPTIONS + --skills Install skills only + --workflows Install workflows only + --force, -f Override existing files without prompts + --version, -v Display version + --help, -h Display help +``` + +By default, the CLI will prompt you to confirm overriding each existing file. Use `--force` to skip prompts and override all existing files automatically: + +```bash +# Override all existing files +bunx open-workflows --force + +# Interactive mode - prompts for each existing file +bunx open-workflows +``` + ## Manual Setup ### 1. Install the Plugin diff --git a/src/cli/index.ts b/src/cli/index.ts index 42dee9f..98b6be5 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -2,7 +2,17 @@ import * as p from '@clack/prompts'; import color from 'picocolors'; -import { installWorkflows, installSkills, installAuthWorkflow, createOpencodeConfig, type InstallResult } from './installer'; +import { + installWorkflows, + installSkills, + installAuthWorkflow, + createOpencodeConfig, + checkExistingWorkflows, + checkExistingSkills, + checkExistingAuthWorkflow, + type InstallResult, + type ExistingFile, +} from './installer'; import type { WorkflowType } from './templates'; const pkg = await import('../../package.json').catch(() => ({ version: 'unknown' })); @@ -13,6 +23,7 @@ const isHelp = args.includes('--help') || args.includes('-h'); const isVersion = args.includes('--version') || args.includes('-v'); const isSkillsOnly = args.includes('--skills'); const isWorkflowsOnly = args.includes('--workflows'); +const isForce = args.includes('--force') || args.includes('-f'); if (isVersion) { process.stdout.write(`@activade/open-workflows v${cliVersion}\n`); @@ -30,6 +41,7 @@ USAGE OPTIONS --skills Install skills only (no workflows) --workflows Install workflows only (no skills) + --force, -f Override existing files without prompts --version, -v Display version --help, -h Display this help @@ -45,7 +57,7 @@ For more information: https://github.com/activadee/open-workflows p.intro(color.bgCyan(color.black(` @activade/open-workflows v${cliVersion} `))); -const results = await p.group( +const promptResults = await p.group( { workflows: () => p.multiselect({ @@ -79,24 +91,81 @@ const results = await p.group( } ); +const selectedWorkflows = (promptResults.workflows || []) as WorkflowType[]; +const useOAuth = Boolean(promptResults.useOAuth); + +const skillOverrides = new Set(); +const workflowOverrides = new Set(); +let overrideAuth = false; + +if (!isForce) { + const existingFiles: ExistingFile[] = []; + + if (!isWorkflowsOnly) { + existingFiles.push(...checkExistingSkills({})); + } + + if (!isSkillsOnly) { + existingFiles.push(...checkExistingWorkflows({ workflows: selectedWorkflows })); + if (useOAuth) { + const authFile = checkExistingAuthWorkflow({}); + if (authFile) { + existingFiles.push(authFile); + } + } + } + + if (existingFiles.length > 0) { + p.log.warn(`Found ${existingFiles.length} existing file(s):`); + + for (const file of existingFiles) { + const shouldOverride = await p.confirm({ + message: `Override ${file.path}?`, + initialValue: false, + }); + + if (p.isCancel(shouldOverride)) { + p.cancel('Installation cancelled.'); + process.exit(0); + } + + if (shouldOverride) { + if (file.type === 'skill') { + skillOverrides.add(file.name); + } else if (file.type === 'workflow') { + workflowOverrides.add(file.name); + } else if (file.type === 'auth') { + overrideAuth = true; + } + } + } + } +} + const s = p.spinner(); s.start('Installing open-workflows...'); const allResults: InstallResult[] = []; -const selectedWorkflows = (results.workflows || []) as WorkflowType[]; -const useOAuth = Boolean(results.useOAuth); if (!isWorkflowsOnly) { - const skillResults = installSkills({}); + const skillResults = installSkills({ + override: isForce, + overrideNames: isForce ? undefined : skillOverrides, + }); allResults.push(...skillResults); } if (!isSkillsOnly) { - const workflowResults = installWorkflows({ workflows: selectedWorkflows, useOAuth }); + const workflowResults = installWorkflows({ + workflows: selectedWorkflows, + useOAuth, + override: isForce, + overrideNames: isForce ? undefined : workflowOverrides, + }); allResults.push(...workflowResults); if (useOAuth) { - const authResult = installAuthWorkflow({}); + const authResult = installAuthWorkflow({ override: isForce || overrideAuth }); allResults.push(authResult); } } @@ -114,6 +183,7 @@ const hasErrors = allResults.some((r) => r.status === 'error'); s.stop(hasErrors ? 'Installation completed with errors' : 'Installation complete!'); const created = allResults.filter((r) => r.status === 'created'); +const overwritten = allResults.filter((r) => r.status === 'overwritten'); const skipped = allResults.filter((r) => r.status === 'skipped'); const errors = allResults.filter((r) => r.status === 'error'); @@ -124,6 +194,13 @@ if (created.length > 0) { } } +if (overwritten.length > 0) { + p.log.success(`Overwritten ${overwritten.length} file(s):`); + for (const r of overwritten) { + p.log.message(` ${color.cyan('◆')} ${r.path}`); + } +} + if (skipped.length > 0) { p.log.warn(`Skipped ${skipped.length} file(s) (already exist):`); for (const r of skipped) { diff --git a/src/cli/installer.ts b/src/cli/installer.ts index 0a6ffd2..e94447c 100644 --- a/src/cli/installer.ts +++ b/src/cli/installer.ts @@ -18,10 +18,68 @@ const WORKFLOW_GENERATORS: Record string> = { release: RELEASE, }; +export interface ExistingFile { + type: 'workflow' | 'skill' | 'auth'; + name: string; + path: string; +} + +export function checkExistingWorkflows(options: { workflows: WorkflowType[]; cwd?: string }): ExistingFile[] { + const { workflows, cwd = process.cwd() } = options; + const workflowDir = path.join(cwd, '.github', 'workflows'); + const existing: ExistingFile[] = []; + + for (const wf of workflows) { + const fileName = WORKFLOW_FILE_MAP[wf]; + const filePath = path.join(workflowDir, `${fileName}.yml`); + if (fs.existsSync(filePath)) { + existing.push({ + type: 'workflow', + name: wf, + path: `.github/workflows/${fileName}.yml`, + }); + } + } + + return existing; +} + +export function checkExistingSkills(options: { cwd?: string }): ExistingFile[] { + const { cwd = process.cwd() } = options; + const targetDir = path.join(cwd, '.opencode', 'skill'); + const existing: ExistingFile[] = []; + + for (const name of SKILL_NAMES) { + const destPath = path.join(targetDir, name, 'SKILL.md'); + if (fs.existsSync(destPath)) { + existing.push({ + type: 'skill', + name, + path: `.opencode/skill/${name}/SKILL.md`, + }); + } + } + + return existing; +} + +export function checkExistingAuthWorkflow(options: { cwd?: string }): ExistingFile | null { + const { cwd = process.cwd() } = options; + const filePath = path.join(cwd, '.github', 'workflows', 'opencode-auth.yml'); + if (fs.existsSync(filePath)) { + return { + type: 'auth', + name: 'opencode-auth', + path: '.github/workflows/opencode-auth.yml', + }; + } + return null; +} + export interface InstallResult { type: 'workflow' | 'skill' | 'config' | 'auth'; name: string; - status: 'created' | 'skipped' | 'error'; + status: 'created' | 'skipped' | 'overwritten' | 'error'; path: string; message: string; } @@ -30,10 +88,12 @@ export interface InstallOptions { workflows: WorkflowType[]; cwd?: string; useOAuth?: boolean; + override?: boolean; + overrideNames?: Set; } export function installWorkflows(options: InstallOptions): InstallResult[] { - const { workflows, cwd = process.cwd(), useOAuth = false } = options; + const { workflows, cwd = process.cwd(), useOAuth = false, override = false, overrideNames } = options; const results: InstallResult[] = []; const workflowDir = path.join(cwd, '.github', 'workflows'); @@ -70,7 +130,10 @@ export function installWorkflows(options: InstallOptions): InstallResult[] { continue; } - if (fs.existsSync(filePath)) { + const fileExists = fs.existsSync(filePath); + const shouldOverride = override || overrideNames?.has(wf); + + if (fileExists && !shouldOverride) { results.push({ type: 'workflow', name: wf, @@ -86,9 +149,9 @@ export function installWorkflows(options: InstallOptions): InstallResult[] { results.push({ type: 'workflow', name: wf, - status: 'created', + status: fileExists ? 'overwritten' : 'created', path: `.github/workflows/${fileName}.yml`, - message: `Created successfully`, + message: fileExists ? 'Overwritten successfully' : 'Created successfully', }); } catch (error) { results.push({ @@ -104,8 +167,8 @@ export function installWorkflows(options: InstallOptions): InstallResult[] { return results; } -export function installAuthWorkflow(options: { cwd?: string }): InstallResult { - const { cwd = process.cwd() } = options; +export function installAuthWorkflow(options: { cwd?: string; override?: boolean }): InstallResult { + const { cwd = process.cwd(), override = false } = options; const workflowDir = path.join(cwd, '.github', 'workflows'); const filePath = path.join(workflowDir, 'opencode-auth.yml'); @@ -113,7 +176,8 @@ export function installAuthWorkflow(options: { cwd?: string }): InstallResult { fs.mkdirSync(workflowDir, { recursive: true }); } - if (fs.existsSync(filePath)) { + const fileExists = fs.existsSync(filePath); + if (fileExists && !override) { return { type: 'auth', name: 'opencode-auth', @@ -128,9 +192,9 @@ export function installAuthWorkflow(options: { cwd?: string }): InstallResult { return { type: 'auth', name: 'opencode-auth', - status: 'created', + status: fileExists ? 'overwritten' : 'created', path: '.github/workflows/opencode-auth.yml', - message: 'Created successfully', + message: fileExists ? 'Overwritten successfully' : 'Created successfully', }; } catch (error) { return { @@ -143,8 +207,8 @@ export function installAuthWorkflow(options: { cwd?: string }): InstallResult { } } -export function installSkills(options: { cwd?: string }): InstallResult[] { - const { cwd = process.cwd() } = options; +export function installSkills(options: { cwd?: string; override?: boolean; overrideNames?: Set }): InstallResult[] { + const { cwd = process.cwd(), override = false, overrideNames } = options; const results: InstallResult[] = []; const targetDir = path.join(cwd, '.opencode', 'skill'); @@ -152,7 +216,10 @@ export function installSkills(options: { cwd?: string }): InstallResult[] { const skill = SKILLS[name]; const destPath = path.join(targetDir, name, 'SKILL.md'); - if (fs.existsSync(destPath)) { + const fileExists = fs.existsSync(destPath); + const shouldOverride = override || overrideNames?.has(name); + + if (fileExists && !shouldOverride) { results.push({ type: 'skill', name, @@ -169,9 +236,9 @@ export function installSkills(options: { cwd?: string }): InstallResult[] { results.push({ type: 'skill', name, - status: 'created', + status: fileExists ? 'overwritten' : 'created', path: `.opencode/skill/${name}/SKILL.md`, - message: `Created successfully`, + message: fileExists ? 'Overwritten successfully' : 'Created successfully', }); } catch (error) { results.push({ diff --git a/test/installer.test.js b/test/installer.test.js new file mode 100644 index 0000000..7774562 --- /dev/null +++ b/test/installer.test.js @@ -0,0 +1,219 @@ +import { describe, expect, it, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { + installWorkflows, + installSkills, + installAuthWorkflow, + checkExistingWorkflows, + checkExistingSkills, + checkExistingAuthWorkflow, +} from '../src/cli/installer.ts'; + +describe('installer override functionality', () => { + let tempDir; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'open-workflows-test-')); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + describe('installWorkflows', () => { + it('creates new workflow files', () => { + const results = installWorkflows({ + workflows: ['review'], + cwd: tempDir, + useOAuth: false, + }); + + expect(results).toHaveLength(1); + expect(results[0].status).toBe('created'); + expect(results[0].name).toBe('review'); + expect(fs.existsSync(path.join(tempDir, '.github', 'workflows', 'pr-review.yml'))).toBe(true); + }); + + it('skips existing files without override', () => { + const workflowDir = path.join(tempDir, '.github', 'workflows'); + fs.mkdirSync(workflowDir, { recursive: true }); + fs.writeFileSync(path.join(workflowDir, 'pr-review.yml'), 'existing content'); + + const results = installWorkflows({ + workflows: ['review'], + cwd: tempDir, + useOAuth: false, + }); + + expect(results).toHaveLength(1); + expect(results[0].status).toBe('skipped'); + expect(fs.readFileSync(path.join(workflowDir, 'pr-review.yml'), 'utf-8')).toBe('existing content'); + }); + + it('overwrites existing files with override=true', () => { + const workflowDir = path.join(tempDir, '.github', 'workflows'); + fs.mkdirSync(workflowDir, { recursive: true }); + fs.writeFileSync(path.join(workflowDir, 'pr-review.yml'), 'existing content'); + + const results = installWorkflows({ + workflows: ['review'], + cwd: tempDir, + useOAuth: false, + override: true, + }); + + expect(results).toHaveLength(1); + expect(results[0].status).toBe('overwritten'); + expect(fs.readFileSync(path.join(workflowDir, 'pr-review.yml'), 'utf-8')).not.toBe('existing content'); + }); + + it('overwrites specific files with overrideNames', () => { + const workflowDir = path.join(tempDir, '.github', 'workflows'); + fs.mkdirSync(workflowDir, { recursive: true }); + fs.writeFileSync(path.join(workflowDir, 'pr-review.yml'), 'existing review'); + fs.writeFileSync(path.join(workflowDir, 'issue-label.yml'), 'existing label'); + + const results = installWorkflows({ + workflows: ['review', 'label'], + cwd: tempDir, + useOAuth: false, + overrideNames: new Set(['review']), + }); + + expect(results).toHaveLength(2); + + const reviewResult = results.find(r => r.name === 'review'); + const labelResult = results.find(r => r.name === 'label'); + + expect(reviewResult.status).toBe('overwritten'); + expect(labelResult.status).toBe('skipped'); + }); + }); + + describe('installSkills', () => { + it('creates new skill files', () => { + const results = installSkills({ cwd: tempDir }); + + expect(results.length).toBeGreaterThan(0); + expect(results.every(r => r.status === 'created')).toBe(true); + }); + + it('skips existing files without override', () => { + const skillDir = path.join(tempDir, '.opencode', 'skill', 'pr-review'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), 'existing content'); + + const results = installSkills({ cwd: tempDir }); + + const prReviewResult = results.find(r => r.name === 'pr-review'); + expect(prReviewResult.status).toBe('skipped'); + }); + + it('overwrites existing files with override=true', () => { + const skillDir = path.join(tempDir, '.opencode', 'skill', 'pr-review'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), 'existing content'); + + const results = installSkills({ cwd: tempDir, override: true }); + + const prReviewResult = results.find(r => r.name === 'pr-review'); + expect(prReviewResult.status).toBe('overwritten'); + }); + + it('overwrites specific files with overrideNames', () => { + const skillDir1 = path.join(tempDir, '.opencode', 'skill', 'pr-review'); + const skillDir2 = path.join(tempDir, '.opencode', 'skill', 'issue-label'); + fs.mkdirSync(skillDir1, { recursive: true }); + fs.mkdirSync(skillDir2, { recursive: true }); + fs.writeFileSync(path.join(skillDir1, 'SKILL.md'), 'existing review'); + fs.writeFileSync(path.join(skillDir2, 'SKILL.md'), 'existing label'); + + const results = installSkills({ + cwd: tempDir, + overrideNames: new Set(['pr-review']), + }); + + const prReviewResult = results.find(r => r.name === 'pr-review'); + const issueLabelResult = results.find(r => r.name === 'issue-label'); + + expect(prReviewResult.status).toBe('overwritten'); + expect(issueLabelResult.status).toBe('skipped'); + }); + }); + + describe('installAuthWorkflow', () => { + it('creates new auth workflow file', () => { + const result = installAuthWorkflow({ cwd: tempDir }); + + expect(result.status).toBe('created'); + expect(fs.existsSync(path.join(tempDir, '.github', 'workflows', 'opencode-auth.yml'))).toBe(true); + }); + + it('skips existing file without override', () => { + const workflowDir = path.join(tempDir, '.github', 'workflows'); + fs.mkdirSync(workflowDir, { recursive: true }); + fs.writeFileSync(path.join(workflowDir, 'opencode-auth.yml'), 'existing content'); + + const result = installAuthWorkflow({ cwd: tempDir }); + + expect(result.status).toBe('skipped'); + expect(fs.readFileSync(path.join(workflowDir, 'opencode-auth.yml'), 'utf-8')).toBe('existing content'); + }); + + it('overwrites existing file with override=true', () => { + const workflowDir = path.join(tempDir, '.github', 'workflows'); + fs.mkdirSync(workflowDir, { recursive: true }); + fs.writeFileSync(path.join(workflowDir, 'opencode-auth.yml'), 'existing content'); + + const result = installAuthWorkflow({ cwd: tempDir, override: true }); + + expect(result.status).toBe('overwritten'); + expect(fs.readFileSync(path.join(workflowDir, 'opencode-auth.yml'), 'utf-8')).not.toBe('existing content'); + }); + }); + + describe('checkExisting* functions', () => { + it('checkExistingWorkflows returns existing workflow files', () => { + const workflowDir = path.join(tempDir, '.github', 'workflows'); + fs.mkdirSync(workflowDir, { recursive: true }); + fs.writeFileSync(path.join(workflowDir, 'pr-review.yml'), 'content'); + + const existing = checkExistingWorkflows({ workflows: ['review', 'label'], cwd: tempDir }); + + expect(existing).toHaveLength(1); + expect(existing[0].name).toBe('review'); + expect(existing[0].type).toBe('workflow'); + }); + + it('checkExistingSkills returns existing skill files', () => { + const skillDir = path.join(tempDir, '.opencode', 'skill', 'pr-review'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), 'content'); + + const existing = checkExistingSkills({ cwd: tempDir }); + + expect(existing).toHaveLength(1); + expect(existing[0].name).toBe('pr-review'); + expect(existing[0].type).toBe('skill'); + }); + + it('checkExistingAuthWorkflow returns existing auth workflow', () => { + const workflowDir = path.join(tempDir, '.github', 'workflows'); + fs.mkdirSync(workflowDir, { recursive: true }); + fs.writeFileSync(path.join(workflowDir, 'opencode-auth.yml'), 'content'); + + const existing = checkExistingAuthWorkflow({ cwd: tempDir }); + + expect(existing).not.toBeNull(); + expect(existing.name).toBe('opencode-auth'); + expect(existing.type).toBe('auth'); + }); + + it('checkExistingAuthWorkflow returns null when file does not exist', () => { + const existing = checkExistingAuthWorkflow({ cwd: tempDir }); + expect(existing).toBeNull(); + }); + }); +});