diff --git a/.github/workflows/cli-docs.yml b/.github/workflows/cli-docs.yml index 85796e55..7a732e3d 100644 --- a/.github/workflows/cli-docs.yml +++ b/.github/workflows/cli-docs.yml @@ -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) diff --git a/.github/workflows/validate-code.yml b/.github/workflows/validate-code.yml index 895ae429..199c9dde 100644 --- a/.github/workflows/validate-code.yml +++ b/.github/workflows/validate-code.yml @@ -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 @@ -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 \ No newline at end of file + directory: coverage diff --git a/package.json b/package.json index a889b087..3d2395d2 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "scripts", "templates", ".env.example", + "docker-compose.yml", "README.md", "LICENSE" ], diff --git a/src/commands/general/init.ts b/src/commands/general/init.ts index 2f6b21ac..4ea6ef3d 100644 --- a/src/commands/general/init.ts +++ b/src/commands/general/init.ts @@ -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..."); diff --git a/src/commands/general/start.ts b/src/commands/general/start.ts index 14c2ab2f..e4ab4add 100644 --- a/src/commands/general/start.ts +++ b/src/commands/general/start.ts @@ -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); diff --git a/src/lib/config/simulator.ts b/src/lib/config/simulator.ts index 7495d2ca..6a6718ea 100644 --- a/src/lib/config/simulator.ts +++ b/src/lib/config/simulator.ts @@ -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", diff --git a/src/lib/interfaces/ISimulatorService.ts b/src/lib/interfaces/ISimulatorService.ts index e2c7c535..b53ac1e4 100644 --- a/src/lib/interfaces/ISimulatorService.ts +++ b/src/lib/interfaces/ISimulatorService.ts @@ -22,6 +22,7 @@ export interface ISimulatorService { normalizeLocalnetVersion(version: string): string; compareVersions(version1: string, version2: string): number; isLocalnetRunning(): Promise; + setupLocalhostAccess(): void; } diff --git a/src/lib/services/simulator.ts b/src/lib/services/simulator.ts index af9584b7..4dcaae5d 100644 --- a/src/lib/services/simulator.ts +++ b/src/lib/services/simulator.ts @@ -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 { const genlayerContainers = await this.getGenlayerContainers(); const runningContainers = genlayerContainers.filter(container => container.State === "running"); diff --git a/tests/actions/init.test.ts b/tests/actions/init.test.ts index b917b864..8975be8f 100644 --- a/tests/actions/init.test.ts +++ b/tests/actions/init.test.ts @@ -23,6 +23,7 @@ describe("InitAction", () => { let openFrontendSpy: ReturnType; let getFrontendUrlSpy: ReturnType; let normalizeLocalnetVersionSpy: ReturnType; + let setupLocalhostAccessSpy: ReturnType; const defaultConfig = {defaultOllamaModel: "llama3"}; @@ -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); }); @@ -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); diff --git a/tests/actions/start.test.ts b/tests/actions/start.test.ts index 7588eb6a..abe26570 100644 --- a/tests/actions/start.test.ts +++ b/tests/actions/start.test.ts @@ -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); diff --git a/tests/libs/configFileManager.test.ts b/tests/libs/configFileManager.test.ts index 4a61fa40..6ccec5f9 100644 --- a/tests/libs/configFileManager.test.ts +++ b/tests/libs/configFileManager.test.ts @@ -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(); diff --git a/tests/libs/platformCommands.test.ts b/tests/libs/platformCommands.test.ts new file mode 100644 index 00000000..436d1d2f --- /dev/null +++ b/tests/libs/platformCommands.test.ts @@ -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}"`); + }); + }); +}); diff --git a/tests/services/simulator.test.ts b/tests/services/simulator.test.ts index f3cc2559..c6e48178 100644 --- a/tests/services/simulator.test.ts +++ b/tests/services/simulator.test.ts @@ -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(); + }); +});