Skip to content
Open
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 docs/api-references/environment/init.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,6 @@ Initialize the GenLayer Environment
| | --numValidators <numValidators> | Number of validators | No | `5` |
| | --headless | Headless mode | No | `false` |
| | --reset-db | Reset Database | No | `false` |
| | --localnet-version <localnetVersion> | Select a specific localnet version (minimum: v0.65.0) | No | `v0.65.0` |
| | --localnet-version <localnetVersion> | Select a specific localnet version (minimum: v0.65.0). Omit to pick from the published versions. | No | |
| | --ollama | Enable Ollama container | No | `false` |
| -h | --help | display help for command | No | |
5 changes: 4 additions & 1 deletion src/commands/general/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ export function initializeGeneralCommands(program: Command) {
.option("--numValidators <numValidators>", "Number of validators", "5")
.option("--headless", "Headless mode", false)
.option("--reset-db", "Reset Database", false)
.option("--localnet-version <localnetVersion>", `Select a specific localnet version (minimum: ${localnetCompatibleVersion})`, localnetCompatibleVersion)
.option(
"--localnet-version <localnetVersion>",
`Select a specific localnet version (minimum: ${localnetCompatibleVersion}). Omit to pick from the published versions.`,
)
.option("--ollama", "Enable Ollama container", false)
.action(async (options: InitActionOptions) => {
const initAction = new InitAction();
Expand Down
52 changes: 50 additions & 2 deletions src/commands/general/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export interface InitActionOptions {
numValidators: number;
headless: boolean;
resetDb: boolean;
localnetVersion: string;
localnetVersion?: string;
ollama: boolean;
}

Expand Down Expand Up @@ -40,10 +40,58 @@ export class InitAction extends BaseAction {
this.simulatorService = new SimulatorService();
}

/**
* Decide which localnet version `init` runs.
*
* An explicit `--localnet-version` is always honoured, so scripted and CI use
* is unaffected. Otherwise show what is actually published: the flag used to
* default to a hardcoded floor (`v0.65.0`) with no way to discover anything
* newer, which is the gap #164 asks to close.
*
* The registry lookup is best-effort. Offline, rate-limited, or an empty list
* falls straight back to `localnetCompatibleVersion`. A force-closed prompt
* (no TTY and no piped stdin, e.g. CI) falls back the same way rather than
* aborting `init`, and an explicit flag still short-circuits the prompt.
*/
private async resolveLocalnetVersion(requested?: string): Promise<string> {
if (requested) {
return requested;
}

this.startSpinner("Fetching available localnet versions...");
const versions = await this.simulatorService.listAvailableLocalnetVersions();
this.stopSpinner();

if (versions.length === 0) {
this.logInfo(
`Could not fetch the published localnet versions, defaulting to ${localnetCompatibleVersion}.`,
);
return localnetCompatibleVersion;
}

try {
const answer = await inquirer.prompt([
{
type: "list",
name: "localnetVersion",
message: "Select the GenLayer localnet version to run:",
choices: versions,
default: versions[0],
},
]);
return answer.localnetVersion as string;
} catch {
// inquirer throws ExitPromptError when there is no way to answer the
// prompt. Default rather than fail: the version picker is an
// improvement, not a precondition for starting a localnet.
return localnetCompatibleVersion;
}
}

public async execute(options: InitActionOptions): Promise<void> {
try {
this.simulatorService.setComposeOptions(options.headless, options.ollama);
let localnetVersion = options.localnetVersion;
let localnetVersion = await this.resolveLocalnetVersion(options.localnetVersion);
if (localnetVersion !== "latest") {
localnetVersion = this.simulatorService.normalizeLocalnetVersion(localnetVersion);

Expand Down
25 changes: 25 additions & 0 deletions src/lib/config/simulator.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,29 @@
export const localnetCompatibleVersion = "v0.65.0";

/**
* Docker Hub repository whose tags are the published localnet releases.
*
* Every simulator image in `docker-compose.yml` is published under the same
* `LOCALNETVERSION` tag, so the jsonrpc repo is a complete index of what a user
* can actually run.
*/
export const SIMULATOR_IMAGE_REPOSITORY = "yeagerai/simulator-jsonrpc";

/**
* Docker Hub tags API, used to enumerate the published localnet versions.
*
* Unauthenticated and rate-limited by IP, so every failure path in
* `listAvailableLocalnetVersions` degrades to "no list" rather than aborting
* `init`.
*/
export const DOCKER_HUB_TAGS_URL = "https://hub.docker.com/v2/repositories";

/** Deadline for the version-listing request, in ms. */
export const LOCALNET_VERSION_LIST_TIMEOUT_MS = 5000;

/** Cap on how many versions the interactive picker offers. */
export const LOCALNET_VERSION_LIST_LIMIT = 20;

export const DEFAULT_JSON_RPC_URL = "http://localhost:4000/api";
export const CONTAINERS_NAME_PREFIX = "/genlayer-";
export const IMAGES_NAME_PREFIX = "yeagerai";
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 @@ -20,6 +20,7 @@ export interface ISimulatorService {
cleanDatabase(): Promise<boolean>;
addConfigToEnvFile(newConfig: Record<string, string>): void;
normalizeLocalnetVersion(version: string): string;
listAvailableLocalnetVersions(): Promise<string[]>;
compareVersions(version1: string, version2: string): number;
ensureDockerRunning(): Promise<void>;
isLocalnetRunning(): Promise<boolean>;
Expand Down
65 changes: 64 additions & 1 deletion src/lib/services/simulator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@ import {
VERSION_REQUIREMENTS,
CONTAINERS_NAME_PREFIX,
IMAGES_NAME_PREFIX,
GENLAYER_REQUIRED_CONTAINERS
GENLAYER_REQUIRED_CONTAINERS,
localnetCompatibleVersion,
SIMULATOR_IMAGE_REPOSITORY,
DOCKER_HUB_TAGS_URL,
LOCALNET_VERSION_LIST_TIMEOUT_MS,
LOCALNET_VERSION_LIST_LIMIT
} from "../config/simulator";
import {
checkCommand,
Expand Down Expand Up @@ -321,6 +326,64 @@ export class SimulatorService implements ISimulatorService {
return version
}

/**
* Enumerate the published localnet versions, newest first.
*
* `--localnet-version` accepts an opaque string, so a typo or a version that
* was never published only surfaces much later as a Docker pull failure. This
* reads the tags of the simulator image repo and returns the runnable ones so
* `init` can offer a real choice.
*
* Deliberately returns `[]` on every failure rather than throwing: the tags
* API is unauthenticated and rate-limited per IP, and a registry hiccup must
* never stop someone from starting a localnet. The caller falls back to the
* compiled-in default.
*
* The registry interleaves real releases with per-arch and per-PR tags
* (`v0.121.24-amd64`, `hotfix-1689-660b154e`, `pr1680-ac0034ff`), and only the
* plain `vX.Y.Z[-pre]` tags are valid `LOCALNETVERSION` values, so everything
* else is dropped here rather than shown to the user.
*/
public async listAvailableLocalnetVersions(): Promise<string[]> {
const url = `${DOCKER_HUB_TAGS_URL}/${SIMULATOR_IMAGE_REPOSITORY}/tags?page_size=100`;
const releaseTag = /^v\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/;
// The per-architecture tags satisfy `releaseTag` too (the suffix is just
// another pre-release segment), so they need excluding by name. The
// unsuffixed multi-arch tag is the one every compose service resolves.
const archTag = /-(amd64|arm64|arm|386|ppc64le|s390x)$/;

let payload: {results?: Array<{name?: string}>};
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(LOCALNET_VERSION_LIST_TIMEOUT_MS),
});
if (!response.ok) {
return [];
}
payload = await response.json();
} catch {
// Offline, DNS failure, rate limit, timeout, or a non-JSON error page.
return [];
}

const names = (payload?.results ?? [])
.map(tag => tag?.name)
.filter((name): name is string => typeof name === "string");

const seen = new Set<string>();
return names
.filter(name => releaseTag.test(name))
.filter(name => !archTag.test(name))
.filter(name => {
if (seen.has(name)) return false;
seen.add(name);
return true;
})
.filter(name => this.compareVersions(name, localnetCompatibleVersion) >= 0)
.sort((a, b) => this.compareVersions(b, a))
.slice(0, LOCALNET_VERSION_LIST_LIMIT);
}

public compareVersions(version1: string, version2: string): number {
const v1 = version1.replace(/^v/, '');
const v2 = version2.replace(/^v/, '');
Expand Down
124 changes: 124 additions & 0 deletions tests/actions/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ describe("InitAction", () => {

const defaultConfig = {defaultOllamaModel: "llama3"};

// An explicit version keeps these tests focused on the rest of `init`; the
// resolution path when the flag is absent has its own describe block below.
const defaultOptions: InitActionOptions = {
numValidators: 5,
headless: false,
Expand Down Expand Up @@ -481,3 +483,125 @@ describe("InitAction", () => {
});
});
});


describe("InitAction localnet version resolution (#164)", () => {
let initAction: InitAction;
let inquirerPromptSpy: ReturnType<any>;
let listVersionsSpy: ReturnType<typeof vi.spyOn>;

const withoutFlag: InitActionOptions = {
numValidators: 5,
headless: false,
resetDb: false,
ollama: false,
};

/** Answer every prompt `init` asks, so the version picker lands first. */
const answerRemainingPrompts = () => {
inquirerPromptSpy.mockResolvedValueOnce({localnetVersion: "v0.121.24"});
inquirerPromptSpy.mockResolvedValueOnce({confirmAction: true});
inquirerPromptSpy.mockResolvedValueOnce({selectedLlmProviders: ["openai"]});
inquirerPromptSpy.mockResolvedValueOnce({openai: "API_KEY_OPENAI"});
};

beforeEach(() => {
vi.clearAllMocks();
initAction = new InitAction();
inquirerPromptSpy = vi.spyOn(inquirer, "prompt");
vi.spyOn(initAction as any, "startSpinner").mockImplementation(() => {});
vi.spyOn(initAction as any, "setSpinnerText").mockImplementation(() => {});
vi.spyOn(initAction as any, "succeedSpinner").mockImplementation(() => {});
vi.spyOn(initAction as any, "failSpinner").mockImplementation(() => {});
vi.spyOn(initAction as any, "stopSpinner").mockImplementation(() => {});
vi.spyOn(initAction as any, "logInfo").mockImplementation(() => {});
vi.spyOn(initAction, "getConfig").mockReturnValue({defaultOllamaModel: "llama3"});
listVersionsSpy = vi
.spyOn(SimulatorService.prototype, "listAvailableLocalnetVersions")
.mockResolvedValue(["v0.121.24", "v0.121.22"]);

vi.spyOn(SimulatorService.prototype, "checkCliVersion").mockResolvedValue(undefined);
vi.spyOn(SimulatorService.prototype, "checkInstallRequirements")
.mockResolvedValue({git: true, docker: true});
vi.spyOn(SimulatorService.prototype, "checkVersionRequirements")
.mockResolvedValue({node: "", docker: ""});
vi.spyOn(SimulatorService.prototype, "resetDockerContainers").mockResolvedValue(undefined);
vi.spyOn(SimulatorService.prototype, "resetDockerImages").mockResolvedValue(undefined);
vi.spyOn(SimulatorService.prototype, "resetDockerVolumes").mockResolvedValue(undefined);
vi.spyOn(SimulatorService.prototype, "addConfigToEnvFile").mockImplementation(() => {});
vi.spyOn(SimulatorService.prototype, "normalizeLocalnetVersion")
.mockImplementation((version: string) => version);
vi.spyOn(SimulatorService.prototype, "setupLocalhostAccess").mockImplementation(() => {});
vi.spyOn(SimulatorService.prototype, "runSimulator").mockResolvedValue({stdout: "", stderr: ""});
vi.spyOn(SimulatorService.prototype, "waitForSimulatorToBeReady")
.mockResolvedValue({initialized: true});
vi.spyOn(SimulatorService.prototype, "deleteAllValidators").mockResolvedValue(undefined);
vi.spyOn(SimulatorService.prototype, "createRandomValidators").mockResolvedValue(undefined);
vi.spyOn(SimulatorService.prototype, "openFrontend").mockResolvedValue(true);
vi.spyOn(OllamaAction.prototype, "updateModel").mockResolvedValue(undefined);
});

test("offers the published versions and uses the chosen one", async () => {
answerRemainingPrompts();

await initAction.execute(withoutFlag);

expect(listVersionsSpy).toHaveBeenCalled();
const [questions] = inquirerPromptSpy.mock.calls[0];
expect(questions[0].type).toBe("list");
expect(questions[0].choices).toEqual(["v0.121.24", "v0.121.22"]);
expect(SimulatorService.prototype.addConfigToEnvFile).toHaveBeenCalledWith({
LOCALNETVERSION: "v0.121.24",
});
});

test("defaults the picker to the newest published version", async () => {
answerRemainingPrompts();

await initAction.execute(withoutFlag);

const [questions] = inquirerPromptSpy.mock.calls[0];
expect(questions[0].default).toBe("v0.121.24");
});

test("an explicit flag short-circuits the lookup and the prompt", async () => {
// No picker prompt here, so the queue starts at the reset confirmation.
inquirerPromptSpy.mockResolvedValueOnce({confirmAction: true});
inquirerPromptSpy.mockResolvedValueOnce({selectedLlmProviders: ["openai"]});
inquirerPromptSpy.mockResolvedValueOnce({openai: "API_KEY_OPENAI"});

await initAction.execute({...withoutFlag, localnetVersion: "v0.120.0"});

expect(listVersionsSpy).not.toHaveBeenCalled();
expect(inquirerPromptSpy.mock.calls[0][0][0].name).toBe("confirmAction");
expect(SimulatorService.prototype.addConfigToEnvFile).toHaveBeenCalledWith({
LOCALNETVERSION: "v0.120.0",
});
});

test("falls back to the compatible version when the registry is unreachable", async () => {
listVersionsSpy.mockResolvedValue([]);
inquirerPromptSpy.mockResolvedValueOnce({confirmAction: true});
inquirerPromptSpy.mockResolvedValueOnce({selectedLlmProviders: ["openai"]});
inquirerPromptSpy.mockResolvedValueOnce({openai: "API_KEY_OPENAI"});

await initAction.execute(withoutFlag);

expect(SimulatorService.prototype.addConfigToEnvFile).toHaveBeenCalledWith({
LOCALNETVERSION: localnetCompatibleVersion,
});
});

test("falls back to the compatible version when the prompt cannot be answered", async () => {
inquirerPromptSpy.mockRejectedValueOnce(new Error("Prompt was force closed"));
inquirerPromptSpy.mockResolvedValueOnce({confirmAction: true});
inquirerPromptSpy.mockResolvedValueOnce({selectedLlmProviders: ["openai"]});
inquirerPromptSpy.mockResolvedValueOnce({openai: "API_KEY_OPENAI"});

await initAction.execute(withoutFlag);

expect(SimulatorService.prototype.addConfigToEnvFile).toHaveBeenCalledWith({
LOCALNETVERSION: localnetCompatibleVersion,
});
});
});
18 changes: 14 additions & 4 deletions tests/commands/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ vi.mock("../../src/commands/general/init");


const openFrontendSpy = vi.spyOn(simulatorService, "openFrontend");
// `--localnet-version` no longer carries a default: `init` resolves it, either
// from the flag or by offering the published versions.
const defaultOptions = {
numValidators: "5",
headless: false,
resetDb: false,
localnetVersion: localnetCompatibleVersion,
ollama: false,
}

Expand Down Expand Up @@ -85,12 +86,21 @@ describe("init command", () => {

test("option --localnet-version description includes minimum version", async () => {
const localnetVersionOption = getCommandOption(initCommand, "--localnet-version");
expect(localnetVersionOption?.description).toBe(`Select a specific localnet version (minimum: ${localnetCompatibleVersion})`);
expect(localnetVersionOption?.description).toContain(
`Select a specific localnet version (minimum: ${localnetCompatibleVersion})`,
);
});

test("option --localnet-version default value is compatible version", async () => {
test("option --localnet-version has no default so init can offer the published versions", async () => {
const localnetVersionOption = getCommandOption(initCommand, "--localnet-version");
expect(localnetVersionOption?.defaultValue).toBe(localnetCompatibleVersion);
expect(localnetVersionOption?.defaultValue).toBeUndefined();
});

test("omitting --localnet-version leaves the choice to InitAction", async () => {
program.parse(["node", "test", "init"]);
expect(InitAction.prototype.execute).toHaveBeenCalledWith(
expect.not.objectContaining({localnetVersion: expect.anything()}),
);
});

test("option --ollama is accepted", async () => {
Expand Down
Loading