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
5 changes: 5 additions & 0 deletions .changeset/add-sync-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@satoshai/abi-cli": minor
---

Add `sync` command for config-driven multi-contract ABI syncing. Supports `abi.config.json` and `abi.config.ts` config files with per-contract network overrides, partial failure handling, and a summary report. Exports `loadConfig`, `validateConfig`, `AbiConfig`, and `ContractEntry` from the programmatic API.
84 changes: 84 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,69 @@ abi-cli fetch SP102V8P0F7JX67ARQ77WEA3D3CFB5XW39REDT0AM.amm-pool-v2-01,SP2C2YFP1
# → writes amm-pool-v2-01.ts and arkadiko-swap-v2-1.ts
```

### Sync — config-driven multi-contract sync

For projects with multiple contracts, create a config file to keep ABIs in sync declaratively.

Create `abi.config.json` in your project root:

```json
{
"outDir": "./src/abis",
"format": "ts",
"network": "mainnet",
"contracts": [
{ "id": "SP102V8P0F7JX67ARQ77WEA3D3CFB5XW39REDT0AM.amm-pool-v2-01" },
{ "id": "SP2C2YFP12AJZB1KD5HQ4XFRYGEK02H70HVK8GQH.arkadiko-swap-v2-1" },
{ "id": "ST1PQHQKV0RJXZFY1DGX8MNSNYVE3VGZJSRTPGZGM.my-contract", "network": "testnet" }
]
}
```

Or use `abi.config.ts` for type-safe config with autocomplete:

```typescript
import type { AbiConfig } from '@satoshai/abi-cli';

export default {
outDir: './src/abis',
format: 'ts',
network: 'mainnet',
contracts: [
{ id: 'SP102V8P0F7JX67ARQ77WEA3D3CFB5XW39REDT0AM.amm-pool-v2-01' },
{ id: 'SP2C2YFP12AJZB1KD5HQ4XFRYGEK02H70HVK8GQH.arkadiko-swap-v2-1' },
],
} satisfies AbiConfig;
```

Then run:

```bash
abi-cli sync
# → reads abi.config.json (or .ts), writes all ABIs to outDir
```

Use `--config` / `-c` to point to a custom config path:

```bash
abi-cli sync --config ./configs/my-abis.json
```

#### Config schema

| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| `outDir` | `string` | yes | — | Output directory for generated files |
| `format` | `"ts" \| "json"` | no | `"ts"` | Output format |
| `network` | `string` | no | `"mainnet"` | Default network for all contracts |
| `contracts` | `ContractEntry[]` | yes | — | List of contracts to sync |
| `contracts[].id` | `string` | yes | — | Contract ID in `address.name` format |
| `contracts[].network` | `string` | no | top-level `network` | Per-contract network override |

## Flags Reference

### `abi-cli fetch`

| Flag | Alias | Default | Description |
|------|-------|---------|-------------|
| `--network` | `-n` | `mainnet` | Network: `mainnet`, `testnet`, `devnet`, or a custom URL |
Expand All @@ -98,6 +159,13 @@ abi-cli fetch SP102V8P0F7JX67ARQ77WEA3D3CFB5XW39REDT0AM.amm-pool-v2-01,SP2C2YFP1
| `--stdout` | | `false` | Print to stdout instead of writing a file |
| `--help` | | | Show help |

### `abi-cli sync`

| Flag | Alias | Default | Description |
|------|-------|---------|-------------|
| `--config` | `-c` | auto-discover | Path to config file |
| `--help` | | | Show help |

## Programmatic API

```typescript
Expand All @@ -116,6 +184,22 @@ const json = generateJson(abi);
const { address, name } = parseContractId('SP102V8P0F7JX67ARQ77WEA3D3CFB5XW39REDT0AM.amm-pool-v2-01');
```

### Config loading

```typescript
import { loadConfig, validateConfig } from '@satoshai/abi-cli';
import type { AbiConfig, ContractEntry } from '@satoshai/abi-cli';

// Load and validate from file (auto-discovers abi.config.json/.ts)
const config = await loadConfig();

// Or from a specific path
const config2 = await loadConfig('./my-config.json');

// Validate a raw object
const validated = validateConfig({ outDir: './abis', contracts: [{ id: 'SP1.token' }] });
```

Types are re-exported from `@stacks/transactions`:

```typescript
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@
"node": ">=18"
},
"dependencies": {
"citty": "^0.1.6"
"citty": "^0.1.6",
"jiti": "^2.6.1"
},
"peerDependencies": {
"@stacks/transactions": ">=7.0.0"
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { defineCommand, runMain } from 'citty';
import { fetchCommand } from './commands/fetch.js';
import { syncCommand } from './commands/sync.js';

declare const __VERSION__: string;

Expand All @@ -11,6 +12,7 @@ const main = defineCommand({
},
subCommands: {
fetch: fetchCommand,
sync: syncCommand,
},
});

Expand Down
61 changes: 61 additions & 0 deletions src/commands/sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { defineCommand } from 'citty';
import { mkdir, writeFile } from 'node:fs/promises';
import { resolve, join } from 'node:path';
import { loadConfig } from '../config.js';
import { parseContractId, fetchContractAbi } from '../fetcher.js';
import { generateTypescript, generateJson, defaultFilename } from '../codegen.js';

export const syncCommand = defineCommand({
meta: {
name: 'sync',
description: 'Sync ABIs for all contracts defined in a config file',
},
args: {
config: {
type: 'string',
alias: 'c',
description: 'Path to config file (default: abi.config.json or abi.config.ts)',
},
},
async run({ args }) {
const config = await loadConfig(args.config);
const format = config.format ?? 'ts';
const outDir = resolve(config.outDir);

await mkdir(outDir, { recursive: true });

const failed: string[] = [];
let synced = 0;

for (const contract of config.contracts) {
const network = contract.network ?? config.network ?? 'mainnet';
try {
const { address, name } = parseContractId(contract.id);

console.error(`Fetching ABI for ${contract.id} on ${network}...`);
const abi = await fetchContractAbi(network, address, name);

const output =
format === 'ts'
? generateTypescript(contract.id, abi)
: generateJson(abi);

const filename = defaultFilename(contract.id, format);
const filepath = join(outDir, filename);
await writeFile(filepath, output, 'utf-8');
console.error(`Wrote ${filepath}`);
synced++;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
console.error(`Failed to sync ${contract.id}: ${message}`);
failed.push(contract.id);
}
}

console.error(`\n${synced}/${config.contracts.length} contracts synced.`);

if (failed.length > 0) {
throw new Error(`Failed to sync: ${failed.join(', ')}`);
}
},
});
122 changes: 122 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { parseContractId } from './fetcher.js';
import { resolveNetwork } from './network.js';

export interface ContractEntry {
id: string;
network?: string;
}

export interface AbiConfig {
outDir: string;
format?: 'ts' | 'json';
network?: string;
contracts: ContractEntry[];
}

export function validateConfig(raw: unknown): AbiConfig {
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
throw new Error('Config must be an object.');
}

const obj = raw as Record<string, unknown>;

if (typeof obj.outDir !== 'string' || !obj.outDir) {
throw new Error('Config "outDir" is required and must be a non-empty string.');
}

if (obj.format !== undefined) {
if (obj.format !== 'ts' && obj.format !== 'json') {
throw new Error(`Invalid config "format": "${obj.format}". Use "ts" or "json".`);
}
}

if (obj.network !== undefined) {
if (typeof obj.network !== 'string') {
throw new Error('Config "network" must be a string.');
}
resolveNetwork(obj.network);
}

if (!Array.isArray(obj.contracts) || obj.contracts.length === 0) {
throw new Error('Config "contracts" is required and must be a non-empty array.');
}

for (const entry of obj.contracts) {
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
throw new Error('Each contract entry must be an object.');
}
const e = entry as Record<string, unknown>;
if (typeof e.id !== 'string' || !e.id) {
throw new Error('Each contract entry must have an "id" string.');
}
parseContractId(e.id);
if (e.network !== undefined) {
if (typeof e.network !== 'string') {
throw new Error(`Contract "${e.id}" has an invalid "network" value.`);
}
resolveNetwork(e.network);
}
}

return {
outDir: obj.outDir,
format: obj.format as AbiConfig['format'],
network: obj.network as string | undefined,
contracts: (obj.contracts as Record<string, unknown>[]).map((c) => ({
id: c.id as string,
...(c.network !== undefined ? { network: c.network as string } : {}),
})),
};
}

const DEFAULT_CONFIG_FILES = ['abi.config.json', 'abi.config.ts'];

export async function loadConfig(configPath?: string): Promise<AbiConfig> {
if (configPath) {
const absolute = resolve(configPath);
return loadFile(absolute);
}

for (const filename of DEFAULT_CONFIG_FILES) {
const absolute = resolve(filename);
try {
return await loadFile(absolute);
} catch (err) {
if (isFileNotFound(err)) continue;
throw err;
}
}

throw new Error(
`No config file found. Create abi.config.json or abi.config.ts in the current directory, or pass --config.`,
);
}

async function loadFile(filepath: string): Promise<AbiConfig> {
let raw: unknown;

if (filepath.endsWith('.ts')) {
const { default: jiti } = await import('jiti');
const loader = jiti(filepath, { interopDefault: true });
raw = await loader.import(filepath, { default: true });
} else {
const content = await readFile(filepath, 'utf-8');
try {
raw = JSON.parse(content);
} catch {
throw new Error(`Failed to parse JSON config at ${filepath}.`);
}
}

return validateConfig(raw);
}

function isFileNotFound(err: unknown): boolean {
return (
err instanceof Error &&
'code' in err &&
(err as NodeJS.ErrnoException).code === 'ENOENT'
);
}
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,6 @@ export { parseContractId, fetchContractAbi } from './fetcher.js';
export type { ContractId } from './fetcher.js';

export { generateTypescript, generateJson, defaultFilename } from './codegen.js';

export { loadConfig, validateConfig } from './config.js';
export type { AbiConfig, ContractEntry } from './config.js';
Loading