Skip to content

Commit 06e814e

Browse files
author
azeth-sync[bot]
committed
v0.2.5: sync from monorepo 2026-03-07
1 parent 34877ce commit 06e814e

8 files changed

Lines changed: 131 additions & 55 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ azeth call https://api.example.com/eth-price --max-amount 0.10
3838
| `AZETH_PRIVATE_KEY` | Yes | Account owner's private key |
3939
| `PIMLICO_API_KEY` | Yes* | Pimlico bundler key (*required for state-changing ops) |
4040
| `AZETH_CHAIN` | No | Default chain (`baseSepolia`) |
41-
| `BASE_RPC_URL` | No | RPC endpoint URL |
41+
| `AZETH_RPC_URL_BASE_SEPOLIA` | No | RPC endpoint (per-chain: `AZETH_RPC_URL_BASE`, etc.) |
4242
| `AZETH_SERVER_URL` | No | Azeth server URL |
4343

4444
## Commands

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@azeth/cli",
3-
"version": "0.2.4",
3+
"version": "0.2.5",
44
"type": "module",
55
"description": "CLI for the Azeth trust infrastructure — register, discover, pay, and manage machine participants",
66
"license": "MIT",
@@ -35,8 +35,8 @@
3535
"clean": "rm -rf dist"
3636
},
3737
"dependencies": {
38-
"@azeth/common": "^0.2.4",
39-
"@azeth/sdk": "^0.2.4",
38+
"@azeth/common": "^0.2.5",
39+
"@azeth/sdk": "^0.2.5",
4040
"commander": "^12.1.0",
4141
"chalk": "^5.3.0",
4242
"ora": "^8.1.0",

src/commands/quickstart.ts

Lines changed: 31 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts';
55
import { AzethKit, type AzethKitConfig } from '@azeth/sdk';
66
import { isValidChainName, TOKENS, type Guardrails, type SupportedChainName } from '@azeth/common';
77
import { printHeader, printField, printSuccess, printError } from '../utils/display.js';
8+
import { saveKey } from '../utils/key-persistence.js';
89

910
const DEFAULT_SERVER_URL = 'https://api.azeth.ai';
1011

@@ -62,6 +63,7 @@ export const quickstartCommand = new Command('quickstart')
6263
} else {
6364
privateKey = generatePrivateKey();
6465
generated = true;
66+
saveKey(privateKey);
6567
}
6668

6769
const account = privateKeyToAccount(privateKey);
@@ -83,13 +85,16 @@ export const quickstartCommand = new Command('quickstart')
8385
}
8486

8587
// Step 4: Build AzethKitConfig directly (no env vars required)
88+
const serverUrl = process.env['AZETH_SERVER_URL'] ?? DEFAULT_SERVER_URL;
8689
const config: AzethKitConfig = {
8790
privateKey,
8891
chain,
89-
serverUrl: process.env['AZETH_SERVER_URL'] ?? DEFAULT_SERVER_URL,
92+
serverUrl,
9093
};
9194

92-
const spinner = ora(`Deploying smart account on ${chain} (gas sponsored by Azeth)...`).start();
95+
// SDK auto-tries gasless relay (createAccountWithSignature) before falling back
96+
// to direct on-chain tx. No need to sponsor gas separately.
97+
const spinner = ora(`Deploying smart account on ${chain}...`).start();
9398
kit = await AzethKit.create(config);
9499

95100
// Step 5: Deploy smart account + register on ERC-8004
@@ -127,7 +132,6 @@ export const quickstartCommand = new Command('quickstart')
127132
spinner.stop();
128133

129134
// Step 6: Print results
130-
const serverUrl = config.serverUrl ?? DEFAULT_SERVER_URL;
131135
const tokenIdStr = result.tokenId.toString();
132136
const badgeUrl = `${serverUrl}/badge/${tokenIdStr}`;
133137
const profileUrl = `https://azeth.ai/agent/${result.account}`;
@@ -142,38 +146,10 @@ export const quickstartCommand = new Command('quickstart')
142146
printField('Profile', profileUrl);
143147
printField('Badge', badgeUrl);
144148
console.log();
145-
printSuccess('Gas sponsored by Azeth testnet — no ETH required.');
149+
printSuccess('Account deployed — zero gas required from you.');
146150
console.log();
147151

148-
// Step 6b: Fund the smart account with testnet USDC via faucet (testnet only)
149-
if (isTestnet) {
150-
try {
151-
const faucetSpinner = ora('Funding account with testnet USDC...').start();
152-
const faucetRes = await fetch(`${serverUrl}/api/v1/faucet`, {
153-
method: 'POST',
154-
headers: { 'Content-Type': 'application/json' },
155-
body: JSON.stringify({ address: result.account }),
156-
signal: AbortSignal.timeout(60_000),
157-
});
158-
if (faucetRes.ok) {
159-
const faucetBody = await faucetRes.json() as { data?: { amount?: string; txHash?: string } };
160-
faucetSpinner.stop();
161-
printSuccess(`Funded with ${faucetBody.data?.amount ?? '1.00 USDC'} for demo calls.`);
162-
console.log();
163-
} else {
164-
faucetSpinner.stop();
165-
// Non-fatal: show manual funding instructions
166-
console.log(chalk.gray(' Faucet unavailable — send testnet USDC to your smart account to try paid services.'));
167-
console.log();
168-
}
169-
} catch {
170-
// Non-fatal — faucet unreachable
171-
console.log(chalk.gray(' Could not reach faucet — send testnet USDC to your smart account to try paid services.'));
172-
console.log();
173-
}
174-
}
175-
176-
// Step 6c: Demo the live ecosystem — call the free catalog
152+
// Step 6b: Demo the live ecosystem — call the free catalog
177153
const catalogUrl = `${serverUrl}/api/v1/pricing`;
178154
try {
179155
const catalogSpinner = ora('Discovering live services...').start();
@@ -190,9 +166,6 @@ export const quickstartCommand = new Command('quickstart')
190166
console.log(` ${chalk.cyan(item.name ?? '?')} ${chalk.gray(item.pricing ?? '')} ${chalk.white(item.description ?? '')}`);
191167
}
192168
console.log();
193-
console.log(chalk.gray(' Try a paid call now' + (isTestnet ? ' (your account is funded)' : '') + ':'));
194-
console.log(chalk.cyan(` azeth call ${serverUrl}/api/v1/pricing/ethereum`));
195-
console.log();
196169
}
197170
} else {
198171
catalogSpinner.stop();
@@ -201,6 +174,26 @@ export const quickstartCommand = new Command('quickstart')
201174
// Non-fatal — catalog fetch failed, just skip the demo
202175
}
203176

177+
// Step 6d: Make a live paid API call to demonstrate x402 (testnet only)
178+
if (isTestnet) {
179+
try {
180+
const demoSpinner = ora('Making your first paid API call...').start();
181+
const demoRes = await kit.fetch402(`${serverUrl}/api/v1/pricing/ethereum`);
182+
demoSpinner.stop();
183+
if (demoRes.response.ok) {
184+
const body = await demoRes.response.json() as { data?: { symbol?: string; price?: string } };
185+
const price = body?.data?.price;
186+
const costStr = demoRes.amount !== undefined
187+
? `$${(Number(demoRes.amount) / 1_000_000).toFixed(2)}`
188+
: '$0.01';
189+
printSuccess(`First paid call complete! ETH = ${price ?? 'N/A'} (cost: ${costStr})`);
190+
console.log();
191+
}
192+
} catch {
193+
// Non-fatal — the demo call is a nice-to-have
194+
}
195+
}
196+
204197
console.log(chalk.gray(' Next steps:'));
205198
console.log(chalk.gray(' azeth status — Check your account'));
206199
console.log(chalk.gray(' azeth find "price feed" — Discover services'));
@@ -209,11 +202,8 @@ export const quickstartCommand = new Command('quickstart')
209202

210203
// Step 7: Print key persistence instructions (only for generated keys)
211204
if (generated) {
212-
console.log(chalk.bold(' To continue using this account:'));
213-
console.log(chalk.cyan(` export AZETH_PRIVATE_KEY=${privateKey}`));
214-
console.log();
215-
console.log(chalk.gray(' Or add to .env:'));
216-
console.log(chalk.gray(` echo 'AZETH_PRIVATE_KEY=${privateKey}' >> .env`));
205+
console.log(chalk.gray(' Key saved to ~/.azeth/key'));
206+
console.log(chalk.gray(' To use in other terminals: export AZETH_PRIVATE_KEY=$(cat ~/.azeth/key)'));
217207
console.log();
218208
}
219209
} catch (err) {

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ const program = new Command()
1616
.description('Azeth.ai CLI — Trust Infrastructure for the Machine Economy')
1717
.version('0.1.0')
1818
.option('--chain <chain>', 'Chain to use (base|baseSepolia|ethereumSepolia|ethereum)', 'baseSepolia')
19-
.option('--rpc-url <url>', 'RPC URL (or set BASE_RPC_URL)')
19+
.option('--rpc-url <url>', 'RPC URL (or set AZETH_RPC_URL_BASE_SEPOLIA, etc.)')
2020
.option('--server-url <url>', 'Azeth server URL (or set AZETH_SERVER_URL)');
2121

2222
// Daily porcelain commands

src/utils/config.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { AzethKit, type AzethKitConfig } from '@azeth/sdk';
2-
import { isValidChainName, isValidPrivateKey } from '@azeth/common';
2+
import { isValidChainName, isValidPrivateKey, RPC_ENV_KEYS } from '@azeth/common';
33
import type { SupportedChainName } from '@azeth/common';
44
import type { Command } from 'commander';
55
import dotenv from 'dotenv';
6+
import { loadKey } from './key-persistence.js';
67

78
dotenv.config();
89

@@ -25,7 +26,7 @@ export function resolveOptions(cmd: Command): CliOptions {
2526
throw new Error(`Invalid chain "${chainRaw}". Must be one of: base, baseSepolia, ethereumSepolia, ethereum`);
2627
}
2728

28-
const rpcUrl = opts.rpcUrl ?? process.env['BASE_RPC_URL'];
29+
const rpcUrl = opts.rpcUrl ?? process.env[RPC_ENV_KEYS[chainRaw]];
2930
const serverUrl = opts.serverUrl ?? process.env['AZETH_SERVER_URL'];
3031

3132
// Validate server URL if provided
@@ -48,10 +49,16 @@ export function resolveOptions(cmd: Command): CliOptions {
4849
* Private key is read exclusively from the AZETH_PRIVATE_KEY environment variable.
4950
* NEVER pass private keys via command-line arguments (visible in shell history and process listings). */
5051
export async function createKit(options: CliOptions): Promise<AzethKit> {
51-
const privateKey = process.env['AZETH_PRIVATE_KEY'];
52+
let privateKey = process.env['AZETH_PRIVATE_KEY'];
53+
if (!privateKey) {
54+
const fileKey = loadKey();
55+
if (fileKey) {
56+
privateKey = fileKey;
57+
}
58+
}
5259
if (!privateKey) {
5360
throw new Error(
54-
'Private key required. Set the AZETH_PRIVATE_KEY environment variable.\n' +
61+
"Private key required. Set AZETH_PRIVATE_KEY or run 'azeth quickstart' to auto-generate one.\n" +
5562
'Example: export AZETH_PRIVATE_KEY=0x...\n' +
5663
'WARNING: Never pass private keys via command-line flags — they are visible in shell history.',
5764
);

src/utils/key-persistence.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/**
2+
* Private key persistence for the Azeth CLI.
3+
*
4+
* Saves and loads private keys from ~/.azeth/key so that quickstart-generated
5+
* keys persist across CLI invocations without manual `export AZETH_PRIVATE_KEY=...`.
6+
*
7+
* File permissions: ~/.azeth/ = 0o700, ~/.azeth/key = 0o600
8+
* The private key is NEVER logged — only the derived EOA address.
9+
*/
10+
11+
import { existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync, statSync } from 'node:fs';
12+
import { homedir } from 'node:os';
13+
import { join } from 'node:path';
14+
15+
const KEY_FILE_RE = /^0x[0-9a-fA-F]{64}$/;
16+
17+
/**
18+
* Save a private key to ~/.azeth/key with secure file permissions.
19+
* Creates ~/.azeth/ directory (0o700) if it doesn't exist.
20+
* Returns true if saved successfully, false otherwise.
21+
*/
22+
export function saveKey(privateKey: string): boolean {
23+
const home = homedir();
24+
if (!home) return false;
25+
26+
const azethDir = join(home, '.azeth');
27+
const keyFile = join(azethDir, 'key');
28+
29+
// Ensure ~/.azeth/ directory exists
30+
if (existsSync(azethDir)) {
31+
try {
32+
if (!statSync(azethDir).isDirectory()) return false;
33+
} catch {
34+
return false;
35+
}
36+
} else {
37+
try {
38+
mkdirSync(azethDir, { recursive: true, mode: 0o700 });
39+
} catch {
40+
return false;
41+
}
42+
}
43+
44+
try {
45+
writeFileSync(keyFile, privateKey, { mode: 0o600 });
46+
chmodSync(keyFile, 0o600);
47+
return true;
48+
} catch {
49+
return false;
50+
}
51+
}
52+
53+
/**
54+
* Load a private key from ~/.azeth/key.
55+
* Returns the key string if found and valid, null otherwise.
56+
*/
57+
export function loadKey(): string | null {
58+
const home = homedir();
59+
if (!home) return null;
60+
61+
const keyFile = join(home, '.azeth', 'key');
62+
63+
if (!existsSync(keyFile)) return null;
64+
65+
try {
66+
const key = readFileSync(keyFile, 'utf-8').trim();
67+
if (KEY_FILE_RE.test(key)) return key;
68+
return null;
69+
} catch {
70+
return null;
71+
}
72+
}

test/commands/discover.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ describe('discover command', () => {
4444
.addCommand(cmd);
4545
}
4646

47-
it('displays results in a table when services are found', async () => {
47+
// First dynamic import after vi.resetModules() cold-loads @azeth/common (large ABIs)
48+
it('displays results in a table when services are found', { timeout: 15000 }, async () => {
4849
const responseData = {
4950
data: [
5051
{ tokenId: '1', owner: '0x1111111111111111111111111111111111111111', entityType: 'service', name: 'PriceFeed', capabilities: ['price-feed'], active: true },

test/utils/config.test.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ vi.mock('@azeth/sdk', () => ({
1515
// Prevent dotenv from loading .env files during tests
1616
vi.mock('dotenv', () => ({ default: { config: vi.fn() } }));
1717

18+
// Mock key-persistence so tests don't read the real ~/.azeth/key
19+
vi.mock('../../src/utils/key-persistence.js', () => ({
20+
loadKey: vi.fn().mockReturnValue(null),
21+
saveKey: vi.fn().mockReturnValue(true),
22+
}));
23+
1824
/** Build a Commander tree that mirrors the real CLI's global options
1925
* so that optsWithGlobals() works correctly in resolveOptions().
2026
*
@@ -48,7 +54,7 @@ describe('resolveOptions', () => {
4854

4955
beforeEach(() => {
5056
// Snapshot env vars we might mutate
51-
for (const key of ['AZETH_CHAIN', 'AZETH_PRIVATE_KEY', 'BASE_RPC_URL', 'AZETH_SERVER_URL']) {
57+
for (const key of ['AZETH_CHAIN', 'AZETH_PRIVATE_KEY', 'AZETH_RPC_URL_BASE_SEPOLIA', 'AZETH_SERVER_URL']) {
5258
savedEnv[key] = process.env[key];
5359
delete process.env[key];
5460
}
@@ -121,8 +127,8 @@ describe('resolveOptions', () => {
121127
expect((opts as Record<string, unknown>)['privateKey']).toBeUndefined();
122128
});
123129

124-
it('reads rpc URL from env var', () => {
125-
process.env['BASE_RPC_URL'] = 'https://custom-rpc.example.com';
130+
it('reads rpc URL from per-chain env var', () => {
131+
process.env['AZETH_RPC_URL_BASE_SEPOLIA'] = 'https://custom-rpc.example.com';
126132
const sub = buildProgram();
127133
const opts = resolveOptions(sub);
128134

@@ -179,7 +185,7 @@ describe('createKit', () => {
179185
};
180186

181187
await expect(createKit(options)).rejects.toThrow(
182-
'Private key required. Set the AZETH_PRIVATE_KEY environment variable.',
188+
'Private key required.',
183189
);
184190
});
185191

0 commit comments

Comments
 (0)