diff --git a/e2e/cli-help.test.ts b/e2e/cli-help.test.ts index 668bab7..e392136 100644 --- a/e2e/cli-help.test.ts +++ b/e2e/cli-help.test.ts @@ -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"); }); }); diff --git a/e2e/cli-install.test.ts b/e2e/cli-install.test.ts index d366525..cdb27e1 100644 --- a/e2e/cli-install.test.ts +++ b/e2e/cli-install.test.ts @@ -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, ); @@ -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", @@ -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, ); @@ -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, ); diff --git a/src/action/actions/install.action.ts b/src/action/actions/install.action.ts index 5a30189..583b50a 100644 --- a/src/action/actions/install.action.ts +++ b/src/action/actions/install.action.ts @@ -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 { @@ -12,10 +24,38 @@ export class InstallAction extends AbstractAction { public async handle(args: Input, options: Input): Promise { 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 { 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 { + 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"), + ); + }); } } diff --git a/src/action/actions/logout.action.ts b/src/action/actions/logout.action.ts index 5d9260d..b2a3a83 100644 --- a/src/action/actions/logout.action.ts +++ b/src/action/actions/logout.action.ts @@ -15,7 +15,7 @@ export class LogoutAction extends AbstractAction { GlobalConfigHandler.write( { - apiKey: null, + apiKey: undefined, }, isLocal, directory, diff --git a/src/command/commands/install.command.ts b/src/command/commands/install.command.ts index 2782e85..4438073 100644 --- a/src/command/commands/install.command.ts +++ b/src/command/commands/install.command.ts @@ -4,6 +4,8 @@ import { AbstractCommand } from "../abstract.command"; interface InstallOptions { directory?: string; + lib?: boolean; + server?: boolean; } export class InstallCommand extends AbstractCommand { @@ -11,11 +13,19 @@ export class InstallCommand extends AbstractCommand { 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, diff --git a/src/lib/global-config/global-config.type.ts b/src/lib/global-config/global-config.type.ts index be11d1a..2efa91a 100644 --- a/src/lib/global-config/global-config.type.ts +++ b/src/lib/global-config/global-config.type.ts @@ -1,3 +1,3 @@ export interface GlobalConfig { - apiKey?: string | null; + apiKey?: string; } diff --git a/src/lib/http/index.ts b/src/lib/http/index.ts index df9ef52..0b6d08d 100644 --- a/src/lib/http/index.ts +++ b/src/lib/http/index.ts @@ -1,2 +1,3 @@ export { api, withAuth } from "./client"; +export type { HttpClient } from "./http-client"; export type { Repository } from "./repository"; diff --git a/src/lib/http/repository.ts b/src/lib/http/repository.ts index 9960e15..f3aeceb 100644 --- a/src/lib/http/repository.ts +++ b/src/lib/http/repository.ts @@ -11,6 +11,10 @@ export class Repository { return this.runRequest("get", path, options); } + getFile(path: string, options?: RequestOptions): Promise { + return this.runFileRequest("get", path, options); + } + post( path: string, body?: I | FormData, @@ -53,6 +57,19 @@ export class Repository { return data; } + private async runFileRequest( + request: "get", + path: string, + options?: RequestOptions, + ): Promise { + 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( request: "post" | "put" | "patch", path: string, diff --git a/src/lib/input/inputs/install/index.ts b/src/lib/input/inputs/install/index.ts index 0b1f84f..427abf8 100644 --- a/src/lib/input/inputs/install/index.ts +++ b/src/lib/input/inputs/install/index.ts @@ -1 +1,3 @@ +export * from "./lib.input"; export * from "./names.input"; +export * from "./server.input"; diff --git a/src/lib/input/inputs/install/lib.input.spec.ts b/src/lib/input/inputs/install/lib.input.spec.ts new file mode 100644 index 0000000..99a0323 --- /dev/null +++ b/src/lib/input/inputs/install/lib.input.spec.ts @@ -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); + }); +}); diff --git a/src/lib/input/inputs/install/lib.input.ts b/src/lib/input/inputs/install/lib.input.ts new file mode 100644 index 0000000..d506b58 --- /dev/null +++ b/src/lib/input/inputs/install/lib.input.ts @@ -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); +} diff --git a/src/lib/input/inputs/install/server.input.spec.ts b/src/lib/input/inputs/install/server.input.spec.ts new file mode 100644 index 0000000..96fe2cb --- /dev/null +++ b/src/lib/input/inputs/install/server.input.spec.ts @@ -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); + }); +}); diff --git a/src/lib/input/inputs/install/server.input.ts b/src/lib/input/inputs/install/server.input.ts new file mode 100644 index 0000000..66e94f6 --- /dev/null +++ b/src/lib/input/inputs/install/server.input.ts @@ -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); +} diff --git a/src/lib/manifest/manifest-resolver.ts b/src/lib/manifest/manifest-resolver.ts new file mode 100644 index 0000000..5969776 --- /dev/null +++ b/src/lib/manifest/manifest-resolver.ts @@ -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; + npm: [string, string][]; +} + +export const resolveManifestDependencies = async ( + names: string[], + dir?: string, +): Promise => { + 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 => { + return await client.get(`/registry/${name}`); +}; + +const resolveDeps = async (name: string, client: Repository): Promise => { + 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()), + }; +}; diff --git a/src/lib/manifest/manifest.type.ts b/src/lib/manifest/manifest.type.ts index 47acf2d..b5e38df 100644 --- a/src/lib/manifest/manifest.type.ts +++ b/src/lib/manifest/manifest.type.ts @@ -74,7 +74,7 @@ export interface FullManifest { npmDependencies?: Record; publish?: { paths?: { - components?: string; + package?: string; }; }; _file: string; diff --git a/src/lib/registry/registry.ts b/src/lib/registry/registry.ts index b6dbd17..d006855 100644 --- a/src/lib/registry/registry.ts +++ b/src/lib/registry/registry.ts @@ -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"; @@ -29,9 +29,34 @@ export class Registry { await client.delete(`/registry/${manifest.name}`); } + static async install(manifests: FullManifest[], dir: string): Promise { + 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 { + 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 { diff --git a/src/lib/ui/messages.ts b/src/lib/ui/messages.ts index 8debc73..964233c 100644 --- a/src/lib/ui/messages.ts +++ b/src/lib/ui/messages.ts @@ -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",