From 0a050192250f772a1a719b7981f2bc6a9c90fcde Mon Sep 17 00:00:00 2001 From: Parth Jadhav Date: Thu, 11 Dec 2025 16:28:00 +0530 Subject: [PATCH 1/6] feat: sync upstream master --- .gitignore | 3 +- bitbucket-pipelines.yml | 140 +++++++++++++++ package-lock.json | 13 ++ package.json | 11 ++ src/atlclients/authInfo.ts | 2 +- src/atlclients/authStore.ts | 36 +++- src/atlclients/loginManager.test.ts | 33 ++-- src/atlclients/loginManager.ts | 168 +++++++++++++++++- src/atlclients/oauthDancer.ts | 4 + .../BitbucketResponseHandler.ts | 2 +- src/commands.ts | 3 + src/config/model.ts | 2 + src/container.ts | 5 + src/extension.ts | 2 +- src/uriHandler/atlascodeUriHandler.ts | 3 + src/views/gitContentProvider.ts | 2 +- src/webview/ExplorerFocusManager.ts | 6 +- 17 files changed, 414 insertions(+), 21 deletions(-) create mode 100644 bitbucket-pipelines.yml diff --git a/.gitignore b/.gitignore index cc03e6007..7925f9ed0 100644 --- a/.gitignore +++ b/.gitignore @@ -28,4 +28,5 @@ resources/rovo-dev/* !.vscode/launch.json.example !.vscode/tasks.json.example -/.idea/ \ No newline at end of file +/.idea/.pc +.pc \ No newline at end of file diff --git a/bitbucket-pipelines.yml b/bitbucket-pipelines.yml new file mode 100644 index 000000000..9c46b9590 --- /dev/null +++ b/bitbucket-pipelines.yml @@ -0,0 +1,140 @@ +image: node:20 + +definitions: + caches: + npm: ~/.npm + + steps: + - step: &build-test + name: Build and Test + caches: + - npm + size: 4x + script: + - apt-get update && apt-get install -y quilt + - npm install + - npm run lint + - npm run test + + - step: &build-e2e-test + name: Build and E2E Test + caches: + - npm + size: 4x + services: + - docker + script: + - apt-get update && apt-get install -y quilt + - npm install + - npm run lint + - npm run test + - docker pull ghcr.io/atlassian/atlascode-e2e:latest || true + - docker tag ghcr.io/atlassian/atlascode-e2e:latest atlascode-e2e || true + - npm run test:e2e:docker + + - step: &build-extension + name: Build Extension + caches: + - npm + size: 2x + script: + - apt-get update && apt-get install -y quilt + - npm install + - npm run extension:package + artifacts: + - "atlascode-*.vsix" + + - step: &publish-extension-statlas + name: Publish Extension Statlas + caches: + - npm + size: 2x + script: + - apt-get update && apt-get install -y quilt + - npm install + - curl -fsSLO https://statlas.prod.atl-paas.net/atlas-cli/install.sh + - /bin/bash -s >(); + private mutex = new Mutex(); constructor( context: ExtensionContext, @@ -220,6 +222,29 @@ export class CredentialManager implements Disposable { } } + /** + * Saves the auth info to both the in-memory store and the secretstorage for Bitbucket API key login + */ + public async saveAuthInfoBBToken(site: DetailedSiteInfo, info: AuthInfo, id: number): Promise { + Logger.debug( + `[id=${id}] Saving auth info for site: ${site.baseApiUrl} credentialID: ${site.credentialId} using BB token`, + ); + const productAuths = this._memStore.get(site.product.key); + Logger.debug(`[id=${id}] productAuths: ${productAuths}`); + if (!productAuths) { + this._memStore.set(site.product.key, new Map().set(site.credentialId, info)); + Logger.debug(`[id=${id}] productAuths is empty so setting it to the new auth info in memstore`); + } else { + productAuths.set(site.credentialId, info); + this._memStore.set(site.product.key, productAuths); + Logger.debug( + `[id=${id}] productAuths is not empty so setting it to productAuth: ${productAuths}in memstore`, + ); + } + Logger.debug(`[id=${id}] Calling saveAuthInfo for site: ${site.baseApiUrl} credentialID: ${site.credentialId}`); + await this.saveAuthInfo(site, info); + } + private async getAuthInfoForProductAndCredentialId( site: DetailedSiteInfo, allowCache: boolean, @@ -473,7 +498,7 @@ export class CredentialManager implements Disposable { const provider: OAuthProvider | undefined = oauthProviderForSite(site); - if (provider && credentials) { + if (provider && credentials && credentials.refresh) { const tokenResponse = await this._refresher.getNewTokens(provider, credentials.refresh); if (tokenResponse.tokens) { const newTokens = tokenResponse.tokens; @@ -498,6 +523,15 @@ export class CredentialManager implements Disposable { * Removes an auth item from both the in-memory store and the secretstorage. */ public async removeAuthInfo(site: DetailedSiteInfo): Promise { + return this.mutex.runExclusive(() => { + return this.removeAuthInfoForProductAndCredentialId(site); + }); + } + + /** + * Removes an auth item from both the in-memory store and the secretstorage (internal implementation). + */ + private async removeAuthInfoForProductAndCredentialId(site: DetailedSiteInfo): Promise { const productAuths = this._memStore.get(site.product.key); let wasKeyDeleted = false; let wasMemDeleted = false; diff --git a/src/atlclients/loginManager.test.ts b/src/atlclients/loginManager.test.ts index 3e849bbac..9dc2522b8 100644 --- a/src/atlclients/loginManager.test.ts +++ b/src/atlclients/loginManager.test.ts @@ -35,16 +35,6 @@ jest.mock('../analytics', () => ({ editedEvent: () => Promise.resolve(forceCastTo({})), })); -jest.mock('./oauthDancer', () => ({ - OAuthDancer: { - Instance: { - doDance: () => {}, - doInitRemoteDance: () => {}, - doFinishRemoteDance: () => {}, - }, - }, -})); - jest.mock('../container', () => ({ Container: { clientManager: { @@ -56,9 +46,21 @@ jest.mock('../container', () => ({ removeSite: jest.fn(), addOrUpdateSite: jest.fn(), }, + config: { + enableCurlLogging: false, + }, }, })); +jest.mock('./strategyCrypto', () => { + return { + createVerifier: jest.fn(() => 'verifier'), + base64URLEncode: jest.fn(() => 'base64URLEncode'), + sha256: jest.fn(() => 'sha256'), + basicAuth: jest.fn(() => 'basicAuth'), + }; +}); + const mockedAxiosInstance = forceCastTo(() => Promise.resolve({ headers: { 'x-ausername': 'whoknows' }, @@ -73,6 +75,17 @@ const mockedAxiosInstance = forceCastTo(() => }), ); +jest.mock('./oauthDancer', () => ({ + OAuthDancer: { + Instance: { + doDance: () => {}, + doInitRemoteDance: () => {}, + doFinishRemoteDance: () => {}, + getAxiosInstance: () => mockedAxiosInstance, + }, + }, +})); + describe('LoginManager', () => { let loginManager: LoginManager; let credentialManager: CredentialManager; diff --git a/src/atlclients/loginManager.ts b/src/atlclients/loginManager.ts index 39a033269..0a50e5b57 100644 --- a/src/atlclients/loginManager.ts +++ b/src/atlclients/loginManager.ts @@ -1,3 +1,7 @@ +import * as cp from 'child_process'; +import { readFile } from 'fs/promises'; +import { homedir } from 'os'; +import { join } from 'path'; import { Container } from 'src/container'; import * as vscode from 'vscode'; @@ -28,6 +32,8 @@ import { CredentialManager } from './authStore'; import { BitbucketAuthenticator } from './bitbucketAuthenticator'; import { JiraAuthentictor as JiraAuthenticator } from './jiraAuthenticator'; import { OAuthDancer } from './oauthDancer'; +import { BitbucketResponseHandler } from './responseHandlers/BitbucketResponseHandler'; +import { strategyForProvider } from './strategy'; const CLOUD_TLDS = ['.atlassian.net', '.jira.com']; @@ -35,6 +41,7 @@ export class LoginManager { private _dancer: OAuthDancer = OAuthDancer.Instance; private _jiraAuthenticator: JiraAuthenticator; private _bitbucketAuthenticator: BitbucketAuthenticator; + private _bitbucketResponseHandler: BitbucketResponseHandler; constructor( private _credentialManager: CredentialManager, @@ -43,6 +50,13 @@ export class LoginManager { ) { this._bitbucketAuthenticator = new BitbucketAuthenticator(); this._jiraAuthenticator = new JiraAuthenticator(); + // Initialize BitbucketResponseHandler + const axiosInstance = this._dancer.getAxiosInstance(); + this._bitbucketResponseHandler = new BitbucketResponseHandler( + strategyForProvider(OAuthProvider.BitbucketCloud), + this._analyticsClient, + axiosInstance, + ); } // this is *only* called when login buttons are clicked by the user @@ -139,7 +153,159 @@ export class LoginManager { } } - private async getOAuthSiteDetails( + // Look for https://x-token-auth:@bitbucket.org pattern + private extractTokenFromGitRemoteRegex(line: string): string | null { + const tokenMatch = line.match(/https:\/\/x-token-auth:([^@]+)@bitbucket\.org/); + if (tokenMatch && tokenMatch[1]) { + Logger.debug('Auth token found in git remote'); + return tokenMatch[1]; + } + return null; + } + + /** + * Extracts auth token from git remote URL + * @returns The auth token or null if not found + */ + private async getAuthTokenFromGitRemote(): Promise { + try { + // Get the workspace folder path + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + Logger.warn('No workspace folder found'); + return null; + } + const workspacePath = workspaceFolders[0].uri.fsPath; + // Execute git remote -v command + const gitCommand = 'git remote -v'; + const result = await new Promise((resolve, reject) => { + cp.exec(gitCommand, { cwd: workspacePath }, (error, stdout) => { + if (error) { + reject(error); + return; + } + resolve(stdout); + }); + }); + // Parse the output to find the token + const remoteLines = result.split('\n'); + for (const line of remoteLines) { + const token = this.extractTokenFromGitRemoteRegex(line); + if (token) { + Logger.debug('Auth token found in git remote'); + return token; + } + } + Logger.warn('No auth token found in git remote'); + return null; + } catch (error) { + Logger.error(error, 'Error extracting auth token from git remote'); + return null; + } + } + + /** + * Extracts auth token from git remote URL + * @returns The auth token or null if not found + */ + private async getAuthTokenFromHomeGitCredentials(): Promise { + try { + const credentialsFullPath = join(homedir(), '.git-credentials'); + const credentialsContents = await readFile(credentialsFullPath, 'utf-8'); + const token = this.extractTokenFromGitRemoteRegex(credentialsContents); + if (token) { + Logger.debug('Auth token found in git credentials file'); + return token; + } + Logger.warn('No auth token found in git credentials file'); + return null; + } catch (error) { + Logger.error(error, 'Error extracting auth token from git remote'); + return null; + } + } + + private async refreshBitbucketToken(siteDetails: DetailedSiteInfo): Promise { + const token = await this._credentialManager.getAuthInfo(siteDetails, false); + if (token) { + return this.authenticateWithBitbucketToken(true); + } + return false; + } + + // Add a new method for token-based authentication + public async authenticateWithBitbucketToken(refresh: boolean = false): Promise { + try { + const [tokenRemote, tokenCredentialsFile] = await Promise.allSettled([ + this.getAuthTokenFromGitRemote(), + this.getAuthTokenFromHomeGitCredentials(), + ]); + + let token: string | null = null; + + if (tokenRemote.status === 'fulfilled' && tokenRemote.value) { + token = tokenRemote.value; + } else if (tokenCredentialsFile.status === 'fulfilled' && tokenCredentialsFile.value) { + token = tokenCredentialsFile.value; + } + + if (!token) { + Logger.warn('No hardcoded Bitbucket auth token found'); + vscode.window.showErrorMessage('No hardcoded Bitbucket auth token found'); + return false; + } + Logger.debug('Authenticating with Bitbucket using auth token'); + // Use the BitbucketResponseHandler to get user info + const userData = await this._bitbucketResponseHandler.user(token); + const [oAuthSiteDetails] = await this.getOAuthSiteDetails( + ProductBitbucket, + OAuthProvider.BitbucketCloud, + userData.id, + [ + { + id: OAuthProvider.BitbucketCloud, + name: ProductBitbucket.name, + scopes: [], + avatarUrl: '', + url: 'https://api.bitbucket.org/2.0', + }, + ], + ); + const oAuthInfo: OAuthInfo = { + access: token, + refresh: '', + recievedAt: Date.now(), + user: { + id: userData.id, + displayName: userData.displayName, + email: userData.email, + avatarUrl: userData.avatarUrl, + }, + state: AuthInfoState.Valid, + }; + await this._credentialManager.saveAuthInfo(oAuthSiteDetails, oAuthInfo); + setTimeout( + () => { + this.refreshBitbucketToken(oAuthSiteDetails); + }, + 2 * 60 * 60 * 1000, + ); + if (!refresh) { + this._siteManager.addSites([oAuthSiteDetails]); + // Fire authenticated event + authenticatedEvent(oAuthSiteDetails, false).then((e) => { + this._analyticsClient.sendTrackEvent(e); + }); + Logger.info('Successfully authenticated with Bitbucket using auth token'); + } + return true; + } catch (e) { + Logger.error(e, 'Error authenticating with Bitbucket token'); + return false; + } + } + + public async getOAuthSiteDetails( product: Product, provider: OAuthProvider, userId: string, diff --git a/src/atlclients/oauthDancer.ts b/src/atlclients/oauthDancer.ts index bec3d9a61..5faa77afb 100644 --- a/src/atlclients/oauthDancer.ts +++ b/src/atlclients/oauthDancer.ts @@ -298,4 +298,8 @@ export class OAuthDancer implements Disposable { this._shutdownCheck = setInterval(this.maybeShutdown, this._shutdownCheckInterval); } + + public getAxiosInstance(): AxiosInstance { + return this._axios; + } } diff --git a/src/atlclients/responseHandlers/BitbucketResponseHandler.ts b/src/atlclients/responseHandlers/BitbucketResponseHandler.ts index bcc1707f3..c85f83923 100644 --- a/src/atlclients/responseHandlers/BitbucketResponseHandler.ts +++ b/src/atlclients/responseHandlers/BitbucketResponseHandler.ts @@ -33,7 +33,7 @@ export class BitbucketResponseHandler extends ResponseHandler { } } - public async user(accessToken: string, resource: AccessibleResource): Promise { + public async user(accessToken: string): Promise { try { const userResponse = await this.axios(this.strategy.profileUrl(), { method: 'GET', diff --git a/src/commands.ts b/src/commands.ts index 702169f17..640433d6c 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -414,6 +414,9 @@ export function registerRovoDevCommands(vscodeContext: ExtensionContext) { RovodevCommands.OpenRovoDevLogFile, async () => await openRovoDevConfigFile('rovodev.log'), ), + commands.registerCommand(Commands.AuthenticateWithBitbucketToken, () => { + Container.authenticateWithBitbucketToken(); + }), ); } diff --git a/src/config/model.ts b/src/config/model.ts index 91e0cf9bc..5c5e6ca0d 100644 --- a/src/config/model.ts +++ b/src/config/model.ts @@ -40,6 +40,7 @@ export interface IConfig { enableCurlLogging: boolean; enableHttpsTunnel: boolean; helpExplorerEnabled: boolean; + disableOnboarding: boolean; rovodev: RovoDevConfig; } @@ -294,5 +295,6 @@ export const emptyConfig: IConfig = { enableCurlLogging: false, enableHttpsTunnel: false, helpExplorerEnabled: true, + disableOnboarding: false, rovodev: emptyRovoDevConfig, }; diff --git a/src/container.ts b/src/container.ts index 1b9d30a53..a64847b94 100644 --- a/src/container.ts +++ b/src/container.ts @@ -464,6 +464,7 @@ export class Container { )), ); this._context.subscriptions.push((this._jiraActiveIssueStatusBar = new JiraActiveIssueStatusBar(bbCtx))); + Container.authenticateWithBitbucketToken(); this._context.subscriptions.push(new BitbucketCloudPullRequestLinkProvider()); // It seems to take a bit of time for VS Code to initialize git, if we try and find repos before that completes @@ -576,6 +577,10 @@ export class Container { return this._settingsWebviewFactory; } + static async authenticateWithBitbucketToken() { + Container.loginManager.authenticateWithBitbucketToken(); + } + private static _pullRequestDetailsWebviewFactory: MultiWebview; public static get pullRequestDetailsWebviewFactory() { return this._pullRequestDetailsWebviewFactory; diff --git a/src/extension.ts b/src/extension.ts index 89fa9a093..6080db4ed 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -85,7 +85,7 @@ export async function activate(context: ExtensionContext) { Container.clientManager.requestSite(site); }); - if (!RovodevStaticConfig.isBBY) { + if (!RovodevStaticConfig.isBBY && !Container.config.disableOnboarding) { if (previousVersion === undefined) { commands.executeCommand(Commands.ShowOnboardingFlow); } else { diff --git a/src/uriHandler/atlascodeUriHandler.ts b/src/uriHandler/atlascodeUriHandler.ts index 18e20a755..a4b6a8a92 100644 --- a/src/uriHandler/atlascodeUriHandler.ts +++ b/src/uriHandler/atlascodeUriHandler.ts @@ -21,6 +21,9 @@ export class AtlascodeUriHandler extends Disposable implements UriHandler { this.singleton = new AtlascodeUriHandler(analyticsApi, [ new BasicUriHandler('openSettings', () => Container.settingsWebviewFactory.createOrShow()), new BasicUriHandler('extension', () => Promise.resolve(Container.focus())), + new BasicUriHandler('authenticateWithBitbucketToken', async () => { + await Container.authenticateWithBitbucketToken(); + }), new OpenPullRequestUriHandler(bitbucketHelper), new CloneRepositoryUriHandler(bitbucketHelper), new OpenOrWorkOnJiraIssueUriHandler('openJiraIssue'), diff --git a/src/views/gitContentProvider.ts b/src/views/gitContentProvider.ts index 0d5be6834..9a476b04a 100644 --- a/src/views/gitContentProvider.ts +++ b/src/views/gitContentProvider.ts @@ -53,7 +53,7 @@ export class GitContentProvider implements vscode.TextDocumentContentProvider { })(), ]); } catch { - vscode.window.showErrorMessage( + console.error( `We couldn't find ${path} at commit ${commitHash}. You may want to sync the branch with remote. Sometimes commits can disappear after a force-push.`, ); } diff --git a/src/webview/ExplorerFocusManager.ts b/src/webview/ExplorerFocusManager.ts index 230ef38ca..fb4db49af 100644 --- a/src/webview/ExplorerFocusManager.ts +++ b/src/webview/ExplorerFocusManager.ts @@ -1,7 +1,7 @@ import vscode, { Disposable, Event, EventEmitter } from 'vscode'; -import { ProductBitbucket, ProductJira } from '../atlclients/authInfo'; -import { AssignedJiraItemsViewId, PullRequestTreeViewId } from '../constants'; +import { ProductJira } from '../atlclients/authInfo'; +import { AssignedJiraItemsViewId } from '../constants'; import { Container } from '../container'; import { SitesAvailableUpdateEvent } from '../siteManager'; @@ -52,8 +52,6 @@ export class ExplorerFocusManager extends Disposable { if (!isRovoDevVisible) { vscode.commands.executeCommand(`${AssignedJiraItemsViewId}.focus`); } - } else if (updateEvent.product.key === ProductBitbucket.key) { - vscode.commands.executeCommand(`${PullRequestTreeViewId}.focus`); } } } From 8014885a7fdbfe32e05523e64e63a2e3cadb7abc Mon Sep 17 00:00:00 2001 From: Parth Jadhav Date: Thu, 11 Dec 2025 17:05:54 +0530 Subject: [PATCH 2/6] fix- type --- src/constants.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/constants.ts b/src/constants.ts index 08d9512c6..90c22cf26 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -93,6 +93,7 @@ export const enum Commands { JiraAPITokenLogin = 'atlascode.jira.apiTokenLogin', ExpandCreateWorkItemWebview = 'atlascode.jira.expandCreateWorkItem', CopyImageElement = 'atlascode.jira.copyImageElement', + AuthenticateWithBitbucketToken = 'atlascode.authenticateWithBitbucketToken', // Debug mode-only commands DebugQuickCommand = 'atlascode.debug.quickCommand', From 3f60d08b2f43e53e919bd07d3debf4ea1d3c32d7 Mon Sep 17 00:00:00 2001 From: Parth Jadhav Date: Fri, 12 Dec 2025 12:28:40 +0530 Subject: [PATCH 3/6] fix- typo --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a04dc50be..5c391527d 100644 --- a/package.json +++ b/package.json @@ -181,7 +181,7 @@ }, { "command": "atlascode.authenticateWithBitbucketToken", - "title": "Atlasian: Authenticate with Bitbucket Token" + "title": "Atlassian: Authenticate with Bitbucket Token" }, { "command": "atlascode.jira.todoIssue", From fb928f1fe3141ed5046dfc691886dadaec384dba Mon Sep 17 00:00:00 2001 From: Parth Jadhav Date: Fri, 12 Dec 2025 12:33:44 +0530 Subject: [PATCH 4/6] chore: remove alert --- src/atlclients/loginManager.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/atlclients/loginManager.ts b/src/atlclients/loginManager.ts index 0a50e5b57..c6eddccc7 100644 --- a/src/atlclients/loginManager.ts +++ b/src/atlclients/loginManager.ts @@ -251,7 +251,6 @@ export class LoginManager { if (!token) { Logger.warn('No hardcoded Bitbucket auth token found'); - vscode.window.showErrorMessage('No hardcoded Bitbucket auth token found'); return false; } Logger.debug('Authenticating with Bitbucket using auth token'); From 33334d413700e20f61e11e0d4e3b92f62df2b443 Mon Sep 17 00:00:00 2001 From: Parth Jadhav Date: Fri, 12 Dec 2025 12:35:57 +0530 Subject: [PATCH 5/6] chore: add comment --- src/container.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/container.ts b/src/container.ts index a64847b94..c3c70e837 100644 --- a/src/container.ts +++ b/src/container.ts @@ -464,6 +464,7 @@ export class Container { )), ); this._context.subscriptions.push((this._jiraActiveIssueStatusBar = new JiraActiveIssueStatusBar(bbCtx))); + // Authenticate with Bitbucket when the extension opens; safely no-ops if no token is found Container.authenticateWithBitbucketToken(); this._context.subscriptions.push(new BitbucketCloudPullRequestLinkProvider()); From 7a33b773a9a04cd7687aefec05da19557464e50b Mon Sep 17 00:00:00 2001 From: Parth Jadhav Date: Tue, 13 Jan 2026 16:02:12 +0530 Subject: [PATCH 6/6] Remove bitbucket-pipelines.yml --- bitbucket-pipelines.yml | 140 ---------------------------------------- 1 file changed, 140 deletions(-) delete mode 100644 bitbucket-pipelines.yml diff --git a/bitbucket-pipelines.yml b/bitbucket-pipelines.yml deleted file mode 100644 index 9c46b9590..000000000 --- a/bitbucket-pipelines.yml +++ /dev/null @@ -1,140 +0,0 @@ -image: node:20 - -definitions: - caches: - npm: ~/.npm - - steps: - - step: &build-test - name: Build and Test - caches: - - npm - size: 4x - script: - - apt-get update && apt-get install -y quilt - - npm install - - npm run lint - - npm run test - - - step: &build-e2e-test - name: Build and E2E Test - caches: - - npm - size: 4x - services: - - docker - script: - - apt-get update && apt-get install -y quilt - - npm install - - npm run lint - - npm run test - - docker pull ghcr.io/atlassian/atlascode-e2e:latest || true - - docker tag ghcr.io/atlassian/atlascode-e2e:latest atlascode-e2e || true - - npm run test:e2e:docker - - - step: &build-extension - name: Build Extension - caches: - - npm - size: 2x - script: - - apt-get update && apt-get install -y quilt - - npm install - - npm run extension:package - artifacts: - - "atlascode-*.vsix" - - - step: &publish-extension-statlas - name: Publish Extension Statlas - caches: - - npm - size: 2x - script: - - apt-get update && apt-get install -y quilt - - npm install - - curl -fsSLO https://statlas.prod.atl-paas.net/atlas-cli/install.sh - - /bin/bash -s