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