diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 59a03e0a..7c9617cc 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,4 +1,4 @@ -name: ci +name: CI on: push: @@ -11,14 +11,16 @@ on: jobs: lint: + name: Lint runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v6 with: node-version: "22" + cache: "yarn" - run: yarn @@ -29,15 +31,70 @@ jobs: - run: yarn build test: + name: Test runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v6 with: node-version: "22" + cache: "yarn" - run: yarn - run: yarn test:ci + + package: + name: Package + runs-on: ubuntu-22.04 + needs: [lint, test] + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: "22" + cache: "yarn" + + - name: Install dependencies + run: | + yarn + npm install -g @vscode/vsce + + - name: Get version from package.json + id: version + run: | + VERSION=$(node -e "console.log(require('./package.json').version)") + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Version: $VERSION" + + - name: Setup package path + id: setup + run: | + EXTENSION_NAME=$(node -e "console.log(require('./package.json').name)") + # Add commit SHA for CI builds + SHORT_SHA=$(git rev-parse --short HEAD) + PACKAGE_NAME="${EXTENSION_NAME}-${{ steps.version.outputs.version }}-${SHORT_SHA}.vsix" + echo "packageName=$PACKAGE_NAME" >> $GITHUB_OUTPUT + + - name: Package extension + run: vsce package --out "${{ steps.setup.outputs.packageName }}" + + - name: Upload artifact (PR) + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v6 + with: + name: extension-pr-${{ github.event.pull_request.number }} + path: ${{ steps.setup.outputs.packageName }} + if-no-files-found: error + retention-days: 7 + + - name: Upload artifact (main) + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/upload-artifact@v6 + with: + name: extension-main-${{ github.sha }} + path: ${{ steps.setup.outputs.packageName }} + if-no-files-found: error diff --git a/.github/workflows/pre-release.yaml b/.github/workflows/pre-release.yaml new file mode 100644 index 00000000..df3a4c0f --- /dev/null +++ b/.github/workflows/pre-release.yaml @@ -0,0 +1,78 @@ +name: Pre-Release +on: + push: + tags: + - "v*-pre" + +permissions: + # Required to publish a release + contents: write + pull-requests: read + +jobs: + package: + name: Package + runs-on: ubuntu-22.04 + outputs: + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: "22" + + - name: Extract version from tag + id: version + run: | + # Extract version from tag (remove 'v' prefix and '-pre' suffix) + TAG_NAME=${GITHUB_REF#refs/tags/v} + VERSION=${TAG_NAME%-pre} + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Pre-release version: $VERSION" + + - name: Validate version matches package.json + run: | + TAG_VERSION="${{ steps.version.outputs.version }}" + PACKAGE_VERSION=$(node -e "console.log(require('./package.json').version)") + + if [ "$TAG_VERSION" != "$PACKAGE_VERSION" ]; then + echo "Error: Tag version ($TAG_VERSION) does not match package.json version ($PACKAGE_VERSION)" + echo "Please ensure the tag version matches the version in package.json" + exit 1 + fi + + echo "Version validation successful: $TAG_VERSION" + + - name: Install dependencies + run: | + yarn + npm install -g @vscode/vsce + + - name: Setup package path + id: setup + run: | + EXTENSION_NAME=$(node -e "console.log(require('./package.json').name)") + PACKAGE_NAME="${EXTENSION_NAME}-${{ steps.version.outputs.version }}-pre.vsix" + echo "packageName=$PACKAGE_NAME" >> $GITHUB_OUTPUT + + - name: Package extension + run: vsce package --pre-release --out "${{ steps.setup.outputs.packageName }}" + + - name: Upload artifact + uses: actions/upload-artifact@v6 + with: + name: extension-${{ steps.version.outputs.version }} + path: ${{ steps.setup.outputs.packageName }} + if-no-files-found: error + + publish: + name: Publish Extension and Create Pre-Release + needs: package + uses: ./.github/workflows/publish-extension.yaml + with: + version: ${{ needs.package.outputs.version }} + isPreRelease: true + secrets: + VSCE_PAT: ${{ secrets.VSCE_PAT }} + OVSX_PAT: ${{ secrets.OVSX_PAT }} diff --git a/.github/workflows/publish-extension.yaml b/.github/workflows/publish-extension.yaml new file mode 100644 index 00000000..51885dc0 --- /dev/null +++ b/.github/workflows/publish-extension.yaml @@ -0,0 +1,125 @@ +name: Publish Extension + +on: + workflow_call: + inputs: + version: + required: true + type: string + description: "Version to publish" + isPreRelease: + required: false + type: boolean + default: false + description: "Whether this is a pre-release" + secrets: + VSCE_PAT: + required: false + OVSX_PAT: + required: false + +jobs: + setup: + name: Setup + runs-on: ubuntu-22.04 + outputs: + packageName: ${{ steps.package.outputs.packageName }} + hasVscePat: ${{ steps.check-secrets.outputs.hasVscePat }} + hasOvsxPat: ${{ steps.check-secrets.outputs.hasOvsxPat }} + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + with: + node-version: "22" + + - name: Construct package name + id: package + run: | + EXTENSION_NAME=$(node -e "console.log(require('./package.json').name)") + if [ "${{ inputs.isPreRelease }}" = "true" ]; then + PACKAGE_NAME="${EXTENSION_NAME}-${{ inputs.version }}-pre.vsix" + else + PACKAGE_NAME="${EXTENSION_NAME}-${{ inputs.version }}.vsix" + fi + echo "packageName=$PACKAGE_NAME" >> $GITHUB_OUTPUT + echo "Package name: $PACKAGE_NAME" + + - name: Check secrets + id: check-secrets + env: + VSCE_PAT: ${{ secrets.VSCE_PAT }} + OVSX_PAT: ${{ secrets.OVSX_PAT }} + run: | + echo "hasVscePat=$([ -n "$VSCE_PAT" ] && echo true || echo false)" >> $GITHUB_OUTPUT + echo "hasOvsxPat=$([ -n "$OVSX_PAT" ] && echo true || echo false)" >> $GITHUB_OUTPUT + + publishMS: + name: Publish to VS Marketplace + needs: setup + runs-on: ubuntu-22.04 + if: ${{ needs.setup.outputs.hasVscePat == 'true' }} + steps: + - uses: actions/setup-node@v6 + with: + node-version: "22" + + - name: Install vsce + run: npm install -g @vscode/vsce + + - uses: actions/download-artifact@v7 + with: + name: extension-${{ inputs.version }} + + - name: Publish to VS Marketplace + run: | + echo "Publishing version ${{ inputs.version }} to VS Marketplace" + if [ "${{ inputs.isPreRelease }}" = "true" ]; then + vsce publish --pre-release --packagePath "./${{ needs.setup.outputs.packageName }}" -p ${{ secrets.VSCE_PAT }} + else + vsce publish --packagePath "./${{ needs.setup.outputs.packageName }}" -p ${{ secrets.VSCE_PAT }} + fi + + publishOVSX: + name: Publish to Open VSX + needs: setup + runs-on: ubuntu-22.04 + if: ${{ needs.setup.outputs.hasOvsxPat == 'true' }} + steps: + - uses: actions/setup-node@v6 + with: + node-version: "22" + + - name: Install ovsx + run: npm install -g ovsx + + - uses: actions/download-artifact@v7 + with: + name: extension-${{ inputs.version }} + + - name: Publish to Open VSX + run: | + echo "Publishing version ${{ inputs.version }} to Open VSX" + if [ "${{ inputs.isPreRelease }}" = "true" ]; then + ovsx publish "./${{ needs.setup.outputs.packageName }}" --pre-release -p ${{ secrets.OVSX_PAT }} + else + ovsx publish "./${{ needs.setup.outputs.packageName }}" -p ${{ secrets.OVSX_PAT }} + fi + + publishGH: + name: Create GitHub ${{ inputs.isPreRelease && 'Pre-' || '' }}Release + needs: setup + runs-on: ubuntu-22.04 + steps: + - uses: actions/download-artifact@v7 + with: + name: extension-${{ inputs.version }} + + - name: Create ${{ inputs.isPreRelease && 'Pre-' || '' }}Release + uses: marvinpinto/action-automatic-releases@latest + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + prerelease: ${{ inputs.isPreRelease }} + draft: true + title: "v${{ inputs.version }}${{ inputs.isPreRelease && '-pre' || '' }}" + files: ${{ needs.setup.outputs.packageName }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 27214dcc..7d33ec1c 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,33 +1,78 @@ +name: Release on: push: tags: - "v*" - -name: release + - "!v*-pre" permissions: # Required to publish a release contents: write - pull-requests: "read" + pull-requests: read jobs: package: + name: Package runs-on: ubuntu-22.04 + outputs: + version: ${{ steps.version.outputs.version }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v6 with: node-version: "22" - - run: yarn + - name: Extract version from tag + id: version + run: | + # Extract version from tag (remove 'v' prefix) + VERSION=${GITHUB_REF#refs/tags/v} + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Release version: $VERSION" + + - name: Validate version matches package.json + run: | + TAG_VERSION="${{ steps.version.outputs.version }}" + PACKAGE_VERSION=$(node -e "console.log(require('./package.json').version)") + + if [ "$TAG_VERSION" != "$PACKAGE_VERSION" ]; then + echo "Error: Tag version ($TAG_VERSION) does not match package.json version ($PACKAGE_VERSION)" + echo "Please ensure the tag version matches the version in package.json" + exit 1 + fi + + echo "Version validation successful: $TAG_VERSION" - - run: npx @vscode/vsce package + - name: Install dependencies + run: | + yarn + npm install -g @vscode/vsce - - uses: "marvinpinto/action-automatic-releases@latest" + - name: Setup package path + id: setup + run: | + EXTENSION_NAME=$(node -e "console.log(require('./package.json').name)") + PACKAGE_NAME="${EXTENSION_NAME}-${{ steps.version.outputs.version }}.vsix" + echo "packageName=$PACKAGE_NAME" >> $GITHUB_OUTPUT + + - name: Package extension + run: vsce package --out "${{ steps.setup.outputs.packageName }}" + + - name: Upload artifact + uses: actions/upload-artifact@v6 with: - repo_token: "${{ secrets.GITHUB_TOKEN }}" - prerelease: false - draft: true - files: | - *.vsix + name: extension-${{ steps.version.outputs.version }} + path: ${{ steps.setup.outputs.packageName }} + if-no-files-found: error + + publish: + name: Publish Extension and Create Release + needs: package + uses: ./.github/workflows/publish-extension.yaml + with: + version: ${{ needs.package.outputs.version }} + isPreRelease: false + secrets: + VSCE_PAT: ${{ secrets.VSCE_PAT }} + OVSX_PAT: ${{ secrets.OVSX_PAT }} diff --git a/.vscode/settings.json b/.vscode/settings.json index daaef897..9dcd366b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -10,5 +10,9 @@ }, "[jsonc]": { "editor.defaultFormatter": "esbenp.prettier-vscode" - } + }, + "vitest.nodeEnv": { + "ELECTRON_RUN_AS_NODE": "1" + }, + "vitest.nodeExecutable": "node_modules/.bin/electron" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 52a8801a..6b7fb7ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,56 @@ ## Unreleased +## [v1.11.6](https://github.com/coder/vscode-coder/releases/tag/v1.11.6) 2025-12-15 + +### Added + +- Log file picker when viewing logs without an active workspace connection. + +### Fixed + +- Fixed false "setting changed" notifications appearing when connecting to a remote workspace. + +## [v1.11.5](https://github.com/coder/vscode-coder/releases/tag/v1.11.5) 2025-12-10 + +### Added + +- Support for paths that begin with a tilde (`~`). +- Support for `coder ssh` flag configurations through the `coder.sshFlags` setting. + +### Fixed + +- Fixed race condition when multiple VS Code windows download the Coder CLI binary simultaneously. + Other windows now wait and display real-time progress instead of attempting concurrent downloads, + preventing corruption and failures. +- Remove duplicate "Cancel" buttons on the workspace update dialog. + +### Changed + +- WebSocket connections now automatically reconnect on network failures, improving reliability when + communicating with Coder deployments. +- Improved SSH process and log file discovery with better reconnect handling and support for + VS Code forks (Cursor, Windsurf, Antigravity). + +## [v1.11.4](https://github.com/coder/vscode-coder/releases/tag/v1.11.4) 2025-11-20 + +### Added + +- Support for the `google.antigravity-remote-openssh` Remote SSH extension. + +### Changed + +- Improved workspace connection progress messages and enhanced the workspace build terminal + with better log streaming. The extension now also waits for blocking startup scripts to + complete before connecting, providing clear progress indicators during the wait. + +## [v1.11.3](https://github.com/coder/vscode-coder/releases/tag/v1.11.3) 2025-10-22 + +### Fixed + +- Fixed WebSocket connections not receiving headers from the configured header command + (`coder.headerCommand`), which could cause authentication failures with remote workspaces. + ## [v1.11.2](https://github.com/coder/vscode-coder/releases/tag/v1.11.2) 2025-10-07 ### Changed @@ -25,7 +75,7 @@ ### Changed -- Always enable verbose (`-v`) flag when a log directory is configured (`coder.proxyLogDir`). +- Always enable verbose (`-v`) flag when a log directory is configured (`coder.proxyLogDirectory`). - Automatically start a workspace without prompting if it is explicitly opened but not running. ### Added @@ -104,7 +154,7 @@ ### Added -- Coder extension sidebar now displays available app statuses, and let's +- Coder extension sidebar now displays available app statuses, and lets the user click them to drop into a session with a running AI Agent. ## [v1.7.1](https://github.com/coder/vscode-coder/releases/tag/v1.7.1) (2025-04-14) diff --git a/CLAUDE.md b/CLAUDE.md index 04c75edc..6aa4c61d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,40 @@ # Coder Extension Development Guidelines +## Working Style + +You're an experienced, pragmatic engineer. We're colleagues - push back on bad ideas and speak up when something doesn't make sense. Honesty over agreeableness. + +- Simple solutions over clever ones. Readability is a primary concern. +- 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 + +## 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 + +## Testing and Debugging + +- Tests must comprehensively cover functionality +- Never mock behavior in end-to-end tests - use real data +- Mock as little as possible in unit tests - try to use real data +- Find root causes, not symptoms. Read error messages carefully before attempting fixes. + +## Version Control + +- Commit frequently throughout development +- Never skip or disable pre-commit hooks +- Check `git status` before using `git add` + ## Build and Test Commands - Build: `yarn build` @@ -8,20 +43,20 @@ - Lint: `yarn lint` - Lint with auto-fix: `yarn lint:fix` - Run all tests: `yarn test` -- Run specific test: `vitest ./src/filename.test.ts` -- CI test mode: `yarn test:ci` +- Unit tests: `yarn test:ci` - Integration tests: `yarn test:integration` +- Run specific unit test: `yarn test:ci ./test/unit/filename.test.ts` +- Run specific integration test: `yarn test:integration ./test/integration/filename.test.ts` -## Code Style Guidelines +## Code Style - TypeScript with strict typing -- No semicolons (see `.prettierrc`) -- Trailing commas for all multi-line lists -- 120 character line width +- Use Prettier for code formatting and ESLint for code linting - Use ES6 features (arrow functions, destructuring, etc.) - Use `const` by default; `let` only when necessary +- Never use `any`, and use exact types when you can - Prefix unused variables with underscore (e.g., `_unused`) -- Sort imports alphabetically in groups: external → parent → sibling - Error handling: wrap and type errors appropriately - Use async/await for promises, avoid explicit Promise construction where possible -- Test files must be named `*.test.ts` and use Vitest +- Unit test files must be named `*.test.ts` and use Vitest, they should be placed in `./test/unit/` +- Never disable ESLint rules without user approval diff --git a/package.json b/package.json index 9d2ea2a3..7a47413b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "coder-remote", "displayName": "Coder", - "version": "1.11.2", + "version": "1.11.6", "description": "Open any workspace with a single click.", "categories": [ "Other" @@ -24,8 +24,8 @@ "lint:fix": "yarn lint --fix", "package": "webpack --mode production --devtool hidden-source-map", "package:prerelease": "npx vsce package --pre-release", - "pretest": "tsc -p . --outDir out && yarn run build && yarn run lint", - "test": "vitest", + "pretest": "tsc -p . --outDir out && tsc -p test --outDir out && yarn run build && yarn run lint", + "test": "ELECTRON_RUN_AS_NODE=1 electron node_modules/vitest/vitest.mjs", "test:ci": "CI=true yarn test", "test:integration": "vscode-test", "vscode:prepublish": "yarn package", @@ -120,6 +120,16 @@ "type": "boolean", "default": false }, + "coder.sshFlags": { + "markdownDescription": "Additional flags to pass to the `coder ssh` command when establishing SSH connections. Enter each flag as a separate array item; values are passed verbatim and in order. See the [CLI ssh reference](https://coder.com/docs/reference/cli/ssh) for available flags.\n\nNote: `--network-info-dir` and `--ssh-host-prefix` are ignored (managed internally). Prefer `#coder.proxyLogDirectory#` over `--log-dir`/`-l` for full functionality.", + "type": "array", + "items": { + "type": "string" + }, + "default": [ + "--disable-autostart" + ] + }, "coder.globalFlags": { "markdownDescription": "Global flags to pass to every Coder CLI invocation. Enter each flag as a separate array item; values are passed verbatim and in order. Do **not** include the `coder` command itself. See the [CLI reference](https://coder.com/docs/reference/cli) for available global flags.\n\nNote that for `--header-command`, precedence is: `#coder.headerCommand#` setting, then `CODER_HEADER_COMMAND` environment variable, then the value specified here. The `--global-config` flag is explicitly ignored.", "type": "array", @@ -182,67 +192,67 @@ "commands": [ { "command": "coder.login", - "title": "Coder: Login" + "title": "Login", + "category": "Coder", + "icon": "$(sign-in)" }, { "command": "coder.logout", - "title": "Coder: Logout", - "when": "coder.authenticated", + "title": "Logout", + "category": "Coder", "icon": "$(sign-out)" }, { "command": "coder.open", "title": "Open Workspace", - "icon": "$(play)", - "category": "Coder" + "category": "Coder", + "icon": "$(play)" }, { "command": "coder.openFromSidebar", - "title": "Coder: Open Workspace", + "title": "Open Workspace", + "category": "Coder", "icon": "$(play)" }, { "command": "coder.createWorkspace", "title": "Create Workspace", "category": "Coder", - "when": "coder.authenticated", "icon": "$(add)" }, { "command": "coder.navigateToWorkspace", "title": "Navigate to Workspace Page", - "when": "coder.authenticated", + "category": "Coder", "icon": "$(link-external)" }, { "command": "coder.navigateToWorkspaceSettings", "title": "Edit Workspace Settings", - "when": "coder.authenticated", + "category": "Coder", "icon": "$(settings-gear)" }, { "command": "coder.workspace.update", - "title": "Coder: Update Workspace", - "when": "coder.workspace.updatable" + "title": "Update Workspace", + "category": "Coder" }, { "command": "coder.refreshWorkspaces", "title": "Refresh Workspace", "category": "Coder", - "icon": "$(refresh)", - "when": "coder.authenticated" + "icon": "$(refresh)" }, { "command": "coder.viewLogs", "title": "Coder: View Logs", - "icon": "$(list-unordered)", - "when": "coder.authenticated" + "icon": "$(list-unordered)" }, { "command": "coder.openAppStatus", - "title": "Coder: Open App Status", - "icon": "$(robot)", - "when": "coder.authenticated" + "title": "Open App Status", + "category": "Coder", + "icon": "$(robot)" }, { "command": "coder.searchMyWorkspaces", @@ -255,10 +265,55 @@ "title": "Search", "category": "Coder", "icon": "$(search)" + }, + { + "command": "coder.debug.listDeployments", + "title": "List Stored Deployments", + "category": "Coder Debug" } ], "menus": { "commandPalette": [ + { + "command": "coder.login", + "when": "!coder.authenticated" + }, + { + "command": "coder.logout", + "when": "coder.authenticated" + }, + { + "command": "coder.createWorkspace", + "when": "coder.authenticated" + }, + { + "command": "coder.navigateToWorkspace", + "when": "coder.workspace.connected" + }, + { + "command": "coder.navigateToWorkspaceSettings", + "when": "coder.workspace.connected" + }, + { + "command": "coder.workspace.update", + "when": "coder.workspace.updatable" + }, + { + "command": "coder.refreshWorkspaces", + "when": "coder.authenticated" + }, + { + "command": "coder.viewLogs", + "when": "true" + }, + { + "command": "coder.openAppStatus", + "when": "false" + }, + { + "command": "coder.open", + "when": "coder.authenticated" + }, { "command": "coder.openFromSidebar", "when": "false" @@ -270,6 +325,10 @@ { "command": "coder.searchAllWorkspaces", "when": "false" + }, + { + "command": "coder.debug.listDeployments", + "when": "coder.devMode" } ], "view/title": [ @@ -338,58 +397,60 @@ "onUri" ], "resolutions": { - "semver": "7.7.1", + "semver": "7.7.3", "trim": "0.0.3", "word-wrap": "1.2.5" }, "dependencies": { + "@peculiar/x509": "^1.14.2", "axios": "1.12.2", "date-fns": "^3.6.0", "eventsource": "^3.0.6", - "find-process": "https://github.com/coder/find-process#fix/sequoia-compat", + "find-process": "^2.0.0", "jsonc-parser": "^3.3.1", - "node-forge": "^1.3.1", - "openpgp": "^6.2.0", - "pretty-bytes": "^7.0.0", + "openpgp": "^6.2.2", + "pretty-bytes": "^7.1.0", + "proper-lockfile": "^4.1.2", "proxy-agent": "^6.5.0", - "semver": "^7.7.1", + "semver": "^7.7.3", "ua-parser-js": "1.0.40", - "ws": "^8.18.2", - "zod": "^3.25.65" + "ws": "^8.18.3", + "zod": "^4.1.12" }, "devDependencies": { "@types/eventsource": "^3.0.0", "@types/glob": "^7.1.3", "@types/node": "^22.14.1", - "@types/node-forge": "^1.3.14", + "@types/proper-lockfile": "^4.1.4", "@types/semver": "^7.7.1", "@types/ua-parser-js": "0.7.36", "@types/vscode": "^1.73.0", "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.44.0", - "@typescript-eslint/parser": "^8.44.0", + "@typescript-eslint/parser": "^8.49.0", "@vitest/coverage-v8": "^3.2.4", - "@vscode/test-cli": "^0.0.11", + "@vscode/test-cli": "^0.0.12", "@vscode/test-electron": "^2.5.2", - "@vscode/vsce": "^3.6.0", + "@vscode/vsce": "^3.7.1", "bufferutil": "^4.0.9", "coder": "https://github.com/coder/coder#main", - "dayjs": "^1.11.13", + "dayjs": "^1.11.19", + "electron": "^39.2.6", "eslint": "^8.57.1", "eslint-config-prettier": "^10.1.8", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.32.0", "eslint-plugin-md": "^1.0.19", - "eslint-plugin-package-json": "^0.56.3", + "eslint-plugin-package-json": "^0.85.0", "eslint-plugin-prettier": "^5.5.4", - "glob": "^10.4.2", - "jsonc-eslint-parser": "^2.4.0", + "glob": "^11.1.0", + "jsonc-eslint-parser": "^2.4.2", "markdown-eslint-parser": "^1.2.1", - "memfs": "^4.47.0", + "memfs": "^4.49.0", "nyc": "^17.1.0", - "prettier": "^3.5.3", - "ts-loader": "^9.5.1", - "typescript": "^5.9.2", + "prettier": "^3.6.2", + "ts-loader": "^9.5.4", + "typescript": "^5.9.3", "utf-8-validate": "^6.0.5", "vitest": "^3.2.4", "vscode-test": "^1.5.0", @@ -404,12 +465,12 @@ "vscode": "^1.73.0" }, "icon": "media/logo.png", - "extensionKind": [ - "ui" - ], "capabilities": { "untrustedWorkspaces": { "supported": true } - } + }, + "extensionKind": [ + "ui" + ] } diff --git a/src/agentMetadataHelper.ts b/src/api/agentMetadataHelper.ts similarity index 87% rename from src/agentMetadataHelper.ts rename to src/api/agentMetadataHelper.ts index 0a976411..26ab1b6f 100644 --- a/src/agentMetadataHelper.ts +++ b/src/api/agentMetadataHelper.ts @@ -5,8 +5,8 @@ import { type AgentMetadataEvent, AgentMetadataEventSchemaArray, errToStr, -} from "./api/api-helper"; -import { type CoderApi } from "./api/coderApi"; +} from "./api-helper"; +import { type CoderApi } from "./coderApi"; export type AgentMetadataWatcher = { onChange: vscode.EventEmitter["event"]; @@ -19,11 +19,11 @@ export type AgentMetadataWatcher = { * Opens a websocket connection to watch metadata for a given workspace agent. * Emits onChange when metadata updates or an error occurs. */ -export function createAgentMetadataWatcher( +export async function createAgentMetadataWatcher( agentId: WorkspaceAgent["id"], client: CoderApi, -): AgentMetadataWatcher { - const socket = client.watchAgentMetadata(agentId); +): Promise { + const socket = await client.watchAgentMetadata(agentId); let disposed = false; const onChange = new vscode.EventEmitter(); @@ -53,7 +53,11 @@ export function createAgentMetadataWatcher( event.parsedMessage.data, ); - // Overwrite metadata if it changed. + if (watcher.error !== undefined) { + watcher.error = undefined; + onChange.fire(null); + } + if (JSON.stringify(watcher.metadata) !== JSON.stringify(metadata)) { watcher.metadata = metadata; onChange.fire(null); diff --git a/src/api/coderApi.ts b/src/api/coderApi.ts index 1d523b60..67604727 100644 --- a/src/api/coderApi.ts +++ b/src/api/coderApi.ts @@ -6,17 +6,19 @@ import { } from "axios"; import { Api } from "coder/site/src/api/api"; import { + type ServerSentEvent, type GetInboxNotificationResponse, type ProvisionerJobLog, - type ServerSentEvent, type Workspace, type WorkspaceAgent, + type WorkspaceAgentLog, } from "coder/site/src/api/typesGenerated"; import * as vscode from "vscode"; import { type ClientOptions } from "ws"; import { CertificateError } from "../error"; import { getHeaderCommand, getHeaders } from "../headers"; +import { EventStreamLogger } from "../logging/eventStreamLogger"; import { createRequestMeta, logRequest, @@ -29,11 +31,21 @@ import { HttpClientLogLevel, } from "../logging/types"; import { sizeOf } from "../logging/utils"; -import { WsLogger } from "../logging/wsLogger"; +import { HttpStatusCode, WebSocketCloseCode } from "../websocket/codes"; +import { + type UnidirectionalStream, + type CloseEvent, + type ErrorEvent, +} from "../websocket/eventStreamConnection"; import { OneWayWebSocket, type OneWayWebSocketInit, } from "../websocket/oneWayWebSocket"; +import { + ReconnectingWebSocket, + type SocketFactory, +} from "../websocket/reconnectingWebSocket"; +import { SseConnection } from "../websocket/sseConnection"; import { createHttpAgent } from "./utils"; @@ -43,7 +55,11 @@ const coderSessionTokenHeader = "Coder-Session-Token"; * Unified API class that includes both REST API methods from the base Api class * and WebSocket methods for real-time functionality. */ -export class CoderApi extends Api { +export class CoderApi extends Api implements vscode.Disposable { + private readonly reconnectingSockets = new Set< + ReconnectingWebSocket + >(); + private constructor(private readonly output: Logger) { super(); } @@ -58,140 +74,362 @@ export class CoderApi extends Api { output: Logger, ): CoderApi { const client = new CoderApi(output); - client.setHost(baseUrl); - if (token) { - client.setSessionToken(token); - } + client.setCredentials(baseUrl, token); - setupInterceptors(client, baseUrl, output); + setupInterceptors(client, output); return client; } - watchInboxNotifications = ( + getHost(): string | undefined { + return this.getAxiosInstance().defaults.baseURL; + } + + getSessionToken(): string | undefined { + return this.getAxiosInstance().defaults.headers.common[ + coderSessionTokenHeader + ] as string | undefined; + } + + /** + * Set both host and token together. Useful for login/logout/switch to + * avoid triggering multiple reconnection events. + */ + setCredentials = ( + host: string | undefined, + token: string | undefined, + ): void => { + const currentHost = this.getHost(); + const currentToken = this.getSessionToken(); + + // We cannot use the super.setHost/setSessionToken methods because they are shadowed here + const defaults = this.getAxiosInstance().defaults; + defaults.baseURL = host; + defaults.headers.common[coderSessionTokenHeader] = token; + + const hostChanged = (currentHost || "") !== (host || ""); + const tokenChanged = (currentToken || "") !== (token || ""); + + if (hostChanged || tokenChanged) { + for (const socket of this.reconnectingSockets) { + if (host) { + socket.reconnect(); + } else { + socket.disconnect(WebSocketCloseCode.NORMAL, "Host cleared"); + } + } + } + }; + + override setSessionToken = (token: string): void => { + this.setCredentials(this.getHost(), token); + }; + + override setHost = (host: string | undefined): void => { + this.setCredentials(host, this.getSessionToken()); + }; + + /** + * Permanently dispose all WebSocket connections. + * This clears handlers and prevents reconnection. + */ + dispose(): void { + for (const socket of this.reconnectingSockets) { + socket.close(); + } + this.reconnectingSockets.clear(); + } + + watchInboxNotifications = async ( watchTemplates: string[], watchTargets: string[], options?: ClientOptions, ) => { - return this.createWebSocket({ - apiRoute: "/api/v2/notifications/inbox/watch", - searchParams: { - format: "plaintext", - templates: watchTemplates.join(","), - targets: watchTargets.join(","), - }, - options, - }); + return this.createReconnectingSocket(() => + this.createOneWayWebSocket({ + apiRoute: "/api/v2/notifications/inbox/watch", + searchParams: { + format: "plaintext", + templates: watchTemplates.join(","), + targets: watchTargets.join(","), + }, + options, + }), + ); }; - watchWorkspace = (workspace: Workspace, options?: ClientOptions) => { - return this.createWebSocket({ - apiRoute: `/api/v2/workspaces/${workspace.id}/watch-ws`, - options, - }); + watchWorkspace = async (workspace: Workspace, options?: ClientOptions) => { + return this.createReconnectingSocket(() => + this.createStreamWithSseFallback({ + apiRoute: `/api/v2/workspaces/${workspace.id}/watch-ws`, + fallbackApiRoute: `/api/v2/workspaces/${workspace.id}/watch`, + options, + }), + ); }; - watchAgentMetadata = ( + watchAgentMetadata = async ( agentId: WorkspaceAgent["id"], options?: ClientOptions, ) => { - return this.createWebSocket({ - apiRoute: `/api/v2/workspaceagents/${agentId}/watch-metadata-ws`, + return this.createReconnectingSocket(() => + this.createStreamWithSseFallback({ + apiRoute: `/api/v2/workspaceagents/${agentId}/watch-metadata-ws`, + fallbackApiRoute: `/api/v2/workspaceagents/${agentId}/watch-metadata`, + options, + }), + ); + }; + + watchBuildLogsByBuildId = async ( + buildId: string, + logs: ProvisionerJobLog[], + options?: ClientOptions, + ) => { + return this.watchLogs( + `/api/v2/workspacebuilds/${buildId}/logs`, + logs, options, - }); + ); }; - watchBuildLogsByBuildId = (buildId: string, logs: ProvisionerJobLog[]) => { + watchWorkspaceAgentLogs = async ( + agentId: string, + logs: WorkspaceAgentLog[], + options?: ClientOptions, + ) => { + return this.watchLogs( + `/api/v2/workspaceagents/${agentId}/logs`, + logs, + options, + ); + }; + + private async watchLogs( + apiRoute: string, + logs: { id: number }[], + options?: ClientOptions, + ) { const searchParams = new URLSearchParams({ follow: "true" }); - if (logs.length) { - searchParams.append("after", logs[logs.length - 1].id.toString()); + const lastLog = logs.at(-1); + if (lastLog) { + searchParams.append("after", lastLog.id.toString()); } - const socket = this.createWebSocket({ - apiRoute: `/api/v2/workspacebuilds/${buildId}/logs`, + return this.createOneWayWebSocket({ + apiRoute, searchParams, + options, }); + } - return socket; - }; - - private createWebSocket( + private async createOneWayWebSocket( configs: Omit, - ) { + ): Promise> { const baseUrlRaw = this.getAxiosInstance().defaults.baseURL; if (!baseUrlRaw) { throw new Error("No base URL set on REST client"); } - - const baseUrl = new URL(baseUrlRaw); const token = this.getAxiosInstance().defaults.headers.common[ coderSessionTokenHeader ] as string | undefined; - const httpAgent = createHttpAgent(vscode.workspace.getConfiguration()); - const webSocket = new OneWayWebSocket({ + const headersFromCommand = await getHeaders( + baseUrlRaw, + getHeaderCommand(vscode.workspace.getConfiguration()), + this.output, + ); + + const httpAgent = await createHttpAgent( + vscode.workspace.getConfiguration(), + ); + + /** + * Similar to the REST client, we want to prioritize headers in this order (highest to lowest): + * 1. Headers from the header command + * 2. Any headers passed directly to this function + * 3. Coder session token from the Api client (if set) + */ + const headers = { + ...(token ? { [coderSessionTokenHeader]: token } : {}), + ...configs.options?.headers, + ...headersFromCommand, + }; + + const baseUrl = new URL(baseUrlRaw); + const ws = new OneWayWebSocket({ location: baseUrl, ...configs, options: { + ...configs.options, agent: httpAgent, followRedirects: true, - headers: { - ...(token ? { [coderSessionTokenHeader]: token } : {}), - ...configs.options?.headers, - }, - ...configs.options, + headers, }, }); - const wsUrl = new URL(webSocket.url); - const pathWithQuery = wsUrl.pathname + wsUrl.search; - const wsLogger = new WsLogger(this.output, pathWithQuery); - wsLogger.logConnecting(); + this.attachStreamLogger(ws); + return ws; + } - webSocket.addEventListener("open", () => { - wsLogger.logOpen(); - }); + private attachStreamLogger( + connection: UnidirectionalStream, + ): void { + const url = new URL(connection.url); + const logger = new EventStreamLogger( + this.output, + url.pathname + url.search, + url.protocol.startsWith("http") ? "SSE" : "WS", + ); + logger.logConnecting(); - webSocket.addEventListener("message", (event) => { - wsLogger.logMessage(event.sourceEvent.data); - }); + connection.addEventListener("open", () => logger.logOpen()); + connection.addEventListener("close", (event: CloseEvent) => + logger.logClose(event.code, event.reason), + ); + connection.addEventListener("error", (event: ErrorEvent) => + logger.logError(event.error, event.message), + ); + connection.addEventListener("message", (event) => + logger.logMessage(event.sourceEvent.data), + ); + } + + /** + * Create a WebSocket connection with SSE fallback on 404. + * + * Tries WS first, falls back to SSE on 404. + * + * Note: The fallback on SSE ignores all passed client options except the headers. + */ + private async createStreamWithSseFallback( + configs: Omit & { + fallbackApiRoute: string; + }, + ): Promise> { + const { fallbackApiRoute, ...socketConfigs } = configs; + try { + const ws = + await this.createOneWayWebSocket(socketConfigs); + return await this.waitForOpen(ws); + } catch (error) { + if (this.is404Error(error)) { + this.output.warn( + `WebSocket failed, using SSE fallback: ${socketConfigs.apiRoute}`, + ); + const sse = this.createSseConnection( + fallbackApiRoute, + socketConfigs.searchParams, + socketConfigs.options?.headers, + ); + return await this.waitForOpen(sse); + } + throw error; + } + } - webSocket.addEventListener("close", (event) => { - wsLogger.logClose(event.code, event.reason); + /** + * Create an SSE connection without waiting for connection. + */ + private createSseConnection( + apiRoute: string, + searchParams?: Record | URLSearchParams, + optionsHeaders?: Record, + ): SseConnection { + const baseUrlRaw = this.getAxiosInstance().defaults.baseURL; + if (!baseUrlRaw) { + throw new Error("No base URL set on REST client"); + } + const url = new URL(baseUrlRaw); + const sse = new SseConnection({ + location: url, + apiRoute, + searchParams, + axiosInstance: this.getAxiosInstance(), + optionsHeaders, + logger: this.output, }); - webSocket.addEventListener("error", (event) => { - wsLogger.logError(event.error, event.message); + this.attachStreamLogger(sse); + return sse; + } + + /** + * Wait for a connection to open. Rejects on error. + */ + private waitForOpen( + connection: UnidirectionalStream, + ): Promise> { + return new Promise((resolve, reject) => { + const cleanup = () => { + connection.removeEventListener("open", handleOpen); + connection.removeEventListener("error", handleError); + }; + + const handleOpen = () => { + cleanup(); + resolve(connection); + }; + + const handleError = (event: ErrorEvent) => { + cleanup(); + connection.close(); + reject(event.error || new Error(event.message)); + }; + + connection.addEventListener("open", handleOpen); + connection.addEventListener("error", handleError); }); + } - return webSocket; + /** + * Check if an error is a 404 Not Found error. + */ + private is404Error(error: unknown): boolean { + const msg = error instanceof Error ? error.message : String(error); + return msg.includes(String(HttpStatusCode.NOT_FOUND)); + } + + /** + * Create a ReconnectingWebSocket and track it for lifecycle management. + */ + private async createReconnectingSocket( + socketFactory: SocketFactory, + ): Promise> { + const reconnectingSocket = await ReconnectingWebSocket.create( + socketFactory, + this.output, + undefined, + () => this.reconnectingSockets.delete(reconnectingSocket), + ); + + this.reconnectingSockets.add(reconnectingSocket); + + return reconnectingSocket; } } /** * Set up logging and request interceptors for the CoderApi instance. */ -function setupInterceptors( - client: CoderApi, - baseUrl: string, - output: Logger, -): void { +function setupInterceptors(client: CoderApi, output: Logger): void { addLoggingInterceptors(client.getAxiosInstance(), output); client.getAxiosInstance().interceptors.request.use(async (config) => { + const baseUrl = client.getAxiosInstance().defaults.baseURL; const headers = await getHeaders( baseUrl, getHeaderCommand(vscode.workspace.getConfiguration()), output, ); // Add headers from the header command. - Object.entries(headers).forEach(([key, value]) => { + for (const [key, value] of Object.entries(headers)) { config.headers[key] = value; - }); + } // Configure proxy and TLS. // Note that by default VS Code overrides the agent. To prevent this, set // `http.proxySupport` to `on` or `off`. - const agent = createHttpAgent(vscode.workspace.getConfiguration()); + const agent = await createHttpAgent(vscode.workspace.getConfiguration()); config.httpsAgent = agent; config.httpAgent = agent; config.proxy = false; @@ -203,7 +441,12 @@ function setupInterceptors( client.getAxiosInstance().interceptors.response.use( (r) => r, async (err) => { - throw await CertificateError.maybeWrap(err, baseUrl, output); + const baseUrl = client.getAxiosInstance().defaults.baseURL; + if (baseUrl) { + throw await CertificateError.maybeWrap(err, baseUrl, output); + } else { + throw err; + } }, ); } @@ -235,7 +478,7 @@ function addLoggingInterceptors(client: AxiosInstance, logger: Logger) { }, (error: unknown) => { logError(logger, error, getLogLevel()); - return Promise.reject(error); + throw error; }, ); @@ -246,7 +489,7 @@ function addLoggingInterceptors(client: AxiosInstance, logger: Logger) { }, (error: unknown) => { logError(logger, error, getLogLevel()); - return Promise.reject(error); + throw error; }, ); } @@ -298,7 +541,7 @@ function wrapResponseTransform( function getSize(headers: AxiosHeaders, data: unknown): number | undefined { const contentLength = headers["content-length"]; if (contentLength !== undefined) { - return parseInt(contentLength, 10); + return Number.parseInt(contentLength, 10); } return sizeOf(data); diff --git a/src/api/streamingFetchAdapter.ts b/src/api/streamingFetchAdapter.ts new file mode 100644 index 00000000..f23ef1a7 --- /dev/null +++ b/src/api/streamingFetchAdapter.ts @@ -0,0 +1,71 @@ +import { type AxiosInstance } from "axios"; +import { type FetchLikeInit, type FetchLikeResponse } from "eventsource"; +import { type IncomingMessage } from "node:http"; + +/** + * Creates a fetch adapter using an Axios instance that returns streaming responses. + * This is used by EventSource to make authenticated SSE connections. + */ +export function createStreamingFetchAdapter( + axiosInstance: AxiosInstance, + configHeaders?: Record, +): (url: string | URL, init?: FetchLikeInit) => Promise { + return async ( + url: string | URL, + init?: FetchLikeInit, + ): Promise => { + const urlStr = url.toString(); + + const response = await axiosInstance.request({ + url: urlStr, + signal: init?.signal, + headers: { ...init?.headers, ...configHeaders }, + responseType: "stream", + validateStatus: () => true, // Don't throw on any status code + }); + + const stream = new ReadableStream({ + start(controller) { + response.data.on("data", (chunk: Buffer) => { + try { + controller.enqueue(chunk); + } catch { + // Stream already closed or errored, ignore + } + }); + + response.data.on("end", () => { + try { + controller.close(); + } catch { + // Stream already closed, ignore + } + }); + + response.data.on("error", (err: Error) => { + controller.error(err); + }); + }, + + cancel() { + response.data.destroy(); + return Promise.resolve(); + }, + }); + + return { + body: { + getReader: () => stream.getReader(), + }, + url: urlStr, + status: response.status, + redirected: response.request?.res?.responseUrl !== urlStr, + headers: { + get: (name: string) => { + const value = response.headers[name.toLowerCase()]; + return value === undefined ? null : String(value); + }, + }, + }; + }; +} diff --git a/src/api/utils.ts b/src/api/utils.ts index 91a18885..86604e3e 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -1,4 +1,4 @@ -import fs from "fs"; +import fs from "node:fs/promises"; import { ProxyAgent } from "proxy-agent"; import { type WorkspaceConfiguration } from "vscode"; @@ -23,7 +23,9 @@ export function needToken(cfg: WorkspaceConfiguration): boolean { * Create a new HTTP agent based on the current VS Code settings. * Configures proxy, TLS certificates, and security options. */ -export function createHttpAgent(cfg: WorkspaceConfiguration): ProxyAgent { +export async function createHttpAgent( + cfg: WorkspaceConfiguration, +): Promise { const insecure = Boolean(cfg.get("coder.insecure")); const certFile = expandPath( String(cfg.get("coder.tlsCertFile") ?? "").trim(), @@ -32,6 +34,12 @@ export function createHttpAgent(cfg: WorkspaceConfiguration): ProxyAgent { const caFile = expandPath(String(cfg.get("coder.tlsCaFile") ?? "").trim()); const altHost = expandPath(String(cfg.get("coder.tlsAltHost") ?? "").trim()); + const [cert, key, ca] = await Promise.all([ + certFile === "" ? Promise.resolve(undefined) : fs.readFile(certFile), + keyFile === "" ? Promise.resolve(undefined) : fs.readFile(keyFile), + caFile === "" ? Promise.resolve(undefined) : fs.readFile(caFile), + ]); + return new ProxyAgent({ // Called each time a request is made. getProxyForUrl: (url: string) => { @@ -41,9 +49,9 @@ export function createHttpAgent(cfg: WorkspaceConfiguration): ProxyAgent { cfg.get("coder.proxyBypass"), ); }, - cert: certFile === "" ? undefined : fs.readFileSync(certFile), - key: keyFile === "" ? undefined : fs.readFileSync(keyFile), - ca: caFile === "" ? undefined : fs.readFileSync(caFile), + cert, + key, + ca, servername: altHost === "" ? undefined : altHost, // rejectUnauthorized defaults to true, so we need to explicitly set it to // false if we want to allow self-signed certificates. diff --git a/src/api/workspace.ts b/src/api/workspace.ts index c2e20c0c..93319337 100644 --- a/src/api/workspace.ts +++ b/src/api/workspace.ts @@ -1,11 +1,17 @@ -import { spawn } from "child_process"; import { type Api } from "coder/site/src/api/api"; -import { type Workspace } from "coder/site/src/api/typesGenerated"; +import { + type WorkspaceAgentLog, + type ProvisionerJobLog, + type Workspace, + type WorkspaceAgent, +} from "coder/site/src/api/typesGenerated"; +import { spawn } from "node:child_process"; import * as vscode from "vscode"; +import { getGlobalFlags } from "../cliConfig"; import { type FeatureSet } from "../featureSet"; -import { getGlobalFlags } from "../globalFlags"; import { escapeCommandArg } from "../util"; +import { type UnidirectionalStream } from "../websocket/eventStreamConnection"; import { errToStr, createWorkspaceIdentifier } from "./api-helper"; import { type CoderApi } from "./coderApi"; @@ -36,7 +42,7 @@ export async function startWorkspaceIfStoppedOrFailed( createWorkspaceIdentifier(workspace), ]; if (featureSet.buildReason) { - startArgs.push(...["--reason", "vscode_connection"]); + startArgs.push("--reason", "vscode_connection"); } // { shell: true } requires one shell-safe command string, otherwise we lose all escaping @@ -44,27 +50,25 @@ export async function startWorkspaceIfStoppedOrFailed( const startProcess = spawn(cmd, { shell: true }); startProcess.stdout.on("data", (data: Buffer) => { - data + const lines = data .toString() .split(/\r*\n/) - .forEach((line: string) => { - if (line !== "") { - writeEmitter.fire(line.toString() + "\r\n"); - } - }); + .filter((line) => line !== ""); + for (const line of lines) { + writeEmitter.fire(line.toString() + "\r\n"); + } }); let capturedStderr = ""; startProcess.stderr.on("data", (data: Buffer) => { - data + const lines = data .toString() .split(/\r*\n/) - .forEach((line: string) => { - if (line !== "") { - writeEmitter.fire(line.toString() + "\r\n"); - capturedStderr += line.toString() + "\n"; - } - }); + .filter((line) => line !== ""); + for (const line of lines) { + writeEmitter.fire(line.toString() + "\r\n"); + capturedStderr += line.toString() + "\n"; + } }); startProcess.on("close", (code: number) => { @@ -82,51 +86,72 @@ export async function startWorkspaceIfStoppedOrFailed( } /** - * Wait for the latest build to finish while streaming logs to the emitter. - * - * Once completed, fetch the workspace again and return it. + * Streams build logs to the emitter in real-time. + * Returns the websocket for lifecycle management. */ -export async function waitForBuild( +export async function streamBuildLogs( client: CoderApi, writeEmitter: vscode.EventEmitter, workspace: Workspace, -): Promise { - // This fetches the initial bunch of logs. - const logs = await client.getWorkspaceBuildLogs(workspace.latest_build.id); - logs.forEach((log) => writeEmitter.fire(log.output + "\r\n")); - - await new Promise((resolve, reject) => { - const socket = client.watchBuildLogsByBuildId( - workspace.latest_build.id, - logs, +): Promise> { + const socket = await client.watchBuildLogsByBuildId( + workspace.latest_build.id, + [], + ); + + socket.addEventListener("message", (data) => { + if (data.parseError) { + writeEmitter.fire( + errToStr(data.parseError, "Failed to parse message") + "\r\n", + ); + } else { + writeEmitter.fire(data.parsedMessage.output + "\r\n"); + } + }); + + socket.addEventListener("error", (error) => { + const baseUrlRaw = client.getAxiosInstance().defaults.baseURL; + writeEmitter.fire( + `Error watching workspace build logs on ${baseUrlRaw}: ${errToStr(error, "no further details")}\r\n`, ); + }); - socket.addEventListener("message", (data) => { - if (data.parseError) { - writeEmitter.fire( - errToStr(data.parseError, "Failed to parse message") + "\r\n", - ); - } else { - writeEmitter.fire(data.parsedMessage.output + "\r\n"); - } - }); + socket.addEventListener("close", () => { + writeEmitter.fire("Build complete\r\n"); + }); - socket.addEventListener("error", (error) => { - const baseUrlRaw = client.getAxiosInstance().defaults.baseURL; - return reject( - new Error( - `Failed to watch workspace build on ${baseUrlRaw}: ${errToStr(error, "no further details")}`, - ), + return socket; +} + +/** + * Streams agent logs to the emitter in real-time. + * Returns the websocket for lifecycle management. + */ +export async function streamAgentLogs( + client: CoderApi, + writeEmitter: vscode.EventEmitter, + agent: WorkspaceAgent, +): Promise> { + const socket = await client.watchWorkspaceAgentLogs(agent.id, []); + + socket.addEventListener("message", (data) => { + if (data.parseError) { + writeEmitter.fire( + errToStr(data.parseError, "Failed to parse message") + "\r\n", ); - }); + } else { + for (const log of data.parsedMessage) { + writeEmitter.fire(log.output + "\r\n"); + } + } + }); - socket.addEventListener("close", () => resolve()); + socket.addEventListener("error", (error) => { + const baseUrlRaw = client.getAxiosInstance().defaults.baseURL; + writeEmitter.fire( + `Error watching agent logs on ${baseUrlRaw}: ${errToStr(error, "no further details")}\r\n`, + ); }); - writeEmitter.fire("Build complete\r\n"); - const updatedWorkspace = await client.getWorkspace(workspace.id); - writeEmitter.fire( - `Workspace is now ${updatedWorkspace.latest_build.status}\r\n`, - ); - return updatedWorkspace; + return socket; } diff --git a/src/cliConfig.ts b/src/cliConfig.ts new file mode 100644 index 00000000..1f23949d --- /dev/null +++ b/src/cliConfig.ts @@ -0,0 +1,40 @@ +import { type WorkspaceConfiguration } from "vscode"; + +import { getHeaderArgs } from "./headers"; +import { escapeCommandArg } from "./util"; + +/** + * Returns the raw global flags from user configuration. + */ +export function getGlobalFlagsRaw( + configs: Pick, +): string[] { + return configs.get("coder.globalFlags", []); +} + +/** + * Returns global configuration flags for Coder CLI commands. + * Always includes the `--global-config` argument with the specified config directory. + */ +export function getGlobalFlags( + configs: Pick, + configDir: string, +): string[] { + // Last takes precedence/overrides previous ones + return [ + ...getGlobalFlagsRaw(configs), + "--global-config", + escapeCommandArg(configDir), + ...getHeaderArgs(configs), + ]; +} + +/** + * Returns SSH flags for the `coder ssh` command from user configuration. + */ +export function getSshFlags( + configs: Pick, +): string[] { + // Make sure to match this default with the one in the package.json + return configs.get("coder.sshFlags", ["--disable-autostart"]); +} diff --git a/src/commands.ts b/src/commands.ts index 5abeb026..ef97bdda 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,24 +1,25 @@ -import { type Api } from "coder/site/src/api/api"; -import { getErrorMessage } from "coder/site/src/api/errors"; import { - type User, type Workspace, type WorkspaceAgent, } from "coder/site/src/api/typesGenerated"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; import * as vscode from "vscode"; import { createWorkspaceIdentifier, extractAgents } from "./api/api-helper"; -import { CoderApi } from "./api/coderApi"; -import { needToken } from "./api/utils"; +import { type CoderApi } from "./api/coderApi"; +import { getGlobalFlags } from "./cliConfig"; 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 DeploymentManager } from "./deployment/deploymentManager"; import { CertificateError } from "./error"; -import { getGlobalFlags } from "./globalFlags"; import { type Logger } from "./logging/logger"; +import { type LoginCoordinator } from "./login/loginCoordinator"; +import { maybeAskAgent, maybeAskUrl } from "./promptUtils"; import { escapeCommandArg, toRemoteAuthority, toSafeHost } from "./util"; import { AgentTreeItem, @@ -34,6 +35,8 @@ export class Commands { private readonly secretsManager: SecretsManager; private readonly cliManager: CliManager; private readonly contextManager: ContextManager; + private readonly loginCoordinator: LoginCoordinator; + // These will only be populated when actively connected to a workspace and are // used in commands. Because commands can be executed by the user, it is not // possible to pass in arguments, so we have to store the current workspace @@ -43,11 +46,12 @@ export class Commands { // if you use multiple deployments). public workspace?: Workspace; public workspaceLogPath?: string; - public workspaceRestClient?: Api; + public remoteWorkspaceClient?: CoderApi; public constructor( serviceContainer: ServiceContainer, - private readonly restClient: Api, + private readonly extensionClient: CoderApi, + private readonly deploymentManager: DeploymentManager, ) { this.vscodeProposed = serviceContainer.getVsCodeProposed(); this.logger = serviceContainer.getLogger(); @@ -56,127 +60,16 @@ export class Commands { this.secretsManager = serviceContainer.getSecretsManager(); this.cliManager = serviceContainer.getCliManager(); this.contextManager = serviceContainer.getContextManager(); + this.loginCoordinator = serviceContainer.getLoginCoordinator(); } /** - * Find the requested agent if specified, otherwise return the agent if there - * is only one or ask the user to pick if there are multiple. Return - * undefined if the user cancels. - */ - public async maybeAskAgent( - agents: WorkspaceAgent[], - filter?: string, - ): Promise { - const filteredAgents = filter - ? agents.filter((agent) => agent.name === filter) - : agents; - if (filteredAgents.length === 0) { - throw new Error("Workspace has no matching agents"); - } else if (filteredAgents.length === 1) { - return filteredAgents[0]; - } else { - const quickPick = vscode.window.createQuickPick(); - quickPick.title = "Select an agent"; - quickPick.busy = true; - const agentItems: vscode.QuickPickItem[] = filteredAgents.map((agent) => { - let icon = "$(debug-start)"; - if (agent.status !== "connected") { - icon = "$(debug-stop)"; - } - return { - alwaysShow: true, - label: `${icon} ${agent.name}`, - detail: `${agent.name} • Status: ${agent.status}`, - }; - }); - quickPick.items = agentItems; - quickPick.busy = false; - quickPick.show(); - - const selected = await new Promise( - (resolve) => { - quickPick.onDidHide(() => resolve(undefined)); - quickPick.onDidChangeSelection((selected) => { - if (selected.length < 1) { - return resolve(undefined); - } - const agent = filteredAgents[quickPick.items.indexOf(selected[0])]; - resolve(agent); - }); - }, - ); - quickPick.dispose(); - return selected; - } - } - - /** - * Ask the user for the URL, letting them choose from a list of recent URLs or - * CODER_URL or enter a new one. Undefined means the user aborted. + * Get the current deployment, throwing if not logged in. */ - private async askURL(selection?: string): Promise { - const defaultURL = vscode.workspace - .getConfiguration() - .get("coder.defaultUrl") - ?.trim(); - const quickPick = vscode.window.createQuickPick(); - quickPick.value = - selection || defaultURL || process.env.CODER_URL?.trim() || ""; - quickPick.placeholder = "https://example.coder.com"; - quickPick.title = "Enter the URL of your Coder deployment."; - - // Initial items. - quickPick.items = this.mementoManager - .withUrlHistory(defaultURL, process.env.CODER_URL) - .map((url) => ({ - alwaysShow: true, - label: url, - })); - - // Quick picks do not allow arbitrary values, so we add the value itself as - // an option in case the user wants to connect to something that is not in - // the list. - quickPick.onDidChangeValue((value) => { - quickPick.items = this.mementoManager - .withUrlHistory(defaultURL, process.env.CODER_URL, value) - .map((url) => ({ - alwaysShow: true, - label: url, - })); - }); - - quickPick.show(); - - const selected = await new Promise((resolve) => { - quickPick.onDidHide(() => resolve(undefined)); - quickPick.onDidChangeSelection((selected) => resolve(selected[0]?.label)); - }); - quickPick.dispose(); - return selected; - } - - /** - * Ask the user for the URL if it was not provided, letting them choose from a - * list of recent URLs or the default URL or CODER_URL or enter a new one, and - * normalizes the returned URL. Undefined means the user aborted. - */ - public async maybeAskUrl( - providedUrl: string | undefined | null, - lastUsedUrl?: string, - ): Promise { - let url = providedUrl || (await this.askURL(lastUsedUrl)); + private requireExtensionBaseUrl(): string { + const url = this.extensionClient.getAxiosInstance().defaults.baseURL; if (!url) { - // User aborted. - return undefined; - } - - // Normalize URL. - if (!url.startsWith("http://") && !url.startsWith("https://")) { - // Default to HTTPS if not provided so URLs can be typed more easily. - url = "https://" + url; - } - while (url.endsWith("/")) { - url = url.substring(0, url.length - 1); + throw new Error("You are not logged in"); } return url; } @@ -188,53 +81,49 @@ export class Commands { */ public async login(args?: { url?: string; - token?: string; - label?: string; autoLogin?: boolean; }): Promise { - if (this.contextManager.get("coder.authenticated")) { + if (this.deploymentManager.isAuthenticated()) { return; } this.logger.info("Logging in"); - const url = await this.maybeAskUrl(args?.url); + const currentDeployment = await this.secretsManager.getCurrentDeployment(); + const url = await maybeAskUrl( + this.mementoManager, + args?.url, + currentDeployment?.url, + ); if (!url) { return; // The user aborted. } - // It is possible that we are trying to log into an old-style host, in which - // case we want to write with the provided blank label instead of generating - // a host label. - const label = args?.label === undefined ? toSafeHost(url) : args.label; - - // Try to get a token from the user, if we need one, and their user. - const autoLogin = args?.autoLogin === true; - const res = await this.maybeAskToken(url, args?.token, autoLogin); - if (!res) { - return; // The user aborted, or unable to auth. - } + const safeHostname = toSafeHost(url); + this.logger.debug("Using hostname", safeHostname); - // The URL is good and the token is either good or not required; authorize - // the global client. - this.restClient.setHost(url); - this.restClient.setSessionToken(res.token); + const result = await this.loginCoordinator.ensureLoggedIn({ + safeHostname, + url, + autoLogin: args?.autoLogin, + }); - // Store these to be used in later sessions. - await this.mementoManager.setUrl(url); - await this.secretsManager.setSessionToken(res.token); + if (!result.success) { + return; + } - // Store on disk to be used by the cli. - await this.cliManager.configure(label, url, res.token); + // Login might have happened in another process/window so we do not have the user yet. + result.user ??= await this.extensionClient.getAuthenticatedUser(); - // These contexts control various menu items and the sidebar. - this.contextManager.set("coder.authenticated", true); - if (res.user.roles.find((role) => role.name === "owner")) { - this.contextManager.set("coder.isOwner", true); - } + await this.deploymentManager.setDeployment({ + url, + safeHostname, + token: result.token, + user: result.user, + }); vscode.window .showInformationMessage( - `Welcome to Coder, ${res.user.username}!`, + `Welcome to Coder, ${result.user.username}!`, { detail: "You can now use the Coder extension to manage your Coder instance.", @@ -246,108 +135,50 @@ export class Commands { vscode.commands.executeCommand("coder.open"); } }); - - await this.secretsManager.triggerLoginStateChange("login"); - // Fetch workspaces for the new deployment. - vscode.commands.executeCommand("coder.refreshWorkspaces"); + this.logger.debug("Login complete to deployment:", url); } /** - * If necessary, ask for a token, and keep asking until the token has been - * validated. Return the token and user that was fetched to validate the - * token. Null means the user aborted or we were unable to authenticate with - * mTLS (in the latter case, an error notification will have been displayed). + * View the logs for the currently connected workspace. */ - private async maybeAskToken( - url: string, - token: string | undefined, - isAutoLogin: boolean, - ): Promise<{ user: User; token: string } | null> { - const client = CoderApi.create(url, token, this.logger); - const needsToken = needToken(vscode.workspace.getConfiguration()); - if (!needsToken || token) { - try { - const user = await client.getAuthenticatedUser(); - // For non-token auth, we write a blank token since the `vscodessh` - // command currently always requires a token file. - // For token auth, we have valid access so we can just return the user here - return { token: needsToken && token ? token : "", user }; - } catch (err) { - const message = getErrorMessage(err, "no response from the server"); - if (isAutoLogin) { - this.logger.warn("Failed to log in to Coder server:", message); - } else { - this.vscodeProposed.window.showErrorMessage( - "Failed to log in to Coder server", - { - detail: message, - modal: true, - useCustom: true, - }, - ); - } - // Invalid certificate, most likely. - return null; - } + public async viewLogs(): Promise { + if (this.workspaceLogPath) { + // Return the connected deployment's log file. + return this.openFile(this.workspaceLogPath); } - // This prompt is for convenience; do not error if they close it since - // they may already have a token or already have the page opened. - await vscode.env.openExternal(vscode.Uri.parse(`${url}/cli-auth`)); - - // For token auth, start with the existing token in the prompt or the last - // used token. Once submitted, if there is a failure we will keep asking - // the user for a new token until they quit. - let user: User | undefined; - const validatedToken = await vscode.window.showInputBox({ - title: "Coder API Key", - password: true, - placeHolder: "Paste your API key.", - value: token || (await this.secretsManager.getSessionToken()), - ignoreFocusOut: true, - validateInput: async (value) => { - if (!value) { - return null; - } - client.setSessionToken(value); - try { - user = await client.getAuthenticatedUser(); - } catch (err) { - // For certificate errors show both a notification and add to the - // text under the input box, since users sometimes miss the - // notification. - if (err instanceof CertificateError) { - err.showNotification(); - - return { - message: err.x509Err || err.message, - severity: vscode.InputBoxValidationSeverity.Error, - }; - } - // This could be something like the header command erroring or an - // invalid session token. - const message = getErrorMessage(err, "no response from the server"); - return { - message: "Failed to authenticate: " + message, - severity: vscode.InputBoxValidationSeverity.Error, - }; + const logDir = vscode.workspace + .getConfiguration() + .get("coder.proxyLogDirectory"); + if (logDir) { + try { + const files = await fs.readdir(logDir); + // Sort explicitly since fs.readdir order is platform-dependent + const logFiles = files + .filter((f) => f.endsWith(".log")) + .sort((a, b) => a.localeCompare(b)) + .reverse(); + + if (logFiles.length === 0) { + vscode.window.showInformationMessage( + "No log files found in the configured log directory.", + ); + return; } - }, - }); - - if (validatedToken && user) { - return { token: validatedToken, user }; - } - // User aborted. - return null; - } + const selected = await vscode.window.showQuickPick(logFiles, { + title: "Select a log file to view", + }); - /** - * View the logs for the currently connected workspace. - */ - public async viewLogs(): Promise { - if (!this.workspaceLogPath) { + if (selected) { + await this.openFile(path.join(logDir, selected)); + } + } catch (error) { + vscode.window.showErrorMessage( + `Failed to read log directory: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } else { vscode.window .showInformationMessage( "No logs available. Make sure to set coder.proxyLogDirectory to get logs.", @@ -361,40 +192,26 @@ export class Commands { ); } }); - return; } - const uri = vscode.Uri.file(this.workspaceLogPath); - const doc = await vscode.workspace.openTextDocument(uri); - await vscode.window.showTextDocument(doc); + } + + private async openFile(filePath: string): Promise { + const uri = vscode.Uri.file(filePath); + await vscode.window.showTextDocument(uri); } /** * Log out from the currently logged-in deployment. */ public async logout(): Promise { - const url = this.mementoManager.getUrl(); - if (!url) { - // Sanity check; command should not be available if no url. - throw new Error("You are not logged in"); - } - await this.forceLogout(); - } - - public async forceLogout(): Promise { - if (!this.contextManager.get("coder.authenticated")) { + if (!this.deploymentManager.isAuthenticated()) { return; } + this.logger.info("Logging out"); - // Clear from the REST client. An empty url will indicate to other parts of - // the code that we are logged out. - this.restClient.setHost(""); - this.restClient.setSessionToken(""); - // Clear from memory. - await this.mementoManager.setUrl(undefined); - await this.secretsManager.setSessionToken(undefined); + await this.deploymentManager.clearDeployment(); - this.contextManager.set("coder.authenticated", false); vscode.window .showInformationMessage("You've been logged out of Coder!", "Login") .then((action) => { @@ -403,9 +220,7 @@ export class Commands { } }); - await this.secretsManager.triggerLoginStateChange("logout"); - // This will result in clearing the workspace list. - vscode.commands.executeCommand("coder.refreshWorkspaces"); + this.logger.debug("Logout complete"); } /** @@ -414,7 +229,8 @@ export class Commands { * Must only be called if currently logged in. */ public async createWorkspace(): Promise { - const uri = this.mementoManager.getUrl() + "/templates"; + const baseUrl = this.requireExtensionBaseUrl(); + const uri = baseUrl + "/templates"; await vscode.commands.executeCommand("vscode.open", uri); } @@ -426,14 +242,15 @@ export class Commands { * * Otherwise, the currently connected workspace is used (if any). */ - public async navigateToWorkspace(item: OpenableTreeItem) { + public async navigateToWorkspace(item?: OpenableTreeItem) { if (item) { + const baseUrl = this.requireExtensionBaseUrl(); const workspaceId = createWorkspaceIdentifier(item.workspace); - const uri = this.mementoManager.getUrl() + `/@${workspaceId}`; + const uri = baseUrl + `/@${workspaceId}`; await vscode.commands.executeCommand("vscode.open", uri); - } else if (this.workspace && this.workspaceRestClient) { + } else if (this.workspace && this.remoteWorkspaceClient) { const baseUrl = - this.workspaceRestClient.getAxiosInstance().defaults.baseURL; + this.remoteWorkspaceClient.getAxiosInstance().defaults.baseURL; const uri = `${baseUrl}/@${createWorkspaceIdentifier(this.workspace)}`; await vscode.commands.executeCommand("vscode.open", uri); } else { @@ -449,14 +266,15 @@ export class Commands { * * Otherwise, the currently connected workspace is used (if any). */ - public async navigateToWorkspaceSettings(item: OpenableTreeItem) { + public async navigateToWorkspaceSettings(item?: OpenableTreeItem) { if (item) { + const baseUrl = this.requireExtensionBaseUrl(); const workspaceId = createWorkspaceIdentifier(item.workspace); - const uri = this.mementoManager.getUrl() + `/@${workspaceId}/settings`; + const uri = baseUrl + `/@${workspaceId}/settings`; await vscode.commands.executeCommand("vscode.open", uri); - } else if (this.workspace && this.workspaceRestClient) { + } else if (this.workspace && this.remoteWorkspaceClient) { const baseUrl = - this.workspaceRestClient.getAxiosInstance().defaults.baseURL; + this.remoteWorkspaceClient.getAxiosInstance().defaults.baseURL; const uri = `${baseUrl}/@${createWorkspaceIdentifier(this.workspace)}/settings`; await vscode.commands.executeCommand("vscode.open", uri); } else { @@ -474,7 +292,7 @@ export class Commands { */ public async openFromSidebar(item: OpenableTreeItem) { if (item) { - const baseUrl = this.restClient.getAxiosInstance().defaults.baseURL; + const baseUrl = this.extensionClient.getAxiosInstance().defaults.baseURL; if (!baseUrl) { throw new Error("You are not logged in"); } @@ -488,7 +306,7 @@ export class Commands { ); } else if (item instanceof WorkspaceTreeItem) { const agents = await this.extractAgentsWithFallback(item.workspace); - const agent = await this.maybeAskAgent(agents); + const agent = await maybeAskAgent(agents); if (!agent) { // User declined to pick an agent. return; @@ -501,7 +319,7 @@ export class Commands { true, ); } else { - throw new Error("Unable to open unknown sidebar item"); + throw new TypeError("Unable to open unknown sidebar item"); } } else { // If there is no tree item, then the user manually ran this command. @@ -529,25 +347,20 @@ export class Commands { const terminal = vscode.window.createTerminal(app.name); // If workspace_name is provided, run coder ssh before the command - - const url = this.mementoManager.getUrl(); - if (!url) { - throw new Error("No coder url found for sidebar"); - } + const baseUrl = this.requireExtensionBaseUrl(); + const safeHost = toSafeHost(baseUrl); const binary = await this.cliManager.fetchBinary( - this.restClient, - toSafeHost(url), + this.extensionClient, + safeHost, ); - const configDir = this.pathResolver.getGlobalConfigDir( - toSafeHost(url), - ); + const configDir = this.pathResolver.getGlobalConfigDir(safeHost); const globalFlags = getGlobalFlags( vscode.workspace.getConfiguration(), configDir, ); terminal.sendText( - `${escapeCommandArg(binary)}${` ${globalFlags.join(" ")}`} ssh ${app.workspace_name}`, + `${escapeCommandArg(binary)} ${globalFlags.join(" ")} ssh ${app.workspace_name}`, ); await new Promise((resolve) => setTimeout(resolve, 5000)); terminal.sendText(app.command ?? ""); @@ -555,19 +368,6 @@ export class Commands { }, ); } - // Check if app has a URL to open - if (app.url) { - return vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: `Opening ${app.name || "application"} in browser...`, - cancellable: false, - }, - async () => { - await vscode.env.openExternal(vscode.Uri.parse(app.url!)); - }, - ); - } // If no URL or command, show information about the app status vscode.window.showInformationMessage(`${app.name}`, { @@ -591,14 +391,14 @@ export class Commands { folderPath?: string, openRecent?: boolean, ): Promise { - const baseUrl = this.restClient.getAxiosInstance().defaults.baseURL; + const baseUrl = this.extensionClient.getAxiosInstance().defaults.baseURL; if (!baseUrl) { throw new Error("You are not logged in"); } let workspace: Workspace | undefined; if (workspaceOwner && workspaceName) { - workspace = await this.restClient.getWorkspaceByOwnerAndName( + workspace = await this.extensionClient.getWorkspaceByOwnerAndName( workspaceOwner, workspaceName, ); @@ -611,7 +411,7 @@ export class Commands { } const agents = await this.extractAgentsWithFallback(workspace); - const agent = await this.maybeAskAgent(agents, agentName); + const agent = await maybeAskAgent(agents, agentName); if (!agent) { // User declined to pick an agent. return; @@ -634,7 +434,7 @@ export class Commands { localWorkspaceFolder: string = "", localConfigFile: string = "", ): Promise { - const baseUrl = this.restClient.getAxiosInstance().defaults.baseURL; + const baseUrl = this.extensionClient.getAxiosInstance().defaults.baseURL; if (!baseUrl) { throw new Error("You are not logged in"); } @@ -646,7 +446,7 @@ export class Commands { workspaceAgent, ); - const hostPath = localWorkspaceFolder ? localWorkspaceFolder : undefined; + const hostPath = localWorkspaceFolder || undefined; const configFile = hostPath && localConfigFile ? { @@ -690,7 +490,7 @@ export class Commands { * this is a no-op. */ public async updateWorkspace(): Promise { - if (!this.workspace || !this.workspaceRestClient) { + if (!this.workspace || !this.remoteWorkspaceClient) { return; } const action = await this.vscodeProposed.window.showWarningMessage( @@ -701,10 +501,9 @@ export class Commands { detail: `Update ${createWorkspaceIdentifier(this.workspace)} to the latest version?\n\nUpdating will restart your workspace which stops any running processes and may result in the loss of unsaved work.`, }, "Update", - "Cancel", ); if (action === "Update") { - await this.workspaceRestClient.updateWorkspaceVersion(this.workspace); + await this.remoteWorkspaceClient.updateWorkspaceVersion(this.workspace); } } @@ -719,7 +518,7 @@ export class Commands { let lastWorkspaces: readonly Workspace[]; quickPick.onDidChangeValue((value) => { quickPick.busy = true; - this.restClient + this.extensionClient .getWorkspaces({ q: value, }) @@ -748,7 +547,6 @@ export class Commands { if (ex instanceof CertificateError) { ex.showNotification(); } - return; }); }); quickPick.show(); @@ -783,7 +581,7 @@ export class Commands { // we need to fetch the agents through the resources API, as the // workspaces query does not include agents when off. this.logger.info("Fetching agents from template version"); - const resources = await this.restClient.getTemplateVersionResources( + const resources = await this.extensionClient.getTemplateVersionResources( workspace.latest_build.template_version_id, ); return extractAgents(resources); diff --git a/src/core/binaryLock.ts b/src/core/binaryLock.ts new file mode 100644 index 00000000..6e334453 --- /dev/null +++ b/src/core/binaryLock.ts @@ -0,0 +1,126 @@ +import prettyBytes from "pretty-bytes"; +import * as lockfile from "proper-lockfile"; +import * as vscode from "vscode"; + +import { type Logger } from "../logging/logger"; + +import * as downloadProgress from "./downloadProgress"; + +/** + * Timeout to detect stale lock files and take over from stuck processes. + * This value is intentionally small so we can quickly takeover. + */ +const STALE_TIMEOUT_MS = 15000; + +const LOCK_POLL_INTERVAL_MS = 500; + +type LockRelease = () => Promise; + +/** + * Manages file locking for binary downloads to coordinate between multiple + * VS Code windows downloading the same binary. + */ +export class BinaryLock { + constructor( + private readonly vscodeProposed: typeof vscode, + private readonly output: Logger, + ) {} + + /** + * Acquire the lock, or wait for another process if the lock is held. + * Returns the lock release function and a flag indicating if we waited. + */ + async acquireLockOrWait( + binPath: string, + progressLogPath: string, + ): Promise<{ release: LockRelease; waited: boolean }> { + const release = await this.safeAcquireLock(binPath); + if (release) { + return { release, waited: false }; + } + + this.output.info( + "Another process is downloading the binary, monitoring progress", + ); + const newRelease = await this.monitorDownloadProgress( + binPath, + progressLogPath, + ); + return { release: newRelease, waited: true }; + } + + /** + * Attempt to acquire a lock on the binary file. + * Returns the release function if successful, null if lock is already held. + */ + private async safeAcquireLock(path: string): Promise { + try { + const release = await lockfile.lock(path, { + stale: STALE_TIMEOUT_MS, + retries: 0, + realpath: false, + }); + return release; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ELOCKED") { + throw error; + } + return null; + } + } + + /** + * Monitor download progress from another process by polling the progress log + * and attempting to acquire the lock. Shows a VS Code progress notification. + * Returns the lock release function once the download completes. + */ + private async monitorDownloadProgress( + binPath: string, + progressLogPath: string, + ): Promise { + return await this.vscodeProposed.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Another window is downloading the Coder CLI binary", + cancellable: false, + }, + async (progress) => { + return new Promise((resolve, reject) => { + const poll = async () => { + try { + await this.updateProgressMonitor(progressLogPath, progress); + const release = await this.safeAcquireLock(binPath); + if (release) { + return resolve(release); + } + // Schedule next poll only after current one completes + setTimeout(poll, LOCK_POLL_INTERVAL_MS); + } catch (error) { + reject(error); + } + }; + poll().catch((error) => reject(error)); + }); + }, + ); + } + + private async updateProgressMonitor( + progressLogPath: string, + progress: vscode.Progress<{ message?: string }>, + ): Promise { + const currentProgress = + await downloadProgress.readProgress(progressLogPath); + if (currentProgress) { + const totalBytesPretty = + currentProgress.totalBytes === null + ? "unknown" + : prettyBytes(currentProgress.totalBytes); + const message = + currentProgress.status === "verifying" + ? "Verifying signature..." + : `${prettyBytes(currentProgress.bytesDownloaded)} / ${totalBytesPretty}`; + progress.report({ message }); + } + } +} diff --git a/src/core/cliManager.ts b/src/core/cliManager.ts index 4e8833fe..6f1fdf8f 100644 --- a/src/core/cliManager.ts +++ b/src/core/cliManager.ts @@ -3,10 +3,10 @@ import globalAxios, { type AxiosRequestConfig, } from "axios"; import { type Api } from "coder/site/src/api/api"; -import { createWriteStream, type WriteStream } from "fs"; -import fs from "fs/promises"; -import { type IncomingMessage } from "http"; -import path from "path"; +import { createWriteStream, type WriteStream } from "node:fs"; +import fs from "node:fs/promises"; +import { type IncomingMessage } from "node:http"; +import path from "node:path"; import prettyBytes from "pretty-bytes"; import * as semver from "semver"; import * as vscode from "vscode"; @@ -15,20 +15,26 @@ import { errToStr } from "../api/api-helper"; import { type Logger } from "../logging/logger"; import * as pgp from "../pgp"; +import { BinaryLock } from "./binaryLock"; import * as cliUtils from "./cliUtils"; +import * as downloadProgress from "./downloadProgress"; import { type PathResolver } from "./pathResolver"; export class CliManager { + private readonly binaryLock: BinaryLock; + constructor( private readonly vscodeProposed: typeof vscode, private readonly output: Logger, private readonly pathResolver: PathResolver, - ) {} + ) { + this.binaryLock = new BinaryLock(vscodeProposed, output); + } /** * Download and return the path to a working binary for the deployment with - * the provided label using the provided client. If the label is empty, use - * the old deployment-unaware path instead. + * the provided hostname using the provided client. If the hostname is empty, + * use the old deployment-unaware path instead. * * If there is already a working binary and it matches the server version, * return that, skipping the download. If it does not match but downloads are @@ -36,7 +42,10 @@ export class CliManager { * unable to download a working binary, whether because of network issues or * downloads being disabled. */ - public async fetchBinary(restClient: Api, label: string): Promise { + public async fetchBinary( + restClient: Api, + safeHostname: string, + ): Promise { const cfg = vscode.workspace.getConfiguration("coder"); // Settings can be undefined when set to their defaults (true in this case), // so explicitly check against false. @@ -58,7 +67,7 @@ export class CliManager { // is valid and matches the server, or if it does not match the server but // downloads are disabled, we can return early. const binPath = path.join( - this.pathResolver.getBinaryCachePath(label), + this.pathResolver.getBinaryCachePath(safeHostname), cliUtils.name(), ); this.output.info("Using binary path", binPath); @@ -97,143 +106,342 @@ export class CliManager { throw new Error("Unable to download CLI because downloads are disabled"); } - // Remove any left-over old or temporary binaries and signatures. - const removed = await cliUtils.rmOld(binPath); - removed.forEach(({ fileName, error }) => { - if (error) { - this.output.warn("Failed to remove", fileName, error); - } else { - this.output.info("Removed", fileName); - } - }); - - // Figure out where to get the binary. - const binName = cliUtils.name(); - const configSource = cfg.get("binarySource"); - const binSource = - configSource && String(configSource).trim().length > 0 - ? String(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 etag = stat !== undefined ? await cliUtils.eTag(binPath) : ""; - this.output.info("Using ETag", etag); - - // Download the binary to a temporary file. + // Create the `bin` folder if it doesn't exist await fs.mkdir(path.dirname(binPath), { recursive: true }); - const tempFile = - binPath + ".temp-" + Math.random().toString(36).substring(8); - const writeStream = createWriteStream(tempFile, { - autoClose: true, - mode: 0o755, - }); - const client = restClient.getAxiosInstance(); - const status = await this.download(client, binSource, writeStream, { - "Accept-Encoding": "gzip", - "If-None-Match": `"${etag}"`, - }); + const progressLogPath = binPath + ".progress.log"; + + let lockResult: + | { release: () => Promise; waited: boolean } + | undefined; + let latestVersion = parsedVersion; + try { + lockResult = await this.binaryLock.acquireLockOrWait( + binPath, + progressLogPath, + ); + this.output.info("Acquired download lock"); + + // If we waited for another process, re-check if binary is now ready + if (lockResult.waited) { + const latestBuildInfo = await restClient.getBuildInfo(); + this.output.info("Got latest server version", latestBuildInfo.version); - switch (status) { - case 200: { - if (cfg.get("disableSignatureVerification")) { + const recheckAfterWait = await this.checkBinaryVersion( + binPath, + latestBuildInfo.version, + ); + if (recheckAfterWait.matches) { this.output.info( - "Skipping binary signature verification due to settings", + "Using existing binary since it matches the latest server version", ); - } else { - await this.verifyBinarySignatures(client, tempFile, [ - // A signature placed at the same level as the binary. It must be - // named exactly the same with an appended `.asc` (such as - // coder-windows-amd64.exe.asc or coder-linux-amd64.asc). - binSource + ".asc", - // The releases.coder.com bucket does not include the leading "v", - // and unlike what we get from buildinfo it uses a truncated version - // with only major.minor.patch. The signature name follows the same - // rule as above. - `https://releases.coder.com/coder-cli/${parsedVersion.major}.${parsedVersion.minor}.${parsedVersion.patch}/${binName}.asc`, - ]); + return binPath; } - // Move the old binary to a backup location first, just in case. And, - // on Linux at least, you cannot write onto a binary that is in use so - // moving first works around that (delete would also work). - if (stat !== undefined) { - const oldBinPath = - binPath + ".old-" + Math.random().toString(36).substring(8); - this.output.info( - "Moving existing binary to", - path.basename(oldBinPath), + // Parse the latest version for download + const latestParsedVersion = semver.parse(latestBuildInfo.version); + if (!latestParsedVersion) { + throw new Error( + `Got invalid version from deployment: ${latestBuildInfo.version}`, ); - await fs.rename(binPath, oldBinPath); } + latestVersion = latestParsedVersion; + } + + return await this.performBinaryDownload( + restClient, + latestVersion, + binPath, + progressLogPath, + ); + } catch (error) { + // Unified error handling - check for fallback binaries and prompt user + return await this.handleAnyBinaryFailure( + error, + binPath, + buildInfo.version, + ); + } finally { + if (lockResult) { + await lockResult.release(); + this.output.info("Released download lock"); + } + } + } + + /** + * Check if a binary exists and matches the expected version. + */ + private async checkBinaryVersion( + binPath: string, + expectedVersion: string, + ): Promise<{ version: string | null; matches: boolean }> { + const stat = await cliUtils.stat(binPath); + if (!stat) { + return { version: null, matches: false }; + } - // Then move the temporary binary into the right place. - this.output.info("Moving downloaded file to", path.basename(binPath)); - await fs.mkdir(path.dirname(binPath), { recursive: true }); - await fs.rename(tempFile, binPath); + try { + const version = await cliUtils.version(binPath); + return { + version, + matches: version === expectedVersion, + }; + } catch (error) { + this.output.warn(`Unable to get version of binary: ${errToStr(error)}`); + return { version: null, matches: false }; + } + } - // For debugging, to see if the binary only partially downloaded. - const newStat = await cliUtils.stat(binPath); + /** + * Prompt the user to use an existing binary version. + */ + private async promptUseExistingBinary( + version: string, + reason: string, + ): Promise { + const choice = await this.vscodeProposed.window.showErrorMessage( + `${reason}. Run version ${version} anyway?`, + "Run", + ); + return choice === "Run"; + } + + /** + * Replace the existing binary with the downloaded temp file. + * Throws WindowsFileLockError if binary is in use. + */ + private async replaceExistingBinary( + binPath: string, + tempFile: string, + ): Promise { + const oldBinPath = + binPath + ".old-" + Math.random().toString(36).substring(8); + + try { + // Step 1: Move existing binary to backup (if it exists) + const stat = await cliUtils.stat(binPath); + if (stat) { this.output.info( - "Downloaded binary size is", - prettyBytes(newStat?.size || 0), + "Moving existing binary to", + path.basename(oldBinPath), ); + await fs.rename(binPath, oldBinPath); + } - // Make sure we can execute this new binary. - const version = await cliUtils.version(binPath); - this.output.info("Downloaded binary version is", version); + // Step 2: Move temp to final location + this.output.info("Moving downloaded file to", path.basename(binPath)); + await fs.rename(tempFile, binPath); + } catch (error) { + throw cliUtils.maybeWrapFileLockError(error, binPath); + } + + // For debugging, to see if the binary only partially downloaded. + const newStat = await cliUtils.stat(binPath); + this.output.info( + "Downloaded binary size is", + prettyBytes(newStat?.size || 0), + ); + + // Make sure we can execute this new binary. + const version = await cliUtils.version(binPath); + this.output.info("Downloaded binary version is", version); + } + /** + * Unified handler for any binary-related failure. + * Checks for existing or old binaries and prompts user once. + */ + private async handleAnyBinaryFailure( + error: unknown, + binPath: string, + expectedVersion: string, + ): Promise { + const message = + error instanceof cliUtils.FileLockError + ? "Unable to update the Coder CLI binary because it's in use" + : "Failed to update CLI binary"; + + // Try existing binary first + const existingCheck = await this.checkBinaryVersion( + binPath, + expectedVersion, + ); + if (existingCheck.version) { + // Perfect match - use without prompting + if (existingCheck.matches) { return binPath; } - case 304: { - this.output.info("Using existing binary since server returned a 304"); + // Version mismatch - prompt user + if (await this.promptUseExistingBinary(existingCheck.version, message)) { return binPath; } - case 404: { - vscode.window - .showErrorMessage( - "Coder isn't supported for your platform. Please open an issue, we'd love to support it!", - "Open an Issue", - ) - .then((value) => { - if (!value) { - return; - } - const os = cliUtils.goos(); - const arch = cliUtils.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.`, - }); - const uri = vscode.Uri.parse( - `https://github.com/coder/vscode-coder/issues/new?${params.toString()}`, - ); - vscode.env.openExternal(uri); - }); - throw new Error("Platform not supported"); + throw error; + } + + // Try .old-* binaries as fallback + const oldBinaries = await cliUtils.findOldBinaries(binPath); + if (oldBinaries.length > 0) { + const oldCheck = await this.checkBinaryVersion( + oldBinaries[0], + expectedVersion, + ); + if ( + oldCheck.version && + (oldCheck.matches || + (await this.promptUseExistingBinary(oldCheck.version, message))) + ) { + await fs.rename(oldBinaries[0], binPath); + return binPath; } - default: { - vscode.window - .showErrorMessage( - "Failed to download binary. Please open an issue.", - "Open an Issue", - ) - .then((value) => { - if (!value) { - return; - } - const params = new URLSearchParams({ - title: `Failed to download binary on \`${cliUtils.goos()}-${cliUtils.goarch()}\``, - body: `Received status code \`${status}\` when downloading the binary.`, - }); - const uri = vscode.Uri.parse( - `https://github.com/coder/vscode-coder/issues/new?${params.toString()}`, - ); - vscode.env.openExternal(uri); + } + + // No fallback available or user declined - re-throw original error + throw error; + } + + private async performBinaryDownload( + restClient: Api, + parsedVersion: semver.SemVer, + binPath: string, + progressLogPath: string, + ): Promise { + const cfg = vscode.workspace.getConfiguration("coder"); + const tempFile = + binPath + ".temp-" + Math.random().toString(36).substring(8); + + try { + const removed = await cliUtils.rmOld(binPath); + for (const { fileName, error } of removed) { + if (error) { + this.output.warn("Failed to remove", fileName, error); + } else { + this.output.info("Removed", fileName); + } + } + + // Figure out where to get the binary. + const binName = cliUtils.name(); + 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) : ""; + this.output.info("Using ETag", etag || ""); + + // Download the binary to a temporary file. + const writeStream = createWriteStream(tempFile, { + autoClose: true, + mode: 0o755, + }); + + const onProgress = async ( + bytesDownloaded: number, + totalBytes: number | null, + ) => { + await downloadProgress.writeProgress(progressLogPath, { + bytesDownloaded, + totalBytes, + status: "downloading", + }); + }; + + const client = restClient.getAxiosInstance(); + const status = await this.download( + client, + binSource, + writeStream, + { + "Accept-Encoding": "gzip", + "If-None-Match": `"${etag}"`, + }, + onProgress, + ); + + switch (status) { + case 200: { + await downloadProgress.writeProgress(progressLogPath, { + bytesDownloaded: 0, + totalBytes: null, + status: "verifying", }); - throw new Error("Failed to download binary"); + + if (cfg.get("disableSignatureVerification")) { + this.output.info( + "Skipping binary signature verification due to settings", + ); + } else { + await this.verifyBinarySignatures(client, tempFile, [ + // A signature placed at the same level as the binary. It must be + // named exactly the same with an appended `.asc` (such as + // coder-windows-amd64.exe.asc or coder-linux-amd64.asc). + binSource + ".asc", + // The releases.coder.com bucket does not include the leading "v", + // and unlike what we get from buildinfo it uses a truncated version + // with only major.minor.patch. The signature name follows the same + // rule as above. + `https://releases.coder.com/coder-cli/${parsedVersion.major}.${parsedVersion.minor}.${parsedVersion.patch}/${binName}.asc`, + ]); + } + + // Replace existing binary (handles both renames + Windows lock) + await this.replaceExistingBinary(binPath, tempFile); + + return binPath; + } + case 304: { + this.output.info("Using existing binary since server returned a 304"); + return binPath; + } + case 404: { + vscode.window + .showErrorMessage( + "Coder isn't supported for your platform. Please open an issue, we'd love to support it!", + "Open an Issue", + ) + .then((value) => { + if (!value) { + return; + } + const os = cliUtils.goos(); + const arch = cliUtils.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.`, + }); + const uri = vscode.Uri.parse( + `https://github.com/coder/vscode-coder/issues/new?${params.toString()}`, + ); + vscode.env.openExternal(uri); + }); + throw new Error("Platform not supported"); + } + default: { + vscode.window + .showErrorMessage( + "Failed to download binary. Please open an issue.", + "Open an Issue", + ) + .then((value) => { + if (!value) { + return; + } + const params = new URLSearchParams({ + title: `Failed to download binary on \`${cliUtils.goos()}-${cliUtils.goarch()}\``, + body: `Received status code \`${status}\` when downloading the binary.`, + }); + const uri = vscode.Uri.parse( + `https://github.com/coder/vscode-coder/issues/new?${params.toString()}`, + ); + vscode.env.openExternal(uri); + }); + throw new Error("Failed to download binary"); + } } + } finally { + await downloadProgress.clearProgress(progressLogPath); } } @@ -246,6 +454,10 @@ export class CliManager { source: string, writeStream: WriteStream, headers?: AxiosRequestConfig["headers"], + onProgress?: ( + bytesDownloaded: number, + totalBytes: number | null, + ) => Promise, ): Promise { const baseUrl = client.defaults.baseURL; @@ -306,6 +518,17 @@ export class CliManager { ? undefined : (buffer.byteLength / contentLength) * 100, }); + if (onProgress) { + onProgress( + written, + Number.isNaN(contentLength) ? null : contentLength, + ).catch((error) => { + this.output.warn( + "Failed to write progress log:", + errToStr(error), + ); + }); + } }); }); @@ -473,76 +696,71 @@ export class CliManager { } /** - * Configure the CLI for the deployment with the provided label. + * Configure the CLI for the deployment with the provided hostname. * * Falsey URLs and null tokens are a no-op; we avoid unconfiguring the CLI to * avoid breaking existing connections. */ public async configure( - label: string, + safeHostname: string, url: string | undefined, token: string | null, ) { await Promise.all([ - this.updateUrlForCli(label, url), - this.updateTokenForCli(label, token), + this.updateUrlForCli(safeHostname, url), + this.updateTokenForCli(safeHostname, token), ]); } /** - * Update the URL for the deployment with the provided label on disk which can - * be used by the CLI via --url-file. If the URL is falsey, do nothing. + * Update the URL for the deployment with the provided hostname on disk which + * can be used by the CLI via --url-file. If the URL is falsey, do nothing. * - * If the label is empty, read the old deployment-unaware config instead. + * If the hostname is empty, read the old deployment-unaware config instead. */ private async updateUrlForCli( - label: string, + safeHostname: string, url: string | undefined, ): Promise { if (url) { - const urlPath = this.pathResolver.getUrlPath(label); - await fs.mkdir(path.dirname(urlPath), { recursive: true }); - await fs.writeFile(urlPath, url); + const urlPath = this.pathResolver.getUrlPath(safeHostname); + await this.atomicWriteFile(urlPath, url); } } /** - * Update the session token for a deployment with the provided label on disk - * which can be used by the CLI via --session-token-file. If the token is - * null, do nothing. + * Update the session token for a deployment with the provided hostname on + * disk which can be used by the CLI via --session-token-file. If the token + * is null, do nothing. * - * If the label is empty, read the old deployment-unaware config instead. + * If the hostname is empty, read the old deployment-unaware config instead. */ - private async updateTokenForCli( - label: string, - token: string | undefined | null, - ) { + private async updateTokenForCli(safeHostname: string, token: string | null) { if (token !== null) { - const tokenPath = this.pathResolver.getSessionTokenPath(label); - await fs.mkdir(path.dirname(tokenPath), { recursive: true }); - await fs.writeFile(tokenPath, token ?? ""); + const tokenPath = this.pathResolver.getSessionTokenPath(safeHostname); + await this.atomicWriteFile(tokenPath, token); } } /** - * Read the CLI config for a deployment with the provided label. - * - * IF a config file does not exist, return an empty string. - * - * If the label is empty, read the old deployment-unaware config. + * Atomically write content to a file by writing to a temporary file first, + * then renaming it. */ - public async readConfig( - label: string, - ): Promise<{ url: string; token: string }> { - const urlPath = this.pathResolver.getUrlPath(label); - const tokenPath = this.pathResolver.getSessionTokenPath(label); - const [url, token] = await Promise.allSettled([ - fs.readFile(urlPath, "utf8"), - fs.readFile(tokenPath, "utf8"), - ]); - return { - url: url.status === "fulfilled" ? url.value.trim() : "", - token: token.status === "fulfilled" ? token.value.trim() : "", - }; + private async atomicWriteFile( + filePath: string, + content: string, + ): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + const tempPath = + filePath + ".temp-" + Math.random().toString(36).substring(8); + try { + await fs.writeFile(tempPath, content); + await fs.rename(tempPath, filePath); + } catch (err) { + await fs.rm(tempPath, { force: true }).catch((rmErr) => { + this.output.warn("Failed to delete temp file", tempPath, rmErr); + }); + throw err; + } } } diff --git a/src/core/cliUtils.ts b/src/core/cliUtils.ts index cc92a345..2297cf77 100644 --- a/src/core/cliUtils.ts +++ b/src/core/cliUtils.ts @@ -1,10 +1,20 @@ -import { execFile, type ExecFileException } from "child_process"; -import * as crypto from "crypto"; -import { createReadStream, type Stats } from "fs"; -import fs from "fs/promises"; -import os from "os"; -import path from "path"; -import { promisify } from "util"; +import { execFile, type ExecFileException } from "node:child_process"; +import * as crypto from "node:crypto"; +import { createReadStream, type Stats } from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; + +/** + * Custom error thrown when a binary file is locked (typically on Windows). + */ +export class FileLockError extends Error { + constructor(binPath: string) { + super(`Binary is in use: ${binPath}`); + this.name = "WindowsFileLockError"; + } +} /** * Stat the path or undefined if the path does not exist. Throw if unable to @@ -77,7 +87,8 @@ export async function rmOld(binPath: string): Promise { if ( fileName.includes(".old-") || fileName.includes(".temp-") || - fileName.endsWith(".asc") + fileName.endsWith(".asc") || + fileName.endsWith(".progress.log") ) { try { await fs.rm(path.join(binDir, file), { force: true }); @@ -97,6 +108,52 @@ export async function rmOld(binPath: string): Promise { } } +/** + * Find all .old-* binaries in the same directory as the given binary path. + * Returns paths sorted by modification time (most recent first). + */ +export async function findOldBinaries(binPath: string): Promise { + const binDir = path.dirname(binPath); + const binName = path.basename(binPath); + try { + const files = await fs.readdir(binDir); + const oldBinaries = files + .filter((f) => f.startsWith(binName) && f.includes(".old-")) + .map((f) => path.join(binDir, f)); + + // Sort by modification time, most recent first + const stats = await Promise.allSettled( + oldBinaries.map(async (f) => ({ + path: f, + mtime: (await fs.stat(f)).mtime, + })), + ).then((result) => + result + .filter((promise) => promise.status === "fulfilled") + .map((promise) => promise.value), + ); + stats.sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); + return stats.map((s) => s.path); + } catch (error) { + // If directory doesn't exist, return empty array + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + return []; + } + throw error; + } +} + +export function maybeWrapFileLockError( + error: unknown, + binPath: string, +): unknown { + const code = (error as NodeJS.ErrnoException).code; + if (code === "EBUSY" || code === "EPERM") { + return new FileLockError(binPath); + } + return error; +} + /** * Return the etag (sha1) of the path. Throw if unable to hash the file. */ diff --git a/src/core/container.ts b/src/core/container.ts index a8f938ea..acf2d854 100644 --- a/src/core/container.ts +++ b/src/core/container.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode"; import { type Logger } from "../logging/logger"; +import { LoginCoordinator } from "../login/loginCoordinator"; import { CliManager } from "./cliManager"; import { ContextManager } from "./contextManager"; @@ -19,6 +20,7 @@ export class ServiceContainer implements vscode.Disposable { private readonly secretsManager: SecretsManager; private readonly cliManager: CliManager; private readonly contextManager: ContextManager; + private readonly loginCoordinator: LoginCoordinator; constructor( context: vscode.ExtensionContext, @@ -30,13 +32,23 @@ export class ServiceContainer implements vscode.Disposable { context.logUri.fsPath, ); this.mementoManager = new MementoManager(context.globalState); - this.secretsManager = new SecretsManager(context.secrets); + this.secretsManager = new SecretsManager( + context.secrets, + context.globalState, + this.logger, + ); this.cliManager = new CliManager( this.vscodeProposed, this.logger, this.pathResolver, ); - this.contextManager = new ContextManager(); + this.contextManager = new ContextManager(context); + this.loginCoordinator = new LoginCoordinator( + this.secretsManager, + this.mementoManager, + this.vscodeProposed, + this.logger, + ); } getVsCodeProposed(): typeof vscode { @@ -67,6 +79,10 @@ export class ServiceContainer implements vscode.Disposable { return this.contextManager; } + getLoginCoordinator(): LoginCoordinator { + return this.loginCoordinator; + } + /** * Dispose of all services and clean up resources. */ diff --git a/src/core/contextManager.ts b/src/core/contextManager.ts index a5a18397..60d3cfa6 100644 --- a/src/core/contextManager.ts +++ b/src/core/contextManager.ts @@ -4,6 +4,7 @@ const CONTEXT_DEFAULTS = { "coder.authenticated": false, "coder.isOwner": false, "coder.loaded": false, + "coder.workspace.connected": false, "coder.workspace.updatable": false, } as const; @@ -12,10 +13,19 @@ type CoderContext = keyof typeof CONTEXT_DEFAULTS; export class ContextManager implements vscode.Disposable { private readonly context = new Map(); - public constructor() { - (Object.keys(CONTEXT_DEFAULTS) as CoderContext[]).forEach((key) => { + public constructor(extensionContext: vscode.ExtensionContext) { + for (const key of Object.keys(CONTEXT_DEFAULTS) as CoderContext[]) { this.set(key, CONTEXT_DEFAULTS[key]); - }); + } + this.setInternalContexts(extensionContext); + } + + private setInternalContexts(extensionContext: vscode.ExtensionContext): void { + vscode.commands.executeCommand( + "setContext", + "coder.devMode", + extensionContext.extensionMode === vscode.ExtensionMode.Development, + ); } public set(key: CoderContext, value: boolean): void { diff --git a/src/core/downloadProgress.ts b/src/core/downloadProgress.ts new file mode 100644 index 00000000..600c3139 --- /dev/null +++ b/src/core/downloadProgress.ts @@ -0,0 +1,44 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +export interface DownloadProgress { + bytesDownloaded: number; + totalBytes: number | null; + status: "downloading" | "verifying"; +} + +export async function writeProgress( + logPath: string, + progress: DownloadProgress, +): Promise { + await fs.mkdir(path.dirname(logPath), { recursive: true }); + await fs.writeFile(logPath, JSON.stringify({ ...progress }) + "\n"); +} + +export async function readProgress( + logPath: string, +): Promise { + try { + const content = await fs.readFile(logPath, "utf-8"); + const progress = JSON.parse(content) as DownloadProgress; + if ( + typeof progress.bytesDownloaded !== "number" || + (typeof progress.totalBytes !== "number" && + progress.totalBytes !== null) || + (progress.status !== "downloading" && progress.status !== "verifying") + ) { + return null; + } + return progress; + } catch { + return null; + } +} + +export async function clearProgress(logPath: string): Promise { + try { + await fs.rm(logPath, { force: true }); + } catch { + // If we cannot remove it now then we'll do it in the next startup + } +} diff --git a/src/core/mementoManager.ts b/src/core/mementoManager.ts index f79be46c..3cf4478e 100644 --- a/src/core/mementoManager.ts +++ b/src/core/mementoManager.ts @@ -7,27 +7,16 @@ export class MementoManager { constructor(private readonly memento: Memento) {} /** - * Add the URL to the list of recently accessed URLs in global storage, then - * set it as the last used URL. - * - * If the URL is falsey, then remove it as the last used URL and do not touch - * the history. + * Add a URL to the history of recently accessed URLs. + * Used by the URL picker to show recent deployments. */ - public async setUrl(url?: string): Promise { - await this.memento.update("url", url); + public async addToUrlHistory(url: string): Promise { if (url) { const history = this.withUrlHistory(url); await this.memento.update("urlHistory", history); } } - /** - * Get the last used URL. - */ - public getUrl(): string | undefined { - return this.memento.get("url"); - } - /** * Get the most recently accessed URLs (oldest to newest) with the provided * values appended. Duplicates will be removed. diff --git a/src/core/pathResolver.ts b/src/core/pathResolver.ts index 514e64fb..8c320088 100644 --- a/src/core/pathResolver.ts +++ b/src/core/pathResolver.ts @@ -1,4 +1,4 @@ -import * as path from "path"; +import * as path from "node:path"; import * as vscode from "vscode"; export class PathResolver { @@ -8,26 +8,28 @@ export class PathResolver { ) {} /** - * Return the directory for the deployment with the provided label to where - * the global Coder configs are stored. + * Return the directory for the deployment with the provided hostname to + * where the global Coder configs are stored. * - * If the label is empty, read the old deployment-unaware config instead. + * If the hostname is empty, read the old deployment-unaware config instead. * * The caller must ensure this directory exists before use. */ - public getGlobalConfigDir(label: string): string { - return label ? path.join(this.basePath, label) : this.basePath; + public getGlobalConfigDir(safeHostname: string): string { + return safeHostname + ? path.join(this.basePath, safeHostname) + : this.basePath; } /** - * Return the directory for a deployment with the provided label to where its - * binary is cached. + * Return the directory for a deployment with the provided hostname to where + * its binary is cached. * - * If the label is empty, read the old deployment-unaware config instead. + * If the hostname is empty, read the old deployment-unaware config instead. * * The caller must ensure this directory exists before use. */ - public getBinaryCachePath(label: string): string { + public getBinaryCachePath(safeHostname: string): string { const settingPath = vscode.workspace .getConfiguration() .get("coder.binaryDestination") @@ -36,7 +38,7 @@ export class PathResolver { settingPath || process.env.CODER_BINARY_DESTINATION?.trim(); return binaryPath ? path.normalize(binaryPath) - : path.join(this.getGlobalConfigDir(label), "bin"); + : path.join(this.getGlobalConfigDir(safeHostname), "bin"); } /** @@ -69,39 +71,39 @@ export class PathResolver { } /** - * Return the directory for the deployment with the provided label to where - * its session token is stored. + * Return the directory for the deployment with the provided hostname to + * where its session token is stored. * - * If the label is empty, read the old deployment-unaware config instead. + * If the hostname is empty, read the old deployment-unaware config instead. * * The caller must ensure this directory exists before use. */ - public getSessionTokenPath(label: string): string { - return path.join(this.getGlobalConfigDir(label), "session"); + public getSessionTokenPath(safeHostname: string): string { + return path.join(this.getGlobalConfigDir(safeHostname), "session"); } /** - * Return the directory for the deployment with the provided label to where - * its session token was stored by older code. + * Return the directory for the deployment with the provided hostname to + * where its session token was stored by older code. * - * If the label is empty, read the old deployment-unaware config instead. + * If the hostname is empty, read the old deployment-unaware config instead. * * The caller must ensure this directory exists before use. */ - public getLegacySessionTokenPath(label: string): string { - return path.join(this.getGlobalConfigDir(label), "session_token"); + public getLegacySessionTokenPath(safeHostname: string): string { + return path.join(this.getGlobalConfigDir(safeHostname), "session_token"); } /** - * Return the directory for the deployment with the provided label to where - * its url is stored. + * Return the directory for the deployment with the provided hostname to + * where its url is stored. * - * If the label is empty, read the old deployment-unaware config instead. + * If the hostname is empty, read the old deployment-unaware config instead. * * The caller must ensure this directory exists before use. */ - public getUrlPath(label: string): string { - return path.join(this.getGlobalConfigDir(label), "url"); + public getUrlPath(safeHostname: string): string { + return path.join(this.getGlobalConfigDir(safeHostname), "url"); } /** diff --git a/src/core/secretsManager.ts b/src/core/secretsManager.ts index 94827b15..e6558299 100644 --- a/src/core/secretsManager.ts +++ b/src/core/secretsManager.ts @@ -1,73 +1,237 @@ -import type { SecretStorage, Disposable } from "vscode"; +import { type Logger } from "../logging/logger"; +import { toSafeHost } from "../util"; -const SESSION_TOKEN_KEY = "sessionToken"; +import type { Memento, SecretStorage, Disposable } from "vscode"; -const LOGIN_STATE_KEY = "loginState"; +import type { Deployment } from "../deployment/types"; -export enum AuthAction { - LOGIN, - LOGOUT, - INVALID, +// Each deployment has its own key to ensure atomic operations (multiple windows +// writing to a shared key could drop data) and to receive proper VS Code events. +const SESSION_KEY_PREFIX = "coder.session."; + +const CURRENT_DEPLOYMENT_KEY = "coder.currentDeployment"; + +const DEPLOYMENT_USAGE_KEY = "coder.deploymentUsage"; +const DEFAULT_MAX_DEPLOYMENTS = 10; + +const LEGACY_SESSION_TOKEN_KEY = "sessionToken"; + +export interface CurrentDeploymentState { + deployment: Deployment | null; +} + +export interface SessionAuth { + url: string; + token: string; +} + +// Tracks when a deployment was last accessed for LRU pruning. +interface DeploymentUsage { + safeHostname: string; + lastAccessedAt: string; } export class SecretsManager { - constructor(private readonly secrets: SecretStorage) {} + constructor( + private readonly secrets: SecretStorage, + private readonly memento: Memento, + private readonly logger: Logger, + ) {} /** - * Set or unset the last used token. + * Sets the current deployment and triggers a cross-window sync event. */ - public async setSessionToken(sessionToken?: string): Promise { - if (!sessionToken) { - await this.secrets.delete(SESSION_TOKEN_KEY); - } else { - await this.secrets.store(SESSION_TOKEN_KEY, sessionToken); - } + public async setCurrentDeployment( + deployment: Deployment | undefined, + ): Promise { + const state: CurrentDeploymentState & { timestamp: string } = { + // Extract the necessary fields before serializing + deployment: deployment + ? { + url: deployment?.url, + safeHostname: deployment?.safeHostname, + } + : null, + timestamp: new Date().toISOString(), + }; + await this.secrets.store(CURRENT_DEPLOYMENT_KEY, JSON.stringify(state)); } /** - * Get the last used token. + * Gets the current deployment from storage. */ - public async getSessionToken(): Promise { + public async getCurrentDeployment(): Promise { try { - return await this.secrets.get(SESSION_TOKEN_KEY); + const data = await this.secrets.get(CURRENT_DEPLOYMENT_KEY); + if (!data) { + return null; + } + const parsed = JSON.parse(data) as CurrentDeploymentState; + return parsed.deployment; } catch { - // The VS Code session store has become corrupt before, and - // will fail to get the session token... - return undefined; + return null; } } /** - * Triggers a login/logout event that propagates across all VS Code windows. - * Uses the secrets storage onDidChange event as a cross-window communication mechanism. - * Appends a timestamp to ensure the value always changes, guaranteeing the event fires. + * Listens for deployment changes from any VS Code window. + * Fires when login, logout, or deployment switch occurs. */ - public async triggerLoginStateChange( - action: "login" | "logout", - ): Promise { - const date = new Date().toISOString(); - await this.secrets.store(LOGIN_STATE_KEY, `${action}-${date}`); + public onDidChangeCurrentDeployment( + listener: (state: CurrentDeploymentState) => void | Promise, + ): Disposable { + return this.secrets.onDidChange(async (e) => { + if (e.key !== CURRENT_DEPLOYMENT_KEY) { + return; + } + + const deployment = await this.getCurrentDeployment(); + try { + await listener({ deployment }); + } catch (err) { + this.logger.error( + "Error in onDidChangeCurrentDeployment listener", + err, + ); + } + }); } /** - * Listens for login/logout events from any VS Code window. - * The secrets storage onDidChange event fires across all windows, enabling cross-window sync. + * Listen for changes to a specific deployment's session auth. */ - public onDidChangeLoginState( - listener: (state: AuthAction) => Promise, + public onDidChangeSessionAuth( + safeHostname: string, + listener: (auth: SessionAuth | undefined) => void | Promise, ): Disposable { + const sessionKey = this.getSessionKey(safeHostname); return this.secrets.onDidChange(async (e) => { - if (e.key === LOGIN_STATE_KEY) { - const state = await this.secrets.get(LOGIN_STATE_KEY); - if (state?.startsWith("login")) { - listener(AuthAction.LOGIN); - } else if (state?.startsWith("logout")) { - listener(AuthAction.LOGOUT); - } else { - // Secret was deleted or is invalid - listener(AuthAction.INVALID); - } + if (e.key !== sessionKey) { + return; + } + const auth = await this.getSessionAuth(safeHostname); + try { + await listener(auth); + } catch (err) { + this.logger.error("Error in onDidChangeSessionAuth listener", err); } }); } + + public async getSessionAuth( + safeHostname: string, + ): Promise { + const sessionKey = this.getSessionKey(safeHostname); + try { + const data = await this.secrets.get(sessionKey); + if (!data) { + return undefined; + } + return JSON.parse(data) as SessionAuth; + } catch { + return undefined; + } + } + + public async setSessionAuth( + safeHostname: string, + auth: SessionAuth, + ): Promise { + const sessionKey = this.getSessionKey(safeHostname); + // Extract only url and token before serializing + const state: SessionAuth = { url: auth.url, token: auth.token }; + await this.secrets.store(sessionKey, JSON.stringify(state)); + await this.recordDeploymentAccess(safeHostname); + } + + private async clearSessionAuth(safeHostname: string): Promise { + const sessionKey = this.getSessionKey(safeHostname); + await this.secrets.delete(sessionKey); + } + + private getSessionKey(safeHostname: string): string { + return `${SESSION_KEY_PREFIX}${safeHostname || ""}`; + } + + /** + * Record that a deployment was accessed, moving it to the front of the LRU list. + * Prunes deployments beyond maxCount, clearing their auth data. + */ + public async recordDeploymentAccess( + safeHostname: string, + maxCount = DEFAULT_MAX_DEPLOYMENTS, + ): Promise { + const usage = this.getDeploymentUsage(); + const filtered = usage.filter((u) => u.safeHostname !== safeHostname); + filtered.unshift({ + safeHostname, + lastAccessedAt: new Date().toISOString(), + }); + + const toKeep = filtered.slice(0, maxCount); + const toRemove = filtered.slice(maxCount); + + await Promise.all( + toRemove.map((u) => this.clearAllAuthData(u.safeHostname)), + ); + await this.memento.update(DEPLOYMENT_USAGE_KEY, toKeep); + } + + /** + * Clear all auth data for a deployment and remove it from the usage list. + */ + public async clearAllAuthData(safeHostname: string): Promise { + await this.clearSessionAuth(safeHostname); + const usage = this.getDeploymentUsage().filter( + (u) => u.safeHostname !== safeHostname, + ); + await this.memento.update(DEPLOYMENT_USAGE_KEY, usage); + } + + /** + * Get all known hostnames, ordered by most recently accessed. + */ + public getKnownSafeHostnames(): string[] { + return this.getDeploymentUsage().map((u) => u.safeHostname); + } + + /** + * Get the full deployment usage list with access timestamps. + */ + private getDeploymentUsage(): DeploymentUsage[] { + return this.memento.get(DEPLOYMENT_USAGE_KEY) ?? []; + } + + /** + * Migrate from legacy flat sessionToken storage to new format. + * Also sets the current deployment if none exists. + */ + public async migrateFromLegacyStorage(): Promise { + const legacyUrl = this.memento.get("url"); + if (!legacyUrl) { + return undefined; + } + + const oldToken = await this.secrets.get(LEGACY_SESSION_TOKEN_KEY); + + await this.secrets.delete(LEGACY_SESSION_TOKEN_KEY); + await this.memento.update("url", undefined); + + const safeHostname = toSafeHost(legacyUrl); + const existing = await this.getSessionAuth(safeHostname); + if (!existing) { + await this.setSessionAuth(safeHostname, { + url: legacyUrl, + token: oldToken ?? "", + }); + } + + // Also set as current deployment if none exists + const currentDeployment = await this.getCurrentDeployment(); + if (!currentDeployment) { + await this.setCurrentDeployment({ url: legacyUrl, safeHostname }); + } + + return safeHostname; + } } diff --git a/src/deployment/deploymentManager.ts b/src/deployment/deploymentManager.ts new file mode 100644 index 00000000..850d2176 --- /dev/null +++ b/src/deployment/deploymentManager.ts @@ -0,0 +1,227 @@ +import { CoderApi } from "../api/coderApi"; + +import type { User } from "coder/site/src/api/typesGenerated"; +import type * as vscode from "vscode"; + +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 { Logger } from "../logging/logger"; +import type { WorkspaceProvider } from "../workspace/workspacesProvider"; + +import type { Deployment, DeploymentWithAuth } from "./types"; + +/** + * Internal state type that allows mutation of user property. + */ +type DeploymentWithUser = Deployment & { user: User }; + +/** + * Manages deployment state for the extension. + * + * Centralizes: + * - In-memory deployment state (url, label, token, user) + * - Client credential updates + * - Auth listener registration + * - Context updates (coder.authenticated, coder.isOwner) + * - Workspace provider refresh + * - Cross-window sync handling + */ +export class DeploymentManager implements vscode.Disposable { + private readonly secretsManager: SecretsManager; + private readonly mementoManager: MementoManager; + private readonly contextManager: ContextManager; + private readonly logger: Logger; + + #deployment: DeploymentWithUser | null = null; + #authListenerDisposable: vscode.Disposable | undefined; + #crossWindowSyncDisposable: vscode.Disposable | undefined; + + private constructor( + serviceContainer: ServiceContainer, + private readonly client: CoderApi, + private readonly workspaceProviders: WorkspaceProvider[], + ) { + this.secretsManager = serviceContainer.getSecretsManager(); + this.mementoManager = serviceContainer.getMementoManager(); + this.contextManager = serviceContainer.getContextManager(); + this.logger = serviceContainer.getLogger(); + } + + public static create( + serviceContainer: ServiceContainer, + client: CoderApi, + workspaceProviders: WorkspaceProvider[], + ): DeploymentManager { + const manager = new DeploymentManager( + serviceContainer, + client, + workspaceProviders, + ); + manager.subscribeToCrossWindowChanges(); + return manager; + } + + /** + * Get the current deployment state. + */ + public getCurrentDeployment(): Deployment | null { + return this.#deployment; + } + + /** + * Check if we have an authenticated deployment (does not guarantee that the current auth data is valid). + */ + public isAuthenticated(): boolean { + return this.#deployment !== null; + } + + /** + * Attempt to change to a deployment after validating authentication. + * Only changes deployment if authentication succeeds. + * Returns true if deployment was changed, false otherwise. + */ + public async setDeploymentIfValid( + deployment: Deployment & { token?: string }, + ): Promise { + const auth = await this.secretsManager.getSessionAuth( + deployment.safeHostname, + ); + const token = deployment.token ?? auth?.token; + const tempClient = CoderApi.create(deployment.url, token, this.logger); + + try { + const user = await tempClient.getAuthenticatedUser(); + + // Authentication succeeded - now change the deployment + await this.setDeployment({ + ...deployment, + token, + user, + }); + return true; + } catch (e) { + this.logger.warn("Failed to authenticate with deployment:", e); + return false; + } finally { + tempClient.dispose(); + } + } + + /** + * Change to a fully authenticated deployment (with user). + * Use this when you already have the user from a successful login. + */ + public async setDeployment( + deployment: DeploymentWithAuth & { user: User }, + ): Promise { + this.#deployment = { ...deployment }; + + // Updates client credentials + if (deployment.token === undefined) { + this.client.setHost(deployment.url); + } else { + this.client.setCredentials(deployment.url, deployment.token); + } + + this.registerAuthListener(); + this.updateAuthContexts(); + this.refreshWorkspaces(); + await this.persistDeployment(deployment); + } + + /** + * Clears the current deployment. + */ + public async clearDeployment(): Promise { + this.#authListenerDisposable?.dispose(); + this.#authListenerDisposable = undefined; + this.#deployment = null; + + this.client.setCredentials(undefined, undefined); + this.updateAuthContexts(); + this.refreshWorkspaces(); + + await this.secretsManager.setCurrentDeployment(undefined); + } + + public dispose(): void { + this.#authListenerDisposable?.dispose(); + this.#crossWindowSyncDisposable?.dispose(); + } + + /** + * Register auth listener for the current deployment. + * Updates credentials when they change (token refresh, cross-window sync). + */ + private registerAuthListener(): void { + if (!this.#deployment) { + return; + } + + // Capture hostname at registration time for the guard clause + const safeHostname = this.#deployment.safeHostname; + + this.#authListenerDisposable?.dispose(); + this.logger.debug("Registering auth listener for hostname", safeHostname); + this.#authListenerDisposable = this.secretsManager.onDidChangeSessionAuth( + safeHostname, + async (auth) => { + if (this.#deployment?.safeHostname !== safeHostname) { + return; + } + + if (auth) { + this.client.setCredentials(auth.url, auth.token); + } else { + await this.clearDeployment(); + } + }, + ); + } + + private subscribeToCrossWindowChanges(): void { + this.#crossWindowSyncDisposable = + this.secretsManager.onDidChangeCurrentDeployment( + async ({ deployment }) => { + if (this.isAuthenticated()) { + // Ignore if we are already authenticated + return; + } + + if (deployment) { + this.logger.info("Deployment changed from another window"); + await this.setDeploymentIfValid(deployment); + } + }, + ); + } + + /** + * Update authentication-related contexts. + */ + private updateAuthContexts(): void { + const user = this.#deployment?.user; + this.contextManager.set("coder.authenticated", Boolean(user)); + const isOwner = user?.roles.some((r) => r.name === "owner") ?? false; + this.contextManager.set("coder.isOwner", isOwner); + } + + /** + * Refresh all workspace providers asynchronously. + */ + private refreshWorkspaces(): void { + for (const provider of this.workspaceProviders) { + provider.fetchAndRefresh(); + } + } + + /** + * Persist deployment to storage for cross-window sync. + */ + private async persistDeployment(deployment: Deployment): Promise { + await this.secretsManager.setCurrentDeployment(deployment); + await this.mementoManager.addToUrlHistory(deployment.url); + } +} diff --git a/src/deployment/types.ts b/src/deployment/types.ts new file mode 100644 index 00000000..9200defb --- /dev/null +++ b/src/deployment/types.ts @@ -0,0 +1,23 @@ +import { type User } from "coder/site/src/api/typesGenerated"; + +/** + * Represents a Coder deployment with its URL and hostname. + * The safeHostname is used as a unique identifier for storing credentials and configuration. + * It is derived from the URL hostname (via toSafeHost) or from SSH host parsing. + */ +export interface Deployment { + readonly url: string; + readonly safeHostname: string; +} + +/** + * Deployment info with authentication credentials. + * Used when logging in or changing to a new deployment. + * + * - Undefined token means that we should not override the existing token (if any). + * - Undefined user means the deployment is set but not authenticated yet. + */ +export type DeploymentWithAuth = Deployment & { + readonly token?: string; + readonly user?: User; +}; diff --git a/src/error.ts b/src/error.ts index 70448d76..09cf173a 100644 --- a/src/error.ts +++ b/src/error.ts @@ -1,7 +1,11 @@ +import { + X509Certificate, + KeyUsagesExtension, + KeyUsageFlags, +} from "@peculiar/x509"; import { isAxiosError } from "axios"; import { isApiError, isApiErrorResponse } from "coder/site/src/api/errors"; -import * as forge from "node-forge"; -import * as tls from "tls"; +import * as tls from "node:tls"; import * as vscode from "vscode"; import { type Logger } from "./logging/logger"; @@ -23,10 +27,6 @@ export enum X509_ERR { UNTRUSTED_CHAIN = "Your Coder deployment's certificate chain does not appear to be trusted by this system. The root of the certificate chain must be added to this system's trust store. ", } -interface KeyUsage { - keyCertSign: boolean; -} - export class CertificateError extends Error { public static ActionAllowInsecure = "Allow Insecure"; public static ActionOK = "OK"; @@ -80,7 +80,7 @@ export class CertificateError extends Error { const url = new URL(address); const socket = tls.connect( { - port: parseInt(url.port, 10) || 443, + port: Number.parseInt(url.port, 10) || 443, host: url.hostname, rejectUnauthorized: false, }, @@ -91,29 +91,27 @@ export class CertificateError extends Error { throw new Error("no peer certificate"); } - // We use node-forge for two reasons: - // 1. Node/Electron only provide extended key usage. - // 2. Electron's checkIssued() will fail because it suffers from same - // the key usage bug that we are trying to work around here in the - // first place. - const cert = forge.pki.certificateFromPem(x509.toString()); - if (!cert.issued(cert)) { + // We use "@peculiar/x509" because Node's x509 returns an undefined `keyUsage`. + const cert = new X509Certificate(x509.toString()); + const isSelfIssued = cert.subject === cert.issuer; + if (!isSelfIssued) { return resolve(X509_ERR.PARTIAL_CHAIN); } // The key usage needs to exist but not have cert signing to fail. - const keyUsage = cert.getExtension({ name: "keyUsage" }) as - | KeyUsage - | undefined; - if (keyUsage && !keyUsage.keyCertSign) { - return resolve(X509_ERR.NON_SIGNING); - } else { - // This branch is currently untested; it does not appear possible to - // get the error "unable to verify" with a self-signed certificate - // unless the key usage was the issue since it would have errored - // with "self-signed certificate" instead. - return resolve(X509_ERR.UNTRUSTED_LEAF); + const extension = cert.getExtension(KeyUsagesExtension); + if (extension) { + const hasKeyCertSign = + extension.usages & KeyUsageFlags.keyCertSign; + if (!hasKeyCertSign) { + return resolve(X509_ERR.NON_SIGNING); + } } + // This branch is currently untested; it does not appear possible to + // get the error "unable to verify" with a self-signed certificate + // unless the key usage was the issue since it would have errored + // with "self-signed certificate" instead. + return resolve(X509_ERR.UNTRUSTED_LEAF); }, ); socket.on("error", reject); diff --git a/src/extension.ts b/src/extension.ts index aba94cfe..0afebc67 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,17 +2,20 @@ import axios, { isAxiosError } from "axios"; import { getErrorMessage } from "coder/site/src/api/errors"; -import * as module from "module"; +import { createRequire } from "node:module"; +import * as path from "node:path"; import * as vscode from "vscode"; import { errToStr } from "./api/api-helper"; import { CoderApi } from "./api/coderApi"; -import { needToken } from "./api/utils"; import { Commands } from "./commands"; import { ServiceContainer } from "./core/container"; -import { AuthAction } from "./core/secretsManager"; +import { type SecretsManager } from "./core/secretsManager"; +import { DeploymentManager } from "./deployment/deploymentManager"; import { CertificateError, getErrorDetail } from "./error"; +import { maybeAskUrl } from "./promptUtils"; import { Remote } from "./remote/remote"; +import { getRemoteSshExtension } from "./remote/sshExtension"; import { toSafeHost } from "./util"; import { WorkspaceProvider, @@ -32,29 +35,21 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // Cursor and VSCode are covered by ms remote, and the only other is windsurf for now // Means that vscodium is not supported by this for now - const remoteSSHExtension = - vscode.extensions.getExtension("jeanp413.open-remote-ssh") || - vscode.extensions.getExtension("codeium.windsurf-remote-openssh") || - vscode.extensions.getExtension("anysphere.remote-ssh") || - vscode.extensions.getExtension("ms-vscode-remote.remote-ssh"); + const remoteSshExtension = getRemoteSshExtension(); let vscodeProposed: typeof vscode = vscode; - if (!remoteSSHExtension) { + if (remoteSshExtension) { + const extensionRequire = createRequire( + path.join(remoteSshExtension.extensionPath, "package.json"), + ); + vscodeProposed = extensionRequire("vscode"); + } else { vscode.window.showErrorMessage( "Remote SSH extension not found, this may not work as expected.\n" + // NB should we link to documentation or marketplace? "Please install your choice of Remote SSH extension from the VS Code Marketplace.", ); - } else { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - vscodeProposed = (module as any)._load( - "vscode", - { - filename: remoteSSHExtension.extensionPath, - }, - false, - ); } const serviceContainer = new ServiceContainer(ctx, vscodeProposed); @@ -65,18 +60,24 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const secretsManager = serviceContainer.getSecretsManager(); const contextManager = serviceContainer.getContextManager(); + // Migrate auth storage from old flat format to new label-based format + await migrateAuthStorage(serviceContainer); + // Try to clear this flag ASAP const isFirstConnect = await mementoManager.getAndClearFirstConnect(); + const deployment = await secretsManager.getCurrentDeployment(); + // This client tracks the current login and will be used through the life of // the plugin to poll workspaces for the current login, as well as being used // in commands that operate on the current login. - const url = mementoManager.getUrl(); const client = CoderApi.create( - url || "", - await secretsManager.getSessionToken(), + deployment?.url || "", + (await secretsManager.getSessionAuth(deployment?.safeHostname ?? "")) + ?.token, output, ); + ctx.subscriptions.push(client); const myWorkspacesProvider = new WorkspaceProvider( WorkspaceQuery.Mine, @@ -121,11 +122,18 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ctx.subscriptions, ); + // Create deployment manager to centralize deployment state management + const deploymentManager = DeploymentManager.create(serviceContainer, client, [ + myWorkspacesProvider, + allWorkspacesProvider, + ]); + ctx.subscriptions.push(deploymentManager); + // Handle vscode:// URIs. const uriHandler = vscode.window.registerUriHandler({ handleUri: async (uri) => { - const cliManager = serviceContainer.getCliManager(); const params = new URLSearchParams(uri.query); + if (uri.path === "/open") { const owner = params.get("owner"); const workspace = params.get("workspace"); @@ -142,48 +150,13 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { throw new Error("workspace must be specified as a query parameter"); } - // We are not guaranteed that the URL we currently have is for the URL - // this workspace belongs to, or that we even have a URL at all (the - // queries will default to localhost) so ask for it if missing. - // Pre-populate in case we do have the right URL so the user can just - // hit enter and move on. - const url = await commands.maybeAskUrl( - params.get("url"), - mementoManager.getUrl(), - ); - if (url) { - client.setHost(url); - await mementoManager.setUrl(url); - } else { - throw new Error( - "url must be provided or specified as a query parameter", - ); - } - - // If the token is missing we will get a 401 later and the user will be - // prompted to sign in again, so we do not need to ensure it is set now. - // For non-token auth, we write a blank token since the `vscodessh` - // command currently always requires a token file. However, if there is - // a query parameter for non-token auth go ahead and use it anyway; all - // that really matters is the file is created. - const token = needToken(vscode.workspace.getConfiguration()) - ? params.get("token") - : (params.get("token") ?? ""); - - if (token) { - client.setSessionToken(token); - await secretsManager.setSessionToken(token); - } - - // Store on disk to be used by the cli. - await cliManager.configure(toSafeHost(url), url, token); + await setupDeploymentFromUri(params, serviceContainer); - vscode.commands.executeCommand( - "coder.open", + await commands.open( owner, workspace, - agent, - folder, + agent ?? undefined, + folder ?? undefined, openRecent, ); } else if (uri.path === "/openDevContainer") { @@ -207,6 +180,12 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ); } + if (!workspaceAgent) { + throw new Error( + "workspace agent must be specified as a query parameter", + ); + } + if (!devContainerName) { throw new Error( "dev container name must be specified as a query parameter", @@ -225,46 +204,16 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ); } - // We are not guaranteed that the URL we currently have is for the URL - // this workspace belongs to, or that we even have a URL at all (the - // queries will default to localhost) so ask for it if missing. - // Pre-populate in case we do have the right URL so the user can just - // hit enter and move on. - const url = await commands.maybeAskUrl( - params.get("url"), - mementoManager.getUrl(), - ); - if (url) { - client.setHost(url); - await mementoManager.setUrl(url); - } else { - throw new Error( - "url must be provided or specified as a query parameter", - ); - } + await setupDeploymentFromUri(params, serviceContainer); - // If the token is missing we will get a 401 later and the user will be - // prompted to sign in again, so we do not need to ensure it is set now. - // For non-token auth, we write a blank token since the `vscodessh` - // command currently always requires a token file. However, if there is - // a query parameter for non-token auth go ahead and use it anyway; all - // that really matters is the file is created. - const token = needToken(vscode.workspace.getConfiguration()) - ? params.get("token") - : (params.get("token") ?? ""); - - // Store on disk to be used by the cli. - await cliManager.configure(toSafeHost(url), url, token); - - vscode.commands.executeCommand( - "coder.openDevContainer", + await commands.openDevContainer( workspaceOwner, workspaceName, workspaceAgent, devContainerName, devContainerFolder, - localWorkspaceFolder, - localConfigFile, + localWorkspaceFolder ?? "", + localConfigFile ?? "", ); } else { throw new Error(`Unknown path ${uri.path}`); @@ -275,7 +224,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // Register globally available commands. Many of these have visibility // controlled by contexts, see `when` in the package.json. - const commands = new Commands(serviceContainer, client); + const commands = new Commands(serviceContainer, client, deploymentManager); ctx.subscriptions.push( vscode.commands.registerCommand( "coder.login", @@ -328,30 +277,12 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { vscode.commands.registerCommand("coder.searchAllWorkspaces", async () => showTreeViewSearch(ALL_WORKSPACES_TREE_ID), ), + vscode.commands.registerCommand("coder.debug.listDeployments", () => + listStoredDeployments(secretsManager), + ), ); - const remote = new Remote(serviceContainer, commands, ctx.extensionMode); - - ctx.subscriptions.push( - secretsManager.onDidChangeLoginState(async (state) => { - switch (state) { - case AuthAction.LOGIN: { - const token = await secretsManager.getSessionToken(); - const url = mementoManager.getUrl(); - // Should login the user directly if the URL+Token are valid - await commands.login({ url, token }); - // Resolve any pending login detection promises - remote.resolveLoginDetected(); - break; - } - case AuthAction.LOGOUT: - await commands.forceLogout(); - break; - case AuthAction.INVALID: - break; - } - }), - ); + const remote = new Remote(serviceContainer, commands, ctx); // Since the "onResolveRemoteAuthority:ssh-remote" activation event exists // in package.json we're able to perform actions before the authority is @@ -362,18 +293,21 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // after the Coder extension is installed, instead of throwing a fatal error // (this would require the user to uninstall the Coder extension and // reinstall after installing the remote SSH extension, which is annoying) - if (remoteSSHExtension && vscodeProposed.env.remoteAuthority) { + if (remoteSshExtension && vscodeProposed.env.remoteAuthority) { try { const details = await remote.setup( vscodeProposed.env.remoteAuthority, isFirstConnect, + remoteSshExtension.id, ); if (details) { ctx.subscriptions.push(details); - // Authenticate the plugin client which is used in the sidebar to display - // workspaces belonging to this deployment. - client.setHost(details.url); - client.setSessionToken(details.token); + + await deploymentManager.setDeploymentIfValid({ + safeHostname: details.safeHostname, + url: details.url, + token: details.token, + }); } } catch (ex) { if (ex instanceof CertificateError) { @@ -413,31 +347,23 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } } - // See if the plugin client is authenticated. - const baseUrl = client.getAxiosInstance().defaults.baseURL; - if (baseUrl) { - output.info(`Logged in to ${baseUrl}; checking credentials`); - client - .getAuthenticatedUser() - .then((user) => { - if (user && user.roles) { - output.info("Credentials are valid"); - contextManager.set("coder.authenticated", true); - if (user.roles.find((role) => role.name === "owner")) { - contextManager.set("coder.isOwner", true); - } - - // Fetch and monitor workspaces, now that we know the client is good. - myWorkspacesProvider.fetchAndRefresh(); - allWorkspacesProvider.fetchAndRefresh(); + // Initialize deployment manager with stored deployment (if any). + // Skip if already set by remote.setup above. + if (deploymentManager.getCurrentDeployment()) { + contextManager.set("coder.loaded", true); + } else if (deployment) { + output.info(`Initializing deployment: ${deployment.url}`); + deploymentManager + .setDeploymentIfValid(deployment) + .then((success) => { + if (success) { + output.info("Deployment authenticated and set"); } else { - output.warn("No error, but got unexpected response", user); + output.info("Failed to authenticate, deployment not set"); } }) .catch((error) => { - // This should be a failure to make the request, like the header command - // errored. - output.warn("Failed to check user authentication", error); + output.warn("Failed to initialize deployment", error); vscode.window.showErrorMessage( `Failed to check user authentication: ${error.message}`, ); @@ -462,7 +388,101 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } } +/** + * Migrates old flat storage (sessionToken) to new label-based map storage. + * This is a one-time operation that runs on extension activation. + */ +async function migrateAuthStorage( + serviceContainer: ServiceContainer, +): Promise { + const secretsManager = serviceContainer.getSecretsManager(); + const output = serviceContainer.getLogger(); + + try { + const migratedHostname = await secretsManager.migrateFromLegacyStorage(); + + if (migratedHostname) { + output.info( + `Successfully migrated auth storage (hostname: ${migratedHostname})`, + ); + } + } catch (error) { + output.error( + `Auth storage migration failed: ${error}. You may need to log in again.`, + ); + } +} + async function showTreeViewSearch(id: string): Promise { await vscode.commands.executeCommand(`${id}.focus`); await vscode.commands.executeCommand("list.find"); } + +/** + * Sets up deployment from URI parameters. Handles URL prompting, client setup, + * and token storage. Throws if user cancels URL input. + */ +async function setupDeploymentFromUri( + params: URLSearchParams, + serviceContainer: ServiceContainer, +): Promise { + const secretsManager = serviceContainer.getSecretsManager(); + const mementoManager = serviceContainer.getMementoManager(); + const currentDeployment = await secretsManager.getCurrentDeployment(); + + // We are not guaranteed that the URL we currently have is for the URL + // this workspace belongs to, or that we even have a URL at all (the + // queries will default to localhost) so ask for it if missing. + // Pre-populate in case we do have the right URL so the user can just + // hit enter and move on. + const url = await maybeAskUrl( + mementoManager, + params.get("url"), + currentDeployment?.url, + ); + if (!url) { + throw new Error("url must be provided or specified as a query parameter"); + } + + const safeHost = toSafeHost(url); + + // If the token is missing we will get a 401 later and the user will be + // prompted to sign in again, so we do not need to ensure it is set now. + const token: string | null = params.get("token"); + if (token === null) { + // We need to ensure there is at least an entry for this in storage + // so that we know what URL to prompt the user with when logging in. + const auth = await secretsManager.getSessionAuth(safeHost); + if (!auth) { + // Racy, we could accidentally overwrite the token that is written in the meantime. + await secretsManager.setSessionAuth(safeHost, { url, token: "" }); + } + } else { + await secretsManager.setSessionAuth(safeHost, { url, token }); + } +} + +async function listStoredDeployments( + secretsManager: SecretsManager, +): Promise { + const hostnames = secretsManager.getKnownSafeHostnames(); + if (hostnames.length === 0) { + vscode.window.showInformationMessage("No deployments stored."); + return; + } + + const selected = await vscode.window.showQuickPick( + hostnames.map((hostname) => ({ + label: hostname, + description: "Click to forget", + })), + { placeHolder: "Select a deployment to forget" }, + ); + + if (selected) { + await secretsManager.clearAllAuthData(selected.label); + vscode.window.showInformationMessage( + `Cleared auth data for ${selected.label}`, + ); + } +} diff --git a/src/globalFlags.ts b/src/globalFlags.ts deleted file mode 100644 index 8e75ce8d..00000000 --- a/src/globalFlags.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { type WorkspaceConfiguration } from "vscode"; - -import { getHeaderArgs } from "./headers"; -import { escapeCommandArg } from "./util"; - -/** - * Returns global configuration flags for Coder CLI commands. - * Always includes the `--global-config` argument with the specified config directory. - */ -export function getGlobalFlags( - configs: WorkspaceConfiguration, - configDir: string, -): string[] { - // Last takes precedence/overrides previous ones - return [ - ...(configs.get("coder.globalFlags") || []), - ...["--global-config", escapeCommandArg(configDir)], - ...getHeaderArgs(configs), - ]; -} diff --git a/src/headers.ts b/src/headers.ts index f5f45301..435b2ad3 100644 --- a/src/headers.ts +++ b/src/headers.ts @@ -18,16 +18,18 @@ function isExecException(err: unknown): err is ExecException { } export function getHeaderCommand( - config: WorkspaceConfiguration, + config: Pick, ): string | undefined { const cmd = config.get("coder.headerCommand")?.trim() || process.env.CODER_HEADER_COMMAND?.trim(); - return cmd ? cmd : undefined; + return cmd || undefined; } -export function getHeaderArgs(config: WorkspaceConfiguration): string[] { +export function getHeaderArgs( + config: Pick, +): string[] { // Escape a command line to be executed by the Coder binary, so ssh doesn't substitute variables. const escapeSubcommand: (str: string) => string = os.platform() === "win32" @@ -44,16 +46,13 @@ export function getHeaderArgs(config: WorkspaceConfiguration): string[] { return ["--header-command", escapeSubcommand(command)]; } -// TODO: getHeaders might make more sense to directly implement on Storage -// but it is difficult to test Storage right now since we use vitest instead of -// the standard extension testing framework which would give us access to vscode -// APIs. We should revert the testing framework then consider moving this. - -// getHeaders executes the header command and parses the headers from stdout. -// Both stdout and stderr are logged on error but stderr is otherwise ignored. -// Throws an error if the process exits with non-zero or the JSON is invalid. -// Returns undefined if there is no header command set. No effort is made to -// validate the JSON other than making sure it can be parsed. +/** + * getHeaders executes the header command and parses the headers from stdout. + * Both stdout and stderr are logged on error but stderr is otherwise ignored. + * Throws an error if the process exits with non-zero or the JSON is invalid. + * Returns undefined if there is no header command set. No effort is made to + * validate the JSON other than making sure it can be parsed. + */ export async function getHeaders( url: string | undefined, command: string | undefined, @@ -90,8 +89,8 @@ export async function getHeaders( return headers; } const lines = result.stdout.replace(/\r?\n$/, "").split(/\r?\n/); - for (let i = 0; i < lines.length; ++i) { - const [key, value] = lines[i].split(/=(.*)/); + for (const line of lines) { + const [key, value] = line.split(/=(.*)/); // Header names cannot be blank or contain whitespace and the Coder CLI // requires that there be an equals sign (the value can be blank though). if ( @@ -100,7 +99,7 @@ export async function getHeaders( typeof value === "undefined" ) { throw new Error( - `Malformed line from header command: [${lines[i]}] (out: ${result.stdout})`, + `Malformed line from header command: [${line}] (out: ${result.stdout})`, ); } headers[key] = value; diff --git a/src/inbox.ts b/src/inbox.ts index 61a780bb..59b9ae0b 100644 --- a/src/inbox.ts +++ b/src/inbox.ts @@ -7,7 +7,7 @@ import type { import type { CoderApi } from "./api/coderApi"; import type { Logger } from "./logging/logger"; -import type { OneWayWebSocket } from "./websocket/oneWayWebSocket"; +import type { UnidirectionalStream } from "./websocket/eventStreamConnection"; // These are the template IDs of our notifications. // Maybe in the future we should avoid hardcoding @@ -16,12 +16,23 @@ const TEMPLATE_WORKSPACE_OUT_OF_MEMORY = "a9d027b4-ac49-4fb1-9f6d-45af15f64e7a"; const TEMPLATE_WORKSPACE_OUT_OF_DISK = "f047f6a3-5713-40f7-85aa-0394cce9fa3a"; export class Inbox implements vscode.Disposable { - readonly #logger: Logger; - #disposed = false; - #socket: OneWayWebSocket; + private socket: + | UnidirectionalStream + | undefined; + private disposed = false; - constructor(workspace: Workspace, client: CoderApi, logger: Logger) { - this.#logger = logger; + private constructor(private readonly logger: Logger) {} + + /** + * Factory method to create and initialize an Inbox. + * Use this instead of the constructor to properly handle async websocket initialization. + */ + static async create( + workspace: Workspace, + client: CoderApi, + logger: Logger, + ): Promise { + const inbox = new Inbox(logger); const watchTemplates = [ TEMPLATE_WORKSPACE_OUT_OF_DISK, @@ -30,33 +41,40 @@ export class Inbox implements vscode.Disposable { const watchTargets = [workspace.id]; - this.#socket = client.watchInboxNotifications(watchTemplates, watchTargets); + const socket = await client.watchInboxNotifications( + watchTemplates, + watchTargets, + ); - this.#socket.addEventListener("open", () => { - this.#logger.info("Listening to Coder Inbox"); + socket.addEventListener("open", () => { + logger.info("Listening to Coder Inbox"); }); - this.#socket.addEventListener("error", () => { + socket.addEventListener("error", () => { // Errors are already logged internally - this.dispose(); + inbox.dispose(); }); - this.#socket.addEventListener("message", (data) => { + socket.addEventListener("message", (data) => { if (data.parseError) { - this.#logger.error("Failed to parse inbox message", data.parseError); + logger.error("Failed to parse inbox message", data.parseError); } else { vscode.window.showInformationMessage( data.parsedMessage.notification.title, ); } }); + + inbox.socket = socket; + + return inbox; } dispose() { - if (!this.#disposed) { - this.#logger.info("No longer listening to Coder Inbox"); - this.#socket.close(); - this.#disposed = true; + if (!this.disposed) { + this.logger.info("No longer listening to Coder Inbox"); + this.socket?.close(); + this.disposed = true; } } } diff --git a/src/logging/wsLogger.ts b/src/logging/eventStreamLogger.ts similarity index 77% rename from src/logging/wsLogger.ts rename to src/logging/eventStreamLogger.ts index fd6acd00..224f52b7 100644 --- a/src/logging/wsLogger.ts +++ b/src/logging/eventStreamLogger.ts @@ -12,31 +12,35 @@ const numFormatter = new Intl.NumberFormat("en", { compactDisplay: "short", }); -export class WsLogger { +export class EventStreamLogger { private readonly logger: Logger; private readonly url: string; private readonly id: string; + private readonly protocol: string; private readonly startedAt: number; private openedAt?: number; private msgCount = 0; private byteCount = 0; private unknownByteCount = false; - constructor(logger: Logger, url: string) { + constructor(logger: Logger, url: string, protocol: "WS" | "SSE") { this.logger = logger; this.url = url; + this.protocol = protocol; this.id = createRequestId(); this.startedAt = Date.now(); } logConnecting(): void { - this.logger.trace(`→ WS ${shortId(this.id)} ${this.url}`); + this.logger.trace(`→ ${this.protocol} ${shortId(this.id)} ${this.url}`); } logOpen(): void { this.openedAt = Date.now(); const time = formatTime(this.openedAt - this.startedAt); - this.logger.trace(`← WS ${shortId(this.id)} connected ${this.url} ${time}`); + this.logger.trace( + `← ${this.protocol} ${shortId(this.id)} connected ${this.url} ${time}`, + ); } logMessage(data: unknown): void { @@ -62,7 +66,7 @@ export class WsLogger { const statsStr = ` [${stats.join(", ")}]`; this.logger.trace( - `▣ WS ${shortId(this.id)} closed ${this.url}${codeStr}${reasonStr}${statsStr}`, + `▣ ${this.protocol} ${shortId(this.id)} closed ${this.url}${codeStr}${reasonStr}${statsStr}`, ); } @@ -70,7 +74,7 @@ export class WsLogger { const time = formatTime(Date.now() - this.startedAt); const errorMsg = message || errToStr(error, "connection error"); this.logger.error( - `✗ WS ${shortId(this.id)} error ${this.url} ${time} - ${errorMsg}`, + `✗ ${this.protocol} ${shortId(this.id)} error ${this.url} ${time} - ${errorMsg}`, error, ); } diff --git a/src/login/loginCoordinator.ts b/src/login/loginCoordinator.ts new file mode 100644 index 00000000..8b5d7b07 --- /dev/null +++ b/src/login/loginCoordinator.ts @@ -0,0 +1,300 @@ +import { isAxiosError } from "axios"; +import { getErrorMessage } from "coder/site/src/api/errors"; +import * as vscode from "vscode"; + +import { CoderApi } from "../api/coderApi"; +import { needToken } from "../api/utils"; +import { CertificateError } from "../error"; +import { maybeAskUrl } from "../promptUtils"; + +import type { User } from "coder/site/src/api/typesGenerated"; + +import type { MementoManager } from "../core/mementoManager"; +import type { SecretsManager } from "../core/secretsManager"; +import type { Deployment } from "../deployment/types"; +import type { Logger } from "../logging/logger"; + +type LoginResult = + | { success: false } + | { success: true; user?: User; token: string }; + +interface LoginOptions { + safeHostname: string; + url: string | undefined; + autoLogin?: boolean; +} + +/** + * Coordinates login prompts across windows and prevents duplicate dialogs. + */ +export class LoginCoordinator { + private readonly inProgressLogins = new Map>(); + + constructor( + private readonly secretsManager: SecretsManager, + private readonly mementoManager: MementoManager, + private readonly vscodeProposed: typeof vscode, + private readonly logger: Logger, + ) {} + + /** + * Direct login - for user-initiated login via commands. + * Stores session auth and URL history on success. + */ + public async ensureLoggedIn( + options: LoginOptions & { url: string }, + ): Promise { + const { safeHostname, url } = options; + return this.executeWithGuard(safeHostname, async () => { + const result = await this.attemptLogin( + { safeHostname, url }, + options.autoLogin ?? false, + ); + + await this.persistSessionAuth(result, safeHostname, url); + + return result; + }); + } + + /** + * Shows dialog then login - for system-initiated auth (remote). + */ + public async ensureLoggedInWithDialog( + options: LoginOptions & { message?: string; detailPrefix?: string }, + ): Promise { + const { safeHostname, url, detailPrefix, message } = options; + return this.executeWithGuard(safeHostname, async () => { + // Show dialog promise + const dialogPromise = this.vscodeProposed.window + .showErrorMessage( + message || "Authentication Required", + { + modal: true, + useCustom: true, + detail: + (detailPrefix || `Authentication needed for ${safeHostname}.`) + + "\n\nIf you've already logged in, you may close this dialog.", + }, + "Login", + ) + .then(async (action) => { + if (action === "Login") { + // Proceed with the login flow, handling logging in from another window + const storedAuth = + await this.secretsManager.getSessionAuth(safeHostname); + const newUrl = await maybeAskUrl( + this.mementoManager, + url, + storedAuth?.url, + ); + if (!newUrl) { + throw new Error("URL must be provided"); + } + + const result = await this.attemptLogin( + { url: newUrl, safeHostname }, + false, + ); + + await this.persistSessionAuth(result, safeHostname, newUrl); + + return result; + } else { + // User cancelled + return { success: false } as const; + } + }); + + // Race between user clicking login and cross-window detection + const { + promise: crossWindowPromise, + dispose: disposeCrossWindowListener, + } = this.waitForCrossWindowLogin(safeHostname); + try { + return await Promise.race([dialogPromise, crossWindowPromise]); + } finally { + disposeCrossWindowListener(); + } + }); + } + + private async persistSessionAuth( + result: LoginResult, + safeHostname: string, + url: string, + ): Promise { + // Empty token is valid for mTLS + if (result.success) { + await this.secretsManager.setSessionAuth(safeHostname, { + url, + token: result.token, + }); + await this.mementoManager.addToUrlHistory(url); + } + } + + /** + * Same-window guard wrapper. + */ + private async executeWithGuard( + safeHostname: string, + executeFn: () => Promise, + ): Promise { + const existingLogin = this.inProgressLogins.get(safeHostname); + if (existingLogin) { + return existingLogin; + } + + const loginPromise = executeFn(); + this.inProgressLogins.set(safeHostname, loginPromise); + + try { + return await loginPromise; + } finally { + this.inProgressLogins.delete(safeHostname); + } + } + + /** + * Waits for login detected from another window. + * Returns a promise and a dispose function to clean up the listener. + */ + private waitForCrossWindowLogin(safeHostname: string): { + promise: Promise; + dispose: () => void; + } { + let disposable: vscode.Disposable | undefined; + const promise = new Promise((resolve) => { + disposable = this.secretsManager.onDidChangeSessionAuth( + safeHostname, + (auth) => { + if (auth?.token) { + disposable?.dispose(); + resolve({ success: true, token: auth.token }); + } + }, + ); + }); + return { + promise, + dispose: () => disposable?.dispose(), + }; + } + + /** + * Attempt to authenticate using token, or mTLS. If necessary, prompts + * for authentication method and credentials. Returns the token and user upon + * successful authentication. Null means the user aborted or authentication + * failed (in which case an error notification will have been displayed). + */ + private async attemptLogin( + deployment: Deployment, + isAutoLogin: boolean, + ): Promise { + const needsToken = needToken(vscode.workspace.getConfiguration()); + const client = CoderApi.create(deployment.url, "", this.logger); + + let storedToken: string | undefined; + if (needsToken) { + const auth = await this.secretsManager.getSessionAuth( + deployment.safeHostname, + ); + storedToken = auth?.token; + if (storedToken) { + client.setSessionToken(storedToken); + } + } + + // Attempt authentication with current credentials (token or mTLS) + try { + if (!needsToken || storedToken) { + const user = await client.getAuthenticatedUser(); + // Return the token that was used (empty string for mTLS since + // the `vscodessh` command currently always requires a token file) + return { success: true, token: storedToken ?? "", user }; + } + } catch (err) { + const is401 = isAxiosError(err) && err.response?.status === 401; + if (needsToken && is401) { + // For token auth with 401: silently continue to prompt for new credentials + } else { + // For mTLS or non-401 errors: show error and abort + const message = getErrorMessage(err, "no response from the server"); + if (isAutoLogin) { + this.logger.warn("Failed to log in to Coder server:", message); + } else { + this.vscodeProposed.window.showErrorMessage( + "Failed to log in to Coder server", + { + detail: message, + modal: true, + useCustom: true, + }, + ); + } + return { success: false }; + } + } + + const result = await this.loginWithToken(client); + return result; + } + + /** + * Session token authentication flow. + */ + private async loginWithToken(client: CoderApi): Promise { + const url = client.getAxiosInstance().defaults.baseURL; + if (!url) { + throw new Error("No base URL set on REST client"); + } + // This prompt is for convenience; do not error if they close it since + // they may already have a token or already have the page opened. + await vscode.env.openExternal(vscode.Uri.parse(`${url}/cli-auth`)); + + // For token auth, start with the existing token in the prompt or the last + // used token. Once submitted, if there is a failure we will keep asking + // the user for a new token until they quit. + let user: User | undefined; + const validatedToken = await vscode.window.showInputBox({ + title: "Coder API Key", + password: true, + placeHolder: "Paste your API key.", + ignoreFocusOut: true, + validateInput: async (value) => { + if (!value) { + return null; + } + client.setSessionToken(value); + try { + user = await client.getAuthenticatedUser(); + } catch (err) { + // For certificate errors show both a notification and add to the + // text under the input box, since users sometimes miss the + // notification. + if (err instanceof CertificateError) { + err.showNotification(); + return { + message: err.x509Err || err.message, + severity: vscode.InputBoxValidationSeverity.Error, + }; + } + // This could be something like the header command erroring or an + // invalid session token. + const message = getErrorMessage(err, "no response from the server"); + return { + message: "Failed to authenticate: " + message, + severity: vscode.InputBoxValidationSeverity.Error, + }; + } + }, + }); + + if (user) { + return { success: true, user, token: validatedToken ?? "" }; + } + + return { success: false }; + } +} diff --git a/src/promptUtils.ts b/src/promptUtils.ts new file mode 100644 index 00000000..3fb31475 --- /dev/null +++ b/src/promptUtils.ts @@ -0,0 +1,132 @@ +import { type WorkspaceAgent } from "coder/site/src/api/typesGenerated"; +import * as vscode from "vscode"; + +import { type MementoManager } from "./core/mementoManager"; + +/** + * Find the requested agent if specified, otherwise return the agent if there + * is only one or ask the user to pick if there are multiple. Return + * undefined if the user cancels. + */ +export async function maybeAskAgent( + agents: WorkspaceAgent[], + filter?: string, +): Promise { + const filteredAgents = filter + ? agents.filter((agent) => agent.name === filter) + : agents; + if (filteredAgents.length === 0) { + throw new Error("Workspace has no matching agents"); + } else if (filteredAgents.length === 1) { + return filteredAgents[0]; + } else { + const quickPick = vscode.window.createQuickPick(); + quickPick.title = "Select an agent"; + quickPick.busy = true; + const agentItems: vscode.QuickPickItem[] = filteredAgents.map((agent) => { + let icon = "$(debug-start)"; + if (agent.status !== "connected") { + icon = "$(debug-stop)"; + } + return { + alwaysShow: true, + label: `${icon} ${agent.name}`, + detail: `${agent.name} • Status: ${agent.status}`, + }; + }); + quickPick.items = agentItems; + quickPick.busy = false; + quickPick.show(); + + const selected = await new Promise( + (resolve) => { + quickPick.onDidHide(() => resolve(undefined)); + quickPick.onDidChangeSelection((selected) => { + if (selected.length < 1) { + return resolve(undefined); + } + const agent = filteredAgents[quickPick.items.indexOf(selected[0])]; + resolve(agent); + }); + }, + ); + quickPick.dispose(); + return selected; + } +} + +/** + * Ask the user for the URL, letting them choose from a list of recent URLs or + * CODER_URL or enter a new one. Undefined means the user aborted. + */ +async function askURL( + mementoManager: MementoManager, + prePopulateUrl: string | undefined, +): Promise { + const defaultURL = vscode.workspace + .getConfiguration() + .get("coder.defaultUrl") + ?.trim(); + const quickPick = vscode.window.createQuickPick(); + quickPick.ignoreFocusOut = true; + quickPick.value = + prePopulateUrl || defaultURL || process.env.CODER_URL?.trim() || ""; + quickPick.placeholder = "https://example.coder.com"; + quickPick.title = "Enter the URL of your Coder deployment."; + + // Initial items. + quickPick.items = mementoManager + .withUrlHistory(defaultURL, process.env.CODER_URL) + .map((url) => ({ + alwaysShow: true, + label: url, + })); + + // Quick picks do not allow arbitrary values, so we add the value itself as + // an option in case the user wants to connect to something that is not in + // the list. + quickPick.onDidChangeValue((value) => { + quickPick.items = mementoManager + .withUrlHistory(defaultURL, process.env.CODER_URL, value) + .map((url) => ({ + alwaysShow: true, + label: url, + })); + }); + + quickPick.show(); + + const selected = await new Promise((resolve) => { + quickPick.onDidHide(() => resolve(undefined)); + quickPick.onDidChangeSelection((selected) => resolve(selected[0]?.label)); + }); + quickPick.dispose(); + return selected; +} + +/** + * Ask the user for the URL if it was not provided, letting them choose from a + * list of recent URLs or the default URL or CODER_URL or enter a new one, and + * normalizes the returned URL. Undefined means the user aborted. + */ +export async function maybeAskUrl( + mementoManager: MementoManager, + providedUrl: string | undefined | null, + prePopulateUrl?: string, +): Promise { + let url = providedUrl || (await askURL(mementoManager, prePopulateUrl)); + if (!url) { + // User aborted. + return undefined; + } + + // Normalize URL. + if (!url.startsWith("http://") && !url.startsWith("https://")) { + // Default to HTTPS if not provided so URLs can be typed more easily. + url = "https://" + url; + } + while (url.endsWith("/")) { + url = url.substring(0, url.length - 1); + } + return url; +} diff --git a/src/remote/remote.ts b/src/remote/remote.ts index 832a8086..974d956d 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -4,12 +4,11 @@ import { type Workspace, type WorkspaceAgent, } from "coder/site/src/api/typesGenerated"; -import find from "find-process"; -import * as fs from "fs/promises"; import * as jsonc from "jsonc-parser"; -import * as os from "os"; -import * as path from "path"; -import prettyBytes from "pretty-bytes"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { isDeepStrictEqual } from "node:util"; import * as semver from "semver"; import * as vscode from "vscode"; @@ -18,37 +17,38 @@ import { getEventValue, formatEventLabel, formatMetadataError, -} from "../agentMetadataHelper"; -import { createWorkspaceIdentifier, extractAgents } from "../api/api-helper"; +} from "../api/agentMetadataHelper"; +import { extractAgents } from "../api/api-helper"; import { CoderApi } from "../api/coderApi"; import { needToken } from "../api/utils"; -import { - startWorkspaceIfStoppedOrFailed, - waitForBuild, -} from "../api/workspace"; +import { getGlobalFlags, getGlobalFlagsRaw, getSshFlags } from "../cliConfig"; import { type Commands } from "../commands"; import { type CliManager } from "../core/cliManager"; import * as cliUtils from "../core/cliUtils"; import { type ServiceContainer } from "../core/container"; import { type ContextManager } from "../core/contextManager"; import { type PathResolver } from "../core/pathResolver"; +import { type SecretsManager } from "../core/secretsManager"; import { featureSetForVersion, type FeatureSet } from "../featureSet"; -import { getGlobalFlags } from "../globalFlags"; +import { getHeaderCommand } from "../headers"; import { Inbox } from "../inbox"; import { type Logger } from "../logging/logger"; +import { type LoginCoordinator } from "../login/loginCoordinator"; import { AuthorityPrefix, escapeCommandArg, expandPath, - findPort, parseRemoteAuthority, } from "../util"; import { WorkspaceMonitor } from "../workspace/workspaceMonitor"; import { SSHConfig, type SSHValues, mergeSSHConfigValues } from "./sshConfig"; +import { SshProcessMonitor } from "./sshProcess"; import { computeSSHProperties, sshSupportsSetEnv } from "./sshSupport"; +import { WorkspaceStateMachine } from "./workspaceStateMachine"; export interface RemoteDetails extends vscode.Disposable { + safeHostname: string; url: string; token: string; } @@ -60,191 +60,21 @@ export class Remote { private readonly pathResolver: PathResolver; private readonly cliManager: CliManager; private readonly contextManager: ContextManager; - - // Used to race between the login dialog and logging in from a different window - private loginDetectedResolver: (() => void) | undefined; - private loginDetectedRejector: ((reason?: Error) => void) | undefined; - private loginDetectedPromise: Promise = Promise.resolve(); + private readonly secretsManager: SecretsManager; + private readonly loginCoordinator: LoginCoordinator; public constructor( serviceContainer: ServiceContainer, private readonly commands: Commands, - private readonly mode: vscode.ExtensionMode, + private readonly extensionContext: vscode.ExtensionContext, ) { this.vscodeProposed = serviceContainer.getVsCodeProposed(); this.logger = serviceContainer.getLogger(); this.pathResolver = serviceContainer.getPathResolver(); this.cliManager = serviceContainer.getCliManager(); this.contextManager = serviceContainer.getContextManager(); - } - - /** - * Creates a new promise that will be resolved when login is detected in another window. - */ - private createLoginDetectionPromise(): void { - if (this.loginDetectedRejector) { - this.loginDetectedRejector( - new Error("Login detection cancelled - new login attempt started"), - ); - } - this.loginDetectedPromise = new Promise((resolve, reject) => { - this.loginDetectedResolver = resolve; - this.loginDetectedRejector = reject; - }); - } - - /** - * Resolves the current login detection promise if one exists. - */ - public resolveLoginDetected(): void { - if (this.loginDetectedResolver) { - this.loginDetectedResolver(); - this.loginDetectedResolver = undefined; - this.loginDetectedRejector = undefined; - } - } - - private async confirmStart(workspaceName: string): Promise { - const action = await this.vscodeProposed.window.showInformationMessage( - `Unable to connect to the workspace ${workspaceName} because it is not running. Start the workspace?`, - { - useCustom: true, - modal: true, - }, - "Start", - ); - return action === "Start"; - } - - /** - * Try to get the workspace running. Return undefined if the user canceled. - */ - private async maybeWaitForRunning( - client: CoderApi, - workspace: Workspace, - label: string, - binPath: string, - featureSet: FeatureSet, - firstConnect: boolean, - ): Promise { - const workspaceName = createWorkspaceIdentifier(workspace); - - // A terminal will be used to stream the build, if one is necessary. - let writeEmitter: undefined | vscode.EventEmitter; - let terminal: undefined | vscode.Terminal; - let attempts = 0; - - function initWriteEmitterAndTerminal(): vscode.EventEmitter { - if (!writeEmitter) { - writeEmitter = new vscode.EventEmitter(); - } - if (!terminal) { - terminal = vscode.window.createTerminal({ - name: "Build Log", - location: vscode.TerminalLocation.Panel, - // Spin makes this gear icon spin! - iconPath: new vscode.ThemeIcon("gear~spin"), - pty: { - onDidWrite: writeEmitter.event, - close: () => undefined, - open: () => undefined, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as Partial as any, - }); - terminal.show(true); - } - return writeEmitter; - } - - try { - // Show a notification while we wait. - return await this.vscodeProposed.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - cancellable: false, - title: "Waiting for workspace build...", - }, - async () => { - const globalConfigDir = this.pathResolver.getGlobalConfigDir(label); - while (workspace.latest_build.status !== "running") { - ++attempts; - switch (workspace.latest_build.status) { - case "pending": - case "starting": - case "stopping": - writeEmitter = initWriteEmitterAndTerminal(); - this.logger.info(`Waiting for ${workspaceName}...`); - workspace = await waitForBuild(client, writeEmitter, workspace); - break; - case "stopped": - if ( - !firstConnect && - !(await this.confirmStart(workspaceName)) - ) { - return undefined; - } - writeEmitter = initWriteEmitterAndTerminal(); - this.logger.info(`Starting ${workspaceName}...`); - workspace = await startWorkspaceIfStoppedOrFailed( - client, - globalConfigDir, - binPath, - workspace, - writeEmitter, - featureSet, - ); - break; - case "failed": - // On a first attempt, we will try starting a failed workspace - // (for example canceling a start seems to cause this state). - if (attempts === 1) { - if ( - !firstConnect && - !(await this.confirmStart(workspaceName)) - ) { - return undefined; - } - writeEmitter = initWriteEmitterAndTerminal(); - this.logger.info(`Starting ${workspaceName}...`); - workspace = await startWorkspaceIfStoppedOrFailed( - client, - globalConfigDir, - binPath, - workspace, - writeEmitter, - featureSet, - ); - break; - } - // Otherwise fall through and error. - case "canceled": - case "canceling": - case "deleted": - case "deleting": - default: { - const is = - workspace.latest_build.status === "failed" ? "has" : "is"; - throw new Error( - `${workspaceName} ${is} ${workspace.latest_build.status}`, - ); - } - } - this.logger.info( - `${workspaceName} status is now`, - workspace.latest_build.status, - ); - } - return workspace; - }, - ); - } finally { - if (writeEmitter) { - writeEmitter.dispose(); - } - if (terminal) { - terminal.dispose(); - } - } + this.secretsManager = serviceContainer.getSecretsManager(); + this.loginCoordinator = serviceContainer.getLoginCoordinator(); } /** @@ -255,6 +85,7 @@ export class Remote { public async setup( remoteAuthority: string, firstConnect: boolean, + remoteSshExtensionId: string, ): Promise { const parts = parseRemoteAuthority(remoteAuthority); if (!parts) { @@ -264,163 +95,187 @@ export class Remote { const workspaceName = `${parts.username}/${parts.workspace}`; - // Migrate "session_token" file to "session", if needed. - await this.migrateSessionToken(parts.label); + // Migrate existing legacy file-based auth to secrets storage. + await this.migrateToSecretsStorage(parts.safeHostname); // Get the URL and token belonging to this host. - const { url: baseUrlRaw, token } = await this.cliManager.readConfig( - parts.label, - ); + const auth = await this.secretsManager.getSessionAuth(parts.safeHostname); + const baseUrlRaw = auth?.url ?? ""; + const token = auth?.token; + // Empty token is valid for mTLS + if (baseUrlRaw && token !== undefined) { + await this.cliManager.configure(parts.safeHostname, baseUrlRaw, token); + } - const showLoginDialog = async (message: string) => { - this.createLoginDetectionPromise(); - const dialogPromise = this.vscodeProposed.window.showInformationMessage( - message, - { - useCustom: true, - modal: true, - detail: `You must log in to access ${workspaceName}. If you've already logged in, you may close this dialog.`, - }, - "Log In", - ); + const disposables: vscode.Disposable[] = []; - // Race between dialog and login detection - const result = await Promise.race([ - this.loginDetectedPromise.then(() => ({ type: "login" as const })), - dialogPromise.then((userChoice) => ({ - type: "dialog" as const, - userChoice, - })), - ]); - - if (result.type === "login") { - return this.setup(remoteAuthority, firstConnect); - } else { - if (!result.userChoice) { - // User declined to log in. - await this.closeRemote(); - return; + try { + const ensureLoggedInAndRetry = async ( + message: string, + url: string | undefined, + ) => { + const result = await this.loginCoordinator.ensureLoggedInWithDialog({ + safeHostname: parts.safeHostname, + url, + message, + detailPrefix: `You must log in to access ${workspaceName}.`, + }); + + // Dispose before retrying since setup will create new disposables + disposables.forEach((d) => d.dispose()); + if (result.success) { + // Login successful, retry setup + return this.setup( + remoteAuthority, + firstConnect, + remoteSshExtensionId, + ); } else { - // Log in then try again. - await this.commands.login({ url: baseUrlRaw, label: parts.label }); - return this.setup(remoteAuthority, firstConnect); + // User cancelled or login failed + await this.closeRemote(); } + }; + + // It could be that the cli config was deleted. If so, ask for the url. + if ( + !baseUrlRaw || + (!token && needToken(vscode.workspace.getConfiguration())) + ) { + return ensureLoggedInAndRetry("You are not logged in...", baseUrlRaw); } - }; - // It could be that the cli config was deleted. If so, ask for the url. - if ( - !baseUrlRaw || - (!token && needToken(vscode.workspace.getConfiguration())) - ) { - return showLoginDialog("You are not logged in..."); - } + this.logger.info("Using deployment URL", baseUrlRaw); + this.logger.info("Using hostname", parts.safeHostname || "n/a"); - this.logger.info("Using deployment URL", baseUrlRaw); - this.logger.info("Using deployment label", parts.label || "n/a"); - - // We could use the plugin client, but it is possible for the user to log - // out or log into a different deployment while still connected, which would - // break this connection. We could force close the remote session or - // disallow logging out/in altogether, but for now just use a separate - // client to remain unaffected by whatever the plugin is doing. - const workspaceClient = CoderApi.create(baseUrlRaw, token, this.logger); - // Store for use in commands. - this.commands.workspaceRestClient = workspaceClient; - - let binaryPath: string | undefined; - if (this.mode === vscode.ExtensionMode.Production) { - binaryPath = await this.cliManager.fetchBinary( - workspaceClient, - parts.label, + // We could use the plugin client, but it is possible for the user to log + // out or log into a different deployment while still connected, which would + // break this connection. We could force close the remote session or + // disallow logging out/in altogether, but for now just use a separate + // client to remain unaffected by whatever the plugin is doing. + const workspaceClient = CoderApi.create(baseUrlRaw, token, this.logger); + disposables.push(workspaceClient); + // Store for use in commands. + this.commands.remoteWorkspaceClient = workspaceClient; + + // Listen for token changes for this deployment + disposables.push( + this.secretsManager.onDidChangeSessionAuth( + parts.safeHostname, + async (auth) => { + workspaceClient.setCredentials(auth?.url, auth?.token); + if (auth?.url) { + await this.cliManager.configure( + parts.safeHostname, + auth.url, + auth.token, + ); + this.logger.info( + "Updated CLI config with new token for remote deployment", + ); + } + }, + ), ); - } else { - try { - // In development, try to use `/tmp/coder` as the binary path. - // This is useful for debugging with a custom bin! - binaryPath = path.join(os.tmpdir(), "coder"); - await fs.stat(binaryPath); - } catch { + + let binaryPath: string | undefined; + if ( + this.extensionContext.extensionMode === vscode.ExtensionMode.Production + ) { binaryPath = await this.cliManager.fetchBinary( workspaceClient, - parts.label, + parts.safeHostname, ); + } else { + try { + // In development, try to use `/tmp/coder` as the binary path. + // This is useful for debugging with a custom bin! + binaryPath = path.join(os.tmpdir(), "coder"); + await fs.stat(binaryPath); + } catch { + binaryPath = await this.cliManager.fetchBinary( + workspaceClient, + parts.safeHostname, + ); + } } - } - // First thing is to check the version. - const buildInfo = await workspaceClient.getBuildInfo(); + // First thing is to check the version. + const buildInfo = await workspaceClient.getBuildInfo(); - let version: semver.SemVer | null = null; - try { - version = semver.parse(await cliUtils.version(binaryPath)); - } catch { - version = semver.parse(buildInfo.version); - } - - const featureSet = featureSetForVersion(version); + let version: semver.SemVer | null = null; + try { + version = semver.parse(await cliUtils.version(binaryPath)); + } catch { + version = semver.parse(buildInfo.version); + } - // Server versions before v0.14.1 don't support the vscodessh command! - if (!featureSet.vscodessh) { - await this.vscodeProposed.window.showErrorMessage( - "Incompatible Server", - { - detail: - "Your Coder server is too old to support the Coder extension! Please upgrade to v0.14.1 or newer.", - modal: true, - useCustom: true, - }, - "Close Remote", - ); - await this.closeRemote(); - return; - } + const featureSet = featureSetForVersion(version); - // Next is to find the workspace from the URI scheme provided. - let workspace: Workspace; - try { - this.logger.info(`Looking for workspace ${workspaceName}...`); - workspace = await workspaceClient.getWorkspaceByOwnerAndName( - parts.username, - parts.workspace, - ); - this.logger.info( - `Found workspace ${workspaceName} with status`, - workspace.latest_build.status, - ); - this.commands.workspace = workspace; - } catch (error) { - if (!isAxiosError(error)) { - throw error; + // Server versions before v0.14.1 don't support the vscodessh command! + if (!featureSet.vscodessh) { + await this.vscodeProposed.window.showErrorMessage( + "Incompatible Server", + { + detail: + "Your Coder server is too old to support the Coder extension! Please upgrade to v0.14.1 or newer.", + modal: true, + useCustom: true, + }, + "Close Remote", + ); + disposables.forEach((d) => d.dispose()); + await this.closeRemote(); + return; } - switch (error.response?.status) { - case 404: { - const result = - await this.vscodeProposed.window.showInformationMessage( - `That workspace doesn't exist!`, - { - modal: true, - detail: `${workspaceName} cannot be found on ${baseUrlRaw}. Maybe it was deleted...`, - useCustom: true, - }, - "Open Workspace", + + // Next is to find the workspace from the URI scheme provided. + let workspace: Workspace; + try { + this.logger.info(`Looking for workspace ${workspaceName}...`); + workspace = await workspaceClient.getWorkspaceByOwnerAndName( + parts.username, + parts.workspace, + ); + this.logger.info( + `Found workspace ${workspaceName} with status`, + workspace.latest_build.status, + ); + this.commands.workspace = workspace; + } catch (error) { + if (!isAxiosError(error)) { + throw error; + } + switch (error.response?.status) { + case 404: { + const result = + await this.vscodeProposed.window.showInformationMessage( + `That workspace doesn't exist!`, + { + modal: true, + detail: `${workspaceName} cannot be found on ${baseUrlRaw}. Maybe it was deleted...`, + useCustom: true, + }, + "Open Workspace", + ); + disposables.forEach((d) => d.dispose()); + if (!result) { + await this.closeRemote(); + } + await vscode.commands.executeCommand("coder.open"); + return; + } + case 401: { + disposables.forEach((d) => d.dispose()); + return ensureLoggedInAndRetry( + "Your session expired...", + baseUrlRaw, ); - if (!result) { - await this.closeRemote(); } - await vscode.commands.executeCommand("coder.open"); - return; + default: + throw error; } - case 401: { - return showLoginDialog("Your session expired..."); - } - default: - throw error; } - } - const disposables: vscode.Disposable[] = []; - try { // Register before connection so the label still displays! let labelFormatterDisposable = this.registerLabelFormatter( remoteAuthority, @@ -431,36 +286,105 @@ export class Remote { dispose: () => labelFormatterDisposable.dispose(), }); - // If the workspace is not in a running state, try to get it running. - if (workspace.latest_build.status !== "running") { - const updatedWorkspace = await this.maybeWaitForRunning( - workspaceClient, - workspace, - parts.label, - binaryPath, - featureSet, - firstConnect, + // Watch the workspace for changes. + const monitor = await WorkspaceMonitor.create( + workspace, + workspaceClient, + this.logger, + this.vscodeProposed, + this.contextManager, + ); + disposables.push( + monitor, + monitor.onChange.event((w) => (this.commands.workspace = w)), + ); + + // Wait for workspace to be running and agent to be ready + const stateMachine = new WorkspaceStateMachine( + parts, + workspaceClient, + firstConnect, + binaryPath, + featureSet, + this.logger, + this.pathResolver, + this.vscodeProposed, + ); + disposables.push(stateMachine); + + try { + workspace = await this.vscodeProposed.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + cancellable: false, + title: "Connecting to workspace", + }, + async (progress) => { + let inProgress = false; + let pendingWorkspace: Workspace | null = null; + + return new Promise((resolve, reject) => { + const processWorkspace = async (w: Workspace) => { + if (inProgress) { + // Process one workspace at a time, keeping only the last + pendingWorkspace = w; + return; + } + + inProgress = true; + try { + pendingWorkspace = null; + + const isReady = await stateMachine.processWorkspace( + w, + progress, + ); + if (isReady) { + subscription.dispose(); + resolve(w); + return; + } + } catch (error) { + subscription.dispose(); + reject(error); + return; + } finally { + inProgress = false; + } + + if (pendingWorkspace) { + processWorkspace(pendingWorkspace); + } + }; + + processWorkspace(workspace); + const subscription = monitor.onChange.event(async (w) => + processWorkspace(w), + ); + }); + }, ); - if (!updatedWorkspace) { - // User declined to start the workspace. - await this.closeRemote(); - return; - } - workspace = updatedWorkspace; + } finally { + stateMachine.dispose(); } - this.commands.workspace = workspace; - // Pick an agent. - this.logger.info(`Finding agent for ${workspaceName}...`); + // Mark initial setup as complete so the monitor can start notifying about state changes + monitor.markInitialSetupComplete(); + const agents = extractAgents(workspace.latest_build.resources); - const gotAgent = await this.commands.maybeAskAgent(agents, parts.agent); - if (!gotAgent) { - // User declined to pick an agent. - await this.closeRemote(); - return; + const agent = agents.find( + (agent) => agent.id === stateMachine.getAgentId(), + ); + + if (!agent) { + throw new Error("Failed to get workspace or agent from state machine"); } - let agent = gotAgent; // Reassign so it cannot be undefined in callbacks. - this.logger.info(`Found agent ${agent.name} with status`, agent.status); + + this.commands.workspace = workspace; + + // Watch coder inbox for messages + const inbox = await Inbox.create(workspace, workspaceClient, this.logger); + disposables.push(inbox); // Do some janky setting manipulation. this.logger.info("Modifying settings..."); @@ -490,10 +414,10 @@ export class Remote { // the user for the platform. let mungedPlatforms = false; if ( - !remotePlatforms[parts.host] || - remotePlatforms[parts.host] !== agent.operating_system + !remotePlatforms[parts.sshHost] || + remotePlatforms[parts.sshHost] !== agent.operating_system ) { - remotePlatforms[parts.host] = agent.operating_system; + remotePlatforms[parts.sshHost] = agent.operating_system; settingsContent = jsonc.applyEdits( settingsContent, jsonc.modify( @@ -542,76 +466,6 @@ export class Remote { } } - // Watch the workspace for changes. - const monitor = new WorkspaceMonitor( - workspace, - workspaceClient, - this.logger, - this.vscodeProposed, - this.contextManager, - ); - disposables.push(monitor); - disposables.push( - monitor.onChange.event((w) => (this.commands.workspace = w)), - ); - - // Watch coder inbox for messages - const inbox = new Inbox(workspace, workspaceClient, this.logger); - disposables.push(inbox); - - // Wait for the agent to connect. - if (agent.status === "connecting") { - this.logger.info(`Waiting for ${workspaceName}/${agent.name}...`); - await vscode.window.withProgress( - { - title: "Waiting for the agent to connect...", - location: vscode.ProgressLocation.Notification, - }, - async () => { - await new Promise((resolve) => { - const updateEvent = monitor.onChange.event((workspace) => { - if (!agent) { - return; - } - const agents = extractAgents(workspace.latest_build.resources); - const found = agents.find((newAgent) => { - return newAgent.id === agent.id; - }); - if (!found) { - return; - } - agent = found; - if (agent.status === "connecting") { - return; - } - updateEvent.dispose(); - resolve(); - }); - }); - }, - ); - this.logger.info(`Agent ${agent.name} status is now`, agent.status); - } - - // Make sure the agent is connected. - // TODO: Should account for the lifecycle state as well? - if (agent.status !== "connected") { - const result = await this.vscodeProposed.window.showErrorMessage( - `${workspaceName}/${agent.name} ${agent.status}`, - { - useCustom: true, - modal: true, - detail: `The ${agent.name} agent failed to connect. Try restarting your workspace.`, - }, - ); - if (!result) { - await this.closeRemote(); - return; - } - await this.reloadWindow(); - return; - } - const logDir = this.getLogDir(featureSet); // This ensures the Remote SSH extension resolves the host to execute the @@ -623,8 +477,8 @@ export class Remote { this.logger.info("Updating SSH config..."); await this.updateSSHConfig( workspaceClient, - parts.label, - parts.host, + parts.safeHostname, + parts.sshHost, binaryPath, logDir, featureSet, @@ -634,30 +488,24 @@ export class Remote { throw error; } - // TODO: This needs to be reworked; it fails to pick up reconnects. - this.findSSHProcessID().then(async (pid) => { - if (!pid) { - // TODO: Show an error here! - return; - } - disposables.push(this.showNetworkUpdates(pid)); - if (logDir) { - const logFiles = await fs.readdir(logDir); - const logFileName = logFiles - .reverse() - .find( - (file) => file === `${pid}.log` || file.endsWith(`-${pid}.log`), - ); - this.commands.workspaceLogPath = logFileName - ? path.join(logDir, logFileName) - : undefined; - } else { - this.commands.workspaceLogPath = undefined; - } + // Monitor SSH process and display network status + const sshMonitor = SshProcessMonitor.start({ + sshHost: parts.sshHost, + networkInfoPath: this.pathResolver.getNetworkInfoPath(), + proxyLogDir: logDir || undefined, + logger: this.logger, + codeLogDir: this.pathResolver.getCodeLogDir(), + remoteSshExtensionId, }); + disposables.push(sshMonitor); + + this.commands.workspaceLogPath = sshMonitor.getLogFilePath(); - // Register the label formatter again because SSH overrides it! disposables.push( + sshMonitor.onLogFilePathChange((newPath) => { + this.commands.workspaceLogPath = newPath; + }), + // Register the label formatter again because SSH overrides it! vscode.extensions.onDidChange(() => { // Dispose previous label formatter labelFormatterDisposable.dispose(); @@ -668,14 +516,47 @@ export class Remote { agent.name, ); }), - ...this.createAgentMetadataStatusBar(agent, workspaceClient), + ...(await this.createAgentMetadataStatusBar(agent, workspaceClient)), ); + + const settingsToWatch: Array<{ + setting: string; + title: string; + getValue: () => unknown; + }> = [ + { + setting: "coder.globalFlags", + title: "Global Flags", + getValue: () => + getGlobalFlagsRaw(vscode.workspace.getConfiguration()), + }, + { + setting: "coder.headerCommand", + title: "Header Command", + getValue: () => + getHeaderCommand(vscode.workspace.getConfiguration()) ?? "", + }, + { + setting: "coder.sshFlags", + title: "SSH Flags", + getValue: () => getSshFlags(vscode.workspace.getConfiguration()), + }, + ]; + if (featureSet.proxyLogDirectory) { + settingsToWatch.push({ + setting: "coder.proxyLogDirectory", + title: "Proxy Log Directory", + getValue: () => this.getLogDir(featureSet), + }); + } + disposables.push(this.watchSettings(settingsToWatch)); } catch (ex) { // Whatever error happens, make sure we clean up the disposables in case of failure disposables.forEach((d) => d.dispose()); throw ex; } + this.contextManager.set("coder.workspace.connected", true); this.logger.info("Remote setup complete"); // Returning the URL and token allows the plugin to authenticate its own @@ -683,8 +564,9 @@ export class Remote { // deployment in the sidebar. We use our own client in here for reasons // explained above. return { + safeHostname: parts.safeHostname, url: baseUrlRaw, - token, + token: token ?? "", dispose: () => { disposables.forEach((d) => d.dispose()); }, @@ -692,24 +574,59 @@ export class Remote { } /** - * Migrate the session token file from "session_token" to "session", if needed. + * Migrate legacy file-based auth to secrets storage. */ - private async migrateSessionToken(label: string) { - const oldTokenPath = this.pathResolver.getLegacySessionTokenPath(label); - const newTokenPath = this.pathResolver.getSessionTokenPath(label); + private async migrateToSecretsStorage(safeHostname: string) { + await this.migrateSessionTokenFile(safeHostname); + await this.migrateSessionAuthFromFiles(safeHostname); + } + + /** + * Migrate the session token file from "session_token" to "session". + */ + private async migrateSessionTokenFile(safeHostname: string) { + const oldTokenPath = + this.pathResolver.getLegacySessionTokenPath(safeHostname); + const newTokenPath = this.pathResolver.getSessionTokenPath(safeHostname); try { await fs.rename(oldTokenPath, newTokenPath); } catch (error) { - if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { - return; + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") { + throw error; } - throw error; } } /** - * Return the --log-dir argument value for the ProxyCommand. It may be an + * Migrate URL and session token from files to the mutli-deployment secrets storage. + */ + private async migrateSessionAuthFromFiles(safeHostname: string) { + const existingAuth = await this.secretsManager.getSessionAuth(safeHostname); + if (existingAuth) { + return; + } + + const urlPath = this.pathResolver.getUrlPath(safeHostname); + const tokenPath = this.pathResolver.getSessionTokenPath(safeHostname); + const [url, token] = await Promise.allSettled([ + fs.readFile(urlPath, "utf8"), + fs.readFile(tokenPath, "utf8"), + ]); + + if (url.status === "fulfilled" && token.status === "fulfilled") { + this.logger.info("Migrating session auth from files for", safeHostname); + await this.secretsManager.setSessionAuth(safeHostname, { + url: url.value.trim(), + token: token.value.trim(), + }); + } + } + + /** + * Return the --log-dir argument value for the ProxyCommand. It may be an * empty string if the setting is not set or the cli does not support it. + * + * Value defined in the "coder.sshFlags" setting is not considered. */ private getLogDir(featureSet: FeatureSet): string { if (!featureSet.proxyLogDirectory) { @@ -725,23 +642,86 @@ export class Remote { } /** - * Formats the --log-dir argument for the ProxyCommand after making sure it + * Builds the ProxyCommand for SSH connections to Coder workspaces. + * Uses `coder ssh` for modern deployments with wildcard support, + * or falls back to `coder vscodessh` for older deployments. + */ + private async buildProxyCommand( + binaryPath: string, + label: string, + hostPrefix: string, + logDir: string, + useWildcardSSH: boolean, + ): Promise { + const vscodeConfig = vscode.workspace.getConfiguration(); + + const escapedBinaryPath = escapeCommandArg(binaryPath); + const globalConfig = getGlobalFlags( + vscodeConfig, + this.pathResolver.getGlobalConfigDir(label), + ); + const logArgs = await this.getLogArgs(logDir); + + if (useWildcardSSH) { + // User SSH flags are included first; internally-managed flags + // are appended last so they take precedence. + const userSshFlags = getSshFlags(vscodeConfig); + // Make sure to update the `coder.sshFlags` description if we add more internal flags here! + const internalFlags = [ + "--stdio", + "--usage-app=vscode", + "--network-info-dir", + escapeCommandArg(this.pathResolver.getNetworkInfoPath()), + ...logArgs, + "--ssh-host-prefix", + hostPrefix, + "%h", + ]; + + const allFlags = [...userSshFlags, ...internalFlags]; + return `${escapedBinaryPath} ${globalConfig.join(" ")} ssh ${allFlags.join(" ")}`; + } else { + const networkInfoDir = escapeCommandArg( + this.pathResolver.getNetworkInfoPath(), + ); + const sessionTokenFile = escapeCommandArg( + this.pathResolver.getSessionTokenPath(label), + ); + const urlFile = escapeCommandArg(this.pathResolver.getUrlPath(label)); + + const sshFlags = [ + "--network-info-dir", + networkInfoDir, + ...logArgs, + "--session-token-file", + sessionTokenFile, + "--url-file", + urlFile, + "%h", + ]; + + return `${escapedBinaryPath} ${globalConfig.join(" ")} vscodessh ${sshFlags.join(" ")}`; + } + } + + /** + * Returns the --log-dir argument for the ProxyCommand after making sure it * has been created. */ - private async formatLogArg(logDir: string): Promise { + private async getLogArgs(logDir: string): Promise { if (!logDir) { - return ""; + return []; } await fs.mkdir(logDir, { recursive: true }); this.logger.info("SSH proxy diagnostics are being written to", logDir); - return ` --log-dir ${escapeCommandArg(logDir)} -v`; + return ["--log-dir", escapeCommandArg(logDir), "-v"]; } // updateSSHConfig updates the SSH configuration with a wildcard that handles // all Coder entries. private async updateSSHConfig( restClient: Api, - label: string, + safeHostname: string, hostName: string, binaryPath: string, logDir: string, @@ -816,19 +796,17 @@ export class Remote { const sshConfig = new SSHConfig(sshConfigFile); await sshConfig.load(); - const hostPrefix = label - ? `${AuthorityPrefix}.${label}--` + const hostPrefix = safeHostname + ? `${AuthorityPrefix}.${safeHostname}--` : `${AuthorityPrefix}--`; - const globalConfigs = this.globalConfigs(label); - - const proxyCommand = featureSet.wildcardSSH - ? `${escapeCommandArg(binaryPath)}${globalConfigs} ssh --stdio --usage-app=vscode --disable-autostart --network-info-dir ${escapeCommandArg(this.pathResolver.getNetworkInfoPath())}${await this.formatLogArg(logDir)} --ssh-host-prefix ${hostPrefix} %h` - : `${escapeCommandArg(binaryPath)}${globalConfigs} vscodessh --network-info-dir ${escapeCommandArg( - this.pathResolver.getNetworkInfoPath(), - )}${await this.formatLogArg(logDir)} --session-token-file ${escapeCommandArg(this.pathResolver.getSessionTokenPath(label))} --url-file ${escapeCommandArg( - this.pathResolver.getUrlPath(label), - )} %h`; + const proxyCommand = await this.buildProxyCommand( + binaryPath, + safeHostname, + hostPrefix, + logDir, + featureSet.wildcardSSH, + ); const sshValues: SSHValues = { Host: hostPrefix + `*`, @@ -844,7 +822,7 @@ export class Remote { sshValues.SetEnv = " CODER_SSH_SESSION_TYPE=vscode"; } - await sshConfig.update(label, sshValues, sshConfigOverrides); + await sshConfig.update(safeHostname, sshValues, sshConfigOverrides); // A user can provide a "Host *" entry in their SSH config to add options // to all hosts. We need to ensure that the options we set are not @@ -858,8 +836,7 @@ export class Remote { "UserKnownHostsFile", "StrictHostKeyChecking", ]; - for (let i = 0; i < keysToMatch.length; i++) { - const key = keysToMatch[i]; + for (const key of keysToMatch) { if (computedProperties[key] === sshValues[key]) { continue; } @@ -877,186 +854,54 @@ export class Remote { await this.reloadWindow(); } await this.closeRemote(); + throw new Error("SSH config mismatch, closing remote"); } return sshConfig.getRaw(); } - private globalConfigs(label: string): string { - const vscodeConfig = vscode.workspace.getConfiguration(); - const args = getGlobalFlags( - vscodeConfig, - this.pathResolver.getGlobalConfigDir(label), + private watchSettings( + settings: Array<{ + setting: string; + title: string; + getValue: () => unknown; + }>, + ): vscode.Disposable { + // Capture applied values at setup time + const appliedValues = new Map( + settings.map((s) => [s.setting, s.getValue()]), ); - return ` ${args.join(" ")}`; - } - // showNetworkUpdates finds the SSH process ID that is being used by this - // workspace and reads the file being created by the Coder CLI. - private showNetworkUpdates(sshPid: number): vscode.Disposable { - const networkStatus = vscode.window.createStatusBarItem( - vscode.StatusBarAlignment.Left, - 1000, - ); - const networkInfoFile = path.join( - this.pathResolver.getNetworkInfoPath(), - `${sshPid}.json`, - ); + return vscode.workspace.onDidChangeConfiguration((e) => { + const changedTitles: string[] = []; - const updateStatus = (network: { - p2p: boolean; - latency: number; - preferred_derp: string; - derp_latency: { [key: string]: number }; - upload_bytes_sec: number; - download_bytes_sec: number; - using_coder_connect: boolean; - }) => { - let statusText = "$(globe) "; - - // Coder Connect doesn't populate any other stats - if (network.using_coder_connect) { - networkStatus.text = statusText + "Coder Connect "; - networkStatus.tooltip = "You're connected using Coder Connect."; - networkStatus.show(); - return; - } + for (const { setting, title, getValue } of settings) { + if (!e.affectsConfiguration(setting)) { + continue; + } - if (network.p2p) { - statusText += "Direct "; - networkStatus.tooltip = "You're connected peer-to-peer ✨."; - } else { - statusText += network.preferred_derp + " "; - networkStatus.tooltip = - "You're connected through a relay 🕵.\nWe'll switch over to peer-to-peer when available."; - } - networkStatus.tooltip += - "\n\nDownload ↓ " + - prettyBytes(network.download_bytes_sec, { - bits: true, - }) + - "/s • Upload ↑ " + - prettyBytes(network.upload_bytes_sec, { - bits: true, - }) + - "/s\n"; - - if (!network.p2p) { - const derpLatency = network.derp_latency[network.preferred_derp]; - - networkStatus.tooltip += `You ↔ ${derpLatency.toFixed(2)}ms ↔ ${network.preferred_derp} ↔ ${(network.latency - derpLatency).toFixed(2)}ms ↔ Workspace`; - - let first = true; - Object.keys(network.derp_latency).forEach((region) => { - if (region === network.preferred_derp) { - return; - } - if (first) { - networkStatus.tooltip += `\n\nOther regions:`; - first = false; - } - networkStatus.tooltip += `\n${region}: ${Math.round(network.derp_latency[region] * 100) / 100}ms`; - }); - } + const newValue = getValue(); - statusText += "(" + network.latency.toFixed(2) + "ms)"; - networkStatus.text = statusText; - networkStatus.show(); - }; - let disposed = false; - const periodicRefresh = () => { - if (disposed) { - return; + if (!isDeepStrictEqual(newValue, appliedValues.get(setting))) { + changedTitles.push(title); + } } - fs.readFile(networkInfoFile, "utf8") - .then((content) => { - return JSON.parse(content); - }) - .then((parsed) => { - try { - updateStatus(parsed); - } catch { - // Ignore - } - }) - .catch(() => { - // TODO: Log a failure here! - }) - .finally(() => { - // This matches the write interval of `coder vscodessh`. - setTimeout(periodicRefresh, 3000); - }); - }; - periodicRefresh(); - return { - dispose: () => { - disposed = true; - networkStatus.dispose(); - }, - }; - } - - // findSSHProcessID returns the currently active SSH process ID that is - // powering the remote SSH connection. - private async findSSHProcessID(timeout = 15000): Promise { - const search = async (logPath: string): Promise => { - // This searches for the socksPort that Remote SSH is connecting to. We do - // this to find the SSH process that is powering this connection. That SSH - // process will be logging network information periodically to a file. - const text = await fs.readFile(logPath, "utf8"); - const port = await findPort(text); - if (!port) { - return; - } - const processes = await find("port", port); - if (processes.length < 1) { + if (changedTitles.length === 0) { return; } - const process = processes[0]; - return process.pid; - }; - const start = Date.now(); - const loop = async (): Promise => { - if (Date.now() - start > timeout) { - return undefined; - } - // Loop until we find the remote SSH log for this window. - const filePath = await this.getRemoteSSHLogPath(); - if (!filePath) { - return new Promise((resolve) => setTimeout(() => resolve(loop()), 500)); - } - // Then we search the remote SSH log until we find the port. - const result = await search(filePath); - if (!result) { - return new Promise((resolve) => setTimeout(() => resolve(loop()), 500)); - } - return result; - }; - return loop(); - } - /** - * Returns the log path for the "Remote - SSH" output panel. There is no VS - * Code API to get the contents of an output panel. We use this to get the - * active port so we can display network information. - */ - private async getRemoteSSHLogPath(): Promise { - const upperDir = path.dirname(this.pathResolver.getCodeLogDir()); - // Node returns these directories sorted already! - const dirs = await fs.readdir(upperDir); - const latestOutput = dirs - .reverse() - .filter((dir) => dir.startsWith("output_logging_")); - if (latestOutput.length === 0) { - return undefined; - } - const dir = await fs.readdir(path.join(upperDir, latestOutput[0])); - const remoteSSH = dir.filter((file) => file.indexOf("Remote - SSH") !== -1); - if (remoteSSH.length === 0) { - return undefined; - } - return path.join(upperDir, latestOutput[0], remoteSSH[0]); + const message = + changedTitles.length === 1 + ? `${changedTitles[0]} setting changed. Reload window to apply.` + : `${changedTitles.join(", ")} settings changed. Reload window to apply.`; + + vscode.window.showInformationMessage(message, "Reload").then((action) => { + if (action === "Reload") { + vscode.commands.executeCommand("workbench.action.reloadWindow"); + } + }); + }); } /** @@ -1064,16 +909,16 @@ export class Remote { * The status bar item updates dynamically based on changes to the agent's metadata, * and hides itself if no metadata is available or an error occurs. */ - private createAgentMetadataStatusBar( + private async createAgentMetadataStatusBar( agent: WorkspaceAgent, client: CoderApi, - ): vscode.Disposable[] { + ): Promise { const statusBarItem = vscode.window.createStatusBarItem( "agentMetadata", vscode.StatusBarAlignment.Left, ); - const agentWatcher = createAgentMetadataWatcher(agent.id, client); + const agentWatcher = await createAgentMetadataWatcher(agent.id, client); const onChangeDisposable = agentWatcher.onChange(() => { if (agentWatcher.error) { diff --git a/src/remote/sshConfig.ts b/src/remote/sshConfig.ts index f5fea264..668ce092 100644 --- a/src/remote/sshConfig.ts +++ b/src/remote/sshConfig.ts @@ -85,18 +85,18 @@ export function mergeSSHConfigValues( } export class SSHConfig { - private filePath: string; - private fileSystem: FileSystem; + private readonly filePath: string; + private readonly fileSystem: FileSystem; private raw: string | undefined; - private startBlockComment(label: string): string { - return label - ? `# --- START CODER VSCODE ${label} ---` + private startBlockComment(safeHostname: string): string { + return safeHostname + ? `# --- START CODER VSCODE ${safeHostname} ---` : `# --- START CODER VSCODE ---`; } - private endBlockComment(label: string): string { - return label - ? `# --- END CODER VSCODE ${label} ---` + private endBlockComment(safeHostname: string): string { + return safeHostname + ? `# --- END CODER VSCODE ${safeHostname} ---` : `# --- END CODER VSCODE ---`; } @@ -115,15 +115,15 @@ export class SSHConfig { } /** - * Update the block for the deployment with the provided label. + * Update the block for the deployment with the provided hostname. */ async update( - label: string, + safeHostname: string, values: SSHValues, overrides?: Record, ) { - const block = this.getBlock(label); - const newBlock = this.buildBlock(label, values, overrides); + const block = this.getBlock(safeHostname); + const newBlock = this.buildBlock(safeHostname, values, overrides); if (block) { this.replaceBlock(block, newBlock); } else { @@ -133,24 +133,24 @@ export class SSHConfig { } /** - * Get the block for the deployment with the provided label. + * Get the block for the deployment with the provided hostname. */ - private getBlock(label: string): Block | undefined { + private getBlock(safeHostname: string): Block | undefined { const raw = this.getRaw(); - const startBlock = this.startBlockComment(label); - const endBlock = this.endBlockComment(label); + const startBlock = this.startBlockComment(safeHostname); + const endBlock = this.endBlockComment(safeHostname); const startBlockCount = countSubstring(startBlock, raw); const endBlockCount = countSubstring(endBlock, raw); if (startBlockCount !== endBlockCount) { throw new SSHConfigBadFormat( - `Malformed config: ${this.filePath} has an unterminated START CODER VSCODE ${label ? label + " " : ""}block. Each START block must have an END block.`, + `Malformed config: ${this.filePath} has an unterminated START CODER VSCODE ${safeHostname ? safeHostname + " " : ""}block. Each START block must have an END block.`, ); } if (startBlockCount > 1 || endBlockCount > 1) { throw new SSHConfigBadFormat( - `Malformed config: ${this.filePath} has ${startBlockCount} START CODER VSCODE ${label ? label + " " : ""}sections. Please remove all but one.`, + `Malformed config: ${this.filePath} has ${startBlockCount} START CODER VSCODE ${safeHostname ? safeHostname + " " : ""}sections. Please remove all but one.`, ); } @@ -185,22 +185,22 @@ export class SSHConfig { * the keys is determinstic based on the input. Expected values are always in * a consistent order followed by any additional overrides in sorted order. * - * @param label - The label for the deployment (like the encoded URL). - * @param values - The expected SSH values for using ssh with Coder. - * @param overrides - Overrides typically come from the deployment api and are - * used to override the default values. The overrides are - * given as key:value pairs where the key is the ssh config - * file key. If the key matches an expected value, the - * expected value is overridden. If it does not match an - * expected value, it is appended to the end of the block. + * @param safeHostname - The hostname for the deployment. + * @param values - The expected SSH values for using ssh with Coder. + * @param overrides - Overrides typically come from the deployment api and are + * used to override the default values. The overrides are + * given as key:value pairs where the key is the ssh config + * file key. If the key matches an expected value, the + * expected value is overridden. If it does not match an + * expected value, it is appended to the end of the block. */ private buildBlock( - label: string, + safeHostname: string, values: SSHValues, overrides?: Record, ) { const { Host, ...otherValues } = values; - const lines = [this.startBlockComment(label), `Host ${Host}`]; + const lines = [this.startBlockComment(safeHostname), `Host ${Host}`]; // configValues is the merged values of the defaults and the overrides. const configValues = mergeSSHConfigValues(otherValues, overrides || {}); @@ -216,7 +216,7 @@ export class SSHConfig { } }); - lines.push(this.endBlockComment(label)); + lines.push(this.endBlockComment(safeHostname)); return { raw: lines.join("\n"), }; diff --git a/src/remote/sshExtension.ts b/src/remote/sshExtension.ts new file mode 100644 index 00000000..70ed849d --- /dev/null +++ b/src/remote/sshExtension.ts @@ -0,0 +1,25 @@ +import * as vscode from "vscode"; + +export const REMOTE_SSH_EXTENSION_IDS = [ + "jeanp413.open-remote-ssh", + "codeium.windsurf-remote-openssh", + "anysphere.remote-ssh", + "ms-vscode-remote.remote-ssh", + "google.antigravity-remote-openssh", +] as const; + +export type RemoteSshExtensionId = (typeof REMOTE_SSH_EXTENSION_IDS)[number]; + +type RemoteSshExtension = vscode.Extension & { + id: RemoteSshExtensionId; +}; + +export function getRemoteSshExtension(): RemoteSshExtension | undefined { + for (const id of REMOTE_SSH_EXTENSION_IDS) { + const extension = vscode.extensions.getExtension(id); + if (extension) { + return extension as RemoteSshExtension; + } + } + return undefined; +} diff --git a/src/remote/sshProcess.ts b/src/remote/sshProcess.ts new file mode 100644 index 00000000..b610d6e4 --- /dev/null +++ b/src/remote/sshProcess.ts @@ -0,0 +1,451 @@ +import find from "find-process"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import prettyBytes from "pretty-bytes"; +import * as vscode from "vscode"; + +import { type Logger } from "../logging/logger"; +import { findPort } from "../util"; + +/** + * Network information from the Coder CLI. + */ +export interface NetworkInfo { + p2p: boolean; + latency: number; + preferred_derp: string; + derp_latency: { [key: string]: number }; + upload_bytes_sec: number; + download_bytes_sec: number; + using_coder_connect: boolean; +} + +/** + * Options for creating an SshProcessMonitor. + */ +export interface SshProcessMonitorOptions { + sshHost: string; + networkInfoPath: string; + proxyLogDir?: string; + logger: Logger; + // Initial poll interval for SSH process and file discovery (ms) + discoveryPollIntervalMs?: number; + // Maximum backoff interval for process and file discovery (ms) + maxDiscoveryBackoffMs?: number; + // Poll interval for network info updates + networkPollInterval?: number; + // For port-based SSH process discovery + codeLogDir: string; + remoteSshExtensionId: string; +} + +/** + * Monitors the SSH process for a Coder workspace connection and displays + * network status in the VS Code status bar. + */ +export class SshProcessMonitor implements vscode.Disposable { + private readonly statusBarItem: vscode.StatusBarItem; + private readonly options: Required< + SshProcessMonitorOptions & { proxyLogDir: string | undefined } + >; + + private readonly _onLogFilePathChange = new vscode.EventEmitter< + string | undefined + >(); + private readonly _onPidChange = new vscode.EventEmitter(); + + /** + * Event fired when the log file path changes (e.g., after reconnecting to a new process). + */ + public readonly onLogFilePathChange = this._onLogFilePathChange.event; + + /** + * Event fired when the SSH process PID changes (e.g., after reconnecting). + */ + public readonly onPidChange = this._onPidChange.event; + + private disposed = false; + private currentPid: number | undefined; + private logFilePath: string | undefined; + private pendingTimeout: NodeJS.Timeout | undefined; + private lastStaleSearchTime = 0; + + private constructor(options: SshProcessMonitorOptions) { + this.options = { + ...options, + proxyLogDir: options.proxyLogDir, + discoveryPollIntervalMs: options.discoveryPollIntervalMs ?? 1000, + maxDiscoveryBackoffMs: options.maxDiscoveryBackoffMs ?? 30_000, + // Matches the SSH update interval + networkPollInterval: options.networkPollInterval ?? 3000, + }; + this.statusBarItem = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Left, + 1000, + ); + } + + /** + * Creates and starts an SSH process monitor. + * Begins searching for the SSH process in the background. + */ + public static start(options: SshProcessMonitorOptions): SshProcessMonitor { + const monitor = new SshProcessMonitor(options); + monitor.searchForProcess().catch((err) => { + options.logger.error("Error in SSH process monitor", err); + }); + return monitor; + } + + /** + * Returns the path to the log file for this connection, or undefined if not found. + */ + getLogFilePath(): string | undefined { + return this.logFilePath; + } + + /** + * Cleans up resources and stops monitoring. + */ + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + if (this.pendingTimeout) { + clearTimeout(this.pendingTimeout); + this.pendingTimeout = undefined; + } + this.statusBarItem.dispose(); + this._onLogFilePathChange.dispose(); + this._onPidChange.dispose(); + } + + /** + * Delays for the specified duration. Returns early if disposed. + */ + private async delay(ms: number): Promise { + if (this.disposed) { + return; + } + await new Promise((resolve) => { + this.pendingTimeout = setTimeout(() => { + this.pendingTimeout = undefined; + resolve(); + }, ms); + }); + } + + /** + * Searches for the SSH process indefinitely until found or disposed. + * Starts monitoring when it finds the process through the port. + */ + private async searchForProcess(): Promise { + const { discoveryPollIntervalMs, maxDiscoveryBackoffMs, logger, sshHost } = + this.options; + let attempt = 0; + let currentBackoff = discoveryPollIntervalMs; + + while (!this.disposed) { + attempt++; + + if (attempt === 1 || attempt % 10 === 0) { + logger.debug( + `SSH process search attempt ${attempt} for host: ${sshHost}`, + ); + } + + const pidByPort = await this.findSshProcessByPort(); + if (pidByPort !== undefined) { + this.setCurrentPid(pidByPort); + this.startMonitoring(); + return; + } + + await this.delay(currentBackoff); + currentBackoff = Math.min(currentBackoff * 2, maxDiscoveryBackoffMs); + } + } + + /** + * Finds SSH process by parsing the Remote SSH extension's log to get the port. + * This is more accurate as each VS Code window has a unique port. + */ + private async findSshProcessByPort(): Promise { + const { codeLogDir, remoteSshExtensionId, logger } = this.options; + + try { + const logPath = await findRemoteSshLogPath( + codeLogDir, + remoteSshExtensionId, + logger, + ); + if (!logPath) { + return undefined; + } + + const logContent = await fs.readFile(logPath, "utf8"); + this.options.logger.debug(`Read Remote SSH log file:`, logPath); + + const port = findPort(logContent); + if (!port) { + return undefined; + } + this.options.logger.debug(`Found SSH port ${port} in log file`); + + const processes = await find("port", port); + if (processes.length === 0) { + return undefined; + } + + return processes[0].pid; + } catch (error) { + logger.debug(`Port-based SSH process search failed: ${error}`); + return undefined; + } + } + + /** + * Updates the current PID and fires change events. + */ + private setCurrentPid(pid: number): void { + const previousPid = this.currentPid; + this.currentPid = pid; + + if (previousPid === undefined) { + this.options.logger.info(`SSH connection established (PID: ${pid})`); + this._onPidChange.fire(pid); + } else if (previousPid !== pid) { + this.options.logger.info( + `SSH process changed from ${previousPid} to ${pid}`, + ); + this.logFilePath = undefined; + this._onLogFilePathChange.fire(undefined); + this._onPidChange.fire(pid); + } + } + + /** + * Starts monitoring tasks after finding the SSH process. + */ + private startMonitoring(): void { + if (this.disposed || this.currentPid === undefined) { + return; + } + this.searchForLogFile(); + this.monitorNetwork(); + } + + /** + * Searches for the log file for the current PID. + * Polls until found or PID changes. + */ + private async searchForLogFile(): Promise { + const { + proxyLogDir: logDir, + logger, + discoveryPollIntervalMs, + maxDiscoveryBackoffMs, + } = this.options; + if (!logDir) { + return; + } + + let currentBackoff = discoveryPollIntervalMs; + + const targetPid = this.currentPid; + while (!this.disposed && this.currentPid === targetPid) { + try { + const logFiles = (await fs.readdir(logDir)) + .sort((a, b) => a.localeCompare(b)) + .reverse(); + const logFileName = logFiles.find( + (file) => + file === `${targetPid}.log` || file.endsWith(`-${targetPid}.log`), + ); + + if (logFileName) { + const foundPath = path.join(logDir, logFileName); + if (foundPath !== this.logFilePath) { + this.logFilePath = foundPath; + logger.info(`Log file found: ${this.logFilePath}`); + this._onLogFilePathChange.fire(this.logFilePath); + } + return; + } + } catch { + logger.debug(`Could not read log directory: ${logDir}`); + } + + await this.delay(currentBackoff); + currentBackoff = Math.min(currentBackoff * 2, maxDiscoveryBackoffMs); + } + } + + /** + * Monitors network info and updates the status bar. + * Checks file mtime to detect stale connections and trigger reconnection search. + */ + private async monitorNetwork(): Promise { + const { networkInfoPath, networkPollInterval, logger } = this.options; + const staleThreshold = networkPollInterval * 5; + + while (!this.disposed && this.currentPid !== undefined) { + const networkInfoFile = path.join( + networkInfoPath, + `${this.currentPid}.json`, + ); + + try { + const stats = await fs.stat(networkInfoFile); + const ageMs = Date.now() - stats.mtime.getTime(); + + if (ageMs > staleThreshold) { + // Prevent tight loop: if we just searched due to stale, wait before searching again + const timeSinceLastSearch = Date.now() - this.lastStaleSearchTime; + if (timeSinceLastSearch < staleThreshold) { + await this.delay(staleThreshold - timeSinceLastSearch); + continue; + } + + logger.debug( + `Network info stale (${Math.round(ageMs / 1000)}s old), searching for new SSH process`, + ); + + // searchForProcess will update PID if a different process is found + this.lastStaleSearchTime = Date.now(); + await this.searchForProcess(); + return; + } + + const content = await fs.readFile(networkInfoFile, "utf8"); + const network = JSON.parse(content) as NetworkInfo; + const isStale = ageMs > this.options.networkPollInterval * 2; + this.updateStatusBar(network, isStale); + } catch (error) { + logger.debug( + `Failed to read network info: ${(error as Error).message}`, + ); + } + + await this.delay(networkPollInterval); + } + } + + /** + * Updates the status bar with network information. + */ + private updateStatusBar(network: NetworkInfo, isStale: boolean): void { + let statusText = "$(globe) "; + + // Coder Connect doesn't populate any other stats + if (network.using_coder_connect) { + this.statusBarItem.text = statusText + "Coder Connect "; + this.statusBarItem.tooltip = "You're connected using Coder Connect."; + this.statusBarItem.show(); + return; + } + + if (network.p2p) { + statusText += "Direct "; + this.statusBarItem.tooltip = "You're connected peer-to-peer ✨."; + } else { + statusText += network.preferred_derp + " "; + this.statusBarItem.tooltip = + "You're connected through a relay 🕵.\nWe'll switch over to peer-to-peer when available."; + } + + let tooltip = this.statusBarItem.tooltip; + tooltip += + "\n\nDownload ↓ " + + prettyBytes(network.download_bytes_sec, { bits: true }) + + "/s • Upload ↑ " + + prettyBytes(network.upload_bytes_sec, { bits: true }) + + "/s\n"; + + if (!network.p2p) { + const derpLatency = network.derp_latency[network.preferred_derp]; + tooltip += `You ↔ ${derpLatency.toFixed(2)}ms ↔ ${network.preferred_derp} ↔ ${(network.latency - derpLatency).toFixed(2)}ms ↔ Workspace`; + + let first = true; + for (const region of Object.keys(network.derp_latency)) { + if (region === network.preferred_derp) { + continue; + } + if (first) { + tooltip += `\n\nOther regions:`; + first = false; + } + tooltip += `\n${region}: ${Math.round(network.derp_latency[region] * 100) / 100}ms`; + } + } + + this.statusBarItem.tooltip = tooltip; + const latencyText = isStale + ? `(~${network.latency.toFixed(2)}ms)` + : `(${network.latency.toFixed(2)}ms)`; + statusText += latencyText; + this.statusBarItem.text = statusText; + this.statusBarItem.show(); + } +} + +/** + * Finds the Remote SSH extension's log file path. + * Tries extension-specific folder first (Cursor, Windsurf, Antigravity), + * then output_logging_ fallback (MS VS Code). + */ +async function findRemoteSshLogPath( + codeLogDir: string, + extensionId: string, + logger: Logger, +): Promise { + const logsParentDir = path.dirname(codeLogDir); + + // Try extension-specific folder (for VS Code clones like Cursor, Windsurf) + try { + const extensionLogDir = path.join(logsParentDir, extensionId); + const remoteSshLog = await findSshLogInDir(extensionLogDir); + if (remoteSshLog) { + return remoteSshLog; + } + + logger.debug( + `Extension log folder exists but no Remote SSH log found: ${extensionLogDir}`, + ); + } catch { + // Extension-specific folder doesn't exist - expected for MS VS Code, try fallback + } + + try { + const dirs = await fs.readdir(logsParentDir); + const outputDirs = dirs + .filter((d) => d.startsWith("output_logging_")) + .sort((a, b) => a.localeCompare(b)) + .reverse(); + + if (outputDirs.length > 0) { + const outputPath = path.join(logsParentDir, outputDirs[0]); + const remoteSshLog = await findSshLogInDir(outputPath); + if (remoteSshLog) { + return remoteSshLog; + } + + logger.debug( + `Output logging folder exists but no Remote SSH log found: ${outputPath}`, + ); + } else { + logger.debug(`No output_logging_ folders found in: ${logsParentDir}`); + } + } catch { + logger.debug(`Could not read logs parent directory: ${logsParentDir}`); + } + + return undefined; +} + +async function findSshLogInDir(dirPath: string): Promise { + const files = await fs.readdir(dirPath); + const remoteSshLog = files.find((f) => f.includes("Remote - SSH")); + return remoteSshLog ? path.join(dirPath, remoteSshLog) : undefined; +} diff --git a/src/remote/terminalSession.ts b/src/remote/terminalSession.ts new file mode 100644 index 00000000..358134a1 --- /dev/null +++ b/src/remote/terminalSession.ts @@ -0,0 +1,39 @@ +import * as vscode from "vscode"; + +/** + * Manages a terminal and its associated write emitter as a single unit. + * Ensures both are created together and disposed together properly. + */ +export class TerminalSession implements vscode.Disposable { + public readonly writeEmitter: vscode.EventEmitter; + public readonly terminal: vscode.Terminal; + + constructor(name: string) { + this.writeEmitter = new vscode.EventEmitter(); + this.terminal = vscode.window.createTerminal({ + name, + location: vscode.TerminalLocation.Panel, + // Spin makes this gear icon spin! + iconPath: new vscode.ThemeIcon("gear~spin"), + pty: { + onDidWrite: this.writeEmitter.event, + close: () => undefined, + open: () => undefined, + }, + }); + this.terminal.show(true); + } + + dispose(): void { + try { + this.writeEmitter.dispose(); + } catch { + // Ignore disposal errors + } + try { + this.terminal.dispose(); + } catch { + // Ignore disposal errors + } + } +} diff --git a/src/remote/workspaceStateMachine.ts b/src/remote/workspaceStateMachine.ts new file mode 100644 index 00000000..d797ae5c --- /dev/null +++ b/src/remote/workspaceStateMachine.ts @@ -0,0 +1,255 @@ +import { type AuthorityParts } from "src/util"; + +import { createWorkspaceIdentifier, extractAgents } from "../api/api-helper"; +import { + startWorkspaceIfStoppedOrFailed, + streamAgentLogs, + streamBuildLogs, +} from "../api/workspace"; +import { maybeAskAgent } from "../promptUtils"; + +import { TerminalSession } from "./terminalSession"; + +import type { + ProvisionerJobLog, + Workspace, + WorkspaceAgentLog, +} from "coder/site/src/api/typesGenerated"; +import type * as vscode from "vscode"; + +import type { CoderApi } from "../api/coderApi"; +import type { PathResolver } from "../core/pathResolver"; +import type { FeatureSet } from "../featureSet"; +import type { Logger } from "../logging/logger"; +import type { UnidirectionalStream } from "../websocket/eventStreamConnection"; + +/** + * Manages workspace and agent state transitions until ready for SSH connection. + * Streams build and agent logs, and handles socket lifecycle. + */ +export class WorkspaceStateMachine implements vscode.Disposable { + private readonly terminal: TerminalSession; + + private agent: { id: string; name: string } | undefined; + + private buildLogSocket: UnidirectionalStream | null = null; + + private agentLogSocket: UnidirectionalStream | null = + null; + + constructor( + private readonly parts: AuthorityParts, + private readonly workspaceClient: CoderApi, + private readonly firstConnect: boolean, + private readonly binaryPath: string, + private readonly featureSet: FeatureSet, + private readonly logger: Logger, + private readonly pathResolver: PathResolver, + private readonly vscodeProposed: typeof vscode, + ) { + this.terminal = new TerminalSession("Workspace Build"); + } + + /** + * Process workspace state and determine if agent is ready. + * Reports progress updates and returns true if ready to connect, false if should wait for next event. + */ + async processWorkspace( + workspace: Workspace, + progress: vscode.Progress<{ message?: string }>, + ): Promise { + const workspaceName = createWorkspaceIdentifier(workspace); + + switch (workspace.latest_build.status) { + case "running": + this.closeBuildLogSocket(); + break; + + case "stopped": + case "failed": { + this.closeBuildLogSocket(); + + if (!this.firstConnect && !(await this.confirmStart(workspaceName))) { + throw new Error(`Workspace start cancelled`); + } + + progress.report({ message: `starting ${workspaceName}...` }); + this.logger.info(`Starting ${workspaceName}`); + const globalConfigDir = this.pathResolver.getGlobalConfigDir( + this.parts.safeHostname, + ); + await startWorkspaceIfStoppedOrFailed( + this.workspaceClient, + globalConfigDir, + this.binaryPath, + workspace, + this.terminal.writeEmitter, + this.featureSet, + ); + this.logger.info(`${workspaceName} status is now running`); + return false; + } + + case "pending": + case "starting": + case "stopping": + // Clear the agent since it's ID could change after a restart + this.agent = undefined; + this.closeAgentLogSocket(); + progress.report({ + message: `building ${workspaceName} (${workspace.latest_build.status})...`, + }); + this.logger.info(`Waiting for ${workspaceName}`); + + this.buildLogSocket ??= await streamBuildLogs( + this.workspaceClient, + this.terminal.writeEmitter, + workspace, + ); + return false; + + case "deleted": + case "deleting": + case "canceled": + case "canceling": + this.closeBuildLogSocket(); + throw new Error(`${workspaceName} is ${workspace.latest_build.status}`); + } + + const agents = extractAgents(workspace.latest_build.resources); + if (this.agent === undefined) { + this.logger.info(`Finding agent for ${workspaceName}`); + const gotAgent = await maybeAskAgent(agents, this.parts.agent); + if (!gotAgent) { + // User declined to pick an agent. + throw new Error("Agent selection cancelled"); + } + this.agent = { id: gotAgent.id, name: gotAgent.name }; + this.logger.info( + `Found agent ${gotAgent.name} with status`, + gotAgent.status, + ); + } + const agent = agents.find((a) => a.id === this.agent?.id); + if (!agent) { + throw new Error( + `Agent ${this.agent.name} not found in ${workspaceName} resources`, + ); + } + + switch (agent.status) { + case "connecting": + progress.report({ + message: `connecting to agent ${agent.name}...`, + }); + this.logger.debug(`Connecting to agent ${agent.name}`); + return false; + + case "disconnected": + throw new Error(`Agent ${workspaceName}/${agent.name} disconnected`); + + case "timeout": + progress.report({ + message: `agent ${agent.name} timed out, retrying...`, + }); + this.logger.debug(`Agent ${agent.name} timed out, retrying`); + return false; + + case "connected": + break; + } + + switch (agent.lifecycle_state) { + case "ready": + this.closeAgentLogSocket(); + return true; + + case "starting": { + const isBlocking = agent.scripts.some( + (script) => script.start_blocks_login, + ); + if (!isBlocking) { + return true; + } + + progress.report({ + message: `running agent ${agent.name} startup scripts...`, + }); + this.logger.debug(`Running agent ${agent.name} startup scripts`); + + this.agentLogSocket ??= await streamAgentLogs( + this.workspaceClient, + this.terminal.writeEmitter, + agent, + ); + return false; + } + + case "created": + progress.report({ + message: `starting agent ${agent.name}...`, + }); + this.logger.debug(`Starting agent ${agent.name}`); + return false; + + case "start_error": + this.closeAgentLogSocket(); + this.logger.info( + `Agent ${agent.name} startup scripts failed, but continuing`, + ); + return true; + + case "start_timeout": + this.closeAgentLogSocket(); + this.logger.info( + `Agent ${agent.name} startup scripts timed out, but continuing`, + ); + return true; + + case "shutting_down": + case "off": + case "shutdown_error": + case "shutdown_timeout": + this.closeAgentLogSocket(); + throw new Error( + `Invalid lifecycle state '${agent.lifecycle_state}' for ${workspaceName}/${agent.name}`, + ); + } + } + + private closeBuildLogSocket(): void { + if (this.buildLogSocket) { + this.buildLogSocket.close(); + this.buildLogSocket = null; + } + } + + private closeAgentLogSocket(): void { + if (this.agentLogSocket) { + this.agentLogSocket.close(); + this.agentLogSocket = null; + } + } + + private async confirmStart(workspaceName: string): Promise { + const action = await this.vscodeProposed.window.showInformationMessage( + `Unable to connect to the workspace ${workspaceName} because it is not running. Start the workspace?`, + { + useCustom: true, + modal: true, + }, + "Start", + ); + return action === "Start"; + } + + public getAgentId(): string | undefined { + return this.agent?.id; + } + + dispose(): void { + this.closeBuildLogSocket(); + this.closeAgentLogSocket(); + this.terminal.dispose(); + } +} diff --git a/src/util.ts b/src/util.ts index e7c5c24c..35492eea 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,10 +1,10 @@ -import * as os from "os"; -import url from "url"; +import * as os from "node:os"; +import url from "node:url"; export interface AuthorityParts { agent: string | undefined; - host: string; - label: string; + sshHost: string; + safeHostname: string; username: string; workspace: string; } @@ -13,27 +13,32 @@ export interface AuthorityParts { // they should be handled by this extension. export const AuthorityPrefix = "coder-vscode"; -// `ms-vscode-remote.remote-ssh`: `-> socksPort ->` -// `codeium.windsurf-remote-openssh`, `jeanp413.open-remote-ssh`: `=> (socks) =>` -// Windows `ms-vscode-remote.remote-ssh`: `between local port ` +// 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+) ->|=> (\d+)\(socks\) =>|between local port (\d+))/; + /(?:-> socksPort (\d+) ->|between local port (\d+)|=> (\d+)\(socks\) =>|Socks port: (\d+))/g; /** - * Given the contents of a Remote - SSH log file, find a port number used by the - * SSH process. This is typically the socks port, but the local port works too. + * 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 matches = text.match(RemoteSSHLogPortRegex); - if (!matches) { + const allMatches = [...text.matchAll(RemoteSSHLogPortRegex)]; + if (allMatches.length === 0) { return null; } - if (matches.length < 2) { - return null; - } - const portStr = matches[1] || matches[2] || matches[3]; + + // Get the last match, which is the most recent port. + const lastMatch = allMatches.at(-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] windsurf/open-remote-ssh/antigravity, [4] anysphere.remote-ssh + const portStr = lastMatch[1] || lastMatch[2] || lastMatch[3] || lastMatch[4]; if (!portStr) { return null; } @@ -88,8 +93,8 @@ export function parseRemoteAuthority(authority: string): AuthorityParts | null { return { agent: agent, - host: authorityParts[1], - label: parts[0].replace(/^coder-vscode\.?/, ""), + sshHost: authorityParts[1], + safeHostname: parts[0].replace(/^coder-vscode\.?/, ""), username: parts[1], workspace: workspace, }; @@ -119,13 +124,14 @@ export function toSafeHost(rawUrl: string): string { } /** - * Expand a path with ${userHome} in the input string - * @param input string - * @returns string + * Expand a path if it starts with tilde (~) or contains ${userHome}. */ export function expandPath(input: string): string { const userHome = os.homedir(); - return input.replace(/\${userHome}/g, userHome); + if (input.startsWith("~")) { + input = userHome + input.substring("~".length); + } + return input.replaceAll("${userHome}", userHome); } /** @@ -145,5 +151,6 @@ export function countSubstring(needle: string, haystack: string): number { } export function escapeCommandArg(arg: string): string { - return `"${arg.replace(/"/g, '\\"')}"`; + const escapedString = arg.replaceAll('"', String.raw`\"`); + return `"${escapedString}"`; } diff --git a/src/websocket/codes.ts b/src/websocket/codes.ts new file mode 100644 index 00000000..f3fd95cd --- /dev/null +++ b/src/websocket/codes.ts @@ -0,0 +1,59 @@ +/** + * WebSocket close codes (RFC 6455) and HTTP status codes for socket connections. + * @see https://www.rfc-editor.org/rfc/rfc6455#section-7.4.1 + */ + +/** WebSocket close codes defined in RFC 6455 */ +export const WebSocketCloseCode = { + /** Normal closure - connection successfully completed */ + NORMAL: 1000, + /** Endpoint going away (server shutdown) */ + GOING_AWAY: 1001, + /** Protocol error - connection cannot be recovered */ + PROTOCOL_ERROR: 1002, + /** Unsupported data type received - connection cannot be recovered */ + UNSUPPORTED_DATA: 1003, + /** Abnormal closure - connection closed without close frame (network issues) */ + ABNORMAL: 1006, +} as const; + +/** HTTP status codes used for socket creation and connection logic */ +export const HttpStatusCode = { + /** Authentication required */ + UNAUTHORIZED: 401, + /** Permission denied */ + FORBIDDEN: 403, + /** Endpoint not found */ + NOT_FOUND: 404, + /** Resource permanently gone */ + GONE: 410, + /** Protocol upgrade required */ + UPGRADE_REQUIRED: 426, +} as const; + +/** + * WebSocket close codes indicating unrecoverable errors. + * These appear in close events and should stop reconnection attempts. + */ +export const UNRECOVERABLE_WS_CLOSE_CODES = new Set([ + WebSocketCloseCode.PROTOCOL_ERROR, + WebSocketCloseCode.UNSUPPORTED_DATA, +]); + +/** + * HTTP status codes indicating unrecoverable errors during handshake. + * These appear during socket creation and should stop reconnection attempts. + */ +export const UNRECOVERABLE_HTTP_CODES = new Set([ + HttpStatusCode.UNAUTHORIZED, + HttpStatusCode.FORBIDDEN, + HttpStatusCode.NOT_FOUND, + HttpStatusCode.GONE, + HttpStatusCode.UPGRADE_REQUIRED, +]); + +/** Close codes indicating intentional closure - do not reconnect */ +export const NORMAL_CLOSURE_CODES = new Set([ + WebSocketCloseCode.NORMAL, + WebSocketCloseCode.GOING_AWAY, +]); diff --git a/src/websocket/eventStreamConnection.ts b/src/websocket/eventStreamConnection.ts new file mode 100644 index 00000000..e3100ee6 --- /dev/null +++ b/src/websocket/eventStreamConnection.ts @@ -0,0 +1,56 @@ +import { type WebSocketEventType } from "coder/site/src/utils/OneWayWebSocket"; +import { + type CloseEvent as WsCloseEvent, + type Event as WsEvent, + type ErrorEvent as WsErrorEvent, + type MessageEvent as WsMessageEvent, +} from "ws"; + +export type Event = Omit; +export type CloseEvent = Omit; +export type ErrorEvent = Omit; +export type MessageEvent = Omit; + +// Event payload types matching OneWayWebSocket +export type ParsedMessageEvent = Readonly< + | { + sourceEvent: MessageEvent; + parsedMessage: TData; + parseError: undefined; + } + | { + sourceEvent: MessageEvent; + parsedMessage: undefined; + parseError: Error; + } +>; + +export type EventPayloadMap = { + close: CloseEvent; + error: ErrorEvent; + message: ParsedMessageEvent; + open: Event; +}; + +export type EventHandler = ( + payload: EventPayloadMap[TEvent], +) => void; + +/** + * Common interface for both WebSocket and SSE connections that handle event streams. + * Matches the OneWayWebSocket interface for compatibility. + */ +export interface UnidirectionalStream { + readonly url: string; + addEventListener( + eventType: TEvent, + callback: EventHandler, + ): void; + + removeEventListener( + eventType: TEvent, + callback: EventHandler, + ): void; + + close(code?: number, reason?: string): void; +} diff --git a/src/websocket/oneWayWebSocket.ts b/src/websocket/oneWayWebSocket.ts index 37965596..c27b1fe4 100644 --- a/src/websocket/oneWayWebSocket.ts +++ b/src/websocket/oneWayWebSocket.ts @@ -8,51 +8,13 @@ */ import { type WebSocketEventType } from "coder/site/src/utils/OneWayWebSocket"; -import Ws, { - type ClientOptions, - type CloseEvent, - type ErrorEvent, - type Event, - type MessageEvent, - type RawData, -} from "ws"; +import Ws, { type ClientOptions, type MessageEvent, type RawData } from "ws"; -export type OneWayMessageEvent = Readonly< - | { - sourceEvent: MessageEvent; - parsedMessage: TData; - parseError: undefined; - } - | { - sourceEvent: MessageEvent; - parsedMessage: undefined; - parseError: Error; - } ->; - -type OneWayEventPayloadMap = { - close: CloseEvent; - error: ErrorEvent; - message: OneWayMessageEvent; - open: Event; -}; - -type OneWayEventCallback = ( - payload: OneWayEventPayloadMap[TEvent], -) => void; - -interface OneWayWebSocketApi { - get url(): string; - addEventListener( - eventType: TEvent, - callback: OneWayEventCallback, - ): void; - removeEventListener( - eventType: TEvent, - callback: OneWayEventCallback, - ): void; - close(code?: number, reason?: string): void; -} +import { + type UnidirectionalStream, + type EventHandler, +} from "./eventStreamConnection"; +import { getQueryString } from "./utils"; export type OneWayWebSocketInit = { location: { protocol: string; host: string }; @@ -63,23 +25,18 @@ export type OneWayWebSocketInit = { }; export class OneWayWebSocket - implements OneWayWebSocketApi + implements UnidirectionalStream { readonly #socket: Ws; readonly #messageCallbacks = new Map< - OneWayEventCallback, + EventHandler, (data: RawData) => void >(); constructor(init: OneWayWebSocketInit) { const { location, apiRoute, protocols, options, searchParams } = init; - const formattedParams = - searchParams instanceof URLSearchParams - ? searchParams - : new URLSearchParams(searchParams); - const paramsString = formattedParams.toString(); - const paramsSuffix = paramsString ? `?${paramsString}` : ""; + const paramsSuffix = getQueryString(searchParams); const wsProtocol = location.protocol === "https:" ? "wss:" : "ws:"; const url = `${wsProtocol}//${location.host}${apiRoute}${paramsSuffix}`; @@ -92,10 +49,10 @@ export class OneWayWebSocket addEventListener( event: TEvent, - callback: OneWayEventCallback, + callback: EventHandler, ): void { if (event === "message") { - const messageCallback = callback as OneWayEventCallback; + const messageCallback = callback as EventHandler; if (this.#messageCallbacks.has(messageCallback)) { return; @@ -128,10 +85,10 @@ export class OneWayWebSocket removeEventListener( event: TEvent, - callback: OneWayEventCallback, + callback: EventHandler, ): void { if (event === "message") { - const messageCallback = callback as OneWayEventCallback; + const messageCallback = callback as EventHandler; const wrapper = this.#messageCallbacks.get(messageCallback); if (wrapper) { diff --git a/src/websocket/reconnectingWebSocket.ts b/src/websocket/reconnectingWebSocket.ts new file mode 100644 index 00000000..fc4327d5 --- /dev/null +++ b/src/websocket/reconnectingWebSocket.ts @@ -0,0 +1,362 @@ +import { + WebSocketCloseCode, + NORMAL_CLOSURE_CODES, + UNRECOVERABLE_WS_CLOSE_CODES, + UNRECOVERABLE_HTTP_CODES, +} from "./codes"; + +import type { WebSocketEventType } from "coder/site/src/utils/OneWayWebSocket"; + +import type { Logger } from "../logging/logger"; + +import type { + EventHandler, + UnidirectionalStream, +} from "./eventStreamConnection"; + +export type SocketFactory = () => Promise>; + +export type ReconnectingWebSocketOptions = { + initialBackoffMs?: number; + maxBackoffMs?: number; + jitterFactor?: number; +}; + +export class ReconnectingWebSocket + implements UnidirectionalStream +{ + readonly #socketFactory: SocketFactory; + readonly #logger: Logger; + readonly #options: Required; + readonly #eventHandlers: { + [K in WebSocketEventType]: Set>; + } = { + open: new Set>(), + close: new Set>(), + error: new Set>(), + message: new Set>(), + }; + + #currentSocket: UnidirectionalStream | null = null; + #lastRoute = "unknown"; // Cached route for logging when socket is closed + #backoffMs: number; + #reconnectTimeoutId: NodeJS.Timeout | null = null; + #isDisconnected = false; // Temporary pause, can be resumed via reconnect() + #isDisposed = false; // Permanent disposal, cannot be resumed + #isConnecting = false; + #pendingReconnect = false; + readonly #onDispose?: () => void; + + private constructor( + socketFactory: SocketFactory, + logger: Logger, + options: ReconnectingWebSocketOptions = {}, + onDispose?: () => void, + ) { + this.#socketFactory = socketFactory; + this.#logger = logger; + this.#options = { + initialBackoffMs: options.initialBackoffMs ?? 250, + maxBackoffMs: options.maxBackoffMs ?? 30000, + jitterFactor: options.jitterFactor ?? 0.1, + }; + this.#backoffMs = this.#options.initialBackoffMs; + this.#onDispose = onDispose; + } + + static async create( + socketFactory: SocketFactory, + logger: Logger, + options: ReconnectingWebSocketOptions = {}, + onDispose?: () => void, + ): Promise> { + const instance = new ReconnectingWebSocket( + socketFactory, + logger, + options, + onDispose, + ); + await instance.connect(); + return instance; + } + + get url(): string { + return this.#currentSocket?.url ?? ""; + } + + /** + * Extract the route (pathname + search) from the current socket URL for logging. + * Falls back to the last known route when the socket is closed. + */ + get #route(): string { + const socketUrl = this.#currentSocket?.url; + if (!socketUrl) { + return this.#lastRoute; + } + const url = new URL(socketUrl); + return url.pathname + url.search; + } + + addEventListener( + event: TEvent, + callback: EventHandler, + ): void { + this.#eventHandlers[event].add(callback); + } + + removeEventListener( + event: TEvent, + callback: EventHandler, + ): void { + this.#eventHandlers[event].delete(callback); + } + + /** + * Force an immediate reconnection attempt. + * Resumes the socket if previously disconnected via disconnect(). + */ + reconnect(): void { + if (this.#isDisconnected) { + this.#isDisconnected = false; + this.#backoffMs = this.#options.initialBackoffMs; + } + + if (this.#isDisposed) { + return; + } + + if (this.#reconnectTimeoutId !== null) { + clearTimeout(this.#reconnectTimeoutId); + this.#reconnectTimeoutId = null; + } + + // If already connecting, schedule reconnect after current attempt + if (this.#isConnecting) { + this.#pendingReconnect = true; + return; + } + + // connect() will close any existing socket + this.connect().catch((error) => this.handleConnectionError(error)); + } + + /** + * Temporarily disconnect the socket. Can be resumed via reconnect(). + */ + disconnect(code?: number, reason?: string): void { + if (this.#isDisposed || this.#isDisconnected) { + return; + } + + this.#isDisconnected = true; + this.clearCurrentSocket(code, reason); + } + + close(code?: number, reason?: string): void { + if (this.#isDisposed) { + return; + } + + // Fire close handlers synchronously before disposing + if (this.#currentSocket) { + this.executeHandlers("close", { + code: code ?? WebSocketCloseCode.NORMAL, + reason: reason ?? "Normal closure", + wasClean: true, + }); + } + + this.dispose(code, reason); + } + + private async connect(): Promise { + if (this.#isDisposed || this.#isDisconnected || this.#isConnecting) { + return; + } + + this.#isConnecting = true; + try { + // Close any existing socket before creating a new one + if (this.#currentSocket) { + this.#currentSocket.close( + WebSocketCloseCode.NORMAL, + "Replacing connection", + ); + this.#currentSocket = null; + } + + const socket = await this.#socketFactory(); + + // Check if disconnected/disposed while waiting for factory + if (this.#isDisposed || this.#isDisconnected) { + socket.close(WebSocketCloseCode.NORMAL, "Cancelled during connection"); + return; + } + + this.#currentSocket = socket; + this.#lastRoute = this.#route; + + socket.addEventListener("open", (event) => { + this.#backoffMs = this.#options.initialBackoffMs; + this.executeHandlers("open", event); + }); + + socket.addEventListener("message", (event) => { + this.executeHandlers("message", event); + }); + + socket.addEventListener("error", (event) => { + this.executeHandlers("error", event); + + // Check for unrecoverable HTTP errors in the error event + // HTTP errors during handshake fire 'error' then 'close' with 1006 + // We need to suspend here to prevent infinite reconnect loops + const errorMessage = event.error?.message ?? event.message ?? ""; + if (this.isUnrecoverableHttpError(errorMessage)) { + this.#logger.error( + `Unrecoverable HTTP error for ${this.#route}: ${errorMessage}`, + ); + this.disconnect(); + } + }); + + socket.addEventListener("close", (event) => { + if (this.#isDisposed || this.#isDisconnected) { + return; + } + + this.executeHandlers("close", event); + + if (UNRECOVERABLE_WS_CLOSE_CODES.has(event.code)) { + this.#logger.error( + `WebSocket connection closed with unrecoverable error code ${event.code}`, + ); + // Suspend instead of dispose - allows recovery when credentials change + this.disconnect(); + return; + } + + // Don't reconnect on normal closure + if (NORMAL_CLOSURE_CODES.has(event.code)) { + return; + } + + // Reconnect on abnormal closures (e.g., 1006) or other unexpected codes + this.scheduleReconnect(); + }); + } finally { + this.#isConnecting = false; + + if (this.#pendingReconnect) { + this.#pendingReconnect = false; + this.reconnect(); + } + } + } + + private scheduleReconnect(): void { + if ( + this.#isDisposed || + this.#isDisconnected || + this.#reconnectTimeoutId !== null + ) { + return; + } + + const jitter = + this.#backoffMs * this.#options.jitterFactor * (Math.random() * 2 - 1); + const delayMs = Math.max(0, this.#backoffMs + jitter); + + this.#logger.debug( + `Reconnecting WebSocket in ${Math.round(delayMs)}ms for ${this.#route}`, + ); + + this.#reconnectTimeoutId = setTimeout(() => { + this.#reconnectTimeoutId = null; + this.connect().catch((error) => this.handleConnectionError(error)); + }, delayMs); + + this.#backoffMs = Math.min(this.#backoffMs * 2, this.#options.maxBackoffMs); + } + + private executeHandlers( + event: TEvent, + eventData: Parameters>[0], + ): void { + for (const handler of this.#eventHandlers[event]) { + try { + handler(eventData); + } catch (error) { + this.#logger.error( + `Error in ${event} handler for ${this.#route}`, + error, + ); + } + } + } + + /** + * Checks if the error is unrecoverable and suspends the connection, + * otherwise schedules a reconnect. + */ + private handleConnectionError(error: unknown): void { + if (this.#isDisposed || this.#isDisconnected) { + return; + } + + if (this.isUnrecoverableHttpError(error)) { + this.#logger.error( + `Unrecoverable HTTP error during connection for ${this.#route}`, + error, + ); + this.disconnect(); + return; + } + + this.#logger.warn(`WebSocket connection failed for ${this.#route}`, error); + this.scheduleReconnect(); + } + + /** + * Check if an error message contains an unrecoverable HTTP status code. + */ + private isUnrecoverableHttpError(error: unknown): boolean { + const message = (error as { message?: string }).message || String(error); + for (const code of UNRECOVERABLE_HTTP_CODES) { + if (message.includes(String(code))) { + return true; + } + } + return false; + } + + private dispose(code?: number, reason?: string): void { + if (this.#isDisposed) { + return; + } + + this.#isDisposed = true; + this.clearCurrentSocket(code, reason); + + for (const set of Object.values(this.#eventHandlers)) { + set.clear(); + } + + this.#onDispose?.(); + } + + private clearCurrentSocket(code?: number, reason?: string): void { + // Clear pending reconnect to prevent resume + this.#pendingReconnect = false; + + if (this.#reconnectTimeoutId !== null) { + clearTimeout(this.#reconnectTimeoutId); + this.#reconnectTimeoutId = null; + } + + if (this.#currentSocket) { + this.#currentSocket.close(code, reason); + this.#currentSocket = null; + } + } +} diff --git a/src/websocket/sseConnection.ts b/src/websocket/sseConnection.ts new file mode 100644 index 00000000..dc20eeda --- /dev/null +++ b/src/websocket/sseConnection.ts @@ -0,0 +1,217 @@ +import { type AxiosInstance } from "axios"; +import { type ServerSentEvent } from "coder/site/src/api/typesGenerated"; +import { type WebSocketEventType } from "coder/site/src/utils/OneWayWebSocket"; +import { EventSource } from "eventsource"; + +import { createStreamingFetchAdapter } from "../api/streamingFetchAdapter"; +import { type Logger } from "../logging/logger"; + +import { WebSocketCloseCode } from "./codes"; +import { getQueryString } from "./utils"; + +import type { + UnidirectionalStream, + ParsedMessageEvent, + EventHandler, + ErrorEvent as WsErrorEvent, +} from "./eventStreamConnection"; + +export type SseConnectionInit = { + location: { protocol: string; host: string }; + apiRoute: string; + searchParams?: Record | URLSearchParams; + optionsHeaders?: Record; + axiosInstance: AxiosInstance; + logger: Logger; +}; + +export class SseConnection implements UnidirectionalStream { + private readonly eventSource: EventSource; + private readonly logger: Logger; + private readonly callbacks = { + open: new Set>(), + close: new Set>(), + error: new Set>(), + }; + // Original callback -> wrapped callback + private readonly messageWrappers = new Map< + EventHandler, + (event: MessageEvent) => void + >(); + + public readonly url: string; + + public constructor(init: SseConnectionInit) { + this.logger = init.logger; + this.url = this.buildUrl(init); + this.eventSource = new EventSource(this.url, { + fetch: createStreamingFetchAdapter( + init.axiosInstance, + init.optionsHeaders, + ), + }); + this.setupEventHandlers(); + } + + private buildUrl(init: SseConnectionInit): string { + const { location, apiRoute, searchParams } = init; + const queryString = getQueryString(searchParams); + return `${location.protocol}//${location.host}${apiRoute}${queryString}`; + } + + private setupEventHandlers(): void { + this.eventSource.addEventListener("open", () => + this.invokeCallbacks(this.callbacks.open, {}, "open"), + ); + + this.eventSource.addEventListener("data", (event: MessageEvent) => { + this.invokeCallbacks(this.messageWrappers.values(), event, "message"); + }); + + this.eventSource.addEventListener("error", (error: Event | ErrorEvent) => { + this.invokeCallbacks( + this.callbacks.error, + this.createErrorEvent(error), + "error", + ); + + if (this.eventSource.readyState === EventSource.CLOSED) { + this.invokeCallbacks( + this.callbacks.close, + { + code: WebSocketCloseCode.ABNORMAL, + reason: "Connection lost", + wasClean: false, + }, + "close", + ); + } + }); + } + + private invokeCallbacks( + callbacks: Iterable<(event: T) => void>, + event: T, + eventType: string, + ): void { + for (const cb of callbacks) { + try { + cb(event); + } catch (err) { + this.logger.error(`Error in SSE ${eventType} callback:`, err); + } + } + } + + private createErrorEvent(event: Event | ErrorEvent): WsErrorEvent { + // Check for properties instead of instanceof to avoid browser-only ErrorEvent global + const eventWithMessage = event as { message?: string; error?: unknown }; + const errorMessage = eventWithMessage.message || "SSE connection error"; + const error = eventWithMessage.error; + + return { + error: error, + message: errorMessage, + }; + } + + public addEventListener( + event: TEvent, + callback: EventHandler, + ): void { + switch (event) { + case "close": + this.callbacks.close.add( + callback as EventHandler, + ); + break; + case "error": + this.callbacks.error.add( + callback as EventHandler, + ); + break; + case "message": { + const messageCallback = callback as EventHandler< + ServerSentEvent, + "message" + >; + if (!this.messageWrappers.has(messageCallback)) { + this.messageWrappers.set(messageCallback, (event: MessageEvent) => { + messageCallback(this.parseMessage(event)); + }); + } + break; + } + case "open": + this.callbacks.open.add( + callback as EventHandler, + ); + break; + } + } + + private parseMessage( + event: MessageEvent, + ): ParsedMessageEvent { + const wsEvent = { data: event.data }; + try { + return { + sourceEvent: wsEvent, + parsedMessage: { type: "data", data: JSON.parse(event.data) }, + parseError: undefined, + }; + } catch (err) { + return { + sourceEvent: wsEvent, + parsedMessage: undefined, + parseError: err as Error, + }; + } + } + + public removeEventListener( + event: TEvent, + callback: EventHandler, + ): void { + switch (event) { + case "close": + this.callbacks.close.delete( + callback as EventHandler, + ); + break; + case "error": + this.callbacks.error.delete( + callback as EventHandler, + ); + break; + case "message": + this.messageWrappers.delete( + callback as EventHandler, + ); + break; + case "open": + this.callbacks.open.delete( + callback as EventHandler, + ); + break; + } + } + + public close(code?: number, reason?: string): void { + this.eventSource.close(); + this.invokeCallbacks( + this.callbacks.close, + { + code: code ?? WebSocketCloseCode.NORMAL, + reason: reason ?? "Normal closure", + wasClean: true, + }, + "close", + ); + + for (const callbackSet of Object.values(this.callbacks)) { + callbackSet.clear(); + } + this.messageWrappers.clear(); + } +} diff --git a/src/websocket/utils.ts b/src/websocket/utils.ts new file mode 100644 index 00000000..592ce45e --- /dev/null +++ b/src/websocket/utils.ts @@ -0,0 +1,15 @@ +/** + * 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/workspace/workspaceMonitor.ts b/src/workspace/workspaceMonitor.ts index 0b154f75..1a332f4e 100644 --- a/src/workspace/workspaceMonitor.ts +++ b/src/workspace/workspaceMonitor.ts @@ -9,7 +9,7 @@ import { createWorkspaceIdentifier, errToStr } from "../api/api-helper"; import { type CoderApi } from "../api/coderApi"; import { type ContextManager } from "../core/contextManager"; import { type Logger } from "../logging/logger"; -import { type OneWayWebSocket } from "../websocket/oneWayWebSocket"; +import { type UnidirectionalStream } from "../websocket/eventStreamConnection"; /** * Monitor a single workspace using a WebSocket for events like shutdown and deletion. @@ -17,18 +17,19 @@ import { type OneWayWebSocket } from "../websocket/oneWayWebSocket"; * workspace status is also shown in the status bar menu. */ export class WorkspaceMonitor implements vscode.Disposable { - private socket: OneWayWebSocket; + private socket: UnidirectionalStream | undefined; private disposed = false; // How soon in advance to notify about autostop and deletion. - private autostopNotifyTime = 1000 * 60 * 30; // 30 minutes. - private deletionNotifyTime = 1000 * 60 * 60 * 24; // 24 hours. + private readonly autostopNotifyTime = 1000 * 60 * 30; // 30 minutes. + private readonly deletionNotifyTime = 1000 * 60 * 60 * 24; // 24 hours. // Only notify once. private notifiedAutostop = false; private notifiedDeletion = false; private notifiedOutdated = false; private notifiedNotRunning = false; + private completedInitialSetup = false; readonly onChange = new vscode.EventEmitter(); private readonly statusBarItem: vscode.StatusBarItem; @@ -36,7 +37,7 @@ export class WorkspaceMonitor implements vscode.Disposable { // For logging. private readonly name: string; - constructor( + private constructor( workspace: Workspace, private readonly client: CoderApi, private readonly logger: Logger, @@ -45,43 +46,73 @@ export class WorkspaceMonitor implements vscode.Disposable { private readonly contextManager: ContextManager, ) { this.name = createWorkspaceIdentifier(workspace); - const socket = this.client.watchWorkspace(workspace); + + const statusBarItem = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Left, + 999, + ); + statusBarItem.name = "Coder Workspace Update"; + statusBarItem.text = "$(fold-up) Update Workspace"; + statusBarItem.command = "coder.workspace.update"; + + // Store so we can update when the workspace data updates. + this.statusBarItem = statusBarItem; + + this.update(workspace); // Set initial state. + } + + /** + * Factory method to create and initialize a WorkspaceMonitor. + * Use this instead of the constructor to properly handle async websocket initialization. + */ + static async create( + workspace: Workspace, + client: CoderApi, + logger: Logger, + vscodeProposed: typeof vscode, + contextManager: ContextManager, + ): Promise { + const monitor = new WorkspaceMonitor( + workspace, + client, + logger, + vscodeProposed, + contextManager, + ); + + // Initialize websocket connection + const socket = await client.watchWorkspace(workspace); socket.addEventListener("open", () => { - this.logger.info(`Monitoring ${this.name}...`); + logger.info(`Monitoring ${monitor.name}...`); }); socket.addEventListener("message", (event) => { try { if (event.parseError) { - this.notifyError(event.parseError); + monitor.notifyError(event.parseError); return; } // Perhaps we need to parse this and validate it. - const newWorkspaceData = event.parsedMessage.data as Workspace; - this.update(newWorkspaceData); - this.maybeNotify(newWorkspaceData); - this.onChange.fire(newWorkspaceData); + const newWorkspaceData = event.parsedMessage.data as Workspace | null; + if (newWorkspaceData) { + monitor.update(newWorkspaceData); + monitor.maybeNotify(newWorkspaceData); + monitor.onChange.fire(newWorkspaceData); + } } catch (error) { - this.notifyError(error); + monitor.notifyError(error); } }); // Store so we can close in dispose(). - this.socket = socket; - - const statusBarItem = vscode.window.createStatusBarItem( - vscode.StatusBarAlignment.Left, - 999, - ); - statusBarItem.name = "Coder Workspace Update"; - statusBarItem.text = "$(fold-up) Update Workspace"; - statusBarItem.command = "coder.workspace.update"; + monitor.socket = socket; - // Store so we can update when the workspace data updates. - this.statusBarItem = statusBarItem; + return monitor; + } - this.update(workspace); // Set initial state. + public markInitialSetupComplete(): void { + this.completedInitialSetup = true; } /** @@ -91,7 +122,7 @@ export class WorkspaceMonitor implements vscode.Disposable { if (!this.disposed) { this.logger.info(`Unmonitoring ${this.name}...`); this.statusBarItem.dispose(); - this.socket.close(); + this.socket?.close(); this.disposed = true; } } @@ -104,8 +135,11 @@ export class WorkspaceMonitor implements vscode.Disposable { private maybeNotify(workspace: Workspace) { this.maybeNotifyOutdated(workspace); this.maybeNotifyAutostop(workspace); - this.maybeNotifyDeletion(workspace); - this.maybeNotifyNotRunning(workspace); + if (this.completedInitialSetup) { + // This instance might be created before the workspace is running + this.maybeNotifyDeletion(workspace); + this.maybeNotifyNotRunning(workspace); + } } private maybeNotifyAutostop(workspace: Workspace) { @@ -167,7 +201,7 @@ export class WorkspaceMonitor implements vscode.Disposable { } private isImpending(target: string, notifyTime: number): boolean { - const nowTime = new Date().getTime(); + const nowTime = Date.now(); const targetTime = new Date(target).getTime(); const timeLeft = targetTime - nowTime; return timeLeft >= 0 && timeLeft <= notifyTime; @@ -223,10 +257,10 @@ export class WorkspaceMonitor implements vscode.Disposable { } private updateStatusBar(workspace: Workspace) { - if (!workspace.outdated) { - this.statusBarItem.hide(); - } else { + if (workspace.outdated) { this.statusBarItem.show(); + } else { + this.statusBarItem.hide(); } } } diff --git a/src/workspace/workspacesProvider.ts b/src/workspace/workspacesProvider.ts index b83e4f84..2dffec13 100644 --- a/src/workspace/workspacesProvider.ts +++ b/src/workspace/workspacesProvider.ts @@ -11,7 +11,7 @@ import { createAgentMetadataWatcher, formatEventLabel, formatMetadataError, -} from "../agentMetadataHelper"; +} from "../api/agentMetadataHelper"; import { type AgentMetadataEvent, extractAgents, @@ -38,8 +38,10 @@ export class WorkspaceProvider { // Undefined if we have never fetched workspaces before. private workspaces: WorkspaceTreeItem[] | undefined; - private agentWatchers: Map = - new Map(); + private readonly agentWatchers: Map< + WorkspaceAgent["id"], + AgentMetadataWatcher + > = new Map(); private timeout: NodeJS.Timeout | undefined; private fetching = false; private visible = false; @@ -130,14 +132,17 @@ export class WorkspaceProvider const showMetadata = this.getWorkspacesQuery === WorkspaceQuery.Mine; if (showMetadata) { const agents = extractAllAgents(resp.workspaces); - agents.forEach((agent) => { + agents.forEach(async (agent) => { // If we have an existing watcher, re-use it. const oldWatcher = this.agentWatchers.get(agent.id); if (oldWatcher) { reusedWatcherIds.push(agent.id); } else { // Otherwise create a new watcher. - const watcher = createAgentMetadataWatcher(agent.id, this.client); + const watcher = await createAgentMetadataWatcher( + agent.id, + this.client, + ); watcher.onChange(() => this.refresh()); this.agentWatchers.set(agent.id, watcher); } diff --git a/test/fixtures/bin.bash b/test/fixtures/scripts/bin.bash similarity index 100% rename from test/fixtures/bin.bash rename to test/fixtures/scripts/bin.bash diff --git a/test/fixtures/bin.old.bash b/test/fixtures/scripts/bin.old.bash similarity index 100% rename from test/fixtures/bin.old.bash rename to test/fixtures/scripts/bin.old.bash diff --git a/test/mocks/testHelpers.ts b/test/mocks/testHelpers.ts index 2ef46716..21978b13 100644 --- a/test/mocks/testHelpers.ts +++ b/test/mocks/testHelpers.ts @@ -1,14 +1,18 @@ import { vi } from "vitest"; import * as vscode from "vscode"; -import { type Logger } from "@/logging/logger"; +import type { User } from "coder/site/src/api/typesGenerated"; +import type { IncomingMessage } from "node:http"; + +import type { CoderApi } from "@/api/coderApi"; +import type { Logger } from "@/logging/logger"; /** * Mock configuration provider that integrates with the vscode workspace configuration mock. * Use this to set configuration values that will be returned by vscode.workspace.getConfiguration(). */ export class MockConfigurationProvider { - private config = new Map(); + private readonly config = new Map(); constructor() { this.setupVSCodeMock(); @@ -28,7 +32,7 @@ export class MockConfigurationProvider { get(key: string, defaultValue: T): T; get(key: string, defaultValue?: T): T | undefined { const value = this.config.get(key); - return value !== undefined ? (value as T) : defaultValue; + return value === undefined ? defaultValue : (value as T); } /** @@ -52,7 +56,7 @@ export class MockConfigurationProvider { return { get: vi.fn((key: string, defaultValue?: unknown) => { const value = snapshot.get(getFullKey(key)); - return value !== undefined ? value : defaultValue; + return value === undefined ? defaultValue : value; }), has: vi.fn((key: string) => { return snapshot.has(getFullKey(key)); @@ -136,11 +140,13 @@ export class MockProgressReporter { } /** - * Mock user interaction that integrates with vscode.window message dialogs. + * Mock user interaction that integrates with vscode.window message dialogs and input boxes. * Use this to control user responses in tests. */ export class MockUserInteraction { - private responses = new Map(); + private readonly responses = new Map(); + private inputBoxValue: string | undefined; + private inputBoxValidateInput: ((value: string) => Promise) | undefined; private externalUrls: string[] = []; constructor() { @@ -148,12 +154,28 @@ export class MockUserInteraction { } /** - * Set a response for a specific message + * Set a response for a specific message dialog */ setResponse(message: string, response: string | undefined): void { this.responses.set(message, response); } + /** + * Set the value to return from showInputBox. + * Pass undefined to simulate user cancelling. + */ + setInputBoxValue(value: string | undefined): void { + this.inputBoxValue = value; + } + + /** + * Set a custom validateInput handler for showInputBox. + * This allows tests to simulate the validation callback behavior. + */ + setInputBoxValidateInput(fn: (value: string) => Promise): void { + this.inputBoxValidateInput = fn; + } + /** * Get all URLs that were opened externally */ @@ -169,10 +191,13 @@ export class MockUserInteraction { } /** - * Clear all responses + * Clear all responses and input box values */ - clearResponses(): void { + clear(): void { this.responses.clear(); + this.inputBoxValue = undefined; + this.inputBoxValidateInput = undefined; + this.externalUrls = []; } /** @@ -205,12 +230,38 @@ export class MockUserInteraction { return Promise.resolve(true); }, ); + + vi.mocked(vscode.window.showInputBox).mockImplementation( + async (options?: vscode.InputBoxOptions) => { + const value = this.inputBoxValue; + if (value === undefined) { + return undefined; // User cancelled + } + + if (options?.validateInput) { + const validationResult = await options.validateInput(value); + if (validationResult) { + // Validation failed - in real VS Code this would show error + // For tests, we can use the custom handler or return undefined + if (this.inputBoxValidateInput) { + await this.inputBoxValidateInput(value); + } + return undefined; + } + } else if (this.inputBoxValidateInput) { + // Run custom validation handler even without options.validateInput + await this.inputBoxValidateInput(value); + } + + return value; + }, + ); } } // Simple in-memory implementation of Memento export class InMemoryMemento implements vscode.Memento { - private storage = new Map(); + private readonly storage = new Map(); get(key: string): T | undefined; get(key: string, defaultValue: T): T; @@ -234,9 +285,11 @@ export class InMemoryMemento implements vscode.Memento { // Simple in-memory implementation of SecretStorage export class InMemorySecretStorage implements vscode.SecretStorage { - private secrets = new Map(); + private readonly secrets = new Map(); private isCorrupted = false; - private listeners: Array<(e: vscode.SecretStorageChangeEvent) => void> = []; + private readonly listeners: Array< + (e: vscode.SecretStorageChangeEvent) => void + > = []; onDidChange: vscode.Event = (listener) => { this.listeners.push(listener); @@ -298,3 +351,201 @@ export function createMockLogger(): Logger { error: vi.fn(), }; } + +export function createMockStream( + content: string, + options: { + chunkSize?: number; + delay?: number; + // If defined will throw an error instead of closing normally + error?: NodeJS.ErrnoException; + } = {}, +): IncomingMessage { + const { chunkSize = 8, delay = 1, error } = options; + + const buffer = Buffer.from(content); + let position = 0; + let closeCallback: ((...args: unknown[]) => void) | null = null; + let errorCallback: ((error: Error) => void) | null = null; + + return { + on: vi.fn((event: string, callback: (...args: unknown[]) => void) => { + if (event === "data") { + const sendChunk = () => { + if (position < buffer.length) { + const chunk = buffer.subarray( + position, + Math.min(position + chunkSize, buffer.length), + ); + position += chunkSize; + callback(chunk); + if (position < buffer.length) { + setTimeout(sendChunk, delay); + } else { + setImmediate(() => { + if (error && errorCallback) { + errorCallback(error); + } else if (closeCallback) { + closeCallback(); + } + }); + } + } + }; + setTimeout(sendChunk, delay); + } else if (event === "error") { + errorCallback = callback; + } else if (event === "close") { + closeCallback = callback; + } + }), + destroy: vi.fn(), + } as unknown as IncomingMessage; +} + +/** + * Mock status bar that integrates with vscode.window.createStatusBarItem. + * Use this to inspect status bar state in tests. + */ +export class MockStatusBar { + text = ""; + tooltip: string | vscode.MarkdownString = ""; + backgroundColor: vscode.ThemeColor | undefined; + color: string | vscode.ThemeColor | undefined; + command: string | vscode.Command | undefined; + accessibilityInformation: vscode.AccessibilityInformation | undefined; + name: string | undefined; + priority: number | undefined; + alignment: vscode.StatusBarAlignment = vscode.StatusBarAlignment.Left; + + readonly show = vi.fn(); + readonly hide = vi.fn(); + readonly dispose = vi.fn(); + + constructor() { + this.setupVSCodeMock(); + } + + /** + * Reset all status bar state + */ + reset(): void { + this.text = ""; + this.tooltip = ""; + this.backgroundColor = undefined; + this.color = undefined; + this.command = undefined; + this.show.mockClear(); + this.hide.mockClear(); + this.dispose.mockClear(); + } + + /** + * Setup the vscode.window.createStatusBarItem mock + */ + private setupVSCodeMock(): void { + vi.mocked(vscode.window.createStatusBarItem).mockReturnValue( + this as unknown as vscode.StatusBarItem, + ); + } +} + +/** + * Mock CoderApi for testing. Tracks method calls and allows controlling responses. + */ +export class MockCoderApi + implements + Pick< + CoderApi, + | "setHost" + | "setSessionToken" + | "setCredentials" + | "getAuthenticatedUser" + | "dispose" + > +{ + private _host: string | undefined; + private _token: string | undefined; + private _disposed = false; + private authenticatedUser: User | Error | undefined; + + readonly setHost = vi.fn((host: string | undefined) => { + this._host = host; + }); + + readonly setSessionToken = vi.fn((token: string) => { + this._token = token; + }); + + readonly setCredentials = vi.fn( + (host: string | undefined, token: string | undefined) => { + this._host = host; + this._token = token; + }, + ); + + readonly getAuthenticatedUser = vi.fn((): Promise => { + if (this.authenticatedUser instanceof Error) { + return Promise.reject(this.authenticatedUser); + } + if (!this.authenticatedUser) { + return Promise.reject(new Error("Not authenticated")); + } + return Promise.resolve(this.authenticatedUser); + }); + + readonly dispose = vi.fn(() => { + this._disposed = true; + }); + + /** + * Get current host (for assertions) + */ + get host(): string | undefined { + return this._host; + } + + /** + * Get current token (for assertions) + */ + get token(): string | undefined { + return this._token; + } + + /** + * Check if dispose was called (for assertions) + */ + get disposed(): boolean { + return this._disposed; + } + + /** + * Set the authenticated user that will be returned by getAuthenticatedUser. + * Pass an Error to make getAuthenticatedUser reject. + */ + setAuthenticatedUserResponse(user: User | Error | undefined): void { + this.authenticatedUser = user; + } +} + +/** + * Create a mock User for testing. + */ +export function createMockUser(overrides: Partial = {}): User { + return { + id: "user-123", + username: "testuser", + email: "test@example.com", + name: "Test User", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + last_seen_at: new Date().toISOString(), + status: "active", + organization_ids: [], + roles: [], + avatar_url: "", + login_type: "password", + theme_preference: "", + ...overrides, + }; +} diff --git a/test/mocks/vscode.runtime.ts b/test/mocks/vscode.runtime.ts index 2201a851..ba282f40 100644 --- a/test/mocks/vscode.runtime.ts +++ b/test/mocks/vscode.runtime.ts @@ -28,6 +28,11 @@ export const TreeItemCollapsibleState = E({ export const StatusBarAlignment = E({ Left: 1, Right: 2 }); export const ExtensionMode = E({ Production: 1, Development: 2, Test: 3 }); export const UIKind = E({ Desktop: 1, Web: 2 }); +export const InputBoxValidationSeverity = E({ + Info: 1, + Warning: 2, + Error: 3, +}); export class Uri { constructor( @@ -55,18 +60,28 @@ export class Uri { } } -// mini event -const makeEvent = () => { - const listeners = new Set<(e: T) => void>(); - const event = (listener: (e: T) => void) => { - listeners.add(listener); - return { dispose: () => listeners.delete(listener) }; +/** + * Mock EventEmitter that matches vscode.EventEmitter interface. + */ +export class EventEmitter { + private readonly listeners = new Set<(e: T) => void>(); + + event = (listener: (e: T) => void) => { + this.listeners.add(listener); + return { dispose: () => this.listeners.delete(listener) }; }; - return { event, fire: (e: T) => listeners.forEach((l) => l(e)) }; -}; -const onDidChangeConfiguration = makeEvent(); -const onDidChangeWorkspaceFolders = makeEvent(); + fire(data: T): void { + this.listeners.forEach((l) => l(data)); + } + + dispose(): void { + this.listeners.clear(); + } +} + +const onDidChangeConfiguration = new EventEmitter(); +const onDidChangeWorkspaceFolders = new EventEmitter(); export const window = { showInformationMessage: vi.fn(), @@ -83,6 +98,7 @@ export const window = { dispose: vi.fn(), clear: vi.fn(), })), + createStatusBarItem: vi.fn(), }; export const commands = { @@ -131,7 +147,9 @@ const vscode = { StatusBarAlignment, ExtensionMode, UIKind, + InputBoxValidationSeverity, Uri, + EventEmitter, window, commands, workspace, diff --git a/test/unit/api/coderApi.test.ts b/test/unit/api/coderApi.test.ts new file mode 100644 index 00000000..877ef5fc --- /dev/null +++ b/test/unit/api/coderApi.test.ts @@ -0,0 +1,655 @@ +import axios, { AxiosError, AxiosHeaders } from "axios"; +import { type ProvisionerJobLog } from "coder/site/src/api/typesGenerated"; +import { EventSource } from "eventsource"; +import { ProxyAgent } from "proxy-agent"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import Ws from "ws"; + +import { CoderApi } from "@/api/coderApi"; +import { createHttpAgent } from "@/api/utils"; +import { CertificateError } from "@/error"; +import { getHeaders } from "@/headers"; +import { type RequestConfigWithMeta } from "@/logging/types"; +import { ReconnectingWebSocket } from "@/websocket/reconnectingWebSocket"; + +import { + createMockLogger, + MockConfigurationProvider, +} from "../../mocks/testHelpers"; + +const CODER_URL = "https://coder.example.com"; +const AXIOS_TOKEN = "passed-token"; +const BUILD_ID = "build-123"; +const AGENT_ID = "agent-123"; + +vi.mock("ws"); +vi.mock("eventsource"); +vi.mock("proxy-agent"); + +vi.mock("axios", async () => { + const actual = await vi.importActual("axios"); + + const mockAdapter = vi.fn(mockAdapterImpl); + + const mockDefault = { + ...actual.default, + create: vi.fn((config) => { + const instance = actual.default.create({ + ...config, + adapter: mockAdapter, + }); + return instance; + }), + __mockAdapter: mockAdapter, + }; + + return { + ...actual, + default: mockDefault, + }; +}); + +vi.mock("@/headers", () => ({ + getHeaders: vi.fn().mockResolvedValue({}), + getHeaderCommand: vi.fn(), +})); + +vi.mock("@/api/utils", () => ({ + createHttpAgent: vi.fn(), +})); + +vi.mock("@/api/streamingFetchAdapter", () => ({ + createStreamingFetchAdapter: vi.fn(() => fetch), +})); + +describe("CoderApi", () => { + let mockLogger: ReturnType; + let mockConfig: MockConfigurationProvider; + let mockAdapter: ReturnType; + let api: CoderApi; + + const createApi = (url = CODER_URL, token = AXIOS_TOKEN) => { + return CoderApi.create(url, token, mockLogger); + }; + + beforeEach(() => { + vi.resetAllMocks(); + + const axiosMock = axios as typeof axios & { + __mockAdapter: ReturnType; + }; + mockAdapter = axiosMock.__mockAdapter; + mockAdapter.mockImplementation(mockAdapterImpl); + + vi.mocked(getHeaders).mockResolvedValue({}); + mockLogger = createMockLogger(); + mockConfig = new MockConfigurationProvider(); + mockConfig.set("coder.httpClientLogLevel", "BASIC"); + }); + + describe("HTTP Interceptors", () => { + it("adds custom headers and HTTP agent to requests", async () => { + const mockAgent = new ProxyAgent(); + vi.mocked(createHttpAgent).mockResolvedValue(mockAgent); + vi.mocked(getHeaders).mockResolvedValue({ + "X-Custom-Header": "custom-value", + "X-Another-Header": "another-value", + }); + + const api = createApi(); + const response = await api.getAxiosInstance().get("/api/v2/users/me"); + + expect(response.config.headers["X-Custom-Header"]).toBe("custom-value"); + expect(response.config.headers["X-Another-Header"]).toBe("another-value"); + expect(response.config.httpsAgent).toBe(mockAgent); + expect(response.config.httpAgent).toBe(mockAgent); + expect(response.config.proxy).toBe(false); + }); + + it("wraps certificate errors in response interceptor", async () => { + const api = createApi(); + const certError = new AxiosError( + "self signed certificate", + "DEPTH_ZERO_SELF_SIGNED_CERT", + ); + mockAdapter.mockRejectedValueOnce(certError); + + const thrownError = await api + .getAxiosInstance() + .get("/api/v2/users/me") + .catch((e) => e); + + expect(thrownError).toBeInstanceOf(CertificateError); + expect(thrownError.message).toContain("Secure connection"); + expect(thrownError.x509Err).toBeDefined(); + }); + + it("applies headers in correct precedence order (command overrides config overrides axios default)", async () => { + const api = createApi(CODER_URL, AXIOS_TOKEN); + + // Test 1: Headers from config, default token from API creation + const response = await api.getAxiosInstance().get("/api/v2/users/me", { + headers: new AxiosHeaders({ + "X-Custom-Header": "from-config", + "X-Extra": "extra-value", + }), + }); + + expect(response.config.headers["X-Custom-Header"]).toBe("from-config"); + expect(response.config.headers["X-Extra"]).toBe("extra-value"); + expect(response.config.headers["Coder-Session-Token"]).toBe(AXIOS_TOKEN); + + // Test 2: Token from request options overrides default + const responseWithToken = await api + .getAxiosInstance() + .get("/api/v2/users/me", { + headers: new AxiosHeaders({ + "Coder-Session-Token": "from-options", + }), + }); + + expect(responseWithToken.config.headers["Coder-Session-Token"]).toBe( + "from-options", + ); + + // Test 3: Header command overrides everything + vi.mocked(getHeaders).mockResolvedValue({ + "Coder-Session-Token": "from-header-command", + }); + + const responseWithHeaderCommand = await api + .getAxiosInstance() + .get("/api/v2/users/me", { + headers: new AxiosHeaders({ + "Coder-Session-Token": "from-options", + }), + }); + + expect( + responseWithHeaderCommand.config.headers["Coder-Session-Token"], + ).toBe("from-header-command"); + }); + + it("logs requests and responses", async () => { + const api = createApi(); + + await api.getWorkspaces({}); + + expect(mockLogger.trace).toHaveBeenCalledWith( + expect.stringContaining("/api/v2/workspaces"), + ); + }); + + it("calculates request and response sizes in transforms", async () => { + const api = createApi(); + const response = await api + .getAxiosInstance() + .post("/api/v2/workspaces", { name: "test" }); + + expect((response.config as RequestConfigWithMeta).rawRequestSize).toBe( + 15, + ); + // We return the same data we sent in the mock adapter + expect((response.config as RequestConfigWithMeta).rawResponseSize).toBe( + 15, + ); + }); + }); + + describe("WebSocket Creation", () => { + const wsUrl = `wss://${CODER_URL.replace("https://", "")}/api/v2/workspacebuilds/${BUILD_ID}/logs?follow=true`; + + beforeEach(() => { + api = createApi(CODER_URL, AXIOS_TOKEN); + const mockWs = createMockWebSocket(wsUrl); + setupWebSocketMock(mockWs); + }); + + it("creates WebSocket with proper headers and configuration", async () => { + const mockAgent = new ProxyAgent(); + vi.mocked(getHeaders).mockResolvedValue({ + "X-Custom-Header": "custom-value", + }); + vi.mocked(createHttpAgent).mockResolvedValue(mockAgent); + + await api.watchBuildLogsByBuildId(BUILD_ID, []); + + expect(Ws).toHaveBeenCalledWith(wsUrl, undefined, { + agent: mockAgent, + followRedirects: true, + headers: { + "X-Custom-Header": "custom-value", + "Coder-Session-Token": AXIOS_TOKEN, + }, + }); + }); + + it("applies headers in correct precedence order (command overrides config overrides axios default)", async () => { + // Test 1: Default token from API creation + await api.watchBuildLogsByBuildId(BUILD_ID, []); + + expect(Ws).toHaveBeenCalledWith(wsUrl, undefined, { + agent: undefined, + followRedirects: true, + headers: { + "Coder-Session-Token": AXIOS_TOKEN, + }, + }); + + // Test 2: Token from config options overrides default + await api.watchBuildLogsByBuildId(BUILD_ID, [], { + headers: { + "X-Config-Header": "config-value", + "Coder-Session-Token": "from-config", + }, + }); + + expect(Ws).toHaveBeenCalledWith(wsUrl, undefined, { + agent: undefined, + followRedirects: true, + headers: { + "Coder-Session-Token": "from-config", + "X-Config-Header": "config-value", + }, + }); + + // Test 3: Header command overrides everything + vi.mocked(getHeaders).mockResolvedValue({ + "Coder-Session-Token": "from-header-command", + }); + + await api.watchBuildLogsByBuildId(BUILD_ID, [], { + headers: { + "Coder-Session-Token": "from-config", + }, + }); + + expect(Ws).toHaveBeenCalledWith(wsUrl, undefined, { + agent: undefined, + followRedirects: true, + headers: { + "Coder-Session-Token": "from-header-command", + }, + }); + }); + + it("logs WebSocket connections", async () => { + await api.watchBuildLogsByBuildId(BUILD_ID, []); + + expect(mockLogger.trace).toHaveBeenCalledWith( + expect.stringContaining(BUILD_ID), + ); + }); + + it("'watchBuildLogsByBuildId' includes after parameter for existing logs", async () => { + const jobLog: ProvisionerJobLog = { + created_at: new Date().toISOString(), + id: 1, + output: "log1", + log_source: "provisioner", + log_level: "info", + stage: "stage1", + }; + const existingLogs = [ + jobLog, + { ...jobLog, id: 20 }, + { ...jobLog, id: 5 }, + ]; + + await api.watchBuildLogsByBuildId(BUILD_ID, existingLogs); + + expect(Ws).toHaveBeenCalledWith( + expect.stringContaining("after=5"), + undefined, + expect.any(Object), + ); + }); + }); + + describe("SSE Fallback", () => { + beforeEach(() => { + api = createApi(); + const mockEventSource = createMockEventSource( + `${CODER_URL}/api/v2/workspaces/123/watch`, + ); + setupEventSourceMock(mockEventSource); + }); + + it("uses WebSocket when no errors occur", async () => { + const mockWs = createMockWebSocket( + `wss://${CODER_URL.replace("https://", "")}/api/v2/workspaceagents/${AGENT_ID}/watch-metadata`, + { + on: vi.fn((event, handler) => { + if (event === "open") { + setImmediate(() => handler()); + } + return mockWs as Ws; + }), + }, + ); + setupWebSocketMock(mockWs); + + const connection = await api.watchAgentMetadata(AGENT_ID); + + expect(connection).toBeInstanceOf(ReconnectingWebSocket); + expect(EventSource).not.toHaveBeenCalled(); + }); + + it("falls back to SSE when WebSocket creation fails with 404", async () => { + // Only 404 errors trigger SSE fallback - other errors are thrown + vi.mocked(Ws).mockImplementation(() => { + throw new Error("Unexpected server response: 404"); + }); + + const connection = await api.watchAgentMetadata(AGENT_ID); + + // Returns ReconnectingWebSocket (which wraps SSE internally) + expect(connection).toBeInstanceOf(ReconnectingWebSocket); + expect(EventSource).toHaveBeenCalled(); + }); + + it("falls back to SSE on 404 error from WebSocket open", async () => { + const mockWs = createMockWebSocket( + `wss://${CODER_URL.replace("https://", "")}/api/v2/test`, + { + on: vi.fn((event: string, handler: (e: unknown) => void) => { + if (event === "error") { + setImmediate(() => { + handler({ + error: new Error("404 Not Found"), + message: "404 Not Found", + }); + }); + } + return mockWs as Ws; + }), + }, + ); + setupWebSocketMock(mockWs); + + const connection = await api.watchAgentMetadata(AGENT_ID); + + // Returns ReconnectingWebSocket (which wraps SSE internally after WS 404) + expect(connection).toBeInstanceOf(ReconnectingWebSocket); + expect(EventSource).toHaveBeenCalled(); + }); + + it("throws non-404 errors without SSE fallback", async () => { + vi.mocked(Ws).mockImplementation(() => { + throw new Error("Network error"); + }); + + await expect(api.watchAgentMetadata(AGENT_ID)).rejects.toThrow( + "Network error", + ); + expect(EventSource).not.toHaveBeenCalled(); + }); + + describe("reconnection after fallback", () => { + beforeEach(() => vi.useFakeTimers({ shouldAdvanceTime: true })); + afterEach(() => vi.useRealTimers()); + + it("reconnects after SSE fallback and retries WS on each reconnect", async () => { + let wsAttempts = 0; + const mockEventSources: MockEventSource[] = []; + + vi.mocked(Ws).mockImplementation(() => { + wsAttempts++; + const mockWs = createMockWebSocket("wss://test", { + on: vi.fn((event: string, handler: (e: unknown) => void) => { + if (event === "error") { + setImmediate(() => + handler({ error: new Error("Something 404") }), + ); + } + return mockWs as Ws; + }), + }); + return mockWs as Ws; + }); + + vi.mocked(EventSource).mockImplementation(() => { + const es = createMockEventSource(`${CODER_URL}/api/v2/test`); + mockEventSources.push(es); + return es as unknown as EventSource; + }); + + const connection = await api.watchAgentMetadata(AGENT_ID); + expect(wsAttempts).toBe(1); + expect(EventSource).toHaveBeenCalledTimes(1); + + mockEventSources[0].fireError(); + await vi.advanceTimersByTimeAsync(300); + + expect(wsAttempts).toBe(2); + expect(EventSource).toHaveBeenCalledTimes(2); + + connection.close(); + }); + }); + }); + + describe("Reconnection on Host/Token Changes", () => { + const setupAutoOpeningWebSocket = () => { + const sockets: Array> = []; + vi.mocked(Ws).mockImplementation((url: string | URL) => { + const mockWs = createMockWebSocket(String(url), { + on: vi.fn((event, handler) => { + if (event === "open") { + setImmediate(() => handler()); + } + return mockWs as Ws; + }), + }); + sockets.push(mockWs); + return mockWs as Ws; + }); + return sockets; + }; + + it("triggers reconnection when session token changes", async () => { + const sockets = setupAutoOpeningWebSocket(); + api = createApi(CODER_URL, AXIOS_TOKEN); + await api.watchAgentMetadata(AGENT_ID); + + api.setSessionToken("new-token"); + await new Promise((resolve) => setImmediate(resolve)); + + expect(sockets[0].close).toHaveBeenCalledWith( + 1000, + "Replacing connection", + ); + expect(sockets).toHaveLength(2); + }); + + it("triggers reconnection when host changes", async () => { + const sockets = setupAutoOpeningWebSocket(); + api = createApi(CODER_URL, AXIOS_TOKEN); + const wsWrap = await api.watchAgentMetadata(AGENT_ID); + expect(wsWrap.url).toContain(CODER_URL.replace("http", "ws")); + + api.setHost("https://new-coder.example.com"); + // Wait for the async reconnect to complete (factory is async) + await new Promise((resolve) => setImmediate(resolve)); + + expect(sockets[0].close).toHaveBeenCalledWith( + 1000, + "Replacing connection", + ); + expect(sockets).toHaveLength(2); + // Verify the new socket was created with the correct URL + expect(sockets[1].url).toContain("wss://new-coder.example.com"); + }); + + it("does not reconnect when token or host are unchanged", async () => { + const sockets = setupAutoOpeningWebSocket(); + api = createApi(CODER_URL, AXIOS_TOKEN); + await api.watchAgentMetadata(AGENT_ID); + + // Same values as before + api.setSessionToken(AXIOS_TOKEN); + api.setHost(CODER_URL); + + expect(sockets[0].close).not.toHaveBeenCalled(); + expect(sockets).toHaveLength(1); + }); + + it("suspends sockets when host is set to empty string (logout)", async () => { + const sockets = setupAutoOpeningWebSocket(); + api = createApi(CODER_URL, AXIOS_TOKEN); + await api.watchAgentMetadata(AGENT_ID); + + // Setting host to empty string (logout) should suspend (not permanently close) + api.setHost(""); + await new Promise((resolve) => setImmediate(resolve)); + + expect(sockets[0].close).toHaveBeenCalledWith(1000, "Host cleared"); + expect(sockets).toHaveLength(1); + }); + + it("does not reconnect when setting token after clearing host", async () => { + const sockets = setupAutoOpeningWebSocket(); + api = createApi(CODER_URL, AXIOS_TOKEN); + await api.watchAgentMetadata(AGENT_ID); + + api.setHost(""); + api.setSessionToken("new-token"); + await new Promise((resolve) => setImmediate(resolve)); + + // Should only have the initial socket - no reconnection after token change + expect(sockets).toHaveLength(1); + expect(sockets[0].close).toHaveBeenCalledWith(1000, "Host cleared"); + }); + + it("setCredentials sets both host and token together", async () => { + const sockets = setupAutoOpeningWebSocket(); + api = createApi(CODER_URL, AXIOS_TOKEN); + await api.watchAgentMetadata(AGENT_ID); + + api.setCredentials("https://new-coder.example.com", "new-token"); + await new Promise((resolve) => setImmediate(resolve)); + + // Should reconnect only once despite both values changing + expect(sockets).toHaveLength(2); + expect(sockets[1].url).toContain("wss://new-coder.example.com"); + }); + + it("setCredentials suspends when host is cleared", async () => { + const sockets = setupAutoOpeningWebSocket(); + api = createApi(CODER_URL, AXIOS_TOKEN); + await api.watchAgentMetadata(AGENT_ID); + + api.setCredentials(undefined, undefined); + await new Promise((resolve) => setImmediate(resolve)); + + expect(sockets).toHaveLength(1); + expect(sockets[0].close).toHaveBeenCalledWith(1000, "Host cleared"); + }); + }); + + describe("Error Handling", () => { + it("throws error when no base URL is set", async () => { + const api = createApi(); + api.getAxiosInstance().defaults.baseURL = undefined; + + await expect(api.watchBuildLogsByBuildId(BUILD_ID, [])).rejects.toThrow( + "No base URL set on REST client", + ); + }); + }); + + describe("getHost/getSessionToken", () => { + it("returns current host and token", () => { + const api = createApi(CODER_URL, AXIOS_TOKEN); + + expect(api.getHost()).toBe(CODER_URL); + expect(api.getSessionToken()).toBe(AXIOS_TOKEN); + }); + }); + + describe("dispose", () => { + it("disposes all tracked reconnecting sockets", async () => { + const sockets: Array> = []; + vi.mocked(Ws).mockImplementation((url: string | URL) => { + const mockWs = createMockWebSocket(String(url), { + on: vi.fn((event, handler) => { + if (event === "open") { + setImmediate(() => handler()); + } + return mockWs as Ws; + }), + }); + sockets.push(mockWs); + return mockWs as Ws; + }); + + api = createApi(CODER_URL, AXIOS_TOKEN); + await api.watchAgentMetadata(AGENT_ID); + expect(sockets).toHaveLength(1); + + api.dispose(); + + // Socket should be closed + expect(sockets[0].close).toHaveBeenCalled(); + }); + }); +}); + +const mockAdapterImpl = vi.hoisted(() => (config: Record) => { + return Promise.resolve({ + data: config.data || "{}", + status: 200, + statusText: "OK", + headers: {}, + config, + }); +}); + +function createMockWebSocket( + url: string, + overrides?: Partial, +): Partial { + return { + url, + on: vi.fn(), + off: vi.fn(), + close: vi.fn(), + ...overrides, + }; +} + +type MockEventSource = Partial & { + readyState: number; + fireOpen: () => void; + fireError: () => void; +}; + +function createMockEventSource(url: string): MockEventSource { + const handlers: Record void) | undefined> = {}; + const mock: MockEventSource = { + url, + readyState: EventSource.CONNECTING, + addEventListener: vi.fn((event: string, handler: (e: Event) => void) => { + handlers[event] = handler; + if (event === "open") { + setImmediate(() => handler(new Event("open"))); + } + }), + removeEventListener: vi.fn(), + close: vi.fn(), + fireOpen: () => handlers.open?.(new Event("open")), + fireError: () => { + mock.readyState = EventSource.CLOSED; + handlers.error?.(new Event("error")); + }, + }; + return mock; +} + +function setupWebSocketMock(ws: Partial): void { + vi.mocked(Ws).mockImplementation(() => ws as Ws); +} + +function setupEventSourceMock(es: Partial): void { + vi.mocked(EventSource).mockImplementation(() => es as EventSource); +} diff --git a/test/unit/api/streamingFetchAdapter.test.ts b/test/unit/api/streamingFetchAdapter.test.ts new file mode 100644 index 00000000..0ba8437b --- /dev/null +++ b/test/unit/api/streamingFetchAdapter.test.ts @@ -0,0 +1,220 @@ +import { type AxiosInstance, type AxiosResponse } from "axios"; +import { type ReaderLike } from "eventsource"; +import { EventEmitter } from "node:events"; +import { type IncomingMessage } from "node:http"; +import { describe, it, expect, vi } from "vitest"; + +import { createStreamingFetchAdapter } from "@/api/streamingFetchAdapter"; + +const TEST_URL = "https://example.com/api"; + +describe("createStreamingFetchAdapter", () => { + describe("Request Handling", () => { + it("passes URL, signal, and responseType to axios", async () => { + const mockAxios = createAxiosMock(); + const mockStream = createMockStream(); + setupAxiosResponse(mockAxios, 200, {}, mockStream); + + const adapter = createStreamingFetchAdapter(mockAxios); + const signal = new AbortController().signal; + + await adapter(TEST_URL, { signal }); + + expect(mockAxios.request).toHaveBeenCalledWith({ + url: TEST_URL, + signal, // correctly passes signal + headers: {}, + responseType: "stream", + validateStatus: expect.any(Function), + }); + }); + + it("applies headers in correct precedence order (config overrides init)", async () => { + const mockAxios = createAxiosMock(); + const mockStream = createMockStream(); + setupAxiosResponse(mockAxios, 200, {}, mockStream); + + // Test 1: No config headers, only init headers + const adapter1 = createStreamingFetchAdapter(mockAxios); + await adapter1(TEST_URL, { + headers: { "X-Init": "init-value" }, + }); + + expect(mockAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { "X-Init": "init-value" }, + }), + ); + + // Test 2: Config headers merge with init headers + const adapter2 = createStreamingFetchAdapter(mockAxios, { + "X-Config": "config-value", + }); + await adapter2(TEST_URL, { + headers: { "X-Init": "init-value" }, + }); + + expect(mockAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { + "X-Init": "init-value", + "X-Config": "config-value", + }, + }), + ); + + // Test 3: Config headers override init headers + const adapter3 = createStreamingFetchAdapter(mockAxios, { + "X-Header": "config-value", + }); + await adapter3(TEST_URL, { + headers: { "X-Header": "init-value" }, + }); + + expect(mockAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + headers: { "X-Header": "config-value" }, + }), + ); + }); + }); + + describe("Response Properties", () => { + it("returns response with correct properties", async () => { + const mockAxios = createAxiosMock(); + const mockStream = createMockStream(); + setupAxiosResponse( + mockAxios, + 200, + { "content-type": "text/event-stream" }, + mockStream, + ); + + const adapter = createStreamingFetchAdapter(mockAxios); + const response = await adapter(TEST_URL); + + expect(response.url).toBe(TEST_URL); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toBe("text/event-stream"); + // Headers are lowercased when we retrieve them + expect(response.headers.get("CoNtEnT-TyPe")).toBe("text/event-stream"); + expect(response.body?.getReader).toBeDefined(); + }); + + it("detects redirected requests", async () => { + const mockAxios = createAxiosMock(); + const mockStream = createMockStream(); + const mockResponse = { + status: 200, + headers: {}, + data: mockStream, + request: { + res: { + responseUrl: "https://redirect.com/api", + }, + }, + } as AxiosResponse; + vi.mocked(mockAxios.request).mockResolvedValue(mockResponse); + + const adapter = createStreamingFetchAdapter(mockAxios); + const response = await adapter(TEST_URL); + + expect(response.redirected).toBe(true); + }); + }); + + describe("Stream Handling", () => { + it("enqueues data chunks from stream", async () => { + const { mockStream, reader } = await setupReaderTest(); + + const chunk1 = Buffer.from("data1"); + const chunk2 = Buffer.from("data2"); + mockStream.emit("data", chunk1); + mockStream.emit("data", chunk2); + mockStream.emit("end"); + + const result1 = await reader.read(); + expect(result1.value).toEqual(chunk1); + expect(result1.done).toBe(false); + + const result2 = await reader.read(); + expect(result2.value).toEqual(chunk2); + expect(result2.done).toBe(false); + + const result3 = await reader.read(); + // Closed after end + expect(result3.done).toBe(true); + }); + + it("propagates stream errors", async () => { + const { mockStream, reader } = await setupReaderTest(); + + const error = new Error("Stream error"); + mockStream.emit("error", error); + + await expect(reader.read()).rejects.toThrow("Stream error"); + }); + + it("handles errors after stream is closed", async () => { + const { mockStream, reader } = await setupReaderTest(); + + mockStream.emit("end"); + await reader.read(); + + // Emit events after stream is closed - should not throw + expect(() => mockStream.emit("data", Buffer.from("late"))).not.toThrow(); + expect(() => mockStream.emit("end")).not.toThrow(); + }); + + it("destroys stream on cancel", async () => { + const { mockStream, reader } = await setupReaderTest(); + + await reader.cancel(); + + expect(mockStream.destroy).toHaveBeenCalled(); + }); + }); +}); + +function createAxiosMock(): AxiosInstance { + return { + request: vi.fn(), + } as unknown as AxiosInstance; +} + +function createMockStream(): IncomingMessage { + const stream = new EventEmitter() as IncomingMessage; + stream.destroy = vi.fn(); + return stream; +} + +function setupAxiosResponse( + axios: AxiosInstance, + status: number, + headers: Record, + stream: IncomingMessage, +): void { + vi.mocked(axios.request).mockResolvedValue({ + status, + headers, + data: stream, + }); +} + +async function setupReaderTest(): Promise<{ + mockStream: IncomingMessage; + reader: ReaderLike | ReadableStreamDefaultReader>; +}> { + const mockAxios = createAxiosMock(); + const mockStream = createMockStream(); + setupAxiosResponse(mockAxios, 200, {}, mockStream); + + const adapter = createStreamingFetchAdapter(mockAxios); + const response = await adapter(TEST_URL); + const reader = response.body?.getReader(); + if (reader === undefined) { + throw new Error("Reader is undefined"); + } + + return { mockStream, reader }; +} diff --git a/test/unit/cliConfig.test.ts b/test/unit/cliConfig.test.ts new file mode 100644 index 00000000..e35fa687 --- /dev/null +++ b/test/unit/cliConfig.test.ts @@ -0,0 +1,124 @@ +import { it, expect, describe } from "vitest"; + +import { getGlobalFlags, getGlobalFlagsRaw, getSshFlags } from "@/cliConfig"; + +import { MockConfigurationProvider } from "../mocks/testHelpers"; +import { isWindows } from "../utils/platform"; + +describe("cliConfig", () => { + describe("getGlobalFlags", () => { + it("should return global-config and header args when no global flags configured", () => { + const config = new MockConfigurationProvider(); + + expect(getGlobalFlags(config, "/config/dir")).toStrictEqual([ + "--global-config", + '"/config/dir"', + ]); + }); + + it("should return global flags from config with global-config appended", () => { + const config = new MockConfigurationProvider(); + config.set("coder.globalFlags", [ + "--verbose", + "--disable-direct-connections", + ]); + + expect(getGlobalFlags(config, "/config/dir")).toStrictEqual([ + "--verbose", + "--disable-direct-connections", + "--global-config", + '"/config/dir"', + ]); + }); + + it("should not filter duplicate global-config flags, last takes precedence", () => { + const config = new MockConfigurationProvider(); + config.set("coder.globalFlags", [ + "-v", + "--global-config /path/to/ignored", + "--disable-direct-connections", + ]); + + expect(getGlobalFlags(config, "/config/dir")).toStrictEqual([ + "-v", + "--global-config /path/to/ignored", + "--disable-direct-connections", + "--global-config", + '"/config/dir"', + ]); + }); + + it("should not filter header-command flags, header args appended at end", () => { + const headerCommand = "echo test"; + const config = new MockConfigurationProvider(); + config.set("coder.headerCommand", headerCommand); + config.set("coder.globalFlags", [ + "-v", + "--header-command custom", + "--no-feature-warning", + ]); + + const result = getGlobalFlags(config, "/config/dir"); + expect(result).toStrictEqual([ + "-v", + "--header-command custom", // ignored by CLI + "--no-feature-warning", + "--global-config", + '"/config/dir"', + "--header-command", + quoteCommand(headerCommand), + ]); + }); + }); + + describe("getGlobalFlagsRaw", () => { + it("returns empty array when no global flags configured", () => { + const config = new MockConfigurationProvider(); + + expect(getGlobalFlagsRaw(config)).toStrictEqual([]); + }); + + it("returns global flags from config", () => { + const config = new MockConfigurationProvider(); + config.set("coder.globalFlags", [ + "--verbose", + "--disable-direct-connections", + ]); + + expect(getGlobalFlagsRaw(config)).toStrictEqual([ + "--verbose", + "--disable-direct-connections", + ]); + }); + }); + + describe("getSshFlags", () => { + it("returns default flags when no SSH flags configured", () => { + const config = new MockConfigurationProvider(); + + expect(getSshFlags(config)).toStrictEqual(["--disable-autostart"]); + }); + + it("returns SSH flags from config", () => { + const config = new MockConfigurationProvider(); + config.set("coder.sshFlags", [ + "--disable-autostart", + "--wait=yes", + "--ssh-host-prefix=custom", + ]); + + expect(getSshFlags(config)).toStrictEqual([ + "--disable-autostart", + "--wait=yes", + // No filtering and returned as-is (even though it'll be overridden later) + "--ssh-host-prefix=custom", + ]); + }); + }); +}); + +function quoteCommand(value: string): string { + // Used to escape environment variables in commands. See `getHeaderArgs` in src/headers.ts + const quote = isWindows() ? '"' : "'"; + return `${quote}${value}${quote}`; +} diff --git a/test/unit/core/binaryLock.test.ts b/test/unit/core/binaryLock.test.ts new file mode 100644 index 00000000..bab76e1a --- /dev/null +++ b/test/unit/core/binaryLock.test.ts @@ -0,0 +1,160 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; + +import { BinaryLock } from "@/core/binaryLock"; +import * as downloadProgress from "@/core/downloadProgress"; + +import { + createMockLogger, + MockProgressReporter, +} from "../../mocks/testHelpers"; + +vi.mock("vscode"); + +// Mock proper-lockfile +vi.mock("proper-lockfile", () => ({ + lock: vi.fn(), +})); + +// Mock downloadProgress module +vi.mock("@/core/downloadProgress", () => ({ + STALE_TIMEOUT_MS: 15000, + readProgress: vi.fn(), + writeProgress: vi.fn(), + clearProgress: vi.fn(), +})); + +describe("BinaryLock", () => { + let binaryLock: BinaryLock; + let mockLogger: ReturnType; + let mockProgress: MockProgressReporter; + let mockRelease: () => Promise; + + const createLockError = () => { + const error = new Error("Lock is busy") as NodeJS.ErrnoException; + error.code = "ELOCKED"; + return error; + }; + + beforeEach(() => { + mockLogger = createMockLogger(); + mockProgress = new MockProgressReporter(); + mockRelease = vi.fn().mockResolvedValue(undefined); + + binaryLock = new BinaryLock(vscode, mockLogger); + }); + + describe("acquireLockOrWait", () => { + it("should acquire lock immediately when available", async () => { + const { lock } = await import("proper-lockfile"); + vi.mocked(lock).mockResolvedValue(mockRelease); + + const result = await binaryLock.acquireLockOrWait( + "/path/to/binary", + "/path/to/progress.log", + ); + + expect(result.release).toBe(mockRelease); + expect(result.waited).toBe(false); + expect(lock).toHaveBeenCalledWith("/path/to/binary", { + stale: 15000, + retries: 0, + realpath: false, + }); + }); + + it("should wait and monitor progress when lock is held", async () => { + const { lock } = await import("proper-lockfile"); + + vi.mocked(lock) + .mockRejectedValueOnce(createLockError()) + .mockResolvedValueOnce(mockRelease); + + vi.mocked(downloadProgress.readProgress).mockResolvedValue({ + bytesDownloaded: 1024, + totalBytes: 2048, + status: "downloading", + }); + + const result = await binaryLock.acquireLockOrWait( + "/path/to/binary", + "/path/to/progress.log", + ); + + expect(result.release).toBe(mockRelease); + expect(result.waited).toBe(true); + + const reports = mockProgress.getProgressReports(); + expect(reports.length).toBeGreaterThan(0); + expect(reports[0].message).toBe("1.02 kB / 2.05 kB"); + }); + + it.each([ + { + name: "downloading with known size", + progress: { + bytesDownloaded: 5000000, + totalBytes: 10000000, + status: "downloading" as const, + }, + expectedMessage: "5 MB / 10 MB", + }, + { + name: "downloading with unknown size", + progress: { + bytesDownloaded: 1024, + totalBytes: null, + status: "downloading" as const, + }, + expectedMessage: "1.02 kB / unknown", + }, + { + name: "verifying signature", + progress: { + bytesDownloaded: 0, + totalBytes: null, + status: "verifying" as const, + }, + expectedMessage: "Verifying signature...", + }, + ])( + "should report progress while waiting: $name", + async ({ progress, expectedMessage }) => { + const { lock } = await import("proper-lockfile"); + + let callCount = 0; + vi.mocked(lock).mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.reject(createLockError()); + } + return Promise.resolve(mockRelease); + }); + + vi.mocked(downloadProgress.readProgress).mockResolvedValue(progress); + + await binaryLock.acquireLockOrWait( + "/path/to/binary", + "/path/to/progress.log", + ); + + const reports = mockProgress.getProgressReports(); + expect(reports.length).toBeGreaterThan(0); + expect(reports[0].message).toContain(expectedMessage); + }, + ); + + it("should re-throw non-ELOCKED errors", async () => { + const { lock } = await import("proper-lockfile"); + const testError = new Error("Filesystem error"); + vi.mocked(lock).mockRejectedValue(testError); + + await expect( + binaryLock.acquireLockOrWait( + "/path/to/binary", + "/path/to/progress.log", + ), + ).rejects.toThrow("Filesystem error"); + }); + }); +}); diff --git a/test/unit/core/cliManager.concurrent.test.ts b/test/unit/core/cliManager.concurrent.test.ts new file mode 100644 index 00000000..457d8a31 --- /dev/null +++ b/test/unit/core/cliManager.concurrent.test.ts @@ -0,0 +1,191 @@ +/** + * This file tests that multiple concurrent calls to fetchBinary properly coordinate + * using proper-lockfile to prevent race conditions. Unlike the main cliManager.test.ts, + * this test uses the real filesystem and doesn't mock the locking library to verify + * actual file-level coordination. + */ +import { type AxiosInstance } from "axios"; +import { type Api } from "coder/site/src/api/api"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; + +import { CliManager } from "@/core/cliManager"; +import * as cliUtils from "@/core/cliUtils"; +import { PathResolver } from "@/core/pathResolver"; +import * as pgp from "@/pgp"; + +import { + createMockLogger, + createMockStream, + MockConfigurationProvider, + MockProgressReporter, +} from "../../mocks/testHelpers"; + +vi.mock("@/pgp"); +vi.mock("@/core/cliUtils", async () => { + const actual = await vi.importActual("@/core/cliUtils"); + return { + ...actual, + goos: vi.fn(), + goarch: vi.fn(), + name: vi.fn(), + version: vi.fn(), + }; +}); + +function setupCliUtilsMocks(version: string) { + vi.mocked(cliUtils.goos).mockReturnValue("linux"); + vi.mocked(cliUtils.goarch).mockReturnValue("amd64"); + vi.mocked(cliUtils.name).mockReturnValue("coder-linux-amd64"); + vi.mocked(cliUtils.version).mockResolvedValue(version); + vi.mocked(pgp.readPublicKeys).mockResolvedValue([]); +} + +function createMockApi( + version: string, + options: { + chunkSize?: number; + delay?: number; + error?: NodeJS.ErrnoException; + } = {}, +): Api { + const mockAxios = { + get: vi.fn().mockImplementation(() => + Promise.resolve({ + status: 200, + headers: { "content-length": "17" }, + data: createMockStream(`mock-binary-v${version}`, options), + }), + ), + defaults: { baseURL: "https://test.coder.com" }, + } as unknown as AxiosInstance; + + return { + getAxiosInstance: () => mockAxios, + getBuildInfo: vi.fn().mockResolvedValue({ version }), + } as unknown as Api; +} + +function setupManager(testDir: string): CliManager { + const _mockProgress = new MockProgressReporter(); + const mockConfig = new MockConfigurationProvider(); + mockConfig.set("coder.disableSignatureVerification", true); + + return new CliManager( + vscode, + createMockLogger(), + new PathResolver(testDir, "/code/log"), + ); +} + +describe("CliManager Concurrent Downloads", () => { + let testDir: string; + + beforeEach(async () => { + testDir = await fs.mkdtemp( + path.join(os.tmpdir(), "climanager-concurrent-"), + ); + }); + + afterEach(async () => { + await fs.rm(testDir, { recursive: true, force: true }); + }); + + it("handles multiple concurrent downloads without race conditions", async () => { + const manager = setupManager(testDir); + setupCliUtilsMocks("1.2.3"); + const mockApi = createMockApi("1.2.3"); + + const label = "test.coder.com"; + const binaryPath = path.join(testDir, label, "bin", "coder-linux-amd64"); + + const downloads = await Promise.all([ + manager.fetchBinary(mockApi, label), + manager.fetchBinary(mockApi, label), + manager.fetchBinary(mockApi, label), + ]); + + expect(downloads).toHaveLength(3); + for (const result of downloads) { + expect(result).toBe(binaryPath); + } + + // Verify binary exists and lock/progress files are cleaned up + await expect(fs.access(binaryPath)).resolves.toBeUndefined(); + await expect(fs.access(binaryPath + ".lock")).rejects.toThrow(); + await expect(fs.access(binaryPath + ".progress.log")).rejects.toThrow(); + }); + + it("redownloads when version mismatch is detected concurrently", async () => { + const manager = setupManager(testDir); + setupCliUtilsMocks("1.2.3"); + vi.mocked(cliUtils.version).mockImplementation(async (binPath) => { + const fileContent = await fs.readFile(binPath, { + encoding: "utf-8", + }); + return fileContent.includes("1.2.3") ? "1.2.3" : "2.0.0"; + }); + + // First call downloads 1.2.3, next two expect 2.0.0 (server upgraded) + const mockApi1 = createMockApi("1.2.3", { delay: 100 }); + const mockApi2 = createMockApi("2.0.0"); + + const label = "test.coder.com"; + const binaryPath = path.join(testDir, label, "bin", "coder-linux-amd64"); + + // Start first call and give it time to acquire the lock + const firstDownload = manager.fetchBinary(mockApi1, label); + // Wait for the lock to be acquired before starting concurrent calls + await new Promise((resolve) => setTimeout(resolve, 50)); + + const downloads = await Promise.all([ + firstDownload, + manager.fetchBinary(mockApi2, label), + manager.fetchBinary(mockApi2, label), + ]); + + expect(downloads).toHaveLength(3); + for (const result of downloads) { + expect(result).toBe(binaryPath); + } + + // Binary should be updated to 2.0.0, lock/progress files cleaned up + await expect(fs.access(binaryPath)).resolves.toBeUndefined(); + const finalContent = await fs.readFile(binaryPath, "utf8"); + expect(finalContent).toContain("v2.0.0"); + await expect(fs.access(binaryPath + ".lock")).rejects.toThrow(); + await expect(fs.access(binaryPath + ".progress.log")).rejects.toThrow(); + }); + + it.each([ + { + name: "disk storage insufficient", + code: "ENOSPC", + message: "no space left on device", + }, + { + name: "connection timeout", + code: "ETIMEDOUT", + message: "connection timed out", + }, + ])("handles $name error during download", async ({ code, message }) => { + const manager = setupManager(testDir); + setupCliUtilsMocks("1.2.3"); + + const error = new Error(`${code}: ${message}`); + (error as NodeJS.ErrnoException).code = code; + const mockApi = createMockApi("1.2.3", { error }); + + const label = "test.coder.com"; + const binaryPath = path.join(testDir, label, "bin", "coder-linux-amd64"); + + await expect(manager.fetchBinary(mockApi, label)).rejects.toThrow( + `Unable to download binary: ${code}: ${message}`, + ); + + await expect(fs.access(binaryPath + ".lock")).rejects.toThrow(); + }); +}); diff --git a/test/unit/core/cliManager.test.ts b/test/unit/core/cliManager.test.ts index 3e1dfb0d..6cdcdb07 100644 --- a/test/unit/core/cliManager.test.ts +++ b/test/unit/core/cliManager.test.ts @@ -1,11 +1,11 @@ import globalAxios, { type AxiosInstance } from "axios"; import { type Api } from "coder/site/src/api/api"; -import EventEmitter from "events"; -import * as fs from "fs"; -import { type IncomingMessage } from "http"; import { fs as memfs, vol } from "memfs"; -import * as os from "os"; -import * as path from "path"; +import EventEmitter from "node:events"; +import * as fs from "node:fs"; +import { type IncomingMessage } from "node:http"; +import * as os from "node:os"; +import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as vscode from "vscode"; @@ -16,14 +16,15 @@ import * as pgp from "@/pgp"; import { createMockLogger, + createMockStream, MockConfigurationProvider, MockProgressReporter, MockUserInteraction, } from "../../mocks/testHelpers"; +import { expectPathsEqual } from "../../utils/platform"; vi.mock("os"); vi.mock("axios"); -vi.mock("@/pgp"); vi.mock("fs", async () => { const memfs: { fs: typeof fs } = await vi.importActual("memfs"); @@ -41,6 +42,14 @@ vi.mock("fs/promises", async () => { }; }); +// Mock lockfile to bypass file locking in tests +vi.mock("proper-lockfile", () => ({ + lock: () => Promise.resolve(() => Promise.resolve()), + check: () => Promise.resolve(false), +})); + +vi.mock("@/pgp"); + vi.mock("@/core/cliUtils", async () => { const actual = await vi.importActual("@/core/cliUtils"); @@ -156,52 +165,6 @@ describe("CliManager", () => { }); }); - describe("Read CLI Configuration", () => { - it("should read and trim stored configuration", async () => { - // Create directories and write files with whitespace - vol.mkdirSync("/path/base/deployment", { recursive: true }); - memfs.writeFileSync( - "/path/base/deployment/url", - " https://coder.example.com \n", - ); - memfs.writeFileSync( - "/path/base/deployment/session", - "\t test-token \r\n", - ); - - const result = await manager.readConfig("deployment"); - - expect(result).toEqual({ - url: "https://coder.example.com", - token: "test-token", - }); - }); - - it("should return empty strings for missing files", async () => { - const result = await manager.readConfig("deployment"); - - expect(result).toEqual({ - url: "", - token: "", - }); - }); - - it("should handle partial configuration", async () => { - vol.mkdirSync("/path/base/deployment", { recursive: true }); - memfs.writeFileSync( - "/path/base/deployment/url", - "https://coder.example.com", - ); - - const result = await manager.readConfig("deployment"); - - expect(result).toEqual({ - url: "https://coder.example.com", - token: "", - }); - }); - }); - describe("Binary Version Validation", () => { it("rejects invalid server versions", async () => { mockApi.getBuildInfo = vi.fn().mockResolvedValue({ version: "invalid" }); @@ -213,7 +176,7 @@ describe("CliManager", () => { it("accepts valid semver versions", async () => { withExistingBinary(TEST_VERSION); const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); + expectPathsEqual(result, BINARY_PATH); }); }); @@ -226,7 +189,7 @@ describe("CliManager", () => { it("reuses matching binary without downloading", async () => { withExistingBinary(TEST_VERSION); const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); + expectPathsEqual(result, BINARY_PATH); expect(mockAxios.get).not.toHaveBeenCalled(); // Verify binary still exists expect(memfs.existsSync(BINARY_PATH)).toBe(true); @@ -236,7 +199,7 @@ describe("CliManager", () => { withExistingBinary("1.0.0"); withSuccessfulDownload(); const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); + expectPathsEqual(result, BINARY_PATH); expect(mockAxios.get).toHaveBeenCalled(); // Verify new binary exists expect(memfs.existsSync(BINARY_PATH)).toBe(true); @@ -249,7 +212,7 @@ describe("CliManager", () => { mockConfig.set("coder.enableDownloads", false); withExistingBinary("1.0.0"); const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); + expectPathsEqual(result, BINARY_PATH); expect(mockAxios.get).not.toHaveBeenCalled(); // Should still have the old version expect(memfs.existsSync(BINARY_PATH)).toBe(true); @@ -262,7 +225,7 @@ describe("CliManager", () => { withCorruptedBinary(); withSuccessfulDownload(); const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); + expectPathsEqual(result, BINARY_PATH); expect(mockAxios.get).toHaveBeenCalled(); expect(memfs.existsSync(BINARY_PATH)).toBe(true); expect(memfs.readFileSync(BINARY_PATH).toString()).toBe( @@ -276,7 +239,7 @@ describe("CliManager", () => { withSuccessfulDownload(); const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); + expectPathsEqual(result, BINARY_PATH); expect(mockAxios.get).toHaveBeenCalled(); // Verify directory was created and binary exists @@ -392,7 +355,7 @@ describe("CliManager", () => { withExistingBinary("1.0.0"); withHttpResponse(304); const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); + expectPathsEqual(result, BINARY_PATH); // No change expect(memfs.readFileSync(BINARY_PATH).toString()).toBe( mockBinaryContent("1.0.0"), @@ -460,7 +423,7 @@ describe("CliManager", () => { it("handles missing content-length", async () => { withSuccessfulDownload({ headers: {} }); const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); + expectPathsEqual(result, BINARY_PATH); expect(memfs.existsSync(BINARY_PATH)).toBe(true); }); }); @@ -494,7 +457,7 @@ describe("CliManager", () => { withSuccessfulDownload(); withSignatureResponses([200]); const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); + expectPathsEqual(result, BINARY_PATH); expect(pgp.verifySignature).toHaveBeenCalled(); const sigFile = expectFileInDir(BINARY_DIR, ".asc"); expect(sigFile).toBeDefined(); @@ -505,7 +468,7 @@ describe("CliManager", () => { withSignatureResponses([404, 200]); mockUI.setResponse("Signature not found", "Download signature"); const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); + expectPathsEqual(result, BINARY_PATH); expect(mockAxios.get).toHaveBeenCalledTimes(3); const sigFile = expectFileInDir(BINARY_DIR, ".asc"); expect(sigFile).toBeDefined(); @@ -519,7 +482,7 @@ describe("CliManager", () => { ); mockUI.setResponse("Signature does not match", "Run anyway"); const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); + expectPathsEqual(result, BINARY_PATH); expect(memfs.existsSync(BINARY_PATH)).toBe(true); }); @@ -539,13 +502,14 @@ describe("CliManager", () => { mockConfig.set("coder.disableSignatureVerification", true); withSuccessfulDownload(); const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); + expectPathsEqual(result, BINARY_PATH); expect(pgp.verifySignature).not.toHaveBeenCalled(); const files = readdir(BINARY_DIR); expect(files.find((file) => file.includes(".asc"))).toBeUndefined(); }); - it.each([ + type SignatureErrorTestCase = [status: number, message: string]; + it.each([ [404, "Signature not found"], [500, "Failed to download signature"], ])("allows skipping verification on %i", async (status, message) => { @@ -553,11 +517,11 @@ describe("CliManager", () => { withHttpResponse(status); mockUI.setResponse(message, "Run without verification"); const result = await manager.fetchBinary(mockApi, "test"); - expect(result).toBe(BINARY_PATH); + expectPathsEqual(result, BINARY_PATH); expect(pgp.verifySignature).not.toHaveBeenCalled(); }); - it.each([ + it.each([ [404, "Signature not found"], [500, "Failed to download signature"], ])( @@ -615,13 +579,16 @@ describe("CliManager", () => { withSuccessfulDownload(); const result = await manager.fetchBinary(mockApi, "test label"); - expect(result).toBe(`${pathWithSpaces}/test label/bin/${BINARY_NAME}`); + expectPathsEqual( + result, + `${pathWithSpaces}/test label/bin/${BINARY_NAME}`, + ); }); it("handles empty deployment label", async () => { withExistingBinary(TEST_VERSION, "/path/base/bin"); const result = await manager.fetchBinary(mockApi, ""); - expect(result).toBe(path.join(BASE_PATH, "bin", BINARY_NAME)); + expectPathsEqual(result, path.join(BASE_PATH, "bin", BINARY_NAME)); }); }); @@ -671,11 +638,11 @@ describe("CliManager", () => { } function withSignatureResponses(statuses: number[]): void { - statuses.forEach((status) => { + for (const status of statuses) { const data = status === 200 ? createMockStream("mock-signature-content") : undefined; withHttpResponse(status, {}, data); - }); + } } function withHttpResponse( @@ -725,70 +692,26 @@ describe("CliManager", () => { withHttpResponse(200, { "content-length": "1024" }, errorStream); } } - - function createMockStream( - content: string, - options: { chunkSize?: number; delay?: number } = {}, - ): IncomingMessage { - const { chunkSize = 8, delay = 1 } = options; - - const buffer = Buffer.from(content); - let position = 0; - let closeCallback: ((...args: unknown[]) => void) | null = null; - - return { - on: vi.fn((event: string, callback: (...args: unknown[]) => void) => { - if (event === "data") { - // Send data in chunks - const sendChunk = () => { - if (position < buffer.length) { - const chunk = buffer.subarray( - position, - Math.min(position + chunkSize, buffer.length), - ); - position += chunkSize; - callback(chunk); - if (position < buffer.length) { - setTimeout(sendChunk, delay); - } else { - // All chunks sent - use setImmediate to ensure close happens - // after all synchronous operations and I/O callbacks complete - setImmediate(() => { - if (closeCallback) { - closeCallback(); - } - }); - } - } - }; - setTimeout(sendChunk, delay); - } else if (event === "close") { - closeCallback = callback; - } - }), - destroy: vi.fn(), - } as unknown as IncomingMessage; - } - - function createVerificationError(msg: string): pgp.VerificationError { - const error = new pgp.VerificationError( - pgp.VerificationErrorCode.Invalid, - msg, - ); - vi.mocked(error.summary).mockReturnValue("Signature does not match"); - return error; - } - - function mockBinaryContent(version: string): string { - return `mock-binary-v${version}`; - } - - function expectFileInDir(dir: string, pattern: string): string | undefined { - const files = readdir(dir); - return files.find((f) => f.includes(pattern)); - } - - function readdir(dir: string): string[] { - return memfs.readdirSync(dir) as string[]; - } }); + +function createVerificationError(msg: string): pgp.VerificationError { + const error = new pgp.VerificationError( + pgp.VerificationErrorCode.Invalid, + msg, + ); + vi.mocked(error.summary).mockReturnValue("Signature does not match"); + return error; +} + +function mockBinaryContent(version: string): string { + return `mock-binary-v${version}`; +} + +function expectFileInDir(dir: string, pattern: string): string | undefined { + const files = readdir(dir); + return files.find((f) => f.includes(pattern)); +} + +function readdir(dir: string): string[] { + return memfs.readdirSync(dir) as string[]; +} diff --git a/test/unit/core/cliUtils.test.ts b/test/unit/core/cliUtils.test.ts index d63ddd87..dd1c56f0 100644 --- a/test/unit/core/cliUtils.test.ts +++ b/test/unit/core/cliUtils.test.ts @@ -6,6 +6,7 @@ import { beforeAll, describe, expect, it } from "vitest"; import * as cliUtils from "@/core/cliUtils"; import { getFixturePath } from "../../utils/fixtures"; +import { isWindows } from "../../utils/platform"; describe("CliUtils", () => { const tmp = path.join(os.tmpdir(), "vscode-coder-tests"); @@ -28,12 +29,14 @@ describe("CliUtils", () => { expect((await cliUtils.stat(binPath))?.size).toBe(4); }); - // TODO: CI only runs on Linux but we should run it on Windows too. - it("version", async () => { + it.skipIf(isWindows())("version", async () => { const binPath = path.join(tmp, "version"); await expect(cliUtils.version(binPath)).rejects.toThrow("ENOENT"); - const binTmpl = await fs.readFile(getFixturePath("bin.bash"), "utf8"); + const binTmpl = await fs.readFile( + getFixturePath("scripts", "bin.bash"), + "utf8", + ); await fs.writeFile(binPath, binTmpl.replace("$ECHO", "hello")); await expect(cliUtils.version(binPath)).rejects.toThrow("EACCES"); @@ -56,7 +59,10 @@ describe("CliUtils", () => { ); expect(await cliUtils.version(binPath)).toBe("v0.0.0"); - const oldTmpl = await fs.readFile(getFixturePath("bin.old.bash"), "utf8"); + const oldTmpl = await fs.readFile( + getFixturePath("scripts", "bin.old.bash"), + "utf8", + ); const old = (stderr: string, stdout: string): string => { return oldTmpl.replace("$STDERR", stderr).replace("$STDOUT", stdout); }; diff --git a/test/unit/core/downloadProgress.test.ts b/test/unit/core/downloadProgress.test.ts new file mode 100644 index 00000000..b39e82b6 --- /dev/null +++ b/test/unit/core/downloadProgress.test.ts @@ -0,0 +1,102 @@ +import { promises as fs } from "node:fs"; +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"; + +describe("downloadProgress", () => { + let testDir: string; + let testLogPath: string; + + beforeEach(async () => { + testDir = await fs.mkdtemp( + path.join(os.tmpdir(), "download-progress-test-"), + ); + testLogPath = path.join(testDir, "test.progress.log"); + }); + + afterEach(async () => { + try { + await fs.rm(testDir, { recursive: true, force: true }); + } catch { + // Ignore + } + }); + + describe("writeProgress", () => { + it("should write and overwrite progress", async () => { + await downloadProgress.writeProgress(testLogPath, { + bytesDownloaded: 1000, + totalBytes: 10000, + status: "downloading", + }); + const first = JSON.parse( + (await fs.readFile(testLogPath, "utf-8")).trim(), + ); + expect(first.bytesDownloaded).toBe(1000); + + await downloadProgress.writeProgress(testLogPath, { + bytesDownloaded: 2000, + totalBytes: null, + status: "verifying", + }); + const second = JSON.parse( + (await fs.readFile(testLogPath, "utf-8")).trim(), + ); + expect(second.bytesDownloaded).toBe(2000); + expect(second.totalBytes).toBeNull(); + }); + + it("should create nested directories", async () => { + const nestedPath = path.join(testDir, "nested", "dir", "progress.log"); + await downloadProgress.writeProgress(nestedPath, { + bytesDownloaded: 500, + totalBytes: 5000, + status: "downloading", + }); + expect(await fs.readFile(nestedPath, "utf-8")).toBeTruthy(); + }); + }); + + describe("readProgress", () => { + it("should read progress from log file", async () => { + const expectedProgress = { + bytesDownloaded: 1500, + totalBytes: 10000, + status: "downloading", + }; + + await fs.writeFile(testLogPath, JSON.stringify(expectedProgress) + "\n"); + const progress = await downloadProgress.readProgress(testLogPath); + expect(progress).toEqual(expectedProgress); + }); + + it("should return null for missing, empty, or invalid files", async () => { + expect( + await downloadProgress.readProgress(path.join(testDir, "nonexistent")), + ).toBeNull(); + + await fs.writeFile(testLogPath, ""); + expect(await downloadProgress.readProgress(testLogPath)).toBeNull(); + + await fs.writeFile(testLogPath, "invalid json"); + expect(await downloadProgress.readProgress(testLogPath)).toBeNull(); + + await fs.writeFile(testLogPath, JSON.stringify({ incomplete: true })); + expect(await downloadProgress.readProgress(testLogPath)).toBeNull(); + }); + }); + + describe("clearProgress", () => { + it("should remove existing file or ignore missing file", async () => { + await fs.writeFile(testLogPath, "test"); + await downloadProgress.clearProgress(testLogPath); + await expect(fs.readFile(testLogPath)).rejects.toThrow(); + + await expect( + downloadProgress.clearProgress(path.join(testDir, "nonexistent")), + ).resolves.toBeUndefined(); + }); + }); +}); diff --git a/test/unit/core/mementoManager.test.ts b/test/unit/core/mementoManager.test.ts index 54289a65..791f7602 100644 --- a/test/unit/core/mementoManager.test.ts +++ b/test/unit/core/mementoManager.test.ts @@ -13,28 +13,22 @@ describe("MementoManager", () => { mementoManager = new MementoManager(memento); }); - describe("setUrl", () => { - it("should store URL and add to history", async () => { - await mementoManager.setUrl("https://coder.example.com"); + describe("addToUrlHistory", () => { + it("should add URL to history", async () => { + await mementoManager.addToUrlHistory("https://coder.example.com"); - expect(mementoManager.getUrl()).toBe("https://coder.example.com"); expect(memento.get("urlHistory")).toEqual(["https://coder.example.com"]); }); it("should not update history for falsy values", async () => { - await mementoManager.setUrl(undefined); - expect(mementoManager.getUrl()).toBeUndefined(); - expect(memento.get("urlHistory")).toBeUndefined(); - - await mementoManager.setUrl(""); - expect(mementoManager.getUrl()).toBe(""); + await mementoManager.addToUrlHistory(""); expect(memento.get("urlHistory")).toBeUndefined(); }); it("should deduplicate URLs in history", async () => { - await mementoManager.setUrl("url1"); - await mementoManager.setUrl("url2"); - await mementoManager.setUrl("url1"); // Re-add first URL + await mementoManager.addToUrlHistory("url1"); + await mementoManager.addToUrlHistory("url2"); + await mementoManager.addToUrlHistory("url1"); // Re-add first URL expect(memento.get("urlHistory")).toEqual(["url2", "url1"]); }); diff --git a/test/unit/core/pathResolver.test.ts b/test/unit/core/pathResolver.test.ts index e0e3b4d6..2930fb7e 100644 --- a/test/unit/core/pathResolver.test.ts +++ b/test/unit/core/pathResolver.test.ts @@ -1,9 +1,10 @@ import * as path from "path"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, it, vi } from "vitest"; import { PathResolver } from "@/core/pathResolver"; import { MockConfigurationProvider } from "../../mocks/testHelpers"; +import { expectPathsEqual } from "../../utils/platform"; describe("PathResolver", () => { const basePath = @@ -19,17 +20,19 @@ describe("PathResolver", () => { }); it("should use base path for empty labels", () => { - expect(pathResolver.getGlobalConfigDir("")).toBe(basePath); - expect(pathResolver.getSessionTokenPath("")).toBe( + expectPathsEqual(pathResolver.getGlobalConfigDir(""), basePath); + expectPathsEqual( + pathResolver.getSessionTokenPath(""), path.join(basePath, "session"), ); - expect(pathResolver.getUrlPath("")).toBe(path.join(basePath, "url")); + expectPathsEqual(pathResolver.getUrlPath(""), path.join(basePath, "url")); }); describe("getBinaryCachePath", () => { it("should use custom binary destination when configured", () => { mockConfig.set("coder.binaryDestination", "/custom/binary/path"); - expect(pathResolver.getBinaryCachePath("deployment")).toBe( + expectPathsEqual( + pathResolver.getBinaryCachePath("deployment"), "/custom/binary/path", ); }); @@ -37,14 +40,16 @@ describe("PathResolver", () => { it("should use default path when custom destination is empty or whitespace", () => { vi.stubEnv("CODER_BINARY_DESTINATION", " "); mockConfig.set("coder.binaryDestination", " "); - expect(pathResolver.getBinaryCachePath("deployment")).toBe( + expectPathsEqual( + pathResolver.getBinaryCachePath("deployment"), path.join(basePath, "deployment", "bin"), ); }); it("should normalize custom paths", () => { mockConfig.set("coder.binaryDestination", "/custom/../binary/./path"); - expect(pathResolver.getBinaryCachePath("deployment")).toBe( + expectPathsEqual( + pathResolver.getBinaryCachePath("deployment"), "/binary/path", ); }); @@ -53,19 +58,22 @@ describe("PathResolver", () => { // Use the global storage when the environment variable and setting are unset/blank vi.stubEnv("CODER_BINARY_DESTINATION", ""); mockConfig.set("coder.binaryDestination", ""); - expect(pathResolver.getBinaryCachePath("deployment")).toBe( + expectPathsEqual( + pathResolver.getBinaryCachePath("deployment"), path.join(basePath, "deployment", "bin"), ); // Test environment variable takes precedence over global storage vi.stubEnv("CODER_BINARY_DESTINATION", " /env/binary/path "); - expect(pathResolver.getBinaryCachePath("deployment")).toBe( + expectPathsEqual( + pathResolver.getBinaryCachePath("deployment"), "/env/binary/path", ); // Test setting takes precedence over environment variable mockConfig.set("coder.binaryDestination", " /setting/path "); - expect(pathResolver.getBinaryCachePath("deployment")).toBe( + expectPathsEqual( + pathResolver.getBinaryCachePath("deployment"), "/setting/path", ); }); diff --git a/test/unit/core/secretsManager.test.ts b/test/unit/core/secretsManager.test.ts index bfe8c713..e5d2efc3 100644 --- a/test/unit/core/secretsManager.test.ts +++ b/test/unit/core/secretsManager.test.ts @@ -1,82 +1,277 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { AuthAction, SecretsManager } from "@/core/secretsManager"; +import { + type CurrentDeploymentState, + SecretsManager, +} from "@/core/secretsManager"; -import { InMemorySecretStorage } from "../../mocks/testHelpers"; +import { + InMemoryMemento, + InMemorySecretStorage, + createMockLogger, +} from "../../mocks/testHelpers"; describe("SecretsManager", () => { let secretStorage: InMemorySecretStorage; + let memento: InMemoryMemento; let secretsManager: SecretsManager; beforeEach(() => { + vi.useRealTimers(); secretStorage = new InMemorySecretStorage(); - secretsManager = new SecretsManager(secretStorage); + memento = new InMemoryMemento(); + secretsManager = new SecretsManager( + secretStorage, + memento, + createMockLogger(), + ); }); - describe("session token", () => { - it("should store and retrieve tokens", async () => { - await secretsManager.setSessionToken("test-token"); - expect(await secretsManager.getSessionToken()).toBe("test-token"); + describe("session auth", () => { + it("should store and retrieve session auth", async () => { + await secretsManager.setSessionAuth("example.com", { + url: "https://example.com", + token: "test-token", + }); + const auth = await secretsManager.getSessionAuth("example.com"); + expect(auth?.token).toBe("test-token"); + expect(auth?.url).toBe("https://example.com"); - await secretsManager.setSessionToken("new-token"); - expect(await secretsManager.getSessionToken()).toBe("new-token"); + await secretsManager.setSessionAuth("example.com", { + url: "https://example.com", + token: "new-token", + }); + const newAuth = await secretsManager.getSessionAuth("example.com"); + expect(newAuth?.token).toBe("new-token"); }); - it("should delete token when empty or undefined", async () => { - await secretsManager.setSessionToken("test-token"); - await secretsManager.setSessionToken(""); - expect(await secretsManager.getSessionToken()).toBeUndefined(); - - await secretsManager.setSessionToken("test-token"); - await secretsManager.setSessionToken(undefined); - expect(await secretsManager.getSessionToken()).toBeUndefined(); + it("should clear session auth", async () => { + await secretsManager.setSessionAuth("example.com", { + url: "https://example.com", + token: "test-token", + }); + await secretsManager.clearAllAuthData("example.com"); + expect( + await secretsManager.getSessionAuth("example.com"), + ).toBeUndefined(); }); it("should return undefined for corrupted storage", async () => { - await secretStorage.store("sessionToken", "valid-token"); + await secretStorage.store( + "coder.session.example.com", + JSON.stringify({ + url: "https://example.com", + token: "valid-token", + }), + ); secretStorage.corruptStorage(); - expect(await secretsManager.getSessionToken()).toBeUndefined(); + expect( + await secretsManager.getSessionAuth("example.com"), + ).toBeUndefined(); }); - }); - describe("login state", () => { - it("should trigger login events", async () => { - const events: Array = []; - secretsManager.onDidChangeLoginState((state) => { - events.push(state); - return Promise.resolve(); + it("should track known safe hostnames", async () => { + expect(secretsManager.getKnownSafeHostnames()).toEqual([]); + + await secretsManager.setSessionAuth("example.com", { + url: "https://example.com", + token: "test-token", }); + expect(secretsManager.getKnownSafeHostnames()).toContain("example.com"); - await secretsManager.triggerLoginStateChange("login"); - expect(events).toEqual([AuthAction.LOGIN]); + await secretsManager.setSessionAuth("other-com", { + url: "https://other.com", + token: "other-token", + }); + expect(secretsManager.getKnownSafeHostnames()).toContain("example.com"); + expect(secretsManager.getKnownSafeHostnames()).toContain("other-com"); }); - it("should trigger logout events", async () => { - const events: Array = []; - secretsManager.onDidChangeLoginState((state) => { - events.push(state); - return Promise.resolve(); + it("should remove safe hostname on clearAllAuthData", async () => { + await secretsManager.setSessionAuth("example.com", { + url: "https://example.com", + token: "test-token", + }); + expect(secretsManager.getKnownSafeHostnames()).toContain("example.com"); + + await secretsManager.clearAllAuthData("example.com"); + expect(secretsManager.getKnownSafeHostnames()).not.toContain( + "example.com", + ); + }); + + it("should order safe hostnames by most recently accessed", async () => { + await secretsManager.setSessionAuth("first.com", { + url: "https://first.com", + token: "token1", + }); + await secretsManager.setSessionAuth("second.com", { + url: "https://second.com", + token: "token2", + }); + await secretsManager.setSessionAuth("first.com", { + url: "https://first.com", + token: "token1-updated", }); - await secretsManager.triggerLoginStateChange("logout"); - expect(events).toEqual([AuthAction.LOGOUT]); + expect(secretsManager.getKnownSafeHostnames()).toEqual([ + "first.com", + "second.com", + ]); }); - it("should fire same event twice in a row", async () => { + it("should prune old deployments when exceeding maxCount", async () => { + for (let i = 1; i <= 5; i++) { + await secretsManager.setSessionAuth(`host${i}.com`, { + url: `https://host${i}.com`, + token: `token${i}`, + }); + } + + await secretsManager.recordDeploymentAccess("new.com", 3); + + expect(secretsManager.getKnownSafeHostnames()).toEqual([ + "new.com", + "host5.com", + "host4.com", + ]); + expect(await secretsManager.getSessionAuth("host1.com")).toBeUndefined(); + expect(await secretsManager.getSessionAuth("host2.com")).toBeUndefined(); + }); + }); + + describe("current deployment", () => { + it("should store and retrieve current deployment", async () => { + const deployment = { + url: "https://example.com", + safeHostname: "example.com", + }; + await secretsManager.setCurrentDeployment(deployment); + + const result = await secretsManager.getCurrentDeployment(); + expect(result).toEqual(deployment); + }); + + it("should clear current deployment with undefined", async () => { + const deployment = { + url: "https://example.com", + safeHostname: "example.com", + }; + await secretsManager.setCurrentDeployment(deployment); + await secretsManager.setCurrentDeployment(undefined); + + const result = await secretsManager.getCurrentDeployment(); + expect(result).toBeNull(); + }); + + it("should return null when no deployment set", async () => { + const result = await secretsManager.getCurrentDeployment(); + expect(result).toBeNull(); + }); + + it("should notify listeners on deployment change", async () => { vi.useFakeTimers(); - const events: Array = []; - secretsManager.onDidChangeLoginState((state) => { + const events: Array = []; + secretsManager.onDidChangeCurrentDeployment((state) => { events.push(state); - return Promise.resolve(); }); - await secretsManager.triggerLoginStateChange("login"); + const deployments = [ + { url: "https://example.com", safeHostname: "example.com" }, + { url: "https://another.org", safeHostname: "another.org" }, + { url: "https://another.org", safeHostname: "another.org" }, + ]; + await secretsManager.setCurrentDeployment(deployments[0]); + vi.advanceTimersByTime(5); + await secretsManager.setCurrentDeployment(deployments[1]); vi.advanceTimersByTime(5); - await secretsManager.triggerLoginStateChange("login"); + await secretsManager.setCurrentDeployment(deployments[2]); + vi.advanceTimersByTime(5); + + // Trigger an event even if the deployment did not change + expect(events).toEqual(deployments.map((deployment) => ({ deployment }))); + }); + + it("should handle corrupted storage gracefully", async () => { + await secretStorage.store("coder.currentDeployment", "invalid-json{"); + + const result = await secretsManager.getCurrentDeployment(); + expect(result).toBeNull(); + }); + }); + + describe("migrateFromLegacyStorage", () => { + it("migrates legacy url/token to new format and sets current deployment", async () => { + // Set up legacy storage + await memento.update("url", "https://legacy.coder.com"); + await secretStorage.store("sessionToken", "legacy-token"); + + const result = await secretsManager.migrateFromLegacyStorage(); + + // Should return the migrated hostname + expect(result).toBe("legacy.coder.com"); + + // Should have migrated to new format + const auth = await secretsManager.getSessionAuth("legacy.coder.com"); + expect(auth?.url).toBe("https://legacy.coder.com"); + expect(auth?.token).toBe("legacy-token"); + + // Should have set current deployment + const deployment = await secretsManager.getCurrentDeployment(); + expect(deployment?.url).toBe("https://legacy.coder.com"); + expect(deployment?.safeHostname).toBe("legacy.coder.com"); + + // Legacy keys should be cleared + expect(memento.get("url")).toBeUndefined(); + expect(await secretStorage.get("sessionToken")).toBeUndefined(); + }); + + it("does not overwrite existing session auth", async () => { + // Set up existing auth + await secretsManager.setSessionAuth("existing.coder.com", { + url: "https://existing.coder.com", + token: "existing-token", + }); + + // Set up legacy storage with same hostname + await memento.update("url", "https://existing.coder.com"); + await secretStorage.store("sessionToken", "legacy-token"); + + await secretsManager.migrateFromLegacyStorage(); + + // Existing auth should not be overwritten + const auth = await secretsManager.getSessionAuth("existing.coder.com"); + expect(auth?.token).toBe("existing-token"); + }); + + it("returns undefined when no legacy data exists", async () => { + const result = await secretsManager.migrateFromLegacyStorage(); + expect(result).toBeUndefined(); + }); + + it("migrates with empty token when only URL exists (mTLS)", async () => { + await memento.update("url", "https://legacy.coder.com"); + + const result = await secretsManager.migrateFromLegacyStorage(); + expect(result).toBe("legacy.coder.com"); + + const auth = await secretsManager.getSessionAuth("legacy.coder.com"); + expect(auth?.url).toBe("https://legacy.coder.com"); + expect(auth?.token).toBe(""); + }); + }); + + describe("session auth - empty token handling (mTLS)", () => { + it("stores and retrieves empty string token", async () => { + await secretsManager.setSessionAuth("mtls.coder.com", { + url: "https://mtls.coder.com", + token: "", + }); - expect(events).toEqual([AuthAction.LOGIN, AuthAction.LOGIN]); - vi.useRealTimers(); + const auth = await secretsManager.getSessionAuth("mtls.coder.com"); + expect(auth?.token).toBe(""); + expect(auth?.url).toBe("https://mtls.coder.com"); }); }); }); diff --git a/test/unit/deployment/deploymentManager.test.ts b/test/unit/deployment/deploymentManager.test.ts new file mode 100644 index 00000000..4f0ca52d --- /dev/null +++ b/test/unit/deployment/deploymentManager.test.ts @@ -0,0 +1,395 @@ +import { describe, expect, it, vi } from "vitest"; + +import { CoderApi } from "@/api/coderApi"; +import { MementoManager } from "@/core/mementoManager"; +import { SecretsManager } from "@/core/secretsManager"; +import { DeploymentManager } from "@/deployment/deploymentManager"; + +import { + createMockLogger, + createMockUser, + InMemoryMemento, + InMemorySecretStorage, + MockCoderApi, +} from "../../mocks/testHelpers"; + +import type { ServiceContainer } from "@/core/container"; +import type { ContextManager } from "@/core/contextManager"; +import type { WorkspaceProvider } from "@/workspace/workspacesProvider"; + +// Mock CoderApi.create to return our mock client for validation +vi.mock("@/api/coderApi", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + CoderApi: { + ...original.CoderApi, + create: vi.fn(), + }, + }; +}); + +/** + * Mock ContextManager for deployment tests. + */ +class MockContextManager { + private readonly contexts = new Map(); + + readonly set = vi.fn((key: string, value: boolean) => { + this.contexts.set(key, value); + }); + + get(key: string): boolean | undefined { + return this.contexts.get(key); + } +} + +/** + * Mock WorkspaceProvider for deployment tests. + */ +class MockWorkspaceProvider { + readonly fetchAndRefresh = vi.fn(); +} + +const TEST_URL = "https://coder.example.com"; +const TEST_HOSTNAME = "coder.example.com"; + +/** + * Creates a fresh test context with all dependencies. + */ +function createTestContext() { + vi.resetAllMocks(); + + const mockClient = new MockCoderApi(); + // For setDeploymentIfValid, we use a separate mock for validation + const validationMockClient = new MockCoderApi(); + const mockWorkspaceProvider = new MockWorkspaceProvider(); + const secretStorage = new InMemorySecretStorage(); + const memento = new InMemoryMemento(); + const logger = createMockLogger(); + const secretsManager = new SecretsManager(secretStorage, memento, logger); + const mementoManager = new MementoManager(memento); + const contextManager = new MockContextManager(); + + // Configure CoderApi.create mock to return validation client + vi.mocked(CoderApi.create).mockReturnValue( + validationMockClient as unknown as CoderApi, + ); + + const container = { + getSecretsManager: () => secretsManager, + getMementoManager: () => mementoManager, + getContextManager: () => contextManager as unknown as ContextManager, + getLogger: () => logger, + }; + + const manager = DeploymentManager.create( + container as unknown as ServiceContainer, + mockClient as unknown as CoderApi, + [mockWorkspaceProvider as unknown as WorkspaceProvider], + ); + + return { + mockClient, + validationMockClient, + secretsManager, + contextManager, + manager, + }; +} + +describe("DeploymentManager", () => { + describe("deployment state", () => { + it("returns null and isAuthenticated=false with no deployment", () => { + const { manager } = createTestContext(); + + expect(manager.getCurrentDeployment()).toBeNull(); + expect(manager.isAuthenticated()).toBe(false); + }); + + it("returns deployment and isAuthenticated=true after setDeployment", async () => { + const { manager } = createTestContext(); + const user = createMockUser(); + + await manager.setDeployment({ + url: TEST_URL, + safeHostname: TEST_HOSTNAME, + token: "test-token", + user, + }); + + expect(manager.getCurrentDeployment()).toMatchObject({ + url: TEST_URL, + safeHostname: TEST_HOSTNAME, + }); + expect(manager.isAuthenticated()).toBe(true); + }); + + it("clears state after logout", async () => { + const { manager } = createTestContext(); + const user = createMockUser(); + + await manager.setDeployment({ + url: TEST_URL, + safeHostname: TEST_HOSTNAME, + token: "test-token", + user, + }); + + await manager.clearDeployment(); + + expect(manager.getCurrentDeployment()).toBeNull(); + expect(manager.isAuthenticated()).toBe(false); + }); + }); + + describe("setDeployment", () => { + it("sets credentials, refreshes workspaces, persists deployment", async () => { + const { mockClient, secretsManager, contextManager, manager } = + createTestContext(); + const user = createMockUser(); + + await manager.setDeployment({ + url: TEST_URL, + safeHostname: TEST_HOSTNAME, + token: "test-token", + user, + }); + + expect(mockClient.host).toBe(TEST_URL); + expect(mockClient.token).toBe("test-token"); + expect(contextManager.get("coder.authenticated")).toBe(true); + expect(contextManager.get("coder.isOwner")).toBe(false); + + const persisted = await secretsManager.getCurrentDeployment(); + expect(persisted?.url).toBe(TEST_URL); + }); + + it("sets isOwner context when user has owner role", async () => { + const { contextManager, manager } = createTestContext(); + const ownerUser = createMockUser({ + roles: [{ name: "owner", display_name: "Owner", organization_id: "" }], + }); + + await manager.setDeployment({ + url: TEST_URL, + safeHostname: TEST_HOSTNAME, + token: "test-token", + user: ownerUser, + }); + + expect(contextManager.get("coder.isOwner")).toBe(true); + }); + }); + + describe("setDeploymentIfValid", () => { + it("returns true and sets deployment on auth success", async () => { + const { mockClient, validationMockClient, manager } = createTestContext(); + const user = createMockUser(); + validationMockClient.setAuthenticatedUserResponse(user); + + const result = await manager.setDeploymentIfValid({ + url: TEST_URL, + safeHostname: TEST_HOSTNAME, + token: "test-token", + }); + + expect(result).toBe(true); + expect(mockClient.host).toBe(TEST_URL); + expect(mockClient.token).toBe("test-token"); + expect(manager.isAuthenticated()).toBe(true); + }); + + it("returns false and does not set deployment on auth failure", async () => { + const { validationMockClient, manager } = createTestContext(); + validationMockClient.setAuthenticatedUserResponse( + new Error("Auth failed"), + ); + + const result = await manager.setDeploymentIfValid({ + url: TEST_URL, + safeHostname: TEST_HOSTNAME, + token: "test-token", + }); + + expect(result).toBe(false); + expect(manager.getCurrentDeployment()).toBeNull(); + expect(manager.isAuthenticated()).toBe(false); + }); + + it("handles empty string token (mTLS) correctly", async () => { + const { mockClient, validationMockClient, manager } = createTestContext(); + const user = createMockUser(); + validationMockClient.setAuthenticatedUserResponse(user); + + const result = await manager.setDeploymentIfValid({ + url: TEST_URL, + safeHostname: TEST_HOSTNAME, + token: "", + }); + + expect(result).toBe(true); + expect(mockClient.host).toBe(TEST_URL); + expect(mockClient.token).toBe(""); + expect(manager.isAuthenticated()).toBe(true); + }); + + it("fetches token from secrets when not provided", async () => { + const { mockClient, validationMockClient, secretsManager, manager } = + createTestContext(); + const user = createMockUser(); + validationMockClient.setAuthenticatedUserResponse(user); + + // Store token in secrets + await secretsManager.setSessionAuth(TEST_HOSTNAME, { + url: TEST_URL, + token: "stored-token", + }); + + const result = await manager.setDeploymentIfValid({ + url: TEST_URL, + safeHostname: TEST_HOSTNAME, + }); + + expect(result).toBe(true); + expect(mockClient.token).toBe("stored-token"); + }); + + it("disposes validation client after use", async () => { + const { validationMockClient, manager } = createTestContext(); + const user = createMockUser(); + validationMockClient.setAuthenticatedUserResponse(user); + + await manager.setDeploymentIfValid({ + url: TEST_URL, + safeHostname: TEST_HOSTNAME, + token: "test-token", + }); + + expect(validationMockClient.disposed).toBe(true); + }); + }); + + describe("cross-window sync", () => { + it("ignores changes when already authenticated", async () => { + const { mockClient, secretsManager, manager } = createTestContext(); + const user = createMockUser(); + + await manager.setDeployment({ + url: TEST_URL, + safeHostname: TEST_HOSTNAME, + token: "test-token", + user, + }); + + // Simulate cross-window change by directly updating secrets + await secretsManager.setCurrentDeployment({ + url: "https://other.example.com", + safeHostname: "other.example.com", + }); + + // Should still have original credentials + expect(mockClient.host).toBe(TEST_URL); + expect(mockClient.token).toBe("test-token"); + }); + + it("picks up deployment when not authenticated", async () => { + const { mockClient, validationMockClient, secretsManager } = + createTestContext(); + const user = createMockUser(); + validationMockClient.setAuthenticatedUserResponse(user); + + // Set up auth in secrets before triggering cross-window sync + await secretsManager.setSessionAuth(TEST_HOSTNAME, { + url: TEST_URL, + token: "synced-token", + }); + + // Simulate cross-window change + await secretsManager.setCurrentDeployment({ + url: TEST_URL, + safeHostname: TEST_HOSTNAME, + }); + + // Wait for async handler + await new Promise((resolve) => setImmediate(resolve)); + + expect(mockClient.host).toBe(TEST_URL); + expect(mockClient.token).toBe("synced-token"); + }); + + it("handles mTLS deployment (empty token) from other window", async () => { + const { mockClient, validationMockClient, secretsManager } = + createTestContext(); + const user = createMockUser(); + validationMockClient.setAuthenticatedUserResponse(user); + + await secretsManager.setSessionAuth(TEST_HOSTNAME, { + url: TEST_URL, + token: "", + }); + + await secretsManager.setCurrentDeployment({ + url: TEST_URL, + safeHostname: TEST_HOSTNAME, + }); + + // Wait for async handler + await new Promise((resolve) => setImmediate(resolve)); + + expect(mockClient.host).toBe(TEST_URL); + expect(mockClient.token).toBe(""); + }); + }); + + describe("auth listener", () => { + it("updates credentials on token change", async () => { + const { mockClient, secretsManager, manager } = createTestContext(); + const user = createMockUser(); + + // Set up authenticated deployment + await manager.setDeployment({ + url: TEST_URL, + safeHostname: TEST_HOSTNAME, + token: "initial-token", + user, + }); + + expect(mockClient.token).toBe("initial-token"); + expect(manager.isAuthenticated()).toBe(true); + + // Simulate token refresh via secrets change + await secretsManager.setSessionAuth(TEST_HOSTNAME, { + url: TEST_URL, + token: "refreshed-token", + }); + + // Wait for async handler + await new Promise((resolve) => setImmediate(resolve)); + + expect(mockClient.token).toBe("refreshed-token"); + expect(manager.isAuthenticated()).toBe(true); + }); + }); + + describe("logout", () => { + it("clears credentials and updates contexts", async () => { + const { mockClient, contextManager, manager } = createTestContext(); + const user = createMockUser(); + + await manager.setDeployment({ + url: TEST_URL, + safeHostname: TEST_HOSTNAME, + token: "test-token", + user, + }); + + await manager.clearDeployment(); + + expect(mockClient.host).toBeUndefined(); + expect(mockClient.token).toBeUndefined(); + expect(contextManager.get("coder.authenticated")).toBe(false); + expect(contextManager.get("coder.isOwner")).toBe(false); + }); + }); +}); diff --git a/test/unit/error.test.ts b/test/unit/error.test.ts index b606f875..7d239768 100644 --- a/test/unit/error.test.ts +++ b/test/unit/error.test.ts @@ -1,6 +1,11 @@ +import { + KeyUsagesExtension, + X509Certificate as X509CertificatePeculiar, +} from "@peculiar/x509"; import axios from "axios"; -import * as fs from "fs/promises"; -import https from "https"; +import { X509Certificate as X509CertificateNode } from "node:crypto"; +import * as fs from "node:fs/promises"; +import https from "node:https"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { CertificateError, X509_ERR, X509_ERR_CODE } from "@/error"; @@ -12,14 +17,11 @@ describe("Certificate errors", () => { // Before each test we make a request to sanity check that we really get the // error we are expecting, then we run it through CertificateError. - // TODO: These sanity checks need to be ran in an Electron environment to - // reflect real usage in VS Code. We should either revert back to the standard - // extension testing framework which I believe runs in a headless VS Code - // instead of using vitest or at least run the tests through Electron running as - // Node (for now I do this manually by shimming Node). - const isElectron = - (process.versions.electron || process.env.ELECTRON_RUN_AS_NODE) && - !process.env.VSCODE_PID; // Running from the test explorer in VS Code + // These tests run in Electron (BoringSSL) for accurate certificate validation testing. + + it("should run in Electron environment", () => { + expect(process.versions.electron).toBeTruthy(); + }); beforeAll(() => { vi.mock("vscode", () => { @@ -114,8 +116,7 @@ describe("Certificate errors", () => { }); // In Electron a self-issued certificate without the signing capability fails - // (again with the same "unable to verify" error) but in Node self-issued - // certificates are not required to have the signing capability. + // (again with the same "unable to verify" error) it("detects self-signed certificates without signing capability", async () => { const address = await startServer("no-signing"); const request = axios.get(address, { @@ -124,26 +125,16 @@ describe("Certificate errors", () => { servername: "localhost", }), }); - if (isElectron) { - await expect(request).rejects.toHaveProperty( - "code", - X509_ERR_CODE.UNABLE_TO_VERIFY_LEAF_SIGNATURE, - ); - try { - await request; - } catch (error) { - const wrapped = await CertificateError.maybeWrap( - error, - address, - logger, - ); - expect(wrapped instanceof CertificateError).toBeTruthy(); - expect((wrapped as CertificateError).x509Err).toBe( - X509_ERR.NON_SIGNING, - ); - } - } else { - await expect(request).resolves.toHaveProperty("data", "foobar"); + await expect(request).rejects.toHaveProperty( + "code", + X509_ERR_CODE.UNABLE_TO_VERIFY_LEAF_SIGNATURE, + ); + try { + await request; + } catch (error) { + const wrapped = await CertificateError.maybeWrap(error, address, logger); + expect(wrapped instanceof CertificateError).toBeTruthy(); + expect((wrapped as CertificateError).x509Err).toBe(X509_ERR.NON_SIGNING); } }); @@ -157,6 +148,24 @@ describe("Certificate errors", () => { await expect(request).resolves.toHaveProperty("data", "foobar"); }); + // Node's X509Certificate.keyUsage is unreliable, so use a third-party parser + it("parses no-signing cert keyUsage with third-party library", async () => { + const certPem = await fs.readFile( + getFixturePath("tls", "no-signing.crt"), + "utf-8", + ); + + // Node's implementation seems to always return `undefined` + const nodeCert = new X509CertificateNode(certPem); + expect(nodeCert.keyUsage).toBeUndefined(); + + // Here we can correctly get the KeyUsages + const peculiarCert = new X509CertificatePeculiar(certPem); + const extension = peculiarCert.getExtension(KeyUsagesExtension); + expect(extension).toBeDefined(); + expect(extension?.usages).toBeTruthy(); + }); + // Both environments give the same error code when a self-issued certificate is // untrusted. it("detects self-signed certificates", async () => { diff --git a/test/unit/globalFlags.test.ts b/test/unit/globalFlags.test.ts deleted file mode 100644 index d570d609..00000000 --- a/test/unit/globalFlags.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { it, expect, describe } from "vitest"; -import { type WorkspaceConfiguration } from "vscode"; - -import { getGlobalFlags } from "@/globalFlags"; - -describe("Global flags suite", () => { - it("should return global-config and header args when no global flags configured", () => { - const config = { - get: () => undefined, - } as unknown as WorkspaceConfiguration; - - expect(getGlobalFlags(config, "/config/dir")).toStrictEqual([ - "--global-config", - '"/config/dir"', - ]); - }); - - it("should return global flags from config with global-config appended", () => { - const config = { - get: (key: string) => - key === "coder.globalFlags" - ? ["--verbose", "--disable-direct-connections"] - : undefined, - } as unknown as WorkspaceConfiguration; - - expect(getGlobalFlags(config, "/config/dir")).toStrictEqual([ - "--verbose", - "--disable-direct-connections", - "--global-config", - '"/config/dir"', - ]); - }); - - it("should not filter duplicate global-config flags, last takes precedence", () => { - const config = { - get: (key: string) => - key === "coder.globalFlags" - ? [ - "-v", - "--global-config /path/to/ignored", - "--disable-direct-connections", - ] - : undefined, - } as unknown as WorkspaceConfiguration; - - expect(getGlobalFlags(config, "/config/dir")).toStrictEqual([ - "-v", - "--global-config /path/to/ignored", - "--disable-direct-connections", - "--global-config", - '"/config/dir"', - ]); - }); - - it("should not filter header-command flags, header args appended at end", () => { - const config = { - get: (key: string) => { - if (key === "coder.headerCommand") { - return "echo test"; - } - if (key === "coder.globalFlags") { - return ["-v", "--header-command custom", "--no-feature-warning"]; - } - return undefined; - }, - } as unknown as WorkspaceConfiguration; - - const result = getGlobalFlags(config, "/config/dir"); - expect(result).toStrictEqual([ - "-v", - "--header-command custom", // ignored by CLI - "--no-feature-warning", - "--global-config", - '"/config/dir"', - "--header-command", - "'echo test'", - ]); - }); -}); diff --git a/test/unit/headers.test.ts b/test/unit/headers.test.ts index b2c29e22..f5812ec1 100644 --- a/test/unit/headers.test.ts +++ b/test/unit/headers.test.ts @@ -1,10 +1,11 @@ -import * as os from "os"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { type WorkspaceConfiguration } from "vscode"; import { getHeaderCommand, getHeaders } from "@/headers"; import { type Logger } from "@/logging/logger"; +import { printCommand, exitCommand, printEnvCommand } from "../utils/platform"; + const logger: Logger = { trace: () => {}, debug: () => {}, @@ -13,142 +14,142 @@ const logger: Logger = { error: () => {}, }; -it("should return no headers", async () => { - await expect(getHeaders(undefined, undefined, logger)).resolves.toStrictEqual( - {}, - ); - await expect( - getHeaders("localhost", undefined, logger), - ).resolves.toStrictEqual({}); - await expect(getHeaders(undefined, "command", logger)).resolves.toStrictEqual( - {}, - ); - await expect(getHeaders("localhost", "", logger)).resolves.toStrictEqual({}); - await expect(getHeaders("", "command", logger)).resolves.toStrictEqual({}); - await expect(getHeaders("localhost", " ", logger)).resolves.toStrictEqual( - {}, - ); - await expect(getHeaders(" ", "command", logger)).resolves.toStrictEqual({}); - await expect( - getHeaders("localhost", "printf ''", logger), - ).resolves.toStrictEqual({}); -}); - -it("should return headers", async () => { - await expect( - getHeaders("localhost", "printf 'foo=bar\\nbaz=qux'", logger), - ).resolves.toStrictEqual({ - foo: "bar", - baz: "qux", - }); - await expect( - getHeaders("localhost", "printf 'foo=bar\\r\\nbaz=qux'", logger), - ).resolves.toStrictEqual({ - foo: "bar", - baz: "qux", +describe("Headers", () => { + it("should return no headers", async () => { + await expect( + getHeaders(undefined, undefined, logger), + ).resolves.toStrictEqual({}); + await expect( + getHeaders("localhost", undefined, logger), + ).resolves.toStrictEqual({}); + await expect( + getHeaders(undefined, "command", logger), + ).resolves.toStrictEqual({}); + await expect(getHeaders("localhost", "", logger)).resolves.toStrictEqual( + {}, + ); + await expect(getHeaders("", "command", logger)).resolves.toStrictEqual({}); + await expect(getHeaders("localhost", " ", logger)).resolves.toStrictEqual( + {}, + ); + await expect(getHeaders(" ", "command", logger)).resolves.toStrictEqual( + {}, + ); + await expect( + getHeaders("localhost", printCommand(""), logger), + ).resolves.toStrictEqual({}); }); - await expect( - getHeaders("localhost", "printf 'foo=bar\\r\\n'", logger), - ).resolves.toStrictEqual({ foo: "bar" }); - await expect( - getHeaders("localhost", "printf 'foo=bar'", logger), - ).resolves.toStrictEqual({ foo: "bar" }); - await expect( - getHeaders("localhost", "printf 'foo=bar='", logger), - ).resolves.toStrictEqual({ foo: "bar=" }); - await expect( - getHeaders("localhost", "printf 'foo=bar=baz'", logger), - ).resolves.toStrictEqual({ foo: "bar=baz" }); - await expect( - getHeaders("localhost", "printf 'foo='", logger), - ).resolves.toStrictEqual({ foo: "" }); -}); - -it("should error on malformed or empty lines", async () => { - await expect( - getHeaders("localhost", "printf 'foo=bar\\r\\n\\r\\n'", logger), - ).rejects.toThrow(/Malformed/); - await expect( - getHeaders("localhost", "printf '\\r\\nfoo=bar'", logger), - ).rejects.toThrow(/Malformed/); - await expect( - getHeaders("localhost", "printf '=foo'", logger), - ).rejects.toThrow(/Malformed/); - await expect(getHeaders("localhost", "printf 'foo'", logger)).rejects.toThrow( - /Malformed/, - ); - await expect( - getHeaders("localhost", "printf ' =foo'", logger), - ).rejects.toThrow(/Malformed/); - await expect( - getHeaders("localhost", "printf 'foo =bar'", logger), - ).rejects.toThrow(/Malformed/); - await expect( - getHeaders("localhost", "printf 'foo foo=bar'", logger), - ).rejects.toThrow(/Malformed/); -}); -it("should have access to environment variables", async () => { - const coderUrl = "dev.coder.com"; - await expect( - getHeaders( - coderUrl, - os.platform() === "win32" - ? "printf url=%CODER_URL%" - : "printf url=$CODER_URL", - logger, - ), - ).resolves.toStrictEqual({ url: coderUrl }); -}); + it("should return headers", async () => { + await expect( + getHeaders("localhost", printCommand("foo=bar\nbaz=qux"), logger), + ).resolves.toStrictEqual({ + foo: "bar", + baz: "qux", + }); + await expect( + getHeaders("localhost", printCommand("foo=bar\r\nbaz=qux"), logger), + ).resolves.toStrictEqual({ + foo: "bar", + baz: "qux", + }); + await expect( + getHeaders("localhost", printCommand("foo=bar\r\n"), logger), + ).resolves.toStrictEqual({ foo: "bar" }); + await expect( + getHeaders("localhost", printCommand("foo=bar"), logger), + ).resolves.toStrictEqual({ foo: "bar" }); + await expect( + getHeaders("localhost", printCommand("foo=bar="), logger), + ).resolves.toStrictEqual({ foo: "bar=" }); + await expect( + getHeaders("localhost", printCommand("foo=bar=baz"), logger), + ).resolves.toStrictEqual({ foo: "bar=baz" }); + await expect( + getHeaders("localhost", printCommand("foo="), logger), + ).resolves.toStrictEqual({ foo: "" }); + }); -it("should error on non-zero exit", async () => { - await expect(getHeaders("localhost", "exit 10", logger)).rejects.toThrow( - /exited unexpectedly with code 10/, - ); -}); + it("should error on malformed or empty lines", async () => { + await expect( + getHeaders("localhost", printCommand("foo=bar\r\n\r\n"), logger), + ).rejects.toThrow(/Malformed/); + await expect( + getHeaders("localhost", printCommand("\r\nfoo=bar"), logger), + ).rejects.toThrow(/Malformed/); + await expect( + getHeaders("localhost", printCommand("=foo"), logger), + ).rejects.toThrow(/Malformed/); + await expect( + getHeaders("localhost", printCommand("foo"), logger), + ).rejects.toThrow(/Malformed/); + await expect( + getHeaders("localhost", printCommand(" =foo"), logger), + ).rejects.toThrow(/Malformed/); + await expect( + getHeaders("localhost", printCommand("foo =bar"), logger), + ).rejects.toThrow(/Malformed/); + await expect( + getHeaders("localhost", printCommand("foo foo=bar"), logger), + ).rejects.toThrow(/Malformed/); + }); -describe("getHeaderCommand", () => { - beforeEach(() => { - vi.stubEnv("CODER_HEADER_COMMAND", ""); + it("should have access to environment variables", async () => { + const coderUrl = "dev.coder.com"; + await expect( + getHeaders(coderUrl, printEnvCommand("url", "CODER_URL"), logger), + ).resolves.toStrictEqual({ url: coderUrl }); }); - afterEach(() => { - vi.unstubAllEnvs(); + it("should error on non-zero exit", async () => { + await expect( + getHeaders("localhost", exitCommand(10), logger), + ).rejects.toThrow(/exited unexpectedly with code 10/); }); - it("should return undefined if coder.headerCommand is not set in config", () => { - const config = { - get: () => undefined, - } as unknown as WorkspaceConfiguration; + describe("getHeaderCommand", () => { + beforeEach(() => { + vi.stubEnv("CODER_HEADER_COMMAND", ""); + }); - expect(getHeaderCommand(config)).toBeUndefined(); - }); + afterEach(() => { + vi.unstubAllEnvs(); + }); - it("should return undefined if coder.headerCommand is a blank string", () => { - const config = { - get: () => " ", - } as unknown as WorkspaceConfiguration; + it("should return undefined if coder.headerCommand is not set in config", () => { + const config = { + get: () => undefined, + } as unknown as WorkspaceConfiguration; - expect(getHeaderCommand(config)).toBeUndefined(); - }); + expect(getHeaderCommand(config)).toBeUndefined(); + }); - it("should return coder.headerCommand if set in config", () => { - vi.stubEnv("CODER_HEADER_COMMAND", "printf 'x=y'"); + it("should return undefined if coder.headerCommand is a blank string", () => { + const config = { + get: () => " ", + } as unknown as WorkspaceConfiguration; - const config = { - get: () => "printf 'foo=bar'", - } as unknown as WorkspaceConfiguration; + expect(getHeaderCommand(config)).toBeUndefined(); + }); - expect(getHeaderCommand(config)).toBe("printf 'foo=bar'"); - }); + it("should return coder.headerCommand if set in config", () => { + vi.stubEnv("CODER_HEADER_COMMAND", "printf 'x=y'"); + + const config = { + get: () => "printf 'foo=bar'", + } as unknown as WorkspaceConfiguration; + + expect(getHeaderCommand(config)).toBe("printf 'foo=bar'"); + }); - it("should return CODER_HEADER_COMMAND if coder.headerCommand is not set in config and CODER_HEADER_COMMAND is set in environment", () => { - vi.stubEnv("CODER_HEADER_COMMAND", "printf 'x=y'"); + it("should return CODER_HEADER_COMMAND if coder.headerCommand is not set in config and CODER_HEADER_COMMAND is set in environment", () => { + vi.stubEnv("CODER_HEADER_COMMAND", "printf 'x=y'"); - const config = { - get: () => undefined, - } as unknown as WorkspaceConfiguration; + const config = { + get: () => undefined, + } as unknown as WorkspaceConfiguration; - expect(getHeaderCommand(config)).toBe("printf 'x=y'"); + expect(getHeaderCommand(config)).toBe("printf 'x=y'"); + }); }); }); diff --git a/test/unit/logging/wsLogger.test.ts b/test/unit/logging/eventStreamLogger.test.ts similarity index 50% rename from test/unit/logging/wsLogger.test.ts rename to test/unit/logging/eventStreamLogger.test.ts index 5bf9d5b1..352ccaac 100644 --- a/test/unit/logging/wsLogger.test.ts +++ b/test/unit/logging/eventStreamLogger.test.ts @@ -1,19 +1,23 @@ import { describe, expect, it } from "vitest"; -import { WsLogger } from "@/logging/wsLogger"; +import { EventStreamLogger } from "@/logging/eventStreamLogger"; import { createMockLogger } from "../../mocks/testHelpers"; -describe("WS Logger", () => { +describe("EventStreamLogger", () => { it("tracks message count and byte size", () => { const logger = createMockLogger(); - const wsLogger = new WsLogger(logger, "wss://example.com"); + const eventStreamLogger = new EventStreamLogger( + logger, + "wss://example.com", + "WS", + ); - wsLogger.logOpen(); - wsLogger.logMessage("hello"); - wsLogger.logMessage("world"); - wsLogger.logMessage(Buffer.from("test")); - wsLogger.logClose(); + eventStreamLogger.logOpen(); + eventStreamLogger.logMessage("hello"); + eventStreamLogger.logMessage("world"); + eventStreamLogger.logMessage(Buffer.from("test")); + eventStreamLogger.logClose(); expect(logger.trace).toHaveBeenCalledWith( expect.stringContaining("3 msgs"), @@ -23,12 +27,16 @@ describe("WS Logger", () => { it("handles unknown byte sizes with >= indicator", () => { const logger = createMockLogger(); - const wsLogger = new WsLogger(logger, "wss://example.com"); + const eventStreamLogger = new EventStreamLogger( + logger, + "wss://example.com", + "WS", + ); - wsLogger.logOpen(); - wsLogger.logMessage({ complex: "object" }); // Unknown size - no estimation - wsLogger.logMessage("known"); - wsLogger.logClose(); + eventStreamLogger.logOpen(); + eventStreamLogger.logMessage({ complex: "object" }); // Unknown size - no estimation + eventStreamLogger.logMessage("known"); + eventStreamLogger.logClose(); expect(logger.trace).toHaveBeenLastCalledWith( expect.stringContaining(">= 5 B"), @@ -37,22 +45,30 @@ describe("WS Logger", () => { it("handles close before open gracefully", () => { const logger = createMockLogger(); - const wsLogger = new WsLogger(logger, "wss://example.com"); + const eventStreamLogger = new EventStreamLogger( + logger, + "wss://example.com", + "WS", + ); // Closing without opening should not throw - expect(() => wsLogger.logClose()).not.toThrow(); + expect(() => eventStreamLogger.logClose()).not.toThrow(); expect(logger.trace).toHaveBeenCalled(); }); it("formats large message counts with compact notation", () => { const logger = createMockLogger(); - const wsLogger = new WsLogger(logger, "wss://example.com"); + const eventStreamLogger = new EventStreamLogger( + logger, + "wss://example.com", + "WS", + ); - wsLogger.logOpen(); + eventStreamLogger.logOpen(); for (let i = 0; i < 1100; i++) { - wsLogger.logMessage("x"); + eventStreamLogger.logMessage("x"); } - wsLogger.logClose(); + eventStreamLogger.logClose(); expect(logger.trace).toHaveBeenLastCalledWith( expect.stringMatching(/1[.,]1K\s*msgs/), @@ -61,10 +77,14 @@ describe("WS Logger", () => { it("logs errors with error object", () => { const logger = createMockLogger(); - const wsLogger = new WsLogger(logger, "wss://example.com"); + const eventStreamLogger = new EventStreamLogger( + logger, + "wss://example.com", + "WS", + ); const error = new Error("Connection failed"); - wsLogger.logError(error, "Failed to connect"); + eventStreamLogger.logError(error, "Failed to connect"); expect(logger.error).toHaveBeenCalledWith(expect.any(String), error); }); diff --git a/test/unit/logging/utils.test.ts b/test/unit/logging/utils.test.ts index 4d0f71eb..989a23e1 100644 --- a/test/unit/logging/utils.test.ts +++ b/test/unit/logging/utils.test.ts @@ -23,7 +23,8 @@ describe("Logging utils", () => { }); describe("sizeOf", () => { - it.each([ + type SizeOfTestCase = [data: unknown, bytes: number | undefined]; + it.each([ // Primitives return a fixed value [null, 0], [undefined, 0], diff --git a/test/unit/login/loginCoordinator.test.ts b/test/unit/login/loginCoordinator.test.ts new file mode 100644 index 00000000..fda88ada --- /dev/null +++ b/test/unit/login/loginCoordinator.test.ts @@ -0,0 +1,334 @@ +import axios from "axios"; +import { describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; + +import { MementoManager } from "@/core/mementoManager"; +import { SecretsManager } from "@/core/secretsManager"; +import { getHeaders } from "@/headers"; +import { LoginCoordinator } from "@/login/loginCoordinator"; + +import { + createMockLogger, + createMockUser, + InMemoryMemento, + InMemorySecretStorage, + MockConfigurationProvider, + MockUserInteraction, +} from "../../mocks/testHelpers"; + +// Hoisted mock adapter implementation +const mockAxiosAdapterImpl = vi.hoisted( + () => (config: Record) => + Promise.resolve({ + data: config.data || "{}", + status: 200, + statusText: "OK", + headers: {}, + config, + }), +); + +vi.mock("axios", async () => { + const actual = await vi.importActual("axios"); + const mockAdapter = vi.fn(); + return { + ...actual, + default: { + ...actual.default, + create: vi.fn((config) => + actual.default.create({ ...config, adapter: mockAdapter }), + ), + __mockAdapter: mockAdapter, + }, + }; +}); + +vi.mock("@/headers", () => ({ + getHeaders: vi.fn().mockResolvedValue({}), + getHeaderCommand: vi.fn(), +})); + +vi.mock("@/api/utils", async () => { + const actual = + await vi.importActual("@/api/utils"); + return { ...actual, createHttpAgent: vi.fn() }; +}); + +vi.mock("@/api/streamingFetchAdapter", () => ({ + createStreamingFetchAdapter: vi.fn(() => fetch), +})); + +vi.mock("@/promptUtils"); + +// Type for axios with our mock adapter +type MockedAxios = typeof axios & { __mockAdapter: ReturnType }; + +const TEST_URL = "https://coder.example.com"; +const TEST_HOSTNAME = "coder.example.com"; + +/** + * Creates a fresh test context with all dependencies. + */ +function createTestContext() { + vi.resetAllMocks(); + + const mockAdapter = (axios as MockedAxios).__mockAdapter; + mockAdapter.mockImplementation(mockAxiosAdapterImpl); + vi.mocked(getHeaders).mockResolvedValue({}); + + // MockConfigurationProvider sets sensible defaults (httpClientLogLevel, tlsCertFile, tlsKeyFile) + const mockConfig = new MockConfigurationProvider(); + // MockUserInteraction sets up vscode.window dialogs and input boxes + const userInteraction = new MockUserInteraction(); + + const secretStorage = new InMemorySecretStorage(); + const memento = new InMemoryMemento(); + const logger = createMockLogger(); + const secretsManager = new SecretsManager(secretStorage, memento, logger); + const mementoManager = new MementoManager(memento); + + const coordinator = new LoginCoordinator( + secretsManager, + mementoManager, + vscode, + logger, + ); + + const mockSuccessfulAuth = (user = createMockUser()) => { + mockAdapter.mockResolvedValue({ + data: user, + status: 200, + statusText: "OK", + headers: {}, + config: {}, + }); + return user; + }; + + const mockAuthFailure = (message = "Unauthorized") => { + mockAdapter.mockRejectedValue({ + response: { status: 401, data: { message } }, + message, + }); + }; + + return { + mockAdapter, + mockConfig, + userInteraction, + secretsManager, + mementoManager, + coordinator, + mockSuccessfulAuth, + mockAuthFailure, + }; +} + +describe("LoginCoordinator", () => { + describe("token authentication", () => { + it("authenticates with stored token on success", async () => { + const { secretsManager, coordinator, mockSuccessfulAuth } = + createTestContext(); + const user = mockSuccessfulAuth(); + + // Pre-store a token + await secretsManager.setSessionAuth(TEST_HOSTNAME, { + url: TEST_URL, + token: "stored-token", + }); + + const result = await coordinator.ensureLoggedIn({ + url: TEST_URL, + safeHostname: TEST_HOSTNAME, + }); + + expect(result).toEqual({ success: true, user, token: "stored-token" }); + + const auth = await secretsManager.getSessionAuth(TEST_HOSTNAME); + expect(auth?.token).toBe("stored-token"); + }); + + it("prompts for token when no stored auth exists", async () => { + const { mockAdapter, userInteraction, secretsManager, coordinator } = + createTestContext(); + const user = createMockUser(); + + // No stored token, so goes directly to input box flow + // Mock succeeds when validateInput calls getAuthenticatedUser + mockAdapter.mockResolvedValueOnce({ + data: user, + status: 200, + statusText: "OK", + headers: {}, + config: {}, + }); + + // User enters a new token in the input box + userInteraction.setInputBoxValue("new-token"); + + const result = await coordinator.ensureLoggedIn({ + url: TEST_URL, + safeHostname: TEST_HOSTNAME, + }); + + expect(result).toEqual({ success: true, user, token: "new-token" }); + + // Verify new token was persisted + const auth = await secretsManager.getSessionAuth(TEST_HOSTNAME); + expect(auth?.token).toBe("new-token"); + }); + + it("returns success false when user cancels input", async () => { + const { userInteraction, coordinator, mockAuthFailure } = + createTestContext(); + mockAuthFailure(); + userInteraction.setInputBoxValue(undefined); + + const result = await coordinator.ensureLoggedIn({ + url: TEST_URL, + safeHostname: TEST_HOSTNAME, + }); + + expect(result.success).toBe(false); + }); + }); + + describe("same-window guard", () => { + it("prevents duplicate login calls for same hostname", async () => { + const { mockAdapter, userInteraction, coordinator } = createTestContext(); + const user = createMockUser(); + + // User enters a token in the input box + userInteraction.setInputBoxValue("new-token"); + + let resolveAuth: (value: unknown) => void; + mockAdapter.mockReturnValue( + new Promise((resolve) => { + resolveAuth = resolve; + }), + ); + + // Start first login + const login1 = coordinator.ensureLoggedIn({ + url: TEST_URL, + safeHostname: TEST_HOSTNAME, + }); + + // Start second login immediately (same hostname) + const login2 = coordinator.ensureLoggedIn({ + url: TEST_URL, + safeHostname: TEST_HOSTNAME, + }); + + // Resolve the auth (this validates the token from input box) + resolveAuth!({ + data: user, + status: 200, + statusText: "OK", + headers: {}, + config: {}, + }); + + // Both should complete with the same result + const [result1, result2] = await Promise.all([login1, login2]); + expect(result1.success).toBe(true); + expect(result1).toEqual(result2); + + // Input box should only be shown once (guard prevents duplicate prompts) + expect(vscode.window.showInputBox).toHaveBeenCalledTimes(1); + }); + }); + + describe("mTLS authentication", () => { + it("succeeds without prompt and returns token=''", async () => { + const { mockConfig, secretsManager, coordinator, mockSuccessfulAuth } = + createTestContext(); + // Configure mTLS via certs (no token needed) + mockConfig.set("coder.tlsCertFile", "/path/to/cert.pem"); + mockConfig.set("coder.tlsKeyFile", "/path/to/key.pem"); + + const user = mockSuccessfulAuth(); + + const result = await coordinator.ensureLoggedIn({ + url: TEST_URL, + safeHostname: TEST_HOSTNAME, + }); + + expect(result).toEqual({ success: true, user, token: "" }); + + // Verify empty string token was persisted + const auth = await secretsManager.getSessionAuth(TEST_HOSTNAME); + expect(auth?.token).toBe(""); + + // Should NOT prompt for token + expect(vscode.window.showInputBox).not.toHaveBeenCalled(); + }); + + it("shows error and returns failure when mTLS fails", async () => { + const { mockConfig, coordinator, mockAuthFailure } = createTestContext(); + mockConfig.set("coder.tlsCertFile", "/path/to/cert.pem"); + mockConfig.set("coder.tlsKeyFile", "/path/to/key.pem"); + mockAuthFailure("Certificate error"); + + const result = await coordinator.ensureLoggedIn({ + url: TEST_URL, + safeHostname: TEST_HOSTNAME, + }); + + expect(result.success).toBe(false); + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Failed to log in to Coder server", + expect.objectContaining({ modal: true }), + ); + + // Should NOT prompt for token since it's mTLS + expect(vscode.window.showInputBox).not.toHaveBeenCalled(); + }); + + it("logs warning instead of showing dialog for autoLogin", async () => { + const { mockConfig, secretsManager, mementoManager, mockAuthFailure } = + createTestContext(); + mockConfig.set("coder.tlsCertFile", "/path/to/cert.pem"); + mockConfig.set("coder.tlsKeyFile", "/path/to/key.pem"); + + const logger = createMockLogger(); + const coordinator = new LoginCoordinator( + secretsManager, + mementoManager, + vscode, + logger, + ); + + mockAuthFailure("Certificate error"); + + const result = await coordinator.ensureLoggedIn({ + url: TEST_URL, + safeHostname: TEST_HOSTNAME, + autoLogin: true, + }); + + expect(result.success).toBe(false); + expect(logger.warn).toHaveBeenCalled(); + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled(); + }); + }); + + describe("ensureLoggedInWithDialog", () => { + it("returns success false when user dismisses dialog", async () => { + const { mockConfig, userInteraction, coordinator } = createTestContext(); + // Use mTLS for simpler dialog test + mockConfig.set("coder.tlsCertFile", "/path/to/cert.pem"); + mockConfig.set("coder.tlsKeyFile", "/path/to/key.pem"); + + // User dismisses dialog (returns undefined instead of "Login") + userInteraction.setResponse("Authentication Required", undefined); + + const result = await coordinator.ensureLoggedInWithDialog({ + url: TEST_URL, + safeHostname: TEST_HOSTNAME, + }); + + expect(result.success).toBe(false); + }); + }); +}); diff --git a/test/unit/remote/sshProcess.test.ts b/test/unit/remote/sshProcess.test.ts new file mode 100644 index 00000000..befd068b --- /dev/null +++ b/test/unit/remote/sshProcess.test.ts @@ -0,0 +1,568 @@ +import find from "find-process"; +import { vol } from "memfs"; +import * as fsPromises from "node:fs/promises"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + SshProcessMonitor, + type SshProcessMonitorOptions, +} from "@/remote/sshProcess"; + +import { createMockLogger, MockStatusBar } from "../../mocks/testHelpers"; + +import type * as fs from "node:fs"; + +vi.mock("find-process", () => ({ default: vi.fn() })); + +vi.mock("node:fs/promises", async () => { + const memfs: { fs: typeof fs } = await vi.importActual("memfs"); + return memfs.fs.promises; +}); + +describe("SshProcessMonitor", () => { + let activeMonitors: SshProcessMonitor[] = []; + let statusBar: MockStatusBar; + + beforeEach(() => { + vi.restoreAllMocks(); + vol.reset(); + activeMonitors = []; + statusBar = new MockStatusBar(); + + // Default: process found immediately + vi.mocked(find).mockResolvedValue([ + { pid: 999, ppid: 1, name: "ssh", cmd: "ssh host" }, + ]); + }); + + afterEach(() => { + for (const m of activeMonitors) { + m.dispose(); + } + activeMonitors = []; + vol.reset(); + }); + + describe("process discovery", () => { + it("finds SSH process by port from Remote SSH logs", async () => { + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 12345 ->", + }); + + const monitor = createMonitor({ codeLogDir: "/logs/window1" }); + const pid = await waitForEvent(monitor.onPidChange); + + expect(find).toHaveBeenCalledWith("port", 12345); + expect(pid).toBe(999); + }); + + it("retries until process is found", async () => { + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 12345 ->", + }); + + // First 2 calls return nothing, third call finds the process + vi.mocked(find) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { pid: 888, ppid: 1, name: "ssh", cmd: "ssh host" }, + ]); + + const monitor = createMonitor({ codeLogDir: "/logs/window1" }); + const pid = await waitForEvent(monitor.onPidChange); + + expect(vi.mocked(find).mock.calls.length).toBeGreaterThanOrEqual(3); + expect(pid).toBe(888); + }); + + it("retries when Remote SSH log appears later", async () => { + // Start with no log file + vol.fromJSON({}); + + vi.mocked(find).mockResolvedValue([ + { pid: 777, ppid: 1, name: "ssh", cmd: "ssh host" }, + ]); + + const monitor = createMonitor({ codeLogDir: "/logs/window1" }); + + // Add the log file after a delay + setTimeout(() => { + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 55555 ->", + }); + }, 50); + + const pid = await waitForEvent(monitor.onPidChange); + + expect(find).toHaveBeenCalledWith("port", 55555); + expect(pid).toBe(777); + }); + + it("uses newest output_logging_ directory when multiple exist", async () => { + // Reverse alphabetical order means highest number/newest first + vol.fromJSON({ + "/logs/output_logging_20240101/1-Remote - SSH.log": + "-> socksPort 11111 ->", + "/logs/output_logging_20240102/1-Remote - SSH.log": + "-> socksPort 22222 ->", + "/logs/output_logging_20240103/1-Remote - SSH.log": + "-> socksPort 33333 ->", + }); + + // Mock readdir to return directories in unsorted order (simulating Windows fs) + mockReaddirOrder("/logs", [ + "output_logging_20240103", + "output_logging_20240101", + "output_logging_20240102", + "window1", + ]); + + const monitor = createMonitor({ codeLogDir: "/logs/window1" }); + await waitForEvent(monitor.onPidChange); + + expect(find).toHaveBeenCalledWith("port", 33333); + }); + + it("sorts output_logging_ directories using localeCompare for consistent ordering", async () => { + // localeCompare differs from default sort() for mixed case + vol.fromJSON({ + "/logs/output_logging_a/1-Remote - SSH.log": "-> socksPort 11111 ->", + "/logs/output_logging_Z/1-Remote - SSH.log": "-> socksPort 22222 ->", + }); + + mockReaddirOrder("/logs", [ + "output_logging_a", + "output_logging_Z", + "window1", + ]); + + const monitor = createMonitor({ codeLogDir: "/logs/window1" }); + await waitForEvent(monitor.onPidChange); + + // With localeCompare: ["a", "Z"] -> reversed -> "Z" first (port 22222) + // With plain sort(): ["Z", "a"] -> reversed -> "a" first (port 11111) + expect(find).toHaveBeenCalledWith("port", 22222); + }); + + it("falls back to output_logging_ when extension folder has no SSH log", async () => { + // Extension folder exists but doesn't have Remote SSH log + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/some-other-log.log": "", + "/logs/output_logging_20240101/1-Remote - SSH.log": + "-> socksPort 55555 ->", + }); + + const monitor = createMonitor({ codeLogDir: "/logs/window1" }); + await waitForEvent(monitor.onPidChange); + + expect(find).toHaveBeenCalledWith("port", 55555); + }); + + it("reconnects when network info becomes stale", async () => { + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 12345 ->", + "/network/999.json": JSON.stringify({ + p2p: true, + latency: 10, + preferred_derp: "", + derp_latency: {}, + upload_bytes_sec: 0, + download_bytes_sec: 0, + using_coder_connect: false, + }), + }); + + // First search finds PID 999, after reconnect finds PID 888 + vi.mocked(find) + .mockResolvedValueOnce([{ pid: 999, ppid: 1, name: "ssh", cmd: "ssh" }]) + .mockResolvedValue([{ pid: 888, ppid: 1, name: "ssh", cmd: "ssh" }]); + + const monitor = createMonitor({ + codeLogDir: "/logs/window1", + networkInfoPath: "/network", + networkPollInterval: 10, + }); + + // Initial PID + const firstPid = await waitForEvent(monitor.onPidChange); + expect(firstPid).toBe(999); + + // Network info will become stale after 50ms (5 * networkPollInterval) + // Monitor keeps showing last status, only fires when PID actually changes + const pids: (number | undefined)[] = []; + monitor.onPidChange((pid) => pids.push(pid)); + + // Wait for reconnection to find new PID + await waitFor(() => pids.includes(888), 200); + + // Should NOT fire undefined - we keep showing last status while searching + expect(pids).toContain(888); + }); + + it("does not fire event when same process is found after stale check", async () => { + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 12345 ->", + "/network/999.json": JSON.stringify({ + p2p: true, + latency: 10, + preferred_derp: "", + derp_latency: {}, + upload_bytes_sec: 0, + download_bytes_sec: 0, + using_coder_connect: false, + }), + }); + + // Always returns the same PID + vi.mocked(find).mockResolvedValue([ + { pid: 999, ppid: 1, name: "ssh", cmd: "ssh" }, + ]); + + const monitor = createMonitor({ + codeLogDir: "/logs/window1", + networkInfoPath: "/network", + networkPollInterval: 10, + }); + + // Wait for initial PID + await waitForEvent(monitor.onPidChange); + + // Track subsequent events + const pids: (number | undefined)[] = []; + monitor.onPidChange((pid) => pids.push(pid)); + + // Wait long enough for stale check to trigger and re-find same process + await new Promise((r) => setTimeout(r, 100)); + + // No events should fire - same process found, no change + expect(pids).toEqual([]); + }); + }); + + describe("log file discovery", () => { + it("finds log file matching PID pattern", async () => { + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 12345 ->", + "/proxy-logs/999.log": "", + "/proxy-logs/other.log": "", + }); + + const monitor = createMonitor({ + codeLogDir: "/logs/window1", + proxyLogDir: "/proxy-logs", + }); + const logPath = await waitForEvent(monitor.onLogFilePathChange); + + expect(logPath).toBe("/proxy-logs/999.log"); + expect(monitor.getLogFilePath()).toBe("/proxy-logs/999.log"); + }); + + it("finds log file with prefix pattern", async () => { + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 12345 ->", + "/proxy-logs/coder-ssh-999.log": "", + }); + + const monitor = createMonitor({ + codeLogDir: "/logs/window1", + proxyLogDir: "/proxy-logs", + }); + const logPath = await waitForEvent(monitor.onLogFilePathChange); + + expect(logPath).toBe("/proxy-logs/coder-ssh-999.log"); + }); + + it("returns undefined when no proxyLogDir set", async () => { + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 12345 ->", + "/proxy-logs/coder-ssh-999.log": "", // ignored + }); + + const monitor = createMonitor({ + codeLogDir: "/logs/window1", + proxyLogDir: undefined, + }); + + // Wait for process to be found + await waitForEvent(monitor.onPidChange); + + expect(monitor.getLogFilePath()).toBeUndefined(); + }); + + it("checks log files in reverse alphabetical order", async () => { + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 12345 ->", + "/proxy-logs/2024-01-01-999.log": "", + "/proxy-logs/2024-01-02-999.log": "", + "/proxy-logs/2024-01-03-999.log": "", + }); + + // Mock readdir to return files in unsorted order (simulating Windows fs) + mockReaddirOrder("/proxy-logs", [ + "2024-01-03-999.log", + "2024-01-01-999.log", + "2024-01-02-999.log", + ]); + + const monitor = createMonitor({ + codeLogDir: "/logs/window1", + proxyLogDir: "/proxy-logs", + }); + const logPath = await waitForEvent(monitor.onLogFilePathChange); + + expect(logPath).toBe("/proxy-logs/2024-01-03-999.log"); + }); + + it("sorts log files using localeCompare for consistent cross-platform ordering", async () => { + // localeCompare differs from default sort() for mixed case + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 12345 ->", + "/proxy-logs/a-999.log": "", + "/proxy-logs/Z-999.log": "", + }); + + mockReaddirOrder("/proxy-logs", ["a-999.log", "Z-999.log"]); + + const monitor = createMonitor({ + codeLogDir: "/logs/window1", + proxyLogDir: "/proxy-logs", + }); + const logPath = await waitForEvent(monitor.onLogFilePathChange); + + // With localeCompare: ["a", "Z"] -> reversed -> "Z" first + // With plain sort(): ["Z", "a"] -> reversed -> "a" first (WRONG) + expect(logPath).toBe("/proxy-logs/Z-999.log"); + }); + }); + + describe("network status", () => { + it("shows P2P connection in status bar", async () => { + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 12345 ->", + "/network/999.json": JSON.stringify({ + p2p: true, + latency: 25.5, + preferred_derp: "NYC", + derp_latency: { NYC: 10 }, + upload_bytes_sec: 1024, + download_bytes_sec: 2048, + using_coder_connect: false, + }), + }); + + createMonitor({ + codeLogDir: "/logs/window1", + networkInfoPath: "/network", + }); + await waitFor(() => statusBar.text.includes("Direct")); + + expect(statusBar.text).toContain("Direct"); + expect(statusBar.text).toContain("25.50ms"); + expect(statusBar.tooltip).toContain("peer-to-peer"); + }); + + it("shows relay connection with DERP region", async () => { + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 12345 ->", + "/network/999.json": JSON.stringify({ + p2p: false, + latency: 50, + preferred_derp: "SFO", + derp_latency: { SFO: 20, NYC: 40 }, + upload_bytes_sec: 512, + download_bytes_sec: 1024, + using_coder_connect: false, + }), + }); + + createMonitor({ + codeLogDir: "/logs/window1", + networkInfoPath: "/network", + }); + await waitFor(() => statusBar.text.includes("SFO")); + + expect(statusBar.text).toContain("SFO"); + expect(statusBar.tooltip).toContain("relay"); + }); + + it("shows Coder Connect status", async () => { + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 12345 ->", + "/network/999.json": JSON.stringify({ + p2p: false, + latency: 0, + preferred_derp: "", + derp_latency: {}, + upload_bytes_sec: 0, + download_bytes_sec: 0, + using_coder_connect: true, + }), + }); + + createMonitor({ + codeLogDir: "/logs/window1", + networkInfoPath: "/network", + }); + await waitFor(() => statusBar.text.includes("Coder Connect")); + + expect(statusBar.text).toContain("Coder Connect"); + }); + }); + + describe("dispose", () => { + it("disposes status bar", () => { + const monitor = createMonitor(); + monitor.dispose(); + + expect(statusBar.dispose).toHaveBeenCalled(); + }); + + it("stops searching for process after dispose", async () => { + // Log file exists so port can be found and find() is called + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 12345 ->", + }); + + // find() always returns empty - monitor will keep retrying + vi.mocked(find).mockResolvedValue([]); + + const monitor = createMonitor({ codeLogDir: "/logs/window1" }); + + // Let a few poll cycles run + await new Promise((r) => setTimeout(r, 30)); + const callsBeforeDispose = vi.mocked(find).mock.calls.length; + expect(callsBeforeDispose).toBeGreaterThan(0); + + monitor.dispose(); + + // Wait and verify no new calls + await new Promise((r) => setTimeout(r, 50)); + expect(vi.mocked(find).mock.calls.length).toBe(callsBeforeDispose); + }); + + it("does not fire log file event after dispose", async () => { + // Start with SSH log but no proxy log file + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 12345 ->", + }); + + const monitor = createMonitor({ + codeLogDir: "/logs/window1", + proxyLogDir: "/proxy-logs", + }); + + // Wait for PID - this starts the log file search loop + await waitForEvent(monitor.onPidChange); + + const events: string[] = []; + monitor.onLogFilePathChange(() => events.push("logPath")); + + monitor.dispose(); + + // Now add the log file that WOULD have been found + vol.fromJSON({ + "/logs/ms-vscode-remote.remote-ssh/1-Remote - SSH.log": + "-> socksPort 12345 ->", + "/proxy-logs/999.log": "", + }); + + await new Promise((r) => setTimeout(r, 50)); + expect(events).toEqual([]); + }); + + it("is idempotent - can be called multiple times", () => { + const monitor = createMonitor(); + + monitor.dispose(); + monitor.dispose(); + monitor.dispose(); + + // Should not throw, and dispose should only be called once on status bar + expect(statusBar.dispose).toHaveBeenCalledTimes(1); + }); + }); + + function createMonitor(overrides: Partial = {}) { + const monitor = SshProcessMonitor.start({ + sshHost: "coder-vscode--user--workspace", + networkInfoPath: "/network", + codeLogDir: "/logs/window1", + remoteSshExtensionId: "ms-vscode-remote.remote-ssh", + logger: createMockLogger(), + discoveryPollIntervalMs: 10, + maxDiscoveryBackoffMs: 100, + networkPollInterval: 10, + ...overrides, + }); + activeMonitors.push(monitor); + return monitor; + } +}); + +/** + * Helper to mock readdir returning files in a specific unsorted order. + * This is needed because memfs returns files in sorted order, which masks + * bugs in sorting logic. + */ +function mockReaddirOrder(dirPath: string, files: string[]): void { + const originalReaddir = fsPromises.readdir; + const mockImpl = (path: fs.PathLike): Promise => { + if (path === dirPath) { + return Promise.resolve(files); + } + return originalReaddir(path); + }; + vi.spyOn(fsPromises, "readdir").mockImplementation( + mockImpl as typeof fsPromises.readdir, + ); +} + +/** Wait for a VS Code event to fire once */ +function waitForEvent( + event: (listener: (e: T) => void) => { dispose(): void }, + timeout = 1000, +): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + disposable.dispose(); + reject(new Error(`waitForEvent timed out after ${timeout}ms`)); + }, timeout); + + const disposable = event((value) => { + clearTimeout(timer); + disposable.dispose(); + resolve(value); + }); + }); +} + +/** Poll for a condition to become true */ +async function waitFor( + condition: () => boolean, + timeout = 1000, + interval = 5, +): Promise { + const start = Date.now(); + while (!condition()) { + if (Date.now() - start > timeout) { + throw new Error(`waitFor timed out after ${timeout}ms`); + } + await new Promise((r) => setTimeout(r, interval)); + } +} diff --git a/test/unit/util.test.ts b/test/unit/util.test.ts index d508f41c..441ecd8c 100644 --- a/test/unit/util.test.ts +++ b/test/unit/util.test.ts @@ -1,96 +1,108 @@ +import os from "node:os"; import { describe, it, expect } from "vitest"; -import { countSubstring, parseRemoteAuthority, toSafeHost } from "@/util"; - -it("ignore unrelated authorities", () => { - const tests = [ - "vscode://ssh-remote+some-unrelated-host.com", - "vscode://ssh-remote+coder-vscode", - "vscode://ssh-remote+coder-vscode-test", - "vscode://ssh-remote+coder-vscode-test--foo--bar", - "vscode://ssh-remote+coder-vscode-foo--bar", - "vscode://ssh-remote+coder--foo--bar", - ]; - for (const test of tests) { - expect(parseRemoteAuthority(test)).toBe(null); - } -}); +import { + countSubstring, + escapeCommandArg, + expandPath, + findPort, + parseRemoteAuthority, + toSafeHost, +} from "@/util"; -it("should error on invalid authorities", () => { - const tests = [ - "vscode://ssh-remote+coder-vscode--foo", - "vscode://ssh-remote+coder-vscode--", - "vscode://ssh-remote+coder-vscode--foo--", - "vscode://ssh-remote+coder-vscode--foo--bar--", - ]; - for (const test of tests) { - expect(() => parseRemoteAuthority(test)).toThrow("Invalid"); - } -}); +describe("parseRemoteAuthority", () => { + it("ignore unrelated authorities", () => { + const tests = [ + "vscode://ssh-remote+some-unrelated-host.com", + "vscode://ssh-remote+coder-vscode", + "vscode://ssh-remote+coder-vscode-test", + "vscode://ssh-remote+coder-vscode-test--foo--bar", + "vscode://ssh-remote+coder-vscode-foo--bar", + "vscode://ssh-remote+coder--foo--bar", + ]; + for (const test of tests) { + expect(parseRemoteAuthority(test)).toBe(null); + } + }); -it("should parse authority", () => { - expect( - parseRemoteAuthority("vscode://ssh-remote+coder-vscode--foo--bar"), - ).toStrictEqual({ - agent: "", - host: "coder-vscode--foo--bar", - label: "", - username: "foo", - workspace: "bar", - }); - expect( - parseRemoteAuthority("vscode://ssh-remote+coder-vscode--foo--bar--baz"), - ).toStrictEqual({ - agent: "baz", - host: "coder-vscode--foo--bar--baz", - label: "", - username: "foo", - workspace: "bar", - }); - expect( - parseRemoteAuthority( - "vscode://ssh-remote+coder-vscode.dev.coder.com--foo--bar", - ), - ).toStrictEqual({ - agent: "", - host: "coder-vscode.dev.coder.com--foo--bar", - label: "dev.coder.com", - username: "foo", - workspace: "bar", - }); - expect( - parseRemoteAuthority( - "vscode://ssh-remote+coder-vscode.dev.coder.com--foo--bar--baz", - ), - ).toStrictEqual({ - agent: "baz", - host: "coder-vscode.dev.coder.com--foo--bar--baz", - label: "dev.coder.com", - username: "foo", - workspace: "bar", - }); - expect( - parseRemoteAuthority( - "vscode://ssh-remote+coder-vscode.dev.coder.com--foo--bar.baz", - ), - ).toStrictEqual({ - agent: "baz", - host: "coder-vscode.dev.coder.com--foo--bar.baz", - label: "dev.coder.com", - username: "foo", - workspace: "bar", + it("should error on invalid authorities", () => { + const tests = [ + "vscode://ssh-remote+coder-vscode--foo", + "vscode://ssh-remote+coder-vscode--", + "vscode://ssh-remote+coder-vscode--foo--", + "vscode://ssh-remote+coder-vscode--foo--bar--", + ]; + for (const test of tests) { + expect(() => parseRemoteAuthority(test)).toThrow("Invalid"); + } + }); + + it("should parse authority", () => { + expect( + parseRemoteAuthority("vscode://ssh-remote+coder-vscode--foo--bar"), + ).toStrictEqual({ + agent: "", + sshHost: "coder-vscode--foo--bar", + safeHostname: "", + username: "foo", + workspace: "bar", + }); + expect( + parseRemoteAuthority("vscode://ssh-remote+coder-vscode--foo--bar--baz"), + ).toStrictEqual({ + agent: "baz", + sshHost: "coder-vscode--foo--bar--baz", + safeHostname: "", + username: "foo", + workspace: "bar", + }); + expect( + parseRemoteAuthority( + "vscode://ssh-remote+coder-vscode.dev.coder.com--foo--bar", + ), + ).toStrictEqual({ + agent: "", + sshHost: "coder-vscode.dev.coder.com--foo--bar", + safeHostname: "dev.coder.com", + username: "foo", + workspace: "bar", + }); + expect( + parseRemoteAuthority( + "vscode://ssh-remote+coder-vscode.dev.coder.com--foo--bar--baz", + ), + ).toStrictEqual({ + agent: "baz", + sshHost: "coder-vscode.dev.coder.com--foo--bar--baz", + safeHostname: "dev.coder.com", + username: "foo", + workspace: "bar", + }); + expect( + parseRemoteAuthority( + "vscode://ssh-remote+coder-vscode.dev.coder.com--foo--bar.baz", + ), + ).toStrictEqual({ + agent: "baz", + sshHost: "coder-vscode.dev.coder.com--foo--bar.baz", + safeHostname: "dev.coder.com", + username: "foo", + workspace: "bar", + }); }); }); -it("escapes url host", () => { - expect(toSafeHost("https://foobar:8080")).toBe("foobar"); - expect(toSafeHost("https://ほげ")).toBe("xn--18j4d"); - expect(toSafeHost("https://test.😉.invalid")).toBe("test.xn--n28h.invalid"); - expect(toSafeHost("https://dev.😉-coder.com")).toBe( - "dev.xn---coder-vx74e.com", - ); - expect(() => toSafeHost("invalid url")).toThrow("Invalid URL"); - expect(toSafeHost("http://ignore-port.com:8080")).toBe("ignore-port.com"); +describe("toSafeHost", () => { + it("escapes url host", () => { + expect(toSafeHost("https://foobar:8080")).toBe("foobar"); + expect(toSafeHost("https://ほげ")).toBe("xn--18j4d"); + expect(toSafeHost("https://test.😉.invalid")).toBe("test.xn--n28h.invalid"); + expect(toSafeHost("https://dev.😉-coder.com")).toBe( + "dev.xn---coder-vx74e.com", + ); + expect(() => toSafeHost("invalid url")).toThrow("Invalid URL"); + expect(toSafeHost("http://ignore-port.com:8080")).toBe("ignore-port.com"); + }); }); describe("countSubstring", () => { @@ -124,3 +136,105 @@ describe("countSubstring", () => { expect(countSubstring("aa", "aaaaaa")).toBe(3); }); }); + +describe("escapeCommandArg", () => { + it("wraps simple string in quotes", () => { + expect(escapeCommandArg("hello")).toBe('"hello"'); + }); + + it("handles empty string", () => { + expect(escapeCommandArg("")).toBe('""'); + }); + + it("escapes double quotes", () => { + expect(escapeCommandArg('say "hello"')).toBe(String.raw`"say \"hello\""`); + }); + + it("preserves backslashes", () => { + expect(escapeCommandArg(String.raw`path\to\file`)).toBe( + String.raw`"path\to\file"`, + ); + }); + + it("handles string with spaces", () => { + expect(escapeCommandArg("hello world")).toBe('"hello world"'); + }); +}); + +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("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, + ], + [ + "windsurf/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/websocket/reconnectingWebSocket.test.ts b/test/unit/websocket/reconnectingWebSocket.test.ts new file mode 100644 index 00000000..bfdc4012 --- /dev/null +++ b/test/unit/websocket/reconnectingWebSocket.test.ts @@ -0,0 +1,663 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { WebSocketCloseCode, HttpStatusCode } from "@/websocket/codes"; +import { + ReconnectingWebSocket, + type SocketFactory, +} from "@/websocket/reconnectingWebSocket"; + +import { createMockLogger } from "../../mocks/testHelpers"; + +import type { CloseEvent, Event as WsEvent } from "ws"; + +import type { UnidirectionalStream } from "@/websocket/eventStreamConnection"; + +describe("ReconnectingWebSocket", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + describe("Reconnection Logic", () => { + it("automatically reconnects on abnormal closure (1006)", async () => { + const { ws, sockets } = await createReconnectingWebSocket(); + + sockets[0].fireOpen(); + sockets[0].fireClose({ + code: WebSocketCloseCode.ABNORMAL, + reason: "Network error", + }); + + await vi.advanceTimersByTimeAsync(300); + expect(sockets).toHaveLength(2); + + ws.close(); + }); + + it.each([ + { code: WebSocketCloseCode.NORMAL, name: "Normal Closure" }, + { code: WebSocketCloseCode.GOING_AWAY, name: "Going Away" }, + ])( + "does not reconnect on normal closure: $name ($code)", + async ({ code }) => { + const { ws, sockets } = await createReconnectingWebSocket(); + + sockets[0].fireOpen(); + sockets[0].fireClose({ code, reason: "Normal" }); + + await vi.advanceTimersByTimeAsync(10000); + expect(sockets).toHaveLength(1); + + ws.close(); + }, + ); + + it.each([ + WebSocketCloseCode.PROTOCOL_ERROR, + WebSocketCloseCode.UNSUPPORTED_DATA, + ])( + "does not reconnect on unrecoverable WebSocket close code: %i", + async (code) => { + const { ws, sockets } = await createReconnectingWebSocket(); + + sockets[0].fireOpen(); + sockets[0].fireClose({ code, reason: "Unrecoverable" }); + + await vi.advanceTimersByTimeAsync(10000); + expect(sockets).toHaveLength(1); + + ws.close(); + }, + ); + + it.each([ + HttpStatusCode.FORBIDDEN, + HttpStatusCode.GONE, + HttpStatusCode.UPGRADE_REQUIRED, + ])( + "does not reconnect on unrecoverable HTTP error during creation: %i", + async (statusCode) => { + let socketCreationAttempts = 0; + const factory = vi.fn(() => { + socketCreationAttempts++; + // Simulate HTTP error during WebSocket handshake + return Promise.reject( + new Error(`Unexpected server response: ${statusCode}`), + ); + }); + + await expect( + ReconnectingWebSocket.create(factory, createMockLogger()), + ).rejects.toThrow(`Unexpected server response: ${statusCode}`); + + // Should not retry after unrecoverable HTTP error + await vi.advanceTimersByTimeAsync(10000); + expect(socketCreationAttempts).toBe(1); + }, + ); + + it.each([ + HttpStatusCode.UNAUTHORIZED, + HttpStatusCode.FORBIDDEN, + HttpStatusCode.GONE, + ])( + "does not reconnect on unrecoverable HTTP error via error event: %i", + async (statusCode) => { + // HTTP errors during handshake fire 'error' event, then 'close' with 1006 + const { ws, sockets } = await createReconnectingWebSocket(); + + sockets[0].fireError( + new Error(`Unexpected server response: ${statusCode}`), + ); + sockets[0].fireClose({ + code: WebSocketCloseCode.ABNORMAL, + reason: "Connection failed", + }); + + // Should not reconnect - unrecoverable HTTP error + await vi.advanceTimersByTimeAsync(10000); + expect(sockets).toHaveLength(1); + + ws.close(); + }, + ); + + it("reconnect() connects immediately and cancels pending reconnections", async () => { + const { ws, sockets } = await createReconnectingWebSocket(); + + sockets[0].fireOpen(); + sockets[0].fireClose({ + code: WebSocketCloseCode.ABNORMAL, + reason: "Connection lost", + }); + + // Manual reconnect() should happen immediately and cancel the scheduled reconnect + ws.reconnect(); + expect(sockets).toHaveLength(2); + + // Verify pending reconnect was cancelled - no third socket should be created + await vi.advanceTimersByTimeAsync(1000); + expect(sockets).toHaveLength(2); + + ws.close(); + }); + + it("queues reconnect() calls made during connection", async () => { + const { ws, sockets, completeConnection } = + await createBlockingReconnectingWebSocket(); + + // Start first reconnect (will block on factory promise) + ws.reconnect(); + expect(sockets).toHaveLength(2); + // Call reconnect again while first reconnect is in progress + ws.reconnect(); + // Still only 2 sockets (queued reconnect hasn't started) + expect(sockets).toHaveLength(2); + + completeConnection(); + await Promise.resolve(); + // Now queued reconnect should have executed, creating third socket + expect(sockets).toHaveLength(3); + + ws.close(); + }); + + it("suspend() cancels pending reconnect queued during connection", async () => { + const { ws, sockets, failConnection } = + await createBlockingReconnectingWebSocket(); + + ws.reconnect(); + ws.reconnect(); // queued + expect(sockets).toHaveLength(2); + + // This should cancel the queued request + ws.disconnect(); + failConnection(new Error("No base URL")); + await Promise.resolve(); + + expect(sockets).toHaveLength(2); + await vi.advanceTimersByTimeAsync(10000); + expect(sockets).toHaveLength(2); + + ws.close(); + }); + + it("disconnect() during pending connection closes socket when factory resolves", async () => { + const { ws, sockets, completeConnection } = + await createBlockingReconnectingWebSocket(); + + // Start reconnect (will block on factory promise) + ws.reconnect(); + expect(sockets).toHaveLength(2); + + // Disconnect while factory is still pending + ws.disconnect(); + + completeConnection(); + await Promise.resolve(); + + expect(sockets[1].close).toHaveBeenCalledWith( + WebSocketCloseCode.NORMAL, + "Cancelled during connection", + ); + + // No reconnection should happen + await vi.advanceTimersByTimeAsync(10000); + expect(sockets).toHaveLength(2); + + ws.close(); + }); + }); + + describe("Event Handlers", () => { + it("persists event handlers across reconnections", async () => { + const { ws, sockets } = await createReconnectingWebSocket(); + sockets[0].fireOpen(); + + const handler = vi.fn(); + ws.addEventListener("message", handler); + + // First message + sockets[0].fireMessage({ test: true }); + expect(handler).toHaveBeenCalledTimes(1); + + // Disconnect and reconnect + sockets[0].fireClose({ + code: WebSocketCloseCode.ABNORMAL, + reason: "Network", + }); + await vi.advanceTimersByTimeAsync(300); + expect(sockets).toHaveLength(2); + sockets[1].fireOpen(); + + // Handler should still work on new socket + sockets[1].fireMessage({ test: true }); + expect(handler).toHaveBeenCalledTimes(2); + + ws.close(); + }); + + it("removes event handlers when removeEventListener is called", async () => { + const socket = createMockSocket(); + const factory = vi.fn(() => Promise.resolve(socket)); + + const ws = await fromFactory(factory); + socket.fireOpen(); + + const handler1 = vi.fn(); + const handler2 = vi.fn(); + + ws.addEventListener("message", handler1); + ws.addEventListener("message", handler2); + ws.removeEventListener("message", handler1); + + socket.fireMessage({ test: true }); + + expect(handler1).not.toHaveBeenCalled(); + expect(handler2).toHaveBeenCalledTimes(1); + + ws.close(); + }); + + it("preserves event handlers after suspend() and reconnect()", async () => { + const { ws, sockets } = await createReconnectingWebSocket(); + sockets[0].fireOpen(); + + const handler = vi.fn(); + ws.addEventListener("message", handler); + sockets[0].fireMessage({ test: 1 }); + expect(handler).toHaveBeenCalledTimes(1); + + // Suspend the socket + ws.disconnect(); + + // Reconnect (async operation) + ws.reconnect(); + await Promise.resolve(); // Wait for async connect() + expect(sockets).toHaveLength(2); + sockets[1].fireOpen(); + + // Handler should still work after suspend/reconnect + sockets[1].fireMessage({ test: 2 }); + expect(handler).toHaveBeenCalledTimes(2); + + ws.close(); + }); + + it("clears event handlers after close()", async () => { + const { ws, sockets } = await createReconnectingWebSocket(); + sockets[0].fireOpen(); + + const handler = vi.fn(); + ws.addEventListener("message", handler); + sockets[0].fireMessage({ test: 1 }); + expect(handler).toHaveBeenCalledTimes(1); + + // Close permanently + ws.close(); + + // Even if we could reconnect (we can't), handlers would be cleared + // Verify handler was removed by checking it's no longer in the set + // We can't easily test this without exposing internals, but close() clears handlers + }); + }); + + describe("close() and Disposal", () => { + it("stops reconnection when close() is called", async () => { + const { ws, sockets } = await createReconnectingWebSocket(); + + sockets[0].fireOpen(); + sockets[0].fireClose({ + code: WebSocketCloseCode.ABNORMAL, + reason: "Network", + }); + ws.close(); + + await vi.advanceTimersByTimeAsync(10000); + expect(sockets).toHaveLength(1); + }); + + it("closes the underlying socket with provided code and reason", async () => { + const socket = createMockSocket(); + const factory = vi.fn(() => Promise.resolve(socket)); + const ws = await fromFactory(factory); + + socket.fireOpen(); + ws.close(WebSocketCloseCode.NORMAL, "Test close"); + + expect(socket.close).toHaveBeenCalledWith( + WebSocketCloseCode.NORMAL, + "Test close", + ); + }); + + it("calls onDispose callback once, even with multiple close() calls", async () => { + let disposeCount = 0; + const { ws } = await createReconnectingWebSocket(() => ++disposeCount); + + ws.close(); + ws.close(); + ws.close(); + + expect(disposeCount).toBe(1); + }); + + it("suspends (not disposes) on unrecoverable WebSocket close code", async () => { + let disposeCount = 0; + const { ws, sockets } = await createReconnectingWebSocket( + () => ++disposeCount, + ); + + sockets[0].fireOpen(); + sockets[0].fireClose({ + code: WebSocketCloseCode.PROTOCOL_ERROR, + reason: "Protocol error", + }); + + // Should suspend, not dispose - allows recovery when credentials change + expect(disposeCount).toBe(0); + + // Should be able to reconnect after suspension + ws.reconnect(); + expect(sockets).toHaveLength(2); + + ws.close(); + }); + + it("does not call onDispose callback during reconnection", async () => { + let disposeCount = 0; + const { ws, sockets } = await createReconnectingWebSocket( + () => ++disposeCount, + ); + + sockets[0].fireOpen(); + sockets[0].fireClose({ + code: WebSocketCloseCode.ABNORMAL, + reason: "Network error", + }); + + await vi.advanceTimersByTimeAsync(300); + expect(disposeCount).toBe(0); + + ws.close(); + expect(disposeCount).toBe(1); + }); + + it("reconnect() resumes suspended socket after HTTP 403 error", async () => { + const { ws, sockets, setFactoryError } = + await createReconnectingWebSocketWithErrorControl(); + sockets[0].fireOpen(); + + // Trigger reconnect that will fail with 403 + setFactoryError( + new Error(`Unexpected server response: ${HttpStatusCode.FORBIDDEN}`), + ); + ws.reconnect(); + await Promise.resolve(); + + // Socket should be suspended - no automatic reconnection + await vi.advanceTimersByTimeAsync(10000); + expect(sockets).toHaveLength(1); + + // reconnect() should resume the suspended socket + setFactoryError(null); + ws.reconnect(); + await Promise.resolve(); + expect(sockets).toHaveLength(2); + + ws.close(); + }); + + it("reconnect() does nothing after close()", async () => { + const { ws, sockets } = await createReconnectingWebSocket(); + sockets[0].fireOpen(); + + ws.close(); + ws.reconnect(); + + expect(sockets).toHaveLength(1); + }); + }); + + describe("Backoff Strategy", () => { + it("doubles backoff delay after each failed connection", async () => { + const { ws, sockets } = await createReconnectingWebSocket(); + const socket = sockets[0]; + socket.fireOpen(); + + const backoffDelays = [300, 600, 1200, 2400]; + + // Fail repeatedly + for (let i = 0; i < 4; i++) { + const currentSocket = sockets[i]; + currentSocket.fireClose({ + code: WebSocketCloseCode.ABNORMAL, + reason: "Fail", + }); + const delay = backoffDelays[i]; + await vi.advanceTimersByTimeAsync(delay); + const nextSocket = sockets[i + 1]; + nextSocket.fireOpen(); + } + + expect(sockets).toHaveLength(5); + ws.close(); + }); + + it("resets backoff delay after successful connection", async () => { + const { ws, sockets } = await createReconnectingWebSocket(); + const socket1 = sockets[0]; + socket1.fireOpen(); + + // First disconnect + socket1.fireClose({ code: WebSocketCloseCode.ABNORMAL, reason: "Fail" }); + await vi.advanceTimersByTimeAsync(300); + const socket2 = sockets[1]; + socket2.fireOpen(); + + // Second disconnect - should use initial backoff again + socket2.fireClose({ code: WebSocketCloseCode.ABNORMAL, reason: "Fail" }); + await vi.advanceTimersByTimeAsync(300); + + expect(sockets).toHaveLength(3); + ws.close(); + }); + }); + + describe("Error Handling", () => { + it("schedules retry when socket factory throws error", async () => { + const sockets: MockSocket[] = []; + let shouldFail = false; + const factory = vi.fn(() => { + if (shouldFail) { + return Promise.reject(new Error("Factory failed")); + } + const socket = createMockSocket(); + sockets.push(socket); + return Promise.resolve(socket); + }); + const ws = await fromFactory(factory); + + sockets[0].fireOpen(); + + shouldFail = true; + sockets[0].fireClose({ + code: WebSocketCloseCode.ABNORMAL, + reason: "Network", + }); + + await vi.advanceTimersByTimeAsync(300); + expect(sockets).toHaveLength(1); + + ws.close(); + }); + }); +}); + +type MockSocket = UnidirectionalStream & { + fireOpen: () => void; + fireClose: (event: { code: number; reason: string }) => void; + fireMessage: (data: unknown) => void; + fireError: (error: Error) => void; +}; + +function createMockSocket(): MockSocket { + const listeners: { + open: Set<(event: WsEvent) => void>; + close: Set<(event: CloseEvent) => void>; + error: Set<(event: { error?: Error; message?: string }) => void>; + message: Set<(event: unknown) => void>; + } = { + open: new Set(), + close: new Set(), + error: new Set(), + message: new Set(), + }; + + return { + url: "ws://test.example.com/api/test", + addEventListener: vi.fn( + (event: keyof typeof listeners, callback: unknown) => { + (listeners[event] as Set<(data: unknown) => void>).add( + callback as (data: unknown) => void, + ); + }, + ), + removeEventListener: vi.fn( + (event: keyof typeof listeners, callback: unknown) => { + (listeners[event] as Set<(data: unknown) => void>).delete( + callback as (data: unknown) => void, + ); + }, + ), + close: vi.fn(), + fireOpen: () => { + for (const cb of listeners.open) { + cb({} as WsEvent); + } + }, + fireClose: (event: { code: number; reason: string }) => { + for (const cb of listeners.close) { + cb({ + code: event.code, + reason: event.reason, + wasClean: event.code === WebSocketCloseCode.NORMAL, + } as CloseEvent); + } + }, + fireMessage: (data: unknown) => { + for (const cb of listeners.message) { + cb({ + sourceEvent: { data }, + parsedMessage: data, + parseError: undefined, + }); + } + }, + fireError: (error: Error) => { + for (const cb of listeners.error) { + cb({ error, message: error.message }); + } + }, + }; +} + +async function createReconnectingWebSocket(onDispose?: () => void): Promise<{ + ws: ReconnectingWebSocket; + sockets: MockSocket[]; +}> { + const sockets: MockSocket[] = []; + const factory = vi.fn(() => { + const socket = createMockSocket(); + sockets.push(socket); + return Promise.resolve(socket); + }); + const ws = await fromFactory(factory, onDispose); + + // We start with one socket + expect(sockets).toHaveLength(1); + + return { ws, sockets }; +} + +async function createReconnectingWebSocketWithErrorControl(): Promise<{ + ws: ReconnectingWebSocket; + sockets: MockSocket[]; + setFactoryError: (error: Error | null) => void; +}> { + const sockets: MockSocket[] = []; + let factoryError: Error | null = null; + + const factory = vi.fn(() => { + if (factoryError) { + return Promise.reject(factoryError); + } + const socket = createMockSocket(); + sockets.push(socket); + return Promise.resolve(socket); + }); + + const ws = await fromFactory(factory); + expect(sockets).toHaveLength(1); + + return { + ws, + sockets, + setFactoryError: (error: Error | null) => { + factoryError = error; + }, + }; +} + +async function fromFactory( + factory: SocketFactory, + onDispose?: () => void, +): Promise> { + return await ReconnectingWebSocket.create( + factory, + createMockLogger(), + undefined, + onDispose, + ); +} + +async function createBlockingReconnectingWebSocket(): Promise<{ + ws: ReconnectingWebSocket; + sockets: MockSocket[]; + completeConnection: () => void; + failConnection: (error: Error) => void; +}> { + const sockets: MockSocket[] = []; + let pendingResolve: ((socket: MockSocket) => void) | null = null; + let pendingReject: ((error: Error) => void) | null = null; + + const factory = vi.fn(() => { + const socket = createMockSocket(); + sockets.push(socket); + if (sockets.length === 1) { + return Promise.resolve(socket); + } + return new Promise((resolve, reject) => { + pendingResolve = resolve; + pendingReject = reject; + }); + }); + + const ws = await fromFactory(factory); + sockets[0].fireOpen(); + + return { + ws, + sockets, + completeConnection: () => { + const socket = sockets.at(-1)!; + pendingResolve?.(socket); + socket.fireOpen(); + }, + failConnection: (error: Error) => pendingReject?.(error), + }; +} diff --git a/test/unit/websocket/sseConnection.test.ts b/test/unit/websocket/sseConnection.test.ts new file mode 100644 index 00000000..378e6f54 --- /dev/null +++ b/test/unit/websocket/sseConnection.test.ts @@ -0,0 +1,364 @@ +import axios, { type AxiosInstance } from "axios"; +import { type ServerSentEvent } from "coder/site/src/api/typesGenerated"; +import { type WebSocketEventType } from "coder/site/src/utils/OneWayWebSocket"; +import { EventSource } from "eventsource"; +import { describe, it, expect, vi } from "vitest"; + +import { type Logger } from "@/logging/logger"; +import { WebSocketCloseCode } from "@/websocket/codes"; +import { + type ParsedMessageEvent, + type CloseEvent, + type ErrorEvent, +} from "@/websocket/eventStreamConnection"; +import { SseConnection } from "@/websocket/sseConnection"; + +import { createMockLogger } from "../../mocks/testHelpers"; + +const TEST_URL = "https://coder.example.com"; +const API_ROUTE = "/api/v2/workspaces/123/watch"; + +vi.mock("eventsource"); +vi.mock("axios"); + +vi.mock("@/api/streamingFetchAdapter", () => ({ + createStreamingFetchAdapter: vi.fn(() => fetch), +})); + +describe("SseConnection", () => { + describe("URL Building", () => { + type UrlBuildingTestCase = [ + searchParams: Record | URLSearchParams | undefined, + expectedUrl: string, + ]; + it.each([ + [undefined, `${TEST_URL}${API_ROUTE}`], + [ + { follow: "true", after: "123" }, + `${TEST_URL}${API_ROUTE}?follow=true&after=123`, + ], + [new URLSearchParams({ foo: "bar" }), `${TEST_URL}${API_ROUTE}?foo=bar`], + ])("constructs URL with %s search params", (searchParams, expectedUrl) => { + const mockAxios = axios.create(); + const mockLogger = createMockLogger(); + const mockES = createMockEventSource(); + setupEventSourceMock(mockES); + + const connection = new SseConnection({ + location: { protocol: "https:", host: "coder.example.com" }, + apiRoute: API_ROUTE, + searchParams, + axiosInstance: mockAxios, + logger: mockLogger, + }); + expect(connection.url).toBe(expectedUrl); + }); + }); + + describe("Event Handling", () => { + it("fires open event and supports multiple listeners", async () => { + const mockES = createMockEventSource({ + addEventListener: vi.fn((event, handler) => { + if (event === "open") { + setImmediate(() => handler(new Event("open"))); + } + }), + }); + setupEventSourceMock(mockES); + + const mockAxios = axios.create(); + const mockLogger = createMockLogger(); + const connection = createConnection(mockAxios, mockLogger); + const events1: object[] = []; + const events2: object[] = []; + connection.addEventListener("open", (event) => events1.push(event)); + connection.addEventListener("open", (event) => events2.push(event)); + + await waitForNextTick(); + expect(events1).toEqual([{}]); + expect(events2).toEqual([{}]); + }); + + it("fires message event with parsed JSON and handles parse errors", async () => { + const testData = { type: "data", workspace: { status: "running" } }; + const mockES = createMockEventSource({ + addEventListener: vi.fn((event, handler) => { + if (event === "data") { + setImmediate(() => { + // Send valid JSON + handler( + new MessageEvent("data", { data: JSON.stringify(testData) }), + ); + // Send invalid JSON + handler(new MessageEvent("data", { data: "not-valid-json" })); + }); + } + }), + }); + setupEventSourceMock(mockES); + + const mockAxios = axios.create(); + const mockLogger = createMockLogger(); + const connection = createConnection(mockAxios, mockLogger); + const events: ParsedMessageEvent[] = []; + connection.addEventListener("message", (event) => events.push(event)); + + await waitForNextTick(); + expect(events).toEqual([ + { + sourceEvent: { data: JSON.stringify(testData) }, + parsedMessage: { type: "data", data: testData }, + parseError: undefined, + }, + { + sourceEvent: { data: "not-valid-json" }, + parsedMessage: undefined, + parseError: expect.any(Error), + }, + ]); + }); + + it("fires error event when connection fails", async () => { + const mockES = createMockEventSource({ + addEventListener: vi.fn((event, handler) => { + if (event === "error") { + const error = { + message: "Connection failed", + error: new Error("Network error"), + }; + setImmediate(() => handler(error)); + } + }), + }); + setupEventSourceMock(mockES); + + const mockAxios = axios.create(); + const mockLogger = createMockLogger(); + const connection = createConnection(mockAxios, mockLogger); + const events: ErrorEvent[] = []; + connection.addEventListener("error", (event) => events.push(event)); + + await waitForNextTick(); + expect(events).toEqual([ + { + error: expect.any(Error), + message: "Connection failed", + }, + ]); + }); + + it("fires close event when connection closes on error", async () => { + const mockES = createMockEventSource({ + addEventListener: vi.fn((event, handler) => { + if (event === "error") { + setImmediate(() => { + // A bit hacky but readyState is a readonly property so we have to override that here + const esWithReadyState = mockES as { readyState: number }; + // Simulate EventSource behavior: state transitions to CLOSED when error occurs + esWithReadyState.readyState = EventSource.CLOSED; + handler(new Event("error")); + }); + } + }), + }); + setupEventSourceMock(mockES); + + const mockAxios = axios.create(); + const mockLogger = createMockLogger(); + const connection = createConnection(mockAxios, mockLogger); + const events: CloseEvent[] = []; + connection.addEventListener("close", (event) => events.push(event)); + + await waitForNextTick(); + expect(events).toEqual([ + { + code: WebSocketCloseCode.ABNORMAL, + reason: "Connection lost", + wasClean: false, + }, + ]); + }); + }); + + describe("Event Listener Management", () => { + it("removes event listener without affecting others", async () => { + const data = '{"test": true}'; + const mockES = createMockEventSource({ + addEventListener: vi.fn((event, handler) => { + if (event === "data") { + setImmediate(() => handler(new MessageEvent("data", { data }))); + } + }), + }); + setupEventSourceMock(mockES); + + const mockAxios = axios.create(); + const mockLogger = createMockLogger(); + const connection = createConnection(mockAxios, mockLogger); + const events: ParsedMessageEvent[] = []; + + const removedHandler = () => { + throw new Error("Removed handler should not have been called!"); + }; + const keptHandler = (event: ParsedMessageEvent) => + events.push(event); + + connection.addEventListener("message", removedHandler); + connection.addEventListener("message", keptHandler); + connection.removeEventListener("message", removedHandler); + + await waitForNextTick(); + // One message event + expect(events).toEqual([ + { + parseError: undefined, + parsedMessage: { + data: JSON.parse(data), + type: "data", + }, + sourceEvent: { data }, + }, + ]); + expect(mockLogger.error).not.toHaveBeenCalled(); + }); + }); + + describe("Close Handling", () => { + type CloseHandlingTestCase = [ + code: number | undefined, + reason: string | undefined, + closeEvent: CloseEvent, + ]; + it.each([ + [ + undefined, + undefined, + { + code: WebSocketCloseCode.NORMAL, + reason: "Normal closure", + wasClean: true, + }, + ], + [ + 4000, + "Custom close", + { code: 4000, reason: "Custom close", wasClean: true }, + ], + ])( + "closes EventSource with code '%s' and reason '%s'", + (code, reason, closeEvent) => { + const mockES = createMockEventSource(); + setupEventSourceMock(mockES); + + const mockAxios = axios.create(); + const mockLogger = createMockLogger(); + const connection = createConnection(mockAxios, mockLogger); + const events: CloseEvent[] = []; + connection.addEventListener("close", (event) => events.push(event)); + connection.addEventListener("open", () => {}); + + connection.close(code, reason); + expect(mockES.close).toHaveBeenCalled(); + expect(events).toEqual([closeEvent]); + }, + ); + }); + + describe("Callback Error Handling", () => { + type CallbackErrorTestCase = [ + sseEvent: WebSocketEventType, + eventData: Event | MessageEvent, + ]; + it.each([ + ["open", new Event("open")], + ["message", new MessageEvent("data", { data: '{"test": true}' })], + ["error", new Event("error")], + ])( + "logs error and continues when %s callback throws", + async (sseEvent, eventData) => { + const mockES = createMockEventSource({ + addEventListener: vi.fn((event, handler) => { + // All SSE events are streaming data and attach a listener on the "data" type in the EventSource + const esEvent = sseEvent === "message" ? "data" : sseEvent; + if (event === esEvent) { + setImmediate(() => handler(eventData)); + } + }), + }); + setupEventSourceMock(mockES); + + const mockAxios = axios.create(); + const mockLogger = createMockLogger(); + const connection = createConnection(mockAxios, mockLogger); + const events: unknown[] = []; + + connection.addEventListener(sseEvent, () => { + throw new Error("Handler error"); + }); + connection.addEventListener(sseEvent, (event: unknown) => + events.push(event), + ); + + await waitForNextTick(); + expect(events).toHaveLength(1); + expect(mockLogger.error).toHaveBeenCalledWith( + `Error in SSE ${sseEvent} callback:`, + expect.any(Error), + ); + }, + ); + + it("completes cleanup when close callback throws", () => { + const mockES = createMockEventSource(); + setupEventSourceMock(mockES); + + const mockAxios = axios.create(); + const mockLogger = createMockLogger(); + const connection = createConnection(mockAxios, mockLogger); + connection.addEventListener("close", () => { + throw new Error("Handler error"); + }); + + connection.close(); + + expect(mockES.close).toHaveBeenCalled(); + expect(mockLogger.error).toHaveBeenCalledWith( + "Error in SSE close callback:", + expect.any(Error), + ); + }); + }); +}); + +function createConnection( + mockAxios: AxiosInstance, + mockLogger: Logger, +): SseConnection { + return new SseConnection({ + location: { protocol: "https:", host: "coder.example.com" }, + apiRoute: API_ROUTE, + axiosInstance: mockAxios, + logger: mockLogger, + }); +} + +function createMockEventSource( + overrides?: Partial, +): Partial { + return { + url: `${TEST_URL}${API_ROUTE}`, + readyState: EventSource.CONNECTING, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + close: vi.fn(), + ...overrides, + }; +} + +function setupEventSourceMock(es: Partial): void { + vi.mocked(EventSource).mockImplementation(() => es as EventSource); +} + +function waitForNextTick(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} diff --git a/test/utils/platform.test.ts b/test/utils/platform.test.ts new file mode 100644 index 00000000..c04820d6 --- /dev/null +++ b/test/utils/platform.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; + +import { + expectPathsEqual, + exitCommand, + printCommand, + printEnvCommand, + isWindows, +} from "./platform"; + +describe("platform utils", () => { + describe("printCommand", () => { + it("should generate a simple node command", () => { + const result = printCommand("hello world"); + expect(result).toBe("node -e \"process.stdout.write('hello world')\""); + }); + + it("should escape special characters", () => { + const result = printCommand('path\\to\\file\'s "name"\nline2\rcarriage'); + expect(result).toBe( + 'node -e "process.stdout.write(\'path\\\\to\\\\file\\\'s \\"name\\"\\nline2\\rcarriage\')"', + ); + }); + }); + + describe("exitCommand", () => { + it("should generate node commands with various exit codes", () => { + expect(exitCommand(0)).toBe('node -e "process.exit(0)"'); + expect(exitCommand(1)).toBe('node -e "process.exit(1)"'); + expect(exitCommand(42)).toBe('node -e "process.exit(42)"'); + expect(exitCommand(-1)).toBe('node -e "process.exit(-1)"'); + }); + }); + + describe("printEnvCommand", () => { + it("should generate node commands that print env variables", () => { + expect(printEnvCommand("url", "CODER_URL")).toBe( + "node -e \"process.stdout.write('url=' + process.env.CODER_URL)\"", + ); + expect(printEnvCommand("token", "CODER_TOKEN")).toBe( + "node -e \"process.stdout.write('token=' + process.env.CODER_TOKEN)\"", + ); + // Will fail to execute but that's fine + expect(printEnvCommand("", "")).toBe( + "node -e \"process.stdout.write('=' + process.env.)\"", + ); + }); + }); + + describe("expectPathsEqual", () => { + it("should consider identical paths equal", () => { + expectPathsEqual("same/path", "same/path"); + }); + + it("should throw when paths are different", () => { + expect(() => + expectPathsEqual("path/to/file1", "path/to/file2"), + ).toThrow(); + }); + + it("should handle empty paths", () => { + expectPathsEqual("", ""); + }); + + it.runIf(isWindows())( + "should consider paths with different separators equal on Windows", + () => { + expectPathsEqual("path/to/file", "path\\to\\file"); + expectPathsEqual("C:/path/to/file", "C:\\path\\to\\file"); + expectPathsEqual( + "C:/path with spaces/file", + "C:\\path with spaces\\file", + ); + }, + ); + + it.skipIf(isWindows())( + "should consider backslash as literal on non-Windows", + () => { + expect(() => + expectPathsEqual("path/to/file", "path\\to\\file"), + ).toThrow(); + }, + ); + }); +}); diff --git a/test/utils/platform.ts b/test/utils/platform.ts new file mode 100644 index 00000000..b0abc660 --- /dev/null +++ b/test/utils/platform.ts @@ -0,0 +1,46 @@ +import os from "node:os"; +import path from "node:path"; +import { expect } from "vitest"; + +export function isWindows(): boolean { + return os.platform() === "win32"; +} + +/** + * Returns a platform-independent command that outputs the given text. + * Uses Node.js which is guaranteed to be available during tests. + */ +export function printCommand(output: string): string { + const escaped = output + .replace(/\\/g, "\\\\") // Escape backslashes first + .replace(/'/g, "\\'") // Escape single quotes + .replace(/"/g, '\\"') // Escape double quotes + .replace(/\r/g, "\\r") // Preserve carriage returns + .replace(/\n/g, "\\n"); // Preserve newlines + + return `node -e "process.stdout.write('${escaped}')"`; +} + +/** + * Returns a platform-independent command that exits with the given code. + */ +export function exitCommand(code: number): string { + return `node -e "process.exit(${code})"`; +} + +/** + * Returns a platform-independent command that prints an environment variable. + * @param key The key for the header (e.g., "url" to output "url=value") + * @param varName The environment variable name to access + */ +export function printEnvCommand(key: string, varName: string): string { + return `node -e "process.stdout.write('${key}=' + process.env.${varName})"`; +} + +export function expectPathsEqual(actual: string, expected: string) { + expect(normalizePath(actual)).toBe(normalizePath(expected)); +} + +function normalizePath(p: string): string { + return p.replaceAll(path.sep, path.posix.sep); +} diff --git a/vitest.config.ts b/vitest.config.ts index 01e3896a..a3fcd089 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,17 +1,12 @@ -import path from "path"; +import path from "node:path"; import { defineConfig } from "vitest/config"; export default defineConfig({ test: { globals: true, environment: "node", - include: ["test/unit/**/*.test.ts", "test/integration/**/*.test.ts"], - exclude: [ - "test/integration/**", - "**/node_modules/**", - "**/out/**", - "**/*.d.ts", - ], + include: ["test/unit/**/*.test.ts", "test/utils/**/*.test.ts"], + exclude: ["**/node_modules/**", "**/out/**", "**/*.d.ts"], pool: "threads", fileParallelism: true, coverage: { diff --git a/yarn.lock b/yarn.lock index a067635f..03de16e6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -326,12 +326,7 @@ "@babel/helper-string-parser" "^7.25.9" "@babel/helper-validator-identifier" "^7.25.9" -"@bcoe/v8-coverage@^0.2.3": - version "0.2.3" - resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" - integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== - -"@bcoe/v8-coverage@^1.0.2": +"@bcoe/v8-coverage@^1.0.1", "@bcoe/v8-coverage@^1.0.2": version "1.0.2" resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz#bbe12dca5b4ef983a0d0af4b07b9bc90ea0ababa" integrity sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA== @@ -341,6 +336,21 @@ resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz#f13c7c205915eb91ae54c557f5e92bddd8be0e83" integrity sha512-4B4OijXeVNOPZlYA2oEwWOTkzyltLao+xbotHQeqN++Rv27Y6s818+n2Qkp8q+Fxhn0t/5lA5X1Mxktud8eayQ== +"@electron/get@^2.0.0": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@electron/get/-/get-2.0.3.tgz#fba552683d387aebd9f3fcadbcafc8e12ee4f960" + integrity sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ== + dependencies: + debug "^4.1.1" + env-paths "^2.2.0" + fs-extra "^8.1.0" + got "^11.8.5" + progress "^2.0.3" + semver "^6.2.0" + sumchecker "^3.0.1" + optionalDependencies: + global-agent "^3.0.0" + "@emnapi/core@^1.4.3": version "1.5.0" resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.5.0.tgz#85cd84537ec989cebb2343606a1ee663ce4edaf0" @@ -743,6 +753,129 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@peculiar/asn1-cms@^2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-cms/-/asn1-cms-2.6.0.tgz#88267055c460ca806651f916315a934c1b1ac994" + integrity sha512-2uZqP+ggSncESeUF/9Su8rWqGclEfEiz1SyU02WX5fUONFfkjzS2Z/F1Li0ofSmf4JqYXIOdCAZqIXAIBAT1OA== + dependencies: + "@peculiar/asn1-schema" "^2.6.0" + "@peculiar/asn1-x509" "^2.6.0" + "@peculiar/asn1-x509-attr" "^2.6.0" + asn1js "^3.0.6" + tslib "^2.8.1" + +"@peculiar/asn1-csr@^2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-csr/-/asn1-csr-2.6.0.tgz#a7eff845b0020720070a12f38f26effb9fdab158" + integrity sha512-BeWIu5VpTIhfRysfEp73SGbwjjoLL/JWXhJ/9mo4vXnz3tRGm+NGm3KNcRzQ9VMVqwYS2RHlolz21svzRXIHPQ== + dependencies: + "@peculiar/asn1-schema" "^2.6.0" + "@peculiar/asn1-x509" "^2.6.0" + asn1js "^3.0.6" + tslib "^2.8.1" + +"@peculiar/asn1-ecc@^2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-ecc/-/asn1-ecc-2.6.0.tgz#4846d39712a1a2b4786c2d6ea27b19a6dcc05ef5" + integrity sha512-FF3LMGq6SfAOwUG2sKpPXblibn6XnEIKa+SryvUl5Pik+WR9rmRA3OCiwz8R3lVXnYnyRkSZsSLdml8H3UiOcw== + dependencies: + "@peculiar/asn1-schema" "^2.6.0" + "@peculiar/asn1-x509" "^2.6.0" + asn1js "^3.0.6" + tslib "^2.8.1" + +"@peculiar/asn1-pfx@^2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-pfx/-/asn1-pfx-2.6.0.tgz#4c8ed3050cdd5b3e63ec4192bf8f646d9e06e3f5" + integrity sha512-rtUvtf+tyKGgokHHmZzeUojRZJYPxoD/jaN1+VAB4kKR7tXrnDCA/RAWXAIhMJJC+7W27IIRGe9djvxKgsldCQ== + dependencies: + "@peculiar/asn1-cms" "^2.6.0" + "@peculiar/asn1-pkcs8" "^2.6.0" + "@peculiar/asn1-rsa" "^2.6.0" + "@peculiar/asn1-schema" "^2.6.0" + asn1js "^3.0.6" + tslib "^2.8.1" + +"@peculiar/asn1-pkcs8@^2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.6.0.tgz#c426caf81cb49935c553b591e0273b4b44d1696f" + integrity sha512-KyQ4D8G/NrS7Fw3XCJrngxmjwO/3htnA0lL9gDICvEQ+GJ+EPFqldcJQTwPIdvx98Tua+WjkdKHSC0/Km7T+lA== + dependencies: + "@peculiar/asn1-schema" "^2.6.0" + "@peculiar/asn1-x509" "^2.6.0" + asn1js "^3.0.6" + tslib "^2.8.1" + +"@peculiar/asn1-pkcs9@^2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.6.0.tgz#96b57122228a0e2e30e81118cd3baa570c13a51d" + integrity sha512-b78OQ6OciW0aqZxdzliXGYHASeCvvw5caqidbpQRYW2mBtXIX2WhofNXTEe7NyxTb0P6J62kAAWLwn0HuMF1Fw== + dependencies: + "@peculiar/asn1-cms" "^2.6.0" + "@peculiar/asn1-pfx" "^2.6.0" + "@peculiar/asn1-pkcs8" "^2.6.0" + "@peculiar/asn1-schema" "^2.6.0" + "@peculiar/asn1-x509" "^2.6.0" + "@peculiar/asn1-x509-attr" "^2.6.0" + asn1js "^3.0.6" + tslib "^2.8.1" + +"@peculiar/asn1-rsa@^2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-rsa/-/asn1-rsa-2.6.0.tgz#49d905ab67ae8aa54e996734f37a391bb7958747" + integrity sha512-Nu4C19tsrTsCp9fDrH+sdcOKoVfdfoQQ7S3VqjJU6vedR7tY3RLkQ5oguOIB3zFW33USDUuYZnPEQYySlgha4w== + dependencies: + "@peculiar/asn1-schema" "^2.6.0" + "@peculiar/asn1-x509" "^2.6.0" + asn1js "^3.0.6" + tslib "^2.8.1" + +"@peculiar/asn1-schema@^2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-schema/-/asn1-schema-2.6.0.tgz#0dca1601d5b0fed2a72fed7a5f1d0d7dbe3a6f82" + integrity sha512-xNLYLBFTBKkCzEZIw842BxytQQATQv+lDTCEMZ8C196iJcJJMBUZxrhSTxLaohMyKK8QlzRNTRkUmanucnDSqg== + dependencies: + asn1js "^3.0.6" + pvtsutils "^1.3.6" + tslib "^2.8.1" + +"@peculiar/asn1-x509-attr@^2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.6.0.tgz#057cb0c3c600a259c9f40582ee5fd7f0114c5be6" + integrity sha512-MuIAXFX3/dc8gmoZBkwJWxUWOSvG4MMDntXhrOZpJVMkYX+MYc/rUAU2uJOved9iJEoiUx7//3D8oG83a78UJA== + dependencies: + "@peculiar/asn1-schema" "^2.6.0" + "@peculiar/asn1-x509" "^2.6.0" + asn1js "^3.0.6" + tslib "^2.8.1" + +"@peculiar/asn1-x509@^2.6.0": + version "2.6.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-x509/-/asn1-x509-2.6.0.tgz#9aa0784b455ca34095fdc91a5cc52869e21528dd" + integrity sha512-uzYbPEpoQiBoTq0/+jZtpM6Gq6zADBx+JNFP3yqRgziWBxQ/Dt/HcuvRfm9zJTPdRcBqPNdaRHTVwpyiq6iNMA== + dependencies: + "@peculiar/asn1-schema" "^2.6.0" + asn1js "^3.0.6" + pvtsutils "^1.3.6" + tslib "^2.8.1" + +"@peculiar/x509@^1.14.2": + version "1.14.2" + resolved "https://registry.yarnpkg.com/@peculiar/x509/-/x509-1.14.2.tgz#635078480a0e4796eab2fb765361dec142af0f3b" + integrity sha512-r2w1Hg6pODDs0zfAKHkSS5HLkOLSeburtcgwvlLLWWCixw+MmW3U6kD5ddyvc2Y2YdbGuVwCF2S2ASoU1cFAag== + dependencies: + "@peculiar/asn1-cms" "^2.6.0" + "@peculiar/asn1-csr" "^2.6.0" + "@peculiar/asn1-ecc" "^2.6.0" + "@peculiar/asn1-pkcs9" "^2.6.0" + "@peculiar/asn1-rsa" "^2.6.0" + "@peculiar/asn1-schema" "^2.6.0" + "@peculiar/asn1-x509" "^2.6.0" + pvtsutils "^1.3.6" + reflect-metadata "^0.2.2" + tslib "^2.8.1" + tsyringe "^4.10.0" + "@pkgjs/parseargs@^0.11.0": version "0.11.0" resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" @@ -863,42 +996,42 @@ resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8" integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g== -"@secretlint/config-creator@^10.2.1": - version "10.2.1" - resolved "https://registry.yarnpkg.com/@secretlint/config-creator/-/config-creator-10.2.1.tgz#867c88741f8cb22988708919e480330e5fa66a44" - integrity sha512-nyuRy8uo2+mXPIRLJ93wizD1HbcdDIsVfgCT01p/zGVFrtvmiL7wqsl4KgZH0QFBM/KRLDLeog3/eaM5ASjtvw== +"@secretlint/config-creator@^10.2.2": + version "10.2.2" + resolved "https://registry.yarnpkg.com/@secretlint/config-creator/-/config-creator-10.2.2.tgz#5d646e83bb2aacfbd5218968ceb358420b4c2cb3" + integrity sha512-BynOBe7Hn3LJjb3CqCHZjeNB09s/vgf0baBaHVw67w7gHF0d25c3ZsZ5+vv8TgwSchRdUCRrbbcq5i2B1fJ2QQ== dependencies: - "@secretlint/types" "^10.2.1" + "@secretlint/types" "^10.2.2" -"@secretlint/config-loader@^10.2.1": - version "10.2.1" - resolved "https://registry.yarnpkg.com/@secretlint/config-loader/-/config-loader-10.2.1.tgz#8acff15b4f52a9569e403cef99fee28d330041aa" - integrity sha512-ob1PwhuSw/Hc6Y4TA63NWj6o++rZTRJOwPZG82o6tgEURqkrAN44fXH9GIouLsOxKa8fbCRLMeGmSBtJLdSqtw== +"@secretlint/config-loader@^10.2.2": + version "10.2.2" + resolved "https://registry.yarnpkg.com/@secretlint/config-loader/-/config-loader-10.2.2.tgz#a7790c8d0301db4f6d47e6fb0f0f9482fe652d9a" + integrity sha512-ndjjQNgLg4DIcMJp4iaRD6xb9ijWQZVbd9694Ol2IszBIbGPPkwZHzJYKICbTBmh6AH/pLr0CiCaWdGJU7RbpQ== dependencies: - "@secretlint/profiler" "^10.2.1" - "@secretlint/resolver" "^10.2.1" - "@secretlint/types" "^10.2.1" + "@secretlint/profiler" "^10.2.2" + "@secretlint/resolver" "^10.2.2" + "@secretlint/types" "^10.2.2" ajv "^8.17.1" debug "^4.4.1" rc-config-loader "^4.1.3" -"@secretlint/core@^10.2.1": - version "10.2.1" - resolved "https://registry.yarnpkg.com/@secretlint/core/-/core-10.2.1.tgz#a727174fbfd7b7f5d8f63b46470c1405bbe85cab" - integrity sha512-2sPp5IE7pM5Q+f1/NK6nJ49FKuqh+e3fZq5MVbtVjegiD4NMhjcoML1Cg7atCBgXPufhXRHY1DWhIhkGzOx/cw== +"@secretlint/core@^10.2.2": + version "10.2.2" + resolved "https://registry.yarnpkg.com/@secretlint/core/-/core-10.2.2.tgz#cd41d5c27ba07c217f0af4e0e24dbdfe5ef62042" + integrity sha512-6rdwBwLP9+TO3rRjMVW1tX+lQeo5gBbxl1I5F8nh8bgGtKwdlCMhMKsBWzWg1ostxx/tIG7OjZI0/BxsP8bUgw== dependencies: - "@secretlint/profiler" "^10.2.1" - "@secretlint/types" "^10.2.1" + "@secretlint/profiler" "^10.2.2" + "@secretlint/types" "^10.2.2" debug "^4.4.1" structured-source "^4.0.0" -"@secretlint/formatter@^10.2.1": - version "10.2.1" - resolved "https://registry.yarnpkg.com/@secretlint/formatter/-/formatter-10.2.1.tgz#a09ed00dbb91a17476dc3cf885387722b5225881" - integrity sha512-0A7ho3j0Y4ysK0mREB3O6FKQtScD4rQgfzuI4Slv9Cut1ynQOI7JXAoIFm4XVzhNcgtmEPeD3pQB206VFphBgQ== +"@secretlint/formatter@^10.2.2": + version "10.2.2" + resolved "https://registry.yarnpkg.com/@secretlint/formatter/-/formatter-10.2.2.tgz#c8ce35803ad0d841cc9b6e703d6fab68a144e9c0" + integrity sha512-10f/eKV+8YdGKNQmoDUD1QnYL7TzhI2kzyx95vsJKbEa8akzLAR5ZrWIZ3LbcMmBLzxlSQMMccRmi05yDQ5YDA== dependencies: - "@secretlint/resolver" "^10.2.1" - "@secretlint/types" "^10.2.1" + "@secretlint/resolver" "^10.2.2" + "@secretlint/types" "^10.2.2" "@textlint/linter-formatter" "^15.2.0" "@textlint/module-interop" "^15.2.0" "@textlint/types" "^15.2.0" @@ -909,67 +1042,79 @@ table "^6.9.0" terminal-link "^4.0.0" -"@secretlint/node@^10.1.1", "@secretlint/node@^10.2.1": - version "10.2.1" - resolved "https://registry.yarnpkg.com/@secretlint/node/-/node-10.2.1.tgz#4ff09a244500ec9c5f9d2a512bd047ebbfa9cb97" - integrity sha512-MQFte7C+5ZHINQGSo6+eUECcUCGvKR9PVgZcTsRj524xsbpeBqF1q1dHsUsdGb9r2jlvf40Q14MRZwMcpmLXWQ== - dependencies: - "@secretlint/config-loader" "^10.2.1" - "@secretlint/core" "^10.2.1" - "@secretlint/formatter" "^10.2.1" - "@secretlint/profiler" "^10.2.1" - "@secretlint/source-creator" "^10.2.1" - "@secretlint/types" "^10.2.1" +"@secretlint/node@^10.1.2", "@secretlint/node@^10.2.2": + version "10.2.2" + resolved "https://registry.yarnpkg.com/@secretlint/node/-/node-10.2.2.tgz#1d8a6ed620170bf4f29829a3a91878682c43c4d9" + integrity sha512-eZGJQgcg/3WRBwX1bRnss7RmHHK/YlP/l7zOQsrjexYt6l+JJa5YhUmHbuGXS94yW0++3YkEJp0kQGYhiw1DMQ== + dependencies: + "@secretlint/config-loader" "^10.2.2" + "@secretlint/core" "^10.2.2" + "@secretlint/formatter" "^10.2.2" + "@secretlint/profiler" "^10.2.2" + "@secretlint/source-creator" "^10.2.2" + "@secretlint/types" "^10.2.2" debug "^4.4.1" p-map "^7.0.3" -"@secretlint/profiler@^10.2.1": - version "10.2.1" - resolved "https://registry.yarnpkg.com/@secretlint/profiler/-/profiler-10.2.1.tgz#eb532c7549b68c639de399760c654529d8327e51" - integrity sha512-gOlfPZ1ASc5mP5cqsL809uMJGp85t+AJZg1ZPscWvB/m5UFFgeNTZcOawggb1S5ExDvR388sIJxagx5hyDZ34g== +"@secretlint/profiler@^10.2.2": + version "10.2.2" + resolved "https://registry.yarnpkg.com/@secretlint/profiler/-/profiler-10.2.2.tgz#82c085ab1966806763bbf6edb830987f25d4e797" + integrity sha512-qm9rWfkh/o8OvzMIfY8a5bCmgIniSpltbVlUVl983zDG1bUuQNd1/5lUEeWx5o/WJ99bXxS7yNI4/KIXfHexig== -"@secretlint/resolver@^10.2.1": - version "10.2.1" - resolved "https://registry.yarnpkg.com/@secretlint/resolver/-/resolver-10.2.1.tgz#513e2e4916d09fd96ead8f7020808a5373794cb8" - integrity sha512-AuwehKwnE2uxKaJVv2Z5a8FzGezBmlNhtLKm70Cvsvtwd0oAtenxCSTKXkiPGYC0+S91fAw3lrX7CUkyr9cTCA== +"@secretlint/resolver@^10.2.2": + version "10.2.2" + resolved "https://registry.yarnpkg.com/@secretlint/resolver/-/resolver-10.2.2.tgz#9c3c3e2fef00679fcce99793e76e19e575b75721" + integrity sha512-3md0cp12e+Ae5V+crPQYGd6aaO7ahw95s28OlULGyclyyUtf861UoRGS2prnUrKh7MZb23kdDOyGCYb9br5e4w== -"@secretlint/secretlint-formatter-sarif@^10.1.1": - version "10.2.1" - resolved "https://registry.yarnpkg.com/@secretlint/secretlint-formatter-sarif/-/secretlint-formatter-sarif-10.2.1.tgz#65e77f5313914041b353ad221613341a89d5bb80" - integrity sha512-qOZUYBesLkhCBP7YVMv0l1Pypt8e3V2rX2PT2Q5aJhJvKTcMiP9YTHG/3H9Zb7Gq3UIwZLEAGXRqJOu1XlE0Fg== +"@secretlint/secretlint-formatter-sarif@^10.1.2": + version "10.2.2" + resolved "https://registry.yarnpkg.com/@secretlint/secretlint-formatter-sarif/-/secretlint-formatter-sarif-10.2.2.tgz#5c4044a6a6c9d95e2f57270d6184931f0979d649" + integrity sha512-ojiF9TGRKJJw308DnYBucHxkpNovDNu1XvPh7IfUp0A12gzTtxuWDqdpuVezL7/IP8Ua7mp5/VkDMN9OLp1doQ== dependencies: node-sarif-builder "^3.2.0" -"@secretlint/secretlint-rule-no-dotenv@^10.1.1": - version "10.2.1" - resolved "https://registry.yarnpkg.com/@secretlint/secretlint-rule-no-dotenv/-/secretlint-rule-no-dotenv-10.2.1.tgz#2c272beecd6c262b6d57413c72fe7aae57f1b3eb" - integrity sha512-XwPjc9Wwe2QljerfvGlBmLJAJVATLvoXXw1fnKyCDNgvY33cu1Z561Kxg93xfRB5LSep0S5hQrAfZRJw6x7MBQ== +"@secretlint/secretlint-rule-no-dotenv@^10.1.2": + version "10.2.2" + resolved "https://registry.yarnpkg.com/@secretlint/secretlint-rule-no-dotenv/-/secretlint-rule-no-dotenv-10.2.2.tgz#ea43dcc2abd1dac3288b056610361f319f5ce6e9" + integrity sha512-KJRbIShA9DVc5Va3yArtJ6QDzGjg3PRa1uYp9As4RsyKtKSSZjI64jVca57FZ8gbuk4em0/0Jq+uy6485wxIdg== dependencies: - "@secretlint/types" "^10.2.1" + "@secretlint/types" "^10.2.2" -"@secretlint/secretlint-rule-preset-recommend@^10.1.1": - version "10.2.1" - resolved "https://registry.yarnpkg.com/@secretlint/secretlint-rule-preset-recommend/-/secretlint-rule-preset-recommend-10.2.1.tgz#c00fbd2257328ec909da43431826cdfb729a2185" - integrity sha512-/kj3UOpFbJt80dqoeEaUVv5nbeW1jPqPExA447FItthiybnaDse5C5HYcfNA2ywEInr399ELdcmpEMRe+ld1iQ== +"@secretlint/secretlint-rule-preset-recommend@^10.1.2": + version "10.2.2" + resolved "https://registry.yarnpkg.com/@secretlint/secretlint-rule-preset-recommend/-/secretlint-rule-preset-recommend-10.2.2.tgz#27b17c38b360c6788826d28fcda28ac6e9772d0b" + integrity sha512-K3jPqjva8bQndDKJqctnGfwuAxU2n9XNCPtbXVI5JvC7FnQiNg/yWlQPbMUlBXtBoBGFYp08A94m6fvtc9v+zA== -"@secretlint/source-creator@^10.2.1": - version "10.2.1" - resolved "https://registry.yarnpkg.com/@secretlint/source-creator/-/source-creator-10.2.1.tgz#1b1c1c64db677034e29c1a3db78dccd60da89d32" - integrity sha512-1CgO+hsRx8KdA5R/LEMNTJkujjomwSQQVV0BcuKynpOefV/rRlIDVQJOU0tJOZdqUMC15oAAwQXs9tMwWLu4JQ== +"@secretlint/source-creator@^10.2.2": + version "10.2.2" + resolved "https://registry.yarnpkg.com/@secretlint/source-creator/-/source-creator-10.2.2.tgz#d600b6d4487859cdd39bbb1cf8cf744540b3f7a1" + integrity sha512-h6I87xJfwfUTgQ7irWq7UTdq/Bm1RuQ/fYhA3dtTIAop5BwSFmZyrchph4WcoEvbN460BWKmk4RYSvPElIIvxw== dependencies: - "@secretlint/types" "^10.2.1" + "@secretlint/types" "^10.2.2" istextorbinary "^9.5.0" -"@secretlint/types@^10.2.1": - version "10.2.1" - resolved "https://registry.yarnpkg.com/@secretlint/types/-/types-10.2.1.tgz#018f252a3754a9ff2371b3e132226d281be8515b" - integrity sha512-F5k1qpoMoUe7rrZossOBgJ3jWKv/FGDBZIwepqnefgPmNienBdInxhtZeXiGwjcxXHVhsdgp6I5Fi/M8PMgwcw== +"@secretlint/types@^10.2.2": + version "10.2.2" + resolved "https://registry.yarnpkg.com/@secretlint/types/-/types-10.2.2.tgz#1412d8f699fd900182cbf4c2923a9df9eb321ca7" + integrity sha512-Nqc90v4lWCXyakD6xNyNACBJNJ0tNCwj2WNk/7ivyacYHxiITVgmLUFXTBOeCdy79iz6HtN9Y31uw/jbLrdOAg== + +"@sindresorhus/is@^4.0.0": + version "4.6.0" + resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.6.0.tgz#3c7c9c46e678feefe7a2e5bb609d3dbd665ffb3f" + integrity sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw== "@sindresorhus/merge-streams@^2.1.0": version "2.3.0" resolved "https://registry.yarnpkg.com/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz#719df7fb41766bc143369eaa0dd56d8dc87c9958" integrity sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg== +"@szmarczak/http-timer@^4.0.5": + version "4.0.6" + resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-4.0.6.tgz#b4a914bb62e7c272d4e5989fe4440f812ab1d807" + integrity sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w== + dependencies: + defer-to-connect "^2.0.0" + "@textlint/ast-node-types@15.2.1": version "15.2.1" resolved "https://registry.yarnpkg.com/@textlint/ast-node-types/-/ast-node-types-15.2.1.tgz#b98ce5bdf9e39941caa02e4cfcee459656c82b21" @@ -1029,6 +1174,16 @@ dependencies: tslib "^2.4.0" +"@types/cacheable-request@^6.0.1": + version "6.0.3" + resolved "https://registry.yarnpkg.com/@types/cacheable-request/-/cacheable-request-6.0.3.tgz#a430b3260466ca7b5ca5bfd735693b36e7a9d183" + integrity sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw== + dependencies: + "@types/http-cache-semantics" "*" + "@types/keyv" "^3.1.4" + "@types/node" "*" + "@types/responselike" "^1.0.0" + "@types/chai@^5.2.2": version "5.2.2" resolved "https://registry.yarnpkg.com/@types/chai/-/chai-5.2.2.tgz#6f14cea18180ffc4416bc0fd12be05fdd73bdd6b" @@ -1077,6 +1232,11 @@ "@types/minimatch" "*" "@types/node" "*" +"@types/http-cache-semantics@*": + version "4.0.4" + resolved "https://registry.yarnpkg.com/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz#b979ebad3919799c979b17c72621c0bc0a31c6c4" + integrity sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA== + "@types/istanbul-lib-coverage@^2.0.1": version "2.0.6" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz#7739c232a1fee9b4d3ce8985f314c0c6d33549d7" @@ -1092,27 +1252,27 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== +"@types/keyv@^3.1.4": + version "3.1.4" + resolved "https://registry.yarnpkg.com/@types/keyv/-/keyv-3.1.4.tgz#3ccdb1c6751b0c7e52300bcdacd5bcbf8faa75b6" + integrity sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg== + dependencies: + "@types/node" "*" + "@types/minimatch@*": version "5.1.2" resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-5.1.2.tgz#07508b45797cb81ec3f273011b054cd0755eddca" integrity sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA== -"@types/mocha@^10.0.2": +"@types/mocha@^10.0.10": version "10.0.10" resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-10.0.10.tgz#91f62905e8d23cbd66225312f239454a23bebfa0" integrity sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q== -"@types/node-forge@^1.3.14": - version "1.3.14" - resolved "https://registry.yarnpkg.com/@types/node-forge/-/node-forge-1.3.14.tgz#006c2616ccd65550560c2757d8472eb6d3ecea0b" - integrity sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw== - dependencies: - "@types/node" "*" - -"@types/node@*", "@types/node@^22.14.1": - version "22.14.1" - resolved "https://registry.yarnpkg.com/@types/node/-/node-22.14.1.tgz#53b54585cec81c21eee3697521e31312d6ca1e6f" - integrity sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw== +"@types/node@*", "@types/node@^22.14.1", "@types/node@^22.7.7": + version "22.19.1" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.19.1.tgz#1188f1ddc9f46b4cc3aec76749050b4e1f459b7b" + integrity sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ== dependencies: undici-types "~6.21.0" @@ -1121,6 +1281,25 @@ resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz#56e2cc26c397c038fab0e3a917a12d5c5909e901" integrity sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA== +"@types/proper-lockfile@^4.1.4": + version "4.1.4" + resolved "https://registry.yarnpkg.com/@types/proper-lockfile/-/proper-lockfile-4.1.4.tgz#cd9fab92bdb04730c1ada542c356f03620f84008" + integrity sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ== + dependencies: + "@types/retry" "*" + +"@types/responselike@^1.0.0": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@types/responselike/-/responselike-1.0.3.tgz#cc29706f0a397cfe6df89debfe4bf5cea159db50" + integrity sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw== + dependencies: + "@types/node" "*" + +"@types/retry@*": + version "0.12.5" + resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.5.tgz#f090ff4bd8d2e5b940ff270ab39fd5ca1834a07e" + integrity sha512-3xSjTp3v03X/lSQLkczaN9UIEwJMoMCA1+Nb5HfbJEQWogdeQIyVtTvxPXDQjZ5zws8rFQfVfRdz03ARihPJgw== + "@types/sarif@^2.1.7": version "2.1.7" resolved "https://registry.yarnpkg.com/@types/sarif/-/sarif-2.1.7.tgz#dab4d16ba7568e9846c454a8764f33c5d98e5524" @@ -1153,6 +1332,13 @@ dependencies: "@types/node" "*" +"@types/yauzl@^2.9.1": + version "2.10.3" + resolved "https://registry.yarnpkg.com/@types/yauzl/-/yauzl-2.10.3.tgz#e9b2808b4f109504a03cda958259876f61017999" + integrity sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q== + dependencies: + "@types/node" "*" + "@typescript-eslint/eslint-plugin@^8.44.0": version "8.44.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.44.1.tgz#011a2b5913d297b3d9d77f64fb78575bab01a1b3" @@ -1168,15 +1354,15 @@ natural-compare "^1.4.0" ts-api-utils "^2.1.0" -"@typescript-eslint/parser@^8.44.0": - version "8.44.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.44.1.tgz#d4c85791389462823596ad46e2b90d34845e05eb" - integrity sha512-EHrrEsyhOhxYt8MTg4zTF+DJMuNBzWwgvvOYNj/zm1vnaD/IC5zCXFehZv94Piqa2cRFfXrTFxIvO95L7Qc/cw== +"@typescript-eslint/parser@^8.49.0": + version "8.49.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.49.0.tgz#0ede412d59e99239b770f0f08c76c42fba717fa2" + integrity sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA== dependencies: - "@typescript-eslint/scope-manager" "8.44.1" - "@typescript-eslint/types" "8.44.1" - "@typescript-eslint/typescript-estree" "8.44.1" - "@typescript-eslint/visitor-keys" "8.44.1" + "@typescript-eslint/scope-manager" "8.49.0" + "@typescript-eslint/types" "8.49.0" + "@typescript-eslint/typescript-estree" "8.49.0" + "@typescript-eslint/visitor-keys" "8.49.0" debug "^4.3.4" "@typescript-eslint/project-service@8.44.1": @@ -1188,6 +1374,15 @@ "@typescript-eslint/types" "^8.44.1" debug "^4.3.4" +"@typescript-eslint/project-service@8.49.0": + version "8.49.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.49.0.tgz#ce220525c88cb2d23792b391c07e14cb9697651a" + integrity sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g== + dependencies: + "@typescript-eslint/tsconfig-utils" "^8.49.0" + "@typescript-eslint/types" "^8.49.0" + debug "^4.3.4" + "@typescript-eslint/scope-manager@8.44.1": version "8.44.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.44.1.tgz#31c27f92e4aed8d0f4d6fe2b9e5187d1d8797bd7" @@ -1196,11 +1391,24 @@ "@typescript-eslint/types" "8.44.1" "@typescript-eslint/visitor-keys" "8.44.1" +"@typescript-eslint/scope-manager@8.49.0": + version "8.49.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.49.0.tgz#a3496765b57fb48035d671174552e462e5bffa63" + integrity sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg== + dependencies: + "@typescript-eslint/types" "8.49.0" + "@typescript-eslint/visitor-keys" "8.49.0" + "@typescript-eslint/tsconfig-utils@8.44.1", "@typescript-eslint/tsconfig-utils@^8.44.1": version "8.44.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.44.1.tgz#e1d9d047078fac37d3e638484ab3b56215963342" integrity sha512-B5OyACouEjuIvof3o86lRMvyDsFwZm+4fBOqFHccIctYgBjqR3qT39FBYGN87khcgf0ExpdCBeGKpKRhSFTjKQ== +"@typescript-eslint/tsconfig-utils@8.49.0", "@typescript-eslint/tsconfig-utils@^8.49.0": + version "8.49.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.49.0.tgz#857777c8e35dd1e564505833d8043f544442fbf4" + integrity sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA== + "@typescript-eslint/type-utils@8.44.1": version "8.44.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.44.1.tgz#be9d31e0f911d17ee8ac99921bb74cf1f9df3906" @@ -1212,11 +1420,16 @@ debug "^4.3.4" ts-api-utils "^2.1.0" -"@typescript-eslint/types@8.44.1", "@typescript-eslint/types@^8.44.1": +"@typescript-eslint/types@8.44.1": version "8.44.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.44.1.tgz#85d1cad1290a003ff60420388797e85d1c3f76ff" integrity sha512-Lk7uj7y9uQUOEguiDIDLYLJOrYHQa7oBiURYVFqIpGxclAFQ78f6VUOM8lI2XEuNOKNB7XuvM2+2cMXAoq4ALQ== +"@typescript-eslint/types@8.49.0", "@typescript-eslint/types@^8.44.1", "@typescript-eslint/types@^8.49.0": + version "8.49.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.49.0.tgz#c1bd3ebf956d9e5216396349ca23c58d74f06aee" + integrity sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ== + "@typescript-eslint/typescript-estree@8.44.1": version "8.44.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.44.1.tgz#4f17650e5adabecfcc13cd8c517937a4ef5cd424" @@ -1233,6 +1446,21 @@ semver "^7.6.0" ts-api-utils "^2.1.0" +"@typescript-eslint/typescript-estree@8.49.0": + version "8.49.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.49.0.tgz#99c5a53275197ccb4e849786dad68344e9924135" + integrity sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA== + dependencies: + "@typescript-eslint/project-service" "8.49.0" + "@typescript-eslint/tsconfig-utils" "8.49.0" + "@typescript-eslint/types" "8.49.0" + "@typescript-eslint/visitor-keys" "8.49.0" + debug "^4.3.4" + minimatch "^9.0.4" + semver "^7.6.0" + tinyglobby "^0.2.15" + ts-api-utils "^2.1.0" + "@typescript-eslint/utils@8.44.1": version "8.44.1" resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.44.1.tgz#f23d48eb90791a821dc17d4f67bb96faeb75d63d" @@ -1251,6 +1479,14 @@ "@typescript-eslint/types" "8.44.1" eslint-visitor-keys "^4.2.1" +"@typescript-eslint/visitor-keys@8.49.0": + version "8.49.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.49.0.tgz#8e450cc502c0d285cad9e84d400cf349a85ced6c" + integrity sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA== + dependencies: + "@typescript-eslint/types" "8.49.0" + eslint-visitor-keys "^4.2.1" + "@typespec/ts-http-runtime@^0.3.0": version "0.3.0" resolved "https://registry.yarnpkg.com/@typespec/ts-http-runtime/-/ts-http-runtime-0.3.0.tgz#f506ff2170e594a257f8e78aa196088f3a46a22d" @@ -1442,19 +1678,19 @@ loupe "^3.1.4" tinyrainbow "^2.0.0" -"@vscode/test-cli@^0.0.11": - version "0.0.11" - resolved "https://registry.yarnpkg.com/@vscode/test-cli/-/test-cli-0.0.11.tgz#043b2c920ef1b115626eaabc5b02cd956044a51d" - integrity sha512-qO332yvzFqGhBMJrp6TdwbIydiHgCtxXc2Nl6M58mbH/Z+0CyLR76Jzv4YWPEthhrARprzCRJUqzFvTHFhTj7Q== +"@vscode/test-cli@^0.0.12": + version "0.0.12" + resolved "https://registry.yarnpkg.com/@vscode/test-cli/-/test-cli-0.0.12.tgz#38c1405436a1c960e1abc08790ea822fc9b3e412" + integrity sha512-iYN0fDg29+a2Xelle/Y56Xvv7Nc8Thzq4VwpzAF/SIE6918rDicqfsQxV6w1ttr2+SOm+10laGuY9FG2ptEKsQ== dependencies: - "@types/mocha" "^10.0.2" - c8 "^9.1.0" - chokidar "^3.5.3" - enhanced-resolve "^5.15.0" + "@types/mocha" "^10.0.10" + c8 "^10.1.3" + chokidar "^3.6.0" + enhanced-resolve "^5.18.3" glob "^10.3.10" minimatch "^9.0.3" - mocha "^11.1.0" - supports-color "^9.4.0" + mocha "^11.7.4" + supports-color "^10.2.2" yargs "^17.7.2" "@vscode/test-electron@^2.5.2": @@ -1528,16 +1764,16 @@ "@vscode/vsce-sign-win32-arm64" "2.0.5" "@vscode/vsce-sign-win32-x64" "2.0.5" -"@vscode/vsce@^3.6.0": - version "3.6.0" - resolved "https://registry.yarnpkg.com/@vscode/vsce/-/vsce-3.6.0.tgz#7102cb846db83ed70ec7119986af7d7c69cf3538" - integrity sha512-u2ZoMfymRNJb14aHNawnXJtXHLXDVKc1oKZaH4VELKT/9iWKRVgtQOdwxCgtwSxJoqYvuK4hGlBWQJ05wxADhg== +"@vscode/vsce@^3.7.1": + version "3.7.1" + resolved "https://registry.yarnpkg.com/@vscode/vsce/-/vsce-3.7.1.tgz#55a88ae40e9618fea251e373bc6b23c128915654" + integrity sha512-OTm2XdMt2YkpSn2Nx7z2EJtSuhRHsTPYsSK59hr3v8jRArK+2UEoju4Jumn1CmpgoBLGI6ReHLJ/czYltNUW3g== dependencies: "@azure/identity" "^4.1.0" - "@secretlint/node" "^10.1.1" - "@secretlint/secretlint-formatter-sarif" "^10.1.1" - "@secretlint/secretlint-rule-no-dotenv" "^10.1.1" - "@secretlint/secretlint-rule-preset-recommend" "^10.1.1" + "@secretlint/node" "^10.1.2" + "@secretlint/secretlint-formatter-sarif" "^10.1.2" + "@secretlint/secretlint-rule-no-dotenv" "^10.1.2" + "@secretlint/secretlint-rule-preset-recommend" "^10.1.2" "@vscode/vsce-sign" "^2.0.0" azure-devops-node-api "^12.5.0" chalk "^4.1.2" @@ -1554,7 +1790,7 @@ minimatch "^3.0.3" parse-semver "^1.1.1" read "^1.0.7" - secretlint "^10.1.1" + secretlint "^10.1.2" semver "^7.5.2" tmp "^0.2.3" typed-rest-client "^1.8.4" @@ -1983,6 +2219,15 @@ arraybuffer.prototype.slice@^1.0.4: get-intrinsic "^1.2.6" is-array-buffer "^3.0.4" +asn1js@^3.0.6: + version "3.0.6" + resolved "https://registry.yarnpkg.com/asn1js/-/asn1js-3.0.6.tgz#53e002ebe00c5f7fd77c1c047c3557d7c04dce25" + integrity sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA== + dependencies: + pvtsutils "^1.3.6" + pvutils "^1.1.3" + tslib "^2.8.1" + assertion-error@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" @@ -2122,6 +2367,11 @@ boolbase@^1.0.0: resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== +boolean@^3.0.1: + version "3.2.0" + resolved "https://registry.yarnpkg.com/boolean/-/boolean-3.2.0.tgz#9e5294af4e98314494cbb17979fa54ca159f116b" + integrity sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw== + boundary@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/boundary/-/boundary-2.0.0.tgz#169c8b1f0d44cf2c25938967a328f37e0a4e5efc" @@ -2211,19 +2461,19 @@ bundle-name@^4.1.0: dependencies: run-applescript "^7.0.0" -c8@^9.1.0: - version "9.1.0" - resolved "https://registry.yarnpkg.com/c8/-/c8-9.1.0.tgz#0e57ba3ab9e5960ab1d650b4a86f71e53cb68112" - integrity sha512-mBWcT5iqNir1zIkzSPyI3NCR9EZCVI3WUD+AVO17MVWTSFNyUueXE82qTeampNtTr+ilN/5Ua3j24LgbCKjDVg== +c8@^10.1.3: + version "10.1.3" + resolved "https://registry.yarnpkg.com/c8/-/c8-10.1.3.tgz#54afb25ebdcc7f3b00112482c6d90d7541ad2fcd" + integrity sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA== dependencies: - "@bcoe/v8-coverage" "^0.2.3" + "@bcoe/v8-coverage" "^1.0.1" "@istanbuljs/schema" "^0.1.3" find-up "^5.0.0" foreground-child "^3.1.1" istanbul-lib-coverage "^3.2.0" istanbul-lib-report "^3.0.1" istanbul-reports "^3.1.6" - test-exclude "^6.0.0" + test-exclude "^7.0.1" v8-to-istanbul "^9.0.0" yargs "^17.7.2" yargs-parser "^21.1.1" @@ -2233,6 +2483,24 @@ cac@^6.7.14: resolved "https://registry.yarnpkg.com/cac/-/cac-6.7.14.tgz#804e1e6f506ee363cb0e3ccbb09cad5dd9870959" integrity sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ== +cacheable-lookup@^5.0.3: + version "5.0.4" + resolved "https://registry.yarnpkg.com/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz#5a6b865b2c44357be3d5ebc2a467b032719a7005" + integrity sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA== + +cacheable-request@^7.0.2: + version "7.0.4" + resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-7.0.4.tgz#7a33ebf08613178b403635be7b899d3e69bbe817" + integrity sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg== + dependencies: + clone-response "^1.0.2" + get-stream "^5.1.0" + http-cache-semantics "^4.0.0" + keyv "^4.0.0" + lowercase-keys "^2.0.0" + normalize-url "^6.0.1" + responselike "^2.0.0" + caching-transform@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/caching-transform/-/caching-transform-4.0.0.tgz#00d297a4206d71e2163c39eaffa8157ac0651f0f" @@ -2357,12 +2625,7 @@ chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2, chalk@~4.1.2: ansi-styles "^4.1.0" supports-color "^7.1.0" -chalk@^5.3.0: - version "5.3.0" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.3.0.tgz#67c20a7ebef70e7f3970a01f90fa210cb6860385" - integrity sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w== - -chalk@^5.4.1: +chalk@^5.3.0, chalk@^5.4.1: version "5.4.1" resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.4.1.tgz#1b48bf0963ec158dce2aacf69c093ae2dd2092d8" integrity sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w== @@ -2427,7 +2690,7 @@ cheerio@^1.0.0-rc.9: parse5 "^7.0.0" parse5-htmlparser2-tree-adapter "^7.0.0" -chokidar@^3.5.3: +chokidar@^3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== @@ -2524,6 +2787,13 @@ clone-deep@^4.0.1: kind-of "^6.0.2" shallow-clone "^3.0.0" +clone-response@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.3.tgz#af2032aa47816399cf5f0a1d0db902f517abb8c3" + integrity sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA== + dependencies: + mimic-response "^1.0.0" + co@3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/co/-/co-3.1.0.tgz#4ea54ea5a08938153185e15210c68d9092bc1b78" @@ -2723,15 +2993,15 @@ date-fns@^3.6.0: resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-3.6.0.tgz#f20ca4fe94f8b754951b24240676e8618c0206bf" integrity sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww== -dayjs@^1.11.13: - version "1.11.13" - resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.13.tgz#92430b0139055c3ebb60150aa13e860a4b5a366c" - integrity sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg== +dayjs@^1.11.19: + version "1.11.19" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.19.tgz#15dc98e854bb43917f12021806af897c58ae2938" + integrity sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw== -debug@4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4: - version "4.3.7" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" - integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== +debug@4, debug@^4.0.1, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.4.1: + version "4.4.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b" + integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ== dependencies: ms "^2.1.3" @@ -2742,13 +3012,6 @@ debug@^3.2.7: dependencies: ms "^2.1.1" -debug@^4.3.5, debug@^4.4.1: - version "4.4.1" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b" - integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ== - dependencies: - ms "^2.1.3" - decamelize@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" @@ -2801,6 +3064,11 @@ default-require-extensions@^3.0.0: dependencies: strip-bom "^4.0.0" +defer-to-connect@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz#8016bdb4143e4632b77a3449c6236277de520587" + integrity sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg== + define-data-property@^1.0.1: version "1.1.0" resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.0.tgz#0db13540704e1d8d479a0656cf781267531b9451" @@ -2864,10 +3132,10 @@ delayed-stream@~1.0.0: resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== -detect-indent@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-7.0.1.tgz#cbb060a12842b9c4d333f1cac4aa4da1bb66bc25" - integrity sha512-Mc7QhQ8s+cLrnUfU/Ji94vG/r8M26m8f++vyres4ZoojaRDpZ1eSIh/EpzLNwlWuvzSZ3UbDFspjFvTDXe6e/g== +detect-indent@^7.0.1, detect-indent@^7.0.2: + version "7.0.2" + resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-7.0.2.tgz#16c516bf75d4b2f759f68214554996d467c8d648" + integrity sha512-y+8xyqdGLL+6sh0tVeHcfP/QDd8gUgbasolJJpY7NgeQGSZ739bDtSiaiDgtoicy+mtYB81dKLxO9xRhCyIB3A== detect-libc@^2.0.0: version "2.0.1" @@ -2879,6 +3147,11 @@ detect-newline@^4.0.1: resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-4.0.1.tgz#fcefdb5713e1fb8cb2839b8b6ee22e6716ab8f23" integrity sha512-qE3Veg1YXzGHQhlA6jzebZN2qVf6NX+A7m7qlhCGG30dJixrAQhYOsJjsnBjJkCSmuOPpCk30145fr8FV0bzog== +detect-node@^2.0.4: + version "2.1.0" + resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1" + integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g== + diff@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/diff/-/diff-7.0.0.tgz#3fb34d387cd76d803f6eebea67b921dab0182a9a" @@ -2968,6 +3241,15 @@ electron-to-chromium@^1.5.41: resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.50.tgz#d9ba818da7b2b5ef1f3dd32bce7046feb7e93234" integrity sha512-eMVObiUQ2LdgeO1F/ySTXsvqvxb6ZH2zPGaMYsWzRDdOddUa77tdmI0ltg+L16UpbWdhPmuF3wIQYyQq65WfZw== +electron@^39.2.6: + version "39.2.6" + resolved "https://registry.yarnpkg.com/electron/-/electron-39.2.6.tgz#7e1fdc01020418ea6c5cc92a3dd05fe65ad94941" + integrity sha512-dHBgTodWBZd+tL1Dt0PSh/CFLHeDkFCTKCTXu1dgPhlE9Z3k2zzlBQ9B2oW55CFsKanBDHiUomHJNw0XaSdQpA== + dependencies: + "@electron/get" "^2.0.0" + "@types/node" "^22.7.7" + extract-zip "^2.0.1" + emoji-regex@^10.3.0: version "10.4.0" resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.4.0.tgz#03553afea80b3975749cfcb36f776ca268e413d4" @@ -2995,7 +3277,7 @@ end-of-stream@^1.1.0, end-of-stream@^1.4.1: dependencies: once "^1.4.0" -enhanced-resolve@^5.0.0, enhanced-resolve@^5.15.0, enhanced-resolve@^5.17.3: +enhanced-resolve@^5.0.0, enhanced-resolve@^5.17.3, enhanced-resolve@^5.18.3: version "5.18.3" resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz#9b5f4c5c076b8787c78fe540392ce76a88855b44" integrity sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww== @@ -3008,6 +3290,11 @@ entities@^4.2.0, entities@^4.3.0, entities@^4.4.0: resolved "https://registry.yarnpkg.com/entities/-/entities-4.4.0.tgz#97bdaba170339446495e653cfd2db78962900174" integrity sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA== +env-paths@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2" + integrity sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A== + envinfo@^7.14.0: version "7.14.0" resolved "https://registry.yarnpkg.com/envinfo/-/envinfo-7.14.0.tgz#26dac5db54418f2a4c1159153a0b2ae980838aae" @@ -3316,7 +3603,7 @@ es-to-primitive@^1.3.0: is-date-object "^1.0.5" is-symbol "^1.0.4" -es6-error@^4.0.1: +es6-error@^4.0.1, es6-error@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/es6-error/-/es6-error-4.1.1.tgz#9e3af407459deed47e9a91f9b885a84eb05c561d" integrity sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg== @@ -3464,21 +3751,21 @@ eslint-plugin-md@^1.0.19: remark-preset-lint-markdown-style-guide "^2.1.3" requireindex "~1.1.0" -eslint-plugin-package-json@^0.56.3: - version "0.56.3" - resolved "https://registry.yarnpkg.com/eslint-plugin-package-json/-/eslint-plugin-package-json-0.56.3.tgz#dcf50aaf3a3bc377396d3df72bb63819b02e8d73" - integrity sha512-ArN3wnOAsduM/6a0egB83DQQfF/4KzxE53U8qcvELCXT929TnBy2IeCli4+in3QSHxcVYSIDa2Y5T2vVAXbe6A== +eslint-plugin-package-json@^0.85.0: + version "0.85.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-package-json/-/eslint-plugin-package-json-0.85.0.tgz#acbd53be1eafe9d667a8bf80c4459ab2d9a80a9f" + integrity sha512-MrOxFvhbqLuk4FIPG9v3u9Amn0n137J8LKILHvgfxK3rRyAHEVzuZM0CtpXFTx7cx4LzmAzONtlpjbM0UFNuTA== dependencies: "@altano/repository-tools" "^2.0.1" change-case "^5.4.4" - detect-indent "^7.0.1" + detect-indent "^7.0.2" detect-newline "^4.0.1" eslint-fix-utils "~0.4.0" - package-json-validator "~0.30.0" - semver "^7.5.4" - sort-object-keys "^1.1.3" - sort-package-json "^3.3.0" - validate-npm-package-name "^6.0.2" + package-json-validator "~0.59.0" + semver "^7.7.3" + sort-object-keys "^2.0.0" + sort-package-json "^3.4.0" + validate-npm-package-name "^7.0.0" eslint-plugin-prettier@^5.5.4: version "5.5.4" @@ -3720,6 +4007,17 @@ external-editor@^3.0.3: iconv-lite "^0.4.24" tmp "^0.0.33" +extract-zip@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-2.0.1.tgz#663dca56fe46df890d5f131ef4a06d22bb8ba13a" + integrity sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg== + dependencies: + debug "^4.1.1" + get-stream "^5.1.0" + yauzl "^2.10.0" + optionalDependencies: + "@types/yauzl" "^2.9.1" + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" @@ -3829,9 +4127,10 @@ find-cache-dir@^3.2.0: make-dir "^3.0.2" pkg-dir "^4.1.0" -"find-process@https://github.com/coder/find-process#fix/sequoia-compat": - version "1.4.10" - resolved "https://github.com/coder/find-process#58804f57e5bdedad72c4319109d3ce2eae09a1ad" +find-process@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/find-process/-/find-process-2.0.0.tgz#0708037e538762835773fe9f3423c4cc5669f8a3" + integrity sha512-YUBQnteWGASJoEVVsOXy6XtKAY2O1FCsWnnvQ8y0YwgY1rZiKeVptnFvMu6RSELZAJOGklqseTnUGGs5D0bKmg== dependencies: chalk "~4.1.2" commander "^12.1.0" @@ -3913,15 +4212,7 @@ foreground-child@^2.0.0: cross-spawn "^7.0.0" signal-exit "^3.0.2" -foreground-child@^3.1.0, foreground-child@^3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.0.tgz#0ac8644c06e431439f8561db8ecf29a7b5519c77" - integrity sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg== - dependencies: - cross-spawn "^7.0.0" - signal-exit "^4.0.1" - -foreground-child@^3.1.1, foreground-child@^3.3.1: +foreground-child@^3.1.0, foreground-child@^3.1.1, foreground-child@^3.3.0, foreground-child@^3.3.1: version "3.3.1" resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== @@ -3973,6 +4264,15 @@ fs-extra@^11.2.0: jsonfile "^6.0.1" universalify "^2.0.0" +fs-extra@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" + integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^4.0.0" + universalify "^0.1.0" + fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" @@ -4125,6 +4425,13 @@ get-proto@^1.0.0, get-proto@^1.0.1: dunder-proto "^1.0.1" es-object-atoms "^1.0.0" +get-stream@^5.1.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3" + integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA== + dependencies: + pump "^3.0.0" + get-symbol-description@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.0.0.tgz#7fdb81c900101fbd564dd5f1a30af5aadc1e58d6" @@ -4202,7 +4509,7 @@ glob-to-regexp@^0.4.1: resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== -glob@^10.3.10, glob@^10.4.1, glob@^10.4.2, glob@^10.4.5: +glob@^10.3.10, glob@^10.4.1, glob@^10.4.5: version "10.4.5" resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg== @@ -4214,14 +4521,14 @@ glob@^10.3.10, glob@^10.4.1, glob@^10.4.2, glob@^10.4.5: package-json-from-dist "^1.0.0" path-scurry "^1.11.1" -glob@^11.0.0: - version "11.0.3" - resolved "https://registry.yarnpkg.com/glob/-/glob-11.0.3.tgz#9d8087e6d72ddb3c4707b1d2778f80ea3eaefcd6" - integrity sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA== +glob@^11.0.0, glob@^11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-11.1.0.tgz#4f826576e4eb99c7dad383793d2f9f08f67e50a6" + integrity sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw== dependencies: foreground-child "^3.3.1" jackspeak "^4.1.1" - minimatch "^10.0.3" + minimatch "^10.1.1" minipass "^7.1.2" package-json-from-dist "^1.0.0" path-scurry "^2.0.0" @@ -4238,6 +4545,18 @@ glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: once "^1.3.0" path-is-absolute "^1.0.0" +global-agent@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/global-agent/-/global-agent-3.0.0.tgz#ae7cd31bd3583b93c5a16437a1afe27cc33a1ab6" + integrity sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q== + dependencies: + boolean "^3.0.1" + es6-error "^4.1.1" + matcher "^3.0.0" + roarr "^2.15.3" + semver "^7.3.2" + serialize-error "^7.0.1" + globals@^11.1.0: version "11.12.0" resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" @@ -4257,14 +4576,7 @@ globals@^13.19.0: dependencies: type-fest "^0.20.2" -globalthis@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.3.tgz#5852882a52b80dc301b0660273e1ed082f0b6ccf" - integrity sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA== - dependencies: - define-properties "^1.1.3" - -globalthis@^1.0.4: +globalthis@^1.0.1, globalthis@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.4.tgz#7430ed3a975d97bfb59bcce41f5cabbafa651236" integrity sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ== @@ -4272,6 +4584,13 @@ globalthis@^1.0.4: define-properties "^1.2.1" gopd "^1.0.1" +globalthis@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.3.tgz#5852882a52b80dc301b0660273e1ed082f0b6ccf" + integrity sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA== + dependencies: + define-properties "^1.1.3" + globby@^14.1.0: version "14.1.0" resolved "https://registry.yarnpkg.com/globby/-/globby-14.1.0.tgz#138b78e77cf5a8d794e327b15dce80bf1fb0a73e" @@ -4296,6 +4615,23 @@ gopd@^1.2.0: resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== +got@^11.8.5: + version "11.8.6" + resolved "https://registry.yarnpkg.com/got/-/got-11.8.6.tgz#276e827ead8772eddbcfc97170590b841823233a" + integrity sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g== + dependencies: + "@sindresorhus/is" "^4.0.0" + "@szmarczak/http-timer" "^4.0.5" + "@types/cacheable-request" "^6.0.1" + "@types/responselike" "^1.0.0" + cacheable-lookup "^5.0.3" + cacheable-request "^7.0.2" + decompress-response "^6.0.0" + http2-wrapper "^1.0.0-beta.5.2" + lowercase-keys "^2.0.0" + p-cancelable "^2.0.0" + responselike "^2.0.0" + graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.11, graceful-fs@^4.2.2, graceful-fs@^4.2.4: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" @@ -4439,6 +4775,11 @@ htmlparser2@^8.0.1: domutils "^3.0.1" entities "^4.3.0" +http-cache-semantics@^4.0.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz#205f4db64f8562b76a4ff9235aa5279839a09dd5" + integrity sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ== + http-proxy-agent@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz#8a8c8ef7f5932ccf953c296ca8291b95aa74aa3a" @@ -4456,6 +4797,14 @@ http-proxy-agent@^7.0.0, http-proxy-agent@^7.0.1, http-proxy-agent@^7.0.2: agent-base "^7.1.0" debug "^4.3.4" +http2-wrapper@^1.0.0-beta.5.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/http2-wrapper/-/http2-wrapper-1.0.3.tgz#b8f55e0c1f25d4ebd08b3b0c2c079f9590800b3d" + integrity sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg== + dependencies: + quick-lru "^5.1.1" + resolve-alpn "^1.0.0" + https-proxy-agent@^5.0.0: version "5.0.1" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" @@ -5303,6 +5652,11 @@ json-stable-stringify-without-jsonify@^1.0.1: resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" integrity sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw== +json-stringify-safe@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" + integrity sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA== + json5@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/json5/-/json5-1.0.2.tgz#63d98d60f21b313b77c4d6da18bfa69d80e1d593" @@ -5315,10 +5669,10 @@ json5@^2.2.2, json5@^2.2.3: resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== -jsonc-eslint-parser@^2.4.0: - version "2.4.1" - resolved "https://registry.yarnpkg.com/jsonc-eslint-parser/-/jsonc-eslint-parser-2.4.1.tgz#64a8ed77311d33ac450725c1a438132dd87b2b3b" - integrity sha512-uuPNLJkKN8NXAlZlQ6kmUF9qO+T6Kyd7oV4+/7yy8Jz6+MZNyhPq8EdLpdfnPVzUC8qSf1b4j1azKaGnFsjmsw== +jsonc-eslint-parser@^2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/jsonc-eslint-parser/-/jsonc-eslint-parser-2.4.2.tgz#f135454fd35784ecc1b848908f0d3e98a5be9433" + integrity sha512-1e4qoRgnn448pRuMvKGsFFymUCquZV0mpGgOyIKNgD3JVDTsVJyRBGH/Fm0tBb8WsWGgmB1mDe6/yJMQM37DUA== dependencies: acorn "^8.5.0" eslint-visitor-keys "^3.0.0" @@ -5330,6 +5684,13 @@ jsonc-parser@^3.2.0, jsonc-parser@^3.3.1: resolved "https://registry.yarnpkg.com/jsonc-parser/-/jsonc-parser-3.3.1.tgz#f2a524b4f7fd11e3d791e559977ad60b98b798b4" integrity sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ== +jsonfile@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb" + integrity sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg== + optionalDependencies: + graceful-fs "^4.1.6" + jsonfile@^6.0.1: version "6.1.0" resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" @@ -5365,7 +5726,7 @@ jszip@^3.10.1: readable-stream "~2.3.6" setimmediate "^1.0.5" -jwa@^1.4.1: +jwa@^1.4.2: version "1.4.2" resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.2.tgz#16011ac6db48de7b102777e57897901520eec7b9" integrity sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw== @@ -5375,11 +5736,11 @@ jwa@^1.4.1: safe-buffer "^5.0.1" jws@^3.2.2: - version "3.2.2" - resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" - integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA== + version "3.2.3" + resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.3.tgz#5ac0690b460900a27265de24520526853c0b8ca1" + integrity sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g== dependencies: - jwa "^1.4.1" + jwa "^1.4.2" safe-buffer "^5.0.1" keytar@^7.7.0: @@ -5390,7 +5751,7 @@ keytar@^7.7.0: node-addon-api "^4.3.0" prebuild-install "^7.0.1" -keyv@^4.5.3: +keyv@^4.0.0, keyv@^4.5.3: version "4.5.4" resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== @@ -5547,6 +5908,11 @@ loupe@^3.1.0, loupe@^3.1.4: resolved "https://registry.yarnpkg.com/loupe/-/loupe-3.2.1.tgz#0095cf56dc5b7a9a7c08ff5b1a8796ec8ad17e76" integrity sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ== +lowercase-keys@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479" + integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA== + lru-cache@^10.0.1: version "10.4.3" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" @@ -5640,6 +6006,13 @@ markdown-table@^1.1.0: resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-1.1.3.tgz#9fcb69bcfdb8717bfd0398c6ec2d93036ef8de60" integrity sha512-1RUZVgQlpJSPWYbFSpmudq5nHY1doEIv89gBtF0s4gW1GF2XorxcA/70M5vq7rLv0a6mhOUccRsqkwhwLCIQ2Q== +matcher@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/matcher/-/matcher-3.0.0.tgz#bd9060f4c5b70aa8041ccc6f80368760994f30ca" + integrity sha512-OkeDaAZ/bQCxeFAozM55PKcKU0yJMPGifLwV4Qgjitu+5MoAfSQN4lsLJeXZ1b8w0x+/Emda6MZgXS1jvsapng== + dependencies: + escape-string-regexp "^4.0.0" + math-intrinsics@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" @@ -5672,10 +6045,10 @@ mdurl@^2.0.0: resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-2.0.0.tgz#80676ec0433025dd3e17ee983d0fe8de5a2237e0" integrity sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w== -memfs@^4.47.0: - version "4.47.0" - resolved "https://registry.yarnpkg.com/memfs/-/memfs-4.47.0.tgz#410291da6dcce89a0d6c9cab23b135231a5ed44c" - integrity sha512-Xey8IZA57tfotV/TN4d6BmccQuhFP+CqRiI7TTNdipZdZBzF2WnzUcH//Cudw6X4zJiUbo/LTuU/HPA/iC/pNg== +memfs@^4.49.0: + version "4.49.0" + resolved "https://registry.yarnpkg.com/memfs/-/memfs-4.49.0.tgz#bc35069570d41a31c62e31f1a6ec6057a8ea82f0" + integrity sha512-L9uC9vGuc4xFybbdOpRLoOAOq1YEBBsocCs5NVW32DfU+CZWWIn3OVF+lB8Gp4ttBVSMazwrTrjv8ussX/e3VQ== dependencies: "@jsonjoy.com/json-pack" "^1.11.0" "@jsonjoy.com/util" "^1.9.0" @@ -5729,15 +6102,20 @@ mimic-function@^5.0.0: resolved "https://registry.yarnpkg.com/mimic-function/-/mimic-function-5.0.1.tgz#acbe2b3349f99b9deaca7fb70e48b83e94e67076" integrity sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA== +mimic-response@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b" + integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ== + mimic-response@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9" integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== -minimatch@^10.0.3: - version "10.0.3" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.0.3.tgz#cf7a0314a16c4d9ab73a7730a0e8e3c3502d47aa" - integrity sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw== +minimatch@^10.1.1: + version "10.1.1" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.1.1.tgz#e6e61b9b0c1dcab116b5a7d1458e8b6ae9e73a55" + integrity sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ== dependencies: "@isaacs/brace-expansion" "^5.0.0" @@ -5777,10 +6155,10 @@ mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3: dependencies: minimist "^1.2.6" -mocha@^11.1.0: - version "11.7.2" - resolved "https://registry.yarnpkg.com/mocha/-/mocha-11.7.2.tgz#3c0079fe5cc2f8ea86d99124debcc42bb1ab22b5" - integrity sha512-lkqVJPmqqG/w5jmmFtiRvtA2jkDyNVUcefFJKb2uyX4dekk8Okgqop3cgbFiaIvj8uCRJVTP5x9dfxGyXm2jvQ== +mocha@^11.7.4: + version "11.7.4" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-11.7.4.tgz#f161b17aeccb0762484b33bdb3f7ab9410ba5c82" + integrity sha512-1jYAaY8x0kAZ0XszLWu14pzsf4KV740Gld4HXkhNTXwcHx4AUEDkPzgEHg9CM5dVcW+zv036tjpsEbLraPJj4w== dependencies: browser-stdout "^1.3.1" chokidar "^4.0.1" @@ -5790,6 +6168,7 @@ mocha@^11.1.0: find-up "^5.0.0" glob "^10.4.5" he "^1.2.0" + is-path-inside "^3.0.3" js-yaml "^4.1.0" log-symbols "^4.1.0" minimatch "^9.0.5" @@ -5860,11 +6239,6 @@ node-addon-api@^4.3.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-4.3.0.tgz#52a1a0b475193e0928e98e0426a0d1254782b77f" integrity sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ== -node-forge@^1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.1.tgz#be8da2af243b2417d5f646a770663a92b7e9ded3" - integrity sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA== - node-gyp-build@^4.3.0: version "4.6.0" resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.6.0.tgz#0c52e4cbf54bbd28b709820ef7b6a3c2d6209055" @@ -5904,6 +6278,11 @@ normalize-path@^3.0.0, normalize-path@~3.0.0: resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== +normalize-url@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a" + integrity sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A== + nth-check@^2.0.1: version "2.1.1" resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d" @@ -6056,10 +6435,10 @@ open@^10.1.0: is-inside-container "^1.0.0" wsl-utils "^0.1.0" -openpgp@^6.2.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/openpgp/-/openpgp-6.2.0.tgz#f9ce7b4fa298c9d1c4c51f8d1bd0d6cb00372144" - integrity sha512-zKbgazxMeGrTqUEWicKufbdcjv2E0om3YVxw+I3hRykp8ODp+yQOJIDqIr1UXJjP8vR2fky3bNQwYoQXyFkYMA== +openpgp@^6.2.2: + version "6.2.2" + resolved "https://registry.yarnpkg.com/openpgp/-/openpgp-6.2.2.tgz#329f4fab075f9746a94e584df8cfbda70a0dcaf3" + integrity sha512-P/dyEqQ3gfwOCo+xsqffzXjmUhGn4AZTOJ1LCcN21S23vAk+EAvMJOQTsb/C8krL6GjOSBxqGYckhik7+hneNw== optionator@^0.8.3: version "0.8.3" @@ -6114,6 +6493,11 @@ own-keys@^1.0.1: object-keys "^1.1.1" safe-push-apply "^1.0.0" +p-cancelable@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-2.1.1.tgz#aab7fbd416582fa32a3db49859c122487c5ed2cf" + integrity sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg== + p-limit@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" @@ -6196,13 +6580,14 @@ package-json-from-dist@^1.0.0: resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz#e501cd3094b278495eb4258d4c9f6d5ac3019f00" integrity sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw== -package-json-validator@~0.30.0: - version "0.30.0" - resolved "https://registry.yarnpkg.com/package-json-validator/-/package-json-validator-0.30.0.tgz#31613a3e4a2455599c7ad3a97f134707f13de1e0" - integrity sha512-gOLW+BBye32t+IB2trIALIcL3DZBy3s4G4ZV6dAgDM+qLs/7jUNOV7iO7PwXqyf+3izI12qHBwtS4kOSJp5Tdg== +package-json-validator@~0.59.0: + version "0.59.0" + resolved "https://registry.yarnpkg.com/package-json-validator/-/package-json-validator-0.59.0.tgz#28612014fd76b97836fd56de35828e86d4828a85" + integrity sha512-WBTDKtO9pBa9GmA1sPbQHqlWxRdnHNfLFIIA49PPgV7px/rG27gHX57DWy77qyu374fla4veaIHy+gA+qRRuug== dependencies: semver "^7.7.2" validate-npm-package-license "^3.0.4" + validate-npm-package-name "^7.0.0" yargs "~18.0.0" pako@~1.0.2: @@ -6419,15 +6804,15 @@ prettier-linter-helpers@^1.0.0: dependencies: fast-diff "^1.1.2" -prettier@^3.5.3: - version "3.5.3" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.5.3.tgz#4fc2ce0d657e7a02e602549f053b239cb7dfe1b5" - integrity sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw== +prettier@^3.6.2: + version "3.6.2" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.6.2.tgz#ccda02a1003ebbb2bfda6f83a074978f608b9393" + integrity sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ== -pretty-bytes@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-7.0.0.tgz#8652cbf0aa81daeeaf72802e0fd059e5e1046cdb" - integrity sha512-U5otLYPR3L0SVjHGrkEUx5mf7MxV2ceXeE7VwWPk+hyzC5drNohsOGNPDZqxCqyX1lkbEN4kl1LiI8QFd7r0ZA== +pretty-bytes@^7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-7.1.0.tgz#d788c9906241dbdcd4defab51b6d7470243db9bd" + integrity sha512-nODzvTiYVRGRqAOvE84Vk5JDPyyxsVk0/fbA/bq7RqlnhksGpset09XTxbpvLTIjoaF7K8Z8DG8yHtKGTPSYRw== process-nextick-args@~2.0.0: version "2.0.1" @@ -6441,11 +6826,20 @@ process-on-spawn@^1.0.0: dependencies: fromentries "^1.2.0" -progress@^2.0.0: +progress@^2.0.0, progress@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== +proper-lockfile@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/proper-lockfile/-/proper-lockfile-4.1.2.tgz#c8b9de2af6b2f1601067f98e01ac66baa223141f" + integrity sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA== + dependencies: + graceful-fs "^4.2.4" + retry "^0.12.0" + signal-exit "^3.0.2" + proxy-agent@^6.5.0: version "6.5.0" resolved "https://registry.yarnpkg.com/proxy-agent/-/proxy-agent-6.5.0.tgz#9e49acba8e4ee234aacb539f89ed9c23d02f232d" @@ -6483,6 +6877,18 @@ punycode@^2.1.0: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.0.tgz#f67fa67c94da8f4d0cfff981aee4118064199b8f" integrity sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA== +pvtsutils@^1.3.6: + version "1.3.6" + resolved "https://registry.yarnpkg.com/pvtsutils/-/pvtsutils-1.3.6.tgz#ec46e34db7422b9e4fdc5490578c1883657d6001" + integrity sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg== + dependencies: + tslib "^2.8.1" + +pvutils@^1.1.3: + version "1.1.5" + resolved "https://registry.yarnpkg.com/pvutils/-/pvutils-1.1.5.tgz#84b0dea4a5d670249aa9800511804ee0b7c2809c" + integrity sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA== + qs@^6.9.1: version "6.11.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" @@ -6495,6 +6901,11 @@ queue-microtask@^1.2.2: resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== +quick-lru@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932" + integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== + randombytes@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" @@ -6581,6 +6992,11 @@ rechoir@^0.8.0: dependencies: resolve "^1.20.0" +reflect-metadata@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.2.2.tgz#400c845b6cba87a21f2c65c4aeb158f4fa4d9c5b" + integrity sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q== + reflect.getprototypeof@^1.0.6, reflect.getprototypeof@^1.0.9: version "1.0.10" resolved "https://registry.yarnpkg.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz#c629219e78a3316d8b604c765ef68996964e7bf9" @@ -7217,6 +7633,11 @@ requireindex@~1.1.0: resolved "https://registry.yarnpkg.com/requireindex/-/requireindex-1.1.0.tgz#e5404b81557ef75db6e49c5a72004893fe03e162" integrity sha512-LBnkqsDE7BZKvqylbmn7lTIVdpx4K/QCduRATpO5R+wtPmky/a8pN1bO2D6wXppn1497AJF9mNjqAXr6bdl9jg== +resolve-alpn@^1.0.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz#b7adbdac3546aaaec20b45e7d8265927072726f9" + integrity sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g== + resolve-cwd@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d" @@ -7257,6 +7678,13 @@ resolve@^1.22.4: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" +responselike@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/responselike/-/responselike-2.0.1.tgz#9a0bc8fdc252f3fb1cca68b016591059ba1422bc" + integrity sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw== + dependencies: + lowercase-keys "^2.0.0" + restore-cursor@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e" @@ -7273,6 +7701,11 @@ restore-cursor@^5.0.0: onetime "^7.0.0" signal-exit "^4.1.0" +retry@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" + integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow== + reusify@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" @@ -7299,6 +7732,18 @@ rimraf@^3.0.0, rimraf@^3.0.2: dependencies: glob "^7.1.3" +roarr@^2.15.3: + version "2.15.4" + resolved "https://registry.yarnpkg.com/roarr/-/roarr-2.15.4.tgz#f5fe795b7b838ccfe35dc608e0282b9eba2e7afd" + integrity sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A== + dependencies: + boolean "^3.0.1" + detect-node "^2.0.4" + globalthis "^1.0.1" + json-stringify-safe "^5.0.1" + semver-compare "^1.0.0" + sprintf-js "^1.1.2" + rollup@^4.43.0: version "4.50.2" resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.50.2.tgz#938d898394939f3386d1e367ee6410a796b8f268" @@ -7449,23 +7894,35 @@ schema-utils@^4.3.0, schema-utils@^4.3.2: ajv-formats "^2.1.1" ajv-keywords "^5.1.0" -secretlint@^10.1.1: - version "10.2.1" - resolved "https://registry.yarnpkg.com/secretlint/-/secretlint-10.2.1.tgz#021ea25bb77f23efba22ce778d1a001b15de77b1" - integrity sha512-3BghQkIGrDz3xJklX/COxgKbxHz2CAsGkXH4oh8MxeYVLlhA3L/TLhAxZiTyqeril+CnDGg8MUEZdX1dZNsxVA== +secretlint@^10.1.2: + version "10.2.2" + resolved "https://registry.yarnpkg.com/secretlint/-/secretlint-10.2.2.tgz#c0cf997153a2bef0b653874dc87030daa6a35140" + integrity sha512-xVpkeHV/aoWe4vP4TansF622nBEImzCY73y/0042DuJ29iKIaqgoJ8fGxre3rVSHHbxar4FdJobmTnLp9AU0eg== dependencies: - "@secretlint/config-creator" "^10.2.1" - "@secretlint/formatter" "^10.2.1" - "@secretlint/node" "^10.2.1" - "@secretlint/profiler" "^10.2.1" + "@secretlint/config-creator" "^10.2.2" + "@secretlint/formatter" "^10.2.2" + "@secretlint/node" "^10.2.2" + "@secretlint/profiler" "^10.2.2" debug "^4.4.1" globby "^14.1.0" read-pkg "^9.0.1" -semver@7.7.1, semver@^5.1.0, semver@^5.5.0, semver@^6.0.0, semver@^6.1.2, semver@^6.3.1, semver@^7.3.4, semver@^7.3.5, semver@^7.5.2, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.6.2, semver@^7.7.1, semver@^7.7.2: - version "7.7.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.1.tgz#abd5098d82b18c6c81f6074ff2647fd3e7220c9f" - integrity sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA== +semver-compare@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/semver-compare/-/semver-compare-1.0.0.tgz#0dee216a1c941ab37e9efb1788f6afc5ff5537fc" + integrity sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow== + +semver@7.7.3, semver@^5.1.0, semver@^5.5.0, semver@^6.0.0, semver@^6.1.2, semver@^6.2.0, semver@^6.3.1, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.5.2, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.6.2, semver@^7.7.1, semver@^7.7.2, semver@^7.7.3: + version "7.7.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" + integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== + +serialize-error@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/serialize-error/-/serialize-error-7.0.1.tgz#f1360b0447f61ffb483ec4157c737fab7d778e18" + integrity sha512-8I8TjW5KMOKsZQTvoxjuSIa7foAwPWGOts+6o7sgjz41/qMD9VQHEDxi6PBvK2l0MXUmqZyNpUK+T2tQaaElvw== + dependencies: + type-fest "^0.13.1" serialize-javascript@^6.0.2: version "6.0.2" @@ -7699,7 +8156,12 @@ sort-object-keys@^1.1.3: resolved "https://registry.yarnpkg.com/sort-object-keys/-/sort-object-keys-1.1.3.tgz#bff833fe85cab147b34742e45863453c1e190b45" integrity sha512-855pvK+VkU7PaKYPc+Jjnmt4EzejQHyhhF33q31qG8x7maDzkeFhAAThdCYay11CISO+qAMwjOBP+fPZe0IPyg== -sort-package-json@^3.3.0: +sort-object-keys@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/sort-object-keys/-/sort-object-keys-2.0.0.tgz#e5dc3d75d07d4efe73ba6ac55f2f1a4380fdedf8" + integrity sha512-FTUWjmUumK0IGXn1INzkS3lS2Fqw81JuomcExd7LsFvQnNl+9+IZ575fC21F/AwrR/6lMrH7lTX0e7qLBk1wMg== + +sort-package-json@^3.4.0: version "3.4.0" resolved "https://registry.yarnpkg.com/sort-package-json/-/sort-package-json-3.4.0.tgz#98e42b78848c517736b069f8aa4fa322fae56677" integrity sha512-97oFRRMM2/Js4oEA9LJhjyMlde+2ewpZQf53pgue27UkbEXfHJnDzHlUxQ/DWUkzqmp7DFwJp8D+wi/TYeQhpA== @@ -7773,7 +8235,7 @@ spdx-license-ids@^3.0.0: resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.21.tgz#6d6e980c9df2b6fc905343a3b2d702a6239536c3" integrity sha512-Bvg/8F5XephndSK3JffaRqdT+gyhfqIPwDHpX80tJrF8QQRYMo8sNMeaZ2Dp5+jhwKnUmIOyFFQfHRkjJm5nXg== -sprintf-js@^1.1.3: +sprintf-js@^1.1.2, sprintf-js@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.3.tgz#4914b903a2f8b685d17fdf78a70e917e872e444a" integrity sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA== @@ -8017,6 +8479,18 @@ structured-source@^4.0.0: dependencies: boundary "^2.0.0" +sumchecker@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/sumchecker/-/sumchecker-3.0.1.tgz#6377e996795abb0b6d348e9b3e1dfb24345a8e42" + integrity sha512-MvjXzkz/BOfyVDkG0oFOtBxHX2u3gKbMHIF/dXblZsgD3BWOFLmHovIpZY7BykJdAjcqRCBi1WYBNdEC9yI7vg== + dependencies: + debug "^4.1.0" + +supports-color@^10.2.2: + version "10.2.2" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-10.2.2.tgz#466c2978cc5cd0052d542a0b576461c2b802ebb4" + integrity sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g== + supports-color@^5.3.0: version "5.5.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" @@ -8038,11 +8512,6 @@ supports-color@^8.0.0, supports-color@^8.1.1: dependencies: has-flag "^4.0.0" -supports-color@^9.4.0: - version "9.4.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-9.4.0.tgz#17bfcf686288f531db3dea3215510621ccb55954" - integrity sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw== - supports-hyperlinks@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/supports-hyperlinks/-/supports-hyperlinks-3.2.0.tgz#b8e485b179681dea496a1e7abdf8985bd3145461" @@ -8269,10 +8738,10 @@ ts-api-utils@^2.1.0: resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.1.0.tgz#595f7094e46eed364c13fd23e75f9513d29baf91" integrity sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ== -ts-loader@^9.5.1: - version "9.5.1" - resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-9.5.1.tgz#63d5912a86312f1fbe32cef0859fb8b2193d9b89" - integrity sha512-rNH3sK9kGZcH9dYzC7CewQm4NtxJTjSEVRJ2DyBZR7f8/wcta+iV44UPCXc5+nzDzivKtlzV6c9P4e+oFhDLYg== +ts-loader@^9.5.4: + version "9.5.4" + resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-9.5.4.tgz#44b571165c10fb5a90744aa5b7e119233c4f4585" + integrity sha512-nCz0rEwunlTZiy6rXFByQU1kVVpCIgUpc/psFiKVrUwrizdnIbRFu8w7bxhUF0X613DYwT4XzrZHpVyMe758hQ== dependencies: chalk "^4.1.0" enhanced-resolve "^5.0.0" @@ -8290,16 +8759,23 @@ tsconfig-paths@^3.15.0: minimist "^1.2.6" strip-bom "^3.0.0" -tslib@^1.9.0: +tslib@^1.9.0, tslib@^1.9.3: version "1.14.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.0, tslib@^2.0.1, tslib@^2.2.0, tslib@^2.4.0, tslib@^2.6.2: +tslib@^2.0.0, tslib@^2.0.1, tslib@^2.2.0, tslib@^2.4.0, tslib@^2.6.2, tslib@^2.8.1: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== +tsyringe@^4.10.0: + version "4.10.0" + resolved "https://registry.yarnpkg.com/tsyringe/-/tsyringe-4.10.0.tgz#d0c95815d584464214060285eaaadd94aa03299c" + integrity sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw== + dependencies: + tslib "^1.9.3" + tunnel-agent@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" @@ -8326,6 +8802,11 @@ type-check@~0.3.2: dependencies: prelude-ls "~1.1.2" +type-fest@^0.13.1: + version "0.13.1" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.13.1.tgz#0172cb5bce80b0bd542ea348db50c7e21834d934" + integrity sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg== + type-fest@^0.20.2: version "0.20.2" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" @@ -8490,10 +8971,10 @@ typedarray-to-buffer@^3.1.5: dependencies: is-typedarray "^1.0.0" -typescript@^5.9.2: - version "5.9.2" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.2.tgz#d93450cddec5154a2d5cabe3b8102b83316fb2a6" - integrity sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A== +typescript@^5.9.3: + version "5.9.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" + integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== ua-parser-js@1.0.40: version "1.0.40" @@ -8623,6 +9104,11 @@ unist-util-visit@^1.0.0, unist-util-visit@^1.1.0, unist-util-visit@^1.1.1, unist dependencies: unist-util-visit-parents "^2.0.0" +universalify@^0.1.0: + version "0.1.2" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" + integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== + universalify@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" @@ -8730,10 +9216,10 @@ validate-npm-package-license@^3.0.4: spdx-correct "^3.0.0" spdx-expression-parse "^3.0.0" -validate-npm-package-name@^6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/validate-npm-package-name/-/validate-npm-package-name-6.0.2.tgz#4e8d2c4d939975a73dd1b7a65e8f08d44c85df96" - integrity sha512-IUoow1YUtvoBBC06dXs8bR8B9vuA3aJfmQNKMoaPG/OFsPmoQvw8xh+6Ye25Gx9DQhoEom3Pcu9MKHerm/NpUQ== +validate-npm-package-name@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/validate-npm-package-name/-/validate-npm-package-name-7.0.0.tgz#3b4fe12b4abfb8b0be010d0e75b1fe2b52295bc6" + integrity sha512-bwVk/OK+Qu108aJcMAEiU4yavHUI7aN20TgZNBj9MR2iU1zPUl1Z1Otr7771ExfYTPTvfN8ZJ1pbr5Iklgt4xg== version-range@^4.13.0: version "4.14.0" @@ -8775,9 +9261,9 @@ vite-node@3.2.4: vite "^5.0.0 || ^6.0.0 || ^7.0.0-0" "vite@^5.0.0 || ^6.0.0 || ^7.0.0-0": - version "7.1.5" - resolved "https://registry.yarnpkg.com/vite/-/vite-7.1.5.tgz#4dbcb48c6313116689be540466fc80faa377be38" - integrity sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ== + version "7.1.11" + resolved "https://registry.yarnpkg.com/vite/-/vite-7.1.11.tgz#4d006746112fee056df64985191e846ebfb6007e" + integrity sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg== dependencies: esbuild "^0.25.0" fdir "^6.5.0" @@ -9113,10 +9599,10 @@ write@1.0.3: dependencies: mkdirp "^0.5.1" -ws@^8.18.2: - version "8.18.2" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.2.tgz#42738b2be57ced85f46154320aabb51ab003705a" - integrity sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ== +ws@^8.18.3: + version "8.18.3" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.3.tgz#b56b88abffde62791c639170400c93dcb0c95472" + integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg== wsl-utils@^0.1.0: version "0.1.0" @@ -9233,7 +9719,7 @@ yargs@~18.0.0: y18n "^5.0.5" yargs-parser "^22.0.0" -yauzl@^2.3.1: +yauzl@^2.10.0, yauzl@^2.3.1: version "2.10.0" resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9" integrity sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g== @@ -9253,7 +9739,7 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -zod@^3.25.65: - version "3.25.65" - resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.65.tgz#190cb604e1b45e0f789a315f65463953d4d4beee" - integrity sha512-kMyE2qsXK1p+TAvO7zsf5wMFiCejU3obrUDs9bR1q5CBKykfvp7QhhXrycUylMoOow0iEUSyjLlZZdCsHwSldQ== +zod@^4.1.12: + version "4.1.12" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.1.12.tgz#64f1ea53d00eab91853195653b5af9eee68970f0" + integrity sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==