diff --git a/package.json b/package.json index 73404e9f..f62fe6c7 100644 --- a/package.json +++ b/package.json @@ -338,6 +338,10 @@ { "command": "vscode-objectscript.openISCDocument", "when": "vscode-objectscript.connectActive && workspaceFolderCount != 0" + }, + { + "command": "vscode-objectscript.connectFolderToServerNamespace", + "when": "!vscode-objectscript.connectActive && vscode-objectscript.explorerRootCount == 0 && workspaceFolderCount != 0" } ], "view/title": [ diff --git a/src/api/index.ts b/src/api/index.ts index b3342f04..4bdc2073 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -140,6 +140,29 @@ export class AtelierAPI { this.namespace = namespace; } + /** + * Manually set the connection spec for this object, + * where `connSpec` is the return value of `getResolvedConnectionSpec()`. + */ + public setConnSpec(serverName: string, connSpec: any): void { + const { + webServer: { scheme, host, port, pathPrefix = "" }, + username, + password, + } = connSpec; + this._config.username = username; + this._config.password = password; + this._config.https = scheme == "https"; + this._config.host = host; + this._config.port = port; + this._config.pathPrefix = pathPrefix; + this._config.apiVersion = this._config.apiVersion || DEFAULT_API_VERSION; + this._config.serverVersion = this._config.serverVersion || DEFAULT_SERVER_VERSION; + this._config.docker = false; + this._config.active = true; + this._config.serverName = serverName; + } + public get active(): boolean { const { host = "", port = 0 } = this.config; return !!this._config.active && host.length > 0 && port > 0; @@ -185,7 +208,7 @@ export class AtelierAPI { this.configName = workspaceFolderName; const conn = config("conn", workspaceFolderName); let serverName = workspaceFolderName.toLowerCase(); - if (config("intersystems.servers").has(serverName)) { + if (config("intersystems.servers", workspaceFolderName).has(serverName)) { this.externalServer = true; } else if ( !(conn["docker-compose"] && extensionContext.extension.extensionKind !== vscode.ExtensionKind.Workspace) && diff --git a/src/commands/addServerNamespaceToWorkspace.ts b/src/commands/addServerNamespaceToWorkspace.ts index 6c4f50d4..038888aa 100644 --- a/src/commands/addServerNamespaceToWorkspace.ts +++ b/src/commands/addServerNamespaceToWorkspace.ts @@ -7,7 +7,7 @@ import { FILESYSTEM_SCHEMA, FILESYSTEM_READONLY_SCHEMA, filesystemSchemas, - smExtensionId, + serverManagerApi, } from "../extension"; import { cspAppsForUri, getWsFolder, handleError, notIsfs } from "../utils"; import { pickProject } from "./project"; @@ -18,7 +18,6 @@ import { isfsConfig, IsfsUriParam } from "../utils/FileProviderUtil"; * @returns An object containing `serverName` and `namespace`, or `undefined`. */ export async function pickServerAndNamespace(message?: string): Promise<{ serverName: string; namespace: string }> { - const serverManagerApi = await getServerManagerApi(); if (!serverManagerApi) { vscode.window.showErrorMessage( `${ @@ -168,22 +167,6 @@ export async function addServerNamespaceToWorkspace(resource?: vscode.Uri): Prom } } -export async function getServerManagerApi(): Promise { - const targetExtension = vscode.extensions.getExtension(smExtensionId); - if (!targetExtension) { - return undefined; - } - if (!targetExtension.isActive) { - await targetExtension.activate(); - } - const api = targetExtension.exports; - - if (!api) { - return undefined; - } - return api; -} - /** Prompt the user to fill in the `path` and `query` of `uri`. */ async function modifyWsFolderUri(uri: vscode.Uri): Promise { if (notIsfs(uri)) return; diff --git a/src/commands/connectFolderToServerNamespace.ts b/src/commands/connectFolderToServerNamespace.ts index 6b40ffb5..c0c241ee 100644 --- a/src/commands/connectFolderToServerNamespace.ts +++ b/src/commands/connectFolderToServerNamespace.ts @@ -1,7 +1,13 @@ import * as vscode from "vscode"; import { AtelierAPI } from "../api"; -import { panel, resolveConnectionSpec, getResolvedConnectionSpec, smExtensionId } from "../extension"; -import { notIsfs } from "../utils"; +import { + panel, + resolveConnectionSpec, + getResolvedConnectionSpec, + serverManagerApi, + resolveUsernameAndPassword, +} from "../extension"; +import { handleError, isUnauthenticated, notIsfs } from "../utils"; interface ConnSettings { server: string; @@ -14,7 +20,6 @@ export async function connectFolderToServerNamespace(): Promise { vscode.window.showErrorMessage("No folders in the workspace.", "Dismiss"); return; } - const serverManagerApi = await getServerManagerApi(); if (!serverManagerApi) { vscode.window.showErrorMessage( "Connecting a folder to a server namespace requires the [InterSystems Server Manager extension](https://marketplace.visualstudio.com/items?itemName=intersystems-community.servermanager) to be installed and enabled.", @@ -31,7 +36,10 @@ export async function connectFolderToServerNamespace(): Promise { return { label: folder.name, description: folder.uri.fsPath, - detail: !conn.server ? undefined : `Currently connected to ${conn.ns} on ${conn.server}`, + detail: + !conn.server || !conn.active + ? "No active server connection" + : `Currently connected to ${conn.ns} on ${conn.server}`, }; }); if (!items.length) { @@ -39,35 +47,61 @@ export async function connectFolderToServerNamespace(): Promise { return; } const pick = - items.length === 1 && !items[0].detail + items.length == 1 && !items[0].detail.startsWith("Currently") ? items[0] : await vscode.window.showQuickPick(items, { title: "Pick a folder" }); if (!pick) return; const folder = vscode.workspace.workspaceFolders.find((el) => el.name === pick.label); // Get user's choice of server const options: vscode.QuickPickOptions = {}; - const serverName: string = await serverManagerApi.pickServer(undefined, options); + const serverName: string = await serverManagerApi.pickServer(folder, options); if (!serverName) { return; } - // Get its namespace list - const uri = vscode.Uri.parse(`isfs://${serverName}/?ns=%SYS`); - await resolveConnectionSpec(serverName); + await resolveConnectionSpec(serverName, undefined, folder); // Prepare a displayable form of its connection spec as a hint to the user // This will never return the default value (second parameter) because we only just resolved the connection spec. const connSpec = getResolvedConnectionSpec(serverName, undefined); const connDisplayString = `${connSpec.webServer.scheme}://${connSpec.webServer.host}:${connSpec.webServer.port}/${connSpec.webServer.pathPrefix}`; // Connect and fetch namespaces - const api = new AtelierAPI(uri); - const allNamespaces: string[] | undefined = await api + const api = new AtelierAPI(vscode.Uri.parse(`isfs://${serverName}/?ns=%SYS`)); + const serverConf = vscode.workspace + .getConfiguration("intersystems", folder) + .inspect<{ [key: string]: any }>("servers"); + if ( + serverConf.workspaceFolderValue && + typeof serverConf.workspaceFolderValue[serverName] == "object" && + !(serverConf.workspaceValue && typeof serverConf.workspaceValue[serverName] == "object") + ) { + // Need to manually set connection info if the server is defined at the workspace folder level + api.setConnSpec(serverName, connSpec); + } + const allNamespaces: string[] = await api .serverInfo(false) .then((data) => data.result.content.namespaces) - .catch((reason) => { - // Notify user about serverInfo failure - vscode.window.showErrorMessage( - reason.message || `Failed to fetch namespace list from server at ${connDisplayString}`, - "Dismiss" - ); + .catch(async (error) => { + if (error?.statusCode == 401 && isUnauthenticated(api.config.username)) { + // Attempt to resolve username and password and try again + const newSpec = await resolveUsernameAndPassword(api.config.serverName, connSpec); + if (newSpec) { + // We were able to resolve credentials, so try again + api.setConnSpec(api.config.serverName, newSpec); + return api + .serverInfo(false) + .then((data) => data.result.content.namespaces) + .catch(async (err) => { + handleError(err, `Failed to fetch namespace list from server at ${connDisplayString}.`); + return undefined; + }); + } else { + handleError( + `Unauthenticated access rejected by '${api.serverId}'.`, + `Failed to fetch namespace list from server at ${connDisplayString}.` + ); + return undefined; + } + } + handleError(error, `Failed to fetch namespace list from server at ${connDisplayString}.`); return undefined; }); // Clear the panel entry created by the connection @@ -91,22 +125,34 @@ export async function connectFolderToServerNamespace(): Promise { } // Update folder's config object const config = vscode.workspace.getConfiguration("objectscript", folder); - const conn: any = config.inspect("conn").workspaceFolderValue; - await config.update("conn", { ...conn, server: serverName, ns: namespace, active: true }); -} - -async function getServerManagerApi(): Promise { - const targetExtension = vscode.extensions.getExtension(smExtensionId); - if (!targetExtension) { - return undefined; - } - if (!targetExtension.isActive) { - await targetExtension.activate(); - } - const api = targetExtension.exports; - - if (!api) { - return undefined; + if (vscode.workspace.workspaceFile && items.length == 1) { + // Ask the user if they want to enable the connection at the workspace or folder level. + // Only allow this when there is a single client-side folder in the workspace because + // the server may be configured at the workspace folder level. + const answer = await vscode.window.showQuickPick( + [ + { label: `Workspace Folder ${folder.name}`, detail: folder.uri.toString(true) }, + { label: "Workspace File", detail: vscode.workspace.workspaceFile.toString(true) }, + ], + { title: "Store the server connection at the workspace or folder level?" } + ); + if (!answer) return; + if (answer.label == "Workspace File") { + // Enable the connection at the workspace level + const conn: any = config.inspect("conn").workspaceValue; + await config.update( + "conn", + { ...conn, server: serverName, ns: namespace, active: true }, + vscode.ConfigurationTarget.Workspace + ); + return; + } } - return api; + // Enable the connection at the workspace folder level + const conn: any = config.inspect("conn").workspaceFolderValue; + await config.update( + "conn", + { ...conn, server: serverName, ns: namespace, active: true }, + vscode.ConfigurationTarget.WorkspaceFolder + ); } diff --git a/src/extension.ts b/src/extension.ts index 15397be6..1a98d43b 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -216,7 +216,7 @@ let reporter: TelemetryReporter; export let checkingConnection = false; -let serverManagerApi: serverManager.ServerManagerAPI; +export let serverManagerApi: serverManager.ServerManagerAPI; /** Map of the intersystems.server connection specs we have resolved via the API to that extension */ const resolvedConnSpecs = new Map(); @@ -227,7 +227,11 @@ const resolvedConnSpecs = new Map(); * @param serverName authority element of an isfs uri, or `objectscript.conn.server` property, or the name of a root folder with an `objectscript.conn.docker-compose` property object * @param uri if passed, re-check the `objectscript.conn.docker-compose` case in case servermanager API couldn't do that because we're still running our own `activate` method. */ -export async function resolveConnectionSpec(serverName: string, uri?: vscode.Uri): Promise { +export async function resolveConnectionSpec( + serverName: string, + uri?: vscode.Uri, + scope?: vscode.ConfigurationScope +): Promise { if (!serverManagerApi || !serverManagerApi.getServerSpec || !serverName) { return; } @@ -235,14 +239,14 @@ export async function resolveConnectionSpec(serverName: string, uri?: vscode.Uri // Already resolved return; } - if (!vscode.workspace.getConfiguration("intersystems.servers", null).has(serverName)) { + if (!vscode.workspace.getConfiguration("intersystems.servers", scope).has(serverName)) { // When not a defined server see it already resolved as a foldername that matches case-insensitively if (getResolvedConnectionSpec(serverName, undefined)) { return; } } - let connSpec = await serverManagerApi.getServerSpec(serverName); + let connSpec = await serverManagerApi.getServerSpec(serverName, scope); if (!connSpec && uri) { // Caller passed uri as a signal to process any docker-compose settings @@ -305,6 +309,24 @@ async function resolvePassword(serverSpec, ignoreUnauthenticated = false): Promi } } +/** Resolve credentials for `serverName` and returned the complete connection spec if successful */ +export async function resolveUsernameAndPassword(serverName: string, oldSpec: any): Promise { + const newSpec: { name: string; username?: string; password?: string } = { + name: serverName, + username: oldSpec?.username, + }; + await resolvePassword(newSpec, true); + if (newSpec.password) { + // Update the connection spec + resolvedConnSpecs.set(serverName, { + ...oldSpec, + username: newSpec.username, + password: newSpec.password, + }); + return resolvedConnSpecs.get(serverName); + } +} + /** Accessor for the cache of resolved connection specs */ export function getResolvedConnectionSpec(key: string, dflt: any): any { let spec = resolvedConnSpecs.get(key); @@ -462,25 +484,20 @@ export async function checkConnection( if (isUnauthenticated(username)) { vscode.window.showErrorMessage( `Unauthenticated access rejected by '${api.serverId}'.${ - !api.externalServer ? " Connection has been disabled." : "" + !api.config.serverName ? " Connection has been disabled." : "" }`, "Dismiss" ); - if (api.externalServer) { + if (api.config.serverName) { // Attempt to resolve a username and password - const newSpec: { name: string; username?: string; password?: string } = { - name: api.config.serverName, - username, - }; - await resolvePassword(newSpec, true); - if (newSpec.password) { - // Update the connection spec and try again + const oldSpec = getResolvedConnectionSpec( + api.config.serverName, + vscode.workspace.getConfiguration("intersystems.servers", uri).get(api.config.serverName) + ); + const newSpec = await resolveUsernameAndPassword(api.config.serverName, oldSpec); + if (newSpec) { + // We were able to resolve credentials, so try again await workspaceState.update(wsKey + ":password", newSpec.password); - resolvedConnSpecs.set(api.config.serverName, { - ...resolvedConnSpecs.get(api.config.serverName), - username: newSpec.username, - password: newSpec.password, - }); api = new AtelierAPI(apiTarget, false); await api .serverInfo(true, serverInfoTimeout)