@artblocks/abx-cli 0.1.0-alpha.2 → 0.1.0-alpha.21

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.
Files changed (137) hide show
  1. package/CHANGELOG.md +1991 -0
  2. package/assets/renderer-scaffold/src/interfaces/IAbxParams.sol +17 -0
  3. package/assets/renderer-scaffold/test/MyRenderer.t.sol +51 -2
  4. package/dist/commands/deploy.d.ts +206 -0
  5. package/dist/commands/deploy.d.ts.map +1 -0
  6. package/dist/commands/deploy.js +4273 -0
  7. package/dist/commands/deploy.js.map +1 -0
  8. package/dist/commands/project.d.ts +241 -0
  9. package/dist/commands/project.d.ts.map +1 -0
  10. package/dist/commands/project.js +1060 -0
  11. package/dist/commands/project.js.map +1 -0
  12. package/dist/commands/reads.d.ts +64 -0
  13. package/dist/commands/reads.d.ts.map +1 -0
  14. package/dist/commands/reads.js +697 -0
  15. package/dist/commands/reads.js.map +1 -0
  16. package/dist/commands/scaffold.d.ts +71 -0
  17. package/dist/commands/scaffold.d.ts.map +1 -0
  18. package/dist/commands/scaffold.js +552 -0
  19. package/dist/commands/scaffold.js.map +1 -0
  20. package/dist/commands/service.d.ts +41 -0
  21. package/dist/commands/service.d.ts.map +1 -0
  22. package/dist/commands/service.js +618 -0
  23. package/dist/commands/service.js.map +1 -0
  24. package/dist/commands/storage.d.ts +37 -0
  25. package/dist/commands/storage.d.ts.map +1 -0
  26. package/dist/commands/storage.js +343 -0
  27. package/dist/commands/storage.js.map +1 -0
  28. package/dist/config.d.ts +91 -2
  29. package/dist/config.d.ts.map +1 -1
  30. package/dist/config.js +244 -11
  31. package/dist/config.js.map +1 -1
  32. package/dist/conformance.d.ts +31 -0
  33. package/dist/conformance.d.ts.map +1 -0
  34. package/dist/conformance.js +371 -0
  35. package/dist/conformance.js.map +1 -0
  36. package/dist/deps.d.ts +6 -39
  37. package/dist/deps.d.ts.map +1 -1
  38. package/dist/deps.js +4 -68
  39. package/dist/deps.js.map +1 -1
  40. package/dist/errors.d.ts +20 -0
  41. package/dist/errors.d.ts.map +1 -0
  42. package/dist/errors.js +25 -0
  43. package/dist/errors.js.map +1 -0
  44. package/dist/flag-allowlists.d.ts +53 -0
  45. package/dist/flag-allowlists.d.ts.map +1 -0
  46. package/dist/flag-allowlists.js +148 -0
  47. package/dist/flag-allowlists.js.map +1 -0
  48. package/dist/flags.d.ts +41 -0
  49. package/dist/flags.d.ts.map +1 -1
  50. package/dist/flags.js +94 -1
  51. package/dist/flags.js.map +1 -1
  52. package/dist/jsonout.d.ts +37 -0
  53. package/dist/jsonout.d.ts.map +1 -0
  54. package/dist/jsonout.js +68 -0
  55. package/dist/jsonout.js.map +1 -0
  56. package/dist/kind.d.ts +53 -0
  57. package/dist/kind.d.ts.map +1 -0
  58. package/dist/kind.js +94 -0
  59. package/dist/kind.js.map +1 -0
  60. package/dist/main.js +534 -4708
  61. package/dist/main.js.map +1 -1
  62. package/dist/mintpage.d.ts +17 -2
  63. package/dist/mintpage.d.ts.map +1 -1
  64. package/dist/mintpage.js +214 -52
  65. package/dist/mintpage.js.map +1 -1
  66. package/dist/output.d.ts +148 -0
  67. package/dist/output.d.ts.map +1 -0
  68. package/dist/output.js +634 -0
  69. package/dist/output.js.map +1 -0
  70. package/dist/ownerops.d.ts +132 -44
  71. package/dist/ownerops.d.ts.map +1 -1
  72. package/dist/ownerops.js +1151 -329
  73. package/dist/ownerops.js.map +1 -1
  74. package/dist/preview.d.ts +81 -0
  75. package/dist/preview.d.ts.map +1 -0
  76. package/dist/preview.js +535 -0
  77. package/dist/preview.js.map +1 -0
  78. package/dist/prompt.d.ts +17 -0
  79. package/dist/prompt.d.ts.map +1 -0
  80. package/dist/prompt.js +19 -0
  81. package/dist/prompt.js.map +1 -0
  82. package/dist/provision.d.ts.map +1 -1
  83. package/dist/provision.js +10 -7
  84. package/dist/provision.js.map +1 -1
  85. package/dist/remote.d.ts +134 -53
  86. package/dist/remote.d.ts.map +1 -1
  87. package/dist/remote.js +358 -46
  88. package/dist/remote.js.map +1 -1
  89. package/dist/riskgate.d.ts +29 -0
  90. package/dist/riskgate.d.ts.map +1 -0
  91. package/dist/riskgate.js +91 -0
  92. package/dist/riskgate.js.map +1 -0
  93. package/dist/scaffold.d.ts +10 -0
  94. package/dist/scaffold.d.ts.map +1 -0
  95. package/dist/scaffold.js +52 -0
  96. package/dist/scaffold.js.map +1 -0
  97. package/dist/schema.d.ts +18 -1
  98. package/dist/schema.d.ts.map +1 -1
  99. package/dist/schema.js +74 -23
  100. package/dist/schema.js.map +1 -1
  101. package/dist/served.d.ts +30 -0
  102. package/dist/served.d.ts.map +1 -0
  103. package/dist/served.js +112 -0
  104. package/dist/served.js.map +1 -0
  105. package/dist/signer.d.ts +13 -0
  106. package/dist/signer.d.ts.map +1 -1
  107. package/dist/signer.js +80 -14
  108. package/dist/signer.js.map +1 -1
  109. package/dist/update-check.d.ts +83 -4
  110. package/dist/update-check.d.ts.map +1 -1
  111. package/dist/update-check.js +132 -17
  112. package/dist/update-check.js.map +1 -1
  113. package/package.json +10 -6
  114. package/skill/SKILL.md +108 -62
  115. package/skill/reference/code-projects.md +109 -13
  116. package/skill/reference/creator-token.md +86 -0
  117. package/skill/reference/decisions.md +87 -0
  118. package/skill/reference/hosting.md +48 -15
  119. package/skill/reference/operating.md +32 -8
  120. package/skill/reference/setup.md +10 -3
  121. package/skill/reference/troubleshooting.md +31 -4
  122. package/dist/inspect.d.ts +0 -48
  123. package/dist/inspect.d.ts.map +0 -1
  124. package/dist/inspect.js +0 -184
  125. package/dist/inspect.js.map +0 -1
  126. package/dist/migrate.d.ts +0 -65
  127. package/dist/migrate.d.ts.map +0 -1
  128. package/dist/migrate.js +0 -180
  129. package/dist/migrate.js.map +0 -1
  130. package/dist/onchain-uri.d.ts +0 -97
  131. package/dist/onchain-uri.d.ts.map +0 -1
  132. package/dist/onchain-uri.js +0 -243
  133. package/dist/onchain-uri.js.map +0 -1
  134. package/dist/upload.d.ts +0 -28
  135. package/dist/upload.d.ts.map +0 -1
  136. package/dist/upload.js +0 -41
  137. package/dist/upload.js.map +0 -1
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../../src/commands/service.ts"],"names":[],"mappings":"AAmBA,OAAO,EAEL,KAAK,OAAO,EAUb,MAAM,oBAAoB,CAAC;AAK5B,OAAO,EAAC,KAAK,KAAK,EAAC,MAAM,aAAa,CAAC;AAiCvC;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,IAAI;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAC,CAStE;AAED;+FAC+F;AAC/F,wBAAgB,kBAAkB,IAAI;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,OAAO,CAAA;CAAC,CASxE;AAED;;;;;;;;GAQG;AACH,wBAAsB,UAAU,CAAC,KAAK,EAAE,KAAK,GAAG,OAAO,CAAC,IAAI,CAAC,CA0C5D;AAGD,wBAAsB,QAAQ,CAAC,KAAK,EAAE,KAAK,iBAiC1C;AAQD,wBAAsB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,SAAS,EAAE,KAAK,EAAE,KAAK,iBAoFrE;AA+DD,wBAAsB,UAAU,CAAC,OAAO,EAAE,OAAO,GAAG,SAAS,EAAE,KAAK,EAAE,KAAK,iBAmI1E;AAED;mGACmG;AACnG,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CASvE;AAED,wBAAsB,iBAAiB,CAAC,KAAK,EAAE,KAAK,iBAyGnD;AAMD,wBAAsB,gBAAgB,CAAC,KAAK,EAAE,KAAK,iBAwFlD"}
@@ -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.minted && !repinnedTokens.has(t.tokenId)) ??
396
+ local.tokens.find((t) => t.minted) ??
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 <art> --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