diff --git a/README.md b/README.md index 9982bf1e..19c514d6 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,17 @@ const main = defineCommand({ name: "hello", version: "1.0.0", description: "My Awesome CLI App", + updateChecker: { + registryName: "helloWorld", + msg: `Update available! {current} → {latest}.\n\nRun "npm install -g {cmd}" to update`, + box: { + padding: 1, + margin: 1, + align: "center", + borderColor: "yellow", + borderStyle: "round", + } + } }, args: { name: { diff --git a/package.json b/package.json index 015eb43f..ddd7f2c1 100644 --- a/package.json +++ b/package.json @@ -29,19 +29,27 @@ "release": "pnpm test && changelogen --release --push && npm publish", "test": "pnpm lint && vitest run --coverage" }, + "dependencies": { + "boxen": "^7.0.2" + }, "devDependencies": { "@types/node": "^18.15.11", + "@types/semver": "^7.3.13", "@vitest/coverage-c8": "^0.29.8", "changelogen": "^0.5.2", "colorette": "^2.0.19", + "defu": "^6.1.2", "eslint": "^8.37.0", "eslint-config-unjs": "^0.1.0", "jiti": "^1.18.2", + "ofetch": "^1.0.1", + "pkg-types": "^1.0.2", "prettier": "^2.8.7", "scule": "^1.0.0", + "semver": "^7.3.8", "typescript": "^5.0.3", "unbuild": "^1.2.0", "vitest": "^0.29.8" }, "packageManager": "pnpm@8.1.0" -} \ No newline at end of file +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e90b5a9..ca33f314 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,9 +1,17 @@ lockfileVersion: '6.0' +dependencies: + boxen: + specifier: ^7.0.2 + version: 7.0.2 + devDependencies: '@types/node': specifier: ^18.15.11 version: 18.15.11 + '@types/semver': + specifier: ^7.3.13 + version: 7.3.13 '@vitest/coverage-c8': specifier: ^0.29.8 version: 0.29.8(vitest@0.29.8) @@ -13,6 +21,9 @@ devDependencies: colorette: specifier: ^2.0.19 version: 2.0.19 + defu: + specifier: ^6.1.2 + version: 6.1.2 eslint: specifier: ^8.37.0 version: 8.37.0 @@ -22,12 +33,21 @@ devDependencies: jiti: specifier: ^1.18.2 version: 1.18.2 + ofetch: + specifier: ^1.0.1 + version: 1.0.1 + pkg-types: + specifier: ^1.0.2 + version: 1.0.2 prettier: specifier: ^2.8.7 version: 2.8.7 scule: specifier: ^1.0.0 version: 1.0.0 + semver: + specifier: ^7.3.8 + version: 7.3.8 typescript: specifier: ^5.0.3 version: 5.0.3 @@ -925,15 +945,19 @@ packages: uri-js: 4.4.1 dev: true + /ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + dependencies: + string-width: 4.2.3 + dev: false + /ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} - dev: true /ansi-regex@6.0.1: resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==} engines: {node: '>=12'} - dev: true /ansi-styles@3.2.1: resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} @@ -957,7 +981,6 @@ packages: /ansi-styles@6.2.1: resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} engines: {node: '>=12'} - dev: true /argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -1024,6 +1047,20 @@ packages: engines: {node: '>=0.6'} dev: true + /boxen@7.0.2: + resolution: {integrity: sha512-1Z4UJabXUP1/R9rLpoU3O2lEMnG3pPLAs/ZD2lF3t2q7qD5lM8rqbtnvtvm4N0wEyNlE+9yZVTVAGmd1V5jabg==} + engines: {node: '>=14.16'} + dependencies: + ansi-align: 3.0.1 + camelcase: 7.0.1 + chalk: 5.2.0 + cli-boxes: 3.0.0 + string-width: 5.1.2 + type-fest: 2.19.0 + widest-line: 4.0.1 + wrap-ansi: 8.1.0 + dev: false + /bplist-parser@0.2.0: resolution: {integrity: sha512-z0M+byMThzQmD9NILRniCUXYsYpjwnlO8N5uCFaCqIOpqRsJCrQL9NK3JsD67CN5a08nF5oIL2bD6loTdHOuKw==} engines: {node: '>= 5.10.0'} @@ -1131,6 +1168,11 @@ packages: engines: {node: '>=6'} dev: true + /camelcase@7.0.1: + resolution: {integrity: sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==} + engines: {node: '>=14.16'} + dev: false + /caniuse-lite@1.0.30001473: resolution: {integrity: sha512-ewDad7+D2vlyy+E4UJuVfiBsU69IL+8oVmTuZnH5Q6CIUbxNfI50uVpRHbUPDD6SUaN2o0Lh4DhTrvLG/Tn1yg==} dev: true @@ -1168,7 +1210,6 @@ packages: /chalk@5.2.0: resolution: {integrity: sha512-ree3Gqw/nazQAPuJJEy+avdl7QfZMcUvmHIKgEZkGL+xOBzRvup5Hxo6LHuMceSxOabuJLJm5Yp/92R9eMmMvA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} - dev: true /changelogen@0.5.2: resolution: {integrity: sha512-VB/DHuB10kCtRCwKjVyVLBn2nGTCx2hDQeU1WLQHamj2naya09ZmhdzO7LFXxr9O64LBKjZlwpiU4sEzGEdQBQ==} @@ -1213,6 +1254,11 @@ packages: escape-string-regexp: 1.0.5 dev: true + /cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + dev: false + /cli-truncate@3.1.0: resolution: {integrity: sha512-wfOBkjXteqSnI59oPcJkcPl/ZmwvMMOj340qUIY1SKZCv0B9Cf4D4fAucRkIKQmsIuYK3x1rrgU7MeGRruiuiA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -1399,7 +1445,6 @@ packages: /eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - dev: true /electron-to-chromium@1.4.348: resolution: {integrity: sha512-gM7TdwuG3amns/1rlgxMbeeyNoBFPa+4Uu0c7FeROWh4qWmvSOnvcslKmWy51ggLKZ2n/F/4i2HJ+PVNxH9uCQ==} @@ -1407,11 +1452,9 @@ packages: /emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - dev: true /emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - dev: true /enhanced-resolve@5.12.0: resolution: {integrity: sha512-QHTXI/sZQmko1cbDoNAa3mJ5qhWUUNAq3vR0/YiD379fWQrcfuoX1+HW2S0MTt7XmoPLapdaDKUtelUSPic7hQ==} @@ -2410,7 +2453,6 @@ packages: /is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} - dev: true /is-fullwidth-code-point@4.0.0: resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} @@ -3350,7 +3392,6 @@ packages: emoji-regex: 8.0.0 is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 - dev: true /string-width@5.1.2: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} @@ -3359,7 +3400,6 @@ packages: eastasianwidth: 0.2.0 emoji-regex: 9.2.2 strip-ansi: 7.0.1 - dev: true /string.prototype.trim@1.2.7: resolution: {integrity: sha512-p6TmeT1T3411M8Cgg9wBTMRtY2q9+PNy9EV1i2lIXUN/btt763oIfxwN3RR8VU6wHX8j/1CFy0L+YuThm6bgOg==} @@ -3391,14 +3431,12 @@ packages: engines: {node: '>=8'} dependencies: ansi-regex: 5.0.1 - dev: true /strip-ansi@7.0.1: resolution: {integrity: sha512-cXNxvT8dFNRVfhVME3JAe98mkXDYN2O1l7jmcwMnOslDeESg1rF/OZMtK0nRAhiari1unG5cD4jG3rapUAkLbw==} engines: {node: '>=12'} dependencies: ansi-regex: 6.0.1 - dev: true /strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} @@ -3582,6 +3620,11 @@ packages: engines: {node: '>=8'} dev: true + /type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + dev: false + /typed-array-length@1.0.4: resolution: {integrity: sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==} dependencies: @@ -3859,6 +3902,13 @@ packages: stackback: 0.0.2 dev: true + /widest-line@4.0.1: + resolution: {integrity: sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==} + engines: {node: '>=12'} + dependencies: + string-width: 5.1.2 + dev: false + /word-wrap@1.2.3: resolution: {integrity: sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==} engines: {node: '>=0.10.0'} @@ -3873,6 +3923,15 @@ packages: strip-ansi: 6.0.1 dev: true + /wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.0.1 + dev: false + /wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} dev: true diff --git a/src/_registry.ts b/src/_registry.ts new file mode 100644 index 00000000..54e64424 --- /dev/null +++ b/src/_registry.ts @@ -0,0 +1,42 @@ +import { ofetch } from "ofetch"; +import { readPackageJSON } from "pkg-types"; +import semver from "semver"; + +interface ReturnPkgVersion { + latest?: string; + current?: string; +} + +const REGISTRY_URL = "https://registry.npmjs.org"; + +// Check the latest version of a package +export async function checkPkgVersion(name: string): Promise { + const latest = await ofetch(`${REGISTRY_URL}/${name}`, { retry: 3 }) + .then((reg) => { + const { + "dist-tags": { latest }, + } = reg; + return latest; + }) + .catch(() => undefined); + + const current = await readPackageJSON() + .then((pkg) => pkg.version) + .catch(() => undefined); + + return { + latest, + current, + }; +} + +// Check if the current version is outdated +export function isPkgOutdated(pkgVer: ReturnPkgVersion): boolean { + const { latest, current } = pkgVer; + + if (!latest || !current) { + return false; + } + + return semver.lt(current, latest); +} diff --git a/src/main.ts b/src/main.ts index a1e5e687..0efa53d4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,8 +1,12 @@ -import { bgRed } from "colorette"; +import boxen from "boxen"; +import defu from "defu"; +import { bgRed, red, bold, green, magenta } from "colorette"; +import { readPackageJSON } from "pkg-types"; import type { CommandDef } from "./types"; import { resolveSubCommand, runCommand } from "./command"; -import { CLIError } from "./_utils"; +import { CLIError, resolveValue } from "./_utils"; import { showUsage } from "./usage"; +import { checkPkgVersion, isPkgOutdated } from "./_registry"; export interface RunMainOptions { rawArgs?: string[]; @@ -11,6 +15,46 @@ export interface RunMainOptions { export async function runMain(cmd: CommandDef, opts: RunMainOptions = {}) { const rawArgs = opts.rawArgs || process.argv.slice(2); try { + const cmdMeta = defu(await resolveValue(cmd.meta || {}), { + updateChecker: { + registryName: undefined, + msg: `Update available! ${red(bold("{current}"))} → ${bold( + green("{latest}") + )}.\n\nRun "${bold(magenta("npm install -g {cmd}"))}" to update.`, + box: { + padding: 1, + textAlignment: "center", + borderStyle: "round", + borderColor: "yellowBright", + }, + }, + }); + + // Check for updates + const pkgName = await readPackageJSON() + .then((r) => r.name) + .catch(() => undefined); + const updateOpts = cmdMeta.updateChecker; + const registryName = + typeof updateOpts === "object" + ? updateOpts?.registryName || pkgName + : false; + + if (registryName && updateOpts) { + const version = await checkPkgVersion(registryName); + const isOutdated = isPkgOutdated(version); + + if (isOutdated && version.current && version.latest) { + const msg = updateOpts.msg + .replace(/{cmd}/g, registryName) + .replace(/{current}/g, version.current) + .replace(/{latest}/g, version.latest); + + console.log(boxen(msg, updateOpts.box as any) + "\n"); + } + } + // End of update check + if (rawArgs.includes("--help") || rawArgs.includes("-h")) { await showUsage(...(await resolveSubCommand(cmd, rawArgs))); process.exit(0); diff --git a/src/types.ts b/src/types.ts index ea6527a3..f8bc07a5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,5 @@ +import type { Options as BoxOptions } from "boxen"; // ----- Args ----- - export type ArgType = "boolean" | "string" | "positional" | undefined; export type _ArgDef = { @@ -44,6 +44,27 @@ export interface CommandMeta { name?: string; version?: string; description?: string; + updateChecker?: + | false + | { + /** + * Defaults to `name` if not provided + * Usually the name of the package in the registry can be different from the name of the package in the `package.json` + */ + registryName?: string; + + /** + * The main update message to display in the box. + * + * @default 'Update available! ${red(bold("{current}"))} → ${bold(green("{latest}"))}.\n\nRun "${bold(magenta("npm install -g {cmd}"))}" to update' + */ + msg: string; + + /** + * Box options to update the look of the box. + */ + box: BoxOptions; + }; } // Command: Definition