From bb90ced5fa1bfabd62032d75641e3209bc3fa072 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 23 Sep 2025 17:01:45 +0300 Subject: [PATCH 01/69] Fix global flags escaping when starting a workspace using the CLI (#592) Closes #591 --- src/api/workspace.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/api/workspace.ts b/src/api/workspace.ts index 3da5f150..45fa9156 100644 --- a/src/api/workspace.ts +++ b/src/api/workspace.ts @@ -4,6 +4,7 @@ import { Workspace } from "coder/site/src/api/typesGenerated"; import * as vscode from "vscode"; import { FeatureSet } from "../featureSet"; import { getGlobalFlags } from "../globalFlags"; +import { escapeCommandArg } from "../util"; import { errToStr, createWorkspaceIdentifier } from "./api-helper"; import { CoderApi } from "./coderApi"; @@ -36,7 +37,9 @@ export async function startWorkspaceIfStoppedOrFailed( startArgs.push(...["--reason", "vscode_connection"]); } - const startProcess = spawn(binPath, startArgs, { shell: true }); + // { shell: true } requires one shell-safe command string, otherwise we lose all escaping + const cmd = `${escapeCommandArg(binPath)} ${startArgs.join(" ")}`; + const startProcess = spawn(cmd, { shell: true }); startProcess.stdout.on("data", (data: Buffer) => { data From a09d254175bb73f47b4ff7bf9c91212bf9c131e2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 24 Sep 2025 10:44:46 +0300 Subject: [PATCH 02/69] chore(deps-dev): bump @vscode/test-cli from 0.0.10 to 0.0.11 (#568) --- package.json | 2 +- yarn.lock | 145 ++++++++++++++------------------------------------- 2 files changed, 41 insertions(+), 106 deletions(-) diff --git a/package.json b/package.json index c250c02f..9aa5d05d 100644 --- a/package.json +++ b/package.json @@ -336,7 +336,7 @@ "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^6.21.0", - "@vscode/test-cli": "^0.0.10", + "@vscode/test-cli": "^0.0.11", "@vscode/test-electron": "^2.5.2", "@vscode/vsce": "^3.6.0", "bufferutil": "^4.0.9", diff --git a/yarn.lock b/yarn.lock index 5cc462f3..f30780a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1233,10 +1233,10 @@ loupe "^2.3.6" pretty-format "^29.5.0" -"@vscode/test-cli@^0.0.10": - version "0.0.10" - resolved "https://registry.yarnpkg.com/@vscode/test-cli/-/test-cli-0.0.10.tgz#35f0e81c2e0ff8daceb223e99d1b65306c15822c" - integrity sha512-B0mMH4ia+MOOtwNiLi79XhA+MLmUItIC8FckEuKrVAVriIuSWjt7vv4+bF8qVFiNFe4QRfzPaIZk39FZGWEwHA== +"@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== dependencies: "@types/mocha" "^10.0.2" c8 "^9.1.0" @@ -1244,7 +1244,7 @@ enhanced-resolve "^5.15.0" glob "^10.3.10" minimatch "^9.0.3" - mocha "^10.2.0" + mocha "^11.1.0" supports-color "^9.4.0" yargs "^17.7.2" @@ -1588,11 +1588,6 @@ ajv@^8.0.0, ajv@^8.0.1, ajv@^8.17.1, ajv@^8.9.0: json-schema-traverse "^1.0.0" require-from-string "^2.0.2" -ansi-colors@^4.1.3: - version "4.1.3" - resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b" - integrity sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw== - ansi-escapes@^4.2.1: version "4.3.2" resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" @@ -2201,6 +2196,13 @@ chokidar@^3.5.3: optionalDependencies: fsevents "~2.3.2" +chokidar@^4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30" + integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA== + dependencies: + readdirp "^4.0.1" + chownr@^1.1.1: version "1.1.4" resolved "https://registry.yarnpkg.com/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b" @@ -2249,15 +2251,6 @@ cliui@^6.0.0: strip-ansi "^6.0.0" wrap-ansi "^6.2.0" -cliui@^7.0.2: - version "7.0.4" - resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" - integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== - dependencies: - string-width "^4.2.0" - strip-ansi "^6.0.0" - wrap-ansi "^7.0.0" - cliui@^8.0.1: version "8.0.1" resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" @@ -2625,10 +2618,10 @@ diff-sequences@^29.4.3: resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.6.3.tgz#4deaf894d11407c51efc8418012f9e70b84ea921" integrity sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q== -diff@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/diff/-/diff-5.2.0.tgz#26ded047cd1179b78b9537d5ef725503ce1ae531" - integrity sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A== +diff@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-7.0.0.tgz#3fb34d387cd76d803f6eebea67b921dab0182a9a" + integrity sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw== dir-glob@^3.0.1: version "3.0.1" @@ -2748,15 +2741,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.17.1: - version "5.17.1" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz#67bfbbcc2f81d511be77d686a90267ef7f898a15" - integrity sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg== - dependencies: - graceful-fs "^4.2.4" - tapable "^2.2.0" - -enhanced-resolve@^5.15.0: +enhanced-resolve@^5.0.0, enhanced-resolve@^5.15.0, enhanced-resolve@^5.17.1: version "5.18.1" resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz#728ab082f8b7b6836de51f1637aab5d3b9568faf" integrity sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg== @@ -3816,7 +3801,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.3.10, glob@^10.4.2, 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== @@ -3828,18 +3813,6 @@ glob@^10.3.10: package-json-from-dist "^1.0.0" path-scurry "^1.11.1" -glob@^10.4.2: - version "10.4.2" - resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.2.tgz#bed6b95dade5c1f80b4434daced233aee76160e5" - integrity sha512-GwMlUF6PkPo3Gk21UxkCohOv0PLcIXVtKyLlpEI28R/cO/4eNOdmLk3CMW1wROV/WR/EsZOWAfBbBOqYvs88/w== - dependencies: - foreground-child "^3.1.0" - jackspeak "^3.1.2" - minimatch "^9.0.4" - minipass "^7.1.2" - 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" @@ -3864,17 +3837,6 @@ glob@^7.1.3, glob@^7.1.4, glob@^7.1.6: once "^1.3.0" path-is-absolute "^1.0.0" -glob@^8.1.0: - version "8.1.0" - resolved "https://registry.yarnpkg.com/glob/-/glob-8.1.0.tgz#d388f656593ef708ee3e34640fdfb99a9fd1c33e" - integrity sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^5.0.1" - once "^1.3.0" - globals@^11.1.0: version "11.12.0" resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" @@ -5175,27 +5137,13 @@ minimatch@^3.0.3, minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatc dependencies: brace-expansion "^1.1.7" -minimatch@^5.0.1, minimatch@^5.1.6: - version "5.1.6" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.1.6.tgz#1cfcb8cf5522ea69952cd2af95ae09477f122a96" - integrity sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g== - dependencies: - brace-expansion "^2.0.1" - -minimatch@^9.0.3: +minimatch@^9.0.3, minimatch@^9.0.4, minimatch@^9.0.5: version "9.0.5" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== dependencies: brace-expansion "^2.0.1" -minimatch@^9.0.4: - version "9.0.4" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.4.tgz#8e49c731d1749cbec05050ee5145147b32496a51" - integrity sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw== - dependencies: - brace-expansion "^2.0.1" - minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.6: version "1.2.7" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.7.tgz#daa1c4d91f507390437c6a8bc01078e7000c4d18" @@ -5228,30 +5176,30 @@ mlly@^1.2.0, mlly@^1.4.0: pkg-types "^1.0.3" ufo "^1.3.0" -mocha@^10.2.0: - version "10.8.2" - resolved "https://registry.yarnpkg.com/mocha/-/mocha-10.8.2.tgz#8d8342d016ed411b12a429eb731b825f961afb96" - integrity sha512-VZlYo/WE8t1tstuRmqgeyBgCbJc/lEdopaa+axcKzTBJ+UIdlAB9XnmvTCAH4pwR4ElNInaedhEBmZD8iCSVEg== +mocha@^11.1.0: + version "11.7.2" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-11.7.2.tgz#3c0079fe5cc2f8ea86d99124debcc42bb1ab22b5" + integrity sha512-lkqVJPmqqG/w5jmmFtiRvtA2jkDyNVUcefFJKb2uyX4dekk8Okgqop3cgbFiaIvj8uCRJVTP5x9dfxGyXm2jvQ== dependencies: - ansi-colors "^4.1.3" browser-stdout "^1.3.1" - chokidar "^3.5.3" + chokidar "^4.0.1" debug "^4.3.5" - diff "^5.2.0" + diff "^7.0.0" escape-string-regexp "^4.0.0" find-up "^5.0.0" - glob "^8.1.0" + glob "^10.4.5" he "^1.2.0" js-yaml "^4.1.0" log-symbols "^4.1.0" - minimatch "^5.1.6" + minimatch "^9.0.5" ms "^2.1.3" + picocolors "^1.1.1" serialize-javascript "^6.0.2" strip-json-comments "^3.1.1" supports-color "^8.1.1" - workerpool "^6.5.1" - yargs "^16.2.0" - yargs-parser "^20.2.9" + workerpool "^9.2.0" + yargs "^17.7.2" + yargs-parser "^21.1.1" yargs-unparser "^2.0.0" ms@^2.1.1, ms@^2.1.3: @@ -6014,6 +5962,11 @@ readable-stream@^3.1.1, readable-stream@^3.4.0: string_decoder "^1.1.1" util-deprecate "^1.0.1" +readdirp@^4.0.1: + version "4.1.2" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d" + integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg== + readdirp@~3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" @@ -8184,10 +8137,10 @@ word-wrap@1.2.5, word-wrap@~1.2.3: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -workerpool@^6.5.1: - version "6.5.1" - resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.5.1.tgz#060f73b39d0caf97c6db64da004cd01b4c099544" - integrity sha512-Fs4dNYcsdpYSAfVxhnl1L5zTksjvOJxtC5hzMNl+1t9B8hTJTdKDyZ5ju7ztgPy+ft9tBFXoOlDNiOT9WUXZlA== +workerpool@^9.2.0: + version "9.3.4" + resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-9.3.4.tgz#f6c92395b2141afd78e2a889e80cb338fe9fca41" + integrity sha512-TmPRQYYSAnnDiEB0P/Ytip7bFGvqnSU6I2BcuSw7Hx+JSg/DsUi5ebYfc8GYaSdpuvOcEs6dXxPurOYpe9QFwg== "wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" @@ -8322,11 +8275,6 @@ yargs-parser@^18.1.2: camelcase "^5.0.0" decamelize "^1.2.0" -yargs-parser@^20.2.2, yargs-parser@^20.2.9: - version "20.2.9" - resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" - integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== - yargs-parser@^21.1.1: version "21.1.1" resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" @@ -8364,19 +8312,6 @@ yargs@^15.0.2: y18n "^4.0.0" yargs-parser "^18.1.2" -yargs@^16.2.0: - version "16.2.0" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" - integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== - dependencies: - cliui "^7.0.2" - escalade "^3.1.1" - get-caller-file "^2.0.5" - require-directory "^2.1.1" - string-width "^4.2.0" - y18n "^5.0.5" - yargs-parser "^20.2.2" - yargs@^17.7.2: version "17.7.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" From dc4d6d472fba848c254ab9f087f0ebe5e5daa68a Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 24 Sep 2025 12:09:41 +0300 Subject: [PATCH 03/69] v1.11.0 (#593) --- CHANGELOG.md | 2 ++ package.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4170b73d..35649a76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## [v1.11.0](https://github.com/coder/vscode-coder/releases/tag/v1.11.0) 2025-09-24 + ### Changed - Always enable verbose (`-v`) flag when a log directory is configured (`coder.proxyLogDir`). diff --git a/package.json b/package.json index 9aa5d05d..b07e754f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "coder-remote", "displayName": "Coder", - "version": "1.10.1", + "version": "1.11.0", "description": "Open any workspace with a single click.", "categories": [ "Other" From 52df12cbadde5d6fb87d78e1e2c5726958ee33f3 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 25 Sep 2025 13:09:55 +0300 Subject: [PATCH 04/69] refactor(storage): split storage.ts into isolated modules; add unit tests; upgrade vitest (#589) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #588 This PR refactors `storage.ts` into small, focused modules that are straightforward to unit test (with mocks). It also upgrades `vitest` to a version that plays nicely with VS Code extensions so we can view coverage and run/debug tests directly in VS Code. Key changes - Extract path resolution from `storage.ts` → dedicated module - Extract memento & secrets management from `storage.ts` → dedicated module - Extract and fully separate CLI management logic → dedicated module - Remove `storage.ts` entirely in favor of the new modules - Add unit tests for the split modules - Upgrade `vitest` and related tooling for VS Code extension testing Why mock `vscode`? - Unit tests (mocked `vscode`): fast, reliable, deterministic validation of module behavior without depending on VS Code APIs or external calls (e.g., Axios). - Integration/E2E tests (real VS Code): cover end-to-end flows by launching VS Code (and eventually a server). Valuable but slower and harder to automate; we reserve these for scenarios that require the actual runtime. --- package.json | 5 +- src/__mocks__/testHelpers.ts | 274 ++++ src/__mocks__/vscode.runtime.ts | 142 ++ src/{cliManager.test.ts => cliUtils.test.ts} | 12 +- src/{cliManager.ts => cliUtils.ts} | 14 - src/commands.ts | 62 +- src/core/cliManager.test.ts | 795 +++++++++++ src/{storage.ts => core/cliManager.ts} | 283 +--- src/core/mementoManager.test.ts | 81 ++ src/core/mementoManager.ts | 71 + src/core/pathResolver.test.ts | 48 + src/core/pathResolver.ts | 115 ++ src/core/secretsManager.test.ts | 42 + src/core/secretsManager.ts | 29 + src/error.test.ts | 446 +++--- src/extension.ts | 82 +- src/headers.test.ts | 16 +- src/inbox.ts | 17 +- src/pgp.ts | 8 +- src/remote.ts | 140 +- src/workspaceMonitor.ts | 10 +- src/workspacesProvider.ts | 8 +- vitest.config.ts | 9 + yarn.lock | 1275 ++++++++++-------- 24 files changed, 2760 insertions(+), 1224 deletions(-) create mode 100644 src/__mocks__/testHelpers.ts create mode 100644 src/__mocks__/vscode.runtime.ts rename src/{cliManager.test.ts => cliUtils.test.ts} (95%) rename src/{cliManager.ts => cliUtils.ts} (92%) create mode 100644 src/core/cliManager.test.ts rename src/{storage.ts => core/cliManager.ts} (68%) create mode 100644 src/core/mementoManager.test.ts create mode 100644 src/core/mementoManager.ts create mode 100644 src/core/pathResolver.test.ts create mode 100644 src/core/pathResolver.ts create mode 100644 src/core/secretsManager.test.ts create mode 100644 src/core/secretsManager.ts diff --git a/package.json b/package.json index b07e754f..9fb96fcb 100644 --- a/package.json +++ b/package.json @@ -316,7 +316,6 @@ "eventsource": "^3.0.6", "find-process": "https://github.com/coder/find-process#fix/sequoia-compat", "jsonc-parser": "^3.3.1", - "memfs": "^4.17.1", "node-forge": "^1.3.1", "openpgp": "^6.2.0", "pretty-bytes": "^7.0.0", @@ -336,6 +335,7 @@ "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^6.21.0", + "@vitest/coverage-v8": "^3.2.4", "@vscode/test-cli": "^0.0.11", "@vscode/test-electron": "^2.5.2", "@vscode/vsce": "^3.6.0", @@ -350,12 +350,13 @@ "eslint-plugin-prettier": "^5.4.1", "glob": "^10.4.2", "jsonc-eslint-parser": "^2.4.0", + "memfs": "^4.46.0", "nyc": "^17.1.0", "prettier": "^3.5.3", "ts-loader": "^9.5.1", "typescript": "^5.8.3", "utf-8-validate": "^6.0.5", - "vitest": "^0.34.6", + "vitest": "^3.2.4", "vscode-test": "^1.5.0", "webpack": "^5.99.6", "webpack-cli": "^5.1.4" diff --git a/src/__mocks__/testHelpers.ts b/src/__mocks__/testHelpers.ts new file mode 100644 index 00000000..3a4ce407 --- /dev/null +++ b/src/__mocks__/testHelpers.ts @@ -0,0 +1,274 @@ +import { vi } from "vitest"; +import * as vscode from "vscode"; + +/** + * 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(); + + constructor() { + this.setupVSCodeMock(); + } + + /** + * Set a configuration value that will be returned by vscode.workspace.getConfiguration().get() + */ + set(key: string, value: unknown): void { + this.config.set(key, value); + } + + /** + * Get a configuration value (for testing purposes) + */ + get(key: string): T | undefined; + 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; + } + + /** + * Clear all configuration values + */ + clear(): void { + this.config.clear(); + } + + /** + * Setup the vscode.workspace.getConfiguration mock to return our values + */ + private setupVSCodeMock(): void { + vi.mocked(vscode.workspace.getConfiguration).mockImplementation( + (section?: string) => { + // Create a snapshot of the current config when getConfiguration is called + const snapshot = new Map(this.config); + const getFullKey = (part: string) => + section ? `${section}.${part}` : part; + + return { + get: vi.fn((key: string, defaultValue?: unknown) => { + const value = snapshot.get(getFullKey(key)); + return value !== undefined ? value : defaultValue; + }), + has: vi.fn((key: string) => { + return snapshot.has(getFullKey(key)); + }), + inspect: vi.fn(), + update: vi.fn((key: string, value: unknown) => { + this.config.set(getFullKey(key), value); + return Promise.resolve(); + }), + }; + }, + ); + } +} + +/** + * Mock progress reporter that integrates with vscode.window.withProgress. + * Use this to control progress reporting behavior and cancellation in tests. + */ +export class MockProgressReporter { + private shouldCancel = false; + private progressReports: Array<{ message?: string; increment?: number }> = []; + + constructor() { + this.setupVSCodeMock(); + } + + /** + * Set whether the progress should be cancelled + */ + setCancellation(cancel: boolean): void { + this.shouldCancel = cancel; + } + + /** + * Get all progress reports that were made + */ + getProgressReports(): Array<{ message?: string; increment?: number }> { + return [...this.progressReports]; + } + + /** + * Clear all progress reports + */ + clearProgressReports(): void { + this.progressReports = []; + } + + /** + * Setup the vscode.window.withProgress mock + */ + private setupVSCodeMock(): void { + vi.mocked(vscode.window.withProgress).mockImplementation( + async ( + _options: vscode.ProgressOptions, + task: ( + progress: vscode.Progress<{ message?: string; increment?: number }>, + token: vscode.CancellationToken, + ) => Thenable, + ): Promise => { + const progress = { + report: vi.fn((value: { message?: string; increment?: number }) => { + this.progressReports.push(value); + }), + }; + + const cancellationToken: vscode.CancellationToken = { + isCancellationRequested: this.shouldCancel, + onCancellationRequested: vi.fn((listener: (x: unknown) => void) => { + if (this.shouldCancel) { + setTimeout(listener, 0); + } + return { dispose: vi.fn() }; + }), + }; + + return task(progress, cancellationToken); + }, + ); + } +} + +/** + * Mock user interaction that integrates with vscode.window message dialogs. + * Use this to control user responses in tests. + */ +export class MockUserInteraction { + private responses = new Map(); + private externalUrls: string[] = []; + + constructor() { + this.setupVSCodeMock(); + } + + /** + * Set a response for a specific message + */ + setResponse(message: string, response: string | undefined): void { + this.responses.set(message, response); + } + + /** + * Get all URLs that were opened externally + */ + getExternalUrls(): string[] { + return [...this.externalUrls]; + } + + /** + * Clear all external URLs + */ + clearExternalUrls(): void { + this.externalUrls = []; + } + + /** + * Clear all responses + */ + clearResponses(): void { + this.responses.clear(); + } + + /** + * Setup the vscode.window message dialog mocks + */ + private setupVSCodeMock(): void { + const getResponse = (message: string): string | undefined => { + return this.responses.get(message); + }; + + vi.mocked(vscode.window.showErrorMessage).mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (message: string): Thenable => { + const response = getResponse(message); + return Promise.resolve(response); + }, + ); + + vi.mocked(vscode.window.showWarningMessage).mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (message: string): Thenable => { + const response = getResponse(message); + return Promise.resolve(response); + }, + ); + + vi.mocked(vscode.window.showInformationMessage).mockImplementation( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (message: string): Thenable => { + const response = getResponse(message); + return Promise.resolve(response); + }, + ); + + vi.mocked(vscode.env.openExternal).mockImplementation( + (target: vscode.Uri): Promise => { + this.externalUrls.push(target.toString()); + return Promise.resolve(true); + }, + ); + } +} + +// Simple in-memory implementation of Memento +export class InMemoryMemento implements vscode.Memento { + private storage = new Map(); + + get(key: string): T | undefined; + get(key: string, defaultValue: T): T; + get(key: string, defaultValue?: T): T | undefined { + return this.storage.has(key) ? (this.storage.get(key) as T) : defaultValue; + } + + async update(key: string, value: unknown): Promise { + if (value === undefined) { + this.storage.delete(key); + } else { + this.storage.set(key, value); + } + return Promise.resolve(); + } + + keys(): readonly string[] { + return Array.from(this.storage.keys()); + } +} + +// Simple in-memory implementation of SecretStorage +export class InMemorySecretStorage implements vscode.SecretStorage { + private secrets = new Map(); + private isCorrupted = false; + + onDidChange: vscode.Event = () => ({ + dispose: () => {}, + }); + + async get(key: string): Promise { + if (this.isCorrupted) { + return Promise.reject(new Error("Storage corrupted")); + } + return this.secrets.get(key); + } + + async store(key: string, value: string): Promise { + if (this.isCorrupted) { + return Promise.reject(new Error("Storage corrupted")); + } + this.secrets.set(key, value); + } + + async delete(key: string): Promise { + if (this.isCorrupted) { + return Promise.reject(new Error("Storage corrupted")); + } + this.secrets.delete(key); + } + + corruptStorage(): void { + this.isCorrupted = true; + } +} diff --git a/src/__mocks__/vscode.runtime.ts b/src/__mocks__/vscode.runtime.ts new file mode 100644 index 00000000..2201a851 --- /dev/null +++ b/src/__mocks__/vscode.runtime.ts @@ -0,0 +1,142 @@ +import { vi } from "vitest"; + +// enum-like helpers +const E = >(o: T) => Object.freeze(o); + +export const ProgressLocation = E({ + SourceControl: 1, + Window: 10, + Notification: 15, +}); +export const ViewColumn = E({ + Active: -1, + Beside: -2, + One: 1, + Two: 2, + Three: 3, +}); +export const ConfigurationTarget = E({ + Global: 1, + Workspace: 2, + WorkspaceFolder: 3, +}); +export const TreeItemCollapsibleState = E({ + None: 0, + Collapsed: 1, + Expanded: 2, +}); +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 class Uri { + constructor( + public scheme: string, + public path: string, + ) {} + static file(p: string) { + return new Uri("file", p); + } + static parse(v: string) { + if (v.startsWith("file://")) { + return Uri.file(v.slice("file://".length)); + } + const [scheme, ...rest] = v.split(":"); + return new Uri(scheme, rest.join(":")); + } + toString() { + return this.scheme === "file" + ? `file://${this.path}` + : `${this.scheme}:${this.path}`; + } + static joinPath(base: Uri, ...paths: string[]) { + const sep = base.path.endsWith("/") ? "" : "/"; + return new Uri(base.scheme, base.path + sep + paths.join("/")); + } +} + +// 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) }; + }; + return { event, fire: (e: T) => listeners.forEach((l) => l(e)) }; +}; + +const onDidChangeConfiguration = makeEvent(); +const onDidChangeWorkspaceFolders = makeEvent(); + +export const window = { + showInformationMessage: vi.fn(), + showWarningMessage: vi.fn(), + showErrorMessage: vi.fn(), + showQuickPick: vi.fn(), + showInputBox: vi.fn(), + withProgress: vi.fn(), + createOutputChannel: vi.fn(() => ({ + appendLine: vi.fn(), + append: vi.fn(), + show: vi.fn(), + hide: vi.fn(), + dispose: vi.fn(), + clear: vi.fn(), + })), +}; + +export const commands = { + registerCommand: vi.fn(), + executeCommand: vi.fn(), +}; + +export const workspace = { + getConfiguration: vi.fn(), // your helpers override this + workspaceFolders: [] as unknown[], + fs: { + readFile: vi.fn(), + writeFile: vi.fn(), + stat: vi.fn(), + readDirectory: vi.fn(), + }, + onDidChangeConfiguration: onDidChangeConfiguration.event, + onDidChangeWorkspaceFolders: onDidChangeWorkspaceFolders.event, + + // test-only triggers: + __fireDidChangeConfiguration: onDidChangeConfiguration.fire, + __fireDidChangeWorkspaceFolders: onDidChangeWorkspaceFolders.fire, +}; + +export const env = { + appName: "Visual Studio Code", + appRoot: "/app", + language: "en", + machineId: "test-machine-id", + sessionId: "test-session-id", + remoteName: undefined as string | undefined, + shell: "/bin/bash", + openExternal: vi.fn(), +}; + +export const extensions = { + getExtension: vi.fn(), + all: [] as unknown[], +}; + +const vscode = { + ProgressLocation, + ViewColumn, + ConfigurationTarget, + TreeItemCollapsibleState, + StatusBarAlignment, + ExtensionMode, + UIKind, + Uri, + window, + commands, + workspace, + env, + extensions, +}; + +export default vscode; diff --git a/src/cliManager.test.ts b/src/cliUtils.test.ts similarity index 95% rename from src/cliManager.test.ts rename to src/cliUtils.test.ts index 87540a61..aec78e87 100644 --- a/src/cliManager.test.ts +++ b/src/cliUtils.test.ts @@ -2,9 +2,9 @@ import fs from "fs/promises"; import os from "os"; import path from "path"; import { beforeAll, describe, expect, it } from "vitest"; -import * as cli from "./cliManager"; +import * as cli from "./cliUtils"; -describe("cliManager", () => { +describe("cliUtils", () => { const tmp = path.join(os.tmpdir(), "vscode-coder-tests"); beforeAll(async () => { @@ -25,14 +25,6 @@ describe("cliManager", () => { expect((await cli.stat(binPath))?.size).toBe(4); }); - it("rm", async () => { - const binPath = path.join(tmp, "rm"); - await cli.rm(binPath); - - await fs.writeFile(binPath, "test"); - await cli.rm(binPath); - }); - // TODO: CI only runs on Linux but we should run it on Windows too. it("version", async () => { const binPath = path.join(tmp, "version"); diff --git a/src/cliManager.ts b/src/cliUtils.ts similarity index 92% rename from src/cliManager.ts rename to src/cliUtils.ts index 60b63f92..cc92a345 100644 --- a/src/cliManager.ts +++ b/src/cliUtils.ts @@ -21,20 +21,6 @@ export async function stat(binPath: string): Promise { } } -/** - * Remove the path. Throw if unable to remove. - */ -export async function rm(binPath: string): Promise { - try { - await fs.rm(binPath, { force: true }); - } catch (error) { - // Just in case; we should never get an ENOENT because of force: true. - if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") { - throw error; - } - } -} - // util.promisify types are dynamic so there is no concrete type we can import // and we have to make our own. type ExecException = ExecFileException & { stdout?: string; stderr?: string }; diff --git a/src/commands.ts b/src/commands.ts index 9961c82b..914adbfc 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -5,14 +5,17 @@ import { Workspace, WorkspaceAgent, } from "coder/site/src/api/typesGenerated"; -import 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 { CliManager } from "./core/cliManager"; +import { MementoManager } from "./core/mementoManager"; +import { PathResolver } from "./core/pathResolver"; +import { SecretsManager } from "./core/secretsManager"; import { CertificateError } from "./error"; import { getGlobalFlags } from "./globalFlags"; -import { Storage } from "./storage"; +import { Logger } from "./logging/logger"; import { escapeCommandArg, toRemoteAuthority, toSafeHost } from "./util"; import { AgentTreeItem, @@ -35,7 +38,11 @@ export class Commands { public constructor( private readonly vscodeProposed: typeof vscode, private readonly restClient: Api, - private readonly storage: Storage, + private readonly logger: Logger, + private readonly pathResolver: PathResolver, + private readonly mementoManager: MementoManager, + private readonly secretsManager: SecretsManager, + private readonly cliManager: CliManager, ) {} /** @@ -103,7 +110,7 @@ export class Commands { quickPick.title = "Enter the URL of your Coder deployment."; // Initial items. - quickPick.items = this.storage + quickPick.items = this.mementoManager .withUrlHistory(defaultURL, process.env.CODER_URL) .map((url) => ({ alwaysShow: true, @@ -114,7 +121,7 @@ export class Commands { // an option in case the user wants to connect to something that is not in // the list. quickPick.onDidChangeValue((value) => { - quickPick.items = this.storage + quickPick.items = this.mementoManager .withUrlHistory(defaultURL, process.env.CODER_URL, value) .map((url) => ({ alwaysShow: true, @@ -194,11 +201,11 @@ export class Commands { this.restClient.setSessionToken(res.token); // Store these to be used in later sessions. - await this.storage.setUrl(url); - await this.storage.setSessionToken(res.token); + await this.mementoManager.setUrl(url); + await this.secretsManager.setSessionToken(res.token); // Store on disk to be used by the cli. - await this.storage.configureCli(label, url, res.token); + await this.cliManager.configure(label, url, res.token); // These contexts control various menu items and the sidebar. await vscode.commands.executeCommand( @@ -240,7 +247,7 @@ export class Commands { token: string, isAutologin: boolean, ): Promise<{ user: User; token: string } | null> { - const client = CoderApi.create(url, token, this.storage.output, () => + const client = CoderApi.create(url, token, this.logger, () => vscode.workspace.getConfiguration(), ); if (!needToken(vscode.workspace.getConfiguration())) { @@ -252,10 +259,7 @@ export class Commands { } catch (err) { const message = getErrorMessage(err, "no response from the server"); if (isAutologin) { - this.storage.output.warn( - "Failed to log in to Coder server:", - message, - ); + this.logger.warn("Failed to log in to Coder server:", message); } else { this.vscodeProposed.window.showErrorMessage( "Failed to log in to Coder server", @@ -283,7 +287,7 @@ export class Commands { title: "Coder API Key", password: true, placeHolder: "Paste your API key.", - value: token || (await this.storage.getSessionToken()), + value: token || (await this.secretsManager.getSessionToken()), ignoreFocusOut: true, validateInput: async (value) => { client.setSessionToken(value); @@ -349,7 +353,7 @@ export class Commands { * Log out from the currently logged-in deployment. */ public async logout(): Promise { - const url = this.storage.getUrl(); + 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"); @@ -361,8 +365,8 @@ export class Commands { this.restClient.setSessionToken(""); // Clear from memory. - await this.storage.setUrl(undefined); - await this.storage.setSessionToken(undefined); + await this.mementoManager.setUrl(undefined); + await this.secretsManager.setSessionToken(undefined); await vscode.commands.executeCommand( "setContext", @@ -387,7 +391,7 @@ export class Commands { * Must only be called if currently logged in. */ public async createWorkspace(): Promise { - const uri = this.storage.getUrl() + "/templates"; + const uri = this.mementoManager.getUrl() + "/templates"; await vscode.commands.executeCommand("vscode.open", uri); } @@ -402,7 +406,7 @@ export class Commands { public async navigateToWorkspace(item: OpenableTreeItem) { if (item) { const workspaceId = createWorkspaceIdentifier(item.workspace); - const uri = this.storage.getUrl() + `/@${workspaceId}`; + const uri = this.mementoManager.getUrl() + `/@${workspaceId}`; await vscode.commands.executeCommand("vscode.open", uri); } else if (this.workspace && this.workspaceRestClient) { const baseUrl = @@ -425,7 +429,7 @@ export class Commands { public async navigateToWorkspaceSettings(item: OpenableTreeItem) { if (item) { const workspaceId = createWorkspaceIdentifier(item.workspace); - const uri = this.storage.getUrl() + `/@${workspaceId}/settings`; + const uri = this.mementoManager.getUrl() + `/@${workspaceId}/settings`; await vscode.commands.executeCommand("vscode.open", uri); } else if (this.workspace && this.workspaceRestClient) { const baseUrl = @@ -503,17 +507,17 @@ export class Commands { // If workspace_name is provided, run coder ssh before the command - const url = this.storage.getUrl(); + const url = this.mementoManager.getUrl(); if (!url) { throw new Error("No coder url found for sidebar"); } - const binary = await this.storage.fetchBinary( + const binary = await this.cliManager.fetchBinary( this.restClient, toSafeHost(url), ); - const configDir = path.dirname( - this.storage.getSessionTokenPath(toSafeHost(url)), + const configDir = this.pathResolver.getGlobalConfigDir( + toSafeHost(url), ); const globalFlags = getGlobalFlags( vscode.workspace.getConfiguration(), @@ -645,8 +649,8 @@ export class Commands { newWindow = false; } - // Only set the memento if when opening a new folder - await this.storage.setFirstConnect(); + // Only set the memento when opening a new folder + await this.mementoManager.setFirstConnect(); await vscode.commands.executeCommand( "vscode.openFolder", vscode.Uri.from({ @@ -755,7 +759,7 @@ export class Commands { // If we have no agents, the workspace may not be running, in which case // we need to fetch the agents through the resources API, as the // workspaces query does not include agents when off. - this.storage.output.info("Fetching agents from template version"); + this.logger.info("Fetching agents from template version"); const resources = await this.restClient.getTemplateVersionResources( workspace.latest_build.template_version_id, ); @@ -826,8 +830,8 @@ export class Commands { } } - // Only set the memento if when opening a new folder/window - await this.storage.setFirstConnect(); + // Only set the memento when opening a new folder/window + await this.mementoManager.setFirstConnect(); if (folderPath) { await vscode.commands.executeCommand( "vscode.openFolder", diff --git a/src/core/cliManager.test.ts b/src/core/cliManager.test.ts new file mode 100644 index 00000000..676de44c --- /dev/null +++ b/src/core/cliManager.test.ts @@ -0,0 +1,795 @@ +import globalAxios, { AxiosInstance } from "axios"; +import { Api } from "coder/site/src/api/api"; +import EventEmitter from "events"; +import * as fs from "fs"; +import { IncomingMessage } from "http"; +import { fs as memfs, vol } from "memfs"; +import * as os from "os"; +import * as path from "path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as vscode from "vscode"; +import { + MockConfigurationProvider, + MockProgressReporter, + MockUserInteraction, +} from "../__mocks__/testHelpers"; +import * as cli from "../cliUtils"; +import { Logger } from "../logging/logger"; +import * as pgp from "../pgp"; +import { CliManager } from "./cliManager"; +import { PathResolver } from "./pathResolver"; + +vi.mock("os"); +vi.mock("axios"); +vi.mock("../pgp"); + +vi.mock("fs", async () => { + const memfs: { fs: typeof fs } = await vi.importActual("memfs"); + return { + ...memfs.fs, + default: memfs.fs, + }; +}); + +vi.mock("fs/promises", async () => { + const memfs: { fs: typeof fs } = await vi.importActual("memfs"); + return { + ...memfs.fs.promises, + default: memfs.fs.promises, + }; +}); + +// Only mock the platform detection functions from CLI manager +vi.mock("../cliUtils", async () => { + const actual = + await vi.importActual("../cliUtils"); + return { + ...actual, + // No need to test script execution here + version: vi.fn(), + }; +}); + +describe("CliManager", () => { + let manager: CliManager; + let mockConfig: MockConfigurationProvider; + let mockProgress: MockProgressReporter; + let mockUI: MockUserInteraction; + let mockApi: Api; + let mockAxios: AxiosInstance; + + const TEST_VERSION = "1.2.3"; + const TEST_URL = "https://test.coder.com"; + const BASE_PATH = "/path/base"; + const BINARY_DIR = `${BASE_PATH}/test/bin`; + const PLATFORM = "linux"; + const ARCH = "amd64"; + const BINARY_NAME = `coder-${PLATFORM}-${ARCH}`; + const BINARY_PATH = `${BINARY_DIR}/${BINARY_NAME}`; + + beforeEach(() => { + vi.resetAllMocks(); + vol.reset(); + + // Core setup + mockApi = createMockApi(TEST_VERSION, TEST_URL); + mockAxios = mockApi.getAxiosInstance(); + vi.mocked(globalAxios.create).mockReturnValue(mockAxios); + mockConfig = new MockConfigurationProvider(); + mockProgress = new MockProgressReporter(); + mockUI = new MockUserInteraction(); + manager = new CliManager( + vscode, + createMockLogger(), + new PathResolver(BASE_PATH, "/code/log"), + ); + + // Mock only what's necessary + vi.mocked(os.platform).mockReturnValue(PLATFORM); + vi.mocked(os.arch).mockReturnValue(ARCH); + vi.mocked(pgp.readPublicKeys).mockResolvedValue([]); + }); + + afterEach(async () => { + mockProgress?.setCancellation(false); + vi.clearAllTimers(); + // memfs internally schedules some FS operations so we have to wait for them to finish + await new Promise((resolve) => setImmediate(resolve)); + vol.reset(); + }); + + describe("Configure CLI", () => { + it("should write both url and token to correct paths", async () => { + await manager.configure( + "deployment", + "https://coder.example.com", + "test-token", + ); + + expect(memfs.readFileSync("/path/base/deployment/url", "utf8")).toBe( + "https://coder.example.com", + ); + expect(memfs.readFileSync("/path/base/deployment/session", "utf8")).toBe( + "test-token", + ); + }); + + it("should skip URL when undefined but write token", async () => { + await manager.configure("deployment", undefined, "test-token"); + + // No entry for the url + expect(memfs.existsSync("/path/base/deployment/url")).toBe(false); + expect(memfs.readFileSync("/path/base/deployment/session", "utf8")).toBe( + "test-token", + ); + }); + + it("should skip token when null but write URL", async () => { + await manager.configure("deployment", "https://coder.example.com", null); + + // No entry for the session + expect(memfs.readFileSync("/path/base/deployment/url", "utf8")).toBe( + "https://coder.example.com", + ); + expect(memfs.existsSync("/path/base/deployment/session")).toBe(false); + }); + + it("should write empty string for token when provided", async () => { + await manager.configure("deployment", "https://coder.example.com", ""); + + expect(memfs.readFileSync("/path/base/deployment/url", "utf8")).toBe( + "https://coder.example.com", + ); + expect(memfs.readFileSync("/path/base/deployment/session", "utf8")).toBe( + "", + ); + }); + + it("should use base path directly when label is empty", async () => { + await manager.configure("", "https://coder.example.com", "token"); + + expect(memfs.readFileSync("/path/base/url", "utf8")).toBe( + "https://coder.example.com", + ); + expect(memfs.readFileSync("/path/base/session", "utf8")).toBe("token"); + }); + }); + + 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" }); + await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( + "Got invalid version from deployment", + ); + }); + + it("accepts valid semver versions", async () => { + withExistingBinary(TEST_VERSION); + const result = await manager.fetchBinary(mockApi, "test"); + expect(result).toBe(BINARY_PATH); + }); + }); + + describe("Existing Binary Handling", () => { + beforeEach(() => { + // Disable signature verification for these tests + mockConfig.set("coder.disableSignatureVerification", true); + }); + + it("reuses matching binary without downloading", async () => { + withExistingBinary(TEST_VERSION); + const result = await manager.fetchBinary(mockApi, "test"); + expect(result).toBe(BINARY_PATH); + expect(mockAxios.get).not.toHaveBeenCalled(); + // Verify binary still exists + expect(memfs.existsSync(BINARY_PATH)).toBe(true); + }); + + it("downloads when versions differ", async () => { + withExistingBinary("1.0.0"); + withSuccessfulDownload(); + const result = await manager.fetchBinary(mockApi, "test"); + expect(result).toBe(BINARY_PATH); + expect(mockAxios.get).toHaveBeenCalled(); + // Verify new binary exists + expect(memfs.existsSync(BINARY_PATH)).toBe(true); + expect(memfs.readFileSync(BINARY_PATH).toString()).toBe( + mockBinaryContent(TEST_VERSION), + ); + }); + + it("keeps mismatched binary when downloads disabled", async () => { + mockConfig.set("coder.enableDownloads", false); + withExistingBinary("1.0.0"); + const result = await manager.fetchBinary(mockApi, "test"); + expect(result).toBe(BINARY_PATH); + expect(mockAxios.get).not.toHaveBeenCalled(); + // Should still have the old version + expect(memfs.existsSync(BINARY_PATH)).toBe(true); + expect(memfs.readFileSync(BINARY_PATH).toString()).toBe( + mockBinaryContent("1.0.0"), + ); + }); + + it("downloads fresh binary when corrupted", async () => { + withCorruptedBinary(); + withSuccessfulDownload(); + const result = await manager.fetchBinary(mockApi, "test"); + expect(result).toBe(BINARY_PATH); + expect(mockAxios.get).toHaveBeenCalled(); + expect(memfs.existsSync(BINARY_PATH)).toBe(true); + expect(memfs.readFileSync(BINARY_PATH).toString()).toBe( + mockBinaryContent(TEST_VERSION), + ); + }); + + it("downloads when no binary exists", async () => { + // Ensure directory doesn't exist initially + expect(memfs.existsSync(BINARY_DIR)).toBe(false); + + withSuccessfulDownload(); + const result = await manager.fetchBinary(mockApi, "test"); + expect(result).toBe(BINARY_PATH); + expect(mockAxios.get).toHaveBeenCalled(); + + // Verify directory was created and binary exists + expect(memfs.existsSync(BINARY_DIR)).toBe(true); + expect(memfs.existsSync(BINARY_PATH)).toBe(true); + expect(memfs.readFileSync(BINARY_PATH).toString()).toBe( + mockBinaryContent(TEST_VERSION), + ); + }); + + it("fails when downloads disabled and no binary", async () => { + mockConfig.set("coder.enableDownloads", false); + await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( + "Unable to download CLI because downloads are disabled", + ); + expect(memfs.existsSync(BINARY_PATH)).toBe(false); + }); + }); + + describe("Binary Download Behavior", () => { + beforeEach(() => { + mockConfig.set("coder.disableSignatureVerification", true); + }); + + it("downloads with correct headers", async () => { + withSuccessfulDownload(); + await manager.fetchBinary(mockApi, "test"); + expect(mockAxios.get).toHaveBeenCalledWith( + `/bin/${BINARY_NAME}`, + expect.objectContaining({ + responseType: "stream", + headers: expect.objectContaining({ + "Accept-Encoding": "gzip", + "If-None-Match": '""', + }), + }), + ); + }); + + it("uses custom binary source", async () => { + mockConfig.set("coder.binarySource", "/custom/path"); + withSuccessfulDownload(); + await manager.fetchBinary(mockApi, "test"); + expect(mockAxios.get).toHaveBeenCalledWith( + "/custom/path", + expect.objectContaining({ + responseType: "stream", + decompress: true, + validateStatus: expect.any(Function), + }), + ); + }); + + it("uses ETag for existing binaries", async () => { + withExistingBinary("1.0.0"); + withSuccessfulDownload(); + await manager.fetchBinary(mockApi, "test"); + + // Verify ETag was computed from actual file content + expect(mockAxios.get).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + headers: expect.objectContaining({ + "If-None-Match": '"0c95a175da8afefd2b52057908a2e30ba2e959b3"', + }), + }), + ); + }); + + it("cleans up old files before download", async () => { + // Create old temporary files and signature files + vol.mkdirSync(BINARY_DIR, { recursive: true }); + memfs.writeFileSync(path.join(BINARY_DIR, "coder.old-xyz"), "old"); + memfs.writeFileSync(path.join(BINARY_DIR, "coder.temp-abc"), "temp"); + memfs.writeFileSync(path.join(BINARY_DIR, "coder.asc"), "signature"); + memfs.writeFileSync(path.join(BINARY_DIR, "keeper.txt"), "keep"); + + withSuccessfulDownload(); + await manager.fetchBinary(mockApi, "test"); + + // Verify old files were actually removed but other files kept + expect(memfs.existsSync(path.join(BINARY_DIR, "coder.old-xyz"))).toBe( + false, + ); + expect(memfs.existsSync(path.join(BINARY_DIR, "coder.temp-abc"))).toBe( + false, + ); + expect(memfs.existsSync(path.join(BINARY_DIR, "coder.asc"))).toBe(false); + expect(memfs.existsSync(path.join(BINARY_DIR, "keeper.txt"))).toBe(true); + }); + + it("moves existing binary to backup file before writing new version", async () => { + withExistingBinary("1.0.0"); + withSuccessfulDownload(); + + await manager.fetchBinary(mockApi, "test"); + + // Verify the old binary was backed up + const files = readdir(BINARY_DIR); + const backupFile = files.find( + (f) => f.startsWith(BINARY_NAME) && f.match(/\.old-[a-z0-9]+$/), + ); + expect(backupFile).toBeDefined(); + }); + }); + + describe("Download HTTP Response Handling", () => { + beforeEach(() => { + mockConfig.set("coder.disableSignatureVerification", true); + }); + + it("handles 304 Not Modified", async () => { + withExistingBinary("1.0.0"); + withHttpResponse(304); + const result = await manager.fetchBinary(mockApi, "test"); + expect(result).toBe(BINARY_PATH); + // No change + expect(memfs.readFileSync(BINARY_PATH).toString()).toBe( + mockBinaryContent("1.0.0"), + ); + }); + + it("handles 404 platform not supported", async () => { + withHttpResponse(404); + mockUI.setResponse( + "Coder isn't supported for your platform. Please open an issue, we'd love to support it!", + "Open an Issue", + ); + await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( + "Platform not supported", + ); + expect(vscode.env.openExternal).toHaveBeenCalledWith( + expect.objectContaining({ + path: expect.stringContaining( + "github.com/coder/vscode-coder/issues/new?", + ), + }), + ); + }); + + it("handles server errors", async () => { + withHttpResponse(500); + mockUI.setResponse( + "Failed to download binary. Please open an issue.", + "Open an Issue", + ); + await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( + "Failed to download binary", + ); + expect(vscode.env.openExternal).toHaveBeenCalledWith( + expect.objectContaining({ + path: expect.stringContaining( + "github.com/coder/vscode-coder/issues/new?", + ), + }), + ); + }); + }); + + describe("Download Stream Handling", () => { + beforeEach(() => { + mockConfig.set("coder.disableSignatureVerification", true); + }); + + it("handles write stream errors", async () => { + withStreamError("write", "disk full"); + await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( + "Unable to download binary: disk full", + ); + expect(memfs.existsSync(BINARY_PATH)).toBe(false); + }); + + it("handles read stream errors", async () => { + withStreamError("read", "network timeout"); + await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( + "Unable to download binary: network timeout", + ); + expect(memfs.existsSync(BINARY_PATH)).toBe(false); + }); + + it("handles missing content-length", async () => { + withSuccessfulDownload({ headers: {} }); + const result = await manager.fetchBinary(mockApi, "test"); + expect(result).toBe(BINARY_PATH); + expect(memfs.existsSync(BINARY_PATH)).toBe(true); + }); + }); + + describe("Download Progress Tracking", () => { + beforeEach(() => { + mockConfig.set("coder.disableSignatureVerification", true); + }); + + it("shows download progress", async () => { + withSuccessfulDownload(); + await manager.fetchBinary(mockApi, "test"); + expect(vscode.window.withProgress).toHaveBeenCalledWith( + expect.objectContaining({ title: `Downloading ${TEST_URL}` }), + expect.any(Function), + ); + }); + + it("handles user cancellation", async () => { + mockProgress.setCancellation(true); + withSuccessfulDownload(); + await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( + "Download aborted", + ); + expect(memfs.existsSync(BINARY_PATH)).toBe(false); + }); + }); + + describe("Binary Signature Verification", () => { + it("verifies valid signatures", async () => { + withSuccessfulDownload(); + withSignatureResponses([200]); + const result = await manager.fetchBinary(mockApi, "test"); + expect(result).toBe(BINARY_PATH); + expect(pgp.verifySignature).toHaveBeenCalled(); + const sigFile = expectFileInDir(BINARY_DIR, ".asc"); + expect(sigFile).toBeDefined(); + }); + + it("tries fallback signature on 404", async () => { + withSuccessfulDownload(); + withSignatureResponses([404, 200]); + mockUI.setResponse("Signature not found", "Download signature"); + const result = await manager.fetchBinary(mockApi, "test"); + expect(result).toBe(BINARY_PATH); + expect(mockAxios.get).toHaveBeenCalledTimes(3); + const sigFile = expectFileInDir(BINARY_DIR, ".asc"); + expect(sigFile).toBeDefined(); + }); + + it("allows running despite invalid signature", async () => { + withSuccessfulDownload(); + withSignatureResponses([200]); + vi.mocked(pgp.verifySignature).mockRejectedValueOnce( + createVerificationError("Invalid signature"), + ); + mockUI.setResponse("Signature does not match", "Run anyway"); + const result = await manager.fetchBinary(mockApi, "test"); + expect(result).toBe(BINARY_PATH); + expect(memfs.existsSync(BINARY_PATH)).toBe(true); + }); + + it("aborts on signature rejection", async () => { + withSuccessfulDownload(); + withSignatureResponses([200]); + vi.mocked(pgp.verifySignature).mockRejectedValueOnce( + createVerificationError("Invalid signature"), + ); + mockUI.setResponse("Signature does not match", undefined); + await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( + "Signature verification aborted", + ); + }); + + it("skips verification when disabled", async () => { + mockConfig.set("coder.disableSignatureVerification", true); + withSuccessfulDownload(); + const result = await manager.fetchBinary(mockApi, "test"); + expect(result).toBe(BINARY_PATH); + expect(pgp.verifySignature).not.toHaveBeenCalled(); + const files = readdir(BINARY_DIR); + expect(files.find((file) => file.includes(".asc"))).toBeUndefined(); + }); + + it.each([ + [404, "Signature not found"], + [500, "Failed to download signature"], + ])("allows skipping verification on %i", async (status, message) => { + withSuccessfulDownload(); + withHttpResponse(status); + mockUI.setResponse(message, "Run without verification"); + const result = await manager.fetchBinary(mockApi, "test"); + expect(result).toBe(BINARY_PATH); + expect(pgp.verifySignature).not.toHaveBeenCalled(); + }); + + it.each([ + [404, "Signature not found"], + [500, "Failed to download signature"], + ])( + "aborts when user declines missing signature on %i", + async (status, message) => { + withSuccessfulDownload(); + withHttpResponse(status); + mockUI.setResponse(message, undefined); // User cancels + await expect(manager.fetchBinary(mockApi, "test")).rejects.toThrow( + "Signature download aborted", + ); + }, + ); + }); + + describe("File System Operations", () => { + beforeEach(() => { + mockConfig.set("coder.disableSignatureVerification", true); + }); + + it("creates binary directory", async () => { + expect(memfs.existsSync(BINARY_DIR)).toBe(false); + withSuccessfulDownload(); + await manager.fetchBinary(mockApi, "test"); + expect(memfs.existsSync(BINARY_DIR)).toBe(true); + const stats = memfs.statSync(BINARY_DIR); + expect(stats.isDirectory()).toBe(true); + }); + + it("validates downloaded binary version", async () => { + withSuccessfulDownload(); + await manager.fetchBinary(mockApi, "test"); + expect(memfs.readFileSync(BINARY_PATH).toString()).toBe( + mockBinaryContent(TEST_VERSION), + ); + }); + + it("sets correct file permissions", async () => { + withSuccessfulDownload(); + await manager.fetchBinary(mockApi, "test"); + const stats = memfs.statSync(BINARY_PATH); + expect(stats.mode & 0o777).toBe(0o755); + }); + }); + + describe("Path Pecularities", () => { + beforeEach(() => { + mockConfig.set("coder.disableSignatureVerification", true); + }); + + it("handles binary with spaces in path", async () => { + const pathWithSpaces = "/path with spaces/bin"; + const resolver = new PathResolver(pathWithSpaces, "/log"); + const manager = new CliManager(vscode, createMockLogger(), resolver); + + withSuccessfulDownload(); + const result = await manager.fetchBinary(mockApi, "test label"); + expect(result).toBe(`${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)); + }); + }); + + function createMockLogger(): Logger { + return { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + } + + function createMockApi(version: string, url: string): Api { + const axios = { + defaults: { baseURL: url }, + get: vi.fn(), + } as unknown as AxiosInstance; + return { + getBuildInfo: vi.fn().mockResolvedValue({ version }), + getAxiosInstance: () => axios, + } as unknown as Api; + } + + function withExistingBinary(version: string, dir: string = BINARY_DIR) { + vol.mkdirSync(dir, { recursive: true }); + memfs.writeFileSync(`${dir}/${BINARY_NAME}`, mockBinaryContent(version), { + mode: 0o755, + }); + + // Mock version to return the specified version + vi.mocked(cli.version).mockResolvedValueOnce(version); + } + + function withCorruptedBinary() { + vol.mkdirSync(BINARY_DIR, { recursive: true }); + memfs.writeFileSync(BINARY_PATH, "corrupted-binary-content", { + mode: 0o755, + }); + + // Mock version to fail + vi.mocked(cli.version).mockRejectedValueOnce(new Error("corrupted")); + } + + function withSuccessfulDownload(opts?: { + headers?: Record; + }) { + const stream = createMockStream(mockBinaryContent(TEST_VERSION)); + withHttpResponse( + 200, + opts?.headers ?? { "content-length": "1024" }, + stream, + ); + + // Mock version to return TEST_VERSION after download + vi.mocked(cli.version).mockResolvedValue(TEST_VERSION); + } + + function withSignatureResponses(statuses: number[]): void { + statuses.forEach((status) => { + const data = + status === 200 ? createMockStream("mock-signature-content") : undefined; + withHttpResponse(status, {}, data); + }); + } + + function withHttpResponse( + status: number, + headers: Record = {}, + data?: unknown, + ) { + vi.mocked(mockAxios.get).mockResolvedValueOnce({ + status, + headers, + data, + }); + } + + function withStreamError(type: "read" | "write", message: string) { + if (type === "write") { + vi.spyOn(fs, "createWriteStream").mockImplementation(() => { + const stream = new EventEmitter(); + (stream as unknown as fs.WriteStream).write = vi.fn(); + (stream as unknown as fs.WriteStream).close = vi.fn(); + // Emit error on next tick after stream is returned + setImmediate(() => { + stream.emit("error", new Error(message)); + }); + + return stream as ReturnType; + }); + + // Provide a normal read stream + withHttpResponse( + 200, + { "content-length": "256" }, + createMockStream("data"), + ); + } else { + // Create a read stream that emits error + const errorStream = { + on: vi.fn((event: string, callback: (...args: unknown[]) => void) => { + if (event === "error") { + setImmediate(() => callback(new Error(message))); + } + return errorStream; + }), + destroy: vi.fn(), + } as unknown as IncomingMessage; + + withHttpResponse(200, { "content-length": "1024" }, errorStream); + } + } + + function createMockStream( + content: string, + options: { chunkSize?: number; delay?: number } = {}, + ): IncomingMessage { + const { chunkSize = 8, delay = 0 } = options; + + const buffer = Buffer.from(content); + let position = 0; + + 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); + } + } + }; + setTimeout(sendChunk, delay); + } else if (event === "close") { + // Just close after a delay + setTimeout(() => callback(), 10); + } + }), + 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[]; + } +}); diff --git a/src/storage.ts b/src/core/cliManager.ts similarity index 68% rename from src/storage.ts rename to src/core/cliManager.ts index 97d62ff7..e8a7ab25 100644 --- a/src/storage.ts +++ b/src/core/cliManager.ts @@ -3,141 +3,27 @@ import globalAxios, { type AxiosRequestConfig, } from "axios"; import { Api } from "coder/site/src/api/api"; -import { createWriteStream, type WriteStream } from "fs"; +import { createWriteStream, WriteStream } from "fs"; import fs from "fs/promises"; import { IncomingMessage } from "http"; import path from "path"; import prettyBytes from "pretty-bytes"; import * as semver from "semver"; -import * as vscode from "vscode"; -import { errToStr } from "./api/api-helper"; -import * as cli from "./cliManager"; -import { getHeaderCommand, getHeaders } from "./headers"; -import * as pgp from "./pgp"; -// Maximium number of recent URLs to store. -const MAX_URLS = 10; +import * as vscode from "vscode"; +import { errToStr } from "../api/api-helper"; +import * as cli from "../cliUtils"; +import { Logger } from "../logging/logger"; +import * as pgp from "../pgp"; +import { PathResolver } from "./pathResolver"; -export class Storage { +export class CliManager { constructor( private readonly vscodeProposed: typeof vscode, - public readonly output: vscode.LogOutputChannel, - private readonly memento: vscode.Memento, - private readonly secrets: vscode.SecretStorage, - private readonly globalStorageUri: vscode.Uri, - private readonly logUri: vscode.Uri, + private readonly output: Logger, + private readonly pathResolver: PathResolver, ) {} - /** - * 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. - */ - public async setUrl(url?: string): Promise { - await this.memento.update("url", url); - 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. - */ - public withUrlHistory(...append: (string | undefined)[]): string[] { - const val = this.memento.get("urlHistory"); - const urls = Array.isArray(val) ? new Set(val) : new Set(); - for (const url of append) { - if (url) { - // It might exist; delete first so it gets appended. - urls.delete(url); - urls.add(url); - } - } - // Slice off the head if the list is too large. - return urls.size > MAX_URLS - ? Array.from(urls).slice(urls.size - MAX_URLS, urls.size) - : Array.from(urls); - } - - /** - * Mark this as the first connection to a workspace, which influences whether - * the workspace startup confirmation is shown to the user. - */ - public async setFirstConnect(): Promise { - return this.memento.update("firstConnect", true); - } - - /** - * Check if this is the first connection to a workspace and clear the flag. - * Used to determine whether to automatically start workspaces without - * prompting the user for confirmation. - */ - public async getAndClearFirstConnect(): Promise { - const isFirst = this.memento.get("firstConnect"); - if (isFirst !== undefined) { - await this.memento.update("firstConnect", undefined); - } - return isFirst === true; - } - - /** - * Set or unset the last used token. - */ - public async setSessionToken(sessionToken?: string): Promise { - if (!sessionToken) { - await this.secrets.delete("sessionToken"); - } else { - await this.secrets.store("sessionToken", sessionToken); - } - } - - /** - * Get the last used token. - */ - public async getSessionToken(): Promise { - try { - return await this.secrets.get("sessionToken"); - } catch (ex) { - // The VS Code session store has become corrupt before, and - // will fail to get the session token... - return undefined; - } - } - - /** - * 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. - */ - public async getRemoteSSHLogPath(): Promise { - const upperDir = path.dirname(this.logUri.fsPath); - // 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]); - } - /** * 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 @@ -151,7 +37,6 @@ export class Storage { */ public async fetchBinary(restClient: Api, label: 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. const enableDownloads = cfg.get("enableDownloads") !== false; @@ -171,7 +56,10 @@ export class Storage { // Check if there is an existing binary and whether it looks valid. If it // 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.getBinaryCachePath(label), cli.name()); + const binPath = path.join( + this.pathResolver.getBinaryCachePath(label), + cli.name(), + ); this.output.info("Using binary path", binPath); const stat = await cli.stat(binPath); if (stat === undefined) { @@ -318,8 +206,7 @@ export class Storage { 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(), + `https://github.com/coder/vscode-coder/issues/new?${params.toString()}`, ); vscode.env.openExternal(uri); }); @@ -340,8 +227,7 @@ export class Storage { body: `Received status code \`${status}\` when downloading the binary.`, }); const uri = vscode.Uri.parse( - `https://github.com/coder/vscode-coder/issues/new?` + - params.toString(), + `https://github.com/coder/vscode-coder/issues/new?${params.toString()}`, ); vscode.env.openExternal(uri); }); @@ -585,109 +471,13 @@ export class Storage { return status; } - /** - * Return the directory for a deployment with the provided label to where its - * binary is cached. - * - * If the label is empty, read the old deployment-unaware config instead. - * - * The caller must ensure this directory exists before use. - */ - public getBinaryCachePath(label: string): string { - const configPath = vscode.workspace - .getConfiguration() - .get("coder.binaryDestination"); - return configPath && String(configPath).trim().length > 0 - ? path.resolve(String(configPath)) - : label - ? path.join(this.globalStorageUri.fsPath, label, "bin") - : path.join(this.globalStorageUri.fsPath, "bin"); - } - - /** - * Return the path where network information for SSH hosts are stored. - * - * The CLI will write files here named after the process PID. - */ - public getNetworkInfoPath(): string { - return path.join(this.globalStorageUri.fsPath, "net"); - } - - /** - * - * Return the path where log data from the connection is stored. - * - * The CLI will write files here named after the process PID. - */ - public getLogPath(): string { - return path.join(this.globalStorageUri.fsPath, "log"); - } - - /** - * Get the path to the user's settings.json file. - * - * Going through VSCode's API should be preferred when modifying settings. - */ - public getUserSettingsPath(): string { - return path.join( - this.globalStorageUri.fsPath, - "..", - "..", - "..", - "User", - "settings.json", - ); - } - - /** - * Return the directory for the deployment with the provided label to where - * its session token is stored. - * - * If the label is empty, read the old deployment-unaware config instead. - * - * The caller must ensure this directory exists before use. - */ - public getSessionTokenPath(label: string): string { - return label - ? path.join(this.globalStorageUri.fsPath, label, "session") - : path.join(this.globalStorageUri.fsPath, "session"); - } - - /** - * Return the directory for the deployment with the provided label to where - * its session token was stored by older code. - * - * If the label is empty, read the old deployment-unaware config instead. - * - * The caller must ensure this directory exists before use. - */ - public getLegacySessionTokenPath(label: string): string { - return label - ? path.join(this.globalStorageUri.fsPath, label, "session_token") - : path.join(this.globalStorageUri.fsPath, "session_token"); - } - - /** - * Return the directory for the deployment with the provided label to where - * its url is stored. - * - * If the label is empty, read the old deployment-unaware config instead. - * - * The caller must ensure this directory exists before use. - */ - public getUrlPath(label: string): string { - return label - ? path.join(this.globalStorageUri.fsPath, label, "url") - : path.join(this.globalStorageUri.fsPath, "url"); - } - /** * Configure the CLI for the deployment with the provided label. * * Falsey URLs and null tokens are a no-op; we avoid unconfiguring the CLI to * avoid breaking existing connections. */ - public async configureCli( + public async configure( label: string, url: string | undefined, token: string | null, @@ -709,7 +499,7 @@ export class Storage { url: string | undefined, ): Promise { if (url) { - const urlPath = this.getUrlPath(label); + const urlPath = this.pathResolver.getUrlPath(label); await fs.mkdir(path.dirname(urlPath), { recursive: true }); await fs.writeFile(urlPath, url); } @@ -727,7 +517,7 @@ export class Storage { token: string | undefined | null, ) { if (token !== null) { - const tokenPath = this.getSessionTokenPath(label); + const tokenPath = this.pathResolver.getSessionTokenPath(label); await fs.mkdir(path.dirname(tokenPath), { recursive: true }); await fs.writeFile(tokenPath, token ?? ""); } @@ -740,11 +530,11 @@ export class Storage { * * If the label is empty, read the old deployment-unaware config. */ - public async readCliConfig( + public async readConfig( label: string, ): Promise<{ url: string; token: string }> { - const urlPath = this.getUrlPath(label); - const tokenPath = this.getSessionTokenPath(label); + 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"), @@ -754,33 +544,4 @@ export class Storage { token: token.status === "fulfilled" ? token.value.trim() : "", }; } - - /** - * Migrate the session token file from "session_token" to "session", if needed. - */ - public async migrateSessionToken(label: string) { - const oldTokenPath = this.getLegacySessionTokenPath(label); - const newTokenPath = this.getSessionTokenPath(label); - try { - await fs.rename(oldTokenPath, newTokenPath); - } catch (error) { - if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { - return; - } - throw error; - } - } - - /** - * Run the header command and return the generated headers. - */ - public async getHeaders( - url: string | undefined, - ): Promise> { - return getHeaders( - url, - getHeaderCommand(vscode.workspace.getConfiguration()), - this.output, - ); - } } diff --git a/src/core/mementoManager.test.ts b/src/core/mementoManager.test.ts new file mode 100644 index 00000000..f1cd6a2d --- /dev/null +++ b/src/core/mementoManager.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { InMemoryMemento } from "../__mocks__/testHelpers"; +import { MementoManager } from "./mementoManager"; + +describe("MementoManager", () => { + let memento: InMemoryMemento; + let mementoManager: MementoManager; + + beforeEach(() => { + memento = new InMemoryMemento(); + mementoManager = new MementoManager(memento); + }); + + describe("setUrl", () => { + it("should store URL and add to history", async () => { + await mementoManager.setUrl("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(""); + 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 + + expect(memento.get("urlHistory")).toEqual(["url2", "url1"]); + }); + }); + + describe("withUrlHistory", () => { + it("should append URLs and remove duplicates", async () => { + await memento.update("urlHistory", ["existing1", "existing2"]); + + const result = mementoManager.withUrlHistory("existing2", "new1"); + + expect(result).toEqual(["existing1", "existing2", "new1"]); + }); + + it("should limit to 10 URLs", async () => { + const urls = Array.from({ length: 10 }, (_, i) => `url${i}`); + await memento.update("urlHistory", urls); + + const result = mementoManager.withUrlHistory("url20"); + + expect(result).toHaveLength(10); + expect(result[0]).toBe("url1"); + expect(result[9]).toBe("url20"); + }); + + it("should handle non-array storage gracefully", async () => { + await memento.update("urlHistory", "not-an-array"); + const result = mementoManager.withUrlHistory("url1"); + expect(result).toEqual(["url1"]); + }); + }); + + describe("firstConnect", () => { + it("should return true only once", async () => { + await mementoManager.setFirstConnect(); + + expect(await mementoManager.getAndClearFirstConnect()).toBe(true); + expect(await mementoManager.getAndClearFirstConnect()).toBe(false); + }); + + it("should return false for non-boolean values", async () => { + await memento.update("firstConnect", "truthy-string"); + expect(await mementoManager.getAndClearFirstConnect()).toBe(false); + }); + }); +}); diff --git a/src/core/mementoManager.ts b/src/core/mementoManager.ts new file mode 100644 index 00000000..f79be46c --- /dev/null +++ b/src/core/mementoManager.ts @@ -0,0 +1,71 @@ +import type { Memento } from "vscode"; + +// Maximum number of recent URLs to store. +const MAX_URLS = 10; + +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. + */ + public async setUrl(url?: string): Promise { + await this.memento.update("url", url); + 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. + */ + public withUrlHistory(...append: (string | undefined)[]): string[] { + const val = this.memento.get("urlHistory"); + const urls = Array.isArray(val) ? new Set(val) : new Set(); + for (const url of append) { + if (url) { + // It might exist; delete first so it gets appended. + urls.delete(url); + urls.add(url); + } + } + // Slice off the head if the list is too large. + return urls.size > MAX_URLS + ? Array.from(urls).slice(urls.size - MAX_URLS, urls.size) + : Array.from(urls); + } + + /** + * Mark this as the first connection to a workspace, which influences whether + * the workspace startup confirmation is shown to the user. + */ + public async setFirstConnect(): Promise { + return this.memento.update("firstConnect", true); + } + + /** + * Check if this is the first connection to a workspace and clear the flag. + * Used to determine whether to automatically start workspaces without + * prompting the user for confirmation. + */ + public async getAndClearFirstConnect(): Promise { + const isFirst = this.memento.get("firstConnect"); + if (isFirst !== undefined) { + await this.memento.update("firstConnect", undefined); + } + return isFirst === true; + } +} diff --git a/src/core/pathResolver.test.ts b/src/core/pathResolver.test.ts new file mode 100644 index 00000000..8216a547 --- /dev/null +++ b/src/core/pathResolver.test.ts @@ -0,0 +1,48 @@ +import * as path from "path"; +import { describe, it, expect, beforeEach } from "vitest"; +import { MockConfigurationProvider } from "../__mocks__/testHelpers"; +import { PathResolver } from "./pathResolver"; + +describe("PathResolver", () => { + const basePath = + "/home/user/.vscode-server/data/User/globalStorage/coder.coder-remote"; + const codeLogPath = "/home/user/.vscode-server/data/logs/coder.coder-remote"; + let pathResolver: PathResolver; + let mockConfig: MockConfigurationProvider; + + beforeEach(() => { + pathResolver = new PathResolver(basePath, codeLogPath); + mockConfig = new MockConfigurationProvider(); + }); + + it("should use base path for empty labels", () => { + expect(pathResolver.getGlobalConfigDir("")).toBe(basePath); + expect(pathResolver.getSessionTokenPath("")).toBe( + path.join(basePath, "session"), + ); + expect(pathResolver.getUrlPath("")).toBe(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( + "/custom/binary/path", + ); + }); + + it("should use default path when custom destination is empty or whitespace", () => { + mockConfig.set("coder.binaryDestination", " "); + expect(pathResolver.getBinaryCachePath("deployment")).toBe( + path.join(basePath, "deployment", "bin"), + ); + }); + + it("should normalize custom paths", () => { + mockConfig.set("coder.binaryDestination", "/custom/../binary/./path"); + expect(pathResolver.getBinaryCachePath("deployment")).toBe( + path.normalize("/custom/../binary/./path"), + ); + }); + }); +}); diff --git a/src/core/pathResolver.ts b/src/core/pathResolver.ts new file mode 100644 index 00000000..6c1ee7ef --- /dev/null +++ b/src/core/pathResolver.ts @@ -0,0 +1,115 @@ +import * as path from "path"; +import * as vscode from "vscode"; + +export class PathResolver { + constructor( + private readonly basePath: string, + private readonly codeLogPath: string, + ) {} + + /** + * Return the directory for the deployment with the provided label to where + * the global Coder configs are stored. + * + * If the label 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; + } + + /** + * Return the directory for a deployment with the provided label to where its + * binary is cached. + * + * If the label is empty, read the old deployment-unaware config instead. + * + * The caller must ensure this directory exists before use. + */ + public getBinaryCachePath(label: string): string { + const configPath = vscode.workspace + .getConfiguration() + .get("coder.binaryDestination"); + return configPath && configPath.trim().length > 0 + ? path.normalize(configPath) + : path.join(this.getGlobalConfigDir(label), "bin"); + } + + /** + * Return the path where network information for SSH hosts are stored. + * + * The CLI will write files here named after the process PID. + */ + public getNetworkInfoPath(): string { + return path.join(this.basePath, "net"); + } + + /** + * Return the path where log data from the connection is stored. + * + * The CLI will write files here named after the process PID. + * + * Note: This directory is not currently used. + */ + public getLogPath(): string { + return path.join(this.basePath, "log"); + } + + /** + * Get the path to the user's settings.json file. + * + * Going through VSCode's API should be preferred when modifying settings. + */ + public getUserSettingsPath(): string { + return path.join(this.basePath, "..", "..", "..", "User", "settings.json"); + } + + /** + * Return the directory for the deployment with the provided label to where + * its session token is stored. + * + * If the label 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"); + } + + /** + * Return the directory for the deployment with the provided label to where + * its session token was stored by older code. + * + * If the label 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"); + } + + /** + * Return the directory for the deployment with the provided label to where + * its url is stored. + * + * If the label 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"); + } + + /** + * The URI of a directory in which the extension can create log files. + * + * The directory might not exist on disk and creation is up to the extension. + * However, the parent directory is guaranteed to be existent. + * + * This directory is provided by VS Code and may not be the same as the directory where the Coder CLI writes its log files. + */ + public getCodeLogDir(): string { + return this.codeLogPath; + } +} diff --git a/src/core/secretsManager.test.ts b/src/core/secretsManager.test.ts new file mode 100644 index 00000000..a6487e0f --- /dev/null +++ b/src/core/secretsManager.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { InMemorySecretStorage } from "../__mocks__/testHelpers"; +import { SecretsManager } from "./secretsManager"; + +describe("SecretsManager", () => { + let secretStorage: InMemorySecretStorage; + let secretsManager: SecretsManager; + + beforeEach(() => { + secretStorage = new InMemorySecretStorage(); + secretsManager = new SecretsManager(secretStorage); + }); + + describe("setSessionToken", () => { + it("should store and retrieve tokens", async () => { + await secretsManager.setSessionToken("test-token"); + expect(await secretsManager.getSessionToken()).toBe("test-token"); + + await secretsManager.setSessionToken("new-token"); + expect(await secretsManager.getSessionToken()).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(); + }); + }); + + describe("getSessionToken", () => { + it("should return undefined for corrupted storage", async () => { + await secretStorage.store("sessionToken", "valid-token"); + secretStorage.corruptStorage(); + + expect(await secretsManager.getSessionToken()).toBeUndefined(); + }); + }); +}); diff --git a/src/core/secretsManager.ts b/src/core/secretsManager.ts new file mode 100644 index 00000000..7fd98f8f --- /dev/null +++ b/src/core/secretsManager.ts @@ -0,0 +1,29 @@ +import type { SecretStorage } from "vscode"; + +export class SecretsManager { + constructor(private readonly secrets: SecretStorage) {} + + /** + * Set or unset the last used token. + */ + public async setSessionToken(sessionToken?: string): Promise { + if (!sessionToken) { + await this.secrets.delete("sessionToken"); + } else { + await this.secrets.store("sessionToken", sessionToken); + } + } + + /** + * Get the last used token. + */ + public async getSessionToken(): Promise { + try { + return await this.secrets.get("sessionToken"); + } catch (ex) { + // The VS Code session store has become corrupt before, and + // will fail to get the session token... + return undefined; + } + } +} diff --git a/src/error.test.ts b/src/error.test.ts index 2d591d89..84c1e14b 100644 --- a/src/error.test.ts +++ b/src/error.test.ts @@ -2,260 +2,274 @@ import axios from "axios"; import * as fs from "fs/promises"; import https from "https"; import * as path from "path"; -import { afterAll, beforeAll, it, expect, vi } from "vitest"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { CertificateError, X509_ERR, X509_ERR_CODE } from "./error"; import { Logger } from "./logging/logger"; -// 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. +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; + // 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 -// TODO: Remove the vscode mock once we revert the testing framework. -beforeAll(() => { - vi.mock("vscode", () => { - return {}; + beforeAll(() => { + vi.mock("vscode", () => { + return {}; + }); }); -}); -const throwingLog = (message: string) => { - throw new Error(message); -}; + const throwingLog = (message: string) => { + throw new Error(message); + }; -const logger: Logger = { - trace: throwingLog, - debug: throwingLog, - info: throwingLog, - warn: throwingLog, - error: throwingLog, -}; + const logger: Logger = { + trace: throwingLog, + debug: throwingLog, + info: throwingLog, + warn: throwingLog, + error: throwingLog, + }; -const disposers: (() => void)[] = []; -afterAll(() => { - disposers.forEach((d) => d()); -}); + const disposers: (() => void)[] = []; + afterAll(() => { + disposers.forEach((d) => d()); + }); -async function startServer(certName: string): Promise { - const server = https.createServer( - { - key: await fs.readFile( - path.join(__dirname, `../fixtures/tls/${certName}.key`), - ), - cert: await fs.readFile( - path.join(__dirname, `../fixtures/tls/${certName}.crt`), - ), - }, - (req, res) => { - if (req.url?.endsWith("/error")) { - res.writeHead(500); - res.end("error"); - return; - } - res.writeHead(200); - res.end("foobar"); - }, - ); - disposers.push(() => server.close()); - return new Promise((resolve, reject) => { - server.on("error", reject); - server.listen(0, "127.0.0.1", () => { - const address = server.address(); - if (!address) { - throw new Error("Server has no address"); - } - if (typeof address !== "string") { - const host = - address.family === "IPv6" ? `[${address.address}]` : address.address; - return resolve(`https://${host}:${address.port}`); - } - resolve(address); + async function startServer(certName: string): Promise { + const server = https.createServer( + { + key: await fs.readFile( + path.join(__dirname, `../fixtures/tls/${certName}.key`), + ), + cert: await fs.readFile( + path.join(__dirname, `../fixtures/tls/${certName}.crt`), + ), + }, + (req, res) => { + if (req.url?.endsWith("/error")) { + res.writeHead(500); + res.end("error"); + return; + } + res.writeHead(200); + res.end("foobar"); + }, + ); + disposers.push(() => server.close()); + return new Promise((resolve, reject) => { + server.on("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (!address) { + throw new Error("Server has no address"); + } + if (typeof address !== "string") { + const host = + address.family === "IPv6" + ? `[${address.address}]` + : address.address; + return resolve(`https://${host}:${address.port}`); + } + resolve(address); + }); + }); + } + + // Both environments give the "unable to verify" error with partial chains. + it("detects partial chains", async () => { + const address = await startServer("chain-leaf"); + const request = axios.get(address, { + httpsAgent: new https.Agent({ + ca: await fs.readFile( + path.join(__dirname, "../fixtures/tls/chain-leaf.crt"), + ), + }), }); + 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.PARTIAL_CHAIN, + ); + } }); -} -// Both environments give the "unable to verify" error with partial chains. -it("detects partial chains", async () => { - const address = await startServer("chain-leaf"); - const request = axios.get(address, { - httpsAgent: new https.Agent({ - ca: await fs.readFile( - path.join(__dirname, "../fixtures/tls/chain-leaf.crt"), - ), - }), + it("can bypass partial chain", async () => { + const address = await startServer("chain-leaf"); + const request = axios.get(address, { + httpsAgent: new https.Agent({ + rejectUnauthorized: false, + }), + }); + 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.PARTIAL_CHAIN); - } -}); -it("can bypass partial chain", async () => { - const address = await startServer("chain-leaf"); - const request = axios.get(address, { - httpsAgent: new https.Agent({ - rejectUnauthorized: false, - }), + // 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. + it("detects self-signed certificates without signing capability", async () => { + const address = await startServer("no-signing"); + const request = axios.get(address, { + httpsAgent: new https.Agent({ + ca: await fs.readFile( + path.join(__dirname, "../fixtures/tls/no-signing.crt"), + ), + 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).resolves.toHaveProperty("data", "foobar"); -}); -// 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. -it("detects self-signed certificates without signing capability", async () => { - const address = await startServer("no-signing"); - const request = axios.get(address, { - httpsAgent: new https.Agent({ - ca: await fs.readFile( - path.join(__dirname, "../fixtures/tls/no-signing.crt"), - ), - servername: "localhost", - }), + it("can bypass self-signed certificates without signing capability", async () => { + const address = await startServer("no-signing"); + const request = axios.get(address, { + httpsAgent: new https.Agent({ + rejectUnauthorized: false, + }), + }); + await expect(request).resolves.toHaveProperty("data", "foobar"); }); - if (isElectron) { + + // Both environments give the same error code when a self-issued certificate is + // untrusted. + it("detects self-signed certificates", async () => { + const address = await startServer("self-signed"); + const request = axios.get(address); await expect(request).rejects.toHaveProperty( "code", - X509_ERR_CODE.UNABLE_TO_VERIFY_LEAF_SIGNATURE, + X509_ERR_CODE.DEPTH_ZERO_SELF_SIGNED_CERT, ); 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); + expect((wrapped as CertificateError).x509Err).toBe( + X509_ERR.UNTRUSTED_LEAF, + ); } - } else { - await expect(request).resolves.toHaveProperty("data", "foobar"); - } -}); - -it("can bypass self-signed certificates without signing capability", async () => { - const address = await startServer("no-signing"); - const request = axios.get(address, { - httpsAgent: new https.Agent({ - rejectUnauthorized: false, - }), }); - await expect(request).resolves.toHaveProperty("data", "foobar"); -}); -// Both environments give the same error code when a self-issued certificate is -// untrusted. -it("detects self-signed certificates", async () => { - const address = await startServer("self-signed"); - const request = axios.get(address); - await expect(request).rejects.toHaveProperty( - "code", - X509_ERR_CODE.DEPTH_ZERO_SELF_SIGNED_CERT, - ); - 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.UNTRUSTED_LEAF); - } -}); - -// Both environments have no problem if the self-issued certificate is trusted -// and has the signing capability. -it("is ok with trusted self-signed certificates", async () => { - const address = await startServer("self-signed"); - const request = axios.get(address, { - httpsAgent: new https.Agent({ - ca: await fs.readFile( - path.join(__dirname, "../fixtures/tls/self-signed.crt"), - ), - servername: "localhost", - }), + // Both environments have no problem if the self-issued certificate is trusted + // and has the signing capability. + it("is ok with trusted self-signed certificates", async () => { + const address = await startServer("self-signed"); + const request = axios.get(address, { + httpsAgent: new https.Agent({ + ca: await fs.readFile( + path.join(__dirname, "../fixtures/tls/self-signed.crt"), + ), + servername: "localhost", + }), + }); + await expect(request).resolves.toHaveProperty("data", "foobar"); }); - await expect(request).resolves.toHaveProperty("data", "foobar"); -}); -it("can bypass self-signed certificates", async () => { - const address = await startServer("self-signed"); - const request = axios.get(address, { - httpsAgent: new https.Agent({ - rejectUnauthorized: false, - }), + it("can bypass self-signed certificates", async () => { + const address = await startServer("self-signed"); + const request = axios.get(address, { + httpsAgent: new https.Agent({ + rejectUnauthorized: false, + }), + }); + await expect(request).resolves.toHaveProperty("data", "foobar"); }); - await expect(request).resolves.toHaveProperty("data", "foobar"); -}); -// Both environments give the same error code when the chain is complete but the -// root is not trusted. -it("detects an untrusted chain", async () => { - const address = await startServer("chain"); - const request = axios.get(address); - await expect(request).rejects.toHaveProperty( - "code", - X509_ERR_CODE.SELF_SIGNED_CERT_IN_CHAIN, - ); - 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.UNTRUSTED_CHAIN, + // Both environments give the same error code when the chain is complete but the + // root is not trusted. + it("detects an untrusted chain", async () => { + const address = await startServer("chain"); + const request = axios.get(address); + await expect(request).rejects.toHaveProperty( + "code", + X509_ERR_CODE.SELF_SIGNED_CERT_IN_CHAIN, ); - } -}); + 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.UNTRUSTED_CHAIN, + ); + } + }); -// Both environments have no problem if the chain is complete and the root is -// trusted. -it("is ok with chains with a trusted root", async () => { - const address = await startServer("chain"); - const request = axios.get(address, { - httpsAgent: new https.Agent({ - ca: await fs.readFile( - path.join(__dirname, "../fixtures/tls/chain-root.crt"), - ), - servername: "localhost", - }), + // Both environments have no problem if the chain is complete and the root is + // trusted. + it("is ok with chains with a trusted root", async () => { + const address = await startServer("chain"); + const request = axios.get(address, { + httpsAgent: new https.Agent({ + ca: await fs.readFile( + path.join(__dirname, "../fixtures/tls/chain-root.crt"), + ), + servername: "localhost", + }), + }); + await expect(request).resolves.toHaveProperty("data", "foobar"); }); - await expect(request).resolves.toHaveProperty("data", "foobar"); -}); -it("can bypass chain", async () => { - const address = await startServer("chain"); - const request = axios.get(address, { - httpsAgent: new https.Agent({ - rejectUnauthorized: false, - }), + it("can bypass chain", async () => { + const address = await startServer("chain"); + const request = axios.get(address, { + httpsAgent: new https.Agent({ + rejectUnauthorized: false, + }), + }); + await expect(request).resolves.toHaveProperty("data", "foobar"); }); - await expect(request).resolves.toHaveProperty("data", "foobar"); -}); -it("falls back with different error", async () => { - const address = await startServer("chain"); - const request = axios.get(address + "/error", { - httpsAgent: new https.Agent({ - ca: await fs.readFile( - path.join(__dirname, "../fixtures/tls/chain-root.crt"), - ), - servername: "localhost", - }), + it("falls back with different error", async () => { + const address = await startServer("chain"); + const request = axios.get(address + "/error", { + httpsAgent: new https.Agent({ + ca: await fs.readFile( + path.join(__dirname, "../fixtures/tls/chain-root.crt"), + ), + servername: "localhost", + }), + }); + await expect(request).rejects.toThrow(/failed with status code 500/); + try { + await request; + } catch (error) { + const wrapped = await CertificateError.maybeWrap(error, "1", logger); + expect(wrapped instanceof CertificateError).toBeFalsy(); + expect((wrapped as Error).message).toMatch(/failed with status code 500/); + } }); - await expect(request).rejects.toMatch(/failed with status code 500/); - try { - await request; - } catch (error) { - const wrapped = await CertificateError.maybeWrap(error, "1", logger); - expect(wrapped instanceof CertificateError).toBeFalsy(); - expect((wrapped as Error).message).toMatch(/failed with status code 500/); - } }); diff --git a/src/extension.ts b/src/extension.ts index 9d1531db..bd8a09c6 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,11 +7,14 @@ import { errToStr } from "./api/api-helper"; import { CoderApi } from "./api/coderApi"; import { needToken } from "./api/utils"; import { Commands } from "./commands"; +import { CliManager } from "./core/cliManager"; +import { MementoManager } from "./core/mementoManager"; +import { PathResolver } from "./core/pathResolver"; +import { SecretsManager } from "./core/secretsManager"; import { CertificateError, getErrorDetail } from "./error"; import { Remote } from "./remote"; -import { Storage } from "./storage"; import { toSafeHost } from "./util"; -import { WorkspaceQuery, WorkspaceProvider } from "./workspacesProvider"; +import { WorkspaceProvider, WorkspaceQuery } from "./workspacesProvider"; export async function activate(ctx: vscode.ExtensionContext): Promise { // The Remote SSH extension's proposed APIs are used to override the SSH host @@ -48,40 +51,39 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ); } - const output = vscode.window.createOutputChannel("Coder", { log: true }); - const storage = new Storage( - vscodeProposed, - output, - ctx.globalState, - ctx.secrets, - ctx.globalStorageUri, - ctx.logUri, + const pathResolver = new PathResolver( + ctx.globalStorageUri.fsPath, + ctx.logUri.fsPath, ); + const mementoManager = new MementoManager(ctx.globalState); + const secretsManager = new SecretsManager(ctx.secrets); - // Try to clear this flag ASAP then pass it around if needed - const isFirstConnect = await storage.getAndClearFirstConnect(); + const output = vscode.window.createOutputChannel("Coder", { log: true }); + + // Try to clear this flag ASAP + const isFirstConnect = await mementoManager.getAndClearFirstConnect(); // 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 = storage.getUrl(); + const url = mementoManager.getUrl(); const client = CoderApi.create( url || "", - await storage.getSessionToken(), - storage.output, + await secretsManager.getSessionToken(), + output, () => vscode.workspace.getConfiguration(), ); const myWorkspacesProvider = new WorkspaceProvider( WorkspaceQuery.Mine, client, - storage, + output, 5, ); const allWorkspacesProvider = new WorkspaceProvider( WorkspaceQuery.All, client, - storage, + output, ); // createTreeView, unlike registerTreeDataProvider, gives us the tree view API @@ -129,11 +131,11 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // hit enter and move on. const url = await commands.maybeAskUrl( params.get("url"), - storage.getUrl(), + mementoManager.getUrl(), ); if (url) { client.setHost(url); - await storage.setUrl(url); + await mementoManager.setUrl(url); } else { throw new Error( "url must be provided or specified as a query parameter", @@ -151,11 +153,11 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { : (params.get("token") ?? ""); if (token) { client.setSessionToken(token); - await storage.setSessionToken(token); + await secretsManager.setSessionToken(token); } // Store on disk to be used by the cli. - await storage.configureCli(toSafeHost(url), url, token); + await cliManager.configure(toSafeHost(url), url, token); vscode.commands.executeCommand( "coder.open", @@ -211,11 +213,11 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // hit enter and move on. const url = await commands.maybeAskUrl( params.get("url"), - storage.getUrl(), + mementoManager.getUrl(), ); if (url) { client.setHost(url); - await storage.setUrl(url); + await mementoManager.setUrl(url); } else { throw new Error( "url must be provided or specified as a query parameter", @@ -233,7 +235,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { : (params.get("token") ?? ""); // Store on disk to be used by the cli. - await storage.configureCli(toSafeHost(url), url, token); + await cliManager.configure(toSafeHost(url), url, token); vscode.commands.executeCommand( "coder.openDevContainer", @@ -251,9 +253,19 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }, }); + const cliManager = new CliManager(vscodeProposed, output, pathResolver); + // Register globally available commands. Many of these have visibility // controlled by contexts, see `when` in the package.json. - const commands = new Commands(vscodeProposed, client, storage); + const commands = new Commands( + vscodeProposed, + client, + output, + pathResolver, + mementoManager, + secretsManager, + cliManager, + ); vscode.commands.registerCommand("coder.login", commands.login.bind(commands)); vscode.commands.registerCommand( "coder.logout", @@ -309,9 +321,11 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { if (remoteSSHExtension && vscodeProposed.env.remoteAuthority) { const remote = new Remote( vscodeProposed, - storage, + output, commands, ctx.extensionMode, + pathResolver, + cliManager, ); try { const details = await remote.setup( @@ -326,7 +340,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } } catch (ex) { if (ex instanceof CertificateError) { - storage.output.warn(ex.x509Err || ex.message); + output.warn(ex.x509Err || ex.message); await ex.showModal("Failed to open workspace"); } else if (isAxiosError(ex)) { const msg = getErrorMessage(ex, "None"); @@ -335,7 +349,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const method = ex.config?.method?.toUpperCase() || "request"; const status = ex.response?.status || "None"; const message = `API ${method} to '${urlString}' failed.\nStatus code: ${status}\nMessage: ${msg}\nDetail: ${detail}`; - storage.output.warn(message); + output.warn(message); await vscodeProposed.window.showErrorMessage( "Failed to open workspace", { @@ -346,7 +360,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ); } else { const message = errToStr(ex, "No error message was provided"); - storage.output.warn(message); + output.warn(message); await vscodeProposed.window.showErrorMessage( "Failed to open workspace", { @@ -365,12 +379,12 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // See if the plugin client is authenticated. const baseUrl = client.getAxiosInstance().defaults.baseURL; if (baseUrl) { - storage.output.info(`Logged in to ${baseUrl}; checking credentials`); + output.info(`Logged in to ${baseUrl}; checking credentials`); client .getAuthenticatedUser() .then(async (user) => { if (user && user.roles) { - storage.output.info("Credentials are valid"); + output.info("Credentials are valid"); vscode.commands.executeCommand( "setContext", "coder.authenticated", @@ -388,13 +402,13 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { myWorkspacesProvider.fetchAndRefresh(); allWorkspacesProvider.fetchAndRefresh(); } else { - storage.output.warn("No error, but got unexpected response", user); + output.warn("No error, but got unexpected response", user); } }) .catch((error) => { // This should be a failure to make the request, like the header command // errored. - storage.output.warn("Failed to check user authentication", error); + output.warn("Failed to check user authentication", error); vscode.window.showErrorMessage( `Failed to check user authentication: ${error.message}`, ); @@ -403,7 +417,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { vscode.commands.executeCommand("setContext", "coder.loaded", true); }); } else { - storage.output.info("Not currently logged in"); + output.info("Not currently logged in"); vscode.commands.executeCommand("setContext", "coder.loaded", true); // Handle autologin, if not already logged in. diff --git a/src/headers.test.ts b/src/headers.test.ts index 10e77f8d..84c39d36 100644 --- a/src/headers.test.ts +++ b/src/headers.test.ts @@ -66,25 +66,25 @@ it("should return headers", async () => { it("should error on malformed or empty lines", async () => { await expect( getHeaders("localhost", "printf 'foo=bar\\r\\n\\r\\n'", logger), - ).rejects.toMatch(/Malformed/); + ).rejects.toThrow(/Malformed/); await expect( getHeaders("localhost", "printf '\\r\\nfoo=bar'", logger), - ).rejects.toMatch(/Malformed/); + ).rejects.toThrow(/Malformed/); await expect( getHeaders("localhost", "printf '=foo'", logger), - ).rejects.toMatch(/Malformed/); - await expect(getHeaders("localhost", "printf 'foo'", logger)).rejects.toMatch( + ).rejects.toThrow(/Malformed/); + await expect(getHeaders("localhost", "printf 'foo'", logger)).rejects.toThrow( /Malformed/, ); await expect( getHeaders("localhost", "printf ' =foo'", logger), - ).rejects.toMatch(/Malformed/); + ).rejects.toThrow(/Malformed/); await expect( getHeaders("localhost", "printf 'foo =bar'", logger), - ).rejects.toMatch(/Malformed/); + ).rejects.toThrow(/Malformed/); await expect( getHeaders("localhost", "printf 'foo foo=bar'", logger), - ).rejects.toMatch(/Malformed/); + ).rejects.toThrow(/Malformed/); }); it("should have access to environment variables", async () => { @@ -101,7 +101,7 @@ it("should have access to environment variables", async () => { }); it("should error on non-zero exit", async () => { - await expect(getHeaders("localhost", "exit 10", logger)).rejects.toMatch( + await expect(getHeaders("localhost", "exit 10", logger)).rejects.toThrow( /exited unexpectedly with code 10/, ); }); diff --git a/src/inbox.ts b/src/inbox.ts index 3141b661..e12263bf 100644 --- a/src/inbox.ts +++ b/src/inbox.ts @@ -4,7 +4,7 @@ import { } from "coder/site/src/api/typesGenerated"; import * as vscode from "vscode"; import { CoderApi } from "./api/coderApi"; -import { type Storage } from "./storage"; +import { Logger } from "./logging/logger"; import { OneWayWebSocket } from "./websocket/oneWayWebSocket"; // These are the template IDs of our notifications. @@ -14,12 +14,12 @@ 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 #storage: Storage; + readonly #logger: Logger; #disposed = false; #socket: OneWayWebSocket; - constructor(workspace: Workspace, client: CoderApi, storage: Storage) { - this.#storage = storage; + constructor(workspace: Workspace, client: CoderApi, logger: Logger) { + this.#logger = logger; const watchTemplates = [ TEMPLATE_WORKSPACE_OUT_OF_DISK, @@ -31,7 +31,7 @@ export class Inbox implements vscode.Disposable { this.#socket = client.watchInboxNotifications(watchTemplates, watchTargets); this.#socket.addEventListener("open", () => { - this.#storage.output.info("Listening to Coder Inbox"); + this.#logger.info("Listening to Coder Inbox"); }); this.#socket.addEventListener("error", () => { @@ -41,10 +41,7 @@ export class Inbox implements vscode.Disposable { this.#socket.addEventListener("message", (data) => { if (data.parseError) { - this.#storage.output.error( - "Failed to parse inbox message", - data.parseError, - ); + this.#logger.error("Failed to parse inbox message", data.parseError); } else { vscode.window.showInformationMessage( data.parsedMessage.notification.title, @@ -55,7 +52,7 @@ export class Inbox implements vscode.Disposable { dispose() { if (!this.#disposed) { - this.#storage.output.info("No longer listening to Coder Inbox"); + this.#logger.info("No longer listening to Coder Inbox"); this.#socket.close(); this.#disposed = true; } diff --git a/src/pgp.ts b/src/pgp.ts index c707c5b4..2e82fb79 100644 --- a/src/pgp.ts +++ b/src/pgp.ts @@ -2,8 +2,8 @@ import { createReadStream, promises as fs } from "fs"; import * as openpgp from "openpgp"; import * as path from "path"; import { Readable } from "stream"; -import * as vscode from "vscode"; import { errToStr } from "./api/api-helper"; +import { Logger } from "./logging/logger"; export type Key = openpgp.Key; @@ -35,9 +35,7 @@ export class VerificationError extends Error { /** * Return the public keys bundled with the plugin. */ -export async function readPublicKeys( - logger?: vscode.LogOutputChannel, -): Promise { +export async function readPublicKeys(logger?: Logger): Promise { const keyFile = path.join(__dirname, "../pgp-public.key"); logger?.info("Reading public key", keyFile); const armoredKeys = await fs.readFile(keyFile, "utf8"); @@ -53,7 +51,7 @@ export async function verifySignature( publicKeys: openpgp.Key[], cliPath: string, signaturePath: string, - logger?: vscode.LogOutputChannel, + logger?: Logger, ): Promise { try { logger?.info("Reading signature", signaturePath); diff --git a/src/remote.ts b/src/remote.ts index 172074ee..c9765fb8 100644 --- a/src/remote.ts +++ b/src/remote.ts @@ -19,14 +19,16 @@ import { createWorkspaceIdentifier, extractAgents } from "./api/api-helper"; import { CoderApi } from "./api/coderApi"; import { needToken } from "./api/utils"; import { startWorkspaceIfStoppedOrFailed, waitForBuild } from "./api/workspace"; -import * as cli from "./cliManager"; +import * as cliUtils from "./cliUtils"; import { Commands } from "./commands"; +import { CliManager } from "./core/cliManager"; +import { PathResolver } from "./core/pathResolver"; import { featureSetForVersion, FeatureSet } from "./featureSet"; import { getGlobalFlags } from "./globalFlags"; import { Inbox } from "./inbox"; +import { Logger } from "./logging/logger"; import { SSHConfig, SSHValues, mergeSSHConfigValues } from "./sshConfig"; import { computeSSHProperties, sshSupportsSetEnv } from "./sshSupport"; -import { Storage } from "./storage"; import { AuthorityPrefix, escapeCommandArg, @@ -45,9 +47,11 @@ export class Remote { public constructor( // We use the proposed API to get access to useCustom in dialogs. private readonly vscodeProposed: typeof vscode, - private readonly storage: Storage, + private readonly logger: Logger, private readonly commands: Commands, private readonly mode: vscode.ExtensionMode, + private readonly pathResolver: PathResolver, + private readonly cliManager: CliManager, ) {} private async confirmStart(workspaceName: string): Promise { @@ -111,9 +115,7 @@ export class Remote { title: "Waiting for workspace build...", }, async () => { - const globalConfigDir = path.dirname( - this.storage.getSessionTokenPath(label), - ); + const globalConfigDir = this.pathResolver.getGlobalConfigDir(label); while (workspace.latest_build.status !== "running") { ++attempts; switch (workspace.latest_build.status) { @@ -121,7 +123,7 @@ export class Remote { case "starting": case "stopping": writeEmitter = initWriteEmitterAndTerminal(); - this.storage.output.info(`Waiting for ${workspaceName}...`); + this.logger.info(`Waiting for ${workspaceName}...`); workspace = await waitForBuild(client, writeEmitter, workspace); break; case "stopped": @@ -132,7 +134,7 @@ export class Remote { return undefined; } writeEmitter = initWriteEmitterAndTerminal(); - this.storage.output.info(`Starting ${workspaceName}...`); + this.logger.info(`Starting ${workspaceName}...`); workspace = await startWorkspaceIfStoppedOrFailed( client, globalConfigDir, @@ -153,7 +155,7 @@ export class Remote { return undefined; } writeEmitter = initWriteEmitterAndTerminal(); - this.storage.output.info(`Starting ${workspaceName}...`); + this.logger.info(`Starting ${workspaceName}...`); workspace = await startWorkspaceIfStoppedOrFailed( client, globalConfigDir, @@ -177,7 +179,7 @@ export class Remote { ); } } - this.storage.output.info( + this.logger.info( `${workspaceName} status is now`, workspace.latest_build.status, ); @@ -213,10 +215,10 @@ export class Remote { const workspaceName = `${parts.username}/${parts.workspace}`; // Migrate "session_token" file to "session", if needed. - await this.storage.migrateSessionToken(parts.label); + await this.migrateSessionToken(parts.label); // Get the URL and token belonging to this host. - const { url: baseUrlRaw, token } = await this.storage.readCliConfig( + const { url: baseUrlRaw, token } = await this.cliManager.readConfig( parts.label, ); @@ -250,8 +252,8 @@ export class Remote { return; } - this.storage.output.info("Using deployment URL", baseUrlRaw); - this.storage.output.info("Using deployment label", parts.label || "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 @@ -261,7 +263,7 @@ export class Remote { const workspaceClient = CoderApi.create( baseUrlRaw, token, - this.storage.output, + this.logger, () => vscode.workspace.getConfiguration(), ); // Store for use in commands. @@ -269,7 +271,10 @@ export class Remote { let binaryPath: string | undefined; if (this.mode === vscode.ExtensionMode.Production) { - binaryPath = await this.storage.fetchBinary(workspaceClient, parts.label); + binaryPath = await this.cliManager.fetchBinary( + workspaceClient, + parts.label, + ); } else { try { // In development, try to use `/tmp/coder` as the binary path. @@ -277,7 +282,7 @@ export class Remote { binaryPath = path.join(os.tmpdir(), "coder"); await fs.stat(binaryPath); } catch (ex) { - binaryPath = await this.storage.fetchBinary( + binaryPath = await this.cliManager.fetchBinary( workspaceClient, parts.label, ); @@ -289,7 +294,7 @@ export class Remote { let version: semver.SemVer | null = null; try { - version = semver.parse(await cli.version(binaryPath)); + version = semver.parse(await cliUtils.version(binaryPath)); } catch (e) { version = semver.parse(buildInfo.version); } @@ -315,12 +320,12 @@ export class Remote { // Next is to find the workspace from the URI scheme provided. let workspace: Workspace; try { - this.storage.output.info(`Looking for workspace ${workspaceName}...`); + this.logger.info(`Looking for workspace ${workspaceName}...`); workspace = await workspaceClient.getWorkspaceByOwnerAndName( parts.username, parts.workspace, ); - this.storage.output.info( + this.logger.info( `Found workspace ${workspaceName} with status`, workspace.latest_build.status, ); @@ -406,7 +411,7 @@ export class Remote { this.commands.workspace = workspace; // Pick an agent. - this.storage.output.info(`Finding agent for ${workspaceName}...`); + this.logger.info(`Finding agent for ${workspaceName}...`); const agents = extractAgents(workspace.latest_build.resources); const gotAgent = await this.commands.maybeAskAgent(agents, parts.agent); if (!gotAgent) { @@ -415,13 +420,10 @@ export class Remote { return; } let agent = gotAgent; // Reassign so it cannot be undefined in callbacks. - this.storage.output.info( - `Found agent ${agent.name} with status`, - agent.status, - ); + this.logger.info(`Found agent ${agent.name} with status`, agent.status); // Do some janky setting manipulation. - this.storage.output.info("Modifying settings..."); + this.logger.info("Modifying settings..."); const remotePlatforms = this.vscodeProposed.workspace .getConfiguration() .get>("remote.SSH.remotePlatform", {}); @@ -437,7 +439,7 @@ export class Remote { let settingsContent = "{}"; try { settingsContent = await fs.readFile( - this.storage.getUserSettingsPath(), + this.pathResolver.getUserSettingsPath(), "utf8", ); } catch (ex) { @@ -486,14 +488,17 @@ export class Remote { if (mungedPlatforms || mungedConnTimeout) { try { - await fs.writeFile(this.storage.getUserSettingsPath(), settingsContent); + await fs.writeFile( + this.pathResolver.getUserSettingsPath(), + settingsContent, + ); } catch (ex) { // This could be because the user's settings.json is read-only. This is // the case when using home-manager on NixOS, for example. Failure to // write here is not necessarily catastrophic since the user will be // asked for the platform and the default timeout might be sufficient. mungedPlatforms = mungedConnTimeout = false; - this.storage.output.warn("Failed to configure settings", ex); + this.logger.warn("Failed to configure settings", ex); } } @@ -501,7 +506,7 @@ export class Remote { const monitor = new WorkspaceMonitor( workspace, workspaceClient, - this.storage, + this.logger, this.vscodeProposed, ); disposables.push(monitor); @@ -510,12 +515,12 @@ export class Remote { ); // Watch coder inbox for messages - const inbox = new Inbox(workspace, workspaceClient, this.storage); + const inbox = new Inbox(workspace, workspaceClient, this.logger); disposables.push(inbox); // Wait for the agent to connect. if (agent.status === "connecting") { - this.storage.output.info(`Waiting for ${workspaceName}/${agent.name}...`); + this.logger.info(`Waiting for ${workspaceName}/${agent.name}...`); await vscode.window.withProgress( { title: "Waiting for the agent to connect...", @@ -544,10 +549,7 @@ export class Remote { }); }, ); - this.storage.output.info( - `Agent ${agent.name} status is now`, - agent.status, - ); + this.logger.info(`Agent ${agent.name} status is now`, agent.status); } // Make sure the agent is connected. @@ -577,7 +579,7 @@ export class Remote { // If we didn't write to the SSH config file, connecting would fail with // "Host not found". try { - this.storage.output.info("Updating SSH config..."); + this.logger.info("Updating SSH config..."); await this.updateSSHConfig( workspaceClient, parts.label, @@ -587,7 +589,7 @@ export class Remote { featureSet, ); } catch (error) { - this.storage.output.warn("Failed to configure SSH", error); + this.logger.warn("Failed to configure SSH", error); throw error; } @@ -631,7 +633,7 @@ export class Remote { ...this.createAgentMetadataStatusBar(agent, workspaceClient), ); - this.storage.output.info("Remote setup complete"); + this.logger.info("Remote setup complete"); // Returning the URL and token allows the plugin to authenticate its own // client, for example to display the list of workspaces belonging to this @@ -646,6 +648,22 @@ export class Remote { }; } + /** + * Migrate the session token file from "session_token" to "session", if needed. + */ + private async migrateSessionToken(label: string) { + const oldTokenPath = this.pathResolver.getLegacySessionTokenPath(label); + const newTokenPath = this.pathResolver.getSessionTokenPath(label); + try { + await fs.rename(oldTokenPath, newTokenPath); + } catch (error) { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + return; + } + throw error; + } + } + /** * 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. @@ -672,10 +690,7 @@ export class Remote { return ""; } await fs.mkdir(logDir, { recursive: true }); - this.storage.output.info( - "SSH proxy diagnostics are being written to", - logDir, - ); + this.logger.info("SSH proxy diagnostics are being written to", logDir); return ` --log-dir ${escapeCommandArg(logDir)} -v`; } @@ -765,11 +780,11 @@ export class Remote { const globalConfigs = this.globalConfigs(label); const proxyCommand = featureSet.wildcardSSH - ? `${escapeCommandArg(binaryPath)}${globalConfigs} ssh --stdio --usage-app=vscode --disable-autostart --network-info-dir ${escapeCommandArg(this.storage.getNetworkInfoPath())}${await this.formatLogArg(logDir)} --ssh-host-prefix ${hostPrefix} %h` + ? `${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.storage.getNetworkInfoPath(), - )}${await this.formatLogArg(logDir)} --session-token-file ${escapeCommandArg(this.storage.getSessionTokenPath(label))} --url-file ${escapeCommandArg( - this.storage.getUrlPath(label), + this.pathResolver.getNetworkInfoPath(), + )}${await this.formatLogArg(logDir)} --session-token-file ${escapeCommandArg(this.pathResolver.getSessionTokenPath(label))} --url-file ${escapeCommandArg( + this.pathResolver.getUrlPath(label), )} %h`; const sshValues: SSHValues = { @@ -828,7 +843,7 @@ export class Remote { const vscodeConfig = vscode.workspace.getConfiguration(); const args = getGlobalFlags( vscodeConfig, - path.dirname(this.storage.getSessionTokenPath(label)), + this.pathResolver.getGlobalConfigDir(label), ); return ` ${args.join(" ")}`; } @@ -841,7 +856,7 @@ export class Remote { 1000, ); const networkInfoFile = path.join( - this.storage.getNetworkInfoPath(), + this.pathResolver.getNetworkInfoPath(), `${sshPid}.json`, ); @@ -964,7 +979,7 @@ export class Remote { return undefined; } // Loop until we find the remote SSH log for this window. - const filePath = await this.storage.getRemoteSSHLogPath(); + const filePath = await this.getRemoteSSHLogPath(); if (!filePath) { return new Promise((resolve) => setTimeout(() => resolve(loop()), 500)); } @@ -978,6 +993,29 @@ export class Remote { 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]); + } + /** * Creates and manages a status bar item that displays metadata information for a given workspace agent. * The status bar item updates dynamically based on changes to the agent's metadata, @@ -997,7 +1035,7 @@ export class Remote { const onChangeDisposable = agentWatcher.onChange(() => { if (agentWatcher.error) { const errMessage = formatMetadataError(agentWatcher.error); - this.storage.output.warn(errMessage); + this.logger.warn(errMessage); statusBarItem.text = "$(warning) Agent Status Unavailable"; statusBarItem.tooltip = errMessage; diff --git a/src/workspaceMonitor.ts b/src/workspaceMonitor.ts index 16c1ecde..ece765a6 100644 --- a/src/workspaceMonitor.ts +++ b/src/workspaceMonitor.ts @@ -3,7 +3,7 @@ import { formatDistanceToNowStrict } from "date-fns"; import * as vscode from "vscode"; import { createWorkspaceIdentifier, errToStr } from "./api/api-helper"; import { CoderApi } from "./api/coderApi"; -import { Storage } from "./storage"; +import { Logger } from "./logging/logger"; import { OneWayWebSocket } from "./websocket/oneWayWebSocket"; /** @@ -34,7 +34,7 @@ export class WorkspaceMonitor implements vscode.Disposable { constructor( workspace: Workspace, private readonly client: CoderApi, - private readonly storage: Storage, + private readonly logger: Logger, // We use the proposed API to get access to useCustom in dialogs. private readonly vscodeProposed: typeof vscode, ) { @@ -42,7 +42,7 @@ export class WorkspaceMonitor implements vscode.Disposable { const socket = this.client.watchWorkspace(workspace); socket.addEventListener("open", () => { - this.storage.output.info(`Monitoring ${this.name}...`); + this.logger.info(`Monitoring ${this.name}...`); }); socket.addEventListener("message", (event) => { @@ -83,7 +83,7 @@ export class WorkspaceMonitor implements vscode.Disposable { */ dispose() { if (!this.disposed) { - this.storage.output.info(`Unmonitoring ${this.name}...`); + this.logger.info(`Unmonitoring ${this.name}...`); this.statusBarItem.dispose(); this.socket.close(); this.disposed = true; @@ -209,7 +209,7 @@ export class WorkspaceMonitor implements vscode.Disposable { error, "Got empty error while monitoring workspace", ); - this.storage.output.error(message); + this.logger.error(message); } private updateContext(workspace: Workspace) { diff --git a/src/workspacesProvider.ts b/src/workspacesProvider.ts index f344eb0f..23f5705a 100644 --- a/src/workspacesProvider.ts +++ b/src/workspacesProvider.ts @@ -13,11 +13,11 @@ import { } from "./agentMetadataHelper"; import { AgentMetadataEvent, - extractAllAgents, extractAgents, + extractAllAgents, } from "./api/api-helper"; import { CoderApi } from "./api/coderApi"; -import { Storage } from "./storage"; +import { Logger } from "./logging/logger"; export enum WorkspaceQuery { Mine = "owner:me", @@ -46,7 +46,7 @@ export class WorkspaceProvider constructor( private readonly getWorkspacesQuery: WorkspaceQuery, private readonly client: CoderApi, - private readonly storage: Storage, + private readonly logger: Logger, private readonly timerSeconds?: number, ) { // No initialization. @@ -92,7 +92,7 @@ export class WorkspaceProvider */ private async fetch(): Promise { if (vscode.env.logLevel <= vscode.LogLevel.Debug) { - this.storage.output.info( + this.logger.info( `Fetching workspaces: ${this.getWorkspacesQuery || "no filter"}...`, ); } diff --git a/vitest.config.ts b/vitest.config.ts index 2007fb45..af067d95 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,3 +1,4 @@ +import path from "path"; import { defineConfig } from "vitest/config"; export default defineConfig({ @@ -13,5 +14,13 @@ export default defineConfig({ "./src/test/**", ], environment: "node", + coverage: { + provider: "v8", + }, + }, + resolve: { + alias: { + vscode: path.resolve(__dirname, "src/__mocks__/vscode.runtime.ts"), + }, }, }); diff --git a/yarn.lock b/yarn.lock index f30780a2..62565608 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12,7 +12,7 @@ resolved "https://registry.yarnpkg.com/@altano/repository-tools/-/repository-tools-1.0.1.tgz#969bb94cc80f8b4d62c7d6956466edc3f3c3817a" integrity sha512-/FFHQOMp5TZWplkDWbbLIjmANDr9H/FtqUm+hfJMK76OBut0Ht0cNfd0ZXd/6LXf4pWUTzvpgVjcin7EEHSznA== -"@ampproject/remapping@^2.2.0": +"@ampproject/remapping@^2.2.0", "@ampproject/remapping@^2.3.0": version "2.3.0" resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.3.0.tgz#ed441b6fa600072520ce18b43d2c8cc8caecc7f4" integrity sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw== @@ -232,6 +232,11 @@ resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz#1aabb72ee72ed35789b4bbcad3ca2862ce614e8c" integrity sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA== +"@babel/helper-string-parser@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687" + integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== + "@babel/helper-validator-identifier@^7.22.20": version "7.22.20" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz#c4ae002c61d2879e724581d96665583dbc1dc0e0" @@ -276,6 +281,13 @@ dependencies: "@babel/types" "^7.26.0" +"@babel/parser@^7.25.4": + version "7.28.4" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.4.tgz#da25d4643532890932cc03f7705fe19637e03fa8" + integrity sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg== + dependencies: + "@babel/types" "^7.28.4" + "@babel/template@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.25.9.tgz#ecb62d81a8a6f5dc5fe8abfc3901fc52ddf15016" @@ -298,6 +310,14 @@ debug "^4.3.1" globals "^11.1.0" +"@babel/types@^7.25.4", "@babel/types@^7.28.4": + version "7.28.4" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.4.tgz#0a4e618f4c60a7cd6c11cb2d48060e4dbe38ac3a" + integrity sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q== + dependencies: + "@babel/helper-string-parser" "^7.27.1" + "@babel/helper-validator-identifier" "^7.27.1" + "@babel/types@^7.25.9", "@babel/types@^7.26.0": version "7.26.0" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.26.0.tgz#deabd08d6b753bc8e0f198f8709fb575e31774ff" @@ -311,125 +331,145 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== +"@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== + "@discoveryjs/json-ext@^0.5.0": version "0.5.7" resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw== -"@esbuild/aix-ppc64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f" - integrity sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ== - -"@esbuild/android-arm64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz#09d9b4357780da9ea3a7dfb833a1f1ff439b4052" - integrity sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A== - -"@esbuild/android-arm@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz#9b04384fb771926dfa6d7ad04324ecb2ab9b2e28" - integrity sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg== - -"@esbuild/android-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz#29918ec2db754cedcb6c1b04de8cd6547af6461e" - integrity sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA== - -"@esbuild/darwin-arm64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz#e495b539660e51690f3928af50a76fb0a6ccff2a" - integrity sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ== - -"@esbuild/darwin-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz#c13838fa57372839abdddc91d71542ceea2e1e22" - integrity sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw== - -"@esbuild/freebsd-arm64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz#646b989aa20bf89fd071dd5dbfad69a3542e550e" - integrity sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g== - -"@esbuild/freebsd-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz#aa615cfc80af954d3458906e38ca22c18cf5c261" - integrity sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ== - -"@esbuild/linux-arm64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz#70ac6fa14f5cb7e1f7f887bcffb680ad09922b5b" - integrity sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q== - -"@esbuild/linux-arm@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz#fc6fd11a8aca56c1f6f3894f2bea0479f8f626b9" - integrity sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA== - -"@esbuild/linux-ia32@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz#3271f53b3f93e3d093d518d1649d6d68d346ede2" - integrity sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg== - -"@esbuild/linux-loong64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz#ed62e04238c57026aea831c5a130b73c0f9f26df" - integrity sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg== - -"@esbuild/linux-mips64el@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz#e79b8eb48bf3b106fadec1ac8240fb97b4e64cbe" - integrity sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg== - -"@esbuild/linux-ppc64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz#5f2203860a143b9919d383ef7573521fb154c3e4" - integrity sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w== - -"@esbuild/linux-riscv64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz#07bcafd99322d5af62f618cb9e6a9b7f4bb825dc" - integrity sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA== - -"@esbuild/linux-s390x@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz#b7ccf686751d6a3e44b8627ababc8be3ef62d8de" - integrity sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A== - -"@esbuild/linux-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz#6d8f0c768e070e64309af8004bb94e68ab2bb3b0" - integrity sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ== - -"@esbuild/netbsd-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz#bbe430f60d378ecb88decb219c602667387a6047" - integrity sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg== - -"@esbuild/openbsd-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz#99d1cf2937279560d2104821f5ccce220cb2af70" - integrity sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow== - -"@esbuild/sunos-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz#08741512c10d529566baba837b4fe052c8f3487b" - integrity sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg== - -"@esbuild/win32-arm64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz#675b7385398411240735016144ab2e99a60fc75d" - integrity sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A== - -"@esbuild/win32-ia32@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz#1bfc3ce98aa6ca9a0969e4d2af72144c59c1193b" - integrity sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA== - -"@esbuild/win32-x64@0.21.5": - version "0.21.5" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c" - integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw== +"@esbuild/aix-ppc64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz#bef96351f16520055c947aba28802eede3c9e9a9" + integrity sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA== + +"@esbuild/android-arm64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz#d2e70be7d51a529425422091e0dcb90374c1546c" + integrity sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg== + +"@esbuild/android-arm@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.9.tgz#d2a753fe2a4c73b79437d0ba1480e2d760097419" + integrity sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ== + +"@esbuild/android-x64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.9.tgz#5278836e3c7ae75761626962f902a0d55352e683" + integrity sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw== + +"@esbuild/darwin-arm64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz#f1513eaf9ec8fa15dcaf4c341b0f005d3e8b47ae" + integrity sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg== + +"@esbuild/darwin-x64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz#e27dbc3b507b3a1cea3b9280a04b8b6b725f82be" + integrity sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ== + +"@esbuild/freebsd-arm64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz#364e3e5b7a1fd45d92be08c6cc5d890ca75908ca" + integrity sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q== + +"@esbuild/freebsd-x64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz#7c869b45faeb3df668e19ace07335a0711ec56ab" + integrity sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg== + +"@esbuild/linux-arm64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz#48d42861758c940b61abea43ba9a29b186d6cb8b" + integrity sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw== + +"@esbuild/linux-arm@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz#6ce4b9cabf148274101701d112b89dc67cc52f37" + integrity sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw== + +"@esbuild/linux-ia32@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz#207e54899b79cac9c26c323fc1caa32e3143f1c4" + integrity sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A== + +"@esbuild/linux-loong64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz#0ba48a127159a8f6abb5827f21198b999ffd1fc0" + integrity sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ== + +"@esbuild/linux-mips64el@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz#a4d4cc693d185f66a6afde94f772b38ce5d64eb5" + integrity sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA== + +"@esbuild/linux-ppc64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz#0f5805c1c6d6435a1dafdc043cb07a19050357db" + integrity sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w== + +"@esbuild/linux-riscv64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz#6776edece0f8fca79f3386398b5183ff2a827547" + integrity sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg== + +"@esbuild/linux-s390x@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz#3f6f29ef036938447c2218d309dc875225861830" + integrity sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA== + +"@esbuild/linux-x64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz#831fe0b0e1a80a8b8391224ea2377d5520e1527f" + integrity sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg== + +"@esbuild/netbsd-arm64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz#06f99d7eebe035fbbe43de01c9d7e98d2a0aa548" + integrity sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q== + +"@esbuild/netbsd-x64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz#db99858e6bed6e73911f92a88e4edd3a8c429a52" + integrity sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g== + +"@esbuild/openbsd-arm64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz#afb886c867e36f9d86bb21e878e1185f5d5a0935" + integrity sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ== + +"@esbuild/openbsd-x64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz#30855c9f8381fac6a0ef5b5f31ac6e7108a66ecf" + integrity sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA== + +"@esbuild/openharmony-arm64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz#2f2144af31e67adc2a8e3705c20c2bd97bd88314" + integrity sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg== + +"@esbuild/sunos-x64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz#69b99a9b5bd226c9eb9c6a73f990fddd497d732e" + integrity sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw== + +"@esbuild/win32-arm64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz#d789330a712af916c88325f4ffe465f885719c6b" + integrity sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ== + +"@esbuild/win32-ia32@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz#52fc735406bd49688253e74e4e837ac2ba0789e3" + integrity sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww== + +"@esbuild/win32-x64@0.25.9": + version "0.25.9" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz#585624dc829cfb6e7c0aa6c3ca7d7e6daa87e34f" + integrity sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ== "@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": version "4.4.0" @@ -522,13 +562,6 @@ resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== -"@jest/schemas@^29.6.3": - version "29.6.3" - resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.6.3.tgz#430b5ce8a4e0044a7e3819663305a7b3091c8e03" - integrity sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA== - dependencies: - "@sinclair/typebox" "^0.27.8" - "@jridgewell/gen-mapping@^0.3.0": version "0.3.2" resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz#c1aedc61e853f2bb9f5dfe6d4442d3b565b253b9" @@ -580,11 +613,16 @@ resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24" integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw== -"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.4.15": +"@jridgewell/sourcemap-codec@^1.4.14": version "1.4.15" resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32" integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg== +"@jridgewell/sourcemap-codec@^1.5.5": + version "1.5.5" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" + integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== + "@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": version "0.3.25" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" @@ -593,6 +631,14 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@jridgewell/trace-mapping@^0.3.23", "@jridgewell/trace-mapping@^0.3.30": + version "0.3.31" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" + integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + "@jridgewell/trace-mapping@^0.3.9": version "0.3.17" resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.17.tgz#793041277af9073b0951a7fe0f0d8c4c98c36985" @@ -601,25 +647,49 @@ "@jridgewell/resolve-uri" "3.1.0" "@jridgewell/sourcemap-codec" "1.4.14" -"@jsonjoy.com/base64@^1.1.1": +"@jsonjoy.com/base64@^1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@jsonjoy.com/base64/-/base64-1.1.2.tgz#cf8ea9dcb849b81c95f14fc0aaa151c6b54d2578" integrity sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA== -"@jsonjoy.com/json-pack@^1.0.3": - version "1.0.4" - resolved "https://registry.yarnpkg.com/@jsonjoy.com/json-pack/-/json-pack-1.0.4.tgz#ab59c642a2e5368e8bcfd815d817143d4f3035d0" - integrity sha512-aOcSN4MeAtFROysrbqG137b7gaDDSmVrl5mpo6sT/w+kcXpWnzhMjmY/Fh/sDx26NBxyIE7MB1seqLeCAzy9Sg== - dependencies: - "@jsonjoy.com/base64" "^1.1.1" - "@jsonjoy.com/util" "^1.1.2" +"@jsonjoy.com/buffers@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/buffers/-/buffers-1.0.0.tgz#ade6895b7d3883d70f87b5743efaa12c71dfef7a" + integrity sha512-NDigYR3PHqCnQLXYyoLbnEdzMMvzeiCWo1KOut7Q0CoIqg9tUAPKJ1iq/2nFhc5kZtexzutNY0LFjdwWL3Dw3Q== + +"@jsonjoy.com/codegen@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/codegen/-/codegen-1.0.0.tgz#5c23f796c47675f166d23b948cdb889184b93207" + integrity sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g== + +"@jsonjoy.com/json-pack@^1.11.0": + version "1.14.0" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/json-pack/-/json-pack-1.14.0.tgz#eda5255ccdaeafb3aa811ff1ae4814790b958b4f" + integrity sha512-LpWbYgVnKzphN5S6uss4M25jJ/9+m6q6UJoeN6zTkK4xAGhKsiBRPVeF7OYMWonn5repMQbE5vieRXcMUrKDKw== + dependencies: + "@jsonjoy.com/base64" "^1.1.2" + "@jsonjoy.com/buffers" "^1.0.0" + "@jsonjoy.com/codegen" "^1.0.0" + "@jsonjoy.com/json-pointer" "^1.0.1" + "@jsonjoy.com/util" "^1.9.0" hyperdyperid "^1.2.0" - thingies "^1.20.0" + thingies "^2.5.0" -"@jsonjoy.com/util@^1.1.2", "@jsonjoy.com/util@^1.3.0": - version "1.5.0" - resolved "https://registry.yarnpkg.com/@jsonjoy.com/util/-/util-1.5.0.tgz#6008e35b9d9d8ee27bc4bfaa70c8cbf33a537b4c" - integrity sha512-ojoNsrIuPI9g6o8UxhraZQSyF2ByJanAY4cTFbc8Mf2AXEF4aQRGY1dJxyJpuyav8r9FGflEt/Ff3u5Nt6YMPA== +"@jsonjoy.com/json-pointer@^1.0.1": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/json-pointer/-/json-pointer-1.0.2.tgz#049cb530ac24e84cba08590c5e36b431c4843408" + integrity sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg== + dependencies: + "@jsonjoy.com/codegen" "^1.0.0" + "@jsonjoy.com/util" "^1.9.0" + +"@jsonjoy.com/util@^1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/util/-/util-1.9.0.tgz#7ee95586aed0a766b746cd8d8363e336c3c47c46" + integrity sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ== + dependencies: + "@jsonjoy.com/buffers" "^1.0.0" + "@jsonjoy.com/codegen" "^1.0.0" "@nodelib/fs.scandir@2.1.5": version "2.1.5" @@ -652,105 +722,110 @@ resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.2.7.tgz#eb5014dfd0b03e7f3ba2eeeff506eed89b028058" integrity sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg== -"@rollup/rollup-android-arm-eabi@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.39.0.tgz#1d8cc5dd3d8ffe569d8f7f67a45c7909828a0f66" - integrity sha512-lGVys55Qb00Wvh8DMAocp5kIcaNzEFTmGhfFd88LfaogYTRKrdxgtlO5H6S49v2Nd8R2C6wLOal0qv6/kCkOwA== - -"@rollup/rollup-android-arm64@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.39.0.tgz#9c136034d3d9ed29d0b138c74dd63c5744507fca" - integrity sha512-It9+M1zE31KWfqh/0cJLrrsCPiF72PoJjIChLX+rEcujVRCb4NLQ5QzFkzIZW8Kn8FTbvGQBY5TkKBau3S8cCQ== - -"@rollup/rollup-darwin-arm64@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.39.0.tgz#830d07794d6a407c12b484b8cf71affd4d3800a6" - integrity sha512-lXQnhpFDOKDXiGxsU9/l8UEGGM65comrQuZ+lDcGUx+9YQ9dKpF3rSEGepyeR5AHZ0b5RgiligsBhWZfSSQh8Q== - -"@rollup/rollup-darwin-x64@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.39.0.tgz#b26f0f47005c1fa5419a880f323ed509dc8d885c" - integrity sha512-mKXpNZLvtEbgu6WCkNij7CGycdw9cJi2k9v0noMb++Vab12GZjFgUXD69ilAbBh034Zwn95c2PNSz9xM7KYEAQ== - -"@rollup/rollup-freebsd-arm64@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.39.0.tgz#2b60c81ac01ff7d1bc8df66aee7808b6690c6d19" - integrity sha512-jivRRlh2Lod/KvDZx2zUR+I4iBfHcu2V/BA2vasUtdtTN2Uk3jfcZczLa81ESHZHPHy4ih3T/W5rPFZ/hX7RtQ== - -"@rollup/rollup-freebsd-x64@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.39.0.tgz#4826af30f4d933d82221289068846c9629cc628c" - integrity sha512-8RXIWvYIRK9nO+bhVz8DwLBepcptw633gv/QT4015CpJ0Ht8punmoHU/DuEd3iw9Hr8UwUV+t+VNNuZIWYeY7Q== - -"@rollup/rollup-linux-arm-gnueabihf@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.39.0.tgz#a1f4f963d5dcc9e5575c7acf9911824806436bf7" - integrity sha512-mz5POx5Zu58f2xAG5RaRRhp3IZDK7zXGk5sdEDj4o96HeaXhlUwmLFzNlc4hCQi5sGdR12VDgEUqVSHer0lI9g== - -"@rollup/rollup-linux-arm-musleabihf@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.39.0.tgz#e924b0a8b7c400089146f6278446e6b398b75a06" - integrity sha512-+YDwhM6gUAyakl0CD+bMFpdmwIoRDzZYaTWV3SDRBGkMU/VpIBYXXEvkEcTagw/7VVkL2vA29zU4UVy1mP0/Yw== - -"@rollup/rollup-linux-arm64-gnu@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.39.0.tgz#cb43303274ec9a716f4440b01ab4e20c23aebe20" - integrity sha512-EKf7iF7aK36eEChvlgxGnk7pdJfzfQbNvGV/+l98iiMwU23MwvmV0Ty3pJ0p5WQfm3JRHOytSIqD9LB7Bq7xdQ== - -"@rollup/rollup-linux-arm64-musl@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.39.0.tgz#531c92533ce3d167f2111bfcd2aa1a2041266987" - integrity sha512-vYanR6MtqC7Z2SNr8gzVnzUul09Wi1kZqJaek3KcIlI/wq5Xtq4ZPIZ0Mr/st/sv/NnaPwy/D4yXg5x0B3aUUA== - -"@rollup/rollup-linux-loongarch64-gnu@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.39.0.tgz#53403889755d0c37c92650aad016d5b06c1b061a" - integrity sha512-NMRUT40+h0FBa5fb+cpxtZoGAggRem16ocVKIv5gDB5uLDgBIwrIsXlGqYbLwW8YyO3WVTk1FkFDjMETYlDqiw== - -"@rollup/rollup-linux-powerpc64le-gnu@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.39.0.tgz#f669f162e29094c819c509e99dbeced58fc708f9" - integrity sha512-0pCNnmxgduJ3YRt+D+kJ6Ai/r+TaePu9ZLENl+ZDV/CdVczXl95CbIiwwswu4L+K7uOIGf6tMo2vm8uadRaICQ== - -"@rollup/rollup-linux-riscv64-gnu@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.39.0.tgz#4bab37353b11bcda5a74ca11b99dea929657fd5f" - integrity sha512-t7j5Zhr7S4bBtksT73bO6c3Qa2AV/HqiGlj9+KB3gNF5upcVkx+HLgxTm8DK4OkzsOYqbdqbLKwvGMhylJCPhQ== - -"@rollup/rollup-linux-riscv64-musl@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.39.0.tgz#4d66be1ce3cfd40a7910eb34dddc7cbd4c2dd2a5" - integrity sha512-m6cwI86IvQ7M93MQ2RF5SP8tUjD39Y7rjb1qjHgYh28uAPVU8+k/xYWvxRO3/tBN2pZkSMa5RjnPuUIbrwVxeA== - -"@rollup/rollup-linux-s390x-gnu@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.39.0.tgz#7181c329395ed53340a0c59678ad304a99627f6d" - integrity sha512-iRDJd2ebMunnk2rsSBYlsptCyuINvxUfGwOUldjv5M4tpa93K8tFMeYGpNk2+Nxl+OBJnBzy2/JCscGeO507kA== - -"@rollup/rollup-linux-x64-gnu@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.39.0.tgz#00825b3458094d5c27cb4ed66e88bfe9f1e65f90" - integrity sha512-t9jqYw27R6Lx0XKfEFe5vUeEJ5pF3SGIM6gTfONSMb7DuG6z6wfj2yjcoZxHg129veTqU7+wOhY6GX8wmf90dA== - -"@rollup/rollup-linux-x64-musl@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.39.0.tgz#81caac2a31b8754186f3acc142953a178fcd6fba" - integrity sha512-ThFdkrFDP55AIsIZDKSBWEt/JcWlCzydbZHinZ0F/r1h83qbGeenCt/G/wG2O0reuENDD2tawfAj2s8VK7Bugg== - -"@rollup/rollup-win32-arm64-msvc@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.39.0.tgz#3a3f421f5ce9bd99ed20ce1660cce7cee3e9f199" - integrity sha512-jDrLm6yUtbOg2TYB3sBF3acUnAwsIksEYjLeHL+TJv9jg+TmTwdyjnDex27jqEMakNKf3RwwPahDIt7QXCSqRQ== - -"@rollup/rollup-win32-ia32-msvc@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.39.0.tgz#a44972d5cdd484dfd9cf3705a884bf0c2b7785a7" - integrity sha512-6w9uMuza+LbLCVoNKL5FSLE7yvYkq9laSd09bwS0tMjkwXrmib/4KmoJcrKhLWHvw19mwU+33ndC69T7weNNjQ== - -"@rollup/rollup-win32-x64-msvc@4.39.0": - version "4.39.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.39.0.tgz#bfe0214e163f70c4fec1c8f7bb8ce266f4c05b7e" - integrity sha512-yAkUOkIKZlK5dl7u6dg897doBgLXmUHhIINM2c+sND3DZwnrdQkkSiDh7N75Ll4mM4dxSkYfXqU9fW3lLkMFug== +"@rollup/rollup-android-arm-eabi@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.50.2.tgz#52d66eba5198155f265f54aed94d2489c49269f6" + integrity sha512-uLN8NAiFVIRKX9ZQha8wy6UUs06UNSZ32xj6giK/rmMXAgKahwExvK6SsmgU5/brh4w/nSgj8e0k3c1HBQpa0A== + +"@rollup/rollup-android-arm64@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.50.2.tgz#137e8153fc9ce6757531ce300b8d2262299f758e" + integrity sha512-oEouqQk2/zxxj22PNcGSskya+3kV0ZKH+nQxuCCOGJ4oTXBdNTbv+f/E3c74cNLeMO1S5wVWacSws10TTSB77g== + +"@rollup/rollup-darwin-arm64@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.50.2.tgz#d4afd904386d37192cf5ef7345fdb0dd1bac0bc3" + integrity sha512-OZuTVTpj3CDSIxmPgGH8en/XtirV5nfljHZ3wrNwvgkT5DQLhIKAeuFSiwtbMto6oVexV0k1F1zqURPKf5rI1Q== + +"@rollup/rollup-darwin-x64@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.50.2.tgz#6dbe83431fc7cbc09a2b6ed2b9fb7a62dd66ebc2" + integrity sha512-Wa/Wn8RFkIkr1vy1k1PB//VYhLnlnn5eaJkfTQKivirOvzu5uVd2It01ukeQstMursuz7S1bU+8WW+1UPXpa8A== + +"@rollup/rollup-freebsd-arm64@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.50.2.tgz#d35afb9f66154b557b3387d12450920f8a954b96" + integrity sha512-QkzxvH3kYN9J1w7D1A+yIMdI1pPekD+pWx7G5rXgnIlQ1TVYVC6hLl7SOV9pi5q9uIDF9AuIGkuzcbF7+fAhow== + +"@rollup/rollup-freebsd-x64@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.50.2.tgz#849303ecdc171a420317ad9166a70af308348f34" + integrity sha512-dkYXB0c2XAS3a3jmyDkX4Jk0m7gWLFzq1C3qUnJJ38AyxIF5G/dyS4N9B30nvFseCfgtCEdbYFhk0ChoCGxPog== + +"@rollup/rollup-linux-arm-gnueabihf@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.50.2.tgz#ab36199ca613376232794b2f3ba10e2b547a447c" + integrity sha512-9VlPY/BN3AgbukfVHAB8zNFWB/lKEuvzRo1NKev0Po8sYFKx0i+AQlCYftgEjcL43F2h9Ui1ZSdVBc4En/sP2w== + +"@rollup/rollup-linux-arm-musleabihf@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.50.2.tgz#f3704bc2eaecd176f558dc47af64197fcac36e8a" + integrity sha512-+GdKWOvsifaYNlIVf07QYan1J5F141+vGm5/Y8b9uCZnG/nxoGqgCmR24mv0koIWWuqvFYnbURRqw1lv7IBINw== + +"@rollup/rollup-linux-arm64-gnu@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.50.2.tgz#dda0b06fd1daedd00b34395a2fb4aaaa2ed6c32b" + integrity sha512-df0Eou14ojtUdLQdPFnymEQteENwSJAdLf5KCDrmZNsy1c3YaCNaJvYsEUHnrg+/DLBH612/R0xd3dD03uz2dg== + +"@rollup/rollup-linux-arm64-musl@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.50.2.tgz#a018de66209051dad0c58e689e080326c3dd15b0" + integrity sha512-iPeouV0UIDtz8j1YFR4OJ/zf7evjauqv7jQ/EFs0ClIyL+by++hiaDAfFipjOgyz6y6xbDvJuiU4HwpVMpRFDQ== + +"@rollup/rollup-linux-loong64-gnu@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.50.2.tgz#6e514f09988615e0c98fa5a34a88a30fec64d969" + integrity sha512-OL6KaNvBopLlj5fTa5D5bau4W82f+1TyTZRr2BdnfsrnQnmdxh4okMxR2DcDkJuh4KeoQZVuvHvzuD/lyLn2Kw== + +"@rollup/rollup-linux-ppc64-gnu@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.50.2.tgz#9b2efebc7b4a1951e684a895fdee0fef26319e0d" + integrity sha512-I21VJl1w6z/K5OTRl6aS9DDsqezEZ/yKpbqlvfHbW0CEF5IL8ATBMuUx6/mp683rKTK8thjs/0BaNrZLXetLag== + +"@rollup/rollup-linux-riscv64-gnu@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.50.2.tgz#a7104270e93d75789d1ba857b2c68ddf61f24f68" + integrity sha512-Hq6aQJT/qFFHrYMjS20nV+9SKrXL2lvFBENZoKfoTH2kKDOJqff5OSJr4x72ZaG/uUn+XmBnGhfr4lwMRrmqCQ== + +"@rollup/rollup-linux-riscv64-musl@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.50.2.tgz#42d153f734a7b9fcacd764cc9bee6c207dca4db6" + integrity sha512-82rBSEXRv5qtKyr0xZ/YMF531oj2AIpLZkeNYxmKNN6I2sVE9PGegN99tYDLK2fYHJITL1P2Lgb4ZXnv0PjQvw== + +"@rollup/rollup-linux-s390x-gnu@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.50.2.tgz#826ad73099f6fd57c083dc5329151b25404bc67d" + integrity sha512-4Q3S3Hy7pC6uaRo9gtXUTJ+EKo9AKs3BXKc2jYypEcMQ49gDPFU2P1ariX9SEtBzE5egIX6fSUmbmGazwBVF9w== + +"@rollup/rollup-linux-x64-gnu@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.50.2.tgz#b9ec17bf0ca3f737d0895fca2115756674342142" + integrity sha512-9Jie/At6qk70dNIcopcL4p+1UirusEtznpNtcq/u/C5cC4HBX7qSGsYIcG6bdxj15EYWhHiu02YvmdPzylIZlA== + +"@rollup/rollup-linux-x64-musl@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.50.2.tgz#29fe0adb45a1d99042f373685efbac9cdd5354d9" + integrity sha512-HPNJwxPL3EmhzeAnsWQCM3DcoqOz3/IC6de9rWfGR8ZCuEHETi9km66bH/wG3YH0V3nyzyFEGUZeL5PKyy4xvw== + +"@rollup/rollup-openharmony-arm64@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.50.2.tgz#29648f11e202736b74413f823b71e339e3068d60" + integrity sha512-nMKvq6FRHSzYfKLHZ+cChowlEkR2lj/V0jYj9JnGUVPL2/mIeFGmVM2mLaFeNa5Jev7W7TovXqXIG2d39y1KYA== + +"@rollup/rollup-win32-arm64-msvc@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.50.2.tgz#91e7edec80542fd81ab1c2581a91403ac63458ae" + integrity sha512-eFUvvnTYEKeTyHEijQKz81bLrUQOXKZqECeiWH6tb8eXXbZk+CXSG2aFrig2BQ/pjiVRj36zysjgILkqarS2YA== + +"@rollup/rollup-win32-ia32-msvc@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.50.2.tgz#9b7cd9779f1147a3e8d3ddad432ae64dd222c4e9" + integrity sha512-cBaWmXqyfRhH8zmUxK3d3sAhEWLrtMjWBRwdMMHJIXSjvjLKvv49adxiEz+FJ8AP90apSDDBx2Tyd/WylV6ikA== + +"@rollup/rollup-win32-x64-msvc@4.50.2": + version "4.50.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.50.2.tgz#40ecd1357526fe328c7af704a283ee8533ca7ad6" + integrity sha512-APwKy6YUhvZaEoHyM+9xqmTpviEI+9eL7LoCH+aLcvWYHJ663qG5zx7WzWZY+a9qkg5JtzcMyJ9z0WtQBMDmgA== "@rtsao/scc@^1.1.0": version "1.1.0" @@ -859,11 +934,6 @@ resolved "https://registry.yarnpkg.com/@secretlint/types/-/types-10.2.1.tgz#018f252a3754a9ff2371b3e132226d281be8515b" integrity sha512-F5k1qpoMoUe7rrZossOBgJ3jWKv/FGDBZIwepqnefgPmNienBdInxhtZeXiGwjcxXHVhsdgp6I5Fi/M8PMgwcw== -"@sinclair/typebox@^0.27.8": - version "0.27.8" - resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" - integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA== - "@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" @@ -921,22 +991,17 @@ resolved "https://registry.yarnpkg.com/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz#db4ecfd499a9765ab24002c3b696d02e6d32a12c" integrity sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA== -"@types/chai-subset@^1.3.3": - version "1.3.3" - resolved "https://registry.yarnpkg.com/@types/chai-subset/-/chai-subset-1.3.3.tgz#97893814e92abd2c534de422cb377e0e0bdaac94" - integrity sha512-frBecisrNGz+F4T6bcc+NLeolfiojh5FxW2klu669+8BARtyQv2C/GkNW6FUodVe4BroGMP/wER/YDGc7rEllw== +"@types/chai@^5.2.2": + version "5.2.2" + resolved "https://registry.yarnpkg.com/@types/chai/-/chai-5.2.2.tgz#6f14cea18180ffc4416bc0fd12be05fdd73bdd6b" + integrity sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg== dependencies: - "@types/chai" "*" + "@types/deep-eql" "*" -"@types/chai@*": - version "4.3.4" - resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.4.tgz#e913e8175db8307d78b4e8fa690408ba6b65dee4" - integrity sha512-KnRanxnpfpjUTqTCXslZSEdLfXExwgNxYPdiO2WGUj8+HDjFi8R3k5RVKPeSCzLjCcshCAtVO2QBbVuAV4kTnw== - -"@types/chai@^4.3.5": - version "4.3.6" - resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.6.tgz#7b489e8baf393d5dd1266fb203ddd4ea941259e6" - integrity sha512-VOVRLM1mBxIRxydiViqPcKn6MIxZytrbMpd6RJLIWKxUNr3zux8no0Oc7kJx0WAPIitgZ0gkrDS+btlqQpubpw== +"@types/deep-eql@*": + version "4.0.2" + resolved "https://registry.yarnpkg.com/@types/deep-eql/-/deep-eql-4.0.2.tgz#334311971d3a07121e7eb91b684a605e7eea9cbd" + integrity sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw== "@types/eslint-scope@^3.7.7": version "3.7.7" @@ -954,11 +1019,16 @@ "@types/estree" "*" "@types/json-schema" "*" -"@types/estree@*", "@types/estree@1.0.7", "@types/estree@^1.0.6": +"@types/estree@*", "@types/estree@^1.0.6": version "1.0.7" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8" integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ== +"@types/estree@1.0.8", "@types/estree@^1.0.0": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== + "@types/eventsource@^3.0.0": version "3.0.0" resolved "https://registry.yarnpkg.com/@types/eventsource/-/eventsource-3.0.0.tgz#6b1b50c677032fd3be0b5c322e8ae819b3df62eb" @@ -1190,48 +1260,85 @@ resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== -"@vitest/expect@0.34.6": - version "0.34.6" - resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-0.34.6.tgz#608a7b7a9aa3de0919db99b4cc087340a03ea77e" - integrity sha512-QUzKpUQRc1qC7qdGo7rMK3AkETI7w18gTCUrsNnyjjJKYiuUB9+TQK3QnR1unhCnWRC0AbKv2omLGQDF/mIjOw== +"@vitest/coverage-v8@^3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz#a2d8d040288c1956a1c7d0a0e2cdcfc7a3319f13" + integrity sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ== dependencies: - "@vitest/spy" "0.34.6" - "@vitest/utils" "0.34.6" - chai "^4.3.10" - -"@vitest/runner@0.34.6": - version "0.34.6" - resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-0.34.6.tgz#6f43ca241fc96b2edf230db58bcde5b974b8dcaf" - integrity sha512-1CUQgtJSLF47NnhN+F9X2ycxUP0kLHQ/JWvNHbeBfwW8CzEGgeskzNnHDyv1ieKTltuR6sdIHV+nmR6kPxQqzQ== - dependencies: - "@vitest/utils" "0.34.6" - p-limit "^4.0.0" - pathe "^1.1.1" - -"@vitest/snapshot@0.34.6": - version "0.34.6" - resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-0.34.6.tgz#b4528cf683b60a3e8071cacbcb97d18b9d5e1d8b" - integrity sha512-B3OZqYn6k4VaN011D+ve+AA4whM4QkcwcrwaKwAbyyvS/NB1hCWjFIBQxAQQSQir9/RtyAAGuq+4RJmbn2dH4w== - dependencies: - magic-string "^0.30.1" - pathe "^1.1.1" - pretty-format "^29.5.0" - -"@vitest/spy@0.34.6": - version "0.34.6" - resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-0.34.6.tgz#b5e8642a84aad12896c915bce9b3cc8cdaf821df" - integrity sha512-xaCvneSaeBw/cz8ySmF7ZwGvL0lBjfvqc1LpQ/vcdHEvpLn3Ff1vAvjw+CoGn0802l++5L/pxb7whwcWAw+DUQ== - dependencies: - tinyspy "^2.1.1" - -"@vitest/utils@0.34.6": - version "0.34.6" - resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-0.34.6.tgz#38a0a7eedddb8e7291af09a2409cb8a189516968" - integrity sha512-IG5aDD8S6zlvloDsnzHw0Ut5xczlF+kv2BOTo+iXfPr54Yhi5qbVOgGB1hZaVq4iJ4C/MZ2J0y15IlsV/ZcI0A== - dependencies: - diff-sequences "^29.4.3" - loupe "^2.3.6" - pretty-format "^29.5.0" + "@ampproject/remapping" "^2.3.0" + "@bcoe/v8-coverage" "^1.0.2" + ast-v8-to-istanbul "^0.3.3" + debug "^4.4.1" + istanbul-lib-coverage "^3.2.2" + istanbul-lib-report "^3.0.1" + istanbul-lib-source-maps "^5.0.6" + istanbul-reports "^3.1.7" + magic-string "^0.30.17" + magicast "^0.3.5" + std-env "^3.9.0" + test-exclude "^7.0.1" + tinyrainbow "^2.0.0" + +"@vitest/expect@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/expect/-/expect-3.2.4.tgz#8362124cd811a5ee11c5768207b9df53d34f2433" + integrity sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig== + dependencies: + "@types/chai" "^5.2.2" + "@vitest/spy" "3.2.4" + "@vitest/utils" "3.2.4" + chai "^5.2.0" + tinyrainbow "^2.0.0" + +"@vitest/mocker@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/mocker/-/mocker-3.2.4.tgz#4471c4efbd62db0d4fa203e65cc6b058a85cabd3" + integrity sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ== + dependencies: + "@vitest/spy" "3.2.4" + estree-walker "^3.0.3" + magic-string "^0.30.17" + +"@vitest/pretty-format@3.2.4", "@vitest/pretty-format@^3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/pretty-format/-/pretty-format-3.2.4.tgz#3c102f79e82b204a26c7a5921bf47d534919d3b4" + integrity sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA== + dependencies: + tinyrainbow "^2.0.0" + +"@vitest/runner@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/runner/-/runner-3.2.4.tgz#5ce0274f24a971f6500f6fc166d53d8382430766" + integrity sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ== + dependencies: + "@vitest/utils" "3.2.4" + pathe "^2.0.3" + strip-literal "^3.0.0" + +"@vitest/snapshot@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/snapshot/-/snapshot-3.2.4.tgz#40a8bc0346ac0aee923c0eefc2dc005d90bc987c" + integrity sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ== + dependencies: + "@vitest/pretty-format" "3.2.4" + magic-string "^0.30.17" + pathe "^2.0.3" + +"@vitest/spy@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/spy/-/spy-3.2.4.tgz#cc18f26f40f3f028da6620046881f4e4518c2599" + integrity sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw== + dependencies: + tinyspy "^4.0.3" + +"@vitest/utils@3.2.4": + version "3.2.4" + resolved "https://registry.yarnpkg.com/@vitest/utils/-/utils-3.2.4.tgz#c0813bc42d99527fb8c5b138c7a88516bca46fea" + integrity sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA== + dependencies: + "@vitest/pretty-format" "3.2.4" + loupe "^3.1.4" + tinyrainbow "^2.0.0" "@vscode/test-cli@^0.0.11": version "0.0.11" @@ -1507,17 +1614,12 @@ acorn-jsx@^5.2.0, acorn-jsx@^5.3.2: resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn-walk@^8.2.0: - version "8.2.0" - resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1" - integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA== - acorn@^7.1.1: version "7.4.1" resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== -acorn@^8.10.0, acorn@^8.14.0, acorn@^8.8.2, acorn@^8.9.0: +acorn@^8.14.0, acorn@^8.8.2, acorn@^8.9.0: version "8.14.1" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.1.tgz#721d5dc10f7d5b5609a891773d47731796935dfb" integrity sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg== @@ -1631,11 +1733,6 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0: dependencies: color-convert "^2.0.1" -ansi-styles@^5.0.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" - integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== - ansi-styles@^6.1.0, ansi-styles@^6.2.1: version "6.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5" @@ -1765,10 +1862,10 @@ arraybuffer.prototype.slice@^1.0.3: is-array-buffer "^3.0.4" is-shared-array-buffer "^1.0.2" -assertion-error@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-1.1.0.tgz#e60b6b0e8f301bd97e5375215bda406c85118c0b" - integrity sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw== +assertion-error@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" + integrity sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA== ast-types@^0.13.4: version "0.13.4" @@ -1777,6 +1874,15 @@ ast-types@^0.13.4: dependencies: tslib "^2.0.1" +ast-v8-to-istanbul@^0.3.3: + version "0.3.5" + resolved "https://registry.yarnpkg.com/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.5.tgz#9fba217c272dd8c2615603da5de3e1a460b4b9af" + integrity sha512-9SdXjNheSiE8bALAQCQQuT6fgQaoxJh7IRYrRGZ8/9nv8WhJeC1aXAwN8TbaOssGOukUvyvnkgD9+Yuykvl1aA== + dependencies: + "@jridgewell/trace-mapping" "^0.3.30" + estree-walker "^3.0.3" + js-tokens "^9.0.1" + astral-regex@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9" @@ -2072,18 +2178,16 @@ ccount@^1.0.0: resolved "https://registry.yarnpkg.com/ccount/-/ccount-1.1.0.tgz#246687debb6014735131be8abab2d93898f8d043" integrity sha512-vlNK021QdI7PNeiUh/lKkC/mNHHfV0m/Ad5JoI0TYtlBnJAslM/JIkm/tGC88bkLIwO6OQ5uV6ztS6kVAtCDlg== -chai@^4.3.10: - version "4.3.10" - resolved "https://registry.yarnpkg.com/chai/-/chai-4.3.10.tgz#d784cec635e3b7e2ffb66446a63b4e33bd390384" - integrity sha512-0UXG04VuVbruMUYbJ6JctvH0YnC/4q3/AkT18q4NaITo91CUm0liMS9VqzT9vZhVQ/1eqPanMWjBM+Juhfb/9g== +chai@^5.2.0: + version "5.3.3" + resolved "https://registry.yarnpkg.com/chai/-/chai-5.3.3.tgz#dd3da955e270916a4bd3f625f4b919996ada7e06" + integrity sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw== dependencies: - assertion-error "^1.1.0" - check-error "^1.0.3" - deep-eql "^4.1.3" - get-func-name "^2.0.2" - loupe "^2.3.6" - pathval "^1.1.1" - type-detect "^4.0.8" + assertion-error "^2.0.1" + check-error "^2.1.1" + deep-eql "^5.0.1" + loupe "^3.1.0" + pathval "^2.0.0" chainsaw@~0.1.0: version "0.1.0" @@ -2149,12 +2253,10 @@ chardet@^0.7.0: resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== -check-error@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/check-error/-/check-error-1.0.3.tgz#a6502e4312a7ee969f646e83bb3ddd56281bd694" - integrity sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg== - dependencies: - get-func-name "^2.0.2" +check-error@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/check-error/-/check-error-2.1.1.tgz#87eb876ae71ee388fa0471fe423f494be1d96ccc" + integrity sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw== cheerio-select@^2.1.0: version "2.1.0" @@ -2498,12 +2600,10 @@ decompress-response@^6.0.0: dependencies: mimic-response "^3.1.0" -deep-eql@^4.1.3: - version "4.1.3" - resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-4.1.3.tgz#7c7775513092f7df98d8df9996dd085eb668cc6d" - integrity sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw== - dependencies: - type-detect "^4.0.0" +deep-eql@^5.0.1: + version "5.0.2" + resolved "https://registry.yarnpkg.com/deep-eql/-/deep-eql-5.0.2.tgz#4b756d8d770a9257300825d52a2c2cff99c3a341" + integrity sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q== deep-extend@^0.6.0: version "0.6.0" @@ -2613,11 +2713,6 @@ detect-newline@4.0.1, detect-newline@^4.0.1: resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-4.0.1.tgz#fcefdb5713e1fb8cb2839b8b6ee22e6716ab8f23" integrity sha512-qE3Veg1YXzGHQhlA6jzebZN2qVf6NX+A7m7qlhCGG30dJixrAQhYOsJjsnBjJkCSmuOPpCk30145fr8FV0bzog== -diff-sequences@^29.4.3: - version "29.6.3" - resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.6.3.tgz#4deaf894d11407c51efc8418012f9e70b84ea921" - integrity sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q== - diff@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/diff/-/diff-7.0.0.tgz#3fb34d387cd76d803f6eebea67b921dab0182a9a" @@ -2928,6 +3023,11 @@ es-module-lexer@^1.2.1: resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.3.1.tgz#c1b0dd5ada807a3b3155315911f364dc4e909db1" integrity sha512-JUFAyicQV9mXc3YRxPnDlrfBKpqt6hUYzz9/boprUJHs4e4KVr3XwOF70doO6gwXUor6EWZJAyWAfKki84t20Q== +es-module-lexer@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz#9159601561880a85f2734560a9099b2c31e5372a" + integrity sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA== + es-object-atoms@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.0.0.tgz#ddb55cd47ac2e240701260bc2a8e31ecb643d941" @@ -2998,34 +3098,37 @@ es6-error@^4.0.1: resolved "https://registry.yarnpkg.com/es6-error/-/es6-error-4.1.1.tgz#9e3af407459deed47e9a91f9b885a84eb05c561d" integrity sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg== -esbuild@^0.21.3: - version "0.21.5" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.21.5.tgz#9ca301b120922959b766360d8ac830da0d02997d" - integrity sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw== +esbuild@^0.25.0: + version "0.25.9" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.9.tgz#15ab8e39ae6cdc64c24ff8a2c0aef5b3fd9fa976" + integrity sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g== optionalDependencies: - "@esbuild/aix-ppc64" "0.21.5" - "@esbuild/android-arm" "0.21.5" - "@esbuild/android-arm64" "0.21.5" - "@esbuild/android-x64" "0.21.5" - "@esbuild/darwin-arm64" "0.21.5" - "@esbuild/darwin-x64" "0.21.5" - "@esbuild/freebsd-arm64" "0.21.5" - "@esbuild/freebsd-x64" "0.21.5" - "@esbuild/linux-arm" "0.21.5" - "@esbuild/linux-arm64" "0.21.5" - "@esbuild/linux-ia32" "0.21.5" - "@esbuild/linux-loong64" "0.21.5" - "@esbuild/linux-mips64el" "0.21.5" - "@esbuild/linux-ppc64" "0.21.5" - "@esbuild/linux-riscv64" "0.21.5" - "@esbuild/linux-s390x" "0.21.5" - "@esbuild/linux-x64" "0.21.5" - "@esbuild/netbsd-x64" "0.21.5" - "@esbuild/openbsd-x64" "0.21.5" - "@esbuild/sunos-x64" "0.21.5" - "@esbuild/win32-arm64" "0.21.5" - "@esbuild/win32-ia32" "0.21.5" - "@esbuild/win32-x64" "0.21.5" + "@esbuild/aix-ppc64" "0.25.9" + "@esbuild/android-arm" "0.25.9" + "@esbuild/android-arm64" "0.25.9" + "@esbuild/android-x64" "0.25.9" + "@esbuild/darwin-arm64" "0.25.9" + "@esbuild/darwin-x64" "0.25.9" + "@esbuild/freebsd-arm64" "0.25.9" + "@esbuild/freebsd-x64" "0.25.9" + "@esbuild/linux-arm" "0.25.9" + "@esbuild/linux-arm64" "0.25.9" + "@esbuild/linux-ia32" "0.25.9" + "@esbuild/linux-loong64" "0.25.9" + "@esbuild/linux-mips64el" "0.25.9" + "@esbuild/linux-ppc64" "0.25.9" + "@esbuild/linux-riscv64" "0.25.9" + "@esbuild/linux-s390x" "0.25.9" + "@esbuild/linux-x64" "0.25.9" + "@esbuild/netbsd-arm64" "0.25.9" + "@esbuild/netbsd-x64" "0.25.9" + "@esbuild/openbsd-arm64" "0.25.9" + "@esbuild/openbsd-x64" "0.25.9" + "@esbuild/openharmony-arm64" "0.25.9" + "@esbuild/sunos-x64" "0.25.9" + "@esbuild/win32-arm64" "0.25.9" + "@esbuild/win32-ia32" "0.25.9" + "@esbuild/win32-x64" "0.25.9" escalade@^3.1.1, escalade@^3.2.0: version "3.2.0" @@ -3308,6 +3411,13 @@ estraverse@^5.1.0, estraverse@^5.2.0: resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== +estree-walker@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/estree-walker/-/estree-walker-3.0.3.tgz#67c3e549ec402a487b4fc193d1953a524752340d" + integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== + dependencies: + "@types/estree" "^1.0.0" + esutils@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" @@ -3335,6 +3445,11 @@ expand-template@^2.0.3: resolved "https://registry.yarnpkg.com/expand-template/-/expand-template-2.0.3.tgz#6e14b3fcee0f3a6340ecb57d2e8918692052a47c" integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg== +expect-type@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.2.2.tgz#c030a329fb61184126c8447585bc75a7ec6fbff3" + integrity sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA== + extend@^3.0.0: version "3.0.2" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" @@ -3427,6 +3542,11 @@ fdir@^6.4.4: resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.4.6.tgz#2b268c0232697063111bbf3f64810a2a741ba281" integrity sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w== +fdir@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== + figures@^3.0.0: version "3.2.0" resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af" @@ -3670,11 +3790,6 @@ get-east-asian-width@^1.0.0: resolved "https://registry.yarnpkg.com/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz#21b4071ee58ed04ee0db653371b55b4299875389" integrity sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ== -get-func-name@^2.0.0, get-func-name@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.2.tgz#0d7cf20cd13fda808669ffa88f4ffc7a3943fc41" - integrity sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ== - get-intrinsic@^1.0.2, get-intrinsic@^1.1.1, get-intrinsic@^1.1.3: version "1.2.0" resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.0.tgz#7ad1dc0535f3a2904bba075772763e5051f6d05f" @@ -3796,12 +3911,17 @@ glob-parent@^6.0.2: dependencies: is-glob "^4.0.3" +glob-to-regex.js@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/glob-to-regex.js/-/glob-to-regex.js-1.0.1.tgz#f71cc9cb8441471a9318626160bc8a35e1306b21" + integrity sha512-CG/iEvgQqfzoVsMUbxSJcwbG2JwyZ3naEqPkeltwl0BSS8Bp83k3xlGms+0QdWFUAwV+uvo80wNswKF6FWEkKg== + glob-to-regexp@^0.4.1: version "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.2, glob@^10.4.5: +glob@^10.3.10, glob@^10.4.1, glob@^10.4.2, 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== @@ -4539,6 +4659,11 @@ istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.0: resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz#189e7909d0a39fa5a3dfad5b03f71947770191d3" integrity sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw== +istanbul-lib-coverage@^3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz#2d166c4b0644d43a39f04bf6c2edd1e585f31756" + integrity sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg== + istanbul-lib-hook@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/istanbul-lib-hook/-/istanbul-lib-hook-3.0.0.tgz#8f84c9434888cc6b1d0a9d7092a76d239ebf0cc6" @@ -4596,6 +4721,15 @@ istanbul-lib-source-maps@^4.0.0: istanbul-lib-coverage "^3.0.0" source-map "^0.6.1" +istanbul-lib-source-maps@^5.0.6: + version "5.0.6" + resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz#acaef948df7747c8eb5fbf1265cb980f6353a441" + integrity sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A== + dependencies: + "@jridgewell/trace-mapping" "^0.3.23" + debug "^4.1.1" + istanbul-lib-coverage "^3.0.0" + istanbul-reports@^3.0.2: version "3.1.5" resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.1.5.tgz#cc9a6ab25cb25659810e4785ed9d9fb742578bae" @@ -4612,6 +4746,14 @@ istanbul-reports@^3.1.6: html-escaper "^2.0.0" istanbul-lib-report "^3.0.0" +istanbul-reports@^3.1.7: + version "3.2.0" + resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.2.0.tgz#cb4535162b5784aa623cee21a7252cf2c807ac93" + integrity sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA== + dependencies: + html-escaper "^2.0.0" + istanbul-lib-report "^3.0.0" + istextorbinary@^9.5.0: version "9.5.0" resolved "https://registry.yarnpkg.com/istextorbinary/-/istextorbinary-9.5.0.tgz#e6e13febf1c1685100ae264809a4f8f46e01dfd3" @@ -4651,6 +4793,11 @@ js-tokens@^4.0.0: resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== +js-tokens@^9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-9.0.1.tgz#2ec43964658435296f6761b34e10671c2d9527f4" + integrity sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ== + js-yaml@^3.13.1, js-yaml@^3.14.1: version "3.14.1" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" @@ -4833,11 +4980,6 @@ loader-runner@^4.2.0: resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.0.tgz#c1b4a163b99f614830353b16755e7149ac2314e1" integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg== -local-pkg@^0.4.3: - version "0.4.3" - resolved "https://registry.yarnpkg.com/local-pkg/-/local-pkg-0.4.3.tgz#0ff361ab3ae7f1c19113d9bb97b98b905dbc4963" - integrity sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g== - locate-path@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" @@ -4933,12 +5075,10 @@ longest-streak@^2.0.1: resolved "https://registry.yarnpkg.com/longest-streak/-/longest-streak-2.0.4.tgz#b8599957da5b5dab64dee3fe316fa774597d90e4" integrity sha512-vM6rUVCVUJJt33bnmHiZEvr7wPT78ztX7rojL+LW51bHtLh6HTjx84LA5W4+oa6aKEJA7jJu5LR6vQRBpA5DVg== -loupe@^2.3.6: - version "2.3.6" - resolved "https://registry.yarnpkg.com/loupe/-/loupe-2.3.6.tgz#76e4af498103c532d1ecc9be102036a21f787b53" - integrity sha512-RaPMZKiMy8/JruncMU5Bt6na1eftNoo++R4Y+N2FrxkDVTrGvcyzFTsaGif4QTeKESheMGegbhw6iUAq+5A8zA== - dependencies: - get-func-name "^2.0.0" +loupe@^3.1.0, loupe@^3.1.4: + version "3.2.1" + resolved "https://registry.yarnpkg.com/loupe/-/loupe-3.2.1.tgz#0095cf56dc5b7a9a7c08ff5b1a8796ec8ad17e76" + integrity sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ== lru-cache@^10.0.1: version "10.4.3" @@ -4974,12 +5114,21 @@ lru-cache@^7.14.1: resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89" integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA== -magic-string@^0.30.1: - version "0.30.4" - resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.4.tgz#c2c683265fc18dda49b56fc7318d33ca0332c98c" - integrity sha512-Q/TKtsC5BPm0kGqgBIF9oXAs/xEf2vRKiIB4wCRQTJOQIByZ1d+NnUOotvJOvNpi5RNIgVOMC3pOuaP1ZTDlVg== +magic-string@^0.30.17: + version "0.30.19" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.19.tgz#cebe9f104e565602e5d2098c5f2e79a77cc86da9" + integrity sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw== dependencies: - "@jridgewell/sourcemap-codec" "^1.4.15" + "@jridgewell/sourcemap-codec" "^1.5.5" + +magicast@^0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/magicast/-/magicast-0.3.5.tgz#8301c3c7d66704a0771eb1bad74274f0ec036739" + integrity sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ== + dependencies: + "@babel/parser" "^7.25.4" + "@babel/types" "^7.25.4" + source-map-js "^1.2.0" make-dir@^3.0.0, make-dir@^3.0.2: version "3.1.0" @@ -5056,14 +5205,16 @@ 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.17.1: - version "4.17.1" - resolved "https://registry.yarnpkg.com/memfs/-/memfs-4.17.1.tgz#3112332cbc2b055da3f1c0ba1fd29fdcb863621a" - integrity sha512-thuTRd7F4m4dReCIy7vv4eNYnU6XI/tHMLSMMHLiortw/Y0QxqKtinG523U2aerzwYWGi606oBP4oMPy4+edag== +memfs@^4.46.0: + version "4.46.0" + resolved "https://registry.yarnpkg.com/memfs/-/memfs-4.46.0.tgz#7b110f7a47cdf28b524072b9dd028c9752e4a29c" + integrity sha512-//IxqL9OO/WMpm2kE2aq+y7vO7/xS9xgVIbFM8RUIfW7TY7lowtnuS1j9MwLGm0OwcHUa4p8Bp+40W7f1BiWGQ== dependencies: - "@jsonjoy.com/json-pack" "^1.0.3" - "@jsonjoy.com/util" "^1.3.0" - tree-dump "^1.0.1" + "@jsonjoy.com/json-pack" "^1.11.0" + "@jsonjoy.com/util" "^1.9.0" + glob-to-regex.js "^1.0.1" + thingies "^2.5.0" + tree-dump "^1.0.3" tslib "^2.0.0" merge-stream@^2.0.0: @@ -5166,16 +5317,6 @@ mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3: dependencies: minimist "^1.2.6" -mlly@^1.2.0, mlly@^1.4.0: - version "1.4.2" - resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.4.2.tgz#7cf406aa319ff6563d25da6b36610a93f2a8007e" - integrity sha512-i/Ykufi2t1EZ6NaPLdfnZk2AX8cs0d+mTzVKuPfqPKPatxLApaBoxJQ9x1/uckXtrS/U5oisPMDkNs0yQTaBRg== - dependencies: - acorn "^8.10.0" - pathe "^1.1.1" - pkg-types "^1.0.3" - ufo "^1.3.0" - mocha@^11.1.0: version "11.7.2" resolved "https://registry.yarnpkg.com/mocha/-/mocha-11.7.2.tgz#3c0079fe5cc2f8ea86d99124debcc42bb1ab22b5" @@ -5212,7 +5353,7 @@ mute-stream@0.0.8, mute-stream@~0.0.4: resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d" integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA== -nanoid@^3.3.8: +nanoid@^3.3.11: version "3.3.11" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== @@ -5495,13 +5636,6 @@ p-limit@^3.0.2: dependencies: yocto-queue "^0.1.0" -p-limit@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-4.0.0.tgz#914af6544ed32bfa54670b061cafcbd04984b644" - integrity sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ== - dependencies: - yocto-queue "^1.0.0" - p-locate@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" @@ -5683,20 +5817,15 @@ path-type@^6.0.0: resolved "https://registry.yarnpkg.com/path-type/-/path-type-6.0.0.tgz#2f1bb6791a91ce99194caede5d6c5920ed81eb51" integrity sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ== -pathe@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.0.tgz#e2e13f6c62b31a3289af4ba19886c230f295ec03" - integrity sha512-ODbEPR0KKHqECXW1GoxdDb+AZvULmXjVPy4rt+pGo2+TnjJTIPJQSVS6N63n8T2Ip+syHhbn52OewKicV0373w== - -pathe@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.1.tgz#1dd31d382b974ba69809adc9a7a347e65d84829a" - integrity sha512-d+RQGp0MAYTIaDBIMmOfMwz3E+LOZnxx1HZd5R18mmCZY0QBlK0LDZfPc8FW8Ed2DlvsuE6PRjroDY+wg4+j/Q== +pathe@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" + integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== -pathval@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.1.tgz#8534e77a77ce7ac5a2512ea21e0fdb8fcf6c3d8d" - integrity sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ== +pathval@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/pathval/-/pathval-2.0.1.tgz#8855c5a2899af072d6ac05d11e46045ad0dc605d" + integrity sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ== pend@~1.2.0: version "1.2.0" @@ -5723,6 +5852,11 @@ picomatch@^4.0.2: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.2.tgz#77c742931e8f3b8820946c76cd0c1f13730d1dab" integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg== +picomatch@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" + integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== + pkg-dir@^4.1.0, pkg-dir@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" @@ -5730,15 +5864,6 @@ pkg-dir@^4.1.0, pkg-dir@^4.2.0: dependencies: find-up "^4.0.0" -pkg-types@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.0.3.tgz#988b42ab19254c01614d13f4f65a2cfc7880f868" - integrity sha512-nN7pYi0AQqJnoLPC9eHFQ8AcyaixBUOwvqc5TDnIKCMEE6I0y8P7OKA7fPexsXGCGxQDl/cmrLAp26LhcwxZ4A== - dependencies: - jsonc-parser "^3.2.0" - mlly "^1.2.0" - pathe "^1.1.0" - plur@^3.0.0: version "3.1.1" resolved "https://registry.yarnpkg.com/plur/-/plur-3.1.1.tgz#60267967866a8d811504fe58f2faaba237546a5b" @@ -5761,12 +5886,12 @@ possible-typed-array-names@^1.0.0: resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz#89bb63c6fada2c3e90adc4a647beeeb39cc7bf8f" integrity sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q== -postcss@^8.4.43: - version "8.5.3" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.3.tgz#1463b6f1c7fb16fe258736cba29a2de35237eafb" - integrity sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A== +postcss@^8.5.6: + version "8.5.6" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" + integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== dependencies: - nanoid "^3.3.8" + nanoid "^3.3.11" picocolors "^1.1.1" source-map-js "^1.2.1" @@ -5815,15 +5940,6 @@ pretty-bytes@^7.0.0: resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-7.0.0.tgz#8652cbf0aa81daeeaf72802e0fd059e5e1046cdb" integrity sha512-U5otLYPR3L0SVjHGrkEUx5mf7MxV2ceXeE7VwWPk+hyzC5drNohsOGNPDZqxCqyX1lkbEN4kl1LiI8QFd7r0ZA== -pretty-format@^29.5.0: - version "29.7.0" - resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812" - integrity sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ== - dependencies: - "@jest/schemas" "^29.6.3" - ansi-styles "^5.0.0" - react-is "^18.0.0" - process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" @@ -5917,11 +6033,6 @@ rc@^1.2.7: minimist "^1.2.0" strip-json-comments "~2.0.1" -react-is@^18.0.0: - version "18.2.0" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" - integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== - read-pkg@^9.0.1: version "9.0.1" resolved "https://registry.yarnpkg.com/read-pkg/-/read-pkg-9.0.1.tgz#b1b81fb15104f5dbb121b6bbdee9bbc9739f569b" @@ -6668,33 +6779,34 @@ rimraf@^3.0.0, rimraf@^3.0.2: dependencies: glob "^7.1.3" -rollup@^4.20.0: - version "4.39.0" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.39.0.tgz#9dc1013b70c0e2cb70ef28350142e9b81b3f640c" - integrity sha512-thI8kNc02yNvnmJp8dr3fNWJ9tCONDhp6TV35X6HkKGGs9E6q7YWCHbe5vKiTa7TAiNcFEmXKj3X/pG2b3ci0g== +rollup@^4.43.0: + version "4.50.2" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.50.2.tgz#938d898394939f3386d1e367ee6410a796b8f268" + integrity sha512-BgLRGy7tNS9H66aIMASq1qSYbAAJV6Z6WR4QYTvj5FgF15rZ/ympT1uixHXwzbZUBDbkvqUI1KR0fH1FhMaQ9w== dependencies: - "@types/estree" "1.0.7" + "@types/estree" "1.0.8" optionalDependencies: - "@rollup/rollup-android-arm-eabi" "4.39.0" - "@rollup/rollup-android-arm64" "4.39.0" - "@rollup/rollup-darwin-arm64" "4.39.0" - "@rollup/rollup-darwin-x64" "4.39.0" - "@rollup/rollup-freebsd-arm64" "4.39.0" - "@rollup/rollup-freebsd-x64" "4.39.0" - "@rollup/rollup-linux-arm-gnueabihf" "4.39.0" - "@rollup/rollup-linux-arm-musleabihf" "4.39.0" - "@rollup/rollup-linux-arm64-gnu" "4.39.0" - "@rollup/rollup-linux-arm64-musl" "4.39.0" - "@rollup/rollup-linux-loongarch64-gnu" "4.39.0" - "@rollup/rollup-linux-powerpc64le-gnu" "4.39.0" - "@rollup/rollup-linux-riscv64-gnu" "4.39.0" - "@rollup/rollup-linux-riscv64-musl" "4.39.0" - "@rollup/rollup-linux-s390x-gnu" "4.39.0" - "@rollup/rollup-linux-x64-gnu" "4.39.0" - "@rollup/rollup-linux-x64-musl" "4.39.0" - "@rollup/rollup-win32-arm64-msvc" "4.39.0" - "@rollup/rollup-win32-ia32-msvc" "4.39.0" - "@rollup/rollup-win32-x64-msvc" "4.39.0" + "@rollup/rollup-android-arm-eabi" "4.50.2" + "@rollup/rollup-android-arm64" "4.50.2" + "@rollup/rollup-darwin-arm64" "4.50.2" + "@rollup/rollup-darwin-x64" "4.50.2" + "@rollup/rollup-freebsd-arm64" "4.50.2" + "@rollup/rollup-freebsd-x64" "4.50.2" + "@rollup/rollup-linux-arm-gnueabihf" "4.50.2" + "@rollup/rollup-linux-arm-musleabihf" "4.50.2" + "@rollup/rollup-linux-arm64-gnu" "4.50.2" + "@rollup/rollup-linux-arm64-musl" "4.50.2" + "@rollup/rollup-linux-loong64-gnu" "4.50.2" + "@rollup/rollup-linux-ppc64-gnu" "4.50.2" + "@rollup/rollup-linux-riscv64-gnu" "4.50.2" + "@rollup/rollup-linux-riscv64-musl" "4.50.2" + "@rollup/rollup-linux-s390x-gnu" "4.50.2" + "@rollup/rollup-linux-x64-gnu" "4.50.2" + "@rollup/rollup-linux-x64-musl" "4.50.2" + "@rollup/rollup-openharmony-arm64" "4.50.2" + "@rollup/rollup-win32-arm64-msvc" "4.50.2" + "@rollup/rollup-win32-ia32-msvc" "4.50.2" + "@rollup/rollup-win32-x64-msvc" "4.50.2" fsevents "~2.3.2" run-applescript@^7.0.0: @@ -7008,7 +7120,7 @@ sort-package-json@^3.0.0: sort-object-keys "^1.1.3" tinyglobby "^0.2.12" -source-map-js@^1.2.1: +source-map-js@^1.2.0, source-map-js@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== @@ -7089,10 +7201,10 @@ state-toggle@^1.0.0: resolved "https://registry.yarnpkg.com/state-toggle/-/state-toggle-1.0.3.tgz#e123b16a88e143139b09c6852221bc9815917dfe" integrity sha512-d/5Z4/2iiCnHw6Xzghyhb+GcmF89bxwgXG60wjIiZaxnymbyOmI8Hk4VqHXiVVp6u2ysaskFfXg3ekCj4WNftQ== -std-env@^3.3.3: - version "3.4.3" - resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.4.3.tgz#326f11db518db751c83fd58574f449b7c3060910" - integrity sha512-f9aPhy8fYBuMN+sNfakZV18U39PbalgjXG3lLB9WkaYTxijru61wb57V9wxxNthXM5Sd88ETBWi29qLAsHO52Q== +std-env@^3.9.0: + version "3.9.0" + resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.9.0.tgz#1a6f7243b339dca4c9fd55e1c7504c77ef23e8f1" + integrity sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw== stdin-discarder@^0.2.2: version "0.2.2" @@ -7263,12 +7375,12 @@ strip-json-comments@~2.0.1: resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ== -strip-literal@^1.0.1: - version "1.3.0" - resolved "https://registry.yarnpkg.com/strip-literal/-/strip-literal-1.3.0.tgz#db3942c2ec1699e6836ad230090b84bb458e3a07" - integrity sha512-PugKzOsyXpArk0yWmUwqOZecSO0GH0bPoctLcqNDH9J04pVW3lflYE0ujElBGTloevcxF5MofAOZ7C5l2b+wLg== +strip-literal@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/strip-literal/-/strip-literal-3.0.0.tgz#ce9c452a91a0af2876ed1ae4e583539a353df3fc" + integrity sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA== dependencies: - acorn "^8.10.0" + js-tokens "^9.0.1" structured-source@^4.0.0: version "4.0.0" @@ -7408,6 +7520,15 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" +test-exclude@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-7.0.1.tgz#20b3ba4906ac20994e275bbcafd68d510264c2a2" + integrity sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg== + dependencies: + "@istanbuljs/schema" "^0.1.2" + glob "^10.4.1" + minimatch "^9.0.4" + text-table@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" @@ -7420,20 +7541,25 @@ textextensions@^6.11.0: dependencies: editions "^6.21.0" -thingies@^1.20.0: - version "1.21.0" - resolved "https://registry.yarnpkg.com/thingies/-/thingies-1.21.0.tgz#e80fbe58fd6fdaaab8fad9b67bd0a5c943c445c1" - integrity sha512-hsqsJsFMsV+aD4s3CWKk85ep/3I9XzYV/IXaSouJMYIoDlgyi11cBhsqYe9/geRfB0YIikBQg6raRaM+nIMP9g== +thingies@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/thingies/-/thingies-2.5.0.tgz#5f7b882c933b85989f8466b528a6247a6881e04f" + integrity sha512-s+2Bwztg6PhWUD7XMfeYm5qliDdSiZm7M7n8KjTkIsm3l/2lgVRc2/Gx/v+ZX8lT4FMA+i8aQvhcWylldc+ZNw== through@^2.3.6: version "2.3.8" resolved "https://registry.yarnpkg.com/through/-/through-2.3.8.tgz#0dd4c9ffaabc357960b1b724115d7e0e86a2e1f5" integrity sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg== -tinybench@^2.5.0: - version "2.5.1" - resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.5.1.tgz#3408f6552125e53a5a48adee31261686fd71587e" - integrity sha512-65NKvSuAVDP/n4CqH+a9w2kTlLReS9vhsAP06MWx+/89nMinJyB2icyl58RIcqCmIggpojIGeuJGhjU1aGMBSg== +tinybench@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/tinybench/-/tinybench-2.9.0.tgz#103c9f8ba6d7237a47ab6dd1dcff77251863426b" + integrity sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg== + +tinyexec@^0.3.2: + version "0.3.2" + resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-0.3.2.tgz#941794e657a85e496577995c6eef66f53f42b3d2" + integrity sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA== tinyglobby@^0.2.12: version "0.2.14" @@ -7443,15 +7569,28 @@ tinyglobby@^0.2.12: fdir "^6.4.4" picomatch "^4.0.2" -tinypool@^0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-0.7.0.tgz#88053cc99b4a594382af23190c609d93fddf8021" - integrity sha512-zSYNUlYSMhJ6Zdou4cJwo/p7w5nmAH17GRfU/ui3ctvjXFErXXkruT4MWW6poDeXgCaIBlGLrfU6TbTXxyGMww== +tinyglobby@^0.2.14, tinyglobby@^0.2.15: + version "0.2.15" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2" + integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.3" -tinyspy@^2.1.1: - version "2.2.0" - resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-2.2.0.tgz#9dc04b072746520b432f77ea2c2d17933de5d6ce" - integrity sha512-d2eda04AN/cPOR89F7Xv5bK/jrQEhmcLFe6HFldoeO9AJtps+fqEnh486vnT/8y4bw38pSyxDcTCAq+Ks2aJTg== +tinypool@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-1.1.1.tgz#059f2d042bd37567fbc017d3d426bdd2a2612591" + integrity sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg== + +tinyrainbow@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-2.0.0.tgz#9509b2162436315e80e3eee0fcce4474d2444294" + integrity sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw== + +tinyspy@^4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-4.0.3.tgz#d1d0f0602f4c15f1aae083a34d6d0df3363b1b52" + integrity sha512-t2T/WLB2WRgZ9EpE4jgPJ9w+i66UZfDc8wHh0xrwiRNN+UwH98GIJkTeZqX9rg0i0ptwzqW+uYeIF0T4F8LR7A== tmp@^0.0.33: version "0.0.33" @@ -7477,10 +7616,10 @@ to-regex-range@^5.0.1: resolved "https://registry.yarnpkg.com/traverse/-/traverse-0.3.9.tgz#717b8f220cc0bb7b44e40514c22b2e8bbc70d8b9" integrity sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ== -tree-dump@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/tree-dump/-/tree-dump-1.0.2.tgz#c460d5921caeb197bde71d0e9a7b479848c5b8ac" - integrity sha512-dpev9ABuLWdEubk+cIaI9cHwRNNDjkBBLXTwI4UCUFdQ5xXKqNXoK4FEciw/vxf+NQ7Cb7sGUyeUtORvHIdRXQ== +tree-dump@^1.0.3: + version "1.1.0" + resolved "https://registry.yarnpkg.com/tree-dump/-/tree-dump-1.1.0.tgz#ab29129169dc46004414f5a9d4a3c6e89f13e8a4" + integrity sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA== trim-trailing-lines@^1.0.0: version "1.1.4" @@ -7559,11 +7698,6 @@ type-check@~0.3.2: dependencies: prelude-ls "~1.1.2" -type-detect@^4.0.0, type-detect@^4.0.8: - version "4.0.8" - resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" - integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== - type-fest@^0.20.2: version "0.20.2" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" @@ -7698,11 +7832,6 @@ uc.micro@^2.0.0, uc.micro@^2.1.0: resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-2.1.0.tgz#f8d3f7d0ec4c3dea35a7e3c8efa4cb8b45c9e7ee" integrity sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A== -ufo@^1.3.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.3.1.tgz#e085842f4627c41d4c1b60ebea1f75cdab4ce86b" - integrity sha512-uY/99gMLIOlJPwATcMVYfqDSxUR9//AUcgZMzwfSTJPDKzA1S8mX4VLqa+fiAtveraQUBCz4FFcwVZBGbwBXIw== - unbox-primitive@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e" @@ -7924,58 +8053,59 @@ vfile@^4.0.0: unist-util-stringify-position "^2.0.0" vfile-message "^2.0.0" -vite-node@0.34.6: - version "0.34.6" - resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-0.34.6.tgz#34d19795de1498562bf21541a58edcd106328a17" - integrity sha512-nlBMJ9x6n7/Amaz6F3zJ97EBwR2FkzhBRxF5e+jE6LA3yi6Wtc2lyTij1OnDMIr34v5g/tVQtsVAzhT0jc5ygA== +vite-node@3.2.4: + version "3.2.4" + resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-3.2.4.tgz#f3676d94c4af1e76898c162c92728bca65f7bb07" + integrity sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg== dependencies: cac "^6.7.14" - debug "^4.3.4" - mlly "^1.4.0" - pathe "^1.1.1" - picocolors "^1.0.0" - vite "^3.0.0 || ^4.0.0 || ^5.0.0-0" - -"vite@^3.0.0 || ^4.0.0 || ^5.0.0-0", "vite@^3.1.0 || ^4.0.0 || ^5.0.0-0": - version "5.4.19" - resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.19.tgz#20efd060410044b3ed555049418a5e7d1998f959" - integrity sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA== - dependencies: - esbuild "^0.21.3" - postcss "^8.4.43" - rollup "^4.20.0" + debug "^4.4.1" + es-module-lexer "^1.7.0" + pathe "^2.0.3" + 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== + dependencies: + esbuild "^0.25.0" + fdir "^6.5.0" + picomatch "^4.0.3" + postcss "^8.5.6" + rollup "^4.43.0" + tinyglobby "^0.2.15" optionalDependencies: fsevents "~2.3.3" -vitest@^0.34.6: - version "0.34.6" - resolved "https://registry.yarnpkg.com/vitest/-/vitest-0.34.6.tgz#44880feeeef493c04b7f795ed268f24a543250d7" - integrity sha512-+5CALsOvbNKnS+ZHMXtuUC7nL8/7F1F2DnHGjSsszX8zCjWSSviphCb/NuS9Nzf4Q03KyyDRBAXhF/8lffME4Q== - dependencies: - "@types/chai" "^4.3.5" - "@types/chai-subset" "^1.3.3" - "@types/node" "*" - "@vitest/expect" "0.34.6" - "@vitest/runner" "0.34.6" - "@vitest/snapshot" "0.34.6" - "@vitest/spy" "0.34.6" - "@vitest/utils" "0.34.6" - acorn "^8.9.0" - acorn-walk "^8.2.0" - cac "^6.7.14" - chai "^4.3.10" - debug "^4.3.4" - local-pkg "^0.4.3" - magic-string "^0.30.1" - pathe "^1.1.1" - picocolors "^1.0.0" - std-env "^3.3.3" - strip-literal "^1.0.1" - tinybench "^2.5.0" - tinypool "^0.7.0" - vite "^3.1.0 || ^4.0.0 || ^5.0.0-0" - vite-node "0.34.6" - why-is-node-running "^2.2.2" +vitest@^3.2.4: + version "3.2.4" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-3.2.4.tgz#0637b903ad79d1539a25bc34c0ed54b5c67702ea" + integrity sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A== + dependencies: + "@types/chai" "^5.2.2" + "@vitest/expect" "3.2.4" + "@vitest/mocker" "3.2.4" + "@vitest/pretty-format" "^3.2.4" + "@vitest/runner" "3.2.4" + "@vitest/snapshot" "3.2.4" + "@vitest/spy" "3.2.4" + "@vitest/utils" "3.2.4" + chai "^5.2.0" + debug "^4.4.1" + expect-type "^1.2.1" + magic-string "^0.30.17" + pathe "^2.0.3" + picomatch "^4.0.2" + std-env "^3.9.0" + tinybench "^2.9.0" + tinyexec "^0.3.2" + tinyglobby "^0.2.14" + tinypool "^1.1.1" + tinyrainbow "^2.0.0" + vite "^5.0.0 || ^6.0.0 || ^7.0.0-0" + vite-node "3.2.4" + why-is-node-running "^2.3.0" vscode-test@^1.5.0: version "1.6.1" @@ -8119,10 +8249,10 @@ which@^2.0.1: dependencies: isexe "^2.0.0" -why-is-node-running@^2.2.2: - version "2.2.2" - resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.2.2.tgz#4185b2b4699117819e7154594271e7e344c9973e" - integrity sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA== +why-is-node-running@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz#a3f69a97107f494b3cdc3bdddd883a7d65cebf04" + integrity sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w== dependencies: siginfo "^2.0.0" stackback "0.0.2" @@ -8357,11 +8487,6 @@ 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== -yocto-queue@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" - integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== - zod@^3.25.65: version "3.25.65" resolved "https://registry.yarnpkg.com/zod/-/zod-3.25.65.tgz#190cb604e1b45e0f789a315f65463953d4d4beee" From 6eca6ec143b2c1300c22d5bd1410ba33c613371f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 25 Sep 2025 12:18:17 -0800 Subject: [PATCH 05/69] chore(deps-dev): bump @types/node-forge from 1.3.11 to 1.3.14 (#576) --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 9fb96fcb..d349c95d 100644 --- a/package.json +++ b/package.json @@ -329,7 +329,7 @@ "@types/eventsource": "^3.0.0", "@types/glob": "^7.1.3", "@types/node": "^22.14.1", - "@types/node-forge": "^1.3.11", + "@types/node-forge": "^1.3.14", "@types/ua-parser-js": "0.7.36", "@types/vscode": "^1.73.0", "@types/ws": "^8.18.1", diff --git a/yarn.lock b/yarn.lock index 62565608..38603017 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1074,10 +1074,10 @@ resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-10.0.10.tgz#91f62905e8d23cbd66225312f239454a23bebfa0" integrity sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q== -"@types/node-forge@^1.3.11": - version "1.3.11" - resolved "https://registry.yarnpkg.com/@types/node-forge/-/node-forge-1.3.11.tgz#0972ea538ddb0f4d9c2fa0ec5db5724773a604da" - integrity sha512-FQx220y22OKNTqaByeBGqHWYz4cl94tpcxeFdvBo3wjG6XPBuZ0BNgNZRV5J5TFmmcsJ4IzsLkmGRiQbnYsBEQ== +"@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" "*" From 84ee1d7e9cfea0932d9fbb5e3068ed96c00081f3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Sep 2025 11:16:49 +0300 Subject: [PATCH 06/69] chore(deps): bump tar-fs from 2.1.3 to 2.1.4 (#596) --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 38603017..8fe29eaf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7462,9 +7462,9 @@ tapable@^2.1.1, tapable@^2.2.0: integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== tar-fs@^2.0.0: - version "2.1.3" - resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.3.tgz#fb3b8843a26b6f13a08e606f7922875eb1fbbf92" - integrity sha512-090nwYJDmlhwFwEW3QQl+vaNnxsO2yVsd45eTKRBzSzu+hlb1w2K9inVq5b0ngXuLVqQ4ApvsUHHnu/zQNkWAg== + version "2.1.4" + resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-2.1.4.tgz#800824dbf4ef06ded9afea4acafe71c67c76b930" + integrity sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ== dependencies: chownr "^1.1.1" mkdirp-classic "^0.5.2" From 8324a07a978c3fd2a3628fe51f3b69b803aa7b10 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Sep 2025 14:39:22 +0300 Subject: [PATCH 07/69] chore(deps): bump actions/checkout from 4 to 5 (#567) --- .github/workflows/ci.yaml | 4 ++-- .github/workflows/release.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a94e7cbe..b731210d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: actions/setup-node@v4 with: @@ -32,7 +32,7 @@ jobs: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: actions/setup-node@v4 with: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 756a2eaa..a73ce17d 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -14,7 +14,7 @@ jobs: package: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: actions/setup-node@v4 with: From a4bd95d829ef966b8f2c4f448c259366727400a5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Sep 2025 15:32:41 +0300 Subject: [PATCH 08/69] chore(deps): bump actions/setup-node from 4 to 5 (#579) --- .github/workflows/ci.yaml | 4 ++-- .github/workflows/release.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b731210d..59a03e0a 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -16,7 +16,7 @@ jobs: steps: - uses: actions/checkout@v5 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version: "22" @@ -34,7 +34,7 @@ jobs: steps: - uses: actions/checkout@v5 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version: "22" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a73ce17d..a6bf5fa4 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -16,7 +16,7 @@ jobs: steps: - uses: actions/checkout@v5 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v5 with: node-version: "22" From 43b33599c47b51baee192d61c0ce828ae4ae8b0c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Sep 2025 15:33:31 +0300 Subject: [PATCH 09/69] chore(deps-dev): bump eslint-plugin-prettier from 5.4.1 to 5.5.4 (#572) --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index d349c95d..fd8bca58 100644 --- a/package.json +++ b/package.json @@ -347,7 +347,7 @@ "eslint-plugin-import": "^2.31.0", "eslint-plugin-md": "^1.0.19", "eslint-plugin-package-json": "^0.40.1", - "eslint-plugin-prettier": "^5.4.1", + "eslint-plugin-prettier": "^5.5.4", "glob": "^10.4.2", "jsonc-eslint-parser": "^2.4.0", "memfs": "^4.46.0", diff --git a/yarn.lock b/yarn.lock index 8fe29eaf..2aae97fb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3236,10 +3236,10 @@ eslint-plugin-package-json@^0.40.1: sort-package-json "^3.0.0" validate-npm-package-name "^6.0.0" -eslint-plugin-prettier@^5.4.1: - version "5.4.1" - resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.4.1.tgz#99b55d7dd70047886b2222fdd853665f180b36af" - integrity sha512-9dF+KuU/Ilkq27A8idRP7N2DH8iUR6qXcjF3FR2wETY21PZdBrIjwCau8oboyGj9b7etWmTGEeM8e7oOed6ZWg== +eslint-plugin-prettier@^5.5.4: + version "5.5.4" + resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.4.tgz#9d61c4ea11de5af704d4edf108c82ccfa7f2e61c" + integrity sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg== dependencies: prettier-linter-helpers "^1.0.0" synckit "^0.11.7" From 9cfb742974d3242ec4be9024cb51310b6bd0834e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Sep 2025 15:37:21 +0300 Subject: [PATCH 10/69] chore(deps-dev): bump webpack-cli from 5.1.4 to 6.0.1 (#571) --- package.json | 2 +- yarn.lock | 97 +++++++++++++++++++++++----------------------------- 2 files changed, 43 insertions(+), 56 deletions(-) diff --git a/package.json b/package.json index fd8bca58..41878cc9 100644 --- a/package.json +++ b/package.json @@ -359,7 +359,7 @@ "vitest": "^3.2.4", "vscode-test": "^1.5.0", "webpack": "^5.99.6", - "webpack-cli": "^5.1.4" + "webpack-cli": "^6.0.1" }, "extensionPack": [ "ms-vscode-remote.remote-ssh" diff --git a/yarn.lock b/yarn.lock index 2aae97fb..a3a699ed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -336,10 +336,10 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz#bbe12dca5b4ef983a0d0af4b07b9bc90ea0ababa" integrity sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA== -"@discoveryjs/json-ext@^0.5.0": - version "0.5.7" - resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" - integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw== +"@discoveryjs/json-ext@^0.6.1": + version "0.6.3" + resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz#f13c7c205915eb91ae54c557f5e92bddd8be0e83" + integrity sha512-4B4OijXeVNOPZlYA2oEwWOTkzyltLao+xbotHQeqN++Rv27Y6s818+n2Qkp8q+Fxhn0t/5lA5X1Mxktud8eayQ== "@esbuild/aix-ppc64@0.25.9": version "0.25.9" @@ -1584,20 +1584,20 @@ "@webassemblyjs/ast" "1.14.1" "@xtuc/long" "4.2.2" -"@webpack-cli/configtest@^2.1.1": - version "2.1.1" - resolved "https://registry.yarnpkg.com/@webpack-cli/configtest/-/configtest-2.1.1.tgz#3b2f852e91dac6e3b85fb2a314fb8bef46d94646" - integrity sha512-wy0mglZpDSiSS0XHrVR+BAdId2+yxPSoJW8fsna3ZpYSlufjvxnP4YbKTCBZnNIcGN4r6ZPXV55X4mYExOfLmw== +"@webpack-cli/configtest@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@webpack-cli/configtest/-/configtest-3.0.1.tgz#76ac285b9658fa642ce238c276264589aa2b6b57" + integrity sha512-u8d0pJ5YFgneF/GuvEiDA61Tf1VDomHHYMjv/wc9XzYj7nopltpG96nXN5dJRstxZhcNpV1g+nT6CydO7pHbjA== -"@webpack-cli/info@^2.0.2": - version "2.0.2" - resolved "https://registry.yarnpkg.com/@webpack-cli/info/-/info-2.0.2.tgz#cc3fbf22efeb88ff62310cf885c5b09f44ae0fdd" - integrity sha512-zLHQdI/Qs1UyT5UBdWNqsARasIA+AaF8t+4u2aS2nEpBQh2mWIVb8qAklq0eUENnC5mOItrIB4LiS9xMtph18A== +"@webpack-cli/info@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@webpack-cli/info/-/info-3.0.1.tgz#3cff37fabb7d4ecaab6a8a4757d3826cf5888c63" + integrity sha512-coEmDzc2u/ffMvuW9aCjoRzNSPDl/XLuhPdlFRpT9tZHmJ/039az33CE7uH+8s0uL1j5ZNtfdv0HkfaKRBGJsQ== -"@webpack-cli/serve@^2.0.5": - version "2.0.5" - resolved "https://registry.yarnpkg.com/@webpack-cli/serve/-/serve-2.0.5.tgz#325db42395cd49fe6c14057f9a900e427df8810e" - integrity sha512-lqaoKnRYBdo1UgDX8uF24AfGMifWK19TxPmM5FHc2vAGxrJ/qtyUyFBWoY1tISZdelsQ5fBcOusifo5o5wSJxQ== +"@webpack-cli/serve@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@webpack-cli/serve/-/serve-3.0.1.tgz#bd8b1f824d57e30faa19eb78e4c0951056f72f00" + integrity sha512-sbgw03xQaCLiT6gcY/6u3qBDn01CWw/nbaXl3gTdTFuJJ75Gffv3E3DBpgvY2fkkrdS1fpjaXNOmJlnbtKauKg== "@xtuc/ieee754@^1.2.0": version "1.2.0" @@ -2435,11 +2435,6 @@ combined-stream@^1.0.8: dependencies: delayed-stream "~1.0.0" -commander@^10.0.1: - version "10.0.1" - resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06" - integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug== - commander@^12.1.0: version "12.1.0" resolved "https://registry.yarnpkg.com/commander/-/commander-12.1.0.tgz#01423b36f501259fdaac4d0e4d60c96c991585d3" @@ -2486,16 +2481,7 @@ cross-spawn@^6.0.5: shebang-command "^1.2.0" which "^1.2.9" -cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3: - version "7.0.3" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" - integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== - dependencies: - path-key "^3.1.0" - shebang-command "^2.0.0" - which "^2.0.1" - -cross-spawn@^7.0.6: +cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3, cross-spawn@^7.0.6: version "7.0.6" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== @@ -2849,10 +2835,10 @@ 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== -envinfo@^7.7.3: - version "7.8.1" - resolved "https://registry.yarnpkg.com/envinfo/-/envinfo-7.8.1.tgz#06377e3e5f4d379fea7ac592d5ad8927e0c4d475" - integrity sha512-/o+BXHmB7ocbHEAs6F2EnG0ogybVVUdkRunTT2glZU9XAaGmhqskrvKwqXuDfNjEO0LZKWdejEEpnq8aM0tOaw== +envinfo@^7.14.0: + version "7.14.0" + resolved "https://registry.yarnpkg.com/envinfo/-/envinfo-7.14.0.tgz#26dac5db54418f2a4c1159153a0b2ae980838aae" + integrity sha512-CO40UI41xDQzhLB1hWyqUKgFhs250pNcGbyGKe1l/e4FSaI/+YE4IMG76GDt0In67WLPACIITC+sOi08x4wIvg== environment@^1.0.0: version "1.1.0" @@ -8125,32 +8111,33 @@ watchpack@^2.4.1: glob-to-regexp "^0.4.1" graceful-fs "^4.1.2" -webpack-cli@^5.1.4: - version "5.1.4" - resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-5.1.4.tgz#c8e046ba7eaae4911d7e71e2b25b776fcc35759b" - integrity sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg== +webpack-cli@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-6.0.1.tgz#a1ce25da5ba077151afd73adfa12e208e5089207" + integrity sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw== dependencies: - "@discoveryjs/json-ext" "^0.5.0" - "@webpack-cli/configtest" "^2.1.1" - "@webpack-cli/info" "^2.0.2" - "@webpack-cli/serve" "^2.0.5" + "@discoveryjs/json-ext" "^0.6.1" + "@webpack-cli/configtest" "^3.0.1" + "@webpack-cli/info" "^3.0.1" + "@webpack-cli/serve" "^3.0.1" colorette "^2.0.14" - commander "^10.0.1" + commander "^12.1.0" cross-spawn "^7.0.3" - envinfo "^7.7.3" + envinfo "^7.14.0" fastest-levenshtein "^1.0.12" import-local "^3.0.2" interpret "^3.1.1" rechoir "^0.8.0" - webpack-merge "^5.7.3" + webpack-merge "^6.0.1" -webpack-merge@^5.7.3: - version "5.8.0" - resolved "https://registry.yarnpkg.com/webpack-merge/-/webpack-merge-5.8.0.tgz#2b39dbf22af87776ad744c390223731d30a68f61" - integrity sha512-/SaI7xY0831XwP6kzuwhKWVKDP9t1QY1h65lAFLbZqMPIuYcD9QAW4u9STIbU9kaJbPBB/geU/gLr1wDjOhQ+Q== +webpack-merge@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/webpack-merge/-/webpack-merge-6.0.1.tgz#50c776868e080574725abc5869bd6e4ef0a16c6a" + integrity sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg== dependencies: clone-deep "^4.0.1" - wildcard "^2.0.0" + flat "^5.0.2" + wildcard "^2.0.1" webpack-sources@^3.2.3: version "3.2.3" @@ -8257,10 +8244,10 @@ why-is-node-running@^2.3.0: siginfo "^2.0.0" stackback "0.0.2" -wildcard@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.0.tgz#a77d20e5200c6faaac979e4b3aadc7b3dd7f8fec" - integrity sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw== +wildcard@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.1.tgz#5ab10d02487198954836b6349f74fff961e10f67" + integrity sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ== word-wrap@1.2.5, word-wrap@~1.2.3: version "1.2.5" From 8fccd760084ad7b41facd8e92a24d846767eefb2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Sep 2025 10:55:33 +0300 Subject: [PATCH 11/69] chore(deps-dev): bump webpack from 5.99.6 to 5.101.3 (#598) --- package.json | 2 +- yarn.lock | 77 ++++++++++++++++++++++------------------------------ 2 files changed, 33 insertions(+), 46 deletions(-) diff --git a/package.json b/package.json index 41878cc9..83d6e15d 100644 --- a/package.json +++ b/package.json @@ -358,7 +358,7 @@ "utf-8-validate": "^6.0.5", "vitest": "^3.2.4", "vscode-test": "^1.5.0", - "webpack": "^5.99.6", + "webpack": "^5.101.3", "webpack-cli": "^6.0.1" }, "extensionPack": [ diff --git a/yarn.lock b/yarn.lock index a3a699ed..cae6bd6b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1019,12 +1019,7 @@ "@types/estree" "*" "@types/json-schema" "*" -"@types/estree@*", "@types/estree@^1.0.6": - version "1.0.7" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8" - integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ== - -"@types/estree@1.0.8", "@types/estree@^1.0.0": +"@types/estree@*", "@types/estree@1.0.8", "@types/estree@^1.0.0", "@types/estree@^1.0.8": version "1.0.8" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== @@ -1049,16 +1044,11 @@ resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz#7739c232a1fee9b4d3ce8985f314c0c6d33549d7" integrity sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w== -"@types/json-schema@*", "@types/json-schema@^7.0.9": +"@types/json-schema@*", "@types/json-schema@^7.0.12", "@types/json-schema@^7.0.15", "@types/json-schema@^7.0.9": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== -"@types/json-schema@^7.0.12": - version "7.0.13" - resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.13.tgz#02c24f4363176d2d18fc8b70b9f3c54aba178a85" - integrity sha512-RbSSoHliUbnXj3ny0CNFOoxrIDV6SUGyStHsvDqosw6CkdPV8TtWGlfecuK4ToyMEAql6pzNxgCFKanovUzlgQ== - "@types/json5@^0.0.29": version "0.0.29" resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" @@ -1609,6 +1599,11 @@ resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== +acorn-import-phases@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz#16eb850ba99a056cb7cbfe872ffb8972e18c8bd7" + integrity sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ== + acorn-jsx@^5.2.0, acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" @@ -1619,12 +1614,7 @@ acorn@^7.1.1: resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== -acorn@^8.14.0, acorn@^8.8.2, acorn@^8.9.0: - version "8.14.1" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.1.tgz#721d5dc10f7d5b5609a891773d47731796935dfb" - integrity sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg== - -acorn@^8.5.0: +acorn@^8.15.0, acorn@^8.5.0, acorn@^8.8.2, acorn@^8.9.0: version "8.15.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816" integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== @@ -2822,10 +2812,10 @@ 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.1: - version "5.18.1" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz#728ab082f8b7b6836de51f1637aab5d3b9568faf" - integrity sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg== +enhanced-resolve@^5.0.0, enhanced-resolve@^5.15.0, enhanced-resolve@^5.17.3: + version "5.18.3" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz#9b5f4c5c076b8787c78fe540392ce76a88855b44" + integrity sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww== dependencies: graceful-fs "^4.2.4" tapable "^2.2.0" @@ -3004,12 +2994,7 @@ es-errors@^1.0.0, es-errors@^1.2.1, es-errors@^1.3.0: resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== -es-module-lexer@^1.2.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.3.1.tgz#c1b0dd5ada807a3b3155315911f364dc4e909db1" - integrity sha512-JUFAyicQV9mXc3YRxPnDlrfBKpqt6hUYzz9/boprUJHs4e4KVr3XwOF70doO6gwXUor6EWZJAyWAfKki84t20Q== - -es-module-lexer@^1.7.0: +es-module-lexer@^1.2.1, es-module-lexer@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz#9159601561880a85f2734560a9099b2c31e5372a" integrity sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA== @@ -6877,10 +6862,10 @@ sax@>=0.6.0: resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== -schema-utils@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-4.3.0.tgz#3b669f04f71ff2dfb5aba7ce2d5a9d79b35622c0" - integrity sha512-Gf9qqc58SpCA/xdziiHz35F4GNIWYWZrEshUc/G/r5BnLph6xpKuLeoJoQuj5WfBIx/eQLf+hmVPYHaxJu7V2g== +schema-utils@^4.3.0, schema-utils@^4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-4.3.2.tgz#0c10878bf4a73fd2b1dfd14b9462b26788c806ae" + integrity sha512-Gn/JaSk/Mt9gYubxTtSn/QCV4em9mpAPiR1rqy/Ocu19u/G9J5WWdNoUT4SiV6mFC3y6cxyFcFwdzPM3FgxGAQ== dependencies: "@types/json-schema" "^7.0.9" ajv "^8.9.0" @@ -8139,25 +8124,27 @@ webpack-merge@^6.0.1: flat "^5.0.2" wildcard "^2.0.1" -webpack-sources@^3.2.3: - version "3.2.3" - resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde" - integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== +webpack-sources@^3.3.3: + version "3.3.3" + resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.3.3.tgz#d4bf7f9909675d7a070ff14d0ef2a4f3c982c723" + integrity sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg== -webpack@^5.99.6: - version "5.99.6" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.99.6.tgz#0d6ba7ce1d3609c977f193d2634d54e5cf36379d" - integrity sha512-TJOLrJ6oeccsGWPl7ujCYuc0pIq2cNsuD6GZDma8i5o5Npvcco/z+NKvZSFsP0/x6SShVb0+X2JK/JHUjKY9dQ== +webpack@^5.101.3: + version "5.101.3" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.101.3.tgz#3633b2375bb29ea4b06ffb1902734d977bc44346" + integrity sha512-7b0dTKR3Ed//AD/6kkx/o7duS8H3f1a4w3BYpIriX4BzIhjkn4teo05cptsxvLesHFKK5KObnadmCHBwGc+51A== dependencies: "@types/eslint-scope" "^3.7.7" - "@types/estree" "^1.0.6" + "@types/estree" "^1.0.8" + "@types/json-schema" "^7.0.15" "@webassemblyjs/ast" "^1.14.1" "@webassemblyjs/wasm-edit" "^1.14.1" "@webassemblyjs/wasm-parser" "^1.14.1" - acorn "^8.14.0" + acorn "^8.15.0" + acorn-import-phases "^1.0.3" browserslist "^4.24.0" chrome-trace-event "^1.0.2" - enhanced-resolve "^5.17.1" + enhanced-resolve "^5.17.3" es-module-lexer "^1.2.1" eslint-scope "5.1.1" events "^3.2.0" @@ -8167,11 +8154,11 @@ webpack@^5.99.6: loader-runner "^4.2.0" mime-types "^2.1.27" neo-async "^2.6.2" - schema-utils "^4.3.0" + schema-utils "^4.3.2" tapable "^2.1.1" terser-webpack-plugin "^5.3.11" watchpack "^2.4.1" - webpack-sources "^3.2.3" + webpack-sources "^3.3.3" which-boxed-primitive@^1.0.2: version "1.0.2" From ee5d7e00c251acf21ced18e0116676d54a8f7dbc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Sep 2025 11:01:16 +0300 Subject: [PATCH 12/69] chore(deps-dev): bump eslint-plugin-package-json from 0.40.1 to 0.56.3 (#602) --- package.json | 2 +- yarn.lock | 70 +++++++++++++++++++++++++++------------------------- 2 files changed, 37 insertions(+), 35 deletions(-) diff --git a/package.json b/package.json index 83d6e15d..314dee58 100644 --- a/package.json +++ b/package.json @@ -346,7 +346,7 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "^2.31.0", "eslint-plugin-md": "^1.0.19", - "eslint-plugin-package-json": "^0.40.1", + "eslint-plugin-package-json": "^0.56.3", "eslint-plugin-prettier": "^5.5.4", "glob": "^10.4.2", "jsonc-eslint-parser": "^2.4.0", diff --git a/yarn.lock b/yarn.lock index cae6bd6b..f5edf496 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7,10 +7,10 @@ resolved "https://registry.yarnpkg.com/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz#bd9154aec9983f77b3a034ecaa015c2e4201f6cf" integrity sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA== -"@altano/repository-tools@^1.0.0": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@altano/repository-tools/-/repository-tools-1.0.1.tgz#969bb94cc80f8b4d62c7d6956466edc3f3c3817a" - integrity sha512-/FFHQOMp5TZWplkDWbbLIjmANDr9H/FtqUm+hfJMK76OBut0Ht0cNfd0ZXd/6LXf4pWUTzvpgVjcin7EEHSznA== +"@altano/repository-tools@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@altano/repository-tools/-/repository-tools-2.0.1.tgz#22b43b5ee9dde190a055c281059d57ac665128df" + integrity sha512-YE/52CkFtb+YtHPgbWPai7oo5N9AKnMuP5LM+i2AG7G1H2jdYBCO1iDnkDE3dZ3C1MIgckaF+d5PNRulgt0bdw== "@ampproject/remapping@^2.2.0", "@ampproject/remapping@^2.3.0": version "2.3.0" @@ -2674,7 +2674,7 @@ 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, detect-indent@^7.0.1: +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== @@ -2684,7 +2684,7 @@ detect-libc@^2.0.0: resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.1.tgz#e1897aa88fa6ad197862937fbc0441ef352ee0cd" integrity sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w== -detect-newline@4.0.1, detect-newline@^4.0.1: +detect-newline@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-4.0.1.tgz#fcefdb5713e1fb8cb2839b8b6ee22e6716ab8f23" integrity sha512-qE3Veg1YXzGHQhlA6jzebZN2qVf6NX+A7m7qlhCGG30dJixrAQhYOsJjsnBjJkCSmuOPpCk30145fr8FV0bzog== @@ -3132,10 +3132,10 @@ eslint-config-prettier@^9.1.0: resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz#31af3d94578645966c082fcb71a5846d3c94867f" integrity sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw== -eslint-fix-utils@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/eslint-fix-utils/-/eslint-fix-utils-0.3.0.tgz#5643ae3c47c49ab247afc1565b2fe7b64ca4fbab" - integrity sha512-0wAVRhCkSCSu4goaIb05gKjFxTd/FC3Jee0ptvWYHS2gBh1mDhsrFyg6JyK47wvM10az/Ns4BlATbTW9HIoQ+Q== +eslint-fix-utils@~0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/eslint-fix-utils/-/eslint-fix-utils-0.4.0.tgz#e1085b4f94f41e7448a80b774d8ed5cbbe7f7e31" + integrity sha512-nCEciwqByGxsKiWqZjqK7xfL+7dUX9Pi0UL3J0tOwfxVN9e6Y59UxEt1ZYsc3XH0ce6T1WQM/QU2DbKK/6IG7g== eslint-import-resolver-node@^0.3.9: version "0.3.9" @@ -3191,21 +3191,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.40.1: - version "0.40.1" - resolved "https://registry.yarnpkg.com/eslint-plugin-package-json/-/eslint-plugin-package-json-0.40.1.tgz#73fb3138840d4de232bb87d228024f62db4d7cda" - integrity sha512-e5BcFpqLORfOZQS+Ygo307b1pCzvhzx+LQgzOd+qi9Uyj3J1UPDMPp5NBjli+l6SD9p9D794aiEwohwbHIPNDA== +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== dependencies: - "@altano/repository-tools" "^1.0.0" + "@altano/repository-tools" "^2.0.1" change-case "^5.4.4" - detect-indent "7.0.1" - detect-newline "4.0.1" - eslint-fix-utils "^0.3.0" - package-json-validator "~0.13.1" + detect-indent "^7.0.1" + 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.0.0" - validate-npm-package-name "^6.0.0" + sort-package-json "^3.3.0" + validate-npm-package-name "^6.0.2" eslint-plugin-prettier@^5.5.4: version "5.5.4" @@ -5675,11 +5675,13 @@ 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.13.1: - version "0.13.3" - resolved "https://registry.yarnpkg.com/package-json-validator/-/package-json-validator-0.13.3.tgz#f661fb1a54643de999133f2c41e90d2f947e88c2" - integrity sha512-/BeP6SFebqXJS27aLrTMjpmF0OZtsptoxYVU9pUGPdUNTc1spFfNcnOOhvT4Cghm1OQ75CyMM11H5jtQbe7bAQ== +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== dependencies: + semver "^7.7.2" + validate-npm-package-license "^3.0.4" yargs "~18.0.0" pako@~1.0.2: @@ -6885,7 +6887,7 @@ secretlint@^10.1.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.2, semver@^7.7.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.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== @@ -7078,10 +7080,10 @@ 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.0.0: - version "3.2.1" - resolved "https://registry.yarnpkg.com/sort-package-json/-/sort-package-json-3.2.1.tgz#889f3bdf43ceeff5fa4278a7c53ae5b1520d287e" - integrity sha512-rTfRdb20vuoAn7LDlEtCqOkYfl2X+Qze6cLbNOzcDpbmKEhJI30tTN44d5shbKJnXsvz24QQhlCm81Bag7EOKg== +sort-package-json@^3.3.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== dependencies: detect-indent "^7.0.1" detect-newline "^4.0.1" @@ -7991,10 +7993,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.0: - version "6.0.1" - resolved "https://registry.yarnpkg.com/validate-npm-package-name/-/validate-npm-package-name-6.0.1.tgz#7b928e5fe23996045a6de5b5a22eedb3611264dd" - integrity sha512-OaI//3H0J7ZkR1OqlhGA8cA+Cbk/2xFOQpJOt5+s27/ta9eZwpeervh4Mxh4w0im/kdgktowaqVNR7QOrUd7Yg== +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== version-range@^4.13.0: version "4.14.0" From c859b8eb180004cc13a544b94629d059e64580eb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Sep 2025 11:07:28 +0300 Subject: [PATCH 13/69] chore(deps-dev): bump typescript from 5.8.3 to 5.9.2 (#600) --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 314dee58..95c6cd60 100644 --- a/package.json +++ b/package.json @@ -354,7 +354,7 @@ "nyc": "^17.1.0", "prettier": "^3.5.3", "ts-loader": "^9.5.1", - "typescript": "^5.8.3", + "typescript": "^5.9.2", "utf-8-validate": "^6.0.5", "vitest": "^3.2.4", "vscode-test": "^1.5.0", diff --git a/yarn.lock b/yarn.lock index f5edf496..c2d79036 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7790,10 +7790,10 @@ typedarray-to-buffer@^3.1.5: dependencies: is-typedarray "^1.0.0" -typescript@^5.8.3: - version "5.8.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e" - integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ== +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== ua-parser-js@1.0.40: version "1.0.40" From 2c7974ce81aabb2b32dc9a670e5eacc71da8de5a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Sep 2025 11:10:46 +0300 Subject: [PATCH 14/69] chore(deps-dev): bump memfs from 4.46.0 to 4.47.0 (#601) --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 95c6cd60..9a666aba 100644 --- a/package.json +++ b/package.json @@ -350,7 +350,7 @@ "eslint-plugin-prettier": "^5.5.4", "glob": "^10.4.2", "jsonc-eslint-parser": "^2.4.0", - "memfs": "^4.46.0", + "memfs": "^4.47.0", "nyc": "^17.1.0", "prettier": "^3.5.3", "ts-loader": "^9.5.1", diff --git a/yarn.lock b/yarn.lock index c2d79036..581e7d3a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5176,10 +5176,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.46.0: - version "4.46.0" - resolved "https://registry.yarnpkg.com/memfs/-/memfs-4.46.0.tgz#7b110f7a47cdf28b524072b9dd028c9752e4a29c" - integrity sha512-//IxqL9OO/WMpm2kE2aq+y7vO7/xS9xgVIbFM8RUIfW7TY7lowtnuS1j9MwLGm0OwcHUa4p8Bp+40W7f1BiWGQ== +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== dependencies: "@jsonjoy.com/json-pack" "^1.11.0" "@jsonjoy.com/util" "^1.9.0" From 923298195c12db1e29f8a7c4f3f667bbe0dedd33 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 1 Oct 2025 10:58:27 +0300 Subject: [PATCH 15/69] Add support to CODER_BINARY_DESTINATION environment variable (#597) Fixes #256 --- CHANGELOG.md | 4 ++++ package.json | 2 +- src/commands.ts | 9 ++++++--- src/core/pathResolver.test.ts | 27 +++++++++++++++++++++++++-- src/core/pathResolver.ts | 11 +++++++---- src/extension.ts | 4 +++- src/headers.test.ts | 4 ++-- src/headers.ts | 9 ++++----- 8 files changed, 52 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35649a76..e9da9987 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Added + +- Support for `CODER_BINARY_DESTINATION` environment variable to set CLI download location (overridden by extension setting `coder.binaryDestination` if configured). + ## [v1.11.0](https://github.com/coder/vscode-coder/releases/tag/v1.11.0) 2025-09-24 ### Changed diff --git a/package.json b/package.json index 9a666aba..7c7b60ca 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "default": "" }, "coder.binaryDestination": { - "markdownDescription": "The full path of the directory into which the Coder CLI will be downloaded. Defaults to the extension's global storage directory.", + "markdownDescription": "The full path of the directory into which the Coder CLI will be downloaded. Defaults to the value of `CODER_BINARY_DESTINATION` if not set, otherwise the extension's global storage directory.", "type": "string", "default": "" }, diff --git a/src/commands.ts b/src/commands.ts index 914adbfc..b9dcf10d 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -102,10 +102,13 @@ export class Commands { * CODER_URL or enter a new one. Undefined means the user aborted. */ private async askURL(selection?: string): Promise { - const defaultURL = - vscode.workspace.getConfiguration().get("coder.defaultUrl") ?? ""; + const defaultURL = vscode.workspace + .getConfiguration() + .get("coder.defaultUrl") + ?.trim(); const quickPick = vscode.window.createQuickPick(); - quickPick.value = selection || defaultURL || process.env.CODER_URL || ""; + quickPick.value = + selection || defaultURL || process.env.CODER_URL?.trim() || ""; quickPick.placeholder = "https://example.coder.com"; quickPick.title = "Enter the URL of your Coder deployment."; diff --git a/src/core/pathResolver.test.ts b/src/core/pathResolver.test.ts index 8216a547..3c331a26 100644 --- a/src/core/pathResolver.test.ts +++ b/src/core/pathResolver.test.ts @@ -1,5 +1,5 @@ import * as path from "path"; -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, vi } from "vitest"; import { MockConfigurationProvider } from "../__mocks__/testHelpers"; import { PathResolver } from "./pathResolver"; @@ -11,6 +11,7 @@ describe("PathResolver", () => { let mockConfig: MockConfigurationProvider; beforeEach(() => { + vi.unstubAllEnvs(); pathResolver = new PathResolver(basePath, codeLogPath); mockConfig = new MockConfigurationProvider(); }); @@ -32,6 +33,7 @@ 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( path.join(basePath, "deployment", "bin"), @@ -41,7 +43,28 @@ describe("PathResolver", () => { it("should normalize custom paths", () => { mockConfig.set("coder.binaryDestination", "/custom/../binary/./path"); expect(pathResolver.getBinaryCachePath("deployment")).toBe( - path.normalize("/custom/../binary/./path"), + "/binary/path", + ); + }); + + it("should use CODER_BINARY_DESTINATION environment variable with proper precedence", () => { + // 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( + 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( + "/env/binary/path", + ); + + // Test setting takes precedence over environment variable + mockConfig.set("coder.binaryDestination", " /setting/path "); + expect(pathResolver.getBinaryCachePath("deployment")).toBe( + "/setting/path", ); }); }); diff --git a/src/core/pathResolver.ts b/src/core/pathResolver.ts index 6c1ee7ef..514e64fb 100644 --- a/src/core/pathResolver.ts +++ b/src/core/pathResolver.ts @@ -28,11 +28,14 @@ export class PathResolver { * The caller must ensure this directory exists before use. */ public getBinaryCachePath(label: string): string { - const configPath = vscode.workspace + const settingPath = vscode.workspace .getConfiguration() - .get("coder.binaryDestination"); - return configPath && configPath.trim().length > 0 - ? path.normalize(configPath) + .get("coder.binaryDestination") + ?.trim(); + const binaryPath = + settingPath || process.env.CODER_BINARY_DESTINATION?.trim(); + return binaryPath + ? path.normalize(binaryPath) : path.join(this.getGlobalConfigDir(label), "bin"); } diff --git a/src/extension.ts b/src/extension.ts index bd8a09c6..678ea3b7 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -423,7 +423,9 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // Handle autologin, if not already logged in. const cfg = vscode.workspace.getConfiguration(); if (cfg.get("coder.autologin") === true) { - const defaultUrl = cfg.get("coder.defaultUrl") || process.env.CODER_URL; + const defaultUrl = + cfg.get("coder.defaultUrl")?.trim() || + process.env.CODER_URL?.trim(); if (defaultUrl) { vscode.commands.executeCommand( "coder.login", diff --git a/src/headers.test.ts b/src/headers.test.ts index 84c39d36..6f2933a3 100644 --- a/src/headers.test.ts +++ b/src/headers.test.ts @@ -123,9 +123,9 @@ describe("getHeaderCommand", () => { expect(getHeaderCommand(config)).toBeUndefined(); }); - it("should return undefined if coder.headerCommand is not a string", () => { + it("should return undefined if coder.headerCommand is a blank string", () => { const config = { - get: () => 1234, + get: () => " ", } as unknown as WorkspaceConfiguration; expect(getHeaderCommand(config)).toBeUndefined(); diff --git a/src/headers.ts b/src/headers.ts index d259c9e1..1aad4258 100644 --- a/src/headers.ts +++ b/src/headers.ts @@ -19,11 +19,10 @@ export function getHeaderCommand( config: WorkspaceConfiguration, ): string | undefined { const cmd = - config.get("coder.headerCommand") || process.env.CODER_HEADER_COMMAND; - if (!cmd || typeof cmd !== "string") { - return undefined; - } - return cmd; + config.get("coder.headerCommand")?.trim() || + process.env.CODER_HEADER_COMMAND?.trim(); + + return cmd ? cmd : undefined; } export function getHeaderArgs(config: WorkspaceConfiguration): string[] { From 67e85e6106000d6729f20cac5d35b3db1e58df9c Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 1 Oct 2025 11:29:19 +0300 Subject: [PATCH 16/69] Refactor test structure, improve linting, and enhance code organization (#594) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This refactor reorganizes the test layout, strengthens linting configuration, and introduces path aliases in tests to improve code maintainability. Test Structure Changes: - Move tests from /src to top-level /test directory - /test/unit: unit tests with mocks - /test/integration: end-to-end tests with real VS Code API - /test/mocks: shared mocks - /test/fixtures: relocated from /fixtures - /test/utils: various test utils - Prevent test file imports in src - Unify fixture path resolution Development Tooling: - Configure ESLint and TypeScript "fix on save" in VS Code - Update ESLint-related dependencies - Address multiple ESLint rule violations (imports, typing) Code Organization: - Add path aliases for tests (@ → /src) - Introduce ServiceContainer to initialize and track services in src/core - Improve .vscodeignore to ship only necessary files --- .eslintrc.json | 56 +- .vscode-test.mjs | 2 +- .vscode/settings.json | 14 + .vscodeignore | 50 +- package.json | 11 +- src/agentMetadataHelper.ts | 7 +- src/api/api-helper.ts | 6 +- src/api/coderApi.ts | 25 +- src/{ => api}/proxy.ts | 0 src/api/utils.ts | 4 +- src/api/workspace.ts | 10 +- src/commands.ts | 46 +- src/core/cliManager.ts | 37 +- src/{ => core}/cliUtils.ts | 0 src/core/container.ts | 69 + src/core/secretsManager.ts | 2 +- src/error.ts | 3 +- src/extension.ts | 47 +- src/featureSet.ts | 2 +- src/globalFlags.ts | 3 +- src/headers.ts | 6 +- src/inbox.ts | 12 +- src/logging/formatters.ts | 3 +- src/logging/httpLogger.ts | 12 +- src/logging/wsLogger.ts | 5 +- src/pgp.ts | 3 +- src/{ => remote}/remote.ts | 74 +- src/{ => remote}/sshConfig.ts | 5 +- src/{ => remote}/sshSupport.ts | 2 +- src/websocket/oneWayWebSocket.ts | 33 +- src/{ => workspace}/workspaceMonitor.ts | 14 +- src/{ => workspace}/workspacesProvider.ts | 21 +- {fixtures => test/fixtures}/bin.bash | 0 {fixtures => test/fixtures}/bin.old.bash | 0 {fixtures => test/fixtures}/pgp/cli | 0 .../fixtures}/pgp/cli.invalid.asc | 0 {fixtures => test/fixtures}/pgp/cli.valid.asc | 0 {fixtures => test/fixtures}/pgp/private.pgp | 0 {fixtures => test/fixtures}/pgp/public.pgp | 0 .../fixtures}/tls/chain-intermediate.crt | 0 .../fixtures}/tls/chain-intermediate.key | 0 .../fixtures}/tls/chain-leaf.crt | 0 .../fixtures}/tls/chain-leaf.key | 0 .../fixtures}/tls/chain-root.crt | 0 .../fixtures}/tls/chain-root.key | 0 {fixtures => test/fixtures}/tls/chain.crt | 0 {fixtures => test/fixtures}/tls/chain.key | 0 {fixtures => test/fixtures}/tls/generate.bash | 0 .../fixtures}/tls/no-signing.crt | 0 .../fixtures}/tls/no-signing.key | 0 .../fixtures}/tls/self-signed.crt | 0 .../fixtures}/tls/self-signed.key | 0 .../integration}/extension.test.ts | 0 {src/__mocks__ => test/mocks}/testHelpers.ts | 26 +- .../mocks}/vscode.runtime.ts | 0 test/tsconfig.json | 10 + {src => test/unit}/core/cliManager.test.ts | 33 +- {src => test/unit/core}/cliUtils.test.ts | 51 +- .../unit}/core/mementoManager.test.ts | 8 +- {src => test/unit}/core/pathResolver.test.ts | 8 +- .../unit}/core/secretsManager.test.ts | 8 +- {src => test/unit}/error.test.ts | 36 +- {src => test/unit}/featureSet.test.ts | 3 +- {src => test/unit}/globalFlags.test.ts | 5 +- {src => test/unit}/headers.test.ts | 9 +- {src => test/unit}/pgp.test.ts | 19 +- {src => test/unit/remote}/sshConfig.test.ts | 4 +- {src => test/unit/remote}/sshSupport.test.ts | 3 +- {src => test/unit}/util.test.ts | 3 +- test/utils/fixtures.ts | 5 + tsconfig.json | 3 +- vitest.config.ts | 17 +- yarn.lock | 1344 +++++++++++++---- 73 files changed, 1584 insertions(+), 595 deletions(-) create mode 100644 .vscode/settings.json rename src/{ => api}/proxy.ts (100%) rename src/{ => core}/cliUtils.ts (100%) create mode 100644 src/core/container.ts rename src/{ => remote}/remote.ts (95%) rename src/{ => remote}/sshConfig.ts (99%) rename src/{ => remote}/sshSupport.ts (99%) rename src/{ => workspace}/workspaceMonitor.ts (94%) rename src/{ => workspace}/workspacesProvider.ts (97%) rename {fixtures => test/fixtures}/bin.bash (100%) rename {fixtures => test/fixtures}/bin.old.bash (100%) rename {fixtures => test/fixtures}/pgp/cli (100%) rename {fixtures => test/fixtures}/pgp/cli.invalid.asc (100%) rename {fixtures => test/fixtures}/pgp/cli.valid.asc (100%) rename {fixtures => test/fixtures}/pgp/private.pgp (100%) rename {fixtures => test/fixtures}/pgp/public.pgp (100%) rename {fixtures => test/fixtures}/tls/chain-intermediate.crt (100%) rename {fixtures => test/fixtures}/tls/chain-intermediate.key (100%) rename {fixtures => test/fixtures}/tls/chain-leaf.crt (100%) rename {fixtures => test/fixtures}/tls/chain-leaf.key (100%) rename {fixtures => test/fixtures}/tls/chain-root.crt (100%) rename {fixtures => test/fixtures}/tls/chain-root.key (100%) rename {fixtures => test/fixtures}/tls/chain.crt (100%) rename {fixtures => test/fixtures}/tls/chain.key (100%) rename {fixtures => test/fixtures}/tls/generate.bash (100%) rename {fixtures => test/fixtures}/tls/no-signing.crt (100%) rename {fixtures => test/fixtures}/tls/no-signing.key (100%) rename {fixtures => test/fixtures}/tls/self-signed.crt (100%) rename {fixtures => test/fixtures}/tls/self-signed.key (100%) rename {src/test => test/integration}/extension.test.ts (100%) rename {src/__mocks__ => test/mocks}/testHelpers.ts (90%) rename {src/__mocks__ => test/mocks}/vscode.runtime.ts (100%) create mode 100644 test/tsconfig.json rename {src => test/unit}/core/cliManager.test.ts (96%) rename {src => test/unit/core}/cliUtils.test.ts (70%) rename {src => test/unit}/core/mementoManager.test.ts (93%) rename {src => test/unit}/core/pathResolver.test.ts (92%) rename {src => test/unit}/core/secretsManager.test.ts (87%) rename {src => test/unit}/error.test.ts (90%) rename {src => test/unit}/featureSet.test.ts (94%) rename {src => test/unit}/globalFlags.test.ts (95%) rename {src => test/unit}/headers.test.ts (95%) rename {src => test/unit}/pgp.test.ts (85%) rename {src => test/unit/remote}/sshConfig.test.ts (99%) rename {src => test/unit/remote}/sshSupport.test.ts (99%) rename {src => test/unit}/util.test.ts (99%) create mode 100644 test/utils/fixtures.ts diff --git a/.eslintrc.json b/.eslintrc.json index a9665178..91d67601 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -4,9 +4,9 @@ "parserOptions": { "ecmaVersion": 6, "sourceType": "module", - "project": "./tsconfig.json" + "project": true }, - "plugins": ["@typescript-eslint", "prettier"], + "plugins": ["@typescript-eslint", "prettier", "import"], "extends": [ "eslint:recommended", "plugin:@typescript-eslint/recommended", @@ -15,12 +15,38 @@ "plugin:md/prettier", "prettier" ], + "ignorePatterns": ["out", "dist", "**/*.d.ts"], + "settings": { + "import/resolver": { + "typescript": { "project": "./tsconfig.json" } + }, + "import/internal-regex": "^@/" + }, "overrides": [ + { + "files": ["test/**/*.{ts,tsx}", "**/*.{test,spec}.ts?(x)"], + "settings": { + "import/resolver": { + "typescript": { + // In tests, resolve using the test tsconfig + "project": "test/tsconfig.json" + } + } + } + }, { "files": ["*.ts"], "rules": { "require-await": "off", - "@typescript-eslint/require-await": "error" + "@typescript-eslint/require-await": "error", + "@typescript-eslint/consistent-type-imports": [ + "error", + { + "disallowTypeAnnotations": false, // Used in tests + "prefer": "type-imports", + "fixStyle": "inline-type-imports" + } + ] } }, { @@ -42,12 +68,25 @@ "import/order": [ "error", { - "alphabetize": { - "order": "asc" - }, - "groups": [["builtin", "external", "internal"], "parent", "sibling"] + "groups": [ + ["builtin", "external"], + "internal", + "parent", + ["sibling", "index"], + "type" + ], + "pathGroups": [ + { "pattern": "@/**", "group": "internal", "position": "before" } + ], + "pathGroupsExcludedImportTypes": ["builtin", "external"], + "newlines-between": "always", + "alphabetize": { "order": "asc", "caseInsensitive": true }, + "sortTypesGroup": true } ], + // Prevent duplicates and prefer merging into a single import + "no-duplicate-imports": "off", + "import/no-duplicates": ["error", { "prefer-inline": true }], "import/no-unresolved": [ "error", { @@ -68,6 +107,5 @@ } } ] - }, - "ignorePatterns": ["out", "dist", "**/*.d.ts"] + } } diff --git a/.vscode-test.mjs b/.vscode-test.mjs index 3bf0c207..60fc8650 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -1,7 +1,7 @@ import { defineConfig } from "@vscode/test-cli"; export default defineConfig({ - files: "out/test/**/*.test.js", + files: "out/test/integration/**/*.test.js", extensionDevelopmentPath: ".", extensionTestsPath: "./out/test", launchArgs: ["--enable-proposed-api", "coder.coder-remote"], diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..daaef897 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,14 @@ +{ + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.ts": "explicit", + "source.fixAll.eslint": "explicit" + }, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[jsonc]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + } +} diff --git a/.vscodeignore b/.vscodeignore index fe6dbade..d9cdd5e1 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -1,16 +1,42 @@ -.vscode/** -.vscode-test/** -.nyc_output/** -coverage/** +# Test and coverage output out/** +coverage/** +.nyc_output/** + +# Development files src/** -usage.md -.gitignore -node_modules/** -**/tsconfig.json -**/.eslintrc.json -**/.editorconfig -**/*.map +test/** **/*.ts +**/*.map + +# Configuration files +.vscode/** +.vscode-test/** +.vscode-test.mjs +tsconfig.json +.eslintrc.json +.editorconfig +.prettierignore +.eslintignore +**/.gitignore +**/.git-blame-ignore-revs + +# Package manager files +yarn.lock + +# Nix/flake files +flake.nix +flake.lock +*.nix + +# Dependencies +node_modules/** + +# Development tools and CI +.github/** +.claude/** + +# Documentation and media +usage.md +CLAUDE.md *.gif -fixtures/** diff --git a/package.json b/package.json index 7c7b60ca..23a49a20 100644 --- a/package.json +++ b/package.json @@ -330,11 +330,12 @@ "@types/glob": "^7.1.3", "@types/node": "^22.14.1", "@types/node-forge": "^1.3.14", + "@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": "^7.0.0", - "@typescript-eslint/parser": "^6.21.0", + "@typescript-eslint/eslint-plugin": "^8.44.0", + "@typescript-eslint/parser": "^8.44.0", "@vitest/coverage-v8": "^3.2.4", "@vscode/test-cli": "^0.0.11", "@vscode/test-electron": "^2.5.2", @@ -343,13 +344,15 @@ "coder": "https://github.com/coder/coder#main", "dayjs": "^1.11.13", "eslint": "^8.57.1", - "eslint-config-prettier": "^9.1.0", - "eslint-plugin-import": "^2.31.0", + "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-prettier": "^5.5.4", "glob": "^10.4.2", "jsonc-eslint-parser": "^2.4.0", + "markdown-eslint-parser": "^1.2.1", "memfs": "^4.47.0", "nyc": "^17.1.0", "prettier": "^3.5.3", diff --git a/src/agentMetadataHelper.ts b/src/agentMetadataHelper.ts index d5e31e5e..0a976411 100644 --- a/src/agentMetadataHelper.ts +++ b/src/agentMetadataHelper.ts @@ -1,11 +1,12 @@ -import { WorkspaceAgent } from "coder/site/src/api/typesGenerated"; +import { type WorkspaceAgent } from "coder/site/src/api/typesGenerated"; import * as vscode from "vscode"; + import { - AgentMetadataEvent, + type AgentMetadataEvent, AgentMetadataEventSchemaArray, errToStr, } from "./api/api-helper"; -import { CoderApi } from "./api/coderApi"; +import { type CoderApi } from "./api/coderApi"; export type AgentMetadataWatcher = { onChange: vscode.EventEmitter["event"]; diff --git a/src/api/api-helper.ts b/src/api/api-helper.ts index 7b41f46c..5b8a5156 100644 --- a/src/api/api-helper.ts +++ b/src/api/api-helper.ts @@ -1,8 +1,8 @@ import { isApiError, isApiErrorResponse } from "coder/site/src/api/errors"; import { - Workspace, - WorkspaceAgent, - WorkspaceResource, + type Workspace, + type WorkspaceAgent, + type WorkspaceResource, } from "coder/site/src/api/typesGenerated"; import { ErrorEvent } from "eventsource"; import { z } from "zod"; diff --git a/src/api/coderApi.ts b/src/api/coderApi.ts index 68592b5c..6c6c0faf 100644 --- a/src/api/coderApi.ts +++ b/src/api/coderApi.ts @@ -1,14 +1,15 @@ -import { AxiosInstance } from "axios"; +import { type AxiosInstance } from "axios"; import { Api } from "coder/site/src/api/api"; import { - GetInboxNotificationResponse, - ProvisionerJobLog, - ServerSentEvent, - Workspace, - WorkspaceAgent, + type GetInboxNotificationResponse, + type ProvisionerJobLog, + type ServerSentEvent, + type Workspace, + type WorkspaceAgent, } from "coder/site/src/api/typesGenerated"; import { type WorkspaceConfiguration } from "vscode"; -import { ClientOptions } from "ws"; +import { type ClientOptions } from "ws"; + import { CertificateError } from "../error"; import { getHeaderCommand, getHeaders } from "../headers"; import { @@ -17,13 +18,17 @@ import { logError, logResponse, } from "../logging/httpLogger"; -import { Logger } from "../logging/logger"; -import { RequestConfigWithMeta, HttpClientLogLevel } from "../logging/types"; +import { type Logger } from "../logging/logger"; +import { + type RequestConfigWithMeta, + HttpClientLogLevel, +} from "../logging/types"; import { WsLogger } from "../logging/wsLogger"; import { OneWayWebSocket, - OneWayWebSocketInit, + type OneWayWebSocketInit, } from "../websocket/oneWayWebSocket"; + import { createHttpAgent } from "./utils"; const coderSessionTokenHeader = "Coder-Session-Token"; diff --git a/src/proxy.ts b/src/api/proxy.ts similarity index 100% rename from src/proxy.ts rename to src/api/proxy.ts diff --git a/src/api/utils.ts b/src/api/utils.ts index 2cb4e91e..91a18885 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -1,9 +1,11 @@ import fs from "fs"; import { ProxyAgent } from "proxy-agent"; import { type WorkspaceConfiguration } from "vscode"; -import { getProxyForUrl } from "../proxy"; + import { expandPath } from "../util"; +import { getProxyForUrl } from "./proxy"; + /** * Return whether the API will need a token for authorization. * If mTLS is in use (as specified by the cert or key files being set) then diff --git a/src/api/workspace.ts b/src/api/workspace.ts index 45fa9156..c2e20c0c 100644 --- a/src/api/workspace.ts +++ b/src/api/workspace.ts @@ -1,12 +1,14 @@ import { spawn } from "child_process"; -import { Api } from "coder/site/src/api/api"; -import { Workspace } from "coder/site/src/api/typesGenerated"; +import { type Api } from "coder/site/src/api/api"; +import { type Workspace } from "coder/site/src/api/typesGenerated"; import * as vscode from "vscode"; -import { FeatureSet } from "../featureSet"; + +import { type FeatureSet } from "../featureSet"; import { getGlobalFlags } from "../globalFlags"; import { escapeCommandArg } from "../util"; + import { errToStr, createWorkspaceIdentifier } from "./api-helper"; -import { CoderApi } from "./coderApi"; +import { type CoderApi } from "./coderApi"; /** * Start or update a workspace and return the updated workspace. diff --git a/src/commands.ts b/src/commands.ts index b9dcf10d..462010ba 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,29 +1,37 @@ -import { Api } from "coder/site/src/api/api"; +import { type Api } from "coder/site/src/api/api"; import { getErrorMessage } from "coder/site/src/api/errors"; import { - User, - Workspace, - WorkspaceAgent, + type User, + type Workspace, + type WorkspaceAgent, } from "coder/site/src/api/typesGenerated"; import * as vscode from "vscode"; + import { createWorkspaceIdentifier, extractAgents } from "./api/api-helper"; import { CoderApi } from "./api/coderApi"; import { needToken } from "./api/utils"; -import { CliManager } from "./core/cliManager"; -import { MementoManager } from "./core/mementoManager"; -import { PathResolver } from "./core/pathResolver"; -import { SecretsManager } from "./core/secretsManager"; +import { type CliManager } from "./core/cliManager"; +import { type ServiceContainer } from "./core/container"; +import { type MementoManager } from "./core/mementoManager"; +import { type PathResolver } from "./core/pathResolver"; +import { type SecretsManager } from "./core/secretsManager"; import { CertificateError } from "./error"; import { getGlobalFlags } from "./globalFlags"; -import { Logger } from "./logging/logger"; +import { type Logger } from "./logging/logger"; import { escapeCommandArg, toRemoteAuthority, toSafeHost } from "./util"; import { AgentTreeItem, - OpenableTreeItem, + type OpenableTreeItem, WorkspaceTreeItem, -} from "./workspacesProvider"; +} from "./workspace/workspacesProvider"; export class Commands { + private readonly vscodeProposed: typeof vscode; + private readonly logger: Logger; + private readonly pathResolver: PathResolver; + private readonly mementoManager: MementoManager; + private readonly secretsManager: SecretsManager; + private readonly cliManager: CliManager; // 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 @@ -36,14 +44,16 @@ export class Commands { public workspaceRestClient?: Api; public constructor( - private readonly vscodeProposed: typeof vscode, + serviceContainer: ServiceContainer, private readonly restClient: Api, - private readonly logger: Logger, - private readonly pathResolver: PathResolver, - private readonly mementoManager: MementoManager, - private readonly secretsManager: SecretsManager, - private readonly cliManager: CliManager, - ) {} + ) { + this.vscodeProposed = serviceContainer.getVsCodeProposed(); + this.logger = serviceContainer.getLogger(); + this.pathResolver = serviceContainer.getPathResolver(); + this.mementoManager = serviceContainer.getMementoManager(); + this.secretsManager = serviceContainer.getSecretsManager(); + this.cliManager = serviceContainer.getCliManager(); + } /** * Find the requested agent if specified, otherwise return the agent if there diff --git a/src/core/cliManager.ts b/src/core/cliManager.ts index e8a7ab25..1bb0afa1 100644 --- a/src/core/cliManager.ts +++ b/src/core/cliManager.ts @@ -2,20 +2,21 @@ import globalAxios, { type AxiosInstance, type AxiosRequestConfig, } from "axios"; -import { Api } from "coder/site/src/api/api"; -import { createWriteStream, WriteStream } from "fs"; +import { type Api } from "coder/site/src/api/api"; +import { createWriteStream, type WriteStream } from "fs"; import fs from "fs/promises"; -import { IncomingMessage } from "http"; +import { type IncomingMessage } from "http"; import path from "path"; import prettyBytes from "pretty-bytes"; import * as semver from "semver"; - import * as vscode from "vscode"; + import { errToStr } from "../api/api-helper"; -import * as cli from "../cliUtils"; -import { Logger } from "../logging/logger"; +import { type Logger } from "../logging/logger"; import * as pgp from "../pgp"; -import { PathResolver } from "./pathResolver"; + +import * as cliUtils from "./cliUtils"; +import { type PathResolver } from "./pathResolver"; export class CliManager { constructor( @@ -58,16 +59,16 @@ export class CliManager { // downloads are disabled, we can return early. const binPath = path.join( this.pathResolver.getBinaryCachePath(label), - cli.name(), + cliUtils.name(), ); this.output.info("Using binary path", binPath); - const stat = await cli.stat(binPath); + const stat = await cliUtils.stat(binPath); if (stat === undefined) { this.output.info("No existing binary found, starting download"); } else { this.output.info("Existing binary size is", prettyBytes(stat.size)); try { - const version = await cli.version(binPath); + const version = await cliUtils.version(binPath); this.output.info("Existing binary version is", version); // If we have the right version we can avoid the request entirely. if (version === buildInfo.version) { @@ -97,7 +98,7 @@ export class CliManager { } // Remove any left-over old or temporary binaries and signatures. - const removed = await cli.rmOld(binPath); + const removed = await cliUtils.rmOld(binPath); removed.forEach(({ fileName, error }) => { if (error) { this.output.warn("Failed to remove", fileName, error); @@ -107,7 +108,7 @@ export class CliManager { }); // Figure out where to get the binary. - const binName = cli.name(); + const binName = cliUtils.name(); const configSource = cfg.get("binarySource"); const binSource = configSource && String(configSource).trim().length > 0 @@ -117,7 +118,7 @@ export class CliManager { // 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 cli.eTag(binPath) : ""; + const etag = stat !== undefined ? await cliUtils.eTag(binPath) : ""; this.output.info("Using ETag", etag); // Download the binary to a temporary file. @@ -173,14 +174,14 @@ export class CliManager { await fs.rename(tempFile, binPath); // For debugging, to see if the binary only partially downloaded. - const newStat = await cli.stat(binPath); + 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 cli.version(binPath); + const version = await cliUtils.version(binPath); this.output.info("Downloaded binary version is", version); return binPath; @@ -199,8 +200,8 @@ export class CliManager { if (!value) { return; } - const os = cli.goos(); - const arch = cli.goarch(); + 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.`, @@ -223,7 +224,7 @@ export class CliManager { return; } const params = new URLSearchParams({ - title: `Failed to download binary on \`${cli.goos()}-${cli.goarch()}\``, + title: `Failed to download binary on \`${cliUtils.goos()}-${cliUtils.goarch()}\``, body: `Received status code \`${status}\` when downloading the binary.`, }); const uri = vscode.Uri.parse( diff --git a/src/cliUtils.ts b/src/core/cliUtils.ts similarity index 100% rename from src/cliUtils.ts rename to src/core/cliUtils.ts diff --git a/src/core/container.ts b/src/core/container.ts new file mode 100644 index 00000000..f820bb0d --- /dev/null +++ b/src/core/container.ts @@ -0,0 +1,69 @@ +import * as vscode from "vscode"; + +import { type Logger } from "../logging/logger"; + +import { CliManager } from "./cliManager"; +import { MementoManager } from "./mementoManager"; +import { PathResolver } from "./pathResolver"; +import { SecretsManager } from "./secretsManager"; + +/** + * Service container for dependency injection. + * Centralizes the creation and management of all core services. + */ +export class ServiceContainer { + private readonly logger: vscode.LogOutputChannel; + private readonly pathResolver: PathResolver; + private readonly mementoManager: MementoManager; + private readonly secretsManager: SecretsManager; + private readonly cliManager: CliManager; + + constructor( + context: vscode.ExtensionContext, + private readonly vscodeProposed: typeof vscode = vscode, + ) { + this.logger = vscode.window.createOutputChannel("Coder", { log: true }); + this.pathResolver = new PathResolver( + context.globalStorageUri.fsPath, + context.logUri.fsPath, + ); + this.mementoManager = new MementoManager(context.globalState); + this.secretsManager = new SecretsManager(context.secrets); + this.cliManager = new CliManager( + this.vscodeProposed, + this.logger, + this.pathResolver, + ); + } + + getVsCodeProposed(): typeof vscode { + return this.vscodeProposed; + } + + getPathResolver(): PathResolver { + return this.pathResolver; + } + + getMementoManager(): MementoManager { + return this.mementoManager; + } + + getSecretsManager(): SecretsManager { + return this.secretsManager; + } + + getLogger(): Logger { + return this.logger; + } + + getCliManager(): CliManager { + return this.cliManager; + } + + /** + * Dispose of all services and clean up resources. + */ + dispose(): void { + this.logger.dispose(); + } +} diff --git a/src/core/secretsManager.ts b/src/core/secretsManager.ts index 7fd98f8f..6a6666da 100644 --- a/src/core/secretsManager.ts +++ b/src/core/secretsManager.ts @@ -20,7 +20,7 @@ export class SecretsManager { public async getSessionToken(): Promise { try { return await this.secrets.get("sessionToken"); - } catch (ex) { + } catch { // The VS Code session store has become corrupt before, and // will fail to get the session token... return undefined; diff --git a/src/error.ts b/src/error.ts index 994b5910..7b93b458 100644 --- a/src/error.ts +++ b/src/error.ts @@ -3,7 +3,8 @@ import { isApiError, isApiErrorResponse } from "coder/site/src/api/errors"; import * as forge from "node-forge"; import * as tls from "tls"; import * as vscode from "vscode"; -import { Logger } from "./logging/logger"; + +import { type Logger } from "./logging/logger"; // X509_ERR_CODE represents error codes as returned from BoringSSL/OpenSSL. export enum X509_ERR_CODE { diff --git a/src/extension.ts b/src/extension.ts index 678ea3b7..f7453cec 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,20 +1,22 @@ "use strict"; + import axios, { isAxiosError } from "axios"; import { getErrorMessage } from "coder/site/src/api/errors"; import * as module from "module"; 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 { CliManager } from "./core/cliManager"; -import { MementoManager } from "./core/mementoManager"; -import { PathResolver } from "./core/pathResolver"; -import { SecretsManager } from "./core/secretsManager"; +import { ServiceContainer } from "./core/container"; import { CertificateError, getErrorDetail } from "./error"; -import { Remote } from "./remote"; +import { Remote } from "./remote/remote"; import { toSafeHost } from "./util"; -import { WorkspaceProvider, WorkspaceQuery } from "./workspacesProvider"; +import { + WorkspaceProvider, + WorkspaceQuery, +} from "./workspace/workspacesProvider"; export async function activate(ctx: vscode.ExtensionContext): Promise { // The Remote SSH extension's proposed APIs are used to override the SSH host @@ -51,14 +53,10 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ); } - const pathResolver = new PathResolver( - ctx.globalStorageUri.fsPath, - ctx.logUri.fsPath, - ); - const mementoManager = new MementoManager(ctx.globalState); - const secretsManager = new SecretsManager(ctx.secrets); - - const output = vscode.window.createOutputChannel("Coder", { log: true }); + const serviceContainer = new ServiceContainer(ctx, vscodeProposed); + const output = serviceContainer.getLogger(); + const mementoManager = serviceContainer.getMementoManager(); + const secretsManager = serviceContainer.getSecretsManager(); // Try to clear this flag ASAP const isFirstConnect = await mementoManager.getAndClearFirstConnect(); @@ -253,19 +251,11 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { }, }); - const cliManager = new CliManager(vscodeProposed, output, pathResolver); + const cliManager = serviceContainer.getCliManager(); // Register globally available commands. Many of these have visibility // controlled by contexts, see `when` in the package.json. - const commands = new Commands( - vscodeProposed, - client, - output, - pathResolver, - mementoManager, - secretsManager, - cliManager, - ); + const commands = new Commands(serviceContainer, client); vscode.commands.registerCommand("coder.login", commands.login.bind(commands)); vscode.commands.registerCommand( "coder.logout", @@ -319,14 +309,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // (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) { - const remote = new Remote( - vscodeProposed, - output, - commands, - ctx.extensionMode, - pathResolver, - cliManager, - ); + const remote = new Remote(serviceContainer, commands, ctx.extensionMode); try { const details = await remote.setup( vscodeProposed.env.remoteAuthority, diff --git a/src/featureSet.ts b/src/featureSet.ts index 67121229..f0b6e95d 100644 --- a/src/featureSet.ts +++ b/src/featureSet.ts @@ -1,4 +1,4 @@ -import * as semver from "semver"; +import type * as semver from "semver"; export type FeatureSet = { vscodessh: boolean; diff --git a/src/globalFlags.ts b/src/globalFlags.ts index 851e41c7..8e75ce8d 100644 --- a/src/globalFlags.ts +++ b/src/globalFlags.ts @@ -1,4 +1,5 @@ -import { WorkspaceConfiguration } from "vscode"; +import { type WorkspaceConfiguration } from "vscode"; + import { getHeaderArgs } from "./headers"; import { escapeCommandArg } from "./util"; diff --git a/src/headers.ts b/src/headers.ts index 1aad4258..f5f45301 100644 --- a/src/headers.ts +++ b/src/headers.ts @@ -1,10 +1,12 @@ import * as cp from "child_process"; import * as os from "os"; import * as util from "util"; -import type { WorkspaceConfiguration } from "vscode"; -import { Logger } from "./logging/logger"; + +import { type Logger } from "./logging/logger"; import { escapeCommandArg } from "./util"; +import type { WorkspaceConfiguration } from "vscode"; + interface ExecException { code?: number; stderr?: string; diff --git a/src/inbox.ts b/src/inbox.ts index e12263bf..61a780bb 100644 --- a/src/inbox.ts +++ b/src/inbox.ts @@ -1,11 +1,13 @@ -import { +import * as vscode from "vscode"; + +import type { Workspace, GetInboxNotificationResponse, } from "coder/site/src/api/typesGenerated"; -import * as vscode from "vscode"; -import { CoderApi } from "./api/coderApi"; -import { Logger } from "./logging/logger"; -import { OneWayWebSocket } from "./websocket/oneWayWebSocket"; + +import type { CoderApi } from "./api/coderApi"; +import type { Logger } from "./logging/logger"; +import type { OneWayWebSocket } from "./websocket/oneWayWebSocket"; // These are the template IDs of our notifications. // Maybe in the future we should avoid hardcoding diff --git a/src/logging/formatters.ts b/src/logging/formatters.ts index 01f55cce..1ad45231 100644 --- a/src/logging/formatters.ts +++ b/src/logging/formatters.ts @@ -1,6 +1,7 @@ -import type { InternalAxiosRequestConfig } from "axios"; import prettyBytes from "pretty-bytes"; +import type { InternalAxiosRequestConfig } from "axios"; + const SENSITIVE_HEADERS = ["Coder-Session-Token", "Proxy-Authorization"]; export function formatTime(ms: number): string { diff --git a/src/logging/httpLogger.ts b/src/logging/httpLogger.ts index 3eed3c56..7e569cad 100644 --- a/src/logging/httpLogger.ts +++ b/src/logging/httpLogger.ts @@ -1,7 +1,8 @@ -import type { AxiosError, AxiosResponse } from "axios"; -import { isAxiosError } from "axios"; +import { isAxiosError, type AxiosError, type AxiosResponse } from "axios"; import { getErrorMessage } from "coder/site/src/api/errors"; + import { getErrorDetail } from "../error"; + import { formatBody, formatContentLength, @@ -10,14 +11,15 @@ import { formatTime, formatUri, } from "./formatters"; -import type { Logger } from "./logger"; import { HttpClientLogLevel, - RequestConfigWithMeta, - RequestMeta, + type RequestConfigWithMeta, + type RequestMeta, } from "./types"; import { createRequestId, shortId } from "./utils"; +import type { Logger } from "./logger"; + /** * Creates metadata for tracking HTTP requests. */ diff --git a/src/logging/wsLogger.ts b/src/logging/wsLogger.ts index 7b922f51..b33118b7 100644 --- a/src/logging/wsLogger.ts +++ b/src/logging/wsLogger.ts @@ -1,9 +1,12 @@ import prettyBytes from "pretty-bytes"; + import { errToStr } from "../api/api-helper"; + import { formatTime } from "./formatters"; -import type { Logger } from "./logger"; import { createRequestId, shortId, sizeOf } from "./utils"; +import type { Logger } from "./logger"; + const numFormatter = new Intl.NumberFormat("en", { notation: "compact", compactDisplay: "short", diff --git a/src/pgp.ts b/src/pgp.ts index 2e82fb79..0e38029f 100644 --- a/src/pgp.ts +++ b/src/pgp.ts @@ -2,8 +2,9 @@ import { createReadStream, promises as fs } from "fs"; import * as openpgp from "openpgp"; import * as path from "path"; import { Readable } from "stream"; + import { errToStr } from "./api/api-helper"; -import { Logger } from "./logging/logger"; +import { type Logger } from "./logging/logger"; export type Key = openpgp.Key; diff --git a/src/remote.ts b/src/remote/remote.ts similarity index 95% rename from src/remote.ts rename to src/remote/remote.ts index c9765fb8..baf7b28c 100644 --- a/src/remote.ts +++ b/src/remote/remote.ts @@ -1,6 +1,9 @@ import { isAxiosError } from "axios"; -import { Api } from "coder/site/src/api/api"; -import { Workspace, WorkspaceAgent } from "coder/site/src/api/typesGenerated"; +import { type Api } from "coder/site/src/api/api"; +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"; @@ -9,34 +12,40 @@ import * as path from "path"; import prettyBytes from "pretty-bytes"; import * as semver from "semver"; import * as vscode from "vscode"; + import { createAgentMetadataWatcher, getEventValue, formatEventLabel, formatMetadataError, -} from "./agentMetadataHelper"; -import { createWorkspaceIdentifier, extractAgents } from "./api/api-helper"; -import { CoderApi } from "./api/coderApi"; -import { needToken } from "./api/utils"; -import { startWorkspaceIfStoppedOrFailed, waitForBuild } from "./api/workspace"; -import * as cliUtils from "./cliUtils"; -import { Commands } from "./commands"; -import { CliManager } from "./core/cliManager"; -import { PathResolver } from "./core/pathResolver"; -import { featureSetForVersion, FeatureSet } from "./featureSet"; -import { getGlobalFlags } from "./globalFlags"; -import { Inbox } from "./inbox"; -import { Logger } from "./logging/logger"; -import { SSHConfig, SSHValues, mergeSSHConfigValues } from "./sshConfig"; -import { computeSSHProperties, sshSupportsSetEnv } from "./sshSupport"; +} from "../agentMetadataHelper"; +import { createWorkspaceIdentifier, extractAgents } from "../api/api-helper"; +import { CoderApi } from "../api/coderApi"; +import { needToken } from "../api/utils"; +import { + startWorkspaceIfStoppedOrFailed, + waitForBuild, +} from "../api/workspace"; +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 PathResolver } from "../core/pathResolver"; +import { featureSetForVersion, type FeatureSet } from "../featureSet"; +import { getGlobalFlags } from "../globalFlags"; +import { Inbox } from "../inbox"; +import { type Logger } from "../logging/logger"; import { AuthorityPrefix, escapeCommandArg, expandPath, findPort, parseRemoteAuthority, -} from "./util"; -import { WorkspaceMonitor } from "./workspaceMonitor"; +} from "../util"; +import { WorkspaceMonitor } from "../workspace/workspaceMonitor"; + +import { SSHConfig, type SSHValues, mergeSSHConfigValues } from "./sshConfig"; +import { computeSSHProperties, sshSupportsSetEnv } from "./sshSupport"; export interface RemoteDetails extends vscode.Disposable { url: string; @@ -44,15 +53,22 @@ export interface RemoteDetails extends vscode.Disposable { } export class Remote { + // We use the proposed API to get access to useCustom in dialogs. + private readonly vscodeProposed: typeof vscode; + private readonly logger: Logger; + private readonly pathResolver: PathResolver; + private readonly cliManager: CliManager; + public constructor( - // We use the proposed API to get access to useCustom in dialogs. - private readonly vscodeProposed: typeof vscode, - private readonly logger: Logger, + serviceContainer: ServiceContainer, private readonly commands: Commands, private readonly mode: vscode.ExtensionMode, - private readonly pathResolver: PathResolver, - private readonly cliManager: CliManager, - ) {} + ) { + this.vscodeProposed = serviceContainer.getVsCodeProposed(); + this.logger = serviceContainer.getLogger(); + this.pathResolver = serviceContainer.getPathResolver(); + this.cliManager = serviceContainer.getCliManager(); + } private async confirmStart(workspaceName: string): Promise { const action = await this.vscodeProposed.window.showInformationMessage( @@ -281,7 +297,7 @@ export class Remote { // This is useful for debugging with a custom bin! binaryPath = path.join(os.tmpdir(), "coder"); await fs.stat(binaryPath); - } catch (ex) { + } catch { binaryPath = await this.cliManager.fetchBinary( workspaceClient, parts.label, @@ -295,7 +311,7 @@ export class Remote { let version: semver.SemVer | null = null; try { version = semver.parse(await cliUtils.version(binaryPath)); - } catch (e) { + } catch { version = semver.parse(buildInfo.version); } @@ -442,7 +458,7 @@ export class Remote { this.pathResolver.getUserSettingsPath(), "utf8", ); - } catch (ex) { + } catch { // Ignore! It's probably because the file doesn't exist. } @@ -932,7 +948,7 @@ export class Remote { .then((parsed) => { try { updateStatus(parsed); - } catch (ex) { + } catch { // Ignore } }) diff --git a/src/sshConfig.ts b/src/remote/sshConfig.ts similarity index 99% rename from src/sshConfig.ts rename to src/remote/sshConfig.ts index 4b184921..f5fea264 100644 --- a/src/sshConfig.ts +++ b/src/remote/sshConfig.ts @@ -1,6 +1,7 @@ import { mkdir, readFile, rename, stat, writeFile } from "fs/promises"; import path from "path"; -import { countSubstring } from "./util"; + +import { countSubstring } from "../util"; class SSHConfigBadFormat extends Error {} @@ -107,7 +108,7 @@ export class SSHConfig { async load() { try { this.raw = await this.fileSystem.readFile(this.filePath, "utf-8"); - } catch (ex) { + } catch { // Probably just doesn't exist! this.raw = ""; } diff --git a/src/sshSupport.ts b/src/remote/sshSupport.ts similarity index 99% rename from src/sshSupport.ts rename to src/remote/sshSupport.ts index 8abcdd24..08860546 100644 --- a/src/sshSupport.ts +++ b/src/remote/sshSupport.ts @@ -6,7 +6,7 @@ export function sshSupportsSetEnv(): boolean { const spawned = childProcess.spawnSync("ssh", ["-V"]); // The version string outputs to stderr. return sshVersionSupportsSetEnv(spawned.stderr.toString().trim()); - } catch (error) { + } catch { return false; } } diff --git a/src/websocket/oneWayWebSocket.ts b/src/websocket/oneWayWebSocket.ts index 3b6a226f..37965596 100644 --- a/src/websocket/oneWayWebSocket.ts +++ b/src/websocket/oneWayWebSocket.ts @@ -7,27 +7,34 @@ * instead of always deriving it from `window.location`. */ -import { WebSocketEventType } from "coder/site/src/utils/OneWayWebSocket"; -import WebSocket, { type ClientOptions } from "ws"; +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"; export type OneWayMessageEvent = Readonly< | { - sourceEvent: WebSocket.MessageEvent; + sourceEvent: MessageEvent; parsedMessage: TData; parseError: undefined; } | { - sourceEvent: WebSocket.MessageEvent; + sourceEvent: MessageEvent; parsedMessage: undefined; parseError: Error; } >; type OneWayEventPayloadMap = { - close: WebSocket.CloseEvent; - error: WebSocket.ErrorEvent; + close: CloseEvent; + error: ErrorEvent; message: OneWayMessageEvent; - open: WebSocket.Event; + open: Event; }; type OneWayEventCallback = ( @@ -58,10 +65,10 @@ export type OneWayWebSocketInit = { export class OneWayWebSocket implements OneWayWebSocketApi { - readonly #socket: WebSocket; + readonly #socket: Ws; readonly #messageCallbacks = new Map< OneWayEventCallback, - (data: WebSocket.RawData) => void + (data: RawData) => void >(); constructor(init: OneWayWebSocketInit) { @@ -76,7 +83,7 @@ export class OneWayWebSocket const wsProtocol = location.protocol === "https:" ? "wss:" : "ws:"; const url = `${wsProtocol}//${location.host}${apiRoute}${paramsSuffix}`; - this.#socket = new WebSocket(url, protocols, options); + this.#socket = new Ws(url, protocols, options); } get url(): string { @@ -94,17 +101,17 @@ export class OneWayWebSocket return; } - const wrapped = (data: WebSocket.RawData): void => { + const wrapped = (data: RawData): void => { try { const message = JSON.parse(data.toString()) as TData; messageCallback({ - sourceEvent: { data } as WebSocket.MessageEvent, + sourceEvent: { data } as MessageEvent, parseError: undefined, parsedMessage: message, }); } catch (err) { messageCallback({ - sourceEvent: { data } as WebSocket.MessageEvent, + sourceEvent: { data } as MessageEvent, parseError: err as Error, parsedMessage: undefined, }); diff --git a/src/workspaceMonitor.ts b/src/workspace/workspaceMonitor.ts similarity index 94% rename from src/workspaceMonitor.ts rename to src/workspace/workspaceMonitor.ts index ece765a6..8ff99137 100644 --- a/src/workspaceMonitor.ts +++ b/src/workspace/workspaceMonitor.ts @@ -1,10 +1,14 @@ -import { ServerSentEvent, Workspace } from "coder/site/src/api/typesGenerated"; +import { + type ServerSentEvent, + type Workspace, +} from "coder/site/src/api/typesGenerated"; import { formatDistanceToNowStrict } from "date-fns"; import * as vscode from "vscode"; -import { createWorkspaceIdentifier, errToStr } from "./api/api-helper"; -import { CoderApi } from "./api/coderApi"; -import { Logger } from "./logging/logger"; -import { OneWayWebSocket } from "./websocket/oneWayWebSocket"; + +import { createWorkspaceIdentifier, errToStr } from "../api/api-helper"; +import { type CoderApi } from "../api/coderApi"; +import { type Logger } from "../logging/logger"; +import { type OneWayWebSocket } from "../websocket/oneWayWebSocket"; /** * Monitor a single workspace using a WebSocket for events like shutdown and deletion. diff --git a/src/workspacesProvider.ts b/src/workspace/workspacesProvider.ts similarity index 97% rename from src/workspacesProvider.ts rename to src/workspace/workspacesProvider.ts index 23f5705a..86279401 100644 --- a/src/workspacesProvider.ts +++ b/src/workspace/workspacesProvider.ts @@ -1,23 +1,24 @@ import { - Workspace, - WorkspaceAgent, - WorkspaceApp, + type Workspace, + type WorkspaceAgent, + type WorkspaceApp, } from "coder/site/src/api/typesGenerated"; import * as path from "path"; import * as vscode from "vscode"; + import { - AgentMetadataWatcher, + type AgentMetadataWatcher, createAgentMetadataWatcher, formatEventLabel, formatMetadataError, -} from "./agentMetadataHelper"; +} from "../agentMetadataHelper"; import { - AgentMetadataEvent, + type AgentMetadataEvent, extractAgents, extractAllAgents, -} from "./api/api-helper"; -import { CoderApi } from "./api/coderApi"; -import { Logger } from "./logging/logger"; +} from "../api/api-helper"; +import { type CoderApi } from "../api/coderApi"; +import { type Logger } from "../logging/logger"; export enum WorkspaceQuery { Mine = "owner:me", @@ -71,7 +72,7 @@ export class WorkspaceProvider let hadError = false; try { this.workspaces = await this.fetch(); - } catch (error) { + } catch { hadError = true; this.workspaces = []; } diff --git a/fixtures/bin.bash b/test/fixtures/bin.bash similarity index 100% rename from fixtures/bin.bash rename to test/fixtures/bin.bash diff --git a/fixtures/bin.old.bash b/test/fixtures/bin.old.bash similarity index 100% rename from fixtures/bin.old.bash rename to test/fixtures/bin.old.bash diff --git a/fixtures/pgp/cli b/test/fixtures/pgp/cli similarity index 100% rename from fixtures/pgp/cli rename to test/fixtures/pgp/cli diff --git a/fixtures/pgp/cli.invalid.asc b/test/fixtures/pgp/cli.invalid.asc similarity index 100% rename from fixtures/pgp/cli.invalid.asc rename to test/fixtures/pgp/cli.invalid.asc diff --git a/fixtures/pgp/cli.valid.asc b/test/fixtures/pgp/cli.valid.asc similarity index 100% rename from fixtures/pgp/cli.valid.asc rename to test/fixtures/pgp/cli.valid.asc diff --git a/fixtures/pgp/private.pgp b/test/fixtures/pgp/private.pgp similarity index 100% rename from fixtures/pgp/private.pgp rename to test/fixtures/pgp/private.pgp diff --git a/fixtures/pgp/public.pgp b/test/fixtures/pgp/public.pgp similarity index 100% rename from fixtures/pgp/public.pgp rename to test/fixtures/pgp/public.pgp diff --git a/fixtures/tls/chain-intermediate.crt b/test/fixtures/tls/chain-intermediate.crt similarity index 100% rename from fixtures/tls/chain-intermediate.crt rename to test/fixtures/tls/chain-intermediate.crt diff --git a/fixtures/tls/chain-intermediate.key b/test/fixtures/tls/chain-intermediate.key similarity index 100% rename from fixtures/tls/chain-intermediate.key rename to test/fixtures/tls/chain-intermediate.key diff --git a/fixtures/tls/chain-leaf.crt b/test/fixtures/tls/chain-leaf.crt similarity index 100% rename from fixtures/tls/chain-leaf.crt rename to test/fixtures/tls/chain-leaf.crt diff --git a/fixtures/tls/chain-leaf.key b/test/fixtures/tls/chain-leaf.key similarity index 100% rename from fixtures/tls/chain-leaf.key rename to test/fixtures/tls/chain-leaf.key diff --git a/fixtures/tls/chain-root.crt b/test/fixtures/tls/chain-root.crt similarity index 100% rename from fixtures/tls/chain-root.crt rename to test/fixtures/tls/chain-root.crt diff --git a/fixtures/tls/chain-root.key b/test/fixtures/tls/chain-root.key similarity index 100% rename from fixtures/tls/chain-root.key rename to test/fixtures/tls/chain-root.key diff --git a/fixtures/tls/chain.crt b/test/fixtures/tls/chain.crt similarity index 100% rename from fixtures/tls/chain.crt rename to test/fixtures/tls/chain.crt diff --git a/fixtures/tls/chain.key b/test/fixtures/tls/chain.key similarity index 100% rename from fixtures/tls/chain.key rename to test/fixtures/tls/chain.key diff --git a/fixtures/tls/generate.bash b/test/fixtures/tls/generate.bash similarity index 100% rename from fixtures/tls/generate.bash rename to test/fixtures/tls/generate.bash diff --git a/fixtures/tls/no-signing.crt b/test/fixtures/tls/no-signing.crt similarity index 100% rename from fixtures/tls/no-signing.crt rename to test/fixtures/tls/no-signing.crt diff --git a/fixtures/tls/no-signing.key b/test/fixtures/tls/no-signing.key similarity index 100% rename from fixtures/tls/no-signing.key rename to test/fixtures/tls/no-signing.key diff --git a/fixtures/tls/self-signed.crt b/test/fixtures/tls/self-signed.crt similarity index 100% rename from fixtures/tls/self-signed.crt rename to test/fixtures/tls/self-signed.crt diff --git a/fixtures/tls/self-signed.key b/test/fixtures/tls/self-signed.key similarity index 100% rename from fixtures/tls/self-signed.key rename to test/fixtures/tls/self-signed.key diff --git a/src/test/extension.test.ts b/test/integration/extension.test.ts similarity index 100% rename from src/test/extension.test.ts rename to test/integration/extension.test.ts diff --git a/src/__mocks__/testHelpers.ts b/test/mocks/testHelpers.ts similarity index 90% rename from src/__mocks__/testHelpers.ts rename to test/mocks/testHelpers.ts index 3a4ce407..14eca74b 100644 --- a/src/__mocks__/testHelpers.ts +++ b/test/mocks/testHelpers.ts @@ -181,28 +181,20 @@ export class MockUserInteraction { return this.responses.get(message); }; - vi.mocked(vscode.window.showErrorMessage).mockImplementation( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (message: string): Thenable => { - const response = getResponse(message); - return Promise.resolve(response); - }, - ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handleMessage = (message: string): Thenable => { + const response = getResponse(message); + return Promise.resolve(response); + }; + + vi.mocked(vscode.window.showErrorMessage).mockImplementation(handleMessage); vi.mocked(vscode.window.showWarningMessage).mockImplementation( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (message: string): Thenable => { - const response = getResponse(message); - return Promise.resolve(response); - }, + handleMessage, ); vi.mocked(vscode.window.showInformationMessage).mockImplementation( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (message: string): Thenable => { - const response = getResponse(message); - return Promise.resolve(response); - }, + handleMessage, ); vi.mocked(vscode.env.openExternal).mockImplementation( diff --git a/src/__mocks__/vscode.runtime.ts b/test/mocks/vscode.runtime.ts similarity index 100% rename from src/__mocks__/vscode.runtime.ts rename to test/mocks/vscode.runtime.ts diff --git a/test/tsconfig.json b/test/tsconfig.json new file mode 100644 index 00000000..ece5f0b1 --- /dev/null +++ b/test/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "baseUrl": "..", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["**/*", "../src/**/*"] +} diff --git a/src/core/cliManager.test.ts b/test/unit/core/cliManager.test.ts similarity index 96% rename from src/core/cliManager.test.ts rename to test/unit/core/cliManager.test.ts index 676de44c..2d76e8d4 100644 --- a/src/core/cliManager.test.ts +++ b/test/unit/core/cliManager.test.ts @@ -1,27 +1,29 @@ -import globalAxios, { AxiosInstance } from "axios"; -import { Api } from "coder/site/src/api/api"; +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 { IncomingMessage } from "http"; +import { type IncomingMessage } from "http"; import { fs as memfs, vol } from "memfs"; import * as os from "os"; import * as path from "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 { type Logger } from "@/logging/logger"; +import * as pgp from "@/pgp"; + import { MockConfigurationProvider, MockProgressReporter, MockUserInteraction, -} from "../__mocks__/testHelpers"; -import * as cli from "../cliUtils"; -import { Logger } from "../logging/logger"; -import * as pgp from "../pgp"; -import { CliManager } from "./cliManager"; -import { PathResolver } from "./pathResolver"; +} from "../../mocks/testHelpers"; vi.mock("os"); vi.mock("axios"); -vi.mock("../pgp"); +vi.mock("@/pgp"); vi.mock("fs", async () => { const memfs: { fs: typeof fs } = await vi.importActual("memfs"); @@ -39,10 +41,9 @@ vi.mock("fs/promises", async () => { }; }); -// Only mock the platform detection functions from CLI manager -vi.mock("../cliUtils", async () => { +vi.mock("@/core/cliUtils", async () => { const actual = - await vi.importActual("../cliUtils"); + await vi.importActual("@/core/cliUtils"); return { ...actual, // No need to test script execution here @@ -652,7 +653,7 @@ describe("CliManager", () => { }); // Mock version to return the specified version - vi.mocked(cli.version).mockResolvedValueOnce(version); + vi.mocked(cliUtils.version).mockResolvedValueOnce(version); } function withCorruptedBinary() { @@ -662,7 +663,7 @@ describe("CliManager", () => { }); // Mock version to fail - vi.mocked(cli.version).mockRejectedValueOnce(new Error("corrupted")); + vi.mocked(cliUtils.version).mockRejectedValueOnce(new Error("corrupted")); } function withSuccessfulDownload(opts?: { @@ -676,7 +677,7 @@ describe("CliManager", () => { ); // Mock version to return TEST_VERSION after download - vi.mocked(cli.version).mockResolvedValue(TEST_VERSION); + vi.mocked(cliUtils.version).mockResolvedValue(TEST_VERSION); } function withSignatureResponses(statuses: number[]): void { diff --git a/src/cliUtils.test.ts b/test/unit/core/cliUtils.test.ts similarity index 70% rename from src/cliUtils.test.ts rename to test/unit/core/cliUtils.test.ts index aec78e87..d63ddd87 100644 --- a/src/cliUtils.test.ts +++ b/test/unit/core/cliUtils.test.ts @@ -2,9 +2,12 @@ import fs from "fs/promises"; import os from "os"; import path from "path"; import { beforeAll, describe, expect, it } from "vitest"; -import * as cli from "./cliUtils"; -describe("cliUtils", () => { +import * as cliUtils from "@/core/cliUtils"; + +import { getFixturePath } from "../../utils/fixtures"; + +describe("CliUtils", () => { const tmp = path.join(os.tmpdir(), "vscode-coder-tests"); beforeAll(async () => { @@ -14,34 +17,31 @@ describe("cliUtils", () => { }); it("name", () => { - expect(cli.name().startsWith("coder-")).toBeTruthy(); + expect(cliUtils.name().startsWith("coder-")).toBeTruthy(); }); it("stat", async () => { const binPath = path.join(tmp, "stat"); - expect(await cli.stat(binPath)).toBeUndefined(); + expect(await cliUtils.stat(binPath)).toBeUndefined(); await fs.writeFile(binPath, "test"); - expect((await cli.stat(binPath))?.size).toBe(4); + 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 () => { const binPath = path.join(tmp, "version"); - await expect(cli.version(binPath)).rejects.toThrow("ENOENT"); + await expect(cliUtils.version(binPath)).rejects.toThrow("ENOENT"); - const binTmpl = await fs.readFile( - path.join(__dirname, "../fixtures/bin.bash"), - "utf8", - ); + const binTmpl = await fs.readFile(getFixturePath("bin.bash"), "utf8"); await fs.writeFile(binPath, binTmpl.replace("$ECHO", "hello")); - await expect(cli.version(binPath)).rejects.toThrow("EACCES"); + await expect(cliUtils.version(binPath)).rejects.toThrow("EACCES"); await fs.chmod(binPath, "755"); - await expect(cli.version(binPath)).rejects.toThrow("Unexpected token"); + await expect(cliUtils.version(binPath)).rejects.toThrow("Unexpected token"); await fs.writeFile(binPath, binTmpl.replace("$ECHO", "{}")); - await expect(cli.version(binPath)).rejects.toThrow( + await expect(cliUtils.version(binPath)).rejects.toThrow( "No version found in output", ); @@ -54,42 +54,39 @@ describe("cliUtils", () => { }), ), ); - expect(await cli.version(binPath)).toBe("v0.0.0"); + expect(await cliUtils.version(binPath)).toBe("v0.0.0"); - const oldTmpl = await fs.readFile( - path.join(__dirname, "../fixtures/bin.old.bash"), - "utf8", - ); + const oldTmpl = await fs.readFile(getFixturePath("bin.old.bash"), "utf8"); const old = (stderr: string, stdout: string): string => { return oldTmpl.replace("$STDERR", stderr).replace("$STDOUT", stdout); }; // Should fall back only if it says "unknown flag". await fs.writeFile(binPath, old("foobar", "Coder v1.1.1")); - await expect(cli.version(binPath)).rejects.toThrow("foobar"); + await expect(cliUtils.version(binPath)).rejects.toThrow("foobar"); await fs.writeFile(binPath, old("unknown flag: --output", "Coder v1.1.1")); - expect(await cli.version(binPath)).toBe("v1.1.1"); + expect(await cliUtils.version(binPath)).toBe("v1.1.1"); // Should trim off the newline if necessary. await fs.writeFile( binPath, old("unknown flag: --output\n", "Coder v1.1.1\n"), ); - expect(await cli.version(binPath)).toBe("v1.1.1"); + expect(await cliUtils.version(binPath)).toBe("v1.1.1"); // Error with original error if it does not begin with "Coder". await fs.writeFile(binPath, old("unknown flag: --output", "Unrelated")); - await expect(cli.version(binPath)).rejects.toThrow("unknown flag"); + await expect(cliUtils.version(binPath)).rejects.toThrow("unknown flag"); // Error if no version. await fs.writeFile(binPath, old("unknown flag: --output", "Coder")); - await expect(cli.version(binPath)).rejects.toThrow("No version found"); + await expect(cliUtils.version(binPath)).rejects.toThrow("No version found"); }); it("rmOld", async () => { const binDir = path.join(tmp, "bins"); - expect(await cli.rmOld(path.join(binDir, "bin1"))).toStrictEqual([]); + expect(await cliUtils.rmOld(path.join(binDir, "bin1"))).toStrictEqual([]); await fs.mkdir(binDir, { recursive: true }); await fs.writeFile(path.join(binDir, "bin.old-1"), "echo hello"); @@ -102,7 +99,7 @@ describe("cliUtils", () => { await fs.writeFile(path.join(binDir, "bin.old-1.asc"), "echo hello"); await fs.writeFile(path.join(binDir, "bin.temp-2.asc"), "echo hello"); - expect(await cli.rmOld(path.join(binDir, "bin1"))).toStrictEqual([ + expect(await cliUtils.rmOld(path.join(binDir, "bin1"))).toStrictEqual([ { fileName: "bin.asc", error: undefined, @@ -143,12 +140,12 @@ describe("cliUtils", () => { const binPath = path.join(tmp, "hash"); await fs.writeFile(binPath, "foobar"); - expect(await cli.eTag(binPath)).toBe( + expect(await cliUtils.eTag(binPath)).toBe( "8843d7f92416211de9ebb963ff4ce28125932878", ); await fs.writeFile(binPath, "test"); - expect(await cli.eTag(binPath)).toBe( + expect(await cliUtils.eTag(binPath)).toBe( "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3", ); }); diff --git a/src/core/mementoManager.test.ts b/test/unit/core/mementoManager.test.ts similarity index 93% rename from src/core/mementoManager.test.ts rename to test/unit/core/mementoManager.test.ts index f1cd6a2d..54289a65 100644 --- a/src/core/mementoManager.test.ts +++ b/test/unit/core/mementoManager.test.ts @@ -1,6 +1,8 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import { InMemoryMemento } from "../__mocks__/testHelpers"; -import { MementoManager } from "./mementoManager"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { MementoManager } from "@/core/mementoManager"; + +import { InMemoryMemento } from "../../mocks/testHelpers"; describe("MementoManager", () => { let memento: InMemoryMemento; diff --git a/src/core/pathResolver.test.ts b/test/unit/core/pathResolver.test.ts similarity index 92% rename from src/core/pathResolver.test.ts rename to test/unit/core/pathResolver.test.ts index 3c331a26..e0e3b4d6 100644 --- a/src/core/pathResolver.test.ts +++ b/test/unit/core/pathResolver.test.ts @@ -1,7 +1,9 @@ import * as path from "path"; -import { describe, it, expect, beforeEach, vi } from "vitest"; -import { MockConfigurationProvider } from "../__mocks__/testHelpers"; -import { PathResolver } from "./pathResolver"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { PathResolver } from "@/core/pathResolver"; + +import { MockConfigurationProvider } from "../../mocks/testHelpers"; describe("PathResolver", () => { const basePath = diff --git a/src/core/secretsManager.test.ts b/test/unit/core/secretsManager.test.ts similarity index 87% rename from src/core/secretsManager.test.ts rename to test/unit/core/secretsManager.test.ts index a6487e0f..7100a29b 100644 --- a/src/core/secretsManager.test.ts +++ b/test/unit/core/secretsManager.test.ts @@ -1,6 +1,8 @@ -import { describe, it, expect, beforeEach } from "vitest"; -import { InMemorySecretStorage } from "../__mocks__/testHelpers"; -import { SecretsManager } from "./secretsManager"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { SecretsManager } from "@/core/secretsManager"; + +import { InMemorySecretStorage } from "../../mocks/testHelpers"; describe("SecretsManager", () => { let secretStorage: InMemorySecretStorage; diff --git a/src/error.test.ts b/test/unit/error.test.ts similarity index 90% rename from src/error.test.ts rename to test/unit/error.test.ts index 84c1e14b..b606f875 100644 --- a/src/error.test.ts +++ b/test/unit/error.test.ts @@ -1,10 +1,12 @@ import axios from "axios"; import * as fs from "fs/promises"; import https from "https"; -import * as path from "path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { CertificateError, X509_ERR, X509_ERR_CODE } from "./error"; -import { Logger } from "./logging/logger"; + +import { CertificateError, X509_ERR, X509_ERR_CODE } from "@/error"; +import { type Logger } from "@/logging/logger"; + +import { getFixturePath } from "../utils/fixtures"; describe("Certificate errors", () => { // Before each test we make a request to sanity check that we really get the @@ -45,12 +47,8 @@ describe("Certificate errors", () => { async function startServer(certName: string): Promise { const server = https.createServer( { - key: await fs.readFile( - path.join(__dirname, `../fixtures/tls/${certName}.key`), - ), - cert: await fs.readFile( - path.join(__dirname, `../fixtures/tls/${certName}.crt`), - ), + key: await fs.readFile(getFixturePath("tls", `${certName}.key`)), + cert: await fs.readFile(getFixturePath("tls", `${certName}.crt`)), }, (req, res) => { if (req.url?.endsWith("/error")) { @@ -87,9 +85,7 @@ describe("Certificate errors", () => { const address = await startServer("chain-leaf"); const request = axios.get(address, { httpsAgent: new https.Agent({ - ca: await fs.readFile( - path.join(__dirname, "../fixtures/tls/chain-leaf.crt"), - ), + ca: await fs.readFile(getFixturePath("tls", "chain-leaf.crt")), }), }); await expect(request).rejects.toHaveProperty( @@ -124,9 +120,7 @@ describe("Certificate errors", () => { const address = await startServer("no-signing"); const request = axios.get(address, { httpsAgent: new https.Agent({ - ca: await fs.readFile( - path.join(__dirname, "../fixtures/tls/no-signing.crt"), - ), + ca: await fs.readFile(getFixturePath("tls", "no-signing.crt")), servername: "localhost", }), }); @@ -189,9 +183,7 @@ describe("Certificate errors", () => { const address = await startServer("self-signed"); const request = axios.get(address, { httpsAgent: new https.Agent({ - ca: await fs.readFile( - path.join(__dirname, "../fixtures/tls/self-signed.crt"), - ), + ca: await fs.readFile(getFixturePath("tls", "self-signed.crt")), servername: "localhost", }), }); @@ -234,9 +226,7 @@ describe("Certificate errors", () => { const address = await startServer("chain"); const request = axios.get(address, { httpsAgent: new https.Agent({ - ca: await fs.readFile( - path.join(__dirname, "../fixtures/tls/chain-root.crt"), - ), + ca: await fs.readFile(getFixturePath("tls", "chain-root.crt")), servername: "localhost", }), }); @@ -257,9 +247,7 @@ describe("Certificate errors", () => { const address = await startServer("chain"); const request = axios.get(address + "/error", { httpsAgent: new https.Agent({ - ca: await fs.readFile( - path.join(__dirname, "../fixtures/tls/chain-root.crt"), - ), + ca: await fs.readFile(getFixturePath("tls", "chain-root.crt")), servername: "localhost", }), }); diff --git a/src/featureSet.test.ts b/test/unit/featureSet.test.ts similarity index 94% rename from src/featureSet.test.ts rename to test/unit/featureSet.test.ts index e3c45d3c..919f7089 100644 --- a/src/featureSet.test.ts +++ b/test/unit/featureSet.test.ts @@ -1,6 +1,7 @@ import * as semver from "semver"; import { describe, expect, it } from "vitest"; -import { featureSetForVersion } from "./featureSet"; + +import { featureSetForVersion } from "@/featureSet"; describe("check version support", () => { it("has logs", () => { diff --git a/src/globalFlags.test.ts b/test/unit/globalFlags.test.ts similarity index 95% rename from src/globalFlags.test.ts rename to test/unit/globalFlags.test.ts index 307500e7..d570d609 100644 --- a/src/globalFlags.test.ts +++ b/test/unit/globalFlags.test.ts @@ -1,6 +1,7 @@ import { it, expect, describe } from "vitest"; -import { WorkspaceConfiguration } from "vscode"; -import { getGlobalFlags } from "./globalFlags"; +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", () => { diff --git a/src/headers.test.ts b/test/unit/headers.test.ts similarity index 95% rename from src/headers.test.ts rename to test/unit/headers.test.ts index 6f2933a3..b2c29e22 100644 --- a/src/headers.test.ts +++ b/test/unit/headers.test.ts @@ -1,8 +1,9 @@ import * as os from "os"; -import { it, expect, describe, beforeEach, afterEach, vi } from "vitest"; -import { WorkspaceConfiguration } from "vscode"; -import { getHeaderCommand, getHeaders } from "./headers"; -import { Logger } from "./logging/logger"; +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"; const logger: Logger = { trace: () => {}, diff --git a/src/pgp.test.ts b/test/unit/pgp.test.ts similarity index 85% rename from src/pgp.test.ts rename to test/unit/pgp.test.ts index 6eeff95b..73faa99b 100644 --- a/src/pgp.test.ts +++ b/test/unit/pgp.test.ts @@ -2,22 +2,19 @@ import fs from "fs/promises"; import * as openpgp from "openpgp"; import path from "path"; import { describe, expect, it } from "vitest"; -import * as pgp from "./pgp"; + +import * as pgp from "@/pgp"; + +import { getFixturePath } from "../utils/fixtures"; describe("pgp", () => { // This contains two keys, like Coder's. - const publicKeysPath = path.join(__dirname, "../fixtures/pgp/public.pgp"); + const publicKeysPath = getFixturePath("pgp", "public.pgp"); // Just a text file, not an actual binary. - const cliPath = path.join(__dirname, "../fixtures/pgp/cli"); - const invalidSignaturePath = path.join( - __dirname, - "../fixtures/pgp/cli.invalid.asc", - ); + const cliPath = getFixturePath("pgp", "cli"); + const invalidSignaturePath = getFixturePath("pgp", "cli.invalid.asc"); // This is signed with the second key, like Coder's. - const validSignaturePath = path.join( - __dirname, - "../fixtures/pgp/cli.valid.asc", - ); + const validSignaturePath = getFixturePath("pgp", "cli.valid.asc"); it("reads bundled public keys", async () => { const keys = await pgp.readPublicKeys(); diff --git a/src/sshConfig.test.ts b/test/unit/remote/sshConfig.test.ts similarity index 99% rename from src/sshConfig.test.ts rename to test/unit/remote/sshConfig.test.ts index 1e4cb785..cfc48c74 100644 --- a/src/sshConfig.test.ts +++ b/test/unit/remote/sshConfig.test.ts @@ -1,6 +1,6 @@ -/* eslint-disable @typescript-eslint/ban-ts-comment */ import { it, afterEach, vi, expect } from "vitest"; -import { SSHConfig } from "./sshConfig"; + +import { SSHConfig } from "@/remote/sshConfig"; // This is not the usual path to ~/.ssh/config, but // setting it to a different path makes it easier to test diff --git a/src/sshSupport.test.ts b/test/unit/remote/sshSupport.test.ts similarity index 99% rename from src/sshSupport.test.ts rename to test/unit/remote/sshSupport.test.ts index 050b7bb2..bb152bd8 100644 --- a/src/sshSupport.test.ts +++ b/test/unit/remote/sshSupport.test.ts @@ -1,9 +1,10 @@ import { it, expect } from "vitest"; + import { computeSSHProperties, sshSupportsSetEnv, sshVersionSupportsSetEnv, -} from "./sshSupport"; +} from "@/remote/sshSupport"; const supports = { "OpenSSH_8.9p1 Ubuntu-3ubuntu0.1, OpenSSL 3.0.2 15 Mar 2022": true, diff --git a/src/util.test.ts b/test/unit/util.test.ts similarity index 99% rename from src/util.test.ts rename to test/unit/util.test.ts index 8f40e656..d508f41c 100644 --- a/src/util.test.ts +++ b/test/unit/util.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; -import { countSubstring, parseRemoteAuthority, toSafeHost } from "./util"; + +import { countSubstring, parseRemoteAuthority, toSafeHost } from "@/util"; it("ignore unrelated authorities", () => { const tests = [ diff --git a/test/utils/fixtures.ts b/test/utils/fixtures.ts new file mode 100644 index 00000000..0b6c66d6 --- /dev/null +++ b/test/utils/fixtures.ts @@ -0,0 +1,5 @@ +import path from "path"; + +const testDir = path.join(__dirname, ".."); +export const getFixturePath = (...parts: string[]) => + path.join(testDir, "fixtures", ...parts); diff --git a/tsconfig.json b/tsconfig.json index 0974a4d1..78cc9654 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,7 @@ "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, + "baseUrl": ".", "paths": { // axios contains both an index.d.ts and index.d.cts which apparently have // conflicting types. For some reason TypeScript is reading both and @@ -20,5 +21,5 @@ } }, "exclude": ["node_modules"], - "include": ["src/**/*"] + "include": ["src"] } diff --git a/vitest.config.ts b/vitest.config.ts index af067d95..01e3896a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,24 +3,25 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { - include: ["src/**/*.test.ts"], + globals: true, + environment: "node", + include: ["test/unit/**/*.test.ts", "test/integration/**/*.test.ts"], exclude: [ + "test/integration/**", "**/node_modules/**", - "**/dist/**", - "**/build/**", "**/out/**", - "**/src/test/**", - "src/test/**", - "./src/test/**", + "**/*.d.ts", ], - environment: "node", + pool: "threads", + fileParallelism: true, coverage: { provider: "v8", }, }, resolve: { alias: { - vscode: path.resolve(__dirname, "src/__mocks__/vscode.runtime.ts"), + "@": path.resolve(__dirname, "src"), + vscode: path.resolve(__dirname, "test/mocks/vscode.runtime.ts"), }, }, }); diff --git a/yarn.lock b/yarn.lock index 581e7d3a..a067635f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -341,6 +341,28 @@ resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz#f13c7c205915eb91ae54c557f5e92bddd8be0e83" integrity sha512-4B4OijXeVNOPZlYA2oEwWOTkzyltLao+xbotHQeqN++Rv27Y6s818+n2Qkp8q+Fxhn0t/5lA5X1Mxktud8eayQ== +"@emnapi/core@^1.4.3": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.5.0.tgz#85cd84537ec989cebb2343606a1ee663ce4edaf0" + integrity sha512-sbP8GzB1WDzacS8fgNPpHlp6C9VZe+SJP3F90W9rLemaQj2PzIuTEl1qDOYQf58YIpyjViI24y9aPWCjEzY2cg== + dependencies: + "@emnapi/wasi-threads" "1.1.0" + tslib "^2.4.0" + +"@emnapi/runtime@^1.4.3": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.5.0.tgz#9aebfcb9b17195dce3ab53c86787a6b7d058db73" + integrity sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ== + dependencies: + tslib "^2.4.0" + +"@emnapi/wasi-threads@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz#60b2102fddc9ccb78607e4a3cf8403ea69be41bf" + integrity sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ== + dependencies: + tslib "^2.4.0" + "@esbuild/aix-ppc64@0.25.9": version "0.25.9" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz#bef96351f16520055c947aba28802eede3c9e9a9" @@ -471,17 +493,17 @@ resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz#585624dc829cfb6e7c0aa6c3ca7d7e6daa87e34f" integrity sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ== -"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": - version "4.4.0" - resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59" - integrity sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA== +"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.7.0": + version "4.9.0" + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz#7308df158e064f0dd8b8fdb58aa14fa2a7f913b3" + integrity sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g== dependencies: - eslint-visitor-keys "^3.3.0" + eslint-visitor-keys "^3.4.3" -"@eslint-community/regexpp@^4.5.1", "@eslint-community/regexpp@^4.6.1": - version "4.9.1" - resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.9.1.tgz#449dfa81a57a1d755b09aa58d826c1262e4283b4" - integrity sha512-Y27x+MBLjXa+0JWDhykM3+JE+il3kHKAEqabfEWq3SDhZjLYb6/BHL/JKFnH3fe207JaXkyDo685Oc2Glt6ifA== +"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.6.1": + version "4.12.1" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz#cfc6cffe39df390a3841cde2abccf92eaa7ae0e0" + integrity sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ== "@eslint/eslintrc@^2.1.4": version "2.1.4" @@ -691,6 +713,15 @@ "@jsonjoy.com/buffers" "^1.0.0" "@jsonjoy.com/codegen" "^1.0.0" +"@napi-rs/wasm-runtime@^0.2.11": + version "0.2.12" + resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz#3e78a8b96e6c33a6c517e1894efbd5385a7cb6f2" + integrity sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ== + dependencies: + "@emnapi/core" "^1.4.3" + "@emnapi/runtime" "^1.4.3" + "@tybys/wasm-util" "^0.10.0" + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -717,10 +748,10 @@ resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== -"@pkgr/core@^0.2.4": - version "0.2.7" - resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.2.7.tgz#eb5014dfd0b03e7f3ba2eeeff506eed89b028058" - integrity sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg== +"@pkgr/core@^0.2.9": + version "0.2.9" + resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.2.9.tgz#d229a7b7f9dac167a156992ef23c7f023653f53b" + integrity sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA== "@rollup/rollup-android-arm-eabi@4.50.2": version "4.50.2" @@ -991,6 +1022,13 @@ resolved "https://registry.yarnpkg.com/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz#db4ecfd499a9765ab24002c3b696d02e6d32a12c" integrity sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA== +"@tybys/wasm-util@^0.10.0": + version "0.10.1" + resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414" + integrity sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg== + dependencies: + tslib "^2.4.0" + "@types/chai@^5.2.2": version "5.2.2" resolved "https://registry.yarnpkg.com/@types/chai/-/chai-5.2.2.tgz#6f14cea18180ffc4416bc0fd12be05fdd73bdd6b" @@ -1044,7 +1082,7 @@ resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz#7739c232a1fee9b4d3ce8985f314c0c6d33549d7" integrity sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w== -"@types/json-schema@*", "@types/json-schema@^7.0.12", "@types/json-schema@^7.0.15", "@types/json-schema@^7.0.9": +"@types/json-schema@*", "@types/json-schema@^7.0.15", "@types/json-schema@^7.0.9": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== @@ -1088,10 +1126,10 @@ resolved "https://registry.yarnpkg.com/@types/sarif/-/sarif-2.1.7.tgz#dab4d16ba7568e9846c454a8764f33c5d98e5524" integrity sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ== -"@types/semver@^7.5.0": - version "7.5.3" - resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.3.tgz#9a726e116beb26c24f1ccd6850201e1246122e04" - integrity sha512-OxepLK9EuNEIPxWNME+C6WwbRAOOI2o2BaQEGzz5Lu2e4Z5eDnEo+/aVEDMIXywoJitJ7xWd641wrGLZdtwRyw== +"@types/semver@^7.7.1": + version "7.7.1" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.7.1.tgz#3ce3af1a5524ef327d2da9e4fd8b6d95c8d70528" + integrity sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA== "@types/ua-parser-js@0.7.36": version "0.7.36" @@ -1115,126 +1153,103 @@ dependencies: "@types/node" "*" -"@typescript-eslint/eslint-plugin@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.0.0.tgz#62cda0d35bbf601683c6e58cf5d04f0275caca4e" - integrity sha512-M72SJ0DkcQVmmsbqlzc6EJgb/3Oz2Wdm6AyESB4YkGgCxP8u5jt5jn4/OBMPK3HLOxcttZq5xbBBU7e2By4SZQ== - dependencies: - "@eslint-community/regexpp" "^4.5.1" - "@typescript-eslint/scope-manager" "7.0.0" - "@typescript-eslint/type-utils" "7.0.0" - "@typescript-eslint/utils" "7.0.0" - "@typescript-eslint/visitor-keys" "7.0.0" - debug "^4.3.4" +"@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" + integrity sha512-molgphGqOBT7t4YKCSkbasmu1tb1MgrZ2szGzHbclF7PNmOkSTQVHy+2jXOSnxvR3+Xe1yySHFZoqMpz3TfQsw== + dependencies: + "@eslint-community/regexpp" "^4.10.0" + "@typescript-eslint/scope-manager" "8.44.1" + "@typescript-eslint/type-utils" "8.44.1" + "@typescript-eslint/utils" "8.44.1" + "@typescript-eslint/visitor-keys" "8.44.1" graphemer "^1.4.0" - ignore "^5.2.4" + ignore "^7.0.0" natural-compare "^1.4.0" - semver "^7.5.4" - ts-api-utils "^1.0.1" + ts-api-utils "^2.1.0" -"@typescript-eslint/parser@^6.21.0": - version "6.21.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-6.21.0.tgz#af8fcf66feee2edc86bc5d1cf45e33b0630bf35b" - integrity sha512-tbsV1jPne5CkFQCgPBcDOt30ItF7aJoZL997JSF7MhGQqOeT3svWRYxiqlfA5RUdlHN6Fi+EI9bxqbdyAUZjYQ== +"@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== dependencies: - "@typescript-eslint/scope-manager" "6.21.0" - "@typescript-eslint/types" "6.21.0" - "@typescript-eslint/typescript-estree" "6.21.0" - "@typescript-eslint/visitor-keys" "6.21.0" + "@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" debug "^4.3.4" -"@typescript-eslint/scope-manager@6.21.0": - version "6.21.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz#ea8a9bfc8f1504a6ac5d59a6df308d3a0630a2b1" - integrity sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg== +"@typescript-eslint/project-service@8.44.1": + version "8.44.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.44.1.tgz#1bccd9796d25032b190f355f55c5fde061158abb" + integrity sha512-ycSa60eGg8GWAkVsKV4E6Nz33h+HjTXbsDT4FILyL8Obk5/mx4tbvCNsLf9zret3ipSumAOG89UcCs/KRaKYrA== dependencies: - "@typescript-eslint/types" "6.21.0" - "@typescript-eslint/visitor-keys" "6.21.0" - -"@typescript-eslint/scope-manager@7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-7.0.0.tgz#15ea9abad2b56fc8f5c0b516775f41c86c5c8685" - integrity sha512-IxTStwhNDPO07CCrYuAqjuJ3Xf5MrMaNgbAZPxFXAUpAtwqFxiuItxUaVtP/SJQeCdJjwDGh9/lMOluAndkKeg== - dependencies: - "@typescript-eslint/types" "7.0.0" - "@typescript-eslint/visitor-keys" "7.0.0" + "@typescript-eslint/tsconfig-utils" "^8.44.1" + "@typescript-eslint/types" "^8.44.1" + debug "^4.3.4" -"@typescript-eslint/type-utils@7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-7.0.0.tgz#a4c7ae114414e09dbbd3c823b5924793f7483252" - integrity sha512-FIM8HPxj1P2G7qfrpiXvbHeHypgo2mFpFGoh5I73ZlqmJOsloSa1x0ZyXCer43++P1doxCgNqIOLqmZR6SOT8g== +"@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" + integrity sha512-NdhWHgmynpSvyhchGLXh+w12OMT308Gm25JoRIyTZqEbApiBiQHD/8xgb6LqCWCFcxFtWwaVdFsLPQI3jvhywg== dependencies: - "@typescript-eslint/typescript-estree" "7.0.0" - "@typescript-eslint/utils" "7.0.0" - debug "^4.3.4" - ts-api-utils "^1.0.1" + "@typescript-eslint/types" "8.44.1" + "@typescript-eslint/visitor-keys" "8.44.1" -"@typescript-eslint/types@6.21.0": - version "6.21.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-6.21.0.tgz#205724c5123a8fef7ecd195075fa6e85bac3436d" - integrity sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg== +"@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/types@7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-7.0.0.tgz#2e5889c7fe3c873fc6dc6420aa77775f17cd5dc6" - integrity sha512-9ZIJDqagK1TTs4W9IyeB2sH/s1fFhN9958ycW8NRTg1vXGzzH5PQNzq6KbsbVGMT+oyyfa17DfchHDidcmf5cg== - -"@typescript-eslint/typescript-estree@6.21.0": - version "6.21.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz#c47ae7901db3b8bddc3ecd73daff2d0895688c46" - integrity sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ== +"@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" + integrity sha512-KdEerZqHWXsRNKjF9NYswNISnFzXfXNDfPxoTh7tqohU/PRIbwTmsjGK6V9/RTYWau7NZvfo52lgVk+sJh0K3g== dependencies: - "@typescript-eslint/types" "6.21.0" - "@typescript-eslint/visitor-keys" "6.21.0" + "@typescript-eslint/types" "8.44.1" + "@typescript-eslint/typescript-estree" "8.44.1" + "@typescript-eslint/utils" "8.44.1" debug "^4.3.4" - globby "^11.1.0" - is-glob "^4.0.3" - minimatch "9.0.3" - semver "^7.5.4" - ts-api-utils "^1.0.1" - -"@typescript-eslint/typescript-estree@7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-7.0.0.tgz#7ce66f2ce068517f034f73fba9029300302fdae9" - integrity sha512-JzsOzhJJm74aQ3c9um/aDryHgSHfaX8SHFIu9x4Gpik/+qxLvxUylhTsO9abcNu39JIdhY2LgYrFxTii3IajLA== - dependencies: - "@typescript-eslint/types" "7.0.0" - "@typescript-eslint/visitor-keys" "7.0.0" + ts-api-utils "^2.1.0" + +"@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/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" + integrity sha512-qnQJ+mVa7szevdEyvfItbO5Vo+GfZ4/GZWWDRRLjrxYPkhM+6zYB2vRYwCsoJLzqFCdZT4mEqyJoyzkunsZ96A== + dependencies: + "@typescript-eslint/project-service" "8.44.1" + "@typescript-eslint/tsconfig-utils" "8.44.1" + "@typescript-eslint/types" "8.44.1" + "@typescript-eslint/visitor-keys" "8.44.1" debug "^4.3.4" - globby "^11.1.0" + fast-glob "^3.3.2" is-glob "^4.0.3" - minimatch "9.0.3" - semver "^7.5.4" - ts-api-utils "^1.0.1" - -"@typescript-eslint/utils@7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-7.0.0.tgz#e43710af746c6ae08484f7afc68abc0212782c7e" - integrity sha512-kuPZcPAdGcDBAyqDn/JVeJVhySvpkxzfXjJq1X1BFSTYo1TTuo4iyb937u457q4K0In84p6u2VHQGaFnv7VYqg== - dependencies: - "@eslint-community/eslint-utils" "^4.4.0" - "@types/json-schema" "^7.0.12" - "@types/semver" "^7.5.0" - "@typescript-eslint/scope-manager" "7.0.0" - "@typescript-eslint/types" "7.0.0" - "@typescript-eslint/typescript-estree" "7.0.0" - semver "^7.5.4" + minimatch "^9.0.4" + semver "^7.6.0" + ts-api-utils "^2.1.0" -"@typescript-eslint/visitor-keys@6.21.0": - version "6.21.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz#87a99d077aa507e20e238b11d56cc26ade45fe47" - integrity sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A== +"@typescript-eslint/utils@8.44.1": + version "8.44.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.44.1.tgz#f23d48eb90791a821dc17d4f67bb96faeb75d63d" + integrity sha512-DpX5Fp6edTlocMCwA+mHY8Mra+pPjRZ0TfHkXI8QFelIKcbADQz1LUPNtzOFUriBB2UYqw4Pi9+xV4w9ZczHFg== dependencies: - "@typescript-eslint/types" "6.21.0" - eslint-visitor-keys "^3.4.1" + "@eslint-community/eslint-utils" "^4.7.0" + "@typescript-eslint/scope-manager" "8.44.1" + "@typescript-eslint/types" "8.44.1" + "@typescript-eslint/typescript-estree" "8.44.1" -"@typescript-eslint/visitor-keys@7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-7.0.0.tgz#83cdadd193ee735fe9ea541f6a2b4d76dfe62081" - integrity sha512-JZP0uw59PRHp7sHQl3aF/lFgwOW2rgNVnXUksj1d932PMita9wFBd3621vHQRDvHwPsSY9FMAAHVc8gTvLYY4w== +"@typescript-eslint/visitor-keys@8.44.1": + version "8.44.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.44.1.tgz#1d96197a7fcceaba647b3bd6a8594df8dc4deb5a" + integrity sha512-576+u0QD+Jp3tZzvfRfxon0EA2lzcDt3lhUbsC6Lgzy9x2VR4E+JUiNyGHi5T8vk0TV+fpJ5GLG1JsJuWCaKhw== dependencies: - "@typescript-eslint/types" "7.0.0" - eslint-visitor-keys "^3.4.1" + "@typescript-eslint/types" "8.44.1" + eslint-visitor-keys "^4.2.1" "@typespec/ts-http-runtime@^0.3.0": version "0.3.0" @@ -1246,9 +1261,106 @@ tslib "^2.6.2" "@ungap/structured-clone@^1.2.0": - version "1.2.0" - resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406" - integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== + version "1.3.0" + resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8" + integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g== + +"@unrs/resolver-binding-android-arm-eabi@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz#9f5b04503088e6a354295e8ea8fe3cb99e43af81" + integrity sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw== + +"@unrs/resolver-binding-android-arm64@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz#7414885431bd7178b989aedc4d25cccb3865bc9f" + integrity sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g== + +"@unrs/resolver-binding-darwin-arm64@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz#b4a8556f42171fb9c9f7bac8235045e82aa0cbdf" + integrity sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g== + +"@unrs/resolver-binding-darwin-x64@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz#fd4d81257b13f4d1a083890a6a17c00de571f0dc" + integrity sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ== + +"@unrs/resolver-binding-freebsd-x64@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz#d2513084d0f37c407757e22f32bd924a78cfd99b" + integrity sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw== + +"@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz#844d2605d057488d77fab09705f2866b86164e0a" + integrity sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw== + +"@unrs/resolver-binding-linux-arm-musleabihf@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz#204892995cefb6bd1d017d52d097193bc61ddad3" + integrity sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw== + +"@unrs/resolver-binding-linux-arm64-gnu@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz#023eb0c3aac46066a10be7a3f362e7b34f3bdf9d" + integrity sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ== + +"@unrs/resolver-binding-linux-arm64-musl@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz#9e6f9abb06424e3140a60ac996139786f5d99be0" + integrity sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w== + +"@unrs/resolver-binding-linux-ppc64-gnu@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz#b111417f17c9d1b02efbec8e08398f0c5527bb44" + integrity sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA== + +"@unrs/resolver-binding-linux-riscv64-gnu@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz#92ffbf02748af3e99873945c9a8a5ead01d508a9" + integrity sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ== + +"@unrs/resolver-binding-linux-riscv64-musl@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz#0bec6f1258fc390e6b305e9ff44256cb207de165" + integrity sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew== + +"@unrs/resolver-binding-linux-s390x-gnu@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz#577843a084c5952f5906770633ccfb89dac9bc94" + integrity sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg== + +"@unrs/resolver-binding-linux-x64-gnu@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz#36fb318eebdd690f6da32ac5e0499a76fa881935" + integrity sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w== + +"@unrs/resolver-binding-linux-x64-musl@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz#bfb9af75f783f98f6a22c4244214efe4df1853d6" + integrity sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA== + +"@unrs/resolver-binding-wasm32-wasi@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz#752c359dd875684b27429500d88226d7cc72f71d" + integrity sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ== + dependencies: + "@napi-rs/wasm-runtime" "^0.2.11" + +"@unrs/resolver-binding-win32-arm64-msvc@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz#ce5735e600e4c2fbb409cd051b3b7da4a399af35" + integrity sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw== + +"@unrs/resolver-binding-win32-ia32-msvc@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz#72fc57bc7c64ec5c3de0d64ee0d1810317bc60a6" + integrity sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ== + +"@unrs/resolver-binding-win32-x64-msvc@1.11.1": + version "1.11.1" + resolved "https://registry.yarnpkg.com/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz#538b1e103bf8d9864e7b85cc96fa8d6fb6c40777" + integrity sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g== "@vitest/coverage-v8@^3.2.4": version "3.2.4" @@ -1776,54 +1888,60 @@ array-buffer-byte-length@^1.0.1: call-bind "^1.0.5" is-array-buffer "^3.0.4" -array-includes@^3.1.8: - version "3.1.8" - resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.8.tgz#5e370cbe172fdd5dd6530c1d4aadda25281ba97d" - integrity sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ== +array-buffer-byte-length@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz#384d12a37295aec3769ab022ad323a18a51ccf8b" + integrity sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw== dependencies: - call-bind "^1.0.7" - define-properties "^1.2.1" - es-abstract "^1.23.2" - es-object-atoms "^1.0.0" - get-intrinsic "^1.2.4" - is-string "^1.0.7" + call-bound "^1.0.3" + is-array-buffer "^3.0.5" -array-union@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" - integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== +array-includes@^3.1.9: + version "3.1.9" + resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.9.tgz#1f0ccaa08e90cdbc3eb433210f903ad0f17c3f3a" + integrity sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.4" + define-properties "^1.2.1" + es-abstract "^1.24.0" + es-object-atoms "^1.1.1" + get-intrinsic "^1.3.0" + is-string "^1.1.1" + math-intrinsics "^1.1.0" -array.prototype.findlastindex@^1.2.5: - version "1.2.5" - resolved "https://registry.yarnpkg.com/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz#8c35a755c72908719453f87145ca011e39334d0d" - integrity sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ== +array.prototype.findlastindex@^1.2.6: + version "1.2.6" + resolved "https://registry.yarnpkg.com/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz#cfa1065c81dcb64e34557c9b81d012f6a421c564" + integrity sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ== dependencies: - call-bind "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.4" define-properties "^1.2.1" - es-abstract "^1.23.2" + es-abstract "^1.23.9" es-errors "^1.3.0" - es-object-atoms "^1.0.0" - es-shim-unscopables "^1.0.2" + es-object-atoms "^1.1.1" + es-shim-unscopables "^1.1.0" -array.prototype.flat@^1.3.2: - version "1.3.2" - resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz#1476217df8cff17d72ee8f3ba06738db5b387d18" - integrity sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA== +array.prototype.flat@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz#534aaf9e6e8dd79fb6b9a9917f839ef1ec63afe5" + integrity sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg== dependencies: - call-bind "^1.0.2" - define-properties "^1.2.0" - es-abstract "^1.22.1" - es-shim-unscopables "^1.0.0" + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.5" + es-shim-unscopables "^1.0.2" -array.prototype.flatmap@^1.3.2: - version "1.3.2" - resolved "https://registry.yarnpkg.com/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz#c9a7c6831db8e719d6ce639190146c24bbd3e527" - integrity sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ== +array.prototype.flatmap@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz#712cc792ae70370ae40586264629e33aab5dd38b" + integrity sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg== dependencies: - call-bind "^1.0.2" - define-properties "^1.2.0" - es-abstract "^1.22.1" - es-shim-unscopables "^1.0.0" + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.5" + es-shim-unscopables "^1.0.2" arraybuffer.prototype.slice@^1.0.2: version "1.0.2" @@ -1852,6 +1970,19 @@ arraybuffer.prototype.slice@^1.0.3: is-array-buffer "^3.0.4" is-shared-array-buffer "^1.0.2" +arraybuffer.prototype.slice@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz#9d760d84dbdd06d0cbf92c8849615a1a7ab3183c" + integrity sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ== + dependencies: + array-buffer-byte-length "^1.0.1" + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.5" + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + is-array-buffer "^3.0.4" + assertion-error@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/assertion-error/-/assertion-error-2.0.1.tgz#f641a196b335690b1070bf00b6e7593fec190bf7" @@ -1883,6 +2014,11 @@ astral-regex@^2.0.0: resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== +async-function@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/async-function/-/async-function-1.0.0.tgz#509c9fca60eaf85034c6829838188e4e4c8ffb2b" + integrity sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA== + asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" @@ -2107,7 +2243,7 @@ caching-transform@^4.0.0: package-hash "^4.0.0" write-file-atomic "^3.0.0" -call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: +call-bind-apply-helpers@^1.0.0, call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== @@ -2143,6 +2279,24 @@ call-bind@^1.0.6, call-bind@^1.0.7: get-intrinsic "^1.2.4" set-function-length "^1.2.1" +call-bind@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.8.tgz#0736a9660f537e3388826f440d5ec45f744eaa4c" + integrity sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww== + dependencies: + call-bind-apply-helpers "^1.0.0" + es-define-property "^1.0.0" + get-intrinsic "^1.2.4" + set-function-length "^1.2.2" + +call-bound@^1.0.2, call-bound@^1.0.3, call-bound@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" + integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== + dependencies: + call-bind-apply-helpers "^1.0.2" + get-intrinsic "^1.3.0" + callsites@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -2471,7 +2625,16 @@ cross-spawn@^6.0.5: shebang-command "^1.2.0" which "^1.2.9" -cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3, cross-spawn@^7.0.6: +cross-spawn@^7.0.0, cross-spawn@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" + integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +cross-spawn@^7.0.2, cross-spawn@^7.0.6: version "7.0.6" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== @@ -2510,6 +2673,15 @@ data-view-buffer@^1.0.1: es-errors "^1.3.0" is-data-view "^1.0.1" +data-view-buffer@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.2.tgz#211a03ba95ecaf7798a8c7198d79536211f88570" + integrity sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + is-data-view "^1.0.2" + data-view-byte-length@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz#90721ca95ff280677eb793749fce1011347669e2" @@ -2519,6 +2691,15 @@ data-view-byte-length@^1.0.1: es-errors "^1.3.0" is-data-view "^1.0.1" +data-view-byte-length@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz#9e80f7ca52453ce3e93d25a35318767ea7704735" + integrity sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + is-data-view "^1.0.2" + data-view-byte-offset@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz#5e0bbfb4828ed2d1b9b400cd8a7d119bca0ff18a" @@ -2528,6 +2709,15 @@ data-view-byte-offset@^1.0.0: es-errors "^1.3.0" is-data-view "^1.0.1" +data-view-byte-offset@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz#068307f9b71ab76dbbe10291389e020856606191" + integrity sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + is-data-view "^1.0.1" + date-fns@^3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-3.6.0.tgz#f20ca4fe94f8b754951b24240676e8618c0206bf" @@ -2694,13 +2884,6 @@ diff@^7.0.0: resolved "https://registry.yarnpkg.com/diff/-/diff-7.0.0.tgz#3fb34d387cd76d803f6eebea67b921dab0182a9a" integrity sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw== -dir-glob@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" - integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA== - dependencies: - path-type "^4.0.0" - doctrine@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" @@ -2745,7 +2928,7 @@ domutils@^3.0.1: domelementtype "^2.3.0" domhandler "^5.0.1" -dunder-proto@^1.0.1: +dunder-proto@^1.0.0, dunder-proto@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== @@ -2977,6 +3160,66 @@ es-abstract@^1.23.0, es-abstract@^1.23.2: unbox-primitive "^1.0.2" which-typed-array "^1.1.15" +es-abstract@^1.23.5, es-abstract@^1.23.9, es-abstract@^1.24.0: + version "1.24.0" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.24.0.tgz#c44732d2beb0acc1ed60df840869e3106e7af328" + integrity sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg== + dependencies: + array-buffer-byte-length "^1.0.2" + arraybuffer.prototype.slice "^1.0.4" + available-typed-arrays "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.4" + data-view-buffer "^1.0.2" + data-view-byte-length "^1.0.2" + data-view-byte-offset "^1.0.1" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + es-set-tostringtag "^2.1.0" + es-to-primitive "^1.3.0" + function.prototype.name "^1.1.8" + get-intrinsic "^1.3.0" + get-proto "^1.0.1" + get-symbol-description "^1.1.0" + globalthis "^1.0.4" + gopd "^1.2.0" + has-property-descriptors "^1.0.2" + has-proto "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + internal-slot "^1.1.0" + is-array-buffer "^3.0.5" + is-callable "^1.2.7" + is-data-view "^1.0.2" + is-negative-zero "^2.0.3" + is-regex "^1.2.1" + is-set "^2.0.3" + is-shared-array-buffer "^1.0.4" + is-string "^1.1.1" + is-typed-array "^1.1.15" + is-weakref "^1.1.1" + math-intrinsics "^1.1.0" + object-inspect "^1.13.4" + object-keys "^1.1.1" + object.assign "^4.1.7" + own-keys "^1.0.1" + regexp.prototype.flags "^1.5.4" + safe-array-concat "^1.1.3" + safe-push-apply "^1.0.0" + safe-regex-test "^1.1.0" + set-proto "^1.0.0" + stop-iteration-iterator "^1.1.0" + string.prototype.trim "^1.2.10" + string.prototype.trimend "^1.0.9" + string.prototype.trimstart "^1.0.8" + typed-array-buffer "^1.0.3" + typed-array-byte-length "^1.0.3" + typed-array-byte-offset "^1.0.4" + typed-array-length "^1.0.7" + unbox-primitive "^1.1.0" + which-typed-array "^1.1.19" + es-define-property@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845" @@ -3041,13 +3284,6 @@ es-set-tostringtag@^2.1.0: has-tostringtag "^1.0.2" hasown "^2.0.2" -es-shim-unscopables@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.0.0.tgz#702e632193201e3edf8713635d083d378e510241" - integrity sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w== - dependencies: - has "^1.0.3" - es-shim-unscopables@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz#1f6942e71ecc7835ed1c8a83006d8771a63a3763" @@ -3055,6 +3291,13 @@ es-shim-unscopables@^1.0.2: dependencies: hasown "^2.0.0" +es-shim-unscopables@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz#438df35520dac5d105f3943d927549ea3b00f4b5" + integrity sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw== + dependencies: + hasown "^2.0.2" + es-to-primitive@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a" @@ -3064,6 +3307,15 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" +es-to-primitive@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.3.0.tgz#96c89c82cc49fd8794a24835ba3e1ff87f214e18" + integrity sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g== + dependencies: + is-callable "^1.2.7" + is-date-object "^1.0.5" + is-symbol "^1.0.4" + es6-error@^4.0.1: version "4.1.1" resolved "https://registry.yarnpkg.com/es6-error/-/es6-error-4.1.1.tgz#9e3af407459deed47e9a91f9b885a84eb05c561d" @@ -3127,16 +3379,24 @@ escodegen@^2.1.0: optionalDependencies: source-map "~0.6.1" -eslint-config-prettier@^9.1.0: - version "9.1.0" - resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz#31af3d94578645966c082fcb71a5846d3c94867f" - integrity sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw== +eslint-config-prettier@^10.1.8: + version "10.1.8" + resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz#15734ce4af8c2778cc32f0b01b37b0b5cd1ecb97" + integrity sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w== eslint-fix-utils@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/eslint-fix-utils/-/eslint-fix-utils-0.4.0.tgz#e1085b4f94f41e7448a80b774d8ed5cbbe7f7e31" integrity sha512-nCEciwqByGxsKiWqZjqK7xfL+7dUX9Pi0UL3J0tOwfxVN9e6Y59UxEt1ZYsc3XH0ce6T1WQM/QU2DbKK/6IG7g== +eslint-import-context@^0.1.8: + version "0.1.9" + resolved "https://registry.yarnpkg.com/eslint-import-context/-/eslint-import-context-0.1.9.tgz#967b0b2f0a90ef4b689125e088f790f0b7756dbe" + integrity sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg== + dependencies: + get-tsconfig "^4.10.1" + stable-hash-x "^0.2.0" + eslint-import-resolver-node@^0.3.9: version "0.3.9" resolved "https://registry.yarnpkg.com/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz#d4eaac52b8a2e7c3cd1903eb00f7e053356118ac" @@ -3146,36 +3406,49 @@ eslint-import-resolver-node@^0.3.9: is-core-module "^2.13.0" resolve "^1.22.4" -eslint-module-utils@^2.12.0: - version "2.12.0" - resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz#fe4cfb948d61f49203d7b08871982b65b9af0b0b" - integrity sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg== +eslint-import-resolver-typescript@^4.4.4: + version "4.4.4" + resolved "https://registry.yarnpkg.com/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.4.tgz#3e83a9c25f4a053fe20e1b07b47e04e8519a8720" + integrity sha512-1iM2zeBvrYmUNTj2vSC/90JTHDth+dfOfiNKkxApWRsTJYNrc8rOdxxIf5vazX+BiAXTeOT0UvWpGI/7qIWQOw== + dependencies: + debug "^4.4.1" + eslint-import-context "^0.1.8" + get-tsconfig "^4.10.1" + is-bun-module "^2.0.0" + stable-hash-x "^0.2.0" + tinyglobby "^0.2.14" + unrs-resolver "^1.7.11" + +eslint-module-utils@^2.12.1: + version "2.12.1" + resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz#f76d3220bfb83c057651359295ab5854eaad75ff" + integrity sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw== dependencies: debug "^3.2.7" -eslint-plugin-import@^2.31.0: - version "2.31.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz#310ce7e720ca1d9c0bb3f69adfd1c6bdd7d9e0e7" - integrity sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A== +eslint-plugin-import@^2.32.0: + version "2.32.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz#602b55faa6e4caeaa5e970c198b5c00a37708980" + integrity sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA== dependencies: "@rtsao/scc" "^1.1.0" - array-includes "^3.1.8" - array.prototype.findlastindex "^1.2.5" - array.prototype.flat "^1.3.2" - array.prototype.flatmap "^1.3.2" + array-includes "^3.1.9" + array.prototype.findlastindex "^1.2.6" + array.prototype.flat "^1.3.3" + array.prototype.flatmap "^1.3.3" debug "^3.2.7" doctrine "^2.1.0" eslint-import-resolver-node "^0.3.9" - eslint-module-utils "^2.12.0" + eslint-module-utils "^2.12.1" hasown "^2.0.2" - is-core-module "^2.15.1" + is-core-module "^2.16.1" is-glob "^4.0.3" minimatch "^3.1.2" object.fromentries "^2.0.8" object.groupby "^1.0.3" - object.values "^1.2.0" + object.values "^1.2.1" semver "^6.3.1" - string.prototype.trimend "^1.0.8" + string.prototype.trimend "^1.0.9" tsconfig-paths "^3.15.0" eslint-plugin-md@^1.0.19: @@ -3243,11 +3516,16 @@ eslint-visitor-keys@^1.1.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz#30ebd1ef7c2fdff01c3a4f151044af25fab0523e" integrity sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ== -eslint-visitor-keys@^3.0.0, eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.3: +eslint-visitor-keys@^3.0.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.3: version "3.4.3" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== +eslint-visitor-keys@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1" + integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ== + eslint@^6.8.0: version "6.8.0" resolved "https://registry.yarnpkg.com/eslint/-/eslint-6.8.0.tgz#62262d6729739f9275723824302fb227c8c93ffb" @@ -3358,13 +3636,20 @@ esprima@^4.0.0, esprima@^4.0.1: resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== -esquery@^1.0.1, esquery@^1.4.2: +esquery@^1.0.1: version "1.5.0" resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.5.0.tgz#6ce17738de8577694edd7361c57182ac8cb0db0b" integrity sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg== dependencies: estraverse "^5.1.0" +esquery@^1.4.2: + version "1.6.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.6.0.tgz#91419234f804d852a82dceec3e16cdc22cf9dae7" + integrity sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg== + dependencies: + estraverse "^5.1.0" + esrecurse@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" @@ -3445,18 +3730,7 @@ fast-diff@^1.1.2: resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03" integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w== -fast-glob@^3.2.9: - version "3.2.12" - resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80" - integrity sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w== - dependencies: - "@nodelib/fs.stat" "^2.0.2" - "@nodelib/fs.walk" "^1.2.3" - glob-parent "^5.1.2" - merge2 "^1.3.0" - micromatch "^4.0.4" - -fast-glob@^3.3.3: +fast-glob@^3.3.2, fast-glob@^3.3.3: version "3.3.3" resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818" integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg== @@ -3589,11 +3863,12 @@ flat-cache@^2.0.1: write "1.0.3" flat-cache@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.0.4.tgz#61b0338302b2fe9f957dcc32fc2a87f1c3048b11" - integrity sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg== + version "3.2.0" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.2.0.tgz#2c0c2d5040c99b1632771a9d105725c0115363ee" + integrity sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw== dependencies: - flatted "^3.1.0" + flatted "^3.2.9" + keyv "^4.5.3" rimraf "^3.0.2" flat@^5.0.2: @@ -3606,10 +3881,10 @@ flatted@^2.0.0: resolved "https://registry.yarnpkg.com/flatted/-/flatted-2.0.2.tgz#4575b21e2bcee7434aa9be662f4b7b5f9c2b5138" integrity sha512-r5wGx7YeOwNWNlCA0wQ86zKyDLMQr+/RB8xy74M4hTphfmjlijTSSXGuH8rnvKZnfT9i+75zmd8jcKdMR4O6jA== -flatted@^3.1.0: - version "3.2.7" - resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.7.tgz#609f39207cb614b89d0765b477cb2d437fbf9787" - integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ== +flatted@^3.2.9: + version "3.3.3" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.3.tgz#67c8fad95454a7c7abebf74bb78ee74a44023358" + integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg== follow-redirects@^1.15.6: version "1.15.6" @@ -3623,6 +3898,13 @@ for-each@^0.3.3: dependencies: is-callable "^1.1.3" +for-each@^0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.5.tgz#d650688027826920feeb0af747ee7b9421a41d47" + integrity sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg== + dependencies: + is-callable "^1.2.7" + foreground-child@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-2.0.0.tgz#71b32800c9f15aa8f2f83f4a6bd9bff35d861a53" @@ -3736,6 +4018,18 @@ function.prototype.name@^1.1.6: es-abstract "^1.22.1" functions-have-names "^1.2.3" +function.prototype.name@^1.1.8: + version "1.1.8" + resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.8.tgz#e68e1df7b259a5c949eeef95cdbde53edffabb78" + integrity sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + define-properties "^1.2.1" + functions-have-names "^1.2.3" + hasown "^2.0.2" + is-callable "^1.2.7" + functional-red-black-tree@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" @@ -3802,7 +4096,7 @@ get-intrinsic@^1.2.3, get-intrinsic@^1.2.4: has-symbols "^1.0.3" hasown "^2.0.0" -get-intrinsic@^1.2.6: +get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.2.7, get-intrinsic@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== @@ -3823,7 +4117,7 @@ get-package-type@^0.1.0: resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== -get-proto@^1.0.1: +get-proto@^1.0.0, get-proto@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== @@ -3848,6 +4142,22 @@ get-symbol-description@^1.0.2: es-errors "^1.3.0" get-intrinsic "^1.2.4" +get-symbol-description@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.1.0.tgz#7bdd54e0befe8ffc9f3b4e203220d9f1e881b6ee" + integrity sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + +get-tsconfig@^4.10.1: + version "4.10.1" + resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.10.1.tgz#d34c1c01f47d65a606c37aa7a177bc3e56ab4b2e" + integrity sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ== + dependencies: + resolve-pkg-maps "^1.0.0" + get-uri@^6.0.1: version "6.0.3" resolved "https://registry.yarnpkg.com/get-uri/-/get-uri-6.0.3.tgz#0d26697bc13cf91092e519aa63aa60ee5b6f385a" @@ -3941,9 +4251,9 @@ globals@^12.1.0: type-fest "^0.8.1" globals@^13.19.0: - version "13.22.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-13.22.0.tgz#0c9fcb9c48a2494fbb5edbfee644285543eba9d8" - integrity sha512-H1Ddc/PbZHTDVJSnj8kWptIRSD6AM3pK+mKytuIVF4uoBV7rshFlhhvA58ceJ5wp3Er58w6zj7bykMpYXt3ETw== + version "13.24.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-13.24.0.tgz#8432a19d78ce0c1e833949c36adb345400bb1171" + integrity sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ== dependencies: type-fest "^0.20.2" @@ -3954,17 +4264,13 @@ globalthis@^1.0.3: dependencies: define-properties "^1.1.3" -globby@^11.1.0: - version "11.1.0" - resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" - integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== +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== dependencies: - array-union "^2.1.0" - dir-glob "^3.0.1" - fast-glob "^3.2.9" - ignore "^5.2.0" - merge2 "^1.4.1" - slash "^3.0.0" + define-properties "^1.2.1" + gopd "^1.0.1" globby@^14.1.0: version "14.1.0" @@ -4046,6 +4352,13 @@ has-proto@^1.0.3: resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.3.tgz#b31ddfe9b0e6e9914536a6ab286426d0214f77fd" integrity sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q== +has-proto@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.2.0.tgz#5de5a6eabd95fdffd9818b43055e8065e39fe9d5" + integrity sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ== + dependencies: + dunder-proto "^1.0.0" + has-symbols@^1.0.2, has-symbols@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" @@ -4181,12 +4494,12 @@ ignore@^4.0.6: resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg== -ignore@^5.2.0, ignore@^5.2.4: +ignore@^5.2.0: version "5.2.4" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.4.tgz#a291c0c6178ff1b960befe47fcdec301674a6324" integrity sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ== -ignore@^7.0.3: +ignore@^7.0.0, ignore@^7.0.3: version "7.0.5" resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.5.tgz#4cb5f6cd7d4c7ab0365738c7aea888baa6d7efd9" integrity sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg== @@ -4282,6 +4595,15 @@ internal-slot@^1.0.7: hasown "^2.0.0" side-channel "^1.0.4" +internal-slot@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.1.0.tgz#1eac91762947d2f7056bc838d93e13b2e9604961" + integrity sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw== + dependencies: + es-errors "^1.3.0" + hasown "^2.0.2" + side-channel "^1.1.0" + interpret@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/interpret/-/interpret-3.1.1.tgz#5be0ceed67ca79c6c4bc5cf0d7ee843dcea110c4" @@ -4344,6 +4666,26 @@ is-array-buffer@^3.0.4: call-bind "^1.0.2" get-intrinsic "^1.2.1" +is-array-buffer@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz#65742e1e687bd2cc666253068fd8707fe4d44280" + integrity sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + get-intrinsic "^1.2.6" + +is-async-function@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-async-function/-/is-async-function-2.1.1.tgz#3e69018c8e04e73b738793d020bfe884b9fd3523" + integrity sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ== + dependencies: + async-function "^1.0.0" + call-bound "^1.0.3" + get-proto "^1.0.1" + has-tostringtag "^1.0.2" + safe-regex-test "^1.1.0" + is-bigint@^1.0.1: version "1.0.4" resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.4.tgz#08147a1875bc2b32005d41ccd8291dffc6691df3" @@ -4351,6 +4693,13 @@ is-bigint@^1.0.1: dependencies: has-bigints "^1.0.1" +is-bigint@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.1.0.tgz#dda7a3445df57a42583db4228682eba7c4170672" + integrity sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ== + dependencies: + has-bigints "^1.0.2" + is-binary-path@~2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" @@ -4366,23 +4715,45 @@ is-boolean-object@^1.1.0: call-bind "^1.0.2" has-tostringtag "^1.0.0" +is-boolean-object@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.2.2.tgz#7067f47709809a393c71ff5bb3e135d8a9215d9e" + integrity sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A== + dependencies: + call-bound "^1.0.3" + has-tostringtag "^1.0.2" + is-buffer@^2.0.0: version "2.0.5" resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.5.tgz#ebc252e400d22ff8d77fa09888821a24a658c191" integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ== +is-bun-module@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-bun-module/-/is-bun-module-2.0.0.tgz#4d7859a87c0fcac950c95e666730e745eae8bddd" + integrity sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ== + dependencies: + semver "^7.7.1" + is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.7: version "1.2.7" resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.7.tgz#3bc2a85ea742d9e36205dcacdd72ca1fdc51b055" integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== -is-core-module@^2.13.0, is-core-module@^2.15.1, is-core-module@^2.9.0: +is-core-module@^2.13.0, is-core-module@^2.9.0: version "2.15.1" resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.15.1.tgz#a7363a25bee942fefab0de13bf6aa372c82dcc37" integrity sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ== dependencies: hasown "^2.0.2" +is-core-module@^2.16.1: + version "2.16.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" + integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w== + dependencies: + hasown "^2.0.2" + is-data-view@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/is-data-view/-/is-data-view-1.0.1.tgz#4b4d3a511b70f3dc26d42c03ca9ca515d847759f" @@ -4390,6 +4761,15 @@ is-data-view@^1.0.1: dependencies: is-typed-array "^1.1.13" +is-data-view@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-data-view/-/is-data-view-1.0.2.tgz#bae0a41b9688986c2188dda6657e56b8f9e63b8e" + integrity sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw== + dependencies: + call-bound "^1.0.2" + get-intrinsic "^1.2.6" + is-typed-array "^1.1.13" + is-date-object@^1.0.1: version "1.0.5" resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.5.tgz#0841d5536e724c25597bf6ea62e1bd38298df31f" @@ -4397,6 +4777,14 @@ is-date-object@^1.0.1: dependencies: has-tostringtag "^1.0.0" +is-date-object@^1.0.5, is-date-object@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.1.0.tgz#ad85541996fc7aa8b2729701d27b7319f95d82f7" + integrity sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg== + dependencies: + call-bound "^1.0.2" + has-tostringtag "^1.0.2" + is-decimal@^1.0.0, is-decimal@^1.0.2: version "1.0.4" resolved "https://registry.yarnpkg.com/is-decimal/-/is-decimal-1.0.4.tgz#65a3a5958a1c5b63a706e1b333d7cd9f630d3fa5" @@ -4412,6 +4800,13 @@ is-extglob@^2.1.1: resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== +is-finalizationregistry@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz#eefdcdc6c94ddd0674d9c85887bf93f944a97c90" + integrity sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg== + dependencies: + call-bound "^1.0.3" + is-fullwidth-code-point@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz#a3b30a5c4f199183167aaab93beefae3ddfb654f" @@ -4422,6 +4817,16 @@ is-fullwidth-code-point@^3.0.0: resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== +is-generator-function@^1.0.10: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.1.0.tgz#bf3eeda931201394f57b5dba2800f91a238309ca" + integrity sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ== + dependencies: + call-bound "^1.0.3" + get-proto "^1.0.0" + has-tostringtag "^1.0.2" + safe-regex-test "^1.1.0" + is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: version "4.0.3" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" @@ -4446,6 +4851,11 @@ is-interactive@^2.0.0: resolved "https://registry.yarnpkg.com/is-interactive/-/is-interactive-2.0.0.tgz#40c57614593826da1100ade6059778d597f16e90" integrity sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ== +is-map@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e" + integrity sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw== + is-negative-zero@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.2.tgz#7bf6f03a28003b8b3965de3ac26f664d765f3150" @@ -4463,6 +4873,14 @@ is-number-object@^1.0.4: dependencies: has-tostringtag "^1.0.0" +is-number-object@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.1.1.tgz#144b21e95a1bc148205dcc2814a9134ec41b2541" + integrity sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw== + dependencies: + call-bound "^1.0.3" + has-tostringtag "^1.0.2" + is-number@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" @@ -4498,6 +4916,21 @@ is-regex@^1.1.4: call-bind "^1.0.2" has-tostringtag "^1.0.0" +is-regex@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.2.1.tgz#76d70a3ed10ef9be48eb577887d74205bf0cad22" + integrity sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g== + dependencies: + call-bound "^1.0.2" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + +is-set@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.3.tgz#8ab209ea424608141372ded6e0cb200ef1d9d01d" + integrity sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg== + is-shared-array-buffer@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz#8f259c573b60b6a32d4058a1a07430c0a7344c79" @@ -4512,6 +4945,13 @@ is-shared-array-buffer@^1.0.3: dependencies: call-bind "^1.0.7" +is-shared-array-buffer@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz#9b67844bd9b7f246ba0708c3a93e34269c774f6f" + integrity sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A== + dependencies: + call-bound "^1.0.3" + is-stream@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" @@ -4524,6 +4964,14 @@ is-string@^1.0.5, is-string@^1.0.7: dependencies: has-tostringtag "^1.0.0" +is-string@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.1.1.tgz#92ea3f3d5c5b6e039ca8677e5ac8d07ea773cbb9" + integrity sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA== + dependencies: + call-bound "^1.0.3" + has-tostringtag "^1.0.2" + is-symbol@^1.0.2, is-symbol@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.4.tgz#a6dac93b635b063ca6872236de88910a57af139c" @@ -4531,6 +4979,15 @@ is-symbol@^1.0.2, is-symbol@^1.0.3: dependencies: has-symbols "^1.0.2" +is-symbol@^1.0.4, is-symbol@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.1.1.tgz#f47761279f532e2b05a7024a7506dbbedacd0634" + integrity sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w== + dependencies: + call-bound "^1.0.2" + has-symbols "^1.1.0" + safe-regex-test "^1.1.0" + is-typed-array@^1.1.10, is-typed-array@^1.1.9: version "1.1.10" resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.10.tgz#36a5b5cb4189b575d1a3e4b08536bfb485801e3f" @@ -4556,6 +5013,13 @@ is-typed-array@^1.1.13: dependencies: which-typed-array "^1.1.14" +is-typed-array@^1.1.14, is-typed-array@^1.1.15: + version "1.1.15" + resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.15.tgz#4bfb4a45b61cee83a5a46fba778e4e8d59c0ce0b" + integrity sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ== + dependencies: + which-typed-array "^1.1.16" + is-typedarray@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" @@ -4576,6 +5040,11 @@ is-unicode-supported@^2.0.0: resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz#09f0ab0de6d3744d48d265ebb98f65d11f2a9b3a" integrity sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ== +is-weakmap@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/is-weakmap/-/is-weakmap-2.0.2.tgz#bf72615d649dfe5f699079c54b83e47d1ae19cfd" + integrity sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w== + is-weakref@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.0.2.tgz#9529f383a9338205e89765e0392efc2f100f06f2" @@ -4583,6 +5052,21 @@ is-weakref@^1.0.2: dependencies: call-bind "^1.0.2" +is-weakref@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.1.1.tgz#eea430182be8d64174bd96bffbc46f21bf3f9293" + integrity sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew== + dependencies: + call-bound "^1.0.3" + +is-weakset@^2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/is-weakset/-/is-weakset-2.0.4.tgz#c9f5deb0bc1906c6d6f1027f284ddf459249daca" + integrity sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ== + dependencies: + call-bound "^1.0.3" + get-intrinsic "^1.2.6" + is-whitespace-character@^1.0.0: version "1.0.4" resolved "https://registry.yarnpkg.com/is-whitespace-character/-/is-whitespace-character-1.0.4.tgz#0858edd94a95594c7c9dd0b5c174ec6e45ee4aa7" @@ -4794,6 +5278,11 @@ jsesc@^3.0.2: resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.0.2.tgz#bb8b09a6597ba426425f2e4a07245c3d00b9343e" integrity sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g== +json-buffer@3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" + integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== + json-parse-even-better-errors@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" @@ -4827,9 +5316,9 @@ json5@^2.2.2, json5@^2.2.3: integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== jsonc-eslint-parser@^2.4.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/jsonc-eslint-parser/-/jsonc-eslint-parser-2.4.0.tgz#74ded53f9d716e8d0671bd167bf5391f452d5461" - integrity sha512-WYDyuc/uFcGp6YtM2H0uKmUwieOuzeE/5YocFJLnLfclZ4inf3mRn8ZVy1s7Hxji7Jxm6Ss8gqpexD/GlKoGgg== + 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== dependencies: acorn "^8.5.0" eslint-visitor-keys "^3.0.0" @@ -4901,6 +5390,13 @@ keytar@^7.7.0: node-addon-api "^4.3.0" prebuild-install "^7.0.1" +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== + dependencies: + json-buffer "3.0.1" + kind-of@^6.0.2: version "6.0.3" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" @@ -5120,7 +5616,7 @@ markdown-escapes@^1.0.0: resolved "https://registry.yarnpkg.com/markdown-escapes/-/markdown-escapes-1.0.4.tgz#c95415ef451499d7602b91095f3c8e8975f78535" integrity sha512-8z4efJYk43E0upd0NbVXwgSTQs6cT3T06etieCMEg7dRbzCbxUCK/GHlX8mhHRDcp+OLlHkPKsvqQTCvsRl2cg== -markdown-eslint-parser@^1.2.0: +markdown-eslint-parser@^1.2.0, markdown-eslint-parser@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/markdown-eslint-parser/-/markdown-eslint-parser-1.2.1.tgz#adea20fd36d08c593a446b39418df0e393eda716" integrity sha512-ImxZH4YUT1BsYrusLPL8tWSZYUN4EZSjaSNL7KC8nsAYWavUgcK/Y1CuufbbkoSlqzv/tjFYLpyxcsaxo97dEA== @@ -5193,12 +5689,12 @@ merge-stream@^2.0.0: resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== -merge2@^1.3.0, merge2@^1.4.1: +merge2@^1.3.0: version "1.4.1" resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== -micromatch@^4.0.0, micromatch@^4.0.4, micromatch@^4.0.8: +micromatch@^4.0.0, micromatch@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== @@ -5238,13 +5734,6 @@ mimic-response@^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@9.0.3: - version "9.0.3" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.3.tgz#a6e00c3de44c3a542bfaae70abfc22420a6da825" - integrity sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg== - dependencies: - brace-expansion "^2.0.1" - minimatch@^10.0.3: version "10.0.3" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.0.3.tgz#cf7a0314a16c4d9ab73a7730a0e8e3c3502d47aa" @@ -5334,6 +5823,11 @@ napi-build-utils@^1.0.1: resolved "https://registry.yarnpkg.com/napi-build-utils/-/napi-build-utils-1.0.2.tgz#b1fddc0b2c46e380a0b7a76f984dd47c41a13806" integrity sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg== +napi-postinstall@^0.3.0: + version "0.3.3" + resolved "https://registry.yarnpkg.com/napi-postinstall/-/napi-postinstall-0.3.3.tgz#93d045c6b576803ead126711d3093995198c6eb9" + integrity sha512-uTp172LLXSxuSYHv/kou+f6KW3SMppU9ivthaVTXian9sOt3XM/zHYHpRZiLgQoxeWfYUnslNWQHF1+G71xcow== + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" @@ -5460,6 +5954,11 @@ object-inspect@^1.13.1: resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.1.tgz#b96c6109324ccfef6b12216a956ca4dc2ff94bc2" integrity sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ== +object-inspect@^1.13.3, object-inspect@^1.13.4: + version "1.13.4" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" + integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== + object-keys@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" @@ -5485,6 +5984,18 @@ object.assign@^4.1.5: has-symbols "^1.0.3" object-keys "^1.1.1" +object.assign@^4.1.7: + version "4.1.7" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.7.tgz#8c14ca1a424c6a561b0bb2a22f66f5049a945d3d" + integrity sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + has-symbols "^1.1.0" + object-keys "^1.1.1" + object.fromentries@^2.0.8: version "2.0.8" resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.8.tgz#f7195d8a9b97bd95cbc1999ea939ecd1a2b00c65" @@ -5504,12 +6015,13 @@ object.groupby@^1.0.3: define-properties "^1.2.1" es-abstract "^1.23.2" -object.values@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.2.0.tgz#65405a9d92cee68ac2d303002e0b8470a4d9ab1b" - integrity sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ== +object.values@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.2.1.tgz#deed520a50809ff7f75a7cfd4bc64c7a038c6216" + integrity sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA== dependencies: - call-bind "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.3" define-properties "^1.2.1" es-object-atoms "^1.0.0" @@ -5593,6 +6105,15 @@ os-tmpdir@~1.0.2: resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g== +own-keys@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/own-keys/-/own-keys-1.0.1.tgz#e4006910a2bf913585289676eebd6f390cf51358" + integrity sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg== + dependencies: + get-intrinsic "^1.2.6" + object-keys "^1.1.1" + safe-push-apply "^1.0.0" + p-limit@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" @@ -5780,11 +6301,6 @@ path-scurry@^2.0.0: lru-cache "^11.0.0" minipass "^7.1.2" -path-type@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" - integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== - path-type@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-6.0.0.tgz#2f1bb6791a91ce99194caede5d6c5920ed81eb51" @@ -6065,6 +6581,20 @@ rechoir@^0.8.0: dependencies: resolve "^1.20.0" +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" + integrity sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw== + dependencies: + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.9" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + get-intrinsic "^1.2.7" + get-proto "^1.0.1" + which-builtin-type "^1.2.1" + regexp.prototype.flags@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz#90ce989138db209f81492edd734183ce99f9677e" @@ -6084,6 +6614,18 @@ regexp.prototype.flags@^1.5.2: es-errors "^1.3.0" set-function-name "^2.0.1" +regexp.prototype.flags@^1.5.4: + version "1.5.4" + resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz#1ad6c62d44a259007e55b3970e00f746efbcaa19" + integrity sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA== + dependencies: + call-bind "^1.0.8" + define-properties "^1.2.1" + es-errors "^1.3.0" + get-proto "^1.0.1" + gopd "^1.2.0" + set-function-name "^2.0.2" + regexpp@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-2.0.1.tgz#8d19d31cf632482b589049f8281f93dbcba4d07f" @@ -6692,6 +7234,11 @@ resolve-from@^5.0.0: resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== +resolve-pkg-maps@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz#616b3dc2c57056b5588c31cdf4b3d64db133720f" + integrity sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw== + resolve@^1.20.0: version "1.22.1" resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177" @@ -6826,6 +7373,17 @@ safe-array-concat@^1.1.2: has-symbols "^1.0.3" isarray "^2.0.5" +safe-array-concat@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.1.3.tgz#c9e54ec4f603b0bbb8e7e5007a5ee7aecd1538c3" + integrity sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.2" + get-intrinsic "^1.2.6" + has-symbols "^1.1.0" + isarray "^2.0.5" + safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" @@ -6836,6 +7394,14 @@ safe-buffer@~5.1.0, safe-buffer@~5.1.1: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== +safe-push-apply@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/safe-push-apply/-/safe-push-apply-1.0.0.tgz#01850e981c1602d398c85081f360e4e6d03d27f5" + integrity sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA== + dependencies: + es-errors "^1.3.0" + isarray "^2.0.5" + safe-regex-test@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.0.0.tgz#793b874d524eb3640d1873aad03596db2d4f2295" @@ -6854,6 +7420,15 @@ safe-regex-test@^1.0.3: es-errors "^1.3.0" is-regex "^1.1.4" +safe-regex-test@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz#7f87dfb67a3150782eaaf18583ff5d1711ac10c1" + integrity sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + is-regex "^1.2.1" + "safer-buffer@>= 2.1.2 < 3": version "2.1.2" resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" @@ -6887,7 +7462,7 @@ secretlint@^10.1.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.2, semver@^7.7.1, semver@^7.7.2: +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== @@ -6915,7 +7490,7 @@ set-function-length@^1.1.1: gopd "^1.0.1" has-property-descriptors "^1.0.1" -set-function-length@^1.2.1: +set-function-length@^1.2.1, set-function-length@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== @@ -6936,7 +7511,7 @@ set-function-name@^2.0.0: functions-have-names "^1.2.3" has-property-descriptors "^1.0.0" -set-function-name@^2.0.1: +set-function-name@^2.0.1, set-function-name@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.2.tgz#16a705c5a0dc2f5e638ca96d8a8cd4e1c2b90985" integrity sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ== @@ -6946,6 +7521,15 @@ set-function-name@^2.0.1: functions-have-names "^1.2.3" has-property-descriptors "^1.0.2" +set-proto@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/set-proto/-/set-proto-1.0.0.tgz#0760dbcff30b2d7e801fd6e19983e56da337565e" + integrity sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw== + dependencies: + dunder-proto "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + setimmediate@^1.0.5, setimmediate@~1.0.4: version "1.0.5" resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" @@ -6982,6 +7566,35 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== +side-channel-list@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad" + integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + +side-channel-map@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" + integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + +side-channel-weakmap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" + integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + side-channel-map "^1.0.1" + side-channel@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf" @@ -6991,6 +7604,17 @@ side-channel@^1.0.4: get-intrinsic "^1.0.2" object-inspect "^1.9.0" +side-channel@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9" + integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + side-channel-list "^1.0.0" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" + siginfo@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/siginfo/-/siginfo-2.0.0.tgz#32e76c70b79724e3bb567cb9d543eb858ccfaf30" @@ -7020,11 +7644,6 @@ simple-get@^4.0.0: once "^1.3.1" simple-concat "^1.0.0" -slash@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" - integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== - slash@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/slash/-/slash-5.1.0.tgz#be3adddcdf09ac38eebe8dcdc7b1a57a75b095ce" @@ -7164,6 +7783,11 @@ sprintf-js@~1.0.2: resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== +stable-hash-x@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/stable-hash-x/-/stable-hash-x-0.2.0.tgz#dfd76bfa5d839a7470125c6a6b3c8b22061793e9" + integrity sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ== + stackback@0.0.2: version "0.0.2" resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b" @@ -7184,6 +7808,14 @@ stdin-discarder@^0.2.2: resolved "https://registry.yarnpkg.com/stdin-discarder/-/stdin-discarder-0.2.2.tgz#390037f44c4ae1a1ae535c5fe38dc3aba8d997be" integrity sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ== +stop-iteration-iterator@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz#f481ff70a548f6124d0312c3aa14cbfa7aa542ad" + integrity sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ== + dependencies: + es-errors "^1.3.0" + internal-slot "^1.1.0" + "string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" @@ -7229,6 +7861,19 @@ string-width@^7.0.0, string-width@^7.2.0: get-east-asian-width "^1.0.0" strip-ansi "^7.1.0" +string.prototype.trim@^1.2.10: + version "1.2.10" + resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz#40b2dd5ee94c959b4dcfb1d65ce72e90da480c81" + integrity sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.2" + define-data-property "^1.1.4" + define-properties "^1.2.1" + es-abstract "^1.23.5" + es-object-atoms "^1.0.0" + has-property-descriptors "^1.0.2" + string.prototype.trim@^1.2.8: version "1.2.8" resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.8.tgz#f9ac6f8af4bd55ddfa8895e6aea92a96395393bd" @@ -7257,6 +7902,16 @@ string.prototype.trimend@^1.0.7, string.prototype.trimend@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" +string.prototype.trimend@^1.0.9: + version "1.0.9" + resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz#62e2731272cd285041b36596054e9f66569b6942" + integrity sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.2" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + string.prototype.trimstart@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.7.tgz#d4cdb44b83a4737ffbac2d406e405d43d0184298" @@ -7402,11 +8057,11 @@ supports-preserve-symlinks-flag@^1.0.0: integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== synckit@^0.11.7: - version "0.11.8" - resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.11.8.tgz#b2aaae998a4ef47ded60773ad06e7cb821f55457" - integrity sha512-+XZ+r1XGIJGeQk3VvXhT6xx/VpbHsRzsTkGgF6E5RX9TTXD0118l87puaEBZ566FhqblC6U0d4XnubznJDm30A== + version "0.11.11" + resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.11.11.tgz#c0b619cf258a97faa209155d9cd1699b5c998cb0" + integrity sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw== dependencies: - "@pkgr/core" "^0.2.4" + "@pkgr/core" "^0.2.9" table@^5.2.3: version "5.4.6" @@ -7609,10 +8264,10 @@ trough@^1.0.0: resolved "https://registry.yarnpkg.com/trough/-/trough-1.0.5.tgz#b8b639cefad7d0bb2abd37d433ff8293efa5f406" integrity sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA== -ts-api-utils@^1.0.1: - version "1.0.3" - resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.0.3.tgz#f12c1c781d04427313dbac808f453f050e54a331" - integrity sha512-wNMeqtMz5NtwpT/UZGY5alT+VoKdSsOOP/kqHFcUW1P/VRhH2wJ48+DN2WwUliNbQ976ETwDL0Ifd2VVvgonvg== +ts-api-utils@^2.1.0: + version "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" @@ -7640,7 +8295,7 @@ tslib@^1.9.0: 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.6.2: +tslib@^2.0.0, tslib@^2.0.1, tslib@^2.2.0, tslib@^2.4.0, tslib@^2.6.2: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== @@ -7709,6 +8364,15 @@ typed-array-buffer@^1.0.2: es-errors "^1.3.0" is-typed-array "^1.1.13" +typed-array-buffer@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz#a72395450a4869ec033fd549371b47af3a2ee536" + integrity sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + is-typed-array "^1.1.14" + typed-array-byte-length@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/typed-array-byte-length/-/typed-array-byte-length-1.0.0.tgz#d787a24a995711611fb2b87a4052799517b230d0" @@ -7730,6 +8394,17 @@ typed-array-byte-length@^1.0.1: has-proto "^1.0.3" is-typed-array "^1.1.13" +typed-array-byte-length@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz#8407a04f7d78684f3d252aa1a143d2b77b4160ce" + integrity sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg== + dependencies: + call-bind "^1.0.8" + for-each "^0.3.3" + gopd "^1.2.0" + has-proto "^1.2.0" + is-typed-array "^1.1.14" + typed-array-byte-offset@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.0.tgz#cbbe89b51fdef9cd6aaf07ad4707340abbc4ea0b" @@ -7753,6 +8428,19 @@ typed-array-byte-offset@^1.0.2: has-proto "^1.0.3" is-typed-array "^1.1.13" +typed-array-byte-offset@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz#ae3698b8ec91a8ab945016108aef00d5bff12355" + integrity sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.8" + for-each "^0.3.3" + gopd "^1.2.0" + has-proto "^1.2.0" + is-typed-array "^1.1.15" + reflect.getprototypeof "^1.0.9" + typed-array-length@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.4.tgz#89d83785e5c4098bec72e08b319651f0eac9c1bb" @@ -7774,6 +8462,18 @@ typed-array-length@^1.0.6: is-typed-array "^1.1.13" possible-typed-array-names "^1.0.0" +typed-array-length@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.7.tgz#ee4deff984b64be1e118b0de8c9c877d5ce73d3d" + integrity sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg== + dependencies: + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + is-typed-array "^1.1.13" + possible-typed-array-names "^1.0.0" + reflect.getprototypeof "^1.0.6" + typed-rest-client@^1.8.4: version "1.8.9" resolved "https://registry.yarnpkg.com/typed-rest-client/-/typed-rest-client-1.8.9.tgz#e560226bcadfe71b0fb5c416b587f8da3b8f92d8" @@ -7815,6 +8515,16 @@ unbox-primitive@^1.0.2: has-symbols "^1.0.3" which-boxed-primitive "^1.0.2" +unbox-primitive@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.1.0.tgz#8d9d2c9edeea8460c7f35033a88867944934d1e2" + integrity sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw== + dependencies: + call-bound "^1.0.3" + has-bigints "^1.0.2" + has-symbols "^1.1.0" + which-boxed-primitive "^1.1.1" + underscore@^1.12.1: version "1.13.6" resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.13.6.tgz#04786a1f589dc6c09f761fc5f45b89e935136441" @@ -7918,6 +8628,33 @@ universalify@^2.0.0: resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== +unrs-resolver@^1.7.11: + version "1.11.1" + resolved "https://registry.yarnpkg.com/unrs-resolver/-/unrs-resolver-1.11.1.tgz#be9cd8686c99ef53ecb96df2a473c64d304048a9" + integrity sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg== + dependencies: + napi-postinstall "^0.3.0" + optionalDependencies: + "@unrs/resolver-binding-android-arm-eabi" "1.11.1" + "@unrs/resolver-binding-android-arm64" "1.11.1" + "@unrs/resolver-binding-darwin-arm64" "1.11.1" + "@unrs/resolver-binding-darwin-x64" "1.11.1" + "@unrs/resolver-binding-freebsd-x64" "1.11.1" + "@unrs/resolver-binding-linux-arm-gnueabihf" "1.11.1" + "@unrs/resolver-binding-linux-arm-musleabihf" "1.11.1" + "@unrs/resolver-binding-linux-arm64-gnu" "1.11.1" + "@unrs/resolver-binding-linux-arm64-musl" "1.11.1" + "@unrs/resolver-binding-linux-ppc64-gnu" "1.11.1" + "@unrs/resolver-binding-linux-riscv64-gnu" "1.11.1" + "@unrs/resolver-binding-linux-riscv64-musl" "1.11.1" + "@unrs/resolver-binding-linux-s390x-gnu" "1.11.1" + "@unrs/resolver-binding-linux-x64-gnu" "1.11.1" + "@unrs/resolver-binding-linux-x64-musl" "1.11.1" + "@unrs/resolver-binding-wasm32-wasi" "1.11.1" + "@unrs/resolver-binding-win32-arm64-msvc" "1.11.1" + "@unrs/resolver-binding-win32-ia32-msvc" "1.11.1" + "@unrs/resolver-binding-win32-x64-msvc" "1.11.1" + unzipper@^0.10.11: version "0.10.11" resolved "https://registry.yarnpkg.com/unzipper/-/unzipper-0.10.11.tgz#0b4991446472cbdb92ee7403909f26c2419c782e" @@ -8173,6 +8910,46 @@ which-boxed-primitive@^1.0.2: is-string "^1.0.5" is-symbol "^1.0.3" +which-boxed-primitive@^1.1.0, which-boxed-primitive@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz#d76ec27df7fa165f18d5808374a5fe23c29b176e" + integrity sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA== + dependencies: + is-bigint "^1.1.0" + is-boolean-object "^1.2.1" + is-number-object "^1.1.1" + is-string "^1.1.1" + is-symbol "^1.1.1" + +which-builtin-type@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/which-builtin-type/-/which-builtin-type-1.2.1.tgz#89183da1b4907ab089a6b02029cc5d8d6574270e" + integrity sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q== + dependencies: + call-bound "^1.0.2" + function.prototype.name "^1.1.6" + has-tostringtag "^1.0.2" + is-async-function "^2.0.0" + is-date-object "^1.1.0" + is-finalizationregistry "^1.1.0" + is-generator-function "^1.0.10" + is-regex "^1.2.1" + is-weakref "^1.0.2" + isarray "^2.0.5" + which-boxed-primitive "^1.1.0" + which-collection "^1.0.2" + which-typed-array "^1.1.16" + +which-collection@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/which-collection/-/which-collection-1.0.2.tgz#627ef76243920a107e7ce8e96191debe4b16c2a0" + integrity sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw== + dependencies: + is-map "^2.0.3" + is-set "^2.0.3" + is-weakmap "^2.0.2" + is-weakset "^2.0.3" + which-module@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.0.tgz#d9ef07dce77b9902b8a3a8fa4b31c3e3f7e6e87a" @@ -8211,6 +8988,19 @@ which-typed-array@^1.1.14, which-typed-array@^1.1.15: gopd "^1.0.1" has-tostringtag "^1.0.2" +which-typed-array@^1.1.16, which-typed-array@^1.1.19: + version "1.1.19" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.19.tgz#df03842e870b6b88e117524a4b364b6fc689f956" + integrity sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.4" + for-each "^0.3.5" + get-proto "^1.0.1" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + which@^1.2.9: version "1.3.1" resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" From 66377d2f2995a99135030e550028056a2679069e Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 2 Oct 2025 12:28:41 +0300 Subject: [PATCH 17/69] Add search filter button to Coder Workspaces tree views (#603) Fixes #330 --- package.json | 38 ++++++++++++++++++++++++++--- src/extension.ts | 18 ++++++++++++-- src/workspace/workspacesProvider.ts | 8 ++++++ 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 23a49a20..438ef3c7 100644 --- a/package.json +++ b/package.json @@ -204,6 +204,7 @@ { "command": "coder.createWorkspace", "title": "Create Workspace", + "category": "Coder", "when": "coder.authenticated", "icon": "$(add)" }, @@ -226,7 +227,8 @@ }, { "command": "coder.refreshWorkspaces", - "title": "Coder: Refresh Workspace", + "title": "Refresh Workspace", + "category": "Coder", "icon": "$(refresh)", "when": "coder.authenticated" }, @@ -241,6 +243,18 @@ "title": "Coder: Open App Status", "icon": "$(robot)", "when": "coder.authenticated" + }, + { + "command": "coder.searchMyWorkspaces", + "title": "Search", + "category": "Coder", + "icon": "$(search)" + }, + { + "command": "coder.searchAllWorkspaces", + "title": "Search", + "category": "Coder", + "icon": "$(search)" } ], "menus": { @@ -248,6 +262,14 @@ { "command": "coder.openFromSidebar", "when": "false" + }, + { + "command": "coder.searchMyWorkspaces", + "when": "false" + }, + { + "command": "coder.searchAllWorkspaces", + "when": "false" } ], "view/title": [ @@ -262,12 +284,22 @@ { "command": "coder.createWorkspace", "when": "coder.authenticated && view == myWorkspaces", - "group": "navigation" + "group": "navigation@1" }, { "command": "coder.refreshWorkspaces", "when": "coder.authenticated && view == myWorkspaces", - "group": "navigation" + "group": "navigation@2" + }, + { + "command": "coder.searchMyWorkspaces", + "when": "coder.authenticated && view == myWorkspaces", + "group": "navigation@3" + }, + { + "command": "coder.searchAllWorkspaces", + "when": "coder.authenticated && view == allWorkspaces", + "group": "navigation@3" } ], "view/item/context": [ diff --git a/src/extension.ts b/src/extension.ts index f7453cec..982342eb 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -18,6 +18,9 @@ import { WorkspaceQuery, } from "./workspace/workspacesProvider"; +const MY_WORKSPACES_TREE_ID = "myWorkspaces"; +const ALL_WORKSPACES_TREE_ID = "allWorkspaces"; + export async function activate(ctx: vscode.ExtensionContext): Promise { // The Remote SSH extension's proposed APIs are used to override the SSH host // name in VS Code itself. It's visually unappealing having a lengthy name! @@ -86,7 +89,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // createTreeView, unlike registerTreeDataProvider, gives us the tree view API // (so we can see when it is visible) but otherwise they have the same effect. - const myWsTree = vscode.window.createTreeView("myWorkspaces", { + const myWsTree = vscode.window.createTreeView(MY_WORKSPACES_TREE_ID, { treeDataProvider: myWorkspacesProvider, }); myWorkspacesProvider.setVisibility(myWsTree.visible); @@ -94,7 +97,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { myWorkspacesProvider.setVisibility(event.visible); }); - const allWsTree = vscode.window.createTreeView("allWorkspaces", { + const allWsTree = vscode.window.createTreeView(ALL_WORKSPACES_TREE_ID, { treeDataProvider: allWorkspacesProvider, }); allWorkspacesProvider.setVisibility(allWsTree.visible); @@ -298,6 +301,12 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { "coder.viewLogs", commands.viewLogs.bind(commands), ); + vscode.commands.registerCommand("coder.searchMyWorkspaces", async () => + showTreeViewSearch(MY_WORKSPACES_TREE_ID), + ); + vscode.commands.registerCommand("coder.searchAllWorkspaces", async () => + showTreeViewSearch(ALL_WORKSPACES_TREE_ID), + ); // Since the "onResolveRemoteAuthority:ssh-remote" activation event exists // in package.json we're able to perform actions before the authority is @@ -421,3 +430,8 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } } } + +async function showTreeViewSearch(id: string): Promise { + await vscode.commands.executeCommand(`${id}.focus`); + await vscode.commands.executeCommand("list.find"); +} diff --git a/src/workspace/workspacesProvider.ts b/src/workspace/workspacesProvider.ts index 86279401..915ef32a 100644 --- a/src/workspace/workspacesProvider.ts +++ b/src/workspace/workspacesProvider.ts @@ -262,6 +262,7 @@ export class WorkspaceProvider // yet. appStatuses.push( new AppStatusTreeItem({ + id: status.id, name: status.message, command: app.command, workspace_name: element.workspace.name, @@ -335,6 +336,7 @@ class AgentMetadataTreeItem extends vscode.TreeItem { metadataEvent.result.collected_at, ).toLocaleString(); + this.id = metadataEvent.description.key; this.tooltip = "Collected at " + collected_at; this.contextValue = "coderAgentMetadata"; } @@ -343,6 +345,7 @@ class AgentMetadataTreeItem extends vscode.TreeItem { class AppStatusTreeItem extends vscode.TreeItem { constructor( public readonly app: { + id: string; name: string; url?: string; command?: string; @@ -350,6 +353,7 @@ class AppStatusTreeItem extends vscode.TreeItem { }, ) { super("", vscode.TreeItemCollapsibleState.None); + this.id = app.id; this.description = app.name; this.contextValue = "coderAppStatus"; @@ -369,6 +373,7 @@ type CoderOpenableTreeItemType = export class OpenableTreeItem extends vscode.TreeItem { constructor( + id: string, label: string, tooltip: string, description: string, @@ -379,6 +384,7 @@ export class OpenableTreeItem extends vscode.TreeItem { contextValue: CoderOpenableTreeItemType, ) { super(label, collapsibleState); + this.id = id; this.contextValue = contextValue; this.tooltip = tooltip; this.description = description; @@ -397,6 +403,7 @@ export class AgentTreeItem extends OpenableTreeItem { watchMetadata = false, ) { super( + agent.id, // id agent.name, // label `Status: ${agent.status}`, // tooltip agent.status, // description @@ -434,6 +441,7 @@ export class WorkspaceTreeItem extends OpenableTreeItem { const detail = `Template: ${workspace.template_display_name || workspace.template_name} • Status: ${status}`; const agents = extractAgents(workspace.latest_build.resources); super( + workspace.id, label, detail, workspace.latest_build.status, // description From 460056787fbb021e6e08d331e38d758c92e5f511 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Fri, 3 Oct 2025 11:48:38 +0300 Subject: [PATCH 18/69] Fix subscriptions not disposed on extension deactivation (#605) Also use `vscode.workspace.getConfiguration` instead of passing a provider --- src/api/coderApi.ts | 42 +-- src/commands.ts | 4 +- src/core/container.ts | 2 +- src/extension.ts | 134 ++++---- src/remote/remote.ts | 453 ++++++++++++++-------------- src/workspace/workspacesProvider.ts | 36 ++- test/tsconfig.json | 2 +- 7 files changed, 344 insertions(+), 329 deletions(-) diff --git a/src/api/coderApi.ts b/src/api/coderApi.ts index 6c6c0faf..1d73ef00 100644 --- a/src/api/coderApi.ts +++ b/src/api/coderApi.ts @@ -7,7 +7,7 @@ import { type Workspace, type WorkspaceAgent, } from "coder/site/src/api/typesGenerated"; -import { type WorkspaceConfiguration } from "vscode"; +import * as vscode from "vscode"; import { type ClientOptions } from "ws"; import { CertificateError } from "../error"; @@ -33,17 +33,12 @@ import { createHttpAgent } from "./utils"; const coderSessionTokenHeader = "Coder-Session-Token"; -type WorkspaceConfigurationProvider = () => WorkspaceConfiguration; - /** * 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 { - private constructor( - private readonly output: Logger, - private readonly configProvider: WorkspaceConfigurationProvider, - ) { + private constructor(private readonly output: Logger) { super(); } @@ -55,15 +50,14 @@ export class CoderApi extends Api { baseUrl: string, token: string | undefined, output: Logger, - configProvider: WorkspaceConfigurationProvider, ): CoderApi { - const client = new CoderApi(output, configProvider); + const client = new CoderApi(output); client.setHost(baseUrl); if (token) { client.setSessionToken(token); } - setupInterceptors(client, baseUrl, output, configProvider); + setupInterceptors(client, baseUrl, output); return client; } @@ -127,7 +121,7 @@ export class CoderApi extends Api { coderSessionTokenHeader ] as string | undefined; - const httpAgent = createHttpAgent(this.configProvider()); + const httpAgent = createHttpAgent(vscode.workspace.getConfiguration()); const webSocket = new OneWayWebSocket({ location: baseUrl, ...configs, @@ -174,14 +168,13 @@ function setupInterceptors( client: CoderApi, baseUrl: string, output: Logger, - configProvider: WorkspaceConfigurationProvider, ): void { - addLoggingInterceptors(client.getAxiosInstance(), output, configProvider); + addLoggingInterceptors(client.getAxiosInstance(), output); client.getAxiosInstance().interceptors.request.use(async (config) => { const headers = await getHeaders( baseUrl, - getHeaderCommand(configProvider()), + getHeaderCommand(vscode.workspace.getConfiguration()), output, ); // Add headers from the header command. @@ -192,7 +185,7 @@ function setupInterceptors( // 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(configProvider()); + const agent = createHttpAgent(vscode.workspace.getConfiguration()); config.httpsAgent = agent; config.httpAgent = agent; config.proxy = false; @@ -209,38 +202,35 @@ function setupInterceptors( ); } -function addLoggingInterceptors( - client: AxiosInstance, - logger: Logger, - configProvider: WorkspaceConfigurationProvider, -) { +function addLoggingInterceptors(client: AxiosInstance, logger: Logger) { client.interceptors.request.use( (config) => { const configWithMeta = config as RequestConfigWithMeta; configWithMeta.metadata = createRequestMeta(); - logRequest(logger, configWithMeta, getLogLevel(configProvider())); + logRequest(logger, configWithMeta, getLogLevel()); return config; }, (error: unknown) => { - logError(logger, error, getLogLevel(configProvider())); + logError(logger, error, getLogLevel()); return Promise.reject(error); }, ); client.interceptors.response.use( (response) => { - logResponse(logger, response, getLogLevel(configProvider())); + logResponse(logger, response, getLogLevel()); return response; }, (error: unknown) => { - logError(logger, error, getLogLevel(configProvider())); + logError(logger, error, getLogLevel()); return Promise.reject(error); }, ); } -function getLogLevel(cfg: WorkspaceConfiguration): HttpClientLogLevel { - const logLevelStr = cfg +function getLogLevel(): HttpClientLogLevel { + const logLevelStr = vscode.workspace + .getConfiguration() .get( "coder.httpClientLogLevel", HttpClientLogLevel[HttpClientLogLevel.BASIC], diff --git a/src/commands.ts b/src/commands.ts index 462010ba..bd4071cc 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -260,9 +260,7 @@ export class Commands { token: string, isAutologin: boolean, ): Promise<{ user: User; token: string } | null> { - const client = CoderApi.create(url, token, this.logger, () => - vscode.workspace.getConfiguration(), - ); + const client = CoderApi.create(url, token, this.logger); if (!needToken(vscode.workspace.getConfiguration())) { try { const user = await client.getAuthenticatedUser(); diff --git a/src/core/container.ts b/src/core/container.ts index f820bb0d..72f28088 100644 --- a/src/core/container.ts +++ b/src/core/container.ts @@ -11,7 +11,7 @@ import { SecretsManager } from "./secretsManager"; * Service container for dependency injection. * Centralizes the creation and management of all core services. */ -export class ServiceContainer { +export class ServiceContainer implements vscode.Disposable { private readonly logger: vscode.LogOutputChannel; private readonly pathResolver: PathResolver; private readonly mementoManager: MementoManager; diff --git a/src/extension.ts b/src/extension.ts index 982342eb..e069c3a3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -57,6 +57,8 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } const serviceContainer = new ServiceContainer(ctx, vscodeProposed); + ctx.subscriptions.push(serviceContainer); + const output = serviceContainer.getLogger(); const mementoManager = serviceContainer.getMementoManager(); const secretsManager = serviceContainer.getSecretsManager(); @@ -72,7 +74,6 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { url || "", await secretsManager.getSessionToken(), output, - () => vscode.workspace.getConfiguration(), ); const myWorkspacesProvider = new WorkspaceProvider( @@ -81,33 +82,47 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { output, 5, ); + ctx.subscriptions.push(myWorkspacesProvider); + const allWorkspacesProvider = new WorkspaceProvider( WorkspaceQuery.All, client, output, ); + ctx.subscriptions.push(allWorkspacesProvider); // createTreeView, unlike registerTreeDataProvider, gives us the tree view API // (so we can see when it is visible) but otherwise they have the same effect. const myWsTree = vscode.window.createTreeView(MY_WORKSPACES_TREE_ID, { treeDataProvider: myWorkspacesProvider, }); + ctx.subscriptions.push(myWsTree); myWorkspacesProvider.setVisibility(myWsTree.visible); - myWsTree.onDidChangeVisibility((event) => { - myWorkspacesProvider.setVisibility(event.visible); - }); + myWsTree.onDidChangeVisibility( + (event) => { + myWorkspacesProvider.setVisibility(event.visible); + }, + undefined, + ctx.subscriptions, + ); const allWsTree = vscode.window.createTreeView(ALL_WORKSPACES_TREE_ID, { treeDataProvider: allWorkspacesProvider, }); + ctx.subscriptions.push(allWsTree); allWorkspacesProvider.setVisibility(allWsTree.visible); - allWsTree.onDidChangeVisibility((event) => { - allWorkspacesProvider.setVisibility(event.visible); - }); + allWsTree.onDidChangeVisibility( + (event) => { + allWorkspacesProvider.setVisibility(event.visible); + }, + undefined, + ctx.subscriptions, + ); // Handle vscode:// URIs. - vscode.window.registerUriHandler({ + 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"); @@ -253,59 +268,63 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { } }, }); - - const cliManager = serviceContainer.getCliManager(); + ctx.subscriptions.push(uriHandler); // Register globally available commands. Many of these have visibility // controlled by contexts, see `when` in the package.json. const commands = new Commands(serviceContainer, client); - vscode.commands.registerCommand("coder.login", commands.login.bind(commands)); - vscode.commands.registerCommand( - "coder.logout", - commands.logout.bind(commands), - ); - vscode.commands.registerCommand("coder.open", commands.open.bind(commands)); - vscode.commands.registerCommand( - "coder.openDevContainer", - commands.openDevContainer.bind(commands), - ); - vscode.commands.registerCommand( - "coder.openFromSidebar", - commands.openFromSidebar.bind(commands), - ); - vscode.commands.registerCommand( - "coder.openAppStatus", - commands.openAppStatus.bind(commands), - ); - vscode.commands.registerCommand( - "coder.workspace.update", - commands.updateWorkspace.bind(commands), - ); - vscode.commands.registerCommand( - "coder.createWorkspace", - commands.createWorkspace.bind(commands), - ); - vscode.commands.registerCommand( - "coder.navigateToWorkspace", - commands.navigateToWorkspace.bind(commands), - ); - vscode.commands.registerCommand( - "coder.navigateToWorkspaceSettings", - commands.navigateToWorkspaceSettings.bind(commands), - ); - vscode.commands.registerCommand("coder.refreshWorkspaces", () => { - myWorkspacesProvider.fetchAndRefresh(); - allWorkspacesProvider.fetchAndRefresh(); - }); - vscode.commands.registerCommand( - "coder.viewLogs", - commands.viewLogs.bind(commands), - ); - vscode.commands.registerCommand("coder.searchMyWorkspaces", async () => - showTreeViewSearch(MY_WORKSPACES_TREE_ID), - ); - vscode.commands.registerCommand("coder.searchAllWorkspaces", async () => - showTreeViewSearch(ALL_WORKSPACES_TREE_ID), + ctx.subscriptions.push( + vscode.commands.registerCommand( + "coder.login", + commands.login.bind(commands), + ), + vscode.commands.registerCommand( + "coder.logout", + commands.logout.bind(commands), + ), + vscode.commands.registerCommand("coder.open", commands.open.bind(commands)), + vscode.commands.registerCommand( + "coder.openDevContainer", + commands.openDevContainer.bind(commands), + ), + vscode.commands.registerCommand( + "coder.openFromSidebar", + commands.openFromSidebar.bind(commands), + ), + vscode.commands.registerCommand( + "coder.openAppStatus", + commands.openAppStatus.bind(commands), + ), + vscode.commands.registerCommand( + "coder.workspace.update", + commands.updateWorkspace.bind(commands), + ), + vscode.commands.registerCommand( + "coder.createWorkspace", + commands.createWorkspace.bind(commands), + ), + vscode.commands.registerCommand( + "coder.navigateToWorkspace", + commands.navigateToWorkspace.bind(commands), + ), + vscode.commands.registerCommand( + "coder.navigateToWorkspaceSettings", + commands.navigateToWorkspaceSettings.bind(commands), + ), + vscode.commands.registerCommand("coder.refreshWorkspaces", () => { + myWorkspacesProvider.fetchAndRefresh(); + allWorkspacesProvider.fetchAndRefresh(); + }), + vscode.commands.registerCommand( + "coder.viewLogs", + commands.viewLogs.bind(commands), + ), + vscode.commands.registerCommand("coder.searchMyWorkspaces", async () => + showTreeViewSearch(MY_WORKSPACES_TREE_ID), + ), + vscode.commands.registerCommand("coder.searchAllWorkspaces", async () => + showTreeViewSearch(ALL_WORKSPACES_TREE_ID), + ), ); // Since the "onResolveRemoteAuthority:ssh-remote" activation event exists @@ -325,6 +344,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { isFirstConnect, ); 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); diff --git a/src/remote/remote.ts b/src/remote/remote.ts index baf7b28c..2a286ab4 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -276,12 +276,7 @@ export class Remote { // 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, - () => vscode.workspace.getConfiguration(), - ); + const workspaceClient = CoderApi.create(baseUrlRaw, token, this.logger); // Store for use in commands. this.commands.workspaceRestClient = workspaceClient; @@ -398,256 +393,260 @@ export class Remote { } const disposables: vscode.Disposable[] = []; - // Register before connection so the label still displays! - disposables.push( - this.registerLabelFormatter( + try { + // Register before connection so the label still displays! + let labelFormatterDisposable = this.registerLabelFormatter( remoteAuthority, workspace.owner_name, workspace.name, - ), - ); - - // 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, ); - if (!updatedWorkspace) { - // User declined to start the workspace. + disposables.push({ + 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, + ); + if (!updatedWorkspace) { + // User declined to start the workspace. + await this.closeRemote(); + return; + } + workspace = updatedWorkspace; + } + this.commands.workspace = workspace; + + // Pick an agent. + this.logger.info(`Finding agent for ${workspaceName}...`); + 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; } - workspace = updatedWorkspace; - } - this.commands.workspace = workspace; - - // Pick an agent. - this.logger.info(`Finding agent for ${workspaceName}...`); - 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; - } - let agent = gotAgent; // Reassign so it cannot be undefined in callbacks. - this.logger.info(`Found agent ${agent.name} with status`, agent.status); - - // Do some janky setting manipulation. - this.logger.info("Modifying settings..."); - const remotePlatforms = this.vscodeProposed.workspace - .getConfiguration() - .get>("remote.SSH.remotePlatform", {}); - const connTimeout = this.vscodeProposed.workspace - .getConfiguration() - .get("remote.SSH.connectTimeout"); - - // We have to directly munge the settings file with jsonc because trying to - // update properly through the extension API hangs indefinitely. Possibly - // VS Code is trying to update configuration on the remote, which cannot - // connect until we finish here leading to a deadlock. We need to update it - // locally, anyway, and it does not seem possible to force that via API. - let settingsContent = "{}"; - try { - settingsContent = await fs.readFile( - this.pathResolver.getUserSettingsPath(), - "utf8", - ); - } catch { - // Ignore! It's probably because the file doesn't exist. - } - - // Add the remote platform for this host to bypass a step where VS Code asks - // the user for the platform. - let mungedPlatforms = false; - if ( - !remotePlatforms[parts.host] || - remotePlatforms[parts.host] !== agent.operating_system - ) { - remotePlatforms[parts.host] = agent.operating_system; - settingsContent = jsonc.applyEdits( - settingsContent, - jsonc.modify( - settingsContent, - ["remote.SSH.remotePlatform"], - remotePlatforms, - {}, - ), - ); - mungedPlatforms = true; - } + let agent = gotAgent; // Reassign so it cannot be undefined in callbacks. + this.logger.info(`Found agent ${agent.name} with status`, agent.status); + + // Do some janky setting manipulation. + this.logger.info("Modifying settings..."); + const remotePlatforms = this.vscodeProposed.workspace + .getConfiguration() + .get>("remote.SSH.remotePlatform", {}); + const connTimeout = this.vscodeProposed.workspace + .getConfiguration() + .get("remote.SSH.connectTimeout"); + + // We have to directly munge the settings file with jsonc because trying to + // update properly through the extension API hangs indefinitely. Possibly + // VS Code is trying to update configuration on the remote, which cannot + // connect until we finish here leading to a deadlock. We need to update it + // locally, anyway, and it does not seem possible to force that via API. + let settingsContent = "{}"; + try { + settingsContent = await fs.readFile( + this.pathResolver.getUserSettingsPath(), + "utf8", + ); + } catch { + // Ignore! It's probably because the file doesn't exist. + } - // VS Code ignores the connect timeout in the SSH config and uses a default - // of 15 seconds, which can be too short in the case where we wait for - // startup scripts. For now we hardcode a longer value. Because this is - // potentially overwriting user configuration, it feels a bit sketchy. If - // microsoft/vscode-remote-release#8519 is resolved we can remove this. - const minConnTimeout = 1800; - let mungedConnTimeout = false; - if (!connTimeout || connTimeout < minConnTimeout) { - settingsContent = jsonc.applyEdits( - settingsContent, - jsonc.modify( + // Add the remote platform for this host to bypass a step where VS Code asks + // the user for the platform. + let mungedPlatforms = false; + if ( + !remotePlatforms[parts.host] || + remotePlatforms[parts.host] !== agent.operating_system + ) { + remotePlatforms[parts.host] = agent.operating_system; + settingsContent = jsonc.applyEdits( settingsContent, - ["remote.SSH.connectTimeout"], - minConnTimeout, - {}, - ), - ); - mungedConnTimeout = true; - } + jsonc.modify( + settingsContent, + ["remote.SSH.remotePlatform"], + remotePlatforms, + {}, + ), + ); + mungedPlatforms = true; + } - if (mungedPlatforms || mungedConnTimeout) { - try { - await fs.writeFile( - this.pathResolver.getUserSettingsPath(), + // VS Code ignores the connect timeout in the SSH config and uses a default + // of 15 seconds, which can be too short in the case where we wait for + // startup scripts. For now we hardcode a longer value. Because this is + // potentially overwriting user configuration, it feels a bit sketchy. If + // microsoft/vscode-remote-release#8519 is resolved we can remove this. + const minConnTimeout = 1800; + let mungedConnTimeout = false; + if (!connTimeout || connTimeout < minConnTimeout) { + settingsContent = jsonc.applyEdits( settingsContent, + jsonc.modify( + settingsContent, + ["remote.SSH.connectTimeout"], + minConnTimeout, + {}, + ), ); - } catch (ex) { - // This could be because the user's settings.json is read-only. This is - // the case when using home-manager on NixOS, for example. Failure to - // write here is not necessarily catastrophic since the user will be - // asked for the platform and the default timeout might be sufficient. - mungedPlatforms = mungedConnTimeout = false; - this.logger.warn("Failed to configure settings", ex); + mungedConnTimeout = true; } - } - // Watch the workspace for changes. - const monitor = new WorkspaceMonitor( - workspace, - workspaceClient, - this.logger, - this.vscodeProposed, - ); - disposables.push(monitor); - disposables.push( - monitor.onChange.event((w) => (this.commands.workspace = w)), - ); + if (mungedPlatforms || mungedConnTimeout) { + try { + await fs.writeFile( + this.pathResolver.getUserSettingsPath(), + settingsContent, + ); + } catch (ex) { + // This could be because the user's settings.json is read-only. This is + // the case when using home-manager on NixOS, for example. Failure to + // write here is not necessarily catastrophic since the user will be + // asked for the platform and the default timeout might be sufficient. + mungedPlatforms = mungedConnTimeout = false; + this.logger.warn("Failed to configure settings", ex); + } + } - // Watch coder inbox for messages - const inbox = new Inbox(workspace, workspaceClient, this.logger); - disposables.push(inbox); + // Watch the workspace for changes. + const monitor = new WorkspaceMonitor( + workspace, + workspaceClient, + this.logger, + this.vscodeProposed, + ); + disposables.push(monitor); + disposables.push( + monitor.onChange.event((w) => (this.commands.workspace = w)), + ); - // 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; + // 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(); }); - if (!found) { - return; - } - agent = found; - if (agent.status === "connecting") { - return; - } - updateEvent.dispose(); - resolve(); }); - }); - }, - ); - this.logger.info(`Agent ${agent.name} status is now`, agent.status); - } + }, + ); + 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(); + // 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; } - await this.reloadWindow(); - return; - } - - const logDir = this.getLogDir(featureSet); - // This ensures the Remote SSH extension resolves the host to execute the - // Coder binary properly. - // - // If we didn't write to the SSH config file, connecting would fail with - // "Host not found". - try { - this.logger.info("Updating SSH config..."); - await this.updateSSHConfig( - workspaceClient, - parts.label, - parts.host, - binaryPath, - logDir, - featureSet, - ); - } catch (error) { - this.logger.warn("Failed to configure SSH", error); - throw error; - } + const logDir = this.getLogDir(featureSet); - // 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; + // This ensures the Remote SSH extension resolves the host to execute the + // Coder binary properly. + // + // If we didn't write to the SSH config file, connecting would fail with + // "Host not found". + try { + this.logger.info("Updating SSH config..."); + await this.updateSSHConfig( + workspaceClient, + parts.label, + parts.host, + binaryPath, + logDir, + featureSet, + ); + } catch (error) { + this.logger.warn("Failed to configure SSH", error); + throw error; } - }); - // Register the label formatter again because SSH overrides it! - disposables.push( - vscode.extensions.onDidChange(() => { - disposables.push( - this.registerLabelFormatter( + // 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; + } + }); + + // Register the label formatter again because SSH overrides it! + disposables.push( + vscode.extensions.onDidChange(() => { + // Dispose previous label formatter + labelFormatterDisposable.dispose(); + labelFormatterDisposable = this.registerLabelFormatter( remoteAuthority, workspace.owner_name, workspace.name, agent.name, - ), - ); - }), - ); - - disposables.push( - ...this.createAgentMetadataStatusBar(agent, workspaceClient), - ); + ); + }), + ...this.createAgentMetadataStatusBar(agent, workspaceClient), + ); + } catch (ex) { + // Whatever error happens, make sure we clean up the disposables in case of failure + disposables.forEach((d) => d.dispose()); + throw ex; + } this.logger.info("Remote setup complete"); diff --git a/src/workspace/workspacesProvider.ts b/src/workspace/workspacesProvider.ts index 915ef32a..b83e4f84 100644 --- a/src/workspace/workspacesProvider.ts +++ b/src/workspace/workspacesProvider.ts @@ -34,12 +34,12 @@ export enum WorkspaceQuery { * abort polling until fetchAndRefresh() is called again. */ export class WorkspaceProvider - implements vscode.TreeDataProvider + implements vscode.TreeDataProvider, vscode.Disposable { // Undefined if we have never fetched workspaces before. private workspaces: WorkspaceTreeItem[] | undefined; - private agentWatchers: Record = - {}; + private agentWatchers: Map = + new Map(); private timeout: NodeJS.Timeout | undefined; private fetching = false; private visible = false; @@ -121,7 +121,7 @@ export class WorkspaceProvider return this.fetch(); } - const oldWatcherIds = Object.keys(this.agentWatchers); + const oldWatcherIds = [...this.agentWatchers.keys()]; const reusedWatcherIds: string[] = []; // TODO: I think it might make more sense for the tree items to contain @@ -132,23 +132,23 @@ export class WorkspaceProvider const agents = extractAllAgents(resp.workspaces); agents.forEach((agent) => { // If we have an existing watcher, re-use it. - if (this.agentWatchers[agent.id]) { + const oldWatcher = this.agentWatchers.get(agent.id); + if (oldWatcher) { reusedWatcherIds.push(agent.id); - return this.agentWatchers[agent.id]; + } else { + // Otherwise create a new watcher. + const watcher = createAgentMetadataWatcher(agent.id, this.client); + watcher.onChange(() => this.refresh()); + this.agentWatchers.set(agent.id, watcher); } - // Otherwise create a new watcher. - const watcher = createAgentMetadataWatcher(agent.id, this.client); - watcher.onChange(() => this.refresh()); - this.agentWatchers[agent.id] = watcher; - return watcher; }); } // Dispose of watchers we ended up not reusing. oldWatcherIds.forEach((id) => { if (!reusedWatcherIds.includes(id)) { - this.agentWatchers[id].dispose(); - delete this.agentWatchers[id]; + this.agentWatchers.get(id)?.dispose(); + this.agentWatchers.delete(id); } }); @@ -244,7 +244,7 @@ export class WorkspaceProvider return Promise.resolve(agentTreeItems); } else if (element instanceof AgentTreeItem) { - const watcher = this.agentWatchers[element.agent.id]; + const watcher = this.agentWatchers.get(element.agent.id); if (watcher?.error) { return Promise.resolve([new ErrorTreeItem(watcher.error)]); } @@ -305,6 +305,14 @@ export class WorkspaceProvider } return Promise.resolve(this.workspaces || []); } + + dispose() { + this.cancelPendingRefresh(); + for (const watcher of this.agentWatchers.values()) { + watcher.dispose(); + } + this.agentWatchers.clear(); + } } /** diff --git a/test/tsconfig.json b/test/tsconfig.json index ece5f0b1..1be61bbd 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -6,5 +6,5 @@ "@/*": ["src/*"] } }, - "include": ["**/*", "../src/**/*"] + "include": [".", "../src"] } From 648360a5fe3a3140ef0e10bbc487586baa6f2675 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 6 Oct 2025 14:20:35 +0300 Subject: [PATCH 19/69] Fix login/logout synchronization across multiple VS Code windows (#590) Introduce ContextManager for centralized state management and use secrets to propagate authentication events between windows. Resolves race conditions in session token handling and ensures consistent authentication behavior across all open extension instances. Fixes #498 --- .eslintrc.json | 39 ++++++--- src/commands.ts | 72 ++++++++++------- src/core/container.ts | 8 ++ src/core/contextManager.ts | 33 ++++++++ src/core/secretsManager.ts | 52 +++++++++++- src/error.ts | 3 + src/extension.ts | 53 +++++++----- src/remote/remote.ts | 112 ++++++++++++++++---------- src/workspace/workspaceMonitor.ts | 8 +- test/mocks/testHelpers.ts | 30 ++++++- test/unit/core/secretsManager.test.ts | 48 +++++++++-- 11 files changed, 336 insertions(+), 122 deletions(-) create mode 100644 src/core/contextManager.ts diff --git a/.eslintrc.json b/.eslintrc.json index 91d67601..32fb8e61 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -23,17 +23,6 @@ "import/internal-regex": "^@/" }, "overrides": [ - { - "files": ["test/**/*.{ts,tsx}", "**/*.{test,spec}.ts?(x)"], - "settings": { - "import/resolver": { - "typescript": { - // In tests, resolve using the test tsconfig - "project": "test/tsconfig.json" - } - } - } - }, { "files": ["*.ts"], "rules": { @@ -46,9 +35,30 @@ "prefer": "type-imports", "fixStyle": "inline-type-imports" } + ], + "@typescript-eslint/switch-exhaustiveness-check": [ + "error", + { "considerDefaultExhaustiveForUnions": true } ] } }, + { + "files": ["test/**/*.{ts,tsx}", "**/*.{test,spec}.ts?(x)"], + "settings": { + "import/resolver": { + "typescript": { + // In tests, resolve using the test tsconfig + "project": "test/tsconfig.json" + } + } + } + }, + { + "files": ["src/core/contextManager.ts"], + "rules": { + "no-restricted-syntax": "off" + } + }, { "extends": ["plugin:package-json/legacy-recommended"], "files": ["*.json"], @@ -106,6 +116,13 @@ "sublings_only": true } } + ], + "no-restricted-syntax": [ + "error", + { + "selector": "CallExpression[callee.property.name='executeCommand'][arguments.0.value='setContext'][arguments.length>=3]", + "message": "Do not use executeCommand('setContext', ...) directly. Use the ContextManager class instead." + } ] } } diff --git a/src/commands.ts b/src/commands.ts index bd4071cc..5abeb026 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -12,6 +12,7 @@ import { CoderApi } from "./api/coderApi"; import { needToken } from "./api/utils"; 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"; @@ -32,6 +33,7 @@ export class Commands { private readonly mementoManager: MementoManager; private readonly secretsManager: SecretsManager; private readonly cliManager: CliManager; + private readonly contextManager: ContextManager; // 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 @@ -53,6 +55,7 @@ export class Commands { this.mementoManager = serviceContainer.getMementoManager(); this.secretsManager = serviceContainer.getSecretsManager(); this.cliManager = serviceContainer.getCliManager(); + this.contextManager = serviceContainer.getContextManager(); } /** @@ -179,19 +182,22 @@ export class Commands { } /** - * Log into the provided deployment. If the deployment URL is not specified, + * Log into the provided deployment. If the deployment URL is not specified, * ask for it first with a menu showing recent URLs along with the default URL * and CODER_URL, if those are set. */ - public async login(...args: string[]): Promise { - // Destructure would be nice but VS Code can pass undefined which errors. - const inputUrl = args[0]; - const inputToken = args[1]; - const inputLabel = args[2]; - const isAutologin = - typeof args[3] === "undefined" ? false : Boolean(args[3]); - - const url = await this.maybeAskUrl(inputUrl); + public async login(args?: { + url?: string; + token?: string; + label?: string; + autoLogin?: boolean; + }): Promise { + if (this.contextManager.get("coder.authenticated")) { + return; + } + this.logger.info("Logging in"); + + const url = await this.maybeAskUrl(args?.url); if (!url) { return; // The user aborted. } @@ -199,11 +205,11 @@ export class Commands { // 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 = - typeof inputLabel === "undefined" ? toSafeHost(url) : inputLabel; + 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 res = await this.maybeAskToken(url, inputToken, isAutologin); + const autoLogin = args?.autoLogin === true; + const res = await this.maybeAskToken(url, args?.token, autoLogin); if (!res) { return; // The user aborted, or unable to auth. } @@ -221,13 +227,9 @@ export class Commands { await this.cliManager.configure(label, url, res.token); // These contexts control various menu items and the sidebar. - await vscode.commands.executeCommand( - "setContext", - "coder.authenticated", - true, - ); + this.contextManager.set("coder.authenticated", true); if (res.user.roles.find((role) => role.name === "owner")) { - await vscode.commands.executeCommand("setContext", "coder.isOwner", true); + this.contextManager.set("coder.isOwner", true); } vscode.window @@ -245,6 +247,7 @@ export class Commands { } }); + await this.secretsManager.triggerLoginStateChange("login"); // Fetch workspaces for the new deployment. vscode.commands.executeCommand("coder.refreshWorkspaces"); } @@ -257,19 +260,21 @@ export class Commands { */ private async maybeAskToken( url: string, - token: string, - isAutologin: boolean, + token: string | undefined, + isAutoLogin: boolean, ): Promise<{ user: User; token: string } | null> { const client = CoderApi.create(url, token, this.logger); - if (!needToken(vscode.workspace.getConfiguration())) { + 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. - return { token: "", user }; + // 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) { + if (isAutoLogin) { this.logger.warn("Failed to log in to Coder server:", message); } else { this.vscodeProposed.window.showErrorMessage( @@ -301,6 +306,9 @@ export class Commands { value: token || (await this.secretsManager.getSessionToken()), ignoreFocusOut: true, validateInput: async (value) => { + if (!value) { + return null; + } client.setSessionToken(value); try { user = await client.getAuthenticatedUser(); @@ -369,7 +377,14 @@ export class Commands { // 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")) { + 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(""); @@ -379,19 +394,16 @@ export class Commands { await this.mementoManager.setUrl(undefined); await this.secretsManager.setSessionToken(undefined); - await vscode.commands.executeCommand( - "setContext", - "coder.authenticated", - false, - ); + this.contextManager.set("coder.authenticated", false); vscode.window .showInformationMessage("You've been logged out of Coder!", "Login") .then((action) => { if (action === "Login") { - vscode.commands.executeCommand("coder.login"); + this.login(); } }); + await this.secretsManager.triggerLoginStateChange("logout"); // This will result in clearing the workspace list. vscode.commands.executeCommand("coder.refreshWorkspaces"); } diff --git a/src/core/container.ts b/src/core/container.ts index 72f28088..a8f938ea 100644 --- a/src/core/container.ts +++ b/src/core/container.ts @@ -3,6 +3,7 @@ import * as vscode from "vscode"; import { type Logger } from "../logging/logger"; import { CliManager } from "./cliManager"; +import { ContextManager } from "./contextManager"; import { MementoManager } from "./mementoManager"; import { PathResolver } from "./pathResolver"; import { SecretsManager } from "./secretsManager"; @@ -17,6 +18,7 @@ export class ServiceContainer implements vscode.Disposable { private readonly mementoManager: MementoManager; private readonly secretsManager: SecretsManager; private readonly cliManager: CliManager; + private readonly contextManager: ContextManager; constructor( context: vscode.ExtensionContext, @@ -34,6 +36,7 @@ export class ServiceContainer implements vscode.Disposable { this.logger, this.pathResolver, ); + this.contextManager = new ContextManager(); } getVsCodeProposed(): typeof vscode { @@ -60,10 +63,15 @@ export class ServiceContainer implements vscode.Disposable { return this.cliManager; } + getContextManager(): ContextManager { + return this.contextManager; + } + /** * Dispose of all services and clean up resources. */ dispose(): void { + this.contextManager.dispose(); this.logger.dispose(); } } diff --git a/src/core/contextManager.ts b/src/core/contextManager.ts new file mode 100644 index 00000000..a5a18397 --- /dev/null +++ b/src/core/contextManager.ts @@ -0,0 +1,33 @@ +import * as vscode from "vscode"; + +const CONTEXT_DEFAULTS = { + "coder.authenticated": false, + "coder.isOwner": false, + "coder.loaded": false, + "coder.workspace.updatable": false, +} as const; + +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) => { + this.set(key, CONTEXT_DEFAULTS[key]); + }); + } + + public set(key: CoderContext, value: boolean): void { + this.context.set(key, value); + vscode.commands.executeCommand("setContext", key, value); + } + + public get(key: CoderContext): boolean { + return this.context.get(key) ?? CONTEXT_DEFAULTS[key]; + } + + public dispose() { + this.context.clear(); + } +} diff --git a/src/core/secretsManager.ts b/src/core/secretsManager.ts index 6a6666da..94827b15 100644 --- a/src/core/secretsManager.ts +++ b/src/core/secretsManager.ts @@ -1,4 +1,14 @@ -import type { SecretStorage } from "vscode"; +import type { SecretStorage, Disposable } from "vscode"; + +const SESSION_TOKEN_KEY = "sessionToken"; + +const LOGIN_STATE_KEY = "loginState"; + +export enum AuthAction { + LOGIN, + LOGOUT, + INVALID, +} export class SecretsManager { constructor(private readonly secrets: SecretStorage) {} @@ -8,9 +18,9 @@ export class SecretsManager { */ public async setSessionToken(sessionToken?: string): Promise { if (!sessionToken) { - await this.secrets.delete("sessionToken"); + await this.secrets.delete(SESSION_TOKEN_KEY); } else { - await this.secrets.store("sessionToken", sessionToken); + await this.secrets.store(SESSION_TOKEN_KEY, sessionToken); } } @@ -19,11 +29,45 @@ export class SecretsManager { */ public async getSessionToken(): Promise { try { - return await this.secrets.get("sessionToken"); + return await this.secrets.get(SESSION_TOKEN_KEY); } catch { // The VS Code session store has become corrupt before, and // will fail to get the session token... return undefined; } } + + /** + * 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. + */ + public async triggerLoginStateChange( + action: "login" | "logout", + ): Promise { + const date = new Date().toISOString(); + await this.secrets.store(LOGIN_STATE_KEY, `${action}-${date}`); + } + + /** + * Listens for login/logout events from any VS Code window. + * The secrets storage onDidChange event fires across all windows, enabling cross-window sync. + */ + public onDidChangeLoginState( + listener: (state: AuthAction) => Promise, + ): Disposable { + 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); + } + } + }); + } } diff --git a/src/error.ts b/src/error.ts index 7b93b458..70448d76 100644 --- a/src/error.ts +++ b/src/error.ts @@ -64,6 +64,8 @@ export class CertificateError extends Error { return new CertificateError(err.message, X509_ERR.UNTRUSTED_LEAF); case X509_ERR_CODE.SELF_SIGNED_CERT_IN_CHAIN: return new CertificateError(err.message, X509_ERR.UNTRUSTED_CHAIN); + case undefined: + break; } } return err; @@ -154,6 +156,7 @@ export class CertificateError extends Error { ); switch (val) { case CertificateError.ActionOK: + case undefined: return; case CertificateError.ActionAllowInsecure: await this.allowInsecure(); diff --git a/src/extension.ts b/src/extension.ts index e069c3a3..aba94cfe 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -10,6 +10,7 @@ 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 { CertificateError, getErrorDetail } from "./error"; import { Remote } from "./remote/remote"; import { toSafeHost } from "./util"; @@ -62,6 +63,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const output = serviceContainer.getLogger(); const mementoManager = serviceContainer.getMementoManager(); const secretsManager = serviceContainer.getSecretsManager(); + const contextManager = serviceContainer.getContextManager(); // Try to clear this flag ASAP const isFirstConnect = await mementoManager.getAndClearFirstConnect(); @@ -167,6 +169,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { const token = needToken(vscode.workspace.getConfiguration()) ? params.get("token") : (params.get("token") ?? ""); + if (token) { client.setSessionToken(token); await secretsManager.setSessionToken(token); @@ -327,6 +330,29 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ), ); + 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; + } + }), + ); + // Since the "onResolveRemoteAuthority:ssh-remote" activation event exists // in package.json we're able to perform actions before the authority is // resolved by the remote SSH extension. @@ -337,7 +363,6 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // (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) { - const remote = new Remote(serviceContainer, commands, ctx.extensionMode); try { const details = await remote.setup( vscodeProposed.env.remoteAuthority, @@ -394,20 +419,12 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { output.info(`Logged in to ${baseUrl}; checking credentials`); client .getAuthenticatedUser() - .then(async (user) => { + .then((user) => { if (user && user.roles) { output.info("Credentials are valid"); - vscode.commands.executeCommand( - "setContext", - "coder.authenticated", - true, - ); + contextManager.set("coder.authenticated", true); if (user.roles.find((role) => role.name === "owner")) { - await vscode.commands.executeCommand( - "setContext", - "coder.isOwner", - true, - ); + contextManager.set("coder.isOwner", true); } // Fetch and monitor workspaces, now that we know the client is good. @@ -426,11 +443,11 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { ); }) .finally(() => { - vscode.commands.executeCommand("setContext", "coder.loaded", true); + contextManager.set("coder.loaded", true); }); } else { output.info("Not currently logged in"); - vscode.commands.executeCommand("setContext", "coder.loaded", true); + contextManager.set("coder.loaded", true); // Handle autologin, if not already logged in. const cfg = vscode.workspace.getConfiguration(); @@ -439,13 +456,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { cfg.get("coder.defaultUrl")?.trim() || process.env.CODER_URL?.trim(); if (defaultUrl) { - vscode.commands.executeCommand( - "coder.login", - defaultUrl, - undefined, - undefined, - "true", - ); + commands.login({ url: defaultUrl, autoLogin: true }); } } } diff --git a/src/remote/remote.ts b/src/remote/remote.ts index 2a286ab4..832a8086 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -30,6 +30,7 @@ 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 { featureSetForVersion, type FeatureSet } from "../featureSet"; import { getGlobalFlags } from "../globalFlags"; @@ -58,6 +59,12 @@ export class Remote { private readonly logger: Logger; 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(); public constructor( serviceContainer: ServiceContainer, @@ -68,6 +75,33 @@ export class Remote { 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 { @@ -238,34 +272,48 @@ export class Remote { parts.label, ); - // It could be that the cli config was deleted. If so, ask for the url. - if ( - !baseUrlRaw || - (!token && needToken(vscode.workspace.getConfiguration())) - ) { - const result = await this.vscodeProposed.window.showInformationMessage( - "You are not logged in...", + 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}.`, + detail: `You must log in to access ${workspaceName}. If you've already logged in, you may close this dialog.`, }, "Log In", ); - if (!result) { - // User declined to log in. - await this.closeRemote(); + + // 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 { - // Log in then try again. - await vscode.commands.executeCommand( - "coder.login", - baseUrlRaw, - undefined, - parts.label, - ); - await this.setup(remoteAuthority, firstConnect); + if (!result.userChoice) { + // User declined to log in. + await this.closeRemote(); + return; + } else { + // Log in then try again. + await this.commands.login({ url: baseUrlRaw, label: parts.label }); + return this.setup(remoteAuthority, firstConnect); + } } - return; + }; + + // 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); @@ -364,28 +412,7 @@ export class Remote { return; } case 401: { - const result = - await this.vscodeProposed.window.showInformationMessage( - "Your session expired...", - { - useCustom: true, - modal: true, - detail: `You must log in to access ${workspaceName}.`, - }, - "Log In", - ); - if (!result) { - await this.closeRemote(); - } else { - await vscode.commands.executeCommand( - "coder.login", - baseUrlRaw, - undefined, - parts.label, - ); - await this.setup(remoteAuthority, firstConnect); - } - return; + return showLoginDialog("Your session expired..."); } default: throw error; @@ -521,6 +548,7 @@ export class Remote { workspaceClient, this.logger, this.vscodeProposed, + this.contextManager, ); disposables.push(monitor); disposables.push( diff --git a/src/workspace/workspaceMonitor.ts b/src/workspace/workspaceMonitor.ts index 8ff99137..0b154f75 100644 --- a/src/workspace/workspaceMonitor.ts +++ b/src/workspace/workspaceMonitor.ts @@ -7,6 +7,7 @@ import * as vscode from "vscode"; 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"; @@ -41,6 +42,7 @@ export class WorkspaceMonitor implements vscode.Disposable { private readonly logger: Logger, // We use the proposed API to get access to useCustom in dialogs. private readonly vscodeProposed: typeof vscode, + private readonly contextManager: ContextManager, ) { this.name = createWorkspaceIdentifier(workspace); const socket = this.client.watchWorkspace(workspace); @@ -217,11 +219,7 @@ export class WorkspaceMonitor implements vscode.Disposable { } private updateContext(workspace: Workspace) { - vscode.commands.executeCommand( - "setContext", - "coder.workspace.updatable", - workspace.outdated, - ); + this.contextManager.set("coder.workspace.updatable", workspace.outdated); } private updateStatusBar(workspace: Workspace) { diff --git a/test/mocks/testHelpers.ts b/test/mocks/testHelpers.ts index 14eca74b..5cfe44e5 100644 --- a/test/mocks/testHelpers.ts +++ b/test/mocks/testHelpers.ts @@ -234,10 +234,19 @@ export class InMemoryMemento implements vscode.Memento { export class InMemorySecretStorage implements vscode.SecretStorage { private secrets = new Map(); private isCorrupted = false; - - onDidChange: vscode.Event = () => ({ - dispose: () => {}, - }); + private listeners: Array<(e: vscode.SecretStorageChangeEvent) => void> = []; + + onDidChange: vscode.Event = (listener) => { + this.listeners.push(listener); + return { + dispose: () => { + const index = this.listeners.indexOf(listener); + if (index > -1) { + this.listeners.splice(index, 1); + } + }, + }; + }; async get(key: string): Promise { if (this.isCorrupted) { @@ -250,17 +259,30 @@ export class InMemorySecretStorage implements vscode.SecretStorage { if (this.isCorrupted) { return Promise.reject(new Error("Storage corrupted")); } + const oldValue = this.secrets.get(key); this.secrets.set(key, value); + if (oldValue !== value) { + this.fireChangeEvent(key); + } } async delete(key: string): Promise { if (this.isCorrupted) { return Promise.reject(new Error("Storage corrupted")); } + const hadKey = this.secrets.has(key); this.secrets.delete(key); + if (hadKey) { + this.fireChangeEvent(key); + } } corruptStorage(): void { this.isCorrupted = true; } + + private fireChangeEvent(key: string): void { + const event: vscode.SecretStorageChangeEvent = { key }; + this.listeners.forEach((listener) => listener(event)); + } } diff --git a/test/unit/core/secretsManager.test.ts b/test/unit/core/secretsManager.test.ts index 7100a29b..bfe8c713 100644 --- a/test/unit/core/secretsManager.test.ts +++ b/test/unit/core/secretsManager.test.ts @@ -1,6 +1,6 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; -import { SecretsManager } from "@/core/secretsManager"; +import { AuthAction, SecretsManager } from "@/core/secretsManager"; import { InMemorySecretStorage } from "../../mocks/testHelpers"; @@ -13,7 +13,7 @@ describe("SecretsManager", () => { secretsManager = new SecretsManager(secretStorage); }); - describe("setSessionToken", () => { + describe("session token", () => { it("should store and retrieve tokens", async () => { await secretsManager.setSessionToken("test-token"); expect(await secretsManager.getSessionToken()).toBe("test-token"); @@ -31,9 +31,7 @@ describe("SecretsManager", () => { await secretsManager.setSessionToken(undefined); expect(await secretsManager.getSessionToken()).toBeUndefined(); }); - }); - describe("getSessionToken", () => { it("should return undefined for corrupted storage", async () => { await secretStorage.store("sessionToken", "valid-token"); secretStorage.corruptStorage(); @@ -41,4 +39,44 @@ describe("SecretsManager", () => { expect(await secretsManager.getSessionToken()).toBeUndefined(); }); }); + + describe("login state", () => { + it("should trigger login events", async () => { + const events: Array = []; + secretsManager.onDidChangeLoginState((state) => { + events.push(state); + return Promise.resolve(); + }); + + await secretsManager.triggerLoginStateChange("login"); + expect(events).toEqual([AuthAction.LOGIN]); + }); + + it("should trigger logout events", async () => { + const events: Array = []; + secretsManager.onDidChangeLoginState((state) => { + events.push(state); + return Promise.resolve(); + }); + + await secretsManager.triggerLoginStateChange("logout"); + expect(events).toEqual([AuthAction.LOGOUT]); + }); + + it("should fire same event twice in a row", async () => { + vi.useFakeTimers(); + const events: Array = []; + secretsManager.onDidChangeLoginState((state) => { + events.push(state); + return Promise.resolve(); + }); + + await secretsManager.triggerLoginStateChange("login"); + vi.advanceTimersByTime(5); + await secretsManager.triggerLoginStateChange("login"); + + expect(events).toEqual([AuthAction.LOGIN, AuthAction.LOGIN]); + vi.useRealTimers(); + }); + }); }); From b2ac27bf4d9058cbae0113cbab3745cd437b418b Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 6 Oct 2025 15:04:20 +0300 Subject: [PATCH 20/69] Add changelog for fixing the login/logout across multiple windows (#608) --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9da9987..c09aea12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixed + +- Logging in or out in one VS Code window now properly updates the authentication status in all other open windows. + ### Added - Support for `CODER_BINARY_DESTINATION` environment variable to set CLI download location (overridden by extension setting `coder.binaryDestination` if configured). From c0a2b5f4e794f07e3eb430afcb03c8f4ca6fc917 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 7 Oct 2025 10:49:14 +0300 Subject: [PATCH 21/69] Fix JSON.stringify circular reference error in logging infrastructure (#607) Resolves circular structure errors when serializing request/response bodies for logging. Other changes: * Added test coverage for logging infrastructure * Fixed flaky stream test * Calculate body size directly from strings/buffers instead of stringifying * Use util.inspect instead of JSON.stringify for body serialization Fixes #606 --- CHANGELOG.md | 1 + src/api/coderApi.ts | 80 +++++++++++++++++- src/core/cliManager.ts | 2 +- src/logging/formatters.ts | 35 ++------ src/logging/httpLogger.ts | 18 ++-- src/logging/types.ts | 2 + src/logging/utils.ts | 43 ++++++++-- src/logging/wsLogger.ts | 2 +- test/mocks/testHelpers.ts | 12 +++ test/unit/core/cliManager.test.ts | 26 +++--- test/unit/logging/formatters.test.ts | 122 +++++++++++++++++++++++++++ test/unit/logging/httpLogger.test.ts | 112 ++++++++++++++++++++++++ test/unit/logging/utils.test.ts | 106 +++++++++++++++++++++++ test/unit/logging/wsLogger.test.ts | 71 ++++++++++++++++ 14 files changed, 573 insertions(+), 59 deletions(-) create mode 100644 test/unit/logging/formatters.test.ts create mode 100644 test/unit/logging/httpLogger.test.ts create mode 100644 test/unit/logging/utils.test.ts create mode 100644 test/unit/logging/wsLogger.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c09aea12..9127b22c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Logging in or out in one VS Code window now properly updates the authentication status in all other open windows. +- Fix an issue with JSON stringification errors occurring when logging circular objects. ### Added diff --git a/src/api/coderApi.ts b/src/api/coderApi.ts index 1d73ef00..1d523b60 100644 --- a/src/api/coderApi.ts +++ b/src/api/coderApi.ts @@ -1,4 +1,9 @@ -import { type AxiosInstance } from "axios"; +import { + type AxiosResponseHeaders, + type AxiosInstance, + type AxiosHeaders, + type AxiosResponseTransformer, +} from "axios"; import { Api } from "coder/site/src/api/api"; import { type GetInboxNotificationResponse, @@ -23,6 +28,7 @@ import { type RequestConfigWithMeta, HttpClientLogLevel, } from "../logging/types"; +import { sizeOf } from "../logging/utils"; import { WsLogger } from "../logging/wsLogger"; import { OneWayWebSocket, @@ -207,7 +213,24 @@ function addLoggingInterceptors(client: AxiosInstance, logger: Logger) { (config) => { const configWithMeta = config as RequestConfigWithMeta; configWithMeta.metadata = createRequestMeta(); - logRequest(logger, configWithMeta, getLogLevel()); + + config.transformRequest = [ + ...wrapRequestTransform( + config.transformRequest || client.defaults.transformRequest || [], + configWithMeta, + ), + (data) => { + // Log after setting the raw request size + logRequest(logger, configWithMeta, getLogLevel()); + return data; + }, + ]; + + config.transformResponse = wrapResponseTransform( + config.transformResponse || client.defaults.transformResponse || [], + configWithMeta, + ); + return config; }, (error: unknown) => { @@ -228,6 +251,59 @@ function addLoggingInterceptors(client: AxiosInstance, logger: Logger) { ); } +function wrapRequestTransform( + transformer: AxiosResponseTransformer | AxiosResponseTransformer[], + config: RequestConfigWithMeta, +): AxiosResponseTransformer[] { + return [ + (data: unknown, headers: AxiosHeaders) => { + const transformerArray = Array.isArray(transformer) + ? transformer + : [transformer]; + + // Transform the request first then get the size (measure what's sent over the wire) + const result = transformerArray.reduce( + (d, fn) => fn.call(config, d, headers), + data, + ); + + config.rawRequestSize = getSize(config.headers, result); + + return result; + }, + ]; +} + +function wrapResponseTransform( + transformer: AxiosResponseTransformer | AxiosResponseTransformer[], + config: RequestConfigWithMeta, +): AxiosResponseTransformer[] { + return [ + (data: unknown, headers: AxiosResponseHeaders, status?: number) => { + // Get the size before transforming the response (measure what's sent over the wire) + config.rawResponseSize = getSize(headers, data); + + const transformerArray = Array.isArray(transformer) + ? transformer + : [transformer]; + + return transformerArray.reduce( + (d, fn) => fn.call(config, d, headers, status), + data, + ); + }, + ]; +} + +function getSize(headers: AxiosHeaders, data: unknown): number | undefined { + const contentLength = headers["content-length"]; + if (contentLength !== undefined) { + return parseInt(contentLength, 10); + } + + return sizeOf(data); +} + function getLogLevel(): HttpClientLogLevel { const logLevelStr = vscode.workspace .getConfiguration() diff --git a/src/core/cliManager.ts b/src/core/cliManager.ts index 1bb0afa1..4e8833fe 100644 --- a/src/core/cliManager.ts +++ b/src/core/cliManager.ts @@ -267,7 +267,7 @@ export class CliManager { if (Number.isNaN(contentLength)) { this.output.warn( "Got invalid or missing content length", - rawContentLength, + rawContentLength ?? "", ); } else { this.output.info("Got content length", prettyBytes(contentLength)); diff --git a/src/logging/formatters.ts b/src/logging/formatters.ts index 1ad45231..8247f9b1 100644 --- a/src/logging/formatters.ts +++ b/src/logging/formatters.ts @@ -1,6 +1,8 @@ import prettyBytes from "pretty-bytes"; -import type { InternalAxiosRequestConfig } from "axios"; +import { safeStringify } from "./utils"; + +import type { AxiosRequestConfig } from "axios"; const SENSITIVE_HEADERS = ["Coder-Session-Token", "Proxy-Authorization"]; @@ -18,35 +20,14 @@ export function formatTime(ms: number): string { } export function formatMethod(method: string | undefined): string { - return (method ?? "GET").toUpperCase(); + return method?.toUpperCase() || "GET"; } -/** - * Formats content-length for display. Returns the header value if available, - * otherwise estimates size by serializing the data body (prefixed with ~). - */ -export function formatContentLength( - headers: Record, - data: unknown, -): string { - const len = headers["content-length"]; - if (len && typeof len === "string") { - const bytes = parseInt(len, 10); - return isNaN(bytes) ? "(?b)" : `(${prettyBytes(bytes)})`; - } - - // Estimate from data if no header - if (data !== undefined && data !== null) { - const estimated = Buffer.byteLength(JSON.stringify(data), "utf8"); - return `(~${prettyBytes(estimated)})`; - } - - return `(${prettyBytes(0)})`; +export function formatSize(size: number | undefined): string { + return size === undefined ? "(? B)" : `(${prettyBytes(size)})`; } -export function formatUri( - config: InternalAxiosRequestConfig | undefined, -): string { +export function formatUri(config: AxiosRequestConfig | undefined): string { return config?.url || ""; } @@ -66,7 +47,7 @@ export function formatHeaders(headers: Record): string { export function formatBody(body: unknown): string { if (body) { - return JSON.stringify(body); + return safeStringify(body) ?? ""; } else { return ""; } diff --git a/src/logging/httpLogger.ts b/src/logging/httpLogger.ts index 7e569cad..5634a165 100644 --- a/src/logging/httpLogger.ts +++ b/src/logging/httpLogger.ts @@ -5,9 +5,9 @@ import { getErrorDetail } from "../error"; import { formatBody, - formatContentLength, formatHeaders, formatMethod, + formatSize, formatTime, formatUri, } from "./formatters"; @@ -42,11 +42,10 @@ export function logRequest( return; } - const { requestId, method, url } = parseConfig(config); - const len = formatContentLength(config.headers, config.data); + const { requestId, method, url, requestSize } = parseConfig(config); const msg = [ - `→ ${shortId(requestId)} ${method} ${url} ${len}`, + `→ ${shortId(requestId)} ${method} ${url} ${requestSize}`, ...buildExtraLogs(config.headers, config.data, logLevel), ]; logger.trace(msg.join("\n")); @@ -64,11 +63,12 @@ export function logResponse( return; } - const { requestId, method, url, time } = parseConfig(response.config); - const len = formatContentLength(response.headers, response.data); + const { requestId, method, url, time, responseSize } = parseConfig( + response.config, + ); const msg = [ - `← ${shortId(requestId)} ${response.status} ${method} ${url} ${len} ${time}`, + `← ${shortId(requestId)} ${response.status} ${method} ${url} ${responseSize} ${time}`, ...buildExtraLogs(response.headers, response.data, logLevel), ]; logger.trace(msg.join("\n")); @@ -150,6 +150,8 @@ function parseConfig(config: RequestConfigWithMeta | undefined): { method: string; url: string; time: string; + requestSize: string; + responseSize: string; } { const meta = config?.metadata; return { @@ -157,5 +159,7 @@ function parseConfig(config: RequestConfigWithMeta | undefined): { method: formatMethod(config?.method), url: formatUri(config), time: meta ? formatTime(Date.now() - meta.startedAt) : "?ms", + requestSize: formatSize(config?.rawRequestSize), + responseSize: formatSize(config?.rawResponseSize), }; } diff --git a/src/logging/types.ts b/src/logging/types.ts index d1ee51ca..30837a0d 100644 --- a/src/logging/types.ts +++ b/src/logging/types.ts @@ -14,4 +14,6 @@ export interface RequestMeta { export type RequestConfigWithMeta = InternalAxiosRequestConfig & { metadata?: RequestMeta; + rawRequestSize?: number; + rawResponseSize?: number; }; diff --git a/src/logging/utils.ts b/src/logging/utils.ts index c371f65e..5deadaaf 100644 --- a/src/logging/utils.ts +++ b/src/logging/utils.ts @@ -1,21 +1,37 @@ import { Buffer } from "node:buffer"; import crypto from "node:crypto"; +import util from "node:util"; export function shortId(id: string): string { return id.slice(0, 8); } +export function createRequestId(): string { + return crypto.randomUUID().replace(/-/g, ""); +} + +/** + * Returns the byte size of the data if it can be determined from the data's intrinsic properties, + * otherwise returns undefined (e.g., for plain objects and arrays that would require serialization). + */ export function sizeOf(data: unknown): number | undefined { if (data === null || data === undefined) { return 0; } - if (typeof data === "string") { - return Buffer.byteLength(data); + if (typeof data === "boolean") { + return 4; + } + if (typeof data === "number") { + return 8; } - if (Buffer.isBuffer(data)) { - return data.length; + if (typeof data === "string" || typeof data === "bigint") { + return Buffer.byteLength(data.toString()); } - if (data instanceof ArrayBuffer || ArrayBuffer.isView(data)) { + if ( + Buffer.isBuffer(data) || + data instanceof ArrayBuffer || + ArrayBuffer.isView(data) + ) { return data.byteLength; } if ( @@ -28,6 +44,19 @@ export function sizeOf(data: unknown): number | undefined { return undefined; } -export function createRequestId(): string { - return crypto.randomUUID().replace(/-/g, ""); +export function safeStringify(data: unknown): string | null { + try { + return util.inspect(data, { + showHidden: false, + depth: Infinity, + maxArrayLength: Infinity, + maxStringLength: Infinity, + breakLength: Infinity, + compact: true, + getters: false, // avoid side-effects + }); + } catch { + // Should rarely happen but just in case + return null; + } } diff --git a/src/logging/wsLogger.ts b/src/logging/wsLogger.ts index b33118b7..fd6acd00 100644 --- a/src/logging/wsLogger.ts +++ b/src/logging/wsLogger.ts @@ -77,6 +77,6 @@ export class WsLogger { private formatBytes(): string { const bytes = prettyBytes(this.byteCount); - return this.unknownByteCount ? `>=${bytes}` : bytes; + return this.unknownByteCount ? `>= ${bytes}` : bytes; } } diff --git a/test/mocks/testHelpers.ts b/test/mocks/testHelpers.ts index 5cfe44e5..2ef46716 100644 --- a/test/mocks/testHelpers.ts +++ b/test/mocks/testHelpers.ts @@ -1,6 +1,8 @@ import { vi } from "vitest"; import * as vscode from "vscode"; +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(). @@ -286,3 +288,13 @@ export class InMemorySecretStorage implements vscode.SecretStorage { this.listeners.forEach((listener) => listener(event)); } } + +export function createMockLogger(): Logger { + return { + trace: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; +} diff --git a/test/unit/core/cliManager.test.ts b/test/unit/core/cliManager.test.ts index 2d76e8d4..3e1dfb0d 100644 --- a/test/unit/core/cliManager.test.ts +++ b/test/unit/core/cliManager.test.ts @@ -12,10 +12,10 @@ import * as vscode from "vscode"; import { CliManager } from "@/core/cliManager"; import * as cliUtils from "@/core/cliUtils"; import { PathResolver } from "@/core/pathResolver"; -import { type Logger } from "@/logging/logger"; import * as pgp from "@/pgp"; import { + createMockLogger, MockConfigurationProvider, MockProgressReporter, MockUserInteraction, @@ -625,16 +625,6 @@ describe("CliManager", () => { }); }); - function createMockLogger(): Logger { - return { - trace: vi.fn(), - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }; - } - function createMockApi(version: string, url: string): Api { const axios = { defaults: { baseURL: url }, @@ -740,10 +730,11 @@ describe("CliManager", () => { content: string, options: { chunkSize?: number; delay?: number } = {}, ): IncomingMessage { - const { chunkSize = 8, delay = 0 } = options; + 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) => { @@ -759,13 +750,20 @@ describe("CliManager", () => { 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") { - // Just close after a delay - setTimeout(() => callback(), 10); + closeCallback = callback; } }), destroy: vi.fn(), diff --git a/test/unit/logging/formatters.test.ts b/test/unit/logging/formatters.test.ts new file mode 100644 index 00000000..1cd4fedf --- /dev/null +++ b/test/unit/logging/formatters.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; + +import { + formatBody, + formatHeaders, + formatMethod, + formatSize, + formatTime, + formatUri, +} from "@/logging/formatters"; + +describe("Logging formatters", () => { + it("formats time in appropriate units", () => { + expect(formatTime(500)).toBe("500ms"); + expect(formatTime(1000)).toBe("1.00s"); + expect(formatTime(5500)).toBe("5.50s"); + expect(formatTime(60000)).toBe("1.00m"); + expect(formatTime(150000)).toBe("2.50m"); + expect(formatTime(3600000)).toBe("1.00h"); + expect(formatTime(7255000)).toBe("2.02h"); + }); + + describe("formatMethod", () => { + it("normalizes HTTP methods to uppercase", () => { + expect(formatMethod("get")).toBe("GET"); + expect(formatMethod("post")).toBe("POST"); + expect(formatMethod("PUT")).toBe("PUT"); + expect(formatMethod("delete")).toBe("DELETE"); + }); + + it("defaults to GET for falsy values", () => { + expect(formatMethod(undefined)).toBe("GET"); + expect(formatMethod("")).toBe("GET"); + }); + }); + + describe("formatSize", () => { + it("formats byte sizes using pretty-bytes", () => { + expect(formatSize(1024)).toContain("1.02 kB"); + expect(formatSize(0)).toBe("(0 B)"); + }); + + it("returns placeholder for undefined", () => { + expect(formatSize(undefined)).toBe("(? B)"); + }); + }); + + describe("formatUri", () => { + it("returns URL when present", () => { + expect(formatUri({ url: "https://example.com/api" })).toBe( + "https://example.com/api", + ); + expect(formatUri({ url: "/relative/path" })).toBe("/relative/path"); + }); + + it("returns placeholder for missing URL", () => { + expect(formatUri(undefined)).toContain("no url"); + expect(formatUri({})).toContain("no url"); + expect(formatUri({ url: "" })).toContain("no url"); + }); + }); + + describe("formatHeaders", () => { + it("formats headers as key-value pairs", () => { + const headers = { + "content-type": "application/json", + accept: "text/html", + }; + const result = formatHeaders(headers); + expect(result).toContain("content-type: application/json"); + expect(result).toContain("accept: text/html"); + }); + + it("redacts sensitive headers", () => { + const sensitiveHeaders = ["Coder-Session-Token", "Proxy-Authorization"]; + + sensitiveHeaders.forEach((header) => { + const result = formatHeaders({ [header]: "secret-value" }); + expect(result).toContain(`${header}: `); + expect(result).not.toContain("secret-value"); + }); + }); + + it("returns placeholder for empty headers", () => { + expect(formatHeaders({})).toBe(""); + }); + }); + + describe("formatBody", () => { + it("formats various body types", () => { + expect(formatBody({ key: "value" })).toContain("key: 'value'"); + expect(formatBody("plain text")).toContain("plain text"); + expect(formatBody([1, 2, 3])).toContain("1"); + expect(formatBody(123)).toContain("123"); + expect(formatBody(true)).toContain("true"); + }); + + it("handles circular references gracefully", () => { + const circular: Record = { a: 1 }; + circular.self = circular; + const result = formatBody(circular); + expect(result).toBeTruthy(); + expect(result).not.toContain("invalid body"); + expect(result).toContain("a: 1"); + }); + + it("handles deep nesting", () => { + const deep = { + level1: { level2: { level3: { level4: { value: "deep" } } } }, + }; + const result = formatBody(deep); + expect(result).toContain("level4: { value: 'deep' }"); + }); + + it("returns placeholder for empty values", () => { + const emptyValues = [null, undefined, "", 0, false]; + emptyValues.forEach((value) => { + expect(formatBody(value)).toContain("no body"); + }); + }); + }); +}); diff --git a/test/unit/logging/httpLogger.test.ts b/test/unit/logging/httpLogger.test.ts new file mode 100644 index 00000000..81cfbed8 --- /dev/null +++ b/test/unit/logging/httpLogger.test.ts @@ -0,0 +1,112 @@ +import { AxiosError, type AxiosHeaders, type AxiosResponse } from "axios"; +import { describe, expect, it, vi } from "vitest"; + +import { + createRequestMeta, + logError, + logRequest, + logResponse, +} from "@/logging/httpLogger"; +import { + HttpClientLogLevel, + type RequestConfigWithMeta, +} from "@/logging/types"; + +import { createMockLogger } from "../../mocks/testHelpers"; + +describe("REST HTTP Logger", () => { + describe("log level behavior", () => { + const config = { + method: "POST", + url: "https://api.example.com/endpoint", + headers: { + "content-type": "application/json", + } as unknown as AxiosHeaders, + data: { key: "value" }, + metadata: createRequestMeta(), + } as RequestConfigWithMeta; + + it("respects NONE level for trace logs", () => { + const logger = createMockLogger(); + + logRequest(logger, config, HttpClientLogLevel.NONE); + logResponse( + logger, + { status: 200 } as AxiosResponse, + HttpClientLogLevel.NONE, + ); + logError(logger, new Error("test"), HttpClientLogLevel.NONE); + + expect(logger.trace).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalled(); // always log errors + }); + + it("includes headers at HEADERS level but not at BASIC", () => { + const logger = createMockLogger(); + + logRequest(logger, config, HttpClientLogLevel.BASIC); + expect(logger.trace).not.toHaveBeenCalledWith( + expect.stringContaining("content-type"), + ); + + vi.clearAllMocks(); + logRequest(logger, config, HttpClientLogLevel.HEADERS); + expect(logger.trace).toHaveBeenCalledWith( + expect.stringContaining("content-type"), + ); + }); + + it("includes body at BODY level but not at HEADERS", () => { + const logger = createMockLogger(); + + logRequest(logger, config, HttpClientLogLevel.HEADERS); + expect(logger.trace).not.toHaveBeenCalledWith( + expect.stringContaining("key: 'value'"), + ); + + vi.clearAllMocks(); + logRequest(logger, config, HttpClientLogLevel.BODY); + expect(logger.trace).toHaveBeenCalledWith( + expect.stringContaining("key: 'value'"), + ); + }); + }); + + describe("error handling", () => { + it("distinguishes between network errors and response errors", () => { + const logger = createMockLogger(); + + const networkError = new AxiosError("Some Network Error", "ECONNREFUSED"); + networkError.config = { + metadata: createRequestMeta(), + } as RequestConfigWithMeta; + + logError(logger, networkError, HttpClientLogLevel.BASIC); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("Some Network Error"), + ); + + // Response error (4xx/5xx) + vi.clearAllMocks(); + const responseError = new AxiosError("Bad Request"); + responseError.config = { + metadata: createRequestMeta(), + } as RequestConfigWithMeta; + responseError.response = { status: 400 } as AxiosResponse; + + logError(logger, responseError, HttpClientLogLevel.BASIC); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("400")); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("Bad Request"), + ); + }); + + it("handles non-Axios errors", () => { + const logger = createMockLogger(); + const error = new Error("Generic error"); + + logError(logger, error, HttpClientLogLevel.BASIC); + expect(logger.error).toHaveBeenCalledWith("Request error", error); + }); + }); +}); diff --git a/test/unit/logging/utils.test.ts b/test/unit/logging/utils.test.ts new file mode 100644 index 00000000..3adbeecb --- /dev/null +++ b/test/unit/logging/utils.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; + +import { + createRequestId, + serializeValue, + shortId, + sizeOf, +} from "@/logging/utils"; + +describe("Logging utils", () => { + describe("shortId", () => { + it("truncates long strings to 8 characters", () => { + expect(shortId("abcdefghijklmnop")).toBe("abcdefgh"); + expect(shortId("12345678")).toBe("12345678"); + expect(shortId("123456789")).toBe("12345678"); + }); + + it("returns short strings unchanged", () => { + expect(shortId("short")).toBe("short"); + expect(shortId("")).toBe(""); + expect(shortId("1234567")).toBe("1234567"); + }); + }); + + describe("sizeOf", () => { + it.each([ + // Primitives return a fixed value + [null, 0], + [undefined, 0], + [42, 8], + [3.14, 8], + [false, 4], + // Strings + ["hello", 5], + ["✓", 3], + ["unicode: ✓", 12], + // Buffers + [Buffer.from("test"), 4], + [BigInt(12345), 5], + [BigInt(0), 1], + [Buffer.alloc(100), 100], + [Buffer.from([]), 0], + // Typed-arrays + [new ArrayBuffer(50), 50], + [new Uint8Array([1, 2, 3, 4]), 4], + [new Int32Array([1, 2, 3]), 12], + [new Float64Array([1.0, 2.0]), 16], + // Objects/untyped-arrays return undefined + [{ size: 1024 }, 1024], + [{ size: 0 }, 0], + [{ size: "not a number" }, undefined], + [[], undefined], + [[1, 2, 3], undefined], + [["a", "b", "c"], undefined], + [{}, undefined], + [{ foo: "bar" }, undefined], + [{ nested: { value: 123 } }, undefined], + ])("returns size for %s", (data: unknown, bytes: number | undefined) => { + expect(sizeOf(data)).toBe(bytes); + }); + + it("handles circular references safely", () => { + const circular: Record = { a: 1 }; + circular.self = circular; + expect(sizeOf(circular)).toBeUndefined(); + + const arr: unknown[] = [1, 2, 3]; + arr.push(arr); + expect(sizeOf(arr)).toBeUndefined(); + }); + }); + + describe("serializeValue", () => { + it("formats various data types", () => { + expect(serializeValue({ key: "value" })).toContain("key: 'value'"); + expect(serializeValue("plain text")).toContain("plain text"); + expect(serializeValue([1, 2, 3])).toContain("1"); + expect(serializeValue(123)).toContain("123"); + expect(serializeValue(true)).toContain("true"); + }); + + it("handles circular references safely", () => { + const circular: Record = { a: 1 }; + circular.self = circular; + const result = serializeValue(circular); + expect(result).toBeTruthy(); + expect(result).toContain("a: 1"); + }); + + it("handles deep nesting", () => { + const deep = { + level1: { level2: { level3: { level4: { value: "deep" } } } }, + }; + const result = serializeValue(deep); + expect(result).toContain("level4: { value: 'deep' }"); + }); + }); + + describe("createRequestId", () => { + it("generates valid UUID format without dashes", () => { + const id = createRequestId(); + expect(id).toHaveLength(32); + expect(id).not.toContain("-"); + }); + }); +}); diff --git a/test/unit/logging/wsLogger.test.ts b/test/unit/logging/wsLogger.test.ts new file mode 100644 index 00000000..5bf9d5b1 --- /dev/null +++ b/test/unit/logging/wsLogger.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; + +import { WsLogger } from "@/logging/wsLogger"; + +import { createMockLogger } from "../../mocks/testHelpers"; + +describe("WS Logger", () => { + it("tracks message count and byte size", () => { + const logger = createMockLogger(); + const wsLogger = new WsLogger(logger, "wss://example.com"); + + wsLogger.logOpen(); + wsLogger.logMessage("hello"); + wsLogger.logMessage("world"); + wsLogger.logMessage(Buffer.from("test")); + wsLogger.logClose(); + + expect(logger.trace).toHaveBeenCalledWith( + expect.stringContaining("3 msgs"), + ); + expect(logger.trace).toHaveBeenCalledWith(expect.stringContaining("14 B")); + }); + + it("handles unknown byte sizes with >= indicator", () => { + const logger = createMockLogger(); + const wsLogger = new WsLogger(logger, "wss://example.com"); + + wsLogger.logOpen(); + wsLogger.logMessage({ complex: "object" }); // Unknown size - no estimation + wsLogger.logMessage("known"); + wsLogger.logClose(); + + expect(logger.trace).toHaveBeenLastCalledWith( + expect.stringContaining(">= 5 B"), + ); + }); + + it("handles close before open gracefully", () => { + const logger = createMockLogger(); + const wsLogger = new WsLogger(logger, "wss://example.com"); + + // Closing without opening should not throw + expect(() => wsLogger.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"); + + wsLogger.logOpen(); + for (let i = 0; i < 1100; i++) { + wsLogger.logMessage("x"); + } + wsLogger.logClose(); + + expect(logger.trace).toHaveBeenLastCalledWith( + expect.stringMatching(/1[.,]1K\s*msgs/), + ); + }); + + it("logs errors with error object", () => { + const logger = createMockLogger(); + const wsLogger = new WsLogger(logger, "wss://example.com"); + const error = new Error("Connection failed"); + + wsLogger.logError(error, "Failed to connect"); + + expect(logger.error).toHaveBeenCalledWith(expect.any(String), error); + }); +}); From dc7688e0c630bbe420f36859577e6b464c5ba7c6 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 7 Oct 2025 11:07:59 +0300 Subject: [PATCH 22/69] Fix function rename in logging utils test (#614) --- test/unit/logging/utils.test.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/test/unit/logging/utils.test.ts b/test/unit/logging/utils.test.ts index 3adbeecb..4d0f71eb 100644 --- a/test/unit/logging/utils.test.ts +++ b/test/unit/logging/utils.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { createRequestId, - serializeValue, + safeStringify, shortId, sizeOf, } from "@/logging/utils"; @@ -70,19 +70,19 @@ describe("Logging utils", () => { }); }); - describe("serializeValue", () => { + describe("safeStringify", () => { it("formats various data types", () => { - expect(serializeValue({ key: "value" })).toContain("key: 'value'"); - expect(serializeValue("plain text")).toContain("plain text"); - expect(serializeValue([1, 2, 3])).toContain("1"); - expect(serializeValue(123)).toContain("123"); - expect(serializeValue(true)).toContain("true"); + expect(safeStringify({ key: "value" })).toContain("key: 'value'"); + expect(safeStringify("plain text")).toContain("plain text"); + expect(safeStringify([1, 2, 3])).toContain("1"); + expect(safeStringify(123)).toContain("123"); + expect(safeStringify(true)).toContain("true"); }); it("handles circular references safely", () => { const circular: Record = { a: 1 }; circular.self = circular; - const result = serializeValue(circular); + const result = safeStringify(circular); expect(result).toBeTruthy(); expect(result).toContain("a: 1"); }); @@ -91,7 +91,7 @@ describe("Logging utils", () => { const deep = { level1: { level2: { level3: { level4: { value: "deep" } } } }, }; - const result = serializeValue(deep); + const result = safeStringify(deep); expect(result).toContain("level4: { value: 'deep' }"); }); }); From a6cefa25145fce664dbd643eb9263c2dd894b3c2 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 7 Oct 2025 11:36:39 +0300 Subject: [PATCH 23/69] v1.11.1 (#615) --- CHANGELOG.md | 4 ++++ package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9127b22c..41b5e7ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,14 +2,18 @@ ## Unreleased +## [v1.11.1](https://github.com/coder/vscode-coder/releases/tag/v1.11.1) 2025-10-07 + ### Fixed - Logging in or out in one VS Code window now properly updates the authentication status in all other open windows. - Fix an issue with JSON stringification errors occurring when logging circular objects. +- Fix resource cleanup issues that could leave lingering components after extension deactivation. ### Added - Support for `CODER_BINARY_DESTINATION` environment variable to set CLI download location (overridden by extension setting `coder.binaryDestination` if configured). +- Search filter button to Coder Workspaces tree views for easier workspace discovery. ## [v1.11.0](https://github.com/coder/vscode-coder/releases/tag/v1.11.0) 2025-09-24 diff --git a/package.json b/package.json index 438ef3c7..dd8dce12 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "coder-remote", "displayName": "Coder", - "version": "1.11.0", + "version": "1.11.1", "description": "Open any workspace with a single click.", "categories": [ "Other" From 2cd05a38931e4c30a42503e3b24ec53425240ff8 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 7 Oct 2025 22:16:10 +0300 Subject: [PATCH 24/69] v1.11.2 (#616) --- .github/workflows/release.yaml | 2 +- CHANGELOG.md | 6 ++++++ README.md | 2 +- package.json | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index a6bf5fa4..27214dcc 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -22,7 +22,7 @@ jobs: - run: yarn - - run: npx vsce package + - run: npx @vscode/vsce package - uses: "marvinpinto/action-automatic-releases@latest" with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 41b5e7ff..52a8801a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +## [v1.11.2](https://github.com/coder/vscode-coder/releases/tag/v1.11.2) 2025-10-07 + +### Changed + +- Updated Visual Studio Marketplace badge in README to use img.shields.io service instead of vsmarketplacebadges. + ## [v1.11.1](https://github.com/coder/vscode-coder/releases/tag/v1.11.1) 2025-10-07 ### Fixed diff --git a/README.md b/README.md index b6bd81dd..05c11d2e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Coder Remote -[![Visual Studio Marketplace](https://vsmarketplacebadges.dev/version/coder.coder-remote.svg)](https://marketplace.visualstudio.com/items?itemName=coder.coder-remote) +[![Visual Studio Marketplace](https://img.shields.io/visual-studio-marketplace/v/coder.coder-remote?label=Visual%20Studio%20Marketplace&color=%233fba11)](https://marketplace.visualstudio.com/items?itemName=coder.coder-remote) [![Open VSX Version](https://img.shields.io/open-vsx/v/coder/coder-remote)](https://open-vsx.org/extension/coder/coder-remote) [!["Join us on Discord"](https://badgen.net/discord/online-members/coder)](https://coder.com/chat?utm_source=github.com/coder/vscode-coder&utm_medium=github&utm_campaign=readme.md) diff --git a/package.json b/package.json index dd8dce12..9d2ea2a3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "coder-remote", "displayName": "Coder", - "version": "1.11.1", + "version": "1.11.2", "description": "Open any workspace with a single click.", "categories": [ "Other" From a4018052e3c11fc97d1e98d4d3ab006399a8a1f1 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 13 Oct 2025 23:49:14 +0300 Subject: [PATCH 25/69] Fixed WebSocket connections not receiving headers from the configured header command (#619) Closes #618 --- CHANGELOG.md | 5 ++ src/{ => api}/agentMetadataHelper.ts | 10 ++-- src/api/coderApi.ts | 30 +++++++---- src/api/utils.ts | 18 +++++-- src/api/workspace.ts | 10 ++-- src/headers.ts | 25 +++++----- src/inbox.ts | 48 ++++++++++++------ src/remote/remote.ts | 39 +++++++-------- src/workspace/workspaceMonitor.ts | 74 ++++++++++++++++++---------- src/workspace/workspacesProvider.ts | 15 ++++-- 10 files changed, 167 insertions(+), 107 deletions(-) rename src/{ => api}/agentMetadataHelper.ts (91%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52a8801a..ef80cd1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### 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 diff --git a/src/agentMetadataHelper.ts b/src/api/agentMetadataHelper.ts similarity index 91% rename from src/agentMetadataHelper.ts rename to src/api/agentMetadataHelper.ts index 0a976411..4de804ad 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(); diff --git a/src/api/coderApi.ts b/src/api/coderApi.ts index 1d523b60..99976ff7 100644 --- a/src/api/coderApi.ts +++ b/src/api/coderApi.ts @@ -67,7 +67,7 @@ export class CoderApi extends Api { return client; } - watchInboxNotifications = ( + watchInboxNotifications = async ( watchTemplates: string[], watchTargets: string[], options?: ClientOptions, @@ -83,14 +83,14 @@ export class CoderApi extends Api { }); }; - watchWorkspace = (workspace: Workspace, options?: ClientOptions) => { + watchWorkspace = async (workspace: Workspace, options?: ClientOptions) => { return this.createWebSocket({ apiRoute: `/api/v2/workspaces/${workspace.id}/watch-ws`, options, }); }; - watchAgentMetadata = ( + watchAgentMetadata = async ( agentId: WorkspaceAgent["id"], options?: ClientOptions, ) => { @@ -100,21 +100,22 @@ export class CoderApi extends Api { }); }; - watchBuildLogsByBuildId = (buildId: string, logs: ProvisionerJobLog[]) => { + watchBuildLogsByBuildId = async ( + buildId: string, + logs: ProvisionerJobLog[], + ) => { const searchParams = new URLSearchParams({ follow: "true" }); if (logs.length) { searchParams.append("after", logs[logs.length - 1].id.toString()); } - const socket = this.createWebSocket({ + return this.createWebSocket({ apiRoute: `/api/v2/workspacebuilds/${buildId}/logs`, searchParams, }); - - return socket; }; - private createWebSocket( + private async createWebSocket( configs: Omit, ) { const baseUrlRaw = this.getAxiosInstance().defaults.baseURL; @@ -127,7 +128,15 @@ export class CoderApi extends Api { coderSessionTokenHeader ] as string | undefined; - const httpAgent = createHttpAgent(vscode.workspace.getConfiguration()); + const headers = await getHeaders( + baseUrlRaw, + getHeaderCommand(vscode.workspace.getConfiguration()), + this.output, + ); + + const httpAgent = await createHttpAgent( + vscode.workspace.getConfiguration(), + ); const webSocket = new OneWayWebSocket({ location: baseUrl, ...configs, @@ -137,6 +146,7 @@ export class CoderApi extends Api { headers: { ...(token ? { [coderSessionTokenHeader]: token } : {}), ...configs.options?.headers, + ...headers, }, ...configs.options, }, @@ -191,7 +201,7 @@ function setupInterceptors( // 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; diff --git a/src/api/utils.ts b/src/api/utils.ts index 91a18885..0f13288e 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -1,4 +1,4 @@ -import fs from "fs"; +import fs from "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..cb03d9fc 100644 --- a/src/api/workspace.ts +++ b/src/api/workspace.ts @@ -95,12 +95,12 @@ export async function waitForBuild( 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, - ); + const socket = await client.watchBuildLogsByBuildId( + workspace.latest_build.id, + logs, + ); + await new Promise((resolve, reject) => { socket.addEventListener("message", (data) => { if (data.parseError) { writeEmitter.fire( diff --git a/src/headers.ts b/src/headers.ts index f5f45301..6c69258c 100644 --- a/src/headers.ts +++ b/src/headers.ts @@ -24,7 +24,7 @@ export function getHeaderCommand( config.get("coder.headerCommand")?.trim() || process.env.CODER_HEADER_COMMAND?.trim(); - return cmd ? cmd : undefined; + return cmd || undefined; } export function getHeaderArgs(config: WorkspaceConfiguration): string[] { @@ -44,16 +44,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 +87,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 +97,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..8dff573f 100644 --- a/src/inbox.ts +++ b/src/inbox.ts @@ -16,12 +16,21 @@ 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: OneWayWebSocket | 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 +39,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/remote/remote.ts b/src/remote/remote.ts index 832a8086..97cb858e 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -18,7 +18,7 @@ import { getEventValue, formatEventLabel, formatMetadataError, -} from "../agentMetadataHelper"; +} from "../api/agentMetadataHelper"; import { createWorkspaceIdentifier, extractAgents } from "../api/api-helper"; import { CoderApi } from "../api/coderApi"; import { needToken } from "../api/utils"; @@ -135,9 +135,7 @@ export class Remote { let attempts = 0; function initWriteEmitterAndTerminal(): vscode.EventEmitter { - if (!writeEmitter) { - writeEmitter = new vscode.EventEmitter(); - } + writeEmitter ??= new vscode.EventEmitter(); if (!terminal) { terminal = vscode.window.createTerminal({ name: "Build Log", @@ -295,16 +293,14 @@ export class Remote { if (result.type === "login") { return this.setup(remoteAuthority, firstConnect); + } else if (!result.userChoice) { + // User declined to log in. + await this.closeRemote(); + return; } else { - if (!result.userChoice) { - // User declined to log in. - await this.closeRemote(); - return; - } else { - // Log in then try again. - await this.commands.login({ url: baseUrlRaw, label: parts.label }); - return this.setup(remoteAuthority, firstConnect); - } + // Log in then try again. + await this.commands.login({ url: baseUrlRaw, label: parts.label }); + return this.setup(remoteAuthority, firstConnect); } }; @@ -543,7 +539,7 @@ export class Remote { } // Watch the workspace for changes. - const monitor = new WorkspaceMonitor( + const monitor = await WorkspaceMonitor.create( workspace, workspaceClient, this.logger, @@ -556,7 +552,7 @@ export class Remote { ); // Watch coder inbox for messages - const inbox = new Inbox(workspace, workspaceClient, this.logger); + const inbox = await Inbox.create(workspace, workspaceClient, this.logger); disposables.push(inbox); // Wait for the agent to connect. @@ -668,7 +664,7 @@ export class Remote { agent.name, ); }), - ...this.createAgentMetadataStatusBar(agent, workspaceClient), + ...(await this.createAgentMetadataStatusBar(agent, workspaceClient)), ); } catch (ex) { // Whatever error happens, make sure we clean up the disposables in case of failure @@ -858,8 +854,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; } @@ -1005,7 +1000,7 @@ export class Remote { // 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); + const port = findPort(text); if (!port) { return; } @@ -1064,16 +1059,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/workspace/workspaceMonitor.ts b/src/workspace/workspaceMonitor.ts index 0b154f75..a761249a 100644 --- a/src/workspace/workspaceMonitor.ts +++ b/src/workspace/workspaceMonitor.ts @@ -17,12 +17,12 @@ 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: OneWayWebSocket | 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; @@ -36,7 +36,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 +45,67 @@ 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); + 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; - - this.update(workspace); // Set initial state. + return monitor; } /** @@ -91,7 +115,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; } } 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); } From 5165adeaccfd069069f0532ee44e7f5b3fb69d26 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 14 Oct 2025 11:23:43 +0300 Subject: [PATCH 26/69] Fix unit tests on windows (#617) Closes #604 --- package.json | 2 +- test/fixtures/{ => scripts}/bin.bash | 0 test/fixtures/{ => scripts}/bin.old.bash | 0 test/unit/core/cliManager.test.ts | 34 ++-- test/unit/core/cliUtils.test.ts | 14 +- test/unit/core/pathResolver.test.ts | 28 ++- test/unit/globalFlags.test.ts | 13 +- test/unit/headers.test.ts | 243 ++++++++++++----------- test/utils/platform.test.ts | 86 ++++++++ test/utils/platform.ts | 46 +++++ vitest.config.ts | 9 +- 11 files changed, 315 insertions(+), 160 deletions(-) rename test/fixtures/{ => scripts}/bin.bash (100%) rename test/fixtures/{ => scripts}/bin.old.bash (100%) create mode 100644 test/utils/platform.test.ts create mode 100644 test/utils/platform.ts diff --git a/package.json b/package.json index 9d2ea2a3..02a6ddc3 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "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", + "pretest": "tsc -p . --outDir out && tsc -p test --outDir out && yarn run build && yarn run lint", "test": "vitest", "test:ci": "CI=true yarn test", "test:integration": "vscode-test", 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/unit/core/cliManager.test.ts b/test/unit/core/cliManager.test.ts index 3e1dfb0d..f2a2c2e5 100644 --- a/test/unit/core/cliManager.test.ts +++ b/test/unit/core/cliManager.test.ts @@ -20,6 +20,7 @@ import { MockProgressReporter, MockUserInteraction, } from "../../mocks/testHelpers"; +import { expectPathsEqual } from "../../utils/platform"; vi.mock("os"); vi.mock("axios"); @@ -213,7 +214,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 +227,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 +237,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 +250,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 +263,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 +277,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 +393,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 +461,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 +495,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 +506,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 +520,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,7 +540,7 @@ 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(); @@ -553,7 +554,7 @@ 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(); }); @@ -615,13 +616,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)); }); }); 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/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/globalFlags.test.ts b/test/unit/globalFlags.test.ts index d570d609..94c89dba 100644 --- a/test/unit/globalFlags.test.ts +++ b/test/unit/globalFlags.test.ts @@ -3,6 +3,8 @@ import { type WorkspaceConfiguration } from "vscode"; import { getGlobalFlags } from "@/globalFlags"; +import { isWindows } from "../utils/platform"; + describe("Global flags suite", () => { it("should return global-config and header args when no global flags configured", () => { const config = { @@ -53,10 +55,11 @@ describe("Global flags suite", () => { }); it("should not filter header-command flags, header args appended at end", () => { + const headerCommand = "echo test"; const config = { get: (key: string) => { if (key === "coder.headerCommand") { - return "echo test"; + return headerCommand; } if (key === "coder.globalFlags") { return ["-v", "--header-command custom", "--no-feature-warning"]; @@ -73,7 +76,13 @@ describe("Global flags suite", () => { "--global-config", '"/config/dir"', "--header-command", - "'echo test'", + quoteCommand(headerCommand), ]); }); }); + +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/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/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..40c5f958 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,13 +5,8 @@ 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: { From f9b1f2516638afb466b11a0ccdb6747459900c27 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Fri, 17 Oct 2025 11:34:38 +0300 Subject: [PATCH 27/69] Add SSE fallback to some one way WS connections (#623) Add SSE fallback to some WS connections: * `/api/v2/workspaces/${workspace.id}/watch-ws` -> `/api/v2/workspaceagents/${agentId}/watch-metadata` * `/api/v2/workspaceagents/${agentId}/watch-metadata-ws` -> `/api/v2/workspaceagents/${agentId}/watch-metadata` Restored the previous code regarding `createStreamingFetchAdapter` to stream in SSE events. * Implemented a unified interface for WS and SSE to be similar to the `OneWayWebSocket`. * Added unified logging for WS and SSE. * Fixed issue with headers order precedence * Add tests for `CoderApi` Closes #620 --- src/api/coderApi.ts | 171 +++++-- src/api/streamingFetchAdapter.ts | 71 +++ .../{wsLogger.ts => eventStreamLogger.ts} | 16 +- src/websocket/eventStreamConnection.ts | 51 +++ src/websocket/oneWayWebSocket.ts | 69 +-- src/websocket/sseConnection.ts | 221 +++++++++ src/websocket/utils.ts | 15 + src/workspace/workspaceMonitor.ts | 14 +- test/unit/api/coderApi.test.ts | 431 ++++++++++++++++++ ...gger.test.ts => eventStreamLogger.test.ts} | 62 ++- 10 files changed, 1005 insertions(+), 116 deletions(-) create mode 100644 src/api/streamingFetchAdapter.ts rename src/logging/{wsLogger.ts => eventStreamLogger.ts} (77%) create mode 100644 src/websocket/eventStreamConnection.ts create mode 100644 src/websocket/sseConnection.ts create mode 100644 src/websocket/utils.ts create mode 100644 test/unit/api/coderApi.test.ts rename test/unit/logging/{wsLogger.test.ts => eventStreamLogger.test.ts} (50%) diff --git a/src/api/coderApi.ts b/src/api/coderApi.ts index 99976ff7..6509ac67 100644 --- a/src/api/coderApi.ts +++ b/src/api/coderApi.ts @@ -6,17 +6,18 @@ import { } from "axios"; import { Api } from "coder/site/src/api/api"; import { + type ServerSentEvent, type GetInboxNotificationResponse, type ProvisionerJobLog, - type ServerSentEvent, type Workspace, type WorkspaceAgent, } from "coder/site/src/api/typesGenerated"; import * as vscode from "vscode"; -import { type ClientOptions } from "ws"; +import { type ClientOptions, type CloseEvent, type ErrorEvent } from "ws"; import { CertificateError } from "../error"; import { getHeaderCommand, getHeaders } from "../headers"; +import { EventStreamLogger } from "../logging/eventStreamLogger"; import { createRequestMeta, logRequest, @@ -29,11 +30,12 @@ import { HttpClientLogLevel, } from "../logging/types"; import { sizeOf } from "../logging/utils"; -import { WsLogger } from "../logging/wsLogger"; +import { type UnidirectionalStream } from "../websocket/eventStreamConnection"; import { OneWayWebSocket, type OneWayWebSocketInit, } from "../websocket/oneWayWebSocket"; +import { SseConnection } from "../websocket/sseConnection"; import { createHttpAgent } from "./utils"; @@ -84,8 +86,9 @@ export class CoderApi extends Api { }; watchWorkspace = async (workspace: Workspace, options?: ClientOptions) => { - return this.createWebSocket({ + return this.createWebSocketWithFallback({ apiRoute: `/api/v2/workspaces/${workspace.id}/watch-ws`, + fallbackApiRoute: `/api/v2/workspaces/${workspace.id}/watch`, options, }); }; @@ -94,8 +97,9 @@ export class CoderApi extends Api { agentId: WorkspaceAgent["id"], options?: ClientOptions, ) => { - return this.createWebSocket({ + return this.createWebSocketWithFallback({ apiRoute: `/api/v2/workspaceagents/${agentId}/watch-metadata-ws`, + fallbackApiRoute: `/api/v2/workspaceagents/${agentId}/watch-metadata`, options, }); }; @@ -103,6 +107,7 @@ export class CoderApi extends Api { watchBuildLogsByBuildId = async ( buildId: string, logs: ProvisionerJobLog[], + options?: ClientOptions, ) => { const searchParams = new URLSearchParams({ follow: "true" }); if (logs.length) { @@ -112,6 +117,7 @@ export class CoderApi extends Api { return this.createWebSocket({ apiRoute: `/api/v2/workspacebuilds/${buildId}/logs`, searchParams, + options, }); }; @@ -128,7 +134,7 @@ export class CoderApi extends Api { coderSessionTokenHeader ] as string | undefined; - const headers = await getHeaders( + const headersFromCommand = await getHeaders( baseUrlRaw, getHeaderCommand(vscode.workspace.getConfiguration()), this.output, @@ -137,43 +143,154 @@ export class CoderApi extends Api { 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 webSocket = new OneWayWebSocket({ location: baseUrl, ...configs, options: { + ...configs.options, agent: httpAgent, followRedirects: true, - headers: { - ...(token ? { [coderSessionTokenHeader]: token } : {}), - ...configs.options?.headers, - ...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(webSocket); + return webSocket; + } - 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), + ); + } - webSocket.addEventListener("close", (event) => { - wsLogger.logClose(event.code, event.reason); + /** + * Create a WebSocket connection with SSE fallback on 404. + * + * Note: The fallback on SSE ignores all passed client options except the headers. + */ + private async createWebSocketWithFallback(configs: { + apiRoute: string; + fallbackApiRoute: string; + searchParams?: Record | URLSearchParams; + options?: ClientOptions; + }): Promise> { + let webSocket: OneWayWebSocket; + try { + webSocket = await this.createWebSocket({ + apiRoute: configs.apiRoute, + searchParams: configs.searchParams, + options: configs.options, + }); + } catch { + // Failed to create WebSocket, use SSE fallback + return this.createSseFallback( + configs.fallbackApiRoute, + configs.searchParams, + configs.options?.headers, + ); + } + + return this.waitForConnection(webSocket, () => + this.createSseFallback( + configs.fallbackApiRoute, + configs.searchParams, + configs.options?.headers, + ), + ); + } + + private waitForConnection( + connection: UnidirectionalStream, + onNotFound?: () => Promise>, + ): 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(); + const is404 = + event.message?.includes("404") || + event.error?.message?.includes("404"); + + if (is404 && onNotFound) { + connection.close(); + onNotFound().then(resolve).catch(reject); + } else { + reject(event.error || new Error(event.message)); + } + }; + + connection.addEventListener("open", handleOpen); + connection.addEventListener("error", handleError); }); + } + + /** + * Create SSE fallback connection + */ + private async createSseFallback( + apiRoute: string, + searchParams?: Record | URLSearchParams, + optionsHeaders?: Record, + ): Promise> { + this.output.warn(`WebSocket failed, using SSE fallback: ${apiRoute}`); + + const baseUrlRaw = this.getAxiosInstance().defaults.baseURL; + if (!baseUrlRaw) { + throw new Error("No base URL set on REST client"); + } - webSocket.addEventListener("error", (event) => { - wsLogger.logError(event.error, event.message); + const baseUrl = new URL(baseUrlRaw); + const sseConnection = new SseConnection({ + location: baseUrl, + apiRoute, + searchParams, + axiosInstance: this.getAxiosInstance(), + optionsHeaders: optionsHeaders, + logger: this.output, }); - return webSocket; + this.attachStreamLogger(sseConnection); + return this.waitForConnection(sseConnection); } } diff --git a/src/api/streamingFetchAdapter.ts b/src/api/streamingFetchAdapter.ts new file mode 100644 index 00000000..f0730535 --- /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 "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/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/websocket/eventStreamConnection.ts b/src/websocket/eventStreamConnection.ts new file mode 100644 index 00000000..2dc6514e --- /dev/null +++ b/src/websocket/eventStreamConnection.ts @@ -0,0 +1,51 @@ +import { type WebSocketEventType } from "coder/site/src/utils/OneWayWebSocket"; +import { + type CloseEvent, + type Event as WsEvent, + type ErrorEvent, + type MessageEvent, +} from "ws"; + +// 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: WsEvent; +}; + +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/sseConnection.ts b/src/websocket/sseConnection.ts new file mode 100644 index 00000000..834100aa --- /dev/null +++ b/src/websocket/sseConnection.ts @@ -0,0 +1,221 @@ +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 { getQueryString } from "./utils"; + +import type { + CloseEvent as WsCloseEvent, + ErrorEvent as WsErrorEvent, + Event as WsEvent, + MessageEvent as WsMessageEvent, +} from "ws"; + +import type { + UnidirectionalStream, + ParsedMessageEvent, + EventHandler, +} 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, {} as WsEvent, "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: 1006, + reason: "Connection lost", + wasClean: false, + } as WsCloseEvent, + "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 { + const errorMessage = + event instanceof ErrorEvent && event.message + ? event.message + : "SSE connection error"; + const error = event instanceof ErrorEvent ? event.error : undefined; + + return { + error: error, + message: errorMessage, + } as WsErrorEvent; + } + + 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 } as WsMessageEvent; + 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 ?? 1000, + reason: reason ?? "Normal closure", + wasClean: true, + } as WsCloseEvent, + "close", + ); + + Object.values(this.callbacks).forEach((callbackSet) => 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 a761249a..ceea8a91 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,7 +17,7 @@ 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 | undefined; + private socket: UnidirectionalStream | undefined; private disposed = false; // How soon in advance to notify about autostop and deletion. @@ -93,10 +93,12 @@ export class WorkspaceMonitor implements vscode.Disposable { return; } // Perhaps we need to parse this and validate it. - const newWorkspaceData = event.parsedMessage.data as Workspace; - monitor.update(newWorkspaceData); - monitor.maybeNotify(newWorkspaceData); - monitor.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) { monitor.notifyError(error); } diff --git a/test/unit/api/coderApi.test.ts b/test/unit/api/coderApi.test.ts new file mode 100644 index 00000000..0336d564 --- /dev/null +++ b/test/unit/api/coderApi.test.ts @@ -0,0 +1,431 @@ +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 { OneWayWebSocket } from "@/websocket/oneWayWebSocket"; +import { SseConnection } from "@/websocket/sseConnection"; + +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 > config > 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 > config > 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(OneWayWebSocket); + expect(EventSource).not.toHaveBeenCalled(); + }); + + it("falls back to SSE when WebSocket creation fails", async () => { + vi.mocked(Ws).mockImplementation(() => { + throw new Error("WebSocket creation failed"); + }); + + const connection = await api.watchAgentMetadata(AGENT_ID); + + expect(connection).toBeInstanceOf(SseConnection); + expect(EventSource).toHaveBeenCalled(); + }); + + it("falls back to SSE on 404 error from WebSocket", 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); + + expect(connection).toBeInstanceOf(SseConnection); + expect(EventSource).toHaveBeenCalled(); + }); + }); + + 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", + ); + }); + }); +}); + +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, + }; +} + +function createMockEventSource(url: string): Partial { + return { + url, + readyState: EventSource.CONNECTING, + addEventListener: vi.fn((event, handler) => { + if (event === "open") { + setImmediate(() => handler(new Event("open"))); + } + }), + removeEventListener: vi.fn(), + close: vi.fn(), + }; +} + +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/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); }); From 3c499bafbd0a1a278684e45c0262c3b0837c8917 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 21 Oct 2025 12:19:06 +0300 Subject: [PATCH 28/69] Add SSE Connection and streamingFetchAdapter tests (#625) --- src/api/coderApi.ts | 11 +- src/api/streamingFetchAdapter.ts | 2 +- src/websocket/sseConnection.ts | 9 +- test/unit/api/coderApi.test.ts | 4 +- test/unit/api/streamingFetchAdapter.test.ts | 220 ++++++++++++ test/unit/core/cliManager.test.ts | 5 +- test/unit/logging/utils.test.ts | 3 +- test/unit/websocket/sseConnection.test.ts | 356 ++++++++++++++++++++ vitest.config.ts | 2 +- 9 files changed, 595 insertions(+), 17 deletions(-) create mode 100644 test/unit/api/streamingFetchAdapter.test.ts create mode 100644 test/unit/websocket/sseConnection.test.ts diff --git a/src/api/coderApi.ts b/src/api/coderApi.ts index 6509ac67..da624bad 100644 --- a/src/api/coderApi.ts +++ b/src/api/coderApi.ts @@ -110,8 +110,9 @@ export class CoderApi extends Api { 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()); } return this.createWebSocket({ @@ -311,9 +312,9 @@ function setupInterceptors( 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 @@ -425,7 +426,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 index f0730535..f23ef1a7 100644 --- a/src/api/streamingFetchAdapter.ts +++ b/src/api/streamingFetchAdapter.ts @@ -1,6 +1,6 @@ import { type AxiosInstance } from "axios"; import { type FetchLikeInit, type FetchLikeResponse } from "eventsource"; -import { type IncomingMessage } from "http"; +import { type IncomingMessage } from "node:http"; /** * Creates a fetch adapter using an Axios instance that returns streaming responses. diff --git a/src/websocket/sseConnection.ts b/src/websocket/sseConnection.ts index 834100aa..5a71d303 100644 --- a/src/websocket/sseConnection.ts +++ b/src/websocket/sseConnection.ts @@ -109,11 +109,10 @@ export class SseConnection implements UnidirectionalStream { } private createErrorEvent(event: Event | ErrorEvent): WsErrorEvent { - const errorMessage = - event instanceof ErrorEvent && event.message - ? event.message - : "SSE connection error"; - const error = event instanceof ErrorEvent ? event.error : undefined; + // 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, diff --git a/test/unit/api/coderApi.test.ts b/test/unit/api/coderApi.test.ts index 0336d564..f133a72d 100644 --- a/test/unit/api/coderApi.test.ts +++ b/test/unit/api/coderApi.test.ts @@ -125,7 +125,7 @@ describe("CoderApi", () => { expect(thrownError.x509Err).toBeDefined(); }); - it("applies headers in correct precedence order (command > config > axios default)", async () => { + 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 @@ -225,7 +225,7 @@ describe("CoderApi", () => { }); }); - it("applies headers in correct precedence order (command > config > axios default)", async () => { + 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, []); 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/core/cliManager.test.ts b/test/unit/core/cliManager.test.ts index f2a2c2e5..d4f16c87 100644 --- a/test/unit/core/cliManager.test.ts +++ b/test/unit/core/cliManager.test.ts @@ -546,7 +546,8 @@ describe("CliManager", () => { 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) => { @@ -558,7 +559,7 @@ describe("CliManager", () => { expect(pgp.verifySignature).not.toHaveBeenCalled(); }); - it.each([ + it.each([ [404, "Signature not found"], [500, "Failed to download signature"], ])( 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/websocket/sseConnection.test.ts b/test/unit/websocket/sseConnection.test.ts new file mode 100644 index 00000000..61cfce4d --- /dev/null +++ b/test/unit/websocket/sseConnection.test.ts @@ -0,0 +1,356 @@ +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 CloseEvent, type ErrorEvent } from "ws"; + +import { type Logger } from "@/logging/logger"; +import { type ParsedMessageEvent } 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: 1006, + 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: Omit, + ]; + it.each([ + [ + undefined, + undefined, + { code: 1000, 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/vitest.config.ts b/vitest.config.ts index 40c5f958..a3fcd089 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,4 +1,4 @@ -import path from "path"; +import path from "node:path"; import { defineConfig } from "vitest/config"; export default defineConfig({ From e64350fd448e63ceb982e1894668816bd091b42e Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 21 Oct 2025 12:33:36 +0300 Subject: [PATCH 29/69] Update CLAUDE.md (#628) --- CLAUDE.md | 51 +++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 8 deletions(-) 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 From ee0a964ba1cc6d353cd27fac453197fec2dcfd01 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Oct 2025 10:35:20 +0300 Subject: [PATCH 30/69] chore(deps): bump vite from 7.1.5 to 7.1.11 (#631) --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index a067635f..f951b225 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8775,9 +8775,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" From b9be79b7daf38a2c81e72c785b63451f9f72773c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Oct 2025 10:40:02 +0300 Subject: [PATCH 31/69] chore(deps): bump openpgp from 6.2.0 to 6.2.2 (#611) --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 02a6ddc3..95da87d2 100644 --- a/package.json +++ b/package.json @@ -349,7 +349,7 @@ "find-process": "https://github.com/coder/find-process#fix/sequoia-compat", "jsonc-parser": "^3.3.1", "node-forge": "^1.3.1", - "openpgp": "^6.2.0", + "openpgp": "^6.2.2", "pretty-bytes": "^7.0.0", "proxy-agent": "^6.5.0", "semver": "^7.7.1", diff --git a/yarn.lock b/yarn.lock index f951b225..814d8e9c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6056,10 +6056,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" From f543fa4bb9bad01b9b98733024983f0e56b08b87 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Oct 2025 10:42:27 +0300 Subject: [PATCH 32/69] chore(deps): bump ws from 8.18.2 to 8.18.3 (#610) --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 95da87d2..218849a5 100644 --- a/package.json +++ b/package.json @@ -354,7 +354,7 @@ "proxy-agent": "^6.5.0", "semver": "^7.7.1", "ua-parser-js": "1.0.40", - "ws": "^8.18.2", + "ws": "^8.18.3", "zod": "^3.25.65" }, "devDependencies": { diff --git a/yarn.lock b/yarn.lock index 814d8e9c..a18ff730 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9113,10 +9113,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" From 591a74bcc5b0349734673ffee19836d3fc6b8402 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Oct 2025 10:45:22 +0300 Subject: [PATCH 33/69] chore(deps-dev): bump typescript from 5.9.2 to 5.9.3 (#612) --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 218849a5..3e8c9b8d 100644 --- a/package.json +++ b/package.json @@ -389,7 +389,7 @@ "nyc": "^17.1.0", "prettier": "^3.5.3", "ts-loader": "^9.5.1", - "typescript": "^5.9.2", + "typescript": "^5.9.3", "utf-8-validate": "^6.0.5", "vitest": "^3.2.4", "vscode-test": "^1.5.0", diff --git a/yarn.lock b/yarn.lock index a18ff730..c8d1ff6a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8490,10 +8490,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" From d5c65e852f89f27fc64d0a93db03d5870ae06131 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Oct 2025 10:49:50 +0300 Subject: [PATCH 34/69] chore(deps-dev): bump memfs from 4.47.0 to 4.49.0 (#622) --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 3e8c9b8d..1c6abd4e 100644 --- a/package.json +++ b/package.json @@ -385,7 +385,7 @@ "glob": "^10.4.2", "jsonc-eslint-parser": "^2.4.0", "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", diff --git a/yarn.lock b/yarn.lock index c8d1ff6a..47f44b96 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5672,10 +5672,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" From 1bb05c5c7aca8516843c256556f92375bf0a8c32 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Oct 2025 11:17:52 +0300 Subject: [PATCH 35/69] chore(deps): bump semver from 7.7.1 to 7.7.3 (#621) --- package.json | 4 ++-- yarn.lock | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 1c6abd4e..363119d5 100644 --- a/package.json +++ b/package.json @@ -338,7 +338,7 @@ "onUri" ], "resolutions": { - "semver": "7.7.1", + "semver": "7.7.3", "trim": "0.0.3", "word-wrap": "1.2.5" }, @@ -352,7 +352,7 @@ "openpgp": "^6.2.2", "pretty-bytes": "^7.0.0", "proxy-agent": "^6.5.0", - "semver": "^7.7.1", + "semver": "^7.7.3", "ua-parser-js": "1.0.40", "ws": "^8.18.3", "zod": "^3.25.65" diff --git a/yarn.lock b/yarn.lock index 47f44b96..3e38b6a6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7462,10 +7462,10 @@ secretlint@^10.1.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@7.7.3, 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, 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-javascript@^6.0.2: version "6.0.2" From 9c884dfd728b47174b297008740ddd66767b4bb7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Oct 2025 11:20:48 +0300 Subject: [PATCH 36/69] chore(deps): bump actions/setup-node from 5 to 6 (#630) --- .github/workflows/ci.yaml | 4 ++-- .github/workflows/release.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 59a03e0a..87a03723 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -16,7 +16,7 @@ jobs: steps: - uses: actions/checkout@v5 - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v6 with: node-version: "22" @@ -34,7 +34,7 @@ jobs: steps: - uses: actions/checkout@v5 - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v6 with: node-version: "22" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 27214dcc..28f8fdf0 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -16,7 +16,7 @@ jobs: steps: - uses: actions/checkout@v5 - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v6 with: node-version: "22" From 930d54329f26ce13793c59f8521d8281be8b55ef Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 23 Oct 2025 02:35:44 +0300 Subject: [PATCH 37/69] Automatically publish extension and add pre-release version (#624) - Automatically publish the pre-release when pushing a `v*-pre` tag to `main`. - Automatically publish the stable release when pushing a `v*` tag to `main`. - Package VSIX on every PR and merge to main. For PRs, it is retained for 7 days, but for main it's retained indefinitely. - Skips publishing if the secret for that platform is not set. Closes #97 --- .github/workflows/ci.yaml | 59 ++++++++++- .github/workflows/pre-release.yaml | 78 ++++++++++++++ .github/workflows/publish-extension.yaml | 125 +++++++++++++++++++++++ .github/workflows/release.yaml | 67 ++++++++++-- 4 files changed, 317 insertions(+), 12 deletions(-) create mode 100644 .github/workflows/pre-release.yaml create mode 100644 .github/workflows/publish-extension.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 87a03723..a878f9f2 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,4 +1,4 @@ -name: ci +name: CI on: push: @@ -11,6 +11,7 @@ on: jobs: lint: + name: Lint runs-on: ubuntu-22.04 steps: @@ -19,6 +20,7 @@ jobs: - uses: actions/setup-node@v6 with: node-version: "22" + cache: "yarn" - run: yarn @@ -29,6 +31,7 @@ jobs: - run: yarn build test: + name: Test runs-on: ubuntu-22.04 steps: @@ -37,7 +40,61 @@ jobs: - 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@v5 + + - 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@v4 + 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@v4 + 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..61761700 --- /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@v5 + + - 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@v4 + 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..cf93d6ba --- /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@v5 + + - 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@v5 + 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@v5 + 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@v5 + 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: "${{ inputs.isPreRelease && 'Pre-' || '' }}Release v${{ inputs.version }}" + files: ${{ needs.setup.outputs.packageName }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 28f8fdf0..51d9ff97 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -1,18 +1,21 @@ +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 @@ -20,14 +23,56 @@ jobs: 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@v4 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 }} From c1206b2497e20cbde7ccde895c948f109c4789fc Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 23 Oct 2025 13:10:47 +0300 Subject: [PATCH 38/69] v1.11.3 (#632) --- CHANGELOG.md | 2 ++ package.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ef80cd1a..927d6d12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## [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 diff --git a/package.json b/package.json index 363119d5..25db26f1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "coder-remote", "displayName": "Coder", - "version": "1.11.2", + "version": "1.11.3", "description": "Open any workspace with a single click.", "categories": [ "Other" From ffe0182de549792080c9a54c2a1c72d269ca7e36 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Oct 2025 12:54:19 -0800 Subject: [PATCH 39/69] chore(deps-dev): bump @typescript-eslint/parser from 8.44.1 to 8.46.2 (#634) --- package.json | 2 +- yarn.lock | 86 +++++++++++++++++++++++++++++++++++++++------------- 2 files changed, 66 insertions(+), 22 deletions(-) diff --git a/package.json b/package.json index 25db26f1..56e9e1af 100644 --- a/package.json +++ b/package.json @@ -367,7 +367,7 @@ "@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.46.2", "@vitest/coverage-v8": "^3.2.4", "@vscode/test-cli": "^0.0.11", "@vscode/test-electron": "^2.5.2", diff --git a/yarn.lock b/yarn.lock index 3e38b6a6..019cbe01 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1168,15 +1168,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== - 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/parser@^8.46.2": + version "8.46.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.46.2.tgz#dd938d45d581ac8ffa9d8a418a50282b306f7ebf" + integrity sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g== + dependencies: + "@typescript-eslint/scope-manager" "8.46.2" + "@typescript-eslint/types" "8.46.2" + "@typescript-eslint/typescript-estree" "8.46.2" + "@typescript-eslint/visitor-keys" "8.46.2" debug "^4.3.4" "@typescript-eslint/project-service@8.44.1": @@ -1188,6 +1188,15 @@ "@typescript-eslint/types" "^8.44.1" debug "^4.3.4" +"@typescript-eslint/project-service@8.46.2": + version "8.46.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.46.2.tgz#ab2f02a0de4da6a7eeb885af5e059be57819d608" + integrity sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg== + dependencies: + "@typescript-eslint/tsconfig-utils" "^8.46.2" + "@typescript-eslint/types" "^8.46.2" + 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 +1205,24 @@ "@typescript-eslint/types" "8.44.1" "@typescript-eslint/visitor-keys" "8.44.1" +"@typescript-eslint/scope-manager@8.46.2": + version "8.46.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.46.2.tgz#7d37df2493c404450589acb3b5d0c69cc0670a88" + integrity sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA== + dependencies: + "@typescript-eslint/types" "8.46.2" + "@typescript-eslint/visitor-keys" "8.46.2" + "@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.46.2", "@typescript-eslint/tsconfig-utils@^8.46.2": + version "8.46.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.2.tgz#d110451cb93bbd189865206ea37ef677c196828c" + integrity sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag== + "@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 +1234,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.46.2", "@typescript-eslint/types@^8.44.1", "@typescript-eslint/types@^8.46.2": + version "8.46.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.46.2.tgz#2bad7348511b31e6e42579820e62b73145635763" + integrity sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ== + "@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 +1260,22 @@ semver "^7.6.0" ts-api-utils "^2.1.0" +"@typescript-eslint/typescript-estree@8.46.2": + version "8.46.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.2.tgz#ab547a27e4222bb6a3281cb7e98705272e2c7d08" + integrity sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ== + dependencies: + "@typescript-eslint/project-service" "8.46.2" + "@typescript-eslint/tsconfig-utils" "8.46.2" + "@typescript-eslint/types" "8.46.2" + "@typescript-eslint/visitor-keys" "8.46.2" + debug "^4.3.4" + fast-glob "^3.3.2" + is-glob "^4.0.3" + minimatch "^9.0.4" + semver "^7.6.0" + 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 +1294,14 @@ "@typescript-eslint/types" "8.44.1" eslint-visitor-keys "^4.2.1" +"@typescript-eslint/visitor-keys@8.46.2": + version "8.46.2" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.2.tgz#803fa298948c39acf810af21bdce6f8babfa9738" + integrity sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w== + dependencies: + "@typescript-eslint/types" "8.46.2" + 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" @@ -2728,10 +2779,10 @@ dayjs@^1.11.13: resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.13.tgz#92430b0139055c3ebb60150aa13e860a4b5a366c" integrity sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg== -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 +2793,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" From 17e2cd164ed2a1a8e0facad95069dfa6899b3500 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Oct 2025 12:54:48 -0800 Subject: [PATCH 40/69] chore(deps-dev): bump @vscode/vsce from 3.6.0 to 3.6.2 (#639) --- package.json | 2 +- yarn.lock | 171 +++++++++++++++++++++++++-------------------------- 2 files changed, 84 insertions(+), 89 deletions(-) diff --git a/package.json b/package.json index 56e9e1af..b7b63433 100644 --- a/package.json +++ b/package.json @@ -371,7 +371,7 @@ "@vitest/coverage-v8": "^3.2.4", "@vscode/test-cli": "^0.0.11", "@vscode/test-electron": "^2.5.2", - "@vscode/vsce": "^3.6.0", + "@vscode/vsce": "^3.6.2", "bufferutil": "^4.0.9", "coder": "https://github.com/coder/coder#main", "dayjs": "^1.11.13", diff --git a/yarn.lock b/yarn.lock index 019cbe01..f7a7155f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -863,42 +863,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,61 +909,61 @@ 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/merge-streams@^2.1.0": version "2.3.0" @@ -1579,16 +1579,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.6.2": + version "3.6.2" + resolved "https://registry.yarnpkg.com/@vscode/vsce/-/vsce-3.6.2.tgz#cefd2802f1dec24fca51293ae563e11912f545fd" + integrity sha512-gvBfarWF+Ii20ESqjA3dpnPJpQJ8fFJYtcWtjwbRADommCzGg1emtmb34E+DKKhECYvaVyAl+TF9lWS/3GSPvg== 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" @@ -1605,7 +1605,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" @@ -2408,12 +2408,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== @@ -7493,15 +7488,15 @@ 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" From 159ffcb151974ba0de7a4f6de31923308f770ade Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 29 Oct 2025 12:55:46 -0800 Subject: [PATCH 41/69] chore(deps-dev): bump eslint-plugin-package-json from 0.56.3 to 0.59.0 (#635) --- package.json | 2 +- yarn.lock | 41 +++++++++++++++++++++++------------------ 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index b7b63433..5db1a2a9 100644 --- a/package.json +++ b/package.json @@ -380,7 +380,7 @@ "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.59.0", "eslint-plugin-prettier": "^5.5.4", "glob": "^10.4.2", "jsonc-eslint-parser": "^2.4.0", diff --git a/yarn.lock b/yarn.lock index f7a7155f..ffdcadb0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2903,10 +2903,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" @@ -3503,20 +3503,20 @@ 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.59.0: + version "0.59.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-package-json/-/eslint-plugin-package-json-0.59.0.tgz#fb847e54742a3465de2e6c813608f95c88075c24" + integrity sha512-4xdVhL3b7LqQQh8cvN3hX8HkAVM6cxZoXqyN4ZE4kN9NuJ21sgnj1IGS19/bmIgCdGBhmsWGXbbyD1H9mjZfMA== 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" + package-json-validator "~0.31.0" + semver "^7.7.3" + sort-object-keys "^2.0.0" + sort-package-json "^3.4.0" validate-npm-package-name "^6.0.2" eslint-plugin-prettier@^5.5.4: @@ -6235,10 +6235,10 @@ 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.31.0: + version "0.31.0" + resolved "https://registry.yarnpkg.com/package-json-validator/-/package-json-validator-0.31.0.tgz#c5a693e6db3ee9ca6dddfd5d07a79807f340dc77" + integrity sha512-kAVO0fNFWI2xpmthogYHnHjCtg0nJvwm9yjd9nnrR5OKIts5fmNMK2OhhjnLD1/ohJNodhCa5tZm8AolOgkfMg== dependencies: semver "^7.7.2" validate-npm-package-license "^3.0.4" @@ -7738,7 +7738,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== From a2d9d56abbb5b50b44d268e7c3dc8d669e235a1b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Oct 2025 15:32:50 +0300 Subject: [PATCH 42/69] chore(deps): bump actions/upload-artifact from 4 to 5 (#638) --- .github/workflows/ci.yaml | 4 ++-- .github/workflows/pre-release.yaml | 2 +- .github/workflows/release.yaml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a878f9f2..64e85a15 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -84,7 +84,7 @@ jobs: - name: Upload artifact (PR) if: github.event_name == 'pull_request' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: extension-pr-${{ github.event.pull_request.number }} path: ${{ steps.setup.outputs.packageName }} @@ -93,7 +93,7 @@ jobs: - name: Upload artifact (main) if: github.event_name == 'push' && github.ref == 'refs/heads/main' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: extension-main-${{ github.sha }} path: ${{ steps.setup.outputs.packageName }} diff --git a/.github/workflows/pre-release.yaml b/.github/workflows/pre-release.yaml index 61761700..430aa2a1 100644 --- a/.github/workflows/pre-release.yaml +++ b/.github/workflows/pre-release.yaml @@ -60,7 +60,7 @@ jobs: run: vsce package --pre-release --out "${{ steps.setup.outputs.packageName }}" - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: extension-${{ steps.version.outputs.version }} path: ${{ steps.setup.outputs.packageName }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 51d9ff97..557586ec 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -60,7 +60,7 @@ jobs: run: vsce package --out "${{ steps.setup.outputs.packageName }}" - name: Upload artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: extension-${{ steps.version.outputs.version }} path: ${{ steps.setup.outputs.packageName }} From 8bff063eccac671c9eb0f6fd22c4e1b34f31d36f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Oct 2025 15:36:41 +0300 Subject: [PATCH 43/69] chore(deps): bump actions/download-artifact from 5 to 6 (#636) --- .github/workflows/publish-extension.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish-extension.yaml b/.github/workflows/publish-extension.yaml index cf93d6ba..e7d5dca7 100644 --- a/.github/workflows/publish-extension.yaml +++ b/.github/workflows/publish-extension.yaml @@ -67,7 +67,7 @@ jobs: - name: Install vsce run: npm install -g @vscode/vsce - - uses: actions/download-artifact@v5 + - uses: actions/download-artifact@v6 with: name: extension-${{ inputs.version }} @@ -93,7 +93,7 @@ jobs: - name: Install ovsx run: npm install -g ovsx - - uses: actions/download-artifact@v5 + - uses: actions/download-artifact@v6 with: name: extension-${{ inputs.version }} @@ -111,7 +111,7 @@ jobs: needs: setup runs-on: ubuntu-22.04 steps: - - uses: actions/download-artifact@v5 + - uses: actions/download-artifact@v6 with: name: extension-${{ inputs.version }} From 6ea816a9d4fc5c32cf31e0c55d367f1efec7c7d4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Oct 2025 15:38:59 +0300 Subject: [PATCH 44/69] chore(deps-dev): bump glob from 10.4.5 to 11.0.3 (#637) --- package.json | 2 +- yarn.lock | 14 +++----------- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index 5db1a2a9..dfb6da93 100644 --- a/package.json +++ b/package.json @@ -382,7 +382,7 @@ "eslint-plugin-md": "^1.0.19", "eslint-plugin-package-json": "^0.59.0", "eslint-plugin-prettier": "^5.5.4", - "glob": "^10.4.2", + "glob": "^11.0.3", "jsonc-eslint-parser": "^2.4.0", "markdown-eslint-parser": "^1.2.1", "memfs": "^4.49.0", diff --git a/yarn.lock b/yarn.lock index ffdcadb0..deda75ac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3952,15 +3952,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== @@ -4241,7 +4233,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== @@ -4253,7 +4245,7 @@ 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: +glob@^11.0.0, glob@^11.0.3: version "11.0.3" resolved "https://registry.yarnpkg.com/glob/-/glob-11.0.3.tgz#9d8087e6d72ddb3c4707b1d2778f80ea3eaefcd6" integrity sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA== From a1ad85e03e1f92116a2771a67a3d34561d9a807c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 30 Oct 2025 15:43:08 +0300 Subject: [PATCH 45/69] chore(deps): bump zod from 3.25.65 to 4.1.12 (#640) --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index dfb6da93..49844183 100644 --- a/package.json +++ b/package.json @@ -355,7 +355,7 @@ "semver": "^7.7.3", "ua-parser-js": "1.0.40", "ws": "^8.18.3", - "zod": "^3.25.65" + "zod": "^4.1.12" }, "devDependencies": { "@types/eventsource": "^3.0.0", diff --git a/yarn.lock b/yarn.lock index deda75ac..cecbf92d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9289,7 +9289,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== From 35387f2fdd0e30700e2beb8669408655fb912eb2 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 11 Nov 2025 14:46:51 +0300 Subject: [PATCH 46/69] Add workspace and agent state machines with improved progress tracking (#627) Introduces state machines to manage workspace and agent lifecycle transitions during connection. Improves user experience with clearer progress messages, enhanced websocket-based log streaming, and proper handling of blocking startup scripts. Previously, the extension would connect before startup scripts completed, leaving users waiting with no indication. Now it waits for scripts to finish and shows clear progress throughout the connection process. Closes #626 --- CHANGELOG.md | 6 + src/api/coderApi.ts | 31 ++- src/api/workspace.ts | 125 ++++++---- src/commands.ts | 130 +---------- src/extension.ts | 7 +- src/promptUtils.ts | 131 +++++++++++ src/remote/remote.ts | 342 ++++++++-------------------- src/remote/terminalSession.ts | 39 ++++ src/remote/workspaceStateMachine.ts | 254 +++++++++++++++++++++ src/workspace/workspaceMonitor.ts | 20 +- 10 files changed, 654 insertions(+), 431 deletions(-) create mode 100644 src/promptUtils.ts create mode 100644 src/remote/terminalSession.ts create mode 100644 src/remote/workspaceStateMachine.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 927d6d12..fa31dd73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +### 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 diff --git a/src/api/coderApi.ts b/src/api/coderApi.ts index da624bad..ef120ce4 100644 --- a/src/api/coderApi.ts +++ b/src/api/coderApi.ts @@ -11,6 +11,7 @@ import { type ProvisionerJobLog, type Workspace, type WorkspaceAgent, + type WorkspaceAgentLog, } from "coder/site/src/api/typesGenerated"; import * as vscode from "vscode"; import { type ClientOptions, type CloseEvent, type ErrorEvent } from "ws"; @@ -109,18 +110,42 @@ export class CoderApi extends Api { logs: ProvisionerJobLog[], options?: ClientOptions, ) => { + return this.watchLogs( + `/api/v2/workspacebuilds/${buildId}/logs`, + logs, + options, + ); + }; + + 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" }); const lastLog = logs.at(-1); if (lastLog) { searchParams.append("after", lastLog.id.toString()); } - return this.createWebSocket({ - apiRoute: `/api/v2/workspacebuilds/${buildId}/logs`, + return this.createWebSocket({ + apiRoute, searchParams, options, }); - }; + } private async createWebSocket( configs: Omit, diff --git a/src/api/workspace.ts b/src/api/workspace.ts index cb03d9fc..a24d3a64 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 { type FeatureSet } from "../featureSet"; import { getGlobalFlags } from "../globalFlags"; import { escapeCommandArg } from "../util"; +import { type OneWayWebSocket } from "../websocket/oneWayWebSocket"; 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")); - +): Promise> { const socket = await client.watchBuildLogsByBuildId( workspace.latest_build.id, - logs, + [], ); - await new Promise((resolve, reject) => { - 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("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("close", () => { + writeEmitter.fire("Build complete\r\n"); + }); + + return socket; +} - 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")}`, - ), +/** + * 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/commands.ts b/src/commands.ts index 5abeb026..682d745b 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -19,6 +19,7 @@ import { type SecretsManager } from "./core/secretsManager"; import { CertificateError } from "./error"; import { getGlobalFlags } from "./globalFlags"; import { type Logger } from "./logging/logger"; +import { maybeAskAgent, maybeAskUrl } from "./promptUtils"; import { escapeCommandArg, toRemoteAuthority, toSafeHost } from "./util"; import { AgentTreeItem, @@ -58,129 +59,6 @@ export class Commands { this.contextManager = serviceContainer.getContextManager(); } - /** - * 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. - */ - 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)); - 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; - } - /** * Log into the provided deployment. If the deployment URL is not specified, * ask for it first with a menu showing recent URLs along with the default URL @@ -197,7 +75,7 @@ export class Commands { } this.logger.info("Logging in"); - const url = await this.maybeAskUrl(args?.url); + const url = await maybeAskUrl(this.mementoManager, args?.url); if (!url) { return; // The user aborted. } @@ -488,7 +366,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; @@ -611,7 +489,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; diff --git a/src/extension.ts b/src/extension.ts index aba94cfe..cbb9e62e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -12,6 +12,7 @@ import { Commands } from "./commands"; import { ServiceContainer } from "./core/container"; import { AuthAction } from "./core/secretsManager"; import { CertificateError, getErrorDetail } from "./error"; +import { maybeAskUrl } from "./promptUtils"; import { Remote } from "./remote/remote"; import { toSafeHost } from "./util"; import { @@ -147,7 +148,8 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // 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( + const url = await maybeAskUrl( + mementoManager, params.get("url"), mementoManager.getUrl(), ); @@ -230,7 +232,8 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { // 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( + const url = await maybeAskUrl( + mementoManager, params.get("url"), mementoManager.getUrl(), ); diff --git a/src/promptUtils.ts b/src/promptUtils.ts new file mode 100644 index 00000000..4d058f12 --- /dev/null +++ b/src/promptUtils.ts @@ -0,0 +1,131 @@ +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, + 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 = 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, + lastUsedUrl?: string, +): Promise { + let url = providedUrl || (await askURL(mementoManager, lastUsedUrl)); + 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 97cb858e..0a9469c3 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -5,10 +5,10 @@ import { 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 * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; import prettyBytes from "pretty-bytes"; import * as semver from "semver"; import * as vscode from "vscode"; @@ -19,13 +19,9 @@ import { formatEventLabel, formatMetadataError, } from "../api/agentMetadataHelper"; -import { createWorkspaceIdentifier, extractAgents } from "../api/api-helper"; +import { extractAgents } from "../api/api-helper"; import { CoderApi } from "../api/coderApi"; import { needToken } from "../api/utils"; -import { - startWorkspaceIfStoppedOrFailed, - waitForBuild, -} from "../api/workspace"; import { type Commands } from "../commands"; import { type CliManager } from "../core/cliManager"; import * as cliUtils from "../core/cliUtils"; @@ -47,6 +43,7 @@ import { WorkspaceMonitor } from "../workspace/workspaceMonitor"; import { SSHConfig, type SSHValues, mergeSSHConfigValues } from "./sshConfig"; import { computeSSHProperties, sshSupportsSetEnv } from "./sshSupport"; +import { WorkspaceStateMachine } from "./workspaceStateMachine"; export interface RemoteDetails extends vscode.Disposable { url: string; @@ -104,147 +101,6 @@ export class Remote { } } - 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 { - 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(); - } - } - } - /** * Ensure the workspace specified by the remote authority is ready to receive * SSH connections. Return undefined if the authority is not for a Coder @@ -427,36 +283,104 @@ 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); + } 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..."); @@ -538,76 +462,6 @@ export class Remote { } } - // Watch the workspace for changes. - const monitor = await WorkspaceMonitor.create( - 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 = await Inbox.create(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 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..eb7aa335 --- /dev/null +++ b/src/remote/workspaceStateMachine.ts @@ -0,0 +1,254 @@ +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 { OneWayWebSocket } from "../websocket/oneWayWebSocket"; + +/** + * 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: OneWayWebSocket | null = null; + + private agentLogSocket: OneWayWebSocket | 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.label, + ); + 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/workspace/workspaceMonitor.ts b/src/workspace/workspaceMonitor.ts index ceea8a91..1a332f4e 100644 --- a/src/workspace/workspaceMonitor.ts +++ b/src/workspace/workspaceMonitor.ts @@ -29,6 +29,7 @@ export class WorkspaceMonitor implements vscode.Disposable { private notifiedDeletion = false; private notifiedOutdated = false; private notifiedNotRunning = false; + private completedInitialSetup = false; readonly onChange = new vscode.EventEmitter(); private readonly statusBarItem: vscode.StatusBarItem; @@ -110,6 +111,10 @@ export class WorkspaceMonitor implements vscode.Disposable { return monitor; } + public markInitialSetupComplete(): void { + this.completedInitialSetup = true; + } + /** * Permanently close the websocket. */ @@ -130,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) { @@ -193,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; @@ -249,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(); } } } From 11fffac430a1bb197c2a522c5767cced7ef47fce Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 15:52:42 +0300 Subject: [PATCH 47/69] chore(deps-dev): bump prettier from 3.5.3 to 3.6.2 (#644) --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 49844183..c0e852f8 100644 --- a/package.json +++ b/package.json @@ -387,7 +387,7 @@ "markdown-eslint-parser": "^1.2.1", "memfs": "^4.49.0", "nyc": "^17.1.0", - "prettier": "^3.5.3", + "prettier": "^3.6.2", "ts-loader": "^9.5.1", "typescript": "^5.9.3", "utf-8-validate": "^6.0.5", diff --git a/yarn.lock b/yarn.lock index cecbf92d..85ab8d14 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6450,10 +6450,10 @@ 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" From 729009e0382a3a0ee0d10613bc30ab378b0bd215 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 16:05:10 +0300 Subject: [PATCH 48/69] chore(deps-dev): bump ts-loader from 9.5.1 to 9.5.4 (#642) --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index c0e852f8..b59e12ae 100644 --- a/package.json +++ b/package.json @@ -388,7 +388,7 @@ "memfs": "^4.49.0", "nyc": "^17.1.0", "prettier": "^3.6.2", - "ts-loader": "^9.5.1", + "ts-loader": "^9.5.4", "typescript": "^5.9.3", "utf-8-validate": "^6.0.5", "vitest": "^3.2.4", diff --git a/yarn.lock b/yarn.lock index 85ab8d14..6b8a35ee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8305,10 +8305,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" From 0ec0e8ad468ab7436abd010418014982adf54f4e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 16:09:56 +0300 Subject: [PATCH 49/69] chore(deps-dev): bump @vscode/test-cli from 0.0.11 to 0.0.12 (#641) --- package.json | 2 +- yarn.lock | 64 ++++++++++++++++++++++++---------------------------- 2 files changed, 31 insertions(+), 35 deletions(-) diff --git a/package.json b/package.json index b59e12ae..27088d07 100644 --- a/package.json +++ b/package.json @@ -369,7 +369,7 @@ "@typescript-eslint/eslint-plugin": "^8.44.0", "@typescript-eslint/parser": "^8.46.2", "@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.2", "bufferutil": "^4.0.9", diff --git a/yarn.lock b/yarn.lock index 6b8a35ee..f36ad108 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== @@ -1097,7 +1092,7 @@ 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== @@ -1493,19 +1488,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": @@ -2262,19 +2257,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" @@ -2473,7 +2468,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== @@ -3034,7 +3029,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== @@ -5808,10 +5803,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" @@ -5821,6 +5816,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" @@ -8053,6 +8049,11 @@ structured-source@^4.0.0: dependencies: boundary "^2.0.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" @@ -8074,11 +8075,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" From ebbbb9a9340dc47ff53b20537d46beb62192bf02 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 11 Nov 2025 16:14:13 +0300 Subject: [PATCH 50/69] chore(deps): bump pretty-bytes from 7.0.0 to 7.1.0 (#643) --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 27088d07..d7644974 100644 --- a/package.json +++ b/package.json @@ -350,7 +350,7 @@ "jsonc-parser": "^3.3.1", "node-forge": "^1.3.1", "openpgp": "^6.2.2", - "pretty-bytes": "^7.0.0", + "pretty-bytes": "^7.1.0", "proxy-agent": "^6.5.0", "semver": "^7.7.3", "ua-parser-js": "1.0.40", diff --git a/yarn.lock b/yarn.lock index f36ad108..b0817a48 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6451,10 +6451,10 @@ prettier@^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" From fa2272a9c741740fb2c76f04b05cbe4202c8f573 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 17 Nov 2025 22:41:57 +0300 Subject: [PATCH 51/69] Remove node forge dependency (#648) Replaced `node-forge` with `@peculiar/x509` (more modern, lightweight, and widely adopted). Node.js's built-in `crypto` module was attempted first, but `keyUsage` returns `undefined`. **Testing in Electron** All vitest tests now run in Electron to mirror VS Code's environment. This adds a few seconds overhead vs Node.js. `electron` was added as dev dependency for BoringSSL compatibility (Node.js uses OpenSSL). --- .vscode/settings.json | 6 +- package.json | 6 +- src/error.ts | 48 ++-- src/remote/remote.ts | 1 + test/unit/error.test.ts | 73 +++--- yarn.lock | 490 +++++++++++++++++++++++++++++++++++++--- 6 files changed, 535 insertions(+), 89 deletions(-) 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/package.json b/package.json index d7644974..44865e25 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "package": "webpack --mode production --devtool hidden-source-map", "package:prerelease": "npx vsce package --pre-release", "pretest": "tsc -p . --outDir out && tsc -p test --outDir out && yarn run build && yarn run lint", - "test": "vitest", + "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", @@ -343,12 +343,12 @@ "word-wrap": "1.2.5" }, "dependencies": { + "@peculiar/x509": "^1.14.0", "axios": "1.12.2", "date-fns": "^3.6.0", "eventsource": "^3.0.6", "find-process": "https://github.com/coder/find-process#fix/sequoia-compat", "jsonc-parser": "^3.3.1", - "node-forge": "^1.3.1", "openpgp": "^6.2.2", "pretty-bytes": "^7.1.0", "proxy-agent": "^6.5.0", @@ -361,7 +361,6 @@ "@types/eventsource": "^3.0.0", "@types/glob": "^7.1.3", "@types/node": "^22.14.1", - "@types/node-forge": "^1.3.14", "@types/semver": "^7.7.1", "@types/ua-parser-js": "0.7.36", "@types/vscode": "^1.73.0", @@ -375,6 +374,7 @@ "bufferutil": "^4.0.9", "coder": "https://github.com/coder/coder#main", "dayjs": "^1.11.13", + "electron": "^39.1.2", "eslint": "^8.57.1", "eslint-config-prettier": "^10.1.8", "eslint-import-resolver-typescript": "^4.4.4", 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/remote/remote.ts b/src/remote/remote.ts index 0a9469c3..1edf351c 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -344,6 +344,7 @@ export class Remote { } catch (error) { subscription.dispose(); reject(error); + return; } finally { inProgress = 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/yarn.lock b/yarn.lock index b0817a48..ea35d101 100644 --- a/yarn.lock +++ b/yarn.lock @@ -336,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" @@ -738,6 +753,129 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@peculiar/asn1-cms@^2.5.0": + version "2.5.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-cms/-/asn1-cms-2.5.0.tgz#3a7e857d86686898ce78efdbf481922bb805c68a" + integrity sha512-p0SjJ3TuuleIvjPM4aYfvYw8Fk1Hn/zAVyPJZTtZ2eE9/MIer6/18ROxX6N/e6edVSfvuZBqhxAj3YgsmSjQ/A== + dependencies: + "@peculiar/asn1-schema" "^2.5.0" + "@peculiar/asn1-x509" "^2.5.0" + "@peculiar/asn1-x509-attr" "^2.5.0" + asn1js "^3.0.6" + tslib "^2.8.1" + +"@peculiar/asn1-csr@^2.5.0": + version "2.5.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-csr/-/asn1-csr-2.5.0.tgz#4dd7534bd7d7db5bbbbde4d00d4836bf7e818d1c" + integrity sha512-ioigvA6WSYN9h/YssMmmoIwgl3RvZlAYx4A/9jD2qaqXZwGcNlAxaw54eSx2QG1Yu7YyBC5Rku3nNoHrQ16YsQ== + dependencies: + "@peculiar/asn1-schema" "^2.5.0" + "@peculiar/asn1-x509" "^2.5.0" + asn1js "^3.0.6" + tslib "^2.8.1" + +"@peculiar/asn1-ecc@^2.5.0": + version "2.5.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-ecc/-/asn1-ecc-2.5.0.tgz#3bbeaa3443567055be112b4c7e9d5562951242cf" + integrity sha512-t4eYGNhXtLRxaP50h3sfO6aJebUCDGQACoeexcelL4roMFRRVgB20yBIu2LxsPh/tdW9I282gNgMOyg3ywg/mg== + dependencies: + "@peculiar/asn1-schema" "^2.5.0" + "@peculiar/asn1-x509" "^2.5.0" + asn1js "^3.0.6" + tslib "^2.8.1" + +"@peculiar/asn1-pfx@^2.5.0": + version "2.5.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-pfx/-/asn1-pfx-2.5.0.tgz#22d12e676c063dfc6244278fe18eb75c2c121880" + integrity sha512-Vj0d0wxJZA+Ztqfb7W+/iu8Uasw6hhKtCdLKXLG/P3kEPIQpqGI4P4YXlROfl7gOCqFIbgsj1HzFIFwQ5s20ug== + dependencies: + "@peculiar/asn1-cms" "^2.5.0" + "@peculiar/asn1-pkcs8" "^2.5.0" + "@peculiar/asn1-rsa" "^2.5.0" + "@peculiar/asn1-schema" "^2.5.0" + asn1js "^3.0.6" + tslib "^2.8.1" + +"@peculiar/asn1-pkcs8@^2.5.0": + version "2.5.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.5.0.tgz#1939643773e928a4802813b595e324a05b453709" + integrity sha512-L7599HTI2SLlitlpEP8oAPaJgYssByI4eCwQq2C9eC90otFpm8MRn66PpbKviweAlhinWQ3ZjDD2KIVtx7PaVw== + dependencies: + "@peculiar/asn1-schema" "^2.5.0" + "@peculiar/asn1-x509" "^2.5.0" + asn1js "^3.0.6" + tslib "^2.8.1" + +"@peculiar/asn1-pkcs9@^2.5.0": + version "2.5.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.5.0.tgz#8c5b873a721bb92b4fe758da9de1ead63165106d" + integrity sha512-UgqSMBLNLR5TzEZ5ZzxR45Nk6VJrammxd60WMSkofyNzd3DQLSNycGWSK5Xg3UTYbXcDFyG8pA/7/y/ztVCa6A== + dependencies: + "@peculiar/asn1-cms" "^2.5.0" + "@peculiar/asn1-pfx" "^2.5.0" + "@peculiar/asn1-pkcs8" "^2.5.0" + "@peculiar/asn1-schema" "^2.5.0" + "@peculiar/asn1-x509" "^2.5.0" + "@peculiar/asn1-x509-attr" "^2.5.0" + asn1js "^3.0.6" + tslib "^2.8.1" + +"@peculiar/asn1-rsa@^2.5.0": + version "2.5.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-rsa/-/asn1-rsa-2.5.0.tgz#7283756ec596ccfbef23ff0e7eda0c37133ebed8" + integrity sha512-qMZ/vweiTHy9syrkkqWFvbT3eLoedvamcUdnnvwyyUNv5FgFXA3KP8td+ATibnlZ0EANW5PYRm8E6MJzEB/72Q== + dependencies: + "@peculiar/asn1-schema" "^2.5.0" + "@peculiar/asn1-x509" "^2.5.0" + asn1js "^3.0.6" + tslib "^2.8.1" + +"@peculiar/asn1-schema@^2.5.0": + version "2.5.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-schema/-/asn1-schema-2.5.0.tgz#4e58d7c3087c4259cebf5363e092f85b9cbf0ca1" + integrity sha512-YM/nFfskFJSlHqv59ed6dZlLZqtZQwjRVJ4bBAiWV08Oc+1rSd5lDZcBEx0lGDHfSoH3UziI2pXt2UM33KerPQ== + dependencies: + asn1js "^3.0.6" + pvtsutils "^1.3.6" + tslib "^2.8.1" + +"@peculiar/asn1-x509-attr@^2.5.0": + version "2.5.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.5.0.tgz#d413597dfe097620a00780e9e2ae851b06f32aed" + integrity sha512-9f0hPOxiJDoG/bfNLAFven+Bd4gwz/VzrCIIWc1025LEI4BXO0U5fOCTNDPbbp2ll+UzqKsZ3g61mpBp74gk9A== + dependencies: + "@peculiar/asn1-schema" "^2.5.0" + "@peculiar/asn1-x509" "^2.5.0" + asn1js "^3.0.6" + tslib "^2.8.1" + +"@peculiar/asn1-x509@^2.5.0": + version "2.5.0" + resolved "https://registry.yarnpkg.com/@peculiar/asn1-x509/-/asn1-x509-2.5.0.tgz#305f9cd534f4b6a723d27fc59363f382debf5500" + integrity sha512-CpwtMCTJvfvYTFMuiME5IH+8qmDe3yEWzKHe7OOADbGfq7ohxeLaXwQo0q4du3qs0AII3UbLCvb9NF/6q0oTKQ== + dependencies: + "@peculiar/asn1-schema" "^2.5.0" + asn1js "^3.0.6" + pvtsutils "^1.3.6" + tslib "^2.8.1" + +"@peculiar/x509@^1.14.0": + version "1.14.0" + resolved "https://registry.yarnpkg.com/@peculiar/x509/-/x509-1.14.0.tgz#4b1abdf7ca5e46f2cb303fba608ef0507762e84a" + integrity sha512-Yc4PDxN3OrxUPiXgU63c+ZRXKGE8YKF2McTciYhUHFtHVB0KMnjeFSU0qpztGhsp4P0uKix4+J2xEpIEDu8oXg== + dependencies: + "@peculiar/asn1-cms" "^2.5.0" + "@peculiar/asn1-csr" "^2.5.0" + "@peculiar/asn1-ecc" "^2.5.0" + "@peculiar/asn1-pkcs9" "^2.5.0" + "@peculiar/asn1-rsa" "^2.5.0" + "@peculiar/asn1-schema" "^2.5.0" + "@peculiar/asn1-x509" "^2.5.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" @@ -960,11 +1098,23 @@ 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" @@ -1024,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" @@ -1072,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" @@ -1087,6 +1252,13 @@ 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" @@ -1097,13 +1269,6 @@ 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" @@ -1111,11 +1276,25 @@ dependencies: undici-types "~6.21.0" +"@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" + "@types/normalize-package-data@^2.4.3": version "2.4.4" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz#56e2cc26c397c038fab0e3a917a12d5c5909e901" integrity sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA== +"@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/sarif@^2.1.7": version "2.1.7" resolved "https://registry.yarnpkg.com/@types/sarif/-/sarif-2.1.7.tgz#dab4d16ba7568e9846c454a8764f33c5d98e5524" @@ -1148,6 +1327,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" @@ -2029,6 +2215,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" @@ -2168,6 +2363,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" @@ -2279,6 +2479,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" @@ -2565,6 +2783,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" @@ -2835,6 +3060,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" @@ -2913,6 +3143,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" @@ -3002,6 +3237,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.1.2: + version "39.1.2" + resolved "https://registry.yarnpkg.com/electron/-/electron-39.1.2.tgz#8871c24c6795aeae8eefc08a9800a4bd0f04093c" + integrity sha512-+/TwT9NWxyQGTm5WemJEJy+bWCpnKJ4PLPswI1yn1P63bzM0/8yAeG05yS+NfFaWH4yNQtGXZmAv87Bxa5RlLg== + 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" @@ -3042,6 +3286,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" @@ -3350,7 +3599,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== @@ -3754,6 +4003,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" @@ -3999,6 +4259,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" @@ -4151,6 +4420,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" @@ -4264,6 +4540,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" @@ -4283,14 +4571,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== @@ -4298,6 +4579,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" @@ -4322,6 +4610,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" @@ -4465,6 +4770,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" @@ -4482,6 +4792,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" @@ -5329,6 +5647,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" @@ -5356,6 +5679,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" @@ -5416,7 +5746,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== @@ -5573,6 +5903,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" @@ -5666,6 +6001,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" @@ -5755,6 +6097,11 @@ 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" @@ -5887,11 +6234,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" @@ -5931,6 +6273,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" @@ -6141,6 +6488,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" @@ -6468,7 +6820,7 @@ 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== @@ -6510,6 +6862,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" @@ -6522,6 +6886,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" @@ -6608,6 +6977,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" @@ -7244,6 +7618,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" @@ -7284,6 +7663,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" @@ -7326,6 +7712,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" @@ -7489,11 +7887,23 @@ secretlint@^10.1.2: globby "^14.1.0" read-pkg "^9.0.1" -semver@7.7.3, 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, semver@^7.7.3: +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" resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2" @@ -7805,7 +8215,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== @@ -8049,6 +8459,13 @@ 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" @@ -8322,16 +8739,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" @@ -8358,6 +8782,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" @@ -8655,6 +9084,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" @@ -9265,7 +9699,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== From c4204bad31c07ce30fda58f79a2c4ea259ced541 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 20 Nov 2025 18:17:22 +0300 Subject: [PATCH 52/69] Add support to Google Antigravity (#658) --- CHANGELOG.md | 4 ++++ src/extension.ts | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa31dd73..7a381cf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Fixed + +- Add support for `google.antigravity-remote-openssh` Remote SSH extension. + ### Changed - Improved workspace connection progress messages and enhanced the workspace build terminal diff --git a/src/extension.ts b/src/extension.ts index cbb9e62e..9751b0f7 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -37,7 +37,8 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { 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"); + vscode.extensions.getExtension("ms-vscode-remote.remote-ssh") || + vscode.extensions.getExtension("google.antigravity-remote-openssh"); let vscodeProposed: typeof vscode = vscode; From 118d50ab4a2ad9f3490a05cc17163838ab3de134 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 20 Nov 2025 18:28:46 +0300 Subject: [PATCH 53/69] v1.11.4 (#659) --- CHANGELOG.md | 2 ++ package.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a381cf8..35872866 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## [v1.11.4](https://github.com/coder/vscode-coder/releases/tag/v1.11.4) 2025-11-20 + ### Fixed - Add support for `google.antigravity-remote-openssh` Remote SSH extension. diff --git a/package.json b/package.json index 44865e25..78d39819 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "coder-remote", "displayName": "Coder", - "version": "1.11.3", + "version": "1.11.4", "description": "Open any workspace with a single click.", "categories": [ "Other" From 9ef38a3cee1c07a4740f41ccaa79dac6224cd6e9 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 25 Nov 2025 12:34:02 +0300 Subject: [PATCH 54/69] Fix race condition in concurrent CLI binary downloads (#656) Implement file-based locking to prevent multiple VS Code windows from downloading the Coder CLI binary simultaneously. When one window is downloading, other windows now wait and display real-time progress from the active download. This prevents file corruption and download failures that could occur when opening multiple VS Code windows simultaneously. Closes #575 --- CHANGELOG.md | 6 + package.json | 2 + src/core/binaryLock.ts | 126 +++++ src/core/cliManager.ts | 462 ++++++++++++++----- src/core/cliUtils.ts | 73 ++- src/core/downloadProgress.ts | 44 ++ test/mocks/testHelpers.ts | 54 ++- test/unit/core/binaryLock.test.ts | 160 +++++++ test/unit/core/cliManager.concurrent.test.ts | 191 ++++++++ test/unit/core/cliManager.test.ts | 112 ++--- test/unit/core/downloadProgress.test.ts | 102 ++++ yarn.lock | 26 ++ 12 files changed, 1154 insertions(+), 204 deletions(-) create mode 100644 src/core/binaryLock.ts create mode 100644 src/core/downloadProgress.ts create mode 100644 test/unit/core/binaryLock.test.ts create mode 100644 test/unit/core/cliManager.concurrent.test.ts create mode 100644 test/unit/core/downloadProgress.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 35872866..760d3b64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +### 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. + ## [v1.11.4](https://github.com/coder/vscode-coder/releases/tag/v1.11.4) 2025-11-20 ### Fixed diff --git a/package.json b/package.json index 78d39819..e1946da1 100644 --- a/package.json +++ b/package.json @@ -351,6 +351,7 @@ "jsonc-parser": "^3.3.1", "openpgp": "^6.2.2", "pretty-bytes": "^7.1.0", + "proper-lockfile": "^4.1.2", "proxy-agent": "^6.5.0", "semver": "^7.7.3", "ua-parser-js": "1.0.40", @@ -361,6 +362,7 @@ "@types/eventsource": "^3.0.0", "@types/glob": "^7.1.3", "@types/node": "^22.14.1", + "@types/proper-lockfile": "^4.1.4", "@types/semver": "^7.7.1", "@types/ua-parser-js": "0.7.36", "@types/vscode": "^1.73.0", 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..5e0b3d26 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,15 +15,21 @@ 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 @@ -97,143 +103,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"); - switch (status) { - case 200: { - if (cfg.get("disableSignatureVerification")) { + // 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); + + 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; + } - // 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); + 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"); + } + } + } - // For debugging, to see if the binary only partially downloaded. - const newStat = await cliUtils.stat(binPath); + /** + * 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 }; + } + + 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 }; + } + } + + /** + * 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 +451,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 +515,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), + ); + }); + } }); }); 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/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/test/mocks/testHelpers.ts b/test/mocks/testHelpers.ts index 2ef46716..faf2a72d 100644 --- a/test/mocks/testHelpers.ts +++ b/test/mocks/testHelpers.ts @@ -1,3 +1,4 @@ +import { type IncomingMessage } from "node:http"; import { vi } from "vitest"; import * as vscode from "vscode"; @@ -8,7 +9,7 @@ import { type Logger } from "@/logging/logger"; * 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(); @@ -298,3 +299,54 @@ 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; +} 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 d4f16c87..95755d31 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,6 +16,7 @@ import * as pgp from "@/pgp"; import { createMockLogger, + createMockStream, MockConfigurationProvider, MockProgressReporter, MockUserInteraction, @@ -24,7 +25,6 @@ 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"); @@ -42,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"); @@ -676,11 +684,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( @@ -730,70 +738,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/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/yarn.lock b/yarn.lock index ea35d101..b2527a90 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1288,6 +1288,13 @@ 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" @@ -1295,6 +1302,11 @@ 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" @@ -6825,6 +6837,15 @@ progress@^2.0.0, progress@^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" @@ -7686,6 +7707,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" From d5c3cc038867207806ebf5655534a37079bb7d37 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 25 Nov 2025 13:01:37 +0300 Subject: [PATCH 55/69] Add automatic reconnection for WebSocket connections (#654) Implement ReconnectingWebSocket class to automatically recover from network failures when communicating with Coder deployments. Uses exponential backoff with jitter and distinguishes between recoverable errors (network issues) and unrecoverable errors (auth failures). - Auto-reconnect on abnormal closures and network failures - Stop on unrecoverable errors (HTTP 403/410/426, WS 1002/1003) - Reconnect when session token or host changes - Event handlers persist across reconnections Closes #595 --- CHANGELOG.md | 5 + src/api/agentMetadataHelper.ts | 6 +- src/api/coderApi.ts | 173 +++++-- src/api/workspace.ts | 6 +- src/inbox.ts | 6 +- src/remote/workspaceStateMachine.ts | 7 +- src/websocket/codes.ts | 55 ++ src/websocket/eventStreamConnection.ts | 13 +- src/websocket/reconnectingWebSocket.ts | 304 ++++++++++++ src/websocket/sseConnection.ts | 27 +- test/unit/api/coderApi.test.ts | 68 ++- .../websocket/reconnectingWebSocket.test.ts | 468 ++++++++++++++++++ test/unit/websocket/sseConnection.test.ts | 18 +- 13 files changed, 1069 insertions(+), 87 deletions(-) create mode 100644 src/websocket/codes.ts create mode 100644 src/websocket/reconnectingWebSocket.ts create mode 100644 test/unit/websocket/reconnectingWebSocket.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 760d3b64..7b1745b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ Other windows now wait and display real-time progress instead of attempting concurrent downloads, preventing corruption and failures. +### Changed + +- WebSocket connections now automatically reconnect on network failures, improving reliability when + communicating with Coder deployments. + ## [v1.11.4](https://github.com/coder/vscode-coder/releases/tag/v1.11.4) 2025-11-20 ### Fixed diff --git a/src/api/agentMetadataHelper.ts b/src/api/agentMetadataHelper.ts index 4de804ad..26ab1b6f 100644 --- a/src/api/agentMetadataHelper.ts +++ b/src/api/agentMetadataHelper.ts @@ -53,7 +53,11 @@ export async 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 ef120ce4..04c696be 100644 --- a/src/api/coderApi.ts +++ b/src/api/coderApi.ts @@ -14,7 +14,7 @@ import { type WorkspaceAgentLog, } from "coder/site/src/api/typesGenerated"; import * as vscode from "vscode"; -import { type ClientOptions, type CloseEvent, type ErrorEvent } from "ws"; +import { type ClientOptions } from "ws"; import { CertificateError } from "../error"; import { getHeaderCommand, getHeaders } from "../headers"; @@ -31,11 +31,20 @@ import { HttpClientLogLevel, } from "../logging/types"; import { sizeOf } from "../logging/utils"; -import { type UnidirectionalStream } from "../websocket/eventStreamConnection"; +import { HttpStatusCode } 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"; @@ -47,6 +56,10 @@ const coderSessionTokenHeader = "Coder-Session-Token"; * and WebSocket methods for real-time functionality. */ export class CoderApi extends Api { + private readonly reconnectingSockets = new Set< + ReconnectingWebSocket + >(); + private constructor(private readonly output: Logger) { super(); } @@ -66,10 +79,34 @@ export class CoderApi extends Api { client.setSessionToken(token); } - setupInterceptors(client, baseUrl, output); + setupInterceptors(client, output); return client; } + setSessionToken = (token: string): void => { + const defaultHeaders = this.getAxiosInstance().defaults.headers.common; + const currentToken = defaultHeaders[coderSessionTokenHeader]; + defaultHeaders[coderSessionTokenHeader] = token; + + if (currentToken !== token) { + for (const socket of this.reconnectingSockets) { + socket.reconnect(); + } + } + }; + + setHost = (host: string | undefined): void => { + const defaults = this.getAxiosInstance().defaults; + const currentHost = defaults.baseURL; + defaults.baseURL = host; + + if (currentHost !== host) { + for (const socket of this.reconnectingSockets) { + socket.reconnect(); + } + } + }; + watchInboxNotifications = async ( watchTemplates: string[], watchTargets: string[], @@ -83,6 +120,7 @@ export class CoderApi extends Api { targets: watchTargets.join(","), }, options, + enableRetry: true, }); }; @@ -91,6 +129,7 @@ export class CoderApi extends Api { apiRoute: `/api/v2/workspaces/${workspace.id}/watch-ws`, fallbackApiRoute: `/api/v2/workspaces/${workspace.id}/watch`, options, + enableRetry: true, }); }; @@ -102,6 +141,7 @@ export class CoderApi extends Api { apiRoute: `/api/v2/workspaceagents/${agentId}/watch-metadata-ws`, fallbackApiRoute: `/api/v2/workspaceagents/${agentId}/watch-metadata`, options, + enableRetry: true, }); }; @@ -148,53 +188,78 @@ export class CoderApi extends Api { } private async createWebSocket( - configs: Omit, - ) { - const baseUrlRaw = this.getAxiosInstance().defaults.baseURL; - if (!baseUrlRaw) { - throw new Error("No base URL set on REST client"); - } + configs: Omit & { enableRetry?: boolean }, + ): Promise> { + const { enableRetry, ...socketConfigs } = configs; + + const socketFactory: SocketFactory = async () => { + 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 headersFromCommand = await getHeaders( + baseUrlRaw, + getHeaderCommand(vscode.workspace.getConfiguration()), + this.output, + ); - const baseUrl = new URL(baseUrlRaw); - const token = this.getAxiosInstance().defaults.headers.common[ - coderSessionTokenHeader - ] as string | undefined; + const httpAgent = await createHttpAgent( + vscode.workspace.getConfiguration(), + ); - const headersFromCommand = await getHeaders( - baseUrlRaw, - getHeaderCommand(vscode.workspace.getConfiguration()), - this.output, - ); + /** + * 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 httpAgent = await createHttpAgent( - vscode.workspace.getConfiguration(), - ); + const webSocket = new OneWayWebSocket({ + location: baseUrl, + ...socketConfigs, + options: { + ...configs.options, + agent: httpAgent, + followRedirects: true, + headers, + }, + }); - /** - * 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, + this.attachStreamLogger(webSocket); + return webSocket; }; - const webSocket = new OneWayWebSocket({ - location: baseUrl, - ...configs, - options: { - ...configs.options, - agent: httpAgent, - followRedirects: true, - headers, - }, - }); + if (enableRetry) { + const reconnectingSocket = await ReconnectingWebSocket.create( + socketFactory, + this.output, + configs.apiRoute, + undefined, + () => + this.reconnectingSockets.delete( + reconnectingSocket as ReconnectingWebSocket, + ), + ); + + this.reconnectingSockets.add( + reconnectingSocket as ReconnectingWebSocket, + ); - this.attachStreamLogger(webSocket); - return webSocket; + return reconnectingSocket; + } else { + return socketFactory(); + } } private attachStreamLogger( @@ -230,13 +295,15 @@ export class CoderApi extends Api { fallbackApiRoute: string; searchParams?: Record | URLSearchParams; options?: ClientOptions; + enableRetry?: boolean; }): Promise> { - let webSocket: OneWayWebSocket; + let webSocket: UnidirectionalStream; try { webSocket = await this.createWebSocket({ apiRoute: configs.apiRoute, searchParams: configs.searchParams, options: configs.options, + enableRetry: configs.enableRetry, }); } catch { // Failed to create WebSocket, use SSE fallback @@ -274,8 +341,8 @@ export class CoderApi extends Api { const handleError = (event: ErrorEvent) => { cleanup(); const is404 = - event.message?.includes("404") || - event.error?.message?.includes("404"); + event.message?.includes(String(HttpStatusCode.NOT_FOUND)) || + event.error?.message?.includes(String(HttpStatusCode.NOT_FOUND)); if (is404 && onNotFound) { connection.close(); @@ -323,14 +390,11 @@ export class CoderApi extends Api { /** * 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()), @@ -356,7 +420,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; + } }, ); } diff --git a/src/api/workspace.ts b/src/api/workspace.ts index a24d3a64..1d3b7a4e 100644 --- a/src/api/workspace.ts +++ b/src/api/workspace.ts @@ -11,7 +11,7 @@ import * as vscode from "vscode"; import { type FeatureSet } from "../featureSet"; import { getGlobalFlags } from "../globalFlags"; import { escapeCommandArg } from "../util"; -import { type OneWayWebSocket } from "../websocket/oneWayWebSocket"; +import { type UnidirectionalStream } from "../websocket/eventStreamConnection"; import { errToStr, createWorkspaceIdentifier } from "./api-helper"; import { type CoderApi } from "./coderApi"; @@ -93,7 +93,7 @@ export async function streamBuildLogs( client: CoderApi, writeEmitter: vscode.EventEmitter, workspace: Workspace, -): Promise> { +): Promise> { const socket = await client.watchBuildLogsByBuildId( workspace.latest_build.id, [], @@ -131,7 +131,7 @@ export async function streamAgentLogs( client: CoderApi, writeEmitter: vscode.EventEmitter, agent: WorkspaceAgent, -): Promise> { +): Promise> { const socket = await client.watchWorkspaceAgentLogs(agent.id, []); socket.addEventListener("message", (data) => { diff --git a/src/inbox.ts b/src/inbox.ts index 8dff573f..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,7 +16,9 @@ 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 { - private socket: OneWayWebSocket | undefined; + private socket: + | UnidirectionalStream + | undefined; private disposed = false; private constructor(private readonly logger: Logger) {} diff --git a/src/remote/workspaceStateMachine.ts b/src/remote/workspaceStateMachine.ts index eb7aa335..340ec960 100644 --- a/src/remote/workspaceStateMachine.ts +++ b/src/remote/workspaceStateMachine.ts @@ -21,7 +21,7 @@ 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 { OneWayWebSocket } from "../websocket/oneWayWebSocket"; +import type { UnidirectionalStream } from "../websocket/eventStreamConnection"; /** * Manages workspace and agent state transitions until ready for SSH connection. @@ -32,9 +32,10 @@ export class WorkspaceStateMachine implements vscode.Disposable { private agent: { id: string; name: string } | undefined; - private buildLogSocket: OneWayWebSocket | null = null; + private buildLogSocket: UnidirectionalStream | null = null; - private agentLogSocket: OneWayWebSocket | null = null; + private agentLogSocket: UnidirectionalStream | null = + null; constructor( private readonly parts: AuthorityParts, diff --git a/src/websocket/codes.ts b/src/websocket/codes.ts new file mode 100644 index 00000000..ac8eccf7 --- /dev/null +++ b/src/websocket/codes.ts @@ -0,0 +1,55 @@ +/** + * 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 or 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.FORBIDDEN, + 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 index 2dc6514e..e3100ee6 100644 --- a/src/websocket/eventStreamConnection.ts +++ b/src/websocket/eventStreamConnection.ts @@ -1,11 +1,16 @@ import { type WebSocketEventType } from "coder/site/src/utils/OneWayWebSocket"; import { - type CloseEvent, + type CloseEvent as WsCloseEvent, type Event as WsEvent, - type ErrorEvent, - type MessageEvent, + 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< | { @@ -24,7 +29,7 @@ export type EventPayloadMap = { close: CloseEvent; error: ErrorEvent; message: ParsedMessageEvent; - open: WsEvent; + open: Event; }; export type EventHandler = ( diff --git a/src/websocket/reconnectingWebSocket.ts b/src/websocket/reconnectingWebSocket.ts new file mode 100644 index 00000000..2ced9351 --- /dev/null +++ b/src/websocket/reconnectingWebSocket.ts @@ -0,0 +1,304 @@ +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 #apiRoute: string; + 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; + #backoffMs: number; + #reconnectTimeoutId: NodeJS.Timeout | null = null; + #isDisposed = false; + #isConnecting = false; + #pendingReconnect = false; + readonly #onDispose?: () => void; + + private constructor( + socketFactory: SocketFactory, + logger: Logger, + apiRoute: string, + options: ReconnectingWebSocketOptions = {}, + onDispose?: () => void, + ) { + this.#socketFactory = socketFactory; + this.#logger = logger; + this.#apiRoute = apiRoute; + 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, + apiRoute: string, + options: ReconnectingWebSocketOptions = {}, + onDispose?: () => void, + ): Promise> { + const instance = new ReconnectingWebSocket( + socketFactory, + logger, + apiRoute, + options, + onDispose, + ); + await instance.connect(); + return instance; + } + + get url(): string { + return this.#currentSocket?.url ?? ""; + } + + addEventListener( + event: TEvent, + callback: EventHandler, + ): void { + this.#eventHandlers[event].add(callback); + } + + removeEventListener( + event: TEvent, + callback: EventHandler, + ): void { + this.#eventHandlers[event].delete(callback); + } + + reconnect(): void { + 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)); + } + + 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.#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(); + this.#currentSocket = socket; + + 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); + }); + + socket.addEventListener("close", (event) => { + if (this.#isDisposed) { + 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}`, + ); + this.dispose(); + 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.#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.#apiRoute}`, + ); + + 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.#apiRoute}`, + error, + ); + } + } + } + + /** + * Checks if the error is unrecoverable and disposes the connection, + * otherwise schedules a reconnect. + */ + private handleConnectionError(error: unknown): void { + if (this.#isDisposed) { + return; + } + + if (this.isUnrecoverableHttpError(error)) { + this.#logger.error( + `Unrecoverable HTTP error during connection for ${this.#apiRoute}`, + error, + ); + this.dispose(); + return; + } + + this.#logger.warn( + `WebSocket connection failed for ${this.#apiRoute}`, + error, + ); + this.scheduleReconnect(); + } + + /** + * Check if an error contains an unrecoverable HTTP status code. + */ + private isUnrecoverableHttpError(error: unknown): boolean { + const errorMessage = error instanceof Error ? error.message : String(error); + for (const code of UNRECOVERABLE_HTTP_CODES) { + if (errorMessage.includes(String(code))) { + return true; + } + } + return false; + } + + private dispose(code?: number, reason?: string): void { + if (this.#isDisposed) { + return; + } + + this.#isDisposed = true; + + if (this.#reconnectTimeoutId !== null) { + clearTimeout(this.#reconnectTimeoutId); + this.#reconnectTimeoutId = null; + } + + if (this.#currentSocket) { + this.#currentSocket.close(code, reason); + this.#currentSocket = null; + } + + for (const set of Object.values(this.#eventHandlers)) { + set.clear(); + } + + this.#onDispose?.(); + } +} diff --git a/src/websocket/sseConnection.ts b/src/websocket/sseConnection.ts index 5a71d303..dc20eeda 100644 --- a/src/websocket/sseConnection.ts +++ b/src/websocket/sseConnection.ts @@ -6,19 +6,14 @@ 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 { - CloseEvent as WsCloseEvent, - ErrorEvent as WsErrorEvent, - Event as WsEvent, - MessageEvent as WsMessageEvent, -} from "ws"; - import type { UnidirectionalStream, ParsedMessageEvent, EventHandler, + ErrorEvent as WsErrorEvent, } from "./eventStreamConnection"; export type SseConnectionInit = { @@ -66,7 +61,7 @@ export class SseConnection implements UnidirectionalStream { private setupEventHandlers(): void { this.eventSource.addEventListener("open", () => - this.invokeCallbacks(this.callbacks.open, {} as WsEvent, "open"), + this.invokeCallbacks(this.callbacks.open, {}, "open"), ); this.eventSource.addEventListener("data", (event: MessageEvent) => { @@ -84,10 +79,10 @@ export class SseConnection implements UnidirectionalStream { this.invokeCallbacks( this.callbacks.close, { - code: 1006, + code: WebSocketCloseCode.ABNORMAL, reason: "Connection lost", wasClean: false, - } as WsCloseEvent, + }, "close", ); } @@ -117,7 +112,7 @@ export class SseConnection implements UnidirectionalStream { return { error: error, message: errorMessage, - } as WsErrorEvent; + }; } public addEventListener( @@ -158,7 +153,7 @@ export class SseConnection implements UnidirectionalStream { private parseMessage( event: MessageEvent, ): ParsedMessageEvent { - const wsEvent = { data: event.data } as WsMessageEvent; + const wsEvent = { data: event.data }; try { return { sourceEvent: wsEvent, @@ -207,14 +202,16 @@ export class SseConnection implements UnidirectionalStream { this.invokeCallbacks( this.callbacks.close, { - code: code ?? 1000, + code: code ?? WebSocketCloseCode.NORMAL, reason: reason ?? "Normal closure", wasClean: true, - } as WsCloseEvent, + }, "close", ); - Object.values(this.callbacks).forEach((callbackSet) => callbackSet.clear()); + for (const callbackSet of Object.values(this.callbacks)) { + callbackSet.clear(); + } this.messageWrappers.clear(); } } diff --git a/test/unit/api/coderApi.test.ts b/test/unit/api/coderApi.test.ts index f133a72d..4f90f33e 100644 --- a/test/unit/api/coderApi.test.ts +++ b/test/unit/api/coderApi.test.ts @@ -10,7 +10,7 @@ import { createHttpAgent } from "@/api/utils"; import { CertificateError } from "@/error"; import { getHeaders } from "@/headers"; import { type RequestConfigWithMeta } from "@/logging/types"; -import { OneWayWebSocket } from "@/websocket/oneWayWebSocket"; +import { ReconnectingWebSocket } from "@/websocket/reconnectingWebSocket"; import { SseConnection } from "@/websocket/sseConnection"; import { @@ -332,7 +332,7 @@ describe("CoderApi", () => { const connection = await api.watchAgentMetadata(AGENT_ID); - expect(connection).toBeInstanceOf(OneWayWebSocket); + expect(connection).toBeInstanceOf(ReconnectingWebSocket); expect(EventSource).not.toHaveBeenCalled(); }); @@ -373,6 +373,70 @@ describe("CoderApi", () => { }); }); + 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"); + await new Promise((resolve) => setImmediate(resolve)); + + expect(sockets[0].close).toHaveBeenCalledWith( + 1000, + "Replacing connection", + ); + expect(sockets).toHaveLength(2); + expect(wsWrap.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); + }); + }); + describe("Error Handling", () => { it("throws error when no base URL is set", async () => { const api = createApi(); diff --git a/test/unit/websocket/reconnectingWebSocket.test.ts b/test/unit/websocket/reconnectingWebSocket.test.ts new file mode 100644 index 00000000..cdf08949 --- /dev/null +++ b/test/unit/websocket/reconnectingWebSocket.test.ts @@ -0,0 +1,468 @@ +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(), + "/api/test", + ), + ).rejects.toThrow(`Unexpected server response: ${statusCode}`); + + // Should not retry after unrecoverable HTTP error + await vi.advanceTimersByTimeAsync(10000); + expect(socketCreationAttempts).toBe(1); + }, + ); + + 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 sockets: MockSocket[] = []; + let pendingResolve: ((socket: MockSocket) => void) | null = null; + + const factory = vi.fn(() => { + const socket = createMockSocket(); + sockets.push(socket); + + // First call resolves immediately, other calls wait for manual resolve + if (sockets.length === 1) { + return Promise.resolve(socket); + } else { + return new Promise((resolve) => { + pendingResolve = resolve; + }); + } + }); + + const ws = await fromFactory(factory); + sockets[0].fireOpen(); + expect(sockets).toHaveLength(1); + + // 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); + + // Complete the first reconnect + pendingResolve!(sockets[1]); + sockets[1].fireOpen(); + + // Wait a tick for the queued reconnect to execute + await Promise.resolve(); + // Now queued reconnect should have executed, creating third socket + expect(sockets).toHaveLength(3); + + 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(); + }); + }); + + 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("calls onDispose callback on unrecoverable WebSocket close code", async () => { + let disposeCount = 0; + const { sockets } = await createReconnectingWebSocket( + () => ++disposeCount, + ); + + sockets[0].fireOpen(); + sockets[0].fireClose({ + code: WebSocketCloseCode.PROTOCOL_ERROR, + reason: "Protocol error", + }); + + expect(disposeCount).toBe(1); + }); + + 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); + }); + }); + + 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 fromFactory( + factory: SocketFactory, + onDispose?: () => void, +): Promise> { + return await ReconnectingWebSocket.create( + factory, + createMockLogger(), + "/random/api", + undefined, + onDispose, + ); +} diff --git a/test/unit/websocket/sseConnection.test.ts b/test/unit/websocket/sseConnection.test.ts index 61cfce4d..378e6f54 100644 --- a/test/unit/websocket/sseConnection.test.ts +++ b/test/unit/websocket/sseConnection.test.ts @@ -3,10 +3,14 @@ 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 CloseEvent, type ErrorEvent } from "ws"; import { type Logger } from "@/logging/logger"; -import { type ParsedMessageEvent } from "@/websocket/eventStreamConnection"; +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"; @@ -168,7 +172,7 @@ describe("SseConnection", () => { await waitForNextTick(); expect(events).toEqual([ { - code: 1006, + code: WebSocketCloseCode.ABNORMAL, reason: "Connection lost", wasClean: false, }, @@ -223,13 +227,17 @@ describe("SseConnection", () => { type CloseHandlingTestCase = [ code: number | undefined, reason: string | undefined, - closeEvent: Omit, + closeEvent: CloseEvent, ]; it.each([ [ undefined, undefined, - { code: 1000, reason: "Normal closure", wasClean: true }, + { + code: WebSocketCloseCode.NORMAL, + reason: "Normal closure", + wasClean: true, + }, ], [ 4000, From 5bfc4a0b799b9cf160edfddd5dbc796f278180b9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Nov 2025 13:03:37 +0300 Subject: [PATCH 56/69] chore(deps): bump actions/checkout from 5 to 6 (#662) --- .github/workflows/ci.yaml | 6 +++--- .github/workflows/pre-release.yaml | 2 +- .github/workflows/publish-extension.yaml | 2 +- .github/workflows/release.yaml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 64e85a15..b1b0df6e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: @@ -35,7 +35,7 @@ jobs: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: @@ -51,7 +51,7 @@ jobs: runs-on: ubuntu-22.04 needs: [lint, test] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: diff --git a/.github/workflows/pre-release.yaml b/.github/workflows/pre-release.yaml index 430aa2a1..4292c968 100644 --- a/.github/workflows/pre-release.yaml +++ b/.github/workflows/pre-release.yaml @@ -16,7 +16,7 @@ jobs: outputs: version: ${{ steps.version.outputs.version }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: diff --git a/.github/workflows/publish-extension.yaml b/.github/workflows/publish-extension.yaml index e7d5dca7..804ec048 100644 --- a/.github/workflows/publish-extension.yaml +++ b/.github/workflows/publish-extension.yaml @@ -27,7 +27,7 @@ jobs: hasVscePat: ${{ steps.check-secrets.outputs.hasVscePat }} hasOvsxPat: ${{ steps.check-secrets.outputs.hasOvsxPat }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 557586ec..5c71f8c2 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -17,7 +17,7 @@ jobs: outputs: version: ${{ steps.version.outputs.version }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: From ae0a553fe7c3e502ad77b12b2b8a48ee4f60b2e6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Nov 2025 13:04:35 +0300 Subject: [PATCH 57/69] chore(deps-dev): bump @vscode/vsce from 3.6.2 to 3.7.0 (#651) --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index e1946da1..7637acf7 100644 --- a/package.json +++ b/package.json @@ -372,7 +372,7 @@ "@vitest/coverage-v8": "^3.2.4", "@vscode/test-cli": "^0.0.12", "@vscode/test-electron": "^2.5.2", - "@vscode/vsce": "^3.6.2", + "@vscode/vsce": "^3.7.0", "bufferutil": "^4.0.9", "coder": "https://github.com/coder/coder#main", "dayjs": "^1.11.13", diff --git a/yarn.lock b/yarn.lock index b2527a90..9d338260 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1772,10 +1772,10 @@ "@vscode/vsce-sign-win32-arm64" "2.0.5" "@vscode/vsce-sign-win32-x64" "2.0.5" -"@vscode/vsce@^3.6.2": - version "3.6.2" - resolved "https://registry.yarnpkg.com/@vscode/vsce/-/vsce-3.6.2.tgz#cefd2802f1dec24fca51293ae563e11912f545fd" - integrity sha512-gvBfarWF+Ii20ESqjA3dpnPJpQJ8fFJYtcWtjwbRADommCzGg1emtmb34E+DKKhECYvaVyAl+TF9lWS/3GSPvg== +"@vscode/vsce@^3.7.0": + version "3.7.0" + resolved "https://registry.yarnpkg.com/@vscode/vsce/-/vsce-3.7.0.tgz#a2a8c0bb414227867c6b246b6fcd84614e5dca7c" + integrity sha512-LY9r2T4joszRjz4d92ZPl6LTBUPS4IWH9gG/3JUv+1QyBJrveZlcVISuiaq0EOpmcgFh0GgVgKD4rD/21Tu8sA== dependencies: "@azure/identity" "^4.1.0" "@secretlint/node" "^10.1.2" From a3c17c5db12712b4e71264ed9e897f3a62be9bc1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Nov 2025 13:09:15 +0300 Subject: [PATCH 58/69] chore(deps-dev): bump @typescript-eslint/parser from 8.46.2 to 8.46.4 (#652) --- package.json | 2 +- yarn.lock | 84 ++++++++++++++++++++++++++-------------------------- 2 files changed, 43 insertions(+), 43 deletions(-) diff --git a/package.json b/package.json index 7637acf7..8a3d820c 100644 --- a/package.json +++ b/package.json @@ -368,7 +368,7 @@ "@types/vscode": "^1.73.0", "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.44.0", - "@typescript-eslint/parser": "^8.46.2", + "@typescript-eslint/parser": "^8.46.4", "@vitest/coverage-v8": "^3.2.4", "@vscode/test-cli": "^0.0.12", "@vscode/test-electron": "^2.5.2", diff --git a/yarn.lock b/yarn.lock index 9d338260..45811626 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1361,15 +1361,15 @@ natural-compare "^1.4.0" ts-api-utils "^2.1.0" -"@typescript-eslint/parser@^8.46.2": - version "8.46.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.46.2.tgz#dd938d45d581ac8ffa9d8a418a50282b306f7ebf" - integrity sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g== - dependencies: - "@typescript-eslint/scope-manager" "8.46.2" - "@typescript-eslint/types" "8.46.2" - "@typescript-eslint/typescript-estree" "8.46.2" - "@typescript-eslint/visitor-keys" "8.46.2" +"@typescript-eslint/parser@^8.46.4": + version "8.46.4" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.46.4.tgz#1a5bfd48be57bc07eec64e090ac46e89f47ade31" + integrity sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w== + dependencies: + "@typescript-eslint/scope-manager" "8.46.4" + "@typescript-eslint/types" "8.46.4" + "@typescript-eslint/typescript-estree" "8.46.4" + "@typescript-eslint/visitor-keys" "8.46.4" debug "^4.3.4" "@typescript-eslint/project-service@8.44.1": @@ -1381,13 +1381,13 @@ "@typescript-eslint/types" "^8.44.1" debug "^4.3.4" -"@typescript-eslint/project-service@8.46.2": - version "8.46.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.46.2.tgz#ab2f02a0de4da6a7eeb885af5e059be57819d608" - integrity sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg== +"@typescript-eslint/project-service@8.46.4": + version "8.46.4" + resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.46.4.tgz#fa9872673b51fb57e5d5da034edbe17424ddd185" + integrity sha512-nPiRSKuvtTN+no/2N1kt2tUh/HoFzeEgOm9fQ6XQk4/ApGqjx0zFIIaLJ6wooR1HIoozvj2j6vTi/1fgAz7UYQ== dependencies: - "@typescript-eslint/tsconfig-utils" "^8.46.2" - "@typescript-eslint/types" "^8.46.2" + "@typescript-eslint/tsconfig-utils" "^8.46.4" + "@typescript-eslint/types" "^8.46.4" debug "^4.3.4" "@typescript-eslint/scope-manager@8.44.1": @@ -1398,23 +1398,23 @@ "@typescript-eslint/types" "8.44.1" "@typescript-eslint/visitor-keys" "8.44.1" -"@typescript-eslint/scope-manager@8.46.2": - version "8.46.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.46.2.tgz#7d37df2493c404450589acb3b5d0c69cc0670a88" - integrity sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA== +"@typescript-eslint/scope-manager@8.46.4": + version "8.46.4" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.46.4.tgz#78c9b4856c0094def64ffa53ea955b46bec13304" + integrity sha512-tMDbLGXb1wC+McN1M6QeDx7P7c0UWO5z9CXqp7J8E+xGcJuUuevWKxuG8j41FoweS3+L41SkyKKkia16jpX7CA== dependencies: - "@typescript-eslint/types" "8.46.2" - "@typescript-eslint/visitor-keys" "8.46.2" + "@typescript-eslint/types" "8.46.4" + "@typescript-eslint/visitor-keys" "8.46.4" "@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.46.2", "@typescript-eslint/tsconfig-utils@^8.46.2": - version "8.46.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.2.tgz#d110451cb93bbd189865206ea37ef677c196828c" - integrity sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag== +"@typescript-eslint/tsconfig-utils@8.46.4", "@typescript-eslint/tsconfig-utils@^8.46.4": + version "8.46.4" + resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.4.tgz#989a338093b6b91b0552f1f51331d89ec6980382" + integrity sha512-+/XqaZPIAk6Cjg7NWgSGe27X4zMGqrFqZ8atJsX3CWxH/jACqWnrWI68h7nHQld0y+k9eTTjb9r+KU4twLoo9A== "@typescript-eslint/type-utils@8.44.1": version "8.44.1" @@ -1432,10 +1432,10 @@ resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.44.1.tgz#85d1cad1290a003ff60420388797e85d1c3f76ff" integrity sha512-Lk7uj7y9uQUOEguiDIDLYLJOrYHQa7oBiURYVFqIpGxclAFQ78f6VUOM8lI2XEuNOKNB7XuvM2+2cMXAoq4ALQ== -"@typescript-eslint/types@8.46.2", "@typescript-eslint/types@^8.44.1", "@typescript-eslint/types@^8.46.2": - version "8.46.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.46.2.tgz#2bad7348511b31e6e42579820e62b73145635763" - integrity sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ== +"@typescript-eslint/types@8.46.4", "@typescript-eslint/types@^8.44.1", "@typescript-eslint/types@^8.46.4": + version "8.46.4" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.46.4.tgz#38022bfda051be80e4120eeefcd2b6e3e630a69b" + integrity sha512-USjyxm3gQEePdUwJBFjjGNG18xY9A2grDVGuk7/9AkjIF1L+ZrVnwR5VAU5JXtUnBL/Nwt3H31KlRDaksnM7/w== "@typescript-eslint/typescript-estree@8.44.1": version "8.44.1" @@ -1453,15 +1453,15 @@ semver "^7.6.0" ts-api-utils "^2.1.0" -"@typescript-eslint/typescript-estree@8.46.2": - version "8.46.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.2.tgz#ab547a27e4222bb6a3281cb7e98705272e2c7d08" - integrity sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ== +"@typescript-eslint/typescript-estree@8.46.4": + version "8.46.4" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.4.tgz#6a9eeab0da45bf400f22c818e0f47102a980ceaa" + integrity sha512-7oV2qEOr1d4NWNmpXLR35LvCfOkTNymY9oyW+lUHkmCno7aOmIf/hMaydnJBUTBMRCOGZh8YjkFOc8dadEoNGA== dependencies: - "@typescript-eslint/project-service" "8.46.2" - "@typescript-eslint/tsconfig-utils" "8.46.2" - "@typescript-eslint/types" "8.46.2" - "@typescript-eslint/visitor-keys" "8.46.2" + "@typescript-eslint/project-service" "8.46.4" + "@typescript-eslint/tsconfig-utils" "8.46.4" + "@typescript-eslint/types" "8.46.4" + "@typescript-eslint/visitor-keys" "8.46.4" debug "^4.3.4" fast-glob "^3.3.2" is-glob "^4.0.3" @@ -1487,12 +1487,12 @@ "@typescript-eslint/types" "8.44.1" eslint-visitor-keys "^4.2.1" -"@typescript-eslint/visitor-keys@8.46.2": - version "8.46.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.2.tgz#803fa298948c39acf810af21bdce6f8babfa9738" - integrity sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w== +"@typescript-eslint/visitor-keys@8.46.4": + version "8.46.4" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.4.tgz#07031bd8d3ca6474e121221dae1055daead888f1" + integrity sha512-/++5CYLQqsO9HFGLI7APrxBJYo+5OCMpViuhV8q5/Qa3o5mMrF//eQHks+PXcsAVaLdn817fMuS7zqoXNNZGaw== dependencies: - "@typescript-eslint/types" "8.46.2" + "@typescript-eslint/types" "8.46.4" eslint-visitor-keys "^4.2.1" "@typespec/ts-http-runtime@^0.3.0": From 91d481e29836df58ccd50981bf97adeb94172352 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Nov 2025 13:12:58 +0300 Subject: [PATCH 59/69] chore(deps-dev): bump glob from 11.0.3 to 11.1.0 (#655) --- package.json | 2 +- yarn.lock | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 8a3d820c..39e53111 100644 --- a/package.json +++ b/package.json @@ -384,7 +384,7 @@ "eslint-plugin-md": "^1.0.19", "eslint-plugin-package-json": "^0.59.0", "eslint-plugin-prettier": "^5.5.4", - "glob": "^11.0.3", + "glob": "^11.1.0", "jsonc-eslint-parser": "^2.4.0", "markdown-eslint-parser": "^1.2.1", "memfs": "^4.49.0", diff --git a/yarn.lock b/yarn.lock index 45811626..29728d41 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4528,14 +4528,14 @@ glob@^10.3.10, glob@^10.4.1, glob@^10.4.5: package-json-from-dist "^1.0.0" path-scurry "^1.11.1" -glob@^11.0.0, glob@^11.0.3: - 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" @@ -6119,10 +6119,10 @@ mimic-response@^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" From ad7bd78bad8d1483c7047ba7e98f09a8de47645d Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 2 Dec 2025 17:16:59 +0300 Subject: [PATCH 60/69] Remove duplicate "Cancel" button for the update workspace dialog (#664) Closes #657 --- src/commands.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/commands.ts b/src/commands.ts index 682d745b..384b4d79 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -579,7 +579,6 @@ 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); From 8f7c748531734151b33a980e9e46a9f91996f1b1 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 4 Dec 2025 17:32:04 +0300 Subject: [PATCH 61/69] Use find-process npm package (#668) --- package.json | 2 +- yarn.lock | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 39e53111..568c6d22 100644 --- a/package.json +++ b/package.json @@ -347,7 +347,7 @@ "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", "openpgp": "^6.2.2", "pretty-bytes": "^7.1.0", diff --git a/yarn.lock b/yarn.lock index 29728d41..36579770 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4135,9 +4135,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" From 6d572920f4ee7653c0a73359151164bb4093351d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Dec 2025 21:17:33 +0300 Subject: [PATCH 62/69] chore(deps): bump jws from 3.2.2 to 3.2.3 (#669) --- yarn.lock | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/yarn.lock b/yarn.lock index 36579770..a12a3469 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5734,7 +5734,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== @@ -5744,11 +5744,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: From 3a67bc1e50fe35d05368740ca22a71c7ec028126 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 8 Dec 2025 11:03:37 +0300 Subject: [PATCH 63/69] Expand tilde in Coder settings paths (#667) Closes #417 --- CHANGELOG.md | 13 +++++--- src/util.ts | 12 ++++---- test/unit/util.test.ts | 68 +++++++++++++++++++++++++++++++++++++++++- 3 files changed, 83 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b1745b7..a7ebd676 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,16 @@ ## Unreleased +### Added + +- Support for paths that begin with a tilde (`~`). + ### 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 @@ -15,9 +20,9 @@ ## [v1.11.4](https://github.com/coder/vscode-coder/releases/tag/v1.11.4) 2025-11-20 -### Fixed +### Added -- Add support for `google.antigravity-remote-openssh` Remote SSH extension. +- Support for the `google.antigravity-remote-openssh` Remote SSH extension. ### Changed @@ -55,7 +60,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 @@ -134,7 +139,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/src/util.ts b/src/util.ts index e7c5c24c..21785cf6 100644 --- a/src/util.ts +++ b/src/util.ts @@ -119,13 +119,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 +146,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/test/unit/util.test.ts b/test/unit/util.test.ts index d508f41c..a5d6eb7a 100644 --- a/test/unit/util.test.ts +++ b/test/unit/util.test.ts @@ -1,6 +1,13 @@ +import os from "node:os"; import { describe, it, expect } from "vitest"; -import { countSubstring, parseRemoteAuthority, toSafeHost } from "@/util"; +import { + countSubstring, + escapeCommandArg, + expandPath, + parseRemoteAuthority, + toSafeHost, +} from "@/util"; it("ignore unrelated authorities", () => { const tests = [ @@ -124,3 +131,62 @@ 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`); + }); +}); From aad1920b6a2e71f10d9a9c9ea74f07081de4b86a Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 8 Dec 2025 12:20:06 +0300 Subject: [PATCH 64/69] Refactor SSH process monitoring to support VS Code forks (#665) Extract SSH process discovery and network status display into a dedicated `SshProcessMonitor` class. Add centralized Remote SSH extension detection to support Cursor, Windsurf, and other VS Code forks. Key changes: - Extract SSH monitoring logic from `remote.ts` into `sshProcess.ts` - Add `sshExtension.ts` to detect installed Remote SSH extension - Use `createRequire` instead of private `module._load` API to load the Remote SSH extension - Fix port detection to find most recent port and handle SSH reconnects - Add Cursor's "Socks port:" log format to port regex Closes #660 --- CHANGELOG.md | 2 + src/extension.ts | 30 +- src/remote/remote.ts | 225 +++----------- src/remote/sshExtension.ts | 25 ++ src/remote/sshProcess.ts | 447 ++++++++++++++++++++++++++++ src/util.ts | 33 +- test/mocks/testHelpers.ts | 61 +++- test/mocks/vscode.runtime.ts | 32 +- test/unit/remote/sshProcess.test.ts | 442 +++++++++++++++++++++++++++ test/unit/util.test.ts | 220 ++++++++------ 10 files changed, 1197 insertions(+), 320 deletions(-) create mode 100644 src/remote/sshExtension.ts create mode 100644 src/remote/sshProcess.ts create mode 100644 test/unit/remote/sshProcess.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a7ebd676..425ed11a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ - 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 diff --git a/src/extension.ts b/src/extension.ts index 9751b0f7..974cbe7d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,7 +2,8 @@ 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"; @@ -14,6 +15,7 @@ import { AuthAction } from "./core/secretsManager"; 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, @@ -33,30 +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") || - vscode.extensions.getExtension("google.antigravity-remote-openssh"); + 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); @@ -366,11 +359,12 @@ 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); diff --git a/src/remote/remote.ts b/src/remote/remote.ts index 1edf351c..4193e46d 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -4,12 +4,10 @@ import { type Workspace, type WorkspaceAgent, } from "coder/site/src/api/typesGenerated"; -import find from "find-process"; import * as jsonc from "jsonc-parser"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import prettyBytes from "pretty-bytes"; import * as semver from "semver"; import * as vscode from "vscode"; @@ -36,12 +34,12 @@ 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"; @@ -109,6 +107,7 @@ export class Remote { public async setup( remoteAuthority: string, firstConnect: boolean, + remoteSshExtensionId: string, ): Promise { const parts = parseRemoteAuthority(remoteAuthority); if (!parts) { @@ -148,7 +147,7 @@ export class Remote { ]); if (result.type === "login") { - return this.setup(remoteAuthority, firstConnect); + return this.setup(remoteAuthority, firstConnect, remoteSshExtensionId); } else if (!result.userChoice) { // User declined to log in. await this.closeRemote(); @@ -156,7 +155,7 @@ export class Remote { } else { // Log in then try again. await this.commands.login({ url: baseUrlRaw, label: parts.label }); - return this.setup(remoteAuthority, firstConnect); + return this.setup(remoteAuthority, firstConnect, remoteSshExtensionId); } }; @@ -485,30 +484,26 @@ 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.host, + 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; + }), + // Watch for logDir configuration changes + this.watchLogDirSetting(logDir, featureSet), + // Register the label formatter again because SSH overrides it! vscode.extensions.onDidChange(() => { // Dispose previous label formatter labelFormatterDisposable.dispose(); @@ -741,172 +736,30 @@ export class Remote { 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`, - ); - - 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(); + private watchLogDirSetting( + currentLogDir: string, + featureSet: FeatureSet, + ): vscode.Disposable { + return vscode.workspace.onDidChangeConfiguration((e) => { + if (!e.affectsConfiguration("coder.proxyLogDirectory")) { return; } - - 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`; - }); - } - - statusText += "(" + network.latency.toFixed(2) + "ms)"; - networkStatus.text = statusText; - networkStatus.show(); - }; - let disposed = false; - const periodicRefresh = () => { - if (disposed) { + const newLogDir = this.getLogDir(featureSet); + if (newLogDir === currentLogDir) { return; } - fs.readFile(networkInfoFile, "utf8") - .then((content) => { - return JSON.parse(content); - }) - .then((parsed) => { - try { - updateStatus(parsed); - } catch { - // Ignore + + vscode.window + .showInformationMessage( + "Log directory configuration changed. Reload window to apply.", + "Reload", + ) + .then((action) => { + if (action === "Reload") { + vscode.commands.executeCommand("workbench.action.reloadWindow"); } - }) - .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 = findPort(text); - if (!port) { - return; - } - const processes = await find("port", port); - if (processes.length < 1) { - 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]); + }); } /** 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..e86cf154 --- /dev/null +++ b/src/remote/sshProcess.ts @@ -0,0 +1,447 @@ +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); + logFiles.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); + // Node returns these directories sorted already! + const files = await fs.readdir(extensionLogDir); + files.reverse(); + + const remoteSsh = files.find((file) => file.includes("Remote - SSH")); + if (remoteSsh) { + return path.join(extensionLogDir, remoteSsh); + } + // Folder exists but no Remote SSH log yet + 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 { + // Node returns these directories sorted already! + const dirs = await fs.readdir(logsParentDir); + dirs.reverse(); + const outputDirs = dirs.filter((d) => d.startsWith("output_logging_")); + + if (outputDirs.length > 0) { + const outputPath = path.join(logsParentDir, outputDirs[0]); + const files = await fs.readdir(outputPath); + const remoteSSHLog = files.find((f) => f.includes("Remote - SSH")); + if (remoteSSHLog) { + return path.join(outputPath, 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; +} diff --git a/src/util.ts b/src/util.ts index 21785cf6..776ba1db 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,5 +1,5 @@ -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; @@ -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; } diff --git a/test/mocks/testHelpers.ts b/test/mocks/testHelpers.ts index faf2a72d..5678cd48 100644 --- a/test/mocks/testHelpers.ts +++ b/test/mocks/testHelpers.ts @@ -29,7 +29,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); } /** @@ -53,7 +53,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)); @@ -141,7 +141,7 @@ export class MockProgressReporter { * Use this to control user responses in tests. */ export class MockUserInteraction { - private responses = new Map(); + private readonly responses = new Map(); private externalUrls: string[] = []; constructor() { @@ -211,7 +211,7 @@ export class MockUserInteraction { // 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; @@ -235,9 +235,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); @@ -350,3 +352,50 @@ export function createMockStream( 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, + ); + } +} diff --git a/test/mocks/vscode.runtime.ts b/test/mocks/vscode.runtime.ts index 2201a851..4da3796f 100644 --- a/test/mocks/vscode.runtime.ts +++ b/test/mocks/vscode.runtime.ts @@ -55,18 +55,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 +93,7 @@ export const window = { dispose: vi.fn(), clear: vi.fn(), })), + createStatusBarItem: vi.fn(), }; export const commands = { @@ -132,6 +143,7 @@ const vscode = { ExtensionMode, UIKind, Uri, + EventEmitter, window, commands, workspace, diff --git a/test/unit/remote/sshProcess.test.ts b/test/unit/remote/sshProcess.test.ts new file mode 100644 index 00000000..1ec0e048 --- /dev/null +++ b/test/unit/remote/sshProcess.test.ts @@ -0,0 +1,442 @@ +import find from "find-process"; +import { vol } from "memfs"; +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.clearAllMocks(); + 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("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(); + }); + }); + + 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; + } +}); + +/** 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 a5d6eb7a..3015a47d 100644 --- a/test/unit/util.test.ts +++ b/test/unit/util.test.ts @@ -5,99 +5,104 @@ import { countSubstring, escapeCommandArg, expandPath, + findPort, 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); - } -}); - -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: "", - 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", +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 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: "", + 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("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", () => { @@ -190,3 +195,46 @@ describe("expandPath", () => { 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); + }); +}); From 43d01d1f18a9d1c544fad0a7866d276a1eea3b36 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:11:49 +0300 Subject: [PATCH 65/69] chore(deps-dev): bump dayjs from 1.11.13 to 1.11.19 (#674) --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 568c6d22..b41f85f8 100644 --- a/package.json +++ b/package.json @@ -375,7 +375,7 @@ "@vscode/vsce": "^3.7.0", "bufferutil": "^4.0.9", "coder": "https://github.com/coder/coder#main", - "dayjs": "^1.11.13", + "dayjs": "^1.11.19", "electron": "^39.1.2", "eslint": "^8.57.1", "eslint-config-prettier": "^10.1.8", diff --git a/yarn.lock b/yarn.lock index a12a3469..edecdd22 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3001,10 +3001,10 @@ 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, debug@^4.3.5, debug@^4.4.1: version "4.4.1" From 8c17dd43bbe44726a0dabc494c3dda1ddc735b98 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:21:14 +0300 Subject: [PATCH 66/69] chore(deps-dev): bump electron from 39.1.2 to 39.2.6 (#673) --- package.json | 2 +- yarn.lock | 17 +++++------------ 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index b41f85f8..19e4d861 100644 --- a/package.json +++ b/package.json @@ -376,7 +376,7 @@ "bufferutil": "^4.0.9", "coder": "https://github.com/coder/coder#main", "dayjs": "^1.11.19", - "electron": "^39.1.2", + "electron": "^39.2.6", "eslint": "^8.57.1", "eslint-config-prettier": "^10.1.8", "eslint-import-resolver-typescript": "^4.4.4", diff --git a/yarn.lock b/yarn.lock index edecdd22..bc824376 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1269,14 +1269,7 @@ resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-10.0.10.tgz#91f62905e8d23cbd66225312f239454a23bebfa0" integrity sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q== -"@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== - dependencies: - undici-types "~6.21.0" - -"@types/node@^22.7.7": +"@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== @@ -3249,10 +3242,10 @@ 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.1.2: - version "39.1.2" - resolved "https://registry.yarnpkg.com/electron/-/electron-39.1.2.tgz#8871c24c6795aeae8eefc08a9800a4bd0f04093c" - integrity sha512-+/TwT9NWxyQGTm5WemJEJy+bWCpnKJ4PLPswI1yn1P63bzM0/8yAeG05yS+NfFaWH4yNQtGXZmAv87Bxa5RlLg== +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" From cd59d8f633718d5ec6b0ed3f900af2afdd948e47 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 16:25:47 +0300 Subject: [PATCH 67/69] chore(deps-dev): bump @vscode/vsce from 3.7.0 to 3.7.1 (#672) --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 19e4d861..bd60a54c 100644 --- a/package.json +++ b/package.json @@ -372,7 +372,7 @@ "@vitest/coverage-v8": "^3.2.4", "@vscode/test-cli": "^0.0.12", "@vscode/test-electron": "^2.5.2", - "@vscode/vsce": "^3.7.0", + "@vscode/vsce": "^3.7.1", "bufferutil": "^4.0.9", "coder": "https://github.com/coder/coder#main", "dayjs": "^1.11.19", diff --git a/yarn.lock b/yarn.lock index bc824376..56ce6194 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1765,10 +1765,10 @@ "@vscode/vsce-sign-win32-arm64" "2.0.5" "@vscode/vsce-sign-win32-x64" "2.0.5" -"@vscode/vsce@^3.7.0": - version "3.7.0" - resolved "https://registry.yarnpkg.com/@vscode/vsce/-/vsce-3.7.0.tgz#a2a8c0bb414227867c6b246b6fcd84614e5dca7c" - integrity sha512-LY9r2T4joszRjz4d92ZPl6LTBUPS4IWH9gG/3JUv+1QyBJrveZlcVISuiaq0EOpmcgFh0GgVgKD4rD/21Tu8sA== +"@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.2" From c3943080860c682e4a976afd9fd5990a59838d58 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 10 Dec 2025 13:09:22 +0300 Subject: [PATCH 68/69] Add configurable SSH flags via `coder.sshFlags` setting (#670) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `coder.sshFlags` setting for passing custom flags to `coder ssh` - Watch SSH-related settings and prompt user to reload when changed - Renames `globalFlags.ts` → `cliConfig.ts` to consolidate CLI configuration logic Closes #666 --- CHANGELOG.md | 1 + package.json | 10 ++ src/api/workspace.ts | 2 +- src/{globalFlags.ts => cliConfig.ts} | 11 +- src/commands.ts | 2 +- src/remote/remote.ts | 153 +++++++++++++++++++-------- test/unit/cliConfig.test.ts | 116 ++++++++++++++++++++ test/unit/globalFlags.test.ts | 88 --------------- 8 files changed, 246 insertions(+), 137 deletions(-) rename src/{globalFlags.ts => cliConfig.ts} (60%) create mode 100644 test/unit/cliConfig.test.ts delete mode 100644 test/unit/globalFlags.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 425ed11a..1166d82c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Support for paths that begin with a tilde (`~`). +- Support for `coder ssh` flag configurations through the `coder.sshFlags` setting. ### Fixed diff --git a/package.json b/package.json index bd60a54c..b06f2a76 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/api/workspace.ts b/src/api/workspace.ts index 1d3b7a4e..93319337 100644 --- a/src/api/workspace.ts +++ b/src/api/workspace.ts @@ -8,8 +8,8 @@ import { 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"; diff --git a/src/globalFlags.ts b/src/cliConfig.ts similarity index 60% rename from src/globalFlags.ts rename to src/cliConfig.ts index 8e75ce8d..0ae0080f 100644 --- a/src/globalFlags.ts +++ b/src/cliConfig.ts @@ -14,7 +14,16 @@ export function getGlobalFlags( // Last takes precedence/overrides previous ones return [ ...(configs.get("coder.globalFlags") || []), - ...["--global-config", escapeCommandArg(configDir)], + "--global-config", + escapeCommandArg(configDir), ...getHeaderArgs(configs), ]; } + +/** + * Returns SSH flags for the `coder ssh` command from user configuration. + */ +export function getSshFlags(configs: WorkspaceConfiguration): 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 384b4d79..9bb2ed54 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -10,6 +10,7 @@ import * as vscode from "vscode"; import { createWorkspaceIdentifier, extractAgents } from "./api/api-helper"; import { CoderApi } from "./api/coderApi"; import { needToken } from "./api/utils"; +import { getGlobalFlags } from "./cliConfig"; import { type CliManager } from "./core/cliManager"; import { type ServiceContainer } from "./core/container"; import { type ContextManager } from "./core/contextManager"; @@ -17,7 +18,6 @@ import { type MementoManager } from "./core/mementoManager"; import { type PathResolver } from "./core/pathResolver"; import { type SecretsManager } from "./core/secretsManager"; import { CertificateError } from "./error"; -import { getGlobalFlags } from "./globalFlags"; import { type Logger } from "./logging/logger"; import { maybeAskAgent, maybeAskUrl } from "./promptUtils"; import { escapeCommandArg, toRemoteAuthority, toSafeHost } from "./util"; diff --git a/src/remote/remote.ts b/src/remote/remote.ts index 4193e46d..27a0477e 100644 --- a/src/remote/remote.ts +++ b/src/remote/remote.ts @@ -20,6 +20,7 @@ import { import { extractAgents } from "../api/api-helper"; import { CoderApi } from "../api/coderApi"; import { needToken } from "../api/utils"; +import { getGlobalFlags, getSshFlags } from "../cliConfig"; import { type Commands } from "../commands"; import { type CliManager } from "../core/cliManager"; import * as cliUtils from "../core/cliUtils"; @@ -27,7 +28,6 @@ import { type ServiceContainer } from "../core/container"; import { type ContextManager } from "../core/contextManager"; import { type PathResolver } from "../core/pathResolver"; import { featureSetForVersion, type FeatureSet } from "../featureSet"; -import { getGlobalFlags } from "../globalFlags"; import { Inbox } from "../inbox"; import { type Logger } from "../logging/logger"; import { @@ -501,8 +501,6 @@ export class Remote { sshMonitor.onLogFilePathChange((newPath) => { this.commands.workspaceLogPath = newPath; }), - // Watch for logDir configuration changes - this.watchLogDirSetting(logDir, featureSet), // Register the label formatter again because SSH overrides it! vscode.extensions.onDidChange(() => { // Dispose previous label formatter @@ -516,6 +514,18 @@ export class Remote { }), ...(await this.createAgentMetadataStatusBar(agent, workspaceClient)), ); + + const settingsToWatch = [ + { setting: "coder.globalFlags", title: "Global flags" }, + { setting: "coder.sshFlags", title: "SSH flags" }, + ]; + if (featureSet.proxyLogDirectory) { + settingsToWatch.push({ + setting: "coder.proxyLogDirectory", + title: "Proxy log directory", + }); + } + 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()); @@ -554,8 +564,10 @@ export class Remote { } /** - * Return the --log-dir argument value for the ProxyCommand. It may be an + * 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) { @@ -571,16 +583,79 @@ 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 @@ -666,15 +741,13 @@ export class Remote { ? `${AuthorityPrefix}.${label}--` : `${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, + label, + hostPrefix, + logDir, + featureSet.wildcardSSH, + ); const sshValues: SSHValues = { Host: hostPrefix + `*`, @@ -727,38 +800,26 @@ export class Remote { return sshConfig.getRaw(); } - private globalConfigs(label: string): string { - const vscodeConfig = vscode.workspace.getConfiguration(); - const args = getGlobalFlags( - vscodeConfig, - this.pathResolver.getGlobalConfigDir(label), - ); - return ` ${args.join(" ")}`; - } - - private watchLogDirSetting( - currentLogDir: string, - featureSet: FeatureSet, + private watchSettings( + settings: Array<{ setting: string; title: string }>, ): vscode.Disposable { return vscode.workspace.onDidChangeConfiguration((e) => { - if (!e.affectsConfiguration("coder.proxyLogDirectory")) { - return; - } - const newLogDir = this.getLogDir(featureSet); - if (newLogDir === currentLogDir) { - return; + for (const { setting, title } of settings) { + if (!e.affectsConfiguration(setting)) { + continue; + } + vscode.window + .showInformationMessage( + `${title} setting changed. Reload window to apply.`, + "Reload", + ) + .then((action) => { + if (action === "Reload") { + vscode.commands.executeCommand("workbench.action.reloadWindow"); + } + }); + break; } - - vscode.window - .showInformationMessage( - "Log directory configuration changed. Reload window to apply.", - "Reload", - ) - .then((action) => { - if (action === "Reload") { - vscode.commands.executeCommand("workbench.action.reloadWindow"); - } - }); }); } diff --git a/test/unit/cliConfig.test.ts b/test/unit/cliConfig.test.ts new file mode 100644 index 00000000..d350dcbd --- /dev/null +++ b/test/unit/cliConfig.test.ts @@ -0,0 +1,116 @@ +import { it, expect, describe } from "vitest"; +import { type WorkspaceConfiguration } from "vscode"; + +import { getGlobalFlags, getSshFlags } from "@/cliConfig"; + +import { isWindows } from "../utils/platform"; + +describe("cliConfig", () => { + describe("getGlobalFlags", () => { + 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 headerCommand = "echo test"; + const config = { + get: (key: string) => { + if (key === "coder.headerCommand") { + return headerCommand; + } + 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", + quoteCommand(headerCommand), + ]); + }); + }); + + describe("getSshFlags", () => { + it("returns default flags when no SSH flags configured", () => { + const config = { + get: (_key: string, defaultValue: unknown) => defaultValue, + } as unknown as WorkspaceConfiguration; + + expect(getSshFlags(config)).toStrictEqual(["--disable-autostart"]); + }); + + it("returns SSH flags from config", () => { + const config = { + get: (key: string) => + key === "coder.sshFlags" + ? ["--disable-autostart", "--wait=yes", "--ssh-host-prefix=custom"] + : undefined, + } as unknown as WorkspaceConfiguration; + + 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/globalFlags.test.ts b/test/unit/globalFlags.test.ts deleted file mode 100644 index 94c89dba..00000000 --- a/test/unit/globalFlags.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { it, expect, describe } from "vitest"; -import { type WorkspaceConfiguration } from "vscode"; - -import { getGlobalFlags } from "@/globalFlags"; - -import { isWindows } from "../utils/platform"; - -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 headerCommand = "echo test"; - const config = { - get: (key: string) => { - if (key === "coder.headerCommand") { - return headerCommand; - } - 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", - quoteCommand(headerCommand), - ]); - }); -}); - -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}`; -} From 677eee4231baac670f2cee3af5fd8fec7ebac99b Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 10 Dec 2025 13:27:17 +0300 Subject: [PATCH 69/69] v1.11.5 (#678) --- .github/workflows/publish-extension.yaml | 2 +- CHANGELOG.md | 2 ++ package.json | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-extension.yaml b/.github/workflows/publish-extension.yaml index 804ec048..77d7f73e 100644 --- a/.github/workflows/publish-extension.yaml +++ b/.github/workflows/publish-extension.yaml @@ -121,5 +121,5 @@ jobs: repo_token: ${{ secrets.GITHUB_TOKEN }} prerelease: ${{ inputs.isPreRelease }} draft: true - title: "${{ inputs.isPreRelease && 'Pre-' || '' }}Release v${{ inputs.version }}" + title: "v${{ inputs.version }}${{ inputs.isPreRelease && '-pre' || '' }}" files: ${{ needs.setup.outputs.packageName }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 1166d82c..bfbc903a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## [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 (`~`). diff --git a/package.json b/package.json index b06f2a76..b827cbac 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "coder-remote", "displayName": "Coder", - "version": "1.11.4", + "version": "1.11.5", "description": "Open any workspace with a single click.", "categories": [ "Other"