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 diff --git a/packages/setupWizard/package.json b/packages/setupWizard/package.json new file mode 100644 index 000000000..8a2a38118 --- /dev/null +++ b/packages/setupWizard/package.json @@ -0,0 +1,30 @@ +{ + "name": "setup-sourcebot", + "version": "0.1.0", + "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" + }, + "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": { + "@types/node": "^22.7.5", + "tsx": "^4.21.0", + "typescript": "^5.6.2" + }, + "engines": { + "node": ">=18" + }, + "files": [ + "dist" + ] +} diff --git a/packages/setupWizard/src/azuredevops.ts b/packages/setupWizard/src/azuredevops.ts new file mode 100644 index 000000000..085b77d67 --- /dev/null +++ b/packages/setupWizard/src/azuredevops.ts @@ -0,0 +1,99 @@ +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, 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})`, + mask: true, + validate: (v) => !v?.trim() ? 'Token is required' : true, + }); + env[envKey] = token; + config.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: 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: `${orgLabelPlural} to index`, + }); + } + + if (targets.includes('projects')) { + config.projects = await multiInput({ + message: `Projects to index (${orgLabel}/project)`, + }); + } + + if (targets.includes('repos')) { + config.repos = await multiInput({ + message: `Repositories to index (${orgLabel}/project/repo)`, + }); + } + + return { connections: [{ config }], env }; +} diff --git a/packages/setupWizard/src/bitbucket.ts b/packages/setupWizard/src/bitbucket.ts new file mode 100644 index 000000000..83035dc16 --- /dev/null +++ b/packages/setupWizard/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/setupWizard/src/genericGit.ts b/packages/setupWizard/src/genericGit.ts new file mode 100644 index 000000000..5f636220e --- /dev/null +++ b/packages/setupWizard/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/setupWizard/src/gerrit.ts b/packages/setupWizard/src/gerrit.ts new file mode 100644 index 000000000..51ccd97e0 --- /dev/null +++ b/packages/setupWizard/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/setupWizard/src/gitea.ts b/packages/setupWizard/src/gitea.ts new file mode 100644 index 000000000..08d716a5e --- /dev/null +++ b/packages/setupWizard/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/setupWizard/src/github.ts b/packages/setupWizard/src/github.ts new file mode 100644 index 000000000..73527c865 --- /dev/null +++ b/packages/setupWizard/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': 'setup-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/setupWizard/src/gitlab.ts b/packages/setupWizard/src/gitlab.ts new file mode 100644 index 000000000..f50e5b5cd --- /dev/null +++ b/packages/setupWizard/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': 'setup-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/setupWizard/src/index.ts b/packages/setupWizard/src/index.ts new file mode 100644 index 000000000..fab29d240 --- /dev/null +++ b/packages/setupWizard/src/index.ts @@ -0,0 +1,282 @@ +#!/usr/bin/env node +import { confirm, 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 { collectModels, PROVIDER_ENV_KEYS } from './models.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_URL = `https://raw.githubusercontent.com/sourcebot-dev/sourcebot/${DOCKER_COMPOSE_BRANCH}/docker-compose.yml`; + +const PLATFORM_LABELS: Record = { + github: 'GitHub', + gitlab: 'GitLab', + bitbucket: 'Bitbucket', + gitea: 'Gitea', + azuredevops: 'Azure DevOps', + gerrit: 'Gerrit', + local: 'Local Git repositories', + git: 'Other Git host', +}; + +async function main() { + console.log(String.raw` +███████╗ ██████╗ ██╗ ██╗██████╗ ██████╗███████╗██████╗ ██████╗ ████████╗ +██╔════╝██╔═══██╗██║ ██║██╔══██╗██╔════╝██╔════╝██╔══██╗██╔═══██╗╚══██╔══╝ +███████╗██║ ██║██║ ██║██████╔╝██║ █████╗ ██████╔╝██║ ██║ ██║ +╚════██║██║ ██║██║ ██║██╔══██╗██║ ██╔══╝ ██╔══██╗██║ ██║ ██║ +███████║╚██████╔╝╚██████╔╝██║ ██║╚██████╗███████╗██████╔╝╚██████╔╝ ██║██╗ +╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝╚══════╝╚═════╝ ╚═════╝ ╚═╝╚═╝ +`); + + const connections: Record = {}; + const allEnv: EnvVars = {}; + const localRepoHostPaths: string[] = []; + + // eslint-disable-next-line no-constant-condition + while (true) { + 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' }, + ], + }); + + 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; + case 'local': + result = await collectLocalReposConfig(); + break; + case 'git': + result = await collectGenericGitConfig(); + break; + default: + continue; + } + + 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 = await confirm({ + message: 'Add another code host?', + default: false, + }); + + if (!addAnother) { + break; + } + } + + const { models, env: modelEnv } = await collectModels(); + Object.assign(allEnv, modelEnv); + + if (existsSync('config.json')) { + const overwrite = await confirm({ + message: 'config.json already exists. Overwrite?', + 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) { + console.log(); + console.log(chalk.red('✗ ') + '.env was not overwritten.'); + process.exit(0); + } + } + + 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', + 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 setup-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'); + writeFileSync('.env', envLines.join('\n') + '\n'); + + const writtenFiles = ['config.json', '.env']; + + if (localRepoHostPaths.length > 0) { + const uniquePaths = [...new Set(localRepoHostPaths)]; + 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`), + '', + ].join('\n'); + writeFileSync('docker-compose.override.yml', overrideYaml); + writtenFiles.push('docker-compose.override.yml'); + } + + s.succeed(`Wrote ${writtenFiles.join(', ')}`); + + let downloadedCompose = false; + + if (!existsSync('docker-compose.yml')) { + const download = await confirm({ + message: 'Download docker-compose.yml?', + default: true, + }); + + if (download) { + 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.succeed('Downloaded docker-compose.yml'); + downloadedCompose = true; + } catch { + ds.fail('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 ${DOCKER_COMPOSE_URL}`); + 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'); + + 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/setupWizard/src/localRepos.ts b/packages/setupWizard/src/localRepos.ts new file mode 100644 index 000000000..1867fd0ed --- /dev/null +++ b/packages/setupWizard/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/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 }; +} diff --git a/packages/setupWizard/src/utils.ts b/packages/setupWizard/src/utils.ts new file mode 100644 index 000000000..99a553f92 --- /dev/null +++ b/packages/setupWizard/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/packages/setupWizard/tsconfig.json b/packages/setupWizard/tsconfig.json new file mode 100644 index 000000000..c3c56b0e2 --- /dev/null +++ b/packages/setupWizard/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "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..e4545b495 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2955,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" @@ -9013,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: @@ -9756,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" @@ -9811,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" @@ -9994,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" @@ -10780,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" @@ -10794,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" @@ -11472,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 @@ -11507,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" @@ -11597,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" @@ -13018,6 +13367,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" @@ -13987,6 +14343,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 +14366,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" @@ -14380,6 +14761,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" @@ -15086,7 +15474,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: @@ -15195,6 +15583,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" @@ -15608,7 +16009,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 @@ -16373,7 +16774,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: @@ -17471,6 +17872,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" @@ -18129,6 +18544,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" @@ -18505,7 +18936,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 @@ -20426,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" @@ -21026,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" @@ -21108,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" @@ -21246,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" @@ -21901,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" @@ -22848,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"