Skip to content
Merged
2 changes: 2 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,5 @@ jobs:
style-scripts: '["repo:lint"]'
unit-script: repo:test:unit
e2e-script: repo:test:e2e
# nf runs on Windows too (#123).
extra-runners: '["windows-latest"]'
22 changes: 15 additions & 7 deletions e2e/cli-install.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { afterAll, beforeAll, describe, expect, it } from "vitest";

import { runCli } from "./helpers/run-cli";

const tmpDir = resolve(__dirname, "../.tmp-e2e-install");
const FETCH_MOCK_PATH = resolve(__dirname, "./helpers/fetch-mock.mjs");
// --import needs a file URL: a raw D:\ path is rejected on Windows.
const FETCH_MOCK_PATH = pathToFileURL(resolve(__dirname, "./helpers/fetch-mock.mjs")).href;

beforeAll(async () => {
mkdirSync(tmpDir, { recursive: true });
Expand All @@ -30,15 +32,15 @@ describe("nf install (with existing project)", () => {
});

it("should run the install command with a library name", async () => {
const { exitCode } = await runCli([
const { exitCode, stdout, stderr } = await runCli([
"install",
"-l",
"@nanoforge-dev/network-client",
"-d",
appDir,
]);

expect(exitCode).toBe(0);
expect(exitCode, `${stdout}\n${stderr}`).toBe(0);

const pkgJson = JSON.parse(readFileSync(resolve(appDir, "package.json"), "utf-8"));
expect(pkgJson.devDependencies).toHaveProperty("@nanoforge-dev/network-client");
Expand All @@ -48,7 +50,7 @@ describe("nf install (with existing project)", () => {
});

it("should run the install command with multiple library names", async () => {
const { exitCode } = await runCli([
const { exitCode, stdout, stderr } = await runCli([
"install",
"-l",
"@nanoforge-dev/network-client",
Expand All @@ -57,7 +59,7 @@ describe("nf install (with existing project)", () => {
appDir,
]);

expect(exitCode).toBe(0);
expect(exitCode, `${stdout}\n${stderr}`).toBe(0);

const pkgJson = JSON.parse(readFileSync(resolve(appDir, "package.json"), "utf-8"));
expect(pkgJson.devDependencies).toHaveProperty("@nanoforge-dev/network-client");
Expand All @@ -71,9 +73,15 @@ describe("nf install (with existing project)", () => {
});

it("should work with the add alias", async () => {
const { exitCode } = await runCli(["add", "-l", "@nanoforge-dev/network-client", "-d", appDir]);
const { exitCode, stdout, stderr } = await runCli([
"add",
"-l",
"@nanoforge-dev/network-client",
"-d",
appDir,
]);

expect(exitCode).toBe(0);
expect(exitCode, `${stdout}\n${stderr}`).toBe(0);

const pkgJson = JSON.parse(readFileSync(resolve(appDir, "package.json"), "utf-8"));
expect(pkgJson.devDependencies).toHaveProperty("@nanoforge-dev/network-client");
Expand Down
37 changes: 37 additions & 0 deletions e2e/cli-local-bin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";

import { runCli } from "./helpers/run-cli";

const project = resolve(__dirname, "../.tmp-e2e-local-bin");

// A project with its own CLI installed: nf loads that CLI's command loader instead of its own (#123).
describe("nf inside a project with a local CLI", () => {
beforeAll(() => {
const localCli = join(project, "node_modules", "@nanoforge-dev", "cli");
mkdirSync(join(localCli, "dist"), { recursive: true });
writeFileSync(join(localCli, "package.json"), JSON.stringify({ type: "module" }));
writeFileSync(
join(localCli, "dist", "command.loader.js"),
`export const CommandLoader = {
load: async (program) => {
program.command("local-marker").description("command of the local cli");
},
};
`,
);
});

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

it("should load the commands of the local CLI", async () => {
const { stdout, stderr, exitCode } = await runCli(["--help"], { cwd: project });

expect(stderr).toBe("");
expect(exitCode).toBe(0);
expect(stdout).toContain("local-marker");
});
});
4 changes: 3 additions & 1 deletion e2e/cli-login.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
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");
// --import needs a file URL: a raw D:\ path is rejected on Windows.
const FETCH_MOCK_PATH = pathToFileURL(resolve(__dirname, "./helpers/fetch-mock.mjs")).href;

function withMockedFetch(status = 200): NodeJS.ProcessEnv {
const existing = process.env.NODE_OPTIONS ?? "";
Expand Down
4 changes: 3 additions & 1 deletion e2e/cli-logout.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
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");
// --import needs a file URL: a raw D:\ path is rejected on Windows.
const FETCH_MOCK_PATH = pathToFileURL(resolve(__dirname, "./helpers/fetch-mock.mjs")).href;

function withMockedFetch(): NodeJS.ProcessEnv {
const existing = process.env.NODE_OPTIONS ?? "";
Expand Down
4 changes: 3 additions & 1 deletion e2e/cli-publish.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
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");
// --import needs a file URL: a raw D:\ path is rejected on Windows.
const FETCH_MOCK_PATH = pathToFileURL(resolve(__dirname, "./helpers/fetch-mock.mjs")).href;

const LOG_PATH = resolve(tmpDir, "requests.jsonl");

Expand Down
4 changes: 3 additions & 1 deletion e2e/cli-unpublish.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";
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");
// --import needs a file URL: a raw D:\ path is rejected on Windows.
const FETCH_MOCK_PATH = pathToFileURL(resolve(__dirname, "./helpers/fetch-mock.mjs")).href;
const LOG_PATH = resolve(tmpDir, "requests.jsonl");

function withMockedFetch(status = 200): NodeJS.ProcessEnv {
Expand Down
4 changes: 3 additions & 1 deletion libs/schematics/e2e/project.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { SchematicTestRunner, type UnitTestTree } from "@angular-devkit/schematics/testing";
import { EOL } from "node:os";
import { resolve } from "node:path";
import { beforeAll, describe, expect, it } from "vitest";

Expand Down Expand Up @@ -449,7 +450,8 @@ describe("project schematic", () => {
it.each(variants)("renders for %o", async (options) => {
const tree = await runner.runSchematic("project", { ...options, directory: "app" });
expect(tree.files).toContain("/app/AGENTS.md");
expect(tree.readContent("/app/CLAUDE.md")).toBe("@AGENTS.md\n");
// The template rule writes the OS line ending (CRLF on Windows).
expect(tree.readContent("/app/CLAUDE.md")).toBe(`@AGENTS.md${EOL}`);
expect(tree.files.some((file) => file.endsWith(".template"))).toBe(false);
const content = tree.readContent("/app/AGENTS.md");
expect(content).not.toContain("<%");
Expand Down
4 changes: 3 additions & 1 deletion libs/schematics/e2e/workspace.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { SchematicTestRunner, type UnitTestTree } from "@angular-devkit/schematics/testing";
import { EOL } from "node:os";
import { resolve } from "node:path";
import { beforeAll, describe, expect, it } from "vitest";

Expand Down Expand Up @@ -287,7 +288,8 @@ describe("workspace schematic", () => {
packageManager,
});
expect(tree.files).toContain("/my-workspace/AGENTS.md");
expect(tree.readContent("/my-workspace/CLAUDE.md")).toBe("@AGENTS.md\n");
// The template rule writes the OS line ending (CRLF on Windows).
expect(tree.readContent("/my-workspace/CLAUDE.md")).toBe(`@AGENTS.md${EOL}`);
expect(tree.files.some((file) => file.endsWith(".template"))).toBe(false);
const content = tree.readContent("/my-workspace/AGENTS.md");
expect(content).not.toContain("<%");
Expand Down
4 changes: 2 additions & 2 deletions libs/schematics/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,8 @@
"scripts": {
"build": "tsc --noEmit && tsdown",
"postbuild": "pnpm run copy:collection && pnpm run copy:lib",
"copy:collection": "cpx src/collection.json dist && cpx 'src/libs/**/schema.json' dist/libs",
"copy:lib": "cpx 'src/libs/**/{files,theme-files}/**/{.,}*' dist/libs",
"copy:collection": "cpx src/collection.json dist && cpx \"src/libs/**/schema.json\" dist/libs",
"copy:lib": "cpx \"src/libs/**/{files,theme-files}/**/{.,}*\" dist/libs",
"lint": "prettier --check . && eslint --format=pretty src",
"format": "prettier --write . && eslint --fix --format=pretty src",
"prepack": "pnpm run build && pnpm run lint",
Expand Down
18 changes: 17 additions & 1 deletion src/lib/runner/runner.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";

import { Runner, quoteForCmd } from "./runner";
import { Runner, cmdLine, quoteForCmd } from "./runner";

describe("Runner", () => {
describe("fullCommand", () => {
Expand Down Expand Up @@ -57,4 +57,20 @@ describe("Runner", () => {
expect(() => quoteForCmd("!PATH!")).toThrow(/error/);
});
});

describe("cmdLine", () => {
it("should leave a bare binary name unquoted", () => {
expect(cmdLine("npm", ["install", "a b"])).toBe('npm "install" "a b"');
});

it("should quote a binary path", () => {
expect(cmdLine("C:\\Program Files\\nf\\bun.cmd", ["x"])).toBe(
'"C:\\Program Files\\nf\\bun.cmd" "x"',
);
});

it("should refuse a binary path cmd.exe can't quote", () => {
expect(() => cmdLine("C:\\%PATH%\\npm", [])).toThrow(/error/);
});
});
});
9 changes: 8 additions & 1 deletion src/lib/runner/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ export const quoteForCmd = (arg: string): string => {
return `"${arg.replace(/(\\+)$/, "$1$1")}"`;
};

/**
* The cmd.exe command line for a binary and its arguments. A bare name (`npm`) stays unquoted:
* started as `"npm"`, a `.cmd` shim resolves `%~dp0` to the working directory and can't find itself.
*/
export const cmdLine = (binary: string, args: string[]): string =>
[/^[\w.-]+$/.test(binary) ? binary : quoteForCmd(binary), ...args.map(quoteForCmd)].join(" ");

export interface RunOptions {
collect?: boolean;
cwd?: string;
Expand All @@ -45,7 +52,7 @@ export class Runner {

return new Promise<string | null>((resolve, reject) => {
const child = IS_WINDOWS
? spawn([this.binary, ...fullArgs].map(quoteForCmd).join(" "), spawnOpts)
? spawn(cmdLine(this.binary, fullArgs), spawnOpts)
: spawn(this.binary, fullArgs, spawnOpts);
const output = this.attachOutputHandlers(child, listeners);

Expand Down
20 changes: 14 additions & 6 deletions src/lib/utils/editor-target.spec.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,31 @@
import { join, resolve } from "node:path";
import { describe, expect, it } from "vitest";

import { editorUrl, resolveEditorTarget } from "./editor-target";

// Paths are native: "/work" resolves to "D:\work" on Windows.
describe("resolveEditorTarget", () => {
it("roots the editor in the working directory", () => {
expect(resolveEditorTarget("/work")).toEqual({ fsRoot: "/work" });
expect(resolveEditorTarget("/work")).toEqual({ fsRoot: resolve("/work") });
expect(resolveEditorTarget("/work", "games/pong")).toEqual({
fsRoot: "/work",
projectPath: "games/pong",
fsRoot: resolve("/work"),
projectPath: join("games", "pong"),
});
expect(resolveEditorTarget("/work", ".")).toEqual({
fsRoot: resolve("/work"),
projectPath: ".",
});
expect(resolveEditorTarget("/work", ".")).toEqual({ fsRoot: "/work", projectPath: "." });
});

it("roots the editor in the parent of a project outside of it", () => {
expect(resolveEditorTarget("/work", "/other/pong")).toEqual({
fsRoot: "/other",
fsRoot: resolve("/other"),
projectPath: "pong",
});
expect(resolveEditorTarget("/work/a", "../b")).toEqual({ fsRoot: "/work", projectPath: "b" });
expect(resolveEditorTarget("/work/a", "../b")).toEqual({
fsRoot: resolve("/work"),
projectPath: "b",
});
});
});

Expand Down
17 changes: 17 additions & 0 deletions src/lib/utils/local-binaries.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";

import { getLocalBinCommandLoaderUrl } from "./local-binaries";

describe("getLocalBinCommandLoaderUrl", () => {
it("should build a file URL from a Windows path", () => {
expect(getLocalBinCommandLoaderUrl("C:\\Users\\me\\my game", true)).toBe(
"file:///C:/Users/me/my%20game/node_modules/@nanoforge-dev/cli/dist/command.loader.js",
);
});

it("should build a file URL from a POSIX path", () => {
expect(getLocalBinCommandLoaderUrl("/home/me/my-game", false)).toBe(
"file:///home/me/my-game/node_modules/@nanoforge-dev/cli/dist/command.loader.js",
);
});
});
18 changes: 14 additions & 4 deletions src/lib/utils/local-binaries.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,25 @@
import { existsSync } from "fs";
import { join, posix } from "path";
import { join, posix, win32 } from "path";
import { pathToFileURL } from "url";

import { type CommandLoader } from "~/command";

const localBinPathSegments = [process.cwd(), "node_modules", "@nanoforge-dev", "cli", "dist"];
const localBinPathSegments = ["node_modules", "@nanoforge-dev", "cli", "dist"];

export const localBinExists = () => {
return existsSync(join(...localBinPathSegments));
return existsSync(join(process.cwd(), ...localBinPathSegments));
};

// ESM import() needs a file:// URL: a raw C:\ path is rejected on Windows.
export const getLocalBinCommandLoaderUrl = (
cwd = process.cwd(),
windows = process.platform === "win32",
) => {
const path = (windows ? win32 : posix).join(cwd, ...localBinPathSegments, "command.loader.js");
return pathToFileURL(path, { windows }).href;
};

export const loadLocalBinCommandLoader = async (): Promise<typeof CommandLoader> => {
const commandsFile = await import(posix.join(...localBinPathSegments, "command.loader.js"));
const commandsFile = await import(getLocalBinCommandLoaderUrl());
return commandsFile.CommandLoader;
};
Loading