@fangorn-network/westmarch 0.0.1-dev → 0.0.2-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/README.md +2 -2
- package/consume/apps.js +4 -2
- package/consume/corpora.js +1 -1
- package/consume/directory.js +1 -1
- package/consume/embed.js +1 -1
- package/consume/lint.js +1 -1
- package/consume/mcp-bin.js +6 -0
- package/consume/mcp.js +193 -34
- package/consume/ort-preload.js +25 -0
- package/consume/rank.js +1 -1
- package/consume/roles.js +6 -4
- package/consume/shard.js +1 -1
- package/consume/steam.js +1 -1
- package/consume/taste-doc.js +1 -1
- package/consume/taste.js +1 -1
- package/consume/tools.js +28 -5
- package/consume/ui.js +1 -1
- package/consume/view-tools.js +202 -0
- package/consume/x402.js +94 -0
- package/docs/app-to-agent.md +553 -185
- package/package.json +11 -4
- package/publish/agent-card.js +12 -2
- package/publish/cohort.js +1 -1
- package/publish/demand.js +1 -1
- package/publish/enrich.js +1 -1
- package/publish/envelope.js +1 -1
- package/publish/graph.js +1 -1
- package/publish/manifest.js +1 -1
- package/publish/reactions.js +4 -2
- package/publish/settle.js +1 -1
- package/publish/ship.js +378 -0
- package/publish/terms.js +1 -1
- package/publish/vault.js +1 -1
- package/publish/view-cli.js +39 -0
- package/publish/view.js +399 -0
- package/site/agent.js +36 -0
- package/site/dist/assets/__vite-browser-external-CKnVkMRn.js +1 -0
- package/site/dist/assets/embed.worker-CQqmvS52.js +1 -0
- package/site/dist/assets/index-DlzVyQ_9.js +6 -0
- package/site/dist/assets/ort-wasm-simd-threaded.asyncify-DMmc6YqF.wasm +0 -0
- package/site/dist/assets/preload-helper-uBIymjUX.js +1 -0
- package/site/dist/assets/roles-C9V_uDr_.js +1 -0
- package/site/dist/assets/roles-YDRjv_PY.js +1 -0
- package/site/dist/assets/rolldown-runtime-Dd_uD5pT.js +1 -0
- package/site/dist/assets/transformers.web-BgUm7F_-.js +46 -0
- package/site/dist/assets/transformers.web-u9uSoh5E.js +37 -0
- package/site/dist/index.html +20 -0
- package/site/feed.js +62 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fangorn-network/westmarch",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2-dev",
|
|
4
4
|
"description": "Publisher and consumer surfaces for a Fangorn app: shape a graph, publish it, read it back as a searchable, agent-drivable catalog",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"./rank": "./consume/rank.js",
|
|
14
14
|
"./apps": "./consume/apps.js",
|
|
15
15
|
"./agent-card": "./publish/agent-card.js",
|
|
16
|
+
"./view": "./publish/view.js",
|
|
16
17
|
"./roles": "./consume/roles.js",
|
|
17
18
|
"./directory": "./consume/directory.js",
|
|
18
19
|
"./taste": "./consume/taste.js",
|
|
@@ -29,10 +30,11 @@
|
|
|
29
30
|
"./demand": "./publish/demand.js",
|
|
30
31
|
"./steam": "./consume/steam.js",
|
|
31
32
|
"./tools": "./consume/tools.js",
|
|
33
|
+
"./x402": "./consume/x402.js",
|
|
32
34
|
"./package.json": "./package.json"
|
|
33
35
|
},
|
|
34
36
|
"scripts": {
|
|
35
|
-
"test": "node publish/terms.js && node publish/reactions.js && node publish/cohort.js && node publish/vault.js && node publish/graph.js && node publish/envelope.js && node publish/demand.js && node publish/settle.js && node publish/manifest.js && node publish/agent-card.js && node publish/cli.js --selfcheck && node publish/enrich.js && node consume/embed.js && node consume/rank.js && node consume/apps.js && node consume/mcp.js --selfcheck && node consume/roles.js && node consume/shard.js && node consume/directory.js && node consume/taste.js && node consume/steam.js && node consume/taste-doc.js && node consume/corpora.js && node consume/ui.js && node consume/lint.js && node consume/tools.js",
|
|
37
|
+
"test": "node publish/terms.js && node publish/reactions.js && node publish/cohort.js && node publish/vault.js && node publish/graph.js && node publish/envelope.js && node publish/demand.js && node publish/settle.js && node publish/manifest.js && node publish/agent-card.js && node publish/view.js && node publish/ship.js --selfcheck && node publish/cli.js --selfcheck && node publish/enrich.js && node consume/embed.js && node consume/rank.js && node consume/apps.js && node consume/mcp.js --selfcheck && node consume/roles.js && node consume/shard.js && node consume/directory.js && node consume/taste.js && node consume/steam.js && node consume/taste-doc.js && node consume/corpora.js && node consume/ui.js && node consume/lint.js && node consume/tools.js && node consume/x402.js && node site/feed.js",
|
|
36
38
|
"lint": "node consume/lint.js"
|
|
37
39
|
},
|
|
38
40
|
"peerDependencies": {
|
|
@@ -54,12 +56,17 @@
|
|
|
54
56
|
},
|
|
55
57
|
"bin": {
|
|
56
58
|
"westmarch-publish": "./publish/cli.js",
|
|
57
|
-
"fangorn-mcp": "./consume/mcp.js"
|
|
59
|
+
"fangorn-mcp": "./consume/mcp.js",
|
|
60
|
+
"westmarch-view": "./publish/view-cli.js",
|
|
61
|
+
"westmarch-ship": "./publish/ship.js"
|
|
58
62
|
},
|
|
59
63
|
"files": [
|
|
60
64
|
"consume",
|
|
61
65
|
"publish",
|
|
62
|
-
"docs/app-to-agent.md"
|
|
66
|
+
"docs/app-to-agent.md",
|
|
67
|
+
"site/agent.js",
|
|
68
|
+
"site/dist",
|
|
69
|
+
"site/feed.js"
|
|
63
70
|
],
|
|
64
71
|
"engines": {
|
|
65
72
|
"node": ">=20"
|
package/publish/agent-card.js
CHANGED
|
@@ -73,7 +73,7 @@ export function agentCard({ name, description, url, version = "0.0.0", tools = [
|
|
|
73
73
|
|
|
74
74
|
/** The params `discoverApp` reads, validated the same way it validates them.
|
|
75
75
|
* A malformed card should fail the build, not the first reader. */
|
|
76
|
-
export function fangornExtension({ app, fromBlock, namespaces = [], views = [], config = FangornConfig }) {
|
|
76
|
+
export function fangornExtension({ app, fromBlock, namespaces = [], views = [], paid, config = FangornConfig }) {
|
|
77
77
|
if (!app) throw new Error("fangorn.app is required");
|
|
78
78
|
const id = HEX32.test(app) ? app : hashAppId(app);
|
|
79
79
|
const from = String(fromBlock ?? "");
|
|
@@ -83,6 +83,14 @@ export function fangornExtension({ app, fromBlock, namespaces = [], views = [],
|
|
|
83
83
|
const u = new URL(v); // throws on garbage
|
|
84
84
|
if (u.protocol !== "https:" && u.protocol !== "http:") throw new Error(`fangorn.views: not http(s): ${v}`);
|
|
85
85
|
}
|
|
86
|
+
// `paid`: records sold over x402. `url` has an `{id}` for the record's id; `price` is in
|
|
87
|
+
// the token's base units. Readers only offer to buy; the 402 itself is the quote.
|
|
88
|
+
if (paid) {
|
|
89
|
+
const u = new URL(paid.url.replace("{id}", "x"));
|
|
90
|
+
if (!/^https?:$/.test(u.protocol) || !paid.url.includes("{id}")) throw new Error(`fangorn.paid.url must be http(s) with {id}: ${paid.url}`);
|
|
91
|
+
if (!/^\d+$/.test(String(paid.price))) throw new Error(`fangorn.paid.price must be base units, got ${paid.price}`);
|
|
92
|
+
if (!/^0x[0-9a-fA-F]{40}$/.test(paid.asset ?? "")) throw new Error(`fangorn.paid.asset must be a token address`);
|
|
93
|
+
}
|
|
86
94
|
return {
|
|
87
95
|
uri: FANGORN_APP_EXTENSION,
|
|
88
96
|
description: "The Fangorn app this agent fronts.",
|
|
@@ -92,12 +100,14 @@ export function fangornExtension({ app, fromBlock, namespaces = [], views = [],
|
|
|
92
100
|
appRegistry: config.appRegistryContractAddress,
|
|
93
101
|
dataRegistry: config.dataRegistryContractAddress,
|
|
94
102
|
appId: id, fromBlock: from, namespaces, views,
|
|
103
|
+
...(paid ? { paid: { url: paid.url, price: String(paid.price), asset: paid.asset, network: paid.network,
|
|
104
|
+
symbol: paid.symbol ?? "USDC", decimals: paid.decimals ?? 6, description: paid.description ?? "" } } : {}),
|
|
95
105
|
},
|
|
96
106
|
};
|
|
97
107
|
}
|
|
98
108
|
|
|
99
109
|
// ── self-check: `node publish/agent-card.js` ────────────────────────────────
|
|
100
|
-
if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
|
|
110
|
+
if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/agent-card.js")) {
|
|
101
111
|
const eq = (a, b, m) => { if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error(`${m}: ${JSON.stringify(a)} != ${JSON.stringify(b)}`); };
|
|
102
112
|
const throws = (f, re, m) => { try { f(); } catch (e) { if (re.test(e.message)) return; throw e; } throw new Error(`must throw: ${m}`); };
|
|
103
113
|
|
package/publish/cohort.js
CHANGED
|
@@ -247,7 +247,7 @@ export function readout(total, cohort, { minSupport = MIN_SUPPORT } = {}) {
|
|
|
247
247
|
}
|
|
248
248
|
|
|
249
249
|
// ── self-check: `node publish/cohort.js` ──────────────────────────────────
|
|
250
|
-
if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
|
|
250
|
+
if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/cohort.js")) {
|
|
251
251
|
const vec = (a, b) => { const v = new Float32Array(STAT_DIM); v[0] = a; v[1] = b; return v; };
|
|
252
252
|
const log = (n, seed) => Array.from({ length: n }, (_, i) => ({
|
|
253
253
|
id: `row-${seed}-${i}`, corpus: "films", reaction: i % 4 === 3 ? "skip" : "like",
|
package/publish/demand.js
CHANGED
|
@@ -383,7 +383,7 @@ export function brief(rep, { title = "demand" } = {}) {
|
|
|
383
383
|
}
|
|
384
384
|
|
|
385
385
|
// ── self-check: `node publish/demand.js` ────────────────────────────────────
|
|
386
|
-
if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
|
|
386
|
+
if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/demand.js")) {
|
|
387
387
|
const { keypair, statistics, contribute, aggregate, readout } = await import("./cohort.js");
|
|
388
388
|
|
|
389
389
|
// A shelf with two genuine regions and a hole between them. 128 dims so the
|
package/publish/enrich.js
CHANGED
|
@@ -155,7 +155,7 @@ export function enrichText({ name, path, desc, mime, abs = null }) {
|
|
|
155
155
|
// in the same browser that already runs the embedder at publish time.
|
|
156
156
|
|
|
157
157
|
// ── self-check: `node server/enrich.js` — no files, no model ──────────────────
|
|
158
|
-
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
158
|
+
if (import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/enrich.js")) {
|
|
159
159
|
const { deflateSync } = await import("node:zlib");
|
|
160
160
|
const eq = (got, want, msg) => { if (got !== want) throw new Error(`${msg}: got ${JSON.stringify(got)}`); };
|
|
161
161
|
|
package/publish/envelope.js
CHANGED
|
@@ -157,7 +157,7 @@ export async function deleteResource({ workerUrl, uploadToken, resourceId, chunk
|
|
|
157
157
|
// every resourceId ever minted by fangorn.tv is derived by this function, and a
|
|
158
158
|
// change here that shifts one byte makes ciphertext already in R2 unreachable from
|
|
159
159
|
// the resource anyone paid for. If this fails, the extraction broke money.
|
|
160
|
-
if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
|
|
160
|
+
if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/envelope.js")) {
|
|
161
161
|
const owner = `0x${"11".repeat(20)}`;
|
|
162
162
|
const uid = "Show/S1/a.mp4";
|
|
163
163
|
|
package/publish/graph.js
CHANGED
|
@@ -183,7 +183,7 @@ export function treeFromGraph(contents) {
|
|
|
183
183
|
// `process` doesn't exist in the browser, and consumers import nest() from here
|
|
184
184
|
// to build a viewer tree — so this guard has to survive being evaluated by a
|
|
185
185
|
// browser.
|
|
186
|
-
if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
|
|
186
|
+
if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/graph.js")) {
|
|
187
187
|
// sond3r's pinned names, which is also the case with every option exercised.
|
|
188
188
|
const SOND3R = {
|
|
189
189
|
file: "video",
|
package/publish/manifest.js
CHANGED
|
@@ -90,7 +90,7 @@ export function manifestFromTree(files, { defaultPrice = "1000", newUid: uid = n
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
// ── self-check: `node publish/manifest.js` ───────────────────────────────────
|
|
93
|
-
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
93
|
+
if (import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/manifest.js")) {
|
|
94
94
|
const assert = (c, m) => { if (!c) throw new Error(m); };
|
|
95
95
|
|
|
96
96
|
// PINNED. sond3r's manifests are already in R2 under this exact key; if this
|
package/publish/reactions.js
CHANGED
|
@@ -53,7 +53,9 @@
|
|
|
53
53
|
import { packVec } from "../consume/embed.js";
|
|
54
54
|
import { lineage } from "./terms.js";
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
// 32, not 8: on Kingsfoil the top 3 of 23 views held 92% of the true top 10 with 32
|
|
57
|
+
// centroids and 86% with 8, and 8 missed whole topics (ALS → neurology).
|
|
58
|
+
export const COVERAGE_K = 32;
|
|
57
59
|
export const COVERAGE_DIM = 128;
|
|
58
60
|
/** Fewest reactions that may stand behind one published centroid. */
|
|
59
61
|
export const MIN_PER_CENTROID = 8;
|
|
@@ -235,7 +237,7 @@ export function reactionCorpus(events = [], {
|
|
|
235
237
|
}
|
|
236
238
|
|
|
237
239
|
// ── self-check: `node publish/reactions.js` ────────────────────────────────
|
|
238
|
-
if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
|
|
240
|
+
if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/reactions.js")) {
|
|
239
241
|
const { splitPayment } = await import("./terms.js");
|
|
240
242
|
const A = (n) => `0x${String(n).repeat(40).slice(0, 40)}`;
|
|
241
243
|
const vec = (a, b) => { const v = new Float32Array(256); v[0] = a; v[1] = b; return v; };
|
package/publish/settle.js
CHANGED
|
@@ -70,7 +70,7 @@ export async function encryptAndUpload({ file, resourceId, workerUrl, uploadToke
|
|
|
70
70
|
// under test here is the ENCRYPTOR: chunk addressing, the AAD index binding, the
|
|
71
71
|
// ragged tail, and that pack/unpack round trip. An app's full publisher→buyer
|
|
72
72
|
// round trip belongs in that app, against its own buyer.
|
|
73
|
-
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
73
|
+
if (import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/settle.js")) {
|
|
74
74
|
const { writeFileSync, rmSync } = await import("node:fs");
|
|
75
75
|
const { unseal } = await import("@fangorn-network/sdk");
|
|
76
76
|
const { x25519 } = await import("@noble/curves/ed25519");
|
package/publish/ship.js
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// westmarch-ship: one config, one command, a live Fangorn app.
|
|
3
|
+
//
|
|
4
|
+
// westmarch-ship app.json [--no-crawl] [--no-deploy] [--dry-run]
|
|
5
|
+
//
|
|
6
|
+
// Every step checks the chain (or Cloudflare) first and only acts on a difference, so
|
|
7
|
+
// re-running is how you update: new data, a changed schema, a new source, a moved site.
|
|
8
|
+
//
|
|
9
|
+
// 1. claim the app (first run only; the claim block becomes the card's fromBlock)
|
|
10
|
+
// 2. join it as a publisher
|
|
11
|
+
// 3. commit the schema, if the one on chain differs from app.json's `types`
|
|
12
|
+
// 4. run each source, which crawls and publishes straight into the app
|
|
13
|
+
// 5. westmarch-view: the app's commits → site/view
|
|
14
|
+
// 6. the stock page, the card and _headers → site/
|
|
15
|
+
// 7. deploy to Cloudflare Pages
|
|
16
|
+
// 8. register the ERC-8004 agent and bind the card, if the chain points elsewhere
|
|
17
|
+
//
|
|
18
|
+
// Chain writes go through the `fangorn` CLI (its configured wallet signs, and
|
|
19
|
+
// `fangorn wallet` shows which). Reads go through the SDK. State lives in `.ship/` next
|
|
20
|
+
// to app.json: the site, the view (its own state, see view.js) and a small state.json.
|
|
21
|
+
|
|
22
|
+
import { spawnSync } from "node:child_process";
|
|
23
|
+
import { createHash } from "node:crypto";
|
|
24
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
25
|
+
import { createRequire } from "node:module";
|
|
26
|
+
import { dirname, join, resolve } from "node:path";
|
|
27
|
+
import { homedir } from "node:os";
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
|
|
30
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
const USDC = { "arbitrum-sepolia": "0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d", arbitrum: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
|
|
32
|
+
"base-sepolia": "0x036CbD53842c5426634e7929541eC2318f3dCF7e", base: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" };
|
|
33
|
+
const STOCK = join(HERE, "../site/dist");
|
|
34
|
+
const log = (s) => console.error(`[ship] ${s}`);
|
|
35
|
+
|
|
36
|
+
// ── config ────────────────────────────────────────────────────────────────────
|
|
37
|
+
//
|
|
38
|
+
// {
|
|
39
|
+
// "app": "quorum", "name": "Quorum", "description": "…", "tags": ["civic"],
|
|
40
|
+
// "site": { "project": "quorum", "account": "<cloudflare account id>" },
|
|
41
|
+
// "types": { "<tag>": { "description": "…", "role_map": {…}, "presentation": {…} } },
|
|
42
|
+
// "relations": [{ "rel": "…", "from": "<tag>", "to": "<tag>" }],
|
|
43
|
+
// "sources": [{ "namespace": "…", "cwd": "…", "command": ["python", "-m", "…", …] }],
|
|
44
|
+
// "fangorn": "fangorn", // the CLI that signs; e.g. "node ~/fangorn/fangorn/lib/cli/cli.js"
|
|
45
|
+
// "paid": { "price": "0.01", "network": "arbitrum-sepolia", "description": "…" }
|
|
46
|
+
// }
|
|
47
|
+
//
|
|
48
|
+
// `paid` sells per-record detail over x402 (site/paid-worker.js): each source that names a
|
|
49
|
+
// `paid_dir` gets `--paid-dir` and writes one JSON per record there; they are deployed
|
|
50
|
+
// behind the worker, and the price goes in the card. The payee is the app owner unless
|
|
51
|
+
// `paid.payTo` says otherwise; the asset is USDC on `paid.network` unless `paid.asset` does.
|
|
52
|
+
//
|
|
53
|
+
// A source is any command that publishes into `--namespace` when given `--publish`:
|
|
54
|
+
// quickbeam's scraper harness does, so every quickbeam Source is a source here. It runs
|
|
55
|
+
// with FANGORN_APP_ID set, so it publishes into this app whatever the CLI's saved app is.
|
|
56
|
+
|
|
57
|
+
/** `{field}`, `{field|slug}` and `{field|lower}` from a row; an argv item that is exactly
|
|
58
|
+
* `{args...}` splices the row's own `args` array (for flags only some rows need). */
|
|
59
|
+
export function fill(template, row) {
|
|
60
|
+
const one = (s) => s.replace(/\{(\w+)(?:\|(slug|lower))?\}/g, (_, k, f) => {
|
|
61
|
+
if (row[k] == null) throw new Error(`template needs "${k}", which ${JSON.stringify(row)} lacks`);
|
|
62
|
+
const v = String(row[k]);
|
|
63
|
+
return f === "slug" ? v.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") : f === "lower" ? v.toLowerCase() : v;
|
|
64
|
+
});
|
|
65
|
+
return Array.isArray(template) ? template.flatMap((t) => (t === "{args...}" ? row.args ?? [] : [one(t)])) : one(template);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A source with `each: "towns.json"` is a template: one source per row of that file
|
|
69
|
+
* (rows with `"skip": true` are left out). Adding a town is adding a row. */
|
|
70
|
+
export function expandSources(sources, base) {
|
|
71
|
+
return sources.flatMap((s) => {
|
|
72
|
+
if (!s.each) return [s];
|
|
73
|
+
const rows = JSON.parse(readFileSync(resolve(base, s.each), "utf8")).filter((r) => !r.skip);
|
|
74
|
+
return rows.map((r) => ({ ...s, each: undefined, row: r, namespace: fill(s.namespace, r), command: fill(s.command, r),
|
|
75
|
+
...(s.paid_dir ? { paid_dir: fill(s.paid_dir, r) } : {}) }));
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function loadConfig(path) {
|
|
80
|
+
const c = JSON.parse(readFileSync(path, "utf8"));
|
|
81
|
+
c.sources = expandSources(c.sources ?? [], dirname(resolve(path)));
|
|
82
|
+
for (const k of ["app", "name", "description", "types", "sources", "site"])
|
|
83
|
+
if (!c[k]) throw new Error(`${path}: missing "${k}"`);
|
|
84
|
+
if (!c.site.project) throw new Error(`${path}: site.project names the Cloudflare Pages project`);
|
|
85
|
+
for (const s of c.sources) if (!s.namespace || !Array.isArray(s.command))
|
|
86
|
+
throw new Error(`${path}: each source needs a namespace and a command (an argv array)`);
|
|
87
|
+
return { relations: [], tags: [], ...c };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The schema commit for `types` + `relations`: one fangorn.type.v1 vertex per type,
|
|
91
|
+
* one edge per allowed relation. */
|
|
92
|
+
export function schemaGraph(types, relations = []) {
|
|
93
|
+
const id = (tag) => `type:${tag}`;
|
|
94
|
+
return {
|
|
95
|
+
vertices: Object.entries(types).map(([tag, t]) => ({ id: id(tag), tag: "fangorn.type.v1", payload: {
|
|
96
|
+
tag, description: t.description ?? "", role_map: t.role_map ?? {}, presentation: t.presentation ?? {} } })),
|
|
97
|
+
edges: relations.map((r) => ({ rel: r.rel, from: id(r.from), to: id(r.to) })),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Keys sorted at every depth: the chain stores dag-cbor, whose maps come back in
|
|
102
|
+
* canonical key order, not the order app.json wrote them in. */
|
|
103
|
+
const stable = (v) => Array.isArray(v) ? v.map(stable)
|
|
104
|
+
: v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, stable(v[k])])) : v;
|
|
105
|
+
|
|
106
|
+
/** Same shape parseSchema returns, so a config and the chain compare directly. */
|
|
107
|
+
const canonical = (types, relations = []) => JSON.stringify(stable({
|
|
108
|
+
types: Object.fromEntries(Object.entries(types).sort(([a], [b]) => a.localeCompare(b))
|
|
109
|
+
.map(([tag, t]) => [tag, { description: t.description ?? "", role_map: t.role_map ?? {}, presentation: t.presentation ?? {} }])),
|
|
110
|
+
relations: [...relations].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))),
|
|
111
|
+
}));
|
|
112
|
+
|
|
113
|
+
// ── processes ─────────────────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
/** `${VAR}` and `${VAR:-default}` from the environment, so one app.json runs on a laptop
|
|
116
|
+
* and in CI (e.g. `"${PYTHON:-python3}"`). */
|
|
117
|
+
export const expandEnv = (s) => s.replace(/\$\{(\w+)(?::-([^}]*))?\}/g, (_, k, d) => process.env[k] ?? d ?? "");
|
|
118
|
+
|
|
119
|
+
function run(cmd, args, { cwd, env, capture = false, dry = false } = {}) {
|
|
120
|
+
cmd = expandEnv(cmd); args = args.map(expandEnv);
|
|
121
|
+
log(`$ ${[cmd, ...args].join(" ")}${cwd ? ` (in ${cwd})` : ""}`);
|
|
122
|
+
if (dry) return "";
|
|
123
|
+
const r = spawnSync(cmd, args, { cwd, env: { ...process.env, ...env }, encoding: "utf8",
|
|
124
|
+
stdio: capture ? ["ignore", "pipe", "inherit"] : "inherit" });
|
|
125
|
+
if (r.status !== 0) throw new Error(`${cmd} ${args[0]} failed (exit ${r.status})`);
|
|
126
|
+
return r.stdout ?? "";
|
|
127
|
+
}
|
|
128
|
+
const expand = (p) => p.replace(/^~(?=\/|$)/, homedir());
|
|
129
|
+
const cliArgv = (cfg) => expandEnv(process.env.FANGORN_CLI ?? cfg.fangorn ?? "fangorn").split(/\s+/).filter(Boolean).map(expand);
|
|
130
|
+
let CLI = ["fangorn"];
|
|
131
|
+
const fangornCli = (app, args, opts = {}) => run(CLI[0], [...CLI.slice(1), "--app", app, ...args], { capture: true, ...opts });
|
|
132
|
+
|
|
133
|
+
/** The CLI must be the SDK version westmarch reads with. A different one signs against
|
|
134
|
+
* another deployment's contracts: its claim and commits land where nothing here looks,
|
|
135
|
+
* and every read says the app is unclaimed. (It happened: a global 2026.08.18-dev CLI
|
|
136
|
+
* claimed and published onto the previous registries.) */
|
|
137
|
+
function assertSameDeployment(cli) {
|
|
138
|
+
const which = (c) => spawnSync("sh", ["-c", `command -v ${c}`], { encoding: "utf8" }).stdout.trim();
|
|
139
|
+
const script = cli.length > 1 && /(^|\/)node$/.test(cli[0]) ? cli[1] : which(cli[0]);
|
|
140
|
+
if (!script) throw new Error(`fangorn CLI not found: ${cli.join(" ")}`);
|
|
141
|
+
let d = dirname(realpathSync(script)), cliVersion = null;
|
|
142
|
+
for (; d !== dirname(d); d = dirname(d)) {
|
|
143
|
+
const pj = join(d, "package.json");
|
|
144
|
+
if (existsSync(pj)) { const p = JSON.parse(readFileSync(pj, "utf8")); if (p.name === "@fangorn-network/sdk") { cliVersion = p.version; break; } }
|
|
145
|
+
}
|
|
146
|
+
const ours = createRequire(import.meta.url)("@fangorn-network/sdk/package.json").version;
|
|
147
|
+
if (cliVersion !== ours) throw new Error(`the fangorn CLI (${cli.join(" ")}) is SDK ${cliVersion ?? "?"}, westmarch reads with ${ours}: ` +
|
|
148
|
+
"they talk to different contracts. Point app.json's \"fangorn\" at a matching CLI.");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── the steps ─────────────────────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
export async function ship(configPath, { crawl = true, deploy = true, dry = false, replace = false } = {}) {
|
|
154
|
+
const cfg = loadConfig(configPath);
|
|
155
|
+
CLI = cliArgv(cfg);
|
|
156
|
+
assertSameDeployment(CLI);
|
|
157
|
+
const base = dirname(resolve(configPath));
|
|
158
|
+
const dir = join(base, ".ship");
|
|
159
|
+
const statePath = join(dir, "state.json");
|
|
160
|
+
mkdirSync(dir, { recursive: true });
|
|
161
|
+
const state = existsSync(statePath) ? JSON.parse(readFileSync(statePath, "utf8")) : {};
|
|
162
|
+
const save = () => !dry && writeFileSync(statePath, JSON.stringify(state, null, 1));
|
|
163
|
+
|
|
164
|
+
const [{ FangornConfig }, { createPublicClient, http }, { env }, view, card] = await Promise.all([
|
|
165
|
+
import("@fangorn-network/sdk"), import("viem"),
|
|
166
|
+
import("@huggingface/transformers"), import("./view.js"), import("./agent-card.js"),
|
|
167
|
+
]);
|
|
168
|
+
env.cacheDir = `${homedir()}/.cache/fangorn-mcp/models`;
|
|
169
|
+
const fangorn = await view.readOnlyFangorn();
|
|
170
|
+
fangorn.setAppId(cfg.app);
|
|
171
|
+
const appId = fangorn.getAppId();
|
|
172
|
+
const registry = fangorn.getAppRegistry();
|
|
173
|
+
|
|
174
|
+
// 1. claim
|
|
175
|
+
const me = (fangornCli(cfg.app, ["wallet"]).match(/Address:\s+(0x[0-9a-fA-F]{40})/) ?? [])[1]?.toLowerCase();
|
|
176
|
+
if (!me) throw new Error("`fangorn wallet` shows no address; run `fangorn init`");
|
|
177
|
+
let owner = (await registry.getAppOwner()).toLowerCase();
|
|
178
|
+
if (/^0x0+$/.test(owner)) {
|
|
179
|
+
const block = await createPublicClient({ transport: http(FangornConfig.rpcUrl) }).getBlockNumber();
|
|
180
|
+
state.fromBlock = String(block);
|
|
181
|
+
log(`${cfg.app} is unclaimed; claiming it for ${me} (fromBlock ${block})`);
|
|
182
|
+
fangornCli(cfg.app, ["app", "claim"], { dry });
|
|
183
|
+
owner = me;
|
|
184
|
+
save();
|
|
185
|
+
} else if (owner !== me) {
|
|
186
|
+
throw new Error(`${cfg.app} belongs to ${owner}, not this wallet (${me}); pick another name`);
|
|
187
|
+
}
|
|
188
|
+
state.fromBlock ??= String(cfg.fromBlock ?? 0);
|
|
189
|
+
log(`app ${cfg.app} = ${appId}, owner ${owner}, fromBlock ${state.fromBlock}`);
|
|
190
|
+
|
|
191
|
+
// 2. join
|
|
192
|
+
// Both registries: claiming joins the owner to the app, but commits also need the
|
|
193
|
+
// wallet's global DataRegistry standing, or every push reverts NotRegistered().
|
|
194
|
+
const info = fangornCli(cfg.app, ["app", "info"]);
|
|
195
|
+
if (!/DataRegistry:\s+registered/.test(info) || !/This app:\s+joined/.test(info)) fangornCli(cfg.app, ["register"], { dry });
|
|
196
|
+
|
|
197
|
+
// 3. schema
|
|
198
|
+
let onChain = null;
|
|
199
|
+
try { onChain = view.parseSchema((await fangorn.readNamespace(owner, view.SCHEMA_NAMESPACE)).contents); } catch { /* none yet */ }
|
|
200
|
+
if (!onChain || canonical(onChain.types, onChain.relations) !== canonical(cfg.types, cfg.relations)) {
|
|
201
|
+
const sdir = join(dir, "schema");
|
|
202
|
+
mkdirSync(sdir, { recursive: true });
|
|
203
|
+
writeFileSync(join(sdir, "graph.json"), JSON.stringify(schemaGraph(cfg.types, cfg.relations), null, 1));
|
|
204
|
+
log(`schema ${onChain ? "changed" : "is new"}: committing ${Object.keys(cfg.types).join(", ")}`);
|
|
205
|
+
fangornCli(cfg.app, ["repo", "init", view.SCHEMA_NAMESPACE], { cwd: sdir, dry });
|
|
206
|
+
fangornCli(cfg.app, ["commit", "graph.json", "-m", "schema", "--replace"], { cwd: sdir, dry });
|
|
207
|
+
fangornCli(cfg.app, ["push"], { cwd: sdir, dry });
|
|
208
|
+
} else log("schema unchanged");
|
|
209
|
+
|
|
210
|
+
// 4. sources: crawl + publish straight into the app
|
|
211
|
+
if (crawl) for (const s of cfg.sources) {
|
|
212
|
+
const cwd = s.cwd ? resolve(base, s.cwd.replace(/^~/, homedir())) : base;
|
|
213
|
+
const paidArgs = cfg.paid && s.paid_dir ? ["--paid-dir", resolve(cwd, s.paid_dir)] : [];
|
|
214
|
+
run(s.command[0], [...s.command.slice(1), ...paidArgs, ...(replace ? ["--replace"] : []),
|
|
215
|
+
"--publish", "--namespace", s.namespace, "--fangorn-bin", CLI.join(" ")],
|
|
216
|
+
{ cwd, env: { FANGORN_APP_ID: cfg.app }, dry });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 5. the view. Its files are its state: on a fresh machine (CI), start from the deployed
|
|
220
|
+
// copy rather than re-embed everything. shard.js checks each shard against its manifest.
|
|
221
|
+
const site = join(dir, "site");
|
|
222
|
+
if (!existsSync(join(site, "view/cdn/catalog")) && state.url && !dry) await mirrorView(`${state.url}/view`, join(site, "view"));
|
|
223
|
+
const namespaces = [...new Set(cfg.sources.map((s) => s.namespace))];
|
|
224
|
+
const report = dry ? {} : await view.publishView({ fangorn, app: cfg.app, namespaces, out: join(site, "view"),
|
|
225
|
+
fromBlock: BigInt(state.fromBlock), log });
|
|
226
|
+
|
|
227
|
+
// 6. the page, the card, the headers
|
|
228
|
+
if (!existsSync(join(STOCK, "index.html"))) throw new Error(`no stock page at ${STOCK}; run \`npx vite build\` in westmarch/site`);
|
|
229
|
+
mkdirSync(site, { recursive: true });
|
|
230
|
+
for (const f of readdirSync(site, { withFileTypes: true })) if (f.name !== "view" && f.name !== "_paid") rmSync(join(site, f.name), { recursive: true, force: true });
|
|
231
|
+
cpSync(STOCK, site, { recursive: true });
|
|
232
|
+
const url = deploy && !dry ? pagesUrl(cfg) : state.url ?? `https://${cfg.site.project}.pages.dev`;
|
|
233
|
+
// Paid records: every source's paid_dir → site/_paid (served only through the worker).
|
|
234
|
+
let paid = null;
|
|
235
|
+
if (cfg.paid) {
|
|
236
|
+
const network = cfg.paid.network ?? "arbitrum-sepolia";
|
|
237
|
+
const asset = cfg.paid.asset ?? USDC[network];
|
|
238
|
+
if (!asset) throw new Error(`paid.network ${network}: no default USDC address; set paid.asset`);
|
|
239
|
+
const decimals = cfg.paid.decimals ?? 6;
|
|
240
|
+
const [whole, frac = ""] = String(cfg.paid.price).split(".");
|
|
241
|
+
const price = (BigInt(whole) * 10n ** BigInt(decimals) + BigInt((frac + "0".repeat(decimals)).slice(0, decimals) || "0")).toString();
|
|
242
|
+
paid = { url: `${url}/paid/{id}`, price, asset, network, decimals, symbol: cfg.paid.symbol ?? "USDC", description: cfg.paid.description ?? "" };
|
|
243
|
+
// Only records a live row vouches for (its committed paid_sha256): one left over from
|
|
244
|
+
// an item since removed or re-extracted is not for sale, whatever is still on disk.
|
|
245
|
+
rmSync(join(site, "_paid"), { recursive: true, force: true });
|
|
246
|
+
mkdirSync(join(site, "_paid"), { recursive: true });
|
|
247
|
+
const vouched = new Set(view.liveFields(join(site, "view")).map((f) => f.paid_sha256).filter(Boolean));
|
|
248
|
+
let n = 0;
|
|
249
|
+
for (const s of cfg.sources) {
|
|
250
|
+
const d = s.paid_dir && resolve(s.cwd ? resolve(base, s.cwd.replace(/^~/, homedir())) : base, s.paid_dir);
|
|
251
|
+
if (d && existsSync(d)) for (const f of readdirSync(d)) {
|
|
252
|
+
const bytes = readFileSync(join(d, f));
|
|
253
|
+
if (!vouched.has(createHash("sha256").update(bytes).digest("hex"))) continue;
|
|
254
|
+
writeFileSync(join(site, "_paid", f), bytes); n++;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
const worker = readFileSync(join(HERE, "../site/paid-worker.js"), "utf8").replace(/\/\*CONFIG\*\/[\s\S]*?\/\*END\*\//, JSON.stringify({
|
|
258
|
+
price, network, asset, payTo: cfg.paid.payTo ?? owner, facilitator: cfg.paid.facilitator ?? "https://facilitator.payai.network",
|
|
259
|
+
name: cfg.paid.tokenName ?? "USD Coin", version: cfg.paid.tokenVersion ?? "2", description: paid.description }));
|
|
260
|
+
writeFileSync(join(site, "_worker.js"), worker);
|
|
261
|
+
log(`paid: ${n} record(s) at ${cfg.paid.price} ${paid.symbol} on ${network}, to ${cfg.paid.payTo ?? owner}`);
|
|
262
|
+
}
|
|
263
|
+
const { registerAgent } = await import("../site/agent.js");
|
|
264
|
+
const agentCard = card.agentCard({
|
|
265
|
+
name: cfg.name, description: cfg.description, url: `${url}/`, version: new Date().toISOString().slice(0, 10),
|
|
266
|
+
tools: await card.captureTools(() => registerAgent({})), tags: cfg.tags,
|
|
267
|
+
fangorn: { app: cfg.app, fromBlock: state.fromBlock, namespaces, views: [`${url}/view`], ...(paid ? { paid } : {}) },
|
|
268
|
+
});
|
|
269
|
+
mkdirSync(join(site, ".well-known"), { recursive: true });
|
|
270
|
+
writeFileSync(join(site, ".well-known/agent-card.json"), JSON.stringify(agentCard, null, 1));
|
|
271
|
+
const immutable = " Cache-Control: public, max-age=31536000, immutable\n";
|
|
272
|
+
const domains = existsSync(join(site, "view/cdn/domains")) ? readdirSync(join(site, "view/cdn/domains")) : [];
|
|
273
|
+
writeFileSync(join(site, "_headers"),
|
|
274
|
+
"/.well-known/agent-card.json\n Access-Control-Allow-Origin: *\n"
|
|
275
|
+
+ "/view/*\n Access-Control-Allow-Origin: *\n"
|
|
276
|
+
+ `/assets/*\n${immutable}`
|
|
277
|
+
// An hour, not immutable: a shard is deleted when a record is retracted (see view.js).
|
|
278
|
+
+ domains.map((d) => `/view/cdn/domains/${d}/shards/*\n Cache-Control: public, max-age=3600\n`).join(""));
|
|
279
|
+
|
|
280
|
+
// 7. deploy
|
|
281
|
+
if (deploy) {
|
|
282
|
+
run("npx", ["wrangler", "pages", "deploy", site, "--project-name", cfg.site.project, "--branch", "main", "--commit-dirty=true"],
|
|
283
|
+
{ env: cfg.site.account ? { CLOUDFLARE_ACCOUNT_ID: cfg.site.account } : {}, dry });
|
|
284
|
+
state.url = url;
|
|
285
|
+
save();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// 8. ERC-8004 + the binding
|
|
289
|
+
const cardUrl = `${url}/.well-known/agent-card.json`;
|
|
290
|
+
const bound = await registry.appAgentUri(appId).catch(() => "");
|
|
291
|
+
if (deploy && bound !== cardUrl) {
|
|
292
|
+
log(`binding ${cardUrl} (on chain: ${bound || "nothing"})`);
|
|
293
|
+
const out = dry ? "" : fangornCli(cfg.app, ["app", "agent", cardUrl]);
|
|
294
|
+
state.agentId = (out.match(/Agent ID:\s+(\S+)/) ?? [])[1] ?? state.agentId;
|
|
295
|
+
save();
|
|
296
|
+
} else if (deploy) log("card already bound");
|
|
297
|
+
|
|
298
|
+
const added = Object.values(report).reduce((n, r) => n + r.added, 0);
|
|
299
|
+
log(`done: ${url} · card ${cardUrl} · agent ${state.agentId ?? "(unchanged)"} · +${added} records embedded`);
|
|
300
|
+
return { url, cardUrl, agentId: state.agentId, report };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Copy a deployed view (catalog, manifests, shards) to `dir`, so the next publish appends. */
|
|
304
|
+
async function mirrorView(base, dir) {
|
|
305
|
+
const get = async (p) => { const r = await fetch(`${base}/${p}`); if (!r.ok) throw new Error(`${base}/${p}: HTTP ${r.status}`); return r; };
|
|
306
|
+
let catalog;
|
|
307
|
+
try { catalog = await (await get("cdn/catalog")).json(); } catch (e) { log(`no deployed view to start from (${e.message})`); return; }
|
|
308
|
+
const { createHash } = await import("node:crypto");
|
|
309
|
+
let files = 0;
|
|
310
|
+
for (const d of catalog.domains ?? []) {
|
|
311
|
+
const m = await (await get(`cdn/domains/${d.name}/manifest`)).json();
|
|
312
|
+
mkdirSync(join(dir, "cdn/domains", d.name, "shards"), { recursive: true });
|
|
313
|
+
writeFileSync(join(dir, "cdn/domains", d.name, "manifest"), JSON.stringify(m, null, 1));
|
|
314
|
+
for (const s of m.shards ?? []) {
|
|
315
|
+
const buf = Buffer.from(await (await get(`cdn/domains/${d.name}/shards/${s.file}`)).arrayBuffer());
|
|
316
|
+
if (s.sha256 && createHash("sha256").update(buf).digest("hex") !== s.sha256) throw new Error(`${s.file} does not match its manifest`);
|
|
317
|
+
writeFileSync(join(dir, "cdn/domains", d.name, "shards", s.file), buf);
|
|
318
|
+
files++;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
writeFileSync(join(dir, "cdn/catalog"), JSON.stringify(catalog, null, 1));
|
|
322
|
+
log(`started from the deployed view: ${catalog.domains?.length ?? 0} domain(s), ${files} shard(s)`);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** The project's pages.dev URL, creating the project on first use. Pages appends a
|
|
326
|
+
* suffix when the name is taken elsewhere, so the URL is read back, never assumed. */
|
|
327
|
+
function pagesUrl(cfg) {
|
|
328
|
+
const env = cfg.site.account ? { CLOUDFLARE_ACCOUNT_ID: cfg.site.account } : {};
|
|
329
|
+
const find = () => {
|
|
330
|
+
const out = run("npx", ["wrangler", "pages", "project", "list"], { env, capture: true });
|
|
331
|
+
const row = out.split("\n").find((l) => l.split("│")[1]?.trim() === cfg.site.project);
|
|
332
|
+
const host = row?.match(/([a-z0-9-]+\.pages\.dev)/)?.[1];
|
|
333
|
+
return host ? `https://${host}` : null;
|
|
334
|
+
};
|
|
335
|
+
let url = find();
|
|
336
|
+
if (!url) {
|
|
337
|
+
run("npx", ["wrangler", "pages", "project", "create", cfg.site.project, "--production-branch", "main"], { env });
|
|
338
|
+
url = find();
|
|
339
|
+
}
|
|
340
|
+
if (!url) throw new Error(`could not read the pages.dev URL of project ${cfg.site.project}`);
|
|
341
|
+
return url;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// ── cli ───────────────────────────────────────────────────────────────────────
|
|
345
|
+
if (process.argv[1]?.endsWith("ship.js") || process.argv[1]?.endsWith("westmarch-ship")) {
|
|
346
|
+
if (process.argv[2] === "--selfcheck") {
|
|
347
|
+
// The schema as committed, read back the way the chain returns it, must compare
|
|
348
|
+
// equal to the config, or every run re-commits an unchanged schema.
|
|
349
|
+
const { parseSchema } = await import("./view.js");
|
|
350
|
+
const types = { "t.v1": { description: "d", role_map: { title: "name", text: ["body"] }, presentation: { externalUrl: { "t.v1": "{url}" } } },
|
|
351
|
+
"u.v1": { description: "e", role_map: { title: "label" } } };
|
|
352
|
+
const relations = [{ rel: "cites", from: "t.v1", to: "u.v1" }];
|
|
353
|
+
const g = schemaGraph(types, relations);
|
|
354
|
+
// Keys reversed at every depth, as a canonical (sorted) encoding hands them back.
|
|
355
|
+
const reverse = (v) => Array.isArray(v) ? v.map(reverse)
|
|
356
|
+
: v && typeof v === "object" ? Object.fromEntries(Object.keys(v).reverse().map((k) => [k, reverse(v[k])])) : v;
|
|
357
|
+
const readBack = parseSchema({
|
|
358
|
+
vertices: g.vertices.map((v) => ({ cid: `cid-${v.id}`, schemaId: v.tag, payload: reverse(v.payload) })),
|
|
359
|
+
edges: g.edges.map((e) => ({ relation: e.rel, sourceCid: `cid-${e.from}`, targetCid: `cid-${e.to}` })),
|
|
360
|
+
});
|
|
361
|
+
if (canonical(readBack.types, readBack.relations) !== canonical(types, relations)) throw new Error("schema does not round-trip");
|
|
362
|
+
if (canonical(readBack.types, readBack.relations) === canonical({ ...types, "v.v1": {} }, relations)) throw new Error("a new type must count as a change");
|
|
363
|
+
const row = { city: "Wisconsin Rapids", state: "WI", site: "https://x", args: ["--archive-page", "/569/A"] };
|
|
364
|
+
const cmd = fill(["py", "--site", "{site}", "--city", "{city}", "{args...}", "--ns", "us-{state|lower}-{city|slug}"], row);
|
|
365
|
+
if (JSON.stringify(cmd) !== JSON.stringify(["py", "--site", "https://x", "--city", "Wisconsin Rapids", "--archive-page", "/569/A", "--ns", "us-wi-wisconsin-rapids"]))
|
|
366
|
+
throw new Error(`template fill: ${JSON.stringify(cmd)}`);
|
|
367
|
+
console.log("ship.js self-check ok — a committed schema reads back equal to its config, a new type counts as a change, and a source template fills per row");
|
|
368
|
+
process.exit(0);
|
|
369
|
+
}
|
|
370
|
+
const [path, ...flags] = process.argv.slice(2);
|
|
371
|
+
if (!path || flags.includes("--help")) {
|
|
372
|
+
console.error("usage: westmarch-ship app.json [--no-crawl] [--no-deploy] [--dry-run] [--replace]");
|
|
373
|
+
process.exit(path ? 0 : 2);
|
|
374
|
+
}
|
|
375
|
+
await ship(path, { crawl: !flags.includes("--no-crawl"), deploy: !flags.includes("--no-deploy"), dry: flags.includes("--dry-run"),
|
|
376
|
+
replace: flags.includes("--replace") })
|
|
377
|
+
.catch((e) => { console.error(`[ship] ✗ ${e.message}`); process.exit(1); });
|
|
378
|
+
}
|
package/publish/terms.js
CHANGED
|
@@ -137,7 +137,7 @@ export function splitPayment(amount, { terms, publisher, lineage: up = [] } = {}
|
|
|
137
137
|
export const label = (base, currency = "USDC") => `${(Number(base) / 1e6).toFixed(2)} ${currency}`;
|
|
138
138
|
|
|
139
139
|
// ── self-check: `node publish/terms.js` ────────────────────────────────────
|
|
140
|
-
if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
|
|
140
|
+
if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/terms.js")) {
|
|
141
141
|
const A = "0x" + "a".repeat(40); // app owner
|
|
142
142
|
const P = "0x" + "b".repeat(40); // publisher
|
|
143
143
|
const S1 = "0x" + "c".repeat(40); // a source
|
package/publish/vault.js
CHANGED
|
@@ -105,7 +105,7 @@ export function tasteFrom(vertices = []) {
|
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
// ── self-check: `node publish/vault.js` ───────────────────────────────────
|
|
108
|
-
if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}`) {
|
|
108
|
+
if (typeof process !== "undefined" && import.meta.url === `file://${process.argv[1]}` && process.argv[1].endsWith("/vault.js")) {
|
|
109
109
|
const { privateKeyToAccount } = await import("viem/accounts");
|
|
110
110
|
const KEY_A = `0x${"11".repeat(32)}`, KEY_B = `0x${"22".repeat(32)}`;
|
|
111
111
|
// A "machine": a fresh client over the same key, holding no state at all.
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// westmarch-view: publish an app's committed data as a view (see view.js).
|
|
3
|
+
//
|
|
4
|
+
// westmarch-view --app my-app --namespace my-app --out site/view [--from-block N] [--rebake]
|
|
5
|
+
//
|
|
6
|
+
// Reads only; needs no key. Deploy <out> with the site, and put its URL in the card's
|
|
7
|
+
// `views`. Run it after every push, by hand, in CI, or from an agent.
|
|
8
|
+
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
|
|
11
|
+
const flags = { namespace: [] };
|
|
12
|
+
for (let i = 2; i < process.argv.length; i++) {
|
|
13
|
+
const a = process.argv[i];
|
|
14
|
+
const m = /^--([^=]+)(?:=(.*))?$/.exec(a);
|
|
15
|
+
if (!m) { console.error(`unexpected argument: ${a}`); process.exit(2); }
|
|
16
|
+
const v = m[2] ?? (["rebake", "help"].includes(m[1]) ? true : process.argv[++i]);
|
|
17
|
+
if (m[1] === "namespace") flags.namespace.push(v); else flags[m[1]] = v;
|
|
18
|
+
}
|
|
19
|
+
if (flags.help || !flags.app || !flags.namespace.length || !flags.out) {
|
|
20
|
+
console.error("usage: westmarch-view --app <name|0xid> --namespace <ns> [--namespace …] --out <dir> [--from-block N] [--rebake]");
|
|
21
|
+
process.exit(flags.help ? 0 : 2);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const [{ env }, { publishView, readOnlyFangorn }] = await Promise.all([
|
|
25
|
+
import("@huggingface/transformers"), import("./view.js"),
|
|
26
|
+
]);
|
|
27
|
+
// Shared with fangorn-mcp, so a machine downloads the model once.
|
|
28
|
+
env.cacheDir = `${homedir()}/.cache/fangorn-mcp/models`;
|
|
29
|
+
const fangorn = await readOnlyFangorn();
|
|
30
|
+
|
|
31
|
+
const t0 = Date.now();
|
|
32
|
+
const report = await publishView({
|
|
33
|
+
fangorn, app: flags.app, namespaces: flags.namespace, out: flags.out,
|
|
34
|
+
fromBlock: BigInt(flags["from-block"] ?? 0), rebake: !!flags.rebake,
|
|
35
|
+
log: (s) => console.error(s),
|
|
36
|
+
});
|
|
37
|
+
const sum = Object.values(report).reduce((a, r) => ({ added: a.added + r.added, removed: a.removed + r.removed }), { added: 0, removed: 0 });
|
|
38
|
+
console.error(`done in ${((Date.now() - t0) / 1000).toFixed(1)}s: ${Object.keys(report).length} domain(s), +${sum.added} -${sum.removed}`);
|
|
39
|
+
console.log(JSON.stringify(report));
|