diff --git a/bin/cli/index.ts b/bin/cli/index.ts index e6814cc..f470baf 100755 --- a/bin/cli/index.ts +++ b/bin/cli/index.ts @@ -5,6 +5,7 @@ import { version } from "./package.json" with { type: "json" }; import { buildCommand } from "./src/commands/build-command"; import { createCommand } from "./src/commands/create-command"; import { deployCommand } from "./src/commands/deploy-command"; +import { startCommand } from "./src/commands/start-command"; import { testCommand } from "./src/commands/test-command"; program @@ -22,6 +23,7 @@ program.addCommand(createCommand); program.addCommand(buildCommand); program.addCommand(testCommand); program.addCommand(deployCommand); +program.addCommand(startCommand); // TODO: [MaSo] Display accual examples program.addHelpText( diff --git a/bin/cli/package.json b/bin/cli/package.json index c4e3fc4..39dc0b8 100644 --- a/bin/cli/package.json +++ b/bin/cli/package.json @@ -19,6 +19,7 @@ "dependencies": { "@clack/prompts": "^0.11.0", "@fluffylabs/jammin-sdk": "workspace:*", + "@typeberry/lib": "^0.5.8", "commander": "^14.0.2" }, "devDependencies": { diff --git a/bin/cli/src/commands/start-command.test.ts b/bin/cli/src/commands/start-command.test.ts new file mode 100644 index 0000000..e62a968 --- /dev/null +++ b/bin/cli/src/commands/start-command.test.ts @@ -0,0 +1,217 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { mkdir, rm, unlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +describe("start-command", () => { + describe("createFilteredGenesis", () => { + const { createFilteredGenesis } = require("./start-command"); + + test("filters out genesis state drop keys from genesis data", async () => { + const genesisWithState = { + network: "testnet", + genesis_state: { + "04000000000000000000000000000000000000000000000000000000000000": { data: "next" }, + "07000000000000000000000000000000000000000000000000000000000000": { data: "designated" }, + "08000000000000000000000000000000000000000000000000000000000000": { data: "current" }, + "09000000000000000000000000000000000000000000000000000000000000": { data: "previous" }, + "05000000000000000000000000000000000000000000000000000000000000": { data: "keep this" }, + }, + }; + + const tmpGenesisPath = join(tmpdir(), `test-genesis-${Date.now()}.json`); + await Bun.write(tmpGenesisPath, JSON.stringify(genesisWithState)); + + const resultPath = await createFilteredGenesis(tmpGenesisPath); + const result = await Bun.file(resultPath).json(); + + expect(result.genesis_state).toBeDefined(); + expect(result.genesis_state["04000000000000000000000000000000000000000000000000000000000000"]).toBeUndefined(); + expect(result.genesis_state["07000000000000000000000000000000000000000000000000000000000000"]).toBeUndefined(); + expect(result.genesis_state["08000000000000000000000000000000000000000000000000000000000000"]).toBeUndefined(); + expect(result.genesis_state["09000000000000000000000000000000000000000000000000000000000000"]).toBeUndefined(); + expect(result.genesis_state["05000000000000000000000000000000000000000000000000000000000000"]).toEqual({ + data: "keep this", + }); + + await unlink(tmpGenesisPath).catch(() => {}); + await unlink(resultPath).catch(() => {}); + }); + + test("handles genesis without genesis_state field", async () => { + const genesisWithoutState = { + network: "testnet", + config: { chainId: 1 }, + }; + + const tmpGenesisPath = join(tmpdir(), `test-genesis-${Date.now()}.json`); + await Bun.write(tmpGenesisPath, JSON.stringify(genesisWithoutState)); + + const resultPath = await createFilteredGenesis(tmpGenesisPath); + const result = await Bun.file(resultPath).json(); + + expect(result.network).toBe("testnet"); + expect(result.config).toEqual({ chainId: 1 }); + expect(result.genesis_state).toBeUndefined(); + + await unlink(tmpGenesisPath).catch(() => {}); + await unlink(resultPath).catch(() => {}); + }); + + test("writes filtered genesis to temp directory", async () => { + const genesis = { genesis_state: {} }; + + const tmpGenesisPath = join(tmpdir(), `test-genesis-${Date.now()}.json`); + await Bun.write(tmpGenesisPath, JSON.stringify(genesis)); + + const resultPath = await createFilteredGenesis(tmpGenesisPath); + + expect(resultPath).toContain("jammin-genesis-"); + expect(resultPath).toContain(tmpdir()); + + await unlink(tmpGenesisPath).catch(() => {}); + await unlink(resultPath).catch(() => {}); + }); + }); + + describe("pullImage", () => { + let originalSpawn: typeof Bun.spawn; + let mockSpawn: ReturnType; + + beforeEach(() => { + originalSpawn = Bun.spawn; + mockSpawn = mock(() => ({ + stdout: new ReadableStream({ + start(controller) { + controller.close(); + }, + }), + stderr: new ReadableStream({ + start(controller) { + controller.close(); + }, + }), + exited: Promise.resolve(0), + })); + // biome-ignore lint/suspicious/noExplicitAny: Need to mock Bun.spawn for testing + (Bun as any).spawn = mockSpawn; + }); + + afterEach(() => { + Bun.spawn = originalSpawn; + }); + + test("pulls the correct Docker image with platform flag", async () => { + const { pullImage } = require("./start-command"); + await pullImage(); + + expect(mockSpawn).toHaveBeenCalledTimes(1); + const callArgs = mockSpawn.mock.calls[0]; + expect(callArgs?.[0]).toEqual([ + "docker", + "pull", + "--platform=linux/amd64", + "ghcr.io/fluffylabs/typeberry:latest", + ]); + }); + + test("throws error when pull fails with non-zero exit code", async () => { + const failingMockSpawn = mock(() => ({ + stdout: new ReadableStream({ + start(controller) { + controller.close(); + }, + }), + stderr: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("pull failed: network error")); + controller.close(); + }, + }), + exited: Promise.resolve(1), + })); + // biome-ignore lint/suspicious/noExplicitAny: Need to mock Bun.spawn for testing + (Bun as any).spawn = failingMockSpawn; + + const { pullImage } = require("./start-command"); + + return Promise.all([ + expect(pullImage()).rejects.toThrow("Failed to pull image"), + expect(pullImage()).rejects.toThrow("exit code 1"), + ]); + }); + }); + + describe("startContainer", () => { + let originalSpawn: typeof Bun.spawn; + let originalCwd: () => string; + let mockSpawn: ReturnType; + let testCwd: string; + + beforeEach(async () => { + originalSpawn = Bun.spawn; + mockSpawn = mock(() => ({ + stdout: new ReadableStream({ + start(controller) { + controller.close(); + }, + }), + stderr: new ReadableStream({ + start(controller) { + controller.close(); + }, + }), + exited: Promise.resolve(0), + })); + // biome-ignore lint/suspicious/noExplicitAny: Need to mock Bun.spawn for testing + (Bun as any).spawn = mockSpawn; + + testCwd = join(tmpdir(), `jammin-test-${Date.now()}`); + await mkdir(testCwd, { recursive: true }); + await mkdir(join(testCwd, "logs"), { recursive: true }); + originalCwd = process.cwd; + process.cwd = mock(() => testCwd); + }); + + afterEach(async () => { + Bun.spawn = originalSpawn; + process.cwd = originalCwd; + await rm(testCwd, { recursive: true }).catch(() => {}); + }); + + test("spawns docker container with correct arguments in correct order", async () => { + const { startContainer } = require("./start-command"); + + await startContainer("/tmp/filtered-genesis.json"); + + expect(mockSpawn).toHaveBeenCalledTimes(1); + const callArgs = mockSpawn.mock.calls[0]; + const dockerArgs = callArgs?.[0] as string[]; + + expect(dockerArgs).toEqual([ + "docker", + "run", + "--rm", + "-v", + "/tmp/filtered-genesis.json:/app/genesis.json:ro", + "-v", + `${testCwd}/logs:/app/bin/jam/logs`, + "--entrypoint", + "/bin/bash", + "ghcr.io/fluffylabs/typeberry:latest", + "-c", + "npm run tiny-network -- --config=dev --config=.chain_spec+=/app/genesis.json", + ]); + }); + + test("passes cwd to docker spawn", async () => { + const { startContainer } = require("./start-command"); + + await startContainer("/tmp/filtered-genesis.json"); + + expect(mockSpawn).toHaveBeenCalledTimes(1); + const callArgs = mockSpawn.mock.calls[0]; + expect(callArgs?.[1]?.cwd).toBe(testCwd); + }); + }); +}); diff --git a/bin/cli/src/commands/start-command.ts b/bin/cli/src/commands/start-command.ts new file mode 100644 index 0000000..79181a3 --- /dev/null +++ b/bin/cli/src/commands/start-command.ts @@ -0,0 +1,144 @@ +import { access, mkdir, unlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import * as p from "@clack/prompts"; +import * as state_merkleization from "@typeberry/lib/state-merkleization"; +import { Command } from "commander"; + +const { StateKeyIdx, stateKeys } = state_merkleization; + +const DOCKER_IMAGE = "ghcr.io/fluffylabs/typeberry:latest"; + +// note [seko]: genesis state keys to drop before passing to typeberry. this is needed to avoid overwriting dev node validator data +const GENESIS_STATE_DROP_KEYS: string[] = [ + StateKeyIdx.Gamma, + StateKeyIdx.Iota, + StateKeyIdx.Kappa, + StateKeyIdx.Lambda, +].map((stateKeyIdx) => stateKeys.index(stateKeyIdx).toString().slice(2, -2)); + +export async function createFilteredGenesis(genesisPath: string): Promise { + const raw = await Bun.file(genesisPath).text(); + const genesis = JSON.parse(raw); + + if (genesis.genesis_state) { + for (const key of GENESIS_STATE_DROP_KEYS) { + delete genesis.genesis_state[key]; + } + } + + const tmpPath = join(tmpdir(), `jammin-genesis-${Date.now()}.json`); + await Bun.write(tmpPath, JSON.stringify(genesis, null, 2)); + return tmpPath; +} + +/** + * Pull the latest Docker image + */ +export async function pullImage(): Promise { + const proc = Bun.spawn(["docker", "pull", "--platform=linux/amd64", DOCKER_IMAGE], { + stdout: "inherit", + stderr: "inherit", + cwd: process.cwd(), + }); + + const [stderr, exitCode] = await Promise.all([new Response(proc.stderr).text(), proc.exited]); + + if (exitCode !== 0) { + throw new Error(`Failed to pull image ${DOCKER_IMAGE} with exit code ${exitCode}: ${stderr}`); + } +} + +/** + * Spawn a Docker container to run a local Typeberry network. + */ +export async function startContainer(filteredGenesisPath: string): Promise { + const logsPath = `${process.cwd()}/logs`; + await mkdir(logsPath, { recursive: true }); + const dockerArgs: string[] = [ + "run", + "--rm", + "-v", + `${filteredGenesisPath}:/app/genesis.json:ro`, + "-v", + `${logsPath}:/app/bin/jam/logs`, + "--entrypoint", + "/bin/bash", + DOCKER_IMAGE, + "-c", + "npm run tiny-network -- --config=dev --config=.chain_spec+=/app/genesis.json", + ]; + + const proc = Bun.spawn(["docker", ...dockerArgs], { + stdout: "inherit", + stderr: "inherit", + cwd: process.cwd(), + }); + + const cleanup = () => { + proc.kill(); + }; + + process.on("SIGINT", cleanup); + process.on("SIGTERM", cleanup); + process.on("exit", cleanup); + + await proc.exited; +} + +/** + * "Start" command definition + */ +export const startCommand = new Command("start") + .description("start a local JAM environment in a Docker container") + .addHelpText( + "after", + ` +Examples: + $ jammin start +`, + ) + .action(async () => { + p.intro("🚀 Starting local JAM network..."); + + const genesisPath = resolve(process.cwd(), "dist/genesis.json"); + try { + await access(genesisPath); + } catch { + p.log.error(`Genesis file not found at ${genesisPath}`); + p.log.info('Run "jammin deploy" first to generate the genesis state.'); + p.outro("❌ Start aborted."); + process.exit(1); + } + + let filteredGenesisPath: string | undefined; + const s = p.spinner(); + try { + s.start("Pulling latest Typeberry image..."); + await pullImage(); + s.stop("✅ Image up to date"); + + s.start("Preparing genesis state..."); + filteredGenesisPath = await createFilteredGenesis(genesisPath); + s.stop("✅ Genesis state ready"); + + s.start("Spawning Docker container..."); + s.stop("Typeberry container output:"); + await startContainer(filteredGenesisPath); + + p.outro("🏁 Local JAM network finished running."); + } catch (error) { + if (error instanceof Error) { + p.log.error(error.message); + } else { + p.log.error(String(error)); + } + + p.outro("❌ Start failed. See the output above for details."); + process.exit(1); + } finally { + if (filteredGenesisPath) { + await unlink(filteredGenesisPath); + } + } + }); diff --git a/bun.lock b/bun.lock index 466f497..e3e7e18 100644 --- a/bun.lock +++ b/bun.lock @@ -24,6 +24,7 @@ "dependencies": { "@clack/prompts": "^0.11.0", "@fluffylabs/jammin-sdk": "workspace:*", + "@typeberry/lib": "^0.5.8", "commander": "^14.0.2", }, "devDependencies": { @@ -90,7 +91,7 @@ "@typeberry/bandersnatch-native-linux-x64-gnu": ["@typeberry/bandersnatch-native-linux-x64-gnu@0.2.0-74dd7d7", "", { "os": "linux", "cpu": "x64" }, "sha512-kPTDJ6YYPghxMubhxMsn9wBea/aH9/hIaiG66PW7skRwDM9D5wNzZrmSD7BlpSKv9ayK2hIQiSL1amCiTS9mXQ=="], - "@typeberry/lib": ["@typeberry/lib@0.5.7", "", { "dependencies": { "@fluffylabs/anan-as": "^1.1.5", "@noble/ed25519": "2.2.3", "@opentelemetry/api": "1.9.0", "@typeberry/native": "0.2.0-74dd7d7", "eventemitter3": "^5.0.1", "hash-wasm": "4.12.0" } }, "sha512-q5g8jWeAeGBwh4Ywdjtc9R8R5xIkChD5uIpjvG8tEFIUGyN0/rf+e4wbrB71dRxJEojKK4ZPYt3gvuk9eWQI4w=="], + "@typeberry/lib": ["@typeberry/lib@0.5.8", "", { "dependencies": { "@fluffylabs/anan-as": "^1.1.5", "@noble/ed25519": "2.2.3", "@opentelemetry/api": "1.9.0", "@typeberry/native": "0.2.0-74dd7d7", "eventemitter3": "^5.0.1", "hash-wasm": "4.12.0" } }, "sha512-yB4ooIn1pxyCXKYp6gz0LzHOCLIXU/fm0exZA1hCsY/MHD1tHl27iaijd/fcmXkiekhNhQ9ejVa/pf5SV4ffpg=="], "@typeberry/native": ["@typeberry/native@0.2.0-74dd7d7", "", { "optionalDependencies": { "@typeberry/bandersnatch-native-darwin-arm64": "0.2.0-74dd7d7", "@typeberry/bandersnatch-native-linux-x64-gnu": "0.2.0-74dd7d7" } }, "sha512-b7qq7cIO30KKUVe2WISd+WmipurlzVePz7zFWFkZ2g8nqvfAt8l3upANC1Yxg+LPFLQLWUbEJ7vZ0reOX68Vqg=="], @@ -120,8 +121,14 @@ "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "@fluffylabs/jammin-cli/@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + + "@fluffylabs/jammin-sdk/@typeberry/lib": ["@typeberry/lib@0.5.7", "", { "dependencies": { "@fluffylabs/anan-as": "^1.1.5", "@noble/ed25519": "2.2.3", "@opentelemetry/api": "1.9.0", "@typeberry/native": "0.2.0-74dd7d7", "eventemitter3": "^5.0.1", "hash-wasm": "4.12.0" } }, "sha512-q5g8jWeAeGBwh4Ywdjtc9R8R5xIkChD5uIpjvG8tEFIUGyN0/rf+e4wbrB71dRxJEojKK4ZPYt3gvuk9eWQI4w=="], + "@fluffylabs/jammin-sdk/@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + "@fluffylabs/jammin-cli/@types/bun/bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], + "@fluffylabs/jammin-sdk/@types/bun/bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], } } diff --git a/packages/jammin-sdk/utils/genesis-state-generator.test.ts b/packages/jammin-sdk/utils/genesis-state-generator.test.ts index 4fe455a..b377af4 100644 --- a/packages/jammin-sdk/utils/genesis-state-generator.test.ts +++ b/packages/jammin-sdk/utils/genesis-state-generator.test.ts @@ -35,10 +35,10 @@ describe("genesis-generator", () => { expect(genesis.bootnodes).toEqual([]); expect(genesis.genesis_header).toBeDefined(); expect(genesis.genesis_state).toBeDefined(); - expect(Array.isArray(genesis.genesis_state)).toBe(true); - expect(genesis.genesis_state.length).toBeGreaterThan(0); + expect(typeof genesis.genesis_state).toBe("object"); + expect(Object.keys(genesis.genesis_state).length).toBeGreaterThan(0); - const stateValues = genesis.genesis_state.map((entry) => entry[1]); + const stateValues = Object.values(genesis.genesis_state); const serviceCodeHex = services[0]?.code.toString().substring(2); const serviceCodeHex1 = services[1]?.code.toString().substring(2); diff --git a/packages/jammin-sdk/utils/genesis-state-generator.ts b/packages/jammin-sdk/utils/genesis-state-generator.ts index 325d354..7a0a240 100644 --- a/packages/jammin-sdk/utils/genesis-state-generator.ts +++ b/packages/jammin-sdk/utils/genesis-state-generator.ts @@ -68,10 +68,12 @@ export function toJip4Schema(genesis: Genesis) { id: genesis.id, bootnodes: genesis.bootnodes, genesis_header: genesis.genesisHeader.toString().substring(2), - genesis_state: Array.from(genesis.genesisState.entries()).map(([key, value]) => [ - key.toString().substring(2), - value.toString().substring(2), - ]), + genesis_state: Object.fromEntries( + [...genesis.genesisState.entries()].map(([key, value]) => [ + key.toString().substring(2), + value.toString().substring(2), + ]), + ), // TODO: [MaSo] Update typeberry jip4Chainspec - add protocol_parameters }; }