opencode-onboard 0.0.1 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +203 -0
- package/content/.opencode/agents/.bootstrap/AGENTS.template.md +130 -126
- package/content/.opencode/agents/back-engineer.md +73 -0
- package/content/.opencode/agents/devops-manager.md +115 -0
- package/content/.opencode/agents/front-engineer.md +73 -0
- package/content/.opencode/agents/infra-engineer.md +73 -0
- package/content/.opencode/agents/quality-engineer.md +75 -0
- package/content/.opencode/agents/security-auditor.md +85 -0
- package/content/.opencode/skills/browser-automation/SKILL.md +63 -0
- package/content/.opencode/skills/ob-userstory-az/SKILL.md +6 -6
- package/content/.opencode/skills/ob-userstory-gh/SKILL.md +3 -3
- package/content/AGENTS.md +12 -12
- package/content/DESIGN.md +1 -1
- package/package.json +18 -1
- package/src/index.js +67 -1
- package/src/presets/platforms.json +10 -0
- package/src/presets/skills-providers.json +14 -0
- package/src/steps/__tests__/check-env.test.js +70 -0
- package/src/steps/__tests__/check-platform.test.js +104 -0
- package/src/steps/__tests__/check-rtk.test.js +37 -0
- package/src/steps/__tests__/choose-platform.test.js +38 -0
- package/src/steps/__tests__/choose-team.test.js +105 -0
- package/src/steps/__tests__/clean-ai-files.test.js +62 -0
- package/src/steps/__tests__/copy-content.test.js +62 -0
- package/src/steps/check-env.js +26 -0
- package/src/steps/check-platform.js +80 -0
- package/src/steps/check-rtk.js +20 -0
- package/src/steps/choose-platform.js +22 -0
- package/src/steps/choose-skills-provider.js +56 -0
- package/src/steps/clean-ai-files.js +51 -0
- package/src/steps/copy-content.js +21 -0
- package/src/steps/init-openspec.js +22 -0
- package/src/steps/install-browser.js +65 -0
- package/src/utils/__tests__/copy.test.js +132 -0
- package/src/utils/__tests__/exec.test.js +106 -0
- package/src/utils/copy.js +54 -0
- package/src/utils/exec.js +84 -0
- package/content/.opencode/agents/ob-pullrequest-creator-az.md +0 -332
- package/content/.opencode/agents/ob-pullrequest-creator-gh.md +0 -177
- package/content/.opencode/agents/ob-pullrequest-observer-az.md +0 -248
- package/content/.opencode/agents/ob-pullrequest-observer-gh.md +0 -197
- package/content/.opencode/agents/qa.md +0 -137
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { code, commandExists, header, info, success, warn } from '../utils/exec.js'
|
|
2
|
+
|
|
3
|
+
export async function checkRtk() {
|
|
4
|
+
header('Step 8, Checking rtk')
|
|
5
|
+
|
|
6
|
+
const available = await commandExists('rtk')
|
|
7
|
+
|
|
8
|
+
if (available) {
|
|
9
|
+
success('rtk is available')
|
|
10
|
+
return
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
warn('rtk not found on PATH.')
|
|
14
|
+
console.log()
|
|
15
|
+
info('rtk is required for agents to run CLI commands safely.')
|
|
16
|
+
info('Install it from: https://github.com/rtk-ai/rtk#pre-built-binaries')
|
|
17
|
+
console.log()
|
|
18
|
+
info('After installing, verify with:')
|
|
19
|
+
code(['rtk --version'])
|
|
20
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { select } from '@inquirer/prompts'
|
|
2
|
+
import fse from 'fs-extra'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
import { header, success } from '../utils/exec.js'
|
|
6
|
+
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
8
|
+
const PLATFORMS_PRESET_PATH = path.resolve(__dirname, '../presets/platforms.json')
|
|
9
|
+
|
|
10
|
+
const platformsPreset = await fse.readJson(PLATFORMS_PRESET_PATH)
|
|
11
|
+
|
|
12
|
+
export async function choosePlatform() {
|
|
13
|
+
header('Step 3, Version control platform')
|
|
14
|
+
|
|
15
|
+
const platform = await select({
|
|
16
|
+
message: 'Which platform are you using?',
|
|
17
|
+
choices: platformsPreset.map(p => ({ name: p.name, value: p.value })),
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
success(`Platform: ${platform === 'github' ? 'GitHub' : 'Azure DevOps'}`)
|
|
21
|
+
return platform
|
|
22
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { select } from '@inquirer/prompts'
|
|
2
|
+
import fse from 'fs-extra'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import { fileURLToPath } from 'url'
|
|
5
|
+
import { header, info, success, warn } from '../utils/exec.js'
|
|
6
|
+
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
8
|
+
const SKILLS_PROVIDERS_PATH = path.resolve(__dirname, '../presets/skills-providers.json')
|
|
9
|
+
const CONTENT_SKILLS_DIR = path.resolve(__dirname, '../../content/.opencode/skills')
|
|
10
|
+
|
|
11
|
+
const providers = await fse.readJson(SKILLS_PROVIDERS_PATH)
|
|
12
|
+
|
|
13
|
+
export async function chooseSkillsProvider() {
|
|
14
|
+
header('Step 5, Choose your skills provider')
|
|
15
|
+
|
|
16
|
+
info('Skills provide platform and tech-specific knowledge to your agents.')
|
|
17
|
+
info('Agents detect and load skills automatically, you never need to specify them.')
|
|
18
|
+
console.log()
|
|
19
|
+
|
|
20
|
+
const selected = await select({
|
|
21
|
+
message: 'Install skills from:',
|
|
22
|
+
choices: providers.map(p => ({
|
|
23
|
+
name: `${p.label}${p.description ? `\n ${p.description}` : ''}`,
|
|
24
|
+
value: p.value,
|
|
25
|
+
})),
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
if (selected === 'none') {
|
|
29
|
+
warn('No skills installed. Add skills to .opencode/skills/ manually.')
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (selected === 'ob-skills') {
|
|
34
|
+
const destSkillsDir = path.join(process.cwd(), '.opencode', 'skills')
|
|
35
|
+
await fse.ensureDir(destSkillsDir)
|
|
36
|
+
|
|
37
|
+
const skills = await fse.readdir(CONTENT_SKILLS_DIR)
|
|
38
|
+
for (const skill of skills) {
|
|
39
|
+
const src = path.join(CONTENT_SKILLS_DIR, skill)
|
|
40
|
+
const dest = path.join(destSkillsDir, skill)
|
|
41
|
+
const stat = await fse.stat(src)
|
|
42
|
+
if (!stat.isDirectory()) continue
|
|
43
|
+
if (await fse.pathExists(dest)) {
|
|
44
|
+
info(`${skill} already exists, skipping`)
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
await fse.copy(src, dest)
|
|
48
|
+
success(`Installed skill: ${skill}`)
|
|
49
|
+
}
|
|
50
|
+
return selected
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Custom provider, future: support npx <package> or git URL
|
|
54
|
+
warn(`Custom provider "${selected}" not yet supported. Add skills to .opencode/skills/ manually.`)
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import fse from 'fs-extra'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import { findAiFiles } from '../utils/copy.js'
|
|
4
|
+
import { header, info, success, warn } from '../utils/exec.js'
|
|
5
|
+
|
|
6
|
+
export async function cleanAiFiles() {
|
|
7
|
+
header('Step 2, Existing AI config files')
|
|
8
|
+
|
|
9
|
+
const cwd = process.cwd()
|
|
10
|
+
const found = await findAiFiles(cwd)
|
|
11
|
+
|
|
12
|
+
// Also find .opencode contents except skills/ (preserve user skills)
|
|
13
|
+
const opencodeDir = path.join(cwd, '.opencode')
|
|
14
|
+
const opencodeEntries = []
|
|
15
|
+
if (await fse.pathExists(opencodeDir)) {
|
|
16
|
+
const entries = await fse.readdir(opencodeDir)
|
|
17
|
+
for (const entry of entries) {
|
|
18
|
+
if (entry !== 'skills') {
|
|
19
|
+
opencodeEntries.push(path.join(opencodeDir, entry))
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const allFiles = [...found, ...opencodeEntries]
|
|
25
|
+
|
|
26
|
+
if (allFiles.length === 0) {
|
|
27
|
+
success('No existing AI config files found')
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
warn('Found the following AI config files:')
|
|
32
|
+
for (const f of allFiles) {
|
|
33
|
+
info(f.replace(cwd, '.'))
|
|
34
|
+
}
|
|
35
|
+
console.log()
|
|
36
|
+
info('Press Enter to remove them all (your .opencode/skills/ will be kept)')
|
|
37
|
+
console.log()
|
|
38
|
+
|
|
39
|
+
await new Promise(resolve => {
|
|
40
|
+
process.stdin.resume()
|
|
41
|
+
process.stdin.once('data', () => {
|
|
42
|
+
process.stdin.pause()
|
|
43
|
+
resolve()
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
for (const f of allFiles) {
|
|
48
|
+
await fse.remove(f)
|
|
49
|
+
}
|
|
50
|
+
success('Removed existing AI config files')
|
|
51
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import path from 'path'
|
|
2
|
+
import { fileURLToPath } from 'url'
|
|
3
|
+
import { copyContent } from '../utils/copy.js'
|
|
4
|
+
import { error, header, success } from '../utils/exec.js'
|
|
5
|
+
|
|
6
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
7
|
+
const CONTENT_DIR = path.resolve(__dirname, '../../content')
|
|
8
|
+
|
|
9
|
+
export async function copyContentStep(platform) {
|
|
10
|
+
header('Step 4, Copying opencode-onboard files')
|
|
11
|
+
|
|
12
|
+
const dest = process.cwd()
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
await copyContent(CONTENT_DIR, dest, platform)
|
|
16
|
+
success('Files copied to project root')
|
|
17
|
+
} catch (err) {
|
|
18
|
+
error(`Failed to copy content: ${err.message}`)
|
|
19
|
+
process.exit(1)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { execa } from 'execa'
|
|
2
|
+
import { error, header, success, warn } from '../utils/exec.js'
|
|
3
|
+
|
|
4
|
+
export async function initOpenspec() {
|
|
5
|
+
header('Step 6, Initializing OpenSpec')
|
|
6
|
+
|
|
7
|
+
try {
|
|
8
|
+
const result = await execa('npx', ['@fission-ai/openspec', 'init', '--tools', 'opencode', '--force'], {
|
|
9
|
+
cwd: process.cwd(),
|
|
10
|
+
stdio: 'pipe',
|
|
11
|
+
reject: false,
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
if (result.exitCode === 0) {
|
|
15
|
+
success('OpenSpec initialized')
|
|
16
|
+
} else {
|
|
17
|
+
warn('OpenSpec init exited with non-zero code, check output above')
|
|
18
|
+
}
|
|
19
|
+
} catch (err) {
|
|
20
|
+
error(`Failed to run openspec init: ${err.message}`)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { execa } from 'execa'
|
|
2
|
+
import { error, header, success, warn } from '../utils/exec.js'
|
|
3
|
+
import os from 'os'
|
|
4
|
+
|
|
5
|
+
export async function installBrowser() {
|
|
6
|
+
header('Step 7, Installing opencode-browser')
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
const child = execa('npx', ['@different-ai/opencode-browser', 'install'], {
|
|
10
|
+
cwd: os.homedir(),
|
|
11
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
12
|
+
reject: false,
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
const AUTO_ANSWERS = [
|
|
16
|
+
{ trigger: 'Choose config location', response: '2' }, // global config
|
|
17
|
+
{ trigger: 'Create one?', response: 'y' },
|
|
18
|
+
{ trigger: 'Add browser-automation skill', response: 'n' },
|
|
19
|
+
{ trigger: 'Check broker', response: 'n' },
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
let pendingTriggers = [...AUTO_ANSWERS]
|
|
23
|
+
let showOutput = true // show output until after step 3 user interaction
|
|
24
|
+
let waitingForUser = false
|
|
25
|
+
|
|
26
|
+
child.stdout.on('data', (chunk) => {
|
|
27
|
+
const text = chunk.toString()
|
|
28
|
+
|
|
29
|
+
if (showOutput) {
|
|
30
|
+
process.stdout.write(chunk)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Step 3 — let user press Enter, then suppress remaining output
|
|
34
|
+
if (text.includes('Press Enter when') && !waitingForUser) {
|
|
35
|
+
waitingForUser = true
|
|
36
|
+
process.stdin.resume()
|
|
37
|
+
process.stdin.once('data', () => {
|
|
38
|
+
child.stdin.write('\n')
|
|
39
|
+
process.stdin.pause()
|
|
40
|
+
showOutput = false // suppress steps 4-9 output
|
|
41
|
+
})
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Auto-answer remaining prompts
|
|
46
|
+
for (let i = 0; i < pendingTriggers.length; i++) {
|
|
47
|
+
if (text.includes(pendingTriggers[i].trigger)) {
|
|
48
|
+
child.stdin.write(pendingTriggers[i].response + '\n')
|
|
49
|
+
pendingTriggers.splice(i, 1)
|
|
50
|
+
break
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const result = await child
|
|
56
|
+
|
|
57
|
+
if (result.exitCode === 0) {
|
|
58
|
+
success('opencode-browser installed')
|
|
59
|
+
} else {
|
|
60
|
+
warn('opencode-browser install exited with non-zero code')
|
|
61
|
+
}
|
|
62
|
+
} catch (err) {
|
|
63
|
+
error(`Failed to install opencode-browser: ${err.message}`)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import os from 'os'
|
|
4
|
+
import fse from 'fs-extra'
|
|
5
|
+
|
|
6
|
+
// Use real fs-extra for file system tests (temp dirs)
|
|
7
|
+
import { copyContent, findAiFiles } from '../copy.js'
|
|
8
|
+
|
|
9
|
+
const tmpDir = () => fse.mkdtempSync(path.join(os.tmpdir(), 'ob-test-'))
|
|
10
|
+
|
|
11
|
+
describe('copy utils', () => {
|
|
12
|
+
describe('findAiFiles()', () => {
|
|
13
|
+
let dir
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
dir = tmpDir()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
await fse.remove(dir)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('returns empty array when no AI files exist', async () => {
|
|
24
|
+
const found = await findAiFiles(dir)
|
|
25
|
+
expect(found).toEqual([])
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('detects AGENTS.md', async () => {
|
|
29
|
+
await fse.writeFile(path.join(dir, 'AGENTS.md'), '# agents')
|
|
30
|
+
const found = await findAiFiles(dir)
|
|
31
|
+
expect(found).toHaveLength(1)
|
|
32
|
+
expect(found[0]).toContain('AGENTS.md')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('detects CLAUDE.md', async () => {
|
|
36
|
+
await fse.writeFile(path.join(dir, 'CLAUDE.md'), '# claude')
|
|
37
|
+
const found = await findAiFiles(dir)
|
|
38
|
+
expect(found).toHaveLength(1)
|
|
39
|
+
expect(found[0]).toContain('CLAUDE.md')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('detects multiple AI files at once', async () => {
|
|
43
|
+
await fse.writeFile(path.join(dir, 'AGENTS.md'), '')
|
|
44
|
+
await fse.writeFile(path.join(dir, '.cursorrules'), '')
|
|
45
|
+
await fse.writeFile(path.join(dir, '.clinerules'), '')
|
|
46
|
+
const found = await findAiFiles(dir)
|
|
47
|
+
expect(found).toHaveLength(3)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('detects nested copilot-instructions.md', async () => {
|
|
51
|
+
const ghDir = path.join(dir, '.github')
|
|
52
|
+
await fse.ensureDir(ghDir)
|
|
53
|
+
await fse.writeFile(path.join(ghDir, 'copilot-instructions.md'), '')
|
|
54
|
+
const found = await findAiFiles(dir)
|
|
55
|
+
expect(found).toHaveLength(1)
|
|
56
|
+
expect(found[0]).toContain('copilot-instructions.md')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('copyContent()', () => {
|
|
61
|
+
let src, dest
|
|
62
|
+
|
|
63
|
+
beforeEach(async () => {
|
|
64
|
+
src = tmpDir()
|
|
65
|
+
dest = tmpDir()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
afterEach(async () => {
|
|
69
|
+
await fse.remove(src)
|
|
70
|
+
await fse.remove(dest)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('copies files that match neither platform exclusion', async () => {
|
|
74
|
+
await fse.writeFile(path.join(src, 'AGENTS.md'), '# agents')
|
|
75
|
+
await copyContent(src, dest, 'github')
|
|
76
|
+
expect(await fse.pathExists(path.join(dest, 'AGENTS.md'))).toBe(true)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('excludes azure files when platform is github', async () => {
|
|
80
|
+
await fse.ensureDir(path.join(src, 'skills', 'ob-userstory-az'))
|
|
81
|
+
await fse.writeFile(path.join(src, 'skills', 'ob-userstory-az', 'SKILL.md'), 'azure skill')
|
|
82
|
+
await fse.writeFile(path.join(src, 'agent-az.md'), 'azure agent')
|
|
83
|
+
|
|
84
|
+
await copyContent(src, dest, 'github')
|
|
85
|
+
|
|
86
|
+
expect(await fse.pathExists(path.join(dest, 'agent-az.md'))).toBe(false)
|
|
87
|
+
expect(await fse.pathExists(path.join(dest, 'skills', 'ob-userstory-az', 'SKILL.md'))).toBe(false)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('excludes github files when platform is azure', async () => {
|
|
91
|
+
await fse.ensureDir(path.join(src, 'skills', 'ob-userstory-gh'))
|
|
92
|
+
await fse.writeFile(path.join(src, 'skills', 'ob-userstory-gh', 'SKILL.md'), 'gh skill')
|
|
93
|
+
await fse.writeFile(path.join(src, 'agent-gh.md'), 'gh agent')
|
|
94
|
+
|
|
95
|
+
await copyContent(src, dest, 'azure')
|
|
96
|
+
|
|
97
|
+
expect(await fse.pathExists(path.join(dest, 'agent-gh.md'))).toBe(false)
|
|
98
|
+
expect(await fse.pathExists(path.join(dest, 'skills', 'ob-userstory-gh', 'SKILL.md'))).toBe(false)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('always excludes .bootstrap folder', async () => {
|
|
102
|
+
await fse.ensureDir(path.join(src, '.bootstrap'))
|
|
103
|
+
await fse.writeFile(path.join(src, '.bootstrap', 'secret.md'), 'internal')
|
|
104
|
+
|
|
105
|
+
await copyContent(src, dest, 'github')
|
|
106
|
+
|
|
107
|
+
expect(await fse.pathExists(path.join(dest, '.bootstrap', 'secret.md'))).toBe(false)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('does not overwrite existing files', async () => {
|
|
111
|
+
await fse.writeFile(path.join(src, 'AGENTS.md'), 'new content')
|
|
112
|
+
await fse.writeFile(path.join(dest, 'AGENTS.md'), 'original content')
|
|
113
|
+
|
|
114
|
+
await copyContent(src, dest, 'github')
|
|
115
|
+
|
|
116
|
+
const content = await fse.readFile(path.join(dest, 'AGENTS.md'), 'utf-8')
|
|
117
|
+
expect(content).toBe('original content')
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('copies github-specific files when platform is github', async () => {
|
|
121
|
+
await fse.writeFile(path.join(src, 'agent-gh.md'), 'github agent')
|
|
122
|
+
await copyContent(src, dest, 'github')
|
|
123
|
+
expect(await fse.pathExists(path.join(dest, 'agent-gh.md'))).toBe(true)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('copies azure-specific files when platform is azure', async () => {
|
|
127
|
+
await fse.writeFile(path.join(src, 'agent-az.md'), 'azure agent')
|
|
128
|
+
await copyContent(src, dest, 'azure')
|
|
129
|
+
expect(await fse.pathExists(path.join(dest, 'agent-az.md'))).toBe(true)
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
})
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
// Mock chalk to return the string as-is (no ANSI codes in tests)
|
|
4
|
+
vi.mock('chalk', () => ({
|
|
5
|
+
default: {
|
|
6
|
+
bold: { cyan: (s) => s },
|
|
7
|
+
green: (s) => s,
|
|
8
|
+
yellow: (s) => s,
|
|
9
|
+
red: (s) => s,
|
|
10
|
+
dim: (s) => s,
|
|
11
|
+
bgGray: { white: (s) => s },
|
|
12
|
+
},
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
// Mock ora spinner
|
|
16
|
+
vi.mock('ora', () => ({
|
|
17
|
+
default: () => ({ start: () => ({ succeed: vi.fn(), fail: vi.fn() }) }),
|
|
18
|
+
}))
|
|
19
|
+
|
|
20
|
+
// Mock execa
|
|
21
|
+
vi.mock('execa', () => ({
|
|
22
|
+
execa: vi.fn(),
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
import { execa } from 'execa'
|
|
26
|
+
import { run, commandExists, header, success, warn, error, info, code } from '../exec.js'
|
|
27
|
+
|
|
28
|
+
describe('exec utils', () => {
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
31
|
+
})
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
vi.restoreAllMocks()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('run()', () => {
|
|
37
|
+
it('returns success=true when exitCode is 0', async () => {
|
|
38
|
+
execa.mockResolvedValue({ exitCode: 0, stdout: 'ok', stderr: '' })
|
|
39
|
+
const result = await run('node', ['--version'])
|
|
40
|
+
expect(result).toEqual({ success: true, stdout: 'ok', stderr: '' })
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('returns success=false when exitCode is non-zero', async () => {
|
|
44
|
+
execa.mockResolvedValue({ exitCode: 1, stdout: '', stderr: 'error' })
|
|
45
|
+
const result = await run('node', ['--bad-flag'])
|
|
46
|
+
expect(result).toEqual({ success: false, stdout: '', stderr: 'error' })
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('returns success=false when execa throws', async () => {
|
|
50
|
+
execa.mockRejectedValue(new Error('spawn ENOENT'))
|
|
51
|
+
const result = await run('nonexistent-command', [])
|
|
52
|
+
expect(result.success).toBe(false)
|
|
53
|
+
expect(result.stderr).toBe('spawn ENOENT')
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('commandExists()', () => {
|
|
58
|
+
it('returns true when command exits with code 0', async () => {
|
|
59
|
+
execa.mockResolvedValue({ exitCode: 0 })
|
|
60
|
+
expect(await commandExists('node')).toBe(true)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('returns false when command exits with non-zero code', async () => {
|
|
64
|
+
execa.mockResolvedValue({ exitCode: 1 })
|
|
65
|
+
expect(await commandExists('badcmd')).toBe(false)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('returns false when execa throws (command not found)', async () => {
|
|
69
|
+
execa.mockRejectedValue(new Error('spawn ENOENT'))
|
|
70
|
+
expect(await commandExists('no-such-binary')).toBe(false)
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('console helpers', () => {
|
|
75
|
+
it('header() calls console.log', () => {
|
|
76
|
+
header('Test Header')
|
|
77
|
+
expect(console.log).toHaveBeenCalled()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('success() calls console.log with text', () => {
|
|
81
|
+
success('all good')
|
|
82
|
+
expect(console.log).toHaveBeenCalledWith(expect.stringContaining('all good'))
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('warn() calls console.log with text', () => {
|
|
86
|
+
warn('watch out')
|
|
87
|
+
expect(console.log).toHaveBeenCalledWith(expect.stringContaining('watch out'))
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('error() calls console.log with text', () => {
|
|
91
|
+
error('something broke')
|
|
92
|
+
expect(console.log).toHaveBeenCalledWith(expect.stringContaining('something broke'))
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('info() calls console.log with text', () => {
|
|
96
|
+
info('just info')
|
|
97
|
+
expect(console.log).toHaveBeenCalledWith(expect.stringContaining('just info'))
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('code() calls console.log for each line', () => {
|
|
101
|
+
code(['line one', 'line two'])
|
|
102
|
+
// 2 lines + 2 blank lines surrounding them
|
|
103
|
+
expect(console.log).toHaveBeenCalledTimes(4)
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import fse from 'fs-extra'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
|
|
4
|
+
// Folders never copied (internal bootstrap tooling)
|
|
5
|
+
const ALWAYS_EXCLUDE = ['.bootstrap', 'skills']
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Copy content/ directory to destination, excluding skills (handled separately by chooseSkillsProvider)
|
|
9
|
+
* and internal bootstrap tooling.
|
|
10
|
+
* @param {string} contentDir - absolute path to content/
|
|
11
|
+
* @param {string} destDir - absolute path to destination (project root)
|
|
12
|
+
* @param {'azure'|'github'} platform
|
|
13
|
+
*/
|
|
14
|
+
export async function copyContent(contentDir, destDir, platform) {
|
|
15
|
+
await fse.copy(contentDir, destDir, {
|
|
16
|
+
overwrite: false,
|
|
17
|
+
filter: (src) => {
|
|
18
|
+
const rel = path.relative(contentDir, src)
|
|
19
|
+
const parts = rel.split(path.sep)
|
|
20
|
+
return !parts.some(part =>
|
|
21
|
+
ALWAYS_EXCLUDE.some(pattern => part.includes(pattern))
|
|
22
|
+
)
|
|
23
|
+
},
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Scan a directory for known AI config files.
|
|
29
|
+
* Returns array of absolute paths found.
|
|
30
|
+
*/
|
|
31
|
+
const AI_FILES = [
|
|
32
|
+
'AGENTS.md',
|
|
33
|
+
'CLAUDE.md',
|
|
34
|
+
'ARCHITECTURE.md',
|
|
35
|
+
'DESIGN.md',
|
|
36
|
+
'.cursorrules',
|
|
37
|
+
'.clinerules',
|
|
38
|
+
'.windsurfrules',
|
|
39
|
+
'.github/copilot-instructions.md',
|
|
40
|
+
'copilot-instructions.md',
|
|
41
|
+
'.aider.conf.yml',
|
|
42
|
+
'.aider',
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
export async function findAiFiles(dir) {
|
|
46
|
+
const found = []
|
|
47
|
+
for (const file of AI_FILES) {
|
|
48
|
+
const fullPath = path.join(dir, file)
|
|
49
|
+
if (await fse.pathExists(fullPath)) {
|
|
50
|
+
found.push(fullPath)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return found
|
|
54
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import chalk from 'chalk'
|
|
2
|
+
import { execa } from 'execa'
|
|
3
|
+
import ora from 'ora'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Run a shell command with a spinner.
|
|
7
|
+
* Returns { success, stdout, stderr }
|
|
8
|
+
*/
|
|
9
|
+
export async function run(command, args = [], { label, cwd = process.cwd() } = {}) {
|
|
10
|
+
const spinner = ora(label ?? `${command} ${args.join(' ')}`).start()
|
|
11
|
+
try {
|
|
12
|
+
const result = await execa(command, args, { cwd, reject: false })
|
|
13
|
+
if (result.exitCode === 0) {
|
|
14
|
+
spinner.succeed()
|
|
15
|
+
} else {
|
|
16
|
+
spinner.fail()
|
|
17
|
+
}
|
|
18
|
+
return { success: result.exitCode === 0, stdout: result.stdout, stderr: result.stderr }
|
|
19
|
+
} catch (err) {
|
|
20
|
+
spinner.fail()
|
|
21
|
+
return { success: false, stdout: '', stderr: err.message }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Check if a command is available on PATH.
|
|
27
|
+
* Returns true/false.
|
|
28
|
+
*/
|
|
29
|
+
export async function commandExists(command) {
|
|
30
|
+
try {
|
|
31
|
+
const result = await execa(command, ['--version'], { reject: false })
|
|
32
|
+
return result.exitCode === 0
|
|
33
|
+
} catch {
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Print a section header.
|
|
40
|
+
*/
|
|
41
|
+
export function header(text) {
|
|
42
|
+
console.log()
|
|
43
|
+
console.log(chalk.bold.cyan(`━━ ${text}`))
|
|
44
|
+
console.log()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Print a success line.
|
|
49
|
+
*/
|
|
50
|
+
export function success(text) {
|
|
51
|
+
console.log(chalk.green('✓ ') + text)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Print a warning line.
|
|
56
|
+
*/
|
|
57
|
+
export function warn(text) {
|
|
58
|
+
console.log(chalk.yellow('⚠ ') + text)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Print an error line.
|
|
63
|
+
*/
|
|
64
|
+
export function error(text) {
|
|
65
|
+
console.log(chalk.red('✗ ') + text)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Print an info line.
|
|
70
|
+
*/
|
|
71
|
+
export function info(text) {
|
|
72
|
+
console.log(chalk.dim(' ' + text))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Print a code block.
|
|
77
|
+
*/
|
|
78
|
+
export function code(lines) {
|
|
79
|
+
console.log()
|
|
80
|
+
for (const line of lines) {
|
|
81
|
+
console.log(chalk.bgGray.white(' ' + line + ' '))
|
|
82
|
+
}
|
|
83
|
+
console.log()
|
|
84
|
+
}
|