From c1e5487c648f46b1c4bb5e16b687acfead326d02 Mon Sep 17 00:00:00 2001 From: Uzhanin Egor Date: Wed, 13 Mar 2024 12:44:54 +0300 Subject: [PATCH] feat: `targets.json` support --- package.json | 6 +- pnpm-lock.yaml | 16 ++++ shims.d.ts | 1 + src/commands/run/run.command.ts | 85 +++++++++++-------- src/commands/run/run.prompts.ts | 3 +- .../run/utils/independent-target-command.ts | 63 ++++++++++++++ .../managers/abstract.pkg-manager.ts | 30 +++---- src/pkg-manager/managers/bun.pkg-manager.ts | 10 ++- src/pkg-manager/managers/npm.pkg-manager.ts | 10 ++- src/pkg-manager/managers/pnpm.pkg-manager.ts | 9 +- .../managers/yarn-berry.pkg-manager.ts | 9 +- src/pkg-manager/managers/yarn.pkg-manager.ts | 7 +- src/pkg-manager/pkg-manager.consts.ts | 3 +- src/pkg-manager/pkg-manager.factory.ts | 19 +++-- src/pkg-manager/pkg-manager.module.ts | 7 +- src/pkg-manager/pkg-manager.types.ts | 4 +- src/resolver/index.ts | 1 + .../package/package.resolver.service.ts | 21 +++++ src/resolver/resolver.module.ts | 21 +++++ src/resolver/resolver.service.ts | 29 +++++++ src/resolver/resolver.types.ts | 6 ++ .../targets/targets-resolver.schema.ts | 20 +++++ .../targets/targets-resolver.service.ts | 51 +++++++++++ .../targets/targets-resolver.utils.ts | 16 ++++ 24 files changed, 368 insertions(+), 79 deletions(-) create mode 100644 src/commands/run/utils/independent-target-command.ts create mode 100644 src/resolver/index.ts create mode 100644 src/resolver/package/package.resolver.service.ts create mode 100644 src/resolver/resolver.module.ts create mode 100644 src/resolver/resolver.service.ts create mode 100644 src/resolver/resolver.types.ts create mode 100644 src/resolver/targets/targets-resolver.schema.ts create mode 100644 src/resolver/targets/targets-resolver.service.ts create mode 100644 src/resolver/targets/targets-resolver.utils.ts diff --git a/package.json b/package.json index c97e748..26f2127 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,7 @@ "vitest": "^1.1.3" }, "simple-git-hooks": { - "pre-commit": "pnpm lint-staged && pnpm typecheck" + "pre-commit": "pnpm install && pnpm lint-staged && pnpm typecheck" }, "lint-staged": { "*": "eslint . --fix" @@ -83,6 +83,7 @@ "@nestjs/core": "^10.3.3", "@nestjs/platform-express": "^10.3.3", "chalk": "^5.3.0", + "envfile": "^7.1.0", "eslint-kit": "^10.19.0", "execa": "^8.0.1", "inquirer": "^9.2.15", @@ -90,6 +91,7 @@ "inquirer-tree-prompt": "^1.1.2", "nest-commander": "^3.12.5", "path-equal": "^1.2.5", - "which": "^4.0.0" + "which": "^4.0.0", + "zod": "^3.22.4" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e03c433..df059ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: chalk: specifier: ^5.3.0 version: 5.3.0 + envfile: + specifier: ^7.1.0 + version: 7.1.0 eslint-kit: specifier: ^10.19.0 version: 10.19.0(effector@23.2.0)(eslint@8.56.0)(prettier@3.2.5)(svelte@3.59.2)(typescript@5.3.3) @@ -50,6 +53,9 @@ importers: which: specifier: ^4.0.0 version: 4.0.0 + zod: + specifier: ^3.22.4 + version: 3.22.4 devDependencies: '@antfu/ni': specifier: ^0.21.12 @@ -2762,6 +2768,12 @@ packages: engines: {node: '>=6'} dev: true + /envfile@7.1.0: + resolution: {integrity: sha512-dyH4QnnZsArCLhPASr29eqBWDvKpq0GggQFTmysTT/S9TTmt1JrEKNvTBc09Cd7ujVZQful2HBGRMe2agu7Krg==} + engines: {node: '>=8'} + hasBin: true + dev: false + /err-code@2.0.3: resolution: {integrity: sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==} dev: true @@ -7060,3 +7072,7 @@ packages: resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==} engines: {node: '>=12.20'} dev: true + + /zod@3.22.4: + resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} + dev: false diff --git a/shims.d.ts b/shims.d.ts index 680e21f..75d4509 100644 --- a/shims.d.ts +++ b/shims.d.ts @@ -1,2 +1,3 @@ declare module 'inquirer-tree-prompt' declare module 'inquirer-search-list' +declare module 'envfile' diff --git a/src/commands/run/run.command.ts b/src/commands/run/run.command.ts index d26fb53..499bb57 100644 --- a/src/commands/run/run.command.ts +++ b/src/commands/run/run.command.ts @@ -1,15 +1,17 @@ -import { ensureFile } from '@neodx/fs' +import { ensureDir } from '@neodx/fs' import { hasOwn, isEmpty, isObject } from '@neodx/std' import { Inject } from '@nestjs/common' +import { execaCommand } from 'execa' import { Command, CommandRunner, Option } from 'nest-commander' -import { dirname, resolve } from 'node:path' +import { dirname } from 'node:path' import { buildTargetInfoPrompt } from '@/commands/run/run.prompts' +import { createIndependentTargetCommand } from '@/commands/run/utils/independent-target-command' import { LoggerService } from '@/logger' import type { AbstractPackageManager } from '@/pkg-manager' import { InjectPackageManager } from '@/pkg-manager' import { PackageManager, ROOT_PROJECT } from '@/pkg-manager/pkg-manager.consts' -import type { PackageJson } from '@/shared/json' -import { readJson } from '@/shared/json' +import { ResolverService } from '@/resolver/resolver.service' +import type { AnyTarget } from '@/resolver/targets/targets-resolver.schema' import { invariant } from '@/shared/misc' export interface BaseInitOptions { @@ -26,7 +28,8 @@ export interface BaseInitOptions { export class RunCommand extends CommandRunner { constructor( @InjectPackageManager() private readonly manager: AbstractPackageManager, - @Inject(LoggerService) private readonly logger: LoggerService + @Inject(LoggerService) private readonly logger: LoggerService, + @Inject(ResolverService) private readonly resolver: ResolverService ) { super() } @@ -36,14 +39,14 @@ export class RunCommand extends CommandRunner { await this.manager.computeWorkspaceProjects() } - const [target, project = 'root'] = isEmpty(params) + const [target, project = ROOT_PROJECT] = isEmpty(params) ? await buildTargetInfoPrompt(this.manager.projects) : params invariant(target, 'Please specify a target. It cannot be empty.') const timeEnd = this.logger.time() - let packageJsonPath = resolve(process.cwd(), 'package.json') + let projectCwd = process.cwd() if (project) { const projectMeta = this.manager.projects.find( @@ -55,43 +58,57 @@ export class RunCommand extends CommandRunner { `Project ${project} not found. Please ensure it exists.` ) - packageJsonPath = resolve(projectMeta.location, 'package.json') + projectCwd = projectMeta.location } - await ensureFile(packageJsonPath) + await ensureDir(projectCwd) - const pkg = await readJson(packageJsonPath) - const projectName = project ?? ROOT_PROJECT + const { targets, type: targetType } = + await this.resolver.resolveProjectTargets(projectCwd) invariant( - isObject(pkg.scripts) && hasOwn(pkg.scripts, target), - `Could not find target ${target} in project ${projectName}.` + isObject(targets) && hasOwn(targets, target), + `Could not find target ${target} in project ${project}.` ) - const command = this.manager.createRunCommand({ - target, - project, - packageJsonPath, - args: options.args - }) - - try { - // https://github.com/oven-sh/bun/issues/6386 - const cwd = - this.manager.agent === PackageManager.BUN - ? dirname(packageJsonPath) - : process.cwd() - - await this.manager.exec(command, { stdio: 'inherit', cwd }) - } catch (error) { - this.logger.error( - `Error occurred while executing a command via ${this.manager.agent} agent.` + if (targetType === 'package-scripts') { + const command = this.manager.createRunCommand({ + target, + project, + packageJsonPath: projectCwd, + args: options.args + }) + + try { + // https://github.com/oven-sh/bun/issues/6386 + const cwd = + this.manager.agent === PackageManager.BUN + ? dirname(projectCwd) + : process.cwd() + + await this.manager.exec(command, { stdio: 'inherit', cwd }) + } catch (error) { + this.logger.error( + `Error occurred while executing a command via ${this.manager.agent} agent.` + ) + this.logger.error(error) + return + } + } + + if (targetType === 'targets') { + const { command, env, cwd, args } = createIndependentTargetCommand( + targets[target] as AnyTarget, + { defaultArgs: options.args, projectCwd } ) - this.logger.error(error) - return + + await execaCommand(`${command} ${args}`, { + cwd, + env + }) } - timeEnd(`Successfully ran target ${target} for project ${projectName}`) + timeEnd(`Successfully ran target ${target} for project ${project}`) } @Option({ diff --git a/src/commands/run/run.prompts.ts b/src/commands/run/run.prompts.ts index c71455d..f98c28f 100644 --- a/src/commands/run/run.prompts.ts +++ b/src/commands/run/run.prompts.ts @@ -3,6 +3,7 @@ import { entries, keys } from '@neodx/std' import inquirer from 'inquirer' import { ROOT_PROJECT } from '@/pkg-manager/pkg-manager.consts' import type { WorkspaceProject } from '@/pkg-manager/pkg-manager.types' +import { formatTargetCommand } from '@/resolver/targets/targets-resolver.utils' import { invariant, truncateString } from '@/shared/misc' export async function buildTargetInfoPrompt( @@ -68,7 +69,7 @@ async function buildTargetPromptTree(projects: WorkspaceProject[]): Promise<{ project: project.name, target: targetName }, - short: truncateString(script, 14) + short: truncateString(formatTargetCommand(script), 14) }) ) diff --git a/src/commands/run/utils/independent-target-command.ts b/src/commands/run/utils/independent-target-command.ts new file mode 100644 index 0000000..1b49340 --- /dev/null +++ b/src/commands/run/utils/independent-target-command.ts @@ -0,0 +1,63 @@ +import { compact } from '@neodx/std' +import envfile from 'envfile' +import type { AnyTarget } from '@/resolver/targets/targets-resolver.schema' +import { invariant, toAbsolutePath } from '@/shared/misc' + +interface CreateIndependentCommandOptions { + defaultArgs?: string + projectCwd: string +} + +enum TargetSeparators { + NORMAL = ' && ', + PARALLEL = ' & ' +} + +export function createIndependentTargetCommand( + opts: AnyTarget, + { defaultArgs, projectCwd }: CreateIndependentCommandOptions +) { + const normalizeCommand = ({ + command, + commands, + parallel + }: Partial): string => { + invariant( + command ?? commands, + 'Either "command" or "commands" must be provided.' + ) + + const result = Array.isArray(command) ? command.join(' ') : command + + if (!result) { + const separator = parallel + ? TargetSeparators.PARALLEL + : TargetSeparators.NORMAL + + return commands!.join(separator) + } + + return result + } + + const normalizeEnv = ({ + env, + envFile + }: Partial): Record | undefined => { + if (envFile) return envfile.parse(toAbsolutePath(envFile)) + if (env) return env + return undefined + } + + const command = normalizeCommand(opts) + const args = compact([defaultArgs, opts.args]).join(' ') + const env = normalizeEnv(opts) + const cwd = toAbsolutePath(opts.cwd ?? projectCwd ?? process.cwd()) + + return { + command, + args, + cwd, + env + } +} diff --git a/src/pkg-manager/managers/abstract.pkg-manager.ts b/src/pkg-manager/managers/abstract.pkg-manager.ts index 16aea67..c02c95f 100644 --- a/src/pkg-manager/managers/abstract.pkg-manager.ts +++ b/src/pkg-manager/managers/abstract.pkg-manager.ts @@ -1,19 +1,25 @@ -import { isObject, isTypeOfString } from '@neodx/std' +import { isTypeOfString } from '@neodx/std' import type { Options as ExecaOptions } from 'execa' import { execaCommand as $ } from 'execa' -import { resolve } from 'node:path' import { ROOT_PROJECT } from '@/pkg-manager/pkg-manager.consts' +import type { PackageManagerFactoryOptions } from '@/pkg-manager/pkg-manager.factory' import type { RunCommandOptions, WorkspaceProject } from '@/pkg-manager/pkg-manager.types' -import type { PackageJson } from '@/shared/json' -import { readJson } from '@/shared/json' +import type { ResolverService } from '@/resolver/resolver.service' export abstract class AbstractPackageManager { + // TODO: map set public projects: WorkspaceProject[] = [] + protected readonly resolver: ResolverService + + constructor( + protected options: PackageManagerFactoryOptions, + private command: string + ) { + this.resolver = options.resolver - constructor(private command: string) { this.computeWorkspaceProjects() } @@ -29,17 +35,6 @@ export abstract class AbstractPackageManager { return output.stdout as string } - public async resolveProjectTargets( - projectPath: string - ): Promise> { - const pkgJsonPath = resolve(projectPath, 'package.json') - const pkg = await readJson(pkgJsonPath) - - if (!isObject(pkg.scripts)) return {} - - return pkg.scripts - } - public get agent() { return this.command } @@ -48,11 +43,12 @@ export abstract class AbstractPackageManager { workspaces: WorkspaceProject[] = [] ): Promise { const cwd = process.cwd() + const { targets } = await this.resolver.resolveProjectTargets(cwd) const root = { name: ROOT_PROJECT, location: cwd, - targets: await this.resolveProjectTargets(cwd) + targets } satisfies WorkspaceProject this.projects = [root, ...workspaces] diff --git a/src/pkg-manager/managers/bun.pkg-manager.ts b/src/pkg-manager/managers/bun.pkg-manager.ts index 0e88c49..feb1eed 100644 --- a/src/pkg-manager/managers/bun.pkg-manager.ts +++ b/src/pkg-manager/managers/bun.pkg-manager.ts @@ -3,7 +3,8 @@ import { isTypeOfString } from '@neodx/std' import { dirname, resolve } from 'node:path' import * as process from 'process' import { AbstractPackageManager } from '@/pkg-manager/managers/abstract.pkg-manager' -import { PackageManager, ROOT_PROJECT } from '@/pkg-manager/pkg-manager.consts' +import { PackageManager } from '@/pkg-manager/pkg-manager.consts' +import type { PackageManagerFactoryOptions } from '@/pkg-manager/pkg-manager.factory' import type { RunCommandOptions } from '@/pkg-manager/pkg-manager.types' import type { PackageJson } from '@/shared/json' import { readJson } from '@/shared/json' @@ -11,8 +12,8 @@ import { readJson } from '@/shared/json' // TODO: split file structure to modules export class BunPackageManager extends AbstractPackageManager { - constructor() { - super(PackageManager.BUN) + constructor(opts: PackageManagerFactoryOptions) { + super(opts, PackageManager.BUN) } public async computeWorkspaceProjects(): Promise { @@ -35,7 +36,8 @@ export class BunPackageManager extends AbstractPackageManager { const workspaceName = scopedPkgJson.name ?? null const workspaceDir = dirname(pattern) - const targets = await this.resolveProjectTargets(workspaceDir) + const { targets } = + await this.resolver.resolveProjectTargets(workspaceDir) return { name: workspaceName, diff --git a/src/pkg-manager/managers/npm.pkg-manager.ts b/src/pkg-manager/managers/npm.pkg-manager.ts index d5530ed..5120eeb 100644 --- a/src/pkg-manager/managers/npm.pkg-manager.ts +++ b/src/pkg-manager/managers/npm.pkg-manager.ts @@ -1,8 +1,10 @@ import { parseJson } from '@neodx/fs' import { entries, hasOwn, isObject, isTypeOfString } from '@neodx/std' +import { Injectable } from '@nestjs/common' import { resolve } from 'node:path' import { AbstractPackageManager } from '@/pkg-manager/managers/abstract.pkg-manager' import { PackageManager, ROOT_PROJECT } from '@/pkg-manager/pkg-manager.consts' +import type { PackageManagerFactoryOptions } from '@/pkg-manager/pkg-manager.factory' import type { RunCommandOptions } from '@/pkg-manager/pkg-manager.types' interface NpmWorkspaceMetadata { @@ -18,9 +20,10 @@ interface NpmWorkspaceMetadata { > } +@Injectable() export class NpmPackageManager extends AbstractPackageManager { - constructor() { - super(PackageManager.NPM) + constructor(opts: PackageManagerFactoryOptions) { + super(opts, PackageManager.NPM) } public async computeWorkspaceProjects(): Promise { @@ -47,7 +50,8 @@ export class NpmPackageManager extends AbstractPackageManager { const normalizedPath = dependency.resolved.replace(/^file:..\//, '') const absolutePath = resolve(cwd, normalizedPath) - const targets = await this.resolveProjectTargets(absolutePath) + const { targets } = + await this.resolver.resolveProjectTargets(absolutePath) return { name, diff --git a/src/pkg-manager/managers/pnpm.pkg-manager.ts b/src/pkg-manager/managers/pnpm.pkg-manager.ts index 8373b38..c5a5a5c 100644 --- a/src/pkg-manager/managers/pnpm.pkg-manager.ts +++ b/src/pkg-manager/managers/pnpm.pkg-manager.ts @@ -3,6 +3,7 @@ import { isTruthy, isTypeOfString } from '@neodx/std' import { pathEqual } from 'path-equal' import { AbstractPackageManager } from '@/pkg-manager/managers/abstract.pkg-manager' import { PackageManager, ROOT_PROJECT } from '@/pkg-manager/pkg-manager.consts' +import type { PackageManagerFactoryOptions } from '@/pkg-manager/pkg-manager.factory' import type { RunCommandOptions } from '@/pkg-manager/pkg-manager.types' type PnpmWorkspaceMeta = Array<{ @@ -13,12 +14,12 @@ type PnpmWorkspaceMeta = Array<{ }> export class PnpmPackageManager extends AbstractPackageManager { - constructor() { - super(PackageManager.PNPM) + constructor(opts: PackageManagerFactoryOptions) { + super(opts, PackageManager.PNPM) } public async computeWorkspaceProjects(): Promise { - const output = await this.exec('list --recursive --depth -1 --json ') + const output = await this.exec('list --recursive --depth -1 --json') const workspaces = parseJson(output) if (!Array.isArray(workspaces)) { @@ -31,7 +32,7 @@ export class PnpmPackageManager extends AbstractPackageManager { if (isRoot) return null - const targets = await this.resolveProjectTargets(path) + const { targets } = await this.resolver.resolveProjectTargets(path) return { name, location: path, diff --git a/src/pkg-manager/managers/yarn-berry.pkg-manager.ts b/src/pkg-manager/managers/yarn-berry.pkg-manager.ts index 06d2916..d2cae91 100644 --- a/src/pkg-manager/managers/yarn-berry.pkg-manager.ts +++ b/src/pkg-manager/managers/yarn-berry.pkg-manager.ts @@ -2,14 +2,15 @@ import { parseJson } from '@neodx/fs' import { isTruthy, isTypeOfString } from '@neodx/std' import { AbstractPackageManager } from '@/pkg-manager/managers/abstract.pkg-manager' import { PackageManager, ROOT_PROJECT } from '@/pkg-manager/pkg-manager.consts' +import type { PackageManagerFactoryOptions } from '@/pkg-manager/pkg-manager.factory' import type { RunCommandOptions, WorkspaceProject } from '@/pkg-manager/pkg-manager.types' export class YarnBerryPackageManager extends AbstractPackageManager { - constructor() { - super(PackageManager.YARN_BERRY) + constructor(opts: PackageManagerFactoryOptions) { + super(opts, PackageManager.YARN_BERRY) } public async computeWorkspaceProjects(): Promise { @@ -19,7 +20,9 @@ export class YarnBerryPackageManager extends AbstractPackageManager { const workspaces = await Promise.all( serializedLines.map(async (serializedMeta) => { const project = parseJson(serializedMeta) - const targets = await this.resolveProjectTargets(project.location) + const { targets } = await this.resolver.resolveProjectTargets( + project.location + ) return { ...project, targets } }) diff --git a/src/pkg-manager/managers/yarn.pkg-manager.ts b/src/pkg-manager/managers/yarn.pkg-manager.ts index eda0392..7850eb3 100644 --- a/src/pkg-manager/managers/yarn.pkg-manager.ts +++ b/src/pkg-manager/managers/yarn.pkg-manager.ts @@ -2,6 +2,7 @@ import { parseJson } from '@neodx/fs' import { entries, isObject, isTypeOfString } from '@neodx/std' import { AbstractPackageManager } from '@/pkg-manager/managers/abstract.pkg-manager' import { PackageManager, ROOT_PROJECT } from '@/pkg-manager/pkg-manager.consts' +import type { PackageManagerFactoryOptions } from '@/pkg-manager/pkg-manager.factory' import type { RunCommandOptions } from '@/pkg-manager/pkg-manager.types' import { toAbsolutePath } from '@/shared/misc' @@ -15,8 +16,8 @@ type YarnWorkspaceMeta = Record< > export class YarnPackageManager extends AbstractPackageManager { - constructor() { - super(PackageManager.YARN) + constructor(opts: PackageManagerFactoryOptions) { + super(opts, PackageManager.YARN) } public async computeWorkspaceProjects(): Promise { @@ -36,7 +37,7 @@ export class YarnPackageManager extends AbstractPackageManager { const yarnWorkspaces = await Promise.all( workspacesEntries.map(async ([name, metadata]) => { const location = toAbsolutePath(metadata.location) - const targets = await this.resolveProjectTargets(location) + const { targets } = await this.resolver.resolveProjectTargets(location) return { name, diff --git a/src/pkg-manager/pkg-manager.consts.ts b/src/pkg-manager/pkg-manager.consts.ts index ffabf48..c1e62c8 100644 --- a/src/pkg-manager/pkg-manager.consts.ts +++ b/src/pkg-manager/pkg-manager.consts.ts @@ -3,6 +3,7 @@ import { BunPackageManager } from '@/pkg-manager/managers/bun.pkg-manager' import { NpmPackageManager } from '@/pkg-manager/managers/npm.pkg-manager' import { PnpmPackageManager } from '@/pkg-manager/managers/pnpm.pkg-manager' import { YarnPackageManager } from '@/pkg-manager/managers/yarn.pkg-manager' +import type { PackageManagerFactoryOptions } from '@/pkg-manager/pkg-manager.factory' export const PACKAGE_MANAGER = 'PACKAGE_MANAGER' as const @@ -17,7 +18,7 @@ export enum PackageManager { interface PackageManagerMatcher { lockFile: string name: PackageManager - manager: new () => AbstractPackageManager + manager: new (opts: PackageManagerFactoryOptions) => AbstractPackageManager } export const packageManagerMatchers = [ diff --git a/src/pkg-manager/pkg-manager.factory.ts b/src/pkg-manager/pkg-manager.factory.ts index 711c14f..7f12adb 100644 --- a/src/pkg-manager/pkg-manager.factory.ts +++ b/src/pkg-manager/pkg-manager.factory.ts @@ -1,5 +1,5 @@ import { ensureFile, scan } from '@neodx/fs' -import { isTypeOfString, uniq, values } from '@neodx/std' +import { includesIn, isTypeOfString, uniq, values } from '@neodx/std' import chalk from 'chalk' import { execaCommand as $ } from 'execa' import { basename, resolve } from 'node:path' @@ -10,15 +10,22 @@ import { PackageManager, packageManagerMatchers } from '@/pkg-manager/pkg-manager.consts' +import type { ResolverService } from '@/resolver/resolver.service' import type { PackageJson } from '@/shared/json' import { readJson } from '@/shared/json' import { invariant } from '@/shared/misc' import { cmdExists } from '@/shared/sh' +export interface PackageManagerFactoryOptions { + resolver: ResolverService +} + export class PackageManagerFactory { private static logger = new LoggerService() - public static async detect(): Promise { + public static async detect( + opts: PackageManagerFactoryOptions + ): Promise { let programmaticAgent: PackageManager const agents = values(PackageManager) @@ -48,10 +55,12 @@ export class PackageManagerFactory { Number.parseInt(version, 10) > 1 if (isYarnBerry) { - return new YarnBerryPackageManager() + return new YarnBerryPackageManager(opts) } - if (agents.includes(agent)) { + const includesAgent = includesIn(agents) + + if (includesAgent(agent)) { programmaticAgent = agent } } @@ -76,6 +85,6 @@ export class PackageManagerFactory { }) } - return new match.manager() + return new match.manager(opts) } } diff --git a/src/pkg-manager/pkg-manager.module.ts b/src/pkg-manager/pkg-manager.module.ts index 4ad703d..529e24f 100644 --- a/src/pkg-manager/pkg-manager.module.ts +++ b/src/pkg-manager/pkg-manager.module.ts @@ -1,12 +1,17 @@ import { Module } from '@nestjs/common' import { PackageManagerFactory } from '@/pkg-manager/pkg-manager.factory' +import { ResolverService } from '@/resolver/resolver.service' import { PACKAGE_MANAGER } from './pkg-manager.consts' @Module({ providers: [ { provide: PACKAGE_MANAGER, - useFactory: () => PackageManagerFactory.detect() + useFactory: (resolver: ResolverService) => + PackageManagerFactory.detect({ + resolver + }), + inject: [ResolverService] } ], exports: [PACKAGE_MANAGER] diff --git a/src/pkg-manager/pkg-manager.types.ts b/src/pkg-manager/pkg-manager.types.ts index f78ade3..576a585 100644 --- a/src/pkg-manager/pkg-manager.types.ts +++ b/src/pkg-manager/pkg-manager.types.ts @@ -1,7 +1,9 @@ +import type { TargetOptions } from '@/resolver/targets/targets-resolver.schema' + export interface WorkspaceProject { name: string | null location: string - targets: Record + targets: TargetOptions } export interface RunCommandOptions { diff --git a/src/resolver/index.ts b/src/resolver/index.ts new file mode 100644 index 0000000..879bedb --- /dev/null +++ b/src/resolver/index.ts @@ -0,0 +1 @@ +export { ResolverModule } from './resolver.module' diff --git a/src/resolver/package/package.resolver.service.ts b/src/resolver/package/package.resolver.service.ts new file mode 100644 index 0000000..1a491ad --- /dev/null +++ b/src/resolver/package/package.resolver.service.ts @@ -0,0 +1,21 @@ +import { assertFile } from '@neodx/fs' +import type { AnyRecord } from '@neodx/std' +import { Injectable } from '@nestjs/common' +import { resolve } from 'node:path' +import type { PackageJson } from '@/shared/json' +import { readJson } from '@/shared/json' + +@Injectable() +export class PackageJsonResolverService { + public async resolvePackageJsonScripts( + projectCwd: string + ): Promise> { + const packageJsonPath = resolve(projectCwd, 'package.json') + + await assertFile(packageJsonPath) + + const pkg = await readJson(packageJsonPath) + + return pkg.scripts ?? {} + } +} diff --git a/src/resolver/resolver.module.ts b/src/resolver/resolver.module.ts new file mode 100644 index 0000000..f81eeb5 --- /dev/null +++ b/src/resolver/resolver.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common' +import { PackageJsonResolverService } from '@/resolver/package/package.resolver.service' +import { ResolverService } from '@/resolver/resolver.service' +import { TargetsSchema } from '@/resolver/targets/targets-resolver.schema' +import { TargetsResolverService } from '@/resolver/targets/targets-resolver.service' + +@Module({ + providers: [ + ResolverService, + TargetsResolverService, + PackageJsonResolverService + ], + exports: [ + ResolverService, + { + provide: 'TARGET_JSON_SCHEMA', + useValue: TargetsSchema + } + ] +}) +export class ResolverModule {} diff --git a/src/resolver/resolver.service.ts b/src/resolver/resolver.service.ts new file mode 100644 index 0000000..2e5595c --- /dev/null +++ b/src/resolver/resolver.service.ts @@ -0,0 +1,29 @@ +import { isNull } from '@neodx/std' +import { Injectable } from '@nestjs/common' +import type { PackageJsonResolverService } from '@/resolver/package/package.resolver.service' +import type { ResolvedTargets } from '@/resolver/resolver.types' +import type { TargetsResolverService } from '@/resolver/targets/targets-resolver.service' + +@Injectable() +export class ResolverService { + constructor( + private readonly targetsResolver: TargetsResolverService, + private readonly pkgResolver: PackageJsonResolverService + ) {} + + public async resolveProjectTargets(cwd: string): Promise { + const targets = await this.targetsResolver.resolveProjectTargets(cwd) + + if (isNull(targets)) { + return { + type: 'package-scripts', + targets: await this.pkgResolver.resolvePackageJsonScripts(cwd) + } + } + + return { + type: 'targets', + targets + } + } +} diff --git a/src/resolver/resolver.types.ts b/src/resolver/resolver.types.ts new file mode 100644 index 0000000..f873169 --- /dev/null +++ b/src/resolver/resolver.types.ts @@ -0,0 +1,6 @@ +import type { TargetOptions } from '@/resolver/targets/targets-resolver.schema' + +export interface ResolvedTargets { + type: 'package-scripts' | 'targets' + targets: TargetOptions +} diff --git a/src/resolver/targets/targets-resolver.schema.ts b/src/resolver/targets/targets-resolver.schema.ts new file mode 100644 index 0000000..a996e61 --- /dev/null +++ b/src/resolver/targets/targets-resolver.schema.ts @@ -0,0 +1,20 @@ +import { z } from 'zod' + +export const CommandSchema = z.string() + +export const TargetSchema = z.object({ + command: CommandSchema.or(z.array(CommandSchema)).optional(), + description: z.string().optional(), + commands: z.array(CommandSchema).optional(), + args: z.string().optional().optional(), + cwd: z.string().optional(), + parallel: z.string().optional(), + env: z.record(z.string()).optional(), + envFile: z.string().optional(), + readyWhen: z.string().optional() +}) + +export const TargetsSchema = z.record(z.string().or(TargetSchema)) + +export type TargetOptions = z.infer +export type AnyTarget = z.infer diff --git a/src/resolver/targets/targets-resolver.service.ts b/src/resolver/targets/targets-resolver.service.ts new file mode 100644 index 0000000..0a1f7f0 --- /dev/null +++ b/src/resolver/targets/targets-resolver.service.ts @@ -0,0 +1,51 @@ +import { assertFile, exists } from '@neodx/fs' +import type { AnyRecord } from '@neodx/std' +import { Inject, Injectable } from '@nestjs/common' +import { resolve } from 'node:path' +import { z } from 'zod' +import { LoggerService } from '@/logger' +import { readJson } from '@/shared/json' +import type { TargetOptions } from './targets-resolver.schema' +import { TargetsSchema } from './targets-resolver.schema' + +@Injectable() +export class TargetsResolverService { + constructor( + @Inject(LoggerService) private readonly loggerService: LoggerService + ) {} + + public async resolveProjectTargets( + projectCwd: string + ): Promise { + const targetJsonPath = resolve(projectCwd, 'commands.json') + + if (!exists(targetJsonPath)) return null + + await assertFile(targetJsonPath) + + const targetsJSON = await readJson(targetJsonPath) + const targets = this.validateTargetsSchema(targetsJSON, projectCwd) + + return targets.values + } + + public validateTargetsSchema(rawCommands: unknown, projectPath: string) { + try { + return { values: TargetsSchema.parse(rawCommands) } + } catch (error_) { + const isZodError = error_ instanceof z.ZodError + + if (!isZodError) { + this.loggerService.error('Unknown error parsing commands.json') + throw error_ + } + + this.loggerService.error( + `Invalid commands.json file. (${projectPath}) See below for detailed info. \n` + ) + this.loggerService.error(error_.format()) + + process.exit(1) + } + } +} diff --git a/src/resolver/targets/targets-resolver.utils.ts b/src/resolver/targets/targets-resolver.utils.ts new file mode 100644 index 0000000..19ef8b9 --- /dev/null +++ b/src/resolver/targets/targets-resolver.utils.ts @@ -0,0 +1,16 @@ +import { isTypeOfString } from '@neodx/std' +import type { AnyTarget } from '@/resolver/targets/targets-resolver.schema' + +export function formatTargetCommand( + target: Target +): string { + if (isTypeOfString(target)) return target + + const { command, commands } = target + + return command + ? (isTypeOfString(command) + ? command + : command.join(' ')) + : commands!.join(' ') +}