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
6 changes: 6 additions & 0 deletions .claude/gates.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@
"description": "@redeploy/deploy-server — HTTP server exposing deployment simulation and execution as an API (node:http, no framework). GET /health now; POST /api/simulate (SSE) in a follow-up.",
"owner": ""
},
{
"name": "cli",
"path": "apps/cli",
"description": "@redeploy/cli — thin command-line entry point wrapping core (deploy/simulate), config (apply-config), verify, and reader (status/snapshot). Human-readable + --json output; env contract mirrors deploy-server; never echoes secrets.",
"owner": ""
},
{
"name": "website",
"path": "apps/website",
Expand Down
30 changes: 30 additions & 0 deletions apps/cli/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "@redeploy/cli",
"version": "0.0.0",
"private": true,
"type": "module",
"description": "Thin command-line entry point for reDeploy: deploy/simulate (core), apply-config (config), verify (verify), status/snapshot (reader). Wraps existing library APIs — no reimplemented business logic.",
"bin": {
"redeploy": "dist/index.js"
},
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"lint": "eslint . --max-warnings=0",
"test": "vitest run",
"coverage": "vitest run --coverage"
},
"dependencies": {
"@redeploy/core": "workspace:*",
"@redeploy/config": "workspace:*",
"@redeploy/verify": "workspace:*",
"@redeploy/reader": "workspace:*",
"viem": "^2.54.1"
},
"devDependencies": {
"@types/node": "^22.7.5",
"typescript": "^5.7.2",
"vitest": "^2.1.8",
"@vitest/coverage-v8": "^2.1.8"
}
}
105 changes: 105 additions & 0 deletions apps/cli/src/args.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/**
* Dependency-free CLI argument parsing for @redeploy/cli.
*
* Built entirely on node:util's `parseArgs` (stdlib) — no CLI framework, per
* the "genuinely thin CLI" requirement.
*/

import { parseArgs as nodeParseArgs, type ParseArgsConfig } from "node:util";

/** Thrown for any bad-input condition: unknown flag, missing required flag, unknown command. */
export class CliUsageError extends Error {
constructor(
message: string,
readonly usage?: string,
) {
super(message);
this.name = "CliUsageError";
}
}

/** A single option's parseArgs-compatible spec, restricted to what this CLI needs. */
export interface OptionSpec {
readonly type: "string" | "boolean";
readonly short?: string;
readonly default?: string | boolean;
}

export type OptionsSchema = Record<string, OptionSpec>;

/** Options every subcommand accepts in addition to its own schema. */
export const COMMON_OPTIONS: OptionsSchema = {
json: { type: "boolean", default: false },
help: { type: "boolean", short: "h", default: false },
};

export interface ParsedArgs {
readonly values: Record<string, string | boolean | undefined>;
readonly positionals: string[];
}

/**
* Parse `args` against `schema` (merged with COMMON_OPTIONS).
*
* @throws CliUsageError on an unknown flag or a flag used with the wrong
* arity (e.g. a string flag with no value). `strict: true` (parseArgs'
* default) is what makes it throw for unknown flags.
*/
export function parseCommandArgs(args: string[], schema: OptionsSchema): ParsedArgs {
const mergedSchema = { ...COMMON_OPTIONS, ...schema };
try {
const { values, positionals } = nodeParseArgs({
args,
// node:util's parseArgs option type is broader than our OptionsSchema;
// the runtime shape matches exactly, so this cast is safe.
options: mergedSchema as NonNullable<ParseArgsConfig["options"]>,
allowPositionals: true,
strict: true,
});
return { values: values as Record<string, string | boolean | undefined>, positionals };
} catch (err) {
throw new CliUsageError(err instanceof Error ? err.message : String(err));
}
}

/** Read a required string flag; throws CliUsageError with a helpful message if absent/empty. */
export function requireString(
values: Record<string, string | boolean | undefined>,
key: string,
commandName: string,
): string {
const raw = values[key];
if (typeof raw !== "string" || raw.trim() === "") {
throw new CliUsageError(`"redeploy ${commandName}" requires --${key} <value>`);
}
return raw;
}

/** Read an optional string flag. */
export function optionalString(
values: Record<string, string | boolean | undefined>,
key: string,
): string | undefined {
const raw = values[key];
return typeof raw === "string" ? raw : undefined;
}

/** Read an optional integer flag (base-10). Throws CliUsageError if present but not a valid integer. */
export function optionalInt(
values: Record<string, string | boolean | undefined>,
key: string,
commandName: string,
): number | undefined {
const raw = optionalString(values, key);
if (raw === undefined) return undefined;
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || String(parsed) !== raw.trim()) {
throw new CliUsageError(`"redeploy ${commandName}" --${key} must be an integer, got "${raw}"`);
}
return parsed;
}

/** Read a boolean flag (defaults false). */
export function flag(values: Record<string, string | boolean | undefined>, key: string): boolean {
return values[key] === true;
}
218 changes: 218 additions & 0 deletions apps/cli/src/chain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
/**
* Glue between reDeploy's chain-agnostic ChainReader / ConfigExecutor
* interfaces (from @redeploy/verify and @redeploy/config) and an actual
* chain, for the `apply-config` and `verify --target config` subcommands.
*
* DESIGN
* ======
*
* Both `ConfigExecutor` (config's write path) and `ChainReader` (verify's
* read path) are injectable-by-design in their respective libraries — they
* intentionally do NOT ship a chain implementation, so callers can plug in
* ethers/viem/a mock. This module is that plug for the CLI:
*
* - ABI lookup: @redeploy/core's `foundryArtifactResolver` already loads a
* contract's ABI by name from Foundry's `out/` — reused as-is.
* - Address -> contract name: read from the `DeploymentView` the `status`/
* `snapshot` commands already use (via `readDeployment()`), NOT
* reinvented here.
* - Encoding/decoding calls and reading state: viem (already a direct
* dependency of @redeploy/core, used the same way inside
* `core/src/provider/jsonRpc.ts`).
* - Sending + confirming write transactions: @redeploy/core's
* `jsonRpcProvider()` (the same signer used by `deploy()`), so key
* handling/signing is not reimplemented here either.
*
* No business logic (address resolution, drift comparison, journal
* idempotency) is reimplemented — this file only adapts existing library
* outputs to the shape the injectable interfaces require.
*/

import { createPublicClient, http, encodeFunctionData, type Abi } from "viem";
import type { ChainReader } from "@redeploy/verify";
import type { ConfigExecutor, ConfigCall } from "@redeploy/config";
import type { ArtifactResolverLike, Eip1193ProviderLike } from "./deps.js";

/** Lowercased deployed address -> Solidity contract (artifact) name. */
export type AddressBook = Readonly<Record<string, string>>;

/**
* Build an AddressBook from a DeploymentView's contracts (id/contractName/address).
* Contracts with a null address (never completed) are skipped.
*/
export function buildAddressBook(
contracts: ReadonlyArray<{ readonly address: string | null; readonly contractName: string }>,
): AddressBook {
const book: Record<string, string> = {};
for (const c of contracts) {
if (c.address !== null) {
book[c.address.toLowerCase()] = c.contractName;
}
}
return book;
}

async function loadAbi(resolver: ArtifactResolverLike, contractName: string): Promise<Abi> {
const artifact = await resolver.loadArtifact(contractName);
return artifact.abi as Abi;
}

function lookupContractName(addressBook: AddressBook, address: string, context: string): string {
const contractName = addressBook[address.toLowerCase()];
if (contractName === undefined) {
throw new Error(
`No known contract at address ${address} (${context}) — is DEPLOYMENT_DIR pointing at the deployment that produced this address?`,
);
}
return contractName;
}

// ---------------------------------------------------------------------------
// ChainReader (read path — used by `verify --target config`)
// ---------------------------------------------------------------------------

/** Injectable read function so tests never make a real network call. */
export type ReadContractFn = (args: {
readonly rpcUrl: string;
readonly address: string;
readonly abi: Abi;
readonly functionName: string;
readonly args: unknown[];
}) => Promise<unknown>;

const defaultReadContract: ReadContractFn = async ({ rpcUrl, address, abi, functionName, args }) => {
const client = createPublicClient({ transport: http(rpcUrl) });
return client.readContract({
address: address as `0x${string}`,
abi,
functionName,
args,
} as Parameters<typeof client.readContract>[0]);
};

export interface BuildChainReaderOptions {
readonly rpcUrl: string;
readonly artifactResolver: ArtifactResolverLike;
readonly addressBook: AddressBook;
/** Injectable for tests; defaults to a real viem `readContract` call. */
readonly readContract?: ReadContractFn;
}

/** Build a ChainReader (verify's on-chain read interface) backed by viem + Foundry artifacts. */
export function buildChainReader(options: BuildChainReaderOptions): ChainReader {
const readContractFn = options.readContract ?? defaultReadContract;
return {
async call({ address, function: functionName, args }) {
const contractName = lookupContractName(options.addressBook, address, "ChainReader.call");
const abi = await loadAbi(options.artifactResolver, contractName);
return readContractFn({
rpcUrl: options.rpcUrl,
address,
abi,
functionName,
args: [...(args ?? [])],
});
},
};
}

// ---------------------------------------------------------------------------
// ConfigExecutor (write path — used by `apply-config`)
// ---------------------------------------------------------------------------

export interface BuildConfigExecutorOptions {
readonly provider: Eip1193ProviderLike;
readonly artifactResolver: ArtifactResolverLike;
readonly addressBook: AddressBook;
/** Poll interval between receipt checks. @default 1000 */
readonly pollIntervalMs?: number;
/** Max receipt-poll attempts before giving up. @default 30 */
readonly maxPollAttempts?: number;
/** Injectable sleep, so tests run instantly. */
readonly sleep?: (ms: number) => Promise<void>;
}

/**
* Build a ConfigExecutor (config's on-chain write interface) backed by the
* same EIP-1193 signer `deploy()` uses (`jsonRpcProvider()`).
*
* Sends the transaction via `eth_sendTransaction` (signed locally by the
* provider — see core/src/provider/jsonRpc.ts) and polls
* `eth_getTransactionReceipt` until the receipt is available, throwing if
* the receipt reports a revert. applyConfig() only journals a step complete
* if `execute()` resolves without throwing, so this executor deliberately
* waits for on-chain confirmation rather than resolving on broadcast alone.
*
* Unlike Ignition-driven deploys (which supply `gas`/fee fields themselves),
* this hand-rolled executor is the only source of the transaction params, so
* it must fill in a gas limit and a fee itself: `jsonRpcProvider()` (see
* core/src/provider/jsonRpc.ts) takes the LEGACY signing branch whenever
* `maxFeePerGas` is absent, and that branch reads `gas`/`gasPrice` verbatim
* from the params — so both are estimated/queried here via `eth_estimateGas`
* and `eth_gasPrice` before broadcasting.
*/
export function buildConfigExecutor(options: BuildConfigExecutorOptions): ConfigExecutor {
const pollIntervalMs = options.pollIntervalMs ?? 1000;
const maxPollAttempts = options.maxPollAttempts ?? 30;
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));

return {
async execute(call: ConfigCall): Promise<void> {
const contractName = lookupContractName(options.addressBook, call.target, `step "${call.stepId}"`);
const abi = await loadAbi(options.artifactResolver, contractName);

const data = encodeFunctionData({
abi,
functionName: call.function,
args: call.args,
} as Parameters<typeof encodeFunctionData>[0]);

const accounts = (await options.provider.request({ method: "eth_accounts" })) as string[];
const from = accounts[0];
if (from === undefined) {
throw new Error(
`No deployer account available for step "${call.stepId}" — check DEPLOYER_PRIVATE_KEY`,
);
}

const callTx = { from, to: call.target, data };

// Estimate a gas limit and query a legacy gas price so the raw signed
// tx (see core/src/provider/jsonRpc.ts) never serializes gas=0 /
// gasPrice=0, which real nodes reject.
const gas = (await options.provider.request({
method: "eth_estimateGas",
params: [callTx],
})) as string;
const gasPrice = (await options.provider.request({
method: "eth_gasPrice",
params: [],
})) as string;

const txHash = (await options.provider.request({
method: "eth_sendTransaction",
params: [{ ...callTx, gas, gasPrice }],
})) as string;

for (let attempt = 0; attempt < maxPollAttempts; attempt++) {
const receipt = (await options.provider.request({
method: "eth_getTransactionReceipt",
params: [txHash],
})) as { status?: string } | null;

if (receipt !== null && receipt !== undefined) {
if (receipt.status === "0x0") {
throw new Error(`Step "${call.stepId}" reverted on-chain (tx ${txHash})`);
}
return;
}

await sleep(pollIntervalMs);
}

throw new Error(
`Step "${call.stepId}" transaction ${txHash} did not confirm within ${maxPollAttempts} poll attempts`,
);
},
};
}
Loading
Loading