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
2 changes: 2 additions & 0 deletions .env.build
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Currently not working as not in production
REGISTRY_URL=https://api.nanoforge.dev
1 change: 1 addition & 0 deletions .env.build.local
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
REGISTRY_URL=http://localhost:3000
38 changes: 28 additions & 10 deletions .github/labeler.yml
Original file line number Diff line number Diff line change
@@ -1,32 +1,50 @@
command:build:
- changed-files:
- any-glob-to-any-file:
- src/command/commands/build.ts
- src/action/actions/build.ts
- src/command/commands/build.command.ts
- src/action/actions/build.action.ts

command:dev:
- changed-files:
- any-glob-to-any-file:
- src/command/commands/dev.command.ts
- src/action/actions/dev.action.ts

command:generate:
- changed-files:
- any-glob-to-any-file:
- src/command/commands/generate.ts
- src/action/actions/generate.ts
- src/command/commands/generate.command.ts
- src/action/actions/generate.action.ts

command:install:
- changed-files:
- any-glob-to-any-file:
- src/command/commands/install.ts
- src/action/actions/install.ts
- src/command/commands/install.command.ts
- src/action/actions/install.action.ts

command:login:
- changed-files:
- any-glob-to-any-file:
- src/command/commands/login.command.ts
- src/action/actions/login.action.ts

command:logout:
- changed-files:
- any-glob-to-any-file:
- src/command/commands/logout.command.ts
- src/action/actions/logout.action.ts

command:new:
- changed-files:
- any-glob-to-any-file:
- src/command/commands/new.ts
- src/action/actions/new.ts
- src/command/commands/new.command.ts
- src/action/actions/new.action.ts

command:start:
- changed-files:
- any-glob-to-any-file:
- src/command/commands/start.ts
- src/action/actions/start.ts
- src/command/commands/start.command.ts
- src/action/actions/start.action.ts

documentation:
- changed-files:
Expand Down
12 changes: 12 additions & 0 deletions .github/labels.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@
description: "Related to build command"
color: "aaa3dc"

- name: "command:dev"
description: "Related to dev command"
color: "aaa3dc"

- name: "command:generate"
description: "Related to generate command"
color: "aaa3dc"
Expand All @@ -58,6 +62,14 @@
description: "Related to install command"
color: "aaa3dc"

- name: "command:login"
description: "Related to login command"
color: "aaa3dc"

- name: "command:logout"
description: "Related to logout command"
color: "aaa3dc"

- name: "command:new"
description: "Related to new command"
color: "aaa3dc"
Expand Down
100 changes: 100 additions & 0 deletions e2e/cli-login.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { existsSync, mkdirSync, readFileSync, rmSync } 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-login");
const RC_FILE = ".nanoforgerc";
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 readLocalRc(dir: string): string {
const rcPath = resolve(dir, RC_FILE);
if (!existsSync(rcPath)) return "";
return readFileSync(rcPath, "utf-8");
}

beforeAll(() => {
mkdirSync(tmpDir, { recursive: true });
});

afterAll(() => {
rmSync(tmpDir, { recursive: true, force: true });
});

describe("nf login --help", () => {
it("should display login command help", async () => {
const { stdout, exitCode } = await runCli(["login", "--help"]);

expect(exitCode).toBe(0);
expect(stdout).toContain("login to Nanoforge registry");
expect(stdout).toContain("--directory");
expect(stdout).toContain("--local");
expect(stdout).toContain("--api-key");
});
});

describe("nf login (local mode)", () => {
const dir = resolve(tmpDir, "login-local");

beforeEach(() => {
mkdirSync(dir, { recursive: true });
});

afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});

it("should write apiKey to local .nanoforgerc", async () => {
const fakeApiKey = "test-api-key-abc123";
const { stdout, exitCode } = await runCli(["login", "--local", "-d", dir, "-k", fakeApiKey], {
env: withMockedFetch(),
});

expect(exitCode).toBe(0);
expect(stdout).toContain("NanoForge Login");
expect(stdout).toContain("Login completed!");

const rcContent = readLocalRc(dir);
expect(rcContent).toContain(fakeApiKey);
});

it("should create the .nanoforgerc file in the specified directory", async () => {
await runCli(["login", "--local", "-d", dir, "-k", "test-api-key-create-file"], {
env: withMockedFetch(),
});

expect(existsSync(resolve(dir, RC_FILE))).toBe(true);
});

it("should overwrite apiKey on second login", async () => {
const firstKey = "first-api-key";
const secondKey = "second-api-key";

await runCli(["login", "--local", "-d", dir, "-k", firstKey], { env: withMockedFetch() });
expect(readLocalRc(dir)).toContain(firstKey);

await runCli(["login", "--local", "-d", dir, "-k", secondKey], { env: withMockedFetch() });
const rcContent = readLocalRc(dir);
expect(rcContent).toContain(secondKey);
expect(rcContent).not.toContain(firstKey);
});

it("should fail when the registry rejects the api key", async () => {
const { exitCode } = await runCli(["login", "--local", "-d", dir, "-k", "invalid-key"], {
env: withMockedFetch(401),
});

expect(exitCode).not.toBe(0);
expect(existsSync(resolve(dir, RC_FILE))).toBe(false);
});
});
101 changes: 101 additions & 0 deletions e2e/cli-logout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { existsSync, mkdirSync, readFileSync, rmSync } 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-logout");
const RC_FILE = ".nanoforgerc";
const FETCH_MOCK_PATH = resolve(__dirname, "./helpers/fetch-mock.mjs");

function withMockedFetch(): NodeJS.ProcessEnv {
const existing = process.env.NODE_OPTIONS ?? "";
return {
...process.env,
NODE_OPTIONS: `${existing} --import ${FETCH_MOCK_PATH}`.trim(),
};
}

function readLocalRc(dir: string): string {
const rcPath = resolve(dir, RC_FILE);
if (!existsSync(rcPath)) return "";
return readFileSync(rcPath, "utf-8");
}

beforeAll(() => {
mkdirSync(tmpDir, { recursive: true });
});

afterAll(() => {
rmSync(tmpDir, { recursive: true, force: true });
});

describe("nf logout --help", () => {
it("should display logout command help", async () => {
const { stdout, exitCode } = await runCli(["logout", "--help"]);

expect(exitCode).toBe(0);
expect(stdout).toContain("logout from Nanoforge registry");
expect(stdout).toContain("--directory");
expect(stdout).toContain("--local");
});
});

describe("nf logout (local mode)", () => {
const dir = resolve(tmpDir, "logout-local");

beforeEach(() => {
mkdirSync(dir, { recursive: true });
});

afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});

it("should clear apiKey from local .nanoforgerc after logout", async () => {
const fakeApiKey = "test-api-key-to-remove";

await runCli(["login", "--local", "-d", dir, "-k", fakeApiKey], { env: withMockedFetch() });
expect(readLocalRc(dir)).toContain(fakeApiKey);

const { stdout, exitCode } = await runCli(["logout", "--local", "-d", dir]);

expect(exitCode).toBe(0);
expect(stdout).toContain("NanoForge Logout");
expect(stdout).toContain("Logout completed!");

expect(readLocalRc(dir)).not.toContain(fakeApiKey);
});

it("should succeed even when no prior login exists", async () => {
const { exitCode } = await runCli(["logout", "--local", "-d", dir]);

expect(exitCode).toBe(0);
});
});

describe("nf login then logout (full flow, local mode)", () => {
const dir = resolve(tmpDir, "full-flow");

beforeEach(() => {
mkdirSync(dir, { recursive: true });
});

afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});

it("should login and then logout successfully", async () => {
const fakeApiKey = "full-flow-api-key-xyz";

const loginResult = await runCli(["login", "--local", "-d", dir, "-k", fakeApiKey], {
env: withMockedFetch(),
});
expect(loginResult.exitCode).toBe(0);
expect(readLocalRc(dir)).toContain(fakeApiKey);

const logoutResult = await runCli(["logout", "--local", "-d", dir]);
expect(logoutResult.exitCode).toBe(0);
expect(readLocalRc(dir)).not.toContain(fakeApiKey);
});
});
18 changes: 18 additions & 0 deletions e2e/helpers/fetch-mock.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* Mocks globalThis.fetch for the CLI subprocess.
* Loaded via NODE_OPTIONS="--import /path/to/fetch-mock.mjs".
*
* Env vars:
* MOCK_REGISTRY_STATUS HTTP status to return (default: 200)
*/
const mockStatus = parseInt(process.env.MOCK_REGISTRY_STATUS ?? "200", 10);

globalThis.fetch = async (_url, _options) => {
const ok = mockStatus >= 200 && mockStatus < 300;
const body = ok ? JSON.stringify({ ok: true }) : JSON.stringify({ message: "Unauthorized" });

return new Response(body, {
status: mockStatus,
headers: { "Content-Type": "application/json" },
});
};
8 changes: 7 additions & 1 deletion e2e/helpers/run-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,23 @@ interface CliResult {

interface RunCliOptions extends SpawnOptions {
timeout?: number;
stdinData?: string;
}

export const runCli = (args: string[], options?: RunCliOptions): Promise<CliResult> => {
return new Promise((resolve) => {
const { timeout, ...spawnOptions } = options ?? {};
const { timeout, stdinData, ...spawnOptions } = options ?? {};

const child = spawn("node", [CLI_PATH, ...args], {
stdio: "pipe",
...spawnOptions,
});

if (stdinData !== undefined) {
child.stdin?.write(stdinData);
child.stdin?.end();
}

let killed = false;
let timer: ReturnType<typeof setTimeout> | undefined;

Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"funding": "",
"scripts": {
"build": "tsc --noEmit && tsup",
"build:dev": "tsc --noEmit && NODE_ENV=development tsup",
"start": "node dist/nf.js",
"lint": "prettier --check . && eslint --format=pretty src",
"format": "prettier --write . && eslint --fix --format=pretty src",
Expand Down Expand Up @@ -63,6 +64,7 @@
"commander": "catalog:cli",
"node-emoji": "catalog:cli",
"ora": "catalog:cli",
"rc9": "catalog:libs",
"reflect-metadata": "catalog:libs"
},
"devDependencies": {
Expand All @@ -76,6 +78,7 @@
"@types/inquirer": "catalog:cli",
"@types/node": "catalog:core",
"@vitest/coverage-v8": "catalog:tests",
"dotenv": "catalog:build",
"eslint": "catalog:lint",
"husky": "catalog:ci",
"lint-staged": "catalog:ci",
Expand Down
Loading