From b1d9f080c06d51c744b7368eecc63e70b0d55c8e Mon Sep 17 00:00:00 2001 From: Exelo Date: Tue, 10 Mar 2026 14:04:40 +0100 Subject: [PATCH] feat: add publish and unpublish commands --- e2e/cli-publish.test.ts | 120 +++++++++++++++++++++ e2e/cli-unpublish.test.ts | 88 +++++++++++++++ src/action/abstract.action.ts | 3 +- src/action/actions/publish.action.ts | 24 +++++ src/action/actions/unpublish.action.ts | 24 +++++ src/action/index.ts | 2 + src/command/command.loader.ts | 12 ++- src/command/commands/publish.command.ts | 23 ++++ src/command/commands/unpublish.command.ts | 23 ++++ src/lib/constants.ts | 1 + src/lib/http/client.ts | 10 +- src/lib/http/http-client.ts | 10 +- src/lib/http/index.ts | 1 + src/lib/http/repository.ts | 17 +-- src/lib/manifest/index.ts | 2 + src/lib/manifest/manifest-loader.ts | 39 +++++++ src/lib/manifest/manifest.type.ts | 81 ++++++++++++++ src/lib/package-manager/package-manager.ts | 33 +----- src/lib/registry/index.ts | 1 + src/lib/registry/registry.ts | 50 +++++++++ src/lib/ui/messages.ts | 12 +++ src/lib/utils/errors.ts | 11 +- src/lib/utils/spinner.ts | 28 +++++ 23 files changed, 568 insertions(+), 47 deletions(-) create mode 100644 e2e/cli-publish.test.ts create mode 100644 e2e/cli-unpublish.test.ts create mode 100644 src/action/actions/publish.action.ts create mode 100644 src/action/actions/unpublish.action.ts create mode 100644 src/command/commands/publish.command.ts create mode 100644 src/command/commands/unpublish.command.ts create mode 100644 src/lib/manifest/index.ts create mode 100644 src/lib/manifest/manifest-loader.ts create mode 100644 src/lib/manifest/manifest.type.ts create mode 100644 src/lib/registry/index.ts create mode 100644 src/lib/registry/registry.ts create mode 100644 src/lib/utils/spinner.ts diff --git a/e2e/cli-publish.test.ts b/e2e/cli-publish.test.ts new file mode 100644 index 0000000..94abf59 --- /dev/null +++ b/e2e/cli-publish.test.ts @@ -0,0 +1,120 @@ +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +import { runCli } from "./helpers/run-cli"; + +const tmpDir = resolve(__dirname, "../.tmp-e2e-publish"); +const FETCH_MOCK_PATH = resolve(__dirname, "./helpers/fetch-mock.mjs"); + +function withMockedFetch(status = 200): NodeJS.ProcessEnv { + const existing = process.env.NODE_OPTIONS ?? ""; + return { + ...process.env, + NODE_OPTIONS: `${existing} --import ${FETCH_MOCK_PATH}`.trim(), + MOCK_REGISTRY_STATUS: String(status), + }; +} + +function writeManifest(dir: string, overrides: Record = {}) { + const manifest = { + name: "test-org/test-package", + type: "component", + ...overrides, + }; + writeFileSync(resolve(dir, "nanoforge.manifest.json"), JSON.stringify(manifest)); +} + +function writePackageFile(dir: string, filename = "index.ts") { + writeFileSync(resolve(dir, filename), `export default {};`); +} + +beforeAll(() => { + mkdirSync(tmpDir, { recursive: true }); +}); + +afterAll(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("nf publish --help", () => { + it("should display publish command help", async () => { + const { stdout, exitCode } = await runCli(["publish", "--help"]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("publish package to Nanoforge registry"); + expect(stdout).toContain("--directory"); + }); +}); + +describe("nf publish", () => { + const dir = resolve(tmpDir, "publish-main"); + + beforeEach(async () => { + mkdirSync(dir, { recursive: true }); + await runCli(["login", "--local", "-d", dir, "-k", "test-api-key"], { + env: withMockedFetch(), + }); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("should publish successfully with a valid manifest and package file", async () => { + writeManifest(dir); + writePackageFile(dir); + + const { stdout, exitCode } = await runCli(["publish", "-d", dir], { + env: withMockedFetch(), + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("NanoForge Publish"); + expect(stdout).toContain("Publish completed!"); + }); + + it("should fail when the manifest file is missing", async () => { + writePackageFile(dir); + + const { exitCode } = await runCli(["publish", "-d", dir], { + env: withMockedFetch(), + }); + + expect(exitCode).not.toBe(0); + }); + + it("should fail when the package file is missing", async () => { + writeManifest(dir); + + const { exitCode } = await runCli(["publish", "-d", dir], { + env: withMockedFetch(), + }); + + expect(exitCode).not.toBe(0); + }); + + it("should fail when the registry rejects the request", async () => { + writeManifest(dir); + writePackageFile(dir); + + const { exitCode } = await runCli(["publish", "-d", dir], { + env: withMockedFetch(401), + }); + + expect(exitCode).not.toBe(0); + }); + + it("should use a custom package file path from the manifest", async () => { + writeManifest(dir, { publish: { paths: { package: "src/main.ts" } } }); + mkdirSync(resolve(dir, "src"), { recursive: true }); + writePackageFile(dir, "src/main.ts"); + + const { stdout, exitCode } = await runCli(["publish", "-d", dir], { + env: withMockedFetch(), + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("Publish completed!"); + }); +}); diff --git a/e2e/cli-unpublish.test.ts b/e2e/cli-unpublish.test.ts new file mode 100644 index 0000000..c1608ca --- /dev/null +++ b/e2e/cli-unpublish.test.ts @@ -0,0 +1,88 @@ +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +import { runCli } from "./helpers/run-cli"; + +const tmpDir = resolve(__dirname, "../.tmp-e2e-unpublish"); +const FETCH_MOCK_PATH = resolve(__dirname, "./helpers/fetch-mock.mjs"); + +function withMockedFetch(status = 200): NodeJS.ProcessEnv { + const existing = process.env.NODE_OPTIONS ?? ""; + return { + ...process.env, + NODE_OPTIONS: `${existing} --import ${FETCH_MOCK_PATH}`.trim(), + MOCK_REGISTRY_STATUS: String(status), + }; +} + +function writeManifest(dir: string) { + const manifest = { + name: "test-org/test-package", + type: "component", + }; + writeFileSync(resolve(dir, "nanoforge.manifest.json"), JSON.stringify(manifest)); +} + +beforeAll(() => { + mkdirSync(tmpDir, { recursive: true }); +}); + +afterAll(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe("nf unpublish --help", () => { + it("should display unpublish command help", async () => { + const { stdout, exitCode } = await runCli(["unpublish", "--help"]); + + expect(exitCode).toBe(0); + expect(stdout).toContain("unpublish package to Nanoforge registry"); + expect(stdout).toContain("--directory"); + }); +}); + +describe("nf unpublish", () => { + const dir = resolve(tmpDir, "unpublish-main"); + + beforeEach(async () => { + mkdirSync(dir, { recursive: true }); + await runCli(["login", "--local", "-d", dir, "-k", "test-api-key"], { + env: withMockedFetch(), + }); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("should unpublish successfully with a valid manifest", async () => { + writeManifest(dir); + + const { stdout, exitCode } = await runCli(["unpublish", "-d", dir], { + env: withMockedFetch(), + }); + + expect(exitCode).toBe(0); + expect(stdout).toContain("NanoForge Unpublish"); + expect(stdout).toContain("Unpublish completed!"); + }); + + it("should fail when the manifest file is missing", async () => { + const { exitCode } = await runCli(["unpublish", "-d", dir], { + env: withMockedFetch(), + }); + + expect(exitCode).not.toBe(0); + }); + + it("should fail when the registry rejects the request", async () => { + writeManifest(dir); + + const { exitCode } = await runCli(["unpublish", "-d", dir], { + env: withMockedFetch(401), + }); + + expect(exitCode).not.toBe(0); + }); +}); diff --git a/src/action/abstract.action.ts b/src/action/abstract.action.ts index 27be51a..acbf9e0 100644 --- a/src/action/abstract.action.ts +++ b/src/action/abstract.action.ts @@ -6,6 +6,7 @@ import { handleActionError } from "@utils/errors"; export interface HandleResult { success?: boolean; keepAlive?: boolean; + error?: unknown; } export abstract class AbstractAction { @@ -45,7 +46,7 @@ export abstract class AbstractAction { console.info(); if (!success) { - if (this.failureMessage) console.error(this.failureMessage); + handleActionError(this.failureMessage, result.error); process.exit(1); } diff --git a/src/action/actions/publish.action.ts b/src/action/actions/publish.action.ts new file mode 100644 index 0000000..4911cff --- /dev/null +++ b/src/action/actions/publish.action.ts @@ -0,0 +1,24 @@ +import { type Input, getDirectoryInput } from "@lib/input"; +import { loadManifest } from "@lib/manifest"; +import { Registry } from "@lib/registry"; +import { Messages } from "@lib/ui"; + +import { withSpinner } from "@utils/spinner"; + +import { AbstractAction, type HandleResult } from "../abstract.action"; + +export class PublishAction extends AbstractAction { + protected startMessage = Messages.PUBLISH_START; + protected successMessage = Messages.PUBLISH_SUCCESS; + protected failureMessage = Messages.PUBLISH_FAILED; + + public async handle(_args: Input, options: Input): Promise { + const directory = getDirectoryInput(options); + + const manifest = await loadManifest(directory); + + return withSpinner(Messages.PUBLISH_IN_PROGRESS(manifest.name), async () => { + await Registry.publish(manifest, directory); + }); + } +} diff --git a/src/action/actions/unpublish.action.ts b/src/action/actions/unpublish.action.ts new file mode 100644 index 0000000..12fa769 --- /dev/null +++ b/src/action/actions/unpublish.action.ts @@ -0,0 +1,24 @@ +import { type Input, getDirectoryInput } from "@lib/input"; +import { loadManifest } from "@lib/manifest"; +import { Registry } from "@lib/registry"; +import { Messages } from "@lib/ui"; + +import { withSpinner } from "@utils/spinner"; + +import { AbstractAction, type HandleResult } from "../abstract.action"; + +export class UnpublishAction extends AbstractAction { + protected startMessage = Messages.UNPUBLISH_START; + protected successMessage = Messages.UNPUBLISH_SUCCESS; + protected failureMessage = Messages.UNPUBLISH_FAILED; + + public async handle(_args: Input, options: Input): Promise { + const directory = getDirectoryInput(options); + + const manifest = await loadManifest(directory); + + return withSpinner(Messages.UNPUBLISH_IN_PROGRESS(manifest.name), async () => { + await Registry.unpublish(manifest, directory); + }); + } +} diff --git a/src/action/index.ts b/src/action/index.ts index 11a9655..f4acc36 100644 --- a/src/action/index.ts +++ b/src/action/index.ts @@ -5,4 +5,6 @@ export * from "./actions/install.action"; export * from "./actions/login.action"; export * from "./actions/logout.action"; export * from "./actions/new.action"; +export * from "./actions/publish.action"; export * from "./actions/start.action"; +export * from "./actions/unpublish.action"; diff --git a/src/command/command.loader.ts b/src/command/command.loader.ts index 78df8a9..2032727 100644 --- a/src/command/command.loader.ts +++ b/src/command/command.loader.ts @@ -11,7 +11,9 @@ import { LoginAction, LogoutAction, NewAction, + PublishAction, StartAction, + UnpublishAction, } from "~/action"; import { BuildCommand } from "./commands/build.command"; @@ -21,18 +23,22 @@ import { InstallCommand } from "./commands/install.command"; import { LoginCommand } from "./commands/login.command"; import { LogoutCommand } from "./commands/logout.command"; import { NewCommand } from "./commands/new.command"; +import { PublishCommand } from "./commands/publish.command"; import { StartCommand } from "./commands/start.command"; +import { UnpublishCommand } from "./commands/unpublish.command"; export class CommandLoader { public static async load(program: Command): Promise { + new NewCommand(new NewAction()).load(program); + new InstallCommand(new InstallAction()).load(program); new BuildCommand(new BuildAction()).load(program); + new StartCommand(new StartAction()).load(program); new DevCommand(new DevAction()).load(program); new GenerateCommand(new GenerateAction()).load(program); - new InstallCommand(new InstallAction()).load(program); new LoginCommand(new LoginAction()).load(program); new LogoutCommand(new LogoutAction()).load(program); - new NewCommand(new NewAction()).load(program); - new StartCommand(new StartAction()).load(program); + new PublishCommand(new PublishAction()).load(program); + new UnpublishCommand(new UnpublishAction()).load(program); this.handleInvalidCommand(program); } diff --git a/src/command/commands/publish.command.ts b/src/command/commands/publish.command.ts new file mode 100644 index 0000000..558465f --- /dev/null +++ b/src/command/commands/publish.command.ts @@ -0,0 +1,23 @@ +import { type Command } from "commander"; + +import { AbstractCommand } from "../abstract.command"; + +interface PublishOptions { + directory?: string; +} + +export class PublishCommand extends AbstractCommand { + public load(program: Command) { + program + .command("publish") + .description("publish package to Nanoforge registry") + .option("-d, --directory [directory]", "specify the directory of your project") + .action(async (rawOptions: PublishOptions) => { + const options = AbstractCommand.mapToInput({ + directory: rawOptions.directory, + }); + + await this.action.run(new Map(), options); + }); + } +} diff --git a/src/command/commands/unpublish.command.ts b/src/command/commands/unpublish.command.ts new file mode 100644 index 0000000..546512b --- /dev/null +++ b/src/command/commands/unpublish.command.ts @@ -0,0 +1,23 @@ +import { type Command } from "commander"; + +import { AbstractCommand } from "../abstract.command"; + +interface UnpublishOptions { + directory?: string; +} + +export class UnpublishCommand extends AbstractCommand { + public load(program: Command) { + program + .command("unpublish") + .description("unpublish package to Nanoforge registry") + .option("-d, --directory [directory]", "specify the directory of your project") + .action(async (rawOptions: UnpublishOptions) => { + const options = AbstractCommand.mapToInput({ + directory: rawOptions.directory, + }); + + await this.action.run(new Map(), options); + }); + } +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index db78430..a4b1663 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -1,4 +1,5 @@ export const CONFIG_FILE_NAME = "nanoforge.config.json"; +export const MANIFEST_FILE_NAME = "nanoforge.manifest.json"; export const GLOBAL_CONFIG_FILE_NAME = ".nanoforgerc"; export const DEFAULT_DIRECTORY = "."; export const NANOFORGE_DIR = ".nanoforge"; diff --git a/src/lib/http/client.ts b/src/lib/http/client.ts index 8021279..afe48d9 100644 --- a/src/lib/http/client.ts +++ b/src/lib/http/client.ts @@ -7,7 +7,13 @@ const client = new HttpClient(REGISTRY_URL ?? ""); export const api = new Repository(client); -export const withAuth = (apiKey?: string, force: boolean = false) => { +export const withAuth = ( + apiKey?: string, + force: boolean = false, + headers: object = { + "Content-Type": "application/json", + }, +) => { if (!apiKey && force) { console.error("No registry key found. Please use `nf login` to login"); throw new Error("No apikey found. Please use `nf login` to login"); @@ -16,7 +22,7 @@ export const withAuth = (apiKey?: string, force: boolean = false) => { new HttpClient(REGISTRY_URL ?? "", { headers: { Authorization: apiKey, - "Content-Type": "application/json", + ...headers, }, }), ); diff --git a/src/lib/http/http-client.ts b/src/lib/http/http-client.ts index 5a9402a..43cedf2 100644 --- a/src/lib/http/http-client.ts +++ b/src/lib/http/http-client.ts @@ -41,7 +41,7 @@ export class HttpClient { }); } - post(path: string, body?: string, options?: RequestOptions): Promise { + post(path: string, body?: string | FormData, options?: RequestOptions): Promise { return this._applyMiddlewares(path, options, (newPath, newOptions) => { return this._request(newPath, { ...newOptions, @@ -51,7 +51,7 @@ export class HttpClient { }); } - put(path: string, body?: string, options?: RequestOptions): Promise { + put(path: string, body?: string | FormData, options?: RequestOptions): Promise { return this._applyMiddlewares(path, options, (newPath, newOptions) => { return this._request(newPath, { ...newOptions, @@ -61,7 +61,7 @@ export class HttpClient { }); } - patch(path: string, body?: string, options?: RequestOptions): Promise { + patch(path: string, body?: string | FormData, options?: RequestOptions): Promise { return this._applyMiddlewares(path, options, async (newPath, newOptions) => { return this._request(newPath, { ...newOptions, @@ -103,6 +103,10 @@ export class HttpClient { options: { ...this._baseOptions, ...options, + headers: { + ...this._baseOptions.headers, + ...options?.headers, + }, }, }; const middlewares = this._middlewares.slice(); diff --git a/src/lib/http/index.ts b/src/lib/http/index.ts index b011fbc..df9ef52 100644 --- a/src/lib/http/index.ts +++ b/src/lib/http/index.ts @@ -1 +1,2 @@ export { api, withAuth } from "./client"; +export type { Repository } from "./repository"; diff --git a/src/lib/http/repository.ts b/src/lib/http/repository.ts index 1c3d72a..9960e15 100644 --- a/src/lib/http/repository.ts +++ b/src/lib/http/repository.ts @@ -13,7 +13,7 @@ export class Repository { post( path: string, - body?: I, + body?: I | FormData, options?: RequestOptions, ): Promise { return this.runRequestBody("post", path, body ?? {}, options); @@ -21,7 +21,7 @@ export class Repository { put( path: string, - body?: I, + body?: I | FormData, options?: RequestOptions, ): Promise { return this.runRequestBody("put", path, body ?? {}, options); @@ -29,7 +29,7 @@ export class Repository { patch( path: string, - body?: I, + body?: I | FormData, options?: RequestOptions, ): Promise { return this.runRequestBody("patch", path, body ?? {}, options); @@ -45,28 +45,29 @@ export class Repository { options?: RequestOptions, ): Promise { const res = await this._client[request](path, options); + const data = (await res.json()) as R; if (!res.ok) throw new Error(`Request failed with status code ${res.status}`, { - cause: res, + cause: data["error" as keyof R], }); - return (await res.json()) as R; + return data; } private async runRequestBody( request: "post" | "put" | "patch", path: string, - body?: I, + body?: I | FormData, options?: RequestOptions, ): Promise { const res = await this._client[request]( path, - body === undefined ? undefined : JSON.stringify(body), + body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body), options, ); const data = (await res.json()) as R; if (!res.ok) throw new Error(`Request failed with status code ${res.status}`, { - cause: data, + cause: data["error" as keyof R], }); return data; } diff --git a/src/lib/manifest/index.ts b/src/lib/manifest/index.ts new file mode 100644 index 0000000..efe049f --- /dev/null +++ b/src/lib/manifest/index.ts @@ -0,0 +1,2 @@ +export * from "./manifest.type"; +export * from "./manifest-loader"; diff --git a/src/lib/manifest/manifest-loader.ts b/src/lib/manifest/manifest-loader.ts new file mode 100644 index 0000000..2eb6af7 --- /dev/null +++ b/src/lib/manifest/manifest-loader.ts @@ -0,0 +1,39 @@ +import { plainToInstance } from "class-transformer"; +import { validate } from "class-validator"; +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { MANIFEST_FILE_NAME } from "@lib/constants"; + +import { deepMerge } from "@utils/object"; + +import { Manifest } from "./manifest.type"; + +const getManifestPath = (directory: string) => { + for (const n of [MANIFEST_FILE_NAME]) { + const path = join(directory, n); + if (existsSync(path)) return path; + } + throw new Error(`No manifest file found in directory: ${directory}`); +}; + +export const loadManifest = async (directory: string): Promise => { + let rawData; + + const path = getManifestPath(directory); + try { + rawData = deepMerge({}, JSON.parse(readFileSync(path, "utf-8"))); + } catch { + rawData = null; + } + if (!rawData) throw new Error(`Not able to read manifest file : ${path}`); + + const data = plainToInstance(Manifest, rawData, { + excludeExtraneousValues: true, + }); + + const errors = await validate(data); + if (errors.length > 0) + throw new Error(`Invalid manifest\n${errors.toString().replace(/,/g, "\n")}`); + return data; +}; diff --git a/src/lib/manifest/manifest.type.ts b/src/lib/manifest/manifest.type.ts new file mode 100644 index 0000000..47acf2d --- /dev/null +++ b/src/lib/manifest/manifest.type.ts @@ -0,0 +1,81 @@ +import { Expose, Type } from "class-transformer"; +import { + IsEnum, + IsNotEmpty, + IsObject, + IsOptional, + IsString, + Matches, + ValidateNested, +} from "class-validator"; + +export enum ManifestPackageTypeEnum { + COMPONENT = "component", + SYSTEM = "system", +} + +class PathsPublishManifest { + @Expose() + @IsString() + @IsNotEmpty() + @IsOptional() + package?: string; +} + +class PublishManifest { + @Expose() + @IsOptional() + @ValidateNested() + @Type(() => PathsPublishManifest) + paths?: PathsPublishManifest; +} + +export class Manifest { + @Expose() + @IsString() + @Matches(/^[A-Za-z0-9-]+\/[A-Za-z0-9-]+$/) + @IsNotEmpty() + name!: string; + + @Expose() + @IsString() + @IsEnum(ManifestPackageTypeEnum) + @IsNotEmpty() + type!: ManifestPackageTypeEnum; + + @Expose() + @IsString() + @IsOptional() + description?: string; + + @Expose() + @IsString({ each: true }) + @IsNotEmpty({ each: true }) + @IsOptional() + dependencies?: string[]; + + @Expose() + @IsOptional() + @ValidateNested() + @Type(() => PublishManifest) + publish?: PublishManifest; + + @Expose() + @IsObject() + @IsOptional() + npmDependencies?: Record; +} + +export interface FullManifest { + name: string; + type: ManifestPackageTypeEnum; + description?: string; + dependencies?: string[]; + npmDependencies?: Record; + publish?: { + paths?: { + components?: string; + }; + }; + _file: string; +} diff --git a/src/lib/package-manager/package-manager.ts b/src/lib/package-manager/package-manager.ts index 466452d..e41ec04 100644 --- a/src/lib/package-manager/package-manager.ts +++ b/src/lib/package-manager/package-manager.ts @@ -1,20 +1,14 @@ 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 { withSpinner } from "@utils/spinner"; import { type PackageManagerCommands } from "./package-manager-commands"; -interface SpinnerTaskResult { - success: boolean; - value?: T; -} - export class PackageManager { constructor( public readonly name: string, @@ -25,7 +19,7 @@ export class PackageManager { public async install(directory: string): Promise { const args = [this.commands.install, this.commands.silentFlag]; - const result = await this.withSpinner( + const result = await withSpinner( Messages.PACKAGE_MANAGER_INSTALLATION_IN_PROGRESS, async (spinner) => { await this.exec(args, directory, { onFail: () => spinner.fail() }); @@ -69,7 +63,7 @@ export class PackageManager { ...flags, ]; - const result = await this.withSpinner( + const result = await withSpinner( message, async (spinner) => { await this.exec(args, directory, { onFail: () => spinner.fail() }); @@ -123,25 +117,6 @@ export class PackageManager { } } - 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, @@ -154,7 +129,7 @@ export class PackageManager { const args = [this.commands.add, saveFlag, ...dependencies]; - const result = await this.withSpinner( + const result = await withSpinner( Messages.PACKAGE_MANAGER_INSTALLATION_IN_PROGRESS, async (spinner) => { await this.exec(args, directory, { onFail: () => spinner.fail() }); diff --git a/src/lib/registry/index.ts b/src/lib/registry/index.ts new file mode 100644 index 0000000..cd6849a --- /dev/null +++ b/src/lib/registry/index.ts @@ -0,0 +1 @@ +export * from "./registry"; diff --git a/src/lib/registry/registry.ts b/src/lib/registry/registry.ts new file mode 100644 index 0000000..b6dbd17 --- /dev/null +++ b/src/lib/registry/registry.ts @@ -0,0 +1,50 @@ +import fs from "fs"; +import { join } from "path"; + +import { GlobalConfigHandler } from "@lib/global-config"; +import { type Repository, withAuth } from "@lib/http"; +import { type Manifest } from "@lib/manifest"; + +import { getCwd } from "@utils/path"; + +export class Registry { + static async publish(manifest: Manifest, dir?: string): Promise { + const client = this._getClient(dir, true, false); + const filename = manifest.publish?.paths?.package ?? "index.ts"; + const file = await this._getPackageFile(filename, dir); + + const data = new FormData(); + for (const key of Object.keys(manifest)) { + const value = manifest[key as keyof Manifest]; + if (!value) continue; + data.append(key, typeof value === "string" ? value : JSON.stringify(value)); + } + data.append("_packageFile", file, filename); + await client.put(`/registry/${manifest.name}`, data); + } + + static async unpublish(manifest: Manifest, dir?: string): Promise { + const client = this._getClient(dir, true); + + await client.delete(`/registry/${manifest.name}`); + } + + private static _getClient(dir?: string, force?: boolean, headers: boolean = true): Repository { + const config = GlobalConfigHandler.read(dir); + return withAuth(config.apiKey ?? undefined, force, !headers ? {} : undefined); + } + + private static _getPackageFile(filename: string, dir?: string): Promise { + const path = join(getCwd(dir ?? "."), filename); + if (!fs.existsSync(path)) + throw new Error( + "Package not found, please specify path in the nanoforge.manifest.json : `publish.paths.components`!", + ); + try { + fs.accessSync(path, fs.constants.R_OK); + return fs.openAsBlob(path); + } catch { + throw new Error("Cannot read package file, please verify your file permissions!"); + } + } +} diff --git a/src/lib/ui/messages.ts b/src/lib/ui/messages.ts index 1196e37..8debc73 100644 --- a/src/lib/ui/messages.ts +++ b/src/lib/ui/messages.ts @@ -64,6 +64,18 @@ export const Messages = { START_PART_SUCCESS: (part: string) => success(`${part} terminated.`), START_PART_FAILED: (part: string) => failure(`${part} failed!`), + // --- Publish --- + PUBLISH_START: "NanoForge Publish", + PUBLISH_SUCCESS: success("Publish completed!"), + PUBLISH_FAILED: failure("Publish failed!"), + PUBLISH_IN_PROGRESS: (name: string) => `Publishing ${name}...`, + + // --- Unpublish --- + UNPUBLISH_START: "NanoForge Unpublish", + UNPUBLISH_SUCCESS: success("Unpublish completed!"), + UNPUBLISH_FAILED: failure("Unpublish failed!"), + UNPUBLISH_IN_PROGRESS: (name: string) => `Unpublishing ${name}...`, + // --- Schematics --- SCHEMATICS_START: "Running schematics", SCHEMATIC_IN_PROGRESS: (name: string) => `Generating ${name}...`, diff --git a/src/lib/utils/errors.ts b/src/lib/utils/errors.ts index ca5aa97..0ba9e7b 100644 --- a/src/lib/utils/errors.ts +++ b/src/lib/utils/errors.ts @@ -1,11 +1,20 @@ import { red } from "ansis"; export const getErrorMessage = (error: unknown): string | undefined => { - if (error instanceof Error) return error.cause as string; + if (error instanceof Error) return getErrorString(error); if (typeof error === "string") return error; return undefined; }; +const getErrorString = (error: Error): string => { + const stack = error.stack ? error.stack : error.message; + const cause = + error.cause && typeof error.cause === "object" + ? JSON.stringify(error.cause, null, 2) + : error.cause; + return `${stack}${cause ? `\n${cause}` : ""}`; +}; + export const handleActionError = (context: string, error: unknown): never => { console.error(); console.error(red(context)); diff --git a/src/lib/utils/spinner.ts b/src/lib/utils/spinner.ts new file mode 100644 index 0000000..4c3056d --- /dev/null +++ b/src/lib/utils/spinner.ts @@ -0,0 +1,28 @@ +import { type Ora } from "ora"; + +import { getSpinner } from "@lib/ui"; + +interface SpinnerTaskResult { + success: boolean; + value?: T; + error?: unknown; +} + +export const withSpinner = async ( + 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 (error: unknown) { + spinner.fail(); + if (onError) onError(); + return { success: false, error }; + } +};