diff --git a/package.json b/package.json index b8f2614..3755a0f 100644 --- a/package.json +++ b/package.json @@ -24,14 +24,19 @@ "contributes": { "commands": [ { - "command": "dbos-ttdbg.shutdown-debug-proxy", - "title": "Shutdown Debug Proxy", + "command": "dbos-ttdbg.cloud-login", + "title": "Log into DBOS Cloud", "category": "DBOS" }, { "command": "dbos-ttdbg.delete-prov-db-passwords", "title": "Delete Stored Provenance DB Passwords", "category": "DBOS" + }, + { + "command": "dbos-ttdbg.shutdown-debug-proxy", + "title": "Shutdown Debug Proxy", + "category": "DBOS" } ], "configuration": { diff --git a/src/commands.ts b/src/commands.ts index c678a75..c4b44c5 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -2,15 +2,22 @@ import * as vscode from 'vscode'; import { logger, config, provDB, debugProxy } from './extension'; import { DbosMethodType } from "./sourceParser"; import { stringify } from './utils'; +import { dbos_cloud_login } from './configuration'; +export const cloudLoginCommandName = "dbos-ttdbg.cloud-login"; export const startDebuggingCommandName = "dbos-ttdbg.startDebugging"; export const shutdownDebugProxyCommandName = "dbos-ttdbg.shutdown-debug-proxy"; -export const deleteProvenanceDatabasePasswordCommandName = "dbos-ttdbg.delete-prov-db-passwords"; +export const deleteProvDbPasswordsCommandName = "dbos-ttdbg.delete-prov-db-passwords"; export async function startDebugging(folder: vscode.WorkspaceFolder, name: string, $type: DbosMethodType) { try { const clientConfig = await config.getProvDbConfig(folder); - const statuses = await provDB.getWorkflowStatuses(clientConfig, name, $type); + if (!clientConfig) { return; } + + const statuses = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Window }, + () => { return provDB.getWorkflowStatuses(clientConfig, name, $type); }); + await debugProxy.launch(clientConfig); // TODO: eventually, we'll need a better UI than "list all workflow IDs and let the user pick one" @@ -40,9 +47,9 @@ export async function startDebugging(folder: vscode.WorkspaceFolder, name: strin } } -export async function shutdownDebugProxy() { +export function shutdownDebugProxy() { try { - await debugProxy.shutdown(); + debugProxy.shutdown(); } catch (e) { logger.error("shutdownDebugProxy", e); } @@ -54,4 +61,17 @@ export async function deleteProvenanceDatabasePasswords() { } catch (e) { logger.error("deleteProvenanceDatabasePasswords", e); } +} + +export async function cloudLogin() { + try { + const folders = vscode.workspace.workspaceFolders ?? []; + if (folders.length === 1) { + await dbos_cloud_login(folders[0]); + } else { + throw new Error("This command only works when exactly one workspace folder is open"); + } + } catch (e) { + logger.error("cloudLogin", e); + } } \ No newline at end of file diff --git a/src/configuration.ts b/src/configuration.ts index fe157b6..a7fe1a1 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; +import { SpawnOptions, spawn as cpSpawn } from "child_process"; import { ClientConfig } from 'pg'; -import * as fs from 'node:fs/promises'; import { execFile, exists } from './utils'; import { logger } from './extension'; @@ -11,31 +11,106 @@ const PROV_DB_DATABASE = "prov_db_database"; const PROV_DB_USER = "prov_db_user"; const DEBUG_PROXY_PORT = "debug_proxy_port"; -async function dbos_cloud_app_get(folder: vscode.WorkspaceFolder) { - const { stdout } = await execFile("npx", ["dbos-cloud", "application", "status", "--json"], { - cwd: folder.uri.fsPath, - }); - return JSON.parse(stdout) as { - Name: string; - ID: string; - PostgresInstanceName: string; - ApplicationDatabaseName: string; - Status: string; - Version: string; - }; +export interface DbosCloudApp { + Name: string; + ID: string; + PostgresInstanceName: string; + ApplicationDatabaseName: string; + Status: string; + Version: string; +} + +export interface DbosCloudDatabase { + PostgresInstanceName: string; + HostName: string; + Status: string; + Port: number; + AdminUsername: string; } -async function dbos_cloud_userdb_status(folder: vscode.WorkspaceFolder, databaseName: string) { - const { stdout } = await execFile("npx", ["dbos-cloud", "database", "status", databaseName, "--json"], { +async function dbos_cloud_cli(folder: vscode.WorkspaceFolder, ...args: string[]): Promise { + const { stdout } = await execFile("npx", ["dbos-cloud", ...args, "--json"], { cwd: folder.uri.fsPath, }); - return JSON.parse(stdout) as { - PostgresInstanceName: string; - HostName: string; - Status: string; - Port: number; - AdminUsername: string; - }; + return JSON.parse(stdout) as T; +} + +async function dbos_cloud_app_status(folder: vscode.WorkspaceFolder) { + return dbos_cloud_cli(folder, "application", "status"); +} + +async function dbos_cloud_db_status(folder: vscode.WorkspaceFolder, databaseName: string) { + return dbos_cloud_cli(folder, "database", "status", databaseName); +} + +export async function dbos_cloud_login(folder: vscode.WorkspaceFolder) { + logger.info("dbos_cloud_login", { folder: folder.uri.fsPath }); + + const cts = new vscode.CancellationTokenSource(); + const loginProc = cpSpawn("npx", ["dbos-cloud", "login"], { cwd: folder.uri.fsPath }); + const userCodeEmitter = new vscode.EventEmitter(); + + const regexLoginUrl = /Login URL: (http.*\/activate\?user_code=([A-Z][A-Z][A-Z][A-Z]-[A-Z][A-Z][A-Z][A-Z]))/; + const regexSuccessfulLogin = /Successfully logged in as (.*)!/; + + try { + const ctsPromise = new Promise(resolve => { + cts.token.onCancellationRequested(() => resolve()); + }); + + loginProc.on('exit', () => { logger.info("dbos-cloud login on exit"); cts.cancel(); }); + loginProc.on('close', () => { logger.info("dbos-cloud login on close"); cts.cancel(); }); + loginProc.on('error', err => { logger.error("dbos-cloud login on error", err); cts.cancel(); }); + + loginProc.stdout.on("data", async (buffer: Buffer) => { + const data = buffer.toString().trim(); + logger.info("dbos-cloud login stdout on data", { data }); + + const loginUrlMatch = regexLoginUrl.exec(data); + if (loginUrlMatch && loginUrlMatch.length === 3) { + const [, loginUrl, userCode] = loginUrlMatch; + logger.info("dbos-cloud login url", { loginUri: loginUrl, userCode }); + userCodeEmitter.fire(userCode); + + const openResult = await vscode.env.openExternal(vscode.Uri.parse(loginUrl)); + if (!openResult) { + logger.error("dbos_cloud_login openExternal failed", { loginUri: loginUrl, userCode }); + cts.cancel(); + } + } + + const successfulLoginMatch = regexSuccessfulLogin.exec(data); + if (successfulLoginMatch && successfulLoginMatch.length === 2) { + const [, user] = successfulLoginMatch; + logger.info("dbos-cloud login successful", { user }); + vscode.window.showInformationMessage(`Successfully logged in to DBOS Cloud as ${user}`); + } + }); + + await vscode.window.withProgress({ + cancellable: true, + location: vscode.ProgressLocation.Notification, + title: "Launching browser to log into DBOS Cloud" + }, async (progress, token) => { + userCodeEmitter.event(userCode => { + progress.report({ message: `\nUser code: ${userCode}` }); + }); + + token.onCancellationRequested(() => cts.cancel()); + await ctsPromise; + }); + } finally { + loginProc.stdout.removeAllListeners(); + loginProc.stderr.removeAllListeners(); + loginProc.removeAllListeners(); + + cts.dispose(); + userCodeEmitter.dispose(); + + const killed = loginProc.killed; + const killResult = loginProc.kill(); + logger.info("dbos_cloud_login exit", { killed, killResult }); + } } interface ExecFileError { @@ -55,10 +130,17 @@ function isExecFileError(e: unknown): e is ExecFileError { return false; } -async function getDbConfigFromDbosCloud(folder: vscode.WorkspaceFolder): Promise { +interface DatabaseConfig { + host: string | undefined; + port: number | undefined; + database: string | undefined; + user: string | undefined; +} + +async function getDbConfigFromDbosCloud(folder: vscode.WorkspaceFolder): Promise { try { - const app = await dbos_cloud_app_get(folder); - const db = await dbos_cloud_userdb_status(folder, app.PostgresInstanceName); + const app = await dbos_cloud_app_status(folder); + const db = await dbos_cloud_db_status(folder, app.PostgresInstanceName); return { host: db.HostName, port: db.Port, @@ -66,19 +148,15 @@ async function getDbConfigFromDbosCloud(folder: vscode.WorkspaceFolder): Promise user: db.AdminUsername }; } catch (e) { - if (isExecFileError(e)) { - if (e.stdout.trim().endsWith("Error: not logged in")) { - // TODO: initiate login - vscode.window.showErrorMessage("Not logged in to DBOS Cloud"); - } + if (isExecFileError(e) && e.stdout.includes("Error: not logged in")) { + return undefined; + } else { + throw e; } - - logger.error("getDbosCloudInfo", e); - return {}; } } -async function getDbConfigFromVSCodeConfig(folder: vscode.WorkspaceFolder): Promise { +function getDbConfigFromVSCodeConfig(folder: vscode.WorkspaceFolder): DatabaseConfig { const cfg = vscode.workspace.getConfiguration(TTDBG_CONFIG_SECTION, folder); const host = cfg.get(PROV_DB_HOST); @@ -94,19 +172,48 @@ async function getDbConfigFromVSCodeConfig(folder: vscode.WorkspaceFolder): Prom }; } +async function startInvalidCredentialsFlow(folder: vscode.WorkspaceFolder): Promise { + const credentialsPath = vscode.Uri.joinPath(folder.uri, ".dbos", "credentials"); + const credentialsExists = await exists(credentialsPath); + + // TODO: Register support + const result = await vscode.window.showWarningMessage( + "Invalid DBOS Cloud credentials", + "Login", "Cancel"); + + if (result === "Login") { + await dbos_cloud_login(folder); + } +} + export class Configuration { constructor(private readonly secrets: vscode.SecretStorage) { } - async getProvDbConfig(folder: vscode.WorkspaceFolder): Promise { + async getProvDbConfig(folder: vscode.WorkspaceFolder): Promise { + const dbConfig = await vscode.window.withProgress( + { location: vscode.ProgressLocation.Window }, + async () => { + const cloudConfig = await getDbConfigFromDbosCloud(folder); + const localConfig = getDbConfigFromVSCodeConfig(folder); - const cloudConfig = await getDbConfigFromDbosCloud(folder); - const localConfig = await getDbConfigFromVSCodeConfig(folder); + const host = localConfig?.host ?? cloudConfig?.host; + const port = localConfig?.port ?? cloudConfig?.port ?? 5432; + const database = localConfig?.database ?? cloudConfig?.database; + const user = localConfig?.user ?? cloudConfig?.user; + + return { host, port, database, user }; + }); + + if (!dbConfig.host || !dbConfig.database || !dbConfig.user) { + startInvalidCredentialsFlow(folder).catch(e => logger.error("startInvalidCredentialsFlow", e)); + return undefined; + } return { - host: localConfig.host ?? cloudConfig.host, - port: localConfig.port ?? cloudConfig.port, - database: localConfig.database ?? cloudConfig.database, - user: localConfig.user ?? cloudConfig.user, + host: dbConfig.host, + port: dbConfig.port, + database: dbConfig.database, + user: dbConfig.user, password: () => this.#getPassword(folder), ssl: { rejectUnauthorized: false, diff --git a/src/extension.ts b/src/extension.ts index 2e551c0..b61977f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import { S3CloudStorage } from './CloudStorage'; import { TTDbgCodeLensProvider } from './codeLensProvider'; -import { deleteProvenanceDatabasePasswords, deleteProvenanceDatabasePasswordCommandName as deleteProvDbPasswordsCommandName, startDebuggingCommandName, startDebugging, shutdownDebugProxyCommandName, shutdownDebugProxy } from './commands'; +import { deleteProvenanceDatabasePasswords, deleteProvDbPasswordsCommandName, startDebuggingCommandName, startDebugging, shutdownDebugProxyCommandName, shutdownDebugProxy, cloudLoginCommandName, cloudLogin } from './commands'; import { Configuration } from './configuration'; import { DebugProxy, } from './DebugProxy'; import { LogOutputChannelTransport, Logger, createLogger } from './logger'; @@ -35,6 +35,8 @@ export async function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand(shutdownDebugProxyCommandName, shutdownDebugProxy)); context.subscriptions.push( vscode.commands.registerCommand(deleteProvDbPasswordsCommandName, deleteProvenanceDatabasePasswords)); + context.subscriptions.push( + vscode.commands.registerCommand(cloudLoginCommandName, cloudLogin)); context.subscriptions.push( vscode.languages.registerCodeLensProvider(