-
Notifications
You must be signed in to change notification settings - Fork 278
feat(v5): Setup wizard #1212
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: v5
Are you sure you want to change the base?
feat(v5): Setup wizard #1212
Changes from all commits
233ba41
701713f
fd861b3
d9dbfe9
9882bb4
e9401a7
404d5ea
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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" | ||
| ] | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<CollectResult> { | ||
| 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/<your-org>/_usersSettings/tokens' | ||
| : ' <your-server-url>/_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<string>({ | ||
| 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 }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<CollectResult> { | ||
| 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<CollectResult> { | ||
| 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<string>({ | ||
| 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<CollectResult> { | ||
| 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; | ||
|
|
||
|
Comment on lines
+161
to
+174
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Bitbucket Server URL validation should not allow insecure HTTP by default. Line 167 currently permits 🔐 Suggested patch- const url = await input({
+ const url = await input({
message: 'Bitbucket Data Center URL (e.g. https://bitbucket.example.com)',
- validate: (v) => {
- if (!v?.trim()) {
+ validate: (raw) => {
+ const v = raw?.trim();
+ if (!v) {
return 'URL is required';
}
- if (!/^https?:\/\//.test(v)) {
- return 'Must start with http:// or https://';
+ if (!/^https:\/\//.test(v)) {
+ return 'Must start with https://';
}
return true;
},
});
- config.url = url;
+ config.url = url.trim();🤖 Prompt for AI Agents |
||
| 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<string>({ | ||
| 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 }; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Harden server URL handling for token auth (
http://currently allowed).Line 30 accepts plain HTTP, which can expose PATs in transit. Require HTTPS by default (or explicitly gate HTTP behind an insecure-confirmation step), and persist the trimmed URL.
🔐 Suggested patch
📝 Committable suggestion
🤖 Prompt for AI Agents