@artblocks/abx-cli 0.1.0-alpha.3 → 0.1.0-alpha.30
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/CHANGELOG.md +3663 -0
- package/assets/renderer-scaffold/README.md +2 -2
- package/assets/renderer-scaffold/src/MyRenderer.sol +2 -2
- package/assets/renderer-scaffold/src/interfaces/IAbxFieldRenderer.sol +1 -1
- package/assets/renderer-scaffold/src/interfaces/IAbxParams.sol +17 -0
- package/assets/renderer-scaffold/test/MyRenderer.t.sol +51 -2
- package/dist/commands/deploy.d.ts +242 -0
- package/dist/commands/deploy.d.ts.map +1 -0
- package/dist/commands/deploy.js +4764 -0
- package/dist/commands/deploy.js.map +1 -0
- package/dist/commands/project.d.ts +257 -0
- package/dist/commands/project.d.ts.map +1 -0
- package/dist/commands/project.js +1299 -0
- package/dist/commands/project.js.map +1 -0
- package/dist/commands/reads.d.ts +64 -0
- package/dist/commands/reads.d.ts.map +1 -0
- package/dist/commands/reads.js +701 -0
- package/dist/commands/reads.js.map +1 -0
- package/dist/commands/scaffold.d.ts +73 -0
- package/dist/commands/scaffold.d.ts.map +1 -0
- package/dist/commands/scaffold.js +674 -0
- package/dist/commands/scaffold.js.map +1 -0
- package/dist/commands/service.d.ts +41 -0
- package/dist/commands/service.d.ts.map +1 -0
- package/dist/commands/service.js +618 -0
- package/dist/commands/service.js.map +1 -0
- package/dist/commands/storage.d.ts +51 -0
- package/dist/commands/storage.d.ts.map +1 -0
- package/dist/commands/storage.js +370 -0
- package/dist/commands/storage.js.map +1 -0
- package/dist/commands/submit-app.d.ts +58 -0
- package/dist/commands/submit-app.d.ts.map +1 -0
- package/dist/commands/submit-app.js +512 -0
- package/dist/commands/submit-app.js.map +1 -0
- package/dist/config.d.ts +90 -2
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +285 -11
- package/dist/config.js.map +1 -1
- package/dist/conformance.d.ts +31 -0
- package/dist/conformance.d.ts.map +1 -0
- package/dist/conformance.js +393 -0
- package/dist/conformance.js.map +1 -0
- package/dist/deps.d.ts +6 -39
- package/dist/deps.d.ts.map +1 -1
- package/dist/deps.js +4 -68
- package/dist/deps.js.map +1 -1
- package/dist/errors.d.ts +20 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +25 -0
- package/dist/errors.js.map +1 -0
- package/dist/flag-allowlists.d.ts +53 -0
- package/dist/flag-allowlists.d.ts.map +1 -0
- package/dist/flag-allowlists.js +157 -0
- package/dist/flag-allowlists.js.map +1 -0
- package/dist/flags.d.ts +45 -0
- package/dist/flags.d.ts.map +1 -1
- package/dist/flags.js +117 -1
- package/dist/flags.js.map +1 -1
- package/dist/jsonout.d.ts +37 -0
- package/dist/jsonout.d.ts.map +1 -0
- package/dist/jsonout.js +68 -0
- package/dist/jsonout.js.map +1 -0
- package/dist/kind.d.ts +61 -0
- package/dist/kind.d.ts.map +1 -0
- package/dist/kind.js +111 -0
- package/dist/kind.js.map +1 -0
- package/dist/main.js +699 -4736
- package/dist/main.js.map +1 -1
- package/dist/mintpage.d.ts +17 -2
- package/dist/mintpage.d.ts.map +1 -1
- package/dist/mintpage.js +241 -54
- package/dist/mintpage.js.map +1 -1
- package/dist/output.d.ts +179 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +756 -0
- package/dist/output.js.map +1 -0
- package/dist/ownerops.d.ts +302 -51
- package/dist/ownerops.d.ts.map +1 -1
- package/dist/ownerops.js +1780 -357
- package/dist/ownerops.js.map +1 -1
- package/dist/preview.d.ts +81 -0
- package/dist/preview.d.ts.map +1 -0
- package/dist/preview.js +535 -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/provision.d.ts.map +1 -1
- package/dist/provision.js +16 -8
- package/dist/provision.js.map +1 -1
- package/dist/remote.d.ts +153 -52
- package/dist/remote.d.ts.map +1 -1
- package/dist/remote.js +406 -46
- package/dist/remote.js.map +1 -1
- package/dist/riskgate.d.ts +58 -0
- package/dist/riskgate.d.ts.map +1 -0
- package/dist/riskgate.js +212 -0
- package/dist/riskgate.js.map +1 -0
- package/dist/scaffold.d.ts +10 -0
- package/dist/scaffold.d.ts.map +1 -0
- package/dist/scaffold.js +52 -0
- package/dist/scaffold.js.map +1 -0
- package/dist/schema.d.ts +36 -1
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +121 -26
- package/dist/schema.js.map +1 -1
- package/dist/script-chunks.d.ts +8 -0
- package/dist/script-chunks.d.ts.map +1 -0
- package/dist/script-chunks.js +35 -0
- package/dist/script-chunks.js.map +1 -0
- package/dist/served.d.ts +30 -0
- package/dist/served.d.ts.map +1 -0
- package/dist/served.js +112 -0
- package/dist/served.js.map +1 -0
- package/dist/signer.d.ts +13 -0
- package/dist/signer.d.ts.map +1 -1
- package/dist/signer.js +84 -15
- package/dist/signer.js.map +1 -1
- package/dist/update-check.d.ts +83 -4
- package/dist/update-check.d.ts.map +1 -1
- package/dist/update-check.js +140 -19
- package/dist/update-check.js.map +1 -1
- package/package.json +10 -6
- package/skill/SKILL.md +350 -150
- package/skill/reference/code-projects.md +183 -26
- package/skill/reference/creator-token.md +99 -0
- package/skill/reference/decisions.md +174 -0
- package/skill/reference/hosting.md +63 -19
- package/skill/reference/operating.md +116 -12
- package/skill/reference/setup.md +55 -6
- package/skill/reference/troubleshooting.md +42 -5
- package/dist/inspect.d.ts +0 -48
- package/dist/inspect.d.ts.map +0 -1
- package/dist/inspect.js +0 -184
- package/dist/inspect.js.map +0 -1
- package/dist/migrate.d.ts +0 -65
- package/dist/migrate.d.ts.map +0 -1
- package/dist/migrate.js +0 -180
- package/dist/migrate.js.map +0 -1
- package/dist/onchain-uri.d.ts +0 -97
- package/dist/onchain-uri.d.ts.map +0 -1
- package/dist/onchain-uri.js +0 -243
- package/dist/onchain-uri.js.map +0 -1
- package/dist/upload.d.ts +0 -28
- package/dist/upload.d.ts.map +0 -1
- package/dist/upload.js +0 -41
- package/dist/upload.js.map +0 -1
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service-facing commands: `serve` (the token API + dashboard), `deploy-resolver` /
|
|
3
|
+
* `deploy-effects` (scaffold a hosted resolver / effect runner for a provider the operator owns),
|
|
4
|
+
* `remote` (inspect a remote service), `migrate` (move a contract's off-chain state to another
|
|
5
|
+
* resolver), and `effects` (co-located effect-runner mode).
|
|
6
|
+
*/
|
|
7
|
+
import { randomBytes } from 'node:crypto';
|
|
8
|
+
import { appendFileSync, copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, } from 'node:fs';
|
|
9
|
+
import { join as joinPath, resolve as resolvePath } from 'node:path';
|
|
10
|
+
import { SqliteStore } from '@artblocks/abx-indexer';
|
|
11
|
+
import { AbxServiceError, buildMigrationPlan, isCodeProject, makePublicClient, probeRpcEndpoints, reconstructProject, resolveChain, verifyParity, } from '@artblocks/abx-sdk';
|
|
12
|
+
import { repinNodeCustody, resolveBackend } from '@artblocks/abx-storage';
|
|
13
|
+
import { DEFAULT_PORT, resolveBaseUrl, startChainWatcher, startTokenApiServer, watchIntervalMs } from '@artblocks/abx-token-api';
|
|
14
|
+
import { formatAssertion, runConformance, verdictLine } from '../conformance.js';
|
|
15
|
+
import { CHAIN, factoryAddress, localIndexer, storageOptions, storageOverrides } from '../config.js';
|
|
16
|
+
import { allowLargeScan, assertPortFree, bold, c, dim, findRepoRoot, g, info, keepAlive, loadEffects, ok, printServing, requirePublishableBackend, resolveScanFloor, step, warn, } from '../output.js';
|
|
17
|
+
import { effectsArtifact, resolverArtifact } from '../provision.js';
|
|
18
|
+
import { describeRemoteError, listConfiguredRemotes, misnamedRemoteVars, remoteFlag, reportRemoteIndexing, requireRemoteToken, resolveRemote, rollUp, serviceClient, statusLabel, } from '../remote.js';
|
|
19
|
+
/**
|
|
20
|
+
* Ensure the LOCAL client credential for your own resolver exists, generating + persisting one to
|
|
21
|
+
* `.env` (as `ABX_REMOTE_SELF_TOKEN` — the named-remote grammar's "self" entry) if not. The SAME
|
|
22
|
+
* value also has to land on the resolver itself, under ITS OWN var name (`ABX_RESOLVER_ADMIN_TOKEN`,
|
|
23
|
+
* server-side — see remote.ts's header comment for why the two names differ across that boundary);
|
|
24
|
+
* the printed deploy steps carry it across. The token authorizes remote indexing control only
|
|
25
|
+
* (never on-chain signing), and — like every other secret — lives ONLY in `.env`, never in a
|
|
26
|
+
* tool-written file or chat. Returns {token, generated} so the caller can tell the operator what
|
|
27
|
+
* happened without printing the secret itself.
|
|
28
|
+
*/
|
|
29
|
+
export function ensureAdminToken() {
|
|
30
|
+
const existing = process.env.ABX_REMOTE_SELF_TOKEN;
|
|
31
|
+
if (existing)
|
|
32
|
+
return { token: existing, generated: false };
|
|
33
|
+
const token = randomBytes(24).toString('base64url');
|
|
34
|
+
const envPath = resolvePath(process.cwd(), '.env');
|
|
35
|
+
const line = `${existsSync(envPath) && readFileSync(envPath, 'utf8').endsWith('\n') === false ? '\n' : ''}ABX_REMOTE_SELF_TOKEN=${token}\n`;
|
|
36
|
+
appendFileSync(envPath, line);
|
|
37
|
+
process.env.ABX_REMOTE_SELF_TOKEN = token;
|
|
38
|
+
return { token, generated: true };
|
|
39
|
+
}
|
|
40
|
+
/** Ensure an ABX_EFFECTS_TOKEN exists locally (generated + persisted to `.env` like the admin
|
|
41
|
+
* token) — it gates a PUBLIC runner's /run + /notify, and the resolver's watcher sends it. */
|
|
42
|
+
export function ensureEffectsToken() {
|
|
43
|
+
const existing = process.env.ABX_EFFECTS_TOKEN;
|
|
44
|
+
if (existing)
|
|
45
|
+
return { token: existing, generated: false };
|
|
46
|
+
const token = randomBytes(24).toString('base64url');
|
|
47
|
+
const envPath = resolvePath(process.cwd(), '.env');
|
|
48
|
+
const line = `${existsSync(envPath) && readFileSync(envPath, 'utf8').endsWith('\n') === false ? '\n' : ''}ABX_EFFECTS_TOKEN=${token}\n`;
|
|
49
|
+
appendFileSync(envPath, line);
|
|
50
|
+
process.env.ABX_EFFECTS_TOKEN = token;
|
|
51
|
+
return { token, generated: true };
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* `abx effects` — run the reference effect runner LOCALLY, in-process (the local counterpart to
|
|
55
|
+
* `abx deploy-effects`, which scaffolds a HOSTED runner). This is what makes a param change / new
|
|
56
|
+
* mint **auto-render** on your own machine: it sweeps every code project the resolver serves and
|
|
57
|
+
* renders any still missing at the CURRENT inputsHash, then keeps sweeping. Co-located with a local
|
|
58
|
+
* `abx serve` (same store) it needs no admin token; against a hosted resolver it publishes via the
|
|
59
|
+
* admin token (locator bridge). Blocks like `serve` — background it. `--once` sweeps all projects a
|
|
60
|
+
* single time and exits (vs `abx render <addr>` which is the per-project repair lane).
|
|
61
|
+
*/
|
|
62
|
+
export async function cmdEffects(flags) {
|
|
63
|
+
const effectsRemote = remoteFlag(flags);
|
|
64
|
+
const resolverUrl = (effectsRemote?.url ?? process.env.ABX_RESOLVER_URL ?? resolveBaseUrl()).replace(/\/$/, '');
|
|
65
|
+
// Token optional BY DESIGN: no token = the co-located topology (shared store, no publish lane).
|
|
66
|
+
// A named remote brings its own token; otherwise the self-host env token (ABX_REMOTE_SELF_TOKEN).
|
|
67
|
+
const adminToken = effectsRemote?.token ?? process.env.ABX_REMOTE_SELF_TOKEN;
|
|
68
|
+
// Co-located (no admin token — we share the resolver's store): record each declared output into
|
|
69
|
+
// the shared artifact registry so the resolver's `artifacts` manifest enumerates it. With an
|
|
70
|
+
// admin token, the publish lane records rows on the hosted resolver instead.
|
|
71
|
+
const localStore = adminToken ? null : new SqliteStore();
|
|
72
|
+
if (adminToken)
|
|
73
|
+
requirePublishableBackend(flags, 'abx effects against a remote resolver');
|
|
74
|
+
const { EffectRunner, renderEffect } = await loadEffects();
|
|
75
|
+
const runner = new EffectRunner({
|
|
76
|
+
resolverUrl,
|
|
77
|
+
client: makePublicClient({ chainKey: CHAIN }),
|
|
78
|
+
storage: resolveBackend(storageOptions(storageOverrides(flags))),
|
|
79
|
+
effects: [renderEffect()],
|
|
80
|
+
environmentId: process.env.ABX_ENVIRONMENT_ID ?? 'web:any',
|
|
81
|
+
// Publish to the resolver's control plane when we DON'T share its store (hosted). Co-located
|
|
82
|
+
// (same fs/backend) it's ignored. The token authorizes indexing/artifact control only, never signing.
|
|
83
|
+
adminToken,
|
|
84
|
+
concurrency: Number(flags.concurrency ?? process.env.ABX_EFFECTS_CONCURRENCY ?? 1),
|
|
85
|
+
authToken: process.env.ABX_EFFECTS_TOKEN,
|
|
86
|
+
recordArtifact: localStore ? (row) => localStore.putEffectArtifact(row) : undefined,
|
|
87
|
+
});
|
|
88
|
+
if (flags.once !== undefined) {
|
|
89
|
+
const stats = await runner.sweepAll();
|
|
90
|
+
const line = `effects once: ran=${stats.ran} skipped=${stats.skipped} failed=${stats.failed} ${dim(`(${resolverUrl})`)}`;
|
|
91
|
+
if (stats.failed)
|
|
92
|
+
warn(`${line}\n ${dim(stats.errors[0] ?? 'see error above')}`);
|
|
93
|
+
else
|
|
94
|
+
ok(line);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const port = Number(flags.port ?? process.env.ABX_EFFECTS_PORT ?? 8788);
|
|
98
|
+
// 300s: the sweep is the SAFETY FLOOR — the resolver's chain watcher POSTs /notify the moment
|
|
99
|
+
// settled state changes, so the interval only catches a missed ping / cold start / new project.
|
|
100
|
+
const intervalMs = Number(flags['interval-ms'] ?? process.env.ABX_EFFECTS_INTERVAL_MS ?? 300_000);
|
|
101
|
+
runner.startHttp(port);
|
|
102
|
+
runner.startLoop(intervalMs);
|
|
103
|
+
console.log(`\n ${g('●')} ${bold('effect runner up')} ${dim(`resolver ${resolverUrl} · port ${port} · sweep ${intervalMs}ms (safety floor)`)}`);
|
|
104
|
+
info('auto-renders every new mint + PostParam change: the resolver\'s chain watcher notifies this runner the moment settled state changes (set ABX_EFFECTS_URL=http://localhost:' + port + ' on the resolver). Needs a local Chromium (`npx playwright install chromium`).');
|
|
105
|
+
info(`${dim('POST /notify enqueues (the watcher lane) · POST /run sweeps synchronously (the command lane). Ctrl-C to stop. Hosting it instead? ')}${bold('abx deploy-effects')}`);
|
|
106
|
+
await new Promise(() => { }); // block like `serve`
|
|
107
|
+
}
|
|
108
|
+
// ── serve ──────────────────────────────────────────────────────────────────--
|
|
109
|
+
export async function cmdServe(flags) {
|
|
110
|
+
const port = Number(flags.port ?? process.env.ABX_PORT ?? DEFAULT_PORT);
|
|
111
|
+
await assertPortFree(port, 'serve');
|
|
112
|
+
const baseUrl = resolveBaseUrl(port);
|
|
113
|
+
const indexer = localIndexer();
|
|
114
|
+
// Surface the most-recently-reconstructed project (what you just deployed), not
|
|
115
|
+
// an arbitrary one — the banner must match reality.
|
|
116
|
+
const latest = [...indexer.listProjects()].sort((a, b) => b.reconstructedAt.localeCompare(a.reconstructedAt))[0];
|
|
117
|
+
const { url } = await startTokenApiServer({ indexer, port, baseUrl, storage: resolveBackend(storageOptions()) });
|
|
118
|
+
printServing(url, latest?.address);
|
|
119
|
+
// The chain watcher — ON by default: the resolver is the protocol's one chain consumer. Every
|
|
120
|
+
// registered project's new events (external mints, foreign-tool param changes) auto-index here
|
|
121
|
+
// and fan out ONE coarse notification per project to the effects layer (ABX_EFFECTS_URL). Tune
|
|
122
|
+
// with ABX_WATCH_INTERVAL_MS; 0 disables (a purely static node).
|
|
123
|
+
const interval = watchIntervalMs();
|
|
124
|
+
if (interval > 0) {
|
|
125
|
+
startChainWatcher({ indexer, intervalMs: interval });
|
|
126
|
+
const effectsUrl = process.env.ABX_EFFECTS_URL;
|
|
127
|
+
console.log(` ${dim('watching')} chain every ${Math.round(interval / 1000)}s ${dim(`(auto-index${effectsUrl ? ` + notify effects → ${effectsUrl}` : ''}; ABX_WATCH_INTERVAL_MS=0 to disable)`)}`);
|
|
128
|
+
// The watcher indexes regardless, but with no effects URL it has nowhere to send the "changed"
|
|
129
|
+
// notification — so thumbnails will NOT auto-re-render. Say it plainly (not buried in dim text):
|
|
130
|
+
// this is the #1 silent misconfig for a co-located resolver + runner. But ONLY warn when a CODE
|
|
131
|
+
// project (the only kind with off-chain rendered stills) is actually registered — for a 1/1 or
|
|
132
|
+
// image Series resolver there is nothing to render, so the warning is just noise (hosting-agent
|
|
133
|
+
// finding: a plain resolver shouldn't nag about an effects runner it doesn't need).
|
|
134
|
+
if (!effectsUrl && indexer.listProjects().some(isCodeProject)) {
|
|
135
|
+
warn('ABX_EFFECTS_URL is not set — the watcher will auto-index changes but NOT auto-render thumbnails for your code project(s).');
|
|
136
|
+
console.log(` ${dim('run `abx effects` (local) or `abx deploy-effects` (hosted), then set ABX_EFFECTS_URL to its URL.')}`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
console.log(` ${dim('watching OFF (ABX_WATCH_INTERVAL_MS=0) — state updates only on explicit add/index')}`);
|
|
141
|
+
}
|
|
142
|
+
keepAlive();
|
|
143
|
+
}
|
|
144
|
+
// ── remote ────────────────────────────────────────────────────────────────--
|
|
145
|
+
// Inspect a remote service. Bare `abx remote` lists the named remotes configured in .env plus the
|
|
146
|
+
// self-host default pair (URLs + whether a token is set — never the secret itself). With a target,
|
|
147
|
+
// fetches its PUBLIC service descriptor (what it serves: interfaces, chains, auth, managed
|
|
148
|
+
// rendering) and — when a token resolves — lists the projects visible to that token, which makes
|
|
149
|
+
// this the one-command "is my provider key valid?" check. Read-only; registers nothing.
|
|
150
|
+
export async function cmdRemote(spec, flags) {
|
|
151
|
+
if (flags.conformance !== undefined)
|
|
152
|
+
return cmdRemoteConformance(spec, flags);
|
|
153
|
+
if (!spec || spec.startsWith('--')) {
|
|
154
|
+
const remotes = listConfiguredRemotes();
|
|
155
|
+
console.log(`\n ${bold('named remotes')} ${dim('(ABX_REMOTE_<NAME>_URL/_TOKEN in .env — inspect one: abx remote <name>)')}`);
|
|
156
|
+
if (remotes.length === 0)
|
|
157
|
+
console.log(dim(' none configured'));
|
|
158
|
+
for (const r of remotes) {
|
|
159
|
+
console.log(` ${g('●')} ${r.name.toLowerCase()} ${dim(r.url)} ${r.hasToken ? g('token set') : dim('no token')}`);
|
|
160
|
+
}
|
|
161
|
+
// A near-miss var reads as "no token" while the value is sitting in .env under the wrong name.
|
|
162
|
+
for (const bad of misnamedRemoteVars()) {
|
|
163
|
+
warn(`${bad.key} isn't a recognized remote var — the convention is ${bold(bad.suggestion)} (only _URL and _TOKEN are read).`);
|
|
164
|
+
}
|
|
165
|
+
const def = process.env.ABX_PUBLIC_BASE_URL ?? process.env.ABX_RESOLVER_URL;
|
|
166
|
+
console.log(`\n ${bold('self-host default')} ${dim('(bare --remote)')}`);
|
|
167
|
+
console.log(def
|
|
168
|
+
? ` ${g('●')} ${def} ${process.env.ABX_REMOTE_SELF_TOKEN ? g('token set') : dim('no ABX_REMOTE_SELF_TOKEN')}`
|
|
169
|
+
: dim(' none (set ABX_PUBLIC_BASE_URL in .env)'));
|
|
170
|
+
console.log('');
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const target = resolveRemote(spec, flags['remote-token']);
|
|
174
|
+
if (!target)
|
|
175
|
+
return;
|
|
176
|
+
const client = serviceClient(target);
|
|
177
|
+
console.log(`\n ${bold(target.name ? `remote ${target.name.toLowerCase()}` : 'remote')} ${dim(`→ ${target.url}`)}`);
|
|
178
|
+
let d;
|
|
179
|
+
try {
|
|
180
|
+
d = await client.descriptor();
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
// Two very different situations, and the fix differs — so don't nest the raw client error
|
|
184
|
+
// (it repeats the URL and leaks `GET`/`fetch failed` at a creator).
|
|
185
|
+
const status = err instanceof AbxServiceError ? err.status : -1;
|
|
186
|
+
if (status === 0) {
|
|
187
|
+
throw new Error(`nothing responded at ${target.url} — check the address. A provider gives you an https:// base ` +
|
|
188
|
+
`(e.g. https://meta.provider.xyz); if it's your own node, is it running?`);
|
|
189
|
+
}
|
|
190
|
+
throw new Error(`${target.url} answered, but serves no ABX service descriptor at /.well-known/abx-service. ` +
|
|
191
|
+
`That's either an older self-hosted node (fine if it's yours — the remote commands still work against it) ` +
|
|
192
|
+
`or not an ABX service at all. Verify the URL before registering anything with it.`);
|
|
193
|
+
}
|
|
194
|
+
info(`service ${d.service?.name ?? '—'} ${dim(d.service?.version ?? '')}`);
|
|
195
|
+
info(`serves ${(d.interfaces ?? []).join(' · ') || '—'}`);
|
|
196
|
+
const chainId = resolveChain(CHAIN).id;
|
|
197
|
+
const coversChain = (d.chains ?? []).includes(chainId);
|
|
198
|
+
info(`chains ${(d.chains ?? []).join(', ') || '—'} ${coversChain ? g(`✓ covers ${CHAIN} (${chainId})`) : `${c.orange}⚠${c.reset} does NOT cover ${CHAIN} (${chainId}) — registrations will be refused`}`);
|
|
199
|
+
if (d.render?.attached) {
|
|
200
|
+
const outputs = d.render.effects?.flatMap((e) => e.outputs.map((o) => `${e.key}/${o.key}`)).join(', ');
|
|
201
|
+
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`);
|
|
202
|
+
}
|
|
203
|
+
if (d.auth) {
|
|
204
|
+
info(`auth bearer${d.auth.signupUrl ? ` · get a key: ${d.auth.signupUrl}` : ''}${d.auth.docsUrl ? ` · docs: ${d.auth.docsUrl}` : ''}`);
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
info(`auth none advertised ${dim('(control plane disabled on this node)')}`);
|
|
208
|
+
}
|
|
209
|
+
if (!target.token) {
|
|
210
|
+
// Same near-miss check the register path does — this is where someone lands FIRST when their
|
|
211
|
+
// credential is set under a name the CLI doesn't read, so the hint has to be here too.
|
|
212
|
+
const nearMiss = misnamedRemoteVars().find((v) => v.suggestion === target.tokenVar);
|
|
213
|
+
if (nearMiss)
|
|
214
|
+
warn(`${nearMiss.key} is set but is NOT read — the convention is ${bold(target.tokenVar)} (only _URL and _TOKEN). Rename it and re-run.`);
|
|
215
|
+
else
|
|
216
|
+
info(dim(`no token resolved (set ${target.tokenVar} or pass --remote-token) — descriptor only; can't list your projects.`));
|
|
217
|
+
console.log('');
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
const projects = await client.listProjects();
|
|
222
|
+
ok(`token accepted — ${projects.length} project(s) visible to it`);
|
|
223
|
+
for (const p of projects.slice(0, 10)) {
|
|
224
|
+
console.log(` ${g('●')} ${p.name ?? p.label ?? p.address} ${dim(`${p.address} · ${p.tokenCount ?? '?'} token(s)`)}` +
|
|
225
|
+
`${p.status ? ` ${statusLabel(p.status)}` : ''}${p.error ? ` ${c.orange}${p.error.class}${c.reset}` : ''}`);
|
|
226
|
+
}
|
|
227
|
+
if (projects.length > 10)
|
|
228
|
+
console.log(dim(` … and ${projects.length - 10} more`));
|
|
229
|
+
if (projects.some((p) => p.status))
|
|
230
|
+
info(rollUp(projects) + dim(' — one project in detail: abx status <address> --remote ' + (target.name?.toLowerCase() ?? target.url)));
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
throw describeRemoteError(err, target, 'remote list');
|
|
234
|
+
}
|
|
235
|
+
console.log('');
|
|
236
|
+
}
|
|
237
|
+
// ── remote --conformance ─────────────────────────────────────────────────--
|
|
238
|
+
// Self-certify a hosted resolver against specs/self-host-toolkit/remote-services.md, from the
|
|
239
|
+
// PUBLISHED CLI — no repo checkout needed (the assertions themselves live in ../conformance.ts,
|
|
240
|
+
// moved here from the repo-local `scripts/conformance-remote-service.mjs`, now a thin wrapper over
|
|
241
|
+
// the same module). Folded into `remote` rather than a new top-level command (command-count
|
|
242
|
+
// discipline): `abx remote <name|url> --conformance [--remote-token <t>] [--chain-id <n>] [--address
|
|
243
|
+
// <a>] [--from-block <n>]`. Exits non-zero on any failed assertion — CI-gateable.
|
|
244
|
+
//
|
|
245
|
+
// Reuses the SAME --remote resolution as every other remote op: a bare URL + --remote-token needs
|
|
246
|
+
// zero .env setup, which is the point — a third party evaluating "can I host this?" runs this
|
|
247
|
+
// against their own node with nothing but the CLI and a token. The register→deregister loop (the
|
|
248
|
+
// one tier that writes anything) stays gated behind BOTH --address and --chain-id together, exactly
|
|
249
|
+
// as the script always required: exercising it against a service you don't own needs a contract you
|
|
250
|
+
// own to point it at, which is its own consent gate stacked on top of the token.
|
|
251
|
+
async function cmdRemoteConformance(spec, flags) {
|
|
252
|
+
// A bare `--conformance` (no positional target) leaks the flag token itself into `spec` — argv
|
|
253
|
+
// parsing keeps positionals raw (main.ts's `rest[0]`), so `abx remote --conformance` alone hands
|
|
254
|
+
// this the literal string '--conformance'. Treat that the same as "no target given" and fall back
|
|
255
|
+
// to the self-host default, exactly like `abx remote`'s own bare-mode check just above.
|
|
256
|
+
const rawSpec = spec && !spec.startsWith('--') ? spec : undefined;
|
|
257
|
+
const target = resolveRemote(rawSpec ?? 'true', flags['remote-token']);
|
|
258
|
+
if (!target)
|
|
259
|
+
return; // unreachable — a defined spec always resolves or throws
|
|
260
|
+
console.log(bold(`\n abx remote --conformance`) + dim(` → ${target.url}`));
|
|
261
|
+
info(`self-certifying against specs/self-host-toolkit/remote-services.md` +
|
|
262
|
+
(target.token ? ' (token present — authed tiers included)' : ' (no token — unauthenticated tier only; pass --remote-token to unlock more)'));
|
|
263
|
+
console.log('');
|
|
264
|
+
const chainId = flags['chain-id'] !== undefined ? Number(flags['chain-id']) : undefined;
|
|
265
|
+
const report = await runConformance({
|
|
266
|
+
baseUrl: target.url,
|
|
267
|
+
token: target.token,
|
|
268
|
+
chainId,
|
|
269
|
+
address: flags.address,
|
|
270
|
+
fromBlock: flags['from-block'],
|
|
271
|
+
});
|
|
272
|
+
for (const a of report.assertions)
|
|
273
|
+
console.log(formatAssertion(a));
|
|
274
|
+
console.log(verdictLine(report));
|
|
275
|
+
// The one exit-discipline every signing/writing surface follows (errors.ts): no process.exit()
|
|
276
|
+
// mid-flow — a local failure sets exitCode and returns.
|
|
277
|
+
if (report.failures)
|
|
278
|
+
process.exitCode = 1;
|
|
279
|
+
}
|
|
280
|
+
// ── migrate ───────────────────────────────────────────────────────────────--
|
|
281
|
+
// Move a contract's OFF-CHAIN operator state from one resolver to another (e.g. fly.io →
|
|
282
|
+
// a droplet). The destination replays all ON-CHAIN state from chain itself; this bridges
|
|
283
|
+
// the rest — description / external_url / off-chain traits / image locators — by reading the
|
|
284
|
+
// SOURCE resolver's PUBLIC api. The two resolvers never talk: we read public endpoints + chain
|
|
285
|
+
// and write through the admin control plane. It does NOT cut over — after a clean migration the
|
|
286
|
+
// operator re-points DNS (custom domain) or the on-chain base URI (provider endpoint).
|
|
287
|
+
//
|
|
288
|
+
// ⚠ IF YOU EVER ADD A REDEPLOY-STYLE MIGRATION HERE — anything that deploys a NEW contract and
|
|
289
|
+
// re-points identity to it (plausible under the greenfield-redeploy stance) — it MUST read the
|
|
290
|
+
// source collection's creator-token status (`readCreatorTokenStatus`) and carry the enrollment +
|
|
291
|
+
// active validator into the new deploy's `transferValidator` InitParam. ERC-721C enrollment is
|
|
292
|
+
// deploy-time-only and PERMANENT, so a migration that forgets it silently converts an enforced
|
|
293
|
+
// collection into a plain ERC-721 — unrecoverable except by yet another redeploy. Corollary: a
|
|
294
|
+
// plain source collection must stay plain; do not "helpfully" enroll on migration. Today's
|
|
295
|
+
// migrate moves off-chain state only, so enrollment lives on the untouched contract and none of
|
|
296
|
+
// this applies — which is exactly why the trap is easy to walk into later.
|
|
297
|
+
export async function cmdMigrate(address, flags) {
|
|
298
|
+
// Both sides accept a named remote or a URL. Only the DESTINATION needs a credential — the
|
|
299
|
+
// source is read via its PUBLIC api (the exit ramp works with zero provider cooperation).
|
|
300
|
+
const fromTarget = typeof flags.from === 'string' ? resolveRemote(flags.from) : null;
|
|
301
|
+
const toTarget = typeof flags.to === 'string' ? resolveRemote(flags.to, flags['remote-token']) : null;
|
|
302
|
+
if (!address || address.startsWith('--') || !fromTarget || !toTarget) {
|
|
303
|
+
console.error('usage: abx migrate <address> --from <source-resolver name|url> --to <dest-resolver name|url> [--from-block N]\n');
|
|
304
|
+
process.exitCode = 1;
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
requireRemoteToken(toTarget);
|
|
308
|
+
const from = fromTarget.url;
|
|
309
|
+
const to = toTarget.url;
|
|
310
|
+
const chainId = resolveChain(CHAIN).id;
|
|
311
|
+
allowLargeScan(flags);
|
|
312
|
+
console.log(bold(`\n abx migrate ${dim('— port off-chain state between resolvers (no cutover)')}`));
|
|
313
|
+
info(`source ${from}`);
|
|
314
|
+
info(`dest ${to}`);
|
|
315
|
+
// 1) Reconstruct on-chain truth locally (read-only) — for the token list + the image hash
|
|
316
|
+
// KEYS the served JSON doesn't expose. No persistence, no local registration written.
|
|
317
|
+
step('Read the chain');
|
|
318
|
+
const client = makePublicClient({ chainKey: CHAIN });
|
|
319
|
+
const reg = localIndexer().store.getRegistration(address);
|
|
320
|
+
const factory = flags.factory ?? factoryAddress() ?? reg?.factory ?? undefined;
|
|
321
|
+
// Resolve the scan floor like add/index — NEVER a silent genesis default. --from-block wins, else
|
|
322
|
+
// the local registration's deploy block, else discover it on-chain (getCode binary search), else
|
|
323
|
+
// refuse with guidance. A from-0 reconstruction of a real collection trips the getLogs cap on a
|
|
324
|
+
// range-capped RPC — the exact wall a migrating creator hit before this.
|
|
325
|
+
const scanFloor = await resolveScanFloor(address, reg?.fromBlock, flags);
|
|
326
|
+
const local = await reconstructProject(client, {
|
|
327
|
+
address,
|
|
328
|
+
fromBlock: BigInt(scanFloor),
|
|
329
|
+
factory: factory,
|
|
330
|
+
});
|
|
331
|
+
ok(`${local.name ?? address}: ${local.tokens.length} token(s), ${local.eventCount} events on-chain`);
|
|
332
|
+
// 2) Reconstruct the off-chain state from the SOURCE resolver's public API.
|
|
333
|
+
step('Read the source resolver');
|
|
334
|
+
const plan = await buildMigrationPlan(from, chainId, local);
|
|
335
|
+
ok(`read ${plan.tokensRead} served token(s)`);
|
|
336
|
+
info(`off-chain: ${plan.description ? 'description ✓' : 'no description'} · ` +
|
|
337
|
+
`${plan.externalUrl ? 'external_url ✓' : 'no external_url'} · ` +
|
|
338
|
+
`${plan.attributes?.length ?? 0} off-chain trait(s) · ` +
|
|
339
|
+
`${Object.keys(plan.contentLocators).length} durable image locator(s) · ${plan.nodeCustody.length} source-only image(s)`);
|
|
340
|
+
// 3) Re-pin any source-only images to a DURABLE backend so the destination never points back at
|
|
341
|
+
// the soon-to-be-gone source host. Fetch → verify vs the on-chain hash → put → bridge the NEW
|
|
342
|
+
// locator. If no durable backend is configured, bridge nothing source-bound and report it.
|
|
343
|
+
const repinnedTokens = new Set();
|
|
344
|
+
if (plan.nodeCustody.length) {
|
|
345
|
+
step('Re-pin source-only images');
|
|
346
|
+
const backend = resolveBackend(storageOptions(storageOverrides(flags)));
|
|
347
|
+
const rp = await repinNodeCustody(plan.nodeCustody, backend);
|
|
348
|
+
for (const [hash, loc] of Object.entries(rp.repinned))
|
|
349
|
+
plan.contentLocators[hash] = loc;
|
|
350
|
+
rp.repinnedTokens.forEach((id) => repinnedTokens.add(id));
|
|
351
|
+
if (rp.repinnedTokens.length)
|
|
352
|
+
ok(`re-pinned ${rp.repinnedTokens.length} image(s) to '${backend.id}' (verified vs on-chain hash) — bridged to the destination`);
|
|
353
|
+
if (rp.mismatched.length)
|
|
354
|
+
warn(`${rp.mismatched.length} image(s) FAILED hash verification — NOT pinned (source bytes don't match the chain): token(s) ${rp.mismatched.map((n) => n.tokenId).join(', ')}`);
|
|
355
|
+
if (rp.unreachable.length)
|
|
356
|
+
warn(`${rp.unreachable.length} image(s) couldn't be fetched from the source: token(s) ${rp.unreachable.map((n) => n.tokenId).join(', ')} — is the source still up?`);
|
|
357
|
+
if (rp.needsDurableBackend.length) {
|
|
358
|
+
warn(`${rp.needsDurableBackend.length} source-only image(s) NOT migrated — backend '${backend.id}' has no durable locator: token(s) ${rp.needsDurableBackend.map((n) => n.tokenId).join(', ')}`);
|
|
359
|
+
info(`re-run with a durable backend: ${g('abx migrate ' + address + ' --from ' + from + ' --to ' + to + ' --backend ipfs')} (or --backend arweave).`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
// 4) Bridge it to the destination — one admin call registers + replays + enriches.
|
|
363
|
+
step('Populate the destination');
|
|
364
|
+
const locCount = Object.keys(plan.contentLocators).length;
|
|
365
|
+
const body = {
|
|
366
|
+
chainId,
|
|
367
|
+
address,
|
|
368
|
+
// The deploy block (locally known); if somehow absent, omit it so the destination derives it
|
|
369
|
+
// rather than scanning from genesis (never bake a from-0 floor into a fresh resolver).
|
|
370
|
+
fromBlock: local.deployBlock ?? flags['from-block'] ?? undefined,
|
|
371
|
+
factory,
|
|
372
|
+
label: flags.label,
|
|
373
|
+
description: plan.description,
|
|
374
|
+
externalUrl: plan.externalUrl,
|
|
375
|
+
attributes: plan.attributes,
|
|
376
|
+
contentLocators: locCount ? plan.contentLocators : undefined,
|
|
377
|
+
full: true, // first registration on the destination — replay from the deploy block
|
|
378
|
+
};
|
|
379
|
+
info(`${bold('REMOTE')} → ${to} ${dim('(control plane — chain replay + off-chain enrichment)')}`);
|
|
380
|
+
let r;
|
|
381
|
+
try {
|
|
382
|
+
r = await serviceClient(toTarget).registerProject(body);
|
|
383
|
+
}
|
|
384
|
+
catch (err) {
|
|
385
|
+
throw describeRemoteError(err, toTarget, 'migrate destination');
|
|
386
|
+
}
|
|
387
|
+
// Always wait here, even if the caller passed --no-wait: the parity check below reads the
|
|
388
|
+
// destination's served metadata, and comparing a half-indexed projection would report a false
|
|
389
|
+
// mismatch — worse than a slow migrate.
|
|
390
|
+
await reportRemoteIndexing(toTarget, chainId, address, r, {}, 'indexed');
|
|
391
|
+
// 5) Parity check — does the destination now serve the same metadata as the source? Sample a
|
|
392
|
+
// token we did NOT re-pin (a re-pinned image is durable-locator-on-dest vs old-host-on-source
|
|
393
|
+
// BY DESIGN — different there is correct, so comparing it would mislead).
|
|
394
|
+
step('Verify parity');
|
|
395
|
+
const sample = local.tokens.find((t) => t.lifecycle === 'live' && !repinnedTokens.has(t.tokenId)) ??
|
|
396
|
+
local.tokens.find((t) => t.lifecycle === 'live') ??
|
|
397
|
+
local.tokens[0];
|
|
398
|
+
if (sample) {
|
|
399
|
+
const par = await verifyParity(from, to, chainId, address, sample.tokenId);
|
|
400
|
+
const mark = (ok_) => (ok_ ? g('✓') : `${c.red}✗${c.reset}`);
|
|
401
|
+
const imgRepinned = repinnedTokens.has(sample.tokenId);
|
|
402
|
+
console.log(` token #${sample.tokenId}: ` +
|
|
403
|
+
`${imgRepinned ? `${g('✓')} image ${dim('(re-pinned → durable; differs from source by design)')}` : `${mark(par.imageMatch)} image`} ` +
|
|
404
|
+
`${mark(par.descriptionMatch)} description ${mark(par.attributesMatch)} attributes`);
|
|
405
|
+
if (!imgRepinned && !par.imageMatch)
|
|
406
|
+
info(`source image ${par.sourceImage ?? '∅'} → dest image ${par.destImage ?? '∅'}`);
|
|
407
|
+
}
|
|
408
|
+
else {
|
|
409
|
+
info('no minted tokens to compare yet');
|
|
410
|
+
}
|
|
411
|
+
// 6) Cut over — NOT done here; the operator re-points DNS or the on-chain base URI.
|
|
412
|
+
step('Cut over (manual)');
|
|
413
|
+
info('the destination now serves identical metadata. Point traffic at it ONE of two ways:');
|
|
414
|
+
console.log(` ${bold('• custom domain')} — re-point DNS to the new host. On-chain base URI unchanged: no tx, no gas.`);
|
|
415
|
+
console.log(` ${bold('• provider endpoint')} (e.g. *.fly.dev) — re-point the on-chain base URI: ` +
|
|
416
|
+
`${g('abx set-token-uri ' + address + ' --uri <dest-base>')} (+ ${g('set-contract-uri')}). A wallet-signed tx.`);
|
|
417
|
+
info('keep the source running until DNS / base-URI propagates (source-only images were already re-pinned above, unless a warning said otherwise).');
|
|
418
|
+
}
|
|
419
|
+
/** DEV only: copy the minimal, buildable workspace source into the artifact dir so the emitted
|
|
420
|
+
* from-source Dockerfile has a self-contained build context — no node_modules, no repo access. */
|
|
421
|
+
export function vendorResolverSource(root, outDir) {
|
|
422
|
+
for (const f of ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', 'tsconfig.base.json']) {
|
|
423
|
+
const src = joinPath(root, f);
|
|
424
|
+
if (existsSync(src))
|
|
425
|
+
copyFileSync(src, joinPath(outDir, f));
|
|
426
|
+
}
|
|
427
|
+
cpSync(joinPath(root, 'packages'), joinPath(outDir, 'packages'), {
|
|
428
|
+
recursive: true,
|
|
429
|
+
filter: (src) => !/[/\\](node_modules|dist|\.turbo)([/\\]|$)/.test(src),
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
export async function cmdDeployResolver(flags) {
|
|
433
|
+
const provider = flags.provider;
|
|
434
|
+
if (!provider || !['fly', 'render', 'vps'].includes(provider)) {
|
|
435
|
+
throw new Error('abx deploy-resolver --provider <fly|render|vps> [--domain meta.you.xyz] [--app <name>] [--dir deploy] [--from-source]');
|
|
436
|
+
}
|
|
437
|
+
const domain = flags.domain;
|
|
438
|
+
const app = flags.app ?? (domain ? domain.split('.')[0] : 'abx-resolver');
|
|
439
|
+
const dir = flags.dir ?? 'deploy';
|
|
440
|
+
// How the emitted image gets the ABX code: npm package (production, default) vs a source build
|
|
441
|
+
// (DEV — pre-publish / local iteration). The dev switch is a flag or ABX_RESOLVER_SOURCE=1 (the
|
|
442
|
+
// sandbox sets the env var so a local agent gets a buildable artifact without knowing the flag).
|
|
443
|
+
const fromSource = flags['from-source'] !== undefined || process.env.ABX_RESOLVER_SOURCE === '1';
|
|
444
|
+
let sourcePackages;
|
|
445
|
+
let pnpmVersion;
|
|
446
|
+
let repoRoot = null;
|
|
447
|
+
if (fromSource) {
|
|
448
|
+
repoRoot = findRepoRoot();
|
|
449
|
+
if (!repoRoot) {
|
|
450
|
+
throw new Error('--from-source (dev) needs a source checkout — pnpm-workspace.yaml not found above the CLI. ' +
|
|
451
|
+
'In production omit it (or unset ABX_RESOLVER_SOURCE) to get the self-contained npm-based image.');
|
|
452
|
+
}
|
|
453
|
+
const pkgDir = joinPath(repoRoot, 'packages');
|
|
454
|
+
sourcePackages = readdirSync(pkgDir)
|
|
455
|
+
.filter((d) => existsSync(joinPath(pkgDir, d, 'package.json')))
|
|
456
|
+
.map((d) => `packages/${d}`)
|
|
457
|
+
.sort();
|
|
458
|
+
try {
|
|
459
|
+
pnpmVersion = (JSON.parse(readFileSync(joinPath(repoRoot, 'package.json'), 'utf8')).packageManager ?? '').split('@')[1] || undefined;
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
/* fall back to the provisioner's pinned default */
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
const art = resolverArtifact({ provider, app, domain, chain: CHAIN, fromSource, sourcePackages, pnpmVersion });
|
|
466
|
+
const outDir = resolvePath(joinPath(dir, provider));
|
|
467
|
+
console.log(bold(`\n ABX Self-Host Toolkit — provision resolver (${provider})`));
|
|
468
|
+
info(fromSource ? `image: ${bold('from source')} ${dim('(DEV — builds from vendored workspace source; production uses the npm image)')}` : `image: ${bold('npm')} ${dim('(production — installs the published @artblocks/abx-cli; add --from-source for a local pre-publish build)')}`);
|
|
469
|
+
info(`base URL: ${bold(art.baseUrl)} ${dim('— baked into ABX_PUBLIC_BASE_URL; the resolver serves its image/animation links from this')}`);
|
|
470
|
+
// The resolver bakes ABX_RPC_URLS as a host secret and runs the getLogs-heavy reconstruction, so
|
|
471
|
+
// vet the range NOW — a capped RPC under marketplace load is slow + rate-limit-prone (a top cause
|
|
472
|
+
// of a hosted resolver that "won't index"). Best-effort: never block the scaffold on a probe.
|
|
473
|
+
try {
|
|
474
|
+
const probes = await probeRpcEndpoints({ chainKey: CHAIN });
|
|
475
|
+
const usable = probes.filter((pr) => pr.verdict !== 'unusable');
|
|
476
|
+
if (usable.length === 0)
|
|
477
|
+
warn(`no usable ${CHAIN} RPC in ABX_RPC_URLS — the resolver couldn't reconstruct. Add a wide-range archive endpoint before deploying (\`abx doctor\` rates them).`);
|
|
478
|
+
else if (!probes.some((pr) => pr.verdict === 'best'))
|
|
479
|
+
warn(`every ${CHAIN} RPC in ABX_RPC_URLS is getLogs-range-capped — a resolver under load will be slow + rate-limit-prone. Add a wide-range archive endpoint before baking it in (\`abx doctor\`).`);
|
|
480
|
+
else
|
|
481
|
+
info(`RPC range: ${g('✓')} a wide-range archive endpoint is available to bake into the resolver.`);
|
|
482
|
+
}
|
|
483
|
+
catch {
|
|
484
|
+
/* probe is best-effort — never block scaffolding on it */
|
|
485
|
+
}
|
|
486
|
+
step('Write the deploy artifact (self-contained — Dockerfile + config, nothing to copy from a repo)');
|
|
487
|
+
mkdirSync(outDir, { recursive: true });
|
|
488
|
+
for (const f of art.files) {
|
|
489
|
+
writeFileSync(joinPath(outDir, f.path), f.content);
|
|
490
|
+
info(`wrote ${joinPath(dir, provider, f.path)}`);
|
|
491
|
+
}
|
|
492
|
+
if (fromSource && repoRoot) {
|
|
493
|
+
// DEV only: vendor the workspace source INTO the artifact dir so the image builds pre-publish.
|
|
494
|
+
// The CLI does this deterministically (it knows its own checkout) — the caller never touches
|
|
495
|
+
// the repo, so the "everything comes from the CLI" contract still holds.
|
|
496
|
+
vendorResolverSource(repoRoot, outDir);
|
|
497
|
+
info(`vendored the workspace source into ${joinPath(dir, provider)}/ (root manifests + packages/, no node_modules)`);
|
|
498
|
+
}
|
|
499
|
+
step(`Next steps (run from ${bold(joinPath(dir, provider))}/ — they use your own cloud account)`);
|
|
500
|
+
art.steps.forEach((s, i) => console.log(` ${g(`${i + 1}.`)} ${s}`));
|
|
501
|
+
if (domain) {
|
|
502
|
+
step('DNS — one record points your domain at the host');
|
|
503
|
+
info(art.dns ?? '');
|
|
504
|
+
info(`re-point THIS record (not an on-chain tx) if you ever move hosts — the domain is the durable pointer.`);
|
|
505
|
+
}
|
|
506
|
+
else {
|
|
507
|
+
info(`No --domain given — baked the platform hostname ${bold(art.baseUrl)} so the resolver works out of the box.`);
|
|
508
|
+
if (provider === 'fly')
|
|
509
|
+
info(`If ${bold('fly launch')} assigns a different app name (yours was taken), update it: ${bold('fly secrets set ABX_PUBLIC_BASE_URL=https://<real-app>.fly.dev')}`);
|
|
510
|
+
info('For a portable setup, re-run with --domain meta.yourproject.xyz — moving hosts is then a DNS change, not an on-chain re-point.');
|
|
511
|
+
}
|
|
512
|
+
step('Admin token — lets you tell THIS resolver which contracts to index');
|
|
513
|
+
const { generated } = ensureAdminToken();
|
|
514
|
+
if (generated) {
|
|
515
|
+
ok('generated ABX_REMOTE_SELF_TOKEN and saved it to your local .env (a secret — not printed, env-only).');
|
|
516
|
+
}
|
|
517
|
+
else {
|
|
518
|
+
info('using the existing ABX_REMOTE_SELF_TOKEN from your .env.');
|
|
519
|
+
}
|
|
520
|
+
info('Set the SAME value on the resolver, as ABX_RESOLVER_ADMIN_TOKEN (the deploy steps above include the command) — its');
|
|
521
|
+
info('own var name, server-side; your local copy is ABX_REMOTE_SELF_TOKEN, the named remote called "self". Authorizes');
|
|
522
|
+
info('indexing control only — never on-chain signing, so the "no signing key on the host" rule holds.');
|
|
523
|
+
step('Security posture');
|
|
524
|
+
info(art.hardening);
|
|
525
|
+
info('The host is READ-ONLY: it only serves + accepts admin index-control; no signing key lives on it.');
|
|
526
|
+
step('Then deploy your NFT, and REGISTER it with this (remote) resolver');
|
|
527
|
+
const url = art.baseUrl;
|
|
528
|
+
info(`abx deploy --image <file> --name … --public-base-url ${url} (or export ABX_PUBLIC_BASE_URL=${url})`);
|
|
529
|
+
info(`the contract derives ${url}/t/${resolveChain(CHAIN).id}/{address}/{tokenId} from that base.`);
|
|
530
|
+
info(`${bold('then')} abx add <clone> --remote ${dim('# tell the remote resolver to index it — a LOCAL deploy does NOT')}`);
|
|
531
|
+
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>`));
|
|
532
|
+
console.log('');
|
|
533
|
+
}
|
|
534
|
+
// ── deploy-effects: scaffold the render runner (the resolver's browser-bearing companion) ─────
|
|
535
|
+
// A code project's marketplace still is rendered off-chain, so SOMETHING must run the live view in a
|
|
536
|
+
// browser and hand the resolver the result. This scaffolds that runner (Playwright + Chromium) for
|
|
537
|
+
// Fly, wired to publish each render to the resolver's control plane (the locator bridge).
|
|
538
|
+
export async function cmdDeployEffects(flags) {
|
|
539
|
+
const provider = flags.provider ?? 'fly';
|
|
540
|
+
if (provider !== 'fly') {
|
|
541
|
+
throw new Error('abx deploy-effects currently scaffolds for --provider fly (on other hosts, run Dockerfile.effects beside the resolver with the same env). ' +
|
|
542
|
+
'usage: abx deploy-effects --resolver-url https://<resolver> [--app <name>] [--dir deploy] [--interval-ms 300000] [--env-id web:any]');
|
|
543
|
+
}
|
|
544
|
+
const resolverUrl = flags['resolver-url'] ?? process.env.ABX_RESOLVER_URL ?? process.env.ABX_PUBLIC_BASE_URL;
|
|
545
|
+
if (!resolverUrl) {
|
|
546
|
+
throw new Error('abx deploy-effects needs the resolver URL — pass --resolver-url https://<resolver> (or set ABX_RESOLVER_URL / ABX_PUBLIC_BASE_URL). ' +
|
|
547
|
+
'The runner reads token state from it and publishes each render back to it.');
|
|
548
|
+
}
|
|
549
|
+
const cleanResolver = resolverUrl.replace(/\/$/, '');
|
|
550
|
+
const app = flags.app ?? `${cleanResolver.replace(/^https?:\/\//, '').split(/[./]/)[0]}-effects`;
|
|
551
|
+
const dir = flags.dir ?? 'deploy';
|
|
552
|
+
const intervalMs = flags['interval-ms'] ? Number(flags['interval-ms']) : undefined;
|
|
553
|
+
const environmentId = flags['env-id'] ?? process.env.ABX_ENVIRONMENT_ID;
|
|
554
|
+
const storageBackend = process.env.ABX_STORAGE_BACKEND;
|
|
555
|
+
// A HOSTED runner holds its own render bytes and hands the resolver a URL — that's the whole
|
|
556
|
+
// topology (`specs/protocol/effects.md → Bound vs referenced`). The default `fs` writes to the
|
|
557
|
+
// runner CONTAINER's disk, which nothing else can reach, so there is no URL to publish: the runner
|
|
558
|
+
// now REFUSES to start on that config. Scaffolding it anyway would just deploy a container that
|
|
559
|
+
// exits, so this is a hard stop rather than the warning it used to be.
|
|
560
|
+
if (!storageBackend || storageBackend === 'fs') {
|
|
561
|
+
throw new Error(`ABX_STORAGE_BACKEND is ${storageBackend ? "'fs'" : 'unset (defaults to fs)'} — a hosted runner can't serve renders off its own container disk, ` +
|
|
562
|
+
`and it refuses to start without a backend that can name a public URL for what it stores.\n` +
|
|
563
|
+
` Set ABX_STORAGE_BACKEND to one of (equal options — pick on cost/ops):\n` +
|
|
564
|
+
` ${bold('cloud')} (alias s3) S3 / R2 / B2 with ABX_S3_* + a public base\n` +
|
|
565
|
+
` ${bold('ipfs')} Pinata or your own Kubo + a public gateway\n` +
|
|
566
|
+
` ${bold('arweave')} pay-once permanent (<100KiB uploads are free)\n` +
|
|
567
|
+
` …then re-run. (Rendering on the resolver's own host instead? Use ${bold('abx effects')} co-located — no publish lane, no locator needed.)`);
|
|
568
|
+
}
|
|
569
|
+
const repoRoot = findRepoRoot();
|
|
570
|
+
if (!repoRoot) {
|
|
571
|
+
throw new Error('abx deploy-effects builds the runner from workspace source — run it from the ABX checkout (pnpm-workspace.yaml not found above the CLI).');
|
|
572
|
+
}
|
|
573
|
+
const pkgDir = joinPath(repoRoot, 'packages');
|
|
574
|
+
const sourcePackages = readdirSync(pkgDir)
|
|
575
|
+
.filter((d) => existsSync(joinPath(pkgDir, d, 'package.json')))
|
|
576
|
+
.map((d) => `packages/${d}`)
|
|
577
|
+
.sort();
|
|
578
|
+
let pnpmVersion;
|
|
579
|
+
try {
|
|
580
|
+
pnpmVersion = (JSON.parse(readFileSync(joinPath(repoRoot, 'package.json'), 'utf8')).packageManager ?? '').split('@')[1] || undefined;
|
|
581
|
+
}
|
|
582
|
+
catch {
|
|
583
|
+
/* fall back to the provisioner's pinned default */
|
|
584
|
+
}
|
|
585
|
+
const art = effectsArtifact({ app, resolverUrl: cleanResolver, chain: CHAIN, intervalMs, environmentId, storageBackend, sourcePackages, pnpmVersion });
|
|
586
|
+
const outDir = resolvePath(joinPath(dir, 'effects'));
|
|
587
|
+
console.log(bold(`\n ABX Self-Host Toolkit — provision effects runner (fly)`));
|
|
588
|
+
info(`resolver: ${bold(cleanResolver)} ${dim('(reads token state + publishes renders here)')}`);
|
|
589
|
+
info(`storage home: ${bold(storageBackend)} ${dim('— the runner HOLDS the render bytes here and publishes their URL; the resolver redirects to it and never proxies')}`);
|
|
590
|
+
step('Write the effects artifact (self-contained — Dockerfile.effects + fly.toml + vendored source)');
|
|
591
|
+
mkdirSync(outDir, { recursive: true });
|
|
592
|
+
for (const f of art.files) {
|
|
593
|
+
writeFileSync(joinPath(outDir, f.path), f.content);
|
|
594
|
+
info(`wrote ${joinPath(dir, 'effects', f.path)}`);
|
|
595
|
+
}
|
|
596
|
+
vendorResolverSource(repoRoot, outDir);
|
|
597
|
+
info(`vendored the workspace source into ${joinPath(dir, 'effects')}/ (root manifests + packages/, no node_modules)`);
|
|
598
|
+
step(`Next steps (run from ${bold(joinPath(dir, 'effects'))}/ — they use your own cloud account)`);
|
|
599
|
+
art.steps.forEach((s, i) => console.log(` ${g(`${i + 1}.`)} ${s}`));
|
|
600
|
+
step('Admin token — the runner publishes renders to the resolver with it (index-control only, never signing)');
|
|
601
|
+
const { generated } = ensureAdminToken();
|
|
602
|
+
if (generated)
|
|
603
|
+
ok('generated ABX_REMOTE_SELF_TOKEN and saved it to your local .env (a secret — not printed, env-only).');
|
|
604
|
+
else
|
|
605
|
+
info('using the existing ABX_REMOTE_SELF_TOKEN from your .env.');
|
|
606
|
+
info('It MUST match ABX_RESOLVER_ADMIN_TOKEN on the resolver (`abx deploy-resolver` set the same value) — publishing is admin-gated.');
|
|
607
|
+
step('Effects token — gates this PUBLIC runner\'s /run + /notify (the resolver\'s watcher sends it)');
|
|
608
|
+
const eff = ensureEffectsToken();
|
|
609
|
+
if (eff.generated)
|
|
610
|
+
ok('generated ABX_EFFECTS_TOKEN and saved it to your local .env (a secret — not printed, env-only).');
|
|
611
|
+
else
|
|
612
|
+
info('using the existing ABX_EFFECTS_TOKEN from your .env.');
|
|
613
|
+
info('Set it on BOTH apps (the steps above do): the runner enforces it; the resolver sends it with every notify.');
|
|
614
|
+
step('Security posture');
|
|
615
|
+
info(art.hardening);
|
|
616
|
+
console.log('');
|
|
617
|
+
}
|
|
618
|
+
//# sourceMappingURL=service.js.map
|