@artblocks/abx-cli 0.1.0-alpha.1 → 0.1.0-alpha.11

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
@@ -14,11 +14,15 @@
14
14
  * abx demo deploy a 1/1 to Sepolia, index it, and serve it
15
15
  * abx deploy [--image ..] deploy + index a 1/1 (--image to custody your own bytes; --no-mint to defer)
16
16
  * abx predict [--salt ..] pre-compute a deploy address (reserve / vanity it before signing)
17
+ * abx preview run a code project on localhost while it's still being made (no chain)
17
18
  * abx deploy-code deploy a code project (SeriesCode): --script <file> (on-chain template)
18
19
  * or --code-dir <dir> (build directory → ipfs/arweave `code` field)
19
- * abx add <address> register + index a project (--remote: on a hosted resolver, not this machine)
20
- * 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)
21
23
  * abx verify <address> re-hash served bytes vs the on-chain commitment (no server)
24
+ * abx tokenuri <address> read tokenURI(id) from the contract + decode · contracturi: the ERC-7572 collection JSON
25
+ * (the contract holds the URL — never hand-build a resolver path)
22
26
  * abx configure-param <addr> <id> <key> <value> set a governed PostParam (typed encode; any lane)
23
27
  * abx set-param-hooks <addr> wire/clear a SeriesCode's configure/augment/transfer param hooks
24
28
  * abx render <addr> [id…] effect-runner repair lane: render missing stills/traits
@@ -34,14 +38,15 @@
34
38
  * abx storage backup-key copy the managed Turbo/Arweave key (holds credits) to a safe path
35
39
  * abx status list indexed projects + node info
36
40
  * abx doctor check environment (key, RPC, balance, factory, storage)
37
- * abx skill install install the abx agent skill into your agent (.claude/skills; --global)
41
+ * abx skill install install the version-locked abx agent skill into your agent(s)
42
+ * (default: Claude Code + the neutral .agents/skills; --agent/--global)
38
43
  *
39
44
  * Every write picks a signing lane: default hot (env key signs), `--sign` (a human
40
45
  * approves in their own wallet via a one-shot localhost page), `--unsigned` (print
41
46
  * the tx for a multisig / offline signer). The agent picks the lane; the CLI signs.
42
47
  */
43
- import { appendFileSync, copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
44
- import { randomBytes } from 'node:crypto';
48
+ import { appendFileSync, copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
49
+ import { createHash, randomBytes } from 'node:crypto';
45
50
  import { basename, extname, join as joinPath, resolve as resolvePath } from 'node:path';
46
51
  import { fileURLToPath } from 'node:url';
47
52
  import { homedir } from 'node:os';
@@ -54,9 +59,11 @@ import { DEP_RESOLUTION, deploySeedSource, deploySeriesCodeFactory, prepareCodeS
54
59
  import { checkRegistryDeps, dependencySetupCalls, parseDepFlag, resolveDepRegistryPointer } from './deps.js';
55
60
  import { composeParamsKeys, expectedChainComplete, hasOnChainUriLane, onchainUriSetupCalls, onChainUriReport } from './onchain-uri.js';
56
61
  import { parseFlags, unknownFlags } from './flags.js';
57
- import { checkForCliUpdate, compareVersions, installedSkillVersions, readCliVersion, SKILL_VERSION_MARKER } from './update-check.js';
62
+ import { AGENT_SKILL_PARENTS, checkForCliUpdate, compareVersions, installedSkillVersions, readCliVersion, readSkillVersion, SKILL_DIR_NAME, } from './update-check.js';
58
63
  import { analyzeScript, recommendLane } from './inspect.js';
64
+ import { previewConfigFromFlags, previewDepTags, shootPreview, startPreviewServer, DEFAULT_PREVIEW_PORT, PREVIEW_FLAGS } from './preview.js';
59
65
  import { parseSchemaSpecs, describeSchema } from './schema.js';
66
+ import { declinesSkillInstall } from './prompt.js';
60
67
  import { parseSeriesTraits, looksPerTokenAttributes, parseSeriesTraitsById } from './series-traits.js';
61
68
  /** The `--schema` type/auth/format catalog, shown wherever the CLI nudges `--schema`. Kept accurate
62
69
  * to the on-chain enums (PARAM_TYPES / AUTH_OPTIONS) + the Select-options / Range-bounds format —
@@ -66,22 +73,20 @@ const SCHEMA_CATALOG = 'Types: Bool·Select·Uint256Range·Int256Range·DecimalR
66
73
  'Select needs options — key:Select[A|B|C]:Auth; a Range takes bounds — key:Uint256Range[0..100]:Auth. ' +
67
74
  'A palette collectors set = palette:HexColor:TokenOwner';
68
75
  import { uploadAndLocate } from './upload.js';
69
- 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';
76
+ 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, indexProgress, isAccepted, AbxIndexTimeoutError, AbxServiceError, } from '@artblocks/abx-sdk';
70
77
  import { SelfHostIndexer, SqliteStore } from '@artblocks/abx-indexer';
71
78
  import { artContentHash, currentRenderArtifact, generateArt, resolveBaseUrl, startChainWatcher, startTokenApiServer, verifyProject, watchIntervalMs, DEFAULT_PORT, } from '@artblocks/abx-token-api';
72
79
  import { ARWEAVE_FREE_UPLOAD_LIMIT, arweaveAddress, arweaveFunding, contentTypeFromPath, hashContent, resolveBackend, turboBalanceForAddress, turboUploadCostUsd, turboUploadWinc } from '@artblocks/abx-storage';
73
80
  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';
74
81
  import { openWalletSession, signTx } from './signer.js';
75
- import { remoteAddProject, remoteRemoveProject } from './remote.js';
82
+ import { describeRemoteError, listConfiguredRemotes, misnamedRemoteVars, requireRemoteToken, resolveRemote, serviceClient, tokenSourceLabel } from './remote.js';
76
83
  import { buildMigrationPlan, repinNodeCustody, verifyParity } from './migrate.js';
77
- import { activeBackendId, arweaveKeyFilePath, backendResolution, ensureArweaveJwk, factoryAddress, seriesFactoryAddress, fixedPriceMinterAddress, loadArweaveJwk, loopbackBaseUrl, rendererAddress, storageOptions, storageSignerChoice, } from './config.js';
84
+ import { activeBackendId, arweaveKeyFilePath, backendResolution, ensureArweaveJwk, factoryAddress, seriesFactoryAddress, fixedPriceMinterAddress, loadArweaveJwk, loopbackBaseUrl, faucetHint, rendererAddress, storageOptions, storageSignerChoice, } from './config.js';
78
85
  const CHAIN = process.env.ABX_CHAIN ?? DEFAULT_CHAIN_KEY;
79
- // Block explorer base per chain (keyed by EIP-155 chainId), for the tx/address links the CLI prints.
80
- const EXPLORERS = {
81
- 11155111: 'https://sepolia.etherscan.io', // Sepolia
82
- 84532: 'https://sepolia.basescan.org', // Base Sepolia
83
- };
84
- const EXPLORER = EXPLORERS[resolveChain(CHAIN).id] ?? 'https://sepolia.etherscan.io';
86
+ // Block explorer base for the tx/address links the CLI prints. Derived from viem's chain metadata via
87
+ // the SDK (see explorerUrl) rather than a local table — this used to be a hand-maintained map, which is
88
+ // the same shape of bug that had the token-api dashboard sending every Base Sepolia link to Etherscan.
89
+ const EXPLORER = explorerUrl(resolveChain(CHAIN).id);
85
90
  // Product dimensions — what you can launch. One concrete contract exists today
86
91
  // (the 1/1 image); this table is the seam future implementations slot into, so
87
92
  // `abx deploy --type <dimension>` is stable while the contracts grow under it.
@@ -133,7 +138,7 @@ async function maybeNotifyUpdate(flags) {
133
138
  const latest = await checkForCliUpdate(current);
134
139
  if (latest) {
135
140
  console.error(`\n ${c.orange}⚠${c.reset} update available: ${bold('abx')} ${dim(current)} → ${g(latest)}\n` +
136
- ` upgrade: ${g('npm i -g @artblocks/abx-cli@latest')} ${dim('· or invoke:')} ${g('npx abx@latest <command>')}\n` +
141
+ ` upgrade: ${g('npm i -g @artblocks/abx-cli@latest')} ${dim('· or invoke:')} ${g('npx @artblocks/abx-cli@latest <command>')}\n` +
137
142
  ` release notes: https://github.com/ArtBlocks/abx/releases ${dim('· silence: ABX_NO_UPDATE_CHECK=1')}\n`);
138
143
  }
139
144
  }
@@ -151,7 +156,7 @@ async function main() {
151
156
  return;
152
157
  }
153
158
  // Notify-only "you're behind" nudge (cached, opt-out, stderr — see update-check.ts). Awaited so
154
- // the notice lands before command output, but it hits the network at most once a day and never
159
+ // the notice lands before command output, but it hits the network at most once every 6h and never
155
160
  // throws, so it can't break or meaningfully slow a command. Skipped for the pure help path below.
156
161
  await maybeNotifyUpdate(flags);
157
162
  // SAFETY: --help / -h on ANY command is read-only — print usage, never execute.
@@ -165,6 +170,7 @@ async function main() {
165
170
  case 'deploy-series': return cmdDeploySeries(flags);
166
171
  case 'deploy-code': return cmdDeployCode(flags);
167
172
  case 'inspect': return cmdInspect(rest[0], flags);
173
+ case 'preview': return cmdPreview(flags);
168
174
  case 'scaffold-renderer': return cmdScaffoldRenderer(rest, flags);
169
175
  case 'predict': return cmdPredict(flags);
170
176
  case 'add': return cmdAdd(rest[0], flags);
@@ -173,6 +179,7 @@ async function main() {
173
179
  case 'render': return cmdRender(rest[0], rest.slice(1), flags);
174
180
  case 'effects': return cmdEffects(flags);
175
181
  case 'tokenuri': return cmdTokenUri(rest[0], flags);
182
+ case 'contracturi': return cmdContractUri(rest[0], flags);
176
183
  case 'serve': return cmdServe(flags);
177
184
  // owner operations — write + sign (hot/wallet/cold lane), then re-index
178
185
  case 'mint': return cmdMint(rest[0], flags);
@@ -204,8 +211,9 @@ async function main() {
204
211
  case 'set-admin': return cmdSetAdmin(rest[0], flags);
205
212
  case 'forget': return cmdForget(rest[0], flags);
206
213
  case 'migrate': return cmdMigrate(rest[0], flags);
214
+ case 'remote': return cmdRemote(rest[0], flags);
207
215
  case 'storage': return cmdStorage(rest);
208
- case 'status': return cmdStatus();
216
+ case 'status': return cmdStatus(rest[0], flags);
209
217
  case 'state': return cmdState(rest[0], flags);
210
218
  case 'doctor': return cmdDoctor(flags);
211
219
  case 'skill': return cmdSkill(rest, flags);
@@ -220,6 +228,65 @@ async function main() {
220
228
  process.exit(1);
221
229
  }
222
230
  }
231
+ /**
232
+ * A `--dry-run` computes the deterministic deploy address, which is a pure function of
233
+ * (factory, salt, deployer) — so it needs a deployer even though it signs nothing. Resolve it the
234
+ * same way the preview will (`--for`, else an env key) and fail EARLY with the fix if neither
235
+ * exists, rather than after the preview has printed several steps of work.
236
+ */
237
+ /**
238
+ * A dry run only checks whether a factory address is CONFIGURED, not whether it has code on this
239
+ * chain — and the manifest always has an address, so a chain where the trust anchor isn't deployed
240
+ * (a private/local chain, or a wrong-network RPC) sailed past this and died inside
241
+ * `predictDeterministicAddress` with a raw `returned no data ("0x")` and a list of ABI hypotheses.
242
+ * The real deploy and `abx predict` both explain that case; a preview of the same deploy must too.
243
+ * Returns false when the caller should stop (message already printed).
244
+ */
245
+ async function previewFactoryLive(client, factory, label) {
246
+ const code = await client.getCode({ address: factory }).catch(() => undefined);
247
+ if (code && code !== '0x')
248
+ return true;
249
+ warn(`the configured ${label} ${factory} has no code on '${CHAIN}' (asked ${redactRpcUrl(resolveRpcUrl(CHAIN))}).`);
250
+ info('so this preview can\'t compute the deterministic address. Either point at a chain where the trust anchor is deployed');
251
+ info(`(${bold('ABX_CHAIN=' + DEFAULT_CHAIN_KEY)} is the default and has one), or deploy your own on this chain with ${bold('--bootstrap-factory')}`);
252
+ info(dim('(a private anchor — platforms won\'t recognize its clones, so it\'s for private/sandbox chains).'));
253
+ console.log(`\n ${g('dry run')} ${dim('— nothing sent.')}\n`);
254
+ return false;
255
+ }
256
+ /**
257
+ * Placeholder-identity guard, shared by ALL THREE deploy commands. `name`/`symbol` are written
258
+ * on-chain as the public collection identity and are effectively permanent, so a real deploy must
259
+ * never bake a tool default silently: warn in a preview, HARD-STOP a real send unless `--yes`.
260
+ *
261
+ * It was duplicated per command, and `deploy-series` simply never got a copy — its default
262
+ * "ABX Series"/"ABXS" went on-chain with at most a warning, while the skill promises the CLI
263
+ * refuses demo defaults. (deploy-code's copy even said "mirror deploy/deploy-series", which made
264
+ * the gap look closed.) One predicate now, like `loopbackBaseUrl()`, so a fourth command can't drift.
265
+ */
266
+ function assertRealIdentity(flags, o) {
267
+ if (flags.name && flags.symbol)
268
+ return;
269
+ if (!flags.name)
270
+ warn(`no --name → default "${o.name}" would be the on-chain collection name`);
271
+ if (!flags.symbol)
272
+ warn(`no --symbol → default "${o.symbol}" would be the on-chain symbol`);
273
+ if (o.dryRun || flags.yes)
274
+ return; // a preview still runs; --yes is the explicit opt-in
275
+ throw new Error('refusing to write tool placeholders as your public on-chain identity — pass --name "Your Title" --symbol SYM ' +
276
+ '(or --yes to accept the defaults). On-chain identity is effectively permanent.');
277
+ }
278
+ function assertPreviewDeployer(flags) {
279
+ if (flags.for)
280
+ return;
281
+ try {
282
+ makeWalletClient({ chainKey: CHAIN });
283
+ }
284
+ catch {
285
+ throw new Error('dry run needs a deployer address to compute the deterministic deploy address — pass --for 0x.. ' +
286
+ '(a preview signs nothing, so no key is needed). For the REAL deploy with no key in .env, use the ' +
287
+ 'wallet lane: --sign --for 0x.. (you approve in your own wallet).');
288
+ }
289
+ }
223
290
  // When the user/agent opts in (--yes), lift the getLogs chunk cap so a large
224
291
  // reconstruction proceeds despite a range-limited RPC (otherwise it stops early with
225
292
  // guidance — see GetLogsScanTooLargeError + the skill's "Choosing an RPC" decision).
@@ -293,17 +360,6 @@ async function detectCanonicalFactory(address, override, stored) {
293
360
  }
294
361
  return factoryAddress() ?? undefined;
295
362
  }
296
- /** A short faucet pointer for the active testnet — a 0-balance signer's #1 next step. A known URL
297
- * plus a search hint (URLs rot; the search always works), so "fund it" isn't a dead end. */
298
- function faucetHint(chainKey) {
299
- const url = {
300
- 'base-sepolia': 'https://portal.cdp.coinbase.com/products/faucet',
301
- sepolia: 'https://www.alchemy.com/faucets/ethereum-sepolia',
302
- };
303
- return url[chainKey]
304
- ? `get free test ETH from a ${chainKey} faucet (${url[chainKey]} — or search "${chainKey} faucet"), usually ≤1 min`
305
- : `get test ETH from a "${chainKey}" faucet`;
306
- }
307
363
  // Funding preflight — a 0-balance signer fails only at the tx, with a confusing error. Surface it
308
364
  // up front. Especially the wallet lane (--sign/--for), which has no env key for `doctor` to check.
309
365
  async function warnUnfunded(publicClient, address) {
@@ -455,7 +511,7 @@ function refuseMissingFactory(kind, envVar, reason) {
455
511
  ` To deploy your OWN trust anchor instead (private chains, sandboxes — platforms won't recognize its clones), ` +
456
512
  `re-run with --bootstrap-factory.`);
457
513
  }
458
- async function ensureFactory(override, allowBootstrap = false) {
514
+ async function ensureFactory(override, allowBootstrap = false, quiet = false) {
459
515
  // Resolve the canonical factory from the shipped manifest (flag → env → manifest); on a chain
460
516
  // with no entry, deploy a fresh trust anchor and tell the operator how to reuse it.
461
517
  const known = factoryAddress(override);
@@ -465,7 +521,10 @@ async function ensureFactory(override, allowBootstrap = false) {
465
521
  const code = await publicClient.getCode({ address: known });
466
522
  if (code && code !== '0x') {
467
523
  if (await isCurrentFactory(publicClient, known)) {
468
- info(`using canonical factory ${EXPLORER}/address/${known}`);
524
+ // `quiet` suppresses only this happy-path line (the demo resolves the factory without making a
525
+ // teaching moment of it). Bootstrap/mismatch messages below always print — those matter.
526
+ if (!quiet)
527
+ info(`using canonical factory ${EXPLORER}/address/${known}`);
469
528
  return known;
470
529
  }
471
530
  if (!allowBootstrap)
@@ -982,26 +1041,53 @@ async function cmdDeploy(flags, serveAfter) {
982
1041
  // editable later via `abx add --traits`); `--traits-onchain` inlines them on-chain (durable,
983
1042
  // lockable) — mirrors the description model. On-chain wins if both ever set the same trait.
984
1043
  const traits = parseDeployTraits(flags);
985
- console.log(bold(`\n ABX Self-Host Toolkit — ${serveAfter ? 'demo' : 'deploy'}\n ${dim('a project, served from chain alone')}`));
986
- const lane = serveAfter ? 'send' : laneFromFlags(flags); // demo is always hot (one-shot)
1044
+ console.log(serveAfter
1045
+ ? bold(`\n ABX · your first token\n ${dim("we'll put art on a blockchain then delete our copy and get all of it back")}`)
1046
+ : bold(`\n ABX Self-Host Toolkit — deploy\n ${dim('a project, served from chain alone')}`));
1047
+ // Signing lane. `demo` used to hard-force the hot lane, which SILENTLY dropped `--sign`: with no
1048
+ // env key it died confusingly, and WITH one it signed from that key while the operator had asked
1049
+ // for their browser wallet — a signing choke point that ignored the lane it was handed. The demo
1050
+ // deploy is a single tx, so the wallet lane works here exactly as it does for `deploy`.
1051
+ const lane = laneFromFlags(flags);
1052
+ // The cold lane only PRINTS a tx; demo's whole point is to index + serve what it just deployed,
1053
+ // and there is nothing to index until someone broadcasts. Refuse the combo instead of doing
1054
+ // half the job — `abx deploy --unsigned` is the command for that lane.
1055
+ if (serveAfter && lane === 'unsigned') {
1056
+ throw new Error('`abx demo --unsigned` can\'t work: the cold lane only prints a transaction, and the demo ' +
1057
+ 'indexes + serves the contract it just deployed. Use `abx demo` (hot key) or `abx demo --sign` ' +
1058
+ '(browser wallet) — or `abx deploy --unsigned` if you only want the raw tx.');
1059
+ }
1060
+ // Same shape for --dry-run: previewing sends nothing, so there is nothing to serve.
1061
+ if (serveAfter && flags['dry-run']) {
1062
+ throw new Error('`abx demo --dry-run` can\'t work: a dry run sends nothing, and the demo indexes + serves what ' +
1063
+ 'it deployed. Use `abx deploy --dry-run` to preview a 1/1 deploy without sending.');
1064
+ }
1065
+ // Port preflight BEFORE anything irreversible. The demo ends by serving, and `listen` used to be
1066
+ // the first thing to discover the port was taken — after the deploy tx had already been signed and
1067
+ // paid for, so a second `abx demo` (a very normal thing to try) spent gas and then died with a raw
1068
+ // Node EADDRINUSE stack trace. Check first, and name the fix.
1069
+ if (serveAfter) {
1070
+ const wanted = Number(flags.port ?? process.env.ABX_PORT ?? DEFAULT_PORT);
1071
+ if (await portInUse(wanted)) {
1072
+ throw new Error(`port ${wanted} is already in use — probably an \`abx demo\`/\`abx serve\` still running in another terminal.\n` +
1073
+ ` Nothing was deployed. Stop that one (Ctrl-C), or run this on another port: \`abx demo --port ${wanted + 1}\`.`);
1074
+ }
1075
+ }
987
1076
  const dryRun = !serveAfter && !!flags['dry-run']; // preview only — no send, no custody, no factory deploy
1077
+ // A keyless preview needs `--for` (the address is a pure function of factory+salt+deployer). Check
1078
+ // it HERE, before the trust-anchor/content/plan steps print — hitting this after a wall of output
1079
+ // reads as "it half-worked", and a first-timer previewing with no key in .env always hits it.
1080
+ if (dryRun)
1081
+ assertPreviewDeployer(flags);
988
1082
  const publicClient = makePublicClient({ chainKey: CHAIN });
989
1083
  // Verify the RPC really is CHAIN before any send (factory/renderer/staging/deploy). A dry run
990
1084
  // sends nothing, but it DOES read the chain (predict address, resolve the factory/renderer), so
991
1085
  // a wrong-network RPC must still be caught with the clear mismatch message rather than failing
992
1086
  // opaquely inside predict; `allowUnreachable` keeps a genuinely offline dry-run previewable.
993
1087
  await assertChainId(CHAIN, { allowUnreachable: dryRun });
994
- // Identity guard: the demo placeholders ("ABX Self-Host Demo"/"ABXSH") are written on-chain as
995
- // the public collection identity. Warn in preview; HARD-STOP a real deploy unless acknowledged.
996
- if (!serveAfter && (!flags.name || !flags.symbol)) {
997
- if (!flags.name)
998
- warn(`no --name → default "${name}" would be the on-chain collection name`);
999
- if (!flags.symbol)
1000
- warn(`no --symbol → default "${symbol}" would be the on-chain symbol`);
1001
- if (!dryRun && !flags.yes) {
1002
- 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).');
1003
- }
1004
- }
1088
+ // `demo` is exempt: its whole job is a zero-argument first token.
1089
+ if (!serveAfter)
1090
+ assertRealIdentity(flags, { name, symbol, dryRun });
1005
1091
  // Funding preflight (real deploys): warn now if the signer is unfunded, not at the tx.
1006
1092
  if (!dryRun) {
1007
1093
  let signer = flags.for;
@@ -1014,7 +1100,13 @@ async function cmdDeploy(flags, serveAfter) {
1014
1100
  if (signer)
1015
1101
  await warnUnfunded(publicClient, signer);
1016
1102
  }
1017
- step('Trust anchor');
1103
+ // `deploy` surfaces the trust anchor as a step; the demo does NOT. It briefly opened on "only this
1104
+ // factory can make a token that IS an ABX token", which is simply false — anything that follows the
1105
+ // protocol's event spine is an ABX token, and the factory is one way to get there, not the
1106
+ // definition of the thing. Rather than restate it more carefully, the demo skips it: a first-timer
1107
+ // does not need a provenance lecture before they have made anything.
1108
+ if (!serveAfter)
1109
+ step('Trust anchor');
1018
1110
  let factory;
1019
1111
  if (dryRun) {
1020
1112
  const existing = factoryAddress(flags.factory);
@@ -1024,10 +1116,12 @@ async function cmdDeploy(flags, serveAfter) {
1024
1116
  return;
1025
1117
  }
1026
1118
  factory = existing;
1119
+ if (!(await previewFactoryLive(publicClient, factory, 'factory')))
1120
+ return;
1027
1121
  info(`would reuse canonical factory ${factory}`);
1028
1122
  }
1029
1123
  else {
1030
- factory = await ensureFactory(flags.factory, !!flags['bootstrap-factory']);
1124
+ factory = await ensureFactory(flags.factory, !!flags['bootstrap-factory'], serveAfter);
1031
1125
  }
1032
1126
  // --onchain-uri: resolve tokenURI/contractURI fully on-chain via the canonical renderer
1033
1127
  // (the JSON is assembled from on-chain fields → no resolver needed, ever). The off-chain
@@ -1035,18 +1129,32 @@ async function cmdDeploy(flags, serveAfter) {
1035
1129
  // --onchain-image: stage the --image bytes on-chain (chunk store, ownerless) and bake a
1036
1130
  // `reader` field into the deploy — so large on-chain content needs NO post-deploy tx. It
1037
1131
  // implies on-chain URI resolution (the renderer emits the reader-backed image).
1038
- const onchainImage = !!flags['onchain-image'];
1039
- const onChainUri = !!flags['onchain-uri'] || onchainImage;
1040
- // On-chain traits when explicitly asked, or implied by --onchain-uri (the renderer can only
1041
- // emit on-chain fields, so off-chain-only traits would be invisible there). Else off-chain.
1042
- const traitsOnchain = traits.length > 0 && (!!flags['traits-onchain'] || onChainUri);
1043
1132
  // A fully on-chain token self-resolves via the renderer; the stored off-chain pointer is
1044
1133
  // never read while a renderer is set. So bake a real URL only if one was explicitly given —
1045
1134
  // otherwise leave it EMPTY rather than baking a misleading localhost into the contract.
1046
1135
  const hasPublicUrl = !!(flags['public-base-url'] || process.env.ABX_PUBLIC_BASE_URL);
1136
+ const onchainImage = !!flags['onchain-image'];
1137
+ // `abx demo` defaults to FULLY ON-CHAIN. Its default art is a generative SVG, which the renderer
1138
+ // can inline — so the token self-resolves and, crucially, NO localhost gets baked into the
1139
+ // contract as the tokenURI base. The old default shipped a first-ever token that resolved for
1140
+ // nobody but its author (broken on every marketplace, dead the moment `abx serve` stops) and
1141
+ // taught that as the normal shape of an NFT. It also undercut the demo's own claim: with the art
1142
+ // on-chain, "rebuilt from the chain alone" now covers the IMAGE, not just the metadata.
1143
+ //
1144
+ // Opting back into off-chain custody is anything that says "I have somewhere to host": a real base
1145
+ // URL, an explicit --backend, or a RASTER --image. An SVG --image still goes on-chain — inlining is
1146
+ // exactly what the renderer supports (v1 is SVG-only), so there's no reason to send someone's own
1147
+ // vector art down the localhost path. Raster stays off-chain because forcing it on-chain would
1148
+ // silently inline a placeholder instead of their image.
1149
+ const demoImageInlineable = !flags.image || contentTypeFromPath(flags.image) === 'image/svg+xml';
1150
+ const demoDefaultsOnChain = serveAfter && demoImageInlineable && !flags.backend && !hasPublicUrl;
1151
+ const onChainUri = !!flags['onchain-uri'] || onchainImage || demoDefaultsOnChain;
1152
+ // On-chain traits when explicitly asked, or implied by --onchain-uri (the renderer can only
1153
+ // emit on-chain fields, so off-chain-only traits would be invisible there). Else off-chain.
1154
+ const traitsOnchain = traits.length > 0 && (!!flags['traits-onchain'] || onChainUri);
1047
1155
  let renderer = zeroAddress;
1048
1156
  if (onChainUri) {
1049
- step('On-chain renderer');
1157
+ step(serveAfter ? 'What will answer when someone asks about your token' : 'On-chain renderer');
1050
1158
  if (dryRun) {
1051
1159
  renderer = rendererAddress(flags.renderer) ?? zeroAddress;
1052
1160
  info(renderer === zeroAddress ? 'would deploy the canonical renderer first' : `would use renderer ${renderer}`);
@@ -1054,8 +1162,28 @@ async function cmdDeploy(flags, serveAfter) {
1054
1162
  else {
1055
1163
  renderer = await ensureRenderer(flags.renderer);
1056
1164
  }
1165
+ // A first-timer has no idea what a "renderer" is, and the word suggests something that draws
1166
+ // pictures. What it actually does is assemble the JSON a marketplace asks for, on-chain, out of
1167
+ // the fields your contract holds — worth one plain sentence, since it's why no server is needed.
1168
+ if (serveAfter) {
1169
+ info(dim('a marketplace asks your contract a question; this shared contract composes the answer'));
1170
+ info(dim('already deployed, used by every ABX token, owned by no one — you are not paying to set it up'));
1171
+ }
1172
+ }
1173
+ step(serveAfter ? `Mint it · one transaction on ${CHAIN}` : `Deploy a ${DIMENSIONS[dimension].label} (--type ${dimension}) to ${CHAIN}`);
1174
+ // What the chain ends up holding is the single most useful thing to understand about an ABX token,
1175
+ // and it's invisible unless someone says it out loud. Two honest versions, because the answer is
1176
+ // genuinely different per lane. Demo only — `deploy` prints the same facts per-field.
1177
+ if (serveAfter && onChainUri) {
1178
+ console.log(` Your art goes ${bold('INTO')} the contract. Not a link to it — the image itself.`);
1179
+ console.log(` ${g('✦')} the chain will hold ${dim('your art · your name on it · your royalty · you as owner')}`);
1180
+ console.log(` ${g('✦')} you will need ${dim('nothing else. no server, no IPFS pin, no monthly bill to forget')}`);
1181
+ }
1182
+ else if (serveAfter) {
1183
+ console.log(` ${g('✦')} the chain will hold ${dim('your name · your royalty · you as owner · a fingerprint of the art')}`);
1184
+ console.log(` ${g('✦')} this computer holds ${dim('the image bytes themselves')}`);
1185
+ info(dim('the chain proves the bytes are unaltered; it does not store them (that fingerprint is a keccak256 hash)'));
1057
1186
  }
1058
- step(`Deploy a ${DIMENSIONS[dimension].label} (--type ${dimension}) to ${CHAIN}`);
1059
1187
  // Off-chain custody (no renderer) bakes the resolver URL straight into the on-chain
1060
1188
  // tokenURI/contractURI at deploy. A localhost / loopback URL there resolves for NO ONE —
1061
1189
  // not marketplaces, not wallets, not even your own browser unless `abx serve` is running —
@@ -1082,7 +1210,14 @@ async function cmdDeploy(flags, serveAfter) {
1082
1210
  }
1083
1211
  }
1084
1212
  }
1085
- if (onChainUri) {
1213
+ if (onChainUri && serveAfter) {
1214
+ // The demo's version of the same fact, in words a first-timer can act on. Naming what we are
1215
+ // NOT doing matters here: a localhost URL written into a contract is the single most common way
1216
+ // a first NFT ends up permanently broken, and the demo used to model exactly that.
1217
+ info(dim('nothing points at this computer — no http://localhost anywhere in your contract.'));
1218
+ info(dim('anyone can read your token from the chain, forever, with you offline.'));
1219
+ }
1220
+ else if (onChainUri) {
1086
1221
  info('tokenURI/contractURI resolve ON-CHAIN via the renderer — no resolver, no server, no localhost.');
1087
1222
  if (!hasPublicUrl)
1088
1223
  info('off-chain fallback pointer left empty (the renderer is authoritative); set --public-base-url to bake one anyway.');
@@ -1259,7 +1394,9 @@ async function cmdDeploy(flags, serveAfter) {
1259
1394
  // wallet lane + on-chain staging: ONE sign session signs every chunk write AND the deploy.
1260
1395
  // The connecting wallet pays for (and is the deployer of) all of it — staging can't precede
1261
1396
  // the connect here, so it happens inside the session, then the deploy bakes in the manifest.
1262
- info(`a wallet will become the owner; URIs point at ${baseUrl}`);
1397
+ // --onchain-image implies on-chain resolution, so NOTHING points at baseUrl — saying it did was
1398
+ // a flat contradiction of the line above it (and reintroduced the localhost the lane exists to avoid).
1399
+ info('a wallet will become the owner; the token resolves from chain — no URI base is baked in.');
1263
1400
  const plan = computeContentPlan(readFileSync(resolvePath(flags.image)), parseCompress(flags.compress)).plan;
1264
1401
  const stagingTxs = plan.mode === 'single' ? 1 : plan.txCount;
1265
1402
  const session = await openWalletSession({
@@ -1324,8 +1461,12 @@ async function cmdDeploy(flags, serveAfter) {
1324
1461
  info(`tx ${EXPLORER}/tx/${r.txHash} (block ${blockNumber})`);
1325
1462
  }
1326
1463
  else {
1327
- // wallet lane (off-chain custody) or cold lane: a single deploy tx, no staging sequence.
1328
- info(`a wallet will become the owner; URIs point at ${baseUrl}`);
1464
+ // wallet lane (off-chain custody OR on-chain URI) or cold lane: a single deploy tx, no staging.
1465
+ // Only claim a URI base when one is actually written — on the on-chain lane this line used to
1466
+ // announce `http://localhost:8787` two lines after promising no localhost anywhere.
1467
+ info(onChainUri
1468
+ ? 'a wallet will become the owner; the token resolves from chain — no URI base is baked in.'
1469
+ : `a wallet will become the owner; URIs point at ${baseUrl}`);
1329
1470
  const result = await signTx(async (signer) => {
1330
1471
  const { clone: predicted, params, salt } = await buildForDeployer(signer);
1331
1472
  return prepareDeployOneOfOne({ factory, params, salt, chainId: resolveChain(CHAIN).id, clone: predicted });
@@ -1338,7 +1479,7 @@ async function cmdDeploy(flags, serveAfter) {
1338
1479
  blockNumber = result.blockNumber;
1339
1480
  ok(`deployed ${clone}`);
1340
1481
  }
1341
- step('Index it — replay the event spine from chain');
1482
+ step(serveAfter ? 'What the chain knows now' : 'Index it — replay the event spine from chain');
1342
1483
  const indexer = new SelfHostIndexer();
1343
1484
  // Off-chain traits ride in the registration (on-chain ones are already in the contract fields).
1344
1485
  const offChainTraits = !traitsOnchain && traits.length ? JSON.stringify(traits) : undefined;
@@ -1353,10 +1494,23 @@ async function cmdDeploy(flags, serveAfter) {
1353
1494
  attributes: offChainTraits,
1354
1495
  };
1355
1496
  indexer.register(baseReg);
1356
- const { state, elapsedMs } = await indexer.reindex(clone);
1357
- ok(`reconstructed ${state.eventCount} events in ${elapsedMs}ms — no provider involved`);
1358
- info(`name "${state.name}" · owner ${state.owner} · canonical: ${state.isCanonical ? 'yes (factory-verified)' : 'unverified'}`);
1359
- info(`extensions: ${state.extensions.map((e) => e.name).join(', ') || 'none'}`);
1497
+ const { state, elapsedMs } = await reindexAfterDeploy(indexer, clone);
1498
+ if (serveAfter) {
1499
+ if (state.eventCount > 0)
1500
+ ok(`read ${bold(String(state.eventCount))} events straight off ${CHAIN} in ${elapsedMs}ms — no API key, no company's server`);
1501
+ // "verified real" overstated it in the same way the removed trust-anchor step did — factory
1502
+ // provenance is a fact about how this contract was made, not a verdict on whether a token counts
1503
+ // as ABX. Report the fact.
1504
+ info(`it says: "${state.name}" · owned by ${state.owner} · ${state.isCanonical ? g('made by the canonical factory') : dim('not factory-made')}`);
1505
+ info(dim(`optional features switched on: ${state.extensions.map((e) => e.name.replace(/^abx\.extension\./, '')).join(' · ') || 'none'}`));
1506
+ walkthroughSpine(state);
1507
+ }
1508
+ else {
1509
+ if (state.eventCount > 0)
1510
+ ok(`reconstructed ${state.eventCount} events in ${elapsedMs}ms — no provider involved`);
1511
+ info(`name "${state.name}" · owner ${state.owner} · canonical: ${canonicalLabel(state.isCanonical)}`);
1512
+ info(`extensions: ${state.extensions.map((e) => e.name).join(', ') || 'none'}`);
1513
+ }
1360
1514
  // For off-chain custody (image committed as keccak256), resolve the durable locator (ipfs://…)
1361
1515
  // from this machine's content index and store it on the registration — so the LOCAL resolver
1362
1516
  // points `image` at IPFS, and `abx add --remote` can ship it to a hosted one (the localhost-image fix).
@@ -1405,8 +1559,21 @@ async function cmdDeploy(flags, serveAfter) {
1405
1559
  }
1406
1560
  return;
1407
1561
  }
1408
- step('Serve the token API + dashboard');
1562
+ // The demo's teaching sections — see the walkthrough helpers. `deploy` skips them: someone
1563
+ // shipping real work doesn't need their projection deleted to make a point.
1564
+ if (serveAfter)
1565
+ await walkthroughRebuild(indexer, clone, state);
1566
+ // Start the server BEFORE the read-back step (which fetches the served metadata over HTTP), but
1567
+ // print the serve banner after it — otherwise the "Serve" step header lands with nothing under it
1568
+ // while the read-back prints below, which reads like the step failed.
1409
1569
  const { url } = await startTokenApiServer({ indexer, port, baseUrl, storage: resolveBackend(storageOptions()) });
1570
+ if (serveAfter)
1571
+ await walkthroughReadBack(state, url, onChainUri);
1572
+ // On the on-chain lane this server is a convenience, not infrastructure — say so, or standing one
1573
+ // up as the finale re-teaches the dependency the whole run just disproved.
1574
+ step(onChainUri ? 'Go look at it' : 'Serve the token API + dashboard');
1575
+ if (onChainUri)
1576
+ info(dim('a local viewer, purely for your eyes — your token does not need it. Ctrl-C whenever; the token stays up.'));
1410
1577
  printServing(url, clone);
1411
1578
  keepAlive();
1412
1579
  }
@@ -1457,21 +1624,15 @@ async function cmdDeploySeries(flags) {
1457
1624
  throw new Error(`--count ${count} exceeds the ${files.length} media file(s) in ${dirPath}`);
1458
1625
  const slots = files.slice(0, count);
1459
1626
  const dryRun = !!flags['dry-run'];
1627
+ if (dryRun)
1628
+ assertPreviewDeployer(flags); // fail fast, before the preview does any work (see cmdDeploy)
1629
+ assertRealIdentity(flags, { name, symbol, dryRun });
1460
1630
  const lane = laneFromFlags(flags);
1461
1631
  const publicClient = makePublicClient({ chainKey: CHAIN });
1462
1632
  // Catch a wrong-network RPC with the clear mismatch message even on dry-run (which still reads
1463
1633
  // the chain to predict the address); tolerate an unreachable RPC so an offline preview still works.
1464
1634
  await assertChainId(CHAIN, { allowUnreachable: dryRun });
1465
- // Identity guard (mirror cmdDeploy): never write demo placeholders as the public identity.
1466
- if (!flags.name || !flags.symbol) {
1467
- if (!flags.name)
1468
- warn(`no --name → default "${name}" would be the on-chain collection name`);
1469
- if (!flags.symbol)
1470
- warn(`no --symbol → default "${symbol}" would be the on-chain symbol`);
1471
- if (!dryRun && !flags.yes) {
1472
- throw new Error('refusing to write demo placeholders as your public on-chain identity — pass --name "Your Title" --symbol SYM (or --yes).');
1473
- }
1474
- }
1635
+ // (identity guard already ran above, via the shared assertRealIdentity before any RPC)
1475
1636
  // Mint timing: mint-all → the whole series; mint-count N → the first N; else deferred.
1476
1637
  const mintCount = flags['mint-all'] !== undefined ? count : flags['mint-count'] ? Number(flags['mint-count']) : 0;
1477
1638
  if (mintCount > count)
@@ -1515,6 +1676,8 @@ async function cmdDeploySeries(flags) {
1515
1676
  return;
1516
1677
  }
1517
1678
  factory = existing;
1679
+ if (!(await previewFactoryLive(publicClient, factory, 'Series factory')))
1680
+ return;
1518
1681
  info(`would reuse canonical Series factory ${factory}`);
1519
1682
  }
1520
1683
  else {
@@ -1848,8 +2011,11 @@ async function cmdDeploySeries(flags) {
1848
2011
  }
1849
2012
  else {
1850
2013
  // wallet lane (off-chain / inline) or cold lane: a single deploy tx, no staging sequence —
1851
- // tokenFields is already built.
1852
- info(`a wallet will become the owner; URIs point at ${baseUrl}`);
2014
+ // tokenFields is already built. Same correction as the 1/1 lane: only claim a URI base when one
2015
+ // is actually written, or an --onchain-uri Series announces a localhost it never bakes.
2016
+ info(onChainUri
2017
+ ? 'a wallet will become the owner; tokens resolve from chain — no URI base is baked in.'
2018
+ : `a wallet will become the owner; URIs point at ${baseUrl}`);
1853
2019
  const result = await signTx(async (signer) => {
1854
2020
  const { clone: predicted, params, salt } = await buildForDeployer(signer);
1855
2021
  return prepareDeploySeries({ factory, params, salt, chainId: resolveChain(CHAIN).id, clone: predicted });
@@ -1877,8 +2043,9 @@ async function cmdDeploySeries(flags) {
1877
2043
  tokenAttributes: offChainTokenTraits,
1878
2044
  };
1879
2045
  indexer.register(baseReg);
1880
- const { state, elapsedMs } = await indexer.reindex(clone);
1881
- ok(`reconstructed ${state.eventCount} events in ${elapsedMs}ms — ${state.tokens.length} token(s), max ${state.maxInvocations}`);
2046
+ const { state, elapsedMs } = await reindexAfterDeploy(indexer, clone);
2047
+ if (state.eventCount > 0)
2048
+ ok(`reconstructed ${state.eventCount} events in ${elapsedMs}ms — ${state.tokens.length} token(s), max ${state.maxInvocations}`);
1882
2049
  info(`extensions: ${state.extensions.map((e) => e.name).join(', ') || 'none'}`);
1883
2050
  // Off-chain custody: bridge each token's keccak → durable locator so the resolver (local and,
1884
2051
  // via `abx add --remote`, a hosted one) points images off this node.
@@ -1972,18 +2139,166 @@ async function cmdPredict(flags) {
1972
2139
  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)'));
1973
2140
  console.log('');
1974
2141
  }
1975
- // A `--remote [url]` flag targets a HOSTED resolver's admin control plane instead of
1976
- // this machine's local projection. Bare `--remote` falls back to ABX_PUBLIC_BASE_URL
1977
- // (the URL baked on-chain). Returns null for a local op (the default).
1978
- function remoteTarget(flags) {
1979
- const v = flags.remote;
1980
- if (v === undefined)
1981
- return null;
1982
- const base = v !== 'true' ? v : process.env.ABX_PUBLIC_BASE_URL;
1983
- if (!base) {
1984
- throw new Error('`--remote` needs a resolver URL: pass `--remote https://host` or set ABX_PUBLIC_BASE_URL in .env');
2142
+ // The `--remote <name|url>` target (remote.ts owns the convention): a named remote's
2143
+ // `ABX_REMOTE_<NAME>_URL/_TOKEN`, an ad-hoc URL, or bare `--remote` = the self-host default.
2144
+ // Returns null for a local op (the default).
2145
+ function remoteFlag(flags) {
2146
+ return resolveRemote(flags.remote, flags['remote-token']);
2147
+ }
2148
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
2149
+ /**
2150
+ * The factory-verification tri-state, said plainly. `null` means the check never ran (no factory
2151
+ * configured, or the configured one has no code on this chain common on a local/dev chain), which
2152
+ * is NOT the same as the chain telling us this contract isn't a clone of it.
2153
+ */
2154
+ function canonicalLabel(isCanonical) {
2155
+ if (isCanonical === true)
2156
+ return g('yes (factory-verified)');
2157
+ if (isCanonical === false)
2158
+ return `${c.orange}NO — not a clone of the configured factory${c.reset}`;
2159
+ return dim('not checked (no canonical factory for this chain — `abx doctor` shows which)');
2160
+ }
2161
+ /** How a lifecycle state reads at a glance — the same word everywhere it's printed. */
2162
+ function statusLabel(s) {
2163
+ if (s === 'live')
2164
+ return g('live');
2165
+ if (s === 'failed')
2166
+ return `${c.orange}failed${c.reset}`;
2167
+ if (s === 'stale')
2168
+ return `${c.orange}stale${c.reset}`;
2169
+ return dim(s); // queued | backfilling — in progress, not a problem
2170
+ }
2171
+ /** `label value` with only the label dimmed — status values carry their own color, and wrapping
2172
+ * them in `info()` would fight it. */
2173
+ const statusRow = (label, value) => console.log(` ${dim(label.padEnd(10))} ${value}`);
2174
+ /**
2175
+ * What to DO about a failure class — whose problem it is and whether waiting is the answer.
2176
+ * The class alone told an agent enough to reason it out; it does not tell a creator, and "whose
2177
+ * problem is this" is the single question a failed catch-up has to answer.
2178
+ */
2179
+ function indexErrorAction(cls) {
2180
+ if (cls === 'rpc_rate_limited')
2181
+ return "the SERVICE's chain RPC is throttled — not your key, address, or chain. It retries on its own; if it stays this way, that's the operator's to fix.";
2182
+ if (cls === 'rpc_unavailable')
2183
+ return "the SERVICE can't reach its chain RPC — not your key, address, or chain. It retries on its own; if it stays this way, contact the operator.";
2184
+ if (cls === 'not_abx_contract')
2185
+ return 'the service found no ABX events at that address on this chain — check the address and that the service serves the right chain.';
2186
+ return 'the cause is in the service operator’s logs — nothing you can fix from here; contact them if it persists.';
2187
+ }
2188
+ /** The one wording for "registered, but catch-up failed" — shared by add/index so both say the same
2189
+ * thing: what failed, whose problem it is, and that the registration survived. */
2190
+ function failedCatchUpMessage(address, remote, err, check) {
2191
+ const cls = err?.class;
2192
+ return (`${address} is registered on ${remote.url}, but its catch-up FAILED` +
2193
+ `${cls ? ` (${cls}${err?.message ? `: ${err.message}` : ''})` : ''}.\n` +
2194
+ ` ${cls ? `→ ${indexErrorAction(cls)}\n ` : ''}` +
2195
+ `The registration is durable and the service retries with backoff — watch it with ${bold(check + ' --watch')}.`);
2196
+ }
2197
+ /**
2198
+ * One human line for a status read: where it is, how far along, and why if it's unhappy.
2199
+ *
2200
+ * What "where" means depends on the state, so say the right thing rather than one number that reads
2201
+ * differently in each: a percentage is only honest while BACKFILLING (see indexProgress). A `live`
2202
+ * project's `toBlock` only advances when that project has events, so on a busy chain a perfectly
2203
+ * current project sits far below head — printing that as a ratio makes healthy look broken.
2204
+ */
2205
+ function statusLine(s) {
2206
+ const p = indexProgress(s);
2207
+ let where;
2208
+ if (p)
2209
+ where = `${s.toBlock}/${s.headBlock} ${dim(`(${p.percent}%)`)}`;
2210
+ else if (!s.toBlock)
2211
+ where = dim('not indexed yet');
2212
+ else if (s.status === 'live')
2213
+ where = `${g('caught up')} ${dim(`· scanned through block ${s.toBlock}`)}`;
2214
+ else if (s.status === 'stale')
2215
+ where = `${dim('not tracking head right now')} ${dim(`· scanned through block ${s.toBlock}${s.headBlock ? ` of ${s.headBlock}` : ''}`)}`;
2216
+ else
2217
+ where = dim(`scanned through block ${s.toBlock}`);
2218
+ const why = s.error ? ` ${c.orange}${s.error.class}${c.reset}${s.error.message ? dim(` — ${s.error.message}`) : ''}` : '';
2219
+ return `${statusLabel(s.status)} ${where}${why}`;
2220
+ }
2221
+ /**
2222
+ * Report a register/reindex answer, whichever of the two conformant shapes the service used.
2223
+ *
2224
+ * A `200` already carries the counts. A `202` means the registration is durable and catch-up is still
2225
+ * running — so by default we poll to a terminal state and print the SAME summary line, giving the
2226
+ * human the "waits, then tells you what happened" UX without the service holding a socket open for
2227
+ * minutes. `--no-wait` stops at the 202 and names the command that checks later.
2228
+ */
2229
+ async function reportRemoteIndexing(remote, chainId, address, r, flags, verb) {
2230
+ // A real ABX clone ALWAYS emits a spine (its extension registrations at minimum), so a caught-up
2231
+ // projection with zero events means the service scanned the wrong chain/floor or its RPC hasn't
2232
+ // served the logs — not that the project is empty. A ✓ there is the lie that produces an empty
2233
+ // dashboard (the same guard `reindexAfterDeploy` applies locally).
2234
+ const settledLine = (events, tail) => {
2235
+ if (events > 0)
2236
+ ok(`remote resolver ${verb} ${address}: ${tail}`);
2237
+ else
2238
+ warn(`${address} is registered and caught up on ${remote.url}, but with ${bold('0 events')} — it will serve nothing. Check the service covers ${CHAIN} and that its RPC serves logs from the deploy block.`);
2239
+ };
2240
+ if (!isAccepted(r)) {
2241
+ settledLine(r.project.eventCount, `${r.project.eventCount} events ${dim(`(${r.mode}, ${r.elapsedMs}ms)`)}`);
2242
+ return;
2243
+ }
2244
+ const spec = remote.name ? remote.name.toLowerCase() : remote.source === 'default' ? '' : remote.url;
2245
+ const check = `abx status ${address} --remote${spec ? ` ${spec}` : ''}`;
2246
+ // The registration is durable in EVERY branch here — but don't claim it "is catching up" when the
2247
+ // service already told us the catch-up failed. Two different sentences for two different facts.
2248
+ if (r.project.status === 'failed') {
2249
+ // Lead with the OUTCOME, not the sub-step that succeeded: a line starting "registered on …" skims
2250
+ // as success even with the failure later in the sentence (a reviewer read it exactly that way).
2251
+ warn(`${bold('catch-up FAILED')} on ${remote.url} — nothing is being served yet ${dim('(the registration itself is durable; the service retries it)')}`);
2252
+ }
2253
+ else {
2254
+ info(`registered — ${r.project.status} ${dim('(the service accepted it and is catching up; the registration is durable)')}`);
2255
+ }
2256
+ // A known failure is not something to "not wait" for — we already have the answer, so report it as
2257
+ // one regardless of --no-wait (never a ✓ over a broken index).
2258
+ if (flags['no-wait'] !== undefined && r.project.status !== 'failed') {
2259
+ info(`not waiting (--no-wait). Check with ${bold(check)}`);
2260
+ return;
2261
+ }
2262
+ const label = `remote ${verb === 'indexed' ? 'add' : 'index'}`;
2263
+ if (r.project.status === 'failed') {
2264
+ // Fetch the class the register response may not have carried, so the "whose problem" line is
2265
+ // never missing on the path that reports the failure soonest.
2266
+ const st = await serviceClient(remote).projectStatus(chainId, address).catch(() => undefined);
2267
+ throw new Error(`${label}: ${failedCatchUpMessage(address, remote, st?.error ?? undefined, check)}`);
2268
+ }
2269
+ let shown = -100;
2270
+ let lastStatus;
2271
+ try {
2272
+ const final = await serviceClient(remote).awaitIndexed(chainId, address, {
2273
+ onProgress: (s) => {
2274
+ const p = indexProgress(s);
2275
+ // Print on a state change or a meaningful step — a poll line every 3s is noise in a log an
2276
+ // agent has to read back.
2277
+ if (s.status !== lastStatus || (p && p.percent - shown >= 10)) {
2278
+ lastStatus = s.status;
2279
+ if (p)
2280
+ shown = p.percent;
2281
+ console.log(` ${statusLine(s)}`);
2282
+ }
2283
+ },
2284
+ });
2285
+ if (final.status === 'failed') {
2286
+ throw new Error(`${label}: ${failedCatchUpMessage(address, remote, final.error, check)}`);
2287
+ }
2288
+ settledLine(final.eventCount, `${final.eventCount} events, ${final.tokenCount} token(s) ${dim('(live)')}`);
2289
+ }
2290
+ catch (err) {
2291
+ if (err instanceof AbxIndexTimeoutError) {
2292
+ warn(`${address} is still ${err.last?.status ?? 'catching up'} on ${remote.url} — nothing is lost, it just isn't done.`);
2293
+ info(`follow it with ${bold(check + ' --watch')}`);
2294
+ return;
2295
+ }
2296
+ // The service went away mid-wait (or rejected the poll). The registration still landed — say
2297
+ // which failure this is, in the same words every other remote command uses.
2298
+ if (err instanceof AbxServiceError)
2299
+ throw describeRemoteError(err, remote, `remote ${verb}: registered, but polling status`);
2300
+ throw err;
1985
2301
  }
1986
- return base;
1987
2302
  }
1988
2303
  /**
1989
2304
  * Ensure a resolver admin token exists locally, generating + persisting one to `.env`
@@ -2016,24 +2331,24 @@ function ensureEffectsToken() {
2016
2331
  process.env.ABX_EFFECTS_TOKEN = token;
2017
2332
  return { token, generated: true };
2018
2333
  }
2019
- /** The shared secret that authorizes remote indexing control (never on-chain signing). */
2020
- function requireAdminToken() {
2021
- const t = process.env.ABX_RESOLVER_ADMIN_TOKEN;
2022
- if (!t) {
2023
- throw new Error('remote ops need ABX_RESOLVER_ADMIN_TOKEN in your .env — it must match the token set on the resolver ' +
2024
- '(`abx deploy-resolver` generates one and wires both sides).');
2025
- }
2026
- return t;
2027
- }
2028
2334
  // ── add ──────────────────────────────────────────────────────────────────────
2029
2335
  // Register + index a project this node didn't deploy. LOCAL by default (this
2030
2336
  // machine's store); `--remote [url]` instead tells a HOSTED resolver to index it —
2031
2337
  // the bridge a local deploy can't make on its own (separate projection stores).
2032
2338
  async function cmdAdd(address, flags) {
2033
2339
  if (!address || address.startsWith('--')) {
2034
- console.error('usage: abx add <address> [--from-block N] [--factory 0x..] [--label "..."] [--remote [url]]\n');
2340
+ console.error('usage: abx add <address> [--from-block N] [--factory 0x..] [--label "..."] [--remote [name|url]]\n');
2035
2341
  process.exit(1);
2036
2342
  }
2343
+ // REFUSE `--dry-run` rather than ignoring it. `add` is a write (a local registration + index, and
2344
+ // with `--remote` a registration on someone else's service), and it has no preview mode — so
2345
+ // silently proceeding to DO the thing when the caller explicitly asked to preview is the one
2346
+ // outcome we must never produce. Name what's read-only instead.
2347
+ if (flags['dry-run'] !== undefined) {
2348
+ throw new Error('`abx add` has no --dry-run: it registers + indexes for real (and with --remote it registers on that service). ' +
2349
+ 'Nothing here touches the chain, but it does write. To look before acting: `abx state <address>` (on-chain snapshot) ' +
2350
+ 'or `abx status <address> [--remote <name>]` (what a node already has). Re-run without --dry-run when you mean it.');
2351
+ }
2037
2352
  // `--attributes` is lane-aware here exactly as at deploy: a PER-TOKEN payload edits a Series'
2038
2353
  // per-token off-chain traits; a flat payload (+ `--traits`) edits the collection/1-of-1 `attributes`.
2039
2354
  // Ambiguity defaults to flat (see looksPerTokenAttributes), so a 1/1 add is never mis-read.
@@ -2045,10 +2360,10 @@ async function cmdAdd(address, flags) {
2045
2360
  if (flags.traits)
2046
2361
  flagTraits.push(...parseTraitPairs(flags.traits));
2047
2362
  const editedTokenAttributes = perTokenEdit ? parseSeriesTraitsById(attrRaw) : undefined;
2048
- const remote = remoteTarget(flags);
2363
+ const remote = remoteFlag(flags);
2049
2364
  if (remote) {
2050
- const token = requireAdminToken();
2051
- // Bridge what a hosted resolver can't derive itself: the off-chain traits and the durable
2365
+ requireRemoteToken(remote);
2366
+ // Bridge what a remote resolver can't derive itself: the off-chain traits and the durable
2052
2367
  // content locators (ipfs://…). Prefer flags; otherwise forward what the LOCAL deploy stored
2053
2368
  // (the local registration), and compute locators from this machine's content index if needed.
2054
2369
  const localReg = new SelfHostIndexer().store.getRegistration(address);
@@ -2057,7 +2372,7 @@ async function cmdAdd(address, flags) {
2057
2372
  : localReg?.attributes
2058
2373
  ? normalizeAttributes(JSON.parse(localReg.attributes))
2059
2374
  : undefined;
2060
- // Bridge a Series' per-token off-chain traits to the hosted resolver (the resolver has no other
2375
+ // Bridge a Series' per-token off-chain traits to the remote resolver (the resolver has no other
2061
2376
  // way to derive them — they're operator metadata, not chain state). A fresh per-token `--attributes`
2062
2377
  // EDITS them; otherwise forward what the LOCAL deploy stored. Best-effort parse.
2063
2378
  let tokenAttributes;
@@ -2083,6 +2398,7 @@ async function cmdAdd(address, flags) {
2083
2398
  // (Its ABSENCE here was the bug: a hosted resolver defaulted to genesis and scanned the whole
2084
2399
  // chain.) Re-sending the same floor stays incremental server-side, so a nudge ≠ a re-scan.
2085
2400
  const body = {
2401
+ chainId: resolveChain(CHAIN).id,
2086
2402
  address,
2087
2403
  fromBlock: await resolveScanFloor(address, localReg?.fromBlock, flags),
2088
2404
  factory: await detectCanonicalFactory(address, flags.factory, localReg?.factory),
@@ -2094,12 +2410,22 @@ async function cmdAdd(address, flags) {
2094
2410
  contentLocators: Object.keys(contentLocators).length ? contentLocators : undefined,
2095
2411
  full: flags.full ? true : undefined,
2096
2412
  };
2097
- info(`${bold('REMOTE')} → ${remote} ${dim('(registering with the hosted resolver — NOT this machine)')}`);
2413
+ info(`${bold('REMOTE')} → ${remote.url} ${dim('(registering with the remote resolver — NOT this machine)')}`);
2098
2414
  if (body.contentLocators)
2099
2415
  info(`bridging image locator → ${Object.values(body.contentLocators)[0]} ${dim('(so the resolver points at IPFS, not its own localhost)')}`);
2100
- const r = await remoteAddProject(remote, token, body);
2101
- ok(`remote resolver indexed ${r.project.name ?? address}: ${r.project.eventCount} events ${dim(`(${r.mode}, ${r.elapsedMs}ms)`)}`);
2102
- info(`it now serves ${remote.replace(/\/+$/, '')}/t/${resolveChain(CHAIN).id}/${address.toLowerCase()}/0`);
2416
+ let r;
2417
+ try {
2418
+ r = await serviceClient(remote).registerProject(body);
2419
+ }
2420
+ catch (err) {
2421
+ throw describeRemoteError(err, remote, 'remote add');
2422
+ }
2423
+ await reportRemoteIndexing(remote, body.chainId, address, r, flags, 'indexed');
2424
+ info(`it now serves ${remote.url}/t/${body.chainId}/${address.toLowerCase()}/0`);
2425
+ // "Indexed" is not "correct". This line proves the service replayed the chain and will answer at
2426
+ // that URL — it says nothing about whether the bytes it serves match the on-chain commitment, and
2427
+ // two reviewers stopped here and reported a blank page as fixed. Name the step that checks.
2428
+ info(`confirm what it actually serves (bytes vs. the on-chain hash): ${bold(`abx verify ${address} --remote ${remote.name?.toLowerCase() ?? remote.url}`)}`);
2103
2429
  return;
2104
2430
  }
2105
2431
  allowLargeScan(flags);
@@ -2142,7 +2468,11 @@ async function cmdAdd(address, flags) {
2142
2468
  info(`scanning blocks ${start}${dim(' → ')}${head} ${dim(`(~${span} blocks)`)} — on a range-capped RPC (see ${g('abx doctor')}) this can take a few minutes with no per-block output; leave it running.`);
2143
2469
  }
2144
2470
  catch { /* advisory only — the real scan still runs */ }
2145
- const { state, elapsedMs } = await indexer.reindex(address);
2471
+ // Don't accept a zero here either: `deploy-code` finishes through this command, so this IS the
2472
+ // post-deploy index for a code project — and a real ABX clone always emits a spine (its extension
2473
+ // registrations at minimum), so 0 events means the RPC hasn't served the logs yet, not that the
2474
+ // project is empty. See reindexAfterDeploy.
2475
+ const { state, elapsedMs } = await reindexAfterDeploy(indexer, address);
2146
2476
  // Resolve durable locators for off-chain-by-hash content from this machine's index.
2147
2477
  const locators = await collectContentLocators(state, resolveBackend(storageOptions(storageOverrides(flags))));
2148
2478
  if (Object.keys(locators).length) {
@@ -2159,7 +2489,14 @@ async function cmdAdd(address, flags) {
2159
2489
  contentLocators: JSON.stringify(locators),
2160
2490
  });
2161
2491
  }
2162
- ok(`registered + indexed ${state.name ?? address} LOCALLY (this machine): ${state.eventCount} events in ${elapsedMs}ms`);
2492
+ // A on 0 events is the lie that produced an empty dashboard; reindexAfterDeploy has already
2493
+ // explained the failure and named the recovery command, so don't stamp it as success too.
2494
+ if (state.eventCount > 0) {
2495
+ ok(`registered + indexed ${state.name ?? address} LOCALLY (this machine): ${state.eventCount} events in ${elapsedMs}ms`);
2496
+ }
2497
+ else {
2498
+ warn(`registered ${state.name ?? address}, but with NO reconstructed state — it will serve empty until the index succeeds.`);
2499
+ }
2163
2500
  info(`serve it from here with ${bold('abx serve')} — or push it to a hosted resolver with ${bold('abx add ' + address + ' --remote')}`);
2164
2501
  }
2165
2502
  /** The code-project trust anchor: use the canonical factory, else deploy one (a sandbox /
@@ -2226,6 +2563,271 @@ async function ensureSeedSource(publicClient) {
2226
2563
  * Schemas: --schema key:Type:Auth[,key:Type:Auth…] (e.g. palette:HexColor:TokenOwner).
2227
2564
  * Seeds: canonical randomizer by default; --no-seed opts out.
2228
2565
  */
2566
+ // ── the demo walkthrough: teaching sections, demo-only ────────────────────────
2567
+ //
2568
+ // `abx demo` is a TEACHING command, not a shortcut — the docs point a first-time reader here to
2569
+ // learn what the toolkit does on their behalf. Its old form asserted the interesting claims
2570
+ // ("reconstructed 9 events — no provider involved") without ever showing them, which made it a
2571
+ // smoke test wearing a demo's clothes. These sections demonstrate instead: print the spine the
2572
+ // chain now holds, throw the local projection away and rebuild it, then read the token back the way
2573
+ // a marketplace would. They run only for `demo` (never `deploy`), and never pause — an agent or CI
2574
+ // run has to behave identically.
2575
+ /**
2576
+ * The deterministic part of a projection: everything that is a pure function of the chain.
2577
+ * Deliberately EXCLUDES `reconstructedAt`, `rpcUrl` and `toBlock` — a timestamp, the endpoint that
2578
+ * happened to answer, and the head at scan time all legitimately differ between two replays, so
2579
+ * folding them in would make the rebuild proof fail for reasons that aren't about correctness.
2580
+ */
2581
+ function projectionFingerprint(s) {
2582
+ const canonical = {
2583
+ address: s.address.toLowerCase(),
2584
+ name: s.name,
2585
+ symbol: s.symbol,
2586
+ owner: s.owner?.toLowerCase() ?? null,
2587
+ isCanonical: s.isCanonical,
2588
+ deployBlock: s.deployBlock,
2589
+ eventCount: s.eventCount,
2590
+ royalty: s.royalty ? { bps: s.royalty.bps, receiver: s.royalty.receiver.toLowerCase() } : null,
2591
+ extensions: s.extensions.map((e) => e.name).sort(),
2592
+ collectionFields: s.collectionFields.map((f) => `${f.field}=${f.value}`).sort(),
2593
+ tokens: s.tokens.map((t) => ({ id: t.tokenId, minted: t.minted, owner: t.owner?.toLowerCase() ?? null })),
2594
+ events: s.events.map((e) => `${e.blockNumber}:${e.logIndex}:${e.name}`),
2595
+ };
2596
+ return createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
2597
+ }
2598
+ /**
2599
+ * Print the reconstructed spine — the point being that this list IS the database. Rendered inside
2600
+ * the index step (no header of its own: "Index it — replay the event spine" immediately followed by
2601
+ * a separate "The event spine" step read as a stutter).
2602
+ */
2603
+ function walkthroughSpine(state) {
2604
+ if (state.events.length === 0) {
2605
+ warn('no events to show (the index came back empty — see the recovery hint above).');
2606
+ return;
2607
+ }
2608
+ const width = Math.max(...state.events.map((e) => e.name.length));
2609
+ state.events.forEach((e, i) => {
2610
+ // ERC vs ABX register: standard ERC-721 events a marketplace already understands, versus ABX's
2611
+ // own. Worth surfacing — it's why an ABX token indexes fine on tools that know nothing about ABX.
2612
+ const reg = e.register === 2 ? p('ABX') : dim('ERC');
2613
+ console.log(` ${dim(`#${String(i + 1).padStart(2)}`)} ${reg} ${e.name.padEnd(width)} ${dim(e.what)}`);
2614
+ });
2615
+ console.log(` ${dim('→')} those ${bold(String(state.events.length))} lines ${bold('are')} the database. ` +
2616
+ dim('There is no other copy that counts — not ours, not anyone\'s.'));
2617
+ console.log(` ${p('ABX')} ${dim('= ABX\'s own events')} ${dim('ERC')} ${dim('= bog-standard ERC-721/7572, which is why wallets and marketplaces that have never heard of ABX still show your token.')}`);
2618
+ }
2619
+ /**
2620
+ * [4] The claim, demonstrated: delete the local projection and rebuild it from the chain.
2621
+ *
2622
+ * This is the one step that can't be faked by good output — it drops the projection for real
2623
+ * (registration kept), confirms it's gone, replays from the deploy block, and compares a
2624
+ * fingerprint of everything chain-derived. If ABX's premise is wrong, this step fails loudly.
2625
+ */
2626
+ async function walkthroughRebuild(indexer, address, before) {
2627
+ step('The moment of truth · delete it all');
2628
+ const fpBefore = projectionFingerprint(before);
2629
+ indexer.dropProjection(address);
2630
+ const gone = indexer.getProject(address) === null;
2631
+ console.log(` ${dim('wiping this computer\'s copy …')} ${gone ? g('gone. nothing left locally.') : `${c.orange}⚠ still present${c.reset}`}`);
2632
+ const { state: after, elapsedMs } = await indexer.reindex(address, { full: true });
2633
+ const fpAfter = projectionFingerprint(after);
2634
+ console.log(` ${dim(`asking ${CHAIN} to tell us everything again …`)} ` +
2635
+ `${g(`${after.eventCount} events, ${elapsedMs}ms`)}`);
2636
+ if (fpBefore === fpAfter) {
2637
+ ok(bold('byte-for-byte identical.'));
2638
+ info('Your token just survived losing every local file. No backup, no API key, no company —');
2639
+ info(`the chain remembered. ${dim('That is the whole point of ABX.')}`);
2640
+ info(dim(`checked by hashing every chain-derived field, not by eyeballing it: sha256 ${fpAfter.slice(0, 12)}…`));
2641
+ }
2642
+ else {
2643
+ warn('the rebuilt state does NOT match what we just had — that is a real bug, please report it.');
2644
+ info(`before ${fpBefore.slice(0, 16)}… · after ${fpAfter.slice(0, 16)}…`);
2645
+ }
2646
+ }
2647
+ /**
2648
+ * [5] Read the token back the way a marketplace would.
2649
+ *
2650
+ * Which is genuinely a different act per lane, so it reads from the real source in each case rather
2651
+ * than always going through the local server:
2652
+ * • fully on-chain → call `tokenURI(0)` on the contract. That IS what a marketplace does, and on
2653
+ * this lane the whole answer (art included) comes back from the chain with nothing else running.
2654
+ * • off-chain custody → fetch the resolver, because that's what the baked URI points at.
2655
+ * Reading the on-chain lane over HTTP would have quietly implied the local server was load-bearing
2656
+ * when it isn't — the opposite of the lesson.
2657
+ */
2658
+ async function walkthroughReadBack(state, baseUrl, onChainUri) {
2659
+ step('Read it back the way a marketplace would');
2660
+ const token = state.tokens[0];
2661
+ if (!token?.minted) {
2662
+ info('no minted token to read yet.');
2663
+ return;
2664
+ }
2665
+ if (onChainUri) {
2666
+ info(dim(`calling tokenURI(0) on your contract — the same call OpenSea makes …`));
2667
+ try {
2668
+ const uri = (await makePublicClient({ chainKey: CHAIN }).readContract({
2669
+ address: state.address,
2670
+ abi: oneOfOneImageAbi,
2671
+ functionName: 'tokenURI',
2672
+ args: [0n],
2673
+ }));
2674
+ const json = decodeOnChainJson(uri);
2675
+ if (json) {
2676
+ const parsed = JSON.parse(json);
2677
+ const img = typeof parsed.image === 'string' ? parsed.image : '';
2678
+ console.log(` ${g('✓')} came back with: ${bold(String(parsed.name ?? '(no name)'))}`);
2679
+ console.log(` ${dim(`image: ${img.slice(0, 48)}${img.length > 48 ? '…' : ''}`)}`);
2680
+ // The punchline of the whole lane: a data: URI means the art travelled IN the answer.
2681
+ if (img.startsWith('data:'))
2682
+ info(`${g('the art itself came back in that answer')} ${dim('— no link to follow, nothing to go missing')}`);
2683
+ }
2684
+ else {
2685
+ console.log(` ${dim(uri.slice(0, 160))}${uri.length > 160 ? dim('…') : ''}`);
2686
+ }
2687
+ info(dim('nothing was running to answer that. no server of ours, no server of yours.'));
2688
+ }
2689
+ catch (e) {
2690
+ info(dim(`could not read tokenURI from the chain: ${e.message}`));
2691
+ }
2692
+ console.log(` ${dim('read it yourself any time:')} ${bold(`abx tokenuri ${state.address}`)}`);
2693
+ return;
2694
+ }
2695
+ info(`tokenURI(0) ${dim('on chain →')} ${token.tokenURI ?? dim('(none)')}`);
2696
+ // Off-chain custody: the CHAIN holds a keccak256 commitment to the image and a URI base; this NODE
2697
+ // holds the bytes. That split is the thing worth understanding, so name it rather than implying the
2698
+ // JSON came from the chain.
2699
+ info(dim('the chain stored a URI base + a keccak256 commitment; this node serves the bytes.'));
2700
+ try {
2701
+ const res = await fetch(`${baseUrl}/t/${resolveChain(CHAIN).id}/${state.address}/0`);
2702
+ const json = (await res.json());
2703
+ const shown = { name: json.name, image: json.image };
2704
+ console.log(` ${dim(JSON.stringify(shown))}`);
2705
+ if (Array.isArray(json.abx_provenance)) {
2706
+ info(dim(`every field is tagged with where it came from (abx_provenance: ${json.abx_provenance.length} entries)`));
2707
+ }
2708
+ }
2709
+ catch (e) {
2710
+ info(dim(`could not read the served metadata: ${e.message}`));
2711
+ }
2712
+ console.log(` ${dim('prove the bytes match the chain:')} ${bold(`abx verify ${state.address}`)}`);
2713
+ }
2714
+ /** Is a TCP port already bound on localhost? Used to preflight a serve BEFORE spending a tx. */
2715
+ async function portInUse(port) {
2716
+ const { createServer } = await import('node:net');
2717
+ return new Promise((resolve) => {
2718
+ const probe = createServer();
2719
+ probe.once('error', (e) => resolve(e.code === 'EADDRINUSE'));
2720
+ probe.once('listening', () => probe.close(() => resolve(false)));
2721
+ probe.listen(port);
2722
+ });
2723
+ }
2724
+ /**
2725
+ * Index a project we *just* deployed — and don't believe a zero.
2726
+ *
2727
+ * `eth_getLogs` is read-after-write inconsistent on load-balanced RPCs: `waitForTransactionReceipt`
2728
+ * resolves against a node that has the block, then the log query lands on one that doesn't yet, and
2729
+ * returns an empty set for a block we KNOW contains our deploy. The old code took that single read at
2730
+ * face value, printed `✓ reconstructed 0 events`, stored the empty projection, and served an empty
2731
+ * dashboard — a first-run that looks like the toolkit simply doesn't work. It reproduced 100% of the
2732
+ * time on `https://sepolia.base.org`, which is the DEFAULT endpoint when there's no `.env`, i.e. the
2733
+ * documented first run was the broken path. The same block returned all 9 logs seconds later.
2734
+ *
2735
+ * We have the one thing that makes this checkable: we just minted, so the spine cannot be empty.
2736
+ * So verify instead of trusting — re-scan with backoff until events appear, and if they never do,
2737
+ * say so as a FAILURE with the recovery command rather than dressing a zero up as a ✓.
2738
+ */
2739
+ async function reindexAfterDeploy(indexer, address, opts = {}) {
2740
+ const attempts = opts.attempts ?? 6;
2741
+ const delayMs = opts.delayMs ?? 1500;
2742
+ let last = await indexer.reindex(address);
2743
+ for (let i = 1; i < attempts && last.state.eventCount === 0; i++) {
2744
+ if (i === 1) {
2745
+ info(dim("no events yet — the RPC hasn't served the logs for that block; re-scanning…"));
2746
+ }
2747
+ await new Promise((r) => setTimeout(r, delayMs));
2748
+ // `full: true` — the stored projection has 0 events, so there is no valid checkpoint to
2749
+ // resume from; a full replay from the deploy block is the only correct re-scan.
2750
+ last = await indexer.reindex(address, { full: true });
2751
+ }
2752
+ if (last.state.eventCount === 0) {
2753
+ warn(`the RPC still reports no logs for this project after ${attempts} tries — the deploy DID succeed ` +
2754
+ `(it's on chain), but this node can't reconstruct it yet.`);
2755
+ console.log(` ${dim('recover with')} ${bold(`abx index ${address} --full`)} ${dim('in a minute, or point ABX_RPC_URLS at a better endpoint (`abx doctor` ranks them).')}`);
2756
+ }
2757
+ return last;
2758
+ }
2759
+ /**
2760
+ * `abx preview` — serve the program on localhost, live, for as long as the work is being made.
2761
+ *
2762
+ * The studio lane, and deliberately the FIRST thing to reach for on a code project: it renders the
2763
+ * same document the generator serves (real `abx.js`, real tokenData shape, real dependency tags)
2764
+ * with a synthetic seed, so a creator can refresh for new seeds, drive their PostParams from real
2765
+ * inputs, and watch an animated piece actually move — none of which a still-image sweep can show.
2766
+ * No chain, no key, no deploy. `--shoot` renders the same document headlessly for an agent that
2767
+ * can't open a browser.
2768
+ */
2769
+ async function cmdPreview(flags) {
2770
+ warnStrayFlags(flags, PREVIEW_FLAGS, 'preview');
2771
+ const cfg = previewConfigFromFlags(flags);
2772
+ const shootDir = flags.shoot && flags.shoot !== 'true' ? String(flags.shoot) : flags.shoot === 'true' ? 'abx-preview' : undefined;
2773
+ const count = Math.min(Math.max(Number(flags.count ?? 9) || 9, 1), 64);
2774
+ console.log(bold(`\n ABX Self-Host Toolkit — preview\n ${dim('the program, running locally — no chain, no deploy')}`));
2775
+ const { notes } = previewDepTags(cfg.deps);
2776
+ step('Program');
2777
+ info(cfg.source.kind === 'dir' ? `directory build ${cfg.source.path}/ (its own index.html + abx.js)` : `script ${cfg.source.path} ${dim('(re-read from disk on every render)')}`);
2778
+ if (cfg.schemas.length)
2779
+ info(`params: ${cfg.schemas.map(describeSchema).join(' · ')}`);
2780
+ else
2781
+ info(dim('params: none declared — add --schema key:Type:Auth to drive them from the studio'));
2782
+ for (const n of notes)
2783
+ info(`dep ${n}`);
2784
+ // A raw on-chain dependency can't be fetched without a chain, so the preview would render a
2785
+ // sketch missing its runtime and look broken for the wrong reason. Say so rather than let them
2786
+ // debug their own art.
2787
+ if (cfg.deps.some((d) => d.display.startsWith('0x'))) {
2788
+ warn('an on-chain data-contract dep is NOT loaded in preview — the sketch will run without it here. Use a name@version ref to preview against the CDN copy.');
2789
+ }
2790
+ const server = await startPreviewServer(cfg, shootDir ? 0 : Number(flags.port ?? DEFAULT_PREVIEW_PORT));
2791
+ if (shootDir) {
2792
+ step(`Render ${count} seeds headlessly`);
2793
+ try {
2794
+ const shots = await shootPreview(server.url, shootDir, count, {
2795
+ width: Number(flags.width ?? 1000) || 1000,
2796
+ timeoutMs: Number(flags['timeout-ms'] ?? 10_000) || 10_000,
2797
+ });
2798
+ ok(`${shots.length} frames → ${shootDir}/ ${dim('(traits in traits.json)')}`);
2799
+ for (const s of shots) {
2800
+ const t = s.traits && Object.keys(s.traits).length
2801
+ ? Object.entries(s.traits).map(([k, v]) => `${k} ${String(v)}`).join(' · ')
2802
+ : `${c.orange}no traits reported${c.reset}`;
2803
+ console.log(` ${dim(s.seed.slice(0, 10) + '…')} ${t}${s.done ? '' : dim(' (no abx.done())')}`);
2804
+ }
2805
+ const silent = shots.filter((s) => !s.traits || !Object.keys(s.traits).length).length;
2806
+ if (silent === shots.length) {
2807
+ warn('NO frame reported traits — `abx.traits({…})` is the only thing that becomes marketplace `attributes`. Verify with `abx inspect`.');
2808
+ }
2809
+ else if (shots.length > 1 && new Set(shots.map((s) => JSON.stringify(s.traits))).size === 1) {
2810
+ // Only meaningful when traits DID come back: identical values across seeds is the signature
2811
+ // of a sketch that never reads `abx.tokenData.seed` (prototyped on Math.random()), which
2812
+ // deploys as N visually identical tokens. Skipped when nothing reported at all — the
2813
+ // warning above already covers that, and firing both reads as noise.
2814
+ warn('every seed produced identical traits — check the sketch actually reads `abx.tokenData.seed` (the silent "all tokens the same" failure).');
2815
+ }
2816
+ }
2817
+ finally {
2818
+ await server.close();
2819
+ }
2820
+ return;
2821
+ }
2822
+ step('Studio');
2823
+ console.log(`\n ${g('●')} ${bold('preview')} ${server.url}`);
2824
+ console.log(` ${dim('studio ')}${server.url} ${dim('seed + params + live traits')}`);
2825
+ console.log(` ${dim('grid ')}${server.url}/grid ${dim('9 seeds at once, all live')}`);
2826
+ console.log(` ${dim('bare view ')}${server.url}/view ${dim('the generator document itself')}`);
2827
+ console.log(`\n ${dim('Edit the program and refresh — it is re-read from disk. Ctrl-C to stop.')}`);
2828
+ console.log(` ${dim('This is a preview: `abx inspect` is still the wiring check, and a testnet deploy is the faithful end-to-end.')}\n`);
2829
+ await new Promise(() => { }); // block like `serve` — the creator drives it
2830
+ }
2229
2831
  /**
2230
2832
  * `abx inspect <script.js>` — read a generative script and, WITHOUT executing it, report what it
2231
2833
  * needs (traits + their on-chain reproducibility, dependency hints, size → assembled-document size →
@@ -2406,17 +3008,7 @@ async function cmdDeployCode(flags) {
2406
3008
  const dryRun = !!flags['dry-run'];
2407
3009
  const name = flags.name ?? 'ABX Code';
2408
3010
  const symbol = flags.symbol ?? 'ABXC';
2409
- // Identity guard (mirror deploy/deploy-series): never write demo placeholders as the public
2410
- // on-chain identity. dry-run only warns so a preview still runs without --name/--symbol.
2411
- if (!flags.name || !flags.symbol) {
2412
- if (!flags.name)
2413
- warn(`no --name → default "${name}" would be the on-chain collection name`);
2414
- if (!flags.symbol)
2415
- warn(`no --symbol → default "${symbol}" would be the on-chain symbol`);
2416
- if (!dryRun && !flags.yes) {
2417
- throw new Error('refusing to write demo placeholders as your public on-chain identity — pass --name "Your Title" --symbol SYM (or --yes).');
2418
- }
2419
- }
3011
+ assertRealIdentity(flags, { name, symbol, dryRun });
2420
3012
  const maxProvided = flags.max !== undefined;
2421
3013
  const max = Number(flags.max ?? 16);
2422
3014
  if (!Number.isInteger(max) || max <= 0)
@@ -3257,7 +3849,7 @@ async function loadEffects() {
3257
3849
  */
3258
3850
  async function cmdRender(address, tokenIds, flags) {
3259
3851
  if (!address || address.startsWith('--')) {
3260
- console.error('usage: abx render <address> [tokenId…] [--force] [--remote [url]] (missing stills/traits; --force re-renders an existing one — abx render --help)\n');
3852
+ console.error('usage: abx render <address> [tokenId…] [--force] [--remote [name|url]] (missing stills/traits; --force re-renders an existing one — abx render --help)\n');
3261
3853
  process.exit(1);
3262
3854
  }
3263
3855
  // Token ids are decimal (mint order). Keep only those — `parseFlags` leaves `--flag value` pairs
@@ -3294,12 +3886,12 @@ async function cmdRender(address, tokenIds, flags) {
3294
3886
  ok(line);
3295
3887
  return;
3296
3888
  }
3297
- // --remote publishes each render to the hosted resolver's control plane (the locator bridge) so a
3889
+ // --remote publishes each render to the remote resolver's control plane (the locator bridge) so a
3298
3890
  // laptop render lands on a resolver that doesn't share this disk; republish=true makes a re-run
3299
3891
  // restore a resolver that lost its volume without re-rendering. Local (no --remote): shared backend.
3300
- const remote = remoteTarget(flags);
3301
- const resolverUrl = (remote ?? process.env.ABX_RESOLVER_URL ?? resolveBaseUrl()).replace(/\/$/, '');
3302
- const adminToken = remote ? requireAdminToken() : undefined;
3892
+ const remote = remoteFlag(flags);
3893
+ const resolverUrl = (remote?.url ?? process.env.ABX_RESOLVER_URL ?? resolveBaseUrl()).replace(/\/$/, '');
3894
+ const adminToken = remote ? requireRemoteToken(remote) : undefined;
3303
3895
  // Co-located (no --remote): record each declared output into the shared store's artifact
3304
3896
  // registry so the local resolver's `artifacts` manifest enumerates it. Remote: the publish
3305
3897
  // lane (adminToken) records rows on the hosted resolver instead.
@@ -3334,8 +3926,11 @@ async function cmdRender(address, tokenIds, flags) {
3334
3926
  * single time and exits (vs `abx render <addr>` which is the per-project repair lane).
3335
3927
  */
3336
3928
  async function cmdEffects(flags) {
3337
- const resolverUrl = (remoteTarget(flags) ?? process.env.ABX_RESOLVER_URL ?? resolveBaseUrl()).replace(/\/$/, '');
3338
- const adminToken = process.env.ABX_RESOLVER_ADMIN_TOKEN;
3929
+ const effectsRemote = remoteFlag(flags);
3930
+ const resolverUrl = (effectsRemote?.url ?? process.env.ABX_RESOLVER_URL ?? resolveBaseUrl()).replace(/\/$/, '');
3931
+ // Token optional BY DESIGN: no token = the co-located topology (shared store, no publish lane).
3932
+ // A named remote brings its own token; otherwise the self-host env token.
3933
+ const adminToken = effectsRemote?.token ?? process.env.ABX_RESOLVER_ADMIN_TOKEN;
3339
3934
  // Co-located (no admin token — we share the resolver's store): record each declared output into
3340
3935
  // the shared artifact registry so the resolver's `artifacts` manifest enumerates it. With an
3341
3936
  // admin token, the publish lane records rows on the hosted resolver instead.
@@ -3375,16 +3970,23 @@ async function cmdEffects(flags) {
3375
3970
  await new Promise(() => { }); // block like `serve`
3376
3971
  }
3377
3972
  async function cmdIndex(address, flags) {
3378
- const remote = remoteTarget(flags);
3973
+ const remote = remoteFlag(flags);
3379
3974
  if (remote) {
3380
3975
  if (!address || address.startsWith('--')) {
3381
- console.error('usage: abx index <address> --remote [url] (re-index one project on a hosted resolver)\n');
3976
+ console.error('usage: abx index <address> --remote [name|url] (re-index one project on a remote resolver)\n');
3382
3977
  process.exit(1);
3383
3978
  }
3384
- const token = requireAdminToken();
3385
- info(`${bold('REMOTE')} → ${remote} ${dim('(re-indexing on the hosted resolver — the post-deploy nudge)')}`);
3386
- const r = await remoteAddProject(remote, token, { address, full: flags.full ? true : undefined });
3387
- ok(`remote resolver re-indexed ${r.project.name ?? address}: ${r.project.eventCount} events ${dim(`(${r.mode}, ${r.elapsedMs}ms)`)}`);
3979
+ requireRemoteToken(remote);
3980
+ info(`${bold('REMOTE')} → ${remote.url} ${dim('(re-indexing on the remote resolver — the post-deploy nudge)')}`);
3981
+ const chainId = resolveChain(CHAIN).id;
3982
+ let r;
3983
+ try {
3984
+ r = await serviceClient(remote).registerProject({ chainId, address, full: flags.full ? true : undefined });
3985
+ }
3986
+ catch (err) {
3987
+ throw describeRemoteError(err, remote, 'remote index');
3988
+ }
3989
+ await reportRemoteIndexing(remote, chainId, address, r, flags, 're-indexed');
3388
3990
  return;
3389
3991
  }
3390
3992
  allowLargeScan(flags);
@@ -3414,7 +4016,7 @@ async function cmdVerify(address, flags) {
3414
4016
  process.exit(1);
3415
4017
  }
3416
4018
  allowLargeScan(flags);
3417
- const remote = remoteTarget(flags);
4019
+ const remote = remoteFlag(flags);
3418
4020
  if (remote)
3419
4021
  return cmdVerifyRemote(address, remote);
3420
4022
  const indexer = new SelfHostIndexer();
@@ -3428,7 +4030,11 @@ async function cmdVerify(address, flags) {
3428
4030
  if (!state)
3429
4031
  throw new Error(`could not load state for ${address}`);
3430
4032
  console.log(bold(`\n verify ${state.name ?? address}`));
3431
- info(`canonical: ${state.isCanonical ? g('yes (factory-verified)') : 'unverified'} · owner ${state.owner ?? '—'}`);
4033
+ // isCanonical is a TRUE TRI-STATE (true | false | null) and collapsing it lost the only
4034
+ // distinction that matters: "the chain says this is NOT a clone of the configured factory" is a
4035
+ // trust finding; "we couldn't run the check" (no factory configured, or none deployed on this
4036
+ // chain) is an environment note. Two reviewers read the collapsed word as a second failure.
4037
+ info(`canonical: ${canonicalLabel(state.isCanonical)} · owner ${state.owner ?? '—'}`);
3432
4038
  const storage = resolveBackend(storageOptions());
3433
4039
  const result = await verifyProject(state, storage);
3434
4040
  const tokens = result.tokens ?? [];
@@ -3558,18 +4164,33 @@ async function cmdVerify(address, flags) {
3558
4164
  console.log(`\n ${dim('live view animates regardless; the placeholder only affects the static marketplace thumbnail.')}\n`);
3559
4165
  else
3560
4166
  console.log(anyCheck && allGood ? `\n ${g('✓ verified')} — the node serves exactly what the chain commits to.\n` : '\n');
4167
+ // A byte-vs-chain mismatch is an integrity FAILURE, so fail the command. Verify's whole job is to
4168
+ // answer "is what's served what the chain vouches for" — exiting 0 while printing ✗ meant nothing
4169
+ // could gate on it (a script or CI would sail past a corrupted image). Deliberately narrow: a
4170
+ // missing render / placeholder is a normal, expected state and still exits 0.
4171
+ if (anyCheck && !allGood)
4172
+ process.exitCode = 1;
3561
4173
  }
3562
4174
  // `abx verify <addr> --remote <url>` — verify what a HOSTED resolver actually serves (the local
3563
4175
  // `verify` checks THIS machine's store/backend, the wrong store for a hosted drop). Probes the real
3564
4176
  // `/…/image` route, so it accounts for the locator bridge (a 302 to ipfs/ar) exactly as a marketplace
3565
4177
  // sees it — the truthful "did the thumbnail land?" check after a remote render.
3566
- async function cmdVerifyRemote(address, resolver) {
3567
- const base = resolver.replace(/\/$/, '');
4178
+ async function cmdVerifyRemote(address, remote) {
4179
+ const base = remote.url.replace(/\/$/, '');
3568
4180
  const chainId = resolveChain(CHAIN).id;
3569
4181
  console.log(bold(`\n verify ${address} ${dim(`(remote → ${base})`)}`));
3570
- const stateRes = await fetch(`${base}/api/project/${address}`);
4182
+ // A DOWN endpoint and a wrong-address endpoint are different problems with different fixes, and a
4183
+ // bare `fetch failed` says neither. `abx status --remote` already gets this right — match it, or the
4184
+ // two commands disagree about the same condition (a reviewer's top misdiagnosis risk).
4185
+ let stateRes;
4186
+ try {
4187
+ stateRes = await fetch(`${base}/api/project/${address}`);
4188
+ }
4189
+ catch {
4190
+ throw new Error(`verify: nothing responded at ${base}${remote.source === 'named' ? ` (from ABX_REMOTE_${remote.name}_URL)` : ''}. Is it running, and is that the right address?`);
4191
+ }
3571
4192
  if (!stateRes.ok) {
3572
- throw new Error(`resolver ${base} doesn't serve ${address} (HTTP ${stateRes.status}) — register it first: abx add ${address} --remote ${base}`);
4193
+ throw new Error(`resolver ${base} doesn't serve ${address} (HTTP ${stateRes.status}) — register it first: abx add ${address} --remote ${remote.name?.toLowerCase() ?? base}`);
3573
4194
  }
3574
4195
  const state = (await stateRes.json());
3575
4196
  info(`serving as "${state.name ?? address}"`);
@@ -3615,10 +4236,24 @@ async function cmdVerifyRemote(address, resolver) {
3615
4236
  console.log(` ${c.orange}⚠${c.reset} ${label}: stale — no render at the current state yet (the runner's next notify/sweep picks it up, or \`abx render ${address} --remote ${base}\`)`);
3616
4237
  }
3617
4238
  const { upToDate, stale, rendering, failed } = report.counts;
3618
- const summary = `${upToDate}/${minted.length} up to date${rendering ? ` · ${rendering} rendering` : ''}${stale ? ` · ${stale} stale` : ''}${failed ? ` · ${failed} FAILED` : ''}`;
3619
- console.log(failed || stale
3620
- ? `\n ${c.orange}⚠${c.reset} ${summary} ${dim('— live view animates regardless; only the static thumbnail is affected.')}\n`
3621
- : `\n ${g(`✓ ${summary}`)}served straight from the resolver.\n`);
4239
+ // RENDERS ONLY say so. This report answers "is there a current render for each token", never
4240
+ // "do the served bytes match the on-chain commitment"; those are different questions and this
4241
+ // command promises the second one too. A project with no renders at all (a 1/1, an image Series)
4242
+ // has nothing to be "up to date" ABOUT, so don't print a 0/N fraction but don't let a green ✓
4243
+ // here read as "the image is verified" either. Byte integrity comes from the check below.
4244
+ if (report.tokens.length === 0) {
4245
+ info(`renders ${dim('none for this project (a static image needs no off-chain render)')}`);
4246
+ }
4247
+ else {
4248
+ // Lead with the count that carries the polarity: "N of M current" never inverts on a skim the
4249
+ // way "0/M up to date" does.
4250
+ const summary = `${upToDate} of ${minted.length} token(s) current${rendering ? ` · ${rendering} rendering` : ''}${stale ? ` · ${stale} stale` : ''}${failed ? ` · ${failed} FAILED` : ''}`;
4251
+ if (failed || stale)
4252
+ console.log(` ${c.orange}⚠${c.reset} renders: ${summary} ${dim('— live view animates regardless; only the static thumbnail is affected.')}`);
4253
+ else
4254
+ ok(`renders: ${summary}`);
4255
+ }
4256
+ await reportRemoteByteIntegrity(address, remote, base);
3622
4257
  return;
3623
4258
  }
3624
4259
  let gap = false;
@@ -3636,8 +4271,63 @@ async function cmdVerifyRemote(address, resolver) {
3636
4271
  }
3637
4272
  }
3638
4273
  console.log(gap
3639
- ? `\n ${dim('live view animates regardless; the placeholder only affects the static marketplace thumbnail.')}\n`
3640
- : `\n ${g('✓ thumbnails are real renders')} — served straight from the resolver.\n`);
4274
+ ? ` ${dim('live view animates regardless; the placeholder only affects the static marketplace thumbnail.')}`
4275
+ : ` ${g('✓ thumbnails are real renders')} ${dim('— served straight from the resolver.')}`);
4276
+ await reportRemoteByteIntegrity(address, remote, base);
4277
+ }
4278
+ /**
4279
+ * The half `abx verify --remote` was missing: do the served BYTES still hash to the on-chain
4280
+ * commitment? Everything above it checks renders (is a thumbnail current, is it a placeholder) — a
4281
+ * different question, and a green ✓ there was reading as "the image is correct" when the bytes could
4282
+ * genuinely mismatch.
4283
+ *
4284
+ * The service is the right place to answer it: it holds both the bytes and the chain, and it already
4285
+ * exposes exactly this check (`GET /api/project/:addr/verify`, bearer-gated because it triggers
4286
+ * outbound fetches). When we can't reach that — no credential, or an older node — say plainly that
4287
+ * byte integrity was NOT checked rather than leaving the ✓ above to imply it was.
4288
+ */
4289
+ async function reportRemoteByteIntegrity(address, remote, base) {
4290
+ if (!remote.token) {
4291
+ warn(`byte integrity NOT checked — that check is credentialed on the service. Set ${remote.tokenVar} (or pass --remote-token) and re-run, or run ${bold(`abx verify ${address}`)} against a node that holds the bytes.`);
4292
+ return;
4293
+ }
4294
+ let report;
4295
+ try {
4296
+ const res = await fetch(`${base}/api/project/${address}/verify`, { headers: { authorization: `Bearer ${remote.token}` } });
4297
+ if (res.status === 401 || res.status === 403) {
4298
+ warn(`byte integrity NOT checked — ${base} rejected ${tokenSourceLabel(remote)} for its verify route (the read plane served fine, so this is a credential/scoping issue, not a broken project).`);
4299
+ return;
4300
+ }
4301
+ if (!res.ok) {
4302
+ // Spec'd as part of `abx-token-api/v1`, so a conforming service has it — but say it neutrally:
4303
+ // this is equally "an older self-hosted node" and "a provider that didn't implement it".
4304
+ warn(`byte integrity NOT checked — ${base} serves no /api/project/…/verify route (HTTP ${res.status}). ` +
4305
+ `Your own node? Redeploy it (\`abx deploy-resolver\`). A provider's? It's part of abx-token-api/v1 — ask them for it. ` +
4306
+ `Meanwhile ${bold(`abx verify ${address}`)} checks the bytes on a node that holds them.`);
4307
+ return;
4308
+ }
4309
+ report = (await res.json());
4310
+ }
4311
+ catch {
4312
+ warn(`byte integrity NOT checked — couldn't reach ${base}'s verify route.`);
4313
+ return;
4314
+ }
4315
+ const checked = (report.tokens ?? []).filter((t) => t.checks.length > 0);
4316
+ if (checked.length === 0) {
4317
+ info(`bytes ${dim('no on-chain byte commitment to check (this project commits no image hash)')}`);
4318
+ return;
4319
+ }
4320
+ const bad = checked.filter((t) => t.checks.some((k) => !k.verified));
4321
+ if (bad.length === 0) {
4322
+ ok(`bytes: ${checked.length} token(s) hash-match their on-chain commitment ${dim('(what the service serves IS what the chain vouches for)')}`);
4323
+ return;
4324
+ }
4325
+ const spec = remote.name?.toLowerCase() ?? base;
4326
+ console.log(` ${c.red}✗${c.reset} ${bold('BYTE MISMATCH')} on token(s) ${bad.map((t) => `#${t.tokenId}`).join(', ')} — ${base} does NOT serve bytes that hash to the on-chain commitment ` +
4327
+ `${dim('(this is what a marketplace shows as a blank or placeholder image)')}. The chain is the truth, so the served copy is the wrong one. Two causes, two fixes:`);
4328
+ info(`durable bytes exist (ipfs://, ar://) but weren't bridged → ${bold(`abx add ${address} --remote ${spec}`)} forwards the locator, then re-run this.`);
4329
+ info(`the bytes only exist on THIS machine (local fs custody) → a hosted resolver can never serve them: ${bold('abx storage upload')} to a durable backend + re-point the field, or serve the project from a node that holds them.`);
4330
+ process.exitCode = 1; // same rule as the local lane: an integrity mismatch fails the command
3641
4331
  }
3642
4332
  // ── tokenuri ─────────────────────────────────────────────────────────────────
3643
4333
  // Read tokenURI(id) STRAIGHT FROM THE CONTRACT on-chain (no server, no node) and decode
@@ -3688,7 +4378,12 @@ async function cmdTokenUri(address, flags) {
3688
4378
  catch {
3689
4379
  const code = await publicClient.getCode({ address }).catch(() => undefined);
3690
4380
  if (!code || code === '0x') {
3691
- 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`);
4381
+ // Name the endpoint we actually asked. "No contract here" is indistinguishable from "you're
4382
+ // pointed at the wrong node", and a chain KEY doesn't disambiguate that — two endpoints can
4383
+ // both claim `sepolia` (a fork, a stale duplicate .env line) and only one has your contract.
4384
+ console.error(`abx tokenuri: no contract at ${address} on ${CHAIN} (asked ${redactRpcUrl(resolveRpcUrl(CHAIN))}) — ` +
4385
+ `double-check the address, and that this endpoint is the network you deployed to. ` +
4386
+ `If you JUST deployed, give the tx a block or two to mine.\n`);
3692
4387
  }
3693
4388
  else {
3694
4389
  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`);
@@ -3705,6 +4400,101 @@ async function cmdTokenUri(address, flags) {
3705
4400
  console.log(` ${uri.slice(0, 240)}${uri.length > 240 ? dim(`… (${uri.length} chars)`) : ''}\n`);
3706
4401
  }
3707
4402
  }
4403
+ // ── contracturi ──────────────────────────────────────────────────────────────
4404
+ /**
4405
+ * `abx contracturi <address>` — the collection-level counterpart of `tokenuri`: read
4406
+ * `contractURI()` (ERC-7572) STRAIGHT FROM THE CONTRACT, then FOLLOW it and decode the JSON.
4407
+ *
4408
+ * Why this exists, and why it follows the URL: the resolver's route grammar is committed
4409
+ * on-chain at deploy (`contractURIBase` = `<baseUrl>/c`), so the contract — not a doc, not a
4410
+ * service descriptor — is the source of truth for where a collection's metadata lives. Without
4411
+ * this command the only way to look was to hand-build the URL from memory of the grammar, and a
4412
+ * guessed path that 404s reads exactly like a broken service. Ask the chain instead.
4413
+ */
4414
+ async function cmdContractUri(address, _flags) {
4415
+ if (!address || address.startsWith('--')) {
4416
+ console.error('usage: abx contracturi <address>\n');
4417
+ process.exit(1);
4418
+ }
4419
+ if (!/^0x[0-9a-fA-F]{40}$/.test(address)) {
4420
+ console.error(`abx contracturi: '${address}' isn't a 0x contract address.\n`);
4421
+ process.exit(1);
4422
+ }
4423
+ const publicClient = makePublicClient({ chainKey: CHAIN });
4424
+ let uri;
4425
+ try {
4426
+ uri = (await publicClient.readContract({
4427
+ address,
4428
+ abi: oneOfOneImageAbi,
4429
+ functionName: 'contractURI',
4430
+ args: [],
4431
+ }));
4432
+ }
4433
+ catch {
4434
+ const code = await publicClient.getCode({ address }).catch(() => undefined);
4435
+ if (!code || code === '0x') {
4436
+ // Name the endpoint we actually asked — "no contract here" and "you're pointed at the wrong
4437
+ // node" are indistinguishable otherwise (same reasoning as `tokenuri`).
4438
+ console.error(`abx contracturi: no contract at ${address} on ${CHAIN} (asked ${redactRpcUrl(resolveRpcUrl(CHAIN))}) — ` +
4439
+ `double-check the address, and that this endpoint is the network you deployed to. ` +
4440
+ `If you JUST deployed, give the tx a block or two to mine.\n`);
4441
+ }
4442
+ else {
4443
+ console.error(`abx contracturi: ${address} didn't return a contractURI — it may not be an ABX/ERC-7572 contract, or ` +
4444
+ `— on a large on-chain contractURI — an unauthenticated RPC read hit its gas cap (try a wallet-connected / high-gas RPC).\n`);
4445
+ }
4446
+ process.exit(1);
4447
+ }
4448
+ console.log(`\n ${bold('contractURI()')} ${dim(`— read directly from ${address} on ${CHAIN}`)}`);
4449
+ if (!uri) {
4450
+ console.error(`\n ${bold('empty')} — this contract has no contractURI set: no collection-level metadata to resolve. ` +
4451
+ `Set one with ${bold(`abx set-contract-uri ${address} --uri <base>`)}, or point it at the canonical renderer for the on-chain lane.\n`);
4452
+ process.exit(1);
4453
+ }
4454
+ const onChain = decodeOnChainJson(uri);
4455
+ if (onChain) {
4456
+ info('resolution: ON-CHAIN (data: URI from the renderer — no server in the path)');
4457
+ console.log(onChain.split('\n').map((l) => ' ' + l).join('\n') + '\n');
4458
+ return;
4459
+ }
4460
+ console.log(` ${dim('resolves to')} ${uri}`);
4461
+ if (!/^https?:\/\//i.test(uri)) {
4462
+ // ipfs:// / ar:// — a locator, not something we can fetch without choosing a gateway. Print it
4463
+ // rather than silently picking one; the creator's gateway choice is theirs.
4464
+ info(`not an http(s) URL — a ${uri.split(':')[0]}: locator needs a gateway to fetch. Nothing more to read from here.`);
4465
+ console.log('');
4466
+ return;
4467
+ }
4468
+ let body;
4469
+ try {
4470
+ const res = await fetch(uri, { headers: { accept: 'application/json' } });
4471
+ body = await res.text();
4472
+ if (!res.ok) {
4473
+ // The URL came FROM THE CHAIN, so a bad status here is genuinely about the service (or the
4474
+ // contract pointing somewhere stale) — never a mistyped path. Say which, so nobody re-guesses.
4475
+ console.error(`\n ${bold(`HTTP ${res.status}`)} from the contract's own contractURI — the URL is correct by construction (it came from ` +
4476
+ `${address} on-chain), so this is the SERVICE, not the path. Likely: the project isn't registered on that resolver ` +
4477
+ `(${bold('abx add ' + address + ' --remote')}), the node serves a different chain, or it's down. ` +
4478
+ `Response: ${body.slice(0, 200)}\n`);
4479
+ process.exit(1);
4480
+ }
4481
+ }
4482
+ catch (e) {
4483
+ console.error(`\n couldn't reach ${uri} — ${e.message}. The URL is what the contract commits to, so check that the ` +
4484
+ `host is up and publicly reachable (a localhost base URL resolves for no one but this machine).\n`);
4485
+ process.exit(1);
4486
+ }
4487
+ info('resolution: OFF-CHAIN (fetched from the URL the contract commits to)');
4488
+ try {
4489
+ console.log(JSON.stringify(JSON.parse(body), null, 2)
4490
+ .split('\n')
4491
+ .map((l) => ' ' + l)
4492
+ .join('\n') + '\n');
4493
+ }
4494
+ catch {
4495
+ console.log(` ${dim('(not JSON)')} ${body.slice(0, 400)}\n`);
4496
+ }
4497
+ }
3708
4498
  // ── serve ──────────────────────────────────────────────────────────────────--
3709
4499
  async function cmdServe(flags) {
3710
4500
  const port = Number(flags.port ?? process.env.ABX_PORT ?? DEFAULT_PORT);
@@ -4007,9 +4797,53 @@ async function cmdState(address, flags) {
4007
4797
  info(`renderer ${renderer && renderer !== zeroAddress ? `on-chain (${renderer})` : dim('off-chain (stored URI base / override)')}`);
4008
4798
  console.log('');
4009
4799
  }
4010
- async function cmdStatus() {
4800
+ /**
4801
+ * `abx status [address] [--remote [name|url]] [--watch]` — INDEXING status: where a project sits in
4802
+ * the lifecycle (`queued | backfilling | live | stale | failed`) and how far behind head it is.
4803
+ *
4804
+ * One vocabulary for both sides of the membrane, which is the point: bare = this node, `--remote` =
4805
+ * ask the service, and the same five words either way. Distinct from `abx state <address>`, which
4806
+ * reads the CHAIN (owner, royalty, locks) and knows nothing about who is serving it.
4807
+ */
4808
+ async function cmdStatus(address, flags) {
4809
+ // `abx status --remote <name>` has no address — without this guard the flag itself lands in
4810
+ // rest[0] and gets sent as the address path segment (the same guard every other command applies).
4811
+ if (address?.startsWith('--'))
4812
+ address = undefined;
4813
+ const remote = remoteFlag(flags);
4814
+ if (remote)
4815
+ return cmdStatusRemote(address, remote, flags);
4011
4816
  const indexer = new SelfHostIndexer();
4012
4817
  const regs = indexer.store.listRegistrations();
4818
+ if (address) {
4819
+ const reg = indexer.store.getRegistration(address);
4820
+ if (!reg)
4821
+ throw new Error(`${address} isn't tracked by this node. Add it with \`abx add ${address}\`, or ask a service: \`abx status ${address} --remote <name>\``);
4822
+ const s = indexer.getProject(address);
4823
+ const row = indexer.indexStatus(address);
4824
+ const head = indexer.store.getMeta(`watch:${reg.chainKey}:head`);
4825
+ // Don't print the address twice when there's no name to lead with.
4826
+ console.log(s?.name ? `\n ${bold(s.name)} ${dim(address)}` : `\n ${bold(address)}`);
4827
+ statusRow('status', statusLine({
4828
+ chainId: resolveChain(reg.chainKey).id,
4829
+ address,
4830
+ status: row.status,
4831
+ fromBlock: reg.fromBlock,
4832
+ toBlock: s?.toBlock ?? null,
4833
+ headBlock: head,
4834
+ eventCount: s?.eventCount ?? 0,
4835
+ tokenCount: s?.tokens.length ?? 0,
4836
+ mintedCount: 0,
4837
+ reconstructedAt: s?.reconstructedAt ?? null,
4838
+ ...(row.errorClass ? { error: { class: row.errorClass, message: row.errorMessage ?? undefined } } : {}),
4839
+ }));
4840
+ statusRow('floor', `${reg.fromBlock}${row.attempts ? dim(` attempts ${row.attempts}`) : ''}`);
4841
+ statusRow('indexed', s ? `${s.eventCount} events · ${s.tokens.length} token(s) ${dim(`· ${s.reconstructedAt}`)}` : dim('no projection yet'));
4842
+ if (!head)
4843
+ statusRow('head', dim("unknown — this node isn't watching the chain (ABX_WATCH_INTERVAL_MS=0, or `abx serve` isn't running)"));
4844
+ console.log('');
4845
+ return;
4846
+ }
4013
4847
  console.log(bold(`\n ABX self-host node`));
4014
4848
  info(`chain: ${CHAIN} · factory: ${factoryAddress() ?? 'none'} · storage: ${activeBackendId()} · data: ${indexer.store.path}`);
4015
4849
  if (regs.length === 0) {
@@ -4019,27 +4853,114 @@ async function cmdStatus() {
4019
4853
  console.log('');
4020
4854
  for (const reg of regs) {
4021
4855
  const s = indexer.getProject(reg.address);
4022
- const line = s
4023
- ? `${g('●')} ${s.name ?? s.address} ${dim(s.address)} ${s.eventCount} events`
4024
- : `${dim('○')} ${reg.address} ${dim('(registered, not indexed)')}`;
4025
- console.log(' ' + line);
4856
+ const st = indexer.indexStatus(reg.address).status;
4857
+ const mark = st === 'failed' || st === 'stale' ? `${c.orange}●${c.reset}` : s ? g('●') : dim('○');
4858
+ const tail = s ? `${s.eventCount} events` : dim('(registered, not indexed)');
4859
+ console.log(` ${mark} ${s?.name ?? reg.address} ${dim(reg.address)} ${tail} ${statusLabel(st)}`);
4026
4860
  }
4027
- console.log('');
4861
+ console.log(dim(`\n one project in detail: abx status <address>\n`));
4862
+ }
4863
+ /** The `--remote` half of {@link cmdStatus}: one project, or the roll-up for every project the token
4864
+ * can see. `--watch` tails until everything reaches a terminal state. */
4865
+ async function cmdStatusRemote(address, remote, flags) {
4866
+ requireRemoteToken(remote);
4867
+ const client = serviceClient(remote);
4868
+ const chainId = resolveChain(CHAIN).id;
4869
+ const watch = flags.watch !== undefined;
4870
+ const spec = remote.name ? remote.name.toLowerCase() : remote.source === 'default' ? '' : remote.url;
4871
+ info(`${bold('REMOTE')} → ${remote.url} ${dim('(indexing status as the service reports it)')}`);
4872
+ if (address) {
4873
+ for (;;) {
4874
+ let s;
4875
+ try {
4876
+ s = await client.projectStatus(chainId, address);
4877
+ }
4878
+ catch (err) {
4879
+ throw describeRemoteError(err, remote, 'remote status');
4880
+ }
4881
+ console.log(`\n ${bold(address)}`);
4882
+ statusRow('status', statusLine(s));
4883
+ statusRow('floor', `${s.fromBlock}${s.attempts ? dim(` attempts ${s.attempts}`) : ''}`);
4884
+ statusRow('indexed', `${s.eventCount} events · ${s.tokenCount} token(s)${s.reconstructedAt ? dim(` · ${s.reconstructedAt}`) : ''}`);
4885
+ // Spell out what `watching` MEANS on the line itself — a bare `no` sent a reviewer to a
4886
+ // different command's output to find out whether it was a problem (it usually isn't).
4887
+ if (s.watcher) {
4888
+ statusRow('watching', s.watcher.watching
4889
+ ? `${g('yes')}${s.watcher.head ? dim(` · head ${s.watcher.head}`) : ''}${dim(' — it tails new blocks, so on-chain changes land on their own')}`
4890
+ : `${dim('no')} ${dim('— this service updates on an explicit add/index, not by tailing new blocks (normal for many providers)')}`);
4891
+ }
4892
+ // Don't leave a creator staring at a red word with no next move. `failed` especially reads as
4893
+ // terminal when it isn't — name what it means and the one command that follows it.
4894
+ if (!watch && s.status !== 'live') {
4895
+ if (s.status === 'failed' && s.error)
4896
+ statusRow('what now', indexErrorAction(s.error.class));
4897
+ statusRow('follow', dim(`this is not final — ${bold(`abx status ${address} --remote${spec ? ` ${spec}` : ''} --watch`)} tails it until it settles`));
4898
+ }
4899
+ console.log('');
4900
+ if (!watch || s.status === 'live' || s.status === 'failed')
4901
+ return;
4902
+ await sleep(3000);
4903
+ }
4904
+ }
4905
+ for (;;) {
4906
+ let projects;
4907
+ try {
4908
+ projects = await client.listProjects();
4909
+ }
4910
+ catch (err) {
4911
+ throw describeRemoteError(err, remote, 'remote status');
4912
+ }
4913
+ if (projects.length === 0) {
4914
+ console.log(dim('\n no projects visible to this token\n'));
4915
+ return;
4916
+ }
4917
+ console.log('');
4918
+ for (const p of projects) {
4919
+ console.log(` ${g('●')} ${p.name ?? p.label ?? p.address} ${dim(p.address)} ` +
4920
+ `${p.status ? statusLabel(p.status) : dim('status not reported')}` +
4921
+ `${p.error ? ` ${c.orange}${p.error.class}${c.reset}` : ''} ${dim(`${p.tokenCount ?? '?'} token(s)`)}`);
4922
+ }
4923
+ console.log(`\n ${rollUp(projects)}\n`);
4924
+ const settled = projects.every((p) => !p.status || p.status === 'live' || p.status === 'failed');
4925
+ if (!watch || settled)
4926
+ return;
4927
+ await sleep(3000);
4928
+ }
4929
+ }
4930
+ /** "3 live, 1 backfilling, 1 failed (rpc_rate_limited)" — the one-line answer to "is my stuff ok?" */
4931
+ function rollUp(projects) {
4932
+ const counts = new Map();
4933
+ for (const p of projects) {
4934
+ const k = p.status ?? 'unknown';
4935
+ counts.set(k, (counts.get(k) ?? 0) + 1);
4936
+ }
4937
+ const classes = [...new Set(projects.filter((p) => p.error).map((p) => p.error.class))];
4938
+ const parts = [...counts.entries()].map(([k, n]) => `${n} ${k}`);
4939
+ return `${parts.join(', ')}${classes.length ? ` ${dim(`(${classes.join(', ')})`)}` : ''}`;
4028
4940
  }
4029
4941
  // ── forget ────────────────────────────────────────────────────────────────--
4030
4942
  // Drop a project this node tracks (registration + projection) — for cleaning up
4031
4943
  // test/junk deploys. On-chain data is untouched; `abx add` can re-register it.
4032
4944
  async function cmdForget(address, flags) {
4033
4945
  if (!address || address.startsWith('--')) {
4034
- console.error('usage: abx forget <address> [--remote [url]]\n');
4946
+ console.error('usage: abx forget <address> [--remote [name|url]]\n');
4035
4947
  process.exit(1);
4036
4948
  }
4037
- const remote = remoteTarget(flags);
4949
+ const remote = remoteFlag(flags);
4038
4950
  if (remote) {
4039
- const token = requireAdminToken();
4040
- info(`${bold('REMOTE')} → ${remote} ${dim('(deregistering on the hosted resolver — NOT this machine)')}`);
4041
- await remoteRemoveProject(remote, token, address);
4042
- ok(`remote resolver forgot ${address} — it will stop serving it. On-chain data is untouched.`);
4951
+ requireRemoteToken(remote);
4952
+ info(`${bold('REMOTE')} → ${remote.url} ${dim('(deregistering on the remote resolver — NOT this machine)')}`);
4953
+ let removed;
4954
+ try {
4955
+ ({ removed } = await serviceClient(remote).removeProject(resolveChain(CHAIN).id, address));
4956
+ }
4957
+ catch (err) {
4958
+ throw describeRemoteError(err, remote, 'remote forget');
4959
+ }
4960
+ if (removed)
4961
+ ok(`remote resolver forgot ${address} — it will stop serving it. On-chain data is untouched.`);
4962
+ else
4963
+ console.log(dim(` ${address} wasn't registered on ${remote.url} — nothing to forget.`));
4043
4964
  return;
4044
4965
  }
4045
4966
  const indexer = new SelfHostIndexer();
@@ -4050,6 +4971,97 @@ async function cmdForget(address, flags) {
4050
4971
  indexer.store.deregister(address);
4051
4972
  ok(`forgot ${address} — dropped its registration + projection. On-chain data is untouched.`);
4052
4973
  }
4974
+ // ── remote ────────────────────────────────────────────────────────────────--
4975
+ // Inspect a remote service. Bare `abx remote` lists the named remotes configured in .env plus the
4976
+ // self-host default pair (URLs + whether a token is set — never the secret itself). With a target,
4977
+ // fetches its PUBLIC service descriptor (what it serves: interfaces, chains, auth, managed
4978
+ // rendering) and — when a token resolves — lists the projects visible to that token, which makes
4979
+ // this the one-command "is my provider key valid?" check. Read-only; registers nothing.
4980
+ async function cmdRemote(spec, flags) {
4981
+ if (!spec || spec.startsWith('--')) {
4982
+ const remotes = listConfiguredRemotes();
4983
+ console.log(`\n ${bold('named remotes')} ${dim('(ABX_REMOTE_<NAME>_URL/_TOKEN in .env — inspect one: abx remote <name>)')}`);
4984
+ if (remotes.length === 0)
4985
+ console.log(dim(' none configured'));
4986
+ for (const r of remotes) {
4987
+ console.log(` ${g('●')} ${r.name.toLowerCase()} ${dim(r.url)} ${r.hasToken ? g('token set') : dim('no token')}`);
4988
+ }
4989
+ // A near-miss var reads as "no token" while the value is sitting in .env under the wrong name.
4990
+ for (const bad of misnamedRemoteVars()) {
4991
+ warn(`${bad.key} isn't a recognized remote var — the convention is ${bold(bad.suggestion)} (only _URL and _TOKEN are read).`);
4992
+ }
4993
+ const def = process.env.ABX_PUBLIC_BASE_URL ?? process.env.ABX_RESOLVER_URL;
4994
+ console.log(`\n ${bold('self-host default')} ${dim('(bare --remote)')}`);
4995
+ console.log(def
4996
+ ? ` ${g('●')} ${def} ${process.env.ABX_RESOLVER_ADMIN_TOKEN ? g('token set') : dim('no ABX_RESOLVER_ADMIN_TOKEN')}`
4997
+ : dim(' none (set ABX_PUBLIC_BASE_URL in .env)'));
4998
+ console.log('');
4999
+ return;
5000
+ }
5001
+ const target = resolveRemote(spec, flags['remote-token']);
5002
+ if (!target)
5003
+ return;
5004
+ const client = serviceClient(target);
5005
+ console.log(`\n ${bold(target.name ? `remote ${target.name.toLowerCase()}` : 'remote')} ${dim(`→ ${target.url}`)}`);
5006
+ let d;
5007
+ try {
5008
+ d = await client.descriptor();
5009
+ }
5010
+ catch (err) {
5011
+ // Two very different situations, and the fix differs — so don't nest the raw client error
5012
+ // (it repeats the URL and leaks `GET`/`fetch failed` at a creator).
5013
+ const status = err instanceof AbxServiceError ? err.status : -1;
5014
+ if (status === 0) {
5015
+ throw new Error(`nothing responded at ${target.url} — check the address. A provider gives you an https:// base ` +
5016
+ `(e.g. https://meta.provider.xyz); if it's your own node, is it running?`);
5017
+ }
5018
+ throw new Error(`${target.url} answered, but serves no ABX service descriptor at /.well-known/abx-service. ` +
5019
+ `That's either an older self-hosted node (fine if it's yours — the remote commands still work against it) ` +
5020
+ `or not an ABX service at all. Verify the URL before registering anything with it.`);
5021
+ }
5022
+ info(`service ${d.service?.name ?? '—'} ${dim(d.service?.version ?? '')}`);
5023
+ info(`serves ${(d.interfaces ?? []).join(' · ') || '—'}`);
5024
+ const chainId = resolveChain(CHAIN).id;
5025
+ const coversChain = (d.chains ?? []).includes(chainId);
5026
+ info(`chains ${(d.chains ?? []).join(', ') || '—'} ${coversChain ? g(`✓ covers ${CHAIN} (${chainId})`) : `${c.orange}⚠${c.reset} does NOT cover ${CHAIN} (${chainId}) — registrations will be refused`}`);
5027
+ if (d.render?.attached) {
5028
+ const outputs = d.render.effects?.flatMap((e) => e.outputs.map((o) => `${e.key}/${o.key}`)).join(', ');
5029
+ 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`);
5030
+ }
5031
+ if (d.auth) {
5032
+ info(`auth bearer${d.auth.signupUrl ? ` · get a key: ${d.auth.signupUrl}` : ''}${d.auth.docsUrl ? ` · docs: ${d.auth.docsUrl}` : ''}`);
5033
+ }
5034
+ else {
5035
+ info(`auth none advertised ${dim('(control plane disabled on this node)')}`);
5036
+ }
5037
+ if (!target.token) {
5038
+ // Same near-miss check the register path does — this is where someone lands FIRST when their
5039
+ // credential is set under a name the CLI doesn't read, so the hint has to be here too.
5040
+ const nearMiss = misnamedRemoteVars().find((v) => v.suggestion === target.tokenVar);
5041
+ if (nearMiss)
5042
+ warn(`${nearMiss.key} is set but is NOT read — the convention is ${bold(target.tokenVar)} (only _URL and _TOKEN). Rename it and re-run.`);
5043
+ else
5044
+ info(dim(`no token resolved (set ${target.tokenVar} or pass --remote-token) — descriptor only; can't list your projects.`));
5045
+ console.log('');
5046
+ return;
5047
+ }
5048
+ try {
5049
+ const projects = await client.listProjects();
5050
+ ok(`token accepted — ${projects.length} project(s) visible to it`);
5051
+ for (const p of projects.slice(0, 10)) {
5052
+ console.log(` ${g('●')} ${p.name ?? p.label ?? p.address} ${dim(`${p.address} · ${p.tokenCount ?? '?'} token(s)`)}` +
5053
+ `${p.status ? ` ${statusLabel(p.status)}` : ''}${p.error ? ` ${c.orange}${p.error.class}${c.reset}` : ''}`);
5054
+ }
5055
+ if (projects.length > 10)
5056
+ console.log(dim(` … and ${projects.length - 10} more`));
5057
+ if (projects.some((p) => p.status))
5058
+ info(rollUp(projects) + dim(' — one project in detail: abx status <address> --remote ' + (target.name?.toLowerCase() ?? target.url)));
5059
+ }
5060
+ catch (err) {
5061
+ throw describeRemoteError(err, target, 'remote list');
5062
+ }
5063
+ console.log('');
5064
+ }
4053
5065
  // ── migrate ───────────────────────────────────────────────────────────────--
4054
5066
  // Move a contract's OFF-CHAIN operator state from one resolver to another (e.g. fly.io →
4055
5067
  // a droplet). The destination replays all ON-CHAIN state from chain itself; this bridges
@@ -4058,13 +5070,17 @@ async function cmdForget(address, flags) {
4058
5070
  // and write through the admin control plane. It does NOT cut over — after a clean migration the
4059
5071
  // operator re-points DNS (custom domain) or the on-chain base URI (provider endpoint).
4060
5072
  async function cmdMigrate(address, flags) {
4061
- const from = typeof flags.from === 'string' ? flags.from : undefined;
4062
- const to = typeof flags.to === 'string' ? flags.to : undefined;
4063
- if (!address || address.startsWith('--') || !from || !to) {
4064
- console.error('usage: abx migrate <address> --from <source-resolver-url> --to <dest-resolver-url> [--from-block N]\n');
5073
+ // Both sides accept a named remote or a URL. Only the DESTINATION needs a credential — the
5074
+ // source is read via its PUBLIC api (the exit ramp works with zero provider cooperation).
5075
+ const fromTarget = typeof flags.from === 'string' ? resolveRemote(flags.from) : null;
5076
+ const toTarget = typeof flags.to === 'string' ? resolveRemote(flags.to, flags['remote-token']) : null;
5077
+ if (!address || address.startsWith('--') || !fromTarget || !toTarget) {
5078
+ console.error('usage: abx migrate <address> --from <source-resolver name|url> --to <dest-resolver name|url> [--from-block N]\n');
4065
5079
  process.exit(1);
4066
5080
  }
4067
- const token = requireAdminToken();
5081
+ requireRemoteToken(toTarget);
5082
+ const from = fromTarget.url;
5083
+ const to = toTarget.url;
4068
5084
  const chainId = resolveChain(CHAIN).id;
4069
5085
  allowLargeScan(flags);
4070
5086
  console.log(bold(`\n abx migrate ${dim('— port off-chain state between resolvers (no cutover)')}`));
@@ -4121,6 +5137,7 @@ async function cmdMigrate(address, flags) {
4121
5137
  step('Populate the destination');
4122
5138
  const locCount = Object.keys(plan.contentLocators).length;
4123
5139
  const body = {
5140
+ chainId,
4124
5141
  address,
4125
5142
  // The deploy block (locally known); if somehow absent, omit it so the destination derives it
4126
5143
  // rather than scanning from genesis (never bake a from-0 floor into a fresh resolver).
@@ -4133,9 +5150,18 @@ async function cmdMigrate(address, flags) {
4133
5150
  contentLocators: locCount ? plan.contentLocators : undefined,
4134
5151
  full: true, // first registration on the destination — replay from the deploy block
4135
5152
  };
4136
- info(`${bold('REMOTE')} → ${to} ${dim('(admin control plane — chain replay + off-chain enrichment)')}`);
4137
- const r = await remoteAddProject(to, token, body);
4138
- ok(`destination indexed ${r.project.name ?? address}: ${r.project.eventCount} events ${dim(`(${r.mode}, ${r.elapsedMs}ms)`)}`);
5153
+ info(`${bold('REMOTE')} → ${to} ${dim('(control plane — chain replay + off-chain enrichment)')}`);
5154
+ let r;
5155
+ try {
5156
+ r = await serviceClient(toTarget).registerProject(body);
5157
+ }
5158
+ catch (err) {
5159
+ throw describeRemoteError(err, toTarget, 'migrate destination');
5160
+ }
5161
+ // Always wait here, even if the caller passed --no-wait: the parity check below reads the
5162
+ // destination's served metadata, and comparing a half-indexed projection would report a false
5163
+ // mismatch — worse than a slow migrate.
5164
+ await reportRemoteIndexing(toTarget, chainId, address, r, {}, 'indexed');
4139
5165
  // 5) Parity check — does the destination now serve the same metadata as the source? Sample a
4140
5166
  // token we did NOT re-pin (a re-pinned image is durable-locator-on-dest vs old-host-on-source
4141
5167
  // BY DESIGN — different there is correct, so comparing it would mislead).
@@ -4165,100 +5191,171 @@ async function cmdMigrate(address, flags) {
4165
5191
  info('keep the source running until DNS / base-URI propagates (source-only images were already re-pinned above, unless a warning said otherwise).');
4166
5192
  }
4167
5193
  // ── doctor ────────────────────────────────────────────────────────────────--
5194
+ /**
5195
+ * Doctor's "want me to fix that?" for a missing or stale skill. Three lanes, deliberately:
5196
+ * `--fix` install without asking (CI, scripts, an agent running doctor for someone)
5197
+ * interactive name the exact directories, then ask — Enter accepts, since doctor's whole job is
5198
+ * getting setup right and this is the one check whose fix is a local file copy
5199
+ * non-TTY change NOTHING and print how to do it; a diagnostic must never mutate a
5200
+ * scripted environment just because nobody was there to say no
5201
+ * Honors `--global` / `--agent` so the fix can target the same place an explicit install would.
5202
+ */
5203
+ async function offerSkillInstall(flags, stale, indent) {
5204
+ const opts = { global: flags.global !== undefined, agent: flags.agent };
5205
+ const src = resolveBundledSkill();
5206
+ if (!src)
5207
+ return; // no bundled skill to install (dev checkout oddity) — the hint above still stands
5208
+ const verb = stale ? 'resync' : 'install';
5209
+ const dests = defaultSkillDests(opts);
5210
+ if (flags.fix === undefined) {
5211
+ if (!process.stdin.isTTY) {
5212
+ console.log(`${indent}${dim(`non-interactive — run \`abx doctor --fix\` (or \`abx skill install\`) to ${verb} it.`)}`);
5213
+ return;
5214
+ }
5215
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
5216
+ const answer = await new Promise((resolve) => {
5217
+ // EOF (Ctrl-D) closes the interface WITHOUT firing the question callback — awaiting only the
5218
+ // callback would hang doctor forever. Treat a closed stream as a decline: the safe direction is
5219
+ // always "change nothing", never "write files because nobody answered".
5220
+ rl.once('close', () => resolve('n'));
5221
+ rl.question(`${indent}${verb} the abx skill into ${bold(dests)} now? [Y/n] `, resolve);
5222
+ });
5223
+ rl.close();
5224
+ if (declinesSkillInstall(answer)) {
5225
+ console.log(`${indent}${dim(`skipped — \`abx skill install\` when you want it.`)}`);
5226
+ return;
5227
+ }
5228
+ }
5229
+ console.log('');
5230
+ installSkillToDefaults(src, opts);
5231
+ }
4168
5232
  async function cmdDoctor(flags) {
4169
- console.log(bold('\n abx doctor\n'));
4170
- const check = (label, pass, detail = '') => console.log(` ${pass ? g('✓') : `${c.red}✗${c.reset}`} ${label}${detail ? dim(' ' + detail) : ''}`);
5233
+ console.log(bold('\n abx doctor') + dim(` · ${CHAIN}`) + '\n');
5234
+ // Two visual tiers: PASS/FAIL checks (✓/✗) for things that are either working or broken, and an
5235
+ // "Optional" block (·) for path-dependent setup that is fine to be unset. We deliberately do NOT
5236
+ // use ⚠ for "unset but often fine" — that read as noise; ⚠ is reserved for a real gotcha (a
5237
+ // range-capped RPC). Labels are padded so both tiers align.
5238
+ const CONT = ' '.repeat(17); // continuation indent: aligns under a check/opt detail column
5239
+ const check = (label, pass, detail = '') => console.log(` ${pass ? g('✓') : `${c.red}✗${c.reset}`} ${label.padEnd(13)}${detail ? dim(detail) : ''}`);
5240
+ const opt = (label, detail) => console.log(` ${dim('·')} ${label.padEnd(13)}${dim(detail)}`);
4171
5241
  const hasKey = !!(process.env.ABX_DEPLOYER_PK ?? process.env.SEPOLIA_FUNDED_PK ?? process.env.SEPOLIA_WALLET_PK);
4172
- // Not having an env key is NOT fatal: the wallet lane (`--sign`) signs in the user's own wallet,
4173
- // key-free. Only flag it as a soft note + point at the lanes never imply "paste a private key."
4174
- if (hasKey)
4175
- check('signing key in env', true, 'hot lane ready (env key signs unattended)');
4176
- else
4177
- console.log(` ${c.orange}⚠${c.reset} signing key in env${dim(' none fine if you sign in your own wallet (`--sign`; the key never touches the CLI). Only set ABX_DEPLOYER_PK / SEPOLIA_FUNDED_PK / SEPOLIA_WALLET_PK for hot/unattended signing.')}`);
5242
+ // 1. Agent skill FIRST and prominent. The primary way to use abx is to let a coding agent drive
5243
+ // it, so a missing/stale skill is a ✗: not broken infra, but the main UX isn't set up. Its
5244
+ // version lives in SKILL.md frontmatter (version-locked to this CLI). Notify-only — no exit code.
5245
+ const cliVersion = readCliVersion();
5246
+ const skillVersions = installedSkillVersions();
5247
+ const staleSkills = skillVersions.filter((v) => compareVersions(cliVersion, v) > 0);
5248
+ const skillMissing = skillVersions.length === 0;
5249
+ const skillStale = !skillMissing && staleSkills.length > 0;
5250
+ if (skillMissing) {
5251
+ check('agent skill', false, `not installed — run ${g('abx skill install')}`);
5252
+ console.log(`${CONT}${dim('(recommended: let a coding agent drive abx)')}`);
5253
+ }
5254
+ else if (skillStale) {
5255
+ check('agent skill', false, `v${staleSkills.join(', v')} behind CLI v${cliVersion} — run ${g('abx skill install')}`);
5256
+ }
5257
+ else {
5258
+ check('agent skill', true, `in sync (v${cliVersion})`);
5259
+ }
5260
+ // Offer to fix it here rather than only naming the command. `npm i -g` + `abx skill install` was a
5261
+ // two-step install flow where the second step is easy to skip and invisible when skipped (an agent
5262
+ // that never learned abx just... doesn't use it). Doctor is already the documented first run, so
5263
+ // this collapses the flow without an npm `postinstall` hook — which could not work anyway: npm runs
5264
+ // lifecycle scripts with cwd set to the installed package dir (so the skill would land inside
5265
+ // node_modules), pnpm gates install scripts by default, and writing to a user's ~/.claude on
5266
+ // install is the kind of side effect that belongs to the user, not to us.
5267
+ if (skillMissing || skillStale) {
5268
+ await offerSkillInstall(flags, skillStale, CONT);
5269
+ }
5270
+ console.log('');
5271
+ // 2. Core environment (✓/✗). Signing-wallet balances are computed here (they need the RPC) but
5272
+ // printed in the Optional block below, so buffer them.
5273
+ let signingOpt = null;
5274
+ let forOpt = null;
4178
5275
  try {
4179
5276
  const publicClient = makePublicClient({ chainKey: CHAIN });
4180
5277
  const bn = await publicClient.getBlockNumber();
4181
- check(`RPC reachable (${CHAIN})`, true, `head block ${bn}`);
4182
- // RPC endpointsprobe each for reachability, getLogs range, and archive depth,
4183
- // so the toolkit uses (and recommends) the one fit for the job — and says so if none are.
5278
+ // Collapse the RPC report to one line (best endpoint + head), and only add a ⚠ when there is a
5279
+ // genuine problema range-capped-only set that will grind a resolver under load.
4184
5280
  const probes = await probeRpcEndpoints({ chainKey: CHAIN });
4185
5281
  const usable = probes.filter((pr) => pr.verdict !== 'unusable');
4186
5282
  const best = probes.find((pr) => pr.verdict === 'best') ?? usable[0];
4187
- check(`RPC endpoints for ${CHAIN} (${probes.length})`, usable.length > 0, usable.length > 0
4188
- ? `best for reconstruction: ${best.label} (${best.verdict === 'best' ? 'wide range + archive' : 'usable, range-capped'})`
4189
- : 'none usable for reconstruction');
4190
- for (const pr of probes) {
4191
- const glyph = pr.verdict === 'best' ? g('✓') : pr.verdict === 'capped' ? `${c.orange}⚠${c.reset}` : `${c.red}✗${c.reset}`;
4192
- console.log(` ${glyph} ${pr.label} ${dim('— ' + (pr.verdict === 'best' ? 'wide getLogs range + archive' : pr.reason ?? pr.verdict))}`);
4193
- }
4194
- if (usable.length === 0) {
4195
- console.log(` ${c.orange}↳${c.reset} ${dim('research a current free archive RPC with a wide getLogs range and add it to ABX_RPC_URLS — see the skill’s “Choosing an RPC”')}`);
4196
- }
4197
- else if (!probes.some((pr) => pr.verdict === 'best')) {
4198
- // Every usable endpoint is range-capped. Indexing still works (chunking), but a first
4199
- // reconstruction — and a resolver under marketplace load — is slow + rate-limit-prone. That's
4200
- // a serious infra signal, NOT "a paid plan is required": flag it. A normal deploy→index only
4201
- // scans from the deploy block, so this bites first reconstructions and busy resolvers most.
4202
- console.log(` ${c.orange}⚠ every usable RPC is getLogs-range-capped${c.reset} ${dim('— add a wide-range archive endpoint to ABX_RPC_URLS before running a resolver under load; capped ones grind on wide scans (skill → “Choosing an RPC”).')}`);
4203
- }
4204
- if (hasKey) {
4205
- const { account } = makeWalletClient({ chainKey: CHAIN });
4206
- const bal = await publicClient.getBalance({ address: account.address });
4207
- check('deployer funded', bal > 0n, `${account.address} · ${formatEther(bal)} ETH`);
4208
- }
4209
- // Wallet lane has no env key — let a creator preflight THEIR OWN signing wallet's balance,
4210
- // the one gap where an unfunded wallet otherwise only surfaces at the signing step.
4211
- if (flags.for) {
4212
- const bal = await publicClient.getBalance({ address: flags.for });
4213
- check('wallet funded (--for)', bal > 0n, `${flags.for} · ${formatEther(bal)} ETH${bal > 0n ? '' : ` — ${faucetHint(CHAIN)}`}`);
5283
+ if (usable.length > 0) {
5284
+ check('RPC', true, `${best.label} · head ${bn} · ${best.verdict === 'best' ? 'wide range + archive' : 'range-capped'}`);
5285
+ if (!probes.some((pr) => pr.verdict === 'best')) {
5286
+ console.log(`${CONT}${c.orange}⚠${c.reset}${dim(' every endpoint is getLogs-range-capped — add a wide-range archive RPC to ABX_RPC_URLS before running a resolver under load')}`);
5287
+ }
4214
5288
  }
4215
- else if (!hasKey) {
4216
- console.log(` ${dim(' wallet lane: run `abx doctor --for 0x<your wallet>` to check your signing wallet is funded')}`);
5289
+ else {
5290
+ check('RPC', false, `${CHAIN} no endpoint usable for reconstruction; add a wide-range archive RPC to ABX_RPC_URLS`);
4217
5291
  }
4218
5292
  const factory = factoryAddress();
4219
5293
  if (factory) {
4220
5294
  const code = await publicClient.getCode({ address: factory });
4221
- if (!code || code === '0x') {
4222
- check('canonical factory deployed', false, `${factory} — no code on ${CHAIN}; \`abx deploy\` redeploys`);
4223
- }
4224
- else if (await isCurrentFactory(publicClient, factory)) {
4225
- check('canonical factory deployed', true, factory);
4226
- }
4227
- else {
4228
- check('canonical factory deployed', false, `${factory} — older/incompatible version; \`abx deploy\` redeploys`);
4229
- }
5295
+ if (!code || code === '0x')
5296
+ check('factory', false, `${factory} — no code on ${CHAIN}; \`abx deploy\` redeploys`);
5297
+ else if (await isCurrentFactory(publicClient, factory))
5298
+ check('factory', true, factory);
5299
+ else
5300
+ check('factory', false, `${factory} — older/incompatible; \`abx deploy\` redeploys`);
4230
5301
  }
4231
5302
  else {
4232
- check('canonical factory deployed', false, 'none yet — `abx demo` deploys one');
5303
+ check('factory', false, 'none yet — `abx demo` deploys one');
5304
+ }
5305
+ if (hasKey) {
5306
+ const { account } = makeWalletClient({ chainKey: CHAIN });
5307
+ const bal = await publicClient.getBalance({ address: account.address });
5308
+ signingOpt = `env key ${account.address} · ${bal > 0n ? `funded ${formatEther(bal)} ETH` : `empty — fund it (${faucetHint(CHAIN)})`}`;
5309
+ }
5310
+ if (flags.for) {
5311
+ const bal = await publicClient.getBalance({ address: flags.for });
5312
+ forOpt = `${flags.for} · ${bal > 0n ? `funded ${formatEther(bal)} ETH` : `empty — ${faucetHint(CHAIN)}`}`;
4233
5313
  }
4234
5314
  }
4235
5315
  catch (err) {
4236
- check('RPC reachable', false, err.message);
5316
+ check('RPC', false, err.message);
4237
5317
  }
4238
- // public base URL — baked into on-chain URIs at deploy; unset is fine for a demo
4239
- // but a silent footgun for a real launch, so surface it explicitly.
4240
- const baseUrl = process.env.ABX_PUBLIC_BASE_URL;
4241
- if (baseUrl)
4242
- check('public base URL set (baked into on-chain URIs)', true, baseUrl);
4243
- else
4244
- console.log(` ${c.orange}⚠${c.reset} public base URL${dim(' ABX_PUBLIC_BASE_URL unset → resolver-served deploys REFUSE (localhost on-chain resolves for no one). Set it (or --public-base-url) for a hosted resolver. IRRELEVANT if you go fully on-chain: --onchain-image (1/1) or --onchain-uri (code project) bake no resolver base.')}`);
4245
5318
  // storage backend — resolve it (catches missing config), then probe liveness/creds
4246
5319
  const backendId = activeBackendId();
4247
5320
  try {
4248
5321
  const backend = resolveBackend(storageOptions());
4249
5322
  const h = await backend.health?.();
4250
5323
  if (h)
4251
- check(`storage backend '${backend.id}' reachable`, h.ok, h.detail ?? '');
5324
+ check('storage', h.ok, `${backend.id} · ${h.detail ?? ''}`);
4252
5325
  else
4253
- check(`storage backend '${backend.id}' configured`, true);
5326
+ check('storage', true, `${backend.id} · configured`);
4254
5327
  }
4255
5328
  catch (err) {
4256
- check(`storage backend '${backendId}' configured`, false, err.message);
5329
+ check('storage', false, `${backendId} · ${err.message}`);
4257
5330
  }
4258
- // Managed Turbo/Arweave key if it exists, it holds prepaid credits, so remind them to back it up.
5331
+ // 3. Optionalpath-dependent setup. Unset is fine; these say WHEN you'll need each, so an unset
5332
+ // value never reads as a warning.
5333
+ console.log(`\n ${dim('Optional — depends how you deploy:')}`);
5334
+ if (signingOpt)
5335
+ opt('signing', signingOpt);
5336
+ else
5337
+ opt('signing', 'no env key → sign in your browser wallet (--sign). Preflight yours: `abx doctor --for 0x<addr>`. Set ABX_DEPLOYER_PK for the unattended hot lane.');
5338
+ if (forOpt)
5339
+ opt('wallet --for', forOpt);
5340
+ const baseUrl = process.env.ABX_PUBLIC_BASE_URL;
5341
+ if (baseUrl)
5342
+ opt('resolver URL', baseUrl);
5343
+ else
5344
+ opt('resolver URL', 'ABX_PUBLIC_BASE_URL unset → needed for off-chain/resolver-served deploys; skip if fully on-chain (--onchain-image / --onchain-uri).');
4259
5345
  if (!process.env.ARWEAVE_JWK && existsSync(arweaveKeyFilePath())) {
4260
5346
  const jwk = loadArweaveJwk();
4261
- console.log(` ${dim(' Turbo/Arweave key ' + (jwk ? arweaveAddress(jwk) + ' ' : '') + 'holds your upload credits — back it up: `abx storage backup-key --out <path>`')}`);
5347
+ opt('arweave key', `${jwk ? arweaveAddress(jwk) + ' ' : ''}holds upload credits — back it up: \`abx storage backup-key --out <path>\``);
5348
+ }
5349
+ // Named remotes: report what's configured, and — the part doctor was missing — flag a credential
5350
+ // stored under a name the CLI does NOT read. That fault presents as "it acts like I never gave it a
5351
+ // key", and until now it only surfaced from `abx remote <name>`, which a creator reaches later
5352
+ // (doctor is the thing they're told to run FIRST).
5353
+ const remotes = listConfiguredRemotes();
5354
+ if (remotes.length) {
5355
+ opt('remotes', remotes.map((r) => `${r.name.toLowerCase()}${r.hasToken ? '' : ` ${c.orange}(no token)${c.reset}`}`).join(' · '));
5356
+ }
5357
+ for (const bad of misnamedRemoteVars()) {
5358
+ check('remote key', false, `${bad.key} is set but is NOT read — the convention is ${bold(bad.suggestion)} (only _URL and _TOKEN). Rename it.`);
4262
5359
  }
4263
5360
  console.log('');
4264
5361
  }
@@ -4273,7 +5370,13 @@ function printServing(url, address) {
4273
5370
  console.log(` ${dim('image ')}${url}/t/${cid}/${address}/0/image`);
4274
5371
  console.log(` ${dim('state API ')}${url}/api/project/${address}`);
4275
5372
  }
4276
- console.log(`\n ${dim('Open the dashboard, then hit “Re-index from chain” to watch state rebuild live.')}`);
5373
+ // The dashboard is READ-ONLY: re-index/verify are admin actions that 404 unless the node has an
5374
+ // ABX_RESOLVER_ADMIN_TOKEN, so there is no button to press. This line used to say "hit Re-index
5375
+ // from chain", which sent every first-run user hunting for a control that isn't there.
5376
+ console.log(`\n ${dim('The dashboard shows the event spine it replayed — that table IS the reconstruction.')}`);
5377
+ if (address) {
5378
+ console.log(` ${dim('Rebuild it yourself (read-only, safe):')} ${bold(`abx index ${address} --full`)} ${dim('— replays from the deploy block and must land on identical state.')}`);
5379
+ }
4277
5380
  console.log(` ${dim('Ctrl-C to stop.')}\n`);
4278
5381
  }
4279
5382
  // ── deploy-resolver: scaffold a hosted resolver for a provider the operator owns ──────────
@@ -4404,7 +5507,8 @@ async function cmdDeployResolver(flags) {
4404
5507
  const url = art.baseUrl;
4405
5508
  info(`abx deploy --image <art> --name … --public-base-url ${url} (or export ABX_PUBLIC_BASE_URL=${url})`);
4406
5509
  info(`the contract derives ${url}/t/${resolveChain(CHAIN).id}/{address}/{tokenId} from that base.`);
4407
- info(`${bold('then')} abx add <clone> --remote ${dim('# tell the hosted resolver to index it — a LOCAL deploy does NOT')}`);
5510
+ info(`${bold('then')} abx add <clone> --remote ${dim('# tell the remote resolver to index it — a LOCAL deploy does NOT')}`);
5511
+ 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>`));
4408
5512
  console.log('');
4409
5513
  }
4410
5514
  // ── deploy-effects: scaffold the render runner (the resolver's browser-bearing companion) ─────
@@ -4558,12 +5662,15 @@ function keepAlive() {
4558
5662
  // ── per-command usage (printed by `<cmd> --help` / `abx help <cmd>`; read-only) ──
4559
5663
  const COMMAND_HELP = {
4560
5664
  skill: `
4561
- ${bold('abx skill')} ${dim('— install the abx agent skill so your coding agent can drive abx')}
4562
- ${g('abx skill install')} copy the skill into ${bold('.claude/skills/abx-self-host')} in the current project
4563
- ${g('--global')} install into your home dir (~/.claude/skills) instead of the project
4564
- ${g('--target <dir>')} install under a different base dir (writes <dir>/.claude/skills/abx-self-host)
5665
+ ${bold('abx skill')} ${dim('— install the version-locked abx agent skill so your coding agent can drive abx')}
5666
+ ${g('abx skill install')} copy the bundled skill (version-locked to this CLI) into your agent(s)
5667
+ ${dim('default: both')} ${bold('.claude/skills')} ${dim('(Claude Code) and')} ${bold('.agents/skills')} ${dim('(Cursor · Codex · Gemini · Copilot)')}
5668
+ ${g('--agent <name>')} only one agent: ${dim('claude | cursor | codex | gemini | copilot')}
5669
+ ${g('--global')} install into your home dir (~) instead of the current project
5670
+ ${g('--target <dir>')} install the skill folder straight under <dir> (writes <dir>/abx-self-host)
4565
5671
  ${g('abx skill path')} print the absolute path to the bundled skill (for ${g('npx skills add <path>')} or manual copy)
4566
- ${dim('Restart your agent after installing so it picks up the skill. Cross-agent install:')} ${g('npx skills add ArtBlocks/abx')}`,
5672
+ ${dim('Restart your agent after installing so it picks up the skill. Once the repo is public, the git-based')}
5673
+ ${dim('cross-agent installer also works (not version-locked):')} ${g('npx skills add ArtBlocks/abx --skill abx-self-host')}`,
4567
5674
  deploy: `
4568
5675
  ${bold('abx deploy')} ${dim('— deploy + index a 1/1 (no server). Sends a tx in the chosen lane.')}
4569
5676
  --image <path> custody your own image (png · jpg · gif · svg · webp); else generative demo art
@@ -4620,6 +5727,20 @@ const COMMAND_HELP = {
4620
5727
  ${g('abx deploy-series')} --dir ./photos --name "My Series" --symbol MS --onchain-uri --backend ipfs --mint-all --sign
4621
5728
  ${dim('# tiny SVGs → fully on-chain (no storage at all):')}
4622
5729
  ${g('abx deploy-series')} --dir ./svgs --name "My Series" --symbol MS --onchain-image --compress fastlz --mint-all --sign`,
5730
+ preview: `
5731
+ ${bold('abx preview')} (--script <file.js> | --code-dir <dir>) ${dim('— run the program on localhost, live. No chain, no key, no deploy.')}
5732
+ ${g('--schema key:Type:Auth')}[,…] declare PostParams so the studio gives you real inputs for them (e.g. palette:HexColor:TokenOwner)
5733
+ ${g('--dep <name@version>')}[,…] load a library the way the resolver would (built-in CDN map; on-chain refs can't be fetched offline)
5734
+ ${g('--port')} <n> studio port (default ${DEFAULT_PREVIEW_PORT}; the resolver's ${DEFAULT_PORT} stays free)
5735
+ ${g('--shoot')} <dir> render headlessly to PNGs + traits.json and EXIT ${dim('(for an agent that has no browser)')}
5736
+ ${g('--count')} <n> seeds to shoot / show in the grid (default 9) ${g('--width')} <px> ${g('--timeout-ms')} <n>
5737
+ Serves the ${bold('same document the generator serves')} — real ${g('abx.js')}, real tokenData shape, real dep tags — with a synthetic
5738
+ seed, so what you iterate on is what deploys. Routes: ${g('/')} studio (seed + params + live traits) · ${g('/grid')} N seeds at once,
5739
+ all live · ${g('/view')} the bare document. The program is re-read from disk per render, so ${bold('edit and refresh')} — no watcher.
5740
+ Unlike a still-image sweep this shows ${bold('animation')}, which is most of what a screenshot throws away.
5741
+ ${dim('Still do both after the art settles:')} ${g('abx inspect')} ${dim('(is it wired right?) and a testnet deploy (the faithful end-to-end).')}
5742
+ ${g('abx preview')} --script art.js --schema palette:HexColor:TokenOwner
5743
+ ${g('abx preview')} --script art.js --shoot ./frames --count 12`,
4623
5744
  inspect: `
4624
5745
  ${bold('abx inspect')} <script.js> ${dim('— static analysis of a generative script + a lane recommendation. Read-only; the script is never executed.')}
4625
5746
  ${g('--dep <name@version>[,…]')} the on-chain deps you plan to declare, so the assembled-document size estimate is realistic (e.g. --dep p5@1.0.0)
@@ -4686,8 +5807,9 @@ const COMMAND_HELP = {
4686
5807
  No tokenId → sweeps all minted tokens; pass ids (${g('0 1 2')}) to target specific tokens.
4687
5808
  ${g('--force')} RE-RENDER even when the still already exists — the fix for a bad / blank / timed-out capture
4688
5809
  (the art is otherwise deterministic, so a plain render idempotent-skips an existing still). Overwrites it (+ republishes on --remote).
4689
- ${g('--remote [url]')} publish each render to a HOSTED resolver (the locator bridge: upload to ${bold('ABX_STORAGE_BACKEND')}, POST /admin/effect-artifacts).
4690
- Idempotent; a re-run restores a resolver that lost its volume. Needs ${g('ABX_RESOLVER_ADMIN_TOKEN')}.
5810
+ ${g('--remote [name|url]')} publish each render to a REMOTE resolver (the locator bridge: upload to ${bold('ABX_STORAGE_BACKEND')}, POST /v1/effect-artifacts).
5811
+ Idempotent; a re-run restores a resolver that lost its volume. Needs its token (a named remote's
5812
+ ${g('ABX_REMOTE_<NAME>_TOKEN')}, else ${g('ABX_RESOLVER_ADMIN_TOKEN')}).
4691
5813
  --effects-url <url> enqueue on a running effect-runner service instead of rendering inline (else ${g('ABX_EFFECTS_URL')})
4692
5814
  ${dim('Inline (no --remote) renders on THIS machine (needs `npx playwright install chromium` once), pointing Chromium at the live')}
4693
5815
  ${dim(`view of ${g('ABX_RESOLVER_URL')} (else your configured base / local ${g('abx serve')}), and stores to the resolved backend.`)}
@@ -4703,8 +5825,8 @@ const COMMAND_HELP = {
4703
5825
  --port <n> HTTP port (default ${g('ABX_EFFECTS_PORT')} / 8788) — ${g('POST /notify')} enqueues (watcher lane) · ${g('POST /run')} sweeps synchronously (command lane)
4704
5826
  --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
4705
5827
  --concurrency <n> parallel renders while draining (default ${g('ABX_EFFECTS_CONCURRENCY')} / 1 — Chromium is heavy; raise deliberately)
4706
- ${dim('Co-located with a local `abx serve` (same store) → no tokens needed. Against a HOSTED resolver: ABX_RESOLVER_URL +')}
4707
- ${dim('ABX_RESOLVER_ADMIN_TOKEN (publish renders + report status). PUBLIC runner? set ABX_EFFECTS_TOKEN — it gates /run + /notify.')}
5828
+ ${dim('Co-located with a local `abx serve` (same store) → no tokens needed. Against a REMOTE resolver: --remote <name|url> (its')}
5829
+ ${dim('token publishes renders + reports status), or ABX_RESOLVER_URL + ABX_RESOLVER_ADMIN_TOKEN. PUBLIC runner? set ABX_EFFECTS_TOKEN — it gates /run + /notify.')}
4708
5830
  ${dim('To HOST the runner (fly/docker), use `abx deploy-effects`.')}`,
4709
5831
  'configure-param': `
4710
5832
  ${bold('abx configure-param')} <address> <tokenId|-> <key> <value> ${dim('— set a PostParam (typed, canonical encode). Sends a tx.')}
@@ -4715,7 +5837,7 @@ const COMMAND_HELP = {
4715
5837
  like ${g('params.keys')} / ${g('display.gateway')}. ≤31 printable-ASCII chars ride as a literal bytes32; longer takes the data path.
4716
5838
  After a token write, a project whose ${g('params.keys')} doesn't list the key gets a one-line fix suggestion.
4717
5839
  --file <path> read the value from a file (String / Bytes payloads)
4718
- ${g('--remote [url]')} nudge a HOSTED resolver to re-index IMMEDIATELY after the change (else ABX_PUBLIC_BASE_URL) — it pings
5840
+ ${g('--remote [name|url]')} nudge a REMOTE resolver to re-index IMMEDIATELY after the change (else ABX_PUBLIC_BASE_URL) — it pings
4719
5841
  the resolver's effect runner, so the thumbnail re-renders without waiting. Usually OPTIONAL now: a
4720
5842
  resolver running the chain watcher (the ${g('abx serve')} default) sees the change on its next poll (~12s)
4721
5843
  and auto-re-renders on its own. Keep --remote for a watcher-disabled resolver or when seconds matter.
@@ -4752,7 +5874,8 @@ const COMMAND_HELP = {
4752
5874
  ${bold('abx state')} <address> ${dim('— read-only, on-chain operational snapshot (no tx, no local index).')}
4753
5875
  Shows owner · supply (minted / max, nextTokenId) · paused · minter · primary payee · royalty · renderer.
4754
5876
  Series-only fields are shown for a Series; a 1/1 shows just supply + royalty + renderer.
4755
- Handy before/after owner ops (mint · pause/unpause · set-minter · set-primary-payee).`,
5877
+ Handy before/after owner ops (mint · pause/unpause · set-minter · set-primary-payee).
5878
+ ${dim('state = what the CHAIN says. For who is SERVING it and how fresh that is, see `abx status`.')}`,
4756
5879
  predict: `
4757
5880
  ${bold('abx predict')} ${dim('— pre-compute a deploy address (read-only).')}
4758
5881
  --salt 0x..<64hex> a fixed / vanity salt --for 0x.. reserve to a deployer --factory 0x..`,
@@ -4866,18 +5989,31 @@ const COMMAND_HELP = {
4866
5989
  ${bold('abx set-max-invocations')} <address> --max <N> ${dim('— LOWER the supply cap (Series). Sends a tx.')}
4867
5990
  --max <N> the new cap — MONOTONIC: can only DECREASE, and never below what's already minted (else it reverts)`,
4868
5991
  doctor: `
4869
- ${bold('abx doctor')} ${dim('— preflight readiness: signing key/wallet, RPC health, canonical factory, storage. Read-only.')}
5992
+ ${bold('abx doctor')} ${dim('— preflight readiness: agent skill, signing key/wallet, RPC health, canonical factory, storage.')}
4870
5993
  --for 0x.. also report that address's balance (fund before signing)
5994
+ ${g('--fix')} ${dim('install/resync the agent skill without asking (the one thing doctor can repair)')}
5995
+ ${g('--global')} · ${g('--agent')} <a> ${dim('where --fix installs the skill (mirrors `abx skill install`)')}
5996
+ ${dim('Read-only apart from --fix. Interactively it OFFERS to install a missing/stale skill; a non-TTY')}
5997
+ ${dim('run changes nothing and just prints the command, so scripts and CI are never mutated.')}
4871
5998
  ${dim('a missing signing key is NOT fatal — the wallet lane (`--sign`) needs no key in `.env`.')}`,
4872
5999
  status: `
4873
- ${bold('abx status')} ${dim('— list the projects this node has indexed, plus node info. Read-only.')}`,
6000
+ ${bold('abx status')} [address] ${dim('— INDEXING status: where a project is in the lifecycle, and how fresh. Read-only.')}
6001
+ ${dim('bare')} this node: chain · factory · storage · data dir, then one line per project
6002
+ ${dim('<address>')} one project in detail: lifecycle · scan floor · blocks indexed vs head · why, if unhappy
6003
+ ${g('--remote')} [name|url] ask a SERVICE instead (your hosted node, or a managed provider) — same five words
6004
+ ${g('--watch')} poll until everything reaches a terminal state (${g('live')} or ${c.orange}failed${c.reset})
6005
+ lifecycle: ${dim('queued')} → ${dim('backfilling')} → ${g('live')} · ${c.orange}stale${c.reset} ${dim('(was live, now lagging — still serving)')} · ${c.orange}failed${c.reset} ${dim('(carries a cause; retried with backoff)')}
6006
+ ${dim('status = who is SERVING it and how fresh. For what the CHAIN says (owner, royalty, locks), see `abx state`.')}`,
4874
6007
  storage: `
4875
6008
  ${bold('abx storage')} <show|upload|balance|topup|backup-key> ${dim('— inspect / operate byte custody. Mostly read-only.')}
4876
6009
  ${g('show')} the resolved backend (fs | cloud | ipfs | arweave) + where each value came from
4877
6010
  ${g('upload')} <path> upload ONE file → prints its locator (the URI ${g('abx attach')} wants) [--backend …] [--dry-run]
4878
6011
  ${g('balance')} · ${g('topup')} --usd <n> Turbo (arweave) upload credits · ${g('backup-key')} --out <path> copy the managed key`,
4879
6012
  demo: `
4880
- ${bold('abx demo')} <${dim('no args')}> ${dim('— deploy a throwaway 1/1 to the testnet, index it, and serve it — a guided first run. Sends a tx.')}`,
6013
+ ${bold('abx demo')} <${dim('no args')}> ${dim('— deploy a throwaway 1/1 to the testnet, index it, and serve it — a guided first run. Sends a tx.')}
6014
+ ${g('--sign')} ${dim('approve in your browser wallet instead of a hot env key (no key needed)')}
6015
+ ${g('--for')} <0x…> ${dim('pin who must connect on --sign (owner + royalty receiver + mint recipient)')}
6016
+ ${dim('--unsigned / --dry-run are refused here: both skip the broadcast, and the demo indexes + serves what it deployed.')}`,
4881
6017
  minter: `
4882
6018
  ${bold('abx minter')} <configure|show|buy> <token> ${dim('— sell a Series via the shared fixed-price minter (Minter spine).')}
4883
6019
  ${g('configure')} <token> (--price <eth> | --price-raw <units>) --allocation <n> [--erc20 0x..] ${dim('(token-owner only)')}
@@ -4892,13 +6028,20 @@ const COMMAND_HELP = {
4892
6028
  ${bold('abx add')} <address> ${dim('— register + index a project. Also edits off-chain display metadata + traits.')}
4893
6029
  --from-block <n> --factory 0x.. --label "<s>" --description "<s>" --external-url <url> [--full] [--yes]
4894
6030
  --traits "K=V; K2=V2" / --attributes <file.json> set the off-chain operator traits (on-chain attributes always win)
4895
- ${g('--remote [url]')} target a HOSTED resolver instead of this machine — bridges the image locator (ipfs://…) + traits to it`,
6031
+ ${g('--remote [name|url]')} target a REMOTE resolver instead of this machine — bridges the image locator (ipfs://…) + traits to it.
6032
+ A ${bold('name')} reads ${g('ABX_REMOTE_<NAME>_URL')} + ${g('ABX_REMOTE_<NAME>_TOKEN')} from .env (a managed provider's API key);
6033
+ a URL (or bare --remote = ${g('ABX_PUBLIC_BASE_URL')}) uses ${g('ABX_RESOLVER_ADMIN_TOKEN')}. Inspect first: ${g('abx remote <name>')}
6034
+ --remote-token <t> override the token for this invocation (--token means a token ID elsewhere, hence the name)
6035
+ ${g('--no-wait')} ${dim('with --remote: return as soon as the service accepts it, instead of waiting out its catch-up.')}
6036
+ ${dim('A service may answer "accepted, still indexing" (202) for a long backfill; by default abx polls')}
6037
+ ${dim('to')} ${g('live')} ${dim('and prints the same summary. The registration is durable either way —')} ${g('abx status <addr> --remote')} ${dim('checks later.')}`,
4896
6038
  index: `
4897
6039
  ${bold('abx index')} [<address>] ${dim('— re-index from chain (read-only). Incremental by default.')}
4898
- ${g('--full')} force a full replay from the deploy block (the durability proof) --yes allow a very large scan`,
6040
+ ${g('--full')} force a full replay from the deploy block (the durability proof) --yes allow a very large scan
6041
+ ${g('--remote [name|url]')} re-index on a REMOTE resolver (the post-deploy nudge) ${g('--no-wait')} don't wait out a deferred catch-up`,
4899
6042
  verify: `
4900
6043
  ${bold('abx verify')} <address> ${dim('— re-hash the served bytes against the on-chain commitment (read-only; no server).')}
4901
- ${g('--remote [url]')} verify what a HOSTED resolver actually serves (else ABX_PUBLIC_BASE_URL) — probes its \`/image\` (302→locator
6044
+ ${g('--remote [name|url]')} verify what a REMOTE resolver actually serves (else ABX_PUBLIC_BASE_URL) — probes its \`/image\` (302→locator
4902
6045
  or 200 bytes), so it accounts for a render PUBLISHED to that resolver. ${bold('Use this for a code project whose')}
4903
6046
  ${bold('renders were published to a hosted resolver')} — a plain \`abx verify\` only checks THIS machine's store and will
4904
6047
  report a false placeholder for a render that lives on the resolver.
@@ -4906,13 +6049,30 @@ const COMMAND_HELP = {
4906
6049
  ${dim(`${g('onChainStatus')} (branch · chain-complete · unresolved refs · URL budget) and decodes ${g('tokenURI')} straight from the contract.`)}`,
4907
6050
  tokenuri: `
4908
6051
  ${bold('abx tokenuri')} <address> [--token <id>] ${dim('— read tokenURI(id) straight from the contract on-chain + decode the JSON (read-only; no server).')}
4909
- ${dim('The proof a fully on-chain token self-resolves: any RPC returns the renderer-assembled metadata. Default token 0.')}`,
6052
+ ${dim('The proof a fully on-chain token self-resolves: any RPC returns the renderer-assembled metadata. Default token 0.')}
6053
+ ${dim('Collection-level (ERC-7572) counterpart:')} ${g('abx contracturi <address>')}`,
6054
+ contracturi: `
6055
+ ${bold('abx contracturi')} <address> ${dim('— read contractURI() (ERC-7572 collection metadata) from the contract, FOLLOW it, and decode (read-only).')}
6056
+ ${dim('The collection-level counterpart of')} ${g('tokenuri')}${dim('. On-chain lane: decodes the data: URI. Off-chain lane: fetches the')}
6057
+ ${dim('URL the contract itself commits to and prints the JSON.')}
6058
+ ${bold('Never hand-build a resolver URL to check this')} ${dim('— the contract holds the answer, so a URL from here is right by')}
6059
+ ${dim('construction. A bad status is then about the SERVICE (unregistered project · wrong chain · down), never a mistyped path.')}`,
4910
6060
  forget: `
4911
- ${bold('abx forget')} <address> ${dim('— drop a project’s local registration + projection. On-chain data is untouched.')}`,
6061
+ ${bold('abx forget')} <address> ${dim('— drop a project’s local registration + projection. On-chain data is untouched.')}
6062
+ ${g('--remote [name|url]')} deregister on a REMOTE resolver instead (it stops serving the project; re-add any time)`,
6063
+ remote: `
6064
+ ${bold('abx remote')} [<name|url>] ${dim('— inspect a remote service (read-only; registers nothing).')}
6065
+ Bare: list the named remotes in .env (${g('ABX_REMOTE_<NAME>_URL')} / ${g('_TOKEN')} — token shown as set/unset, never printed)
6066
+ plus the self-host default (bare --remote = ${g('ABX_PUBLIC_BASE_URL')} + ${g('ABX_RESOLVER_ADMIN_TOKEN')}).
6067
+ With a target: fetch its PUBLIC ${g('/.well-known/abx-service')} descriptor — what it serves (interfaces), which chains
6068
+ (flags a mismatch with your ${g('ABX_CHAIN')}), whether ${bold('rendering is managed')} behind it (code drops then need no effects
6069
+ runner), and where a human gets an API key (${g('auth.signupUrl')}). With a token: lists the projects visible to it —
6070
+ ${bold('the one-command "is my provider key valid?" check')} (401 = fix the key · 403 = provider-side scoping, not a typo).`,
4912
6071
  migrate: `
4913
6072
  ${bold('abx migrate')} <address> ${dim('— move a contract\'s OFF-CHAIN state to another resolver (read-only on both; no cutover).')}
4914
- --from <url> the SOURCE resolver (currently serving the contract)
4915
- --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')})
6073
+ --from <name|url> the SOURCE resolver (currently serving the contract) — read via its PUBLIC api; no source credential needed
6074
+ --to <name|url> the DESTINATION resolver (its control plane) — the ONE credential migrate needs: a named remote's
6075
+ ${g('ABX_REMOTE_<NAME>_TOKEN')}, else ${g('ABX_RESOLVER_ADMIN_TOKEN')} (your own node's, from ${g('deploy-resolver')}), else --remote-token
4916
6076
  --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
4917
6077
  --backend <id> durable custody for re-pinning source-only images (ipfs · arweave); else config/env
4918
6078
  ${dim('replays on-chain state on the dest from chain, then bridges description / external_url / off-chain')}
@@ -4962,16 +6122,20 @@ function help() {
4962
6122
  --minter 0x.. · --primary-payee 0x.. · --unpaused · ${g('--dry-run')} · see ${g('abx help deploy-series')}
4963
6123
  ${g('abx predict')} pre-compute a deploy address flags: [--salt 0x..] [--for 0x..] [--factory 0x..]
4964
6124
  ${g('abx add')} <address> register + index a project this node didn't deploy
4965
- flags: --from-block --factory --label
6125
+ flags: --from-block --factory --label · ${g('--remote <name|url>')} registers on a REMOTE resolver instead
6126
+ ${g('abx remote')} [<name|url>] inspect a remote service: its descriptor (chains · managed rendering · where to get a key) + your projects there
4966
6127
  ${g('abx index')} [<address>] re-index from chain (incremental by default; ${g('--full')} forces a replay from deploy)
4967
6128
  ${g('abx verify')} <addr> re-hash served bytes vs the on-chain commitment (no server needed)
4968
6129
  ${g('abx tokenuri')} <addr> read tokenURI(0) on-chain + decode the JSON (proof a self-resolving token works)
6130
+ ${g('abx contracturi')} <addr> read contractURI() (ERC-7572 collection metadata) on-chain, follow it, decode — never hand-build the URL
4969
6131
  ${g('abx state')} <addr> one-glance on-chain snapshot: owner · supply · paused · minter · payee · royalty · renderer
4970
6132
  ${g('abx serve')} [--port ..] serve the token API + dashboard — and WATCH the chain: auto-index every registered
4971
6133
  project + notify the effects layer on change (${g('ABX_WATCH_INTERVAL_MS')}; 0 = off)
4972
6134
 
4973
6135
  ${bold('code / generative projects')} ${dim('— a program is the content; output is a function of live on-chain state')}
4974
- ${g('abx inspect')} <script.js> ${bold('start here')} — static analysis (traits + on-chain reproducibility, deps, doc size RPC viability) + a lane recommendation
6136
+ ${g('abx preview')} (--script <f> | --code-dir <d>) ${bold('while you are still making it')} — run the program on localhost, live: refresh for new seeds,
6137
+ drive your PostParams, watch it animate. Same document the generator serves. ${g('--shoot <dir>')} for headless frames. No chain.
6138
+ ${g('abx inspect')} <script.js> ${bold('before you pick a lane')} — static analysis (traits + on-chain reproducibility, deps, doc size → RPC viability) + a lane recommendation
4975
6139
  ${g('abx scaffold-renderer')} [<dir>] write a buildable Foundry project for the ${bold('in-chain Solidity art lane')} (seed + PostParam → on-chain SVG + traits; you forge build/test/deploy)
4976
6140
  ${g('abx deploy-code')} (--script <file> | --code-dir <dir> | ${g('--image-renderer 0x..')}) deploy a ${bold('generative / code project')} (on-chain script, a build directory, or a Solidity SVG renderer — in-chain art)
4977
6141
  ${bold('--public-base-url <url>')} OR ${bold('--onchain-uri')} · --schema key:Type:Auth · ${g('--dep')} name@version|0x.. (ordered; index 0 = the runtime) ·
@@ -5017,13 +6181,13 @@ function help() {
5017
6181
  ${g('abx storage')} topup buy Turbo credits by card --usd <n> (one-time; <100 KB is always free)
5018
6182
  ${g('abx storage')} backup-key copy the managed Turbo/Arweave key (holds credits) to a safe path --out <path>
5019
6183
  ${g('abx forget')} <address> drop a project's local registration + projection (on-chain untouched)
5020
- ${g('abx status')} list indexed projects + node info
6184
+ ${g('abx status')} [address] indexing status: lifecycle + freshness (bare = this node; ${g('--remote')} [name] = a service; ${g('--watch')} to tail)
5021
6185
  ${g('abx doctor')} check environment (key, RPC, balance, factory, storage)
5022
- ${g('abx skill install')} install the abx agent skill into your agent [--global] [--target <dir>] · ${g('abx skill path')} prints the bundled skill
6186
+ ${g('abx skill install')} install the version-locked abx skill into your agent(s) [--agent <name>] [--global] [--target <dir>] · ${g('abx skill path')} prints the bundled skill
5023
6187
  ${g('abx version')} print the installed CLI version
5024
6188
 
5025
6189
  ${dim('Run')} ${g('abx <command> --help')} ${dim('for per-command usage. --help / -h never executes — it only prints usage.')}
5026
- ${dim('abx checks npm for a newer release (once a day, notify-only). Silence with')} ${g('ABX_NO_UPDATE_CHECK=1')} ${dim('or')} ${g('--no-update-check')}${dim('.')}
6190
+ ${dim('abx checks npm for a newer release (every 6h, notify-only). Silence with')} ${g('ABX_NO_UPDATE_CHECK=1')} ${dim('or')} ${g('--no-update-check')}${dim('.')}
5027
6191
  `);
5028
6192
  }
5029
6193
  /** Validate an explicit --salt (a 32-byte hex). Undefined when absent. */
@@ -5041,8 +6205,13 @@ function parseSaltFlag(raw) {
5041
6205
  // ── skill: install the abx agent skill into the user's coding agent ───────────────────────
5042
6206
  // The skill is the agentic half of the toolkit — the CLI is only useful once an agent knows how
5043
6207
  // to drive it. It ships bundled inside the published package (packages/cli/skill/, created at
5044
- // prepack); in dev the same command reads the canonical copy straight from the repo. A separate
5045
- // cross-agent channel is `npx skills add ArtBlocks/abx` (the Agent Skills standard, git-based).
6208
+ // prepack); in dev the same command reads the canonical copy straight from the repo. This is the
6209
+ // CANONICAL install path precisely because the bundle is version-locked to the CLI it ships inside
6210
+ // (the version lives in SKILL.md frontmatter — see update-check.ts). By default it writes to BOTH
6211
+ // `.claude/skills` (Claude Code) and the neutral `.agents/skills` (Cursor · Codex · Gemini ·
6212
+ // Copilot all read it), so one command covers the whole ecosystem; `--agent` narrows it. A separate
6213
+ // git-based channel, `npx skills add ArtBlocks/abx --skill abx-self-host`, works once the repo is public but is NOT
6214
+ // version-locked to a local CLI — prefer `abx skill install`.
5046
6215
  /** Locate the skill folder: the bundled copy beside the compiled CLI (published), else the
5047
6216
  * canonical repo copy (dev). Returns null if neither is present. */
5048
6217
  function resolveBundledSkill() {
@@ -5106,37 +6275,86 @@ function cmdScaffoldRenderer(rest, flags) {
5106
6275
  console.log(` ${g('abx deploy-code --image-renderer <MyRenderer> --attributes-renderer <MyTraits> --onchain-uri --schema palette:HexColor:TokenOwner --name "…" --symbol …')}`);
5107
6276
  info(`full walkthrough: ${bold(`${basename(dir)}/README.md`)} · interface + invariants: https://abx.docs.artblocks.io/protocol/renderers/`);
5108
6277
  }
6278
+ /** Human label for each skills-parent dir — which agents pick the skill up from there. */
6279
+ const SKILL_PARENT_LABELS = {
6280
+ '.claude/skills': 'Claude Code',
6281
+ '.agents/skills': 'Cursor · Codex · Gemini · Copilot',
6282
+ };
6283
+ /** Resolve which skills-parent dirs `install` should write to. No `--agent` → the whole-ecosystem
6284
+ * default (Claude Code + the neutral `.agents/skills` everyone else reads). `--agent a,b` narrows
6285
+ * to those agents' dirs (de-duped, since several share `.agents/skills`). */
6286
+ function resolveInstallParents(agentFlag) {
6287
+ if (!agentFlag || agentFlag === 'true')
6288
+ return ['.claude/skills', '.agents/skills'];
6289
+ const parents = new Set();
6290
+ for (const raw of agentFlag.split(',')) {
6291
+ const a = raw.trim().toLowerCase();
6292
+ const parent = AGENT_SKILL_PARENTS[a];
6293
+ if (!parent) {
6294
+ throw new Error(`unknown --agent '${a}'. Use one of: ${Object.keys(AGENT_SKILL_PARENTS).join(', ')} (or omit --agent to install for every agent).`);
6295
+ }
6296
+ parents.add(parent);
6297
+ }
6298
+ return [...parents];
6299
+ }
6300
+ /** Copy the bundled skill folder to `dest`, replacing any prior copy so a re-install after an
6301
+ * upgrade never leaves stale reference files behind. */
6302
+ function installSkillTo(src, dest) {
6303
+ mkdirSync(joinPath(dest, '..'), { recursive: true });
6304
+ rmSync(dest, { recursive: true, force: true });
6305
+ cpSync(src, dest, { recursive: true });
6306
+ }
6307
+ /**
6308
+ * Install the bundled skill into the default per-agent parents (or `~` with `global`), reporting
6309
+ * each destination. Shared by `abx skill install` and `abx doctor`'s offer to fix a missing/stale
6310
+ * skill — one implementation, so the two can't drift on where the skill lands or what it prints.
6311
+ */
6312
+ function installSkillToDefaults(src, opts = {}) {
6313
+ const version = readSkillVersion(joinPath(src, 'SKILL.md')) ?? readCliVersion();
6314
+ const base = opts.global ? homedir() : process.cwd();
6315
+ const parents = resolveInstallParents(opts.agent);
6316
+ ok(`installed the abx skill v${version}${opts.global ? ' (global, ~)' : ''}:`);
6317
+ for (const parent of parents) {
6318
+ const dest = joinPath(base, parent, SKILL_DIR_NAME);
6319
+ installSkillTo(src, dest);
6320
+ const label = SKILL_PARENT_LABELS[parent];
6321
+ console.log(` ${g(joinPath(parent, SKILL_DIR_NAME))}${label ? dim(' → ' + label) : ''}`);
6322
+ }
6323
+ info('restart your agent so it loads the skill, then ask it to launch an NFT with abx.');
6324
+ info(`the skill is version-locked to this CLI (v${version}); re-run ${g('abx skill install')} after upgrading so the two stay in sync.`);
6325
+ return version;
6326
+ }
6327
+ /** The default skill destinations, as a display string — what doctor's prompt has to name up front. */
6328
+ function defaultSkillDests(opts = {}) {
6329
+ const prefix = opts.global ? '~/' : './';
6330
+ return resolveInstallParents(opts.agent).map((p) => `${prefix}${p}`).join(' and ');
6331
+ }
5109
6332
  async function cmdSkill(rest, flags) {
5110
6333
  const sub = rest[0] ?? 'install';
5111
6334
  const src = resolveBundledSkill();
5112
6335
  if (!src) {
5113
6336
  throw new Error('bundled skill not found (expected <pkg>/skill or .claude/skills/abx-self-host). ' +
5114
- 'Reinstall @artblocks/abx-cli, or install cross-agent with: npx skills add ArtBlocks/abx');
6337
+ 'Reinstall @artblocks/abx-cli, or install cross-agent with: npx skills add ArtBlocks/abx --skill abx-self-host');
5115
6338
  }
5116
6339
  if (sub === 'path') {
5117
6340
  console.log(src);
5118
6341
  return;
5119
6342
  }
5120
6343
  if (sub === 'install') {
5121
- const base = flags.global !== undefined ? homedir() : flags.target ?? process.cwd();
5122
- const destParent = joinPath(base, '.claude', 'skills');
5123
- const dest = joinPath(destParent, 'abx-self-host');
5124
- mkdirSync(destParent, { recursive: true });
5125
- cpSync(src, dest, { recursive: true });
5126
- // Stamp the CLI version so a later run can notice the skill has drifted behind an upgraded CLI
5127
- // (the two are co-versioned). Best-effort — a missing marker just means "no drift nudge".
5128
- try {
5129
- writeFileSync(joinPath(dest, SKILL_VERSION_MARKER), readCliVersion());
5130
- }
5131
- catch {
5132
- /* non-fatal */
6344
+ const version = readSkillVersion(joinPath(src, 'SKILL.md')) ?? readCliVersion();
6345
+ // Escape hatch: --target <dir> writes the skill folder straight under <dir> (for an agent
6346
+ // whose skills dir we don't special-case, or a bespoke location).
6347
+ if (typeof flags.target === 'string' && flags.target !== 'true') {
6348
+ const dest = joinPath(resolvePath(flags.target), SKILL_DIR_NAME);
6349
+ installSkillTo(src, dest);
6350
+ ok(`installed the abx skill v${version} ${dest}`);
6351
+ info('restart your agent so it loads the skill, then ask it to launch an NFT with abx.');
6352
+ return;
5133
6353
  }
5134
- ok(`installed the abx skill ${dest}`);
5135
- info('restart your agent (Claude Code / Cursor) so it loads the skill, then ask it to launch an NFT with abx.');
5136
- info('cross-agent alternative (git-based): npx skills add ArtBlocks/abx');
6354
+ installSkillToDefaults(src, { global: flags.global !== undefined, agent: flags.agent });
5137
6355
  return;
5138
6356
  }
5139
- throw new Error('usage: abx skill <install|path> [--global] [--target <dir>]');
6357
+ throw new Error('usage: abx skill <install|path> [--agent claude|cursor|codex|gemini|copilot] [--global] [--target <dir>]');
5140
6358
  }
5141
6359
  main().catch((err) => {
5142
6360
  // Keep the actionable message; strip viem's verbose boilerplate trailer (Docs:/Version: lines) so