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: 1 addition & 1 deletion .github/workflows/cli-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ jobs:
echo "value=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT
else
# Prefer package.json version when not a release event
echo "value=$(node -p \"require('./package.json').version\")" >> $GITHUB_OUTPUT
echo "value=$(node -p 'require("./package.json").version')" >> $GITHUB_OUTPUT
fi

- name: Generate CLI docs (MDX)
Expand Down
12 changes: 8 additions & 4 deletions .github/workflows/validate-code.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,18 @@ on:

jobs:
build-and-test:
name: Build and Test
runs-on: ubuntu-latest
name: Build and Test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Install libsecret runtime
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libsecret-1-0

- name: Set up Node.js
Expand All @@ -38,10 +42,10 @@ jobs:
run: npm run test:coverage

- name: Upload coverage report
if: success()
if: success() && matrix.os == 'ubuntu-latest'
uses: codecov/codecov-action@v5.4.3
with:
verbose: true
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
directory: coverage
directory: coverage
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"scripts",
"templates",
".env.example",
"docker-compose.yml",
"README.md",
"LICENSE"
],
Expand Down
1 change: 1 addition & 0 deletions src/commands/general/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export class InitAction extends BaseAction {
this.simulatorService.addConfigToEnvFile({LOCALNETVERSION: localnetVersion});

this.setSpinnerText("Running GenLayer Localnet...");
this.simulatorService.setupLocalhostAccess();
await this.simulatorService.runSimulator();

this.setSpinnerText("Waiting for localnet to be ready...");
Expand Down
1 change: 1 addition & 0 deletions src/commands/general/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export class StartAction extends BaseAction {
this.setSpinnerText(`Starting GenLayer Localnet (${restartValidatorsHintText})...`);

try {
this.simulatorService.setupLocalhostAccess();
await this.simulatorService.runSimulator();
} catch (error) {
this.failSpinner("Error starting the simulator", error);
Expand Down
6 changes: 3 additions & 3 deletions src/lib/config/simulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ export const DEFAULT_JSON_RPC_URL = "http://localhost:4000/api";
export const CONTAINERS_NAME_PREFIX = "/genlayer-";
export const IMAGES_NAME_PREFIX = "yeagerai";
export const DEFAULT_RUN_SIMULATOR_COMMAND = (location: string, profiles: string) => ({
darwin: `osascript -e 'tell application "Terminal" to do script "cd ${location} && docker compose build && docker compose -p genlayer ${profiles} up"'`,
win32: `start cmd.exe /c "cd /d ${location} && docker compose build && docker compose -p genlayer ${profiles} up && pause"`,
linux: `nohup bash -c 'cd ${location} && docker compose build && docker compose -p genlayer ${profiles} up -d '`,
darwin: `osascript -e 'tell application "Terminal" to do script "cd \\"${location}\\" && docker compose build && docker compose -p genlayer ${profiles} up"'`,
win32: `cd /d "${location}" && docker compose build && docker compose -p genlayer ${profiles} up -d`,
linux: `nohup bash -c 'cd "${location}" && docker compose build && docker compose -p genlayer ${profiles} up -d'`,
});
export const DEFAULT_RUN_DOCKER_COMMAND = {
darwin: "open -a Docker",
Expand Down
1 change: 1 addition & 0 deletions src/lib/interfaces/ISimulatorService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface ISimulatorService {
normalizeLocalnetVersion(version: string): string;
compareVersions(version1: string, version2: string): number;
isLocalnetRunning(): Promise<boolean>;
setupLocalhostAccess(): void;
}


Expand Down
31 changes: 31 additions & 0 deletions src/lib/services/simulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,37 @@ export class SimulatorService implements ISimulatorService {
return 0;
}

public setupLocalhostAccess(): void {
const overridePath = path.join(this.location, 'docker-compose.override.yml');
const overrideContent = `# Auto-generated by genlayer CLI — enables localhost access for local development
services:
jsonrpc:
volumes:
- ./config-overrides/genvm-module-web.yaml:/genvm/config/genvm-module-web.yaml:ro
extra_hosts:
- "host.docker.internal:host-gateway"
- "anvil-local:host-gateway"
`;
fs.writeFileSync(overridePath, overrideContent);

const configDir = path.join(this.location, 'config-overrides');
if (!fs.existsSync(configDir)) {
fs.mkdirSync(configDir, { recursive: true });
}

const genvmConfigPath = path.join(configDir, 'genvm-module-web.yaml');
const genvmConfigContent = `# Auto-generated by genlayer CLI — allows GenVM to reach localhost services
always_allow_hosts:
- "localhost"
- "127.0.0.1"
- "host.docker.internal"
- "anvil-local"
`;
fs.writeFileSync(genvmConfigPath, genvmConfigContent);

console.warn('\x1b[33m⚠ Localhost access enabled — GenVM can reach host services (Anvil, local APIs). Not for production use.\x1b[0m');
}

public async isLocalnetRunning(): Promise<boolean> {
const genlayerContainers = await this.getGenlayerContainers();
const runningContainers = genlayerContainers.filter(container => container.State === "running");
Expand Down
16 changes: 16 additions & 0 deletions tests/actions/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ describe("InitAction", () => {
let openFrontendSpy: ReturnType<typeof vi.spyOn>;
let getFrontendUrlSpy: ReturnType<typeof vi.spyOn>;
let normalizeLocalnetVersionSpy: ReturnType<typeof vi.spyOn>;
let setupLocalhostAccessSpy: ReturnType<typeof vi.spyOn>;

const defaultConfig = {defaultOllamaModel: "llama3"};

Expand Down Expand Up @@ -82,6 +83,9 @@ describe("InitAction", () => {
normalizeLocalnetVersionSpy = vi
.spyOn(SimulatorService.prototype, "normalizeLocalnetVersion")
.mockImplementation((v: string) => v) as any;
setupLocalhostAccessSpy = vi
.spyOn(SimulatorService.prototype, "setupLocalhostAccess")
.mockImplementation(() => {});
vi.spyOn(SimulatorService.prototype, "isLocalnetRunning").mockResolvedValue(false);
});

Expand All @@ -90,6 +94,18 @@ describe("InitAction", () => {
});

describe("Successful Execution", () => {
test("should call setupLocalhostAccess before running simulator", async () => {
inquirerPromptSpy
.mockResolvedValueOnce({confirmAction: true})
.mockResolvedValueOnce({selectedLlmProviders: ["openai"]})
.mockResolvedValueOnce({openai: "API_KEY_OPENAI"});

await initAction.execute(defaultOptions);

expect(setupLocalhostAccessSpy).toHaveBeenCalled();
expect(runSimulatorSpy).toHaveBeenCalled();
});

test("should show combined confirmation message when localnet is running", async () => {
vi.spyOn(SimulatorService.prototype, "isLocalnetRunning").mockResolvedValue(true);

Expand Down
9 changes: 9 additions & 0 deletions tests/actions/start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ describe("StartAction", () => {
ollama: false
};

test("should call setupLocalhostAccess before running simulator", async () => {
mockSimulatorService.isLocalnetRunning = vi.fn().mockResolvedValue(false);

await startAction.execute(defaultOptions);

expect(mockSimulatorService.setupLocalhostAccess).toHaveBeenCalled();
expect(mockSimulatorService.runSimulator).toHaveBeenCalled();
});

test("should check if localnet is running and proceed without confirmation when not running", async () => {
mockSimulatorService.isLocalnetRunning = vi.fn().mockResolvedValue(false);

Expand Down
9 changes: 5 additions & 4 deletions tests/libs/configFileManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,16 @@ vi.mock("fs");
vi.mock("os")

describe("ConfigFileManager", () => {
const mockFolderPath = "/mocked/home/.genlayer";
const mockKeystoresPath = `${mockFolderPath}/keystores`;
const mockConfigFilePath = `${mockFolderPath}/genlayer-config.json`;
const mockHome = path.resolve("/mocked/home");
const mockFolderPath = path.resolve(mockHome, ".genlayer");
const mockKeystoresPath = path.resolve(mockFolderPath, "keystores");
const mockConfigFilePath = path.resolve(mockFolderPath, "genlayer-config.json");

let configFileManager: ConfigFileManager;

beforeEach(() => {
vi.clearAllMocks();
vi.mocked(os.homedir).mockReturnValue("/mocked/home");
vi.mocked(os.homedir).mockReturnValue(mockHome);
vi.mocked(fs.existsSync).mockReturnValue(true);
vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({}));
configFileManager = new ConfigFileManager();
Expand Down
78 changes: 78 additions & 0 deletions tests/libs/platformCommands.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import {describe, test, expect} from "vitest";
import {DEFAULT_RUN_SIMULATOR_COMMAND} from "../../src/lib/config/simulator";

describe("Platform-specific command generation", () => {
describe("DEFAULT_RUN_SIMULATOR_COMMAND", () => {
test("win32 command does not use start cmd.exe", () => {
const cmds = DEFAULT_RUN_SIMULATOR_COMMAND("/some/path", "");
expect(cmds.win32).not.toContain("start cmd.exe");
});

test("win32 command quotes the path", () => {
const pathWithSpaces = "C:\\Program Files\\genlayer";
const cmds = DEFAULT_RUN_SIMULATOR_COMMAND(pathWithSpaces, "");
expect(cmds.win32).toContain(`"${pathWithSpaces}"`);
});

test("win32 command uses -d flag for detached mode", () => {
const cmds = DEFAULT_RUN_SIMULATOR_COMMAND("/path", "");
expect(cmds.win32).toContain("up -d");
});

test("win32 command uses cd /d for drive changes", () => {
const cmds = DEFAULT_RUN_SIMULATOR_COMMAND("D:\\projects\\genlayer", "");
expect(cmds.win32).toContain("cd /d");
});

test("linux command uses nohup with -d flag", () => {
const cmds = DEFAULT_RUN_SIMULATOR_COMMAND("/path", "");
expect(cmds.linux).toContain("nohup");
expect(cmds.linux).toContain("up -d");
});

test("linux command quotes the path", () => {
const cmds = DEFAULT_RUN_SIMULATOR_COMMAND("/path with spaces", "");
expect(cmds.linux).toContain('"/path with spaces"');
});

test("darwin command uses osascript", () => {
const cmds = DEFAULT_RUN_SIMULATOR_COMMAND("/path", "");
expect(cmds.darwin).toContain("osascript");
});

test("darwin command quotes the path for spaces", () => {
const cmds = DEFAULT_RUN_SIMULATOR_COMMAND("/path with spaces", "");
expect(cmds.darwin).toContain("/path with spaces");
});

test("all platforms include profiles when provided", () => {
const profiles = "--profile frontend --profile ollama";
const cmds = DEFAULT_RUN_SIMULATOR_COMMAND("/path", profiles);
expect(cmds.darwin).toContain(profiles);
expect(cmds.win32).toContain(profiles);
expect(cmds.linux).toContain(profiles);
});

test("all platforms include docker compose build and up", () => {
const cmds = DEFAULT_RUN_SIMULATOR_COMMAND("/path", "");
for (const platform of ["darwin", "win32", "linux"] as const) {
expect(cmds[platform]).toContain("docker compose build");
expect(cmds[platform]).toContain("docker compose -p genlayer");
}
});

test("all platforms handle empty profiles", () => {
const cmds = DEFAULT_RUN_SIMULATOR_COMMAND("/path", "");
for (const platform of ["darwin", "win32", "linux"] as const) {
expect(cmds[platform]).toContain("docker compose -p genlayer up");
}
});

test("paths with spaces are quoted on win32 and linux", () => {
const spacePath = "/some path/with spaces";
const cmds = DEFAULT_RUN_SIMULATOR_COMMAND(spacePath, "");
expect(cmds.win32).toContain(`"${spacePath}"`);
expect(cmds.linux).toContain(`"${spacePath}"`);
});
});
});
84 changes: 84 additions & 0 deletions tests/services/simulator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -703,3 +703,87 @@ describe("compareVersions", () => {
expect(simulatorService.compareVersions("v1.0.1-beta", "1.0.0")).toBe(1);
});
});

describe("setupLocalhostAccess", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(path.join).mockImplementation((...args) => args.join("/"));
});

test("should write docker-compose.override.yml to location", () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});

simulatorService.setupLocalhostAccess();

const writeCalls = vi.mocked(fs.writeFileSync).mock.calls;
const overrideCall = writeCalls.find(c =>
(c[0] as string).includes("docker-compose.override.yml")
);
expect(overrideCall).toBeDefined();
expect(overrideCall![1]).toContain("jsonrpc");
expect(overrideCall![1]).toContain("extra_hosts");
expect(overrideCall![1]).toContain("host.docker.internal:host-gateway");

consoleWarnSpy.mockRestore();
});

test("should create config-overrides directory if it does not exist", () => {
vi.mocked(fs.existsSync).mockReturnValue(false);
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});

simulatorService.setupLocalhostAccess();

expect(fs.mkdirSync).toHaveBeenCalledWith(
expect.stringContaining("config-overrides"),
{ recursive: true }
);

consoleWarnSpy.mockRestore();
});

test("should not create config-overrides directory if it already exists", () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});

simulatorService.setupLocalhostAccess();

expect(fs.mkdirSync).not.toHaveBeenCalled();

consoleWarnSpy.mockRestore();
});

test("should write genvm-module-web.yaml with localhost access hosts", () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});

simulatorService.setupLocalhostAccess();

const writeCalls = vi.mocked(fs.writeFileSync).mock.calls;
const configCall = writeCalls.find(c =>
(c[0] as string).includes("genvm-module-web.yaml")
);
expect(configCall).toBeDefined();
const content = configCall![1] as string;
expect(content).toContain("always_allow_hosts");
expect(content).toContain("localhost");
expect(content).toContain("127.0.0.1");
expect(content).toContain("host.docker.internal");
expect(content).toContain("anvil-local");

consoleWarnSpy.mockRestore();
});

test("should print a warning about localhost access", () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});

simulatorService.setupLocalhostAccess();

expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining("Localhost access enabled")
);

consoleWarnSpy.mockRestore();
});
});
Loading