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

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
@@ -55,15 +55,16 @@ import { effectsArtifact, resolverArtifact } from './provision.js';
55
55
  import { mintPageArtifact, PUBLIC_RPC } from './mintpage.js';
56
56
  import { formatEther, toHex, zeroAddress } from 'viem';
57
57
  import { encodeFunctionData } from 'viem';
58
- import { DEP_RESOLUTION, deploySeedSource, deploySeriesCodeFactory, prepareCodeSetup, prepareDeploySeriesCode, resolveGenerator, resolveSeedSource, resolveSeriesCodeFactory, seriesCodeAbi, seriesCodeFactoryAbi, } from '@artblocks/abx-sdk';
58
+ import { DEP_RESOLUTION, deploySeedSource, deploySeriesCodeFactory, prepareCodeSetup, prepareDeploySeriesCode, resolveGenerator, resolveSeedSource, resolveSeriesCodeFactory, seriesCodeAbi, readParamSchema, seriesCodeFactoryAbi, } from '@artblocks/abx-sdk';
59
59
  import { checkRegistryDeps, dependencySetupCalls, parseDepFlag, resolveDepRegistryPointer } from './deps.js';
60
- import { composeParamsKeys, expectedChainComplete, hasOnChainUriLane, onchainUriSetupCalls, onChainUriReport } from './onchain-uri.js';
60
+ import { composeParamsKeys, expectedChainComplete, hasOnChainUriLane, onchainUriSetupCalls, onChainUriReport, readParamsKeys } from './onchain-uri.js';
61
61
  import { parseFlags, unknownFlags } from './flags.js';
62
62
  import { AGENT_SKILL_PARENTS, checkForCliUpdate, compareVersions, installedSkillVersions, readCliVersion, readSkillVersion, SKILL_DIR_NAME, } from './update-check.js';
63
63
  import { analyzeScript, recommendLane } from './inspect.js';
64
64
  import { previewConfigFromFlags, previewDepTags, shootPreview, startPreviewServer, DEFAULT_PREVIEW_PORT, PREVIEW_FLAGS } from './preview.js';
65
65
  import { parseSchemaSpecs, describeSchema } from './schema.js';
66
66
  import { declinesSkillInstall } from './prompt.js';
67
+ import { pinGas, waitForCodeAt } from './gas.js';
67
68
  import { parseSeriesTraits, looksPerTokenAttributes, parseSeriesTraitsById } from './series-traits.js';
68
69
  /** The `--schema` type/auth/format catalog, shown wherever the CLI nudges `--schema`. Kept accurate
69
70
  * to the on-chain enums (PARAM_TYPES / AUTH_OPTIONS) + the Select-options / Range-bounds format —
@@ -73,15 +74,38 @@ const SCHEMA_CATALOG = 'Types: Bool·Select·Uint256Range·Int256Range·DecimalR
73
74
  'Select needs options — key:Select[A|B|C]:Auth; a Range takes bounds — key:Uint256Range[0..100]:Auth. ' +
74
75
  'A palette collectors set = palette:HexColor:TokenOwner';
75
76
  import { uploadAndLocate } from './upload.js';
76
- import { assertChainId, discoverDeployBlock, deployFactory, deploySeriesFactory, deploySeries, deployOneOfOne, deployRenderer, predictRenderer, predictSeedSource, encodeTag, encodeFieldRenderer, isCodeProject, loadDotEnv, makePublicClient, makeWalletClient, oneOfOneImageAbi, oneOfOneImageFactoryAbi, seriesImageAbi, seriesImageFactoryAbi, abxMetadataRendererAbi, predictClone, probeRpcEndpoints, prepareDeployOneOfOne, prepareDeploySeries, reconstructProject, saltFor, saltGuard, resolveChain, resolveRpcUrl, redactRpcUrl, explorerUrl, DEFAULT_CHAIN_KEY, normalizeAttributes, parseTraitPairs, METADATA_FIELD as F, METADATA_REPRESENTATION as R, indexProgress, isAccepted, AbxIndexTimeoutError, AbxServiceError, } from '@artblocks/abx-sdk';
77
+ 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, METADATA_FIELD as F, METADATA_REPRESENTATION as R, indexProgress, isAccepted, AbxIndexTimeoutError, AbxServiceError, } from '@artblocks/abx-sdk';
77
78
  import { SelfHostIndexer, SqliteStore } from '@artblocks/abx-indexer';
78
79
  import { artContentHash, currentRenderArtifact, generateArt, resolveBaseUrl, startChainWatcher, startTokenApiServer, verifyProject, watchIntervalMs, DEFAULT_PORT, } from '@artblocks/abx-token-api';
79
80
  import { ARWEAVE_FREE_UPLOAD_LIMIT, arweaveAddress, arweaveFunding, contentTypeFromPath, hashContent, resolveBackend, turboBalanceForAddress, turboUploadCostUsd, turboUploadWinc } from '@artblocks/abx-storage';
80
- import { cmdTransfer, cmdMint, cmdSetMinter, cmdSetMaxInvocations, cmdConfigureParam, cmdSetParamHooks, cmdSetDependency, cmdRemoveLastDependency, cmdSetDependencyRegistry, cmdLockDependencies, cmdSetPrimaryPayee, cmdPause, cmdUnpause, cmdRefresh, cmdSetTokenUri, cmdSetContractUri, cmdSetRoyalty, cmdSetField, cmdAttach, cmdLockField, cmdSetRenderer, cmdLockUri, cmdSetAdmin, cmdMinterConfigure, cmdMinterShow, cmdMinterBuy, computeContentPlan, envStagingSender, laneFromFlags, ONCHAIN_PROJECT_SOFT_LIMIT, parseCompress, previewImageStaging, sessionStagingSender, stageImageField, stageImageFieldsBatch, authorshipContractFields, AUTHORSHIP_DEPLOY_FIELDS, } from './ownerops.js';
81
+ import { cmdTransfer, cmdMint, cmdSetMinter, cmdSetMaxInvocations, cmdConfigureParam, cmdSetSchema, cmdRetireParam, cmdSetParamHooks, cmdSetDependency, cmdRemoveLastDependency, cmdSetDependencyRegistry, cmdLockDependencies, cmdSetPrimaryPayee, cmdPause, cmdUnpause, cmdRefresh, cmdSetTokenUri, cmdSetContractUri, cmdSetRoyalty, cmdSetField, cmdAttach, cmdLockField, cmdSetRenderer, cmdLockUri, cmdSetAdmin, cmdMinterConfigure, cmdMinterShow, cmdMinterBuy, computeContentPlan, envStagingSender, laneFromFlags, ONCHAIN_PROJECT_SOFT_LIMIT, parseCompress, previewImageStaging, sessionStagingSender, stageImageField, stageImageFieldsBatch, authorshipContractFields, AUTHORSHIP_DEPLOY_FIELDS, } from './ownerops.js';
81
82
  import { openWalletSession, signTx } from './signer.js';
82
83
  import { describeRemoteError, listConfiguredRemotes, misnamedRemoteVars, requireRemoteToken, resolveRemote, serviceClient, tokenSourceLabel } from './remote.js';
83
84
  import { buildMigrationPlan, repinNodeCustody, verifyParity } from './migrate.js';
84
85
  import { activeBackendId, arweaveKeyFilePath, backendResolution, ensureArweaveJwk, factoryAddress, seriesFactoryAddress, fixedPriceMinterAddress, loadArweaveJwk, loopbackBaseUrl, faucetHint, rendererAddress, storageOptions, storageSignerChoice, } from './config.js';
86
+ /**
87
+ * Validate `ABX_CHAIN` at module load, BEFORE anything derives from it.
88
+ *
89
+ * This has to run here, not inside `main()`. Chain-derived constants are evaluated during module
90
+ * evaluation (the `EXPLORER` line right below, and — until they were made lazy — several in the
91
+ * token-api package the CLI imports), so an unknown value threw before `main()` ever ran. The result
92
+ * was a raw Node stack trace quoting an internal source path, for EVERY command, including `doctor`
93
+ * — whose entire job is telling you what is wrong with your environment. A cold agent hit exactly
94
+ * this and reported the tool as broken. A typo should get an answer, not a crash.
95
+ */
96
+ function assertKnownChainEnv() {
97
+ const key = process.env.ABX_CHAIN;
98
+ if (!key || KNOWN_CHAIN_KEYS.includes(key))
99
+ return;
100
+ const mainnetish = /^(mainnet|ethereum|homestead|base|eth|1|8453)$/i.test(key.trim());
101
+ process.stderr.write(`\n\u001b[31m\u2717\u001b[0m ABX_CHAIN="${key}" is not a chain this toolkit ships. Known: ${KNOWN_CHAIN_KEYS.join(', ')}.\n` +
102
+ (mainnetish
103
+ ? ` Mainnet is not supported yet \u2014 ABX is testnet-only today. Use base-sepolia (the default) or sepolia;\n` +
104
+ ` a testnet launch exercises the real thing end to end, just without real money.\n\n`
105
+ : ` Unset it to use the default (${DEFAULT_CHAIN_KEY}), or set one of the above.\n\n`));
106
+ process.exit(1);
107
+ }
108
+ assertKnownChainEnv();
85
109
  const CHAIN = process.env.ABX_CHAIN ?? DEFAULT_CHAIN_KEY;
86
110
  // Block explorer base for the tx/address links the CLI prints. Derived from viem's chain metadata via
87
111
  // the SDK (see explorerUrl) rather than a local table — this used to be a hand-maintained map, which is
@@ -186,6 +210,8 @@ async function main() {
186
210
  case 'set-minter': return cmdSetMinter(rest[0], flags);
187
211
  case 'set-max-invocations': return cmdSetMaxInvocations(rest[0], flags);
188
212
  case 'configure-param': return cmdConfigureParam(rest[0], rest.slice(1), flags);
213
+ case 'set-schema': return cmdSetSchema(rest[0], flags);
214
+ case 'retire-param': return cmdRetireParam(rest[0], rest.slice(1), flags);
189
215
  case 'set-param-hooks': return cmdSetParamHooks(rest[0], flags);
190
216
  case 'set-dependency': return cmdSetDependency(rest[0], rest.slice(1), flags);
191
217
  case 'remove-last-dependency': return cmdRemoveLastDependency(rest[0], flags);
@@ -708,7 +734,51 @@ const imageUrlTemplateField = (template) => ({
708
734
  representation: encodeTag(R.urlTemplate),
709
735
  value: toHex(template),
710
736
  });
737
+ /** Backends that can hand out a publicly-readable URL for uploaded bytes — the ones that make
738
+ * "image off-chain, JSON on-chain, no server" possible. `fs` serves only from this machine. */
739
+ const DIRECT_URL_BACKENDS = new Set(['ipfs', 'arweave', 'cloud']);
711
740
  const looksLikeSvg = (s) => /^\s*<(\?xml|svg)/i.test(s);
741
+ /**
742
+ * Does the IMAGE itself end up on-chain for this deploy? Two ways it can:
743
+ * • `--onchain-image` — the bytes are staged in the chunk store and read back via a `reader` field.
744
+ * • `--onchain-uri` with an SVG — v1 inlines SVG directly as a data: URI (raster cannot be).
745
+ * Anything else falls through to keccak256 custody, and the on-chain renderer serves its PLACEHOLDER
746
+ * image. This distinction is the difference between a token that is durable and one that only looks
747
+ * durable, so it must be computed from the actual file — never inferred from the flag alone (the
748
+ * success banner used to claim "fully on-chain, no hosting needed" for a keccak-anchored raster).
749
+ */
750
+ /**
751
+ * Will `tokenURI` resolve to the creator's REAL image, with nothing to keep running?
752
+ *
753
+ * Three ways yes, and they are not the same promise: the bytes are on-chain
754
+ * ({@link imageEndsUpOnChain}), or they sit at a durable public URL that the on-chain JSON points at
755
+ * (a direct-URL backend — the "no server" pattern). Only `fs` custody under `--onchain-uri` fails,
756
+ * because the renderer then holds a hash and nothing can serve it. Keep this separate from
757
+ * "fully on-chain": conflating them is what let a keccak-anchored raster ship under a permanence
758
+ * banner it did not earn.
759
+ */
760
+ function imageResolvesWithoutServer(flags, onchainImage) {
761
+ if (imageEndsUpOnChain(flags, onchainImage))
762
+ return true;
763
+ return !!flags.image && DIRECT_URL_BACKENDS.has(backendResolution(storageOverrides(flags)).backend);
764
+ }
765
+ function imageEndsUpOnChain(flags, onchainImage) {
766
+ if (onchainImage)
767
+ return true;
768
+ const image = typeof flags.image === 'string' ? flags.image : undefined;
769
+ if (!image)
770
+ return false;
771
+ try {
772
+ const p = resolvePath(image);
773
+ if (!statSync(p).isFile())
774
+ return false;
775
+ // Only the head matters — looksLikeSvg tests the leading tag.
776
+ return looksLikeSvg(readFileSync(p).subarray(0, 256).toString('utf8'));
777
+ }
778
+ catch {
779
+ return false;
780
+ }
781
+ }
712
782
  async function prepareContent(imagePath, clone, overrides, store = true, // false for --dry-run: compute the hash but don't custody bytes
713
783
  onChain = false, // true (--onchain-uri): put the image ON-CHAIN (inline SVG) so it self-resolves
714
784
  remoteEth, // wallet-lane Turbo uploads: sign+pay with the connected browser wallet
@@ -730,9 +800,8 @@ walletAddr) {
730
800
  const path = resolvePath(imagePath);
731
801
  const bytes = new Uint8Array(readFileSync(path));
732
802
  const contentType = contentTypeFromPath(path);
733
- // On-chain mode can inline SVG directly (the renderer emits it as a data: URI). Raster
734
- // can't be inlined cheaply in v1 (a future `reader` is the path) — fall through to
735
- // custody + keccak, and the on-chain renderer will show its placeholder for the image.
803
+ // On-chain mode can inline SVG directly (the renderer emits it as a data: URI).
804
+ let onchainUrlLane = false;
736
805
  if (onChain) {
737
806
  const text = Buffer.from(bytes).toString('utf8');
738
807
  if (looksLikeSvg(text)) {
@@ -741,7 +810,18 @@ walletAddr) {
741
810
  contentNote: `content: ${basename(path)} (${bytes.length} bytes SVG) INLINE on-chain`,
742
811
  };
743
812
  }
744
- warn(`--onchain-uri can't inline ${contentType} on-chain (v1 inlines SVG only) storing in custody; the on-chain image will show a placeholder until a reader is set.`);
813
+ // A raster can't be inlined in v1. That used to mean "fall through to keccak custody and let the
814
+ // renderer show a placeholder" — which quietly made the documented pattern-2 recipe (image
815
+ // off-chain, JSON on-chain, no server) impossible for a 1/1 even though `deploy-series` did it
816
+ // fine. Now the 1/1 takes the same route the Series takes: upload to a durable, publicly-readable
817
+ // backend and bake the resulting URL on-chain as the image field, so `tokenURI` resolves from the
818
+ // chain to a real image with nothing to keep running. Only a backend that can hand out a public
819
+ // read URL qualifies; `fs` (this machine only) still falls through to custody.
820
+ onchainUrlLane = DIRECT_URL_BACKENDS.has(backendResolution(overrides).backend);
821
+ if (!onchainUrlLane) {
822
+ warn(`--onchain-uri can't inline ${contentType} on-chain (v1 inlines SVG only), and '${backendResolution(overrides).backend}' can't serve a public URL — ` +
823
+ `storing in custody, so the on-chain image will be a placeholder. Add ${bold('--backend arweave')} (or ipfs/cloud) to bake a real image URL, or ${bold('--onchain-image')} to put the bytes themselves on-chain.`);
824
+ }
745
825
  }
746
826
  const hash = hashContent(bytes);
747
827
  const opts = storageOptions(overrides); // flags override config/env
@@ -750,6 +830,13 @@ walletAddr) {
750
830
  opts.arweave = { ...opts.arweave, remoteEth, jwk: undefined, ethSignerKey: undefined };
751
831
  noteArweavePlan(opts, bytes.length); // free-vs-credit readout (Turbo) — shown for dry-run too
752
832
  if (!store) {
833
+ const backendId = backendResolution(overrides).backend;
834
+ if (onchainUrlLane) {
835
+ return {
836
+ tokenFields: [imageUrlField(`<${backendId}-url>`)],
837
+ contentNote: `content: ${basename(path)} (${bytes.length} bytes, ${contentType}) → would upload to ${backendId}; its public URL becomes the ON-CHAIN image field (no server)`,
838
+ };
839
+ }
753
840
  return {
754
841
  tokenFields: [imageKeccakField(hash)],
755
842
  contentNote: `content: ${basename(path)} (${bytes.length} bytes, ${contentType}) · image keccak256 ${hash} (would be stored on deploy)`,
@@ -761,6 +848,26 @@ walletAddr) {
761
848
  if (!opts.arweave?.remoteEth)
762
849
  await assertTurboFundsForUpload(opts, [bytes.length], walletAddr);
763
850
  const backend = resolveBackend(opts);
851
+ if (onchainUrlLane) {
852
+ // One file, uploaded as a one-entry directory — the same call `deploy-series` uses for its
853
+ // uniform-extension folder, so both commands produce the same URL shape from the same code path.
854
+ // Named `0.<ext>` because a 1/1 is token 0. Falls back to custody if the backend turns out not to
855
+ // support directories, rather than silently producing a token whose image never resolves.
856
+ const ext = extname(path).toLowerCase() || '.bin';
857
+ if (backend.putDirectory) {
858
+ const { base } = await backend.putDirectory([{ name: `0${ext}`, bytes, contentType }]);
859
+ const url = `${base}/0${ext}`;
860
+ // Keep the bytes under their hash too: `abx verify` can then still prove the served image
861
+ // matches what was uploaded, even though the on-chain field addresses it by URL.
862
+ await backend.put(hash, { bytes, contentType });
863
+ ok(`image → ${url} ${dim(`(${backend.id}; baked on-chain, no server)`)}`);
864
+ return {
865
+ tokenFields: [imageUrlField(url)],
866
+ contentNote: `content: ${basename(path)} (${bytes.length} bytes, ${contentType}) on ${backend.id} · ON-CHAIN image url ${url}`,
867
+ };
868
+ }
869
+ warn(`'${backend.id}' cannot upload a directory, so no public image URL could be baked — falling back to custody (the on-chain image will be a placeholder).`);
870
+ }
764
871
  await backend.put(hash, { bytes, contentType });
765
872
  return {
766
873
  tokenFields: [imageKeccakField(hash)],
@@ -1222,6 +1329,23 @@ async function cmdDeploy(flags, serveAfter) {
1222
1329
  if (!hasPublicUrl)
1223
1330
  info('off-chain fallback pointer left empty (the renderer is authoritative); set --public-base-url to bake one anyway.');
1224
1331
  }
1332
+ // `--onchain-uri` puts the tokenURI JSON on-chain; it does NOT put the IMAGE on-chain. Without
1333
+ // `--onchain-image` the image is committed as a keccak256 anchor, which the on-chain renderer
1334
+ // cannot serve bytes for — so `tokenURI` returns a PLACEHOLDER image. Say that here, before the
1335
+ // spend (this runs in `--dry-run` too), because the success banner used to claim "fully on-chain,
1336
+ // no hosting needed" for exactly this config and a creator would believe they had permanence they
1337
+ // don't. The code lane's dry run already warns about this case; the 1/1 lane shipped it silently.
1338
+ if (onChainUri && !imageResolvesWithoutServer(flags, onchainImage)) {
1339
+ warn(`${bold('--onchain-uri puts the metadata JSON on-chain, not the image.')} With '${backendResolution(storageOverrides(flags)).backend}' custody the image is only ` +
1340
+ `a keccak256 anchor, so ${bold('tokenURI')} will serve a PLACEHOLDER — nothing can fetch the bytes.`);
1341
+ info(`two ways to get a real image with no server: ${bold('--backend arweave')} (or ipfs/cloud) uploads it and bakes the URL on-chain, or ${bold('--onchain-image --compress fastlz')} puts the bytes themselves on-chain (best under ~24KB).`);
1342
+ }
1343
+ else if (onChainUri && !imageEndsUpOnChain(flags, onchainImage)) {
1344
+ // Resolves, durably, with no server — but the IMAGE is not on-chain, and that distinction is
1345
+ // exactly what a creator asking for permanence is buying. Say which one they are getting.
1346
+ info(`${bold('image off-chain, JSON on-chain')} — the image uploads to ${bold(backendResolution(storageOverrides(flags)).backend)} and its URL is baked into the on-chain metadata. ` +
1347
+ `No server to run; permanence is the backend's (Arweave is pay-once-forever, IPFS lasts as long as it stays pinned). For the image itself on-chain, use ${bold('--onchain-image')}.`);
1348
+ }
1225
1349
  // IPFS/Arweave custody serves the image from the gateway URL. A LOCAL gateway produces an image
1226
1350
  // URL only this machine can load — the image equivalent of the localhost-tokenURI footgun above.
1227
1351
  if (!onChainUri && !onchainImage) {
@@ -1524,7 +1648,23 @@ async function cmdDeploy(flags, serveAfter) {
1524
1648
  if (!serveAfter) {
1525
1649
  if (onChainUri) {
1526
1650
  // Fully on-chain → it self-resolves. Prove it from the chain, no server/localhost.
1527
- console.log(`\n${g('Done fully on-chain.')} ${dim('The token self-resolves; no server or hosting needed.')}`);
1651
+ // BUT only claim "fully on-chain" when the IMAGE is on-chain too. With `--onchain-uri` alone
1652
+ // the image is a keccak256 anchor the renderer can't serve, so tokenURI yields a placeholder —
1653
+ // and this banner previously said "no server or hosting needed" over exactly that, which is a
1654
+ // promise of permanence the token does not have. Truthful, per-config wording instead.
1655
+ if (imageEndsUpOnChain(flags, onchainImage)) {
1656
+ console.log(`\n${g('Done — fully on-chain.')} ${dim('The token self-resolves; no server or hosting needed.')}`);
1657
+ }
1658
+ else if (imageResolvesWithoutServer(flags, onchainImage)) {
1659
+ const bk = backendResolution(storageOverrides(flags)).backend;
1660
+ console.log(`\n${g('Done — metadata on-chain, image on ' + bk + '.')} ${dim('No server to run: the on-chain JSON points straight at the image.')}`);
1661
+ console.log(` ${dim(`the image is addressed by URL rather than stored on-chain — its permanence is ${bk}'s (Arweave: paid once, kept forever · IPFS: kept while pinned).`)}`);
1662
+ }
1663
+ else {
1664
+ console.log(`\n${g('Done — metadata on-chain.')} ${dim('tokenURI/contractURI resolve from the chain with no server.')}`);
1665
+ console.log(` ${c.orange}The IMAGE is not on-chain${c.reset} ${dim('— it is a keccak256 anchor, so tokenURI serves a placeholder image.')}`);
1666
+ console.log(` ${dim(`prove it: ${bold(`abx tokenuri ${clone}`)} reports the image field's provenance. To fix it, redeploy with ${bold('--backend arweave')} (URL baked on-chain) or ${bold('--onchain-image')} (bytes on-chain).`)}`);
1667
+ }
1528
1668
  console.log(` ${bold(`abx tokenuri ${clone}`)} ${dim('# read tokenURI(0) straight from the contract + decode the JSON')}`);
1529
1669
  if (noMint)
1530
1670
  console.log(` ${bold(`abx mint ${clone}`)} ${dim('# issue token #0')}`);
@@ -2799,14 +2939,25 @@ async function cmdPreview(flags) {
2799
2939
  for (const s of shots) {
2800
2940
  const t = s.traits && Object.keys(s.traits).length
2801
2941
  ? Object.entries(s.traits).map(([k, v]) => `${k} ${String(v)}`).join(' · ')
2802
- : `${c.orange}no traits reported${c.reset}`;
2803
- console.log(` ${dim(s.seed.slice(0, 10) + '…')} ${t}${s.done ? '' : dim(' (no abx.done())')}`);
2942
+ : s.timedOut
2943
+ ? `${c.orange}timed out traits unknown${c.reset}`
2944
+ : `${c.orange}no traits reported${c.reset}`;
2945
+ console.log(` ${dim(s.seed.slice(0, 10) + '…')} ${t}${s.done || s.timedOut ? '' : dim(' (no abx.done())')}`);
2804
2946
  }
2947
+ const timedOut = shots.filter((s) => s.timedOut).length;
2805
2948
  const silent = shots.filter((s) => !s.traits || !Object.keys(s.traits).length).length;
2806
- if (silent === shots.length) {
2949
+ // A timeout is a measurement failure, not a finding about the program — so it must never be
2950
+ // reported as one. The old code printed the "NO frame reported traits" alarm whenever traits
2951
+ // came back empty for any reason, which condemned a CORRECT program on a loaded machine and
2952
+ // inverted the one line agents are told to trust.
2953
+ if (timedOut) {
2954
+ warn(`${timedOut} of ${shots.length} frame(s) hit the ${Math.round((Number(flags['timeout-ms'] ?? 10_000) || 10_000) / 1000)}s wait with nothing reported — ` +
2955
+ `that is a TIMEOUT, not a verdict on the program. Re-run with ${bold('--timeout-ms 30000')} (or on an idle machine) before believing anything about its traits.`);
2956
+ }
2957
+ if (silent === shots.length && !timedOut) {
2807
2958
  warn('NO frame reported traits — `abx.traits({…})` is the only thing that becomes marketplace `attributes`. Verify with `abx inspect`.');
2808
2959
  }
2809
- else if (shots.length > 1 && new Set(shots.map((s) => JSON.stringify(s.traits))).size === 1) {
2960
+ else if (silent < shots.length && shots.length > 1 && new Set(shots.map((s) => JSON.stringify(s.traits))).size === 1) {
2810
2961
  // Only meaningful when traits DID come back: identical values across seeds is the signature
2811
2962
  // of a sketch that never reads `abx.tokenData.seed` (prototyped on Math.random()), which
2812
2963
  // deploys as N visually identical tokens. Skipped when nothing reported at all — the
@@ -3613,8 +3764,15 @@ async function cmdDeployCode(flags) {
3613
3764
  // has nothing to run — point at the from-chain check instead.
3614
3765
  if (hasImageRenderer && !hasProgram)
3615
3766
  info(` ${g('nothing to run')} — verify from chain: ${bold('abx tokenuri ' + (predicted ?? '<address>'))} ${dim('(decodes name + on-chain SVG + traits)')}`);
3616
- else
3617
- info(` ${dim('stand up the runner:')} ${bold('abx deploy-effects --resolver-url ' + baseUrl)} · one-shot: ${bold('abx render ' + (predicted ?? '<address>') + (onChainUri && hasImageBase ? '' : ' --remote ' + baseUrl))} · verify: ${bold('abx verify ' + (predicted ?? '<address>'))}`);
3767
+ else {
3768
+ const remoteRender = !(onChainUri && hasImageBase);
3769
+ 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>'))}`);
3770
+ // Rendering against a resolver you don't share a disk with means YOU hold the bytes and it
3771
+ // holds the URL — so say now which backend that needs, rather than letting the render fail.
3772
+ if (remoteRender && !resolveBackend(storageOptions(storageOverrides(flags))).locator) {
3773
+ info(` ${dim('that render publishes a URL, so it needs a backend that can name one:')} ${bold('--backend cloud')} ${dim('(S3/R2 + public base) ·')} ${bold('ipfs')} ${dim('·')} ${bold('arweave')} ${dim('— or render co-located with the resolver (`abx effects` on its host).')}`);
3774
+ }
3775
+ }
3618
3776
  if (dirUpload)
3619
3777
  await noteStorageReadiness(storageOptions(storageOverrides(flags)), dirUpload.sizes);
3620
3778
  if (deployer && salt && !parseSaltFlag(flags.salt)) {
@@ -3675,7 +3833,7 @@ async function cmdDeployCode(flags) {
3675
3833
  });
3676
3834
  const setupCalls = (owner) => [
3677
3835
  ...scriptChunks.map((chunk, i) => encodeFunctionData({ abi: seriesCodeAbi, functionName: 'setScriptChunk', args: [BigInt(i), chunk] })),
3678
- ...schemas.map(({ key, paramType, auth, min, max, selectOptions }) => encodeFunctionData({ abi: seriesCodeAbi, functionName: 'setParamSchema', args: [encodeTag(key), paramType, auth, zeroAddress, 0, min, max, selectOptions] })),
3836
+ ...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] })),
3679
3837
  ...dependencySetupCalls(deps, depRegistry), // setDependency legs in index order (+ the soft registry pointer)
3680
3838
  ...(onchainUriLegs?.calls ?? []), // program lane only: animation_url field · params.keys · the URI renderers (before the mints)
3681
3839
  ].concat(Array.from({ length: setupMintCount }, () => encodeFunctionData({ abi: seriesCodeAbi, functionName: 'mint', args: [owner] }))); // folded mints ride init, not here
@@ -3686,7 +3844,7 @@ async function cmdDeployCode(flags) {
3686
3844
  const calls = setupCalls(owner);
3687
3845
  const txs = [prepareDeploySeriesCode({ factory, params: initParamsFor(owner), salt, chainId: cid, clone })];
3688
3846
  if (calls.length)
3689
- txs.push(prepareCodeSetup({ contract: clone, calls, chainId: cid, chunkCount: scriptChunks.length, schemaKeys: schemas.map((x) => x.key), deps: deps.map((d) => d.display), onchainUri: onChainUri && hasProgram }));
3847
+ 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 }));
3690
3848
  return { clone, txs };
3691
3849
  };
3692
3850
  let clone;
@@ -3738,8 +3896,19 @@ async function cmdDeployCode(flags) {
3738
3896
  // the mempool rejects the duplicate as "replacement transaction underpriced" and the setup leg is
3739
3897
  // lost (leaving a deployed-but-unconfigured clone). Fetch once, then increment per send.
3740
3898
  let nonce = await publicClient.getTransactionCount({ address: account.address, blockTag: 'pending' });
3741
- for (const tx of txs) {
3742
- const hash = await wallet.sendTransaction({ to: tx.to, data: tx.data, account, chain: wallet.chain, nonce });
3899
+ for (const [i, tx] of txs.entries()) {
3900
+ // The SAME read-after-write lag the nonce is pinned for, one field over. Every tx after the
3901
+ // first targets the clone the FIRST tx created; if the node serving `eth_estimateGas` hasn't
3902
+ // seen that block yet, the target looks codeless and the estimate comes back as the CALLDATA
3903
+ // cost alone (~200k). That cannot fund the CREATE inside `SSTORE2.write` (~200 gas per stored
3904
+ // byte), so the CREATE returns 0 and the tx reverts `DeploymentFailed()` — which is what a
3905
+ // reporter hit on Base Sepolia on every `deploy-code` attempt, in both lanes: 201,616 sent
3906
+ // where 941,331 was needed. Wait for the code to be visible, then PIN the gas ourselves
3907
+ // (never re-read at send time), flooring it at what the payload provably costs.
3908
+ if (i > 0 && tx.to)
3909
+ await waitForCodeAt(publicClient, tx.to);
3910
+ const gas = await pinGas(publicClient, { from: account.address, to: tx.to, data: tx.data, gasFloor: tx.gasFloor });
3911
+ const hash = await wallet.sendTransaction({ to: tx.to, data: tx.data, account, chain: wallet.chain, nonce, gas });
3743
3912
  nonce += 1;
3744
3913
  const receipt = await publicClient.waitForTransactionReceipt({ hash });
3745
3914
  if (receipt.status !== 'success')
@@ -3892,6 +4061,10 @@ async function cmdRender(address, tokenIds, flags) {
3892
4061
  const remote = remoteFlag(flags);
3893
4062
  const resolverUrl = (remote?.url ?? process.env.ABX_RESOLVER_URL ?? resolveBaseUrl()).replace(/\/$/, '');
3894
4063
  const adminToken = remote ? requireRemoteToken(remote) : undefined;
4064
+ // Refuse the combination that can't work before launching Chromium (a laptop render against a
4065
+ // hosted resolver, on a backend with no public URL) — the render would end in a 400 either way.
4066
+ if (remote)
4067
+ requirePublishableBackend(flags, 'abx render --remote');
3895
4068
  // Co-located (no --remote): record each declared output into the shared store's artifact
3896
4069
  // registry so the local resolver's `artifacts` manifest enumerates it. Remote: the publish
3897
4070
  // lane (adminToken) records rows on the hosted resolver instead.
@@ -3916,6 +4089,41 @@ async function cmdRender(address, tokenIds, flags) {
3916
4089
  else
3917
4090
  ok(summary);
3918
4091
  }
4092
+ /**
4093
+ * The publish topology's one hard prerequisite, checked BEFORE any capture.
4094
+ *
4095
+ * A resolver that doesn't share this machine's disk serves referenced output (the still, a video, a
4096
+ * model) by **redirect** — so it needs a URL, and it refuses the bytes (`specs/protocol/effects.md →
4097
+ * Bound vs referenced`). A backend that can't name a locator therefore has no publish lane at all,
4098
+ * and every render against one would be work spent to earn a 400.
4099
+ *
4100
+ * So this refuses up front and names the ways out, rather than warning and letting the render run.
4101
+ * It deliberately does NOT pick a backend for the operator: which store holds their art — and which
4102
+ * gateway serves it — is theirs to decide. The three that work are peers, not a ranking: `https://`
4103
+ * from S3/R2, an IPFS gateway, and Arweave are all fine, because derived output is re-creatable and
4104
+ * the only real requirement is that a third party can fetch it.
4105
+ */
4106
+ function requirePublishableBackend(flags, what) {
4107
+ const backend = resolveBackend(storageOptions(storageOverrides(flags)));
4108
+ // `cloud` is the one backend that HAS a locator method and can still return null: the URL is
4109
+ // `<publicBase>/<key>`, so without a public base it can name nothing. Catch that here too — it
4110
+ // would otherwise be the same failure one render later.
4111
+ if (backend.id === 'cloud' && !backend.publicBase) {
4112
+ throw new Error(`${what} hands the resolver a URL for each render, but the 'cloud' backend has no public read base — ` +
4113
+ `set ${bold('ABX_S3_PUBLIC_BASE')} to the bucket's public URL (an R2/CloudFront/S3-website base), ` +
4114
+ `or use ${bold('--backend ipfs')} / ${bold('--backend arweave')}, or render co-located with the resolver.`);
4115
+ }
4116
+ if (backend.locator)
4117
+ return;
4118
+ const { backend: name, source } = backendResolution(storageOverrides(flags));
4119
+ throw new Error(`${what} publishes each render to a resolver that doesn't share this machine's disk, but the '${name}' backend ` +
4120
+ `(${source === 'default' ? 'the default' : `from ${source}`}) can't produce a URL for what it stores — so the resolver would have nothing to serve.\n` +
4121
+ ` Point it at a backend that can (equal options — pick on cost/ops, not durability dogma):\n` +
4122
+ ` ${bold('--backend cloud')} S3 / R2 / B2 with a public base (ABX_S3_* + ABX_S3_PUBLIC_BASE)\n` +
4123
+ ` ${bold('--backend ipfs')} Pinata or your own Kubo + a public gateway (PINATA_JWT / ABX_IPFS_*)\n` +
4124
+ ` ${bold('--backend arweave')} pay-once permanent (uploads under 100KiB are free, no setup)\n` +
4125
+ ` …or run co-located instead: ${bold('abx effects')} on the same host as ${bold('abx serve')}, sharing one backend and no remote token.`);
4126
+ }
3919
4127
  /**
3920
4128
  * `abx effects` — run the reference effect runner LOCALLY, in-process (the local counterpart to
3921
4129
  * `abx deploy-effects`, which scaffolds a HOSTED runner). This is what makes a param change / new
@@ -3935,6 +4143,8 @@ async function cmdEffects(flags) {
3935
4143
  // the shared artifact registry so the resolver's `artifacts` manifest enumerates it. With an
3936
4144
  // admin token, the publish lane records rows on the hosted resolver instead.
3937
4145
  const localStore = adminToken ? null : new SqliteStore();
4146
+ if (adminToken)
4147
+ requirePublishableBackend(flags, 'abx effects against a remote resolver');
3938
4148
  const { EffectRunner, renderEffect } = await loadEffects();
3939
4149
  const runner = new EffectRunner({
3940
4150
  resolverUrl,
@@ -4795,6 +5005,36 @@ async function cmdState(address, flags) {
4795
5005
  if (royalty)
4796
5006
  info(`royalty ${Number(royalty[1]) / 100}% → ${royalty[0]}`);
4797
5007
  info(`renderer ${renderer && renderer !== zeroAddress ? `on-chain (${renderer})` : dim('off-chain (stored URI base / override)')}`);
5008
+ // The governed PostParam surface. There is no on-chain enumeration of schema keys, so the source
5009
+ // is the project's own `params.keys` list — which is also what the on-chain generator reads, so
5010
+ // anything missing from it is invisible to renders anyway. Read-only, best-effort, and skipped
5011
+ // entirely for a project that has none (a 1/1, or a code project with no params).
5012
+ try {
5013
+ const csv = await readParamsKeys(publicClient, address);
5014
+ const keys = (csv ?? '').split(',').map((k) => k.trim()).filter((k) => k && k !== 'params.keys');
5015
+ if (keys.length) {
5016
+ const rows = await Promise.all(keys.map(async (key) => ({ key, s: await readParamSchema(publicClient, address, key) })));
5017
+ const governed = rows.filter((r) => r.s.exists);
5018
+ const now = Math.floor(Date.now() / 1000);
5019
+ if (governed.length) {
5020
+ console.log(`\n ${bold('PostParams')} ${dim(`${governed.length} governed · collectors/artist set these; the generator injects them`)}`);
5021
+ for (const { key, s } of governed) {
5022
+ const retired = s.lockAfter !== 0 && s.lockAfter < now;
5023
+ const locks = s.lockAfter !== 0 && !retired ? dim(` locks ${new Date(s.lockAfter * 1000).toISOString().slice(0, 10)}`) : '';
5024
+ const tag = retired ? ` ${c.orange}retired${c.reset}${dim(' — writes revert')}` : locks;
5025
+ console.log(` ${describeSchema({ key, paramType: s.paramType, auth: s.auth, authAddress: s.authAddress, lockAfter: 0, min: s.min, max: s.max, selectOptions: s.selectOptions })}${tag}`);
5026
+ }
5027
+ }
5028
+ // Listed but ungoverned: the generator will inject the key with whatever raw value is set (or
5029
+ // nothing). Not an error — say it plainly rather than leaving a silent discrepancy.
5030
+ const ungoverned = rows.filter((r) => !r.s.exists).map((r) => r.key);
5031
+ if (ungoverned.length)
5032
+ info(`${dim('also in params.keys, no schema:')} ${ungoverned.join(', ')}`);
5033
+ }
5034
+ }
5035
+ catch {
5036
+ /* best-effort — a non-code project has no params surface to read */
5037
+ }
4798
5038
  console.log('');
4799
5039
  }
4800
5040
  /**
@@ -5320,10 +5560,16 @@ async function cmdDoctor(flags) {
5320
5560
  try {
5321
5561
  const backend = resolveBackend(storageOptions());
5322
5562
  const h = await backend.health?.();
5563
+ // Whether this backend can name a public URL for what it stores decides one thing operators hit
5564
+ // later: a REMOTE effects runner needs it (the resolver serves referenced output by redirect, so
5565
+ // it takes a URL and refuses bytes). Co-located rendering doesn't care — hence a note, not a fail.
5566
+ const publishable = backend.locator
5567
+ ? 'can publish to a remote resolver'
5568
+ : 'local-only — a remote effects runner needs cloud/ipfs/arweave (co-located rendering is fine)';
5323
5569
  if (h)
5324
- check('storage', h.ok, `${backend.id} · ${h.detail ?? ''}`);
5570
+ check('storage', h.ok, `${backend.id} · ${h.detail ?? ''} · ${publishable}`);
5325
5571
  else
5326
- check('storage', true, `${backend.id} · configured`);
5572
+ check('storage', true, `${backend.id} · configured · ${publishable}`);
5327
5573
  }
5328
5574
  catch (err) {
5329
5575
  check('storage', false, `${backendId} · ${err.message}`);
@@ -5532,13 +5778,19 @@ async function cmdDeployEffects(flags) {
5532
5778
  const intervalMs = flags['interval-ms'] ? Number(flags['interval-ms']) : undefined;
5533
5779
  const environmentId = flags['env-id'] ?? process.env.ABX_ENVIRONMENT_ID;
5534
5780
  const storageBackend = process.env.ABX_STORAGE_BACKEND;
5535
- // A HOSTED runner must publish renders where the resolver can read them a durable locator
5536
- // (ipfs/arweave) or a shared bucket (s3). The default `fs` writes to the runner CONTAINER's disk,
5537
- // which the resolver can't see the thumbnail never clears. Flag it before scaffolding (not just
5538
- // in the artifact's comments), since it's the #1 silent misconfig for a hosted effects runner.
5781
+ // A HOSTED runner holds its own render bytes and hands the resolver a URLthat's the whole
5782
+ // topology (`specs/protocol/effects.md Bound vs referenced`). The default `fs` writes to the
5783
+ // runner CONTAINER's disk, which nothing else can reach, so there is no URL to publish: the runner
5784
+ // now REFUSES to start on that config. Scaffolding it anyway would just deploy a container that
5785
+ // exits, so this is a hard stop rather than the warning it used to be.
5539
5786
  if (!storageBackend || storageBackend === 'fs') {
5540
- warn(`ABX_STORAGE_BACKEND is ${storageBackend ? "'fs'" : 'unset (defaults to fs)'} — a HOSTED runner can't publish 'fs' renders anywhere the resolver can read ` +
5541
- `(the thumbnail will stay a placeholder). Set ABX_STORAGE_BACKEND to ${bold('ipfs')} / ${bold('arweave')} (durable locator) or ${bold('s3')} before deploying the runner.`);
5787
+ throw new Error(`ABX_STORAGE_BACKEND is ${storageBackend ? "'fs'" : 'unset (defaults to fs)'} — a hosted runner can't serve renders off its own container disk, ` +
5788
+ `and it refuses to start without a backend that can name a public URL for what it stores.\n` +
5789
+ ` Set ABX_STORAGE_BACKEND to one of (equal options — pick on cost/ops):\n` +
5790
+ ` ${bold('cloud')} (alias s3) S3 / R2 / B2 with ABX_S3_* + a public base\n` +
5791
+ ` ${bold('ipfs')} Pinata or your own Kubo + a public gateway\n` +
5792
+ ` ${bold('arweave')} pay-once permanent (<100KiB uploads are free)\n` +
5793
+ ` …then re-run. (Rendering on the resolver's own host instead? Use ${bold('abx effects')} co-located — no publish lane, no locator needed.)`);
5542
5794
  }
5543
5795
  const repoRoot = findRepoRoot();
5544
5796
  if (!repoRoot) {
@@ -5560,10 +5812,7 @@ async function cmdDeployEffects(flags) {
5560
5812
  const outDir = resolvePath(joinPath(dir, 'effects'));
5561
5813
  console.log(bold(`\n ABX Self-Host Toolkit — provision effects runner (fly)`));
5562
5814
  info(`resolver: ${bold(cleanResolver)} ${dim('(reads token state + publishes renders here)')}`);
5563
- info(`storage home: ${bold(storageBackend ?? 'fs')} ${dim('— where rendered thumbnails live; the resolver redirects/serves from here')}`);
5564
- if (!storageBackend || storageBackend === 'fs') {
5565
- warn('storage backend is fs (LOCAL) — a HOSTED runner needs a PUBLIC home (ipfs/arweave/s3) so the resolver can serve the render. Set ABX_STORAGE_BACKEND before deploying, or the runner will push raw bytes to the resolver.');
5566
- }
5815
+ info(`storage home: ${bold(storageBackend)} ${dim('— the runner HOLDS the render bytes here and publishes their URL; the resolver redirects to it and never proxies')}`);
5567
5816
  step('Write the effects artifact (self-contained — Dockerfile.effects + fly.toml + vendored source)');
5568
5817
  mkdirSync(outDir, { recursive: true });
5569
5818
  for (const f of art.files) {
@@ -5772,7 +6021,10 @@ const COMMAND_HELP = {
5772
6021
  (no --public-base-url needed); the ${g('params.keys')} contract param is written automatically from the schema
5773
6022
  flags. The marketplace still is rendered off-chain by the effect runner ${bold('for a JS program')} (--script/--code-dir) — but with ${g('--image-renderer')} the image is computed ON-CHAIN too (nothing to render). Verify with ${g('abx verify <addr>')}.
5774
6023
  (--generator 0x.. / --renderer 0x.. override the canonical singletons; ABX_GENERATOR / ABX_RENDERER via env.)
5775
- --schema key:Type:Auth,… governed PostParams (e.g. ${g('palette:HexColor:TokenOwner')}) — reconfigure later with ${g('abx configure-param')}
6024
+ --schema key:Type:Auth,… governed PostParams (e.g. ${g('palette:HexColor:TokenOwner')}) — set values later with ${g('abx configure-param')},
6025
+ change the SCHEMA later with ${g('abx set-schema')}. An Address leg names its writer inline —
6026
+ ${g('key:Type:Address(0x…)')} (a CONTRACT may hold it: the controller pattern for open participation).
6027
+ Append ${g(':lock=<when>')} (ISO date, unix seconds, or ${g('now')}) to freeze the value after that time.
5776
6028
  ${dim('repeatable OR comma-separable (like --dep): ')}${g('--schema a --schema b')}${dim(' ≡ ')}${g('--schema a,b')}
5777
6029
  Type ∈ Bool·Select·Uint256Range·Int256Range·DecimalRange·HexColor·Timestamp·String·Bytes; Auth ∈ Artist·TokenOwner·Address (+ Or-combos).
5778
6030
  ${bold('a Select declares its options')}: ${g('mood:Select[Calm|Wild|Chaotic]:TokenOwner')}; ${bold('a Range takes bounds')}: ${g('density:Uint256Range[0..100]:TokenOwner')} (Int/Decimal/Timestamp too; bounds optional)
@@ -5797,6 +6049,7 @@ const COMMAND_HELP = {
5797
6049
  --backend ipfs|arweave directory-mode custody for the build upload ${g('--unpaused')} · --minter 0x.. · --primary-payee 0x..
5798
6050
  ${dim('a fixed-price PRIMARY SALE is set up AFTER deploy, not by these flags: ')}${g('abx minter configure <addr> --price <eth>')}${dim(' → ')}${g('set-minter')}${dim(' → ')}${g('set-primary-payee')}${dim(' → ')}${g('unpause')}${dim(' (see `abx minter --help`). --minter/--primary-payee here only pre-authorize an already-known minter.')}
5799
6051
  signing: ${g('--send')} (default) · ${g('--sign')} · ${g('--unsigned')} ${dim('(needs --for 0x..)')} ${g('--bootstrap-factory')} ${dim('(private chains/sandboxes only)')}
6052
+ ${g('--yes')} accept inferred --name/--symbol defaults (the real send otherwise REFUSES to bake placeholders)
5800
6053
  ${g('--dry-run')} preview (address · resolver base · schema · mint plan · tx count) — nothing sent, no bytes pinned
5801
6054
  ${g('--confirm')} interactive y/N gate before the real send
5802
6055
  ${bold('After deploy — render the marketplace still')} ${dim('(JS lanes only — --script/--code-dir; with --image-renderer the image is on-chain, nothing to render)')}:
@@ -5807,8 +6060,12 @@ const COMMAND_HELP = {
5807
6060
  No tokenId → sweeps all minted tokens; pass ids (${g('0 1 2')}) to target specific tokens.
5808
6061
  ${g('--force')} RE-RENDER even when the still already exists — the fix for a bad / blank / timed-out capture
5809
6062
  (the art is otherwise deterministic, so a plain render idempotent-skips an existing still). Overwrites it (+ republishes on --remote).
5810
- ${g('--remote [name|url]')} publish each render to a REMOTE resolver (the locator bridge: upload to ${bold('ABX_STORAGE_BACKEND')}, POST /v1/effect-artifacts).
5811
- Idempotent; a re-run restores a resolver that lost its volume. Needs its token (a named remote's
6063
+ ${g('--remote [name|url]')} register each render with a REMOTE resolver: the bytes go to ${bold('ABX_STORAGE_BACKEND')} (YOU hold them), and
6064
+ POST /v1/effect-artifacts hands the resolver the URL it redirects there and never proxies. Traits are the
6065
+ exception: they stitch into the token JSON, so their content (≤64KB) goes to the resolver itself.
6066
+ ${bold('Requires a backend that can name a public URL')} — ${g('--backend cloud')} (S3/R2 + public base), ${g('ipfs')}, or ${g('arweave')};
6067
+ equal options, pick on cost/ops. The default ${g('fs')} is refused up front rather than after the render.
6068
+ Idempotent; a re-run re-registers rows on a resolver that lost them. Needs its token (a named remote's
5812
6069
  ${g('ABX_REMOTE_<NAME>_TOKEN')}, else ${g('ABX_RESOLVER_ADMIN_TOKEN')}).
5813
6070
  --effects-url <url> enqueue on a running effect-runner service instead of rendering inline (else ${g('ABX_EFFECTS_URL')})
5814
6071
  ${dim('Inline (no --remote) renders on THIS machine (needs `npx playwright install chromium` once), pointing Chromium at the live')}
@@ -5825,9 +6082,30 @@ const COMMAND_HELP = {
5825
6082
  --port <n> HTTP port (default ${g('ABX_EFFECTS_PORT')} / 8788) — ${g('POST /notify')} enqueues (watcher lane) · ${g('POST /run')} sweeps synchronously (command lane)
5826
6083
  --interval-ms <n> the SAFETY-FLOOR sweep (default ${g('ABX_EFFECTS_INTERVAL_MS')} / 300000) — catches a missed notify / cold start; the watcher is the trigger
5827
6084
  --concurrency <n> parallel renders while draining (default ${g('ABX_EFFECTS_CONCURRENCY')} / 1 — Chromium is heavy; raise deliberately)
5828
- ${dim('Co-located with a local `abx serve` (same store) → no tokens needed. Against a REMOTE resolver: --remote <name|url> (its')}
5829
- ${dim('token publishes renders + reports status), or ABX_RESOLVER_URL + ABX_RESOLVER_ADMIN_TOKEN. PUBLIC runner? set ABX_EFFECTS_TOKEN — it gates /run + /notify.')}
6085
+ ${dim('Co-located with a local `abx serve` (same store) → no tokens needed, any backend works (fs included).')}
6086
+ ${dim('Against a REMOTE resolver: --remote <name|url> (its token registers renders + reports status), or ABX_RESOLVER_URL +')}
6087
+ ${dim('ABX_RESOLVER_ADMIN_TOKEN — and a backend that can name a public URL (cloud/ipfs/arweave), since the resolver takes the')}
6088
+ ${dim('URL and refuses the bytes. PUBLIC runner? set ABX_EFFECTS_TOKEN — it gates /run + /notify.')}
5830
6089
  ${dim('To HOST the runner (fly/docker), use `abx deploy-effects`.')}`,
6090
+ 'set-schema': `
6091
+ ${bold('abx set-schema')} <address> ${g('--schema key:Type:Auth[:lock=<when>]')} ${dim('— attach or replace ONE PostParam schema. Owner-only. Sends a tx.')}
6092
+ A project's param surface is NOT frozen at deploy: ${g('setParamSchema')} is owner-gated with no deploy-time restriction,
6093
+ so you can add a key a piece turned out to need without redeploying (and losing the address, mints, and collectors).
6094
+ ${bold('This is a full-row upsert, not a patch.')} Replacing an existing schema rewrites every field — restate anything you
6095
+ want to keep, including an existing ${g('lock=')}. The contract does ${bold('not')} re-validate values already stored under the key,
6096
+ so narrowing a bound, dropping a Select option, or changing the type strands them; that is refused unless you pass
6097
+ ${g('--force')}. A key can never be un-governed — to decommission one use ${g('abx retire-param')}.
6098
+ ${g('--force')} apply a change that could strand already-stored values
6099
+ ${g('--dry-run')} print the exact tx and send nothing`,
6100
+ 'retire-param': `
6101
+ ${bold('abx retire-param')} <address> <key> ${dim('— permanently stop further writes to a PostParam. Owner-only. Sends a tx.')}
6102
+ Sets the schema's ${g('lockAfter')} into the past, so every later ${g('configure-param')} reverts ${g('ParamLockExpired')}. This is the
6103
+ closest the protocol comes to deleting a parameter, and it is ${bold('irreversible')}.
6104
+ Reads the current schema and changes ONLY the lock, so type/auth/bounds/options are carried forward untouched.
6105
+ What it does NOT do: remove the key (a governed key stays governed forever) or erase a value already stored — that
6106
+ value keeps serving in token data. A value written under a TokenOwner/Address leg came from a collector, and the
6107
+ artist deliberately cannot delete it.
6108
+ ${g('--dry-run')} print the exact tx and send nothing`,
5831
6109
  'configure-param': `
5832
6110
  ${bold('abx configure-param')} <address> <tokenId|-> <key> <value> ${dim('— set a PostParam (typed, canonical encode). Sends a tx.')}
5833
6111
  Reads the on-chain schema for <key> and canonically encodes <value> (${g('#rrggbb')} for HexColor, fixed-decimal ranges,
@@ -6142,6 +6420,8 @@ function help() {
6142
6420
  --dep-registry 0x.. · --description "<s>" · --external-url <url> · ${g('--image-base <url>')} (off-chain thumbnails at a deterministic /{id} URL) ·
6143
6421
  ${g('--attributes-renderer 0x..')} (traits on-chain) · --max N · ${g('--mint-all')}|${g('--mint-count')} N · --backend ipfs|arweave · ${g('--dry-run')}
6144
6422
  ${g('abx configure-param')} <addr> <id> <key> <value> set a governed PostParam (typed encode; a change re-addresses the render)
6423
+ ${g('abx set-schema')} <addr> --schema key:Type:Auth[:lock=<when>] attach or replace ONE key's schema, any time after deploy
6424
+ ${g('abx retire-param')} <addr> <key> stop all further writes to a param, permanently (the closest thing to removing one)
6145
6425
  ${g('abx set-dependency')} <addr> <index> <ref> declare/replace a code dependency (name@version via the registry pointer, or 0x.. on-chain)
6146
6426
  ${g('abx remove-last-dependency')} <addr> · ${g('abx set-dependency-registry')} <addr> <0x..|none> · ${g('abx lock-dependencies')} <addr> operate/freeze the set
6147
6427
  ${g('abx render')} <addr> [id…] render the still + traits ONCE (the repair lane) [${g('--force')} re-render] [${g('--remote')} [url] publish to a hosted resolver]
@@ -6215,18 +6495,25 @@ function parseSaltFlag(raw) {
6215
6495
  /** Locate the skill folder: the bundled copy beside the compiled CLI (published), else the
6216
6496
  * canonical repo copy (dev). Returns null if neither is present. */
6217
6497
  function resolveBundledSkill() {
6218
- // Published layout: dist/main.js → <pkg>/skill (see scripts/bundle-skill.mjs).
6219
- const pkgDir = resolvePath(fileURLToPath(import.meta.url), '..', '..');
6498
+ const here = fileURLToPath(import.meta.url);
6499
+ const pkgDir = resolvePath(here, '..', '..');
6220
6500
  const bundled = joinPath(pkgDir, 'skill');
6501
+ // Running from SOURCE (`pnpm abx`, tsx on packages/cli/src/**) means we're in the repo working
6502
+ // tree, where the canonical skill is the source of truth and `<pkg>/skill` is gitignored PREPACK
6503
+ // OUTPUT that may be arbitrarily old. Preferring the bundle there is actively destructive: a
6504
+ // leftover `skill/` from a months-old `npm pack` silently overwrote the canonical skill with a
6505
+ // copy 8 versions behind (and the drift check then reported the damage as if the user had caused
6506
+ // it). In the published layout there is no repo and no canonical copy, so the bundle is correct.
6507
+ const fromSource = /[/\\]src[/\\][^/\\]+$/.test(here);
6508
+ const root = findRepoRoot();
6509
+ const canonical = root ? joinPath(root, '.claude', 'skills', 'abx-self-host') : null;
6510
+ if (fromSource && canonical && existsSync(joinPath(canonical, 'SKILL.md')))
6511
+ return canonical;
6512
+ // Published layout: dist/main.js → <pkg>/skill (see scripts/bundle-skill.mjs).
6221
6513
  if (existsSync(joinPath(bundled, 'SKILL.md')))
6222
6514
  return bundled;
6223
- // Dev layout: the canonical skill in the repo working tree.
6224
- const root = findRepoRoot();
6225
- if (root) {
6226
- const canonical = joinPath(root, '.claude', 'skills', 'abx-self-host');
6227
- if (existsSync(joinPath(canonical, 'SKILL.md')))
6228
- return canonical;
6229
- }
6515
+ if (canonical && existsSync(joinPath(canonical, 'SKILL.md')))
6516
+ return canonical;
6230
6517
  return null;
6231
6518
  }
6232
6519
  /** Locate the in-chain renderer Foundry scaffold: bundled beside the CLI (published), else the
@@ -6298,11 +6585,20 @@ function resolveInstallParents(agentFlag) {
6298
6585
  return [...parents];
6299
6586
  }
6300
6587
  /** Copy the bundled skill folder to `dest`, replacing any prior copy so a re-install after an
6301
- * upgrade never leaves stale reference files behind. */
6588
+ * upgrade never leaves stale reference files behind.
6589
+ *
6590
+ * Returns `'already-canonical'` when destination IS the source, having done nothing. That case is
6591
+ * not hypothetical: in a dev checkout of the abx repo `resolveBundledSkill()` resolves to the
6592
+ * canonical `.claude/skills/abx-self-host`, which is exactly where a cwd-relative install writes —
6593
+ * so the `rmSync` below would delete the canonical skill, and the copy would then have no source.
6594
+ * A self-destructing install is a bad way to learn that. */
6302
6595
  function installSkillTo(src, dest) {
6596
+ if (resolvePath(src) === resolvePath(dest))
6597
+ return 'already-canonical';
6303
6598
  mkdirSync(joinPath(dest, '..'), { recursive: true });
6304
6599
  rmSync(dest, { recursive: true, force: true });
6305
6600
  cpSync(src, dest, { recursive: true });
6601
+ return 'installed';
6306
6602
  }
6307
6603
  /**
6308
6604
  * Install the bundled skill into the default per-agent parents (or `~` with `global`), reporting
@@ -6316,9 +6612,10 @@ function installSkillToDefaults(src, opts = {}) {
6316
6612
  ok(`installed the abx skill v${version}${opts.global ? ' (global, ~)' : ''}:`);
6317
6613
  for (const parent of parents) {
6318
6614
  const dest = joinPath(base, parent, SKILL_DIR_NAME);
6319
- installSkillTo(src, dest);
6615
+ const outcome = installSkillTo(src, dest);
6320
6616
  const label = SKILL_PARENT_LABELS[parent];
6321
- console.log(` ${g(joinPath(parent, SKILL_DIR_NAME))}${label ? dim(' → ' + label) : ''}`);
6617
+ const note = outcome === 'already-canonical' ? dim(' (already the canonical copy — left as is)') : label ? dim(' → ' + label) : '';
6618
+ console.log(` ${g(joinPath(parent, SKILL_DIR_NAME))}${note}`);
6322
6619
  }
6323
6620
  info('restart your agent so it loads the skill, then ask it to launch an NFT with abx.');
6324
6621
  info(`the skill is version-locked to this CLI (v${version}); re-run ${g('abx skill install')} after upgrading so the two stay in sync.`);
@@ -6346,7 +6643,10 @@ async function cmdSkill(rest, flags) {
6346
6643
  // whose skills dir we don't special-case, or a bespoke location).
6347
6644
  if (typeof flags.target === 'string' && flags.target !== 'true') {
6348
6645
  const dest = joinPath(resolvePath(flags.target), SKILL_DIR_NAME);
6349
- installSkillTo(src, dest);
6646
+ if (installSkillTo(src, dest) === 'already-canonical') {
6647
+ ok(`${dest} is already the canonical skill v${version} — nothing to install.`);
6648
+ return;
6649
+ }
6350
6650
  ok(`installed the abx skill v${version} → ${dest}`);
6351
6651
  info('restart your agent so it loads the skill, then ask it to launch an NFT with abx.');
6352
6652
  return;