Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions e2e/cli-publish.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) {
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!");
});
});
88 changes: 88 additions & 0 deletions e2e/cli-unpublish.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
3 changes: 2 additions & 1 deletion src/action/abstract.action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { handleActionError } from "@utils/errors";
export interface HandleResult {
success?: boolean;
keepAlive?: boolean;
error?: unknown;
}

export abstract class AbstractAction {
Expand Down Expand Up @@ -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);
}

Expand Down
24 changes: 24 additions & 0 deletions src/action/actions/publish.action.ts
Original file line number Diff line number Diff line change
@@ -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<HandleResult> {
const directory = getDirectoryInput(options);

const manifest = await loadManifest(directory);

return withSpinner(Messages.PUBLISH_IN_PROGRESS(manifest.name), async () => {
await Registry.publish(manifest, directory);
});
}
}
24 changes: 24 additions & 0 deletions src/action/actions/unpublish.action.ts
Original file line number Diff line number Diff line change
@@ -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<HandleResult> {
const directory = getDirectoryInput(options);

const manifest = await loadManifest(directory);

return withSpinner(Messages.UNPUBLISH_IN_PROGRESS(manifest.name), async () => {
await Registry.unpublish(manifest, directory);
});
}
}
2 changes: 2 additions & 0 deletions src/action/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
12 changes: 9 additions & 3 deletions src/command/command.loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import {
LoginAction,
LogoutAction,
NewAction,
PublishAction,
StartAction,
UnpublishAction,
} from "~/action";

import { BuildCommand } from "./commands/build.command";
Expand All @@ -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<void> {
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);
}

Expand Down
23 changes: 23 additions & 0 deletions src/command/commands/publish.command.ts
Original file line number Diff line number Diff line change
@@ -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);
});
}
}
23 changes: 23 additions & 0 deletions src/command/commands/unpublish.command.ts
Original file line number Diff line number Diff line change
@@ -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);
});
}
}
1 change: 1 addition & 0 deletions src/lib/constants.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
10 changes: 8 additions & 2 deletions src/lib/http/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -16,7 +22,7 @@ export const withAuth = (apiKey?: string, force: boolean = false) => {
new HttpClient(REGISTRY_URL ?? "", {
headers: {
Authorization: apiKey,
"Content-Type": "application/json",
...headers,
},
}),
);
Expand Down
Loading