Skip to content
Draft
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/cd-28-keep-relative-import-paths.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@perfect-abstractions/compose-cli": patch
---

Preserve the relative folder structure of locally imported dependencies when copying catalog facets, so nested imports (for example `./libraries/LibFee.sol`) keep resolving after `compose install` and `compose init`.
5 changes: 5 additions & 0 deletions .changeset/facet-install-remove-commands.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@perfect-abstractions/compose-cli": patch
---

Add `compose install`/`i` and `compose remove`/`rm` commands for managing facet registrations in `compose.json`, including versioned batch manifests.
5 changes: 5 additions & 0 deletions .changeset/fuzzy-local-facet-reference.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@perfect-abstractions/compose-cli": patch
---

Resolve bare facet names in `compose install` by fuzzy-matching the project's Solidity sources, so a local `path.sol:ContractName` reference is only needed when several sources match; interactive runs pick the source from a list.
5 changes: 5 additions & 0 deletions .changeset/remove-path-reference.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@perfect-abstractions/compose-cli": patch
---

Accept `path.sol:ContractName` references in `compose remove`/`rm` and remove manifests, so they resolve to the registered facet name the way `compose install` does instead of being skipped as unregistered.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ npx @perfect-abstractions/compose-cli init
### Foundry

```bash
forge install Perfect-Abstractions/Compose@tag=compose@0.0.6
forge install Perfect-Abstractions/Compose@tag=compose@0.0.7
```

### Hardhat / NPM
Expand Down
158 changes: 135 additions & 23 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ npx @perfect-abstractions/compose-cli init
```

This starts an interactive flow to scaffold a new diamond project with:
- Base preset selection (Counter, ERC-20, ERC-721, ERC-1155, ERC-6909)
- Base preset selection (Counter, ERC-20, ERC-721, ERC-721 Enumerable, ERC-1155, ERC-6909)
- Extension facet selection
- Ownership and access control configuration
- Compose library facet selection
Expand All @@ -18,32 +18,41 @@ This starts an interactive flow to scaffold a new diamond project with:
## Usage

```bash
compose init [options]
compose init [options] [project-name]
compose install [facet-ref...] [options]
compose remove [facet-ref...] [options]
compose catalog
compose info
compose validate
compose --version | -v
compose validate [options]
compose build
compose rpc [options]
compose inspect <address> [options]
compose help [command]
compose --version | -V
compose --help | -h
```
```

### `compose init`

Scaffold a new Compose diamond project.

**Options:**
- `--name <project-name>`: directory name for the new project
- `--framework <foundry|hardhat>`: target framework
- `[project-name]`: directory name for the new project
- `--framework <foundry|hardhat>`: target framework (default: foundry)
- `--toolbox <ethers|viem>`: Hardhat toolbox (default: ethers)
- `--base <base-id>`: base preset (counter, erc-20, erc-721, erc-1155, erc-6909)
- `--ownership <none|owner|owner-two-step>`: ownership model
- `--access-control <facets...>`: access control facets to include
- `--libraries <facets...>`: Compose library facets to include
- `--extensions <facets...>`: extension facets to include
- `--examples`: include local example facets
- `--yes`: non-interactive mode with sensible defaults
- `--no-install-deps`: skip dependency installation

When `--yes` is not provided, `compose init` will prompt for any values you omit.
- `--base <base-id>`: base preset — `counter`, `erc-20`, `erc-721`, `erc-721-enumerable`, `erc-1155`, `erc-6909`, or `none` for a blank project
- `--ownership <base>`: ownership base — `owner` or `owner-two-step`; omit the flag for no ownership
- `--ownership-extensions <facets>`: comma-separated ownership extension facets
- `--access-control <bases>`: comma-separated access control bases (for example `access-control`)
- `--access-control-extensions <facets>`: comma-separated access control extension facets
- `--libraries <facets>`: comma-separated Compose library facets
- `--extensions <facets>`: comma-separated extension facets
- `--out <dir>`: parent directory for the project (defaults to the current directory)
- `--yes`: non-interactive mode; requires `--base`
- `--install-deps` / `--no-install-deps`: install or skip framework dependencies

Selection flags apply to `--yes` runs. Without `--yes`, only `[project-name]` and `--out` are honored and `compose init` prompts for the framework, toolbox, base, extensions, libraries, ownership, access control, and dependency installation.

### `compose catalog`

Expand All @@ -57,27 +66,130 @@ Display a summary of the local project:
- Storage slot annotations
- Validation warnings

### `compose validate` (Coming Soon...)
### `compose install` / `compose i`

Register facets in the desired composition for a diamond in `compose.json`.
This edits project configuration only; it does not deploy facets or change a deployed diamond.

```bash
# Register Compose catalog facets
compose install ERC20BurnFacet DiamondUpgradeFacet --diamond my-token

# Register a local facet (the source file must already be in the project)
compose i src/facets/FeeFacet.sol:FeeFacet --diamond my-token

# Register a local facet by contract name; the source file is located by fuzzy match
compose i FeeFacet --diamond my-token

# Create a reviewable batch file without applying it
compose install --create-manifest add-fees --diamond my-token

# Apply a batch file from compose/facet-batches/add-fees.json
compose install --manifest add-fees
```

**Options:**
- `--diamond <name>`: target diamond; inferred when the project defines one diamond, otherwise prompted interactively or required in non-interactive use
- `--project-root <dir>`: start searching for the project from this directory
- `--manifest <path-or-name>`: apply an install manifest; a simple name resolves under `compose/facet-batches/`
- `--create-manifest <name>`: create a manifest in `compose/facet-batches/` without changing `compose.json`
- `--yes`: skip interactive confirmation prompts

Catalog facets backed by local Compose templates are copied into the framework's Solidity source tree. Local `path.sol:ContractName` references are registered in place. A bare name that is not in the catalog is fuzzy-matched against the project's Solidity sources (file name first, then declared contract names, then substring matches); several matches prompt interactively outside of `--yes`/non-interactive use. Package facets require their package source to be available in the project.

### `compose remove` / `compose rm`

Unregister facets from the desired composition. This does not delete Solidity files or package dependencies, and it does not modify an already deployed diamond.
The CLI keeps at least one facet registered per diamond so the project remains validatable.

```bash
compose remove FeeFacet --diamond my-token
compose rm --manifest remove-legacy
compose remove --create-manifest remove-legacy --diamond my-token
```

The options are the same as `compose install`. Facet arguments take the same forms as install — a catalog or project contract name, or a `path.sol:ContractName` reference — and both resolve to the registered facet name. In an interactive terminal, direct removals ask for confirmation. `--manifest` accepts a file path or a name under `compose/facet-batches/`.

### Facet batch manifests

Manifests are versioned JSON files and can be reviewed or edited before applying. Install manifests accept catalog names or explicit local/package references:

```json
{
"version": 1,
"operation": "install",
"diamonds": {
"my-token": [
{ "name": "ERC20BurnFacet" },
{
"name": "FeeFacet",
"source": "local",
"contract": "src/facets/FeeFacet.sol:FeeFacet"
}
]
}
}
```

Remove manifests list registered facet names:

```json
{
"version": 1,
"operation": "remove",
"diamonds": { "my-token": ["LegacyFacet"] }
}
```

Applying a batch validates the resulting source configuration before writing `compose.json`. It checks selector collisions and virtual storage layout compatibility. Install/remove commands do not update `compose.lock` or refresh the deploy script generated by `init`; deployment tooling will consume the configured facet set in a later workflow.

### `compose validate`

Run static analysis on the local codebase:
- Storage layout validation
- Selector clash detection
- `exportSelectors` consistency checks
- Missing facet registration warnings

**Options:**
- `--project-root <dir>`: start searching for the project from this directory

Exit code non-zero on failure (CI-friendly).

### `compose build`

Compile the project if artifacts are stale or missing.

### `compose rpc`

Check RPC connectivity and optionally inspect contract bytecode.

**Options:**
- `--chain <chain-key>`: chain key from `compose.json` (default: `local`)
- `--address <address>`: contract address to check or inspect

### `compose inspect`

Inspect a deployed diamond's facets and selectors.

**Arguments:**
- `<address>`: diamond contract address to inspect

**Options:**
- `--chain <chain-key>`: chain key from `compose.json` (default: `local`)

## Base Presets

Each base preset provides a starting point for common diamond patterns:

| Base | Description | Required Facets |
|------|-------------|-----------------|
| **Counter** (local) | Simple counter with increment/decrement | CounterDataFacet, CounterIncrementFacet, CounterDecrementFacet |
| **Counter** (local) | Simple counter with increment/decrement | CounterDataFacet, CounterIncrementFacet |
| **ERC-20** (package) | Fungible token standard | ERC20DataFacet, ERC20ApproveFacet, ERC20TransferFacet |
| **ERC-721** (package) | Non-fungible token standard | ERC721DataFacet, ERC721ApproveFacet, ERC721TransferFacet |
| **ERC-721 Enumerable** (package) | ERC-721 with token enumeration | ERC721ApproveFacet, ERC721DataFacet, ERC721EnumerableDataFacet, ERC721EnumerableTransferFacet |
| **ERC-1155** (package) | Multi-token standard | ERC1155DataFacet, ERC1155ApproveFacet, ERC1155TransferFacet |
| **ERC-6909** (package) | Minimal multi-token standard | ERC6909DataFacet, ERC6909TransferFacet |
| **ERC-6909** (package) | Minimal multi-token standard | ERC6909DataFacet, ERC6909TransferFacet, ERC6909ApproveFacet, ERC6909OperatorFacet |

Each base has compatible extension facets that are filtered during interactive selection to prevent cross-standard conflicts.

Expand All @@ -95,20 +207,20 @@ Follow the prompts to select framework, base, extensions, ownership, and access

```bash
# Foundry project with Counter base
compose init --name my-counter --framework foundry --base counter --ownership owner --yes
compose init my-counter --framework foundry --base counter --ownership owner --yes

# Hardhat project with ERC-20 base and extensions
compose init --name my-token \
compose init my-token \
--framework hardhat \
--toolbox ethers \
--base erc-20 \
--ownership owner-two-step \
--extensions ERC20BurnFacet,ERC20MintFacet \
--extensions ERC20BurnFacet,ERC20PermitFacet \
--libraries ERC165Facet \
--yes

# Hardhat project with Viem toolbox
compose init --name my-nft \
compose init my-nft \
--framework hardhat \
--toolbox viem \
--base erc-721 \
Expand Down
41 changes: 33 additions & 8 deletions cli/src/comander.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,28 @@ export function buildProgram(): Command {
.option("--no-install-deps", "Skip dependency installation")
.option("--toolbox <ethers|viem>", "Hardhat toolbox", "ethers");

program
.command("install")
.alias("i")
.description("Register facets in compose.json without deploying them")
.argument("[facets...]", "Catalog facet names, project contract names, or local path:Contract references")
.option("--diamond <name>", "Diamond name from compose.json")
.option("--project-root <dir>", "Compose project directory")
.option("--manifest <path-or-name>", "Apply an install batch manifest")
.option("--create-manifest <name>", "Create an install batch manifest without applying it")
.option("--yes", "Skip confirmation prompts")

program
.command("remove")
.alias("rm")
.description("Unregister facets from compose.json without changing a deployment")
.argument("[facets...]", "Facet names or local path:Contract references to unregister")
.option("--diamond <name>", "Diamond name from compose.json")
.option("--project-root <dir>", "Compose project directory")
.option("--manifest <path-or-name>", "Apply a remove batch manifest")
.option("--create-manifest <name>", "Create a remove batch manifest without applying it")
.option("--yes", "Skip confirmation prompts");

program
.command("validate")
.description("Validate selectors and virtual storage layouts")
Expand Down Expand Up @@ -74,13 +96,17 @@ export function parseArgs(argv: string[]): { command: string; flags: Record<stri
const program = buildProgram();
program.parse(argv);

const command = program.args[0] ?? "";
const commandInstance = program.commands.find((c) => c.name() === command);
const invokedCommand = program.args[0] ?? "";
const commandInstance = program.commands.find(
(candidate) => candidate.name() === invokedCommand || candidate.aliases().includes(invokedCommand),
);

if (!commandInstance) {
return { command, flags: {} };
return { command: invokedCommand, flags: {} };
}

const command = commandInstance.name();

const opts = commandInstance.opts();
const flags: Record<string, unknown> = { ...opts };

Expand All @@ -89,11 +115,10 @@ export function parseArgs(argv: string[]): { command: string; flags: Record<stri
delete flags.out;
}

// Map the first positional argument to projectName if --name wasn't passed
const positionalArgs = commandInstance.args.filter(
(arg): arg is string => typeof arg === "string" && arg !== command,
);
if (positionalArgs.length > 0) {
const positionalArgs = commandInstance.args.filter((arg): arg is string => typeof arg === "string");
if (command === "install" || command === "remove") {
if (positionalArgs.length > 0) flags.facets = positionalArgs;
} else if (positionalArgs.length > 0) {
if (command === "inspect" && !flags.address) {
flags.address = positionalArgs[0];
} else if (!flags.projectName) {
Expand Down
34 changes: 4 additions & 30 deletions cli/src/modules/deployGeneration/renderers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { DeployFacetEntry, DeployGenerationModel } from "./types";
import { toCamelCase } from "../../utils/strings";
import { loadTemplate } from "../../utils/codegen";
import { renderFacetSections } from "../facetSections";

/**
* Renders a Foundry (Solidity) deploy script that deploys a Diamond proxy
Expand All @@ -20,8 +21,6 @@ export async function renderFoundryDeployScript(model: DeployGenerationModel): P
return `import {${facet.contractName}} from "${importPath}";`;
})
.join("\n");
const baseFacets = model.facets.filter((facet) => facet.group === "base");
const libraryFacets = model.facets.filter((facet) => facet.group === "library");
let index = 0;

const renderDeployLine = (facet: DeployFacetEntry): string => {
Expand All @@ -31,8 +30,7 @@ export async function renderFoundryDeployScript(model: DeployGenerationModel): P
return line;
};

const baseLines = baseFacets.map(renderDeployLine).join("\n");
const libraryLines = libraryFacets.map(renderDeployLine).join("\n");
const { baseLines, libraryLines } = renderFacetSections(model.facets, renderDeployLine, "\n");

return template
.replace("{{IMPORTS}}", imports)
Expand Down Expand Up @@ -68,30 +66,6 @@ function renderHardhatViemFacetDeploy(facet: DeployFacetEntry): string {
facets.push(${variableName}.address);`;
}

/**
* Renders base and library facet deploy sections for a Hardhat deploy script
* using the provided per-facet renderer.
*
* @param model - The deploy generation model with facet entries.
* @param renderFacetDeploy - Callback to render a single facet's deploy code.
* @returns An object with `baseLines` and `libraryLines` strings.
*/
function renderHardhatDeploySections(
model: DeployGenerationModel,
renderFacetDeploy: (facet: DeployFacetEntry) => string,
): { baseLines: string; libraryLines: string } {
const baseLines = model.facets
.filter((facet) => facet.group === "base")
.map(renderFacetDeploy)
.join("\n\n");
const libraryLines = model.facets
.filter((facet) => facet.group === "library")
.map(renderFacetDeploy)
.join("\n\n");

return { baseLines, libraryLines };
}

/**
* Renders a complete Hardhat + ethers.js deploy script that deploys a Diamond proxy.
*
Expand All @@ -101,7 +75,7 @@ function renderHardhatDeploySections(
export async function renderHardhatEthersDeployScript(model: DeployGenerationModel): Promise<string> {
const [template, { baseLines, libraryLines }] = await Promise.all([
loadTemplate("deploy", "hardhat-ethers-deploy.ts"),
renderHardhatDeploySections(model, renderHardhatEthersFacetDeploy),
renderFacetSections(model.facets, renderHardhatEthersFacetDeploy, "\n\n"),
]);

return template
Expand All @@ -118,7 +92,7 @@ export async function renderHardhatEthersDeployScript(model: DeployGenerationMod
export async function renderHardhatViemDeployScript(model: DeployGenerationModel): Promise<string> {
const [template, { baseLines, libraryLines }] = await Promise.all([
loadTemplate("deploy", "hardhat-viem-deploy.ts"),
renderHardhatDeploySections(model, renderHardhatViemFacetDeploy),
renderFacetSections(model.facets, renderHardhatViemFacetDeploy, "\n\n"),
]);

return template
Expand Down
Loading
Loading