run402 1.35.0 → 1.35.2
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/lib/blob.mjs +1 -8
- package/lib/deploy.mjs +117 -11
- package/lib/init.mjs +45 -8
- package/lib/manifest.mjs +32 -7
- package/lib/projects.mjs +6 -2
- package/lib/status.mjs +75 -4
- package/package.json +1 -1
package/lib/blob.mjs
CHANGED
|
@@ -34,7 +34,7 @@ import { basename, dirname, join, resolve as resolvePath } from "node:path";
|
|
|
34
34
|
import { homedir } from "node:os";
|
|
35
35
|
import { pipeline } from "node:stream/promises";
|
|
36
36
|
|
|
37
|
-
import {
|
|
37
|
+
import { resolveProject, API } from "./config.mjs";
|
|
38
38
|
|
|
39
39
|
const HELP = `run402 blob — Direct-to-S3 blob storage
|
|
40
40
|
|
|
@@ -97,13 +97,6 @@ function parseArgs(args) {
|
|
|
97
97
|
return out;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
function resolveProject(projectId) {
|
|
101
|
-
if (!projectId) die("--project is required (or run 'run402 projects use <id>' to set default)");
|
|
102
|
-
const p = findProject(projectId);
|
|
103
|
-
if (!p) die(`Project not found: ${projectId}`);
|
|
104
|
-
return p;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
100
|
async function sha256File(filePath) {
|
|
108
101
|
const h = createHash("sha256");
|
|
109
102
|
const stream = createReadStream(filePath);
|
package/lib/deploy.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "fs";
|
|
2
2
|
import { dirname, resolve } from "path";
|
|
3
3
|
import { Agent, fetch as undiciFetch } from "undici";
|
|
4
|
-
import { API, allowanceAuthHeaders,
|
|
4
|
+
import { API, allowanceAuthHeaders, resolveProjectId } from "./config.mjs";
|
|
5
5
|
import { resolveFilePathsInManifest, resolveMigrationsFile } from "./manifest.mjs";
|
|
6
6
|
|
|
7
7
|
// Custom undici dispatcher with longer timeouts for large-batch deploys.
|
|
@@ -158,6 +158,91 @@ async function readStdin() {
|
|
|
158
158
|
return Buffer.concat(chunks).toString("utf-8");
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Load + parse the manifest from --manifest file or stdin, and resolve any
|
|
163
|
+
* referenced files[].path / migrations_file against the manifest's directory.
|
|
164
|
+
*
|
|
165
|
+
* Returns { manifest } on success, or { error } with a structured error object
|
|
166
|
+
* on any fs / parse failure. Never throws.
|
|
167
|
+
*
|
|
168
|
+
* The returned error shape (GH-44):
|
|
169
|
+
* { status: "error", message, field, path?, hint? }
|
|
170
|
+
* where `field` is one of: "manifest", "stdin", "migrations_file", "files[<i>].path".
|
|
171
|
+
*/
|
|
172
|
+
async function loadManifest(opts) {
|
|
173
|
+
let raw;
|
|
174
|
+
let baseDir = null;
|
|
175
|
+
|
|
176
|
+
// Step 1: read the manifest source.
|
|
177
|
+
if (opts.manifest) {
|
|
178
|
+
const manifestAbs = resolve(opts.manifest);
|
|
179
|
+
baseDir = dirname(manifestAbs);
|
|
180
|
+
try {
|
|
181
|
+
raw = readFileSync(opts.manifest, "utf-8");
|
|
182
|
+
} catch (err) {
|
|
183
|
+
if (err && err.code === "ENOENT") {
|
|
184
|
+
return { error: {
|
|
185
|
+
status: "error",
|
|
186
|
+
message: `File not found: ${manifestAbs}`,
|
|
187
|
+
field: "manifest",
|
|
188
|
+
path: manifestAbs,
|
|
189
|
+
hint: "Check that --manifest points to an existing JSON file.",
|
|
190
|
+
} };
|
|
191
|
+
}
|
|
192
|
+
return { error: {
|
|
193
|
+
status: "error",
|
|
194
|
+
message: err && err.message ? err.message : String(err),
|
|
195
|
+
field: "manifest",
|
|
196
|
+
path: manifestAbs,
|
|
197
|
+
...(err && err.code ? { code: err.code } : {}),
|
|
198
|
+
} };
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
raw = await readStdin();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Step 2: parse JSON.
|
|
205
|
+
let manifest;
|
|
206
|
+
try {
|
|
207
|
+
manifest = JSON.parse(raw);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
return { error: {
|
|
210
|
+
status: "error",
|
|
211
|
+
message: `Manifest is not valid JSON: ${err.message}`,
|
|
212
|
+
field: opts.manifest ? "manifest" : "stdin",
|
|
213
|
+
...(opts.manifest ? { path: resolve(opts.manifest) } : {}),
|
|
214
|
+
} };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Step 3: resolve file paths (only when reading from a manifest file — we
|
|
218
|
+
// can't resolve relative paths without a baseDir).
|
|
219
|
+
if (opts.manifest) {
|
|
220
|
+
try {
|
|
221
|
+
resolveMigrationsFile(manifest, baseDir);
|
|
222
|
+
resolveFilePathsInManifest(manifest, baseDir);
|
|
223
|
+
} catch (err) {
|
|
224
|
+
if (err && err.code === "ENOENT") {
|
|
225
|
+
return { error: {
|
|
226
|
+
status: "error",
|
|
227
|
+
message: `File not found: ${err.absPath || err.path || "<unknown>"}`,
|
|
228
|
+
field: err.field || "manifest",
|
|
229
|
+
...(err.absPath || err.path ? { path: err.absPath || err.path } : {}),
|
|
230
|
+
hint: `Paths in manifest.${err.field || "files[].path"} are resolved relative to the manifest file's directory (${baseDir}).`,
|
|
231
|
+
} };
|
|
232
|
+
}
|
|
233
|
+
return { error: {
|
|
234
|
+
status: "error",
|
|
235
|
+
message: err && err.message ? err.message : String(err),
|
|
236
|
+
...(err && err.field ? { field: err.field } : {}),
|
|
237
|
+
...(err && (err.absPath || err.path) ? { path: err.absPath || err.path } : {}),
|
|
238
|
+
...(err && err.code ? { code: err.code } : {}),
|
|
239
|
+
} };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return { manifest };
|
|
244
|
+
}
|
|
245
|
+
|
|
161
246
|
export async function run(args) {
|
|
162
247
|
const opts = { manifest: null, project: null };
|
|
163
248
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -166,21 +251,42 @@ export async function run(args) {
|
|
|
166
251
|
if (args[i] === "--project" && args[i + 1]) opts.project = args[++i];
|
|
167
252
|
}
|
|
168
253
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
254
|
+
// Load + parse the manifest. Errors here (missing --manifest path, malformed
|
|
255
|
+
// JSON, or any referenced files[].path / migrations_file that doesn't exist)
|
|
256
|
+
// must be surfaced as structured JSON on stderr — never as a raw Node stack
|
|
257
|
+
// trace (GH-44). The CLI is agent-first; stack traces break JSON consumers.
|
|
258
|
+
const manifestResult = await loadManifest(opts);
|
|
259
|
+
if (manifestResult.error) {
|
|
260
|
+
console.error(JSON.stringify(manifestResult.error));
|
|
261
|
+
process.exit(1);
|
|
262
|
+
}
|
|
263
|
+
const manifest = manifestResult.manifest;
|
|
264
|
+
|
|
265
|
+
// If both sources set project_id and they disagree, refuse to deploy rather
|
|
266
|
+
// than silently shipping to the wrong target. Agents and humans should be
|
|
267
|
+
// forced to be explicit when the two sources conflict (GH-42).
|
|
268
|
+
if (opts.project && manifest.project_id && opts.project !== manifest.project_id) {
|
|
269
|
+
const err = {
|
|
270
|
+
status: "error",
|
|
271
|
+
message: `project_id conflict: manifest.project_id=${manifest.project_id} but --project=${opts.project}`,
|
|
272
|
+
manifest_project_id: manifest.project_id,
|
|
273
|
+
flag_project_id: opts.project,
|
|
274
|
+
hint: "Remove one of them or make them match. The --project flag and manifest.project_id must agree (or only one of them must be set).",
|
|
275
|
+
};
|
|
276
|
+
console.error(JSON.stringify(err));
|
|
277
|
+
process.exit(1);
|
|
175
278
|
}
|
|
176
279
|
|
|
177
|
-
// --project flag
|
|
280
|
+
// --project flag fills in manifest's project_id when the manifest doesn't
|
|
281
|
+
// specify one. (When both are set they must already agree — enforced above.)
|
|
178
282
|
if (opts.project) manifest.project_id = opts.project;
|
|
179
283
|
|
|
180
|
-
// If no project_id in manifest,
|
|
284
|
+
// If no project_id in manifest, fall back to the active project.
|
|
285
|
+
// resolveProjectId() returns the active project id when its argument is
|
|
286
|
+
// falsy, and emits a clear error + exits non-zero when no active project
|
|
287
|
+
// is set either.
|
|
181
288
|
if (!manifest.project_id) {
|
|
182
|
-
|
|
183
|
-
manifest.project_id = id;
|
|
289
|
+
manifest.project_id = resolveProjectId(null);
|
|
184
290
|
}
|
|
185
291
|
|
|
186
292
|
// Remove legacy 'name' field if present
|
package/lib/init.mjs
CHANGED
|
@@ -12,6 +12,8 @@ const HELP = `run402 init — Set up allowance, funding, and check tier status
|
|
|
12
12
|
Usage:
|
|
13
13
|
run402 init Set up with x402 (Base Sepolia) — default
|
|
14
14
|
run402 init mpp Set up with MPP (Tempo Moderato)
|
|
15
|
+
run402 init --json Same as init, but emit a JSON summary on stdout
|
|
16
|
+
(human lines go to stderr — for agent automation)
|
|
15
17
|
|
|
16
18
|
Steps (idempotent — safe to re-run):
|
|
17
19
|
1. Creates config directory (~/.config/run402)
|
|
@@ -25,12 +27,28 @@ Run this once to get started, or again to check your setup.
|
|
|
25
27
|
`;
|
|
26
28
|
|
|
27
29
|
function short(addr) { return addr.slice(0, 6) + "..." + addr.slice(-4); }
|
|
28
|
-
function line(label, value) { console.log(` ${label.padEnd(10)} ${value}`); }
|
|
29
30
|
|
|
30
31
|
export async function run(args = []) {
|
|
31
32
|
if (args.includes("--help") || args.includes("-h")) { console.log(HELP); process.exit(0); }
|
|
33
|
+
const jsonMode = args.includes("--json");
|
|
32
34
|
const isMpp = args[0] === "mpp";
|
|
33
|
-
|
|
35
|
+
|
|
36
|
+
// In --json mode, human-readable lines go to stderr so stdout stays clean for
|
|
37
|
+
// agents. We also collect structured data for the final JSON emit.
|
|
38
|
+
const write = jsonMode ? (s) => console.error(s) : (s) => console.log(s);
|
|
39
|
+
const line = (label, value) => write(` ${label.padEnd(10)} ${value}`);
|
|
40
|
+
const summary = {
|
|
41
|
+
config_dir: CONFIG_DIR,
|
|
42
|
+
allowance: null,
|
|
43
|
+
rail: null,
|
|
44
|
+
network: null,
|
|
45
|
+
balance: null,
|
|
46
|
+
tier: null,
|
|
47
|
+
projects_saved: 0,
|
|
48
|
+
next_step: null,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
write("");
|
|
34
52
|
|
|
35
53
|
// 1. Config directory
|
|
36
54
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
@@ -58,6 +76,10 @@ export async function run(args = []) {
|
|
|
58
76
|
line("Allowance", short(allowance.address));
|
|
59
77
|
}
|
|
60
78
|
|
|
79
|
+
summary.allowance = { address: allowance.address, funded: allowance.funded || false };
|
|
80
|
+
summary.network = isMpp ? "tempo-moderato" : "base-sepolia";
|
|
81
|
+
summary.rail = isMpp ? "mpp" : "x402";
|
|
82
|
+
|
|
61
83
|
line("Network", isMpp ? "Tempo Moderato (testnet)" : "Base Sepolia (testnet)");
|
|
62
84
|
line("Rail", isMpp ? "mpp" : "x402");
|
|
63
85
|
|
|
@@ -110,6 +132,7 @@ export async function run(args = []) {
|
|
|
110
132
|
} else {
|
|
111
133
|
line("Balance", `${(balance / 1e6).toFixed(2)} pathUSD`);
|
|
112
134
|
}
|
|
135
|
+
summary.balance = { symbol: "pathUSD", usd_micros: balance };
|
|
113
136
|
} else {
|
|
114
137
|
// Base Sepolia: read USDC balance (existing behavior)
|
|
115
138
|
const { createPublicClient, http } = await import("viem");
|
|
@@ -152,6 +175,7 @@ export async function run(args = []) {
|
|
|
152
175
|
} else {
|
|
153
176
|
line("Balance", `${(balance / 1e6).toFixed(2)} USDC`);
|
|
154
177
|
}
|
|
178
|
+
summary.balance = { symbol: "USDC", usd_micros: balance };
|
|
155
179
|
}
|
|
156
180
|
|
|
157
181
|
// Show note if switching rails
|
|
@@ -176,19 +200,32 @@ export async function run(args = []) {
|
|
|
176
200
|
if (tierInfo && tierInfo.tier && tierInfo.active) {
|
|
177
201
|
const expiry = tierInfo.lease_expires_at ? tierInfo.lease_expires_at.split("T")[0] : "unknown";
|
|
178
202
|
line("Tier", `${tierInfo.tier} (expires ${expiry})`);
|
|
203
|
+
summary.tier = { name: tierInfo.tier, expires: tierInfo.lease_expires_at || null };
|
|
179
204
|
} else {
|
|
180
205
|
line("Tier", "(none)");
|
|
206
|
+
summary.tier = null;
|
|
181
207
|
}
|
|
182
208
|
|
|
183
|
-
// 5. Projects
|
|
184
|
-
|
|
209
|
+
// 5. Projects — count locally saved project entries. Note: "saved" (not
|
|
210
|
+
// "active") — these are all projects in the keystore, regardless of whether
|
|
211
|
+
// the server considers them active.
|
|
212
|
+
summary.projects_saved = Object.keys(store.projects).length;
|
|
213
|
+
line("Projects", `${summary.projects_saved} saved`);
|
|
185
214
|
|
|
186
215
|
// 6. Next step
|
|
187
|
-
|
|
216
|
+
write("");
|
|
217
|
+
const nextStep = (!tierInfo || !tierInfo.tier || !tierInfo.active)
|
|
218
|
+
? "run402 tier set prototype"
|
|
219
|
+
: "run402 deploy --manifest app.json";
|
|
188
220
|
if (!tierInfo || !tierInfo.tier || !tierInfo.active) {
|
|
189
|
-
|
|
221
|
+
write(" Next: run402 tier set prototype");
|
|
190
222
|
} else {
|
|
191
|
-
|
|
223
|
+
write(" Ready to deploy. Run: run402 deploy --manifest app.json");
|
|
224
|
+
}
|
|
225
|
+
write("");
|
|
226
|
+
summary.next_step = nextStep;
|
|
227
|
+
|
|
228
|
+
if (jsonMode) {
|
|
229
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
192
230
|
}
|
|
193
|
-
console.log();
|
|
194
231
|
}
|
package/lib/manifest.mjs
CHANGED
|
@@ -11,6 +11,12 @@ const TEXT_EXTS = new Set([
|
|
|
11
11
|
* read the SQL from that file path and set `migrations` to its contents.
|
|
12
12
|
* `migrations_file` is resolved relative to `baseDir`.
|
|
13
13
|
*
|
|
14
|
+
* On read failure, re-throws the underlying fs error with additional context
|
|
15
|
+
* attached:
|
|
16
|
+
* err.field = "migrations_file"
|
|
17
|
+
* err.absPath = <absolute path that was attempted>
|
|
18
|
+
* (the original Error.code / Error.message / Error.path are preserved).
|
|
19
|
+
*
|
|
14
20
|
* @param {object} manifest Parsed manifest JSON (mutated in place)
|
|
15
21
|
* @param {string} baseDir Directory to resolve relative paths from
|
|
16
22
|
* @returns {object} The same manifest object
|
|
@@ -18,7 +24,13 @@ const TEXT_EXTS = new Set([
|
|
|
18
24
|
export function resolveMigrationsFile(manifest, baseDir) {
|
|
19
25
|
if (!manifest.migrations_file) return manifest;
|
|
20
26
|
const abs = resolve(baseDir, manifest.migrations_file);
|
|
21
|
-
|
|
27
|
+
try {
|
|
28
|
+
manifest.migrations = readFileSync(abs, "utf-8");
|
|
29
|
+
} catch (err) {
|
|
30
|
+
err.field = "migrations_file";
|
|
31
|
+
err.absPath = abs;
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
22
34
|
delete manifest.migrations_file;
|
|
23
35
|
return manifest;
|
|
24
36
|
}
|
|
@@ -31,6 +43,12 @@ export function resolveMigrationsFile(manifest, baseDir) {
|
|
|
31
43
|
*
|
|
32
44
|
* Entries with `data` already set are left untouched.
|
|
33
45
|
*
|
|
46
|
+
* On read failure, re-throws the underlying fs error with additional context
|
|
47
|
+
* attached:
|
|
48
|
+
* err.field = `files[<i>].path`
|
|
49
|
+
* err.absPath = <absolute path that was attempted>
|
|
50
|
+
* (the original Error.code / Error.message / Error.path are preserved).
|
|
51
|
+
*
|
|
34
52
|
* @param {object} manifest Parsed manifest JSON (mutated in place)
|
|
35
53
|
* @param {string} baseDir Directory to resolve relative paths from
|
|
36
54
|
* @returns {object} The same manifest object
|
|
@@ -38,18 +56,25 @@ export function resolveMigrationsFile(manifest, baseDir) {
|
|
|
38
56
|
export function resolveFilePathsInManifest(manifest, baseDir) {
|
|
39
57
|
if (!Array.isArray(manifest.files)) return manifest;
|
|
40
58
|
|
|
41
|
-
for (
|
|
59
|
+
for (let i = 0; i < manifest.files.length; i++) {
|
|
60
|
+
const entry = manifest.files[i];
|
|
42
61
|
if (!entry.path || entry.data !== undefined) continue;
|
|
43
62
|
|
|
44
63
|
const abs = resolve(baseDir, entry.path);
|
|
45
64
|
const ext = extname(abs).toLowerCase();
|
|
46
65
|
const isText = TEXT_EXTS.has(ext);
|
|
47
66
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
67
|
+
try {
|
|
68
|
+
if (isText) {
|
|
69
|
+
entry.data = readFileSync(abs, "utf-8");
|
|
70
|
+
} else {
|
|
71
|
+
entry.data = readFileSync(abs).toString("base64");
|
|
72
|
+
entry.encoding = "base64";
|
|
73
|
+
}
|
|
74
|
+
} catch (err) {
|
|
75
|
+
err.field = `files[${i}].path`;
|
|
76
|
+
err.absPath = abs;
|
|
77
|
+
throw err;
|
|
53
78
|
}
|
|
54
79
|
|
|
55
80
|
// If no explicit file (deploy target name), use the path value
|
package/lib/projects.mjs
CHANGED
|
@@ -141,13 +141,17 @@ async function sqlCmd(projectId, args = []) {
|
|
|
141
141
|
const headers = { "Authorization": `Bearer ${p.service_key}`, "Content-Type": useParams ? "application/json" : "text/plain" };
|
|
142
142
|
const body = useParams ? JSON.stringify({ sql, params }) : sql;
|
|
143
143
|
const res = await fetch(`${API}/projects/v1/admin/${projectId}/sql`, { method: "POST", headers, body });
|
|
144
|
-
|
|
144
|
+
const data = await res.json();
|
|
145
|
+
if (!res.ok) { console.error(JSON.stringify({ status: "error", http: res.status, ...data })); process.exit(1); }
|
|
146
|
+
console.log(JSON.stringify(data, null, 2));
|
|
145
147
|
}
|
|
146
148
|
|
|
147
149
|
async function rest(projectId, table, queryParams) {
|
|
148
150
|
const p = findProject(projectId);
|
|
149
151
|
const res = await fetch(`${API}/rest/v1/${table}${queryParams ? '?' + queryParams : ''}`, { headers: { "apikey": p.anon_key } });
|
|
150
|
-
|
|
152
|
+
const data = await res.json();
|
|
153
|
+
if (!res.ok) { console.error(JSON.stringify({ status: "error", http: res.status, ...data })); process.exit(1); }
|
|
154
|
+
console.log(JSON.stringify(data, null, 2));
|
|
151
155
|
}
|
|
152
156
|
|
|
153
157
|
async function usage(projectId) {
|
package/lib/status.mjs
CHANGED
|
@@ -8,6 +8,7 @@ Usage:
|
|
|
8
8
|
|
|
9
9
|
Displays:
|
|
10
10
|
- Allowance address and funding status
|
|
11
|
+
- Wallet on-chain USDC/pathUSD balance (wallet_balance_usd_micros)
|
|
11
12
|
- Billing balance (available + held)
|
|
12
13
|
- Tier subscription (name, status, expiry)
|
|
13
14
|
- Projects (from server, with fallback to local keystore)
|
|
@@ -16,6 +17,63 @@ Displays:
|
|
|
16
17
|
Output is JSON. Requires an existing allowance (run 'run402 init' first).
|
|
17
18
|
`;
|
|
18
19
|
|
|
20
|
+
// USDC / pathUSD constants (match allowance.mjs)
|
|
21
|
+
const USDC_ABI = [{ name: "balanceOf", type: "function", stateMutability: "view", inputs: [{ name: "account", type: "address" }], outputs: [{ name: "", type: "uint256" }] }];
|
|
22
|
+
const USDC_MAINNET = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913";
|
|
23
|
+
const USDC_SEPOLIA = "0x036CbD53842c5426634e7929541eC2318f3dCF7e";
|
|
24
|
+
const PATH_USD = "0x20c0000000000000000000000000000000000000";
|
|
25
|
+
const TEMPO_RPC = "https://rpc.moderato.tempo.xyz/";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Read the on-chain wallet balance in USD micros for the current rail.
|
|
29
|
+
* For x402: read Base mainnet + Base Sepolia USDC and sum funded networks.
|
|
30
|
+
* For mpp: read pathUSD on Tempo Moderato.
|
|
31
|
+
* Returns null if every read fails (e.g. offline or RPC down).
|
|
32
|
+
*/
|
|
33
|
+
async function readWalletBalanceUsdMicros(rail, address) {
|
|
34
|
+
try {
|
|
35
|
+
const { createPublicClient, http, defineChain } = await import("viem");
|
|
36
|
+
if (rail === "mpp") {
|
|
37
|
+
const tempoModerato = defineChain({
|
|
38
|
+
id: 42431,
|
|
39
|
+
name: "Tempo Moderato",
|
|
40
|
+
nativeCurrency: { name: "pathUSD", symbol: "pathUSD", decimals: 6 },
|
|
41
|
+
rpcUrls: { default: { http: [TEMPO_RPC] } },
|
|
42
|
+
});
|
|
43
|
+
const client = createPublicClient({ chain: tempoModerato, transport: http() });
|
|
44
|
+
try {
|
|
45
|
+
const raw = await client.readContract({ address: PATH_USD, abi: USDC_ABI, functionName: "balanceOf", args: [address] });
|
|
46
|
+
return Number(raw);
|
|
47
|
+
} catch { return null; }
|
|
48
|
+
}
|
|
49
|
+
// x402 rail — read Base mainnet + Base Sepolia in parallel; sum any that succeed.
|
|
50
|
+
const { base, baseSepolia } = await import("viem/chains");
|
|
51
|
+
const mainnetClient = createPublicClient({ chain: base, transport: http() });
|
|
52
|
+
const sepoliaClient = createPublicClient({ chain: baseSepolia, transport: http() });
|
|
53
|
+
const [mainnet, sepolia] = await Promise.all([
|
|
54
|
+
mainnetClient.readContract({ address: USDC_MAINNET, abi: USDC_ABI, functionName: "balanceOf", args: [address] }).then(Number).catch(() => null),
|
|
55
|
+
sepoliaClient.readContract({ address: USDC_SEPOLIA, abi: USDC_ABI, functionName: "balanceOf", args: [address] }).then(Number).catch(() => null),
|
|
56
|
+
]);
|
|
57
|
+
if (mainnet === null && sepolia === null) return null;
|
|
58
|
+
return (mainnet || 0) + (sepolia || 0);
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Normalize a project entry to the agreed-on shape: always expose `project_id`
|
|
66
|
+
* (matching `projects list`). The remote /wallets/v1/:wallet/projects endpoint
|
|
67
|
+
* returns entries keyed as `id`, so we map them here and drop the raw `id`
|
|
68
|
+
* field to avoid having two aliases for the same identity.
|
|
69
|
+
*/
|
|
70
|
+
function normalizeProject(raw) {
|
|
71
|
+
if (!raw || typeof raw !== "object") return raw;
|
|
72
|
+
const projectId = raw.project_id || raw.id;
|
|
73
|
+
const { id: _dropId, project_id: _dropPid, ...rest } = raw;
|
|
74
|
+
return { project_id: projectId, ...rest };
|
|
75
|
+
}
|
|
76
|
+
|
|
19
77
|
export async function run(args = []) {
|
|
20
78
|
if (args.includes("--help") || args.includes("-h")) { console.log(HELP); process.exit(0); }
|
|
21
79
|
const allowance = readAllowance();
|
|
@@ -26,14 +84,16 @@ export async function run(args = []) {
|
|
|
26
84
|
|
|
27
85
|
const wallet = allowance.address.toLowerCase();
|
|
28
86
|
const authHeaders = getAllowanceAuthHeaders("/tiers/v1/status");
|
|
87
|
+
const rail = allowance.rail || "x402";
|
|
29
88
|
|
|
30
|
-
// Parallel API calls: tier + billing balance + server-side projects
|
|
31
|
-
const [tierRes, balanceRes, projectsRes] = await Promise.all([
|
|
89
|
+
// Parallel API calls: tier + billing balance + server-side projects + on-chain wallet balance
|
|
90
|
+
const [tierRes, balanceRes, projectsRes, walletBalance] = await Promise.all([
|
|
32
91
|
authHeaders
|
|
33
92
|
? fetch(`${API}/tiers/v1/status`, { headers: { ...authHeaders } }).catch(() => null)
|
|
34
93
|
: null,
|
|
35
94
|
fetch(`${API}/billing/v1/accounts/${wallet}`).catch(() => null),
|
|
36
95
|
fetch(`${API}/wallets/v1/${wallet}/projects`).catch(() => null),
|
|
96
|
+
readWalletBalanceUsdMicros(rail, allowance.address),
|
|
37
97
|
]);
|
|
38
98
|
|
|
39
99
|
const tier = tierRes?.ok ? await tierRes.json() : null;
|
|
@@ -44,18 +104,29 @@ export async function run(args = []) {
|
|
|
44
104
|
const store = loadKeyStore();
|
|
45
105
|
const activeId = getActiveProjectId();
|
|
46
106
|
|
|
107
|
+
const projects = remote?.projects
|
|
108
|
+
? remote.projects.map(normalizeProject)
|
|
109
|
+
: Object.keys(store.projects).map(id => ({ project_id: id }));
|
|
110
|
+
|
|
47
111
|
const result = {
|
|
48
112
|
allowance: {
|
|
49
113
|
address: allowance.address,
|
|
50
114
|
funded: allowance.funded || false,
|
|
51
115
|
},
|
|
116
|
+
rail,
|
|
52
117
|
tier: tier && tier.tier
|
|
53
118
|
? { name: tier.tier, status: tier.status, expires: tier.lease_expires_at }
|
|
54
119
|
: null,
|
|
55
|
-
|
|
120
|
+
// GH-32: `balance` used to mean the billing-account balance, which
|
|
121
|
+
// confused people who expected their on-chain wallet balance. Split into
|
|
122
|
+
// two unambiguous fields:
|
|
123
|
+
// - billing: credits held by Run402 (available + held), null if no account
|
|
124
|
+
// - wallet_balance_usd_micros: on-chain USDC/pathUSD, null if RPC fails
|
|
125
|
+
billing: billing && billing.exists
|
|
56
126
|
? { available_usd_micros: billing.available_usd_micros, held_usd_micros: billing.held_usd_micros }
|
|
57
127
|
: null,
|
|
58
|
-
|
|
128
|
+
wallet_balance_usd_micros: walletBalance,
|
|
129
|
+
projects,
|
|
59
130
|
active_project: activeId || null,
|
|
60
131
|
};
|
|
61
132
|
|
package/package.json
CHANGED