From ddec422a9e4969d8ef4362f45b426902ab04af4f Mon Sep 17 00:00:00 2001 From: Jordan Harband Date: Mon, 2 Dec 2024 21:21:28 -0800 Subject: [PATCH] [Breaking] v2 implementation, tests, readme, types --- .attw.json | 5 + .eslintrc | 23 +++++ .github/FUNDING.yml | 12 +++ .github/workflows/node-pretest.yml | 10 ++ .github/workflows/node.yml | 14 +++ .github/workflows/rebase.yml | 9 ++ .github/workflows/require-allow-edits.yml | 18 ++++ .gitignore | 2 + .npmrc | 2 + CHANGELOG.md | 16 +++ README.md | 51 +++++++--- bin.mjs | 63 ++++++++++++ bin/index.js | 67 ------------ help.txt | 8 ++ index.d.mts | 8 ++ index.mjs | 67 ++++++++++++ package.json | 118 +++++++++++++++------- pargs.mjs | 100 ++++++++++++++++++ test/index.mjs | 37 +++++++ tsconfig.json | 10 ++ 20 files changed, 525 insertions(+), 115 deletions(-) create mode 100644 .attw.json create mode 100644 .eslintrc create mode 100644 .github/FUNDING.yml create mode 100644 .github/workflows/node-pretest.yml create mode 100644 .github/workflows/node.yml create mode 100644 .github/workflows/rebase.yml create mode 100644 .github/workflows/require-allow-edits.yml create mode 100644 CHANGELOG.md create mode 100755 bin.mjs delete mode 100755 bin/index.js create mode 100644 help.txt create mode 100644 index.d.mts create mode 100644 index.mjs create mode 100644 pargs.mjs create mode 100644 test/index.mjs create mode 100644 tsconfig.json diff --git a/.attw.json b/.attw.json new file mode 100644 index 0000000..4eaa422 --- /dev/null +++ b/.attw.json @@ -0,0 +1,5 @@ +{ + "pack": true, + + "profile": "esm-only" +} diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..7ddcf69 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,23 @@ +{ + "root": true, + + "extends": "@ljharb", + + "rules": { + "max-statements": 0, + }, + + "overrides": [ + { + "files": "pargs.mjs", + "extends": "@ljharb/eslint-config/node/20", + "rules": { + "max-lines-per-function": 0, + }, + }, + { + "files": "./bin.mjs", + "extends": "@ljharb/eslint-config/node/20", + }, + ] +} diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..fc821fa --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,12 @@ +# These are supported funding model platforms + +github: [ljharb] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: npm/has-types +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +custom: # Replace with a single custom sponsorship URL diff --git a/.github/workflows/node-pretest.yml b/.github/workflows/node-pretest.yml new file mode 100644 index 0000000..88d49f9 --- /dev/null +++ b/.github/workflows/node-pretest.yml @@ -0,0 +1,10 @@ +name: 'Tests: pretest/posttest' + +on: [pull_request, push] + +permissions: + contents: read + +jobs: + tests: + uses: ljharb/actions/.github/workflows/pretest.yml@main diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml new file mode 100644 index 0000000..cd2230b --- /dev/null +++ b/.github/workflows/node.yml @@ -0,0 +1,14 @@ +name: 'Tests: node.js >= 22' + +on: [pull_request, push] + +permissions: + contents: read + +jobs: + tests: + uses: ljharb/actions/.github/workflows/node.yml@main + with: + range: '^22.11 || >= 23.3' + type: minors + command: npm run tests-only diff --git a/.github/workflows/rebase.yml b/.github/workflows/rebase.yml new file mode 100644 index 0000000..b9e1712 --- /dev/null +++ b/.github/workflows/rebase.yml @@ -0,0 +1,9 @@ +name: Automatic Rebase + +on: [pull_request_target] + +jobs: + _: + uses: ljharb/actions/.github/workflows/rebase.yml@main + secrets: + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/require-allow-edits.yml b/.github/workflows/require-allow-edits.yml new file mode 100644 index 0000000..a685b8a --- /dev/null +++ b/.github/workflows/require-allow-edits.yml @@ -0,0 +1,18 @@ +name: Require “Allow Edits” + +on: [pull_request_target] + +permissions: + contents: read + +jobs: + _: + permissions: + pull-requests: read # for ljharb/require-allow-edits to check 'allow edits' on PR + + name: "Require “Allow Edits”" + + runs-on: ubuntu-latest + + steps: + - uses: ljharb/require-allow-edits@main diff --git a/.gitignore b/.gitignore index 5829e8e..9659738 100644 --- a/.gitignore +++ b/.gitignore @@ -133,3 +133,5 @@ dist npm-shrinkwrap.json package-lock.json yarn.lock + +.npmignore \ No newline at end of file diff --git a/.npmrc b/.npmrc index 43c97e7..eacea13 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,3 @@ package-lock=false +allow-same-version=true +message=v%s diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..4922334 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## 1.0.0 - 2017-11-13 + +### Commits + +- Initial commit [`cafb49b`](https://github.com/elliotblackburn/has-types/commit/cafb49b7292b7a85e4aeeccfd9f0c9425b1be8a1) +- Rename from has-types to hastypes [`8aa23de`](https://github.com/elliotblackburn/has-types/commit/8aa23deb28fbf0ada1bb47b4f1ff70b3c11847ee) +- Force node engine to be 8.9.1 or higher [`bf908d7`](https://github.com/elliotblackburn/has-types/commit/bf908d7a1a0fcab39e22b0f408668df514839ee6) +- Correct bin file [`162fb4a`](https://github.com/elliotblackburn/has-types/commit/162fb4af78f9cad3a2f4176d0595f081ef0143a0) +- 1.0.1 release [`fbcee3a`](https://github.com/elliotblackburn/has-types/commit/fbcee3a04ae4a861a1a6a0d46f8a45dd1a6b4d46) diff --git a/README.md b/README.md index 1c579a0..fa828bc 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,48 @@ -# Has types? +# hastypes [![Version Badge][npm-version-svg]][package-url] -> Check if an npm package is typescript friendly! +[![github actions][actions-image]][actions-url] +[![coverage][codecov-image]][codecov-url] +[![License][license-image]][license-url] +[![Downloads][downloads-image]][downloads-url] + +[![npm badge][npm-badge-png]][package-url] + +Does the given package have TypeScript types? **Inspired by https://github.com/ofrobots/typescript-friendly** -## Installation +## Example -``` -npm i -g hastypes +```sh +$ hastypes hastypes # => integrated +$ hastypes react@19 # => @types/react +$ hastypes mdpdf@1 # => none ``` -## Usage +```mjs +import hasTypes from 'hastypes'; +import assert from 'assert'; -Simply call `hastypes` followed by an npm package name! - -```sh -$ hastypes @sindresorhus/is # => integrated -$ hastypes express # => @types/express -$ hastypes mdpdf # => none +hasTypes('hastypes').then(x => assert.equal(x, true)); +hasTypes('react@19').then(x => assert.equal(x, '@types/react')); +hasTypes('mdpdf@1').then(x => assert.equal(x, false)); ``` + +## Tests +Simply clone the repo, `npm install`, and run `npm test` + +[package-url]: https://npmjs.org/package/hastypes +[npm-version-svg]: https://versionbadg.es/elliotblackburn/has-types.svg +[deps-svg]: https://david-dm.org/elliotblackburn/has-types.svg +[deps-url]: https://david-dm.org/elliotblackburn/has-types +[dev-deps-svg]: https://david-dm.org/elliotblackburn/has-types/dev-status.svg +[dev-deps-url]: https://david-dm.org/elliotblackburn/has-types#info=devDependencies +[npm-badge-png]: https://nodei.co/npm/hastypes.png?downloads=true&stars=true +[license-image]: https://img.shields.io/npm/l/hastypes.svg +[license-url]: LICENSE +[downloads-image]: https://img.shields.io/npm/dm/hastypes.svg +[downloads-url]: https://npm-stat.com/charts.html?package=hastypes +[codecov-image]: https://codecov.io/gh/elliotblackburn/has-types/branch/main/graphs/badge.svg +[codecov-url]: https://app.codecov.io/gh/elliotblackburn/has-types/ +[actions-image]: https://img.shields.io/endpoint?url=https://github-actions-badge-u3jn4tfpocch.runkit.sh/elliotblackburn/has-types +[actions-url]: https://github.com/elliotblackburn/has-types/actions diff --git a/bin.mjs b/bin.mjs new file mode 100755 index 0000000..c4a9c40 --- /dev/null +++ b/bin.mjs @@ -0,0 +1,63 @@ +#! /usr/bin/env node + +import { readFileSync } from 'fs'; +import { join } from 'path'; + +import npa from 'npm-package-arg'; + +import pargs from './pargs.mjs'; + +const help = readFileSync(join(import.meta.dirname, './help.txt'), 'utf8'); + +const { + positionals, + values: { before }, +} = pargs(help, import.meta.url, { + allowPositionals: true, + options: { + before: { + type: 'string', + }, + }, +}); + +const specifiers = positionals.slice(1); + +if (specifiers.length !== 1) { + console.error('You must provide exactly one specifier'); + process.exit(1); +} + +if (typeof before !== 'undefined' && typeof before !== 'string') { + console.error('`before` option must be a valid Date value'); + process.exit(1); +} + +let name, rawSpec; +try { + ({ name, rawSpec } = npa(specifiers[0])); + if (rawSpec === '*') { + rawSpec = 'latest'; + } +} catch (e) { + // eslint-disable-next-line no-extra-parens + console.error(/** @type {Error} */ (e)?.message ?? 'Unknown error'); + process.exit(1); +} + +import hasTypes from './index.mjs'; + +import mockProperty from 'mock-property'; + +// eslint-disable-next-line no-empty-function, no-extra-parens +const restore = mockProperty(/** @type {Parameters[0]} */ (/** @type {unknown} */ (console)), 'error', { value() {} }); +const promise = hasTypes(specifiers[0], { before }); + +promise.finally(() => { + restore(); +}).catch((e) => { + console.error(e.message); + process.exit(1); +}).then((r) => { + console.log(`${name}@${rawSpec} ${typeof r === 'string' ? r : r ? 'integrated' : 'none'}`); +}); diff --git a/bin/index.js b/bin/index.js deleted file mode 100755 index 93f08f0..0000000 --- a/bin/index.js +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -const meow = require('meow'); -const rp = require('request-promise'); - -const cli = meow(` - has-types will return information about an npm packages typescript support and friendliness. - - Usage: - $ hastypes -`); - -const pkg = cli.input[0]; - -if (!pkg) { - // Show help and exit - cli.showHelp(); -} - -function escapePackageName(pkg) { - return pkg.replace('/', '%2f'); -} - -async function check() { - let friendliness = 'none'; - - let res; - try { - res = await rp('https://registry.npmjs.com/' + escapePackageName(pkg)); - } catch (err) { - if (err.statusCode === 404) { - console.error("Package not found, is it part of an npm organisation?"); - process.exit(1); - } else { - console.error("Failed to get details of types"); - } - } - const manifest = JSON.parse(res); - - const latestVersion = manifest['dist-tags'].latest; - const latestManifest = manifest.versions[latestVersion]; - - if (latestManifest.types) { - friendliness = 'integrated'; - } else { - // Check if an @types/ package for the module - const typesPackage = escapePackageName('@types/' + pkg); - let res; - try { - res = await rp('https://registry.npmjs.com/' + typesPackage); - } catch (err) { - if (err.statusCode !== 404) { - console.error("Failed to get details of types"); - process.exit(1); - } - } - if (res) { - const typesManifest = JSON.parse(res); - friendliness = "@types/" + pkg; - } - } - - console.log(friendliness); -} - -check(); \ No newline at end of file diff --git a/help.txt b/help.txt new file mode 100644 index 0000000..1281ff1 --- /dev/null +++ b/help.txt @@ -0,0 +1,8 @@ +Usage: has-types [--before=MM/DD/YYYY] + +`package-specifier` must be a valid registry specifier according to `npm-package-arg`. + + - see https://www.npmjs.com/package/npm-package-arg + + Options: + `--before`: npm‘s `before` option. Must be a valid date. \ No newline at end of file diff --git a/index.d.mts b/index.d.mts new file mode 100644 index 0000000..240a4cd --- /dev/null +++ b/index.d.mts @@ -0,0 +1,8 @@ +declare function hasTypes( + specifier: string, + options?: { + before?: string; + }, +): Promise<`@types/${string}` | boolean>; + +export default hasTypes; \ No newline at end of file diff --git a/index.mjs b/index.mjs new file mode 100644 index 0000000..dc36556 --- /dev/null +++ b/index.mjs @@ -0,0 +1,67 @@ +import { existsSync } from 'fs'; +import { join, dirname, basename, extname } from 'path'; + +import { dirSync } from 'tmp'; +import npa from 'npm-package-arg'; +import pacote from 'pacote'; +import { getDTName } from 'dts-gen/dist/names.js'; + +/** @type {import('.')} */ +export default async function hasTypes(specifier, options = {}) { + let { before } = options; + let date = typeof before !== 'undefined' && new Date(before); + if (date && isNaN(Number(date))) { + throw new TypeError('`before` option must be a valid Date value'); + } + + const { + registry, + name, + fetchSpec, + } = npa(specifier); + if (!registry) { + throw new TypeError('specifier must be a registry package'); + } + + const { name: tmpdir, removeCallback } = dirSync({ unsafeCleanup: true }); + + try { + const pExtract = pacote.extract(specifier, tmpdir, { before: date }); + const manifest = pacote.manifest(specifier, { before: date }); + + // don't bother supporting typings + const explicitTypes = manifest.types; + if (explicitTypes) { + if (typeof explicitTypes !== 'string') { + throw new TypeError('`types` field is not a string. Please report this!'); + } + + if (!explicitTypes.endsWith('.d.ts')) { + return false; + } + await pExtract; + if (!existsSync(join(tmpdir, explicitTypes))) { + return false; + } + + return true; + } + + var index = manifest.main || 'index.js'; + var extless = join(dirname(index), basename(index, extname(index))); + var dts = `./${extless}.d.ts`; + + await pExtract; + if (existsSync(join(tmpdir, dts))) { + return true; + } + + const dtSpec = `@types/${getDTName(name)}@${fetchSpec === '*' ? 'latest' : fetchSpec}`; + + const result = await pacote.manifest(dtSpec, { before: date }).catch(() => null); + + return result !== null && dtSpec; + } finally { + removeCallback(); + } +} diff --git a/package.json b/package.json index 98caef0..b4306a1 100644 --- a/package.json +++ b/package.json @@ -1,38 +1,84 @@ { - "name": "hastypes", - "version": "1.0.0", - "description": "Check if a package has typescript typings from the command line", - "main": "index.js", - "bin": { - "hastypes": "bin/index.js" - }, - "files": [ - "bin" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/BlueHatbRit/has-types.git" - }, - "keywords": [ - "typescript", - "check", - "package", - "npm", - "has", - "types" - ], - "author": "Elliot Blackburn ", - "license": "MIT", - "bugs": { - "url": "https://github.com/BlueHatbRit/has-types/issues" - }, - "homepage": "https://github.com/BlueHatbRit/has-types#readme", - "dependencies": { - "meow": "3.7.0", - "request": "2.83.0", - "request-promise": "4.2.2" - }, - "engines": { - "node": ">=8.9.1" - } + "name": "hastypes", + "version": "1.0.0", + "description": "Does the given package have TypeScript types?", + "bin": "./bin.mjs", + "main": "./index.mjs", + "exports": { + ".": "./index.mjs", + "./package.json": "./package.json" + }, + "sideEffects": false, + "scripts": { + "prepack": "npmignore --auto --commentLines=autogenerated", + "version": "auto-changelog && git add CHANGELOG.md", + "postversion": "auto-changelog && git add CHANGELOG.md && git commit --no-edit --amend && git tag -f \"v$(node -e \"console.log(require('./package.json').version)\")\"", + "prepublishOnly": "safe-publish-latest", + "prepublish": "not-in-publish || npm run prepublishOnly", + "lint": "eslint --ext=js,mjs .", + "postlint": "tsc -p . && attw -P", + "pretest": "npm run lint", + "tests-only": "tape test/*.*js", + "test": "npm run tests-only", + "posttest": "npx npm@'>=10.2' audit --production" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/elliotblackburn/has-types.git" + }, + "keywords": [ + "npm", + "package", + "typescript", + "ts", + "types", + "typings", + "definitelytyped", + "dt" + ], + "author": "Elliot Blackburn ", + "license": "MIT", + "bugs": { + "url": "https://github.com/elliotblackburn/has-types/issues" + }, + "homepage": "https://github.com/elliotblackburn/has-types#readme", + "dependencies": { + "dts-gen": "^0.10.4", + "get-dep-tree": "^2.0.0", + "mock-property": "^1.1.0", + "npm-package-arg": "^12.0.0", + "pacote": "^21.0.0", + "tmp": "^0.2.3" + }, + "devDependencies": { + "@arethetypeswrong/cli": "^0.17.0", + "@ljharb/eslint-config": "^21.1.1", + "@ljharb/tsconfig": "^0.2.2", + "@types/node": "^22.10.1", + "@types/npm-package-arg": "^6.1.4", + "@types/tape": "^5.6.5", + "auto-changelog": "^2.5.0", + "eslint": "=8.8.0", + "in-publish": "^2.0.1", + "npmignore": "^0.3.1", + "safe-publish-latest": "^2.0.0", + "tape": "^5.9.0", + "typescript": "next" + }, + "auto-changelog": { + "output": "CHANGELOG.md", + "template": "keepachangelog", + "unreleased": false, + "commitLimit": false, + "backfillLimit": false, + "hideCredit": true + }, + "publishConfig": { + "ignore": [ + ".github/workflows" + ] + }, + "engines": { + "node": "^22.11 || >= 23.3" + } } diff --git a/pargs.mjs b/pargs.mjs new file mode 100644 index 0000000..a19a4bd --- /dev/null +++ b/pargs.mjs @@ -0,0 +1,100 @@ +import { parseArgs } from 'util'; +import { realpathSync } from 'fs'; + +// import groupByPolyfill from 'object.groupby/polyfill'; + +const { groupBy } = Object; + +/** @typedef {import('util').ParseArgsConfig} ParseArgsConfig */ + +/** @typedef {(Error | TypeError) & { code: 'ERR_PARSE_ARGS_UNKNOWN_OPTION' | 'ERR_PARSE_ARGS_INVALID_OPTION_VALUE' | 'ERR_INVALID_ARG_TYPE' | 'ERR_INVALID_ARG_VALUE' | 'ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL'}} ParseArgsError */ + +/** @type {(e: unknown) => e is ParseArgsError} */ +function isParseArgsError(e) { + return !!e + && typeof e === 'object' + && 'code' in e + && ( + e.code === 'ERR_PARSE_ARGS_UNKNOWN_OPTION' + || e.code === 'ERR_PARSE_ARGS_INVALID_OPTION_VALUE' + || e.code === 'ERR_INVALID_ARG_TYPE' + || e.code === 'ERR_INVALID_ARG_VALUE' + || e.code === 'ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL' + ); +} + +/** @type {(helpText: string, entrypointPath: string, obj: Exclude) => ReturnType} */ +export default function pargs(helpText, entrypointPath, obj) { + const argv = process.argv.flatMap((arg) => { + try { + const realpathedArg = realpathSync(arg); + if ( + realpathedArg === process.execPath + || realpathedArg === entrypointPath + ) { + return []; + } + } catch (e) { /**/ } + return arg; + }); + + if ('help' in obj) { + throw new TypeError('The "help" option is reserved'); + } + + const bools = obj.options ? Object.entries(obj.options).filter(([key, { type }]) => type === 'boolean' && key !== 'help') : []; + const inverseBools = Object.fromEntries(bools.map(([key, value]) => [ + `no-${key}`, + { default: !value.default, type: 'boolean' }, + ])); + /** @type {ParseArgsConfig & { tokens: true }} */ + const newObj = { + args: argv, + ...obj, + options: { + ...obj.options, + ...inverseBools, + help: { + default: false, + type: 'boolean', + }, + }, + tokens: true, + }; + + try { + const { tokens, ...results } = parseArgs(newObj); + + if ('help' in results.values && results.values.help) { + console.log(helpText); + process.exit(0); + } + + /** @typedef {Extract} OptionToken */ + const optionTokens = tokens.filter(/** @type {(token: typeof tokens[number]) => token is OptionToken} */ (token) => token.kind === 'option'); + const passedArgs = new Set(optionTokens.map(({ name }) => name)); + const groups = groupBy(passedArgs, /** @param {string} x */ (x) => x.replace(/^no-/, '')); + bools.forEach(([key]) => { + if ((groups[key]?.length ?? 0) > 1) { + console.log(helpText); + console.error(`Error: Arguments --${key} and --no-${key} are mutually exclusive`); + process.exit(2); + } + if (passedArgs.has(`no-${key}`)) { + // @ts-expect-error + results.values[key] = !results.values[`no-${key}`]; + } + // @ts-expect-error + delete results.values[`no-${key}`]; + }); + + return obj.tokens ? { ...results, tokens } : results; + } catch (e) { + if (isParseArgsError(e)) { + console.log(helpText); + console.error(`Error: ${e.message}`); + process.exit(1); + } + throw e; + } +} diff --git a/test/index.mjs b/test/index.mjs new file mode 100644 index 0000000..e749cfe --- /dev/null +++ b/test/index.mjs @@ -0,0 +1,37 @@ +import test from 'tape'; + +import hasTypes from '../index.mjs'; + +const before = '2024-12-01'; // update this date as desired + +test('hasTypes', async (t) => { + t.comment(`date is pinned to ${before}`); + + const packages = { + 'has-proto': true, + 'tape@4': '@types/tape@4', + 'tape@latest': '@types/tape@latest', + tape: '@types/tape@latest', + }; + + // eslint-disable-next-line no-extra-parens, max-len + const results = /** @type {[keyof packages, PromiseSettledResult<`@types/${string}` | boolean>][]} */ (await Promise.all(Object.keys(packages).map(async (specifier) => [ + specifier, + (await Promise.allSettled([hasTypes(specifier, { before })]))[0], + ]))); + + results.forEach(([specifier, { + status, + // @ts-expect-error yes, it exists on one half of the union + value, + // @ts-expect-error yes, it exists on one half of the union + reason, + }]) => { + t.equal(status, 'fulfilled', `expected ${specifier} to be fulfilled; got ${status}`); + if (status === 'fulfilled') { + t.equal(value, packages[specifier], `expected ${specifier} to be ${packages[specifier]}; got ${value}`); + } else { + t.fail(reason); + } + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a0f7417 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@ljharb/tsconfig", + "compilerOptions": { + "target": "esnext", + "module": "nodenext", + }, + "exclude": [ + "coverage" + ] +}