@artblocks/abx-cli 0.1.0-alpha.15 → 0.1.0-alpha.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.js CHANGED
@@ -17,12 +17,14 @@
17
17
  * abx preview run a code project on localhost while it's still being made (no chain)
18
18
  * abx deploy-code deploy a code project (SeriesCode): --script <file> (on-chain template)
19
19
  * or --code-dir <dir> (build directory → ipfs/arweave `code` field)
20
+ * --resume <addr>: finish one whose setup tx failed (sends only what's missing)
20
21
  * abx add <address> register + index a project (--remote <name|url>: on a remote resolver, not this machine)
21
22
  * abx remote [<name|url>] inspect a remote service (descriptor · chains · managed rendering · your projects there)
22
23
  * abx index [<address>] re-index a project from chain (replay; --remote to nudge a remote resolver)
23
24
  * abx verify <address> re-hash served bytes vs the on-chain commitment (no server)
24
25
  * abx tokenuri <address> read tokenURI(id) from the contract + decode · contracturi: the ERC-7572 collection JSON
25
26
  * (the contract holds the URL — never hand-build a resolver path)
27
+ * abx tokens <address> every token's owner + seed + params, from chain alone (--json for the machine path)
26
28
  * abx configure-param <addr> <id> <key> <value> set a governed PostParam (typed encode; any lane)
27
29
  * abx set-param-hooks <addr> wire/clear a SeriesCode's configure/augment/transfer param hooks
28
30
  * abx render <addr> [id…] effect-runner repair lane: render missing stills/traits
@@ -34,6 +36,7 @@
34
36
  * abx migrate <addr> move a contract's off-chain state to another resolver (--from/--to)
35
37
  * abx set-token-uri <addr> · set-contract-uri · set-royalty · set-field · lock-field
36
38
  * abx storage show show byte custody (fs | cloud | ipfs | arweave); stateless — choose per command with flags
39
+ * abx storage status <loc> is a locator RETRIEVABLE yet, not just accepted? (Arweave propagation is minutes)
37
40
  * abx storage balance|topup Turbo (arweave) upload credits — view / buy by card (credits attach to the signing identity)
38
41
  * abx storage backup-key copy the managed Turbo/Arweave key (holds credits) to a safe path
39
42
  * abx status list indexed projects + node info
@@ -55,14 +58,18 @@ import { effectsArtifact, resolverArtifact } from './provision.js';
55
58
  import { mintPageArtifact, PUBLIC_RPC } from './mintpage.js';
56
59
  import { formatEther, toHex, zeroAddress } from 'viem';
57
60
  import { encodeFunctionData } from 'viem';
58
- import { DEP_RESOLUTION, deploySeedSource, deploySeriesCodeFactory, prepareCodeSetup, prepareDeploySeriesCode, resolveGenerator, resolveSeedSource, resolveSeriesCodeFactory, seriesCodeAbi, readParamSchema, seriesCodeFactoryAbi, } from '@artblocks/abx-sdk';
61
+ import { AUTH_OPTIONS, DEP_RESOLUTION, PARAM_TYPES, deploySeedSource, deploySeriesCodeFactory, listTokens, prepareCodeSetup, prepareDeploySeriesCode, resolveGenerator, resolveSeedSource, resolveSeriesCodeFactory, seriesCodeAbi, readParamSchema, seriesCodeFactoryAbi, } from '@artblocks/abx-sdk';
59
62
  import { checkRegistryDeps, dependencySetupCalls, parseDepFlag, resolveDepRegistryPointer } from './deps.js';
60
63
  import { expectedChainComplete, hasOnChainUriLane, onchainUriSetupCalls, onChainUriReport, readLegacyParamsKeys, readParamSchemaKeys, readSetParamKeys, } from './onchain-uri.js';
61
- import { parseFlags, unknownFlags } from './flags.js';
64
+ import { parseFlags, positionalArgs, unknownFlags } from './flags.js';
62
65
  import { AGENT_SKILL_PARENTS, checkForCliUpdate, compareVersions, installedSkillVersions, readCliVersion, readSkillVersion, SKILL_DIR_NAME, } from './update-check.js';
63
66
  import { analyzeScript, recommendLane } from './inspect.js';
64
67
  import { previewConfigFromFlags, previewDepTags, parsePreviewParams, shootPreview, startPreviewServer, DEFAULT_PREVIEW_PORT, PREVIEW_FLAGS } from './preview.js';
65
68
  import { parseSchemaSpecs, describeSchema } from './schema.js';
69
+ import { copyRendererScaffold } from './scaffold.js';
70
+ import { jsonSafe, withJson } from './jsonout.js';
71
+ import { fetchServedTokenUri, prettyBody, servedOk } from './served.js';
72
+ import { planResume } from './resume.js';
66
73
  import { declinesSkillInstall } from './prompt.js';
67
74
  import { pinGas, waitForCodeAt } from './gas.js';
68
75
  import { parseSeriesTraits, looksPerTokenAttributes, parseSeriesTraitsById } from './series-traits.js';
@@ -77,7 +84,7 @@ import { uploadAndLocate } from './upload.js';
77
84
  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, KNOWN_CHAIN_KEYS, normalizeAttributes, parseTraitPairs, resolveRecommendedTransferValidator, CREATOR_TOKEN_INTERFACE_ID, METADATA_FIELD as F, METADATA_REPRESENTATION as R, indexProgress, isAccepted, AbxIndexTimeoutError, AbxServiceError, } from '@artblocks/abx-sdk';
78
85
  import { SelfHostIndexer, SqliteStore } from '@artblocks/abx-indexer';
79
86
  import { artContentHash, currentRenderArtifact, generateArt, resolveBaseUrl, startChainWatcher, startTokenApiServer, verifyProject, watchIntervalMs, DEFAULT_PORT, } from '@artblocks/abx-token-api';
80
- import { ARWEAVE_FREE_UPLOAD_LIMIT, arweaveAddress, arweaveFunding, contentTypeFromPath, hashContent, resolveBackend, turboBalanceForAddress, turboUploadCostUsd, turboUploadWinc } from '@artblocks/abx-storage';
87
+ import { ARWEAVE_FREE_UPLOAD_LIMIT, arweaveAddress, arweaveFunding, contentTypeFromPath, hashContent, locatorStatus, resolveBackend, turboBalanceForAddress, turboUploadCostUsd, turboUploadWinc } from '@artblocks/abx-storage';
81
88
  import { cmdTransfer, cmdMint, cmdSetMinter, cmdSetMaxInvocations, cmdConfigureParam, cmdSetSchema, cmdRetireParam, cmdSetParamHooks, cmdSetDependency, cmdRemoveLastDependency, cmdSetDependencyRegistry, cmdLockDependencies, cmdSetPrimaryPayee, cmdPause, cmdUnpause, cmdRefresh, cmdSetTokenUri, cmdSetContractUri, cmdSetRoyalty, cmdSetTransferValidator, cmdSetField, cmdAttach, parseTransferValidatorValue, 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';
82
89
  import { openWalletSession, signTx } from './signer.js';
83
90
  import { describeRemoteError, listConfiguredRemotes, misnamedRemoteVars, requireRemoteToken, resolveRemote, serviceClient, tokenSourceLabel } from './remote.js';
@@ -161,9 +168,16 @@ async function maybeNotifyUpdate(flags) {
161
168
  }
162
169
  const latest = await checkForCliUpdate(current);
163
170
  if (latest) {
171
+ // The notes pointer must RESOLVE. This printed github.com/ArtBlocks/abx/releases, which 404s
172
+ // for everyone outside the org (the repo is private) — so a tester reconstructed the diff by
173
+ // running the same dry run on both versions, which is how they discovered the canonical
174
+ // singletons had moved and then had no way to tell whether that needed them to act. The
175
+ // packaged CHANGELOG.md ships with every install (see package.json `files`) and carries the
176
+ // real per-release notes, so it's readable offline and always matches the version you have.
164
177
  console.error(`\n ${c.orange}⚠${c.reset} update available: ${bold('abx')} ${dim(current)} → ${g(latest)}\n` +
165
178
  ` upgrade: ${g('npm i -g @artblocks/abx-cli@latest')} ${dim('· or invoke:')} ${g('npx @artblocks/abx-cli@latest <command>')}\n` +
166
- ` release notes: https://github.com/ArtBlocks/abx/releases ${dim('· silence: ABX_NO_UPDATE_CHECK=1')}\n`);
179
+ ` release notes: ${g('abx changelog')} ${dim('(ships with the CLI) · all versions: https://www.npmjs.com/package/@artblocks/abx-cli?activeTab=versions')}\n` +
180
+ ` ${dim('silence: ABX_NO_UPDATE_CHECK=1')}\n`);
167
181
  }
168
182
  }
169
183
  catch {
@@ -212,13 +226,15 @@ async function main() {
212
226
  case 'inspect': return cmdInspect(rest[0], flags);
213
227
  case 'preview': return cmdPreview(flags);
214
228
  case 'scaffold-renderer': return cmdScaffoldRenderer(rest, flags);
229
+ case 'changelog': return cmdChangelog(flags);
215
230
  case 'predict': return cmdPredict(flags);
216
231
  case 'add': return cmdAdd(rest[0], flags);
217
232
  case 'index': return cmdIndex(rest[0], flags);
218
233
  case 'verify': return cmdVerify(rest[0], flags);
219
234
  case 'render': return cmdRender(rest[0], rest.slice(1), flags);
220
235
  case 'effects': return cmdEffects(flags);
221
- case 'tokenuri': return cmdTokenUri(rest[0], flags);
236
+ case 'tokenuri': return cmdTokenUri(rest[0], flags, rest.slice(1));
237
+ case 'tokens': return cmdTokens(rest[0], flags);
222
238
  case 'contracturi': return cmdContractUri(rest[0], flags);
223
239
  case 'serve': return cmdServe(flags);
224
240
  // owner operations — write + sign (hot/wallet/cold lane), then re-index
@@ -1152,6 +1168,11 @@ async function remoteLocators(address, stored, flags) {
1152
1168
  }
1153
1169
  // ── deploy (+ optionally serve) ──────────────────────────────────────────────
1154
1170
  async function cmdDeploy(flags, serveAfter) {
1171
+ // `--json`: the deployed ADDRESS is the value a program came for (B19). Narration goes to stderr,
1172
+ // so `ADDR=$(abx deploy … --json | jq -r .address)` works without parsing a success banner.
1173
+ return withJson(flags, async (emit) => cmdDeployBody(flags, serveAfter, emit));
1174
+ }
1175
+ async function cmdDeployBody(flags, serveAfter, emit) {
1155
1176
  refuseStrayFlags(flags, DEPLOY_FLAGS, 'deploy');
1156
1177
  warnSignWithoutFor(flags);
1157
1178
  const dimension = resolveDimension(flags.type ?? '1of1');
@@ -1202,6 +1223,8 @@ async function cmdDeploy(flags, serveAfter) {
1202
1223
  ` Nothing was deployed. Stop that one (Ctrl-C), or run this on another port: \`abx demo --port ${wanted + 1}\`.`);
1203
1224
  }
1204
1225
  }
1226
+ // (demo keeps its own message: "Nothing was deployed" is the load-bearing part here, and it must
1227
+ // be true — this check runs before anything irreversible. assertPortFree covers serve/preview.)
1205
1228
  const dryRun = !serveAfter && !!flags['dry-run']; // preview only — no send, no custody, no factory deploy
1206
1229
  // A keyless preview needs `--for` (the address is a pure function of factory+salt+deployer). Check
1207
1230
  // it HERE, before the trust-anchor/content/plan steps print — hitting this after a wall of output
@@ -1516,6 +1539,10 @@ async function cmdDeploy(flags, serveAfter) {
1516
1539
  // values — nothing is added, inferred, or substituted between here and the on-chain tx.
1517
1540
  info(dim('the values above are exactly what a real deploy writes — nothing else is added.'));
1518
1541
  console.log(`\n ${g('dry run')} ${dim('— nothing sent, no bytes stored. Re-run without --dry-run to deploy.')}\n`);
1542
+ // A dry run DOES know the address (it is a pure function of factory+salt), so --json reports it
1543
+ // with `sent: false` rather than nothing. Note `saltPinned`: without --salt a real re-run
1544
+ // reserves a fresh salt and lands elsewhere, so a caller must not treat this as reserved.
1545
+ emit(jsonSafe({ command: 'deploy', dryRun: true, sent: false, address: predicted, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, salt, saltPinned: !!explicitSalt, name, symbol }));
1519
1546
  return;
1520
1547
  }
1521
1548
  // opt-in --confirm: a final y/N before the real send (no-op without --confirm; never blocks scripts)
@@ -1629,6 +1656,9 @@ async function cmdDeploy(flags, serveAfter) {
1629
1656
  blockNumber = result.blockNumber;
1630
1657
  ok(`deployed ${clone}`);
1631
1658
  }
1659
+ // Emitted the moment the address is known, and again below once minting is settled — so a crash in
1660
+ // the indexing steps that follow still yields the address of a contract that really does exist.
1661
+ emit(jsonSafe({ command: 'deploy', address: clone, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, deployBlock: blockNumber, name, symbol }));
1632
1662
  step(serveAfter ? 'What the chain knows now' : 'Index it — replay the event spine from chain');
1633
1663
  const indexer = new SelfHostIndexer();
1634
1664
  // Off-chain traits ride in the registration (on-chain ones are already in the contract fields).
@@ -1764,6 +1794,9 @@ const tokenFieldOf = (tokenId, f) => ({
1764
1794
  value: f.value,
1765
1795
  });
1766
1796
  async function cmdDeploySeries(flags) {
1797
+ return withJson(flags, async (emit) => cmdDeploySeriesBody(flags, emit));
1798
+ }
1799
+ async function cmdDeploySeriesBody(flags, emit) {
1767
1800
  refuseStrayFlags(flags, DEPLOY_SERIES_FLAGS, 'deploy-series');
1768
1801
  warnSignWithoutFor(flags);
1769
1802
  const name = flags.name ?? 'ABX Series';
@@ -2104,6 +2137,7 @@ async function cmdDeploySeries(flags) {
2104
2137
  console.log(` ${bold(deploySeriesCommandLine(flags, salt))}`);
2105
2138
  }
2106
2139
  console.log(`\n ${g('dry run')} ${dim('— nothing sent, no bytes stored.')}\n`);
2140
+ emit(jsonSafe({ command: 'deploy-series', dryRun: true, sent: false, address: predicted, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, salt, saltPinned: !!explicitSalt, name, symbol }));
2107
2141
  return;
2108
2142
  }
2109
2143
  let clone;
@@ -2198,6 +2232,7 @@ async function cmdDeploySeries(flags) {
2198
2232
  blockNumber = result.blockNumber;
2199
2233
  ok(`deployed ${clone}`);
2200
2234
  }
2235
+ emit(jsonSafe({ command: 'deploy-series', address: clone, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, deployBlock: blockNumber, name, symbol }));
2201
2236
  step('Index it — replay the event spine from chain');
2202
2237
  const indexer = new SelfHostIndexer();
2203
2238
  const baseReg = {
@@ -2881,6 +2916,21 @@ async function walkthroughReadBack(state, baseUrl, onChainUri) {
2881
2916
  }
2882
2917
  console.log(` ${dim('prove the bytes match the chain:')} ${bold(`abx verify ${state.address}`)}`);
2883
2918
  }
2919
+ /**
2920
+ * Refuse a port that's already bound, with a formatted one-liner naming the port and the fix.
2921
+ *
2922
+ * Without this, `listen()` has no `'error'` handler and EADDRINUSE reaches Node's default handler:
2923
+ * the creator gets a raw stack trace through `node:net` and our own `dist/` paths, which reads as a
2924
+ * crash inside abx rather than "something else is on this port" — and it's the one unformatted error
2925
+ * surface in a CLI where every other error is formatted. `preview`'s default port colliding with a
2926
+ * studio left running in another terminal was reported as especially hard to diagnose.
2927
+ */
2928
+ async function assertPortFree(port, cmd) {
2929
+ if (!(await portInUse(port)))
2930
+ return;
2931
+ throw new Error(`port ${port} is already in use — most likely an \`abx ${cmd}\`/\`abx serve\`/\`abx preview\` still running in another terminal.\n` +
2932
+ ` Stop that one (Ctrl-C), or run this on a different port: \`abx ${cmd} --port ${port + 1}\`.`);
2933
+ }
2884
2934
  /** Is a TCP port already bound on localhost? Used to preflight a serve BEFORE spending a tx. */
2885
2935
  async function portInUse(port) {
2886
2936
  const { createServer } = await import('node:net');
@@ -2957,7 +3007,11 @@ async function cmdPreview(flags) {
2957
3007
  if (cfg.deps.some((d) => d.display.startsWith('0x'))) {
2958
3008
  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.');
2959
3009
  }
2960
- const server = await startPreviewServer(cfg, shootDir ? 0 : Number(flags.port ?? DEFAULT_PREVIEW_PORT));
3010
+ // `--shoot` takes an ephemeral port (0), so only the studio lane can collide.
3011
+ const previewPort = shootDir ? 0 : Number(flags.port ?? DEFAULT_PREVIEW_PORT);
3012
+ if (previewPort !== 0)
3013
+ await assertPortFree(previewPort, 'preview');
3014
+ const server = await startPreviewServer(cfg, previewPort);
2961
3015
  if (shootDir) {
2962
3016
  step(`Render ${count} seeds headlessly`);
2963
3017
  try {
@@ -3115,12 +3169,15 @@ const DEPLOY_CODE_FLAGS = new Set([
3115
3169
  'sign', 'unsigned', 'for', 'salt', 'factory', 'bootstrap-factory', 'sign-url-file',
3116
3170
  // preview / confirm
3117
3171
  'dry-run', 'confirm', 'yes',
3172
+ 'json', // B19: the deployed ADDRESS as data, narration to stderr
3173
+ 'resume', // B15: finish an EXISTING contract whose setup tx never landed (no deploy)
3118
3174
  ]);
3119
3175
  // Storage + signing + preview flags shared by every deploy path (mirrors storageOverrides() + the lanes).
3120
3176
  const SHARED_DEPLOY_FLAGS = [
3121
3177
  'backend', 'endpoint', 'bucket', 'region', 'prefix', 'public-base', 'gateway', 'mode', 'api-url', 'upload-url', 'provider', 'storage-signer',
3122
3178
  'sign', 'unsigned', 'for', 'sign-url-file', 'dry-run', 'confirm', 'yes', 'salt', 'factory', 'bootstrap-factory', 'port', 'renderer', 'public-base-url',
3123
3179
  '721c', // opt-in ERC-721C enrollment (recommended | 0x…) — every deploy path takes it
3180
+ 'json', // B19: the deployed ADDRESS as data, narration to stderr — every deploy path takes it
3124
3181
  ];
3125
3182
  // A 1/1 `abx deploy` accepts these (see `abx help deploy`). Anything else warns (typo'd/unsupported).
3126
3183
  const DEPLOY_FLAGS = new Set([
@@ -3196,6 +3253,9 @@ function warnSignWithoutFor(flags) {
3196
3253
  }
3197
3254
  }
3198
3255
  async function cmdDeployCode(flags) {
3256
+ return withJson(flags, async (emit) => cmdDeployCodeBody(flags, emit));
3257
+ }
3258
+ async function cmdDeployCodeBody(flags, emit) {
3199
3259
  const usage = 'abx deploy-code (--script <file> | --code-dir <dir>) --name "Title" --symbol SYM ' +
3200
3260
  '(--public-base-url https://your.resolver.domain | --onchain-uri) ' +
3201
3261
  '[--description "<s>"] [--external-url <url>] [--image-base <url> | --image-renderer 0x..] [--attributes-renderer 0x..] ' +
@@ -3690,7 +3750,11 @@ async function cmdDeployCode(flags) {
3690
3750
  }
3691
3751
  // --dry-run: emit the full plan (the on-chain values the skill formats into its confirm table),
3692
3752
  // then stop — no upload, no factory/seed deploy, no send.
3693
- if (dryRun) {
3753
+ //
3754
+ // `--resume` skips this preview entirely and previews its OWN plan further down. Without the guard,
3755
+ // `--resume 0xLive --dry-run` printed a fresh-deploy plan complete with a newly-reserved salt and a
3756
+ // different predicted address — a preview of the exact thing the flag exists not to do.
3757
+ if (dryRun && flags.resume === undefined) {
3694
3758
  // The deployer is needed ONLY to derive the CREATE2 salt → the deterministic address (and the
3695
3759
  // pinned-salt re-run line). Everything else in the plan — content, deps, chain-complete
3696
3760
  // expectation, tx count, cost — is deployer-independent, so a wallet-less creator (the common
@@ -3800,7 +3864,13 @@ async function cmdDeployCode(flags) {
3800
3864
  catch { /* gas pricing unavailable — cost guidance is a bonus, skip silently */ }
3801
3865
  // ── Surfaces — every marketplace-facing dimension, resolved NOW (none is backfillable) ──────
3802
3866
  const renderHome = process.env.ABX_STORAGE_BACKEND || 'fs'; // empty string counts as unset (=fs)
3803
- const anySurfaceBroken = imageOrphaned || traitsBroken || undeclaredParams.length > 0;
3867
+ // A surface is BROKEN only when it resolves to nothing a marketplace can see. A dropped param is
3868
+ // NOT that: the token still renders, the param just takes its default. Folding it in here made
3869
+ // the block contradict itself two lines apart — "thumbnail: ON-CHAIN ✓ / traits: on-chain ✓"
3870
+ // followed by "one or more surfaces resolve to NOTHING", re-recommending the very flags that
3871
+ // were already set. The alarm now covers only the surfaces it can honestly speak for, and names
3872
+ // only the remedies for what is actually broken.
3873
+ const anySurfaceBroken = imageOrphaned || traitsBroken;
3804
3874
  step('Surfaces — what marketplaces will see (all DEPLOY-TIME; not backfillable)');
3805
3875
  (imageOrphaned ? warn : info)(`thumbnail: ${imageDisposition}`);
3806
3876
  // Render-mode / render-home guidance is for the RENDERED-still lanes only. The in-chain
@@ -3818,12 +3888,25 @@ async function cmdDeployCode(flags) {
3818
3888
  (traitsBroken ? warn : info)(`traits: ${traitsDisposition}`);
3819
3889
  const paramsNudge = !scriptAnalysis && !schemas.length && (hasImageRenderer || hasAttributesRenderer);
3820
3890
  ((undeclaredParams.length || paramsNudge ? warn : info))(`postparams: ${paramsDisposition}`);
3821
- if (anySurfaceBroken)
3822
- warn(`${bold('one or more surfaces resolve to NOTHING a marketplace can see')} — fix before deploy (these are on-chain decisions you can't add later without a re-point tx): thumbnail ⇒ ${bold('--image-renderer <Solidity SVG>')} / ${bold('--image-base <public bucket>')} / a resolver · traits ⇒ ${bold('--attributes-renderer')} or a resolver · dropped params ⇒ ${bold('--schema')}.`);
3823
- // The runner/verify line is for rendered-still lanes. Fully in-chain (image on-chain, no program)
3824
- // has nothing to run point at the from-chain check instead.
3825
- if (hasImageRenderer && !hasProgram)
3826
- info(` ${g('nothing to run')} verify from chain: ${bold('abx tokenuri ' + (predicted ?? '<address>'))} ${dim('(decodes name + on-chain SVG + traits)')}`);
3891
+ if (anySurfaceBroken) {
3892
+ const remedies = [
3893
+ imageOrphaned && `thumbnail ${bold('--image-renderer <Solidity SVG>')} / ${bold('--image-base <public bucket>')} / a resolver`,
3894
+ traitsBroken && `traits ${bold('--attributes-renderer')} or a resolver`,
3895
+ ].filter(Boolean).join(' · ');
3896
+ const which = imageOrphaned && traitsBroken ? 'the thumbnail and traits surfaces resolve' : `the ${imageOrphaned ? 'thumbnail' : 'traits'} surface resolves`;
3897
+ warn(`${bold(`${which} to NOTHING a marketplace can see`)} — fix before deploy (a deploy-time decision you can't add later without a re-point tx): ${remedies}.`);
3898
+ }
3899
+ // A dropped param is its own, milder problem: the piece renders, that input just takes its
3900
+ // default. Kept separate from the broken-surface alarm above (see the note there).
3901
+ if (undeclaredParams.length)
3902
+ info(` ${dim('dropped params render with their defaults — declare them to make them real:')} ${bold('--schema <key>:<Type>:<Auth>')}`);
3903
+ // The runner/verify line is for lanes that need an off-chain STILL. An on-chain image renderer has
3904
+ // no still to render, host, or refresh — whether or not there's also a program driving
3905
+ // animation_url — so pointing at a runner and a bucket backend there is simply wrong.
3906
+ if (hasImageRenderer) {
3907
+ info(` ${g('nothing to render')} — the thumbnail is computed on-chain${hasProgram ? ' and the animation assembles on-chain from your script' : ''}; no runner, no bucket, no refresh. ` +
3908
+ `verify from chain: ${bold('abx tokenuri ' + (predicted ?? '<address>'))} ${dim('(decodes name + on-chain SVG + traits)')}`);
3909
+ }
3827
3910
  else {
3828
3911
  const remoteRender = !(onChainUri && hasImageBase);
3829
3912
  info(` ${dim('stand up the runner:')} ${bold('abx deploy-effects --resolver-url ' + baseUrl)} · one-shot: ${bold('abx render ' + (predicted ?? '<address>') + (remoteRender ? ' --remote ' + baseUrl : ''))} · verify: ${bold('abx verify ' + (predicted ?? '<address>'))}`);
@@ -3844,6 +3927,9 @@ async function cmdDeployCode(flags) {
3844
3927
  info(`pass ${bold('--for 0x..')} to see the exact deterministic address + a pinned-salt re-run command.`);
3845
3928
  }
3846
3929
  console.log(`\n ${g('dry run')} ${dim('— nothing sent, no bytes stored. Re-run without --dry-run to deploy.')}\n`);
3930
+ // `address` is null in the wallet lane with no --for: the signer decides the salt, so no address
3931
+ // exists yet. Reporting null is the honest answer; inventing one would be a wrong reservation.
3932
+ emit(jsonSafe({ command: 'deploy-code', dryRun: true, sent: false, address: predicted ?? null, chain: CHAIN, chainId: resolveChain(CHAIN).id, factory, salt: salt ?? null, saltPinned: !!parseSaltFlag(flags.salt), name, symbol, onChainUri }));
3847
3933
  return;
3848
3934
  }
3849
3935
  // Real deploy: perform the deferred directory upload now (past the confirm gate).
@@ -3892,12 +3978,39 @@ async function cmdDeployCode(flags) {
3892
3978
  tokenFields: [],
3893
3979
  contractFields,
3894
3980
  });
3895
- const setupCalls = (owner) => [
3896
- ...scriptChunks.map((chunk, i) => encodeFunctionData({ abi: seriesCodeAbi, functionName: 'setScriptChunk', args: [BigInt(i), chunk] })),
3897
- ...schemas.map(({ key, paramType, auth, authAddress, lockAfter, min, max, selectOptions }) => encodeFunctionData({ abi: seriesCodeAbi, functionName: 'setParamSchema', args: [encodeTag(key), paramType, auth, authAddress, lockAfter, min, max, selectOptions] })),
3898
- ...dependencySetupCalls(deps, depRegistry), // setDependency legs in index order (+ the soft registry pointer)
3899
- ...(onchainUriLegs ?? []), // program lane only: animation_url field · the URI renderers (before the mints)
3900
- ].concat(Array.from({ length: setupMintCount }, () => encodeFunctionData({ abi: seriesCodeAbi, functionName: 'mint', args: [owner] }))); // folded mints ride init, not here
3981
+ // The setup legs, GROUPED. The normal deploy flattens them; `--resume` diffs them against chain
3982
+ // state and sends only what is missing (see resume.ts). One builder for both, so a resume can never
3983
+ // drift from what a fresh deploy would have written a second implementation of this sequence is
3984
+ // the failure mode a repair verb most easily introduces.
3985
+ const setupLegGroups = (owner) => ({
3986
+ chunks: scriptChunks.map((chunk, i) => ({
3987
+ index: i,
3988
+ hex: chunk,
3989
+ data: encodeFunctionData({ abi: seriesCodeAbi, functionName: 'setScriptChunk', args: [BigInt(i), chunk] }),
3990
+ })),
3991
+ schemas: schemas.map(({ key, paramType, auth, authAddress, lockAfter, min, max, selectOptions }) => ({
3992
+ key,
3993
+ data: encodeFunctionData({ abi: seriesCodeAbi, functionName: 'setParamSchema', args: [encodeTag(key), paramType, auth, authAddress, lockAfter, min, max, selectOptions] }),
3994
+ })),
3995
+ deps: { count: deps.length, registry: depRegistry ?? null, calls: dependencySetupCalls(deps, depRegistry) },
3996
+ // program lane only: animation_url field · the URI renderers (before the mints)
3997
+ uri: { calls: onchainUriLegs ?? [], animationField: onchainUriLegs?.length ? F.animationUrl : null },
3998
+ // The INTENDED TOTAL, not a count to add — a resume mints the shortfall. `mintCount` covers both
3999
+ // lanes: folded-into-init (renderer-only) and setup-carried, since either way it is what the
4000
+ // creator asked for.
4001
+ mints: { intendedTotal: mintCount, data: encodeFunctionData({ abi: seriesCodeAbi, functionName: 'mint', args: [owner] }) },
4002
+ });
4003
+ const setupCalls = (owner) => {
4004
+ const g = setupLegGroups(owner);
4005
+ return [
4006
+ ...g.chunks.map((c) => c.data),
4007
+ ...g.schemas.map((x) => x.data),
4008
+ ...g.deps.calls,
4009
+ ...g.uri.calls,
4010
+ // folded mints ride init, not here — so this uses setupMintCount, not the intended total.
4011
+ ...Array.from({ length: setupMintCount }, () => g.mints.data),
4012
+ ];
4013
+ };
3901
4014
  const cid = resolveChain(CHAIN).id;
3902
4015
  const preparedFor = async (owner) => {
3903
4016
  const salt = parseSaltFlag(flags.salt) ?? saltFor(owner);
@@ -3908,6 +4021,139 @@ async function cmdDeployCode(flags) {
3908
4021
  txs.push(prepareCodeSetup({ contract: clone, calls, chainId: cid, chunkCount: scriptChunks.length, chunkBytes: scriptChunks.map((h) => (h.length - 2) / 2), schemaKeys: schemas.map((x) => x.key), deps: deps.map((d) => d.display), onchainUri: onChainUri && hasProgram }));
3909
4022
  return { clone, txs };
3910
4023
  };
4024
+ // ── --resume: finish an EXISTING contract, deploy nothing (backlog B15) ─────────────────────
4025
+ // A code deploy is two transactions. When the second fails you own a live-but-unusable contract and
4026
+ // the salt reserved for its address is spent, so the dry run's pinned-salt reproduce command can
4027
+ // never be run again. The contract is recoverable, not lost — a tester proved that by resending the
4028
+ // setup by hand with `cast`. This is that, as a verb: read what is missing, send only that.
4029
+ const resumeAddr = flags.resume;
4030
+ if (resumeAddr !== undefined) {
4031
+ if (!/^0x[0-9a-fA-F]{40}$/.test(resumeAddr)) {
4032
+ throw new Error(`--resume wants the address of the contract to finish; '${resumeAddr}' isn't a 0x address.`);
4033
+ }
4034
+ const target = resumeAddr;
4035
+ const code = await publicClient.getCode({ address: target }).catch(() => undefined);
4036
+ if (!code || code === '0x') {
4037
+ throw new Error(`--resume ${target}: no contract at that address on ${CHAIN} (asked ${redactRpcUrl(resolveRpcUrl(CHAIN))}). ` +
4038
+ `There is nothing to finish — if the DEPLOY tx is what failed, run a normal deploy instead. ` +
4039
+ `If you JUST deployed, give the tx a block or two to mine.`);
4040
+ }
4041
+ // Deploy-time-only flags are refused rather than ignored: --salt/--721c/--bootstrap-factory all
4042
+ // describe how a contract is CREATED, and this creates nothing. 721C especially — enrollment is
4043
+ // permanent and deploy-time-only, so accepting the flag here would imply it can be added later.
4044
+ for (const [flag, why] of [
4045
+ ['salt', 'the address already exists, so no salt is used'],
4046
+ ['721c', 'ERC-721C enrollment is deploy-time-only and PERMANENT — it cannot be added to an existing collection'],
4047
+ ['bootstrap-factory', 'no factory is involved: nothing is being created'],
4048
+ ['mint-all', 'use --mint-count <n> on a resume; --mint-all is resolved against the cap at deploy time'],
4049
+ ]) {
4050
+ if (flags[flag] !== undefined)
4051
+ throw new Error(`--resume cannot be combined with --${flag}: ${why}.`);
4052
+ }
4053
+ const owner = await publicClient
4054
+ .readContract({ address: target, abi: seriesCodeAbi, functionName: 'owner' })
4055
+ .catch(() => undefined);
4056
+ if (!owner)
4057
+ throw new Error(`--resume ${target}: could not read owner() — is this an ABX SeriesCode contract?`);
4058
+ step('Resume — read what is missing, send only that');
4059
+ info(`target ${bold(target)} ${dim(`· owner ${owner}`)}`);
4060
+ // Every read is a view on the target; the intended legs come from the SAME builder a fresh deploy
4061
+ // uses, so what gets sent is by construction what the deploy would have written.
4062
+ const legs = setupLegGroups(owner);
4063
+ const readAt = async (functionName, args = []) => {
4064
+ try {
4065
+ return (await publicClient.readContract({ address: target, abi: seriesCodeAbi, functionName, args }));
4066
+ }
4067
+ catch {
4068
+ return null;
4069
+ }
4070
+ };
4071
+ const nonZero = (a) => (a && a !== zeroAddress ? a : null);
4072
+ const reader = {
4073
+ scriptChunkCount: async () => Number((await readAt('scriptChunkCount')) ?? 0n),
4074
+ scriptChunk: (index) => readAt('scriptChunk', [BigInt(index)]),
4075
+ schemaExists: async (key) => {
4076
+ const r = await readAt('paramSchema', [encodeTag(key)]);
4077
+ return !!r?.[0];
4078
+ },
4079
+ dependencyCount: async () => Number((await readAt('dependencyCount')) ?? 0n),
4080
+ dependencyRegistry: async () => nonZero(await readAt('dependencyRegistry')),
4081
+ tokenURIRenderer: async () => nonZero(await readAt('tokenURIRenderer')),
4082
+ contractURIRenderer: async () => nonZero(await readAt('contractURIRenderer')),
4083
+ contractFieldSet: async (field) => {
4084
+ const r = await readAt('contractField', [encodeTag(field)]);
4085
+ return !!r && r[0] !== `0x${'0'.repeat(64)}`;
4086
+ },
4087
+ totalSupply: async () => Number((await readAt('totalSupply')) ?? 0n),
4088
+ };
4089
+ const plan = await planResume(reader, legs);
4090
+ for (const line of plan.done)
4091
+ ok(line);
4092
+ for (const line of plan.todo)
4093
+ info(`will send — ${line}`);
4094
+ if (!plan.calls.length) {
4095
+ ok(`nothing missing — this contract's setup is already complete. ${dim('Confirm with `abx verify ' + target + '`.')}`);
4096
+ emit(jsonSafe({ command: 'deploy-code', resumed: target, chain: CHAIN, chainId: resolveChain(CHAIN).id, sent: false, complete: true, sentLegs: 0 }));
4097
+ console.log('');
4098
+ return;
4099
+ }
4100
+ const tx = prepareCodeSetup({
4101
+ contract: target,
4102
+ calls: plan.calls,
4103
+ chainId: resolveChain(CHAIN).id,
4104
+ chunkCount: plan.sending.chunkIndices.length,
4105
+ chunkBytes: plan.sending.chunkBytes,
4106
+ schemaKeys: plan.sending.schemaKeys,
4107
+ deps: plan.sending.deps ? deps.map((d) => d.display) : [],
4108
+ onchainUri: plan.sending.uri,
4109
+ });
4110
+ if (flags['dry-run']) {
4111
+ console.log(`\n ${bold('\u25c6 ' + tx.summary)} ${dim('(dry run — nothing sent)')}`);
4112
+ for (const [k, v] of Object.entries(tx.fields))
4113
+ console.log(` ${dim(k.padEnd(14))} ${v}`);
4114
+ console.log(dim(`\n Re-run without --dry-run to send (lane: ${lane}).\n`));
4115
+ emit(jsonSafe({ command: 'deploy-code', resumed: target, chain: CHAIN, chainId: resolveChain(CHAIN).id, dryRun: true, sent: false, complete: false, sentLegs: plan.calls.length }));
4116
+ return;
4117
+ }
4118
+ await confirmSend(`finish setup on ${target} (${plan.calls.length} call(s), one transaction)`, flags);
4119
+ // Signed through the same choke point as every other write, so --sign / --unsigned / the owner
4120
+ // check / the chain-id guard all apply exactly as they do to a deploy.
4121
+ const result = await signTx(() => tx, {
4122
+ lane,
4123
+ chainKey: CHAIN,
4124
+ yes: !!flags.yes,
4125
+ expectedSigner: owner,
4126
+ port: flags.port ? Number(flags.port) : undefined,
4127
+ signUrlFile: flags['sign-url-file'],
4128
+ });
4129
+ if (!result) {
4130
+ console.log(`\n${dim(' unsigned — broadcast it, then re-run `abx verify ' + target + '` to confirm the setup completed.')}\n`);
4131
+ emit(jsonSafe({ command: 'deploy-code', resumed: target, chain: CHAIN, chainId: resolveChain(CHAIN).id, sent: false, complete: false, sentLegs: plan.calls.length }));
4132
+ return;
4133
+ }
4134
+ ok(`${tx.summary}`);
4135
+ // Locals, not the outer clone/deployBlock: this branch never falls through to the deploy path, and
4136
+ // the authoritative scan floor is still the clone's CREATION block (getCode search), not this
4137
+ // repair tx's block — flooring the resolver above the deploy would hide the `code` field again.
4138
+ const resumedFloor = (await discoverDeployBlock(publicClient, target)) ?? result.blockNumber;
4139
+ emit(jsonSafe({
4140
+ command: 'deploy-code',
4141
+ resumed: target,
4142
+ address: target,
4143
+ chain: CHAIN,
4144
+ chainId: resolveChain(CHAIN).id,
4145
+ deployBlock: resumedFloor,
4146
+ sent: true,
4147
+ complete: true,
4148
+ sentLegs: plan.calls.length,
4149
+ txHash: result.txHash,
4150
+ }));
4151
+ step('Index');
4152
+ await cmdAdd(target, { 'from-block': resumedFloor.toString(), factory, label: name });
4153
+ console.log(`\n ${g('\u2713 setup finished')} \u2014 ${dim('confirm it resolves:')} ${bold(`abx verify ${target}`)}` +
4154
+ `${onChainUri ? dim(' (chain-complete + the on-chain tokenURI)') : ''}\n`);
4155
+ return;
4156
+ }
3911
4157
  let clone;
3912
4158
  let deployBlock;
3913
4159
  if (lane === 'unsigned') {
@@ -3990,6 +4236,20 @@ async function cmdDeployCode(flags) {
3990
4236
  if (discovered !== null)
3991
4237
  deployBlock = discovered;
3992
4238
  ok(`SeriesCode live: ${clone} ${dim(`(from block ${deployBlock})`)}`);
4239
+ // Emitted here rather than at the end: a code deploy is TWO transactions, and if the second (setup)
4240
+ // fails, the address of the live-but-incomplete contract is the single most valuable thing a caller
4241
+ // can be told — it is exactly the input `--resume <address>` takes to finish the job.
4242
+ emit(jsonSafe({
4243
+ command: 'deploy-code',
4244
+ address: clone,
4245
+ chain: CHAIN,
4246
+ chainId: resolveChain(CHAIN).id,
4247
+ factory,
4248
+ deployBlock,
4249
+ name,
4250
+ symbol,
4251
+ onChainUri,
4252
+ }));
3993
4253
  step('Index');
3994
4254
  await cmdAdd(clone, { 'from-block': deployBlock.toString(), factory, label: name });
3995
4255
  if (onChainUri) {
@@ -4149,6 +4409,34 @@ async function cmdRender(address, tokenIds, flags) {
4149
4409
  warn(`${summary}\n ${dim(stats.errors[0] ?? 'see error above')}`);
4150
4410
  else
4151
4411
  ok(summary);
4412
+ if (stats.ran)
4413
+ noteArweavePropagation(flags);
4414
+ }
4415
+ /**
4416
+ * After an Arweave publish, say that a fresh 404 at the gateway is PROPAGATION, not a failed render.
4417
+ *
4418
+ * `arweave.net` lags Turbo uploads by minutes: a tester found 32/32 of their renders 404ing there
4419
+ * while Turbo reported CONFIRMED and 22/32 already served fine from other ar.io gateways. The bytes
4420
+ * were never in doubt — only the gateway was behind. Unexplained, a "broken" thumbnail on a fresh
4421
+ * drop reads as a failed render, and the natural next move is `abx render --force` on all of them:
4422
+ * a full re-upload that fixes nothing.
4423
+ *
4424
+ * Note this is advisory only, and deliberately so — the locator is baked into what the resolver
4425
+ * registers at publish time, so it cannot be repaired by a redirect later. Choosing the gateway is
4426
+ * the operator's call (`ABX_ARWEAVE_GATEWAY`), which is why this names it.
4427
+ */
4428
+ function noteArweavePropagation(flags) {
4429
+ const opts = storageOptions(storageOverrides(flags));
4430
+ if (resolveBackend(opts).id !== 'arweave')
4431
+ return;
4432
+ const gateway = opts.arweave?.gateway ?? 'https://arweave.net';
4433
+ if (!/(^|\/\/)([^/]*\.)?arweave\.net/.test(gateway))
4434
+ return; // a gateway they chose — don't lecture
4435
+ info(`${dim('arweave: the locator points at')} ${gateway}${dim(', which can 404 for several minutes after upload while it catches up. That is PROPAGATION, not a failed render — the bytes are already confirmed. Do NOT re-run with')} ${bold('--force')}${dim('; the URL starts working on its own.')}`);
4436
+ info(` ${dim('to bake a different gateway into the locator instead (it is fixed at publish time):')} ${bold('ABX_ARWEAVE_GATEWAY=https://<gateway>')} ${dim('before you render.')}`);
4437
+ // The note is advisory; this is the answer. Naming it here is the difference between "wait and
4438
+ // hope" and a check a script can gate on — which is what both reporters ended up building.
4439
+ info(` ${dim('to KNOW rather than wait:')} ${bold('abx storage status <locator>')} ${dim('— ready vs propagating vs unreachable, exits non-zero until it serves.')}`);
4152
4440
  }
4153
4441
  /**
4154
4442
  * The publish topology's one hard prerequisite, checked BEFORE any capture.
@@ -4283,13 +4571,19 @@ async function cmdIndex(address, flags) {
4283
4571
  // so an agent can confirm integrity right after deploy/mint without curling.
4284
4572
  async function cmdVerify(address, flags) {
4285
4573
  if (!address || address.startsWith('--')) {
4286
- console.error('usage: abx verify <address>\n');
4574
+ console.error('usage: abx verify <address> [--json] [--remote <name|url>]\n');
4287
4575
  process.exit(1);
4288
4576
  }
4289
4577
  allowLargeScan(flags);
4290
4578
  const remote = remoteFlag(flags);
4291
4579
  if (remote)
4292
4580
  return cmdVerifyRemote(address, remote);
4581
+ // `--json` matters most here of all the read commands: verify already exits non-zero on a byte
4582
+ // mismatch, so it is the one command a CI job would gate on — and gating on prose is what B19 is
4583
+ // about. The payload's `ok` is the same boolean the exit code carries.
4584
+ return withJson(flags, async (emit) => cmdVerifyBody(address, flags, emit));
4585
+ }
4586
+ async function cmdVerifyBody(address, flags, emit) {
4293
4587
  const indexer = new SelfHostIndexer();
4294
4588
  let state = indexer.getProject(address);
4295
4589
  if (indexer.store.getRegistration(address)) {
@@ -4300,6 +4594,20 @@ async function cmdVerify(address, flags) {
4300
4594
  }
4301
4595
  if (!state)
4302
4596
  throw new Error(`could not load state for ${address}`);
4597
+ // Accumulated as the checks run, re-emitted at each stage so a payload survives an early return.
4598
+ const verifyReport = {
4599
+ address,
4600
+ chain: CHAIN,
4601
+ name: state.name ?? null,
4602
+ canonical: state.isCanonical, // TRUE tri-state: true | false | null (couldn't check)
4603
+ owner: state.owner ?? null,
4604
+ contentChecks: [],
4605
+ contentIntegrity: 'no-commitments',
4606
+ renders: null,
4607
+ onChainUri: null,
4608
+ ok: true,
4609
+ };
4610
+ emit(verifyReport);
4303
4611
  console.log(bold(`\n verify ${state.name ?? address}`));
4304
4612
  // isCanonical is a TRUE TRI-STATE (true | false | null) and collapsing it lost the only
4305
4613
  // distinction that matters: "the chain says this is NOT a clone of the configured factory" is a
@@ -4315,6 +4623,15 @@ async function cmdVerify(address, flags) {
4315
4623
  const tok = state.tokens.find((s) => s.tokenId === t.tokenId);
4316
4624
  info(`token #${t.tokenId}: ${tok?.minted ? 'minted' : 'not yet minted'}`);
4317
4625
  for (const ch of t.checks) {
4626
+ verifyReport.contentChecks.push({
4627
+ tokenId: t.tokenId,
4628
+ minted: !!tok?.minted,
4629
+ kind: ch.kind,
4630
+ // tri-state again: true (re-hashed, matched) · false (MISMATCH) · null (pointer-only,
4631
+ // nothing local to recompute). Collapsing null into false would report a normal state as a
4632
+ // failure, which is the bug this command exists to not have.
4633
+ verified: ch.verified,
4634
+ });
4318
4635
  anyCheck = true;
4319
4636
  if (ch.verified === true)
4320
4637
  ok(`${ch.kind} matches on-chain commitment — content is authentic`);
@@ -4328,6 +4645,9 @@ async function cmdVerify(address, flags) {
4328
4645
  }
4329
4646
  if (!anyCheck && !isCodeProject(state))
4330
4647
  info('no content commitments on this project');
4648
+ verifyReport.contentIntegrity = !anyCheck ? 'no-commitments' : allGood ? 'ok' : 'mismatch';
4649
+ verifyReport.ok = !anyCheck || allGood;
4650
+ emit(verifyReport);
4331
4651
  // Code projects have no on-chain `image` commitment (the still is rendered off-chain by the
4332
4652
  // effect runner), so the hash checks above are silent for them. Instead report what actually
4333
4653
  // matters after mint: does a REAL thumbnail exist yet, or is the marketplace image still the
@@ -4361,14 +4681,36 @@ async function cmdVerify(address, flags) {
4361
4681
  // `abx verify <addr> --remote <resolver>` probes what the resolver actually serves (the truthful check).
4362
4682
  if (minted.length)
4363
4683
  info(dim(`render check is against THIS node's store; for a HOSTED drop use \`abx verify ${address} --remote <resolver>\``));
4684
+ // ONE line per outcome, not per token. This printed the same full-sentence advisory 32 times on a
4685
+ // 32-token project (~4KB of identical text) and pushed the four lines that answer "did my deploy
4686
+ // work" off the top of the screen; at a 1000-token supply it is unreadable. The per-token detail
4687
+ // that survives is the token LIST, which is the only part that differs.
4688
+ const missing = [];
4689
+ let present = 0;
4364
4690
  for (const token of minted) {
4365
4691
  const { found } = await currentRenderArtifact(client, state, token, storageForRender, 'image');
4366
4692
  if (found)
4367
- ok(`token #${token.tokenId} image: real render present (in this node's store)`);
4368
- else {
4369
- renderGap = true;
4370
- console.log(` ${c.orange}⚠${c.reset} token #${token.tokenId} image: no render in THIS node's store — if you published to a hosted resolver, check it with \`abx verify ${address} --remote <resolver>\`; else render it: \`abx render ${address}\` (once) or \`abx effects\` (continuous)`);
4371
- }
4693
+ present++;
4694
+ else
4695
+ missing.push(String(token.tokenId));
4696
+ }
4697
+ verifyReport.renders = {
4698
+ minted: minted.length,
4699
+ present,
4700
+ missing, // token ids, so a caller can re-render exactly these
4701
+ // Named for what it IS: this node's store. A render PUBLISHED to a hosted resolver lives
4702
+ // there, not here, so `missing` is a false negative for a hosted drop — hence the scope.
4703
+ scope: "this node's store",
4704
+ };
4705
+ emit(verifyReport);
4706
+ if (present)
4707
+ ok(`${present}/${minted.length} minted token(s): real render present (in this node's store)`);
4708
+ if (missing.length) {
4709
+ renderGap = true;
4710
+ const ids = missing.length > 12 ? `${missing.slice(0, 12).join(', ')}, …+${missing.length - 12} more` : missing.join(', ');
4711
+ console.log(` ${c.orange}⚠${c.reset} ${missing.length}/${minted.length} token(s) have no render in THIS node's store ${dim(`(#${ids})`)}`);
4712
+ console.log(` ${dim('published to a hosted resolver? check there:')} ${bold(`abx verify ${address} --remote <resolver>`)}`);
4713
+ console.log(` ${dim('else render them:')} ${bold(`abx render ${address}`)} ${dim('(once) ·')} ${bold('abx effects')} ${dim('(continuous)')}`);
4372
4714
  }
4373
4715
  }
4374
4716
  // The on-chain URI lane (a non-zero tokenURIRenderer, or an animation field pointing at the
@@ -4380,6 +4722,18 @@ async function cmdVerify(address, flags) {
4380
4722
  try {
4381
4723
  const report = await onChainUriReport(client, state, flags.generator);
4382
4724
  const { status, probe } = report;
4725
+ verifyReport.onChainUri = jsonSafe({
4726
+ generator: report.generator,
4727
+ tokenURIRenderer: state.tokenURIRenderer ?? null,
4728
+ branch: status.branchName,
4729
+ chainComplete: !!status.chainComplete,
4730
+ unresolvedRefs: [...status.unresolvedRefs],
4731
+ urlOverBudget: !!status.urlOverBudget,
4732
+ probe: probe
4733
+ ? { tokenId: probe.tokenId, onChainJson: !!probe.onChainJson, animation: probe.animation }
4734
+ : null,
4735
+ });
4736
+ emit(verifyReport);
4383
4737
  info(`on-chain URI lane: generator ${report.generator}${state.tokenURIRenderer ? ` · tokenURI renderer ${state.tokenURIRenderer}` : ' · tokenURIRenderer NOT set (animation field only — tokenURI still resolves off-chain)'}`);
4384
4738
  if (status.branchName === 'template') {
4385
4739
  ok(`generator branch: template — the document assembles from the on-chain script chunks`);
@@ -4642,15 +4996,28 @@ function decodeOnChainJson(uri, verbatim = false) {
4642
4996
  return verbatim ? raw : raw.slice(0, 600);
4643
4997
  }
4644
4998
  }
4645
- async function cmdTokenUri(address, flags) {
4999
+ async function cmdTokenUri(address, flags, extra = []) {
4646
5000
  if (!address || address.startsWith('--')) {
4647
- console.error('usage: abx tokenuri <address> [--token <id>] [--json]\n');
5001
+ console.error('usage: abx tokenuri <address> [--token <id>] [--fetch] [--json]\n');
4648
5002
  process.exit(1);
4649
5003
  }
4650
5004
  if (!/^0x[0-9a-fA-F]{40}$/.test(address)) {
4651
5005
  console.error(`abx tokenuri: '${address}' isn't a 0x contract address.\n`);
4652
5006
  process.exit(1);
4653
5007
  }
5008
+ // `abx tokenuri <addr> 0` silently ignored the `0` and printed token 0 — a COINCIDENTALLY correct
5009
+ // answer, which is the dangerous kind: `… <addr> 7` would have printed token 0 just as confidently
5010
+ // and exited 0. The token id is a flag here, so name it rather than guessing at intent.
5011
+ const strayPositionals = positionalArgs(extra);
5012
+ if (strayPositionals.length) {
5013
+ const first = strayPositionals[0];
5014
+ const looksLikeTokenId = /^\d+$/.test(first);
5015
+ console.error(`abx tokenuri: unexpected extra argument '${first}'.` +
5016
+ (looksLikeTokenId
5017
+ ? ` The token id is a flag — did you mean:\n abx tokenuri ${address} --token ${first}\n`
5018
+ : `\n usage: abx tokenuri <address> [--token <id>] [--fetch] [--json]\n`));
5019
+ process.exit(1);
5020
+ }
4654
5021
  const tokenId = BigInt(flags.token ?? '0');
4655
5022
  const publicClient = makePublicClient({ chainKey: CHAIN });
4656
5023
  // A creator verifying their work often runs this on a wrong / not-yet-mined address — turn viem's
@@ -4679,10 +5046,33 @@ async function cmdTokenUri(address, flags) {
4679
5046
  }
4680
5047
  process.exit(1);
4681
5048
  }
5049
+ // --fetch: FOLLOW the URL the contract commits to and print what is actually served (backlog B14).
5050
+ // The gap this closes: `tokenuri` read the chain, `verify` re-hashed bytes, `status` reported the
5051
+ // lifecycle — none of them showed the served JSON body, so an agent in a cold sweep fell back to raw
5052
+ // `curl`. It also makes a provider mismatch self-evident without a warning that cries wolf: if you
5053
+ // registered with one provider while a different base is baked on-chain, the fetch shows you what
5054
+ // the BAKED base returns (often a 404, or another project's document entirely).
5055
+ const served = flags.fetch !== undefined ? await fetchServedTokenUri(uri) : null;
4682
5056
  // --json: the verbatim document, nothing else on stdout — no banner, no ANSI, no truncation, so
4683
5057
  // `abx tokenuri <addr> --json | jq` is a supported read path rather than something to regex.
4684
5058
  if (flags.json !== undefined) {
4685
- process.stdout.write((decodeOnChainJson(uri, true) ?? uri) + '\n');
5059
+ const onChain = decodeOnChainJson(uri, true);
5060
+ if (served === null) {
5061
+ process.stdout.write((onChain ?? uri) + '\n');
5062
+ return;
5063
+ }
5064
+ // With --fetch the payload has to say WHICH answer is which: a marketplace reads the served body,
5065
+ // and conflating it with the on-chain URI is the confusion the flag exists to remove.
5066
+ process.stdout.write(JSON.stringify({
5067
+ tokenId: tokenId.toString(),
5068
+ contract: address,
5069
+ chain: CHAIN,
5070
+ tokenURI: uri,
5071
+ onChain: onChain !== null,
5072
+ served,
5073
+ }, null, 2) + '\n');
5074
+ if (served.status !== null && (served.status < 200 || served.status >= 300))
5075
+ process.exitCode = 1;
4686
5076
  return;
4687
5077
  }
4688
5078
  console.log(`\n ${bold(`tokenURI(${tokenId})`)} ${dim(`— read directly from ${address} on ${CHAIN}, no server`)}`);
@@ -4694,6 +5084,36 @@ async function cmdTokenUri(address, flags) {
4694
5084
  else {
4695
5085
  console.log(` ${uri.slice(0, 240)}${uri.length > 240 ? dim(`… (${uri.length} chars)`) : ''}\n`);
4696
5086
  }
5087
+ if (served)
5088
+ reportServedTokenUri(served, address);
5089
+ }
5090
+ /** The human readout for `--fetch`. */
5091
+ function reportServedTokenUri(served, address) {
5092
+ if (served.skipped) {
5093
+ info(`--fetch: ${served.skipped}`);
5094
+ return;
5095
+ }
5096
+ console.log(` ${bold('served')} ${dim(`— GET ${served.url}`)}`);
5097
+ if (served.status === null) {
5098
+ warn(`the baked URL did not respond: ${served.error}. That host is what a marketplace will ask, so this is what they see too.`);
5099
+ return;
5100
+ }
5101
+ const line = `HTTP ${served.status}${served.contentType ? dim(` · ${served.contentType.split(';')[0]}`) : ''}`;
5102
+ if (servedOk(served))
5103
+ ok(line);
5104
+ else
5105
+ console.log(` ${c.orange}\u26a0${c.reset} ${line}`);
5106
+ const pretty = prettyBody(served.body);
5107
+ const clipped = pretty.length > 1200 ? `${pretty.slice(0, 1200)}\n \u2026 (${pretty.length} chars \u2014 --json for the full body)` : pretty;
5108
+ console.log(clipped.split('\n').map((l) => ' ' + l).join('\n'));
5109
+ if (!servedOk(served)) {
5110
+ // The mismatch case, stated where it is actionable rather than as a standing warning that would
5111
+ // cry wolf on the common custom-domain setup.
5112
+ console.log(`\n ${dim('the contract commits to that URL, so a non-2xx is about the SERVICE, never a mistyped path:')} ` +
5113
+ `${dim('the project may not be registered there, the service may serve a different chain, or the base baked on-chain may point at a provider you never registered with.')}\n` +
5114
+ ` ${dim('check the lifecycle:')} ${bold(`abx status ${address} --remote <name|url>`)}`);
5115
+ }
5116
+ console.log('');
4697
5117
  }
4698
5118
  // ── contracturi ──────────────────────────────────────────────────────────────
4699
5119
  /**
@@ -4793,6 +5213,7 @@ async function cmdContractUri(address, _flags) {
4793
5213
  // ── serve ──────────────────────────────────────────────────────────────────--
4794
5214
  async function cmdServe(flags) {
4795
5215
  const port = Number(flags.port ?? process.env.ABX_PORT ?? DEFAULT_PORT);
5216
+ await assertPortFree(port, 'serve');
4796
5217
  const baseUrl = resolveBaseUrl(port);
4797
5218
  const indexer = new SelfHostIndexer();
4798
5219
  // Surface the most-recently-reconstructed project (what you just deployed), not
@@ -4896,6 +5317,9 @@ async function cmdStorage(rest) {
4896
5317
  if (sub === 'backup-key') {
4897
5318
  return cmdStorageBackupKey(flags);
4898
5319
  }
5320
+ if (sub === 'status') {
5321
+ return cmdStorageStatus(args[0], flags);
5322
+ }
4899
5323
  // show (default) — the RESOLVED effective config + where each value came from. No persisted state.
4900
5324
  const { backend, source } = backendResolution();
4901
5325
  const opts = storageOptions(); // resolved from env → default (no per-command flags here)
@@ -4919,6 +5343,91 @@ async function cmdStorage(rest) {
4919
5343
  console.log(dim('\n choose per command: --backend <fs|cloud|ipfs|arweave> [--gateway/--bucket/--region/…] · or declare in .env'));
4920
5344
  console.log(dim(' secrets from env only: ABX_S3_ACCESS_KEY_ID · ABX_S3_SECRET_ACCESS_KEY · PINATA_JWT · ARWEAVE_UPLOAD_TOKEN · ARWEAVE_JWK\n'));
4921
5345
  }
5346
+ /**
5347
+ * `abx storage status <locator>` — is it **retrievable** yet, not just accepted?
5348
+ *
5349
+ * The gap this closes: an upload service answers "accepted" the moment it holds your bytes, a gateway
5350
+ * answers "serving" only once they reach it, and on Arweave that runs to minutes. Two independent
5351
+ * integrators built their own version of this check eight days apart; the second saw 32/32 renders
5352
+ * 404 on `arweave.net` while 22/32 already served elsewhere and the uploader said `CONFIRMED`. The
5353
+ * expensive part is what a creator does next: a placeholder on a fresh drop reads as a failed render,
5354
+ * so you re-run `abx render --force` and re-upload everything for nothing.
5355
+ */
5356
+ const STORAGE_STATUS_FLAGS = new Set(['json', 'gateway', 'backend', 'primary-only', 'timeout']);
5357
+ async function cmdStorageStatus(locator, flags) {
5358
+ if (!locator || locator.startsWith('--')) {
5359
+ console.error('usage: abx storage status <locator> [--json] [--gateway <url>] [--primary-only]\n' +
5360
+ ' <locator> — ar://<txid> · ipfs://<cid> · an https:// gateway URL · or a bare txid/CID\n');
5361
+ process.exit(1);
5362
+ }
5363
+ warnStrayFlags(flags, STORAGE_STATUS_FLAGS, 'storage status');
5364
+ const jsonMode = flags.json !== undefined;
5365
+ // The gateway that matters is the one a baked locator resolves through, so default to the active
5366
+ // backend's configured gateway rather than a hardcoded host — otherwise the verdict is about a
5367
+ // gateway this project will never use.
5368
+ const opts = storageOptions(storageOverrides(flags));
5369
+ const configured = flags.gateway ?? opts.arweave?.gateway ?? opts.ipfs?.gateway ?? undefined;
5370
+ const status = await locatorStatus(locator, {
5371
+ gateway: configured,
5372
+ primaryOnly: flags['primary-only'] !== undefined,
5373
+ timeoutMs: flags.timeout === undefined ? undefined : Number(flags.timeout),
5374
+ });
5375
+ if (jsonMode) {
5376
+ process.stdout.write(JSON.stringify(status, null, 2) + '\n');
5377
+ // A program's whole reason to call this is to gate on it, so the verdict is also the exit code:
5378
+ // 0 = ready, 1 = not yet. `until abx storage status <loc> --json; do sleep 10; done` just works.
5379
+ if (status.readiness !== 'ready')
5380
+ process.exitCode = 1;
5381
+ return;
5382
+ }
5383
+ const probeLine = (p, label) => {
5384
+ const verdict = p.serving
5385
+ ? `${g('serving')}`
5386
+ : p.status !== null
5387
+ ? `${c.orange}${p.status}${c.reset}`
5388
+ : `${c.orange}no response${c.reset}`;
5389
+ const size = p.bytes !== null ? dim(` · ${p.bytes < 1024 ? `${p.bytes} B` : `${(p.bytes / 1024).toFixed(1)} KB`}`) : '';
5390
+ const type = p.contentType ? dim(` · ${p.contentType.split(';')[0]}`) : '';
5391
+ const why = p.error ? dim(` · ${p.error}`) : '';
5392
+ console.log(` ${label} ${bold(p.gateway)} → ${verdict}${size}${type} ${dim(`${p.ms}ms`)}${why}`);
5393
+ };
5394
+ console.log(`\n ${bold('storage status')} ${dim(`— ${status.network} · ${status.id.length > 60 ? status.id.slice(0, 60) + '…' : status.id}`)}`);
5395
+ probeLine(status.primary, dim('your gateway '));
5396
+ for (const a of status.alternates)
5397
+ probeLine(a, dim('also '));
5398
+ console.log('');
5399
+ if (status.readiness === 'ready') {
5400
+ ok(`${bold('ready')} — your gateway serves these bytes. Safe to reference in a token.`);
5401
+ }
5402
+ else if (status.readiness === 'propagating') {
5403
+ const where = status.alternates.filter((a) => a.serving).map((a) => a.gateway).join(', ');
5404
+ warn(`${bold('propagating')} — the data is provably on the network (${where} serves it) but ${bold(status.primary.gateway)} hasn't caught up.`);
5405
+ info('Waiting is the fix. Do NOT re-render or re-upload — the bytes are already stored, and a second upload just pays twice.');
5406
+ if (status.network === 'arweave') {
5407
+ info(`To serve sooner from a gateway that already has it, set ${bold('ABX_ARWEAVE_GATEWAY')} before the upload that bakes the locator ${dim('(it cannot be repaired afterwards — the gateway is part of the stored value).')}`);
5408
+ }
5409
+ }
5410
+ else {
5411
+ // Never call this "propagating": from outside, a settling locator and a wrong one look the same.
5412
+ warn(`${bold('not retrievable')} — no gateway probed is serving it.`);
5413
+ // One exception worth reading the statuses for. Gateways answer 404 for "I don't have these
5414
+ // bytes" (settling and wrong are identical there) but 400 for "that isn't a valid id at all".
5415
+ // Every probe agreeing on a non-404 client error is real evidence, and it matters because the
5416
+ // inverse mistake — waiting out a typo — costs more than a needless re-upload.
5417
+ const probes = [status.primary, ...status.alternates];
5418
+ const answered = probes.filter((p) => p.status !== null);
5419
+ const allInvalid = answered.length > 0 && answered.every((p) => p.status >= 400 && p.status < 500 && p.status !== 404);
5420
+ if (allInvalid) {
5421
+ info(`every gateway rejected the id itself (${answered.map((p) => p.status).join(', ')}), not merely "don't have it" — so this is very likely a ${bold('malformed locator')} (truncated txid/CID, or a stray character), not propagation. Re-check the value you stored; waiting will not fix it.`);
5422
+ }
5423
+ else {
5424
+ info('Two causes, and this check cannot tell them apart: it is still settling (wait and re-run — minutes on Arweave), or the locator is wrong (a truncated txid/CID, or the upload never completed).');
5425
+ }
5426
+ }
5427
+ console.log(`\n ${dim('gate a script on this:')} ${bold(`abx storage status ${locator} --json`)} ${dim('(exit 0 only when ready)')}\n`);
5428
+ if (status.readiness !== 'ready')
5429
+ process.exitCode = 1;
5430
+ }
4922
5431
  /**
4923
5432
  * `abx storage upload <path> [--backend ipfs|arweave|cloud]` — upload one file to a durable
4924
5433
  * backend and print the **locator** to point at. This is the missing first half of the data-plane
@@ -5049,6 +5558,106 @@ async function cmdStorageFunding(sub, flags) {
5049
5558
  info(' Credits land on the address above and persist for future uploads. Re-check with `abx storage balance`.');
5050
5559
  }
5051
5560
  // ── status ────────────────────────────────────────────────────────────────--
5561
+ // ── tokens: every token's seed + params, read straight from chain ────────────────────────────
5562
+ // For a generative collection the seed list IS the collection — it's the first thing you want after
5563
+ // a drop ("what did the seeds actually deal?") and, before this existed, the one thing no command
5564
+ // could answer. An agent's workaround was: start `abx serve`, GET the project API, base64-decode
5565
+ // each tokenURI, base64-decode the animation_url inside it, then regex `0x[0-9a-f]{64}` out of the
5566
+ // resulting HTML — 32 times. Every input to that was already a plain contract read.
5567
+ const TOKENS_FLAGS = new Set(['json', 'limit', 'from']);
5568
+ /** An owner address abbreviated for a human table. Abbreviating for eyes is fine; abbreviating for a
5569
+ * PROGRAM is not — which is why every value here is also reachable verbatim via `--json`. */
5570
+ function shortAddr(a) {
5571
+ return `${a.slice(0, 6)}…${a.slice(-4)}`;
5572
+ }
5573
+ async function cmdTokens(address, flags) {
5574
+ if (!address || address.startsWith('--')) {
5575
+ console.error('usage: abx tokens <address> [--json] [--from <id>] [--limit <n>] — every token\'s owner, seed, and params, from chain\n');
5576
+ process.exit(1);
5577
+ }
5578
+ if (!/^0x[0-9a-fA-F]{40}$/.test(address)) {
5579
+ console.error(`abx tokens: '${address}' isn't a 0x contract address.\n`);
5580
+ process.exit(1);
5581
+ }
5582
+ warnStrayFlags(flags, TOKENS_FLAGS, 'tokens');
5583
+ const jsonMode = flags.json !== undefined;
5584
+ const publicClient = makePublicClient({ chainKey: CHAIN });
5585
+ const code = await publicClient.getCode({ address }).catch(() => undefined);
5586
+ if (!code || code === '0x') {
5587
+ // Name the endpoint we actually asked — "no contract here" and "wrong node" are otherwise
5588
+ // indistinguishable (same reasoning as `tokenuri` / `contracturi`).
5589
+ console.error(`abx tokens: no contract at ${address} on ${CHAIN} (asked ${redactRpcUrl(resolveRpcUrl(CHAIN))}) — ` +
5590
+ `double-check the address, and that this endpoint is the network you deployed to. ` +
5591
+ `If you JUST deployed, give the tx a block or two to mine.\n`);
5592
+ process.exit(1);
5593
+ }
5594
+ const listing = await listTokens(publicClient, address, {
5595
+ from: flags.from === undefined ? undefined : Number(flags.from),
5596
+ limit: flags.limit === undefined ? undefined : Number(flags.limit),
5597
+ // Progress on stderr only — stdout must stay pure JSON in --json mode (the ANSI-in-a-locator
5598
+ // lesson: a program's channel is not a place to narrate).
5599
+ onProgress: (done, total) => total > 50 && done % 25 === 0 ? process.stderr.write(` reading ${done}/${total}…\r`) : undefined,
5600
+ });
5601
+ if (listing.tokens.length > 50)
5602
+ process.stderr.write(' \r');
5603
+ if (jsonMode) {
5604
+ process.stdout.write(JSON.stringify(listing, null, 2) + '\n');
5605
+ return;
5606
+ }
5607
+ const supply = listing.totalSupply ?? listing.tokens.length;
5608
+ const cap = listing.maxInvocations !== null && listing.maxInvocations > 0 ? ` of ${listing.maxInvocations} max` : '';
5609
+ console.log(`\n ${bold(`tokens ${address}`)} ${dim(`— ${supply} minted${cap} on ${CHAIN}`)}`);
5610
+ info('read straight from the contract — no indexer, no resolver, no server in the loop');
5611
+ if (!listing.hasParams) {
5612
+ info('this token type has no params extension — so no seeds or params to list (that is correct, not a failed read). Owners below.');
5613
+ }
5614
+ else if (!listing.hasParamEnumeration) {
5615
+ warn('this contract predates on-chain param enumeration — seeds still read, but params cannot be listed. Redeploy on a current implementation to enumerate them.');
5616
+ }
5617
+ const contractKeys = Object.keys(listing.contractParams);
5618
+ if (contractKeys.length) {
5619
+ info(`contract-scope params ${dim('(every token inherits these; a token-scope value of the same key wins)')}`);
5620
+ for (const k of contractKeys.sort())
5621
+ console.log(` ${g(k)}=${listing.contractParams[k]}`);
5622
+ }
5623
+ if (!listing.tokens.length) {
5624
+ console.log(`\n ${dim('no tokens in range — nothing minted yet, or --from is past the mint frontier.')}\n`);
5625
+ return;
5626
+ }
5627
+ const anySeed = listing.tokens.some((t) => t.seed !== null);
5628
+ console.log(`\n ${dim('id'.padStart(4))} ${anySeed ? dim('seed'.padEnd(66)) + ' ' : ''}${dim('owner')}`);
5629
+ for (const t of listing.tokens) {
5630
+ // A seed is the whole reason this command exists, so it prints in FULL. The owner truncates —
5631
+ // an address stays recognizable abbreviated, and `--json` carries both verbatim either way.
5632
+ const seedCell = anySeed ? `${t.seed ?? dim('—'.padEnd(66))} ` : '';
5633
+ const owner = t.owner ? shortAddr(t.owner) : dim('— unminted or burned');
5634
+ console.log(` ${bold(('#' + t.tokenId).padStart(4))} ${seedCell}${owner}`);
5635
+ const keys = Object.keys(t.params).sort();
5636
+ if (keys.length)
5637
+ console.log(` ${keys.map((k) => `${g(k)}=${t.params[k]}`).join(dim(' · '))}`);
5638
+ }
5639
+ // The token-scope param SPREAD — what collectors actually chose, which is the question a param
5640
+ // dump is usually a step toward. Deliberately NOT called a trait spread: traits come from running
5641
+ // the script against the seed (that's `abx render`), not from params.
5642
+ const spread = new Map();
5643
+ for (const t of listing.tokens) {
5644
+ for (const [k, v] of Object.entries(t.params)) {
5645
+ const counts = spread.get(k) ?? new Map();
5646
+ counts.set(v, (counts.get(v) ?? 0) + 1);
5647
+ spread.set(k, counts);
5648
+ }
5649
+ }
5650
+ if (spread.size && listing.tokens.length > 1) {
5651
+ console.log(`\n ${bold('token-scope param spread')} ${dim('(what has actually been set — not the trait spread, which comes from running the script)')}`);
5652
+ for (const key of [...spread.keys()].sort()) {
5653
+ const counts = [...spread.get(key).entries()].sort((a, b) => b[1] - a[1]);
5654
+ const shown = counts.slice(0, 8).map(([v, n]) => `${v} ${dim('×' + n)}`).join(dim(' · '));
5655
+ const rest = counts.length > 8 ? dim(` …+${counts.length - 8} more values`) : '';
5656
+ console.log(` ${g(key)}: ${shown}${rest}`);
5657
+ }
5658
+ }
5659
+ console.log(`\n ${dim('every value above verbatim + machine-readable:')} ${bold(`abx tokens ${address} --json`)}\n`);
5660
+ }
5052
5661
  // ── state: a one-glance operational snapshot of a deployed contract, read straight from chain ──
5053
5662
  // Read-only (no tx, no local index). Series-only getters revert on a 1/1, so each read is defensive
5054
5663
  // → the same command works for both. The agent-friendly "what's the state before/after an op" call.
@@ -5071,6 +5680,11 @@ async function cmdState(address, flags) {
5071
5680
  console.error('usage: abx state <address> — a read-only, on-chain operational snapshot\n');
5072
5681
  process.exit(1);
5073
5682
  }
5683
+ // `--json` emits the same facts as data (B19: a value a program needs must be obtainable without
5684
+ // parsing prose). The human narration below is unchanged and rides stderr under --json.
5685
+ return withJson(flags, async (emit) => cmdStateBody(address, flags, emit));
5686
+ }
5687
+ async function cmdStateBody(address, flags, emit) {
5074
5688
  const publicClient = makePublicClient({ chainKey: CHAIN });
5075
5689
  const read = async (functionName, args = []) => {
5076
5690
  try {
@@ -5099,6 +5713,29 @@ async function cmdState(address, flags) {
5099
5713
  }
5100
5714
  const isSeries = maxInvocations !== undefined; // series-only getter
5101
5715
  const addrOrNone = (a) => (a && a !== zeroAddress ? a : dim('none'));
5716
+ // The payload mirrors what is printed, with `undefined` (getter absent) kept distinct from a zero
5717
+ // address (present and deliberately unset) — collapsing those is what makes a readout lie.
5718
+ const zeroToNull = (a) => (a === undefined ? null : a === zeroAddress ? null : a);
5719
+ const payload = jsonSafe({
5720
+ address,
5721
+ chain: CHAIN,
5722
+ chainId: resolveChain(CHAIN).id,
5723
+ type: isSeries ? 'series' : '1of1',
5724
+ owner: owner ?? null,
5725
+ totalSupply: totalSupply ?? null,
5726
+ nextTokenId: nextTokenId ?? null,
5727
+ maxInvocations: maxInvocations ?? null,
5728
+ paused: paused ?? null,
5729
+ minter: zeroToNull(minter),
5730
+ primaryPayee: zeroToNull(primaryPayee),
5731
+ tokenURIRenderer: zeroToNull(renderer),
5732
+ onChainTokenUri: !!(renderer && renderer !== zeroAddress),
5733
+ royalty: royalty ? { receiver: royalty[0], bps: Number(royalty[1]) } : null,
5734
+ creatorToken: !!creatorToken,
5735
+ transferValidator: creatorToken ? zeroToNull(transferValidator) : null,
5736
+ params: { governed: [], contractScopeSet: [], legacy: false },
5737
+ });
5738
+ emit(payload);
5102
5739
  console.log(bold(`\n ${isSeries ? 'Series' : '1/1'} state`) + dim(` ${address} · ${CHAIN}`));
5103
5740
  info(`owner ${owner ?? dim('?')}`);
5104
5741
  if (isSeries) {
@@ -5142,6 +5779,21 @@ async function cmdState(address, flags) {
5142
5779
  : [];
5143
5780
  const governed = rows.filter((r) => r.s.exists);
5144
5781
  const now = Math.floor(Date.now() / 1000);
5782
+ payload.params.legacy = legacy;
5783
+ payload.params.governed = jsonSafe(governed.map(({ key, s }) => ({
5784
+ key,
5785
+ // Canonical NAMES, not the raw enum indices — a caller must not have to know the Solidity
5786
+ // enum's ordering to read this (the same reason `retired` is a boolean below).
5787
+ paramType: PARAM_TYPES[s.paramType] ?? String(s.paramType),
5788
+ auth: AUTH_OPTIONS[s.auth] ?? String(s.auth),
5789
+ authAddress: s.authAddress === zeroAddress ? null : s.authAddress,
5790
+ lockAfter: s.lockAfter,
5791
+ // A past lockAfter IS how a param is retired — surfaced as a boolean so a caller doesn't
5792
+ // have to re-derive the convention from a timestamp.
5793
+ retired: s.lockAfter !== 0 && s.lockAfter < now,
5794
+ selectOptions: s.selectOptions,
5795
+ })));
5796
+ emit(payload);
5145
5797
  if (governed.length) {
5146
5798
  console.log(`\n ${bold('PostParams')} ${dim(`${governed.length} governed · collectors/artist set these; the generator injects them`)}` +
5147
5799
  (legacy ? dim(' (legacy project — read from its params.keys list)') : ''));
@@ -5161,6 +5813,8 @@ async function cmdState(address, flags) {
5161
5813
  // it is a tokenData coordinate, read directly.
5162
5814
  const set = legacy ? null : await readSetParamKeys(publicClient, address);
5163
5815
  if (set?.contract.length) {
5816
+ payload.params.contractScopeSet = [...set.contract].sort();
5817
+ emit(payload);
5164
5818
  info(`${dim('contract-scope params set:')} ${[...set.contract].sort().join(', ')} ${dim('(apply to every token)')}`);
5165
5819
  }
5166
5820
  }
@@ -5441,6 +6095,16 @@ async function cmdRemote(spec, flags) {
5441
6095
  // SOURCE resolver's PUBLIC api. The two resolvers never talk: we read public endpoints + chain
5442
6096
  // and write through the admin control plane. It does NOT cut over — after a clean migration the
5443
6097
  // operator re-points DNS (custom domain) or the on-chain base URI (provider endpoint).
6098
+ //
6099
+ // ⚠ IF YOU EVER ADD A REDEPLOY-STYLE MIGRATION HERE — anything that deploys a NEW contract and
6100
+ // re-points identity to it (plausible under the greenfield-redeploy stance) — it MUST read the
6101
+ // source collection's creator-token status (`readCreatorTokenStatus`) and carry the enrollment +
6102
+ // active validator into the new deploy's `transferValidator` InitParam. ERC-721C enrollment is
6103
+ // deploy-time-only and PERMANENT, so a migration that forgets it silently converts an enforced
6104
+ // collection into a plain ERC-721 — unrecoverable except by yet another redeploy. Corollary: a
6105
+ // plain source collection must stay plain; do not "helpfully" enroll on migration. Today's
6106
+ // migrate moves off-chain state only, so enrollment lives on the untouched contract and none of
6107
+ // this applies — which is exactly why the trap is easy to walk into later.
5444
6108
  async function cmdMigrate(address, flags) {
5445
6109
  // Both sides accept a named remote or a URL. Only the DESTINATION needs a credential — the
5446
6110
  // source is read via its PUBLIC api (the exit ramp works with zero provider cooperation).
@@ -6153,6 +6817,15 @@ const COMMAND_HELP = {
6153
6817
  The live view 302s to the gateway, so the gateway ${bold('MUST serve HTML')} (a dedicated Pinata gateway or Arweave — never the shared public one)
6154
6818
  --name <s> --symbol <s> ERC-721 identity --royalty-bps <0-10000> (default 500)
6155
6819
  ${bold('--public-base-url <url>')} the resolver you run (${bold('REQUIRED')} unless --onchain-uri — a code project resolves live on-chain state; localhost/missing is refused)
6820
+ ${g('--resume')} <address> ${bold('FINISH an existing contract whose setup transaction failed')} — deploys nothing. A code deploy is TWO
6821
+ txns (create the clone, then one atomic setup multicall); when the second fails you own a
6822
+ live-but-unusable contract AND the salt for its address is spent, so the pinned-salt re-run can
6823
+ never work. Pass the SAME content flags the original deploy used: it reads what is already
6824
+ on-chain and sends only what is missing, in one transaction. Chunks compare by CONTENT (so a
6825
+ hand repair is respected), schemas are left alone if present (re-writing one is an upsert that
6826
+ can strand values), and mints are a SHORTFALL against current supply — never a re-send, since a
6827
+ token cannot be un-minted. Nothing missing ⇒ it sends nothing and says so.
6828
+ ${dim('Refuses --salt / --721c / --bootstrap-factory / --mint-all: those describe how a contract is CREATED.')}
6156
6829
  ${g('--onchain-uri')} the CHAIN-COMPLETE lane: tokenURI/contractURI resolve ON-CHAIN via the canonical metadata renderer, and
6157
6830
  animation_url is COMPUTED on-chain by the canonical ${bold('AbxGenerator')} (template: the full HTML document;
6158
6831
  directory: a parameterized gateway URL — 8KB URL budget, gateway liveness applies). No resolver base is baked
@@ -6255,6 +6928,9 @@ const COMMAND_HELP = {
6255
6928
  like ${g('display.gateway')}. ≤31 printable-ASCII chars ride as a literal bytes32; longer takes the data path.
6256
6929
  A contract-scope param applies to every token, and enumerates on-chain like any other.
6257
6930
  --file <path> read the value from a file (String / Bytes payloads)
6931
+ ${dim('PAYLOAD TYPES:')} ${g('String')} ${dim('takes literal text (UTF-8).')} ${g('Bytes')} ${dim('takes')} ${bold('0x-prefixed hex')} ${dim(`or ${g('--file')} — a bare`)}
6932
+ ${dim('string is refused, because there is no safe guess between "these characters" and "these bytes".')}
6933
+ ${dim(`(The docs' "Bytes becomes base64" describes how your ${bold('program')} receives the value, not how you write it.)`)}
6258
6934
  ${g('--remote [name|url]')} nudge a REMOTE resolver to re-index IMMEDIATELY after the change (else ABX_PUBLIC_BASE_URL) — it pings
6259
6935
  the resolver's effect runner, so the thumbnail re-renders without waiting. Usually OPTIONAL now: a
6260
6936
  resolver running the chain watcher (the ${g('abx serve')} default) sees the change on its next poll (~12s)
@@ -6294,6 +6970,7 @@ const COMMAND_HELP = {
6294
6970
  (+ the 721C transfer validator, for a collection that enrolled at deploy — plain ERC-721s show nothing new).
6295
6971
  Series-only fields are shown for a Series; a 1/1 shows just supply + royalty + renderer.
6296
6972
  Handy before/after owner ops (mint · pause/unpause · set-minter · set-primary-payee).
6973
+ ${g('--json')} the same facts as data (uncoloured, untruncated) — the machine path; narration moves to stderr.
6297
6974
  ${dim('state = what the CHAIN says. For who is SERVING it and how fresh that is, see `abx status`.')}`,
6298
6975
  predict: `
6299
6976
  ${bold('abx predict')} ${dim('— pre-compute a deploy address (read-only).')}
@@ -6302,6 +6979,8 @@ const COMMAND_HELP = {
6302
6979
  ${bold('abx mint')} <address> ${dim('— mint the NEXT token (a 1/1\'s #0, or a Series\' next-in-order). Owner-only. Sends a tx.')}
6303
6980
  --to 0x.. recipient (default: the owner — pre-mint; a buyer = primary sale)
6304
6981
  ${g('--count')} <n> mint N tokens in order in ONE tx (Series) — e.g. reserves/airdrops (do them BEFORE you unpause the sale)
6982
+ ${g('--json')} ${g('{tokenIds, txHash, blockNumber, sent}')} on stdout — the TOKEN ID as data, read from the mint's own
6983
+ Transfer logs (not by re-reading nextTokenId, which a concurrent mint would make wrong).
6305
6984
  ${dim('minting is sequential (by token id); repeat with different --to for an airdrop to N wallets.')}
6306
6985
  signing: ${g('--send')} (default) · ${g('--sign')} · ${g('--unsigned')} · ${g('--dry-run')} preview`,
6307
6986
  refresh: `
@@ -6355,10 +7034,13 @@ const COMMAND_HELP = {
6355
7034
  ${dim('up front: enrollment is a deploy-time decision and can never be added (or fully removed) later.')}
6356
7035
  signing: ${g('--send')} (default) · ${g('--sign')} · ${g('--unsigned')} · ${g('--dry-run')} preview (encodes + shows the tx, sends nothing)`,
6357
7036
  attach: `
6358
- ${bold('abx attach')} <address> <key> <ipfs://… | ar://… | https://…> ${dim('— attach a named file to a token. Sends a tx.')}
6359
- ${dim('The data-plane verb: the file joins the token\'s served')} ${bold('artifacts')} ${dim('manifest as {key, mimeType, uri}.')}
7037
+ ${bold('abx attach')} <address> <key> <uri> [<key> <uri> …] ${dim('— attach named file(s) to a token. Sends ONE tx.')}
7038
+ ${dim('The data-plane verb: each file joins the token\'s served')} ${bold('artifacts')} ${dim('manifest as {key, mimeType, uri}.')}
6360
7039
  <key> any name YOU choose (print · certificate · stems · readme · source) — how it appears in the manifest
6361
7040
  <uri> the file's locator; representation is ${bold('auto-detected')} from the scheme (ipfs → ipfs, ar → arweave, https → url)
7041
+ ${bold('Several pairs in one invocation become ONE transaction')} ${dim('— all-or-nothing, so a revert lands NONE of them. Attaching')}
7042
+ ${dim('artifacts one command at a time is what leaves a half-written token on a failure partway through, and a mint')}
7043
+ ${dim('cannot be undone. Every pair is validated before anything is sent, and a key repeated in one batch is refused.')}
6362
7044
  ${g('--file')} <path> instead of a URI: store TINY bytes on-chain (SSTORE2, ≈200 gas/byte) — locators are the norm otherwise
6363
7045
  --collection attach to the collection scope · else --token <id> (default 0)
6364
7046
  ${g('--dry-run')} preview the tx, send nothing · signing: ${g('--send')} (default, needs a hot key) · ${g('--sign')} (browser wallet, no key) · ${g('--unsigned')}
@@ -6383,8 +7065,12 @@ const COMMAND_HELP = {
6383
7065
  ${g('--dry-run')} preview the tx, send nothing
6384
7066
  --collection target collection (ERC-7572) scope · else --token <id> (default 0)`,
6385
7067
  'lock-field': `
6386
- ${bold('abx lock-field')} <address> --field <name> ${dim('— freeze a field FOREVER (irreversible). Sends a tx.')}
6387
- --collection | --token <id>`,
7068
+ ${bold('abx lock-field')} <address> --field <name> ${dim('— freeze a metadata FIELD forever (irreversible). Sends a tx.')}
7069
+ --collection | --token <id>
7070
+ --force-field proceed even if a PostParam shares this name (you mean the field)
7071
+ ${dim('FIELDS AND PARAMS ARE DIFFERENT NAMESPACES and may share a name. This locks the field only —')}
7072
+ ${dim(`to weld a PostParam use ${g('abx set-schema <addr> --schema <key>:<Type>:<Auth>:lock=now')}. Passing a`)}
7073
+ ${dim('declared param key here is refused, because locking the field leaves the param writable.')}`,
6388
7074
  'set-renderer': `
6389
7075
  ${bold('abx set-renderer')} <address> ${dim('— toggle URI resolution between off-chain and on-chain. Sends a tx.')}
6390
7076
  (default) point at the chain's canonical renderer (deploys it if needed) → resolve ON-CHAIN
@@ -6433,9 +7119,15 @@ const COMMAND_HELP = {
6433
7119
  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)')}
6434
7120
  ${dim('status = who is SERVING it and how fresh. For what the CHAIN says (owner, royalty, locks), see `abx state`.')}`,
6435
7121
  storage: `
6436
- ${bold('abx storage')} <show|upload|balance|topup|backup-key> ${dim('— inspect / operate byte custody. Mostly read-only.')}
7122
+ ${bold('abx storage')} <show|upload|status|balance|topup|backup-key> ${dim('— inspect / operate byte custody. Mostly read-only.')}
6437
7123
  ${g('show')} the resolved backend (fs | cloud | ipfs | arweave) + where each value came from
6438
7124
  ${g('upload')} <path> upload ONE file → prints its locator (the URI ${g('abx attach')} wants) [--backend …] [--dry-run] [${g('--json')}]
7125
+ ${g('status')} <locator> is it ${bold('RETRIEVABLE')} yet, not just accepted? [${g('--json')}] [--gateway <url>] [--primary-only]
7126
+ An upload service says "accepted" the moment it holds your bytes; a gateway serves them only once
7127
+ they propagate — minutes, on Arweave. Probes YOUR gateway plus two others, because propagation is
7128
+ per-gateway: ${g('ready')} (yours serves it) · ${g('propagating')} (another does, so the data provably exists —
7129
+ ${bold('wait, do NOT re-upload')}) · ${g('unreachable')} (nothing does — still settling, or a bad locator).
7130
+ Exits non-zero unless ready, so ${g('until abx storage status <loc>; do sleep 10; done')} is the whole wait.
6439
7131
  ${g('balance')} · ${g('topup')} --usd <n> Turbo (arweave) upload credits · ${g('backup-key')} --out <path> copy the managed key`,
6440
7132
  demo: `
6441
7133
  ${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.')}
@@ -6451,7 +7143,8 @@ const COMMAND_HELP = {
6451
7143
  ${g('buy')} <token> [--to 0x..] buy ONE token (public; any funded signer). ETH sales attach the price.
6452
7144
  ${dim('the full go-live: deploy-series → minter configure → set-minter <token> --minter <printed> → set-primary-payee → unpause')}
6453
7145
  --minter-contract 0x.. override the shared minter address (else ABX_FIXED_PRICE_MINTER → manifest)
6454
- signing: ${g('--send')} (default) · ${g('--sign')} · ${g('--unsigned')} · ${g('--dry-run')} preview (safe — sends nothing)`,
7146
+ signing: ${g('--send')} (default) · ${g('--sign')} · ${g('--unsigned')} · ${g('--dry-run')} preview (safe — sends nothing)
7147
+ ${g('--json')} the deployed ADDRESS as data on stdout (narration → stderr). With ${g('--dry-run')}: the PREDICTED address + saltPinned.`,
6455
7148
  add: `
6456
7149
  ${bold('abx add')} <address> ${dim('— register + index a project. Also edits off-chain display metadata + traits.')}
6457
7150
  --from-block <n> --factory 0x.. --label "<s>" --description "<s>" --external-url <url> [--full] [--yes]
@@ -6473,12 +7166,29 @@ const COMMAND_HELP = {
6473
7166
  or 200 bytes), so it accounts for a render PUBLISHED to that resolver. ${bold('Use this for a code project whose')}
6474
7167
  ${bold('renders were published to a hosted resolver')} — a plain \`abx verify\` only checks THIS machine's store and will
6475
7168
  report a false placeholder for a render that lives on the resolver.
7169
+ ${g('--json')} the findings as data, with ${g('ok')} matching the exit code — so CI can gate on a byte mismatch
7170
+ without parsing prose (a mismatch exits non-zero either way).
6476
7171
  ${dim('On-chain URI lane (a non-zero tokenURIRenderer / a generator-backed animation field): also eth_calls the generator')}
6477
7172
  ${dim(`${g('onChainStatus')} (branch · chain-complete · unresolved refs · URL budget) and decodes ${g('tokenURI')} straight from the contract.`)}`,
6478
7173
  tokenuri: `
6479
7174
  ${bold('abx tokenuri')} <address> [--token <id>] ${dim('— read tokenURI(id) straight from the contract on-chain + decode the JSON (read-only; no server).')}
6480
7175
  ${dim('The proof a fully on-chain token self-resolves: any RPC returns the renderer-assembled metadata. Default token 0.')}
6481
- ${dim('Collection-level (ERC-7572) counterpart:')} ${g('abx contracturi <address>')}`,
7176
+ ${g('--fetch')} FOLLOW the URL the contract commits to and print ${bold('what is actually served')} — the body a marketplace reads.
7177
+ On a fully on-chain token there is nothing to fetch (the tokenURI IS the document) and it says so.
7178
+ ${dim('This is also how you catch a provider mismatch: it shows what the BAKED base returns, so registering with')}
7179
+ ${dim('one provider while another is baked on-chain shows up as their 404 rather than as a warning that guesses.')}
7180
+ ${dim('A non-2xx exits non-zero under --json. A bad status here is about the SERVICE, never a mistyped path —')}
7181
+ ${dim('the URL came from the chain, so it is right by construction.')}
7182
+ ${dim('Collection-level (ERC-7572) counterpart:')} ${g('abx contracturi <address>')}${dim('. Whole-collection counterpart:')} ${g('abx tokens <address>')}`,
7183
+ tokens: `
7184
+ ${bold('abx tokens')} <address> ${dim('— every token’s owner, seed, and params, read straight from the contract (read-only; no server).')}
7185
+ ${g('--json')} the listing as data: ${g('{chainId, address, contractParams, tokens: [{tokenId, owner, seed, params}]}')} — verbatim,
7186
+ uncoloured, untruncated. The machine path; the human table abbreviates owners only.
7187
+ ${g('--from <id>')} first token id to read (default 0) ${g('--limit <n>')} read only n ids from there
7188
+ ${dim('For a generative collection the seed list IS the collection — this is the "what did the seeds actually deal?" read,')}
7189
+ ${dim('and the input to any distribution check before a real launch. Chain-only: no indexer projection and no running')}
7190
+ ${dim('resolver, because the params store enumerates its own keys on-chain and')} ${g('seed')} ${dim('is a reserved param read by name.')}
7191
+ ${dim('Traits are NOT here: a trait comes from running the script against the seed —')} ${g('abx render')}${dim(' does that.')}`,
6482
7192
  contracturi: `
6483
7193
  ${bold('abx contracturi')} <address> ${dim('— read contractURI() (ERC-7572 collection metadata) from the contract, FOLLOW it, and decode (read-only).')}
6484
7194
  ${dim('The collection-level counterpart of')} ${g('tokenuri')}${dim('. On-chain lane: decodes the data: URI. Off-chain lane: fetches the')}
@@ -6514,6 +7224,11 @@ const COMMAND_HELP = {
6514
7224
  project to the effects layer (${g('ABX_EFFECTS_URL')} + ${g('ABX_EFFECTS_TOKEN')}) → thumbnails auto-re-render.
6515
7225
  ${g('ABX_WATCH_INTERVAL_MS')} tunes the cadence; ${g('0')} disables (state then updates only on explicit add/index).
6516
7226
  ${dim('Reorg note: no lookback (post-PoS reorgs are rare); the repair is the deterministic full replay — `abx index <addr> --full`.')}`,
7227
+ changelog: `
7228
+ ${bold('abx changelog')} ${dim('— what changed in this and recent releases. Reads nothing from the network. No tx.')}
7229
+ ${g('--all')} the full history (default: the 3 most recent releases)
7230
+ ${dim('The notes ship INSIDE the package, so they always match the version you have and work offline.')}
7231
+ ${dim('Every published version is listed at https://www.npmjs.com/package/@artblocks/abx-cli?activeTab=versions')}`,
6517
7232
  };
6518
7233
  function printCommandHelp(cmd) {
6519
7234
  if (cmd && COMMAND_HELP[cmd]) {
@@ -6556,6 +7271,7 @@ function help() {
6556
7271
  ${g('abx verify')} <addr> re-hash served bytes vs the on-chain commitment (no server needed)
6557
7272
  ${g('abx tokenuri')} <addr> read tokenURI(0) on-chain + decode the JSON (proof a self-resolving token works)
6558
7273
  ${g('abx contracturi')} <addr> read contractURI() (ERC-7572 collection metadata) on-chain, follow it, decode — never hand-build the URL
7274
+ ${g('abx tokens')} <addr> EVERY token's owner + seed + params, from chain flags: [--json] [--from <id>] [--limit <n>]
6559
7275
  ${g('abx state')} <addr> one-glance on-chain snapshot: owner · supply · paused · minter · payee · royalty · renderer
6560
7276
  ${g('abx serve')} [--port ..] serve the token API + dashboard — and WATCH the chain: auto-index every registered
6561
7277
  project + notify the effects layer on change (${g('ABX_WATCH_INTERVAL_MS')}; 0 = off)
@@ -6608,6 +7324,7 @@ function help() {
6608
7324
  ${dim('storage is stateless — no config file. Pick a backend PER COMMAND with flags')}
6609
7325
  ${dim('(--backend ipfs --gateway … · --backend cloud --bucket … --public-base … · --backend arweave), or set defaults in .env.')}
6610
7326
  ${g('abx storage')} upload <path> upload ONE file → prints its locator (the URI ${g('abx attach')} wants) [--backend ipfs|arweave|cloud] [--dry-run]
7327
+ ${g('abx storage')} status <locator> is that locator RETRIEVABLE yet (not just accepted)? — ready | propagating | unreachable [--json]
6611
7328
  ${g('abx storage')} balance show Turbo (arweave) upload credits + the funded address
6612
7329
  ${g('abx storage')} topup buy Turbo credits by card --usd <n> (one-time; <100 KB is always free)
6613
7330
  ${g('abx storage')} backup-key copy the managed Turbo/Arweave key (holds credits) to a safe path --out <path>
@@ -6616,6 +7333,7 @@ function help() {
6616
7333
  ${g('abx doctor')} check environment (key, RPC, balance, factory, storage)
6617
7334
  ${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
6618
7335
  ${g('abx version')} print the installed CLI version
7336
+ ${g('abx changelog')} what changed in this and recent releases (ships with the CLI; offline) [--all]
6619
7337
 
6620
7338
  ${dim('Run')} ${g('abx <command> --help')} ${dim('for per-command usage. --help / -h never executes — it only prints usage.')}
6621
7339
  ${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('.')}
@@ -6709,6 +7427,40 @@ function resolveBundledSkill() {
6709
7427
  return canonical;
6710
7428
  return null;
6711
7429
  }
7430
+ /**
7431
+ * `abx changelog [--all]` — print the release notes that ship with THIS install.
7432
+ *
7433
+ * The update banner used to point at `github.com/ArtBlocks/abx/releases`, which 404s for anyone
7434
+ * outside the org, and no changelog shipped in the package — so "what changed?" was unanswerable
7435
+ * without diffing two versions' dry-run output by hand. `CHANGELOG.md` now ships (package.json
7436
+ * `files`), which makes the notes offline, version-matched, and readable by an agent.
7437
+ *
7438
+ * Default: the most recent few entries (what an upgrade needs). `--all`: the whole file.
7439
+ */
7440
+ function cmdChangelog(flags) {
7441
+ const pkgDir = resolvePath(fileURLToPath(import.meta.url), '..', '..');
7442
+ const candidates = [joinPath(pkgDir, 'CHANGELOG.md')];
7443
+ const root = findRepoRoot();
7444
+ if (root)
7445
+ candidates.push(joinPath(root, 'packages', 'cli', 'CHANGELOG.md')); // dev checkout
7446
+ const path = candidates.find((p) => existsSync(p));
7447
+ if (!path) {
7448
+ throw new Error(`no CHANGELOG.md found beside this install (looked in ${candidates.join(', ')}). ` +
7449
+ 'Version history: https://www.npmjs.com/package/@artblocks/abx-cli?activeTab=versions');
7450
+ }
7451
+ const text = readFileSync(path, 'utf8');
7452
+ if (flags.all !== undefined) {
7453
+ console.log(text);
7454
+ return;
7455
+ }
7456
+ // Entries are `## <version>` sections; show the newest few and say how to see the rest.
7457
+ const lines = text.split('\n');
7458
+ const heads = lines.map((l, i) => (/^## /.test(l) ? i : -1)).filter((i) => i >= 0);
7459
+ const end = heads.length > 3 ? heads[3] : lines.length;
7460
+ console.log(`\n${lines.slice(0, end).join('\n').trimEnd()}\n`);
7461
+ if (heads.length > 3)
7462
+ info(`showing the ${3} most recent releases — ${bold('abx changelog --all')} for the full history ${dim(`(${path})`)}`);
7463
+ }
6712
7464
  /** Locate the in-chain renderer Foundry scaffold: bundled beside the CLI (published), else the
6713
7465
  * repo copy (dev). Same layout in both — it lives under the CLI package's `assets/`. */
6714
7466
  function resolveRendererScaffold() {
@@ -6737,12 +7489,9 @@ function cmdScaffoldRenderer(rest, flags) {
6737
7489
  if (existsSync(dir) && readdirSync(dir).length > 0 && flags.force === undefined) {
6738
7490
  throw new Error(`${dir} already exists and is not empty — pass a fresh path, or --force to write into it.`);
6739
7491
  }
6740
- mkdirSync(dir, { recursive: true });
6741
- // Copy the committed scaffold; never carry a stale build/deps dir if one somehow exists.
6742
- cpSync(src, dir, {
6743
- recursive: true,
6744
- filter: (s) => !/(^|\/)(out|cache|dependencies|broadcast|node_modules)(\/|$)/.test(s),
6745
- });
7492
+ // Copy + assert it landed. Throws rather than printing a success banner over an empty directory —
7493
+ // the alpha.9→alpha.14 failure mode. See src/scaffold.ts for why this is not inline.
7494
+ copyRendererScaffold(src, dir);
6746
7495
  ok(`in-chain renderer scaffold → ${dir}`);
6747
7496
  step('The in-chain Solidity art lane — nothing to run after deploy, tokenURI resolves from chain forever');
6748
7497
  info(`${bold('src/MyRenderer.sol')} draws an SVG from the token ${bold('seed')} + a ${bold('palette')} HexColor PostParam; ${bold('src/MyTraits.sol')} reads the SAME seed for coherent on-chain traits. Fork the art; keep the invariants in ${bold('src/interfaces/IAbxFieldRenderer.sol')} (above all: render() must NEVER revert).`);