Skip to content

Commit 947efd5

Browse files
iclantonclaude
andauthored
[lockfile-explorer] Replace update-notifier with a built-in solution (#5786)
* Extract package.json stuff into a constants file. * Clean up terminal actions and terminal and add a CLI help test. * [rush-lib] Add PackageUpdateChecker; [lockfile-explorer] replace update-notifier - Add `PackageUpdateChecker` class to `rush-lib` as an `@internal` utility: caches latest-version results in `~/.rushstack/update-checks/`, uses global `fetch` with a 5s timeout, and supports `forceCheck`/`skip` options - Export as `_PackageUpdateChecker` from `@microsoft/rush-lib` - Replace `update-notifier` in `lockfile-explorer` with `PackageUpdateChecker`; fire the check concurrently with server setup and display the result inside `app.listen` via a standalone `printUpdateNotification` helper - Remove `update-notifier` and `@types/update-notifier` from dependencies - Remove `update-notifier` from nonbrowser-approved-packages.json - Add unit tests for `PackageUpdateChecker` (mocking `fetch` and `JsonFile`) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Move PackageUpdateChecker to lockfile-explorer. * Rush change. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 488875f commit 947efd5

18 files changed

Lines changed: 639 additions & 152 deletions

apps/lockfile-explorer/package.json

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@
5252
"@types/cors": "~2.8.12",
5353
"@types/express": "4.17.21",
5454
"@types/js-yaml": "4.0.9",
55-
"@types/update-notifier": "~6.0.1",
5655
"eslint": "~9.37.0",
5756
"local-node-rig": "workspace:*",
5857
"@pnpm/lockfile.types": "1002.0.1",
@@ -70,8 +69,7 @@
7069
"cors": "~2.8.5",
7170
"express": "4.21.1",
7271
"js-yaml": "~4.1.0",
73-
"semver": "~7.7.4",
74-
"update-notifier": "~5.1.0"
72+
"semver": "~7.7.4"
7573
},
7674
"exports": {
7775
"./lib/*.schema.json": "./lib-commonjs/*.schema.json",

apps/lockfile-explorer/src/cli/explorer/ExplorerCommandLineParser.ts

Lines changed: 40 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,9 @@ import type { ChildProcess } from 'node:child_process';
88
import express from 'express';
99
import yaml from 'js-yaml';
1010
import cors from 'cors';
11-
import updateNotifier from 'update-notifier';
1211

13-
import {
14-
Executable,
15-
FileSystem,
16-
type IPackageJson,
17-
JsonFile,
18-
PackageJsonLookup
19-
} from '@rushstack/node-core-library';
20-
import { ConsoleTerminalProvider, type ITerminal, Terminal, Colorize } from '@rushstack/terminal';
12+
import { Executable, FileSystem, type IPackageJson, JsonFile } from '@rushstack/node-core-library';
13+
import { type ITerminal, Colorize } from '@rushstack/terminal';
2114
import {
2215
type CommandLineFlagParameter,
2316
CommandLineParser,
@@ -36,17 +29,32 @@ import type { IAppState } from '../../state';
3629
import { init } from '../../utils/init';
3730
import { PnpmfileRunner } from '../../graph/PnpmfileRunner';
3831
import * as lfxGraphLoader from '../../graph/lfxGraphLoader';
32+
import { LFX_PACKAGE_NAME, LFX_VERSION } from '../../utils/constants';
33+
import { type IPackageUpdateResult, PackageUpdateChecker } from '../../utils/PackageUpdateChecker';
3934

4035
const EXPLORER_TOOL_FILENAME: 'lockfile-explorer' = 'lockfile-explorer';
4136

37+
function printUpdateNotification(
38+
result: { latestVersion: string; isOutdated: boolean } | undefined,
39+
terminal: ITerminal
40+
): void {
41+
if (result?.isOutdated) {
42+
terminal.writeLine(
43+
Colorize.yellow(
44+
`\nUpdate available: ${LFX_VERSION}${result.latestVersion}\n` +
45+
`Run: npm install -g ${LFX_PACKAGE_NAME}\n`
46+
)
47+
);
48+
}
49+
}
50+
4251
export class ExplorerCommandLineParser extends CommandLineParser {
4352
public readonly globalTerminal: ITerminal;
44-
private readonly _terminalProvider: ConsoleTerminalProvider;
45-
private readonly _debugParameter: CommandLineFlagParameter;
4653

54+
private readonly _debugParameter: CommandLineFlagParameter;
4755
private readonly _subspaceParameter: IRequiredCommandLineStringParameter;
4856

49-
public constructor() {
57+
public constructor(terminal: ITerminal) {
5058
super({
5159
toolFilename: EXPLORER_TOOL_FILENAME,
5260
toolDescription:
@@ -66,45 +74,37 @@ export class ExplorerCommandLineParser extends CommandLineParser {
6674
defaultValue: 'default'
6775
});
6876

69-
this._terminalProvider = new ConsoleTerminalProvider();
70-
this.globalTerminal = new Terminal(this._terminalProvider);
77+
this.globalTerminal = terminal;
7178
}
7279

7380
public get isDebug(): boolean {
7481
return this._debugParameter.value;
7582
}
7683

7784
protected override async onExecuteAsync(): Promise<void> {
78-
const lockfileExplorerProjectRoot: string = PackageJsonLookup.instance.tryGetPackageFolderFor(__dirname)!;
79-
const lockfileExplorerPackageJson: IPackageJson = JsonFile.load(
80-
`${lockfileExplorerProjectRoot}/package.json`
81-
);
82-
const appVersion: string = lockfileExplorerPackageJson.version;
85+
const terminal: ITerminal = this.globalTerminal;
8386

84-
this.globalTerminal.writeLine(
85-
Colorize.bold(`\nRush Lockfile Explorer ${appVersion}`) +
87+
terminal.writeLine(
88+
Colorize.bold(`\nRush Lockfile Explorer ${LFX_VERSION}`) +
8689
Colorize.cyan(' - https://lfx.rushstack.io/\n')
8790
);
8891

89-
updateNotifier({
90-
pkg: lockfileExplorerPackageJson,
91-
// Normally update-notifier waits a day or so before it starts displaying upgrade notices.
92-
// In debug mode, show the notice right away.
93-
updateCheckInterval: this.isDebug ? 0 : undefined
94-
}).notify({
95-
// Make sure it says "-g" in the "npm install" example command line
96-
isGlobal: true,
97-
// Show the notice immediately, rather than waiting for process.onExit()
98-
defer: false
92+
// Start the update check now so it runs concurrently with server setup.
93+
// The result is awaited and displayed inside app.listen once the server is ready.
94+
const updateChecker: PackageUpdateChecker = new PackageUpdateChecker({
95+
packageName: LFX_PACKAGE_NAME,
96+
currentVersion: LFX_VERSION,
97+
// In debug mode, bypass the cache so the notice appears immediately.
98+
forceCheck: this.isDebug
9999
});
100+
const updateCheckPromise: Promise<IPackageUpdateResult | undefined> = updateChecker.tryGetUpdateAsync();
100101

101102
const PORT: number = 8091;
102103
// Must not have a trailing slash
103104
const SERVICE_URL: string = `http://localhost:${PORT}`;
104105

105106
const appState: IAppState = init({
106-
lockfileExplorerProjectRoot,
107-
appVersion,
107+
appVersion: LFX_VERSION,
108108
debugMode: this.isDebug,
109109
subspaceName: this._subspaceParameter.value
110110
});
@@ -125,8 +125,8 @@ export class ExplorerCommandLineParser extends CommandLineParser {
125125
let disconnected: boolean = false;
126126
setInterval(() => {
127127
if (!isClientConnected && !awaitingFirstConnect && !disconnected) {
128-
console.log(Colorize.red('The client has disconnected!'));
129-
console.log(`Please open a browser window at http://localhost:${PORT}/app`);
128+
terminal.writeLine(Colorize.red('The client has disconnected!'));
129+
terminal.writeLine(`Please open a browser window at http://localhost:${PORT}/app`);
130130
disconnected = true;
131131
} else if (!awaitingFirstConnect) {
132132
isClientConnected = false;
@@ -157,7 +157,7 @@ export class ExplorerCommandLineParser extends CommandLineParser {
157157
isClientConnected = true;
158158
if (disconnected) {
159159
disconnected = false;
160-
console.log(Colorize.green('The client has reconnected!'));
160+
terminal.writeLine(Colorize.green('The client has reconnected!'));
161161
}
162162
res.status(200).send();
163163
});
@@ -253,7 +253,9 @@ export class ExplorerCommandLineParser extends CommandLineParser {
253253
);
254254

255255
app.listen(PORT, async () => {
256-
console.log(`App launched on ${SERVICE_URL}`);
256+
terminal.writeLine(`App launched on ${SERVICE_URL}`);
257+
258+
printUpdateNotification(await updateCheckPromise, terminal);
257259

258260
if (!appState.debugMode) {
259261
try {
@@ -288,7 +290,7 @@ export class ExplorerCommandLineParser extends CommandLineParser {
288290
// Detach from our Node.js process so the browser stays open after we exit
289291
browserProcess.unref();
290292
} catch (e) {
291-
this.globalTerminal.writeError('Error launching browser: ' + e.toString());
293+
terminal.writeError('Error launching browser: ' + e.toString());
292294
}
293295
}
294296
});
Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,48 +1,41 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
22
// See LICENSE in the project root for license information.
33

4-
import { ConsoleTerminalProvider, type ITerminal, Terminal, Colorize } from '@rushstack/terminal';
4+
import { type ITerminal, Colorize } from '@rushstack/terminal';
55
import { CommandLineParser } from '@rushstack/ts-command-line';
6-
import { type IPackageJson, JsonFile, PackageJsonLookup } from '@rushstack/node-core-library';
76

87
import { InitAction } from './actions/InitAction';
98
import { CheckAction } from './actions/CheckAction';
9+
import { LFX_VERSION } from '../../utils/constants';
1010

1111
const LINT_TOOL_FILENAME: 'lockfile-lint' = 'lockfile-lint';
1212

1313
export class LintCommandLineParser extends CommandLineParser {
1414
public readonly globalTerminal: ITerminal;
15-
private readonly _terminalProvider: ConsoleTerminalProvider;
1615

17-
public constructor() {
16+
public constructor(terminal: ITerminal) {
1817
super({
1918
toolFilename: LINT_TOOL_FILENAME,
2019
toolDescription:
2120
'Lockfile Lint applies configured policies to find and report dependency issues in your PNPM workspace.'
2221
});
2322

24-
this._terminalProvider = new ConsoleTerminalProvider();
25-
this.globalTerminal = new Terminal(this._terminalProvider);
23+
this.globalTerminal = terminal;
2624

2725
this._populateActions();
2826
}
2927

3028
protected override async onExecuteAsync(): Promise<void> {
31-
const lockfileExplorerProjectRoot: string = PackageJsonLookup.instance.tryGetPackageFolderFor(__dirname)!;
32-
const lockfileExplorerPackageJson: IPackageJson = JsonFile.load(
33-
`${lockfileExplorerProjectRoot}/package.json`
34-
);
35-
const appVersion: string = lockfileExplorerPackageJson.version;
36-
3729
this.globalTerminal.writeLine(
38-
Colorize.bold(`\nRush Lockfile Lint ${appVersion}`) + Colorize.cyan(' - https://lfx.rushstack.io/\n')
30+
Colorize.bold(`\nRush Lockfile Lint ${LFX_VERSION}`) + Colorize.cyan(' - https://lfx.rushstack.io/\n')
3931
);
4032

4133
await super.onExecuteAsync();
4234
}
4335

4436
private _populateActions(): void {
45-
this.addAction(new InitAction(this));
46-
this.addAction(new CheckAction(this));
37+
const terminal: ITerminal = this.globalTerminal;
38+
this.addAction(new InitAction(terminal));
39+
this.addAction(new CheckAction(terminal));
4740
}
4841
}

apps/lockfile-explorer/src/cli/lint/actions/CheckAction.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import { AlreadyReportedError, Async, FileSystem, JsonFile, JsonSchema } from '@
1515

1616
import lockfileLintSchema from '../../../schemas/lockfile-lint.schema.json';
1717
import { LOCKFILE_EXPLORER_FOLDERNAME, LOCKFILE_LINT_JSON_FILENAME } from '../../../constants/common';
18-
import type { LintCommandLineParser } from '../LintCommandLineParser';
1918
import {
2019
getShrinkwrapFileMajorVersion,
2120
parseDependencyPath,
@@ -45,7 +44,7 @@ export class CheckAction extends CommandLineAction {
4544
private _checkedProjects: Set<RushConfigurationProject>;
4645
private _docMap: Map<string, lockfileTypes.LockfileObject>;
4746

48-
public constructor(parser: LintCommandLineParser) {
47+
public constructor(terminal: ITerminal) {
4948
super({
5049
actionName: 'check',
5150
summary: 'Check and report dependency issues in your workspace',
@@ -55,7 +54,7 @@ export class CheckAction extends CommandLineAction {
5554
', reporting any problems found in your PNPM workspace.'
5655
});
5756

58-
this._terminal = parser.globalTerminal;
57+
this._terminal = terminal;
5958
this._checkedProjects = new Set();
6059
this._docMap = new Map();
6160
}

apps/lockfile-explorer/src/cli/lint/actions/InitAction.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,20 @@ import { Colorize, type ITerminal } from '@rushstack/terminal';
88
import { RushConfiguration } from '@rushstack/rush-sdk';
99
import { FileSystem } from '@rushstack/node-core-library';
1010

11-
import type { LintCommandLineParser } from '../LintCommandLineParser';
1211
import { LOCKFILE_EXPLORER_FOLDERNAME, LOCKFILE_LINT_JSON_FILENAME } from '../../../constants/common';
1312

1413
export class InitAction extends CommandLineAction {
1514
private readonly _terminal: ITerminal;
1615

17-
public constructor(parser: LintCommandLineParser) {
16+
public constructor(terminal: ITerminal) {
1817
super({
1918
actionName: 'init',
2019
summary: `Create a new ${LOCKFILE_LINT_JSON_FILENAME} config file`,
2120
documentation:
2221
`This command initializes a new ${LOCKFILE_LINT_JSON_FILENAME} config file.` +
2322
` The created template file includes source code comments that document the settings.`
2423
});
25-
this._terminal = parser.globalTerminal;
24+
this._terminal = terminal;
2625
}
2726

2827
protected override async onExecuteAsync(): Promise<void> {
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import { AnsiEscape, Terminal, StringBufferTerminalProvider } from '@rushstack/terminal';
5+
import type { CommandLineParser } from '@rushstack/ts-command-line';
6+
7+
import { ExplorerCommandLineParser } from '../explorer/ExplorerCommandLineParser';
8+
import { LintCommandLineParser } from '../lint/LintCommandLineParser';
9+
10+
describe('CommandLineHelp', () => {
11+
let terminal: Terminal;
12+
let terminalProvider: StringBufferTerminalProvider;
13+
14+
beforeEach(() => {
15+
terminalProvider = new StringBufferTerminalProvider();
16+
terminal = new Terminal(terminalProvider);
17+
18+
// ts-command-line calls process.exit() which interferes with Jest
19+
jest.spyOn(process, 'exit').mockImplementation((code) => {
20+
throw new Error(`Test code called process.exit(${code})`);
21+
});
22+
});
23+
24+
afterEach(() => {
25+
expect(terminalProvider.getAllOutputAsChunks({ asLines: true })).toMatchSnapshot('terminal output');
26+
});
27+
28+
describe.each([
29+
{
30+
name: 'ExplorerCommandLineParser',
31+
createParser: () => new ExplorerCommandLineParser(terminal)
32+
},
33+
{
34+
name: 'LintCommandLineParser',
35+
createParser: () => new LintCommandLineParser(terminal)
36+
}
37+
])('$name', ({ createParser }) => {
38+
it(`prints the help`, async () => {
39+
const parser: CommandLineParser = createParser();
40+
41+
const globalHelpText: string = AnsiEscape.formatForTests(parser.renderHelpText());
42+
expect(globalHelpText).toMatchSnapshot('global help');
43+
44+
for (const action of parser.actions) {
45+
const actionHelpText: string = AnsiEscape.formatForTests(action.renderHelpText());
46+
expect(actionHelpText).toMatchSnapshot(action.actionName);
47+
}
48+
});
49+
});
50+
});
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
2+
3+
exports[`CommandLineHelp ExplorerCommandLineParser prints the help: global help 1`] = `
4+
"usage: lockfile-explorer [-h] [-d] [--subspace SUBSPACE_NAME]
5+
6+
Lockfile Explorer is a desktop app for investigating and solving version
7+
conflicts in a PNPM workspace.
8+
9+
Optional arguments:
10+
-h, --help Show this help message and exit.
11+
-d, --debug Show the full call stack if an error occurs while
12+
executing the tool
13+
--subspace SUBSPACE_NAME
14+
Specifies an individual Rush subspace to check. The
15+
default value is \\"default\\".
16+
17+
[bold]For detailed help about a specific command, use: lockfile-explorer
18+
<command> -h[normal]
19+
"
20+
`;
21+
22+
exports[`CommandLineHelp ExplorerCommandLineParser prints the help: terminal output 1`] = `Array []`;
23+
24+
exports[`CommandLineHelp LintCommandLineParser prints the help: check 1`] = `
25+
"usage: lockfile-lint check [-h]
26+
27+
This command applies the policies that are configured in lockfile-lint.json,
28+
reporting any problems found in your PNPM workspace.
29+
30+
Optional arguments:
31+
-h, --help Show this help message and exit.
32+
"
33+
`;
34+
35+
exports[`CommandLineHelp LintCommandLineParser prints the help: global help 1`] = `
36+
"usage: lockfile-lint [-h] <command> ...
37+
38+
Lockfile Lint applies configured policies to find and report dependency
39+
issues in your PNPM workspace.
40+
41+
Positional arguments:
42+
<command>
43+
init Create a new lockfile-lint.json config file
44+
check Check and report dependency issues in your workspace
45+
46+
Optional arguments:
47+
-h, --help Show this help message and exit.
48+
49+
[bold]For detailed help about a specific command, use: lockfile-lint <command>
50+
-h[normal]
51+
"
52+
`;
53+
54+
exports[`CommandLineHelp LintCommandLineParser prints the help: init 1`] = `
55+
"usage: lockfile-lint init [-h]
56+
57+
This command initializes a new lockfile-lint.json config file. The created
58+
template file includes source code comments that document the settings.
59+
60+
Optional arguments:
61+
-h, --help Show this help message and exit.
62+
"
63+
`;
64+
65+
exports[`CommandLineHelp LintCommandLineParser prints the help: terminal output 1`] = `Array []`;

0 commit comments

Comments
 (0)