@artblocks/abx-cli 0.1.0-alpha.3 → 0.1.0-alpha.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.js CHANGED
@@ -14,6 +14,7 @@
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
20
  * abx add <address> register + index a project (--remote: on a hosted resolver, not this machine)
@@ -42,7 +43,7 @@
42
43
  * the tx for a multisig / offline signer). The agent picks the lane; the CLI signs.
43
44
  */
44
45
  import { appendFileSync, copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
45
- import { randomBytes } from 'node:crypto';
46
+ import { createHash, randomBytes } from 'node:crypto';
46
47
  import { basename, extname, join as joinPath, resolve as resolvePath } from 'node:path';
47
48
  import { fileURLToPath } from 'node:url';
48
49
  import { homedir } from 'node:os';
@@ -57,6 +58,7 @@ import { composeParamsKeys, expectedChainComplete, hasOnChainUriLane, onchainUri
57
58
  import { parseFlags, unknownFlags } from './flags.js';
58
59
  import { AGENT_SKILL_PARENTS, checkForCliUpdate, compareVersions, installedSkillVersions, readCliVersion, readSkillVersion, SKILL_DIR_NAME, } from './update-check.js';
59
60
  import { analyzeScript, recommendLane } from './inspect.js';
61
+ import { previewConfigFromFlags, previewDepTags, shootPreview, startPreviewServer, DEFAULT_PREVIEW_PORT, PREVIEW_FLAGS } from './preview.js';
60
62
  import { parseSchemaSpecs, describeSchema } from './schema.js';
61
63
  import { parseSeriesTraits, looksPerTokenAttributes, parseSeriesTraitsById } from './series-traits.js';
62
64
  /** The `--schema` type/auth/format catalog, shown wherever the CLI nudges `--schema`. Kept accurate
@@ -166,6 +168,7 @@ async function main() {
166
168
  case 'deploy-series': return cmdDeploySeries(flags);
167
169
  case 'deploy-code': return cmdDeployCode(flags);
168
170
  case 'inspect': return cmdInspect(rest[0], flags);
171
+ case 'preview': return cmdPreview(flags);
169
172
  case 'scaffold-renderer': return cmdScaffoldRenderer(rest, flags);
170
173
  case 'predict': return cmdPredict(flags);
171
174
  case 'add': return cmdAdd(rest[0], flags);
@@ -983,8 +986,38 @@ async function cmdDeploy(flags, serveAfter) {
983
986
  // editable later via `abx add --traits`); `--traits-onchain` inlines them on-chain (durable,
984
987
  // lockable) — mirrors the description model. On-chain wins if both ever set the same trait.
985
988
  const traits = parseDeployTraits(flags);
986
- console.log(bold(`\n ABX Self-Host Toolkit — ${serveAfter ? 'demo' : 'deploy'}\n ${dim('a project, served from chain alone')}`));
987
- const lane = serveAfter ? 'send' : laneFromFlags(flags); // demo is always hot (one-shot)
989
+ console.log(serveAfter
990
+ ? bold(`\n ABX guided first run\n ${dim('a token deployed, then rebuilt from the chain alone')}`)
991
+ : bold(`\n ABX Self-Host Toolkit — deploy\n ${dim('a project, served from chain alone')}`));
992
+ // Signing lane. `demo` used to hard-force the hot lane, which SILENTLY dropped `--sign`: with no
993
+ // env key it died confusingly, and WITH one it signed from that key while the operator had asked
994
+ // for their browser wallet — a signing choke point that ignored the lane it was handed. The demo
995
+ // deploy is a single tx, so the wallet lane works here exactly as it does for `deploy`.
996
+ const lane = laneFromFlags(flags);
997
+ // The cold lane only PRINTS a tx; demo's whole point is to index + serve what it just deployed,
998
+ // and there is nothing to index until someone broadcasts. Refuse the combo instead of doing
999
+ // half the job — `abx deploy --unsigned` is the command for that lane.
1000
+ if (serveAfter && lane === 'unsigned') {
1001
+ throw new Error('`abx demo --unsigned` can\'t work: the cold lane only prints a transaction, and the demo ' +
1002
+ 'indexes + serves the contract it just deployed. Use `abx demo` (hot key) or `abx demo --sign` ' +
1003
+ '(browser wallet) — or `abx deploy --unsigned` if you only want the raw tx.');
1004
+ }
1005
+ // Same shape for --dry-run: previewing sends nothing, so there is nothing to serve.
1006
+ if (serveAfter && flags['dry-run']) {
1007
+ throw new Error('`abx demo --dry-run` can\'t work: a dry run sends nothing, and the demo indexes + serves what ' +
1008
+ 'it deployed. Use `abx deploy --dry-run` to preview a 1/1 deploy without sending.');
1009
+ }
1010
+ // Port preflight BEFORE anything irreversible. The demo ends by serving, and `listen` used to be
1011
+ // the first thing to discover the port was taken — after the deploy tx had already been signed and
1012
+ // paid for, so a second `abx demo` (a very normal thing to try) spent gas and then died with a raw
1013
+ // Node EADDRINUSE stack trace. Check first, and name the fix.
1014
+ if (serveAfter) {
1015
+ const wanted = Number(flags.port ?? process.env.ABX_PORT ?? DEFAULT_PORT);
1016
+ if (await portInUse(wanted)) {
1017
+ throw new Error(`port ${wanted} is already in use — probably an \`abx demo\`/\`abx serve\` still running in another terminal.\n` +
1018
+ ` Nothing was deployed. Stop that one (Ctrl-C), or run this on another port: \`abx demo --port ${wanted + 1}\`.`);
1019
+ }
1020
+ }
988
1021
  const dryRun = !serveAfter && !!flags['dry-run']; // preview only — no send, no custody, no factory deploy
989
1022
  const publicClient = makePublicClient({ chainKey: CHAIN });
990
1023
  // Verify the RPC really is CHAIN before any send (factory/renderer/staging/deploy). A dry run
@@ -1030,6 +1063,12 @@ async function cmdDeploy(flags, serveAfter) {
1030
1063
  else {
1031
1064
  factory = await ensureFactory(flags.factory, !!flags['bootstrap-factory']);
1032
1065
  }
1066
+ // The demo explains what it just did; `deploy` stays terse. The authenticity point is the least
1067
+ // obvious thing about ABX and the easiest to get wrong, so it's worth a sentence here.
1068
+ if (serveAfter) {
1069
+ info(dim('why it matters: a contract counts as ABX because THIS factory made it (isAbxClone),'));
1070
+ info(dim('not because it emitted an event claiming to be — anyone can emit that event.'));
1071
+ }
1033
1072
  // --onchain-uri: resolve tokenURI/contractURI fully on-chain via the canonical renderer
1034
1073
  // (the JSON is assembled from on-chain fields → no resolver needed, ever). The off-chain
1035
1074
  // URLs are still baked as a fallback if the renderer is later cleared.
@@ -1056,7 +1095,13 @@ async function cmdDeploy(flags, serveAfter) {
1056
1095
  renderer = await ensureRenderer(flags.renderer);
1057
1096
  }
1058
1097
  }
1059
- step(`Deploy a ${DIMENSIONS[dimension].label} (--type ${dimension}) to ${CHAIN}`);
1098
+ step(serveAfter ? `Deploy — one transaction to ${CHAIN}` : `Deploy a ${DIMENSIONS[dimension].label} (--type ${dimension}) to ${CHAIN}`);
1099
+ // The custody split is the single most useful thing to understand about an ABX token, and it's
1100
+ // invisible unless someone says it out loud. Demo only — `deploy` prints the same facts per-field.
1101
+ if (serveAfter) {
1102
+ info(dim(`on chain: name · symbol · royalty · a tokenURI base · a keccak256 commitment to the image`));
1103
+ info(dim(`NOT on chain: the image bytes themselves — this node serves them, the chain pins their hash`));
1104
+ }
1060
1105
  // Off-chain custody (no renderer) bakes the resolver URL straight into the on-chain
1061
1106
  // tokenURI/contractURI at deploy. A localhost / loopback URL there resolves for NO ONE —
1062
1107
  // not marketplaces, not wallets, not even your own browser unless `abx serve` is running —
@@ -1354,10 +1399,13 @@ async function cmdDeploy(flags, serveAfter) {
1354
1399
  attributes: offChainTraits,
1355
1400
  };
1356
1401
  indexer.register(baseReg);
1357
- const { state, elapsedMs } = await indexer.reindex(clone);
1358
- ok(`reconstructed ${state.eventCount} events in ${elapsedMs}ms — no provider involved`);
1402
+ const { state, elapsedMs } = await reindexAfterDeploy(indexer, clone);
1403
+ if (state.eventCount > 0)
1404
+ ok(`reconstructed ${state.eventCount} events in ${elapsedMs}ms — no provider involved`);
1359
1405
  info(`name "${state.name}" · owner ${state.owner} · canonical: ${state.isCanonical ? 'yes (factory-verified)' : 'unverified'}`);
1360
1406
  info(`extensions: ${state.extensions.map((e) => e.name).join(', ') || 'none'}`);
1407
+ if (serveAfter)
1408
+ walkthroughSpine(state);
1361
1409
  // For off-chain custody (image committed as keccak256), resolve the durable locator (ipfs://…)
1362
1410
  // from this machine's content index and store it on the registration — so the LOCAL resolver
1363
1411
  // points `image` at IPFS, and `abx add --remote` can ship it to a hosted one (the localhost-image fix).
@@ -1406,8 +1454,17 @@ async function cmdDeploy(flags, serveAfter) {
1406
1454
  }
1407
1455
  return;
1408
1456
  }
1409
- step('Serve the token API + dashboard');
1457
+ // The demo's teaching sections — see the walkthrough helpers. `deploy` skips them: someone
1458
+ // shipping real work doesn't need their projection deleted to make a point.
1459
+ if (serveAfter)
1460
+ await walkthroughRebuild(indexer, clone, state);
1461
+ // Start the server BEFORE the read-back step (which fetches the served metadata over HTTP), but
1462
+ // print the serve banner after it — otherwise the "Serve" step header lands with nothing under it
1463
+ // while the read-back prints below, which reads like the step failed.
1410
1464
  const { url } = await startTokenApiServer({ indexer, port, baseUrl, storage: resolveBackend(storageOptions()) });
1465
+ if (serveAfter)
1466
+ await walkthroughReadBack(state, url);
1467
+ step('Serve the token API + dashboard');
1411
1468
  printServing(url, clone);
1412
1469
  keepAlive();
1413
1470
  }
@@ -1878,8 +1935,9 @@ async function cmdDeploySeries(flags) {
1878
1935
  tokenAttributes: offChainTokenTraits,
1879
1936
  };
1880
1937
  indexer.register(baseReg);
1881
- const { state, elapsedMs } = await indexer.reindex(clone);
1882
- ok(`reconstructed ${state.eventCount} events in ${elapsedMs}ms — ${state.tokens.length} token(s), max ${state.maxInvocations}`);
1938
+ const { state, elapsedMs } = await reindexAfterDeploy(indexer, clone);
1939
+ if (state.eventCount > 0)
1940
+ ok(`reconstructed ${state.eventCount} events in ${elapsedMs}ms — ${state.tokens.length} token(s), max ${state.maxInvocations}`);
1883
1941
  info(`extensions: ${state.extensions.map((e) => e.name).join(', ') || 'none'}`);
1884
1942
  // Off-chain custody: bridge each token's keccak → durable locator so the resolver (local and,
1885
1943
  // via `abx add --remote`, a hosted one) points images off this node.
@@ -2143,7 +2201,11 @@ async function cmdAdd(address, flags) {
2143
2201
  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.`);
2144
2202
  }
2145
2203
  catch { /* advisory only — the real scan still runs */ }
2146
- const { state, elapsedMs } = await indexer.reindex(address);
2204
+ // Don't accept a zero here either: `deploy-code` finishes through this command, so this IS the
2205
+ // post-deploy index for a code project — and a real ABX clone always emits a spine (its extension
2206
+ // registrations at minimum), so 0 events means the RPC hasn't served the logs yet, not that the
2207
+ // project is empty. See reindexAfterDeploy.
2208
+ const { state, elapsedMs } = await reindexAfterDeploy(indexer, address);
2147
2209
  // Resolve durable locators for off-chain-by-hash content from this machine's index.
2148
2210
  const locators = await collectContentLocators(state, resolveBackend(storageOptions(storageOverrides(flags))));
2149
2211
  if (Object.keys(locators).length) {
@@ -2160,7 +2222,14 @@ async function cmdAdd(address, flags) {
2160
2222
  contentLocators: JSON.stringify(locators),
2161
2223
  });
2162
2224
  }
2163
- ok(`registered + indexed ${state.name ?? address} LOCALLY (this machine): ${state.eventCount} events in ${elapsedMs}ms`);
2225
+ // A on 0 events is the lie that produced an empty dashboard; reindexAfterDeploy has already
2226
+ // explained the failure and named the recovery command, so don't stamp it as success too.
2227
+ if (state.eventCount > 0) {
2228
+ ok(`registered + indexed ${state.name ?? address} LOCALLY (this machine): ${state.eventCount} events in ${elapsedMs}ms`);
2229
+ }
2230
+ else {
2231
+ warn(`registered ${state.name ?? address}, but with NO reconstructed state — it will serve empty until the index succeeds.`);
2232
+ }
2164
2233
  info(`serve it from here with ${bold('abx serve')} — or push it to a hosted resolver with ${bold('abx add ' + address + ' --remote')}`);
2165
2234
  }
2166
2235
  /** The code-project trust anchor: use the canonical factory, else deploy one (a sandbox /
@@ -2227,6 +2296,230 @@ async function ensureSeedSource(publicClient) {
2227
2296
  * Schemas: --schema key:Type:Auth[,key:Type:Auth…] (e.g. palette:HexColor:TokenOwner).
2228
2297
  * Seeds: canonical randomizer by default; --no-seed opts out.
2229
2298
  */
2299
+ // ── the demo walkthrough: teaching sections, demo-only ────────────────────────
2300
+ //
2301
+ // `abx demo` is a TEACHING command, not a shortcut — the docs point a first-time reader here to
2302
+ // learn what the toolkit does on their behalf. Its old form asserted the interesting claims
2303
+ // ("reconstructed 9 events — no provider involved") without ever showing them, which made it a
2304
+ // smoke test wearing a demo's clothes. These sections demonstrate instead: print the spine the
2305
+ // chain now holds, throw the local projection away and rebuild it, then read the token back the way
2306
+ // a marketplace would. They run only for `demo` (never `deploy`), and never pause — an agent or CI
2307
+ // run has to behave identically.
2308
+ /**
2309
+ * The deterministic part of a projection: everything that is a pure function of the chain.
2310
+ * Deliberately EXCLUDES `reconstructedAt`, `rpcUrl` and `toBlock` — a timestamp, the endpoint that
2311
+ * happened to answer, and the head at scan time all legitimately differ between two replays, so
2312
+ * folding them in would make the rebuild proof fail for reasons that aren't about correctness.
2313
+ */
2314
+ function projectionFingerprint(s) {
2315
+ const canonical = {
2316
+ address: s.address.toLowerCase(),
2317
+ name: s.name,
2318
+ symbol: s.symbol,
2319
+ owner: s.owner?.toLowerCase() ?? null,
2320
+ isCanonical: s.isCanonical,
2321
+ deployBlock: s.deployBlock,
2322
+ eventCount: s.eventCount,
2323
+ royalty: s.royalty ? { bps: s.royalty.bps, receiver: s.royalty.receiver.toLowerCase() } : null,
2324
+ extensions: s.extensions.map((e) => e.name).sort(),
2325
+ collectionFields: s.collectionFields.map((f) => `${f.field}=${f.value}`).sort(),
2326
+ tokens: s.tokens.map((t) => ({ id: t.tokenId, minted: t.minted, owner: t.owner?.toLowerCase() ?? null })),
2327
+ events: s.events.map((e) => `${e.blockNumber}:${e.logIndex}:${e.name}`),
2328
+ };
2329
+ return createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
2330
+ }
2331
+ /**
2332
+ * Print the reconstructed spine — the point being that this list IS the database. Rendered inside
2333
+ * the index step (no header of its own: "Index it — replay the event spine" immediately followed by
2334
+ * a separate "The event spine" step read as a stutter).
2335
+ */
2336
+ function walkthroughSpine(state) {
2337
+ if (state.events.length === 0) {
2338
+ warn('no events to show (the index came back empty — see the recovery hint above).');
2339
+ return;
2340
+ }
2341
+ const width = Math.max(...state.events.map((e) => e.name.length));
2342
+ state.events.forEach((e, i) => {
2343
+ // ERC vs ABX register: standard ERC-721 events a marketplace already understands, versus ABX's
2344
+ // own. Worth surfacing — it's why an ABX token indexes fine on tools that know nothing about ABX.
2345
+ const reg = e.register === 2 ? p('ABX') : dim('ERC');
2346
+ console.log(` ${dim(`#${String(i + 1).padStart(2)}`)} ${reg} ${e.name.padEnd(width)} ${dim(e.what)}`);
2347
+ });
2348
+ console.log(` ${dim('→')} those ${bold(String(state.events.length))} rows are the entire database — ` +
2349
+ dim('no row in a server anywhere is authoritative.'));
2350
+ console.log(` ${p('ABX')} ${dim('= a native ABX event')} ${dim('ERC')} ${dim('= plain ERC-721/7572, so wallets and marketplaces that have never heard of ABX still index this token.')}`);
2351
+ }
2352
+ /**
2353
+ * [4] The claim, demonstrated: delete the local projection and rebuild it from the chain.
2354
+ *
2355
+ * This is the one step that can't be faked by good output — it drops the projection for real
2356
+ * (registration kept), confirms it's gone, replays from the deploy block, and compares a
2357
+ * fingerprint of everything chain-derived. If ABX's premise is wrong, this step fails loudly.
2358
+ */
2359
+ async function walkthroughRebuild(indexer, address, before) {
2360
+ step('Prove it — rebuild from nothing');
2361
+ const fpBefore = projectionFingerprint(before);
2362
+ indexer.dropProjection(address);
2363
+ const gone = indexer.getProject(address) === null;
2364
+ console.log(` ${dim('deleting this machine\'s projection …')} ${gone ? g('✓ gone') : `${c.orange}⚠ still present${c.reset}`}`);
2365
+ const { state: after, elapsedMs } = await indexer.reindex(address, { full: true });
2366
+ const fpAfter = projectionFingerprint(after);
2367
+ console.log(` ${dim(`replaying from block ${after.deployBlock ?? after.fromBlock} …`)} ` +
2368
+ `${g('✓')} ${after.eventCount} events in ${elapsedMs}ms`);
2369
+ if (fpBefore === fpAfter) {
2370
+ ok(`identical state ${dim(`(sha256 ${fpAfter.slice(0, 12)}… over every chain-derived field)`)}`);
2371
+ info('no provider, no backup, no API key — an RPC endpoint and the chain were enough.');
2372
+ }
2373
+ else {
2374
+ warn('the rebuilt state does NOT match what we just had — that is a real bug, please report it.');
2375
+ info(`before ${fpBefore.slice(0, 16)}… · after ${fpAfter.slice(0, 16)}…`);
2376
+ }
2377
+ }
2378
+ /** [5] Read the token back the way a marketplace would, and check the bytes against the chain. */
2379
+ async function walkthroughReadBack(state, baseUrl) {
2380
+ step('Read it back the way a marketplace would');
2381
+ const token = state.tokens[0];
2382
+ if (!token?.minted) {
2383
+ info('no minted token to read yet.');
2384
+ return;
2385
+ }
2386
+ const uri = token.tokenURI;
2387
+ info(`tokenURI(0) ${dim('on chain →')} ${uri ?? dim('(none)')}`);
2388
+ // The demo's default is off-chain custody: the CHAIN holds a keccak256 commitment to the image and
2389
+ // a URI base; this NODE holds the bytes. That split is the thing worth understanding, so name it
2390
+ // rather than implying the JSON came from the chain.
2391
+ info(dim('the chain stored a URI base + a keccak256 commitment; this node serves the bytes.'));
2392
+ try {
2393
+ const res = await fetch(`${baseUrl}/t/${resolveChain(CHAIN).id}/${state.address}/0`);
2394
+ const json = (await res.json());
2395
+ const shown = { name: json.name, image: json.image };
2396
+ console.log(` ${dim(JSON.stringify(shown))}`);
2397
+ if (Array.isArray(json.abx_provenance)) {
2398
+ info(dim(`every field is tagged with where it came from (abx_provenance: ${json.abx_provenance.length} entries)`));
2399
+ }
2400
+ }
2401
+ catch (e) {
2402
+ info(dim(`could not read the served metadata: ${e.message}`));
2403
+ }
2404
+ console.log(` ${dim('prove the bytes match the chain:')} ${bold(`abx verify ${state.address}`)}`);
2405
+ }
2406
+ /** Is a TCP port already bound on localhost? Used to preflight a serve BEFORE spending a tx. */
2407
+ async function portInUse(port) {
2408
+ const { createServer } = await import('node:net');
2409
+ return new Promise((resolve) => {
2410
+ const probe = createServer();
2411
+ probe.once('error', (e) => resolve(e.code === 'EADDRINUSE'));
2412
+ probe.once('listening', () => probe.close(() => resolve(false)));
2413
+ probe.listen(port);
2414
+ });
2415
+ }
2416
+ /**
2417
+ * Index a project we *just* deployed — and don't believe a zero.
2418
+ *
2419
+ * `eth_getLogs` is read-after-write inconsistent on load-balanced RPCs: `waitForTransactionReceipt`
2420
+ * resolves against a node that has the block, then the log query lands on one that doesn't yet, and
2421
+ * returns an empty set for a block we KNOW contains our deploy. The old code took that single read at
2422
+ * face value, printed `✓ reconstructed 0 events`, stored the empty projection, and served an empty
2423
+ * dashboard — a first-run that looks like the toolkit simply doesn't work. It reproduced 100% of the
2424
+ * time on `https://sepolia.base.org`, which is the DEFAULT endpoint when there's no `.env`, i.e. the
2425
+ * documented first run was the broken path. The same block returned all 9 logs seconds later.
2426
+ *
2427
+ * We have the one thing that makes this checkable: we just minted, so the spine cannot be empty.
2428
+ * So verify instead of trusting — re-scan with backoff until events appear, and if they never do,
2429
+ * say so as a FAILURE with the recovery command rather than dressing a zero up as a ✓.
2430
+ */
2431
+ async function reindexAfterDeploy(indexer, address, opts = {}) {
2432
+ const attempts = opts.attempts ?? 6;
2433
+ const delayMs = opts.delayMs ?? 1500;
2434
+ let last = await indexer.reindex(address);
2435
+ for (let i = 1; i < attempts && last.state.eventCount === 0; i++) {
2436
+ if (i === 1) {
2437
+ info(dim("no events yet — the RPC hasn't served the logs for that block; re-scanning…"));
2438
+ }
2439
+ await new Promise((r) => setTimeout(r, delayMs));
2440
+ // `full: true` — the stored projection has 0 events, so there is no valid checkpoint to
2441
+ // resume from; a full replay from the deploy block is the only correct re-scan.
2442
+ last = await indexer.reindex(address, { full: true });
2443
+ }
2444
+ if (last.state.eventCount === 0) {
2445
+ warn(`the RPC still reports no logs for this project after ${attempts} tries — the deploy DID succeed ` +
2446
+ `(it's on chain), but this node can't reconstruct it yet.`);
2447
+ 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).')}`);
2448
+ }
2449
+ return last;
2450
+ }
2451
+ /**
2452
+ * `abx preview` — serve the program on localhost, live, for as long as the work is being made.
2453
+ *
2454
+ * The studio lane, and deliberately the FIRST thing to reach for on a code project: it renders the
2455
+ * same document the generator serves (real `abx.js`, real tokenData shape, real dependency tags)
2456
+ * with a synthetic seed, so a creator can refresh for new seeds, drive their PostParams from real
2457
+ * inputs, and watch an animated piece actually move — none of which a still-image sweep can show.
2458
+ * No chain, no key, no deploy. `--shoot` renders the same document headlessly for an agent that
2459
+ * can't open a browser.
2460
+ */
2461
+ async function cmdPreview(flags) {
2462
+ warnStrayFlags(flags, PREVIEW_FLAGS, 'preview');
2463
+ const cfg = previewConfigFromFlags(flags);
2464
+ const shootDir = flags.shoot && flags.shoot !== 'true' ? String(flags.shoot) : flags.shoot === 'true' ? 'abx-preview' : undefined;
2465
+ const count = Math.min(Math.max(Number(flags.count ?? 9) || 9, 1), 64);
2466
+ console.log(bold(`\n ABX Self-Host Toolkit — preview\n ${dim('the program, running locally — no chain, no deploy')}`));
2467
+ const { notes } = previewDepTags(cfg.deps);
2468
+ step('Program');
2469
+ 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)')}`);
2470
+ if (cfg.schemas.length)
2471
+ info(`params: ${cfg.schemas.map(describeSchema).join(' · ')}`);
2472
+ else
2473
+ info(dim('params: none declared — add --schema key:Type:Auth to drive them from the studio'));
2474
+ for (const n of notes)
2475
+ info(`dep ${n}`);
2476
+ // A raw on-chain dependency can't be fetched without a chain, so the preview would render a
2477
+ // sketch missing its runtime and look broken for the wrong reason. Say so rather than let them
2478
+ // debug their own art.
2479
+ if (cfg.deps.some((d) => d.display.startsWith('0x'))) {
2480
+ 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.');
2481
+ }
2482
+ const server = await startPreviewServer(cfg, shootDir ? 0 : Number(flags.port ?? DEFAULT_PREVIEW_PORT));
2483
+ if (shootDir) {
2484
+ step(`Render ${count} seeds headlessly`);
2485
+ try {
2486
+ const shots = await shootPreview(server.url, shootDir, count, {
2487
+ width: Number(flags.width ?? 1000) || 1000,
2488
+ timeoutMs: Number(flags['timeout-ms'] ?? 10_000) || 10_000,
2489
+ });
2490
+ ok(`${shots.length} frames → ${shootDir}/ ${dim('(traits in traits.json)')}`);
2491
+ for (const s of shots) {
2492
+ const t = s.traits && Object.keys(s.traits).length
2493
+ ? Object.entries(s.traits).map(([k, v]) => `${k} ${String(v)}`).join(' · ')
2494
+ : `${c.orange}no traits reported${c.reset}`;
2495
+ console.log(` ${dim(s.seed.slice(0, 10) + '…')} ${t}${s.done ? '' : dim(' (no abx.done())')}`);
2496
+ }
2497
+ const silent = shots.filter((s) => !s.traits || !Object.keys(s.traits).length).length;
2498
+ if (silent === shots.length) {
2499
+ warn('NO frame reported traits — `abx.traits({…})` is the only thing that becomes marketplace `attributes`. Verify with `abx inspect`.');
2500
+ }
2501
+ else if (shots.length > 1 && new Set(shots.map((s) => JSON.stringify(s.traits))).size === 1) {
2502
+ // Only meaningful when traits DID come back: identical values across seeds is the signature
2503
+ // of a sketch that never reads `abx.tokenData.seed` (prototyped on Math.random()), which
2504
+ // deploys as N visually identical tokens. Skipped when nothing reported at all — the
2505
+ // warning above already covers that, and firing both reads as noise.
2506
+ warn('every seed produced identical traits — check the sketch actually reads `abx.tokenData.seed` (the silent "all tokens the same" failure).');
2507
+ }
2508
+ }
2509
+ finally {
2510
+ await server.close();
2511
+ }
2512
+ return;
2513
+ }
2514
+ step('Studio');
2515
+ console.log(`\n ${g('●')} ${bold('preview')} ${server.url}`);
2516
+ console.log(` ${dim('studio ')}${server.url} ${dim('seed + params + live traits')}`);
2517
+ console.log(` ${dim('grid ')}${server.url}/grid ${dim('9 seeds at once, all live')}`);
2518
+ console.log(` ${dim('bare view ')}${server.url}/view ${dim('the generator document itself')}`);
2519
+ console.log(`\n ${dim('Edit the program and refresh — it is re-read from disk. Ctrl-C to stop.')}`);
2520
+ 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`);
2521
+ await new Promise(() => { }); // block like `serve` — the creator drives it
2522
+ }
2230
2523
  /**
2231
2524
  * `abx inspect <script.js>` — read a generative script and, WITHOUT executing it, report what it
2232
2525
  * needs (traits + their on-chain reproducibility, dependency hints, size → assembled-document size →
@@ -4284,7 +4577,13 @@ function printServing(url, address) {
4284
4577
  console.log(` ${dim('image ')}${url}/t/${cid}/${address}/0/image`);
4285
4578
  console.log(` ${dim('state API ')}${url}/api/project/${address}`);
4286
4579
  }
4287
- console.log(`\n ${dim('Open the dashboard, then hit “Re-index from chain” to watch state rebuild live.')}`);
4580
+ // The dashboard is READ-ONLY: re-index/verify are admin actions that 404 unless the node has an
4581
+ // ABX_RESOLVER_ADMIN_TOKEN, so there is no button to press. This line used to say "hit Re-index
4582
+ // from chain", which sent every first-run user hunting for a control that isn't there.
4583
+ console.log(`\n ${dim('The dashboard shows the event spine it replayed — that table IS the reconstruction.')}`);
4584
+ if (address) {
4585
+ 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.')}`);
4586
+ }
4288
4587
  console.log(` ${dim('Ctrl-C to stop.')}\n`);
4289
4588
  }
4290
4589
  // ── deploy-resolver: scaffold a hosted resolver for a provider the operator owns ──────────
@@ -4634,6 +4933,20 @@ const COMMAND_HELP = {
4634
4933
  ${g('abx deploy-series')} --dir ./photos --name "My Series" --symbol MS --onchain-uri --backend ipfs --mint-all --sign
4635
4934
  ${dim('# tiny SVGs → fully on-chain (no storage at all):')}
4636
4935
  ${g('abx deploy-series')} --dir ./svgs --name "My Series" --symbol MS --onchain-image --compress fastlz --mint-all --sign`,
4936
+ preview: `
4937
+ ${bold('abx preview')} (--script <file.js> | --code-dir <dir>) ${dim('— run the program on localhost, live. No chain, no key, no deploy.')}
4938
+ ${g('--schema key:Type:Auth')}[,…] declare PostParams so the studio gives you real inputs for them (e.g. palette:HexColor:TokenOwner)
4939
+ ${g('--dep <name@version>')}[,…] load a library the way the resolver would (built-in CDN map; on-chain refs can't be fetched offline)
4940
+ ${g('--port')} <n> studio port (default ${DEFAULT_PREVIEW_PORT}; the resolver's ${DEFAULT_PORT} stays free)
4941
+ ${g('--shoot')} <dir> render headlessly to PNGs + traits.json and EXIT ${dim('(for an agent that has no browser)')}
4942
+ ${g('--count')} <n> seeds to shoot / show in the grid (default 9) ${g('--width')} <px> ${g('--timeout-ms')} <n>
4943
+ Serves the ${bold('same document the generator serves')} — real ${g('abx.js')}, real tokenData shape, real dep tags — with a synthetic
4944
+ seed, so what you iterate on is what deploys. Routes: ${g('/')} studio (seed + params + live traits) · ${g('/grid')} N seeds at once,
4945
+ all live · ${g('/view')} the bare document. The program is re-read from disk per render, so ${bold('edit and refresh')} — no watcher.
4946
+ Unlike a still-image sweep this shows ${bold('animation')}, which is most of what a screenshot throws away.
4947
+ ${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).')}
4948
+ ${g('abx preview')} --script art.js --schema palette:HexColor:TokenOwner
4949
+ ${g('abx preview')} --script art.js --shoot ./frames --count 12`,
4637
4950
  inspect: `
4638
4951
  ${bold('abx inspect')} <script.js> ${dim('— static analysis of a generative script + a lane recommendation. Read-only; the script is never executed.')}
4639
4952
  ${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)
@@ -4891,7 +5204,10 @@ const COMMAND_HELP = {
4891
5204
  ${g('upload')} <path> upload ONE file → prints its locator (the URI ${g('abx attach')} wants) [--backend …] [--dry-run]
4892
5205
  ${g('balance')} · ${g('topup')} --usd <n> Turbo (arweave) upload credits · ${g('backup-key')} --out <path> copy the managed key`,
4893
5206
  demo: `
4894
- ${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.')}`,
5207
+ ${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.')}
5208
+ ${g('--sign')} ${dim('approve in your browser wallet instead of a hot env key (no key needed)')}
5209
+ ${g('--for')} <0x…> ${dim('pin who must connect on --sign (owner + royalty receiver + mint recipient)')}
5210
+ ${dim('--unsigned / --dry-run are refused here: both skip the broadcast, and the demo indexes + serves what it deployed.')}`,
4895
5211
  minter: `
4896
5212
  ${bold('abx minter')} <configure|show|buy> <token> ${dim('— sell a Series via the shared fixed-price minter (Minter spine).')}
4897
5213
  ${g('configure')} <token> (--price <eth> | --price-raw <units>) --allocation <n> [--erc20 0x..] ${dim('(token-owner only)')}
@@ -4985,7 +5301,9 @@ function help() {
4985
5301
  project + notify the effects layer on change (${g('ABX_WATCH_INTERVAL_MS')}; 0 = off)
4986
5302
 
4987
5303
  ${bold('code / generative projects')} ${dim('— a program is the content; output is a function of live on-chain state')}
4988
- ${g('abx inspect')} <script.js> ${bold('start here')} — static analysis (traits + on-chain reproducibility, deps, doc size RPC viability) + a lane recommendation
5304
+ ${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,
5305
+ drive your PostParams, watch it animate. Same document the generator serves. ${g('--shoot <dir>')} for headless frames. No chain.
5306
+ ${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
4989
5307
  ${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)
4990
5308
  ${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)
4991
5309
  ${bold('--public-base-url <url>')} OR ${bold('--onchain-uri')} · --schema key:Type:Auth · ${g('--dep')} name@version|0x.. (ordered; index 0 = the runtime) ·