Skip to content

Commit

Permalink
[cli] Replace update-notifier dependency with built in (#8090)
Browse files Browse the repository at this point in the history
This PR replaces the `update-notifier` dependency with a custom
implementation.

There are a few reasons: the dependency is quite large, it requires ESM
in order to update, and can sometimes suggest an update to an older
version. For example:


![image](https://user-images.githubusercontent.com/229881/195891579-c8c047a6-51ec-45f2-b597-daf927f48203.png)


- Related to #8038

Co-authored-by: Chris Barber <chris.barber@vercel.com>
Co-authored-by: Chris Barber <chris@cb1inc.com>
Co-authored-by: Sean Massa <EndangeredMassa@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
  • Loading branch information
5 people committed Dec 1, 2022
1 parent d649a3c commit 577fd3e
Show file tree
Hide file tree
Showing 8 changed files with 518 additions and 162 deletions.
3 changes: 1 addition & 2 deletions packages/cli/package.json
Expand Up @@ -50,8 +50,7 @@
"@vercel/redwood": "1.0.38",
"@vercel/remix": "1.1.0",
"@vercel/ruby": "1.3.44",
"@vercel/static-build": "1.0.41",
"update-notifier": "5.1.0"
"@vercel/static-build": "1.0.41"
},
"devDependencies": {
"@alex_neo/jest-expect-message": "1.0.5",
Expand Down
22 changes: 13 additions & 9 deletions packages/cli/scripts/build.ts
@@ -1,7 +1,7 @@
import cpy from 'cpy';
import execa from 'execa';
import { join } from 'path';
import { remove, writeFile } from 'fs-extra';
import { remove, readJSON, writeFile } from 'fs-extra';

const dirRoot = join(__dirname, '..');
const distRoot = join(dirRoot, 'dist');
Expand Down Expand Up @@ -43,15 +43,15 @@ async function main() {
stdio: 'inherit',
});

const pkg = await readJSON(join(dirRoot, 'package.json'));
const dependencies = Object.keys(pkg?.dependencies ?? {});
// Do the initial `ncc` build
console.log();
const args = [
'ncc',
'build',
'--external',
'update-notifier',
'src/index.ts',
];
console.log('Dependencies:', dependencies);
const externs = [];
for (const dep of dependencies) {
externs.push('--external', dep);
}
const args = ['ncc', 'build', 'src/index.ts', ...externs];
await execa('yarn', args, { stdio: 'inherit', cwd: dirRoot });

// `ncc` has some issues with `@vercel/fun`'s runtime files:
Expand All @@ -78,6 +78,10 @@ async function main() {
// Band-aid to bundle stuff that `ncc` neglects to bundle
await cpy(join(dirRoot, 'src/util/projects/VERCEL_DIR_README.txt'), distRoot);
await cpy(join(dirRoot, 'src/util/dev/builder-worker.js'), distRoot);
await cpy(
join(dirRoot, 'src/util/get-latest-version/get-latest-worker.js'),
distRoot
);

console.log('Finished building Vercel CLI');
}
Expand Down
47 changes: 24 additions & 23 deletions packages/cli/src/index.ts
Expand Up @@ -18,7 +18,7 @@ import sourceMap from '@zeit/source-map-support';
import { mkdirp } from 'fs-extra';
import chalk from 'chalk';
import epipebomb from 'epipebomb';
import updateNotifier from 'update-notifier';
import getLatestVersion from './util/get-latest-version';
import { URL } from 'url';
import * as Sentry from '@sentry/node';
import hp from './util/humanize-path';
Expand Down Expand Up @@ -55,13 +55,6 @@ import { VercelConfig } from '@vercel/client';

const isCanary = pkg.version.includes('canary');

// Checks for available update and returns an instance
const notifier = updateNotifier({
pkg,
distTag: isCanary ? 'canary' : 'latest',
updateCheckInterval: 1000 * 60 * 60 * 24 * 7, // 1 week
});

const VERCEL_DIR = getGlobalPathConfig();
const VERCEL_CONFIG_PATH = configFiles.getConfigFilePath();
const VERCEL_AUTH_CONFIG_PATH = configFiles.getAuthConfigFilePath();
Expand Down Expand Up @@ -149,22 +142,30 @@ const main = async () => {
}

// Print update information, if available
if (notifier.update && notifier.update.latest !== pkg.version && isTTY) {
const { latest } = notifier.update;
console.log(
info(
`${chalk.black.bgCyan('UPDATE AVAILABLE')} ` +
`Run ${cmd(
await getUpdateCommand()
)} to install ${getTitleName()} CLI ${latest}`
)
);
if (isTTY && !process.env.NO_UPDATE_NOTIFIER) {
// Check if an update is available. If so, `latest` will contain a string
// of the latest version, otherwise `undefined`.
const latest = getLatestVersion({
distTag: isCanary ? 'canary' : 'latest',
output,
pkg,
});
if (latest) {
console.log(
info(
`${chalk.black.bgCyan('UPDATE AVAILABLE')} ` +
`Run ${cmd(
await getUpdateCommand()
)} to install ${getTitleName()} CLI ${latest}`
)
);

console.log(
info(
`Changelog: https://github.com/vercel/vercel/releases/tag/vercel@${latest}`
)
);
console.log(
`${info(
`Changelog: https://github.com/vercel/vercel/releases/tag/vercel@${latest}`
)}\n`
);
}
}

// The second argument to the command can be:
Expand Down
186 changes: 186 additions & 0 deletions packages/cli/src/util/get-latest-version/get-latest-worker.js
@@ -0,0 +1,186 @@
/**
* This file is spawned in the background and checks npm for the latest version
* of the CLI, then writes the version to the cache file.
*
* NOTE: Since this file runs asynchronously in the background, it's possible
* for multiple instances of this file to be running at the same time leading
* to a race condition where the most recent instance will overwrite the
* previous cache file resetting the `notified` flag and cause the update
* notification to appear for multiple consequetive commands. Not the end of
* the world, but something to be aware of.
*/

const fetch = require('node-fetch');
const fs = require('fs-extra');
const path = require('path');
const { Agent: HttpsAgent } = require('https');
const { bold, gray, red } = require('chalk');
const { format, inspect } = require('util');

/**
* An simple output helper which accumulates error and debug log messages in
* memory for potential persistance to disk while immediately outputting errors
* and debug messages, when the `--debug` flag is set, to `stderr`.
*/
class WorkerOutput {
debugLog = [];
logFile = null;

constructor({ debug = true }) {
this.debugOutputEnabled = debug;
}

debug(...args) {
this.print('debug', args);
}

error(...args) {
this.print('error', args);
}

print(type, args) {
const str = format(
...args.map(s => (typeof s === 'string' ? s : inspect(s)))
);
this.debugLog.push(`[${new Date().toISOString()}] [${type}] ${str}`);
if (type === 'debug' && this.debugOutputEnabled) {
console.error(
`${gray('>')} ${bold('[debug]')} ${gray(
`[${new Date().toISOString()}]`
)} ${str}`
);
} else if (type === 'error') {
console.error(`${red(`Error:`)} ${str}`);
}
}

setLogFile(file) {
// wire up the exit handler the first time the log file is set
if (this.logFile === null) {
process.on('exit', () => {
if (this.debugLog.length) {
fs.outputFileSync(this.logFile, this.debugLog.join('\n'));
}
});
}
this.logFile = file;
}
}

const output = new WorkerOutput({
// enable the debug logging if the `--debug` is set or if this worker script
// was directly executed
debug: process.argv.includes('--debug') || !process.connected,
});

process.on('unhandledRejection', err => {
output.error('Exiting worker due to unhandled rejection:', err);
process.exit(1);
});

// this timer will prevent this worker process from running longer than 10s
const timer = setTimeout(() => {
output.error('Worker timed out after 10 seconds');
process.exit(1);
}, 10000);

// wait for the parent to give us the work payload
process.once('message', async msg => {
output.debug('Received message from parent:', msg);

output.debug('Disconnecting from parent');
process.disconnect();

const { cacheFile, distTag, name, updateCheckInterval } = msg;
const cacheFileParsed = path.parse(cacheFile);
await fs.mkdirp(cacheFileParsed.dir);

output.setLogFile(
path.join(cacheFileParsed.dir, `${cacheFileParsed.name}.log`)
);

const lockFile = path.join(
cacheFileParsed.dir,
`${cacheFileParsed.name}.lock`
);

try {
// check for a lock file and either bail if running or write our pid and continue
output.debug(`Checking lock file: ${lockFile}`);
if (await isRunning(lockFile)) {
output.debug('Worker already running, exiting');
process.exit(1);
}
output.debug(`Initializing lock file with pid ${process.pid}`);
await fs.writeFile(lockFile, String(process.pid), 'utf-8');

// fetch the latest version from npm
const agent = new HttpsAgent({
keepAlive: true,
maxSockets: 15, // See: `npm config get maxsockets`
});
const headers = {
accept:
'application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*',
};
const url = `https://registry.npmjs.org/${name}`;
output.debug(`Fetching ${url}`);
const res = await fetch(url, { agent, headers });
const json = await res.json();
const tags = json['dist-tags'];
const version = tags[distTag];

if (version) {
output.debug(`Found dist tag "${distTag}" with version "${version}"`);
} else {
output.error(`Dist tag "${distTag}" not found`);
output.debug('Available dist tags:', Object.keys(tags));
}

output.debug(`Writing cache file: ${cacheFile}`);
await fs.outputJSON(cacheFile, {
expireAt: Date.now() + updateCheckInterval,
notified: false,
version,
});
} catch (err) {
output.error(`Failed to get package info:`, err);
} finally {
clearTimeout(timer);

output.debug(`Releasing lock file: ${lockFile}`);
await fs.remove(lockFile);

output.debug(`Worker finished successfully!`);

// force the worker to exit
process.exit(0);
}
});

// signal the parent process we're ready
if (process.connected) {
output.debug("Notifying parent we're ready");
process.send({ type: 'ready' });
} else {
console.error('No IPC bridge detected, exiting');
process.exit(1);
}

async function isRunning(lockFile) {
try {
const pid = parseInt(await fs.readFile(lockFile, 'utf-8'));
output.debug(`Found lock file with pid: ${pid}`);

// checks for existence of a process; throws if not found
process.kill(pid, 0);

// process is still running
return true;
} catch (err) {
// lock file does not exist or process is not running and pid is stale
output.debug(`Resetting lock file: ${err.toString()}`);
await fs.remove(lockFile);
return false;
}
}

0 comments on commit 577fd3e

Please sign in to comment.