yappr 0.1.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.
- package/.env.example +115 -0
- package/config/context/personality.md +7 -0
- package/config/context/security.md +10 -0
- package/config/hooks/example.ts +47 -0
- package/config/hooks/holder.ts +154 -0
- package/config/hooks/user-memory.ts +102 -0
- package/config/skills/compute/handler.ts +6 -0
- package/config/skills/compute/skill.md +7 -0
- package/config/skills/cron/handler.ts +89 -0
- package/config/skills/cron/skill.md +36 -0
- package/config/skills/generate-image/handler.ts +133 -0
- package/config/skills/generate-image/skill.md +20 -0
- package/config/skills/generate-meme-prompt/handler.ts +40 -0
- package/config/skills/generate-meme-prompt/skill.md +23 -0
- package/config/skills/stats/handler.ts +76 -0
- package/config/skills/stats/skill.md +18 -0
- package/config/skills/wallet/handler.ts +56 -0
- package/config/skills/wallet/skill.md +17 -0
- package/config/skills/x/handler.ts +135 -0
- package/config/skills/x/skill.md +163 -0
- package/dist/config/hooks/example.d.ts +2 -0
- package/dist/config/hooks/example.js +37 -0
- package/dist/config/hooks/holder.d.ts +2 -0
- package/dist/config/hooks/holder.js +147 -0
- package/dist/config/hooks/user-memory.d.ts +2 -0
- package/dist/config/hooks/user-memory.js +79 -0
- package/dist/config/skills/compute/handler.d.ts +2 -0
- package/dist/config/skills/compute/handler.js +5 -0
- package/dist/config/skills/cron/handler.d.ts +2 -0
- package/dist/config/skills/cron/handler.js +84 -0
- package/dist/config/skills/generate-image/handler.d.ts +2 -0
- package/dist/config/skills/generate-image/handler.js +122 -0
- package/dist/config/skills/generate-meme/handler.d.ts +2 -0
- package/dist/config/skills/generate-meme/handler.js +121 -0
- package/dist/config/skills/generate-meme-prompt/handler.d.ts +2 -0
- package/dist/config/skills/generate-meme-prompt/handler.js +38 -0
- package/dist/config/skills/stats/handler.d.ts +2 -0
- package/dist/config/skills/stats/handler.js +71 -0
- package/dist/config/skills/wallet/handler.d.ts +2 -0
- package/dist/config/skills/wallet/handler.js +54 -0
- package/dist/config/skills/x/handler.d.ts +2 -0
- package/dist/config/skills/x/handler.js +115 -0
- package/dist/src/agent-prompt.d.ts +1 -0
- package/dist/src/agent-prompt.js +45 -0
- package/dist/src/bankr.d.ts +41 -0
- package/dist/src/bankr.js +76 -0
- package/dist/src/cli/backup.d.ts +7 -0
- package/dist/src/cli/backup.js +78 -0
- package/dist/src/cli/charts.d.ts +32 -0
- package/dist/src/cli/charts.js +222 -0
- package/dist/src/cli/config-sync.d.ts +7 -0
- package/dist/src/cli/config-sync.js +71 -0
- package/dist/src/cli/deploy.d.ts +2 -0
- package/dist/src/cli/deploy.js +1059 -0
- package/dist/src/cli/env.d.ts +4 -0
- package/dist/src/cli/env.js +50 -0
- package/dist/src/cli/host-key.d.ts +4 -0
- package/dist/src/cli/host-key.js +50 -0
- package/dist/src/cli/index.d.ts +2 -0
- package/dist/src/cli/index.js +71 -0
- package/dist/src/cli/init.d.ts +1 -0
- package/dist/src/cli/init.js +51 -0
- package/dist/src/cli/ssh.d.ts +2 -0
- package/dist/src/cli/ssh.js +141 -0
- package/dist/src/cli/status.d.ts +7 -0
- package/dist/src/cli/status.js +1184 -0
- package/dist/src/cli/tui.d.ts +18 -0
- package/dist/src/cli/tui.js +115 -0
- package/dist/src/cli/ui.d.ts +30 -0
- package/dist/src/cli/ui.js +164 -0
- package/dist/src/cli/update.d.ts +1 -0
- package/dist/src/cli/update.js +263 -0
- package/dist/src/cli/x-login.d.ts +6 -0
- package/dist/src/cli/x-login.js +70 -0
- package/dist/src/compute.d.ts +11 -0
- package/dist/src/compute.js +109 -0
- package/dist/src/config-loader.d.ts +19 -0
- package/dist/src/config-loader.js +82 -0
- package/dist/src/config.d.ts +29 -0
- package/dist/src/config.js +68 -0
- package/dist/src/cron/capability.d.ts +6 -0
- package/dist/src/cron/capability.js +66 -0
- package/dist/src/cron/runner.d.ts +2 -0
- package/dist/src/cron/runner.js +113 -0
- package/dist/src/cron/schedule.d.ts +19 -0
- package/dist/src/cron/schedule.js +154 -0
- package/dist/src/cron/store.d.ts +46 -0
- package/dist/src/cron/store.js +220 -0
- package/dist/src/db.d.ts +4 -0
- package/dist/src/db.js +53 -0
- package/dist/src/hooks/loader.d.ts +1 -0
- package/dist/src/hooks/loader.js +17 -0
- package/dist/src/hooks/registry.d.ts +17 -0
- package/dist/src/hooks/registry.js +78 -0
- package/dist/src/hooks/types.d.ts +45 -0
- package/dist/src/hooks/types.js +1 -0
- package/dist/src/index.d.ts +25 -0
- package/dist/src/index.js +35 -0
- package/dist/src/llm/index.d.ts +23 -0
- package/dist/src/llm/index.js +213 -0
- package/dist/src/llm/prompts.d.ts +6 -0
- package/dist/src/llm/prompts.js +99 -0
- package/dist/src/log.d.ts +2 -0
- package/dist/src/log.js +30 -0
- package/dist/src/reply/agent.d.ts +20 -0
- package/dist/src/reply/agent.js +215 -0
- package/dist/src/reply/context-blocks.d.ts +12 -0
- package/dist/src/reply/context-blocks.js +22 -0
- package/dist/src/reply/gating.d.ts +3 -0
- package/dist/src/reply/gating.js +35 -0
- package/dist/src/reply/pipeline.d.ts +3 -0
- package/dist/src/reply/pipeline.js +144 -0
- package/dist/src/reply/poller.d.ts +5 -0
- package/dist/src/reply/poller.js +79 -0
- package/dist/src/skills/holder-access.d.ts +7 -0
- package/dist/src/skills/holder-access.js +53 -0
- package/dist/src/skills/loader.d.ts +2 -0
- package/dist/src/skills/loader.js +64 -0
- package/dist/src/skills/registry.d.ts +4 -0
- package/dist/src/skills/registry.js +10 -0
- package/dist/src/skills/types.d.ts +16 -0
- package/dist/src/skills/types.js +1 -0
- package/dist/src/state.d.ts +5 -0
- package/dist/src/state.js +26 -0
- package/dist/src/stats-cli.d.ts +1 -0
- package/dist/src/stats-cli.js +82 -0
- package/dist/src/stats.d.ts +41 -0
- package/dist/src/stats.js +236 -0
- package/dist/src/storage.d.ts +16 -0
- package/dist/src/storage.js +107 -0
- package/dist/src/treasury/abi.d.ts +99 -0
- package/dist/src/treasury/abi.js +71 -0
- package/dist/src/treasury/cycle.d.ts +16 -0
- package/dist/src/treasury/cycle.js +154 -0
- package/dist/src/treasury/index.d.ts +28 -0
- package/dist/src/treasury/index.js +222 -0
- package/dist/src/util.d.ts +3 -0
- package/dist/src/util.js +18 -0
- package/dist/src/wallet.d.ts +5 -0
- package/dist/src/wallet.js +241 -0
- package/dist/src/x/client.d.ts +74 -0
- package/dist/src/x/client.js +323 -0
- package/dist/src/x/types.d.ts +61 -0
- package/dist/src/x/types.js +1 -0
- package/dist/src/x402.d.ts +6 -0
- package/dist/src/x402.js +11 -0
- package/dist/src/yappr.d.ts +1 -0
- package/dist/src/yappr.js +85 -0
- package/package.json +52 -0
|
@@ -0,0 +1,1059 @@
|
|
|
1
|
+
// Provision + deploy yappr to an x402 compute instance (the `yappr deploy` command).
|
|
2
|
+
import "dotenv/config";
|
|
3
|
+
import { readFile, writeFile, copyFile, access, mkdtemp, rm } from "node:fs/promises";
|
|
4
|
+
import { resolve, join, dirname, basename } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { createConnection } from "node:net";
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
import { NodeSSH } from "node-ssh";
|
|
11
|
+
import { input, password, select, confirm, banner, step, printPanel, ok, info, warn, fail, spin, themeText, } from "./tui.js";
|
|
12
|
+
import { bankrApi, deployTokenLaunch } from "../bankr.js";
|
|
13
|
+
import { createBankrSigner, createPayFetch } from "../x402.js";
|
|
14
|
+
import { computeInstanceData, computeInstanceId, computeInstanceIp, computeInstancePassword, computeInstanceExpiry, remainingComputeHours, fetchComputeInstance, fetchOneTimePassword, waitForComputeIp, resolveEvmAddress, } from "../compute.js";
|
|
15
|
+
import { dim, bold, green, accent, kv as kvRow, setTheme, detectTerminalTheme, themeLine, } from "./ui.js";
|
|
16
|
+
import { isUnset, setEnvVar, setEnvVarInContent, removeEnvVarInContent } from "./env.js";
|
|
17
|
+
import { connectXViaBrowser } from "./x-login.js";
|
|
18
|
+
import { runStatus } from "./status.js";
|
|
19
|
+
import { latestLocalBackup, remoteFileExists, backupLabel, REMOTE_DB_PATH } from "./backup.js";
|
|
20
|
+
import { hostKeyConfig } from "./host-key.js";
|
|
21
|
+
const execFileAsync = promisify(execFile);
|
|
22
|
+
// Engine package root. This file is <root>/dist/src/cli/deploy.js in prod (or
|
|
23
|
+
// <root>/src/cli/deploy.ts in dev), so the root is three levels up. Used to build +
|
|
24
|
+
// pack the engine into a tarball the server installs.
|
|
25
|
+
const ENGINE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
|
26
|
+
// Shared field validators (used for TOKEN_ADDRESS, DEV_ADDRESS, and the token-launch
|
|
27
|
+
// fallback). A 0x… 40-hex EVM address, and a plain http(s) URL.
|
|
28
|
+
const EVM_ADDRESS_RE = /^0x[a-fA-F0-9]{40}$/;
|
|
29
|
+
const validateAddress = (v) => EVM_ADDRESS_RE.test(v) || "Must be a 0x… 42-character address.";
|
|
30
|
+
const isHttpUrl = (v) => /^https?:\/\/\S+$/.test(v) || "Must be an http(s):// URL.";
|
|
31
|
+
// Pull Bankr's human message out of a thrown bankrApi error. bankrApi throws
|
|
32
|
+
// `Bankr <path> failed: <status> <body>`; when the body is JSON with an `error`
|
|
33
|
+
// (or `message`) field, return just that text — otherwise the raw message.
|
|
34
|
+
function bankrErrorText(err) {
|
|
35
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
36
|
+
const jsonStart = msg.indexOf("{");
|
|
37
|
+
if (jsonStart !== -1) {
|
|
38
|
+
try {
|
|
39
|
+
const body = JSON.parse(msg.slice(jsonStart));
|
|
40
|
+
if (typeof body.error === "string")
|
|
41
|
+
return body.error;
|
|
42
|
+
if (typeof body.message === "string")
|
|
43
|
+
return body.message;
|
|
44
|
+
}
|
|
45
|
+
catch { /* not JSON — fall through */ }
|
|
46
|
+
}
|
|
47
|
+
return msg;
|
|
48
|
+
}
|
|
49
|
+
// ─── process helpers ──────────────────────────────────────────────────────────
|
|
50
|
+
async function getLlmCreditBalanceUsd(apiKey) {
|
|
51
|
+
const llmUrl = process.env.BANKR_LLM_URL || "https://llm.bankr.bot";
|
|
52
|
+
const res = await fetch(`${llmUrl}/v1/credits`, {
|
|
53
|
+
headers: {
|
|
54
|
+
"X-API-Key": process.env.BANKR_LLM_KEY || apiKey,
|
|
55
|
+
"User-Agent": "yappr-deploy/0.1",
|
|
56
|
+
},
|
|
57
|
+
signal: AbortSignal.timeout(10000),
|
|
58
|
+
});
|
|
59
|
+
if (res.status === 402)
|
|
60
|
+
return 0;
|
|
61
|
+
if (!res.ok)
|
|
62
|
+
throw new Error(`Bankr LLM credits check failed: ${res.status} ${await res.text()}`);
|
|
63
|
+
const body = await res.json();
|
|
64
|
+
return Number(body.balanceUsd ?? 0);
|
|
65
|
+
}
|
|
66
|
+
// List models available on the Bankr LLM Gateway (OpenAI-compatible /v1/models).
|
|
67
|
+
async function fetchLlmModels(apiKey) {
|
|
68
|
+
const llmUrl = process.env.BANKR_LLM_URL || "https://llm.bankr.bot";
|
|
69
|
+
const res = await fetch(`${llmUrl}/v1/models`, {
|
|
70
|
+
headers: { "X-API-Key": process.env.BANKR_LLM_KEY || apiKey, "User-Agent": "yappr-deploy/0.1" },
|
|
71
|
+
signal: AbortSignal.timeout(10000),
|
|
72
|
+
});
|
|
73
|
+
if (!res.ok)
|
|
74
|
+
throw new Error(`Bankr LLM models fetch failed: ${res.status} ${await res.text()}`);
|
|
75
|
+
const body = await res.json();
|
|
76
|
+
return body.data ?? [];
|
|
77
|
+
}
|
|
78
|
+
const BASE_USDC_AUTO_TOP_UP_TOKEN = {
|
|
79
|
+
address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
|
|
80
|
+
chain: "base",
|
|
81
|
+
symbol: "USDC",
|
|
82
|
+
name: "USD Coin",
|
|
83
|
+
decimals: 6,
|
|
84
|
+
};
|
|
85
|
+
function regionCode(region) {
|
|
86
|
+
return String(region?.id ?? region?.slug ?? region?.code ?? region?.name ?? "").toLowerCase();
|
|
87
|
+
}
|
|
88
|
+
function regionLabel(region) {
|
|
89
|
+
const code = regionCode(region);
|
|
90
|
+
const city = region?.city;
|
|
91
|
+
const country = region?.country;
|
|
92
|
+
const explicitName = region?.name ?? region?.label;
|
|
93
|
+
const label = city && country
|
|
94
|
+
? `${city}, ${country}`
|
|
95
|
+
: explicitName && explicitName !== code
|
|
96
|
+
? explicitName
|
|
97
|
+
: undefined;
|
|
98
|
+
return label ? `${label} (${code})` : code || "Unknown region";
|
|
99
|
+
}
|
|
100
|
+
function formatUsd(amount, decimals) {
|
|
101
|
+
return `$${amount.toFixed(decimals)}`;
|
|
102
|
+
}
|
|
103
|
+
// Atomic USDC (6-dp) authorized by an x402 payment, read from the X-PAYMENT header the
|
|
104
|
+
// client attaches to its (paid) retry — so deploy can report what a compute payment cost.
|
|
105
|
+
function paymentAtomicFromHeaders(headers) {
|
|
106
|
+
const raw = headers.get("x-payment") ?? headers.get("payment-signature");
|
|
107
|
+
if (!raw)
|
|
108
|
+
return undefined;
|
|
109
|
+
try {
|
|
110
|
+
const decoded = JSON.parse(Buffer.from(raw, "base64").toString("utf8"));
|
|
111
|
+
const value = decoded?.payload?.authorization?.value;
|
|
112
|
+
return value != null ? BigInt(value) : undefined;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async function computeX402Pay(apiKey, walletAddress, url, body, onPaid) {
|
|
119
|
+
let paidAtomic;
|
|
120
|
+
// Trace the underlying fetch to capture the paid amount from the X-PAYMENT header the
|
|
121
|
+
// x402 client sends on its (paid) retry — createPayFetch doesn't surface it otherwise.
|
|
122
|
+
const tracingFetch = async (input, init) => {
|
|
123
|
+
const res = await fetch(input, init);
|
|
124
|
+
const headers = input instanceof Request ? input.headers : new Headers(init?.headers);
|
|
125
|
+
const atomic = paymentAtomicFromHeaders(headers);
|
|
126
|
+
if (atomic != null)
|
|
127
|
+
paidAtomic = atomic;
|
|
128
|
+
return res;
|
|
129
|
+
};
|
|
130
|
+
const payFetch = createPayFetch(createBankrSigner(apiKey, walletAddress), tracingFetch);
|
|
131
|
+
const res = await payFetch(url, {
|
|
132
|
+
method: "POST",
|
|
133
|
+
headers: { "Content-Type": "application/json" },
|
|
134
|
+
body,
|
|
135
|
+
});
|
|
136
|
+
const text = await res.text();
|
|
137
|
+
let parsed;
|
|
138
|
+
try {
|
|
139
|
+
parsed = JSON.parse(text);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
parsed = text;
|
|
143
|
+
}
|
|
144
|
+
if (!res.ok)
|
|
145
|
+
throw new Error(`Compute payment failed: ${res.status} ${JSON.stringify(parsed)}`);
|
|
146
|
+
if (paidAtomic != null && onPaid)
|
|
147
|
+
onPaid(Number(paidAtomic) / 1e6);
|
|
148
|
+
return parsed;
|
|
149
|
+
}
|
|
150
|
+
async function extendComputeInstance(apiKey, walletAddress, instanceId, onPaid) {
|
|
151
|
+
return computeX402Pay(apiKey, walletAddress, `https://compute.x402layer.cc/compute/instances/${instanceId}/extend`, JSON.stringify({ extend_hours: 24, network: "base" }), onPaid);
|
|
152
|
+
}
|
|
153
|
+
async function topUpLlmCredits(apiKey, amountUsd) {
|
|
154
|
+
const result = await bankrApi(apiKey, "/llm/credits/topup", {
|
|
155
|
+
method: "POST",
|
|
156
|
+
body: JSON.stringify({ amountUsd, sourceToken: "USDC" }),
|
|
157
|
+
});
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
async function getLlmAutoTopUpConfig(apiKey) {
|
|
161
|
+
const { config } = await bankrApi(apiKey, "/llm/credits/auto-topup");
|
|
162
|
+
return config;
|
|
163
|
+
}
|
|
164
|
+
async function enableLlmAutoTopUp(apiKey, token) {
|
|
165
|
+
await bankrApi(apiKey, "/llm/credits/auto-topup", {
|
|
166
|
+
method: "POST",
|
|
167
|
+
body: JSON.stringify({
|
|
168
|
+
enabled: true,
|
|
169
|
+
amountUsd: 5,
|
|
170
|
+
thresholdUsd: 1,
|
|
171
|
+
tokens: [token],
|
|
172
|
+
}),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
async function waitForPort(host, port, timeoutMs) {
|
|
176
|
+
const deadline = Date.now() + timeoutMs;
|
|
177
|
+
while (Date.now() < deadline) {
|
|
178
|
+
const reachable = await new Promise((resolve) => {
|
|
179
|
+
const socket = createConnection({ host, port });
|
|
180
|
+
const timer = setTimeout(() => { socket.destroy(); resolve(false); }, 3000);
|
|
181
|
+
socket.once("connect", () => { clearTimeout(timer); socket.destroy(); resolve(true); });
|
|
182
|
+
socket.once("error", () => { clearTimeout(timer); socket.destroy(); resolve(false); });
|
|
183
|
+
});
|
|
184
|
+
if (reachable)
|
|
185
|
+
return;
|
|
186
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
187
|
+
}
|
|
188
|
+
throw new Error(`Timed out waiting for ${host}:${port}`);
|
|
189
|
+
}
|
|
190
|
+
// Run a remote command. By default output is suppressed (quiet); on failure the
|
|
191
|
+
// captured stderr/stdout is surfaced in the thrown error.
|
|
192
|
+
async function sshExec(ssh, cmd, opts = {}) {
|
|
193
|
+
const result = await ssh.execCommand(cmd, { cwd: "/" });
|
|
194
|
+
if (!opts.quiet) {
|
|
195
|
+
if (result.stdout)
|
|
196
|
+
console.log(result.stdout.split("\n").map((l) => ` ${l}`).join("\n"));
|
|
197
|
+
if (result.stderr)
|
|
198
|
+
process.stderr.write(result.stderr.split("\n").map((l) => ` ${l}`).join("\n") + "\n");
|
|
199
|
+
}
|
|
200
|
+
if (result.code !== 0) {
|
|
201
|
+
const detail = (result.stderr || result.stdout || "").trim().split("\n").slice(-5).join("\n");
|
|
202
|
+
throw new Error(`SSH command failed (exit ${result.code})${detail ? `:\n${detail}` : ""}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// Returns true if `bin` is on PATH on the remote host (used to skip already-done work).
|
|
206
|
+
async function sshHas(ssh, bin) {
|
|
207
|
+
const result = await ssh.execCommand(`command -v ${bin} >/dev/null 2>&1`, { cwd: "/" });
|
|
208
|
+
return result.code === 0;
|
|
209
|
+
}
|
|
210
|
+
// Remote Node.js major version, or null if node isn't installed.
|
|
211
|
+
async function sshNodeMajor(ssh) {
|
|
212
|
+
const result = await ssh.execCommand("node -v 2>/dev/null | sed 's/v//' | cut -d. -f1", { cwd: "/" });
|
|
213
|
+
const major = parseInt(result.stdout.trim(), 10);
|
|
214
|
+
return Number.isFinite(major) ? major : null;
|
|
215
|
+
}
|
|
216
|
+
// Build the engine and pack it into an npm tarball (its `files`: dist + config
|
|
217
|
+
// template + .env.example). Returns the local tarball path; the server installs it.
|
|
218
|
+
//
|
|
219
|
+
// TODO(publish): the engine isn't on npm yet, so we bundle the LOCAL package this way.
|
|
220
|
+
// Once `yappr` is published, drop the build+pack+upload and just `npm i yappr@<ver>`
|
|
221
|
+
// on the server (the cloud package.json below would depend on the registry version).
|
|
222
|
+
async function bundleEngine() {
|
|
223
|
+
await execFileAsync("npm", ["run", "build"], { cwd: ENGINE_ROOT });
|
|
224
|
+
const dest = await mkdtemp(join(tmpdir(), "yappr-pack-"));
|
|
225
|
+
const { stdout } = await execFileAsync("npm", ["pack", "--pack-destination", dest], { cwd: ENGINE_ROOT });
|
|
226
|
+
return join(dest, stdout.trim().split("\n").pop().trim()); // npm prints the .tgz filename last
|
|
227
|
+
}
|
|
228
|
+
// ─── deploy ─────────────────────────────────────────────────────────────────
|
|
229
|
+
async function main() {
|
|
230
|
+
// Start from a clean screen for the deploy UI. The escape sequence clears the
|
|
231
|
+
// screen (2J), the scrollback buffer (3J), and homes the cursor (H). Skipped
|
|
232
|
+
// when stdout isn't a TTY (piped/CI) so we don't write escape codes into logs.
|
|
233
|
+
if (process.stdout.isTTY)
|
|
234
|
+
process.stdout.write("\x1b[2J\x1b[3J\x1b[H");
|
|
235
|
+
// Match the terminal's background (dark/light) so deploy's colors track the
|
|
236
|
+
// status dashboard. Deploy has no manual toggle, so it's always automatic —
|
|
237
|
+
// detect once up front (the OSC query borrows stdin) before any prompts.
|
|
238
|
+
// STATUS_THEME still pins a palette explicitly for both scripts.
|
|
239
|
+
if (!process.env.STATUS_THEME) {
|
|
240
|
+
const detected = await detectTerminalTheme().catch(() => null);
|
|
241
|
+
if (detected)
|
|
242
|
+
setTheme(detected);
|
|
243
|
+
}
|
|
244
|
+
// Paint every printed line in the palette's default text color, exactly like
|
|
245
|
+
// the status dashboard themes its frame (see ui.themeLine). The deploy helpers
|
|
246
|
+
// only color specific glyphs/labels and leave message text uncolored — which
|
|
247
|
+
// otherwise falls back to the terminal's own foreground (often green) and
|
|
248
|
+
// clashes with the Base-blue accents. themeLine re-arms the palette color after
|
|
249
|
+
// each ANSI reset. Only when stdout is a TTY (don't write SGR into piped logs).
|
|
250
|
+
if (process.stdout.isTTY) {
|
|
251
|
+
for (const m of ["log", "error"]) {
|
|
252
|
+
const orig = console[m].bind(console);
|
|
253
|
+
console[m] = (...args) => orig(...args.map((a) => (typeof a === "string" ? themeLine(a) : a)));
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const TOTAL_STEPS = 7;
|
|
257
|
+
banner("Deploy", "Self-sustaining AI agent on X");
|
|
258
|
+
if (process.env.CLOUD_INSTANCE === "true") {
|
|
259
|
+
console.error("\n This looks like the deployed cloud instance.");
|
|
260
|
+
console.error(" Do not run the deploy script from here. To start or restart the bot, run:");
|
|
261
|
+
console.error("");
|
|
262
|
+
console.error(" cd /yappr && (pm2 delete yappr || true) && pm2 start node_modules/yappr/dist/src/yappr.js --name yappr --update-env");
|
|
263
|
+
console.error("");
|
|
264
|
+
process.exit(1);
|
|
265
|
+
}
|
|
266
|
+
// ── Step 1: collect env ───────────────────────────────────────────────────
|
|
267
|
+
step(1, TOTAL_STEPS, "Configuration");
|
|
268
|
+
const envPath = resolve(process.cwd(), ".env");
|
|
269
|
+
if (!await access(envPath).then(() => true).catch(() => false)) {
|
|
270
|
+
await copyFile(resolve(process.cwd(), ".env.example"), envPath);
|
|
271
|
+
ok(".env created from .env.example");
|
|
272
|
+
}
|
|
273
|
+
async function field(key, message, opts = {}) {
|
|
274
|
+
if (!isUnset(process.env[key])) {
|
|
275
|
+
info(`${key} already set — skipping`);
|
|
276
|
+
return process.env[key];
|
|
277
|
+
}
|
|
278
|
+
const validate = (raw) => {
|
|
279
|
+
const v = raw.trim();
|
|
280
|
+
if (!v)
|
|
281
|
+
return opts.required ? "Required." : true;
|
|
282
|
+
return opts.validate ? opts.validate(v) : true;
|
|
283
|
+
};
|
|
284
|
+
const answer = opts.secret
|
|
285
|
+
? await password({ message, mask: "•", validate })
|
|
286
|
+
: await input({ message, validate });
|
|
287
|
+
const value = (opts.transform ? opts.transform(answer.trim()) : answer.trim());
|
|
288
|
+
if (!value) {
|
|
289
|
+
info(`${key} skipped`);
|
|
290
|
+
return "";
|
|
291
|
+
}
|
|
292
|
+
await setEnvVar(envPath, key, value);
|
|
293
|
+
ok(`${key} saved`);
|
|
294
|
+
return value;
|
|
295
|
+
}
|
|
296
|
+
const bankrApiKey = await field("BANKR_API_KEY", "Bankr API key", {
|
|
297
|
+
required: true,
|
|
298
|
+
secret: true,
|
|
299
|
+
validate: (v) => v.startsWith("bk_") || "Bankr keys start with 'bk_'.",
|
|
300
|
+
});
|
|
301
|
+
// X/Twitter session — the two cookies the agent posts with. Offer a browser
|
|
302
|
+
// login that grabs them automatically (like a normal "Connect X" flow), with
|
|
303
|
+
// manual cookie entry as the fallback.
|
|
304
|
+
if (!isUnset(process.env.TWITTER_AUTH_TOKEN) && !isUnset(process.env.TWITTER_CT0)) {
|
|
305
|
+
info("TWITTER_AUTH_TOKEN already set — skipping");
|
|
306
|
+
info("TWITTER_CT0 already set — skipping");
|
|
307
|
+
}
|
|
308
|
+
else {
|
|
309
|
+
const method = await select({
|
|
310
|
+
message: "Connect the agent's X/Twitter account",
|
|
311
|
+
choices: [
|
|
312
|
+
{ name: `Log in via browser ${dim("opens x.com in Chrome and grabs the session cookies automatically")}`, value: "browser" },
|
|
313
|
+
{ name: `Enter cookies manually ${dim("paste auth_token + ct0 from your own browser")}`, value: "manual" },
|
|
314
|
+
],
|
|
315
|
+
});
|
|
316
|
+
let connected = false;
|
|
317
|
+
if (method === "browser") {
|
|
318
|
+
try {
|
|
319
|
+
const creds = await spin("Waiting for you to log in to x.com in the Chrome window… (3 min timeout)", () => connectXViaBrowser(), "X session captured");
|
|
320
|
+
await setEnvVar(envPath, "TWITTER_AUTH_TOKEN", creds.authToken);
|
|
321
|
+
await setEnvVar(envPath, "TWITTER_CT0", creds.ct0);
|
|
322
|
+
ok(`Connected ${creds.username ? accent("@" + creds.username) : "X account"} — cookies saved to .env`);
|
|
323
|
+
// The connected account IS the agent's account (its cookies post the
|
|
324
|
+
// replies), so its handle is the right AGENT_HANDLE default.
|
|
325
|
+
if (creds.username && isUnset(process.env.AGENT_HANDLE)) {
|
|
326
|
+
await setEnvVar(envPath, "AGENT_HANDLE", creds.username);
|
|
327
|
+
ok(`AGENT_HANDLE saved: ${creds.username}`);
|
|
328
|
+
}
|
|
329
|
+
connected = true;
|
|
330
|
+
}
|
|
331
|
+
catch (err) {
|
|
332
|
+
warn(`Browser connect failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
333
|
+
info("Falling back to manual cookie entry.");
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (!connected) {
|
|
337
|
+
await field("TWITTER_AUTH_TOKEN", "X/Twitter auth_token cookie", { required: true, secret: true });
|
|
338
|
+
await field("TWITTER_CT0", "X/Twitter ct0 cookie", { required: true, secret: true });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
// AGENT_HANDLE before the token step: the launch flow needs it for the fee
|
|
342
|
+
// recipient. The browser-login path may have set it already; field() is
|
|
343
|
+
// isUnset-guarded, so this is a no-op then.
|
|
344
|
+
await field("AGENT_HANDLE", "Agent's Twitter handle (without @)", {
|
|
345
|
+
required: true,
|
|
346
|
+
transform: (v) => v.replace(/^@/, ""),
|
|
347
|
+
});
|
|
348
|
+
// ── Token: reuse an existing CA, or launch one on Bankr inline ──
|
|
349
|
+
// The agent funds itself from its token's trading fees, so a token is required.
|
|
350
|
+
const promptForExistingToken = () => field("TOKEN_ADDRESS", "Agent token address on Base", { required: true, validate: validateAddress });
|
|
351
|
+
const launchToken = async () => {
|
|
352
|
+
const handle = process.env.AGENT_HANDLE; // set + @-stripped by the field() above
|
|
353
|
+
const tokenName = (await input({ message: "Token name", validate: (v) => v.trim() ? true : "Required." })).trim();
|
|
354
|
+
const tokenSymbol = (await input({
|
|
355
|
+
message: "Token symbol (ticker)",
|
|
356
|
+
validate: (v) => {
|
|
357
|
+
const s = v.trim().replace(/^\$/, "");
|
|
358
|
+
return s.length >= 1 && s.length <= 11 ? true : "1–11 characters.";
|
|
359
|
+
},
|
|
360
|
+
})).trim().replace(/^\$/, "");
|
|
361
|
+
const image = (await input({ message: "Image URL (optional)", validate: (v) => !v.trim() || isHttpUrl(v.trim()) })).trim();
|
|
362
|
+
const tweet = (await input({ message: "X link — announcement post (optional)", validate: (v) => !v.trim() || isHttpUrl(v.trim()) })).trim();
|
|
363
|
+
const website = (await input({ message: "Website link (optional)", validate: (v) => !v.trim() || isHttpUrl(v.trim()) })).trim();
|
|
364
|
+
info(`All fees will be redirected to your agent handle on X: ${accent("@" + handle)}`);
|
|
365
|
+
if (!await confirm(`Ready to launch $${tokenSymbol} — confirm?`, true)) {
|
|
366
|
+
info("Launch cancelled — re-run yappr deploy when ready, or supply an existing address.");
|
|
367
|
+
await promptForExistingToken();
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
let res;
|
|
371
|
+
try {
|
|
372
|
+
res = await spin(`Launching $${tokenSymbol} on Bankr…`, () => deployTokenLaunch({
|
|
373
|
+
tokenName,
|
|
374
|
+
tokenSymbol,
|
|
375
|
+
feeRecipient: { type: "x", value: handle },
|
|
376
|
+
image: image || undefined,
|
|
377
|
+
websiteUrl: website || undefined,
|
|
378
|
+
tweetUrl: tweet || undefined,
|
|
379
|
+
}), "Token launched");
|
|
380
|
+
}
|
|
381
|
+
catch (err) {
|
|
382
|
+
// Launches go through the shared Club launch key, so a Club 403 shouldn't
|
|
383
|
+
// happen here; on any other launch failure show Bankr's reason verbatim, then
|
|
384
|
+
// fall back to the manual address prompt so the operator can paste an existing CA.
|
|
385
|
+
fail(bankrErrorText(err));
|
|
386
|
+
await promptForExistingToken();
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const ca = res.tokenAddress;
|
|
390
|
+
if (!ca)
|
|
391
|
+
throw new Error(`Token launch returned no address: ${JSON.stringify(res)}`);
|
|
392
|
+
await setEnvVar(envPath, "TOKEN_ADDRESS", ca);
|
|
393
|
+
ok("Your token is launched!");
|
|
394
|
+
info(`The following CA was registered to the configuration: ${accent(ca)}`);
|
|
395
|
+
if (res.txHash)
|
|
396
|
+
info(`Transaction: https://basescan.org/tx/${res.txHash}`);
|
|
397
|
+
};
|
|
398
|
+
if (!isUnset(process.env.TOKEN_ADDRESS)) {
|
|
399
|
+
info("TOKEN_ADDRESS already set — skipping");
|
|
400
|
+
}
|
|
401
|
+
else if (await confirm("Is your agent's token already deployed on Bankr?", false)) {
|
|
402
|
+
await promptForExistingToken();
|
|
403
|
+
}
|
|
404
|
+
else if (await confirm("Launch a new token on Bankr now?", true)) {
|
|
405
|
+
await launchToken();
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
fail("A token is required to deploy — launch one or supply an address.");
|
|
409
|
+
process.exit(1);
|
|
410
|
+
}
|
|
411
|
+
await field("ADMIN_HANDLES", "Admin handles for admin-only skills, comma-separated (without @, blank to skip)", {
|
|
412
|
+
transform: (v) => v.split(",").map((h) => h.trim().replace(/^@/, "")).filter(Boolean).join(","),
|
|
413
|
+
});
|
|
414
|
+
// LLM model — chosen from the Bankr LLM Gateway catalogue (default deepseek-v4-flash).
|
|
415
|
+
const DEFAULT_MODEL = "deepseek-v4-flash";
|
|
416
|
+
if (isUnset(process.env.LLM_MODEL)) {
|
|
417
|
+
let models = [];
|
|
418
|
+
try {
|
|
419
|
+
models = await fetchLlmModels(bankrApiKey);
|
|
420
|
+
}
|
|
421
|
+
catch (err) {
|
|
422
|
+
warn(`Could not fetch model list (${err instanceof Error ? err.message : String(err)})`);
|
|
423
|
+
}
|
|
424
|
+
if (models.length) {
|
|
425
|
+
models.sort((a, b) => (a.pricing?.output ?? 1e9) - (b.pricing?.output ?? 1e9));
|
|
426
|
+
const model = await select({
|
|
427
|
+
message: "LLM model (via Bankr LLM Gateway)",
|
|
428
|
+
default: models.some((m) => m.id === DEFAULT_MODEL) ? DEFAULT_MODEL : models[0].id,
|
|
429
|
+
choices: models.map((m) => ({
|
|
430
|
+
name: `${m.name ?? m.id} ${dim(`${m.id} · $${m.pricing?.input ?? "?"}/$${m.pricing?.output ?? "?"} per Mtok`)}`,
|
|
431
|
+
value: m.id,
|
|
432
|
+
})),
|
|
433
|
+
});
|
|
434
|
+
await setEnvVar(envPath, "LLM_MODEL", model);
|
|
435
|
+
ok(`LLM_MODEL saved: ${model}`);
|
|
436
|
+
}
|
|
437
|
+
else {
|
|
438
|
+
await setEnvVar(envPath, "LLM_MODEL", DEFAULT_MODEL);
|
|
439
|
+
ok(`LLM_MODEL saved: ${DEFAULT_MODEL}`);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
info("LLM_MODEL already set — skipping");
|
|
444
|
+
}
|
|
445
|
+
// Token burn rate — % of collected token fees burned each treasury cycle.
|
|
446
|
+
if (isUnset(process.env.BURN_BPS)) {
|
|
447
|
+
const burnPct = await select({
|
|
448
|
+
message: "How much of collected token fees should be burned?",
|
|
449
|
+
default: 50,
|
|
450
|
+
choices: [
|
|
451
|
+
{ name: "0% — keep all fees", value: 0 },
|
|
452
|
+
{ name: "25%", value: 25 },
|
|
453
|
+
{ name: "50% — recommended", value: 50 },
|
|
454
|
+
{ name: "75%", value: 75 },
|
|
455
|
+
{ name: "100% — burn all fees", value: 100 },
|
|
456
|
+
],
|
|
457
|
+
});
|
|
458
|
+
await setEnvVar(envPath, "BURN_BPS", String(burnPct * 100));
|
|
459
|
+
ok(`BURN_BPS saved: ${burnPct * 100} (${burnPct}%)`);
|
|
460
|
+
}
|
|
461
|
+
else {
|
|
462
|
+
info("BURN_BPS already set — skipping");
|
|
463
|
+
}
|
|
464
|
+
// Optional dev fee — a cut of each treasury claim sent to a dev address
|
|
465
|
+
if (isUnset(process.env.DEV_ADDRESS)) {
|
|
466
|
+
if (await confirm("Set up a dev fee (send a cut of each claim's token + WETH to a dev address)?", false)) {
|
|
467
|
+
await field("DEV_ADDRESS", "Dev recipient address on Base", {
|
|
468
|
+
required: true,
|
|
469
|
+
validate: validateAddress,
|
|
470
|
+
});
|
|
471
|
+
const burnBps = Number(process.env.BURN_BPS ?? "5000");
|
|
472
|
+
await field("DEV_TOKEN_BPS", "Dev token fee in basis points (e.g. 500 = 5%)", {
|
|
473
|
+
required: true,
|
|
474
|
+
validate: (v) => {
|
|
475
|
+
if (!/^\d+$/.test(v) || +v < 0 || +v > 10000)
|
|
476
|
+
return "Whole number between 0 and 10000.";
|
|
477
|
+
if (burnBps + +v > 10000)
|
|
478
|
+
return `BURN_BPS (${burnBps}) + DEV_TOKEN_BPS (${v}) would exceed 10000.`;
|
|
479
|
+
return true;
|
|
480
|
+
},
|
|
481
|
+
});
|
|
482
|
+
await field("DEV_WETH_BPS", "Dev WETH fee in basis points (e.g. 500 = 5%)", {
|
|
483
|
+
required: true,
|
|
484
|
+
validate: (v) => {
|
|
485
|
+
if (!/^\d+$/.test(v) || +v < 0 || +v > 10000)
|
|
486
|
+
return "Whole number between 0 and 10000.";
|
|
487
|
+
return true;
|
|
488
|
+
},
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
492
|
+
await setEnvVar(envPath, "DEV_ADDRESS", "none");
|
|
493
|
+
info("Dev fee skipped");
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
else if (process.env.DEV_ADDRESS !== "none") {
|
|
497
|
+
info("DEV_ADDRESS already set — skipping");
|
|
498
|
+
}
|
|
499
|
+
// ── Step 2: Bankr wallet ──────────────────────────────────────────────────
|
|
500
|
+
step(2, TOTAL_STEPS, "Checking Bankr wallet");
|
|
501
|
+
const address = await resolveEvmAddress(bankrApiKey);
|
|
502
|
+
info(`Wallet: ${address}`);
|
|
503
|
+
let usdcBalance = 0;
|
|
504
|
+
let llmAutoTopUpToken = BASE_USDC_AUTO_TOP_UP_TOKEN;
|
|
505
|
+
try {
|
|
506
|
+
const balJson = await bankrApi(bankrApiKey, "/wallet/balances");
|
|
507
|
+
const base = balJson.balances?.base;
|
|
508
|
+
const usdc = base?.tokenBalances?.find((t) => {
|
|
509
|
+
const symbol = t.symbol ?? t.token?.baseToken?.symbol;
|
|
510
|
+
return symbol?.toUpperCase() === "USDC";
|
|
511
|
+
});
|
|
512
|
+
usdcBalance = Number(usdc?.balance ?? usdc?.token?.balance ?? 0);
|
|
513
|
+
const token = usdc?.token?.baseToken ?? usdc?.baseToken ?? usdc;
|
|
514
|
+
if (token?.address) {
|
|
515
|
+
llmAutoTopUpToken = {
|
|
516
|
+
address: token.address,
|
|
517
|
+
chain: "base",
|
|
518
|
+
symbol: token.symbol ?? "USDC",
|
|
519
|
+
name: token.name ?? "USD Coin",
|
|
520
|
+
decimals: Number(token.decimals ?? 6),
|
|
521
|
+
imageUrl: token.imgUrl ?? token.imageUrl,
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
catch { /* non-fatal */ }
|
|
526
|
+
info(`USDC balance: ${usdcBalance.toFixed(4)} USDC on Base`);
|
|
527
|
+
let llmCreditBalanceUsd;
|
|
528
|
+
try {
|
|
529
|
+
llmCreditBalanceUsd = await getLlmCreditBalanceUsd(bankrApiKey);
|
|
530
|
+
info(`LLM credits: $${llmCreditBalanceUsd.toFixed(2)}`);
|
|
531
|
+
}
|
|
532
|
+
catch (err) {
|
|
533
|
+
warn(`Could not check LLM credits: ${err instanceof Error ? err.message : String(err)}`);
|
|
534
|
+
}
|
|
535
|
+
let llmAutoTopUpConfig;
|
|
536
|
+
try {
|
|
537
|
+
llmAutoTopUpConfig = await getLlmAutoTopUpConfig(bankrApiKey);
|
|
538
|
+
info(`LLM auto top-up: ${llmAutoTopUpConfig.enabled ? "enabled" : "disabled"}`);
|
|
539
|
+
}
|
|
540
|
+
catch (err) {
|
|
541
|
+
warn(`Could not check LLM auto top-up: ${err instanceof Error ? err.message : String(err)}`);
|
|
542
|
+
}
|
|
543
|
+
const needsInitialLlmSeed = llmCreditBalanceUsd === undefined || llmCreditBalanceUsd < 1;
|
|
544
|
+
const requiredBalance = needsInitialLlmSeed ? 20 : 15;
|
|
545
|
+
// The full balance (compute + first day of X API) is only required for a fresh
|
|
546
|
+
// provision. When reusing an existing instance (COMPUTE_INSTANCE_ID set),
|
|
547
|
+
// compute is already prepaid — don't gate on it.
|
|
548
|
+
const reusingCompute = !isUnset(process.env.COMPUTE_INSTANCE_ID);
|
|
549
|
+
// Captured from the x402 compute payment (extend or provision) so it can be recorded
|
|
550
|
+
// into the server's stats DB after install — deploy pays out-of-band from the agent's
|
|
551
|
+
// payFetch, so this is the only path that books that compute spend onto the ledger.
|
|
552
|
+
let computeSpendUsd;
|
|
553
|
+
if (!reusingCompute && usdcBalance < requiredBalance) {
|
|
554
|
+
warn(`Insufficient balance: ${usdcBalance.toFixed(2)} USDC. You need at least ${requiredBalance} USDC to deploy:`);
|
|
555
|
+
if (needsInitialLlmSeed)
|
|
556
|
+
warn(` $5 LLM Gateway credits (initial seed)`);
|
|
557
|
+
else
|
|
558
|
+
warn(` $0 LLM Gateway credits (already at or above $1)`);
|
|
559
|
+
warn(` ~$1 First day of compute`);
|
|
560
|
+
warn(` ~$14 First day of X API usage via x402`);
|
|
561
|
+
warn(`Deposit USDC on Base to: ${address}`);
|
|
562
|
+
console.log("");
|
|
563
|
+
console.error(" Cannot proceed — top up and re-run.");
|
|
564
|
+
process.exit(1);
|
|
565
|
+
}
|
|
566
|
+
ok(`Balance: ${usdcBalance.toFixed(2)} USDC`);
|
|
567
|
+
if (!await confirm("Proceed with deployment?", true))
|
|
568
|
+
process.exit(0);
|
|
569
|
+
// ── Step 3: Bankr LLM Gateway setup ──────────────────────────────────────
|
|
570
|
+
step(3, TOTAL_STEPS, "Bankr LLM Gateway setup");
|
|
571
|
+
const needsAutoTopUp = !llmAutoTopUpConfig?.enabled;
|
|
572
|
+
if (!needsInitialLlmSeed && !needsAutoTopUp) {
|
|
573
|
+
ok(`LLM Gateway ready: $${llmCreditBalanceUsd?.toFixed(2)} credits and auto top-up enabled`);
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
info("This step will:");
|
|
577
|
+
if (needsInitialLlmSeed) {
|
|
578
|
+
info(" 1. Add $5 USDC to your LLM Gateway credits because balance is below $1");
|
|
579
|
+
info(" 2. Enable auto top-up: $5 each time balance drops below $1, funded from Base USDC");
|
|
580
|
+
info("");
|
|
581
|
+
info("Cost: ~$5 USDC drawn from your Bankr wallet on Base now.");
|
|
582
|
+
}
|
|
583
|
+
else {
|
|
584
|
+
info(` 1. Skip initial credit top-up; LLM Gateway already has $${llmCreditBalanceUsd?.toFixed(2)} credits`);
|
|
585
|
+
info(" 2. Enable auto top-up: $5 each time balance drops below $1, funded from Base USDC");
|
|
586
|
+
info("");
|
|
587
|
+
info("Cost now: $0 USDC. Future auto top-ups draw $5 USDC from your Bankr wallet on Base.");
|
|
588
|
+
}
|
|
589
|
+
const confirmMessage = needsInitialLlmSeed
|
|
590
|
+
? "Add $5 LLM credits and enable auto top-up?"
|
|
591
|
+
: "Enable LLM auto top-up from Base USDC?";
|
|
592
|
+
if (!await confirm(confirmMessage, true)) {
|
|
593
|
+
warn("Skipped LLM Gateway setup. The agent may fail on inference when credits run out:");
|
|
594
|
+
if (needsInitialLlmSeed)
|
|
595
|
+
warn(" bankr llm credits add 5 --yes");
|
|
596
|
+
warn(" bankr llm credits auto --enable --amount 5 --threshold 1 --tokens USDC");
|
|
597
|
+
}
|
|
598
|
+
else {
|
|
599
|
+
console.log("");
|
|
600
|
+
if (llmCreditBalanceUsd === undefined) {
|
|
601
|
+
info("Checking current LLM credits...");
|
|
602
|
+
llmCreditBalanceUsd = await getLlmCreditBalanceUsd(bankrApiKey);
|
|
603
|
+
info(`Current LLM credits: $${llmCreditBalanceUsd.toFixed(2)}`);
|
|
604
|
+
}
|
|
605
|
+
if (llmCreditBalanceUsd >= 1) {
|
|
606
|
+
info(`Skipping initial LLM credit top-up; balance is already $${llmCreditBalanceUsd.toFixed(2)}.`);
|
|
607
|
+
}
|
|
608
|
+
else {
|
|
609
|
+
info("Adding $5 initial LLM credits...");
|
|
610
|
+
const result = await topUpLlmCredits(bankrApiKey, 5);
|
|
611
|
+
llmCreditBalanceUsd = result.newBalance;
|
|
612
|
+
info(`New LLM credit balance: $${result.newBalance.toFixed(2)}`);
|
|
613
|
+
if (result.txHash)
|
|
614
|
+
info(`Transaction: https://basescan.org/tx/${result.txHash}`);
|
|
615
|
+
}
|
|
616
|
+
console.log("");
|
|
617
|
+
info("Enabling auto top-up ($5 when balance < $1, funded from Base USDC)...");
|
|
618
|
+
await enableLlmAutoTopUp(bankrApiKey, llmAutoTopUpToken);
|
|
619
|
+
ok("LLM Gateway ready");
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
let sshPassword = "";
|
|
623
|
+
let instance = null;
|
|
624
|
+
let instanceId = "";
|
|
625
|
+
let ip = "";
|
|
626
|
+
// Poll a provisioning instance until it reports a real IP, behind a spinner.
|
|
627
|
+
async function pollForIp(id, timeoutMs) {
|
|
628
|
+
const maxMinutes = Math.round(timeoutMs / 60_000);
|
|
629
|
+
return spin(`Waiting for instance IP — this can take up to ${maxMinutes} min…`, async (spinner) => {
|
|
630
|
+
const inst = await waitForComputeIp(bankrApiKey, address, id, timeoutMs);
|
|
631
|
+
spinner.text = themeText(computeInstanceIp(inst) ? `IP assigned: ${computeInstanceIp(inst)}` : "Instance IP not ready (timed out)");
|
|
632
|
+
return inst;
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
// The one-time root password can be retrieved only ONCE per instance (a second
|
|
636
|
+
// POST 409s). Persist it to .env immediately so a re-run after any later
|
|
637
|
+
// failure can reuse it instead of being locked out.
|
|
638
|
+
async function fetchAndSavePassword(id) {
|
|
639
|
+
const pw = await fetchOneTimePassword(bankrApiKey, address, id);
|
|
640
|
+
if (!pw)
|
|
641
|
+
throw new Error("compute API returned no password");
|
|
642
|
+
await setEnvVar(envPath, "COMPUTE_SSH_PASSWORD", pw);
|
|
643
|
+
return pw;
|
|
644
|
+
}
|
|
645
|
+
// The one root-password waterfall, used by both the existing-instance path and
|
|
646
|
+
// the post-provision install step: already-known (instance response) → .env →
|
|
647
|
+
// fetch-once + persist (when the API can serve it) → interactive prompt.
|
|
648
|
+
async function resolveSshPassword(current, id, canFetch) {
|
|
649
|
+
let pw = current;
|
|
650
|
+
if (!pw && !isUnset(process.env.COMPUTE_SSH_PASSWORD))
|
|
651
|
+
pw = process.env.COMPUTE_SSH_PASSWORD;
|
|
652
|
+
if (!pw && canFetch) {
|
|
653
|
+
try {
|
|
654
|
+
pw = await spin("Fetching one-time root password…", () => fetchAndSavePassword(id), "Got one-time root password");
|
|
655
|
+
}
|
|
656
|
+
catch (err) {
|
|
657
|
+
warn(`Could not fetch one-time password: ${err instanceof Error ? err.message : String(err)}`);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
if (!pw) {
|
|
661
|
+
pw = await password({ message: "Root one-time password", mask: "•", validate: (v) => v.trim() ? true : "Required." });
|
|
662
|
+
}
|
|
663
|
+
return pw;
|
|
664
|
+
}
|
|
665
|
+
// Drop a saved value from both the .env file and the live process — used when a
|
|
666
|
+
// destroyed instance's stale id/host/password must not shadow a fresh provision.
|
|
667
|
+
async function clearEnvVar(key) {
|
|
668
|
+
const content = await readFile(envPath, "utf8").catch(() => "");
|
|
669
|
+
await writeFile(envPath, removeEnvVarInContent(content, key));
|
|
670
|
+
delete process.env[key];
|
|
671
|
+
}
|
|
672
|
+
const existingComputeInstanceId = !isUnset(process.env.COMPUTE_INSTANCE_ID)
|
|
673
|
+
? process.env.COMPUTE_INSTANCE_ID
|
|
674
|
+
: "";
|
|
675
|
+
// Flipped on when an existing instance turns out to be destroyed/expired and the
|
|
676
|
+
// user opts to provision a replacement — routes into the fresh-provision flow below.
|
|
677
|
+
let needFreshProvision = !existingComputeInstanceId;
|
|
678
|
+
if (existingComputeInstanceId) {
|
|
679
|
+
// ── Step 4: existing compute ────────────────────────────────────────────
|
|
680
|
+
step(4, TOTAL_STEPS, "Checking existing compute");
|
|
681
|
+
info(`COMPUTE_INSTANCE_ID is set: ${existingComputeInstanceId}`);
|
|
682
|
+
try {
|
|
683
|
+
instance = await fetchComputeInstance(bankrApiKey, address, existingComputeInstanceId);
|
|
684
|
+
}
|
|
685
|
+
catch (err) {
|
|
686
|
+
warn(`Could not read compute details directly: ${err instanceof Error ? err.message : String(err)}`);
|
|
687
|
+
info("If this instance was bought with another wallet, enter the IP and one-time root password from that wallet's compute response.");
|
|
688
|
+
instance = { id: existingComputeInstanceId, status: "unknown" };
|
|
689
|
+
}
|
|
690
|
+
instanceId = computeInstanceId(instance) ?? existingComputeInstanceId;
|
|
691
|
+
ip = computeInstanceIp(instance) ?? "";
|
|
692
|
+
sshPassword = computeInstancePassword(instance) ?? "";
|
|
693
|
+
// Instance may still be booting — poll for its IP before falling back to a prompt.
|
|
694
|
+
if (!ip && instance?.status !== "unknown") {
|
|
695
|
+
instance = await pollForIp(instanceId, 180_000);
|
|
696
|
+
ip = computeInstanceIp(instance) ?? "";
|
|
697
|
+
}
|
|
698
|
+
if (!ip) {
|
|
699
|
+
ip = !isUnset(process.env.COMPUTE_HOST)
|
|
700
|
+
? process.env.COMPUTE_HOST
|
|
701
|
+
: await input({ message: "Compute public IP", validate: (v) => v.trim() ? true : "Required." });
|
|
702
|
+
}
|
|
703
|
+
// Instances use one-time root-password auth (skip the API fetch when the
|
|
704
|
+
// instance couldn't be read — it likely belongs to another wallet).
|
|
705
|
+
sshPassword = await resolveSshPassword(sshPassword, instanceId, instance?.status !== "unknown");
|
|
706
|
+
const remainingHours = remainingComputeHours(instance);
|
|
707
|
+
if (remainingHours !== null && remainingHours >= 24) {
|
|
708
|
+
ok(`Compute has ${remainingHours.toFixed(1)}h remaining; skipping compute purchase`);
|
|
709
|
+
// ── Step 5: compute ready ─────────────────────────────────────────────
|
|
710
|
+
step(5, TOTAL_STEPS, "Compute ready");
|
|
711
|
+
ok("Reusing existing compute instance");
|
|
712
|
+
}
|
|
713
|
+
else if (remainingHours === null) {
|
|
714
|
+
warn("Could not determine remaining compute hours; skipping compute purchase because an existing instance was provided");
|
|
715
|
+
// ── Step 5: compute ready ─────────────────────────────────────────────
|
|
716
|
+
step(5, TOTAL_STEPS, "Compute ready");
|
|
717
|
+
ok("Using provided compute instance");
|
|
718
|
+
}
|
|
719
|
+
else {
|
|
720
|
+
const remaining = remainingHours === null ? "unknown" : `${remainingHours.toFixed(1)}h`;
|
|
721
|
+
warn(`Compute has ${remaining} remaining; extending by 24h`);
|
|
722
|
+
// ── Step 5: extend compute ────────────────────────────────────────────
|
|
723
|
+
step(5, TOTAL_STEPS, "Extending compute");
|
|
724
|
+
try {
|
|
725
|
+
await spin("Paying for +24h via x402…", () => extendComputeInstance(bankrApiKey, address, instanceId, (usd) => { computeSpendUsd = usd; }), "Compute extended by 24h");
|
|
726
|
+
instance = await fetchComputeInstance(bankrApiKey, address, instanceId);
|
|
727
|
+
ip = computeInstanceIp(instance) ?? ip;
|
|
728
|
+
}
|
|
729
|
+
catch (err) {
|
|
730
|
+
// A destroyed/expired instance can't be extended — the provider has already
|
|
731
|
+
// reclaimed it (typically once remaining hours went negative, as here). Offer
|
|
732
|
+
// to provision a fresh instance and rewire .env instead of dead-ending.
|
|
733
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
734
|
+
if (!/destroy|expir|not\s*found|no\s*such|410|404/i.test(msg))
|
|
735
|
+
throw err;
|
|
736
|
+
warn(`Could not extend compute: ${msg}`);
|
|
737
|
+
warn("This instance has been destroyed by the provider and can't be extended.");
|
|
738
|
+
if (!await confirm("Provision a fresh compute instance and update .env?", true)) {
|
|
739
|
+
console.log(" Aborted.");
|
|
740
|
+
process.exit(0);
|
|
741
|
+
}
|
|
742
|
+
// Drop the dead instance's saved id/host/password so they don't shadow the
|
|
743
|
+
// new one (COMPUTE_HOST + resolveSshPassword read these back later).
|
|
744
|
+
for (const key of ["COMPUTE_INSTANCE_ID", "COMPUTE_HOST", "COMPUTE_SSH_PASSWORD"]) {
|
|
745
|
+
await clearEnvVar(key);
|
|
746
|
+
}
|
|
747
|
+
instance = null;
|
|
748
|
+
instanceId = "";
|
|
749
|
+
ip = "";
|
|
750
|
+
sshPassword = "";
|
|
751
|
+
needFreshProvision = true;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
if (needFreshProvision) {
|
|
756
|
+
// ── Step 4: select compute ──────────────────────────────────────────────
|
|
757
|
+
step(4, TOTAL_STEPS, "Selecting compute");
|
|
758
|
+
info("Fetching available plans, regions and OS...");
|
|
759
|
+
const [plansRes, regionsRes, osRes] = await Promise.all([
|
|
760
|
+
fetch("https://compute.x402layer.cc/compute/plans?type=vps"),
|
|
761
|
+
fetch("https://compute.x402layer.cc/compute/regions"),
|
|
762
|
+
fetch("https://compute.x402layer.cc/compute/os"),
|
|
763
|
+
]);
|
|
764
|
+
if (!plansRes.ok)
|
|
765
|
+
throw new Error(`Failed to fetch plans: ${plansRes.status}`);
|
|
766
|
+
const plansJson = await plansRes.json();
|
|
767
|
+
const plans = (plansJson.plans ?? plansJson.data ?? plansJson ?? [])
|
|
768
|
+
.sort((a, b) => (a.our_hourly ?? 999) - (b.our_hourly ?? 999));
|
|
769
|
+
if (!plans.length)
|
|
770
|
+
throw new Error("No VPS plans found");
|
|
771
|
+
const regionsJson = regionsRes.ok ? await regionsRes.json() : {};
|
|
772
|
+
const regions = regionsJson.regions ?? regionsJson.data ?? regionsJson ?? [];
|
|
773
|
+
const osJson = osRes.ok ? await osRes.json() : {};
|
|
774
|
+
const osList = osJson.os ?? osJson.data ?? osJson ?? [];
|
|
775
|
+
const ubuntu = osList.find((o) => /ubuntu.*22|ubuntu.*24/i.test(o.name ?? o.label ?? "")) ?? osList[0];
|
|
776
|
+
const osId = ubuntu?.id ?? ubuntu?.os_id ?? 387;
|
|
777
|
+
console.log("");
|
|
778
|
+
const selectedPlan = await select({
|
|
779
|
+
message: "Select a compute plan",
|
|
780
|
+
choices: plans.map((p) => {
|
|
781
|
+
const id = p.id ?? p.slug ?? p.name;
|
|
782
|
+
const provider = p.provider ?? "unknown provider";
|
|
783
|
+
const price = typeof p.our_daily === "number" ? `${formatUsd(p.our_daily, 2)}/day` : "price unavailable";
|
|
784
|
+
const specs = [p.vcpu_count && `${p.vcpu_count} vCPU`, p.ram && `${p.ram}MB RAM`, p.disk && `${p.disk}GB`].filter(Boolean).join(" · ");
|
|
785
|
+
return { name: `${id} ${dim(`${provider} · ${price}${specs ? " · " + specs : ""}`)}`, value: p };
|
|
786
|
+
}),
|
|
787
|
+
});
|
|
788
|
+
const planLocations = Array.isArray(selectedPlan.locations)
|
|
789
|
+
? new Set(selectedPlan.locations.map((location) => String(location).toLowerCase()))
|
|
790
|
+
: undefined;
|
|
791
|
+
const matchingRegions = planLocations
|
|
792
|
+
? regions.filter((r) => planLocations.has(regionCode(r)))
|
|
793
|
+
: regions;
|
|
794
|
+
const selectableRegions = planLocations && matchingRegions.length === 0
|
|
795
|
+
? [...planLocations].map((id) => ({ id }))
|
|
796
|
+
: matchingRegions;
|
|
797
|
+
if (!selectableRegions.length) {
|
|
798
|
+
throw new Error(`No regions found for plan ${selectedPlan.id ?? selectedPlan.name ?? "unknown"}`);
|
|
799
|
+
}
|
|
800
|
+
const selectedRegion = await select({
|
|
801
|
+
message: "Select a region",
|
|
802
|
+
choices: selectableRegions.map((r) => ({
|
|
803
|
+
name: regionLabel(r),
|
|
804
|
+
value: r,
|
|
805
|
+
})),
|
|
806
|
+
});
|
|
807
|
+
const planId = selectedPlan.id ?? selectedPlan.slug ?? selectedPlan.name;
|
|
808
|
+
const provider = selectedPlan.provider ?? "vultr";
|
|
809
|
+
const region = regionCode(selectedRegion);
|
|
810
|
+
const regionName = regionLabel(selectedRegion);
|
|
811
|
+
const hourlyRate = selectedPlan.our_hourly;
|
|
812
|
+
const dailyRate = selectedPlan.our_daily;
|
|
813
|
+
if (typeof dailyRate !== "number" && typeof hourlyRate !== "number") {
|
|
814
|
+
throw new Error(`Plan ${planId} did not include our_daily or our_hourly pricing from the compute API`);
|
|
815
|
+
}
|
|
816
|
+
const hourlyCost = typeof hourlyRate === "number" ? `${formatUsd(hourlyRate, 4)}/hr` : "price unavailable";
|
|
817
|
+
const dailyCost = typeof dailyRate === "number"
|
|
818
|
+
? formatUsd(dailyRate, 2)
|
|
819
|
+
: `~${formatUsd(hourlyRate * 24, 2)}`;
|
|
820
|
+
console.log("");
|
|
821
|
+
info(`Plan: ${planId} (${hourlyCost})`);
|
|
822
|
+
info(`Provider: ${provider}`);
|
|
823
|
+
info(`Region: ${regionName}`);
|
|
824
|
+
info(`OS: ${ubuntu?.name ?? "Ubuntu"}`);
|
|
825
|
+
info(`Cost: 1 day prepaid (${dailyCost} via x402 on Base)`);
|
|
826
|
+
info("");
|
|
827
|
+
info("After deployment the agent self-extends compute when < 24h remain.");
|
|
828
|
+
if (!await confirm(`Provision VPS (${dailyCost} paid via x402 from your Bankr wallet)?`, true)) {
|
|
829
|
+
console.log(" Aborted.");
|
|
830
|
+
process.exit(0);
|
|
831
|
+
}
|
|
832
|
+
// ── Step 5: provision VPS ───────────────────────────────────────────────
|
|
833
|
+
step(5, TOTAL_STEPS, "Provisioning VPS");
|
|
834
|
+
// Provision without an SSH key — the box uses one-time root-password auth,
|
|
835
|
+
// which we fetch from the compute API once it's up.
|
|
836
|
+
const provisionBody = JSON.stringify({
|
|
837
|
+
plan: planId,
|
|
838
|
+
region,
|
|
839
|
+
os_id: osId,
|
|
840
|
+
label: "yappr",
|
|
841
|
+
hostname: "yappr",
|
|
842
|
+
prepaid_hours: 24,
|
|
843
|
+
network: "base",
|
|
844
|
+
provider,
|
|
845
|
+
});
|
|
846
|
+
const provision = await spin(`Paying ${dailyCost} via x402 & provisioning…`, () => computeX402Pay(bankrApiKey, address, "https://compute.x402layer.cc/compute/provision", provisionBody, (usd) => { computeSpendUsd = usd; }), "Compute paid & provisioning started");
|
|
847
|
+
instanceId = computeInstanceId(provision) ?? "";
|
|
848
|
+
if (!instanceId)
|
|
849
|
+
throw new Error(`Provision response had no instance id: ${JSON.stringify(provision)}`);
|
|
850
|
+
// Persist the id immediately — payment already happened, so a later failure
|
|
851
|
+
// (e.g. IP not ready) must not orphan a paid instance on re-run.
|
|
852
|
+
await setEnvVar(envPath, "COMPUTE_INSTANCE_ID", instanceId);
|
|
853
|
+
ok(`Instance provisioned: ${instanceId} (saved to .env)`);
|
|
854
|
+
sshPassword = computeInstancePassword(provision) ?? "";
|
|
855
|
+
instance = provision;
|
|
856
|
+
// Freshly provisioned instances report 0.0.0.0 until the provider boots them.
|
|
857
|
+
ip = computeInstanceIp(provision) ?? "";
|
|
858
|
+
if (!ip) {
|
|
859
|
+
instance = await pollForIp(instanceId, 300_000);
|
|
860
|
+
ip = computeInstanceIp(instance) ?? "";
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
if (!instanceId)
|
|
864
|
+
throw new Error("Could not resolve compute instance id");
|
|
865
|
+
instance = await fetchComputeInstance(bankrApiKey, address, instanceId).catch(() => instance);
|
|
866
|
+
if (!ip)
|
|
867
|
+
ip = computeInstanceIp(instance) ?? "";
|
|
868
|
+
if (!ip)
|
|
869
|
+
throw new Error(`Could not resolve compute IP for instance ${instanceId} (still provisioning?)`);
|
|
870
|
+
// Persist the resolved IP so re-runs / `npm run ssh` can skip the lookup.
|
|
871
|
+
await setEnvVar(envPath, "COMPUTE_HOST", ip);
|
|
872
|
+
const data = computeInstanceData(instance);
|
|
873
|
+
const status = data?.status ?? "provisioning";
|
|
874
|
+
const plan = data?.plan ?? data?.provider_plan_id ?? data?.vultr_plan ?? "—";
|
|
875
|
+
const region = data?.region ?? data?.vultr_region ?? "—";
|
|
876
|
+
const os = data?.os ?? data?.vultr_os ?? "—";
|
|
877
|
+
const expiry = computeInstanceExpiry(instance);
|
|
878
|
+
const expiryFmt = expiry ? expiry.toLocaleString() : "—";
|
|
879
|
+
console.log("");
|
|
880
|
+
printPanel("INSTANCE", [
|
|
881
|
+
kvRow("Instance", instanceId),
|
|
882
|
+
kvRow("IP", ip),
|
|
883
|
+
kvRow("Status", status === "active" ? green(status) : status),
|
|
884
|
+
kvRow("Plan", `${plan} ${dim("· " + region)}`),
|
|
885
|
+
kvRow("OS", os),
|
|
886
|
+
kvRow("Expires", expiryFmt),
|
|
887
|
+
]);
|
|
888
|
+
// ── Step 6: remote setup ──────────────────────────────────────────────────
|
|
889
|
+
step(6, TOTAL_STEPS, "Installing agent on VPS");
|
|
890
|
+
// Password-only auth: resolve the one-time root password (response → env → fetch → prompt).
|
|
891
|
+
if (!sshPassword)
|
|
892
|
+
sshPassword = computeInstancePassword(instance) ?? "";
|
|
893
|
+
sshPassword = await resolveSshPassword(sshPassword, instanceId, true);
|
|
894
|
+
await spin("Waiting for SSH on port 22…", () => waitForPort(ip, 22, 120_000), "SSH port open");
|
|
895
|
+
const ssh = new NodeSSH();
|
|
896
|
+
await spin("Connecting via SSH…", () => ssh.connect({ host: ip, username: "root", password: sshPassword, ...hostKeyConfig(ip) }), `Connected to ${ip}`);
|
|
897
|
+
// Bundle the local engine into a tarball (the server installs that exact build).
|
|
898
|
+
const tarball = await spin("Bundling engine…", () => bundleEngine(), "Engine bundled");
|
|
899
|
+
await spin("Uploading to /yappr…", async () => {
|
|
900
|
+
// /yappr is wiped and re-uploaded each deploy, so durable data lives elsewhere:
|
|
901
|
+
// the app DB sits in /var/lib/yappr (persisted across redeploys via DB_PATH).
|
|
902
|
+
await sshExec(ssh, "rm -rf /yappr && mkdir -p /yappr /var/lib/yappr", { quiet: true });
|
|
903
|
+
// The engine tarball + a minimal package.json that installs it.
|
|
904
|
+
const tarName = basename(tarball);
|
|
905
|
+
await ssh.putFile(tarball, `/yappr/${tarName}`);
|
|
906
|
+
// Private staging dir (0700) for generated files — the env copy below holds
|
|
907
|
+
// every credential, so it must never sit world-readable in /tmp. Always
|
|
908
|
+
// removed, even when the upload fails.
|
|
909
|
+
const stageDir = await mkdtemp(join(tmpdir(), "yappr-deploy-"));
|
|
910
|
+
try {
|
|
911
|
+
const serverPkg = { name: "yappr-instance", private: true, type: "module", dependencies: { yappr: `file:./${tarName}` } };
|
|
912
|
+
const pkgTmp = join(stageDir, "package.json");
|
|
913
|
+
await writeFile(pkgTmp, JSON.stringify(serverPkg, null, 2));
|
|
914
|
+
await ssh.putFile(pkgTmp, "/yappr/package.json");
|
|
915
|
+
// The instance's config (the user's add-ons) — skip hidden junk.
|
|
916
|
+
const uploaded = await ssh.putDirectory(join(process.cwd(), "config"), "/yappr/config", {
|
|
917
|
+
recursive: true,
|
|
918
|
+
concurrency: 8,
|
|
919
|
+
validate: (p) => !basename(p).startsWith("."),
|
|
920
|
+
});
|
|
921
|
+
if (!uploaded)
|
|
922
|
+
throw new Error("Failed to upload config/ to /yappr");
|
|
923
|
+
// The uploaded .env carries everything the agent needs — minus the box's own
|
|
924
|
+
// root password, which the server has no use for. 0600 locally and remotely
|
|
925
|
+
// (SFTP would otherwise land it 0644, readable by any non-root user).
|
|
926
|
+
const cloudEnvPath = join(stageDir, "cloud.env");
|
|
927
|
+
const localEnv = await readFile(envPath, "utf8");
|
|
928
|
+
let cloudEnv = setEnvVarInContent(localEnv, "CLOUD_INSTANCE", "true");
|
|
929
|
+
cloudEnv = setEnvVarInContent(cloudEnv, "DB_PATH", "/var/lib/yappr/yappr.db");
|
|
930
|
+
cloudEnv = removeEnvVarInContent(cloudEnv, "COMPUTE_SSH_PASSWORD");
|
|
931
|
+
await writeFile(cloudEnvPath, cloudEnv, { mode: 0o600 });
|
|
932
|
+
await ssh.putFile(cloudEnvPath, "/yappr/.env");
|
|
933
|
+
await sshExec(ssh, "chmod 600 /yappr/.env", { quiet: true });
|
|
934
|
+
}
|
|
935
|
+
finally {
|
|
936
|
+
await rm(stageDir, { recursive: true, force: true });
|
|
937
|
+
}
|
|
938
|
+
}, "Uploaded");
|
|
939
|
+
// Each step is a separate SSH command (the shell resets to / between calls, so
|
|
940
|
+
// commands that need the project dir cd into /yappr themselves).
|
|
941
|
+
// Node.js: only show the install spinner when it's actually missing or too old.
|
|
942
|
+
// The floor is 20 — better-sqlite3@12 (a native dep) doesn't support older majors,
|
|
943
|
+
// so accepting e.g. a pre-existing Node 18 would fail later, at `npm install`.
|
|
944
|
+
const nodeMajor = await sshNodeMajor(ssh);
|
|
945
|
+
if (nodeMajor !== null && nodeMajor >= 20) {
|
|
946
|
+
ok(`Node.js already installed (v${nodeMajor})`);
|
|
947
|
+
}
|
|
948
|
+
else {
|
|
949
|
+
await spin("Installing Node.js…", () => sshExec(ssh, "curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && apt-get install -y nodejs", { quiet: true }), "Node.js ready");
|
|
950
|
+
}
|
|
951
|
+
// Installs the engine from the tarball + its deps (no build needed — dist ships).
|
|
952
|
+
await spin("Installing engine…", () => sshExec(ssh, "cd /yappr && npm install", { quiet: true }), "Engine installed");
|
|
953
|
+
// Migrate stats onto a FRESH box. /var/lib/yappr survives redeploys to the same
|
|
954
|
+
// instance, so the DB is only absent when this is a brand-new instance. If the user
|
|
955
|
+
// has a local backup (pulled by `yappr status`), offer to restore it so stats carry
|
|
956
|
+
// over to the new instance. Never clobber an existing remote DB.
|
|
957
|
+
const localBackup = await latestLocalBackup();
|
|
958
|
+
if (localBackup && !(await remoteFileExists(ssh, REMOTE_DB_PATH))) {
|
|
959
|
+
if (await confirm(`Restore the database from local backup ${backupLabel(localBackup)} onto this instance?`, true)) {
|
|
960
|
+
await spin("Restoring database…", async () => {
|
|
961
|
+
await sshExec(ssh, "mkdir -p /var/lib/yappr", { quiet: true });
|
|
962
|
+
await ssh.putFile(localBackup, REMOTE_DB_PATH);
|
|
963
|
+
}, "Database restored from backup");
|
|
964
|
+
}
|
|
965
|
+
else {
|
|
966
|
+
info("Starting with a fresh database");
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
// Book the deploy-time compute payment into the server's stats DB so its cost shows up
|
|
970
|
+
// in spend totals, the runway burn and the expense charts. Deploy pays for compute
|
|
971
|
+
// locally (computeX402Pay, outside the agent's payFetch), so this SSH-side write is the
|
|
972
|
+
// only thing that records it. The engine is installed and the DB is final by now, and
|
|
973
|
+
// stats-cli run from /yappr resolves DB_PATH to the same DB the agent uses. Best-effort:
|
|
974
|
+
// a stats write must never fail the deploy.
|
|
975
|
+
if (computeSpendUsd && computeSpendUsd > 0) {
|
|
976
|
+
try {
|
|
977
|
+
await sshExec(ssh, `cd /yappr && node node_modules/yappr/dist/src/stats-cli.js record-spend compute ${computeSpendUsd.toFixed(6)}`, { quiet: true });
|
|
978
|
+
ok(`Recorded compute spend ($${computeSpendUsd.toFixed(4)}) to stats`);
|
|
979
|
+
}
|
|
980
|
+
catch (err) {
|
|
981
|
+
warn(`Could not record compute spend to stats: ${err instanceof Error ? err.message : String(err)}`);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
// Only show the install spinner when pm2 is actually missing.
|
|
985
|
+
if (await sshHas(ssh, "pm2")) {
|
|
986
|
+
ok("pm2 already installed");
|
|
987
|
+
}
|
|
988
|
+
else {
|
|
989
|
+
await spin("Installing pm2…", () => sshExec(ssh, "npm install -g pm2", { quiet: true }), "pm2 installed");
|
|
990
|
+
}
|
|
991
|
+
// Start under pm2 from /yappr so the process cwd is the project dir (config-loader
|
|
992
|
+
// reads /yappr/config). The agent entry lives in the installed engine. We DELETE any
|
|
993
|
+
// existing process first rather than `pm2 restart`: restart reuses the script path
|
|
994
|
+
// baked in at first registration, but each deploy wipes /yappr and reinstalls the
|
|
995
|
+
// engine, so a fresh start is the only way pm2 picks up the current path.
|
|
996
|
+
await spin("Starting agent under pm2…", () => sshExec(ssh, `
|
|
997
|
+
cd /yappr &&
|
|
998
|
+
(pm2 delete yappr || true) &&
|
|
999
|
+
pm2 start node_modules/yappr/dist/src/yappr.js --name yappr --update-env &&
|
|
1000
|
+
pm2 save &&
|
|
1001
|
+
(pm2 startup systemd -u root --hp /root || true)
|
|
1002
|
+
`, { quiet: true }), "Agent started");
|
|
1003
|
+
// ── Step 7: health check ──────────────────────────────────────────────────
|
|
1004
|
+
step(7, TOTAL_STEPS, "Health check");
|
|
1005
|
+
await spin("Waiting for pm2 process to come online…", () => sshExec(ssh, "timeout 30 bash -c 'until pm2 show yappr | grep -q online; do sleep 2; done'", { quiet: true }), "Agent is online");
|
|
1006
|
+
ssh.dispose();
|
|
1007
|
+
// ── Summary ───────────────────────────────────────────────────────────────
|
|
1008
|
+
const handle = process.env.AGENT_HANDLE;
|
|
1009
|
+
console.log("");
|
|
1010
|
+
console.log(` ${green("✓")} ${bold("Deployment complete")} ${dim("— your agent is live")}`);
|
|
1011
|
+
console.log("");
|
|
1012
|
+
printPanel("AGENT", [
|
|
1013
|
+
kvRow("Agent", accent(`@${handle}`)),
|
|
1014
|
+
kvRow("IP", ip),
|
|
1015
|
+
kvRow("Wallet", address),
|
|
1016
|
+
"",
|
|
1017
|
+
dim("The agent will:"),
|
|
1018
|
+
...[
|
|
1019
|
+
`Poll for @${handle} mentions every 20s`,
|
|
1020
|
+
"Reply via Bankr LLM Gateway",
|
|
1021
|
+
"Self-extend compute when < 24h remain",
|
|
1022
|
+
"Auto top-up LLM credits when balance < $1",
|
|
1023
|
+
].map((line) => `${accent("·")} ${dim(line)}`),
|
|
1024
|
+
"",
|
|
1025
|
+
kvRow("Status", `${bold("yappr status")} ${dim("live dashboard + logs")}`),
|
|
1026
|
+
kvRow("SSH", `${bold("yappr ssh")} ${dim(`(or ssh root@${ip})`)}`),
|
|
1027
|
+
]);
|
|
1028
|
+
console.log("");
|
|
1029
|
+
// Hand off to the live status dashboard (interactive terminals only). This
|
|
1030
|
+
// streams pm2 logs until the user hits Ctrl+C; the same view is available
|
|
1031
|
+
// anytime via `npm run status`.
|
|
1032
|
+
if (process.stdout.isTTY) {
|
|
1033
|
+
console.log(` ${dim("Opening live dashboard… (Ctrl+C to exit)")}`);
|
|
1034
|
+
await runStatus({ ip, password: sshPassword, handle });
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
export async function run() {
|
|
1038
|
+
await main();
|
|
1039
|
+
}
|
|
1040
|
+
// Direct invocation (`tsx src/cli/deploy.ts`) — the bin calls run() instead.
|
|
1041
|
+
const isMain = (() => {
|
|
1042
|
+
try {
|
|
1043
|
+
return fileURLToPath(import.meta.url) === resolve(process.argv[1] ?? "");
|
|
1044
|
+
}
|
|
1045
|
+
catch {
|
|
1046
|
+
return false;
|
|
1047
|
+
}
|
|
1048
|
+
})();
|
|
1049
|
+
if (isMain) {
|
|
1050
|
+
run().catch((err) => {
|
|
1051
|
+
// Inquirer throws ExitPromptError on Ctrl-C — exit quietly
|
|
1052
|
+
if (err?.name === "ExitPromptError") {
|
|
1053
|
+
console.log("\n Aborted.");
|
|
1054
|
+
process.exit(0);
|
|
1055
|
+
}
|
|
1056
|
+
console.error(`\n ✗ ${err.message}`);
|
|
1057
|
+
process.exit(1);
|
|
1058
|
+
});
|
|
1059
|
+
}
|