From 6daf51c8c7cbeed4a033ca35ddc2c3c0f02f3a31 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Fri, 9 Oct 2026 17:42:00 +0300 Subject: [PATCH 1/2] refactor: name modules by topic and document the code structure Replace the util and grab-bag modules with topic-named modules, and write the layout rules down in CODE_STRUCTURE.md. Extension: - Move each util module to the module that takes most of its code (for example `util/fs` to `common/fs`, `api/utils` to `api/httpAgent`, `core/cliUtils` to `cli/cliBinary`). `core/` splits into `cli/`, `storage/` and `vscode/`; `container.ts` moves next to `extension.ts`, and `vscodeProposed.ts` becomes `vscode/proposed.ts`. - Split grab-bag modules by topic: `common/fs`, `common/strings`, `common/url`, `api/models`, `error/abort`, `error/normalize`, `logging/ids`, `oauth/tokens`, and `sessionId` in `telemetry/ids`. - Move single-consumer helpers into their consumer, and drop the unused `LogSources` re-export. Webviews: - Rename `utils` modules after their topic (`shared/error/toError`, `shared/netcheck/severity`, `shared/tasks/status`, `speedtest/chartGeometry`, `speedtest/format`), lift `tasks/src/utils/*` to the package root, and move `useTaskMenuItems` into `tasks/src/hooks/`. Tests move to mirror their source path. CODE_STRUCTURE.md covers the runtime boundary and one-way package dependencies, `@repo/ui` as a standalone library, where new code goes, topic names instead of `utils`/`helpers`, file naming, and test placement. It describes how to decide rather than listing folders, so adding a folder or package needs no doc change. --- .oxlintrc.jsonc | 2 +- .vscodeignore | 1 + AGENTS.md | 15 +- CODE_STRUCTURE.md | 120 +++++++++ CONTRIBUTING.md | 7 +- .../shared/src/error/{utils.ts => toError.ts} | 0 packages/shared/src/index.ts | 6 +- .../src/netcheck/{utils.ts => severity.ts} | 0 .../shared/src/tasks/{utils.ts => status.ts} | 0 packages/speedtest/src/chart.ts | 3 +- .../src/{chartUtils.ts => chartGeometry.ts} | 37 +-- packages/speedtest/src/format.ts | 34 +++ packages/speedtest/src/index.ts | 5 +- packages/tasks/src/components/ActionMenu.tsx | 2 +- packages/tasks/src/components/PromptInput.tsx | 2 +- .../tasks/src/components/TaskDetailHeader.tsx | 4 +- packages/tasks/src/components/TaskItem.tsx | 6 +- packages/tasks/src/{utils => }/config.ts | 0 packages/tasks/src/hooks/useSelectedTask.ts | 2 +- .../{components => hooks}/useTaskMenuItems.ts | 7 +- packages/tasks/src/hooks/useTasksQuery.ts | 2 +- packages/tasks/src/{utils => }/keys.ts | 0 .../tasks/src/{utils => }/taskLoadingState.ts | 0 src/announcements/manager.ts | 8 +- ...gentMetadataHelper.ts => agentMetadata.ts} | 28 +- src/api/authInterceptor.ts | 6 +- src/api/coderApi.ts | 11 +- src/api/{utils.ts => httpAgent.ts} | 2 +- src/api/{api-helper.ts => models.ts} | 70 +---- src/api/workspace.ts | 3 +- src/{core => cli}/binaryLock.ts | 2 +- src/{core/cliUtils.ts => cli/cliBinary.ts} | 0 src/{core => cli}/cliCredentialManager.ts | 7 +- src/{core => cli}/cliExec.ts | 5 +- src/{core => cli}/cliManager.ts | 54 ++-- src/{core => cli}/downloadProgress.ts | 0 src/command/diagnosticFlow.ts | 4 +- src/commands.ts | 51 ++-- src/common/fs.ts | 196 ++++++++++++++ src/common/strings.ts | 38 +++ src/{util/uri.ts => common/url.ts} | 16 ++ src/{core => }/container.ts | 49 ++-- src/core/sessionId.ts | 16 -- src/deployment/deploymentManager.ts | 10 +- src/error/{errorUtils.ts => abort.ts} | 28 -- src/error/certificateError.ts | 2 +- src/error/clientCertificateError.ts | 2 +- src/error/normalize.ts | 47 ++++ src/error/serverCertificateError.ts | 2 +- src/extension.ts | 9 +- src/instrumentation/credentials.ts | 2 +- src/instrumentation/outcomes.ts | 2 +- src/instrumentation/workspaceOpen.ts | 2 +- src/logging/eventStreamLogger.ts | 6 +- src/logging/flightRecorder.ts | 2 +- src/logging/formatters.ts | 58 +++- src/logging/httpLogger.ts | 4 +- src/logging/ids.ts | 9 + src/logging/utils.ts | 63 ----- src/{util => login}/credentials.ts | 2 +- src/login/loginCoordinator.ts | 29 +- src/oauth/authorizer.ts | 35 ++- src/oauth/constants.ts | 5 + src/oauth/sessionManager.ts | 6 +- src/oauth/{utils.ts => tokens.ts} | 31 +-- src/pgp.ts | 2 +- src/{util => remote}/authority.ts | 2 +- src/remote/environment.ts | 2 +- src/remote/migration.ts | 4 +- src/remote/remote.ts | 35 +-- src/remote/sshConfig.ts | 22 +- src/remote/sshProcess.ts | 38 ++- src/remote/sshSupport.ts | 2 +- src/remote/windowsAcl.ts | 2 +- src/remote/workspaceStateMachine.ts | 18 +- src/settings/cli.ts | 3 +- src/{core => storage}/mementoManager.ts | 0 src/{core => storage}/pathResolver.ts | 4 +- src/{core => storage}/secretsManager.ts | 2 +- src/supportBundle/appendVsCodeLogs.ts | 4 +- src/supportBundle/remoteServerDataPath.ts | 2 +- src/{util => telemetry}/date.ts | 0 src/telemetry/event.ts | 2 +- src/telemetry/export/command.ts | 4 +- src/telemetry/export/files.ts | 2 +- src/telemetry/export/pipeline.ts | 2 +- src/telemetry/export/prompts.ts | 2 +- src/telemetry/export/range.ts | 2 +- src/telemetry/export/writers/json.ts | 2 +- src/telemetry/export/writers/otlp/envelope.ts | 2 +- src/telemetry/export/writers/otlp/writer.ts | 12 +- src/telemetry/ids.ts | 15 ++ src/telemetry/sinks/localJsonlSink.ts | 7 +- src/{ => ui}/progress.ts | 2 +- src/{promptUtils.ts => ui/prompts.ts} | 7 +- src/{util => ui}/statusBar.ts | 0 src/uri/uriHandler.ts | 12 +- src/util.ts | 105 -------- src/util/fileCleanup.ts | 94 ------- src/util/fs.ts | 84 ------ src/{core => vscode}/commandManager.ts | 0 src/{core => vscode}/contextManager.ts | 0 src/{vscodeProposed.ts => vscode/proposed.ts} | 0 src/websocket/codes.ts | 16 ++ src/websocket/oneWayWebSocket.ts | 17 +- src/websocket/reconnectingWebSocket.ts | 4 +- src/websocket/sseConnection.ts | 6 +- src/websocket/utils.ts | 45 ---- src/webviews/dispatch.ts | 2 +- src/webviews/tasks/tasksPanelProvider.ts | 6 +- src/{util => webviews/workspaces}/poller.ts | 2 +- src/webviews/workspaces/workspaceStore.ts | 8 +- ...Provider.ts => workspacesPanelProvider.ts} | 2 +- src/workspace/agentMetadataTracker.ts | 2 +- src/workspace/filters.ts | 2 +- src/workspace/observers.ts | 2 +- src/workspace/workspaceMonitor.ts | 15 +- src/workspace/workspacesProvider.ts | 10 +- test/mocks/commands.ts | 2 +- test/mocks/testHelpers.ts | 16 +- test/unit/announcements/manager.test.ts | 2 +- test/unit/api/authInterceptor.test.ts | 4 +- test/unit/api/coderApi.test.ts | 9 +- .../api/{utils.test.ts => httpAgent.test.ts} | 2 +- .../{api-helper.test.ts => models.test.ts} | 2 +- test/unit/{core => cli}/binaryLock.test.ts | 6 +- .../cliBinary.test.ts} | 18 +- .../cliCredentialManager.test.ts | 10 +- test/unit/{core => cli}/cliExec.test.ts | 4 +- .../cliManager.concurrent.test.ts | 22 +- .../cliManager.telemetry.test.ts | 4 +- test/unit/{core => cli}/cliManager.test.ts | 4 +- test/unit/{core => cli}/cliManagerHarness.ts | 6 +- .../{core => cli}/downloadProgress.test.ts | 2 +- test/unit/commands.netcheck.test.ts | 4 +- test/unit/commands.openSshConfig.test.ts | 2 +- test/unit/commands.supportBundle.test.ts | 8 +- test/unit/commands.telemetry.test.ts | 16 +- ...st.ts => commands.updateWorkspace.test.ts} | 4 +- test/unit/{util => common}/fs.test.ts | 186 ++++++++++++- test/unit/common/strings.test.ts | 104 +++++++ .../{util/uri.test.ts => common/url.test.ts} | 2 +- .../unit/deployment/deploymentManager.test.ts | 4 +- test/unit/error/abort.test.ts | 70 +++++ .../{errorUtils.test.ts => normalize.test.ts} | 76 +----- test/unit/logging/formatters.test.ts | 91 +++++++ test/unit/logging/ids.test.ts | 25 ++ test/unit/logging/utils.test.ts | 123 --------- test/unit/{util => login}/credentials.test.ts | 2 +- test/unit/login/loginCoordinator.test.ts | 12 +- test/unit/oauth/authorizer.test.ts | 4 +- test/unit/oauth/metadataClient.test.ts | 4 +- test/unit/oauth/sessionManager.test.ts | 6 +- test/unit/oauth/testUtils.ts | 4 +- .../oauth/{utils.test.ts => tokens.test.ts} | 2 +- test/unit/{util => remote}/authority.test.ts | 2 +- test/unit/remote/environment.test.ts | 2 +- test/unit/remote/migration.test.ts | 4 +- test/unit/remote/remote.test.ts | 14 +- test/unit/remote/sshConfig.test.ts | 33 +++ test/unit/remote/sshProcess.test.ts | 44 +++ .../unit/remote/workspaceStateMachine.test.ts | 8 +- .../cli.test.ts} | 4 +- .../{core => storage}/mementoManager.test.ts | 2 +- .../{core => storage}/pathResolver.test.ts | 2 +- .../{core => storage}/secretsManager.test.ts | 4 +- .../supportBundle/appendVsCodeLogs.test.ts | 7 +- test/unit/{util => telemetry}/date.test.ts | 2 +- test/unit/{ => ui}/progress.test.ts | 4 +- test/unit/uri/uriHandler.test.ts | 12 +- test/unit/util.test.ts | 253 ------------------ test/unit/util/fileCleanup.test.ts | 116 -------- .../{core => vscode}/commandManager.test.ts | 2 +- .../{utils.test.ts => codes.test.ts} | 7 +- test/unit/websocket/oneWayWebSocket.test.ts | 2 +- .../websocket/reconnectingWebSocket.test.ts | 2 +- test/unit/websocket/sseConnection.test.ts | 3 +- .../workspaces}/poller.test.ts | 8 +- ...est.ts => workspacesPanelProvider.test.ts} | 2 +- .../unit/workspace/workspacesProvider.test.ts | 2 +- .../{ => shared}/netcheck/severity.test.ts | 2 +- .../tasks/{utils.test.ts => status.test.ts} | 0 ...artUtils.test.ts => chartGeometry.test.ts} | 62 +---- test/webview/speedtest/format.test.ts | 64 +++++ .../{ => components}/ActionMenu.test.tsx | 2 +- .../AgentChatHistory.test.tsx | 2 +- .../CreateTaskSection.test.tsx | 2 +- .../{ => components}/ErrorBanner.test.tsx | 2 +- .../tasks/{ => components}/LogViewer.test.tsx | 2 +- .../{ => components}/PromptInput.test.tsx | 2 +- .../{ => components}/StatePanel.test.tsx | 0 .../{ => components}/StatusIndicator.test.tsx | 0 .../TaskDetailHeader.test.tsx | 4 +- .../{ => components}/TaskDetailView.test.tsx | 4 +- .../tasks/{ => components}/TaskItem.test.tsx | 2 +- .../tasks/{ => components}/TaskList.test.tsx | 2 +- .../TaskMessageInput.test.tsx | 4 +- .../{ => components}/WorkspaceLogs.test.tsx | 2 +- .../{ => hooks}/useTaskMenuItems.test.tsx | 4 +- .../{ => hooks}/useWorkspaceLogs.test.ts | 0 test/webview/tasks/keys.test.ts | 2 +- .../Tree/Tree.core.test.tsx} | 0 .../Tree/Tree.hover.test.tsx} | 0 .../Tree/Tree.keyboard.test.tsx} | 0 .../Tree/Tree.rows.test.tsx} | 0 .../Tree/Tree.selection.test.tsx} | 0 .../Tree/Tree.sticky.test.tsx} | 0 .../Tree/sticky/stickyState.test.ts} | 0 .../{ => components/Tree}/treeModel.test.tsx | 0 .../{ => components/Tree}/treePolicy.test.ts | 0 .../{ => components/Tree}/treeTestHelpers.tsx | 0 .../Tree}/treeTransition.test.ts | 0 .../Tree/useTreeAdapter.test.tsx} | 0 .../ui/{ => components}/components.test.tsx | 2 +- .../ui/{ => components}/forms.test.tsx | 2 +- .../ui/{ => components}/overlays.test.tsx | 0 .../{shared => webview-shared}/api.test.ts | 0 .../{shared => webview-shared}/dom.test.ts | 0 .../{shared => webview-shared}/ipc.test.ts | 0 .../react}/ErrorBoundary.test.tsx | 0 .../react}/hooks.test.ts | 0 .../react}/useIpc.test.ts | 0 .../{ => hooks}/useWorkspaces.test.tsx | 0 223 files changed, 1744 insertions(+), 1641 deletions(-) create mode 100644 CODE_STRUCTURE.md rename packages/shared/src/error/{utils.ts => toError.ts} (100%) rename packages/shared/src/netcheck/{utils.ts => severity.ts} (100%) rename packages/shared/src/tasks/{utils.ts => status.ts} (100%) rename packages/speedtest/src/{chartUtils.ts => chartGeometry.ts} (67%) create mode 100644 packages/speedtest/src/format.ts rename packages/tasks/src/{utils => }/config.ts (100%) rename packages/tasks/src/{components => hooks}/useTaskMenuItems.ts (92%) rename packages/tasks/src/{utils => }/keys.ts (100%) rename packages/tasks/src/{utils => }/taskLoadingState.ts (100%) rename src/api/{agentMetadataHelper.ts => agentMetadata.ts} (84%) rename src/api/{utils.ts => httpAgent.ts} (98%) rename src/api/{api-helper.ts => models.ts} (54%) rename src/{core => cli}/binaryLock.ts (98%) rename src/{core/cliUtils.ts => cli/cliBinary.ts} (100%) rename src/{core => cli}/cliCredentialManager.ts (98%) rename src/{core => cli}/cliExec.ts (98%) rename src/{core => cli}/cliManager.ts (95%) rename src/{core => cli}/downloadProgress.ts (100%) create mode 100644 src/common/fs.ts create mode 100644 src/common/strings.ts rename src/{util/uri.ts => common/url.ts} (80%) rename src/{core => }/container.ts (79%) delete mode 100644 src/core/sessionId.ts rename src/error/{errorUtils.ts => abort.ts} (58%) create mode 100644 src/error/normalize.ts create mode 100644 src/logging/ids.ts delete mode 100644 src/logging/utils.ts rename src/{util => login}/credentials.ts (95%) rename src/oauth/{utils.ts => tokens.ts} (67%) rename src/{util => remote}/authority.ts (99%) rename src/{core => storage}/mementoManager.ts (100%) rename src/{core => storage}/pathResolver.ts (98%) rename src/{core => storage}/secretsManager.ts (99%) rename src/{util => telemetry}/date.ts (100%) rename src/{ => ui}/progress.ts (98%) rename src/{promptUtils.ts => ui/prompts.ts} (96%) rename src/{util => ui}/statusBar.ts (100%) delete mode 100644 src/util.ts delete mode 100644 src/util/fileCleanup.ts delete mode 100644 src/util/fs.ts rename src/{core => vscode}/commandManager.ts (100%) rename src/{core => vscode}/contextManager.ts (100%) rename src/{vscodeProposed.ts => vscode/proposed.ts} (100%) delete mode 100644 src/websocket/utils.ts rename src/{util => webviews/workspaces}/poller.ts (97%) rename src/webviews/workspaces/{panelProvider.ts => workspacesPanelProvider.ts} (98%) rename test/unit/api/{utils.test.ts => httpAgent.test.ts} (99%) rename test/unit/api/{api-helper.test.ts => models.test.ts} (92%) rename test/unit/{core => cli}/binaryLock.test.ts (96%) rename test/unit/{core/cliUtils.test.ts => cli/cliBinary.test.ts} (83%) rename test/unit/{core => cli}/cliCredentialManager.test.ts (98%) rename test/unit/{core => cli}/cliExec.test.ts (99%) rename test/unit/{core => cli}/cliManager.concurrent.test.ts (90%) rename test/unit/{core => cli}/cliManager.telemetry.test.ts (98%) rename test/unit/{core => cli}/cliManager.test.ts (99%) rename test/unit/{core => cli}/cliManagerHarness.ts (98%) rename test/unit/{core => cli}/downloadProgress.test.ts (98%) rename test/unit/{command/updateWorkspace.telemetry.test.ts => commands.updateWorkspace.test.ts} (92%) rename test/unit/{util => common}/fs.test.ts (53%) create mode 100644 test/unit/common/strings.test.ts rename test/unit/{util/uri.test.ts => common/url.test.ts} (99%) create mode 100644 test/unit/error/abort.test.ts rename test/unit/error/{errorUtils.test.ts => normalize.test.ts} (62%) create mode 100644 test/unit/logging/ids.test.ts delete mode 100644 test/unit/logging/utils.test.ts rename test/unit/{util => login}/credentials.test.ts (96%) rename test/unit/oauth/{utils.test.ts => tokens.test.ts} (98%) rename test/unit/{util => remote}/authority.test.ts (99%) rename test/unit/{cliConfig.test.ts => settings/cli.test.ts} (98%) rename test/unit/{core => storage}/mementoManager.test.ts (98%) rename test/unit/{core => storage}/pathResolver.test.ts (99%) rename test/unit/{core => storage}/secretsManager.test.ts (99%) rename test/unit/{util => telemetry}/date.test.ts (98%) rename test/unit/{ => ui}/progress.test.ts (98%) delete mode 100644 test/unit/util.test.ts delete mode 100644 test/unit/util/fileCleanup.test.ts rename test/unit/{core => vscode}/commandManager.test.ts (99%) rename test/unit/websocket/{utils.test.ts => codes.test.ts} (96%) rename test/unit/{util => webviews/workspaces}/poller.test.ts (95%) rename test/unit/webviews/workspaces/{panelProvider.test.ts => workspacesPanelProvider.test.ts} (99%) rename test/webview/{ => shared}/netcheck/severity.test.ts (90%) rename test/webview/shared/tasks/{utils.test.ts => status.test.ts} (100%) rename test/webview/speedtest/{chartUtils.test.ts => chartGeometry.test.ts} (71%) create mode 100644 test/webview/speedtest/format.test.ts rename test/webview/tasks/{ => components}/ActionMenu.test.tsx (99%) rename test/webview/tasks/{ => components}/AgentChatHistory.test.tsx (99%) rename test/webview/tasks/{ => components}/CreateTaskSection.test.tsx (98%) rename test/webview/tasks/{ => components}/ErrorBanner.test.tsx (97%) rename test/webview/tasks/{ => components}/LogViewer.test.tsx (95%) rename test/webview/tasks/{ => components}/PromptInput.test.tsx (98%) rename test/webview/tasks/{ => components}/StatePanel.test.tsx (100%) rename test/webview/tasks/{ => components}/StatusIndicator.test.tsx (100%) rename test/webview/tasks/{ => components}/TaskDetailHeader.test.tsx (97%) rename test/webview/tasks/{ => components}/TaskDetailView.test.tsx (98%) rename test/webview/tasks/{ => components}/TaskItem.test.tsx (98%) rename test/webview/tasks/{ => components}/TaskList.test.tsx (96%) rename test/webview/tasks/{ => components}/TaskMessageInput.test.tsx (98%) rename test/webview/tasks/{ => components}/WorkspaceLogs.test.tsx (97%) rename test/webview/tasks/{ => hooks}/useTaskMenuItems.test.tsx (98%) rename test/webview/tasks/{ => hooks}/useWorkspaceLogs.test.ts (100%) rename test/webview/ui/{tree.core.test.tsx => components/Tree/Tree.core.test.tsx} (100%) rename test/webview/ui/{tree.hover.test.tsx => components/Tree/Tree.hover.test.tsx} (100%) rename test/webview/ui/{tree.keyboard.test.tsx => components/Tree/Tree.keyboard.test.tsx} (100%) rename test/webview/ui/{tree.rows.test.tsx => components/Tree/Tree.rows.test.tsx} (100%) rename test/webview/ui/{tree.selection.test.tsx => components/Tree/Tree.selection.test.tsx} (100%) rename test/webview/ui/{tree.sticky.test.tsx => components/Tree/Tree.sticky.test.tsx} (100%) rename test/webview/ui/{treeStickyState.test.ts => components/Tree/sticky/stickyState.test.ts} (100%) rename test/webview/ui/{ => components/Tree}/treeModel.test.tsx (100%) rename test/webview/ui/{ => components/Tree}/treePolicy.test.ts (100%) rename test/webview/ui/{ => components/Tree}/treeTestHelpers.tsx (100%) rename test/webview/ui/{ => components/Tree}/treeTransition.test.ts (100%) rename test/webview/ui/{treeController.test.tsx => components/Tree/useTreeAdapter.test.tsx} (100%) rename test/webview/ui/{ => components}/components.test.tsx (99%) rename test/webview/ui/{ => components}/forms.test.tsx (99%) rename test/webview/ui/{ => components}/overlays.test.tsx (100%) rename test/webview/{shared => webview-shared}/api.test.ts (100%) rename test/webview/{shared => webview-shared}/dom.test.ts (100%) rename test/webview/{shared => webview-shared}/ipc.test.ts (100%) rename test/webview/{shared => webview-shared/react}/ErrorBoundary.test.tsx (100%) rename test/webview/{shared => webview-shared/react}/hooks.test.ts (100%) rename test/webview/{shared => webview-shared/react}/useIpc.test.ts (100%) rename test/webview/workspaces/{ => hooks}/useWorkspaces.test.tsx (100%) diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index a9423ee32c..5477ce4c9a 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -268,7 +268,7 @@ } }, { - "files": ["src/core/contextManager.ts", "src/core/commandManager.ts"], + "files": ["src/vscode/contextManager.ts", "src/vscode/commandManager.ts"], "rules": { "eslint-js/no-restricted-syntax": "off" } diff --git a/.vscodeignore b/.vscodeignore index 0997b1288f..9e785a0cc6 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -54,6 +54,7 @@ node_modules/** # Documentation and media usage.md AGENTS.md +CODE_STRUCTURE.md *.gif # Build artifacts diff --git a/AGENTS.md b/AGENTS.md index 909ecc4dfd..e123cee839 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,13 @@ doesn't make sense. Honesty over agreeableness. - Match the style of surrounding code - consistency within a file matters - Fix bugs immediately when you find them +## Code Structure + +[CODE_STRUCTURE.md](CODE_STRUCTURE.md) is the single source for where code +lives: the extension/webview boundary and package dependencies, where new +code goes, topic-named modules (no `utils` or `helpers`), file naming and +test placement. Follow it when adding, moving or splitting modules. + ## Essential Commands | Task | Command | @@ -92,8 +99,8 @@ vi.mocked(SomeClass).mockImplementation(function () { ```text test/ -├── unit/ # Extension unit tests (mirrors src/ structure) -├── webview/ # Webview unit tests (by package name) +├── unit/ # Extension unit tests (mirrors src/) +├── webview/ # Webview unit tests (mirrors packages//src/) ├── integration/ # VS Code integration tests (uses Mocha, not Vitest) ├── utils/ # Test utilities that are also tested └── mocks/ # Shared test mocks @@ -138,8 +145,8 @@ Non-negotiables: - Use async/await for promises, avoid explicit Promise construction where possible - Unit test files must be named `*.test.ts` and use Vitest -- Extension tests go in `./test/unit/` -- Webview tests go in `./test/webview//` +- Test files mirror their source path (see + [CODE_STRUCTURE.md](CODE_STRUCTURE.md#tests)) - Never disable lint rules without user approval ### Naming and Comments diff --git a/CODE_STRUCTURE.md b/CODE_STRUCTURE.md new file mode 100644 index 0000000000..1724881fb1 --- /dev/null +++ b/CODE_STRUCTURE.md @@ -0,0 +1,120 @@ +# Code Structure + +Rules for where code lives and what it is called. The rules describe how to +decide, not what exists today, so adding a folder or package needs no edit +here. Change this file only when a rule changes. + +## Runtimes + +The repo builds for two runtimes, split the same way as Electron's main and +renderer processes: + +- **Extension host** (`src/`): Node.js plus the `vscode` API. +- **Webviews** (`packages/`): isolated browser contexts with no Node or + `vscode` access. + +Rules for code on either side: + +- **Cross the boundary only with messages.** The extension and webviews talk + through the typed IPC contracts in `@repo/shared` (see the + [`webview-shared` README](packages/webview-shared/README.md)), never by + importing each other's code. +- **Keep `@repo/shared` runtime-free.** Use plain TypeScript, with no Node + built-ins, DOM or `vscode`. When behavior differs by runtime, take it as a + parameter, the way `toError` takes its serializer. +- **Dependencies point one way.** `src/` imports only `@repo/shared`. Panel + packages may import `@repo/webview-shared`, `@repo/ui` and `@repo/shared`, + and `@repo/webview-shared` imports only `@repo/shared`. Nothing imports a + panel package. +- **Import a package through its `exports`** (`@repo/shared`, + `@repo/webview-shared/react`), never through a relative path into another + package. Inside a package, shared internals can use `#` subpath imports. +- **`@repo/mocks` and `@repo/storybook-utils` are for tests and stories + only.** Lint rejects them in runtime code. + +### `@repo/ui` + +`@repo/ui` is a library of generic, VS Code-style React components, and it is +built to be split into its own package later: + +- **It imports no other workspace package**, and lint enforces this. +- **It styles only through its `--ui-*` tokens**, so it never depends on a + particular panel. +- **Its public API is the package root and its CSS exports.** Consumers never + reach into its files. + +A component that knows about Coder, tasks or workspaces belongs in a panel +package, even if it is built from `@repo/ui` parts. The +[`ui` README](packages/ui/README.md) covers the details. + +## Where new code goes + +Pick the first option that fits: + +1. **One consumer:** put it in that consumer's file. +2. **One feature:** put it in a module in that feature's folder. A new + feature gets its own `src//` folder, and a new webview panel + gets its own package. +3. **Several features, one domain:** put it in that domain's folder (the + folder for the Coder API, the CLI, storage, VS Code APIs, and so on). + Code that knows about a domain never goes in `src/common/`. +4. **Several features, no domain:** put it in a topic file in `src/common/` + (for example `strings.ts`). Don't add a new top-level file for it. + +In webviews, apply the same steps inside a package. Code used by more than +one package goes in: + +- `@repo/shared`: types and IPC contracts the extension also uses +- `@repo/webview-shared`: IPC and React plumbing for webviews +- `@repo/ui`: generic components (see [`@repo/ui`](#repoui)) + +Move code to a shared place only once a second consumer needs it. + +## Modules + +- **Name a module by its topic**, so the name says what's inside + (`severity.ts`, `httpAgent.ts`). In `src/` and `packages/*/src/`, never use + `utils`, `helpers` or `misc` as a file or folder name. Test-only support + code is the exception (see [Tests](#tests)). +- **Group related functions in one module.** Don't create one file per + function. When a module mixes topics, split it by topic, not by size. +- **A class, component or hook gets its own file**, named after it: + + | Kind | File | Main export | + | ---------------- | ---------------------- | ---------------------------- | + | Extension module | `fooManager.ts` | `FooManager` or `fooManager` | + | React component | `FooPanel.tsx` | `FooPanel` | + | Hook | `useFoo.ts` | `useFoo` | + | Story | `FooPanel.stories.tsx` | stories for `FooPanel` | + +- **In a panel package, hooks used by several components go in `hooks/`.** + A hook used by one component sits next to that component. Library packages + keep a hook next to the code it serves. +- **Import from the module that defines a symbol.** Re-export files exist + only as a package's entry points, the paths listed in its `package.json` + `exports`. + +## Tests + +- **Tests mirror source:** + - `src/.ts` → `test/unit/.test.ts` + - `packages//src/.ts` → `test/webview//.test.ts` +- **Split a large suite by aspect** as `..test.ts`, for + example `fooManager.concurrent.test.ts`. +- **A suite that tests several modules through a public API** sits in the + closest folder those modules share. Suites that guard repo config, like + `oxlintConfig.test.ts`, sit at the `test/unit/` root. +- **Test support code** goes next to the tests that use it. Mocks shared + across folders go in `test/mocks/`, and tested helpers in `test/utils/`. + +## Background + +- [Electron process model](https://www.electronjs.org/docs/latest/tutorial/process-model) + and [VS Code webviews](https://code.visualstudio.com/api/extension-guides/webview): + isolated renderers that talk to the privileged side only through messages +- [VS Code source organization](https://github.com/microsoft/vscode/wiki/Source-Code-Organization): + runtime-free `common` code that both sides can use, with dependencies + pointing one way +- [VS Code `base/common`](https://github.com/microsoft/vscode/tree/main/src/vs/base/common): + topic modules with several functions each +- [Colocation](https://kentcdodds.com/blog/colocation) (Kent C. Dodds) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 87b0db4ebe..d5c407e362 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,9 @@ ## Architecture +Where code lives and how modules are named is described in +[CODE_STRUCTURE.md](CODE_STRUCTURE.md). + When the Coder Remote plugin handles a request to open a workspace, it invokes Microsoft's [Remote - SSH](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-ssh) extension using the following URI structure: @@ -221,8 +224,8 @@ Test files are organized by type: ```text test/ -├── unit/ # Extension unit tests -├── webview/ # Webview unit tests (jsdom environment) +├── unit/ # Extension unit tests (mirrors src/) +├── webview/ # Webview unit tests (mirrors packages//src/, jsdom) ├── integration/ # Integration tests (real VS Code) └── mocks/ # Shared test mocks ``` diff --git a/packages/shared/src/error/utils.ts b/packages/shared/src/error/toError.ts similarity index 100% rename from packages/shared/src/error/utils.ts rename to packages/shared/src/error/toError.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 684e49e72e..711137cb6b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2,11 +2,11 @@ export * from "./ipc/protocol"; // Error utilities -export { toError } from "./error/utils"; +export { toError } from "./error/toError"; // Tasks types, utilities, and API export * from "./tasks/types"; -export * from "./tasks/utils"; +export * from "./tasks/status"; export * from "./tasks/api"; // Speedtest API @@ -19,7 +19,7 @@ export { // Netcheck API export { NetcheckApi } from "./netcheck/api"; -export { overallNetcheckSeverity, worstSeverity } from "./netcheck/utils"; +export { overallNetcheckSeverity, worstSeverity } from "./netcheck/severity"; export type { NetcheckConnectivity, NetcheckData, diff --git a/packages/shared/src/netcheck/utils.ts b/packages/shared/src/netcheck/severity.ts similarity index 100% rename from packages/shared/src/netcheck/utils.ts rename to packages/shared/src/netcheck/severity.ts diff --git a/packages/shared/src/tasks/utils.ts b/packages/shared/src/tasks/status.ts similarity index 100% rename from packages/shared/src/tasks/utils.ts rename to packages/shared/src/tasks/status.ts diff --git a/packages/speedtest/src/chart.ts b/packages/speedtest/src/chart.ts index 6a1a3f6e39..dd7344621a 100644 --- a/packages/speedtest/src/chart.ts +++ b/packages/speedtest/src/chart.ts @@ -1,4 +1,5 @@ -import { type ChartPoint, formatTick, niceRound, niceStep } from "./chartUtils"; +import { type ChartPoint, niceRound, niceStep } from "./chartGeometry"; +import { formatTick } from "./format"; const MIN_TICK_SPACING_EM = 4; /** Target Y-axis tick intervals; the actual count varies slightly so steps land on round numbers. */ diff --git a/packages/speedtest/src/chartUtils.ts b/packages/speedtest/src/chartGeometry.ts similarity index 67% rename from packages/speedtest/src/chartUtils.ts rename to packages/speedtest/src/chartGeometry.ts index ea56b8d870..72ea092569 100644 --- a/packages/speedtest/src/chartUtils.ts +++ b/packages/speedtest/src/chartGeometry.ts @@ -1,3 +1,5 @@ +import { formatThroughput } from "./format"; + import type { SpeedtestResult } from "@repo/shared"; export interface ChartPoint { @@ -30,41 +32,6 @@ export function niceRound(n: number): number { return fraction * mag; } -/** Pick the largest time unit that fits a duration (or tick step). */ -function pickTimeUnit(seconds: number): { - unit: "s" | "m" | "h"; - divisor: number; -} { - if (seconds >= 3600) return { unit: "h", divisor: 3600 }; - if (seconds >= 60) return { unit: "m", divisor: 60 }; - return { unit: "s", divisor: 1 }; -} - -/** Format a tick value as `Ns`, `Nm`, or `Nh` depending on the step size. */ -export function formatTick(t: number, step: number): string { - const { unit, divisor } = pickTimeUnit(step); - const v = t / divisor; - if (unit === "s") return `${v}s`; - return `${Number.isInteger(v) ? v : v.toFixed(1)}${unit}`; -} - -/** `YY.XX` below 1000 Mbps, integer above. */ -export function formatThroughput(mbits: number): string { - return mbits >= 1000 ? mbits.toFixed(0) : mbits.toFixed(2); -} - -/** Format a duration value-magnitude as `{value, unit}` so the summary can style the unit. */ -export function formatDuration(seconds: number): { - value: string; - unit: string; -} { - const { unit, divisor } = pickTimeUnit(seconds); - const v = seconds / divisor; - // Sub-minute always shows a decimal; m/h drop the trailing zero on whole numbers. - if (unit === "s") return { value: v.toFixed(1), unit }; - return { value: Number.isInteger(v) ? String(v) : v.toFixed(1), unit }; -} - /** Convert speedtest intervals into chart points with hover labels. */ export function toChartSamples( intervals: SpeedtestResult["intervals"], diff --git a/packages/speedtest/src/format.ts b/packages/speedtest/src/format.ts new file mode 100644 index 0000000000..6b8e4081df --- /dev/null +++ b/packages/speedtest/src/format.ts @@ -0,0 +1,34 @@ +/** Pick the largest time unit that fits a duration (or tick step). */ +function pickTimeUnit(seconds: number): { + unit: "s" | "m" | "h"; + divisor: number; +} { + if (seconds >= 3600) return { unit: "h", divisor: 3600 }; + if (seconds >= 60) return { unit: "m", divisor: 60 }; + return { unit: "s", divisor: 1 }; +} + +/** Format a tick value as `Ns`, `Nm`, or `Nh` depending on the step size. */ +export function formatTick(t: number, step: number): string { + const { unit, divisor } = pickTimeUnit(step); + const v = t / divisor; + if (unit === "s") return `${v}s`; + return `${Number.isInteger(v) ? v : v.toFixed(1)}${unit}`; +} + +/** `YY.XX` below 1000 Mbps, integer above. */ +export function formatThroughput(mbits: number): string { + return mbits >= 1000 ? mbits.toFixed(0) : mbits.toFixed(2); +} + +/** Format a duration value-magnitude as `{value, unit}` so the summary can style the unit. */ +export function formatDuration(seconds: number): { + value: string; + unit: string; +} { + const { unit, divisor } = pickTimeUnit(seconds); + const v = seconds / divisor; + // Sub-minute always shows a decimal; m/h drop the trailing zero on whole numbers. + if (unit === "s") return { value: v.toFixed(1), unit }; + return { value: Number.isInteger(v) ? String(v) : v.toFixed(1), unit }; +} diff --git a/packages/speedtest/src/index.ts b/packages/speedtest/src/index.ts index b0fd9da45d..3430ce4010 100644 --- a/packages/speedtest/src/index.ts +++ b/packages/speedtest/src/index.ts @@ -14,10 +14,9 @@ import { type ChartPoint, findNearestDot, findNearestOnLine, - formatDuration, - formatThroughput, toChartSamples, -} from "./chartUtils"; +} from "./chartGeometry"; +import { formatDuration, formatThroughput } from "./format"; import "./index.css"; /** Above this sample count, render the line alone (no per-point dots). */ diff --git a/packages/tasks/src/components/ActionMenu.tsx b/packages/tasks/src/components/ActionMenu.tsx index 849b061469..b72d549c19 100644 --- a/packages/tasks/src/components/ActionMenu.tsx +++ b/packages/tasks/src/components/ActionMenu.tsx @@ -4,7 +4,7 @@ import { } from "@vscode-elements/react-elements"; import { useState, useRef, useEffect } from "react"; -import { isEscape } from "../utils/keys"; +import { isEscape } from "../keys"; interface ActionMenuAction { label: string; diff --git a/packages/tasks/src/components/PromptInput.tsx b/packages/tasks/src/components/PromptInput.tsx index f64194c685..1449ce4a55 100644 --- a/packages/tasks/src/components/PromptInput.tsx +++ b/packages/tasks/src/components/PromptInput.tsx @@ -3,7 +3,7 @@ import { VscodeProgressRing, } from "@vscode-elements/react-elements"; -import { isSubmit } from "../utils/keys"; +import { isSubmit } from "../keys"; export interface PromptInputProps { value: string; diff --git a/packages/tasks/src/components/TaskDetailHeader.tsx b/packages/tasks/src/components/TaskDetailHeader.tsx index c3a6d66ead..ba343705e0 100644 --- a/packages/tasks/src/components/TaskDetailHeader.tsx +++ b/packages/tasks/src/components/TaskDetailHeader.tsx @@ -1,11 +1,11 @@ import { getTaskLabel, type Task } from "@repo/shared"; import { VscodeIcon } from "@vscode-elements/react-elements"; -import { getActionLabel } from "../utils/taskLoadingState"; +import { useTaskMenuItems } from "../hooks/useTaskMenuItems"; +import { getActionLabel } from "../taskLoadingState"; import { ActionMenu } from "./ActionMenu"; import { StatusIndicator } from "./StatusIndicator"; -import { useTaskMenuItems } from "./useTaskMenuItems"; interface TaskDetailHeaderProps { task: Task; diff --git a/packages/tasks/src/components/TaskItem.tsx b/packages/tasks/src/components/TaskItem.tsx index 4f05564dac..8b4337a926 100644 --- a/packages/tasks/src/components/TaskItem.tsx +++ b/packages/tasks/src/components/TaskItem.tsx @@ -1,12 +1,12 @@ import { getTaskLabel, type Task } from "@repo/shared"; import { VscodeProgressRing } from "@vscode-elements/react-elements"; -import { isActivate } from "../utils/keys"; -import { getActionLabel } from "../utils/taskLoadingState"; +import { useTaskMenuItems } from "../hooks/useTaskMenuItems"; +import { isActivate } from "../keys"; +import { getActionLabel } from "../taskLoadingState"; import { ActionMenu } from "./ActionMenu"; import { StatusIndicator } from "./StatusIndicator"; -import { useTaskMenuItems } from "./useTaskMenuItems"; interface TaskItemProps { task: Task; diff --git a/packages/tasks/src/utils/config.ts b/packages/tasks/src/config.ts similarity index 100% rename from packages/tasks/src/utils/config.ts rename to packages/tasks/src/config.ts diff --git a/packages/tasks/src/hooks/useSelectedTask.ts b/packages/tasks/src/hooks/useSelectedTask.ts index b9adeb2cb0..4e0fa2f4e5 100644 --- a/packages/tasks/src/hooks/useSelectedTask.ts +++ b/packages/tasks/src/hooks/useSelectedTask.ts @@ -6,7 +6,7 @@ import { TASK_ACTIVE_INTERVAL_MS, TASK_IDLE_INTERVAL_MS, queryKeys, -} from "../utils/config"; +} from "../config"; import { useTasksApi } from "./useTasksApi"; diff --git a/packages/tasks/src/components/useTaskMenuItems.ts b/packages/tasks/src/hooks/useTaskMenuItems.ts similarity index 92% rename from packages/tasks/src/components/useTaskMenuItems.ts rename to packages/tasks/src/hooks/useTaskMenuItems.ts index d5a0985aa0..f1a0440d2e 100644 --- a/packages/tasks/src/components/useTaskMenuItems.ts +++ b/packages/tasks/src/hooks/useTaskMenuItems.ts @@ -2,11 +2,10 @@ import { getTaskPermissions, getTaskLabel, type Task } from "@repo/shared"; import { logger } from "@repo/webview-shared/logger"; import { useMutation } from "@tanstack/react-query"; -import { useTasksApi } from "../hooks/useTasksApi"; +import { useTasksApi } from "./useTasksApi"; -import type { TaskLoadingState } from "../utils/taskLoadingState"; - -import type { ActionMenuItem } from "./ActionMenu"; +import type { ActionMenuItem } from "../components/ActionMenu"; +import type { TaskLoadingState } from "../taskLoadingState"; interface UseTaskMenuItemsOptions { task: Task; diff --git a/packages/tasks/src/hooks/useTasksQuery.ts b/packages/tasks/src/hooks/useTasksQuery.ts index 09f917ae7c..eda442f5ae 100644 --- a/packages/tasks/src/hooks/useTasksQuery.ts +++ b/packages/tasks/src/hooks/useTasksQuery.ts @@ -5,7 +5,7 @@ import { TASK_LIST_POLL_INTERVAL_MS, TEMPLATE_POLL_INTERVAL_MS, queryKeys, -} from "../utils/config"; +} from "../config"; import { useTasksApi } from "./useTasksApi"; diff --git a/packages/tasks/src/utils/keys.ts b/packages/tasks/src/keys.ts similarity index 100% rename from packages/tasks/src/utils/keys.ts rename to packages/tasks/src/keys.ts diff --git a/packages/tasks/src/utils/taskLoadingState.ts b/packages/tasks/src/taskLoadingState.ts similarity index 100% rename from packages/tasks/src/utils/taskLoadingState.ts rename to packages/tasks/src/taskLoadingState.ts diff --git a/src/announcements/manager.ts b/src/announcements/manager.ts index f780aee81d..b9b5df7229 100644 --- a/src/announcements/manager.ts +++ b/src/announcements/manager.ts @@ -1,9 +1,9 @@ import * as vscode from "vscode"; -import { errToStr } from "../api/api-helper"; -import { withProgress } from "../progress"; +import { errToStr } from "../error/normalize"; import { areNotificationsDisabled } from "../settings/notifications"; -import { createStatusBarItem } from "../util/statusBar"; +import { withProgress } from "../ui/progress"; +import { createStatusBarItem } from "../ui/statusBar"; import { type Announcement, @@ -16,9 +16,9 @@ import { import { AnnouncementsPreview } from "./preview"; import type { CoderApi } from "../api/coderApi"; -import type { MementoManager } from "../core/mementoManager"; import type { SessionState } from "../deployment/sessionStore"; import type { Logger } from "../logging/logger"; +import type { MementoManager } from "../storage/mementoManager"; /** Background poll interval; sign-in and manual refresh happen immediately either way. */ export const REFRESH_INTERVAL_MS = 30 * 60 * 1000; diff --git a/src/api/agentMetadataHelper.ts b/src/api/agentMetadata.ts similarity index 84% rename from src/api/agentMetadataHelper.ts rename to src/api/agentMetadata.ts index 14c52d60da..ee747e7330 100644 --- a/src/api/agentMetadataHelper.ts +++ b/src/api/agentMetadata.ts @@ -1,16 +1,32 @@ import { type WorkspaceAgent } from "coder/site/src/api/typesGenerated"; import * as vscode from "vscode"; +import { z } from "zod"; +import { errToStr } from "../error/normalize"; import { WebSocketCloseCode } from "../websocket/codes"; -import { - type AgentMetadataEvent, - AgentMetadataEventSchemaArray, - errToStr, -} from "./api-helper"; - import type { UnidirectionalStream } from "../websocket/eventStreamConnection"; +export const AgentMetadataEventSchema = z.object({ + result: z.object({ + collected_at: z.string(), + age: z.number(), + value: z.string(), + error: z.string(), + }), + description: z.object({ + display_name: z.string(), + key: z.string(), + script: z.string(), + interval: z.number(), + timeout: z.number(), + }), +}); + +export const AgentMetadataEventSchemaArray = z.array(AgentMetadataEventSchema); + +export type AgentMetadataEvent = z.infer; + export interface AgentMetadataClient { watchAgentMetadata( agentId: string, diff --git a/src/api/authInterceptor.ts b/src/api/authInterceptor.ts index b7962bb3ac..7ea7bc5cab 100644 --- a/src/api/authInterceptor.ts +++ b/src/api/authInterceptor.ts @@ -1,17 +1,17 @@ import { type AxiosError, isAxiosError } from "axios"; +import { toSafeHost } from "../common/url"; import { AuthTelemetry } from "../instrumentation/auth"; import { OAuthError } from "../oauth/errors"; -import { toSafeHost } from "../util/uri"; import { HttpStatusCode } from "./httpStatusCode"; import type * as vscode from "vscode"; -import type { ServiceContainer } from "../core/container"; -import type { SecretsManager } from "../core/secretsManager"; +import type { ServiceContainer } from "../container"; import type { Logger } from "../logging/logger"; import type { OAuthSessionManager } from "../oauth/sessionManager"; +import type { SecretsManager } from "../storage/secretsManager"; import type { CoderApi } from "./coderApi"; diff --git a/src/api/coderApi.ts b/src/api/coderApi.ts index 6398e1086e..80fe8d84a4 100644 --- a/src/api/coderApi.ts +++ b/src/api/coderApi.ts @@ -12,12 +12,12 @@ import { CONFIG_CHANGE_DEBOUNCE_MS, watchConfigurationChanges, } from "../configWatcher"; -import { sessionId } from "../core/sessionId"; import { ClientCertificateError } from "../error/clientCertificateError"; -import { toError } from "../error/errorUtils"; +import { toError } from "../error/normalize"; import { ServerCertificateError } from "../error/serverCertificateError"; import { getHeaders } from "../headers"; import { EventStreamLogger } from "../logging/eventStreamLogger"; +import { sizeOf } from "../logging/formatters"; import { createRequestMeta, logError, @@ -29,15 +29,15 @@ import { type RequestConfigWithMeta, type HttpClientLogLevel, } from "../logging/types"; -import { sizeOf } from "../logging/utils"; import { AuthConfigTracker } from "../settings/authConfig"; import { getHeaderCommand } from "../settings/headers"; import { readHttpClientLogLevel } from "../settings/logger"; +import { sessionId } from "../telemetry/ids"; import { NOOP_TELEMETRY_REPORTER, type TelemetryReporter, } from "../telemetry/reporter"; -import { WebSocketCloseCode } from "../websocket/codes"; +import { WebSocketCloseCode, handshakeStatus } from "../websocket/codes"; import { OneWayWebSocket, type OneWayWebSocketInit, @@ -49,16 +49,15 @@ import { type SocketFactory, } from "../websocket/reconnectingWebSocket"; import { SseConnection } from "../websocket/sseConnection"; -import { handshakeStatus } from "../websocket/utils"; import { getRefreshCommand, refreshCertificates } from "./certificateRefresh"; +import { createHttpAgent } from "./httpAgent"; import { HttpStatusCode } from "./httpStatusCode"; import { parseApiResponse, VALIDATED_RESPONSES, type ValidatedMethods, } from "./responseValidation"; -import { createHttpAgent } from "./utils"; import type { GetInboxNotificationResponse, diff --git a/src/api/utils.ts b/src/api/httpAgent.ts similarity index 98% rename from src/api/utils.ts rename to src/api/httpAgent.ts index fb58a74dab..d7a60d8bfc 100644 --- a/src/api/utils.ts +++ b/src/api/httpAgent.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import { ProxyAgent } from "proxy-agent"; import { type WorkspaceConfiguration } from "vscode"; -import { expandPath } from "../util"; +import { expandPath } from "../common/fs"; import { getProxyForUrl, joinNoProxy } from "./proxy"; diff --git a/src/api/api-helper.ts b/src/api/models.ts similarity index 54% rename from src/api/api-helper.ts rename to src/api/models.ts index 6a2d7c6b7c..2d70b0eb8c 100644 --- a/src/api/api-helper.ts +++ b/src/api/models.ts @@ -1,4 +1,3 @@ -import { isApiError, isApiErrorResponse } from "coder/site/src/api/errors"; import { type User, type Workspace, @@ -6,27 +5,6 @@ import { type WorkspaceResource, type WorkspaceStatus, } from "coder/site/src/api/typesGenerated"; -import { ErrorEvent } from "eventsource"; -import { z } from "zod"; - -/** - * Convert various error types to readable strings - */ -export function errToStr(error: unknown, def = "No error message provided") { - if (error instanceof Error && error.message) { - return error.message; - } else if (isApiError(error)) { - return error.response.data.message; - } else if (isApiErrorResponse(error)) { - return error.message; - } else if (error instanceof ErrorEvent) { - const message = error.message || def; - return error.code ? `${error.code}: ${message}` : message; - } else if (typeof error === "string" && error.trim().length > 0) { - return error; - } - return def; -} /** True when the user holds the deployment-wide owner role. */ export function isOwner(user: User | undefined): boolean { @@ -40,22 +18,6 @@ export function createWorkspaceIdentifier(workspace: Workspace): string { return `${workspace.owner_name}/${workspace.name}`; } -export function extractAllAgents( - workspaces: readonly Workspace[], -): WorkspaceAgent[] { - return workspaces.reduce((acc, workspace) => { - return acc.concat(extractAgents(workspace.latest_build.resources)); - }, [] as WorkspaceAgent[]); -} - -export function extractAgents( - resources: readonly WorkspaceResource[], -): WorkspaceAgent[] { - return resources.reduce((acc, resource) => { - return acc.concat(resource.agents ?? []); - }, [] as WorkspaceAgent[]); -} - const WORKSPACE_STATUS_LABEL: Readonly> = { canceled: "Canceled", canceling: "Canceling", @@ -76,22 +38,18 @@ export function workspaceStatusLabel(status: WorkspaceStatus): string { ); } -export const AgentMetadataEventSchema = z.object({ - result: z.object({ - collected_at: z.string(), - age: z.number(), - value: z.string(), - error: z.string(), - }), - description: z.object({ - display_name: z.string(), - key: z.string(), - script: z.string(), - interval: z.number(), - timeout: z.number(), - }), -}); - -export const AgentMetadataEventSchemaArray = z.array(AgentMetadataEventSchema); +export function extractAllAgents( + workspaces: readonly Workspace[], +): WorkspaceAgent[] { + return workspaces.reduce((acc, workspace) => { + return acc.concat(extractAgents(workspace.latest_build.resources)); + }, [] as WorkspaceAgent[]); +} -export type AgentMetadataEvent = z.infer; +export function extractAgents( + resources: readonly WorkspaceResource[], +): WorkspaceAgent[] { + return resources.reduce((acc, resource) => { + return acc.concat(resource.agents ?? []); + }, [] as WorkspaceAgent[]); +} diff --git a/src/api/workspace.ts b/src/api/workspace.ts index 2ee12a6820..365d5781e6 100644 --- a/src/api/workspace.ts +++ b/src/api/workspace.ts @@ -1,9 +1,10 @@ import { spawn } from "node:child_process"; import * as vscode from "vscode"; +import { errToStr } from "../error/normalize"; import { getGlobalFlags, type CliAuth } from "../settings/cli"; -import { errToStr, createWorkspaceIdentifier } from "./api-helper"; +import { createWorkspaceIdentifier } from "./models"; import type { Api } from "coder/site/src/api/api"; import type { diff --git a/src/core/binaryLock.ts b/src/cli/binaryLock.ts similarity index 98% rename from src/core/binaryLock.ts rename to src/cli/binaryLock.ts index ae6e2c3cd3..0555dad2e4 100644 --- a/src/core/binaryLock.ts +++ b/src/cli/binaryLock.ts @@ -3,7 +3,7 @@ import * as lockfile from "proper-lockfile"; import * as vscode from "vscode"; import { type Logger } from "../logging/logger"; -import { vscodeProposed } from "../vscodeProposed"; +import { vscodeProposed } from "../vscode/proposed"; import * as downloadProgress from "./downloadProgress"; diff --git a/src/core/cliUtils.ts b/src/cli/cliBinary.ts similarity index 100% rename from src/core/cliUtils.ts rename to src/cli/cliBinary.ts diff --git a/src/core/cliCredentialManager.ts b/src/cli/cliCredentialManager.ts similarity index 98% rename from src/core/cliCredentialManager.ts rename to src/cli/cliCredentialManager.ts index 099eb1d6c7..16bc140026 100644 --- a/src/core/cliCredentialManager.ts +++ b/src/cli/cliCredentialManager.ts @@ -3,7 +3,8 @@ import fs from "node:fs/promises"; import { promisify } from "node:util"; import * as semver from "semver"; -import { isAbortError } from "../error/errorUtils"; +import { toSafeHost } from "../common/url"; +import { isAbortError } from "../error/abort"; import { cliFeatureSet, type CliFeatureSet } from "../featureSet"; import { categorizeCredentialError, @@ -18,17 +19,15 @@ import { resolveCliAuth, } from "../settings/cli"; import { type TelemetryReporter } from "../telemetry/reporter"; -import { toSafeHost } from "../util/uri"; import { version } from "./cliExec"; import type { WorkspaceConfiguration } from "vscode"; import type { Logger } from "../logging/logger"; +import type { PathResolver } from "../storage/pathResolver"; import type { Span } from "../telemetry/span"; -import type { PathResolver } from "./pathResolver"; - const execFileAsync = promisify(execFile); const EXEC_TIMEOUT_MS = 60_000; diff --git a/src/core/cliExec.ts b/src/cli/cliExec.ts similarity index 98% rename from src/core/cliExec.ts rename to src/cli/cliExec.ts index 0cad9fd0dd..f793159931 100644 --- a/src/core/cliExec.ts +++ b/src/cli/cliExec.ts @@ -3,13 +3,14 @@ import { stat } from "node:fs/promises"; import { promisify } from "node:util"; import * as vscode from "vscode"; -import { isAbortError, toError } from "../error/errorUtils"; +import { escapeCommandArg } from "../common/strings"; +import { isAbortError } from "../error/abort"; +import { toError } from "../error/normalize"; import { type CliAuth, getGlobalFlags, getGlobalShellFlags, } from "../settings/cli"; -import { escapeCommandArg } from "../util"; /** Resolved CLI environment for invoking the coder binary. */ export interface CliEnv { diff --git a/src/core/cliManager.ts b/src/cli/cliManager.ts similarity index 95% rename from src/core/cliManager.ts rename to src/cli/cliManager.ts index 13a109876f..bc49b00e54 100644 --- a/src/core/cliManager.ts +++ b/src/cli/cliManager.ts @@ -9,8 +9,10 @@ import prettyBytes from "pretty-bytes"; import * as semver from "semver"; import * as vscode from "vscode"; -import { errToStr } from "../api/api-helper"; import { HttpStatusCode } from "../api/httpStatusCode"; +import { tempFilePath } from "../common/fs"; +import { toSafeHost } from "../common/url"; +import { errToStr } from "../error/normalize"; import { CliDownloadsDisabledError, CliFallbackDeclinedError, @@ -21,27 +23,25 @@ import { type CliVersionCheckOutcome, type CliResolveTrace, } from "../instrumentation/cli"; +import { showStoreCredentialsError } from "../login/credentials"; import * as pgp from "../pgp"; -import { withCancellableProgress, withOptionalProgress } from "../progress"; -import { showStoreCredentialsError } from "../util/credentials"; -import { tempFilePath } from "../util/fs"; -import { toSafeHost } from "../util/uri"; -import { vscodeProposed } from "../vscodeProposed"; +import { withCancellableProgress, withOptionalProgress } from "../ui/progress"; +import { vscodeProposed } from "../vscode/proposed"; import { BinaryLock } from "./binaryLock"; +import * as cliBinary from "./cliBinary"; import { version as cliVersion } from "./cliExec"; -import * as cliUtils from "./cliUtils"; import * as downloadProgress from "./downloadProgress"; import type { Api } from "coder/site/src/api/api"; import type { IncomingMessage } from "node:http"; import type { Logger } from "../logging/logger"; +import type { PathResolver } from "../storage/pathResolver"; import type { TelemetryService } from "../telemetry/service"; import type { Span } from "../telemetry/span"; import type { CliCredentialManager } from "./cliCredentialManager"; -import type { PathResolver } from "./pathResolver"; type ResolvedBinary = | { binPath: string; stat: Stats; source: "file_path" | "directory" } @@ -89,13 +89,13 @@ export class CliManager { safeHostname: string, ): Promise { const cachePath = this.pathResolver.getBinaryCachePath(safeHostname); - const cacheStat = await cliUtils.stat(cachePath); + const cacheStat = await cliBinary.stat(cachePath); if (cacheStat?.isFile()) { return { binPath: cachePath, stat: cacheStat, source: "file_path" }; } - const fullNamePath = path.join(cachePath, cliUtils.fullName()); + const fullNamePath = path.join(cachePath, cliBinary.fullName()); // Path does not exist yet; return the platform-specific path to download. if (!cacheStat) { @@ -103,13 +103,13 @@ export class CliManager { } // Directory exists; check platform-specific name, then simple name. - const fullStat = await cliUtils.stat(fullNamePath); + const fullStat = await cliBinary.stat(fullNamePath); if (fullStat) { return { binPath: fullNamePath, stat: fullStat, source: "directory" }; } - const simpleNamePath = path.join(cachePath, cliUtils.simpleName()); - const simpleStat = await cliUtils.stat(simpleNamePath); + const simpleNamePath = path.join(cachePath, cliBinary.simpleName()); + const simpleStat = await cliBinary.stat(simpleNamePath); if (simpleStat) { return { binPath: simpleNamePath, stat: simpleStat, source: "directory" }; } @@ -288,7 +288,7 @@ export class CliManager { // Always download using the platform-specific name. const downloadBinPath = path.join( path.dirname(resolved.binPath), - cliUtils.fullName(), + cliBinary.fullName(), ); // Create the `bin` folder if it doesn't exist await fs.mkdir(path.dirname(downloadBinPath), { recursive: true }); @@ -411,7 +411,7 @@ export class CliManager { binPath: string, expectedVersion: string, ): Promise<{ version: string | null; matches: boolean }> { - const stat = await cliUtils.stat(binPath); + const stat = await cliBinary.stat(binPath); if (!stat) { return { version: null, matches: false }; } @@ -476,7 +476,7 @@ export class CliManager { try { // Step 1: Move existing binary to backup (if it exists) - const stat = await cliUtils.stat(binPath); + const stat = await cliBinary.stat(binPath); if (stat) { this.output.info( "Moving existing binary to", @@ -489,11 +489,11 @@ export class CliManager { this.output.info("Moving downloaded file to", path.basename(binPath)); await fs.rename(tempFile, binPath); } catch (error) { - throw cliUtils.maybeWrapFileLockError(error, binPath); + throw cliBinary.maybeWrapFileLockError(error, binPath); } // For debugging, to see if the binary only partially downloaded. - const newStat = await cliUtils.stat(binPath); + const newStat = await cliBinary.stat(binPath); this.output.info( "Downloaded binary size is", prettyBytes(newStat?.size ?? 0), @@ -515,7 +515,7 @@ export class CliManager { fallbackBinPath?: string, ): Promise { const message = - error instanceof cliUtils.FileLockError + error instanceof cliBinary.FileLockError ? "Unable to update the Coder CLI binary because it's in use" : "Failed to update CLI binary"; @@ -550,7 +550,7 @@ export class CliManager { } // Last resort: most recent .old-* backup (deferred to avoid IO when unnecessary). - const oldBinaries = await cliUtils.findOldBinaries(binPath); + const oldBinaries = await cliBinary.findOldBinaries(binPath); if (oldBinaries.length > 0) { const old = await tryCandidate(oldBinaries[0]); if (old) { @@ -578,7 +578,7 @@ export class CliManager { let bytesWritten = 0; try { - const removed = await cliUtils.rmOld(binPath); + const removed = await cliBinary.rmOld(binPath); for (const { fileName, error } of removed) { if (error) { this.output.warn("Failed to remove", fileName, error); @@ -588,15 +588,15 @@ export class CliManager { } // Figure out where to get the binary. - const binName = cliUtils.fullName(); + const binName = cliBinary.fullName(); const configSource = cfg.get("binarySource"); const binSource = configSource?.trim() ? configSource : "/bin/" + binName; this.output.info("Downloading binary from", binSource); // Ideally we already caught that this was the right version and returned // early, but just in case set the ETag. - const stat = await cliUtils.stat(binPath); - const etag = stat ? await cliUtils.eTag(binPath) : ""; + const stat = await cliBinary.stat(binPath); + const etag = stat ? await cliBinary.eTag(binPath) : ""; this.output.info("Using ETag", etag || ""); // Download the binary to a temporary file. @@ -683,8 +683,8 @@ export class CliManager { if (!value) { return; } - const os = cliUtils.goos(); - const arch = cliUtils.goarch(); + const os = cliBinary.goos(); + const arch = cliBinary.goarch(); const params = new URLSearchParams({ title: `Support the \`${os}-${arch}\` platform`, body: `I'd like to use the \`${os}-${arch}\` architecture with the VS Code extension.`, @@ -707,7 +707,7 @@ export class CliManager { return; } const params = new URLSearchParams({ - title: `Failed to download binary on \`${cliUtils.goos()}-${cliUtils.goarch()}\``, + title: `Failed to download binary on \`${cliBinary.goos()}-${cliBinary.goarch()}\``, body: `Received status code \`${status}\` when downloading the binary.`, }); const uri = vscode.Uri.parse( diff --git a/src/core/downloadProgress.ts b/src/cli/downloadProgress.ts similarity index 100% rename from src/core/downloadProgress.ts rename to src/cli/downloadProgress.ts diff --git a/src/command/diagnosticFlow.ts b/src/command/diagnosticFlow.ts index 1b3708b673..88b0323195 100644 --- a/src/command/diagnosticFlow.ts +++ b/src/command/diagnosticFlow.ts @@ -1,8 +1,8 @@ import * as vscode from "vscode"; import { ZodError } from "zod"; -import { toError } from "../error/errorUtils"; -import { withCancellableProgress, type ProgressContext } from "../progress"; +import { toError } from "../error/normalize"; +import { withCancellableProgress, type ProgressContext } from "../ui/progress"; import { openJsonBeside } from "../webviews/openJson"; import type { DiagnosticTrace } from "../instrumentation/diagnostics"; diff --git a/src/commands.ts b/src/commands.ts index 759306ee8a..ed7cebef34 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -5,14 +5,16 @@ import * as semver from "semver"; import * as vscode from "vscode"; import { - createWorkspaceIdentifier, extractAgents, + createWorkspaceIdentifier, workspaceStatusLabel, -} from "./api/api-helper"; +} from "./api/models"; +import * as cliExec from "./cli/cliExec"; import { runDiagnosticCli } from "./command/diagnosticFlow"; -import * as cliExec from "./core/cliExec"; +import { openInBrowser, toSafeHost } from "./common/url"; +import { raceWithAbort } from "./error/abort"; import { CertificateError } from "./error/certificateError"; -import { raceWithAbort, toError } from "./error/errorUtils"; +import { toError } from "./error/normalize"; import { type CliFeatureSet, cliFeatureSet } from "./featureSet"; import { AuthTelemetry, @@ -34,11 +36,15 @@ import { type WorkspacePickerSource, } from "./instrumentation/workspaceOpen"; import { - reportElapsedProgress, - withCancellableProgress, - withProgress, -} from "./progress"; -import { maybeAskAgent, maybeAskUrl } from "./promptUtils"; + LegacyEditorId, + currentEditorId, + hostEditorId, + isRemoteAuthorityCompatible, + parseRemoteAuthority, + sshHostOf, + toLegacyAuthority, + toRemoteAuthority, +} from "./remote/authority"; import { applySettingOverrides, getRecommendedSshSettings, @@ -51,17 +57,12 @@ import { } from "./supportBundle/remoteServerDataPath"; import { runExportTelemetryCommand } from "./telemetry/export/command"; import { - LegacyEditorId, - currentEditorId, - hostEditorId, - isRemoteAuthorityCompatible, - parseRemoteAuthority, - sshHostOf, - toLegacyAuthority, - toRemoteAuthority, -} from "./util/authority"; -import { openInBrowser, toSafeHost } from "./util/uri"; -import { vscodeProposed } from "./vscodeProposed"; + reportElapsedProgress, + withCancellableProgress, + withProgress, +} from "./ui/progress"; +import { maybeAskAgent, maybeAskUrl } from "./ui/prompts"; +import { vscodeProposed } from "./vscode/proposed"; import { parseNetcheckReport } from "./webviews/netcheck/types"; import { parseSpeedtestResult } from "./webviews/speedtest/types"; import { @@ -78,14 +79,14 @@ import type { import type { DashboardPage } from "@repo/shared"; import type { CoderApi } from "./api/coderApi"; -import type { CliManager } from "./core/cliManager"; -import type { ServiceContainer } from "./core/container"; -import type { MementoManager } from "./core/mementoManager"; -import type { PathResolver } from "./core/pathResolver"; -import type { SecretsManager, SessionAuth } from "./core/secretsManager"; +import type { CliManager } from "./cli/cliManager"; +import type { ServiceContainer } from "./container"; import type { DeploymentManager } from "./deployment/deploymentManager"; import type { Logger } from "./logging/logger"; import type { LoginCoordinator, LoginMethod } from "./login/loginCoordinator"; +import type { MementoManager } from "./storage/mementoManager"; +import type { PathResolver } from "./storage/pathResolver"; +import type { SecretsManager, SessionAuth } from "./storage/secretsManager"; import type { TelemetryService } from "./telemetry/service"; import type { NetcheckPanelFactory } from "./webviews/netcheck/netcheckPanelFactory"; import type { SpeedtestPanelFactory } from "./webviews/speedtest/speedtestPanelFactory"; diff --git a/src/common/fs.ts b/src/common/fs.ts new file mode 100644 index 0000000000..8b25edb77b --- /dev/null +++ b/src/common/fs.ts @@ -0,0 +1,196 @@ +import * as fs from "node:fs/promises"; +import os from "node:os"; +import * as path from "node:path"; + +import type { Logger } from "../logging/logger"; + +const transientRenameCodes: ReadonlySet = new Set([ + "EPERM", + "EACCES", + "EBUSY", +]); + +/** + * Rename with retry for transient Windows filesystem errors (EPERM, EACCES, + * EBUSY). On Windows, antivirus, Search Indexer, cloud sync, or concurrent + * processes can briefly lock files causing renames to fail. + * + * On non-Windows platforms, calls renameFn directly with no retry. + * + * Matches the strategy used by VS Code (pfs.ts) and graceful-fs: 60s + * wall-clock timeout with linear backoff (10ms increments) capped at 100ms. + */ +export async function renameWithRetry( + renameFn: (src: string, dest: string) => Promise, + source: string, + destination: string, + timeoutMs = 60_000, + delayCapMs = 100, +): Promise { + if (process.platform !== "win32") { + return renameFn(source, destination); + } + const startTime = Date.now(); + for (let attempt = 1; ; attempt++) { + try { + return await renameFn(source, destination); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if ( + !code || + !transientRenameCodes.has(code) || + Date.now() - startTime >= timeoutMs + ) { + throw err; + } + const delay = Math.min(delayCapMs, attempt * 10); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } +} + +/** + * Generate a temporary file path by appending a suffix with a random component. + * The suffix describes the purpose of the temp file (e.g. "temp", "old"). + * Example: tempFilePath("/a/b", "temp") returns "/a/b.temp-b91b24be" + */ +export function tempFilePath(basePath: string, suffix: string): string { + return `${basePath}.${suffix}-${crypto.randomUUID().substring(0, 8)}`; +} + +/** + * Atomically writes to `outputPath` via a sibling temp file and rename. + * The parent directory must already exist. On failure the destination is + * left untouched, the temp file is best-effort removed, and the writer + * error is always rethrown. `onCleanupError`, if given, receives any error + * from the cleanup attempt; its own throws are swallowed. + */ +export async function writeAtomically( + outputPath: string, + write: (tempPath: string) => Promise, + onCleanupError?: (err: unknown, tempPath: string) => void, +): Promise { + const tempPath = tempFilePath(outputPath, "temp"); + try { + const result = await write(tempPath); + await renameWithRetry(fs.rename, tempPath, outputPath); + return result; + } catch (err) { + try { + await fs.rm(tempPath, { force: true }).catch((rmErr) => { + onCleanupError?.(rmErr, tempPath); + }); + } catch { + // onCleanupError threw; the writer error below takes precedence. + } + throw err; + } +} + +export interface FileCleanupCandidate { + name: string; + mtime: number; + size: number; +} + +export interface FileCleanupOptions { + /** Label for log messages, e.g. "telemetry file". */ + label: string; + /** Cheap name-based predicate; non-matching entries are skipped before stat. */ + filter?: (name: string) => boolean; + /** From the stat'd survivors of `filter`, returns the files to delete. */ + select: ( + files: FileCleanupCandidate[], + now: number, + ) => Array<{ name: string }>; +} + +/** + * Lists files in `dir`, applies `filter` to names, stats the survivors, and + * unlinks whatever `select` returns. ENOENT is swallowed so concurrent + * deletes are safe. Never throws; failures go to `logger.debug`. + */ +export async function cleanupFiles( + dir: string, + logger: Logger, + options: FileCleanupOptions, +): Promise { + const { label, filter, select } = options; + const now = Date.now(); + let names: string[]; + try { + names = await fs.readdir(dir); + } catch (error) { + // ENOENT just means the dir hasn't been created yet; anything else + // (EACCES, EMFILE, ...) is a real failure worth surfacing. + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + logger.debug(`Failed to read ${label} directory ${dir}`, error); + } + return; + } + const candidates = filter ? names.filter(filter) : names; + + const withStats = await Promise.all( + candidates.map(async (name) => { + try { + const stats = await fs.stat(path.join(dir, name)); + return { + name, + mtime: stats.mtime.getTime(), + size: stats.size, + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + logger.debug(`Failed to stat ${label} ${name}`, error); + } + return null; + } + }), + ); + + const toDelete = select( + withStats.filter((f) => f !== null), + now, + ); + + const deleted = await Promise.all( + toDelete.map(async (file) => { + // Basename only; never let `select` escape `dir`. + const safeName = path.basename(file.name); + try { + await fs.unlink(path.join(dir, safeName)); + return safeName; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + logger.debug(`Failed to delete ${label} ${safeName}`, error); + } + return null; + } + }), + ); + + const successful = deleted.filter((name) => name !== null); + if (successful.length > 0) { + logger.debug( + `Cleaned up ${successful.length} ${label}(s): ${successful.join(", ")}`, + ); + } +} + +/** + * Substitute `${env:VAR}` with `process.env.VAR` (unset → empty string), + * `${userHome}` (anywhere) with `os.homedir()`, and a leading `~` with + * `os.homedir()`. Env substitution runs first so env values can themselves + * contain `~` or `${userHome}`. + */ +export function expandPath(input: string): string { + const expanded = input.replace( + /\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, + (_, name: string) => process.env[name] ?? "", + ); + const userHome = os.homedir(); + const tildeExpanded = expanded.startsWith("~") + ? userHome + expanded.substring("~".length) + : expanded; + return tildeExpanded.replaceAll("${userHome}", userHome); +} diff --git a/src/common/strings.ts b/src/common/strings.ts new file mode 100644 index 0000000000..bc1c9069bf --- /dev/null +++ b/src/common/strings.ts @@ -0,0 +1,38 @@ +import os from "node:os"; + +/** `toLowerCase` typed for indexing `Lowercase`-keyed records without a cast. */ +export function lowercase(value: T): Lowercase { + return value.toLowerCase() as Lowercase; +} + +/** + * Wraps `arg` in `"..."` unless every character is in the shell-safe + * whitelist (matching Python `shlex.quote`'s set: alphanumerics plus + * `@%+,=:./-`). Anything else (whitespace, `"`, `&|;()<>*?[~#!^\` `$`) + * forces quoting so the output is a single token in POSIX `sh`, cmd.exe, + * and PowerShell. + * + * Not a universal shell-escape: `$VAR` / `$(...)` / `%VAR%` still expand + * inside `"..."`. For untrusted values use {@link escapeShellArg}. + * + * @see https://docs.python.org/3/library/shlex.html#shlex.quote + * @see https://learn.microsoft.com/en-us/archive/blogs/twistylittlepassagesallalike/everyone-quotes-command-line-arguments-the-wrong-way + */ +export function escapeCommandArg(arg: string): string { + if (arg !== "" && /^[\w@%+,=:./-]+$/.test(arg)) { + return arg; + } + return `"${arg.replaceAll('"', String.raw`\"`)}"`; +} + +/** + * Cross-platform shell quoting that blocks variable expansion. Use for + * values from outside the user's local settings (e.g. server-controlled). + */ +export function escapeShellArg(arg: string): string { + if (os.platform() === "win32") { + const escaped = arg.replace(/"/g, '""').replace(/%/g, "%%"); + return `"${escaped}"`; + } + return `'${arg.replace(/'/g, "'\\''")}'`; +} diff --git a/src/util/uri.ts b/src/common/url.ts similarity index 80% rename from src/util/uri.ts rename to src/common/url.ts index c4a92ff624..3bd3e3d649 100644 --- a/src/util/uri.ts +++ b/src/common/url.ts @@ -57,3 +57,19 @@ export function openInBrowser( const base = vscode.Uri.parse(resolveCoderDashboardUrl(connectionUrl)); return vscode.env.openExternal(vscode.Uri.joinPath(base, path)); } + +/** + * Converts params to a query string. Returns empty string if no params, + * otherwise returns params prefixed with '?'. + */ +export function getQueryString( + params: Record | URLSearchParams | undefined, +): string { + if (!params) { + return ""; + } + const searchParams = + params instanceof URLSearchParams ? params : new URLSearchParams(params); + const str = searchParams.toString(); + return str ? `?${str}` : ""; +} diff --git a/src/core/container.ts b/src/container.ts similarity index 79% rename from src/core/container.ts rename to src/container.ts index cde147bd0c..7c6f3171fe 100644 --- a/src/core/container.ts +++ b/src/container.ts @@ -1,33 +1,32 @@ import * as vscode from "vscode"; -import { watchConfigurationChanges } from "../configWatcher"; -import { AuthTelemetry } from "../instrumentation/auth"; -import { FlightRecorder } from "../logging/flightRecorder"; -import { prefixLogger } from "../logging/prefixLogger"; -import { shortId } from "../logging/utils"; -import { LoginCoordinator } from "../login/loginCoordinator"; -import { OAuthCallback } from "../oauth/oauthCallback"; +import { CliCredentialManager } from "./cli/cliCredentialManager"; +import { CliManager } from "./cli/cliManager"; +import { watchConfigurationChanges } from "./configWatcher"; +import { AuthTelemetry } from "./instrumentation/auth"; +import { FlightRecorder } from "./logging/flightRecorder"; +import { shortId } from "./logging/ids"; +import { prefixLogger } from "./logging/prefixLogger"; +import { LoginCoordinator } from "./login/loginCoordinator"; +import { OAuthCallback } from "./oauth/oauthCallback"; import { CONNECTION_LOG_BUFFER_SIZE_SETTING, readConnectionLogBufferSize, -} from "../settings/logger"; -import { buildSession, extractExtensionVersion } from "../telemetry/event"; -import { TelemetryService } from "../telemetry/service"; -import { LocalJsonlSink } from "../telemetry/sinks/localJsonlSink"; -import { NetcheckPanelFactory } from "../webviews/netcheck/netcheckPanelFactory"; -import { SpeedtestPanelFactory } from "../webviews/speedtest/speedtestPanelFactory"; -import { DuplicateWorkspaceIpc } from "../workspace/duplicateWorkspaceIpc"; - -import { CliCredentialManager } from "./cliCredentialManager"; -import { CliManager } from "./cliManager"; -import { CommandManager } from "./commandManager"; -import { ContextManager } from "./contextManager"; -import { MementoManager } from "./mementoManager"; -import { PathResolver } from "./pathResolver"; -import { SecretsManager } from "./secretsManager"; -import { sessionId } from "./sessionId"; - -import type { Logger } from "../logging/logger"; +} from "./settings/logger"; +import { MementoManager } from "./storage/mementoManager"; +import { PathResolver } from "./storage/pathResolver"; +import { SecretsManager } from "./storage/secretsManager"; +import { buildSession, extractExtensionVersion } from "./telemetry/event"; +import { sessionId } from "./telemetry/ids"; +import { TelemetryService } from "./telemetry/service"; +import { LocalJsonlSink } from "./telemetry/sinks/localJsonlSink"; +import { CommandManager } from "./vscode/commandManager"; +import { ContextManager } from "./vscode/contextManager"; +import { NetcheckPanelFactory } from "./webviews/netcheck/netcheckPanelFactory"; +import { SpeedtestPanelFactory } from "./webviews/speedtest/speedtestPanelFactory"; +import { DuplicateWorkspaceIpc } from "./workspace/duplicateWorkspaceIpc"; + +import type { Logger } from "./logging/logger"; /** * Service container for dependency injection. diff --git a/src/core/sessionId.ts b/src/core/sessionId.ts deleted file mode 100644 index 44bd804660..0000000000 --- a/src/core/sessionId.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { randomBytes } from "node:crypto"; - -/** - * One session ID per extension host process, shared across logs, API - * requests, telemetry, and the CLI so all data for a session can be correlated - * by a single ID. - * - * In rare cases when the extension is deactivated then activated within the - * same window, it should reuse the same ID. However, reloading or closing- - * then-reopening a window creates a new process with a new ID. - * - * 16 bytes / 32 lowercase hex, matching the OTel id format so a future OTel - * exporter maps 1:1. Avoids `vscode.env.sessionId`, which is a UUID - * concatenated with a timestamp. - */ -export const sessionId = randomBytes(16).toString("hex"); diff --git a/src/deployment/deploymentManager.ts b/src/deployment/deploymentManager.ts index 36e6fac94d..42653ce023 100644 --- a/src/deployment/deploymentManager.ts +++ b/src/deployment/deploymentManager.ts @@ -1,13 +1,10 @@ -import { isOwner } from "../api/api-helper"; import { CoderApi } from "../api/coderApi"; +import { isOwner } from "../api/models"; import { CONFIG_CHANGE_DEBOUNCE_MS, watchConfigurationChanges, } from "../configWatcher"; -import { type ServiceContainer } from "../core/container"; -import { type ContextManager } from "../core/contextManager"; -import { type MementoManager } from "../core/mementoManager"; -import { type SecretsManager } from "../core/secretsManager"; +import { type ServiceContainer } from "../container"; import { DeploymentTelemetry, type DeploymentRecoveryTrigger, @@ -16,7 +13,10 @@ import { import { type Logger } from "../logging/logger"; import { type OAuthSessionManager } from "../oauth/sessionManager"; import { getAuthConfigWatchSettings } from "../settings/authConfig"; +import { type MementoManager } from "../storage/mementoManager"; +import { type SecretsManager } from "../storage/secretsManager"; import { type TelemetryService } from "../telemetry/service"; +import { type ContextManager } from "../vscode/contextManager"; import { SessionStore, diff --git a/src/error/errorUtils.ts b/src/error/abort.ts similarity index 58% rename from src/error/errorUtils.ts rename to src/error/abort.ts index d8145080be..2b39b481de 100644 --- a/src/error/errorUtils.ts +++ b/src/error/abort.ts @@ -1,8 +1,3 @@ -import { isApiError, isApiErrorResponse } from "coder/site/src/api/errors"; -import util from "node:util"; - -import { toError as baseToError } from "@repo/shared"; - /** Check whether an unknown thrown value is an AbortError (signal cancellation). */ export function isAbortError(error: unknown): error is Error { return error instanceof Error && error.name === "AbortError"; @@ -44,26 +39,3 @@ export async function raceWithAbort( signal.removeEventListener("abort", onAbort); } } - -// getErrorDetail is copied from coder/site, but changes the default return. -export const getErrorDetail = (error: unknown): string | undefined | null => { - if (isApiError(error)) { - return error.response.data.detail; - } - if (isApiErrorResponse(error)) { - return error.detail; - } - return null; -}; - -/** Node flavor of toError: uses `util.inspect` for the richer object format. */ -export function toError(value: unknown, defaultMsg?: string): Error { - return baseToError(value, defaultMsg, util.inspect); -} - -/** Wrap `cause` as `Failed to : `, preserving the chain. */ -export function wrapError(verb: string, target: string, cause: unknown): Error { - return new Error(`Failed to ${verb} ${target}: ${toError(cause).message}`, { - cause, - }); -} diff --git a/src/error/certificateError.ts b/src/error/certificateError.ts index a8f78f5c88..8a12ab540a 100644 --- a/src/error/certificateError.ts +++ b/src/error/certificateError.ts @@ -1,4 +1,4 @@ -import { vscodeProposed } from "../vscodeProposed"; +import { vscodeProposed } from "../vscode/proposed"; /** * Base class for certificate-related errors that can display notifications to users. diff --git a/src/error/clientCertificateError.ts b/src/error/clientCertificateError.ts index 75994ba6c0..790892c443 100644 --- a/src/error/clientCertificateError.ts +++ b/src/error/clientCertificateError.ts @@ -1,7 +1,7 @@ import * as vscode from "vscode"; import { CertificateError } from "./certificateError"; -import { toError } from "./errorUtils"; +import { toError } from "./normalize"; /** * SSL/TLS alert codes related to client certificates (RFC 5246). diff --git a/src/error/normalize.ts b/src/error/normalize.ts new file mode 100644 index 0000000000..3a21bdb4c4 --- /dev/null +++ b/src/error/normalize.ts @@ -0,0 +1,47 @@ +import { isApiError, isApiErrorResponse } from "coder/site/src/api/errors"; +import { ErrorEvent } from "eventsource"; +import util from "node:util"; + +import { toError as baseToError } from "@repo/shared"; + +// getErrorDetail is copied from coder/site, but changes the default return. +export const getErrorDetail = (error: unknown): string | undefined | null => { + if (isApiError(error)) { + return error.response.data.detail; + } + if (isApiErrorResponse(error)) { + return error.detail; + } + return null; +}; + +/** Node flavor of toError: uses `util.inspect` for the richer object format. */ +export function toError(value: unknown, defaultMsg?: string): Error { + return baseToError(value, defaultMsg, util.inspect); +} + +/** Wrap `cause` as `Failed to : `, preserving the chain. */ +export function wrapError(verb: string, target: string, cause: unknown): Error { + return new Error(`Failed to ${verb} ${target}: ${toError(cause).message}`, { + cause, + }); +} + +/** + * Convert various error types to readable strings + */ +export function errToStr(error: unknown, def = "No error message provided") { + if (error instanceof Error && error.message) { + return error.message; + } else if (isApiError(error)) { + return error.response.data.message; + } else if (isApiErrorResponse(error)) { + return error.message; + } else if (error instanceof ErrorEvent) { + const message = error.message || def; + return error.code ? `${error.code}: ${message}` : message; + } else if (typeof error === "string" && error.trim().length > 0) { + return error; + } + return def; +} diff --git a/src/error/serverCertificateError.ts b/src/error/serverCertificateError.ts index 65c189e7e6..432e3f44bf 100644 --- a/src/error/serverCertificateError.ts +++ b/src/error/serverCertificateError.ts @@ -11,7 +11,7 @@ import * as vscode from "vscode"; import { type Logger } from "../logging/logger"; import { CertificateError } from "./certificateError"; -import { toError } from "./errorUtils"; +import { toError } from "./normalize"; // X509_ERR_CODE represents error codes as returned from BoringSSL/OpenSSL. export enum X509_ERR_CODE { diff --git a/src/extension.ts b/src/extension.ts index 210ba68c28..5eb840f6bf 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -8,14 +8,13 @@ import * as semver from "semver"; import * as vscode from "vscode"; import { AnnouncementManager } from "./announcements/manager"; -import { errToStr } from "./api/api-helper"; import { AuthInterceptor } from "./api/authInterceptor"; import { CoderApi } from "./api/coderApi"; import { Commands } from "./commands"; -import { ServiceContainer } from "./core/container"; +import { ServiceContainer } from "./container"; import { DeploymentManager } from "./deployment/deploymentManager"; import { CertificateError } from "./error/certificateError"; -import { getErrorDetail, toError } from "./error/errorUtils"; +import { errToStr, getErrorDetail, toError } from "./error/normalize"; import { serverFeatureSet } from "./featureSet"; import { ActivationTelemetry, @@ -25,9 +24,9 @@ import { OAuthSessionManager } from "./oauth/sessionManager"; import { Remote } from "./remote/remote"; import { getRemoteSshExtension } from "./remote/sshExtension"; import { registerUriHandler } from "./uri/uriHandler"; -import { initVscodeProposed } from "./vscodeProposed"; +import { initVscodeProposed } from "./vscode/proposed"; import { TasksPanelProvider } from "./webviews/tasks/tasksPanelProvider"; -import { WorkspacesPanelProvider } from "./webviews/workspaces/panelProvider"; +import { WorkspacesPanelProvider } from "./webviews/workspaces/workspacesPanelProvider"; import { WorkspaceStore } from "./webviews/workspaces/workspaceStore"; import { WorkspaceProvider } from "./workspace/workspacesProvider"; diff --git a/src/instrumentation/credentials.ts b/src/instrumentation/credentials.ts index 44735397bb..d2cc50cd1e 100644 --- a/src/instrumentation/credentials.ts +++ b/src/instrumentation/credentials.ts @@ -1,4 +1,4 @@ -import { isAbortError } from "../error/errorUtils"; +import { isAbortError } from "../error/abort"; import { isKeyringEnabled } from "../settings/cli"; import type { WorkspaceConfiguration } from "vscode"; diff --git a/src/instrumentation/outcomes.ts b/src/instrumentation/outcomes.ts index 7b759aebd7..e4999ee9f8 100644 --- a/src/instrumentation/outcomes.ts +++ b/src/instrumentation/outcomes.ts @@ -1,4 +1,4 @@ -import { isAbortError } from "../error/errorUtils"; +import { isAbortError } from "../error/abort"; import type { Span } from "../telemetry/span"; diff --git a/src/instrumentation/workspaceOpen.ts b/src/instrumentation/workspaceOpen.ts index c3845bd94f..1a84ae4e59 100644 --- a/src/instrumentation/workspaceOpen.ts +++ b/src/instrumentation/workspaceOpen.ts @@ -1,4 +1,4 @@ -import { extractAgents } from "../api/api-helper"; +import { extractAgents } from "../api/models"; import { recordAbortableError, recordAborted, recordError } from "./outcomes"; diff --git a/src/logging/eventStreamLogger.ts b/src/logging/eventStreamLogger.ts index 224f52b745..6b36b34a35 100644 --- a/src/logging/eventStreamLogger.ts +++ b/src/logging/eventStreamLogger.ts @@ -1,9 +1,9 @@ import prettyBytes from "pretty-bytes"; -import { errToStr } from "../api/api-helper"; +import { errToStr } from "../error/normalize"; -import { formatTime } from "./formatters"; -import { createRequestId, shortId, sizeOf } from "./utils"; +import { formatTime, sizeOf } from "./formatters"; +import { createRequestId, shortId } from "./ids"; import type { Logger } from "./logger"; diff --git a/src/logging/flightRecorder.ts b/src/logging/flightRecorder.ts index 32486c3519..dbc49906b5 100644 --- a/src/logging/flightRecorder.ts +++ b/src/logging/flightRecorder.ts @@ -1,4 +1,4 @@ -import { safeStringify } from "./utils"; +import { safeStringify } from "./formatters"; import type { LogSink, Logger } from "./logger"; diff --git a/src/logging/formatters.ts b/src/logging/formatters.ts index 331a67b990..d3389b7358 100644 --- a/src/logging/formatters.ts +++ b/src/logging/formatters.ts @@ -1,8 +1,8 @@ +import { Buffer } from "node:buffer"; +import util from "node:util"; import prettyBytes from "pretty-bytes"; -import { lowercase } from "../util"; - -import { safeStringify } from "./utils"; +import { lowercase } from "../common/strings"; import type { AxiosRequestConfig } from "axios"; @@ -165,3 +165,55 @@ function redactStringBody(body: string, seen: WeakSet): unknown { } return body; } + +/** + * Returns the byte size of the data if it can be determined from the data's intrinsic properties, + * otherwise returns undefined (e.g., for plain objects and arrays that would require serialization). + */ +export function sizeOf(data: unknown): number | undefined { + if (data === null || data === undefined) { + return 0; + } + if (typeof data === "boolean") { + return 4; + } + if (typeof data === "number") { + return 8; + } + if (typeof data === "string" || typeof data === "bigint") { + return Buffer.byteLength(data.toString()); + } + if ( + Buffer.isBuffer(data) || + data instanceof ArrayBuffer || + ArrayBuffer.isView(data) + ) { + return data.byteLength; + } + if ( + typeof data === "object" && + "size" in data && + typeof data.size === "number" + ) { + return data.size; + } + return undefined; +} + +export function safeStringify(data: unknown): string | null { + try { + return util.inspect(data, { + showHidden: false, + // Bounded so a single log line cannot balloon in size. + depth: 8, + maxArrayLength: 100, + maxStringLength: 10_000, + breakLength: Infinity, + compact: true, + getters: false, // avoid side-effects + }); + } catch { + // Should rarely happen but just in case + return null; + } +} diff --git a/src/logging/httpLogger.ts b/src/logging/httpLogger.ts index f780342aa5..7f8496ef3b 100644 --- a/src/logging/httpLogger.ts +++ b/src/logging/httpLogger.ts @@ -1,7 +1,7 @@ import { isAxiosError, type AxiosResponse } from "axios"; import { getErrorMessage } from "coder/site/src/api/errors"; -import { getErrorDetail } from "../error/errorUtils"; +import { getErrorDetail } from "../error/normalize"; import { formatBody, @@ -11,12 +11,12 @@ import { formatTime, formatUri, } from "./formatters"; +import { createRequestId, shortId } from "./ids"; import { HttpClientLogLevel, type RequestConfigWithMeta, type RequestMeta, } from "./types"; -import { createRequestId, shortId } from "./utils"; import type { Logger } from "./logger"; diff --git a/src/logging/ids.ts b/src/logging/ids.ts new file mode 100644 index 0000000000..0bea330de3 --- /dev/null +++ b/src/logging/ids.ts @@ -0,0 +1,9 @@ +import crypto from "node:crypto"; + +export function shortId(id: string): string { + return id.slice(0, 8); +} + +export function createRequestId(): string { + return crypto.randomUUID().replace(/-/g, ""); +} diff --git a/src/logging/utils.ts b/src/logging/utils.ts deleted file mode 100644 index b72765997a..0000000000 --- a/src/logging/utils.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { Buffer } from "node:buffer"; -import crypto from "node:crypto"; -import util from "node:util"; - -export function shortId(id: string): string { - return id.slice(0, 8); -} - -export function createRequestId(): string { - return crypto.randomUUID().replace(/-/g, ""); -} - -/** - * Returns the byte size of the data if it can be determined from the data's intrinsic properties, - * otherwise returns undefined (e.g., for plain objects and arrays that would require serialization). - */ -export function sizeOf(data: unknown): number | undefined { - if (data === null || data === undefined) { - return 0; - } - if (typeof data === "boolean") { - return 4; - } - if (typeof data === "number") { - return 8; - } - if (typeof data === "string" || typeof data === "bigint") { - return Buffer.byteLength(data.toString()); - } - if ( - Buffer.isBuffer(data) || - data instanceof ArrayBuffer || - ArrayBuffer.isView(data) - ) { - return data.byteLength; - } - if ( - typeof data === "object" && - "size" in data && - typeof data.size === "number" - ) { - return data.size; - } - return undefined; -} - -export function safeStringify(data: unknown): string | null { - try { - return util.inspect(data, { - showHidden: false, - // Bounded so a single log line cannot balloon in size. - depth: 8, - maxArrayLength: 100, - maxStringLength: 10_000, - breakLength: Infinity, - compact: true, - getters: false, // avoid side-effects - }); - } catch { - // Should rarely happen but just in case - return null; - } -} diff --git a/src/util/credentials.ts b/src/login/credentials.ts similarity index 95% rename from src/util/credentials.ts rename to src/login/credentials.ts index 9c53193509..cb6905b77f 100644 --- a/src/util/credentials.ts +++ b/src/login/credentials.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode"; -import { errToStr } from "../api/api-helper"; +import { errToStr } from "../error/normalize"; import { isKeyringEnabled } from "../settings/cli"; import type { WorkspaceConfiguration } from "vscode"; diff --git a/src/login/loginCoordinator.ts b/src/login/loginCoordinator.ts index 248d1fe2cf..98263562cf 100644 --- a/src/login/loginCoordinator.ts +++ b/src/login/loginCoordinator.ts @@ -3,26 +3,21 @@ import { getErrorMessage } from "coder/site/src/api/errors"; import * as vscode from "vscode"; import { CoderApi } from "../api/coderApi"; +import { needToken } from "../api/httpAgent"; import { HttpStatusCode } from "../api/httpStatusCode"; -import { needToken } from "../api/utils"; +import { isSameOrigin, openInBrowser } from "../common/url"; import { CertificateError } from "../error/certificateError"; import { OAuthAuthorizer } from "../oauth/authorizer"; -import { buildOAuthTokenData } from "../oauth/utils"; -import { withCancellableProgress } from "../progress"; -import { maybeAskAuthMethod, maybeAskUrl } from "../promptUtils"; -import { showStoreCredentialsError } from "../util/credentials"; -import { isSameOrigin, openInBrowser } from "../util/uri"; -import { vscodeProposed } from "../vscodeProposed"; +import { buildOAuthTokenData } from "../oauth/tokens"; +import { withCancellableProgress } from "../ui/progress"; +import { maybeAskAuthMethod, maybeAskUrl } from "../ui/prompts"; +import { vscodeProposed } from "../vscode/proposed"; + +import { showStoreCredentialsError } from "./credentials"; import type { User } from "coder/site/src/api/typesGenerated"; -import type { CliCredentialManager } from "../core/cliCredentialManager"; -import type { MementoManager } from "../core/mementoManager"; -import type { - OAuthTokenData, - SecretsManager, - SessionAuth, -} from "../core/secretsManager"; +import type { CliCredentialManager } from "../cli/cliCredentialManager"; import type { Deployment } from "../deployment/types"; import type { AuthLoginPromptTrigger, @@ -31,6 +26,12 @@ import type { } from "../instrumentation/auth"; import type { Logger } from "../logging/logger"; import type { OAuthCallback } from "../oauth/oauthCallback"; +import type { MementoManager } from "../storage/mementoManager"; +import type { + OAuthTokenData, + SecretsManager, + SessionAuth, +} from "../storage/secretsManager"; export type LoginMethod = | "mtls" diff --git a/src/oauth/authorizer.ts b/src/oauth/authorizer.ts index 6d23718a7b..c3fcb7db75 100644 --- a/src/oauth/authorizer.ts +++ b/src/oauth/authorizer.ts @@ -1,7 +1,8 @@ +import { createHash, randomBytes } from "node:crypto"; import * as vscode from "vscode"; import { CoderApi } from "../api/coderApi"; -import { resolveCoderDashboardUrl } from "../util/uri"; +import { resolveCoderDashboardUrl } from "../common/url"; import { AUTH_GRANT_TYPE, @@ -10,14 +11,10 @@ import { PKCE_CHALLENGE_METHOD, RESPONSE_TYPE, TOKEN_ENDPOINT_AUTH_METHOD, + CALLBACK_PATH, } from "./constants"; import { OAuthMetadataClient } from "./metadataClient"; -import { - CALLBACK_PATH, - generatePKCE, - generateState, - toUrlSearchParams, -} from "./utils"; +import { toUrlSearchParams } from "./tokens"; import { OAuth2ClientRegistrationResponseSchema, OAuth2TokenResponseSchema, @@ -34,9 +31,9 @@ import type { User, } from "coder/site/src/api/typesGenerated"; -import type { SecretsManager } from "../core/secretsManager"; import type { Deployment } from "../deployment/types"; import type { Logger } from "../logging/logger"; +import type { SecretsManager } from "../storage/secretsManager"; import type { OAuthCallback } from "./oauthCallback"; @@ -420,3 +417,25 @@ function toBrowserAuthorizationUrl( } return endpoint; } + +interface PKCEChallenge { + verifier: string; + challenge: string; +} + +/** + * Generates a PKCE challenge pair (RFC 7636). + * Creates a code verifier and its SHA256 challenge for secure OAuth flows. + */ +function generatePKCE(): PKCEChallenge { + const verifier = randomBytes(32).toString("base64url"); + const challenge = createHash("sha256").update(verifier).digest("base64url"); + return { verifier, challenge }; +} + +/** + * Generates a cryptographically secure state parameter to prevent CSRF attacks (RFC 6749). + */ +function generateState(): string { + return randomBytes(16).toString("base64url"); +} diff --git a/src/oauth/constants.ts b/src/oauth/constants.ts index 3f8356c62a..32c314fa1a 100644 --- a/src/oauth/constants.ts +++ b/src/oauth/constants.ts @@ -28,3 +28,8 @@ export const TOKEN_ENDPOINT_AUTH_METHOD = "client_secret_post"; // PKCE Code Challenge Methods (OAuth 2.1 requires S256) export const PKCE_CHALLENGE_METHOD = "S256"; + +/** + * OAuth callback path for handling authorization responses (RFC 6749). + */ +export const CALLBACK_PATH = "/oauth/callback"; diff --git a/src/oauth/sessionManager.ts b/src/oauth/sessionManager.ts index b435910171..843d4b3658 100644 --- a/src/oauth/sessionManager.ts +++ b/src/oauth/sessionManager.ts @@ -7,7 +7,7 @@ import { import { DEFAULT_OAUTH_SCOPES, REFRESH_GRANT_TYPE } from "./constants"; import { OAuthError, parseOAuthError } from "./errors"; import { OAuthMetadataClient } from "./metadataClient"; -import { buildOAuthTokenData, toUrlSearchParams } from "./utils"; +import { buildOAuthTokenData, toUrlSearchParams } from "./tokens"; import { OAuth2TokenResponseSchema, parseOAuthResponse } from "./validation"; import type { AxiosInstance } from "axios"; @@ -20,10 +20,10 @@ import type { } from "coder/site/src/api/typesGenerated"; import type * as vscode from "vscode"; -import type { ServiceContainer } from "../core/container"; -import type { OAuthTokenData, SecretsManager } from "../core/secretsManager"; +import type { ServiceContainer } from "../container"; import type { Deployment } from "../deployment/types"; import type { Logger } from "../logging/logger"; +import type { OAuthTokenData, SecretsManager } from "../storage/secretsManager"; /** * Token refresh threshold: refresh when token expires in less than this time. diff --git a/src/oauth/utils.ts b/src/oauth/tokens.ts similarity index 67% rename from src/oauth/utils.ts rename to src/oauth/tokens.ts index 1c65818ea5..1f0ade9165 100644 --- a/src/oauth/utils.ts +++ b/src/oauth/tokens.ts @@ -1,13 +1,6 @@ -import { createHash, randomBytes } from "node:crypto"; - import type { OAuth2TokenResponse } from "coder/site/src/api/typesGenerated"; -import type { OAuthTokenData } from "../core/secretsManager"; - -/** - * OAuth callback path for handling authorization responses (RFC 6749). - */ -export const CALLBACK_PATH = "/oauth/callback"; +import type { OAuthTokenData } from "../storage/secretsManager"; /** * Fallback expiry time for access tokens when the server omits expires_in. @@ -15,28 +8,6 @@ export const CALLBACK_PATH = "/oauth/callback"; */ const ACCESS_TOKEN_DEFAULT_EXPIRY_MS = 60 * 60 * 1000; -export interface PKCEChallenge { - verifier: string; - challenge: string; -} - -/** - * Generates a PKCE challenge pair (RFC 7636). - * Creates a code verifier and its SHA256 challenge for secure OAuth flows. - */ -export function generatePKCE(): PKCEChallenge { - const verifier = randomBytes(32).toString("base64url"); - const challenge = createHash("sha256").update(verifier).digest("base64url"); - return { verifier, challenge }; -} - -/** - * Generates a cryptographically secure state parameter to prevent CSRF attacks (RFC 6749). - */ -export function generateState(): string { - return randomBytes(16).toString("base64url"); -} - /** * Converts an object with string properties to URLSearchParams, * filtering out undefined values for use with OAuth requests. diff --git a/src/pgp.ts b/src/pgp.ts index 417ce54be3..e9d8832909 100644 --- a/src/pgp.ts +++ b/src/pgp.ts @@ -3,7 +3,7 @@ import * as openpgp from "openpgp"; import * as path from "path"; import { Readable } from "stream"; -import { errToStr } from "./api/api-helper"; +import { errToStr } from "./error/normalize"; import { type Logger } from "./logging/logger"; export type Key = openpgp.Key; diff --git a/src/util/authority.ts b/src/remote/authority.ts similarity index 99% rename from src/util/authority.ts rename to src/remote/authority.ts index 1120460ed1..2ce6e5db63 100644 --- a/src/util/authority.ts +++ b/src/remote/authority.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode"; -import { toSafeHost } from "./uri"; +import { toSafeHost } from "../common/url"; /** The editor every host was named after before per-editor prefixes existed. */ export const LegacyEditorId = "vscode"; diff --git a/src/remote/environment.ts b/src/remote/environment.ts index 15bf1f40fe..1fcec8379c 100644 --- a/src/remote/environment.ts +++ b/src/remote/environment.ts @@ -1,5 +1,5 @@ import { joinNoProxy } from "../api/proxy"; -import { sessionId } from "../core/sessionId"; +import { sessionId } from "../telemetry/ids"; import type { GlobalEnvironmentVariableCollection, diff --git a/src/remote/migration.ts b/src/remote/migration.ts index c18a68fde7..e7fd6e4c2a 100644 --- a/src/remote/migration.ts +++ b/src/remote/migration.ts @@ -1,8 +1,8 @@ import * as fs from "node:fs/promises"; -import type { PathResolver } from "../core/pathResolver"; -import type { SecretsManager } from "../core/secretsManager"; import type { Logger } from "../logging/logger"; +import type { PathResolver } from "../storage/pathResolver"; +import type { SecretsManager } from "../storage/secretsManager"; type SessionAuthStore = Pick< SecretsManager, diff --git a/src/remote/remote.ts b/src/remote/remote.ts index 88277d3501..5559abdabc 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -10,18 +10,20 @@ import { getEventValue, formatEventLabel, formatMetadataError, -} from "../api/agentMetadataHelper"; -import { extractAgents } from "../api/api-helper"; +} from "../api/agentMetadata"; import { AuthInterceptor } from "../api/authInterceptor"; import { CoderApi } from "../api/coderApi"; +import { needToken } from "../api/httpAgent"; import { HttpStatusCode } from "../api/httpStatusCode"; -import { needToken } from "../api/utils"; +import { extractAgents } from "../api/models"; +import { version as cliVersion } from "../cli/cliExec"; +import { expandPath } from "../common/fs"; +import { escapeCommandArg } from "../common/strings"; import { CONFIG_CHANGE_DEBOUNCE_MS, watchConfigurationChanges, } from "../configWatcher"; -import { version as cliVersion } from "../core/cliExec"; -import { toError } from "../error/errorUtils"; +import { toError } from "../error/normalize"; import { cliFeatureSet, serverFeatureSet, @@ -43,18 +45,17 @@ import { resolveCliAuth, } from "../settings/cli"; import { getHeaderCommand } from "../settings/headers"; -import { escapeCommandArg, expandPath } from "../util"; +import { createStatusBarItem } from "../ui/statusBar"; +import { vscodeProposed } from "../vscode/proposed"; +import { WorkspaceMonitor } from "../workspace/workspaceMonitor"; + import { type AuthorityParts, classifySshHost, hostEditorId, parseRemoteAuthority, sshHostOf, -} from "../util/authority"; -import { createStatusBarItem } from "../util/statusBar"; -import { vscodeProposed } from "../vscodeProposed"; -import { WorkspaceMonitor } from "../workspace/workspaceMonitor"; - +} from "./authority"; import { applySshEnvironment, SSH_PROXY_SETTINGS } from "./environment"; import { migrateAuthToSecretsStorage } from "./migration"; import { @@ -82,15 +83,15 @@ import type { WorkspaceAgent, } from "coder/site/src/api/typesGenerated"; +import type { CliManager } from "../cli/cliManager"; import type { Commands } from "../commands"; -import type { CliManager } from "../core/cliManager"; -import type { ServiceContainer } from "../core/container"; -import type { ContextManager } from "../core/contextManager"; -import type { StartupMode } from "../core/mementoManager"; -import type { PathResolver } from "../core/pathResolver"; -import type { SecretsManager } from "../core/secretsManager"; +import type { ServiceContainer } from "../container"; import type { Logger } from "../logging/logger"; import type { LoginCoordinator } from "../login/loginCoordinator"; +import type { StartupMode } from "../storage/mementoManager"; +import type { PathResolver } from "../storage/pathResolver"; +import type { SecretsManager } from "../storage/secretsManager"; +import type { ContextManager } from "../vscode/contextManager"; export interface RemoteDetails extends vscode.Disposable { safeHostname: string; diff --git a/src/remote/sshConfig.ts b/src/remote/sshConfig.ts index 7e71817e9e..337387cf3b 100644 --- a/src/remote/sshConfig.ts +++ b/src/remote/sshConfig.ts @@ -10,9 +10,9 @@ import { import * as os from "node:os"; import path from "node:path"; -import { SSH_CONFIG_EXT } from "../core/pathResolver"; -import { countSubstring, lowercase } from "../util"; -import { renameWithRetry, tempFilePath } from "../util/fs"; +import { renameWithRetry, tempFilePath } from "../common/fs"; +import { lowercase } from "../common/strings"; +import { SSH_CONFIG_EXT } from "../storage/pathResolver"; import type { Logger } from "../logging/logger"; @@ -594,3 +594,19 @@ export class SshConfig { } } } + +/** + * Return the number of times a substring appears in a string. + */ +export function countSubstring(needle: string, haystack: string): number { + if (needle.length < 1 || haystack.length < 1) { + return 0; + } + let count = 0; + let pos = haystack.indexOf(needle); + while (pos !== -1) { + count++; + pos = haystack.indexOf(needle, pos + needle.length); + } + return count; +} diff --git a/src/remote/sshProcess.ts b/src/remote/sshProcess.ts index f6b20ec0c7..879840f073 100644 --- a/src/remote/sshProcess.ts +++ b/src/remote/sshProcess.ts @@ -3,10 +3,9 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import * as vscode from "vscode"; +import { cleanupFiles } from "../common/fs"; import { SshTelemetry, type ProcessLossCause } from "../instrumentation/ssh"; -import { findPort } from "../util"; -import { cleanupFiles } from "../util/fileCleanup"; -import { createStatusBarItem } from "../util/statusBar"; +import { createStatusBarItem } from "../ui/statusBar"; import { NetworkStatusReporter } from "./networkStatus"; import { @@ -546,3 +545,36 @@ async function findSshLogInDir(dirPath: string): Promise { const remoteSshLog = files.find(isSharedChannelRemoteSshLog); return remoteSshLog ? path.join(dirPath, remoteSshLog) : undefined; } + +// Regex patterns to find the SSH port from Remote SSH extension logs. +// `ms-vscode-remote.remote-ssh`: `-> socksPort ->` or `between local port ` +// `codeium.windsurf-remote-openssh`, `jeanp413.open-remote-ssh`, `google.antigravity-remote-openssh`: `=> (socks) =>` +// `anysphere.remote-ssh`: `Socks port: ` +const RemoteSSHLogPortRegex = + /(?:-> socksPort (\d+) ->|between local port (\d+)|=> (\d+)\(socks\) =>|Socks port: (\d+))/g; + +/** + * Given the contents of a Remote - SSH log file, find the most recent port + * number used by the SSH process. This is typically the socks port, but the + * local port works too. + * + * Returns null if no port is found. + */ +export function findPort(text: string): number | null { + const allMatches = [...text.matchAll(RemoteSSHLogPortRegex)]; + if (allMatches.length === 0) { + return null; + } + + // Get the last match, which is the most recent port. + const lastMatch = allMatches[allMatches.length - 1]; + // Each capture group corresponds to a different Remote SSH extension log format: + // [0] full match, [1] and [2] ms-vscode-remote.remote-ssh, + // [3] devin/windsurf/open-remote-ssh/antigravity, [4] anysphere.remote-ssh + const portStr = lastMatch[1] || lastMatch[2] || lastMatch[3] || lastMatch[4]; + if (!portStr) { + return null; + } + + return Number.parseInt(portStr); +} diff --git a/src/remote/sshSupport.ts b/src/remote/sshSupport.ts index b0e6f7d26b..6ca5c87f4a 100644 --- a/src/remote/sshSupport.ts +++ b/src/remote/sshSupport.ts @@ -1,6 +1,6 @@ import * as childProcess from "child_process"; -import { lowercase } from "../util"; +import { lowercase } from "../common/strings"; /** * Matches the OpenSSH version number from `ssh -V` output. diff --git a/src/remote/windowsAcl.ts b/src/remote/windowsAcl.ts index 4684872a9c..cd9a8368a1 100644 --- a/src/remote/windowsAcl.ts +++ b/src/remote/windowsAcl.ts @@ -3,7 +3,7 @@ import { lstat, readdir } from "node:fs/promises"; import * as path from "node:path"; import { promisify } from "node:util"; -import { wrapError } from "../error/errorUtils"; +import { wrapError } from "../error/normalize"; import type { ManagedPermissions } from "./sshConfig"; diff --git a/src/remote/workspaceStateMachine.ts b/src/remote/workspaceStateMachine.ts index 1ed91335dc..07aef67d66 100644 --- a/src/remote/workspaceStateMachine.ts +++ b/src/remote/workspaceStateMachine.ts @@ -1,8 +1,4 @@ -import { - createWorkspaceIdentifier, - errToStr, - extractAgents, -} from "../api/api-helper"; +import { extractAgents, createWorkspaceIdentifier } from "../api/models"; import { collectUpdateParameters, WorkspaceUpdateCancelledError, @@ -14,9 +10,10 @@ import { streamAgentLogs, streamBuildLogs, } from "../api/workspace"; +import { errToStr } from "../error/normalize"; import { WorkspaceOperationTelemetry } from "../instrumentation/workspace"; -import { maybeAskAgent } from "../promptUtils"; -import { vscodeProposed } from "../vscodeProposed"; +import { maybeAskAgent } from "../ui/prompts"; +import { vscodeProposed } from "../vscode/proposed"; import { TerminalOutputChannel } from "./terminalOutputChannel"; @@ -28,12 +25,13 @@ import type { import type * as vscode from "vscode"; import type { CoderApi } from "../api/coderApi"; -import type { ServiceContainer } from "../core/container"; -import type { StartupMode } from "../core/mementoManager"; +import type { ServiceContainer } from "../container"; import type { CliFeatureSet, ServerFeatureSet } from "../featureSet"; import type { Logger } from "../logging/logger"; import type { CliAuth } from "../settings/cli"; -import type { AuthorityParts } from "../util/authority"; +import type { StartupMode } from "../storage/mementoManager"; + +import type { AuthorityParts } from "./authority"; /** * Manages workspace and agent state transitions until ready for SSH connection. diff --git a/src/settings/cli.ts b/src/settings/cli.ts index f8810b839f..3d42bd81db 100644 --- a/src/settings/cli.ts +++ b/src/settings/cli.ts @@ -1,6 +1,7 @@ import os from "node:os"; -import { escapeCommandArg, escapeShellArg, expandPath } from "../util"; +import { expandPath } from "../common/fs"; +import { escapeCommandArg, escapeShellArg } from "../common/strings"; import { getHeaderArgs } from "./headers"; diff --git a/src/core/mementoManager.ts b/src/storage/mementoManager.ts similarity index 100% rename from src/core/mementoManager.ts rename to src/storage/mementoManager.ts diff --git a/src/core/pathResolver.ts b/src/storage/pathResolver.ts similarity index 98% rename from src/core/pathResolver.ts rename to src/storage/pathResolver.ts index aa47edb461..42e9bd8a3f 100644 --- a/src/core/pathResolver.ts +++ b/src/storage/pathResolver.ts @@ -2,8 +2,8 @@ import * as os from "node:os"; import * as path from "node:path"; import * as vscode from "vscode"; -import { expandPath } from "../util"; -import { currentEditorId } from "../util/authority"; +import { expandPath } from "../common/fs"; +import { currentEditorId } from "../remote/authority"; /** Extension of generated SSH config files; the include glob matches on it. */ export const SSH_CONFIG_EXT = ".conf"; diff --git a/src/core/secretsManager.ts b/src/storage/secretsManager.ts similarity index 99% rename from src/core/secretsManager.ts rename to src/storage/secretsManager.ts index 28fbd52e9c..783005e6fb 100644 --- a/src/core/secretsManager.ts +++ b/src/storage/secretsManager.ts @@ -1,7 +1,7 @@ import { z } from "zod"; +import { toSafeHost } from "../common/url"; import { DeploymentSchema, type Deployment } from "../deployment/types"; -import { toSafeHost } from "../util/uri"; import type { OAuth2ClientRegistrationResponse } from "coder/site/src/api/typesGenerated"; import type { SecretStorage, Disposable } from "vscode"; diff --git a/src/supportBundle/appendVsCodeLogs.ts b/src/supportBundle/appendVsCodeLogs.ts index d76a22c27f..5748563132 100644 --- a/src/supportBundle/appendVsCodeLogs.ts +++ b/src/supportBundle/appendVsCodeLogs.ts @@ -4,13 +4,11 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { promisify } from "node:util"; +import { renameWithRetry } from "../common/fs"; import { type Logger } from "../logging/logger"; -import { renameWithRetry } from "../util/fs"; import { collectVsCodeDiagnostics, type LogSources } from "./logFiles"; -export type { LogSources } from "./logFiles"; - const unzipAsync = promisify(unzip); const zipAsync = promisify(zip); diff --git a/src/supportBundle/remoteServerDataPath.ts b/src/supportBundle/remoteServerDataPath.ts index 0688439cbb..a114c9c338 100644 --- a/src/supportBundle/remoteServerDataPath.ts +++ b/src/supportBundle/remoteServerDataPath.ts @@ -2,11 +2,11 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import * as vscode from "vscode"; +import { parseRemoteAuthority } from "../remote/authority"; import { getRemoteSshExtension, type RemoteSshExtensionId, } from "../remote/sshExtension"; -import { parseRemoteAuthority } from "../util/authority"; import type { Logger } from "../logging/logger"; diff --git a/src/util/date.ts b/src/telemetry/date.ts similarity index 100% rename from src/util/date.ts rename to src/telemetry/date.ts diff --git a/src/telemetry/event.ts b/src/telemetry/event.ts index 7de8ad8c08..9854d792f4 100644 --- a/src/telemetry/event.ts +++ b/src/telemetry/event.ts @@ -1,7 +1,7 @@ import * as os from "node:os"; import * as vscode from "vscode"; -import { toError } from "../error/errorUtils"; +import { toError } from "../error/normalize"; export type { SessionContext, diff --git a/src/telemetry/export/command.ts b/src/telemetry/export/command.ts index 2c0193d3ce..9a3c04cd92 100644 --- a/src/telemetry/export/command.ts +++ b/src/telemetry/export/command.ts @@ -1,11 +1,11 @@ import * as vscode from "vscode"; -import { toError } from "../../error/errorUtils"; +import { toError } from "../../error/normalize"; import { withCancellableProgress, type ProgressContext, type ProgressResult, -} from "../../progress"; +} from "../../ui/progress"; import { collectTelemetryExport, diff --git a/src/telemetry/export/files.ts b/src/telemetry/export/files.ts index 9b2b468f72..2bc1e00b61 100644 --- a/src/telemetry/export/files.ts +++ b/src/telemetry/export/files.ts @@ -3,7 +3,7 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import * as readline from "node:readline"; -import { toError } from "../../error/errorUtils"; +import { toError } from "../../error/normalize"; import * as localJsonlFiles from "../localJsonlFiles"; import { TelemetryFileParser, TelemetryFileParseError } from "../wireFormat"; diff --git a/src/telemetry/export/pipeline.ts b/src/telemetry/export/pipeline.ts index 0ae65edde6..83915c7881 100644 --- a/src/telemetry/export/pipeline.ts +++ b/src/telemetry/export/pipeline.ts @@ -1,6 +1,6 @@ import * as fs from "node:fs/promises"; -import { throwIfAborted } from "../../error/errorUtils"; +import { throwIfAborted } from "../../error/abort"; import { listTelemetryFilesForRange, diff --git a/src/telemetry/export/prompts.ts b/src/telemetry/export/prompts.ts index 12d86c0ef0..484eaf6646 100644 --- a/src/telemetry/export/prompts.ts +++ b/src/telemetry/export/prompts.ts @@ -2,7 +2,7 @@ import * as os from "node:os"; import * as path from "node:path"; import * as vscode from "vscode"; -import { toUtcDateString, validateUtcDateInput } from "../../util/date"; +import { toUtcDateString, validateUtcDateInput } from "../date"; import { TELEMETRY_RANGE_PRESETS, diff --git a/src/telemetry/export/range.ts b/src/telemetry/export/range.ts index 014f54c3de..0111983ea2 100644 --- a/src/telemetry/export/range.ts +++ b/src/telemetry/export/range.ts @@ -1,4 +1,4 @@ -import { DAY_MS, parseUtcDate, toUtcDateString } from "../../util/date"; +import { DAY_MS, parseUtcDate, toUtcDateString } from "../date"; /** * Half-open UTC window `[startMs, endMs)` used to filter telemetry. Either diff --git a/src/telemetry/export/writers/json.ts b/src/telemetry/export/writers/json.ts index d539c4be32..db907601de 100644 --- a/src/telemetry/export/writers/json.ts +++ b/src/telemetry/export/writers/json.ts @@ -2,7 +2,7 @@ import { createWriteStream } from "node:fs"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; -import { writeAtomically } from "../../../util/fs"; +import { writeAtomically } from "../../../common/fs"; import { serializeTelemetryEvent } from "../../wireFormat"; import type { TelemetryEvent } from "../../event"; diff --git a/src/telemetry/export/writers/otlp/envelope.ts b/src/telemetry/export/writers/otlp/envelope.ts index 744a626c5e..615c6788ef 100644 --- a/src/telemetry/export/writers/otlp/envelope.ts +++ b/src/telemetry/export/writers/otlp/envelope.ts @@ -1,6 +1,6 @@ import { createWriteStream } from "node:fs"; -import { wrapError } from "../../../../error/errorUtils"; +import { wrapError } from "../../../../error/normalize"; /** `openBlock` and `append` are not re-entrant. */ export interface EnvelopeFile { diff --git a/src/telemetry/export/writers/otlp/writer.ts b/src/telemetry/export/writers/otlp/writer.ts index c9519c7f59..adcd49752b 100644 --- a/src/telemetry/export/writers/otlp/writer.ts +++ b/src/telemetry/export/writers/otlp/writer.ts @@ -4,14 +4,10 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { - isAbortError, - throwIfAborted, - toError, - wrapError, -} from "../../../../error/errorUtils"; -import { toUtcDateString } from "../../../../util/date"; -import { writeAtomically } from "../../../../util/fs"; +import { writeAtomically } from "../../../../common/fs"; +import { isAbortError, throwIfAborted } from "../../../../error/abort"; +import { toError, wrapError } from "../../../../error/normalize"; +import { toUtcDateString } from "../../../date"; import { describeMetricEvent } from "../../metrics"; import { parseTelemetryTimestampMs } from "../../range"; diff --git a/src/telemetry/ids.ts b/src/telemetry/ids.ts index 486eeb61fa..40044c379e 100644 --- a/src/telemetry/ids.ts +++ b/src/telemetry/ids.ts @@ -12,3 +12,18 @@ export function newTraceId(): string { export function newSpanId(): string { return randomBytes(8).toString("hex"); } + +/** + * One session ID per extension host process, shared across logs, API + * requests, telemetry, and the CLI so all data for a session can be correlated + * by a single ID. + * + * In rare cases when the extension is deactivated then activated within the + * same window, it should reuse the same ID. However, reloading or closing- + * then-reopening a window creates a new process with a new ID. + * + * 16 bytes / 32 lowercase hex, matching the OTel id format so a future OTel + * exporter maps 1:1. Avoids `vscode.env.sessionId`, which is a UUID + * concatenated with a timestamp. + */ +export const sessionId = randomBytes(16).toString("hex"); diff --git a/src/telemetry/sinks/localJsonlSink.ts b/src/telemetry/sinks/localJsonlSink.ts index 7d5ca607d7..f1d39ae875 100644 --- a/src/telemetry/sinks/localJsonlSink.ts +++ b/src/telemetry/sinks/localJsonlSink.ts @@ -2,17 +2,14 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import * as vscode from "vscode"; +import { cleanupFiles, type FileCleanupCandidate } from "../../common/fs"; import { watchConfigurationChanges } from "../../configWatcher"; import { LOCAL_TELEMETRY_SETTING, readLocalSinkConfig, type LocalSinkConfig, } from "../../settings/telemetry"; -import { DAY_MS, toUtcDateString } from "../../util/date"; -import { - cleanupFiles, - type FileCleanupCandidate, -} from "../../util/fileCleanup"; +import { DAY_MS, toUtcDateString } from "../date"; import * as localJsonlFiles from "../localJsonlFiles"; import { serializeTelemetryEventLine, diff --git a/src/progress.ts b/src/ui/progress.ts similarity index 98% rename from src/progress.ts rename to src/ui/progress.ts index f08da27476..342885b90d 100644 --- a/src/progress.ts +++ b/src/ui/progress.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode"; -import { isAbortError } from "./error/errorUtils"; +import { isAbortError } from "../error/abort"; export type ProgressResult = | { ok: true; value: T } diff --git a/src/promptUtils.ts b/src/ui/prompts.ts similarity index 96% rename from src/promptUtils.ts rename to src/ui/prompts.ts index 049b249c52..f3d63883e6 100644 --- a/src/promptUtils.ts +++ b/src/ui/prompts.ts @@ -1,9 +1,10 @@ import { type WorkspaceAgent } from "coder/site/src/api/typesGenerated"; import * as vscode from "vscode"; -import { type CoderApi } from "./api/coderApi"; -import { type MementoManager } from "./core/mementoManager"; -import { OAuthMetadataClient } from "./oauth/metadataClient"; +import { type CoderApi } from "../api/coderApi"; +import { OAuthMetadataClient } from "../oauth/metadataClient"; +import { type MementoManager } from "../storage/mementoManager"; + import { withProgress } from "./progress"; type AuthMethod = "oauth" | "legacy"; diff --git a/src/util/statusBar.ts b/src/ui/statusBar.ts similarity index 100% rename from src/util/statusBar.ts rename to src/ui/statusBar.ts diff --git a/src/uri/uriHandler.ts b/src/uri/uriHandler.ts index 0675d36b8b..67623a14cc 100644 --- a/src/uri/uriHandler.ts +++ b/src/uri/uriHandler.ts @@ -1,14 +1,14 @@ import * as vscode from "vscode"; -import { errToStr } from "../api/api-helper"; +import { isSameOrigin, toSafeHost } from "../common/url"; +import { errToStr } from "../error/normalize"; import { AuthTelemetry } from "../instrumentation/auth"; -import { CALLBACK_PATH } from "../oauth/utils"; -import { maybeAskUrl } from "../promptUtils"; -import { isSameOrigin, toSafeHost } from "../util/uri"; -import { vscodeProposed } from "../vscodeProposed"; +import { CALLBACK_PATH } from "../oauth/constants"; +import { maybeAskUrl } from "../ui/prompts"; +import { vscodeProposed } from "../vscode/proposed"; import type { Commands } from "../commands"; -import type { ServiceContainer } from "../core/container"; +import type { ServiceContainer } from "../container"; import type { DeploymentManager } from "../deployment/deploymentManager"; interface UriHandlerDeps { diff --git a/src/util.ts b/src/util.ts deleted file mode 100644 index 87199e041c..0000000000 --- a/src/util.ts +++ /dev/null @@ -1,105 +0,0 @@ -import os from "node:os"; - -// Regex patterns to find the SSH port from Remote SSH extension logs. -// `ms-vscode-remote.remote-ssh`: `-> socksPort ->` or `between local port ` -// `codeium.windsurf-remote-openssh`, `jeanp413.open-remote-ssh`, `google.antigravity-remote-openssh`: `=> (socks) =>` -// `anysphere.remote-ssh`: `Socks port: ` -export const RemoteSSHLogPortRegex = - /(?:-> socksPort (\d+) ->|between local port (\d+)|=> (\d+)\(socks\) =>|Socks port: (\d+))/g; - -/** - * Given the contents of a Remote - SSH log file, find the most recent port - * number used by the SSH process. This is typically the socks port, but the - * local port works too. - * - * Returns null if no port is found. - */ -export function findPort(text: string): number | null { - const allMatches = [...text.matchAll(RemoteSSHLogPortRegex)]; - if (allMatches.length === 0) { - return null; - } - - // Get the last match, which is the most recent port. - const lastMatch = allMatches[allMatches.length - 1]; - // Each capture group corresponds to a different Remote SSH extension log format: - // [0] full match, [1] and [2] ms-vscode-remote.remote-ssh, - // [3] devin/windsurf/open-remote-ssh/antigravity, [4] anysphere.remote-ssh - const portStr = lastMatch[1] || lastMatch[2] || lastMatch[3] || lastMatch[4]; - if (!portStr) { - return null; - } - - return Number.parseInt(portStr); -} - -/** - * Substitute `${env:VAR}` with `process.env.VAR` (unset → empty string), - * `${userHome}` (anywhere) with `os.homedir()`, and a leading `~` with - * `os.homedir()`. Env substitution runs first so env values can themselves - * contain `~` or `${userHome}`. - */ -export function expandPath(input: string): string { - const expanded = input.replace( - /\$\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g, - (_, name: string) => process.env[name] ?? "", - ); - const userHome = os.homedir(); - const tildeExpanded = expanded.startsWith("~") - ? userHome + expanded.substring("~".length) - : expanded; - return tildeExpanded.replaceAll("${userHome}", userHome); -} - -/** `toLowerCase` typed for indexing `Lowercase`-keyed records without a cast. */ -export function lowercase(value: T): Lowercase { - return value.toLowerCase() as Lowercase; -} - -/** - * Return the number of times a substring appears in a string. - */ -export function countSubstring(needle: string, haystack: string): number { - if (needle.length < 1 || haystack.length < 1) { - return 0; - } - let count = 0; - let pos = haystack.indexOf(needle); - while (pos !== -1) { - count++; - pos = haystack.indexOf(needle, pos + needle.length); - } - return count; -} - -/** - * Wraps `arg` in `"..."` unless every character is in the shell-safe - * whitelist (matching Python `shlex.quote`'s set: alphanumerics plus - * `@%+,=:./-`). Anything else (whitespace, `"`, `&|;()<>*?[~#!^\` `$`) - * forces quoting so the output is a single token in POSIX `sh`, cmd.exe, - * and PowerShell. - * - * Not a universal shell-escape: `$VAR` / `$(...)` / `%VAR%` still expand - * inside `"..."`. For untrusted values use {@link escapeShellArg}. - * - * @see https://docs.python.org/3/library/shlex.html#shlex.quote - * @see https://learn.microsoft.com/en-us/archive/blogs/twistylittlepassagesallalike/everyone-quotes-command-line-arguments-the-wrong-way - */ -export function escapeCommandArg(arg: string): string { - if (arg !== "" && /^[\w@%+,=:./-]+$/.test(arg)) { - return arg; - } - return `"${arg.replaceAll('"', String.raw`\"`)}"`; -} - -/** - * Cross-platform shell quoting that blocks variable expansion. Use for - * values from outside the user's local settings (e.g. server-controlled). - */ -export function escapeShellArg(arg: string): string { - if (os.platform() === "win32") { - const escaped = arg.replace(/"/g, '""').replace(/%/g, "%%"); - return `"${escaped}"`; - } - return `'${arg.replace(/'/g, "'\\''")}'`; -} diff --git a/src/util/fileCleanup.ts b/src/util/fileCleanup.ts deleted file mode 100644 index c8804286eb..0000000000 --- a/src/util/fileCleanup.ts +++ /dev/null @@ -1,94 +0,0 @@ -import * as fs from "node:fs/promises"; -import * as path from "node:path"; - -import type { Logger } from "../logging/logger"; - -export interface FileCleanupCandidate { - name: string; - mtime: number; - size: number; -} - -export interface FileCleanupOptions { - /** Label for log messages, e.g. "telemetry file". */ - label: string; - /** Cheap name-based predicate; non-matching entries are skipped before stat. */ - filter?: (name: string) => boolean; - /** From the stat'd survivors of `filter`, returns the files to delete. */ - select: ( - files: FileCleanupCandidate[], - now: number, - ) => Array<{ name: string }>; -} - -/** - * Lists files in `dir`, applies `filter` to names, stats the survivors, and - * unlinks whatever `select` returns. ENOENT is swallowed so concurrent - * deletes are safe. Never throws; failures go to `logger.debug`. - */ -export async function cleanupFiles( - dir: string, - logger: Logger, - options: FileCleanupOptions, -): Promise { - const { label, filter, select } = options; - const now = Date.now(); - let names: string[]; - try { - names = await fs.readdir(dir); - } catch (error) { - // ENOENT just means the dir hasn't been created yet; anything else - // (EACCES, EMFILE, ...) is a real failure worth surfacing. - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - logger.debug(`Failed to read ${label} directory ${dir}`, error); - } - return; - } - const candidates = filter ? names.filter(filter) : names; - - const withStats = await Promise.all( - candidates.map(async (name) => { - try { - const stats = await fs.stat(path.join(dir, name)); - return { - name, - mtime: stats.mtime.getTime(), - size: stats.size, - }; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - logger.debug(`Failed to stat ${label} ${name}`, error); - } - return null; - } - }), - ); - - const toDelete = select( - withStats.filter((f) => f !== null), - now, - ); - - const deleted = await Promise.all( - toDelete.map(async (file) => { - // Basename only; never let `select` escape `dir`. - const safeName = path.basename(file.name); - try { - await fs.unlink(path.join(dir, safeName)); - return safeName; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - logger.debug(`Failed to delete ${label} ${safeName}`, error); - } - return null; - } - }), - ); - - const successful = deleted.filter((name) => name !== null); - if (successful.length > 0) { - logger.debug( - `Cleaned up ${successful.length} ${label}(s): ${successful.join(", ")}`, - ); - } -} diff --git a/src/util/fs.ts b/src/util/fs.ts deleted file mode 100644 index 738cbee8b6..0000000000 --- a/src/util/fs.ts +++ /dev/null @@ -1,84 +0,0 @@ -import * as fs from "node:fs/promises"; - -const transientRenameCodes: ReadonlySet = new Set([ - "EPERM", - "EACCES", - "EBUSY", -]); - -/** - * Rename with retry for transient Windows filesystem errors (EPERM, EACCES, - * EBUSY). On Windows, antivirus, Search Indexer, cloud sync, or concurrent - * processes can briefly lock files causing renames to fail. - * - * On non-Windows platforms, calls renameFn directly with no retry. - * - * Matches the strategy used by VS Code (pfs.ts) and graceful-fs: 60s - * wall-clock timeout with linear backoff (10ms increments) capped at 100ms. - */ -export async function renameWithRetry( - renameFn: (src: string, dest: string) => Promise, - source: string, - destination: string, - timeoutMs = 60_000, - delayCapMs = 100, -): Promise { - if (process.platform !== "win32") { - return renameFn(source, destination); - } - const startTime = Date.now(); - for (let attempt = 1; ; attempt++) { - try { - return await renameFn(source, destination); - } catch (err) { - const code = (err as NodeJS.ErrnoException).code; - if ( - !code || - !transientRenameCodes.has(code) || - Date.now() - startTime >= timeoutMs - ) { - throw err; - } - const delay = Math.min(delayCapMs, attempt * 10); - await new Promise((resolve) => setTimeout(resolve, delay)); - } - } -} - -/** - * Generate a temporary file path by appending a suffix with a random component. - * The suffix describes the purpose of the temp file (e.g. "temp", "old"). - * Example: tempFilePath("/a/b", "temp") returns "/a/b.temp-b91b24be" - */ -export function tempFilePath(basePath: string, suffix: string): string { - return `${basePath}.${suffix}-${crypto.randomUUID().substring(0, 8)}`; -} - -/** - * Atomically writes to `outputPath` via a sibling temp file and rename. - * The parent directory must already exist. On failure the destination is - * left untouched, the temp file is best-effort removed, and the writer - * error is always rethrown. `onCleanupError`, if given, receives any error - * from the cleanup attempt; its own throws are swallowed. - */ -export async function writeAtomically( - outputPath: string, - write: (tempPath: string) => Promise, - onCleanupError?: (err: unknown, tempPath: string) => void, -): Promise { - const tempPath = tempFilePath(outputPath, "temp"); - try { - const result = await write(tempPath); - await renameWithRetry(fs.rename, tempPath, outputPath); - return result; - } catch (err) { - try { - await fs.rm(tempPath, { force: true }).catch((rmErr) => { - onCleanupError?.(rmErr, tempPath); - }); - } catch { - // onCleanupError threw; the writer error below takes precedence. - } - throw err; - } -} diff --git a/src/core/commandManager.ts b/src/vscode/commandManager.ts similarity index 100% rename from src/core/commandManager.ts rename to src/vscode/commandManager.ts diff --git a/src/core/contextManager.ts b/src/vscode/contextManager.ts similarity index 100% rename from src/core/contextManager.ts rename to src/vscode/contextManager.ts diff --git a/src/vscodeProposed.ts b/src/vscode/proposed.ts similarity index 100% rename from src/vscodeProposed.ts rename to src/vscode/proposed.ts diff --git a/src/websocket/codes.ts b/src/websocket/codes.ts index d2cf3f60e9..31abe105a2 100644 --- a/src/websocket/codes.ts +++ b/src/websocket/codes.ts @@ -40,3 +40,19 @@ export const UNRECOVERABLE_HTTP_CODES = new Set([ HttpStatusCode.GONE, HttpStatusCode.UPGRADE_REQUIRED, ]); + +export class HandshakeError extends Error { + constructor( + readonly statusCode: number, + message = `HTTP handshake failed (${statusCode})`, + options?: ErrorOptions, + ) { + super(message, options); + this.name = "HandshakeError"; + } +} + +/** HTTP status from a failed `ws` or `eventsource` handshake, or `undefined`. */ +export function handshakeStatus(error: unknown): number | undefined { + return error instanceof HandshakeError ? error.statusCode : undefined; +} diff --git a/src/websocket/oneWayWebSocket.ts b/src/websocket/oneWayWebSocket.ts index 2b3b7af117..243f51df83 100644 --- a/src/websocket/oneWayWebSocket.ts +++ b/src/websocket/oneWayWebSocket.ts @@ -10,13 +10,14 @@ import { type WebSocketEventType } from "coder/site/src/utils/OneWayWebSocket"; import Ws, { type ClientOptions, type RawData } from "ws"; -import { toError } from "../error/errorUtils"; +import { getQueryString } from "../common/url"; +import { toError } from "../error/normalize"; +import { HandshakeError } from "./codes"; import { type UnidirectionalStream, type EventHandler, } from "./eventStreamConnection"; -import { getQueryString, HandshakeError, rawDataToString } from "./utils"; export interface OneWayWebSocketInit { location: { protocol: string; host: string }; @@ -159,3 +160,15 @@ export class OneWayWebSocket< this.#socket.close(code, reason); } } + +function rawDataToString(data: RawData): string { + if (Buffer.isBuffer(data)) { + return data.toString("utf8"); + } else if (data instanceof ArrayBuffer) { + return new TextDecoder().decode(data); + } else if (Array.isArray(data)) { + return Buffer.concat(data).toString("utf8"); + } else { + return new TextDecoder().decode(data); + } +} diff --git a/src/websocket/reconnectingWebSocket.ts b/src/websocket/reconnectingWebSocket.ts index ff8a154ce9..08986ad86c 100644 --- a/src/websocket/reconnectingWebSocket.ts +++ b/src/websocket/reconnectingWebSocket.ts @@ -1,6 +1,6 @@ import { HttpStatusCode } from "../api/httpStatusCode"; import { ClientCertificateError } from "../error/clientCertificateError"; -import { toError } from "../error/errorUtils"; +import { toError } from "../error/normalize"; import { WebSocketTelemetry, type ConnectionDropCause, @@ -11,8 +11,8 @@ import { WebSocketCloseCode, UNRECOVERABLE_WS_CLOSE_CODES, UNRECOVERABLE_HTTP_CODES, + handshakeStatus, } from "./codes"; -import { handshakeStatus } from "./utils"; import type { WebSocketEventType } from "coder/site/src/utils/OneWayWebSocket"; diff --git a/src/websocket/sseConnection.ts b/src/websocket/sseConnection.ts index 60c0892e6c..8b27b58de8 100644 --- a/src/websocket/sseConnection.ts +++ b/src/websocket/sseConnection.ts @@ -4,11 +4,11 @@ import { type WebSocketEventType } from "coder/site/src/utils/OneWayWebSocket"; import { EventSource, type ErrorEvent } from "eventsource"; import { createStreamingFetchAdapter } from "../api/streamingFetchAdapter"; -import { toError } from "../error/errorUtils"; +import { getQueryString } from "../common/url"; +import { toError } from "../error/normalize"; import { type Logger } from "../logging/logger"; -import { WebSocketCloseCode } from "./codes"; -import { getQueryString, HandshakeError } from "./utils"; +import { WebSocketCloseCode, HandshakeError } from "./codes"; import type { UnidirectionalStream, diff --git a/src/websocket/utils.ts b/src/websocket/utils.ts deleted file mode 100644 index 30bd8c62ca..0000000000 --- a/src/websocket/utils.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { RawData } from "ws"; - -/** - * Converts params to a query string. Returns empty string if no params, - * otherwise returns params prefixed with '?'. - */ -export function getQueryString( - params: Record | URLSearchParams | undefined, -): string { - if (!params) { - return ""; - } - const searchParams = - params instanceof URLSearchParams ? params : new URLSearchParams(params); - const str = searchParams.toString(); - return str ? `?${str}` : ""; -} - -export function rawDataToString(data: RawData): string { - if (Buffer.isBuffer(data)) { - return data.toString("utf8"); - } else if (data instanceof ArrayBuffer) { - return new TextDecoder().decode(data); - } else if (Array.isArray(data)) { - return Buffer.concat(data).toString("utf8"); - } else { - return new TextDecoder().decode(data); - } -} - -export class HandshakeError extends Error { - constructor( - readonly statusCode: number, - message = `HTTP handshake failed (${statusCode})`, - options?: ErrorOptions, - ) { - super(message, options); - this.name = "HandshakeError"; - } -} - -/** HTTP status from a failed `ws` or `eventsource` handshake, or `undefined`. */ -export function handshakeStatus(error: unknown): number | undefined { - return error instanceof HandshakeError ? error.statusCode : undefined; -} diff --git a/src/webviews/dispatch.ts b/src/webviews/dispatch.ts index d097314155..7c232f7527 100644 --- a/src/webviews/dispatch.ts +++ b/src/webviews/dispatch.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode"; -import { toError } from "../error/errorUtils"; +import { toError } from "../error/normalize"; import { type Logger } from "../logging/logger"; import type { IpcRequest, IpcResponse, NotificationDef } from "@repo/shared"; diff --git a/src/webviews/tasks/tasksPanelProvider.ts b/src/webviews/tasks/tasksPanelProvider.ts index 005b746d0b..b16050e080 100644 --- a/src/webviews/tasks/tasksPanelProvider.ts +++ b/src/webviews/tasks/tasksPanelProvider.ts @@ -17,7 +17,6 @@ import { type TaskTemplate, } from "@repo/shared"; -import { errToStr } from "../../api/api-helper"; import { type CoderApi } from "../../api/coderApi"; import { HttpStatusCode } from "../../api/httpStatusCode"; import { @@ -25,9 +24,10 @@ import { streamAgentLogs, streamBuildLogs, } from "../../api/workspace"; +import { openInBrowser } from "../../common/url"; +import { errToStr } from "../../error/normalize"; import { type Logger } from "../../logging/logger"; -import { openInBrowser } from "../../util/uri"; -import { vscodeProposed } from "../../vscodeProposed"; +import { vscodeProposed } from "../../vscode/proposed"; import { dispatchWebviewMessage, notifyWebview } from "../dispatch"; import { getWebviewHtml } from "../html"; diff --git a/src/util/poller.ts b/src/webviews/workspaces/poller.ts similarity index 97% rename from src/util/poller.ts rename to src/webviews/workspaces/poller.ts index 2875b17e7f..40c3a1c42d 100644 --- a/src/util/poller.ts +++ b/src/webviews/workspaces/poller.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode"; -import type { Logger } from "../logging/logger"; +import type { Logger } from "../../logging/logger"; /** What a task asks for once it is done. */ export type NextRun = { readonly delayMs: number } | "retry" | "idle"; diff --git a/src/webviews/workspaces/workspaceStore.ts b/src/webviews/workspaces/workspaceStore.ts index f4a4f528ec..bd4bf86fe3 100644 --- a/src/webviews/workspaces/workspaceStore.ts +++ b/src/webviews/workspaces/workspaceStore.ts @@ -1,8 +1,8 @@ import { isDeepStrictEqual } from "node:util"; import * as vscode from "vscode"; -import { errToStr, extractAllAgents } from "../../api/api-helper"; -import { Poller, type NextRun, type RetryOptions } from "../../util/poller"; +import { extractAllAgents } from "../../api/models"; +import { errToStr } from "../../error/normalize"; import { AgentMetadataTracker } from "../../workspace/agentMetadataTracker"; import { availableFilters, @@ -11,6 +11,8 @@ import { WORKSPACE_FILTERS, } from "../../workspace/filters"; +import { Poller, type NextRun, type RetryOptions } from "./poller"; + import type { Workspace, WorkspaceFilter, @@ -18,7 +20,7 @@ import type { WorkspacesState, } from "@repo/shared"; -import type { AgentMetadataClient } from "../../api/agentMetadataHelper"; +import type { AgentMetadataClient } from "../../api/agentMetadata"; import type { CoderApi } from "../../api/coderApi"; import type { SessionState } from "../../deployment/sessionStore"; import type { Logger } from "../../logging/logger"; diff --git a/src/webviews/workspaces/panelProvider.ts b/src/webviews/workspaces/workspacesPanelProvider.ts similarity index 98% rename from src/webviews/workspaces/panelProvider.ts rename to src/webviews/workspaces/workspacesPanelProvider.ts index f91a6b239c..ab837390b6 100644 --- a/src/webviews/workspaces/panelProvider.ts +++ b/src/webviews/workspaces/workspacesPanelProvider.ts @@ -6,7 +6,7 @@ import { type OpenWorkspaceParams, } from "@repo/shared"; -import { extractAgents } from "../../api/api-helper"; +import { extractAgents } from "../../api/models"; import { dispatchWebviewMessage, notifyWebview, diff --git a/src/workspace/agentMetadataTracker.ts b/src/workspace/agentMetadataTracker.ts index fb8b83b37f..1a952add22 100644 --- a/src/workspace/agentMetadataTracker.ts +++ b/src/workspace/agentMetadataTracker.ts @@ -5,7 +5,7 @@ import { formatMetadataError, type AgentMetadataWatcher, type AgentMetadataClient, -} from "../api/agentMetadataHelper"; +} from "../api/agentMetadata"; import type { AgentMetadataMap, diff --git a/src/workspace/filters.ts b/src/workspace/filters.ts index a025c43e45..146544ae5a 100644 --- a/src/workspace/filters.ts +++ b/src/workspace/filters.ts @@ -1,7 +1,7 @@ import { isAxiosError } from "axios"; -import { isOwner } from "../api/api-helper"; import { HttpStatusCode } from "../api/httpStatusCode"; +import { isOwner } from "../api/models"; import type { WorkspaceFilter } from "@repo/shared"; diff --git a/src/workspace/observers.ts b/src/workspace/observers.ts index 5b06641d0f..566d055eef 100644 --- a/src/workspace/observers.ts +++ b/src/workspace/observers.ts @@ -1,4 +1,4 @@ -import { extractAgents } from "../api/api-helper"; +import { extractAgents } from "../api/models"; import type { Workspace, diff --git a/src/workspace/workspaceMonitor.ts b/src/workspace/workspaceMonitor.ts index 5a64138197..8454b94b86 100644 --- a/src/workspace/workspaceMonitor.ts +++ b/src/workspace/workspaceMonitor.ts @@ -9,11 +9,8 @@ import { import { formatDistanceToNowStrict } from "date-fns"; import * as vscode from "vscode"; -import { - createWorkspaceIdentifier, - errToStr, - extractAgents, -} from "../api/api-helper"; +import { extractAgents, createWorkspaceIdentifier } from "../api/models"; +import { errToStr } from "../error/normalize"; import { recordAgentState, recordWorkspaceState, @@ -22,8 +19,8 @@ import { areNotificationsDisabled, areUpdateNotificationsDisabled, } from "../settings/notifications"; -import { createStatusBarItem } from "../util/statusBar"; -import { vscodeProposed } from "../vscodeProposed"; +import { createStatusBarItem } from "../ui/statusBar"; +import { vscodeProposed } from "../vscode/proposed"; import { INITIAL_STATE, @@ -32,10 +29,10 @@ import { } from "./observers"; import type { CoderApi } from "../api/coderApi"; -import type { ServiceContainer } from "../core/container"; -import type { ContextManager } from "../core/contextManager"; +import type { ServiceContainer } from "../container"; import type { Logger } from "../logging/logger"; import type { TelemetryReporter } from "../telemetry/reporter"; +import type { ContextManager } from "../vscode/contextManager"; import type { UnidirectionalStream } from "../websocket/eventStreamConnection"; const stateVerb = (from: string | undefined) => diff --git a/src/workspace/workspacesProvider.ts b/src/workspace/workspacesProvider.ts index 22f2e101e4..5dff5ae413 100644 --- a/src/workspace/workspacesProvider.ts +++ b/src/workspace/workspacesProvider.ts @@ -10,14 +10,14 @@ import { createAgentMetadataWatcher, formatEventLabel, formatMetadataError, -} from "../api/agentMetadataHelper"; -import { type AgentMetadataEvent, - workspaceStatusLabel, +} from "../api/agentMetadata"; +import { type CoderApi } from "../api/coderApi"; +import { extractAgents, extractAllAgents, -} from "../api/api-helper"; -import { type CoderApi } from "../api/coderApi"; + workspaceStatusLabel, +} from "../api/models"; import { type Logger } from "../logging/logger"; import { diff --git a/test/mocks/commands.ts b/test/mocks/commands.ts index 684f109e07..b35d25f194 100644 --- a/test/mocks/commands.ts +++ b/test/mocks/commands.ts @@ -7,7 +7,7 @@ import { createTestTelemetryService } from "./telemetry"; import { createMockLogger } from "./testHelpers"; import type { CoderApi } from "@/api/coderApi"; -import type { ServiceContainer } from "@/core/container"; +import type { ServiceContainer } from "@/container"; import type { DeploymentManager } from "@/deployment/deploymentManager"; /** Build `Commands`; services left unnamed stand in as empty objects. */ diff --git a/test/mocks/testHelpers.ts b/test/mocks/testHelpers.ts index 858fd31956..3e6cef9be5 100644 --- a/test/mocks/testHelpers.ts +++ b/test/mocks/testHelpers.ts @@ -34,20 +34,20 @@ import type { import type { WebSocketEventType } from "coder/site/src/utils/OneWayWebSocket"; import type { IncomingMessage } from "node:http"; -import type { AgentMetadataEvent } from "@/api/api-helper"; +import type { AgentMetadataEvent } from "@/api/agentMetadata"; import type { CoderApi } from "@/api/coderApi"; -import type { CliCredentialManager } from "@/core/cliCredentialManager"; -import type { CliManager } from "@/core/cliManager"; -import type { ServiceContainer } from "@/core/container"; -import type { ContextManager } from "@/core/contextManager"; -import type { MementoManager } from "@/core/mementoManager"; -import type { PathResolver } from "@/core/pathResolver"; -import type { SecretsManager } from "@/core/secretsManager"; +import type { CliCredentialManager } from "@/cli/cliCredentialManager"; +import type { CliManager } from "@/cli/cliManager"; +import type { ServiceContainer } from "@/container"; import type { Deployment } from "@/deployment/types"; import type { Logger } from "@/logging/logger"; import type { LoginCoordinator } from "@/login/loginCoordinator"; import type { NetworkInfo } from "@/remote/sshProcess"; +import type { MementoManager } from "@/storage/mementoManager"; +import type { PathResolver } from "@/storage/pathResolver"; +import type { SecretsManager } from "@/storage/secretsManager"; import type { TelemetryService } from "@/telemetry/service"; +import type { ContextManager } from "@/vscode/contextManager"; import type { EventHandler, EventPayloadMap, diff --git a/test/unit/announcements/manager.test.ts b/test/unit/announcements/manager.test.ts index bc3e394fcc..f357d7e4c2 100644 --- a/test/unit/announcements/manager.test.ts +++ b/test/unit/announcements/manager.test.ts @@ -5,8 +5,8 @@ import { AnnouncementManager, REFRESH_INTERVAL_MS, } from "@/announcements/manager"; -import { MementoManager } from "@/core/mementoManager"; import { SessionStore } from "@/deployment/sessionStore"; +import { MementoManager } from "@/storage/mementoManager"; import { createMockLogger, diff --git a/test/unit/api/authInterceptor.test.ts b/test/unit/api/authInterceptor.test.ts index 7273eea7ff..94888450f9 100644 --- a/test/unit/api/authInterceptor.test.ts +++ b/test/unit/api/authInterceptor.test.ts @@ -6,8 +6,8 @@ import { AuthInterceptor, } from "@/api/authInterceptor"; import { HttpStatusCode } from "@/api/httpStatusCode"; -import { MementoManager } from "@/core/mementoManager"; -import { SecretsManager } from "@/core/secretsManager"; +import { MementoManager } from "@/storage/mementoManager"; +import { SecretsManager } from "@/storage/secretsManager"; import { createTestTelemetryService, diff --git a/test/unit/api/coderApi.test.ts b/test/unit/api/coderApi.test.ts index 7bcedd47c7..b9ef234250 100644 --- a/test/unit/api/coderApi.test.ts +++ b/test/unit/api/coderApi.test.ts @@ -22,25 +22,24 @@ import { refreshCertificates, } from "@/api/certificateRefresh"; import { CoderApi, DEFAULT_REQUEST_TIMEOUT_MS } from "@/api/coderApi"; +import { createHttpAgent } from "@/api/httpAgent"; import { HttpStatusCode } from "@/api/httpStatusCode"; import { InvalidApiResponseError, VALIDATED_RESPONSES, type ValidatedMethods, } from "@/api/responseValidation"; -import { createHttpAgent } from "@/api/utils"; import { CONFIG_CHANGE_DEBOUNCE_MS } from "@/configWatcher"; -import { sessionId } from "@/core/sessionId"; import { ClientCertificateError } from "@/error/clientCertificateError"; import { ServerCertificateError } from "@/error/serverCertificateError"; import { getHeaders } from "@/headers"; +import { sessionId } from "@/telemetry/ids"; import { NOOP_TELEMETRY_REPORTER, type TelemetryReporter, } from "@/telemetry/reporter"; -import { WebSocketCloseCode } from "@/websocket/codes"; +import { WebSocketCloseCode, HandshakeError } from "@/websocket/codes"; import { ReconnectingWebSocket } from "@/websocket/reconnectingWebSocket"; -import { HandshakeError } from "@/websocket/utils"; import { createMockLogger, @@ -92,7 +91,7 @@ vi.mock("@/headers", () => ({ getHeaderCommand: vi.fn(), })); -vi.mock("@/api/utils", () => ({ +vi.mock("@/api/httpAgent", () => ({ createHttpAgent: vi.fn(), })); diff --git a/test/unit/api/utils.test.ts b/test/unit/api/httpAgent.test.ts similarity index 99% rename from test/unit/api/utils.test.ts rename to test/unit/api/httpAgent.test.ts index 18298a02fc..500b58e8a5 100644 --- a/test/unit/api/utils.test.ts +++ b/test/unit/api/httpAgent.test.ts @@ -1,7 +1,7 @@ import { vol } from "memfs"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { createHttpAgent, needToken } from "@/api/utils"; +import { createHttpAgent, needToken } from "@/api/httpAgent"; import { config, diff --git a/test/unit/api/api-helper.test.ts b/test/unit/api/models.test.ts similarity index 92% rename from test/unit/api/api-helper.test.ts rename to test/unit/api/models.test.ts index e9da05929f..7010ab9520 100644 --- a/test/unit/api/api-helper.test.ts +++ b/test/unit/api/models.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { isOwner } from "@/api/api-helper"; +import { isOwner } from "@/api/models"; import { userWithRoles } from "../../mocks/testHelpers"; diff --git a/test/unit/core/binaryLock.test.ts b/test/unit/cli/binaryLock.test.ts similarity index 96% rename from test/unit/core/binaryLock.test.ts rename to test/unit/cli/binaryLock.test.ts index b0452d5a59..39926f78a9 100644 --- a/test/unit/core/binaryLock.test.ts +++ b/test/unit/cli/binaryLock.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { BinaryLock } from "@/core/binaryLock"; -import * as downloadProgress from "@/core/downloadProgress"; +import { BinaryLock } from "@/cli/binaryLock"; +import * as downloadProgress from "@/cli/downloadProgress"; import { createMockLogger, @@ -16,7 +16,7 @@ vi.mock("proper-lockfile", () => ({ })); // Mock downloadProgress module -vi.mock("@/core/downloadProgress", () => ({ +vi.mock("@/cli/downloadProgress", () => ({ STALE_TIMEOUT_MS: 15000, readProgress: vi.fn(), writeProgress: vi.fn(), diff --git a/test/unit/core/cliUtils.test.ts b/test/unit/cli/cliBinary.test.ts similarity index 83% rename from test/unit/core/cliUtils.test.ts rename to test/unit/cli/cliBinary.test.ts index c0aa1a07c4..755465d93c 100644 --- a/test/unit/core/cliUtils.test.ts +++ b/test/unit/cli/cliBinary.test.ts @@ -3,7 +3,7 @@ import os from "os"; import path from "path"; import { beforeAll, describe, expect, it } from "vitest"; -import * as cliUtils from "@/core/cliUtils"; +import * as cliBinary from "@/cli/cliBinary"; describe("CliUtils", () => { const tmp = path.join(os.tmpdir(), "vscode-coder-tests"); @@ -15,11 +15,11 @@ describe("CliUtils", () => { }); it("fullName", () => { - expect(cliUtils.fullName().startsWith("coder-")).toBeTruthy(); + expect(cliBinary.fullName().startsWith("coder-")).toBeTruthy(); }); it("simpleName", () => { - const simple = cliUtils.simpleName(); + const simple = cliBinary.simpleName(); if (process.platform === "win32") { expect(simple).toBe("coder.exe"); } else { @@ -29,15 +29,15 @@ describe("CliUtils", () => { it("stat", async () => { const binPath = path.join(tmp, "stat"); - expect(await cliUtils.stat(binPath)).toBeUndefined(); + expect(await cliBinary.stat(binPath)).toBeUndefined(); await fs.writeFile(binPath, "test"); - expect((await cliUtils.stat(binPath))?.size).toBe(4); + expect((await cliBinary.stat(binPath))?.size).toBe(4); }); it("rmOld", async () => { const binDir = path.join(tmp, "bins"); - expect(await cliUtils.rmOld(path.join(binDir, "bin1"))).toStrictEqual([]); + expect(await cliBinary.rmOld(path.join(binDir, "bin1"))).toStrictEqual([]); await fs.mkdir(binDir, { recursive: true }); await fs.writeFile(path.join(binDir, "coder.old-1"), "echo hello"); @@ -53,7 +53,7 @@ describe("CliUtils", () => { await fs.writeFile(path.join(binDir, "unrelated.old-1"), "echo hello"); await fs.writeFile(path.join(binDir, "unrelated.temp-1"), "echo hello"); - expect(await cliUtils.rmOld(path.join(binDir, "coder"))).toStrictEqual([ + expect(await cliBinary.rmOld(path.join(binDir, "coder"))).toStrictEqual([ { fileName: "coder.asc", error: undefined, @@ -97,12 +97,12 @@ describe("CliUtils", () => { const binPath = path.join(tmp, "hash"); await fs.writeFile(binPath, "foobar"); - expect(await cliUtils.eTag(binPath)).toBe( + expect(await cliBinary.eTag(binPath)).toBe( "8843d7f92416211de9ebb963ff4ce28125932878", ); await fs.writeFile(binPath, "test"); - expect(await cliUtils.eTag(binPath)).toBe( + expect(await cliBinary.eTag(binPath)).toBe( "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", ); }); diff --git a/test/unit/core/cliCredentialManager.test.ts b/test/unit/cli/cliCredentialManager.test.ts similarity index 98% rename from test/unit/core/cliCredentialManager.test.ts rename to test/unit/cli/cliCredentialManager.test.ts index a6c5934cde..10b6ba4f03 100644 --- a/test/unit/core/cliCredentialManager.test.ts +++ b/test/unit/cli/cliCredentialManager.test.ts @@ -7,9 +7,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CliCredentialManager, type BinaryResolver, -} from "@/core/cliCredentialManager"; -import * as cliExec from "@/core/cliExec"; -import { PathResolver } from "@/core/pathResolver"; +} from "@/cli/cliCredentialManager"; +import * as cliExec from "@/cli/cliExec"; +import { PathResolver } from "@/storage/pathResolver"; import { createTestTelemetryService, TestSink } from "../../mocks/telemetry"; import { @@ -23,9 +23,9 @@ vi.mock("node:child_process", () => ({ execFile: vi.fn() })); vi.mock("node:os"); -vi.mock("@/core/cliExec", async () => { +vi.mock("@/cli/cliExec", async () => { const actual = - await vi.importActual("@/core/cliExec"); + await vi.importActual("@/cli/cliExec"); return { ...actual, version: vi.fn() }; }); diff --git a/test/unit/core/cliExec.test.ts b/test/unit/cli/cliExec.test.ts similarity index 99% rename from test/unit/core/cliExec.test.ts rename to test/unit/cli/cliExec.test.ts index 43e99cb16f..83d9b3a603 100644 --- a/test/unit/core/cliExec.test.ts +++ b/test/unit/cli/cliExec.test.ts @@ -21,7 +21,7 @@ import { writeStdoutJs, } from "../../utils/platform"; -import type { CliEnv } from "@/core/cliExec"; +import type { CliEnv } from "@/cli/cliExec"; // Shim execFile so .js test scripts are run through node cross-platform. // Spawn is replaced with vi.fn() so the ping tests can intercept it without @@ -32,7 +32,7 @@ vi.mock("node:child_process", async (importOriginal) => { }); // Import after mock so the module picks up the shimmed execFile. -const cliExec = await import("@/core/cliExec"); +const cliExec = await import("@/cli/cliExec"); const { spawn } = await import("node:child_process"); const sharedAuth = (url: string): CliEnv["auth"] => ({ diff --git a/test/unit/core/cliManager.concurrent.test.ts b/test/unit/cli/cliManager.concurrent.test.ts similarity index 90% rename from test/unit/core/cliManager.concurrent.test.ts rename to test/unit/cli/cliManager.concurrent.test.ts index 895f11657c..f994943131 100644 --- a/test/unit/core/cliManager.concurrent.test.ts +++ b/test/unit/cli/cliManager.concurrent.test.ts @@ -11,11 +11,11 @@ import * as os from "node:os"; import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import * as cliExec from "@/core/cliExec"; -import { CliManager } from "@/core/cliManager"; -import * as cliUtils from "@/core/cliUtils"; -import { PathResolver } from "@/core/pathResolver"; +import * as cliBinary from "@/cli/cliBinary"; +import * as cliExec from "@/cli/cliExec"; +import { CliManager } from "@/cli/cliManager"; import * as pgp from "@/pgp"; +import { PathResolver } from "@/storage/pathResolver"; import { createTestTelemetryService } from "../../mocks/telemetry"; import { @@ -28,8 +28,8 @@ import { vi.mock("@/pgp"); -vi.mock("@/core/cliUtils", async () => { - const actual = await vi.importActual("@/core/cliUtils"); +vi.mock("@/cli/cliBinary", async () => { + const actual = await vi.importActual("@/cli/cliBinary"); return { ...actual, goos: vi.fn(), @@ -38,8 +38,8 @@ vi.mock("@/core/cliUtils", async () => { }; }); -vi.mock("@/core/cliExec", async () => { - const actual = await vi.importActual("@/core/cliExec"); +vi.mock("@/cli/cliExec", async () => { + const actual = await vi.importActual("@/cli/cliExec"); return { ...actual, version: vi.fn(), @@ -47,9 +47,9 @@ vi.mock("@/core/cliExec", async () => { }); function setupCliUtilsMocks(version: string) { - vi.mocked(cliUtils.goos).mockReturnValue("linux"); - vi.mocked(cliUtils.goarch).mockReturnValue("amd64"); - vi.mocked(cliUtils.fullName).mockReturnValue("coder-linux-amd64"); + vi.mocked(cliBinary.goos).mockReturnValue("linux"); + vi.mocked(cliBinary.goarch).mockReturnValue("amd64"); + vi.mocked(cliBinary.fullName).mockReturnValue("coder-linux-amd64"); vi.mocked(cliExec.version).mockResolvedValue(version); vi.mocked(pgp.readPublicKeys).mockResolvedValue([]); } diff --git a/test/unit/core/cliManager.telemetry.test.ts b/test/unit/cli/cliManager.telemetry.test.ts similarity index 98% rename from test/unit/core/cliManager.telemetry.test.ts rename to test/unit/cli/cliManager.telemetry.test.ts index e9371ef3c8..d07f02b429 100644 --- a/test/unit/core/cliManager.telemetry.test.ts +++ b/test/unit/cli/cliManager.telemetry.test.ts @@ -34,9 +34,9 @@ vi.mock("proper-lockfile", () => ({ vi.mock("@/pgp"); -vi.mock("@/core/cliExec", async () => { +vi.mock("@/cli/cliExec", async () => { const actual = - await vi.importActual("@/core/cliExec"); + await vi.importActual("@/cli/cliExec"); return { ...actual, version: vi.fn() }; }); diff --git a/test/unit/core/cliManager.test.ts b/test/unit/cli/cliManager.test.ts similarity index 99% rename from test/unit/core/cliManager.test.ts rename to test/unit/cli/cliManager.test.ts index 384ae2970e..0dc9183e60 100644 --- a/test/unit/core/cliManager.test.ts +++ b/test/unit/cli/cliManager.test.ts @@ -43,9 +43,9 @@ vi.mock("proper-lockfile", () => ({ vi.mock("@/pgp"); -vi.mock("@/core/cliExec", async () => { +vi.mock("@/cli/cliExec", async () => { const actual = - await vi.importActual("@/core/cliExec"); + await vi.importActual("@/cli/cliExec"); return { ...actual, version: vi.fn() }; }); diff --git a/test/unit/core/cliManagerHarness.ts b/test/unit/cli/cliManagerHarness.ts similarity index 98% rename from test/unit/core/cliManagerHarness.ts rename to test/unit/cli/cliManagerHarness.ts index c79f86f52f..e5e86ce0ee 100644 --- a/test/unit/core/cliManagerHarness.ts +++ b/test/unit/cli/cliManagerHarness.ts @@ -7,10 +7,10 @@ import { type IncomingMessage } from "node:http"; import * as os from "node:os"; import { expect, vi } from "vitest"; -import * as cliExec from "@/core/cliExec"; -import { CliManager } from "@/core/cliManager"; -import { PathResolver } from "@/core/pathResolver"; +import * as cliExec from "@/cli/cliExec"; +import { CliManager } from "@/cli/cliManager"; import * as pgp from "@/pgp"; +import { PathResolver } from "@/storage/pathResolver"; import { createTestTelemetryService, TestSink } from "../../mocks/telemetry"; import { diff --git a/test/unit/core/downloadProgress.test.ts b/test/unit/cli/downloadProgress.test.ts similarity index 98% rename from test/unit/core/downloadProgress.test.ts rename to test/unit/cli/downloadProgress.test.ts index 8b0299b6e2..3dbe2a6d09 100644 --- a/test/unit/core/downloadProgress.test.ts +++ b/test/unit/cli/downloadProgress.test.ts @@ -3,7 +3,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import * as downloadProgress from "@/core/downloadProgress"; +import * as downloadProgress from "@/cli/downloadProgress"; describe("downloadProgress", () => { let testDir: string; diff --git a/test/unit/commands.netcheck.test.ts b/test/unit/commands.netcheck.test.ts index d18e9f988a..040dcc116e 100644 --- a/test/unit/commands.netcheck.test.ts +++ b/test/unit/commands.netcheck.test.ts @@ -2,13 +2,13 @@ import { describe, expect, it, vi } from "vitest"; import * as vscode from "vscode"; import { Commands } from "@/commands"; -import { toSafeHost } from "@/util/uri"; +import { toSafeHost } from "@/common/url"; import { createTestCoderApi } from "../mocks/coderApi"; import { createTelemetryHarness } from "../mocks/telemetry"; import { createMockLogger, MockUserInteraction } from "../mocks/testHelpers"; -import type { ServiceContainer } from "@/core/container"; +import type { ServiceContainer } from "@/container"; import type { DeploymentManager } from "@/deployment/deploymentManager"; import type { NetcheckPanelFactory } from "@/webviews/netcheck/netcheckPanelFactory"; diff --git a/test/unit/commands.openSshConfig.test.ts b/test/unit/commands.openSshConfig.test.ts index a6d8d4ce0e..9131eec3cc 100644 --- a/test/unit/commands.openSshConfig.test.ts +++ b/test/unit/commands.openSshConfig.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import * as vscode from "vscode"; -import { PathResolver } from "@/core/pathResolver"; +import { PathResolver } from "@/storage/pathResolver"; import { createTestCommands } from "../mocks/commands"; import { MockConfigurationProvider, useEditor } from "../mocks/testHelpers"; diff --git a/test/unit/commands.supportBundle.test.ts b/test/unit/commands.supportBundle.test.ts index 313fbce0d6..c1dc387e16 100644 --- a/test/unit/commands.supportBundle.test.ts +++ b/test/unit/commands.supportBundle.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi } from "vitest"; import * as vscode from "vscode"; +import * as cliExec from "@/cli/cliExec"; import { Commands } from "@/commands"; -import * as cliExec from "@/core/cliExec"; import { appendVsCodeLogs } from "@/supportBundle/appendVsCodeLogs"; import { getRemoteServerDataPath } from "@/supportBundle/remoteServerDataPath"; import { @@ -22,11 +22,11 @@ import { } from "../mocks/testHelpers"; import type { CoderApi } from "@/api/coderApi"; -import type { ServiceContainer } from "@/core/container"; +import type { ServiceContainer } from "@/container"; import type { DeploymentManager } from "@/deployment/deploymentManager"; -vi.mock("@/core/cliExec", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("@/cli/cliExec", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, version: vi.fn(), supportBundle: vi.fn() }; }); diff --git a/test/unit/commands.telemetry.test.ts b/test/unit/commands.telemetry.test.ts index ef21501de7..f71b20aac7 100644 --- a/test/unit/commands.telemetry.test.ts +++ b/test/unit/commands.telemetry.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { Commands } from "@/commands"; -import { maybeAskUrl } from "@/promptUtils"; +import { maybeAskUrl } from "@/ui/prompts"; import { createTestCoderApi } from "../mocks/coderApi"; import { createTelemetryHarness } from "../mocks/telemetry"; @@ -11,20 +11,20 @@ import { MockUserInteraction, } from "../mocks/testHelpers"; -import type { CliManager } from "@/core/cliManager"; -import type { ServiceContainer } from "@/core/container"; -import type { MementoManager } from "@/core/mementoManager"; -import type { PathResolver } from "@/core/pathResolver"; -import type { SecretsManager, SessionAuth } from "@/core/secretsManager"; +import type { CliManager } from "@/cli/cliManager"; +import type { ServiceContainer } from "@/container"; import type { DeploymentManager } from "@/deployment/deploymentManager"; import type { Deployment } from "@/deployment/types"; import type { LoginCoordinator, LoginResult } from "@/login/loginCoordinator"; +import type { MementoManager } from "@/storage/mementoManager"; +import type { PathResolver } from "@/storage/pathResolver"; +import type { SecretsManager, SessionAuth } from "@/storage/secretsManager"; import type { NetcheckPanelFactory } from "@/webviews/netcheck/netcheckPanelFactory"; import type { SpeedtestPanelFactory } from "@/webviews/speedtest/speedtestPanelFactory"; import type { DuplicateWorkspaceIpc } from "@/workspace/duplicateWorkspaceIpc"; -vi.mock("@/promptUtils", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("@/ui/prompts", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, maybeAskUrl: vi.fn() }; }); diff --git a/test/unit/command/updateWorkspace.telemetry.test.ts b/test/unit/commands.updateWorkspace.test.ts similarity index 92% rename from test/unit/command/updateWorkspace.telemetry.test.ts rename to test/unit/commands.updateWorkspace.test.ts index d963604cb1..b24c9b5f3d 100644 --- a/test/unit/command/updateWorkspace.telemetry.test.ts +++ b/test/unit/commands.updateWorkspace.test.ts @@ -3,8 +3,8 @@ import * as vscode from "vscode"; import { workspace } from "@repo/mocks"; -import { createTestCommands } from "../../mocks/commands"; -import { createTelemetryHarness } from "../../mocks/telemetry"; +import { createTestCommands } from "../mocks/commands"; +import { createTelemetryHarness } from "../mocks/telemetry"; import type { CoderApi } from "@/api/coderApi"; diff --git a/test/unit/util/fs.test.ts b/test/unit/common/fs.test.ts similarity index 53% rename from test/unit/util/fs.test.ts rename to test/unit/common/fs.test.ts index a67e95cbe1..9a25236419 100644 --- a/test/unit/util/fs.test.ts +++ b/test/unit/common/fs.test.ts @@ -1,8 +1,17 @@ import { vol } from "memfs"; import * as fsPromises from "node:fs/promises"; +import os from "node:os"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { renameWithRetry, tempFilePath, writeAtomically } from "@/util/fs"; +import { + cleanupFiles, + expandPath, + renameWithRetry, + tempFilePath, + writeAtomically, +} from "@/common/fs"; + +import { createMockLogger } from "../../mocks/testHelpers"; vi.mock("node:fs/promises", async () => (await import("memfs")).fs.promises); @@ -201,3 +210,178 @@ describe("writeAtomically", () => { ).rejects.toThrow(/write boom/); }); }); + +describe("cleanupFiles", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vol.reset(); + }); + + afterEach(() => { + vol.reset(); + }); + + function setup(files: Record = {}) { + vol.fromJSON(files); + return { logger: createMockLogger() }; + } + + it("does not throw when the directory is missing", async () => { + const { logger } = setup(); + await expect( + cleanupFiles("/nope", logger, { label: "thing", select: () => [] }), + ).resolves.toBeUndefined(); + }); + + it("unlinks the files chosen by select and leaves the rest", async () => { + const { logger } = setup({ "/d/a": "1", "/d/b": "2", "/d/c": "3" }); + + await cleanupFiles("/d", logger, { + label: "thing", + select: (files) => files.filter((f) => f.name !== "b"), + }); + + expect(vol.readdirSync("/d")).toEqual(["b"]); + }); + + it("exposes mtime, size, and the current time so select can filter on them", async () => { + const { logger } = setup({ + "/d/old-big": "x".repeat(100), + "/d/new-small": "x", + }); + // 1970-01-01: definitely older than `now`. + vol.utimesSync("/d/old-big", 1, 1); + + await cleanupFiles("/d", logger, { + label: "thing", + select: (files, now) => + files.filter((f) => now - f.mtime > 1000 && f.size > 50), + }); + + expect(vol.readdirSync("/d")).toEqual(["new-small"]); + }); + + it("only feeds select the files matched by `filter`", async () => { + const { logger } = setup({ + "/d/keep.json": "{}", + "/d/skip.txt": "no", + "/d/keep-too.json": "{}", + }); + + await cleanupFiles("/d", logger, { + label: "thing", + filter: (n) => n.endsWith(".json"), + select: (files) => files, + }); + + expect(vol.readdirSync("/d")).toEqual(["skip.txt"]); + }); + + it("keeps going when a file disappears between stat and unlink", async () => { + const { logger } = setup({ "/d/a": "1", "/d/b": "2" }); + + await cleanupFiles("/d", logger, { + label: "thing", + select: (files) => { + vol.unlinkSync("/d/a"); + return files; + }, + }); + + expect(vol.readdirSync("/d")).toEqual([]); + }); + + it("does not throw when readdir fails for reasons other than ENOENT", async () => { + const { logger } = setup(); + const err = Object.assign(new Error("denied"), { code: "EACCES" }); + vi.spyOn(fsPromises, "readdir").mockRejectedValueOnce(err); + + const select = vi.fn(() => []); + await expect( + cleanupFiles("/d", logger, { label: "thing", select }), + ).resolves.toBeUndefined(); + expect(select).not.toHaveBeenCalled(); + }); + + it("clamps select names to their basename so unlinks cannot escape the directory", async () => { + const { logger } = setup({ + "/d/inside.txt": "x", + "/outside.txt": "y", + }); + + await cleanupFiles("/d", logger, { + label: "thing", + select: () => [{ name: "../outside.txt" }], + }); + + expect(vol.existsSync("/outside.txt")).toBe(true); + }); +}); + +describe("expandPath", () => { + const home = os.homedir(); + + it("expands tilde at start of path", () => { + expect(expandPath("~/foo/bar")).toBe(`${home}/foo/bar`); + }); + + it("expands standalone tilde", () => { + expect(expandPath("~")).toBe(home); + }); + + it("does not expand tilde in middle of path", () => { + expect(expandPath("/foo/~/bar")).toBe("/foo/~/bar"); + }); + + it("expands ${userHome} variable", () => { + expect(expandPath("${userHome}/foo")).toBe(`${home}/foo`); + }); + + it("expands multiple ${userHome} variables", () => { + expect(expandPath("${userHome}/foo/${userHome}/bar")).toBe( + `${home}/foo/${home}/bar`, + ); + }); + + it("leaves paths without tilde or variable unchanged", () => { + expect(expandPath("/absolute/path")).toBe("/absolute/path"); + expect(expandPath("relative/path")).toBe("relative/path"); + }); + + it("expands both tilde and ${userHome}", () => { + expect(expandPath("~/${userHome}/foo")).toBe(`${home}/${home}/foo`); + }); + + describe("${env:VAR}", () => { + const envKey = "CODER_EXPAND_PATH_TEST"; + const ref = "${env:" + envKey + "}"; + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("substitutes a present env var", () => { + vi.stubEnv(envKey, "/data"); + expect(expandPath(`${ref}/foo`)).toBe("/data/foo"); + }); + + it("replaces a missing env var with an empty string", () => { + vi.stubEnv(envKey, undefined); + expect(expandPath(`prefix-${ref}-suffix`)).toBe("prefix--suffix"); + }); + + it("substitutes multiple occurrences in one string", () => { + vi.stubEnv(envKey, "data"); + expect(expandPath(`${ref}/${ref}`)).toBe("data/data"); + }); + + it("expands tilde or ${userHome} that appears inside the env value", () => { + vi.stubEnv(envKey, "~/projects"); + expect(expandPath(`${ref}/x`)).toBe(`${home}/projects/x`); + }); + + it("ignores ${env:...} with invalid names", () => { + expect(expandPath("${env:1BAD}/x")).toBe("${env:1BAD}/x"); + }); + }); +}); diff --git a/test/unit/common/strings.test.ts b/test/unit/common/strings.test.ts new file mode 100644 index 0000000000..de6d925b91 --- /dev/null +++ b/test/unit/common/strings.test.ts @@ -0,0 +1,104 @@ +import os from "node:os"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { escapeCommandArg, escapeShellArg } from "@/common/strings"; + +describe("escapeCommandArg", () => { + it("returns simple strings unquoted", () => { + expect(escapeCommandArg("hello")).toBe("hello"); + }); + + it("returns flag-style strings unquoted", () => { + expect(escapeCommandArg("--verbose")).toBe("--verbose"); + expect(escapeCommandArg("--cfg=/etc/coder")).toBe("--cfg=/etc/coder"); + }); + + it("quotes the empty string so it survives as a token", () => { + expect(escapeCommandArg("")).toBe('""'); + }); + + it("escapes double quotes and wraps in quotes", () => { + expect(escapeCommandArg('say "hello"')).toBe(String.raw`"say \"hello\""`); + }); + + it("quotes backslashes (POSIX escape char)", () => { + expect(escapeCommandArg(String.raw`path\to\file`)).toBe( + String.raw`"path\to\file"`, + ); + }); + + it("quotes strings containing spaces", () => { + expect(escapeCommandArg("hello world")).toBe('"hello world"'); + }); + + it.each([["tab\there"], ["line\nbreak"], ["vtab\vhere"]])( + "quotes strings containing other whitespace: %j", + (input) => { + expect(escapeCommandArg(input)).toBe(`"${input}"`); + }, + ); + + it.each([ + ["foo&bar"], + ["foo;bar"], + ["foo|bar"], + ["foo(bar)"], + ["foobar"], + ["foo*bar"], + ["foo?bar"], + ["foo$bar"], + ["foo`bar"], + ["foo~bar"], + ["foo!bar"], + ["foo#bar"], + ["https://x.com?a=1&b=2"], + ])("quotes strings containing shell metacharacter: %j", (input) => { + expect(escapeCommandArg(input)).toBe(`"${input}"`); + }); +}); + +describe("escapeShellArg", () => { + const platformSpy = vi.spyOn(os, "platform"); + afterEach(() => platformSpy.mockReset()); + + describe("on Unix", () => { + beforeEach(() => platformSpy.mockReturnValue("linux")); + + it("wraps in single quotes", () => { + expect(escapeShellArg("env=dev")).toBe("'env=dev'"); + }); + + it("escapes single quotes via the '\\'' sequence", () => { + expect(escapeShellArg("it's fine")).toBe("'it'\\''s fine'"); + }); + + it("leaves $VAR, $(...), and backticks literal inside the quotes", () => { + expect(escapeShellArg("$(echo pwned)")).toBe("'$(echo pwned)'"); + }); + }); + + describe("on Windows", () => { + beforeEach(() => platformSpy.mockReturnValue("win32")); + + it("wraps in double quotes", () => { + expect(escapeShellArg("env=dev")).toBe('"env=dev"'); + }); + + it('doubles embedded `"`', () => { + expect(escapeShellArg('regions=["us","eu"]')).toBe( + '"regions=[""us"",""eu""]"', + ); + }); + + it("doubles `%` to block %VAR% expansion", () => { + expect(escapeShellArg("%PATH%")).toBe('"%%PATH%%"'); + }); + + it("keeps cmd metachars inside the quoted region", () => { + expect(escapeShellArg('foo" & calc.exe & "x')).toBe( + '"foo"" & calc.exe & ""x"', + ); + }); + }); +}); diff --git a/test/unit/util/uri.test.ts b/test/unit/common/url.test.ts similarity index 99% rename from test/unit/util/uri.test.ts rename to test/unit/common/url.test.ts index 6c326b4874..8ed405effe 100644 --- a/test/unit/util/uri.test.ts +++ b/test/unit/common/url.test.ts @@ -7,7 +7,7 @@ import { removeTrailingSlashes, resolveCoderDashboardUrl, toSafeHost, -} from "@/util/uri"; +} from "@/common/url"; import { MockConfigurationProvider } from "../../mocks/testHelpers"; diff --git a/test/unit/deployment/deploymentManager.test.ts b/test/unit/deployment/deploymentManager.test.ts index 59c03876d9..ad3f9c6ea2 100644 --- a/test/unit/deployment/deploymentManager.test.ts +++ b/test/unit/deployment/deploymentManager.test.ts @@ -2,9 +2,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { CoderApi } from "@/api/coderApi"; import { CONFIG_CHANGE_DEBOUNCE_MS } from "@/configWatcher"; -import { MementoManager } from "@/core/mementoManager"; -import { SecretsManager } from "@/core/secretsManager"; import { DeploymentManager } from "@/deployment/deploymentManager"; +import { MementoManager } from "@/storage/mementoManager"; +import { SecretsManager } from "@/storage/secretsManager"; import { createTestTelemetryService, TestSink } from "../../mocks/telemetry"; import { diff --git a/test/unit/error/abort.test.ts b/test/unit/error/abort.test.ts new file mode 100644 index 0000000000..35b3a79fe2 --- /dev/null +++ b/test/unit/error/abort.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import { isAbortError, raceWithAbort } from "@/error/abort"; + +describe("isAbortError", () => { + it("returns true for an Error named AbortError", () => { + const err = new Error("aborted"); + err.name = "AbortError"; + expect(isAbortError(err)).toBe(true); + }); + + it("returns true for DOMException-style abort thrown by AbortController", () => { + const ac = new AbortController(); + ac.abort(); + // `signal.reason` is a DOMException with name "AbortError" in modern Node. + const reason = ac.signal.reason; + expect(isAbortError(reason)).toBe(true); + }); + + it("returns false for a plain Error", () => { + expect(isAbortError(new Error("nope"))).toBe(false); + }); + + it.each<[string, unknown]>([ + ["null", null], + ["undefined", undefined], + ["string", "AbortError"], + ["object with name only", { name: "AbortError" }], + ])("returns false for %s", (_name, input) => { + expect(isAbortError(input)).toBe(false); + }); + + it("narrows the type to Error", () => { + const err: unknown = Object.assign(new Error("aborted"), { + name: "AbortError", + }); + if (isAbortError(err)) { + // Type-only assertion: this line must compile without a cast. + expect(err.message).toBe("aborted"); + } else { + throw new Error("expected isAbortError to narrow"); + } + }); +}); + +describe("raceWithAbort", () => { + it("resolves with the promise result when not aborted", async () => { + const ac = new AbortController(); + await expect( + raceWithAbort(Promise.resolve("done"), ac.signal), + ).resolves.toBe("done"); + }); + + it("rejects with an AbortError when the signal aborts first", async () => { + const ac = new AbortController(); + const hanging = new Promise(() => {}); + const raced = raceWithAbort(hanging, ac.signal); + ac.abort(); + await expect(raced).rejects.toSatisfy(isAbortError); + }); + + it("rejects immediately when the signal is already aborted", async () => { + const ac = new AbortController(); + ac.abort(); + const hanging = new Promise(() => {}); + await expect(raceWithAbort(hanging, ac.signal)).rejects.toSatisfy( + isAbortError, + ); + }); +}); diff --git a/test/unit/error/errorUtils.test.ts b/test/unit/error/normalize.test.ts similarity index 62% rename from test/unit/error/errorUtils.test.ts rename to test/unit/error/normalize.test.ts index b6691f06fa..82377dc717 100644 --- a/test/unit/error/errorUtils.test.ts +++ b/test/unit/error/normalize.test.ts @@ -1,78 +1,6 @@ -import { describe, it, expect } from "vitest"; - -import { - getErrorDetail, - isAbortError, - raceWithAbort, - toError, -} from "@/error/errorUtils"; - -describe("isAbortError", () => { - it("returns true for an Error named AbortError", () => { - const err = new Error("aborted"); - err.name = "AbortError"; - expect(isAbortError(err)).toBe(true); - }); - - it("returns true for DOMException-style abort thrown by AbortController", () => { - const ac = new AbortController(); - ac.abort(); - // `signal.reason` is a DOMException with name "AbortError" in modern Node. - const reason = ac.signal.reason; - expect(isAbortError(reason)).toBe(true); - }); - - it("returns false for a plain Error", () => { - expect(isAbortError(new Error("nope"))).toBe(false); - }); - - it.each<[string, unknown]>([ - ["null", null], - ["undefined", undefined], - ["string", "AbortError"], - ["object with name only", { name: "AbortError" }], - ])("returns false for %s", (_name, input) => { - expect(isAbortError(input)).toBe(false); - }); +import { describe, expect, it } from "vitest"; - it("narrows the type to Error", () => { - const err: unknown = Object.assign(new Error("aborted"), { - name: "AbortError", - }); - if (isAbortError(err)) { - // Type-only assertion: this line must compile without a cast. - expect(err.message).toBe("aborted"); - } else { - throw new Error("expected isAbortError to narrow"); - } - }); -}); - -describe("raceWithAbort", () => { - it("resolves with the promise result when not aborted", async () => { - const ac = new AbortController(); - await expect( - raceWithAbort(Promise.resolve("done"), ac.signal), - ).resolves.toBe("done"); - }); - - it("rejects with an AbortError when the signal aborts first", async () => { - const ac = new AbortController(); - const hanging = new Promise(() => {}); - const raced = raceWithAbort(hanging, ac.signal); - ac.abort(); - await expect(raced).rejects.toSatisfy(isAbortError); - }); - - it("rejects immediately when the signal is already aborted", async () => { - const ac = new AbortController(); - ac.abort(); - const hanging = new Promise(() => {}); - await expect(raceWithAbort(hanging, ac.signal)).rejects.toSatisfy( - isAbortError, - ); - }); -}); +import { getErrorDetail, toError } from "@/error/normalize"; describe("getErrorDetail", () => { it("returns detail from API error", () => { diff --git a/test/unit/logging/formatters.test.ts b/test/unit/logging/formatters.test.ts index ab0d581b22..ded822f45e 100644 --- a/test/unit/logging/formatters.test.ts +++ b/test/unit/logging/formatters.test.ts @@ -7,6 +7,8 @@ import { formatSize, formatTime, formatUri, + safeStringify, + sizeOf, } from "@/logging/formatters"; describe("Logging formatters", () => { @@ -201,3 +203,92 @@ describe("Logging formatters", () => { }); }); }); + +describe("sizeOf", () => { + type SizeOfTestCase = [data: unknown, bytes: number | undefined]; + it.each([ + // Primitives return a fixed value + [null, 0], + [undefined, 0], + [42, 8], + [3.14, 8], + [false, 4], + // Strings + ["hello", 5], + ["✓", 3], + ["unicode: ✓", 12], + // Buffers + [Buffer.from("test"), 4], + [BigInt(12345), 5], + [BigInt(0), 1], + [Buffer.alloc(100), 100], + [Buffer.from([]), 0], + // Typed-arrays + [new ArrayBuffer(50), 50], + [new Uint8Array([1, 2, 3, 4]), 4], + [new Int32Array([1, 2, 3]), 12], + [new Float64Array([1.0, 2.0]), 16], + // Objects/untyped-arrays return undefined + [{ size: 1024 }, 1024], + [{ size: 0 }, 0], + [{ size: "not a number" }, undefined], + [[], undefined], + [[1, 2, 3], undefined], + [["a", "b", "c"], undefined], + [{}, undefined], + [{ foo: "bar" }, undefined], + [{ nested: { value: 123 } }, undefined], + ])("returns size for %s", (data: unknown, bytes: number | undefined) => { + expect(sizeOf(data)).toBe(bytes); + }); + + it("handles circular references safely", () => { + const circular: Record = { a: 1 }; + circular.self = circular; + expect(sizeOf(circular)).toBeUndefined(); + + const arr: unknown[] = [1, 2, 3]; + arr.push(arr); + expect(sizeOf(arr)).toBeUndefined(); + }); +}); + +describe("safeStringify", () => { + it("formats various data types", () => { + expect(safeStringify({ key: "value" })).toContain("key: 'value'"); + expect(safeStringify("plain text")).toContain("plain text"); + expect(safeStringify([1, 2, 3])).toContain("1"); + expect(safeStringify(123)).toContain("123"); + expect(safeStringify(true)).toContain("true"); + }); + + it("handles circular references safely", () => { + const circular: Record = { a: 1 }; + circular.self = circular; + const result = safeStringify(circular); + expect(result).toBeTruthy(); + expect(result).toContain("a: 1"); + }); + + it("handles deep nesting", () => { + const deep = { + level1: { level2: { level3: { level4: { value: "deep" } } } }, + }; + const result = safeStringify(deep); + expect(result).toContain("level4: { value: 'deep' }"); + }); + + it("bounds output size for large inputs", () => { + const longString = safeStringify("a".repeat(50_000)); + expect(longString?.length).toBeLessThan(20_000); + + const bigArray = safeStringify(Array.from({ length: 10_000 }, (_, i) => i)); + expect(bigArray).toContain("more items"); + + let nested: Record = { value: "bottom" }; + for (let i = 0; i < 30; i++) { + nested = { child: nested }; + } + expect(safeStringify(nested)).not.toContain("bottom"); + }); +}); diff --git a/test/unit/logging/ids.test.ts b/test/unit/logging/ids.test.ts new file mode 100644 index 0000000000..32149f4f92 --- /dev/null +++ b/test/unit/logging/ids.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; + +import { createRequestId, shortId } from "@/logging/ids"; + +describe("shortId", () => { + it("truncates long strings to 8 characters", () => { + expect(shortId("abcdefghijklmnop")).toBe("abcdefgh"); + expect(shortId("12345678")).toBe("12345678"); + expect(shortId("123456789")).toBe("12345678"); + }); + + it("returns short strings unchanged", () => { + expect(shortId("short")).toBe("short"); + expect(shortId("")).toBe(""); + expect(shortId("1234567")).toBe("1234567"); + }); +}); + +describe("createRequestId", () => { + it("generates valid UUID format without dashes", () => { + const id = createRequestId(); + expect(id).toHaveLength(32); + expect(id).not.toContain("-"); + }); +}); diff --git a/test/unit/logging/utils.test.ts b/test/unit/logging/utils.test.ts deleted file mode 100644 index 2a3b8ca263..0000000000 --- a/test/unit/logging/utils.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - createRequestId, - safeStringify, - shortId, - sizeOf, -} from "@/logging/utils"; - -describe("Logging utils", () => { - describe("shortId", () => { - it("truncates long strings to 8 characters", () => { - expect(shortId("abcdefghijklmnop")).toBe("abcdefgh"); - expect(shortId("12345678")).toBe("12345678"); - expect(shortId("123456789")).toBe("12345678"); - }); - - it("returns short strings unchanged", () => { - expect(shortId("short")).toBe("short"); - expect(shortId("")).toBe(""); - expect(shortId("1234567")).toBe("1234567"); - }); - }); - - describe("sizeOf", () => { - type SizeOfTestCase = [data: unknown, bytes: number | undefined]; - it.each([ - // Primitives return a fixed value - [null, 0], - [undefined, 0], - [42, 8], - [3.14, 8], - [false, 4], - // Strings - ["hello", 5], - ["✓", 3], - ["unicode: ✓", 12], - // Buffers - [Buffer.from("test"), 4], - [BigInt(12345), 5], - [BigInt(0), 1], - [Buffer.alloc(100), 100], - [Buffer.from([]), 0], - // Typed-arrays - [new ArrayBuffer(50), 50], - [new Uint8Array([1, 2, 3, 4]), 4], - [new Int32Array([1, 2, 3]), 12], - [new Float64Array([1.0, 2.0]), 16], - // Objects/untyped-arrays return undefined - [{ size: 1024 }, 1024], - [{ size: 0 }, 0], - [{ size: "not a number" }, undefined], - [[], undefined], - [[1, 2, 3], undefined], - [["a", "b", "c"], undefined], - [{}, undefined], - [{ foo: "bar" }, undefined], - [{ nested: { value: 123 } }, undefined], - ])("returns size for %s", (data: unknown, bytes: number | undefined) => { - expect(sizeOf(data)).toBe(bytes); - }); - - it("handles circular references safely", () => { - const circular: Record = { a: 1 }; - circular.self = circular; - expect(sizeOf(circular)).toBeUndefined(); - - const arr: unknown[] = [1, 2, 3]; - arr.push(arr); - expect(sizeOf(arr)).toBeUndefined(); - }); - }); - - describe("safeStringify", () => { - it("formats various data types", () => { - expect(safeStringify({ key: "value" })).toContain("key: 'value'"); - expect(safeStringify("plain text")).toContain("plain text"); - expect(safeStringify([1, 2, 3])).toContain("1"); - expect(safeStringify(123)).toContain("123"); - expect(safeStringify(true)).toContain("true"); - }); - - it("handles circular references safely", () => { - const circular: Record = { a: 1 }; - circular.self = circular; - const result = safeStringify(circular); - expect(result).toBeTruthy(); - expect(result).toContain("a: 1"); - }); - - it("handles deep nesting", () => { - const deep = { - level1: { level2: { level3: { level4: { value: "deep" } } } }, - }; - const result = safeStringify(deep); - expect(result).toContain("level4: { value: 'deep' }"); - }); - - it("bounds output size for large inputs", () => { - const longString = safeStringify("a".repeat(50_000)); - expect(longString?.length).toBeLessThan(20_000); - - const bigArray = safeStringify( - Array.from({ length: 10_000 }, (_, i) => i), - ); - expect(bigArray).toContain("more items"); - - let nested: Record = { value: "bottom" }; - for (let i = 0; i < 30; i++) { - nested = { child: nested }; - } - expect(safeStringify(nested)).not.toContain("bottom"); - }); - }); - - describe("createRequestId", () => { - it("generates valid UUID format without dashes", () => { - const id = createRequestId(); - expect(id).toHaveLength(32); - expect(id).not.toContain("-"); - }); - }); -}); diff --git a/test/unit/util/credentials.test.ts b/test/unit/login/credentials.test.ts similarity index 96% rename from test/unit/util/credentials.test.ts rename to test/unit/login/credentials.test.ts index 432721cbd7..703b88b1e6 100644 --- a/test/unit/util/credentials.test.ts +++ b/test/unit/login/credentials.test.ts @@ -2,7 +2,7 @@ import * as os from "node:os"; import { beforeEach, describe, expect, it, vi } from "vitest"; import * as vscode from "vscode"; -import { showStoreCredentialsError } from "@/util/credentials"; +import { showStoreCredentialsError } from "@/login/credentials"; import { createMockLogger } from "../../mocks/testHelpers"; diff --git a/test/unit/login/loginCoordinator.test.ts b/test/unit/login/loginCoordinator.test.ts index d37750f0ee..ab413003ce 100644 --- a/test/unit/login/loginCoordinator.test.ts +++ b/test/unit/login/loginCoordinator.test.ts @@ -3,13 +3,13 @@ import { describe, expect, it, vi, type Mock } from "vitest"; import * as vscode from "vscode"; import { HttpStatusCode } from "@/api/httpStatusCode"; -import { MementoManager } from "@/core/mementoManager"; -import { SecretsManager } from "@/core/secretsManager"; import { getHeaders } from "@/headers"; import { AuthTelemetry } from "@/instrumentation/auth"; import { LoginCoordinator, type LoginMethod } from "@/login/loginCoordinator"; import { OAuthCallback } from "@/oauth/oauthCallback"; -import { maybeAskAuthMethod, maybeAskUrl } from "@/promptUtils"; +import { MementoManager } from "@/storage/mementoManager"; +import { SecretsManager } from "@/storage/secretsManager"; +import { maybeAskAuthMethod, maybeAskUrl } from "@/ui/prompts"; import { createTestTelemetryService, TestSink } from "../../mocks/telemetry"; import { @@ -60,9 +60,9 @@ vi.mock("@/headers", () => ({ getHeaderCommand: vi.fn(), })); -vi.mock("@/api/utils", async () => { +vi.mock("@/api/httpAgent", async () => { const actual = - await vi.importActual("@/api/utils"); + await vi.importActual("@/api/httpAgent"); return { ...actual, createHttpAgent: vi.fn() }; }); @@ -70,7 +70,7 @@ vi.mock("@/api/streamingFetchAdapter", () => ({ createStreamingFetchAdapter: vi.fn(() => fetch), })); -vi.mock("@/promptUtils", () => ({ +vi.mock("@/ui/prompts", () => ({ maybeAskAuthMethod: vi.fn().mockResolvedValue("legacy"), maybeAskUrl: vi.fn(), })); diff --git a/test/unit/oauth/authorizer.test.ts b/test/unit/oauth/authorizer.test.ts index 34549bcb61..2780ed6a68 100644 --- a/test/unit/oauth/authorizer.test.ts +++ b/test/unit/oauth/authorizer.test.ts @@ -50,9 +50,9 @@ vi.mock("@/headers", () => ({ getHeaderCommand: vi.fn(), })); -vi.mock("@/api/utils", async () => { +vi.mock("@/api/httpAgent", async () => { const actual = - await vi.importActual("@/api/utils"); + await vi.importActual("@/api/httpAgent"); return { ...actual, createHttpAgent: vi.fn() }; }); diff --git a/test/unit/oauth/metadataClient.test.ts b/test/unit/oauth/metadataClient.test.ts index 79390076d5..866abd4887 100644 --- a/test/unit/oauth/metadataClient.test.ts +++ b/test/unit/oauth/metadataClient.test.ts @@ -36,9 +36,9 @@ vi.mock("@/headers", () => ({ getHeaderCommand: vi.fn(), })); -vi.mock("@/api/utils", async () => { +vi.mock("@/api/httpAgent", async () => { const actual = - await vi.importActual("@/api/utils"); + await vi.importActual("@/api/httpAgent"); return { ...actual, createHttpAgent: vi.fn() }; }); diff --git a/test/unit/oauth/sessionManager.test.ts b/test/unit/oauth/sessionManager.test.ts index 34608e2fc1..71d38f1d50 100644 --- a/test/unit/oauth/sessionManager.test.ts +++ b/test/unit/oauth/sessionManager.test.ts @@ -6,9 +6,9 @@ import { import { describe, expect, it, vi } from "vitest"; import { InvalidApiResponseError } from "@/api/responseValidation"; -import { type SessionAuth } from "@/core/secretsManager"; import { DEFAULT_OAUTH_SCOPES } from "@/oauth/constants"; import { OAuthSessionManager } from "@/oauth/sessionManager"; +import { type SessionAuth } from "@/storage/secretsManager"; import { createTestTelemetryService, @@ -55,9 +55,9 @@ vi.mock("@/headers", () => ({ getHeaderCommand: vi.fn(), })); -vi.mock("@/api/utils", async () => { +vi.mock("@/api/httpAgent", async () => { const actual = - await vi.importActual("@/api/utils"); + await vi.importActual("@/api/httpAgent"); return { ...actual, createHttpAgent: vi.fn() }; }); diff --git a/test/unit/oauth/testUtils.ts b/test/unit/oauth/testUtils.ts index f12954b82b..ac37625186 100644 --- a/test/unit/oauth/testUtils.ts +++ b/test/unit/oauth/testUtils.ts @@ -1,10 +1,10 @@ import { AxiosError, AxiosHeaders } from "axios"; import { vi } from "vitest"; -import { MementoManager } from "@/core/mementoManager"; -import { SecretsManager } from "@/core/secretsManager"; import { getHeaders } from "@/headers"; import { OAuthCallback } from "@/oauth/oauthCallback"; +import { MementoManager } from "@/storage/mementoManager"; +import { SecretsManager } from "@/storage/secretsManager"; import { createMockLogger, diff --git a/test/unit/oauth/utils.test.ts b/test/unit/oauth/tokens.test.ts similarity index 98% rename from test/unit/oauth/utils.test.ts rename to test/unit/oauth/tokens.test.ts index 1bebaf8e95..34092685a3 100644 --- a/test/unit/oauth/utils.test.ts +++ b/test/unit/oauth/tokens.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { buildOAuthTokenData } from "@/oauth/utils"; +import { buildOAuthTokenData } from "@/oauth/tokens"; import type { OAuth2TokenResponse } from "coder/site/src/api/typesGenerated"; diff --git a/test/unit/util/authority.test.ts b/test/unit/remote/authority.test.ts similarity index 99% rename from test/unit/util/authority.test.ts rename to test/unit/remote/authority.test.ts index 555b3e3048..c061148986 100644 --- a/test/unit/util/authority.test.ts +++ b/test/unit/remote/authority.test.ts @@ -9,7 +9,7 @@ import { parseRemoteAuthority, toLegacyAuthority, toRemoteAuthority, -} from "@/util/authority"; +} from "@/remote/authority"; import { useEditor } from "../../mocks/testHelpers"; diff --git a/test/unit/remote/environment.test.ts b/test/unit/remote/environment.test.ts index c616e92f62..f9560cdeb9 100644 --- a/test/unit/remote/environment.test.ts +++ b/test/unit/remote/environment.test.ts @@ -1,11 +1,11 @@ import { spawnSync } from "node:child_process"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { sessionId } from "@/core/sessionId"; import { applySshEnvironment, getSshProxyEnvironment, } from "@/remote/environment"; +import { sessionId } from "@/telemetry/ids"; import { config, diff --git a/test/unit/remote/migration.test.ts b/test/unit/remote/migration.test.ts index f663e77b02..a1f52e286b 100644 --- a/test/unit/remote/migration.test.ts +++ b/test/unit/remote/migration.test.ts @@ -1,14 +1,14 @@ import { vol } from "memfs"; import { describe, expect, it, vi } from "vitest"; -import { PathResolver } from "@/core/pathResolver"; import { migrateAuthToSecretsStorage } from "@/remote/migration"; +import { PathResolver } from "@/storage/pathResolver"; import { createMockLogger } from "../../mocks/testHelpers"; import type * as nodeFs from "node:fs"; -import type { SessionAuth } from "@/core/secretsManager"; +import type { SessionAuth } from "@/storage/secretsManager"; vi.mock("fs/promises", async () => { const memfs: { fs: typeof nodeFs } = await vi.importActual("memfs"); diff --git a/test/unit/remote/remote.test.ts b/test/unit/remote/remote.test.ts index fe4d7cf6a5..eb8c6a38ec 100644 --- a/test/unit/remote/remote.test.ts +++ b/test/unit/remote/remote.test.ts @@ -4,18 +4,18 @@ import * as semver from "semver"; import { beforeEach, describe, expect, it, vi } from "vitest"; import * as vscode from "vscode"; -import { version as cliVersion } from "@/core/cliExec"; -import { MementoManager } from "@/core/mementoManager"; -import { PathResolver } from "@/core/pathResolver"; -import { SecretsManager } from "@/core/secretsManager"; +import { version as cliVersion } from "@/cli/cliExec"; import { cliFeatureSet } from "@/featureSet"; +import { parseRemoteAuthority } from "@/remote/authority"; import { buildSshProxyCommand, Remote, workspaceLabelSuffix, } from "@/remote/remote"; import { sshSupportsSetEnv } from "@/remote/sshSupport"; -import { parseRemoteAuthority } from "@/util/authority"; +import { MementoManager } from "@/storage/mementoManager"; +import { PathResolver } from "@/storage/pathResolver"; +import { SecretsManager } from "@/storage/secretsManager"; import { createTestTelemetryService } from "../../mocks/telemetry"; import { @@ -29,8 +29,8 @@ import { useEditor, } from "../../mocks/testHelpers"; +import type { CliManager } from "@/cli/cliManager"; import type { Commands } from "@/commands"; -import type { CliManager } from "@/core/cliManager"; import type { Logger } from "@/logging/logger"; import type { CliAuth } from "@/settings/cli"; @@ -43,7 +43,7 @@ vi.mock("node:fs/promises", async () => (await import("memfs")).fs.promises); vi.mock("@/remote/windowsAcl", () => ({ createManagedPermissions: () => undefined, })); -vi.mock(import("@/core/cliExec"), async (importOriginal) => ({ +vi.mock(import("@/cli/cliExec"), async (importOriginal) => ({ ...(await importOriginal()), version: vi.fn(), })); diff --git a/test/unit/remote/sshConfig.test.ts b/test/unit/remote/sshConfig.test.ts index c8c2dd6673..80e9348d21 100644 --- a/test/unit/remote/sshConfig.test.ts +++ b/test/unit/remote/sshConfig.test.ts @@ -5,6 +5,7 @@ import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + countSubstring, mergeSshConfigValues, parseCoderSshOptions, parseSshConfig, @@ -820,3 +821,35 @@ describe("validateDeploymentSshOptions", () => { ).toThrow('"LocalCommand"'); }); }); + +describe("countSubstring", () => { + it("handles empty strings", () => { + expect(countSubstring("", "")).toBe(0); + expect(countSubstring("foo", "")).toBe(0); + expect(countSubstring("", "foo")).toBe(0); + }); + + it("handles single character", () => { + expect(countSubstring("a", "a")).toBe(1); + expect(countSubstring("a", "b")).toBe(0); + expect(countSubstring("a", "aa")).toBe(2); + expect(countSubstring("a", "aaa")).toBe(3); + expect(countSubstring("a", "baaa")).toBe(3); + }); + + it("handles multiple characters", () => { + expect(countSubstring("foo", "foo")).toBe(1); + expect(countSubstring("foo", "bar")).toBe(0); + expect(countSubstring("foo", "foobar")).toBe(1); + expect(countSubstring("foo", "foobarbaz")).toBe(1); + expect(countSubstring("foo", "foobarbazfoo")).toBe(2); + expect(countSubstring("foo", "foobarbazfoof")).toBe(2); + }); + + it("does not handle overlapping substrings", () => { + expect(countSubstring("aa", "aaa")).toBe(1); + expect(countSubstring("aa", "aaaa")).toBe(2); + expect(countSubstring("aa", "aaaaa")).toBe(2); + expect(countSubstring("aa", "aaaaaa")).toBe(3); + }); +}); diff --git a/test/unit/remote/sshProcess.test.ts b/test/unit/remote/sshProcess.test.ts index e53f1b7615..490adf6972 100644 --- a/test/unit/remote/sshProcess.test.ts +++ b/test/unit/remote/sshProcess.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ThemeColor } from "vscode"; import { + findPort, SshProcessMonitor, type NetworkInfo, type SshProcessMonitorOptions, @@ -1077,3 +1078,46 @@ function waitForEvent( }); }); } + +describe("findPort", () => { + it.each([[""], ["some random log text without ports"]])( + "returns null for <%s>", + (input) => { + expect(findPort(input)).toBe(null); + }, + ); + + it.each([ + [ + "ms-vscode-remote.remote-ssh", + "[10:30:45] SSH established -> socksPort 12345 -> ready", + 12345, + ], + [ + "ms-vscode-remote.remote-ssh[2]", + "Forwarding between local port 54321 and remote", + 54321, + ], + [ + "devin/open-remote-ssh/antigravity", + "[INFO] Connection => 9999(socks) => target", + 9999, + ], + [ + "anysphere.remote-ssh", + "[DEBUG] Initialized Socks port: 8888 proxy", + 8888, + ], + ])("finds port from %s log format", (_name, input, expected) => { + expect(findPort(input)).toBe(expected); + }); + + it("returns most recent port when multiple matches exist", () => { + const log = ` +[10:30:00] Starting connection -> socksPort 1111 -> initialized +[10:30:05] Reconnecting => 2222(socks) => retry +[10:30:10] Final connection Socks port: 3333 established + `; + expect(findPort(log)).toBe(3333); + }); +}); diff --git a/test/unit/remote/workspaceStateMachine.test.ts b/test/unit/remote/workspaceStateMachine.test.ts index f6249c8022..2bbfec3218 100644 --- a/test/unit/remote/workspaceStateMachine.test.ts +++ b/test/unit/remote/workspaceStateMachine.test.ts @@ -11,8 +11,8 @@ import { streamBuildLogs, streamAgentLogs, } from "@/api/workspace"; -import { maybeAskAgent } from "@/promptUtils"; import { WorkspaceStateMachine } from "@/remote/workspaceStateMachine"; +import { maybeAskAgent } from "@/ui/prompts"; import { agent as createAgent, @@ -39,10 +39,10 @@ import type { } from "coder/site/src/api/typesGenerated"; import type { CoderApi } from "@/api/coderApi"; -import type { StartupMode } from "@/core/mementoManager"; import type { CliFeatureSet } from "@/featureSet"; +import type { AuthorityParts } from "@/remote/authority"; +import type { StartupMode } from "@/storage/mementoManager"; import type { TelemetryService } from "@/telemetry/service"; -import type { AuthorityParts } from "@/util/authority"; vi.mock("@/api/workspace", async (importActual) => { const { LazyStream } = await importActual(); @@ -69,7 +69,7 @@ vi.mock("@/api/updateParameters", async (importActual) => { }; }); -vi.mock("@/promptUtils", () => ({ +vi.mock("@/ui/prompts", () => ({ maybeAskAgent: vi.fn((agents: WorkspaceAgent[]) => Promise.resolve(agents.length > 0 ? agents[0] : undefined), ), diff --git a/test/unit/cliConfig.test.ts b/test/unit/settings/cli.test.ts similarity index 98% rename from test/unit/cliConfig.test.ts rename to test/unit/settings/cli.test.ts index 17c6163155..52c894547b 100644 --- a/test/unit/cliConfig.test.ts +++ b/test/unit/settings/cli.test.ts @@ -14,8 +14,8 @@ import { resolveCliAuth, } from "@/settings/cli"; -import { MockConfigurationProvider } from "../mocks/testHelpers"; -import { quoteCommand } from "../utils/platform"; +import { MockConfigurationProvider } from "../../mocks/testHelpers"; +import { quoteCommand } from "../../utils/platform"; vi.mock("node:os"); diff --git a/test/unit/core/mementoManager.test.ts b/test/unit/storage/mementoManager.test.ts similarity index 98% rename from test/unit/core/mementoManager.test.ts rename to test/unit/storage/mementoManager.test.ts index e92c939f59..7f1c9486ca 100644 --- a/test/unit/core/mementoManager.test.ts +++ b/test/unit/storage/mementoManager.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { MementoManager } from "@/core/mementoManager"; +import { MementoManager } from "@/storage/mementoManager"; import { InMemoryMemento } from "../../mocks/testHelpers"; diff --git a/test/unit/core/pathResolver.test.ts b/test/unit/storage/pathResolver.test.ts similarity index 99% rename from test/unit/core/pathResolver.test.ts rename to test/unit/storage/pathResolver.test.ts index 4361ccdbab..dfc51ea9f1 100644 --- a/test/unit/core/pathResolver.test.ts +++ b/test/unit/storage/pathResolver.test.ts @@ -2,7 +2,7 @@ import * as os from "node:os"; import * as path from "path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { PathResolver } from "@/core/pathResolver"; +import { PathResolver } from "@/storage/pathResolver"; import { MockConfigurationProvider, useEditor } from "../../mocks/testHelpers"; import { expectPathsEqual } from "../../utils/platform"; diff --git a/test/unit/core/secretsManager.test.ts b/test/unit/storage/secretsManager.test.ts similarity index 99% rename from test/unit/core/secretsManager.test.ts rename to test/unit/storage/secretsManager.test.ts index 482d71e0a9..776c9999ef 100644 --- a/test/unit/core/secretsManager.test.ts +++ b/test/unit/storage/secretsManager.test.ts @@ -1,10 +1,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { MementoManager } from "@/core/mementoManager"; +import { MementoManager } from "@/storage/mementoManager"; import { type CurrentDeploymentState, SecretsManager, -} from "@/core/secretsManager"; +} from "@/storage/secretsManager"; import { InMemoryMemento, diff --git a/test/unit/supportBundle/appendVsCodeLogs.test.ts b/test/unit/supportBundle/appendVsCodeLogs.test.ts index 9d0b61dc06..5740a6e893 100644 --- a/test/unit/supportBundle/appendVsCodeLogs.test.ts +++ b/test/unit/supportBundle/appendVsCodeLogs.test.ts @@ -4,9 +4,9 @@ import * as os from "node:os"; import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { renameWithRetry } from "@/common/fs"; import { appendVsCodeLogs } from "@/supportBundle/appendVsCodeLogs"; import { collectVsCodeDiagnostics } from "@/supportBundle/logFiles"; -import { renameWithRetry } from "@/util/fs"; import { createMockLogger } from "../../mocks/testHelpers"; @@ -18,8 +18,9 @@ vi.mock("@/supportBundle/logFiles", () => ({ // Wrap renameWithRetry so individual tests can override it via // mockRejectedValueOnce; by default it calls through to the real impl. -vi.mock("@/util/fs", async () => { - const actual = await vi.importActual("@/util/fs"); +vi.mock("@/common/fs", async () => { + const actual = + await vi.importActual("@/common/fs"); return { ...actual, renameWithRetry: vi.fn(actual.renameWithRetry) }; }); diff --git a/test/unit/util/date.test.ts b/test/unit/telemetry/date.test.ts similarity index 98% rename from test/unit/util/date.test.ts rename to test/unit/telemetry/date.test.ts index dc6fa859de..c1ec39ca60 100644 --- a/test/unit/util/date.test.ts +++ b/test/unit/telemetry/date.test.ts @@ -4,7 +4,7 @@ import { parseUtcDate, toUtcDateString, validateUtcDateInput, -} from "@/util/date"; +} from "@/telemetry/date"; describe("toUtcDateString", () => { it("formats a Date as a UTC YYYY-MM-DD string", () => { diff --git a/test/unit/progress.test.ts b/test/unit/ui/progress.test.ts similarity index 98% rename from test/unit/progress.test.ts rename to test/unit/ui/progress.test.ts index 5c5f3b6f78..509cfb8ffe 100644 --- a/test/unit/progress.test.ts +++ b/test/unit/ui/progress.test.ts @@ -7,9 +7,9 @@ import { withOptionalProgress, withProgress, type ProgressContext, -} from "@/progress"; +} from "@/ui/progress"; -import { MockProgress } from "../mocks/testHelpers"; +import { MockProgress } from "../../mocks/testHelpers"; function mockWithProgress(opts?: { cancelImmediately?: boolean }) { const dispose = vi.fn(); diff --git a/test/unit/uri/uriHandler.test.ts b/test/unit/uri/uriHandler.test.ts index 1d8bbce101..25f863f96b 100644 --- a/test/unit/uri/uriHandler.test.ts +++ b/test/unit/uri/uriHandler.test.ts @@ -1,12 +1,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import * as vscode from "vscode"; -import { MementoManager } from "@/core/mementoManager"; -import { SecretsManager } from "@/core/secretsManager"; +import { CALLBACK_PATH } from "@/oauth/constants"; import { OAuthCallback } from "@/oauth/oauthCallback"; -import { CALLBACK_PATH } from "@/oauth/utils"; -import { maybeAskUrl } from "@/promptUtils"; +import { MementoManager } from "@/storage/mementoManager"; +import { SecretsManager } from "@/storage/secretsManager"; import { NOOP_TELEMETRY_REPORTER } from "@/telemetry/reporter"; +import { maybeAskUrl } from "@/ui/prompts"; import { registerUriHandler } from "@/uri/uriHandler"; import { @@ -18,10 +18,10 @@ import { MockUserInteraction, } from "../../mocks/testHelpers"; -import type { ServiceContainer } from "@/core/container"; +import type { ServiceContainer } from "@/container"; import type { LoginCoordinator, LoginOptions } from "@/login/loginCoordinator"; -vi.mock("@/promptUtils", () => ({ maybeAskUrl: vi.fn() })); +vi.mock("@/ui/prompts", () => ({ maybeAskUrl: vi.fn() })); const TEST_URL = "https://coder.example.com"; const TEST_HOSTNAME = "coder.example.com"; diff --git a/test/unit/util.test.ts b/test/unit/util.test.ts deleted file mode 100644 index 91667ab5bd..0000000000 --- a/test/unit/util.test.ts +++ /dev/null @@ -1,253 +0,0 @@ -import os from "node:os"; -import { afterEach, beforeEach, describe, it, expect, vi } from "vitest"; - -import { - countSubstring, - escapeCommandArg, - escapeShellArg, - expandPath, - findPort, -} from "@/util"; - -describe("countSubstring", () => { - it("handles empty strings", () => { - expect(countSubstring("", "")).toBe(0); - expect(countSubstring("foo", "")).toBe(0); - expect(countSubstring("", "foo")).toBe(0); - }); - - it("handles single character", () => { - expect(countSubstring("a", "a")).toBe(1); - expect(countSubstring("a", "b")).toBe(0); - expect(countSubstring("a", "aa")).toBe(2); - expect(countSubstring("a", "aaa")).toBe(3); - expect(countSubstring("a", "baaa")).toBe(3); - }); - - it("handles multiple characters", () => { - expect(countSubstring("foo", "foo")).toBe(1); - expect(countSubstring("foo", "bar")).toBe(0); - expect(countSubstring("foo", "foobar")).toBe(1); - expect(countSubstring("foo", "foobarbaz")).toBe(1); - expect(countSubstring("foo", "foobarbazfoo")).toBe(2); - expect(countSubstring("foo", "foobarbazfoof")).toBe(2); - }); - - it("does not handle overlapping substrings", () => { - expect(countSubstring("aa", "aaa")).toBe(1); - expect(countSubstring("aa", "aaaa")).toBe(2); - expect(countSubstring("aa", "aaaaa")).toBe(2); - expect(countSubstring("aa", "aaaaaa")).toBe(3); - }); -}); - -describe("escapeCommandArg", () => { - it("returns simple strings unquoted", () => { - expect(escapeCommandArg("hello")).toBe("hello"); - }); - - it("returns flag-style strings unquoted", () => { - expect(escapeCommandArg("--verbose")).toBe("--verbose"); - expect(escapeCommandArg("--cfg=/etc/coder")).toBe("--cfg=/etc/coder"); - }); - - it("quotes the empty string so it survives as a token", () => { - expect(escapeCommandArg("")).toBe('""'); - }); - - it("escapes double quotes and wraps in quotes", () => { - expect(escapeCommandArg('say "hello"')).toBe(String.raw`"say \"hello\""`); - }); - - it("quotes backslashes (POSIX escape char)", () => { - expect(escapeCommandArg(String.raw`path\to\file`)).toBe( - String.raw`"path\to\file"`, - ); - }); - - it("quotes strings containing spaces", () => { - expect(escapeCommandArg("hello world")).toBe('"hello world"'); - }); - - it.each([["tab\there"], ["line\nbreak"], ["vtab\vhere"]])( - "quotes strings containing other whitespace: %j", - (input) => { - expect(escapeCommandArg(input)).toBe(`"${input}"`); - }, - ); - - it.each([ - ["foo&bar"], - ["foo;bar"], - ["foo|bar"], - ["foo(bar)"], - ["foobar"], - ["foo*bar"], - ["foo?bar"], - ["foo$bar"], - ["foo`bar"], - ["foo~bar"], - ["foo!bar"], - ["foo#bar"], - ["https://x.com?a=1&b=2"], - ])("quotes strings containing shell metacharacter: %j", (input) => { - expect(escapeCommandArg(input)).toBe(`"${input}"`); - }); -}); - -describe("escapeShellArg", () => { - const platformSpy = vi.spyOn(os, "platform"); - afterEach(() => platformSpy.mockReset()); - - describe("on Unix", () => { - beforeEach(() => platformSpy.mockReturnValue("linux")); - - it("wraps in single quotes", () => { - expect(escapeShellArg("env=dev")).toBe("'env=dev'"); - }); - - it("escapes single quotes via the '\\'' sequence", () => { - expect(escapeShellArg("it's fine")).toBe("'it'\\''s fine'"); - }); - - it("leaves $VAR, $(...), and backticks literal inside the quotes", () => { - expect(escapeShellArg("$(echo pwned)")).toBe("'$(echo pwned)'"); - }); - }); - - describe("on Windows", () => { - beforeEach(() => platformSpy.mockReturnValue("win32")); - - it("wraps in double quotes", () => { - expect(escapeShellArg("env=dev")).toBe('"env=dev"'); - }); - - it('doubles embedded `"`', () => { - expect(escapeShellArg('regions=["us","eu"]')).toBe( - '"regions=[""us"",""eu""]"', - ); - }); - - it("doubles `%` to block %VAR% expansion", () => { - expect(escapeShellArg("%PATH%")).toBe('"%%PATH%%"'); - }); - - it("keeps cmd metachars inside the quoted region", () => { - expect(escapeShellArg('foo" & calc.exe & "x')).toBe( - '"foo"" & calc.exe & ""x"', - ); - }); - }); -}); - -describe("expandPath", () => { - const home = os.homedir(); - - it("expands tilde at start of path", () => { - expect(expandPath("~/foo/bar")).toBe(`${home}/foo/bar`); - }); - - it("expands standalone tilde", () => { - expect(expandPath("~")).toBe(home); - }); - - it("does not expand tilde in middle of path", () => { - expect(expandPath("/foo/~/bar")).toBe("/foo/~/bar"); - }); - - it("expands ${userHome} variable", () => { - expect(expandPath("${userHome}/foo")).toBe(`${home}/foo`); - }); - - it("expands multiple ${userHome} variables", () => { - expect(expandPath("${userHome}/foo/${userHome}/bar")).toBe( - `${home}/foo/${home}/bar`, - ); - }); - - it("leaves paths without tilde or variable unchanged", () => { - expect(expandPath("/absolute/path")).toBe("/absolute/path"); - expect(expandPath("relative/path")).toBe("relative/path"); - }); - - it("expands both tilde and ${userHome}", () => { - expect(expandPath("~/${userHome}/foo")).toBe(`${home}/${home}/foo`); - }); - - describe("${env:VAR}", () => { - const envKey = "CODER_EXPAND_PATH_TEST"; - const ref = "${env:" + envKey + "}"; - - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it("substitutes a present env var", () => { - vi.stubEnv(envKey, "/data"); - expect(expandPath(`${ref}/foo`)).toBe("/data/foo"); - }); - - it("replaces a missing env var with an empty string", () => { - vi.stubEnv(envKey, undefined); - expect(expandPath(`prefix-${ref}-suffix`)).toBe("prefix--suffix"); - }); - - it("substitutes multiple occurrences in one string", () => { - vi.stubEnv(envKey, "data"); - expect(expandPath(`${ref}/${ref}`)).toBe("data/data"); - }); - - it("expands tilde or ${userHome} that appears inside the env value", () => { - vi.stubEnv(envKey, "~/projects"); - expect(expandPath(`${ref}/x`)).toBe(`${home}/projects/x`); - }); - - it("ignores ${env:...} with invalid names", () => { - expect(expandPath("${env:1BAD}/x")).toBe("${env:1BAD}/x"); - }); - }); -}); - -describe("findPort", () => { - it.each([[""], ["some random log text without ports"]])( - "returns null for <%s>", - (input) => { - expect(findPort(input)).toBe(null); - }, - ); - - it.each([ - [ - "ms-vscode-remote.remote-ssh", - "[10:30:45] SSH established -> socksPort 12345 -> ready", - 12345, - ], - [ - "ms-vscode-remote.remote-ssh[2]", - "Forwarding between local port 54321 and remote", - 54321, - ], - [ - "devin/open-remote-ssh/antigravity", - "[INFO] Connection => 9999(socks) => target", - 9999, - ], - [ - "anysphere.remote-ssh", - "[DEBUG] Initialized Socks port: 8888 proxy", - 8888, - ], - ])("finds port from %s log format", (_name, input, expected) => { - expect(findPort(input)).toBe(expected); - }); - - it("returns most recent port when multiple matches exist", () => { - const log = ` -[10:30:00] Starting connection -> socksPort 1111 -> initialized -[10:30:05] Reconnecting => 2222(socks) => retry -[10:30:10] Final connection Socks port: 3333 established - `; - expect(findPort(log)).toBe(3333); - }); -}); diff --git a/test/unit/util/fileCleanup.test.ts b/test/unit/util/fileCleanup.test.ts deleted file mode 100644 index b363f3ef11..0000000000 --- a/test/unit/util/fileCleanup.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { vol } from "memfs"; -import * as fsPromises from "node:fs/promises"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -import { cleanupFiles } from "@/util/fileCleanup"; - -import { createMockLogger } from "../../mocks/testHelpers"; - -vi.mock("node:fs/promises", async () => (await import("memfs")).fs.promises); - -describe("cleanupFiles", () => { - beforeEach(() => { - vi.restoreAllMocks(); - vol.reset(); - }); - - afterEach(() => { - vol.reset(); - }); - - function setup(files: Record = {}) { - vol.fromJSON(files); - return { logger: createMockLogger() }; - } - - it("does not throw when the directory is missing", async () => { - const { logger } = setup(); - await expect( - cleanupFiles("/nope", logger, { label: "thing", select: () => [] }), - ).resolves.toBeUndefined(); - }); - - it("unlinks the files chosen by select and leaves the rest", async () => { - const { logger } = setup({ "/d/a": "1", "/d/b": "2", "/d/c": "3" }); - - await cleanupFiles("/d", logger, { - label: "thing", - select: (files) => files.filter((f) => f.name !== "b"), - }); - - expect(vol.readdirSync("/d")).toEqual(["b"]); - }); - - it("exposes mtime, size, and the current time so select can filter on them", async () => { - const { logger } = setup({ - "/d/old-big": "x".repeat(100), - "/d/new-small": "x", - }); - // 1970-01-01: definitely older than `now`. - vol.utimesSync("/d/old-big", 1, 1); - - await cleanupFiles("/d", logger, { - label: "thing", - select: (files, now) => - files.filter((f) => now - f.mtime > 1000 && f.size > 50), - }); - - expect(vol.readdirSync("/d")).toEqual(["new-small"]); - }); - - it("only feeds select the files matched by `filter`", async () => { - const { logger } = setup({ - "/d/keep.json": "{}", - "/d/skip.txt": "no", - "/d/keep-too.json": "{}", - }); - - await cleanupFiles("/d", logger, { - label: "thing", - filter: (n) => n.endsWith(".json"), - select: (files) => files, - }); - - expect(vol.readdirSync("/d")).toEqual(["skip.txt"]); - }); - - it("keeps going when a file disappears between stat and unlink", async () => { - const { logger } = setup({ "/d/a": "1", "/d/b": "2" }); - - await cleanupFiles("/d", logger, { - label: "thing", - select: (files) => { - vol.unlinkSync("/d/a"); - return files; - }, - }); - - expect(vol.readdirSync("/d")).toEqual([]); - }); - - it("does not throw when readdir fails for reasons other than ENOENT", async () => { - const { logger } = setup(); - const err = Object.assign(new Error("denied"), { code: "EACCES" }); - vi.spyOn(fsPromises, "readdir").mockRejectedValueOnce(err); - - const select = vi.fn(() => []); - await expect( - cleanupFiles("/d", logger, { label: "thing", select }), - ).resolves.toBeUndefined(); - expect(select).not.toHaveBeenCalled(); - }); - - it("clamps select names to their basename so unlinks cannot escape the directory", async () => { - const { logger } = setup({ - "/d/inside.txt": "x", - "/outside.txt": "y", - }); - - await cleanupFiles("/d", logger, { - label: "thing", - select: () => [{ name: "../outside.txt" }], - }); - - expect(vol.existsSync("/outside.txt")).toBe(true); - }); -}); diff --git a/test/unit/core/commandManager.test.ts b/test/unit/vscode/commandManager.test.ts similarity index 99% rename from test/unit/core/commandManager.test.ts rename to test/unit/vscode/commandManager.test.ts index c95bff1797..cb2a009b9b 100644 --- a/test/unit/core/commandManager.test.ts +++ b/test/unit/vscode/commandManager.test.ts @@ -7,7 +7,7 @@ import { CODER_COMMAND_IDS, CommandManager, type CoderCommandId, -} from "@/core/commandManager"; +} from "@/vscode/commandManager"; import { createTestTelemetryService, TestSink } from "../../mocks/telemetry"; import { MockConfigurationProvider } from "../../mocks/testHelpers"; diff --git a/test/unit/websocket/utils.test.ts b/test/unit/websocket/codes.test.ts similarity index 96% rename from test/unit/websocket/utils.test.ts rename to test/unit/websocket/codes.test.ts index 2e7797dba2..baca0d640b 100644 --- a/test/unit/websocket/utils.test.ts +++ b/test/unit/websocket/codes.test.ts @@ -4,11 +4,14 @@ import { type AddressInfo } from "node:net"; import { afterEach, describe, expect, it, vi } from "vitest"; import { HttpStatusCode } from "@/api/httpStatusCode"; -import { WebSocketCloseCode } from "@/websocket/codes"; +import { + WebSocketCloseCode, + HandshakeError, + handshakeStatus, +} from "@/websocket/codes"; import { type ErrorEvent } from "@/websocket/eventStreamConnection"; import { OneWayWebSocket } from "@/websocket/oneWayWebSocket"; import { SseConnection } from "@/websocket/sseConnection"; -import { HandshakeError, handshakeStatus } from "@/websocket/utils"; import { createMockLogger } from "../../mocks/testHelpers"; diff --git a/test/unit/websocket/oneWayWebSocket.test.ts b/test/unit/websocket/oneWayWebSocket.test.ts index 755e5b88db..9f4f80dcb6 100644 --- a/test/unit/websocket/oneWayWebSocket.test.ts +++ b/test/unit/websocket/oneWayWebSocket.test.ts @@ -3,7 +3,7 @@ import { type AddressInfo } from "node:net"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { WebSocketServer } from "ws"; -import { toError } from "@/error/errorUtils"; +import { toError } from "@/error/normalize"; import { WebSocketCloseCode } from "@/websocket/codes"; import { OneWayWebSocket } from "@/websocket/oneWayWebSocket"; diff --git a/test/unit/websocket/reconnectingWebSocket.test.ts b/test/unit/websocket/reconnectingWebSocket.test.ts index a730e3ba54..a2d7a57957 100644 --- a/test/unit/websocket/reconnectingWebSocket.test.ts +++ b/test/unit/websocket/reconnectingWebSocket.test.ts @@ -8,13 +8,13 @@ import { import { UNRECOVERABLE_HTTP_CODES, WebSocketCloseCode, + HandshakeError, } from "@/websocket/codes"; import { ConnectionState, ReconnectingWebSocket, type SocketFactory, } from "@/websocket/reconnectingWebSocket"; -import { HandshakeError } from "@/websocket/utils"; import { createTestTelemetryService, diff --git a/test/unit/websocket/sseConnection.test.ts b/test/unit/websocket/sseConnection.test.ts index 5ef42df741..3be7095291 100644 --- a/test/unit/websocket/sseConnection.test.ts +++ b/test/unit/websocket/sseConnection.test.ts @@ -6,14 +6,13 @@ import { describe, it, expect, vi } from "vitest"; import { HttpStatusCode } from "@/api/httpStatusCode"; import { type Logger } from "@/logging/logger"; -import { WebSocketCloseCode } from "@/websocket/codes"; +import { WebSocketCloseCode, handshakeStatus } from "@/websocket/codes"; import { type ParsedMessageEvent, type CloseEvent, type ErrorEvent, } from "@/websocket/eventStreamConnection"; import { SseConnection } from "@/websocket/sseConnection"; -import { handshakeStatus } from "@/websocket/utils"; import { createMockLogger } from "../../mocks/testHelpers"; diff --git a/test/unit/util/poller.test.ts b/test/unit/webviews/workspaces/poller.test.ts similarity index 95% rename from test/unit/util/poller.test.ts rename to test/unit/webviews/workspaces/poller.test.ts index 6ef48ef9d7..09b69f2d39 100644 --- a/test/unit/util/poller.test.ts +++ b/test/unit/webviews/workspaces/poller.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it, onTestFinished, vi } from "vitest"; -import { Poller, type NextRun, type RetryOptions } from "@/util/poller"; +import { + Poller, + type NextRun, + type RetryOptions, +} from "@/webviews/workspaces/poller"; -import { createMockLogger, flushPromises } from "../../mocks/testHelpers"; +import { createMockLogger, flushPromises } from "../../../mocks/testHelpers"; import type * as vscode from "vscode"; diff --git a/test/unit/webviews/workspaces/panelProvider.test.ts b/test/unit/webviews/workspaces/workspacesPanelProvider.test.ts similarity index 99% rename from test/unit/webviews/workspaces/panelProvider.test.ts rename to test/unit/webviews/workspaces/workspacesPanelProvider.test.ts index c0bbfe271e..7d920c91a4 100644 --- a/test/unit/webviews/workspaces/panelProvider.test.ts +++ b/test/unit/webviews/workspaces/workspacesPanelProvider.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, onTestFinished, vi } from "vitest"; import * as vscode from "vscode"; -import { WorkspacesPanelProvider } from "@/webviews/workspaces/panelProvider"; +import { WorkspacesPanelProvider } from "@/webviews/workspaces/workspacesPanelProvider"; import { WorkspaceStore } from "@/webviews/workspaces/workspaceStore"; import { diff --git a/test/unit/workspace/workspacesProvider.test.ts b/test/unit/workspace/workspacesProvider.test.ts index 62e1019651..055fe8a763 100644 --- a/test/unit/workspace/workspacesProvider.test.ts +++ b/test/unit/workspace/workspacesProvider.test.ts @@ -26,7 +26,7 @@ import type { WorkspaceAppStatus, } from "coder/site/src/api/typesGenerated"; -import type { AgentMetadataEvent } from "@/api/api-helper"; +import type { AgentMetadataEvent } from "@/api/agentMetadata"; import type { CoderApi } from "@/api/coderApi"; import type { WorkspaceFilter } from "@repo/shared"; diff --git a/test/webview/netcheck/severity.test.ts b/test/webview/shared/netcheck/severity.test.ts similarity index 90% rename from test/webview/netcheck/severity.test.ts rename to test/webview/shared/netcheck/severity.test.ts index 288e536423..72cfa49a13 100644 --- a/test/webview/netcheck/severity.test.ts +++ b/test/webview/shared/netcheck/severity.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { overallNetcheckSeverity } from "@repo/shared"; -import { report } from "./fixtures"; +import { report } from "../../netcheck/fixtures"; describe("overallNetcheckSeverity", () => { it("takes the worst of the two section severities", () => { diff --git a/test/webview/shared/tasks/utils.test.ts b/test/webview/shared/tasks/status.test.ts similarity index 100% rename from test/webview/shared/tasks/utils.test.ts rename to test/webview/shared/tasks/status.test.ts diff --git a/test/webview/speedtest/chartUtils.test.ts b/test/webview/speedtest/chartGeometry.test.ts similarity index 71% rename from test/webview/speedtest/chartUtils.test.ts rename to test/webview/speedtest/chartGeometry.test.ts index eeb53d8075..7dd61566a7 100644 --- a/test/webview/speedtest/chartUtils.test.ts +++ b/test/webview/speedtest/chartGeometry.test.ts @@ -4,13 +4,10 @@ import { findNearestByX, findNearestDot, findNearestOnLine, - formatDuration, - formatThroughput, - formatTick, niceRound, niceStep, toChartSamples, -} from "@repo/speedtest/chartUtils"; +} from "@repo/speedtest/chartGeometry"; describe("niceStep", () => { it("rounds up to the next candidate for sub-hour ranges", () => { @@ -83,63 +80,6 @@ describe("niceRound", () => { }); }); -describe("formatThroughput", () => { - it("uses two decimals below 1000 Mbps", () => { - expect(formatThroughput(0.45)).toBe("0.45"); - expect(formatThroughput(11.52)).toBe("11.52"); - expect(formatThroughput(43.49)).toBe("43.49"); - expect(formatThroughput(999.99)).toBe("999.99"); - }); - - it("drops decimals at 1000 Mbps and above", () => { - expect(formatThroughput(1000)).toBe("1000"); - expect(formatThroughput(1500.5)).toBe("1501"); - expect(formatThroughput(9999.99)).toBe("10000"); - }); -}); - -describe("formatDuration", () => { - it("returns seconds with one decimal below a minute", () => { - expect(formatDuration(5.8)).toEqual({ value: "5.8", unit: "s" }); - expect(formatDuration(14.6)).toEqual({ value: "14.6", unit: "s" }); - expect(formatDuration(59.9)).toEqual({ value: "59.9", unit: "s" }); - }); - - it("switches to minutes between 1m and 1h, dropping trailing zero on whole values", () => { - expect(formatDuration(60)).toEqual({ value: "1", unit: "m" }); - expect(formatDuration(120)).toEqual({ value: "2", unit: "m" }); - expect(formatDuration(330)).toEqual({ value: "5.5", unit: "m" }); - expect(formatDuration(900)).toEqual({ value: "15", unit: "m" }); - }); - - it("switches to hours at 1h and above", () => { - expect(formatDuration(3600)).toEqual({ value: "1", unit: "h" }); - expect(formatDuration(5400)).toEqual({ value: "1.5", unit: "h" }); - expect(formatDuration(7200)).toEqual({ value: "2", unit: "h" }); - }); -}); - -describe("formatTick", () => { - it("uses seconds below a minute", () => { - expect(formatTick(0, 1)).toBe("0s"); - expect(formatTick(5, 5)).toBe("5s"); - expect(formatTick(30, 15)).toBe("30s"); - }); - - it("uses minutes between 1m and 1h", () => { - expect(formatTick(60, 60)).toBe("1m"); - expect(formatTick(120, 60)).toBe("2m"); - expect(formatTick(90, 60)).toBe("1.5m"); - expect(formatTick(300, 300)).toBe("5m"); - }); - - it("uses hours at or above 1h", () => { - expect(formatTick(3600, 3600)).toBe("1h"); - expect(formatTick(7200, 3600)).toBe("2h"); - expect(formatTick(5400, 3600)).toBe("1.5h"); - }); -}); - describe("toChartSamples", () => { it("maps intervals to points with throughput labels", () => { const samples = toChartSamples([ diff --git a/test/webview/speedtest/format.test.ts b/test/webview/speedtest/format.test.ts new file mode 100644 index 0000000000..9135452d7f --- /dev/null +++ b/test/webview/speedtest/format.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; + +import { + formatDuration, + formatThroughput, + formatTick, +} from "@repo/speedtest/format"; + +describe("formatThroughput", () => { + it("uses two decimals below 1000 Mbps", () => { + expect(formatThroughput(0.45)).toBe("0.45"); + expect(formatThroughput(11.52)).toBe("11.52"); + expect(formatThroughput(43.49)).toBe("43.49"); + expect(formatThroughput(999.99)).toBe("999.99"); + }); + + it("drops decimals at 1000 Mbps and above", () => { + expect(formatThroughput(1000)).toBe("1000"); + expect(formatThroughput(1500.5)).toBe("1501"); + expect(formatThroughput(9999.99)).toBe("10000"); + }); +}); + +describe("formatDuration", () => { + it("returns seconds with one decimal below a minute", () => { + expect(formatDuration(5.8)).toEqual({ value: "5.8", unit: "s" }); + expect(formatDuration(14.6)).toEqual({ value: "14.6", unit: "s" }); + expect(formatDuration(59.9)).toEqual({ value: "59.9", unit: "s" }); + }); + + it("switches to minutes between 1m and 1h, dropping trailing zero on whole values", () => { + expect(formatDuration(60)).toEqual({ value: "1", unit: "m" }); + expect(formatDuration(120)).toEqual({ value: "2", unit: "m" }); + expect(formatDuration(330)).toEqual({ value: "5.5", unit: "m" }); + expect(formatDuration(900)).toEqual({ value: "15", unit: "m" }); + }); + + it("switches to hours at 1h and above", () => { + expect(formatDuration(3600)).toEqual({ value: "1", unit: "h" }); + expect(formatDuration(5400)).toEqual({ value: "1.5", unit: "h" }); + expect(formatDuration(7200)).toEqual({ value: "2", unit: "h" }); + }); +}); + +describe("formatTick", () => { + it("uses seconds below a minute", () => { + expect(formatTick(0, 1)).toBe("0s"); + expect(formatTick(5, 5)).toBe("5s"); + expect(formatTick(30, 15)).toBe("30s"); + }); + + it("uses minutes between 1m and 1h", () => { + expect(formatTick(60, 60)).toBe("1m"); + expect(formatTick(120, 60)).toBe("2m"); + expect(formatTick(90, 60)).toBe("1.5m"); + expect(formatTick(300, 300)).toBe("5m"); + }); + + it("uses hours at or above 1h", () => { + expect(formatTick(3600, 3600)).toBe("1h"); + expect(formatTick(7200, 3600)).toBe("2h"); + expect(formatTick(5400, 3600)).toBe("1.5h"); + }); +}); diff --git a/test/webview/tasks/ActionMenu.test.tsx b/test/webview/tasks/components/ActionMenu.test.tsx similarity index 99% rename from test/webview/tasks/ActionMenu.test.tsx rename to test/webview/tasks/components/ActionMenu.test.tsx index f72e6f1236..aa07d03352 100644 --- a/test/webview/tasks/ActionMenu.test.tsx +++ b/test/webview/tasks/components/ActionMenu.test.tsx @@ -6,7 +6,7 @@ import { type ActionMenuItem, } from "@repo/tasks/components/ActionMenu"; -import { qs } from "../helpers"; +import { qs } from "../../helpers"; type ActionMenuAction = Exclude; diff --git a/test/webview/tasks/AgentChatHistory.test.tsx b/test/webview/tasks/components/AgentChatHistory.test.tsx similarity index 99% rename from test/webview/tasks/AgentChatHistory.test.tsx rename to test/webview/tasks/components/AgentChatHistory.test.tsx index 1b0f7a0d53..b7a0ea0c77 100644 --- a/test/webview/tasks/AgentChatHistory.test.tsx +++ b/test/webview/tasks/components/AgentChatHistory.test.tsx @@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest"; import { logEntry } from "@repo/mocks"; import { AgentChatHistory } from "@repo/tasks/components/AgentChatHistory"; -import { renderWithQuery } from "../render"; +import { renderWithQuery } from "../../render"; import type { TaskLogs } from "@repo/shared"; diff --git a/test/webview/tasks/CreateTaskSection.test.tsx b/test/webview/tasks/components/CreateTaskSection.test.tsx similarity index 98% rename from test/webview/tasks/CreateTaskSection.test.tsx rename to test/webview/tasks/components/CreateTaskSection.test.tsx index 0c6297b5ab..b07657c864 100644 --- a/test/webview/tasks/CreateTaskSection.test.tsx +++ b/test/webview/tasks/components/CreateTaskSection.test.tsx @@ -5,7 +5,7 @@ import { taskTemplate } from "@repo/mocks"; import { CreateTaskSection } from "@repo/tasks/components/CreateTaskSection"; import { logger } from "@repo/webview-shared/logger"; -import { renderWithQuery } from "../render"; +import { renderWithQuery } from "../../render"; import type { TaskTemplate } from "@repo/shared"; diff --git a/test/webview/tasks/ErrorBanner.test.tsx b/test/webview/tasks/components/ErrorBanner.test.tsx similarity index 97% rename from test/webview/tasks/ErrorBanner.test.tsx rename to test/webview/tasks/components/ErrorBanner.test.tsx index 8ed66937d5..e80857c287 100644 --- a/test/webview/tasks/ErrorBanner.test.tsx +++ b/test/webview/tasks/components/ErrorBanner.test.tsx @@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { task } from "@repo/mocks"; import { ErrorBanner } from "@repo/tasks/components/ErrorBanner"; -import { renderWithQuery } from "../render"; +import { renderWithQuery } from "../../render"; import type { Task } from "@repo/shared"; diff --git a/test/webview/tasks/LogViewer.test.tsx b/test/webview/tasks/components/LogViewer.test.tsx similarity index 95% rename from test/webview/tasks/LogViewer.test.tsx rename to test/webview/tasks/components/LogViewer.test.tsx index 6bb73d4f39..9f5952c5c9 100644 --- a/test/webview/tasks/LogViewer.test.tsx +++ b/test/webview/tasks/components/LogViewer.test.tsx @@ -6,7 +6,7 @@ import { LogViewerPlaceholder, } from "@repo/tasks/components/LogViewer"; -import { renderWithQuery } from "../render"; +import { renderWithQuery } from "../../render"; describe("LogViewer", () => { it("renders header and children", () => { diff --git a/test/webview/tasks/PromptInput.test.tsx b/test/webview/tasks/components/PromptInput.test.tsx similarity index 98% rename from test/webview/tasks/PromptInput.test.tsx rename to test/webview/tasks/components/PromptInput.test.tsx index 8cdf816ddb..2062ff532e 100644 --- a/test/webview/tasks/PromptInput.test.tsx +++ b/test/webview/tasks/components/PromptInput.test.tsx @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { PromptInput } from "@repo/tasks/components/PromptInput"; -import { qs } from "../helpers"; +import { qs } from "../../helpers"; const defaults = { onChange: vi.fn(), diff --git a/test/webview/tasks/StatePanel.test.tsx b/test/webview/tasks/components/StatePanel.test.tsx similarity index 100% rename from test/webview/tasks/StatePanel.test.tsx rename to test/webview/tasks/components/StatePanel.test.tsx diff --git a/test/webview/tasks/StatusIndicator.test.tsx b/test/webview/tasks/components/StatusIndicator.test.tsx similarity index 100% rename from test/webview/tasks/StatusIndicator.test.tsx rename to test/webview/tasks/components/StatusIndicator.test.tsx diff --git a/test/webview/tasks/TaskDetailHeader.test.tsx b/test/webview/tasks/components/TaskDetailHeader.test.tsx similarity index 97% rename from test/webview/tasks/TaskDetailHeader.test.tsx rename to test/webview/tasks/components/TaskDetailHeader.test.tsx index 2be328de5e..acf60cc4b1 100644 --- a/test/webview/tasks/TaskDetailHeader.test.tsx +++ b/test/webview/tasks/components/TaskDetailHeader.test.tsx @@ -4,8 +4,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { task, taskState } from "@repo/mocks"; import { TaskDetailHeader } from "@repo/tasks/components/TaskDetailHeader"; -import { qs } from "../helpers"; -import { renderWithQuery } from "../render"; +import { qs } from "../../helpers"; +import { renderWithQuery } from "../../render"; import type { Task } from "@repo/shared"; diff --git a/test/webview/tasks/TaskDetailView.test.tsx b/test/webview/tasks/components/TaskDetailView.test.tsx similarity index 98% rename from test/webview/tasks/TaskDetailView.test.tsx rename to test/webview/tasks/components/TaskDetailView.test.tsx index 7f7f7e4959..4dccaf90c4 100644 --- a/test/webview/tasks/TaskDetailView.test.tsx +++ b/test/webview/tasks/components/TaskDetailView.test.tsx @@ -4,8 +4,8 @@ import { describe, expect, it, vi } from "vitest"; import { logEntry, taskDetails, taskState } from "@repo/mocks"; import { TaskDetailView } from "@repo/tasks/components/TaskDetailView"; -import { qs } from "../helpers"; -import { renderWithQuery } from "../render"; +import { qs } from "../../helpers"; +import { renderWithQuery } from "../../render"; import type { Task } from "@repo/shared"; diff --git a/test/webview/tasks/TaskItem.test.tsx b/test/webview/tasks/components/TaskItem.test.tsx similarity index 98% rename from test/webview/tasks/TaskItem.test.tsx rename to test/webview/tasks/components/TaskItem.test.tsx index 3101cb98b1..f9b59e5210 100644 --- a/test/webview/tasks/TaskItem.test.tsx +++ b/test/webview/tasks/components/TaskItem.test.tsx @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import { task } from "@repo/mocks"; import { TaskItem } from "@repo/tasks/components/TaskItem"; -import { renderWithQuery } from "../render"; +import { renderWithQuery } from "../../render"; import type { Task } from "@repo/shared"; diff --git a/test/webview/tasks/TaskList.test.tsx b/test/webview/tasks/components/TaskList.test.tsx similarity index 96% rename from test/webview/tasks/TaskList.test.tsx rename to test/webview/tasks/components/TaskList.test.tsx index 9339fe01da..9db1023984 100644 --- a/test/webview/tasks/TaskList.test.tsx +++ b/test/webview/tasks/components/TaskList.test.tsx @@ -4,7 +4,7 @@ import { describe, expect, it, vi } from "vitest"; import { task } from "@repo/mocks"; import { TaskList } from "@repo/tasks/components/TaskList"; -import { renderWithQuery } from "../render"; +import { renderWithQuery } from "../../render"; vi.mock("@repo/tasks/hooks/useTasksApi", () => ({ useTasksApi: () => new Proxy({}, { get: () => vi.fn() }), diff --git a/test/webview/tasks/TaskMessageInput.test.tsx b/test/webview/tasks/components/TaskMessageInput.test.tsx similarity index 98% rename from test/webview/tasks/TaskMessageInput.test.tsx rename to test/webview/tasks/components/TaskMessageInput.test.tsx index 5d075d0582..c25947dc61 100644 --- a/test/webview/tasks/TaskMessageInput.test.tsx +++ b/test/webview/tasks/components/TaskMessageInput.test.tsx @@ -4,8 +4,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { task, taskState } from "@repo/mocks"; import { TaskMessageInput } from "@repo/tasks/components/TaskMessageInput"; -import { qs } from "../helpers"; -import { renderWithQuery } from "../render"; +import { qs } from "../../helpers"; +import { renderWithQuery } from "../../render"; import type { Task } from "@repo/shared"; diff --git a/test/webview/tasks/WorkspaceLogs.test.tsx b/test/webview/tasks/components/WorkspaceLogs.test.tsx similarity index 97% rename from test/webview/tasks/WorkspaceLogs.test.tsx rename to test/webview/tasks/components/WorkspaceLogs.test.tsx index 4f2d94e941..f8a2d7cf9f 100644 --- a/test/webview/tasks/WorkspaceLogs.test.tsx +++ b/test/webview/tasks/components/WorkspaceLogs.test.tsx @@ -5,7 +5,7 @@ import { task } from "@repo/mocks"; import { WorkspaceLogs } from "@repo/tasks/components/WorkspaceLogs"; import * as hookModule from "@repo/tasks/hooks/useWorkspaceLogs"; -import { renderWithQuery } from "../render"; +import { renderWithQuery } from "../../render"; vi.mock("@repo/tasks/hooks/useWorkspaceLogs", () => ({ useWorkspaceLogs: () => [], diff --git a/test/webview/tasks/useTaskMenuItems.test.tsx b/test/webview/tasks/hooks/useTaskMenuItems.test.tsx similarity index 98% rename from test/webview/tasks/useTaskMenuItems.test.tsx rename to test/webview/tasks/hooks/useTaskMenuItems.test.tsx index c0f409cafc..206dae3033 100644 --- a/test/webview/tasks/useTaskMenuItems.test.tsx +++ b/test/webview/tasks/hooks/useTaskMenuItems.test.tsx @@ -3,9 +3,9 @@ import { describe, expect, it, vi } from "vitest"; import { task } from "@repo/mocks"; import { getTaskLabel, type Task } from "@repo/shared"; -import { useTaskMenuItems } from "@repo/tasks/components/useTaskMenuItems"; +import { useTaskMenuItems } from "@repo/tasks/hooks/useTaskMenuItems"; -import { QueryWrapper } from "../render"; +import { QueryWrapper } from "../../render"; import type { ActionMenuItem } from "@repo/tasks/components/ActionMenu"; diff --git a/test/webview/tasks/useWorkspaceLogs.test.ts b/test/webview/tasks/hooks/useWorkspaceLogs.test.ts similarity index 100% rename from test/webview/tasks/useWorkspaceLogs.test.ts rename to test/webview/tasks/hooks/useWorkspaceLogs.test.ts diff --git a/test/webview/tasks/keys.test.ts b/test/webview/tasks/keys.test.ts index 83953dce5d..354b11b19f 100644 --- a/test/webview/tasks/keys.test.ts +++ b/test/webview/tasks/keys.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { isActivate, isEscape, isSubmit } from "@repo/tasks/utils/keys"; +import { isActivate, isEscape, isSubmit } from "@repo/tasks/keys"; describe("isSubmit", () => { it("returns true for Enter+ctrlKey", () => { diff --git a/test/webview/ui/tree.core.test.tsx b/test/webview/ui/components/Tree/Tree.core.test.tsx similarity index 100% rename from test/webview/ui/tree.core.test.tsx rename to test/webview/ui/components/Tree/Tree.core.test.tsx diff --git a/test/webview/ui/tree.hover.test.tsx b/test/webview/ui/components/Tree/Tree.hover.test.tsx similarity index 100% rename from test/webview/ui/tree.hover.test.tsx rename to test/webview/ui/components/Tree/Tree.hover.test.tsx diff --git a/test/webview/ui/tree.keyboard.test.tsx b/test/webview/ui/components/Tree/Tree.keyboard.test.tsx similarity index 100% rename from test/webview/ui/tree.keyboard.test.tsx rename to test/webview/ui/components/Tree/Tree.keyboard.test.tsx diff --git a/test/webview/ui/tree.rows.test.tsx b/test/webview/ui/components/Tree/Tree.rows.test.tsx similarity index 100% rename from test/webview/ui/tree.rows.test.tsx rename to test/webview/ui/components/Tree/Tree.rows.test.tsx diff --git a/test/webview/ui/tree.selection.test.tsx b/test/webview/ui/components/Tree/Tree.selection.test.tsx similarity index 100% rename from test/webview/ui/tree.selection.test.tsx rename to test/webview/ui/components/Tree/Tree.selection.test.tsx diff --git a/test/webview/ui/tree.sticky.test.tsx b/test/webview/ui/components/Tree/Tree.sticky.test.tsx similarity index 100% rename from test/webview/ui/tree.sticky.test.tsx rename to test/webview/ui/components/Tree/Tree.sticky.test.tsx diff --git a/test/webview/ui/treeStickyState.test.ts b/test/webview/ui/components/Tree/sticky/stickyState.test.ts similarity index 100% rename from test/webview/ui/treeStickyState.test.ts rename to test/webview/ui/components/Tree/sticky/stickyState.test.ts diff --git a/test/webview/ui/treeModel.test.tsx b/test/webview/ui/components/Tree/treeModel.test.tsx similarity index 100% rename from test/webview/ui/treeModel.test.tsx rename to test/webview/ui/components/Tree/treeModel.test.tsx diff --git a/test/webview/ui/treePolicy.test.ts b/test/webview/ui/components/Tree/treePolicy.test.ts similarity index 100% rename from test/webview/ui/treePolicy.test.ts rename to test/webview/ui/components/Tree/treePolicy.test.ts diff --git a/test/webview/ui/treeTestHelpers.tsx b/test/webview/ui/components/Tree/treeTestHelpers.tsx similarity index 100% rename from test/webview/ui/treeTestHelpers.tsx rename to test/webview/ui/components/Tree/treeTestHelpers.tsx diff --git a/test/webview/ui/treeTransition.test.ts b/test/webview/ui/components/Tree/treeTransition.test.ts similarity index 100% rename from test/webview/ui/treeTransition.test.ts rename to test/webview/ui/components/Tree/treeTransition.test.ts diff --git a/test/webview/ui/treeController.test.tsx b/test/webview/ui/components/Tree/useTreeAdapter.test.tsx similarity index 100% rename from test/webview/ui/treeController.test.tsx rename to test/webview/ui/components/Tree/useTreeAdapter.test.tsx diff --git a/test/webview/ui/components.test.tsx b/test/webview/ui/components/components.test.tsx similarity index 99% rename from test/webview/ui/components.test.tsx rename to test/webview/ui/components/components.test.tsx index ee21d6c6d7..a660d87b4b 100644 --- a/test/webview/ui/components.test.tsx +++ b/test/webview/ui/components/components.test.tsx @@ -13,7 +13,7 @@ import { TooltipProvider, } from "@repo/ui"; -import { qs } from "../helpers"; +import { qs } from "../../helpers"; describe("Icon", () => { it("renders a decorative codicon by default", () => { diff --git a/test/webview/ui/forms.test.tsx b/test/webview/ui/components/forms.test.tsx similarity index 99% rename from test/webview/ui/forms.test.tsx rename to test/webview/ui/components/forms.test.tsx index 4a12f1ae61..78acf9dc54 100644 --- a/test/webview/ui/forms.test.tsx +++ b/test/webview/ui/components/forms.test.tsx @@ -18,7 +18,7 @@ import { Textarea, } from "@repo/ui"; -import { qs } from "../helpers"; +import { qs } from "../../helpers"; const ROOT_STYLING = { className: "custom-root", style: { width: "200px" } }; diff --git a/test/webview/ui/overlays.test.tsx b/test/webview/ui/components/overlays.test.tsx similarity index 100% rename from test/webview/ui/overlays.test.tsx rename to test/webview/ui/components/overlays.test.tsx diff --git a/test/webview/shared/api.test.ts b/test/webview/webview-shared/api.test.ts similarity index 100% rename from test/webview/shared/api.test.ts rename to test/webview/webview-shared/api.test.ts diff --git a/test/webview/shared/dom.test.ts b/test/webview/webview-shared/dom.test.ts similarity index 100% rename from test/webview/shared/dom.test.ts rename to test/webview/webview-shared/dom.test.ts diff --git a/test/webview/shared/ipc.test.ts b/test/webview/webview-shared/ipc.test.ts similarity index 100% rename from test/webview/shared/ipc.test.ts rename to test/webview/webview-shared/ipc.test.ts diff --git a/test/webview/shared/ErrorBoundary.test.tsx b/test/webview/webview-shared/react/ErrorBoundary.test.tsx similarity index 100% rename from test/webview/shared/ErrorBoundary.test.tsx rename to test/webview/webview-shared/react/ErrorBoundary.test.tsx diff --git a/test/webview/shared/hooks.test.ts b/test/webview/webview-shared/react/hooks.test.ts similarity index 100% rename from test/webview/shared/hooks.test.ts rename to test/webview/webview-shared/react/hooks.test.ts diff --git a/test/webview/shared/useIpc.test.ts b/test/webview/webview-shared/react/useIpc.test.ts similarity index 100% rename from test/webview/shared/useIpc.test.ts rename to test/webview/webview-shared/react/useIpc.test.ts diff --git a/test/webview/workspaces/useWorkspaces.test.tsx b/test/webview/workspaces/hooks/useWorkspaces.test.tsx similarity index 100% rename from test/webview/workspaces/useWorkspaces.test.tsx rename to test/webview/workspaces/hooks/useWorkspaces.test.tsx From 4f95c795f00dc1f92dd719d28c6e5f18dc17e501 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Fri, 9 Oct 2026 17:42:01 +0300 Subject: [PATCH 2/2] chore: clean up root files, tooling config, and agent docs Root files: - Fold usage.md into the README and move demo.gif into media/. The README links it by relative path, which vsce rewrites to the default branch. - Move esbuild.mjs to scripts/ and the integration test config to test/integration/. - Replace .vscodeignore with a package.json "files" allowlist; vsce ls produces the same file list. - Merge .oxlintrc.react.jsonc into .oxlintrc.jsonc, listing the typescript and oxc plugins the extended file pulled in, so the resolved config is identical. - Remove the Prettier leftovers; Oxfmt formats every file type with its own bundled Prettier. - Keep the ESLint cache in node_modules and trim .gitignore. Dependencies and environment: - Drop devDependencies nothing loads since the Oxlint migration. - Update the Nix flake: pin nixpkgs, use Node 24 and nixpkgs' pnpm, and stop pnpm downloading a prebuilt binary that cannot run on NixOS. - Make the VS Code launch config build before running the extension. Docs: - Restructure AGENTS.md after coder/coder: a short root file that routes to topic guides in docs/ (code structure, architecture, testing, tooling) and packages/AGENTS.md for webviews, kept short since agents load them on every task. CONTRIBUTING.md keeps setup, PR and review guidance, and releasing. - Adopt the coder/coder rules that apply here, plus Testing Library's own testing guidance, and forbid em and en dashes as punctuation. - Match the generated SSH config example to the current output, and drop the requirement to close VS Code before integration tests. --- .devcontainer/devcontainer.json | 2 +- .gitignore | 10 +- .oxfmtrc.jsonc | 9 +- .oxlintrc.jsonc | 91 ++++- .oxlintrc.react.jsonc | 87 ----- .prettierignore | 18 - .prettierrc.json | 10 - .vscode/launch.json | 3 +- .vscode/tasks.json | 10 +- .vscodeignore | 66 ---- AGENTS.md | 279 +++++--------- CONTRIBUTING.md | 352 +++--------------- README.md | 11 +- docs/ARCHITECTURE.md | 182 +++++++++ CODE_STRUCTURE.md => docs/CODE_STRUCTURE.md | 4 +- docs/TESTING.md | 87 +++++ docs/TOOLING.md | 85 +++++ eslint.config.mjs | 2 + flake.lock | 12 +- flake.nix | 14 +- demo.gif => media/demo.gif | Bin package.json | 23 +- packages/AGENTS.md | 91 +++++ pnpm-lock.yaml | 95 +---- esbuild.mjs => scripts/esbuild.mjs | 0 .../integration/.vscode-test.mjs | 6 +- usage.md | 17 - 27 files changed, 736 insertions(+), 830 deletions(-) delete mode 100644 .oxlintrc.react.jsonc delete mode 100644 .prettierignore delete mode 100644 .prettierrc.json delete mode 100644 .vscodeignore create mode 100644 docs/ARCHITECTURE.md rename CODE_STRUCTURE.md => docs/CODE_STRUCTURE.md (97%) create mode 100644 docs/TESTING.md create mode 100644 docs/TOOLING.md rename demo.gif => media/demo.gif (100%) create mode 100644 packages/AGENTS.md rename esbuild.mjs => scripts/esbuild.mjs (100%) rename .vscode-test.mjs => test/integration/.vscode-test.mjs (81%) delete mode 100644 usage.md diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 9e388b559f..18304e2e44 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -7,7 +7,7 @@ "customizations": { "vscode": { "extensions": [ - "esbenp.prettier-vscode", + "oxc.oxc-vscode", "dbaeumer.vscode-eslint", "vitest.explorer", "ms-vscode.extension-test-runner" diff --git a/.gitignore b/.gitignore index 3002a72a7b..9829365498 100644 --- a/.gitignore +++ b/.gitignore @@ -1,17 +1,15 @@ +node_modules/ /dist/ -/node_modules/ /out/ /.vscode-test/ -/.nyc_output/ /coverage/ /.pnpm-store/ *.vsix pnpm-debug.log -.eslintcache - -# Workspace packages -packages/*/node_modules/ # Storybook storybook-static/ debug-storybook.log + +# Personal agent instructions +AGENTS.local.md diff --git a/.oxfmtrc.jsonc b/.oxfmtrc.jsonc index 957addc988..cd2940a01e 100644 --- a/.oxfmtrc.jsonc +++ b/.oxfmtrc.jsonc @@ -3,16 +3,11 @@ "printWidth": 80, // Disabled to avoid churning the key order of every package.json in the repo. "sortPackageJson": false, + // Oxfmt also skips everything in .gitignore. "ignorePatterns": [ - "/dist/", - "/node_modules/", - "/out/", - "/.vscode-test/", - "/coverage/", - "*.vsix", "flake.lock", "pnpm-lock.yaml", - "/storybook-static/", + // Golden snapshots and generated VS Code theme dumps are written verbatim. "**/__golden__/", ".storybook/themes/generated/" ], diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index 5477ce4c9a..030a401c01 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -1,7 +1,6 @@ { "$schema": "./node_modules/oxlint/configuration_schema.json", - "extends": [".oxlintrc.react.jsonc"], - "plugins": ["unicorn"], + "plugins": ["unicorn", "typescript", "oxc"], "categories": { "correctness": "error" }, @@ -21,6 +20,88 @@ "storybook-static/**" ], "overrides": [ + { + "files": ["packages/**/*.{ts,tsx}"], + "rules": { + "react/exhaustive-deps": "off", + "react/preserve-manual-memoization": "error", + "react/incompatible-library": "warn", + "react/error-boundaries": "error", + "react/globals": "error", + "react/immutability": "error", + "react/jsx-no-comment-textnodes": "warn", + "react/no-array-index-key": "warn", + "react/no-clone-element": "warn", + "react/no-direct-mutation-state": "error", + "react/purity": "warn", + "react/refs": "error", + "react/rules-of-hooks": "error", + "react/set-state-in-effect": "warn", + "react/set-state-in-render": "error", + "react/static-components": "error", + "react/unsupported-syntax": "error", + "react/use-memo": "error", + "react-x/dom-no-dangerously-set-innerhtml-with-children": "error", + "react-x/dom-no-dangerously-set-innerhtml": "warn", + "react-x/dom-no-find-dom-node": "error", + "react-x/dom-no-flush-sync": "error", + "react-x/dom-no-hydrate": "error", + "react-x/dom-no-render-return-value": "error", + "react-x/dom-no-render": "error", + "react-x/dom-no-script-url": "warn", + "react-x/dom-no-unsafe-iframe-sandbox": "warn", + "react-x/dom-no-use-form-state": "error", + "react-x/dom-no-void-elements-with-children": "error", + "react-x/jsx-no-children-prop-with-children": "error", + "react-x/jsx-no-children-prop": "warn", + "react-x/jsx-no-key-after-spread": "error", + "react-x/jsx-no-leaked-dollar": "error", + "react-x/jsx-no-leaked-semicolon": "warn", + "react-x/jsx-no-namespace": "error", + "react-x/naming-convention-context-name": "warn", + "react-x/naming-convention-id-name": "warn", + "react-x/naming-convention-ref-name": "warn", + "react-x/no-access-state-in-setstate": "error", + "react-x/no-children-count": "warn", + "react-x/no-children-for-each": "warn", + "react-x/no-children-map": "warn", + "react-x/no-children-only": "warn", + "react-x/no-children-to-array": "warn", + "react-x/no-component-will-mount": "error", + "react-x/no-component-will-receive-props": "error", + "react-x/no-component-will-update": "error", + "react-x/no-context-provider": "warn", + "react-x/no-create-ref": "error", + "react-x/no-forward-ref": "warn", + "react-x/no-missing-key": "error", + "react-x/no-nested-component-definitions": "error", + "react-x/no-nested-lazy-component-declarations": "error", + "react-x/no-set-state-in-component-did-mount": "warn", + "react-x/no-set-state-in-component-did-update": "warn", + "react-x/no-set-state-in-component-will-update": "warn", + "react-x/no-unnecessary-use-prefix": "warn", + "react-x/no-unsafe-component-will-mount": "warn", + "react-x/no-unsafe-component-will-receive-props": "warn", + "react-x/no-unsafe-component-will-update": "warn", + "react-x/no-unused-class-component-members": "warn", + "react-x/no-use-context": "warn", + "react-x/rsc-function-definition": "error", + "react-x/use-state": "warn", + "react-x/web-api-no-leaked-event-listener": "warn", + "react-x/web-api-no-leaked-fetch": "error", + "react-x/web-api-no-leaked-intersection-observer": "warn", + "react-x/web-api-no-leaked-interval": "warn", + "react-x/web-api-no-leaked-resize-observer": "warn", + "react-x/web-api-no-leaked-timeout": "warn" + }, + "jsPlugins": [ + { + "name": "react-x", + "specifier": "./node_modules/@eslint-react/eslint-plugin/dist/index.js" + } + ], + "plugins": ["react"] + }, { "files": ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"], "plugins": ["typescript", "import"], @@ -166,11 +247,7 @@ } }, { - "files": [ - "esbuild.mjs", - "scripts/*.mjs", - ".storybook/themes/*.{mjs,cjs}" - ], + "files": ["scripts/*.mjs", ".storybook/themes/*.{mjs,cjs}"], "env": { "node": true } diff --git a/.oxlintrc.react.jsonc b/.oxlintrc.react.jsonc deleted file mode 100644 index b2423bd5d4..0000000000 --- a/.oxlintrc.react.jsonc +++ /dev/null @@ -1,87 +0,0 @@ -{ - "$schema": "./node_modules/oxlint/configuration_schema.json", - "overrides": [ - { - "files": ["packages/**/*.{ts,tsx}"], - "rules": { - "react/exhaustive-deps": "off", - "react/preserve-manual-memoization": "error", - "react/incompatible-library": "warn", - "react/error-boundaries": "error", - "react/globals": "error", - "react/immutability": "error", - "react/jsx-no-comment-textnodes": "warn", - "react/no-array-index-key": "warn", - "react/no-clone-element": "warn", - "react/no-direct-mutation-state": "error", - "react/purity": "warn", - "react/refs": "error", - "react/rules-of-hooks": "error", - "react/set-state-in-effect": "warn", - "react/set-state-in-render": "error", - "react/static-components": "error", - "react/unsupported-syntax": "error", - "react/use-memo": "error", - "react-x/dom-no-dangerously-set-innerhtml-with-children": "error", - "react-x/dom-no-dangerously-set-innerhtml": "warn", - "react-x/dom-no-find-dom-node": "error", - "react-x/dom-no-flush-sync": "error", - "react-x/dom-no-hydrate": "error", - "react-x/dom-no-render-return-value": "error", - "react-x/dom-no-render": "error", - "react-x/dom-no-script-url": "warn", - "react-x/dom-no-unsafe-iframe-sandbox": "warn", - "react-x/dom-no-use-form-state": "error", - "react-x/dom-no-void-elements-with-children": "error", - "react-x/jsx-no-children-prop-with-children": "error", - "react-x/jsx-no-children-prop": "warn", - "react-x/jsx-no-key-after-spread": "error", - "react-x/jsx-no-leaked-dollar": "error", - "react-x/jsx-no-leaked-semicolon": "warn", - "react-x/jsx-no-namespace": "error", - "react-x/naming-convention-context-name": "warn", - "react-x/naming-convention-id-name": "warn", - "react-x/naming-convention-ref-name": "warn", - "react-x/no-access-state-in-setstate": "error", - "react-x/no-children-count": "warn", - "react-x/no-children-for-each": "warn", - "react-x/no-children-map": "warn", - "react-x/no-children-only": "warn", - "react-x/no-children-to-array": "warn", - "react-x/no-component-will-mount": "error", - "react-x/no-component-will-receive-props": "error", - "react-x/no-component-will-update": "error", - "react-x/no-context-provider": "warn", - "react-x/no-create-ref": "error", - "react-x/no-forward-ref": "warn", - "react-x/no-missing-key": "error", - "react-x/no-nested-component-definitions": "error", - "react-x/no-nested-lazy-component-declarations": "error", - "react-x/no-set-state-in-component-did-mount": "warn", - "react-x/no-set-state-in-component-did-update": "warn", - "react-x/no-set-state-in-component-will-update": "warn", - "react-x/no-unnecessary-use-prefix": "warn", - "react-x/no-unsafe-component-will-mount": "warn", - "react-x/no-unsafe-component-will-receive-props": "warn", - "react-x/no-unsafe-component-will-update": "warn", - "react-x/no-unused-class-component-members": "warn", - "react-x/no-use-context": "warn", - "react-x/rsc-function-definition": "error", - "react-x/use-state": "warn", - "react-x/web-api-no-leaked-event-listener": "warn", - "react-x/web-api-no-leaked-fetch": "error", - "react-x/web-api-no-leaked-intersection-observer": "warn", - "react-x/web-api-no-leaked-interval": "warn", - "react-x/web-api-no-leaked-resize-observer": "warn", - "react-x/web-api-no-leaked-timeout": "warn" - }, - "jsPlugins": [ - { - "name": "react-x", - "specifier": "./node_modules/@eslint-react/eslint-plugin/dist/index.js" - } - ], - "plugins": ["react"] - } - ] -} diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index e40f7d14f8..0000000000 --- a/.prettierignore +++ /dev/null @@ -1,18 +0,0 @@ -/dist/ -/node_modules/ -/out/ -/.vscode-test/ -/.nyc_output/ -/coverage/ -/.claude/ -*.vsix -flake.lock -pnpm-debug.log -pnpm-lock.yaml -/storybook-static/ - -# Golden snapshots are written verbatim; prettier would drift them out of sync. -**/__golden__/ - -# Generated VS Code theme dumps are written verbatim, never formatted. -.storybook/themes/generated/ \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json deleted file mode 100644 index d9c1738b9e..0000000000 --- a/.prettierrc.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "overrides": [ - { - "files": "*.jsonc", - "options": { - "trailingComma": "none" - } - } - ] -} diff --git a/.vscode/launch.json b/.vscode/launch.json index a5b3ea737f..64187dc357 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -6,7 +6,8 @@ "type": "extensionHost", "request": "launch", "args": ["--extensionDevelopmentPath=${workspaceFolder}"], - "outFiles": ["${workspaceFolder}/dist/**/*.js"] + "outFiles": ["${workspaceFolder}/dist/**/*.js"], + "preLaunchTask": "build" } ] } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 214329b2cf..abe0eb6577 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -2,11 +2,11 @@ "version": "2.0.0", "tasks": [ { - "type": "typescript", - "tsconfig": "tsconfig.json", - "problemMatcher": ["$tsc"], - "group": "build", - "label": "tsc: build" + "label": "build", + "type": "npm", + "script": "build", + "group": { "kind": "build", "isDefault": true }, + "problemMatcher": [] } ] } diff --git a/.vscodeignore b/.vscodeignore deleted file mode 100644 index 9e785a0cc6..0000000000 --- a/.vscodeignore +++ /dev/null @@ -1,66 +0,0 @@ -# Test and coverage output -out/** -coverage/** -.nyc_output/** - -# Development files -src/** -test/** -scripts/** -**/*.ts -**/*.map - -# Configuration files -.vscode/** -.vscode-test/** -.vscode-test.mjs -tsconfig.json -eslint.config.mjs -.oxlintrc.jsonc -.oxlintrc.react.jsonc -.oxfmtrc.jsonc -.eslintcache -esbuild.mjs -vitest.config.mts -pixel.jsonc -.editorconfig -.prettierignore -**/.gitignore -**/.git-blame-ignore-revs - -# Package manager files -pnpm-lock.yaml -pnpm-workspace.yaml -pnpm-debug.log -.pnpm-store/** - -# Webview packages (exclude everything except built output in dist/webviews) -packages/** -!dist/webviews/** - -# Nix/flake files -flake.nix -flake.lock -*.nix - -# Dependencies -node_modules/** - -# Development tools and CI -.devcontainer/** -.github/** -.claude/** - -# Documentation and media -usage.md -AGENTS.md -CODE_STRUCTURE.md -*.gif - -# Build artifacts -*.vsix - -# Storybook -.storybook/** -storybook-static/** -**/*.stories.* diff --git a/AGENTS.md b/AGENTS.md index e123cee839..a5c895f455 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,183 +1,100 @@ # Coder Extension Development Guidelines -You are an experienced, pragmatic software engineer. Simple solutions -over clever ones. Readability is a primary concern. - -## Our Relationship - -We're colleagues - push back on bad ideas and speak up when something -doesn't make sense. Honesty over agreeableness. - -- Disagree when I'm wrong - act as a critical peer reviewer -- Call out bad ideas, unreasonable expectations, and mistakes -- Ask for clarification rather than making assumptions -- Discuss architectural decisions before implementation; - routine fixes don't need discussion - -## Foundational Rules - -- Doing it right is better than doing it fast -- YAGNI - don't add features we don't need right now -- Make the smallest reasonable changes to achieve the goal -- Reduce code duplication, even if it takes extra effort -- Match the style of surrounding code - consistency within a file matters -- Fix bugs immediately when you find them - -## Code Structure - -[CODE_STRUCTURE.md](CODE_STRUCTURE.md) is the single source for where code -lives: the extension/webview boundary and package dependencies, where new -code goes, topic-named modules (no `utils` or `helpers`), file naming and -test placement. Follow it when adding, moving or splitting modules. - -## Essential Commands - -| Task | Command | -| ------------------------- | --------------------------------------------------- | -| **Build** | `pnpm build` | -| **Watch mode** | `pnpm watch` | -| **Package** | `pnpm package` | -| **Type check** | `pnpm typecheck` | -| **Format** | `pnpm format` | -| **Format check** | `pnpm format:check` | -| **Lint** | `pnpm lint` | -| **Lint with auto-fix** | `pnpm lint:fix` | -| **All unit tests** | `pnpm test` | -| **Extension tests** | `pnpm test:extension` | -| **Webview tests** | `pnpm test:webview` | -| **Integration tests** | `pnpm test:integration` | -| **Single extension test** | `pnpm test:extension ./test/unit/filename.test.ts` | -| **Single webview test** | `pnpm test:webview ./test/webview/filename.test.ts` | -| **Sync webview themes** | `pnpm sync:vscode-themes` | - -Integration tests and the theme sync launch VS Code and need a display. On -headless environments (CI, devcontainers) prefix with `xvfb-run -a`: - -```sh -xvfb-run -a pnpm test:integration -``` - -## Linting and Formatting - -Linting runs in two stages via `pnpm lint`: - -1. **Oxlint** (`.oxlintrc.jsonc`): all JS/TS/TSX rules, including type-aware - rules via `oxlint-tsgolint`. -2. **ESLint** (`eslint.config.mjs`): a small residual set Oxlint cannot do. - See [CONTRIBUTING.md](CONTRIBUTING.md#linting) for the exhaustive list. - -When editing `.oxlintrc.jsonc`: - -- `overrides[].files` does not support extglob alternatives like `@(ts|tsx)`. - They silently match nothing (oxc-project/oxc#21525). Use brace globs - (`{ts,tsx}`) or list extensions. -- `settings` is not supported inside `overrides`, and `no-restricted-imports` - patterns only understand `**` and literal paths, not single `*`. - -`test/unit/oxlintConfig.test.ts` guards both regressions. - -## Testing - -- Test observable behavior and outputs, not implementation details -- Descriptive names, minimal setup, no shared mutable state -- Never mock in end-to-end tests; minimize mocking in unit tests -- Find root causes, not symptoms - read error messages carefully -- When mocking constructors (classes) with - `vi.mocked(...).mockImplementation()`, use regular functions, not arrow - functions. Arrow functions can't be called with `new`. - -```typescript -// Wrong -vi.mocked(SomeClass).mockImplementation(() => mock); -// Correct -vi.mocked(SomeClass).mockImplementation(function () { - return mock; -}); -``` - -### Test File Organization - -```text -test/ -├── unit/ # Extension unit tests (mirrors src/) -├── webview/ # Webview unit tests (mirrors packages//src/) -├── integration/ # VS Code integration tests (uses Mocha, not Vitest) -├── utils/ # Test utilities that are also tested -└── mocks/ # Shared test mocks -``` - -## Webviews - -When adding or modifying a panel, follow `packages/webview-shared/README.md`. -It is the single source of truth for the IPC contract, exhaustive handler -maps, and the visibility/theme re-send guarantee. - -Non-negotiables: - -- Never hand-roll `window.addEventListener("message", ...)` or - `postMessage({ method, params })`. Use `onNotification` / `sendCommand` - (vanilla) or `useIpc` (React) from `@repo/webview-shared`. -- Extension panels must call **both** `buildCommandHandlers` and - `buildRequestHandlers` (empty `{}` is fine). This gives a compile error - when anyone adds an action to the API without a matching handler. -- Every webview and Storybook build runs the React Compiler, so components - and hooks must follow the rules of React: no reading or writing a ref - during render, no mutating props, state, or anything already rendered, - and hooks called unconditionally. A component that breaks them is skipped - silently and loses its memoization. Parameter defaults that read another - prop (`focused = adapter?.focusedId === row.node.id`) are the usual - culprit; put those defaults in the body. `useMemo` and `useCallback` are - rarely needed, and when kept they must list every dependency, or - `react-hooks/preserve-manual-memoization` fails the lint. - -## Code Style - -- TypeScript with strict typing -- Use Oxlint for code linting (`.oxlintrc.jsonc`) and Oxfmt for formatting. - A residual ESLint config covers `import-x/order`, Markdown, and - `package.json` -- Use ES6 features (arrow functions, destructuring, etc.) -- Use `const` by default; `let` only when necessary -- Never use `any` - use exact types when possible -- Avoid `as unknown as` - fix the types instead -- Prefix unused variables with underscore (e.g., `_unused`) -- Error handling: wrap and type errors appropriately -- Use async/await for promises, avoid explicit Promise construction where - possible -- Unit test files must be named `*.test.ts` and use Vitest -- Test files mirror their source path (see - [CODE_STRUCTURE.md](CODE_STRUCTURE.md#tests)) -- Never disable lint rules without user approval - -### Naming and Comments - -Names should describe what code does, not how it's implemented. - -Comments explain what code does or why it exists: - -- Never add comments about what used to be there or how things changed -- Never use temporal terms like "new", "improved", "refactored", "legacy" -- Code should be evergreen - describe it as it is -- Do not add comments when you can instead use proper variable/function - naming - -### Avoid Unnecessary Changes - -When fixing a bug or adding a feature, don't modify code unrelated to your -task. Unnecessary changes make PRs harder to review and can introduce -regressions. - -Don't reword existing comments or code unless the change is directly -motivated by your task. Don't delete existing comments that explain -non-obvious behavior. - -When adding tests for existing behavior, read existing tests first to -understand what's covered. Add cases for uncovered behavior. Edit existing -tests as needed, but don't change what they verify. - -## Version Control - -- Commit frequently throughout development -- Never skip or disable pre-commit hooks -- Check `git status` before using `git add` -- Don't use `git push --force` unless explicitly requested +The Coder Remote VS Code extension: an extension host (`src/`) and webview +panels (`packages/`) sharing typed contracts through `@repo/shared`. + +Make the smallest correct change, follow existing patterns, and verify the +result. Prefer simple, readable solutions over clever ones; doing it right +beats doing it fast. We're colleagues: prioritize correctness over agreement, +push back on bad ideas and mistakes with evidence, and state uncertainty +instead of guessing. + +## Autonomy + +- Resolve routine ambiguity from the code, tests, docs and git history. Make + reasonable, reversible assumptions that match existing patterns, state the + consequential ones, and keep going. +- Ask only when the answer can't be recovered and would change the result, + or before a destructive or irreversible action. +- Discuss architectural decisions first: new packages, dependencies, or + patterns that cross features. Routine fixes need no discussion. +- Requests to implement, fix or investigate authorize that work even when + phrased as a question. Answer informational questions directly. + +## Guides + +Before changing anything under `packages/` or `test/webview/`, read +[packages/AGENTS.md](packages/AGENTS.md); not every agent loads nested +`AGENTS.md` files on its own. Otherwise read only what the task needs: + +- Where code goes, naming, test placement: [docs/CODE_STRUCTURE.md](docs/CODE_STRUCTURE.md) +- Tests: [docs/TESTING.md](docs/TESTING.md) +- Opening workspaces, SSH config, logging: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) +- Telemetry: [src/instrumentation/CONVENTIONS.md](src/instrumentation/CONVENTIONS.md) +- Lint, format, TypeScript, Node.js, dependencies: [docs/TOOLING.md](docs/TOOLING.md) +- Running, PRs, reviews, releasing: [CONTRIBUTING.md](CONTRIBUTING.md) + +## Commands + +- Build: `pnpm build`, `pnpm watch`, `pnpm package` +- Check: `pnpm typecheck`, `pnpm lint` (`lint:fix`), `pnpm format:check` + (`format`) +- Unit tests: `pnpm test`, or `pnpm test:extension` / `pnpm test:webview` + with an optional file path +- Integration tests: `pnpm test:integration` +- Storybook: `pnpm storybook`; theme snapshots: `pnpm sync:vscode-themes` + +Integration tests and the theme sync launch VS Code; on headless machines +prefix them with `xvfb-run -a`. Run targeted tests while iterating, and +before handoff run typecheck, lint, format check and the affected tests. + +## Guardrails + +- Register `coder.*` commands through `CommandManager` and set context keys + through `ContextManager` (`src/vscode/`); lint rejects direct calls. +- Read `env.remoteAuthority` through `vscodeProposed`; plain `vscode` throws. +- Never give a `coder.*` setting `"scope": "machine"`; use `application` + (the README explains why). +- Webviews talk to the extension only through the typed IPC helpers. +- Emit telemetry through the domain's instrumentation class and list new + events in `src/instrumentation/EVENTS.md`. +- Add a `CHANGELOG.md` entry for changes users should know about. +- Never disable a lint rule without user approval. + +## Code style + +- Strict TypeScript: no `any`, `as unknown as`, or non-null assertions + outside tests. Prefer + annotations and narrowing over `as`; fix types at the source. +- Use the generated API types from `coder/site/src/api/typesGenerated`; never + redeclare them. +- ES6 features, `const` over `let`, async/await over explicit Promises, + `_`-prefixed unused variables, wrapped and typed errors. +- YAGNI. Search for existing code before writing new code, reduce + duplication, and delete dead code. +- Match the surrounding style. Fix bugs when you find them. +- Names say what code does, never how it's implemented or its history + (`JsonParser`, `LegacyHandler`). Use pattern names only when they add + clarity, and abbreviate only when obvious. +- Comments must earn their place: only non-obvious invariants, external + constraints or tradeoffs, in one to three lines. Never restate the code, + comment where a better name would do, or describe how code changed, and avoid words like "new", "improved" or + "legacy". Re-read every comment your diff adds before committing. +- JSDoc exported functions and types whose contract isn't obvious. +- No em dashes, en dashes or spaced double hyphens as punctuation in code, + comments, strings or docs. +- Keep each change to one purpose. Don't touch unrelated code, reword + existing comments, or delete comments that explain non-obvious behavior. +- Navigate with the TypeScript language server when available. + +## Version control + +- Commit often. Titles use Conventional Commits (`type(scope): message`), + imperative, around 70 characters; CI checks PR titles. +- Never skip hooks. Check `git status` before `git add`. Never force push + unless asked. +- Linear links branches containing `-`: use a Linear ID only + for its own issue, and write GitHub issue numbers as `issue-`. + +Read `AGENTS.local.md` when present; it is gitignored, for personal +instructions. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d5c407e362..e5adbd2f15 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,249 +1,21 @@ # Contributing -## Architecture - -Where code lives and how modules are named is described in -[CODE_STRUCTURE.md](CODE_STRUCTURE.md). - -When the Coder Remote plugin handles a request to open a workspace, it invokes -Microsoft's [Remote - SSH](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-ssh) -extension using the following URI structure: - -```text -vscode://ssh-remote+ -``` - -The `ssh-remote` scheme is registered by Microsoft's Remote - SSH extension and -indicates that it should connect to the provided host name using SSH. - -The host name takes the format -`coder-.----`, where `` comes from -that product's URI scheme, such as `vscode`, `cursor`, or `devin`/`windsurf`. The CLI is -invoked through SSH's `ProxyCommand` with this prefix so it can route SSH to the -right workspace. A legacy `coder-vscode` authority opened in another editor is -reopened once with that editor's prefix; legacy recent-folder entries remain -compatible when opening the same workspace. - -The Coder Remote extension also registers for the -`onResolveRemoteAuthority:ssh-remote` [extension activation -event](https://code.visualstudio.com/api/references/activation-events) to hook -into this process, running before the Remote - SSH extension actually connects. - -On activation of this event, we check whether the remote authority belongs to -the current editor, and if so we delay activation to: - -1. Parse the host name to get the domain, username, and workspace. -2. Ensure the workspace is running. -3. Download the matching server binary to the client. -4. Configure the binary with the URL and token, asking the user for them if they - are missing. Each domain gets its own config directory. -5. Write an entry for `coder-.--*` to a per-editor, - per-deployment file in a data directory shared by every editor, such as - `~/.local/share/coder.coder-remote/ssh/cursor--dev.coder.com.conf`. -6. Keep a shared `Include` block at the top of the user's SSH config that - globs the whole directory. Every editor writes the identical block, so - concurrent writers converge on the same content. The `CODER INCLUDE ` - marker convention lets other Coder integrations recognize the block, since - Coder-managed includes route disjoint hosts and are order-independent. - -```text -# --- START CODER INCLUDE CODER-REMOTE --- -# Managed by the Coder extension for VS Code and its forks. -# Moves back to the top on connect; override options via coder.sshConfig. -Include "~/.local/share/coder.coder-remote/ssh/*.conf" -# --- END CODER INCLUDE CODER-REMOTE --- -``` - -Each generated file contains only its own editor's host entries for one -deployment: - -```text -Host coder-cursor.dev.coder.com--* - ProxyCommand "/tmp/coder" --global-config "/home/kyle/.config/Cursor/User/globalStorage/coder.coder-remote/dev.coder.com" ssh --stdio --network-info-dir "/home/kyle/.config/Cursor/User/globalStorage/coder.coder-remote/net" --ssh-host-prefix coder-cursor.dev.coder.com-- %h - ConnectTimeout 0 - StrictHostKeyChecking no - UserKnownHostsFile /dev/null - LogLevel ERROR -``` - -Which main file gains the include depends on the Remote - SSH extension. -Microsoft's and Cursor's pass `remote.SSH.configFile` to ssh with `-F`, and -VSCodium's parses the file itself instead of running ssh, so all three connect -through it. Antigravity and Windsurf/Devin renamed the setting but spawn ssh -without `-F`, so ssh reads `~/.ssh/config` regardless; we ignore the renamed -setting there rather than add the include where the connection never looks. - -If any step fails, we show an error message. Once the error message is closed -we close the remote so the Remote - SSH connection does not continue to -connection. Otherwise, we yield, which lets the Remote - SSH continue. - -VS Code SSH uses the `ssh -D ` flag to start a SOCKS server on the -specified port. This port is printed to the `Remote - SSH` log file in the VS -Code Output panel in the format `-> socksPort ->`. We use this port to -find the SSH process ID that is being used by the remote session. - -The `ssh` subcommand on the `coder` binary periodically flushes its network -information to `network-info-dir + "/" + process.ppid`. SSH executes -`ProxyCommand`, which means the `process.ppid` will always be the matching SSH -command. - -Coder Remote periodically reads the `network-info-dir + "/" + matchingSSHPID` -file to display network information. - -### Windows SSH config permissions - -Windows files inherit their permissions from the directory they live in, so a -config the extension generates under `%APPDATA%\coder.coder-remote\ssh` can end -up readable by other accounts. OpenSSH rejects such a file with "Bad owner or -permissions" and skips the whole `Include`, which blocks every Coder host, not -just the one it came from. - -Before each managed write, `src/remote/windowsAcl.ts` locks the directory down -and lets its files inherit from it: - -| Step | Command | -| -------------------------------------------------------- | ---------------------------------------------------- | -| Read the current user's SID | `whoami.exe /user /fo csv /nh` | -| Clear the directory's own grants | `icacls.exe /reset` | -| Grant that user, SYSTEM, and Administrators full control | `icacls.exe /inheritance:r /grant:r ` | -| Clear each `*.conf` file so it inherits the directory | `icacls.exe /reset` | - -Resetting every `*.conf` file, not only the one being written, also repairs -files left behind by other deployments and editors. - -Worth knowing: - -- Like VS Code, the code checks exit codes but never reads ACLs back. It needs - no script, native module, ownership change, or elevation, and it leaves the - user's own SSH config alone. -- Links and non-files are rejected before the repair, because inheritable - grants reach children even without `/T`. That stops mistakes, not an attacker - racing the check. -- The repair is not atomic: a failure after `/reset` can leave the directory - with its parent's grants. - -`windowsAcl.native.test.ts` drives the real `icacls.exe`, `whoami.exe`, and -OpenSSH. Run it unelevated as well as in CI to catch privilege assumptions. - -## Other features - -The extension provides several sidebar panels: - -- **My Workspaces / All Workspaces** - tree views showing workspaces with status - indicators, quick actions, and search. -- **Coder Tasks** - a React webview for creating, monitoring, and managing AI - agent tasks with real-time log streaming. - -There are also notifications for outdated workspace templates and for workspaces -that are close to shutting down. - -## Webviews - -The extension ships rich UI panels as webviews built with Vite, organized as a -pnpm workspace in `packages/`. The canonical guide for building one covers -the IPC contract, exhaustiveness rules, the "no dropped events" guarantee, -and a new-panel checklist. It lives next to the code: - -**[`packages/webview-shared/README.md`](packages/webview-shared/README.md)** - -Existing webviews as references: - -- `packages/tasks` + `src/webviews/tasks/`: React (uses `useIpc`). -- `packages/speedtest` + `src/webviews/speedtest/`: vanilla TS (uses - `onNotification` / `sendCommand`). - -### Development - -```bash -pnpm watch # Rebuild extension and webviews on changes -``` - -Press F5 to launch the Extension Development Host. Use "Developer: Reload -Webviews" to see webview changes. - -## Telemetry - -Local telemetry instrumentation follows a shared style: how spans are threaded, -how events and properties are named, and properties vs measurements. Read this -before adding new telemetry so it stays consistent across the codebase. It lives -next to the code: - -**[`src/instrumentation/CONVENTIONS.md`](src/instrumentation/CONVENTIONS.md)** - -## Logging - -The extension logs to the "Coder" output channel, a `LogOutputChannel` that gates -messages by the level chosen in its gear menu. To help Support diagnose -connection failures without asking users to reproduce with debug logging enabled, -a `FlightRecorder` ([`src/logging/flightRecorder.ts`](src/logging/flightRecorder.ts)) -wraps the channel and keeps a bounded, in-memory ring of the entries that sit -**below** the current level, which the channel would otherwise drop. - -When a WebSocket fails terminally, a remote session closes after a failed or -canceled open, or you collect a support bundle, the extension replays the ring -into the channel. The first physical line of each replayed entry carries a -`[buffered]` marker with its original timestamp and level, and any continuation -lines carry the bare marker. -Capture is best-effort: the channel writes on its own schedule, so a bundle may -miss the most recent lines, but a later failure flush still replays them. -Transient reconnects and intentional teardown never flush, and neither does a -handshake `401` (a 401 explains itself, and with OAuth a refresh reconnects the -same socket). Nothing is recorded or flushed while the channel is at `Off`. - -The buffer size is set by `coder.connectionLogBuffer.size` (number of entries; -`0` disables it) and lives in memory, so a hard kill or out-of-memory event -loses it. Extension SSH debug logs that pass through the shared logger are -buffered; the CLI `ProxyCommand` file logs under `coder.proxyLogDirectory` are -not, since support bundles already collect them from disk. - -## Testing - -There are a few ways you can test the "Open in VS Code" flow: - -- Use the "VS Code Desktop" button from a Coder dashboard. -- Manually open the link with `Developer: Open URL` from inside VS Code. -- Use `code --open-url` on the command line. - -The link format is `vscode://coder.coder-remote/open?${query}`. For example: - -```bash -code --open-url 'vscode://coder.coder-remote/open?url=dev.coder.com&owner=my-username&workspace=my-ws&agent=my-agent' -``` - -### Unit Tests - -The project uses Vitest with separate test configurations for extension and webview code: - -```bash -pnpm test:extension # Extension tests (runs in Electron) -pnpm test:webview # Webview tests (runs in Electron with jsdom) -pnpm test # Both extension and webview tests (CI mode) -``` - -Test files are organized by type: - -```text -test/ -├── unit/ # Extension unit tests (mirrors src/) -├── webview/ # Webview unit tests (mirrors packages//src/, jsdom) -├── integration/ # Integration tests (real VS Code) -└── mocks/ # Shared test mocks -``` - -### Integration Tests - -Integration tests run inside a real VS Code instance: - -```bash -pnpm test:integration -``` - -**Limitations:** - -- Must use Mocha (VS Code test runner requirement), not Vitest -- Cannot run while another VS Code instance is open (they share state) -- Requires closing VS Code or running in a clean environment -- Test files in `test/integration/` are compiled to `out/` before running +Start with [Development](#development) to build and run the extension. These +guides cover the rest; they serve humans and coding agents alike, and +[AGENTS.md](AGENTS.md) routes agents to them. + +- [Code structure](docs/CODE_STRUCTURE.md): where code lives, module and file + naming, and test placement. +- [Architecture](docs/ARCHITECTURE.md): how opening a workspace works, SSH + config management, Windows permissions and connection logging. +- [Testing](docs/TESTING.md): unit, webview and integration tests, and + testing the open flow by hand. +- [Tooling](docs/TOOLING.md): linting, formatting, TypeScript, Node.js and + dependency upgrades. +- [Webviews](packages/AGENTS.md): React, `@repo/ui`, Storybook and the + [IPC contract](packages/webview-shared/README.md). +- [Telemetry](src/instrumentation/CONVENTIONS.md): how to add events, and the + [event catalog](src/instrumentation/EVENTS.md). ## Development @@ -277,70 +49,40 @@ Alternatively: 4. If your change is something users ought to be aware of, add an entry in the changelog. -## Linting - -Linting runs in two stages: [Oxlint](https://oxc.rs) handles all JS/TS/TSX -rules, and a residual ESLint pass covers what Oxlint cannot do yet. When an -Oxlint equivalent lands, remove the corresponding entry from -`eslint.config.mjs` and this list. - -| ESLint rule/plugin | Why Oxlint can't do it | -| ----------------------------------------------------------- | ------------------------------------------------------------------------------ | -| `import-x/order` | Oxlint has no import ordering rule; Oxfmt's `sortImports` reorders differently | -| `@eslint/markdown` (`markdown/no-missing-label-refs`, etc.) | Oxlint only lints source extensions; Markdown needs processors | -| `eslint-plugin-package-json` (58 rules) | Oxlint only lints source extensions | - -`eslint-plugin-oxlint` reads `.oxlintrc.jsonc` and disables every rule Oxlint -already covers, so the two stages never overlap. - -## TypeScript Version - -TypeScript 7 has no programmatic API yet, so `typescript-eslint` cannot load -against it. The two run side-by-side, following the -[official guidance](https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/): - -- `typescript` is aliased to `@typescript/typescript6`: the 6.0 API that - `typescript-eslint` consumes. Its binary is `tsc6`. -- `@typescript/native` is aliased to `typescript@^7`: the `tsc` binary used by - `pnpm typecheck` and editors. - -When TypeScript 7 ships an API, drop `@typescript/native` and point `typescript` -back at a single version. - -## Node.js Version +### Webviews -This extension targets the Node.js version bundled with VS Code's Electron: - -| VS Code | Electron | Node.js | Status | -| ------- | -------- | ------- | ----------------- | -| 1.105 | 37 | 22 | Minimum supported | -| stable | latest | varies | Also tested in CI | - -When updating the minimum Node.js version, update these files: - -- **package.json**: `engines.vscode`, `engines.node`, `@types/node`, `@tsconfig/nodeXX` -- **tsconfig.json**: `extends` (the `@tsconfig/nodeXX` package), `lib` (match base ESNext version) -- **esbuild.mjs**: `target` -- **.github/workflows/ci.yaml**: `electron-version` and `vscode-version` matrices - -## Dependencies - -Some dependencies are not directly used in the source but are required anyway. - -- `bufferutil` and `utf-8-validate` are peer dependencies of `ws`. Their source - builds are off, so Windows on ARM64 uses their JavaScript fallback. -- `ua-parser-js` and `dayjs` are used by the Coder API client. +```bash +pnpm watch # Rebuild extension and webviews on changes +``` -The coder client is vendored from coder/coder. Pin it to a release tag in -`pnpm-workspace.yaml` (e.g. `coder: github:coder/coder#v2.33.1`), not `#main`. -A tag gives reproducible builds and is not re-fetched on every `pnpm install`, -unlike `#main` which can drift between installs. To update, bump the tag and -run `pnpm install` (or `pnpm update coder`). +Press F5 to launch the Extension Development Host. Use "Developer: Reload +Webviews" to see webview changes. -After running `pnpm update`, always run `pnpm dedupe` to consolidate duplicate -package versions across the workspace. Without this, workspace packages can -resolve to different versions of the same dependency, causing issues like broken -React context propagation when two copies of a library are loaded. +## Pull requests + +- Titles use Conventional Commits (`type(scope): message`); CI checks them + against the types in `.github/workflows/pr-title-lint.yaml`. +- Open PRs as drafts unless asked otherwise, so the author reviews them + before requesting reviewers. +- Keep the description short: what changed and why, in one or two + paragraphs. Use Summary, Problem and Fix sections only when the change + needs them. +- Link related issues and PRs. Add screenshots for UI changes and numbers + for performance changes. +- Leave out test plans, "benefits" sections, line-by-line walkthroughs and + marketing language. Let GitHub wrap the prose instead of hard-wrapping it. + +## Reviewing + +- Read the full files and related code before commenting, and check + [AGENTS.md](AGENTS.md) before flagging style. +- Report only issues you're confident are real, state their impact ("crashes + when X", not "could be better"), and make each one actionable. +- State correctness and security findings as facts, not "might" or "could"; + verify how an API behaves instead of guessing. +- Don't flag style that matches existing patterns, unchanged code, + theoretical issues without a concrete impact, or changes outside the PR's + purpose. ## Releasing diff --git a/README.md b/README.md index 2010fcfa58..47c7f39252 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Discord"](https://badgen.net/discord/online-members/coder)](https://coder.com/ch The Coder Remote extension connects your editor to [Coder](https://github.com/coder/coder) workspaces with a single click. -![Demo](https://github.com/coder/vscode-coder/raw/main/demo.gif?raw=true) +![Demo](media/demo.gif) ## Features @@ -104,3 +104,12 @@ Opens a dev container running inside a workspace. | `localWorkspaceFolder` | No | Local path of the dev container project. Required if `localConfigFile` is set. | | `localConfigFile` | No | Local path to the `devcontainer.json` file. | | `url`, `token` | No | Same as `/open`. | + +## Sharing Extensions with code-server + +Extensions installed in a remote window live in `~/.vscode-server/extensions` +on the workspace. To reuse them in code-server, point it at that directory: + +```shell +code-server --extensions-dir ~/.vscode-server/extensions +``` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000000..98d38400cb --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,182 @@ +# Architecture + +How the extension opens workspaces and the internals worth knowing before +changing them. Where code lives is covered in +[CODE_STRUCTURE.md](CODE_STRUCTURE.md). + +## Opening a workspace + +When the Coder Remote plugin handles a request to open a workspace, it invokes +Microsoft's [Remote - SSH](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-ssh) +extension using the following URI structure: + +```text +vscode://ssh-remote+ +``` + +The `ssh-remote` scheme is registered by Microsoft's Remote - SSH extension and +indicates that it should connect to the provided host name using SSH. + +The host name takes the format +`coder-.----`, where `` comes from +that product's URI scheme, such as `vscode`, `cursor`, or `devin`/`windsurf`. The CLI is +invoked through SSH's `ProxyCommand` with this prefix so it can route SSH to the +right workspace. A legacy `coder-vscode` authority opened in another editor is +reopened once with that editor's prefix; legacy recent-folder entries remain +compatible when opening the same workspace. + +The Coder Remote extension also registers for the +`onResolveRemoteAuthority:ssh-remote` [extension activation +event](https://code.visualstudio.com/api/references/activation-events) to hook +into this process, running before the Remote - SSH extension actually connects. + +On activation of this event, we check whether the remote authority belongs to +the current editor, and if so we delay activation to: + +1. Parse the host name to get the domain, username, and workspace. +2. Ensure the workspace is running. +3. Download the matching server binary to the client. +4. Configure the binary with the URL and token, asking the user for them if they + are missing. Each domain gets its own config directory. +5. Write an entry for `coder-.--*` to a per-editor, + per-deployment file in a data directory shared by every editor, such as + `~/.local/share/coder.coder-remote/ssh/cursor--dev.coder.com.conf`. +6. Keep a shared `Include` block at the top of the user's SSH config that + globs the whole directory. Every editor writes the identical block, so + concurrent writers converge on the same content. The `CODER INCLUDE ` + marker convention lets other Coder integrations recognize the block, since + Coder-managed includes route disjoint hosts and are order-independent. + +```text +# --- START CODER INCLUDE CODER-REMOTE --- +# Managed by the Coder extension for VS Code and its forks. +# Moves back to the top on connect; override options via coder.sshConfig. +Include "~/.local/share/coder.coder-remote/ssh/*.conf" +# --- END CODER INCLUDE CODER-REMOTE --- +``` + +Each generated file contains only its own editor's host entries for one +deployment, with keys sorted. The extension rewrites the whole file on every +connection: + +```text +# Coder workspace hosts. Do not edit; the Coder extension rewrites this file +# on every connection. Override options with the "coder.sshConfig" setting. + +Host coder-cursor.dev.coder.com--* + ConnectTimeout 0 + LogLevel ERROR + ProxyCommand /tmp/coder --global-config /home/kyle/.config/Cursor/User/globalStorage/coder.coder-remote/dev.coder.com ssh --stdio --usage-app=cursor --network-info-dir /home/kyle/.config/Cursor/User/globalStorage/coder.coder-remote/net --ssh-host-prefix coder-cursor.dev.coder.com-- %h + ServerAliveCountMax 3 + ServerAliveInterval 10 + SetEnv CODER_SSH_SESSION_TYPE=cursor + StrictHostKeyChecking no + UserKnownHostsFile /dev/null +``` + +Options merge from three sources, highest priority first: the +`coder.sshConfig` setting, `--ssh-option` flags from a `coder config-ssh` +block, and the deployment's SSH config. Only the deployment's options are +deny-listed (`DENIED_DEPLOYMENT_KEYS` in `src/remote/sshConfig.ts`), since the +other two are the user's own. `SetEnv` is written only when the local ssh +supports it, and `--usage-app` only when both the CLI and the deployment +accept custom session app names; otherwise the session reports as `vscode`. +CLIs without wildcard host support get a `coder vscodessh` ProxyCommand +instead. + +Which main file gains the include depends on the Remote - SSH extension. +Microsoft's and Cursor's pass `remote.SSH.configFile` to ssh with `-F`, and +VSCodium's parses the file itself instead of running ssh, so all three connect +through it. Antigravity and Windsurf/Devin renamed the setting but spawn ssh +without `-F`, so ssh reads `~/.ssh/config` regardless; we ignore the renamed +setting there rather than add the include where the connection never looks. + +If any step fails, we show an error message. Once the error message is closed +we close the remote so the Remote - SSH connection does not continue to +connection. Otherwise, we yield, which lets the Remote - SSH continue. + +VS Code SSH uses the `ssh -D ` flag to start a SOCKS server on the +specified port. This port is printed to the `Remote - SSH` log file in the VS +Code Output panel in the format `-> socksPort ->`. We use this port to +find the SSH process ID that is being used by the remote session. + +The `ssh` subcommand on the `coder` binary periodically flushes its network +information to `network-info-dir + "/" + process.ppid`. SSH executes +`ProxyCommand`, which means the `process.ppid` will always be the matching SSH +command. + +Coder Remote periodically reads the `network-info-dir + "/" + matchingSSHPID` +file to display network information. + +### Windows SSH config permissions + +Windows files inherit their permissions from the directory they live in, so a +config the extension generates under `%APPDATA%\coder.coder-remote\ssh` can end +up readable by other accounts. OpenSSH rejects such a file with "Bad owner or +permissions" and skips the whole `Include`, which blocks every Coder host, not +just the one it came from. + +Before each managed write, `src/remote/windowsAcl.ts` locks the directory down +and lets its files inherit from it: + +| Step | Command | +| -------------------------------------------------------- | ---------------------------------------------------- | +| Read the current user's SID | `whoami.exe /user /fo csv /nh` | +| Clear the directory's own grants | `icacls.exe /reset` | +| Grant that user, SYSTEM, and Administrators full control | `icacls.exe /inheritance:r /grant:r ` | +| Clear each `*.conf` file so it inherits the directory | `icacls.exe /reset` | + +Resetting every `*.conf` file, not only the one being written, also repairs +files left behind by other deployments and editors. + +Worth knowing: + +- Like VS Code, the code checks exit codes but never reads ACLs back. It needs + no script, native module, ownership change, or elevation, and it leaves the + user's own SSH config alone. +- Links and non-files are rejected before the repair, because inheritable + grants reach children even without `/T`. That stops mistakes, not an attacker + racing the check. +- The repair is not atomic: a failure after `/reset` can leave the directory + with its parent's grants. + +`windowsAcl.native.test.ts` drives the real `icacls.exe`, `whoami.exe`, and +OpenSSH. Run it unelevated as well as in CI to catch privilege assumptions. + +## Other features + +The extension provides several sidebar panels: + +- **My Workspaces / All Workspaces** - tree views showing workspaces with status + indicators, quick actions, and search. +- **Coder Tasks** - a React webview for creating, monitoring, and managing AI + agent tasks with real-time log streaming. + +There are also notifications for outdated workspace templates and for workspaces +that are close to shutting down. + +## Logging + +The extension logs to the "Coder" output channel, a `LogOutputChannel` that gates +messages by the level chosen in its gear menu. To help Support diagnose +connection failures without asking users to reproduce with debug logging enabled, +a `FlightRecorder` ([`src/logging/flightRecorder.ts`](../src/logging/flightRecorder.ts)) +wraps the channel and keeps a bounded, in-memory ring of the entries that sit +**below** the current level, which the channel would otherwise drop. + +When a WebSocket fails terminally, a remote session closes after a failed or +canceled open, or you collect a support bundle, the extension replays the ring +into the channel. The first physical line of each replayed entry carries a +`[buffered]` marker with its original timestamp and level, and any continuation +lines carry the bare marker. +Capture is best-effort: the channel writes on its own schedule, so a bundle may +miss the most recent lines, but a later failure flush still replays them. +Transient reconnects and intentional teardown never flush, and neither does a +handshake `401` (a 401 explains itself, and with OAuth a refresh reconnects the +same socket). Nothing is recorded or flushed while the channel is at `Off`. + +The buffer size is set by `coder.connectionLogBuffer.size` (number of entries; +`0` disables it) and lives in memory, so a hard kill or out-of-memory event +loses it. Extension SSH debug logs that pass through the shared logger are +buffered; the CLI `ProxyCommand` file logs under `coder.proxyLogDirectory` are +not, since support bundles already collect them from disk. diff --git a/CODE_STRUCTURE.md b/docs/CODE_STRUCTURE.md similarity index 97% rename from CODE_STRUCTURE.md rename to docs/CODE_STRUCTURE.md index 1724881fb1..9294b167b5 100644 --- a/CODE_STRUCTURE.md +++ b/docs/CODE_STRUCTURE.md @@ -17,7 +17,7 @@ Rules for code on either side: - **Cross the boundary only with messages.** The extension and webviews talk through the typed IPC contracts in `@repo/shared` (see the - [`webview-shared` README](packages/webview-shared/README.md)), never by + [`webview-shared` README](../packages/webview-shared/README.md)), never by importing each other's code. - **Keep `@repo/shared` runtime-free.** Use plain TypeScript, with no Node built-ins, DOM or `vscode`. When behavior differs by runtime, take it as a @@ -45,7 +45,7 @@ built to be split into its own package later: A component that knows about Coder, tasks or workspaces belongs in a panel package, even if it is built from `@repo/ui` parts. The -[`ui` README](packages/ui/README.md) covers the details. +[`ui` README](../packages/ui/README.md) covers the details. ## Where new code goes diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000000..2d11f1bf19 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,87 @@ +# Testing + +Where test files go is covered in +[CODE_STRUCTURE.md](CODE_STRUCTURE.md#tests). Webview-specific guidance lives +in [packages/AGENTS.md](../packages/AGENTS.md#tests). + +## Principles + +- Test observable behavior and outputs, not implementation details. +- Descriptive names, minimal setup, no shared mutable state. +- Never mock in end-to-end tests; minimize mocking in unit tests. +- Find root causes, not symptoms: read error messages carefully. +- Cover the failure paths, not only the happy path. +- Never wait on real time to fix a timing problem. Instead of sleeping with + `setTimeout`, use `vi.useFakeTimers()` and advance the clock, or await the + event or promise that signals the condition. Sleeps flake under load. +- Extend existing coverage instead of adding a test that duplicates it. +- When adding tests for existing behavior, read the existing tests first and + add cases for what is not covered. Edit existing tests as needed, but don't + change what they verify. +- When mocking constructors (classes) with + `vi.mocked(...).mockImplementation()`, use regular functions, not arrow + functions. Arrow functions can't be called with `new`. + +```typescript +// Wrong +vi.mocked(SomeClass).mockImplementation(() => mock); +// Correct +vi.mocked(SomeClass).mockImplementation(function () { + return mock; +}); +``` + +## Unit Tests + +The project uses Vitest with separate projects for extension and webview code. +Both run inside Electron's Node.js, matching the runtime VS Code ships: + +```bash +pnpm test:extension # Extension tests +pnpm test:webview # Webview tests (jsdom) +pnpm test # Both (CI mode) +pnpm test:extension ./test/unit/filename.test.ts # A single file +``` + +Test files are named `*.test.ts` and organized by type: + +```text +test/ +├── unit/ # Extension unit tests (mirrors src/) +├── webview/ # Webview unit tests (mirrors packages//src/, jsdom) +├── integration/ # Integration tests (real VS Code, Mocha) +├── utils/ # Test utilities that are also tested +└── mocks/ # Shared test mocks +``` + +## Integration Tests + +Integration tests run inside a real VS Code instance, against both the minimum +supported version and stable (see `test/integration/.vscode-test.mjs`): + +```bash +pnpm test:integration +``` + +They need a display. On headless environments (CI, devcontainers) prefix the +command with `xvfb-run -a`. + +- Tests use Mocha (VS Code test runner requirement), not Vitest. +- Test files in `test/integration/` are compiled to `out/` before running. +- The first run downloads each VS Code version into `.vscode-test/`. Tests + run in that copy with its own user data directory, so an open VS Code + doesn't interfere. + +## Testing the open flow by hand + +There are a few ways you can test the "Open in VS Code" flow: + +- Use the "VS Code Desktop" button from a Coder dashboard. +- Manually open the link with `Developer: Open URL` from inside VS Code. +- Use `code --open-url` on the command line. + +The link format is `vscode://coder.coder-remote/open?${query}`. For example: + +```bash +code --open-url 'vscode://coder.coder-remote/open?url=dev.coder.com&owner=my-username&workspace=my-ws&agent=my-agent' +``` diff --git a/docs/TOOLING.md b/docs/TOOLING.md new file mode 100644 index 0000000000..d4b7b2f7fb --- /dev/null +++ b/docs/TOOLING.md @@ -0,0 +1,85 @@ +# Tooling + +How linting, formatting, TypeScript, Node.js and dependencies are set up, and +what to update when changing them. + +## Linting + +Linting runs in two stages: [Oxlint](https://oxc.rs) handles all JS/TS/TSX +rules, and a residual ESLint pass covers what Oxlint cannot do yet. When an +Oxlint equivalent lands, remove the corresponding entry from +`eslint.config.mjs` and this list. + +| ESLint rule/plugin | Why Oxlint can't do it | +| ----------------------------------------------------------- | ------------------------------------------------------------------------------ | +| `import-x/order` | Oxlint has no import ordering rule; Oxfmt's `sortImports` reorders differently | +| `@eslint/markdown` (`markdown/no-missing-label-refs`, etc.) | Oxlint only lints source extensions; Markdown needs processors | +| `eslint-plugin-package-json` (58 rules) | Oxlint only lints source extensions | + +`eslint-plugin-oxlint` reads `.oxlintrc.jsonc` and disables every rule Oxlint +already covers, so the two stages never overlap. + +When editing `.oxlintrc.jsonc`: + +- `overrides[].files` does not support extglob alternatives like `@(ts|tsx)`. + They silently match nothing (oxc-project/oxc#21525). Use brace globs + (`{ts,tsx}`) or list extensions. +- `settings` is not supported inside `overrides`, and `no-restricted-imports` + patterns only understand `**` and literal paths, not single `*`. + +`test/unit/oxlintConfig.test.ts` guards both regressions. + +## Formatting + +Oxfmt formats every file type in the repo, using its own bundled Prettier for +Markdown, YAML and CSS. Its config is `.oxfmtrc.jsonc`, and it also skips +everything in `.gitignore`. + +## TypeScript Version + +TypeScript 7 has no programmatic API yet, so `typescript-eslint` cannot load +against it. The two run side-by-side, following the +[official guidance](https://devblogs.microsoft.com/typescript/announcing-typescript-7-0/): + +- `typescript` is aliased to `@typescript/typescript6`: the 6.0 API that + `typescript-eslint` consumes. Its binary is `tsc6`. +- `@typescript/native` is aliased to `typescript@^7`: the `tsc` binary used by + `pnpm typecheck` and editors. + +When TypeScript 7 ships an API, drop `@typescript/native` and point `typescript` +back at a single version. + +## Node.js Version + +This extension targets the Node.js version bundled with VS Code's Electron: + +| VS Code | Electron | Node.js | Status | +| ------- | -------- | ------- | ----------------- | +| 1.105 | 37 | 22 | Minimum supported | +| stable | latest | varies | Also tested in CI | + +When updating the minimum Node.js version, update these files: + +- **package.json**: `engines.vscode`, `engines.node`, `@types/node`, `@tsconfig/nodeXX` +- **tsconfig.json**: `extends` (the `@tsconfig/nodeXX` package), `lib` (match base ESNext version) +- **scripts/esbuild.mjs**: `target` +- **.github/workflows/ci.yaml**: `electron-version` and `vscode-version` matrices + +## Dependencies + +Some dependencies are not directly used in the source but are required anyway. + +- `bufferutil` and `utf-8-validate` are peer dependencies of `ws`. Their source + builds are off, so Windows on ARM64 uses their JavaScript fallback. +- `ua-parser-js` and `dayjs` are used by the Coder API client. + +The coder client is vendored from coder/coder. Pin it to a release tag in +`pnpm-workspace.yaml` (e.g. `coder: github:coder/coder#v2.33.1`), not `#main`. +A tag gives reproducible builds and is not re-fetched on every `pnpm install`, +unlike `#main` which can drift between installs. To update, bump the tag and +run `pnpm install` (or `pnpm update coder`). + +After running `pnpm update`, always run `pnpm dedupe` to consolidate duplicate +package versions across the workspace. Without this, workspace packages can +resolve to different versions of the same dependency, causing issues like broken +React context propagation when two copies of a library are loaded. diff --git a/eslint.config.mjs b/eslint.config.mjs index 7ef58e4223..dd11a8c2c4 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -89,6 +89,8 @@ export default defineConfig( "package-json/require-files": "off", "package-json/require-sideEffects": "off", "package-json/require-attribution": "off", + // vsce, unlike npm, only packages LICENSE when "files" lists it. + "package-json/no-redundant-files": "off", }, }, diff --git a/flake.lock b/flake.lock index 5b84be3f50..fd698adbc8 100644 --- a/flake.lock +++ b/flake.lock @@ -20,16 +20,18 @@ }, "nixpkgs": { "locked": { - "lastModified": 1752997324, - "narHash": "sha256-vtTM4oDke3SeDj+1ey6DjmzXdq8ZZSCLWSaApADDvIE=", + "lastModified": 1791456040, + "narHash": "sha256-H83yz/do+hjOO0uujHyH8RgtIQP2v7Mw+cLwcZK/xKc=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "7c688a0875df5a8c28a53fb55ae45e94eae0dddb", + "rev": "e7439b6b14ad3cc35d05608ebca9bce01a25f5f8", "type": "github" }, "original": { - "id": "nixpkgs", - "type": "indirect" + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" } }, "root": { diff --git a/flake.nix b/flake.nix index 36155ac2ee..fb13839b8c 100644 --- a/flake.nix +++ b/flake.nix @@ -1,17 +1,25 @@ { description = "vscode-coder"; - inputs.flake-utils.url = "github:numtide/flake-utils"; + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + }; - outputs = { self, nixpkgs, flake-utils }: + outputs = { nixpkgs, flake-utils, ... }: flake-utils.lib.eachDefaultSystem (system: let pkgs = nixpkgs.legacyPackages.${system}; in { devShells.default = pkgs.mkShell { + # Node 24 matches the devcontainer; CI also covers the Node 22 minimum. nativeBuildInputs = with pkgs; [ - nodejs nodePackages.pnpm + nodejs_24 + pnpm ]; + # pnpm would otherwise download the packageManager version from + # package.json, a prebuilt binary that cannot run on NixOS. + pnpm_config_manage_package_manager_versions = "false"; }; } ); diff --git a/demo.gif b/media/demo.gif similarity index 100% rename from demo.gif rename to media/demo.gif diff --git a/package.json b/package.json index c092eccb4d..4eaf7ed8cf 100644 --- a/package.json +++ b/package.json @@ -17,15 +17,22 @@ "publisher": "coder", "type": "commonjs", "main": "./dist/extension.js", + "files": [ + "dist/**/*.{js,css,ttf}", + "media/*.{png,svg}", + "pgp-public.key", + "CHANGELOG.md", + "LICENSE" + ], "scripts": { - "build": "concurrently -g -n webviews,extension \"pnpm build:webviews\" \"node esbuild.mjs\"", + "build": "concurrently -g -n webviews,extension \"pnpm build:webviews\" \"node scripts/esbuild.mjs\"", "build:production": "cross-env NODE_ENV=production pnpm build", "build:test": "tsc -p test/integration --outDir out --noCheck", "build:webviews": "pnpm -r --filter \"./packages/*\" --parallel build", "format": "node scripts/run-tool.mjs oxfmt --write .", "format:check": "node scripts/run-tool.mjs oxfmt --check .", "lint": "concurrently -g -n oxlint,eslint \"pnpm lint:oxlint\" \"pnpm lint:eslint\"", - "lint:eslint": "node scripts/run-tool.mjs eslint --cache --cache-strategy content .", + "lint:eslint": "node scripts/run-tool.mjs eslint --cache --cache-strategy content --cache-location node_modules/.cache/eslint/ .", "lint:fix": "pnpm lint:oxlint --fix && pnpm lint:eslint --fix", "lint:oxlint": "node scripts/run-tool.mjs oxlint --type-aware .", "package": "pnpm build:production && vsce package --no-dependencies", @@ -36,12 +43,12 @@ "sync:vscode-themes": "node .storybook/themes/sync.mjs", "test": "cross-env CI=true ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs", "test:extension": "cross-env ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs --project extension", - "test:integration": "pnpm build:test && node esbuild.mjs && vscode-test", + "test:integration": "pnpm build:test && node scripts/esbuild.mjs && vscode-test --config test/integration/.vscode-test.mjs", "test:webview": "cross-env ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs --project webview", "typecheck": "concurrently -g -n extension,tests,packages,storybook \"tsc --noEmit\" \"tsc --noEmit -p test\" \"pnpm typecheck:packages\" \"tsc --noEmit -p .storybook\"", "typecheck:packages": "pnpm -r --filter \"./packages/*\" --parallel typecheck", "watch": "concurrently -g -n extension,webviews \"pnpm watch:extension\" \"pnpm watch:webviews\"", - "watch:extension": "node esbuild.mjs --watch", + "watch:extension": "node scripts/esbuild.mjs --watch", "watch:webviews": "pnpm -r --filter \"./packages/*\" --parallel dev" }, "contributes": { @@ -815,7 +822,6 @@ "devDependencies": { "@coder/pixel-storybook": "^0.3.0", "@eslint-react/eslint-plugin": "^5.24.9", - "@eslint/js": "^10.0.1", "@eslint/markdown": "^8.0.3", "@repo/mocks": "workspace:*", "@rolldown/plugin-babel": "catalog:", @@ -837,8 +843,6 @@ "@types/vscode": "1.105.0", "@types/vscode-webview": "catalog:", "@types/ws": "^8.18.2", - "@typescript-eslint/eslint-plugin": "^8.71.0", - "@typescript-eslint/parser": "^8.71.0", "@typescript/native": "npm:typescript@^7.0.2", "@vitejs/plugin-react": "catalog:", "@vitest/coverage-v8": "^5.0.3", @@ -855,23 +859,18 @@ "electron": "44.4.1", "esbuild": "^0.28.2", "eslint": "^10.11.0", - "eslint-config-prettier": "^10.1.8", "eslint-import-resolver-typescript": "^4.4.5", "eslint-plugin-import-x": "^4.17.1", "eslint-plugin-oxlint": "^1.86.0", "eslint-plugin-package-json": "^1.10.1", - "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-storybook": "^10.6.1", - "globals": "^17.13.0", "jsdom": "^30.1.2", - "jsonc-eslint-parser": "^3.3.0", "memfs": "^4.80.0", "oxfmt": "^0.71.0", "oxlint": "^1.86.0", "oxlint-plugin-eslint": "^1.86.0", "oxlint-tsgolint": "^7.0.2003", "playwright-core": "^1.63.0", - "prettier": "^3.9.9", "react": "catalog:", "react-dom": "catalog:", "storybook": "catalog:", diff --git a/packages/AGENTS.md b/packages/AGENTS.md new file mode 100644 index 0000000000..9e5a0910ff --- /dev/null +++ b/packages/AGENTS.md @@ -0,0 +1,91 @@ +# Webview Development Guidelines + +Webviews are isolated browser contexts with no Node.js or `vscode` access. +Each panel is a Vite package here; its extension side is in +`src/webviews//`. Package boundaries: +[CODE_STRUCTURE.md](../docs/CODE_STRUCTURE.md). + +## IPC + +[webview-shared/README.md](webview-shared/README.md) defines the IPC +contract and the new-panel checklist. + +- Never hand-roll `window.addEventListener("message", ...)` or + `postMessage`. Use `onNotification` / `sendCommand` (vanilla) or `useIpc` + (React) from `@repo/webview-shared`. +- Extension panels call both `buildCommandHandlers` and + `buildRequestHandlers` (`{}` is fine), so an API method without a handler + fails to compile. +- Panels that push data re-send it on visibility and theme changes with + `onWhileVisible`; hidden webviews lose state and canvases keep old colors. + +## React + +The React Compiler runs on every build, so follow the rules of React: no +reading or writing refs during render, no mutating props, state or rendered +values, hooks called unconditionally. A component that breaks them silently +loses its memoization. + +- Put parameter defaults that read another prop + (`focused = adapter?.focusedId === row.node.id`) in the body. +- `useMemo` / `useCallback` are rarely needed; when kept they must list every + dependency or lint fails. +- Effects are a last resort. Compute derived values during render, handle + user actions in event handlers, and load requested data with React Query + (pushed data arrives through `useIpc`). + Use `useEffect` only to sync with an external system, and audit every + dependency so it doesn't resubscribe on each update. +- Build React Query keys from the panel's key factory (`queryKeys` in + `tasks/src/config.ts`); never retype one as a literal, and export it if + needed. `isLoading` means + no data yet and `isFetching` includes refetches: don't blank valid data + while refetching. Invalidate every affected query after a mutation, even + on partial failure. Prefer `mutate()` with callbacks; never wrap + `mutateAsync()` in an empty `catch`. +- Views handle loading (spinner, never blank), error (actionable message), + empty (deliberate copy) and refetch (keep data, never reset input or + selection). Show a fallback ("Unknown") for missing text. + +## Accessibility + +- Every interactive element is keyboard reachable, including the reason a + control is disabled. +- The accessible name contains the visible label (WCAG 2.5.3); don't replace + it with an unrelated `aria-label`. +- Generate IDs for labels and `aria-*` with `useId`; hardcoded IDs collide. +- Hidden interactive elements leave the tab order too; prefer not rendering + them. +- Check what a primitive does with `aria-*` and `role` before setting them. + +## `@repo/ui` and Storybook + +- `@repo/ui` holds generic VS Code-style components + ([README](ui/README.md)). It imports no other workspace package and styles + only through `--ui-*` tokens. Anything that knows about Coder belongs in a + panel package. Reuse its components before writing new ones. +- Stories sit next to components as `Foo.stories.tsx` and render in captured + VS Code themes ([.storybook/README.md](../.storybook/README.md)). +- Pixel screenshots every story in CI after its `play` function, in light + and dark (`pixel.jsonc`); `PIXEL_ALL_THEMES` (`@repo/ui`'s `#storybook`) in + `parameters.pixel` adds the high-contrast themes. +- `@repo/mocks` and `@repo/storybook-utils` are for stories and tests only. + +## Tests + +Tests live in `test/webview//` and run in jsdom (`pnpm test:webview`). +[docs/TESTING.md](../docs/TESTING.md) has the general rules. These follow +Testing Library's guidance; apply them to tests you add or change: + +- Use `const user = userEvent.setup()` before rendering, not `fireEvent`, + except for events `user-event` can't produce. +- Assert what the interaction did (IPC message, callback arguments, state); + stories cover how it looks. +- Query by role and accessible name. `data-testid` only for elements with no + role or name; never `querySelector` or class names. +- Use `find*` instead of `waitFor` around `get*`, and `query*` only to assert + absence. Keep `waitFor` to one assertion and no side effects. +- Stay deterministic: time as a prop or fake timers, explicit locales. +- Stub globals with `vi.stubGlobal` (undo in-test stubs with + `vi.unstubAllGlobals()`), never `Object.defineProperty`. +- Wrap React Query components with `renderWithQuery` + (`test/webview/render.tsx`). diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1169a8773a..091f0ceb03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -306,9 +306,6 @@ importers: '@eslint-react/eslint-plugin': specifier: ^5.24.9 version: 5.24.9(@typescript/typescript6@6.0.2)(eslint@10.11.0)(supports-color@8.1.1) - '@eslint/js': - specifier: ^10.0.1 - version: 10.0.1(eslint@10.11.0) '@eslint/markdown': specifier: ^8.0.3 version: 8.0.3(supports-color@8.1.1) @@ -372,12 +369,6 @@ importers: '@types/ws': specifier: ^8.18.2 version: 8.18.2 - '@typescript-eslint/eslint-plugin': - specifier: ^8.71.0 - version: 8.71.0(@typescript-eslint/parser@8.71.0)(@typescript/typescript6@6.0.2)(eslint@10.11.0)(supports-color@8.1.1) - '@typescript-eslint/parser': - specifier: ^8.71.0 - version: 8.71.0(@typescript/typescript6@6.0.2)(eslint@10.11.0)(supports-color@8.1.1) '@typescript/native': specifier: npm:typescript@^7.0.2 version: typescript@7.0.2 @@ -426,9 +417,6 @@ importers: eslint: specifier: ^10.11.0 version: 10.11.0(supports-color@8.1.1) - eslint-config-prettier: - specifier: ^10.1.8 - version: 10.1.8(eslint@10.11.0) eslint-import-resolver-typescript: specifier: ^4.4.5 version: 4.4.5(eslint-plugin-import-x@4.17.1)(eslint@10.11.0)(supports-color@8.1.1) @@ -441,21 +429,12 @@ importers: eslint-plugin-package-json: specifier: ^1.10.1 version: 1.10.1(eslint@10.11.0) - eslint-plugin-react-hooks: - specifier: ^7.1.1 - version: 7.1.1(eslint@10.11.0)(supports-color@8.1.1) eslint-plugin-storybook: specifier: ^10.6.1 version: 10.6.1(@typescript/typescript6@6.0.2)(eslint@10.11.0)(supports-color@8.1.1) - globals: - specifier: ^17.13.0 - version: 17.13.0 jsdom: specifier: ^30.1.2 version: 30.1.2 - jsonc-eslint-parser: - specifier: ^3.3.0 - version: 3.3.0 memfs: specifier: ^4.80.0 version: 4.80.0 @@ -474,9 +453,6 @@ importers: playwright-core: specifier: ^1.63.0 version: 1.63.0 - prettier: - specifier: ^3.9.9 - version: 3.9.9 react: specifier: 'catalog:' version: 19.3.0 @@ -1251,15 +1227,6 @@ packages: resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@eslint/js@10.0.1': - resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - peerDependencies: - eslint: ^10.0.0 - peerDependenciesMeta: - eslint: - optional: true - '@eslint/markdown@8.0.3': resolution: {integrity: sha512-rBTSSShrq7e4O+PWfeE4azH4/CWPNrC+VGxBXiW00o3vYVJnznsZiDayj3KC9JztIMVRZxZHn2nrDIUau/4j7A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -3505,12 +3472,6 @@ packages: engines: {node: '>=6.0'} hasBin: true - eslint-config-prettier@10.1.8: - resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} - hasBin: true - peerDependencies: - eslint: '>=7.0.0' - eslint-fix-utils@0.4.4: resolution: {integrity: sha512-9reP0fw++eZv0qs0ji8U1G9a+j8yWQW1RT8Wrlw32JlhQJDiweVX98kyDrJq6G7jdOLgKwmj0nPghyMadsOgHQ==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -3592,12 +3553,6 @@ packages: eslint: optional: true - eslint-plugin-react-hooks@7.1.1: - resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} - engines: {node: '>=18'} - peerDependencies: - eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 - eslint-plugin-react-jsx@5.24.9: resolution: {integrity: sha512-AuLn4Z43eM2NMEM6C0ZGAt5EfoNJ6Xcw/jd91L0lWI747C4PC5GXih5WoRBprWUlvJtkQvl5FXNClHv90h73gQ==} engines: {node: '>=22.0.0'} @@ -3847,10 +3802,6 @@ packages: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} - globals@17.13.0: - resolution: {integrity: sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==} - engines: {node: '>=18'} - gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -3882,12 +3833,6 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true - hermes-estree@0.25.1: - resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} - - hermes-parser@0.25.1: - resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} - hookified@1.15.1: resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} @@ -5458,12 +5403,6 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} - zod-validation-error@4.0.2: - resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} - engines: {node: '>=18.0.0'} - peerDependencies: - zod: ^3.25.0 || ^4.0.0 - zod@3.23.8: resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} @@ -6060,10 +5999,6 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/js@10.0.1(eslint@10.11.0)': - optionalDependencies: - eslint: 10.11.0(supports-color@8.1.1) - '@eslint/markdown@8.0.3(supports-color@8.1.1)': dependencies: '@eslint/core': 1.2.1 @@ -8060,10 +7995,6 @@ snapshots: optionalDependencies: source-map: 0.6.1 - eslint-config-prettier@10.1.8(eslint@10.11.0): - dependencies: - eslint: 10.11.0(supports-color@8.1.1) - eslint-fix-utils@0.4.4(@types/estree@1.0.9)(eslint@10.11.0): dependencies: eslint: 10.11.0(supports-color@8.1.1) @@ -8153,17 +8084,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-react-hooks@7.1.1(eslint@10.11.0)(supports-color@8.1.1): - dependencies: - '@babel/core': 7.29.7(supports-color@8.1.1) - '@babel/parser': 7.29.9 - eslint: 10.11.0(supports-color@8.1.1) - hermes-parser: 0.25.1 - zod: 4.6.5 - zod-validation-error: 4.0.2(zod@4.6.5) - transitivePeerDependencies: - - supports-color - eslint-plugin-react-jsx@5.24.9(@typescript/typescript6@6.0.2)(eslint@10.11.0)(supports-color@8.1.1): dependencies: '@eslint-react/ast': 5.24.9(@typescript/typescript6@6.0.2)(eslint@10.11.0)(supports-color@8.1.1) @@ -8467,8 +8387,6 @@ snapshots: minipass: 7.1.3 path-scurry: 2.0.2 - globals@17.13.0: {} - gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -8491,12 +8409,6 @@ snapshots: he@1.2.0: {} - hermes-estree@0.25.1: {} - - hermes-parser@0.25.1: - dependencies: - hermes-estree: 0.25.1 - hookified@1.15.1: {} hookified@2.2.0: {} @@ -9540,7 +9452,8 @@ snapshots: prelude-ls@1.2.1: {} - prettier@3.9.9: {} + prettier@3.9.9: + optional: true pretty-bytes@7.2.0: {} @@ -10343,10 +10256,6 @@ snapshots: yocto-queue@0.1.0: {} - zod-validation-error@4.0.2(zod@4.6.5): - dependencies: - zod: 4.6.5 - zod@3.23.8: {} zod@4.6.5: {} diff --git a/esbuild.mjs b/scripts/esbuild.mjs similarity index 100% rename from esbuild.mjs rename to scripts/esbuild.mjs diff --git a/.vscode-test.mjs b/test/integration/.vscode-test.mjs similarity index 81% rename from .vscode-test.mjs rename to test/integration/.vscode-test.mjs index e7f325629a..87f6afa344 100644 --- a/.vscode-test.mjs +++ b/test/integration/.vscode-test.mjs @@ -6,10 +6,10 @@ import { defineConfig } from "@vscode/test-cli"; // See https://github.com/ewanharris/vscode-versions for version mapping const versions = ["1.105.0", "stable"]; +// Paths resolve against this file's directory. const baseConfig = { - files: "out/test/integration/**/*.test.js", - extensionDevelopmentPath: ".", - extensionTestsPath: "./out/test", + files: "../../out/test/integration/**/*.test.js", + extensionDevelopmentPath: "../..", launchArgs: ["--enable-proposed-api", "coder.coder-remote", "--disable-gpu"], mocha: { ui: "tdd", diff --git a/usage.md b/usage.md deleted file mode 100644 index 304d17e13d..0000000000 --- a/usage.md +++ /dev/null @@ -1,17 +0,0 @@ -# Usage - -## How to share extensions with code-server - -When you install extensions remotely with VSCode, they are stored on the machine -here: - -```shell -~/.vscode-server/extensions -``` - -If you're using code-server and want to use those same extensions, you can use -the `--extensions-dir` flag like so: - -```shell -code-server --extensions-dir ~/.vscode-server/extensions -```