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.
Files changed (42) hide show
  1. package/README.md +203 -0
  2. package/content/.opencode/agents/.bootstrap/AGENTS.template.md +130 -126
  3. package/content/.opencode/agents/back-engineer.md +73 -0
  4. package/content/.opencode/agents/devops-manager.md +115 -0
  5. package/content/.opencode/agents/front-engineer.md +73 -0
  6. package/content/.opencode/agents/infra-engineer.md +73 -0
  7. package/content/.opencode/agents/quality-engineer.md +75 -0
  8. package/content/.opencode/agents/security-auditor.md +85 -0
  9. package/content/.opencode/skills/browser-automation/SKILL.md +63 -0
  10. package/content/.opencode/skills/ob-userstory-az/SKILL.md +6 -6
  11. package/content/.opencode/skills/ob-userstory-gh/SKILL.md +3 -3
  12. package/content/AGENTS.md +12 -12
  13. package/content/DESIGN.md +1 -1
  14. package/package.json +18 -1
  15. package/src/index.js +67 -1
  16. package/src/presets/platforms.json +10 -0
  17. package/src/presets/skills-providers.json +14 -0
  18. package/src/steps/__tests__/check-env.test.js +70 -0
  19. package/src/steps/__tests__/check-platform.test.js +104 -0
  20. package/src/steps/__tests__/check-rtk.test.js +37 -0
  21. package/src/steps/__tests__/choose-platform.test.js +38 -0
  22. package/src/steps/__tests__/choose-team.test.js +105 -0
  23. package/src/steps/__tests__/clean-ai-files.test.js +62 -0
  24. package/src/steps/__tests__/copy-content.test.js +62 -0
  25. package/src/steps/check-env.js +26 -0
  26. package/src/steps/check-platform.js +80 -0
  27. package/src/steps/check-rtk.js +20 -0
  28. package/src/steps/choose-platform.js +22 -0
  29. package/src/steps/choose-skills-provider.js +56 -0
  30. package/src/steps/clean-ai-files.js +51 -0
  31. package/src/steps/copy-content.js +21 -0
  32. package/src/steps/init-openspec.js +22 -0
  33. package/src/steps/install-browser.js +65 -0
  34. package/src/utils/__tests__/copy.test.js +132 -0
  35. package/src/utils/__tests__/exec.test.js +106 -0
  36. package/src/utils/copy.js +54 -0
  37. package/src/utils/exec.js +84 -0
  38. package/content/.opencode/agents/ob-pullrequest-creator-az.md +0 -332
  39. package/content/.opencode/agents/ob-pullrequest-creator-gh.md +0 -177
  40. package/content/.opencode/agents/ob-pullrequest-observer-az.md +0 -248
  41. package/content/.opencode/agents/ob-pullrequest-observer-gh.md +0 -197
  42. package/content/.opencode/agents/qa.md +0 -137
package/src/index.js CHANGED
@@ -1,2 +1,68 @@
1
1
  #!/usr/bin/env node
2
- console.log('opencode-onboard coming soon');
2
+ import chalk from 'chalk'
3
+ import { checkEnv } from './steps/check-env.js'
4
+ import { cleanAiFiles } from './steps/clean-ai-files.js'
5
+ import { choosePlatform } from './steps/choose-platform.js'
6
+ import { copyContentStep } from './steps/copy-content.js'
7
+ import { chooseSkillsProvider } from './steps/choose-skills-provider.js'
8
+ import { initOpenspec } from './steps/init-openspec.js'
9
+ import { installBrowser } from './steps/install-browser.js'
10
+ import { checkRtk } from './steps/check-rtk.js'
11
+ import { checkPlatform } from './steps/check-platform.js'
12
+
13
+ console.log()
14
+ console.log(chalk.bold('┌─────────────────────────────────────┐'))
15
+ console.log(chalk.bold('│ opencode-onboard │'))
16
+ console.log(chalk.bold('│ Prepare your codebase for AI agents│'))
17
+ console.log(chalk.bold('└─────────────────────────────────────┘'))
18
+ console.log()
19
+
20
+ try {
21
+ // 1. Check Node + npm/pnpm
22
+ await checkEnv()
23
+
24
+ // 2. Clean existing AI config files
25
+ await cleanAiFiles()
26
+
27
+ // 3. Choose platform
28
+ const platform = await choosePlatform()
29
+
30
+ // 4. Copy content filtered by platform
31
+ await copyContentStep(platform)
32
+
33
+ // 5. Choose skills provider
34
+ await chooseSkillsProvider()
35
+
36
+ // 6. Init OpenSpec
37
+ await initOpenspec()
38
+
39
+ // 7. Install opencode-browser
40
+ await installBrowser()
41
+
42
+ // 8. Check rtk
43
+ await checkRtk()
44
+
45
+ // 9. Check platform CLI (az or gh)
46
+ await checkPlatform(platform)
47
+
48
+ // Done
49
+ console.log()
50
+ console.log(chalk.bold.green('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'))
51
+ console.log(chalk.bold.green(' Onboarding complete!'))
52
+ console.log(chalk.bold.green('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'))
53
+ console.log()
54
+ console.log(' Next step:')
55
+ console.log(chalk.cyan(' Open OpenCode in this project and type: ') + chalk.bold('"init"'))
56
+ console.log()
57
+ console.log(' OpenCode will generate ARCHITECTURE.md and DESIGN.md')
58
+ console.log(' from your actual codebase, then activate the agent team.')
59
+ console.log()
60
+ } catch (err) {
61
+ if (err.name === 'ExitPromptError') {
62
+ console.log()
63
+ console.log(chalk.yellow('Cancelled.'))
64
+ } else {
65
+ console.error(chalk.red('\nUnexpected error:'), err.message)
66
+ process.exit(1)
67
+ }
68
+ }
@@ -0,0 +1,10 @@
1
+ [
2
+ {
3
+ "value": "github",
4
+ "name": "GitHub"
5
+ },
6
+ {
7
+ "value": "azure",
8
+ "name": "Azure DevOps"
9
+ }
10
+ ]
@@ -0,0 +1,14 @@
1
+ [
2
+ {
3
+ "label": "ob-skills (default, ships with opencode-onboard)",
4
+ "value": "ob-skills",
5
+ "package": "opencode-onboard",
6
+ "description": "Default skill pack: GitHub and Azure DevOps user stories, pull requests, OpenSpec workflows."
7
+ },
8
+ {
9
+ "label": "None, I will add skills manually",
10
+ "value": "none",
11
+ "package": null,
12
+ "description": "Skip skill installation. Add skills to .opencode/skills/ manually."
13
+ }
14
+ ]
@@ -0,0 +1,70 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+
3
+ vi.mock('../../utils/exec.js', () => ({
4
+ commandExists: vi.fn(),
5
+ header: vi.fn(),
6
+ success: vi.fn(),
7
+ error: vi.fn(),
8
+ }))
9
+
10
+ // Mock execa used directly in check-env.js
11
+ vi.mock('execa', () => ({ execa: vi.fn() }))
12
+
13
+ import { commandExists, error, success } from '../../utils/exec.js'
14
+ import { checkEnv } from '../check-env.js'
15
+
16
+ describe('checkEnv()', () => {
17
+ const originalVersion = process.version
18
+ const originalExit = process.exit
19
+
20
+ beforeEach(() => {
21
+ process.exit = vi.fn()
22
+ vi.clearAllMocks()
23
+ })
24
+
25
+ afterEach(() => {
26
+ Object.defineProperty(process, 'version', { value: originalVersion, writable: true })
27
+ process.exit = originalExit
28
+ })
29
+
30
+ it('exits with error when Node version is < 18', async () => {
31
+ Object.defineProperty(process, 'version', { value: 'v16.0.0', writable: true })
32
+ commandExists.mockResolvedValue(true)
33
+
34
+ await checkEnv()
35
+
36
+ expect(error).toHaveBeenCalledWith(expect.stringContaining('v16.0.0'))
37
+ expect(process.exit).toHaveBeenCalledWith(1)
38
+ })
39
+
40
+ it('succeeds when Node version is >= 18 and pnpm is available', async () => {
41
+ Object.defineProperty(process, 'version', { value: 'v20.0.0', writable: true })
42
+ commandExists.mockImplementation(async (cmd) => cmd === 'pnpm')
43
+
44
+ await checkEnv()
45
+
46
+ expect(process.exit).not.toHaveBeenCalled()
47
+ expect(success).toHaveBeenCalledWith(expect.stringContaining('v20.0.0'))
48
+ expect(success).toHaveBeenCalledWith('pnpm available')
49
+ })
50
+
51
+ it('succeeds when Node version is >= 18 and only npm is available', async () => {
52
+ Object.defineProperty(process, 'version', { value: 'v18.0.0', writable: true })
53
+ commandExists.mockImplementation(async (cmd) => cmd === 'npm')
54
+
55
+ await checkEnv()
56
+
57
+ expect(process.exit).not.toHaveBeenCalled()
58
+ expect(success).toHaveBeenCalledWith('npm available')
59
+ })
60
+
61
+ it('exits when neither npm nor pnpm available', async () => {
62
+ Object.defineProperty(process, 'version', { value: 'v20.0.0', writable: true })
63
+ commandExists.mockResolvedValue(false)
64
+
65
+ await checkEnv()
66
+
67
+ expect(error).toHaveBeenCalledWith(expect.stringContaining('Neither npm nor pnpm'))
68
+ expect(process.exit).toHaveBeenCalledWith(1)
69
+ })
70
+ })
@@ -0,0 +1,104 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ vi.mock('../../utils/exec.js', () => ({
4
+ commandExists: vi.fn(),
5
+ header: vi.fn(),
6
+ success: vi.fn(),
7
+ warn: vi.fn(),
8
+ info: vi.fn(),
9
+ code: vi.fn(),
10
+ }))
11
+
12
+ vi.mock('execa', () => ({
13
+ execa: vi.fn(),
14
+ }))
15
+
16
+ import { execa } from 'execa'
17
+ import { commandExists, success, warn } from '../../utils/exec.js'
18
+ import { checkPlatform } from '../check-platform.js'
19
+
20
+ describe('checkPlatform()', () => {
21
+ beforeEach(() => {
22
+ vi.clearAllMocks()
23
+ })
24
+
25
+ describe('github path', () => {
26
+ it('prints success when gh is installed and authenticated', async () => {
27
+ commandExists.mockResolvedValue(true)
28
+ execa.mockResolvedValue({ exitCode: 0, stdout: '' })
29
+
30
+ await checkPlatform('github')
31
+
32
+ expect(success).toHaveBeenCalledWith('GitHub CLI (gh) available')
33
+ expect(success).toHaveBeenCalledWith('GitHub CLI authenticated')
34
+ })
35
+
36
+ it('warns when gh is installed but not authenticated', async () => {
37
+ commandExists.mockResolvedValue(true)
38
+ execa.mockResolvedValue({ exitCode: 1, stdout: '' })
39
+
40
+ await checkPlatform('github')
41
+
42
+ expect(success).toHaveBeenCalledWith('GitHub CLI (gh) available')
43
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('not authenticated'))
44
+ })
45
+
46
+ it('warns when gh is not installed', async () => {
47
+ commandExists.mockResolvedValue(false)
48
+
49
+ await checkPlatform('github')
50
+
51
+ expect(warn).toHaveBeenCalledWith('GitHub CLI (gh) not found.')
52
+ expect(success).not.toHaveBeenCalled()
53
+ })
54
+
55
+ it('warns when gh auth status check throws', async () => {
56
+ commandExists.mockResolvedValue(true)
57
+ execa.mockRejectedValue(new Error('spawn error'))
58
+
59
+ await checkPlatform('github')
60
+
61
+ expect(warn).toHaveBeenCalledWith('Could not check gh auth status.')
62
+ })
63
+ })
64
+
65
+ describe('azure path', () => {
66
+ it('prints success when az is installed and azure-devops extension present', async () => {
67
+ commandExists.mockResolvedValue(true)
68
+ execa.mockResolvedValue({ exitCode: 0, stdout: 'azure-devops\tsome info' })
69
+
70
+ await checkPlatform('azure')
71
+
72
+ expect(success).toHaveBeenCalledWith('Azure CLI (az) available')
73
+ expect(success).toHaveBeenCalledWith('azure-devops extension installed')
74
+ })
75
+
76
+ it('warns when az is installed but azure-devops extension is missing', async () => {
77
+ commandExists.mockResolvedValue(true)
78
+ execa.mockResolvedValue({ exitCode: 0, stdout: '' })
79
+
80
+ await checkPlatform('azure')
81
+
82
+ expect(success).toHaveBeenCalledWith('Azure CLI (az) available')
83
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('azure-devops extension not found'))
84
+ })
85
+
86
+ it('warns when az is not installed', async () => {
87
+ commandExists.mockResolvedValue(false)
88
+
89
+ await checkPlatform('azure')
90
+
91
+ expect(warn).toHaveBeenCalledWith('Azure CLI (az) not found.')
92
+ expect(success).not.toHaveBeenCalled()
93
+ })
94
+
95
+ it('warns when extension check throws', async () => {
96
+ commandExists.mockResolvedValue(true)
97
+ execa.mockRejectedValue(new Error('spawn error'))
98
+
99
+ await checkPlatform('azure')
100
+
101
+ expect(warn).toHaveBeenCalledWith('Could not check azure-devops extension. Run:')
102
+ })
103
+ })
104
+ })
@@ -0,0 +1,37 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ vi.mock('../../utils/exec.js', () => ({
4
+ commandExists: vi.fn(),
5
+ header: vi.fn(),
6
+ success: vi.fn(),
7
+ warn: vi.fn(),
8
+ info: vi.fn(),
9
+ code: vi.fn(),
10
+ }))
11
+
12
+ import { commandExists, success, warn } from '../../utils/exec.js'
13
+ import { checkRtk } from '../check-rtk.js'
14
+
15
+ describe('checkRtk()', () => {
16
+ beforeEach(() => {
17
+ vi.clearAllMocks()
18
+ })
19
+
20
+ it('prints success when rtk is available', async () => {
21
+ commandExists.mockResolvedValue(true)
22
+
23
+ await checkRtk()
24
+
25
+ expect(success).toHaveBeenCalledWith('rtk is available')
26
+ expect(warn).not.toHaveBeenCalled()
27
+ })
28
+
29
+ it('prints warning with install instructions when rtk is not found', async () => {
30
+ commandExists.mockResolvedValue(false)
31
+
32
+ await checkRtk()
33
+
34
+ expect(warn).toHaveBeenCalledWith('rtk not found on PATH.')
35
+ expect(success).not.toHaveBeenCalled()
36
+ })
37
+ })
@@ -0,0 +1,38 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ vi.mock('../../utils/exec.js', () => ({
4
+ header: vi.fn(),
5
+ success: vi.fn(),
6
+ }))
7
+
8
+ vi.mock('@inquirer/prompts', () => ({
9
+ select: vi.fn(),
10
+ }))
11
+
12
+ import { select } from '@inquirer/prompts'
13
+ import { success } from '../../utils/exec.js'
14
+ import { choosePlatform } from '../choose-platform.js'
15
+
16
+ describe('choosePlatform()', () => {
17
+ beforeEach(() => {
18
+ vi.clearAllMocks()
19
+ })
20
+
21
+ it('returns "github" when user selects GitHub', async () => {
22
+ select.mockResolvedValue('github')
23
+
24
+ const result = await choosePlatform()
25
+
26
+ expect(result).toBe('github')
27
+ expect(success).toHaveBeenCalledWith('Platform: GitHub')
28
+ })
29
+
30
+ it('returns "azure" when user selects Azure DevOps', async () => {
31
+ select.mockResolvedValue('azure')
32
+
33
+ const result = await choosePlatform()
34
+
35
+ expect(result).toBe('azure')
36
+ expect(success).toHaveBeenCalledWith('Platform: Azure DevOps')
37
+ })
38
+ })
@@ -0,0 +1,105 @@
1
+ import fse from 'fs-extra'
2
+ import os from 'os'
3
+ import path from 'path'
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
+
6
+ vi.mock('../../utils/exec.js', () => ({
7
+ header: vi.fn(),
8
+ success: vi.fn(),
9
+ info: vi.fn(),
10
+ }))
11
+
12
+ vi.mock('@inquirer/prompts', () => ({
13
+ checkbox: vi.fn(),
14
+ input: vi.fn(),
15
+ }))
16
+
17
+ import { checkbox, input } from '@inquirer/prompts'
18
+ import { info } from '../../utils/exec.js'
19
+
20
+ describe('chooseTeam()', () => {
21
+ let tmpDir
22
+ let originalCwd
23
+
24
+ beforeEach(async () => {
25
+ tmpDir = await fse.mkdtemp(path.join(os.tmpdir(), 'ob-team-test-'))
26
+ originalCwd = process.cwd()
27
+ process.chdir(tmpDir)
28
+ vi.clearAllMocks()
29
+ })
30
+
31
+ afterEach(async () => {
32
+ process.chdir(originalCwd)
33
+ await fse.remove(tmpDir)
34
+ vi.resetModules()
35
+ })
36
+
37
+ it('returns empty array and skips when no agents selected', async () => {
38
+ checkbox.mockResolvedValue([])
39
+ // single empty input to exit the custom loop
40
+ input.mockResolvedValue('')
41
+
42
+ // Dynamic import so process.cwd() is captured at call time
43
+ const { chooseTeam } = await import('../choose-team.js')
44
+ const result = await chooseTeam()
45
+
46
+ expect(result).toEqual([])
47
+ expect(info).toHaveBeenCalledWith('No agents selected, skipping team setup.')
48
+ })
49
+
50
+ it('creates agent files for selected preset agents', async () => {
51
+ checkbox.mockResolvedValue(['frontend', 'backend'])
52
+ input.mockResolvedValue('') // no custom agents
53
+
54
+ const { chooseTeam } = await import('../choose-team.js')
55
+ const result = await chooseTeam()
56
+
57
+ expect(result).toEqual(['frontend', 'backend'])
58
+
59
+ const frontendPath = path.join(tmpDir, '.opencode', 'agents', 'frontend.md')
60
+ const backendPath = path.join(tmpDir, '.opencode', 'agents', 'backend.md')
61
+ expect(await fse.pathExists(frontendPath)).toBe(true)
62
+ expect(await fse.pathExists(backendPath)).toBe(true)
63
+ })
64
+
65
+ it('creates agent file for custom agent name', async () => {
66
+ checkbox.mockResolvedValue([])
67
+ input.mockResolvedValueOnce('devops').mockResolvedValueOnce('') // one custom, then stop
68
+
69
+ const { chooseTeam } = await import('../choose-team.js')
70
+ const result = await chooseTeam()
71
+
72
+ expect(result).toContain('devops')
73
+ const devopsPath = path.join(tmpDir, '.opencode', 'agents', 'devops.md')
74
+ expect(await fse.pathExists(devopsPath)).toBe(true)
75
+ })
76
+
77
+ it('normalises custom agent name (lowercase, spaces to dashes)', async () => {
78
+ checkbox.mockResolvedValue([])
79
+ input.mockResolvedValueOnce('My Agent').mockResolvedValueOnce('')
80
+
81
+ const { chooseTeam } = await import('../choose-team.js')
82
+ const result = await chooseTeam()
83
+
84
+ expect(result).toContain('my-agent')
85
+ const agentPath = path.join(tmpDir, '.opencode', 'agents', 'my-agent.md')
86
+ expect(await fse.pathExists(agentPath)).toBe(true)
87
+ })
88
+
89
+ it('skips agent file creation if it already exists', async () => {
90
+ checkbox.mockResolvedValue(['frontend'])
91
+ input.mockResolvedValue('')
92
+
93
+ const agentsDir = path.join(tmpDir, '.opencode', 'agents')
94
+ await fse.ensureDir(agentsDir)
95
+ await fse.writeFile(path.join(agentsDir, 'frontend.md'), 'existing content')
96
+
97
+ const { chooseTeam } = await import('../choose-team.js')
98
+ await chooseTeam()
99
+
100
+ // File should still have original content (not overwritten)
101
+ const content = await fse.readFile(path.join(agentsDir, 'frontend.md'), 'utf-8')
102
+ expect(content).toBe('existing content')
103
+ expect(info).toHaveBeenCalledWith('frontend.md already exists, skipping')
104
+ })
105
+ })
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
2
+
3
+ vi.mock('../../utils/exec.js', () => ({
4
+ header: vi.fn(),
5
+ success: vi.fn(),
6
+ warn: vi.fn(),
7
+ info: vi.fn(),
8
+ }))
9
+
10
+ vi.mock('../../utils/copy.js', () => ({
11
+ findAiFiles: vi.fn(),
12
+ }))
13
+
14
+ vi.mock('fs-extra', () => ({
15
+ default: { remove: vi.fn() },
16
+ }))
17
+
18
+ vi.mock('@inquirer/prompts', () => ({
19
+ confirm: vi.fn(),
20
+ }))
21
+
22
+ import { findAiFiles } from '../../utils/copy.js'
23
+ import { success, warn } from '../../utils/exec.js'
24
+ import fse from 'fs-extra'
25
+ import { confirm } from '@inquirer/prompts'
26
+ import { cleanAiFiles } from '../clean-ai-files.js'
27
+
28
+ describe('cleanAiFiles()', () => {
29
+ beforeEach(() => {
30
+ vi.clearAllMocks()
31
+ })
32
+
33
+ it('prints success when no AI files are found', async () => {
34
+ findAiFiles.mockResolvedValue([])
35
+
36
+ await cleanAiFiles()
37
+
38
+ expect(success).toHaveBeenCalledWith('No existing AI config files found')
39
+ expect(confirm).not.toHaveBeenCalled()
40
+ })
41
+
42
+ it('deletes files when user confirms', async () => {
43
+ findAiFiles.mockResolvedValue(['/proj/AGENTS.md', '/proj/CLAUDE.md'])
44
+ confirm.mockResolvedValue(true)
45
+
46
+ await cleanAiFiles()
47
+
48
+ expect(fse.remove).toHaveBeenCalledWith('/proj/AGENTS.md')
49
+ expect(fse.remove).toHaveBeenCalledWith('/proj/CLAUDE.md')
50
+ expect(success).toHaveBeenCalledWith('Removed existing AI config files')
51
+ })
52
+
53
+ it('skips deletion when user declines', async () => {
54
+ findAiFiles.mockResolvedValue(['/proj/AGENTS.md'])
55
+ confirm.mockResolvedValue(false)
56
+
57
+ await cleanAiFiles()
58
+
59
+ expect(fse.remove).not.toHaveBeenCalled()
60
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining('Skipped'))
61
+ })
62
+ })
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+
3
+ vi.mock('../../utils/exec.js', () => ({
4
+ header: vi.fn(),
5
+ success: vi.fn(),
6
+ error: vi.fn(),
7
+ }))
8
+
9
+ vi.mock('../../utils/copy.js', () => ({
10
+ copyContent: vi.fn(),
11
+ }))
12
+
13
+ import { copyContent } from '../../utils/copy.js'
14
+ import { success, error } from '../../utils/exec.js'
15
+ import { copyContentStep } from '../copy-content.js'
16
+
17
+ describe('copyContentStep()', () => {
18
+ const originalExit = process.exit
19
+
20
+ beforeEach(() => {
21
+ process.exit = vi.fn()
22
+ vi.clearAllMocks()
23
+ })
24
+
25
+ afterEach(() => {
26
+ process.exit = originalExit
27
+ })
28
+
29
+ it('calls copyContent with the correct platform and prints success', async () => {
30
+ copyContent.mockResolvedValue(undefined)
31
+
32
+ await copyContentStep('github')
33
+
34
+ expect(copyContent).toHaveBeenCalledWith(
35
+ expect.stringContaining('content'),
36
+ process.cwd(),
37
+ 'github'
38
+ )
39
+ expect(success).toHaveBeenCalledWith('Files copied to project root')
40
+ })
41
+
42
+ it('calls copyContent with azure platform', async () => {
43
+ copyContent.mockResolvedValue(undefined)
44
+
45
+ await copyContentStep('azure')
46
+
47
+ expect(copyContent).toHaveBeenCalledWith(
48
+ expect.stringContaining('content'),
49
+ process.cwd(),
50
+ 'azure'
51
+ )
52
+ })
53
+
54
+ it('calls process.exit(1) when copyContent throws', async () => {
55
+ copyContent.mockRejectedValue(new Error('disk full'))
56
+
57
+ await copyContentStep('github')
58
+
59
+ expect(error).toHaveBeenCalledWith(expect.stringContaining('disk full'))
60
+ expect(process.exit).toHaveBeenCalledWith(1)
61
+ })
62
+ })
@@ -0,0 +1,26 @@
1
+ import { commandExists, error, header, success } from '../utils/exec.js'
2
+
3
+ export async function checkEnv() {
4
+ header('Step 1, Checking environment')
5
+
6
+ // Node version
7
+ const nodeVersion = process.version
8
+ const major = parseInt(nodeVersion.slice(1).split('.')[0], 10)
9
+ if (major < 18) {
10
+ error(`Node.js ${nodeVersion} detected. Version 18+ is required.`)
11
+ process.exit(1)
12
+ }
13
+ success(`Node.js ${nodeVersion}`)
14
+
15
+ // npm or pnpm
16
+ const hasPnpm = await commandExists('pnpm')
17
+ const hasNpm = await commandExists('npm')
18
+
19
+ if (!hasPnpm && !hasNpm) {
20
+ error('Neither npm nor pnpm found. Please install Node.js from https://nodejs.org')
21
+ process.exit(1)
22
+ }
23
+
24
+ if (hasPnpm) success('pnpm available')
25
+ else success('npm available')
26
+ }
@@ -0,0 +1,80 @@
1
+ import { execa } from 'execa'
2
+ import { code, commandExists, header, info, success, warn } from '../utils/exec.js'
3
+
4
+ export async function checkPlatform(platform) {
5
+ if (platform === 'azure') {
6
+ await checkAzure()
7
+ } else {
8
+ await checkGithub()
9
+ }
10
+ }
11
+
12
+ async function checkAzure() {
13
+ header('Step 9, Checking Azure DevOps CLI')
14
+
15
+ // Check az is installed
16
+ const hasAz = await commandExists('az')
17
+ if (!hasAz) {
18
+ warn('Azure CLI (az) not found.')
19
+ info('Install from: https://learn.microsoft.com/en-us/cli/azure/install-azure-cli')
20
+ return
21
+ }
22
+ success('Azure CLI (az) available')
23
+
24
+ // Check az devops extension
25
+ try {
26
+ const result = await execa('az', ['extension', 'list', '--query', "[?name=='azure-devops']", '-o', 'tsv'], {
27
+ reject: false,
28
+ })
29
+ const hasExtension = result.stdout && result.stdout.includes('azure-devops')
30
+
31
+ if (hasExtension) {
32
+ success('azure-devops extension installed')
33
+ } else {
34
+ warn('azure-devops extension not found. Run:')
35
+ code([
36
+ 'az extension add --name azure-devops',
37
+ 'az config set extension.dynamic_install_allow_preview=true',
38
+ 'az login',
39
+ 'az devops login --organization https://dev.azure.com/<your-org>',
40
+ ])
41
+ }
42
+ } catch {
43
+ warn('Could not check azure-devops extension. Run:')
44
+ code([
45
+ 'az extension add --name azure-devops',
46
+ 'az config set extension.dynamic_install_allow_preview=true',
47
+ 'az login',
48
+ 'az devops login --organization https://dev.azure.com/<your-org>',
49
+ ])
50
+ }
51
+ }
52
+
53
+ async function checkGithub() {
54
+ header('Step 9, Checking GitHub CLI')
55
+
56
+ const hasGh = await commandExists('gh')
57
+
58
+ if (hasGh) {
59
+ success('GitHub CLI (gh) available')
60
+
61
+ // Check auth status
62
+ try {
63
+ const result = await execa('gh', ['auth', 'status'], { reject: false })
64
+ if (result.exitCode === 0) {
65
+ success('GitHub CLI authenticated')
66
+ } else {
67
+ warn('GitHub CLI not authenticated. Run:')
68
+ code(['gh auth login'])
69
+ }
70
+ } catch {
71
+ warn('Could not check gh auth status.')
72
+ }
73
+ } else {
74
+ warn('GitHub CLI (gh) not found.')
75
+ info('Install from: https://cli.github.com')
76
+ console.log()
77
+ info('After installing, authenticate with:')
78
+ code(['gh auth login'])
79
+ }
80
+ }