diff --git a/.eslintrc.json b/.eslintrc.json index f9b22b79..6e2777e7 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -16,6 +16,14 @@ "no-throw-literal": "warn", "semi": "off" }, + "overrides": [ + { + "files": ["build/setup*.js", "build/normalize-package-lock*.js"], + "env": { "node": true, "es2022": true }, + "parserOptions": { "ecmaVersion": 2022, "sourceType": "script" }, + "rules": { "@typescript-eslint/naming-convention": "off" } + } + ], "ignorePatterns": [ "out", "dist", diff --git a/.githooks/.gitattributes b/.githooks/.gitattributes new file mode 100644 index 00000000..a6a13212 --- /dev/null +++ b/.githooks/.gitattributes @@ -0,0 +1 @@ +pre-commit text eol=lf diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100644 index 00000000..e98820aa --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,5 @@ +#!/bin/sh +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +exec node build/normalize-package-lock.js --staged diff --git a/.github/actions/build-vsix/action.yml b/.github/actions/build-vsix/action.yml index ee4b6b3b..775d04f1 100644 --- a/.github/actions/build-vsix/action.yml +++ b/.github/actions/build-vsix/action.yml @@ -35,8 +35,10 @@ runs: run: python -m pip install -U pip pipx wheel shell: bash - - name: Run npm ci - run: npm ci --prefer-offline + - name: Install Node dependencies through Microsoft feeds + run: npm run ci:msft + env: + npm_config_prefer_offline: 'true' shell: bash - name: Install bundled python libraries diff --git a/.github/actions/lint/action.yml b/.github/actions/lint/action.yml index 7074ce14..22a8ee0f 100644 --- a/.github/actions/lint/action.yml +++ b/.github/actions/lint/action.yml @@ -15,8 +15,12 @@ runs: node-version: ${{ inputs.node_version }} cache: 'npm' + - name: Test setup and hook scripts (no dependencies required) + run: npm run test:setup + shell: bash + - name: Install Node dependencies - run: npm ci + run: npm run ci:msft shell: bash - name: Lint TypeScript code diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 3ae09bd9..8242e0ec 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -20,6 +20,10 @@ on: schedule: - cron: '15 16 * * 2' +env: + NPM_CONFIG_REGISTRY: https://packagefeedproxy.microsoft.io/npm/ + PIP_INDEX_URL: https://packagefeedproxy.microsoft.io/pypi/simple/ + jobs: analyze: name: Analyze diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index 24b41d57..13bcde29 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -6,6 +6,7 @@ on: permissions: {} env: + PIP_INDEX_URL: https://packagefeedproxy.microsoft.io/pypi/simple/ NODE_VERSION: 22.17.0 TEST_RESULTS_DIRECTORY: . # Force a path with spaces and unicode chars to test extension works in these scenarios @@ -84,7 +85,7 @@ jobs: cache-dependency-path: ${{ env.special-working-directory-relative }}/package-lock.json - name: Install dependencies (npm ci) - run: npm ci + run: npm run ci:msft # Now that the bundle is installed to target using python 3.9 # switch back the python we want to test with diff --git a/.github/workflows/push-check.yml b/.github/workflows/push-check.yml index d55fc26a..ba3c9bd7 100644 --- a/.github/workflows/push-check.yml +++ b/.github/workflows/push-check.yml @@ -11,6 +11,7 @@ on: permissions: {} env: + PIP_INDEX_URL: https://packagefeedproxy.microsoft.io/pypi/simple/ NODE_VERSION: 22.17.0 TEST_RESULTS_DIRECTORY: . # Force a path with spaces and unicode chars to test extension works in these scenarios @@ -89,7 +90,7 @@ jobs: cache-dependency-path: ${{ env.special-working-directory-relative }}/package-lock.json - name: Install dependencies (npm ci) - run: npm ci + run: npm run ci:msft # Now that the bundle is installed to target using python 3.9 # switch back the python we want to test with diff --git a/README.md b/README.md index 051cbe01..b16cca0a 100644 --- a/README.md +++ b/README.md @@ -54,3 +54,81 @@ You can reference the table below to find the most recent Python Debugger extens ## Data and telemetry The Debugpy Extension for Visual Studio Code collects usage data and sends it to Microsoft to help improve our products and services. Read our [privacy statement](https://privacy.microsoft.com/privacystatement) to learn more. This extension respects the `telemetry.telemetryLevel` setting which you can learn more about at https://code.visualstudio.com/docs/supporting/faq#_how-to-disable-telemetry-reporting. + +### Local package feeds + +Use Node.js supported by this repository and Python 3.10 or later. Both setup commands create or reuse `.venv` and install the Python bundling tools. Choose the appropriate setup below before installing dependencies. Committed lockfiles use public npm URLs; Microsoft setup rewrites working copies for proxy installs. + +#### Public contributors (non-Microsoft employees) + +Use the public setup, which selects `https://registry.npmjs.org/` and `https://pypi.org/simple/` for its child processes. No Microsoft account or internal-feed credentials are needed, and this command works before `node_modules` exists: + +From the repository root: + +```bash +npm run setup +``` + +For subsequent npm installs, use `npm ci`. Public contributors do not need the Microsoft feed commands or lockfile hook. + +#### Microsoft employees only + +> **Microsoft users only:** The commands in this section configure Microsoft package feeds. Public contributors should use the public setup above. + +Use the Microsoft setup, which selects `https://packagefeedproxy.microsoft.io/npm/` and `https://packagefeedproxy.microsoft.io/pypi/simple/` for its child processes: + +```bash +npm run setup:msft +``` + +Microsoft setup rewrites public npm and Yarn `resolved` URLs in the root `package-lock.json` to the npm proxy before running `npm ci`. Versions, integrity hashes, formatting, and private-feed URLs are preserved. The public setup leaves lockfiles unchanged. + +After pulling a new lockfile from GitHub, rerun `npm run setup:msft`, or run the dependency-free rewrite step alone before your next install/build: + +```bash +npm run setup:msft-registry +``` + +The standalone step requires the root lockfile to exist; it does not require a shared TypeScript checkout. Rewriting is idempotent and stops on missing or invalid lockfiles. Keep committed lockfile URLs public using the optional pre-commit hook below (or `npm run normalize-lockfile` for the root lockfile). + +For subsequent root npm installs, use: + +```bash +npm run ci:msft +``` + +This rewrites the root lockfile, then runs root `npm ci` with the proxy registry explicitly selected for package, audit, and metadata requests. Use `npm run setup:msft` for full setup, including Python dependencies. + +Local setup, lockfile rewriting, and `ci:msft` share the feed URLs in `build/package-feeds.json`. + +Both setup commands use the same implementation, stop on errors, and keep registry overrides scoped to setup's child processes; they do not change your shell environment, edit `.npmrc`, or add credentials. The standalone rewrite step only edits lockfiles; it does not configure the registry for subsequent commands. + +After setup, run `npm run compile` to build the extension. For subsequent Python commands, use `.venv\Scripts\python.exe` on Windows or `.venv/bin/python` on macOS/Linux (or activate that virtual environment). Registry overrides are not retained after setup. + +#### Microsoft contributor pre-commit hook + +If you use the Microsoft feeds and will commit changes, enable the recommended lockfile hook once per clone (no installed npm dependencies are required). It is not needed just to install or build: + +```bash +npm run setup-hooks +``` + +The installer sets the clone's local `core.hooksPath` to `.githooks`, which also affects its linked worktrees. It refuses to replace an existing hook or a different hooks configuration. If you already use hooks, add `node build/normalize-package-lock.js --staged` to your existing pre-commit hook instead. Only enable this configuration in worktrees/branches that contain these hook files. + +Before a local commit, the hook converts known `Pylance_PublicPackages`, `packagefeedproxy.microsoft.io/npm/`, and named `1es-public/npm-public` mirror tarball URLs in the staged root `package-lock.json` to public npm URLs. It preserves versions, integrity hashes, formatting, and intentional Azure-hosted dependencies such as `msft_consumption`. Unknown feeds are left unchanged. It re-stages only the lockfile; if normalization is needed and the lockfile has unstaged changes, the commit is blocked without modifying it. Stage or stash those changes, then retry. + +To normalize manually, run `npm run normalize-lockfile`, review the changes, and stage them. To disable the installed hook, run `git config --local --unset core.hooksPath`. These are local Git hooks: GitHub and Dependabot do not execute them. No workflow writes changes to PR branches. + +### GitHub Actions package feeds + +The PR and push workflows set `PIP_INDEX_URL` to `https://packagefeedproxy.microsoft.io/pypi/simple/` for pip and nox installs. Their Node installation steps select `https://packagefeedproxy.microsoft.io/npm/` explicitly through `npm run ci:msft`, so these workflows do not set `NPM_CONFIG_REGISTRY`. CodeQL retains both environment variables because its autobuild step does not use the install helper. The npm endpoint follows the [TypeScript CI package-feed-proxy job](https://github.com/microsoft/TypeScript/blob/main/.github/workflows/ci.yml). + +The shared lint and VSIX build actions and applicable PR/push test jobs use `npm run ci:msft`, rewriting working-copy lockfile URLs before installation and explicitly selecting the proxy for npm requests. Workflows do not commit these rewrites or change local registry configuration or Dependabot configuration. Existing dependency caches are retained; the VSIX build also retains its offline-cache preference. CodeQL retains workflow-level feed settings for its autobuild step. + +### Azure release package feeds + +The DevDiv Azure stable/prerelease pipelines pass the npm proxy to their build/publish templates using `customNPMRegistry`. Their dependency install steps use `npm run ci:msft` to rewrite the root npm lockfile and install through the proxy. + +Each Python bootstrap and bundled-library install step explicitly sets `PIP_INDEX_URL` from `PIP_PROXY_INDEX_URL`, selecting the pip proxy even when a template's authentication setup changes the job-level index. Release triggers, approval gates, signing, and publishing behavior are unchanged. + +Dependabot continues to use public registries and its existing update settings. Legacy Azure stable, prerelease, and validation definitions are unchanged. diff --git a/build/azure-devdiv-pipeline.pre-release.yml b/build/azure-devdiv-pipeline.pre-release.yml index 33fa6a5d..d919be89 100644 --- a/build/azure-devdiv-pipeline.pre-release.yml +++ b/build/azure-devdiv-pipeline.pre-release.yml @@ -26,8 +26,10 @@ variables: value: VSCode-python-debugger - name: VsixName value: python-debugger.vsix - - name: AZURE_ARTIFACTS_FEED - value: 'https://devdiv.pkgs.visualstudio.com/DevDiv/_packaging/Pylance_PublicPackages/npm/registry/' + - name: NPM_CONFIG_REGISTRY + value: 'https://packagefeedproxy.microsoft.io/npm/' + - name: PIP_PROXY_INDEX_URL + value: 'https://packagefeedproxy.microsoft.io/pypi/simple/' parameters: - name: publishExtension @@ -65,20 +67,28 @@ parameters: - name: buildSteps type: stepList default: - - script: npm ci + - script: npm run ci:msft displayName: Install NPM dependencies - script: python -m pip install -U pip displayName: Upgrade pip + env: + PIP_INDEX_URL: $(PIP_PROXY_INDEX_URL) - script: python -m pip install wheel displayName: Install wheel + env: + PIP_INDEX_URL: $(PIP_PROXY_INDEX_URL) - script: python -m pip install nox displayName: Install nox + env: + PIP_INDEX_URL: $(PIP_PROXY_INDEX_URL) - script: python -m nox --session install_bundled_libs displayName: Install Python dependencies + env: + PIP_INDEX_URL: $(PIP_PROXY_INDEX_URL) - script: python ./build/update_ext_version.py --for-publishing displayName: Update build number @@ -114,7 +124,7 @@ extends: buildSteps: ${{ parameters.buildSteps }} isPreRelease: true standardizedVersioning: true - customNPMRegistry: $(AZURE_ARTIFACTS_FEED) + customNPMRegistry: $(NPM_CONFIG_REGISTRY) - stage: Publish displayName: Publish Extension @@ -129,4 +139,4 @@ extends: ghCreateTag: true ghCreateRelease: true ghReleaseAddChangeLog: true - customNPMRegistry: $(AZURE_ARTIFACTS_FEED) + customNPMRegistry: $(NPM_CONFIG_REGISTRY) diff --git a/build/azure-devdiv-pipeline.stable.yml b/build/azure-devdiv-pipeline.stable.yml index 5f67dd00..cc93102d 100644 --- a/build/azure-devdiv-pipeline.stable.yml +++ b/build/azure-devdiv-pipeline.stable.yml @@ -21,8 +21,10 @@ variables: value: VSCode-python-debugger - name: VsixName value: python-debugger.vsix - - name: AZURE_ARTIFACTS_FEED - value: 'https://devdiv.pkgs.visualstudio.com/DevDiv/_packaging/Pylance_PublicPackages/npm/registry/' + - name: NPM_CONFIG_REGISTRY + value: 'https://packagefeedproxy.microsoft.io/npm/' + - name: PIP_PROXY_INDEX_URL + value: 'https://packagefeedproxy.microsoft.io/pypi/simple/' parameters: - name: publishExtension @@ -60,20 +62,28 @@ parameters: - name: buildSteps type: stepList default: - - script: npm ci + - script: npm run ci:msft displayName: Install NPM dependencies - script: python -m pip install -U pip displayName: Upgrade pip + env: + PIP_INDEX_URL: $(PIP_PROXY_INDEX_URL) - script: python -m pip install wheel displayName: Install wheel + env: + PIP_INDEX_URL: $(PIP_PROXY_INDEX_URL) - script: python -m pip install nox displayName: Install nox + env: + PIP_INDEX_URL: $(PIP_PROXY_INDEX_URL) - script: python -m nox --session install_bundled_libs displayName: Install Python dependencies + env: + PIP_INDEX_URL: $(PIP_PROXY_INDEX_URL) - script: python ./build/update_ext_version.py --release --for-publishing displayName: Update build number @@ -109,7 +119,7 @@ extends: buildSteps: ${{ parameters.buildSteps }} isPreRelease: false standardizedVersioning: true - customNPMRegistry: $(AZURE_ARTIFACTS_FEED) + customNPMRegistry: $(NPM_CONFIG_REGISTRY) - stage: WaitForValidation displayName: Wait for Validation @@ -139,4 +149,4 @@ extends: ghCreateTag: true ghCreateRelease: true ghReleaseAddChangeLog: true - customNPMRegistry: $(AZURE_ARTIFACTS_FEED) + customNPMRegistry: $(NPM_CONFIG_REGISTRY) diff --git a/build/normalize-package-lock.js b/build/normalize-package-lock.js new file mode 100644 index 00000000..7885062e --- /dev/null +++ b/build/normalize-package-lock.js @@ -0,0 +1,112 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +const { execFileSync, spawnSync } = require('node:child_process'); +const { lstatSync, readFileSync, writeFileSync } = require('node:fs'); +const path = require('node:path'); +const { URL } = require('node:url'); + +const mirrorPrefix = + /^https:\/\/(?:(?:devdiv\.pkgs\.visualstudio\.com\/DevDiv|pkgs\.dev\.azure\.com\/devdiv\/DevDiv)\/_packaging\/Pylance_PublicPackages\/npm\/registry\/|packagefeedproxy\.microsoft\.io\/npm\/|ms-feed-\d+\.pkgs\.visualstudio\.com\/1es-public\/_packaging\/npm-public\/npm\/registry\/)/i; + +function normalizePackageLock(source) { + const lockfile = JSON.parse(source); + if ( + ![2, 3].includes(lockfile.lockfileVersion) || + !lockfile.packages || + typeof lockfile.packages !== 'object' || + Array.isArray(lockfile.packages) + ) { + throw new Error('Expected an npm lockfile with lockfileVersion 2 or 3 and a packages section.'); + } + + const replacements = new Map(); + function visit(value) { + if (!value || typeof value !== 'object') { + return; + } + if (typeof value.resolved === 'string' && mirrorPrefix.test(value.resolved)) { + const publicUrl = value.resolved.replace(mirrorPrefix, 'https://registry.npmjs.org/'); + const url = new URL(publicUrl); + if (url.search || url.hash || !url.pathname.endsWith('.tgz')) { + throw new Error('Unexpected npm mirror tarball URL; refusing to rewrite it.'); + } + if (typeof value.integrity !== 'string' || !value.integrity.trim()) { + throw new Error('Cannot normalize a mirrored package without an integrity hash.'); + } + replacements.set(value.resolved, publicUrl); + } + for (const child of Object.values(value)) { + visit(child); + } + } + visit(lockfile.packages); + visit(lockfile.dependencies); + + // Preserve formatting, dependency versions, and integrity hashes. + return source.replace(/("resolved"\s*:\s*)("(?:\\.|[^"\\])*")/g, (match, prefix, encodedUrl) => { + const replacement = replacements.get(JSON.parse(encodedUrl)); + return replacement ? prefix + JSON.stringify(replacement) : match; + }); +} + +function normalizeStagedLockfile(cwd = process.cwd()) { + const git = (args) => execFileSync('git', args, { cwd, encoding: 'utf8' }); + const staged = git(['diff', '--cached', '--name-only', '--diff-filter=ACMR', '-z', '--', 'package-lock.json']); + if (!staged.split('\0').includes('package-lock.json')) { + return false; + } + const source = git(['show', ':package-lock.json']); + if (normalizePackageLock(source) === source) { + return false; + } + + const unstaged = spawnSync('git', ['diff', '--quiet', '--', 'package-lock.json'], { cwd, encoding: 'utf8' }); + if (unstaged.error) { + throw unstaged.error; + } + if (unstaged.status === 1) { + throw new Error( + 'package-lock.json has unstaged changes. Stage or stash them before committing; no changes were made.', + ); + } + if (unstaged.status !== 0) { + throw new Error(`Unable to check unstaged lockfile changes: ${unstaged.stderr}`); + } + + const file = path.join(cwd, 'package-lock.json'); + if (!lstatSync(file).isFile()) { + throw new Error('Refusing to normalize a package-lock.json that is not a regular file.'); + } + const workingSource = readFileSync(file, 'utf8'); + writeFileSync(file, normalizePackageLock(workingSource), 'utf8'); + git(['add', '--', 'package-lock.json']); + return true; +} + +if (require.main === module) { + try { + const args = process.argv.slice(2); + if (args.length === 1 && args[0] === '--staged') { + if (normalizeStagedLockfile()) { + console.log('Normalized and re-staged package-lock.json registry URLs.'); + } + } else if (args.length === 0) { + const source = readFileSync('package-lock.json', 'utf8'); + const normalized = normalizePackageLock(source); + if (normalized !== source) { + writeFileSync('package-lock.json', normalized, 'utf8'); + console.log('Normalized package-lock.json registry URLs. Stage the file before committing.'); + } else { + console.log('package-lock.json registry URLs are already normalized.'); + } + } else { + throw new Error('Usage: node build/normalize-package-lock.js [--staged]'); + } + } catch (error) { + console.error(`Lockfile normalization failed: ${error.message}`); + process.exitCode = 1; + } +} + +module.exports = { normalizePackageLock, normalizeStagedLockfile }; diff --git a/build/normalize-package-lock.test.js b/build/normalize-package-lock.test.js new file mode 100644 index 00000000..fdd504e9 --- /dev/null +++ b/build/normalize-package-lock.test.js @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +const assert = require('node:assert/strict'); +const { execFileSync, spawnSync } = require('node:child_process'); +const { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } = require('node:fs'); +const { tmpdir } = require('node:os'); +const { join } = require('node:path'); +const { test } = require('node:test'); +const { normalizePackageLock, normalizeStagedLockfile } = require('./normalize-package-lock'); +const { setupHooks } = require('./setup-hooks'); + +const mirrors = [ + 'https://devdiv.pkgs.visualstudio.com/DevDiv/_packaging/Pylance_PublicPackages/npm/registry/', + 'https://pkgs.dev.azure.com/devdiv/DevDiv/_packaging/Pylance_PublicPackages/npm/registry/', + 'https://packagefeedproxy.microsoft.io/npm/', + 'https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/', +]; +const publicRegistry = 'https://registry.npmjs.org/'; +const entry = { + version: '1.0.0', + resolved: `${mirrors[0]}@example/package/-/package-1.0.0.tgz`, + integrity: 'sha512-preserved', +}; + +function lockfile(packages = { 'node_modules/@example/package': entry }, extra = {}) { + return JSON.stringify({ name: 'python-debugger', lockfileVersion: 3, packages, ...extra }, null, 4) + '\n'; +} + +function fixture(t) { + const cwd = mkdtempSync(join(tmpdir(), 'python-debugger hook with spaces-')); + t.after(() => rmSync(cwd, { recursive: true, force: true })); + const git = (args) => execFileSync('git', args, { cwd, encoding: 'utf8' }).replace(/\r\n/g, '\n'); + git(['init', '--quiet']); + git(['config', 'user.name', 'Hook Test']); + git(['config', 'user.email', 'hook-test@example.invalid']); + git(['config', 'core.autocrlf', 'false']); + git(['config', 'commit.gpgsign', 'false']); + git(['config', '--local', 'core.hooksPath', '.githooks']); + mkdirSync(join(cwd, '.githooks')); + mkdirSync(join(cwd, 'build')); + copyFileSync(join(__dirname, '..', '.githooks', 'pre-commit'), join(cwd, '.githooks', 'pre-commit')); + copyFileSync(join(__dirname, 'normalize-package-lock.js'), join(cwd, 'build', 'normalize-package-lock.js')); + chmodSync(join(cwd, '.githooks', 'pre-commit'), 0o755); + return { cwd, git }; +} + +for (const mirror of mirrors) { + test(`normalizes ${mirror} preserving bytes outside resolved URLs`, () => { + const source = lockfile().replaceAll(mirrors[0], mirror).replace(/\n/g, '\r\n'); + const normalized = normalizePackageLock(source); + assert.equal(normalized, source.replace(mirror, publicRegistry)); + assert.equal(normalizePackageLock(normalized), normalized); + }); +} + +test('normalizes v2 nested legacy dependencies and unscoped packages', () => { + const unscoped = { ...entry, resolved: `${mirrors[2]}example/-/example-1.0.0.tgz` }; + const source = lockfile( + { 'node_modules/example': unscoped }, + { lockfileVersion: 2, dependencies: { parent: { dependencies: { example: entry } } } }, + ); + assert.equal( + normalizePackageLock(source), + source.replaceAll(mirrors[0], publicRegistry).replaceAll(mirrors[2], publicRegistry), + ); +}); + +test('preserves intentional Azure, unknown feeds, public, git and local sources', () => { + const source = lockfile({ + '': { name: 'python-debugger' }, + public: { resolved: `${publicRegistry}example/-/example-1.0.0.tgz` }, + azure: { + resolved: + 'https://pkgs.dev.azure.com/azure-public/vside/_packaging/msft_consumption/npm/registry/example.tgz', + }, + private: { resolved: `${mirrors[0].replace('Pylance_PublicPackages', 'OtherFeed')}example.tgz` }, + git: { resolved: 'git+https://github.com/example/example.git#abc' }, + file: { resolved: 'file:external/example' }, + link: { resolved: 'external/example', link: true }, + }); + assert.equal(normalizePackageLock(source), source); +}); + +test('rejects invalid lockfiles and mirror entries without integrity or with query strings', () => { + assert.throws(() => normalizePackageLock('{'), SyntaxError); + assert.throws(() => normalizePackageLock('{"lockfileVersion":1}'), /Expected an npm lockfile/); + assert.throws(() => normalizePackageLock(lockfile([])), /Expected an npm lockfile/); + assert.throws( + () => normalizePackageLock(lockfile({ example: { ...entry, integrity: undefined } })), + /without an integrity hash/, + ); + assert.throws( + () => normalizePackageLock(lockfile({ example: { ...entry, resolved: entry.resolved + '?token=example' } })), + /Unexpected npm mirror/, + ); +}); + +test('normalizes and re-stages only the lockfile for an initial commit', (t) => { + const { cwd, git } = fixture(t); + const source = lockfile(); + writeFileSync(join(cwd, 'package-lock.json'), source); + writeFileSync(join(cwd, 'unstaged.txt'), 'keep unstaged'); + git(['add', '--', 'package-lock.json']); + assert.equal(normalizeStagedLockfile(cwd), true); + assert.equal(readFileSync(join(cwd, 'package-lock.json'), 'utf8'), source.replace(mirrors[0], publicRegistry)); + assert.equal(git(['show', ':package-lock.json']), source.replace(mirrors[0], publicRegistry)); + assert.equal(git(['diff', '--cached', '--name-only']).trim(), 'package-lock.json'); + assert.equal(normalizeStagedLockfile(cwd), false); +}); + +test('does not touch an unstaged lockfile or a staged deletion', (t) => { + const { cwd, git } = fixture(t); + const source = lockfile(); + writeFileSync(join(cwd, 'package-lock.json'), source); + assert.equal(normalizeStagedLockfile(cwd), false); + git(['add', '--', 'package-lock.json']); + git(['commit', '--quiet', '-m', 'fixture']); + git(['rm', '--quiet', '--', 'package-lock.json']); + assert.equal(normalizeStagedLockfile(cwd), false); +}); + +test('refuses partial staging without modifying worktree or index', (t) => { + const { cwd, git } = fixture(t); + const source = lockfile(); + writeFileSync(join(cwd, 'package-lock.json'), source); + git(['add', '--', 'package-lock.json']); + const unstaged = source.replace('"name": "python-debugger"', '"name": "unstaged"'); + writeFileSync(join(cwd, 'package-lock.json'), unstaged); + assert.throws(() => normalizeStagedLockfile(cwd), /has unstaged changes/); + assert.equal(readFileSync(join(cwd, 'package-lock.json'), 'utf8'), unstaged); + assert.equal(git(['show', ':package-lock.json']), source); +}); + +test('installed hook normalizes actual commits and blocks partial staging', (t) => { + const { cwd, git } = fixture(t); + setupHooks(cwd); + const source = lockfile(); + writeFileSync(join(cwd, 'package-lock.json'), source); + git(['add', '--', 'package-lock.json']); + git(['commit', '--quiet', '-m', 'test normalization']); + assert.equal(git(['show', 'HEAD:package-lock.json']), source.replace(mirrors[0], publicRegistry)); + writeFileSync(join(cwd, 'package-lock.json'), source); + git(['add', '--', 'package-lock.json']); + writeFileSync(join(cwd, 'package-lock.json'), source.replace('"name": "python-debugger"', '"name": "unstaged"')); + const refused = spawnSync('git', ['commit', '--quiet', '-m', 'must refuse partial staging'], { + cwd, + encoding: 'utf8', + }); + assert.notEqual(refused.status, 0); + assert.match(refused.stderr, /has unstaged changes/); + assert.equal(git(['show', 'HEAD:package-lock.json']), source.replace(mirrors[0], publicRegistry)); +}); + +test('hook installer refuses to replace existing hook configuration or a hook file', (t) => { + const { cwd, git } = fixture(t); + git(['config', '--local', 'core.hooksPath', 'custom-hooks']); + assert.throws(() => setupHooks(cwd), /existing core.hooksPath/); + assert.equal(git(['config', '--get', 'core.hooksPath']).trim(), 'custom-hooks'); + git(['config', '--local', '--unset', 'core.hooksPath']); + const hooks = git(['rev-parse', '--path-format=absolute', '--git-path', 'hooks']).trim(); + mkdirSync(hooks, { recursive: true }); + writeFileSync(join(hooks, 'pre-commit'), '# keep existing hook\n'); + assert.throws(() => setupHooks(cwd), /existing pre-commit hook/); + assert.equal(readFileSync(join(hooks, 'pre-commit'), 'utf8'), '# keep existing hook\n'); +}); + +test('hook installer enables an unconfigured clone and can be rerun', (t) => { + const { cwd, git } = fixture(t); + git(['config', '--local', '--unset', 'core.hooksPath']); + setupHooks(cwd); + assert.equal(git(['config', '--local', '--get', 'core.hooksPath']).trim(), '.githooks'); + setupHooks(cwd); + assert.equal(git(['config', '--local', '--get', 'core.hooksPath']).trim(), '.githooks'); +}); diff --git a/build/package-feeds.json b/build/package-feeds.json new file mode 100644 index 00000000..c32a71a0 --- /dev/null +++ b/build/package-feeds.json @@ -0,0 +1,10 @@ +{ + "public": { + "npm": "https://registry.npmjs.org/", + "pip": "https://pypi.org/simple/" + }, + "microsoft": { + "npm": "https://packagefeedproxy.microsoft.io/npm/", + "pip": "https://packagefeedproxy.microsoft.io/pypi/simple/" + } +} diff --git a/build/setup-hooks.js b/build/setup-hooks.js new file mode 100644 index 00000000..cf9a41bd --- /dev/null +++ b/build/setup-hooks.js @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +const { execFileSync, spawnSync } = require('node:child_process'); +const { chmodSync, existsSync } = require('node:fs'); +const path = require('node:path'); + +function setupHooks(cwd = path.resolve(__dirname, '..')) { + const git = (args) => execFileSync('git', args, { cwd, encoding: 'utf8' }).trim(); + const existing = spawnSync('git', ['config', '--get', 'core.hooksPath'], { cwd, encoding: 'utf8' }); + if (existing.error) { + throw existing.error; + } + if (![0, 1].includes(existing.status)) { + throw new Error(`Cannot inspect Git hooks configuration: ${existing.stderr}`); + } + if (existing.status === 0 && existing.stdout.trim() !== '.githooks') { + throw new Error('An existing core.hooksPath is configured. Integrate the normalizer into your hook manually.'); + } + if ( + existing.status === 1 && + existsSync(git(['rev-parse', '--path-format=absolute', '--git-path', 'hooks/pre-commit'])) + ) { + throw new Error('An existing pre-commit hook is installed. Integrate the normalizer into your hook manually.'); + } + chmodSync(path.join(cwd, '.githooks', 'pre-commit'), 0o755); + git(['config', '--local', 'core.hooksPath', '.githooks']); + console.log('Enabled the local lockfile normalization hook (applies to all worktrees of this clone).'); +} + +if (require.main === module) { + try { + setupHooks(); + } catch (error) { + console.error(`Hook setup failed: ${error.message}`); + process.exitCode = 1; + } +} + +module.exports = { setupHooks }; diff --git a/build/setup-msft.js b/build/setup-msft.js new file mode 100644 index 00000000..17c4e26c --- /dev/null +++ b/build/setup-msft.js @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +require('./setup').runCli({ microsoft: true }); diff --git a/build/setup-npm-registry.js b/build/setup-npm-registry.js new file mode 100644 index 00000000..8f084651 --- /dev/null +++ b/build/setup-npm-registry.js @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +const { spawnSync } = require('node:child_process'); +const { readFileSync, writeFileSync } = require('node:fs'); +const path = require('node:path'); +const { microsoft } = require('./package-feeds.json'); + +function rewritePackageLock(source) { + const lockfile = JSON.parse(source); + if ( + ![2, 3].includes(lockfile.lockfileVersion) || + !lockfile.packages || + typeof lockfile.packages !== 'object' || + Array.isArray(lockfile.packages) + ) { + throw new Error('Expected an npm lockfile with lockfileVersion 2 or 3 and a packages section.'); + } + + return source.replace(/("resolved"\s*:\s*)("(?:\\.|[^"\\])*")/g, (match, prefix, encodedUrl) => { + const url = JSON.parse(encodedUrl); + const rewritten = url.replace(/^https:\/\/registry\.(?:npmjs\.org|yarnpkg\.com)\//, microsoft.npm); + return rewritten === url ? match : prefix + JSON.stringify(rewritten); + }); +} + +function rewritePackageLocks(root = path.resolve(__dirname, '..')) { + const files = [path.join(root, 'package-lock.json')]; + const updates = files.map((file) => { + const source = readFileSync(file, 'utf8'); + return { file, source, rewritten: rewritePackageLock(source) }; + }); + for (const { file, source, rewritten } of updates) { + if (rewritten !== source) { + writeFileSync(file, rewritten, 'utf8'); + console.log(`[setup] Rewrote npm registry URLs: ${file}`); + } + } +} + +if (require.main === module) { + try { + const args = process.argv.slice(2); + const install = args.length === 1 && args[0] === '--ci'; + if (args.length && !install) { + throw new Error('Usage: node build/setup-npm-registry.js [--ci]'); + } + if (install && !process.env.npm_execpath) { + throw new Error('Run npm install using npm run ci:msft.'); + } + rewritePackageLocks(); + if (install) { + const result = spawnSync( + process.execPath, + [process.env.npm_execpath, 'ci', `--registry=${microsoft.npm}`], + { cwd: path.resolve(__dirname, '..'), stdio: 'inherit' }, + ); + if (result.error || result.signal || result.status !== 0) { + const reason = result.error?.message || result.signal || `exit code ${result.status}`; + const error = new Error(`npm ci failed: ${reason}`); + error.exitCode = result.status || 1; + throw error; + } + } + } catch (error) { + console.error(`[setup] Npm registry setup failed: ${error.message}`); + process.exitCode = error.exitCode || 1; + } +} + +module.exports = { rewritePackageLock, rewritePackageLocks }; diff --git a/build/setup-npm-registry.test.js b/build/setup-npm-registry.test.js new file mode 100644 index 00000000..3d7eae5c --- /dev/null +++ b/build/setup-npm-registry.test.js @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +const assert = require('node:assert/strict'); +const { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } = require('node:fs'); +const { tmpdir } = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); +const vm = require('node:vm'); +const { rewritePackageLock, rewritePackageLocks } = require('./setup-npm-registry'); +const { normalizePackageLock } = require('./normalize-package-lock'); + +const publicRegistry = 'https://registry.npmjs.org/'; +const proxyRegistry = 'https://packagefeedproxy.microsoft.io/npm/'; +const source = `${JSON.stringify( + { + lockfileVersion: 3, + packages: { + '': { name: 'python-debugger' }, + 'node_modules/example': { + version: '1.2.3', + resolved: `${publicRegistry}example/-/example-1.2.3.tgz`, + integrity: 'sha512-example', + }, + 'node_modules/@scope/example': { + resolved: 'https://registry.yarnpkg.com/@scope/example/-/example-1.0.0.tgz', + integrity: 'sha512-scoped', + }, + }, + }, + null, + 4, +)}\n`; + +test('rewrites public resolved URLs without changing versions, integrity or formatting', () => { + const expected = source + .replaceAll(publicRegistry, proxyRegistry) + .replace('https://registry.yarnpkg.com/', proxyRegistry); + assert.equal(rewritePackageLock(source), expected); + assert.equal(rewritePackageLock(expected), expected); + assert.equal(normalizePackageLock(expected), source.replace('https://registry.yarnpkg.com/', publicRegistry)); +}); + +test('supports version 2 legacy dependencies as well as packages', () => { + const legacy = JSON.stringify({ + lockfileVersion: 2, + packages: {}, + dependencies: { example: { resolved: `${publicRegistry}example/-/example.tgz` } }, + }); + assert.equal(rewritePackageLock(legacy), legacy.replace(publicRegistry, proxyRegistry)); +}); + +test('preserves other fields, private feeds, Git dependencies and lookalike hosts', () => { + const unchanged = JSON.stringify({ + lockfileVersion: 3, + packages: { + '': { description: `${publicRegistry}example` }, + private: { + resolved: 'https://pkgs.dev.azure.com/azure-public/vside/_packaging/msft_consumption/example.tgz', + }, + git: { resolved: 'git+https://github.com/example/example.git#123' }, + lookalike: { resolved: 'https://registry.npmjs.org.example.com/example.tgz' }, + local: { resolved: 'file:../example' }, + }, + }); + assert.equal(rewritePackageLock(unchanged), unchanged); +}); + +test('rejects malformed or unsupported lockfiles', () => { + for (const invalid of ['{', '{}', '{"lockfileVersion":1}', '{"lockfileVersion":3,"packages":[]}']) { + assert.throws(() => rewritePackageLock(invalid)); + } +}); + +test('rewrites only the root lockfile without requiring a submodule', (t) => { + const root = mkdtempSync(path.join(tmpdir(), 'python-debugger-registry-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const shared = path.join(root, 'external', 'vscode-common-python-lsp', 'typescript'); + mkdirSync(shared, { recursive: true }); + const files = [path.join(root, 'package-lock.json')]; + writeFileSync(path.join(shared, 'package-lock.json'), source); + for (const file of files) { + writeFileSync(file, source); + } + const npmrc = path.join(root, '.npmrc'); + writeFileSync(npmrc, 'registry=https://registry.npmjs.org/\n'); + rewritePackageLocks(root); + for (const file of files) { + assert.equal(readFileSync(file, 'utf8'), rewritePackageLock(source)); + } + assert.equal(readFileSync(npmrc, 'utf8'), 'registry=https://registry.npmjs.org/\n'); + assert.equal(readFileSync(path.join(shared, 'package-lock.json'), 'utf8'), source); +}); + +test('missing or invalid root lockfile fails explicitly', (t) => { + const root = mkdtempSync(path.join(tmpdir(), 'registry-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + assert.throws(() => rewritePackageLocks(root), /ENOENT/); + writeFileSync(path.join(root, 'package-lock.json'), '{}'); + assert.throws(() => rewritePackageLocks(root), /Expected an npm lockfile/); +}); + +function runCli({ args = ['--ci'], npmCli = 'npm-cli.js', result = { status: 0 } } = {}) { + const calls = []; + const errors = []; + const process = { + argv: ['node', 'setup-npm-registry.js', ...args], + env: { npm_execpath: npmCli }, + execPath: 'node', + }; + const module = { exports: {} }; + const mockedRequire = (name) => { + if (name === 'node:fs') { + return { + readFileSync: () => source, + writeFileSync: (file) => calls.push({ file }), + }; + } + if (name === 'node:child_process') { + return { + spawnSync(command, args, options) { + calls.push({ command, args: Array.from(args), ...options }); + return result; + }, + }; + } + return require(name); + }; + mockedRequire.main = module; + vm.runInNewContext(readFileSync(path.join(__dirname, 'setup-npm-registry.js'), 'utf8'), { + require: mockedRequire, + module, + process, + __dirname, + console: { log() {}, error: (message) => errors.push(message) }, + }); + return { calls, errors, process }; +} + +test('ci:msft rewrites the root lockfile before installing with the shared proxy registry', () => { + const context = runCli(); + assert.equal(context.calls.length, 2); + assert.ok(context.calls[0].file.endsWith('package-lock.json')); + assert.equal(context.calls[1].command, 'node'); + assert.deepEqual(context.calls[1].args, ['npm-cli.js', 'ci', `--registry=${proxyRegistry}`]); + assert.equal(context.calls[1].cwd, path.resolve(__dirname, '..')); + assert.equal(context.calls[1].stdio, 'inherit'); + assert.equal(context.errors.length, 0); + const scripts = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).scripts; + assert.equal(scripts['ci:msft'], 'node ./build/setup-npm-registry.js --ci'); +}); + +for (const result of [{ status: 7 }, { status: null, signal: 'SIGTERM' }, { error: new Error('not found') }]) { + test(`ci:msft reports npm failure: ${result.signal || result.error?.message || result.status}`, () => { + const context = runCli({ result }); + assert.equal(context.process.exitCode, result.status || 1); + assert.match(context.errors[0], /npm ci failed/); + }); +} + +test('registry CLI rejects unsupported arguments or missing npm before rewriting files', () => { + for (const options of [{ args: ['--unknown'] }, { args: ['--ci', '--unknown'] }, { npmCli: '' }]) { + const context = runCli(options); + assert.equal(context.process.exitCode, 1); + assert.equal(context.calls.length, 0); + assert.equal(context.errors.length, 1); + } +}); + +test('standalone registry rewrite does not install dependencies or require npm', () => { + const context = runCli({ args: [], npmCli: '' }); + assert.equal(context.calls.length, 1); + assert.ok(context.calls.every((call) => call.file)); + assert.equal(context.errors.length, 0); +}); diff --git a/build/setup.js b/build/setup.js new file mode 100644 index 00000000..e36afec1 --- /dev/null +++ b/build/setup.js @@ -0,0 +1,72 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +const { spawnSync } = require('node:child_process'); +const { existsSync } = require('node:fs'); +const path = require('node:path'); +const feeds = require('./package-feeds.json'); + +function setup({ microsoft = false } = {}) { + const npmCli = process.env.npm_execpath; + if (!npmCli) { + throw new Error('Run setup using npm run setup or npm run setup:msft.'); + } + + const root = path.resolve(__dirname, '..'); + const env = { ...process.env }; + for (const key of Object.keys(env)) { + if (['npm_config_registry', 'pip_index_url'].includes(key.toLowerCase())) { + delete env[key]; + } + } + const feed = microsoft ? feeds.microsoft : feeds.public; + env.npm_config_registry = feed.npm; + env.PIP_INDEX_URL = feed.pip; + + function run(command, args, cwd = root) { + console.log(`[setup] ${command} ${args.join(' ')}`); + const result = spawnSync(command, args, { cwd, env, stdio: 'inherit' }); + if (result.error || result.signal || result.status !== 0) { + const reason = result.error?.message || result.signal || `exit code ${result.status}`; + const error = new Error(`Setup failed running ${command}: ${reason}`); + error.exitCode = result.status || 1; + throw error; + } + } + + if (microsoft) { + run(process.execPath, [path.join(root, 'build', 'setup-npm-registry.js')]); + } + + const python = path.join(root, '.venv', process.platform === 'win32' ? 'Scripts\\python.exe' : 'bin/python'); + if (!existsSync(python)) { + run(process.platform === 'win32' ? 'python' : 'python3', ['-m', 'venv', '.venv']); + } + run(python, [ + '-c', + 'import sys; sys.exit("Setup requires Python 3.10 or later.") if sys.version_info < (3, 10) else None', + ]); + run(python, ['-m', 'pip', 'install', '--upgrade', 'pip', 'wheel', 'nox']); + + run(process.execPath, [npmCli, 'ci', '--include=dev']); + run(python, ['-m', 'nox', '--session', 'install_bundled_libs']); + console.log('[setup] Complete. Python tools are installed in .venv; run npm run compile to build the extension.'); +} + +function runCli(options) { + try { + if (process.argv.length > 2) { + throw new Error('Setup takes no arguments. Use npm run setup or npm run setup:msft.'); + } + setup(options); + } catch (error) { + console.error(`[setup] ${error.message}`); + process.exitCode = error.exitCode || 1; + } +} + +if (require.main === module) { + runCli(); +} + +module.exports = { setup, runCli }; diff --git a/build/setup.test.js b/build/setup.test.js new file mode 100644 index 00000000..e75ac2f6 --- /dev/null +++ b/build/setup.test.js @@ -0,0 +1,195 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +const assert = require('node:assert/strict'); +const { readFileSync } = require('node:fs'); +const path = require('node:path'); +const { test } = require('node:test'); +const vm = require('node:vm'); + +const submodule = false; +const shared = false; +const source = readFileSync(path.join(__dirname, 'setup.js'), 'utf8'); + +function load({ platform = 'win32', existingVenv = false, failAt, failure, args = [], cli = false } = {}) { + const paths = platform === 'win32' ? path.win32 : path.posix; + const root = platform === 'win32' ? 'C:\\workspace with spaces\\extension' : '/workspace with spaces/extension'; + const calls = []; + const errors = []; + const env = { + npm_execpath: paths.join(root, 'npm', 'npm-cli.js'), + npm_config_registry: 'https://old.example/npm/', + NPM_CONFIG_REGISTRY: 'https://other.example/npm/', + PIP_INDEX_URL: 'https://old.example/pypi/', + HTTPS_PROXY: 'https://network.example/', + }; + const originalEnv = { ...env }; + const process = { env, platform, execPath: paths.join(root, 'node'), argv: ['node', 'setup.js', ...args] }; + const module = { exports: {} }; + const require = (name) => { + if (name === 'node:path') { + return paths; + } + if (name === 'node:fs') { + return { existsSync: () => existingVenv }; + } + if (name === './package-feeds.json') { + return JSON.parse(readFileSync(path.join(__dirname, 'package-feeds.json'), 'utf8')); + } + assert.equal(name, 'node:child_process'); + return { + spawnSync(command, args, options) { + calls.push({ command, args: Array.from(args), ...options }); + return calls.length === failAt ? failure : { status: 0 }; + }, + }; + }; + require.main = cli ? module : undefined; + vm.runInNewContext(source, { + require, + module, + process, + __dirname: paths.join(root, 'build'), + console: { log() {}, error: (message) => errors.push(message) }, + }); + return { ...module.exports, calls, errors, process, originalEnv, root, paths }; +} + +for (const platform of ['win32', 'linux']) { + for (const microsoft of [false, true]) { + test(`${platform}: ${microsoft ? 'Microsoft' : 'public'} setup orders bootstrap, installs and bundling`, () => { + const context = load({ platform }); + context.setup({ microsoft }); + const { calls, root, paths } = context; + const python = paths.join(root, '.venv', platform === 'win32' ? 'Scripts\\python.exe' : 'bin/python'); + const npmCli = context.process.env.npm_execpath; + const expected = []; + if (submodule) { + expected.push(['git', ['submodule', 'update', '--init', '--recursive'], root]); + } + if (microsoft) { + expected.push([context.process.execPath, [paths.join(root, 'build', 'setup-npm-registry.js')], root]); + } + expected.push([platform === 'win32' ? 'python' : 'python3', ['-m', 'venv', '.venv'], root]); + const versionCheck = calls[expected.length]; + assert.equal(versionCheck.command, python); + assert.match(versionCheck.args[1], /sys.version_info < \(3, 10\)/); + expected.push([python, versionCheck.args, root]); + expected.push([python, ['-m', 'pip', 'install', '--upgrade', 'pip', 'wheel', 'nox'], root]); + if (shared) { + const dir = paths.join(root, 'external', 'vscode-common-python-lsp', 'typescript'); + expected.push([context.process.execPath, [npmCli, 'ci', '--include=dev'], dir]); + expected.push([context.process.execPath, [npmCli, 'run', 'build'], dir]); + } + expected.push([context.process.execPath, [npmCli, 'ci', '--include=dev'], root]); + expected.push([python, ['-m', 'nox', '--session', 'install_bundled_libs'], root]); + assert.deepEqual( + calls.map((call) => [call.command, call.args, call.cwd]), + expected, + ); + for (const call of calls) { + assert.equal( + call.env.npm_config_registry, + microsoft ? 'https://packagefeedproxy.microsoft.io/npm/' : 'https://registry.npmjs.org/', + ); + assert.equal( + call.env.PIP_INDEX_URL, + microsoft ? 'https://packagefeedproxy.microsoft.io/pypi/simple/' : 'https://pypi.org/simple/', + ); + assert.equal(call.env.NPM_CONFIG_REGISTRY, undefined); + assert.equal(call.env.HTTPS_PROXY, context.originalEnv.HTTPS_PROXY); + assert.equal(call.stdio, 'inherit'); + assert.equal(call.shell, undefined); + } + assert.deepEqual(context.process.env, context.originalEnv); + }); + } +} + +test('setup reuses an existing project virtual environment', () => { + const context = load({ existingVenv: true }); + context.setup(); + assert.equal( + context.calls.some((call) => call.args.includes('venv')), + false, + ); + assert.equal(context.calls.length, 4 + Number(submodule) + 2 * Number(shared)); +}); + +test('Microsoft setup stops before installing if rewriting fails', () => { + const failAt = Number(submodule) + 1; + const context = load({ failAt, failure: { status: 1 } }); + context.runCli({ microsoft: true }); + assert.equal(context.calls.length, failAt); + assert.equal(context.process.exitCode, 1); + assert.match(context.errors[0], /Setup failed/); +}); + +for (const failure of [{ status: 7 }, { status: null, signal: 'SIGTERM' }, { error: new Error('not found') }]) { + test(`setup reports failure and stops: ${failure.signal || failure.error?.message || failure.status}`, () => { + const context = load({ failAt: 3, failure }); + context.runCli(); + assert.equal(context.calls.length, 3); + assert.equal(context.process.exitCode, failure.status || 1); + assert.match(context.errors[0], /Setup failed/); + }); +} + +test('setup requires npm to supply its CLI path', () => { + const context = load(); + delete context.process.env.npm_execpath; + context.runCli(); + assert.equal(context.calls.length, 0); + assert.equal(context.process.exitCode, 1); + assert.match(context.errors[0], /npm run setup/); +}); + +test('public setup runs directly as a standalone entry point', () => { + const context = load({ cli: true }); + assert.equal(context.calls.length, 5 + Number(submodule) + 2 * Number(shared)); + assert.equal(context.calls[0].env.npm_config_registry, 'https://registry.npmjs.org/'); + assert.equal(context.errors.length, 0); +}); + +test('setup rejects unsupported arguments before running commands', () => { + const context = load({ args: ['--proxy'] }); + context.runCli(); + assert.equal(context.calls.length, 0); + assert.equal(context.process.exitCode, 1); + assert.match(context.errors[0], /takes no arguments/); +}); + +test('Microsoft entry point selects the shared Microsoft setup implementation', () => { + let called = false; + vm.runInNewContext(readFileSync(path.join(__dirname, 'setup-msft.js'), 'utf8'), { + require(name) { + assert.equal(name, './setup'); + return { + runCli(options) { + assert.equal(options.microsoft, true); + called = true; + }, + }; + }, + }); + assert.equal(called, true); + const scripts = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8')).scripts; + assert.equal(scripts.setup, 'node ./build/setup.js'); + assert.equal(scripts['setup:msft'], 'node ./build/setup-msft.js'); +}); + +test('PR and push installs use the npm helper without a redundant registry environment setting', () => { + const root = path.join(__dirname, '..'); + for (const file of ['pr-check.yml', 'push-check.yml']) { + const workflow = readFileSync(path.join(root, '.github', 'workflows', file), 'utf8'); + assert.doesNotMatch(workflow, /^\s*NPM_CONFIG_REGISTRY:/m); + assert.match(workflow, /^\s*PIP_INDEX_URL: https:\/\/packagefeedproxy\.microsoft\.io\/pypi\/simple\//m); + assert.doesNotMatch(workflow, /^\s*run: npm ci\b/m); + } + for (const action of ['lint', 'build-vsix']) { + const workflow = readFileSync(path.join(root, '.github', 'actions', action, 'action.yml'), 'utf8'); + assert.match(workflow, /^\s*run: npm run ci:msft\s*$/m); + } + const codeql = readFileSync(path.join(root, '.github', 'workflows', 'codeql-analysis.yml'), 'utf8'); + assert.match(codeql, /^\s*NPM_CONFIG_REGISTRY: https:\/\/packagefeedproxy\.microsoft\.io\/npm\//m); +}); diff --git a/package.json b/package.json index 7339861e..2f44247f 100644 --- a/package.json +++ b/package.json @@ -701,6 +701,13 @@ "ms-python.python" ], "scripts": { + "setup": "node ./build/setup.js", + "setup:msft": "node ./build/setup-msft.js", + "setup:msft-registry": "node ./build/setup-npm-registry.js", + "ci:msft": "node ./build/setup-npm-registry.js --ci", + "setup-hooks": "node ./build/setup-hooks.js", + "normalize-lockfile": "node ./build/normalize-package-lock.js", + "test:setup": "node --test ./build/setup.test.js ./build/setup-npm-registry.test.js ./build/normalize-package-lock.test.js", "vscode:prepublish": "npm run package", "compile": "webpack", "watch": "webpack --watch", @@ -708,9 +715,9 @@ "compile-tests": "tsc -p . --outDir out", "watch-tests": "tsc -p . -w --outDir out", "pretest": "npm run compile-tests && npm run compile && npm run lint", - "lint": "eslint src --ext ts", - "format-check": "prettier --check \"src/**/*.ts\" \"build/**/*.yml\" \".github/**/*.yml\"", - "format-fix": "prettier --write \"src/**/*.ts\" \"build/**/*.yml\" \".github/**/*.yml\"", + "lint": "eslint src --ext ts \"build/setup*.js\" \"build/normalize-package-lock*.js\"", + "format-check": "prettier --check \"src/**/*.ts\" \"build/**/*.yml\" \".github/**/*.yml\" \"build/setup*.js\" \"build/normalize-package-lock*.js\" build/package-feeds.json", + "format-fix": "prettier --write \"src/**/*.ts\" \"build/**/*.yml\" \".github/**/*.yml\" \"build/setup*.js\" \"build/normalize-package-lock*.js\" build/package-feeds.json", "test": "node ./out/test/runTest.js", "vsce-package": "npx @vscode/vsce package -o python-debugger.vsix" },