From 233ba4128d622d04fe1fe555bb5b266b981435d5 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 19 May 2026 09:52:54 -0700 Subject: [PATCH 01/10] wip on setup wizard --- packages/create-sourcebot/package.json | 25 + packages/create-sourcebot/src/index.ts | 765 ++++++++++++++++++++++++ packages/create-sourcebot/tsconfig.json | 22 + yarn.lock | 60 ++ 4 files changed, 872 insertions(+) create mode 100644 packages/create-sourcebot/package.json create mode 100644 packages/create-sourcebot/src/index.ts create mode 100644 packages/create-sourcebot/tsconfig.json diff --git a/packages/create-sourcebot/package.json b/packages/create-sourcebot/package.json new file mode 100644 index 000000000..67f580c9d --- /dev/null +++ b/packages/create-sourcebot/package.json @@ -0,0 +1,25 @@ +{ + "name": "create-sourcebot", + "version": "0.1.0", + "description": "CLI wizard for creating a Sourcebot configuration", + "type": "module", + "bin": "./dist/index.js", + "scripts": { + "build": "tsc", + "dev": "tsx src/index.ts" + }, + "dependencies": { + "@clack/prompts": "^1.4.0" + }, + "devDependencies": { + "@types/node": "^22.7.5", + "tsx": "^4.21.0", + "typescript": "^5.6.2" + }, + "engines": { + "node": ">=18" + }, + "files": [ + "dist" + ] +} diff --git a/packages/create-sourcebot/src/index.ts b/packages/create-sourcebot/src/index.ts new file mode 100644 index 000000000..6486f28ee --- /dev/null +++ b/packages/create-sourcebot/src/index.ts @@ -0,0 +1,765 @@ +#!/usr/bin/env node +import { + cancel, + confirm, + intro, + isCancel, + multiselect, + note, + outro, + password, + select, + spinner, + text, +} from '@clack/prompts'; +import { randomBytes } from 'crypto'; +import { existsSync, writeFileSync } from 'fs'; +import { writeFile } from 'fs/promises'; + +type ConnectionConfig = Record; +type EnvVars = Record; +type CollectResult = { config: ConnectionConfig; env: EnvVars }; + +function generateSecret(bytes: number): string { + return randomBytes(bytes).toString('base64'); +} + +function checkCancel(value: T | symbol): T { + if (isCancel(value)) { + cancel('Setup cancelled.'); + process.exit(0); + } + return value as T; +} + +function parseCommaSeparated(input: string): string[] { + return input.split(',').map(s => s.trim()).filter(Boolean); +} + +function toEnvKey(connectionName: string, suffix: string): string { + return `${connectionName.toUpperCase().replace(/-/g, '_')}_${suffix}`; +} + +function generateConnectionName(platform: string, existing: Record): string { + if (!existing[platform]) { + return platform; + } + let i = 1; + while (existing[`${platform}-${i}`]) { + i++; + } + return `${platform}-${i}`; +} + +async function collectGitHubConfig(connectionName: string): Promise { + const env: EnvVars = {}; + const config: ConnectionConfig = { type: 'github' }; + + const url = checkCancel(await text({ + message: 'GitHub URL', + initialValue: 'https://github.com', + validate: v => { + if (!v?.trim()) { + return 'URL is required'; + } + if (!/^https?:\/\//.test(v)) { + return 'Must start with http:// or https://'; + } + }, + })) as string; + if (url !== 'https://github.com') { + config.url = url; + } + + note( + [ + 'Fine-grained PAT (recommended):', + ` ${url}/settings/personal-access-tokens/new`, + ' Required permissions: Contents (read), Metadata (read)', + '', + 'Classic PAT:', + ` ${url}/settings/tokens/new`, + ' Required scope: repo', + ].join('\n'), + 'Create a GitHub Personal Access Token' + ); + + const envKey = toEnvKey(connectionName, 'TOKEN'); + const token = checkCancel(await password({ + message: `GitHub Personal Access Token (stored as ${envKey}, leave blank for public repos only)`, + })); + if ((token as string | undefined)?.trim()) { + env[envKey] = token as string; + config.token = { env: envKey }; + } + + const targets = checkCancel(await multiselect({ + message: 'What do you want to index?', + options: [ + { value: 'repos', label: 'Specific repositories', hint: 'e.g. org/repo' }, + { value: 'orgs', label: 'Organizations', hint: 'all repos in an org' }, + { value: 'users', label: 'Users', hint: 'all repos owned by a user' }, + ], + required: true, + })) as string[]; + + if (targets.includes('repos')) { + const input = checkCancel(await text({ + message: 'Repositories (comma-separated, owner/repo)', + placeholder: 'sourcebot-dev/sourcebot, torvalds/linux', + validate: v => { + if (!v?.trim()) { + return 'At least one repository is required'; + } + for (const r of parseCommaSeparated(v)) { + if (!/^[\w.-]+\/[\w.-]+$/.test(r)) { + return `Invalid format: "${r}" — expected owner/repo`; + } + } + }, + })); + config.repos = parseCommaSeparated(input as string); + } + + if (targets.includes('orgs')) { + const input = checkCancel(await text({ + message: 'Organizations (comma-separated)', + placeholder: 'my-org, another-org', + validate: v => !v?.trim() ? 'At least one organization is required' : undefined, + })); + config.orgs = parseCommaSeparated(input as string); + } + + if (targets.includes('users')) { + const input = checkCancel(await text({ + message: 'GitHub users (comma-separated)', + placeholder: 'torvalds, DHH', + validate: v => !v?.trim() ? 'At least one user is required' : undefined, + })); + config.users = parseCommaSeparated(input as string); + } + + return { config, env }; +} + +async function collectGitLabConfig(connectionName: string): Promise { + const env: EnvVars = {}; + const config: ConnectionConfig = { type: 'gitlab' }; + + const url = checkCancel(await text({ + message: 'GitLab URL', + initialValue: 'https://gitlab.com', + validate: v => { + if (!v?.trim()) { + return 'URL is required'; + } + if (!/^https?:\/\//.test(v)) { + return 'Must start with http:// or https://'; + } + }, + })) as string; + if (url !== 'https://gitlab.com') { + config.url = url; + } + + const gitlabEnvKey = toEnvKey(connectionName, 'TOKEN'); + const gitlabToken = checkCancel(await password({ + message: `GitLab Personal Access Token (stored as ${gitlabEnvKey}, leave blank for public repos only)`, + })); + if ((gitlabToken as string | undefined)?.trim()) { + env[gitlabEnvKey] = gitlabToken as string; + config.token = { env: gitlabEnvKey }; + } + + const targets = checkCancel(await multiselect({ + message: 'What do you want to index?', + options: [ + { value: 'groups', label: 'Groups', hint: 'all projects in a group' }, + { value: 'projects', label: 'Specific projects', hint: 'e.g. group/project' }, + { value: 'users', label: 'Users', hint: 'all projects owned by a user' }, + ], + required: true, + })) as string[]; + + if (targets.includes('groups')) { + const input = checkCancel(await text({ + message: 'Groups (comma-separated)', + placeholder: 'my-group, another-group', + validate: v => !v?.trim() ? 'At least one group is required' : undefined, + })); + config.groups = parseCommaSeparated(input as string); + } + + if (targets.includes('projects')) { + const input = checkCancel(await text({ + message: 'Projects (comma-separated, group/project)', + placeholder: 'my-group/my-project', + validate: v => !v?.trim() ? 'At least one project is required' : undefined, + })); + config.projects = parseCommaSeparated(input as string); + } + + if (targets.includes('users')) { + const input = checkCancel(await text({ + message: 'Users (comma-separated)', + placeholder: 'john.doe, jane.smith', + validate: v => !v?.trim() ? 'At least one user is required' : undefined, + })); + config.users = parseCommaSeparated(input as string); + } + + return { config, env }; +} + +async function collectBitbucketConfig(connectionName: string): Promise { + const env: EnvVars = {}; + const config: ConnectionConfig = { type: 'bitbucket' }; + + const userEnvKey = toEnvKey(connectionName, 'USERNAME'); + const username = checkCancel(await text({ + message: `Bitbucket username (stored as ${userEnvKey})`, + placeholder: 'your-username', + validate: v => !v?.trim() ? 'Username is required' : undefined, + })); + env[userEnvKey] = username as string; + config.user = { env: userEnvKey }; + + const tokenEnvKey = toEnvKey(connectionName, 'APP_PASSWORD'); + const token = checkCancel(await password({ + message: `Bitbucket App Password (stored as ${tokenEnvKey})`, + validate: v => !v?.trim() ? 'App Password is required' : undefined, + })); + env[tokenEnvKey] = token as string; + config.token = { env: tokenEnvKey }; + + const targets = checkCancel(await multiselect({ + message: 'What do you want to index?', + options: [ + { value: 'workspaces', label: 'Workspaces', hint: 'all repos in a workspace' }, + { value: 'repos', label: 'Specific repositories', hint: 'workspace/repo format' }, + ], + required: true, + })) as string[]; + + if (targets.includes('workspaces')) { + const input = checkCancel(await text({ + message: 'Workspaces (comma-separated)', + placeholder: 'my-workspace', + validate: v => !v?.trim() ? 'At least one workspace is required' : undefined, + })); + config.workspaces = parseCommaSeparated(input as string); + } + + if (targets.includes('repos')) { + const input = checkCancel(await text({ + message: 'Repositories (comma-separated, workspace/repo)', + placeholder: 'my-workspace/my-repo', + validate: v => !v?.trim() ? 'At least one repository is required' : undefined, + })); + config.repos = parseCommaSeparated(input as string); + } + + return { config, env }; +} + +async function collectGiteaConfig(connectionName: string): Promise { + const env: EnvVars = {}; + const config: ConnectionConfig = { type: 'gitea' }; + + const url = checkCancel(await text({ + message: 'Gitea URL', + initialValue: 'https://gitea.com', + validate: v => { + if (!v?.trim()) { + return 'URL is required'; + } + if (!/^https?:\/\//.test(v)) { + return 'Must start with http:// or https://'; + } + }, + })) as string; + if (url !== 'https://gitea.com') { + config.url = url; + } + + const giteaEnvKey = toEnvKey(connectionName, 'TOKEN'); + const giteaToken = checkCancel(await password({ + message: `Gitea Access Token (stored as ${giteaEnvKey}, leave blank for public repos only)`, + })); + if ((giteaToken as string | undefined)?.trim()) { + env[giteaEnvKey] = giteaToken as string; + config.token = { env: giteaEnvKey }; + } + + const targets = checkCancel(await multiselect({ + message: 'What do you want to index?', + options: [ + { value: 'orgs', label: 'Organizations' }, + { value: 'repos', label: 'Specific repositories', hint: 'owner/repo format' }, + { value: 'users', label: 'Users' }, + ], + required: true, + })) as string[]; + + if (targets.includes('orgs')) { + const input = checkCancel(await text({ + message: 'Organizations (comma-separated)', + placeholder: 'my-org', + validate: v => !v?.trim() ? 'At least one organization is required' : undefined, + })); + config.orgs = parseCommaSeparated(input as string); + } + + if (targets.includes('repos')) { + const input = checkCancel(await text({ + message: 'Repositories (comma-separated, owner/repo)', + placeholder: 'owner/repo', + validate: v => !v?.trim() ? 'At least one repository is required' : undefined, + })); + config.repos = parseCommaSeparated(input as string); + } + + if (targets.includes('users')) { + const input = checkCancel(await text({ + message: 'Users (comma-separated)', + placeholder: 'username', + validate: v => !v?.trim() ? 'At least one user is required' : undefined, + })); + config.users = parseCommaSeparated(input as string); + } + + return { config, env }; +} + +async function collectAzureDevOpsConfig(connectionName: string): Promise { + const env: EnvVars = {}; + const config: ConnectionConfig = { type: 'azuredevops' }; + + const envKey = toEnvKey(connectionName, 'TOKEN'); + const token = checkCancel(await password({ + message: `Azure DevOps Personal Access Token (stored as ${envKey})`, + validate: v => !v?.trim() ? 'Token is required' : undefined, + })); + env[envKey] = token as string; + config.token = { env: envKey }; + + const targets = checkCancel(await multiselect({ + message: 'What do you want to index?', + options: [ + { value: 'orgs', label: 'Organizations', hint: 'all projects in an org' }, + { value: 'projects', label: 'Specific projects', hint: 'org/project format' }, + { value: 'repos', label: 'Specific repositories', hint: 'org/project/repo format' }, + ], + required: true, + })) as string[]; + + if (targets.includes('orgs')) { + const input = checkCancel(await text({ + message: 'Organizations (comma-separated)', + placeholder: 'my-org', + validate: v => !v?.trim() ? 'At least one organization is required' : undefined, + })); + config.orgs = parseCommaSeparated(input as string); + } + + if (targets.includes('projects')) { + const input = checkCancel(await text({ + message: 'Projects (comma-separated, org/project)', + placeholder: 'my-org/my-project', + validate: v => !v?.trim() ? 'At least one project is required' : undefined, + })); + config.projects = parseCommaSeparated(input as string); + } + + if (targets.includes('repos')) { + const input = checkCancel(await text({ + message: 'Repositories (comma-separated, org/project/repo)', + placeholder: 'my-org/my-project/my-repo', + validate: v => !v?.trim() ? 'At least one repository is required' : undefined, + })); + config.repos = parseCommaSeparated(input as string); + } + + return { config, env }; +} + +async function collectGerritConfig(): Promise { + const config: ConnectionConfig = { type: 'gerrit' }; + + const url = checkCancel(await text({ + message: 'Gerrit URL', + placeholder: 'https://gerrit.example.com', + validate: v => { + if (!v?.trim()) { + return 'URL is required'; + } + if (!/^https?:\/\//.test(v)) { + return 'Must start with http:// or https://'; + } + }, + })); + config.url = url; + + const indexAll = checkCancel(await confirm({ + message: 'Index all projects?', + initialValue: true, + })); + + if (!indexAll) { + const input = checkCancel(await text({ + message: 'Projects to index (comma-separated)', + placeholder: 'my-project, another-project', + validate: v => !v?.trim() ? 'At least one project is required' : undefined, + })); + config.projects = parseCommaSeparated(input as string); + } + + return { config, env: {} }; +} + +type ModelConfig = Record; + +const PROVIDER_DEFAULT_MODELS: Record = { + 'anthropic': 'claude-sonnet-4-6', + 'openai': 'gpt-4o', + 'google-generative-ai': 'gemini-2.0-flash', + 'deepseek': 'deepseek-chat', + 'mistral': 'mistral-large-latest', + 'xai': 'grok-2-latest', +}; + +const PROVIDER_ENV_KEYS: Record = { + 'anthropic': 'ANTHROPIC_API_KEY', + 'openai': 'OPENAI_API_KEY', + 'google-generative-ai': 'GOOGLE_GENERATIVE_AI_API_KEY', + 'deepseek': 'DEEPSEEK_API_KEY', + 'mistral': 'MISTRAL_API_KEY', + 'xai': 'XAI_API_KEY', + 'openrouter': 'OPENROUTER_API_KEY', + 'openai-compatible': 'OPENAI_COMPATIBLE_API_KEY', + 'azure': 'AZURE_OPENAI_API_KEY', +}; + +async function collectModels(): Promise<{ models: ModelConfig[]; env: EnvVars }> { + const models: ModelConfig[] = []; + const env: EnvVars = {}; + + const wantsAI = checkCancel(await confirm({ + message: 'Would you like to configure AI features?', + initialValue: true, + })); + + if (!wantsAI) { + return { models, env }; + } + + // eslint-disable-next-line no-constant-condition + while (true) { + const provider = checkCancel(await select({ + message: 'Which AI provider?', + options: [ + { value: 'anthropic', label: 'Anthropic', hint: 'Claude' }, + { value: 'openai', label: 'OpenAI', hint: 'GPT-4o, o1' }, + { value: 'google-generative-ai', label: 'Google Gemini' }, + { value: 'deepseek', label: 'DeepSeek' }, + { value: 'mistral', label: 'Mistral' }, + { value: 'xai', label: 'xAI', hint: 'Grok' }, + { value: 'openrouter', label: 'OpenRouter' }, + { value: 'openai-compatible', label: 'OpenAI-compatible', hint: 'self-hosted / custom endpoint' }, + { value: 'amazon-bedrock', label: 'Amazon Bedrock' }, + { value: 'azure', label: 'Azure OpenAI' }, + ], + })) as string; + + const modelConfig: ModelConfig = { provider }; + + const defaultModel = PROVIDER_DEFAULT_MODELS[provider]; + const model = checkCancel(await text({ + message: 'Model name', + initialValue: defaultModel ?? '', + placeholder: defaultModel ? undefined : 'model-name', + validate: v => !v?.trim() ? 'Model name is required' : undefined, + })); + modelConfig.model = model; + + if (provider === 'openai-compatible') { + const baseUrl = checkCancel(await text({ + message: 'Base URL', + placeholder: 'https://your-endpoint.example.com/v1', + validate: v => { + if (!v?.trim()) { + return 'Base URL is required'; + } + if (!/^https?:\/\//.test(v)) { + return 'Must start with http:// or https://'; + } + }, + })); + modelConfig.baseUrl = baseUrl; + } + + if (provider === 'azure') { + const resourceName = checkCancel(await text({ + message: 'Azure resource name', + placeholder: 'my-azure-resource', + validate: v => !v?.trim() ? 'Resource name is required' : undefined, + })); + modelConfig.resourceName = resourceName; + + const apiVersion = checkCancel(await text({ + message: 'API version', + initialValue: '2024-08-01-preview', + validate: v => !v?.trim() ? 'API version is required' : undefined, + })); + modelConfig.apiVersion = apiVersion; + } + + if (provider === 'amazon-bedrock') { + const useDefaultChain = checkCancel(await confirm({ + message: 'Use the default AWS credential chain? (No to provide Access Key ID and Secret explicitly)', + initialValue: true, + })); + + if (!useDefaultChain) { + if (!env['AWS_ACCESS_KEY_ID']) { + const keyId = checkCancel(await text({ + message: 'AWS Access Key ID (stored as AWS_ACCESS_KEY_ID)', + validate: v => !v?.trim() ? 'Access Key ID is required' : undefined, + })); + env['AWS_ACCESS_KEY_ID'] = keyId as string; + } + modelConfig.accessKeyId = { env: 'AWS_ACCESS_KEY_ID' }; + + if (!env['AWS_SECRET_ACCESS_KEY']) { + const secret = checkCancel(await password({ + message: 'AWS Secret Access Key (stored as AWS_SECRET_ACCESS_KEY)', + validate: v => !v?.trim() ? 'Secret Access Key is required' : undefined, + })); + env['AWS_SECRET_ACCESS_KEY'] = secret as string; + } + modelConfig.accessKeySecret = { env: 'AWS_SECRET_ACCESS_KEY' }; + } + + const region = checkCancel(await text({ + message: 'AWS region', + initialValue: 'us-east-1', + validate: v => !v?.trim() ? 'Region is required' : undefined, + })); + modelConfig.region = region; + } else { + const envKey = PROVIDER_ENV_KEYS[provider] ?? `${provider.toUpperCase().replace(/-/g, '_')}_API_KEY`; + if (!env[envKey]) { + const apiKey = checkCancel(await password({ + message: `API key (stored as ${envKey})`, + validate: v => !v?.trim() ? 'API key is required' : undefined, + })); + env[envKey] = apiKey as string; + } + modelConfig.token = { env: envKey }; + } + + models.push(modelConfig); + + const addAnother = checkCancel(await confirm({ + message: 'Add another model?', + initialValue: false, + })); + + if (!addAnother) { + break; + } + } + + return { models, env }; +} + +const PLATFORM_LABELS: Record = { + github: 'GitHub', + gitlab: 'GitLab', + bitbucket: 'Bitbucket Cloud', + gitea: 'Gitea', + azuredevops: 'Azure DevOps', + gerrit: 'Gerrit', +}; + +async function main() { + intro('Create Sourcebot Configuration'); + + const connections: Record = {}; + const allEnv: EnvVars = {}; + + // eslint-disable-next-line no-constant-condition + while (true) { + const platform = checkCancel(await select({ + message: 'Which platform do you want to connect?', + options: [ + { value: 'github', label: 'GitHub', hint: 'github.com or GitHub Enterprise' }, + { value: 'gitlab', label: 'GitLab', hint: 'gitlab.com or self-hosted' }, + { value: 'bitbucket', label: 'Bitbucket Cloud', hint: 'bitbucket.org' }, + { value: 'gitea', label: 'Gitea', hint: 'self-hosted Gitea' }, + { value: 'azuredevops', label: 'Azure DevOps', hint: 'dev.azure.com' }, + { value: 'gerrit', label: 'Gerrit', hint: 'self-hosted Gerrit' }, + ], + })) as string; + + const connectionName = generateConnectionName(platform, connections); + + note(`Configuring ${PLATFORM_LABELS[platform] ?? platform}`, connectionName); + + let result: CollectResult; + + switch (platform) { + case 'github': + result = await collectGitHubConfig(connectionName); + break; + case 'gitlab': + result = await collectGitLabConfig(connectionName); + break; + case 'bitbucket': + result = await collectBitbucketConfig(connectionName); + break; + case 'gitea': + result = await collectGiteaConfig(connectionName); + break; + case 'azuredevops': + result = await collectAzureDevOpsConfig(connectionName); + break; + case 'gerrit': + result = await collectGerritConfig(); + break; + default: + continue; + } + + connections[connectionName] = result.config; + Object.assign(allEnv, result.env); + + const addAnother = checkCancel(await confirm({ + message: 'Add another connection?', + initialValue: false, + })); + + if (!addAnother) { + break; + } + } + + const { models, env: modelEnv } = await collectModels(); + Object.assign(allEnv, modelEnv); + + if (existsSync('config.json')) { + const overwrite = checkCancel(await confirm({ + message: 'config.json already exists. Overwrite?', + initialValue: false, + })); + if (!overwrite) { + cancel('config.json was not overwritten.'); + process.exit(0); + } + } + + const s = spinner(); + s.start('Writing configuration files...'); + + const configOutput: Record = { + $schema: 'https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json', + connections, + }; + if (models.length > 0) { + configOutput.models = models; + } + const configJson = JSON.stringify(configOutput, null, 4); + + const connectionEnv = Object.fromEntries( + Object.entries(allEnv).filter(([k]) => !Object.values(PROVIDER_ENV_KEYS).includes(k) && !['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY'].includes(k)) + ); + const aiEnv = Object.fromEntries( + Object.entries(allEnv).filter(([k]) => Object.values(PROVIDER_ENV_KEYS).includes(k) || ['AWS_ACCESS_KEY_ID', 'AWS_SECRET_ACCESS_KEY'].includes(k)) + ); + + const envLines: string[] = [ + '# Generated by create-sourcebot', + '', + '# Auto-generated secrets — do not change after first run', + `AUTH_SECRET=${generateSecret(33)}`, + `SOURCEBOT_ENCRYPTION_KEY=${generateSecret(24)}`, + ]; + + if (Object.keys(connectionEnv).length > 0) { + envLines.push('', '# Code host credentials'); + for (const [key, value] of Object.entries(connectionEnv)) { + envLines.push(`${key}=${value}`); + } + } + + if (Object.keys(aiEnv).length > 0) { + envLines.push('', '# AI provider credentials'); + for (const [key, value] of Object.entries(aiEnv)) { + envLines.push(`${key}=${value}`); + } + } + + writeFileSync('config.json', configJson + '\n'); + + const envPath = existsSync('.env') ? '.env.sourcebot' : '.env'; + writeFileSync(envPath, envLines.join('\n') + '\n'); + + s.stop(`Wrote config.json and ${envPath}`); + + const dockerComposeUrl = 'https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/docker-compose.yml'; + let downloadedCompose = false; + + if (!existsSync('docker-compose.yml')) { + const download = checkCancel(await confirm({ + message: 'Download docker-compose.yml?', + initialValue: true, + })); + + if (download) { + const ds = spinner(); + ds.start('Downloading docker-compose.yml...'); + try { + const res = await fetch(dockerComposeUrl); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + await writeFile('docker-compose.yml', await res.text()); + ds.stop('Downloaded docker-compose.yml'); + downloadedCompose = true; + } catch { + ds.stop('Download failed — you can get it manually (see next steps)'); + } + } + } else { + downloadedCompose = true; + } + + const nextSteps: string[] = []; + let step = 1; + + if (!downloadedCompose) { + nextSteps.push(`${step++}. Download docker-compose.yml:`); + nextSteps.push(` curl -o docker-compose.yml ${dockerComposeUrl}`); + nextSteps.push(''); + } + + if (envPath === '.env.sourcebot') { + nextSteps.push(`${step++}. Rename ${envPath} to .env:`); + nextSteps.push(` mv ${envPath} .env`); + nextSteps.push(''); + } + + nextSteps.push(`${step++}. Start Sourcebot:`); + nextSteps.push(' docker compose up'); + nextSteps.push(''); + nextSteps.push(`${step}. Open http://localhost:3000`); + + note(nextSteps.join('\n'), 'Next steps'); + + outro('Your Sourcebot configuration is ready!'); +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/packages/create-sourcebot/tsconfig.json b/packages/create-sourcebot/tsconfig.json new file mode 100644 index 000000000..018aa345b --- /dev/null +++ b/packages/create-sourcebot/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "outDir": "dist", + "declaration": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "module": "Node16", + "moduleResolution": "Node16", + "target": "ES2022", + "noEmitOnError": false, + "noImplicitAny": true, + "pretty": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "lib": ["ES2023"], + "types": ["node"], + "strict": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/yarn.lock b/yarn.lock index 7be7eb0ae..af36ada1c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1368,6 +1368,28 @@ __metadata: languageName: node linkType: hard +"@clack/core@npm:1.3.1": + version: 1.3.1 + resolution: "@clack/core@npm:1.3.1" + dependencies: + fast-wrap-ansi: "npm:^0.2.0" + sisteransi: "npm:^1.0.5" + checksum: 10c0/550f6e83574b12c94fcf67621c9e094419186bc2238c89a2f40b9f67c872b3563bb55381beb05ab48cef2d2b3b5d42c33d06bbba2fb28622fafab6afdde20262 + languageName: node + linkType: hard + +"@clack/prompts@npm:^1.4.0": + version: 1.4.0 + resolution: "@clack/prompts@npm:1.4.0" + dependencies: + "@clack/core": "npm:1.3.1" + fast-string-width: "npm:^3.0.2" + fast-wrap-ansi: "npm:^0.2.0" + sisteransi: "npm:^1.0.5" + checksum: 10c0/851ea8ec1597b70ff2e4b3e2ed4e340f5f10877f86b0372a29d96d6c6131f2a5a46f75a98dcb77378d3ec88ffe1d276724f7385be534be06f8a4ba36a550bc01 + languageName: node + linkType: hard + "@codemirror/autocomplete@npm:^6.0.0, @codemirror/autocomplete@npm:^6.16.2, @codemirror/autocomplete@npm:^6.3.2, @codemirror/autocomplete@npm:^6.7.1": version: 6.18.6 resolution: "@codemirror/autocomplete@npm:6.18.6" @@ -12124,6 +12146,19 @@ __metadata: languageName: node linkType: hard +"create-sourcebot@workspace:packages/create-sourcebot": + version: 0.0.0-use.local + resolution: "create-sourcebot@workspace:packages/create-sourcebot" + dependencies: + "@clack/prompts": "npm:^1.4.0" + "@types/node": "npm:^22.7.5" + tsx: "npm:^4.21.0" + typescript: "npm:^5.6.2" + bin: + create-sourcebot: ./dist/index.js + languageName: unknown + linkType: soft + "crelt@npm:^1.0.5": version: 1.0.6 resolution: "crelt@npm:1.0.6" @@ -13987,6 +14022,22 @@ __metadata: languageName: node linkType: hard +"fast-string-truncated-width@npm:^3.0.2": + version: 3.0.3 + resolution: "fast-string-truncated-width@npm:3.0.3" + checksum: 10c0/043b8663397d14a3880ce4f3407bcda60b40db9bbeafe62863a35d1f9c69ea17c8da3fcd72de235553e6c9cd053128cde9e24ca0d4a7463208f48db3cd23d981 + languageName: node + linkType: hard + +"fast-string-width@npm:^3.0.2": + version: 3.0.2 + resolution: "fast-string-width@npm:3.0.2" + dependencies: + fast-string-truncated-width: "npm:^3.0.2" + checksum: 10c0/c8822d175315bb353ebe782b65214ac53b13e3bf704e03b132ea7bdfa8de6a636375b3ab7a4097545393d109381c37c4f387c72a462c90b61412dbc4632f39a7 + languageName: node + linkType: hard + "fast-uri@npm:^3.1.2": version: 3.1.2 resolution: "fast-uri@npm:3.1.2" @@ -13994,6 +14045,15 @@ __metadata: languageName: node linkType: hard +"fast-wrap-ansi@npm:^0.2.0": + version: 0.2.0 + resolution: "fast-wrap-ansi@npm:0.2.0" + dependencies: + fast-string-width: "npm:^3.0.2" + checksum: 10c0/c0eb6debee565c5dbb9132dddff5c4d4aba5eb02185ae4dab285acd6186018cffca04264e92f373cbf592a9bcd1c33d65dba036030a8f3baeff1169969a1b59b + languageName: node + linkType: hard + "fast-xml-builder@npm:^1.1.5": version: 1.2.0 resolution: "fast-xml-builder@npm:1.2.0" From 701713f2b86a4c10c4137617be250e64721146e0 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 19 May 2026 09:57:40 -0700 Subject: [PATCH 02/10] docker-compose changes --- docker-compose.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 5e954067c..e0272ba35 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,9 @@ services: volumes: - ./config.json:/data/config.json - sourcebot_data:/data + env_file: + - path: .env + required: false environment: - CONFIG_PATH=/data/config.json - AUTH_URL=${AUTH_URL:-http://localhost:3000} @@ -22,7 +25,6 @@ services: - SOURCEBOT_ENCRYPTION_KEY=${SOURCEBOT_ENCRYPTION_KEY:-000000000000000000000000000000000} # CHANGEME: generate via `openssl rand -base64 24` - DATABASE_URL=${DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/postgres} # CHANGEME - REDIS_URL=${REDIS_URL:-redis://redis:6379} # CHANGEME - - SOURCEBOT_EE_LICENSE_KEY=${SOURCEBOT_EE_LICENSE_KEY:-} # For the full list of environment variables see: # https://docs.sourcebot.dev/docs/configuration/environment-variables From fd861b3a5d5a15d0e13fea2e4a85ef7122e22b42 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 19 May 2026 10:00:13 -0700 Subject: [PATCH 03/10] docker compose url --- packages/create-sourcebot/src/index.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/create-sourcebot/src/index.ts b/packages/create-sourcebot/src/index.ts index 6486f28ee..5100dfeb5 100644 --- a/packages/create-sourcebot/src/index.ts +++ b/packages/create-sourcebot/src/index.ts @@ -16,6 +16,10 @@ import { randomBytes } from 'crypto'; import { existsSync, writeFileSync } from 'fs'; import { writeFile } from 'fs/promises'; +// @nocheckin: change this to main +const DOCKER_COMPOSE_BRANCH = 'bkellam/setup-wizard' +const DOCKER_COMPOSE_URL = `https://raw.githubusercontent.com/sourcebot-dev/sourcebot/${DOCKER_COMPOSE_BRANCH}/docker-compose.yml`; + type ConnectionConfig = Record; type EnvVars = Record; type CollectResult = { config: ConnectionConfig; env: EnvVars }; @@ -706,7 +710,6 @@ async function main() { s.stop(`Wrote config.json and ${envPath}`); - const dockerComposeUrl = 'https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/docker-compose.yml'; let downloadedCompose = false; if (!existsSync('docker-compose.yml')) { @@ -719,7 +722,7 @@ async function main() { const ds = spinner(); ds.start('Downloading docker-compose.yml...'); try { - const res = await fetch(dockerComposeUrl); + const res = await fetch(DOCKER_COMPOSE_URL); if (!res.ok) { throw new Error(`HTTP ${res.status}`); } @@ -739,7 +742,7 @@ async function main() { if (!downloadedCompose) { nextSteps.push(`${step++}. Download docker-compose.yml:`); - nextSteps.push(` curl -o docker-compose.yml ${dockerComposeUrl}`); + nextSteps.push(` curl -o docker-compose.yml ${DOCKER_COMPOSE_URL}`); nextSteps.push(''); } From d9dbfe90e26fdbf60272757a8f6b5439cadd5956 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 19 May 2026 20:18:39 -0700 Subject: [PATCH 04/10] further progress on setup wizard --- packages/create-sourcebot/package.json | 7 +- packages/create-sourcebot/poc.mjs | 65 ++ packages/create-sourcebot/src/azuredevops.ts | 52 ++ packages/create-sourcebot/src/bitbucket.ts | 235 +++++++ packages/create-sourcebot/src/genericGit.ts | 25 + packages/create-sourcebot/src/gerrit.ts | 37 + packages/create-sourcebot/src/gitea.ts | 66 ++ packages/create-sourcebot/src/github.ts | 190 +++++ packages/create-sourcebot/src/gitlab.ts | 208 ++++++ packages/create-sourcebot/src/index.ts | 695 +++++-------------- packages/create-sourcebot/src/localRepos.ts | 145 ++++ packages/create-sourcebot/src/utils.ts | 83 +++ yarn.lock | 490 ++++++++++++- 13 files changed, 1755 insertions(+), 543 deletions(-) create mode 100644 packages/create-sourcebot/poc.mjs create mode 100644 packages/create-sourcebot/src/azuredevops.ts create mode 100644 packages/create-sourcebot/src/bitbucket.ts create mode 100644 packages/create-sourcebot/src/genericGit.ts create mode 100644 packages/create-sourcebot/src/gerrit.ts create mode 100644 packages/create-sourcebot/src/gitea.ts create mode 100644 packages/create-sourcebot/src/github.ts create mode 100644 packages/create-sourcebot/src/gitlab.ts create mode 100644 packages/create-sourcebot/src/localRepos.ts create mode 100644 packages/create-sourcebot/src/utils.ts diff --git a/packages/create-sourcebot/package.json b/packages/create-sourcebot/package.json index 67f580c9d..392aaeb42 100644 --- a/packages/create-sourcebot/package.json +++ b/packages/create-sourcebot/package.json @@ -6,10 +6,15 @@ "bin": "./dist/index.js", "scripts": { "build": "tsc", + "watch": "tsc --watch", "dev": "tsx src/index.ts" }, "dependencies": { - "@clack/prompts": "^1.4.0" + "@inquirer/prompts": "^8.4.3", + "@sourcebot/schemas": "workspace:^", + "chalk": "^5.6.2", + "inquirer-select-pro": "^1.0.0-alpha.9", + "ora": "^9.4.0" }, "devDependencies": { "@types/node": "^22.7.5", diff --git a/packages/create-sourcebot/poc.mjs b/packages/create-sourcebot/poc.mjs new file mode 100644 index 000000000..05f21165b --- /dev/null +++ b/packages/create-sourcebot/poc.mjs @@ -0,0 +1,65 @@ +import { password } from '@inquirer/prompts'; +import { select, Separator } from 'inquirer-select-pro'; +import { appendFileSync } from 'fs'; + +function dbg(msg) { + appendFileSync('/tmp/poc-debug.log', msg + '\n'); +} + +const cache = new Map(); +let abortController = null; + +async function searchGitHubOrgs(query, token, signal) { + if (cache.has(query)) { + dbg(`cache hit: "${query}"`); + return cache.get(query); + } + dbg(`searching: "${query}"`); + const headers = { + 'User-Agent': 'create-sourcebot', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; + const url = `https://api.github.com/search/users?q=${encodeURIComponent(query)}+type:org&per_page=8`; + const res = await fetch(url, { headers, signal }); + dbg(`status: ${res.status}`); + const data = await res.json(); + dbg(`response: ${JSON.stringify(data).slice(0, 300)}`); + + if (!res.ok) { + const warning = + (res.status === 403 && res.headers.get('x-ratelimit-remaining') === '0') ? + '⚠ Autocomplete disabled - GitHub rate limit exceeded.' : + '⚠ Autocomplete disalbed - Authentication failed, check your PAT.'; + return [ + { name: query, value: query }, + new Separator(warning), + ]; + } + + const results = data.items.map(item => ({ name: item.login, value: item.login })); + if (results.length === 0) { + return [{ name: query, value: query }]; + } + cache.set(query, results); + return results; +} + +const token = await password({ + message: 'GitHub PAT (leave blank for public search)', + mask: true, +}); + +const orgs = await select({ + message: 'Search for GitHub organizations to index', + multiple: true, + loop: false, + clearInputWhenSelected: true, + options: async (input) => { + if (!input || input.length < 2) { + return []; + } + return searchGitHubOrgs(input, token); + }, +}); + +console.log(`\nSelected: ${orgs.join(', ')}`); diff --git a/packages/create-sourcebot/src/azuredevops.ts b/packages/create-sourcebot/src/azuredevops.ts new file mode 100644 index 000000000..e9a3c49f2 --- /dev/null +++ b/packages/create-sourcebot/src/azuredevops.ts @@ -0,0 +1,52 @@ +import { checkbox, password } from '@inquirer/prompts'; +import type { AzureDevOpsConnectionConfig } from '@sourcebot/schemas/v3/azuredevops.type'; +import type { CollectResult, EnvVars } from './utils.js'; +import { multiInput, toEnvKey } from './utils.js'; + +export async function collectAzureDevOpsConfig(connectionName: string): Promise { + const env: EnvVars = {}; + + const envKey = toEnvKey(connectionName, 'TOKEN'); + const token = await password({ + message: `Azure DevOps Personal Access Token (stored as ${envKey})`, + mask: true, + validate: (v) => !v?.trim() ? 'Token is required' : true, + }); + env[envKey] = token; + + const config: AzureDevOpsConnectionConfig = { + type: 'azuredevops', + deploymentType: 'cloud', + token: { env: envKey }, + }; + + const targets = await checkbox({ + message: 'What do you want to index?', + choices: [ + { value: 'orgs', name: 'Organizations', description: 'all projects in an org' }, + { value: 'projects', name: 'Specific projects', description: 'org/project format' }, + { value: 'repos', name: 'Specific repositories', description: 'org/project/repo format' }, + ], + required: true, + }); + + if (targets.includes('orgs')) { + config.orgs = await multiInput({ + message: 'Organizations to index', + }); + } + + if (targets.includes('projects')) { + config.projects = await multiInput({ + message: 'Projects to index (org/project)', + }); + } + + if (targets.includes('repos')) { + config.repos = await multiInput({ + message: 'Repositories to index (org/project/repo)', + }); + } + + return { connections: [{ config }], env }; +} diff --git a/packages/create-sourcebot/src/bitbucket.ts b/packages/create-sourcebot/src/bitbucket.ts new file mode 100644 index 000000000..83035dc16 --- /dev/null +++ b/packages/create-sourcebot/src/bitbucket.ts @@ -0,0 +1,235 @@ +import { checkbox, confirm, input, password, select } from '@inquirer/prompts'; +import type { BitbucketConnectionConfig } from '@sourcebot/schemas/v3/bitbucket.type'; +import type { CollectResult, EnvVars } from './utils.js'; +import { multiInput, note, toEnvKey } from './utils.js'; + +export async function collectBitbucketConfig(connectionName: string): Promise { + const env: EnvVars = {}; + + const deploymentType = await select<'cloud' | 'server'>({ + message: 'Which Bitbucket deployment?', + choices: [ + { value: 'cloud', name: 'Bitbucket Cloud', description: 'bitbucket.org' }, + { value: 'server', name: 'Bitbucket Data Center', description: 'self-hosted' }, + ], + }); + + const config: BitbucketConnectionConfig = { + type: 'bitbucket', + deploymentType, + }; + + if (deploymentType === 'cloud') { + return collectBitbucketCloud(connectionName, config, env); + } + return collectBitbucketServer(connectionName, config, env); +} + +async function collectBitbucketCloud( + connectionName: string, + config: BitbucketConnectionConfig, + env: EnvVars, +): Promise { + const authMethod = await select<'api-token' | 'access-token' | 'app-password'>({ + message: 'How will you authenticate?', + choices: [ + { value: 'api-token', name: 'API Token', description: 'Recommended by Atlassian' }, + { value: 'access-token', name: 'Access Token', description: 'Scoped to a repo, project, or workspace' }, + { value: 'app-password', name: 'App Password (deprecated)', description: 'Deprecated by Atlassian' }, + ], + }); + + if (authMethod === 'api-token') { + note( + 'The email you use to sign in to Atlassian (e.g. you@example.com).', + 'Atlassian account email', + ); + + const email = await input({ + message: 'Atlassian account email', + validate: (v) => !v?.trim() ? 'Email is required' : true, + }); + config.user = email; + + note( + [ + 'Your Bitbucket username (separate from your Atlassian email).', + ' Find it at: https://bitbucket.org/account/settings/', + ].join('\n'), + 'Bitbucket username', + ); + + const gitUser = await input({ + message: 'Bitbucket username', + validate: (v) => !v?.trim() ? 'Username is required' : true, + }); + config.gitUser = gitUser; + + note( + [ + 'Create an API Token at:', + ' https://id.atlassian.com/manage-profile/security/api-tokens', + 'Click "Create API token with scopes", choose Bitbucket, and grant:', + ' read:repository:bitbucket', + ' read:workspace:bitbucket', + ].join('\n'), + 'Bitbucket Cloud API Token', + ); + + const tokenEnvKey = toEnvKey(connectionName, 'TOKEN'); + const token = await password({ + message: `API Token (stored as ${tokenEnvKey})`, + mask: true, + validate: (v) => !v?.trim() ? 'Token is required' : true, + }); + env[tokenEnvKey] = token; + config.token = { env: tokenEnvKey }; + } else if (authMethod === 'access-token') { + note( + [ + 'Create an Access Token scoped to a repo, project, or workspace.', + ' https://support.atlassian.com/bitbucket-cloud/docs/access-tokens/', + ].join('\n'), + 'Create a Bitbucket Cloud Access Token', + ); + + const tokenEnvKey = toEnvKey(connectionName, 'TOKEN'); + const token = await password({ + message: `Access Token (stored as ${tokenEnvKey})`, + mask: true, + validate: (v) => !v?.trim() ? 'Token is required' : true, + }); + env[tokenEnvKey] = token; + config.token = { env: tokenEnvKey }; + } else { + note( + [ + '⚠ App Passwords are deprecated. Prefer an API Token if possible.', + '', + 'Create an App Password:', + ' https://bitbucket.org/account/settings/app-passwords/new', + ' Required permissions: Repositories (read), Workspaces (read)', + ].join('\n'), + 'Create a Bitbucket Cloud App Password', + ); + + const username = await input({ + message: 'Bitbucket username', + validate: (v) => !v?.trim() ? 'Username is required' : true, + }); + config.user = username; + + const tokenEnvKey = toEnvKey(connectionName, 'APP_PASSWORD'); + const token = await password({ + message: `Bitbucket App Password (stored as ${tokenEnvKey})`, + mask: true, + validate: (v) => !v?.trim() ? 'App Password is required' : true, + }); + env[tokenEnvKey] = token; + config.token = { env: tokenEnvKey }; + } + + const targets = await checkbox({ + message: 'What do you want to index?', + choices: [ + { value: 'workspaces', name: 'Workspaces', description: 'Index every repo each chosen workspace owns' }, + { value: 'repos', name: 'Specific repositories', description: 'Hand-pick individual repos to index' }, + ], + required: true, + }); + + if (targets.includes('workspaces')) { + config.workspaces = await multiInput({ + message: 'Workspaces to index', + }); + } + + if (targets.includes('repos')) { + config.repos = await multiInput({ + message: 'Repositories to index (workspace/repo)', + }); + } + + return { connections: [{ config }], env }; +} + +async function collectBitbucketServer( + connectionName: string, + config: BitbucketConnectionConfig, + env: EnvVars, +): Promise { + const url = await input({ + message: 'Bitbucket Data Center URL (e.g. https://bitbucket.example.com)', + validate: (v) => { + if (!v?.trim()) { + return 'URL is required'; + } + if (!/^https?:\/\//.test(v)) { + return 'Must start with http:// or https://'; + } + return true; + }, + }); + config.url = url; + + note( + [ + 'Create an HTTP Access Token:', + ' Profile → Manage account → HTTP access tokens', + ' Required permissions: Project read, Repository read', + '', + 'Use a user-account token for cross-project access,', + 'or a project/repository-scoped token for narrower access.', + ].join('\n'), + 'Create a Bitbucket Data Center HTTP Access Token', + ); + + const username = await input({ + message: 'Bitbucket username (leave blank if using a project/repo-scoped token)', + }); + if (username.trim()) { + config.user = username; + } + + const tokenEnvKey = toEnvKey(connectionName, 'TOKEN'); + const token = await password({ + message: `Bitbucket HTTP Access Token (stored as ${tokenEnvKey})`, + mask: true, + validate: (v) => !v?.trim() ? 'Token is required' : true, + }); + env[tokenEnvKey] = token; + config.token = { env: tokenEnvKey }; + + const indexAll = await confirm({ + message: 'Index every repository visible to the token?', + default: false, + }); + + if (indexAll) { + config.all = true; + return { connections: [{ config }], env }; + } + + const targets = await checkbox({ + message: 'What do you want to index?', + choices: [ + { value: 'projects', name: 'Projects', description: 'Index every repo in each chosen project' }, + { value: 'repos', name: 'Specific repositories', description: 'Hand-pick individual repos to index' }, + ], + required: true, + }); + + if (targets.includes('projects')) { + config.projects = await multiInput({ + message: 'Project keys to index (e.g. MYPROJ)', + }); + } + + if (targets.includes('repos')) { + config.repos = await multiInput({ + message: 'Repositories to index (project/repo)', + }); + } + + return { connections: [{ config }], env }; +} diff --git a/packages/create-sourcebot/src/genericGit.ts b/packages/create-sourcebot/src/genericGit.ts new file mode 100644 index 000000000..5f636220e --- /dev/null +++ b/packages/create-sourcebot/src/genericGit.ts @@ -0,0 +1,25 @@ +import { input } from '@inquirer/prompts'; +import type { GenericGitHostConnectionConfig } from '@sourcebot/schemas/v3/genericGitHost.type'; +import type { CollectResult } from './utils.js'; + +export async function collectGenericGitConfig(): Promise { + const url = await input({ + message: 'Git clone URL (e.g. https://github.com/sourcebot-dev/sourcebot)', + validate: (v) => { + if (!v?.trim()) { + return 'URL is required'; + } + if (!/^https?:\/\//.test(v)) { + return 'Must start with http:// or https://'; + } + return true; + }, + }); + + const config: GenericGitHostConnectionConfig = { + type: 'git', + url, + }; + + return { connections: [{ config }], env: {} }; +} diff --git a/packages/create-sourcebot/src/gerrit.ts b/packages/create-sourcebot/src/gerrit.ts new file mode 100644 index 000000000..51ccd97e0 --- /dev/null +++ b/packages/create-sourcebot/src/gerrit.ts @@ -0,0 +1,37 @@ +import { confirm, input } from '@inquirer/prompts'; +import type { GerritConnectionConfig } from '@sourcebot/schemas/v3/gerrit.type'; +import type { CollectResult } from './utils.js'; +import { multiInput } from './utils.js'; + +export async function collectGerritConfig(): Promise { + const url = await input({ + message: 'Gerrit URL (e.g. https://gerrit.example.com)', + validate: (v) => { + if (!v?.trim()) { + return 'URL is required'; + } + if (!/^https?:\/\//.test(v)) { + return 'Must start with http:// or https://'; + } + return true; + }, + }); + + const config: GerritConnectionConfig = { + type: 'gerrit', + url, + }; + + const indexAll = await confirm({ + message: 'Index all projects?', + default: true, + }); + + if (!indexAll) { + config.projects = await multiInput({ + message: 'Projects to index', + }); + } + + return { connections: [{ config }], env: {} }; +} diff --git a/packages/create-sourcebot/src/gitea.ts b/packages/create-sourcebot/src/gitea.ts new file mode 100644 index 000000000..08d716a5e --- /dev/null +++ b/packages/create-sourcebot/src/gitea.ts @@ -0,0 +1,66 @@ +import { checkbox, input, password } from '@inquirer/prompts'; +import type { GiteaConnectionConfig } from '@sourcebot/schemas/v3/gitea.type'; +import type { CollectResult, EnvVars } from './utils.js'; +import { multiInput, toEnvKey } from './utils.js'; + +export async function collectGiteaConfig(connectionName: string): Promise { + const env: EnvVars = {}; + const config: GiteaConnectionConfig = { type: 'gitea' }; + + const url = await input({ + message: 'Gitea URL', + default: 'https://gitea.com', + validate: (v) => { + if (!v?.trim()) { + return 'URL is required'; + } + if (!/^https?:\/\//.test(v)) { + return 'Must start with http:// or https://'; + } + return true; + }, + }); + if (url !== 'https://gitea.com') { + config.url = url; + } + + const giteaEnvKey = toEnvKey(connectionName, 'TOKEN'); + const giteaToken = await password({ + message: `Gitea Access Token (stored as ${giteaEnvKey}, leave blank for public repos only)`, + mask: true, + }); + if (giteaToken.trim()) { + env[giteaEnvKey] = giteaToken; + config.token = { env: giteaEnvKey }; + } + + const targets = await checkbox({ + message: 'What do you want to index?', + choices: [ + { value: 'orgs', name: 'Organizations' }, + { value: 'repos', name: 'Specific repositories', description: 'owner/repo format' }, + { value: 'users', name: 'Users' }, + ], + required: true, + }); + + if (targets.includes('orgs')) { + config.orgs = await multiInput({ + message: 'Organizations to index', + }); + } + + if (targets.includes('repos')) { + config.repos = await multiInput({ + message: 'Repositories to index (owner/repo)', + }); + } + + if (targets.includes('users')) { + config.users = await multiInput({ + message: 'Users to index', + }); + } + + return { connections: [{ config }], env }; +} diff --git a/packages/create-sourcebot/src/github.ts b/packages/create-sourcebot/src/github.ts new file mode 100644 index 000000000..39f22e4f7 --- /dev/null +++ b/packages/create-sourcebot/src/github.ts @@ -0,0 +1,190 @@ +import { checkbox, input, password } from '@inquirer/prompts'; +import { select as searchSelect, Separator } from 'inquirer-select-pro'; +import type { GithubConnectionConfig } from '@sourcebot/schemas/v3/github.type'; +import type { CollectResult, EnvVars } from './utils.js'; +import { note, toEnvKey } from './utils.js'; + +function githubApiBase(url: string): string { + try { + const u = new URL(url); + if (u.hostname === 'github.com') { + return 'https://api.github.com'; + } + return `${u.protocol}//${u.hostname}/api/v3`; + } catch { + return 'https://api.github.com'; + } +} + +type SearchOption = { name: string; value: string }; +type GitHubSearchType = 'org' | 'user' | 'repo'; +const githubSearchCache = new Map>(); +const REPO_PATTERN = /^[\w.-]+\/[\w.-]+$/; + +async function searchGitHub( + apiBase: string, + query: string, + token: string, + type: GitHubSearchType, +): Promise> { + const cacheKey = `${apiBase}|${type}|${query}`; + const cached = githubSearchCache.get(cacheKey); + if (cached) { + return cached; + } + + const headers: Record = { + 'User-Agent': 'create-sourcebot', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; + const url = type === 'repo' + ? `${apiBase}/search/repositories?q=${encodeURIComponent(query)}&per_page=8` + : `${apiBase}/search/users?q=${encodeURIComponent(query)}+type:${type}&per_page=8`; + const res = await fetch(url, { headers }); + const data = await res.json() as { items?: Array<{ login?: string; full_name?: string }> }; + + const literalFallback = (): SearchOption | null => { + return { name: query, value: query }; + }; + + if (!res.ok) { + const warning = + (res.status === 403 && res.headers.get('x-ratelimit-remaining') === '0') + ? '⚠ Autocomplete disabled — GitHub rate limit exceeded.' + : '⚠ Autocomplete disabled — authentication failed, check your PAT.'; + const fallback = literalFallback(); + return fallback ? [fallback, new Separator(warning)] : [new Separator(warning)]; + } + + const results: SearchOption[] = (data.items ?? []).map((item) => { + const value = type === 'repo' ? item.full_name! : item.login!; + return { name: value, value }; + }); + if (results.length === 0) { + const fallback = literalFallback(); + return fallback ? [fallback] : []; + } + githubSearchCache.set(cacheKey, results); + return results; +} + +export async function collectGitHubConfig(connectionName: string): Promise { + const env: EnvVars = {}; + const config: GithubConnectionConfig = { type: 'github' }; + + const url = await input({ + message: 'GitHub URL', + default: 'https://github.com', + validate: (v) => { + if (!v?.trim()) { + return 'URL is required'; + } + if (!/^https?:\/\//.test(v)) { + return 'Must start with http:// or https://'; + } + return true; + }, + }); + if (url !== 'https://github.com') { + config.url = url; + } + + note( + [ + 'Fine-grained PAT (recommended):', + ` ${url}/settings/personal-access-tokens/new`, + ' Required permissions: Contents (read), Metadata (read)', + '', + 'Classic PAT:', + ` ${url}/settings/tokens/new`, + ' Required scope: repo', + ].join('\n'), + 'Create a GitHub Personal Access Token', + ); + + const tokenEnvKey = toEnvKey(connectionName, 'TOKEN'); + const token = await password({ + message: `GitHub Personal Access Token (stored as ${tokenEnvKey}, leave blank for public repos only)`, + mask: true, + }); + if (token.trim()) { + env[tokenEnvKey] = token; + config.token = { env: tokenEnvKey }; + } + + const apiBase = githubApiBase(url); + + const targets = await checkbox({ + message: 'What do you want to index?', + choices: [ + { value: 'repos', name: 'Specific repositories', description: 'Hand-pick individual repos to index' }, + { value: 'orgs', name: 'Organizations', description: 'Index every repo each chosen org owns' }, + { value: 'users', name: 'Users', description: 'Index every repo each chosen user owns' }, + ], + required: true, + }); + + if (targets.includes('repos')) { + const repos = await searchSelect({ + message: 'Repositories to index (type to search, or type owner/repo)', + multiple: true, + required: true, + loop: false, + clearInputWhenSelected: true, + placeholder: 'Type 2+ characters to search...', + options: async (search) => { + if (!search || search.length < 2) { + return []; + } + return searchGitHub(apiBase, search, token, 'repo'); + }, + validate: (selected) => { + for (const opt of selected) { + if (!REPO_PATTERN.test(opt.value)) { + return `Invalid format: "${opt.value}" — expected owner/repo`; + } + } + return true; + }, + }); + config.repos = repos; + } + + if (targets.includes('orgs')) { + const orgs = await searchSelect({ + message: 'Organizations to index (type to search)', + multiple: true, + required: true, + loop: false, + clearInputWhenSelected: true, + placeholder: 'Type 2+ characters to search...', + options: async (search) => { + if (!search || search.length < 2) { + return []; + } + return searchGitHub(apiBase, search, token, 'org'); + }, + }); + config.orgs = orgs; + } + + if (targets.includes('users')) { + const users = await searchSelect({ + message: 'GitHub users to index (type to search)', + multiple: true, + required: true, + loop: false, + clearInputWhenSelected: true, + placeholder: 'Type 2+ characters to search...', + options: async (search) => { + if (!search || search.length < 2) { + return []; + } + return searchGitHub(apiBase, search, token, 'user'); + }, + }); + config.users = users; + } + + return { connections: [{ config }], env }; +} diff --git a/packages/create-sourcebot/src/gitlab.ts b/packages/create-sourcebot/src/gitlab.ts new file mode 100644 index 000000000..109181e48 --- /dev/null +++ b/packages/create-sourcebot/src/gitlab.ts @@ -0,0 +1,208 @@ +import { checkbox, input, password } from '@inquirer/prompts'; +import { select as searchSelect, Separator } from 'inquirer-select-pro'; +import type { GitlabConnectionConfig } from '@sourcebot/schemas/v3/gitlab.type'; +import type { CollectResult, EnvVars } from './utils.js'; +import { note, toEnvKey } from './utils.js'; + +function gitlabApiBase(url: string): string { + try { + const u = new URL(url); + return `${u.protocol}//${u.host}/api/v4`; + } catch { + return 'https://gitlab.com/api/v4'; + } +} + +type SearchOption = { name: string; value: string }; +type GitLabSearchType = 'group' | 'project' | 'user'; +const gitlabSearchCache = new Map>(); +const PROJECT_PATTERN = /^[\w.-]+(\/[\w.-]+)+$/; + +async function searchGitLab( + apiBase: string, + query: string, + token: string, + type: GitLabSearchType, +): Promise> { + const cacheKey = `${apiBase}|${type}|${query}`; + const cached = gitlabSearchCache.get(cacheKey); + if (cached) { + return cached; + } + + const headers: Record = { + 'User-Agent': 'create-sourcebot', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }; + + const endpoint = type === 'group' ? 'groups' : type === 'project' ? 'projects' : 'users'; + const extraParams = type === 'project' ? '&simple=true' : ''; + const url = `${apiBase}/${endpoint}?search=${encodeURIComponent(query)}&per_page=8${extraParams}`; + const res = await fetch(url, { headers }); + + const literalFallback = (): SearchOption | null => { + if (type === 'project') { + return PROJECT_PATTERN.test(query) ? { name: query, value: query } : null; + } + return { name: query, value: query }; + }; + + if (!res.ok) { + const warning = res.status === 401 + ? '⚠ Autocomplete disabled — authentication failed, check your PAT.' + : `⚠ Autocomplete disabled — GitLab API error (${res.status}).`; + const fallback = literalFallback(); + return fallback ? [fallback, new Separator(warning)] : [new Separator(warning)]; + } + + const data = await res.json() as Array<{ + full_path?: string; + path_with_namespace?: string; + username?: string; + }>; + + const results: SearchOption[] = data.map((item) => { + let value: string; + if (type === 'group') { + value = item.full_path!; + } else if (type === 'project') { + value = item.path_with_namespace!; + } else { + value = item.username!; + } + return { name: value, value }; + }); + + if (results.length === 0) { + const fallback = literalFallback(); + return fallback ? [fallback] : []; + } + + gitlabSearchCache.set(cacheKey, results); + return results; +} + +export async function collectGitLabConfig(connectionName: string): Promise { + const env: EnvVars = {}; + const config: GitlabConnectionConfig = { type: 'gitlab' }; + + const url = await input({ + message: 'GitLab URL', + default: 'https://gitlab.com', + validate: (v) => { + if (!v?.trim()) { + return 'URL is required'; + } + if (!/^https?:\/\//.test(v)) { + return 'Must start with http:// or https://'; + } + return true; + }, + }); + if (url !== 'https://gitlab.com') { + config.url = url; + } + + note( + [ + 'Create a PAT:', + ` ${url}/-/user_settings/personal_access_tokens`, + ' Required scope: read_api', + ].join('\n'), + 'Create a GitLab Personal Access Token', + ); + + const gitlabEnvKey = toEnvKey(connectionName, 'TOKEN'); + const gitlabToken = await password({ + message: `GitLab Personal Access Token (stored as ${gitlabEnvKey}, leave blank for public repos only)`, + mask: true, + }); + if (gitlabToken.trim()) { + env[gitlabEnvKey] = gitlabToken; + config.token = { env: gitlabEnvKey }; + } + + const apiBase = gitlabApiBase(url); + const isSelfHosted = url !== 'https://gitlab.com'; + + const targets = await checkbox({ + message: 'What do you want to index?', + choices: [ + ...(isSelfHosted + ? [{ value: 'all', name: 'Everything', description: 'Index every project visible to the token on this self-hosted instance' }] + : []), + { value: 'groups', name: 'Groups', description: 'Index every project each chosen group owns' }, + { value: 'projects', name: 'Specific projects', description: 'Hand-pick individual projects to index' }, + { value: 'users', name: 'Users', description: 'Index every project each chosen user owns' }, + ], + required: true, + }); + + if (targets.includes('all')) { + config.all = true; + } + + if (targets.includes('groups')) { + const groups = await searchSelect({ + message: 'Groups to index (type to search)', + multiple: true, + required: true, + loop: false, + clearInputWhenSelected: true, + placeholder: 'Type 2+ characters to search...', + options: async (search) => { + if (!search || search.length < 2) { + return []; + } + return searchGitLab(apiBase, search, gitlabToken, 'group'); + }, + }); + config.groups = groups; + } + + if (targets.includes('projects')) { + const projects = await searchSelect({ + message: 'Projects to index (type to search, or type group/project)', + multiple: true, + required: true, + loop: false, + clearInputWhenSelected: true, + placeholder: 'Type 2+ characters to search...', + options: async (search) => { + if (!search || search.length < 2) { + return []; + } + return searchGitLab(apiBase, search, gitlabToken, 'project'); + }, + validate: (selected) => { + for (const opt of selected) { + if (!PROJECT_PATTERN.test(opt.value)) { + return `Invalid format: "${opt.value}" — expected group/project`; + } + } + return true; + }, + }); + config.projects = projects; + } + + if (targets.includes('users')) { + const users = await searchSelect({ + message: 'Users to index (type to search)', + multiple: true, + required: true, + loop: false, + clearInputWhenSelected: true, + placeholder: 'Type 2+ characters to search...', + options: async (search) => { + if (!search || search.length < 2) { + return []; + } + return searchGitLab(apiBase, search, gitlabToken, 'user'); + }, + }); + config.users = users; + } + + return { connections: [{ config }], env }; +} diff --git a/packages/create-sourcebot/src/index.ts b/packages/create-sourcebot/src/index.ts index 5100dfeb5..acb8a0852 100644 --- a/packages/create-sourcebot/src/index.ts +++ b/packages/create-sourcebot/src/index.ts @@ -1,426 +1,30 @@ #!/usr/bin/env node -import { - cancel, - confirm, - intro, - isCancel, - multiselect, - note, - outro, - password, - select, - spinner, - text, -} from '@clack/prompts'; -import { randomBytes } from 'crypto'; +import { confirm, input, password, select } from '@inquirer/prompts'; +import chalk from 'chalk'; +import ora from 'ora'; import { existsSync, writeFileSync } from 'fs'; import { writeFile } from 'fs/promises'; +import { collectAzureDevOpsConfig } from './azuredevops.js'; +import { collectBitbucketConfig } from './bitbucket.js'; +import { collectGenericGitConfig } from './genericGit.js'; +import { collectGerritConfig } from './gerrit.js'; +import { collectGiteaConfig } from './gitea.js'; +import { collectGitHubConfig } from './github.js'; +import { collectGitLabConfig } from './gitlab.js'; +import { collectLocalReposConfig } from './localRepos.js'; +import { + type CollectResult, + type ConnectionConfig, + type EnvVars, + generateConnectionName, + generateSecret, + note, +} from './utils.js'; // @nocheckin: change this to main -const DOCKER_COMPOSE_BRANCH = 'bkellam/setup-wizard' +const DOCKER_COMPOSE_BRANCH = 'bkellam/setup-wizard'; const DOCKER_COMPOSE_URL = `https://raw.githubusercontent.com/sourcebot-dev/sourcebot/${DOCKER_COMPOSE_BRANCH}/docker-compose.yml`; -type ConnectionConfig = Record; -type EnvVars = Record; -type CollectResult = { config: ConnectionConfig; env: EnvVars }; - -function generateSecret(bytes: number): string { - return randomBytes(bytes).toString('base64'); -} - -function checkCancel(value: T | symbol): T { - if (isCancel(value)) { - cancel('Setup cancelled.'); - process.exit(0); - } - return value as T; -} - -function parseCommaSeparated(input: string): string[] { - return input.split(',').map(s => s.trim()).filter(Boolean); -} - -function toEnvKey(connectionName: string, suffix: string): string { - return `${connectionName.toUpperCase().replace(/-/g, '_')}_${suffix}`; -} - -function generateConnectionName(platform: string, existing: Record): string { - if (!existing[platform]) { - return platform; - } - let i = 1; - while (existing[`${platform}-${i}`]) { - i++; - } - return `${platform}-${i}`; -} - -async function collectGitHubConfig(connectionName: string): Promise { - const env: EnvVars = {}; - const config: ConnectionConfig = { type: 'github' }; - - const url = checkCancel(await text({ - message: 'GitHub URL', - initialValue: 'https://github.com', - validate: v => { - if (!v?.trim()) { - return 'URL is required'; - } - if (!/^https?:\/\//.test(v)) { - return 'Must start with http:// or https://'; - } - }, - })) as string; - if (url !== 'https://github.com') { - config.url = url; - } - - note( - [ - 'Fine-grained PAT (recommended):', - ` ${url}/settings/personal-access-tokens/new`, - ' Required permissions: Contents (read), Metadata (read)', - '', - 'Classic PAT:', - ` ${url}/settings/tokens/new`, - ' Required scope: repo', - ].join('\n'), - 'Create a GitHub Personal Access Token' - ); - - const envKey = toEnvKey(connectionName, 'TOKEN'); - const token = checkCancel(await password({ - message: `GitHub Personal Access Token (stored as ${envKey}, leave blank for public repos only)`, - })); - if ((token as string | undefined)?.trim()) { - env[envKey] = token as string; - config.token = { env: envKey }; - } - - const targets = checkCancel(await multiselect({ - message: 'What do you want to index?', - options: [ - { value: 'repos', label: 'Specific repositories', hint: 'e.g. org/repo' }, - { value: 'orgs', label: 'Organizations', hint: 'all repos in an org' }, - { value: 'users', label: 'Users', hint: 'all repos owned by a user' }, - ], - required: true, - })) as string[]; - - if (targets.includes('repos')) { - const input = checkCancel(await text({ - message: 'Repositories (comma-separated, owner/repo)', - placeholder: 'sourcebot-dev/sourcebot, torvalds/linux', - validate: v => { - if (!v?.trim()) { - return 'At least one repository is required'; - } - for (const r of parseCommaSeparated(v)) { - if (!/^[\w.-]+\/[\w.-]+$/.test(r)) { - return `Invalid format: "${r}" — expected owner/repo`; - } - } - }, - })); - config.repos = parseCommaSeparated(input as string); - } - - if (targets.includes('orgs')) { - const input = checkCancel(await text({ - message: 'Organizations (comma-separated)', - placeholder: 'my-org, another-org', - validate: v => !v?.trim() ? 'At least one organization is required' : undefined, - })); - config.orgs = parseCommaSeparated(input as string); - } - - if (targets.includes('users')) { - const input = checkCancel(await text({ - message: 'GitHub users (comma-separated)', - placeholder: 'torvalds, DHH', - validate: v => !v?.trim() ? 'At least one user is required' : undefined, - })); - config.users = parseCommaSeparated(input as string); - } - - return { config, env }; -} - -async function collectGitLabConfig(connectionName: string): Promise { - const env: EnvVars = {}; - const config: ConnectionConfig = { type: 'gitlab' }; - - const url = checkCancel(await text({ - message: 'GitLab URL', - initialValue: 'https://gitlab.com', - validate: v => { - if (!v?.trim()) { - return 'URL is required'; - } - if (!/^https?:\/\//.test(v)) { - return 'Must start with http:// or https://'; - } - }, - })) as string; - if (url !== 'https://gitlab.com') { - config.url = url; - } - - const gitlabEnvKey = toEnvKey(connectionName, 'TOKEN'); - const gitlabToken = checkCancel(await password({ - message: `GitLab Personal Access Token (stored as ${gitlabEnvKey}, leave blank for public repos only)`, - })); - if ((gitlabToken as string | undefined)?.trim()) { - env[gitlabEnvKey] = gitlabToken as string; - config.token = { env: gitlabEnvKey }; - } - - const targets = checkCancel(await multiselect({ - message: 'What do you want to index?', - options: [ - { value: 'groups', label: 'Groups', hint: 'all projects in a group' }, - { value: 'projects', label: 'Specific projects', hint: 'e.g. group/project' }, - { value: 'users', label: 'Users', hint: 'all projects owned by a user' }, - ], - required: true, - })) as string[]; - - if (targets.includes('groups')) { - const input = checkCancel(await text({ - message: 'Groups (comma-separated)', - placeholder: 'my-group, another-group', - validate: v => !v?.trim() ? 'At least one group is required' : undefined, - })); - config.groups = parseCommaSeparated(input as string); - } - - if (targets.includes('projects')) { - const input = checkCancel(await text({ - message: 'Projects (comma-separated, group/project)', - placeholder: 'my-group/my-project', - validate: v => !v?.trim() ? 'At least one project is required' : undefined, - })); - config.projects = parseCommaSeparated(input as string); - } - - if (targets.includes('users')) { - const input = checkCancel(await text({ - message: 'Users (comma-separated)', - placeholder: 'john.doe, jane.smith', - validate: v => !v?.trim() ? 'At least one user is required' : undefined, - })); - config.users = parseCommaSeparated(input as string); - } - - return { config, env }; -} - -async function collectBitbucketConfig(connectionName: string): Promise { - const env: EnvVars = {}; - const config: ConnectionConfig = { type: 'bitbucket' }; - - const userEnvKey = toEnvKey(connectionName, 'USERNAME'); - const username = checkCancel(await text({ - message: `Bitbucket username (stored as ${userEnvKey})`, - placeholder: 'your-username', - validate: v => !v?.trim() ? 'Username is required' : undefined, - })); - env[userEnvKey] = username as string; - config.user = { env: userEnvKey }; - - const tokenEnvKey = toEnvKey(connectionName, 'APP_PASSWORD'); - const token = checkCancel(await password({ - message: `Bitbucket App Password (stored as ${tokenEnvKey})`, - validate: v => !v?.trim() ? 'App Password is required' : undefined, - })); - env[tokenEnvKey] = token as string; - config.token = { env: tokenEnvKey }; - - const targets = checkCancel(await multiselect({ - message: 'What do you want to index?', - options: [ - { value: 'workspaces', label: 'Workspaces', hint: 'all repos in a workspace' }, - { value: 'repos', label: 'Specific repositories', hint: 'workspace/repo format' }, - ], - required: true, - })) as string[]; - - if (targets.includes('workspaces')) { - const input = checkCancel(await text({ - message: 'Workspaces (comma-separated)', - placeholder: 'my-workspace', - validate: v => !v?.trim() ? 'At least one workspace is required' : undefined, - })); - config.workspaces = parseCommaSeparated(input as string); - } - - if (targets.includes('repos')) { - const input = checkCancel(await text({ - message: 'Repositories (comma-separated, workspace/repo)', - placeholder: 'my-workspace/my-repo', - validate: v => !v?.trim() ? 'At least one repository is required' : undefined, - })); - config.repos = parseCommaSeparated(input as string); - } - - return { config, env }; -} - -async function collectGiteaConfig(connectionName: string): Promise { - const env: EnvVars = {}; - const config: ConnectionConfig = { type: 'gitea' }; - - const url = checkCancel(await text({ - message: 'Gitea URL', - initialValue: 'https://gitea.com', - validate: v => { - if (!v?.trim()) { - return 'URL is required'; - } - if (!/^https?:\/\//.test(v)) { - return 'Must start with http:// or https://'; - } - }, - })) as string; - if (url !== 'https://gitea.com') { - config.url = url; - } - - const giteaEnvKey = toEnvKey(connectionName, 'TOKEN'); - const giteaToken = checkCancel(await password({ - message: `Gitea Access Token (stored as ${giteaEnvKey}, leave blank for public repos only)`, - })); - if ((giteaToken as string | undefined)?.trim()) { - env[giteaEnvKey] = giteaToken as string; - config.token = { env: giteaEnvKey }; - } - - const targets = checkCancel(await multiselect({ - message: 'What do you want to index?', - options: [ - { value: 'orgs', label: 'Organizations' }, - { value: 'repos', label: 'Specific repositories', hint: 'owner/repo format' }, - { value: 'users', label: 'Users' }, - ], - required: true, - })) as string[]; - - if (targets.includes('orgs')) { - const input = checkCancel(await text({ - message: 'Organizations (comma-separated)', - placeholder: 'my-org', - validate: v => !v?.trim() ? 'At least one organization is required' : undefined, - })); - config.orgs = parseCommaSeparated(input as string); - } - - if (targets.includes('repos')) { - const input = checkCancel(await text({ - message: 'Repositories (comma-separated, owner/repo)', - placeholder: 'owner/repo', - validate: v => !v?.trim() ? 'At least one repository is required' : undefined, - })); - config.repos = parseCommaSeparated(input as string); - } - - if (targets.includes('users')) { - const input = checkCancel(await text({ - message: 'Users (comma-separated)', - placeholder: 'username', - validate: v => !v?.trim() ? 'At least one user is required' : undefined, - })); - config.users = parseCommaSeparated(input as string); - } - - return { config, env }; -} - -async function collectAzureDevOpsConfig(connectionName: string): Promise { - const env: EnvVars = {}; - const config: ConnectionConfig = { type: 'azuredevops' }; - - const envKey = toEnvKey(connectionName, 'TOKEN'); - const token = checkCancel(await password({ - message: `Azure DevOps Personal Access Token (stored as ${envKey})`, - validate: v => !v?.trim() ? 'Token is required' : undefined, - })); - env[envKey] = token as string; - config.token = { env: envKey }; - - const targets = checkCancel(await multiselect({ - message: 'What do you want to index?', - options: [ - { value: 'orgs', label: 'Organizations', hint: 'all projects in an org' }, - { value: 'projects', label: 'Specific projects', hint: 'org/project format' }, - { value: 'repos', label: 'Specific repositories', hint: 'org/project/repo format' }, - ], - required: true, - })) as string[]; - - if (targets.includes('orgs')) { - const input = checkCancel(await text({ - message: 'Organizations (comma-separated)', - placeholder: 'my-org', - validate: v => !v?.trim() ? 'At least one organization is required' : undefined, - })); - config.orgs = parseCommaSeparated(input as string); - } - - if (targets.includes('projects')) { - const input = checkCancel(await text({ - message: 'Projects (comma-separated, org/project)', - placeholder: 'my-org/my-project', - validate: v => !v?.trim() ? 'At least one project is required' : undefined, - })); - config.projects = parseCommaSeparated(input as string); - } - - if (targets.includes('repos')) { - const input = checkCancel(await text({ - message: 'Repositories (comma-separated, org/project/repo)', - placeholder: 'my-org/my-project/my-repo', - validate: v => !v?.trim() ? 'At least one repository is required' : undefined, - })); - config.repos = parseCommaSeparated(input as string); - } - - return { config, env }; -} - -async function collectGerritConfig(): Promise { - const config: ConnectionConfig = { type: 'gerrit' }; - - const url = checkCancel(await text({ - message: 'Gerrit URL', - placeholder: 'https://gerrit.example.com', - validate: v => { - if (!v?.trim()) { - return 'URL is required'; - } - if (!/^https?:\/\//.test(v)) { - return 'Must start with http:// or https://'; - } - }, - })); - config.url = url; - - const indexAll = checkCancel(await confirm({ - message: 'Index all projects?', - initialValue: true, - })); - - if (!indexAll) { - const input = checkCancel(await text({ - message: 'Projects to index (comma-separated)', - placeholder: 'my-project, another-project', - validate: v => !v?.trim() ? 'At least one project is required' : undefined, - })); - config.projects = parseCommaSeparated(input as string); - } - - return { config, env: {} }; -} - type ModelConfig = Record; const PROVIDER_DEFAULT_MODELS: Record = { @@ -448,10 +52,10 @@ async function collectModels(): Promise<{ models: ModelConfig[]; env: EnvVars }> const models: ModelConfig[] = []; const env: EnvVars = {}; - const wantsAI = checkCancel(await confirm({ + const wantsAI = await confirm({ message: 'Would you like to configure AI features?', - initialValue: true, - })); + default: true, + }); if (!wantsAI) { return { models, env }; @@ -459,115 +63,115 @@ async function collectModels(): Promise<{ models: ModelConfig[]; env: EnvVars }> // eslint-disable-next-line no-constant-condition while (true) { - const provider = checkCancel(await select({ + const provider = await select({ message: 'Which AI provider?', - options: [ - { value: 'anthropic', label: 'Anthropic', hint: 'Claude' }, - { value: 'openai', label: 'OpenAI', hint: 'GPT-4o, o1' }, - { value: 'google-generative-ai', label: 'Google Gemini' }, - { value: 'deepseek', label: 'DeepSeek' }, - { value: 'mistral', label: 'Mistral' }, - { value: 'xai', label: 'xAI', hint: 'Grok' }, - { value: 'openrouter', label: 'OpenRouter' }, - { value: 'openai-compatible', label: 'OpenAI-compatible', hint: 'self-hosted / custom endpoint' }, - { value: 'amazon-bedrock', label: 'Amazon Bedrock' }, - { value: 'azure', label: 'Azure OpenAI' }, + choices: [ + { value: 'anthropic', name: 'Anthropic', description: 'Claude' }, + { value: 'openai', name: 'OpenAI', description: 'GPT-4o, o1' }, + { value: 'google-generative-ai', name: 'Google Gemini' }, + { value: 'deepseek', name: 'DeepSeek' }, + { value: 'mistral', name: 'Mistral' }, + { value: 'xai', name: 'xAI', description: 'Grok' }, + { value: 'openrouter', name: 'OpenRouter' }, + { value: 'openai-compatible', name: 'OpenAI-compatible', description: 'self-hosted / custom endpoint' }, + { value: 'amazon-bedrock', name: 'Amazon Bedrock' }, + { value: 'azure', name: 'Azure OpenAI' }, ], - })) as string; + }); const modelConfig: ModelConfig = { provider }; const defaultModel = PROVIDER_DEFAULT_MODELS[provider]; - const model = checkCancel(await text({ + const model = await input({ message: 'Model name', - initialValue: defaultModel ?? '', - placeholder: defaultModel ? undefined : 'model-name', - validate: v => !v?.trim() ? 'Model name is required' : undefined, - })); + default: defaultModel ?? '', + validate: (v) => !v?.trim() ? 'Model name is required' : true, + }); modelConfig.model = model; if (provider === 'openai-compatible') { - const baseUrl = checkCancel(await text({ - message: 'Base URL', - placeholder: 'https://your-endpoint.example.com/v1', - validate: v => { + const baseUrl = await input({ + message: 'Base URL (e.g. https://your-endpoint.example.com/v1)', + validate: (v) => { if (!v?.trim()) { return 'Base URL is required'; } if (!/^https?:\/\//.test(v)) { return 'Must start with http:// or https://'; } + return true; }, - })); + }); modelConfig.baseUrl = baseUrl; } if (provider === 'azure') { - const resourceName = checkCancel(await text({ + const resourceName = await input({ message: 'Azure resource name', - placeholder: 'my-azure-resource', - validate: v => !v?.trim() ? 'Resource name is required' : undefined, - })); + validate: (v) => !v?.trim() ? 'Resource name is required' : true, + }); modelConfig.resourceName = resourceName; - const apiVersion = checkCancel(await text({ + const apiVersion = await input({ message: 'API version', - initialValue: '2024-08-01-preview', - validate: v => !v?.trim() ? 'API version is required' : undefined, - })); + default: '2024-08-01-preview', + validate: (v) => !v?.trim() ? 'API version is required' : true, + }); modelConfig.apiVersion = apiVersion; } if (provider === 'amazon-bedrock') { - const useDefaultChain = checkCancel(await confirm({ + const useDefaultChain = await confirm({ message: 'Use the default AWS credential chain? (No to provide Access Key ID and Secret explicitly)', - initialValue: true, - })); + default: true, + }); if (!useDefaultChain) { if (!env['AWS_ACCESS_KEY_ID']) { - const keyId = checkCancel(await text({ + const keyId = await input({ message: 'AWS Access Key ID (stored as AWS_ACCESS_KEY_ID)', - validate: v => !v?.trim() ? 'Access Key ID is required' : undefined, - })); - env['AWS_ACCESS_KEY_ID'] = keyId as string; + validate: (v) => !v?.trim() ? 'Access Key ID is required' : true, + }); + env['AWS_ACCESS_KEY_ID'] = keyId; } modelConfig.accessKeyId = { env: 'AWS_ACCESS_KEY_ID' }; if (!env['AWS_SECRET_ACCESS_KEY']) { - const secret = checkCancel(await password({ + const secret = await password({ message: 'AWS Secret Access Key (stored as AWS_SECRET_ACCESS_KEY)', - validate: v => !v?.trim() ? 'Secret Access Key is required' : undefined, - })); - env['AWS_SECRET_ACCESS_KEY'] = secret as string; + mask: true, + validate: (v) => !v?.trim() ? 'Secret Access Key is required' : true, + }); + env['AWS_SECRET_ACCESS_KEY'] = secret; } modelConfig.accessKeySecret = { env: 'AWS_SECRET_ACCESS_KEY' }; } - const region = checkCancel(await text({ + const region = await input({ message: 'AWS region', - initialValue: 'us-east-1', - validate: v => !v?.trim() ? 'Region is required' : undefined, - })); + default: 'us-east-1', + validate: (v) => !v?.trim() ? 'Region is required' : true, + }); modelConfig.region = region; } else { const envKey = PROVIDER_ENV_KEYS[provider] ?? `${provider.toUpperCase().replace(/-/g, '_')}_API_KEY`; if (!env[envKey]) { - const apiKey = checkCancel(await password({ + const apiKey = await password({ message: `API key (stored as ${envKey})`, - validate: v => !v?.trim() ? 'API key is required' : undefined, - })); - env[envKey] = apiKey as string; + mask: true, + validate: (v) => !v?.trim() ? 'API key is required' : true, + }); + env[envKey] = apiKey; } modelConfig.token = { env: envKey }; } models.push(modelConfig); - const addAnother = checkCancel(await confirm({ + const addAnother = await confirm({ message: 'Add another model?', - initialValue: false, - })); + default: false, + }); if (!addAnother) { break; @@ -580,31 +184,44 @@ async function collectModels(): Promise<{ models: ModelConfig[]; env: EnvVars }> const PLATFORM_LABELS: Record = { github: 'GitHub', gitlab: 'GitLab', - bitbucket: 'Bitbucket Cloud', + bitbucket: 'Bitbucket', gitea: 'Gitea', azuredevops: 'Azure DevOps', gerrit: 'Gerrit', + local: 'Local Git repositories', + git: 'Other Git host', }; async function main() { - intro('Create Sourcebot Configuration'); + console.log(String.raw` +███████╗ ██████╗ ██╗ ██╗██████╗ ██████╗███████╗██████╗ ██████╗ ████████╗ +██╔════╝██╔═══██╗██║ ██║██╔══██╗██╔════╝██╔════╝██╔══██╗██╔═══██╗╚══██╔══╝ +███████╗██║ ██║██║ ██║██████╔╝██║ █████╗ ██████╔╝██║ ██║ ██║ +╚════██║██║ ██║██║ ██║██╔══██╗██║ ██╔══╝ ██╔══██╗██║ ██║ ██║ +███████║╚██████╔╝╚██████╔╝██║ ██║╚██████╗███████╗██████╔╝╚██████╔╝ ██║██╗ +╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝╚══════╝╚═════╝ ╚═════╝ ╚═╝╚═╝ +`); const connections: Record = {}; const allEnv: EnvVars = {}; + const localRepoHostPaths: string[] = []; // eslint-disable-next-line no-constant-condition while (true) { - const platform = checkCancel(await select({ - message: 'Which platform do you want to connect?', - options: [ - { value: 'github', label: 'GitHub', hint: 'github.com or GitHub Enterprise' }, - { value: 'gitlab', label: 'GitLab', hint: 'gitlab.com or self-hosted' }, - { value: 'bitbucket', label: 'Bitbucket Cloud', hint: 'bitbucket.org' }, - { value: 'gitea', label: 'Gitea', hint: 'self-hosted Gitea' }, - { value: 'azuredevops', label: 'Azure DevOps', hint: 'dev.azure.com' }, - { value: 'gerrit', label: 'Gerrit', hint: 'self-hosted Gerrit' }, + const platform = await select({ + message: 'Which code host do you want to connect?', + loop: false, + choices: [ + { value: 'github', name: 'GitHub', description: 'github.com or GitHub Enterprise' }, + { value: 'gitlab', name: 'GitLab', description: 'gitlab.com or self-hosted' }, + { value: 'local', name: 'Local Git repositories', description: 'A folder of cloned repos on the host filesystem' }, + { value: 'git', name: 'Other Git host', description: 'Any git clone URL (catch-all for unsupported hosts)' }, + { value: 'azuredevops', name: 'Azure DevOps', description: 'dev.azure.com' }, + { value: 'bitbucket', name: 'Bitbucket', description: 'Cloud (bitbucket.org) or self-hosted Data Center' }, + { value: 'gitea', name: 'Gitea', description: 'self-hosted Gitea' }, + { value: 'gerrit', name: 'Gerrit', description: 'self-hosted Gerrit' }, ], - })) as string; + }); const connectionName = generateConnectionName(platform, connections); @@ -631,17 +248,31 @@ async function main() { case 'gerrit': result = await collectGerritConfig(); break; + case 'local': + result = await collectLocalReposConfig(); + break; + case 'git': + result = await collectGenericGitConfig(); + break; default: continue; } - connections[connectionName] = result.config; + for (const { name, config } of result.connections) { + const finalName = name + ? generateConnectionName(name, connections) + : connectionName; + connections[finalName] = config; + } Object.assign(allEnv, result.env); + if (result.localRepoHostPath) { + localRepoHostPaths.push(result.localRepoHostPath); + } - const addAnother = checkCancel(await confirm({ - message: 'Add another connection?', - initialValue: false, - })); + const addAnother = await confirm({ + message: 'Add another code host?', + default: false, + }); if (!addAnother) { break; @@ -652,18 +283,42 @@ async function main() { Object.assign(allEnv, modelEnv); if (existsSync('config.json')) { - const overwrite = checkCancel(await confirm({ + const overwrite = await confirm({ message: 'config.json already exists. Overwrite?', - initialValue: false, - })); + default: true, + }); + if (!overwrite) { + console.log(); + console.log(chalk.red('✗ ') + 'config.json was not overwritten.'); + process.exit(0); + } + } + + if (existsSync('.env')) { + const overwrite = await confirm({ + message: '.env already exists. Overwrite?', + default: true, + }); if (!overwrite) { - cancel('config.json was not overwritten.'); + console.log(); + console.log(chalk.red('✗ ') + '.env was not overwritten.'); process.exit(0); } } - const s = spinner(); - s.start('Writing configuration files...'); + if (localRepoHostPaths.length > 0 && existsSync('docker-compose.override.yml')) { + const overwrite = await confirm({ + message: 'docker-compose.override.yml already exists. Overwrite?', + default: true, + }); + if (!overwrite) { + console.log(); + console.log(chalk.red('✗ ') + 'docker-compose.override.yml was not overwritten.'); + process.exit(0); + } + } + + const s = ora('Writing configuration files...').start(); const configOutput: Record = { $schema: 'https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json', @@ -704,33 +359,47 @@ async function main() { } writeFileSync('config.json', configJson + '\n'); + writeFileSync('.env', envLines.join('\n') + '\n'); + + const writtenFiles = ['config.json', '.env']; + + if (localRepoHostPaths.length > 0) { + const uniquePaths = [...new Set(localRepoHostPaths)]; + const overrideYaml = [ + '# Generated by create-sourcebot', + '# Merged with docker-compose.yml at `docker compose up` time.', + 'services:', + ' sourcebot:', + ' volumes:', + ...uniquePaths.map((p) => ` - ${p}:/repos:ro`), + '', + ].join('\n'); + writeFileSync('docker-compose.override.yml', overrideYaml); + writtenFiles.push('docker-compose.override.yml'); + } - const envPath = existsSync('.env') ? '.env.sourcebot' : '.env'; - writeFileSync(envPath, envLines.join('\n') + '\n'); - - s.stop(`Wrote config.json and ${envPath}`); + s.succeed(`Wrote ${writtenFiles.join(', ')}`); let downloadedCompose = false; if (!existsSync('docker-compose.yml')) { - const download = checkCancel(await confirm({ + const download = await confirm({ message: 'Download docker-compose.yml?', - initialValue: true, - })); + default: true, + }); if (download) { - const ds = spinner(); - ds.start('Downloading docker-compose.yml...'); + const ds = ora('Downloading docker-compose.yml...').start(); try { const res = await fetch(DOCKER_COMPOSE_URL); if (!res.ok) { throw new Error(`HTTP ${res.status}`); } await writeFile('docker-compose.yml', await res.text()); - ds.stop('Downloaded docker-compose.yml'); + ds.succeed('Downloaded docker-compose.yml'); downloadedCompose = true; } catch { - ds.stop('Download failed — you can get it manually (see next steps)'); + ds.fail('Download failed — you can get it manually (see next steps)'); } } } else { @@ -746,12 +415,6 @@ async function main() { nextSteps.push(''); } - if (envPath === '.env.sourcebot') { - nextSteps.push(`${step++}. Rename ${envPath} to .env:`); - nextSteps.push(` mv ${envPath} .env`); - nextSteps.push(''); - } - nextSteps.push(`${step++}. Start Sourcebot:`); nextSteps.push(' docker compose up'); nextSteps.push(''); @@ -759,10 +422,16 @@ async function main() { note(nextSteps.join('\n'), 'Next steps'); - outro('Your Sourcebot configuration is ready!'); + console.log(); + console.log(chalk.green('✓ ') + chalk.bold('Your Sourcebot configuration is ready!')); } main().catch(err => { + if (err instanceof Error && err.name === 'ExitPromptError') { + console.log(); + console.log(chalk.red('✗ ') + 'Setup cancelled.'); + process.exit(0); + } console.error(err); process.exit(1); }); diff --git a/packages/create-sourcebot/src/localRepos.ts b/packages/create-sourcebot/src/localRepos.ts new file mode 100644 index 000000000..1867fd0ed --- /dev/null +++ b/packages/create-sourcebot/src/localRepos.ts @@ -0,0 +1,145 @@ +import { checkbox, input } from '@inquirer/prompts'; +import { existsSync, statSync } from 'fs'; +import { readdir } from 'fs/promises'; +import { homedir } from 'os'; +import { basename, join, relative, resolve } from 'path'; +import ora from 'ora'; +import type { GenericGitHostConnectionConfig } from '@sourcebot/schemas/v3/genericGitHost.type'; +import type { CollectResult } from './utils.js'; +import { note } from './utils.js'; + +const MAX_DEPTH = 5; + +const SKIP_DIRS = new Set([ + 'node_modules', + 'dist', + 'build', + 'out', + 'target', + 'vendor', + 'coverage', + '__pycache__', +]); + +function expandHostPath(p: string): string { + const trimmed = p.trim(); + if (trimmed.startsWith('~')) { + return resolve(join(homedir(), trimmed.slice(1))); + } + return resolve(trimmed); +} + +async function findGitRepos(root: string, maxDepth: number): Promise { + const repos: string[] = []; + + async function walk(dir: string, depth: number): Promise { + if (existsSync(join(dir, '.git'))) { + repos.push(dir); + return; + } + if (depth >= maxDepth) { + return; + } + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return; + } + for (const entry of entries) { + if (!entry.isDirectory()) { + continue; + } + if (entry.name.startsWith('.')) { + continue; + } + if (SKIP_DIRS.has(entry.name)) { + continue; + } + await walk(join(dir, entry.name), depth + 1); + } + } + + await walk(root, 0); + return repos.sort(); +} + +export async function collectLocalReposConfig(): Promise { + note( + [ + 'Point at a directory on your machine that contains git repositories.', + `The wizard will scan up to ${MAX_DEPTH} levels deep and let you pick which to index.`, + 'Local repos are treated as read-only.', + ].join('\n'), + 'Local Git repositories', + ); + + let hostPath: string; + let repos: string[]; + + // eslint-disable-next-line no-constant-condition + while (true) { + const rawPath = await input({ + message: 'Path to your repos directory (e.g. ~/code)', + validate: (v) => { + if (!v?.trim()) { + return 'Path is required'; + } + const resolved = expandHostPath(v); + if (!existsSync(resolved)) { + return `Path does not exist: ${resolved}`; + } + if (!statSync(resolved).isDirectory()) { + return `Not a directory: ${resolved}`; + } + return true; + }, + }); + + hostPath = expandHostPath(rawPath); + + const spinner = ora(`Scanning ${hostPath} for git repositories...`).start(); + repos = await findGitRepos(hostPath, MAX_DEPTH); + if (repos.length === 0) { + spinner.fail(`No git repositories found under ${hostPath}`); + continue; + } + spinner.succeed(`Found ${repos.length} repositor${repos.length === 1 ? 'y' : 'ies'}`); + break; + } + + const choices = repos.map((repoPath) => ({ + name: relative(hostPath, repoPath), + value: repoPath, + checked: true, + })); + + const selected = await checkbox({ + message: 'Which repositories should be indexed?', + choices, + required: true, + pageSize: 15, + loop: false, + }); + + const allSelected = selected.length === repos.length; + const allAtDepthOne = repos.every((p) => !relative(hostPath, p).includes('/')); + + const connections = allSelected && allAtDepthOne + ? [{ + config: { + type: 'git', + url: 'file:///repos/*', + } satisfies GenericGitHostConnectionConfig, + }] + : selected.map((repoPath) => { + const rel = relative(hostPath, repoPath); + const config: GenericGitHostConnectionConfig = { + type: 'git', + url: `file:///repos/${rel}`, + }; + return { name: basename(repoPath), config }; + }); + + return { connections, env: {}, localRepoHostPath: hostPath }; +} diff --git a/packages/create-sourcebot/src/utils.ts b/packages/create-sourcebot/src/utils.ts new file mode 100644 index 000000000..99a553f92 --- /dev/null +++ b/packages/create-sourcebot/src/utils.ts @@ -0,0 +1,83 @@ +import chalk from 'chalk'; +import { randomBytes } from 'crypto'; +import { select as searchSelect } from 'inquirer-select-pro'; +import type { ConnectionConfig } from '@sourcebot/schemas/v3/index.type'; + +export type { ConnectionConfig }; +export type EnvVars = Record; +export type CollectResult = { + /** + * One or more connections produced by the host's collect function. Single-connection + * hosts return a single entry with no `name` (main() uses the platform-derived + * connection name). Multi-connection hosts provide a `name` per entry. + */ + connections: Array<{ name?: string; config: ConnectionConfig }>; + env: EnvVars; + /** + * Optional host path that needs to be mounted into the Sourcebot container. + * Surfaced in the wizard's next-steps so users get the matching volume mount line. + */ + localRepoHostPath?: string; +}; + +export function generateSecret(bytes: number): string { + return randomBytes(bytes).toString('base64'); +} + +export function toEnvKey(connectionName: string, suffix: string): string { + return `${connectionName.toUpperCase().replace(/-/g, '_')}_${suffix}`; +} + +export function generateConnectionName(platform: string, existing: Record): string { + if (!existing[platform]) { + return platform; + } + let i = 1; + while (existing[`${platform}-${i}`]) { + i++; + } + return `${platform}-${i}`; +} + +export async function multiInput(options: { + message: string; + placeholder?: string; + validate?: (value: string) => string | true; +}): Promise { + return searchSelect({ + message: options.message, + multiple: true, + required: true, + loop: false, + clearInputWhenSelected: true, + placeholder: options.placeholder ?? 'Type a value and press space to add, enter to finish', + options: async (search) => { + if (!search) { + return []; + } + return [{ name: search, value: search }]; + }, + validate: options.validate + ? (selected) => { + for (const opt of selected) { + const result = options.validate!(opt.value); + if (result !== true) { + return result; + } + } + return true; + } + : undefined, + }); +} + +export function note(message: string, title?: string): void { + console.log(); + if (title) { + console.log(chalk.cyan('◆ ') + chalk.bold(title)); + } + for (const line of message.split('\n')) { + console.log(chalk.gray('│ ') + line); + } + console.log(); +} diff --git a/yarn.lock b/yarn.lock index af36ada1c..55754d42a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1368,28 +1368,6 @@ __metadata: languageName: node linkType: hard -"@clack/core@npm:1.3.1": - version: 1.3.1 - resolution: "@clack/core@npm:1.3.1" - dependencies: - fast-wrap-ansi: "npm:^0.2.0" - sisteransi: "npm:^1.0.5" - checksum: 10c0/550f6e83574b12c94fcf67621c9e094419186bc2238c89a2f40b9f67c872b3563bb55381beb05ab48cef2d2b3b5d42c33d06bbba2fb28622fafab6afdde20262 - languageName: node - linkType: hard - -"@clack/prompts@npm:^1.4.0": - version: 1.4.0 - resolution: "@clack/prompts@npm:1.4.0" - dependencies: - "@clack/core": "npm:1.3.1" - fast-string-width: "npm:^3.0.2" - fast-wrap-ansi: "npm:^0.2.0" - sisteransi: "npm:^1.0.5" - checksum: 10c0/851ea8ec1597b70ff2e4b3e2ed4e340f5f10877f86b0372a29d96d6c6131f2a5a46f75a98dcb77378d3ec88ffe1d276724f7385be534be06f8a4ba36a550bc01 - languageName: node - linkType: hard - "@codemirror/autocomplete@npm:^6.0.0, @codemirror/autocomplete@npm:^6.16.2, @codemirror/autocomplete@npm:^6.3.2, @codemirror/autocomplete@npm:^6.7.1": version: 6.18.6 resolution: "@codemirror/autocomplete@npm:6.18.6" @@ -2977,6 +2955,284 @@ __metadata: languageName: node linkType: hard +"@inquirer/ansi@npm:^2.0.5": + version: 2.0.5 + resolution: "@inquirer/ansi@npm:2.0.5" + checksum: 10c0/ad61532e5bb47473e3d987c32d4015499a8ce5f4f86e46467e8e672fc52670beb303905d6b324e453935a61671f59f3b9b1b6a1edbbe1f64085e2bb87735e295 + languageName: node + linkType: hard + +"@inquirer/checkbox@npm:^5.1.5": + version: 5.1.5 + resolution: "@inquirer/checkbox@npm:5.1.5" + dependencies: + "@inquirer/ansi": "npm:^2.0.5" + "@inquirer/core": "npm:^11.1.10" + "@inquirer/figures": "npm:^2.0.5" + "@inquirer/type": "npm:^4.0.5" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/6cf3bfbc0e39b80b8a37b69e49231c22616877b31b77507a55be5363d14b33e91cd3c1eb4ebf0ba89435ab5ef8204ce634579a23ab7b186ee8c71d54a7c959ee + languageName: node + linkType: hard + +"@inquirer/confirm@npm:^6.0.13": + version: 6.0.13 + resolution: "@inquirer/confirm@npm:6.0.13" + dependencies: + "@inquirer/core": "npm:^11.1.10" + "@inquirer/type": "npm:^4.0.5" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/59f3c484f405b3ffe2e97a9e4927111d71d317ea84fa14dc0ffd2d7c902f934ad31f48b380765937f5ff85722ece475edcde8a64b8018c21f2ee3e33082cee8b + languageName: node + linkType: hard + +"@inquirer/core@npm:^11.1.10": + version: 11.1.10 + resolution: "@inquirer/core@npm:11.1.10" + dependencies: + "@inquirer/ansi": "npm:^2.0.5" + "@inquirer/figures": "npm:^2.0.5" + "@inquirer/type": "npm:^4.0.5" + cli-width: "npm:^4.1.0" + fast-wrap-ansi: "npm:^0.2.0" + mute-stream: "npm:^3.0.0" + signal-exit: "npm:^4.1.0" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/d1d4081cbb0bd3dc15a3c95c58560a4836079a14161c407bc20f9a1b0fdb93c2228daf61aa60acab7023bc78df1b85875fde94308dcac2c6bd0ffd24b5f05190 + languageName: node + linkType: hard + +"@inquirer/core@npm:^8.1.0": + version: 8.2.4 + resolution: "@inquirer/core@npm:8.2.4" + dependencies: + "@inquirer/figures": "npm:^1.0.3" + "@inquirer/type": "npm:^1.3.3" + "@types/mute-stream": "npm:^0.0.4" + "@types/node": "npm:^20.14.9" + "@types/wrap-ansi": "npm:^3.0.0" + ansi-escapes: "npm:^4.3.2" + cli-spinners: "npm:^2.9.2" + cli-width: "npm:^4.1.0" + mute-stream: "npm:^1.0.0" + picocolors: "npm:^1.0.1" + signal-exit: "npm:^4.1.0" + strip-ansi: "npm:^6.0.1" + wrap-ansi: "npm:^6.2.0" + checksum: 10c0/3328ea52823a59cad4bf6c36b143c7322a2e1430ae040717e63c94680f246c0d628aed3032a2f6890652dd4b7fdb0fec7e324059b74173ffa78ae2a485939f33 + languageName: node + linkType: hard + +"@inquirer/editor@npm:^5.1.2": + version: 5.1.2 + resolution: "@inquirer/editor@npm:5.1.2" + dependencies: + "@inquirer/core": "npm:^11.1.10" + "@inquirer/external-editor": "npm:^3.0.0" + "@inquirer/type": "npm:^4.0.5" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/5b24700b8d4339d4b9b5fe5991d4c35843c1977595bfc0ab242e95a2bd8c2463c9039050f3b7821e8f49786926c5b68ba4cb20d6d887292fc0b12ccb03bd3ca2 + languageName: node + linkType: hard + +"@inquirer/expand@npm:^5.0.14": + version: 5.0.14 + resolution: "@inquirer/expand@npm:5.0.14" + dependencies: + "@inquirer/core": "npm:^11.1.10" + "@inquirer/type": "npm:^4.0.5" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/efdc9a93d57397f415529ed2b969de6fa78c2ab98901a89efd73f1cfc79eba3ea899c621a63a458c530ca4142c89fd61cfeb873384e906f9cc33c022a5a3f5be + languageName: node + linkType: hard + +"@inquirer/external-editor@npm:^3.0.0": + version: 3.0.0 + resolution: "@inquirer/external-editor@npm:3.0.0" + dependencies: + chardet: "npm:^2.1.1" + iconv-lite: "npm:^0.7.2" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/120910c954869c73c54aee825abef6f4c4aa8620cafa831d56b218586b1ee02c12ad0a17b8d701784fb9d33fa8fd63ee155d9c718c90533ff4d8086b99986e1d + languageName: node + linkType: hard + +"@inquirer/figures@npm:^1.0.1, @inquirer/figures@npm:^1.0.3": + version: 1.0.15 + resolution: "@inquirer/figures@npm:1.0.15" + checksum: 10c0/6e39a040d260ae234ae220180b7994ff852673e20be925f8aa95e78c7934d732b018cbb4d0ec39e600a410461bcb93dca771e7de23caa10630d255692e440f69 + languageName: node + linkType: hard + +"@inquirer/figures@npm:^2.0.5": + version: 2.0.5 + resolution: "@inquirer/figures@npm:2.0.5" + checksum: 10c0/139671b88f33f059aec85ed3fdf464999115573350c6dea61141adc1cfd43d14742b6cb68150c2ca9baf5a1bae618f990ed89b4430ae768d415bbd19944c56df + languageName: node + linkType: hard + +"@inquirer/input@npm:^5.0.13": + version: 5.0.13 + resolution: "@inquirer/input@npm:5.0.13" + dependencies: + "@inquirer/core": "npm:^11.1.10" + "@inquirer/type": "npm:^4.0.5" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/df2d67a6f0a1b4cc22dfdc1e78f833560f613647bd75374d4664601b7947106dd977aceb8440a19a17204b58c11611e6084096eae3eb1873260f1f1d029a8a0a + languageName: node + linkType: hard + +"@inquirer/number@npm:^4.0.13": + version: 4.0.13 + resolution: "@inquirer/number@npm:4.0.13" + dependencies: + "@inquirer/core": "npm:^11.1.10" + "@inquirer/type": "npm:^4.0.5" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/9185d15c8b0ab820dc0456e7db6f189ae84bf8d5f5bce19398f3a858fca0276e66a53922a4ab118dbae65f1f4f6a29f06f3d34c6436ae9e095a98e9862590e2b + languageName: node + linkType: hard + +"@inquirer/password@npm:^5.0.13": + version: 5.0.13 + resolution: "@inquirer/password@npm:5.0.13" + dependencies: + "@inquirer/ansi": "npm:^2.0.5" + "@inquirer/core": "npm:^11.1.10" + "@inquirer/type": "npm:^4.0.5" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/e06aa6ae4344e3e37630655c443001bcf4421b1e4821c15987fc747b8d0362d536a52a115d25d02b023bb7091fc88630a65d7b0ac02e15a2f70d933f6a863da3 + languageName: node + linkType: hard + +"@inquirer/prompts@npm:^8.4.3": + version: 8.4.3 + resolution: "@inquirer/prompts@npm:8.4.3" + dependencies: + "@inquirer/checkbox": "npm:^5.1.5" + "@inquirer/confirm": "npm:^6.0.13" + "@inquirer/editor": "npm:^5.1.2" + "@inquirer/expand": "npm:^5.0.14" + "@inquirer/input": "npm:^5.0.13" + "@inquirer/number": "npm:^4.0.13" + "@inquirer/password": "npm:^5.0.13" + "@inquirer/rawlist": "npm:^5.2.9" + "@inquirer/search": "npm:^4.1.9" + "@inquirer/select": "npm:^5.1.5" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/fd77efb0b12a9293d6533cf332a1af10f69fefa97202c3790c2a7695a06c443ed5d4877d05efe512803e4a98cce0fa46fed9d372149512c2eccbfcf23f1c44bd + languageName: node + linkType: hard + +"@inquirer/rawlist@npm:^5.2.9": + version: 5.2.9 + resolution: "@inquirer/rawlist@npm:5.2.9" + dependencies: + "@inquirer/core": "npm:^11.1.10" + "@inquirer/type": "npm:^4.0.5" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/10f1a23e5222a932d9965490beb8b37551b2c68dcb281290d8f0099dc0778b49d4ca671ad8b346c802cbff29924faffd01b62dc88f297020e3d1061eb00b7f78 + languageName: node + linkType: hard + +"@inquirer/search@npm:^4.1.9": + version: 4.1.9 + resolution: "@inquirer/search@npm:4.1.9" + dependencies: + "@inquirer/core": "npm:^11.1.10" + "@inquirer/figures": "npm:^2.0.5" + "@inquirer/type": "npm:^4.0.5" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/0e0cd6f2f312cecfa02f7d5556a7ccf5cbffff442fbdd0828f320e0a0239b63d24db5e31e05ec77d2a969900ad40b2d115f6496b2c9c47561a15dc504298d9dc + languageName: node + linkType: hard + +"@inquirer/select@npm:^5.1.5": + version: 5.1.5 + resolution: "@inquirer/select@npm:5.1.5" + dependencies: + "@inquirer/ansi": "npm:^2.0.5" + "@inquirer/core": "npm:^11.1.10" + "@inquirer/figures": "npm:^2.0.5" + "@inquirer/type": "npm:^4.0.5" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/871e05266c00151031798dc659acaed3d9c76d0040a25204cbcc99f7104559db1a8bc2a73ee04318a01f06f879a3d7e5986db50f563aeca23ae4cd5e8cff6057 + languageName: node + linkType: hard + +"@inquirer/type@npm:^1.3.1, @inquirer/type@npm:^1.3.3": + version: 1.5.5 + resolution: "@inquirer/type@npm:1.5.5" + dependencies: + mute-stream: "npm:^1.0.0" + checksum: 10c0/4c41736c09ba9426b5a9e44993bdd54e8f532e791518802e33866f233a2a6126a25c1c82c19d1abbf1df627e57b1b957dd3f8318ea96073d8bfc32193943bcb3 + languageName: node + linkType: hard + +"@inquirer/type@npm:^4.0.5": + version: 4.0.5 + resolution: "@inquirer/type@npm:4.0.5" + peerDependencies: + "@types/node": ">=18" + peerDependenciesMeta: + "@types/node": + optional: true + checksum: 10c0/390edb0fd1f027f9c8dc26bac28486d38bbde6c19974ef1588ea187f54a2cb58db639ebca31fa81a8fe4a4e84c2f0953ab3f5a6768ba86649368c5e806148a6f + languageName: node + linkType: hard + "@ioredis/commands@npm:^1.1.1": version: 1.2.0 resolution: "@ioredis/commands@npm:1.2.0" @@ -9035,7 +9291,7 @@ __metadata: languageName: unknown linkType: soft -"@sourcebot/schemas@workspace:*, @sourcebot/schemas@workspace:packages/schemas": +"@sourcebot/schemas@workspace:*, @sourcebot/schemas@workspace:^, @sourcebot/schemas@workspace:packages/schemas": version: 0.0.0-use.local resolution: "@sourcebot/schemas@workspace:packages/schemas" dependencies: @@ -9778,6 +10034,15 @@ __metadata: languageName: node linkType: hard +"@types/mute-stream@npm:^0.0.4": + version: 0.0.4 + resolution: "@types/mute-stream@npm:0.0.4" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/944730fd7b398c5078de3c3d4d0afeec8584283bc694da1803fdfca14149ea385e18b1b774326f1601baf53898ce6d121a952c51eb62d188ef6fcc41f725c0dc + languageName: node + linkType: hard + "@types/mysql@npm:2.15.27": version: 2.15.27 resolution: "@types/mysql@npm:2.15.27" @@ -9833,6 +10098,15 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:^20.14.9": + version: 20.19.41 + resolution: "@types/node@npm:20.19.41" + dependencies: + undici-types: "npm:~6.21.0" + checksum: 10c0/aa2a07317bbd700bea68d5784b403a738dbcebadbe2d8ef05649f7953065120d5d37f7edfdd7881df3a3bd15328c8a4dc46fdd69732ab540d552c505378c585b + languageName: node + linkType: hard + "@types/node@npm:^20.17.9": version: 20.19.37 resolution: "@types/node@npm:20.19.37" @@ -10016,6 +10290,13 @@ __metadata: languageName: node linkType: hard +"@types/wrap-ansi@npm:^3.0.0": + version: 3.0.0 + resolution: "@types/wrap-ansi@npm:3.0.0" + checksum: 10c0/8d8f53363f360f38135301a06b596c295433ad01debd082078c33c6ed98b05a5c8fe8853a88265432126096084f4a135ec1564e3daad631b83296905509f90b3 + languageName: node + linkType: hard + "@typescript-eslint/eslint-plugin@npm:8.56.1": version: 8.56.1 resolution: "@typescript-eslint/eslint-plugin@npm:8.56.1" @@ -10802,6 +11083,24 @@ __metadata: languageName: node linkType: hard +"ansi-escapes@npm:^4.3.2": + version: 4.3.2 + resolution: "ansi-escapes@npm:4.3.2" + dependencies: + type-fest: "npm:^0.21.3" + checksum: 10c0/da917be01871525a3dfcf925ae2977bc59e8c513d4423368645634bf5d4ceba5401574eb705c1e92b79f7292af5a656f78c5725a4b0e1cec97c4b413705c1d50 + languageName: node + linkType: hard + +"ansi-escapes@npm:^7.0.0": + version: 7.3.0 + resolution: "ansi-escapes@npm:7.3.0" + dependencies: + environment: "npm:^1.0.0" + checksum: 10c0/068961d99f0ef28b661a4a9f84a5d645df93ccf3b9b93816cc7d46bbe1913321d4cdf156bb842a4e1e4583b7375c631fa963efb43001c4eb7ff9ab8f78fc0679 + languageName: node + linkType: hard + "ansi-regex@npm:^5.0.1": version: 5.0.1 resolution: "ansi-regex@npm:5.0.1" @@ -10816,6 +11115,13 @@ __metadata: languageName: node linkType: hard +"ansi-regex@npm:^6.2.2": + version: 6.2.2 + resolution: "ansi-regex@npm:6.2.2" + checksum: 10c0/05d4acb1d2f59ab2cf4b794339c7b168890d44dda4bf0ce01152a8da0213aca207802f930442ce8cd22d7a92f44907664aac6508904e75e038fa944d2601b30f + languageName: node + linkType: hard + "ansi-styles@npm:^3.2.1": version: 3.2.1 resolution: "ansi-styles@npm:3.2.1" @@ -11494,7 +11800,7 @@ __metadata: languageName: node linkType: hard -"chalk@npm:^5.3.0": +"chalk@npm:^5.3.0, chalk@npm:^5.6.2": version: 5.6.2 resolution: "chalk@npm:5.6.2" checksum: 10c0/99a4b0f0e7991796b1e7e3f52dceb9137cae2a9dfc8fc0784a550dc4c558e15ab32ed70b14b21b52beb2679b4892b41a0aa44249bcb996f01e125d58477c6976 @@ -11529,6 +11835,13 @@ __metadata: languageName: node linkType: hard +"chardet@npm:^2.1.1": + version: 2.1.1 + resolution: "chardet@npm:2.1.1" + checksum: 10c0/d8391dd412338442b3de0d3a488aa9327f8bcf74b62b8723d6bd0b85c4084d50b731320e0a7c710edb1d44de75969995d2784b80e4c13b004a6c7a0db4c6e793 + languageName: node + linkType: hard + "chokidar@npm:^3.5.2, chokidar@npm:^3.6.0": version: 3.6.0 resolution: "chokidar@npm:3.6.0" @@ -11619,6 +11932,20 @@ __metadata: languageName: node linkType: hard +"cli-spinners@npm:^3.2.0": + version: 3.4.0 + resolution: "cli-spinners@npm:3.4.0" + checksum: 10c0/91296c32e147d5b973c9d439d1512306499215437b92f0c0d8be44ec850b555acb8795c19c606b2f6747f31d50c4e41fdde7dcef653f18f0ae7cdd58e99a4764 + languageName: node + linkType: hard + +"cli-width@npm:^4.1.0": + version: 4.1.0 + resolution: "cli-width@npm:4.1.0" + checksum: 10c0/1fbd56413578f6117abcaf858903ba1f4ad78370a4032f916745fa2c7e390183a9d9029cf837df320b0fdce8137668e522f60a30a5f3d6529ff3872d265a955f + languageName: node + linkType: hard + "client-only@npm:0.0.1, client-only@npm:^0.0.1": version: 0.0.1 resolution: "client-only@npm:0.0.1" @@ -12150,8 +12477,12 @@ __metadata: version: 0.0.0-use.local resolution: "create-sourcebot@workspace:packages/create-sourcebot" dependencies: - "@clack/prompts": "npm:^1.4.0" + "@inquirer/prompts": "npm:^8.4.3" + "@sourcebot/schemas": "workspace:^" "@types/node": "npm:^22.7.5" + chalk: "npm:^5.6.2" + inquirer-select-pro: "npm:^1.0.0-alpha.9" + ora: "npm:^9.4.0" tsx: "npm:^4.21.0" typescript: "npm:^5.6.2" bin: @@ -13053,6 +13384,13 @@ __metadata: languageName: node linkType: hard +"environment@npm:^1.0.0": + version: 1.1.0 + resolution: "environment@npm:1.1.0" + checksum: 10c0/fb26434b0b581ab397039e51ff3c92b34924a98b2039dcb47e41b7bca577b9dbf134a8eadb364415c74464b682e2d3afe1a4c0eb9873dc44ea814c5d3103331d + languageName: node + linkType: hard + "err-code@npm:^2.0.2": version: 2.0.3 resolution: "err-code@npm:2.0.3" @@ -14440,6 +14778,13 @@ __metadata: languageName: node linkType: hard +"get-east-asian-width@npm:^1.5.0": + version: 1.6.0 + resolution: "get-east-asian-width@npm:1.6.0" + checksum: 10c0/7e72e9550fd49ca5b246f9af6bb2afc129c96412845ff6556b3274fd44817a381702ca17028efe9866b261a3d44254cbf21e6c90cf05b4b61675630af776d431 + languageName: node + linkType: hard + "get-intrinsic@npm:^1.2.4, get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.2.6, get-intrinsic@npm:^1.2.7, get-intrinsic@npm:^1.3.0": version: 1.3.0 resolution: "get-intrinsic@npm:1.3.0" @@ -15146,7 +15491,7 @@ __metadata: languageName: node linkType: hard -"iconv-lite@npm:^0.7.0, iconv-lite@npm:~0.7.0": +"iconv-lite@npm:^0.7.0, iconv-lite@npm:^0.7.2, iconv-lite@npm:~0.7.0": version: 0.7.2 resolution: "iconv-lite@npm:0.7.2" dependencies: @@ -15255,6 +15600,19 @@ __metadata: languageName: node linkType: hard +"inquirer-select-pro@npm:^1.0.0-alpha.9": + version: 1.0.0-alpha.9 + resolution: "inquirer-select-pro@npm:1.0.0-alpha.9" + dependencies: + "@inquirer/core": "npm:^8.1.0" + "@inquirer/figures": "npm:^1.0.1" + "@inquirer/type": "npm:^1.3.1" + ansi-escapes: "npm:^7.0.0" + chalk: "npm:^5.3.0" + checksum: 10c0/5362b6260f067d9a2309c669f6b89660eec7e61150a84ba640bb757e397e58fe856099ce9b4813fa6d96201dc2d716dc9846e9fdd68af3f4cfe0a79e569d8805 + languageName: node + linkType: hard + "internal-slot@npm:^1.1.0": version: 1.1.0 resolution: "internal-slot@npm:1.1.0" @@ -15668,7 +16026,7 @@ __metadata: languageName: node linkType: hard -"is-unicode-supported@npm:^2.0.0": +"is-unicode-supported@npm:^2.0.0, is-unicode-supported@npm:^2.1.0": version: 2.1.0 resolution: "is-unicode-supported@npm:2.1.0" checksum: 10c0/a0f53e9a7c1fdbcf2d2ef6e40d4736fdffff1c9f8944c75e15425118ff3610172c87bf7bc6c34d3903b04be59790bb2212ddbe21ee65b5a97030fc50370545a5 @@ -16433,7 +16791,7 @@ __metadata: languageName: node linkType: hard -"log-symbols@npm:^7.0.0": +"log-symbols@npm:^7.0.0, log-symbols@npm:^7.0.1": version: 7.0.1 resolution: "log-symbols@npm:7.0.1" dependencies: @@ -17531,6 +17889,20 @@ __metadata: languageName: node linkType: hard +"mute-stream@npm:^1.0.0": + version: 1.0.0 + resolution: "mute-stream@npm:1.0.0" + checksum: 10c0/dce2a9ccda171ec979a3b4f869a102b1343dee35e920146776780de182f16eae459644d187e38d59a3d37adf85685e1c17c38cf7bfda7e39a9880f7a1d10a74c + languageName: node + linkType: hard + +"mute-stream@npm:^3.0.0": + version: 3.0.0 + resolution: "mute-stream@npm:3.0.0" + checksum: 10c0/12cdb36a101694c7a6b296632e6d93a30b74401873cf7507c88861441a090c71c77a58f213acadad03bc0c8fa186639dec99d68a14497773a8744320c136e701 + languageName: node + linkType: hard + "mz@npm:^2.7.0": version: 2.7.0 resolution: "mz@npm:2.7.0" @@ -18189,6 +18561,22 @@ __metadata: languageName: node linkType: hard +"ora@npm:^9.4.0": + version: 9.4.0 + resolution: "ora@npm:9.4.0" + dependencies: + chalk: "npm:^5.6.2" + cli-cursor: "npm:^5.0.0" + cli-spinners: "npm:^3.2.0" + is-interactive: "npm:^2.0.0" + is-unicode-supported: "npm:^2.1.0" + log-symbols: "npm:^7.0.1" + stdin-discarder: "npm:^0.3.2" + string-width: "npm:^8.1.0" + checksum: 10c0/8f2d7a8869cd68607797ac0ffe9f5cdceeb6009437672510d9920aea794cdd055164e3fe804248624c4940a71b22f94f1ffd94ce8fecf0746baef97a5c121a91 + languageName: node + linkType: hard + "own-keys@npm:^1.0.1": version: 1.0.1 resolution: "own-keys@npm:1.0.1" @@ -18565,7 +18953,7 @@ __metadata: languageName: node linkType: hard -"picocolors@npm:1.1.1, picocolors@npm:^1.0.0, picocolors@npm:^1.1.1": +"picocolors@npm:1.1.1, picocolors@npm:^1.0.0, picocolors@npm:^1.0.1, picocolors@npm:^1.1.1": version: 1.1.1 resolution: "picocolors@npm:1.1.1" checksum: 10c0/e2e3e8170ab9d7c7421969adaa7e1b31434f789afb9b3f115f6b96d91945041ac3ceb02e9ec6fe6510ff036bcc0bf91e69a1772edc0b707e12b19c0f2d6bcf58 @@ -21086,6 +21474,13 @@ __metadata: languageName: node linkType: hard +"stdin-discarder@npm:^0.3.2": + version: 0.3.2 + resolution: "stdin-discarder@npm:0.3.2" + checksum: 10c0/5dbaba9efbcb447a4450d5ae19794641ea9166abe96dc4b5547a109db1bb6e8bdb17bbe1029e02ca8d9d8ee996b7c7cbcce12b12c18c121871cd4f574292381a + languageName: node + linkType: hard + "steno@npm:^4.0.2": version: 4.0.2 resolution: "steno@npm:4.0.2" @@ -21168,6 +21563,16 @@ __metadata: languageName: node linkType: hard +"string-width@npm:^8.1.0": + version: 8.2.1 + resolution: "string-width@npm:8.2.1" + dependencies: + get-east-asian-width: "npm:^1.5.0" + strip-ansi: "npm:^7.1.2" + checksum: 10c0/d467b4eaf4c40a01bb438a2620e77badd2456ffd5131c9973abe4f3acf7c802d5b21f3b6a00a5e33a7fc28ca8f9c103226e01bac61e9f259659c6f46d78e353a + languageName: node + linkType: hard + "string.prototype.includes@npm:^2.0.1": version: 2.0.1 resolution: "string.prototype.includes@npm:2.0.1" @@ -21306,6 +21711,15 @@ __metadata: languageName: node linkType: hard +"strip-ansi@npm:^7.1.2": + version: 7.2.0 + resolution: "strip-ansi@npm:7.2.0" + dependencies: + ansi-regex: "npm:^6.2.2" + checksum: 10c0/544d13b7582f8254811ea97db202f519e189e59d35740c46095897e254e4f1aa9fe1524a83ad6bc5ad67d4dd6c0281d2e0219ed62b880a6238a16a17d375f221 + languageName: node + linkType: hard + "strip-bom@npm:^3.0.0": version: 3.0.0 resolution: "strip-bom@npm:3.0.0" @@ -21961,6 +22375,13 @@ __metadata: languageName: node linkType: hard +"type-fest@npm:^0.21.3": + version: 0.21.3 + resolution: "type-fest@npm:0.21.3" + checksum: 10c0/902bd57bfa30d51d4779b641c2bc403cdf1371fb9c91d3c058b0133694fcfdb817aef07a47f40faf79039eecbaa39ee9d3c532deff244f3a19ce68cea71a61e8 + languageName: node + linkType: hard + "type-fest@npm:^0.7.1": version: 0.7.1 resolution: "type-fest@npm:0.7.1" @@ -22908,6 +23329,17 @@ __metadata: languageName: node linkType: hard +"wrap-ansi@npm:^6.2.0": + version: 6.2.0 + resolution: "wrap-ansi@npm:6.2.0" + dependencies: + ansi-styles: "npm:^4.0.0" + string-width: "npm:^4.1.0" + strip-ansi: "npm:^6.0.0" + checksum: 10c0/baad244e6e33335ea24e86e51868fe6823626e3a3c88d9a6674642afff1d34d9a154c917e74af8d845fd25d170c4ea9cf69a47133c3f3656e1252b3d462d9f6c + languageName: node + linkType: hard + "wrap-ansi@npm:^8.1.0": version: 8.1.0 resolution: "wrap-ansi@npm:8.1.0" From 9882bb4b21deab1c37bbf5506f52c986e8be0b96 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 19 May 2026 20:21:42 -0700 Subject: [PATCH 05/10] rename --- packages/create-sourcebot/poc.mjs | 65 ------------------- .../package.json | 2 +- .../src/azuredevops.ts | 0 .../src/bitbucket.ts | 0 .../src/genericGit.ts | 0 .../src/gerrit.ts | 0 .../src/gitea.ts | 0 .../src/github.ts | 2 +- .../src/gitlab.ts | 2 +- .../src/index.ts | 4 +- .../src/localRepos.ts | 0 .../src/utils.ts | 0 .../tsconfig.json | 0 yarn.lock | 34 +++++----- 14 files changed, 22 insertions(+), 87 deletions(-) delete mode 100644 packages/create-sourcebot/poc.mjs rename packages/{create-sourcebot => setupWizard}/package.json (95%) rename packages/{create-sourcebot => setupWizard}/src/azuredevops.ts (100%) rename packages/{create-sourcebot => setupWizard}/src/bitbucket.ts (100%) rename packages/{create-sourcebot => setupWizard}/src/genericGit.ts (100%) rename packages/{create-sourcebot => setupWizard}/src/gerrit.ts (100%) rename packages/{create-sourcebot => setupWizard}/src/gitea.ts (100%) rename packages/{create-sourcebot => setupWizard}/src/github.ts (99%) rename packages/{create-sourcebot => setupWizard}/src/gitlab.ts (99%) rename packages/{create-sourcebot => setupWizard}/src/index.ts (99%) rename packages/{create-sourcebot => setupWizard}/src/localRepos.ts (100%) rename packages/{create-sourcebot => setupWizard}/src/utils.ts (100%) rename packages/{create-sourcebot => setupWizard}/tsconfig.json (100%) diff --git a/packages/create-sourcebot/poc.mjs b/packages/create-sourcebot/poc.mjs deleted file mode 100644 index 05f21165b..000000000 --- a/packages/create-sourcebot/poc.mjs +++ /dev/null @@ -1,65 +0,0 @@ -import { password } from '@inquirer/prompts'; -import { select, Separator } from 'inquirer-select-pro'; -import { appendFileSync } from 'fs'; - -function dbg(msg) { - appendFileSync('/tmp/poc-debug.log', msg + '\n'); -} - -const cache = new Map(); -let abortController = null; - -async function searchGitHubOrgs(query, token, signal) { - if (cache.has(query)) { - dbg(`cache hit: "${query}"`); - return cache.get(query); - } - dbg(`searching: "${query}"`); - const headers = { - 'User-Agent': 'create-sourcebot', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }; - const url = `https://api.github.com/search/users?q=${encodeURIComponent(query)}+type:org&per_page=8`; - const res = await fetch(url, { headers, signal }); - dbg(`status: ${res.status}`); - const data = await res.json(); - dbg(`response: ${JSON.stringify(data).slice(0, 300)}`); - - if (!res.ok) { - const warning = - (res.status === 403 && res.headers.get('x-ratelimit-remaining') === '0') ? - '⚠ Autocomplete disabled - GitHub rate limit exceeded.' : - '⚠ Autocomplete disalbed - Authentication failed, check your PAT.'; - return [ - { name: query, value: query }, - new Separator(warning), - ]; - } - - const results = data.items.map(item => ({ name: item.login, value: item.login })); - if (results.length === 0) { - return [{ name: query, value: query }]; - } - cache.set(query, results); - return results; -} - -const token = await password({ - message: 'GitHub PAT (leave blank for public search)', - mask: true, -}); - -const orgs = await select({ - message: 'Search for GitHub organizations to index', - multiple: true, - loop: false, - clearInputWhenSelected: true, - options: async (input) => { - if (!input || input.length < 2) { - return []; - } - return searchGitHubOrgs(input, token); - }, -}); - -console.log(`\nSelected: ${orgs.join(', ')}`); diff --git a/packages/create-sourcebot/package.json b/packages/setupWizard/package.json similarity index 95% rename from packages/create-sourcebot/package.json rename to packages/setupWizard/package.json index 392aaeb42..8a2a38118 100644 --- a/packages/create-sourcebot/package.json +++ b/packages/setupWizard/package.json @@ -1,5 +1,5 @@ { - "name": "create-sourcebot", + "name": "setup-sourcebot", "version": "0.1.0", "description": "CLI wizard for creating a Sourcebot configuration", "type": "module", diff --git a/packages/create-sourcebot/src/azuredevops.ts b/packages/setupWizard/src/azuredevops.ts similarity index 100% rename from packages/create-sourcebot/src/azuredevops.ts rename to packages/setupWizard/src/azuredevops.ts diff --git a/packages/create-sourcebot/src/bitbucket.ts b/packages/setupWizard/src/bitbucket.ts similarity index 100% rename from packages/create-sourcebot/src/bitbucket.ts rename to packages/setupWizard/src/bitbucket.ts diff --git a/packages/create-sourcebot/src/genericGit.ts b/packages/setupWizard/src/genericGit.ts similarity index 100% rename from packages/create-sourcebot/src/genericGit.ts rename to packages/setupWizard/src/genericGit.ts diff --git a/packages/create-sourcebot/src/gerrit.ts b/packages/setupWizard/src/gerrit.ts similarity index 100% rename from packages/create-sourcebot/src/gerrit.ts rename to packages/setupWizard/src/gerrit.ts diff --git a/packages/create-sourcebot/src/gitea.ts b/packages/setupWizard/src/gitea.ts similarity index 100% rename from packages/create-sourcebot/src/gitea.ts rename to packages/setupWizard/src/gitea.ts diff --git a/packages/create-sourcebot/src/github.ts b/packages/setupWizard/src/github.ts similarity index 99% rename from packages/create-sourcebot/src/github.ts rename to packages/setupWizard/src/github.ts index 39f22e4f7..73527c865 100644 --- a/packages/create-sourcebot/src/github.ts +++ b/packages/setupWizard/src/github.ts @@ -34,7 +34,7 @@ async function searchGitHub( } const headers: Record = { - 'User-Agent': 'create-sourcebot', + 'User-Agent': 'setup-sourcebot', ...(token ? { Authorization: `Bearer ${token}` } : {}), }; const url = type === 'repo' diff --git a/packages/create-sourcebot/src/gitlab.ts b/packages/setupWizard/src/gitlab.ts similarity index 99% rename from packages/create-sourcebot/src/gitlab.ts rename to packages/setupWizard/src/gitlab.ts index 109181e48..f50e5b5cd 100644 --- a/packages/create-sourcebot/src/gitlab.ts +++ b/packages/setupWizard/src/gitlab.ts @@ -31,7 +31,7 @@ async function searchGitLab( } const headers: Record = { - 'User-Agent': 'create-sourcebot', + 'User-Agent': 'setup-sourcebot', ...(token ? { Authorization: `Bearer ${token}` } : {}), }; diff --git a/packages/create-sourcebot/src/index.ts b/packages/setupWizard/src/index.ts similarity index 99% rename from packages/create-sourcebot/src/index.ts rename to packages/setupWizard/src/index.ts index acb8a0852..84427b8a0 100644 --- a/packages/create-sourcebot/src/index.ts +++ b/packages/setupWizard/src/index.ts @@ -337,7 +337,7 @@ async function main() { ); const envLines: string[] = [ - '# Generated by create-sourcebot', + '# Generated by setup-sourcebot', '', '# Auto-generated secrets — do not change after first run', `AUTH_SECRET=${generateSecret(33)}`, @@ -366,7 +366,7 @@ async function main() { if (localRepoHostPaths.length > 0) { const uniquePaths = [...new Set(localRepoHostPaths)]; const overrideYaml = [ - '# Generated by create-sourcebot', + '# Generated by setup-sourcebot', '# Merged with docker-compose.yml at `docker compose up` time.', 'services:', ' sourcebot:', diff --git a/packages/create-sourcebot/src/localRepos.ts b/packages/setupWizard/src/localRepos.ts similarity index 100% rename from packages/create-sourcebot/src/localRepos.ts rename to packages/setupWizard/src/localRepos.ts diff --git a/packages/create-sourcebot/src/utils.ts b/packages/setupWizard/src/utils.ts similarity index 100% rename from packages/create-sourcebot/src/utils.ts rename to packages/setupWizard/src/utils.ts diff --git a/packages/create-sourcebot/tsconfig.json b/packages/setupWizard/tsconfig.json similarity index 100% rename from packages/create-sourcebot/tsconfig.json rename to packages/setupWizard/tsconfig.json diff --git a/yarn.lock b/yarn.lock index 55754d42a..e4545b495 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12473,23 +12473,6 @@ __metadata: languageName: node linkType: hard -"create-sourcebot@workspace:packages/create-sourcebot": - version: 0.0.0-use.local - resolution: "create-sourcebot@workspace:packages/create-sourcebot" - dependencies: - "@inquirer/prompts": "npm:^8.4.3" - "@sourcebot/schemas": "workspace:^" - "@types/node": "npm:^22.7.5" - chalk: "npm:^5.6.2" - inquirer-select-pro: "npm:^1.0.0-alpha.9" - ora: "npm:^9.4.0" - tsx: "npm:^4.21.0" - typescript: "npm:^5.6.2" - bin: - create-sourcebot: ./dist/index.js - languageName: unknown - linkType: soft - "crelt@npm:^1.0.5": version: 1.0.6 resolution: "crelt@npm:1.0.6" @@ -20874,6 +20857,23 @@ __metadata: languageName: node linkType: hard +"setup-sourcebot@workspace:packages/setupWizard": + version: 0.0.0-use.local + resolution: "setup-sourcebot@workspace:packages/setupWizard" + dependencies: + "@inquirer/prompts": "npm:^8.4.3" + "@sourcebot/schemas": "workspace:^" + "@types/node": "npm:^22.7.5" + chalk: "npm:^5.6.2" + inquirer-select-pro: "npm:^1.0.0-alpha.9" + ora: "npm:^9.4.0" + tsx: "npm:^4.21.0" + typescript: "npm:^5.6.2" + bin: + setup-sourcebot: ./dist/index.js + languageName: unknown + linkType: soft + "sharp@npm:^0.33.5": version: 0.33.5 resolution: "sharp@npm:0.33.5" From e9401a7d44b189a4d33cb7f4390d1399e489f633 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 19 May 2026 20:38:39 -0700 Subject: [PATCH 06/10] azure devops --- packages/setupWizard/src/azuredevops.ts | 73 ++++++++++++++++++++----- packages/setupWizard/tsconfig.json | 1 + 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/packages/setupWizard/src/azuredevops.ts b/packages/setupWizard/src/azuredevops.ts index e9a3c49f2..085b77d67 100644 --- a/packages/setupWizard/src/azuredevops.ts +++ b/packages/setupWizard/src/azuredevops.ts @@ -1,11 +1,60 @@ -import { checkbox, password } from '@inquirer/prompts'; +import { checkbox, confirm, input, password, select } from '@inquirer/prompts'; import type { AzureDevOpsConnectionConfig } from '@sourcebot/schemas/v3/azuredevops.type'; import type { CollectResult, EnvVars } from './utils.js'; -import { multiInput, toEnvKey } from './utils.js'; +import { multiInput, note, toEnvKey } from './utils.js'; export async function collectAzureDevOpsConfig(connectionName: string): Promise { const env: EnvVars = {}; + const deploymentType = await select<'cloud' | 'server'>({ + message: 'Which Azure DevOps deployment?', + choices: [ + { value: 'cloud', name: 'Azure DevOps Cloud', description: 'dev.azure.com' }, + { value: 'server', name: 'Azure DevOps Server', description: 'self-hosted' }, + ], + }); + + const config: AzureDevOpsConnectionConfig = { + type: 'azuredevops', + deploymentType, + token: { env: '' }, + }; + + if (deploymentType === 'server') { + const url = await input({ + message: 'Azure DevOps Server URL (e.g. https://ado.example.com)', + validate: (v) => { + if (!v?.trim()) { + return 'URL is required'; + } + if (!/^https?:\/\//.test(v)) { + return 'Must start with http:// or https://'; + } + return true; + }, + }); + config.url = url; + + const useTfsPath = await confirm({ + message: 'Use legacy TFS path format (/tfs in API URLs)?', + default: false, + }); + if (useTfsPath) { + config.useTfsPath = true; + } + } + + note( + [ + 'Create a Personal Access Token at:', + deploymentType === 'cloud' + ? ' https://dev.azure.com//_usersSettings/tokens' + : ' /_usersSettings/tokens', + 'Grant `Code (Read)` scope so Sourcebot can find and clone your repos.', + ].join('\n'), + 'Azure DevOps Personal Access Token', + ); + const envKey = toEnvKey(connectionName, 'TOKEN'); const token = await password({ message: `Azure DevOps Personal Access Token (stored as ${envKey})`, @@ -13,38 +62,36 @@ export async function collectAzureDevOpsConfig(connectionName: string): Promise< validate: (v) => !v?.trim() ? 'Token is required' : true, }); env[envKey] = token; + config.token = { env: envKey }; - const config: AzureDevOpsConnectionConfig = { - type: 'azuredevops', - deploymentType: 'cloud', - token: { env: envKey }, - }; + const orgLabel = deploymentType === 'cloud' ? 'organization' : 'collection'; + const orgLabelPlural = deploymentType === 'cloud' ? 'Organizations' : 'Collections'; const targets = await checkbox({ message: 'What do you want to index?', choices: [ - { value: 'orgs', name: 'Organizations', description: 'all projects in an org' }, - { value: 'projects', name: 'Specific projects', description: 'org/project format' }, - { value: 'repos', name: 'Specific repositories', description: 'org/project/repo format' }, + { value: 'orgs', name: orgLabelPlural, description: `all projects in a ${orgLabel}` }, + { value: 'projects', name: 'Specific projects', description: `${orgLabel}/project format` }, + { value: 'repos', name: 'Specific repositories', description: `${orgLabel}/project/repo format` }, ], required: true, }); if (targets.includes('orgs')) { config.orgs = await multiInput({ - message: 'Organizations to index', + message: `${orgLabelPlural} to index`, }); } if (targets.includes('projects')) { config.projects = await multiInput({ - message: 'Projects to index (org/project)', + message: `Projects to index (${orgLabel}/project)`, }); } if (targets.includes('repos')) { config.repos = await multiInput({ - message: 'Repositories to index (org/project/repo)', + message: `Repositories to index (${orgLabel}/project/repo)`, }); } diff --git a/packages/setupWizard/tsconfig.json b/packages/setupWizard/tsconfig.json index 018aa345b..c3c56b0e2 100644 --- a/packages/setupWizard/tsconfig.json +++ b/packages/setupWizard/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "outDir": "dist", + "rootDir": "src", "declaration": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, From 404d5ea8d3d703a64f311bf3d62b32be772ed278 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 19 May 2026 21:40:02 -0700 Subject: [PATCH 07/10] polish models UX --- packages/setupWizard/src/index.ts | 159 +----------- packages/setupWizard/src/models.ts | 375 +++++++++++++++++++++++++++++ 2 files changed, 377 insertions(+), 157 deletions(-) create mode 100644 packages/setupWizard/src/models.ts diff --git a/packages/setupWizard/src/index.ts b/packages/setupWizard/src/index.ts index 84427b8a0..fab29d240 100644 --- a/packages/setupWizard/src/index.ts +++ b/packages/setupWizard/src/index.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { confirm, input, password, select } from '@inquirer/prompts'; +import { confirm, select } from '@inquirer/prompts'; import chalk from 'chalk'; import ora from 'ora'; import { existsSync, writeFileSync } from 'fs'; @@ -12,6 +12,7 @@ import { collectGiteaConfig } from './gitea.js'; import { collectGitHubConfig } from './github.js'; import { collectGitLabConfig } from './gitlab.js'; import { collectLocalReposConfig } from './localRepos.js'; +import { collectModels, PROVIDER_ENV_KEYS } from './models.js'; import { type CollectResult, type ConnectionConfig, @@ -25,162 +26,6 @@ import { const DOCKER_COMPOSE_BRANCH = 'bkellam/setup-wizard'; const DOCKER_COMPOSE_URL = `https://raw.githubusercontent.com/sourcebot-dev/sourcebot/${DOCKER_COMPOSE_BRANCH}/docker-compose.yml`; -type ModelConfig = Record; - -const PROVIDER_DEFAULT_MODELS: Record = { - 'anthropic': 'claude-sonnet-4-6', - 'openai': 'gpt-4o', - 'google-generative-ai': 'gemini-2.0-flash', - 'deepseek': 'deepseek-chat', - 'mistral': 'mistral-large-latest', - 'xai': 'grok-2-latest', -}; - -const PROVIDER_ENV_KEYS: Record = { - 'anthropic': 'ANTHROPIC_API_KEY', - 'openai': 'OPENAI_API_KEY', - 'google-generative-ai': 'GOOGLE_GENERATIVE_AI_API_KEY', - 'deepseek': 'DEEPSEEK_API_KEY', - 'mistral': 'MISTRAL_API_KEY', - 'xai': 'XAI_API_KEY', - 'openrouter': 'OPENROUTER_API_KEY', - 'openai-compatible': 'OPENAI_COMPATIBLE_API_KEY', - 'azure': 'AZURE_OPENAI_API_KEY', -}; - -async function collectModels(): Promise<{ models: ModelConfig[]; env: EnvVars }> { - const models: ModelConfig[] = []; - const env: EnvVars = {}; - - const wantsAI = await confirm({ - message: 'Would you like to configure AI features?', - default: true, - }); - - if (!wantsAI) { - return { models, env }; - } - - // eslint-disable-next-line no-constant-condition - while (true) { - const provider = await select({ - message: 'Which AI provider?', - choices: [ - { value: 'anthropic', name: 'Anthropic', description: 'Claude' }, - { value: 'openai', name: 'OpenAI', description: 'GPT-4o, o1' }, - { value: 'google-generative-ai', name: 'Google Gemini' }, - { value: 'deepseek', name: 'DeepSeek' }, - { value: 'mistral', name: 'Mistral' }, - { value: 'xai', name: 'xAI', description: 'Grok' }, - { value: 'openrouter', name: 'OpenRouter' }, - { value: 'openai-compatible', name: 'OpenAI-compatible', description: 'self-hosted / custom endpoint' }, - { value: 'amazon-bedrock', name: 'Amazon Bedrock' }, - { value: 'azure', name: 'Azure OpenAI' }, - ], - }); - - const modelConfig: ModelConfig = { provider }; - - const defaultModel = PROVIDER_DEFAULT_MODELS[provider]; - const model = await input({ - message: 'Model name', - default: defaultModel ?? '', - validate: (v) => !v?.trim() ? 'Model name is required' : true, - }); - modelConfig.model = model; - - if (provider === 'openai-compatible') { - const baseUrl = await input({ - message: 'Base URL (e.g. https://your-endpoint.example.com/v1)', - validate: (v) => { - if (!v?.trim()) { - return 'Base URL is required'; - } - if (!/^https?:\/\//.test(v)) { - return 'Must start with http:// or https://'; - } - return true; - }, - }); - modelConfig.baseUrl = baseUrl; - } - - if (provider === 'azure') { - const resourceName = await input({ - message: 'Azure resource name', - validate: (v) => !v?.trim() ? 'Resource name is required' : true, - }); - modelConfig.resourceName = resourceName; - - const apiVersion = await input({ - message: 'API version', - default: '2024-08-01-preview', - validate: (v) => !v?.trim() ? 'API version is required' : true, - }); - modelConfig.apiVersion = apiVersion; - } - - if (provider === 'amazon-bedrock') { - const useDefaultChain = await confirm({ - message: 'Use the default AWS credential chain? (No to provide Access Key ID and Secret explicitly)', - default: true, - }); - - if (!useDefaultChain) { - if (!env['AWS_ACCESS_KEY_ID']) { - const keyId = await input({ - message: 'AWS Access Key ID (stored as AWS_ACCESS_KEY_ID)', - validate: (v) => !v?.trim() ? 'Access Key ID is required' : true, - }); - env['AWS_ACCESS_KEY_ID'] = keyId; - } - modelConfig.accessKeyId = { env: 'AWS_ACCESS_KEY_ID' }; - - if (!env['AWS_SECRET_ACCESS_KEY']) { - const secret = await password({ - message: 'AWS Secret Access Key (stored as AWS_SECRET_ACCESS_KEY)', - mask: true, - validate: (v) => !v?.trim() ? 'Secret Access Key is required' : true, - }); - env['AWS_SECRET_ACCESS_KEY'] = secret; - } - modelConfig.accessKeySecret = { env: 'AWS_SECRET_ACCESS_KEY' }; - } - - const region = await input({ - message: 'AWS region', - default: 'us-east-1', - validate: (v) => !v?.trim() ? 'Region is required' : true, - }); - modelConfig.region = region; - } else { - const envKey = PROVIDER_ENV_KEYS[provider] ?? `${provider.toUpperCase().replace(/-/g, '_')}_API_KEY`; - if (!env[envKey]) { - const apiKey = await password({ - message: `API key (stored as ${envKey})`, - mask: true, - validate: (v) => !v?.trim() ? 'API key is required' : true, - }); - env[envKey] = apiKey; - } - modelConfig.token = { env: envKey }; - } - - models.push(modelConfig); - - const addAnother = await confirm({ - message: 'Add another model?', - default: false, - }); - - if (!addAnother) { - break; - } - } - - return { models, env }; -} - const PLATFORM_LABELS: Record = { github: 'GitHub', gitlab: 'GitLab', diff --git a/packages/setupWizard/src/models.ts b/packages/setupWizard/src/models.ts new file mode 100644 index 000000000..c7b0d4862 --- /dev/null +++ b/packages/setupWizard/src/models.ts @@ -0,0 +1,375 @@ +import { confirm, input, password, select } from '@inquirer/prompts'; +import { select as searchSelect } from 'inquirer-select-pro'; +import type { + AmazonBedrockLanguageModel, + AzureLanguageModel, + GoogleVertexAnthropicLanguageModel, + GoogleVertexLanguageModel, + LanguageModel, + OpenAICompatibleLanguageModel, +} from '@sourcebot/schemas/v3/languageModel.type'; +import { note, type EnvVars } from './utils.js'; + +type Provider = LanguageModel['provider']; + +export const PROVIDER_ENV_KEYS: Record = { + 'anthropic': 'ANTHROPIC_API_KEY', + 'openai': 'OPENAI_API_KEY', + 'google-generative-ai': 'GOOGLE_GENERATIVE_AI_API_KEY', + 'deepseek': 'DEEPSEEK_API_KEY', + 'mistral': 'MISTRAL_API_KEY', + 'xai': 'XAI_API_KEY', + 'openrouter': 'OPENROUTER_API_KEY', + 'openai-compatible': 'OPENAI_COMPATIBLE_API_KEY', + 'azure': 'AZURE_OPENAI_API_KEY', +}; + +// ─── models.dev catalog ──────────────────────────────────────────────────── + +type ModelsDevModel = { + id: string; + name?: string; + release_date?: string; +}; + +type ModelsDevProvider = { + id: string; + name?: string; + models?: Record; +}; + +type ModelsDevCatalog = Record; + +type ModelOption = { + id: string; + name: string; + releaseDate?: string; +}; + +const MODELS_DEV_API_URL = 'https://models.dev/api.json'; +const FETCH_TIMEOUT_MS = 8000; + +const PROVIDER_ID_OVERRIDES: Record = { + 'google-generative-ai': 'google', +}; + +let catalogPromise: Promise | null = null; + +async function loadCatalog(): Promise { + if (!catalogPromise) { + catalogPromise = (async () => { + try { + const response = await fetch(MODELS_DEV_API_URL, { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + }); + if (!response.ok) { + return null; + } + return await response.json() as ModelsDevCatalog; + } catch { + return null; + } + })(); + } + return catalogPromise; +} + +async function getModelOptionsForProvider(providerKey: string): Promise { + const catalog = await loadCatalog(); + if (!catalog) { + return null; + } + const providerId = PROVIDER_ID_OVERRIDES[providerKey] ?? providerKey; + const provider = catalog[providerId]; + if (!provider || !provider.models) { + return null; + } + const models = Object.values(provider.models); + if (models.length === 0) { + return null; + } + return models + .map((m) => ({ + id: m.id, + name: m.name || m.id, + releaseDate: m.release_date, + })) + .sort((a, b) => { + if (a.releaseDate && b.releaseDate) { + return b.releaseDate.localeCompare(a.releaseDate); + } + if (a.releaseDate) { + return -1; + } + if (b.releaseDate) { + return 1; + } + return a.name.localeCompare(b.name); + }); +} + +// ─── prompts ─────────────────────────────────────────────────────────────── + +async function searchModel(options: { + message: string; + models: ModelOption[]; +}): Promise { + const choices = options.models.map((m) => ({ + name: m.name === m.id ? m.id : `${m.id} · ${m.name}`, + value: m.id, + })); + + const result = await searchSelect({ + message: options.message, + multiple: false, + loop: false, + clearInputWhenSelected: false, + placeholder: 'Type to search models, or enter a custom name', + options: async (search) => { + const trimmed = (search ?? '').trim(); + if (!trimmed) { + return choices; + } + const lowered = trimmed.toLowerCase(); + const filtered = choices.filter((c) => + c.value.toLowerCase().includes(lowered) || c.name.toLowerCase().includes(lowered), + ); + const hasExact = choices.some((c) => c.value === trimmed); + if (!hasExact) { + filtered.unshift({ name: `${trimmed} (custom)`, value: trimmed }); + } + return filtered; + }, + }); + if (result === null) { + throw new Error('Model name is required'); + } + return result; +} + +async function ensureApiKey(provider: Provider, env: EnvVars): Promise { + const envKey = PROVIDER_ENV_KEYS[provider] ?? `${provider.toUpperCase().replace(/-/g, '_')}_API_KEY`; + if (!env[envKey]) { + const apiKey = await password({ + message: `API key (stored as ${envKey})`, + mask: true, + validate: (v) => !v?.trim() ? 'API key is required' : true, + }); + env[envKey] = apiKey; + } + return envKey; +} + +async function collectModelConfig( + provider: Provider, + model: string, + env: EnvVars, +): Promise { + switch (provider) { + case 'anthropic': + case 'openai': + case 'google-generative-ai': + case 'deepseek': + case 'mistral': + case 'xai': + case 'openrouter': { + const envKey = await ensureApiKey(provider, env); + return { provider, model, token: { env: envKey } } satisfies LanguageModel; + } + case 'openai-compatible': { + const baseUrl = await input({ + message: 'Base URL (e.g. https://your-endpoint.example.com/v1)', + validate: (v) => { + if (!v?.trim()) { + return 'Base URL is required'; + } + if (!/^https?:\/\//.test(v)) { + return 'Must start with http:// or https://'; + } + return true; + }, + }); + const envKey = await ensureApiKey(provider, env); + const config: OpenAICompatibleLanguageModel = { + provider, + model, + baseUrl, + token: { env: envKey }, + }; + return config; + } + case 'azure': { + const resourceName = await input({ + message: 'Azure resource name', + validate: (v) => !v?.trim() ? 'Resource name is required' : true, + }); + const apiVersion = await input({ + message: 'API version', + default: '2024-08-01-preview', + validate: (v) => !v?.trim() ? 'API version is required' : true, + }); + const envKey = await ensureApiKey(provider, env); + const config: AzureLanguageModel = { + provider, + model, + resourceName, + apiVersion, + token: { env: envKey }, + }; + return config; + } + case 'amazon-bedrock': { + const useDefaultChain = await confirm({ + message: 'Use the default AWS credential chain? (No to provide Access Key ID and Secret explicitly)', + default: true, + }); + + const config: AmazonBedrockLanguageModel = { provider, model }; + + if (!useDefaultChain) { + if (!env['AWS_ACCESS_KEY_ID']) { + env['AWS_ACCESS_KEY_ID'] = await input({ + message: 'AWS Access Key ID (stored as AWS_ACCESS_KEY_ID)', + validate: (v) => !v?.trim() ? 'Access Key ID is required' : true, + }); + } + config.accessKeyId = { env: 'AWS_ACCESS_KEY_ID' }; + + if (!env['AWS_SECRET_ACCESS_KEY']) { + env['AWS_SECRET_ACCESS_KEY'] = await password({ + message: 'AWS Secret Access Key (stored as AWS_SECRET_ACCESS_KEY)', + mask: true, + validate: (v) => !v?.trim() ? 'Secret Access Key is required' : true, + }); + } + config.accessKeySecret = { env: 'AWS_SECRET_ACCESS_KEY' }; + } + + config.region = await input({ + message: 'AWS region', + default: 'us-east-1', + validate: (v) => !v?.trim() ? 'Region is required' : true, + }); + return config; + } + case 'google-vertex': + case 'google-vertex-anthropic': { + if (!env['GOOGLE_VERTEX_PROJECT']) { + env['GOOGLE_VERTEX_PROJECT'] = await input({ + message: 'Google Cloud project ID (stored as GOOGLE_VERTEX_PROJECT)', + validate: (v) => !v?.trim() ? 'Project ID is required' : true, + }); + } + if (!env['GOOGLE_VERTEX_REGION']) { + env['GOOGLE_VERTEX_REGION'] = await input({ + message: 'Google Cloud region (stored as GOOGLE_VERTEX_REGION)', + default: 'us-central1', + validate: (v) => !v?.trim() ? 'Region is required' : true, + }); + } + + const useAppDefault = await confirm({ + message: 'Use Application Default Credentials? (No to provide a service account credentials file path)', + default: true, + }); + + const config: GoogleVertexLanguageModel | GoogleVertexAnthropicLanguageModel = { + provider, + model, + }; + + if (!useAppDefault) { + if (!env['GOOGLE_APPLICATION_CREDENTIALS']) { + env['GOOGLE_APPLICATION_CREDENTIALS'] = await input({ + message: 'Path to service account credentials JSON (stored as GOOGLE_APPLICATION_CREDENTIALS)', + validate: (v) => !v?.trim() ? 'Credentials path is required' : true, + }); + } + config.credentials = { env: 'GOOGLE_APPLICATION_CREDENTIALS' }; + } + return config; + } + } +} + +export async function collectModels(): Promise<{ models: LanguageModel[]; env: EnvVars }> { + const models: LanguageModel[] = []; + const env: EnvVars = {}; + + note( + [ + 'AI features include Ask, which lets you ask questions about your codebase', + 'in natural language and get answers grounded in your indexed code.', + ' https://docs.sourcebot.dev/docs/features/ask/overview', + '', + 'You\'ll need an API key from at least one supported provider', + '(Anthropic, OpenAI, Google, etc.) to enable these features.', + ].join('\n'), + 'AI features', + ); + + const wantsAI = await confirm({ + message: 'Would you like to configure AI features?', + default: true, + }); + + if (!wantsAI) { + return { models, env }; + } + + // eslint-disable-next-line no-constant-condition + while (true) { + const provider = await select({ + message: 'Which AI provider?', + loop: false, + choices: [ + { value: 'anthropic', name: 'Anthropic' }, + { value: 'openai', name: 'OpenAI' }, + { value: 'openai-compatible', name: 'OpenAI-compatible', description: 'self-hosted / custom endpoint' }, + { value: 'amazon-bedrock', name: 'Amazon Bedrock' }, + { value: 'google-generative-ai', name: 'Google Gemini' }, + { value: 'google-vertex', name: 'Google Vertex AI', description: 'Gemini via Vertex' }, + { value: 'google-vertex-anthropic', name: 'Google Vertex AI (Anthropic)', description: 'Claude via Vertex' }, + { value: 'azure', name: 'Azure OpenAI' }, + { value: 'deepseek', name: 'DeepSeek' }, + { value: 'mistral', name: 'Mistral' }, + { value: 'openrouter', name: 'OpenRouter' }, + { value: 'xai', name: 'xAI', description: 'Grok' }, + ], + }); + + const modelOptions = provider === 'openai-compatible' + ? null + : await getModelOptionsForProvider(provider); + const model = modelOptions && modelOptions.length > 0 + ? await searchModel({ + message: 'Model name', + models: modelOptions, + }) + : await input({ + message: 'Model name', + validate: (v) => !v?.trim() ? 'Model name is required' : true, + }); + + const config = await collectModelConfig(provider, model, env); + + const displayName = (await input({ + message: 'Display name (optional, press enter to skip)', + })).trim(); + if (displayName) { + config.displayName = displayName; + } + models.push(config); + + const addAnother = await confirm({ + message: 'Add another model?', + default: false, + }); + + if (!addAnother) { + break; + } + } + + return { models, env }; +} From db27b5efc344462b3640b98733a418f68865e870 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Thu, 21 May 2026 14:13:10 -0700 Subject: [PATCH 08/10] additional polish --- docs/docs/deployment/docker-compose.mdx | 109 +++++++++++++----------- docs/images/setup_sourcebot_splash.png | Bin 0 -> 79666 bytes packages/setupWizard/README.md | 25 ++++++ packages/setupWizard/package.json | 12 +-- packages/setupWizard/src/index.ts | 77 ++++++++++++++--- 5 files changed, 156 insertions(+), 67 deletions(-) create mode 100644 docs/images/setup_sourcebot_splash.png create mode 100644 packages/setupWizard/README.md diff --git a/docs/docs/deployment/docker-compose.mdx b/docs/docs/deployment/docker-compose.mdx index 15055a8cc..8362bbeda 100644 --- a/docs/docs/deployment/docker-compose.mdx +++ b/docs/docs/deployment/docker-compose.mdx @@ -2,59 +2,68 @@ title: "Docker Compose" --- -This guide will walk you through deploying Sourcebot locally or on a VM using Docker Compose. We will use the [docker-compose.yml](https://github.com/sourcebot-dev/sourcebot/blob/main/docker-compose.yml) file from the [Sourcebot repository](https://github.com/sourcebot-dev/sourcebot). This is the simplest way to get started with Sourcebot. +This guide will walk you through deploying Sourcebot locally or on a VM using [Docker Compose](https://github.com/sourcebot-dev/sourcebot/blob/main/docker-compose.yml). This is the simplest way to get started with Sourcebot. If you are looking to deploy onto Kubernetes, see the [Kubernetes (Helm)](/docs/deployment/k8s) guide. -## Get started - - - - - docker & docker compose. Use [Docker Desktop](https://www.docker.com/products/docker-desktop/) on Mac or Windows. - - - Download the [docker-compose.yml](https://github.com/sourcebot-dev/sourcebot/blob/main/docker-compose.yml) file from the Sourcebot repository. - - ```bash wrap icon="terminal" - curl -o docker-compose.yml https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/docker-compose.yml - ``` - - - - - In the same directory as the `docker-compose.yml` file, create a [configuration file](/docs/configuration/config-file). The configuration file is a JSON file that configures Sourcebot's behaviour, including what repositories to index, language model providers, auth providers, and more. - - ```bash wrap icon="terminal" Create example config - touch config.json - echo '{ - "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json", - // Comments are supported. - // This config creates a single connection to GitHub.com that - // indexes the Sourcebot repository - "connections": { - "starter-connection": { - "type": "github", - "repos": [ - "sourcebot-dev/sourcebot" - ] - } - } - }' > config.json - ``` - - - - Update the secrets in the `docker-compose.yml` and then run Sourcebot using: - - ```bash wrap icon="terminal" - docker compose up - ``` - - - - You're all set! Navigate to [http://localhost:3000](http://localhost:3000) to access your Sourcebot instance. - - +## System requirements +- RAM: Ensure your environment has at least 4GB of RAM. Insufficient memory can cause processes to crash. +- Docker & Docker Compose: Make sure both are installed and up-to-date. +- Node.js 18+: Required for the setup CLI + +## Option 1: Setup CLI + +The setup CLI will guide you through configuring your Sourcebot instance to connect to your code hosts and LLM providers. From a empty folder, run the following command: + +``` +npx setup-sourcebot +``` + + + npx setup-sourcebot + + +## Option 2: Manual steps + +### Obtain the Docker Compose file + +Download the [docker-compose.yml](https://github.com/sourcebot-dev/sourcebot/blob/main/docker-compose.yml) file from the Sourcebot repository. + +```bash wrap icon="terminal" +curl -o docker-compose.yml https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/docker-compose.yml +``` + +### Create a config.json + +In the same directory as the `docker-compose.yml` file, create a [configuration file](/docs/configuration/config-file). The configuration file is a JSON file that configures Sourcebot's behaviour, including what repositories to index, language model providers, auth providers, and more. + +```bash wrap icon="terminal" Create example config +touch config.json +echo '{ + "$schema": "https://raw.githubusercontent.com/sourcebot-dev/sourcebot/main/schemas/v3/index.json", + // Comments are supported. + // This config creates a single connection to GitHub.com that + // indexes the Sourcebot repository + "connections": { + "starter-connection": { + "type": "github", + "repos": [ + "sourcebot-dev/sourcebot" + ] + } + } +}' > config.json +``` + +### Launch your instance + +Update the secrets in the `docker-compose.yml` and then run Sourcebot using: + +```bash wrap icon="terminal" +docker compose up +``` + +Navigate to [http://localhost:3000](http://localhost:3000) to access your Sourcebot instance. ## Next steps diff --git a/docs/images/setup_sourcebot_splash.png b/docs/images/setup_sourcebot_splash.png new file mode 100644 index 0000000000000000000000000000000000000000..fadd9cc5b76aaca19a76adeaba623f47a23ff012 GIT binary patch literal 79666 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
  • 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 literal 0 HcmV?d00001 diff --git a/packages/setupWizard/README.md b/packages/setupWizard/README.md new file mode 100644 index 000000000..ddd2be912 --- /dev/null +++ b/packages/setupWizard/README.md @@ -0,0 +1,25 @@ +# setup-sourcebot + +Interactive CLI wizard for setting up a self-hosted [Sourcebot](https://sourcebot.dev) instance. + +## Usage + +Run from an empty directory: + +```bash +npx setup-sourcebot +``` + +The wizard walks you through: + +- **Code hosts** — GitHub, GitLab, Bitbucket (Cloud or Data Center), Azure DevOps (Cloud or Server), Gitea, Gerrit, a local folder of cloned repos, or any other git URL. +- **AI providers** (optional) — Anthropic, OpenAI, Google Gemini, Google Vertex, DeepSeek, Mistral, xAI, OpenRouter, OpenAI-compatible endpoints, Amazon Bedrock, or Azure OpenAI. Powers [Ask](https://docs.sourcebot.dev/docs/features/ask/overview). + +## Requirements + +- Node.js 18+ +- Docker and Docker Compose + +## Docs + +Full deployment guide: [docs.sourcebot.dev/docs/deployment/docker-compose](https://docs.sourcebot.dev/docs/deployment/docker-compose) diff --git a/packages/setupWizard/package.json b/packages/setupWizard/package.json index 8a2a38118..0209ae3ba 100644 --- a/packages/setupWizard/package.json +++ b/packages/setupWizard/package.json @@ -1,22 +1,23 @@ { "name": "setup-sourcebot", - "version": "0.1.0", - "description": "CLI wizard for creating a Sourcebot configuration", + "version": "0.1.1", + "description": "CLI wizard for creating a Sourcebot configuration.", "type": "module", "bin": "./dist/index.js", "scripts": { "build": "tsc", "watch": "tsc --watch", - "dev": "tsx src/index.ts" + "dev": "tsx src/index.ts", + "prepublishOnly": "yarn build" }, "dependencies": { "@inquirer/prompts": "^8.4.3", - "@sourcebot/schemas": "workspace:^", "chalk": "^5.6.2", "inquirer-select-pro": "^1.0.0-alpha.9", "ora": "^9.4.0" }, "devDependencies": { + "@sourcebot/schemas": "workspace:^", "@types/node": "^22.7.5", "tsx": "^4.21.0", "typescript": "^5.6.2" @@ -25,6 +26,7 @@ "node": ">=18" }, "files": [ - "dist" + "dist", + "README.md" ] } diff --git a/packages/setupWizard/src/index.ts b/packages/setupWizard/src/index.ts index fab29d240..5178476e6 100644 --- a/packages/setupWizard/src/index.ts +++ b/packages/setupWizard/src/index.ts @@ -2,6 +2,7 @@ import { confirm, select } from '@inquirer/prompts'; import chalk from 'chalk'; import ora from 'ora'; +import { spawn } from 'node:child_process'; import { existsSync, writeFileSync } from 'fs'; import { writeFile } from 'fs/promises'; import { collectAzureDevOpsConfig } from './azuredevops.js'; @@ -26,6 +27,32 @@ import { const DOCKER_COMPOSE_BRANCH = 'bkellam/setup-wizard'; const DOCKER_COMPOSE_URL = `https://raw.githubusercontent.com/sourcebot-dev/sourcebot/${DOCKER_COMPOSE_BRANCH}/docker-compose.yml`; +const SOURCEBOT_URL = 'http://localhost:3000'; + +function openBrowser(url: string): void { + const cmd = process.platform === 'darwin' ? 'open' + : process.platform === 'win32' ? 'cmd' + : 'xdg-open'; + const args = process.platform === 'win32' ? ['/c', 'start', '""', url] : [url]; + spawn(cmd, args, { stdio: 'ignore', detached: true }).unref(); +} + +async function openBrowserWhenReady(url: string, timeoutMs = 120_000): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const res = await fetch(url, { signal: AbortSignal.timeout(2000) }); + if (res.status < 500) { + openBrowser(url); + return; + } + } catch { + // not yet ready + } + await new Promise((r) => setTimeout(r, 2000)); + } +} + const PLATFORM_LABELS: Record = { github: 'GitHub', gitlab: 'GitLab', @@ -57,14 +84,14 @@ async function main() { message: 'Which code host do you want to connect?', loop: false, choices: [ - { value: 'github', name: 'GitHub', description: 'github.com or GitHub Enterprise' }, - { value: 'gitlab', name: 'GitLab', description: 'gitlab.com or self-hosted' }, - { value: 'local', name: 'Local Git repositories', description: 'A folder of cloned repos on the host filesystem' }, - { value: 'git', name: 'Other Git host', description: 'Any git clone URL (catch-all for unsupported hosts)' }, - { value: 'azuredevops', name: 'Azure DevOps', description: 'dev.azure.com' }, - { value: 'bitbucket', name: 'Bitbucket', description: 'Cloud (bitbucket.org) or self-hosted Data Center' }, - { value: 'gitea', name: 'Gitea', description: 'self-hosted Gitea' }, - { value: 'gerrit', name: 'Gerrit', description: 'self-hosted Gerrit' }, + { value: 'github', name: 'GitHub', description: 'github.com, GitHub Enterprise Server, or GitHub Enterprise Cloud' }, + { value: 'gitlab', name: 'GitLab', description: 'gitlab.com, GitLab Self Managed, or GitLab Dedicated' }, + { value: 'local', name: 'Local git repositories', description: 'git repositories in a local directory' }, + { value: 'git', name: 'Remote git repository', description: 'Arbitrary git URL' }, + { value: 'azuredevops', name: 'Azure DevOps', description: 'dev.azure.com or Azure Devops Server' }, + { value: 'bitbucket', name: 'Bitbucket', description: 'Bitbucket Cloud or Bitbucket Data Center' }, + { value: 'gitea', name: 'Gitea', description: 'Gitea Cloud or Gitea self-hosted' }, + { value: 'gerrit', name: 'Gerrit' }, ], }); @@ -251,6 +278,33 @@ async function main() { downloadedCompose = true; } + console.log(); + console.log(chalk.green('✓ ') + chalk.bold('Your Sourcebot configuration is ready!')); + + if (downloadedCompose) { + const startNow = await confirm({ + message: 'Start Sourcebot now? (runs `docker compose up`)', + default: true, + }); + + if (startNow) { + note( + `Sourcebot will open at ${SOURCEBOT_URL} once it's ready.\nPress Ctrl+C to stop.`, + 'Starting Sourcebot', + ); + void openBrowserWhenReady(SOURCEBOT_URL).catch(() => { /* best effort */ }); + await new Promise((resolve) => { + const child = spawn('docker', ['compose', 'up'], { stdio: 'inherit' }); + child.on('exit', () => resolve()); + child.on('error', (err) => { + console.error(chalk.red('✗ ') + 'Failed to run `docker compose up`: ' + (err instanceof Error ? err.message : String(err))); + resolve(); + }); + }); + return; + } + } + const nextSteps: string[] = []; let step = 1; @@ -266,13 +320,12 @@ async function main() { nextSteps.push(`${step}. Open http://localhost:3000`); note(nextSteps.join('\n'), 'Next steps'); - - console.log(); - console.log(chalk.green('✓ ') + chalk.bold('Your Sourcebot configuration is ready!')); } main().catch(err => { - if (err instanceof Error && err.name === 'ExitPromptError') { + const isExitPrompt = err instanceof Error + && (err.name === 'ExitPromptError' || err.message?.startsWith('User force closed the prompt')); + if (isExitPrompt) { console.log(); console.log(chalk.red('✗ ') + 'Setup cancelled.'); process.exit(0); From ef27d12d0fbe6f2271c0e9b9c95187ee6dc8455d Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Thu, 21 May 2026 14:30:44 -0700 Subject: [PATCH 09/10] feedback --- packages/setupWizard/src/index.ts | 17 ++++++------ packages/setupWizard/src/localRepos.ts | 37 +++++++++++++++++++++----- packages/setupWizard/tsconfig.json | 2 +- 3 files changed, 40 insertions(+), 16 deletions(-) diff --git a/packages/setupWizard/src/index.ts b/packages/setupWizard/src/index.ts index 5178476e6..4cbf1597b 100644 --- a/packages/setupWizard/src/index.ts +++ b/packages/setupWizard/src/index.ts @@ -76,7 +76,7 @@ async function main() { const connections: Record = {}; const allEnv: EnvVars = {}; - const localRepoHostPaths: string[] = []; + const localRepoIndex = new Map(); // eslint-disable-next-line no-constant-condition while (true) { @@ -121,7 +121,7 @@ async function main() { result = await collectGerritConfig(); break; case 'local': - result = await collectLocalReposConfig(); + result = await collectLocalReposConfig(localRepoIndex); break; case 'git': result = await collectGenericGitConfig(); @@ -137,9 +137,6 @@ async function main() { connections[finalName] = config; } Object.assign(allEnv, result.env); - if (result.localRepoHostPath) { - localRepoHostPaths.push(result.localRepoHostPath); - } const addAnother = await confirm({ message: 'Add another code host?', @@ -178,7 +175,7 @@ async function main() { } } - if (localRepoHostPaths.length > 0 && existsSync('docker-compose.override.yml')) { + if (localRepoIndex.size > 0 && existsSync('docker-compose.override.yml')) { const overwrite = await confirm({ message: 'docker-compose.override.yml already exists. Overwrite?', default: true, @@ -235,15 +232,17 @@ async function main() { const writtenFiles = ['config.json', '.env']; - if (localRepoHostPaths.length > 0) { - const uniquePaths = [...new Set(localRepoHostPaths)]; + if (localRepoIndex.size > 0) { + const mounts = [...localRepoIndex.entries()] + .sort((a, b) => a[1] - b[1]) + .map(([p, i]) => ` - ${p}:/repos/${i}:ro`); const overrideYaml = [ '# Generated by setup-sourcebot', '# Merged with docker-compose.yml at `docker compose up` time.', 'services:', ' sourcebot:', ' volumes:', - ...uniquePaths.map((p) => ` - ${p}:/repos:ro`), + ...mounts, '', ].join('\n'); writeFileSync('docker-compose.override.yml', overrideYaml); diff --git a/packages/setupWizard/src/localRepos.ts b/packages/setupWizard/src/localRepos.ts index 1867fd0ed..d5c7b6e52 100644 --- a/packages/setupWizard/src/localRepos.ts +++ b/packages/setupWizard/src/localRepos.ts @@ -64,7 +64,9 @@ async function findGitRepos(root: string, maxDepth: number): Promise { return repos.sort(); } -export async function collectLocalReposConfig(): Promise { +export async function collectLocalReposConfig( + localRepoIndex: Map, +): Promise { note( [ 'Point at a directory on your machine that contains git repositories.', @@ -108,8 +110,30 @@ export async function collectLocalReposConfig(): Promise { break; } + let index = localRepoIndex.get(hostPath); + if (index === undefined) { + index = localRepoIndex.size; + localRepoIndex.set(hostPath, index); + } + const containerRoot = `/repos/${index}`; + + const hostPathIsRepo = repos.length === 1 && repos[0] === hostPath; + if (hostPathIsRepo) { + return { + connections: [{ + name: basename(hostPath), + config: { + type: 'git', + url: `file://${containerRoot}`, + } satisfies GenericGitHostConnectionConfig, + }], + env: {}, + localRepoHostPath: hostPath, + }; + } + const choices = repos.map((repoPath) => ({ - name: relative(hostPath, repoPath), + name: relative(hostPath, repoPath) || basename(repoPath), value: repoPath, checked: true, })); @@ -122,21 +146,22 @@ export async function collectLocalReposConfig(): Promise { loop: false, }); + const posixRel = (p: string): string => relative(hostPath, p).split('\\').join('/'); + const allSelected = selected.length === repos.length; - const allAtDepthOne = repos.every((p) => !relative(hostPath, p).includes('/')); + const allAtDepthOne = repos.every((p) => !posixRel(p).includes('/')); const connections = allSelected && allAtDepthOne ? [{ config: { type: 'git', - url: 'file:///repos/*', + url: `file://${containerRoot}/*`, } satisfies GenericGitHostConnectionConfig, }] : selected.map((repoPath) => { - const rel = relative(hostPath, repoPath); const config: GenericGitHostConnectionConfig = { type: 'git', - url: `file:///repos/${rel}`, + url: `file://${containerRoot}/${posixRel(repoPath)}`, }; return { name: basename(repoPath), config }; }); diff --git a/packages/setupWizard/tsconfig.json b/packages/setupWizard/tsconfig.json index c3c56b0e2..efb889845 100644 --- a/packages/setupWizard/tsconfig.json +++ b/packages/setupWizard/tsconfig.json @@ -9,7 +9,7 @@ "module": "Node16", "moduleResolution": "Node16", "target": "ES2022", - "noEmitOnError": false, + "noEmitOnError": true, "noImplicitAny": true, "pretty": true, "resolveJsonModule": true, From 73eb586098dab0e5c37c0df236df48584975b39d Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Thu, 21 May 2026 14:31:44 -0700 Subject: [PATCH 10/10] update git branch --- packages/setupWizard/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/setupWizard/src/index.ts b/packages/setupWizard/src/index.ts index 4cbf1597b..6c349e3d1 100644 --- a/packages/setupWizard/src/index.ts +++ b/packages/setupWizard/src/index.ts @@ -24,7 +24,7 @@ import { } from './utils.js'; // @nocheckin: change this to main -const DOCKER_COMPOSE_BRANCH = 'bkellam/setup-wizard'; +const DOCKER_COMPOSE_BRANCH = 'v5'; const DOCKER_COMPOSE_URL = `https://raw.githubusercontent.com/sourcebot-dev/sourcebot/${DOCKER_COMPOSE_BRANCH}/docker-compose.yml`; const SOURCEBOT_URL = 'http://localhost:3000';