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
16 changes: 8 additions & 8 deletions bin/cli/src/commands/build-command.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
import { resolve } from "node:path";
import { SDK_CONFIGS, type SdkConfig, type ServiceConfig } from "@fluffylabs/jammin-sdk";
import { buildService } from "./build-command";
import { callDockerBuild } from "./build-command";

describe("build-command", () => {
describe("buildService - Docker command generation", () => {
Expand Down Expand Up @@ -41,7 +41,7 @@ describe("build-command", () => {
sdk: "jambrains-1cfc41c",
};

await buildService(service, "/test/project");
await callDockerBuild(service, "/test/project");

expect(mockSpawn).toHaveBeenCalledTimes(1);
const spawnCall = mockSpawn.mock.calls[0];
Expand All @@ -64,7 +64,7 @@ describe("build-command", () => {
sdk: "jade-0.0.15-pre.1",
};

await buildService(service, "/test/project");
await callDockerBuild(service, "/test/project");

expect(mockSpawn).toHaveBeenCalledTimes(1);
const spawnCall = mockSpawn.mock.calls[0];
Expand All @@ -91,7 +91,7 @@ describe("build-command", () => {
sdk: customSdk,
};

await buildService(service, "/test/project");
await callDockerBuild(service, "/test/project");

expect(mockSpawn).toHaveBeenCalledTimes(1);
const spawnCall = mockSpawn.mock.calls[0];
Expand Down Expand Up @@ -132,8 +132,8 @@ describe("build-command", () => {
sdk: "jambrains-1cfc41c",
};

expect(buildService(service, "/test/project")).rejects.toThrow();
expect(buildService(service, "/test/project")).rejects.toThrow("Build failed for service 'failing-service'");
expect(callDockerBuild(service, "/test/project")).rejects.toThrow();
expect(callDockerBuild(service, "/test/project")).rejects.toThrow("Build failed for service 'failing-service'");
});

test("should return build output on success", async () => {
Expand Down Expand Up @@ -164,7 +164,7 @@ describe("build-command", () => {
sdk: "jambrains-1cfc41c",
};

const output = await buildService(service, "/test/project");
const output = await callDockerBuild(service, "/test/project");
expect(output).toBe(expectedOutput);
});
});
Expand Down Expand Up @@ -208,7 +208,7 @@ describe("build-command", () => {
};

const projectRoot = "/absolute/project/root";
await buildService(service, projectRoot);
await callDockerBuild(service, projectRoot);

expect(mockSpawn).toHaveBeenCalledTimes(1);
expect(mockSpawn.mock.calls.length).toBeGreaterThan(0);
Expand Down
86 changes: 35 additions & 51 deletions bin/cli/src/commands/build-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { mkdir } from "node:fs/promises";
import { join, relative, resolve } from "node:path";
import * as p from "@clack/prompts";
import type { ServiceConfig } from "@fluffylabs/jammin-sdk";
import { getJamFiles, getServiceConfigs, SDK_CONFIGS } from "@fluffylabs/jammin-sdk";
import { copyJamToDist, getJamFiles, getServiceConfigs, SDK_CONFIGS } from "@fluffylabs/jammin-sdk";
import { Command } from "commander";

/**
Expand All @@ -17,7 +17,7 @@ export class DockerError extends Error {
}
}

export async function buildService(service: ServiceConfig, projectRoot: string): Promise<string> {
export async function callDockerBuild(service: ServiceConfig, projectRoot: string): Promise<string> {
const sdk = typeof service.sdk === "string" ? SDK_CONFIGS[service.sdk] : service.sdk;
const servicePath = resolve(projectRoot, service.path);

Expand All @@ -39,6 +39,33 @@ export async function buildService(service: ServiceConfig, projectRoot: string):
return combinedOutput;
}

export async function buildService(service: ServiceConfig, projectRoot: string): Promise<string> {
const timestamp = new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-");
const logsDir = join(projectRoot, "logs");
await mkdir(logsDir, { recursive: true });
const logFileName = `jammin-build-${service.name}-${timestamp}.log`;
const logFilePath = join(logsDir, logFileName);

const servicePath = resolve(projectRoot, service.path);
const output = await callDockerBuild(service, projectRoot);

const files = await getJamFiles(servicePath);

const file = files.length > 0 ? files[0] : undefined;
let distPath: string | undefined;
if (file) {
distPath = await copyJamToDist(file, service.name, projectRoot);
} else {
throw new Error(`Failed to find generated file for: '${service.name}'`);
}

if (output) {
await Bun.write(logFilePath, output);
}

return relative(projectRoot, distPath);
}

/**
* Build command definition
*/
Expand All @@ -65,60 +92,17 @@ Examples:
s.stop("✅ Configuration loaded");

const projectRoot = process.cwd();
const logsDir = join(projectRoot, "logs");
await mkdir(logsDir, { recursive: true });

let buildFailed = false;
const buildFailed = false;

for (const service of services) {
p.log.info("--------------------------------");

s.start(`Building service '${service.name}'...`);
const timestamp = new Date().toISOString().replaceAll(":", "-").replaceAll(".", "-");
const logFileName = `jammin-build-${service.name}-${timestamp}.log`;
const logFilePath = join(logsDir, logFileName);

const servicePath = resolve(projectRoot, service.path);
const filesBefore = await getJamFiles(servicePath);

let output: string | undefined;

// Build service and save log
try {
output = await buildService(service, projectRoot);
s.stop(`✅ Service '${service.name}' built successfully`);
} catch (error) {
buildFailed = true;
let errorMessage: string;
if (error instanceof DockerError) {
output = error.output;
errorMessage = error.message;
} else {
const errorObj = error instanceof Error ? error : new Error(String(error));
errorMessage = errorObj.message;
}
s.stop(`❌ Failed to build service '${service.name}': ${errorMessage}`);
}

// Find new or updated .jam files
const filesAfter = await getJamFiles(servicePath);
const newFiles: string[] = [];

for (const [file, mtimeAfter] of filesAfter) {
const mtimeBefore = filesBefore.get(file);
if (mtimeBefore === undefined || mtimeAfter > mtimeBefore) {
newFiles.push(file);
}
}

if (newFiles.length > 0) {
const filesList = newFiles.map((f) => ` - ${relative(projectRoot, f)}`).join("\n");
p.log.info(`🎁 Output files for '${service.name}':\n${filesList}`);
}

if (output) {
await Bun.write(logFilePath, output);
p.log.info(`📝 Log saved: ${relative(projectRoot, logFilePath)}`);
}
const createdFile = await buildService(service, projectRoot);
s.stop(`✅ Service '${service.name}' built successfully`);

p.log.message(`🎁 Output file: ${createdFile}`);
}

p.log.info("--------------------------------");
Expand Down
70 changes: 5 additions & 65 deletions bin/cli/src/commands/deploy-command.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,5 @@
import * as p from "@clack/prompts";
import {
generateGenesis,
generateServiceOutput,
getJamFiles,
getServiceConfigs,
loadBuildConfig,
type ServiceBuildOutput,
saveStateFile,
} from "@fluffylabs/jammin-sdk";
import { generateGenesis, getServiceConfigs, loadServices, saveStateFile } from "@fluffylabs/jammin-sdk";
import { Command } from "commander";
import { buildService } from "./build-command";

Expand Down Expand Up @@ -46,66 +38,14 @@ Examples:
s.stop("✅ Building was successful!");

s.start("Generating Genesis State...");

const serviceJamFiles = new Map<string, string>();
for (const service of services) {
// NOTE: Taking only first jam blob (closest to service path directory)
// since each service should produce one blob
// if they produce more its probably for testing purposes
const jamFiles = await getJamFiles(service.path);
const jamFile = jamFiles.keys().next().value;
if (!jamFile) {
throw new DeployError(`Cannot find a jam file for ${service.name} service`);
}
serviceJamFiles.set(service.name, jamFile);
}

if (serviceJamFiles.size === 0) {
throw new DeployError("No JAM files found");
}

// Get deployment config for services
const buildConfig = await loadBuildConfig();
const serviceDeploymentConfigs = buildConfig.deployment?.services ?? {};

// generate ids avoiding collisions
const usedIds = new Set<number>();
for (const service of services) {
const deploymentConfig = serviceDeploymentConfigs[service.name];
if (deploymentConfig?.id !== undefined) {
usedIds.add(deploymentConfig.id);
}
}
let nextAvailableId = 0;
const getNextAvailableId = () => {
while (usedIds.has(nextAvailableId)) {
nextAvailableId++;
}
return nextAvailableId++;
};

const buildOutputs: ServiceBuildOutput[] = await Promise.all(
services.map(async (service) => {
const jamFile = serviceJamFiles.get(service.name);
if (!jamFile) {
throw new DeployError(`Jam file not found for service ${service.name}`);
}

const deploymentConfig = serviceDeploymentConfigs[service.name];
const serviceId = deploymentConfig?.id ?? getNextAvailableId();

return generateServiceOutput(jamFile, serviceId, deploymentConfig?.storage, deploymentConfig?.info);
}),
);

const genesisOutput = `${projectRoot}/genesis.json`;
await saveStateFile(generateGenesis(buildOutputs), genesisOutput);

const buildOutputs = await loadServices(projectRoot);
const genesisOutput = "dist/genesis.json";
await saveStateFile(generateGenesis(buildOutputs), `${projectRoot}/${genesisOutput}`);
s.stop("✅ Genesis state generated!");

p.log.info(`Found ${buildOutputs.length} service(s)`);

p.note(`🎁 Genesis file: ${genesisOutput}`);
p.log.message(`🎁 Generated file: ${genesisOutput}`);

p.outro("✅ Deployment was successful!");
});
30 changes: 19 additions & 11 deletions packages/jammin-sdk/utils/file-utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { readdir, stat } from "node:fs/promises";
import { copyFile, mkdir, readdir, stat } from "node:fs/promises";
import { homedir } from "node:os";
import { join, resolve } from "node:path";

Expand Down Expand Up @@ -51,23 +51,18 @@ export async function findConfigFile(fileName: string, startDir: string = proces
}

/**
* Recursively get all jam files in a directory with their modification times
* Recursively get all jam files in a directory
*/
export async function getJamFiles(dirPath: string): Promise<Map<string, number>> {
const files = new Map<string, number>();
export async function getJamFiles(dirPath: string): Promise<string[]> {
const files: string[] = [];

try {
const entries = await readdir(dirPath, { withFileTypes: true, recursive: true });

for (const entry of entries) {
if (entry.name.endsWith(".jam")) {
try {
const fullPath = resolve(entry.parentPath, entry.name);
const stats = await stat(fullPath);
files.set(fullPath, stats.mtimeMs);
} catch {
// Ignore stat errors
}
const fullPath = resolve(entry.parentPath, entry.name);
files.push(fullPath);
}
}
} catch {
Expand Down Expand Up @@ -103,3 +98,16 @@ export async function updatePackageJson(

await Bun.write(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
}

/**
* Copy a .jam file to the dist/ directory with a service-specific name
*/
export async function copyJamToDist(jamFilePath: string, serviceName: string, projectRoot: string): Promise<string> {
const distDir = join(projectRoot, "dist");
await mkdir(distDir, { recursive: true });

const destPath = join(distDir, `${serviceName}.jam`);
await copyFile(jamFilePath, destPath);

return destPath;
}
42 changes: 40 additions & 2 deletions packages/jammin-sdk/utils/generate-service-output.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,52 @@
import { resolve } from "node:path";
import { join, resolve } from "node:path";
import { type ServiceId as ServiceIdType, tryAsServiceGas, tryAsServiceId, tryAsTimeSlot } from "@typeberry/lib/block";
import { BytesBlob } from "@typeberry/lib/bytes";
import { tryAsU32, tryAsU64 } from "@typeberry/lib/numbers";
import type { ServiceAccountInfo } from "@typeberry/lib/state";
import { loadBuildConfig } from "../config/config-loader.js";
import { ServiceId } from "../types.js";

export interface ServiceBuildOutput {
id: ServiceIdType;
code: BytesBlob;
storage?: Record<string, string>;
info?: Partial<import("@typeberry/lib/state").ServiceAccountInfo>;
info?: Partial<ServiceAccountInfo>;
}

/**
* Load services from the dist/ directory
*/
export async function loadServices(projectRoot: string = process.cwd()): Promise<ServiceBuildOutput[]> {
const outputs: ServiceBuildOutput[] = [];
const config = await loadBuildConfig();
const serviceDeployConfigs = config.deployment?.services ?? {};
const usedIds = new Set<number>();

// Prepare ServiceIds
for (const service of config.services) {
const deployInfo = serviceDeployConfigs[service.name];
if (deployInfo?.id !== undefined) {
usedIds.add(deployInfo.id);
}
}

let nextId = 0;
const getNextAvailableId = () => {
while (usedIds.has(nextId)) {
nextId++;
}
return nextId++;
};

// Generate service build outputs
for (const service of config.services) {
const jamFilePath = join(projectRoot, "dist", `${service.name}.jam`);
const deployConfig = serviceDeployConfigs[service.name];
const serviceId = deployConfig?.id ?? getNextAvailableId();
outputs.push(await generateServiceOutput(jamFilePath, serviceId, deployConfig?.storage, deployConfig?.info));
}

return outputs;
}

export async function generateServiceOutput(
Expand Down
Loading