@artblocks/abx-cli 0.1.0-alpha.0 → 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/flags.d.ts +6 -0
- package/dist/flags.d.ts.map +1 -1
- package/dist/flags.js +7 -1
- package/dist/flags.js.map +1 -1
- package/dist/main.js +1425 -263
- 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 +85 -0
- package/dist/update-check.d.ts.map +1 -0
- package/dist/update-check.js +282 -0
- package/dist/update-check.js.map +1 -0
- package/package.json +9 -6
- package/skill/SKILL.md +74 -19
- package/skill/reference/code-projects.md +35 -0
- package/skill/reference/hosting.md +40 -9
- 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,8 +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';
|
|
60
|
+
import { AGENT_SKILL_PARENTS, checkForCliUpdate, compareVersions, installedSkillVersions, readCliVersion, readSkillVersion, SKILL_DIR_NAME, } from './update-check.js';
|
|
57
61
|
import { analyzeScript, recommendLane } from './inspect.js';
|
|
62
|
+
import { previewConfigFromFlags, previewDepTags, shootPreview, startPreviewServer, DEFAULT_PREVIEW_PORT, PREVIEW_FLAGS } from './preview.js';
|
|
58
63
|
import { parseSchemaSpecs, describeSchema } from './schema.js';
|
|
64
|
+
import { declinesSkillInstall } from './prompt.js';
|
|
59
65
|
import { parseSeriesTraits, looksPerTokenAttributes, parseSeriesTraitsById } from './series-traits.js';
|
|
60
66
|
/** The `--schema` type/auth/format catalog, shown wherever the CLI nudges `--schema`. Kept accurate
|
|
61
67
|
* to the on-chain enums (PARAM_TYPES / AUTH_OPTIONS) + the Select-options / Range-bounds format —
|
|
@@ -65,22 +71,20 @@ const SCHEMA_CATALOG = 'Types: Bool·Select·Uint256Range·Int256Range·DecimalR
|
|
|
65
71
|
'Select needs options — key:Select[A|B|C]:Auth; a Range takes bounds — key:Uint256Range[0..100]:Auth. ' +
|
|
66
72
|
'A palette collectors set = palette:HexColor:TokenOwner';
|
|
67
73
|
import { uploadAndLocate } from './upload.js';
|
|
68
|
-
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';
|
|
69
75
|
import { SelfHostIndexer, SqliteStore } from '@artblocks/abx-indexer';
|
|
70
76
|
import { artContentHash, currentRenderArtifact, generateArt, resolveBaseUrl, startChainWatcher, startTokenApiServer, verifyProject, watchIntervalMs, DEFAULT_PORT, } from '@artblocks/abx-token-api';
|
|
71
77
|
import { ARWEAVE_FREE_UPLOAD_LIMIT, arweaveAddress, arweaveFunding, contentTypeFromPath, hashContent, resolveBackend, turboBalanceForAddress, turboUploadCostUsd, turboUploadWinc } from '@artblocks/abx-storage';
|
|
72
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';
|
|
73
79
|
import { openWalletSession, signTx } from './signer.js';
|
|
74
|
-
import {
|
|
80
|
+
import { describeRemoteError, listConfiguredRemotes, misnamedRemoteVars, requireRemoteToken, resolveRemote, serviceClient, tokenSourceLabel } from './remote.js';
|
|
75
81
|
import { buildMigrationPlan, repinNodeCustody, verifyParity } from './migrate.js';
|
|
76
|
-
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';
|
|
77
83
|
const CHAIN = process.env.ABX_CHAIN ?? DEFAULT_CHAIN_KEY;
|
|
78
|
-
// Block explorer base
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
};
|
|
83
|
-
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);
|
|
84
88
|
// Product dimensions — what you can launch. One concrete contract exists today
|
|
85
89
|
// (the 1/1 image); this table is the seam future implementations slot into, so
|
|
86
90
|
// `abx deploy --type <dimension>` is stable while the contracts grow under it.
|
|
@@ -110,10 +114,49 @@ const step = (s) => console.log(`\n${p(`[${++stepN}]`)} ${bold(s)}`);
|
|
|
110
114
|
const ok = (s) => console.log(` ${g('✓')} ${s}`);
|
|
111
115
|
const info = (s) => console.log(` ${dim(s)}`);
|
|
112
116
|
const warn = (s) => console.log(` ${c.orange}⚠${c.reset} ${s}`);
|
|
117
|
+
/**
|
|
118
|
+
* Notify-only update nudge. Prints an upgrade one-liner to STDERR (never stdout — an agent may be
|
|
119
|
+
* parsing that) when a newer @artblocks/abx-cli is published, plus a nudge when a locally-installed
|
|
120
|
+
* skill has drifted behind the running CLI. Fully suppressed by CI / ABX_NO_UPDATE_CHECK /
|
|
121
|
+
* --no-update-check, and wrapped so it can NEVER break a command (all failures are swallowed).
|
|
122
|
+
*/
|
|
123
|
+
async function maybeNotifyUpdate(flags) {
|
|
124
|
+
try {
|
|
125
|
+
if (flags['no-update-check'] !== undefined || process.env.ABX_NO_UPDATE_CHECK || process.env.CI)
|
|
126
|
+
return;
|
|
127
|
+
const current = readCliVersion();
|
|
128
|
+
// Skill drift (cheap, offline): the skill and CLI are co-versioned, but a separately-installed
|
|
129
|
+
// skill copy doesn't move when the CLI upgrades. Nudge a reinstall when the CLI is ahead.
|
|
130
|
+
for (const v of installedSkillVersions()) {
|
|
131
|
+
if (compareVersions(current, v) > 0) {
|
|
132
|
+
console.error(` ${c.orange}⚠${c.reset} installed abx skill is v${v} but the CLI is v${current} — refresh it: ${g('abx skill install')}`);
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const latest = await checkForCliUpdate(current);
|
|
137
|
+
if (latest) {
|
|
138
|
+
console.error(`\n ${c.orange}⚠${c.reset} update available: ${bold('abx')} ${dim(current)} → ${g(latest)}\n` +
|
|
139
|
+
` upgrade: ${g('npm i -g @artblocks/abx-cli@latest')} ${dim('· or invoke:')} ${g('npx @artblocks/abx-cli@latest <command>')}\n` +
|
|
140
|
+
` release notes: https://github.com/ArtBlocks/abx/releases ${dim('· silence: ABX_NO_UPDATE_CHECK=1')}\n`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
/* an update check must never break a command */
|
|
145
|
+
}
|
|
146
|
+
}
|
|
113
147
|
async function main() {
|
|
114
148
|
loadDotEnv();
|
|
115
149
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
116
150
|
const flags = parseFlags(rest);
|
|
151
|
+
// `version` / --version / -v is a crisp query — print and exit, no update nag around it.
|
|
152
|
+
if (cmd === 'version' || cmd === '--version' || cmd === '-v') {
|
|
153
|
+
console.log(readCliVersion());
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
// Notify-only "you're behind" nudge (cached, opt-out, stderr — see update-check.ts). Awaited so
|
|
157
|
+
// the notice lands before command output, but it hits the network at most once every 6h and never
|
|
158
|
+
// throws, so it can't break or meaningfully slow a command. Skipped for the pure help path below.
|
|
159
|
+
await maybeNotifyUpdate(flags);
|
|
117
160
|
// SAFETY: --help / -h on ANY command is read-only — print usage, never execute.
|
|
118
161
|
// (A write command must never deploy/mint/spend just because someone asked for help.)
|
|
119
162
|
if (cmd && cmd !== 'help' && (flags.help !== undefined || rest.includes('-h'))) {
|
|
@@ -125,6 +168,7 @@ async function main() {
|
|
|
125
168
|
case 'deploy-series': return cmdDeploySeries(flags);
|
|
126
169
|
case 'deploy-code': return cmdDeployCode(flags);
|
|
127
170
|
case 'inspect': return cmdInspect(rest[0], flags);
|
|
171
|
+
case 'preview': return cmdPreview(flags);
|
|
128
172
|
case 'scaffold-renderer': return cmdScaffoldRenderer(rest, flags);
|
|
129
173
|
case 'predict': return cmdPredict(flags);
|
|
130
174
|
case 'add': return cmdAdd(rest[0], flags);
|
|
@@ -164,8 +208,9 @@ async function main() {
|
|
|
164
208
|
case 'set-admin': return cmdSetAdmin(rest[0], flags);
|
|
165
209
|
case 'forget': return cmdForget(rest[0], flags);
|
|
166
210
|
case 'migrate': return cmdMigrate(rest[0], flags);
|
|
211
|
+
case 'remote': return cmdRemote(rest[0], flags);
|
|
167
212
|
case 'storage': return cmdStorage(rest);
|
|
168
|
-
case 'status': return cmdStatus();
|
|
213
|
+
case 'status': return cmdStatus(rest[0], flags);
|
|
169
214
|
case 'state': return cmdState(rest[0], flags);
|
|
170
215
|
case 'doctor': return cmdDoctor(flags);
|
|
171
216
|
case 'skill': return cmdSkill(rest, flags);
|
|
@@ -180,6 +225,65 @@ async function main() {
|
|
|
180
225
|
process.exit(1);
|
|
181
226
|
}
|
|
182
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
|
+
}
|
|
183
287
|
// When the user/agent opts in (--yes), lift the getLogs chunk cap so a large
|
|
184
288
|
// reconstruction proceeds despite a range-limited RPC (otherwise it stops early with
|
|
185
289
|
// guidance — see GetLogsScanTooLargeError + the skill's "Choosing an RPC" decision).
|
|
@@ -253,17 +357,6 @@ async function detectCanonicalFactory(address, override, stored) {
|
|
|
253
357
|
}
|
|
254
358
|
return factoryAddress() ?? undefined;
|
|
255
359
|
}
|
|
256
|
-
/** A short faucet pointer for the active testnet — a 0-balance signer's #1 next step. A known URL
|
|
257
|
-
* plus a search hint (URLs rot; the search always works), so "fund it" isn't a dead end. */
|
|
258
|
-
function faucetHint(chainKey) {
|
|
259
|
-
const url = {
|
|
260
|
-
'base-sepolia': 'https://portal.cdp.coinbase.com/products/faucet',
|
|
261
|
-
sepolia: 'https://www.alchemy.com/faucets/ethereum-sepolia',
|
|
262
|
-
};
|
|
263
|
-
return url[chainKey]
|
|
264
|
-
? `get free test ETH from a ${chainKey} faucet (${url[chainKey]} — or search "${chainKey} faucet"), usually ≤1 min`
|
|
265
|
-
: `get test ETH from a "${chainKey}" faucet`;
|
|
266
|
-
}
|
|
267
360
|
// Funding preflight — a 0-balance signer fails only at the tx, with a confusing error. Surface it
|
|
268
361
|
// up front. Especially the wallet lane (--sign/--for), which has no env key for `doctor` to check.
|
|
269
362
|
async function warnUnfunded(publicClient, address) {
|
|
@@ -415,7 +508,7 @@ function refuseMissingFactory(kind, envVar, reason) {
|
|
|
415
508
|
` To deploy your OWN trust anchor instead (private chains, sandboxes — platforms won't recognize its clones), ` +
|
|
416
509
|
`re-run with --bootstrap-factory.`);
|
|
417
510
|
}
|
|
418
|
-
async function ensureFactory(override, allowBootstrap = false) {
|
|
511
|
+
async function ensureFactory(override, allowBootstrap = false, quiet = false) {
|
|
419
512
|
// Resolve the canonical factory from the shipped manifest (flag → env → manifest); on a chain
|
|
420
513
|
// with no entry, deploy a fresh trust anchor and tell the operator how to reuse it.
|
|
421
514
|
const known = factoryAddress(override);
|
|
@@ -425,7 +518,10 @@ async function ensureFactory(override, allowBootstrap = false) {
|
|
|
425
518
|
const code = await publicClient.getCode({ address: known });
|
|
426
519
|
if (code && code !== '0x') {
|
|
427
520
|
if (await isCurrentFactory(publicClient, known)) {
|
|
428
|
-
|
|
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}`);
|
|
429
525
|
return known;
|
|
430
526
|
}
|
|
431
527
|
if (!allowBootstrap)
|
|
@@ -942,26 +1038,53 @@ async function cmdDeploy(flags, serveAfter) {
|
|
|
942
1038
|
// editable later via `abx add --traits`); `--traits-onchain` inlines them on-chain (durable,
|
|
943
1039
|
// lockable) — mirrors the description model. On-chain wins if both ever set the same trait.
|
|
944
1040
|
const traits = parseDeployTraits(flags);
|
|
945
|
-
console.log(
|
|
946
|
-
|
|
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
|
+
}
|
|
947
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);
|
|
948
1079
|
const publicClient = makePublicClient({ chainKey: CHAIN });
|
|
949
1080
|
// Verify the RPC really is CHAIN before any send (factory/renderer/staging/deploy). A dry run
|
|
950
1081
|
// sends nothing, but it DOES read the chain (predict address, resolve the factory/renderer), so
|
|
951
1082
|
// a wrong-network RPC must still be caught with the clear mismatch message rather than failing
|
|
952
1083
|
// opaquely inside predict; `allowUnreachable` keeps a genuinely offline dry-run previewable.
|
|
953
1084
|
await assertChainId(CHAIN, { allowUnreachable: dryRun });
|
|
954
|
-
//
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
if (!flags.name)
|
|
958
|
-
warn(`no --name → default "${name}" would be the on-chain collection name`);
|
|
959
|
-
if (!flags.symbol)
|
|
960
|
-
warn(`no --symbol → default "${symbol}" would be the on-chain symbol`);
|
|
961
|
-
if (!dryRun && !flags.yes) {
|
|
962
|
-
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).');
|
|
963
|
-
}
|
|
964
|
-
}
|
|
1085
|
+
// `demo` is exempt: its whole job is a zero-argument first token.
|
|
1086
|
+
if (!serveAfter)
|
|
1087
|
+
assertRealIdentity(flags, { name, symbol, dryRun });
|
|
965
1088
|
// Funding preflight (real deploys): warn now if the signer is unfunded, not at the tx.
|
|
966
1089
|
if (!dryRun) {
|
|
967
1090
|
let signer = flags.for;
|
|
@@ -974,7 +1097,13 @@ async function cmdDeploy(flags, serveAfter) {
|
|
|
974
1097
|
if (signer)
|
|
975
1098
|
await warnUnfunded(publicClient, signer);
|
|
976
1099
|
}
|
|
977
|
-
|
|
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');
|
|
978
1107
|
let factory;
|
|
979
1108
|
if (dryRun) {
|
|
980
1109
|
const existing = factoryAddress(flags.factory);
|
|
@@ -984,10 +1113,12 @@ async function cmdDeploy(flags, serveAfter) {
|
|
|
984
1113
|
return;
|
|
985
1114
|
}
|
|
986
1115
|
factory = existing;
|
|
1116
|
+
if (!(await previewFactoryLive(publicClient, factory, 'factory')))
|
|
1117
|
+
return;
|
|
987
1118
|
info(`would reuse canonical factory ${factory}`);
|
|
988
1119
|
}
|
|
989
1120
|
else {
|
|
990
|
-
factory = await ensureFactory(flags.factory, !!flags['bootstrap-factory']);
|
|
1121
|
+
factory = await ensureFactory(flags.factory, !!flags['bootstrap-factory'], serveAfter);
|
|
991
1122
|
}
|
|
992
1123
|
// --onchain-uri: resolve tokenURI/contractURI fully on-chain via the canonical renderer
|
|
993
1124
|
// (the JSON is assembled from on-chain fields → no resolver needed, ever). The off-chain
|
|
@@ -995,18 +1126,32 @@ async function cmdDeploy(flags, serveAfter) {
|
|
|
995
1126
|
// --onchain-image: stage the --image bytes on-chain (chunk store, ownerless) and bake a
|
|
996
1127
|
// `reader` field into the deploy — so large on-chain content needs NO post-deploy tx. It
|
|
997
1128
|
// implies on-chain URI resolution (the renderer emits the reader-backed image).
|
|
998
|
-
const onchainImage = !!flags['onchain-image'];
|
|
999
|
-
const onChainUri = !!flags['onchain-uri'] || onchainImage;
|
|
1000
|
-
// On-chain traits when explicitly asked, or implied by --onchain-uri (the renderer can only
|
|
1001
|
-
// emit on-chain fields, so off-chain-only traits would be invisible there). Else off-chain.
|
|
1002
|
-
const traitsOnchain = traits.length > 0 && (!!flags['traits-onchain'] || onChainUri);
|
|
1003
1129
|
// A fully on-chain token self-resolves via the renderer; the stored off-chain pointer is
|
|
1004
1130
|
// never read while a renderer is set. So bake a real URL only if one was explicitly given —
|
|
1005
1131
|
// otherwise leave it EMPTY rather than baking a misleading localhost into the contract.
|
|
1006
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);
|
|
1007
1152
|
let renderer = zeroAddress;
|
|
1008
1153
|
if (onChainUri) {
|
|
1009
|
-
step('On-chain renderer');
|
|
1154
|
+
step(serveAfter ? 'What will answer when someone asks about your token' : 'On-chain renderer');
|
|
1010
1155
|
if (dryRun) {
|
|
1011
1156
|
renderer = rendererAddress(flags.renderer) ?? zeroAddress;
|
|
1012
1157
|
info(renderer === zeroAddress ? 'would deploy the canonical renderer first' : `would use renderer ${renderer}`);
|
|
@@ -1014,8 +1159,28 @@ async function cmdDeploy(flags, serveAfter) {
|
|
|
1014
1159
|
else {
|
|
1015
1160
|
renderer = await ensureRenderer(flags.renderer);
|
|
1016
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)'));
|
|
1017
1183
|
}
|
|
1018
|
-
step(`Deploy a ${DIMENSIONS[dimension].label} (--type ${dimension}) to ${CHAIN}`);
|
|
1019
1184
|
// Off-chain custody (no renderer) bakes the resolver URL straight into the on-chain
|
|
1020
1185
|
// tokenURI/contractURI at deploy. A localhost / loopback URL there resolves for NO ONE —
|
|
1021
1186
|
// not marketplaces, not wallets, not even your own browser unless `abx serve` is running —
|
|
@@ -1042,7 +1207,14 @@ async function cmdDeploy(flags, serveAfter) {
|
|
|
1042
1207
|
}
|
|
1043
1208
|
}
|
|
1044
1209
|
}
|
|
1045
|
-
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) {
|
|
1046
1218
|
info('tokenURI/contractURI resolve ON-CHAIN via the renderer — no resolver, no server, no localhost.');
|
|
1047
1219
|
if (!hasPublicUrl)
|
|
1048
1220
|
info('off-chain fallback pointer left empty (the renderer is authoritative); set --public-base-url to bake one anyway.');
|
|
@@ -1219,7 +1391,9 @@ async function cmdDeploy(flags, serveAfter) {
|
|
|
1219
1391
|
// wallet lane + on-chain staging: ONE sign session signs every chunk write AND the deploy.
|
|
1220
1392
|
// The connecting wallet pays for (and is the deployer of) all of it — staging can't precede
|
|
1221
1393
|
// the connect here, so it happens inside the session, then the deploy bakes in the manifest.
|
|
1222
|
-
|
|
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.');
|
|
1223
1397
|
const plan = computeContentPlan(readFileSync(resolvePath(flags.image)), parseCompress(flags.compress)).plan;
|
|
1224
1398
|
const stagingTxs = plan.mode === 'single' ? 1 : plan.txCount;
|
|
1225
1399
|
const session = await openWalletSession({
|
|
@@ -1284,8 +1458,12 @@ async function cmdDeploy(flags, serveAfter) {
|
|
|
1284
1458
|
info(`tx ${EXPLORER}/tx/${r.txHash} (block ${blockNumber})`);
|
|
1285
1459
|
}
|
|
1286
1460
|
else {
|
|
1287
|
-
// wallet lane (off-chain custody) or cold lane: a single deploy tx, no staging
|
|
1288
|
-
|
|
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}`);
|
|
1289
1467
|
const result = await signTx(async (signer) => {
|
|
1290
1468
|
const { clone: predicted, params, salt } = await buildForDeployer(signer);
|
|
1291
1469
|
return prepareDeployOneOfOne({ factory, params, salt, chainId: resolveChain(CHAIN).id, clone: predicted });
|
|
@@ -1298,7 +1476,7 @@ async function cmdDeploy(flags, serveAfter) {
|
|
|
1298
1476
|
blockNumber = result.blockNumber;
|
|
1299
1477
|
ok(`deployed ${clone}`);
|
|
1300
1478
|
}
|
|
1301
|
-
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');
|
|
1302
1480
|
const indexer = new SelfHostIndexer();
|
|
1303
1481
|
// Off-chain traits ride in the registration (on-chain ones are already in the contract fields).
|
|
1304
1482
|
const offChainTraits = !traitsOnchain && traits.length ? JSON.stringify(traits) : undefined;
|
|
@@ -1313,10 +1491,23 @@ async function cmdDeploy(flags, serveAfter) {
|
|
|
1313
1491
|
attributes: offChainTraits,
|
|
1314
1492
|
};
|
|
1315
1493
|
indexer.register(baseReg);
|
|
1316
|
-
const { state, elapsedMs } = await indexer
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
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
|
+
}
|
|
1320
1511
|
// For off-chain custody (image committed as keccak256), resolve the durable locator (ipfs://…)
|
|
1321
1512
|
// from this machine's content index and store it on the registration — so the LOCAL resolver
|
|
1322
1513
|
// points `image` at IPFS, and `abx add --remote` can ship it to a hosted one (the localhost-image fix).
|
|
@@ -1365,8 +1556,21 @@ async function cmdDeploy(flags, serveAfter) {
|
|
|
1365
1556
|
}
|
|
1366
1557
|
return;
|
|
1367
1558
|
}
|
|
1368
|
-
|
|
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.
|
|
1369
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.'));
|
|
1370
1574
|
printServing(url, clone);
|
|
1371
1575
|
keepAlive();
|
|
1372
1576
|
}
|
|
@@ -1417,21 +1621,15 @@ async function cmdDeploySeries(flags) {
|
|
|
1417
1621
|
throw new Error(`--count ${count} exceeds the ${files.length} media file(s) in ${dirPath}`);
|
|
1418
1622
|
const slots = files.slice(0, count);
|
|
1419
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 });
|
|
1420
1627
|
const lane = laneFromFlags(flags);
|
|
1421
1628
|
const publicClient = makePublicClient({ chainKey: CHAIN });
|
|
1422
1629
|
// Catch a wrong-network RPC with the clear mismatch message even on dry-run (which still reads
|
|
1423
1630
|
// the chain to predict the address); tolerate an unreachable RPC so an offline preview still works.
|
|
1424
1631
|
await assertChainId(CHAIN, { allowUnreachable: dryRun });
|
|
1425
|
-
//
|
|
1426
|
-
if (!flags.name || !flags.symbol) {
|
|
1427
|
-
if (!flags.name)
|
|
1428
|
-
warn(`no --name → default "${name}" would be the on-chain collection name`);
|
|
1429
|
-
if (!flags.symbol)
|
|
1430
|
-
warn(`no --symbol → default "${symbol}" would be the on-chain symbol`);
|
|
1431
|
-
if (!dryRun && !flags.yes) {
|
|
1432
|
-
throw new Error('refusing to write demo placeholders as your public on-chain identity — pass --name "Your Title" --symbol SYM (or --yes).');
|
|
1433
|
-
}
|
|
1434
|
-
}
|
|
1632
|
+
// (identity guard already ran above, via the shared assertRealIdentity — before any RPC)
|
|
1435
1633
|
// Mint timing: mint-all → the whole series; mint-count N → the first N; else deferred.
|
|
1436
1634
|
const mintCount = flags['mint-all'] !== undefined ? count : flags['mint-count'] ? Number(flags['mint-count']) : 0;
|
|
1437
1635
|
if (mintCount > count)
|
|
@@ -1475,6 +1673,8 @@ async function cmdDeploySeries(flags) {
|
|
|
1475
1673
|
return;
|
|
1476
1674
|
}
|
|
1477
1675
|
factory = existing;
|
|
1676
|
+
if (!(await previewFactoryLive(publicClient, factory, 'Series factory')))
|
|
1677
|
+
return;
|
|
1478
1678
|
info(`would reuse canonical Series factory ${factory}`);
|
|
1479
1679
|
}
|
|
1480
1680
|
else {
|
|
@@ -1808,8 +2008,11 @@ async function cmdDeploySeries(flags) {
|
|
|
1808
2008
|
}
|
|
1809
2009
|
else {
|
|
1810
2010
|
// wallet lane (off-chain / inline) or cold lane: a single deploy tx, no staging sequence —
|
|
1811
|
-
// tokenFields is already built.
|
|
1812
|
-
|
|
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}`);
|
|
1813
2016
|
const result = await signTx(async (signer) => {
|
|
1814
2017
|
const { clone: predicted, params, salt } = await buildForDeployer(signer);
|
|
1815
2018
|
return prepareDeploySeries({ factory, params, salt, chainId: resolveChain(CHAIN).id, clone: predicted });
|
|
@@ -1837,8 +2040,9 @@ async function cmdDeploySeries(flags) {
|
|
|
1837
2040
|
tokenAttributes: offChainTokenTraits,
|
|
1838
2041
|
};
|
|
1839
2042
|
indexer.register(baseReg);
|
|
1840
|
-
const { state, elapsedMs } = await indexer
|
|
1841
|
-
|
|
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}`);
|
|
1842
2046
|
info(`extensions: ${state.extensions.map((e) => e.name).join(', ') || 'none'}`);
|
|
1843
2047
|
// Off-chain custody: bridge each token's keccak → durable locator so the resolver (local and,
|
|
1844
2048
|
// via `abx add --remote`, a hosted one) points images off this node.
|
|
@@ -1932,18 +2136,166 @@ async function cmdPredict(flags) {
|
|
|
1932
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)'));
|
|
1933
2137
|
console.log('');
|
|
1934
2138
|
}
|
|
1935
|
-
//
|
|
1936
|
-
//
|
|
1937
|
-
//
|
|
1938
|
-
function
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
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;
|
|
1945
2298
|
}
|
|
1946
|
-
return base;
|
|
1947
2299
|
}
|
|
1948
2300
|
/**
|
|
1949
2301
|
* Ensure a resolver admin token exists locally, generating + persisting one to `.env`
|
|
@@ -1976,24 +2328,24 @@ function ensureEffectsToken() {
|
|
|
1976
2328
|
process.env.ABX_EFFECTS_TOKEN = token;
|
|
1977
2329
|
return { token, generated: true };
|
|
1978
2330
|
}
|
|
1979
|
-
/** The shared secret that authorizes remote indexing control (never on-chain signing). */
|
|
1980
|
-
function requireAdminToken() {
|
|
1981
|
-
const t = process.env.ABX_RESOLVER_ADMIN_TOKEN;
|
|
1982
|
-
if (!t) {
|
|
1983
|
-
throw new Error('remote ops need ABX_RESOLVER_ADMIN_TOKEN in your .env — it must match the token set on the resolver ' +
|
|
1984
|
-
'(`abx deploy-resolver` generates one and wires both sides).');
|
|
1985
|
-
}
|
|
1986
|
-
return t;
|
|
1987
|
-
}
|
|
1988
2331
|
// ── add ──────────────────────────────────────────────────────────────────────
|
|
1989
2332
|
// Register + index a project this node didn't deploy. LOCAL by default (this
|
|
1990
2333
|
// machine's store); `--remote [url]` instead tells a HOSTED resolver to index it —
|
|
1991
2334
|
// the bridge a local deploy can't make on its own (separate projection stores).
|
|
1992
2335
|
async function cmdAdd(address, flags) {
|
|
1993
2336
|
if (!address || address.startsWith('--')) {
|
|
1994
|
-
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');
|
|
1995
2338
|
process.exit(1);
|
|
1996
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
|
+
}
|
|
1997
2349
|
// `--attributes` is lane-aware here exactly as at deploy: a PER-TOKEN payload edits a Series'
|
|
1998
2350
|
// per-token off-chain traits; a flat payload (+ `--traits`) edits the collection/1-of-1 `attributes`.
|
|
1999
2351
|
// Ambiguity defaults to flat (see looksPerTokenAttributes), so a 1/1 add is never mis-read.
|
|
@@ -2005,10 +2357,10 @@ async function cmdAdd(address, flags) {
|
|
|
2005
2357
|
if (flags.traits)
|
|
2006
2358
|
flagTraits.push(...parseTraitPairs(flags.traits));
|
|
2007
2359
|
const editedTokenAttributes = perTokenEdit ? parseSeriesTraitsById(attrRaw) : undefined;
|
|
2008
|
-
const remote =
|
|
2360
|
+
const remote = remoteFlag(flags);
|
|
2009
2361
|
if (remote) {
|
|
2010
|
-
|
|
2011
|
-
// Bridge what a
|
|
2362
|
+
requireRemoteToken(remote);
|
|
2363
|
+
// Bridge what a remote resolver can't derive itself: the off-chain traits and the durable
|
|
2012
2364
|
// content locators (ipfs://…). Prefer flags; otherwise forward what the LOCAL deploy stored
|
|
2013
2365
|
// (the local registration), and compute locators from this machine's content index if needed.
|
|
2014
2366
|
const localReg = new SelfHostIndexer().store.getRegistration(address);
|
|
@@ -2017,7 +2369,7 @@ async function cmdAdd(address, flags) {
|
|
|
2017
2369
|
: localReg?.attributes
|
|
2018
2370
|
? normalizeAttributes(JSON.parse(localReg.attributes))
|
|
2019
2371
|
: undefined;
|
|
2020
|
-
// 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
|
|
2021
2373
|
// way to derive them — they're operator metadata, not chain state). A fresh per-token `--attributes`
|
|
2022
2374
|
// EDITS them; otherwise forward what the LOCAL deploy stored. Best-effort parse.
|
|
2023
2375
|
let tokenAttributes;
|
|
@@ -2043,6 +2395,7 @@ async function cmdAdd(address, flags) {
|
|
|
2043
2395
|
// (Its ABSENCE here was the bug: a hosted resolver defaulted to genesis and scanned the whole
|
|
2044
2396
|
// chain.) Re-sending the same floor stays incremental server-side, so a nudge ≠ a re-scan.
|
|
2045
2397
|
const body = {
|
|
2398
|
+
chainId: resolveChain(CHAIN).id,
|
|
2046
2399
|
address,
|
|
2047
2400
|
fromBlock: await resolveScanFloor(address, localReg?.fromBlock, flags),
|
|
2048
2401
|
factory: await detectCanonicalFactory(address, flags.factory, localReg?.factory),
|
|
@@ -2054,12 +2407,22 @@ async function cmdAdd(address, flags) {
|
|
|
2054
2407
|
contentLocators: Object.keys(contentLocators).length ? contentLocators : undefined,
|
|
2055
2408
|
full: flags.full ? true : undefined,
|
|
2056
2409
|
};
|
|
2057
|
-
info(`${bold('REMOTE')} → ${remote} ${dim('(registering with the
|
|
2410
|
+
info(`${bold('REMOTE')} → ${remote.url} ${dim('(registering with the remote resolver — NOT this machine)')}`);
|
|
2058
2411
|
if (body.contentLocators)
|
|
2059
2412
|
info(`bridging image locator → ${Object.values(body.contentLocators)[0]} ${dim('(so the resolver points at IPFS, not its own localhost)')}`);
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
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}`)}`);
|
|
2063
2426
|
return;
|
|
2064
2427
|
}
|
|
2065
2428
|
allowLargeScan(flags);
|
|
@@ -2102,7 +2465,11 @@ async function cmdAdd(address, flags) {
|
|
|
2102
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.`);
|
|
2103
2466
|
}
|
|
2104
2467
|
catch { /* advisory only — the real scan still runs */ }
|
|
2105
|
-
|
|
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);
|
|
2106
2473
|
// Resolve durable locators for off-chain-by-hash content from this machine's index.
|
|
2107
2474
|
const locators = await collectContentLocators(state, resolveBackend(storageOptions(storageOverrides(flags))));
|
|
2108
2475
|
if (Object.keys(locators).length) {
|
|
@@ -2119,7 +2486,14 @@ async function cmdAdd(address, flags) {
|
|
|
2119
2486
|
contentLocators: JSON.stringify(locators),
|
|
2120
2487
|
});
|
|
2121
2488
|
}
|
|
2122
|
-
|
|
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
|
+
}
|
|
2123
2497
|
info(`serve it from here with ${bold('abx serve')} — or push it to a hosted resolver with ${bold('abx add ' + address + ' --remote')}`);
|
|
2124
2498
|
}
|
|
2125
2499
|
/** The code-project trust anchor: use the canonical factory, else deploy one (a sandbox /
|
|
@@ -2186,6 +2560,271 @@ async function ensureSeedSource(publicClient) {
|
|
|
2186
2560
|
* Schemas: --schema key:Type:Auth[,key:Type:Auth…] (e.g. palette:HexColor:TokenOwner).
|
|
2187
2561
|
* Seeds: canonical randomizer by default; --no-seed opts out.
|
|
2188
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
|
+
}
|
|
2189
2828
|
/**
|
|
2190
2829
|
* `abx inspect <script.js>` — read a generative script and, WITHOUT executing it, report what it
|
|
2191
2830
|
* needs (traits + their on-chain reproducibility, dependency hints, size → assembled-document size →
|
|
@@ -2366,17 +3005,7 @@ async function cmdDeployCode(flags) {
|
|
|
2366
3005
|
const dryRun = !!flags['dry-run'];
|
|
2367
3006
|
const name = flags.name ?? 'ABX Code';
|
|
2368
3007
|
const symbol = flags.symbol ?? 'ABXC';
|
|
2369
|
-
|
|
2370
|
-
// on-chain identity. dry-run only warns so a preview still runs without --name/--symbol.
|
|
2371
|
-
if (!flags.name || !flags.symbol) {
|
|
2372
|
-
if (!flags.name)
|
|
2373
|
-
warn(`no --name → default "${name}" would be the on-chain collection name`);
|
|
2374
|
-
if (!flags.symbol)
|
|
2375
|
-
warn(`no --symbol → default "${symbol}" would be the on-chain symbol`);
|
|
2376
|
-
if (!dryRun && !flags.yes) {
|
|
2377
|
-
throw new Error('refusing to write demo placeholders as your public on-chain identity — pass --name "Your Title" --symbol SYM (or --yes).');
|
|
2378
|
-
}
|
|
2379
|
-
}
|
|
3008
|
+
assertRealIdentity(flags, { name, symbol, dryRun });
|
|
2380
3009
|
const maxProvided = flags.max !== undefined;
|
|
2381
3010
|
const max = Number(flags.max ?? 16);
|
|
2382
3011
|
if (!Number.isInteger(max) || max <= 0)
|
|
@@ -3217,7 +3846,7 @@ async function loadEffects() {
|
|
|
3217
3846
|
*/
|
|
3218
3847
|
async function cmdRender(address, tokenIds, flags) {
|
|
3219
3848
|
if (!address || address.startsWith('--')) {
|
|
3220
|
-
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');
|
|
3221
3850
|
process.exit(1);
|
|
3222
3851
|
}
|
|
3223
3852
|
// Token ids are decimal (mint order). Keep only those — `parseFlags` leaves `--flag value` pairs
|
|
@@ -3254,12 +3883,12 @@ async function cmdRender(address, tokenIds, flags) {
|
|
|
3254
3883
|
ok(line);
|
|
3255
3884
|
return;
|
|
3256
3885
|
}
|
|
3257
|
-
// --remote publishes each render to the
|
|
3886
|
+
// --remote publishes each render to the remote resolver's control plane (the locator bridge) so a
|
|
3258
3887
|
// laptop render lands on a resolver that doesn't share this disk; republish=true makes a re-run
|
|
3259
3888
|
// restore a resolver that lost its volume without re-rendering. Local (no --remote): shared backend.
|
|
3260
|
-
const remote =
|
|
3261
|
-
const resolverUrl = (remote ?? process.env.ABX_RESOLVER_URL ?? resolveBaseUrl()).replace(/\/$/, '');
|
|
3262
|
-
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;
|
|
3263
3892
|
// Co-located (no --remote): record each declared output into the shared store's artifact
|
|
3264
3893
|
// registry so the local resolver's `artifacts` manifest enumerates it. Remote: the publish
|
|
3265
3894
|
// lane (adminToken) records rows on the hosted resolver instead.
|
|
@@ -3294,8 +3923,11 @@ async function cmdRender(address, tokenIds, flags) {
|
|
|
3294
3923
|
* single time and exits (vs `abx render <addr>` which is the per-project repair lane).
|
|
3295
3924
|
*/
|
|
3296
3925
|
async function cmdEffects(flags) {
|
|
3297
|
-
const
|
|
3298
|
-
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;
|
|
3299
3931
|
// Co-located (no admin token — we share the resolver's store): record each declared output into
|
|
3300
3932
|
// the shared artifact registry so the resolver's `artifacts` manifest enumerates it. With an
|
|
3301
3933
|
// admin token, the publish lane records rows on the hosted resolver instead.
|
|
@@ -3335,16 +3967,23 @@ async function cmdEffects(flags) {
|
|
|
3335
3967
|
await new Promise(() => { }); // block like `serve`
|
|
3336
3968
|
}
|
|
3337
3969
|
async function cmdIndex(address, flags) {
|
|
3338
|
-
const remote =
|
|
3970
|
+
const remote = remoteFlag(flags);
|
|
3339
3971
|
if (remote) {
|
|
3340
3972
|
if (!address || address.startsWith('--')) {
|
|
3341
|
-
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');
|
|
3342
3974
|
process.exit(1);
|
|
3343
3975
|
}
|
|
3344
|
-
|
|
3345
|
-
info(`${bold('REMOTE')} → ${remote} ${dim('(re-indexing on the
|
|
3346
|
-
const
|
|
3347
|
-
|
|
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');
|
|
3348
3987
|
return;
|
|
3349
3988
|
}
|
|
3350
3989
|
allowLargeScan(flags);
|
|
@@ -3374,7 +4013,7 @@ async function cmdVerify(address, flags) {
|
|
|
3374
4013
|
process.exit(1);
|
|
3375
4014
|
}
|
|
3376
4015
|
allowLargeScan(flags);
|
|
3377
|
-
const remote =
|
|
4016
|
+
const remote = remoteFlag(flags);
|
|
3378
4017
|
if (remote)
|
|
3379
4018
|
return cmdVerifyRemote(address, remote);
|
|
3380
4019
|
const indexer = new SelfHostIndexer();
|
|
@@ -3388,7 +4027,11 @@ async function cmdVerify(address, flags) {
|
|
|
3388
4027
|
if (!state)
|
|
3389
4028
|
throw new Error(`could not load state for ${address}`);
|
|
3390
4029
|
console.log(bold(`\n verify ${state.name ?? address}`));
|
|
3391
|
-
|
|
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 ?? '—'}`);
|
|
3392
4035
|
const storage = resolveBackend(storageOptions());
|
|
3393
4036
|
const result = await verifyProject(state, storage);
|
|
3394
4037
|
const tokens = result.tokens ?? [];
|
|
@@ -3518,18 +4161,33 @@ async function cmdVerify(address, flags) {
|
|
|
3518
4161
|
console.log(`\n ${dim('live view animates regardless; the placeholder only affects the static marketplace thumbnail.')}\n`);
|
|
3519
4162
|
else
|
|
3520
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;
|
|
3521
4170
|
}
|
|
3522
4171
|
// `abx verify <addr> --remote <url>` — verify what a HOSTED resolver actually serves (the local
|
|
3523
4172
|
// `verify` checks THIS machine's store/backend, the wrong store for a hosted drop). Probes the real
|
|
3524
4173
|
// `/…/image` route, so it accounts for the locator bridge (a 302 to ipfs/ar) exactly as a marketplace
|
|
3525
4174
|
// sees it — the truthful "did the thumbnail land?" check after a remote render.
|
|
3526
|
-
async function cmdVerifyRemote(address,
|
|
3527
|
-
const base =
|
|
4175
|
+
async function cmdVerifyRemote(address, remote) {
|
|
4176
|
+
const base = remote.url.replace(/\/$/, '');
|
|
3528
4177
|
const chainId = resolveChain(CHAIN).id;
|
|
3529
4178
|
console.log(bold(`\n verify ${address} ${dim(`(remote → ${base})`)}`));
|
|
3530
|
-
|
|
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
|
+
}
|
|
3531
4189
|
if (!stateRes.ok) {
|
|
3532
|
-
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}`);
|
|
3533
4191
|
}
|
|
3534
4192
|
const state = (await stateRes.json());
|
|
3535
4193
|
info(`serving as "${state.name ?? address}"`);
|
|
@@ -3575,10 +4233,24 @@ async function cmdVerifyRemote(address, resolver) {
|
|
|
3575
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}\`)`);
|
|
3576
4234
|
}
|
|
3577
4235
|
const { upToDate, stale, rendering, failed } = report.counts;
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
|
|
3581
|
-
|
|
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);
|
|
3582
4254
|
return;
|
|
3583
4255
|
}
|
|
3584
4256
|
let gap = false;
|
|
@@ -3596,8 +4268,63 @@ async function cmdVerifyRemote(address, resolver) {
|
|
|
3596
4268
|
}
|
|
3597
4269
|
}
|
|
3598
4270
|
console.log(gap
|
|
3599
|
-
?
|
|
3600
|
-
:
|
|
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
|
|
3601
4328
|
}
|
|
3602
4329
|
// ── tokenuri ─────────────────────────────────────────────────────────────────
|
|
3603
4330
|
// Read tokenURI(id) STRAIGHT FROM THE CONTRACT on-chain (no server, no node) and decode
|
|
@@ -3648,7 +4375,12 @@ async function cmdTokenUri(address, flags) {
|
|
|
3648
4375
|
catch {
|
|
3649
4376
|
const code = await publicClient.getCode({ address }).catch(() => undefined);
|
|
3650
4377
|
if (!code || code === '0x') {
|
|
3651
|
-
|
|
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`);
|
|
3652
4384
|
}
|
|
3653
4385
|
else {
|
|
3654
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`);
|
|
@@ -3967,9 +4699,53 @@ async function cmdState(address, flags) {
|
|
|
3967
4699
|
info(`renderer ${renderer && renderer !== zeroAddress ? `on-chain (${renderer})` : dim('off-chain (stored URI base / override)')}`);
|
|
3968
4700
|
console.log('');
|
|
3969
4701
|
}
|
|
3970
|
-
|
|
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);
|
|
3971
4718
|
const indexer = new SelfHostIndexer();
|
|
3972
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
|
+
}
|
|
3973
4749
|
console.log(bold(`\n ABX self-host node`));
|
|
3974
4750
|
info(`chain: ${CHAIN} · factory: ${factoryAddress() ?? 'none'} · storage: ${activeBackendId()} · data: ${indexer.store.path}`);
|
|
3975
4751
|
if (regs.length === 0) {
|
|
@@ -3979,27 +4755,114 @@ async function cmdStatus() {
|
|
|
3979
4755
|
console.log('');
|
|
3980
4756
|
for (const reg of regs) {
|
|
3981
4757
|
const s = indexer.getProject(reg.address);
|
|
3982
|
-
const
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
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)}`);
|
|
3986
4762
|
}
|
|
3987
|
-
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(', ')})`)}` : ''}`;
|
|
3988
4842
|
}
|
|
3989
4843
|
// ── forget ────────────────────────────────────────────────────────────────--
|
|
3990
4844
|
// Drop a project this node tracks (registration + projection) — for cleaning up
|
|
3991
4845
|
// test/junk deploys. On-chain data is untouched; `abx add` can re-register it.
|
|
3992
4846
|
async function cmdForget(address, flags) {
|
|
3993
4847
|
if (!address || address.startsWith('--')) {
|
|
3994
|
-
console.error('usage: abx forget <address> [--remote [url]]\n');
|
|
4848
|
+
console.error('usage: abx forget <address> [--remote [name|url]]\n');
|
|
3995
4849
|
process.exit(1);
|
|
3996
4850
|
}
|
|
3997
|
-
const remote =
|
|
4851
|
+
const remote = remoteFlag(flags);
|
|
3998
4852
|
if (remote) {
|
|
3999
|
-
|
|
4000
|
-
info(`${bold('REMOTE')} → ${remote} ${dim('(deregistering on the
|
|
4001
|
-
|
|
4002
|
-
|
|
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.`));
|
|
4003
4866
|
return;
|
|
4004
4867
|
}
|
|
4005
4868
|
const indexer = new SelfHostIndexer();
|
|
@@ -4010,6 +4873,97 @@ async function cmdForget(address, flags) {
|
|
|
4010
4873
|
indexer.store.deregister(address);
|
|
4011
4874
|
ok(`forgot ${address} — dropped its registration + projection. On-chain data is untouched.`);
|
|
4012
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
|
+
}
|
|
4013
4967
|
// ── migrate ───────────────────────────────────────────────────────────────--
|
|
4014
4968
|
// Move a contract's OFF-CHAIN operator state from one resolver to another (e.g. fly.io →
|
|
4015
4969
|
// a droplet). The destination replays all ON-CHAIN state from chain itself; this bridges
|
|
@@ -4018,13 +4972,17 @@ async function cmdForget(address, flags) {
|
|
|
4018
4972
|
// and write through the admin control plane. It does NOT cut over — after a clean migration the
|
|
4019
4973
|
// operator re-points DNS (custom domain) or the on-chain base URI (provider endpoint).
|
|
4020
4974
|
async function cmdMigrate(address, flags) {
|
|
4021
|
-
|
|
4022
|
-
|
|
4023
|
-
|
|
4024
|
-
|
|
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');
|
|
4025
4981
|
process.exit(1);
|
|
4026
4982
|
}
|
|
4027
|
-
|
|
4983
|
+
requireRemoteToken(toTarget);
|
|
4984
|
+
const from = fromTarget.url;
|
|
4985
|
+
const to = toTarget.url;
|
|
4028
4986
|
const chainId = resolveChain(CHAIN).id;
|
|
4029
4987
|
allowLargeScan(flags);
|
|
4030
4988
|
console.log(bold(`\n abx migrate ${dim('— port off-chain state between resolvers (no cutover)')}`));
|
|
@@ -4081,6 +5039,7 @@ async function cmdMigrate(address, flags) {
|
|
|
4081
5039
|
step('Populate the destination');
|
|
4082
5040
|
const locCount = Object.keys(plan.contentLocators).length;
|
|
4083
5041
|
const body = {
|
|
5042
|
+
chainId,
|
|
4084
5043
|
address,
|
|
4085
5044
|
// The deploy block (locally known); if somehow absent, omit it so the destination derives it
|
|
4086
5045
|
// rather than scanning from genesis (never bake a from-0 floor into a fresh resolver).
|
|
@@ -4093,9 +5052,18 @@ async function cmdMigrate(address, flags) {
|
|
|
4093
5052
|
contentLocators: locCount ? plan.contentLocators : undefined,
|
|
4094
5053
|
full: true, // first registration on the destination — replay from the deploy block
|
|
4095
5054
|
};
|
|
4096
|
-
info(`${bold('REMOTE')} → ${to} ${dim('(
|
|
4097
|
-
|
|
4098
|
-
|
|
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');
|
|
4099
5067
|
// 5) Parity check — does the destination now serve the same metadata as the source? Sample a
|
|
4100
5068
|
// token we did NOT re-pin (a re-pinned image is durable-locator-on-dest vs old-host-on-source
|
|
4101
5069
|
// BY DESIGN — different there is correct, so comparing it would mislead).
|
|
@@ -4125,100 +5093,171 @@ async function cmdMigrate(address, flags) {
|
|
|
4125
5093
|
info('keep the source running until DNS / base-URI propagates (source-only images were already re-pinned above, unless a warning said otherwise).');
|
|
4126
5094
|
}
|
|
4127
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
|
+
}
|
|
4128
5134
|
async function cmdDoctor(flags) {
|
|
4129
|
-
console.log(bold('\n abx doctor\n')
|
|
4130
|
-
|
|
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)}`);
|
|
4131
5143
|
const hasKey = !!(process.env.ABX_DEPLOYER_PK ?? process.env.SEPOLIA_FUNDED_PK ?? process.env.SEPOLIA_WALLET_PK);
|
|
4132
|
-
//
|
|
4133
|
-
//
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
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;
|
|
4138
5177
|
try {
|
|
4139
5178
|
const publicClient = makePublicClient({ chainKey: CHAIN });
|
|
4140
5179
|
const bn = await publicClient.getBlockNumber();
|
|
4141
|
-
|
|
4142
|
-
//
|
|
4143
|
-
// 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.
|
|
4144
5182
|
const probes = await probeRpcEndpoints({ chainKey: CHAIN });
|
|
4145
5183
|
const usable = probes.filter((pr) => pr.verdict !== 'unusable');
|
|
4146
5184
|
const best = probes.find((pr) => pr.verdict === 'best') ?? usable[0];
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
|
|
4150
|
-
|
|
4151
|
-
|
|
4152
|
-
console.log(` ${glyph} ${pr.label} ${dim('— ' + (pr.verdict === 'best' ? 'wide getLogs range + archive' : pr.reason ?? pr.verdict))}`);
|
|
4153
|
-
}
|
|
4154
|
-
if (usable.length === 0) {
|
|
4155
|
-
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”')}`);
|
|
4156
|
-
}
|
|
4157
|
-
else if (!probes.some((pr) => pr.verdict === 'best')) {
|
|
4158
|
-
// Every usable endpoint is range-capped. Indexing still works (chunking), but a first
|
|
4159
|
-
// reconstruction — and a resolver under marketplace load — is slow + rate-limit-prone. That's
|
|
4160
|
-
// a serious infra signal, NOT "a paid plan is required": flag it. A normal deploy→index only
|
|
4161
|
-
// scans from the deploy block, so this bites first reconstructions and busy resolvers most.
|
|
4162
|
-
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”).')}`);
|
|
4163
|
-
}
|
|
4164
|
-
if (hasKey) {
|
|
4165
|
-
const { account } = makeWalletClient({ chainKey: CHAIN });
|
|
4166
|
-
const bal = await publicClient.getBalance({ address: account.address });
|
|
4167
|
-
check('deployer funded', bal > 0n, `${account.address} · ${formatEther(bal)} ETH`);
|
|
4168
|
-
}
|
|
4169
|
-
// Wallet lane has no env key — let a creator preflight THEIR OWN signing wallet's balance,
|
|
4170
|
-
// the one gap where an unfunded wallet otherwise only surfaces at the signing step.
|
|
4171
|
-
if (flags.for) {
|
|
4172
|
-
const bal = await publicClient.getBalance({ address: flags.for });
|
|
4173
|
-
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
|
+
}
|
|
4174
5190
|
}
|
|
4175
|
-
else
|
|
4176
|
-
|
|
5191
|
+
else {
|
|
5192
|
+
check('RPC', false, `${CHAIN} — no endpoint usable for reconstruction; add a wide-range archive RPC to ABX_RPC_URLS`);
|
|
4177
5193
|
}
|
|
4178
5194
|
const factory = factoryAddress();
|
|
4179
5195
|
if (factory) {
|
|
4180
5196
|
const code = await publicClient.getCode({ address: factory });
|
|
4181
|
-
if (!code || code === '0x')
|
|
4182
|
-
check('
|
|
4183
|
-
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4187
|
-
else {
|
|
4188
|
-
check('canonical factory deployed', false, `${factory} — older/incompatible version; \`abx deploy\` redeploys`);
|
|
4189
|
-
}
|
|
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`);
|
|
4190
5203
|
}
|
|
4191
5204
|
else {
|
|
4192
|
-
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)}`}`;
|
|
4193
5215
|
}
|
|
4194
5216
|
}
|
|
4195
5217
|
catch (err) {
|
|
4196
|
-
check('RPC
|
|
5218
|
+
check('RPC', false, err.message);
|
|
4197
5219
|
}
|
|
4198
|
-
// public base URL — baked into on-chain URIs at deploy; unset is fine for a demo
|
|
4199
|
-
// but a silent footgun for a real launch, so surface it explicitly.
|
|
4200
|
-
const baseUrl = process.env.ABX_PUBLIC_BASE_URL;
|
|
4201
|
-
if (baseUrl)
|
|
4202
|
-
check('public base URL set (baked into on-chain URIs)', true, baseUrl);
|
|
4203
|
-
else
|
|
4204
|
-
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.')}`);
|
|
4205
5220
|
// storage backend — resolve it (catches missing config), then probe liveness/creds
|
|
4206
5221
|
const backendId = activeBackendId();
|
|
4207
5222
|
try {
|
|
4208
5223
|
const backend = resolveBackend(storageOptions());
|
|
4209
5224
|
const h = await backend.health?.();
|
|
4210
5225
|
if (h)
|
|
4211
|
-
check(
|
|
5226
|
+
check('storage', h.ok, `${backend.id} · ${h.detail ?? ''}`);
|
|
4212
5227
|
else
|
|
4213
|
-
check(
|
|
5228
|
+
check('storage', true, `${backend.id} · configured`);
|
|
4214
5229
|
}
|
|
4215
5230
|
catch (err) {
|
|
4216
|
-
check(
|
|
5231
|
+
check('storage', false, `${backendId} · ${err.message}`);
|
|
4217
5232
|
}
|
|
4218
|
-
//
|
|
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).');
|
|
4219
5247
|
if (!process.env.ARWEAVE_JWK && existsSync(arweaveKeyFilePath())) {
|
|
4220
5248
|
const jwk = loadArweaveJwk();
|
|
4221
|
-
|
|
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.`);
|
|
4222
5261
|
}
|
|
4223
5262
|
console.log('');
|
|
4224
5263
|
}
|
|
@@ -4233,7 +5272,13 @@ function printServing(url, address) {
|
|
|
4233
5272
|
console.log(` ${dim('image ')}${url}/t/${cid}/${address}/0/image`);
|
|
4234
5273
|
console.log(` ${dim('state API ')}${url}/api/project/${address}`);
|
|
4235
5274
|
}
|
|
4236
|
-
|
|
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
|
+
}
|
|
4237
5282
|
console.log(` ${dim('Ctrl-C to stop.')}\n`);
|
|
4238
5283
|
}
|
|
4239
5284
|
// ── deploy-resolver: scaffold a hosted resolver for a provider the operator owns ──────────
|
|
@@ -4364,7 +5409,8 @@ async function cmdDeployResolver(flags) {
|
|
|
4364
5409
|
const url = art.baseUrl;
|
|
4365
5410
|
info(`abx deploy --image <art> --name … --public-base-url ${url} (or export ABX_PUBLIC_BASE_URL=${url})`);
|
|
4366
5411
|
info(`the contract derives ${url}/t/${resolveChain(CHAIN).id}/{address}/{tokenId} from that base.`);
|
|
4367
|
-
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>`));
|
|
4368
5414
|
console.log('');
|
|
4369
5415
|
}
|
|
4370
5416
|
// ── deploy-effects: scaffold the render runner (the resolver's browser-bearing companion) ─────
|
|
@@ -4518,12 +5564,15 @@ function keepAlive() {
|
|
|
4518
5564
|
// ── per-command usage (printed by `<cmd> --help` / `abx help <cmd>`; read-only) ──
|
|
4519
5565
|
const COMMAND_HELP = {
|
|
4520
5566
|
skill: `
|
|
4521
|
-
${bold('abx skill')} ${dim('— install the abx agent skill so your coding agent can drive abx')}
|
|
4522
|
-
${g('abx skill install')} copy the skill
|
|
4523
|
-
${
|
|
4524
|
-
${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)
|
|
4525
5573
|
${g('abx skill path')} print the absolute path to the bundled skill (for ${g('npx skills add <path>')} or manual copy)
|
|
4526
|
-
${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')}`,
|
|
4527
5576
|
deploy: `
|
|
4528
5577
|
${bold('abx deploy')} ${dim('— deploy + index a 1/1 (no server). Sends a tx in the chosen lane.')}
|
|
4529
5578
|
--image <path> custody your own image (png · jpg · gif · svg · webp); else generative demo art
|
|
@@ -4580,6 +5629,20 @@ const COMMAND_HELP = {
|
|
|
4580
5629
|
${g('abx deploy-series')} --dir ./photos --name "My Series" --symbol MS --onchain-uri --backend ipfs --mint-all --sign
|
|
4581
5630
|
${dim('# tiny SVGs → fully on-chain (no storage at all):')}
|
|
4582
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`,
|
|
4583
5646
|
inspect: `
|
|
4584
5647
|
${bold('abx inspect')} <script.js> ${dim('— static analysis of a generative script + a lane recommendation. Read-only; the script is never executed.')}
|
|
4585
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)
|
|
@@ -4646,8 +5709,9 @@ const COMMAND_HELP = {
|
|
|
4646
5709
|
No tokenId → sweeps all minted tokens; pass ids (${g('0 1 2')}) to target specific tokens.
|
|
4647
5710
|
${g('--force')} RE-RENDER even when the still already exists — the fix for a bad / blank / timed-out capture
|
|
4648
5711
|
(the art is otherwise deterministic, so a plain render idempotent-skips an existing still). Overwrites it (+ republishes on --remote).
|
|
4649
|
-
${g('--remote [url]')}
|
|
4650
|
-
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')}).
|
|
4651
5715
|
--effects-url <url> enqueue on a running effect-runner service instead of rendering inline (else ${g('ABX_EFFECTS_URL')})
|
|
4652
5716
|
${dim('Inline (no --remote) renders on THIS machine (needs `npx playwright install chromium` once), pointing Chromium at the live')}
|
|
4653
5717
|
${dim(`view of ${g('ABX_RESOLVER_URL')} (else your configured base / local ${g('abx serve')}), and stores to the resolved backend.`)}
|
|
@@ -4663,8 +5727,8 @@ const COMMAND_HELP = {
|
|
|
4663
5727
|
--port <n> HTTP port (default ${g('ABX_EFFECTS_PORT')} / 8788) — ${g('POST /notify')} enqueues (watcher lane) · ${g('POST /run')} sweeps synchronously (command lane)
|
|
4664
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
|
|
4665
5729
|
--concurrency <n> parallel renders while draining (default ${g('ABX_EFFECTS_CONCURRENCY')} / 1 — Chromium is heavy; raise deliberately)
|
|
4666
|
-
${dim('Co-located with a local `abx serve` (same store) → no tokens needed. Against a
|
|
4667
|
-
${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.')}
|
|
4668
5732
|
${dim('To HOST the runner (fly/docker), use `abx deploy-effects`.')}`,
|
|
4669
5733
|
'configure-param': `
|
|
4670
5734
|
${bold('abx configure-param')} <address> <tokenId|-> <key> <value> ${dim('— set a PostParam (typed, canonical encode). Sends a tx.')}
|
|
@@ -4675,7 +5739,7 @@ const COMMAND_HELP = {
|
|
|
4675
5739
|
like ${g('params.keys')} / ${g('display.gateway')}. ≤31 printable-ASCII chars ride as a literal bytes32; longer takes the data path.
|
|
4676
5740
|
After a token write, a project whose ${g('params.keys')} doesn't list the key gets a one-line fix suggestion.
|
|
4677
5741
|
--file <path> read the value from a file (String / Bytes payloads)
|
|
4678
|
-
${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
|
|
4679
5743
|
the resolver's effect runner, so the thumbnail re-renders without waiting. Usually OPTIONAL now: a
|
|
4680
5744
|
resolver running the chain watcher (the ${g('abx serve')} default) sees the change on its next poll (~12s)
|
|
4681
5745
|
and auto-re-renders on its own. Keep --remote for a watcher-disabled resolver or when seconds matter.
|
|
@@ -4712,7 +5776,8 @@ const COMMAND_HELP = {
|
|
|
4712
5776
|
${bold('abx state')} <address> ${dim('— read-only, on-chain operational snapshot (no tx, no local index).')}
|
|
4713
5777
|
Shows owner · supply (minted / max, nextTokenId) · paused · minter · primary payee · royalty · renderer.
|
|
4714
5778
|
Series-only fields are shown for a Series; a 1/1 shows just supply + royalty + renderer.
|
|
4715
|
-
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`.')}`,
|
|
4716
5781
|
predict: `
|
|
4717
5782
|
${bold('abx predict')} ${dim('— pre-compute a deploy address (read-only).')}
|
|
4718
5783
|
--salt 0x..<64hex> a fixed / vanity salt --for 0x.. reserve to a deployer --factory 0x..`,
|
|
@@ -4826,18 +5891,31 @@ const COMMAND_HELP = {
|
|
|
4826
5891
|
${bold('abx set-max-invocations')} <address> --max <N> ${dim('— LOWER the supply cap (Series). Sends a tx.')}
|
|
4827
5892
|
--max <N> the new cap — MONOTONIC: can only DECREASE, and never below what's already minted (else it reverts)`,
|
|
4828
5893
|
doctor: `
|
|
4829
|
-
${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.')}
|
|
4830
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.')}
|
|
4831
5900
|
${dim('a missing signing key is NOT fatal — the wallet lane (`--sign`) needs no key in `.env`.')}`,
|
|
4832
5901
|
status: `
|
|
4833
|
-
${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`.')}`,
|
|
4834
5909
|
storage: `
|
|
4835
5910
|
${bold('abx storage')} <show|upload|balance|topup|backup-key> ${dim('— inspect / operate byte custody. Mostly read-only.')}
|
|
4836
5911
|
${g('show')} the resolved backend (fs | cloud | ipfs | arweave) + where each value came from
|
|
4837
5912
|
${g('upload')} <path> upload ONE file → prints its locator (the URI ${g('abx attach')} wants) [--backend …] [--dry-run]
|
|
4838
5913
|
${g('balance')} · ${g('topup')} --usd <n> Turbo (arweave) upload credits · ${g('backup-key')} --out <path> copy the managed key`,
|
|
4839
5914
|
demo: `
|
|
4840
|
-
${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.')}`,
|
|
4841
5919
|
minter: `
|
|
4842
5920
|
${bold('abx minter')} <configure|show|buy> <token> ${dim('— sell a Series via the shared fixed-price minter (Minter spine).')}
|
|
4843
5921
|
${g('configure')} <token> (--price <eth> | --price-raw <units>) --allocation <n> [--erc20 0x..] ${dim('(token-owner only)')}
|
|
@@ -4852,13 +5930,20 @@ const COMMAND_HELP = {
|
|
|
4852
5930
|
${bold('abx add')} <address> ${dim('— register + index a project. Also edits off-chain display metadata + traits.')}
|
|
4853
5931
|
--from-block <n> --factory 0x.. --label "<s>" --description "<s>" --external-url <url> [--full] [--yes]
|
|
4854
5932
|
--traits "K=V; K2=V2" / --attributes <file.json> set the off-chain operator traits (on-chain attributes always win)
|
|
4855
|
-
${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.')}`,
|
|
4856
5940
|
index: `
|
|
4857
5941
|
${bold('abx index')} [<address>] ${dim('— re-index from chain (read-only). Incremental by default.')}
|
|
4858
|
-
${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`,
|
|
4859
5944
|
verify: `
|
|
4860
5945
|
${bold('abx verify')} <address> ${dim('— re-hash the served bytes against the on-chain commitment (read-only; no server).')}
|
|
4861
|
-
${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
|
|
4862
5947
|
or 200 bytes), so it accounts for a render PUBLISHED to that resolver. ${bold('Use this for a code project whose')}
|
|
4863
5948
|
${bold('renders were published to a hosted resolver')} — a plain \`abx verify\` only checks THIS machine's store and will
|
|
4864
5949
|
report a false placeholder for a render that lives on the resolver.
|
|
@@ -4868,11 +5953,21 @@ const COMMAND_HELP = {
|
|
|
4868
5953
|
${bold('abx tokenuri')} <address> [--token <id>] ${dim('— read tokenURI(id) straight from the contract on-chain + decode the JSON (read-only; no server).')}
|
|
4869
5954
|
${dim('The proof a fully on-chain token self-resolves: any RPC returns the renderer-assembled metadata. Default token 0.')}`,
|
|
4870
5955
|
forget: `
|
|
4871
|
-
${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).`,
|
|
4872
5966
|
migrate: `
|
|
4873
5967
|
${bold('abx migrate')} <address> ${dim('— move a contract\'s OFF-CHAIN state to another resolver (read-only on both; no cutover).')}
|
|
4874
|
-
--from <url>
|
|
4875
|
-
--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
|
|
4876
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
|
|
4877
5972
|
--backend <id> durable custody for re-pinning source-only images (ipfs · arweave); else config/env
|
|
4878
5973
|
${dim('replays on-chain state on the dest from chain, then bridges description / external_url / off-chain')}
|
|
@@ -4922,7 +6017,8 @@ function help() {
|
|
|
4922
6017
|
--minter 0x.. · --primary-payee 0x.. · --unpaused · ${g('--dry-run')} · see ${g('abx help deploy-series')}
|
|
4923
6018
|
${g('abx predict')} pre-compute a deploy address flags: [--salt 0x..] [--for 0x..] [--factory 0x..]
|
|
4924
6019
|
${g('abx add')} <address> register + index a project this node didn't deploy
|
|
4925
|
-
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
|
|
4926
6022
|
${g('abx index')} [<address>] re-index from chain (incremental by default; ${g('--full')} forces a replay from deploy)
|
|
4927
6023
|
${g('abx verify')} <addr> re-hash served bytes vs the on-chain commitment (no server needed)
|
|
4928
6024
|
${g('abx tokenuri')} <addr> read tokenURI(0) on-chain + decode the JSON (proof a self-resolving token works)
|
|
@@ -4931,7 +6027,9 @@ function help() {
|
|
|
4931
6027
|
project + notify the effects layer on change (${g('ABX_WATCH_INTERVAL_MS')}; 0 = off)
|
|
4932
6028
|
|
|
4933
6029
|
${bold('code / generative projects')} ${dim('— a program is the content; output is a function of live on-chain state')}
|
|
4934
|
-
${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
|
|
4935
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)
|
|
4936
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)
|
|
4937
6035
|
${bold('--public-base-url <url>')} OR ${bold('--onchain-uri')} · --schema key:Type:Auth · ${g('--dep')} name@version|0x.. (ordered; index 0 = the runtime) ·
|
|
@@ -4977,11 +6075,13 @@ function help() {
|
|
|
4977
6075
|
${g('abx storage')} topup buy Turbo credits by card --usd <n> (one-time; <100 KB is always free)
|
|
4978
6076
|
${g('abx storage')} backup-key copy the managed Turbo/Arweave key (holds credits) to a safe path --out <path>
|
|
4979
6077
|
${g('abx forget')} <address> drop a project's local registration + projection (on-chain untouched)
|
|
4980
|
-
${g('abx status')}
|
|
6078
|
+
${g('abx status')} [address] indexing status: lifecycle + freshness (bare = this node; ${g('--remote')} [name] = a service; ${g('--watch')} to tail)
|
|
4981
6079
|
${g('abx doctor')} check environment (key, RPC, balance, factory, storage)
|
|
4982
|
-
${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
|
|
6081
|
+
${g('abx version')} print the installed CLI version
|
|
4983
6082
|
|
|
4984
6083
|
${dim('Run')} ${g('abx <command> --help')} ${dim('for per-command usage. --help / -h never executes — it only prints usage.')}
|
|
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('.')}
|
|
4985
6085
|
`);
|
|
4986
6086
|
}
|
|
4987
6087
|
/** Validate an explicit --salt (a 32-byte hex). Undefined when absent. */
|
|
@@ -4999,8 +6099,13 @@ function parseSaltFlag(raw) {
|
|
|
4999
6099
|
// ── skill: install the abx agent skill into the user's coding agent ───────────────────────
|
|
5000
6100
|
// The skill is the agentic half of the toolkit — the CLI is only useful once an agent knows how
|
|
5001
6101
|
// to drive it. It ships bundled inside the published package (packages/cli/skill/, created at
|
|
5002
|
-
// prepack); in dev the same command reads the canonical copy straight from the repo.
|
|
5003
|
-
//
|
|
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`.
|
|
5004
6109
|
/** Locate the skill folder: the bundled copy beside the compiled CLI (published), else the
|
|
5005
6110
|
* canonical repo copy (dev). Returns null if neither is present. */
|
|
5006
6111
|
function resolveBundledSkill() {
|
|
@@ -5064,29 +6169,86 @@ function cmdScaffoldRenderer(rest, flags) {
|
|
|
5064
6169
|
console.log(` ${g('abx deploy-code --image-renderer <MyRenderer> --attributes-renderer <MyTraits> --onchain-uri --schema palette:HexColor:TokenOwner --name "…" --symbol …')}`);
|
|
5065
6170
|
info(`full walkthrough: ${bold(`${basename(dir)}/README.md`)} · interface + invariants: https://abx.docs.artblocks.io/protocol/renderers/`);
|
|
5066
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
|
+
}
|
|
5067
6226
|
async function cmdSkill(rest, flags) {
|
|
5068
6227
|
const sub = rest[0] ?? 'install';
|
|
5069
6228
|
const src = resolveBundledSkill();
|
|
5070
6229
|
if (!src) {
|
|
5071
6230
|
throw new Error('bundled skill not found (expected <pkg>/skill or .claude/skills/abx-self-host). ' +
|
|
5072
|
-
'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');
|
|
5073
6232
|
}
|
|
5074
6233
|
if (sub === 'path') {
|
|
5075
6234
|
console.log(src);
|
|
5076
6235
|
return;
|
|
5077
6236
|
}
|
|
5078
6237
|
if (sub === 'install') {
|
|
5079
|
-
const
|
|
5080
|
-
|
|
5081
|
-
|
|
5082
|
-
|
|
5083
|
-
|
|
5084
|
-
|
|
5085
|
-
|
|
5086
|
-
|
|
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;
|
|
6247
|
+
}
|
|
6248
|
+
installSkillToDefaults(src, { global: flags.global !== undefined, agent: flags.agent });
|
|
5087
6249
|
return;
|
|
5088
6250
|
}
|
|
5089
|
-
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>]');
|
|
5090
6252
|
}
|
|
5091
6253
|
main().catch((err) => {
|
|
5092
6254
|
// Keep the actionable message; strip viem's verbose boilerplate trailer (Docs:/Version: lines) so
|