Add vscode-extras extension with npm up-to-date feature and related configurations (#298295)

This commit is contained in:
Henning Dieterichs
2026-02-27 18:16:26 +01:00
committed by GitHub
parent 98ad6b67c2
commit 5b7dafcb12
12 changed files with 550 additions and 67 deletions

16
.vscode/extensions/vscode-extras/package-lock.json generated vendored Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "vscode-extras",
"version": "0.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "vscode-extras",
"version": "0.0.1",
"license": "MIT",
"engines": {
"vscode": "^1.88.0"
}
}
}
}

View File

@@ -0,0 +1,38 @@
{
"name": "vscode-extras",
"displayName": "VS Code Extras",
"description": "Extra utility features for the VS Code selfhost workspace",
"engines": {
"vscode": "^1.88.0"
},
"version": "0.0.1",
"publisher": "ms-vscode",
"categories": [
"Other"
],
"activationEvents": [
"workspaceContains:src/vscode-dts/vscode.d.ts"
],
"main": "./out/extension.js",
"repository": {
"type": "git",
"url": "https://github.com/microsoft/vscode.git"
},
"license": "MIT",
"scripts": {
"compile": "gulp compile-extension:vscode-extras",
"watch": "gulp watch-extension:vscode-extras"
},
"contributes": {
"configuration": {
"title": "VS Code Extras",
"properties": {
"vscode-extras.npmUpToDateFeature.enabled": {
"type": "boolean",
"default": true,
"description": "Show a status bar warning when npm dependencies are out of date."
}
}
}
}
}

View File

@@ -0,0 +1,51 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { NpmUpToDateFeature } from './npmUpToDateFeature';
export class Extension extends vscode.Disposable {
private readonly _output: vscode.LogOutputChannel;
private _npmFeature: NpmUpToDateFeature | undefined;
constructor(context: vscode.ExtensionContext) {
const disposables: vscode.Disposable[] = [];
super(() => disposables.forEach(d => d.dispose()));
this._output = vscode.window.createOutputChannel('VS Code Extras', { log: true });
disposables.push(this._output);
this._updateNpmFeature();
disposables.push(
vscode.workspace.onDidChangeConfiguration(e => {
if (e.affectsConfiguration('vscode-extras.npmUpToDateFeature.enabled')) {
this._updateNpmFeature();
}
})
);
}
private _updateNpmFeature(): void {
const enabled = vscode.workspace.getConfiguration('vscode-extras').get<boolean>('npmUpToDateFeature.enabled', true);
if (enabled && !this._npmFeature) {
this._npmFeature = new NpmUpToDateFeature(this._output);
} else if (!enabled && this._npmFeature) {
this._npmFeature.dispose();
this._npmFeature = undefined;
}
}
}
let extension: Extension | undefined;
export function activate(context: vscode.ExtensionContext) {
extension = new Extension(context);
context.subscriptions.push(extension);
}
export function deactivate() {
extension = undefined;
}

View File

@@ -0,0 +1,176 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as cp from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as vscode from 'vscode';
interface FileHashes {
readonly [relativePath: string]: string;
}
interface PostinstallState {
readonly nodeVersion: string;
readonly fileHashes: FileHashes;
}
interface InstallState {
readonly root: string;
readonly current: PostinstallState;
readonly saved: PostinstallState | undefined;
readonly files: readonly string[];
}
export class NpmUpToDateFeature extends vscode.Disposable {
private readonly _statusBarItem: vscode.StatusBarItem;
private readonly _disposables: vscode.Disposable[] = [];
private _watchers: fs.FSWatcher[] = [];
private _terminal: vscode.Terminal | undefined;
constructor(private readonly _output: vscode.LogOutputChannel) {
const disposables: vscode.Disposable[] = [];
super(() => {
disposables.forEach(d => d.dispose());
for (const w of this._watchers) {
w.close();
}
});
this._disposables = disposables;
this._statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 10000);
this._statusBarItem.name = 'npm Install State';
this._statusBarItem.text = '$(warning) node_modules is stale - run npm i';
this._statusBarItem.tooltip = 'Dependencies are out of date. Click to run npm install.';
this._statusBarItem.command = 'vscode-extras.runNpmInstall';
this._statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground');
this._disposables.push(this._statusBarItem);
this._disposables.push(
vscode.commands.registerCommand('vscode-extras.runNpmInstall', () => this._runNpmInstall())
);
this._disposables.push(
vscode.window.onDidCloseTerminal(t => {
if (t === this._terminal) {
this._terminal = undefined;
this._check();
}
})
);
this._check();
}
private _runNpmInstall(): void {
if (this._terminal) {
this._terminal.show();
return;
}
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri;
if (!workspaceRoot) {
return;
}
this._terminal = vscode.window.createTerminal({ name: 'npm install', cwd: workspaceRoot });
this._terminal.sendText('node build/npm/fast-install.ts --force');
this._terminal.show();
this._statusBarItem.text = '$(loading~spin) npm i';
this._statusBarItem.tooltip = 'npm install is running...';
this._statusBarItem.backgroundColor = undefined;
this._statusBarItem.command = 'vscode-extras.runNpmInstall';
}
private _queryState(): InstallState | undefined {
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!workspaceRoot) {
return undefined;
}
try {
const script = path.join(workspaceRoot, 'build', 'npm', 'installStateHash.ts');
const output = cp.execFileSync(process.execPath, [script], {
cwd: workspaceRoot,
timeout: 10_000,
encoding: 'utf8',
});
const parsed = JSON.parse(output.trim());
this._output.trace('raw output:', output.trim());
return parsed;
} catch (e) {
this._output.error('_queryState error:', e as any);
return undefined;
}
}
private _check(): void {
const state = this._queryState();
this._output.trace('state:', JSON.stringify(state, null, 2));
if (!state) {
this._output.trace('no state, hiding');
this._statusBarItem.hide();
return;
}
this._setupWatcher(state);
const changedFiles = this._getChangedFiles(state);
this._output.trace('changedFiles:', JSON.stringify(changedFiles));
if (changedFiles.length === 0) {
this._statusBarItem.hide();
} else {
this._statusBarItem.text = '$(warning) node_modules is stale - run npm i';
const tooltip = new vscode.MarkdownString();
tooltip.appendText('Dependencies are out of date. Click to run npm install.\n\nChanged files:\n');
for (const file of changedFiles) {
tooltip.appendText(`${file}\n`);
}
this._statusBarItem.tooltip = tooltip;
this._statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground');
this._statusBarItem.show();
}
}
private _getChangedFiles(state: InstallState): string[] {
if (!state.saved) {
return ['(no postinstall state found)'];
}
const changed: string[] = [];
if (state.saved.nodeVersion !== state.current.nodeVersion) {
changed.push(`Node.js version (${state.saved.nodeVersion}${state.current.nodeVersion})`);
}
const allKeys = new Set([...Object.keys(state.current.fileHashes), ...Object.keys(state.saved.fileHashes)]);
for (const key of allKeys) {
if (state.current.fileHashes[key] !== state.saved.fileHashes[key]) {
changed.push(key);
}
}
return changed;
}
private _setupWatcher(state: InstallState): void {
for (const w of this._watchers) {
w.close();
}
this._watchers = [];
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
const scheduleCheck = () => {
if (debounceTimer) {
clearTimeout(debounceTimer);
}
debounceTimer = setTimeout(() => this._check(), 500);
};
for (const file of state.files) {
try {
const watcher = fs.watch(file, scheduleCheck);
this._watchers.push(watcher);
} catch {
// file may not exist yet
}
}
}
}

View File

@@ -0,0 +1,14 @@
{
"extends": "../../../extensions/tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./out",
"types": [
"node"
]
},
"include": [
"src/**/*",
"../../../src/vscode-dts/vscode.d.ts"
]
}

View File

@@ -6,6 +6,9 @@
"testObserver",
"testRelatedCode"
],
"extensionDependencies": [
"ms-vscode.vscode-extras"
],
"engines": {
"vscode": "^1.88.0"
},

View File

@@ -95,6 +95,7 @@ const compilations = [
'.vscode/extensions/vscode-selfhost-test-provider/tsconfig.json',
'.vscode/extensions/vscode-selfhost-import-aid/tsconfig.json',
'.vscode/extensions/vscode-extras/tsconfig.json',
];
const getBaseUrl = (out: string) => `https://main.vscode-cdn.net/sourcemaps/${commit}/${out}`;

View File

@@ -60,6 +60,7 @@ export const dirs = [
'test/mcp',
'.vscode/extensions/vscode-selfhost-import-aid',
'.vscode/extensions/vscode-selfhost-test-provider',
'.vscode/extensions/vscode-extras',
];
if (existsSync(`${import.meta.dirname}/../../.build/distro/npm`)) {

22
build/npm/fast-install.ts Normal file
View File

@@ -0,0 +1,22 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as child_process from 'child_process';
import { root, isUpToDate, forceInstallMessage } from './installStateHash.ts';
if (!process.argv.includes('--force') && isUpToDate()) {
console.log(`\x1b[32mAll dependencies up to date.\x1b[0m ${forceInstallMessage}`);
process.exit(0);
}
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const result = child_process.spawnSync(npm, ['install'], {
cwd: root,
stdio: 'inherit',
shell: true,
env: { ...process.env, VSCODE_FORCE_INSTALL: '1' },
});
process.exit(result.status ?? 1);

View File

@@ -0,0 +1,78 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as crypto from 'crypto';
import * as fs from 'fs';
import path from 'path';
import { dirs } from './dirs.ts';
export const root = fs.realpathSync.native(path.dirname(path.dirname(import.meta.dirname)));
export const stateFile = path.join(root, 'node_modules', '.postinstall-state');
export const forceInstallMessage = 'Run \x1b[36mnode build/npm/fast-install.ts --force\x1b[0m to force a full install.';
export function collectInputFiles(): string[] {
const files: string[] = [];
for (const dir of dirs) {
const base = dir === '' ? root : path.join(root, dir);
for (const file of ['package.json', '.npmrc']) {
const filePath = path.join(base, file);
if (fs.existsSync(filePath)) {
files.push(filePath);
}
}
}
files.push(path.join(root, '.nvmrc'));
return files;
}
export interface PostinstallState {
readonly nodeVersion: string;
readonly fileHashes: Record<string, string>;
}
function hashFileContent(filePath: string): string {
const hash = crypto.createHash('sha256');
hash.update(fs.readFileSync(filePath));
return hash.digest('hex');
}
export function computeState(): PostinstallState {
const fileHashes: Record<string, string> = {};
for (const filePath of collectInputFiles()) {
fileHashes[path.relative(root, filePath)] = hashFileContent(filePath);
}
return { nodeVersion: process.versions.node, fileHashes };
}
export function readSavedState(): PostinstallState | undefined {
try {
return JSON.parse(fs.readFileSync(stateFile, 'utf8'));
} catch {
return undefined;
}
}
export function isUpToDate(): boolean {
const saved = readSavedState();
if (!saved) {
return false;
}
const current = computeState();
return saved.nodeVersion === current.nodeVersion
&& JSON.stringify(saved.fileHashes) === JSON.stringify(current.fileHashes);
}
// When run directly, output state as JSON for tooling (e.g. the vscode-extras extension).
if (import.meta.filename === process.argv[1]) {
console.log(JSON.stringify({
root,
current: computeState(),
saved: readSavedState(),
files: [...collectInputFiles(), stateFile],
}));
}

View File

@@ -8,9 +8,9 @@ import path from 'path';
import * as os from 'os';
import * as child_process from 'child_process';
import { dirs } from './dirs.ts';
import { root, stateFile, computeState, isUpToDate } from './installStateHash.ts';
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const root = path.dirname(path.dirname(import.meta.dirname));
const rootNpmrcConfigKeys = getNpmrcConfigKeys(path.join(root, '.npmrc'));
function log(dir: string, message: string) {
@@ -35,24 +35,45 @@ function run(command: string, args: string[], opts: child_process.SpawnSyncOptio
}
}
function npmInstall(dir: string, opts?: child_process.SpawnSyncOptions) {
opts = {
function spawnAsync(command: string, args: string[], opts: child_process.SpawnOptions): Promise<string> {
return new Promise((resolve, reject) => {
const child = child_process.spawn(command, args, { ...opts, stdio: ['ignore', 'pipe', 'pipe'] });
let output = '';
child.stdout?.on('data', (data: Buffer) => { output += data.toString(); });
child.stderr?.on('data', (data: Buffer) => { output += data.toString(); });
child.on('error', reject);
child.on('close', (code) => {
if (code !== 0) {
reject(new Error(`Process exited with code: ${code}\n${output}`));
} else {
resolve(output);
}
});
});
}
async function npmInstallAsync(dir: string, opts?: child_process.SpawnOptions): Promise<void> {
const finalOpts: child_process.SpawnOptions = {
env: { ...process.env },
...(opts ?? {}),
cwd: dir,
stdio: 'inherit',
shell: true
cwd: path.join(root, dir),
shell: true,
};
const command = process.env['npm_command'] || 'install';
if (process.env['VSCODE_REMOTE_DEPENDENCIES_CONTAINER_NAME'] && /^(.build\/distro\/npm\/)?remote$/.test(dir)) {
const syncOpts: child_process.SpawnSyncOptions = {
env: finalOpts.env,
cwd: root,
stdio: 'inherit',
shell: true,
};
const userinfo = os.userInfo();
log(dir, `Installing dependencies inside container ${process.env['VSCODE_REMOTE_DEPENDENCIES_CONTAINER_NAME']}...`);
opts.cwd = root;
if (process.env['npm_config_arch'] === 'arm64') {
run('sudo', ['docker', 'run', '--rm', '--privileged', 'multiarch/qemu-user-static', '--reset', '-p', 'yes'], opts);
run('sudo', ['docker', 'run', '--rm', '--privileged', 'multiarch/qemu-user-static', '--reset', '-p', 'yes'], syncOpts);
}
run('sudo', [
'docker', 'run',
@@ -63,11 +84,16 @@ function npmInstall(dir: string, opts?: child_process.SpawnSyncOptions) {
'-w', path.resolve('/root/vscode', dir),
process.env['VSCODE_REMOTE_DEPENDENCIES_CONTAINER_NAME'],
'sh', '-c', `\"chown -R root:root ${path.resolve('/root/vscode', dir)} && export PATH="/root/vscode/.build/nodejs-musl/usr/local/bin:$PATH" && npm i -g node-gyp-build && npm ci\"`
], opts);
run('sudo', ['chown', '-R', `${userinfo.uid}:${userinfo.gid}`, `${path.resolve(root, dir)}`], opts);
], syncOpts);
run('sudo', ['chown', '-R', `${userinfo.uid}:${userinfo.gid}`, `${path.resolve(root, dir)}`], syncOpts);
} else {
log(dir, 'Installing dependencies...');
run(npm, command.split(' '), opts);
const output = await spawnAsync(npm, command.split(' '), finalOpts);
if (output.trim()) {
for (const line of output.trim().split('\n')) {
log(dir, line);
}
}
}
removeParcelWatcherPrebuild(dir);
}
@@ -156,65 +182,114 @@ function clearInheritedNpmrcConfig(dir: string, env: NodeJS.ProcessEnv): void {
}
}
for (const dir of dirs) {
async function runWithConcurrency(tasks: (() => Promise<void>)[], concurrency: number): Promise<void> {
const errors: Error[] = [];
let index = 0;
if (dir === '') {
removeParcelWatcherPrebuild(dir);
continue; // already executed in root
}
let opts: child_process.SpawnSyncOptions | undefined;
if (dir === 'build') {
opts = {
env: {
...process.env
},
};
if (process.env['CC']) { opts.env!['CC'] = 'gcc'; }
if (process.env['CXX']) { opts.env!['CXX'] = 'g++'; }
if (process.env['CXXFLAGS']) { opts.env!['CXXFLAGS'] = ''; }
if (process.env['LDFLAGS']) { opts.env!['LDFLAGS'] = ''; }
setNpmrcConfig('build', opts.env!);
npmInstall('build', opts);
continue;
}
if (/^(.build\/distro\/npm\/)?remote$/.test(dir)) {
// node modules used by vscode server
opts = {
env: {
...process.env
},
};
if (process.env['VSCODE_REMOTE_CC']) {
opts.env!['CC'] = process.env['VSCODE_REMOTE_CC'];
} else {
delete opts.env!['CC'];
async function worker() {
while (index < tasks.length) {
const i = index++;
try {
await tasks[i]();
} catch (err) {
errors.push(err as Error);
}
}
if (process.env['VSCODE_REMOTE_CXX']) {
opts.env!['CXX'] = process.env['VSCODE_REMOTE_CXX'];
} else {
delete opts.env!['CXX'];
}
if (process.env['CXXFLAGS']) { delete opts.env!['CXXFLAGS']; }
if (process.env['CFLAGS']) { delete opts.env!['CFLAGS']; }
if (process.env['LDFLAGS']) { delete opts.env!['LDFLAGS']; }
if (process.env['VSCODE_REMOTE_CXXFLAGS']) { opts.env!['CXXFLAGS'] = process.env['VSCODE_REMOTE_CXXFLAGS']; }
if (process.env['VSCODE_REMOTE_LDFLAGS']) { opts.env!['LDFLAGS'] = process.env['VSCODE_REMOTE_LDFLAGS']; }
if (process.env['VSCODE_REMOTE_NODE_GYP']) { opts.env!['npm_config_node_gyp'] = process.env['VSCODE_REMOTE_NODE_GYP']; }
setNpmrcConfig('remote', opts.env!);
npmInstall(dir, opts);
continue;
}
// For directories that don't define their own .npmrc, clear inherited config
const env = { ...process.env };
clearInheritedNpmrcConfig(dir, env);
npmInstall(dir, { env });
await Promise.all(Array.from({ length: Math.min(concurrency, tasks.length) }, () => worker()));
if (errors.length > 0) {
for (const err of errors) {
console.error(err.message);
}
process.exit(1);
}
}
child_process.execSync('git config pull.rebase merges');
child_process.execSync('git config blame.ignoreRevsFile .git-blame-ignore-revs');
async function main() {
if (!process.env['VSCODE_FORCE_INSTALL'] && isUpToDate()) {
log('.', 'All dependencies up to date, skipping postinstall.');
child_process.execSync('git config pull.rebase merges');
child_process.execSync('git config blame.ignoreRevsFile .git-blame-ignore-revs');
return;
}
const _state = computeState();
const nativeTasks: (() => Promise<void>)[] = [];
const parallelTasks: (() => Promise<void>)[] = [];
for (const dir of dirs) {
if (dir === '') {
removeParcelWatcherPrebuild(dir);
continue; // already executed in root
}
if (dir === 'build') {
nativeTasks.push(() => {
const env: NodeJS.ProcessEnv = { ...process.env };
if (process.env['CC']) { env['CC'] = 'gcc'; }
if (process.env['CXX']) { env['CXX'] = 'g++'; }
if (process.env['CXXFLAGS']) { env['CXXFLAGS'] = ''; }
if (process.env['LDFLAGS']) { env['LDFLAGS'] = ''; }
setNpmrcConfig('build', env);
return npmInstallAsync('build', { env });
});
continue;
}
if (/^(.build\/distro\/npm\/)?remote$/.test(dir)) {
const remoteDir = dir;
nativeTasks.push(() => {
const env: NodeJS.ProcessEnv = { ...process.env };
if (process.env['VSCODE_REMOTE_CC']) {
env['CC'] = process.env['VSCODE_REMOTE_CC'];
} else {
delete env['CC'];
}
if (process.env['VSCODE_REMOTE_CXX']) {
env['CXX'] = process.env['VSCODE_REMOTE_CXX'];
} else {
delete env['CXX'];
}
if (process.env['CXXFLAGS']) { delete env['CXXFLAGS']; }
if (process.env['CFLAGS']) { delete env['CFLAGS']; }
if (process.env['LDFLAGS']) { delete env['LDFLAGS']; }
if (process.env['VSCODE_REMOTE_CXXFLAGS']) { env['CXXFLAGS'] = process.env['VSCODE_REMOTE_CXXFLAGS']; }
if (process.env['VSCODE_REMOTE_LDFLAGS']) { env['LDFLAGS'] = process.env['VSCODE_REMOTE_LDFLAGS']; }
if (process.env['VSCODE_REMOTE_NODE_GYP']) { env['npm_config_node_gyp'] = process.env['VSCODE_REMOTE_NODE_GYP']; }
setNpmrcConfig('remote', env);
return npmInstallAsync(remoteDir, { env });
});
continue;
}
const taskDir = dir;
parallelTasks.push(() => {
const env = { ...process.env };
clearInheritedNpmrcConfig(taskDir, env);
return npmInstallAsync(taskDir, { env });
});
}
// Native dirs (build, remote) run sequentially to avoid node-gyp conflicts
for (const task of nativeTasks) {
await task();
}
// JS-only dirs run in parallel
const concurrency = Math.min(os.cpus().length, 8);
log('.', `Running ${parallelTasks.length} npm installs with concurrency ${concurrency}...`);
await runWithConcurrency(parallelTasks, concurrency);
child_process.execSync('git config pull.rebase merges');
child_process.execSync('git config blame.ignoreRevsFile .git-blame-ignore-revs');
fs.writeFileSync(stateFile, JSON.stringify(_state));
}
main().catch(err => {
console.error(err);
process.exit(1);
});

View File

@@ -6,6 +6,7 @@ import path from 'path';
import * as fs from 'fs';
import * as child_process from 'child_process';
import * as os from 'os';
import { isUpToDate, forceInstallMessage } from './installStateHash.ts';
if (!process.env['VSCODE_SKIP_NODE_VERSION_CHECK']) {
// Get the running Node.js version
@@ -41,6 +42,13 @@ if (process.env.npm_execpath?.includes('yarn')) {
throw new Error();
}
// Fast path: if nothing changed since last successful install, skip everything.
// This makes `npm i` near-instant when dependencies haven't changed.
if (!process.env['VSCODE_FORCE_INSTALL'] && isUpToDate()) {
console.log(`\x1b[32mAll dependencies up to date.\x1b[0m ${forceInstallMessage}`);
process.exit(0);
}
if (process.platform === 'win32') {
if (!hasSupportedVisualStudioVersion()) {
console.error('\x1b[1;31m*** Invalid C/C++ Compiler Toolchain. Please check https://github.com/microsoft/vscode/wiki/How-to-Contribute#prerequisites.\x1b[0;0m');