@fangorn-network/westmarch 0.0.1-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/LICENSE.md +20 -0
- package/README.md +280 -0
- package/consume/apps.js +111 -0
- package/consume/corpora.js +297 -0
- package/consume/directory.js +262 -0
- package/consume/embed.js +251 -0
- package/consume/embed.worker.js +26 -0
- package/consume/lint.js +500 -0
- package/consume/mcp.js +381 -0
- package/consume/rank.js +218 -0
- package/consume/roles.js +556 -0
- package/consume/shard.js +726 -0
- package/consume/steam.js +341 -0
- package/consume/taste-doc.js +342 -0
- package/consume/taste.js +350 -0
- package/consume/tools.js +358 -0
- package/consume/ui.js +724 -0
- package/docs/app-to-agent.md +351 -0
- package/package.json +70 -0
- package/publish/agent-card.js +134 -0
- package/publish/cli.js +336 -0
- package/publish/cohort.js +349 -0
- package/publish/demand.js +537 -0
- package/publish/enrich.js +211 -0
- package/publish/envelope.js +185 -0
- package/publish/graph.js +307 -0
- package/publish/manifest.js +142 -0
- package/publish/reactions.js +343 -0
- package/publish/settle.js +165 -0
- package/publish/terms.js +200 -0
- package/publish/vault.js +162 -0
package/publish/cli.js
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The publisher's CLI. Everything an app's publish UI does, with a local key.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS IS NOT IN AN APP
|
|
5
|
+
// -------------------------
|
|
6
|
+
// A publisher should not have to clone the app they are publishing to. The
|
|
7
|
+
// browser flow costs one file-picker click and one wallet confirmation PER FILE,
|
|
8
|
+
// which makes seeding a library impossible and publishing from a chat message
|
|
9
|
+
// impossible for the same reason — but the deeper problem is that it made the
|
|
10
|
+
// app's repo a dependency of publishing to it.
|
|
11
|
+
//
|
|
12
|
+
// So this is a CLIENT of the relay's HTTP surface and of nothing else. The only
|
|
13
|
+
// app-specific thing it needs is `ns` — the namespace folded into every
|
|
14
|
+
// resourceId — and the relay declares that itself, so a publisher needs a URL, a
|
|
15
|
+
// key, and a price.
|
|
16
|
+
//
|
|
17
|
+
// PUBLISHER_PRIVATE_KEY=0x… westmarch-publish <relay> <path…> [flags]
|
|
18
|
+
//
|
|
19
|
+
// --ns=name app namespace. Defaults to what the relay declares.
|
|
20
|
+
// NEVER guessed: it is folded into every resourceId, so a
|
|
21
|
+
// wrong one mints ids nobody can buy from and orphans a
|
|
22
|
+
// library that already exists.
|
|
23
|
+
// --dir=sub/folder stage under this folder instead of the library root
|
|
24
|
+
// --price=1000 USDC base units per file (default: the relay's)
|
|
25
|
+
// --desc="…" description for every file in this run
|
|
26
|
+
// --catalog-only publish as FREE catalog entries: committed to the graph,
|
|
27
|
+
// searchable, but never encrypted, uploaded or minted as a
|
|
28
|
+
// resource. One commitStateRoot covers a graph of any size,
|
|
29
|
+
// so this is what makes bulk ingest cost one transaction
|
|
30
|
+
// instead of one per file.
|
|
31
|
+
// --register send the one-time, fee-paying register() tx if needed
|
|
32
|
+
// --setup onboard only — terms and registration. Publishes nothing,
|
|
33
|
+
// and without --register sends nothing at all: it reports
|
|
34
|
+
// what is missing and what it would cost. Ends with one
|
|
35
|
+
// `SETUP <json>` line for a caller to parse.
|
|
36
|
+
// --selfcheck run the offline self-check and exit
|
|
37
|
+
//
|
|
38
|
+
// A description is the highest-signal thing a file carries — for an image it is
|
|
39
|
+
// the ONLY thing, since nothing in a JPEG's bytes says what it is. So per-file
|
|
40
|
+
// descriptions come from a `<file>.txt` sidecar when one exists (--desc is the
|
|
41
|
+
// fallback for the rest).
|
|
42
|
+
//
|
|
43
|
+
// The key comes from the environment and never from argv — argv is visible in
|
|
44
|
+
// `ps` to every process on the box.
|
|
45
|
+
|
|
46
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
47
|
+
import { basename, join, relative, resolve, sep } from "node:path";
|
|
48
|
+
import { createPublicClient, createWalletClient, defineChain, http } from "viem";
|
|
49
|
+
import { privateKeyToAccount } from "viem/accounts";
|
|
50
|
+
import { encryptAndUpload } from "./settle.js";
|
|
51
|
+
import { resourceIdFor } from "./envelope.js";
|
|
52
|
+
import { newUid, readManifest, writeManifest } from "./manifest.js";
|
|
53
|
+
|
|
54
|
+
// ── argv ──────────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
const flags = {};
|
|
57
|
+
const args = [];
|
|
58
|
+
for (const a of process.argv.slice(2)) {
|
|
59
|
+
const m = /^--([^=]+)(?:=([\s\S]*))?$/.exec(a);
|
|
60
|
+
if (m) flags[m[1]] = m[2] ?? true;
|
|
61
|
+
else args.push(a);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── file collection ───────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
/** Sidecars and dotfiles are not products. A `.txt` NEXT TO a file it describes
|
|
67
|
+
* is a caption; an orphan `.txt` is a document someone may want to sell. */
|
|
68
|
+
export const skip = (name, isSidecar) =>
|
|
69
|
+
name.startsWith(".") || name.endsWith(".vtt") || isSidecar;
|
|
70
|
+
|
|
71
|
+
function collect(abs, root = abs) {
|
|
72
|
+
const st = statSync(abs);
|
|
73
|
+
if (st.isDirectory()) {
|
|
74
|
+
return readdirSync(abs).sort().flatMap((n) => collect(join(abs, n), root));
|
|
75
|
+
}
|
|
76
|
+
const name = basename(abs);
|
|
77
|
+
const described = name.endsWith(".txt") && existsSync(abs.slice(0, -4));
|
|
78
|
+
if (skip(name, described)) return [];
|
|
79
|
+
const rel = (root === abs ? name : relative(root, abs)).split(sep).join("/");
|
|
80
|
+
return [{ abs, rel }];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** `<file>.txt` beside the file, else --desc, else nothing. */
|
|
84
|
+
const descFor = (abs) => {
|
|
85
|
+
const side = `${abs}.txt`;
|
|
86
|
+
if (existsSync(side)) return readFileSync(side, "utf8").trim().slice(0, 2000);
|
|
87
|
+
return typeof flags.desc === "string" ? flags.desc : null;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// ── relay client ──────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
function client(relay) {
|
|
93
|
+
let token = null;
|
|
94
|
+
// Origin is sent explicitly and identically on every request: the relay
|
|
95
|
+
// derives both the SIWE message's domain and the terms URL from it, so a
|
|
96
|
+
// nonce issued under one origin and a terms acceptance under another would
|
|
97
|
+
// sign two different documents.
|
|
98
|
+
const headers = (extra = {}) => ({ Origin: relay, ...(token ? { Authorization: `Bearer ${token}` } : {}), ...extra });
|
|
99
|
+
const j = async (res) => {
|
|
100
|
+
const body = await res.json().catch(() => ({}));
|
|
101
|
+
if (!res.ok) throw new Error(body.error ?? `${res.status} ${res.statusText}`);
|
|
102
|
+
return body;
|
|
103
|
+
};
|
|
104
|
+
return {
|
|
105
|
+
setToken: (t) => { token = t; },
|
|
106
|
+
get: (path) => fetch(`${relay}${path}`, { headers: headers() }).then(j),
|
|
107
|
+
post: (path, body) => fetch(`${relay}${path}`, {
|
|
108
|
+
method: "POST", headers: headers({ "Content-Type": "application/json" }), body: JSON.stringify(body),
|
|
109
|
+
}).then(j),
|
|
110
|
+
prepare: (library) => fetch(`${relay}/api/publish/prepare`, {
|
|
111
|
+
method: "POST", headers: headers({ "Content-Type": "application/json" }), body: JSON.stringify({ library }),
|
|
112
|
+
}).then(j),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── main ──────────────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
async function main() {
|
|
119
|
+
const [relayArg, ...paths] = args;
|
|
120
|
+
if (!relayArg) {
|
|
121
|
+
console.error("usage: PUBLISHER_PRIVATE_KEY=0x… westmarch-publish <relay> <path…> [--ns=] [--dir=] [--price=] [--desc=] [--register] [--setup]");
|
|
122
|
+
process.exit(2);
|
|
123
|
+
}
|
|
124
|
+
const relay = relayArg.replace(/\/$/, "");
|
|
125
|
+
|
|
126
|
+
const key = process.env.PUBLISHER_PRIVATE_KEY;
|
|
127
|
+
if (!/^0x[0-9a-fA-F]{64}$/.test(key ?? "")) throw new Error("PUBLISHER_PRIVATE_KEY must be a 0x-prefixed 32-byte hex key (env only — never argv)");
|
|
128
|
+
const account = privateKeyToAccount(key);
|
|
129
|
+
const api = client(relay);
|
|
130
|
+
|
|
131
|
+
const cfg = await api.get("/api/config");
|
|
132
|
+
if (cfg.readOnly) throw new Error(`${relay} is read-only — it serves a published catalog but accepts no publishes. Point at the relay for the app you are publishing to.`);
|
|
133
|
+
|
|
134
|
+
// Never defaulted, never guessed. `ns` is folded into every resourceId this
|
|
135
|
+
// wallet mints, so the wrong one produces ids that address nobody's bytes and
|
|
136
|
+
// a manifest key that reads back as an empty library.
|
|
137
|
+
const ns = typeof flags.ns === "string" ? flags.ns : cfg.ns;
|
|
138
|
+
if (!ns) throw new Error(`${relay} does not declare an app namespace (config.ns) — pass --ns=<name> explicitly, and be sure it is the one this app already publishes under`);
|
|
139
|
+
|
|
140
|
+
const chain = defineChain({
|
|
141
|
+
id: cfg.chainId,
|
|
142
|
+
name: `chain-${cfg.chainId}`,
|
|
143
|
+
nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
|
|
144
|
+
rpcUrls: { default: { http: [cfg.rpc] } },
|
|
145
|
+
});
|
|
146
|
+
const wallet = createWalletClient({ account, chain, transport: http(cfg.rpc) });
|
|
147
|
+
const node = createPublicClient({ chain, transport: http(cfg.rpc) });
|
|
148
|
+
const send = async (tx) => {
|
|
149
|
+
const hash = await wallet.sendTransaction({ to: tx.to, data: tx.data, ...(tx.value ? { value: BigInt(tx.value) } : {}) });
|
|
150
|
+
const rc = await node.waitForTransactionReceipt({ hash });
|
|
151
|
+
if (rc.status !== "success") throw new Error(`tx reverted: ${hash}`);
|
|
152
|
+
return hash;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// ── sign in ───────────────────────────────────────────────────────────────
|
|
156
|
+
// This signature is also the rights attestation the relay appends to its log
|
|
157
|
+
// before minting a session. Automating it does not make it mean less: the
|
|
158
|
+
// key holder is asserting they are licensed to distribute what follows.
|
|
159
|
+
const { nonce, message } = await api.post("/api/session/nonce", { address: account.address });
|
|
160
|
+
const { token } = await api.post("/api/session", { nonce, signature: await account.signMessage({ message }) });
|
|
161
|
+
api.setToken(token);
|
|
162
|
+
console.log(`signed in as ${account.address} — publishing to ${relay} as "${ns}"`);
|
|
163
|
+
|
|
164
|
+
// ── terms + registration ──────────────────────────────────────────────────
|
|
165
|
+
let reg = await api.get("/api/registration");
|
|
166
|
+
if (!reg.acceptedTerms) {
|
|
167
|
+
const t = await api.get("/api/terms");
|
|
168
|
+
await api.post("/api/terms/accept", { signature: await account.signMessage({ message: t.message }) });
|
|
169
|
+
console.log(`accepted publisher terms (${t.hash.slice(0, 12)}…) — ${t.url}`);
|
|
170
|
+
reg = await api.get("/api/registration");
|
|
171
|
+
}
|
|
172
|
+
if (!reg.registered) {
|
|
173
|
+
// Registration costs a fee in ETH and is irreversible, so it is never
|
|
174
|
+
// sent implicitly. Two txs, in the order the relay gave: global standing
|
|
175
|
+
// in the data registry, then membership of this app. Both are needed —
|
|
176
|
+
// commitStateRoot cross-calls the app registry, so stopping after the
|
|
177
|
+
// first buys a revert at the end of the publish instead of an error here.
|
|
178
|
+
const total = BigInt(reg.fee ?? 0) + BigInt(reg.appFee ?? 0);
|
|
179
|
+
if (!flags.register) {
|
|
180
|
+
if (!flags.setup) throw new Error(`${account.address} is not a registered publisher. Re-run with --register to send ${reg.txs.length} registration tx(s) (fee: ${total} wei), or register at https://fangorn.network`);
|
|
181
|
+
} else {
|
|
182
|
+
console.log(`registering as a publisher (${reg.txs.length} tx, fee ${total} wei)…`);
|
|
183
|
+
for (const tx of reg.txs) await send(tx);
|
|
184
|
+
reg = await api.get("/api/registration");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ── setup mode ────────────────────────────────────────────────────────────
|
|
189
|
+
// Onboarding a publisher — terms, registration txs, and a bucket to put bytes
|
|
190
|
+
// in — used to be reachable only by attempting a publish and reading the
|
|
191
|
+
// error. A caller that wants to ASK "am I set up?" had nothing to call.
|
|
192
|
+
//
|
|
193
|
+
// Without --register it is a dry run: it says what is missing and what the
|
|
194
|
+
// fees would be, and sends nothing. The last line is `SETUP <json>` so a
|
|
195
|
+
// caller parses one line instead of scraping prose.
|
|
196
|
+
if (flags.setup) {
|
|
197
|
+
const state = { address: account.address, ns, registered: !!reg.registered, acceptedTerms: true };
|
|
198
|
+
const w = await api.get("/api/worker").catch(() => null);
|
|
199
|
+
// Null until this wallet is a registered publisher — the relay withholds
|
|
200
|
+
// both, which is the only way storage can be "not ready" now.
|
|
201
|
+
state.workerUrl = w?.workerUrl ?? null;
|
|
202
|
+
state.storage = !!w?.workerUrl;
|
|
203
|
+
if (!reg.registered) {
|
|
204
|
+
state.fee = (BigInt(reg.fee ?? 0) + BigInt(reg.appFee ?? 0)).toString();
|
|
205
|
+
state.txs = reg.txs?.length ?? 0;
|
|
206
|
+
}
|
|
207
|
+
console.log(`SETUP ${JSON.stringify(state)}`);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ── stage ─────────────────────────────────────────────────────────────────
|
|
212
|
+
const under = typeof flags.dir === "string" ? flags.dir.replace(/^\/+|\/+$/g, "") : "";
|
|
213
|
+
const files = paths.flatMap((p) => collect(resolve(p)));
|
|
214
|
+
if (paths.length && !files.length) throw new Error("nothing to upload — every path was a dotfile, a .vtt or a description sidecar");
|
|
215
|
+
|
|
216
|
+
// The manifest lives in the bucket, not on the relay, so this client reads
|
|
217
|
+
// it, edits it and writes it back. Both the worker and this wallet's upload
|
|
218
|
+
// token come from the relay, which stores neither: the URL is its own config
|
|
219
|
+
// and the token is derived from the address.
|
|
220
|
+
const { workerUrl, uploadToken } = await api.get("/api/worker");
|
|
221
|
+
if (!workerUrl) throw new Error(`${account.address} has no storage — it is not a registered Fangorn publisher yet (run with --setup --register, or sign up at https://fangorn.network)`);
|
|
222
|
+
const store = { ns, workerUrl, uploadToken, owner: account.address };
|
|
223
|
+
const manifest = await readManifest(store);
|
|
224
|
+
|
|
225
|
+
for (const f of files) {
|
|
226
|
+
const rel = under ? `${under}/${f.rel}` : f.rel;
|
|
227
|
+
const entry = (manifest.files[rel] ??= { uid: newUid() });
|
|
228
|
+
if (flags["catalog-only"]) entry.forSale = false;
|
|
229
|
+
else if (flags.price) entry.price = String(flags.price);
|
|
230
|
+
const desc = descFor(f.abs);
|
|
231
|
+
if (desc) entry.desc = desc;
|
|
232
|
+
|
|
233
|
+
// A free catalog entry is committed to the graph but never encrypted,
|
|
234
|
+
// uploaded or minted — which is what makes bulk ingest cost one commit
|
|
235
|
+
// instead of one createResource per file.
|
|
236
|
+
if (entry.forSale === false) {
|
|
237
|
+
console.log(`↑ ${rel} … ok (free entry)${desc ? " (described)" : ""}`);
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
process.stdout.write(`↑ ${rel} … `);
|
|
241
|
+
const out = await encryptAndUpload({
|
|
242
|
+
file: f.abs, workerUrl, uploadToken,
|
|
243
|
+
resourceId: entry.published?.resourceId ?? resourceIdFor(ns, account.address, entry.uid),
|
|
244
|
+
});
|
|
245
|
+
entry.published = { ...out, resourceId: entry.published?.resourceId, workerUrl, mime: entry.mime };
|
|
246
|
+
// Written per file: a run that dies partway must not lose the pointers
|
|
247
|
+
// already paid for in bandwidth.
|
|
248
|
+
await writeManifest({ ...store, manifest });
|
|
249
|
+
console.log(`ok${desc ? " (described)" : ""}`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── publish ───────────────────────────────────────────────────────────────
|
|
253
|
+
const library = Object.entries(manifest.files).map(([path, f]) => ({
|
|
254
|
+
path, type: "video", uid: f.uid, price: f.price, mime: f.mime,
|
|
255
|
+
desc: f.desc, forSale: f.forSale, cues: f.cues, published: f.published,
|
|
256
|
+
}));
|
|
257
|
+
const prep = await api.prepare(library);
|
|
258
|
+
|
|
259
|
+
for (const [i, c] of prep.creates.entries()) {
|
|
260
|
+
console.log(`createResource ${i + 1}/${prep.creates.length} — ${c.path}`);
|
|
261
|
+
await send(c);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// No vectors. A publisher builds and publishes the graph; embedding is not
|
|
265
|
+
// its job and never was. The commit route still takes a `vectors` map because
|
|
266
|
+
// an app's own browser flow may fill it, and an empty one is the ordinary
|
|
267
|
+
// case, not a degraded one.
|
|
268
|
+
// The pin is authorized by the publisher's own signature against their own
|
|
269
|
+
// storage quota. The challenge carries an Issued-At the gate only honours for
|
|
270
|
+
// a few minutes, so it is fetched here and not at the start of the run.
|
|
271
|
+
//
|
|
272
|
+
// `storageGate: null` is not a broken relay — it is one pinning directly on
|
|
273
|
+
// its own account, which ignores storageAuth entirely. Asking a gate that
|
|
274
|
+
// isn't there for a challenge failed a publish the relay would have accepted,
|
|
275
|
+
// and blamed a service that was never in the path.
|
|
276
|
+
let storageAuth;
|
|
277
|
+
if (cfg.storageGate) {
|
|
278
|
+
const gate = await fetch(cfg.storageGate, {
|
|
279
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
280
|
+
body: JSON.stringify({ address: account.address, size: 1 }),
|
|
281
|
+
}).then((r) => r.json()).catch(() => ({}));
|
|
282
|
+
if (!gate.challenge) throw new Error(`storage gate issued no challenge — ${cfg.storageGate} may be down`);
|
|
283
|
+
storageAuth = { message: gate.challenge, signature: await account.signMessage({ message: gate.challenge }) };
|
|
284
|
+
} else {
|
|
285
|
+
console.log("relay pins on its own account — no storage gate to authorize against");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const sealed = await api.post("/api/publish/commit", { vectors: {}, storageAuth });
|
|
289
|
+
console.log("commitStateRoot…");
|
|
290
|
+
await send(sealed.commitTx);
|
|
291
|
+
for (const [path, ptr] of Object.entries(sealed.published)) {
|
|
292
|
+
manifest.files[path].published = { ...(manifest.files[path].published ?? {}), ...ptr };
|
|
293
|
+
}
|
|
294
|
+
await writeManifest({ ...store, manifest });
|
|
295
|
+
await api.post("/api/settle", {});
|
|
296
|
+
|
|
297
|
+
const { vertices, edges } = sealed.staged;
|
|
298
|
+
console.log(`published — ${vertices} vertices, ${edges} edges`);
|
|
299
|
+
for (const [path, ptr] of Object.entries(sealed.published)) console.log(` ${path} ${ptr.resourceId}`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── self-check: `node publish/cli.js --selfcheck` (offline) ──────────────────
|
|
303
|
+
|
|
304
|
+
if (flags.selfcheck) {
|
|
305
|
+
const assert = (cond, msg) => { if (!cond) throw new Error(msg); };
|
|
306
|
+
|
|
307
|
+
// Collection: sidecars are descriptions, not products; a .txt with no file
|
|
308
|
+
// beside it still sells; nested paths keep their shape.
|
|
309
|
+
const here = new Set(["/lib/a.jpg", "/lib/notes.txt", "/lib/sub/b.mp3"]);
|
|
310
|
+
const survives = (name, abs) => !skip(name, name.endsWith(".txt") && here.has(abs.slice(0, -4)));
|
|
311
|
+
assert(survives("a.jpg", "/lib/a.jpg"), "a real file was skipped");
|
|
312
|
+
assert(!survives("a.jpg.txt", "/lib/a.jpg.txt"), "a description sidecar was published as a file");
|
|
313
|
+
assert(survives("notes.txt", "/lib/notes.txt"), "an orphan .txt should publish as a text file");
|
|
314
|
+
assert(!survives(".hidden", "/lib/.hidden"), "a dotfile was published");
|
|
315
|
+
assert(!survives("c.mp4.vtt", "/lib/c.mp4.vtt"), "a .vtt was published — /api/upload rejects these");
|
|
316
|
+
|
|
317
|
+
// A read-only relay and a relay that declares no namespace must both fail
|
|
318
|
+
// BEFORE anything is signed, uploaded or sent — and say which it was.
|
|
319
|
+
const real = globalThis.fetch;
|
|
320
|
+
const runAgainst = async (config) => {
|
|
321
|
+
globalThis.fetch = async (url) => String(url).endsWith("/api/config")
|
|
322
|
+
? { ok: true, json: async () => config }
|
|
323
|
+
: { ok: false, json: async () => ({ error: "should never be reached" }) };
|
|
324
|
+
process.env.PUBLISHER_PRIVATE_KEY = `0x${"11".repeat(32)}`;
|
|
325
|
+
args.length = 0; args.push("http://relay", "/tmp");
|
|
326
|
+
return main().then(() => "no error", (e) => e.message);
|
|
327
|
+
};
|
|
328
|
+
try {
|
|
329
|
+
assert(/read-only/.test(await runAgainst({ readOnly: true })), "a read-only relay must be refused by name");
|
|
330
|
+
assert(/namespace/.test(await runAgainst({ chainId: 1, rpc: "http://r" })), "a relay declaring no ns must be refused, not guessed at");
|
|
331
|
+
} finally { globalThis.fetch = real; delete process.env.PUBLISHER_PRIVATE_KEY; }
|
|
332
|
+
|
|
333
|
+
console.log("cli.js self-check ok — sidecars, read-only relay refused, missing ns refused");
|
|
334
|
+
} else {
|
|
335
|
+
await main().catch((e) => { console.error(`✗ ${e.message}`); process.exit(1); });
|
|
336
|
+
}
|