@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 +2 -2
- package/site/worker.js +14 -2
- package/src/publish/init.js +15 -15
- package/src/publish/ship.js +103 -15
- package/template/README.md +30 -18
- package/template/app.json +1 -1
- package/template/package.json +7 -2
- package/template/wrangler.json +12 -0
- package/template/.github/workflows/ship.yml +0 -33
- package/template/ci/ship.sh +0 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fangorn-network/westmarch",
|
|
3
|
-
"version": "2026.10.
|
|
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.
|
|
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
|
-
|
|
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");
|
package/src/publish/init.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// A new app from the template: its recipe (app.json, eval
|
|
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.
|
|
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"))
|
|
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
|
}
|
package/src/publish/ship.js
CHANGED
|
@@ -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
|
|
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 =
|
|
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 =
|
|
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");
|
package/template/README.md
CHANGED
|
@@ -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
|
|
5
|
-
on chain and IPFS, committed by the app's
|
|
6
|
-
minutes, and serves the site, the view, `/mcp` and the agent card.
|
|
7
|
-
|
|
8
|
-
##
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
2.
|
|
14
|
-
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
+
[](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
|
|
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"}`)
|
|
38
|
-
|
|
49
|
+
`{"id","q","expect","min","goal"}`), then grade the live view:
|
|
50
|
+
`npx westmarch-eval <url>/view`.
|
package/template/app.json
CHANGED
package/template/package.json
CHANGED
|
@@ -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": "
|
|
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
|
package/template/ci/ship.sh
DELETED
|
@@ -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
|