'use strict'
const os = require('os')
const path = require('path')
const fs = require('fs')
const { execSync, spawnSync } = require('child_process')
const https = require('https')
const http = require('http')
function run(cmd, opts = {}) {
return spawnSync(cmd, { shell: true, stdio: 'inherit', ...opts })
}
function runCapture(cmd, opts = {}) {
try {
return execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], ...opts }).trim()
} catch {
return null
}
}
function httpGet(url, headers = {}) {
return new Promise((resolve, reject) => {
const lib = url.startsWith('https') ? https : http
lib.get(url, { headers }, (res) => {
let data = ''
res.on('data', (chunk) => (data += chunk))
res.on('end', () => resolve({ status: res.statusCode, body: data }))
}).on('error', reject)
})
}
function httpPost(url, body, headers = {}) {
return new Promise((resolve, reject) => {
const lib = url.startsWith('https') ? https : http
const payload = JSON.stringify(body)
const urlObj = new URL(url)
const opts = {
hostname: urlObj.hostname,
path: urlObj.pathname + urlObj.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
...headers,
},
}
const req = lib.request(opts, (res) => {
let data = ''
res.on('data', (chunk) => (data += chunk))
res.on('end', () => resolve({ status: res.statusCode, body: data }))
})
req.on('error', reject)
req.write(payload)
req.end()
})
}
function checkNodeVersion() {
const [major] = process.versions.node.split('.').map(Number)
if (major < 20) {
console.error(`❌ Node.js >= 20 required. Current: ${process.version}`)
process.exit(1)
}
console.log(`✅ Node.js ${process.version}`)
}
function npmInstallAndBuild() {
const botDir = __dirname
console.log('\n📦 Running npm install...')
const r1 = run('npm install', { cwd: botDir })
if (r1.status !== 0) { console.error('❌ npm install failed'); process.exit(1) }
console.log('\n🔨 Running npm run build (tsc)...')
const r2 = run('npm run build', { cwd: botDir })
if (r2.status !== 0) { console.error('❌ npm run build failed'); process.exit(1) }
console.log('✅ Build complete')
}
async function getInquirer() {
try {
return require('inquirer')
} catch {
console.error('❌ inquirer not found. Make sure it is listed in bot/package.json dependencies.')
process.exit(1)
}
}
const MODEL_DEFAULTS = {
anthropic: 'claude-sonnet-4-5',
openai: 'gpt-4o',
ollama: 'llama3',
}
async function promptConfig() {
const inquirer = await getInquirer()
const answers = await inquirer.prompt([
{
type: 'input',
name: 'slackSessionName',
message: 'Slack session name (e.g. cijira):',
validate: (v) => v.trim().length > 0 || 'Required',
},
{
type: 'password',
name: 'slackBotToken',
message: 'Slack Bot Token (xoxb-...):',
validate: (v) => v.trim().startsWith('xoxb-') || 'Must start with xoxb-',
},
{
type: 'password',
name: 'slackAppToken',
message: 'Slack App Token (xapp-...):',
validate: (v) => v.trim().startsWith('xapp-') || 'Must start with xapp-',
},
{
type: 'input',
name: 'jiraBaseUrl',
message: 'Jira base URL (e.g. https://myorg.atlassian.net):',
validate: (v) => v.trim().startsWith('http') || 'Must be a URL',
},
{
type: 'input',
name: 'jiraProjectKey',
message: 'Jira project key:',
validate: (v) => v.trim().length > 0 || 'Required',
},
{
type: 'input',
name: 'jiraBacklogColumn',
message: 'Jira backlog column name:',
default: 'Ideia/Backlog',
},
{
type: 'password',
name: 'jiraApiToken',
message: 'Jira API token:',
validate: (v) => v.trim().length > 0 || 'Required',
},
{
type: 'input',
name: 'jiraUserEmail',
message: 'Jira user email:',
validate: (v) => v.includes('@') || 'Must be an email',
},
{
type: 'input',
name: 'bitbucketWorkspace',
message: 'Bitbucket workspace:',
validate: (v) => v.trim().length > 0 || 'Required',
},
{
type: 'input',
name: 'bitbucketRepoSlug',
message: 'Bitbucket repo slug:',
validate: (v) => v.trim().length > 0 || 'Required',
},
{
type: 'input',
name: 'bitbucketBaseBranch',
message: 'Bitbucket base branch:',
default: 'homolog',
},
{
type: 'password',
name: 'bitbucketAppPassword',
message: 'Bitbucket API token with scopes (bitbucket.org/account/settings/api-tokens — Repositories:RW, Pull requests:RW, Account:R):',
validate: (v) => v.trim().length > 0 || 'Required',
},
{
type: 'input',
name: 'bitbucketUsername',
message: 'Bitbucket email (email da conta, nao o username):',
validate: (v) => v.trim().length > 0 || 'Required',
},
{
type: 'list',
name: 'llmProvider',
message: 'LLM provider:',
choices: ['anthropic', 'openai', 'ollama'],
},
{
type: 'input',
name: 'llmModel',
message: 'Model name:',
default: (ans) => MODEL_DEFAULTS[ans.llmProvider] || '',
},
{
type: 'password',
name: 'llmApiKey',
message: 'API key (leave blank for ollama):',
when: (ans) => ans.llmProvider !== 'ollama',
default: '',
},
])
if (!answers.llmApiKey) answers.llmApiKey = ''
return answers
}
async function validateConnections(cfg) {
const results = {}
try {
const r = await httpGet('https://slack.com/api/auth.test', {
Authorization: `Bearer ${cfg.slackBotToken}`,
})
const body = JSON.parse(r.body)
results.slack = body.ok === true
} catch {
results.slack = false
}
console.log(results.slack ? '✅ Slack' : '❌ Slack')
try {
const creds = Buffer.from(`${cfg.jiraUserEmail}:${cfg.jiraApiToken}`).toString('base64')
const r = await httpGet(`${cfg.jiraBaseUrl.replace(/\/$/, '')}/rest/api/3/myself`, {
Authorization: `Basic ${creds}`,
Accept: 'application/json',
})
results.jira = r.status >= 200 && r.status < 300
} catch {
results.jira = false
}
console.log(results.jira ? '✅ Jira' : '❌ Jira')
try {
const creds = Buffer.from(`${cfg.bitbucketUsername}:${cfg.bitbucketAppPassword}`).toString('base64')
const r = await httpGet(`https://api.bitbucket.org/2.0/repositories/${cfg.bitbucketWorkspace}/${cfg.bitbucketRepoSlug}`, {
Authorization: `Basic ${creds}`,
})
results.bitbucket = r.status >= 200 && r.status < 300
} catch {
results.bitbucket = false
}
console.log(results.bitbucket ? '✅ Bitbucket' : '❌ Bitbucket')
try {
if (cfg.llmProvider === 'anthropic') {
const r = await httpPost(
'https://api.anthropic.com/v1/messages',
{
model: cfg.llmModel,
max_tokens: 8,
messages: [{ role: 'user', content: 'ping' }],
},
{
'x-api-key': cfg.llmApiKey,
'anthropic-version': '2023-06-01',
}
)
results.llm = r.status >= 200 && r.status < 300
} else if (cfg.llmProvider === 'openai') {
const r = await httpGet('https://api.openai.com/v1/models', {
Authorization: `Bearer ${cfg.llmApiKey}`,
})
results.llm = r.status >= 200 && r.status < 300
} else {
const r = await httpGet('http://localhost:11434/api/tags')
results.llm = r.status >= 200 && r.status < 300
}
} catch {
results.llm = false
}
console.log(results.llm ? `✅ LLM (${cfg.llmProvider})` : `❌ LLM (${cfg.llmProvider})`)
return results
}
async function retryFailedServices(cfg, results) {
const inquirer = await getInquirer()
const failed = Object.entries(results).filter(([, ok]) => !ok).map(([k]) => k)
if (failed.length === 0) return cfg
const { retry } = await inquirer.prompt([
{
type: 'confirm',
name: 'retry',
message: `Some connections failed (${failed.join(', ')}). Re-enter credentials?`,
default: true,
},
])
if (!retry) return cfg
const updatedCfg = { ...cfg }
for (const service of failed) {
if (service === 'slack') {
const ans = await inquirer.prompt([
{ type: 'password', name: 'slackBotToken', message: 'Slack Bot Token:', validate: (v) => v.trim().length > 0 },
{ type: 'password', name: 'slackAppToken', message: 'Slack App Token:', validate: (v) => v.trim().length > 0 },
])
Object.assign(updatedCfg, ans)
} else if (service === 'jira') {
const ans = await inquirer.prompt([
{ type: 'input', name: 'jiraBaseUrl', message: 'Jira base URL:', default: cfg.jiraBaseUrl },
{ type: 'password', name: 'jiraApiToken', message: 'Jira API token:' },
{ type: 'input', name: 'jiraUserEmail', message: 'Jira user email:', default: cfg.jiraUserEmail },
])
Object.assign(updatedCfg, ans)
} else if (service === 'bitbucket') {
const ans = await inquirer.prompt([
{ type: 'password', name: 'bitbucketAppPassword', message: 'Bitbucket API token with scopes:' },
{ type: 'input', name: 'bitbucketUsername', message: 'Bitbucket email (email da conta):', default: cfg.bitbucketUsername },
])
Object.assign(updatedCfg, ans)
} else if (service === 'llm') {
const ans = await inquirer.prompt([
{ type: 'password', name: 'llmApiKey', message: 'LLM API key:' },
])
Object.assign(updatedCfg, ans)
}
}
console.log('\nRe-validating...')
const newResults = await validateConnections(updatedCfg)
return retryFailedServices(updatedCfg, newResults)
}
function writeConfigFiles(cfg, projectRoot) {
const botDir = path.join(projectRoot, '.sdd', 'bot')
fs.mkdirSync(botDir, { recursive: true })
fs.mkdirSync(path.join(botDir, 'versions'), { recursive: true })
const yaml = `# sdd-bot configuration — generated by install.js
# DO NOT commit this file.
slack:
session_name: "${cfg.slackSessionName}"
bot_token: "${cfg.slackBotToken}"
app_token: "${cfg.slackAppToken}"
jira:
base_url: "${cfg.jiraBaseUrl}"
project_key: "${cfg.jiraProjectKey}"
backlog_column: "${cfg.jiraBacklogColumn}"
api_token: "${cfg.jiraApiToken}"
user: "${cfg.jiraUserEmail}"
bitbucket:
workspace: "${cfg.bitbucketWorkspace}"
repo_slug: "${cfg.bitbucketRepoSlug}"
base_branch: "${cfg.bitbucketBaseBranch}"
app_password: "${cfg.bitbucketAppPassword}"
user: "${cfg.bitbucketUsername}"
llm:
provider: "${cfg.llmProvider}"
model: "${cfg.llmModel}"
api_key: "${cfg.llmApiKey}"
sdd:
worktrees_root: "${projectRoot.replace(/\\/g, '\\\\')}\\\\.worktree"
`
fs.writeFileSync(path.join(botDir, 'sdd-bot.config.yaml'), yaml, 'utf8')
console.log('✅ Written .sdd/bot/sdd-bot.config.yaml')
const sessionsPath = path.join(botDir, 'sessions.json')
if (!fs.existsSync(sessionsPath)) {
fs.writeFileSync(sessionsPath, '{}', 'utf8')
console.log('✅ Created .sdd/bot/sessions.json')
}
}
function writeEcosystemConfig(projectRoot) {
const ecosystemPath = path.join(__dirname, 'ecosystem.config.js')
const content = `module.exports = {
apps: [{
name: 'sdd-bot',
script: './dist/index.js',
cwd: __dirname,
env: { NODE_ENV: 'production', SDD_BOT_ROOT: ${JSON.stringify(projectRoot)} },
restart_delay: 5000,
max_restarts: 10,
}]
}
`
fs.writeFileSync(ecosystemPath, content, 'utf8')
console.log('✅ Written bot/ecosystem.config.js')
}
function configureGitRemote(cfg, projectRoot) {
const token = cfg.bitbucketAppPassword
const ws = cfg.bitbucketWorkspace
const repo = cfg.bitbucketRepoSlug
if (!token || !ws || !repo) {
console.warn('⚠️ Dados do Bitbucket incompletos — pulei a configuração do remote git')
return
}
const url = `https://x-bitbucket-api-token-auth:${token}@bitbucket.org/${ws}/${repo}.git`
const hasOrigin = runCapture('git remote get-url origin', { cwd: projectRoot })
const action = hasOrigin ? 'set-url' : 'add'
const r = spawnSync('git', ['remote', action, 'origin', url], {
cwd: projectRoot,
stdio: 'pipe',
})
if (r.status === 0) {
console.log('✅ remote git origin configurado com API token (x-bitbucket-api-token-auth)')
const test = spawnSync('git', ['ls-remote', '--heads', 'origin'], {
cwd: projectRoot,
stdio: 'pipe',
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
})
console.log(
test.status === 0
? '✅ autenticação git validada (fetch/push prontos para o bot)'
: '⚠️ git ls-remote falhou — confirme o token e os scopes (Repositories: Read+Write)'
)
} else {
console.warn('⚠️ não consegui configurar o remote git origin automaticamente')
}
}
function updateGitignore(projectRoot) {
const gitignorePath = path.join(projectRoot, '.gitignore')
const entries = [
'.sdd/bot/sdd-bot.config.yaml',
'.sdd/bot/sessions.json',
'.sdd/bot/versions/',
'bot/node_modules/',
'bot/dist/',
'bot/ecosystem.config.js',
]
let existing = ''
if (fs.existsSync(gitignorePath)) {
existing = fs.readFileSync(gitignorePath, 'utf8')
}
const toAdd = entries.filter((e) => !existing.includes(e))
if (toAdd.length > 0) {
const addition = '\n# sdd-bot (generated by install.js)\n' + toAdd.join('\n') + '\n'
fs.appendFileSync(gitignorePath, addition, 'utf8')
console.log('✅ .gitignore updated')
} else {
console.log('✅ .gitignore already up to date')
}
}
function ensurePm2() {
const found = runCapture('pm2 --version')
if (found) {
console.log(`✅ pm2 already installed (${found})`)
return
}
console.log('📦 Installing pm2 globally...')
const r = run('npm install -g pm2')
if (r.status !== 0) { console.error('❌ pm2 install failed'); process.exit(1) }
console.log('✅ pm2 installed')
}
function setupPm2Autostart() {
const platform = os.platform()
console.log(`\n⚙️ Configuring pm2 autostart for platform: ${platform}`)
const startResult = run('pm2 start ecosystem.config.js', { cwd: __dirname })
if (startResult.status !== 0) {
console.warn('⚠️ pm2 start returned non-zero — continuing anyway')
}
if (platform === 'win32') {
const hasWindowsStartup = runCapture('npm list -g pm2-windows-startup')
if (!hasWindowsStartup || hasWindowsStartup.includes('empty')) {
console.log('📦 Installing pm2-windows-startup...')
run('npm install -g pm2-windows-startup')
run('pm2-startup install')
} else {
run('pm2-startup install')
}
} else {
const startupOut = runCapture('pm2 startup')
if (startupOut) {
const match = startupOut.match(/sudo\s+.+/)
if (match) {
console.log('Running generated startup command (may require sudo):')
console.log(match[0])
run(match[0])
}
}
}
run('pm2 save')
console.log('✅ pm2 autostart configured and process list saved')
}
async function install() {
console.log('=== sdd-bot installer ===\n')
checkNodeVersion()
npmInstallAndBuild()
const cfg = await promptConfig()
console.log('\nValidating connections...')
const results = await validateConnections(cfg)
const finalCfg = await retryFailedServices(cfg, results)
const projectRoot = path.resolve(__dirname, '..')
writeConfigFiles(finalCfg, projectRoot)
writeEcosystemConfig(projectRoot)
updateGitignore(projectRoot)
configureGitRemote(finalCfg, projectRoot)
ensurePm2()
setupPm2Autostart()
console.log('\n🎉 sdd-bot installation complete!')
}
module.exports = { install, validateConnections, setupPm2Autostart }
if (require.main === module) {
install().catch((err) => {
console.error('Installation failed:', err)
process.exit(1)
})
}