diff --git a/.vscode/extensions/vscode-extras/src/npmUpToDateFeature.ts b/.vscode/extensions/vscode-extras/src/npmUpToDateFeature.ts index b3f172eca1d..df9abf863ae 100644 --- a/.vscode/extensions/vscode-extras/src/npmUpToDateFeature.ts +++ b/.vscode/extensions/vscode-extras/src/npmUpToDateFeature.ts @@ -19,6 +19,7 @@ interface PostinstallState { interface InstallState { readonly root: string; + readonly stateContentsFile: string; readonly current: PostinstallState; readonly saved: PostinstallState | undefined; readonly files: readonly string[]; @@ -29,6 +30,10 @@ export class NpmUpToDateFeature extends vscode.Disposable { private readonly _disposables: vscode.Disposable[] = []; private _watchers: fs.FSWatcher[] = []; private _terminal: vscode.Terminal | undefined; + private _stateContentsFile: string | undefined; + private _root: string | undefined; + + private static readonly _scheme = 'npm-dep-state'; constructor(private readonly _output: vscode.LogOutputChannel) { const disposables: vscode.Disposable[] = []; @@ -48,10 +53,28 @@ export class NpmUpToDateFeature extends vscode.Disposable { this._statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground'); this._disposables.push(this._statusBarItem); + this._disposables.push( + vscode.workspace.registerTextDocumentContentProvider(NpmUpToDateFeature._scheme, { + provideTextDocumentContent: (uri) => { + const params = new URLSearchParams(uri.query); + const source = params.get('source'); + const file = uri.path.slice(1); // strip leading / + if (source === 'saved') { + return this._readSavedContent(file); + } + return this._readCurrentContent(file); + } + }) + ); + this._disposables.push( vscode.commands.registerCommand('vscode-extras.runNpmInstall', () => this._runNpmInstall()) ); + this._disposables.push( + vscode.commands.registerCommand('vscode-extras.showDependencyDiff', (file: string) => this._showDiff(file)) + ); + this._disposables.push( vscode.window.onDidCloseTerminal(t => { if (t === this._terminal) { @@ -66,8 +89,7 @@ export class NpmUpToDateFeature extends vscode.Disposable { private _runNpmInstall(): void { if (this._terminal) { - this._terminal.show(); - return; + this._terminal.dispose(); } const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri; if (!workspaceRoot) { @@ -113,6 +135,8 @@ export class NpmUpToDateFeature extends vscode.Disposable { return; } + this._stateContentsFile = state.stateContentsFile; + this._root = state.root; this._setupWatcher(state); const changedFiles = this._getChangedFiles(state); @@ -123,9 +147,16 @@ export class NpmUpToDateFeature extends vscode.Disposable { } 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`); + tooltip.isTrusted = true; + tooltip.supportHtml = true; + tooltip.appendMarkdown('**Dependencies are out of date.** Click to run npm install.\n\nChanged files:\n\n'); + for (const entry of changedFiles) { + if (entry.isFile) { + const args = encodeURIComponent(JSON.stringify(entry.label)); + tooltip.appendMarkdown(`- [${entry.label}](command:vscode-extras.showDependencyDiff?${args})\n`); + } else { + tooltip.appendMarkdown(`- ${entry.label}\n`); + } } this._statusBarItem.tooltip = tooltip; this._statusBarItem.backgroundColor = new vscode.ThemeColor('statusBarItem.warningBackground'); @@ -133,18 +164,71 @@ export class NpmUpToDateFeature extends vscode.Disposable { } } - private _getChangedFiles(state: InstallState): string[] { - if (!state.saved) { - return ['(no postinstall state found)']; + private _showDiff(file: string): void { + const cacheBuster = Date.now().toString(); + const savedUri = vscode.Uri.from({ + scheme: NpmUpToDateFeature._scheme, + path: `/${file}`, + query: new URLSearchParams({ source: 'saved', t: cacheBuster }).toString(), + }); + const currentUri = vscode.Uri.from({ + scheme: NpmUpToDateFeature._scheme, + path: `/${file}`, + query: new URLSearchParams({ source: 'current', t: cacheBuster }).toString(), + }); + + vscode.commands.executeCommand('vscode.diff', savedUri, currentUri, `${file} (last install ↔ current)`); + } + + private _readSavedContent(file: string): string { + if (!this._stateContentsFile) { + return ''; } - const changed: string[] = []; + try { + const contents: Record = JSON.parse(fs.readFileSync(this._stateContentsFile, 'utf8')); + return contents[file] ?? ''; + } catch { + return ''; + } + } + + private _readCurrentContent(file: string): string { + if (!this._root) { + return ''; + } + try { + return this._normalizeFileContent(path.join(this._root, file)); + } catch { + return ''; + } + } + + private _normalizeFileContent(filePath: string): string { + const raw = fs.readFileSync(filePath, 'utf8'); + if (path.basename(filePath) === 'package.json') { + const json = JSON.parse(raw); + for (const key of NpmUpToDateFeature._packageJsonIgnoredKeys) { + delete json[key]; + } + return JSON.stringify(json, null, '\t') + '\n'; + } + return raw; + } + + private static readonly _packageJsonIgnoredKeys = ['distro']; + + private _getChangedFiles(state: InstallState): { readonly label: string; readonly isFile: boolean }[] { + if (!state.saved) { + return [{ label: '(no postinstall state found)', isFile: false }]; + } + const changed: { readonly label: string; readonly isFile: boolean }[] = []; if (state.saved.nodeVersion !== state.current.nodeVersion) { - changed.push(`Node.js version (${state.saved.nodeVersion} → ${state.current.nodeVersion})`); + changed.push({ label: `Node.js version (${state.saved.nodeVersion} → ${state.current.nodeVersion})`, isFile: false }); } 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); + changed.push({ label: key, isFile: true }); } } return changed; diff --git a/build/npm/installStateHash.ts b/build/npm/installStateHash.ts index 79c0c130f5e..5674a1eaee3 100644 --- a/build/npm/installStateHash.ts +++ b/build/npm/installStateHash.ts @@ -10,6 +10,7 @@ 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 stateContentsFile = path.join(root, 'node_modules', '.postinstall-state-contents'); export const forceInstallMessage = 'Run \x1b[36mnode build/npm/fast-install.ts --force\x1b[0m to force a full install.'; export function collectInputFiles(): string[] { @@ -17,7 +18,7 @@ export function collectInputFiles(): string[] { for (const dir of dirs) { const base = dir === '' ? root : path.join(root, dir); - for (const file of ['package.json', '.npmrc']) { + for (const file of ['package.json', 'package-lock.json', '.npmrc']) { const filePath = path.join(base, file); if (fs.existsSync(filePath)) { files.push(filePath); @@ -35,23 +36,55 @@ export interface PostinstallState { readonly fileHashes: Record; } -function hashFileContent(filePath: string): string { +const packageJsonIgnoredKeys = new Set(['distro']); + +function normalizeFileContent(filePath: string): string { + const raw = fs.readFileSync(filePath, 'utf8'); + if (path.basename(filePath) === 'package.json') { + const json = JSON.parse(raw); + for (const key of packageJsonIgnoredKeys) { + delete json[key]; + } + return JSON.stringify(json, null, '\t') + '\n'; + } + return raw; +} + +function hashContent(content: string): string { const hash = crypto.createHash('sha256'); - hash.update(fs.readFileSync(filePath)); + hash.update(content); return hash.digest('hex'); } export function computeState(): PostinstallState { const fileHashes: Record = {}; for (const filePath of collectInputFiles()) { - fileHashes[path.relative(root, filePath)] = hashFileContent(filePath); + const key = path.relative(root, filePath); + try { + fileHashes[key] = hashContent(normalizeFileContent(filePath)); + } catch { + // file may not be readable + } } return { nodeVersion: process.versions.node, fileHashes }; } +export function computeContents(): Record { + const fileContents: Record = {}; + for (const filePath of collectInputFiles()) { + try { + fileContents[path.relative(root, filePath)] = normalizeFileContent(filePath); + } catch { + // file may not be readable + } + } + return fileContents; +} + export function readSavedState(): PostinstallState | undefined { try { - return JSON.parse(fs.readFileSync(stateFile, 'utf8')); + const { nodeVersion, fileHashes } = JSON.parse(fs.readFileSync(stateFile, 'utf8')); + return { nodeVersion, fileHashes }; } catch { return undefined; } @@ -67,10 +100,19 @@ export function isUpToDate(): boolean { && JSON.stringify(saved.fileHashes) === JSON.stringify(current.fileHashes); } +export function readSavedContents(): Record | undefined { + try { + return JSON.parse(fs.readFileSync(stateContentsFile, 'utf8')); + } catch { + return undefined; + } +} + // 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, + stateContentsFile, current: computeState(), saved: readSavedState(), files: [...collectInputFiles(), stateFile], diff --git a/build/npm/postinstall.ts b/build/npm/postinstall.ts index 6b61bbded5c..ae2651cd188 100644 --- a/build/npm/postinstall.ts +++ b/build/npm/postinstall.ts @@ -8,7 +8,7 @@ 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'; +import { root, stateFile, stateContentsFile, computeState, computeContents, isUpToDate } from './installStateHash.ts'; const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; const rootNpmrcConfigKeys = getNpmrcConfigKeys(path.join(root, '.npmrc')); @@ -287,6 +287,7 @@ async function main() { child_process.execSync('git config blame.ignoreRevsFile .git-blame-ignore-revs'); fs.writeFileSync(stateFile, JSON.stringify(_state)); + fs.writeFileSync(stateContentsFile, JSON.stringify(computeContents())); } main().catch(err => {