@fangorn-network/westmarch 2026.10.21-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/docs/app-to-agent.md +25 -39
- package/package.json +4 -2
- package/site/worker.js +20 -8
- package/src/publish/init.js +74 -0
- package/src/publish/ship.js +123 -74
- package/src/publish/view-cli.js +6 -3
- package/src/publish/view.js +27 -43
- package/template/README.md +50 -0
- package/template/app.json +27 -0
- package/template/eval/goals.md +7 -0
- package/template/eval/golden.jsonl +0 -0
- package/template/gitignore +3 -0
- package/template/package.json +13 -0
- package/template/wrangler.json +12 -0
package/docs/app-to-agent.md
CHANGED
|
@@ -19,9 +19,9 @@ which anything can produce: a scraper, a database export, a converted spreadshee
|
|
|
19
19
|
|
|
20
20
|
```
|
|
21
21
|
your data ─► data/graph.json ─► fangorn commit/push ─┐
|
|
22
|
-
schema/graph.json ─► fangorn commit/push ────────────┤ (namespace fangorn.schema)
|
|
23
22
|
▼
|
|
24
23
|
chain ─► westmarch-view ─► site/view/
|
|
24
|
+
app.json types ──────────────────────────────────────────────┘
|
|
25
25
|
(embeds only what changed, applies the schema)
|
|
26
26
|
app/ (page) ─► vite build ─► site/ ◄── the page loads site/view
|
|
27
27
|
pipeline/agent-card.mjs ─► site/.well-known/agent-card.json (views: [site/view]) ─► deploy
|
|
@@ -69,7 +69,7 @@ update: each step checks the chain (or Cloudflare) first and acts only on a diff
|
|
|
69
69
|
```
|
|
70
70
|
|
|
71
71
|
```sh
|
|
72
|
-
npx westmarch-ship app.json # claim, join,
|
|
72
|
+
npx westmarch-ship app.json # claim, join, crawl + publish, view, site, card, deploy, agent
|
|
73
73
|
npx westmarch-ship app.json --dry-run # every step, printed, none done
|
|
74
74
|
npx westmarch-ship app.json --no-crawl # rebuild view, site and card from what is on chain
|
|
75
75
|
npx westmarch-ship app.json --replace # each source's crawl becomes its namespace's whole state
|
|
@@ -81,21 +81,20 @@ Workers AI), the view, `/mcp`, and the page. What `ship` does, in order:
|
|
|
81
81
|
|
|
82
82
|
1. **claim** the app on the first run, and record the claim block (the card's `fromBlock`);
|
|
83
83
|
the claimer is the app's first member;
|
|
84
|
-
2. **
|
|
85
|
-
3. **run each source**: a command that publishes into `--namespace` when given `--publish`.
|
|
84
|
+
2. **run each source**: a command that publishes into `--namespace` when given `--publish`.
|
|
86
85
|
Every quickbeam scraper Source does. A source with `"each": "rows.json"` is a template,
|
|
87
86
|
one source per row (`{field}`, `{field|slug}`, `{args...}`), so adding a town, a feed or
|
|
88
87
|
an account is adding a row;
|
|
89
|
-
|
|
88
|
+
3. **the site** in `.ship/site`: the stock page (search, plus a feed of what is coming up and
|
|
90
89
|
what just happened, by the `temporal` role and the first `facets` field; for rows that are
|
|
91
90
|
not meetings, an explore page: `site.examples` as searches to try, the latest records by
|
|
92
91
|
`temporal`, and each declared facet's values to browse), the agent card,
|
|
93
92
|
`/openapi.json` and `/.well-known/x402` when anything is for sale, and `_headers`;
|
|
94
|
-
|
|
95
|
-
the cron (`site.cron`, default every 5 minutes) and its CONFIG. With `paid`, it sells each
|
|
93
|
+
4. **deploy the Worker** (`wrangler deploy`): its R2 bucket (created if missing), Workers AI,
|
|
94
|
+
the cron (`site.cron`, default every 5 minutes) and its CONFIG, which carries the schema. With `paid`, it sells each
|
|
96
95
|
record over x402; only records whose sha256 a live row carries are for sale. If the app
|
|
97
96
|
has no view yet, ship builds the first one here (`--index`) rather than wait for the cron;
|
|
98
|
-
|
|
97
|
+
5. **register** the ERC-8004 agent and bind the card, if the chain points elsewhere. An
|
|
99
98
|
agent is minted once; later runs only rebind.
|
|
100
99
|
|
|
101
100
|
State lives in `.ship/` next to `app.json`. In CI, cache `.ship/cache` and `.ship/stage`
|
|
@@ -304,40 +303,30 @@ What makes records work well:
|
|
|
304
303
|
|
|
305
304
|
## 5. Declare the schema
|
|
306
305
|
|
|
307
|
-
The schema says what your data is.
|
|
308
|
-
|
|
309
|
-
-
|
|
310
|
-
-
|
|
306
|
+
The schema says what your data is. It lives in `app.json` and nowhere else; nothing is
|
|
307
|
+
committed on chain:
|
|
308
|
+
- **`types`:** one entry per type tag: `description`, `role_map`, `presentation`.
|
|
309
|
+
- **`relations`:** the references allowed between types, `{ "rel", "from", "to" }`.
|
|
311
310
|
|
|
312
|
-
`westmarch-
|
|
311
|
+
`westmarch-ship` hands it to the Worker in its CONFIG, and `westmarch-view` reads it with
|
|
312
|
+
`--config app.json`. It is applied to every record they embed, so it decides:
|
|
313
313
|
- **what gets embedded:** the `text` role, plus title, subtitle and tags;
|
|
314
314
|
- **what readers see:** titles, links, labels;
|
|
315
315
|
- **what agents can do:** filters, counts, and choosing among views by their description.
|
|
316
316
|
|
|
317
|
-
`
|
|
317
|
+
In `app.json`:
|
|
318
318
|
|
|
319
319
|
```json
|
|
320
|
-
{
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
],
|
|
329
|
-
"edges": []
|
|
330
|
-
}
|
|
331
|
-
```
|
|
332
|
-
|
|
333
|
-
```sh
|
|
334
|
-
cd schema && fangorn repo init fangorn.schema \
|
|
335
|
-
&& fangorn commit graph.json -m "schema v1" --replace && fangorn push && cd ..
|
|
320
|
+
"types": {
|
|
321
|
+
"my-app.tree.v1": {
|
|
322
|
+
"description": "Tree species: what they are, where they grow, and what they are good for.",
|
|
323
|
+
"role_map": { "identity": "id", "title": "name", "subtitle": "species",
|
|
324
|
+
"tags": ["family", "region"], "text": ["description"] },
|
|
325
|
+
"presentation": { "externalUrl": { "my-app.tree.v1": "https://en.wikipedia.org/wiki/{species}" } } }
|
|
326
|
+
},
|
|
327
|
+
"relations": []
|
|
336
328
|
```
|
|
337
329
|
|
|
338
|
-
`fangorn repo init` tracks one namespace per directory (in `.fangorn/`), which is why the
|
|
339
|
-
schema is committed from its own `schema/` folder.
|
|
340
|
-
|
|
341
330
|
**`role_map`** says which field is what:
|
|
342
331
|
|
|
343
332
|
| role | means | used for |
|
|
@@ -354,13 +343,10 @@ schema is committed from its own `schema/` folder.
|
|
|
354
343
|
map inferred from its fields. That works for display and often fails for search: a
|
|
355
344
|
field called `desc` or `nct` isn't recognized, and a `price` can end up as the date.
|
|
356
345
|
Declare every type you publish.
|
|
357
|
-
- **
|
|
358
|
-
|
|
359
|
-
- **You can change it at any time** with another commit. A view takes the new role map and
|
|
360
|
-
description on its next `westmarch-view` run. **Records already embedded keep the text they were embedded
|
|
346
|
+
- **You can change it at any time**: edit `app.json` and ship again. The view takes the new
|
|
347
|
+
role map and description on the Worker's next cron run (or the next `westmarch-view`). **Records already embedded keep the text they were embedded
|
|
361
348
|
with** until they're re-embedded, so change the `text` role early.
|
|
362
|
-
- **Relations** (
|
|
363
|
-
"to": "region" }`) declare which edges the app allows. Nothing enforces them yet; they
|
|
349
|
+
- **Relations** (e.g. `{ "rel": "grows_in", "from": "my-app.tree.v1", "to": "my-app.region.v1" }`) declare which edges the app allows. Nothing enforces them yet; they
|
|
364
350
|
are what publisher graphs will be validated against.
|
|
365
351
|
|
|
366
352
|
## 6. Commit and push the data
|
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",
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
"viem": "^2.56.3"
|
|
69
69
|
},
|
|
70
70
|
"bin": {
|
|
71
|
+
"westmarch": "./src/publish/init.js",
|
|
71
72
|
"westmarch-publish": "./src/publish/cli.js",
|
|
72
73
|
"westmarch-view": "./src/publish/view-cli.js",
|
|
73
74
|
"westmarch-ship": "./src/publish/ship.js",
|
|
@@ -78,6 +79,7 @@
|
|
|
78
79
|
},
|
|
79
80
|
"files": [
|
|
80
81
|
"src",
|
|
82
|
+
"template",
|
|
81
83
|
"docs/app-to-agent.md",
|
|
82
84
|
"site/agent.js",
|
|
83
85
|
"site/dist",
|
package/site/worker.js
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
// paying, so nobody pays for a record that does not exist, and decrypts and checks it after.
|
|
28
28
|
//
|
|
29
29
|
// Bindings: VIEW (R2), AI (Workers AI), ASSETS (the site), CONFIG (a JSON var: app, name,
|
|
30
|
-
// description, fromBlock, repos, paid, deployment), and optionally RPC_URL and IPFS_GATEWAY
|
|
30
|
+
// description, fromBlock, schema, repos, paid, deployment), and optionally RPC_URL and IPFS_GATEWAY
|
|
31
31
|
// (vars) and PAID_SECRET (a secret).
|
|
32
32
|
|
|
33
33
|
import { Fangorn, FangornConfig, SupportedNetworks, repoExcluded } from "@fangorn-network/sdk";
|
|
@@ -105,7 +105,7 @@ export async function index(env, { log = console.log } = {}) {
|
|
|
105
105
|
// Imported here: x402.js's self-check imports this module, and a cycle at load would deadlock it.
|
|
106
106
|
const { openRow } = await import("../src/agent/x402.js");
|
|
107
107
|
const report = await publishView({
|
|
108
|
-
fangorn: r.fangorn, app: CONFIG.app, repos: CONFIG.repos, store: r2Store(env.VIEW),
|
|
108
|
+
fangorn: r.fangorn, app: CONFIG.app, schema: CONFIG.schema, repos: CONFIG.repos, store: r2Store(env.VIEW),
|
|
109
109
|
presented: await presented(r, state), embedBatch: encode(env, DOCUMENT), embedder: await embedderOf(env), log,
|
|
110
110
|
...(CONFIG.private ? { open: (p) => openRow(env.PAID_SECRET, p) } : {}),
|
|
111
111
|
});
|
|
@@ -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");
|
|
@@ -488,7 +498,7 @@ export default worker;
|
|
|
488
498
|
|
|
489
499
|
// ── self-check: `node site/worker.js` — a fake bucket, a fake AI binding, a fake registry ──
|
|
490
500
|
if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}` && process.argv[1]?.endsWith("/worker.js")) {
|
|
491
|
-
const { localFangorn
|
|
501
|
+
const { localFangorn } = await import("../src/publish/view.js");
|
|
492
502
|
const eq = (a, b, m) => { if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error(`${m}: ${JSON.stringify(a)} != ${JSON.stringify(b)}`); };
|
|
493
503
|
|
|
494
504
|
const objs = new Map();
|
|
@@ -511,20 +521,20 @@ 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
|
|
|
518
528
|
const APP = `0x${"ab".repeat(32)}`, OWNER = `0x${"11".repeat(20)}`, PUB = `0x${"22".repeat(20)}`, TAG = "shop.item.v1";
|
|
519
529
|
const rec = (cid, type, payload) => ({ id: cid, cid, type, payload });
|
|
530
|
+
CONFIG.schema = { types: { [TAG]: { role_map: { identity: "sku", title: "name", text: "about", media: "doc" } } } };
|
|
520
531
|
const chain = {
|
|
521
|
-
[`${OWNER}/${SCHEMA_REPO}`]: { records: [rec("t", TYPE_TAG, { tag: TAG, role_map: { identity: "sku", title: "name", text: "about", media: "doc" } })] },
|
|
522
532
|
[`${PUB}/shop`]: { records: [
|
|
523
533
|
rec("c1", TAG, { sku: "a1", name: "Oak table", about: "a solid oak dining table", doc: "https://docs.example/oak.pdf" }),
|
|
524
534
|
rec("c2", TAG, { sku: "b2", name: "Brass lamp", about: "a brass reading lamp" })] },
|
|
525
535
|
};
|
|
526
536
|
const fangorn = localFangorn(chain, { appId: APP, owner: OWNER });
|
|
527
|
-
await publishView({ fangorn, app: APP, store: r2Store(VIEW), embedBatch: encode(env, DOCUMENT), embedder: WORKER_EMBEDDER, log: () => {} });
|
|
537
|
+
await publishView({ fangorn, app: APP, schema: CONFIG.schema, store: r2Store(VIEW), embedBatch: encode(env, DOCUMENT), embedder: WORKER_EMBEDDER, log: () => {} });
|
|
528
538
|
eq([calls[0].model, calls[0].text[0].startsWith(DOCUMENT)], [AI_MODEL, true], "documents go to the AI binding with the document prompt");
|
|
529
539
|
|
|
530
540
|
const call = async (method, params) => (await (await worker.fetch(new Request("https://shop.test/mcp",
|
|
@@ -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");
|
|
@@ -551,7 +563,7 @@ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv
|
|
|
551
563
|
const sealedAgain = await sealRow(SECRET, { sku: "v1", name: "Velvet chair", about: "a green velvet armchair" });
|
|
552
564
|
eq(sealedAgain, chain[`${PUB}/vault`].records[0].payload, "an unchanged row seals to the same bytes (the same CID: not embedded again)");
|
|
553
565
|
if (JSON.stringify(sealedAgain).includes("Velvet")) throw new Error("a sealed row must not carry its plaintext");
|
|
554
|
-
await publishView({ fangorn, app: APP, store: r2Store(VIEW), embedBatch: encode(env, DOCUMENT), embedder: WORKER_EMBEDDER, log: () => {},
|
|
566
|
+
await publishView({ fangorn, app: APP, schema: CONFIG.schema, store: r2Store(VIEW), embedBatch: encode(env, DOCUMENT), embedder: WORKER_EMBEDDER, log: () => {},
|
|
555
567
|
open: (p) => openRow(SECRET, p) });
|
|
556
568
|
env.PAID_SECRET = SECRET;
|
|
557
569
|
CONFIG = env.CONFIG = { ...env.CONFIG, private: { network: "eip155:84532", asset: `0x${"aa".repeat(20)}`, payTo: `0x${"5e".repeat(20)}`,
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// A new app from the template: its recipe (app.json, wrangler.json, eval/), no data.
|
|
3
|
+
// npx @fangorn-network/westmarch init <app> [dir] [--no-install]
|
|
4
|
+
// westmarch build the deploy button's build step (ship.js buildForDeploy)
|
|
5
|
+
// The app's data is what its members commit; the repo this makes can be public.
|
|
6
|
+
import { cpSync, existsSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { dirname, join, resolve } from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { buildForDeploy, nameApp } from "./ship.js";
|
|
12
|
+
|
|
13
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
14
|
+
const TEMPLATE = join(ROOT, "template");
|
|
15
|
+
// What fangorn.network accepts: a name a shell and a URL need no quoting for.
|
|
16
|
+
const APP_NAME = /^[a-z0-9][a-z0-9._-]{1,62}$/;
|
|
17
|
+
|
|
18
|
+
export function init(app, dir = app, { install = true, log = console.log } = {}) {
|
|
19
|
+
if (!APP_NAME.test(app ?? "")) throw new Error(`"${app}": 2-63 characters, lowercase letters, digits, dot, dash or underscore`);
|
|
20
|
+
dir = resolve(dir);
|
|
21
|
+
if (existsSync(dir) && readdirSync(dir).length) throw new Error(`${dir} is not empty`);
|
|
22
|
+
cpSync(TEMPLATE, dir, { recursive: true });
|
|
23
|
+
renameSync(join(dir, "gitignore"), join(dir, ".gitignore")); // npm drops a published .gitignore
|
|
24
|
+
|
|
25
|
+
const cfg = nameApp(JSON.parse(readFileSync(join(dir, "app.json"), "utf8")), app);
|
|
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");
|
|
30
|
+
writeFileSync(join(dir, "README.md"), readFileSync(join(dir, "README.md"), "utf8").replaceAll("my-app", app));
|
|
31
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf8"));
|
|
32
|
+
pkg.dependencies["@fangorn-network/westmarch"] = `^${JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")).version}`;
|
|
33
|
+
writeFileSync(join(dir, "package.json"), JSON.stringify(pkg, null, 2) + "\n");
|
|
34
|
+
|
|
35
|
+
if (install) {
|
|
36
|
+
const sh = (cmd, ...args) => { if (spawnSync(cmd, args, { cwd: dir, stdio: "inherit" }).status !== 0) throw new Error(`${cmd} ${args[0]} failed`); };
|
|
37
|
+
sh("npm", "install"); // the lock CI installs from
|
|
38
|
+
sh("git", "init", "-q", "-b", "main");
|
|
39
|
+
sh("git", "add", "-A");
|
|
40
|
+
sh("git", "commit", "-q", "-m", `${app}: from the westmarch template`);
|
|
41
|
+
}
|
|
42
|
+
log(`${app} is in ${dir}. Next:
|
|
43
|
+
1. app.json: the description, and the type's fields (role_map)
|
|
44
|
+
2. npx westmarch-ship app.json # claims ${app} if it isn't yet, deploys the Worker, registers its agent`);
|
|
45
|
+
return dir;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (process.argv[2] === "--selfcheck") {
|
|
49
|
+
const dir = join(mkdtempSync(join(tmpdir(), "init-")), "app");
|
|
50
|
+
try {
|
|
51
|
+
init("my.app_1", dir, { install: false, log: () => {} });
|
|
52
|
+
const cfg = JSON.parse(readFileSync(join(dir, "app.json"), "utf8"));
|
|
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"])
|
|
54
|
+
throw new Error(`app.json not filled: ${JSON.stringify(cfg)}`);
|
|
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)}`);
|
|
58
|
+
if (readFileSync(join(dir, "README.md"), "utf8").includes("my-app")) throw new Error("README still names my-app");
|
|
59
|
+
let threw = false;
|
|
60
|
+
try { init("x", join(dir, "y"), { install: false, log: () => {} }); } catch { threw = true; }
|
|
61
|
+
if (!threw) throw new Error("a one-letter name must be refused");
|
|
62
|
+
console.log("init: ok");
|
|
63
|
+
} finally { rmSync(dirname(dir), { recursive: true, force: true }); }
|
|
64
|
+
} else if (process.argv[2] === "init") {
|
|
65
|
+
const [app, dir] = process.argv.slice(3).filter((a) => !a.startsWith("--"));
|
|
66
|
+
try { init(app, dir, { install: !process.argv.includes("--no-install") }); }
|
|
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
|
+
} else {
|
|
72
|
+
console.error("usage: westmarch init <app> [dir] [--no-install] | westmarch build [app.json]");
|
|
73
|
+
process.exit(2);
|
|
74
|
+
}
|
package/src/publish/ship.js
CHANGED
|
@@ -14,16 +14,15 @@
|
|
|
14
14
|
//
|
|
15
15
|
// 1. claim the app (first run only; the claim block becomes the card's fromBlock).
|
|
16
16
|
// The claimer is the app's first member, so its own updates are accepted.
|
|
17
|
-
// 2.
|
|
18
|
-
// 3.
|
|
19
|
-
// 4. the
|
|
20
|
-
// 5.
|
|
21
|
-
// 6. register the ERC-8004 agent and bind the card, if the chain points elsewhere
|
|
17
|
+
// 2. run each source, which crawls and publishes straight into the app
|
|
18
|
+
// 3. the stock page, the card and _headers → .ship/site
|
|
19
|
+
// 4. deploy the Worker: its R2 bucket, Workers AI, the cron, CONFIG (app.json's schema rides in it)
|
|
20
|
+
// 5. register the ERC-8004 agent and bind the card, if the chain points elsewhere
|
|
22
21
|
//
|
|
23
|
-
// The Worker picks up what 2
|
|
22
|
+
// The Worker picks up what 2 published on its next cron run (every few minutes),
|
|
24
23
|
// so the view follows the chain with nobody running anything.
|
|
25
24
|
//
|
|
26
|
-
// --register is the fast path, run before there is any data: 1,
|
|
25
|
+
// --register is the fast path, run before there is any data: 1, 3, 4 and 5 binding the card
|
|
27
26
|
// without minting. The mint copies the card's skills into its IPFS registration file and is
|
|
28
27
|
// never redone, so it waits for the first full ship, when the card lists what the app does.
|
|
29
28
|
//
|
|
@@ -35,10 +34,9 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
35
34
|
import { createHash, randomBytes } from "node:crypto";
|
|
36
35
|
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
37
36
|
import { createRequire } from "node:module";
|
|
38
|
-
import { dirname, join, resolve } from "node:path";
|
|
37
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
39
38
|
import { homedir } from "node:os";
|
|
40
39
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
41
|
-
import { RELATION_TAG, TYPE_TAG } from "./view.js";
|
|
42
40
|
|
|
43
41
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
44
42
|
// USDC per x402 network (CAIP-2): its address and EIP-712 domain, as each contract reports them.
|
|
@@ -90,6 +88,9 @@ const log = (s) => console.error(`[ship] ${s}`);
|
|
|
90
88
|
// "types": { "<tag>": { "description": "…", "role_map": {…}, "presentation": {…} } },
|
|
91
89
|
// "relations": [{ "rel": "…", "from": "<tag>", "to": "<tag>" }],
|
|
92
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.
|
|
93
94
|
// "network": "baseSepolia", // required: the SDK's network name. baseSepolia for a new app; an app
|
|
94
95
|
// // already claimed on arbitrumSepolia keeps it (a different name re-claims it there)
|
|
95
96
|
// "fangorn": "fangorn", // the CLI that signs; e.g. "node ~/fangorn/fangorn/lib/cli/cli.js",
|
|
@@ -148,17 +149,16 @@ export function loadConfig(path) {
|
|
|
148
149
|
if (!c.site.project) throw new Error(`${path}: site.project names the Cloudflare Worker`);
|
|
149
150
|
for (const s of c.sources) if (!s.namespace || !Array.isArray(s.command))
|
|
150
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`);
|
|
151
154
|
return { relations: [], tags: [], types: {}, ...c };
|
|
152
155
|
}
|
|
153
156
|
|
|
154
|
-
/** The
|
|
155
|
-
*
|
|
156
|
-
export function
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
tag, description: t.description ?? "", role_map: t.role_map ?? {}, presentation: t.presentation ?? {} } })),
|
|
160
|
-
...relations.map((r) => ({ id: `rel:${r.rel}:${r.from}:${r.to}`, type: RELATION_TAG, payload: { rel: r.rel, from: r.from, to: r.to } })),
|
|
161
|
-
] };
|
|
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
162
|
}
|
|
163
163
|
|
|
164
164
|
/** The Worker's wrangler config: the stock worker, the site as its assets, its bucket, the AI
|
|
@@ -186,13 +186,6 @@ export function wranglerConfig({ name, site, account, cron = "*/5 * * * *", conf
|
|
|
186
186
|
const stable = (v) => Array.isArray(v) ? v.map(stable)
|
|
187
187
|
: v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, stable(v[k])])) : v;
|
|
188
188
|
|
|
189
|
-
/** Same shape parseSchema returns, so a config and the chain compare directly. */
|
|
190
|
-
const canonical = (types, relations = []) => JSON.stringify(stable({
|
|
191
|
-
types: Object.fromEntries(Object.entries(types).sort(([a], [b]) => a.localeCompare(b))
|
|
192
|
-
.map(([tag, t]) => [tag, { description: t.description ?? "", role_map: t.role_map ?? {}, presentation: t.presentation ?? {} }])),
|
|
193
|
-
relations: [...relations].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))),
|
|
194
|
-
}));
|
|
195
|
-
|
|
196
189
|
// ── processes ─────────────────────────────────────────────────────────────────
|
|
197
190
|
|
|
198
191
|
/** `${VAR}` and `${VAR:-default}` from the environment, so one app.json runs on a laptop
|
|
@@ -236,12 +229,75 @@ function assertSameDeployment(cli) {
|
|
|
236
229
|
"they talk to different contracts. Point app.json's \"fangorn\" at a matching CLI.");
|
|
237
230
|
}
|
|
238
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
|
+
|
|
239
294
|
// ── the steps ─────────────────────────────────────────────────────────────────
|
|
240
295
|
|
|
241
296
|
export async function ship(configPath, { crawl = true, deploy = true, dry = false, replace = false, only = null, register = false } = {}) {
|
|
242
297
|
const cfg = loadConfig(configPath);
|
|
243
|
-
|
|
244
|
-
|
|
298
|
+
// No sources is fine: the app then presents whatever its members commit (repos: [] = all of them).
|
|
299
|
+
if (!register && !Object.keys(cfg.types).length)
|
|
300
|
+
throw new Error(`${configPath}: no types yet, so nothing to build; --register sets up the app alone`);
|
|
245
301
|
// No default: one that changed would claim every app that leaned on it again, elsewhere.
|
|
246
302
|
if (!cfg.network) throw new Error(`${configPath}: "network" is required: "baseSepolia" for a new app, or the network the app was claimed on (before 2026-10-20 the default was "arbitrumSepolia")`);
|
|
247
303
|
CLI = cliArgv(cfg);
|
|
@@ -276,8 +332,9 @@ export async function ship(configPath, { crawl = true, deploy = true, dry = fals
|
|
|
276
332
|
const me = (fangornCli(cfg.app, ["wallet"]).match(/Address:\s+(0x[0-9a-fA-F]{40})/) ?? [])[1]?.toLowerCase();
|
|
277
333
|
if (!me) throw new Error("`fangorn wallet` shows no address; run `fangorn init`");
|
|
278
334
|
let owner = (await registry.getAppOwner()).toLowerCase();
|
|
335
|
+
const chain = createPublicClient({ transport: http(FangornConfig.rpcUrl) });
|
|
279
336
|
if (/^0x0+$/.test(owner)) {
|
|
280
|
-
const block = await
|
|
337
|
+
const block = await chain.getBlockNumber();
|
|
281
338
|
state.fromBlock = String(block);
|
|
282
339
|
log(`${cfg.app} is unclaimed; claiming it for ${me} (fromBlock ${block})`);
|
|
283
340
|
fangornCli(cfg.app, ["app", "claim"], { dry });
|
|
@@ -286,25 +343,12 @@ export async function ship(configPath, { crawl = true, deploy = true, dry = fals
|
|
|
286
343
|
} else if (owner !== me) {
|
|
287
344
|
throw new Error(`${cfg.app} belongs to ${owner}, not this wallet (${me}); pick another name`);
|
|
288
345
|
}
|
|
346
|
+
// Claimed elsewhere (fangorn.network, the CLI): the view starts at the claim, found once.
|
|
347
|
+
if (state.fromBlock == null && cfg.fromBlock == null) state.fromBlock = await claimBlock(chain, FangornConfig, appId);
|
|
289
348
|
state.fromBlock ??= String(cfg.fromBlock ?? FangornConfig.appRegistryFromBlock);
|
|
290
349
|
log(`app ${cfg.app} = ${appId}, owner ${owner}, fromBlock ${state.fromBlock}`);
|
|
291
350
|
|
|
292
|
-
// 2.
|
|
293
|
-
if (!register) {
|
|
294
|
-
let onChain = null;
|
|
295
|
-
try { onChain = view.parseSchema((await fangorn.readRepo(owner, view.SCHEMA_REPO)).contents); } catch { /* none yet */ }
|
|
296
|
-
if (!onChain || canonical(onChain.types, onChain.relations) !== canonical(cfg.types, cfg.relations)) {
|
|
297
|
-
const sdir = join(dir, "schema");
|
|
298
|
-
mkdirSync(sdir, { recursive: true });
|
|
299
|
-
writeFileSync(join(sdir, "schema.json"), JSON.stringify(schemaRecords(cfg.types, cfg.relations), null, 1));
|
|
300
|
-
log(`schema ${onChain ? "changed" : "is new"}: committing ${Object.keys(cfg.types).join(", ")}`);
|
|
301
|
-
fangornCli(cfg.app, ["repo", "init", view.SCHEMA_REPO], { cwd: sdir, dry });
|
|
302
|
-
fangornCli(cfg.app, ["commit", "schema.json", "-m", "schema", "--replace"], { cwd: sdir, dry });
|
|
303
|
-
fangornCli(cfg.app, ["push"], { cwd: sdir, dry });
|
|
304
|
-
} else log("schema unchanged");
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
// 3. sources: crawl + publish straight into the app. One source failing (a site that
|
|
351
|
+
// 2. sources: crawl + publish straight into the app. One source failing (a site that
|
|
308
352
|
// blocks us, a feed that is down) keeps its last published data and does not stop the
|
|
309
353
|
// others; the ship still exits non-zero at the end, naming it.
|
|
310
354
|
const failed = [];
|
|
@@ -322,12 +366,12 @@ export async function ship(configPath, { crawl = true, deploy = true, dry = fals
|
|
|
322
366
|
}
|
|
323
367
|
}
|
|
324
368
|
|
|
325
|
-
//
|
|
369
|
+
// 3. the page, the card, the headers
|
|
326
370
|
const { site, writeCard } = await buildSite(cfg, base);
|
|
327
371
|
// site.url: the app's own domain, routed to the Worker. Otherwise its workers.dev URL,
|
|
328
372
|
// which is only known once it is deployed: the first deploy learns it and the card follows.
|
|
329
373
|
let url = cfg.site.url?.replace(/\/$/, "") ?? state.url ?? null;
|
|
330
|
-
const repos =
|
|
374
|
+
const repos = presentedRepos(cfg);
|
|
331
375
|
// Paid records are in the committed rows already, encrypted (`paid_ct`: AES-256-GCM under
|
|
332
376
|
// HMAC-SHA256(PAID_SECRET, id), see x402.js). The Worker sells keys, never records, so the
|
|
333
377
|
// data a buyer pays for is on IPFS, committed, and in their hands before they pay.
|
|
@@ -350,13 +394,13 @@ export async function ship(configPath, { crawl = true, deploy = true, dry = fals
|
|
|
350
394
|
const writeSite = (u) => writeCard(u, { fromBlock: state.fromBlock, repos, paid: sale(u).paid, access: access(u).card });
|
|
351
395
|
writeSite(url ?? `https://${cfg.site.project}.workers.dev`);
|
|
352
396
|
|
|
353
|
-
//
|
|
397
|
+
// 4. the Worker
|
|
354
398
|
const workerConfig = (u) => {
|
|
355
399
|
const path = join(dir, "wrangler.json");
|
|
356
400
|
writeFileSync(path, JSON.stringify(wranglerConfig({
|
|
357
401
|
name: cfg.site.project, site, account: cfg.site.account, cron: cfg.site.cron, url: cfg.site.url, ipfsGateway: view.ipfsGateway(),
|
|
358
402
|
config: { app: cfg.app, name: cfg.name, description: cfg.description, fromBlock: state.fromBlock, network: cfg.network,
|
|
359
|
-
repos: register ? [] : repos, paid: sale(u).sale, private: access(u).config },
|
|
403
|
+
schema: { types: cfg.types, relations: cfg.relations }, repos: register ? [] : repos, paid: sale(u).sale, private: access(u).config },
|
|
360
404
|
}), null, 1));
|
|
361
405
|
return path;
|
|
362
406
|
};
|
|
@@ -380,7 +424,7 @@ export async function ship(configPath, { crawl = true, deploy = true, dry = fals
|
|
|
380
424
|
else if (cfg.paid) log("paid: set the key secret once with `npx wrangler secret put PAID_SECRET --config .ship/wrangler.json`");
|
|
381
425
|
} else workerConfig(url ?? `https://${cfg.site.project}.workers.dev`);
|
|
382
426
|
|
|
383
|
-
//
|
|
427
|
+
// 5. ERC-8004 + the binding
|
|
384
428
|
const cardUrl = `${url}/.well-known/agent-card.json`;
|
|
385
429
|
const bound = await registry.appAgentUri(appId).catch(() => "");
|
|
386
430
|
const mint = () => {
|
|
@@ -403,7 +447,7 @@ export async function ship(configPath, { crawl = true, deploy = true, dry = fals
|
|
|
403
447
|
} else if (deploy) log("card already bound");
|
|
404
448
|
|
|
405
449
|
// A new app has no view until the cron runs, which may be hours off: build the first one now.
|
|
406
|
-
if (deploy && !dry && !register && !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)) {
|
|
407
451
|
log("no view yet: indexing it here rather than waiting for the cron");
|
|
408
452
|
await indexHere(configPath).catch((e) => log(`✗ first index: ${e.message}; the cron will build it`));
|
|
409
453
|
}
|
|
@@ -579,21 +623,18 @@ export function readStage(cfg, base, { crawl = false, only = null, log: say = lo
|
|
|
579
623
|
}
|
|
580
624
|
|
|
581
625
|
/**
|
|
582
|
-
* The whole app on this machine (devnet.js): a local chain and IPFS, the app claimed and its
|
|
583
|
-
* schema committed there, what the sources staged published into it, and its Worker under
|
|
626
|
+
* The whole app on this machine (devnet.js): a local chain and IPFS, the app claimed there, what the sources staged published into it, and its Worker under
|
|
584
627
|
* `wrangler dev` with a local R2 and an encoder on localhost (or Workers AI with ai=true).
|
|
585
628
|
* Runs the indexer once, says where everything is, and stays up until interrupted.
|
|
586
629
|
*/
|
|
587
630
|
export async function devnetShip(configPath, { crawl = false, only = null, port = 8787, ai = false } = {}) {
|
|
588
631
|
const cfg = loadConfig(configPath);
|
|
589
632
|
const base = dirname(resolve(configPath));
|
|
590
|
-
const
|
|
633
|
+
const { devnet } = await import("./devnet.js");
|
|
591
634
|
const staged = readStage(cfg, base, { crawl, only });
|
|
592
635
|
const net = await devnet({ log });
|
|
593
636
|
const owner = await net.client(cfg.app);
|
|
594
637
|
await owner.getAppRegistry().registerApp();
|
|
595
|
-
await owner.initRepo(SCHEMA_REPO);
|
|
596
|
-
await owner.uploadBatch(SCHEMA_REPO, schemaRecords(cfg.types, cfg.relations).records);
|
|
597
638
|
// The claimer publishes the sources, as `fangorn` does in a ship.
|
|
598
639
|
for (const [ns, records] of Object.entries(staged)) {
|
|
599
640
|
if (!records.length) continue;
|
|
@@ -607,7 +648,7 @@ export async function devnetShip(configPath, { crawl = false, only = null, port
|
|
|
607
648
|
const { site, writeCard } = await buildSite(cfg, base);
|
|
608
649
|
writeCard(url, { fromBlock: "0", repos });
|
|
609
650
|
const wcfg = wranglerConfig({ name: cfg.site.project, site, config: { app: cfg.app, name: cfg.name, description: cfg.description,
|
|
610
|
-
fromBlock: "0", repos, paid: null, deployment: net.deployment } });
|
|
651
|
+
fromBlock: "0", schema: { types: cfg.types, relations: cfg.relations }, repos, paid: null, deployment: net.deployment } });
|
|
611
652
|
// Its own R2 and state, apart from a deployed app's.
|
|
612
653
|
wcfg.vars.RPC_URL = net.rpcUrl;
|
|
613
654
|
wcfg.vars.IPFS_GATEWAY = net.gatewayUrl;
|
|
@@ -701,17 +742,34 @@ export async function buildLocal(configPath, out, { crawl = false, only = null,
|
|
|
701
742
|
env.cacheDir = `${homedir()}/.cache/fangorn-mcp/models`;
|
|
702
743
|
// Not the app's real id or owner: nothing here is compared with the chain.
|
|
703
744
|
const appId = `0x${createHash("sha256").update(cfg.app).digest("hex")}`, owner = `0x${"10ca1".padStart(40, "0")}`;
|
|
704
|
-
const chain = {
|
|
705
|
-
records: schemaRecords(cfg.types, cfg.relations).records.map((r) => ({ ...r, cid: r.id })) } };
|
|
745
|
+
const chain = {};
|
|
706
746
|
// Storage addresses a record by its content, and so does this.
|
|
707
|
-
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 })))
|
|
708
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");
|
|
709
767
|
rmSync(out, { recursive: true, force: true });
|
|
710
768
|
mkdirSync(join(base, ".ship"), { recursive: true });
|
|
711
769
|
const embed = view.cachedEmbed(join(base, ".ship/vectors.ndjson"));
|
|
712
|
-
const repos =
|
|
770
|
+
const repos = presentedRepos(cfg).filter((n) => !only || only.includes(n));
|
|
713
771
|
const report = await view.publishView({ fangorn: view.localFangorn(chain, { appId, owner }), app: cfg.app,
|
|
714
|
-
repos, out, embed, log: say });
|
|
772
|
+
schema: { types: cfg.types, relations: cfg.relations }, repos, out, embed, log: say });
|
|
715
773
|
const rows = Object.values(report).reduce((n, r) => n + r.added, 0);
|
|
716
774
|
say(`local view: ${out} · ${rows} records, ${embed.misses} embedded, ${rows - embed.misses} from the vector cache`);
|
|
717
775
|
return report;
|
|
@@ -720,19 +778,6 @@ export async function buildLocal(configPath, out, { crawl = false, only = null,
|
|
|
720
778
|
// ── cli ───────────────────────────────────────────────────────────────────────
|
|
721
779
|
if (process.argv[1]?.endsWith("ship.js") || process.argv[1]?.endsWith("westmarch-ship")) {
|
|
722
780
|
if (process.argv[2] === "--selfcheck") {
|
|
723
|
-
// The schema as committed, read back the way the chain returns it, must compare
|
|
724
|
-
// equal to the config, or every run re-commits an unchanged schema.
|
|
725
|
-
const { parseSchema } = await import("./view.js");
|
|
726
|
-
const types = { "t.v1": { description: "d", role_map: { title: "name", text: ["body"] }, presentation: { externalUrl: { "t.v1": "{url}" } } },
|
|
727
|
-
"u.v1": { description: "e", role_map: { title: "label" } } };
|
|
728
|
-
const relations = [{ rel: "cites", from: "t.v1", to: "u.v1" }];
|
|
729
|
-
const g = schemaRecords(types, relations);
|
|
730
|
-
// Keys reversed at every depth, as a canonical (sorted) encoding hands them back.
|
|
731
|
-
const reverse = (v) => Array.isArray(v) ? v.map(reverse)
|
|
732
|
-
: v && typeof v === "object" ? Object.fromEntries(Object.keys(v).reverse().map((k) => [k, reverse(v[k])])) : v;
|
|
733
|
-
const readBack = parseSchema({ records: g.records.map((r) => ({ ...r, cid: `cid-${r.id}`, payload: reverse(r.payload) })) });
|
|
734
|
-
if (canonical(readBack.types, readBack.relations) !== canonical(types, relations)) throw new Error("schema does not round-trip");
|
|
735
|
-
if (canonical(readBack.types, readBack.relations) === canonical({ ...types, "v.v1": {} }, relations)) throw new Error("a new type must count as a change");
|
|
736
781
|
const row = { city: "Wisconsin Rapids", state: "WI", site: "https://x", args: ["--archive-page", "/569/A"] };
|
|
737
782
|
const cmd = fill(["py", "--site", "{site}", "--city", "{city}", "{args...}", "--ns", "us-{state|lower}-{city|slug}"], row);
|
|
738
783
|
if (JSON.stringify(cmd) !== JSON.stringify(["py", "--site", "https://x", "--city", "Wisconsin Rapids", "--archive-page", "/569/A", "--ns", "us-wi-wisconsin-rapids"]))
|
|
@@ -748,9 +793,13 @@ if (process.argv[1]?.endsWith("ship.js") || process.argv[1]?.endsWith("westmarch
|
|
|
748
793
|
const dp = discovery({ name: "Q", description: "q" }, "https://q.example", null, { pass: "5000000", days: 30, call: "10000", decimals: 6 });
|
|
749
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"])
|
|
750
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");
|
|
751
800
|
if (units("0.01") !== "10000" || units("5") !== "5000000" || units("1.5", 2) !== "150") throw new Error("units");
|
|
752
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");
|
|
753
|
-
console.log("ship.js self-check ok — a
|
|
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");
|
|
754
803
|
process.exit(0);
|
|
755
804
|
}
|
|
756
805
|
const [path, ...flags] = process.argv.slice(2);
|
package/src/publish/view-cli.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// westmarch-view: publish what an app presents as a view (see view.js).
|
|
3
3
|
//
|
|
4
|
-
// westmarch-view --app my-app --out site/view [--repo catalog …] [--from-block N] [--rebake]
|
|
4
|
+
// westmarch-view --app my-app --out site/view [--config app.json] [--repo catalog …] [--from-block N] [--rebake]
|
|
5
5
|
//
|
|
6
|
+
// The schema (types, relations) is read from --config; without it every type is guessed.
|
|
6
7
|
// Reads only; needs no key. Deploy <out> with the site, and put its URL in the card's
|
|
7
8
|
// `views`. An app on the free tier needs none of this: its Worker runs the same code on a
|
|
8
9
|
// cron (site/worker.js).
|
|
9
10
|
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
10
12
|
import { homedir } from "node:os";
|
|
11
13
|
|
|
12
14
|
const flags = { repo: [] };
|
|
@@ -18,7 +20,7 @@ for (let i = 2; i < process.argv.length; i++) {
|
|
|
18
20
|
if (m[1] === "repo") flags.repo.push(v); else flags[m[1]] = v;
|
|
19
21
|
}
|
|
20
22
|
if (flags.help || !flags.app || !flags.out) {
|
|
21
|
-
console.error("usage: westmarch-view --app <name|0xid> --out <dir> [--repo <name> …] [--from-block N] [--rebake]");
|
|
23
|
+
console.error("usage: westmarch-view --app <name|0xid> --out <dir> [--config app.json] [--repo <name> …] [--from-block N] [--rebake]");
|
|
22
24
|
process.exit(flags.help ? 0 : 2);
|
|
23
25
|
}
|
|
24
26
|
|
|
@@ -31,7 +33,8 @@ const fangorn = await readOnlyFangorn();
|
|
|
31
33
|
|
|
32
34
|
const t0 = Date.now();
|
|
33
35
|
const report = await publishView({
|
|
34
|
-
fangorn, app: flags.app, repos: flags.repo,
|
|
36
|
+
fangorn, app: flags.app, repos: flags.repo,
|
|
37
|
+
schema: flags.config ? JSON.parse(readFileSync(flags.config, "utf8")) : undefined, out: flags.out,
|
|
35
38
|
fromBlock: flags["from-block"], rebake: !!flags.rebake,
|
|
36
39
|
log: (s) => console.error(s),
|
|
37
40
|
});
|
package/src/publish/view.js
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
// site/worker.js, on a cron (the free tier)
|
|
6
6
|
//
|
|
7
7
|
// Reads the registry and storage and nothing else: every repo the app presents (its
|
|
8
|
-
// members' repos, less its exclusions, `appRepos`), at their latest heads
|
|
9
|
-
//
|
|
8
|
+
// members' repos, less its exclusions, `appRepos`), at their latest heads; the schema
|
|
9
|
+
// (app.json's `types` + `relations`) comes from the caller. Embeds only records the view does not hold
|
|
10
10
|
// yet, drops removed ones, and writes, through a store (a directory or an R2 bucket):
|
|
11
11
|
//
|
|
12
12
|
// cdn/catalog
|
|
@@ -40,15 +40,9 @@ import { dirname } from "node:path";
|
|
|
40
40
|
import { EMBED_DIM, EMBED_DTYPE, EMBED_MODEL, embedDocumentDirect, packVec, unpackVec } from "../core/embed.js";
|
|
41
41
|
import { coverage as fitCoverage } from "../market/reactions.js";
|
|
42
42
|
|
|
43
|
-
/** The app owner's repo that declares the app's types: one TYPE_TAG record per type, one
|
|
44
|
-
* RELATION_TAG record per allowed reference between two of them. */
|
|
45
|
-
export const SCHEMA_REPO = "fangorn.schema";
|
|
46
|
-
export const TYPE_TAG = "fangorn.type.v1";
|
|
47
|
-
export const RELATION_TAG = "fangorn.relation.v1";
|
|
48
43
|
export const EMBEDDER = { runtime: "transformers.js", model: EMBED_MODEL, dtype: EMBED_DTYPE };
|
|
49
44
|
const SINGULAR = ["identity", "title", "subtitle", "temporal", "spatial", "media", "thread"];
|
|
50
45
|
const MULTI = ["tags", "measures", "relations", "text"];
|
|
51
|
-
const ZERO = "0x0000000000000000000000000000000000000000";
|
|
52
46
|
// Refit coverage once a domain has grown this many times over what it was fit on.
|
|
53
47
|
const COVERAGE_REFIT = 2;
|
|
54
48
|
|
|
@@ -75,17 +69,13 @@ export function composeText(fields, rm) {
|
|
|
75
69
|
return s.slice(0, 1000 - "search_document: ".length);
|
|
76
70
|
}
|
|
77
71
|
|
|
78
|
-
/**
|
|
79
|
-
export function
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
else if (r.type === RELATION_TAG && p.rel && p.from && p.to)
|
|
86
|
-
relations.push({ rel: String(p.rel), from: String(p.from), to: String(p.to) });
|
|
87
|
-
}
|
|
88
|
-
return { types, relations };
|
|
72
|
+
/** app.json's `types` + `relations` → { types: {tag: {description, role_map, presentation}}, relations }. */
|
|
73
|
+
export function schemaOf({ types = {}, relations = [] } = {}) {
|
|
74
|
+
return {
|
|
75
|
+
types: Object.fromEntries(Object.entries(types).map(([tag, t]) =>
|
|
76
|
+
[tag, { description: String(t.description ?? ""), role_map: t.role_map ?? {}, presentation: t.presentation ?? {} }])),
|
|
77
|
+
relations: relations.filter((r) => r.rel && r.from && r.to).map((r) => ({ rel: String(r.rel), from: String(r.from), to: String(r.to) })),
|
|
78
|
+
};
|
|
89
79
|
}
|
|
90
80
|
|
|
91
81
|
/** Declared means declared: every role present, left-out ones empty. */
|
|
@@ -281,7 +271,7 @@ const sha256 = (s) => createHash("sha256").update(s).digest("hex");
|
|
|
281
271
|
|
|
282
272
|
/**
|
|
283
273
|
* Bring the view up to date with what the app presents. `fangorn` is an SDK client (reads
|
|
284
|
-
* only); the view goes to `store`, or the directory `out`. `repos` narrows it to those repo
|
|
274
|
+
* only); `schema` is app.json's { types, relations } (a type it leaves out is guessed); the view goes to `store`, or the directory `out`. `repos` narrows it to those repo
|
|
285
275
|
* names (every publisher's); without it, every repo the app presents. `fromBlock` must be
|
|
286
276
|
* at or before the first update to a repo the app presents, or a repo the scan misses is left out;
|
|
287
277
|
* left out, the SDK's deployment start. `embedBatch(texts)` embeds many at once where the
|
|
@@ -290,25 +280,20 @@ const sha256 = (s) => createHash("sha256").update(s).digest("hex");
|
|
|
290
280
|
* ([{owner, repo, root}], exclusions already applied), as a Worker does across runs.
|
|
291
281
|
* Returns per-domain counts of what changed.
|
|
292
282
|
*/
|
|
293
|
-
export async function publishView({ fangorn, app, repos, out, store = fsStore(out), fromBlock, presented, rebake = false,
|
|
283
|
+
export async function publishView({ fangorn, app, schema, repos, out, store = fsStore(out), fromBlock, presented, rebake = false,
|
|
294
284
|
embed = embedDocumentDirect, embedBatch = (ts) => Promise.all(ts.map(embed)),
|
|
295
285
|
embedder = EMBEDDER, log = console.log, shardRows = 20000, concurrency = 4, open = (p) => p }) {
|
|
296
286
|
fangorn.setAppId(app);
|
|
297
287
|
const appId = fangorn.getAppId();
|
|
298
|
-
|
|
299
|
-
let schema = { types: {}, relations: [] };
|
|
300
|
-
if (owner === ZERO) log(`app ${app} is unclaimed, so it has no schema; every type will be guessed`);
|
|
301
|
-
else {
|
|
302
|
-
try { schema = parseSchema((await retry(() => fangorn.readRepo(owner, SCHEMA_REPO))).contents); }
|
|
303
|
-
catch (e) { log(`schema: none read (${e.message}); every type will be guessed`); }
|
|
304
|
-
}
|
|
288
|
+
schema = schemaOf(schema);
|
|
305
289
|
const declared = Object.keys(schema.types);
|
|
306
290
|
log(`schema: ${declared.length ? declared.join(", ") : "no declared types"}`);
|
|
307
291
|
const schemaHash = sha256(JSON.stringify(schema));
|
|
308
292
|
|
|
309
293
|
// Every repo the app presents, at its accepted head: one log scan, exclusions applied.
|
|
310
294
|
const listed = (presented ?? await retry(() => fangorn.appRepos(fromBlock == null ? {} : { fromBlock: BigInt(fromBlock) })))
|
|
311
|
-
|
|
295
|
+
// ponytail: "fangorn.schema" is where apps shipped before 2026-10-22 committed their schema; never data.
|
|
296
|
+
.filter((t) => t.repo !== "fangorn.schema" && (!repos?.length || repos.includes(t.repo)));
|
|
312
297
|
log(`${listed.length} repo(s) presented${repos?.length ? ` of ${repos.join(", ")}` : ""}`);
|
|
313
298
|
|
|
314
299
|
const report = {};
|
|
@@ -535,10 +520,9 @@ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv
|
|
|
535
520
|
|
|
536
521
|
const APP = `0x${"ab".repeat(32)}`, OWNER = `0x${"11".repeat(20)}`, PUB = `0x${"22".repeat(20)}`;
|
|
537
522
|
const TAG = "shop.item.v1";
|
|
523
|
+
const schema = { types: { [TAG]: { description: "Things for a room.", role_map: { identity: "sku", title: "name", text: "about" },
|
|
524
|
+
presentation: { externalUrl: { [TAG]: "https://shop.example/{sku}" } } } } };
|
|
538
525
|
const chain = {
|
|
539
|
-
[`${OWNER}/${SCHEMA_REPO}`]: { records: [rec("t1", TYPE_TAG, {
|
|
540
|
-
tag: TAG, description: "Things for a room.", role_map: { identity: "sku", title: "name", text: "about" },
|
|
541
|
-
presentation: { externalUrl: { [TAG]: "https://shop.example/{sku}" } } })] },
|
|
542
526
|
[`${PUB}/shop`]: { records: [
|
|
543
527
|
rec("c1", TAG, { sku: "a1", name: "Oak table", about: "a solid oak dining table", price: 3 }),
|
|
544
528
|
rec("c2", TAG, { sku: "b2", name: "Lamp", about: "brass reading lamp", price: 1 }),
|
|
@@ -557,11 +541,11 @@ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv
|
|
|
557
541
|
appendFileSync(`${out}/vectors.ndjson`, '["cut sho');
|
|
558
542
|
eq(packVec(await cachedEmbed(`${out}/vectors.ndjson`, embed)("Title: Oak")), packVec(v1), "a line cut short by a kill is skipped, not fatal");
|
|
559
543
|
embedded.length = 0; rmSync(`${out}/vectors.ndjson`);
|
|
560
|
-
const run = () => publishView({ fangorn, app: APP, out, embed, log: () => {} });
|
|
544
|
+
const run = () => publishView({ fangorn, app: APP, schema, out, embed, log: () => {} });
|
|
561
545
|
|
|
562
546
|
const d = domainFor(APP, PUB, "shop");
|
|
563
547
|
const split = mkdtempSync(`${tmpdir()}/view-`);
|
|
564
|
-
await publishView({ fangorn, app: APP, out: split, embed, log: () => {}, shardRows: 2 });
|
|
548
|
+
await publishView({ fangorn, app: APP, schema, out: split, embed, log: () => {}, shardRows: 2 });
|
|
565
549
|
eq(manifestOf(split, d).shards.map((s) => s.count), [2, 1], "rows past shardRows go to the next shard");
|
|
566
550
|
embedded.length = 0;
|
|
567
551
|
eq((await run())[d], { added: 3, removed: 0 }, "first run embeds everything");
|
|
@@ -626,16 +610,16 @@ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv
|
|
|
626
610
|
let budget = 40;
|
|
627
611
|
const capped = (ts) => (budget -= ts.length) < 0 ? Promise.reject(new Error("daily allowance used")) : Promise.all(ts.map(embed));
|
|
628
612
|
const bd = domainFor(APP, PUB, "big");
|
|
629
|
-
eq((await publishView({ fangorn, app: APP, out: big, repos: ["big"], embedBatch: capped, log: () => {} }))[bd],
|
|
613
|
+
eq((await publishView({ fangorn, app: APP, schema, out: big, repos: ["big"], embedBatch: capped, log: () => {} }))[bd],
|
|
630
614
|
{ added: 32, removed: 0, partial: true }, "an encoder that gives out keeps what it embedded");
|
|
631
615
|
eq([manifestOf(big, bd).count, manifestOf(big, bd).head], [32, null], "served, and the head not taken");
|
|
632
616
|
budget = 1e9;
|
|
633
|
-
eq((await publishView({ fangorn, app: APP, out: big, repos: ["big"], embedBatch: capped, log: () => {} }))[bd],
|
|
617
|
+
eq((await publishView({ fangorn, app: APP, schema, out: big, repos: ["big"], embedBatch: capped, log: () => {} }))[bd],
|
|
634
618
|
{ added: 48, removed: 0 }, "the next run embeds only the rest");
|
|
635
619
|
eq(manifestOf(big, bd).count, 80, "and serves all of it");
|
|
636
620
|
delete chain[BIG];
|
|
637
621
|
rmSync(big, { recursive: true, force: true });
|
|
638
|
-
eq((await publishView({ fangorn, app: APP, out, embed, rebake: true, log: () => {} }))[d],
|
|
622
|
+
eq((await publishView({ fangorn, app: APP, schema, out, embed, rebake: true, log: () => {} }))[d],
|
|
639
623
|
{ added: 2, removed: 0 }, "--rebake starts the domain over");
|
|
640
624
|
eq(readdirSync(`${out}/cdn/domains/${d}/shards`).length, 1, "and leaves no orphaned shards");
|
|
641
625
|
rmSync(out, { recursive: true, force: true });
|
|
@@ -644,16 +628,16 @@ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv
|
|
|
644
628
|
// undeclared one is left alone; a target whose identity moves relinks the row without embedding it.
|
|
645
629
|
{
|
|
646
630
|
const T = "civic.item.v1", M = "civic.matter.v1";
|
|
647
|
-
const
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
631
|
+
const relSchema = { types: { [T]: { role_map: { identity: "item_id", title: "heading", thread: "part_of" } },
|
|
632
|
+
[M]: { role_map: { identity: "matter_id", title: "title" } } },
|
|
633
|
+
relations: [{ rel: "part_of", from: T, to: M }] };
|
|
634
|
+
const rel = {
|
|
651
635
|
[`${PUB}/civic`]: { records: [
|
|
652
636
|
rec("i1", T, { item_id: "a#1", heading: "Rezone, committee", part_of: { local: "m" } }),
|
|
653
637
|
rec("i2", T, { item_id: "b#4", heading: "Rezone, council", cites: { local: "m" } }),
|
|
654
638
|
rec("m1", M, { matter_id: "plover:7", title: "Rezone" }, "m")] } };
|
|
655
639
|
const o = mkdtempSync(`${tmpdir()}/view-`), dr = domainFor(APP, PUB, "civic");
|
|
656
|
-
const go = () => publishView({ fangorn: localFangorn(rel, { appId: APP, owner: OWNER }), app: APP, out: o, embed, log: () => {} });
|
|
640
|
+
const go = () => publishView({ fangorn: localFangorn(rel, { appId: APP, owner: OWNER }), app: APP, schema: relSchema, out: o, embed, log: () => {} });
|
|
657
641
|
const rows = async () => Object.fromEntries([...(await loadDomain(fsStore(o), `cdn/domains/${dr}`)).rows.values()]
|
|
658
642
|
.map((r) => [r.fields.heading ?? r.fields.title, r.fields.part_of ?? r.fields.cites ?? null]));
|
|
659
643
|
await go();
|
|
@@ -682,7 +666,7 @@ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv
|
|
|
682
666
|
},
|
|
683
667
|
};
|
|
684
668
|
const r2 = r2Store(bucket);
|
|
685
|
-
const via = await publishView({ fangorn, app: APP, store: r2, embed, log: () => {} });
|
|
669
|
+
const via = await publishView({ fangorn, app: APP, schema, store: r2, embed, log: () => {} });
|
|
686
670
|
eq(Object.keys(via), [d], "a view builds into a bucket");
|
|
687
671
|
eq(await r2.list("cdn/domains"), [d], "list names a folder's children");
|
|
688
672
|
eq((await liveFields(r2)).length, 2, "and reads back");
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# A Fangorn app
|
|
2
|
+
|
|
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 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.
|
|
29
|
+
|
|
30
|
+
## Publish data
|
|
31
|
+
|
|
32
|
+
Any member of the app commits records of its type, from anywhere:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
echo '{"records":[{"id":"first","type":"my-app.item.v1","payload":{"id":"first","title":"Hello","text":"The first item.","tags":["demo"]}}]}' > items.json
|
|
36
|
+
fangorn --app my-app repo init items
|
|
37
|
+
fangorn --app my-app commit items.json -m "first items"
|
|
38
|
+
fangorn --app my-app push
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The view picks it up within 5 minutes at `<url>/view`. A source that crawls something
|
|
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.
|
|
45
|
+
|
|
46
|
+
## Keep it good
|
|
47
|
+
|
|
48
|
+
Write `eval/goals.md` (who it serves) and `eval/golden.jsonl` (one check per line:
|
|
49
|
+
`{"id","q","expect","min","goal"}`), then grade the live view:
|
|
50
|
+
`npx westmarch-eval <url>/view`.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"app": "my-app",
|
|
3
|
+
"name": "my-app",
|
|
4
|
+
"description": "One sentence: what is in here, and who it is for.",
|
|
5
|
+
"network": "baseSepolia",
|
|
6
|
+
"site": {
|
|
7
|
+
"project": "my-app",
|
|
8
|
+
"examples": []
|
|
9
|
+
},
|
|
10
|
+
"tags": [],
|
|
11
|
+
"types": {
|
|
12
|
+
"my-app.item.v1": {
|
|
13
|
+
"description": "One item: what a record is, in the words a searcher would use.",
|
|
14
|
+
"role_map": {
|
|
15
|
+
"identity": "id",
|
|
16
|
+
"title": "title",
|
|
17
|
+
"tags": ["tags"],
|
|
18
|
+
"text": ["text"]
|
|
19
|
+
},
|
|
20
|
+
"presentation": {
|
|
21
|
+
"types": { "my-app.item.v1": { "singular": "item", "plural": "items" } },
|
|
22
|
+
"facets": ["tags"]
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
},
|
|
26
|
+
"sources": []
|
|
27
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Goals
|
|
2
|
+
|
|
3
|
+
One section per kind of person this app serves. Each check in golden.jsonl names one with `goal`.
|
|
4
|
+
|
|
5
|
+
## <goal-id>: who finds what
|
|
6
|
+
- **They ask:** two or three real questions, in their words.
|
|
7
|
+
- **Failure looks like:** what a wrong or missing answer would be.
|
|
File without changes
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"private": true,
|
|
3
|
+
"type": "module",
|
|
4
|
+
"scripts": {
|
|
5
|
+
"build": "westmarch build",
|
|
6
|
+
"deploy": "wrangler deploy"
|
|
7
|
+
},
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"@fangorn-network/westmarch": "2026.10.2-2.0-dev",
|
|
10
|
+
"@huggingface/transformers": "^4.3.1",
|
|
11
|
+
"wrangler": "^4.83.0"
|
|
12
|
+
}
|
|
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
|
+
}
|