@finchip.ai/foga 0.6.0-alpha.5 → 0.6.0-alpha.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli-cell.js CHANGED
@@ -6,6 +6,7 @@ import { cellInit } from "./node/init.js";
6
6
  import { KeyIsolationError, forbiddenVariables, loadOperatorKey, loadRoleKey } from "./node/keys.js";
7
7
  import { cellMigrate } from "./node/migrate.js";
8
8
  import { Node } from "./node/node.js";
9
+ import { loadWallet, walletKey } from "./node/wallet.js";
9
10
  import { Watchers } from "./node/watchers.js";
10
11
  import { defaultCatalog, OPERATOR_CALLER, PUBLIC_CALLER } from "./tools/index.js";
11
12
  import { exists, repoRoot } from "./util/fs.js";
@@ -20,23 +21,37 @@ import { fail } from "./util/log.js";
20
21
  export const CELL_USAGE = `
21
22
  foga cell list [--public] [--as-json] the capabilities (action, visibility, principal, modes) as the operator or as the public sees them
22
23
  foga cell schema <capability> the input and output JSON Schema of a capability
23
- foga cell new --name <cell> --world <world.json> --store <ops.sqlite> --owner <addr> --operator <addr> --guardian <addr> --out <cell.config.json> [--rpc <url>] [--skills <dir>] [--input-dir <dir>]
24
- foga cell init --config <cell.config.json> [--rpc <url>] [--capital-wei <n>]
25
- the cell's birth (the owner's key: FOGA_OWNER_KEY[_FILE]); writes the account, agent id, binding, record and unit into the config
24
+ foga cell new --name <cell> --world <world.json> --store <ops.sqlite> --out <cell.config.json> (--wallet <dir> | --owner <addr> --operator <addr> --guardian <addr>) [--rpc <url>] [--skills <dir>] [--input-dir <dir>]
25
+ foga cell init --config <cell.config.json> [--wallet <dir>] [--rpc <url>] [--capital-wei <n>]
26
+ the cell's birth (the owner's key: the wallet's, or FOGA_OWNER_KEY[_FILE]); writes the account, agent id, binding, record and unit into the config
26
27
  foga cell migrate --config <cell.config.json> --to <new.config.json> [--store <new ops.sqlite>] [--operator <addr>] [--capital-wei <n>] [--no-sweep]
27
28
  move the cell to a new revision-2 account (the owner's key); the new config and store are written beside
28
29
  foga cell status --config <cell.config.json> cell_status as the operator
29
- foga cell <capability> --config <cell.config.json> [--mode simulate|build|act] [--json '<input json>' | --json-file <f>] [--<field> <value> ...]
30
- any capability; an act loads FOGA_OPERATOR_KEY[_FILE], an admin capability FOGA_OWNER_KEY[_FILE] or FOGA_GUARDIAN_KEY[_FILE]
30
+ foga cell docs build --config <cell.config.json> [--rpc <url>] [--out <dir>]
31
+ the cell's documents from its configuration and the chain (registration, AID document, ARD descriptor, AUF unit,
32
+ agent card, server.json, manifest) under runtime.http.documentsDir — what the node serves at /documents and /.well-known
33
+ foga cell docs publish --config <cell.config.json> [--wallet <dir>] [--rpc <url>]
34
+ build, then commit on chain what differs (setAgentURI, setDocumentURI, updateDescriptor, publishVersion; the operator
35
+ key, one keyed job), then read every commitment back
36
+ foga cell docs status --config <cell.config.json> [--rpc <url>] what is built, what the chain holds, what a publish would commit
37
+ foga cell <capability> --config <cell.config.json> [--wallet <dir>] [--mode simulate|build|act] [--json '<input json>' | --json-file <f>] [--<field> <value> ...]
38
+ any capability; an act loads the operator key, an admin capability the owner's or the guardian's — from --wallet, or from
39
+ FOGA_OPERATOR_KEY[_FILE], FOGA_OWNER_KEY[_FILE], FOGA_GUARDIAN_KEY[_FILE]
31
40
  [--caller <id> --level read|build|act] call as another caller (what the public or an API-key holder would get)
32
41
  foga cell owner <capability> … | foga cell guardian <capability> … the same, said explicitly
42
+ foga node <command> … [--wallet <dir>] every node command takes the operator key from the wallet's operator.key (= FOGA_OPERATOR_KEY_FILE)
43
+ foga node preflight --config <cell.config.json> [--wallet <dir>] [--rpc <url>] [--no-executors] [--no-network] [--out <report.json>] [--strict]
44
+ the readiness reconciliation before the public is served: the account and its allow-list, the operator key and its
45
+ gas, the standing, the skills' packages and executors, the documents against the chain, the public URL, the model;
46
+ advisory (exit 5 on a Fail only with --strict)
33
47
  foga node tick --config <cell.config.json> run the watchers once (heartbeat, discovery, settlement, safety) in a node environment (operator key alone)
34
48
  foga node watch --config <cell.config.json> [--interval <s>] run the watchers on the poll interval until stopped
35
49
  foga node mcp --config <cell.config.json> MCP over stdio for a local client (Claude Desktop, Claude Code, Cursor): the operator's full level with
36
50
  FOGA_OPERATOR_KEY[_FILE] in the environment, read and build without it; logs go to stderr
37
51
  foga node serve --config <cell.config.json> [--listen <host:port>] [--no-watch]
38
- the HTTP server: POST /api/<capability>, /mcp (Streamable HTTP), /healthz, / — the public reads and builds,
39
- a Bearer key acts as its grant says; the watchers run beside it
52
+ host mode: POST /api/<capability>, /mcp (Streamable HTTP), /a2a (A2A 1.0), /.well-known/agent-card.json, /documents/*,
53
+ /healthz, / — the public reads and builds, a Bearer key or a wallet signature (FOGA-EIP712) acts as its grant says;
54
+ the watchers run beside it; no model
40
55
  foga node key new --config <cell.config.json> --id <id> [--level read|build|act] [--visibility public|caller|operator] [--note <text>]
41
56
  mint an API key for the HTTP entrance: printed once, kept only as its hash in the config
42
57
  foga node run --config <cell.config.json> [--model stub|anthropic] [--listen <host:port> | --no-http] [--interval <s>] [--once | --ticks <n>] [--transcripts <dir>]
@@ -44,7 +59,7 @@ export const CELL_USAGE = `
44
59
  judges) on the poll interval; the operator key alone; anthropic needs ANTHROPIC_API_KEY and runtime.model.model
45
60
  `;
46
61
  const str = (flags, key) => (typeof flags[key] === "string" ? flags[key] : undefined);
47
- const RESERVED = new Set(["config", "mode", "json", "json-file", "caller", "level", "rpc", "public", "help"]);
62
+ const RESERVED = new Set(["config", "mode", "json", "json-file", "caller", "level", "rpc", "public", "help", "wallet"]);
48
63
  function inputFromFlags(flags) {
49
64
  let input = {};
50
65
  const inline = str(flags, "json");
@@ -81,7 +96,26 @@ function callerFromFlags(flags) {
81
96
  fail(`--level must be read, build, act or admin`);
82
97
  return { id: id ?? "caller", entrance: "cli", level: lvl, visibility: lvl === "admin" ? "operator" : id ? "caller" : "public" };
83
98
  }
84
- function keysFor(role, strict = false) {
99
+ /** The wallet `--wallet <dir>` names, or null without the flag. */
100
+ function walletFromFlags(flags) {
101
+ const dir = str(flags, "wallet");
102
+ if (!dir)
103
+ return null;
104
+ const w = loadWallet(resolve(dir));
105
+ if (!w)
106
+ fail(`${resolve(dir)} holds no wallet (no wallet.json); foga wallet new --dir ${resolve(dir)} makes one`);
107
+ return w;
108
+ }
109
+ /** A role's key: from the wallet when --wallet is given, from the environment otherwise. */
110
+ function keysFor(role, strict = false, wallet = null) {
111
+ if (wallet) {
112
+ try {
113
+ return walletKey(wallet, role);
114
+ }
115
+ catch (e) {
116
+ fail(e.message);
117
+ }
118
+ }
85
119
  try {
86
120
  return (role === "operator" && strict ? loadOperatorKey() : loadRoleKey(role)).privateKey;
87
121
  }
@@ -120,7 +154,8 @@ export async function cellCommand(cmd, rest, flags) {
120
154
  return print({ name: cap.name, title: cap.title, description: cap.description, action: cap.action, visibility: cap.visibility, principal: cap.principal, ercs: cap.ercs, input: z.toJSONSchema(cap.input, { target: "draft-2020-12", io: "input" }), output: z.toJSONSchema(cap.output, { target: "draft-2020-12", io: "output" }) });
121
155
  }
122
156
  if (cmd === "new") {
123
- const need = (k) => str(flags, k) ?? fail(`--${k} is required`);
157
+ const wallet = walletFromFlags(flags);
158
+ const need = (k) => str(flags, k) ?? (wallet && wallet.addresses[k] ? wallet.addresses[k] : fail(`--${k} is required (or --wallet <dir> with a ${k} key)`));
124
159
  const out = resolve(need("out"));
125
160
  if (exists(out))
126
161
  fail(`${out} exists; choose another --out (a config is never overwritten)`);
@@ -137,8 +172,9 @@ export async function cellCommand(cmd, rest, flags) {
137
172
  const configPath = str(flags, "config") ?? fail("--config <cell.config.json> is required");
138
173
  const rpc = str(flags, "rpc");
139
174
  const log = (line) => console.error(line);
175
+ const wallet = walletFromFlags(flags);
140
176
  if (cmd === "init") {
141
- const owner = keysFor("owner");
177
+ const owner = keysFor("owner", false, wallet);
142
178
  const node = await Node.open({ configPath, rpc, keys: { owner }, allowUnborn: true, log });
143
179
  try {
144
180
  const capital = str(flags, "capital-wei");
@@ -151,7 +187,7 @@ export async function cellCommand(cmd, rest, flags) {
151
187
  return;
152
188
  }
153
189
  if (cmd === "migrate") {
154
- const owner = keysFor("owner");
190
+ const owner = keysFor("owner", false, wallet);
155
191
  const to = resolve(str(flags, "to") ?? fail("--to <new.config.json> is required"));
156
192
  if (exists(to))
157
193
  fail(`${to} exists; choose another --to`);
@@ -166,6 +202,72 @@ export async function cellCommand(cmd, rest, flags) {
166
202
  }
167
203
  return;
168
204
  }
205
+ if (cmd === "docs") {
206
+ const sub = rest[0];
207
+ const { buildDocuments, readBack, documentsDir, onChainState, readBuilt, publishSteps } = await import("./node/documents.js");
208
+ const { version } = await import("./util/version.js");
209
+ if (sub === "build") {
210
+ const node = await Node.open({ configPath, rpc, keys: {}, log });
211
+ try {
212
+ const built = await buildDocuments(node, { version, dir: str(flags, "out") });
213
+ console.error(`documents v${built.manifest.version} written to ${built.dir}:`);
214
+ for (const [file, d] of Object.entries(built.manifest.documents))
215
+ console.error(` ${file.padEnd(20)} ${d.standard.padEnd(40)} ${d.contentHash}`);
216
+ const { steps, unchanged } = await publishSteps(node, built);
217
+ console.error(steps.length ? `a publish would commit: ${steps.map((x) => x.label).join("; ")}` : "every commitment on chain matches what was built");
218
+ if (unchanged.length)
219
+ console.error(`already as built: ${unchanged.join(", ")}`);
220
+ print(built.manifest);
221
+ }
222
+ finally {
223
+ node.close();
224
+ }
225
+ return;
226
+ }
227
+ if (sub === "status") {
228
+ const node = await Node.open({ configPath, rpc, keys: {}, log });
229
+ try {
230
+ const dir = documentsDir(node);
231
+ const built = readBuilt(dir);
232
+ const rows = built ? await readBack(node, built) : [];
233
+ print({ dir, manifest: built?.manifest ?? null, onChain: await onChainState(node), readBack: rows, pending: built ? (await publishSteps(node, built)).steps.map((x) => x.label) : [] });
234
+ if (rows.some((r) => r.ok === false))
235
+ process.exitCode = 2;
236
+ }
237
+ finally {
238
+ node.close();
239
+ }
240
+ return;
241
+ }
242
+ if (sub === "publish") {
243
+ const node = await Node.open({ configPath, rpc, keys: { operator: keysFor("operator", false, wallet) }, log });
244
+ try {
245
+ const result = await catalog.invoke(node, "docs_publish", {}, OPERATOR_CALLER, { mode: "act" });
246
+ if (!result.ok) {
247
+ print(result);
248
+ process.exitCode = 2;
249
+ return;
250
+ }
251
+ const out = result.output;
252
+ console.error(`documents v${out.version} in ${out.dir}: ${out.committed.length ? `committed ${out.committed.join(", ")}` : "nothing to commit"}${out.unchanged.length ? `; already as built: ${out.unchanged.join(", ")}` : ""} (job ${out.jobId}, ${out.operations.length} operation(s))`);
253
+ // the read-back from here fetches the URLs too, when the node serves them
254
+ const built = readBuilt(out.dir);
255
+ const rows = await readBack(node, built, { fetchUrls: true });
256
+ for (const r of rows)
257
+ console.error(` ${r.ok === true ? "ok " : r.ok === false ? "FAIL " : "unread "} ${r.commitment.padEnd(26)} ${r.detail}`);
258
+ print({ ...out, readBack: rows });
259
+ if (rows.some((r) => r.ok === false)) {
260
+ console.error("a commitment read back from the chain does not match what was built: the publish is not complete");
261
+ process.exitCode = 2;
262
+ }
263
+ }
264
+ finally {
265
+ node.close();
266
+ }
267
+ return;
268
+ }
269
+ fail("foga cell docs build | publish | status");
270
+ }
169
271
  // a capability, possibly said as "owner <capability>" / "guardian <capability>"
170
272
  let name = cmd;
171
273
  let explicitRole = null;
@@ -186,10 +288,10 @@ export async function cellCommand(cmd, rest, flags) {
186
288
  if (cap.action !== "read" && mode === "act") {
187
289
  if (cap.action === "admin") {
188
290
  const role = cap.signer ?? explicitRole ?? "owner";
189
- keys[role] = keysFor(role);
291
+ keys[role] = keysFor(role, false, wallet);
190
292
  }
191
293
  else
192
- keys.operator = keysFor("operator");
294
+ keys.operator = keysFor("operator", false, wallet);
193
295
  }
194
296
  const node = await Node.open({ configPath, rpc, keys, log });
195
297
  try {
@@ -230,6 +332,32 @@ export async function nodeCommand(cmd, rest, flags) {
230
332
  }
231
333
  const configPath = str(flags, "config") ?? fail("--config <cell.config.json> is required");
232
334
  const log = (line) => console.error(line);
335
+ // --wallet <dir>: the operator key file of the wallet, as FOGA_OPERATOR_KEY_FILE would name it; never beside another key source
336
+ const wallet = walletFromFlags(flags);
337
+ if (wallet) {
338
+ if (process.env.FOGA_OPERATOR_KEY || process.env.FOGA_OPERATOR_KEY_FILE)
339
+ fail("--wallet and FOGA_OPERATOR_KEY[_FILE] are both set; a node takes its operator key from one place");
340
+ if (!wallet.files.operator)
341
+ fail(`wallet ${wallet.dir} has no operator key`);
342
+ process.env.FOGA_OPERATOR_KEY_FILE = wallet.files.operator;
343
+ }
344
+ if (cmd === "preflight") {
345
+ const { preflight, printPreflight } = await import("./node/preflight.js");
346
+ const { writeJson } = await import("./util/fs.js");
347
+ const node = await Node.open({ configPath, rpc: str(flags, "rpc"), keys: {}, log });
348
+ try {
349
+ const report = await preflight(node, { executors: !flags["no-executors"], network: !flags["no-network"] });
350
+ printPreflight(report, (line) => console.log(line));
351
+ if (str(flags, "out"))
352
+ writeJson(resolve(str(flags, "out")), report);
353
+ if (flags.strict && report.summary.Fail > 0)
354
+ process.exitCode = 5;
355
+ }
356
+ finally {
357
+ node.close();
358
+ }
359
+ return;
360
+ }
233
361
  if (cmd === "key") {
234
362
  if (rest[0] !== "new")
235
363
  fail("foga node key new --config <cfg> --id <id> [--level act] [--visibility caller]");
@@ -281,7 +409,10 @@ export async function nodeCommand(cmd, rest, flags) {
281
409
  const { version } = await import("./util/version.js");
282
410
  const listen = str(flags, "listen") ?? node.config.runtime.http.listen;
283
411
  const running = await createHttpServer({ node, catalog, version, log }).listen(listen);
284
- log(`foga ${version}: serving cell ${node.config.name}${node.cell ? ` (${node.cell.address})` : ""} at ${running.url} — /mcp, /api/<capability>, /healthz; ${keys.operator ? "acting with the operator key" : "no operator key: reads and builds only"}; ${node.config.access.keys.length} API key(s) configured`);
412
+ const { documentsDir, readBuilt } = await import("./node/documents.js");
413
+ const built = readBuilt(documentsDir(node));
414
+ const signers = node.config.access.signers;
415
+ log(`foga ${version}: serving cell ${node.config.name}${node.cell ? ` (${node.cell.address})` : ""} at ${running.url} — /mcp, /api/<capability>, /a2a, /.well-known/agent-card.json, /documents/*, /healthz; ${keys.operator ? "acting with the operator key" : "no operator key: reads and builds only"}; ${node.config.access.keys.length} API key(s) configured; signatures: ${signers.default} by default, ${signers.allow.length} allowed${signers.standing ? `, standing level ${signers.standing.minLevel}+ granted ${signers.standing.level}` : ""}; documents ${built ? `v${built.manifest.version}` : "not built (foga cell docs build)"}`);
285
416
  const stopWatch = !flags["no-watch"] && keys.operator && node.cell ? startWatchLoop(new Watchers(node, catalog), node.config.runtime.pollSeconds * 1000, log) : () => { };
286
417
  const stop = () => {
287
418
  stopWatch();
@@ -363,7 +494,7 @@ export async function nodeCommand(cmd, rest, flags) {
363
494
  return;
364
495
  }
365
496
  if (cmd !== "tick" && cmd !== "watch")
366
- fail(`unknown node command ${cmd}; tick, watch, mcp, serve, run or key`);
497
+ fail(`unknown node command ${cmd}; tick, watch, mcp, serve, run, preflight or key`);
367
498
  // a node environment: the operator key alone, nothing that derives another role
368
499
  const operator = keysFor("operator", true);
369
500
  const node = await Node.open({ configPath, rpc: str(flags, "rpc"), keys: { operator }, log });
package/dist/cli.js CHANGED
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
- import { spawn } from "node:child_process";
3
- import { closeSync, mkdirSync, openSync, readFileSync, writeFileSync } from "node:fs";
4
- import { createConnection } from "node:net";
2
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
5
3
  import { cpus, homedir, loadavg } from "node:os";
6
4
  import { dirname, join, relative, resolve } from "node:path";
7
5
  import { buildBundle } from "./build/bundle.js";
8
6
  import { syncVendor, verifyVendor } from "./build/vendor.js";
7
+ import { anvilBinary, launchAnvil, portProblem } from "./demo/anvil.js";
8
+ import { LOOP_D_MODELS } from "./demo/loop-d.js";
9
9
  import { runDoctor } from "./doctor/doctor.js";
10
- import { ensureDir, exists, readJson, repoRoot, writeJson } from "./util/fs.js";
10
+ import { exists, readJson, repoRoot, writeJson } from "./util/fs.js";
11
11
  import { fail, log } from "./util/log.js";
12
12
  import { attachWorld, deployWorld, loadWorldConfig, missingPlanSteps } from "./world/deploy.js";
13
13
  import { loadBundle } from "./bundle/load.js";
@@ -21,6 +21,16 @@ usage:
21
21
  deploy-mode config: the whole stack; attach-mode config: attach what it
22
22
  lists and deploy the reference contracts it does not (deployer = W)
23
23
  foga world attach --world <config.json> [--out <file>] address book only, no transactions (every contract listed)
24
+ foga world verify --world <world.json> --sourcify [--dry-run] [--only <id,id>] [--server <url>] [--out <dir>] [--build-info <dir>]
25
+ submit every contract the world deployed from the bundle to Sourcify, with the standard JSON input of its
26
+ compiler run (.foga-build/out/<repo>/build-info, written by npm run bundle:verify); a local chain is a dry run
27
+ foga wallet new [--name <n> | --dir <dir>] [--single] the first step: a wallet of your own — owner, operator and guardian keys in 0600 files
28
+ (default ~/.foga/wallet; --name under ~/.foga/wallets/<n>; --single: one signer key). Never overwrites; prints the addresses
29
+ foga wallet show [--name <n> | --dir <dir>] [--rpc <url>] the wallet's addresses (and balances with an RPC)
30
+ foga wallet sign (--config <cell.config.json> | --url <node url> | --cell <account> --chain-id <n>) [--name <n> | --dir <dir>]
31
+ [--role signer|operator|owner|guardian] [--ttl <s>] [--agent-id <id>] [--method <METHOD> --path <path> [--body '<json>' | --body-file <f>]]
32
+ an Authorization header (FOGA-EIP712) proving the wallet to that cell — named by its configuration, by its node's
33
+ URL (/healthz says which account and chain) or by hand: a session envelope, or one bound to a request
24
34
  foga keys new [--out <file>] write a fresh 12-word phrase (default ~/.foga/genesis.mnemonic) and print the role addresses
25
35
  foga keys show print the role addresses the environment derives (FOGA_MNEMONIC[_FILE], FOGA_KEY_*)
26
36
  foga keys export --role <role> [--out <file>] write one role's key to a 0600 file for a node that holds that key alone
@@ -38,12 +48,18 @@ usage:
38
48
  [--zk auto|require|skip] [--crash-after <opKey>] [--crash-before <opKey>]
39
49
  run the genesis scenario (idempotent; re-run to resume)
40
50
  foga genesis status --world <world.json> [--store <file.sqlite>] list the operations the store holds
51
+ foga quickstart [--dir <dir>] [--wallet <dir>] [--port <n>] [--listen <host:port> | --no-serve] [--name <cell>] [--no-tenders] [--exit]
52
+ one command to a live cell: the wallet (made on the first run, reused after), anvil, the local world, a cell born with
53
+ the wallet's keys and its two skills, two tenders, the node serving HTTP and MCP, and the MCP client configurations
54
+ printed with every path filled in (~/.foga/quickstart)
41
55
  foga demo [--port <n>] [--keep] [--genesis <T0..T9>] start anvil, deploy the local world, run the doctor (and the genesis)
42
- foga demo --loop-d [--model stub|anthropic] [--model-name <name>] [--seed <n>] [--no-recovery] [--port <n>] [--keep]
43
- the above, then Loop D: a new cell born, a demander minting cases a-g, the embedded node with a model,
44
- the recovery drill, the guardian's suspension; evidence to deployments/loop-d.local.json, judged by the doctor
45
- foga loop-d run --world <world.json> --rpc <url> [--dir <dir>] [--out <evidence.json>] [--model stub|anthropic] [--model-name <name>] [--seed <n>] [--no-recovery]
46
- Loop D on a world that exists (FOGA_FUNDER_KEY funds the cell's roles on a non-local chain)
56
+ foga demo --loop-d [--model stub|anthropic|external] [--model-name <name>] [--seed <n>] [--no-recovery] [--port <n>] [--keep]
57
+ the above, then Loop D: a new cell born, a demander minting cases a-g, the node with a model (or served for an
58
+ outside agent), the recovery drill, the guardian's suspension; evidence to deployments/loop-d.local.json, judged by the doctor
59
+ foga loop-d run --world <world.json> --rpc <url> [--dir <dir>] [--out <evidence.json>] [--model stub|anthropic|external] [--model-name <name>] [--seed <n>] [--no-recovery]
60
+ [--listen <host:port>] [--wait <s>] [--idle <s>]
61
+ Loop D on a world that exists (FOGA_FUNDER_KEY funds the cell's roles on a non-local chain); with --model external the
62
+ node is served and an outside agent — any model, any runtime — works the tenders through /mcp or /api with the key printed
47
63
  foga demo demander --world <w> --rpc <url> --store <sqlite> --cell <addr> --scheme <id> --issuer <addr> --probe-hash <h> --extract-hash <h> --cases a,b,c --seed <n> --out <json>
48
64
  the independent demander of Loop D (FOGA_DEMANDER_KEY; anvil's first account by default)
49
65
  foga bundle info summarize the committed bundle
@@ -144,6 +160,21 @@ async function main() {
144
160
  }
145
161
  return;
146
162
  }
163
+ if (group === "world" && cmd === "verify") {
164
+ if (!flags.sourcify)
165
+ fail("foga world verify --sourcify --world <world.json> (Sourcify is the one verifier wired so far)");
166
+ const worldPath = str(flags, "world") ?? fail("--world <world.json> is required (a deployed world)");
167
+ const { verifyWorld } = await import("./world/sourcify.js");
168
+ const { report, outDir } = await verifyWorld({ worldPath: resolve(worldPath), server: str(flags, "server"), dryRun: flags["dry-run"] ? true : undefined, only: str(flags, "only")?.split(","), outDir: str(flags, "out") ? resolve(str(flags, "out")) : undefined, buildInfoRoot: str(flags, "build-info") ? resolve(str(flags, "build-info")) : undefined, log: (line) => console.error(line) });
169
+ for (const r of report.results)
170
+ console.log(` ${r.outcome.padEnd(12)} ${r.id.padEnd(32)} ${r.address} ${r.contractIdentifier} (${r.compilerVersion})${r.outcome === "dry-run" ? "" : ` ${r.detail}`}`);
171
+ for (const m of report.missing)
172
+ console.log(` missing ${m.id.padEnd(32)} ${m.reason}`);
173
+ console.log(`sourcify: ${Object.entries(report.summary).map(([k, v]) => `${v} ${k}`).join(", ") || "nothing to do"}${report.missing.length ? `, ${report.missing.length} missing` : ""}; report ${relative(root, join(outDir, "report.json"))}`);
174
+ if (report.missing.length || report.results.some((r) => r.outcome === "failed" || r.outcome === "error"))
175
+ process.exitCode = 3;
176
+ return;
177
+ }
147
178
  if (group === "world" && (cmd === "deploy" || cmd === "attach")) {
148
179
  const worldConfigPath = str(flags, "world");
149
180
  if (!worldConfigPath)
@@ -212,6 +243,113 @@ async function main() {
212
243
  process.stdout.write(RELAY_SCRIPT);
213
244
  return;
214
245
  }
246
+ if (group === "wallet") {
247
+ const { createWallet, loadWallet, walletDir, walletFileProblems } = await import("./node/wallet.js");
248
+ const dir = resolve(str(flags, "dir") ?? walletDir(str(flags, "name")));
249
+ const printWallet = async (w, made) => {
250
+ console.log(`${made ? "wallet made at" : "wallet"} ${w.dir}${w.name !== "default" ? ` (${w.name})` : ""}, ${w.createdAt}`);
251
+ const rpc = str(flags, "rpc") ?? process.env.FOGA_RPC_URL;
252
+ const provider = rpc ? new (await import("ethers")).JsonRpcProvider(rpc) : null;
253
+ for (const role of w.roles) {
254
+ let balance = "";
255
+ if (provider) {
256
+ try {
257
+ balance = ` ${(await import("ethers")).formatEther(await provider.getBalance(w.addresses[role]))} ETH`;
258
+ }
259
+ catch {
260
+ balance = " (balance: RPC did not answer)";
261
+ }
262
+ }
263
+ console.log(` ${role.padEnd(9)} ${w.addresses[role]}${balance} ${w.files[role]}`);
264
+ }
265
+ provider?.destroy();
266
+ for (const p of walletFileProblems(w))
267
+ log.warn(p);
268
+ if (w.roles.includes("owner"))
269
+ console.log(`fund the owner for a cell's birth (capital and gas) and the operator for its gas; then: foga cell new --wallet ${dir} … and foga cell init --config <cfg> --wallet ${dir}`);
270
+ };
271
+ if (cmd === "new") {
272
+ const existing = loadWallet(dir);
273
+ if (existing) {
274
+ await printWallet(existing, false);
275
+ console.log("(already there; a wallet is never overwritten — foga wallet new --name <n> makes another)");
276
+ return;
277
+ }
278
+ try {
279
+ await printWallet(createWallet(dir, { name: str(flags, "name") ?? "default", roles: flags.single ? ["signer"] : undefined }), true);
280
+ }
281
+ catch (e) {
282
+ fail(e.message);
283
+ }
284
+ return;
285
+ }
286
+ if (cmd === "show") {
287
+ const w = loadWallet(dir);
288
+ if (!w)
289
+ fail(`no wallet at ${dir}; foga wallet new makes one`);
290
+ await printWallet(w, false);
291
+ return;
292
+ }
293
+ if (cmd === "sign") {
294
+ const w = loadWallet(dir);
295
+ if (!w)
296
+ fail(`no wallet at ${dir}; foga wallet new makes one`);
297
+ const { walletKey } = await import("./node/wallet.js");
298
+ const { loadCellConfig, configPath } = await import("./node/config.js");
299
+ const { signAuthorization } = await import("./http/signed.js");
300
+ const { Wallet } = await import("ethers");
301
+ // the cell the signature is for: its own configuration (the operator's shell), its node's URL (anyone's: /healthz names the account and the chain), or both values by hand
302
+ let cellAddress;
303
+ let chainId;
304
+ let cellName;
305
+ if (str(flags, "config")) {
306
+ const loaded = loadCellConfig(str(flags, "config"));
307
+ cellAddress = loaded.config.identity.account ?? fail("the config names no account yet (the cell is not born)");
308
+ chainId = readJson(configPath(loaded, loaded.config.world)).chainId;
309
+ cellName = loaded.config.name;
310
+ }
311
+ else if (str(flags, "url")) {
312
+ const base = str(flags, "url").replace(/\/+$/, "");
313
+ let health;
314
+ try {
315
+ health = (await (await fetch(`${base}/healthz`, { signal: AbortSignal.timeout(10_000) })).json());
316
+ }
317
+ catch (e) {
318
+ fail(`${base}/healthz did not answer: ${e.message}`);
319
+ }
320
+ if (!health.account || !health.chainId)
321
+ fail(`${base}/healthz names no account or chain (${JSON.stringify(health).slice(0, 200)}); the cell is not born or this is not a FOGA node`);
322
+ cellAddress = health.account;
323
+ chainId = health.chainId;
324
+ cellName = health.cell ?? base;
325
+ }
326
+ else if (str(flags, "cell") && str(flags, "chain-id")) {
327
+ cellAddress = str(flags, "cell");
328
+ chainId = Number(str(flags, "chain-id"));
329
+ cellName = cellAddress;
330
+ }
331
+ else
332
+ fail("the signature is made for one cell: --config <cell.config.json>, or --url <its node's URL>, or --cell <account> --chain-id <n>");
333
+ const role = (str(flags, "role") ?? (w.roles.includes("signer") ? "signer" : "operator"));
334
+ let key;
335
+ try {
336
+ key = walletKey(w, role);
337
+ }
338
+ catch (e) {
339
+ fail(e.message);
340
+ }
341
+ const method = str(flags, "method");
342
+ const path = str(flags, "path");
343
+ if ((method && !path) || (!method && path))
344
+ fail("--method and --path go together");
345
+ const bodyText = str(flags, "body") ?? (str(flags, "body-file") ? readFileSync(resolve(str(flags, "body-file")), "utf8") : undefined);
346
+ const header = await signAuthorization(new Wallet(key), { chainId, cell: cellAddress, ttlSeconds: str(flags, "ttl") ? Number(str(flags, "ttl")) : undefined, agentId: str(flags, "agent-id"), request: method ? { method, path: path, body: bodyText ?? null } : undefined });
347
+ console.error(`${role} ${new Wallet(key).address} signed ${method ? `a ${method} ${path} envelope` : "a session envelope"} for cell ${cellName} (${cellAddress}, chain ${chainId}); the header value follows (one line):`);
348
+ console.log(header);
349
+ return;
350
+ }
351
+ fail("foga wallet new | show | sign");
352
+ }
215
353
  if (group === "keys" && cmd === "export") {
216
354
  const { ROLE_ORDER, envKeys, roleAddresses } = await import("./genesis/roles.js");
217
355
  const role = str(flags, "role");
@@ -350,33 +488,54 @@ async function main() {
350
488
  const { runLoopD, defaultLoopDDir } = await import("./demo/loop-d.js");
351
489
  const worldPath = str(flags, "world") ?? fail("--world <world.json> is required");
352
490
  const rpc = str(flags, "rpc") ?? process.env.FOGA_RPC_URL ?? fail("--rpc <url> is required");
353
- const model = (str(flags, "model") ?? "stub");
354
- if (model !== "stub" && model !== "anthropic")
355
- fail("--model must be stub or anthropic");
356
- await runLoopD({ rpc, worldPath: resolve(worldPath), dir: str(flags, "dir") ? resolve(str(flags, "dir")) : defaultLoopDDir(), evidencePath: resolve(str(flags, "out") ?? join(root, "deployments", "loop-d.local.json")), model, modelName: str(flags, "model-name") ?? process.env.FOGA_MODEL, seed: str(flags, "seed") ? Number(str(flags, "seed")) : undefined, recovery: !flags["no-recovery"], funderKey: process.env.FOGA_FUNDER_KEY });
491
+ await runLoopD({ rpc, worldPath: resolve(worldPath), dir: str(flags, "dir") ? resolve(str(flags, "dir")) : defaultLoopDDir(), evidencePath: resolve(str(flags, "out") ?? join(root, "deployments", "loop-d.local.json")), funderKey: process.env.FOGA_FUNDER_KEY, ...loopDFlags(flags) });
357
492
  return;
358
493
  }
494
+ if (group === "quickstart") {
495
+ const { runQuickstart } = await import("./demo/quickstart.js");
496
+ let result;
497
+ try {
498
+ result = await runQuickstart({ dir: str(flags, "dir"), wallet: str(flags, "wallet"), port: str(flags, "port") ? Number(str(flags, "port")) : undefined, listen: flags["no-serve"] ? null : str(flags, "listen"), name: str(flags, "name"), tenders: !flags["no-tenders"], exit: Boolean(flags.exit) });
499
+ }
500
+ catch (e) {
501
+ fail(e.message);
502
+ }
503
+ if (flags.exit) {
504
+ await result.stop();
505
+ return;
506
+ }
507
+ const stopping = () => {
508
+ console.error("quickstart: stopping the node and the chain");
509
+ result.stop().finally(() => process.exit(0));
510
+ };
511
+ process.on("SIGINT", stopping);
512
+ process.on("SIGTERM", stopping);
513
+ const why = await result.ended;
514
+ fail(`${why}; the quickstart is over`);
515
+ }
359
516
  if (group === "demo") {
360
- const model = (str(flags, "model") ?? "stub");
361
- if (model !== "stub" && model !== "anthropic")
362
- fail("--model must be stub or anthropic");
363
- await demo({ port: Number(str(flags, "port") ?? 8545), keep: Boolean(flags.keep), genesis: str(flags, "genesis"), loopD: flags["loop-d"] ? { model, modelName: str(flags, "model-name") ?? process.env.FOGA_MODEL, seed: str(flags, "seed") ? Number(str(flags, "seed")) : undefined, recovery: !flags["no-recovery"] } : undefined });
517
+ await demo({ port: Number(str(flags, "port") ?? 8545), keep: Boolean(flags.keep), genesis: str(flags, "genesis"), loopD: flags["loop-d"] ? loopDFlags(flags) : undefined });
364
518
  return;
365
519
  }
366
520
  console.log(USAGE);
367
521
  fail(`unknown command: ${positional.join(" ")}`);
368
522
  }
523
+ /** The Loop D options the flags name (shared by `foga demo --loop-d` and `foga loop-d run`). */
524
+ function loopDFlags(flags) {
525
+ const model = (str(flags, "model") ?? "stub");
526
+ if (!LOOP_D_MODELS.includes(model))
527
+ fail(`--model must be one of ${LOOP_D_MODELS.join(", ")}`);
528
+ const seconds = (key) => (str(flags, key) ? Number(str(flags, key)) * 1000 : undefined);
529
+ return { model, modelName: str(flags, "model-name") ?? process.env.FOGA_MODEL, seed: str(flags, "seed") ? Number(str(flags, "seed")) : undefined, recovery: !flags["no-recovery"], listen: str(flags, "listen"), waitMs: seconds("wait"), idleMs: seconds("idle") };
530
+ }
369
531
  async function demo(opts) {
370
532
  const root = repoRoot();
371
533
  const rpc = `http://127.0.0.1:${opts.port}`;
372
534
  // Say what is wrong before starting anything: a port that is already taken makes anvil exit at once, and a
373
535
  // silent exit would otherwise surface only as "the RPC did not come up".
374
- if (await portInUse(opts.port)) {
375
- const answers = await rpcAnswers(rpc);
376
- fail(answers
377
- ? `${rpc} already answers JSON-RPC (another anvil or node is running). Stop it, or pass --port <n> to use another port.`
378
- : `port ${opts.port} is in use by something that does not speak JSON-RPC. Free it, or pass --port <n>.`);
379
- }
536
+ const problem = await portProblem(opts.port);
537
+ if (problem)
538
+ fail(problem);
380
539
  // A machine that is already saturated (a proving-key build, for instance) starves anvil: blocks come late and the
381
540
  // kernel gives up on a receipt after its timeout. Say so before starting rather than after a failure.
382
541
  const cores = cpus().length || 1;
@@ -385,51 +544,27 @@ async function demo(opts) {
385
544
  log.warn(`this machine is busy (load ${load.toFixed(1)} on ${cores} core${cores === 1 ? "" : "s"}): another heavy process slows anvil down and can make the genesis time out waiting for receipts. If a proving-key build (npm run zk:predicate) is running, let it finish or stop it first.`);
386
545
  }
387
546
  // FOGA_FOUNDRY_DIR points at a Foundry install of its own (scripts/install-foundry.sh puts the static build there).
388
- const foundryDir = process.env.FOGA_FOUNDRY_DIR;
389
- const anvilBin = foundryDir ? join(foundryDir, "anvil") : "anvil";
390
547
  // anvil's output goes to a log file rather than a pipe: with --keep the node survives this process, and a
391
548
  // Rust program whose stderr pipe has gone away dies on its next write.
549
+ const { path: anvilBin, fromDir } = anvilBinary();
392
550
  const logPath = join(root, ".foga-build", "anvil.log");
393
- ensureDir(join(root, ".foga-build"));
394
- const logFd = openSync(logPath, "w");
395
- log.info(`starting anvil on ${rpc}${foundryDir ? ` (${anvilBin})` : ""}; log ${relative(root, logPath)}`);
396
- const anvil = spawn(anvilBin, ["--port", String(opts.port), "--silent"], { stdio: ["ignore", logFd, logFd], detached: opts.keep });
397
- closeSync(logFd);
398
- let exited = null;
399
- anvil.on("exit", (code, signal) => {
400
- exited = code !== null ? `exit code ${code}` : `signal ${signal}`;
401
- });
551
+ log.info(`starting anvil on ${rpc}${fromDir ? ` (${anvilBin})` : ""}; log ${relative(root, logPath)}`);
552
+ let anvil;
553
+ try {
554
+ anvil = await launchAnvil({ port: opts.port, logPath, detached: opts.keep });
555
+ }
556
+ catch (e) {
557
+ fail(e.message);
558
+ }
402
559
  const stop = () => {
403
560
  if (!opts.keep && !anvil.killed)
404
561
  anvil.kill("SIGTERM");
405
562
  };
406
- anvil.on("error", (e) => fail(e.code === "ENOENT"
407
- ? foundryDir
408
- ? `${anvilBin} not found (FOGA_FOUNDRY_DIR=${foundryDir}). Run scripts/install-foundry.sh ${foundryDir} first.`
409
- : "anvil not found on PATH. Install Foundry (https://getfoundry.sh: `curl -L https://foundry.sh | bash && foundryup`), or run scripts/install-foundry.sh and export FOGA_FOUNDRY_DIR, or use docker compose."
410
- : `could not start anvil: ${e.message}`));
411
563
  process.on("exit", stop);
412
564
  process.on("SIGINT", () => {
413
565
  stop();
414
566
  process.exit(130);
415
567
  });
416
- try {
417
- await waitForRpc(rpc, 20000, () => exited !== null);
418
- }
419
- catch (e) {
420
- let output = "";
421
- try {
422
- output = readFileSync(logPath, "utf8").slice(-8000);
423
- }
424
- catch {
425
- /* no log */
426
- }
427
- const why = exited ? `anvil stopped (${exited}) before serving ${rpc}` : `anvil did not serve ${rpc} within 20 s`;
428
- const glibc = /GLIBC_[0-9.]+' not found/.test(output)
429
- ? "\nThis anvil is the glibc build and this machine's glibc is older than it needs (GLIBC >= 2.35: Ubuntu 22.04 or newer). Install the static build next to it: `scripts/install-foundry.sh` then `export FOGA_FOUNDRY_DIR=~/.foga/foundry` (or put that directory first on PATH)."
430
- : "";
431
- fail(`${why}.${output.trim() ? `\n--- anvil output ---\n${output.trim()}\n--------------------` : ""}${glibc}\nRun \`${anvilBin} --port ${opts.port}\` by hand to see its output; \`${anvilBin} --version\` shows the installed version (CI uses Foundry v1.7.1).`);
432
- }
433
568
  const worldConfig = join(root, "worlds", "local.json");
434
569
  const out = join(root, "deployments", "local.json");
435
570
  await deployWorld({ worldConfigPath: worldConfig, rpc, outPath: out });
@@ -454,7 +589,7 @@ async function demo(opts) {
454
589
  if (opts.loopD) {
455
590
  const { runLoopD, defaultLoopDDir } = await import("./demo/loop-d.js");
456
591
  const evidencePath = join(root, "deployments", "loop-d.local.json");
457
- await runLoopD({ rpc, worldPath: out, dir: defaultLoopDDir(), evidencePath, model: opts.loopD.model, modelName: opts.loopD.modelName, seed: opts.loopD.seed, recovery: opts.loopD.recovery });
592
+ await runLoopD({ rpc, worldPath: out, dir: defaultLoopDDir(), evidencePath, ...opts.loopD });
458
593
  const judged = await runDoctor({ worldPath: out, rpc, outPath: join(root, "deployments", "local.doctor.json"), genesisPath: opts.genesis ? join(root, "deployments", "local.genesis.json") : undefined, loopDPath: evidencePath, quiet: true });
459
594
  if (judged.report && judged.report.summary.Fail > 0)
460
595
  process.exitCode = 3;
@@ -467,39 +602,6 @@ async function demo(opts) {
467
602
  else
468
603
  stop();
469
604
  }
470
- /** True when something accepts TCP connections on 127.0.0.1:port. */
471
- function portInUse(port) {
472
- return new Promise((resolve) => {
473
- const socket = createConnection({ host: "127.0.0.1", port });
474
- const done = (v) => {
475
- socket.destroy();
476
- resolve(v);
477
- };
478
- socket.setTimeout(1000, () => done(false));
479
- socket.once("connect", () => done(true));
480
- socket.once("error", () => done(false));
481
- });
482
- }
483
- async function rpcAnswers(rpc) {
484
- try {
485
- const res = await fetch(rpc, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_chainId", params: [] }), signal: AbortSignal.timeout(2000) });
486
- return res.ok;
487
- }
488
- catch {
489
- return false;
490
- }
491
- }
492
- async function waitForRpc(rpc, timeoutMs, giveUp) {
493
- const start = Date.now();
494
- while (Date.now() - start < timeoutMs) {
495
- if (await rpcAnswers(rpc))
496
- return;
497
- if (giveUp?.())
498
- break;
499
- await new Promise((r) => setTimeout(r, 250));
500
- }
501
- throw new Error(`RPC at ${rpc} did not come up within ${timeoutMs} ms`);
502
- }
503
605
  main().catch((e) => {
504
606
  fail(e?.stack ?? String(e));
505
607
  });