From fb54d7ddc04c36b55b79f1e6d0572351aa0c4f89 Mon Sep 17 00:00:00 2001 From: Jacob Ley Date: Thu, 5 Dec 2024 17:56:34 +0000 Subject: [PATCH] nx-update-ts-references: Support CLI usage --- .changeset/nice-ladybugs-grin.md | 5 + apps/nx-update-ts-references/README.md | 38 +++---- apps/nx-update-ts-references/bin.mjs | 3 + apps/nx-update-ts-references/eslint.config.js | 11 +- apps/nx-update-ts-references/src/bin.ts | 25 +++++ apps/nx-update-ts-references/src/cli.ts | 76 +++++++++++++ .../src/commands/index.ts | 25 +++++ .../src/commands/lib/dependencies.ts | 39 +++++++ .../src/commands/lib/types.ts | 18 ++++ .../commands/update-ts-references-command.ts | 102 ++++++++++++++++++ 10 files changed, 323 insertions(+), 19 deletions(-) create mode 100644 .changeset/nice-ladybugs-grin.md create mode 100755 apps/nx-update-ts-references/bin.mjs create mode 100644 apps/nx-update-ts-references/src/bin.ts create mode 100644 apps/nx-update-ts-references/src/cli.ts create mode 100644 apps/nx-update-ts-references/src/commands/index.ts create mode 100644 apps/nx-update-ts-references/src/commands/lib/dependencies.ts create mode 100644 apps/nx-update-ts-references/src/commands/lib/types.ts create mode 100644 apps/nx-update-ts-references/src/commands/update-ts-references-command.ts diff --git a/.changeset/nice-ladybugs-grin.md b/.changeset/nice-ladybugs-grin.md new file mode 100644 index 00000000..7fcbd02c --- /dev/null +++ b/.changeset/nice-ladybugs-grin.md @@ -0,0 +1,5 @@ +--- +"nx-update-ts-references": minor +--- + +Support CLI usage diff --git a/apps/nx-update-ts-references/README.md b/apps/nx-update-ts-references/README.md index 51e9c480..3d43a6a7 100644 --- a/apps/nx-update-ts-references/README.md +++ b/apps/nx-update-ts-references/README.md @@ -1,7 +1,7 @@
# nx-update-ts-references -An [Nx](https://nx.dev/) plugin that updates your [tsconfig.json references](https://www.typescriptlang.org/tsconfig/#references) based on your local dependencies. +An [Nx](https://nx.dev/) plugin + CLI that updates your [tsconfig.json references](https://www.typescriptlang.org/tsconfig/#references) based on your local dependencies. [![npm package](https://badge.fury.io/js/nx-update-ts-references.svg)](https://www.npmjs.com/package/nx-update-ts-references) [![License](https://img.shields.io/npm/l/nx-update-ts-references.svg)](https://github.com/JacobLey/leyman/blob/main/tools/nx-update-ts-references/LICENSE) @@ -39,18 +39,27 @@ Register it as a target in your `project.json`: } ``` +You can also use the API directly as a CLI: + +`pnpx update-ts-references --packageRoot ./path/to/project` + ## Usage -Due to Nx deriving the dependency graph from your `package.json`, and the rest of the fields of `tsconfig.json` being maintained, those are the only two inputs, and the `tsconfig.json` file is the out, this is a trivially cacheable operation: +Due to Nx deriving the dependency graph from your dependency's `tsconfig.json`, and the rest of the fields of `tsconfig.json` being maintained, those are the only two inputs, and the `tsconfig.json` file is the out, this is a trivially cacheable operation: ```json { + "namedInputs": { + "tsConfig": [ + "{projectRoot}/tsconfig.json" + ] + }, "targets": { "update-ts-references": { "executor": "nx-update-ts-references:update-ts-references", "cache": true, "inputs": [ - "{projectRoot}/package.json", + "^tsConfig", "{projectRoot}/tsconfig.json" ], "outputs": [ @@ -65,20 +74,13 @@ It is recommended to include this in every project that is based on typescript. ## API -The following options can be passed to the [options](https://nx.dev/reference/project-configuration#executorcommand-options) object. - -### check - -`boolean` - -When true, target will fail if the `tsconfig.json` is out of sync from the desired state. - -Defaults to false when executing locally, but defaults to true when running in CI environments. Useful for enforcing that any updates to package dependencies are reflected in the version-controlled code prior to deployment. - -### dryRun - -`boolean` +The following options can be passed as options to CLI and as the plugin [options](https://nx.dev/reference/project-configuration#executorcommand-options) object. -When true, will not actually write the `tsconfig.json` file. Can still fail if [check](#check) is true. +All CLI parameters support both camelCase and kabob-case. -Defaults to false. \ No newline at end of file +### Options: +| name | alias | type | required | default | Plugin Equivalent | description | +|------|-------|------|----------|---------|-------------------|-------------| +| `packageRoot` | `projectRoot` | string | ✅ | Current Directory | None. Inferred directly based on project | Directory containing the `tsconfig.json` file to edit/update. | +| `dryRun` | ❌ | boolean | ❌ | `false` | `check` | When true, will not actually perform update of `tsconfig.json`. | +| `ci` | `check` | boolean | ❌ | `true` when in CI environments | `dryRun` | When true, will fail if tsconfig.json is out of date instead of writing file. Implies `dry-run` | diff --git a/apps/nx-update-ts-references/bin.mjs b/apps/nx-update-ts-references/bin.mjs new file mode 100755 index 00000000..43c29614 --- /dev/null +++ b/apps/nx-update-ts-references/bin.mjs @@ -0,0 +1,3 @@ +#!/usr/bin/env node + +export { default } from './dist/bin.js'; diff --git a/apps/nx-update-ts-references/eslint.config.js b/apps/nx-update-ts-references/eslint.config.js index 99658416..948c2446 100644 --- a/apps/nx-update-ts-references/eslint.config.js +++ b/apps/nx-update-ts-references/eslint.config.js @@ -1,4 +1,13 @@ import configGenerator from '@leyman/eslint-config'; import packageJson from './package.json' with { type: 'json' }; -export default configGenerator({ configUrl: import.meta.url, packageJson }); +export default [ + ...configGenerator({ configUrl: import.meta.url, packageJson }), + { + languageOptions: { + globals: { + process: 'readonly', + }, + }, + }, +]; diff --git a/apps/nx-update-ts-references/src/bin.ts b/apps/nx-update-ts-references/src/bin.ts new file mode 100644 index 00000000..d7d08967 --- /dev/null +++ b/apps/nx-update-ts-references/src/bin.ts @@ -0,0 +1,25 @@ +import { EntryScript } from 'npm-entry-script'; +import { bind, singletonScope } from 'npm-haywire'; +import { launch } from 'npm-haywire-launcher'; +import { UpdateTsReferencesCli } from './cli.js'; +import { commandsId, commandsModule } from './commands/index.js'; +import { consoleErrorId, consoleLogId, exitCodeId } from './commands/lib/dependencies.js'; +import { dependenciesModule } from './lib/dependencies-module.js'; + +export default launch( + commandsModule + .mergeModule(dependenciesModule) + .addBinding( + bind(EntryScript) + .withDependencies([UpdateTsReferencesCli]) + .withProvider(cli => cli) + .scoped(singletonScope) + ) + .addBinding( + bind(UpdateTsReferencesCli) + .withDependencies([commandsId.supplier(), consoleLogId, consoleErrorId, exitCodeId]) + .withConstructorProvider() + .scoped(singletonScope) + ) + .toContainer() +); diff --git a/apps/nx-update-ts-references/src/cli.ts b/apps/nx-update-ts-references/src/cli.ts new file mode 100644 index 00000000..852726e8 --- /dev/null +++ b/apps/nx-update-ts-references/src/cli.ts @@ -0,0 +1,76 @@ +import { createRequire } from 'node:module'; +import { defaultImport } from 'npm-default-import'; +import { EntryScript } from 'npm-entry-script'; +import type { Supplier } from 'npm-haywire'; +import yargsDefault, { type Argv } from 'yargs'; +import type { ConsoleLog, ExitCode } from './commands/lib/dependencies.js'; +import type { AbstractCommand } from './commands/lib/types.js'; + +const packageJson = createRequire(import.meta.url)('../package.json') as { version: string }; +const yargs = defaultImport(yargsDefault) as Argv; + +/** + * UpdateTsReferences CLI. Run `./bin.mjs --help` for options. + * + * Uses `yargs` package for command line parsing and logic flow. + */ +export class UpdateTsReferencesCli extends EntryScript { + readonly #getCommands: Supplier; + readonly #logger: ConsoleLog; + readonly #errorLogger: ConsoleLog; + readonly #exitCode: ExitCode; + + public constructor( + getCommands: Supplier, + logger: ConsoleLog, + errorLogger: ConsoleLog, + exitCode: ExitCode + ) { + super(); + this.#getCommands = getCommands; + this.#logger = logger; + this.#errorLogger = errorLogger; + this.#exitCode = exitCode; + } + + /** + * Entry point to CLI script. + * + * Sets high level Yargs settings. Command/options logic is implemented in individual command modules. + * + * @param argv - process arguments + */ + public override async main(argv: string[]): Promise { + const yarg = yargs() + .scriptName('update-ts-references') + .option({ + packageRoot: { + type: 'string', + default: '.', + describe: 'Directory containing tsconfig.json and project.json', + alias: 'projectRoot', + }, + }) + .strict() + .help() + .alias('help', 'info') + .version(packageJson.version); + + for (const command of this.#getCommands()) { + yarg.command(command); + } + + await yarg.parseAsync(argv, {}, this.#yargsOutput.bind(this)); + } + + #yargsOutput(e: unknown, _argv: unknown, log: string): void { + if (e) { + this.#exitCode(1); + if (log) { + this.#errorLogger(log); + } + } else if (log) { + this.#logger(log); + } + } +} diff --git a/apps/nx-update-ts-references/src/commands/index.ts b/apps/nx-update-ts-references/src/commands/index.ts new file mode 100644 index 00000000..e1fd6038 --- /dev/null +++ b/apps/nx-update-ts-references/src/commands/index.ts @@ -0,0 +1,25 @@ +import { bind, identifier } from 'npm-haywire'; +import { readFileId } from '../lib/dependencies-module.js'; +import { updateTsReferencesId } from '../lib/update-ts-references.js'; +import { dependenciesModule, parseCwdId, projectGraphId } from './lib/dependencies.js'; +import type { AbstractCommand } from './lib/types.js'; +import { UpdateTsReferencesCommand } from './update-ts-references-command.js'; + +export const commandsId = identifier(); + +export const commandsModule = dependenciesModule + .addBinding( + bind(commandsId) + .withDependencies([UpdateTsReferencesCommand]) + .withProvider((...commands) => commands) + ) + .addBinding( + bind(UpdateTsReferencesCommand) + .withDependencies([ + updateTsReferencesId, + parseCwdId, + projectGraphId.supplier('async'), + readFileId, + ]) + .withProvider((...args) => new UpdateTsReferencesCommand(...args)) + ); diff --git a/apps/nx-update-ts-references/src/commands/lib/dependencies.ts b/apps/nx-update-ts-references/src/commands/lib/dependencies.ts new file mode 100644 index 00000000..8b394d7e --- /dev/null +++ b/apps/nx-update-ts-references/src/commands/lib/dependencies.ts @@ -0,0 +1,39 @@ +import { createProjectGraphAsync, type ProjectGraph } from '@nx/devkit'; +import { bind, createModule, identifier, singletonScope } from 'npm-haywire'; +import { type Directory, parseCwd } from 'npm-parse-cwd'; + +export type ConsoleLog = (log: unknown) => void; +export const consoleLogId = identifier().named('log'); +export const consoleErrorId = identifier().named('error'); + +export type ExitCode = (code: number) => void; +export const exitCodeId = identifier(); + +export type ParseCwd = (dir?: Directory) => Promise; +export const parseCwdId = identifier(); + +export const projectGraphId = identifier(); + +export const dependenciesModule = createModule( + bind(consoleLogId).withInstance( + // eslint-disable-next-line no-console + console.log + ) +) + .addBinding( + bind(consoleErrorId).withInstance( + // eslint-disable-next-line no-console + console.error + ) + ) + .addBinding( + bind(exitCodeId).withInstance(code => { + process.exitCode = code; + }) + ) + .addBinding(bind(parseCwdId).withInstance(parseCwd)) + .addBinding( + bind(projectGraphId) + .withAsyncGenerator(async () => createProjectGraphAsync()) + .scoped(singletonScope) + ); diff --git a/apps/nx-update-ts-references/src/commands/lib/types.ts b/apps/nx-update-ts-references/src/commands/lib/types.ts new file mode 100644 index 00000000..bd67ed2c --- /dev/null +++ b/apps/nx-update-ts-references/src/commands/lib/types.ts @@ -0,0 +1,18 @@ +import type { CommandModule } from 'yargs'; + +export interface UpdateTsReferencesCommandInput { + packageRoot: string; +} + +export type Command = Pick< + CommandModule, + 'builder' | 'command' | 'describe' | 'handler' +>; + +export interface AbstractCommand + extends Pick< + CommandModule, + 'builder' | 'command' | 'describe' + > { + handler: (args: any) => Promise; +} diff --git a/apps/nx-update-ts-references/src/commands/update-ts-references-command.ts b/apps/nx-update-ts-references/src/commands/update-ts-references-command.ts new file mode 100644 index 00000000..7e6054fd --- /dev/null +++ b/apps/nx-update-ts-references/src/commands/update-ts-references-command.ts @@ -0,0 +1,102 @@ +import type { readFile as ReadFile } from 'node:fs/promises'; +import Path from 'node:path'; +import type { ProjectGraph } from '@nx/devkit'; +import { isCI } from 'ci-info'; +import type { AsyncSupplier } from 'npm-haywire'; +import type { Argv } from 'yargs'; +import { isProjectJson } from '../lib/project-json-validator.js'; +import type { IUpdateTsReferences } from '../lib/update-ts-references.js'; +import type { ParseCwd } from './lib/dependencies.js'; +import type { Command, UpdateTsReferencesCommandInput } from './lib/types.js'; + +interface UpdateTsReferencesCommandExtendedInput extends UpdateTsReferencesCommandInput { + ci: boolean; + dryRun: boolean; +} + +/** + * Main `update-ts-references` command + */ +export class UpdateTsReferencesCommand implements Command { + public readonly command = ['$0', 'update-ts-references']; + public readonly describe = + "Write tsconfig.json's references field based on Nx detected dependencies"; + + readonly #updateTsReferences: IUpdateTsReferences; + readonly #parseCwd: ParseCwd; + readonly #projectGraph: AsyncSupplier; + readonly #readFile: typeof ReadFile; + + public constructor( + updateTsReferences: IUpdateTsReferences, + parseCwd: ParseCwd, + projectGraph: AsyncSupplier, + readFile: typeof ReadFile + ) { + this.#updateTsReferences = updateTsReferences; + this.#parseCwd = parseCwd; + this.#projectGraph = projectGraph; + this.#readFile = readFile; + + this.handler = this.handler.bind(this); + } + + public builder( + yargs: Argv + ): Argv { + return yargs + .options({ + ci: { + describe: 'Fail if file is not up to date. Implies --dry-run', + type: 'boolean', + default: isCI, + alias: 'check', + }, + dryRun: { + describe: 'Do not write file', + type: 'boolean', + default: false, + }, + }) + .strict(); + } + + public async handler(options: UpdateTsReferencesCommandExtendedInput): Promise { + const packageRoot = await this.#parseCwd(options.packageRoot); + + const dependencyRootPaths = await this.#getDependencyRootPaths(packageRoot); + + const changed = await this.#updateTsReferences({ + packageRoot, + dependencyRootPaths, + dryRun: options.ci || options.dryRun, + tsConfigPath: Path.resolve(packageRoot, 'tsconfig.json'), + }); + + if (options.ci && changed) { + throw new Error('tsconfig.json is not built'); + } + } + + async #getDependencyRootPaths(packageRoot: string): Promise { + const [projectGraph, rawProjectJson] = await Promise.all([ + this.#projectGraph(), + this.#readFile(Path.join(packageRoot, 'project.json'), 'utf8'), + ]); + + const projectJson: unknown = JSON.parse(rawProjectJson); + + if (!isProjectJson(projectJson)) { + throw new Error('Cannot parse project.json name'); + } + + const { root } = projectGraph.nodes[projectJson.name]!.data; + + return projectGraph.dependencies[projectJson.name]!.map( + ({ target }) => projectGraph.nodes[target] + ) + .filter(node => !!node) + .map(node => Path.relative(root, node.data.root)) + .map(path => Path.resolve(packageRoot, path, 'tsconfig.json')); + } +}