diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 183f0d4..763462f 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -6,5 +6,12 @@ "features": { "ghcr.io/devcontainers/features/node:1": {} }, - "postCreateCommand": "yarn install" + "postCreateCommand": "yarn install", + "customizations": { + "vscode": { + "extensions": [ + "esbenp.prettier-vscode" + ] + } + } } diff --git a/examples/cleanup.ts b/examples/cleanup.ts new file mode 100644 index 0000000..f08b423 --- /dev/null +++ b/examples/cleanup.ts @@ -0,0 +1,36 @@ +import { Disposables } from '../src/lib/disposables'; + +/** + * A utility function that wraps the main logic with proper signal handling and cleanup. + * It ensures that disposables are cleaned up properly when the process is interrupted. + * + * @param fn The main function that receives disposables and returns a promise + * @returns A promise that resolves to the result of the function + */ +export async function withCleanup(fn: (disposables: Disposables) => Promise): Promise { + let disposablesCleanup: (() => Promise) | undefined; + + // Setup signal handlers for cleanup + const signalHandler = async (signal: NodeJS.Signals) => { + console.log(`\nReceived ${signal}. Cleaning up...`); + if (disposablesCleanup) { + await disposablesCleanup(); + } + process.exit(0); + }; + + process.on('SIGINT', signalHandler); + process.on('SIGTERM', signalHandler); + process.on('SIGQUIT', signalHandler); + + try { + return await Disposables.with(async (disposables) => { + // Store cleanup function for signal handlers + disposablesCleanup = () => disposables.cleanup(); + return await fn(disposables); + }); + } catch (error) { + console.error('Error:', error); + process.exit(1); + } +} diff --git a/examples/fs-access.ts b/examples/fs-access.ts new file mode 100644 index 0000000..b36bec6 --- /dev/null +++ b/examples/fs-access.ts @@ -0,0 +1,162 @@ +import { Gitpod } from '../src/client'; +import { findMostUsedEnvironmentClass, EnvironmentState } from '../src/lib/environment'; +import { EnvironmentSpec } from '../src/resources/environments/environments'; +import { verifyContextUrl } from './scm-auth'; +import { generateKeyPairSync } from 'crypto'; +import { Client, SFTPWrapper } from 'ssh2'; +import { withCleanup } from './cleanup'; +import * as sshpk from 'sshpk'; + +/** + * Examples: + * - yarn ts-node examples/fs-access.ts + * - yarn ts-node examples/fs-access.ts https://github.com/gitpod-io/empty + */ +async function main() { + const contextUrl = process.argv[2]; + + await withCleanup(async (disposables) => { + const client = new Gitpod({ + logLevel: 'info', + }); + + const envClass = await findMostUsedEnvironmentClass(client); + if (!envClass) { + console.error('Error: No environment class found. Please create one first.'); + process.exit(1); + } + console.log(`Found environment class: ${envClass.displayName} (${envClass.description})`); + + console.log('Generating SSH key pair'); + const { publicKey: pemPublicKey, privateKey: pemPrivateKey } = generateKeyPairSync('rsa', { + modulusLength: 2048, + publicKeyEncoding: { + type: 'spki', + format: 'pem', + }, + privateKeyEncoding: { + type: 'pkcs8', + format: 'pem', + }, + }); + + // Convert PEM keys to OpenSSH format + const keyObject = sshpk.parseKey(pemPublicKey, 'pem'); + const publicKey = keyObject.toString('ssh'); + + const privateKeyObject = sshpk.parsePrivateKey(pemPrivateKey, 'pem'); + const privateKey = privateKeyObject.toString('ssh'); + + console.log('Creating environment with SSH access'); + const keyId = 'fs-access-example'; + const spec: EnvironmentSpec = { + desiredPhase: 'ENVIRONMENT_PHASE_RUNNING', + machine: { class: envClass.id }, + sshPublicKeys: [ + { + id: keyId, + value: publicKey, + }, + ], + }; + + if (contextUrl) { + await verifyContextUrl(client, contextUrl, envClass.runnerId); + spec.content = { + initializer: { + specs: [ + { + contextUrl: { + url: contextUrl, + }, + }, + ], + }, + }; + } + + console.log('Creating environment'); + const { environment } = await client.environments.create({ spec }); + disposables.add(() => client.environments.delete({ environmentId: environment.id })); + + const env = new EnvironmentState(client, environment.id); + disposables.add(() => env.close()); + + console.log('Waiting for environment to be running'); + await env.waitUntilRunning(); + + console.log('Waiting for SSH key to be applied'); + await env.waitForSshKeyApplied(keyId, publicKey); + + console.log('Waiting for SSH URL'); + const sshUrl = await env.waitForSshUrl(); + + console.log(`Setting up SSH connection to ${sshUrl}`); + // Parse ssh://username@host:port format + const urlParts = sshUrl.split('://')[1]; + if (!urlParts) { + throw new Error('Invalid SSH URL format'); + } + + const [username, rest] = urlParts.split('@'); + if (!username || !rest) { + throw new Error('Invalid SSH URL format: missing username or host'); + } + + const [host, portStr] = rest.split(':'); + if (!host || !portStr) { + throw new Error('Invalid SSH URL format: missing host or port'); + } + + const port = parseInt(portStr, 10); + if (isNaN(port)) { + throw new Error('Invalid SSH URL format: invalid port number'); + } + + const ssh = new Client(); + disposables.add(() => ssh.end()); + + await new Promise((resolve, reject) => { + ssh.on('ready', resolve); + ssh.on('error', reject); + + ssh.connect({ + host, + port, + username, + privateKey, + }); + }); + + console.log('Creating SFTP client'); + const sftp = await new Promise((resolve, reject) => { + ssh.sftp((err, sftp) => { + if (err) reject(err); + else resolve(sftp); + }); + }); + disposables.add(() => sftp.end()); + + console.log('Writing test file'); + const testContent = 'Hello from Gitpod TypeScript SDK!'; + await new Promise((resolve, reject) => { + sftp.writeFile('test.txt', Buffer.from(testContent), (err) => { + if (err) reject(err); + else resolve(); + }); + }); + + const content = await new Promise((resolve, reject) => { + sftp.readFile('test.txt', (err, data) => { + if (err) reject(err); + else resolve(data.toString()); + }); + }); + console.log(`File content: ${content}`); + }); +} + +main().catch((error) => { + console.error('Error:', error); + process.exit(1); +}); diff --git a/examples/run-command.ts b/examples/run-command.ts new file mode 100644 index 0000000..e61394b --- /dev/null +++ b/examples/run-command.ts @@ -0,0 +1,73 @@ +import { Gitpod } from '../src/client'; +import { findMostUsedEnvironmentClass, waitForEnvironmentRunning } from '../src/lib/environment'; +import { runCommand } from '../src/lib/automation'; +import { EnvironmentSpec } from '../src/resources/environments/environments'; +import { verifyContextUrl } from './scm-auth'; +import { withCleanup } from './cleanup'; + +/** + * Examples: + * - yarn ts-node examples/run-command.ts 'echo "Hello World!"' + * - yarn ts-node examples/run-command.ts 'echo "Hello World!"' https://github.com/gitpod-io/empty + */ +async function main() { + const args = process.argv.slice(2); + if (args.length < 1) { + console.log('Usage: yarn ts-node examples/run-command.ts "" [CONTEXT_URL]'); + process.exit(1); + } + + const command = args[0]; + const contextUrl = args[1]; + + await withCleanup(async (disposables) => { + const client = new Gitpod({ + logLevel: 'info', + }); + + const envClass = await findMostUsedEnvironmentClass(client); + if (!envClass) { + console.error('Error: No environment class found. Please create one first.'); + process.exit(1); + } + console.log(`Found environment class: ${envClass.displayName} (${envClass.description})`); + + const spec: EnvironmentSpec = { + desiredPhase: 'ENVIRONMENT_PHASE_RUNNING', + machine: { class: envClass.id }, + }; + + if (contextUrl) { + await verifyContextUrl(client, contextUrl, envClass.runnerId); + spec.content = { + initializer: { + specs: [ + { + contextUrl: { + url: contextUrl, + }, + }, + ], + }, + }; + } + + console.log('Creating environment'); + const { environment } = await client.environments.create({ spec }); + disposables.add(() => client.environments.delete({ environmentId: environment.id })); + + console.log('Waiting for environment to be ready'); + await waitForEnvironmentRunning(client, environment.id); + + console.log('Running command'); + const lines = await runCommand(client, environment.id, command!); + for await (const line of lines) { + console.log(line); + } + }); +} + +main().catch((error) => { + console.error('Error:', error); + process.exit(1); +}); diff --git a/examples/run-service.ts b/examples/run-service.ts new file mode 100644 index 0000000..6330a44 --- /dev/null +++ b/examples/run-service.ts @@ -0,0 +1,94 @@ +import { Gitpod } from '../src/client'; +import { findMostUsedEnvironmentClass, EnvironmentState } from '../src/lib/environment'; +import { runService } from '../src/lib/automation'; +import { EnvironmentSpec } from '../src/resources/environments/environments'; +import { verifyContextUrl } from './scm-auth'; +import { withCleanup } from './cleanup'; + +/** + * Examples: + * - yarn ts-node examples/run-service.ts + * - yarn ts-node examples/run-service.ts https://github.com/gitpod-io/empty + */ +async function main() { + const contextUrl = process.argv[2]; + + await withCleanup(async (disposables) => { + const client = new Gitpod({ + logLevel: 'info', + }); + + const envClass = await findMostUsedEnvironmentClass(client); + if (!envClass) { + console.error('Error: No environment class found. Please create one first.'); + process.exit(1); + } + console.log(`Found environment class: ${envClass.displayName} (${envClass.description})`); + + const port = 8888; + const spec: EnvironmentSpec = { + desiredPhase: 'ENVIRONMENT_PHASE_RUNNING', + machine: { class: envClass.id }, + ports: [ + { + name: 'Lama Service', + port, + admission: 'ADMISSION_LEVEL_EVERYONE', + }, + ], + }; + + if (contextUrl) { + await verifyContextUrl(client, contextUrl, envClass.runnerId); + spec.content = { + initializer: { + specs: [ + { + contextUrl: { + url: contextUrl, + }, + }, + ], + }, + }; + } + + console.log('Creating environment'); + const { environment } = await client.environments.create({ spec }); + disposables.add(() => client.environments.delete({ environmentId: environment.id })); + + console.log('Waiting for environment to be ready'); + const env = new EnvironmentState(client, environment.id); + disposables.add(() => env.close()); + await env.waitUntilRunning(); + + console.log('Starting Lama Service'); + const lines = await runService( + client, + environment.id, + { + name: 'Lama Service', + description: 'Lama Service', + reference: 'lama-service', + }, + { + commands: { + start: `curl lama.sh | LAMA_PORT=${port} sh`, + ready: `curl -s http://localhost:${port}`, + }, + }, + ); + + const portUrl = await env.waitForPortUrl(port); + console.log(`Lama Service is running at ${portUrl}`); + + for await (const line of lines) { + console.log(line); + } + }); +} + +main().catch((error) => { + console.error('Error:', error); + process.exit(1); +}); diff --git a/examples/scm-auth.ts b/examples/scm-auth.ts new file mode 100644 index 0000000..e4c57a7 --- /dev/null +++ b/examples/scm-auth.ts @@ -0,0 +1,222 @@ +import { APIError } from '../src'; +import { Gitpod } from '../src/client'; +import { setScmPat } from '../src/lib/runner'; +import { RunnerCheckAuthenticationForHostResponse } from '../src/resources/runners/runners'; + +/** + * Verify and handle authentication for a repository context URL. + * + * This function checks if the user has access to the specified repository and manages + * the authentication process if needed. Git access to the repository is required for + * environments to function properly. + * + * As an alternative, you can authenticate once via the Gitpod dashboard: + * 1. Start a new environment + * 2. Complete the browser-based authentication flow + * + * See https://www.gitpod.io/docs/flex/source-control for more details. + * + * @param client The Gitpod client instance + * @param contextUrl The context URL to verify + * @param runnerId The ID of the runner to verify access for + */ +export async function verifyContextUrl(client: Gitpod, contextUrl: string, runnerId: string): Promise { + const host = new URL(contextUrl).hostname; + if (!host) { + console.error('Error: Invalid context URL'); + process.exit(1); + } + + const user = (await client.users.getAuthenticatedUser({})).user; + + // Main authentication loop + let firstAttempt = true; + while (true) { + try { + // Try to access the context URL + await client.runners.parseContextURL({ contextUrl, runnerId }); + console.log('\n✓ Authentication verified successfully'); + return; + } catch (error) { + if (!(error instanceof APIError) || error.code !== 'failed_precondition') { + throw error; + } + } + + // Show authentication required message only on first attempt + if (firstAttempt) { + console.log(`\nAuthentication required for ${host}`); + firstAttempt = false; + } + + // Get authentication options for the host + const authResp = await client.runners.checkAuthenticationForHost({ + host, + runnerId, + }); + + // Handle re-authentication case + if (authResp.authenticated && !firstAttempt) { + console.log('\nIt looks like you are already authenticated.'); + const readline = require('readline').createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const answer = await new Promise((resolve) => { + readline.question('Would you like to re-authenticate? (y/n): ', (answer: string) => { + readline.close(); + resolve(answer.toLowerCase().trim()); + }); + }); + + if (answer !== 'y') { + console.log('\nAuthentication cancelled'); + process.exit(1); + } else { + console.log('\nRetrying authentication...'); + continue; + } + } + + const authMethods: Array<[string, string]> = []; + if (authResp.supportsOauth2) { + authMethods.push(['OAuth', 'Recommended']); + } + if (authResp.supportsPat) { + authMethods.push(['Personal Access Token (PAT)', '']); + } + + if (!authMethods.length) { + console.error(`\nError: No authentication method available for ${host}`); + process.exit(1); + } + + // Present authentication options + let methodIndex = 0; + if (authMethods.length > 1) { + console.log('\nAvailable authentication methods:'); + authMethods.forEach((method, index) => { + const [methodName, note] = method; + const noteText = note ? ` (${note})` : ''; + console.log(`${index + 1}. ${methodName}${noteText}`); + }); + + const readline = require('readline').createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const choice = await new Promise((resolve) => { + readline.question(`\nChoose authentication method (1-${authMethods.length}): `, (answer: string) => { + readline.close(); + resolve(answer.trim()); + }); + }); + + try { + methodIndex = parseInt(choice) - 1; + if (!(methodIndex >= 0 && methodIndex < authMethods.length)) { + throw new Error(); + } + } catch { + methodIndex = 0; // Default to OAuth if invalid input + } + } + + // Handle chosen authentication method + const method = authMethods[methodIndex]; + if (!method) { + throw new Error('Invalid authentication method'); + } + const [chosenMethod] = method; + + if (chosenMethod === 'Personal Access Token (PAT)') { + if (!authResp.supportsPat) { + throw new Error('PAT authentication not supported'); + } + await handlePatAuth(client, user.id, runnerId, host, authResp.supportsPat); + } else { + if (!authResp.supportsOauth2) { + throw new Error('OAuth authentication not supported'); + } + console.log('\nPlease visit the following URL to authenticate:'); + console.log(authResp.supportsOauth2.authUrl); + if (authResp.supportsOauth2.docsUrl) { + console.log(`\nFor detailed instructions, visit: ${authResp.supportsOauth2.docsUrl}`); + } + console.log('\nWaiting for authentication to complete...'); + + const readline = require('readline').createInterface({ + input: process.stdin, + output: process.stdout, + }); + + await new Promise((resolve) => { + readline.question('Press Enter after completing authentication in your browser...', () => { + readline.close(); + resolve(); + }); + }); + } + } +} + +/** + * Handle Personal Access Token (PAT) authentication for a source control host. + * @param client The Gitpod client instance + * @param userId ID of the user to set the token for + * @param runnerId ID of the runner to associate the token with + * @param host Source control host (e.g. github.com, gitlab.com) + * @param supportsPat PAT support information from the host + */ +async function handlePatAuth( + client: Gitpod, + userId: string, + runnerId: string, + host: string, + supportsPat: RunnerCheckAuthenticationForHostResponse.SupportsPat, +): Promise { + console.log('\nTo create a Personal Access Token:'); + + if (supportsPat.createUrl) { + console.log(`1. Visit: ${supportsPat.createUrl}`); + } else { + console.log(`1. Go to ${host} > Settings > Developer Settings`); + } + + if (supportsPat.requiredScopes && supportsPat.requiredScopes.length > 0) { + const requiredScopes = supportsPat.requiredScopes.join(', '); + console.log(`2. Create a new token with the following scopes: ${requiredScopes}`); + } else { + console.log('2. Create a new token'); + } + + if (supportsPat.example) { + console.log(`3. Copy the generated token (example: ${supportsPat.example})`); + } else { + console.log('3. Copy the generated token'); + } + + if (supportsPat.docsUrl) { + console.log(`\nFor detailed instructions, visit: ${supportsPat.docsUrl}`); + } + + const readline = require('readline').createInterface({ + input: process.stdin, + output: process.stdout, + }); + + const pat = await new Promise((resolve) => { + readline.question('\nEnter your Personal Access Token: ', (answer: string) => { + readline.close(); + resolve(answer.trim()); + }); + }); + + if (!pat) { + return; + } + + await setScmPat(client, userId, runnerId, host, pat); +} diff --git a/package.json b/package.json index 779a6bb..65113ef 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "@swc/jest": "^0.2.29", "@types/jest": "^29.4.0", "@types/node": "^20.17.6", - "typescript-eslint": "^8.24.0", + "@types/ssh2": "^1.15.4", + "@types/sshpk": "^1.17.4", "@typescript-eslint/eslint-plugin": "^8.24.0", "@typescript-eslint/parser": "^8.24.0", "eslint": "^9.20.1", @@ -40,11 +41,14 @@ "jest": "^29.4.0", "prettier": "^3.0.0", "publint": "^0.2.12", + "ssh2": "^1.16.0", + "sshpk": "^1.18.0", "ts-jest": "^29.1.0", "ts-node": "^10.5.0", "tsc-multi": "https://github.com/stainless-api/tsc-multi/releases/download/v1.1.3/tsc-multi.tgz", "tsconfig-paths": "^4.0.0", - "typescript": "^4.8.2" + "typescript": "^4.8.2", + "typescript-eslint": "^8.24.0" }, "imports": { "@gitpod/sdk": ".", diff --git a/src/lib/automation.ts b/src/lib/automation.ts new file mode 100644 index 0000000..d5e1d5f --- /dev/null +++ b/src/lib/automation.ts @@ -0,0 +1,204 @@ +import { Gitpod } from '../client'; +import { ServiceMetadata, ServiceSpec } from '../resources/environments/automations/services'; + +const TASK_REFERENCE = 'gitpod-typescript-sdk'; + +export async function runService( + client: Gitpod, + environmentId: string, + metadata: ServiceMetadata, + spec: ServiceSpec, +): Promise> { + const reference = metadata.reference; + if (!reference) { + throw new Error('metadata.reference is required'); + } + + const services = ( + await client.environments.automations.services.list({ + filter: { + references: [reference], + environmentIds: [environmentId], + }, + }) + ).services; + + let service = services[0]; + if (!service) { + const response = await client.environments.automations.services.create({ + environmentId, + spec, + metadata, + }); + service = response.service; + } + + await client.environments.automations.services.start({ id: service.id }); + const logUrl = await waitForServiceLogUrl(client, environmentId, service.id); + return streamLogs(client, environmentId, logUrl); +} + +export async function runCommand( + client: Gitpod, + environmentId: string, + command: string, +): Promise> { + const tasks = ( + await client.environments.automations.tasks.list({ + filter: { + references: [TASK_REFERENCE], + environmentIds: [environmentId], + }, + }) + ).tasks; + + let task = tasks[0]; + if (!task) { + const response = await client.environments.automations.tasks.create({ + spec: { command }, + environmentId, + metadata: { + name: 'Gitpod TypeScript SDK Task', + description: 'Gitpod TypeScript SDK Task', + reference: TASK_REFERENCE, + }, + }); + task = response.task; + } else { + await client.environments.automations.tasks.update({ + id: task.id, + spec: { command }, + }); + } + + const taskExecution = (await client.environments.automations.tasks.start({ id: task.id })).taskExecution; + const logUrl = await waitForTaskLogUrl(client, environmentId, taskExecution.id); + return streamLogs(client, environmentId, logUrl); +} + +async function waitForTaskLogUrl( + client: Gitpod, + environmentId: string, + taskExecutionId: string, +): Promise { + async function getLogUrl(): Promise { + const execution = ( + await client.environments.automations.tasks.executions.retrieve({ id: taskExecutionId }) + ).taskExecution; + if (!execution?.status) { + return undefined; + } + return execution.status.logUrl; + } + + return waitForLogUrl(client, environmentId, taskExecutionId, getLogUrl, 'RESOURCE_TYPE_TASK_EXECUTION'); +} + +async function waitForServiceLogUrl( + client: Gitpod, + environmentId: string, + serviceId: string, +): Promise { + async function getLogUrl(): Promise { + const service = (await client.environments.automations.services.retrieve({ id: serviceId })).service; + if (!service?.status) { + return undefined; + } + if (service.status.phase !== 'SERVICE_PHASE_RUNNING') { + return undefined; + } + return service.status.logUrl; + } + + return waitForLogUrl(client, environmentId, serviceId, getLogUrl, 'RESOURCE_TYPE_SERVICE'); +} + +async function waitForLogUrl( + client: Gitpod, + environmentId: string, + resourceId: string, + getLogUrlFn: () => Promise, + resourceType: string, +): Promise { + let logUrl = await getLogUrlFn(); + if (logUrl) { + return logUrl; + } + + const controller = new AbortController(); + const eventStream = await client.events.watch({ environmentId }, { signal: controller.signal }); + try { + logUrl = await getLogUrlFn(); + if (logUrl) { + return logUrl; + } + + for await (const event of eventStream) { + if (event.resourceType === resourceType && event.resourceId === resourceId) { + logUrl = await getLogUrlFn(); + if (logUrl) { + return logUrl; + } + } + } + } finally { + controller.abort(); + } + + throw new Error('Failed to get log URL'); +} + +async function* streamLogs(client: Gitpod, environmentId: string, logUrl: string): AsyncGenerator { + const logsAccessToken = (await client.environments.createLogsToken({ environmentId })).accessToken; + const headers = { Authorization: `Bearer ${logsAccessToken}` }; + + let retries = 3; + while (retries > 0) { + try { + const response = await fetch(logUrl, { headers }); + if (response.status === 502) { + retries--; + if (retries === 0) { + throw new Error('Failed to stream logs after 3 retries'); + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + continue; + } + + if (!response.body) { + throw new Error('No response body'); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + let newlineIndex; + while ((newlineIndex = buffer.indexOf('\n')) !== -1) { + const line = buffer.slice(0, newlineIndex); + buffer = buffer.slice(newlineIndex + 1); + if (line) { + yield line; + } + } + } + + if (buffer) { + yield buffer; + } + break; + } catch (error) { + if (retries > 0 && error instanceof Error && 'status' in error && error.status === 502) { + retries--; + await new Promise((resolve) => setTimeout(resolve, 1000)); + continue; + } + throw error; + } + } +} diff --git a/src/lib/disposables.ts b/src/lib/disposables.ts new file mode 100644 index 0000000..7e15b33 --- /dev/null +++ b/src/lib/disposables.ts @@ -0,0 +1,62 @@ +/** + * A utility class to manage cleanup actions (disposables) in a LIFO order. + * + * Example: + * ```typescript + * const disposables = new Disposables(); + * try { + * // Add cleanup actions + * disposables.add(() => cleanupSomething()); + * disposables.add(() => cleanupSomethingElse()); + * // Do work that needs cleanup + * doSomething(); + * doSomethingElse(); + * } finally { + * // Cleanup actions will be executed in reverse order + * await disposables.cleanup(); + * } + * ``` + */ +export class Disposables { + private actions: (() => any | Promise)[] = []; + + /** + * Add a cleanup action to be executed when cleanup is called. + * + * @param action A function that performs cleanup when called + */ + add(action: () => any | Promise): void { + this.actions.push(action); + } + + /** + * Execute all cleanup actions in reverse order. + * + * If any cleanup action raises an exception, it will be logged but won't prevent + * other cleanup actions from executing. + */ + async cleanup(): Promise { + for (const action of this.actions.reverse()) { + try { + await action(); + } catch (error) { + console.error('cleanup action failed:', error); + } + } + } + + /** + * Helper function to manage disposables with automatic cleanup. + * + * @param fn Function that receives disposables and returns a promise + * @returns The result of the function execution + */ + static async with(fn: (disposables: Disposables) => Promise): Promise { + const disposables = new Disposables(); + try { + return await fn(disposables); + } finally { + await disposables.cleanup(); + } + } +} diff --git a/src/lib/environment.ts b/src/lib/environment.ts new file mode 100644 index 0000000..959bac5 --- /dev/null +++ b/src/lib/environment.ts @@ -0,0 +1,266 @@ +import { Gitpod } from '../client'; +import { Environment } from '../resources/environments/environments'; +import { EnvironmentClass } from '../resources/shared'; + +/** + * Maintains the current state of an environment and updates it via event stream. + * Uses event emitter pattern for state updates. + */ +export class EnvironmentState { + private environment?: Environment; + private ready: Promise; + private resolveReady!: () => void; + private listeners: Array<(env: Environment) => void> = []; + private shouldStop = false; + private controller: AbortController | undefined; + private updateTask: Promise; + + constructor( + private client: Gitpod, + private environmentId: string, + ) { + this.ready = new Promise((resolve) => { + this.resolveReady = resolve; + }); + this.updateTask = this.startUpdateLoop(); + } + + async getEnvironment(): Promise { + await this.ready; + if (!this.environment) { + throw new Error('Environment not initialized'); + } + return this.environment; + } + + private async updateEnvironment(): Promise { + try { + const resp = await this.client.environments.retrieve({ environmentId: this.environmentId }); + const env = resp.environment; + this.environment = env; + this.resolveReady(); + + for (const listener of [...this.listeners]) { + try { + listener(env); + } catch (error) { + console.error('Failed to call listener:', error); + } + } + } catch (error) { + console.error('Failed to update environment:', error); + } + } + + private async startUpdateLoop(): Promise { + let retryDelay = 1000; // Initial retry delay in milliseconds + const maxDelay = 32000; // Maximum retry delay + + await this.updateEnvironment(); + + while (!this.shouldStop) { + try { + if (this.shouldStop) return; + + this.controller = new AbortController(); + const eventStream = await this.client.events.watch( + { + environmentId: this.environmentId, + }, + { + signal: this.controller.signal, + }, + ); + + retryDelay = 1000; // Reset delay on successful connection + if (this.shouldStop) return; + + await this.updateEnvironment(); + if (this.shouldStop) return; + + try { + for await (const event of eventStream) { + if (this.shouldStop) return; + + if ( + event.resourceType === 'RESOURCE_TYPE_ENVIRONMENT' && + event.resourceId === this.environmentId + ) { + await this.updateEnvironment(); + } + } + } finally { + this.controller?.abort(); + this.controller = undefined; + } + } catch (error) { + if (this.shouldStop || (error instanceof Error && error.name === 'AbortError')) { + return; + } + + console.error(`Error in event stream, retrying in ${retryDelay}ms:`, error); + await new Promise((resolve) => setTimeout(resolve, retryDelay)); + retryDelay = Math.min(retryDelay * 2, maxDelay); + } + } + } + + async close(): Promise { + this.shouldStop = true; + this.controller?.abort(); + await this.updateTask; + } + + async waitUntil(checkFn: (env: Environment) => T | undefined): Promise { + const initialEnv = await this.getEnvironment(); + const initialCheck = checkFn(initialEnv); + if (initialCheck !== undefined) { + return initialCheck; + } + + return new Promise((resolve) => { + const listener = (env: Environment) => { + const result = checkFn(env); + if (result !== undefined) { + this.listeners = this.listeners.filter((l) => l !== listener); + resolve(result); + } + }; + this.listeners.push(listener); + }); + } + + isRunning(env: Environment): boolean { + if (!env.status) { + return false; + } + + if (env.status.failureMessage) { + throw new Error(`Environment ${env.id} failed: ${env.status.failureMessage.join('; ')}`); + } else if ( + [ + 'ENVIRONMENT_PHASE_STOPPING', + 'ENVIRONMENT_PHASE_STOPPED', + 'ENVIRONMENT_PHASE_DELETING', + 'ENVIRONMENT_PHASE_DELETED', + ].includes(env.status.phase || '') + ) { + throw new Error(`Environment ${env.id} is in unexpected phase: ${env.status.phase}`); + } + + return env.status.phase === 'ENVIRONMENT_PHASE_RUNNING'; + } + + getSshUrl(env: Environment): string | undefined { + return env.status?.environmentUrls?.ssh?.url; + } + + getPortUrl(env: Environment, port: number): string | undefined { + return env.status?.environmentUrls?.ports?.find((p) => p.port === port)?.url; + } + + checkSshKeyApplied(env: Environment, keyId: string, keyValue: string): boolean { + if (!env.spec?.sshPublicKeys) { + return false; + } + + const key = env.spec.sshPublicKeys.find((k) => k.id === keyId); + if (!key) { + throw new Error(`SSH key '${keyId}' not found in environment spec`); + } + + if (key.value !== keyValue) { + throw new Error(`SSH key '${keyId}' has incorrect value`); + } + + if (!env.status?.sshPublicKeys) { + return false; + } + + const keyStatus = env.status.sshPublicKeys.find((ks) => ks.id === keyId); + if (!keyStatus) { + return false; + } + + if (keyStatus.phase === 'CONTENT_PHASE_FAILED') { + throw new Error(`SSH key '${keyId}' failed to apply`); + } + + return keyStatus.phase === 'CONTENT_PHASE_READY'; + } + + async waitUntilRunning(): Promise { + await this.waitUntil((env) => (this.isRunning(env) ? true : undefined)); + } + + async waitForSshUrl(): Promise { + const url = await this.waitUntil((env) => this.getSshUrl(env)); + return url; + } + + async waitForPortUrl(port: number): Promise { + const url = await this.waitUntil((env) => this.getPortUrl(env, port)); + return url; + } + + async waitForSshKeyApplied(keyId: string, keyValue: string): Promise { + await this.waitUntil((env) => (this.checkSshKeyApplied(env, keyId, keyValue) ? true : undefined)); + } +} + +export async function waitForEnvironmentRunning(client: Gitpod, environmentId: string): Promise { + const env = new EnvironmentState(client, environmentId); + try { + await env.waitUntilRunning(); + } finally { + await env.close(); + } +} + +export async function findMostUsedEnvironmentClass(client: Gitpod): Promise { + const classUsage = new Map(); + let envsResp = await client.environments.list({}); + + while (envsResp) { + for (const env of envsResp.environments) { + const envClass = env.spec?.machine?.class; + if (envClass) { + classUsage.set(envClass, (classUsage.get(envClass) || 0) + 1); + } + } + if (envsResp.pagination?.nextToken) { + envsResp = await client.environments.list({ token: envsResp.pagination.nextToken }); + } else { + break; + } + } + + const sortedClasses = Array.from(classUsage.entries()).sort(([, a], [, b]) => b - a); + const environmentClassId = sortedClasses[0]?.[0]; + if (!environmentClassId) { + return undefined; + } + + return findEnvironmentClassById(client, environmentClassId); +} + +export async function findEnvironmentClassById( + client: Gitpod, + environmentClassId: string, +): Promise { + let classesResp = await client.environments.classes.list({ filter: { canCreateEnvironments: true } }); + + while (classesResp) { + for (const cls of classesResp.environmentClasses) { + if (cls.id === environmentClassId) { + return cls; + } + } + if (classesResp.pagination?.nextToken) { + classesResp = await client.environments.classes.list({ token: classesResp.pagination.nextToken }); + } else { + break; + } + } + return undefined; +} diff --git a/src/lib/runner.ts b/src/lib/runner.ts new file mode 100644 index 0000000..d7775ec --- /dev/null +++ b/src/lib/runner.ts @@ -0,0 +1,45 @@ +import { Gitpod } from '../client'; + +/** + * Set a Personal Access Token (PAT) for source control authentication. + * + * This will delete any existing tokens for the given host and create a new one. + * + * @param client The Gitpod client instance + * @param userId ID of the user to set the token for + * @param runnerId ID of the runner to associate the token with + * @param host Source control host (e.g. github.com, gitlab.com) + * @param pat The Personal Access Token string + */ +export async function setScmPat( + client: Gitpod, + userId: string, + runnerId: string, + host: string, + pat: string, +): Promise { + const tokensResponse = await client.runners.configurations.hostAuthenticationTokens.list({ + filter: { + userId, + runnerId, + }, + }); + + if (tokensResponse?.tokens) { + for (const token of tokensResponse.tokens) { + if (token.host === host) { + await client.runners.configurations.hostAuthenticationTokens.delete({ + id: token.id, + }); + } + } + } + + await client.runners.configurations.hostAuthenticationTokens.create({ + token: pat, + host, + runnerId, + userId, + source: 'HOST_AUTHENTICATION_TOKEN_SOURCE_PAT', + }); +} diff --git a/yarn.lock b/yarn.lock index 30e61b1..6bd4ab2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -853,6 +853,13 @@ resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.2.tgz#423c77877d0569db20e1fc80885ac4118314010e" integrity sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA== +"@types/asn1@*": + version "0.2.4" + resolved "https://registry.yarnpkg.com/@types/asn1/-/asn1-0.2.4.tgz#a0f89f9ddad8186c9c081c5df2e5cade855d2ac0" + integrity sha512-V91DSJ2l0h0gRhVP4oBfBzRBN9lAbPUkGDMCnwedqPKX2d84aAMc9CulOvxdw1f7DfEYx99afab+Rsm3e52jhA== + dependencies: + "@types/node" "*" + "@types/babel__core@^7.1.14": version "7.20.5" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" @@ -937,6 +944,13 @@ dependencies: undici-types "~5.26.4" +"@types/node@^18.11.18": + version "18.19.76" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.76.tgz#7991658e0ba41ad30cc8be01c9bbe580d58f2112" + integrity sha512-yvR7Q9LdPz2vGpmpJX5LolrgRdWvB67MJKDPSgIIzpFbaf9a1j/f5DnLp5VDyHGMR0QZHlTr1afsD87QCXFHKw== + dependencies: + undici-types "~5.26.4" + "@types/node@^20.17.6": version "20.17.6" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.17.6.tgz#6e4073230c180d3579e8c60141f99efdf5df0081" @@ -944,6 +958,21 @@ dependencies: undici-types "~6.19.2" +"@types/ssh2@^1.15.4": + version "1.15.4" + resolved "https://registry.yarnpkg.com/@types/ssh2/-/ssh2-1.15.4.tgz#2347d2ff079e205b077c02407d822803bfd23c45" + integrity sha512-9JTQgVBWSgq6mAen6PVnrAmty1lqgCMvpfN+1Ck5WRUsyMYPa6qd50/vMJ0y1zkGpOEgLzm8m8Dx/Y5vRouLaA== + dependencies: + "@types/node" "^18.11.18" + +"@types/sshpk@^1.17.4": + version "1.17.4" + resolved "https://registry.yarnpkg.com/@types/sshpk/-/sshpk-1.17.4.tgz#239f86cc7f39c74285d4aea1cfee9fb3288f856f" + integrity sha512-5gI/7eJn6wmkuIuFY8JZJ1g5b30H9K5U5vKrvOuYu+hoZLb2xcVEgxhYZ2Vhbs0w/ACyzyfkJq0hQtBfSCugjw== + dependencies: + "@types/asn1" "*" + "@types/node" "*" + "@types/stack-utils@^2.0.0": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8" @@ -1153,6 +1182,18 @@ argparse@^2.0.1: resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== +asn1@^0.2.6, asn1@~0.2.3: + version "0.2.6" + resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.6.tgz#0d3a7bb6e64e02a90c0303b31f292868ea09a08d" + integrity sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ== + dependencies: + safer-buffer "~2.1.0" + +assert-plus@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" + integrity sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw== + babel-jest@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.7.0.tgz#f4369919225b684c56085998ac63dbd05be020d5" @@ -1218,6 +1259,13 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +bcrypt-pbkdf@^1.0.0, bcrypt-pbkdf@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" + integrity sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w== + dependencies: + tweetnacl "^0.14.3" + brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -1269,6 +1317,11 @@ buffer-from@^1.0.0: resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== +buildcheck@~0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/buildcheck/-/buildcheck-0.0.6.tgz#89aa6e417cfd1e2196e3f8fe915eb709d2fe4238" + integrity sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A== + callsites@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -1424,6 +1477,14 @@ convert-source-map@^2.0.0: resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== +cpu-features@~0.0.10: + version "0.0.10" + resolved "https://registry.yarnpkg.com/cpu-features/-/cpu-features-0.0.10.tgz#9aae536db2710c7254d7ed67cb3cbc7d29ad79c5" + integrity sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA== + dependencies: + buildcheck "~0.0.6" + nan "^2.19.0" + create-jest@^29.7.0: version "29.7.0" resolved "https://registry.yarnpkg.com/create-jest/-/create-jest-29.7.0.tgz#a355c5b3cb1e1af02ba177fe7afd7feee49a5320" @@ -1451,6 +1512,13 @@ cross-spawn@^7.0.3, cross-spawn@^7.0.6: shebang-command "^2.0.0" which "^2.0.1" +dashdash@^1.12.0: + version "1.14.1" + resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" + integrity sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g== + dependencies: + assert-plus "^1.0.0" + debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" @@ -1495,6 +1563,14 @@ diff@^4.0.1: resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== +ecc-jsbn@~0.1.1: + version "0.1.2" + resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" + integrity sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw== + dependencies: + jsbn "~0.1.0" + safer-buffer "^2.1.0" + electron-to-chromium@^1.4.601: version "1.4.614" resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.614.tgz#2fe789d61fa09cb875569f37c309d0c2701f91c0" @@ -1820,6 +1896,13 @@ get-stream@^6.0.0: resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== +getpass@^0.1.1: + version "0.1.7" + resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" + integrity sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng== + dependencies: + assert-plus "^1.0.0" + glob-parent@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" @@ -2447,6 +2530,11 @@ js-yaml@^4.1.0: dependencies: argparse "^2.0.1" +jsbn@~0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" + integrity sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg== + jsesc@^2.5.1: version "2.5.2" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" @@ -2665,6 +2753,11 @@ mz@^2.4.0: object-assign "^4.0.1" thenify-all "^1.0.0" +nan@^2.19.0, nan@^2.20.0: + version "2.22.0" + resolved "https://registry.yarnpkg.com/nan/-/nan-2.22.0.tgz#31bc433fc33213c97bad36404bb68063de604de3" + integrity sha512-nbajikzWTMwsW+eSsNm3QwlOs7het9gGJU5dDZzRTQGk03vyBOauxgI4VakDzE0PtsGTmXPsXTbbjVhRwR5mpw== + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" @@ -3015,7 +3108,7 @@ safe-buffer@~5.2.0: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== -"safer-buffer@>= 2.1.2 < 3.0.0": +"safer-buffer@>= 2.1.2 < 3.0.0", safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== @@ -3094,6 +3187,32 @@ sprintf-js@~1.0.2: resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== +ssh2@^1.16.0: + version "1.16.0" + resolved "https://registry.yarnpkg.com/ssh2/-/ssh2-1.16.0.tgz#79221d40cbf4d03d07fe881149de0a9de928c9f0" + integrity sha512-r1X4KsBGedJqo7h8F5c4Ybpcr5RjyP+aWIG007uBPRjmdQWfEiVLzSK71Zji1B9sKxwaCvD8y8cwSkYrlLiRRg== + dependencies: + asn1 "^0.2.6" + bcrypt-pbkdf "^1.0.2" + optionalDependencies: + cpu-features "~0.0.10" + nan "^2.20.0" + +sshpk@^1.18.0: + version "1.18.0" + resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.18.0.tgz#1663e55cddf4d688b86a46b77f0d5fe363aba028" + integrity sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ== + dependencies: + asn1 "~0.2.3" + assert-plus "^1.0.0" + bcrypt-pbkdf "^1.0.0" + dashdash "^1.12.0" + ecc-jsbn "~0.1.1" + getpass "^0.1.1" + jsbn "~0.1.0" + safer-buffer "^2.0.2" + tweetnacl "~0.14.0" + stack-utils@^2.0.3: version "2.0.6" resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.6.tgz#aaf0748169c02fc33c8232abccf933f54a1cc34f" @@ -3318,6 +3437,11 @@ tslib@^2.8.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== +tweetnacl@^0.14.3, tweetnacl@~0.14.0: + version "0.14.5" + resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" + integrity sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA== + type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"