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
5 changes: 5 additions & 0 deletions .changeset/cute-queens-hunt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@perfect-abstractions/compose-cli": patch
---

add project build + auto detect project framework
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,5 @@ docgen-summary.json
.agents
.cursor
.opencode

soljson-latest.js
2 changes: 2 additions & 0 deletions cli/.npmignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ test/
tsconfig.json
eslint.config.js
*.log

soljson-latest.json
2 changes: 1 addition & 1 deletion cli/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ const tseslint = require("typescript-eslint");

module.exports = [
{
ignores: ["node_modules/**", "dist/**", "src/templates/**"],
ignores: ["node_modules/**", "dist/**", "src/templates/**", "soljson-latest.js"],
},
js.configs.recommended,
{
Expand Down
8 changes: 8 additions & 0 deletions cli/src/adapters/foundryAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ const adapter: IFrameworkAdapter = {
return path.join(projectRoot, "test");
},

getArtifactDir(projectRoot: string): string {
return path.join(projectRoot, "out");
},

async compile(projectRoot: string): Promise<void> {
await runCommand("forge", ["build"], { cwd: projectRoot });
},

async resolveSoliditySourcePath(ctx: ComposeContext, sourcePath: string): Promise<string> {
return resolveCatalogSourceForRead(sourcePath);
},
Expand Down
8 changes: 8 additions & 0 deletions cli/src/adapters/hardhatAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ const adapter: IFrameworkAdapter = {
return path.join(projectRoot, "test");
},

getArtifactDir(projectRoot: string): string {
return path.join(projectRoot, "artifacts");
},

async compile(projectRoot: string): Promise<void> {
await runCommand("npx", ["hardhat", "compile"], { cwd: projectRoot });
},

async resolveSoliditySourcePath(ctx: ComposeContext, sourcePath: string): Promise<string> {
return resolveCatalogSourceForRead(sourcePath);
},
Expand Down
6 changes: 6 additions & 0 deletions cli/src/adapters/interface/IFrameworkAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ export interface IFrameworkAdapter {
/** Resolve the framework's test root inside the generated project. */
getTestRoot(projectRoot: string): string;

/** Resolve the framework's artifact output directory inside the generated project. */
getArtifactDir(projectRoot: string): string;

/** Compile the project using the framework's build tool. */
compile(projectRoot: string): Promise<void>;

/**
* Resolve a catalog Solidity path to a readable local file.
*
Expand Down
4 changes: 4 additions & 0 deletions cli/src/comander.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ export function buildProgram(): Command {
.command("catalog")
.description("List all available bases in the Compose Catalog")

program
.command("build")
.description("Compile the project if artifacts are stale or missing")

return program;
}

Expand Down
37 changes: 37 additions & 0 deletions cli/src/modules/compile/module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import fs from "node:fs";
import { IFrameworkAdapter } from "../../adapters/interface/IFrameworkAdapter";
import { getNewestSourceMtime, getOldestArtifactMtime } from "./utils";

export const CompileModule = {
areArtifactsStale(projectRoot: string, adapter: IFrameworkAdapter): boolean {
const artifactDir = adapter.getArtifactDir(projectRoot);

if (!fs.existsSync(artifactDir)) {
return true;
}

const sourceDir = adapter.getContractSourceRoot(projectRoot);
const newestSource = getNewestSourceMtime(sourceDir);

if (newestSource === null) {
return false;
}

const oldestArtifact = getOldestArtifactMtime(artifactDir);

if (oldestArtifact === null) {
return true;
}

return newestSource > oldestArtifact;
},

async compileIfNeeded(projectRoot: string, adapter: IFrameworkAdapter): Promise<void> {
if (!this.areArtifactsStale(projectRoot, adapter)) {
console.log("No file change")
return;
}

await adapter.compile(projectRoot);
}
}
46 changes: 46 additions & 0 deletions cli/src/modules/compile/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import fs from "node:fs";
import path from "node:path";

export function getNewestSourceMtime(sourceDir: string): number | null {
if (!fs.existsSync(sourceDir)) return null;

let newest = 0;
const entries = fs.readdirSync(sourceDir, { recursive: true });

for (const entry of entries) {
const fullPath = path.join(sourceDir, String(entry));
if (!fullPath.endsWith(".sol")) continue;

try {
const stat = fs.statSync(fullPath);
if (stat.isFile() && stat.mtimeMs > newest) {
newest = stat.mtimeMs;
}
} catch {
// skip inaccessible files
}
}

return newest > 0 ? newest : null;
}

export function getOldestArtifactMtime(artifactDir: string): number | null {
if (!fs.existsSync(artifactDir)) return null;

let oldest = Infinity;
const entries = fs.readdirSync(artifactDir, { recursive: true });

for (const entry of entries) {
const fullPath = path.join(artifactDir, String(entry));
try {
const stat = fs.statSync(fullPath);
if (stat.isFile() && stat.mtimeMs < oldest) {
oldest = stat.mtimeMs;
}
} catch {
// skip inaccessible files
}
}

return oldest < Infinity ? oldest : null;
}
25 changes: 25 additions & 0 deletions cli/src/modules/framework/module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import fs from "node:fs";
import path from "node:path";

export type Framework = "foundry" | "hardhat";

const HARDHAT_CONFIG_FILES = [
"hardhat.config.js",
"hardhat.config.ts",
"hardhat.config.cjs",
"hardhat.config.mjs",
];

export const FrameworkModule = {
detect(projectRoot: string): Framework | null {
if (fs.existsSync(path.join(projectRoot, "foundry.toml"))) {
return "foundry";
}

if (HARDHAT_CONFIG_FILES.some((f) => fs.existsSync(path.join(projectRoot, f)))) {
return "hardhat";
}

return null;
}
}
8 changes: 8 additions & 0 deletions cli/src/modules/pipelineBuilder/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ComposeContext } from "../../context/types";
import { InitPipeline } from "../../pipelines/initPipeline";
import { InfoPipeline } from "../../pipelines/infoPipeline";
import { CatalogPipeline } from "../../pipelines/catalogPipeline";
import { BuildPipeline } from "../../pipelines/buildPipeline";

/**
* Pipeline builder module that routes CLI commands to their corresponding pipelines.
Expand Down Expand Up @@ -51,6 +52,13 @@ export const PipelineBuilderModule = {
error: null,
};
return CatalogPipeline.execute(ctx);
case "build":
ctx.state.commandSelected = {
success: true,
result: { command: ctx.param.command },
error: null,
};
return BuildPipeline.execute(ctx);
default:
ctx.state.commandRouting = {
success: false,
Expand Down
34 changes: 34 additions & 0 deletions cli/src/pipelines/buildPipeline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { ComposeContext } from "../context/types";
import { CompileModule } from "../modules/compile/module";
import { FrameworkModule } from "../modules/framework/module";
import { DependencyKey } from "../resolver/dependencyKey";
import { DependencyResolver } from "../resolver/dependencyResolver";
import { IFrameworkAdapter } from "../adapters/interface/IFrameworkAdapter";

export const BuildPipeline = {
async execute(ctx: ComposeContext): Promise<ComposeContext> {
const projectRoot = String(ctx.param.projectRoot ?? process.cwd());

const framework = FrameworkModule.detect(projectRoot);

if (!framework) {
throw new Error(
`No supported framework detected in ${projectRoot}. ` +
`Expected foundry.toml or hardhat.config.*`,
);
}

const deps = await DependencyResolver.resolve([
{ key: framework as DependencyKey },
]);

const adapter = deps[framework as DependencyKey] as IFrameworkAdapter | undefined;
if (!adapter) {
throw new Error(`${framework} adapter was not resolved.`);
}

await CompileModule.compileIfNeeded(projectRoot, adapter);

return ctx;
},
};
33 changes: 33 additions & 0 deletions cli/src/utils/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,39 @@ export function runCommand(command: string, args: string[], options?: { cwd?: st
* @param binaryName - Name of the binary to find (e.g., "node", "forge").
* @throws {Error} If the binary is not found in PATH.
*/
export function runCommandCapture(
command: string,
args: string[],
options?: { cwd?: string },
): Promise<void> {
return new Promise((resolve, reject) => {
const resolvedCommand = resolveCommand(command);
const useShell = process.platform === "win32" && (command === "npm" || command === "npx");
const child = spawn(resolvedCommand, args, {
stdio: ["inherit", "pipe", "pipe"],
shell: useShell,
...options,
});

let stderr = "";
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});

child.on("error", reject);
child.on("exit", (code) => {
if (code === 0) {
resolve();
return;
}
if (stderr) {
process.stderr.write(stderr);
}
reject(new Error(`Compilation failed: ${command} ${args.join(" ")} (exit ${code})`));
});
});
}

export async function isBinaryInPath(binaryName: string): Promise<void> {
const pathEnv = process.env.PATH || "";
const separator = process.platform === "win32" ? ";" : ":";
Expand Down
Loading
Loading