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