diff --git a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/CodyAgentServer.kt b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/CodyAgentServer.kt index ac8dfb8710a5..30f8f6bf42ce 100644 --- a/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/CodyAgentServer.kt +++ b/agent/bindings/kotlin/lib/src/main/kotlin/com/sourcegraph/cody/agent/protocol_generated/CodyAgentServer.kt @@ -136,6 +136,8 @@ interface CodyAgentServer { fun ignore_test(params: Ignore_TestParams): CompletableFuture @JsonRequest("testing/ignore/overridePolicy") fun testing_ignore_overridePolicy(params: ContextFilters?): CompletableFuture + @JsonRequest("extension/reset") + fun extension_reset(params: Null?): CompletableFuture // ============= // Notifications diff --git a/agent/package.json b/agent/package.json index 95327f4aa175..75270bb094f3 100644 --- a/agent/package.json +++ b/agent/package.json @@ -44,7 +44,6 @@ "csv-writer": "^1.6.0", "dedent": "^0.7.0", "easy-table": "^1.2.0", - "env-paths": "^3.0.0", "fast-myers-diff": "^3.2.0", "glob": "^11.0.0", "js-levenshtein": "^1.1.6", diff --git a/agent/src/agent.ts b/agent/src/agent.ts index 58e16addd077..13ddb904a135 100644 --- a/agent/src/agent.ts +++ b/agent/src/agent.ts @@ -44,6 +44,7 @@ import type * as agent_protocol from '../../vscode/src/jsonrpc/agent-protocol' import { mkdirSync, statSync } from 'node:fs' import { PassThrough } from 'node:stream' import type { Har } from '@pollyjs/persister' +import { codyPaths } from '@sourcegraph/cody-shared' import { TESTING_TELEMETRY_EXPORTER } from '@sourcegraph/cody-shared/src/telemetry-v2/TelemetryRecorderProvider' import { type TelemetryEventParameters, TestTelemetryExporter } from '@sourcegraph/telemetry' import { copySync } from 'fs-extra' @@ -76,7 +77,6 @@ import { AgentWorkspaceConfiguration } from './AgentWorkspaceConfiguration' import { AgentWorkspaceDocuments } from './AgentWorkspaceDocuments' import { registerNativeWebviewHandlers, resolveWebviewView } from './NativeWebview' import type { PollyRequestError } from './cli/command-jsonrpc-stdio' -import { codyPaths } from './codyPaths' import { currentProtocolAuthStatus, currentProtocolAuthStatusOrNotReadyYet, @@ -967,6 +967,11 @@ export class Agent extends MessageHandler implements ExtensionClient { } }) + this.registerAuthenticatedRequest('extension/reset', async () => { + await this.globalState?.reset() + return null + }) + this.registerNotification('autocomplete/completionAccepted', async ({ completionID }) => { const provider = await vscode_shim.completionProvider() await provider.handleDidAcceptCompletionItem(completionID as CompletionItemID) diff --git a/agent/src/cli/command-auth/settings.ts b/agent/src/cli/command-auth/settings.ts index fc16002ab781..5e44d116b08c 100644 --- a/agent/src/cli/command-auth/settings.ts +++ b/agent/src/cli/command-auth/settings.ts @@ -1,7 +1,7 @@ import fs from 'node:fs' import path from 'node:path' -import { codyPaths } from '../../codyPaths' +import { codyPaths } from '@sourcegraph/cody-shared' export interface Account { // In most cases, the ID will be the same as the username. It's only when diff --git a/agent/src/cli/command-bench/command-bench.ts b/agent/src/cli/command-bench/command-bench.ts index bc76e6ba4f94..169530c8ed6e 100644 --- a/agent/src/cli/command-bench/command-bench.ts +++ b/agent/src/cli/command-bench/command-bench.ts @@ -10,7 +10,7 @@ import { newAgentClient } from '../../agent' import { exec } from 'node:child_process' import fs from 'node:fs' import { promisify } from 'node:util' -import { isDefined, modelsService, setClientCapabilities } from '@sourcegraph/cody-shared' +import { codyPaths, isDefined, modelsService, setClientCapabilities } from '@sourcegraph/cody-shared' import { sleep } from '../../../../vscode/src/completions/utils' import { getConfiguration, @@ -21,7 +21,6 @@ import { createOrUpdateTelemetryRecorderProvider } from '../../../../vscode/src/ import { startPollyRecording } from '../../../../vscode/src/testutils/polly' import { dotcomCredentials } from '../../../../vscode/src/testutils/testing-credentials' import { allClientCapabilitiesEnabled } from '../../allClientCapabilitiesEnabled' -import { codyPaths } from '../../codyPaths' import { arrayOption, booleanOption, intOption } from './cli-parsers' import { matchesGlobPatterns } from './matchesGlobPatterns' import { evaluateAutocompleteStrategy } from './strategy-autocomplete' diff --git a/agent/src/esbuild.mjs b/agent/src/esbuild.mjs index 12dc91442acb..777c6b4f96be 100644 --- a/agent/src/esbuild.mjs +++ b/agent/src/esbuild.mjs @@ -1,6 +1,8 @@ import fs from 'node:fs/promises' import path from 'node:path' import process from 'node:process' +// @ts-ignore this is not compiled by typescript so it can import files from outside the rootDir +import { detectForbiddenImportPlugin } from '../../lib/shared/esbuild.utils.mjs' import { build } from 'esbuild' @@ -65,7 +67,7 @@ async function buildAgent() { '@sourcegraph/cody-shared/src': '@sourcegraph/cody-shared/src', }, } - const res = await build(esbuildOptions) + await build(esbuildOptions) // Copy all .wasm files to the dist/ directory const distDir = path.join(process.cwd(), '..', 'vscode', 'dist') @@ -83,24 +85,3 @@ async function buildAgent() { await fs.copyFile(src, dest) } } - -function detectForbiddenImportPlugin(allForbiddenModules) { - return { - name: 'detect-forbidden-import-plugin', - setup(build) { - build.onResolve({ filter: /.*/ }, args => { - for (const forbidden of allForbiddenModules) { - if (args.path === forbidden) { - throw new Error(`'${forbidden}' module is imported in file: ${args.importer}`) - } - } - args - }) - - build.onLoad({ filter: /.*/ }, async args => { - const contents = await fs.readFile(args.path, 'utf8') - return { contents, loader: 'default' } - }) - }, - } -} diff --git a/agent/src/global-state/AgentGlobalState.ts b/agent/src/global-state/AgentGlobalState.ts index ce594be9c471..1d96411768f4 100644 --- a/agent/src/global-state/AgentGlobalState.ts +++ b/agent/src/global-state/AgentGlobalState.ts @@ -45,12 +45,10 @@ export class AgentGlobalState implements vscode.Memento { } public async reset(): Promise { - if (this.db instanceof InMemoryDB) { - this.db.clear() + this.db.clear() - // HACK(sqs): Force `localStorage` to fire a change event. - await localStorage.delete('') - } + // HACK(sqs): Force `localStorage` to fire a change event. + await localStorage.delete('') } public keys(): readonly string[] { @@ -91,6 +89,7 @@ interface DB { get(key: string): any set(key: string, value: any): void keys(): readonly string[] + clear(): void } class InMemoryDB implements DB { @@ -120,6 +119,9 @@ class LocalStorageDB implements DB { const quota = 1024 * 1024 * 256 // 256 MB this.storage = new LocalStorage(path.join(dir, `${ide}-globalState`), quota) } + clear() { + this.storage.clear() + } get(key: string): any { const item = this.storage.getItem(key) diff --git a/agent/tsconfig.json b/agent/tsconfig.json index d1b66575797c..72287b695456 100644 --- a/agent/tsconfig.json +++ b/agent/tsconfig.json @@ -5,9 +5,21 @@ "rootDir": ".", "outDir": "dist", "target": "es2019", - "allowJs": true, + "allowJs": false }, - "include": ["**/*", ".*", "package.json", "src/language-file-extensions.json"], - "exclude": ["dist", "bindings", "src/__tests__", "vitest.config.ts", "typehacks"], - "references": [{ "path": "../lib/shared" }, { "path": "../vscode" }], + "include": [ + "**/*", + ".*", + "package.json", + "src/language-file-extensions.json" + ], + "exclude": [ + "dist", + "bindings", + "src/__tests__", + "vitest.config.ts", + "typehacks", + "*.mjs" + ], + "references": [{ "path": "../lib/shared" }, { "path": "../vscode" }] } diff --git a/lib/shared/esbuild.utils.mjs b/lib/shared/esbuild.utils.mjs new file mode 100644 index 000000000000..f97bc888db93 --- /dev/null +++ b/lib/shared/esbuild.utils.mjs @@ -0,0 +1,22 @@ +import fs from 'node:fs/promises' + +export function detectForbiddenImportPlugin(allForbiddenModules) { + return { + name: 'detect-forbidden-import-plugin', + setup(build) { + build.onResolve({ filter: /.*/ }, args => { + for (const forbidden of allForbiddenModules) { + if (args.path === forbidden) { + throw new Error(`'${forbidden}' module is imported in file: ${args.importer}`) + } + } + args + }) + + build.onLoad({ filter: /.*/ }, async args => { + const contents = await fs.readFile(args.path, 'utf8') + return { contents, loader: 'default' } + }) + }, + } +} diff --git a/lib/shared/package.json b/lib/shared/package.json index 6b4de55c5a25..3ecd31f0a970 100644 --- a/lib/shared/package.json +++ b/lib/shared/package.json @@ -25,6 +25,7 @@ "date-fns": "^2.30.0", "dedent": "^0.7.0", "diff": "^5.2.0", + "env-paths": "^2.2.1", "immer": "^10.1.1", "isomorphic-fetch": "^3.0.0", "js-tiktoken": "^1.0.14", diff --git a/lib/shared/src/auth/types.ts b/lib/shared/src/auth/types.ts index b770f282021a..162881d6bae0 100644 --- a/lib/shared/src/auth/types.ts +++ b/lib/shared/src/auth/types.ts @@ -51,16 +51,14 @@ export interface UnauthenticatedAuthStatus { } export const AUTH_STATUS_FIXTURE_AUTHED: AuthenticatedAuthStatus = { - // this typecast is necessary to prevent codegen from becoming too specific - endpoint: 'https://example.com' as string, + endpoint: 'https://example.com', authenticated: true, username: 'alice', pendingValidation: false, } export const AUTH_STATUS_FIXTURE_UNAUTHED: AuthStatus & { authenticated: false } = { - // this typecast is necessary to prevent codegen from becoming too specific - endpoint: 'https://example.com' as string, + endpoint: 'https://example.com', authenticated: false, pendingValidation: false, } diff --git a/agent/src/codyPaths.ts b/lib/shared/src/codyPaths.ts similarity index 100% rename from agent/src/codyPaths.ts rename to lib/shared/src/codyPaths.ts diff --git a/lib/shared/src/configuration/resolver.ts b/lib/shared/src/configuration/resolver.ts index c88b630beb99..331309fbfc7f 100644 --- a/lib/shared/src/configuration/resolver.ts +++ b/lib/shared/src/configuration/resolver.ts @@ -19,6 +19,10 @@ export interface ConfigurationInput { clientConfiguration: ClientConfiguration clientSecrets: ClientSecrets clientState: ClientState + reinstall: { + isReinstalling(): Promise + onReinstall(): Promise + } } export interface ClientSecrets { @@ -42,6 +46,7 @@ export type ResolvedConfiguration = ReadonlyDeep<{ configuration: ClientConfiguration auth: AuthCredentials clientState: ClientState + isReinstall: boolean }> /** @@ -67,29 +72,39 @@ export type PickResolvedConfiguration = { : undefined } -async function resolveConfiguration(input: ConfigurationInput): Promise { +async function resolveConfiguration({ + clientConfiguration, + clientSecrets, + clientState, + reinstall: { isReinstalling, onReinstall }, +}: ConfigurationInput): Promise { + const isReinstall = await isReinstalling() + if (isReinstall) { + await onReinstall() + } // we allow for overriding the server endpoint from config if we haven't // manually signed in somewhere else const serverEndpoint = normalizeServerEndpointURL( - input.clientConfiguration.overrideServerEndpoint || - (input.clientState.lastUsedEndpoint ?? DOTCOM_URL.toString()) + clientConfiguration.overrideServerEndpoint || + (clientState.lastUsedEndpoint ?? DOTCOM_URL.toString()) ) // We must not throw here, because that would result in the `resolvedConfig` observable // terminating and all callers receiving no further config updates. const loadTokenFn = () => - input.clientSecrets.getToken(serverEndpoint).catch(error => { + clientSecrets.getToken(serverEndpoint).catch(error => { logError( 'resolveConfiguration', `Failed to get access token for endpoint ${serverEndpoint}: ${error}` ) return null }) - const accessToken = input.clientConfiguration.overrideAuthToken || ((await loadTokenFn()) ?? null) + const accessToken = clientConfiguration.overrideAuthToken || ((await loadTokenFn()) ?? null) return { - configuration: input.clientConfiguration, - clientState: input.clientState, + configuration: clientConfiguration, + clientState, auth: { accessToken, serverEndpoint }, + isReinstall, } } diff --git a/lib/shared/src/index.ts b/lib/shared/src/index.ts index 49dc4d968586..f98bb80b7dea 100644 --- a/lib/shared/src/index.ts +++ b/lib/shared/src/index.ts @@ -107,6 +107,7 @@ export { LARGE_FILE_WARNING_LABEL, NO_SYMBOL_MATCHES_HELP_LABEL, } from './codebase-context/messages' +export * from './codyPaths' export type { CodyCommand, CodyCommandContext, diff --git a/lib/shared/tsconfig.json b/lib/shared/tsconfig.json index 15c112b36e89..38557f8fae93 100644 --- a/lib/shared/tsconfig.json +++ b/lib/shared/tsconfig.json @@ -4,18 +4,9 @@ "module": "ESNext", "rootDir": "src", "outDir": "dist", - "lib": [ - "ESNext", - "DOM", - "DOM.Iterable" - ], - "moduleResolution": "Bundler", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "moduleResolution": "Bundler" }, - "include": [ - "src", - ], - "exclude": [ - "dist", - "typehacks" - ], + "include": ["src"], + "exclude": ["dist", "typehacks", "*.mjs"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6d5d835072b..88926bfe5a9f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -356,6 +356,9 @@ importers: diff: specifier: ^5.2.0 version: 5.2.0 + env-paths: + specifier: ^2.2.1 + version: 2.2.1 immer: specifier: ^10.1.1 version: 10.1.1 @@ -743,8 +746,8 @@ importers: specifier: ^1.79.0 version: 1.80.0 '@vscode/test-electron': - specifier: ^2.3.8 - version: 2.3.8 + specifier: ^2.4.0 + version: 2.4.0 '@vscode/test-web': specifier: ^0.0.47 version: 0.0.47 @@ -6262,11 +6265,6 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: true - /@tootallnate/once@1.1.2: - resolution: {integrity: sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==} - engines: {node: '>= 6'} - dev: true - /@tootallnate/once@2.0.0: resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} engines: {node: '>= 10'} @@ -6891,14 +6889,15 @@ packages: /@vscode/codicons@0.0.35: resolution: {integrity: sha512-7iiKdA5wHVYSbO7/Mm0hiHD3i4h+9hKUe1O4hISAe/nHhagMwb2ZbFC8jU6d7Cw+JNT2dWXN2j+WHbkhT5/l2w==} - /@vscode/test-electron@2.3.8: - resolution: {integrity: sha512-b4aZZsBKtMGdDljAsOPObnAi7+VWIaYl3ylCz1jTs+oV6BZ4TNHcVNC3xUn0azPeszBmwSBDQYfFESIaUQnrOg==} + /@vscode/test-electron@2.4.0: + resolution: {integrity: sha512-yojuDFEjohx6Jb+x949JRNtSn6Wk2FAh4MldLE3ck9cfvCqzwxF32QsNy1T9Oe4oT+ZfFcg0uPUCajJzOmPlTA==} engines: {node: '>=16'} dependencies: - http-proxy-agent: 4.0.1 - https-proxy-agent: 5.0.1 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.4 jszip: 3.10.1 - semver: 7.6.0 + ora: 7.0.1 + semver: 7.6.3 transitivePeerDependencies: - supports-color dev: true @@ -7537,6 +7536,14 @@ packages: readable-stream: 3.6.2 dev: true + /bl@5.1.0: + resolution: {integrity: sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==} + dependencies: + buffer: 6.0.3 + inherits: 2.0.4 + readable-stream: 3.6.2 + dev: true + /bluebird@3.4.7: resolution: {integrity: sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==} dev: false @@ -10279,17 +10286,6 @@ packages: transitivePeerDependencies: - supports-color - /http-proxy-agent@4.0.1: - resolution: {integrity: sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==} - engines: {node: '>= 6'} - dependencies: - '@tootallnate/once': 1.1.2 - agent-base: 6.0.2 - debug: 4.3.5 - transitivePeerDependencies: - - supports-color - dev: true - /http-proxy-agent@5.0.0: resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} engines: {node: '>= 6'} @@ -11416,6 +11412,14 @@ packages: is-unicode-supported: 0.1.0 dev: true + /log-symbols@5.1.0: + resolution: {integrity: sha512-l0x2DvrW294C9uDCoQe1VSU4gf529FkSZ6leBl4TiqZH/e+0R7hSfHQBNut2mNygDgHwvYHfFLn6Oxb3VWj2rA==} + engines: {node: '>=12'} + dependencies: + chalk: 5.3.0 + is-unicode-supported: 1.3.0 + dev: true + /log-symbols@6.0.0: resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} engines: {node: '>=18'} @@ -12816,6 +12820,21 @@ packages: wcwidth: 1.0.1 dev: true + /ora@7.0.1: + resolution: {integrity: sha512-0TUxTiFJWv+JnjWm4o9yvuskpEJLXTcng8MJuKd+SzAzp2o+OP3HWqNhB4OdJRt1Vsd9/mR0oyaEYlOnL7XIRw==} + engines: {node: '>=16'} + dependencies: + chalk: 5.3.0 + cli-cursor: 4.0.0 + cli-spinners: 2.9.2 + is-interactive: 2.0.0 + is-unicode-supported: 1.3.0 + log-symbols: 5.1.0 + stdin-discarder: 0.1.0 + string-width: 6.1.0 + strip-ansi: 7.1.0 + dev: true + /ora@8.0.1: resolution: {integrity: sha512-ANIvzobt1rls2BDny5fWZ3ZVKyD6nscLvfFRpQgfWsythlcsVUC9kL0zq6j2Z5z9wwp1kd7wpsD/T9qNPVLCaQ==} engines: {node: '>=18'} @@ -14346,6 +14365,12 @@ packages: dependencies: lru-cache: 6.0.0 + /semver@7.6.3: + resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} + engines: {node: '>=10'} + hasBin: true + dev: true + /send@0.18.0: resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==} engines: {node: '>= 0.8.0'} @@ -14716,6 +14741,13 @@ packages: resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==} dev: true + /stdin-discarder@0.1.0: + resolution: {integrity: sha512-xhV7w8S+bUwlPTb4bAOUQhv8/cSS5offJuX8GQGq32ONF0ZtDWKfkdomM3HMRA+LhX6um/FZ0COqlwsjD53LeQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + bl: 5.1.0 + dev: true + /stdin-discarder@0.2.2: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} @@ -14795,6 +14827,15 @@ packages: emoji-regex: 9.2.2 strip-ansi: 7.1.0 + /string-width@6.1.0: + resolution: {integrity: sha512-k01swCJAgQmuADB0YIc+7TuatfNvTBVOoaUWJjTB9R4VJzR5vNWzf5t42ESVZFPS8xTySF7CAdV4t/aaIm3UnQ==} + engines: {node: '>=16'} + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 10.3.0 + strip-ansi: 7.1.0 + dev: true + /string-width@7.1.0: resolution: {integrity: sha512-SEIJCWiX7Kg4c129n48aDRwLbFb2LJmXXFrWBG4NGaRtMQ3myKPKbwrD1BKqQn74oCoNMBVrfDEr5M9YxCsrkw==} engines: {node: '>=18'} diff --git a/vscode/package.json b/vscode/package.json index 4ba273e2a971..7e37bcea24b7 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -32,15 +32,16 @@ "watch:build:dev:desktop": "concurrently \"pnpm run -s _build:esbuild:desktop --watch\" \"pnpm run -s _build:webviews --mode development --watch\"", "_build:esbuild:desktop": "pnpm download-wasm && pnpm run -s _build:esbuild:uninstall && pnpm run -s _build:esbuild:node", "_build:esbuild:node": "esbuild ./src/extension.node.ts --bundle --outfile=dist/extension.node.js --external:vscode --external:typescript --alias:@sourcegraph/cody-shared=@sourcegraph/cody-shared/src/index --alias:@sourcegraph/cody-shared/src=@sourcegraph/cody-shared/src --alias:lexical=./build/lexical-package-fix --format=cjs --platform=node --sourcemap", - "_build:esbuild:web": "esbuild ./src/extension.web.ts --platform=browser --bundle --outfile=dist/extension.web.js --alias:@sourcegraph/cody-shared=@sourcegraph/cody-shared/src/index --alias:@sourcegraph/cody-shared/src=@sourcegraph/cody-shared/src --alias:path=path-browserify --external:typescript --alias:node:path=path-browserify --alias:node:os=os-browserify --external:vscode --external:node:child_process --external:node:util --external:node:fs --external:node:fs/promises --define:process='{\"env\":{}}' --define:window=self --format=cjs --sourcemap", - "_build:esbuild:uninstall": "esbuild ./src/uninstall/post-uninstall.ts --bundle --outfile=dist/post-uninstall.js --external:typescript --format=cjs --platform=node --sourcemap --alias:@sourcegraph/cody-shared=@sourcegraph/cody-shared/src/index --alias:@sourcegraph/cody-shared/src=@sourcegraph/cody-shared/src --alias:lexical=./build/lexical-package-fix", + "_build:esbuild:web": "esbuild ./src/extension.web.ts --platform=browser --bundle --outfile=dist/extension.web.js --alias:@sourcegraph/cody-shared=@sourcegraph/cody-shared/src/index --alias:@sourcegraph/cody-shared/src=@sourcegraph/cody-shared/src --alias:path=path-browserify --external:typescript --alias:node:path=path-browserify --alias:node:os=os-browserify --alias:os=os-browserify --external:vscode --external:node:child_process --external:node:util --external:node:fs --external:node:fs/promises --external:node:process --define:process='{\"env\":{}}' --define:window=self --format=cjs --sourcemap", + "_build:esbuild:uninstall": "node ./uninstall/esbuild.mjs", "_build:webviews": "vite -c webviews/vite.config.mts build", + "_build:vsix_for_test": "vsce package --no-dependencies --out dist/cody.e2e.vsix", "release": "ts-node-transpile-only ./scripts/release.ts", "download-wasm": "ts-node-transpile-only ./scripts/download-wasm-modules.ts", "copy-win-ca-roots": "ts-node-transpile-only ./scripts/copy-win-ca-roots.ts", "release:dry-run": "pnpm run download-wasm && CODY_RELEASE_DRY_RUN=1 ts-node ./scripts/release.ts", "storybook": "storybook dev -p 6007 --no-open --no-version-updates", - "test:e2e": "playwright install && tsc --build && node dist/tsc/test/e2e/install-deps.js && pnpm run -s build:dev:desktop && pnpm run -s test:e2e:run", + "test:e2e": "playwright install && tsc --build && node dist/tsc/test/e2e/install-deps.js && pnpm run -s _build:vsix_for_test && pnpm run -s build:dev:desktop && pnpm run -s test:e2e:run", "test:e2e:run": "playwright test", "test:e2e2": "pnpm -s test:e2e2:deps && pnpm -s build:root && pnpm -s build:dev:desktop && pnpm -s test:e2e2:run", "test:e2e2:run": "playwright test -c playwright.v2.config.ts", @@ -1424,7 +1425,7 @@ "@types/unzipper": "^0.10.7", "@types/uuid": "^9.0.2", "@types/vscode": "^1.79.0", - "@vscode/test-electron": "^2.3.8", + "@vscode/test-electron": "^2.4.0", "@vscode/test-web": "^0.0.47", "@vscode/vsce": "^2.22.0", "ajv": "^8.14.0", diff --git a/vscode/src/configuration.ts b/vscode/src/configuration.ts index 0c6c9ab537d1..d8cf9b649af9 100644 --- a/vscode/src/configuration.ts +++ b/vscode/src/configuration.ts @@ -172,5 +172,6 @@ export function setStaticResolvedConfigurationWithAuthCredentials({ configuration: { ...getConfiguration(), customHeaders: configuration.customHeaders }, auth, clientState: localStorage.getClientState(), + isReinstall: false, }) } diff --git a/vscode/src/extension.node.ts b/vscode/src/extension.node.ts index 4c934b7c86ab..80a863b002f4 100644 --- a/vscode/src/extension.node.ts +++ b/vscode/src/extension.node.ts @@ -1,12 +1,7 @@ // Sentry should be imported first import { NodeSentryService } from './services/sentry/sentry.node' -import { - currentAuthStatus, - currentResolvedConfig, - resolvedConfig, - subscriptionDisposable, -} from '@sourcegraph/cody-shared' +import { resolvedConfig, subscriptionDisposable } from '@sourcegraph/cody-shared' import * as vscode from 'vscode' import { startTokenReceiver } from './auth/token-receiver' import { CommandsProvider } from './commands/services/provider' @@ -16,9 +11,7 @@ import { type ExtensionClient, defaultVSCodeExtensionClient } from './extension- import { activate as activateCommon } from './extension.common' import { initializeNetworkAgent, setCustomAgent } from './fetch.node' import { SymfRunner } from './local-context/symf' -import { localStorage } from './services/LocalStorageProvider' import { OpenTelemetryService } from './services/open-telemetry/OpenTelemetryService.node' -import { serializeConfigSnapshot } from './uninstall/serializeConfig' /** * Activation entrypoint for the VS Code extension when running VS Code as a desktop app @@ -59,15 +52,3 @@ export function activate( extensionClient, }) } - -// When Cody is deactivated, we serialize the current configuration to disk, -// so that it can be sent with Telemetry when the post-uninstall script runs. -// The vscode API is not available in the post-uninstall script. -export async function deactivate(): Promise { - const config = localStorage.getConfig() ?? (await currentResolvedConfig()) - const authStatus = currentAuthStatus() - serializeConfigSnapshot({ - config, - authStatus, - }) -} diff --git a/vscode/src/jsonrpc/agent-protocol.ts b/vscode/src/jsonrpc/agent-protocol.ts index 3e8cf9855a94..b5fabbfb24ae 100644 --- a/vscode/src/jsonrpc/agent-protocol.ts +++ b/vscode/src/jsonrpc/agent-protocol.ts @@ -284,6 +284,10 @@ export type ClientRequests = { // which match the specified regular expressions. Pass `undefined` to remove // the override. 'testing/ignore/overridePolicy': [ContextFilters | null, null] + + // Called after the extension has been uninstalled by a user action. + // Attempts to wipe out any state that the extension has stored. + 'extension/reset': [null, null] } // ================ diff --git a/vscode/src/main.ts b/vscode/src/main.ts index 7176d3cafe00..16bbc2380703 100644 --- a/vscode/src/main.ts +++ b/vscode/src/main.ts @@ -4,6 +4,7 @@ import { type ChatClient, ClientConfigSingleton, type ConfigurationInput, + DOTCOM_URL, type DefaultCodyCommands, FeatureFlag, type Guardrails, @@ -36,8 +37,10 @@ import { take, telemetryRecorder, } from '@sourcegraph/cody-shared' +import _ from 'lodash' import { isEqual } from 'lodash' import { filter, map } from 'observable-fns' +import { isReinstalling } from '../uninstall/reinstall' import type { CommandResult } from './CommandResult' import { showAccountMenu } from './auth/account-menu' import { showSignInMenu, showSignOutMenu, tokenCallbackHandler } from './auth/auth' @@ -135,6 +138,8 @@ export async function start( agentCapabilities: platform.extensionClient.capabilities, }) + let hasReinstallCleanupRun = false + setResolvedConfigurationObservable( combineLatest( fromVSCodeEvent(vscode.workspace.onDidChangeConfiguration).pipe( @@ -157,6 +162,30 @@ export async function start( clientConfiguration, clientSecrets, clientState, + reinstall: { + isReinstalling, + onReinstall: async () => { + // short circuit so that we only run this cleanup once, not every time the config updates + if (hasReinstallCleanupRun) return + logDebug('start', 'Reinstalling Cody') + // VSCode does not provide a way to simply clear all secrets + // associated with the extension (https://github.com/microsoft/vscode/issues/123817) + // So we have to build a list of all endpoints we'd expect to have been populated + // and clear them individually. + const history = await localStorage.deleteEndpointHistory() + const additionalEndpointsToClear = [ + clientConfiguration.overrideServerEndpoint, + clientState.lastUsedEndpoint, + DOTCOM_URL.toString(), + ].filter(_.isString) + await Promise.all( + history + .concat(additionalEndpointsToClear) + .map(clientSecrets.deleteToken.bind(clientSecrets)) + ) + hasReinstallCleanupRun = true + }, + }, }) satisfies ConfigurationInput ) ) diff --git a/vscode/src/services/AuthProvider.test.ts b/vscode/src/services/AuthProvider.test.ts index 7964d4341553..2ff7fd5a7ab0 100644 --- a/vscode/src/services/AuthProvider.test.ts +++ b/vscode/src/services/AuthProvider.test.ts @@ -149,6 +149,10 @@ describe('AuthProvider', () => { .mockReturnValue(asyncValue(authedAuthStatusAlice, 10)) const { authProvider, authStatus, resolvedConfig } = setup() + const mockSerializeUninstallerInfo = vi + .spyOn(authProvider, 'serializeUninstallerInfo') + .mockReturnValue(asyncValue(undefined, 10)) + const { values, clearValues } = readValuesFrom(authStatus) resolvedConfig.next({ configuration: {}, @@ -165,6 +169,7 @@ describe('AuthProvider', () => { clearValues() expect(validateCredentialsMock).toHaveBeenCalledTimes(1) expect(saveEndpointAndTokenMock).toHaveBeenCalledTimes(0) + expect(mockSerializeUninstallerInfo).toHaveBeenCalledTimes(0) // Call validateAndStoreCredentials. validateCredentialsMock.mockReturnValue(asyncValue(authedAuthStatusBob, 10)) @@ -180,12 +185,14 @@ describe('AuthProvider', () => { expect(values).toStrictEqual([]) expect(validateCredentialsMock).toHaveBeenCalledTimes(2) expect(saveEndpointAndTokenMock).toHaveBeenCalledTimes(0) + expect(mockSerializeUninstallerInfo).toHaveBeenCalledTimes(0) await vi.advanceTimersByTimeAsync(9) await promise expect(values).toStrictEqual([authedAuthStatusBob]) expect(validateCredentialsMock).toHaveBeenCalledTimes(2) expect(saveEndpointAndTokenMock).toHaveBeenCalledTimes(1) + expect(mockSerializeUninstallerInfo).toHaveBeenCalledTimes(1) }) test('refresh', async () => { diff --git a/vscode/src/services/AuthProvider.ts b/vscode/src/services/AuthProvider.ts index d9b00a237b01..766f988f6e65 100644 --- a/vscode/src/services/AuthProvider.ts +++ b/vscode/src/services/AuthProvider.ts @@ -3,16 +3,17 @@ import * as vscode from 'vscode' import { type AuthCredentials, type AuthStatus, + type ClientCapabilitiesWithLegacyFields, NEVER, type ResolvedConfiguration, type Unsubscribable, abortableOperation, authStatus, - clientCapabilities, combineLatest, currentResolvedConfig, disposableSubscription, distinctUntilChanged, + clientCapabilities as getClientCapabilities, isAbortError, normalizeServerEndpointURL, pluck, @@ -25,9 +26,11 @@ import { } from '@sourcegraph/cody-shared' import isEqual from 'lodash/isEqual' import { Observable, Subject } from 'observable-fns' +import { serializeConfigSnapshot } from '../../uninstall/serializeConfig' import { type ResolvedConfigurationCredentialsOnly, validateCredentials } from '../auth/auth' import { logError } from '../output-channel-logger' import { maybeStartInteractiveTutorial } from '../tutorial/helpers' +import { version } from '../version' import { localStorage } from './LocalStorageProvider' const HAS_AUTHENTICATED_BEFORE_KEY = 'has-authenticated-before' @@ -70,7 +73,7 @@ class AuthProvider implements vscode.Disposable { ) .pipe( abortableOperation(async ([config], signal) => { - if (clientCapabilities().isCodyWeb) { + if (getClientCapabilities().isCodyWeb) { // Cody Web calls {@link AuthProvider.validateAndStoreCredentials} // explicitly. This early exit prevents duplicate authentications during // the initial load. @@ -176,7 +179,10 @@ class AuthProvider implements vscode.Disposable { const shouldStore = mode === 'always-store' || authStatus.authenticated if (shouldStore) { this.lastValidatedAndStoredCredentials.next(credentials) - await localStorage.saveEndpointAndToken(credentials.auth) + await Promise.all([ + localStorage.saveEndpointAndToken(credentials.auth), + this.serializeUninstallerInfo(authStatus), + ]) this.status.next(authStatus) signal?.throwIfAborted() } @@ -212,6 +218,34 @@ class AuthProvider implements vscode.Disposable { private setHasAuthenticatedBefore() { return localStorage.set(HAS_AUTHENTICATED_BEFORE_KEY, 'true') } + + // When the auth status is updated, we serialize the current configuration to disk, + // so that it can be sent with Telemetry when the post-uninstall script runs. + // we only write on auth change as that is the only significantly important factor + // and we don't want to write too frequently (so we don't react to config changes) + // The vscode API is not available in the post-uninstall script. + // Public so that it can be mocked for testing + public async serializeUninstallerInfo(authStatus: AuthStatus): Promise { + if (!authStatus.authenticated) return + let clientCapabilities: ClientCapabilitiesWithLegacyFields | undefined + try { + clientCapabilities = getClientCapabilities() + } catch { + // If client capabilities cannot be retrieved, we will just synthesize + // them from defaults in the post-uninstall script. + } + // TODO: put this behind a proper client capability if any other IDE's need to uninstall + // the same way as VSCode (most editors have a proper uninstall hook) + if (clientCapabilities?.isVSCode) { + const config = localStorage.getConfig() ?? (await currentResolvedConfig()) + await serializeConfigSnapshot({ + config, + authStatus, + clientCapabilities, + version, + }) + } + } } export const authProvider = new AuthProvider() diff --git a/vscode/src/services/LocalStorageProvider.ts b/vscode/src/services/LocalStorageProvider.ts index f0b8b76b39ea..9338d7d15d56 100644 --- a/vscode/src/services/LocalStorageProvider.ts +++ b/vscode/src/services/LocalStorageProvider.ts @@ -139,6 +139,13 @@ class LocalStorage implements LocalStorageForModelPreferences { await this.set(this.LAST_USED_ENDPOINT, null) } + // Deletes and returns the endpoint history + public async deleteEndpointHistory(): Promise { + const history = this.getEndpointHistory() + await Promise.all([this.deleteEndpoint(), this.set(this.CODY_ENDPOINT_HISTORY, null)]) + return history || [] + } + public getEndpointHistory(): string[] | null { return this.get(this.CODY_ENDPOINT_HISTORY) } diff --git a/vscode/src/services/telemetry-v2.ts b/vscode/src/services/telemetry-v2.ts index 6760d7e829be..a7cb9e8ec771 100644 --- a/vscode/src/services/telemetry-v2.ts +++ b/vscode/src/services/telemetry-v2.ts @@ -44,7 +44,7 @@ export function createOrUpdateTelemetryRecorderProvider( isExtensionModeDevOrTest: boolean ): Disposable { return subscriptionDisposable( - resolvedConfig.subscribe(({ configuration, auth, clientState }) => { + resolvedConfig.subscribe(({ configuration, auth, clientState, isReinstall }) => { // Add timestamp processor for realistic data in output for dev or no-op scenarios const defaultNoOpProvider = new NoOpTelemetryRecorderProvider([ new TimestampTelemetryProcessor(), @@ -86,16 +86,20 @@ export function createOrUpdateTelemetryRecorderProvider( */ const newAnonymousUser = localStorage.checkIfCreatedAnonymousUserID() if (initialize && !clientCapabilities().isCodyWeb) { - if (newAnonymousUser) { + if (newAnonymousUser || isReinstall) { /** * New user */ - telemetryRecorder.recordEvent('cody.extension', 'installed', { - billingMetadata: { - product: 'cody', - category: 'billable', - }, - }) + telemetryRecorder.recordEvent( + 'cody.extension', + isReinstall ? 'reinstalled' : 'installed', + { + billingMetadata: { + product: 'cody', + category: 'billable', + }, + } + ) } else if ( !configuration.isRunningInsideAgent || configuration.agentHasPersistentStorage diff --git a/vscode/src/uninstall/post-uninstall.ts b/vscode/src/uninstall/post-uninstall.ts deleted file mode 100644 index 71fb4b9e1d78..000000000000 --- a/vscode/src/uninstall/post-uninstall.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { - TelemetryRecorderProvider, - nextTick, - setAuthStatusObservable, - setStaticResolvedConfigurationValue, -} from '@sourcegraph/cody-shared' -import { Observable } from 'observable-fns' -import { deleteUninstallerDirectory, readConfig } from './serializeConfig' - -async function main() { - // Do not record telemetry events during testing - if (process.env.CODY_TESTING) { - return - } - - const uninstaller = readConfig() - if (uninstaller) { - const { config, authStatus } = uninstaller - if (config && authStatus) { - try { - setStaticResolvedConfigurationValue(config) - } catch {} - try { - setAuthStatusObservable(Observable.of(authStatus)) - } catch {} - // Wait for `currentAuthStatusOrNotReadyYet` to have this value synchronously. - await nextTick() - - const provider = new TelemetryRecorderProvider(config, 'connected-instance-only') - const recorder = provider.getRecorder() - recorder.recordEvent('cody.extension', 'uninstalled', { - billingMetadata: { - product: 'cody', - category: 'billable', - }, - }) - - // cleanup the uninstaller config - deleteUninstallerDirectory() - } - } -} - -main() diff --git a/vscode/src/uninstall/serializeConfig.ts b/vscode/src/uninstall/serializeConfig.ts deleted file mode 100644 index ddbba402cb7a..000000000000 --- a/vscode/src/uninstall/serializeConfig.ts +++ /dev/null @@ -1,66 +0,0 @@ -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import type { AuthStatus, ResolvedConfiguration } from '@sourcegraph/cody-shared' - -import { Platform, getOSArch } from '../os' - -const CONFIG_FILE = 'config.json' - -function getPlatformSpecificDirectory(): string { - const { platform } = getOSArch() - const appName = 'cody-ai' - - switch (platform) { - case Platform.Windows: - return path.join(process.env.APPDATA || os.homedir(), appName) - case Platform.Mac: - case Platform.Linux: - return path.join(os.homedir(), '.config', appName) - default: - throw new Error(`Unsupported platform: ${platform}`) - } -} - -function ensureDirectoryExists(directory: string) { - if (!fs.existsSync(directory)) { - fs.mkdirSync(directory, { recursive: true }) - } -} - -// Used to cleanup the uninstaller directory after the last telemetry event is sent -export function deleteUninstallerDirectory() { - fs.rmdirSync(getPlatformSpecificDirectory(), { recursive: true }) -} - -function writeSnapshot(directory: string, filename: string, content: any) { - const filePath = path.join(directory, filename) - - fs.writeFileSync(filePath, JSON.stringify(content, null, 2)) -} - -interface UninstallerConfig { - config?: ResolvedConfiguration - authStatus: AuthStatus | undefined -} - -/** - * Serializes the current configuration and auth status to disk. This is used in the case - * of an uninstall event to log one last telemetry event. - */ -export function serializeConfigSnapshot(uninstall: UninstallerConfig) { - const directory = getPlatformSpecificDirectory() - ensureDirectoryExists(directory) - writeSnapshot(directory, CONFIG_FILE, uninstall) -} - -export function readConfig(): UninstallerConfig | null { - const file = path.join(getPlatformSpecificDirectory(), CONFIG_FILE) - - if (!fs.existsSync(file)) { - return null - } - - const obj = fs.readFileSync(file, 'utf-8') - return JSON.parse(obj) -} diff --git a/vscode/test/e2e/helpers.ts b/vscode/test/e2e/helpers.ts index ab09b519272a..70c571a8286f 100644 --- a/vscode/test/e2e/helpers.ts +++ b/vscode/test/e2e/helpers.ts @@ -27,6 +27,7 @@ import { import type { RepoListResponse } from '@sourcegraph/cody-shared' import type { RepositoryIdResponse } from '@sourcegraph/cody-shared/src/sourcegraph-api/graphql/client' +import { resolveCliArgsFromVSCodeExecutablePath } from '@vscode/test-electron' import { closeSidebar, expectAuthenticated, focusSidebar } from './common' import { installVsCode } from './install-deps' import { buildCustomCommandConfigFile } from './utils/buildCustomCommands' @@ -73,6 +74,17 @@ export const getAssetsDir = (testName: string): string => export const testAssetsTmpDir = (testName: string, label: string): string => path.join(getAssetsDir(testName), `temp-${label}`) +export interface OpenVSCodeOptions { + // A list of extensions to install or uninstall before starting VSCode. These can + // be extensions that are already published to the VSCode Marketplace, or they can + // be local paths to a VSIX package. Useful if you want to behavior related to setup / teardown + installExtensions?: string[] + uninstallExtensions?: string[] + // Whether or not the code in the current git tree will be installed int the VSCode + // instance. Defaults to true. + skipLocalInstall?: boolean +} + export const test = base // By default, use ../../test/fixtures/workspace as the workspace. .extend({ @@ -137,9 +149,9 @@ export const test = base { auto: true }, ], }) - .extend<{ app: ElectronApplication }>({ + .extend<{ openVSCode: (opts?: OpenVSCodeOptions) => Promise }>({ // starts a new instance of vscode with the given workspace settings - app: async ( + openVSCode: async ( { workspaceDirectory, extraWorkspaceSettings, @@ -174,37 +186,70 @@ export const test = base TESTING_SECRET_STORAGE_TOKEN: JSON.stringify([SERVER_URL, VALID_TOKEN]), } } + const args = [ + // https://github.com/microsoft/vscode/issues/84238 + '--no-sandbox', + // https://github.com/microsoft/vscode-test/issues/120 + '--disable-updates', + '--skip-welcome', + '--skip-release-notes', + '--disable-workspace-trust', + `--user-data-dir=${userDataDirectory}`, + `--extensions-dir=${extensionsDirectory}`, + ] + + async function runVSCodeCommand(cmd: string): Promise { + const [cli, ...additionalArgs] = + resolveCliArgsFromVSCodeExecutablePath(vscodeExecutablePath) + + await spawn(cli, [...additionalArgs, ...args, cmd], { + stdio: 'ignore', + shell: process.platform === 'win32', + }) + } + // See: https://github.com/microsoft/vscode-test/blob/main/lib/runTest.ts - const app = await electron.launch({ - executablePath: vscodeExecutablePath, - env: { - ...process.env, - ...dotcomUrlOverride, - ...secretStorageState, - CODY_TESTING: 'true', - CODY_LOG_FILE: tmpLogFile, - }, - args: [ - // https://github.com/microsoft/vscode/issues/84238 - '--no-sandbox', - // https://github.com/microsoft/vscode-test/issues/120 - '--disable-updates', - '--skip-welcome', - '--skip-release-notes', - '--disable-workspace-trust', - `--extensionDevelopmentPath=${extensionDevelopmentPath}`, - `--user-data-dir=${userDataDirectory}`, - `--extensions-dir=${extensionsDirectory}`, - workspaceDirectory, - ], - recordVideo: { - // All running tests will be recorded to a temp video file. - // successful runs will be deleted, failures will be kept - dir: testAssetsTmpDir(testInfo.title, 'videos'), - }, - }) + use(async (opts: OpenVSCodeOptions = {}) => { + if (opts.installExtensions?.length) { + runVSCodeCommand( + opts.installExtensions.map(ext => `--install-extension=${ext}`).join(' ') + ) + } + if (opts.uninstallExtensions?.length) { + runVSCodeCommand( + opts.uninstallExtensions.map(ext => `--uninstall-extension=${ext}`).join(' ') + ) + } + if (!opts.skipLocalInstall) { + args.push(`--extensionDevelopmentPath=${extensionDevelopmentPath}`) + } + + args.push(workspaceDirectory) - await waitUntil(() => app.windows().length > 0) + const app = await electron.launch({ + executablePath: vscodeExecutablePath, + env: { + ...process.env, + ...dotcomUrlOverride, + ...secretStorageState, + CODY_TESTING: 'true', + CODY_LOG_FILE: tmpLogFile, + }, + args, + recordVideo: { + // All running tests will be recorded to a temp video file. + // successful runs will be deleted, failures will be kept + dir: testAssetsTmpDir(testInfo.title, 'videos'), + }, + }) + await waitUntil(() => app.windows().length > 0) + return app + }) + }, + }) + .extend<{ app: ElectronApplication }>({ + app: async ({ openVSCode, userDataDirectory, extensionsDirectory }, use) => { + const app = await openVSCode() await use(app) @@ -337,7 +382,7 @@ export async function rmSyncWithRetries(path: PathLike, options?: RmOptions): Pr } } -async function getCodySidebar(page: Page): Promise { +export async function getCodySidebar(page: Page): Promise { async function findCodySidebarFrame(): Promise { for (const frame of page.frames()) { try { @@ -347,9 +392,13 @@ async function getCodySidebar(page: Page): Promise { } } catch (error: any) { // Skip over frames that were detached in the meantime. - if (error.message.indexOf('Frame was detached') === -1) { - throw error + if ( + error.message.includes('Frame was detached') || + error.message.includes('because of a navigation') + ) { + continue } + throw error } } return null @@ -412,7 +461,7 @@ export async function executeCommandInPalette(page: Page, commandName: string): /** * Verifies that loggedEvents contain all of expectedEvents (in any order). */ -const expect = baseExpect.extend({ +export const expect = baseExpect.extend({ async toContainEvents( received: string[], expected: string[], @@ -424,11 +473,9 @@ const expect = baseExpect.extend({ try { await baseExpect - .poll(() => received, { timeout: 3000, ...options }) + .poll(() => received, { timeout: options?.timeout ?? 3000, ...options }) .toEqual(baseExpect.arrayContaining(expected)) } catch (e: any) { - // const missingEvents = new Set() - // const extraEvents = new Set() const receivedSet = new Set(received) for (const event of expected) { if (!receivedSet.has(event)) { diff --git a/vscode/test/e2e/install-deps.ts b/vscode/test/e2e/install-deps.ts index e6f7a08ada06..3f6980a05ed1 100644 --- a/vscode/test/e2e/install-deps.ts +++ b/vscode/test/e2e/install-deps.ts @@ -1,12 +1,6 @@ import { spawn } from 'node:child_process' -import { - ConsoleReporter, - type ProgressReport, - ProgressReportStage, - downloadAndUnzipVSCode as _downloadAndUnzipVSCode, -} from '@vscode/test-electron' -import type { DownloadOptions } from '@vscode/test-electron/out/download' +import { SilentReporter, downloadAndUnzipVSCode } from '@vscode/test-electron' // The VS Code version to use for e2e tests (there is also a version in ../integration/main.ts used for integration tests). // @@ -15,38 +9,8 @@ import type { DownloadOptions } from '@vscode/test-electron/out/download' // missed because we're running on an older version than users. const vscodeVersion = 'stable' -// A custom version of the VS Code download reporter that silences matching installation -// notifications as these otherwise are emitted on every test run -class CustomConsoleReporter extends ConsoleReporter { - public report(report: ProgressReport): void { - if (report.stage !== ProgressReportStage.FoundMatchingInstall) { - super.report(report) - } - } -} - -/** - * Patches the default logger but otherwise leaves all options available - * @param opts - */ -export function downloadAndUnzipVSCode(opts: Partial) { - return _downloadAndUnzipVSCode( - Object.assign( - { - version: vscodeVersion, - reporter: new CustomConsoleReporter(process.stdout.isTTY), - } satisfies Partial, - opts - ) - ) -} - -export function installVsCode(): Promise { - return _downloadAndUnzipVSCode( - vscodeVersion, - undefined, - new CustomConsoleReporter(process.stdout.isTTY) - ) +export async function installVsCode(): Promise { + return downloadAndUnzipVSCode(vscodeVersion, undefined, new SilentReporter()) } function installChromium(): Promise { diff --git a/vscode/test/e2e/uninstall.test.ts b/vscode/test/e2e/uninstall.test.ts new file mode 100644 index 000000000000..b04fe37938b6 --- /dev/null +++ b/vscode/test/e2e/uninstall.test.ts @@ -0,0 +1,56 @@ +import path from 'node:path' +import type { Page } from 'playwright' +import { loggedV2Events } from '../fixtures/mock-server' +import { expectAuthenticated, focusSidebar, sidebarSignin } from './common' +import { expect, getCodySidebar, test } from './helpers' + +test('uninstall extension', async ({ openVSCode }) => { + // In order to trigger the uninstall event, we need to actually install the extension + // into the local vscode instance + const customExtensionVSIX = path.join(process.cwd(), 'dist', 'cody.e2e.vsix') + let app = await openVSCode({ + installExtensions: [customExtensionVSIX], + skipLocalInstall: true, + }) + let page = await app.firstWindow() + await signin(page) + await app.close() + + // Now we uninstall the extension, and re-open VSCode. This will trigger the + // vscode:uninstall event which will trigger telemetry events and set a marker + // that the app has been uninstalled + app = await openVSCode({ + uninstallExtensions: [customExtensionVSIX], + skipLocalInstall: true, + }) + // Allow the uninstaller to finish + await expect(loggedV2Events).toContainEvents(['cody.extension:uninstalled'], { timeout: 5000 }) + await app.close() + + // we re-install the extension, and re-open VSCode. This will trigger the + // the reinstall flow which will trigger telemetry events but will clear out secret storage + app = await openVSCode({ + installExtensions: [customExtensionVSIX], + skipLocalInstall: true, + }) + page = await app.firstWindow() + + // This will fail if the credentials are saved because the login screen will still be + // visible, thus it acts as an implicit test that credentials were cleared out + await signin(page) + await expect(loggedV2Events).toContainEvents(['cody.extension:reinstalled'], { timeout: 5000 }) + await app.close() + + // Finally, re-open the VSCode and ensure that we are still logged in + app = await openVSCode({ + skipLocalInstall: true, + }) + page = await app.firstWindow() + await expectAuthenticated(page) +}) + +async function signin(page: Page): Promise { + await focusSidebar(page) + const sidebar = await getCodySidebar(page) + await sidebarSignin(page, sidebar) +} diff --git a/vscode/tsconfig.json b/vscode/tsconfig.json index f83c0a08f846..ea82d5d5fba9 100644 --- a/vscode/tsconfig.json +++ b/vscode/tsconfig.json @@ -22,7 +22,8 @@ "e2e", "webviews", "webviews/*.d.ts", - "package.json" + "package.json", + "uninstall" ], "exclude": [ "typehacks", diff --git a/vscode/uninstall/esbuild.mjs b/vscode/uninstall/esbuild.mjs new file mode 100644 index 000000000000..e2c3b2200378 --- /dev/null +++ b/vscode/uninstall/esbuild.mjs @@ -0,0 +1,39 @@ +// @ts-ignore this is not compiled by typescript so it can import files from outside the rootDir +import { detectForbiddenImportPlugin } from '../../lib/shared/esbuild.utils.mjs' + +import { build as esbuild } from 'esbuild' + +async function build() { + const plugins = [detectForbiddenImportPlugin(['vscode'])] + /** @type {import('esbuild').BuildOptions} */ + const esbuildOptions = { + entryPoints: ['./uninstall/post-uninstall.ts'], + bundle: true, + platform: 'node', + sourcemap: true, + logLevel: 'silent', + write: true, + outfile: './dist/post-uninstall.js', + plugins, + external: ['typescript'], + format: 'cjs', + alias: { + // Build from TypeScript sources so we don't need to run `tsc -b` in the background + // during dev. + '@sourcegraph/cody-shared': '@sourcegraph/cody-shared/src/index', + '@sourcegraph/cody-shared/src': '@sourcegraph/cody-shared/src', + lexical: './build/lexical-package-fix', + }, + } + + return esbuild(esbuildOptions) +} + +build() + .then(() => { + console.log('Post-uninstall script built successfully.') + }) + .catch(err => { + console.error('Could not build the post-uninstall script.', err.message) + process.exit(1) + }) diff --git a/vscode/uninstall/post-uninstall.ts b/vscode/uninstall/post-uninstall.ts new file mode 100644 index 000000000000..e94cfc30a329 --- /dev/null +++ b/vscode/uninstall/post-uninstall.ts @@ -0,0 +1,61 @@ +import { + CodyIDE, + MockServerTelemetryRecorderProvider, + TelemetryRecorderProvider, + mockClientCapabilities, + nextTick, + setAuthStatusObservable, + setStaticResolvedConfigurationValue, +} from '@sourcegraph/cody-shared' +import { Observable } from 'observable-fns' +import { createUninstallMarker } from './reinstall' +import { deleteUninstallerConfig, readConfig } from './serializeConfig' + +async function main() { + const uninstaller = await readConfig() + if (uninstaller) { + const { config, authStatus, version, clientCapabilities } = uninstaller + if (config && authStatus) { + try { + setStaticResolvedConfigurationValue(config) + } catch (error) { + console.error('Failed to set config', error) + } + try { + setAuthStatusObservable(Observable.of(authStatus)) + } catch (error) { + console.error('Failed to set auth status', error) + } + // Wait for `currentAuthStatusOrNotReadyYet` to have this value synchronously. + await nextTick() + mockClientCapabilities( + clientCapabilities ?? { + agentIDE: CodyIDE.VSCode, + isVSCode: true, + isCodyWeb: false, + agentExtensionVersion: version, + // Unused by TelemetryRecorderProvider + agentIDEVersion: '', + telemetryClientName: `${CodyIDE.VSCode}.Cody`, + } + ) + + const provider = process.env.CODY_TESTING + ? new MockServerTelemetryRecorderProvider(config) + : new TelemetryRecorderProvider(config, 'connected-instance-only') + const recorder = provider.getRecorder() + recorder.recordEvent('cody.extension', 'uninstalled', { + billingMetadata: { + product: 'cody', + category: 'billable', + }, + }) + + // cleanup the uninstaller config + await deleteUninstallerConfig() + await createUninstallMarker() + } + } +} + +main() diff --git a/vscode/uninstall/reinstall.ts b/vscode/uninstall/reinstall.ts new file mode 100644 index 000000000000..c69c3d2425e6 --- /dev/null +++ b/vscode/uninstall/reinstall.ts @@ -0,0 +1,29 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { codyPaths } from '@sourcegraph/cody-shared' + +export const uninstallMarker = path.join(codyPaths().config, 'uninstall-marker') + +export const createUninstallMarker = async (): Promise => { + await fs.writeFile(uninstallMarker, '') +} + +let isReinstall: boolean | undefined = undefined +// Checks if the user is reinstalling the extension by checking for the existence of a marker file +// If found, it deletes the marker file so that we only report reinstalling once +// Caches the value of isReinstall so that throughout the lifetime of the extension +// it still reports it as a re-install +export const isReinstalling = async (): Promise => { + if (typeof isReinstall === 'boolean') { + return isReinstall + } + try { + await fs.stat(uninstallMarker) + await fs.unlink(uninstallMarker) + isReinstall = true + } catch (error) { + isReinstall = false + } + + return isReinstall +} diff --git a/vscode/uninstall/serializeConfig.ts b/vscode/uninstall/serializeConfig.ts new file mode 100644 index 000000000000..03d4f8497529 --- /dev/null +++ b/vscode/uninstall/serializeConfig.ts @@ -0,0 +1,70 @@ +import * as fs from 'node:fs/promises' +import * as path from 'node:path' +import { + type AuthStatus, + type ClientCapabilitiesWithLegacyFields, + type ResolvedConfiguration, + codyPaths, +} from '@sourcegraph/cody-shared' + +const CONFIG_FILE = 'config.json' + +const getConfigPath = () => path.join(codyPaths().config, CONFIG_FILE) + +async function exists(path: string): Promise { + try { + await fs.stat(path) + return true + } catch { + return false + } +} + +async function ensureDirectoryExists(directory: string) { + if (!(await exists(directory))) { + await fs.mkdir(directory, { recursive: true }) + } +} + +// Used to cleanup the uninstaller directory after the last telemetry event is sent +export async function deleteUninstallerConfig() { + return fs.rm(getConfigPath()) +} + +async function writeSnapshot( + directory: string, + filename: string, + content: UninstallerConfig +): Promise { + const filePath = path.join(directory, filename) + + return fs.writeFile(filePath, JSON.stringify(content, null, 2)) +} + +interface UninstallerConfig { + config?: ResolvedConfiguration + authStatus: AuthStatus | undefined + clientCapabilities?: ClientCapabilitiesWithLegacyFields + version?: string +} + +/** + * Serializes the current configuration and auth status to disk. This is used in the case + * of an uninstall event to log one last telemetry event. + */ +export async function serializeConfigSnapshot(uninstall: UninstallerConfig) { + const directory = codyPaths().config + await ensureDirectoryExists(directory) + await writeSnapshot(directory, CONFIG_FILE, uninstall) +} + +export async function readConfig(): Promise { + const file = getConfigPath() + + if (!(await exists(file))) { + return null + } + + const obj = await fs.readFile(file, 'utf-8') + return JSON.parse(obj) +}