@fangorn-network/westmarch 2026.10.2-2.0-dev → 2026.10.2-7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/agent/agent-card.js +18 -1
- package/src/agent/mcp.js +15 -5
- package/src/discover/apps.js +15 -1
- package/src/publish/ship.js +53 -5
- package/template/README.md +3 -2
- package/template/package.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fangorn-network/westmarch",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.10.27.1",
|
|
4
4
|
"description": "all-in-one builder for fangorn apps",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"@fangorn-network/markov": "^0.1.0"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
|
-
"@fangorn-network/sdk": "^2026.10.
|
|
48
|
+
"@fangorn-network/sdk": "^2026.10.26-dev",
|
|
49
49
|
"@huggingface/transformers": "^4.2.0",
|
|
50
50
|
"@noble/curves": "^1.9.1",
|
|
51
51
|
"viem": "^2.55.1"
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
}
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
|
-
"@fangorn-network/sdk": "^2026.10.
|
|
59
|
+
"@fangorn-network/sdk": "^2026.10.26-dev",
|
|
60
60
|
"@huggingface/transformers": "^4.2.0",
|
|
61
61
|
"@noble/curves": "^1.9.1",
|
|
62
62
|
"@privy-io/react-auth": "^3.47.0",
|
package/src/agent/agent-card.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// point back at it, so writing someone else's appId here gets you nothing.
|
|
18
18
|
|
|
19
19
|
import { FANGORN_APP_EXTENSION, BaseSepoliaConfig, appId as hashAppId } from "@fangorn-network/sdk";
|
|
20
|
+
import { PLUGIN_MARKETPLACE, PLUGIN_NAME } from "../discover/apps.js";
|
|
20
21
|
|
|
21
22
|
/** Run `register` against a recording `document.modelContext`; return what it
|
|
22
23
|
* registered. `register` is whatever the page calls at load. */
|
|
@@ -48,6 +49,10 @@ const HEX32 = /^0x[0-9a-fA-F]{64}$/;
|
|
|
48
49
|
* tools, as the free tier's Worker does at /mcp.
|
|
49
50
|
* config the deployment; defaults to the SDK's, so the addresses in the
|
|
50
51
|
* card cannot drift from the ones `discoverApp` checks.
|
|
52
|
+
* skill `{url, sha256}`: the app's own agent skill (a SKILL.md), for agents
|
|
53
|
+
* that adopt it, so its behaviour travels with the app and not with a
|
|
54
|
+
* marketplace. The hash pins what the reader saw: an agent adopts it
|
|
55
|
+
* only on its user's yes, and asks again when the hash changes.
|
|
51
56
|
*/
|
|
52
57
|
export function agentCard({ name, description, url, version = "0.0.0", tools = [], tags = [], skills = [], fangorn, ...rest }) {
|
|
53
58
|
if (!name || !url) throw new Error("agentCard: name and url are required");
|
|
@@ -75,7 +80,7 @@ export function agentCard({ name, description, url, version = "0.0.0", tools = [
|
|
|
75
80
|
|
|
76
81
|
/** The params `discoverApp` reads, validated the same way it validates them.
|
|
77
82
|
* A malformed card should fail the build, not the first reader. */
|
|
78
|
-
export function fangornExtension({ app, fromBlock, repos = [], views = [], mcp, paid, access, config = BaseSepoliaConfig }) {
|
|
83
|
+
export function fangornExtension({ app, fromBlock, repos = [], views = [], mcp, paid, access, skill, plugin, config = BaseSepoliaConfig }) {
|
|
79
84
|
if (!app) throw new Error("fangorn.app is required");
|
|
80
85
|
const id = HEX32.test(app) ? app : hashAppId(app);
|
|
81
86
|
const from = String(fromBlock ?? "");
|
|
@@ -96,6 +101,10 @@ export function fangornExtension({ app, fromBlock, repos = [], views = [], mcp,
|
|
|
96
101
|
}
|
|
97
102
|
// `access`: a private app. Its view and tools need a pass (`pass_url`, `pass` in base units, for
|
|
98
103
|
// `days`) or one x402 payment of `call` per tool call; describe and the catalog are free.
|
|
104
|
+
if (skill && !(/^https?:\/\//.test(skill.url ?? "") && /^[0-9a-f]{64}$/.test(skill.sha256 ?? "")))
|
|
105
|
+
throw new Error(`fangorn.skill needs an http(s) url and the sha256 of the file, in hex: ${JSON.stringify(skill)}`);
|
|
106
|
+
if (plugin && !(PLUGIN_MARKETPLACE.test(plugin.marketplace ?? "") && PLUGIN_NAME.test(plugin.name ?? "")))
|
|
107
|
+
throw new Error(`fangorn.plugin needs a marketplace (GitHub owner/repo or https URL) and a plugin name: ${JSON.stringify(plugin)}`);
|
|
99
108
|
if (access && !(/^https?:\/\//.test(access.pass_url ?? "") && /^\d+$/.test(String(access.pass)) && /^\d+$/.test(String(access.call))))
|
|
100
109
|
throw new Error(`fangorn.access needs pass_url, and pass and call in base units: ${JSON.stringify(access)}`);
|
|
101
110
|
return {
|
|
@@ -110,6 +119,8 @@ export function fangornExtension({ app, fromBlock, repos = [], views = [], mcp,
|
|
|
110
119
|
...(paid ? { paid: { url: paid.url, price: String(paid.price), asset: paid.asset, network: paid.network,
|
|
111
120
|
symbol: paid.symbol ?? "USDC", decimals: paid.decimals ?? 6, description: paid.description ?? "" } } : {}),
|
|
112
121
|
...(access ? { access } : {}),
|
|
122
|
+
...(skill ? { skill: { url: skill.url, sha256: skill.sha256 } } : {}),
|
|
123
|
+
...(plugin ? { plugin: { marketplace: plugin.marketplace, name: plugin.name } } : {}),
|
|
113
124
|
},
|
|
114
125
|
};
|
|
115
126
|
}
|
|
@@ -148,6 +159,12 @@ if (typeof process !== "undefined" && import.meta.url === `file://${process.argv
|
|
|
148
159
|
throws(() => fangornExtension({ app: "a" }), /fromBlock/, "missing fromBlock");
|
|
149
160
|
throws(() => fangornExtension({ app: "a", fromBlock: 0, views: ["javascript:alert(1)"] }), /views/, "non-http view");
|
|
150
161
|
throws(() => agentCard({ name: "X" }), /url/, "missing url");
|
|
162
|
+
const sha = "a".repeat(64);
|
|
163
|
+
eq(fangornExtension({ app: "a", fromBlock: 0, skill: { url: "https://x.test/skill/a/SKILL.md", sha256: sha } }).params.skill, { url: "https://x.test/skill/a/SKILL.md", sha256: sha }, "a skill rides in the extension");
|
|
164
|
+
throws(() => fangornExtension({ app: "a", fromBlock: 0, skill: { url: "https://x.test/s.md" } }), /skill/, "a skill without its hash");
|
|
165
|
+
throws(() => fangornExtension({ app: "a", fromBlock: 0, skill: { url: "file:///etc/passwd", sha256: sha } }), /skill/, "a non-http skill");
|
|
166
|
+
eq(fangornExtension({ app: "a", fromBlock: 0, plugin: { marketplace: "o/r", name: "a" } }).params.plugin, { marketplace: "o/r", name: "a" }, "a plugin rides in the extension");
|
|
167
|
+
throws(() => fangornExtension({ app: "a", fromBlock: 0, plugin: { marketplace: "o/r; rm -rf ~", name: "a" } }), /plugin/, "a plugin that would inject into a shell");
|
|
151
168
|
|
|
152
169
|
console.log("agent-card.js self-check ok — tools captured without executing, extension matches the SDK's, bad params fail the build");
|
|
153
170
|
}
|
package/src/agent/mcp.js
CHANGED
|
@@ -24,7 +24,7 @@ import net from "node:net";
|
|
|
24
24
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
25
25
|
import { homedir } from "node:os";
|
|
26
26
|
import { createInterface } from "node:readline";
|
|
27
|
-
import { APP_EXTENSION, listApps, toApp } from "../discover/apps.js";
|
|
27
|
+
import { APP_EXTENSION, listApps, pluginOf, skillOf, toApp } from "../discover/apps.js";
|
|
28
28
|
|
|
29
29
|
const log = (...a) => console.error("[fangorn-mcp]", ...a); // stdout is the protocol
|
|
30
30
|
// FANGORN_MCP_TRACE=1: step timings on stderr, for finding where an answer waits.
|
|
@@ -214,8 +214,9 @@ export function handlers(deps) {
|
|
|
214
214
|
if (!mcp) throw new Error(`${card.name ?? cardUrl} has no MCP server (its card has no \`mcp\`)`);
|
|
215
215
|
let s = slug(card.name ?? app);
|
|
216
216
|
while (open.has(s)) s += "-";
|
|
217
|
-
const
|
|
218
|
-
const
|
|
217
|
+
const ext = card.capabilities?.extensions?.find((e) => e.uri === APP_EXTENSION);
|
|
218
|
+
const access = ext?.params?.access ?? null;
|
|
219
|
+
const o = { slug: s, name: card.name, card: cardUrl, appId, skill: skillOf(ext), plugin: pluginOf(ext), ...await deps.remoteTools(mcp, { access }) };
|
|
219
220
|
trace(`open ${app}: ready`);
|
|
220
221
|
open.set(o.slug, o);
|
|
221
222
|
deps.notify?.("notifications/tools/list_changed");
|
|
@@ -249,7 +250,13 @@ export function handlers(deps) {
|
|
|
249
250
|
"open-app": async ({ app }) => {
|
|
250
251
|
const o = await openApp(app);
|
|
251
252
|
return text({ app: o.slug, name: o.name, card: o.card,
|
|
252
|
-
call_as: `${o.slug}__<tool>, or call-app-tool`, tools: o.tools
|
|
253
|
+
call_as: `${o.slug}__<tool>, or call-app-tool`, tools: o.tools,
|
|
254
|
+
// An app's own skill: standing instructions for the reader's agent, so never loaded
|
|
255
|
+
// silently. The fangorn-index skill offers it to the user and pins the hash on a yes.
|
|
256
|
+
...(o.skill ? { skill: { ...o.skill, adopt: "The app ships an agent skill. Offer it to the user (what it would make you do, from its description); adopt it only on their yes, checking sha256, and ask again if the hash changes." } } : {}),
|
|
257
|
+
// Its Claude Code plugin: hooks run code on every matching event, so the same rule, louder.
|
|
258
|
+
...(o.plugin ? { plugin: { ...o.plugin, install: `claude plugin install ${o.plugin.name} --marketplace ${o.plugin.marketplace}`,
|
|
259
|
+
adopt: "The app ships a Claude Code plugin, whose hooks run code on this machine without being called. Offer it to the user once, saying what its hooks trigger on; install only on their yes. It takes effect next session." } } : {}) });
|
|
253
260
|
},
|
|
254
261
|
"call-app-tool": async ({ app, tool, arguments: args }) => call(app, tool, args),
|
|
255
262
|
};
|
|
@@ -298,7 +305,8 @@ if (process.argv[2] === "--selfcheck") {
|
|
|
298
305
|
findApps: async () => ({ apps: [{ name: "Kings Foil", desc: "trials", card: "https://k.test/card", appId: "0xk" },
|
|
299
306
|
{ name: "Bare", desc: "", card: "https://b.test/card", appId: "0xb" }], rejected: [{}] }),
|
|
300
307
|
exploreApps: async (q) => ({ ranked_by: q ? "meaning" : null, apps: [{ app: "kings-foil", name: "Kings Foil", relevant: true }, { app: "b", name: "B", relevant: false }] }),
|
|
301
|
-
resolveCard: async (url) => (url.includes("b.test") ? { card: { name: "Bare" }, mcp: null } : { card: { name: "Kings Foil"
|
|
308
|
+
resolveCard: async (url) => (url.includes("b.test") ? { card: { name: "Bare" }, mcp: null } : { card: { name: "Kings Foil", capabilities: { extensions: [{ uri: APP_EXTENSION,
|
|
309
|
+
params: { skill: { url: "https://k.test/skill/kings-foil/SKILL.md", sha256: "b".repeat(64) }, plugin: { marketplace: "k/kf", name: "kings-foil" } } }] } }, mcp: "https://k.test/mcp" }),
|
|
302
310
|
remoteTools: async () => (opened++, remote),
|
|
303
311
|
};
|
|
304
312
|
deps.notify = serve(handlers(deps), { input, output });
|
|
@@ -321,6 +329,8 @@ if (process.argv[2] === "--selfcheck") {
|
|
|
321
329
|
call(5, "open-app", { app: "Kings Foil" }); await wait();
|
|
322
330
|
call(6, "open-app", { app: "https://k.test/card" }); await wait();
|
|
323
331
|
assert(body(5).tools[0].name === "search" && opened === 1, "open once, by name or by card URL");
|
|
332
|
+
assert(body(5).skill?.url === "https://k.test/skill/kings-foil/SKILL.md" && /user/.test(body(5).skill.adopt), "open-app offers the app's skill, for the user to adopt");
|
|
333
|
+
assert(body(5).plugin?.marketplace === "k/kf" && /user/.test(body(5).plugin.adopt), "open-app offers the app's plugin, for the user to install");
|
|
324
334
|
assert(replies.some((r) => r.method === "notifications/tools/list_changed"), "opening notifies");
|
|
325
335
|
rpc(7, "tools/list"); call(8, "kings-foil__search", { query: "x" }); call(9, "call-app-tool", { app: "kings-foil", tool: "search" });
|
|
326
336
|
call(10, "call-app-tool", { app: "kings-foil", tool: "boom" }); call(11, "open-app", { app: "nope" }); call(13, "open-app", { app: "Bare" }); rpc(12, "nope"); await wait();
|
package/src/discover/apps.js
CHANGED
|
@@ -33,6 +33,20 @@ function safeView(url) {
|
|
|
33
33
|
* src/agent/agent-card.js's self-check fails if the two drift. */
|
|
34
34
|
export const APP_EXTENSION = "https://fangorn.network/a2a/app/v1";
|
|
35
35
|
|
|
36
|
+
/** The app's agent skill, `{url, sha256}`, when its card names one well-formed; else null. */
|
|
37
|
+
export const skillOf = (ext) => {
|
|
38
|
+
const k = ext?.params?.skill;
|
|
39
|
+
return k && safeView(k.url) && /^[0-9a-f]{64}$/.test(k.sha256 ?? "") ? { url: k.url, sha256: k.sha256 } : null;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// The app's Claude Code plugin, for what a skill can't do (hooks). Both fields reach a shell command
|
|
43
|
+
// in the reader's agent, so they are held to a GitHub owner/repo (or https URL) and a plain name.
|
|
44
|
+
export const PLUGIN_MARKETPLACE = /^([\w.-]+\/[\w.-]+|https:\/\/[\w.\/-]+)$/, PLUGIN_NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
45
|
+
export const pluginOf = (ext) => {
|
|
46
|
+
const p = ext?.params?.plugin;
|
|
47
|
+
return p && PLUGIN_MARKETPLACE.test(p.marketplace ?? "") && PLUGIN_NAME.test(p.name ?? "") ? { marketplace: p.marketplace, name: p.name } : null;
|
|
48
|
+
};
|
|
49
|
+
|
|
36
50
|
/** A verified card → an app entry. `views` is westmarch's param; the SDK does not read it. */
|
|
37
51
|
export function toApp({ card, appId, fromBlock, repos }, cardUrl) {
|
|
38
52
|
const ext = card.capabilities.extensions.find((e) => e.uri === APP_EXTENSION);
|
|
@@ -40,7 +54,7 @@ export function toApp({ card, appId, fromBlock, repos }, cardUrl) {
|
|
|
40
54
|
const p = ext?.params?.paid;
|
|
41
55
|
const paid = p && /^https?:\/\//.test(p.url ?? "") && p.url.includes("{id}") && /^\d+$/.test(String(p.price)) ? p : null;
|
|
42
56
|
return {
|
|
43
|
-
appId, card: cardUrl, fromBlock, repos, views, paid,
|
|
57
|
+
appId, card: cardUrl, fromBlock, repos, views, paid, skill: skillOf(ext), plugin: pluginOf(ext),
|
|
44
58
|
mcp: safeView(ext?.params?.mcp),
|
|
45
59
|
name: card.name ?? appId,
|
|
46
60
|
desc: card.description ?? "",
|
package/src/publish/ship.js
CHANGED
|
@@ -83,11 +83,17 @@ const log = (s) => console.error(`[ship] ${s}`);
|
|
|
83
83
|
// // (pages/intro.html, a fragment, replaces the description on the front page)
|
|
84
84
|
// "nav": [{ "href": "coverage.html", "label": "Coverage" }], // optional: header links to them
|
|
85
85
|
// "examples": ["…"], // optional: questions to start from, on the front page (and a private app's gate)
|
|
86
|
+
// "plugin": {"marketplace": "owner/repo", "name": "…"}, // optional: its Claude Code plugin (hooks), offered by open-app
|
|
87
|
+
// "skill": "skill/SKILL.md", // optional: the app's agent skill, served at skill/<app>/SKILL.md and named
|
|
88
|
+
// // in the card with its sha256; fangorn-index offers it to whoever opens the app
|
|
86
89
|
// "agent": "app/agent.js" }, // optional: the module whose registerAgent the card lists, when
|
|
87
90
|
// // `pages` replaces the stock index.html with a page of its own
|
|
88
91
|
// "types": { "<tag>": { "description": "…", "role_map": {…}, "presentation": {…} } },
|
|
89
92
|
// "relations": [{ "rel": "…", "from": "<tag>", "to": "<tag>" }],
|
|
90
93
|
// "sources": [{ "namespace": "…", "cwd": "…", "command": ["python", "-m", "…", …] }],
|
|
94
|
+
// "live": ["readings"], // optional: namespaces members push to from their own wallets (devices,
|
|
95
|
+
// // contributors), presented beside the sources'; true = every repo they push.
|
|
96
|
+
// // Without sources the app already presents every member's repo.
|
|
91
97
|
// "network": "baseSepolia", // required: the SDK's network name. baseSepolia for a new app; an app
|
|
92
98
|
// // already claimed on arbitrumSepolia keeps it (a different name re-claims it there)
|
|
93
99
|
// "fangorn": "fangorn", // the CLI that signs; e.g. "node ~/fangorn/fangorn/lib/cli/cli.js",
|
|
@@ -146,9 +152,18 @@ export function loadConfig(path) {
|
|
|
146
152
|
if (!c.site.project) throw new Error(`${path}: site.project names the Cloudflare Worker`);
|
|
147
153
|
for (const s of c.sources) if (!s.namespace || !Array.isArray(s.command))
|
|
148
154
|
throw new Error(`${path}: each source needs a namespace and a command (an argv array)`);
|
|
155
|
+
if (c.live != null && c.live !== true && !(Array.isArray(c.live) && c.live.every((n) => typeof n === "string")))
|
|
156
|
+
throw new Error(`${path}: live is true (every repo members push) or a list of the namespaces they push to`);
|
|
149
157
|
return { relations: [], tags: [], types: {}, ...c };
|
|
150
158
|
}
|
|
151
159
|
|
|
160
|
+
/** The repo names the view presents: the sources' namespaces plus `live`'s, the ones members push
|
|
161
|
+
* to themselves (devices, contributors). [] means every repo the app presents: no sources, or live: true. */
|
|
162
|
+
export function presentedRepos(cfg) {
|
|
163
|
+
if (cfg.live === true) return [];
|
|
164
|
+
return [...new Set([...cfg.sources.map((s) => s.namespace), ...(cfg.live ?? [])])];
|
|
165
|
+
}
|
|
166
|
+
|
|
152
167
|
/** The Worker's wrangler config: the stock worker, the site as its assets, its bucket, the AI
|
|
153
168
|
* binding, the cron and CONFIG. `worker` is site/worker.js unless a test points elsewhere. */
|
|
154
169
|
export function wranglerConfig({ name, site, account, cron = "*/5 * * * *", config, url, ipfsGateway, worker = join(HERE, "../../site/worker.js") }) {
|
|
@@ -359,7 +374,7 @@ export async function ship(configPath, { crawl = true, deploy = true, dry = fals
|
|
|
359
374
|
// site.url: the app's own domain, routed to the Worker. Otherwise its workers.dev URL,
|
|
360
375
|
// which is only known once it is deployed: the first deploy learns it and the card follows.
|
|
361
376
|
let url = cfg.site.url?.replace(/\/$/, "") ?? state.url ?? null;
|
|
362
|
-
const repos =
|
|
377
|
+
const repos = presentedRepos(cfg);
|
|
363
378
|
// Paid records are in the committed rows already, encrypted (`paid_ct`: AES-256-GCM under
|
|
364
379
|
// HMAC-SHA256(PAID_SECRET, id), see x402.js). The Worker sells keys, never records, so the
|
|
365
380
|
// data a buyer pays for is on IPFS, committed, and in their hands before they pay.
|
|
@@ -435,7 +450,7 @@ export async function ship(configPath, { crawl = true, deploy = true, dry = fals
|
|
|
435
450
|
} else if (deploy) log("card already bound");
|
|
436
451
|
|
|
437
452
|
// A new app has no view until the cron runs, which may be hours off: build the first one now.
|
|
438
|
-
if (deploy && !dry && !register && cfg.sources.length && !await fetch(`${url}/view/cdn/catalog`).then((r) => r.ok, () => false)) {
|
|
453
|
+
if (deploy && !dry && !register && (cfg.sources.length || cfg.live) && !await fetch(`${url}/view/cdn/catalog`).then((r) => r.ok, () => false)) {
|
|
439
454
|
log("no view yet: indexing it here rather than waiting for the cron");
|
|
440
455
|
await indexHere(configPath).catch((e) => log(`✗ first index: ${e.message}; the cron will build it`));
|
|
441
456
|
}
|
|
@@ -537,6 +552,15 @@ export async function buildSite(cfg, base) {
|
|
|
537
552
|
if (cfg.site.pages) cpSync(resolve(base, cfg.site.pages), site, { recursive: true });
|
|
538
553
|
writeFileSync(join(site, "nav.json"), JSON.stringify(cfg.site.nav ?? []));
|
|
539
554
|
writeFileSync(join(site, "examples.json"), JSON.stringify(cfg.site.examples ?? []));
|
|
555
|
+
// site.skill: the app's agent skill (a SKILL.md), served at skill/<app>/SKILL.md and named in the
|
|
556
|
+
// card with its hash, so fangorn-index can offer it to whoever opens the app.
|
|
557
|
+
let skill = null;
|
|
558
|
+
if (cfg.site.skill) {
|
|
559
|
+
const bytes = readFileSync(resolve(base, cfg.site.skill));
|
|
560
|
+
mkdirSync(join(site, "skill", cfg.app), { recursive: true });
|
|
561
|
+
writeFileSync(join(site, "skill", cfg.app, "SKILL.md"), bytes);
|
|
562
|
+
skill = { path: `skill/${cfg.app}/SKILL.md`, sha256: createHash("sha256").update(bytes).digest("hex") };
|
|
563
|
+
}
|
|
540
564
|
// The card lists the tools the page at `url` registers: the stock page's, or the app's own.
|
|
541
565
|
const card = await import("../agent/agent-card.js");
|
|
542
566
|
const { registerAgent } = await import(cfg.site.agent ? pathToFileURL(resolve(base, cfg.site.agent)).href : "../../site/agent.js");
|
|
@@ -552,7 +576,9 @@ export async function buildSite(cfg, base) {
|
|
|
552
576
|
...(paid ? [{ id: "keys", name: "Keys", tags: [...(cfg.tags ?? []), "x402"],
|
|
553
577
|
description: `The keys to sealed records (POST ${u}/paid), per record.` }] : []),
|
|
554
578
|
],
|
|
555
|
-
fangorn: { app: cfg.app, fromBlock, repos, views: [`${u}/view`], mcp: `${u}/mcp`, config, ...(paid ? { paid } : {}), ...(access ? { access } : {})
|
|
579
|
+
fangorn: { app: cfg.app, fromBlock, repos, views: [`${u}/view`], mcp: `${u}/mcp`, config, ...(paid ? { paid } : {}), ...(access ? { access } : {}),
|
|
580
|
+
...(skill ? { skill: { url: `${u}/${skill.path}`, sha256: skill.sha256 } } : {}),
|
|
581
|
+
...(cfg.site.plugin ? { plugin: cfg.site.plugin } : {}) },
|
|
556
582
|
});
|
|
557
583
|
mkdirSync(join(site, ".well-known"), { recursive: true });
|
|
558
584
|
writeFileSync(join(site, ".well-known/agent-card.json"), JSON.stringify(agentCard, null, 1));
|
|
@@ -732,12 +758,30 @@ export async function buildLocal(configPath, out, { crawl = false, only = null,
|
|
|
732
758
|
const appId = `0x${createHash("sha256").update(cfg.app).digest("hex")}`, owner = `0x${"10ca1".padStart(40, "0")}`;
|
|
733
759
|
const chain = {};
|
|
734
760
|
// Storage addresses a record by its content, and so does this.
|
|
735
|
-
for (const [ns, records] of Object.entries(readStage(cfg, base, { crawl, only, log: say })))
|
|
761
|
+
if (cfg.sources.length) for (const [ns, records] of Object.entries(readStage(cfg, base, { crawl, only, log: say })))
|
|
736
762
|
chain[`${owner}/${ns}`] = { records: records.map((r) => ({ ...r, cid: createHash("sha256").update(JSON.stringify(stable(r.payload))).digest("hex") })) };
|
|
763
|
+
// What members already pushed to the live namespaces, read from the chain (reads only, no gas),
|
|
764
|
+
// so a recipe is graded on contributed data too, not just on what the sources staged.
|
|
765
|
+
if (cfg.live) {
|
|
766
|
+
if (cfg.network) process.env.FANGORN_NETWORK = process.env.CHAIN_NAME = cfg.network;
|
|
767
|
+
const fangorn = await view.readOnlyFangorn();
|
|
768
|
+
fangorn.setAppId(cfg.app);
|
|
769
|
+
const statePath = join(base, ".ship/state.json");
|
|
770
|
+
const fromBlock = (existsSync(statePath) ? JSON.parse(readFileSync(statePath, "utf8")).fromBlock : null) ?? cfg.fromBlock;
|
|
771
|
+
const staged = new Set(cfg.sources.map((s) => s.namespace));
|
|
772
|
+
for (const t of await fangorn.appRepos(fromBlock == null ? {} : { fromBlock: BigInt(fromBlock) })) {
|
|
773
|
+
if (t.repo === "fangorn.schema" || staged.has(t.repo) || (only && !only.includes(t.repo))
|
|
774
|
+
|| (cfg.live !== true && !cfg.live.includes(t.repo))) continue;
|
|
775
|
+
const { contents } = await fangorn.readRepo(t.owner, t.repo);
|
|
776
|
+
chain[`${t.owner.toLowerCase()}/${t.repo}`] = { records: contents.records };
|
|
777
|
+
say(`live ${t.owner}/${t.repo}: ${contents.records.length} records, from the chain`);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
if (!Object.keys(chain).length) throw new Error("nothing to build: no sources staged anything and no member has pushed to a live namespace");
|
|
737
781
|
rmSync(out, { recursive: true, force: true });
|
|
738
782
|
mkdirSync(join(base, ".ship"), { recursive: true });
|
|
739
783
|
const embed = view.cachedEmbed(join(base, ".ship/vectors.ndjson"));
|
|
740
|
-
const repos =
|
|
784
|
+
const repos = presentedRepos(cfg).filter((n) => !only || only.includes(n));
|
|
741
785
|
const report = await view.publishView({ fangorn: view.localFangorn(chain, { appId, owner }), app: cfg.app,
|
|
742
786
|
schema: { types: cfg.types, relations: cfg.relations }, repos, out, embed, log: say });
|
|
743
787
|
const rows = Object.values(report).reduce((n, r) => n + r.added, 0);
|
|
@@ -763,6 +807,10 @@ if (process.argv[1]?.endsWith("ship.js") || process.argv[1]?.endsWith("westmarch
|
|
|
763
807
|
const dp = discovery({ name: "Q", description: "q" }, "https://q.example", null, { pass: "5000000", days: 30, call: "10000", decimals: 6 });
|
|
764
808
|
if (dp.openapi.paths["/pass"].post["x-payment-info"].price.amount !== "5" || dp.x402.resources.join() !== "https://q.example/pass" || dp.openapi.paths["/paid"])
|
|
765
809
|
throw new Error(`private discovery: ${JSON.stringify(dp.x402)}`);
|
|
810
|
+
const src = [{ namespace: "towns", command: ["py"] }];
|
|
811
|
+
if (presentedRepos({ sources: src }).join() !== "towns" || presentedRepos({ sources: src, live: ["readings", "towns"] }).join() !== "towns,readings"
|
|
812
|
+
|| presentedRepos({ sources: src, live: true }).length || presentedRepos({ sources: [] }).length)
|
|
813
|
+
throw new Error("presentedRepos: sources plus live namespaces; [] (every repo) for live: true or no sources");
|
|
766
814
|
if (units("0.01") !== "10000" || units("5") !== "5000000" || units("1.5", 2) !== "150") throw new Error("units");
|
|
767
815
|
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");
|
|
768
816
|
console.log("ship.js self-check ok — a source template fills per row, the Worker config binds its bucket, AI and cron, and the paid route is priced per id in /openapi.json and named in /.well-known/x402");
|
package/template/README.md
CHANGED
|
@@ -39,8 +39,9 @@ fangorn --app my-app push
|
|
|
39
39
|
```
|
|
40
40
|
|
|
41
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
|
|
43
|
-
|
|
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.
|
|
44
45
|
|
|
45
46
|
## Keep it good
|
|
46
47
|
|