Skip to content

Commit 8970f7f

Browse files
committed
(feat) (openai/gpt-5.5, reviewed T, tested T) add wallet create and link commands
1 parent 44b9f39 commit 8970f7f

5 files changed

Lines changed: 177 additions & 0 deletions

File tree

cli/src/index.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ import { getOctokit } from "./github/auth.js";
1616
import { getLocalGithubRepo } from "./github/repo.js";
1717
import { getRepoSecretMetadata, setRepoSecret } from "./github/secrets.js";
1818
import { getRepoVariableMetadata, setRepoVariable } from "./github/vars.js";
19+
import { createWallet } from "./wallet/create.js";
20+
import { DEFAULT_PRIVATE_KEY_SECRET_NAME, linkWallet } from "./wallet/link.js";
21+
import { walletFilePath } from "./wallet/store.js";
1922

2023
const COMMAND_NAME = "fcf";
2124
const COMMAND_DESCRIPTION = "FCF CLI tool.";
@@ -42,6 +45,7 @@ function buildProgram(): Command {
4245
addRegisterCommand(program);
4346
addKeysSyncCommand(program);
4447
addListCommand(program);
48+
addWalletCommand(program);
4549
const githubCommand = program.command("github");
4650
addGithubWhoamiCommand(githubCommand);
4751
addGithubSecretsCommand(githubCommand);
@@ -50,6 +54,33 @@ function buildProgram(): Command {
5054
return program;
5155
}
5256

57+
function addWalletCommand(program: Command): void {
58+
const walletCommand = program.command("wallet");
59+
60+
walletCommand
61+
.command("create")
62+
.option("--force", "overwrite existing local wallet")
63+
.action((opts) => {
64+
try {
65+
const wallet = createWallet({ force: Boolean(opts.force) });
66+
console.log(`wallet created: ${wallet.address}`);
67+
console.log(`saved: ${walletFilePath()}`);
68+
} catch (err) { die(err); }
69+
});
70+
71+
// set FCF_PRIVATE_KEY in the active github repo -> used after `wallet create`
72+
walletCommand
73+
.command("link")
74+
.option("--secret-name <name>", "GitHub Actions private key secret name", DEFAULT_PRIVATE_KEY_SECRET_NAME)
75+
.action(async (opts) => {
76+
try {
77+
const wallet = await linkWallet({ secretName: opts.secretName });
78+
console.log(`wallet linked: ${wallet.address}`);
79+
console.log(`secret set: ${opts.secretName}`);
80+
} catch (err) { die(err); }
81+
});
82+
}
83+
5384
// spawns the github action workflow file that runs the `register` command
5485
function addInitCommand(program: Command): void {
5586
program

cli/src/wallet/create.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { privateKeyToAccount, generatePrivateKey } from "viem/accounts";
2+
3+
import { readLocalWallet, saveLocalWallet, type StoredWallet, walletFilePath } from "./store.js";
4+
5+
export type CreateWalletOptions = {
6+
force: boolean;
7+
}
8+
9+
export function createWallet(options: CreateWalletOptions): StoredWallet {
10+
if (readLocalWallet() && !options.force) {
11+
throw new Error(`wallet already exists at ${walletFilePath()}; use --force to overwrite`);
12+
}
13+
14+
const privateKey = generatePrivateKey();
15+
const account = privateKeyToAccount(privateKey);
16+
const wallet = { address: account.address, privateKey, createdAt: new Date().toISOString() };
17+
saveLocalWallet(wallet);
18+
return wallet;
19+
}

cli/src/wallet/link.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { getOctokit } from "@/github/auth.js";
2+
import { getLocalGithubRepo } from "@/github/repo.js";
3+
import { setRepoSecret } from "@/github/secrets.js";
4+
import { getLocalWallet, type StoredWallet } from "./store.js";
5+
6+
export const DEFAULT_PRIVATE_KEY_SECRET_NAME = "FCF_PRIVATE_KEY";
7+
8+
export type LinkWalletOptions = {
9+
secretName: string;
10+
}
11+
12+
export async function linkWallet(options: LinkWalletOptions): Promise<StoredWallet> {
13+
const wallet = getLocalWallet();
14+
const octokit = await getOctokit();
15+
const repo = await getLocalGithubRepo(octokit);
16+
17+
await setRepoSecret(octokit, repo.repoName, repo.repoOwnerName, options.secretName, wallet.privateKey);
18+
return wallet;
19+
}

cli/src/wallet/store.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2+
import { homedir } from "node:os";
3+
import { dirname, join, resolve } from "node:path";
4+
5+
import { getAddress, isAddress } from "viem";
6+
7+
export type StoredWallet = {
8+
address: `0x${string}`;
9+
privateKey: `0x${string}`;
10+
createdAt: string;
11+
}
12+
13+
type WalletState = {
14+
local?: StoredWallet;
15+
}
16+
17+
export function walletFilePath(): string {
18+
if (process.env.FCF_CONFIG_HOME) return join(resolve(process.env.FCF_CONFIG_HOME), "wallet.json");
19+
if (process.env.XDG_CONFIG_HOME) return join(resolve(process.env.XDG_CONFIG_HOME), "fcf", "wallet.json");
20+
return join(homedir(), ".config", "fcf", "wallet.json");
21+
}
22+
23+
export function readLocalWallet(): StoredWallet | undefined {
24+
return readWalletState().local;
25+
}
26+
27+
export function getLocalWallet(): StoredWallet {
28+
const wallet = readLocalWallet();
29+
if (!wallet) throw new Error(`wallet not found at ${walletFilePath()}; run fcf wallet create`);
30+
return wallet;
31+
}
32+
33+
export function saveLocalWallet(wallet: StoredWallet): void {
34+
const state = readWalletState();
35+
state.local = wallet;
36+
writeWalletState(state);
37+
}
38+
39+
function readWalletState(): WalletState {
40+
try {
41+
return parseWalletState(JSON.parse(readFileSync(walletFilePath(), "utf8")));
42+
} catch (err: any) {
43+
if (err?.code === "ENOENT") return {};
44+
throw err;
45+
}
46+
}
47+
48+
function writeWalletState(state: WalletState): void {
49+
const filePath = walletFilePath();
50+
mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
51+
52+
const tmpPath = `${filePath}.${process.pid}.tmp`;
53+
writeFileSync(tmpPath, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600 });
54+
renameSync(tmpPath, filePath);
55+
chmodSync(filePath, 0o600);
56+
}
57+
58+
function parseWalletState(value: any): WalletState {
59+
const state: WalletState = {};
60+
if (value?.local !== undefined) state.local = parseStoredWallet(value.local);
61+
return state;
62+
}
63+
64+
function parseStoredWallet(value: any): StoredWallet {
65+
if (!isAddress(value?.address)) throw new Error(`invalid wallet file: local.address`);
66+
if (!isPrivateKey(value?.privateKey)) throw new Error(`invalid wallet file: local.privateKey`);
67+
if (typeof value?.createdAt !== "string") throw new Error(`invalid wallet file: local.createdAt`);
68+
69+
return {
70+
address: getAddress(value.address),
71+
privateKey: value.privateKey,
72+
createdAt: value.createdAt,
73+
};
74+
}
75+
76+
function isPrivateKey(value: unknown): value is `0x${string}` {
77+
return typeof value === "string" && /^0x[0-9a-fA-F]{64}$/.test(value);
78+
}

cli/test/wallet.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
5+
import { test, expect } from "vitest";
6+
7+
import { createWallet } from "@/wallet/create.js";
8+
import { readLocalWallet, walletFilePath } from "@/wallet/store.js";
9+
10+
test("creates and stores a local wallet", () => {
11+
const previousConfigHome = process.env.FCF_CONFIG_HOME;
12+
const configHome = mkdtempSync(join(tmpdir(), "fcf-wallet-"));
13+
process.env.FCF_CONFIG_HOME = configHome;
14+
15+
try {
16+
const wallet = createWallet({ force: false });
17+
const filePath = walletFilePath();
18+
19+
expect(wallet.address).toMatch(/^0x[0-9a-fA-F]{40}$/);
20+
expect(wallet.privateKey).toMatch(/^0x[0-9a-fA-F]{64}$/);
21+
expect(readLocalWallet()).toEqual(wallet);
22+
expect(JSON.parse(readFileSync(filePath, "utf8")).local.privateKey).toBe(wallet.privateKey);
23+
expect(statSync(filePath).mode & 0o777).toBe(0o600);
24+
expect(() => createWallet({ force: false })).toThrow("wallet already exists");
25+
} finally {
26+
if (previousConfigHome === undefined) delete process.env.FCF_CONFIG_HOME;
27+
else process.env.FCF_CONFIG_HOME = previousConfigHome;
28+
rmSync(configHome, { recursive: true, force: true });
29+
}
30+
});

0 commit comments

Comments
 (0)