Skip to content

Commit

Permalink
nx-update-ts-references: Support CLI usage
Browse files Browse the repository at this point in the history
  • Loading branch information
JacobLey committed Dec 5, 2024
1 parent 045efe5 commit fb54d7d
Show file tree
Hide file tree
Showing 10 changed files with 323 additions and 19 deletions.
5 changes: 5 additions & 0 deletions .changeset/nice-ladybugs-grin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nx-update-ts-references": minor
---

Support CLI usage
38 changes: 20 additions & 18 deletions apps/nx-update-ts-references/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<div style="text-align:center">

# 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)
Expand Down Expand Up @@ -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": [
Expand All @@ -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.
### 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` |
3 changes: 3 additions & 0 deletions apps/nx-update-ts-references/bin.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/usr/bin/env node

export { default } from './dist/bin.js';
11 changes: 10 additions & 1 deletion apps/nx-update-ts-references/eslint.config.js
Original file line number Diff line number Diff line change
@@ -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',
},
},
},
];
25 changes: 25 additions & 0 deletions apps/nx-update-ts-references/src/bin.ts
Original file line number Diff line number Diff line change
@@ -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()
);
76 changes: 76 additions & 0 deletions apps/nx-update-ts-references/src/cli.ts
Original file line number Diff line number Diff line change
@@ -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<AbstractCommand[]>;
readonly #logger: ConsoleLog;
readonly #errorLogger: ConsoleLog;
readonly #exitCode: ExitCode;

public constructor(
getCommands: Supplier<AbstractCommand[]>,
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<void> {
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);
}
}
}
25 changes: 25 additions & 0 deletions apps/nx-update-ts-references/src/commands/index.ts
Original file line number Diff line number Diff line change
@@ -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<AbstractCommand[]>();

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))
);
39 changes: 39 additions & 0 deletions apps/nx-update-ts-references/src/commands/lib/dependencies.ts
Original file line number Diff line number Diff line change
@@ -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<ConsoleLog>().named('log');
export const consoleErrorId = identifier<ConsoleLog>().named('error');

export type ExitCode = (code: number) => void;
export const exitCodeId = identifier<ExitCode>();

export type ParseCwd = (dir?: Directory) => Promise<string>;
export const parseCwdId = identifier<ParseCwd>();

export const projectGraphId = identifier<ProjectGraph>();

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)
);
18 changes: 18 additions & 0 deletions apps/nx-update-ts-references/src/commands/lib/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { CommandModule } from 'yargs';

export interface UpdateTsReferencesCommandInput {
packageRoot: string;
}

export type Command<ExtendedInput extends UpdateTsReferencesCommandInput> = Pick<
CommandModule<UpdateTsReferencesCommandInput, ExtendedInput>,
'builder' | 'command' | 'describe' | 'handler'
>;

export interface AbstractCommand
extends Pick<
CommandModule<UpdateTsReferencesCommandInput, UpdateTsReferencesCommandInput>,
'builder' | 'command' | 'describe'
> {
handler: (args: any) => Promise<void>;
}
Original file line number Diff line number Diff line change
@@ -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<UpdateTsReferencesCommandExtendedInput> {
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<ProjectGraph>;
readonly #readFile: typeof ReadFile;

public constructor(
updateTsReferences: IUpdateTsReferences,
parseCwd: ParseCwd,
projectGraph: AsyncSupplier<ProjectGraph>,
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<UpdateTsReferencesCommandInput>
): Argv<UpdateTsReferencesCommandExtendedInput> {
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<void> {
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<string[]> {
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'));
}
}

0 comments on commit fb54d7d

Please sign in to comment.