@artblocks/abx-cli 0.1.0-alpha.7 → 0.1.0-alpha.9

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/dist/main.js CHANGED
@@ -17,8 +17,9 @@
17
17
  * abx preview run a code project on localhost while it's still being made (no chain)
18
18
  * abx deploy-code deploy a code project (SeriesCode): --script <file> (on-chain template)
19
19
  * or --code-dir <dir> (build directory → ipfs/arweave `code` field)
20
- * abx add <address> register + index a project (--remote: on a hosted resolver, not this machine)
21
- * abx index [<address>] re-index a project from chain (replay; --remote to nudge a hosted resolver)
20
+ * abx add <address> register + index a project (--remote <name|url>: on a remote resolver, not this machine)
21
+ * abx remote [<name|url>] inspect a remote service (descriptor · chains · managed rendering · your projects there)
22
+ * abx index [<address>] re-index a project from chain (replay; --remote to nudge a remote resolver)
22
23
  * abx verify <address> re-hash served bytes vs the on-chain commitment (no server)
23
24
  * abx configure-param <addr> <id> <key> <value> set a governed PostParam (typed encode; any lane)
24
25
  * abx set-param-hooks <addr> wire/clear a SeriesCode's configure/augment/transfer param hooks
@@ -70,22 +71,20 @@ const SCHEMA_CATALOG = 'Types: Bool·Select·Uint256Range·Int256Range·DecimalR
70
71
  'Select needs options — key:Select[A|B|C]:Auth; a Range takes bounds — key:Uint256Range[0..100]:Auth. ' +
71
72
  'A palette collectors set = palette:HexColor:TokenOwner';
72
73
  import { uploadAndLocate } from './upload.js';
73
- import { assertChainId, discoverDeployBlock, deployFactory, deploySeriesFactory, deploySeries, deployOneOfOne, deployRenderer, predictRenderer, predictSeedSource, encodeTag, encodeFieldRenderer, isCodeProject, loadDotEnv, makePublicClient, makeWalletClient, oneOfOneImageAbi, oneOfOneImageFactoryAbi, seriesImageAbi, seriesImageFactoryAbi, abxMetadataRendererAbi, predictClone, probeRpcEndpoints, prepareDeployOneOfOne, prepareDeploySeries, reconstructProject, saltFor, saltGuard, resolveChain, DEFAULT_CHAIN_KEY, normalizeAttributes, parseTraitPairs, METADATA_FIELD as F, METADATA_REPRESENTATION as R, } from '@artblocks/abx-sdk';
74
+ import { assertChainId, discoverDeployBlock, deployFactory, deploySeriesFactory, deploySeries, deployOneOfOne, deployRenderer, predictRenderer, predictSeedSource, encodeTag, encodeFieldRenderer, isCodeProject, loadDotEnv, makePublicClient, makeWalletClient, oneOfOneImageAbi, oneOfOneImageFactoryAbi, seriesImageAbi, seriesImageFactoryAbi, abxMetadataRendererAbi, predictClone, probeRpcEndpoints, prepareDeployOneOfOne, prepareDeploySeries, reconstructProject, saltFor, saltGuard, resolveChain, resolveRpcUrl, redactRpcUrl, explorerUrl, DEFAULT_CHAIN_KEY, normalizeAttributes, parseTraitPairs, METADATA_FIELD as F, METADATA_REPRESENTATION as R, AbxServiceError, } from '@artblocks/abx-sdk';
74
75
  import { SelfHostIndexer, SqliteStore } from '@artblocks/abx-indexer';
75
76
  import { artContentHash, currentRenderArtifact, generateArt, resolveBaseUrl, startChainWatcher, startTokenApiServer, verifyProject, watchIntervalMs, DEFAULT_PORT, } from '@artblocks/abx-token-api';
76
77
  import { ARWEAVE_FREE_UPLOAD_LIMIT, arweaveAddress, arweaveFunding, contentTypeFromPath, hashContent, resolveBackend, turboBalanceForAddress, turboUploadCostUsd, turboUploadWinc } from '@artblocks/abx-storage';
77
78
  import { cmdTransfer, cmdMint, cmdSetMinter, cmdSetMaxInvocations, cmdConfigureParam, cmdSetParamHooks, cmdSetDependency, cmdRemoveLastDependency, cmdSetDependencyRegistry, cmdLockDependencies, cmdSetPrimaryPayee, cmdPause, cmdUnpause, cmdRefresh, cmdSetTokenUri, cmdSetContractUri, cmdSetRoyalty, cmdSetField, cmdAttach, cmdLockField, cmdSetRenderer, cmdLockUri, cmdSetAdmin, cmdMinterConfigure, cmdMinterShow, cmdMinterBuy, computeContentPlan, envStagingSender, laneFromFlags, ONCHAIN_PROJECT_SOFT_LIMIT, parseCompress, previewImageStaging, sessionStagingSender, stageImageField, stageImageFieldsBatch, authorshipContractFields, AUTHORSHIP_DEPLOY_FIELDS, } from './ownerops.js';
78
79
  import { openWalletSession, signTx } from './signer.js';
79
- import { remoteAddProject, remoteRemoveProject } from './remote.js';
80
+ import { describeRemoteError, listConfiguredRemotes, misnamedRemoteVars, requireRemoteToken, resolveRemote, serviceClient } from './remote.js';
80
81
  import { buildMigrationPlan, repinNodeCustody, verifyParity } from './migrate.js';
81
82
  import { activeBackendId, arweaveKeyFilePath, backendResolution, ensureArweaveJwk, factoryAddress, seriesFactoryAddress, fixedPriceMinterAddress, loadArweaveJwk, loopbackBaseUrl, faucetHint, rendererAddress, storageOptions, storageSignerChoice, } from './config.js';
82
83
  const CHAIN = process.env.ABX_CHAIN ?? DEFAULT_CHAIN_KEY;
83
- // Block explorer base per chain (keyed by EIP-155 chainId), for the tx/address links the CLI prints.
84
- const EXPLORERS = {
85
- 11155111: 'https://sepolia.etherscan.io', // Sepolia
86
- 84532: 'https://sepolia.basescan.org', // Base Sepolia
87
- };
88
- const EXPLORER = EXPLORERS[resolveChain(CHAIN).id] ?? 'https://sepolia.etherscan.io';
84
+ // Block explorer base for the tx/address links the CLI prints. Derived from viem's chain metadata via
85
+ // the SDK (see explorerUrl) rather than a local table — this used to be a hand-maintained map, which is
86
+ // the same shape of bug that had the token-api dashboard sending every Base Sepolia link to Etherscan.
87
+ const EXPLORER = explorerUrl(resolveChain(CHAIN).id);
89
88
  // Product dimensions — what you can launch. One concrete contract exists today
90
89
  // (the 1/1 image); this table is the seam future implementations slot into, so
91
90
  // `abx deploy --type <dimension>` is stable while the contracts grow under it.
@@ -209,6 +208,7 @@ async function main() {
209
208
  case 'set-admin': return cmdSetAdmin(rest[0], flags);
210
209
  case 'forget': return cmdForget(rest[0], flags);
211
210
  case 'migrate': return cmdMigrate(rest[0], flags);
211
+ case 'remote': return cmdRemote(rest[0], flags);
212
212
  case 'storage': return cmdStorage(rest);
213
213
  case 'status': return cmdStatus();
214
214
  case 'state': return cmdState(rest[0], flags);
@@ -225,6 +225,65 @@ async function main() {
225
225
  process.exit(1);
226
226
  }
227
227
  }
228
+ /**
229
+ * A `--dry-run` computes the deterministic deploy address, which is a pure function of
230
+ * (factory, salt, deployer) — so it needs a deployer even though it signs nothing. Resolve it the
231
+ * same way the preview will (`--for`, else an env key) and fail EARLY with the fix if neither
232
+ * exists, rather than after the preview has printed several steps of work.
233
+ */
234
+ /**
235
+ * A dry run only checks whether a factory address is CONFIGURED, not whether it has code on this
236
+ * chain — and the manifest always has an address, so a chain where the trust anchor isn't deployed
237
+ * (a private/local chain, or a wrong-network RPC) sailed past this and died inside
238
+ * `predictDeterministicAddress` with a raw `returned no data ("0x")` and a list of ABI hypotheses.
239
+ * The real deploy and `abx predict` both explain that case; a preview of the same deploy must too.
240
+ * Returns false when the caller should stop (message already printed).
241
+ */
242
+ async function previewFactoryLive(client, factory, label) {
243
+ const code = await client.getCode({ address: factory }).catch(() => undefined);
244
+ if (code && code !== '0x')
245
+ return true;
246
+ warn(`the configured ${label} ${factory} has no code on '${CHAIN}' (asked ${redactRpcUrl(resolveRpcUrl(CHAIN))}).`);
247
+ info('so this preview can\'t compute the deterministic address. Either point at a chain where the trust anchor is deployed');
248
+ info(`(${bold('ABX_CHAIN=' + DEFAULT_CHAIN_KEY)} is the default and has one), or deploy your own on this chain with ${bold('--bootstrap-factory')}`);
249
+ info(dim('(a private anchor — platforms won\'t recognize its clones, so it\'s for private/sandbox chains).'));
250
+ console.log(`\n ${g('dry run')} ${dim('— nothing sent.')}\n`);
251
+ return false;
252
+ }
253
+ /**
254
+ * Placeholder-identity guard, shared by ALL THREE deploy commands. `name`/`symbol` are written
255
+ * on-chain as the public collection identity and are effectively permanent, so a real deploy must
256
+ * never bake a tool default silently: warn in a preview, HARD-STOP a real send unless `--yes`.
257
+ *
258
+ * It was duplicated per command, and `deploy-series` simply never got a copy — its default
259
+ * "ABX Series"/"ABXS" went on-chain with at most a warning, while the skill promises the CLI
260
+ * refuses demo defaults. (deploy-code's copy even said "mirror deploy/deploy-series", which made
261
+ * the gap look closed.) One predicate now, like `loopbackBaseUrl()`, so a fourth command can't drift.
262
+ */
263
+ function assertRealIdentity(flags, o) {
264
+ if (flags.name && flags.symbol)
265
+ return;
266
+ if (!flags.name)
267
+ warn(`no --name → default "${o.name}" would be the on-chain collection name`);
268
+ if (!flags.symbol)
269
+ warn(`no --symbol → default "${o.symbol}" would be the on-chain symbol`);
270
+ if (o.dryRun || flags.yes)
271
+ return; // a preview still runs; --yes is the explicit opt-in
272
+ throw new Error('refusing to write tool placeholders as your public on-chain identity — pass --name "Your Title" --symbol SYM ' +
273
+ '(or --yes to accept the defaults). On-chain identity is effectively permanent.');
274
+ }
275
+ function assertPreviewDeployer(flags) {
276
+ if (flags.for)
277
+ return;
278
+ try {
279
+ makeWalletClient({ chainKey: CHAIN });
280
+ }
281
+ catch {
282
+ throw new Error('dry run needs a deployer address to compute the deterministic deploy address — pass --for 0x.. ' +
283
+ '(a preview signs nothing, so no key is needed). For the REAL deploy with no key in .env, use the ' +
284
+ 'wallet lane: --sign --for 0x.. (you approve in your own wallet).');
285
+ }
286
+ }
228
287
  // When the user/agent opts in (--yes), lift the getLogs chunk cap so a large
229
288
  // reconstruction proceeds despite a range-limited RPC (otherwise it stops early with
230
289
  // guidance — see GetLogsScanTooLargeError + the skill's "Choosing an RPC" decision).
@@ -449,7 +508,7 @@ function refuseMissingFactory(kind, envVar, reason) {
449
508
  ` To deploy your OWN trust anchor instead (private chains, sandboxes — platforms won't recognize its clones), ` +
450
509
  `re-run with --bootstrap-factory.`);
451
510
  }
452
- async function ensureFactory(override, allowBootstrap = false) {
511
+ async function ensureFactory(override, allowBootstrap = false, quiet = false) {
453
512
  // Resolve the canonical factory from the shipped manifest (flag → env → manifest); on a chain
454
513
  // with no entry, deploy a fresh trust anchor and tell the operator how to reuse it.
455
514
  const known = factoryAddress(override);
@@ -459,7 +518,10 @@ async function ensureFactory(override, allowBootstrap = false) {
459
518
  const code = await publicClient.getCode({ address: known });
460
519
  if (code && code !== '0x') {
461
520
  if (await isCurrentFactory(publicClient, known)) {
462
- info(`using canonical factory ${EXPLORER}/address/${known}`);
521
+ // `quiet` suppresses only this happy-path line (the demo resolves the factory without making a
522
+ // teaching moment of it). Bootstrap/mismatch messages below always print — those matter.
523
+ if (!quiet)
524
+ info(`using canonical factory ${EXPLORER}/address/${known}`);
463
525
  return known;
464
526
  }
465
527
  if (!allowBootstrap)
@@ -1009,23 +1071,20 @@ async function cmdDeploy(flags, serveAfter) {
1009
1071
  }
1010
1072
  }
1011
1073
  const dryRun = !serveAfter && !!flags['dry-run']; // preview only — no send, no custody, no factory deploy
1074
+ // A keyless preview needs `--for` (the address is a pure function of factory+salt+deployer). Check
1075
+ // it HERE, before the trust-anchor/content/plan steps print — hitting this after a wall of output
1076
+ // reads as "it half-worked", and a first-timer previewing with no key in .env always hits it.
1077
+ if (dryRun)
1078
+ assertPreviewDeployer(flags);
1012
1079
  const publicClient = makePublicClient({ chainKey: CHAIN });
1013
1080
  // Verify the RPC really is CHAIN before any send (factory/renderer/staging/deploy). A dry run
1014
1081
  // sends nothing, but it DOES read the chain (predict address, resolve the factory/renderer), so
1015
1082
  // a wrong-network RPC must still be caught with the clear mismatch message rather than failing
1016
1083
  // opaquely inside predict; `allowUnreachable` keeps a genuinely offline dry-run previewable.
1017
1084
  await assertChainId(CHAIN, { allowUnreachable: dryRun });
1018
- // Identity guard: the demo placeholders ("ABX Self-Host Demo"/"ABXSH") are written on-chain as
1019
- // the public collection identity. Warn in preview; HARD-STOP a real deploy unless acknowledged.
1020
- if (!serveAfter && (!flags.name || !flags.symbol)) {
1021
- if (!flags.name)
1022
- warn(`no --name → default "${name}" would be the on-chain collection name`);
1023
- if (!flags.symbol)
1024
- warn(`no --symbol → default "${symbol}" would be the on-chain symbol`);
1025
- if (!dryRun && !flags.yes) {
1026
- throw new Error('refusing to write demo placeholders as your public on-chain identity — pass --name "Your Title" --symbol SYM (or --yes to accept the defaults).');
1027
- }
1028
- }
1085
+ // `demo` is exempt: its whole job is a zero-argument first token.
1086
+ if (!serveAfter)
1087
+ assertRealIdentity(flags, { name, symbol, dryRun });
1029
1088
  // Funding preflight (real deploys): warn now if the signer is unfunded, not at the tx.
1030
1089
  if (!dryRun) {
1031
1090
  let signer = flags.for;
@@ -1038,7 +1097,13 @@ async function cmdDeploy(flags, serveAfter) {
1038
1097
  if (signer)
1039
1098
  await warnUnfunded(publicClient, signer);
1040
1099
  }
1041
- step(serveAfter ? 'Who vouches for this token?' : 'Trust anchor');
1100
+ // `deploy` surfaces the trust anchor as a step; the demo does NOT. It briefly opened on "only this
1101
+ // factory can make a token that IS an ABX token", which is simply false — anything that follows the
1102
+ // protocol's event spine is an ABX token, and the factory is one way to get there, not the
1103
+ // definition of the thing. Rather than restate it more carefully, the demo skips it: a first-timer
1104
+ // does not need a provenance lecture before they have made anything.
1105
+ if (!serveAfter)
1106
+ step('Trust anchor');
1042
1107
  let factory;
1043
1108
  if (dryRun) {
1044
1109
  const existing = factoryAddress(flags.factory);
@@ -1048,19 +1113,12 @@ async function cmdDeploy(flags, serveAfter) {
1048
1113
  return;
1049
1114
  }
1050
1115
  factory = existing;
1116
+ if (!(await previewFactoryLive(publicClient, factory, 'factory')))
1117
+ return;
1051
1118
  info(`would reuse canonical factory ${factory}`);
1052
1119
  }
1053
1120
  else {
1054
- factory = await ensureFactory(flags.factory, !!flags['bootstrap-factory']);
1055
- }
1056
- // The demo explains what it just did; `deploy` stays terse. The authenticity point is the least
1057
- // obvious thing about ABX and the easiest to get wrong, so it's worth spelling out — but in plain
1058
- // language, with the actual term parked in a dim aside for whoever wants to look it up.
1059
- if (serveAfter) {
1060
- console.log(` ${g('✦')} anyone can write a contract that ${bold('claims')} to be an ABX token`);
1061
- console.log(` ${g('✦')} only this one factory can make a token that ${bold('is')} one`);
1062
- info(dim('so "is this real?" is a yes/no question anyone can ask the chain — not a matter of trusting us'));
1063
- info(dim(`the factory answers it on-chain: isAbxClone(yourToken) · ${EXPLORER}/address/${factory}`));
1121
+ factory = await ensureFactory(flags.factory, !!flags['bootstrap-factory'], serveAfter);
1064
1122
  }
1065
1123
  // --onchain-uri: resolve tokenURI/contractURI fully on-chain via the canonical renderer
1066
1124
  // (the JSON is assembled from on-chain fields → no resolver needed, ever). The off-chain
@@ -1437,7 +1495,10 @@ async function cmdDeploy(flags, serveAfter) {
1437
1495
  if (serveAfter) {
1438
1496
  if (state.eventCount > 0)
1439
1497
  ok(`read ${bold(String(state.eventCount))} events straight off ${CHAIN} in ${elapsedMs}ms — no API key, no company's server`);
1440
- info(`it says: "${state.name}" · owned by ${state.owner} · ${state.isCanonical ? `${g('verified real')} ${dim('(that factory vouched for it)')}` : `${c.orange}unverified${c.reset}`}`);
1498
+ // "verified real" overstated it in the same way the removed trust-anchor step did factory
1499
+ // provenance is a fact about how this contract was made, not a verdict on whether a token counts
1500
+ // as ABX. Report the fact.
1501
+ info(`it says: "${state.name}" · owned by ${state.owner} · ${state.isCanonical ? g('made by the canonical factory') : dim('not factory-made')}`);
1441
1502
  info(dim(`optional features switched on: ${state.extensions.map((e) => e.name.replace(/^abx\.extension\./, '')).join(' · ') || 'none'}`));
1442
1503
  walkthroughSpine(state);
1443
1504
  }
@@ -1560,21 +1621,15 @@ async function cmdDeploySeries(flags) {
1560
1621
  throw new Error(`--count ${count} exceeds the ${files.length} media file(s) in ${dirPath}`);
1561
1622
  const slots = files.slice(0, count);
1562
1623
  const dryRun = !!flags['dry-run'];
1624
+ if (dryRun)
1625
+ assertPreviewDeployer(flags); // fail fast, before the preview does any work (see cmdDeploy)
1626
+ assertRealIdentity(flags, { name, symbol, dryRun });
1563
1627
  const lane = laneFromFlags(flags);
1564
1628
  const publicClient = makePublicClient({ chainKey: CHAIN });
1565
1629
  // Catch a wrong-network RPC with the clear mismatch message even on dry-run (which still reads
1566
1630
  // the chain to predict the address); tolerate an unreachable RPC so an offline preview still works.
1567
1631
  await assertChainId(CHAIN, { allowUnreachable: dryRun });
1568
- // Identity guard (mirror cmdDeploy): never write demo placeholders as the public identity.
1569
- if (!flags.name || !flags.symbol) {
1570
- if (!flags.name)
1571
- warn(`no --name → default "${name}" would be the on-chain collection name`);
1572
- if (!flags.symbol)
1573
- warn(`no --symbol → default "${symbol}" would be the on-chain symbol`);
1574
- if (!dryRun && !flags.yes) {
1575
- throw new Error('refusing to write demo placeholders as your public on-chain identity — pass --name "Your Title" --symbol SYM (or --yes).');
1576
- }
1577
- }
1632
+ // (identity guard already ran above, via the shared assertRealIdentity before any RPC)
1578
1633
  // Mint timing: mint-all → the whole series; mint-count N → the first N; else deferred.
1579
1634
  const mintCount = flags['mint-all'] !== undefined ? count : flags['mint-count'] ? Number(flags['mint-count']) : 0;
1580
1635
  if (mintCount > count)
@@ -1618,6 +1673,8 @@ async function cmdDeploySeries(flags) {
1618
1673
  return;
1619
1674
  }
1620
1675
  factory = existing;
1676
+ if (!(await previewFactoryLive(publicClient, factory, 'Series factory')))
1677
+ return;
1621
1678
  info(`would reuse canonical Series factory ${factory}`);
1622
1679
  }
1623
1680
  else {
@@ -2079,18 +2136,11 @@ async function cmdPredict(flags) {
2079
2136
  info(dim('(this is the 1/1 lane; a Series/code drop uses a different factory → a different address — pass --dir / --script to predict those, or use that command\'s --dry-run)'));
2080
2137
  console.log('');
2081
2138
  }
2082
- // A `--remote [url]` flag targets a HOSTED resolver's admin control plane instead of
2083
- // this machine's local projection. Bare `--remote` falls back to ABX_PUBLIC_BASE_URL
2084
- // (the URL baked on-chain). Returns null for a local op (the default).
2085
- function remoteTarget(flags) {
2086
- const v = flags.remote;
2087
- if (v === undefined)
2088
- return null;
2089
- const base = v !== 'true' ? v : process.env.ABX_PUBLIC_BASE_URL;
2090
- if (!base) {
2091
- throw new Error('`--remote` needs a resolver URL: pass `--remote https://host` or set ABX_PUBLIC_BASE_URL in .env');
2092
- }
2093
- return base;
2139
+ // The `--remote <name|url>` target (remote.ts owns the convention): a named remote's
2140
+ // `ABX_REMOTE_<NAME>_URL/_TOKEN`, an ad-hoc URL, or bare `--remote` = the self-host default.
2141
+ // Returns null for a local op (the default).
2142
+ function remoteFlag(flags) {
2143
+ return resolveRemote(flags.remote, flags['remote-token']);
2094
2144
  }
2095
2145
  /**
2096
2146
  * Ensure a resolver admin token exists locally, generating + persisting one to `.env`
@@ -2123,22 +2173,13 @@ function ensureEffectsToken() {
2123
2173
  process.env.ABX_EFFECTS_TOKEN = token;
2124
2174
  return { token, generated: true };
2125
2175
  }
2126
- /** The shared secret that authorizes remote indexing control (never on-chain signing). */
2127
- function requireAdminToken() {
2128
- const t = process.env.ABX_RESOLVER_ADMIN_TOKEN;
2129
- if (!t) {
2130
- throw new Error('remote ops need ABX_RESOLVER_ADMIN_TOKEN in your .env — it must match the token set on the resolver ' +
2131
- '(`abx deploy-resolver` generates one and wires both sides).');
2132
- }
2133
- return t;
2134
- }
2135
2176
  // ── add ──────────────────────────────────────────────────────────────────────
2136
2177
  // Register + index a project this node didn't deploy. LOCAL by default (this
2137
2178
  // machine's store); `--remote [url]` instead tells a HOSTED resolver to index it —
2138
2179
  // the bridge a local deploy can't make on its own (separate projection stores).
2139
2180
  async function cmdAdd(address, flags) {
2140
2181
  if (!address || address.startsWith('--')) {
2141
- console.error('usage: abx add <address> [--from-block N] [--factory 0x..] [--label "..."] [--remote [url]]\n');
2182
+ console.error('usage: abx add <address> [--from-block N] [--factory 0x..] [--label "..."] [--remote [name|url]]\n');
2142
2183
  process.exit(1);
2143
2184
  }
2144
2185
  // `--attributes` is lane-aware here exactly as at deploy: a PER-TOKEN payload edits a Series'
@@ -2152,10 +2193,10 @@ async function cmdAdd(address, flags) {
2152
2193
  if (flags.traits)
2153
2194
  flagTraits.push(...parseTraitPairs(flags.traits));
2154
2195
  const editedTokenAttributes = perTokenEdit ? parseSeriesTraitsById(attrRaw) : undefined;
2155
- const remote = remoteTarget(flags);
2196
+ const remote = remoteFlag(flags);
2156
2197
  if (remote) {
2157
- const token = requireAdminToken();
2158
- // Bridge what a hosted resolver can't derive itself: the off-chain traits and the durable
2198
+ requireRemoteToken(remote);
2199
+ // Bridge what a remote resolver can't derive itself: the off-chain traits and the durable
2159
2200
  // content locators (ipfs://…). Prefer flags; otherwise forward what the LOCAL deploy stored
2160
2201
  // (the local registration), and compute locators from this machine's content index if needed.
2161
2202
  const localReg = new SelfHostIndexer().store.getRegistration(address);
@@ -2164,7 +2205,7 @@ async function cmdAdd(address, flags) {
2164
2205
  : localReg?.attributes
2165
2206
  ? normalizeAttributes(JSON.parse(localReg.attributes))
2166
2207
  : undefined;
2167
- // Bridge a Series' per-token off-chain traits to the hosted resolver (the resolver has no other
2208
+ // Bridge a Series' per-token off-chain traits to the remote resolver (the resolver has no other
2168
2209
  // way to derive them — they're operator metadata, not chain state). A fresh per-token `--attributes`
2169
2210
  // EDITS them; otherwise forward what the LOCAL deploy stored. Best-effort parse.
2170
2211
  let tokenAttributes;
@@ -2190,6 +2231,7 @@ async function cmdAdd(address, flags) {
2190
2231
  // (Its ABSENCE here was the bug: a hosted resolver defaulted to genesis and scanned the whole
2191
2232
  // chain.) Re-sending the same floor stays incremental server-side, so a nudge ≠ a re-scan.
2192
2233
  const body = {
2234
+ chainId: resolveChain(CHAIN).id,
2193
2235
  address,
2194
2236
  fromBlock: await resolveScanFloor(address, localReg?.fromBlock, flags),
2195
2237
  factory: await detectCanonicalFactory(address, flags.factory, localReg?.factory),
@@ -2201,12 +2243,18 @@ async function cmdAdd(address, flags) {
2201
2243
  contentLocators: Object.keys(contentLocators).length ? contentLocators : undefined,
2202
2244
  full: flags.full ? true : undefined,
2203
2245
  };
2204
- info(`${bold('REMOTE')} → ${remote} ${dim('(registering with the hosted resolver — NOT this machine)')}`);
2246
+ info(`${bold('REMOTE')} → ${remote.url} ${dim('(registering with the remote resolver — NOT this machine)')}`);
2205
2247
  if (body.contentLocators)
2206
2248
  info(`bridging image locator → ${Object.values(body.contentLocators)[0]} ${dim('(so the resolver points at IPFS, not its own localhost)')}`);
2207
- const r = await remoteAddProject(remote, token, body);
2249
+ let r;
2250
+ try {
2251
+ r = await serviceClient(remote).registerProject(body);
2252
+ }
2253
+ catch (err) {
2254
+ throw describeRemoteError(err, remote, 'remote add');
2255
+ }
2208
2256
  ok(`remote resolver indexed ${r.project.name ?? address}: ${r.project.eventCount} events ${dim(`(${r.mode}, ${r.elapsedMs}ms)`)}`);
2209
- info(`it now serves ${remote.replace(/\/+$/, '')}/t/${resolveChain(CHAIN).id}/${address.toLowerCase()}/0`);
2257
+ info(`it now serves ${remote.url}/t/${body.chainId}/${address.toLowerCase()}/0`);
2210
2258
  return;
2211
2259
  }
2212
2260
  allowLargeScan(flags);
@@ -2789,17 +2837,7 @@ async function cmdDeployCode(flags) {
2789
2837
  const dryRun = !!flags['dry-run'];
2790
2838
  const name = flags.name ?? 'ABX Code';
2791
2839
  const symbol = flags.symbol ?? 'ABXC';
2792
- // Identity guard (mirror deploy/deploy-series): never write demo placeholders as the public
2793
- // on-chain identity. dry-run only warns so a preview still runs without --name/--symbol.
2794
- if (!flags.name || !flags.symbol) {
2795
- if (!flags.name)
2796
- warn(`no --name → default "${name}" would be the on-chain collection name`);
2797
- if (!flags.symbol)
2798
- warn(`no --symbol → default "${symbol}" would be the on-chain symbol`);
2799
- if (!dryRun && !flags.yes) {
2800
- throw new Error('refusing to write demo placeholders as your public on-chain identity — pass --name "Your Title" --symbol SYM (or --yes).');
2801
- }
2802
- }
2840
+ assertRealIdentity(flags, { name, symbol, dryRun });
2803
2841
  const maxProvided = flags.max !== undefined;
2804
2842
  const max = Number(flags.max ?? 16);
2805
2843
  if (!Number.isInteger(max) || max <= 0)
@@ -3640,7 +3678,7 @@ async function loadEffects() {
3640
3678
  */
3641
3679
  async function cmdRender(address, tokenIds, flags) {
3642
3680
  if (!address || address.startsWith('--')) {
3643
- console.error('usage: abx render <address> [tokenId…] [--force] [--remote [url]] (missing stills/traits; --force re-renders an existing one — abx render --help)\n');
3681
+ console.error('usage: abx render <address> [tokenId…] [--force] [--remote [name|url]] (missing stills/traits; --force re-renders an existing one — abx render --help)\n');
3644
3682
  process.exit(1);
3645
3683
  }
3646
3684
  // Token ids are decimal (mint order). Keep only those — `parseFlags` leaves `--flag value` pairs
@@ -3677,12 +3715,12 @@ async function cmdRender(address, tokenIds, flags) {
3677
3715
  ok(line);
3678
3716
  return;
3679
3717
  }
3680
- // --remote publishes each render to the hosted resolver's control plane (the locator bridge) so a
3718
+ // --remote publishes each render to the remote resolver's control plane (the locator bridge) so a
3681
3719
  // laptop render lands on a resolver that doesn't share this disk; republish=true makes a re-run
3682
3720
  // restore a resolver that lost its volume without re-rendering. Local (no --remote): shared backend.
3683
- const remote = remoteTarget(flags);
3684
- const resolverUrl = (remote ?? process.env.ABX_RESOLVER_URL ?? resolveBaseUrl()).replace(/\/$/, '');
3685
- const adminToken = remote ? requireAdminToken() : undefined;
3721
+ const remote = remoteFlag(flags);
3722
+ const resolverUrl = (remote?.url ?? process.env.ABX_RESOLVER_URL ?? resolveBaseUrl()).replace(/\/$/, '');
3723
+ const adminToken = remote ? requireRemoteToken(remote) : undefined;
3686
3724
  // Co-located (no --remote): record each declared output into the shared store's artifact
3687
3725
  // registry so the local resolver's `artifacts` manifest enumerates it. Remote: the publish
3688
3726
  // lane (adminToken) records rows on the hosted resolver instead.
@@ -3717,8 +3755,11 @@ async function cmdRender(address, tokenIds, flags) {
3717
3755
  * single time and exits (vs `abx render <addr>` which is the per-project repair lane).
3718
3756
  */
3719
3757
  async function cmdEffects(flags) {
3720
- const resolverUrl = (remoteTarget(flags) ?? process.env.ABX_RESOLVER_URL ?? resolveBaseUrl()).replace(/\/$/, '');
3721
- const adminToken = process.env.ABX_RESOLVER_ADMIN_TOKEN;
3758
+ const effectsRemote = remoteFlag(flags);
3759
+ const resolverUrl = (effectsRemote?.url ?? process.env.ABX_RESOLVER_URL ?? resolveBaseUrl()).replace(/\/$/, '');
3760
+ // Token optional BY DESIGN: no token = the co-located topology (shared store, no publish lane).
3761
+ // A named remote brings its own token; otherwise the self-host env token.
3762
+ const adminToken = effectsRemote?.token ?? process.env.ABX_RESOLVER_ADMIN_TOKEN;
3722
3763
  // Co-located (no admin token — we share the resolver's store): record each declared output into
3723
3764
  // the shared artifact registry so the resolver's `artifacts` manifest enumerates it. With an
3724
3765
  // admin token, the publish lane records rows on the hosted resolver instead.
@@ -3758,15 +3799,21 @@ async function cmdEffects(flags) {
3758
3799
  await new Promise(() => { }); // block like `serve`
3759
3800
  }
3760
3801
  async function cmdIndex(address, flags) {
3761
- const remote = remoteTarget(flags);
3802
+ const remote = remoteFlag(flags);
3762
3803
  if (remote) {
3763
3804
  if (!address || address.startsWith('--')) {
3764
- console.error('usage: abx index <address> --remote [url] (re-index one project on a hosted resolver)\n');
3805
+ console.error('usage: abx index <address> --remote [name|url] (re-index one project on a remote resolver)\n');
3765
3806
  process.exit(1);
3766
3807
  }
3767
- const token = requireAdminToken();
3768
- info(`${bold('REMOTE')} → ${remote} ${dim('(re-indexing on the hosted resolver — the post-deploy nudge)')}`);
3769
- const r = await remoteAddProject(remote, token, { address, full: flags.full ? true : undefined });
3808
+ requireRemoteToken(remote);
3809
+ info(`${bold('REMOTE')} → ${remote.url} ${dim('(re-indexing on the remote resolver — the post-deploy nudge)')}`);
3810
+ let r;
3811
+ try {
3812
+ r = await serviceClient(remote).registerProject({ chainId: resolveChain(CHAIN).id, address, full: flags.full ? true : undefined });
3813
+ }
3814
+ catch (err) {
3815
+ throw describeRemoteError(err, remote, 'remote index');
3816
+ }
3770
3817
  ok(`remote resolver re-indexed ${r.project.name ?? address}: ${r.project.eventCount} events ${dim(`(${r.mode}, ${r.elapsedMs}ms)`)}`);
3771
3818
  return;
3772
3819
  }
@@ -3797,9 +3844,9 @@ async function cmdVerify(address, flags) {
3797
3844
  process.exit(1);
3798
3845
  }
3799
3846
  allowLargeScan(flags);
3800
- const remote = remoteTarget(flags);
3847
+ const remote = remoteFlag(flags);
3801
3848
  if (remote)
3802
- return cmdVerifyRemote(address, remote);
3849
+ return cmdVerifyRemote(address, remote.url);
3803
3850
  const indexer = new SelfHostIndexer();
3804
3851
  let state = indexer.getProject(address);
3805
3852
  if (indexer.store.getRegistration(address)) {
@@ -4071,7 +4118,12 @@ async function cmdTokenUri(address, flags) {
4071
4118
  catch {
4072
4119
  const code = await publicClient.getCode({ address }).catch(() => undefined);
4073
4120
  if (!code || code === '0x') {
4074
- console.error(`abx tokenuri: no contract at ${address} on ${CHAIN} double-check the address and that you're on the right chain. If you JUST deployed, give the tx a block or two to mine.\n`);
4121
+ // Name the endpoint we actually asked. "No contract here" is indistinguishable from "you're
4122
+ // pointed at the wrong node", and a chain KEY doesn't disambiguate that — two endpoints can
4123
+ // both claim `sepolia` (a fork, a stale duplicate .env line) and only one has your contract.
4124
+ console.error(`abx tokenuri: no contract at ${address} on ${CHAIN} (asked ${redactRpcUrl(resolveRpcUrl(CHAIN))}) — ` +
4125
+ `double-check the address, and that this endpoint is the network you deployed to. ` +
4126
+ `If you JUST deployed, give the tx a block or two to mine.\n`);
4075
4127
  }
4076
4128
  else {
4077
4129
  console.error(`abx tokenuri: ${address} didn't return a tokenURI for token ${tokenId} — it may not be an ABX/ERC-721 token, token ${tokenId} may be unminted (try --token <id>), or — on a large on-chain tokenURI — an unauthenticated RPC read hit its gas cap (try a wallet-connected / high-gas RPC).\n`);
@@ -4414,15 +4466,24 @@ async function cmdStatus() {
4414
4466
  // test/junk deploys. On-chain data is untouched; `abx add` can re-register it.
4415
4467
  async function cmdForget(address, flags) {
4416
4468
  if (!address || address.startsWith('--')) {
4417
- console.error('usage: abx forget <address> [--remote [url]]\n');
4469
+ console.error('usage: abx forget <address> [--remote [name|url]]\n');
4418
4470
  process.exit(1);
4419
4471
  }
4420
- const remote = remoteTarget(flags);
4472
+ const remote = remoteFlag(flags);
4421
4473
  if (remote) {
4422
- const token = requireAdminToken();
4423
- info(`${bold('REMOTE')} → ${remote} ${dim('(deregistering on the hosted resolver — NOT this machine)')}`);
4424
- await remoteRemoveProject(remote, token, address);
4425
- ok(`remote resolver forgot ${address} — it will stop serving it. On-chain data is untouched.`);
4474
+ requireRemoteToken(remote);
4475
+ info(`${bold('REMOTE')} → ${remote.url} ${dim('(deregistering on the remote resolver — NOT this machine)')}`);
4476
+ let removed;
4477
+ try {
4478
+ ({ removed } = await serviceClient(remote).removeProject(resolveChain(CHAIN).id, address));
4479
+ }
4480
+ catch (err) {
4481
+ throw describeRemoteError(err, remote, 'remote forget');
4482
+ }
4483
+ if (removed)
4484
+ ok(`remote resolver forgot ${address} — it will stop serving it. On-chain data is untouched.`);
4485
+ else
4486
+ console.log(dim(` ${address} wasn't registered on ${remote.url} — nothing to forget.`));
4426
4487
  return;
4427
4488
  }
4428
4489
  const indexer = new SelfHostIndexer();
@@ -4433,6 +4494,94 @@ async function cmdForget(address, flags) {
4433
4494
  indexer.store.deregister(address);
4434
4495
  ok(`forgot ${address} — dropped its registration + projection. On-chain data is untouched.`);
4435
4496
  }
4497
+ // ── remote ────────────────────────────────────────────────────────────────--
4498
+ // Inspect a remote service. Bare `abx remote` lists the named remotes configured in .env plus the
4499
+ // self-host default pair (URLs + whether a token is set — never the secret itself). With a target,
4500
+ // fetches its PUBLIC service descriptor (what it serves: interfaces, chains, auth, managed
4501
+ // rendering) and — when a token resolves — lists the projects visible to that token, which makes
4502
+ // this the one-command "is my provider key valid?" check. Read-only; registers nothing.
4503
+ async function cmdRemote(spec, flags) {
4504
+ if (!spec || spec.startsWith('--')) {
4505
+ const remotes = listConfiguredRemotes();
4506
+ console.log(`\n ${bold('named remotes')} ${dim('(ABX_REMOTE_<NAME>_URL/_TOKEN in .env — inspect one: abx remote <name>)')}`);
4507
+ if (remotes.length === 0)
4508
+ console.log(dim(' none configured'));
4509
+ for (const r of remotes) {
4510
+ console.log(` ${g('●')} ${r.name.toLowerCase()} ${dim(r.url)} ${r.hasToken ? g('token set') : dim('no token')}`);
4511
+ }
4512
+ // A near-miss var reads as "no token" while the value is sitting in .env under the wrong name.
4513
+ for (const bad of misnamedRemoteVars()) {
4514
+ warn(`${bad.key} isn't a recognized remote var — the convention is ${bold(bad.suggestion)} (only _URL and _TOKEN are read).`);
4515
+ }
4516
+ const def = process.env.ABX_PUBLIC_BASE_URL ?? process.env.ABX_RESOLVER_URL;
4517
+ console.log(`\n ${bold('self-host default')} ${dim('(bare --remote)')}`);
4518
+ console.log(def
4519
+ ? ` ${g('●')} ${def} ${process.env.ABX_RESOLVER_ADMIN_TOKEN ? g('token set') : dim('no ABX_RESOLVER_ADMIN_TOKEN')}`
4520
+ : dim(' none (set ABX_PUBLIC_BASE_URL in .env)'));
4521
+ console.log('');
4522
+ return;
4523
+ }
4524
+ const target = resolveRemote(spec, flags['remote-token']);
4525
+ if (!target)
4526
+ return;
4527
+ const client = serviceClient(target);
4528
+ console.log(`\n ${bold(target.name ? `remote ${target.name.toLowerCase()}` : 'remote')} ${dim(`→ ${target.url}`)}`);
4529
+ let d;
4530
+ try {
4531
+ d = await client.descriptor();
4532
+ }
4533
+ catch (err) {
4534
+ // Two very different situations, and the fix differs — so don't nest the raw client error
4535
+ // (it repeats the URL and leaks `GET`/`fetch failed` at a creator).
4536
+ const status = err instanceof AbxServiceError ? err.status : -1;
4537
+ if (status === 0) {
4538
+ throw new Error(`nothing responded at ${target.url} — check the address. A provider gives you an https:// base ` +
4539
+ `(e.g. https://meta.provider.xyz); if it's your own node, is it running?`);
4540
+ }
4541
+ throw new Error(`${target.url} answered, but serves no ABX service descriptor at /.well-known/abx-service. ` +
4542
+ `That's either an older self-hosted node (fine if it's yours — the remote commands still work against it) ` +
4543
+ `or not an ABX service at all. Verify the URL before registering anything with it.`);
4544
+ }
4545
+ info(`service ${d.service?.name ?? '—'} ${dim(d.service?.version ?? '')}`);
4546
+ info(`serves ${(d.interfaces ?? []).join(' · ') || '—'}`);
4547
+ const chainId = resolveChain(CHAIN).id;
4548
+ const coversChain = (d.chains ?? []).includes(chainId);
4549
+ info(`chains ${(d.chains ?? []).join(', ') || '—'} ${coversChain ? g(`✓ covers ${CHAIN} (${chainId})`) : `${c.orange}⚠${c.reset} does NOT cover ${CHAIN} (${chainId}) — registrations will be refused`}`);
4550
+ if (d.render?.attached) {
4551
+ const outputs = d.render.effects?.flatMap((e) => e.outputs.map((o) => `${e.key}/${o.key}`)).join(', ');
4552
+ info(`rendering managed behind this service${outputs ? ` (${outputs})` : d.render.effects === null ? dim(' (attached — runner unverified right now)') : ''} — code drops need no effects runner here`);
4553
+ }
4554
+ if (d.auth) {
4555
+ info(`auth bearer${d.auth.signupUrl ? ` · get a key: ${d.auth.signupUrl}` : ''}${d.auth.docsUrl ? ` · docs: ${d.auth.docsUrl}` : ''}`);
4556
+ }
4557
+ else {
4558
+ info(`auth none advertised ${dim('(control plane disabled on this node)')}`);
4559
+ }
4560
+ if (!target.token) {
4561
+ // Same near-miss check the register path does — this is where someone lands FIRST when their
4562
+ // credential is set under a name the CLI doesn't read, so the hint has to be here too.
4563
+ const nearMiss = misnamedRemoteVars().find((v) => v.suggestion === target.tokenVar);
4564
+ if (nearMiss)
4565
+ warn(`${nearMiss.key} is set but is NOT read — the convention is ${bold(target.tokenVar)} (only _URL and _TOKEN). Rename it and re-run.`);
4566
+ else
4567
+ info(dim(`no token resolved (set ${target.tokenVar} or pass --remote-token) — descriptor only; can't list your projects.`));
4568
+ console.log('');
4569
+ return;
4570
+ }
4571
+ try {
4572
+ const projects = await client.listProjects();
4573
+ ok(`token accepted — ${projects.length} project(s) visible to it`);
4574
+ for (const p of projects.slice(0, 10)) {
4575
+ console.log(` ${g('●')} ${p.name ?? p.label ?? p.address} ${dim(`${p.address} · ${p.tokenCount ?? '?'} token(s)`)}`);
4576
+ }
4577
+ if (projects.length > 10)
4578
+ console.log(dim(` … and ${projects.length - 10} more`));
4579
+ }
4580
+ catch (err) {
4581
+ throw describeRemoteError(err, target, 'remote list');
4582
+ }
4583
+ console.log('');
4584
+ }
4436
4585
  // ── migrate ───────────────────────────────────────────────────────────────--
4437
4586
  // Move a contract's OFF-CHAIN operator state from one resolver to another (e.g. fly.io →
4438
4587
  // a droplet). The destination replays all ON-CHAIN state from chain itself; this bridges
@@ -4441,13 +4590,17 @@ async function cmdForget(address, flags) {
4441
4590
  // and write through the admin control plane. It does NOT cut over — after a clean migration the
4442
4591
  // operator re-points DNS (custom domain) or the on-chain base URI (provider endpoint).
4443
4592
  async function cmdMigrate(address, flags) {
4444
- const from = typeof flags.from === 'string' ? flags.from : undefined;
4445
- const to = typeof flags.to === 'string' ? flags.to : undefined;
4446
- if (!address || address.startsWith('--') || !from || !to) {
4447
- console.error('usage: abx migrate <address> --from <source-resolver-url> --to <dest-resolver-url> [--from-block N]\n');
4593
+ // Both sides accept a named remote or a URL. Only the DESTINATION needs a credential — the
4594
+ // source is read via its PUBLIC api (the exit ramp works with zero provider cooperation).
4595
+ const fromTarget = typeof flags.from === 'string' ? resolveRemote(flags.from) : null;
4596
+ const toTarget = typeof flags.to === 'string' ? resolveRemote(flags.to, flags['remote-token']) : null;
4597
+ if (!address || address.startsWith('--') || !fromTarget || !toTarget) {
4598
+ console.error('usage: abx migrate <address> --from <source-resolver name|url> --to <dest-resolver name|url> [--from-block N]\n');
4448
4599
  process.exit(1);
4449
4600
  }
4450
- const token = requireAdminToken();
4601
+ requireRemoteToken(toTarget);
4602
+ const from = fromTarget.url;
4603
+ const to = toTarget.url;
4451
4604
  const chainId = resolveChain(CHAIN).id;
4452
4605
  allowLargeScan(flags);
4453
4606
  console.log(bold(`\n abx migrate ${dim('— port off-chain state between resolvers (no cutover)')}`));
@@ -4504,6 +4657,7 @@ async function cmdMigrate(address, flags) {
4504
4657
  step('Populate the destination');
4505
4658
  const locCount = Object.keys(plan.contentLocators).length;
4506
4659
  const body = {
4660
+ chainId,
4507
4661
  address,
4508
4662
  // The deploy block (locally known); if somehow absent, omit it so the destination derives it
4509
4663
  // rather than scanning from genesis (never bake a from-0 floor into a fresh resolver).
@@ -4516,8 +4670,14 @@ async function cmdMigrate(address, flags) {
4516
4670
  contentLocators: locCount ? plan.contentLocators : undefined,
4517
4671
  full: true, // first registration on the destination — replay from the deploy block
4518
4672
  };
4519
- info(`${bold('REMOTE')} → ${to} ${dim('(admin control plane — chain replay + off-chain enrichment)')}`);
4520
- const r = await remoteAddProject(to, token, body);
4673
+ info(`${bold('REMOTE')} → ${to} ${dim('(control plane — chain replay + off-chain enrichment)')}`);
4674
+ let r;
4675
+ try {
4676
+ r = await serviceClient(toTarget).registerProject(body);
4677
+ }
4678
+ catch (err) {
4679
+ throw describeRemoteError(err, toTarget, 'migrate destination');
4680
+ }
4521
4681
  ok(`destination indexed ${r.project.name ?? address}: ${r.project.eventCount} events ${dim(`(${r.mode}, ${r.elapsedMs}ms)`)}`);
4522
4682
  // 5) Parity check — does the destination now serve the same metadata as the source? Sample a
4523
4683
  // token we did NOT re-pin (a re-pinned image is durable-locator-on-dest vs old-host-on-source
@@ -4853,7 +5013,8 @@ async function cmdDeployResolver(flags) {
4853
5013
  const url = art.baseUrl;
4854
5014
  info(`abx deploy --image <art> --name … --public-base-url ${url} (or export ABX_PUBLIC_BASE_URL=${url})`);
4855
5015
  info(`the contract derives ${url}/t/${resolveChain(CHAIN).id}/{address}/{tokenId} from that base.`);
4856
- info(`${bold('then')} abx add <clone> --remote ${dim('# tell the hosted resolver to index it — a LOCAL deploy does NOT')}`);
5016
+ info(`${bold('then')} abx add <clone> --remote ${dim('# tell the remote resolver to index it — a LOCAL deploy does NOT')}`);
5017
+ info(dim(`prefer addressing it by name? add ABX_REMOTE_<NAME>_URL=${url} (+ ABX_REMOTE_<NAME>_TOKEN=<the same token>) to .env → abx add <clone> --remote <name>`));
4857
5018
  console.log('');
4858
5019
  }
4859
5020
  // ── deploy-effects: scaffold the render runner (the resolver's browser-bearing companion) ─────
@@ -5152,8 +5313,9 @@ const COMMAND_HELP = {
5152
5313
  No tokenId → sweeps all minted tokens; pass ids (${g('0 1 2')}) to target specific tokens.
5153
5314
  ${g('--force')} RE-RENDER even when the still already exists — the fix for a bad / blank / timed-out capture
5154
5315
  (the art is otherwise deterministic, so a plain render idempotent-skips an existing still). Overwrites it (+ republishes on --remote).
5155
- ${g('--remote [url]')} publish each render to a HOSTED resolver (the locator bridge: upload to ${bold('ABX_STORAGE_BACKEND')}, POST /admin/effect-artifacts).
5156
- Idempotent; a re-run restores a resolver that lost its volume. Needs ${g('ABX_RESOLVER_ADMIN_TOKEN')}.
5316
+ ${g('--remote [name|url]')} publish each render to a REMOTE resolver (the locator bridge: upload to ${bold('ABX_STORAGE_BACKEND')}, POST /v1/effect-artifacts).
5317
+ Idempotent; a re-run restores a resolver that lost its volume. Needs its token (a named remote's
5318
+ ${g('ABX_REMOTE_<NAME>_TOKEN')}, else ${g('ABX_RESOLVER_ADMIN_TOKEN')}).
5157
5319
  --effects-url <url> enqueue on a running effect-runner service instead of rendering inline (else ${g('ABX_EFFECTS_URL')})
5158
5320
  ${dim('Inline (no --remote) renders on THIS machine (needs `npx playwright install chromium` once), pointing Chromium at the live')}
5159
5321
  ${dim(`view of ${g('ABX_RESOLVER_URL')} (else your configured base / local ${g('abx serve')}), and stores to the resolved backend.`)}
@@ -5169,8 +5331,8 @@ const COMMAND_HELP = {
5169
5331
  --port <n> HTTP port (default ${g('ABX_EFFECTS_PORT')} / 8788) — ${g('POST /notify')} enqueues (watcher lane) · ${g('POST /run')} sweeps synchronously (command lane)
5170
5332
  --interval-ms <n> the SAFETY-FLOOR sweep (default ${g('ABX_EFFECTS_INTERVAL_MS')} / 300000) — catches a missed notify / cold start; the watcher is the trigger
5171
5333
  --concurrency <n> parallel renders while draining (default ${g('ABX_EFFECTS_CONCURRENCY')} / 1 — Chromium is heavy; raise deliberately)
5172
- ${dim('Co-located with a local `abx serve` (same store) → no tokens needed. Against a HOSTED resolver: ABX_RESOLVER_URL +')}
5173
- ${dim('ABX_RESOLVER_ADMIN_TOKEN (publish renders + report status). PUBLIC runner? set ABX_EFFECTS_TOKEN — it gates /run + /notify.')}
5334
+ ${dim('Co-located with a local `abx serve` (same store) → no tokens needed. Against a REMOTE resolver: --remote <name|url> (its')}
5335
+ ${dim('token publishes renders + reports status), or ABX_RESOLVER_URL + ABX_RESOLVER_ADMIN_TOKEN. PUBLIC runner? set ABX_EFFECTS_TOKEN — it gates /run + /notify.')}
5174
5336
  ${dim('To HOST the runner (fly/docker), use `abx deploy-effects`.')}`,
5175
5337
  'configure-param': `
5176
5338
  ${bold('abx configure-param')} <address> <tokenId|-> <key> <value> ${dim('— set a PostParam (typed, canonical encode). Sends a tx.')}
@@ -5181,7 +5343,7 @@ const COMMAND_HELP = {
5181
5343
  like ${g('params.keys')} / ${g('display.gateway')}. ≤31 printable-ASCII chars ride as a literal bytes32; longer takes the data path.
5182
5344
  After a token write, a project whose ${g('params.keys')} doesn't list the key gets a one-line fix suggestion.
5183
5345
  --file <path> read the value from a file (String / Bytes payloads)
5184
- ${g('--remote [url]')} nudge a HOSTED resolver to re-index IMMEDIATELY after the change (else ABX_PUBLIC_BASE_URL) — it pings
5346
+ ${g('--remote [name|url]')} nudge a REMOTE resolver to re-index IMMEDIATELY after the change (else ABX_PUBLIC_BASE_URL) — it pings
5185
5347
  the resolver's effect runner, so the thumbnail re-renders without waiting. Usually OPTIONAL now: a
5186
5348
  resolver running the chain watcher (the ${g('abx serve')} default) sees the change on its next poll (~12s)
5187
5349
  and auto-re-renders on its own. Keep --remote for a watcher-disabled resolver or when seconds matter.
@@ -5365,13 +5527,16 @@ const COMMAND_HELP = {
5365
5527
  ${bold('abx add')} <address> ${dim('— register + index a project. Also edits off-chain display metadata + traits.')}
5366
5528
  --from-block <n> --factory 0x.. --label "<s>" --description "<s>" --external-url <url> [--full] [--yes]
5367
5529
  --traits "K=V; K2=V2" / --attributes <file.json> set the off-chain operator traits (on-chain attributes always win)
5368
- ${g('--remote [url]')} target a HOSTED resolver instead of this machine — bridges the image locator (ipfs://…) + traits to it`,
5530
+ ${g('--remote [name|url]')} target a REMOTE resolver instead of this machine — bridges the image locator (ipfs://…) + traits to it.
5531
+ A ${bold('name')} reads ${g('ABX_REMOTE_<NAME>_URL')} + ${g('ABX_REMOTE_<NAME>_TOKEN')} from .env (a managed provider's API key);
5532
+ a URL (or bare --remote = ${g('ABX_PUBLIC_BASE_URL')}) uses ${g('ABX_RESOLVER_ADMIN_TOKEN')}. Inspect first: ${g('abx remote <name>')}
5533
+ --remote-token <t> override the token for this invocation (--token means a token ID elsewhere, hence the name)`,
5369
5534
  index: `
5370
5535
  ${bold('abx index')} [<address>] ${dim('— re-index from chain (read-only). Incremental by default.')}
5371
5536
  ${g('--full')} force a full replay from the deploy block (the durability proof) --yes allow a very large scan`,
5372
5537
  verify: `
5373
5538
  ${bold('abx verify')} <address> ${dim('— re-hash the served bytes against the on-chain commitment (read-only; no server).')}
5374
- ${g('--remote [url]')} verify what a HOSTED resolver actually serves (else ABX_PUBLIC_BASE_URL) — probes its \`/image\` (302→locator
5539
+ ${g('--remote [name|url]')} verify what a REMOTE resolver actually serves (else ABX_PUBLIC_BASE_URL) — probes its \`/image\` (302→locator
5375
5540
  or 200 bytes), so it accounts for a render PUBLISHED to that resolver. ${bold('Use this for a code project whose')}
5376
5541
  ${bold('renders were published to a hosted resolver')} — a plain \`abx verify\` only checks THIS machine's store and will
5377
5542
  report a false placeholder for a render that lives on the resolver.
@@ -5381,11 +5546,21 @@ const COMMAND_HELP = {
5381
5546
  ${bold('abx tokenuri')} <address> [--token <id>] ${dim('— read tokenURI(id) straight from the contract on-chain + decode the JSON (read-only; no server).')}
5382
5547
  ${dim('The proof a fully on-chain token self-resolves: any RPC returns the renderer-assembled metadata. Default token 0.')}`,
5383
5548
  forget: `
5384
- ${bold('abx forget')} <address> ${dim('— drop a project’s local registration + projection. On-chain data is untouched.')}`,
5549
+ ${bold('abx forget')} <address> ${dim('— drop a project’s local registration + projection. On-chain data is untouched.')}
5550
+ ${g('--remote [name|url]')} deregister on a REMOTE resolver instead (it stops serving the project; re-add any time)`,
5551
+ remote: `
5552
+ ${bold('abx remote')} [<name|url>] ${dim('— inspect a remote service (read-only; registers nothing).')}
5553
+ Bare: list the named remotes in .env (${g('ABX_REMOTE_<NAME>_URL')} / ${g('_TOKEN')} — token shown as set/unset, never printed)
5554
+ plus the self-host default (bare --remote = ${g('ABX_PUBLIC_BASE_URL')} + ${g('ABX_RESOLVER_ADMIN_TOKEN')}).
5555
+ With a target: fetch its PUBLIC ${g('/.well-known/abx-service')} descriptor — what it serves (interfaces), which chains
5556
+ (flags a mismatch with your ${g('ABX_CHAIN')}), whether ${bold('rendering is managed')} behind it (code drops then need no effects
5557
+ runner), and where a human gets an API key (${g('auth.signupUrl')}). With a token: lists the projects visible to it —
5558
+ ${bold('the one-command "is my provider key valid?" check')} (401 = fix the key · 403 = provider-side scoping, not a typo).`,
5385
5559
  migrate: `
5386
5560
  ${bold('abx migrate')} <address> ${dim('— move a contract\'s OFF-CHAIN state to another resolver (read-only on both; no cutover).')}
5387
- --from <url> the SOURCE resolver (currently serving the contract)
5388
- --to <url> the DESTINATION resolver (its admin control plane) — needs ${g('ABX_RESOLVER_ADMIN_TOKEN')} in .env (the dest's token, from ${g('deploy-resolver')})
5561
+ --from <name|url> the SOURCE resolver (currently serving the contract) — read via its PUBLIC api; no source credential needed
5562
+ --to <name|url> the DESTINATION resolver (its control plane) — the ONE credential migrate needs: a named remote's
5563
+ ${g('ABX_REMOTE_<NAME>_TOKEN')}, else ${g('ABX_RESOLVER_ADMIN_TOKEN')} (your own node's, from ${g('deploy-resolver')}), else --remote-token
5389
5564
  --from-block <n> chain scan floor for the local read (default: the deploy block, discovered on-chain — never genesis) --yes allow a very large scan
5390
5565
  --backend <id> durable custody for re-pinning source-only images (ipfs · arweave); else config/env
5391
5566
  ${dim('replays on-chain state on the dest from chain, then bridges description / external_url / off-chain')}
@@ -5435,7 +5610,8 @@ function help() {
5435
5610
  --minter 0x.. · --primary-payee 0x.. · --unpaused · ${g('--dry-run')} · see ${g('abx help deploy-series')}
5436
5611
  ${g('abx predict')} pre-compute a deploy address flags: [--salt 0x..] [--for 0x..] [--factory 0x..]
5437
5612
  ${g('abx add')} <address> register + index a project this node didn't deploy
5438
- flags: --from-block --factory --label
5613
+ flags: --from-block --factory --label · ${g('--remote <name|url>')} registers on a REMOTE resolver instead
5614
+ ${g('abx remote')} [<name|url>] inspect a remote service: its descriptor (chains · managed rendering · where to get a key) + your projects there
5439
5615
  ${g('abx index')} [<address>] re-index from chain (incremental by default; ${g('--full')} forces a replay from deploy)
5440
5616
  ${g('abx verify')} <addr> re-hash served bytes vs the on-chain commitment (no server needed)
5441
5617
  ${g('abx tokenuri')} <addr> read tokenURI(0) on-chain + decode the JSON (proof a self-resolving token works)