diff --git a/packages/cli/bin/rapid.js b/packages/cli/bin/rapid.js index ad1bfb2..3802326 100755 --- a/packages/cli/bin/rapid.js +++ b/packages/cli/bin/rapid.js @@ -7,8 +7,9 @@ const yargs = require('yargs'); const { NpmFsMode, NYDUS_TYPE } = require('../lib/constants.js'); const util = require('../lib/util'); const fuse_t = require('../lib/fuse_t'); +const { Alert } = require('../lib/logger.js'); -yargs +const argv = yargs .command({ command: 'install', aliases: [ 'i', 'ii' ], @@ -48,6 +49,7 @@ yargs } await util.shouldFuseSupport(); + await install({ cwd, pkg, @@ -57,7 +59,11 @@ yargs productionMode, }); - console.log('[rapid] install finished'); + Alert.success('🚀 Success', [ + 'All dependencies have been successfully installed.', + 'Please refrain from using `rm -rf node_modules` directly.', + 'Consider using `rapid clean` or `rapid update` as alternatives.', + ]); // 首次执行 nydusd 后台服务可能会 hang 住输入 process.exit(0); }, @@ -81,5 +87,18 @@ yargs await list(cwd); }, }) + .fail((_, err) => { + Alert.error('🚨 Error', [ + err, + 'To enable debug mode, add the NODE_DEBUG=rapid before running the command.', + 'If the problem continues, please provide feedback at:', + 'https://github.com/cnpm/rapid/issues', + ]); + }) .help() .parse(); + + +if (argv._?.length === 0) { + yargs.showHelp(); +} diff --git a/packages/cli/lib/fuse_t.js b/packages/cli/lib/fuse_t.js index 54e2b8c..8675708 100644 --- a/packages/cli/lib/fuse_t.js +++ b/packages/cli/lib/fuse_t.js @@ -6,6 +6,7 @@ const urllib = require('urllib'); const execa = require('execa'); const os = require('node:os'); const inquirer = require('inquirer'); +const { Spin } = require('./logger'); const FUSE_T_INSTALL_PATH = '/usr/local/bin/go-nfsv4'; @@ -24,6 +25,10 @@ exports.checkFuseT = async function checkFuseT() { }; exports.installFuseT = async function installFuseT() { + const spin = new Spin({ + title: 'Installing fuse-t, it may take a few seconds', + showDots: true, + }); const tmpPath = path.join('/tmp', `${crypto.randomUUID()}.pkg`); await urllib.request(FUSE_T_DOWNLOAD_URL, { method: 'GET', @@ -31,6 +36,7 @@ exports.installFuseT = async function installFuseT() { followRedirect: true, }); await execa.command(`sudo installer -pkg ${tmpPath} -target /`); + spin.success('fuse-t installed successfully'); }; exports.confirmInstallFuseT = async function confirmInstallFuseT() { diff --git a/packages/cli/lib/logger.js b/packages/cli/lib/logger.js index 6b35bab..cc925e1 100644 --- a/packages/cli/lib/logger.js +++ b/packages/cli/lib/logger.js @@ -1,4 +1,5 @@ const cliProgress = require('cli-progress'); +const boxen = require('boxen'); const MAX_TITLE_LENGTH = 11; @@ -9,6 +10,8 @@ function padCenter(str, length, char = ' ') { return char.repeat(padLeft) + str + char.repeat(padRight); } +const isTTY = process.stdout.isTTY; + class Bar { constructor({ type, total }) { const title = padCenter(type, MAX_TITLE_LENGTH); @@ -24,10 +27,9 @@ class Bar { ); this.startTime = Date.now(); - this.isTTY = process.stdout.isTTY; // init - if (this.isTTY) { + if (isTTY) { this.bar = this.multiBar.create(total, 1, { status: 'Running', warning: '', @@ -39,22 +41,22 @@ class Bar { update(current = '') { - if (!this.isTTY) { + if (!isTTY) { return; } const { value, total } = this.bar; if (value < total) { - this.isTTY && this.bar.update(value + 1, { status: 'Running', message: current }); + isTTY && this.bar.update(value + 1, { status: 'Running', message: current }); } if (value >= total - 1) { - this.isTTY && this.bar.update(total - 1, { status: 'Processing', message: 'Processing...' }); + isTTY && this.bar.update(total - 1, { status: 'Processing', message: 'Processing...' }); } } stop() { - if (!this.isTTY) { + if (!isTTY) { console.log('[rapid] %s complete, %dms', this.type, Date.now() - this.startTime); return; } @@ -70,4 +72,92 @@ class Bar { } } +class Alert { + static formatMessage(message) { + if (Array.isArray(message)) { + return message.map(_ => `* ${_}`).join('\n'); + } + return message.trim(); + } + + static error(title = 'Error', message = 'OOPS, something error') { + message = this.formatMessage(message); + if (!isTTY) { + console.log(message); + process.exit(1); + } + const boxedMessage = boxen(message, { + padding: 1, + margin: 1, + borderStyle: 'round', + borderColor: 'red', + title, + titleAlignment: 'center', + }); + console.log(boxedMessage); + process.exit(1); + } + + static success(title = 'Success', message = [ 'Congratulations', 'The operation was successful' ]) { + message = this.formatMessage(message); + if (!isTTY) { + console.log(message); + } + const boxedMessage = boxen(message, { + padding: 1, + margin: 1, + borderStyle: 'round', + borderColor: 'green', + title, + titleAlignment: 'center', + }); + console.log(boxedMessage); + } +} + +class Spin { + constructor({ title = 'processing', showDots = false }) { + const { createSpinner } = require('nanospinner'); + + if (!isTTY) { + console.log(`[rapid] ${title}`); + return; + } + + this.spinner = createSpinner(title).start(); + this.dots = 0; + this.start = Date.now(); + + if (showDots) { + this.interval = setInterval(() => { + this.dots = (this.dots + 1) % 4; // 循环点的数量从0到3 + const dotsString = '.'.repeat(this.dots); // 创建一个字符串,包含对应数量的点 + this.spinner.update({ text: `${title}${dotsString}` }); // 更新 spinner 文本 + }, 200); // 每200毫秒更新一次 + } + } + + update(message) { + if (!isTTY) { + console.log(`[rapid] ${message}`); + return; + } + this.spinner.update({ text: message }); + } + + success(message) { + const text = `${message || this.title}: ${Date.now() - this.start}ms`; + if (!isTTY) { + console.log(`[rapid] ${text}`); + return; + } + if (this.showDots) { + clearInterval(this.interval); + } + this.spinner.success({ text }); + } +} + +exports.Spin = Spin; exports.Bar = Bar; +exports.Alert = Alert; diff --git a/packages/cli/lib/nydusd/fuse_mode.js b/packages/cli/lib/nydusd/fuse_mode.js index 508a4d1..d968c23 100644 --- a/packages/cli/lib/nydusd/fuse_mode.js +++ b/packages/cli/lib/nydusd/fuse_mode.js @@ -84,6 +84,7 @@ async function mountOverlay(cwd, pkg) { await wrapRetry({ cmd: async () => await execa.command(wrapSudo(`mount -t tmpfs tmpfs ${overlay}`)), + title: 'mount tnpmfs', }); } else if (os.type() === 'Darwin') { // hdiutil create -size 512m -fs "APFS" -volname "NewAPFSDisk" -type SPARSE -layout NONE -imagekey diskimage-class=CRawDiskImage loopfile.dmg @@ -93,9 +94,11 @@ async function mountOverlay(cwd, pkg) { await execa.command( `hdiutil create -size 512m -fs APFS -volname ${volumeName} -type SPARSE -layout NONE -imagekey diskimage-class=CRawDiskImage ${tmpDmg}` ), + title: 'hdiutil create', }); await wrapRetry({ cmd: async () => await execa.command(`hdiutil attach -mountpoint ${overlay} ${tmpDmg}`), + title: 'hdiutil attach', }); } await fs.mkdir(upper, { recursive: true }); @@ -141,6 +144,7 @@ async function endNydusFs(cwd, pkg, force = true) { } await execa.command(`umount ${nodeModulesDir}`); }, + title: 'umount node_modules', fallback: force ? async () => { // force 模式再次尝试 @@ -162,6 +166,7 @@ async function endNydusFs(cwd, pkg, force = true) { } await execa.command(`hdiutil detach ${overlay}`); }, + title: 'hdiutil detach', fallback: force ? async () => { console.log(`[rapid] use fallback umount -f ${overlay}`); @@ -172,9 +177,11 @@ async function endNydusFs(cwd, pkg, force = true) { } else { await wrapRetry({ cmd: () => execa.command(wrapSudo(`${umountCmd} ${nodeModulesDir}`)), + title: 'umount node_modules', }); await wrapRetry({ cmd: () => execa.command(wrapSudo(`${umountCmd} ${overlay}`)), + title: 'umount node_modules', }); } await nydusdApi.umount(`/${dirname}`); diff --git a/packages/cli/lib/util.js b/packages/cli/lib/util.js index b000801..df7db97 100644 --- a/packages/cli/lib/util.js +++ b/packages/cli/lib/util.js @@ -9,6 +9,7 @@ const url = require('node:url'); const crypto = require('node:crypto'); const mapWorkspaces = require('@npmcli/map-workspaces'); const fuse_t = require('./fuse_t'); +const { Spin } = require('./logger'); const parser = require('yargs-parser'); const { NpmFsMode } = require('./constants'); @@ -24,6 +25,7 @@ const { nydusdBootstrapFile, nydusdMnt, } = require('./constants'); +const { Alert } = require('./logger'); // node_modules/a -> a // node_mdoules/@scope/b -> @scope/b @@ -55,22 +57,31 @@ function wrapSudo(shScript) { return `sudo ${shScript}`; } -async function wrapRetry({ cmd, timeout = 3000, fallback }) { +async function wrapRetry({ cmd, timeout = 3000, fallback, title = 'shell cmd' }) { // 最多等 3 秒 + // 只在第一次失败时才展示 spin + let spin; const startTime = Date.now(); let done = false; + let count = 0; while (!done) { try { await cmd(); done = true; + spin && spin.success(title); } catch (error) { - console.info(`[rapid] cmd failed: ${error}, retrying...`); + if (!spin) { + spin = new Spin({ title }); + } + // spin.update(`${cmd} failed, ${error}, retrying...`); if (Date.now() - startTime <= timeout) { await exports.sleep(300); + count++; + spin.update(`${title} retrying ${count} times ...`); } else { if (fallback) { await fallback(); - console.info('[rapid] cmd with fallback success'); + spin.success('[rapid] fallback success'); return; } throw error; @@ -561,9 +572,17 @@ exports.readPkgJSON = async function readPkgJSON(cwd) { }; exports.readPackageLock = async function readPackageLock(cwd) { - const lockPath = path.join(cwd || exports.findLocalPrefix(), './package-lock.json'); - const packageLock = JSON.parse(await fs.readFile(lockPath, 'utf8')); - return { packageLock, lockPath }; + try { + const lockPath = path.join(cwd || exports.findLocalPrefix(), './package-lock.json'); + const packageLock = JSON.parse(await fs.readFile(lockPath, 'utf8')); + return { packageLock, lockPath }; + } catch (e) { + Alert.error('Error', [ + 'Failed to parse package-lock.json.', + 'We only support package-lock.json version 3.', + 'Run `npm i --package-lock-only` to generate it.', + ]); + } }; // 列出当前 mount 的 fuse endpoint diff --git a/packages/cli/package.json b/packages/cli/package.json index 205220d..d4b48d9 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -16,11 +16,13 @@ "@npmcli/map-workspaces": "^3.0.0", "await-event": "^2.1.0", "binary-mirror-config": "^2.5.0", + "boxen": "^5.1.2", "chalk": "^4.0.0", "cli-progress": "^3.12.0", "execa": "^5.1.1", "inquirer": "^8.2.6", "ms": "^0.7.1", + "nanospinner": "^1.1.0", "npm-normalize-package-bin": "^3.0.1", "npm-package-arg": "^10.1.0", "p-map": "^4.0.0",