Skip to content

Commit 3d025b8

Browse files
committed
(refactor) (openai/gpt-5.5, reviewed T, tested T) tidy CLI entrypoint
1 parent d153c9f commit 3d025b8

2 files changed

Lines changed: 202 additions & 164 deletions

File tree

cli/src/index.ts

Lines changed: 185 additions & 164 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,174 @@
11
#!/usr/bin/env node
2+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3+
import { dirname } from "node:path";
4+
import { exit } from "node:process";
5+
26
import { Command } from "commander";
3-
import { createWalletClient, createPublicClient, http, parseAbiItem, toHex } from "viem";
47
import type { Abi } from "viem";
8+
import { createPublicClient, createWalletClient, http, parseAbiItem, toHex } from "viem";
59
import { privateKeyToAccount } from "viem/accounts";
6-
import { sepolia, foundry } from "viem/chains";
10+
import { foundry, sepolia } from "viem/chains";
11+
712
import { importAbi } from "@/importAbi.js";
813
import packageJson from "../package.json" with { type: "json" };
9-
import { exit } from "node:process";
10-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11-
import { dirname } from "node:path";
12-
import { b64urlToHex, jwtKid, parseJwt } from "./oidc.js";
14+
import { b64urlToHex, jwtKid, parseJwt, requestGithubOidcToken } from "@/oidc.js";
1315

1416
const COMMAND_NAME = "fcf";
15-
const COMMAND_DESCRIPTION = "FCF CLI tool."
17+
const COMMAND_DESCRIPTION = "FCF CLI tool.";
1618
const VERSION = packageJson?.version || "v0.0.1";
19+
20+
const DEFAULT_LIST_BLOCK_RANGE = 50_000n;
1721
const GITHUB_ISSUER = "https://token.actions.githubusercontent.com";
22+
1823
const REGISTER_WORKFLOW_PATH = ".github/workflows/fcf-register.yml";
1924
const REGISTER_WORKFLOW_TEMPLATE_PATH = new URL("../templates/fcf-register.yml", import.meta.url);
20-
const DEFAULT_LIST_BLOCK_RANGE = 50_000n;
2125

22-
function die(err: any): never {
23-
let error = "unknown error";
24-
if (err instanceof Error) error = err.message;
25-
console.error(`${COMMAND_NAME}: ${error}; exiting.`)
26-
exit(1);
26+
const abi = loadAbi();
27+
const RepoRegisteredEvent = parseAbiItem(
28+
"event RepoRegistered(uint256 indexed repoId, address indexed registrant, uint64 githubOwnerId, uint64 registeredAt)"
29+
);
30+
31+
function buildProgram(): Command {
32+
const program = new Command()
33+
.name(COMMAND_NAME)
34+
.description(COMMAND_DESCRIPTION)
35+
.version(VERSION);
36+
37+
addInitCommand(program);
38+
addRegisterCommand(program);
39+
addKeysSyncCommand(program);
40+
addListCommand(program);
41+
42+
return program;
2743
}
2844

29-
// import abi -> created by forge build
30-
let abi: Abi | null = null;
31-
try {
32-
abi = importAbi();
33-
} catch (err) {
34-
die(err);
45+
// spawns the github action workflow file that runs the `register` command
46+
function addInitCommand(program: Command): void {
47+
program
48+
.command("init")
49+
.option("--force", "overwrite existing workflow")
50+
.action((opts) => {
51+
if (existsSync(REGISTER_WORKFLOW_PATH) && !opts.force) {
52+
die(new Error(`${REGISTER_WORKFLOW_PATH} already exists`));
53+
}
54+
55+
mkdirSync(dirname(REGISTER_WORKFLOW_PATH), { recursive: true });
56+
writeFileSync(REGISTER_WORKFLOW_PATH, readRegisterWorkflowTemplate());
57+
console.log(`created: ${REGISTER_WORKFLOW_PATH}`);
58+
});
3559
}
3660

37-
const RepoRegisteredEvent = parseAbiItem(
38-
"event RepoRegistered(uint256 indexed repoId, address indexed registrant, uint64 githubOwnerId, uint64 registeredAt)"
39-
);
61+
// registers the RIK of a repository on-chain -> meant to be run in a github action in the target repo
62+
function addRegisterCommand(program: Command): void {
63+
program
64+
.command("register")
65+
.option("--oidc-token <token>", "GitHub's OIDC repository token")
66+
// needs to be removed in prod or be a default
67+
.requiredOption("--contract <addr>", "deployed RIK address")
68+
.action(async (opts) => {
69+
const { account, publicClient, walletClient } = clients();
70+
const oidcToken = opts.oidcToken ??
71+
await (async () => {
72+
try { return await requestGithubOidcToken(account.address.toLowerCase()) } catch (err) { die(err); }
73+
})();
74+
const jwt = parseRequiredJwt(oidcToken);
75+
76+
const expectedAud = account.address.toLowerCase();
77+
if (jwt.payload.aud?.toLowerCase() !== expectedAud) {
78+
die(new Error(`aud mismatch: want ${expectedAud}, got ${jwt.payload.aud}`));
79+
}
4080

41-
const program = new Command();
81+
const repoId = BigInt(jwt.payload.repository_id);
82+
const ownerId = BigInt(jwt.payload.repository_owner_id);
83+
if (!jwt.header.kid) die(new Error("jwt kid missing"));
4284

43-
program.name(COMMAND_NAME)
44-
.description(COMMAND_DESCRIPTION)
45-
.version(VERSION);
85+
const hash = await walletClient.writeContract({
86+
address: opts.contract as `0x${string}`,
87+
abi,
88+
functionName: "register",
89+
args: [
90+
jwtKid(jwt.header.kid),
91+
toHex(jwt.headerB64),
92+
toHex(jwt.payloadB64),
93+
b64urlToHex(jwt.signatureB64),
94+
repoId,
95+
ownerId,
96+
],
97+
account,
98+
});
99+
const r = await publicClient.waitForTransactionReceipt({ hash });
100+
console.log(`registered: ${hash} status=${r.status}`);
101+
});
102+
}
103+
104+
// only the contract deployer can run this -> periodically adds valid Github public signing keys
105+
function addKeysSyncCommand(program: Command): void {
106+
program
107+
.command("keys")
108+
.command("sync")
109+
// needs to be removed in prod or be a default
110+
.requiredOption("--contract <addr>", "deployed RIK address")
111+
.action(async (opts) => {
112+
const { account, publicClient, walletClient } = clients();
113+
const config = await fetchJson(`${GITHUB_ISSUER}/.well-known/openid-configuration`);
114+
const jwks = await fetchJson(config.jwks_uri);
115+
116+
for (const key of jwks.keys ?? []) {
117+
if (key.kty !== "RSA" || !key.kid || !key.n || !key.e) continue;
118+
119+
const kid = jwtKid(key.kid);
120+
const hash = await walletClient.writeContract({
121+
address: opts.contract as `0x${string}`,
122+
abi,
123+
functionName: "addKey",
124+
args: [kid, b64urlToHex(key.n), b64urlToHex(key.e)],
125+
account,
126+
});
127+
const r = await publicClient.waitForTransactionReceipt({ hash });
128+
console.log(`key synced: ${key.kid} kid=${kid} status=${r.status}`);
129+
}
130+
});
131+
}
132+
133+
function addListCommand(program: Command): void {
134+
program
135+
.command("list")
136+
// needs to be removed in prod or be a default
137+
.requiredOption("--contract <addr>", "deployed RIK address")
138+
.option("--from-block <n>", "starting block")
139+
.action(async (opts) => {
140+
const { publicClient } = clients();
141+
const fromBlock = opts.fromBlock
142+
? BigInt(opts.fromBlock)
143+
: await publicClient.getBlockNumber() - DEFAULT_LIST_BLOCK_RANGE;
144+
145+
const logs = await publicClient.getLogs({
146+
address: opts.contract as `0x${string}`,
147+
event: RepoRegisteredEvent,
148+
fromBlock,
149+
toBlock: "latest",
150+
});
151+
152+
for (const l of logs) {
153+
const { repoId, registrant, githubOwnerId, registeredAt } = l.args;
154+
console.log(
155+
`repo=${repoId} ownerId=${githubOwnerId} registrant=${registrant} at=${registeredAt}`
156+
);
157+
}
158+
});
159+
}
46160

47161
function clients() {
48-
const pk = process.env.PRIVATE_KEY as `0x${string}`;
49-
const rpc = process.env.RPC_URL ?? "http://127.0.0.1:8545";
50-
const chain = rpc.includes("sepolia") ? sepolia : foundry;
51-
const account = privateKeyToAccount(pk);
162+
const privateKey = process.env.PRIVATE_KEY as `0x${string}`;
163+
const rpcUrl = process.env.RPC_URL ?? "http://127.0.0.1:8545";
164+
const chain = rpcUrl.includes("sepolia") ? sepolia : foundry;
165+
const account = privateKeyToAccount(privateKey);
52166

53167
return {
54-
wallet: createWalletClient({ account, chain, transport: http(rpc) }),
55-
publicC: createPublicClient({ chain, transport: http(rpc) }),
56168
account,
169+
publicClient: createPublicClient({ chain, transport: http(rpcUrl) }),
170+
walletClient: createWalletClient({ account, chain, transport: http(rpcUrl) }),
57171
};
58-
59-
}
60-
61-
async function requestGithubOidcToken(audience: string): Promise<string> {
62-
const requestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
63-
const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
64-
if (!requestUrl || !requestToken) die(new Error("GitHub OIDC env vars not found"));
65-
66-
const separator = requestUrl.includes("?") ? "&" : "?";
67-
// include aud in payload -> aud == eth address of signer
68-
const res = await fetch(`${requestUrl}${separator}audience=${encodeURIComponent(audience)}`, {
69-
headers: { authorization: `bearer ${requestToken}` },
70-
});
71-
if (!res.ok) die(new Error(`failed to fetch GitHub OIDC token: ${res.status}`));
72-
73-
const body = await res.json() as { value?: string };
74-
if (!body.value) die(new Error("GitHub OIDC response did not contain token"));
75-
return body.value;
76172
}
77173

78174
async function fetchJson(url: string): Promise<any> {
@@ -81,119 +177,44 @@ async function fetchJson(url: string): Promise<any> {
81177
return res.json();
82178
}
83179

84-
program
85-
.command("init")
86-
.option("--force", "overwrite existing workflow")
87-
.action((opts) => {
88-
if (existsSync(REGISTER_WORKFLOW_PATH) && !opts.force) {
89-
die(new Error(`${REGISTER_WORKFLOW_PATH} already exists`));
90-
}
91-
92-
let registerWorkflow: string;
93-
try {
94-
registerWorkflow = readFileSync(REGISTER_WORKFLOW_TEMPLATE_PATH, "utf8");
95-
} catch (err) {
96-
die(err);
97-
}
98-
99-
mkdirSync(dirname(REGISTER_WORKFLOW_PATH), { recursive: true });
100-
writeFileSync(REGISTER_WORKFLOW_PATH, registerWorkflow);
101-
console.log(`created: ${REGISTER_WORKFLOW_PATH}`);
102-
});
103-
104-
program
105-
.command("register")
106-
.option("--oidc-token <token>", "GitHub's OIDC repository token")
107-
// needs to be removed in prod or be a default
108-
.requiredOption("--contract <addr>", "deployed RIK address")
109-
.action(async (opts) => {
110-
const { wallet, publicC, account } = clients();
111-
const oidcToken = opts.oidcToken ?? await requestGithubOidcToken(account.address.toLowerCase());
112-
113-
const jwt = (() => {
114-
try {
115-
return parseJwt(oidcToken);
116-
} catch(err) {
117-
die(err);
118-
}
119-
})();
120-
121-
const expectedAud = account.address.toLowerCase();
122-
if (jwt.payload.aud?.toLowerCase() !== expectedAud) {
123-
die(new Error(`aud mismatch: want ${expectedAud}, got ${jwt.payload.aud}`));
124-
}
125-
const repoId = BigInt(jwt.payload.repository_id);
126-
const ownerId = BigInt(jwt.payload.repository_owner_id);
127-
if (!jwt.header.kid) die(new Error("jwt kid missing"));
128-
129-
const hash = await wallet.writeContract({
130-
address: opts.contract as `0x${string}`,
131-
abi,
132-
functionName: "register",
133-
args: [
134-
jwtKid(jwt.header.kid),
135-
toHex(jwt.headerB64),
136-
toHex(jwt.payloadB64),
137-
b64urlToHex(jwt.signatureB64),
138-
repoId,
139-
ownerId,
140-
],
141-
account,
142-
});
143-
const r = await publicC.waitForTransactionReceipt({ hash });
144-
console.log(`registered: ${hash} status=${r.status}`);
145-
});
146-
147-
program
148-
.command("keys")
149-
.command("sync")
150-
// needs to be removed in prod or be a default
151-
.requiredOption("--contract <addr>", "deployed RIK address")
152-
.action(async (opts) => {
153-
const { wallet, publicC, account } = clients();
154-
const config = await fetchJson(`${GITHUB_ISSUER}/.well-known/openid-configuration`);
155-
const jwks = await fetchJson(config.jwks_uri);
156-
157-
for (const key of jwks.keys ?? []) {
158-
if (key.kty !== "RSA" || !key.kid || !key.n || !key.e) continue;
159-
160-
const kid = jwtKid(key.kid);
161-
const hash = await wallet.writeContract({
162-
address: opts.contract as `0x${string}`,
163-
abi,
164-
functionName: "addKey",
165-
args: [kid, b64urlToHex(key.n), b64urlToHex(key.e)],
166-
account,
167-
});
168-
const r = await publicC.waitForTransactionReceipt({ hash });
169-
console.log(`key synced: ${key.kid} kid=${kid} status=${r.status}`);
170-
}
171-
});
172-
173-
program
174-
.command("list")
175-
// needs to be removed in prod or be a default
176-
.requiredOption("--contract <addr>", "deployed RIK address")
177-
.option("--from-block <n>", "starting block")
178-
.action(async (opts) => {
179-
const { publicC } = clients();
180-
181-
// by default looks only in the last DEFAULT_LIST_BLOCK_RANGE blocks
182-
if (!opts.fromBlock) opts.fromBlock = await publicC.getBlockNumber() - DEFAULT_LIST_BLOCK_RANGE;
183-
184-
const logs = await publicC.getLogs({
185-
address: opts.contract as `0x${string}`,
186-
event: RepoRegisteredEvent,
187-
fromBlock: BigInt(opts.fromBlock),
188-
toBlock: "latest",
189-
});
190-
191-
for (const l of logs) {
192-
const { repoId, registrant, githubOwnerId, registeredAt} = l.args;
193-
console.log(
194-
`repo=${repoId} ownerId=${githubOwnerId} registrant=${registrant} at=${registeredAt}`
195-
);
196-
}
197-
});
180+
async function requestRequiredGithubOidcToken(audience: string): Promise<string> {
181+
try {
182+
return await requestGithubOidcToken(audience);
183+
} catch (err) {
184+
die(err);
185+
}
186+
}
187+
188+
function parseRequiredJwt(token: string): ReturnType<typeof parseJwt> {
189+
try {
190+
return parseJwt(token);
191+
} catch (err) {
192+
die(err);
193+
}
194+
}
195+
196+
function readRegisterWorkflowTemplate(): string {
197+
try {
198+
return readFileSync(REGISTER_WORKFLOW_TEMPLATE_PATH, "utf8");
199+
} catch (err) {
200+
die(err);
201+
}
202+
}
203+
204+
function loadAbi(): Abi {
205+
try {
206+
return importAbi();
207+
} catch (err) {
208+
die(err);
209+
}
210+
}
211+
212+
function die(err: any): never {
213+
let error = "unknown error";
214+
if (err instanceof Error) error = err.message;
215+
console.error(`${COMMAND_NAME}: ${error}; exiting.`);
216+
exit(1);
217+
}
198218

219+
const program = buildProgram();
199220
program.parseAsync(process.argv);

0 commit comments

Comments
 (0)