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
4 changes: 3 additions & 1 deletion e2e/cli-help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,10 @@ describe("nf install --help", () => {
const { stdout, exitCode } = await runCli(["install", "--help"]);

expect(exitCode).toBe(0);
expect(stdout).toContain("add NanoForge library to your project");
expect(stdout).toContain("add Nanoforge components and systems to your project");
expect(stdout).toContain("--directory");
expect(stdout).toContain("--lib");
expect(stdout).toContain("--server");
});
});

Expand Down
19 changes: 13 additions & 6 deletions e2e/cli-install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,18 @@ describe("nf install (with existing project)", () => {
});

it("should run the install command with a library name", async () => {
const { exitCode } = await runCli(["install", "@nanoforge-dev/network-client", "-d", appDir]);
const { exitCode } = await runCli([
"install",
"-l",
"@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(pkgJson.devDependencies).toHaveProperty("@nanoforge-dev/network-client");
expect(existsSync(resolve(appDir, "node_modules", "@nanoforge-dev", "network-client"))).toBe(
true,
);
Expand All @@ -54,6 +60,7 @@ describe("nf install (with existing project)", () => {
it("should run the install command with multiple library names", async () => {
const { exitCode } = await runCli([
"install",
"-l",
"@nanoforge-dev/network-client",
"@nanoforge-dev/network-server",
"-d",
Expand All @@ -63,8 +70,8 @@ describe("nf install (with existing project)", () => {
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(pkgJson.devDependencies).toHaveProperty("@nanoforge-dev/network-client");
expect(pkgJson.devDependencies).toHaveProperty("@nanoforge-dev/network-server");
expect(existsSync(resolve(appDir, "node_modules", "@nanoforge-dev", "network-client"))).toBe(
true,
);
Expand All @@ -74,12 +81,12 @@ describe("nf install (with existing project)", () => {
});

it("should work with the add alias", async () => {
const { exitCode } = await runCli(["add", "@nanoforge-dev/network-client", "-d", appDir]);
const { exitCode } = await runCli(["add", "-l", "@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(pkgJson.devDependencies).toHaveProperty("@nanoforge-dev/network-client");
expect(existsSync(resolve(appDir, "node_modules", "@nanoforge-dev", "network-client"))).toBe(
true,
);
Expand Down
46 changes: 43 additions & 3 deletions src/action/actions/install.action.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
import { type Input, getDirectoryInput, getInstallNamesInputOrAsk } from "@lib/input";
import { join } from "path";

import {
type Input,
getDirectoryInput,
getInstallLibInput,
getInstallNamesInputOrAsk,
getInstallServerInput,
} from "@lib/input";
import { resolveManifestDependencies } from "@lib/manifest/manifest-resolver";
import { PackageManagerFactory } from "@lib/package-manager";
import { Registry } from "@lib/registry";
import { Messages } from "@lib/ui";

import { withSpinner } from "@utils/spinner";

import { AbstractAction, type HandleResult } from "../abstract.action";

export class InstallAction extends AbstractAction {
Expand All @@ -12,10 +24,38 @@ export class InstallAction extends AbstractAction {
public async handle(args: Input, options: Input): Promise<HandleResult> {
const names = await getInstallNamesInputOrAsk(args);
const directory = getDirectoryInput(options);
const isLib = getInstallLibInput(options);
const isServer = getInstallServerInput(options);

return isLib
? this._installLibs(directory, names)
: this._installNfPackages(directory, names, isServer);
}

private async _installLibs(directory: string, names: string[]): Promise<HandleResult> {
const packageManager = await PackageManagerFactory.find(directory);
const success = await packageManager.addProduction(directory, names);
return { success: await packageManager.addDevelopment(directory, names) };
}

private async _installNfPackages(
directory: string,
names: string[],
isServer?: boolean,
): Promise<HandleResult> {
const deps = await resolveManifestDependencies(names, directory);

const libSuccess = await this._installLibs(
directory,
deps.npm.map(([name, version]) => `${name}@${version}`),
);

if (!libSuccess) return { success: false };

return { success };
return withSpinner(Messages.INSTALL_PACKAGES_IN_PROGRESS, async () => {
await Registry.install(
Object.values(deps.nf),
join(directory, isServer ? "server" : "client"),
);
});
}
}
2 changes: 1 addition & 1 deletion src/action/actions/logout.action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export class LogoutAction extends AbstractAction {

GlobalConfigHandler.write(
{
apiKey: null,
apiKey: undefined,
},
isLocal,
directory,
Expand Down
12 changes: 11 additions & 1 deletion src/command/commands/install.command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,28 @@ import { AbstractCommand } from "../abstract.command";

interface InstallOptions {
directory?: string;
lib?: boolean;
server?: boolean;
}

export class InstallCommand extends AbstractCommand {
public load(program: Command) {
program
.command("install [names...]")
.alias("add")
.description("add NanoForge library to your project")
.description("add Nanoforge components and systems to your project")
.option("-d, --directory [directory]", "specify the directory of your project")
.option("-l, --lib", "install library instead of component/system", false)
.option(
"-s, --server",
"install components/systems on server (default install on client)",
false,
)
.action(async (names: string[], rawOptions: InstallOptions) => {
const options = AbstractCommand.mapToInput({
directory: rawOptions.directory,
lib: rawOptions.lib,
server: rawOptions.server,
});
const args = AbstractCommand.mapToInput({
names: names.length ? names : undefined,
Expand Down
2 changes: 1 addition & 1 deletion src/lib/global-config/global-config.type.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export interface GlobalConfig {
apiKey?: string | null;
apiKey?: string;
}
1 change: 1 addition & 0 deletions src/lib/http/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { api, withAuth } from "./client";
export type { HttpClient } from "./http-client";
export type { Repository } from "./repository";
17 changes: 17 additions & 0 deletions src/lib/http/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ export class Repository {
return this.runRequest("get", path, options);
}

getFile(path: string, options?: RequestOptions): Promise<Blob> {
return this.runFileRequest("get", path, options);
}

post<R extends object = object, I extends object = object>(
path: string,
body?: I | FormData,
Expand Down Expand Up @@ -53,6 +57,19 @@ export class Repository {
return data;
}

private async runFileRequest(
request: "get",
path: string,
options?: RequestOptions,
): Promise<Blob> {
const res = await this._client[request](path, options);
if (!res.ok)
throw new Error(`Request failed with status code ${res.status}`, {
cause: ((await res.json()) as { error: any })["error"],
});
return await res.blob();
}

private async runRequestBody<R, I>(
request: "post" | "put" | "patch",
path: string,
Expand Down
2 changes: 2 additions & 0 deletions src/lib/input/inputs/install/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
export * from "./lib.input";
export * from "./names.input";
export * from "./server.input";
20 changes: 20 additions & 0 deletions src/lib/input/inputs/install/lib.input.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";

import { type Input } from "../../input.type";
import { getInstallLibInput } from "./lib.input";

const createInput = (entries: [string, any][]): Input => {
return new Map(entries.map(([key, value]) => [key, { value }]));
};

describe("getInstallLibInput", () => {
it("should return the lib value when provided", () => {
const input = createInput([["lib", true]]);
expect(getInstallLibInput(input)).toBe(true);
});

it("should return false as default when lib is missing", () => {
const input = createInput([]);
expect(getInstallLibInput(input)).toBe(false);
});
});
6 changes: 6 additions & 0 deletions src/lib/input/inputs/install/lib.input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { getBooleanInputWithDefault } from "../../base-inputs";
import { type Input } from "../../input.type";

export function getInstallLibInput(inputs: Input): boolean {
return getBooleanInputWithDefault(inputs, "lib", false);
}
20 changes: 20 additions & 0 deletions src/lib/input/inputs/install/server.input.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";

import { type Input } from "../../input.type";
import { getInstallServerInput } from "./server.input";

const createInput = (entries: [string, any][]): Input => {
return new Map(entries.map(([key, value]) => [key, { value }]));
};

describe("getInstallServerInput", () => {
it("should return the server value when provided", () => {
const input = createInput([["server", true]]);
expect(getInstallServerInput(input)).toBe(true);
});

it("should return false as default when server is missing", () => {
const input = createInput([]);
expect(getInstallServerInput(input)).toBe(false);
});
});
6 changes: 6 additions & 0 deletions src/lib/input/inputs/install/server.input.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { getBooleanInputWithDefault } from "../../base-inputs";
import { type Input } from "../../input.type";

export function getInstallServerInput(inputs: Input): boolean {
return getBooleanInputWithDefault(inputs, "server", false);
}
40 changes: 40 additions & 0 deletions src/lib/manifest/manifest-resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { GlobalConfigHandler } from "@lib/global-config";
import { type Repository, withAuth } from "@lib/http";
import { type FullManifest, type Manifest } from "@lib/manifest/manifest.type";

interface ManifestDeps {
nf: Record<string, FullManifest>;
npm: [string, string][];
}

export const resolveManifestDependencies = async (
names: string[],
dir?: string,
): Promise<ManifestDeps> => {
const client = withAuth(GlobalConfigHandler.read(dir).apiKey, false);
return concatDeps(await Promise.all(names.map(async (d) => resolveDeps(d, client))));
};

const resolveManifest = async (name: string, client: Repository): Promise<FullManifest | never> => {
return await client.get(`/registry/${name}`);
};

const resolveDeps = async (name: string, client: Repository): Promise<ManifestDeps> => {
const manifest = await resolveManifest(name, client);
const baseDeps = manifest.dependencies ?? [];
const deps = await Promise.all(baseDeps.map(async (d) => resolveDeps(d, client)));
return concatDeps(
[{ nf: { [manifest.name]: manifest }, npm: getNpmDeps(manifest) }].concat(deps),
);
};

const getNpmDeps = (manifest: Manifest): [string, string][] => {
return Object.entries(manifest.npmDependencies ?? {});
};

const concatDeps = (deps: ManifestDeps[]): ManifestDeps => {
return {
npm: deps.map(({ npm }) => npm).flat(),
nf: Object.fromEntries(deps.map(({ nf }) => Object.entries(nf)).flat()),
};
};
2 changes: 1 addition & 1 deletion src/lib/manifest/manifest.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export interface FullManifest {
npmDependencies?: Record<string, string>;
publish?: {
paths?: {
components?: string;
package?: string;
};
};
_file: string;
Expand Down
29 changes: 27 additions & 2 deletions src/lib/registry/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { join } from "path";

import { GlobalConfigHandler } from "@lib/global-config";
import { type Repository, withAuth } from "@lib/http";
import { type Manifest } from "@lib/manifest";
import { type FullManifest, type Manifest } from "@lib/manifest";

import { getCwd } from "@utils/path";

Expand All @@ -29,9 +29,34 @@ export class Registry {
await client.delete(`/registry/${manifest.name}`);
}

static async install(manifests: FullManifest[], dir: string): Promise<void> {
const cwd = getCwd(dir);
const client = this._getClient(dir, false);
for (const manifest of manifests) {
await this.installPackage(client, manifest, cwd);
}
}

private static async installPackage(
client: Repository,
manifest: FullManifest,
dir: string,
): Promise<void> {
const file = await client.getFile(`/registry/${manifest.name}/-/${manifest._file}`);
const path = join(dir, this.getTypeSubFolder(manifest.type));
fs.mkdirSync(path, { recursive: true });
fs.writeFileSync(join(path, manifest._file), await file.bytes());
}

private static getTypeSubFolder(type: string): string {
if (type === "component") return "components";
if (type === "system") return "systems";
return ".";
}

private static _getClient(dir?: string, force?: boolean, headers: boolean = true): Repository {
const config = GlobalConfigHandler.read(dir);
return withAuth(config.apiKey ?? undefined, force, !headers ? {} : undefined);
return withAuth(config.apiKey, force, !headers ? {} : undefined);
}

private static _getPackageFile(filename: string, dir?: string): Promise<Blob> {
Expand Down
1 change: 1 addition & 0 deletions src/lib/ui/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const Messages = {
INSTALL_SUCCESS: success("Installation completed!"),
INSTALL_FAILED: failure("Installation failed!"),
INSTALL_NAMES_QUESTION: "Which libraries do you want to install?",
INSTALL_PACKAGES_IN_PROGRESS: "Install Nanoforge Packages",

// --- Login ---
LOGIN_START: "NanoForge Login",
Expand Down