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: 2 additions & 0 deletions bin/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down
1 change: 1 addition & 0 deletions bin/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"dependencies": {
"@clack/prompts": "^0.11.0",
"@fluffylabs/jammin-sdk": "workspace:*",
"@typeberry/lib": "^0.5.8",
"commander": "^14.0.2"
},
"devDependencies": {
Expand Down
217 changes: 217 additions & 0 deletions bin/cli/src/commands/start-command.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof mock>;

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;
Comment thread
skoszuta marked this conversation as resolved.
});

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<typeof mock>;
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(() => {});
});
Comment thread
skoszuta marked this conversation as resolved.

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);
});
});
});
144 changes: 144 additions & 0 deletions bin/cli/src/commands/start-command.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<void> {
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}`);
}
Comment thread
skoszuta marked this conversation as resolved.
}

/**
* Spawn a Docker container to run a local Typeberry network.
*/
export async function startContainer(filteredGenesisPath: string): Promise<void> {
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);
}
}
});
Loading
Loading