diff --git a/AGENTS.md b/AGENTS.md index 31b405bd15..b0c261e588 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ Start with the owning directory and its colocated tests before searching broadly | Environment snapshots & `requirements.txt` generation | `src/notebooks/deepnote/snapshots/` | | Database integrations (credentials, env refresh, webview) | `src/notebooks/deepnote/integrations/`, `src/webviews/webview-side/integrations/` | | Kernel selection, SQL LSP, server startup | `src/kernels/deepnote/` | +| Deepnote Cloud API token, sync and publishing | `src/notebooks/deepnote/cloud/` | | Shared Deepnote types & platform config | `src/platform/deepnote/`, `src/platform/notebooks/deepnote/` | | Core serializer architecture & data flow | `specs/architecture.md` | | Kernel management internals | `specs/DEBUGGING_KERNEL_MANAGEMENT.md`, `specs/DEEPNOTE_KERNEL_IMPLEMENTATION.md` | diff --git a/README.md b/README.md index 693e6dd010..6e95e1fad2 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,8 @@ Open the Command Palette (`Cmd+Shift+P` or `Ctrl+Shift+P`) and type `Deepnote` t | `Deepnote: Manage Integrations` | Configure database connections and credentials | | `Deepnote: New Project` | Create a new Deepnote project | | `Deepnote: Import Notebook` | Import an existing notebook into your project | +| `Deepnote: Set API Token` | Save a Deepnote API token to `.env` for syncing and publishing | +| `Deepnote: Clear API Token` | Remove the saved Deepnote API token | | `Notebook: Select Notebook Kernel` | Select or switch kernels within your notebook | | `Notebook: Change Cell Language` | Change the language of the cell currently in focus | | `Deepnote: Enable Snapshots` | Enable snapshot mode for the current workspace | diff --git a/package.json b/package.json index 158521f6c9..d3d4dec271 100644 --- a/package.json +++ b/package.json @@ -132,6 +132,18 @@ "category": "Deepnote", "icon": "$(globe)" }, + { + "command": "deepnote.setApiToken", + "title": "%deepnote.commands.setApiToken.title%", + "category": "Deepnote", + "enablement": "isWorkspaceTrusted && !deepnote.webExtension" + }, + { + "command": "deepnote.clearApiToken", + "title": "%deepnote.commands.clearApiToken.title%", + "category": "Deepnote", + "enablement": "isWorkspaceTrusted && !deepnote.webExtension" + }, { "command": "deepnote.newProject", "title": "%deepnote.commands.newProject.title%", diff --git a/package.nls.json b/package.nls.json index 388ab8d32c..df6c9d3ae6 100644 --- a/package.nls.json +++ b/package.nls.json @@ -259,6 +259,8 @@ "deepnote.commands.manageIntegrations.title": "Manage Integrations", "deepnote.commands.addExistingIntegration.title": "Add Existing Integration", "deepnote.commands.authenticateIntegration.title": "Authenticate Integration", + "deepnote.commands.setApiToken.title": "Set API Token", + "deepnote.commands.clearApiToken.title": "Clear API Token", "deepnote.commands.newProject.title": "New Project", "deepnote.commands.importNotebook.title": "Import Notebook", "deepnote.commands.importJupyterNotebook.title": "Import Jupyter Notebook", diff --git a/src/commands.ts b/src/commands.ts index 363547f50f..7fb68d68f0 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -210,4 +210,6 @@ export interface ICommandNameArgumentTypeMapping { [DSCommands.DuplicateNotebook]: []; [DSCommands.AddNotebookToProject]: []; [DSCommands.OpenInDeepnote]: []; + [DSCommands.SetApiToken]: [Uri | undefined]; + [DSCommands.ClearApiToken]: []; } diff --git a/src/notebooks/deepnote/cloud/cloudErrors.node.ts b/src/notebooks/deepnote/cloud/cloudErrors.node.ts new file mode 100644 index 0000000000..bc7f68d052 --- /dev/null +++ b/src/notebooks/deepnote/cloud/cloudErrors.node.ts @@ -0,0 +1,39 @@ +import { ApiError } from '@deepnote/cloud-sync'; +import { commands, Uri, window } from 'vscode'; + +import { Commands } from '../../../platform/common/constants'; +import { DeepnoteCloud } from '../../../platform/common/utils/localize'; +import { logger } from '../../../platform/logging'; + +const UNAUTHORIZED_STATUS = 401; + +/** + * Whether Deepnote answered 401, meaning the token is missing, invalid or revoked. A 403 is not matched: the token was + * accepted, but the request is not allowed. + */ +export function isRejectedTokenError(error: unknown): boolean { + return error instanceof ApiError && error.statusCode === UNAUTHORIZED_STATUS; +} + +/** + * Logs a failed Deepnote Cloud call and tells the user. A rejected token offers "Set API Token" for `resource` and + * resolves once that prompt is dismissed; any other failure shows `: ` and resolves immediately. + */ +export async function showCloudError(message: string, error: unknown, resource?: Uri): Promise { + logger.error(message, error); + + if (isRejectedTokenError(error)) { + const choice = await window.showErrorMessage( + DeepnoteCloud.apiTokenRejectedForRequest, + DeepnoteCloud.setApiToken + ); + if (choice === DeepnoteCloud.setApiToken) { + await commands.executeCommand(Commands.SetApiToken, resource); + } + + return; + } + + const detail = error instanceof Error ? error.message : String(error); + void window.showErrorMessage(DeepnoteCloud.errorWithDetail(message, detail)); +} diff --git a/src/notebooks/deepnote/cloud/cloudErrors.node.unit.test.ts b/src/notebooks/deepnote/cloud/cloudErrors.node.unit.test.ts new file mode 100644 index 0000000000..8f97bdc761 --- /dev/null +++ b/src/notebooks/deepnote/cloud/cloudErrors.node.unit.test.ts @@ -0,0 +1,97 @@ +import { ApiError } from '@deepnote/cloud-sync'; +import { assert } from 'chai'; +import { anything, verify, when } from 'ts-mockito'; +import { Uri } from 'vscode'; + +import { DeepnoteCloud } from '../../../platform/common/utils/localize'; +import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../../test/vscode-mock'; +import { isRejectedTokenError, showCloudError } from './cloudErrors.node'; + +suite('cloudErrors', () => { + setup(() => { + resetVSCodeMocks(); + when(mockedVSCodeNamespaces.commands.executeCommand(anything(), anything())).thenResolve(undefined); + }); + + suite('isRejectedTokenError', () => { + test('is true for an ApiError with status 401', () => { + assert.isTrue(isRejectedTokenError(new ApiError(401, 'Unauthorized'))); + }); + + test('is false for an ApiError with another status, including 403', () => { + assert.isFalse(isRejectedTokenError(new ApiError(403, 'Forbidden'))); + assert.isFalse(isRejectedTokenError(new ApiError(500, 'Boom'))); + }); + + test('is false for errors that are not an ApiError', () => { + assert.isFalse(isRejectedTokenError(new Error('offline'))); + assert.isFalse(isRejectedTokenError({ statusCode: 401 })); + assert.isFalse(isRejectedTokenError('401')); + assert.isFalse(isRejectedTokenError(undefined)); + }); + }); + + suite('showCloudError', () => { + test('offers Set API Token for a rejected token, and runs the command for the resource when chosen', async () => { + const resource = Uri.file('/r'); + when( + mockedVSCodeNamespaces.window.showErrorMessage( + DeepnoteCloud.apiTokenRejectedForRequest, + DeepnoteCloud.setApiToken + ) + ).thenResolve(DeepnoteCloud.setApiToken as any); + + await showCloudError( + 'Sync failed', + new ApiError(401, 'Authentication failed. Please check your API token.'), + resource + ); + + verify( + mockedVSCodeNamespaces.window.showErrorMessage( + DeepnoteCloud.apiTokenRejectedForRequest, + DeepnoteCloud.setApiToken + ) + ).once(); + verify(mockedVSCodeNamespaces.commands.executeCommand('deepnote.setApiToken', resource)).once(); + verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).never(); + }); + + test('does not run the command when the rejected-token prompt is dismissed', async () => { + await showCloudError('Sync failed', new ApiError(401, 'Unauthorized'), Uri.file('/r')); + + verify(mockedVSCodeNamespaces.commands.executeCommand(anything(), anything())).never(); + verify(mockedVSCodeNamespaces.window.showErrorMessage(anything())).never(); + }); + + test('shows the server message without a button for a 403', async () => { + await showCloudError('Publish failed', new ApiError(403, 'Insufficient permissions to publish this app.')); + + verify( + mockedVSCodeNamespaces.window.showErrorMessage( + 'Publish failed: Insufficient permissions to publish this app.' + ) + ).once(); + verify(mockedVSCodeNamespaces.window.showErrorMessage(anything(), anything())).never(); + }); + + test('shows the message of any other ApiError', async () => { + await showCloudError('Sync failed', new ApiError(500, 'Boom')); + + verify(mockedVSCodeNamespaces.window.showErrorMessage('Sync failed: Boom')).once(); + }); + + test('shows the message of a plain Error without a button', async () => { + await showCloudError('Sync failed', new Error('offline')); + + verify(mockedVSCodeNamespaces.window.showErrorMessage('Sync failed: offline')).once(); + verify(mockedVSCodeNamespaces.window.showErrorMessage(anything(), anything())).never(); + }); + + test('stringifies a thrown value that is not an Error', async () => { + await showCloudError('Sync failed', 'timed out'); + + verify(mockedVSCodeNamespaces.window.showErrorMessage('Sync failed: timed out')).once(); + }); + }); +}); diff --git a/src/notebooks/deepnote/cloud/deepnoteApiTokenService.node.ts b/src/notebooks/deepnote/cloud/deepnoteApiTokenService.node.ts new file mode 100644 index 0000000000..3b531c7fb2 --- /dev/null +++ b/src/notebooks/deepnote/cloud/deepnoteApiTokenService.node.ts @@ -0,0 +1,491 @@ +import { getWorkspace, type Workspace } from '@deepnote/cloud'; +import { findSyncManifestRoot } from '@deepnote/cloud-sync'; +import { isUtf8 } from 'buffer'; +import dotenv from 'dotenv'; +import * as fs from 'fs'; +import { inject, injectable } from 'inversify'; +import { isDeepStrictEqual } from 'util'; +import { commands, extensions, ProgressLocation, Uri, window, workspace } from 'vscode'; + +import { IExtensionSyncActivationService } from '../../../platform/activation/types'; +import { Commands } from '../../../platform/common/constants'; +import { isFileNotFoundError } from '../../../platform/common/platform/errors'; +import { getFilePath } from '../../../platform/common/platform/fs-paths'; +import { IExtensionContext } from '../../../platform/common/types'; +import { DeepnoteCloud } from '../../../platform/common/utils/localize'; +import { logger } from '../../../platform/logging'; +import { + dirname, + extUriBiasedIgnorePathCase, + isEqual, + removeTrailingPathSeparator +} from '../../../platform/vscode-path/resources'; +import { getApiEndpoint } from '../importClient.node'; +import { isRejectedTokenError } from './cloudErrors.node'; +import { IDeepnoteApiTokenService } from './types'; + +const TOKEN_ENV_VAR = 'DEEPNOTE_TOKEN'; +const ENV_FILE_NAME = '.env'; +const GITIGNORE_FILE_NAME = '.gitignore'; + +// Both assignment forms `dotenv.parse` accepts: `KEY=value` and `KEY: value`, with an optional `export `. +const TOKEN_LINE_PATTERN = /^[ \t]*(?:export[ \t]+)?DEEPNOTE_TOKEN(?:[ \t]*=|:[ \t]).*$/; + +// What a `.env` line or a shell would split or unquote on, plus control characters: `fetch` rejects a NUL in a header +// with an error that quotes the whole `Authorization` value, token included. +const INVALID_TOKEN_CHARACTERS = /[\s"'#\p{Cc}]/u; +const BYTE_ORDER_MARK = '\uFEFF'; + +// Minimal shape of the members used from the built-in Git extension, which ships no types. +interface GitRepository { + checkIgnore?(paths: string[]): Promise>; +} +interface GitExtensionExports { + getAPI(version: 1): { getRepository(uri: Uri): GitRepository | null }; +} + +function withoutQueryAndFragment(uri: Uri): Uri { + return uri.with({ query: '', fragment: '' }); +} + +async function getStartFolder(resource: Uri): Promise { + const stats = await fs.promises.stat(getFilePath(resource)).catch(() => undefined); + + return stats?.isDirectory() ? resource : dirname(resource); +} + +async function readFileBytes(filePath: string): Promise { + try { + return await fs.promises.readFile(filePath); + } catch (error) { + if (error instanceof Error && isFileNotFoundError(error)) { + return undefined; + } + + throw error; + } +} + +async function readTextFile(filePath: string): Promise { + return (await readFileBytes(filePath))?.toString('utf8'); +} + +/** Throws for a file that is not UTF-8: writing back its lossy decode would replace every such byte with U+FFFD. */ +async function readTextFileToRewrite(filePath: string): Promise { + const bytes = await readFileBytes(filePath); + if (bytes && !isUtf8(bytes)) { + throw new Error(`${filePath} is not valid UTF-8`); + } + + return bytes?.toString('utf8'); +} + +function detectLineEnding(content: string): string { + return content.includes('\r\n') ? '\r\n' : '\n'; +} + +/** Splits into lines without the terminator of the last one, so a trailing line ending is not a blank line. */ +function splitLines(content: string): string[] { + const lines = content.split(/\r?\n/); + if (lines[lines.length - 1] === '') { + lines.pop(); + } + + return lines; +} + +function joinLines(lines: string[], lineEnding: string): string { + return lines.map((line) => line + lineEnding).join(''); +} + +function withToken(content: string, token: string): string { + const assignment = `${TOKEN_ENV_VAR}=${token}`; + const lines = splitLines(content); + const first = lines.findIndex((line) => TOKEN_LINE_PATTERN.test(line)); + const result = + first === -1 + ? [...lines, assignment] + : lines.flatMap((line, index) => { + if (index === first) { + return [assignment]; + } + + return TOKEN_LINE_PATTERN.test(line) ? [] : [line]; + }); + + return joinLines(result, detectLineEnding(content)); +} + +function withoutToken(content: string): string { + return joinLines( + splitLines(content).filter((line) => !TOKEN_LINE_PATTERN.test(line)), + detectLineEnding(content) + ); +} + +function otherVariables(content: string): Record { + const variables = dotenv.parse(content); + delete variables[TOKEN_ENV_VAR]; + + return variables; +} + +/** + * Whether `after` is `before` with only `DEEPNOTE_TOKEN` changed to `token` (absent when `token` is `undefined`), as + * `dotenv.parse` reads them. The line-based edit cannot tell a multi-line quoted value from separate assignments, so + * this is what stops it from corrupting another variable. + */ +function isSafeEnvEdit(before: string, after: string, token: string | undefined): boolean { + return ( + isDeepStrictEqual(otherVariables(before), otherVariables(after)) && dotenv.parse(after)[TOKEN_ENV_VAR] === token + ); +} + +@injectable() +export class DeepnoteApiTokenService implements IDeepnoteApiTokenService, IExtensionSyncActivationService { + constructor(@inject(IExtensionContext) private readonly extensionContext: IExtensionContext) {} + + public activate(): void { + this.extensionContext.subscriptions.push( + commands.registerCommand(Commands.SetApiToken, async (resource?: Uri) => { + await this.promptAndSaveToken(resource); + }), + commands.registerCommand(Commands.ClearApiToken, () => this.clearToken()) + ); + } + + public async getOrPromptToken(resource: Uri): Promise { + if (!this.ensureTrusted()) { + return undefined; + } + + if (resource.scheme !== 'file') { + void window.showErrorMessage(DeepnoteCloud.localFilesOnly); + + return undefined; + } + + return (await this.getToken(resource)) ?? (await this.promptAndSaveToken(resource)); + } + + public async getToken(resource: Uri): Promise { + if (!workspace.isTrusted || resource.scheme !== 'file') { + return undefined; + } + + for (const dir of await this.getLookupDirs(resource)) { + const token = (await this.readEnvFile(dir))?.[TOKEN_ENV_VAR]?.trim(); + if (token) { + return token; + } + } + + return undefined; + } + + /** Seam for tests, which never reach the network. */ + protected fetchWorkspace(baseUrl: string, token: string): Promise { + return getWorkspace(baseUrl, token); + } + + /** `undefined` means the Git extension gave no answer. Never throws. */ + protected async isIgnoredByGit(envUri: Uri): Promise { + try { + const extension = extensions.getExtension('vscode.git'); + if (!extension) { + return undefined; + } + + const exports = extension.isActive ? extension.exports : await extension.activate(); + const repository = exports.getAPI(1).getRepository(envUri); + if (!repository || typeof repository.checkIgnore !== 'function') { + return undefined; + } + + return (await repository.checkIgnore([getFilePath(envUri)])).size > 0; + } catch (error) { + logger.debug('The Git extension could not tell whether the .env file is ignored', error); + + return undefined; + } + } + + private async addToGitignore(folder: Uri): Promise { + const gitignorePath = getFilePath(Uri.joinPath(folder, GITIGNORE_FILE_NAME)); + // git reads .gitignore as bytes, and latin1 maps every byte to one character and back, so a file in any + // encoding gets its new line without its other bytes being rewritten. + const before = (await readFileBytes(gitignorePath))?.toString('latin1') ?? ''; + + await fs.promises.writeFile( + gitignorePath, + joinLines([...splitLines(before), ENV_FILE_NAME], detectLineEnding(before)), + 'latin1' + ); + } + + /** Never throws. */ + private async checkEnvFileInGit(envUri: Uri): Promise { + try { + if ((await this.isIgnoredByGit(envUri)) !== false) { + return; + } + + const choice = await window.showWarningMessage( + DeepnoteCloud.envNotIgnored(workspace.asRelativePath(envUri)), + { modal: true }, + DeepnoteCloud.addToGitignore + ); + if (choice === DeepnoteCloud.addToGitignore) { + await this.addToGitignore(dirname(envUri)); + } + } catch (error) { + logger.warn('Could not add .env to .gitignore', error); + } + } + + /** Resolves `undefined` after telling the user why the token was not accepted. */ + private async checkToken(start: Uri, token: string): Promise<{ workspaceName?: string } | undefined> { + try { + const deepnoteWorkspace = await window.withProgress( + { location: ProgressLocation.Notification, title: DeepnoteCloud.checkingApiToken }, + () => this.fetchWorkspace(getApiEndpoint(start), token) + ); + + return { workspaceName: deepnoteWorkspace?.name }; + } catch (error) { + logger.warn('Could not check the Deepnote API token', error); + void window.showErrorMessage( + isRejectedTokenError(error) + ? DeepnoteCloud.apiTokenRejected + : DeepnoteCloud.apiTokenCheckFailed(error instanceof Error ? error.message : String(error)) + ); + + return undefined; + } + } + + private async clearToken(): Promise { + if (!this.ensureTrusted()) { + return; + } + + const start = await this.resolveStart(); + if (!start) { + return; + } + + const envFiles = await this.findAssigningEnvFiles(await this.getLookupDirs(start)); + if (envFiles.length === 0) { + void window.showInformationMessage(DeepnoteCloud.noApiTokenFound); + + return; + } + + let cleared = false; + for (const envUri of envFiles) { + cleared = (await this.editEnvFile(envUri, withoutToken, undefined)) || cleared; + } + + if (cleared) { + void window.showInformationMessage(DeepnoteCloud.apiTokenCleared); + } + } + + /** + * Rewrites `envUri` through `edit`, creating it when missing. Writes nothing and tells the user when the edit is + * not safe or the file cannot be read or written. + */ + private async editEnvFile( + envUri: Uri, + edit: (content: string) => string, + expectedToken: string | undefined + ): Promise { + const envPath = getFilePath(envUri); + try { + const before = (await readTextFileToRewrite(envPath)) ?? ''; + // dotenv reads past a byte order mark, TOKEN_LINE_PATTERN does not, so edit around it. + const bom = before.startsWith(BYTE_ORDER_MARK) ? BYTE_ORDER_MARK : ''; + const after = bom + edit(before.slice(bom.length)); + if (isSafeEnvEdit(before, after, expectedToken)) { + await fs.promises.writeFile(envPath, after); + + return true; + } + } catch (error) { + logger.warn(`Could not update ${envPath}`, error); + } + + void window.showErrorMessage(DeepnoteCloud.envEditFailed(workspace.asRelativePath(envUri))); + + return false; + } + + private ensureTrusted(): boolean { + if (workspace.isTrusted) { + return true; + } + + void window.showErrorMessage(DeepnoteCloud.workspaceNotTrusted); + + return false; + } + + /** The `.env` files among `dirs` that assign `DEEPNOTE_TOKEN`, even to an empty value, nearest first. */ + private async findAssigningEnvFiles(dirs: Uri[]): Promise { + const envFiles: Uri[] = []; + for (const dir of dirs) { + if ((await this.readEnvFile(dir))?.[TOKEN_ENV_VAR] !== undefined) { + envFiles.push(Uri.joinPath(dir, ENV_FILE_NAME)); + } + } + + return envFiles; + } + + private async findSyncRoot(folder: Uri): Promise { + try { + const root = await findSyncManifestRoot(getFilePath(folder)); + + return root ? Uri.file(root) : undefined; + } catch (error) { + logger.debug(`Could not look for a sync root above ${getFilePath(folder)}`, error); + + return undefined; + } + } + + /** The folders to search for a `.env`, nearest first, from the folder of `resource` up to the folder it is rooted in. */ + private async getLookupDirs(resource: Uri): Promise { + const start = withoutQueryAndFragment(resource); + const startFolder = await getStartFolder(start); + const stopFolder = removeTrailingPathSeparator(await this.getStopFolder(start, startFolder)); + + // VS Code matches workspace folders case-insensitively on macOS and Windows and keeps a trailing slash from a + // `.code-workspace` file. A stop folder that is not above `startFolder`, such as a workspace folder missing on + // disk, must end the search, not widen it to the filesystem root. + const dirs: Uri[] = []; + for (let dir = startFolder; extUriBiasedIgnorePathCase.isEqualOrParent(dir, stopFolder); dir = dirname(dir)) { + dirs.push(dir); + if (extUriBiasedIgnorePathCase.isEqual(dir, stopFolder) || isEqual(dirname(dir), dir)) { + break; + } + } + + return dirs; + } + + /** The higher of the workspace folder root and the sync root, else `startFolder`. */ + private async getStopFolder(start: Uri, startFolder: Uri): Promise { + const workspaceFolder = workspace.getWorkspaceFolder(start)?.uri; + const syncRoot = await this.findSyncRoot(startFolder); + if (workspaceFolder && syncRoot) { + return extUriBiasedIgnorePathCase.isEqualOrParent(syncRoot, workspaceFolder) ? workspaceFolder : syncRoot; + } + + return workspaceFolder ?? syncRoot ?? startFolder; + } + + private async pickWorkspaceFolder(): Promise { + const folders = workspace.workspaceFolders ?? []; + if (folders.length === 0) { + void window.showErrorMessage(DeepnoteCloud.openFolderFirst); + + return undefined; + } + + const folder = folders.length === 1 ? folders[0] : await window.showWorkspaceFolderPick(); + if (folder && folder.uri.scheme !== 'file') { + void window.showErrorMessage(DeepnoteCloud.localFilesOnly); + + return undefined; + } + + return folder?.uri; + } + + private async promptAndSaveToken(resource?: Uri): Promise { + if (!this.ensureTrusted()) { + return undefined; + } + + const start = await this.resolveStart(resource); + if (!start) { + return undefined; + } + + const target = await this.resolveTargetFolder(start); + const token = await this.promptForToken(); + if (!token) { + return undefined; + } + + const checked = await this.checkToken(start, token); + if (!checked) { + return undefined; + } + + const [assigningEnvFile] = await this.findAssigningEnvFiles(await this.getLookupDirs(start)); + const envUri = assigningEnvFile ?? Uri.joinPath(target, ENV_FILE_NAME); + if (!(await this.editEnvFile(envUri, (content) => withToken(content, token), token))) { + return undefined; + } + + await this.checkEnvFileInGit(envUri); + + // Notifications render `[label](command:...)` as runnable links, and this name comes from the Deepnote API. + const workspaceName = checked.workspaceName?.replace(/\]\(/g, '] ('); + const envPath = workspace.asRelativePath(envUri); + void window.showInformationMessage( + workspaceName + ? DeepnoteCloud.apiTokenSavedForWorkspace(workspaceName, envPath) + : DeepnoteCloud.apiTokenSaved(envPath) + ); + + return token; + } + + private async promptForToken(): Promise { + const value = await window.showInputBox({ + password: true, + ignoreFocusOut: true, + prompt: DeepnoteCloud.apiTokenPrompt, + placeHolder: DeepnoteCloud.apiTokenPlaceholder, + validateInput: (input) => + INVALID_TOKEN_CHARACTERS.test(input.trim()) ? DeepnoteCloud.apiTokenInvalidCharacters : undefined + }); + + return value?.trim() || undefined; + } + + /** The variables of the `.env` in `dir`; `undefined` when it is missing or cannot be read. */ + private async readEnvFile(dir: Uri): Promise | undefined> { + const envPath = getFilePath(Uri.joinPath(dir, ENV_FILE_NAME)); + try { + const content = await readTextFile(envPath); + + return content === undefined ? undefined : dotenv.parse(content); + } catch (error) { + logger.warn(`Could not read ${envPath}`, error); + + return undefined; + } + } + + /** The command argument, else the active notebook or file; with none, the workspace folder. Tells the user why not. */ + private async resolveStart(resource?: Uri): Promise { + const activeNotebook = window.activeNotebookEditor?.notebook.uri; + const start = + resource ?? + (activeNotebook && withoutQueryAndFragment(activeNotebook)) ?? + window.activeTextEditor?.document.uri; + + return start?.scheme === 'file' ? start : this.pickWorkspaceFolder(); + } + + private async resolveTargetFolder(start: Uri): Promise { + const cleared = withoutQueryAndFragment(start); + const startFolder = await getStartFolder(cleared); + + return (await this.findSyncRoot(startFolder)) ?? workspace.getWorkspaceFolder(cleared)?.uri ?? startFolder; + } +} diff --git a/src/notebooks/deepnote/cloud/deepnoteApiTokenService.node.unit.test.ts b/src/notebooks/deepnote/cloud/deepnoteApiTokenService.node.unit.test.ts new file mode 100644 index 0000000000..4c6093a32a --- /dev/null +++ b/src/notebooks/deepnote/cloud/deepnoteApiTokenService.node.unit.test.ts @@ -0,0 +1,1148 @@ +import type { Workspace } from '@deepnote/cloud'; +import { ApiError, findSyncManifestRoot } from '@deepnote/cloud-sync'; +import { assert } from 'chai'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as sinon from 'sinon'; +import { anything, capture, deepEqual, instance, mock, verify, when } from 'ts-mockito'; +import { Extension, ExtensionKind, InputBoxOptions, Uri, WorkspaceFolder } from 'vscode'; + +import { Commands, NotebookCellScheme } from '../../../platform/common/constants'; +import { IExtensionContext } from '../../../platform/common/types'; +import { DeepnoteCloud } from '../../../platform/common/utils/localize'; +import * as path from '../../../platform/vscode-path/path'; +import { extUriBiasedIgnorePathCase, extUriIgnorePathCase } from '../../../platform/vscode-path/resources'; +import { mockedVSCodeNamespaces, resetVSCodeMocks } from '../../../test/vscode-mock'; +import { DeepnoteApiTokenService } from './deepnoteApiTokenService.node'; + +class TestableService extends DeepnoteApiTokenService { + public readonly fetchWorkspaceStub = sinon.stub<[string, string], Promise>(); + public readonly isIgnoredByGitStub = sinon.stub<[Uri], Promise>(); + + protected override fetchWorkspace(baseUrl: string, token: string): Promise { + return this.fetchWorkspaceStub(baseUrl, token); + } + + protected override isIgnoredByGit(envUri: Uri): Promise { + return this.isIgnoredByGitStub(envUri); + } +} + +class GitProbe extends DeepnoteApiTokenService { + public isIgnored(envUri: Uri): Promise { + return this.isIgnoredByGit(envUri); + } +} + +suite('DeepnoteApiTokenService', () => { + let tmp: string; + let ws: string; + let service: TestableService; + let sandbox: sinon.SinonSandbox; + let commandHandlers: Map Promise>; + + const win = () => mockedVSCodeNamespaces.window; + const wsp = () => mockedVSCodeNamespaces.workspace; + + async function writeFile(file: string, content: string): Promise { + await fs.promises.mkdir(path.dirname(file), { recursive: true }); + await fs.promises.writeFile(file, content); + } + + function readFile(file: string): Promise { + return fs.promises.readFile(file, 'utf8'); + } + + function exists(file: string): Promise { + return fs.promises.access(file).then( + () => true, + () => false + ); + } + + function isInside(candidate: string, folder: string): boolean { + return candidate === folder || candidate.startsWith(folder + path.sep); + } + + function openWorkspaceFolder(folderPath: string): void { + const folder: WorkspaceFolder = { uri: Uri.file(folderPath), name: path.basename(folderPath), index: 0 }; + when(wsp().workspaceFolders).thenReturn([folder]); + // fsPath lower-cases a Windows drive letter, so compare against the folder's fsPath, not folderPath. + when(wsp().getWorkspaceFolder(anything())).thenCall((uri: Uri) => + isInside(uri.fsPath, folder.uri.fsPath) ? folder : undefined + ); + when(wsp().asRelativePath(anything())).thenCall((uri: Uri) => path.relative(folder.uri.fsPath, uri.fsPath)); + } + + function activeFile(file: string): void { + when(win().activeTextEditor).thenReturn({ document: { uri: Uri.file(file) } } as any); + } + + function enterToken(token: string | undefined): void { + when(win().showInputBox(anything())).thenResolve(token as any); + } + + function setApiToken(resource?: Uri): Promise { + return commandHandlers.get(Commands.SetApiToken)!(resource); + } + + function clearApiToken(): Promise { + return commandHandlers.get(Commands.ClearApiToken)!(); + } + + function inputBoxOptions(): InputBoxOptions { + return capture(win().showInputBox).last()[0] as InputBoxOptions; + } + + function verifyError(message: string): void { + verify(win().showErrorMessage(message)).once(); + } + + function verifyNoInputBox(): void { + verify(win().showInputBox(anything())).never(); + } + + function createSyncManifest(folder: string): Promise { + return writeFile(path.join(folder, '.deepnote-sync.json'), '{}'); + } + + setup(async () => { + resetVSCodeMocks(); + sandbox = sinon.createSandbox(); + tmp = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'deepnote-token-')); + assert.isUndefined( + await findSyncManifestRoot(tmp), + 'A .deepnote-sync.json above the temp dir would redirect token writes outside it' + ); + ws = path.join(tmp, 'ws'); + await fs.promises.mkdir(ws); + + openWorkspaceFolder(ws); + when(wsp().getConfiguration('deepnote', anything())).thenReturn({ + get: (_key: string, fallback: unknown) => fallback + } as any); + + commandHandlers = new Map(); + when(mockedVSCodeNamespaces.commands.registerCommand(anything(), anything())).thenCall( + (id: string, handler: (...args: unknown[]) => Promise) => { + commandHandlers.set(id, handler); + + return { dispose: () => undefined }; + } + ); + + service = new TestableService({ subscriptions: [] } as unknown as IExtensionContext); + service.fetchWorkspaceStub.resolves({ id: 'w1', name: 'Acme' }); + service.isIgnoredByGitStub.resolves(true); + service.activate(); + }); + + teardown(async () => { + sandbox.restore(); + await fs.promises.rm(tmp, { recursive: true, force: true }); + }); + + suite('activate', () => { + test('registers the Set and Clear commands', () => { + assert.sameMembers([...commandHandlers.keys()], ['deepnote.setApiToken', 'deepnote.clearApiToken']); + }); + }); + + suite('getToken', () => { + test('prefers the .env nearest the file over the one at the workspace root', async () => { + await writeFile(path.join(ws, '.env'), 'DEEPNOTE_TOKEN=root\n'); + await writeFile(path.join(ws, 'a', 'b', '.env'), 'DEEPNOTE_TOKEN=near\n'); + + assert.strictEqual(await service.getToken(Uri.file(path.join(ws, 'a', 'b', 'nb.deepnote'))), 'near'); + }); + + test('skips a .env with an empty DEEPNOTE_TOKEN in favor of its parent', async () => { + await writeFile(path.join(ws, '.env'), 'DEEPNOTE_TOKEN=root\n'); + await writeFile(path.join(ws, 'a', 'b', '.env'), 'DEEPNOTE_TOKEN=\n'); + + assert.strictEqual(await service.getToken(Uri.file(path.join(ws, 'a', 'b', 'nb.deepnote'))), 'root'); + }); + + test('trims the value and reads the `KEY: value` form and `export`', async () => { + await writeFile(path.join(ws, 'a', '.env'), "DEEPNOTE_TOKEN: ' spaced '\n"); + await writeFile(path.join(ws, 'b', '.env'), 'export DEEPNOTE_TOKEN=exported\n'); + + assert.strictEqual(await service.getToken(Uri.file(path.join(ws, 'a', 'nb.deepnote'))), 'spaced'); + assert.strictEqual(await service.getToken(Uri.file(path.join(ws, 'b', 'nb.deepnote'))), 'exported'); + }); + + test('is undefined when no .env on the path defines it', async () => { + await writeFile(path.join(ws, '.env'), 'OTHER=1\n'); + await writeFile(path.join(tmp, '.env'), 'DEEPNOTE_TOKEN=outside\n'); + + assert.isUndefined(await service.getToken(Uri.file(path.join(ws, 'a', 'nb.deepnote')))); + }); + + test('stops at the workspace folder root when the sync root is below it', async () => { + const syncRoot = path.join(ws, 'sync'); + await createSyncManifest(syncRoot); + await writeFile(path.join(ws, '.env'), 'DEEPNOTE_TOKEN=at-workspace\n'); + await writeFile(path.join(tmp, '.env'), 'DEEPNOTE_TOKEN=above-everything\n'); + + assert.strictEqual( + await service.getToken(Uri.file(path.join(syncRoot, 'P', 'nb.deepnote'))), + 'at-workspace' + ); + }); + + test('searches up to the sync root when it is above the workspace folder', async () => { + const syncRoot = path.join(ws, 'sync'); + const projectFolder = path.join(syncRoot, 'P'); + openWorkspaceFolder(projectFolder); + await createSyncManifest(syncRoot); + await writeFile(path.join(syncRoot, '.env'), 'DEEPNOTE_TOKEN=at-sync-root\n'); + await writeFile(path.join(ws, '.env'), 'DEEPNOTE_TOKEN=above-sync-root\n'); + + assert.strictEqual( + await service.getToken(Uri.file(path.join(projectFolder, 'nb.deepnote'))), + 'at-sync-root' + ); + }); + + test('treats a symbolic-link sync manifest as no sync root', async () => { + const syncRoot = path.join(ws, 'sync'); + await fs.promises.mkdir(path.join(syncRoot, 'P'), { recursive: true }); + await writeFile(path.join(tmp, 'real-manifest.json'), '{}'); + await fs.promises.symlink(path.join(tmp, 'real-manifest.json'), path.join(syncRoot, '.deepnote-sync.json')); + await writeFile(path.join(ws, '.env'), 'DEEPNOTE_TOKEN=at-workspace\n'); + + assert.strictEqual( + await service.getToken(Uri.file(path.join(syncRoot, 'P', 'nb.deepnote'))), + 'at-workspace' + ); + }); + + test('reads the folder itself when given a folder', async () => { + await writeFile(path.join(ws, 'a', '.env'), 'DEEPNOTE_TOKEN=in-folder\n'); + + assert.strictEqual(await service.getToken(Uri.file(path.join(ws, 'a'))), 'in-folder'); + }); + + test('ignores the query and fragment of a notebook URI', async () => { + await writeFile(path.join(ws, 'a', '.env'), 'DEEPNOTE_TOKEN=in-folder\n'); + + assert.strictEqual( + await service.getToken( + Uri.file(path.join(ws, 'a', 'nb.deepnote')).with({ query: 'id=1', fragment: 'x' }) + ), + 'in-folder' + ); + }); + + test('does not search above the workspace folder for a notebook URI with a query', async () => { + await writeFile(path.join(tmp, '.env'), 'DEEPNOTE_TOKEN=above-workspace\n'); + + assert.isUndefined( + await service.getToken( + Uri.file(path.join(ws, 'a', 'nb.deepnote')).with({ query: 'id=1', fragment: 'x' }) + ) + ); + }); + + test('does not search above a workspace folder that is missing on disk', async () => { + const missing = path.join(tmp, 'projects', 'foo'); + openWorkspaceFolder(missing); + await writeFile(path.join(tmp, 'projects', '.env'), 'DEEPNOTE_TOKEN=other-project\n'); + + assert.isUndefined(await service.getToken(Uri.file(missing))); + }); + + test('does not search above the workspace folder for a folder URI with a trailing slash', async () => { + await writeFile(path.join(tmp, '.env'), 'DEEPNOTE_TOKEN=above-workspace\n'); + + assert.isUndefined(await service.getToken(Uri.file(ws + '/'))); + }); + + test('stops at, and still reads, a workspace folder whose URI ends in a slash', async () => { + const folder: WorkspaceFolder = { uri: Uri.parse(Uri.file(ws).toString() + '/'), name: 'ws', index: 0 }; + when(wsp().getWorkspaceFolder(anything())).thenReturn(folder); + await writeFile(path.join(tmp, '.env'), 'DEEPNOTE_TOKEN=above-workspace\n'); + const notebook = Uri.file(path.join(ws, 'a', 'nb.deepnote')); + + assert.isUndefined(await service.getToken(notebook)); + await writeFile(path.join(ws, '.env'), 'DEEPNOTE_TOKEN=at-root\n'); + assert.strictEqual(await service.getToken(notebook), 'at-root'); + }); + + test('does not search above a workspace folder matched in another path case', async () => { + // VS Code matches file URIs to workspace folders case-insensitively on macOS and Windows. + const folder: WorkspaceFolder = { uri: Uri.file(path.join(tmp, 'WS')), name: 'WS', index: 0 }; + when(wsp().getWorkspaceFolder(anything())).thenReturn(folder); + await writeFile(path.join(tmp, '.env'), 'DEEPNOTE_TOKEN=above-workspace\n'); + + assert.isUndefined(await service.getToken(Uri.file(path.join(ws, 'a', 'nb.deepnote')))); + }); + + test('reads up to a workspace folder matched in another path case, past a sync root below it', async () => { + // Path case is ignored only off Linux and CI runs on Linux, so apply the macOS and Windows comparison. + sandbox + .stub(extUriBiasedIgnorePathCase, 'isEqualOrParent') + .callsFake((...args) => extUriIgnorePathCase.isEqualOrParent(...args)); + sandbox + .stub(extUriBiasedIgnorePathCase, 'isEqual') + .callsFake((...args) => extUriIgnorePathCase.isEqual(...args)); + when(wsp().getWorkspaceFolder(anything())).thenReturn({ + uri: Uri.file(path.join(tmp, 'WS')), + name: 'WS', + index: 0 + }); + await createSyncManifest(path.join(ws, 'sync')); + await writeFile(path.join(ws, '.env'), 'DEEPNOTE_TOKEN=at-workspace\n'); + + assert.strictEqual( + await service.getToken(Uri.file(path.join(ws, 'sync', 'P', 'nb.deepnote'))), + 'at-workspace' + ); + }); + + test('resolves undefined for a non-file URI without touching the disk', async () => { + const stat = sandbox.spy(fs.promises, 'stat'); + const read = sandbox.spy(fs.promises, 'readFile'); + + assert.isUndefined(await service.getToken(Uri.parse('vscode-vfs://github/o/r/nb.deepnote'))); + assert.isTrue(stat.notCalled); + assert.isTrue(read.notCalled); + }); + + test('resolves undefined in an untrusted workspace without reading a file', async () => { + await writeFile(path.join(ws, '.env'), 'DEEPNOTE_TOKEN=t\n'); + when(wsp().isTrusted).thenReturn(false); + const read = sandbox.spy(fs.promises, 'readFile'); + + assert.isUndefined(await service.getToken(Uri.file(path.join(ws, 'nb.deepnote')))); + assert.isTrue(read.notCalled); + }); + + test('reads the last real assignment, not one inside a quoted multi-line value', async () => { + await writeFile( + path.join(ws, '.env'), + 'CERT="-----BEGIN\nDEEPNOTE_TOKEN=fake\n-----END"\nDEEPNOTE_TOKEN=real\n' + ); + + assert.strictEqual(await service.getToken(Uri.file(path.join(ws, 'nb.deepnote'))), 'real'); + }); + }); + + suite('getOrPromptToken', () => { + const notebook = () => Uri.file(path.join(ws, 'a', 'nb.deepnote')); + + test('returns the stored token without prompting', async () => { + await writeFile(path.join(ws, '.env'), 'DEEPNOTE_TOKEN=stored\n'); + + assert.strictEqual(await service.getOrPromptToken(notebook()), 'stored'); + verifyNoInputBox(); + }); + + test('prompts, saves and returns a new token when none is stored', async () => { + enterToken('fresh'); + + assert.strictEqual(await service.getOrPromptToken(notebook()), 'fresh'); + assert.strictEqual(await readFile(path.join(ws, '.env')), 'DEEPNOTE_TOKEN=fresh\n'); + }); + + test('resolves undefined and writes nothing when the prompt is cancelled', async () => { + enterToken(undefined); + + assert.isUndefined(await service.getOrPromptToken(notebook())); + assert.isFalse(await exists(path.join(ws, '.env'))); + assert.isTrue(service.fetchWorkspaceStub.notCalled); + }); + + test('tells the user about local files and does not prompt for a non-file URI', async () => { + assert.isUndefined(await service.getOrPromptToken(Uri.parse('vscode-vfs://github/o/r/nb.deepnote'))); + + verifyError(DeepnoteCloud.localFilesOnly); + verifyNoInputBox(); + }); + + test('tells the user to trust the workspace and does not prompt when untrusted', async () => { + await writeFile(path.join(ws, '.env'), 'DEEPNOTE_TOKEN=t\n'); + when(wsp().isTrusted).thenReturn(false); + + assert.isUndefined(await service.getOrPromptToken(notebook())); + + verifyError(DeepnoteCloud.workspaceNotTrusted); + verifyNoInputBox(); + }); + }); + + suite('Set API Token', () => { + const NEW_TOKEN = 'new'; + + setup(() => { + activeFile(path.join(ws, 'a', 'b', 'nb.deepnote')); + enterToken(NEW_TOKEN); + }); + + test('creates the .env at the workspace root and names the Deepnote workspace', async () => { + await setApiToken(); + + assert.strictEqual(await readFile(path.join(ws, '.env')), 'DEEPNOTE_TOKEN=new\n'); + verify(win().showInformationMessage(DeepnoteCloud.apiTokenSavedForWorkspace('Acme', '.env'))).once(); + }); + + test('confirms without a workspace name when the API returns none', async () => { + service.fetchWorkspaceStub.resolves(undefined); + + await setApiToken(); + + verify(win().showInformationMessage(DeepnoteCloud.apiTokenSaved('.env'))).once(); + }); + + test('checks the token against the API of the configured domain', async () => { + await setApiToken(); + assert.deepStrictEqual(service.fetchWorkspaceStub.lastCall.args, ['https://api.deepnote.com', NEW_TOKEN]); + + const notebookUri = Uri.file(path.join(ws, 'a', 'b', 'nb.deepnote')).toString(); + when(wsp().getConfiguration('deepnote', anything())).thenCall((_section: string, resource?: Uri) => ({ + get: (key: string, fallback: unknown) => + key === 'domain' && resource?.toString() === notebookUri ? 'example.test' : fallback + })); + await setApiToken(); + assert.deepStrictEqual(service.fetchWorkspaceStub.lastCall.args, ['https://api.example.test', NEW_TOKEN]); + }); + + test('replaces the first assignment, drops the others, and keeps every other line', async () => { + await writeFile(path.join(ws, '.env'), 'A=1\nexport DEEPNOTE_TOKEN=old\nB=2\nDEEPNOTE_TOKEN=older\n'); + + await setApiToken(); + + assert.strictEqual(await readFile(path.join(ws, '.env')), 'A=1\nDEEPNOTE_TOKEN=new\nB=2\n'); + }); + + test('keeps CRLF line endings', async () => { + await writeFile(path.join(ws, '.env'), 'A=1\r\nDEEPNOTE_TOKEN=old\r\n'); + + await setApiToken(); + + assert.strictEqual(await readFile(path.join(ws, '.env')), 'A=1\r\nDEEPNOTE_TOKEN=new\r\n'); + }); + + test('appends after a last line that has no line ending', async () => { + await writeFile(path.join(ws, '.env'), 'A=1'); + + await setApiToken(); + + assert.strictEqual(await readFile(path.join(ws, '.env')), 'A=1\nDEEPNOTE_TOKEN=new\n'); + }); + + test('rewrites the nearest .env that already assigns the token, not the workspace root', async () => { + await writeFile(path.join(ws, 'a', 'b', '.env'), 'A=1\nDEEPNOTE_TOKEN: old\n'); + + await setApiToken(); + + assert.strictEqual(await readFile(path.join(ws, 'a', 'b', '.env')), 'A=1\nDEEPNOTE_TOKEN=new\n'); + assert.isFalse(await exists(path.join(ws, '.env'))); + }); + + test('rewrites an .env that assigns an empty token', async () => { + await writeFile(path.join(ws, 'a', '.env'), 'DEEPNOTE_TOKEN=\n'); + + await setApiToken(); + + assert.strictEqual(await readFile(path.join(ws, 'a', '.env')), 'DEEPNOTE_TOKEN=new\n'); + assert.isFalse(await exists(path.join(ws, '.env'))); + }); + + test('writes to the sync root, not a folder in between, when no .env assigns the token', async () => { + const project = path.join(ws, 'sync', 'P'); + await createSyncManifest(path.join(ws, 'sync')); + await writeFile(path.join(project, 'nb.deepnote'), ''); + activeFile(path.join(project, 'nb.deepnote')); + + await setApiToken(); + + assert.strictEqual(await readFile(path.join(ws, 'sync', '.env')), 'DEEPNOTE_TOKEN=new\n'); + assert.isFalse(await exists(path.join(project, '.env'))); + assert.isFalse(await exists(path.join(ws, '.env'))); + }); + + test('rewrites the .env next to the file even when a sync root is above it', async () => { + const project = path.join(ws, 'sync', 'P'); + await createSyncManifest(path.join(ws, 'sync')); + await writeFile(path.join(project, '.env'), 'DEEPNOTE_TOKEN=stale'); + activeFile(path.join(project, 'nb.deepnote')); + + await setApiToken(); + + assert.strictEqual(await readFile(path.join(project, '.env')), 'DEEPNOTE_TOKEN=new\n'); + assert.isFalse(await exists(path.join(ws, 'sync', '.env'))); + }); + + test('writes to the workspace root when the sync manifest is a symbolic link', async () => { + const project = path.join(ws, 'sync', 'P'); + await fs.promises.mkdir(project, { recursive: true }); + await writeFile(path.join(tmp, 'real-manifest.json'), '{}'); + await fs.promises.symlink( + path.join(tmp, 'real-manifest.json'), + path.join(ws, 'sync', '.deepnote-sync.json') + ); + activeFile(path.join(project, 'nb.deepnote')); + + await setApiToken(); + + assert.strictEqual(await readFile(path.join(ws, '.env')), 'DEEPNOTE_TOKEN=new\n'); + assert.isFalse(await exists(path.join(ws, 'sync', '.env'))); + }); + + test('rewrites only the nearest of several .env files that assign the token', async () => { + await writeFile(path.join(ws, 'a', 'b', '.env'), 'DEEPNOTE_TOKEN=old\n'); + await writeFile(path.join(ws, '.env'), 'DEEPNOTE_TOKEN=root\n'); + + await setApiToken(); + + assert.strictEqual(await readFile(path.join(ws, 'a', 'b', '.env')), 'DEEPNOTE_TOKEN=new\n'); + assert.strictEqual(await readFile(path.join(ws, '.env')), 'DEEPNOTE_TOKEN=root\n'); + }); + + test('keeps a variable whose name only starts with DEEPNOTE_TOKEN', async () => { + await writeFile(path.join(ws, '.env'), 'DEEPNOTE_TOKEN_BACKUP=keep\nDEEPNOTE_TOKEN=old\n'); + + await setApiToken(); + + assert.strictEqual( + await readFile(path.join(ws, '.env')), + 'DEEPNOTE_TOKEN_BACKUP=keep\nDEEPNOTE_TOKEN=new\n' + ); + }); + + test('replaces a first-line assignment after a UTF-8 byte order mark and keeps the mark', async () => { + await writeFile(path.join(ws, '.env'), '\uFEFFDEEPNOTE_TOKEN=old\nB=2\n'); + + await setApiToken(); + + assert.strictEqual(await readFile(path.join(ws, '.env')), '\uFEFFDEEPNOTE_TOKEN=new\nB=2\n'); + }); + + test('leaves an .env that is not UTF-8 byte-for-byte unchanged', async () => { + const latin1 = Buffer.from('DB_PASSWORD=p\xe4ss\nDEEPNOTE_TOKEN=old\n', 'latin1'); + await fs.promises.writeFile(path.join(ws, '.env'), latin1); + + await setApiToken(); + + assert.deepStrictEqual(await fs.promises.readFile(path.join(ws, '.env')), latin1); + verifyError(DeepnoteCloud.envEditFailed('.env')); + verify(win().showInformationMessage(anything())).never(); + }); + + test('does not overwrite an .env it cannot read with just the token', async () => { + const content = 'A=1\nDEEPNOTE_TOKEN=old\n'; + await writeFile(path.join(ws, '.env'), content); + const denied = Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }); + sandbox.stub(fs.promises, 'readFile').rejects(denied); + const write = sandbox.spy(fs.promises, 'writeFile'); + + await setApiToken(); + + sandbox.restore(); + assert.isTrue(write.notCalled); + assert.strictEqual(await readFile(path.join(ws, '.env')), content); + verifyError(DeepnoteCloud.envEditFailed('.env')); + }); + + test('does not overwrite an .env above a workspace folder that is missing on disk', async () => { + const missing = path.join(tmp, 'projects', 'foo'); + openWorkspaceFolder(missing); + when(win().activeTextEditor).thenReturn(undefined); + await writeFile(path.join(tmp, 'projects', '.env'), 'DEEPNOTE_TOKEN=other-project\n'); + + await setApiToken(); + + assert.strictEqual(await readFile(path.join(tmp, 'projects', '.env')), 'DEEPNOTE_TOKEN=other-project\n'); + }); + + test('keeps a workspace name from the API from forming a command link in the confirmation', async () => { + service.fetchWorkspaceStub.resolves({ id: 'w1', name: 'X [y](command:foo) [z](command:bar)' }); + + await setApiToken(); + + verify( + win().showInformationMessage( + DeepnoteCloud.apiTokenSavedForWorkspace('X [y] (command:foo) [z] (command:bar)', '.env') + ) + ).once(); + }); + + test('shows a workspace name with ordinary brackets and parentheses unchanged', async () => { + service.fetchWorkspaceStub.resolves({ id: 'w1', name: 'Acme [EU] (prod)' }); + + await setApiToken(); + + verify( + win().showInformationMessage(DeepnoteCloud.apiTokenSavedForWorkspace('Acme [EU] (prod)', '.env')) + ).once(); + }); + + test('uses the command argument over the active editor', async () => { + await writeFile(path.join(ws, 'other', '.env'), 'DEEPNOTE_TOKEN=old\n'); + await writeFile(path.join(ws, 'a', 'b', '.env'), 'DEEPNOTE_TOKEN=untouched\n'); + + await setApiToken(Uri.file(path.join(ws, 'other', 'nb.deepnote'))); + + assert.strictEqual(await readFile(path.join(ws, 'other', '.env')), 'DEEPNOTE_TOKEN=new\n'); + assert.strictEqual(await readFile(path.join(ws, 'a', 'b', '.env')), 'DEEPNOTE_TOKEN=untouched\n'); + }); + + test('starts from the active notebook, ignoring its query and fragment', async () => { + when(win().activeTextEditor).thenReturn(undefined); + when(win().activeNotebookEditor).thenReturn({ + notebook: { uri: Uri.file(path.join(ws, 'n', 'nb.deepnote')).with({ query: 'id=1', fragment: 'x' }) } + } as any); + await writeFile(path.join(ws, 'n', '.env'), 'DEEPNOTE_TOKEN=old\n'); + + await setApiToken(); + + assert.strictEqual(await readFile(path.join(ws, 'n', '.env')), 'DEEPNOTE_TOKEN=new\n'); + }); + + test('starts from the active notebook, not the focused cell editor inside it', async () => { + const notebookUri = Uri.file(path.join(ws, 'n', 'nb.deepnote')).with({ query: 'id=1' }); + when(win().activeNotebookEditor).thenReturn({ notebook: { uri: notebookUri } } as any); + when(win().activeTextEditor).thenReturn({ + document: { uri: notebookUri.with({ scheme: NotebookCellScheme, fragment: 'cell-1' }) } + } as any); + await writeFile(path.join(ws, 'n', '.env'), 'DEEPNOTE_TOKEN=old\n'); + + await setApiToken(); + + assert.strictEqual(await readFile(path.join(ws, 'n', '.env')), 'DEEPNOTE_TOKEN=new\n'); + assert.isFalse(await exists(path.join(ws, '.env'))); + }); + + test('falls back to the only workspace folder when no file is open', async () => { + when(win().activeTextEditor).thenReturn(undefined); + + await setApiToken(); + + assert.strictEqual(await readFile(path.join(ws, '.env')), 'DEEPNOTE_TOKEN=new\n'); + }); + + test('falls back to the workspace folder when the active file is not a local file', async () => { + when(win().activeTextEditor).thenReturn({ document: { uri: Uri.parse('untitled:Untitled-1') } } as any); + + await setApiToken(); + + assert.strictEqual(await readFile(path.join(ws, '.env')), 'DEEPNOTE_TOKEN=new\n'); + }); + + test('asks which workspace folder to use when there are several', async () => { + const second = path.join(tmp, 'second'); + await fs.promises.mkdir(second); + when(win().activeTextEditor).thenReturn(undefined); + when(wsp().workspaceFolders).thenReturn([ + { uri: Uri.file(ws), name: 'ws', index: 0 }, + { uri: Uri.file(second), name: 'second', index: 1 } + ]); + when(win().showWorkspaceFolderPick()).thenResolve({ + uri: Uri.file(second), + name: 'second', + index: 1 + } as any); + + await setApiToken(); + + assert.strictEqual(await readFile(path.join(second, '.env')), 'DEEPNOTE_TOKEN=new\n'); + assert.isFalse(await exists(path.join(ws, '.env'))); + }); + + test('stops without a prompt when the folder pick is cancelled', async () => { + when(win().activeTextEditor).thenReturn(undefined); + when(wsp().workspaceFolders).thenReturn([ + { uri: Uri.file(ws), name: 'ws', index: 0 }, + { uri: Uri.file(tmp), name: 'tmp', index: 1 } + ]); + when(win().showWorkspaceFolderPick()).thenResolve(undefined); + + await setApiToken(); + + verifyNoInputBox(); + }); + + test('asks to open a folder when there is neither a file nor a workspace folder', async () => { + when(win().activeTextEditor).thenReturn(undefined); + when(wsp().workspaceFolders).thenReturn(undefined); + + await setApiToken(); + + verifyError(DeepnoteCloud.openFolderFirst); + verifyNoInputBox(); + }); + + test('refuses a workspace folder that is not a local folder', async () => { + when(win().activeTextEditor).thenReturn(undefined); + when(wsp().workspaceFolders).thenReturn([ + { uri: Uri.parse('vscode-vfs://github/o/r'), name: 'r', index: 0 } + ]); + + await setApiToken(); + + verifyError(DeepnoteCloud.localFilesOnly); + verifyNoInputBox(); + }); + + test('writes nothing when the prompt is cancelled or blank', async () => { + enterToken(undefined); + await setApiToken(); + enterToken(' '); + await setApiToken(); + + assert.isTrue(service.fetchWorkspaceStub.notCalled); + assert.isFalse(await exists(path.join(ws, '.env'))); + }); + + test('trims the token before checking and saving it', async () => { + enterToken(' padded '); + + await setApiToken(); + + assert.strictEqual(service.fetchWorkspaceStub.lastCall.args[1], 'padded'); + assert.strictEqual(await readFile(path.join(ws, '.env')), 'DEEPNOTE_TOKEN=padded\n'); + }); + + test('asks the input box for a masked prompt that rejects whitespace, quotes, # and control characters', async () => { + await setApiToken(); + + const { validateInput, ...options } = inputBoxOptions(); + assert.deepStrictEqual(options, { + password: true, + ignoreFocusOut: true, + prompt: DeepnoteCloud.apiTokenPrompt, + placeHolder: DeepnoteCloud.apiTokenPlaceholder + }); + for (const invalid of ['a b', 'a"b', "a'b", 'a#b', 'a\tb', 'a\u0000b', 'a\u007fb']) { + assert.strictEqual( + validateInput!(invalid), + DeepnoteCloud.apiTokenInvalidCharacters, + JSON.stringify(invalid) + ); + } + assert.isUndefined(validateInput!('abc-123_XYZ.=')); + assert.isUndefined(validateInput!(' abc ')); + }); + + test('writes nothing and says so when Deepnote rejects the token with 401', async () => { + service.fetchWorkspaceStub.rejects(new ApiError(401, 'Unauthorized')); + + await setApiToken(); + + assert.isFalse(await exists(path.join(ws, '.env'))); + verifyError(DeepnoteCloud.apiTokenRejected); + }); + + test('writes nothing and shows the reason when the check fails with 403', async () => { + service.fetchWorkspaceStub.rejects(new ApiError(403, 'Failed to fetch Deepnote workspace: HTTP 403')); + + await setApiToken(); + + assert.isFalse(await exists(path.join(ws, '.env'))); + verifyError(DeepnoteCloud.apiTokenCheckFailed('Failed to fetch Deepnote workspace: HTTP 403')); + }); + + test('writes nothing and shows the reason when the check fails for any other reason', async () => { + service.fetchWorkspaceStub.rejects(new Error('fetch failed')); + + await setApiToken(); + + assert.isFalse(await exists(path.join(ws, '.env'))); + verifyError(DeepnoteCloud.apiTokenCheckFailed('fetch failed')); + }); + + test('leaves an .env byte-for-byte unchanged when the edit would corrupt another variable', async () => { + const content = 'CERT="-----BEGIN\nDEEPNOTE_TOKEN=fake\n-----END"\nDEEPNOTE_TOKEN=real\n'; + await writeFile(path.join(ws, '.env'), content); + + await setApiToken(); + + assert.strictEqual(await readFile(path.join(ws, '.env')), content); + verifyError(DeepnoteCloud.envEditFailed('.env')); + verify(win().showInformationMessage(anything())).never(); + }); + + test('leaves the file unchanged when the token would not read back as typed', async () => { + enterToken('`quoted`'); + await writeFile(path.join(ws, '.env'), 'A=1\n'); + + await setApiToken(); + + assert.strictEqual(await readFile(path.join(ws, '.env')), 'A=1\n'); + verifyError(DeepnoteCloud.envEditFailed('.env')); + }); + + test('reports a .env it cannot read or write instead of throwing', async () => { + await fs.promises.mkdir(path.join(ws, '.env')); + + await setApiToken(); + + verifyError(DeepnoteCloud.envEditFailed('.env')); + verify(win().showInformationMessage(anything())).never(); + }); + + test('stops for an untrusted workspace before any prompt, read, write or request', async () => { + when(wsp().isTrusted).thenReturn(false); + const read = sandbox.spy(fs.promises, 'readFile'); + const write = sandbox.spy(fs.promises, 'writeFile'); + + await setApiToken(); + + verifyError(DeepnoteCloud.workspaceNotTrusted); + verifyNoInputBox(); + assert.isTrue(service.fetchWorkspaceStub.notCalled); + assert.isTrue(read.notCalled); + assert.isTrue(write.notCalled); + }); + + suite('.gitignore offer', () => { + const offerAccepted = () => + when(win().showWarningMessage(anything(), anything(), anything())).thenResolve( + DeepnoteCloud.addToGitignore as any + ); + + test('adds .env to a new .gitignore next to the written .env when git does not ignore it', async () => { + service.isIgnoredByGitStub.resolves(false); + offerAccepted(); + + await setApiToken(); + + verify( + win().showWarningMessage( + DeepnoteCloud.envNotIgnored('.env'), + deepEqual({ modal: true }), + DeepnoteCloud.addToGitignore + ) + ).once(); + assert.strictEqual(await readFile(path.join(ws, '.gitignore')), '.env\n'); + }); + + test('appends to an existing .gitignore using its line endings', async () => { + service.isIgnoredByGitStub.resolves(false); + offerAccepted(); + await writeFile(path.join(ws, '.gitignore'), 'node_modules\r\nout'); + + await setApiToken(); + + assert.strictEqual(await readFile(path.join(ws, '.gitignore')), 'node_modules\r\nout\r\n.env\r\n'); + }); + + test('puts the .gitignore in the folder of an .env found below the workspace root', async () => { + service.isIgnoredByGitStub.resolves(false); + offerAccepted(); + await writeFile(path.join(ws, 'a', 'b', '.env'), 'DEEPNOTE_TOKEN=old\n'); + + await setApiToken(); + + assert.strictEqual(await readFile(path.join(ws, 'a', 'b', '.gitignore')), '.env\n'); + assert.isFalse(await exists(path.join(ws, '.gitignore'))); + }); + + test('leaves .gitignore alone when the offer is dismissed', async () => { + service.isIgnoredByGitStub.resolves(false); + + await setApiToken(); + + assert.isFalse(await exists(path.join(ws, '.gitignore'))); + assert.strictEqual(await readFile(path.join(ws, '.env')), 'DEEPNOTE_TOKEN=new\n'); + }); + + test('appends .env to a .gitignore that is not UTF-8 and keeps its other bytes', async () => { + service.isIgnoredByGitStub.resolves(false); + offerAccepted(); + const latin1 = Buffer.from('# \xdc \xe9 docs\nnode_modules\n', 'latin1'); + await fs.promises.writeFile(path.join(ws, '.gitignore'), latin1); + + await setApiToken(); + + assert.deepStrictEqual( + await fs.promises.readFile(path.join(ws, '.gitignore')), + Buffer.concat([latin1, Buffer.from('.env\n')]) + ); + verify(win().showInformationMessage(DeepnoteCloud.apiTokenSavedForWorkspace('Acme', '.env'))).once(); + }); + + test('shows no offer when git already ignores the file', async () => { + service.isIgnoredByGitStub.resolves(true); + + await setApiToken(); + + verify(win().showWarningMessage(anything(), anything(), anything())).never(); + }); + + test('shows no offer, and still confirms, when the Git extension gives no answer', async () => { + service.isIgnoredByGitStub.resolves(undefined); + + await setApiToken(); + + verify(win().showWarningMessage(anything(), anything(), anything())).never(); + verify(win().showInformationMessage(DeepnoteCloud.apiTokenSavedForWorkspace('Acme', '.env'))).once(); + }); + + test('still confirms when the .gitignore cannot be written', async () => { + service.isIgnoredByGitStub.resolves(false); + offerAccepted(); + await fs.promises.mkdir(path.join(ws, '.gitignore')); + + await setApiToken(); + + verify(win().showInformationMessage(DeepnoteCloud.apiTokenSavedForWorkspace('Acme', '.env'))).once(); + }); + + test('asks the Git extension about the written file', async () => { + await setApiToken(); + + assert.strictEqual( + service.isIgnoredByGitStub.lastCall.args[0].fsPath, + Uri.file(path.join(ws, '.env')).fsPath + ); + }); + }); + }); + + suite('Clear API Token', () => { + setup(() => { + activeFile(path.join(ws, 'sync', 'P', 'nb.deepnote')); + }); + + test('removes the assignment from every .env on the path and keeps the other lines', async () => { + await createSyncManifest(path.join(ws, 'sync')); + await writeFile(path.join(ws, 'sync', 'P', '.env'), 'A=1\nDEEPNOTE_TOKEN=near\nB=2\n'); + await writeFile(path.join(ws, '.env'), 'export DEEPNOTE_TOKEN=root\nC=3\n'); + + await clearApiToken(); + + assert.strictEqual(await readFile(path.join(ws, 'sync', 'P', '.env')), 'A=1\nB=2\n'); + assert.strictEqual(await readFile(path.join(ws, '.env')), 'C=3\n'); + assert.isUndefined(await service.getToken(Uri.file(path.join(ws, 'sync', 'P', 'nb.deepnote')))); + verify(win().showInformationMessage(DeepnoteCloud.apiTokenCleared)).once(); + }); + + test('clears the `KEY: value` form', async () => { + await writeFile(path.join(ws, 'sync', 'P', '.env'), 'A=1\nDEEPNOTE_TOKEN: old\n'); + + await clearApiToken(); + + assert.strictEqual(await readFile(path.join(ws, 'sync', 'P', '.env')), 'A=1\n'); + assert.isUndefined(await service.getToken(Uri.file(path.join(ws, 'sync', 'P', 'nb.deepnote')))); + verify(win().showInformationMessage(DeepnoteCloud.apiTokenCleared)).once(); + }); + + test('keeps CRLF line endings', async () => { + await writeFile(path.join(ws, '.env'), 'A=1\r\nDEEPNOTE_TOKEN=t\r\nB=2\r\n'); + + await clearApiToken(); + + assert.strictEqual(await readFile(path.join(ws, '.env')), 'A=1\r\nB=2\r\n'); + }); + + test('never deletes a file, even when nothing else is left in it', async () => { + await writeFile(path.join(ws, '.env'), 'DEEPNOTE_TOKEN=t\n'); + + await clearApiToken(); + + assert.strictEqual(await readFile(path.join(ws, '.env')), ''); + }); + + test('says so when no .env assigns the token', async () => { + await writeFile(path.join(ws, '.env'), 'A=1\n'); + + await clearApiToken(); + + assert.strictEqual(await readFile(path.join(ws, '.env')), 'A=1\n'); + verify(win().showInformationMessage(DeepnoteCloud.noApiTokenFound)).once(); + verify(win().showInformationMessage(DeepnoteCloud.apiTokenCleared)).never(); + }); + + test('leaves an .env it cannot edit safely unchanged and names it', async () => { + const content = 'CERT="-----BEGIN\nDEEPNOTE_TOKEN=fake\n-----END"\nDEEPNOTE_TOKEN=real\n'; + await writeFile(path.join(ws, '.env'), content); + + await clearApiToken(); + + assert.strictEqual(await readFile(path.join(ws, '.env')), content); + verifyError(DeepnoteCloud.envEditFailed('.env')); + verify(win().showInformationMessage(DeepnoteCloud.apiTokenCleared)).never(); + }); + + test('carries on with the next .env after one it cannot edit, and past a later one', async () => { + // Clear visits .env files nearest first: a failure before the editable file checks that it carries on, and + // one after it checks that the later failure does not hide the earlier success. + const unsafe = 'CERT="-----BEGIN\nDEEPNOTE_TOKEN=fake\n-----END"\nDEEPNOTE_TOKEN=real\n'; + await writeFile(path.join(ws, 'sync', 'P', '.env'), unsafe); + await writeFile(path.join(ws, 'sync', '.env'), 'A=1\nDEEPNOTE_TOKEN=mid\n'); + await writeFile(path.join(ws, '.env'), unsafe); + + await clearApiToken(); + + assert.strictEqual(await readFile(path.join(ws, 'sync', 'P', '.env')), unsafe); + assert.strictEqual(await readFile(path.join(ws, 'sync', '.env')), 'A=1\n'); + assert.strictEqual(await readFile(path.join(ws, '.env')), unsafe); + verifyError(DeepnoteCloud.envEditFailed(path.join('sync', 'P', '.env'))); + verifyError(DeepnoteCloud.envEditFailed('.env')); + verify(win().showInformationMessage(DeepnoteCloud.apiTokenCleared)).once(); + }); + + test('keeps a variable whose name only starts with DEEPNOTE_TOKEN', async () => { + await writeFile(path.join(ws, '.env'), 'DEEPNOTE_TOKEN_BACKUP=keep\nDEEPNOTE_TOKEN=old\n'); + + await clearApiToken(); + + assert.strictEqual(await readFile(path.join(ws, '.env')), 'DEEPNOTE_TOKEN_BACKUP=keep\n'); + }); + + test('clears a first-line assignment after a UTF-8 byte order mark and keeps the mark', async () => { + await writeFile(path.join(ws, '.env'), '\uFEFFDEEPNOTE_TOKEN=t\nA=1\n'); + + await clearApiToken(); + + assert.strictEqual(await readFile(path.join(ws, '.env')), '\uFEFFA=1\n'); + verify(win().showInformationMessage(DeepnoteCloud.apiTokenCleared)).once(); + }); + + test('leaves an .env that is not UTF-8 byte-for-byte unchanged and names it', async () => { + const latin1 = Buffer.from('DB_PASSWORD=p\xe4ss\nDEEPNOTE_TOKEN=old\n', 'latin1'); + await fs.promises.writeFile(path.join(ws, '.env'), latin1); + + await clearApiToken(); + + assert.deepStrictEqual(await fs.promises.readFile(path.join(ws, '.env')), latin1); + verifyError(DeepnoteCloud.envEditFailed('.env')); + verify(win().showInformationMessage(DeepnoteCloud.apiTokenCleared)).never(); + }); + + test('leaves an .env above a workspace folder that is missing on disk unchanged', async () => { + const missing = path.join(tmp, 'projects', 'foo'); + openWorkspaceFolder(missing); + when(win().activeTextEditor).thenReturn(undefined); + await writeFile(path.join(tmp, 'projects', '.env'), 'DEEPNOTE_TOKEN=other-project\nA=1\n'); + + await clearApiToken(); + + assert.strictEqual( + await readFile(path.join(tmp, 'projects', '.env')), + 'DEEPNOTE_TOKEN=other-project\nA=1\n' + ); + verify(win().showInformationMessage(DeepnoteCloud.noApiTokenFound)).once(); + }); + + test('leaves an .env above a workspace folder matched in another path case unchanged', async () => { + when(wsp().getWorkspaceFolder(anything())).thenReturn({ + uri: Uri.file(path.join(tmp, 'WS')), + name: 'WS', + index: 0 + }); + await writeFile(path.join(tmp, '.env'), 'DEEPNOTE_TOKEN=above-workspace\n'); + + await clearApiToken(); + + assert.strictEqual(await readFile(path.join(tmp, '.env')), 'DEEPNOTE_TOKEN=above-workspace\n'); + }); + + test('clears the .env of the workspace folder when no file is open', async () => { + when(win().activeTextEditor).thenReturn(undefined); + await writeFile(path.join(ws, '.env'), 'DEEPNOTE_TOKEN=t\nA=1\n'); + + await clearApiToken(); + + assert.strictEqual(await readFile(path.join(ws, '.env')), 'A=1\n'); + }); + + test('does nothing but tell the user in an untrusted workspace', async () => { + await writeFile(path.join(ws, '.env'), 'DEEPNOTE_TOKEN=t\n'); + when(wsp().isTrusted).thenReturn(false); + + await clearApiToken(); + + assert.strictEqual(await readFile(path.join(ws, '.env')), 'DEEPNOTE_TOKEN=t\n'); + verifyError(DeepnoteCloud.workspaceNotTrusted); + }); + }); +}); + +suite('DeepnoteApiTokenService.isIgnoredByGit', () => { + // A drive letter makes fsPath, which the service passes to git, differ from uri.path on every platform. + const envUri = Uri.parse('file:///C:/repo/.env'); + const envPath = envUri.fsPath; + let probe: GitProbe; + + // Like VS Code, reading the exports of an extension that is not active yet throws. + function installGit(getAPI: () => unknown, isActive = true): void { + const exports = { getAPI }; + let active = isActive; + const extension: Extension = { + id: 'vscode.git', + extensionUri: Uri.file('/git'), + extensionPath: '/git', + get isActive() { + return active; + }, + packageJSON: {}, + extensionKind: ExtensionKind.Workspace, + get exports() { + if (!active) { + throw new Error("Extension 'vscode.git' is not known or not activated"); + } + + return exports; + }, + activate: () => { + active = true; + + return Promise.resolve(exports); + } + }; + when(mockedVSCodeNamespaces.extensions.getExtension('vscode.git')).thenReturn(extension); + } + + function apiIgnoring(ignoredPaths: string[]): () => unknown { + return () => ({ + getRepository: () => ({ + checkIgnore: (paths: string[]) => + Promise.resolve(new Set(paths.filter((candidate) => ignoredPaths.includes(candidate)))) + }) + }); + } + + setup(() => { + resetVSCodeMocks(); + probe = new GitProbe(instance(mock())); + }); + + test('is undefined when the Git extension is not installed', async () => { + when(mockedVSCodeNamespaces.extensions.getExtension('vscode.git')).thenReturn(undefined); + + assert.isUndefined(await probe.isIgnored(envUri)); + }); + + test('is undefined when the Git extension is not set up', async () => { + installGit(() => { + throw new Error('Git model not found'); + }); + + assert.isUndefined(await probe.isIgnored(envUri)); + }); + + test('is undefined outside an open repository and without checkIgnore', async () => { + installGit(() => ({ getRepository: () => null })); + assert.isUndefined(await probe.isIgnored(envUri)); + + installGit(() => ({ getRepository: () => ({}) })); + assert.isUndefined(await probe.isIgnored(envUri)); + }); + + test('is true when git ignores the file and false when it does not', async () => { + installGit(apiIgnoring([envPath])); + assert.isTrue(await probe.isIgnored(envUri)); + + installGit(apiIgnoring([])); + assert.isFalse(await probe.isIgnored(envUri)); + }); + + test('activates an inactive Git extension to get its API', async () => { + installGit(apiIgnoring([envPath]), false); + + assert.isTrue(await probe.isIgnored(envUri)); + }); +}); diff --git a/src/notebooks/deepnote/cloud/types.ts b/src/notebooks/deepnote/cloud/types.ts new file mode 100644 index 0000000000..92223698bc --- /dev/null +++ b/src/notebooks/deepnote/cloud/types.ts @@ -0,0 +1,18 @@ +import { Uri } from 'vscode'; + +export const IDeepnoteApiTokenService = Symbol('IDeepnoteApiTokenService'); +export interface IDeepnoteApiTokenService { + /** + * The trimmed `DEEPNOTE_TOKEN` from the nearest `.env` on the lookup path of `resource` that defines a non-empty + * value. `undefined` when no `.env` defines it, `resource` is not a local file, or the workspace is not trusted. + * Never prompts. + */ + getToken(resource: Uri): Promise; + + /** + * {@link getToken}, else asks the user for a token, checks it with Deepnote and saves it to `.env`. `undefined` + * means the user cancelled, `resource` is not a local file, or the workspace is not trusted; the user has already + * been told. + */ + getOrPromptToken(resource: Uri): Promise; +} diff --git a/src/notebooks/deepnote/importClient.node.ts b/src/notebooks/deepnote/importClient.node.ts index bd8506a959..3c3733546c 100644 --- a/src/notebooks/deepnote/importClient.node.ts +++ b/src/notebooks/deepnote/importClient.node.ts @@ -39,7 +39,7 @@ function getDomain(resource?: Uri): string { /** * Gets the API endpoint from configuration */ -function getApiEndpoint(resource?: Uri): string { +export function getApiEndpoint(resource?: Uri): string { const domain = getDomain(resource); return `https://api.${domain}`; } diff --git a/src/notebooks/serviceRegistry.node.ts b/src/notebooks/serviceRegistry.node.ts index 9c885cd583..e12cb0f36f 100644 --- a/src/notebooks/serviceRegistry.node.ts +++ b/src/notebooks/serviceRegistry.node.ts @@ -99,6 +99,8 @@ import { EphemeralCellStatusBarProvider } from './deepnote/ephemeralCellStatusBa import { OrphanedEphemeralCellCleaner } from './deepnote/orphanedEphemeralCellCleaner'; import { DeepnoteNewCellLanguageService } from './deepnote/deepnoteNewCellLanguageService'; import { DeepnoteCellCopyHandler } from './deepnote/deepnoteCellCopyHandler'; +import { DeepnoteApiTokenService } from './deepnote/cloud/deepnoteApiTokenService.node'; +import { IDeepnoteApiTokenService } from './deepnote/cloud/types'; import { OpenInDeepnoteHandler } from './deepnote/openInDeepnoteHandler.node'; import { IntegrationEnvRefreshHandler } from './deepnote/integrations/integrationEnvRefreshHandler'; import { IntegrationsEnvFileWatcher } from './deepnote/integrations/integrationsEnvFileWatcher.node'; @@ -230,6 +232,8 @@ export function registerTypes(serviceManager: IServiceManager, isDevMode: boolea IExtensionSyncActivationService, OpenInDeepnoteHandler ); + serviceManager.addSingleton(IDeepnoteApiTokenService, DeepnoteApiTokenService); + serviceManager.addBinding(IDeepnoteApiTokenService, IExtensionSyncActivationService); serviceManager.addSingleton( IExtensionSyncActivationService, IntegrationEnvRefreshHandler diff --git a/src/platform/common/constants.ts b/src/platform/common/constants.ts index dbbbb80601..464b442f97 100644 --- a/src/platform/common/constants.ts +++ b/src/platform/common/constants.ts @@ -273,6 +273,8 @@ export namespace Commands { export const AddNotebookToProject = 'deepnote.addNotebookToProject'; export const ExportNotebook = 'deepnote.exportNotebook'; export const OpenInDeepnote = 'deepnote.openInDeepnote'; + export const SetApiToken = 'deepnote.setApiToken'; + export const ClearApiToken = 'deepnote.clearApiToken'; export const ExportAsPythonScript = 'deepnote.exportAsPythonScript'; export const ExportToHTML = 'deepnote.exportToHTML'; export const ExportToPDF = 'deepnote.exportToPDF'; diff --git a/src/platform/common/utils/localize.ts b/src/platform/common/utils/localize.ts index 6ce22bf72b..e57378bdf8 100644 --- a/src/platform/common/utils/localize.ts +++ b/src/platform/common/utils/localize.ts @@ -1276,6 +1276,36 @@ export namespace Integrations { export const caCertificateTextPlaceholder = l10n.t('-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----'); } +export namespace DeepnoteCloud { + export const apiTokenPrompt = l10n.t( + 'Enter your Deepnote API token. Create one in Deepnote under Workspace > Settings & members > Security > API keys.' + ); + export const apiTokenPlaceholder = l10n.t('Deepnote API token'); + export const apiTokenInvalidCharacters = l10n.t('The token cannot contain spaces, quotes, # or control characters'); + export const checkingApiToken = l10n.t('Checking the Deepnote API token…'); + export const apiTokenRejected = l10n.t('Deepnote rejected this API token.'); + export const apiTokenCheckFailed = (message: string) => + l10n.t('Could not check the API token with Deepnote: {0}', message); + export const apiTokenSaved = (envPath: string) => l10n.t('Deepnote API token saved to {0}.', envPath); + export const envEditFailed = (envPath: string) => + l10n.t('Could not update DEEPNOTE_TOKEN in {0} automatically. Edit the file by hand.', envPath); + export const apiTokenSavedForWorkspace = (workspaceName: string, envPath: string) => + l10n.t('Signed in to the Deepnote workspace "{0}". Token saved to {1}.', workspaceName, envPath); + export const apiTokenCleared = l10n.t('Deepnote API token removed.'); + export const noApiTokenFound = l10n.t('No Deepnote API token is set for this folder.'); + export const envNotIgnored = (envPath: string) => + l10n.t('{0} is not ignored by git, so the token could be committed. Add .env to .gitignore?', envPath); + export const addToGitignore = l10n.t('Add to .gitignore'); + export const openFolderFirst = l10n.t('Open a folder to store the Deepnote API token in.'); + export const localFilesOnly = l10n.t('Deepnote Cloud sync and publishing work with local files only.'); + export const workspaceNotTrusted = l10n.t( + 'Deepnote Cloud features need a trusted workspace. Trust this workspace, then try again.' + ); + export const apiTokenRejectedForRequest = l10n.t('Deepnote rejected the API token. Set a new one and try again.'); + export const setApiToken = l10n.t('Set API Token'); + export const errorWithDetail = (message: string, detail: string) => l10n.t('{0}: {1}', message, detail); +} + export namespace SelectInputSettings { export const title = l10n.t('Select Input Settings'); export const allowMultipleValues = l10n.t('Allow to select multiple values');