@artblocks/abx-cli 0.1.0-alpha.1 → 0.1.0-alpha.10
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 +1386 -274
- 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 +74 -19
- package/skill/reference/code-projects.md +35 -0
- package/skill/reference/hosting.md +39 -8
- package/skill/reference/operating.md +5 -3
- package/skill/reference/setup.md +7 -1
- package/skill/reference/troubleshooting.md +9 -2
package/dist/main.js
CHANGED
|
@@ -14,10 +14,12 @@
|
|
|
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)
|
|
22
24
|
* abx configure-param <addr> <id> <key> <value> set a governed PostParam (typed encode; any lane)
|
|
23
25
|
* abx set-param-hooks <addr> wire/clear a SeriesCode's configure/augment/transfer param hooks
|
|
@@ -34,14 +36,15 @@
|
|
|
34
36
|
* abx storage backup-key copy the managed Turbo/Arweave key (holds credits) to a safe path
|
|
35
37
|
* abx status list indexed projects + node info
|
|
36
38
|
* abx doctor check environment (key, RPC, balance, factory, storage)
|
|
37
|
-
* abx skill install install the abx agent skill into your agent
|
|
39
|
+
* abx skill install install the version-locked abx agent skill into your agent(s)
|
|
40
|
+
* (default: Claude Code + the neutral .agents/skills; --agent/--global)
|
|
38
41
|
*
|
|
39
42
|
* Every write picks a signing lane: default hot (env key signs), `--sign` (a human
|
|
40
43
|
* approves in their own wallet via a one-shot localhost page), `--unsigned` (print
|
|
41
44
|
* the tx for a multisig / offline signer). The agent picks the lane; the CLI signs.
|
|
42
45
|
*/
|
|
43
|
-
import { appendFileSync, copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
44
|
-
import { randomBytes } from 'node:crypto';
|
|
46
|
+
import { appendFileSync, copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
47
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
45
48
|
import { basename, extname, join as joinPath, resolve as resolvePath } from 'node:path';
|
|
46
49
|
import { fileURLToPath } from 'node:url';
|
|
47
50
|
import { homedir } from 'node:os';
|
|
@@ -54,9 +57,11 @@ import { DEP_RESOLUTION, deploySeedSource, deploySeriesCodeFactory, prepareCodeS
|
|
|
54
57
|
import { checkRegistryDeps, dependencySetupCalls, parseDepFlag, resolveDepRegistryPointer } from './deps.js';
|
|
55
58
|
import { composeParamsKeys, expectedChainComplete, hasOnChainUriLane, onchainUriSetupCalls, onChainUriReport } from './onchain-uri.js';
|
|
56
59
|
import { parseFlags, unknownFlags } from './flags.js';
|
|
57
|
-
import { checkForCliUpdate, compareVersions, installedSkillVersions, readCliVersion,
|
|
60
|
+
import { AGENT_SKILL_PARENTS, checkForCliUpdate, compareVersions, installedSkillVersions, readCliVersion, readSkillVersion, SKILL_DIR_NAME, } from './update-check.js';
|
|
58
61
|
import { analyzeScript, recommendLane } from './inspect.js';
|
|
62
|
+
import { previewConfigFromFlags, previewDepTags, shootPreview, startPreviewServer, DEFAULT_PREVIEW_PORT, PREVIEW_FLAGS } from './preview.js';
|
|
59
63
|
import { parseSchemaSpecs, describeSchema } from './schema.js';
|
|
64
|
+
import { declinesSkillInstall } from './prompt.js';
|
|
60
65
|
import { parseSeriesTraits, looksPerTokenAttributes, parseSeriesTraitsById } from './series-traits.js';
|
|
61
66
|
/** The `--schema` type/auth/format catalog, shown wherever the CLI nudges `--schema`. Kept accurate
|
|
62
67
|
* to the on-chain enums (PARAM_TYPES / AUTH_OPTIONS) + the Select-options / Range-bounds format —
|
|
@@ -66,22 +71,20 @@ const SCHEMA_CATALOG = 'Types: Bool·Select·Uint256Range·Int256Range·DecimalR
|
|
|
66
71
|
'Select needs options — key:Select[A|B|C]:Auth; a Range takes bounds — key:Uint256Range[0..100]:Auth. ' +
|
|
67
72
|
'A palette collectors set = palette:HexColor:TokenOwner';
|
|
68
73
|
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';
|
|
74
|
+
import { assertChainId, discoverDeployBlock, deployFactory, deploySeriesFactory, deploySeries, deployOneOfOne, deployRenderer, predictRenderer, predictSeedSource, encodeTag, encodeFieldRenderer, isCodeProject, loadDotEnv, makePublicClient, makeWalletClient, oneOfOneImageAbi, oneOfOneImageFactoryAbi, seriesImageAbi, seriesImageFactoryAbi, abxMetadataRendererAbi, predictClone, probeRpcEndpoints, prepareDeployOneOfOne, prepareDeploySeries, reconstructProject, saltFor, saltGuard, resolveChain, resolveRpcUrl, redactRpcUrl, explorerUrl, DEFAULT_CHAIN_KEY, normalizeAttributes, parseTraitPairs, METADATA_FIELD as F, METADATA_REPRESENTATION as R, indexProgress, isAccepted, AbxIndexTimeoutError, AbxServiceError, } from '@artblocks/abx-sdk';
|
|
70
75
|
import { SelfHostIndexer, SqliteStore } from '@artblocks/abx-indexer';
|
|
71
76
|
import { artContentHash, currentRenderArtifact, generateArt, resolveBaseUrl, startChainWatcher, startTokenApiServer, verifyProject, watchIntervalMs, DEFAULT_PORT, } from '@artblocks/abx-token-api';
|
|
72
77
|
import { ARWEAVE_FREE_UPLOAD_LIMIT, arweaveAddress, arweaveFunding, contentTypeFromPath, hashContent, resolveBackend, turboBalanceForAddress, turboUploadCostUsd, turboUploadWinc } from '@artblocks/abx-storage';
|
|
73
78
|
import { cmdTransfer, cmdMint, cmdSetMinter, cmdSetMaxInvocations, cmdConfigureParam, cmdSetParamHooks, cmdSetDependency, cmdRemoveLastDependency, cmdSetDependencyRegistry, cmdLockDependencies, cmdSetPrimaryPayee, cmdPause, cmdUnpause, cmdRefresh, cmdSetTokenUri, cmdSetContractUri, cmdSetRoyalty, cmdSetField, cmdAttach, cmdLockField, cmdSetRenderer, cmdLockUri, cmdSetAdmin, cmdMinterConfigure, cmdMinterShow, cmdMinterBuy, computeContentPlan, envStagingSender, laneFromFlags, ONCHAIN_PROJECT_SOFT_LIMIT, parseCompress, previewImageStaging, sessionStagingSender, stageImageField, stageImageFieldsBatch, authorshipContractFields, AUTHORSHIP_DEPLOY_FIELDS, } from './ownerops.js';
|
|
74
79
|
import { openWalletSession, signTx } from './signer.js';
|
|
75
|
-
import {
|
|
80
|
+
import { describeRemoteError, listConfiguredRemotes, misnamedRemoteVars, requireRemoteToken, resolveRemote, serviceClient, tokenSourceLabel } from './remote.js';
|
|
76
81
|
import { buildMigrationPlan, repinNodeCustody, verifyParity } from './migrate.js';
|
|
77
|
-
import { activeBackendId, arweaveKeyFilePath, backendResolution, ensureArweaveJwk, factoryAddress, seriesFactoryAddress, fixedPriceMinterAddress, loadArweaveJwk, loopbackBaseUrl, rendererAddress, storageOptions, storageSignerChoice, } from './config.js';
|
|
82
|
+
import { activeBackendId, arweaveKeyFilePath, backendResolution, ensureArweaveJwk, factoryAddress, seriesFactoryAddress, fixedPriceMinterAddress, loadArweaveJwk, loopbackBaseUrl, faucetHint, rendererAddress, storageOptions, storageSignerChoice, } from './config.js';
|
|
78
83
|
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';
|
|
84
|
+
// Block explorer base for the tx/address links the CLI prints. Derived from viem's chain metadata via
|
|
85
|
+
// the SDK (see explorerUrl) rather than a local table — this used to be a hand-maintained map, which is
|
|
86
|
+
// the same shape of bug that had the token-api dashboard sending every Base Sepolia link to Etherscan.
|
|
87
|
+
const EXPLORER = explorerUrl(resolveChain(CHAIN).id);
|
|
85
88
|
// Product dimensions — what you can launch. One concrete contract exists today
|
|
86
89
|
// (the 1/1 image); this table is the seam future implementations slot into, so
|
|
87
90
|
// `abx deploy --type <dimension>` is stable while the contracts grow under it.
|
|
@@ -133,7 +136,7 @@ async function maybeNotifyUpdate(flags) {
|
|
|
133
136
|
const latest = await checkForCliUpdate(current);
|
|
134
137
|
if (latest) {
|
|
135
138
|
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` +
|
|
139
|
+
` upgrade: ${g('npm i -g @artblocks/abx-cli@latest')} ${dim('· or invoke:')} ${g('npx @artblocks/abx-cli@latest <command>')}\n` +
|
|
137
140
|
` release notes: https://github.com/ArtBlocks/abx/releases ${dim('· silence: ABX_NO_UPDATE_CHECK=1')}\n`);
|
|
138
141
|
}
|
|
139
142
|
}
|
|
@@ -151,7 +154,7 @@ async function main() {
|
|
|
151
154
|
return;
|
|
152
155
|
}
|
|
153
156
|
// 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
|
|
157
|
+
// the notice lands before command output, but it hits the network at most once every 6h and never
|
|
155
158
|
// throws, so it can't break or meaningfully slow a command. Skipped for the pure help path below.
|
|
156
159
|
await maybeNotifyUpdate(flags);
|
|
157
160
|
// SAFETY: --help / -h on ANY command is read-only — print usage, never execute.
|
|
@@ -165,6 +168,7 @@ async function main() {
|
|
|
165
168
|
case 'deploy-series': return cmdDeploySeries(flags);
|
|
166
169
|
case 'deploy-code': return cmdDeployCode(flags);
|
|
167
170
|
case 'inspect': return cmdInspect(rest[0], flags);
|
|
171
|
+
case 'preview': return cmdPreview(flags);
|
|
168
172
|
case 'scaffold-renderer': return cmdScaffoldRenderer(rest, flags);
|
|
169
173
|
case 'predict': return cmdPredict(flags);
|
|
170
174
|
case 'add': return cmdAdd(rest[0], flags);
|
|
@@ -204,8 +208,9 @@ async function main() {
|
|
|
204
208
|
case 'set-admin': return cmdSetAdmin(rest[0], flags);
|
|
205
209
|
case 'forget': return cmdForget(rest[0], flags);
|
|
206
210
|
case 'migrate': return cmdMigrate(rest[0], flags);
|
|
211
|
+
case 'remote': return cmdRemote(rest[0], flags);
|
|
207
212
|
case 'storage': return cmdStorage(rest);
|
|
208
|
-
case 'status': return cmdStatus();
|
|
213
|
+
case 'status': return cmdStatus(rest[0], flags);
|
|
209
214
|
case 'state': return cmdState(rest[0], flags);
|
|
210
215
|
case 'doctor': return cmdDoctor(flags);
|
|
211
216
|
case 'skill': return cmdSkill(rest, flags);
|
|
@@ -220,6 +225,65 @@ async function main() {
|
|
|
220
225
|
process.exit(1);
|
|
221
226
|
}
|
|
222
227
|
}
|
|
228
|
+
/**
|
|
229
|
+
* A `--dry-run` computes the deterministic deploy address, which is a pure function of
|
|
230
|
+
* (factory, salt, deployer) — so it needs a deployer even though it signs nothing. Resolve it the
|
|
231
|
+
* same way the preview will (`--for`, else an env key) and fail EARLY with the fix if neither
|
|
232
|
+
* exists, rather than after the preview has printed several steps of work.
|
|
233
|
+
*/
|
|
234
|
+
/**
|
|
235
|
+
* A dry run only checks whether a factory address is CONFIGURED, not whether it has code on this
|
|
236
|
+
* chain — and the manifest always has an address, so a chain where the trust anchor isn't deployed
|
|
237
|
+
* (a private/local chain, or a wrong-network RPC) sailed past this and died inside
|
|
238
|
+
* `predictDeterministicAddress` with a raw `returned no data ("0x")` and a list of ABI hypotheses.
|
|
239
|
+
* The real deploy and `abx predict` both explain that case; a preview of the same deploy must too.
|
|
240
|
+
* Returns false when the caller should stop (message already printed).
|
|
241
|
+
*/
|
|
242
|
+
async function previewFactoryLive(client, factory, label) {
|
|
243
|
+
const code = await client.getCode({ address: factory }).catch(() => undefined);
|
|
244
|
+
if (code && code !== '0x')
|
|
245
|
+
return true;
|
|
246
|
+
warn(`the configured ${label} ${factory} has no code on '${CHAIN}' (asked ${redactRpcUrl(resolveRpcUrl(CHAIN))}).`);
|
|
247
|
+
info('so this preview can\'t compute the deterministic address. Either point at a chain where the trust anchor is deployed');
|
|
248
|
+
info(`(${bold('ABX_CHAIN=' + DEFAULT_CHAIN_KEY)} is the default and has one), or deploy your own on this chain with ${bold('--bootstrap-factory')}`);
|
|
249
|
+
info(dim('(a private anchor — platforms won\'t recognize its clones, so it\'s for private/sandbox chains).'));
|
|
250
|
+
console.log(`\n ${g('dry run')} ${dim('— nothing sent.')}\n`);
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Placeholder-identity guard, shared by ALL THREE deploy commands. `name`/`symbol` are written
|
|
255
|
+
* on-chain as the public collection identity and are effectively permanent, so a real deploy must
|
|
256
|
+
* never bake a tool default silently: warn in a preview, HARD-STOP a real send unless `--yes`.
|
|
257
|
+
*
|
|
258
|
+
* It was duplicated per command, and `deploy-series` simply never got a copy — its default
|
|
259
|
+
* "ABX Series"/"ABXS" went on-chain with at most a warning, while the skill promises the CLI
|
|
260
|
+
* refuses demo defaults. (deploy-code's copy even said "mirror deploy/deploy-series", which made
|
|
261
|
+
* the gap look closed.) One predicate now, like `loopbackBaseUrl()`, so a fourth command can't drift.
|
|
262
|
+
*/
|
|
263
|
+
function assertRealIdentity(flags, o) {
|
|
264
|
+
if (flags.name && flags.symbol)
|
|
265
|
+
return;
|
|
266
|
+
if (!flags.name)
|
|
267
|
+
warn(`no --name → default "${o.name}" would be the on-chain collection name`);
|
|
268
|
+
if (!flags.symbol)
|
|
269
|
+
warn(`no --symbol → default "${o.symbol}" would be the on-chain symbol`);
|
|
270
|
+
if (o.dryRun || flags.yes)
|
|
271
|
+
return; // a preview still runs; --yes is the explicit opt-in
|
|
272
|
+
throw new Error('refusing to write tool placeholders as your public on-chain identity — pass --name "Your Title" --symbol SYM ' +
|
|
273
|
+
'(or --yes to accept the defaults). On-chain identity is effectively permanent.');
|
|
274
|
+
}
|
|
275
|
+
function assertPreviewDeployer(flags) {
|
|
276
|
+
if (flags.for)
|
|
277
|
+
return;
|
|
278
|
+
try {
|
|
279
|
+
makeWalletClient({ chainKey: CHAIN });
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
throw new Error('dry run needs a deployer address to compute the deterministic deploy address — pass --for 0x.. ' +
|
|
283
|
+
'(a preview signs nothing, so no key is needed). For the REAL deploy with no key in .env, use the ' +
|
|
284
|
+
'wallet lane: --sign --for 0x.. (you approve in your own wallet).');
|
|
285
|
+
}
|
|
286
|
+
}
|
|
223
287
|
// When the user/agent opts in (--yes), lift the getLogs chunk cap so a large
|
|
224
288
|
// reconstruction proceeds despite a range-limited RPC (otherwise it stops early with
|
|
225
289
|
// guidance — see GetLogsScanTooLargeError + the skill's "Choosing an RPC" decision).
|
|
@@ -293,17 +357,6 @@ async function detectCanonicalFactory(address, override, stored) {
|
|
|
293
357
|
}
|
|
294
358
|
return factoryAddress() ?? undefined;
|
|
295
359
|
}
|
|
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
360
|
// Funding preflight — a 0-balance signer fails only at the tx, with a confusing error. Surface it
|
|
308
361
|
// up front. Especially the wallet lane (--sign/--for), which has no env key for `doctor` to check.
|
|
309
362
|
async function warnUnfunded(publicClient, address) {
|
|
@@ -455,7 +508,7 @@ function refuseMissingFactory(kind, envVar, reason) {
|
|
|
455
508
|
` To deploy your OWN trust anchor instead (private chains, sandboxes — platforms won't recognize its clones), ` +
|
|
456
509
|
`re-run with --bootstrap-factory.`);
|
|
457
510
|
}
|
|
458
|
-
async function ensureFactory(override, allowBootstrap = false) {
|
|
511
|
+
async function ensureFactory(override, allowBootstrap = false, quiet = false) {
|
|
459
512
|
// Resolve the canonical factory from the shipped manifest (flag → env → manifest); on a chain
|
|
460
513
|
// with no entry, deploy a fresh trust anchor and tell the operator how to reuse it.
|
|
461
514
|
const known = factoryAddress(override);
|
|
@@ -465,7 +518,10 @@ async function ensureFactory(override, allowBootstrap = false) {
|
|
|
465
518
|
const code = await publicClient.getCode({ address: known });
|
|
466
519
|
if (code && code !== '0x') {
|
|
467
520
|
if (await isCurrentFactory(publicClient, known)) {
|
|
468
|
-
|
|
521
|
+
// `quiet` suppresses only this happy-path line (the demo resolves the factory without making a
|
|
522
|
+
// teaching moment of it). Bootstrap/mismatch messages below always print — those matter.
|
|
523
|
+
if (!quiet)
|
|
524
|
+
info(`using canonical factory ${EXPLORER}/address/${known}`);
|
|
469
525
|
return known;
|
|
470
526
|
}
|
|
471
527
|
if (!allowBootstrap)
|
|
@@ -982,26 +1038,53 @@ async function cmdDeploy(flags, serveAfter) {
|
|
|
982
1038
|
// editable later via `abx add --traits`); `--traits-onchain` inlines them on-chain (durable,
|
|
983
1039
|
// lockable) — mirrors the description model. On-chain wins if both ever set the same trait.
|
|
984
1040
|
const traits = parseDeployTraits(flags);
|
|
985
|
-
console.log(
|
|
986
|
-
|
|
1041
|
+
console.log(serveAfter
|
|
1042
|
+
? 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")}`)
|
|
1043
|
+
: bold(`\n ABX Self-Host Toolkit — deploy\n ${dim('a project, served from chain alone')}`));
|
|
1044
|
+
// Signing lane. `demo` used to hard-force the hot lane, which SILENTLY dropped `--sign`: with no
|
|
1045
|
+
// env key it died confusingly, and WITH one it signed from that key while the operator had asked
|
|
1046
|
+
// for their browser wallet — a signing choke point that ignored the lane it was handed. The demo
|
|
1047
|
+
// deploy is a single tx, so the wallet lane works here exactly as it does for `deploy`.
|
|
1048
|
+
const lane = laneFromFlags(flags);
|
|
1049
|
+
// The cold lane only PRINTS a tx; demo's whole point is to index + serve what it just deployed,
|
|
1050
|
+
// and there is nothing to index until someone broadcasts. Refuse the combo instead of doing
|
|
1051
|
+
// half the job — `abx deploy --unsigned` is the command for that lane.
|
|
1052
|
+
if (serveAfter && lane === 'unsigned') {
|
|
1053
|
+
throw new Error('`abx demo --unsigned` can\'t work: the cold lane only prints a transaction, and the demo ' +
|
|
1054
|
+
'indexes + serves the contract it just deployed. Use `abx demo` (hot key) or `abx demo --sign` ' +
|
|
1055
|
+
'(browser wallet) — or `abx deploy --unsigned` if you only want the raw tx.');
|
|
1056
|
+
}
|
|
1057
|
+
// Same shape for --dry-run: previewing sends nothing, so there is nothing to serve.
|
|
1058
|
+
if (serveAfter && flags['dry-run']) {
|
|
1059
|
+
throw new Error('`abx demo --dry-run` can\'t work: a dry run sends nothing, and the demo indexes + serves what ' +
|
|
1060
|
+
'it deployed. Use `abx deploy --dry-run` to preview a 1/1 deploy without sending.');
|
|
1061
|
+
}
|
|
1062
|
+
// Port preflight BEFORE anything irreversible. The demo ends by serving, and `listen` used to be
|
|
1063
|
+
// the first thing to discover the port was taken — after the deploy tx had already been signed and
|
|
1064
|
+
// paid for, so a second `abx demo` (a very normal thing to try) spent gas and then died with a raw
|
|
1065
|
+
// Node EADDRINUSE stack trace. Check first, and name the fix.
|
|
1066
|
+
if (serveAfter) {
|
|
1067
|
+
const wanted = Number(flags.port ?? process.env.ABX_PORT ?? DEFAULT_PORT);
|
|
1068
|
+
if (await portInUse(wanted)) {
|
|
1069
|
+
throw new Error(`port ${wanted} is already in use — probably an \`abx demo\`/\`abx serve\` still running in another terminal.\n` +
|
|
1070
|
+
` Nothing was deployed. Stop that one (Ctrl-C), or run this on another port: \`abx demo --port ${wanted + 1}\`.`);
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
987
1073
|
const dryRun = !serveAfter && !!flags['dry-run']; // preview only — no send, no custody, no factory deploy
|
|
1074
|
+
// A keyless preview needs `--for` (the address is a pure function of factory+salt+deployer). Check
|
|
1075
|
+
// it HERE, before the trust-anchor/content/plan steps print — hitting this after a wall of output
|
|
1076
|
+
// reads as "it half-worked", and a first-timer previewing with no key in .env always hits it.
|
|
1077
|
+
if (dryRun)
|
|
1078
|
+
assertPreviewDeployer(flags);
|
|
988
1079
|
const publicClient = makePublicClient({ chainKey: CHAIN });
|
|
989
1080
|
// Verify the RPC really is CHAIN before any send (factory/renderer/staging/deploy). A dry run
|
|
990
1081
|
// sends nothing, but it DOES read the chain (predict address, resolve the factory/renderer), so
|
|
991
1082
|
// a wrong-network RPC must still be caught with the clear mismatch message rather than failing
|
|
992
1083
|
// opaquely inside predict; `allowUnreachable` keeps a genuinely offline dry-run previewable.
|
|
993
1084
|
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
|
-
}
|
|
1085
|
+
// `demo` is exempt: its whole job is a zero-argument first token.
|
|
1086
|
+
if (!serveAfter)
|
|
1087
|
+
assertRealIdentity(flags, { name, symbol, dryRun });
|
|
1005
1088
|
// Funding preflight (real deploys): warn now if the signer is unfunded, not at the tx.
|
|
1006
1089
|
if (!dryRun) {
|
|
1007
1090
|
let signer = flags.for;
|
|
@@ -1014,7 +1097,13 @@ async function cmdDeploy(flags, serveAfter) {
|
|
|
1014
1097
|
if (signer)
|
|
1015
1098
|
await warnUnfunded(publicClient, signer);
|
|
1016
1099
|
}
|
|
1017
|
-
|
|
1100
|
+
// `deploy` surfaces the trust anchor as a step; the demo does NOT. It briefly opened on "only this
|
|
1101
|
+
// factory can make a token that IS an ABX token", which is simply false — anything that follows the
|
|
1102
|
+
// protocol's event spine is an ABX token, and the factory is one way to get there, not the
|
|
1103
|
+
// definition of the thing. Rather than restate it more carefully, the demo skips it: a first-timer
|
|
1104
|
+
// does not need a provenance lecture before they have made anything.
|
|
1105
|
+
if (!serveAfter)
|
|
1106
|
+
step('Trust anchor');
|
|
1018
1107
|
let factory;
|
|
1019
1108
|
if (dryRun) {
|
|
1020
1109
|
const existing = factoryAddress(flags.factory);
|
|
@@ -1024,10 +1113,12 @@ async function cmdDeploy(flags, serveAfter) {
|
|
|
1024
1113
|
return;
|
|
1025
1114
|
}
|
|
1026
1115
|
factory = existing;
|
|
1116
|
+
if (!(await previewFactoryLive(publicClient, factory, 'factory')))
|
|
1117
|
+
return;
|
|
1027
1118
|
info(`would reuse canonical factory ${factory}`);
|
|
1028
1119
|
}
|
|
1029
1120
|
else {
|
|
1030
|
-
factory = await ensureFactory(flags.factory, !!flags['bootstrap-factory']);
|
|
1121
|
+
factory = await ensureFactory(flags.factory, !!flags['bootstrap-factory'], serveAfter);
|
|
1031
1122
|
}
|
|
1032
1123
|
// --onchain-uri: resolve tokenURI/contractURI fully on-chain via the canonical renderer
|
|
1033
1124
|
// (the JSON is assembled from on-chain fields → no resolver needed, ever). The off-chain
|
|
@@ -1035,18 +1126,32 @@ async function cmdDeploy(flags, serveAfter) {
|
|
|
1035
1126
|
// --onchain-image: stage the --image bytes on-chain (chunk store, ownerless) and bake a
|
|
1036
1127
|
// `reader` field into the deploy — so large on-chain content needs NO post-deploy tx. It
|
|
1037
1128
|
// 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
1129
|
// A fully on-chain token self-resolves via the renderer; the stored off-chain pointer is
|
|
1044
1130
|
// never read while a renderer is set. So bake a real URL only if one was explicitly given —
|
|
1045
1131
|
// otherwise leave it EMPTY rather than baking a misleading localhost into the contract.
|
|
1046
1132
|
const hasPublicUrl = !!(flags['public-base-url'] || process.env.ABX_PUBLIC_BASE_URL);
|
|
1133
|
+
const onchainImage = !!flags['onchain-image'];
|
|
1134
|
+
// `abx demo` defaults to FULLY ON-CHAIN. Its default art is a generative SVG, which the renderer
|
|
1135
|
+
// can inline — so the token self-resolves and, crucially, NO localhost gets baked into the
|
|
1136
|
+
// contract as the tokenURI base. The old default shipped a first-ever token that resolved for
|
|
1137
|
+
// nobody but its author (broken on every marketplace, dead the moment `abx serve` stops) and
|
|
1138
|
+
// taught that as the normal shape of an NFT. It also undercut the demo's own claim: with the art
|
|
1139
|
+
// on-chain, "rebuilt from the chain alone" now covers the IMAGE, not just the metadata.
|
|
1140
|
+
//
|
|
1141
|
+
// Opting back into off-chain custody is anything that says "I have somewhere to host": a real base
|
|
1142
|
+
// URL, an explicit --backend, or a RASTER --image. An SVG --image still goes on-chain — inlining is
|
|
1143
|
+
// exactly what the renderer supports (v1 is SVG-only), so there's no reason to send someone's own
|
|
1144
|
+
// vector art down the localhost path. Raster stays off-chain because forcing it on-chain would
|
|
1145
|
+
// silently inline a placeholder instead of their image.
|
|
1146
|
+
const demoImageInlineable = !flags.image || contentTypeFromPath(flags.image) === 'image/svg+xml';
|
|
1147
|
+
const demoDefaultsOnChain = serveAfter && demoImageInlineable && !flags.backend && !hasPublicUrl;
|
|
1148
|
+
const onChainUri = !!flags['onchain-uri'] || onchainImage || demoDefaultsOnChain;
|
|
1149
|
+
// On-chain traits when explicitly asked, or implied by --onchain-uri (the renderer can only
|
|
1150
|
+
// emit on-chain fields, so off-chain-only traits would be invisible there). Else off-chain.
|
|
1151
|
+
const traitsOnchain = traits.length > 0 && (!!flags['traits-onchain'] || onChainUri);
|
|
1047
1152
|
let renderer = zeroAddress;
|
|
1048
1153
|
if (onChainUri) {
|
|
1049
|
-
step('On-chain renderer');
|
|
1154
|
+
step(serveAfter ? 'What will answer when someone asks about your token' : 'On-chain renderer');
|
|
1050
1155
|
if (dryRun) {
|
|
1051
1156
|
renderer = rendererAddress(flags.renderer) ?? zeroAddress;
|
|
1052
1157
|
info(renderer === zeroAddress ? 'would deploy the canonical renderer first' : `would use renderer ${renderer}`);
|
|
@@ -1054,8 +1159,28 @@ async function cmdDeploy(flags, serveAfter) {
|
|
|
1054
1159
|
else {
|
|
1055
1160
|
renderer = await ensureRenderer(flags.renderer);
|
|
1056
1161
|
}
|
|
1162
|
+
// A first-timer has no idea what a "renderer" is, and the word suggests something that draws
|
|
1163
|
+
// pictures. What it actually does is assemble the JSON a marketplace asks for, on-chain, out of
|
|
1164
|
+
// the fields your contract holds — worth one plain sentence, since it's why no server is needed.
|
|
1165
|
+
if (serveAfter) {
|
|
1166
|
+
info(dim('a marketplace asks your contract a question; this shared contract composes the answer'));
|
|
1167
|
+
info(dim('already deployed, used by every ABX token, owned by no one — you are not paying to set it up'));
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
step(serveAfter ? `Mint it · one transaction on ${CHAIN}` : `Deploy a ${DIMENSIONS[dimension].label} (--type ${dimension}) to ${CHAIN}`);
|
|
1171
|
+
// What the chain ends up holding is the single most useful thing to understand about an ABX token,
|
|
1172
|
+
// and it's invisible unless someone says it out loud. Two honest versions, because the answer is
|
|
1173
|
+
// genuinely different per lane. Demo only — `deploy` prints the same facts per-field.
|
|
1174
|
+
if (serveAfter && onChainUri) {
|
|
1175
|
+
console.log(` Your art goes ${bold('INTO')} the contract. Not a link to it — the image itself.`);
|
|
1176
|
+
console.log(` ${g('✦')} the chain will hold ${dim('your art · your name on it · your royalty · you as owner')}`);
|
|
1177
|
+
console.log(` ${g('✦')} you will need ${dim('nothing else. no server, no IPFS pin, no monthly bill to forget')}`);
|
|
1178
|
+
}
|
|
1179
|
+
else if (serveAfter) {
|
|
1180
|
+
console.log(` ${g('✦')} the chain will hold ${dim('your name · your royalty · you as owner · a fingerprint of the art')}`);
|
|
1181
|
+
console.log(` ${g('✦')} this computer holds ${dim('the image bytes themselves')}`);
|
|
1182
|
+
info(dim('the chain proves the bytes are unaltered; it does not store them (that fingerprint is a keccak256 hash)'));
|
|
1057
1183
|
}
|
|
1058
|
-
step(`Deploy a ${DIMENSIONS[dimension].label} (--type ${dimension}) to ${CHAIN}`);
|
|
1059
1184
|
// Off-chain custody (no renderer) bakes the resolver URL straight into the on-chain
|
|
1060
1185
|
// tokenURI/contractURI at deploy. A localhost / loopback URL there resolves for NO ONE —
|
|
1061
1186
|
// not marketplaces, not wallets, not even your own browser unless `abx serve` is running —
|
|
@@ -1082,7 +1207,14 @@ async function cmdDeploy(flags, serveAfter) {
|
|
|
1082
1207
|
}
|
|
1083
1208
|
}
|
|
1084
1209
|
}
|
|
1085
|
-
if (onChainUri) {
|
|
1210
|
+
if (onChainUri && serveAfter) {
|
|
1211
|
+
// The demo's version of the same fact, in words a first-timer can act on. Naming what we are
|
|
1212
|
+
// NOT doing matters here: a localhost URL written into a contract is the single most common way
|
|
1213
|
+
// a first NFT ends up permanently broken, and the demo used to model exactly that.
|
|
1214
|
+
info(dim('nothing points at this computer — no http://localhost anywhere in your contract.'));
|
|
1215
|
+
info(dim('anyone can read your token from the chain, forever, with you offline.'));
|
|
1216
|
+
}
|
|
1217
|
+
else if (onChainUri) {
|
|
1086
1218
|
info('tokenURI/contractURI resolve ON-CHAIN via the renderer — no resolver, no server, no localhost.');
|
|
1087
1219
|
if (!hasPublicUrl)
|
|
1088
1220
|
info('off-chain fallback pointer left empty (the renderer is authoritative); set --public-base-url to bake one anyway.');
|
|
@@ -1259,7 +1391,9 @@ async function cmdDeploy(flags, serveAfter) {
|
|
|
1259
1391
|
// wallet lane + on-chain staging: ONE sign session signs every chunk write AND the deploy.
|
|
1260
1392
|
// The connecting wallet pays for (and is the deployer of) all of it — staging can't precede
|
|
1261
1393
|
// the connect here, so it happens inside the session, then the deploy bakes in the manifest.
|
|
1262
|
-
|
|
1394
|
+
// --onchain-image implies on-chain resolution, so NOTHING points at baseUrl — saying it did was
|
|
1395
|
+
// a flat contradiction of the line above it (and reintroduced the localhost the lane exists to avoid).
|
|
1396
|
+
info('a wallet will become the owner; the token resolves from chain — no URI base is baked in.');
|
|
1263
1397
|
const plan = computeContentPlan(readFileSync(resolvePath(flags.image)), parseCompress(flags.compress)).plan;
|
|
1264
1398
|
const stagingTxs = plan.mode === 'single' ? 1 : plan.txCount;
|
|
1265
1399
|
const session = await openWalletSession({
|
|
@@ -1324,8 +1458,12 @@ async function cmdDeploy(flags, serveAfter) {
|
|
|
1324
1458
|
info(`tx ${EXPLORER}/tx/${r.txHash} (block ${blockNumber})`);
|
|
1325
1459
|
}
|
|
1326
1460
|
else {
|
|
1327
|
-
// wallet lane (off-chain custody) or cold lane: a single deploy tx, no staging
|
|
1328
|
-
|
|
1461
|
+
// wallet lane (off-chain custody OR on-chain URI) or cold lane: a single deploy tx, no staging.
|
|
1462
|
+
// Only claim a URI base when one is actually written — on the on-chain lane this line used to
|
|
1463
|
+
// announce `http://localhost:8787` two lines after promising no localhost anywhere.
|
|
1464
|
+
info(onChainUri
|
|
1465
|
+
? 'a wallet will become the owner; the token resolves from chain — no URI base is baked in.'
|
|
1466
|
+
: `a wallet will become the owner; URIs point at ${baseUrl}`);
|
|
1329
1467
|
const result = await signTx(async (signer) => {
|
|
1330
1468
|
const { clone: predicted, params, salt } = await buildForDeployer(signer);
|
|
1331
1469
|
return prepareDeployOneOfOne({ factory, params, salt, chainId: resolveChain(CHAIN).id, clone: predicted });
|
|
@@ -1338,7 +1476,7 @@ async function cmdDeploy(flags, serveAfter) {
|
|
|
1338
1476
|
blockNumber = result.blockNumber;
|
|
1339
1477
|
ok(`deployed ${clone}`);
|
|
1340
1478
|
}
|
|
1341
|
-
step('Index it — replay the event spine from chain');
|
|
1479
|
+
step(serveAfter ? 'What the chain knows now' : 'Index it — replay the event spine from chain');
|
|
1342
1480
|
const indexer = new SelfHostIndexer();
|
|
1343
1481
|
// Off-chain traits ride in the registration (on-chain ones are already in the contract fields).
|
|
1344
1482
|
const offChainTraits = !traitsOnchain && traits.length ? JSON.stringify(traits) : undefined;
|
|
@@ -1353,10 +1491,23 @@ async function cmdDeploy(flags, serveAfter) {
|
|
|
1353
1491
|
attributes: offChainTraits,
|
|
1354
1492
|
};
|
|
1355
1493
|
indexer.register(baseReg);
|
|
1356
|
-
const { state, elapsedMs } = await indexer
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1494
|
+
const { state, elapsedMs } = await reindexAfterDeploy(indexer, clone);
|
|
1495
|
+
if (serveAfter) {
|
|
1496
|
+
if (state.eventCount > 0)
|
|
1497
|
+
ok(`read ${bold(String(state.eventCount))} events straight off ${CHAIN} in ${elapsedMs}ms — no API key, no company's server`);
|
|
1498
|
+
// "verified real" overstated it in the same way the removed trust-anchor step did — factory
|
|
1499
|
+
// provenance is a fact about how this contract was made, not a verdict on whether a token counts
|
|
1500
|
+
// as ABX. Report the fact.
|
|
1501
|
+
info(`it says: "${state.name}" · owned by ${state.owner} · ${state.isCanonical ? g('made by the canonical factory') : dim('not factory-made')}`);
|
|
1502
|
+
info(dim(`optional features switched on: ${state.extensions.map((e) => e.name.replace(/^abx\.extension\./, '')).join(' · ') || 'none'}`));
|
|
1503
|
+
walkthroughSpine(state);
|
|
1504
|
+
}
|
|
1505
|
+
else {
|
|
1506
|
+
if (state.eventCount > 0)
|
|
1507
|
+
ok(`reconstructed ${state.eventCount} events in ${elapsedMs}ms — no provider involved`);
|
|
1508
|
+
info(`name "${state.name}" · owner ${state.owner} · canonical: ${canonicalLabel(state.isCanonical)}`);
|
|
1509
|
+
info(`extensions: ${state.extensions.map((e) => e.name).join(', ') || 'none'}`);
|
|
1510
|
+
}
|
|
1360
1511
|
// For off-chain custody (image committed as keccak256), resolve the durable locator (ipfs://…)
|
|
1361
1512
|
// from this machine's content index and store it on the registration — so the LOCAL resolver
|
|
1362
1513
|
// points `image` at IPFS, and `abx add --remote` can ship it to a hosted one (the localhost-image fix).
|
|
@@ -1405,8 +1556,21 @@ async function cmdDeploy(flags, serveAfter) {
|
|
|
1405
1556
|
}
|
|
1406
1557
|
return;
|
|
1407
1558
|
}
|
|
1408
|
-
|
|
1559
|
+
// The demo's teaching sections — see the walkthrough helpers. `deploy` skips them: someone
|
|
1560
|
+
// shipping real work doesn't need their projection deleted to make a point.
|
|
1561
|
+
if (serveAfter)
|
|
1562
|
+
await walkthroughRebuild(indexer, clone, state);
|
|
1563
|
+
// Start the server BEFORE the read-back step (which fetches the served metadata over HTTP), but
|
|
1564
|
+
// print the serve banner after it — otherwise the "Serve" step header lands with nothing under it
|
|
1565
|
+
// while the read-back prints below, which reads like the step failed.
|
|
1409
1566
|
const { url } = await startTokenApiServer({ indexer, port, baseUrl, storage: resolveBackend(storageOptions()) });
|
|
1567
|
+
if (serveAfter)
|
|
1568
|
+
await walkthroughReadBack(state, url, onChainUri);
|
|
1569
|
+
// On the on-chain lane this server is a convenience, not infrastructure — say so, or standing one
|
|
1570
|
+
// up as the finale re-teaches the dependency the whole run just disproved.
|
|
1571
|
+
step(onChainUri ? 'Go look at it' : 'Serve the token API + dashboard');
|
|
1572
|
+
if (onChainUri)
|
|
1573
|
+
info(dim('a local viewer, purely for your eyes — your token does not need it. Ctrl-C whenever; the token stays up.'));
|
|
1410
1574
|
printServing(url, clone);
|
|
1411
1575
|
keepAlive();
|
|
1412
1576
|
}
|
|
@@ -1457,21 +1621,15 @@ async function cmdDeploySeries(flags) {
|
|
|
1457
1621
|
throw new Error(`--count ${count} exceeds the ${files.length} media file(s) in ${dirPath}`);
|
|
1458
1622
|
const slots = files.slice(0, count);
|
|
1459
1623
|
const dryRun = !!flags['dry-run'];
|
|
1624
|
+
if (dryRun)
|
|
1625
|
+
assertPreviewDeployer(flags); // fail fast, before the preview does any work (see cmdDeploy)
|
|
1626
|
+
assertRealIdentity(flags, { name, symbol, dryRun });
|
|
1460
1627
|
const lane = laneFromFlags(flags);
|
|
1461
1628
|
const publicClient = makePublicClient({ chainKey: CHAIN });
|
|
1462
1629
|
// Catch a wrong-network RPC with the clear mismatch message even on dry-run (which still reads
|
|
1463
1630
|
// the chain to predict the address); tolerate an unreachable RPC so an offline preview still works.
|
|
1464
1631
|
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
|
-
}
|
|
1632
|
+
// (identity guard already ran above, via the shared assertRealIdentity — before any RPC)
|
|
1475
1633
|
// Mint timing: mint-all → the whole series; mint-count N → the first N; else deferred.
|
|
1476
1634
|
const mintCount = flags['mint-all'] !== undefined ? count : flags['mint-count'] ? Number(flags['mint-count']) : 0;
|
|
1477
1635
|
if (mintCount > count)
|
|
@@ -1515,6 +1673,8 @@ async function cmdDeploySeries(flags) {
|
|
|
1515
1673
|
return;
|
|
1516
1674
|
}
|
|
1517
1675
|
factory = existing;
|
|
1676
|
+
if (!(await previewFactoryLive(publicClient, factory, 'Series factory')))
|
|
1677
|
+
return;
|
|
1518
1678
|
info(`would reuse canonical Series factory ${factory}`);
|
|
1519
1679
|
}
|
|
1520
1680
|
else {
|
|
@@ -1848,8 +2008,11 @@ async function cmdDeploySeries(flags) {
|
|
|
1848
2008
|
}
|
|
1849
2009
|
else {
|
|
1850
2010
|
// wallet lane (off-chain / inline) or cold lane: a single deploy tx, no staging sequence —
|
|
1851
|
-
// tokenFields is already built.
|
|
1852
|
-
|
|
2011
|
+
// tokenFields is already built. Same correction as the 1/1 lane: only claim a URI base when one
|
|
2012
|
+
// is actually written, or an --onchain-uri Series announces a localhost it never bakes.
|
|
2013
|
+
info(onChainUri
|
|
2014
|
+
? 'a wallet will become the owner; tokens resolve from chain — no URI base is baked in.'
|
|
2015
|
+
: `a wallet will become the owner; URIs point at ${baseUrl}`);
|
|
1853
2016
|
const result = await signTx(async (signer) => {
|
|
1854
2017
|
const { clone: predicted, params, salt } = await buildForDeployer(signer);
|
|
1855
2018
|
return prepareDeploySeries({ factory, params, salt, chainId: resolveChain(CHAIN).id, clone: predicted });
|
|
@@ -1877,8 +2040,9 @@ async function cmdDeploySeries(flags) {
|
|
|
1877
2040
|
tokenAttributes: offChainTokenTraits,
|
|
1878
2041
|
};
|
|
1879
2042
|
indexer.register(baseReg);
|
|
1880
|
-
const { state, elapsedMs } = await indexer
|
|
1881
|
-
|
|
2043
|
+
const { state, elapsedMs } = await reindexAfterDeploy(indexer, clone);
|
|
2044
|
+
if (state.eventCount > 0)
|
|
2045
|
+
ok(`reconstructed ${state.eventCount} events in ${elapsedMs}ms — ${state.tokens.length} token(s), max ${state.maxInvocations}`);
|
|
1882
2046
|
info(`extensions: ${state.extensions.map((e) => e.name).join(', ') || 'none'}`);
|
|
1883
2047
|
// Off-chain custody: bridge each token's keccak → durable locator so the resolver (local and,
|
|
1884
2048
|
// via `abx add --remote`, a hosted one) points images off this node.
|
|
@@ -1972,18 +2136,166 @@ async function cmdPredict(flags) {
|
|
|
1972
2136
|
info(dim('(this is the 1/1 lane; a Series/code drop uses a different factory → a different address — pass --dir / --script to predict those, or use that command\'s --dry-run)'));
|
|
1973
2137
|
console.log('');
|
|
1974
2138
|
}
|
|
1975
|
-
//
|
|
1976
|
-
//
|
|
1977
|
-
//
|
|
1978
|
-
function
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
2139
|
+
// The `--remote <name|url>` target (remote.ts owns the convention): a named remote's
|
|
2140
|
+
// `ABX_REMOTE_<NAME>_URL/_TOKEN`, an ad-hoc URL, or bare `--remote` = the self-host default.
|
|
2141
|
+
// Returns null for a local op (the default).
|
|
2142
|
+
function remoteFlag(flags) {
|
|
2143
|
+
return resolveRemote(flags.remote, flags['remote-token']);
|
|
2144
|
+
}
|
|
2145
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2146
|
+
/**
|
|
2147
|
+
* The factory-verification tri-state, said plainly. `null` means the check never ran (no factory
|
|
2148
|
+
* configured, or the configured one has no code on this chain — common on a local/dev chain), which
|
|
2149
|
+
* is NOT the same as the chain telling us this contract isn't a clone of it.
|
|
2150
|
+
*/
|
|
2151
|
+
function canonicalLabel(isCanonical) {
|
|
2152
|
+
if (isCanonical === true)
|
|
2153
|
+
return g('yes (factory-verified)');
|
|
2154
|
+
if (isCanonical === false)
|
|
2155
|
+
return `${c.orange}NO — not a clone of the configured factory${c.reset}`;
|
|
2156
|
+
return dim('not checked (no canonical factory for this chain — `abx doctor` shows which)');
|
|
2157
|
+
}
|
|
2158
|
+
/** How a lifecycle state reads at a glance — the same word everywhere it's printed. */
|
|
2159
|
+
function statusLabel(s) {
|
|
2160
|
+
if (s === 'live')
|
|
2161
|
+
return g('live');
|
|
2162
|
+
if (s === 'failed')
|
|
2163
|
+
return `${c.orange}failed${c.reset}`;
|
|
2164
|
+
if (s === 'stale')
|
|
2165
|
+
return `${c.orange}stale${c.reset}`;
|
|
2166
|
+
return dim(s); // queued | backfilling — in progress, not a problem
|
|
2167
|
+
}
|
|
2168
|
+
/** `label value` with only the label dimmed — status values carry their own color, and wrapping
|
|
2169
|
+
* them in `info()` would fight it. */
|
|
2170
|
+
const statusRow = (label, value) => console.log(` ${dim(label.padEnd(10))} ${value}`);
|
|
2171
|
+
/**
|
|
2172
|
+
* What to DO about a failure class — whose problem it is and whether waiting is the answer.
|
|
2173
|
+
* The class alone told an agent enough to reason it out; it does not tell a creator, and "whose
|
|
2174
|
+
* problem is this" is the single question a failed catch-up has to answer.
|
|
2175
|
+
*/
|
|
2176
|
+
function indexErrorAction(cls) {
|
|
2177
|
+
if (cls === 'rpc_rate_limited')
|
|
2178
|
+
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.";
|
|
2179
|
+
if (cls === 'rpc_unavailable')
|
|
2180
|
+
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.";
|
|
2181
|
+
if (cls === 'not_abx_contract')
|
|
2182
|
+
return 'the service found no ABX events at that address on this chain — check the address and that the service serves the right chain.';
|
|
2183
|
+
return 'the cause is in the service operator’s logs — nothing you can fix from here; contact them if it persists.';
|
|
2184
|
+
}
|
|
2185
|
+
/** The one wording for "registered, but catch-up failed" — shared by add/index so both say the same
|
|
2186
|
+
* thing: what failed, whose problem it is, and that the registration survived. */
|
|
2187
|
+
function failedCatchUpMessage(address, remote, err, check) {
|
|
2188
|
+
const cls = err?.class;
|
|
2189
|
+
return (`${address} is registered on ${remote.url}, but its catch-up FAILED` +
|
|
2190
|
+
`${cls ? ` (${cls}${err?.message ? `: ${err.message}` : ''})` : ''}.\n` +
|
|
2191
|
+
` ${cls ? `→ ${indexErrorAction(cls)}\n ` : ''}` +
|
|
2192
|
+
`The registration is durable and the service retries with backoff — watch it with ${bold(check + ' --watch')}.`);
|
|
2193
|
+
}
|
|
2194
|
+
/**
|
|
2195
|
+
* One human line for a status read: where it is, how far along, and why if it's unhappy.
|
|
2196
|
+
*
|
|
2197
|
+
* What "where" means depends on the state, so say the right thing rather than one number that reads
|
|
2198
|
+
* differently in each: a percentage is only honest while BACKFILLING (see indexProgress). A `live`
|
|
2199
|
+
* project's `toBlock` only advances when that project has events, so on a busy chain a perfectly
|
|
2200
|
+
* current project sits far below head — printing that as a ratio makes healthy look broken.
|
|
2201
|
+
*/
|
|
2202
|
+
function statusLine(s) {
|
|
2203
|
+
const p = indexProgress(s);
|
|
2204
|
+
let where;
|
|
2205
|
+
if (p)
|
|
2206
|
+
where = `${s.toBlock}/${s.headBlock} ${dim(`(${p.percent}%)`)}`;
|
|
2207
|
+
else if (!s.toBlock)
|
|
2208
|
+
where = dim('not indexed yet');
|
|
2209
|
+
else if (s.status === 'live')
|
|
2210
|
+
where = `${g('caught up')} ${dim(`· scanned through block ${s.toBlock}`)}`;
|
|
2211
|
+
else if (s.status === 'stale')
|
|
2212
|
+
where = `${dim('not tracking head right now')} ${dim(`· scanned through block ${s.toBlock}${s.headBlock ? ` of ${s.headBlock}` : ''}`)}`;
|
|
2213
|
+
else
|
|
2214
|
+
where = dim(`scanned through block ${s.toBlock}`);
|
|
2215
|
+
const why = s.error ? ` ${c.orange}${s.error.class}${c.reset}${s.error.message ? dim(` — ${s.error.message}`) : ''}` : '';
|
|
2216
|
+
return `${statusLabel(s.status)} ${where}${why}`;
|
|
2217
|
+
}
|
|
2218
|
+
/**
|
|
2219
|
+
* Report a register/reindex answer, whichever of the two conformant shapes the service used.
|
|
2220
|
+
*
|
|
2221
|
+
* A `200` already carries the counts. A `202` means the registration is durable and catch-up is still
|
|
2222
|
+
* running — so by default we poll to a terminal state and print the SAME summary line, giving the
|
|
2223
|
+
* human the "waits, then tells you what happened" UX without the service holding a socket open for
|
|
2224
|
+
* minutes. `--no-wait` stops at the 202 and names the command that checks later.
|
|
2225
|
+
*/
|
|
2226
|
+
async function reportRemoteIndexing(remote, chainId, address, r, flags, verb) {
|
|
2227
|
+
// A real ABX clone ALWAYS emits a spine (its extension registrations at minimum), so a caught-up
|
|
2228
|
+
// projection with zero events means the service scanned the wrong chain/floor or its RPC hasn't
|
|
2229
|
+
// served the logs — not that the project is empty. A ✓ there is the lie that produces an empty
|
|
2230
|
+
// dashboard (the same guard `reindexAfterDeploy` applies locally).
|
|
2231
|
+
const settledLine = (events, tail) => {
|
|
2232
|
+
if (events > 0)
|
|
2233
|
+
ok(`remote resolver ${verb} ${address}: ${tail}`);
|
|
2234
|
+
else
|
|
2235
|
+
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.`);
|
|
2236
|
+
};
|
|
2237
|
+
if (!isAccepted(r)) {
|
|
2238
|
+
settledLine(r.project.eventCount, `${r.project.eventCount} events ${dim(`(${r.mode}, ${r.elapsedMs}ms)`)}`);
|
|
2239
|
+
return;
|
|
2240
|
+
}
|
|
2241
|
+
const spec = remote.name ? remote.name.toLowerCase() : remote.source === 'default' ? '' : remote.url;
|
|
2242
|
+
const check = `abx status ${address} --remote${spec ? ` ${spec}` : ''}`;
|
|
2243
|
+
// The registration is durable in EVERY branch here — but don't claim it "is catching up" when the
|
|
2244
|
+
// service already told us the catch-up failed. Two different sentences for two different facts.
|
|
2245
|
+
if (r.project.status === 'failed') {
|
|
2246
|
+
// Lead with the OUTCOME, not the sub-step that succeeded: a line starting "registered on …" skims
|
|
2247
|
+
// as success even with the failure later in the sentence (a reviewer read it exactly that way).
|
|
2248
|
+
warn(`${bold('catch-up FAILED')} on ${remote.url} — nothing is being served yet ${dim('(the registration itself is durable; the service retries it)')}`);
|
|
2249
|
+
}
|
|
2250
|
+
else {
|
|
2251
|
+
info(`registered — ${r.project.status} ${dim('(the service accepted it and is catching up; the registration is durable)')}`);
|
|
2252
|
+
}
|
|
2253
|
+
// A known failure is not something to "not wait" for — we already have the answer, so report it as
|
|
2254
|
+
// one regardless of --no-wait (never a ✓ over a broken index).
|
|
2255
|
+
if (flags['no-wait'] !== undefined && r.project.status !== 'failed') {
|
|
2256
|
+
info(`not waiting (--no-wait). Check with ${bold(check)}`);
|
|
2257
|
+
return;
|
|
2258
|
+
}
|
|
2259
|
+
const label = `remote ${verb === 'indexed' ? 'add' : 'index'}`;
|
|
2260
|
+
if (r.project.status === 'failed') {
|
|
2261
|
+
// Fetch the class the register response may not have carried, so the "whose problem" line is
|
|
2262
|
+
// never missing on the path that reports the failure soonest.
|
|
2263
|
+
const st = await serviceClient(remote).projectStatus(chainId, address).catch(() => undefined);
|
|
2264
|
+
throw new Error(`${label}: ${failedCatchUpMessage(address, remote, st?.error ?? undefined, check)}`);
|
|
2265
|
+
}
|
|
2266
|
+
let shown = -100;
|
|
2267
|
+
let lastStatus;
|
|
2268
|
+
try {
|
|
2269
|
+
const final = await serviceClient(remote).awaitIndexed(chainId, address, {
|
|
2270
|
+
onProgress: (s) => {
|
|
2271
|
+
const p = indexProgress(s);
|
|
2272
|
+
// Print on a state change or a meaningful step — a poll line every 3s is noise in a log an
|
|
2273
|
+
// agent has to read back.
|
|
2274
|
+
if (s.status !== lastStatus || (p && p.percent - shown >= 10)) {
|
|
2275
|
+
lastStatus = s.status;
|
|
2276
|
+
if (p)
|
|
2277
|
+
shown = p.percent;
|
|
2278
|
+
console.log(` ${statusLine(s)}`);
|
|
2279
|
+
}
|
|
2280
|
+
},
|
|
2281
|
+
});
|
|
2282
|
+
if (final.status === 'failed') {
|
|
2283
|
+
throw new Error(`${label}: ${failedCatchUpMessage(address, remote, final.error, check)}`);
|
|
2284
|
+
}
|
|
2285
|
+
settledLine(final.eventCount, `${final.eventCount} events, ${final.tokenCount} token(s) ${dim('(live)')}`);
|
|
2286
|
+
}
|
|
2287
|
+
catch (err) {
|
|
2288
|
+
if (err instanceof AbxIndexTimeoutError) {
|
|
2289
|
+
warn(`${address} is still ${err.last?.status ?? 'catching up'} on ${remote.url} — nothing is lost, it just isn't done.`);
|
|
2290
|
+
info(`follow it with ${bold(check + ' --watch')}`);
|
|
2291
|
+
return;
|
|
2292
|
+
}
|
|
2293
|
+
// The service went away mid-wait (or rejected the poll). The registration still landed — say
|
|
2294
|
+
// which failure this is, in the same words every other remote command uses.
|
|
2295
|
+
if (err instanceof AbxServiceError)
|
|
2296
|
+
throw describeRemoteError(err, remote, `remote ${verb}: registered, but polling status`);
|
|
2297
|
+
throw err;
|
|
1985
2298
|
}
|
|
1986
|
-
return base;
|
|
1987
2299
|
}
|
|
1988
2300
|
/**
|
|
1989
2301
|
* Ensure a resolver admin token exists locally, generating + persisting one to `.env`
|
|
@@ -2016,24 +2328,24 @@ function ensureEffectsToken() {
|
|
|
2016
2328
|
process.env.ABX_EFFECTS_TOKEN = token;
|
|
2017
2329
|
return { token, generated: true };
|
|
2018
2330
|
}
|
|
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
2331
|
// ── add ──────────────────────────────────────────────────────────────────────
|
|
2029
2332
|
// Register + index a project this node didn't deploy. LOCAL by default (this
|
|
2030
2333
|
// machine's store); `--remote [url]` instead tells a HOSTED resolver to index it —
|
|
2031
2334
|
// the bridge a local deploy can't make on its own (separate projection stores).
|
|
2032
2335
|
async function cmdAdd(address, flags) {
|
|
2033
2336
|
if (!address || address.startsWith('--')) {
|
|
2034
|
-
console.error('usage: abx add <address> [--from-block N] [--factory 0x..] [--label "..."] [--remote [url]]\n');
|
|
2337
|
+
console.error('usage: abx add <address> [--from-block N] [--factory 0x..] [--label "..."] [--remote [name|url]]\n');
|
|
2035
2338
|
process.exit(1);
|
|
2036
2339
|
}
|
|
2340
|
+
// REFUSE `--dry-run` rather than ignoring it. `add` is a write (a local registration + index, and
|
|
2341
|
+
// with `--remote` a registration on someone else's service), and it has no preview mode — so
|
|
2342
|
+
// silently proceeding to DO the thing when the caller explicitly asked to preview is the one
|
|
2343
|
+
// outcome we must never produce. Name what's read-only instead.
|
|
2344
|
+
if (flags['dry-run'] !== undefined) {
|
|
2345
|
+
throw new Error('`abx add` has no --dry-run: it registers + indexes for real (and with --remote it registers on that service). ' +
|
|
2346
|
+
'Nothing here touches the chain, but it does write. To look before acting: `abx state <address>` (on-chain snapshot) ' +
|
|
2347
|
+
'or `abx status <address> [--remote <name>]` (what a node already has). Re-run without --dry-run when you mean it.');
|
|
2348
|
+
}
|
|
2037
2349
|
// `--attributes` is lane-aware here exactly as at deploy: a PER-TOKEN payload edits a Series'
|
|
2038
2350
|
// per-token off-chain traits; a flat payload (+ `--traits`) edits the collection/1-of-1 `attributes`.
|
|
2039
2351
|
// Ambiguity defaults to flat (see looksPerTokenAttributes), so a 1/1 add is never mis-read.
|
|
@@ -2045,10 +2357,10 @@ async function cmdAdd(address, flags) {
|
|
|
2045
2357
|
if (flags.traits)
|
|
2046
2358
|
flagTraits.push(...parseTraitPairs(flags.traits));
|
|
2047
2359
|
const editedTokenAttributes = perTokenEdit ? parseSeriesTraitsById(attrRaw) : undefined;
|
|
2048
|
-
const remote =
|
|
2360
|
+
const remote = remoteFlag(flags);
|
|
2049
2361
|
if (remote) {
|
|
2050
|
-
|
|
2051
|
-
// Bridge what a
|
|
2362
|
+
requireRemoteToken(remote);
|
|
2363
|
+
// Bridge what a remote resolver can't derive itself: the off-chain traits and the durable
|
|
2052
2364
|
// content locators (ipfs://…). Prefer flags; otherwise forward what the LOCAL deploy stored
|
|
2053
2365
|
// (the local registration), and compute locators from this machine's content index if needed.
|
|
2054
2366
|
const localReg = new SelfHostIndexer().store.getRegistration(address);
|
|
@@ -2057,7 +2369,7 @@ async function cmdAdd(address, flags) {
|
|
|
2057
2369
|
: localReg?.attributes
|
|
2058
2370
|
? normalizeAttributes(JSON.parse(localReg.attributes))
|
|
2059
2371
|
: undefined;
|
|
2060
|
-
// Bridge a Series' per-token off-chain traits to the
|
|
2372
|
+
// Bridge a Series' per-token off-chain traits to the remote resolver (the resolver has no other
|
|
2061
2373
|
// way to derive them — they're operator metadata, not chain state). A fresh per-token `--attributes`
|
|
2062
2374
|
// EDITS them; otherwise forward what the LOCAL deploy stored. Best-effort parse.
|
|
2063
2375
|
let tokenAttributes;
|
|
@@ -2083,6 +2395,7 @@ async function cmdAdd(address, flags) {
|
|
|
2083
2395
|
// (Its ABSENCE here was the bug: a hosted resolver defaulted to genesis and scanned the whole
|
|
2084
2396
|
// chain.) Re-sending the same floor stays incremental server-side, so a nudge ≠ a re-scan.
|
|
2085
2397
|
const body = {
|
|
2398
|
+
chainId: resolveChain(CHAIN).id,
|
|
2086
2399
|
address,
|
|
2087
2400
|
fromBlock: await resolveScanFloor(address, localReg?.fromBlock, flags),
|
|
2088
2401
|
factory: await detectCanonicalFactory(address, flags.factory, localReg?.factory),
|
|
@@ -2094,12 +2407,22 @@ async function cmdAdd(address, flags) {
|
|
|
2094
2407
|
contentLocators: Object.keys(contentLocators).length ? contentLocators : undefined,
|
|
2095
2408
|
full: flags.full ? true : undefined,
|
|
2096
2409
|
};
|
|
2097
|
-
info(`${bold('REMOTE')} → ${remote} ${dim('(registering with the
|
|
2410
|
+
info(`${bold('REMOTE')} → ${remote.url} ${dim('(registering with the remote resolver — NOT this machine)')}`);
|
|
2098
2411
|
if (body.contentLocators)
|
|
2099
2412
|
info(`bridging image locator → ${Object.values(body.contentLocators)[0]} ${dim('(so the resolver points at IPFS, not its own localhost)')}`);
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2413
|
+
let r;
|
|
2414
|
+
try {
|
|
2415
|
+
r = await serviceClient(remote).registerProject(body);
|
|
2416
|
+
}
|
|
2417
|
+
catch (err) {
|
|
2418
|
+
throw describeRemoteError(err, remote, 'remote add');
|
|
2419
|
+
}
|
|
2420
|
+
await reportRemoteIndexing(remote, body.chainId, address, r, flags, 'indexed');
|
|
2421
|
+
info(`it now serves ${remote.url}/t/${body.chainId}/${address.toLowerCase()}/0`);
|
|
2422
|
+
// "Indexed" is not "correct". This line proves the service replayed the chain and will answer at
|
|
2423
|
+
// that URL — it says nothing about whether the bytes it serves match the on-chain commitment, and
|
|
2424
|
+
// two reviewers stopped here and reported a blank page as fixed. Name the step that checks.
|
|
2425
|
+
info(`confirm what it actually serves (bytes vs. the on-chain hash): ${bold(`abx verify ${address} --remote ${remote.name?.toLowerCase() ?? remote.url}`)}`);
|
|
2103
2426
|
return;
|
|
2104
2427
|
}
|
|
2105
2428
|
allowLargeScan(flags);
|
|
@@ -2142,7 +2465,11 @@ async function cmdAdd(address, flags) {
|
|
|
2142
2465
|
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
2466
|
}
|
|
2144
2467
|
catch { /* advisory only — the real scan still runs */ }
|
|
2145
|
-
|
|
2468
|
+
// Don't accept a zero here either: `deploy-code` finishes through this command, so this IS the
|
|
2469
|
+
// post-deploy index for a code project — and a real ABX clone always emits a spine (its extension
|
|
2470
|
+
// registrations at minimum), so 0 events means the RPC hasn't served the logs yet, not that the
|
|
2471
|
+
// project is empty. See reindexAfterDeploy.
|
|
2472
|
+
const { state, elapsedMs } = await reindexAfterDeploy(indexer, address);
|
|
2146
2473
|
// Resolve durable locators for off-chain-by-hash content from this machine's index.
|
|
2147
2474
|
const locators = await collectContentLocators(state, resolveBackend(storageOptions(storageOverrides(flags))));
|
|
2148
2475
|
if (Object.keys(locators).length) {
|
|
@@ -2159,7 +2486,14 @@ async function cmdAdd(address, flags) {
|
|
|
2159
2486
|
contentLocators: JSON.stringify(locators),
|
|
2160
2487
|
});
|
|
2161
2488
|
}
|
|
2162
|
-
|
|
2489
|
+
// A ✓ on 0 events is the lie that produced an empty dashboard; reindexAfterDeploy has already
|
|
2490
|
+
// explained the failure and named the recovery command, so don't stamp it as success too.
|
|
2491
|
+
if (state.eventCount > 0) {
|
|
2492
|
+
ok(`registered + indexed ${state.name ?? address} LOCALLY (this machine): ${state.eventCount} events in ${elapsedMs}ms`);
|
|
2493
|
+
}
|
|
2494
|
+
else {
|
|
2495
|
+
warn(`registered ${state.name ?? address}, but with NO reconstructed state — it will serve empty until the index succeeds.`);
|
|
2496
|
+
}
|
|
2163
2497
|
info(`serve it from here with ${bold('abx serve')} — or push it to a hosted resolver with ${bold('abx add ' + address + ' --remote')}`);
|
|
2164
2498
|
}
|
|
2165
2499
|
/** The code-project trust anchor: use the canonical factory, else deploy one (a sandbox /
|
|
@@ -2226,6 +2560,271 @@ async function ensureSeedSource(publicClient) {
|
|
|
2226
2560
|
* Schemas: --schema key:Type:Auth[,key:Type:Auth…] (e.g. palette:HexColor:TokenOwner).
|
|
2227
2561
|
* Seeds: canonical randomizer by default; --no-seed opts out.
|
|
2228
2562
|
*/
|
|
2563
|
+
// ── the demo walkthrough: teaching sections, demo-only ────────────────────────
|
|
2564
|
+
//
|
|
2565
|
+
// `abx demo` is a TEACHING command, not a shortcut — the docs point a first-time reader here to
|
|
2566
|
+
// learn what the toolkit does on their behalf. Its old form asserted the interesting claims
|
|
2567
|
+
// ("reconstructed 9 events — no provider involved") without ever showing them, which made it a
|
|
2568
|
+
// smoke test wearing a demo's clothes. These sections demonstrate instead: print the spine the
|
|
2569
|
+
// chain now holds, throw the local projection away and rebuild it, then read the token back the way
|
|
2570
|
+
// a marketplace would. They run only for `demo` (never `deploy`), and never pause — an agent or CI
|
|
2571
|
+
// run has to behave identically.
|
|
2572
|
+
/**
|
|
2573
|
+
* The deterministic part of a projection: everything that is a pure function of the chain.
|
|
2574
|
+
* Deliberately EXCLUDES `reconstructedAt`, `rpcUrl` and `toBlock` — a timestamp, the endpoint that
|
|
2575
|
+
* happened to answer, and the head at scan time all legitimately differ between two replays, so
|
|
2576
|
+
* folding them in would make the rebuild proof fail for reasons that aren't about correctness.
|
|
2577
|
+
*/
|
|
2578
|
+
function projectionFingerprint(s) {
|
|
2579
|
+
const canonical = {
|
|
2580
|
+
address: s.address.toLowerCase(),
|
|
2581
|
+
name: s.name,
|
|
2582
|
+
symbol: s.symbol,
|
|
2583
|
+
owner: s.owner?.toLowerCase() ?? null,
|
|
2584
|
+
isCanonical: s.isCanonical,
|
|
2585
|
+
deployBlock: s.deployBlock,
|
|
2586
|
+
eventCount: s.eventCount,
|
|
2587
|
+
royalty: s.royalty ? { bps: s.royalty.bps, receiver: s.royalty.receiver.toLowerCase() } : null,
|
|
2588
|
+
extensions: s.extensions.map((e) => e.name).sort(),
|
|
2589
|
+
collectionFields: s.collectionFields.map((f) => `${f.field}=${f.value}`).sort(),
|
|
2590
|
+
tokens: s.tokens.map((t) => ({ id: t.tokenId, minted: t.minted, owner: t.owner?.toLowerCase() ?? null })),
|
|
2591
|
+
events: s.events.map((e) => `${e.blockNumber}:${e.logIndex}:${e.name}`),
|
|
2592
|
+
};
|
|
2593
|
+
return createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
|
|
2594
|
+
}
|
|
2595
|
+
/**
|
|
2596
|
+
* Print the reconstructed spine — the point being that this list IS the database. Rendered inside
|
|
2597
|
+
* the index step (no header of its own: "Index it — replay the event spine" immediately followed by
|
|
2598
|
+
* a separate "The event spine" step read as a stutter).
|
|
2599
|
+
*/
|
|
2600
|
+
function walkthroughSpine(state) {
|
|
2601
|
+
if (state.events.length === 0) {
|
|
2602
|
+
warn('no events to show (the index came back empty — see the recovery hint above).');
|
|
2603
|
+
return;
|
|
2604
|
+
}
|
|
2605
|
+
const width = Math.max(...state.events.map((e) => e.name.length));
|
|
2606
|
+
state.events.forEach((e, i) => {
|
|
2607
|
+
// ERC vs ABX register: standard ERC-721 events a marketplace already understands, versus ABX's
|
|
2608
|
+
// own. Worth surfacing — it's why an ABX token indexes fine on tools that know nothing about ABX.
|
|
2609
|
+
const reg = e.register === 2 ? p('ABX') : dim('ERC');
|
|
2610
|
+
console.log(` ${dim(`#${String(i + 1).padStart(2)}`)} ${reg} ${e.name.padEnd(width)} ${dim(e.what)}`);
|
|
2611
|
+
});
|
|
2612
|
+
console.log(` ${dim('→')} those ${bold(String(state.events.length))} lines ${bold('are')} the database. ` +
|
|
2613
|
+
dim('There is no other copy that counts — not ours, not anyone\'s.'));
|
|
2614
|
+
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.')}`);
|
|
2615
|
+
}
|
|
2616
|
+
/**
|
|
2617
|
+
* [4] The claim, demonstrated: delete the local projection and rebuild it from the chain.
|
|
2618
|
+
*
|
|
2619
|
+
* This is the one step that can't be faked by good output — it drops the projection for real
|
|
2620
|
+
* (registration kept), confirms it's gone, replays from the deploy block, and compares a
|
|
2621
|
+
* fingerprint of everything chain-derived. If ABX's premise is wrong, this step fails loudly.
|
|
2622
|
+
*/
|
|
2623
|
+
async function walkthroughRebuild(indexer, address, before) {
|
|
2624
|
+
step('The moment of truth · delete it all');
|
|
2625
|
+
const fpBefore = projectionFingerprint(before);
|
|
2626
|
+
indexer.dropProjection(address);
|
|
2627
|
+
const gone = indexer.getProject(address) === null;
|
|
2628
|
+
console.log(` ${dim('wiping this computer\'s copy …')} ${gone ? g('gone. nothing left locally.') : `${c.orange}⚠ still present${c.reset}`}`);
|
|
2629
|
+
const { state: after, elapsedMs } = await indexer.reindex(address, { full: true });
|
|
2630
|
+
const fpAfter = projectionFingerprint(after);
|
|
2631
|
+
console.log(` ${dim(`asking ${CHAIN} to tell us everything again …`)} ` +
|
|
2632
|
+
`${g(`${after.eventCount} events, ${elapsedMs}ms`)}`);
|
|
2633
|
+
if (fpBefore === fpAfter) {
|
|
2634
|
+
ok(bold('byte-for-byte identical.'));
|
|
2635
|
+
info('Your token just survived losing every local file. No backup, no API key, no company —');
|
|
2636
|
+
info(`the chain remembered. ${dim('That is the whole point of ABX.')}`);
|
|
2637
|
+
info(dim(`checked by hashing every chain-derived field, not by eyeballing it: sha256 ${fpAfter.slice(0, 12)}…`));
|
|
2638
|
+
}
|
|
2639
|
+
else {
|
|
2640
|
+
warn('the rebuilt state does NOT match what we just had — that is a real bug, please report it.');
|
|
2641
|
+
info(`before ${fpBefore.slice(0, 16)}… · after ${fpAfter.slice(0, 16)}…`);
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
/**
|
|
2645
|
+
* [5] Read the token back the way a marketplace would.
|
|
2646
|
+
*
|
|
2647
|
+
* Which is genuinely a different act per lane, so it reads from the real source in each case rather
|
|
2648
|
+
* than always going through the local server:
|
|
2649
|
+
* • fully on-chain → call `tokenURI(0)` on the contract. That IS what a marketplace does, and on
|
|
2650
|
+
* this lane the whole answer (art included) comes back from the chain with nothing else running.
|
|
2651
|
+
* • off-chain custody → fetch the resolver, because that's what the baked URI points at.
|
|
2652
|
+
* Reading the on-chain lane over HTTP would have quietly implied the local server was load-bearing
|
|
2653
|
+
* when it isn't — the opposite of the lesson.
|
|
2654
|
+
*/
|
|
2655
|
+
async function walkthroughReadBack(state, baseUrl, onChainUri) {
|
|
2656
|
+
step('Read it back the way a marketplace would');
|
|
2657
|
+
const token = state.tokens[0];
|
|
2658
|
+
if (!token?.minted) {
|
|
2659
|
+
info('no minted token to read yet.');
|
|
2660
|
+
return;
|
|
2661
|
+
}
|
|
2662
|
+
if (onChainUri) {
|
|
2663
|
+
info(dim(`calling tokenURI(0) on your contract — the same call OpenSea makes …`));
|
|
2664
|
+
try {
|
|
2665
|
+
const uri = (await makePublicClient({ chainKey: CHAIN }).readContract({
|
|
2666
|
+
address: state.address,
|
|
2667
|
+
abi: oneOfOneImageAbi,
|
|
2668
|
+
functionName: 'tokenURI',
|
|
2669
|
+
args: [0n],
|
|
2670
|
+
}));
|
|
2671
|
+
const json = decodeOnChainJson(uri);
|
|
2672
|
+
if (json) {
|
|
2673
|
+
const parsed = JSON.parse(json);
|
|
2674
|
+
const img = typeof parsed.image === 'string' ? parsed.image : '';
|
|
2675
|
+
console.log(` ${g('✓')} came back with: ${bold(String(parsed.name ?? '(no name)'))}`);
|
|
2676
|
+
console.log(` ${dim(`image: ${img.slice(0, 48)}${img.length > 48 ? '…' : ''}`)}`);
|
|
2677
|
+
// The punchline of the whole lane: a data: URI means the art travelled IN the answer.
|
|
2678
|
+
if (img.startsWith('data:'))
|
|
2679
|
+
info(`${g('the art itself came back in that answer')} ${dim('— no link to follow, nothing to go missing')}`);
|
|
2680
|
+
}
|
|
2681
|
+
else {
|
|
2682
|
+
console.log(` ${dim(uri.slice(0, 160))}${uri.length > 160 ? dim('…') : ''}`);
|
|
2683
|
+
}
|
|
2684
|
+
info(dim('nothing was running to answer that. no server of ours, no server of yours.'));
|
|
2685
|
+
}
|
|
2686
|
+
catch (e) {
|
|
2687
|
+
info(dim(`could not read tokenURI from the chain: ${e.message}`));
|
|
2688
|
+
}
|
|
2689
|
+
console.log(` ${dim('read it yourself any time:')} ${bold(`abx tokenuri ${state.address}`)}`);
|
|
2690
|
+
return;
|
|
2691
|
+
}
|
|
2692
|
+
info(`tokenURI(0) ${dim('on chain →')} ${token.tokenURI ?? dim('(none)')}`);
|
|
2693
|
+
// Off-chain custody: the CHAIN holds a keccak256 commitment to the image and a URI base; this NODE
|
|
2694
|
+
// holds the bytes. That split is the thing worth understanding, so name it rather than implying the
|
|
2695
|
+
// JSON came from the chain.
|
|
2696
|
+
info(dim('the chain stored a URI base + a keccak256 commitment; this node serves the bytes.'));
|
|
2697
|
+
try {
|
|
2698
|
+
const res = await fetch(`${baseUrl}/t/${resolveChain(CHAIN).id}/${state.address}/0`);
|
|
2699
|
+
const json = (await res.json());
|
|
2700
|
+
const shown = { name: json.name, image: json.image };
|
|
2701
|
+
console.log(` ${dim(JSON.stringify(shown))}`);
|
|
2702
|
+
if (Array.isArray(json.abx_provenance)) {
|
|
2703
|
+
info(dim(`every field is tagged with where it came from (abx_provenance: ${json.abx_provenance.length} entries)`));
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
2706
|
+
catch (e) {
|
|
2707
|
+
info(dim(`could not read the served metadata: ${e.message}`));
|
|
2708
|
+
}
|
|
2709
|
+
console.log(` ${dim('prove the bytes match the chain:')} ${bold(`abx verify ${state.address}`)}`);
|
|
2710
|
+
}
|
|
2711
|
+
/** Is a TCP port already bound on localhost? Used to preflight a serve BEFORE spending a tx. */
|
|
2712
|
+
async function portInUse(port) {
|
|
2713
|
+
const { createServer } = await import('node:net');
|
|
2714
|
+
return new Promise((resolve) => {
|
|
2715
|
+
const probe = createServer();
|
|
2716
|
+
probe.once('error', (e) => resolve(e.code === 'EADDRINUSE'));
|
|
2717
|
+
probe.once('listening', () => probe.close(() => resolve(false)));
|
|
2718
|
+
probe.listen(port);
|
|
2719
|
+
});
|
|
2720
|
+
}
|
|
2721
|
+
/**
|
|
2722
|
+
* Index a project we *just* deployed — and don't believe a zero.
|
|
2723
|
+
*
|
|
2724
|
+
* `eth_getLogs` is read-after-write inconsistent on load-balanced RPCs: `waitForTransactionReceipt`
|
|
2725
|
+
* resolves against a node that has the block, then the log query lands on one that doesn't yet, and
|
|
2726
|
+
* returns an empty set for a block we KNOW contains our deploy. The old code took that single read at
|
|
2727
|
+
* face value, printed `✓ reconstructed 0 events`, stored the empty projection, and served an empty
|
|
2728
|
+
* dashboard — a first-run that looks like the toolkit simply doesn't work. It reproduced 100% of the
|
|
2729
|
+
* time on `https://sepolia.base.org`, which is the DEFAULT endpoint when there's no `.env`, i.e. the
|
|
2730
|
+
* documented first run was the broken path. The same block returned all 9 logs seconds later.
|
|
2731
|
+
*
|
|
2732
|
+
* We have the one thing that makes this checkable: we just minted, so the spine cannot be empty.
|
|
2733
|
+
* So verify instead of trusting — re-scan with backoff until events appear, and if they never do,
|
|
2734
|
+
* say so as a FAILURE with the recovery command rather than dressing a zero up as a ✓.
|
|
2735
|
+
*/
|
|
2736
|
+
async function reindexAfterDeploy(indexer, address, opts = {}) {
|
|
2737
|
+
const attempts = opts.attempts ?? 6;
|
|
2738
|
+
const delayMs = opts.delayMs ?? 1500;
|
|
2739
|
+
let last = await indexer.reindex(address);
|
|
2740
|
+
for (let i = 1; i < attempts && last.state.eventCount === 0; i++) {
|
|
2741
|
+
if (i === 1) {
|
|
2742
|
+
info(dim("no events yet — the RPC hasn't served the logs for that block; re-scanning…"));
|
|
2743
|
+
}
|
|
2744
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
2745
|
+
// `full: true` — the stored projection has 0 events, so there is no valid checkpoint to
|
|
2746
|
+
// resume from; a full replay from the deploy block is the only correct re-scan.
|
|
2747
|
+
last = await indexer.reindex(address, { full: true });
|
|
2748
|
+
}
|
|
2749
|
+
if (last.state.eventCount === 0) {
|
|
2750
|
+
warn(`the RPC still reports no logs for this project after ${attempts} tries — the deploy DID succeed ` +
|
|
2751
|
+
`(it's on chain), but this node can't reconstruct it yet.`);
|
|
2752
|
+
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).')}`);
|
|
2753
|
+
}
|
|
2754
|
+
return last;
|
|
2755
|
+
}
|
|
2756
|
+
/**
|
|
2757
|
+
* `abx preview` — serve the program on localhost, live, for as long as the work is being made.
|
|
2758
|
+
*
|
|
2759
|
+
* The studio lane, and deliberately the FIRST thing to reach for on a code project: it renders the
|
|
2760
|
+
* same document the generator serves (real `abx.js`, real tokenData shape, real dependency tags)
|
|
2761
|
+
* with a synthetic seed, so a creator can refresh for new seeds, drive their PostParams from real
|
|
2762
|
+
* inputs, and watch an animated piece actually move — none of which a still-image sweep can show.
|
|
2763
|
+
* No chain, no key, no deploy. `--shoot` renders the same document headlessly for an agent that
|
|
2764
|
+
* can't open a browser.
|
|
2765
|
+
*/
|
|
2766
|
+
async function cmdPreview(flags) {
|
|
2767
|
+
warnStrayFlags(flags, PREVIEW_FLAGS, 'preview');
|
|
2768
|
+
const cfg = previewConfigFromFlags(flags);
|
|
2769
|
+
const shootDir = flags.shoot && flags.shoot !== 'true' ? String(flags.shoot) : flags.shoot === 'true' ? 'abx-preview' : undefined;
|
|
2770
|
+
const count = Math.min(Math.max(Number(flags.count ?? 9) || 9, 1), 64);
|
|
2771
|
+
console.log(bold(`\n ABX Self-Host Toolkit — preview\n ${dim('the program, running locally — no chain, no deploy')}`));
|
|
2772
|
+
const { notes } = previewDepTags(cfg.deps);
|
|
2773
|
+
step('Program');
|
|
2774
|
+
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)')}`);
|
|
2775
|
+
if (cfg.schemas.length)
|
|
2776
|
+
info(`params: ${cfg.schemas.map(describeSchema).join(' · ')}`);
|
|
2777
|
+
else
|
|
2778
|
+
info(dim('params: none declared — add --schema key:Type:Auth to drive them from the studio'));
|
|
2779
|
+
for (const n of notes)
|
|
2780
|
+
info(`dep ${n}`);
|
|
2781
|
+
// A raw on-chain dependency can't be fetched without a chain, so the preview would render a
|
|
2782
|
+
// sketch missing its runtime and look broken for the wrong reason. Say so rather than let them
|
|
2783
|
+
// debug their own art.
|
|
2784
|
+
if (cfg.deps.some((d) => d.display.startsWith('0x'))) {
|
|
2785
|
+
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.');
|
|
2786
|
+
}
|
|
2787
|
+
const server = await startPreviewServer(cfg, shootDir ? 0 : Number(flags.port ?? DEFAULT_PREVIEW_PORT));
|
|
2788
|
+
if (shootDir) {
|
|
2789
|
+
step(`Render ${count} seeds headlessly`);
|
|
2790
|
+
try {
|
|
2791
|
+
const shots = await shootPreview(server.url, shootDir, count, {
|
|
2792
|
+
width: Number(flags.width ?? 1000) || 1000,
|
|
2793
|
+
timeoutMs: Number(flags['timeout-ms'] ?? 10_000) || 10_000,
|
|
2794
|
+
});
|
|
2795
|
+
ok(`${shots.length} frames → ${shootDir}/ ${dim('(traits in traits.json)')}`);
|
|
2796
|
+
for (const s of shots) {
|
|
2797
|
+
const t = s.traits && Object.keys(s.traits).length
|
|
2798
|
+
? Object.entries(s.traits).map(([k, v]) => `${k} ${String(v)}`).join(' · ')
|
|
2799
|
+
: `${c.orange}no traits reported${c.reset}`;
|
|
2800
|
+
console.log(` ${dim(s.seed.slice(0, 10) + '…')} ${t}${s.done ? '' : dim(' (no abx.done())')}`);
|
|
2801
|
+
}
|
|
2802
|
+
const silent = shots.filter((s) => !s.traits || !Object.keys(s.traits).length).length;
|
|
2803
|
+
if (silent === shots.length) {
|
|
2804
|
+
warn('NO frame reported traits — `abx.traits({…})` is the only thing that becomes marketplace `attributes`. Verify with `abx inspect`.');
|
|
2805
|
+
}
|
|
2806
|
+
else if (shots.length > 1 && new Set(shots.map((s) => JSON.stringify(s.traits))).size === 1) {
|
|
2807
|
+
// Only meaningful when traits DID come back: identical values across seeds is the signature
|
|
2808
|
+
// of a sketch that never reads `abx.tokenData.seed` (prototyped on Math.random()), which
|
|
2809
|
+
// deploys as N visually identical tokens. Skipped when nothing reported at all — the
|
|
2810
|
+
// warning above already covers that, and firing both reads as noise.
|
|
2811
|
+
warn('every seed produced identical traits — check the sketch actually reads `abx.tokenData.seed` (the silent "all tokens the same" failure).');
|
|
2812
|
+
}
|
|
2813
|
+
}
|
|
2814
|
+
finally {
|
|
2815
|
+
await server.close();
|
|
2816
|
+
}
|
|
2817
|
+
return;
|
|
2818
|
+
}
|
|
2819
|
+
step('Studio');
|
|
2820
|
+
console.log(`\n ${g('●')} ${bold('preview')} ${server.url}`);
|
|
2821
|
+
console.log(` ${dim('studio ')}${server.url} ${dim('seed + params + live traits')}`);
|
|
2822
|
+
console.log(` ${dim('grid ')}${server.url}/grid ${dim('9 seeds at once, all live')}`);
|
|
2823
|
+
console.log(` ${dim('bare view ')}${server.url}/view ${dim('the generator document itself')}`);
|
|
2824
|
+
console.log(`\n ${dim('Edit the program and refresh — it is re-read from disk. Ctrl-C to stop.')}`);
|
|
2825
|
+
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`);
|
|
2826
|
+
await new Promise(() => { }); // block like `serve` — the creator drives it
|
|
2827
|
+
}
|
|
2229
2828
|
/**
|
|
2230
2829
|
* `abx inspect <script.js>` — read a generative script and, WITHOUT executing it, report what it
|
|
2231
2830
|
* needs (traits + their on-chain reproducibility, dependency hints, size → assembled-document size →
|
|
@@ -2406,17 +3005,7 @@ async function cmdDeployCode(flags) {
|
|
|
2406
3005
|
const dryRun = !!flags['dry-run'];
|
|
2407
3006
|
const name = flags.name ?? 'ABX Code';
|
|
2408
3007
|
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
|
-
}
|
|
3008
|
+
assertRealIdentity(flags, { name, symbol, dryRun });
|
|
2420
3009
|
const maxProvided = flags.max !== undefined;
|
|
2421
3010
|
const max = Number(flags.max ?? 16);
|
|
2422
3011
|
if (!Number.isInteger(max) || max <= 0)
|
|
@@ -3257,7 +3846,7 @@ async function loadEffects() {
|
|
|
3257
3846
|
*/
|
|
3258
3847
|
async function cmdRender(address, tokenIds, flags) {
|
|
3259
3848
|
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');
|
|
3849
|
+
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
3850
|
process.exit(1);
|
|
3262
3851
|
}
|
|
3263
3852
|
// Token ids are decimal (mint order). Keep only those — `parseFlags` leaves `--flag value` pairs
|
|
@@ -3294,12 +3883,12 @@ async function cmdRender(address, tokenIds, flags) {
|
|
|
3294
3883
|
ok(line);
|
|
3295
3884
|
return;
|
|
3296
3885
|
}
|
|
3297
|
-
// --remote publishes each render to the
|
|
3886
|
+
// --remote publishes each render to the remote resolver's control plane (the locator bridge) so a
|
|
3298
3887
|
// laptop render lands on a resolver that doesn't share this disk; republish=true makes a re-run
|
|
3299
3888
|
// 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 ?
|
|
3889
|
+
const remote = remoteFlag(flags);
|
|
3890
|
+
const resolverUrl = (remote?.url ?? process.env.ABX_RESOLVER_URL ?? resolveBaseUrl()).replace(/\/$/, '');
|
|
3891
|
+
const adminToken = remote ? requireRemoteToken(remote) : undefined;
|
|
3303
3892
|
// Co-located (no --remote): record each declared output into the shared store's artifact
|
|
3304
3893
|
// registry so the local resolver's `artifacts` manifest enumerates it. Remote: the publish
|
|
3305
3894
|
// lane (adminToken) records rows on the hosted resolver instead.
|
|
@@ -3334,8 +3923,11 @@ async function cmdRender(address, tokenIds, flags) {
|
|
|
3334
3923
|
* single time and exits (vs `abx render <addr>` which is the per-project repair lane).
|
|
3335
3924
|
*/
|
|
3336
3925
|
async function cmdEffects(flags) {
|
|
3337
|
-
const
|
|
3338
|
-
const
|
|
3926
|
+
const effectsRemote = remoteFlag(flags);
|
|
3927
|
+
const resolverUrl = (effectsRemote?.url ?? process.env.ABX_RESOLVER_URL ?? resolveBaseUrl()).replace(/\/$/, '');
|
|
3928
|
+
// Token optional BY DESIGN: no token = the co-located topology (shared store, no publish lane).
|
|
3929
|
+
// A named remote brings its own token; otherwise the self-host env token.
|
|
3930
|
+
const adminToken = effectsRemote?.token ?? process.env.ABX_RESOLVER_ADMIN_TOKEN;
|
|
3339
3931
|
// Co-located (no admin token — we share the resolver's store): record each declared output into
|
|
3340
3932
|
// the shared artifact registry so the resolver's `artifacts` manifest enumerates it. With an
|
|
3341
3933
|
// admin token, the publish lane records rows on the hosted resolver instead.
|
|
@@ -3375,16 +3967,23 @@ async function cmdEffects(flags) {
|
|
|
3375
3967
|
await new Promise(() => { }); // block like `serve`
|
|
3376
3968
|
}
|
|
3377
3969
|
async function cmdIndex(address, flags) {
|
|
3378
|
-
const remote =
|
|
3970
|
+
const remote = remoteFlag(flags);
|
|
3379
3971
|
if (remote) {
|
|
3380
3972
|
if (!address || address.startsWith('--')) {
|
|
3381
|
-
console.error('usage: abx index <address> --remote [url] (re-index one project on a
|
|
3973
|
+
console.error('usage: abx index <address> --remote [name|url] (re-index one project on a remote resolver)\n');
|
|
3382
3974
|
process.exit(1);
|
|
3383
3975
|
}
|
|
3384
|
-
|
|
3385
|
-
info(`${bold('REMOTE')} → ${remote} ${dim('(re-indexing on the
|
|
3386
|
-
const
|
|
3387
|
-
|
|
3976
|
+
requireRemoteToken(remote);
|
|
3977
|
+
info(`${bold('REMOTE')} → ${remote.url} ${dim('(re-indexing on the remote resolver — the post-deploy nudge)')}`);
|
|
3978
|
+
const chainId = resolveChain(CHAIN).id;
|
|
3979
|
+
let r;
|
|
3980
|
+
try {
|
|
3981
|
+
r = await serviceClient(remote).registerProject({ chainId, address, full: flags.full ? true : undefined });
|
|
3982
|
+
}
|
|
3983
|
+
catch (err) {
|
|
3984
|
+
throw describeRemoteError(err, remote, 'remote index');
|
|
3985
|
+
}
|
|
3986
|
+
await reportRemoteIndexing(remote, chainId, address, r, flags, 're-indexed');
|
|
3388
3987
|
return;
|
|
3389
3988
|
}
|
|
3390
3989
|
allowLargeScan(flags);
|
|
@@ -3414,7 +4013,7 @@ async function cmdVerify(address, flags) {
|
|
|
3414
4013
|
process.exit(1);
|
|
3415
4014
|
}
|
|
3416
4015
|
allowLargeScan(flags);
|
|
3417
|
-
const remote =
|
|
4016
|
+
const remote = remoteFlag(flags);
|
|
3418
4017
|
if (remote)
|
|
3419
4018
|
return cmdVerifyRemote(address, remote);
|
|
3420
4019
|
const indexer = new SelfHostIndexer();
|
|
@@ -3428,7 +4027,11 @@ async function cmdVerify(address, flags) {
|
|
|
3428
4027
|
if (!state)
|
|
3429
4028
|
throw new Error(`could not load state for ${address}`);
|
|
3430
4029
|
console.log(bold(`\n verify ${state.name ?? address}`));
|
|
3431
|
-
|
|
4030
|
+
// isCanonical is a TRUE TRI-STATE (true | false | null) and collapsing it lost the only
|
|
4031
|
+
// distinction that matters: "the chain says this is NOT a clone of the configured factory" is a
|
|
4032
|
+
// trust finding; "we couldn't run the check" (no factory configured, or none deployed on this
|
|
4033
|
+
// chain) is an environment note. Two reviewers read the collapsed word as a second failure.
|
|
4034
|
+
info(`canonical: ${canonicalLabel(state.isCanonical)} · owner ${state.owner ?? '—'}`);
|
|
3432
4035
|
const storage = resolveBackend(storageOptions());
|
|
3433
4036
|
const result = await verifyProject(state, storage);
|
|
3434
4037
|
const tokens = result.tokens ?? [];
|
|
@@ -3558,18 +4161,33 @@ async function cmdVerify(address, flags) {
|
|
|
3558
4161
|
console.log(`\n ${dim('live view animates regardless; the placeholder only affects the static marketplace thumbnail.')}\n`);
|
|
3559
4162
|
else
|
|
3560
4163
|
console.log(anyCheck && allGood ? `\n ${g('✓ verified')} — the node serves exactly what the chain commits to.\n` : '\n');
|
|
4164
|
+
// A byte-vs-chain mismatch is an integrity FAILURE, so fail the command. Verify's whole job is to
|
|
4165
|
+
// answer "is what's served what the chain vouches for" — exiting 0 while printing ✗ meant nothing
|
|
4166
|
+
// could gate on it (a script or CI would sail past a corrupted image). Deliberately narrow: a
|
|
4167
|
+
// missing render / placeholder is a normal, expected state and still exits 0.
|
|
4168
|
+
if (anyCheck && !allGood)
|
|
4169
|
+
process.exitCode = 1;
|
|
3561
4170
|
}
|
|
3562
4171
|
// `abx verify <addr> --remote <url>` — verify what a HOSTED resolver actually serves (the local
|
|
3563
4172
|
// `verify` checks THIS machine's store/backend, the wrong store for a hosted drop). Probes the real
|
|
3564
4173
|
// `/…/image` route, so it accounts for the locator bridge (a 302 to ipfs/ar) exactly as a marketplace
|
|
3565
4174
|
// sees it — the truthful "did the thumbnail land?" check after a remote render.
|
|
3566
|
-
async function cmdVerifyRemote(address,
|
|
3567
|
-
const base =
|
|
4175
|
+
async function cmdVerifyRemote(address, remote) {
|
|
4176
|
+
const base = remote.url.replace(/\/$/, '');
|
|
3568
4177
|
const chainId = resolveChain(CHAIN).id;
|
|
3569
4178
|
console.log(bold(`\n verify ${address} ${dim(`(remote → ${base})`)}`));
|
|
3570
|
-
|
|
4179
|
+
// A DOWN endpoint and a wrong-address endpoint are different problems with different fixes, and a
|
|
4180
|
+
// bare `fetch failed` says neither. `abx status --remote` already gets this right — match it, or the
|
|
4181
|
+
// two commands disagree about the same condition (a reviewer's top misdiagnosis risk).
|
|
4182
|
+
let stateRes;
|
|
4183
|
+
try {
|
|
4184
|
+
stateRes = await fetch(`${base}/api/project/${address}`);
|
|
4185
|
+
}
|
|
4186
|
+
catch {
|
|
4187
|
+
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?`);
|
|
4188
|
+
}
|
|
3571
4189
|
if (!stateRes.ok) {
|
|
3572
|
-
throw new Error(`resolver ${base} doesn't serve ${address} (HTTP ${stateRes.status}) — register it first: abx add ${address} --remote ${base}`);
|
|
4190
|
+
throw new Error(`resolver ${base} doesn't serve ${address} (HTTP ${stateRes.status}) — register it first: abx add ${address} --remote ${remote.name?.toLowerCase() ?? base}`);
|
|
3573
4191
|
}
|
|
3574
4192
|
const state = (await stateRes.json());
|
|
3575
4193
|
info(`serving as "${state.name ?? address}"`);
|
|
@@ -3615,10 +4233,24 @@ async function cmdVerifyRemote(address, resolver) {
|
|
|
3615
4233
|
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
4234
|
}
|
|
3617
4235
|
const { upToDate, stale, rendering, failed } = report.counts;
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
4236
|
+
// RENDERS ONLY — say so. This report answers "is there a current render for each token", never
|
|
4237
|
+
// "do the served bytes match the on-chain commitment"; those are different questions and this
|
|
4238
|
+
// command promises the second one too. A project with no renders at all (a 1/1, an image Series)
|
|
4239
|
+
// has nothing to be "up to date" ABOUT, so don't print a 0/N fraction — but don't let a green ✓
|
|
4240
|
+
// here read as "the image is verified" either. Byte integrity comes from the check below.
|
|
4241
|
+
if (report.tokens.length === 0) {
|
|
4242
|
+
info(`renders ${dim('none for this project (a static image needs no off-chain render)')}`);
|
|
4243
|
+
}
|
|
4244
|
+
else {
|
|
4245
|
+
// Lead with the count that carries the polarity: "N of M current" never inverts on a skim the
|
|
4246
|
+
// way "0/M up to date" does.
|
|
4247
|
+
const summary = `${upToDate} of ${minted.length} token(s) current${rendering ? ` · ${rendering} rendering` : ''}${stale ? ` · ${stale} stale` : ''}${failed ? ` · ${failed} FAILED` : ''}`;
|
|
4248
|
+
if (failed || stale)
|
|
4249
|
+
console.log(` ${c.orange}⚠${c.reset} renders: ${summary} ${dim('— live view animates regardless; only the static thumbnail is affected.')}`);
|
|
4250
|
+
else
|
|
4251
|
+
ok(`renders: ${summary}`);
|
|
4252
|
+
}
|
|
4253
|
+
await reportRemoteByteIntegrity(address, remote, base);
|
|
3622
4254
|
return;
|
|
3623
4255
|
}
|
|
3624
4256
|
let gap = false;
|
|
@@ -3636,8 +4268,63 @@ async function cmdVerifyRemote(address, resolver) {
|
|
|
3636
4268
|
}
|
|
3637
4269
|
}
|
|
3638
4270
|
console.log(gap
|
|
3639
|
-
?
|
|
3640
|
-
:
|
|
4271
|
+
? ` ${dim('live view animates regardless; the placeholder only affects the static marketplace thumbnail.')}`
|
|
4272
|
+
: ` ${g('✓ thumbnails are real renders')} ${dim('— served straight from the resolver.')}`);
|
|
4273
|
+
await reportRemoteByteIntegrity(address, remote, base);
|
|
4274
|
+
}
|
|
4275
|
+
/**
|
|
4276
|
+
* The half `abx verify --remote` was missing: do the served BYTES still hash to the on-chain
|
|
4277
|
+
* commitment? Everything above it checks renders (is a thumbnail current, is it a placeholder) — a
|
|
4278
|
+
* different question, and a green ✓ there was reading as "the image is correct" when the bytes could
|
|
4279
|
+
* genuinely mismatch.
|
|
4280
|
+
*
|
|
4281
|
+
* The service is the right place to answer it: it holds both the bytes and the chain, and it already
|
|
4282
|
+
* exposes exactly this check (`GET /api/project/:addr/verify`, bearer-gated because it triggers
|
|
4283
|
+
* outbound fetches). When we can't reach that — no credential, or an older node — say plainly that
|
|
4284
|
+
* byte integrity was NOT checked rather than leaving the ✓ above to imply it was.
|
|
4285
|
+
*/
|
|
4286
|
+
async function reportRemoteByteIntegrity(address, remote, base) {
|
|
4287
|
+
if (!remote.token) {
|
|
4288
|
+
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.`);
|
|
4289
|
+
return;
|
|
4290
|
+
}
|
|
4291
|
+
let report;
|
|
4292
|
+
try {
|
|
4293
|
+
const res = await fetch(`${base}/api/project/${address}/verify`, { headers: { authorization: `Bearer ${remote.token}` } });
|
|
4294
|
+
if (res.status === 401 || res.status === 403) {
|
|
4295
|
+
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).`);
|
|
4296
|
+
return;
|
|
4297
|
+
}
|
|
4298
|
+
if (!res.ok) {
|
|
4299
|
+
// Spec'd as part of `abx-token-api/v1`, so a conforming service has it — but say it neutrally:
|
|
4300
|
+
// this is equally "an older self-hosted node" and "a provider that didn't implement it".
|
|
4301
|
+
warn(`byte integrity NOT checked — ${base} serves no /api/project/…/verify route (HTTP ${res.status}). ` +
|
|
4302
|
+
`Your own node? Redeploy it (\`abx deploy-resolver\`). A provider's? It's part of abx-token-api/v1 — ask them for it. ` +
|
|
4303
|
+
`Meanwhile ${bold(`abx verify ${address}`)} checks the bytes on a node that holds them.`);
|
|
4304
|
+
return;
|
|
4305
|
+
}
|
|
4306
|
+
report = (await res.json());
|
|
4307
|
+
}
|
|
4308
|
+
catch {
|
|
4309
|
+
warn(`byte integrity NOT checked — couldn't reach ${base}'s verify route.`);
|
|
4310
|
+
return;
|
|
4311
|
+
}
|
|
4312
|
+
const checked = (report.tokens ?? []).filter((t) => t.checks.length > 0);
|
|
4313
|
+
if (checked.length === 0) {
|
|
4314
|
+
info(`bytes ${dim('no on-chain byte commitment to check (this project commits no image hash)')}`);
|
|
4315
|
+
return;
|
|
4316
|
+
}
|
|
4317
|
+
const bad = checked.filter((t) => t.checks.some((k) => !k.verified));
|
|
4318
|
+
if (bad.length === 0) {
|
|
4319
|
+
ok(`bytes: ${checked.length} token(s) hash-match their on-chain commitment ${dim('(what the service serves IS what the chain vouches for)')}`);
|
|
4320
|
+
return;
|
|
4321
|
+
}
|
|
4322
|
+
const spec = remote.name?.toLowerCase() ?? base;
|
|
4323
|
+
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 ` +
|
|
4324
|
+
`${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:`);
|
|
4325
|
+
info(`durable bytes exist (ipfs://, ar://) but weren't bridged → ${bold(`abx add ${address} --remote ${spec}`)} forwards the locator, then re-run this.`);
|
|
4326
|
+
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.`);
|
|
4327
|
+
process.exitCode = 1; // same rule as the local lane: an integrity mismatch fails the command
|
|
3641
4328
|
}
|
|
3642
4329
|
// ── tokenuri ─────────────────────────────────────────────────────────────────
|
|
3643
4330
|
// Read tokenURI(id) STRAIGHT FROM THE CONTRACT on-chain (no server, no node) and decode
|
|
@@ -3688,7 +4375,12 @@ async function cmdTokenUri(address, flags) {
|
|
|
3688
4375
|
catch {
|
|
3689
4376
|
const code = await publicClient.getCode({ address }).catch(() => undefined);
|
|
3690
4377
|
if (!code || code === '0x') {
|
|
3691
|
-
|
|
4378
|
+
// Name the endpoint we actually asked. "No contract here" is indistinguishable from "you're
|
|
4379
|
+
// pointed at the wrong node", and a chain KEY doesn't disambiguate that — two endpoints can
|
|
4380
|
+
// both claim `sepolia` (a fork, a stale duplicate .env line) and only one has your contract.
|
|
4381
|
+
console.error(`abx tokenuri: no contract at ${address} on ${CHAIN} (asked ${redactRpcUrl(resolveRpcUrl(CHAIN))}) — ` +
|
|
4382
|
+
`double-check the address, and that this endpoint is the network you deployed to. ` +
|
|
4383
|
+
`If you JUST deployed, give the tx a block or two to mine.\n`);
|
|
3692
4384
|
}
|
|
3693
4385
|
else {
|
|
3694
4386
|
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`);
|
|
@@ -4007,9 +4699,53 @@ async function cmdState(address, flags) {
|
|
|
4007
4699
|
info(`renderer ${renderer && renderer !== zeroAddress ? `on-chain (${renderer})` : dim('off-chain (stored URI base / override)')}`);
|
|
4008
4700
|
console.log('');
|
|
4009
4701
|
}
|
|
4010
|
-
|
|
4702
|
+
/**
|
|
4703
|
+
* `abx status [address] [--remote [name|url]] [--watch]` — INDEXING status: where a project sits in
|
|
4704
|
+
* the lifecycle (`queued | backfilling | live | stale | failed`) and how far behind head it is.
|
|
4705
|
+
*
|
|
4706
|
+
* One vocabulary for both sides of the membrane, which is the point: bare = this node, `--remote` =
|
|
4707
|
+
* ask the service, and the same five words either way. Distinct from `abx state <address>`, which
|
|
4708
|
+
* reads the CHAIN (owner, royalty, locks) and knows nothing about who is serving it.
|
|
4709
|
+
*/
|
|
4710
|
+
async function cmdStatus(address, flags) {
|
|
4711
|
+
// `abx status --remote <name>` has no address — without this guard the flag itself lands in
|
|
4712
|
+
// rest[0] and gets sent as the address path segment (the same guard every other command applies).
|
|
4713
|
+
if (address?.startsWith('--'))
|
|
4714
|
+
address = undefined;
|
|
4715
|
+
const remote = remoteFlag(flags);
|
|
4716
|
+
if (remote)
|
|
4717
|
+
return cmdStatusRemote(address, remote, flags);
|
|
4011
4718
|
const indexer = new SelfHostIndexer();
|
|
4012
4719
|
const regs = indexer.store.listRegistrations();
|
|
4720
|
+
if (address) {
|
|
4721
|
+
const reg = indexer.store.getRegistration(address);
|
|
4722
|
+
if (!reg)
|
|
4723
|
+
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>\``);
|
|
4724
|
+
const s = indexer.getProject(address);
|
|
4725
|
+
const row = indexer.indexStatus(address);
|
|
4726
|
+
const head = indexer.store.getMeta(`watch:${reg.chainKey}:head`);
|
|
4727
|
+
// Don't print the address twice when there's no name to lead with.
|
|
4728
|
+
console.log(s?.name ? `\n ${bold(s.name)} ${dim(address)}` : `\n ${bold(address)}`);
|
|
4729
|
+
statusRow('status', statusLine({
|
|
4730
|
+
chainId: resolveChain(reg.chainKey).id,
|
|
4731
|
+
address,
|
|
4732
|
+
status: row.status,
|
|
4733
|
+
fromBlock: reg.fromBlock,
|
|
4734
|
+
toBlock: s?.toBlock ?? null,
|
|
4735
|
+
headBlock: head,
|
|
4736
|
+
eventCount: s?.eventCount ?? 0,
|
|
4737
|
+
tokenCount: s?.tokens.length ?? 0,
|
|
4738
|
+
mintedCount: 0,
|
|
4739
|
+
reconstructedAt: s?.reconstructedAt ?? null,
|
|
4740
|
+
...(row.errorClass ? { error: { class: row.errorClass, message: row.errorMessage ?? undefined } } : {}),
|
|
4741
|
+
}));
|
|
4742
|
+
statusRow('floor', `${reg.fromBlock}${row.attempts ? dim(` attempts ${row.attempts}`) : ''}`);
|
|
4743
|
+
statusRow('indexed', s ? `${s.eventCount} events · ${s.tokens.length} token(s) ${dim(`· ${s.reconstructedAt}`)}` : dim('no projection yet'));
|
|
4744
|
+
if (!head)
|
|
4745
|
+
statusRow('head', dim("unknown — this node isn't watching the chain (ABX_WATCH_INTERVAL_MS=0, or `abx serve` isn't running)"));
|
|
4746
|
+
console.log('');
|
|
4747
|
+
return;
|
|
4748
|
+
}
|
|
4013
4749
|
console.log(bold(`\n ABX self-host node`));
|
|
4014
4750
|
info(`chain: ${CHAIN} · factory: ${factoryAddress() ?? 'none'} · storage: ${activeBackendId()} · data: ${indexer.store.path}`);
|
|
4015
4751
|
if (regs.length === 0) {
|
|
@@ -4019,27 +4755,114 @@ async function cmdStatus() {
|
|
|
4019
4755
|
console.log('');
|
|
4020
4756
|
for (const reg of regs) {
|
|
4021
4757
|
const s = indexer.getProject(reg.address);
|
|
4022
|
-
const
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
console.log(
|
|
4758
|
+
const st = indexer.indexStatus(reg.address).status;
|
|
4759
|
+
const mark = st === 'failed' || st === 'stale' ? `${c.orange}●${c.reset}` : s ? g('●') : dim('○');
|
|
4760
|
+
const tail = s ? `${s.eventCount} events` : dim('(registered, not indexed)');
|
|
4761
|
+
console.log(` ${mark} ${s?.name ?? reg.address} ${dim(reg.address)} ${tail} ${statusLabel(st)}`);
|
|
4026
4762
|
}
|
|
4027
|
-
console.log(
|
|
4763
|
+
console.log(dim(`\n one project in detail: abx status <address>\n`));
|
|
4764
|
+
}
|
|
4765
|
+
/** The `--remote` half of {@link cmdStatus}: one project, or the roll-up for every project the token
|
|
4766
|
+
* can see. `--watch` tails until everything reaches a terminal state. */
|
|
4767
|
+
async function cmdStatusRemote(address, remote, flags) {
|
|
4768
|
+
requireRemoteToken(remote);
|
|
4769
|
+
const client = serviceClient(remote);
|
|
4770
|
+
const chainId = resolveChain(CHAIN).id;
|
|
4771
|
+
const watch = flags.watch !== undefined;
|
|
4772
|
+
const spec = remote.name ? remote.name.toLowerCase() : remote.source === 'default' ? '' : remote.url;
|
|
4773
|
+
info(`${bold('REMOTE')} → ${remote.url} ${dim('(indexing status as the service reports it)')}`);
|
|
4774
|
+
if (address) {
|
|
4775
|
+
for (;;) {
|
|
4776
|
+
let s;
|
|
4777
|
+
try {
|
|
4778
|
+
s = await client.projectStatus(chainId, address);
|
|
4779
|
+
}
|
|
4780
|
+
catch (err) {
|
|
4781
|
+
throw describeRemoteError(err, remote, 'remote status');
|
|
4782
|
+
}
|
|
4783
|
+
console.log(`\n ${bold(address)}`);
|
|
4784
|
+
statusRow('status', statusLine(s));
|
|
4785
|
+
statusRow('floor', `${s.fromBlock}${s.attempts ? dim(` attempts ${s.attempts}`) : ''}`);
|
|
4786
|
+
statusRow('indexed', `${s.eventCount} events · ${s.tokenCount} token(s)${s.reconstructedAt ? dim(` · ${s.reconstructedAt}`) : ''}`);
|
|
4787
|
+
// Spell out what `watching` MEANS on the line itself — a bare `no` sent a reviewer to a
|
|
4788
|
+
// different command's output to find out whether it was a problem (it usually isn't).
|
|
4789
|
+
if (s.watcher) {
|
|
4790
|
+
statusRow('watching', s.watcher.watching
|
|
4791
|
+
? `${g('yes')}${s.watcher.head ? dim(` · head ${s.watcher.head}`) : ''}${dim(' — it tails new blocks, so on-chain changes land on their own')}`
|
|
4792
|
+
: `${dim('no')} ${dim('— this service updates on an explicit add/index, not by tailing new blocks (normal for many providers)')}`);
|
|
4793
|
+
}
|
|
4794
|
+
// Don't leave a creator staring at a red word with no next move. `failed` especially reads as
|
|
4795
|
+
// terminal when it isn't — name what it means and the one command that follows it.
|
|
4796
|
+
if (!watch && s.status !== 'live') {
|
|
4797
|
+
if (s.status === 'failed' && s.error)
|
|
4798
|
+
statusRow('what now', indexErrorAction(s.error.class));
|
|
4799
|
+
statusRow('follow', dim(`this is not final — ${bold(`abx status ${address} --remote${spec ? ` ${spec}` : ''} --watch`)} tails it until it settles`));
|
|
4800
|
+
}
|
|
4801
|
+
console.log('');
|
|
4802
|
+
if (!watch || s.status === 'live' || s.status === 'failed')
|
|
4803
|
+
return;
|
|
4804
|
+
await sleep(3000);
|
|
4805
|
+
}
|
|
4806
|
+
}
|
|
4807
|
+
for (;;) {
|
|
4808
|
+
let projects;
|
|
4809
|
+
try {
|
|
4810
|
+
projects = await client.listProjects();
|
|
4811
|
+
}
|
|
4812
|
+
catch (err) {
|
|
4813
|
+
throw describeRemoteError(err, remote, 'remote status');
|
|
4814
|
+
}
|
|
4815
|
+
if (projects.length === 0) {
|
|
4816
|
+
console.log(dim('\n no projects visible to this token\n'));
|
|
4817
|
+
return;
|
|
4818
|
+
}
|
|
4819
|
+
console.log('');
|
|
4820
|
+
for (const p of projects) {
|
|
4821
|
+
console.log(` ${g('●')} ${p.name ?? p.label ?? p.address} ${dim(p.address)} ` +
|
|
4822
|
+
`${p.status ? statusLabel(p.status) : dim('status not reported')}` +
|
|
4823
|
+
`${p.error ? ` ${c.orange}${p.error.class}${c.reset}` : ''} ${dim(`${p.tokenCount ?? '?'} token(s)`)}`);
|
|
4824
|
+
}
|
|
4825
|
+
console.log(`\n ${rollUp(projects)}\n`);
|
|
4826
|
+
const settled = projects.every((p) => !p.status || p.status === 'live' || p.status === 'failed');
|
|
4827
|
+
if (!watch || settled)
|
|
4828
|
+
return;
|
|
4829
|
+
await sleep(3000);
|
|
4830
|
+
}
|
|
4831
|
+
}
|
|
4832
|
+
/** "3 live, 1 backfilling, 1 failed (rpc_rate_limited)" — the one-line answer to "is my stuff ok?" */
|
|
4833
|
+
function rollUp(projects) {
|
|
4834
|
+
const counts = new Map();
|
|
4835
|
+
for (const p of projects) {
|
|
4836
|
+
const k = p.status ?? 'unknown';
|
|
4837
|
+
counts.set(k, (counts.get(k) ?? 0) + 1);
|
|
4838
|
+
}
|
|
4839
|
+
const classes = [...new Set(projects.filter((p) => p.error).map((p) => p.error.class))];
|
|
4840
|
+
const parts = [...counts.entries()].map(([k, n]) => `${n} ${k}`);
|
|
4841
|
+
return `${parts.join(', ')}${classes.length ? ` ${dim(`(${classes.join(', ')})`)}` : ''}`;
|
|
4028
4842
|
}
|
|
4029
4843
|
// ── forget ────────────────────────────────────────────────────────────────--
|
|
4030
4844
|
// Drop a project this node tracks (registration + projection) — for cleaning up
|
|
4031
4845
|
// test/junk deploys. On-chain data is untouched; `abx add` can re-register it.
|
|
4032
4846
|
async function cmdForget(address, flags) {
|
|
4033
4847
|
if (!address || address.startsWith('--')) {
|
|
4034
|
-
console.error('usage: abx forget <address> [--remote [url]]\n');
|
|
4848
|
+
console.error('usage: abx forget <address> [--remote [name|url]]\n');
|
|
4035
4849
|
process.exit(1);
|
|
4036
4850
|
}
|
|
4037
|
-
const remote =
|
|
4851
|
+
const remote = remoteFlag(flags);
|
|
4038
4852
|
if (remote) {
|
|
4039
|
-
|
|
4040
|
-
info(`${bold('REMOTE')} → ${remote} ${dim('(deregistering on the
|
|
4041
|
-
|
|
4042
|
-
|
|
4853
|
+
requireRemoteToken(remote);
|
|
4854
|
+
info(`${bold('REMOTE')} → ${remote.url} ${dim('(deregistering on the remote resolver — NOT this machine)')}`);
|
|
4855
|
+
let removed;
|
|
4856
|
+
try {
|
|
4857
|
+
({ removed } = await serviceClient(remote).removeProject(resolveChain(CHAIN).id, address));
|
|
4858
|
+
}
|
|
4859
|
+
catch (err) {
|
|
4860
|
+
throw describeRemoteError(err, remote, 'remote forget');
|
|
4861
|
+
}
|
|
4862
|
+
if (removed)
|
|
4863
|
+
ok(`remote resolver forgot ${address} — it will stop serving it. On-chain data is untouched.`);
|
|
4864
|
+
else
|
|
4865
|
+
console.log(dim(` ${address} wasn't registered on ${remote.url} — nothing to forget.`));
|
|
4043
4866
|
return;
|
|
4044
4867
|
}
|
|
4045
4868
|
const indexer = new SelfHostIndexer();
|
|
@@ -4050,6 +4873,97 @@ async function cmdForget(address, flags) {
|
|
|
4050
4873
|
indexer.store.deregister(address);
|
|
4051
4874
|
ok(`forgot ${address} — dropped its registration + projection. On-chain data is untouched.`);
|
|
4052
4875
|
}
|
|
4876
|
+
// ── remote ────────────────────────────────────────────────────────────────--
|
|
4877
|
+
// Inspect a remote service. Bare `abx remote` lists the named remotes configured in .env plus the
|
|
4878
|
+
// self-host default pair (URLs + whether a token is set — never the secret itself). With a target,
|
|
4879
|
+
// fetches its PUBLIC service descriptor (what it serves: interfaces, chains, auth, managed
|
|
4880
|
+
// rendering) and — when a token resolves — lists the projects visible to that token, which makes
|
|
4881
|
+
// this the one-command "is my provider key valid?" check. Read-only; registers nothing.
|
|
4882
|
+
async function cmdRemote(spec, flags) {
|
|
4883
|
+
if (!spec || spec.startsWith('--')) {
|
|
4884
|
+
const remotes = listConfiguredRemotes();
|
|
4885
|
+
console.log(`\n ${bold('named remotes')} ${dim('(ABX_REMOTE_<NAME>_URL/_TOKEN in .env — inspect one: abx remote <name>)')}`);
|
|
4886
|
+
if (remotes.length === 0)
|
|
4887
|
+
console.log(dim(' none configured'));
|
|
4888
|
+
for (const r of remotes) {
|
|
4889
|
+
console.log(` ${g('●')} ${r.name.toLowerCase()} ${dim(r.url)} ${r.hasToken ? g('token set') : dim('no token')}`);
|
|
4890
|
+
}
|
|
4891
|
+
// A near-miss var reads as "no token" while the value is sitting in .env under the wrong name.
|
|
4892
|
+
for (const bad of misnamedRemoteVars()) {
|
|
4893
|
+
warn(`${bad.key} isn't a recognized remote var — the convention is ${bold(bad.suggestion)} (only _URL and _TOKEN are read).`);
|
|
4894
|
+
}
|
|
4895
|
+
const def = process.env.ABX_PUBLIC_BASE_URL ?? process.env.ABX_RESOLVER_URL;
|
|
4896
|
+
console.log(`\n ${bold('self-host default')} ${dim('(bare --remote)')}`);
|
|
4897
|
+
console.log(def
|
|
4898
|
+
? ` ${g('●')} ${def} ${process.env.ABX_RESOLVER_ADMIN_TOKEN ? g('token set') : dim('no ABX_RESOLVER_ADMIN_TOKEN')}`
|
|
4899
|
+
: dim(' none (set ABX_PUBLIC_BASE_URL in .env)'));
|
|
4900
|
+
console.log('');
|
|
4901
|
+
return;
|
|
4902
|
+
}
|
|
4903
|
+
const target = resolveRemote(spec, flags['remote-token']);
|
|
4904
|
+
if (!target)
|
|
4905
|
+
return;
|
|
4906
|
+
const client = serviceClient(target);
|
|
4907
|
+
console.log(`\n ${bold(target.name ? `remote ${target.name.toLowerCase()}` : 'remote')} ${dim(`→ ${target.url}`)}`);
|
|
4908
|
+
let d;
|
|
4909
|
+
try {
|
|
4910
|
+
d = await client.descriptor();
|
|
4911
|
+
}
|
|
4912
|
+
catch (err) {
|
|
4913
|
+
// Two very different situations, and the fix differs — so don't nest the raw client error
|
|
4914
|
+
// (it repeats the URL and leaks `GET`/`fetch failed` at a creator).
|
|
4915
|
+
const status = err instanceof AbxServiceError ? err.status : -1;
|
|
4916
|
+
if (status === 0) {
|
|
4917
|
+
throw new Error(`nothing responded at ${target.url} — check the address. A provider gives you an https:// base ` +
|
|
4918
|
+
`(e.g. https://meta.provider.xyz); if it's your own node, is it running?`);
|
|
4919
|
+
}
|
|
4920
|
+
throw new Error(`${target.url} answered, but serves no ABX service descriptor at /.well-known/abx-service. ` +
|
|
4921
|
+
`That's either an older self-hosted node (fine if it's yours — the remote commands still work against it) ` +
|
|
4922
|
+
`or not an ABX service at all. Verify the URL before registering anything with it.`);
|
|
4923
|
+
}
|
|
4924
|
+
info(`service ${d.service?.name ?? '—'} ${dim(d.service?.version ?? '')}`);
|
|
4925
|
+
info(`serves ${(d.interfaces ?? []).join(' · ') || '—'}`);
|
|
4926
|
+
const chainId = resolveChain(CHAIN).id;
|
|
4927
|
+
const coversChain = (d.chains ?? []).includes(chainId);
|
|
4928
|
+
info(`chains ${(d.chains ?? []).join(', ') || '—'} ${coversChain ? g(`✓ covers ${CHAIN} (${chainId})`) : `${c.orange}⚠${c.reset} does NOT cover ${CHAIN} (${chainId}) — registrations will be refused`}`);
|
|
4929
|
+
if (d.render?.attached) {
|
|
4930
|
+
const outputs = d.render.effects?.flatMap((e) => e.outputs.map((o) => `${e.key}/${o.key}`)).join(', ');
|
|
4931
|
+
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`);
|
|
4932
|
+
}
|
|
4933
|
+
if (d.auth) {
|
|
4934
|
+
info(`auth bearer${d.auth.signupUrl ? ` · get a key: ${d.auth.signupUrl}` : ''}${d.auth.docsUrl ? ` · docs: ${d.auth.docsUrl}` : ''}`);
|
|
4935
|
+
}
|
|
4936
|
+
else {
|
|
4937
|
+
info(`auth none advertised ${dim('(control plane disabled on this node)')}`);
|
|
4938
|
+
}
|
|
4939
|
+
if (!target.token) {
|
|
4940
|
+
// Same near-miss check the register path does — this is where someone lands FIRST when their
|
|
4941
|
+
// credential is set under a name the CLI doesn't read, so the hint has to be here too.
|
|
4942
|
+
const nearMiss = misnamedRemoteVars().find((v) => v.suggestion === target.tokenVar);
|
|
4943
|
+
if (nearMiss)
|
|
4944
|
+
warn(`${nearMiss.key} is set but is NOT read — the convention is ${bold(target.tokenVar)} (only _URL and _TOKEN). Rename it and re-run.`);
|
|
4945
|
+
else
|
|
4946
|
+
info(dim(`no token resolved (set ${target.tokenVar} or pass --remote-token) — descriptor only; can't list your projects.`));
|
|
4947
|
+
console.log('');
|
|
4948
|
+
return;
|
|
4949
|
+
}
|
|
4950
|
+
try {
|
|
4951
|
+
const projects = await client.listProjects();
|
|
4952
|
+
ok(`token accepted — ${projects.length} project(s) visible to it`);
|
|
4953
|
+
for (const p of projects.slice(0, 10)) {
|
|
4954
|
+
console.log(` ${g('●')} ${p.name ?? p.label ?? p.address} ${dim(`${p.address} · ${p.tokenCount ?? '?'} token(s)`)}` +
|
|
4955
|
+
`${p.status ? ` ${statusLabel(p.status)}` : ''}${p.error ? ` ${c.orange}${p.error.class}${c.reset}` : ''}`);
|
|
4956
|
+
}
|
|
4957
|
+
if (projects.length > 10)
|
|
4958
|
+
console.log(dim(` … and ${projects.length - 10} more`));
|
|
4959
|
+
if (projects.some((p) => p.status))
|
|
4960
|
+
info(rollUp(projects) + dim(' — one project in detail: abx status <address> --remote ' + (target.name?.toLowerCase() ?? target.url)));
|
|
4961
|
+
}
|
|
4962
|
+
catch (err) {
|
|
4963
|
+
throw describeRemoteError(err, target, 'remote list');
|
|
4964
|
+
}
|
|
4965
|
+
console.log('');
|
|
4966
|
+
}
|
|
4053
4967
|
// ── migrate ───────────────────────────────────────────────────────────────--
|
|
4054
4968
|
// Move a contract's OFF-CHAIN operator state from one resolver to another (e.g. fly.io →
|
|
4055
4969
|
// a droplet). The destination replays all ON-CHAIN state from chain itself; this bridges
|
|
@@ -4058,13 +4972,17 @@ async function cmdForget(address, flags) {
|
|
|
4058
4972
|
// and write through the admin control plane. It does NOT cut over — after a clean migration the
|
|
4059
4973
|
// operator re-points DNS (custom domain) or the on-chain base URI (provider endpoint).
|
|
4060
4974
|
async function cmdMigrate(address, flags) {
|
|
4061
|
-
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
|
|
4975
|
+
// Both sides accept a named remote or a URL. Only the DESTINATION needs a credential — the
|
|
4976
|
+
// source is read via its PUBLIC api (the exit ramp works with zero provider cooperation).
|
|
4977
|
+
const fromTarget = typeof flags.from === 'string' ? resolveRemote(flags.from) : null;
|
|
4978
|
+
const toTarget = typeof flags.to === 'string' ? resolveRemote(flags.to, flags['remote-token']) : null;
|
|
4979
|
+
if (!address || address.startsWith('--') || !fromTarget || !toTarget) {
|
|
4980
|
+
console.error('usage: abx migrate <address> --from <source-resolver name|url> --to <dest-resolver name|url> [--from-block N]\n');
|
|
4065
4981
|
process.exit(1);
|
|
4066
4982
|
}
|
|
4067
|
-
|
|
4983
|
+
requireRemoteToken(toTarget);
|
|
4984
|
+
const from = fromTarget.url;
|
|
4985
|
+
const to = toTarget.url;
|
|
4068
4986
|
const chainId = resolveChain(CHAIN).id;
|
|
4069
4987
|
allowLargeScan(flags);
|
|
4070
4988
|
console.log(bold(`\n abx migrate ${dim('— port off-chain state between resolvers (no cutover)')}`));
|
|
@@ -4121,6 +5039,7 @@ async function cmdMigrate(address, flags) {
|
|
|
4121
5039
|
step('Populate the destination');
|
|
4122
5040
|
const locCount = Object.keys(plan.contentLocators).length;
|
|
4123
5041
|
const body = {
|
|
5042
|
+
chainId,
|
|
4124
5043
|
address,
|
|
4125
5044
|
// The deploy block (locally known); if somehow absent, omit it so the destination derives it
|
|
4126
5045
|
// rather than scanning from genesis (never bake a from-0 floor into a fresh resolver).
|
|
@@ -4133,9 +5052,18 @@ async function cmdMigrate(address, flags) {
|
|
|
4133
5052
|
contentLocators: locCount ? plan.contentLocators : undefined,
|
|
4134
5053
|
full: true, // first registration on the destination — replay from the deploy block
|
|
4135
5054
|
};
|
|
4136
|
-
info(`${bold('REMOTE')} → ${to} ${dim('(
|
|
4137
|
-
|
|
4138
|
-
|
|
5055
|
+
info(`${bold('REMOTE')} → ${to} ${dim('(control plane — chain replay + off-chain enrichment)')}`);
|
|
5056
|
+
let r;
|
|
5057
|
+
try {
|
|
5058
|
+
r = await serviceClient(toTarget).registerProject(body);
|
|
5059
|
+
}
|
|
5060
|
+
catch (err) {
|
|
5061
|
+
throw describeRemoteError(err, toTarget, 'migrate destination');
|
|
5062
|
+
}
|
|
5063
|
+
// Always wait here, even if the caller passed --no-wait: the parity check below reads the
|
|
5064
|
+
// destination's served metadata, and comparing a half-indexed projection would report a false
|
|
5065
|
+
// mismatch — worse than a slow migrate.
|
|
5066
|
+
await reportRemoteIndexing(toTarget, chainId, address, r, {}, 'indexed');
|
|
4139
5067
|
// 5) Parity check — does the destination now serve the same metadata as the source? Sample a
|
|
4140
5068
|
// token we did NOT re-pin (a re-pinned image is durable-locator-on-dest vs old-host-on-source
|
|
4141
5069
|
// BY DESIGN — different there is correct, so comparing it would mislead).
|
|
@@ -4165,100 +5093,171 @@ async function cmdMigrate(address, flags) {
|
|
|
4165
5093
|
info('keep the source running until DNS / base-URI propagates (source-only images were already re-pinned above, unless a warning said otherwise).');
|
|
4166
5094
|
}
|
|
4167
5095
|
// ── doctor ────────────────────────────────────────────────────────────────--
|
|
5096
|
+
/**
|
|
5097
|
+
* Doctor's "want me to fix that?" for a missing or stale skill. Three lanes, deliberately:
|
|
5098
|
+
* `--fix` install without asking (CI, scripts, an agent running doctor for someone)
|
|
5099
|
+
* interactive name the exact directories, then ask — Enter accepts, since doctor's whole job is
|
|
5100
|
+
* getting setup right and this is the one check whose fix is a local file copy
|
|
5101
|
+
* non-TTY change NOTHING and print how to do it; a diagnostic must never mutate a
|
|
5102
|
+
* scripted environment just because nobody was there to say no
|
|
5103
|
+
* Honors `--global` / `--agent` so the fix can target the same place an explicit install would.
|
|
5104
|
+
*/
|
|
5105
|
+
async function offerSkillInstall(flags, stale, indent) {
|
|
5106
|
+
const opts = { global: flags.global !== undefined, agent: flags.agent };
|
|
5107
|
+
const src = resolveBundledSkill();
|
|
5108
|
+
if (!src)
|
|
5109
|
+
return; // no bundled skill to install (dev checkout oddity) — the hint above still stands
|
|
5110
|
+
const verb = stale ? 'resync' : 'install';
|
|
5111
|
+
const dests = defaultSkillDests(opts);
|
|
5112
|
+
if (flags.fix === undefined) {
|
|
5113
|
+
if (!process.stdin.isTTY) {
|
|
5114
|
+
console.log(`${indent}${dim(`non-interactive — run \`abx doctor --fix\` (or \`abx skill install\`) to ${verb} it.`)}`);
|
|
5115
|
+
return;
|
|
5116
|
+
}
|
|
5117
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
5118
|
+
const answer = await new Promise((resolve) => {
|
|
5119
|
+
// EOF (Ctrl-D) closes the interface WITHOUT firing the question callback — awaiting only the
|
|
5120
|
+
// callback would hang doctor forever. Treat a closed stream as a decline: the safe direction is
|
|
5121
|
+
// always "change nothing", never "write files because nobody answered".
|
|
5122
|
+
rl.once('close', () => resolve('n'));
|
|
5123
|
+
rl.question(`${indent}${verb} the abx skill into ${bold(dests)} now? [Y/n] `, resolve);
|
|
5124
|
+
});
|
|
5125
|
+
rl.close();
|
|
5126
|
+
if (declinesSkillInstall(answer)) {
|
|
5127
|
+
console.log(`${indent}${dim(`skipped — \`abx skill install\` when you want it.`)}`);
|
|
5128
|
+
return;
|
|
5129
|
+
}
|
|
5130
|
+
}
|
|
5131
|
+
console.log('');
|
|
5132
|
+
installSkillToDefaults(src, opts);
|
|
5133
|
+
}
|
|
4168
5134
|
async function cmdDoctor(flags) {
|
|
4169
|
-
console.log(bold('\n abx doctor\n')
|
|
4170
|
-
|
|
5135
|
+
console.log(bold('\n abx doctor') + dim(` · ${CHAIN}`) + '\n');
|
|
5136
|
+
// Two visual tiers: PASS/FAIL checks (✓/✗) for things that are either working or broken, and an
|
|
5137
|
+
// "Optional" block (·) for path-dependent setup that is fine to be unset. We deliberately do NOT
|
|
5138
|
+
// use ⚠ for "unset but often fine" — that read as noise; ⚠ is reserved for a real gotcha (a
|
|
5139
|
+
// range-capped RPC). Labels are padded so both tiers align.
|
|
5140
|
+
const CONT = ' '.repeat(17); // continuation indent: aligns under a check/opt detail column
|
|
5141
|
+
const check = (label, pass, detail = '') => console.log(` ${pass ? g('✓') : `${c.red}✗${c.reset}`} ${label.padEnd(13)}${detail ? dim(detail) : ''}`);
|
|
5142
|
+
const opt = (label, detail) => console.log(` ${dim('·')} ${label.padEnd(13)}${dim(detail)}`);
|
|
4171
5143
|
const hasKey = !!(process.env.ABX_DEPLOYER_PK ?? process.env.SEPOLIA_FUNDED_PK ?? process.env.SEPOLIA_WALLET_PK);
|
|
4172
|
-
//
|
|
4173
|
-
//
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
5144
|
+
// 1. Agent skill — FIRST and prominent. The primary way to use abx is to let a coding agent drive
|
|
5145
|
+
// it, so a missing/stale skill is a ✗: not broken infra, but the main UX isn't set up. Its
|
|
5146
|
+
// version lives in SKILL.md frontmatter (version-locked to this CLI). Notify-only — no exit code.
|
|
5147
|
+
const cliVersion = readCliVersion();
|
|
5148
|
+
const skillVersions = installedSkillVersions();
|
|
5149
|
+
const staleSkills = skillVersions.filter((v) => compareVersions(cliVersion, v) > 0);
|
|
5150
|
+
const skillMissing = skillVersions.length === 0;
|
|
5151
|
+
const skillStale = !skillMissing && staleSkills.length > 0;
|
|
5152
|
+
if (skillMissing) {
|
|
5153
|
+
check('agent skill', false, `not installed — run ${g('abx skill install')}`);
|
|
5154
|
+
console.log(`${CONT}${dim('(recommended: let a coding agent drive abx)')}`);
|
|
5155
|
+
}
|
|
5156
|
+
else if (skillStale) {
|
|
5157
|
+
check('agent skill', false, `v${staleSkills.join(', v')} behind CLI v${cliVersion} — run ${g('abx skill install')}`);
|
|
5158
|
+
}
|
|
5159
|
+
else {
|
|
5160
|
+
check('agent skill', true, `in sync (v${cliVersion})`);
|
|
5161
|
+
}
|
|
5162
|
+
// Offer to fix it here rather than only naming the command. `npm i -g` + `abx skill install` was a
|
|
5163
|
+
// two-step install flow where the second step is easy to skip and invisible when skipped (an agent
|
|
5164
|
+
// that never learned abx just... doesn't use it). Doctor is already the documented first run, so
|
|
5165
|
+
// this collapses the flow without an npm `postinstall` hook — which could not work anyway: npm runs
|
|
5166
|
+
// lifecycle scripts with cwd set to the installed package dir (so the skill would land inside
|
|
5167
|
+
// node_modules), pnpm gates install scripts by default, and writing to a user's ~/.claude on
|
|
5168
|
+
// install is the kind of side effect that belongs to the user, not to us.
|
|
5169
|
+
if (skillMissing || skillStale) {
|
|
5170
|
+
await offerSkillInstall(flags, skillStale, CONT);
|
|
5171
|
+
}
|
|
5172
|
+
console.log('');
|
|
5173
|
+
// 2. Core environment (✓/✗). Signing-wallet balances are computed here (they need the RPC) but
|
|
5174
|
+
// printed in the Optional block below, so buffer them.
|
|
5175
|
+
let signingOpt = null;
|
|
5176
|
+
let forOpt = null;
|
|
4178
5177
|
try {
|
|
4179
5178
|
const publicClient = makePublicClient({ chainKey: CHAIN });
|
|
4180
5179
|
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.
|
|
5180
|
+
// Collapse the RPC report to one line (best endpoint + head), and only add a ⚠ when there is a
|
|
5181
|
+
// genuine problem — a range-capped-only set that will grind a resolver under load.
|
|
4184
5182
|
const probes = await probeRpcEndpoints({ chainKey: CHAIN });
|
|
4185
5183
|
const usable = probes.filter((pr) => pr.verdict !== 'unusable');
|
|
4186
5184
|
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)}`}`);
|
|
5185
|
+
if (usable.length > 0) {
|
|
5186
|
+
check('RPC', true, `${best.label} · head ${bn} · ${best.verdict === 'best' ? 'wide range + archive' : 'range-capped'}`);
|
|
5187
|
+
if (!probes.some((pr) => pr.verdict === 'best')) {
|
|
5188
|
+
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')}`);
|
|
5189
|
+
}
|
|
4214
5190
|
}
|
|
4215
|
-
else
|
|
4216
|
-
|
|
5191
|
+
else {
|
|
5192
|
+
check('RPC', false, `${CHAIN} — no endpoint usable for reconstruction; add a wide-range archive RPC to ABX_RPC_URLS`);
|
|
4217
5193
|
}
|
|
4218
5194
|
const factory = factoryAddress();
|
|
4219
5195
|
if (factory) {
|
|
4220
5196
|
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
|
-
}
|
|
5197
|
+
if (!code || code === '0x')
|
|
5198
|
+
check('factory', false, `${factory} — no code on ${CHAIN}; \`abx deploy\` redeploys`);
|
|
5199
|
+
else if (await isCurrentFactory(publicClient, factory))
|
|
5200
|
+
check('factory', true, factory);
|
|
5201
|
+
else
|
|
5202
|
+
check('factory', false, `${factory} — older/incompatible; \`abx deploy\` redeploys`);
|
|
4230
5203
|
}
|
|
4231
5204
|
else {
|
|
4232
|
-
check('
|
|
5205
|
+
check('factory', false, 'none yet — `abx demo` deploys one');
|
|
5206
|
+
}
|
|
5207
|
+
if (hasKey) {
|
|
5208
|
+
const { account } = makeWalletClient({ chainKey: CHAIN });
|
|
5209
|
+
const bal = await publicClient.getBalance({ address: account.address });
|
|
5210
|
+
signingOpt = `env key ${account.address} · ${bal > 0n ? `funded ${formatEther(bal)} ETH` : `empty — fund it (${faucetHint(CHAIN)})`}`;
|
|
5211
|
+
}
|
|
5212
|
+
if (flags.for) {
|
|
5213
|
+
const bal = await publicClient.getBalance({ address: flags.for });
|
|
5214
|
+
forOpt = `${flags.for} · ${bal > 0n ? `funded ${formatEther(bal)} ETH` : `empty — ${faucetHint(CHAIN)}`}`;
|
|
4233
5215
|
}
|
|
4234
5216
|
}
|
|
4235
5217
|
catch (err) {
|
|
4236
|
-
check('RPC
|
|
5218
|
+
check('RPC', false, err.message);
|
|
4237
5219
|
}
|
|
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
5220
|
// storage backend — resolve it (catches missing config), then probe liveness/creds
|
|
4246
5221
|
const backendId = activeBackendId();
|
|
4247
5222
|
try {
|
|
4248
5223
|
const backend = resolveBackend(storageOptions());
|
|
4249
5224
|
const h = await backend.health?.();
|
|
4250
5225
|
if (h)
|
|
4251
|
-
check(
|
|
5226
|
+
check('storage', h.ok, `${backend.id} · ${h.detail ?? ''}`);
|
|
4252
5227
|
else
|
|
4253
|
-
check(
|
|
5228
|
+
check('storage', true, `${backend.id} · configured`);
|
|
4254
5229
|
}
|
|
4255
5230
|
catch (err) {
|
|
4256
|
-
check(
|
|
5231
|
+
check('storage', false, `${backendId} · ${err.message}`);
|
|
4257
5232
|
}
|
|
4258
|
-
//
|
|
5233
|
+
// 3. Optional — path-dependent setup. Unset is fine; these say WHEN you'll need each, so an unset
|
|
5234
|
+
// value never reads as a warning.
|
|
5235
|
+
console.log(`\n ${dim('Optional — depends how you deploy:')}`);
|
|
5236
|
+
if (signingOpt)
|
|
5237
|
+
opt('signing', signingOpt);
|
|
5238
|
+
else
|
|
5239
|
+
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.');
|
|
5240
|
+
if (forOpt)
|
|
5241
|
+
opt('wallet --for', forOpt);
|
|
5242
|
+
const baseUrl = process.env.ABX_PUBLIC_BASE_URL;
|
|
5243
|
+
if (baseUrl)
|
|
5244
|
+
opt('resolver URL', baseUrl);
|
|
5245
|
+
else
|
|
5246
|
+
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
5247
|
if (!process.env.ARWEAVE_JWK && existsSync(arweaveKeyFilePath())) {
|
|
4260
5248
|
const jwk = loadArweaveJwk();
|
|
4261
|
-
|
|
5249
|
+
opt('arweave key', `${jwk ? arweaveAddress(jwk) + ' ' : ''}holds upload credits — back it up: \`abx storage backup-key --out <path>\``);
|
|
5250
|
+
}
|
|
5251
|
+
// Named remotes: report what's configured, and — the part doctor was missing — flag a credential
|
|
5252
|
+
// stored under a name the CLI does NOT read. That fault presents as "it acts like I never gave it a
|
|
5253
|
+
// key", and until now it only surfaced from `abx remote <name>`, which a creator reaches later
|
|
5254
|
+
// (doctor is the thing they're told to run FIRST).
|
|
5255
|
+
const remotes = listConfiguredRemotes();
|
|
5256
|
+
if (remotes.length) {
|
|
5257
|
+
opt('remotes', remotes.map((r) => `${r.name.toLowerCase()}${r.hasToken ? '' : ` ${c.orange}(no token)${c.reset}`}`).join(' · '));
|
|
5258
|
+
}
|
|
5259
|
+
for (const bad of misnamedRemoteVars()) {
|
|
5260
|
+
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
5261
|
}
|
|
4263
5262
|
console.log('');
|
|
4264
5263
|
}
|
|
@@ -4273,7 +5272,13 @@ function printServing(url, address) {
|
|
|
4273
5272
|
console.log(` ${dim('image ')}${url}/t/${cid}/${address}/0/image`);
|
|
4274
5273
|
console.log(` ${dim('state API ')}${url}/api/project/${address}`);
|
|
4275
5274
|
}
|
|
4276
|
-
|
|
5275
|
+
// The dashboard is READ-ONLY: re-index/verify are admin actions that 404 unless the node has an
|
|
5276
|
+
// ABX_RESOLVER_ADMIN_TOKEN, so there is no button to press. This line used to say "hit Re-index
|
|
5277
|
+
// from chain", which sent every first-run user hunting for a control that isn't there.
|
|
5278
|
+
console.log(`\n ${dim('The dashboard shows the event spine it replayed — that table IS the reconstruction.')}`);
|
|
5279
|
+
if (address) {
|
|
5280
|
+
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.')}`);
|
|
5281
|
+
}
|
|
4277
5282
|
console.log(` ${dim('Ctrl-C to stop.')}\n`);
|
|
4278
5283
|
}
|
|
4279
5284
|
// ── deploy-resolver: scaffold a hosted resolver for a provider the operator owns ──────────
|
|
@@ -4404,7 +5409,8 @@ async function cmdDeployResolver(flags) {
|
|
|
4404
5409
|
const url = art.baseUrl;
|
|
4405
5410
|
info(`abx deploy --image <art> --name … --public-base-url ${url} (or export ABX_PUBLIC_BASE_URL=${url})`);
|
|
4406
5411
|
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
|
|
5412
|
+
info(`${bold('then')} abx add <clone> --remote ${dim('# tell the remote resolver to index it — a LOCAL deploy does NOT')}`);
|
|
5413
|
+
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
5414
|
console.log('');
|
|
4409
5415
|
}
|
|
4410
5416
|
// ── deploy-effects: scaffold the render runner (the resolver's browser-bearing companion) ─────
|
|
@@ -4558,12 +5564,15 @@ function keepAlive() {
|
|
|
4558
5564
|
// ── per-command usage (printed by `<cmd> --help` / `abx help <cmd>`; read-only) ──
|
|
4559
5565
|
const COMMAND_HELP = {
|
|
4560
5566
|
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('--
|
|
5567
|
+
${bold('abx skill')} ${dim('— install the version-locked abx agent skill so your coding agent can drive abx')}
|
|
5568
|
+
${g('abx skill install')} copy the bundled skill (version-locked to this CLI) into your agent(s)
|
|
5569
|
+
${dim('default: both')} ${bold('.claude/skills')} ${dim('(Claude Code) and')} ${bold('.agents/skills')} ${dim('(Cursor · Codex · Gemini · Copilot)')}
|
|
5570
|
+
${g('--agent <name>')} only one agent: ${dim('claude | cursor | codex | gemini | copilot')}
|
|
5571
|
+
${g('--global')} install into your home dir (~) instead of the current project
|
|
5572
|
+
${g('--target <dir>')} install the skill folder straight under <dir> (writes <dir>/abx-self-host)
|
|
4565
5573
|
${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.
|
|
5574
|
+
${dim('Restart your agent after installing so it picks up the skill. Once the repo is public, the git-based')}
|
|
5575
|
+
${dim('cross-agent installer also works (not version-locked):')} ${g('npx skills add ArtBlocks/abx --skill abx-self-host')}`,
|
|
4567
5576
|
deploy: `
|
|
4568
5577
|
${bold('abx deploy')} ${dim('— deploy + index a 1/1 (no server). Sends a tx in the chosen lane.')}
|
|
4569
5578
|
--image <path> custody your own image (png · jpg · gif · svg · webp); else generative demo art
|
|
@@ -4620,6 +5629,20 @@ const COMMAND_HELP = {
|
|
|
4620
5629
|
${g('abx deploy-series')} --dir ./photos --name "My Series" --symbol MS --onchain-uri --backend ipfs --mint-all --sign
|
|
4621
5630
|
${dim('# tiny SVGs → fully on-chain (no storage at all):')}
|
|
4622
5631
|
${g('abx deploy-series')} --dir ./svgs --name "My Series" --symbol MS --onchain-image --compress fastlz --mint-all --sign`,
|
|
5632
|
+
preview: `
|
|
5633
|
+
${bold('abx preview')} (--script <file.js> | --code-dir <dir>) ${dim('— run the program on localhost, live. No chain, no key, no deploy.')}
|
|
5634
|
+
${g('--schema key:Type:Auth')}[,…] declare PostParams so the studio gives you real inputs for them (e.g. palette:HexColor:TokenOwner)
|
|
5635
|
+
${g('--dep <name@version>')}[,…] load a library the way the resolver would (built-in CDN map; on-chain refs can't be fetched offline)
|
|
5636
|
+
${g('--port')} <n> studio port (default ${DEFAULT_PREVIEW_PORT}; the resolver's ${DEFAULT_PORT} stays free)
|
|
5637
|
+
${g('--shoot')} <dir> render headlessly to PNGs + traits.json and EXIT ${dim('(for an agent that has no browser)')}
|
|
5638
|
+
${g('--count')} <n> seeds to shoot / show in the grid (default 9) ${g('--width')} <px> ${g('--timeout-ms')} <n>
|
|
5639
|
+
Serves the ${bold('same document the generator serves')} — real ${g('abx.js')}, real tokenData shape, real dep tags — with a synthetic
|
|
5640
|
+
seed, so what you iterate on is what deploys. Routes: ${g('/')} studio (seed + params + live traits) · ${g('/grid')} N seeds at once,
|
|
5641
|
+
all live · ${g('/view')} the bare document. The program is re-read from disk per render, so ${bold('edit and refresh')} — no watcher.
|
|
5642
|
+
Unlike a still-image sweep this shows ${bold('animation')}, which is most of what a screenshot throws away.
|
|
5643
|
+
${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).')}
|
|
5644
|
+
${g('abx preview')} --script art.js --schema palette:HexColor:TokenOwner
|
|
5645
|
+
${g('abx preview')} --script art.js --shoot ./frames --count 12`,
|
|
4623
5646
|
inspect: `
|
|
4624
5647
|
${bold('abx inspect')} <script.js> ${dim('— static analysis of a generative script + a lane recommendation. Read-only; the script is never executed.')}
|
|
4625
5648
|
${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 +5709,9 @@ const COMMAND_HELP = {
|
|
|
4686
5709
|
No tokenId → sweeps all minted tokens; pass ids (${g('0 1 2')}) to target specific tokens.
|
|
4687
5710
|
${g('--force')} RE-RENDER even when the still already exists — the fix for a bad / blank / timed-out capture
|
|
4688
5711
|
(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
|
|
5712
|
+
${g('--remote [name|url]')} publish each render to a REMOTE resolver (the locator bridge: upload to ${bold('ABX_STORAGE_BACKEND')}, POST /v1/effect-artifacts).
|
|
5713
|
+
Idempotent; a re-run restores a resolver that lost its volume. Needs its token (a named remote's
|
|
5714
|
+
${g('ABX_REMOTE_<NAME>_TOKEN')}, else ${g('ABX_RESOLVER_ADMIN_TOKEN')}).
|
|
4691
5715
|
--effects-url <url> enqueue on a running effect-runner service instead of rendering inline (else ${g('ABX_EFFECTS_URL')})
|
|
4692
5716
|
${dim('Inline (no --remote) renders on THIS machine (needs `npx playwright install chromium` once), pointing Chromium at the live')}
|
|
4693
5717
|
${dim(`view of ${g('ABX_RESOLVER_URL')} (else your configured base / local ${g('abx serve')}), and stores to the resolved backend.`)}
|
|
@@ -4703,8 +5727,8 @@ const COMMAND_HELP = {
|
|
|
4703
5727
|
--port <n> HTTP port (default ${g('ABX_EFFECTS_PORT')} / 8788) — ${g('POST /notify')} enqueues (watcher lane) · ${g('POST /run')} sweeps synchronously (command lane)
|
|
4704
5728
|
--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
5729
|
--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('
|
|
5730
|
+
${dim('Co-located with a local `abx serve` (same store) → no tokens needed. Against a REMOTE resolver: --remote <name|url> (its')}
|
|
5731
|
+
${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
5732
|
${dim('To HOST the runner (fly/docker), use `abx deploy-effects`.')}`,
|
|
4709
5733
|
'configure-param': `
|
|
4710
5734
|
${bold('abx configure-param')} <address> <tokenId|-> <key> <value> ${dim('— set a PostParam (typed, canonical encode). Sends a tx.')}
|
|
@@ -4715,7 +5739,7 @@ const COMMAND_HELP = {
|
|
|
4715
5739
|
like ${g('params.keys')} / ${g('display.gateway')}. ≤31 printable-ASCII chars ride as a literal bytes32; longer takes the data path.
|
|
4716
5740
|
After a token write, a project whose ${g('params.keys')} doesn't list the key gets a one-line fix suggestion.
|
|
4717
5741
|
--file <path> read the value from a file (String / Bytes payloads)
|
|
4718
|
-
${g('--remote [url]')}
|
|
5742
|
+
${g('--remote [name|url]')} nudge a REMOTE resolver to re-index IMMEDIATELY after the change (else ABX_PUBLIC_BASE_URL) — it pings
|
|
4719
5743
|
the resolver's effect runner, so the thumbnail re-renders without waiting. Usually OPTIONAL now: a
|
|
4720
5744
|
resolver running the chain watcher (the ${g('abx serve')} default) sees the change on its next poll (~12s)
|
|
4721
5745
|
and auto-re-renders on its own. Keep --remote for a watcher-disabled resolver or when seconds matter.
|
|
@@ -4752,7 +5776,8 @@ const COMMAND_HELP = {
|
|
|
4752
5776
|
${bold('abx state')} <address> ${dim('— read-only, on-chain operational snapshot (no tx, no local index).')}
|
|
4753
5777
|
Shows owner · supply (minted / max, nextTokenId) · paused · minter · primary payee · royalty · renderer.
|
|
4754
5778
|
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)
|
|
5779
|
+
Handy before/after owner ops (mint · pause/unpause · set-minter · set-primary-payee).
|
|
5780
|
+
${dim('state = what the CHAIN says. For who is SERVING it and how fresh that is, see `abx status`.')}`,
|
|
4756
5781
|
predict: `
|
|
4757
5782
|
${bold('abx predict')} ${dim('— pre-compute a deploy address (read-only).')}
|
|
4758
5783
|
--salt 0x..<64hex> a fixed / vanity salt --for 0x.. reserve to a deployer --factory 0x..`,
|
|
@@ -4866,18 +5891,31 @@ const COMMAND_HELP = {
|
|
|
4866
5891
|
${bold('abx set-max-invocations')} <address> --max <N> ${dim('— LOWER the supply cap (Series). Sends a tx.')}
|
|
4867
5892
|
--max <N> the new cap — MONOTONIC: can only DECREASE, and never below what's already minted (else it reverts)`,
|
|
4868
5893
|
doctor: `
|
|
4869
|
-
${bold('abx doctor')} ${dim('— preflight readiness: signing key/wallet, RPC health, canonical factory, storage.
|
|
5894
|
+
${bold('abx doctor')} ${dim('— preflight readiness: agent skill, signing key/wallet, RPC health, canonical factory, storage.')}
|
|
4870
5895
|
--for 0x.. also report that address's balance (fund before signing)
|
|
5896
|
+
${g('--fix')} ${dim('install/resync the agent skill without asking (the one thing doctor can repair)')}
|
|
5897
|
+
${g('--global')} · ${g('--agent')} <a> ${dim('where --fix installs the skill (mirrors `abx skill install`)')}
|
|
5898
|
+
${dim('Read-only apart from --fix. Interactively it OFFERS to install a missing/stale skill; a non-TTY')}
|
|
5899
|
+
${dim('run changes nothing and just prints the command, so scripts and CI are never mutated.')}
|
|
4871
5900
|
${dim('a missing signing key is NOT fatal — the wallet lane (`--sign`) needs no key in `.env`.')}`,
|
|
4872
5901
|
status: `
|
|
4873
|
-
${bold('abx status')} ${dim('—
|
|
5902
|
+
${bold('abx status')} [address] ${dim('— INDEXING status: where a project is in the lifecycle, and how fresh. Read-only.')}
|
|
5903
|
+
${dim('bare')} this node: chain · factory · storage · data dir, then one line per project
|
|
5904
|
+
${dim('<address>')} one project in detail: lifecycle · scan floor · blocks indexed vs head · why, if unhappy
|
|
5905
|
+
${g('--remote')} [name|url] ask a SERVICE instead (your hosted node, or a managed provider) — same five words
|
|
5906
|
+
${g('--watch')} poll until everything reaches a terminal state (${g('live')} or ${c.orange}failed${c.reset})
|
|
5907
|
+
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)')}
|
|
5908
|
+
${dim('status = who is SERVING it and how fresh. For what the CHAIN says (owner, royalty, locks), see `abx state`.')}`,
|
|
4874
5909
|
storage: `
|
|
4875
5910
|
${bold('abx storage')} <show|upload|balance|topup|backup-key> ${dim('— inspect / operate byte custody. Mostly read-only.')}
|
|
4876
5911
|
${g('show')} the resolved backend (fs | cloud | ipfs | arweave) + where each value came from
|
|
4877
5912
|
${g('upload')} <path> upload ONE file → prints its locator (the URI ${g('abx attach')} wants) [--backend …] [--dry-run]
|
|
4878
5913
|
${g('balance')} · ${g('topup')} --usd <n> Turbo (arweave) upload credits · ${g('backup-key')} --out <path> copy the managed key`,
|
|
4879
5914
|
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.')}
|
|
5915
|
+
${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.')}
|
|
5916
|
+
${g('--sign')} ${dim('approve in your browser wallet instead of a hot env key (no key needed)')}
|
|
5917
|
+
${g('--for')} <0x…> ${dim('pin who must connect on --sign (owner + royalty receiver + mint recipient)')}
|
|
5918
|
+
${dim('--unsigned / --dry-run are refused here: both skip the broadcast, and the demo indexes + serves what it deployed.')}`,
|
|
4881
5919
|
minter: `
|
|
4882
5920
|
${bold('abx minter')} <configure|show|buy> <token> ${dim('— sell a Series via the shared fixed-price minter (Minter spine).')}
|
|
4883
5921
|
${g('configure')} <token> (--price <eth> | --price-raw <units>) --allocation <n> [--erc20 0x..] ${dim('(token-owner only)')}
|
|
@@ -4892,13 +5930,20 @@ const COMMAND_HELP = {
|
|
|
4892
5930
|
${bold('abx add')} <address> ${dim('— register + index a project. Also edits off-chain display metadata + traits.')}
|
|
4893
5931
|
--from-block <n> --factory 0x.. --label "<s>" --description "<s>" --external-url <url> [--full] [--yes]
|
|
4894
5932
|
--traits "K=V; K2=V2" / --attributes <file.json> set the off-chain operator traits (on-chain attributes always win)
|
|
4895
|
-
${g('--remote [url]')}
|
|
5933
|
+
${g('--remote [name|url]')} target a REMOTE resolver instead of this machine — bridges the image locator (ipfs://…) + traits to it.
|
|
5934
|
+
A ${bold('name')} reads ${g('ABX_REMOTE_<NAME>_URL')} + ${g('ABX_REMOTE_<NAME>_TOKEN')} from .env (a managed provider's API key);
|
|
5935
|
+
a URL (or bare --remote = ${g('ABX_PUBLIC_BASE_URL')}) uses ${g('ABX_RESOLVER_ADMIN_TOKEN')}. Inspect first: ${g('abx remote <name>')}
|
|
5936
|
+
--remote-token <t> override the token for this invocation (--token means a token ID elsewhere, hence the name)
|
|
5937
|
+
${g('--no-wait')} ${dim('with --remote: return as soon as the service accepts it, instead of waiting out its catch-up.')}
|
|
5938
|
+
${dim('A service may answer "accepted, still indexing" (202) for a long backfill; by default abx polls')}
|
|
5939
|
+
${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
5940
|
index: `
|
|
4897
5941
|
${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
|
|
5942
|
+
${g('--full')} force a full replay from the deploy block (the durability proof) --yes allow a very large scan
|
|
5943
|
+
${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
5944
|
verify: `
|
|
4900
5945
|
${bold('abx verify')} <address> ${dim('— re-hash the served bytes against the on-chain commitment (read-only; no server).')}
|
|
4901
|
-
${g('--remote [url]')}
|
|
5946
|
+
${g('--remote [name|url]')} verify what a REMOTE resolver actually serves (else ABX_PUBLIC_BASE_URL) — probes its \`/image\` (302→locator
|
|
4902
5947
|
or 200 bytes), so it accounts for a render PUBLISHED to that resolver. ${bold('Use this for a code project whose')}
|
|
4903
5948
|
${bold('renders were published to a hosted resolver')} — a plain \`abx verify\` only checks THIS machine's store and will
|
|
4904
5949
|
report a false placeholder for a render that lives on the resolver.
|
|
@@ -4908,11 +5953,21 @@ const COMMAND_HELP = {
|
|
|
4908
5953
|
${bold('abx tokenuri')} <address> [--token <id>] ${dim('— read tokenURI(id) straight from the contract on-chain + decode the JSON (read-only; no server).')}
|
|
4909
5954
|
${dim('The proof a fully on-chain token self-resolves: any RPC returns the renderer-assembled metadata. Default token 0.')}`,
|
|
4910
5955
|
forget: `
|
|
4911
|
-
${bold('abx forget')} <address> ${dim('— drop a project’s local registration + projection. On-chain data is untouched.')}
|
|
5956
|
+
${bold('abx forget')} <address> ${dim('— drop a project’s local registration + projection. On-chain data is untouched.')}
|
|
5957
|
+
${g('--remote [name|url]')} deregister on a REMOTE resolver instead (it stops serving the project; re-add any time)`,
|
|
5958
|
+
remote: `
|
|
5959
|
+
${bold('abx remote')} [<name|url>] ${dim('— inspect a remote service (read-only; registers nothing).')}
|
|
5960
|
+
Bare: list the named remotes in .env (${g('ABX_REMOTE_<NAME>_URL')} / ${g('_TOKEN')} — token shown as set/unset, never printed)
|
|
5961
|
+
plus the self-host default (bare --remote = ${g('ABX_PUBLIC_BASE_URL')} + ${g('ABX_RESOLVER_ADMIN_TOKEN')}).
|
|
5962
|
+
With a target: fetch its PUBLIC ${g('/.well-known/abx-service')} descriptor — what it serves (interfaces), which chains
|
|
5963
|
+
(flags a mismatch with your ${g('ABX_CHAIN')}), whether ${bold('rendering is managed')} behind it (code drops then need no effects
|
|
5964
|
+
runner), and where a human gets an API key (${g('auth.signupUrl')}). With a token: lists the projects visible to it —
|
|
5965
|
+
${bold('the one-command "is my provider key valid?" check')} (401 = fix the key · 403 = provider-side scoping, not a typo).`,
|
|
4912
5966
|
migrate: `
|
|
4913
5967
|
${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>
|
|
5968
|
+
--from <name|url> the SOURCE resolver (currently serving the contract) — read via its PUBLIC api; no source credential needed
|
|
5969
|
+
--to <name|url> the DESTINATION resolver (its control plane) — the ONE credential migrate needs: a named remote's
|
|
5970
|
+
${g('ABX_REMOTE_<NAME>_TOKEN')}, else ${g('ABX_RESOLVER_ADMIN_TOKEN')} (your own node's, from ${g('deploy-resolver')}), else --remote-token
|
|
4916
5971
|
--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
5972
|
--backend <id> durable custody for re-pinning source-only images (ipfs · arweave); else config/env
|
|
4918
5973
|
${dim('replays on-chain state on the dest from chain, then bridges description / external_url / off-chain')}
|
|
@@ -4962,7 +6017,8 @@ function help() {
|
|
|
4962
6017
|
--minter 0x.. · --primary-payee 0x.. · --unpaused · ${g('--dry-run')} · see ${g('abx help deploy-series')}
|
|
4963
6018
|
${g('abx predict')} pre-compute a deploy address flags: [--salt 0x..] [--for 0x..] [--factory 0x..]
|
|
4964
6019
|
${g('abx add')} <address> register + index a project this node didn't deploy
|
|
4965
|
-
flags: --from-block --factory --label
|
|
6020
|
+
flags: --from-block --factory --label · ${g('--remote <name|url>')} registers on a REMOTE resolver instead
|
|
6021
|
+
${g('abx remote')} [<name|url>] inspect a remote service: its descriptor (chains · managed rendering · where to get a key) + your projects there
|
|
4966
6022
|
${g('abx index')} [<address>] re-index from chain (incremental by default; ${g('--full')} forces a replay from deploy)
|
|
4967
6023
|
${g('abx verify')} <addr> re-hash served bytes vs the on-chain commitment (no server needed)
|
|
4968
6024
|
${g('abx tokenuri')} <addr> read tokenURI(0) on-chain + decode the JSON (proof a self-resolving token works)
|
|
@@ -4971,7 +6027,9 @@ function help() {
|
|
|
4971
6027
|
project + notify the effects layer on change (${g('ABX_WATCH_INTERVAL_MS')}; 0 = off)
|
|
4972
6028
|
|
|
4973
6029
|
${bold('code / generative projects')} ${dim('— a program is the content; output is a function of live on-chain state')}
|
|
4974
|
-
${g('abx
|
|
6030
|
+
${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,
|
|
6031
|
+
drive your PostParams, watch it animate. Same document the generator serves. ${g('--shoot <dir>')} for headless frames. No chain.
|
|
6032
|
+
${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
6033
|
${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
6034
|
${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
6035
|
${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 +6075,13 @@ function help() {
|
|
|
5017
6075
|
${g('abx storage')} topup buy Turbo credits by card --usd <n> (one-time; <100 KB is always free)
|
|
5018
6076
|
${g('abx storage')} backup-key copy the managed Turbo/Arweave key (holds credits) to a safe path --out <path>
|
|
5019
6077
|
${g('abx forget')} <address> drop a project's local registration + projection (on-chain untouched)
|
|
5020
|
-
${g('abx status')}
|
|
6078
|
+
${g('abx status')} [address] indexing status: lifecycle + freshness (bare = this node; ${g('--remote')} [name] = a service; ${g('--watch')} to tail)
|
|
5021
6079
|
${g('abx doctor')} check environment (key, RPC, balance, factory, storage)
|
|
5022
|
-
${g('abx skill install')} install the abx
|
|
6080
|
+
${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
6081
|
${g('abx version')} print the installed CLI version
|
|
5024
6082
|
|
|
5025
6083
|
${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 (
|
|
6084
|
+
${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
6085
|
`);
|
|
5028
6086
|
}
|
|
5029
6087
|
/** Validate an explicit --salt (a 32-byte hex). Undefined when absent. */
|
|
@@ -5041,8 +6099,13 @@ function parseSaltFlag(raw) {
|
|
|
5041
6099
|
// ── skill: install the abx agent skill into the user's coding agent ───────────────────────
|
|
5042
6100
|
// The skill is the agentic half of the toolkit — the CLI is only useful once an agent knows how
|
|
5043
6101
|
// 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
|
-
//
|
|
6102
|
+
// prepack); in dev the same command reads the canonical copy straight from the repo. This is the
|
|
6103
|
+
// CANONICAL install path precisely because the bundle is version-locked to the CLI it ships inside
|
|
6104
|
+
// (the version lives in SKILL.md frontmatter — see update-check.ts). By default it writes to BOTH
|
|
6105
|
+
// `.claude/skills` (Claude Code) and the neutral `.agents/skills` (Cursor · Codex · Gemini ·
|
|
6106
|
+
// Copilot all read it), so one command covers the whole ecosystem; `--agent` narrows it. A separate
|
|
6107
|
+
// git-based channel, `npx skills add ArtBlocks/abx --skill abx-self-host`, works once the repo is public but is NOT
|
|
6108
|
+
// version-locked to a local CLI — prefer `abx skill install`.
|
|
5046
6109
|
/** Locate the skill folder: the bundled copy beside the compiled CLI (published), else the
|
|
5047
6110
|
* canonical repo copy (dev). Returns null if neither is present. */
|
|
5048
6111
|
function resolveBundledSkill() {
|
|
@@ -5106,37 +6169,86 @@ function cmdScaffoldRenderer(rest, flags) {
|
|
|
5106
6169
|
console.log(` ${g('abx deploy-code --image-renderer <MyRenderer> --attributes-renderer <MyTraits> --onchain-uri --schema palette:HexColor:TokenOwner --name "…" --symbol …')}`);
|
|
5107
6170
|
info(`full walkthrough: ${bold(`${basename(dir)}/README.md`)} · interface + invariants: https://abx.docs.artblocks.io/protocol/renderers/`);
|
|
5108
6171
|
}
|
|
6172
|
+
/** Human label for each skills-parent dir — which agents pick the skill up from there. */
|
|
6173
|
+
const SKILL_PARENT_LABELS = {
|
|
6174
|
+
'.claude/skills': 'Claude Code',
|
|
6175
|
+
'.agents/skills': 'Cursor · Codex · Gemini · Copilot',
|
|
6176
|
+
};
|
|
6177
|
+
/** Resolve which skills-parent dirs `install` should write to. No `--agent` → the whole-ecosystem
|
|
6178
|
+
* default (Claude Code + the neutral `.agents/skills` everyone else reads). `--agent a,b` narrows
|
|
6179
|
+
* to those agents' dirs (de-duped, since several share `.agents/skills`). */
|
|
6180
|
+
function resolveInstallParents(agentFlag) {
|
|
6181
|
+
if (!agentFlag || agentFlag === 'true')
|
|
6182
|
+
return ['.claude/skills', '.agents/skills'];
|
|
6183
|
+
const parents = new Set();
|
|
6184
|
+
for (const raw of agentFlag.split(',')) {
|
|
6185
|
+
const a = raw.trim().toLowerCase();
|
|
6186
|
+
const parent = AGENT_SKILL_PARENTS[a];
|
|
6187
|
+
if (!parent) {
|
|
6188
|
+
throw new Error(`unknown --agent '${a}'. Use one of: ${Object.keys(AGENT_SKILL_PARENTS).join(', ')} (or omit --agent to install for every agent).`);
|
|
6189
|
+
}
|
|
6190
|
+
parents.add(parent);
|
|
6191
|
+
}
|
|
6192
|
+
return [...parents];
|
|
6193
|
+
}
|
|
6194
|
+
/** Copy the bundled skill folder to `dest`, replacing any prior copy so a re-install after an
|
|
6195
|
+
* upgrade never leaves stale reference files behind. */
|
|
6196
|
+
function installSkillTo(src, dest) {
|
|
6197
|
+
mkdirSync(joinPath(dest, '..'), { recursive: true });
|
|
6198
|
+
rmSync(dest, { recursive: true, force: true });
|
|
6199
|
+
cpSync(src, dest, { recursive: true });
|
|
6200
|
+
}
|
|
6201
|
+
/**
|
|
6202
|
+
* Install the bundled skill into the default per-agent parents (or `~` with `global`), reporting
|
|
6203
|
+
* each destination. Shared by `abx skill install` and `abx doctor`'s offer to fix a missing/stale
|
|
6204
|
+
* skill — one implementation, so the two can't drift on where the skill lands or what it prints.
|
|
6205
|
+
*/
|
|
6206
|
+
function installSkillToDefaults(src, opts = {}) {
|
|
6207
|
+
const version = readSkillVersion(joinPath(src, 'SKILL.md')) ?? readCliVersion();
|
|
6208
|
+
const base = opts.global ? homedir() : process.cwd();
|
|
6209
|
+
const parents = resolveInstallParents(opts.agent);
|
|
6210
|
+
ok(`installed the abx skill v${version}${opts.global ? ' (global, ~)' : ''}:`);
|
|
6211
|
+
for (const parent of parents) {
|
|
6212
|
+
const dest = joinPath(base, parent, SKILL_DIR_NAME);
|
|
6213
|
+
installSkillTo(src, dest);
|
|
6214
|
+
const label = SKILL_PARENT_LABELS[parent];
|
|
6215
|
+
console.log(` ${g(joinPath(parent, SKILL_DIR_NAME))}${label ? dim(' → ' + label) : ''}`);
|
|
6216
|
+
}
|
|
6217
|
+
info('restart your agent so it loads the skill, then ask it to launch an NFT with abx.');
|
|
6218
|
+
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.`);
|
|
6219
|
+
return version;
|
|
6220
|
+
}
|
|
6221
|
+
/** The default skill destinations, as a display string — what doctor's prompt has to name up front. */
|
|
6222
|
+
function defaultSkillDests(opts = {}) {
|
|
6223
|
+
const prefix = opts.global ? '~/' : './';
|
|
6224
|
+
return resolveInstallParents(opts.agent).map((p) => `${prefix}${p}`).join(' and ');
|
|
6225
|
+
}
|
|
5109
6226
|
async function cmdSkill(rest, flags) {
|
|
5110
6227
|
const sub = rest[0] ?? 'install';
|
|
5111
6228
|
const src = resolveBundledSkill();
|
|
5112
6229
|
if (!src) {
|
|
5113
6230
|
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');
|
|
6231
|
+
'Reinstall @artblocks/abx-cli, or install cross-agent with: npx skills add ArtBlocks/abx --skill abx-self-host');
|
|
5115
6232
|
}
|
|
5116
6233
|
if (sub === 'path') {
|
|
5117
6234
|
console.log(src);
|
|
5118
6235
|
return;
|
|
5119
6236
|
}
|
|
5120
6237
|
if (sub === 'install') {
|
|
5121
|
-
const
|
|
5122
|
-
|
|
5123
|
-
|
|
5124
|
-
|
|
5125
|
-
|
|
5126
|
-
|
|
5127
|
-
|
|
5128
|
-
|
|
5129
|
-
|
|
5130
|
-
}
|
|
5131
|
-
catch {
|
|
5132
|
-
/* non-fatal */
|
|
6238
|
+
const version = readSkillVersion(joinPath(src, 'SKILL.md')) ?? readCliVersion();
|
|
6239
|
+
// Escape hatch: --target <dir> writes the skill folder straight under <dir> (for an agent
|
|
6240
|
+
// whose skills dir we don't special-case, or a bespoke location).
|
|
6241
|
+
if (typeof flags.target === 'string' && flags.target !== 'true') {
|
|
6242
|
+
const dest = joinPath(resolvePath(flags.target), SKILL_DIR_NAME);
|
|
6243
|
+
installSkillTo(src, dest);
|
|
6244
|
+
ok(`installed the abx skill v${version} → ${dest}`);
|
|
6245
|
+
info('restart your agent so it loads the skill, then ask it to launch an NFT with abx.');
|
|
6246
|
+
return;
|
|
5133
6247
|
}
|
|
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');
|
|
6248
|
+
installSkillToDefaults(src, { global: flags.global !== undefined, agent: flags.agent });
|
|
5137
6249
|
return;
|
|
5138
6250
|
}
|
|
5139
|
-
throw new Error('usage: abx skill <install|path> [--global] [--target <dir>]');
|
|
6251
|
+
throw new Error('usage: abx skill <install|path> [--agent claude|cursor|codex|gemini|copilot] [--global] [--target <dir>]');
|
|
5140
6252
|
}
|
|
5141
6253
|
main().catch((err) => {
|
|
5142
6254
|
// Keep the actionable message; strip viem's verbose boilerplate trailer (Docs:/Version: lines) so
|