openzoo 0.50.99 → 0.51.0

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 (39) hide show
  1. package/bin/openzoo.js +33 -0
  2. package/lib/cursorbackend.js +6 -1
  3. package/lib/cursorbackend.js.bak-hiddenbots +4072 -0
  4. package/lib/models.js +6 -1
  5. package/package.json +7 -4
  6. package/transmute/README.md +352 -0
  7. package/transmute/bin/openzoo-transmute.js +7 -0
  8. package/transmute/docs/OPENZOO_CLI_PATCH.md +139 -0
  9. package/transmute/docs/VERCEL_TO_SOLANA.md +381 -0
  10. package/transmute/lib/build.js +311 -0
  11. package/transmute/lib/cli.js +231 -0
  12. package/transmute/lib/compile/index.js +146 -0
  13. package/transmute/lib/compile/ir.js +1634 -0
  14. package/transmute/lib/compile/parse.js +753 -0
  15. package/transmute/lib/compile/rust.js +777 -0
  16. package/transmute/lib/deploy.js +209 -0
  17. package/transmute/lib/eligibility.js +211 -0
  18. package/transmute/lib/gateway.js +704 -0
  19. package/transmute/lib/hub.js +249 -0
  20. package/transmute/lib/solana.js +339 -0
  21. package/transmute/lib/vercel.js +296 -0
  22. package/transmute/lib/wallet.js +41 -0
  23. package/transmute/lib/wire.js +167 -0
  24. package/transmute/runtime/zoo-host/Cargo.lock +588 -0
  25. package/transmute/runtime/zoo-host/Cargo.toml +28 -0
  26. package/transmute/runtime/zoo-host/src/assets.rs +170 -0
  27. package/transmute/runtime/zoo-host/src/ctx.rs +322 -0
  28. package/transmute/runtime/zoo-host/src/json.rs +257 -0
  29. package/transmute/runtime/zoo-host/src/kv.rs +182 -0
  30. package/transmute/runtime/zoo-host/src/lib.rs +123 -0
  31. package/transmute/runtime/zoo-host/src/val.rs +1044 -0
  32. package/transmute/runtime/zoo-host/src/wire.rs +239 -0
  33. package/transmute/site/.env.production +2 -0
  34. package/transmute/site/api/echo.js +10 -0
  35. package/transmute/site/api/hello.js +12 -0
  36. package/transmute/site/api/hits.js +13 -0
  37. package/transmute/site/api/time.js +5 -0
  38. package/transmute/site/public/app.js +16 -0
  39. package/transmute/site/public/index.html +62 -0
@@ -0,0 +1,311 @@
1
+ // `build`: Vercel deployment model → Rust crate → .so, plus the cost sheet.
2
+ //
3
+ // readDeployment(root) → transmute() → <out>/.zoo/{crate/, manifest.json,
4
+ // report.json, static-plan.json, build.json} → cargo build-sbf → .so
5
+ //
6
+ // The compiler (lib/compile/index.js) owns the JS→Rust translation; this
7
+ // module owns the filesystem layout, the cargo invocation and the rent
8
+ // estimate so `deploy` can pick the artifacts up without re-reading the
9
+ // source repo.
10
+ import fs from 'node:fs';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import { spawn } from 'node:child_process';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { readDeployment } from './vercel.js';
16
+ import { ASSET_FIXED_HEADER, MANIFEST_PATH } from './wire.js';
17
+ import { connect } from './solana.js';
18
+ import { rpcUrl } from './wallet.js';
19
+
20
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
21
+ export const RUNTIME_PATH = path.resolve(HERE, '..', 'runtime', 'zoo-host');
22
+ export const SOLANA_BIN = path.join(os.homedir(), '.local', 'share', 'solana', 'install', 'active_release', 'bin');
23
+ export const INSTALL_HINT = 'sh -c "$(curl -sSfL https://release.anza.xyz/stable/install)"';
24
+ export const DEFAULT_ARCH = 'v0';
25
+ export const ARCHES = ['v0', 'v1', 'v2', 'v3', 'v4'];
26
+ /** Rent rule of thumb: 3480 lamports per byte-year × 2 years = 6960 lamports/byte ≈ 6.96 SOL/MB. */
27
+ export const LAMPORTS_PER_BYTE = 6960;
28
+ export const ACCOUNT_OVERHEAD_BYTES = 128;
29
+ export const LAMPORTS_PER_SOL = 1_000_000_000;
30
+ /** BPF upgradeable loader account sizes. */
31
+ export const PROGRAM_ACCOUNT_BYTES = 36;
32
+ export const PROGRAMDATA_HEADER_BYTES = 45;
33
+ export const BUFFER_HEADER_BYTES = 37;
34
+
35
+ // ---------------------------------------------------------------- naming
36
+
37
+ /** A crate name Cargo accepts, derived from the project directory (or --name). */
38
+ export function crateNameFor(root, name) {
39
+ const base = (name || path.basename(path.resolve(root)) || 'site').toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
40
+ const safe = /^[a-z]/.test(base) ? base : 'site-' + base;
41
+ return safe || 'site';
42
+ }
43
+ export function soNameFor(crateName) { return crateName.replace(/-/g, '_') + '.so'; }
44
+
45
+ /** Parse `[package] name` out of a Cargo.toml (falls back to `fallback`). */
46
+ export function packageNameOf(cargoToml, fallback) {
47
+ const m = String(cargoToml || '').match(/\[package\][^[]*?\bname\s*=\s*"([^"]+)"/);
48
+ return m ? m[1] : fallback;
49
+ }
50
+
51
+ // ---------------------------------------------------------------- cargo
52
+
53
+ /** Process env with the Solana toolchain on PATH (`cargo build-sbf` lives there). */
54
+ export function solanaEnv(extra = {}) {
55
+ const sep = path.delimiter;
56
+ const cur = process.env.PATH || '';
57
+ const parts = cur.split(sep);
58
+ const PATH = parts.includes(SOLANA_BIN) ? cur : [SOLANA_BIN, path.join(os.homedir(), '.cargo', 'bin'), cur].filter(Boolean).join(sep);
59
+ return { ...process.env, PATH, ...extra };
60
+ }
61
+
62
+ /** Absolute path of `cargo-build-sbf` on the given PATH, or null. */
63
+ export function findCargoBuildSbf(env = solanaEnv()) {
64
+ const exe = process.platform === 'win32' ? 'cargo-build-sbf.exe' : 'cargo-build-sbf';
65
+ for (const dir of (env.PATH || '').split(path.delimiter)) {
66
+ if (!dir) continue;
67
+ const p = path.join(dir, exe);
68
+ try { fs.accessSync(p, fs.constants.X_OK); return p; } catch { /* next */ }
69
+ }
70
+ return null;
71
+ }
72
+
73
+ /**
74
+ * Run `cargo build-sbf --arch <arch>` in `crateDir`. Never throws for a
75
+ * missing toolchain: returns `{ok:false, missing:true}` with the install hint.
76
+ * @returns {Promise<{ok:boolean, missing?:boolean, soPath:string|null, arch:string, code?:number, output:string, error?:string}>}
77
+ */
78
+ export async function runCargoBuildSbf(crateDir, { arch = DEFAULT_ARCH, outDir, log = console.log, env = solanaEnv(), cargo = 'cargo', extraArgs = [] } = {}) {
79
+ if (!ARCHES.includes(arch)) throw new Error(`unknown --arch ${arch} (expected one of ${ARCHES.join(', ')})`);
80
+ const sbfOut = outDir || path.join(crateDir, 'target', 'deploy');
81
+ if (!findCargoBuildSbf(env) && cargo === 'cargo') {
82
+ log(`cargo-build-sbf not found on PATH (looked in ${SOLANA_BIN}).`);
83
+ log(`install the Solana toolchain with:\n ${INSTALL_HINT}\nthen re-run \`build\`; continuing without a .so.`);
84
+ return { ok: false, missing: true, soPath: null, arch, output: '', error: 'cargo-build-sbf not installed' };
85
+ }
86
+ const args = ['build-sbf', '--arch', arch, '--sbf-out-dir', sbfOut, ...extraArgs];
87
+ log(`$ ${cargo} ${args.join(' ')} (in ${crateDir})`);
88
+ const t0 = Date.now();
89
+ const res = await new Promise((resolve) => {
90
+ let output = '';
91
+ let child;
92
+ try {
93
+ child = spawn(cargo, args, { cwd: crateDir, env, stdio: ['ignore', 'pipe', 'pipe'] });
94
+ } catch (e) { return resolve({ code: -1, output: '', error: String(e?.message || e) }); }
95
+ const onData = (d) => { const s = d.toString(); output += s; for (const line of s.split('\n')) if (line.trim()) log(' ' + line.trimEnd()); };
96
+ child.stdout.on('data', onData);
97
+ child.stderr.on('data', onData);
98
+ child.on('error', (e) => resolve({ code: -1, output, error: String(e?.message || e) }));
99
+ child.on('close', (code) => resolve({ code, output }));
100
+ });
101
+ if (res.error && /ENOENT/.test(res.error)) {
102
+ log(`\`${cargo}\` is not installed. Install the Solana toolchain with:\n ${INSTALL_HINT}`);
103
+ return { ok: false, missing: true, soPath: null, arch, output: res.output, error: res.error };
104
+ }
105
+ if (res.code !== 0) return { ok: false, soPath: null, arch, code: res.code, output: res.output, error: res.error || `cargo build-sbf exited with ${res.code}` };
106
+ const soPath = findSo(sbfOut, res.output);
107
+ log(`built ${soPath ? path.relative(process.cwd(), soPath) : '(no .so found?)'} in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
108
+ return { ok: !!soPath, soPath, arch, code: 0, output: res.output, error: soPath ? undefined : `no .so found in ${sbfOut}` };
109
+ }
110
+
111
+ function findSo(dir, output = '') {
112
+ const m = output.match(/\b(\/\S+\.so)\b/);
113
+ if (m && fs.existsSync(m[1])) return m[1];
114
+ if (!fs.existsSync(dir)) return null;
115
+ const sos = fs.readdirSync(dir).filter((f) => f.endsWith('.so')).map((f) => path.join(dir, f)).sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
116
+ return sos[0] || null;
117
+ }
118
+
119
+ // ---------------------------------------------------------------- cost
120
+
121
+ function accountBytesFor(kind, size, contentType = '') {
122
+ switch (kind) {
123
+ case 'asset': return ASSET_FIXED_HEADER + Buffer.byteLength(contentType.slice(0, 120)) + size;
124
+ case 'program': return PROGRAM_ACCOUNT_BYTES;
125
+ case 'programdata': return PROGRAMDATA_HEADER_BYTES + size;
126
+ case 'buffer': return BUFFER_HEADER_BYTES + size;
127
+ default: return size;
128
+ }
129
+ }
130
+
131
+ /** Reach the cluster within `timeoutMs`; null when unreachable. */
132
+ export async function probeConnection(cluster, { timeoutMs = 2500 } = {}) {
133
+ let url;
134
+ try { url = rpcUrl(cluster); } catch { return null; }
135
+ const connection = connect(url);
136
+ try {
137
+ await Promise.race([connection.getSlot('confirmed'), new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), timeoutMs).unref?.())]);
138
+ return connection;
139
+ } catch { return null; }
140
+ }
141
+
142
+ /**
143
+ * Rent the deployment will lock. Uses the cluster's real rent parameters
144
+ * when a connection is given, else the 6.96 SOL/MB rule of thumb.
145
+ * @param {{staticFiles:Array<{path:string,size:number,contentType?:string}>, soSize?:number|null, manifestBytes?:number, connection?:object|null, upgrade?:boolean}} o
146
+ */
147
+ export async function estimateCost({ staticFiles = [], soSize = null, manifestBytes = 0, connection = null, upgrade = false }) {
148
+ const items = [];
149
+ if (soSize != null) {
150
+ if (upgrade) items.push({ label: 'program upgrade buffer (refunded on success)', kind: 'buffer', bytes: accountBytesFor('buffer', soSize), transient: true });
151
+ else {
152
+ items.push({ label: 'program account', kind: 'program', bytes: accountBytesFor('program', 0) });
153
+ items.push({ label: `program data (2×.so = ${(soSize * 2).toLocaleString()} B)`, kind: 'programdata', bytes: accountBytesFor('programdata', soSize * 2) });
154
+ }
155
+ } else {
156
+ items.push({ label: 'program (no .so built yet)', kind: 'program', bytes: 0, unknown: true });
157
+ }
158
+ for (const f of staticFiles) items.push({ label: f.path, kind: 'asset', bytes: accountBytesFor('asset', f.size, f.contentType || '') });
159
+ items.push({ label: MANIFEST_PATH + ' (manifest)', kind: 'asset', bytes: accountBytesFor('asset', manifestBytes, 'application/json; charset=utf-8') });
160
+
161
+ let source = 'rule-of-thumb';
162
+ const rentFor = new Map();
163
+ if (connection) {
164
+ try {
165
+ const sizes = [...new Set(items.filter((i) => !i.unknown).map((i) => i.bytes))];
166
+ await Promise.all(sizes.map(async (n) => rentFor.set(n, await connection.getMinimumBalanceForRentExemption(n))));
167
+ source = 'rpc';
168
+ } catch { rentFor.clear(); }
169
+ }
170
+ let lamports = 0, transientLamports = 0;
171
+ for (const it of items) {
172
+ it.lamports = it.unknown ? 0 : (rentFor.get(it.bytes) ?? (ACCOUNT_OVERHEAD_BYTES + it.bytes) * LAMPORTS_PER_BYTE);
173
+ it.sol = it.lamports / LAMPORTS_PER_SOL;
174
+ if (it.transient) transientLamports += it.lamports; else lamports += it.lamports;
175
+ }
176
+ const feeLamports = 5000 * (1 + Math.ceil((soSize || 0) / 900) + staticFiles.reduce((n, f) => n + 1 + Math.ceil(f.size / 900), 0) + 2);
177
+ return {
178
+ source, rpc: connection?.rpcEndpoint || null, items,
179
+ lamports, sol: lamports / LAMPORTS_PER_SOL,
180
+ transientLamports, transientSol: transientLamports / LAMPORTS_PER_SOL,
181
+ feeLamports, feeSol: feeLamports / LAMPORTS_PER_SOL,
182
+ totalLamports: lamports + transientLamports + feeLamports,
183
+ totalSol: (lamports + transientLamports + feeLamports) / LAMPORTS_PER_SOL,
184
+ unknownProgram: soSize == null,
185
+ };
186
+ }
187
+
188
+ /** Render the cost estimate as a plain-text table. */
189
+ export function formatCostTable(est, { maxRows = 40 } = {}) {
190
+ const rows = est.items.map((i) => [i.label, i.unknown ? '?' : i.bytes.toLocaleString(), i.unknown ? '?' : i.sol.toFixed(4)]);
191
+ const shown = rows.length > maxRows ? [...rows.slice(0, maxRows), [`… ${rows.length - maxRows} more files`, '', '']] : rows;
192
+ shown.push(['rent total', '', est.sol.toFixed(4)]);
193
+ if (est.transientLamports) shown.push(['+ transient (refunded)', '', est.transientSol.toFixed(4)]);
194
+ shown.push(['+ tx fees (approx.)', '', est.feeSol.toFixed(4)]);
195
+ shown.push(['TOTAL', '', est.totalSol.toFixed(4)]);
196
+ const w0 = Math.min(60, Math.max(...shown.map((r) => r[0].length), 4));
197
+ const w1 = Math.max(...shown.map((r) => r[1].length), 5);
198
+ const lines = [` ${'item'.padEnd(w0)} ${'bytes'.padStart(w1)} ${'SOL'.padStart(8)}`];
199
+ for (const [a, b, c] of shown) lines.push(` ${a.length > w0 ? a.slice(0, w0 - 1) + '…' : a.padEnd(w0)} ${b.padStart(w1)} ${c.padStart(8)}`);
200
+ lines.push(` (rent per ${est.source === 'rpc' ? est.rpc.replace(/\?.*$/, '') : 'the 6.96 SOL/MB rule of thumb'}${est.unknownProgram ? '; program rent unknown until the .so is built' : ''})`);
201
+ return lines.join('\n');
202
+ }
203
+
204
+ // ---------------------------------------------------------------- build
205
+
206
+ async function loadTransmute() {
207
+ try {
208
+ const mod = await import('./compile/index.js');
209
+ if (typeof mod.transmute !== 'function') throw new Error('lib/compile/index.js does not export transmute()');
210
+ return mod.transmute;
211
+ } catch (e) {
212
+ if (e?.code === 'ERR_MODULE_NOT_FOUND' && /compile\/index\.js/.test(String(e.message))) {
213
+ throw new Error('the compiler (lib/compile/index.js) is not present in this install of openzoo-transmute');
214
+ }
215
+ throw e;
216
+ }
217
+ }
218
+
219
+ function writeJson(p, obj) { fs.writeFileSync(p, JSON.stringify(obj, null, 2) + '\n'); }
220
+
221
+ /** Point the generated Cargo.toml at a zoo-host checkout that exists on this machine. */
222
+ export function fixRuntimePath(cargoToml, runtimePath) {
223
+ const rt = runtimePath.replace(/\\/g, '/');
224
+ const re = /(zoo-host\s*=\s*\{[^}]*?\bpath\s*=\s*")([^"]*)(")/;
225
+ const m = cargoToml.match(re);
226
+ if (!m) return cargoToml.includes('zoo-host') ? cargoToml : cargoToml + `\n[dependencies.zoo-host]\npath = "${rt}"\n`;
227
+ if (path.isAbsolute(m[2]) && fs.existsSync(path.join(m[2], 'Cargo.toml'))) return cargoToml;
228
+ return cargoToml.replace(re, `$1${rt}$3`);
229
+ }
230
+
231
+ /**
232
+ * Build a Vercel-shaped repo into `<out>/.zoo/`.
233
+ *
234
+ * @param {string} root the app's directory
235
+ * @param {{out?:string, name?:string, arch?:string, cluster?:string, runtimePath?:string, skipCargo?:boolean,
236
+ * transmute?:Function, log?:Function, cargo?:string, connection?:object|null}} opts
237
+ * @returns {Promise<{outDir:string, zooDir:string, crateDir:string, manifest:object, report:object, soPath:string|null, arch:string, costEstimate:object, deployment:object, notes:string[]}>}
238
+ */
239
+ export async function build(root = '.', opts = {}) {
240
+ const log = opts.log ?? console.log;
241
+ root = path.resolve(root);
242
+ if (!fs.existsSync(root)) throw new Error(`${root}: no such directory`);
243
+ const outDir = path.resolve(opts.out || path.join(root, '.zoo-out'));
244
+ const zooDir = path.join(outDir, '.zoo');
245
+ const crateDir = path.join(zooDir, 'crate');
246
+ const notes = [];
247
+
248
+ const deployment = readDeployment(root);
249
+ const name = crateNameFor(root, opts.name);
250
+ const runtimePath = path.resolve(opts.runtimePath || RUNTIME_PATH);
251
+ log(`reading ${root} → ${deployment.source}${deployment.framework ? ` (${deployment.framework})` : ''}: ${deployment.functions.length} function(s), ${deployment.staticFiles.length} static file(s)`);
252
+ for (const n of deployment.notes) { log(`note: ${n}`); notes.push(n); }
253
+
254
+ const transmute = opts.transmute || await loadTransmute();
255
+ const result = await transmute(deployment, { name, runtimePath, arch: opts.arch, root, outDir });
256
+ if (!result || !result.crate || !result.crate['Cargo.toml'] || !result.crate['src/lib.rs']) throw new Error('transmute() returned no crate (expected {crate:{"Cargo.toml","src/lib.rs"}, manifest, report})');
257
+ const manifest = { name, ...(result.manifest || {}) };
258
+ if (!manifest.version) manifest.version = 1;
259
+ if (!manifest.framework) manifest.framework = deployment.framework;
260
+ if (!Array.isArray(manifest.routes)) manifest.routes = [];
261
+ if (!Array.isArray(manifest.static)) manifest.static = deployment.staticFiles.map((f) => ({ path: f.path, contentType: f.contentType, size: f.size }));
262
+ if (!manifest.config) manifest.config = deployment.config;
263
+ const report = { eligible: [], ineligible: [], warnings: [], ...(result.report || {}) };
264
+
265
+ // Lay the crate out.
266
+ fs.rmSync(crateDir, { recursive: true, force: true });
267
+ fs.mkdirSync(crateDir, { recursive: true });
268
+ for (const [rel, content] of Object.entries(result.crate)) {
269
+ const p = path.join(crateDir, rel);
270
+ fs.mkdirSync(path.dirname(p), { recursive: true });
271
+ fs.writeFileSync(p, rel === 'Cargo.toml' ? fixRuntimePath(String(content), runtimePath) : content);
272
+ }
273
+ const crateName = packageNameOf(result.crate['Cargo.toml'], name);
274
+ const byPath = new Map(deployment.staticFiles.map((f) => [f.path, f]));
275
+ const staticPlan = manifest.static.map((s) => ({ ...s, file: byPath.get(s.path)?.file || s.file || null }));
276
+ writeJson(path.join(zooDir, 'manifest.json'), manifest);
277
+ writeJson(path.join(zooDir, 'report.json'), report);
278
+ writeJson(path.join(zooDir, 'static-plan.json'), staticPlan);
279
+ log(`wrote ${path.relative(process.cwd(), zooDir) || zooDir}/{crate/, manifest.json, report.json, static-plan.json}`);
280
+ log(`functions: ${report.eligible.length} eligible, ${report.ineligible.length} ineligible${report.warnings.length ? `, ${report.warnings.length} warning(s)` : ''}`);
281
+ for (const i of report.ineligible) log(` ✗ ${i.name}: ${i.reason}${i.file ? ` (${path.relative(root, i.file)}${i.line ? ':' + i.line : ''})` : ''}`);
282
+ for (const w of report.warnings) log(` ! ${typeof w === 'string' ? w : w.message || JSON.stringify(w)}`);
283
+
284
+ // Compile.
285
+ let arch = opts.arch || DEFAULT_ARCH;
286
+ if (!opts.arch) { const n = `no --arch given: building for ${DEFAULT_ARCH} (mainnet); \`deploy\` re-detects the cluster's SBPF version and rebuilds if needed`; notes.push(n); log(`note: ${n}`); }
287
+ let soPath = null, cargo = null;
288
+ if (opts.skipCargo) { notes.push('cargo build skipped (--skip-cargo)'); log('skipping cargo build-sbf (--skip-cargo)'); }
289
+ else {
290
+ cargo = await runCargoBuildSbf(crateDir, { arch, outDir: path.join(zooDir, 'deploy'), log, cargo: opts.cargo, env: opts.env });
291
+ if (cargo.ok) soPath = cargo.soPath;
292
+ else if (cargo.missing) notes.push(`cargo-build-sbf not installed; install with: ${INSTALL_HINT}`);
293
+ else throw Object.assign(new Error(`cargo build-sbf failed (${cargo.error})\n${cargo.output.split('\n').filter((l) => /error/i.test(l)).slice(0, 20).join('\n')}`), { output: cargo.output });
294
+ }
295
+ const soSize = soPath ? fs.statSync(soPath).size : null;
296
+ writeJson(path.join(zooDir, 'build.json'), {
297
+ name, crateName, root, outDir, arch, soPath, soSize, soName: soNameFor(crateName), builtAt: new Date().toISOString(),
298
+ runtimePath, cargo: cargo ? { ok: cargo.ok, missing: !!cargo.missing } : { skipped: true },
299
+ });
300
+
301
+ // Cost sheet.
302
+ const connection = opts.connection !== undefined ? opts.connection : await probeConnection(opts.cluster || 'localnet');
303
+ const manifestBytes = Buffer.byteLength(JSON.stringify({ ...manifest, programId: 'x'.repeat(44), deployedAt: new Date().toISOString() }));
304
+ const costEstimate = await estimateCost({ staticFiles: staticPlan, soSize, manifestBytes, connection });
305
+ log('');
306
+ log(formatCostTable(costEstimate));
307
+ log('');
308
+ if (soPath) log(`.so: ${soPath} (${soSize.toLocaleString()} B, ${arch})`);
309
+ log(`next: openzoo-transmute deploy ${path.relative(process.cwd(), outDir) || outDir} --cluster <localnet|devnet|mainnet>`);
310
+ return { outDir, zooDir, crateDir, manifest, report, soPath, arch, costEstimate, deployment, notes, staticPlan };
311
+ }
@@ -0,0 +1,231 @@
1
+ // The openzoo-transmute command line.
2
+ //
3
+ // The openzoo package can mount these subcommands in its own bin without
4
+ // duplicating anything:
5
+ //
6
+ // case 'build': case 'deploy': case 'serve':
7
+ // await (await import('openzoo-transmute/lib/cli.js')).run(process.argv.slice(2));
8
+ //
9
+ // `run(argv)` resolves to the exit code (0 = ok) and also sets
10
+ // `process.exitCode` when non-zero; it never calls `process.exit` itself so a
11
+ // `serve` keeps the event loop (and the http server) alive.
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { PublicKey } from '@solana/web3.js';
15
+ import { readDeployment } from './vercel.js';
16
+ import { build } from './build.js';
17
+ import { deploy, resolveOutDir, clusterLabel } from './deploy.js';
18
+ import { startGateway, DEFAULT_PORT } from './gateway.js';
19
+ import { startHub, DEFAULT_HUB_PORT } from './hub.js';
20
+ import { connect, getProgramInfo, readManifest } from './solana.js';
21
+ import { loadWallet, rpcUrl } from './wallet.js';
22
+ import { MANIFEST_PATH } from './wire.js';
23
+
24
+ export const USAGE = `openzoo-transmute — Vercel app → Solana program + asset accounts
25
+
26
+ usage:
27
+ openzoo-transmute build [dir] [--out .zoo-out] [--name <crate>] [--arch v0|v3] [--cluster <c>] [--skip-cargo]
28
+ openzoo-transmute deploy [dir|outDir] [--cluster mainnet|devnet|localnet|<url>] [--keypair <path>] [--yes] [--program <id>]
29
+ [--concurrency 4] [--skip-assets] [--force]
30
+ openzoo-transmute serve <programId> [--cluster <c>] [--port ${DEFAULT_PORT}] [--keypair <path>] [--host 127.0.0.1] [--quiet]
31
+ openzoo-transmute hub [--cluster mainnet] [--port 8080] [--host 0.0.0.0] [--public-url <https://…>]
32
+ hosted explorer for EVERY program on the cluster (/s/<programId>), read-only
33
+ openzoo-transmute inspect [dir] print the Vercel model + eligibility report
34
+ openzoo-transmute status <programId> [--cluster <c>]
35
+ openzoo-transmute help
36
+
37
+ clusters: mainnet (default, needs --yes to deploy), devnet, testnet, localnet, or an RPC URL.
38
+ env: OPENZOO_CLUSTER, OPENZOO_RPC (mainnet RPC), OPENZOO_KEYPAIR / OPENZOO_WALLET (signer).
39
+ `;
40
+
41
+ /** `--k v`, `--k=v`, `--flag`, `--no-flag`; everything else is positional. */
42
+ export function parseArgs(argv) {
43
+ const flags = {};
44
+ const positionals = [];
45
+ const boolFlags = new Set(['yes', 'skip-cargo', 'skip-assets', 'force', 'quiet', 'help', 'json', 'version']);
46
+ for (let i = 0; i < argv.length; i++) {
47
+ const a = argv[i];
48
+ if (a === '--') { positionals.push(...argv.slice(i + 1)); break; }
49
+ if (a.startsWith('--')) {
50
+ const eq = a.indexOf('=');
51
+ let key = eq >= 0 ? a.slice(2, eq) : a.slice(2);
52
+ let val;
53
+ if (eq >= 0) val = a.slice(eq + 1);
54
+ else if (key.startsWith('no-')) { key = key.slice(3); val = false; }
55
+ else if (boolFlags.has(key) || i + 1 >= argv.length || argv[i + 1].startsWith('--')) val = true;
56
+ else val = argv[++i];
57
+ flags[key] = val;
58
+ flags[key.replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = val;
59
+ } else if (a === '-h') flags.help = true;
60
+ else if (a === '-y') flags.yes = true;
61
+ else positionals.push(a);
62
+ }
63
+ return { cmd: positionals[0] || 'help', positionals: positionals.slice(1), flags };
64
+ }
65
+
66
+ function fmtBytes(n) { return n == null ? '?' : n.toLocaleString() + ' B'; }
67
+
68
+ export function inspectText(deployment, report = null) {
69
+ const lines = [];
70
+ lines.push(`${deployment.root}`);
71
+ lines.push(`source: ${deployment.source}${deployment.framework ? ` · framework: ${deployment.framework}` : ''} · static dir: ${deployment.staticDir ? path.relative(deployment.root, deployment.staticDir) || '.' : '-'}`);
72
+ for (const n of deployment.notes) lines.push(`note: ${n}`);
73
+ lines.push('');
74
+ lines.push(`functions (${deployment.functions.length}):`);
75
+ const inel = new Map((report?.ineligible || []).map((i) => [i.name, i]));
76
+ const elig = new Set((report?.eligible || []).map((e) => (typeof e === 'string' ? e : e.name)));
77
+ for (const f of deployment.functions) {
78
+ const mark = report ? (inel.has(f.name) ? '✗' : elig.has(f.name) ? '✓' : '?') : ' ';
79
+ const meth = f.methods ? f.methods.join(',') : 'any';
80
+ lines.push(` ${mark} ${f.routePath.padEnd(32)} ${f.style.padEnd(12)} ${f.runtime.padEnd(11)} ${String(f.maxDuration + 's').padEnd(5)} ${String(f.memory + 'MB').padEnd(7)} ${meth}${f.params.length ? ` params=${f.params.join(',')}` : ''}${f.middleware ? ' middleware' : ''}${f.prerender ? ' ISR' : ''}`);
81
+ lines.push(` ${f.sourceFile ? path.relative(deployment.root, f.sourceFile) : '(no source)'}${Object.keys(f.environment || {}).length ? ` env=${Object.keys(f.environment).join(',')}` : ''}`);
82
+ const r = inel.get(f.name);
83
+ if (r) lines.push(` ineligible: ${r.reason}${r.line ? ` (line ${r.line})` : ''}`);
84
+ }
85
+ if (deployment.crons.length) { lines.push(''); lines.push(`crons (${deployment.crons.length}) — not transmuted, Solana has no scheduler:`); for (const c of deployment.crons) lines.push(` ${c.schedule || '?'} ${c.path || ''}`); }
86
+ lines.push('');
87
+ const total = deployment.staticFiles.reduce((n, f) => n + f.size, 0);
88
+ lines.push(`static files (${deployment.staticFiles.length}, ${fmtBytes(total)}):`);
89
+ for (const f of deployment.staticFiles.slice(0, 60)) lines.push(` ${f.path.padEnd(48)} ${f.contentType.padEnd(32)} ${fmtBytes(f.size)}`);
90
+ if (deployment.staticFiles.length > 60) lines.push(` … ${deployment.staticFiles.length - 60} more`);
91
+ if (report) {
92
+ lines.push('');
93
+ lines.push(`eligibility: ${report.eligible.length} eligible, ${report.ineligible.length} ineligible, ${report.warnings.length} warning(s)`);
94
+ for (const w of report.warnings) lines.push(` ! ${typeof w === 'string' ? w : w.message || JSON.stringify(w)}`);
95
+ }
96
+ const routes = deployment.config?.routes || [];
97
+ if (routes.length) {
98
+ lines.push('');
99
+ lines.push(`routes (${routes.length}):`);
100
+ for (const r of routes) lines.push(' ' + (r.handle ? `[handle: ${r.handle}]` : `${r.src} → ${r.dest || (r.status ? `status ${r.status}` : '(headers)')}${r.continue ? ' (continue)' : ''}`));
101
+ }
102
+ return lines.join('\n');
103
+ }
104
+
105
+ async function tryTransmute(deployment) {
106
+ try {
107
+ const mod = await import('./compile/index.js');
108
+ if (typeof mod.transmute !== 'function') return null;
109
+ const r = await mod.transmute(deployment, { name: 'inspect', dryRun: true });
110
+ return r?.report || null;
111
+ } catch (e) {
112
+ if (e?.code === 'ERR_MODULE_NOT_FOUND') return null;
113
+ return { eligible: [], ineligible: [], warnings: [`compiler failed: ${e?.message || e}`] };
114
+ }
115
+ }
116
+
117
+ async function cmdBuild(p, f, log) {
118
+ const r = await build(p[0] || '.', { out: f.out, name: f.name, arch: f.arch, cluster: f.cluster, runtimePath: f.runtime, skipCargo: !!f.skipCargo, log });
119
+ if (f.json) log(JSON.stringify({ outDir: r.outDir, soPath: r.soPath, arch: r.arch, report: r.report, costEstimate: { sol: r.costEstimate.sol, totalSol: r.costEstimate.totalSol, source: r.costEstimate.source } }, null, 2));
120
+ return r.report.ineligible.length && !r.report.eligible.length && r.manifest.routes.length === 0 && r.deployment.functions.length ? 2 : 0;
121
+ }
122
+
123
+ async function cmdDeploy(p, f, log) {
124
+ const r = await deploy({ outDir: p[0] || '.', cluster: f.cluster, keypair: f.keypair, programId: f.program, yes: !!f.yes, concurrency: f.concurrency ? Number(f.concurrency) : undefined, skipAssets: !!f.skipAssets, force: !!f.force, log });
125
+ if (f.json) log(JSON.stringify(r, null, 2));
126
+ return 0;
127
+ }
128
+
129
+ async function cmdServe(p, f, log) {
130
+ let programId = p[0];
131
+ if (!programId) {
132
+ // Fall back to the last deploy in ./ or ./.zoo-out.
133
+ try { programId = JSON.parse(fs.readFileSync(path.join(resolveOutDir('.'), '.zoo', 'deploy.json'), 'utf8')).programId; } catch { /* none */ }
134
+ if (!programId) throw new Error('usage: serve <programId> (no .zoo/deploy.json found to infer it from)');
135
+ }
136
+ new PublicKey(programId); // validate
137
+ let keypair = null, walletPath = null;
138
+ if (f.keypair !== false) {
139
+ try { const w = loadWallet({ keypair: typeof f.keypair === 'string' ? f.keypair : undefined }); keypair = w.keypair; walletPath = w.path; }
140
+ catch (e) { if (typeof f.keypair === 'string') throw e; log(`no wallet found (${String(e.message).split(';')[0]}); mutating requests will answer 402`); }
141
+ }
142
+ const port = f.port != null ? Number(f.port) : DEFAULT_PORT;
143
+ const host = f.host || '127.0.0.1';
144
+ if (keypair && !/^(127\.0\.0\.1|localhost|::1|\[::1\])$/.test(host)) log(`warning: binding ${host} with a signer: anyone who can reach this gateway can send transactions paid by ${keypair.publicKey.toBase58()}; use --no-keypair unless that is intended`);
145
+ const gw = await startGateway({ programId, cluster: f.cluster, port, host, keypair, log, quiet: !!f.quiet });
146
+ const m = gw.state.manifest;
147
+ log(`openzoo-transmute gateway → ${gw.url}/ (explorer ${gw.url}/.zoo/, manifest ${gw.url}${MANIFEST_PATH})`);
148
+ log(`program ${programId} on ${gw.state.cluster} · ${m.routes.length} function(s), ${m.static ? m.static.length : '?'} asset(s) · signer ${keypair ? keypair.publicKey.toBase58() + (walletPath ? ` (${walletPath})` : '') : 'none (reads only)'}`);
149
+ for (const r of m.routes) log(` ${(r.methods && r.methods.length ? r.methods.join('|') : 'ANY').padEnd(16)} ${r.routePath}`);
150
+ const stop = () => gw.close().then(() => process.exit(0));
151
+ process.once('SIGINT', stop);
152
+ process.once('SIGTERM', stop);
153
+ return 0;
154
+ }
155
+
156
+ async function cmdInspect(p, f, log) {
157
+ const deployment = readDeployment(p[0] || '.');
158
+ const report = await tryTransmute(deployment);
159
+ if (f.json) log(JSON.stringify({ ...deployment, report }, null, 2));
160
+ else {
161
+ log(inspectText(deployment, report));
162
+ if (!report) log('\n(compiler not available: eligibility not checked)');
163
+ }
164
+ return 0;
165
+ }
166
+
167
+ async function cmdStatus(p, f, log) {
168
+ if (!p[0]) throw new Error('usage: status <programId> [--cluster <c>]');
169
+ const programId = new PublicKey(p[0]);
170
+ const rpc = rpcUrl(f.cluster);
171
+ const connection = connect(rpc);
172
+ const [info, slot, manifest] = await Promise.all([getProgramInfo(connection, programId), connection.getSlot('confirmed'), readManifest(connection, programId).catch(() => null)]);
173
+ const out = {
174
+ programId: programId.toBase58(), cluster: clusterLabel(f.cluster), rpc: rpc.replace(/\?.*$/, ''), slot,
175
+ exists: info.exists, executable: info.executable ?? null, authority: info.authority ? info.authority.toBase58() : null,
176
+ deploySlot: info.slot, maxDataLen: info.maxDataLen, programData: info.programData.toBase58(),
177
+ manifest: manifest ? { name: manifest.name, framework: manifest.framework, routes: manifest.routes?.length ?? 0, static: manifest.static?.length ?? 0, deployedAt: manifest.deployedAt, arch: manifest.arch } : null,
178
+ };
179
+ if (f.json) { log(JSON.stringify(out, null, 2)); return info.exists ? 0 : 1; }
180
+ log(`program ${out.programId} on ${out.cluster} (${out.rpc}) @ slot ${slot}`);
181
+ if (!info.exists) { log('status NOT DEPLOYED'); return 1; }
182
+ log(`status deployed at slot ${out.deploySlot}${out.executable === false ? ' (not executable!)' : ''}`);
183
+ log(`authority ${out.authority || 'none (immutable)'}`);
184
+ log(`programData ${out.programData} (max ${out.maxDataLen?.toLocaleString()} B)`);
185
+ if (manifest) {
186
+ log(`manifest ${manifest.name || ''} ${manifest.framework || ''} · ${out.manifest.routes} function(s), ${out.manifest.static} asset(s) · deployed ${manifest.deployedAt || '?'}${manifest.arch ? ` (${manifest.arch})` : ''}`);
187
+ for (const r of manifest.routes || []) log(` ${(r.methods && r.methods.length ? r.methods.join('|') : 'ANY').padEnd(16)} ${r.routePath}`);
188
+ } else log(`manifest none at ${MANIFEST_PATH}`);
189
+ return 0;
190
+ }
191
+
192
+ /**
193
+ * Run the CLI. Returns the exit code; prints errors to stderr.
194
+ * @param {string[]} argv arguments after the program name
195
+ * @param {{log?:Function, error?:Function}} io
196
+ */
197
+ async function cmdHub(p, f, log) {
198
+ // The hosted explorer: every program on the cluster from one public host,
199
+ // read-only by default (a public signer would be a drain). --keypair opts in.
200
+ const cluster = f.cluster || process.env.OPENZOO_CLUSTER || 'mainnet';
201
+ let keypair = null;
202
+ if (typeof f.keypair === 'string') { keypair = loadWallet({ keypair: f.keypair }).keypair; log(`warning: this hub signs writes with ${keypair.publicKey.toBase58()} for anyone who can reach it`); }
203
+ const port = f.port != null ? Number(f.port) : Number(process.env.PORT || DEFAULT_HUB_PORT);
204
+ const h = await startHub({ cluster, port, host: f.host || '0.0.0.0', keypair, log, quiet: !!f.quiet, publicUrl: f.publicUrl || process.env.OPENZOO_HUB_URL || null, maxSites: f.maxSites ? Number(f.maxSites) : undefined });
205
+ log(`openzoo hub → ${h.url}/.hub (cluster ${cluster}; sites at ${h.url}/s/<programId>)`);
206
+ const stop = () => h.close().then(() => process.exit(0));
207
+ process.once('SIGINT', stop);
208
+ process.once('SIGTERM', stop);
209
+ return 0;
210
+ }
211
+
212
+ export async function run(argv = process.argv.slice(2), io = {}) {
213
+ const log = io.log ?? console.log;
214
+ const error = io.error ?? console.error;
215
+ const { cmd, positionals, flags } = parseArgs(argv);
216
+ if (flags.version) { log(JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version); return 0; }
217
+ if (flags.help || cmd === 'help' || cmd === '--help') { log(USAGE); return 0; }
218
+ const commands = { build: cmdBuild, deploy: cmdDeploy, serve: cmdServe, inspect: cmdInspect, status: cmdStatus, hub: cmdHub };
219
+ const fn = commands[cmd];
220
+ if (!fn) { error(`unknown command: ${cmd}\n`); error(USAGE); process.exitCode = 1; return 1; }
221
+ try {
222
+ const code = (await fn(positionals, flags, log)) || 0;
223
+ if (code) process.exitCode = code;
224
+ return code;
225
+ } catch (e) {
226
+ error(`error: ${e?.message || e}`);
227
+ if (process.env.OPENZOO_DEBUG && e?.stack) error(e.stack);
228
+ process.exitCode = 1;
229
+ return 1;
230
+ }
231
+ }