diff --git a/e2e/cli-build.test.ts b/e2e/cli-build.test.ts new file mode 100644 index 0000000..aff125c --- /dev/null +++ b/e2e/cli-build.test.ts @@ -0,0 +1,127 @@ +import { existsSync, mkdirSync, rmSync } from "node:fs"; +import { resolve } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { runCli } from "./helpers/run-cli"; + +const tmpDir = resolve(__dirname, "../.tmp-e2e-build"); + +beforeAll(async () => { + mkdirSync(tmpDir, { recursive: true }); +}); + +afterAll(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("nf build (TypeScript, no server)", () => { + const projectDir = resolve(tmpDir, "build-ts-no-server"); + const appDir = resolve(projectDir, "build-app"); + + beforeAll(async () => { + mkdirSync(projectDir, { recursive: true }); + + await runCli([ + "new", + "--name", + "build-app", + "--language", + "ts", + "--package-manager", + "npm", + "--strict", + "--no-server", + "--no-init-functions", + "--no-skip-install", + "-d", + projectDir, + ]); + }); + + it("should run the build command", async () => { + const { exitCode } = await runCli(["build", "-d", appDir]); + + expect(exitCode).toBe(0); + expect(existsSync(resolve(appDir, ".nanoforge", "client"))).toBe(true); + expect(existsSync(resolve(appDir, ".nanoforge", "client", "main.js"))).toBe(true); + }); + + it("should accept --config option", async () => { + const { exitCode } = await runCli(["build", "-d", appDir, "--config", "nanoforge.config.json"]); + + expect(exitCode).toBe(0); + expect(existsSync(resolve(appDir, ".nanoforge", "client", "main.js"))).toBe(true); + }); + + it("should accept --client-outDir option", async () => { + const { exitCode } = await runCli(["build", "-d", appDir, "--client-outDir", "custom-out"]); + + expect(exitCode).toBe(0); + expect(existsSync(resolve(appDir, "custom-out"))).toBe(true); + expect(existsSync(resolve(appDir, "custom-out", "main.js"))).toBe(true); + }); +}); + +describe("nf build (TypeScript, with server)", () => { + const projectDir = resolve(tmpDir, "build-ts-with-server"); + const appDir = resolve(projectDir, "build-server-app"); + + beforeAll(async () => { + mkdirSync(projectDir, { recursive: true }); + + await runCli([ + "new", + "--name", + "build-server-app", + "--language", + "ts", + "--package-manager", + "npm", + "--no-strict", + "--server", + "--no-init-functions", + "--no-skip-install", + "-d", + projectDir, + ]); + }); + + it("should run the build command with server enabled", async () => { + const { exitCode } = await runCli(["build", "-d", appDir]); + + expect(exitCode).toBe(0); + expect(existsSync(resolve(appDir, ".nanoforge", "client", "main.js"))).toBe(true); + expect(existsSync(resolve(appDir, ".nanoforge", "server", "main.js"))).toBe(true); + }); + + it("should accept --server-outDir option", async () => { + const { exitCode } = await runCli([ + "build", + "-d", + appDir, + "--server-outDir", + "custom-server-out", + ]); + + expect(exitCode).toBe(0); + expect(existsSync(resolve(appDir, "custom-server-out"))).toBe(true); + expect(existsSync(resolve(appDir, "custom-server-out", "main.js"))).toBe(true); + }); +}); + +describe("nf build (with invalid directory)", () => { + it("should fail when directory does not exist", async () => { + const { exitCode } = await runCli(["build", "-d", resolve(tmpDir, "nonexistent")]); + + expect(exitCode).not.toBe(0); + }); + + it("should fail when no config file is found", async () => { + const emptyDir = resolve(tmpDir, "empty-dir"); + mkdirSync(emptyDir, { recursive: true }); + + const { exitCode } = await runCli(["build", "-d", emptyDir]); + + expect(exitCode).not.toBe(0); + }); +}); diff --git a/e2e/cli-install.test.ts b/e2e/cli-install.test.ts new file mode 100644 index 0000000..8c1bcfc --- /dev/null +++ b/e2e/cli-install.test.ts @@ -0,0 +1,132 @@ +import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs"; +import { resolve } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; + +import { runCli } from "./helpers/run-cli"; + +const tmpDir = resolve(__dirname, "../.tmp-e2e-install"); + +beforeAll(async () => { + mkdirSync(tmpDir, { recursive: true }); +}); + +afterAll(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("nf install (with existing project)", () => { + const projectDir = resolve(tmpDir, "install-project"); + const appDir = resolve(projectDir, "install-app"); + + beforeAll(async () => { + mkdirSync(projectDir, { recursive: true }); + + await runCli([ + "new", + "--name", + "install-app", + "--language", + "ts", + "--package-manager", + "npm", + "--strict", + "--no-server", + "--no-init-functions", + "--no-skip-install", + "-d", + projectDir, + ]); + }); + + it("should run the install command with a library name", async () => { + const { exitCode } = await runCli(["install", "@nanoforge-dev/network-client", "-d", appDir]); + + expect(exitCode).toBe(0); + + const pkgJson = JSON.parse(readFileSync(resolve(appDir, "package.json"), "utf-8")); + expect(pkgJson.dependencies).toHaveProperty("@nanoforge-dev/network-client"); + expect(existsSync(resolve(appDir, "node_modules", "@nanoforge-dev", "network-client"))).toBe( + true, + ); + }); + + it("should run the install command with multiple library names", async () => { + const { exitCode } = await runCli([ + "install", + "@nanoforge-dev/network-client", + "@nanoforge-dev/network-server", + "-d", + appDir, + ]); + + expect(exitCode).toBe(0); + + const pkgJson = JSON.parse(readFileSync(resolve(appDir, "package.json"), "utf-8")); + expect(pkgJson.dependencies).toHaveProperty("@nanoforge-dev/network-client"); + expect(pkgJson.dependencies).toHaveProperty("@nanoforge-dev/network-server"); + expect(existsSync(resolve(appDir, "node_modules", "@nanoforge-dev", "network-client"))).toBe( + true, + ); + expect(existsSync(resolve(appDir, "node_modules", "@nanoforge-dev", "network-server"))).toBe( + true, + ); + }); + + it("should work with the add alias", async () => { + const { exitCode } = await runCli(["add", "@nanoforge-dev/network-client", "-d", appDir]); + + expect(exitCode).toBe(0); + + const pkgJson = JSON.parse(readFileSync(resolve(appDir, "package.json"), "utf-8")); + expect(pkgJson.dependencies).toHaveProperty("@nanoforge-dev/network-client"); + expect(existsSync(resolve(appDir, "node_modules", "@nanoforge-dev", "network-client"))).toBe( + true, + ); + }); +}); + +describe("nf install (with invalid directory)", () => { + it("should fail when directory does not exist", async () => { + const { exitCode } = await runCli([ + "install", + "some-lib", + "-d", + resolve(tmpDir, "nonexistent"), + ]); + + expect(exitCode).not.toBe(0); + }); +}); + +describe("nf install (without library name)", () => { + const projectDir = resolve(tmpDir, "install-no-name"); + const appDir = resolve(projectDir, "install-no-name-app"); + + beforeAll(async () => { + mkdirSync(projectDir, { recursive: true }); + + await runCli([ + "new", + "--name", + "install-no-name-app", + "--language", + "ts", + "--package-manager", + "npm", + "--strict", + "--no-server", + "--no-init-functions", + "--no-skip-install", + "-d", + projectDir, + ]); + }); + + it("should prompt or fail when no library name is provided", async () => { + const { stdout, stderr, killed } = await runCli(["install", "-d", appDir], { + timeout: 3_000, + }); + + expect(killed || (stdout + stderr).length > 0).toBe(true); + }); +}); diff --git a/src/action/abstract.action.ts b/src/action/abstract.action.ts index 3e489f8..27be51a 100644 --- a/src/action/abstract.action.ts +++ b/src/action/abstract.action.ts @@ -1,5 +1,55 @@ import { type Input } from "@lib/input"; +import { Prefixes } from "@lib/ui"; + +import { handleActionError } from "@utils/errors"; + +export interface HandleResult { + success?: boolean; + keepAlive?: boolean; +} export abstract class AbstractAction { - public abstract handle(args?: Input, options?: Input, extraFlags?: string[]): Promise; + protected abstract startMessage: string; + protected abstract successMessage: string; + protected abstract failureMessage: string; + + public abstract handle( + args?: Input, + options?: Input, + extraFlags?: string[], + ): Promise; + + public async run(args?: Input, options?: Input, extraFlags?: string[]): Promise { + this.logStart(); + + try { + const result = await this.handle(args, options, extraFlags); + this.resolveResult(result); + } catch (error: unknown) { + handleActionError(this.failureMessage, error); + } + } + + private logStart(): void { + console.info(); + console.info(`${Prefixes.INFO} ${this.startMessage}`); + console.info(); + } + + private resolveResult(result: HandleResult): void { + const success = result?.success !== false; + const keepAlive = result?.keepAlive === true; + + if (keepAlive) return; + + console.info(); + + if (!success) { + if (this.failureMessage) console.error(this.failureMessage); + process.exit(1); + } + + if (this.successMessage) console.info(this.successMessage); + process.exit(0); + } } diff --git a/src/action/actions/build.action.ts b/src/action/actions/build.action.ts index 8abd647..25b646a 100644 --- a/src/action/actions/build.action.ts +++ b/src/action/actions/build.action.ts @@ -1,120 +1,134 @@ -import * as ansis from "ansis"; import { watch } from "chokidar"; -import * as console from "node:console"; -import { dirname, join } from "path"; +import { dirname, join } from "node:path"; -import { type BuildConfig } from "@lib/config"; -import { type Input, getDirectoryInput } from "@lib/input"; -import { getWatchInput } from "@lib/input"; -import { PackageManager, PackageManagerFactory } from "@lib/package-manager"; +import { type BuildConfig, type Config } from "@lib/config"; +import { type Input, getDirectoryInput, getStringInput, getWatchInput } from "@lib/input"; +import { PackageManagerFactory, PackageManagerName } from "@lib/package-manager"; import { Messages } from "@lib/ui"; import { getCwd } from "@utils/path"; +import { runSafe } from "@utils/run-safe"; import { getConfig } from "~/action/common/config"; -import { AbstractAction } from "../abstract.action"; +import { AbstractAction, type HandleResult } from "../abstract.action"; -interface BuildPart { +interface BuildTarget { + name: string; entry: string; output: string; - target: "client" | "server"; + platform: "browser" | "node"; } export class BuildAction extends AbstractAction { - public async handle(_args: Input, options: Input) { - console.info(Messages.BUILD_START); - console.info(); + protected startMessage = Messages.BUILD_START; + protected successMessage = Messages.BUILD_SUCCESS; + protected failureMessage = Messages.BUILD_FAILED; + + public async handle(_args: Input, options: Input): Promise { + const directory = getDirectoryInput(options); + const config = await getConfig(options, directory); + const isWatch = getWatchInput(options); + + const targets = this.resolveTargets(config, options); + const results = await this.buildAll(targets, directory, isWatch); - try { - const directory = getDirectoryInput(options); - const config = await getConfig(options, directory); - const watch = getWatchInput(options); + if (isWatch) { + return this.enterWatchMode(); + } + + return { success: results.every(Boolean) }; + } - const client = getPart( + private resolveTargets(config: Config, options: Input): BuildTarget[] { + const targets: BuildTarget[] = [ + this.createTarget( + "Client", config.client.build, - options.get("clientDirectory")?.value as string | undefined, - "client", + "browser", + getStringInput(options, "clientDirectory"), + ), + ]; + + if (config.server.enable) { + targets.push( + this.createTarget( + "Server", + config.server.build, + "node", + getStringInput(options, "serverDirectory"), + ), ); - let res = await buildPart("Client", client, directory, { watch }); + } - if (config.server.enable) { - const server = getPart( - config.server.build, - options.get("serverDirectory")?.value as string | undefined, - "server", - ); - res = (await buildPart("Server", server, directory, { watch })) ? res : false; - } - - console.info(); - - if (watch) { - console.info(Messages.BUILD_WATCH_START); - console.info(); - return; - } - - if (!res) { - console.info(Messages.BUILD_FAILED); - process.exit(1); - } - console.info(Messages.BUILD_SUCCESS); - process.exit(0); - } catch (e) { - console.error(e); - process.exit(1); + return targets; + } + + private createTarget( + name: string, + config: BuildConfig, + platform: "browser" | "node", + outDirOverride?: string, + ): BuildTarget { + return { + name, + entry: config.entryFile, + output: outDirOverride || config.outDir, + platform, + }; + } + + private async buildAll( + targets: BuildTarget[], + directory: string, + isWatch: boolean, + ): Promise { + const results: boolean[] = []; + for (const target of targets) { + const result = await this.buildTarget(target, directory, isWatch); + results.push(result); } + return results; } -} -const getPart = ( - config: BuildConfig, - directoryOption: string | undefined, - target: "client" | "server", -): BuildPart => { - return { - entry: config.entryFile, - output: directoryOption || config.outDir, - target: target, - }; -}; - -const buildPart = async ( - name: string, - part: BuildPart, - directory: string, - options?: { watch?: boolean }, -) => { - const packageManagerName = PackageManager.LOCAL_BUN; - - const packageManager = PackageManagerFactory.create(packageManagerName); - - const build = async (watch = false) => { - try { - return await packageManager.build( - name, - directory, - part.entry, - part.output, - [ - "--asset-naming", - "[name].[ext]", - "--target", - part.target === "client" ? "browser" : "node", - ], - watch, + private async buildTarget( + target: BuildTarget, + directory: string, + isWatch: boolean, + ): Promise { + const packageManager = PackageManagerFactory.create(PackageManagerName.LOCAL_BUN); + + const executeBuild = (rebuild = false) => + runSafe( + () => + packageManager.build( + target.name, + directory, + target.entry, + target.output, + ["--asset-naming", "[name].[ext]", "--target", target.platform], + rebuild, + ), + false, ); - } catch (error: any) { - if (error && error.message) { - console.error(ansis.red(error.message)); - } - return false; + + if (isWatch) { + this.watchDirectory(directory, target.entry, () => executeBuild(true)); } - }; - if (options?.watch) - watch(dirname(join(getCwd(directory), part.entry))).on("change", () => build(true)); + const result = await executeBuild(); + return result !== false; + } - return await build(); -}; + private watchDirectory(directory: string, entry: string, onChange: () => void): void { + const watchPath = dirname(join(getCwd(directory), entry)); + watch(watchPath).on("change", onChange); + } + + private enterWatchMode(): HandleResult { + console.info(); + console.info(Messages.BUILD_WATCH_START); + console.info(); + return { keepAlive: true }; + } +} diff --git a/src/action/actions/dev.action.ts b/src/action/actions/dev.action.ts index 16ff60f..8666e15 100644 --- a/src/action/actions/dev.action.ts +++ b/src/action/actions/dev.action.ts @@ -1,48 +1,47 @@ -import * as ansis from "ansis"; - import { type Input, getDevGenerateInput, getDirectoryInput } from "@lib/input"; import { PackageManagerFactory } from "@lib/package-manager"; import { Messages } from "@lib/ui"; -import { AbstractAction } from "../abstract.action"; +import { runSafe } from "@utils/run-safe"; + +import { AbstractAction, type HandleResult } from "../abstract.action"; export class DevAction extends AbstractAction { - public async handle(_args: Input, options: Input) { - console.info(Messages.DEV_START); - console.info(); - - try { - const directory = getDirectoryInput(options); - const generate = getDevGenerateInput(options); - - await Promise.all([ - generate ? runAction("generate", [], directory, false) : undefined, - runAction("build", [], directory, false), - runAction("start", [], directory, true), - ]); - - console.info(Messages.DEV_SUCCESS); - process.exit(0); - } catch (e) { - console.error(Messages.DEV_FAILED); - console.error(e); - process.exit(1); - } + protected startMessage = Messages.DEV_START; + protected successMessage = Messages.DEV_SUCCESS; + protected failureMessage = Messages.DEV_FAILED; + + public async handle(_args: Input, options: Input): Promise { + const directory = getDirectoryInput(options); + const generate = getDevGenerateInput(options); + + const tasks = this.buildTaskList(directory, generate); + await Promise.all(tasks); + + return { keepAlive: true }; } -} -const runAction = async ( - command: string, - params: string[], - directory: string, - stdout: boolean = false, -) => { - try { - const packageManager = await PackageManagerFactory.find(directory); - await packageManager.runDev(directory, "nf", {}, [command, ...params, "--watch"], !stdout); - } catch (error: any) { - if (error && error.message) { - console.error(ansis.red(error.message)); + private buildTaskList(directory: string, generate: boolean): Promise[] { + const tasks: Promise[] = []; + + if (generate) { + tasks.push(this.runSubCommand("generate", directory, { silent: true })); } + + tasks.push(this.runSubCommand("build", directory, { silent: true })); + tasks.push(this.runSubCommand("start", directory, { silent: false })); + + return tasks; } -}; + + private async runSubCommand( + command: string, + directory: string, + options: { silent: boolean }, + ): Promise { + await runSafe(async () => { + const packageManager = await PackageManagerFactory.find(directory); + await packageManager.runDev(directory, "nf", {}, [command, "--watch"], options.silent); + }); + } +} diff --git a/src/action/actions/generate.action.ts b/src/action/actions/generate.action.ts index 1b06ee9..ac101cf 100644 --- a/src/action/actions/generate.action.ts +++ b/src/action/actions/generate.action.ts @@ -1,19 +1,19 @@ -import * as console from "node:console"; -import { join } from "path"; +import { join } from "node:path"; import { type Config } from "@lib/config"; +import { NANOFORGE_DIR } from "@lib/constants"; import { type Input, getDirectoryInput, getWatchInput } from "@lib/input"; -import { type AbstractCollection, Collection, CollectionFactory } from "@lib/schematics"; +import { Collection, CollectionFactory } from "@lib/schematics"; import { Messages } from "@lib/ui"; import { getCwd } from "@utils/path"; import { getConfig } from "~/action/common/config"; -import { AbstractAction } from "../abstract.action"; +import { AbstractAction, type HandleResult } from "../abstract.action"; import { executeSchematic } from "../common/schematics"; -interface GenerateOptions { +interface GenerateValues { name: string; directory: string; language: string; @@ -22,81 +22,79 @@ interface GenerateOptions { } export class GenerateAction extends AbstractAction { - public async handle(_args: Input, options: Input) { - console.info(Messages.GENERATE_START); - console.info(); + protected startMessage = Messages.GENERATE_START; + protected successMessage = Messages.GENERATE_SUCCESS; + protected failureMessage = Messages.GENERATE_FAILED; - try { - const directory = getDirectoryInput(options); + public async handle(_args: Input, options: Input): Promise { + const directory = getDirectoryInput(options); + const config = await getConfig(options, directory); + const isWatch = getWatchInput(options); - const config = await getConfig(options, directory); - const watch = getWatchInput(options); + const values = this.extractValues(config); + await this.generateParts(values, directory, isWatch); - const values = await getSchemaValues(config); + if (isWatch) { + return this.enterWatchMode(); + } - await generateFiles(values, directory, watch); + return {}; + } + + private extractValues(config: Config): GenerateValues { + return { + name: config.name, + directory: ".", + language: config.language, + server: config.server.enable, + initFunctions: config.initFunctions, + }; + } - console.info(); + private async generateParts( + values: GenerateValues, + directory: string, + watch: boolean, + ): Promise { + const collection = CollectionFactory.create(Collection.NANOFORGE, directory); + const baseOptions = this.baseSchematicOptions(values); - if (watch) { - console.info(Messages.GENERATE_WATCH_START); - console.info(); - return; - } + await executeSchematic( + "Client main file", + collection, + "part-main", + { ...baseOptions, part: "client" }, + watch ? this.watchPath(directory, values.directory, "client") : undefined, + ); - console.info(Messages.GENERATE_SUCCESS); - process.exit(0); - } catch (e) { - console.error(Messages.GENERATE_FAILED); - console.error(e); - process.exit(1); + if (values.server) { + await executeSchematic( + "Server main file", + collection, + "part-main", + { ...baseOptions, part: "server" }, + this.watchPath(directory, values.directory, "server"), + ); } } -} -const getSchemaValues = async (config: Config): Promise => { - return { - name: config.name, - directory: ".", - language: config.language, - server: config.server.enable, - initFunctions: config.initFunctions, - }; -}; - -const generateFiles = async (values: GenerateOptions, directory: string, watch?: boolean) => { - const collection: AbstractCollection = CollectionFactory.create(Collection.NANOFORGE, directory); - - console.info(Messages.SCHEMATICS_START); - console.info(); - - await executeSchematic( - "Client main file", - collection, - "part-main", - { + private baseSchematicOptions(values: GenerateValues) { + return { name: values.name, - part: "client", directory: values.directory, language: values.language, initFunctions: values.initFunctions, - }, - watch ? join(getCwd(directory), values.directory, ".nanoforge", "client.save.json") : undefined, - ); + }; + } - if (values.server) { - await executeSchematic( - "Server main file", - collection, - "part-main", - { - name: values.name, - part: "server", - directory: values.directory, - language: values.language, - initFunctions: values.initFunctions, - }, - join(getCwd(directory), values.directory, ".nanoforge", "server.save.json"), - ); + private watchPath(directory: string, subDir: string, part: string): string { + return join(getCwd(directory), subDir, NANOFORGE_DIR, `${part}.save.json`); } -}; + + private enterWatchMode(): HandleResult { + console.info(); + console.info(Messages.GENERATE_WATCH_START); + console.info(); + return { keepAlive: true }; + } +} diff --git a/src/action/actions/install.action.ts b/src/action/actions/install.action.ts index a51b4a5..5a30189 100644 --- a/src/action/actions/install.action.ts +++ b/src/action/actions/install.action.ts @@ -1,40 +1,21 @@ -import * as ansis from "ansis"; -import * as process from "node:process"; - import { type Input, getDirectoryInput, getInstallNamesInputOrAsk } from "@lib/input"; import { PackageManagerFactory } from "@lib/package-manager"; import { Messages } from "@lib/ui"; -import { AbstractAction } from "../abstract.action"; +import { AbstractAction, type HandleResult } from "../abstract.action"; export class InstallAction extends AbstractAction { - public async handle(args: Input, options: Input) { - console.info(Messages.INSTALL_START); - console.info(); + protected startMessage = Messages.INSTALL_START; + protected successMessage = Messages.INSTALL_SUCCESS; + protected failureMessage = Messages.INSTALL_FAILED; - try { - const names = await getInstallNamesInputOrAsk(args); - const directory = getDirectoryInput(options); + public async handle(args: Input, options: Input): Promise { + const names = await getInstallNamesInputOrAsk(args); + const directory = getDirectoryInput(options); - await installPackages(names, directory); + const packageManager = await PackageManagerFactory.find(directory); + const success = await packageManager.addProduction(directory, names); - process.exit(0); - } catch (e) { - console.error(e); - process.exit(1); - } + return { success }; } } - -const installPackages = async (names: string[], directory: string) => { - try { - const packageManager = await PackageManagerFactory.find(directory); - const res = await packageManager.addProduction(directory, names); - if (!res) process.exit(1); - } catch (error: any) { - if (error && error.message) { - console.error(ansis.red(error.message)); - } - process.exit(1); - } -}; diff --git a/src/action/actions/new.action.ts b/src/action/actions/new.action.ts index 2defcde..ae06181 100644 --- a/src/action/actions/new.action.ts +++ b/src/action/actions/new.action.ts @@ -1,7 +1,4 @@ -import * as ansis from "ansis"; -import console from "node:console"; import { join } from "node:path"; -import * as process from "node:process"; import { type Input, getDirectoryInput } from "@lib/input"; import { getNewInitFunctionsWithDefault } from "@lib/input/inputs/new/init-functions.input"; @@ -13,13 +10,13 @@ import { getNewServerOrAsk } from "@lib/input/inputs/new/server.input"; import { getNewSkipInstallOrAsk } from "@lib/input/inputs/new/skip-install.input"; import { getNewStrictOrAsk } from "@lib/input/inputs/new/strict.input"; import { PackageManagerFactory } from "@lib/package-manager"; -import { type AbstractCollection, Collection, CollectionFactory } from "@lib/schematics"; +import { Collection, CollectionFactory } from "@lib/schematics"; import { Messages } from "@lib/ui"; -import { AbstractAction } from "../abstract.action"; +import { AbstractAction, type HandleResult } from "../abstract.action"; import { executeSchematic } from "../common/schematics"; -interface NewOptions { +interface NewValues { name: string; directory?: string; packageManager: string; @@ -31,106 +28,119 @@ interface NewOptions { } export class NewAction extends AbstractAction { - public async handle(_args: Input, options: Input) { - console.info(Messages.NEW_START); + protected startMessage = Messages.NEW_START; + protected successMessage = Messages.NEW_SUCCESS; + protected failureMessage = Messages.NEW_FAILED; - try { - const directory = getDirectoryInput(options); + public async handle(_args: Input, options: Input): Promise { + const directory = getDirectoryInput(options); + const values = await this.collectValues(options); - const values = await getSchemaValues(options); + await this.scaffold(values, directory); - await generateApplicationFiles(values, directory); + let res = true; - if (!values.skipInstall) - await runInstall(join(directory, values.name), values.packageManager); + if (!values.skipInstall) { + res = await this.installDependencies(values.packageManager, join(directory, values.name)); + } + + return { success: res }; + } + + private async collectValues(inputs: Input): Promise { + return { + name: await getNewNameInputOrAsk(inputs), + directory: getNewPathInput(inputs), + packageManager: await getNewPackageManagerInputOrAsk(inputs), + language: await getNewLanguageInputOrAsk(inputs), + strict: await getNewStrictOrAsk(inputs), + server: await getNewServerOrAsk(inputs), + initFunctions: getNewInitFunctionsWithDefault(inputs), + skipInstall: await getNewSkipInstallOrAsk(inputs), + }; + } + + private async scaffold(values: NewValues, directory: string): Promise { + const collection = CollectionFactory.create(Collection.NANOFORGE, directory); + + console.info(Messages.SCHEMATICS_START); + console.info(); + + await this.generateApplication(collection, values); + await this.generateConfiguration(collection, values); + await this.generateClientParts(collection, values); - console.info(); - console.info(Messages.NEW_SUCCESS); - process.exit(0); - } catch { - console.error(Messages.NEW_FAILED); - process.exit(1); + if (values.server) { + await this.generateServerParts(collection, values); } } -} -const getSchemaValues = async (inputs: Input): Promise => { - return { - name: await getNewNameInputOrAsk(inputs), - directory: getNewPathInput(inputs), - packageManager: await getNewPackageManagerInputOrAsk(inputs), - language: await getNewLanguageInputOrAsk(inputs), - strict: await getNewStrictOrAsk(inputs), - server: await getNewServerOrAsk(inputs), - initFunctions: getNewInitFunctionsWithDefault(inputs), - skipInstall: await getNewSkipInstallOrAsk(inputs), - }; -}; - -const generateApplicationFiles = async (values: NewOptions, directory: string) => { - console.info(); - const collection: AbstractCollection = CollectionFactory.create(Collection.NANOFORGE, directory); - - console.info(); - console.info(Messages.SCHEMATICS_START); - console.info(); - - await executeSchematic("Application", collection, "application", { - name: values.name, - directory: values.directory, - packageManager: values.packageManager, - language: values.language, - strict: values.strict, - server: values.server, - }); - await executeSchematic("Configuration", collection, "configuration", { - name: values.name, - directory: values.directory, - server: values.server, - }); - await executeSchematic("Base Client", collection, "part-base", { - name: values.name, - part: "client", - directory: values.directory, - language: values.language, - initFunctions: values.initFunctions, - server: values.server, - }); - await executeSchematic("Client main file", collection, "part-main", { - name: values.name, - part: "client", - directory: values.directory, - language: values.language, - initFunctions: values.initFunctions, - }); - - if (values.server) { - await executeSchematic("Base server", collection, "part-base", { + private generateApplication( + collection: ReturnType, + values: NewValues, + ) { + return executeSchematic("Application", collection, "application", { name: values.name, - part: "server", directory: values.directory, + packageManager: values.packageManager, language: values.language, - initFunctions: values.initFunctions, + strict: values.strict, + server: values.server, + }); + } + + private generateConfiguration( + collection: ReturnType, + values: NewValues, + ) { + return executeSchematic("Configuration", collection, "configuration", { + name: values.name, + directory: values.directory, server: values.server, }); - await executeSchematic("Server main file", collection, "part-main", { + } + + private async generateClientParts( + collection: ReturnType, + values: NewValues, + ) { + const partOptions = this.partOptions(values, "client"); + + await executeSchematic("Client base", collection, "part-base", { + ...partOptions, + server: values.server, + }); + await executeSchematic("Client main file", collection, "part-main", partOptions); + } + + private async generateServerParts( + collection: ReturnType, + values: NewValues, + ) { + const partOptions = this.partOptions(values, "server"); + + await executeSchematic("Server base", collection, "part-base", { + ...partOptions, + server: values.server, + }); + await executeSchematic("Server main file", collection, "part-main", partOptions); + } + + private partOptions(values: NewValues, part: "client" | "server") { + return { name: values.name, - part: "server", + part, directory: values.directory, language: values.language, initFunctions: values.initFunctions, - }); + }; } -}; - -const runInstall = async (directory: string, pkgManagerName: string) => { - try { - const packageManager = PackageManagerFactory.create(pkgManagerName); - await packageManager.install(directory); - } catch (error: any) { - if (error && error.message) { - console.error(ansis.red(error.message)); - } - process.exit(1); + + private async installDependencies( + packageManagerName: string, + directory: string, + ): Promise { + const packageManager = PackageManagerFactory.create(packageManagerName); + return await packageManager.install(directory); } -}; +} diff --git a/src/action/actions/start.action.ts b/src/action/actions/start.action.ts index 437875c..44a9355 100644 --- a/src/action/actions/start.action.ts +++ b/src/action/actions/start.action.ts @@ -1,8 +1,6 @@ -import * as ansis from "ansis"; -import * as console from "node:console"; -import * as process from "node:process"; import { join } from "path"; +import { type Config } from "@lib/config"; import { type Input, getDirectoryInput, @@ -14,103 +12,153 @@ import { PackageManagerFactory } from "@lib/package-manager"; import { Messages } from "@lib/ui"; import { getCwd, getModulePath } from "@utils/path"; +import { runSafe } from "@utils/run-safe"; import { getConfig } from "~/action/common/config"; -import { AbstractAction } from "../abstract.action"; +import { AbstractAction, type HandleResult } from "../abstract.action"; + +interface PortOptions { + clientPort: string; + gameExposurePort?: string; + serverPort?: string; +} + +interface SSLOptions { + cert: string; + key: string; +} export class StartAction extends AbstractAction { - public async handle(_args: Input, options: Input) { - console.info(Messages.RUN_START); - console.info(); - - try { - const directory = getDirectoryInput(options); - const config = await getConfig(options, directory); - - const clientDir = config.client.runtime.dir; - const serverDir = config.server.runtime.dir; - - const clientPort = getStringInputWithDefault(options, "clientPort", config.client.port); - const cert = getStringInput(options, "cert"); - const key = getStringInput(options, "key"); - - const watch = getWatchInput(options); - - await Promise.all([ - config.server.enable ? this.startServer(directory, serverDir, watch) : undefined, - this.startClient( - clientPort, - directory, - clientDir, - { - watch, - serverGameDir: config.server.enable ? serverDir : undefined, - }, - cert, - key, - ), - ]); - process.exit(0); - } catch (e) { - console.error(e); - process.exit(1); + protected startMessage = Messages.START_START; + protected successMessage = Messages.START_SUCCESS; + protected failureMessage = Messages.START_FAILED; + + public async handle(_args: Input, options: Input): Promise { + const directory = getDirectoryInput(options); + const config = await getConfig(options, directory); + const watch = getWatchInput(options); + const ports = this.resolvePorts(options, config); + const ssl = this.resolveSSL(options); + + const tasks = this.buildStartTasks(config, directory, watch, ports, ssl); + await Promise.all(tasks); + + return { keepAlive: true }; + } + + private resolvePorts(options: Input, config: Config): PortOptions { + return { + clientPort: getStringInputWithDefault(options, "clientPort", config.client.port), + gameExposurePort: getStringInput(options, "gameExposurePort"), + serverPort: getStringInput(options, "serverPort"), + }; + } + + private resolveSSL(options: Input): SSLOptions | undefined { + const cert = getStringInput(options, "cert"); + const key = getStringInput(options, "key"); + + if (!cert && !key) return undefined; + + if (!cert) throw new Error("No cert entered for SSL. Please enter a key with --cert."); + if (!key) throw new Error("No key entered for SSL. Please enter a key with --key."); + + return { + cert, + key, + }; + } + + private buildStartTasks( + config: Config, + directory: string, + watch: boolean, + ports: PortOptions, + ssl?: SSLOptions, + ): Promise[] { + const tasks: Promise[] = []; + + if (config.server.enable) { + tasks.push(this.startServer(directory, config.server.runtime.dir, watch, ports.serverPort)); } + + tasks.push(this.startClient(directory, config, watch, ports, ssl)); + + return tasks; } private async startClient( - port: string, directory: string, - gameDir: string, - options?: { - watch?: boolean; - serverGameDir?: string; - }, - cert?: string, - key?: string, + config: Config, + watch: boolean, + ports: PortOptions, + ssl?: SSLOptions, ): Promise { - const path = getModulePath("@nanoforge-dev/loader-client/package.json", true); + const loaderPath = getModulePath("@nanoforge-dev/loader-client/package.json", true); + const gameDir = config.client.runtime.dir; - const params: any = { - PORT: port, + const env = this.buildClientEnv(directory, gameDir, watch, config, ports, ssl); + await this.runLoader("Client", loaderPath, env); + } + + private buildClientEnv( + directory: string, + gameDir: string, + watch: boolean, + config: Config, + ports: PortOptions, + ssl?: SSLOptions, + ): Record { + const env: Record = { + PORT: ports.clientPort, GAME_DIR: getCwd(join(directory, gameDir)), - CERT: cert ? join(getCwd(directory), cert) : undefined, - KEY: key ? join(getCwd(directory), key) : undefined, }; - if (options?.watch) { - params["WATCH"] = "true"; - if (options?.serverGameDir) { - params["WATCH_SERVER_GAME_DIR"] = getCwd(join(directory, options.serverGameDir)); + + if (ports.gameExposurePort) { + env["GAME_EXPOSURE_PORT"] = ports.gameExposurePort; + } + + if (watch) { + env["WATCH"] = "true"; + if (config.server.enable) { + env["WATCH_SERVER_GAME_DIR"] = getCwd(join(directory, config.server.runtime.dir)); } } - return runPart("Client", path, params); + if (ssl) { + env["CERT"] = ssl.cert; + env["KEY"] = ssl.key; + } + + return env; } - private startServer(directory: string, gameDir: string, watch: boolean): Promise { - const path = getModulePath("@nanoforge-dev/loader-server/package.json", true); + private async startServer( + directory: string, + gameDir: string, + watch: boolean, + port?: string, + ): Promise { + const loaderPath = getModulePath("@nanoforge-dev/loader-server/package.json", true); - const params: any = { + const env: Record = { GAME_DIR: getCwd(join(directory, gameDir)), }; - if (watch) params["WATCH"] = "true"; + if (port) env["PORT"] = port; + if (watch) env["WATCH"] = "true"; - return runPart("Server", path, params); + await this.runLoader("Server", loaderPath, env); } -} -const runPart = async ( - part: string, - directory: string, - env?: Record, - flags?: string[], -) => { - try { - const packageManager = await PackageManagerFactory.find(directory); - await packageManager.run(part, directory, "start", env, flags, true); - } catch (error: any) { - if (error && error.message) { - console.error(ansis.red(error.message)); - } + private async runLoader( + name: string, + directory: string, + env: Record, + ): Promise { + await runSafe(async () => { + const packageManager = await PackageManagerFactory.find(directory); + await packageManager.run(name, directory, "start", env, [], true); + }); } -}; +} diff --git a/src/action/common/schematics.ts b/src/action/common/schematics.ts index 509ab4f..715bc53 100644 --- a/src/action/common/schematics.ts +++ b/src/action/common/schematics.ts @@ -2,8 +2,7 @@ import { watch } from "chokidar"; import { type AbstractCollection, SchematicOption } from "@lib/schematics"; import { Messages } from "@lib/ui"; - -import { getSpinner } from "~/action/common/spinner"; +import { getSpinner } from "@lib/ui/spinner"; export const executeSchematic = async ( name: string, @@ -11,29 +10,38 @@ export const executeSchematic = async ( schematicName: string, options: object, fileToWatch?: string, -) => { - const execute = async (watch: boolean = false) => { - const spinner = getSpinner( - (watch ? Messages.SCHEMATIC_WATCH_IN_PROGRESS : Messages.SCHEMATIC_IN_PROGRESS)(name), - ); +): Promise => { + const execute = async (isRebuild = false) => { + const message = isRebuild + ? Messages.SCHEMATIC_WATCH_IN_PROGRESS(name) + : Messages.SCHEMATIC_IN_PROGRESS(name); + const spinner = getSpinner(message); spinner.start(); + await collection.execute(schematicName, mapSchematicOptions(options), undefined, () => spinner.fail(Messages.SCHEMATIC_FAILED(name)), ); + spinner.succeed(Messages.SCHEMATIC_SUCCESS(name)); }; - if (fileToWatch) watch(fileToWatch).on("change", () => execute(true)); + if (fileToWatch) { + watch(fileToWatch).on("change", () => execute(true)); + } - return await execute(); + await execute(); }; export const mapSchematicOptions = (inputs: object): SchematicOption[] => { - return Object.entries(inputs).reduce((old, [key, value]) => { - if (value === undefined) return old; - return [ - ...old, - new SchematicOption(key, typeof value === "object" ? mapSchematicOptions(value) : value), - ]; - }, [] as SchematicOption[]); + return Object.entries(inputs).reduce((acc: SchematicOption[], [key, value]) => { + if (value === undefined) return acc; + + const mapped = + typeof value === "object" + ? new SchematicOption(key, mapSchematicOptions(value)) + : new SchematicOption(key, value); + + acc.push(mapped); + return acc; + }, []); }; diff --git a/src/action/common/spinner.ts b/src/action/common/spinner.ts index 4f645f0..f61063f 100644 --- a/src/action/common/spinner.ts +++ b/src/action/common/spinner.ts @@ -1,6 +1 @@ -import ora from "ora"; - -export const getSpinner = (message: string) => - ora({ - text: message, - }); +export { getSpinner } from "@lib/ui/spinner"; diff --git a/src/command/abstract.command.ts b/src/command/abstract.command.ts index b988f74..fbeca49 100644 --- a/src/command/abstract.command.ts +++ b/src/command/abstract.command.ts @@ -1,9 +1,19 @@ import { type Command } from "commander"; +import { type Input, type InputValue } from "@lib/input"; + import { type AbstractAction } from "~/action/abstract.action"; export abstract class AbstractCommand { constructor(protected action: AbstractAction) {} public abstract load(program: Command): void; + + protected static mapToInput(mapping: Record): Input { + const input: Input = new Map(); + for (const [key, value] of Object.entries(mapping)) { + input.set(key, { value }); + } + return input; + } } diff --git a/src/command/commands/build.command.ts b/src/command/commands/build.command.ts index b85318f..e043f0c 100644 --- a/src/command/commands/build.command.ts +++ b/src/command/commands/build.command.ts @@ -1,6 +1,6 @@ import { type Command } from "commander"; -import { type Input } from "@lib/input"; +import { CONFIG_FILE_NAME } from "@lib/constants"; import { AbstractCommand } from "../abstract.command"; @@ -18,21 +18,20 @@ export class BuildCommand extends AbstractCommand { .command("build") .description("build your game") .option("-d, --directory [directory]", "specify the directory of your project") - .option("-c, --config [config]", "path to the config file", "nanoforge.config.json") + .option("-c, --config [config]", "path to the config file", CONFIG_FILE_NAME) .option("--client-outDir [clientDirectory]", "specify the output directory of the client") .option("--server-outDir [serverDirectory]", "specify the output directory of the server") .option("--watch", "build app in watching mode", false) .action(async (rawOptions: BuildOptions) => { - const options: Input = new Map(); - options.set("directory", { value: rawOptions.directory }); - options.set("config", { value: rawOptions.config }); - options.set("clientDirectory", { value: rawOptions.clientOutDir }); - options.set("serverDirectory", { value: rawOptions.serverOutDir }); - options.set("watch", { value: rawOptions.watch }); + const options = AbstractCommand.mapToInput({ + directory: rawOptions.directory, + config: rawOptions.config, + clientDirectory: rawOptions.clientOutDir, + serverDirectory: rawOptions.serverOutDir, + watch: rawOptions.watch, + }); - const args: Input = new Map(); - - await this.action.handle(args, options); + await this.action.run(new Map(), options); }); } } diff --git a/src/command/commands/dev.command.ts b/src/command/commands/dev.command.ts index fdf7cb5..2b52868 100644 --- a/src/command/commands/dev.command.ts +++ b/src/command/commands/dev.command.ts @@ -1,6 +1,6 @@ import { type Command } from "commander"; -import { type Input } from "@lib/input"; +import { CONFIG_FILE_NAME } from "@lib/constants"; import { AbstractCommand } from "../abstract.command"; @@ -16,16 +16,16 @@ export class DevCommand extends AbstractCommand { .command("dev") .description("run your game in dev mode") .option("-d, --directory [directory]", "specify the directory of your project") + .option("-c, --config [config]", "path to the config file", CONFIG_FILE_NAME) .option("--generate", "generate app from config", false) .action(async (rawOptions: DevOptions) => { - const options: Input = new Map(); - options.set("directory", { value: rawOptions.directory }); - options.set("config", { value: rawOptions.config }); - options.set("generate", { value: rawOptions.generate }); + const options = AbstractCommand.mapToInput({ + directory: rawOptions.directory, + config: rawOptions.config, + generate: rawOptions.generate, + }); - const args: Input = new Map(); - - await this.action.handle(args, options); + await this.action.run(new Map(), options); }); } } diff --git a/src/command/commands/generate.command.ts b/src/command/commands/generate.command.ts index 5d41d16..f319c4f 100644 --- a/src/command/commands/generate.command.ts +++ b/src/command/commands/generate.command.ts @@ -1,6 +1,6 @@ import { type Command } from "commander"; -import { type Input } from "@lib/input"; +import { CONFIG_FILE_NAME } from "@lib/constants"; import { AbstractCommand } from "../abstract.command"; @@ -16,17 +16,16 @@ export class GenerateCommand extends AbstractCommand { .command("generate") .description("generate nanoforge files from config") .option("-d, --directory [directory]", "specify the directory of your project") - .option("-c, --config [config]", "path to the config file", "nanoforge.config.json") + .option("-c, --config [config]", "path to the config file", CONFIG_FILE_NAME) .option("--watch", "generate app in watching mode", false) .action(async (rawOptions: GenerateOptions) => { - const options: Input = new Map(); - options.set("directory", { value: rawOptions.directory }); - options.set("config", { value: rawOptions.config }); - options.set("watch", { value: rawOptions.watch }); + const options = AbstractCommand.mapToInput({ + directory: rawOptions.directory, + config: rawOptions.config, + watch: rawOptions.watch, + }); - const args: Input = new Map(); - - await this.action.handle(args, options); + await this.action.run(new Map(), options); }); } } diff --git a/src/command/commands/install.command.ts b/src/command/commands/install.command.ts index ab9c0b3..2782e85 100644 --- a/src/command/commands/install.command.ts +++ b/src/command/commands/install.command.ts @@ -1,7 +1,5 @@ import { type Command } from "commander"; -import { type Input } from "@lib/input"; - import { AbstractCommand } from "../abstract.command"; interface InstallOptions { @@ -16,13 +14,14 @@ export class InstallCommand extends AbstractCommand { .description("add NanoForge library to your project") .option("-d, --directory [directory]", "specify the directory of your project") .action(async (names: string[], rawOptions: InstallOptions) => { - const options: Input = new Map(); - options.set("directory", { value: rawOptions.directory }); - - const args: Input = new Map(); - args.set("names", { value: names.length ? names : undefined }); + const options = AbstractCommand.mapToInput({ + directory: rawOptions.directory, + }); + const args = AbstractCommand.mapToInput({ + names: names.length ? names : undefined, + }); - await this.action.handle(args, options); + await this.action.run(args, options); }); } } diff --git a/src/command/commands/new.command.ts b/src/command/commands/new.command.ts index 507d3d6..a92c0a0 100644 --- a/src/command/commands/new.command.ts +++ b/src/command/commands/new.command.ts @@ -1,7 +1,5 @@ import { type Command } from "commander"; -import { type Input } from "@lib/input"; - import { AbstractCommand } from "../abstract.command"; interface NewOptions { @@ -35,20 +33,19 @@ export class NewCommand extends AbstractCommand { .option("--skip-install", "skip installing dependencies") .option("--no-skip-install", "do not skip installing dependencies") .action(async (rawOptions: NewOptions) => { - const options: Input = new Map(); - options.set("directory", { value: rawOptions.directory }); - options.set("name", { value: rawOptions.name }); - options.set("path", { value: rawOptions.path }); - options.set("packageManager", { value: rawOptions.packageManager }); - options.set("language", { value: rawOptions.language }); - options.set("strict", { value: rawOptions.strict }); - options.set("server", { value: rawOptions.server }); - options.set("initFunctions", { value: rawOptions.initFunctions }); - options.set("skipInstall", { value: rawOptions.skipInstall }); - - const args: Input = new Map(); + const options = AbstractCommand.mapToInput({ + directory: rawOptions.directory, + name: rawOptions.name, + path: rawOptions.path, + packageManager: rawOptions.packageManager, + language: rawOptions.language, + strict: rawOptions.strict, + server: rawOptions.server, + initFunctions: rawOptions.initFunctions, + skipInstall: rawOptions.skipInstall, + }); - await this.action.handle(args, options); + await this.action.run(new Map(), options); }); } } diff --git a/src/command/commands/start.command.ts b/src/command/commands/start.command.ts index 3ccc157..1b1a8cc 100644 --- a/src/command/commands/start.command.ts +++ b/src/command/commands/start.command.ts @@ -1,6 +1,6 @@ import { type Command } from "commander"; -import { type Input } from "@lib/input"; +import { CONFIG_FILE_NAME } from "@lib/constants"; import { AbstractCommand } from "../abstract.command"; @@ -21,7 +21,7 @@ export class StartCommand extends AbstractCommand { .command("start") .description("start your game") .option("-d, --directory [directory]", "specify the directory of your project") - .option("-c, --config [config]", "path to the config file", "nanoforge.config.json") + .option("-c, --config [config]", "path to the config file", CONFIG_FILE_NAME) .option( "-p, --client-port [clientPort]", "specify the port of the loader (the website to load the game)", @@ -32,21 +32,18 @@ export class StartCommand extends AbstractCommand { .option("--cert [cert]", "path to the SSL certificate for HTTPS") .option("--key [key]", "path to the SSL key for HTTPS") .action(async (rawOptions: StartOptions) => { - const options: Input = new Map(); - options.set("directory", { value: rawOptions.directory }); - options.set("config", { value: rawOptions.config }); - options.set("clientPort", { value: rawOptions.clientPort }); - options.set("gameExposurePort", { - value: rawOptions.gameExposurePort, + const options = AbstractCommand.mapToInput({ + directory: rawOptions.directory, + config: rawOptions.config, + clientPort: rawOptions.clientPort, + gameExposurePort: rawOptions.gameExposurePort, + serverPort: rawOptions.serverPort, + watch: rawOptions.watch, + cert: rawOptions.cert, + key: rawOptions.key, }); - options.set("serverPort", { value: rawOptions.serverPort }); - options.set("watch", { value: rawOptions.watch }); - options.set("cert", { value: rawOptions.cert }); - options.set("key", { value: rawOptions.key }); - const args: Input = new Map(); - - await this.action.handle(args, options); + await this.action.run(new Map(), options); }); } } diff --git a/src/lib/config/config-loader.spec.ts b/src/lib/config/config-loader.spec.ts index b1be310..e58b837 100644 --- a/src/lib/config/config-loader.spec.ts +++ b/src/lib/config/config-loader.spec.ts @@ -62,7 +62,9 @@ describe("loadConfig", () => { const { loadConfig: freshLoad } = await import("./config-loader"); - await expect(freshLoad("/project")).rejects.toThrow("Unsupported config"); + await expect(freshLoad("/project")).rejects.toThrow( + "No config file found in directory: /project", + ); }); it("should throw when config file cannot be parsed", async () => { diff --git a/src/lib/config/config-loader.ts b/src/lib/config/config-loader.ts index 4757f18..644d2de 100644 --- a/src/lib/config/config-loader.ts +++ b/src/lib/config/config-loader.ts @@ -1,7 +1,9 @@ import { plainToInstance } from "class-transformer"; import { validate } from "class-validator"; import { existsSync, readFileSync } from "node:fs"; -import { join } from "path"; +import { join } from "node:path"; + +import { CONFIG_FILE_NAME } from "@lib/constants"; import { deepMerge } from "@utils/object"; @@ -14,11 +16,11 @@ const getConfigPath = (directory: string, name?: string) => { if (name) { return join(directory, name); } else { - for (const n of ["nanoforge.config.json"]) { + for (const n of [CONFIG_FILE_NAME]) { const path = join(directory, n); if (existsSync(path)) return path; } - throw new Error(`Unsupported config: ${name}`); + throw new Error(`No config file found in directory: ${directory}`); } }; @@ -28,7 +30,6 @@ export const loadConfig = async (directory: string, name?: string): Promise; } export type Input = Map; diff --git a/src/lib/package-manager/abstract.package-manager.ts b/src/lib/package-manager/abstract.package-manager.ts deleted file mode 100644 index 6326ee3..0000000 --- a/src/lib/package-manager/abstract.package-manager.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { bold, green, red, yellow } from "ansis"; -import ora, { type Ora } from "ora"; - -import { type AbstractRunner } from "@lib/runner/abstract.runner"; -import { Messages } from "@lib/ui"; - -import { getCwd } from "@utils/path"; - -import { type PackageManagerCommands } from "./package-manager-commands"; - -const SPINNER = (message: string) => - ora({ - text: message, - }); - -const FAIL_SPINNER = (spinner: Ora) => () => spinner.fail(); - -export abstract class AbstractPackageManager { - constructor(protected runner: AbstractRunner) {} - - public async install(directory: string) { - const spinner = SPINNER(Messages.PACKAGE_MANAGER_INSTALLATION_IN_PROGRESS); - spinner.start(); - try { - const commandArgs = [this.cli.install, this.cli.silentFlag]; - const collect = true; - await this.runner.run(commandArgs, collect, getCwd(directory), undefined, undefined, () => - spinner.fail(), - ); - spinner.succeed(); - this.printInstallSuccess(); - } catch { - const commandArgs = [this.cli.install]; - const commandToRun = this.runner.rawFullCommand(commandArgs); - this.printInstallFailure(commandToRun); - } - } - - public version(): Promise { - const commandArguments = ["--version"]; - const collect = true; - return this.runner.run(commandArguments, collect) as Promise; - } - - public addProduction(directory: string, dependencies: string[]): Promise { - const command = [this.cli.add, this.cli.saveFlag]; - return this.add(command, directory, dependencies); - } - - public addDevelopment(directory: string, dependencies: string[]): Promise { - const command = [this.cli.add, this.cli.saveDevFlag]; - return this.add(command, directory, dependencies); - } - - async build( - name: string, - directory: string, - entry: string, - output: string, - flags?: string[], - watch?: boolean, - ): Promise { - if (!this.cli.build) throw new Error(`Package manager ${this.name} does not support building`); - - const spinner = SPINNER( - (watch ? Messages.BUILD_PART_WATCH_IN_PROGRESS : Messages.BUILD_PART_IN_PROGRESS)(name), - ); - spinner.start(); - try { - const commandArgs = [ - this.cli.build, - this.cli.silentFlag, - entry, - "--outdir", - output, - ...(flags ?? []), - ]; - const collect = true; - await this.runner.run( - commandArgs, - collect, - getCwd(directory), - undefined, - undefined, - FAIL_SPINNER(spinner), - ); - spinner.succeed(); - return true; - } catch { - const commandArgs = [this.cli.install]; - const commandToRun = this.runner.rawFullCommand(commandArgs); - console.error(red(Messages.BUILD_PART_FAILED(name, bold(commandToRun)))); - return false; - } - } - - async run( - name: string, - directory: string, - file: string, - env: Record = {}, - flags: string[] = [], - silent = false, - ): Promise { - try { - console.info(Messages.RUN_PART_IN_PROGRESS(name)); - const commandArgs = [...flags, this.cli.run]; - if (silent) commandArgs.push(this.cli.silentFlag); - commandArgs.push(file); - await this.runner.run(commandArgs, true, getCwd(directory), env, { - onStdout: this.onRunStdout(name), - onStderr: this.onRunStderr(name), - }); - console.info(Messages.RUN_PART_SUCCESS(name)); - return true; - } catch { - console.error(red(Messages.RUN_PART_FAILED(name))); - return false; - } - } - - async runDev( - directory: string, - command: string, - env: Record = {}, - flags: string[] = [], - collect = true, - ): Promise { - try { - const commandArgs = [this.cli.exec, command, ...flags]; - await this.runner.run(commandArgs, collect, getCwd(directory), env); - return true; - } catch { - return false; - } - } - - private async add(args: string[], directory: string, dependencies: string[]) { - if (!dependencies.length) { - console.info(); - console.info(Messages.PACKAGE_MANAGER_INSTALLATION_NOTHING); - console.info(); - return true; - } - - const commandArguments = [...args, ...dependencies]; - - const spinner = SPINNER(Messages.PACKAGE_MANAGER_INSTALLATION_IN_PROGRESS); - spinner.start(); - - try { - const collect = true; - await this.runner.run( - commandArguments, - collect, - getCwd(directory), - undefined, - undefined, - FAIL_SPINNER(spinner), - ); - - spinner.succeed(); - this.printInstallSuccess(dependencies); - return true; - } catch { - spinner.fail(); - const commandToRun = this.runner.rawFullCommand(commandArguments); - this.printInstallFailure(commandToRun); - return false; - } - } - - private printInstallSuccess(dependencies?: string[]) { - console.info(); - console.info(Messages.PACKAGE_MANAGER_INSTALLATION_SUCCEED(dependencies)); - console.info(); - } - - private printInstallFailure(command: string) { - console.error(red(Messages.PACKAGE_MANAGER_INSTALLATION_FAILED(bold(command)))); - } - - private onRunStdout = (name: string) => (chunk: string) => { - chunk - .toString() - .replace(/\r\n|\n/g, "\n") - .replace(/^\n+|\n+$/g, "") - .split("\n") - .forEach((line) => { - const date = yellow(`[${new Date().toISOString()}]`); - const prompt = green(`(${name}) INFO -`); - console.info(`${date} ${prompt} ${line}`); - }); - }; - - private onRunStderr = (name: string) => (chunk: ArrayBuffer) => { - chunk - .toString() - .replace(/\r\n|\n/g, "\n") - .replace(/^\n+|\n+$/g, "") - .split("\n") - .forEach((line) => { - const date = yellow(`[${new Date().toISOString()}]`); - const prompt = red(`(${name}) ERROR -`); - console.error(`${date} ${prompt} ${line}`); - }); - }; - - public abstract get name(): string; - - public abstract get cli(): PackageManagerCommands; -} diff --git a/src/lib/package-manager/index.ts b/src/lib/package-manager/index.ts index badafbe..7b77875 100644 --- a/src/lib/package-manager/index.ts +++ b/src/lib/package-manager/index.ts @@ -1,8 +1,5 @@ export * from "./package-manager"; export * from "./package-manager.factory"; -export * from "./package-managers/bun.package-manager"; -export * from "./package-managers/local-bun.package-manager"; -export * from "./package-managers/npm.package-manager"; -export * from "./package-managers/pnpm.package-manager"; -export * from "./package-managers/yarn.package-manager"; +export * from "./package-manager-name"; export * from "./package-manager-commands"; +export * from "./package-manager-configs"; diff --git a/src/lib/package-manager/package-manager-configs.ts b/src/lib/package-manager/package-manager-configs.ts new file mode 100644 index 0000000..496f6d3 --- /dev/null +++ b/src/lib/package-manager/package-manager-configs.ts @@ -0,0 +1,80 @@ +import { type PackageManagerCommands } from "./package-manager-commands"; +import { PackageManagerName } from "./package-manager-name"; + +export const PM_CONFIGS: Record< + PackageManagerName, + { binary: string; commands: PackageManagerCommands } +> = { + [PackageManagerName.BUN]: { + binary: "bun", + commands: { + install: "install", + add: "add", + update: "update", + remove: "remove", + exec: "exec", + run: "run", + saveFlag: "--save", + saveDevFlag: "--dev", + silentFlag: "--silent", + }, + }, + [PackageManagerName.LOCAL_BUN]: { + binary: "bun", + commands: { + install: "install", + add: "add", + update: "update", + remove: "remove", + exec: "exec", + run: "run", + build: "build", + runFile: "run", + saveFlag: "--save", + saveDevFlag: "--dev", + silentFlag: "--silent", + }, + }, + [PackageManagerName.NPM]: { + binary: "npm", + commands: { + install: "install", + add: "install", + update: "update", + remove: "uninstall", + exec: "exec", + run: "run", + saveFlag: "--save", + saveDevFlag: "--save-dev", + silentFlag: "--silent", + }, + }, + [PackageManagerName.PNPM]: { + binary: "pnpm", + commands: { + install: "install", + add: "add", + update: "update", + remove: "remove", + exec: "exec", + run: "run", + saveFlag: "-P", + saveDevFlag: "-D", + silentFlag: "--silent", + }, + }, + [PackageManagerName.YARN]: { + binary: "yarn", + commands: { + install: "install", + add: "add", + update: "update", + remove: "remove", + exec: "exec", + run: "run", + saveFlag: "", + saveDevFlag: "-D", + silentFlag: "--silent", + }, + }, +}; diff --git a/src/lib/package-manager/package-manager-name.ts b/src/lib/package-manager/package-manager-name.ts new file mode 100644 index 0000000..24b9b30 --- /dev/null +++ b/src/lib/package-manager/package-manager-name.ts @@ -0,0 +1,7 @@ +export enum PackageManagerName { + BUN = "bun", + LOCAL_BUN = "local_bun", + NPM = "npm", + PNPM = "pnpm", + YARN = "yarn", +} diff --git a/src/lib/package-manager/package-manager.factory.spec.ts b/src/lib/package-manager/package-manager.factory.spec.ts index 9545938..5780cee 100644 --- a/src/lib/package-manager/package-manager.factory.spec.ts +++ b/src/lib/package-manager/package-manager.factory.spec.ts @@ -1,35 +1,39 @@ import { describe, expect, it } from "vitest"; import { PackageManager } from "./package-manager"; +import { PackageManagerName } from "./package-manager-name"; import { PackageManagerFactory } from "./package-manager.factory"; -import { BunPackageManager } from "./package-managers/bun.package-manager"; -import { LocalBunPackageManager } from "./package-managers/local-bun.package-manager"; -import { NpmPackageManager } from "./package-managers/npm.package-manager"; -import { PnpmPackageManager } from "./package-managers/pnpm.package-manager"; -import { YarnPackageManager } from "./package-managers/yarn.package-manager"; describe("PackageManagerFactory", () => { describe("create", () => { - it("should create BunPackageManager", () => { - expect(PackageManagerFactory.create(PackageManager.BUN)).toBeInstanceOf(BunPackageManager); + it("should create a PackageManager for BUN", () => { + const pm = PackageManagerFactory.create(PackageManagerName.BUN); + expect(pm).toBeInstanceOf(PackageManager); + expect(pm.name).toBe(PackageManagerName.BUN); }); - it("should create LocalBunPackageManager", () => { - expect(PackageManagerFactory.create(PackageManager.LOCAL_BUN)).toBeInstanceOf( - LocalBunPackageManager, - ); + it("should create a PackageManager for LOCAL_BUN", () => { + const pm = PackageManagerFactory.create(PackageManagerName.LOCAL_BUN); + expect(pm).toBeInstanceOf(PackageManager); + expect(pm.name).toBe(PackageManagerName.LOCAL_BUN); }); - it("should create NpmPackageManager", () => { - expect(PackageManagerFactory.create(PackageManager.NPM)).toBeInstanceOf(NpmPackageManager); + it("should create a PackageManager for NPM", () => { + const pm = PackageManagerFactory.create(PackageManagerName.NPM); + expect(pm).toBeInstanceOf(PackageManager); + expect(pm.name).toBe(PackageManagerName.NPM); }); - it("should create PnpmPackageManager", () => { - expect(PackageManagerFactory.create(PackageManager.PNPM)).toBeInstanceOf(PnpmPackageManager); + it("should create a PackageManager for PNPM", () => { + const pm = PackageManagerFactory.create(PackageManagerName.PNPM); + expect(pm).toBeInstanceOf(PackageManager); + expect(pm.name).toBe(PackageManagerName.PNPM); }); - it("should create YarnPackageManager", () => { - expect(PackageManagerFactory.create(PackageManager.YARN)).toBeInstanceOf(YarnPackageManager); + it("should create a PackageManager for YARN", () => { + const pm = PackageManagerFactory.create(PackageManagerName.YARN); + expect(pm).toBeInstanceOf(PackageManager); + expect(pm.name).toBe(PackageManagerName.YARN); }); it("should throw for unsupported package manager", () => { diff --git a/src/lib/package-manager/package-manager.factory.ts b/src/lib/package-manager/package-manager.factory.ts index 27550d6..b5224e1 100644 --- a/src/lib/package-manager/package-manager.factory.ts +++ b/src/lib/package-manager/package-manager.factory.ts @@ -1,57 +1,51 @@ import fs from "fs"; import { resolve } from "path"; -import { type AbstractPackageManager } from "./abstract.package-manager"; +import { RunnerFactory } from "@lib/runner"; + import { PackageManager } from "./package-manager"; -import { BunPackageManager } from "./package-managers/bun.package-manager"; -import { LocalBunPackageManager } from "./package-managers/local-bun.package-manager"; -import { NpmPackageManager } from "./package-managers/npm.package-manager"; -import { PnpmPackageManager } from "./package-managers/pnpm.package-manager"; -import { YarnPackageManager } from "./package-managers/yarn.package-manager"; +import { PM_CONFIGS } from "./package-manager-configs"; +import { PackageManagerName } from "./package-manager-name"; + +const LOCK_FILE_MAP: Record = { + "bun.lock": PackageManagerName.BUN, + "package-lock.json": PackageManagerName.NPM, + "pnpm-lock.yaml": PackageManagerName.PNPM, + "yarn.lock": PackageManagerName.YARN, +}; export class PackageManagerFactory { - public static create(name: PackageManager | string): AbstractPackageManager { - switch (name) { - case PackageManager.BUN: - return new BunPackageManager(); - case PackageManager.LOCAL_BUN: - return new LocalBunPackageManager(); - case PackageManager.NPM: - return new NpmPackageManager(); - case PackageManager.PNPM: - return new PnpmPackageManager(); - case PackageManager.YARN: - return new YarnPackageManager(); - default: - throw new Error(`Package manager ${name} is not managed.`); + public static create(name: PackageManagerName | string): PackageManager { + const config = PM_CONFIGS[name as PackageManagerName]; + if (!config) { + throw new Error(`Package manager ${name} is not managed.`); } + + const runner = this.createRunner(name as PackageManagerName, config.binary); + return new PackageManager(name, config.commands, runner); + } + + public static async find(directory = "."): Promise { + const detected = await this.detectFromLockFile(directory); + return this.create(detected); } - public static async find(directory: string = "."): Promise { - const DEFAULT_PACKAGE_MANAGER = PackageManager.NPM; + private static createRunner(name: PackageManagerName, binary: string) { + if (name === PackageManagerName.LOCAL_BUN) { + return RunnerFactory.createLocal("bun"); + } + return RunnerFactory.create(binary); + } + private static async detectFromLockFile(directory: string): Promise { try { const files = await fs.promises.readdir(resolve(directory)); - - if (files.includes("bun.lock")) { - return this.create(PackageManager.BUN); + for (const [lockFile, pmName] of Object.entries(LOCK_FILE_MAP)) { + if (files.includes(lockFile)) return pmName; } - - if (files.includes("package-lock.json")) { - return this.create(PackageManager.NPM); - } - - if (files.includes("pnpm-lock.yaml")) { - return this.create(PackageManager.PNPM); - } - - if (files.includes("yarn.lock")) { - return this.create(PackageManager.YARN); - } - - return this.create(DEFAULT_PACKAGE_MANAGER); } catch { - return this.create(DEFAULT_PACKAGE_MANAGER); + // directory unreadable, fall through to default } + return PackageManagerName.NPM; } } diff --git a/src/lib/package-manager/package-manager.ts b/src/lib/package-manager/package-manager.ts index f80b1d2..1e4a1fb 100644 --- a/src/lib/package-manager/package-manager.ts +++ b/src/lib/package-manager/package-manager.ts @@ -1,7 +1,224 @@ -export enum PackageManager { - BUN = "bun", - LOCAL_BUN = "local_bun", - NPM = "npm", - PNPM = "pnpm", - YARN = "yarn", +import { bold, red } from "ansis"; +import { type Ora } from "ora"; + +import { createStderrLogger, createStdoutLogger } from "@lib/runner/process-logger"; +import { type RunOptions, type Runner } from "@lib/runner/runner"; +import { Messages } from "@lib/ui"; +import { getSpinner } from "@lib/ui/spinner"; + +import { getCwd } from "@utils/path"; + +import { type PackageManagerCommands } from "./package-manager-commands"; + +interface SpinnerTaskResult { + success: boolean; + value?: T; +} + +export class PackageManager { + constructor( + public readonly name: string, + private readonly commands: PackageManagerCommands, + private readonly runner: Runner, + ) {} + + public async install(directory: string): Promise { + const args = [this.commands.install, this.commands.silentFlag]; + + const result = await this.withSpinner( + Messages.PACKAGE_MANAGER_INSTALLATION_IN_PROGRESS, + async (spinner) => { + await this.exec(args, directory, { onFail: () => spinner.fail() }); + this.logSuccess(Messages.PACKAGE_MANAGER_INSTALLATION_SUCCEED()); + }, + () => this.logFailure(this.formatFailCommand([this.commands.install])), + ); + + return result.success; + } + + public async addProduction(directory: string, dependencies: string[]): Promise { + return this.addDependencies(this.commands.saveFlag, directory, dependencies); + } + + public async addDevelopment(directory: string, dependencies: string[]): Promise { + return this.addDependencies(this.commands.saveDevFlag, directory, dependencies); + } + + public async build( + name: string, + directory: string, + entry: string, + output: string, + flags: string[] = [], + watch = false, + ): Promise { + this.assertSupports("build"); + + const message = watch + ? Messages.BUILD_PART_WATCH_IN_PROGRESS(name) + : Messages.BUILD_PART_IN_PROGRESS(name); + + const args = [ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.commands.build!, + this.commands.silentFlag, + entry, + "--outdir", + output, + ...flags, + ]; + + const result = await this.withSpinner( + message, + async (spinner) => { + await this.exec(args, directory, { onFail: () => spinner.fail() }); + }, + () => this.logBuildFailure(name), + ); + + return result.success; + } + + public async run( + name: string, + directory: string, + script: string, + env: Record = {}, + flags: string[] = [], + silent = false, + ): Promise { + console.info(Messages.START_PART_IN_PROGRESS(name)); + + try { + const args = this.buildRunArgs(script, flags, silent); + await this.exec(args, directory, { + env, + listeners: { + onStdout: createStdoutLogger(name), + onStderr: createStderrLogger(name), + }, + }); + console.info(Messages.START_PART_SUCCESS(name)); + return true; + } catch { + console.error(red(Messages.START_PART_FAILED(name))); + return false; + } + } + + public async runDev( + directory: string, + command: string, + env: Record = {}, + flags: string[] = [], + collect = true, + ): Promise { + try { + await this.exec([this.commands.run, command, ...flags], directory, { collect, env }); + return true; + } catch { + return false; + } + } + + private async withSpinner( + message: string, + task: (spinner: Ora) => Promise, + onError?: () => void, + ): Promise> { + const spinner = getSpinner(message); + spinner.start(); + + try { + const value = await task(spinner); + spinner.succeed(); + return { success: true, value }; + } catch { + spinner.fail(); + if (onError) onError(); + return { success: false }; + } + } + + private async addDependencies( + saveFlag: string, + directory: string, + dependencies: string[], + ): Promise { + if (!dependencies.length) { + this.logEmpty(Messages.PACKAGE_MANAGER_INSTALLATION_NOTHING); + return true; + } + + const args = [this.commands.add, saveFlag, ...dependencies]; + + const result = await this.withSpinner( + Messages.PACKAGE_MANAGER_INSTALLATION_IN_PROGRESS, + async (spinner) => { + await this.exec(args, directory, { onFail: () => spinner.fail() }); + this.logSuccess(Messages.PACKAGE_MANAGER_INSTALLATION_SUCCEED(dependencies)); + }, + () => this.logFailure(this.formatFailCommand(args)), + ); + + return result.success; + } + + private assertSupports(feature: keyof PackageManagerCommands): void { + if (!this.commands[feature]) { + throw new Error(`Package manager "${this.name}" does not support "${feature}"`); + } + } + + private buildRunArgs(script: string, flags: string[], silent: boolean): string[] { + const args = [...flags, this.commands.run]; + if (silent) args.push(this.commands.silentFlag); + args.push(script); + return args; + } + + private exec( + args: string[], + directory: string, + options: { + collect?: boolean; + env?: Record; + listeners?: RunOptions["listeners"]; + onFail?: () => void; + } = {}, + ): Promise { + return this.runner.run(args, { + collect: options.collect ?? true, + cwd: getCwd(directory), + env: options.env, + listeners: options.listeners, + onFail: options.onFail, + }); + } + + private formatFailCommand(args: string[]): string { + return this.runner.fullCommand(args); + } + + private logSuccess(message: string): void { + console.info(); + console.info(message); + console.info(); + } + + private logFailure(command: string): void { + console.error(red(Messages.PACKAGE_MANAGER_INSTALLATION_FAILED(bold(command)))); + } + + private logBuildFailure(name: string): void { + const command = this.formatFailCommand([this.commands.install]); + console.error(red(Messages.BUILD_PART_FAILED(name, bold(command)))); + } + + private logEmpty(message: string): void { + console.info(); + console.info(message); + console.info(); + } } diff --git a/src/lib/package-manager/package-managers/bun.package-manager.ts b/src/lib/package-manager/package-managers/bun.package-manager.ts deleted file mode 100644 index 17910e9..0000000 --- a/src/lib/package-manager/package-managers/bun.package-manager.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { type BunRunner, Runner, RunnerFactory } from "@lib/runner"; - -import { AbstractPackageManager } from "../abstract.package-manager"; -import { PackageManager } from "../package-manager"; -import { type PackageManagerCommands } from "../package-manager-commands"; - -export class BunPackageManager extends AbstractPackageManager { - constructor() { - super(RunnerFactory.create(Runner.BUN) as BunRunner); - } - - public get name() { - return PackageManager.BUN.toUpperCase(); - } - - get cli(): PackageManagerCommands { - return { - install: "install", - add: "add", - update: "update", - remove: "remove", - exec: "exec", - run: "run", - saveFlag: "--save", - saveDevFlag: "--dev", - silentFlag: "--silent", - }; - } -} diff --git a/src/lib/package-manager/package-managers/local-bun.package-manager.ts b/src/lib/package-manager/package-managers/local-bun.package-manager.ts deleted file mode 100644 index e27b0c5..0000000 --- a/src/lib/package-manager/package-managers/local-bun.package-manager.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { type LocalBunRunner, Runner, RunnerFactory } from "@lib/runner"; - -import { AbstractPackageManager } from "../abstract.package-manager"; -import { PackageManager } from "../package-manager"; -import { type PackageManagerCommands } from "../package-manager-commands"; - -export class LocalBunPackageManager extends AbstractPackageManager { - constructor() { - super(RunnerFactory.create(Runner.LOCAL_BUN) as LocalBunRunner); - } - - public get name() { - return PackageManager.LOCAL_BUN.toUpperCase(); - } - - get cli(): PackageManagerCommands { - return { - install: "install", - add: "add", - update: "update", - remove: "remove", - exec: "exec", - run: "run", - build: "build", - runFile: "run", - saveFlag: "--save", - saveDevFlag: "--dev", - silentFlag: "--silent", - }; - } -} diff --git a/src/lib/package-manager/package-managers/npm.package-manager.ts b/src/lib/package-manager/package-managers/npm.package-manager.ts deleted file mode 100644 index ebd96b5..0000000 --- a/src/lib/package-manager/package-managers/npm.package-manager.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { type NpmRunner, Runner, RunnerFactory } from "@lib/runner"; - -import { AbstractPackageManager } from "../abstract.package-manager"; -import { PackageManager } from "../package-manager"; -import { type PackageManagerCommands } from "../package-manager-commands"; - -export class NpmPackageManager extends AbstractPackageManager { - constructor() { - super(RunnerFactory.create(Runner.NPM) as NpmRunner); - } - - public get name() { - return PackageManager.NPM.toUpperCase(); - } - - get cli(): PackageManagerCommands { - return { - install: "install", - add: "install", - update: "update", - remove: "uninstall", - exec: "exec", - run: "run", - saveFlag: "--save", - saveDevFlag: "--save-dev", - silentFlag: "--silent", - }; - } -} diff --git a/src/lib/package-manager/package-managers/pnpm.package-manager.ts b/src/lib/package-manager/package-managers/pnpm.package-manager.ts deleted file mode 100644 index 10721e7..0000000 --- a/src/lib/package-manager/package-managers/pnpm.package-manager.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { type PnpmRunner, Runner, RunnerFactory } from "@lib/runner"; - -import { AbstractPackageManager } from "../abstract.package-manager"; -import { PackageManager } from "../package-manager"; -import { type PackageManagerCommands } from "../package-manager-commands"; - -export class PnpmPackageManager extends AbstractPackageManager { - constructor() { - super(RunnerFactory.create(Runner.PNPM) as PnpmRunner); - } - - public get name() { - return PackageManager.PNPM.toUpperCase(); - } - - get cli(): PackageManagerCommands { - return { - install: "install", - add: "add", - update: "update", - remove: "remove", - exec: "exec", - run: "run", - saveFlag: "-P", - saveDevFlag: "-D", - silentFlag: "--silent", - }; - } -} diff --git a/src/lib/package-manager/package-managers/yarn.package-manager.ts b/src/lib/package-manager/package-managers/yarn.package-manager.ts deleted file mode 100644 index d5cece4..0000000 --- a/src/lib/package-manager/package-managers/yarn.package-manager.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { Runner, RunnerFactory, type YarnRunner } from "@lib/runner"; - -import { AbstractPackageManager } from "../abstract.package-manager"; -import { PackageManager } from "../package-manager"; -import { type PackageManagerCommands } from "../package-manager-commands"; - -export class YarnPackageManager extends AbstractPackageManager { - constructor() { - super(RunnerFactory.create(Runner.YARN) as YarnRunner); - } - - public get name() { - return PackageManager.YARN.toUpperCase(); - } - - get cli(): PackageManagerCommands { - return { - install: "install", - add: "add", - update: "update", - remove: "remove", - exec: "exec", - run: "run", - saveFlag: "", - saveDevFlag: "-D", - silentFlag: "--silent", - }; - } -} diff --git a/src/lib/runner/abstract.runner.spec.ts b/src/lib/runner/abstract.runner.spec.ts deleted file mode 100644 index 1f95e7f..0000000 --- a/src/lib/runner/abstract.runner.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { AbstractRunner } from "./abstract.runner"; - -class TestRunner extends AbstractRunner { - constructor() { - super("node", ["--version"]); - } -} - -describe("AbstractRunner", () => { - describe("rawFullCommand", () => { - it("should combine binary, base args, and additional args", () => { - const runner = new TestRunner(); - expect(runner.rawFullCommand(["--help"])).toBe("node --version --help"); - }); - - it("should handle empty additional args", () => { - const runner = new TestRunner(); - expect(runner.rawFullCommand([])).toBe("node --version"); - }); - - it("should handle multiple additional args", () => { - const runner = new TestRunner(); - expect(runner.rawFullCommand(["run", "script.js"])).toBe("node --version run script.js"); - }); - }); -}); diff --git a/src/lib/runner/abstract.runner.ts b/src/lib/runner/abstract.runner.ts deleted file mode 100644 index ed9ecbe..0000000 --- a/src/lib/runner/abstract.runner.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { red } from "ansis"; -import { type ChildProcess, type SpawnOptions, spawn } from "child_process"; -import * as process from "node:process"; - -import { Messages } from "@lib/ui"; - -interface RunnerListeners { - onStdout?: (chunk: any) => void; - onStderr?: (chunk: any) => void; -} - -export class AbstractRunner { - constructor( - protected binary: string, - protected args: string[] = [], - ) {} - - public async run( - args: string[], - collect = false, - cwd: string = process.cwd(), - env?: Record, - listeners?: RunnerListeners, - failSpinner?: () => void, - ): Promise { - const options: SpawnOptions = { - cwd, - stdio: collect ? "pipe" : "inherit", - shell: true, - env: { ...process.env, ...env }, - }; - return new Promise((resolve, reject) => { - const child: ChildProcess = spawn( - `${this.binary} ${[...this.args, ...args].join(" ")}`, - options, - ); - - const res: string[] = []; - child.stdout?.on( - "data", - listeners?.onStdout ?? ((data) => res.push(data.toString().replace(/\r\n|\n/, ""))), - ); - child.stderr?.on( - "data", - listeners?.onStderr ?? ((data) => res.push(data.toString().replace(/\r\n|\n/, ""))), - ); - - child.on("close", (code) => { - if (code === 0) { - resolve(collect && res.length ? res.join("\n") : null); - } else { - if (failSpinner) failSpinner(); - console.error( - red(Messages.RUNNER_EXECUTION_ERROR([this.binary, ...this.args, ...args].join(" "))), - ); - if (res.length) { - console.error(); - console.error(res.join("\n")); - console.error(); - } - reject(); - } - }); - }); - } - - public rawFullCommand(args: string[]): string { - const commandArgs: string[] = [...this.args, ...args]; - return `${this.binary} ${commandArgs.join(" ")}`; - } -} diff --git a/src/lib/runner/index.ts b/src/lib/runner/index.ts index 1276611..ef62349 100644 --- a/src/lib/runner/index.ts +++ b/src/lib/runner/index.ts @@ -1,9 +1,3 @@ export * from "./runner"; export * from "./runner.factory"; - -export * from "./runners/bun.runner"; -export * from "./runners/local-bun.runner"; -export * from "./runners/npm.runner"; -export * from "./runners/pnpm.runner"; -export * from "./runners/schematic.runner"; -export * from "./runners/yarn.runner"; +export * from "./process-logger"; diff --git a/src/lib/runner/process-logger.ts b/src/lib/runner/process-logger.ts new file mode 100644 index 0000000..38ff168 --- /dev/null +++ b/src/lib/runner/process-logger.ts @@ -0,0 +1,25 @@ +import { green, red, yellow } from "ansis"; + +const formatLines = (chunk: string | ArrayBuffer): string[] => { + return chunk + .toString() + .replace(/\r\n|\n/g, "\n") + .replace(/^\n+|\n+$/g, "") + .split("\n"); +}; + +const timestamp = (): string => yellow(`[${new Date().toISOString()}]`); + +export const createStdoutLogger = (name: string) => (chunk: string) => { + const prefix = green(`(${name}) INFO -`); + for (const line of formatLines(chunk)) { + console.info(`${timestamp()} ${prefix} ${line}`); + } +}; + +export const createStderrLogger = (name: string) => (chunk: string) => { + const prefix = red(`(${name}) ERROR -`); + for (const line of formatLines(chunk)) { + console.error(`${timestamp()} ${prefix} ${line}`); + } +}; diff --git a/src/lib/runner/runner.factory.spec.ts b/src/lib/runner/runner.factory.spec.ts index 36d070e..9ccf3ee 100644 --- a/src/lib/runner/runner.factory.spec.ts +++ b/src/lib/runner/runner.factory.spec.ts @@ -2,39 +2,28 @@ import { describe, expect, it } from "vitest"; import { Runner } from "./runner"; import { RunnerFactory } from "./runner.factory"; -import { BunRunner } from "./runners/bun.runner"; -import { LocalBunRunner } from "./runners/local-bun.runner"; -import { NpmRunner } from "./runners/npm.runner"; -import { PnpmRunner } from "./runners/pnpm.runner"; -import { SchematicRunner } from "./runners/schematic.runner"; -import { YarnRunner } from "./runners/yarn.runner"; describe("RunnerFactory", () => { - it("should create a BunRunner", () => { - expect(RunnerFactory.create(Runner.BUN)).toBeInstanceOf(BunRunner); + it("should create a Runner with the given binary", () => { + const runner = RunnerFactory.create("node"); + expect(runner).toBeInstanceOf(Runner); + expect(runner.fullCommand(["--version"])).toBe("node --version"); }); - it("should create a LocalBunRunner", () => { - expect(RunnerFactory.create(Runner.LOCAL_BUN)).toBeInstanceOf(LocalBunRunner); + it("should create a Runner with binary and args", () => { + const runner = RunnerFactory.create("node", ["--experimental"]); + expect(runner).toBeInstanceOf(Runner); + expect(runner.fullCommand(["script.js"])).toBe("node --experimental script.js"); }); - it("should create a NpmRunner", () => { - expect(RunnerFactory.create(Runner.NPM)).toBeInstanceOf(NpmRunner); + it("should create a local runner", () => { + const runner = RunnerFactory.create("npm"); + expect(runner).toBeInstanceOf(Runner); + expect(runner.fullCommand(["install"])).toBe("npm install"); }); - it("should create a PnpmRunner", () => { - expect(RunnerFactory.create(Runner.PNPM)).toBeInstanceOf(PnpmRunner); - }); - - it("should create a SchematicRunner", () => { - expect(RunnerFactory.create(Runner.SCHEMATIC)).toBeInstanceOf(SchematicRunner); - }); - - it("should create a YarnRunner", () => { - expect(RunnerFactory.create(Runner.YARN)).toBeInstanceOf(YarnRunner); - }); - - it("should throw for unsupported runner", () => { - expect(() => RunnerFactory.create(999 as Runner)).toThrow("Unsupported runner: 999"); + it("should create a schematic runner", () => { + const runner = RunnerFactory.createSchematic(); + expect(runner).toBeInstanceOf(Runner); }); }); diff --git a/src/lib/runner/runner.factory.ts b/src/lib/runner/runner.factory.ts index a9a07c4..1f8f428 100644 --- a/src/lib/runner/runner.factory.ts +++ b/src/lib/runner/runner.factory.ts @@ -1,32 +1,26 @@ -import { yellow } from "ansis"; +import { getModulePath, resolveCLINodeBinaryPath } from "@utils/path"; import { Runner } from "./runner"; -import { BunRunner } from "./runners/bun.runner"; -import { LocalBunRunner } from "./runners/local-bun.runner"; -import { NpmRunner } from "./runners/npm.runner"; -import { PnpmRunner } from "./runners/pnpm.runner"; -import { SchematicRunner } from "./runners/schematic.runner"; -import { YarnRunner } from "./runners/yarn.runner"; export class RunnerFactory { - public static create(runner: Runner) { - switch (runner) { - case Runner.BUN: - return new BunRunner(); - case Runner.LOCAL_BUN: - return new LocalBunRunner(); - case Runner.NPM: - return new NpmRunner(); - case Runner.PNPM: - return new PnpmRunner(); - case Runner.SCHEMATIC: - return new SchematicRunner(); - case Runner.YARN: - return new YarnRunner(); + public static create(binary: string, args?: string[]): Runner { + return new Runner(binary, args); + } + + public static createLocal(binary: string, args?: string[]): Runner { + return new Runner(resolveCLINodeBinaryPath(binary), args); + } + + public static createSchematic(): Runner { + const binaryPath = this.resolveSchematicBinary(); + return new Runner("node", [`"${binaryPath}"`]); + } - default: - console.info(yellow`[WARN] Unsupported runner: ${runner}`); - throw Error(`Unsupported runner: ${runner}`); + private static resolveSchematicBinary(): string { + try { + return getModulePath("@angular-devkit/schematics-cli/bin/schematics.js"); + } catch { + throw new Error("'schematics' binary path could not be found!"); } } } diff --git a/src/lib/runner/runner.spec.ts b/src/lib/runner/runner.spec.ts new file mode 100644 index 0000000..21bc4ed --- /dev/null +++ b/src/lib/runner/runner.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { Runner } from "./runner"; + +describe("Runner", () => { + describe("fullCommand", () => { + it("should combine binary, base args, and additional args", () => { + const runner = new Runner("node", ["--version"]); + expect(runner.fullCommand(["--help"])).toBe("node --version --help"); + }); + + it("should handle empty additional args", () => { + const runner = new Runner("node", ["--version"]); + expect(runner.fullCommand([])).toBe("node --version"); + }); + + it("should handle multiple additional args", () => { + const runner = new Runner("node", ["--version"]); + expect(runner.fullCommand(["run", "script.js"])).toBe("node --version run script.js"); + }); + + it("should handle runner with no base args", () => { + const runner = new Runner("bun"); + expect(runner.fullCommand(["install"])).toBe("bun install"); + }); + }); +}); diff --git a/src/lib/runner/runner.ts b/src/lib/runner/runner.ts index eeaf7d3..245a627 100644 --- a/src/lib/runner/runner.ts +++ b/src/lib/runner/runner.ts @@ -1,8 +1,96 @@ -export enum Runner { - BUN, - LOCAL_BUN, - NPM, - PNPM, - SCHEMATIC, - YARN, +import { red } from "ansis"; +import { type ChildProcess, type SpawnOptions, spawn } from "child_process"; +import * as process from "node:process"; + +export interface RunOptions { + collect?: boolean; + cwd?: string; + env?: Record; + listeners?: RunnerListeners; + onFail?: () => void; +} + +export interface RunnerListeners { + onStdout?: (chunk: string) => void; + onStderr?: (chunk: string) => void; +} + +export class Runner { + constructor( + private readonly binary: string, + private readonly baseArgs: string[] = [], + ) {} + + public async run(args: string[], options: RunOptions = {}): Promise { + const { collect = false, cwd = process.cwd(), env, listeners, onFail } = options; + const spawnOpts = this.buildSpawnOptions(collect, cwd, env); + const fullArgs = [...this.baseArgs, ...args]; + + return new Promise((resolve, reject) => { + const child = spawn(`${this.binary} ${fullArgs.join(" ")}`, spawnOpts); + const output = this.attachOutputHandlers(child, listeners); + + child.on("close", (code) => { + if (code === 0) { + resolve(this.formatOutput(output, collect)); + } else { + this.handleFailure(output, fullArgs, onFail); + reject(this.createError(fullArgs, code)); + } + }); + }); + } + + public fullCommand(args: string[]): string { + return [this.binary, ...this.baseArgs, ...args].join(" "); + } + + private buildSpawnOptions( + collect: boolean, + cwd: string, + env?: Record, + ): SpawnOptions { + return { + cwd, + stdio: collect ? "pipe" : "inherit", + shell: true, + env: { ...process.env, ...env }, + }; + } + + private attachOutputHandlers(child: ChildProcess, listeners?: RunnerListeners): string[] { + const output: string[] = []; + const defaultHandler = (data: Buffer) => output.push(data.toString().replace(/\r\n|\n/, "")); + + child.stdout?.on("data", listeners?.onStdout ?? defaultHandler); + child.stderr?.on("data", listeners?.onStderr ?? defaultHandler); + + return output; + } + + private formatOutput(output: string[], collect: boolean): string | null { + return collect && output.length ? output.join("\n") : null; + } + + private handleFailure(output: string[], args: string[], onFail?: () => void): void { + if (onFail) onFail(); + this.logFailedCommand(args); + this.logCapturedOutput(output); + } + + private logFailedCommand(args: string[]): void { + console.error(red(`\nFailed to execute command: ${this.binary} ${args.join(" ")}`)); + } + + private logCapturedOutput(output: string[]): void { + if (output.length) { + console.error(); + console.error(output.join("\n")); + console.error(); + } + } + + private createError(args: string[], code: number | null): Error { + return new Error(`Command "${this.binary} ${args.join(" ")}" exited with code ${code}`); + } } diff --git a/src/lib/runner/runners/bun.runner.ts b/src/lib/runner/runners/bun.runner.ts deleted file mode 100644 index d7be307..0000000 --- a/src/lib/runner/runners/bun.runner.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { AbstractRunner } from "../abstract.runner"; - -export class BunRunner extends AbstractRunner { - constructor() { - super("bun"); - } -} diff --git a/src/lib/runner/runners/local-bun.runner.ts b/src/lib/runner/runners/local-bun.runner.ts deleted file mode 100644 index f706597..0000000 --- a/src/lib/runner/runners/local-bun.runner.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { resolveCLINodeBinaryPath } from "@utils/path"; - -import { AbstractRunner } from "../abstract.runner"; - -export class LocalBunRunner extends AbstractRunner { - constructor() { - super(resolveCLINodeBinaryPath("bun")); - } -} diff --git a/src/lib/runner/runners/npm.runner.ts b/src/lib/runner/runners/npm.runner.ts deleted file mode 100644 index 77b8d8b..0000000 --- a/src/lib/runner/runners/npm.runner.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { AbstractRunner } from "../abstract.runner"; - -export class NpmRunner extends AbstractRunner { - constructor() { - super("npm"); - } -} diff --git a/src/lib/runner/runners/pnpm.runner.ts b/src/lib/runner/runners/pnpm.runner.ts deleted file mode 100644 index 98bedd4..0000000 --- a/src/lib/runner/runners/pnpm.runner.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { AbstractRunner } from "../abstract.runner"; - -export class PnpmRunner extends AbstractRunner { - constructor() { - super("pnpm"); - } -} diff --git a/src/lib/runner/runners/schematic.runner.ts b/src/lib/runner/runners/schematic.runner.ts deleted file mode 100644 index 8797bf2..0000000 --- a/src/lib/runner/runners/schematic.runner.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { getModulePath } from "@utils/path"; - -import { AbstractRunner } from "../abstract.runner"; - -export class SchematicRunner extends AbstractRunner { - public static getModulePaths() { - return module.paths; - } - - public static findClosestSchematicsBinary(): string { - try { - return getModulePath("@angular-devkit/schematics-cli/bin/schematics.js"); - } catch (e) { - console.error(e); - throw new Error("'schematics' binary path could not be found!"); - } - } - - constructor() { - super(`node`, [`"${SchematicRunner.findClosestSchematicsBinary()}"`]); - } -} diff --git a/src/lib/runner/runners/yarn.runner.ts b/src/lib/runner/runners/yarn.runner.ts deleted file mode 100644 index b92892b..0000000 --- a/src/lib/runner/runners/yarn.runner.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { AbstractRunner } from "../abstract.runner"; - -export class YarnRunner extends AbstractRunner { - constructor() { - super("yarn"); - } -} diff --git a/src/lib/schematics/abstract.collection.ts b/src/lib/schematics/abstract.collection.ts index e512bca..95d6a2e 100644 --- a/src/lib/schematics/abstract.collection.ts +++ b/src/lib/schematics/abstract.collection.ts @@ -1,13 +1,14 @@ +import { type Runner } from "@lib/runner/runner"; + import { getCwd } from "@utils/path"; -import { type AbstractRunner } from "../runner/abstract.runner"; import { type Schematic } from "./nanoforge.collection"; import { type SchematicOption } from "./schematic.option"; export abstract class AbstractCollection { protected constructor( protected collection: string, - protected runner: AbstractRunner, + protected runner: Runner, protected cwd?: string, ) {} @@ -15,17 +16,14 @@ export abstract class AbstractCollection { name: string, options: SchematicOption[], flags?: string[], - failSpinner?: () => void, - ) { + onFail?: () => void, + ): Promise { const command = this.buildCommandLine(name, options, flags); - await this.runner.run( - command, - true, - this.cwd ? getCwd(this.cwd) : undefined, - undefined, - undefined, - failSpinner, - ); + await this.runner.run(command, { + collect: true, + cwd: this.cwd ? getCwd(this.cwd) : undefined, + onFail, + }); } public abstract getSchematics(): Schematic[]; @@ -35,13 +33,10 @@ export abstract class AbstractCollection { options: SchematicOption[], flags: string[] = [], ): string[] { - return [`${this.collection}:${name}`, ...flags, ...this.buildOptions(options)]; + return [`${this.collection}:${name}`, ...flags, ...this.serializeOptions(options)]; } - private buildOptions(options: SchematicOption[]): string[] { - return options.reduce( - (old: string[], option: SchematicOption) => [...old, ...option.toCommandString()], - [], - ); + private serializeOptions(options: SchematicOption[]): string[] { + return options.flatMap((option) => option.toCommandString()); } } diff --git a/src/lib/schematics/collection.factory.ts b/src/lib/schematics/collection.factory.ts index ae18638..8a6e0f5 100644 --- a/src/lib/schematics/collection.factory.ts +++ b/src/lib/schematics/collection.factory.ts @@ -1,17 +1,16 @@ -import { Runner, RunnerFactory } from "../runner"; -import { type SchematicRunner } from "../runner/runners/schematic.runner"; +import { RunnerFactory } from "@lib/runner"; + import { type AbstractCollection } from "./abstract.collection"; import { Collection } from "./collection"; import { NanoforgeCollection } from "./nanoforge.collection"; export class CollectionFactory { public static create(collection: Collection | string, directory: string): AbstractCollection { - const schematicRunner = RunnerFactory.create(Runner.SCHEMATIC) as SchematicRunner; + const schematicRunner = RunnerFactory.createSchematic(); if (collection === Collection.NANOFORGE) { return new NanoforgeCollection(schematicRunner, directory); - } else { - return new NanoforgeCollection(schematicRunner, directory); } + throw new Error(`Unknown collection: ${collection}`); } } diff --git a/src/lib/schematics/nanoforge.collection.ts b/src/lib/schematics/nanoforge.collection.ts index 72279ce..8ee995e 100644 --- a/src/lib/schematics/nanoforge.collection.ts +++ b/src/lib/schematics/nanoforge.collection.ts @@ -1,4 +1,5 @@ -import { type AbstractRunner } from "../runner/abstract.runner"; +import { type Runner } from "@lib/runner/runner"; + import { AbstractCollection } from "./abstract.collection"; import { type SchematicOption } from "./schematic.option"; @@ -32,7 +33,7 @@ export class NanoforgeCollection extends AbstractCollection { }, ]; - constructor(runner: AbstractRunner, cwd?: string) { + constructor(runner: Runner, cwd?: string) { super("@nanoforge-dev/schematics", runner, cwd); } diff --git a/src/lib/ui/index.ts b/src/lib/ui/index.ts index 120b1b1..56bef50 100644 --- a/src/lib/ui/index.ts +++ b/src/lib/ui/index.ts @@ -1,2 +1,3 @@ export * from "./messages"; export * from "./prefixes"; +export * from "./spinner"; diff --git a/src/lib/ui/messages.ts b/src/lib/ui/messages.ts index 2ab922c..c4e1f43 100644 --- a/src/lib/ui/messages.ts +++ b/src/lib/ui/messages.ts @@ -2,52 +2,73 @@ import { green } from "ansis"; import { Emojis } from "./emojis"; +const success = (text: string) => `${Emojis.ROCKET} ${text}`; +const failure = (text: string) => `${Emojis.SCREAM} ${text}`; + export const Messages = { + // --- Build --- BUILD_START: "NanoForge Build", - BUILD_WATCH_START: "Start watching mode", + BUILD_SUCCESS: success("Build succeeded!"), + BUILD_FAILED: failure("Build failed!"), + BUILD_WATCH_START: "Watching for changes...", BUILD_PART_IN_PROGRESS: (part: string) => `Building ${part}`, - BUILD_PART_WATCH_IN_PROGRESS: (part: string) => `${part} updated. Rebuilding`, - BUILD_NOTHING: "Nothing to build, terminated.", - BUILD_SUCCESS: `${Emojis.ROCKET} Build succeeded !`, - BUILD_PART_FAILED: (part: string, commandToRunManually: string) => - `${Emojis.SCREAM} Build of ${part} failed !\nIn case you don't see any errors above, consider manually running the failed command ${commandToRunManually} to see more details on why it errored out.`, - BUILD_FAILED: `${Emojis.SCREAM} Build failed !`, + BUILD_PART_WATCH_IN_PROGRESS: (part: string) => `${part} updated, rebuilding`, + BUILD_PART_FAILED: (part: string, command: string) => + failure(`Build of ${part} failed!\nTry running manually: ${command}`), + + // --- Install --- INSTALL_START: "NanoForge Installation", - INSTALL_NAMES_QUESTION: "Witch libraries do you want to install ?", + INSTALL_SUCCESS: success("Installation completed!"), + INSTALL_FAILED: failure("Installation failed!"), + INSTALL_NAMES_QUESTION: "Which libraries do you want to install?", + + // --- New Project --- NEW_START: "NanoForge Project Creation", - NEW_SUCCESS: `${Emojis.ROCKET} Project successfully created !`, - NEW_FAILED: `${Emojis.SCREAM} Project creation failed !`, - NEW_NAME_QUESTION: "What is the name of your project ?", - NEW_PACKAGE_MANAGER_QUESTION: "Which package manager do you want to use ?", - NEW_LANGUAGE_QUESTION: "Which language do you want to use ?", - NEW_STRICT_QUESTION: "Do you want to use types strict mode ?", - NEW_SERVER_QUESTION: "Do you want generate a server to create a multiplayer game ?", - NEW_SKIP_INSTALL_QUESTION: "Do you want to skip installation ?", + NEW_SUCCESS: success("Project successfully created!"), + NEW_FAILED: failure("Project creation failed!"), + NEW_NAME_QUESTION: "What is the name of your project?", + NEW_PACKAGE_MANAGER_QUESTION: "Which package manager do you want to use?", + NEW_LANGUAGE_QUESTION: "Which language do you want to use?", + NEW_STRICT_QUESTION: "Do you want to use strict type checking?", + NEW_SERVER_QUESTION: "Do you want to generate a server for multiplayer?", + NEW_SKIP_INSTALL_QUESTION: "Do you want to skip dependency installation?", + + // --- Generate --- GENERATE_START: "NanoForge Generate", - GENERATE_WATCH_START: "Start watching mode", - GENERATE_SUCCESS: `${Emojis.ROCKET} Generate succeeded !`, - GENERATE_FAILED: `${Emojis.SCREAM} Generate failed !`, - DEV_START: "NanoForge Dev mode", + GENERATE_SUCCESS: success("Generation succeeded!"), + GENERATE_FAILED: failure("Generation failed!"), + GENERATE_WATCH_START: "Watching for changes...", + + // --- Dev --- + DEV_START: "NanoForge Dev Mode", DEV_SUCCESS: "Dev mode ended", - DEV_FAILED: `${Emojis.SCREAM} Dev failed !`, - SCHEMATICS_START: "Schematics execution", - SCHEMATIC_IN_PROGRESS: (name: string) => `Executing schematic ${name}...`, - SCHEMATIC_WATCH_IN_PROGRESS: (name: string) => `Update watched. Executing schematic ${name}...`, - SCHEMATIC_SUCCESS: (name: string) => - `${Emojis.ROCKET} Schematic ${name} executed successfully !`, - SCHEMATIC_FAILED: (name: string) => - `${Emojis.SCREAM} Schematic ${name} execution failed. See error below for more details.`, - PACKAGE_MANAGER_INSTALLATION_IN_PROGRESS: `Installation in progress... ${Emojis.COFFEE}`, - PACKAGE_MANAGER_INSTALLATION_NOTHING: "Nothing to install, terminated.", + DEV_FAILED: failure("Dev mode failed!"), + + // --- Start --- + START_START: "NanoForge Start", + START_SUCCESS: success("Start completed!"), + START_FAILED: failure("Start failed!"), + START_PART_IN_PROGRESS: (part: string) => `Starting ${part}...`, + START_PART_SUCCESS: (part: string) => success(`${part} terminated.`), + START_PART_FAILED: (part: string) => failure(`${part} failed!`), + + // --- Schematics --- + SCHEMATICS_START: "Running schematics", + SCHEMATIC_IN_PROGRESS: (name: string) => `Generating ${name}...`, + SCHEMATIC_WATCH_IN_PROGRESS: (name: string) => `Change detected, regenerating ${name}...`, + SCHEMATIC_SUCCESS: (name: string) => success(`${name} generated successfully!`), + SCHEMATIC_FAILED: (name: string) => failure(`${name} generation failed.`), + + // --- Package Manager --- + PACKAGE_MANAGER_INSTALLATION_IN_PROGRESS: `Installing dependencies... ${Emojis.COFFEE}`, + PACKAGE_MANAGER_INSTALLATION_NOTHING: "Nothing to install.", PACKAGE_MANAGER_INSTALLATION_SUCCEED: (names?: string[]) => names - ? `${Emojis.ROCKET} Packages successfully installed : ${names.map((name) => green(name)).join(", ")} !` - : `${Emojis.ROCKET} Packages successfully installed !`, - PACKAGE_MANAGER_INSTALLATION_FAILED: (commandToRunManually: string) => - `${Emojis.SCREAM} Packages installation failed !\nIn case you don't see any errors above, consider manually running the failed command ${commandToRunManually} to see more details on why it errored out.`, - RUN_START: "NanoForge Run", - RUN_PART_IN_PROGRESS: (part: string) => `Running ${part}...`, - RUN_PART_SUCCESS: (part: string) => `${Emojis.ROCKET} Run of ${part} terminated.`, - RUN_PART_FAILED: (part: string) => `${Emojis.SCREAM} Run of ${part} failed !`, + ? success(`Packages installed: ${names.map((n) => green(n)).join(", ")}`) + : success("Packages installed!"), + PACKAGE_MANAGER_INSTALLATION_FAILED: (command: string) => + failure(`Package installation failed!\nTry running manually: ${command}`), + + // --- Runner --- RUNNER_EXECUTION_ERROR: (command: string) => `\nFailed to execute command: ${command}`, }; diff --git a/src/lib/ui/spinner.ts b/src/lib/ui/spinner.ts new file mode 100644 index 0000000..14ff867 --- /dev/null +++ b/src/lib/ui/spinner.ts @@ -0,0 +1,3 @@ +import ora from "ora"; + +export const getSpinner = (message: string) => ora({ text: message }); diff --git a/src/lib/utils/errors.spec.ts b/src/lib/utils/errors.spec.ts index 74c3123..be4c69a 100644 --- a/src/lib/utils/errors.spec.ts +++ b/src/lib/utils/errors.spec.ts @@ -4,12 +4,13 @@ import { promptError } from "./errors"; describe("promptError", () => { it("should exit process on ExitPromptError", () => { - const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => undefined as never); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("process.exit called"); + }); const error = new Error("exit"); error.name = "ExitPromptError"; - promptError(error); - + expect(() => promptError(error)).toThrow("process.exit called"); expect(exitSpy).toHaveBeenCalledWith(1); exitSpy.mockRestore(); }); diff --git a/src/lib/utils/errors.ts b/src/lib/utils/errors.ts index 6071ed8..181ebdc 100644 --- a/src/lib/utils/errors.ts +++ b/src/lib/utils/errors.ts @@ -1,7 +1,22 @@ -export const promptError = (err: Error) => { +import { red } from "ansis"; + +export const getErrorMessage = (error: unknown): string | undefined => { + if (error instanceof Error) return error.message; + if (typeof error === "string") return error; + return undefined; +}; + +export const handleActionError = (context: string, error: unknown): never => { + console.error(); + console.error(red(context)); + const msg = getErrorMessage(error); + if (msg) console.error(msg); + process.exit(1); +}; + +export const promptError = (err: Error): never => { if (err.name === "ExitPromptError") { process.exit(1); - } else { - throw err; } + throw err; }; diff --git a/src/lib/utils/run-safe.ts b/src/lib/utils/run-safe.ts new file mode 100644 index 0000000..c463f2e --- /dev/null +++ b/src/lib/utils/run-safe.ts @@ -0,0 +1,13 @@ +import { red } from "ansis"; + +import { getErrorMessage } from "@utils/errors"; + +export const runSafe = async (fn: () => Promise, fallback?: T): Promise => { + try { + return await fn(); + } catch (error: unknown) { + const msg = getErrorMessage(error); + if (msg) console.error(red(msg)); + return fallback; + } +};