@fangorn-network/westmarch 2026.10.22-dev → 2026.10.26-dev

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fangorn-network/westmarch",
3
- "version": "2026.10.22-dev",
3
+ "version": "2026.10.26-dev",
4
4
  "description": "all-in-one builder for fangorn apps",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -56,7 +56,7 @@
56
56
  }
57
57
  },
58
58
  "devDependencies": {
59
- "@fangorn-network/sdk": "^2026.10.21-dev",
59
+ "@fangorn-network/sdk": "^2026.10.26-dev",
60
60
  "@huggingface/transformers": "^4.2.0",
61
61
  "@noble/curves": "^1.9.1",
62
62
  "@privy-io/react-auth": "^3.47.0",
package/site/worker.js CHANGED
@@ -455,6 +455,9 @@ async function paidCall(origin, tool, payment, run) {
455
455
  return { ...result, _meta: { ...result._meta, "x402/payment-response": settled } };
456
456
  }
457
457
 
458
+ export const ORIGIN = "https://origin.westmarch.invalid";
459
+ const BY_ORIGIN = new Set(["/.well-known/agent-card.json", "/openapi.json", "/.well-known/x402"]);
460
+
458
461
  const worker = {
459
462
  async fetch(request, env) {
460
463
  CONFIG = env.CONFIG ?? CONFIG;
@@ -473,7 +476,14 @@ const worker = {
473
476
  if (p === "/paid" && request.method === "POST") return CONFIG.paid ? sellBatch(request, env, url) : json({ error: "nothing is sold here" }, 404);
474
477
  if (p.startsWith("/paid/")) return CONFIG.paid ? sell(request, env, url) : json({ error: "nothing is sold here" }, 404);
475
478
 
476
- const res = await env.ASSETS.fetch(request);
479
+ let res = await env.ASSETS.fetch(request);
480
+ // A site built before its URL was known (`westmarch build`, under the deploy button) names
481
+ // ORIGIN wherever it means its own: it is wherever it is served from.
482
+ if (res.ok && BY_ORIGIN.has(p)) {
483
+ const body = (await res.text()).replaceAll(ORIGIN, url.origin);
484
+ res = new Response(body, { status: res.status, headers: res.headers });
485
+ res.headers.delete("content-length");
486
+ }
477
487
  const out = new Response(res.body, res);
478
488
  if (p === "/.well-known/agent-card.json") out.headers.set("access-control-allow-origin", "*");
479
489
  if (p.startsWith("/assets/")) out.headers.set("cache-control", "public, max-age=31536000, immutable");
@@ -511,7 +521,7 @@ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv
511
521
  const calls = [];
512
522
  const AI = { run: async (model, { text }) => { calls.push({ model, text });
513
523
  return { data: text.map((t) => { const v = new Array(768).fill(0); for (const w of t.toLowerCase().split(/\W+/)) if (w.length > 3) v[[...w].reduce((h, c) => (h * 31 + c.charCodeAt(0)) % 256, 7)] += 1; return v; }) }; } };
514
- const env = { VIEW, AI, ASSETS: { fetch: async () => new Response("<!doctype html>site") },
524
+ const env = { VIEW, AI, ASSETS: { fetch: async (r) => new Response(new URL(r.url).pathname.endsWith("agent-card.json") ? `{"url":"${ORIGIN}/"}` : "<!doctype html>site") },
515
525
  CONFIG: { app: "shop", name: "Shop", description: "Things for a room.", repos: [] } };
516
526
  CONFIG = env.CONFIG;
517
527
 
@@ -540,6 +550,8 @@ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv
540
550
  eq((await (await worker.fetch(new Request("https://shop.test/mcp", { method: "POST", body: '{"jsonrpc":"2.0","method":"notifications/initialized"}' }), env)).status), 202, "a notification is accepted, not answered");
541
551
 
542
552
  const cat = await worker.fetch(new Request("https://shop.test/view/cdn/catalog"), env);
553
+ const card = await worker.fetch(new Request("https://shop.example.workers.dev/.well-known/agent-card.json"), env);
554
+ eq([await card.json(), card.headers.get("access-control-allow-origin")], [{ url: "https://shop.example.workers.dev/" }, "*"], "a card built before its URL was known names the origin it is served from");
543
555
  eq([cat.status, cat.headers.get("access-control-allow-origin"), (await cat.json()).domains[0].link_hosts], [200, "*", ["docs.example"]], "the view is served from R2, with the hosts its records link to");
544
556
  eq((await worker.fetch(new Request("https://shop.test/doc?u=https://evil.example/x.pdf"), env)).status, 403, "/doc refuses a host no record links to");
545
557
  eq((await worker.fetch(new Request("https://shop.test/view/cdn/nope"), env)).status, 404, "a missing file is a 404");
@@ -1,12 +1,14 @@
1
1
  #!/usr/bin/env node
2
- // A new app from the template: its recipe (app.json, eval/, ci/), no data.
2
+ // A new app from the template: its recipe (app.json, wrangler.json, eval/), no data.
3
3
  // npx @fangorn-network/westmarch init <app> [dir] [--no-install]
4
+ // westmarch build the deploy button's build step (ship.js buildForDeploy)
4
5
  // The app's data is what its members commit; the repo this makes can be public.
5
6
  import { cpSync, existsSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
6
7
  import { spawnSync } from "node:child_process";
7
8
  import { tmpdir } from "node:os";
8
9
  import { dirname, join, resolve } from "node:path";
9
10
  import { fileURLToPath } from "node:url";
11
+ import { buildForDeploy, nameApp } from "./ship.js";
10
12
 
11
13
  const ROOT = join(dirname(fileURLToPath(import.meta.url)), "../..");
12
14
  const TEMPLATE = join(ROOT, "template");
@@ -20,12 +22,11 @@ export function init(app, dir = app, { install = true, log = console.log } = {})
20
22
  cpSync(TEMPLATE, dir, { recursive: true });
21
23
  renameSync(join(dir, "gitignore"), join(dir, ".gitignore")); // npm drops a published .gitignore
22
24
 
23
- const cfg = JSON.parse(readFileSync(join(dir, "app.json"), "utf8"));
24
- const tag = `${app}.item.v1`;
25
- const type = JSON.parse(JSON.stringify(cfg.types["my-app.item.v1"]).replaceAll("my-app.item.v1", tag));
26
- Object.assign(cfg, { app, name: app, types: { [tag]: type } });
27
- cfg.site.project = app.replace(/[._]/g, "-"); // a Worker name: letters, digits, dashes
25
+ const cfg = nameApp(JSON.parse(readFileSync(join(dir, "app.json"), "utf8")), app);
28
26
  writeFileSync(join(dir, "app.json"), JSON.stringify(cfg, null, 1) + "\n");
27
+ const w = JSON.parse(readFileSync(join(dir, "wrangler.json"), "utf8"));
28
+ Object.assign(w, { name: cfg.site.project, r2_buckets: [{ ...w.r2_buckets[0], bucket_name: `${cfg.site.project}-view` }] });
29
+ writeFileSync(join(dir, "wrangler.json"), JSON.stringify(w, null, 1) + "\n");
29
30
  writeFileSync(join(dir, "README.md"), readFileSync(join(dir, "README.md"), "utf8").replaceAll("my-app", app));
30
31
  const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
31
32
  pkg.dependencies["@fangorn-network/westmarch"] = `^${JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")).version}`;
@@ -40,13 +41,7 @@ export function init(app, dir = app, { install = true, log = console.log } = {})
40
41
  }
41
42
  log(`${app} is in ${dir}. Next:
42
43
  1. app.json: the description, and the type's fields (role_map)
43
- 2. gh repo create ${app} --private --source ${dir} --push
44
- 3. gh secret set ETH_PRIVATE_KEY # the wallet that claimed ${app}; read from the prompt, never echoed
45
- gh secret set PINATA_JWT
46
- gh variable set PINATA_GATEWAY --body <your-gateway>.mypinata.cloud
47
- gh secret set CLOUDFLARE_API_TOKEN
48
- gh secret set CLOUDFLARE_ACCOUNT_ID
49
- 4. gh workflow run ship # deploys the Worker; then publish records (README.md)`);
44
+ 2. npx westmarch-ship app.json # claims ${app} if it isn't yet, deploys the Worker, registers its agent`);
50
45
  return dir;
51
46
  }
52
47
 
@@ -57,7 +52,9 @@ if (process.argv[2] === "--selfcheck") {
57
52
  const cfg = JSON.parse(readFileSync(join(dir, "app.json"), "utf8"));
58
53
  if (cfg.app !== "my.app_1" || cfg.site.project !== "my-app-1" || !cfg.types["my.app_1.item.v1"]?.presentation.types["my.app_1.item.v1"])
59
54
  throw new Error(`app.json not filled: ${JSON.stringify(cfg)}`);
60
- if (!existsSync(join(dir, ".gitignore")) || !existsSync(join(dir, ".github/workflows/ship.yml"))) throw new Error("template files missing");
55
+ if (!existsSync(join(dir, ".gitignore"))) throw new Error("template files missing");
56
+ const w = JSON.parse(readFileSync(join(dir, "wrangler.json"), "utf8"));
57
+ if (w.name !== "my-app-1" || w.r2_buckets[0].bucket_name !== "my-app-1-view") throw new Error(`wrangler.json not filled: ${JSON.stringify(w)}`);
61
58
  if (readFileSync(join(dir, "README.md"), "utf8").includes("my-app")) throw new Error("README still names my-app");
62
59
  let threw = false;
63
60
  try { init("x", join(dir, "y"), { install: false, log: () => {} }); } catch { threw = true; }
@@ -68,7 +65,10 @@ if (process.argv[2] === "--selfcheck") {
68
65
  const [app, dir] = process.argv.slice(3).filter((a) => !a.startsWith("--"));
69
66
  try { init(app, dir, { install: !process.argv.includes("--no-install") }); }
70
67
  catch (e) { console.error(e.message); process.exit(1); }
68
+ } else if (process.argv[2] === "build") {
69
+ try { await buildForDeploy(process.argv[3] ?? "app.json"); }
70
+ catch (e) { console.error(e.message); process.exit(1); }
71
71
  } else {
72
- console.error("usage: westmarch init <app> [dir] [--no-install]");
72
+ console.error("usage: westmarch init <app> [dir] [--no-install] | westmarch build [app.json]");
73
73
  process.exit(2);
74
74
  }
@@ -34,7 +34,7 @@ import { spawn, spawnSync } from "node:child_process";
34
34
  import { createHash, randomBytes } from "node:crypto";
35
35
  import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
36
36
  import { createRequire } from "node:module";
37
- import { dirname, join, resolve } from "node:path";
37
+ import { dirname, join, relative, resolve } from "node:path";
38
38
  import { homedir } from "node:os";
39
39
  import { fileURLToPath, pathToFileURL } from "node:url";
40
40
 
@@ -88,6 +88,9 @@ const log = (s) => console.error(`[ship] ${s}`);
88
88
  // "types": { "<tag>": { "description": "…", "role_map": {…}, "presentation": {…} } },
89
89
  // "relations": [{ "rel": "…", "from": "<tag>", "to": "<tag>" }],
90
90
  // "sources": [{ "namespace": "…", "cwd": "…", "command": ["python", "-m", "…", …] }],
91
+ // "live": ["readings"], // optional: namespaces members push to from their own wallets (devices,
92
+ // // contributors), presented beside the sources'; true = every repo they push.
93
+ // // Without sources the app already presents every member's repo.
91
94
  // "network": "baseSepolia", // required: the SDK's network name. baseSepolia for a new app; an app
92
95
  // // already claimed on arbitrumSepolia keeps it (a different name re-claims it there)
93
96
  // "fangorn": "fangorn", // the CLI that signs; e.g. "node ~/fangorn/fangorn/lib/cli/cli.js",
@@ -146,9 +149,18 @@ export function loadConfig(path) {
146
149
  if (!c.site.project) throw new Error(`${path}: site.project names the Cloudflare Worker`);
147
150
  for (const s of c.sources) if (!s.namespace || !Array.isArray(s.command))
148
151
  throw new Error(`${path}: each source needs a namespace and a command (an argv array)`);
152
+ if (c.live != null && c.live !== true && !(Array.isArray(c.live) && c.live.every((n) => typeof n === "string")))
153
+ throw new Error(`${path}: live is true (every repo members push) or a list of the namespaces they push to`);
149
154
  return { relations: [], tags: [], types: {}, ...c };
150
155
  }
151
156
 
157
+ /** The repo names the view presents: the sources' namespaces plus `live`'s, the ones members push
158
+ * to themselves (devices, contributors). [] means every repo the app presents: no sources, or live: true. */
159
+ export function presentedRepos(cfg) {
160
+ if (cfg.live === true) return [];
161
+ return [...new Set([...cfg.sources.map((s) => s.namespace), ...(cfg.live ?? [])])];
162
+ }
163
+
152
164
  /** The Worker's wrangler config: the stock worker, the site as its assets, its bucket, the AI
153
165
  * binding, the cron and CONFIG. `worker` is site/worker.js unless a test points elsewhere. */
154
166
  export function wranglerConfig({ name, site, account, cron = "*/5 * * * *", config, url, ipfsGateway, worker = join(HERE, "../../site/worker.js") }) {
@@ -217,6 +229,68 @@ function assertSameDeployment(cli) {
217
229
  "they talk to different contracts. Point app.json's \"fangorn\" at a matching CLI.");
218
230
  }
219
231
 
232
+ /** The block `appId` was claimed in, or undefined: the AppRegistered log, window by window. */
233
+ export async function claimBlock(chain, FangornConfig, appId) {
234
+ const { parseAbiItem } = await import("viem");
235
+ const from = BigInt(FangornConfig.appRegistryFromBlock), to = await chain.getBlockNumber(), step = BigInt(FangornConfig.logWindow ?? 10000);
236
+ const event = parseAbiItem("event AppRegistered(bytes32 indexed app_id, address indexed owner)");
237
+ for (let a = from; a <= to; a += step) {
238
+ const [claimed] = await chain.getLogs({ address: FangornConfig.appRegistryContractAddress, event, args: { app_id: appId },
239
+ fromBlock: a, toBlock: a + step - 1n < to ? a + step - 1n : to });
240
+ if (claimed) return String(claimed.blockNumber);
241
+ }
242
+ }
243
+
244
+ /** The template's app.json as `app`'s: its name, its type tag `<app>.item.v1` and the Worker. */
245
+ export const TEMPLATE_APP = "my-app";
246
+ export function nameApp(cfg, app) {
247
+ const from = `${TEMPLATE_APP}.item.v1`, tag = `${app}.item.v1`;
248
+ const types = Object.fromEntries(Object.entries(cfg.types ?? {}).map(([k, t]) =>
249
+ k === from ? [tag, JSON.parse(JSON.stringify(t).replaceAll(from, tag))] : [k, t]));
250
+ return { ...cfg, app, name: cfg.name === TEMPLATE_APP ? app : cfg.name, types,
251
+ site: { ...cfg.site, project: app.replace(/[._]/g, "-") } }; // a Worker name: letters, digits, dashes
252
+ }
253
+
254
+ /**
255
+ * `westmarch build`: what Workers Builds runs under the deploy button. The site, with a card
256
+ * that names ORIGIN for its own URL (the Worker fills it in), and CONFIG, into the app's own
257
+ * wrangler.json, which `wrangler deploy` then ships with the bucket, AI and cron it declares.
258
+ * No chain writes and no Cloudflare calls: the owner claims the app and registers its agent
259
+ * from fangorn.network. While app.json still names the template's app, the app is the Worker's name.
260
+ *
261
+ * ponytail: free apps only; a paid or private one ships with westmarch-ship, which sets its secret.
262
+ */
263
+ export async function buildForDeploy(configPath, wranglerPath = join(dirname(resolve(configPath)), "wrangler.json")) {
264
+ const w = JSON.parse(readFileSync(wranglerPath, "utf8"));
265
+ let cfg = loadConfig(configPath);
266
+ if (cfg.app === TEMPLATE_APP) cfg = nameApp(cfg, w.name);
267
+ if (cfg.paid || cfg.private) throw new Error(`${configPath}: a paid or private app ships with westmarch-ship`);
268
+ if (!cfg.network) throw new Error(`${configPath}: "network" is required`);
269
+ if (!w.r2_buckets?.[0]?.bucket_name?.includes(w.name))
270
+ log(`the R2 bucket ${w.r2_buckets?.[0]?.bucket_name} doesn't name the Worker; two apps sharing one overwrite each other's view`);
271
+
272
+ process.env.FANGORN_NETWORK = process.env.CHAIN_NAME = cfg.network;
273
+ const [{ createPublicClient, http }, view, { ORIGIN }] = await Promise.all([import("viem"), import("./view.js"), import("../../site/worker.js")]);
274
+ const FangornConfig = await view.network(cfg.network);
275
+ const fangorn = await view.readOnlyFangorn();
276
+ fangorn.setAppId(cfg.app);
277
+ if (/^0x0+$/.test(await fangorn.getAppRegistry().getAppOwner()))
278
+ throw new Error(`${cfg.app} is unclaimed: claim it at fangorn.network first${/[._]/.test(cfg.app) ? "" : `, or set "app" in app.json to the name you claimed`}`);
279
+ const chain = createPublicClient({ transport: http(FangornConfig.rpcUrl) });
280
+ const fromBlock = cfg.fromBlock != null ? String(cfg.fromBlock) : await claimBlock(chain, FangornConfig, fangorn.getAppId());
281
+
282
+ const base = dirname(resolve(configPath));
283
+ const { site, writeCard } = await buildSite(cfg, base);
284
+ writeCard(ORIGIN, { fromBlock, repos: [] });
285
+ const gateway = process.env.FANGORN_IPFS_GATEWAY;
286
+ w.assets = { ...w.assets, directory: relative(dirname(resolve(wranglerPath)), site) };
287
+ w.vars = { ...w.vars, CONFIG: { app: cfg.app, name: cfg.name, description: cfg.description, fromBlock, network: cfg.network,
288
+ schema: { types: cfg.types, relations: cfg.relations }, repos: [], paid: null, private: null },
289
+ ...(gateway ? { IPFS_GATEWAY: gateway } : {}) };
290
+ writeFileSync(wranglerPath, JSON.stringify(w, null, 1) + "\n");
291
+ log(`built ${cfg.app} (fromBlock ${fromBlock}) into ${site}; wrangler deploy ships it`);
292
+ }
293
+
220
294
  // ── the steps ─────────────────────────────────────────────────────────────────
221
295
 
222
296
  export async function ship(configPath, { crawl = true, deploy = true, dry = false, replace = false, only = null, register = false } = {}) {
@@ -245,7 +319,7 @@ export async function ship(configPath, { crawl = true, deploy = true, dry = fals
245
319
 
246
320
  // The SDK's network name: view.js reads with it, and the CLI and the sources spawned below sign on it.
247
321
  process.env.FANGORN_NETWORK = process.env.CHAIN_NAME = cfg.network;
248
- const [{ createPublicClient, http, parseAbiItem }, view] = await Promise.all([import("viem"), import("./view.js")]);
322
+ const [{ createPublicClient, http }, view] = await Promise.all([import("viem"), import("./view.js")]);
249
323
  const FangornConfig = await view.network(cfg.network);
250
324
  const fangorn = await view.readOnlyFangorn();
251
325
  fangorn.setAppId(cfg.app);
@@ -270,15 +344,7 @@ export async function ship(configPath, { crawl = true, deploy = true, dry = fals
270
344
  throw new Error(`${cfg.app} belongs to ${owner}, not this wallet (${me}); pick another name`);
271
345
  }
272
346
  // Claimed elsewhere (fangorn.network, the CLI): the view starts at the claim, found once.
273
- if (state.fromBlock == null && cfg.fromBlock == null) {
274
- const from = BigInt(FangornConfig.appRegistryFromBlock), to = await chain.getBlockNumber(), step = BigInt(FangornConfig.logWindow ?? 10000);
275
- const event = parseAbiItem("event AppRegistered(bytes32 indexed app_id, address indexed owner)");
276
- for (let a = from; a <= to && state.fromBlock == null; a += step) {
277
- const [claimed] = await chain.getLogs({ address: FangornConfig.appRegistryContractAddress, event, args: { app_id: appId },
278
- fromBlock: a, toBlock: a + step - 1n < to ? a + step - 1n : to });
279
- if (claimed) state.fromBlock = String(claimed.blockNumber);
280
- }
281
- }
347
+ if (state.fromBlock == null && cfg.fromBlock == null) state.fromBlock = await claimBlock(chain, FangornConfig, appId);
282
348
  state.fromBlock ??= String(cfg.fromBlock ?? FangornConfig.appRegistryFromBlock);
283
349
  log(`app ${cfg.app} = ${appId}, owner ${owner}, fromBlock ${state.fromBlock}`);
284
350
 
@@ -305,7 +371,7 @@ export async function ship(configPath, { crawl = true, deploy = true, dry = fals
305
371
  // site.url: the app's own domain, routed to the Worker. Otherwise its workers.dev URL,
306
372
  // which is only known once it is deployed: the first deploy learns it and the card follows.
307
373
  let url = cfg.site.url?.replace(/\/$/, "") ?? state.url ?? null;
308
- const repos = [...new Set(cfg.sources.map((s) => s.namespace))];
374
+ const repos = presentedRepos(cfg);
309
375
  // Paid records are in the committed rows already, encrypted (`paid_ct`: AES-256-GCM under
310
376
  // HMAC-SHA256(PAID_SECRET, id), see x402.js). The Worker sells keys, never records, so the
311
377
  // data a buyer pays for is on IPFS, committed, and in their hands before they pay.
@@ -381,7 +447,7 @@ export async function ship(configPath, { crawl = true, deploy = true, dry = fals
381
447
  } else if (deploy) log("card already bound");
382
448
 
383
449
  // A new app has no view until the cron runs, which may be hours off: build the first one now.
384
- if (deploy && !dry && !register && cfg.sources.length && !await fetch(`${url}/view/cdn/catalog`).then((r) => r.ok, () => false)) {
450
+ if (deploy && !dry && !register && (cfg.sources.length || cfg.live) && !await fetch(`${url}/view/cdn/catalog`).then((r) => r.ok, () => false)) {
385
451
  log("no view yet: indexing it here rather than waiting for the cron");
386
452
  await indexHere(configPath).catch((e) => log(`✗ first index: ${e.message}; the cron will build it`));
387
453
  }
@@ -678,12 +744,30 @@ export async function buildLocal(configPath, out, { crawl = false, only = null,
678
744
  const appId = `0x${createHash("sha256").update(cfg.app).digest("hex")}`, owner = `0x${"10ca1".padStart(40, "0")}`;
679
745
  const chain = {};
680
746
  // Storage addresses a record by its content, and so does this.
681
- for (const [ns, records] of Object.entries(readStage(cfg, base, { crawl, only, log: say })))
747
+ if (cfg.sources.length) for (const [ns, records] of Object.entries(readStage(cfg, base, { crawl, only, log: say })))
682
748
  chain[`${owner}/${ns}`] = { records: records.map((r) => ({ ...r, cid: createHash("sha256").update(JSON.stringify(stable(r.payload))).digest("hex") })) };
749
+ // What members already pushed to the live namespaces, read from the chain (reads only, no gas),
750
+ // so a recipe is graded on contributed data too, not just on what the sources staged.
751
+ if (cfg.live) {
752
+ if (cfg.network) process.env.FANGORN_NETWORK = process.env.CHAIN_NAME = cfg.network;
753
+ const fangorn = await view.readOnlyFangorn();
754
+ fangorn.setAppId(cfg.app);
755
+ const statePath = join(base, ".ship/state.json");
756
+ const fromBlock = (existsSync(statePath) ? JSON.parse(readFileSync(statePath, "utf8")).fromBlock : null) ?? cfg.fromBlock;
757
+ const staged = new Set(cfg.sources.map((s) => s.namespace));
758
+ for (const t of await fangorn.appRepos(fromBlock == null ? {} : { fromBlock: BigInt(fromBlock) })) {
759
+ if (t.repo === "fangorn.schema" || staged.has(t.repo) || (only && !only.includes(t.repo))
760
+ || (cfg.live !== true && !cfg.live.includes(t.repo))) continue;
761
+ const { contents } = await fangorn.readRepo(t.owner, t.repo);
762
+ chain[`${t.owner.toLowerCase()}/${t.repo}`] = { records: contents.records };
763
+ say(`live ${t.owner}/${t.repo}: ${contents.records.length} records, from the chain`);
764
+ }
765
+ }
766
+ if (!Object.keys(chain).length) throw new Error("nothing to build: no sources staged anything and no member has pushed to a live namespace");
683
767
  rmSync(out, { recursive: true, force: true });
684
768
  mkdirSync(join(base, ".ship"), { recursive: true });
685
769
  const embed = view.cachedEmbed(join(base, ".ship/vectors.ndjson"));
686
- const repos = [...new Set(cfg.sources.map((s) => s.namespace))].filter((n) => !only || only.includes(n));
770
+ const repos = presentedRepos(cfg).filter((n) => !only || only.includes(n));
687
771
  const report = await view.publishView({ fangorn: view.localFangorn(chain, { appId, owner }), app: cfg.app,
688
772
  schema: { types: cfg.types, relations: cfg.relations }, repos, out, embed, log: say });
689
773
  const rows = Object.values(report).reduce((n, r) => n + r.added, 0);
@@ -709,6 +793,10 @@ if (process.argv[1]?.endsWith("ship.js") || process.argv[1]?.endsWith("westmarch
709
793
  const dp = discovery({ name: "Q", description: "q" }, "https://q.example", null, { pass: "5000000", days: 30, call: "10000", decimals: 6 });
710
794
  if (dp.openapi.paths["/pass"].post["x-payment-info"].price.amount !== "5" || dp.x402.resources.join() !== "https://q.example/pass" || dp.openapi.paths["/paid"])
711
795
  throw new Error(`private discovery: ${JSON.stringify(dp.x402)}`);
796
+ const src = [{ namespace: "towns", command: ["py"] }];
797
+ if (presentedRepos({ sources: src }).join() !== "towns" || presentedRepos({ sources: src, live: ["readings", "towns"] }).join() !== "towns,readings"
798
+ || presentedRepos({ sources: src, live: true }).length || presentedRepos({ sources: [] }).length)
799
+ throw new Error("presentedRepos: sources plus live namespaces; [] (every repo) for live: true or no sources");
712
800
  if (units("0.01") !== "10000" || units("5") !== "5000000" || units("1.5", 2) !== "150") throw new Error("units");
713
801
  if (USDC["eip155:84532"].name !== "USDC" || USDC["eip155:8453"].name !== "USD Coin") throw new Error("USDC's EIP-712 name differs on Base Sepolia and Base");
714
802
  console.log("ship.js self-check ok — a source template fills per row, the Worker config binds its bucket, AI and cron, and the paid route is priced per id in /openapi.json and named in /.well-known/x402");
@@ -1,21 +1,31 @@
1
1
  # A Fangorn app
2
2
 
3
3
  This repo holds the app's recipe, not its data: what a record is (`app.json`), what a good
4
- answer looks like (`eval/`), and how to ship it (`ci/`). It can be public. The data lives
5
- on chain and IPFS, committed by the app's publishers; the Worker indexes it every 5
6
- minutes, and serves the site, the view, `/mcp` and the agent card.
7
-
8
- ## Set up (testnet, your own Cloudflare)
9
-
10
- 1. Edit `app.json`: `app` (the name you claim, globally unique), `name`, `description`,
11
- `site.project` (the Worker's name), and the type: rename `my-app.item.v1` and map your
12
- fields in `role_map`.
13
- 2. Add repo secrets (Settings → Secrets and variables → Actions):
14
- - `ETH_PRIVATE_KEY`: a Base Sepolia wallet with test ETH; it claims and owns the app
15
- - `PINATA_JWT`, and the variable `PINATA_GATEWAY`
16
- - `CLOUDFLARE_API_TOKEN` (template "Edit Cloudflare Workers", plus R2 and Workers AI) and `CLOUDFLARE_ACCOUNT_ID`
17
- 3. Push `app.json` to `main`. The `ship` workflow claims the app, deploys the Worker and
18
- binds its agent, then commits `.ship/state.json` back. Ship again whenever `app.json` changes.
4
+ answer looks like (`eval/`), and the Worker that serves it (`wrangler.json`). It can be
5
+ public. The data lives on chain and IPFS, committed by the app's members; the Worker
6
+ indexes it every 5 minutes, and serves the site, the view, `/mcp` and the agent card.
7
+
8
+ ## Deploy
9
+
10
+ [![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/fangorn-network/westmarch/tree/main/template)
11
+
12
+ 1. Claim the app at [fangorn.network](https://fangorn.network).
13
+ 2. Press the button. Name the Worker after the app (`my-app`) and its R2 bucket
14
+ `my-app-view`. Cloudflare copies this repo into your GitHub, makes the bucket and
15
+ deploys; every push to `main` deploys again.
16
+ 3. Back at fangorn.network, give the app its Worker's URL. Your wallet registers the
17
+ app's agent and binds its card there.
18
+
19
+ An app name with a dot or an underscore can't be a Worker name: set `"app"` in
20
+ `app.json` to the name you claimed.
21
+
22
+ `npm run build` (`westmarch build`) is what Cloudflare runs: it builds the site and writes
23
+ the app's config into `wrangler.json`, without signing or deploying anything.
24
+
25
+ ## Make it yours
26
+
27
+ Edit `app.json`: `name`, `description`, and the type: rename `my-app.item.v1` and map your
28
+ fields in `role_map`. Push, and the Worker follows.
19
29
 
20
30
  ## Publish data
21
31
 
@@ -29,10 +39,12 @@ fangorn --app my-app push
29
39
  ```
30
40
 
31
41
  The view picks it up within 5 minutes at `<url>/view`. A source that crawls something
32
- on a schedule goes in `app.json` `sources`; see westmarch's docs.
42
+ on a schedule goes in `app.json` `sources`, and publishes with
43
+ `npx westmarch-ship app.json --no-deploy` (the push to `main` deploys; without the flag it
44
+ would deploy a second copy over this Worker). See westmarch's docs.
33
45
 
34
46
  ## Keep it good
35
47
 
36
48
  Write `eval/goals.md` (who it serves) and `eval/golden.jsonl` (one check per line:
37
- `{"id","q","expect","min","goal"}`). The workflow grades the live view daily, and the run
38
- goes red when a check fails. `ci/ship.sh` runs the same steps on any CI or a laptop.
49
+ `{"id","q","expect","min","goal"}`), then grade the live view:
50
+ `npx westmarch-eval <url>/view`.
package/template/app.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "app": "my-app",
3
- "name": "My App",
3
+ "name": "my-app",
4
4
  "description": "One sentence: what is in here, and who it is for.",
5
5
  "network": "baseSepolia",
6
6
  "site": {
@@ -1,8 +1,13 @@
1
1
  {
2
2
  "private": true,
3
3
  "type": "module",
4
+ "scripts": {
5
+ "build": "westmarch build",
6
+ "deploy": "wrangler deploy"
7
+ },
4
8
  "dependencies": {
5
- "@fangorn-network/westmarch": "^2026.10.21-dev",
6
- "@huggingface/transformers": "^4.3.1"
9
+ "@fangorn-network/westmarch": "2026.10.2-2.0-dev",
10
+ "@huggingface/transformers": "^4.3.1",
11
+ "wrangler": "^4.83.0"
7
12
  }
8
13
  }
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "my-app",
3
+ "main": "node_modules/@fangorn-network/westmarch/site/worker.js",
4
+ "compatibility_date": "2025-09-15",
5
+ "compatibility_flags": ["nodejs_compat"],
6
+ "assets": { "directory": ".ship/site", "binding": "ASSETS" },
7
+ "r2_buckets": [{ "binding": "VIEW", "bucket_name": "my-app-view" }],
8
+ "ai": { "binding": "AI" },
9
+ "triggers": { "crons": ["*/5 * * * *"] },
10
+ "alias": { "@huggingface/transformers": "./node_modules/@fangorn-network/westmarch/site/no-model.js" },
11
+ "vars": {}
12
+ }
@@ -1,33 +0,0 @@
1
- # Ship when app.json changes (or by hand); grade the live view daily. Data never comes
2
- # through here: publishers commit it to the app, and the Worker indexes it every 5 minutes.
3
- name: ship
4
- on:
5
- push: { branches: [main], paths: [app.json] }
6
- schedule: [{ cron: "17 13 * * *" }]
7
- workflow_dispatch:
8
- concurrency: { group: ship, cancel-in-progress: false }
9
- permissions: { contents: write } # to commit .ship/state.json back
10
- jobs:
11
- ship:
12
- runs-on: ubuntu-latest
13
- timeout-minutes: 30
14
- env:
15
- ETH_PRIVATE_KEY: ${{ secrets.ETH_PRIVATE_KEY }}
16
- PINATA_JWT: ${{ secrets.PINATA_JWT }}
17
- PINATA_GATEWAY: ${{ vars.PINATA_GATEWAY }}
18
- CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
19
- CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
20
- steps:
21
- - uses: actions/checkout@v4
22
- - uses: actions/setup-node@v4
23
- with: { node-version: 22, cache: npm }
24
- - if: github.event_name != 'schedule'
25
- run: ci/ship.sh ship
26
- - if: github.event_name != 'schedule'
27
- name: keep the claim block, URL and agent id
28
- run: |
29
- git add .ship/state.json
30
- git diff --cached --quiet || { git -c user.name=github-actions -c user.email=41898282+github-actions[bot]@users.noreply.github.com commit -m "ship: state" && git push; }
31
- - uses: actions/cache@v4
32
- with: { path: ~/.cache/fangorn-mcp/models, key: models-embeddinggemma }
33
- - run: ci/ship.sh eval
@@ -1,21 +0,0 @@
1
- #!/usr/bin/env bash
2
- # Portable CI: any runner (or a laptop) with node 22 and jq.
3
- # ci/ship.sh ship claim on first run, commit the schema, build the site, deploy the Worker, bind the agent
4
- # ci/ship.sh eval grade the live view against eval/golden.jsonl
5
- # Env: ETH_PRIVATE_KEY, PINATA_JWT, PINATA_GATEWAY, CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID
6
- set -euo pipefail
7
- cd "$(dirname "$0")/.."
8
- export FANGORN_IPFS_GATEWAY="${FANGORN_IPFS_GATEWAY:-${PINATA_GATEWAY:-}}" # westmarch reads this name, not PINATA_GATEWAY
9
-
10
- grep -q '"app": "my-app"' app.json && { echo "edit app.json first: app, name, description, site.project, the type" >&2; exit 1; }
11
- [ -d node_modules ] || npm ci
12
- # ship signs with the fangorn CLI, which must be the SDK version westmarch reads with
13
- command -v fangorn >/dev/null || npm i -g "@fangorn-network/sdk@$(node -p 'require("@fangorn-network/sdk/package.json").version')"
14
-
15
- case "${1:-}" in
16
- ship) npx westmarch-ship app.json ;;
17
- eval)
18
- [ -s eval/golden.jsonl ] || { echo "no golden queries in eval/golden.jsonl yet: nothing to grade"; exit 0; }
19
- npx westmarch-eval "$(jq -r .url .ship/state.json)/view" ;;
20
- *) echo "usage: ci/ship.sh ship|eval" >&2; exit 2 ;;
21
- esac