diff --git a/.changeset/add-sync-command.md b/.changeset/add-sync-command.md new file mode 100644 index 0000000..3b3a61e --- /dev/null +++ b/.changeset/add-sync-command.md @@ -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. diff --git a/README.md b/README.md index ac78125..05bb083 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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 @@ -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 diff --git a/package.json b/package.json index c5525f3..be91051 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ebc3d44..38d4854 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: citty: specifier: ^0.1.6 version: 0.1.6 + jiti: + specifier: ^2.6.1 + version: 2.6.1 devDependencies: '@changesets/cli': specifier: ^2.29.8 diff --git a/src/cli.ts b/src/cli.ts index c88733a..026b01d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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; @@ -11,6 +12,7 @@ const main = defineCommand({ }, subCommands: { fetch: fetchCommand, + sync: syncCommand, }, }); diff --git a/src/commands/sync.ts b/src/commands/sync.ts new file mode 100644 index 0000000..95c1265 --- /dev/null +++ b/src/commands/sync.ts @@ -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(', ')}`); + } + }, +}); diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..e3388ac --- /dev/null +++ b/src/config.ts @@ -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; + + 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; + 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[]).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 { + 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 { + 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' + ); +} diff --git a/src/index.ts b/src/index.ts index 363506e..73100c0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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'; diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts new file mode 100644 index 0000000..4aaf8ee --- /dev/null +++ b/tests/unit/config.test.ts @@ -0,0 +1,191 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { validateConfig, loadConfig } from '../../src/config.js'; +import { readFile } from 'node:fs/promises'; + +vi.mock('node:fs/promises', () => ({ + readFile: vi.fn(), +})); + +describe('validateConfig', () => { + it('accepts a valid minimal config', () => { + const result = validateConfig({ + outDir: './abis', + contracts: [{ id: 'SP1.token-a' }], + }); + + expect(result.outDir).toBe('./abis'); + expect(result.contracts).toEqual([{ id: 'SP1.token-a' }]); + expect(result.format).toBeUndefined(); + expect(result.network).toBeUndefined(); + }); + + it('accepts a valid full config', () => { + const result = validateConfig({ + outDir: './abis', + format: 'json', + network: 'testnet', + contracts: [ + { id: 'SP1.token-a' }, + { id: 'SP2.token-b', network: 'mainnet' }, + ], + }); + + expect(result.outDir).toBe('./abis'); + expect(result.format).toBe('json'); + expect(result.network).toBe('testnet'); + expect(result.contracts).toHaveLength(2); + expect(result.contracts[1].network).toBe('mainnet'); + }); + + it('throws on missing outDir', () => { + expect(() => + validateConfig({ contracts: [{ id: 'SP1.token' }] }), + ).toThrow('"outDir" is required'); + }); + + it('throws on missing contracts', () => { + expect(() => validateConfig({ outDir: './abis' })).toThrow( + '"contracts" is required', + ); + }); + + it('throws on empty contracts array', () => { + expect(() => + validateConfig({ outDir: './abis', contracts: [] }), + ).toThrow('"contracts" is required and must be a non-empty array'); + }); + + it('throws on invalid format', () => { + expect(() => + validateConfig({ + outDir: './abis', + format: 'yaml', + contracts: [{ id: 'SP1.token' }], + }), + ).toThrow('Invalid config "format"'); + }); + + it('throws on invalid network', () => { + expect(() => + validateConfig({ + outDir: './abis', + network: 'badnet', + contracts: [{ id: 'SP1.token' }], + }), + ).toThrow('Invalid network "badnet"'); + }); + + it('throws on invalid contract ID', () => { + expect(() => + validateConfig({ + outDir: './abis', + contracts: [{ id: 'not-a-valid-id' }], + }), + ).toThrow('Invalid contract ID'); + }); + + it('throws on non-object input', () => { + expect(() => validateConfig('string')).toThrow('Config must be an object'); + expect(() => validateConfig(null)).toThrow('Config must be an object'); + expect(() => validateConfig([])).toThrow('Config must be an object'); + }); +}); + +describe('loadConfig', () => { + beforeEach(() => { + vi.mocked(readFile).mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('loads from an explicit JSON path', async () => { + vi.mocked(readFile).mockResolvedValueOnce( + JSON.stringify({ + outDir: './abis', + contracts: [{ id: 'SP1.token' }], + }), + ); + + const config = await loadConfig('/tmp/my-config.json'); + + expect(readFile).toHaveBeenCalledWith( + expect.stringContaining('my-config.json'), + 'utf-8', + ); + expect(config.outDir).toBe('./abis'); + }); + + it('discovers abi.config.json by default', async () => { + vi.mocked(readFile).mockResolvedValueOnce( + JSON.stringify({ + outDir: './out', + contracts: [{ id: 'SP1.nft' }], + }), + ); + + const config = await loadConfig(); + + expect(readFile).toHaveBeenCalledWith( + expect.stringContaining('abi.config.json'), + 'utf-8', + ); + expect(config.outDir).toBe('./out'); + }); + + it('falls back to abi.config.ts when json not found', async () => { + const notFound = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + vi.mocked(readFile).mockRejectedValueOnce(notFound); + + // TS loading goes through jiti — mock the jiti import + const mockImport = vi.fn().mockResolvedValue({ + outDir: './from-ts', + contracts: [{ id: 'SP1.ts-contract' }], + }); + const mockJiti = vi.fn().mockReturnValue({ import: mockImport }); + vi.doMock('jiti', () => ({ default: mockJiti })); + + // Need to re-import to pick up the mock + const { loadConfig: loadConfigFresh } = await import('../../src/config.js'); + const config = await loadConfigFresh(); + + expect(config.outDir).toBe('./from-ts'); + + vi.doUnmock('jiti'); + }); + + it('throws when no config file is found', async () => { + const notFound = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + vi.mocked(readFile).mockRejectedValue(notFound); + + // Also mock jiti to fail with ENOENT for .ts discovery + const mockImport = vi.fn().mockRejectedValue(notFound); + const mockJiti = vi.fn().mockReturnValue({ import: mockImport }); + vi.doMock('jiti', () => ({ default: mockJiti })); + + const { loadConfig: loadConfigFresh } = await import('../../src/config.js'); + + await expect(loadConfigFresh()).rejects.toThrow('No config file found'); + + vi.doUnmock('jiti'); + }); + + it('throws on invalid JSON content', async () => { + vi.mocked(readFile).mockResolvedValueOnce('not json {{{'); + + await expect(loadConfig('/tmp/bad.json')).rejects.toThrow( + 'Failed to parse JSON config', + ); + }); + + it('propagates validation errors from loaded config', async () => { + vi.mocked(readFile).mockResolvedValueOnce( + JSON.stringify({ outDir: './abis' }), + ); + + await expect(loadConfig('/tmp/missing-contracts.json')).rejects.toThrow( + '"contracts" is required', + ); + }); +}); diff --git a/tests/unit/sync.test.ts b/tests/unit/sync.test.ts new file mode 100644 index 0000000..de9377b --- /dev/null +++ b/tests/unit/sync.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { syncCommand } from '../../src/commands/sync.js'; +import { sampleAbi } from './fixtures.js'; +import { writeFile, mkdir, readFile } from 'node:fs/promises'; +import { resolve, join } from 'node:path'; + +vi.mock('node:fs/promises', () => ({ + writeFile: vi.fn(), + mkdir: vi.fn(), + readFile: vi.fn(), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const runSync = (args: Record) => + syncCommand.run!({ args, rawArgs: [], cmd: syncCommand } as never); + +function mockFetchSuccess(times = 1) { + for (let i = 0; i < times; i++) { + vi.mocked(globalThis.fetch).mockResolvedValueOnce({ + ok: true, + json: async () => sampleAbi, + } as Response); + } +} + +function mockConfig(config: object) { + vi.mocked(readFile).mockResolvedValueOnce(JSON.stringify(config)); +} + +describe('syncCommand', () => { + const originalFetch = globalThis.fetch; + let errorSpy: ReturnType; + + beforeEach(() => { + globalThis.fetch = vi.fn(); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.mocked(writeFile).mockResolvedValue(); + vi.mocked(mkdir).mockResolvedValue(undefined); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + errorSpy.mockRestore(); + vi.restoreAllMocks(); + }); + + it('syncs all contracts from config', async () => { + mockConfig({ + outDir: './abis', + contracts: [{ id: 'SP1.token-a' }, { id: 'SP2.token-b' }], + }); + mockFetchSuccess(2); + + await runSync({ config: '/tmp/abi.config.json' }); + + expect(mkdir).toHaveBeenCalledWith(resolve('./abis'), { recursive: true }); + expect(writeFile).toHaveBeenCalledTimes(2); + expect(writeFile).toHaveBeenCalledWith( + join(resolve('./abis'), 'token-a.ts'), + expect.stringContaining('export const abi ='), + 'utf-8', + ); + expect(writeFile).toHaveBeenCalledWith( + join(resolve('./abis'), 'token-b.ts'), + expect.stringContaining('export const abi ='), + 'utf-8', + ); + }); + + it('creates outDir with recursive: true', async () => { + mockConfig({ + outDir: './deep/nested/abis', + contracts: [{ id: 'SP1.token' }], + }); + mockFetchSuccess(); + + await runSync({ config: '/tmp/abi.config.json' }); + + expect(mkdir).toHaveBeenCalledWith(resolve('./deep/nested/abis'), { + recursive: true, + }); + }); + + it('respects top-level format and network', async () => { + mockConfig({ + outDir: './abis', + format: 'json', + network: 'testnet', + contracts: [{ id: 'SP1.token' }], + }); + mockFetchSuccess(); + + await runSync({ config: '/tmp/abi.config.json' }); + + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringContaining('api.testnet.hiro.so'), + ); + expect(writeFile).toHaveBeenCalledWith( + join(resolve('./abis'), 'token.json'), + expect.any(String), + 'utf-8', + ); + }); + + it('per-contract network overrides top-level', async () => { + mockConfig({ + outDir: './abis', + network: 'testnet', + contracts: [{ id: 'SP1.token', network: 'mainnet' }], + }); + mockFetchSuccess(); + + await runSync({ config: '/tmp/abi.config.json' }); + + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringContaining('api.hiro.so'), + ); + }); + + it('continues on partial failure and throws summary', async () => { + mockConfig({ + outDir: './abis', + contracts: [ + { id: 'SP1.token-a' }, + { id: 'SP2.token-b' }, + { id: 'SP3.token-c' }, + ], + }); + mockFetchSuccess(); // SP1.token-a succeeds + vi.mocked(globalThis.fetch).mockRejectedValueOnce(new Error('network error')); // SP2 fails + mockFetchSuccess(); // SP3.token-c succeeds + + await expect(runSync({ config: '/tmp/abi.config.json' })).rejects.toThrow( + 'Failed to sync: SP2.token-b', + ); + + // Still wrote 2 successful contracts + expect(writeFile).toHaveBeenCalledTimes(2); + }); + + it('throws when config not found', async () => { + const notFound = Object.assign(new Error('ENOENT'), { code: 'ENOENT' }); + vi.mocked(readFile).mockRejectedValue(notFound); + + // Also mock jiti for .ts fallback + const mockImport = vi.fn().mockRejectedValue(notFound); + const mockJiti = vi.fn().mockReturnValue({ import: mockImport }); + vi.doMock('jiti', () => ({ default: mockJiti })); + + const { syncCommand: syncFresh } = await import( + '../../src/commands/sync.js' + ); + const runSyncFresh = (args: Record) => + syncFresh.run!({ args, rawArgs: [], cmd: syncFresh } as never); + + await expect(runSyncFresh({})).rejects.toThrow('No config file found'); + + vi.doUnmock('jiti'); + }); + + it('uses custom --config path', async () => { + mockConfig({ + outDir: './custom-out', + contracts: [{ id: 'SP1.my-contract' }], + }); + mockFetchSuccess(); + + await runSync({ config: '/path/to/custom.json' }); + + expect(readFile).toHaveBeenCalledWith('/path/to/custom.json', 'utf-8'); + expect(writeFile).toHaveBeenCalledWith( + join(resolve('./custom-out'), 'my-contract.ts'), + expect.stringContaining('export const abi ='), + 'utf-8', + ); + }); +});