@retasc/cli 1.3.2 → 1.5.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/dist/api.js +47 -0
- package/dist/commands/billing.js +121 -0
- package/dist/index.js +19 -3
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -19,6 +19,12 @@ const fns = {
|
|
|
19
19
|
acceptInvite: makeFunctionReference("manage:acceptInvite"),
|
|
20
20
|
listInvites: makeFunctionReference("manage:listInvites"),
|
|
21
21
|
revokeInvite: makeFunctionReference("manage:revokeInvite"),
|
|
22
|
+
// Billing (RTSC-279). The CLI is a HUMAN surface (device-login JWT), so these are the
|
|
23
|
+
// very functions the Dash uses — already owner-gated, already org-scoped across every
|
|
24
|
+
// payment link. No key-based path needed here (that's the `billing_summary` MCP tool).
|
|
25
|
+
billingStatus: makeFunctionReference("manage:billingStatus"),
|
|
26
|
+
chargeHistory: makeFunctionReference("manage:chargeHistory"),
|
|
27
|
+
orgPayments: makeFunctionReference("manage:orgPayments"),
|
|
22
28
|
};
|
|
23
29
|
function client() {
|
|
24
30
|
const cfg = loadConfig();
|
|
@@ -27,11 +33,49 @@ function client() {
|
|
|
27
33
|
c.setAuth(cfg.token);
|
|
28
34
|
return c;
|
|
29
35
|
}
|
|
36
|
+
/**
|
|
37
|
+
* Read a caught backend error into the parts a command wants to show (RTSC-261).
|
|
38
|
+
*
|
|
39
|
+
* `code` is machine-readable, so a command branches on `code === "EXPIRED"`
|
|
40
|
+
* instead of regex-matching prose that any copy edit would break. `hint` is the
|
|
41
|
+
* next step, kept separate so callers can dim or indent it.
|
|
42
|
+
*
|
|
43
|
+
* Both are optional because the sweep is incremental: a backend site still
|
|
44
|
+
* throwing a plain Error (or an OLD deployment this CLI is pointed at) yields
|
|
45
|
+
* `{message}` alone via the same first-line/strip-prefix cleanup used before.
|
|
46
|
+
* Callers must therefore keep any existing string fallback rather than assuming
|
|
47
|
+
* `code` is present.
|
|
48
|
+
*/
|
|
49
|
+
export function formatError(e) {
|
|
50
|
+
const data = e?.data;
|
|
51
|
+
if (data && typeof data === "object" && typeof data.message === "string") {
|
|
52
|
+
// Type-guard EVERY field, not just `message`: a foreign error carrying
|
|
53
|
+
// `{message: "x", code: {…}}` would otherwise print `✗ [object Object]: x`.
|
|
54
|
+
// And strip control characters — these strings go to `console.error`, so a
|
|
55
|
+
// future converted site that interpolates user data (issue ids, org names)
|
|
56
|
+
// must not be able to smuggle ANSI escapes into the terminal. Mirrors
|
|
57
|
+
// `sanitize` in convex/lib/userError.ts; the two are one wire contract.
|
|
58
|
+
const clean = (v) =>
|
|
59
|
+
// eslint-disable-next-line no-control-regex
|
|
60
|
+
typeof v === "string" ? v.replace(/[\x00-\x1f\x7f]/g, " ").trim() : undefined;
|
|
61
|
+
return { code: clean(data.code), message: clean(data.message), hint: clean(data.hint) };
|
|
62
|
+
}
|
|
63
|
+
const message = String(e?.message ?? e)
|
|
64
|
+
.split("\n")[0]
|
|
65
|
+
.replace(/^.*?Uncaught Error:\s*/, "")
|
|
66
|
+
.trim();
|
|
67
|
+
return { message };
|
|
68
|
+
}
|
|
30
69
|
// Does this error mean "the access token is missing/expired", i.e. a refresh
|
|
31
70
|
// might fix it? The access-token JWT lives ~1h, so any long-lived login trips
|
|
32
71
|
// this. Two shapes surface: our server functions throw `UNAUTHENTICATED …`
|
|
33
72
|
// (requireUser), and the Convex platform rejects a stale JWT with "Could not
|
|
34
73
|
// verify OIDC token"/"Unauthenticated". Match either, case-insensitively.
|
|
74
|
+
// RTSC-261 deliberately does NOT touch this. It gates the refresh-and-retry
|
|
75
|
+
// loop, no site converted here throws an auth code, and `formatError` truncates
|
|
76
|
+
// to the first line — so routing it through there would add real risk (a missed
|
|
77
|
+
// match means a spurious device-flow re-login) for no gain today. It gets a
|
|
78
|
+
// `code === "UNAUTHENTICATED"` fast path in the PR that converts lib/auth.ts.
|
|
35
79
|
export function isAuthError(e) {
|
|
36
80
|
const msg = String(e?.message ?? e);
|
|
37
81
|
return /unauthenticated|could not verify oidc|oidc token/i.test(msg);
|
|
@@ -92,4 +136,7 @@ export const api = {
|
|
|
92
136
|
acceptInvite: (args) => withAuth(() => client().mutation(fns.acceptInvite, args)),
|
|
93
137
|
listInvites: (args) => withAuth(() => client().query(fns.listInvites, args)),
|
|
94
138
|
revokeInvite: (args) => withAuth(() => client().mutation(fns.revokeInvite, args)),
|
|
139
|
+
billingStatus: (args) => withAuth(() => client().query(fns.billingStatus, args)),
|
|
140
|
+
chargeHistory: (args) => withAuth(() => client().query(fns.chargeHistory, args)),
|
|
141
|
+
orgPayments: (args) => withAuth(() => client().action(fns.orgPayments, args)),
|
|
95
142
|
};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { api } from "../api.js";
|
|
2
|
+
// RTSC-279 — `retasc billing`: the whole billing picture in the terminal, so you don't
|
|
3
|
+
// have to open the Dash to answer "what do I owe / what have I paid".
|
|
4
|
+
//
|
|
5
|
+
// The CLI is a HUMAN surface (device-login JWT), so this reuses the very functions the
|
|
6
|
+
// Dash renders — already owner-gated, and already ORG-scoped across every payment link
|
|
7
|
+
// the org has ridden (charges: RTSC-254, payments: RTSC-277). A link change therefore
|
|
8
|
+
// never hides prior history here either.
|
|
9
|
+
//
|
|
10
|
+
// Payments are a LIVE Xenarch read fanned per link, so they can be slow or unavailable;
|
|
11
|
+
// they're fetched last and degrade to a note rather than failing the whole command.
|
|
12
|
+
/** Metered billing is micro-USDC, so `toFixed(2)` hides real sub-cent amounts. Show up
|
|
13
|
+
* to 6 decimals, trim trailing zeros, keep a 2-decimal floor so money still reads right. */
|
|
14
|
+
function usd(n) {
|
|
15
|
+
if (n == null)
|
|
16
|
+
return "—";
|
|
17
|
+
const trimmed = n.toFixed(6).replace(/0+$/, "").replace(/\.$/, "");
|
|
18
|
+
const dot = trimmed.indexOf(".");
|
|
19
|
+
const decimals = dot === -1 ? 0 : trimmed.length - dot - 1;
|
|
20
|
+
return `$${decimals >= 2 ? trimmed : n.toFixed(2)}`;
|
|
21
|
+
}
|
|
22
|
+
function date(ms) {
|
|
23
|
+
return ms == null ? "—" : new Date(ms).toISOString().slice(0, 10);
|
|
24
|
+
}
|
|
25
|
+
function isoDate(s) {
|
|
26
|
+
return s ? s.slice(0, 10) : "—";
|
|
27
|
+
}
|
|
28
|
+
const row = (label, value) => console.log(` ${label.padEnd(22)}${value}`);
|
|
29
|
+
export async function billingAction(opts) {
|
|
30
|
+
// Resolve the org: explicit --org-id wins; otherwise use the only one you're in. With
|
|
31
|
+
// several, ask rather than guess — printing the wrong org's money is worse than an error.
|
|
32
|
+
let orgId = opts.orgId;
|
|
33
|
+
let orgLabel = "";
|
|
34
|
+
const me = await api.me();
|
|
35
|
+
if (!orgId) {
|
|
36
|
+
if (me.orgs.length === 0)
|
|
37
|
+
throw new Error("You're not a member of any org yet.");
|
|
38
|
+
if (me.orgs.length > 1) {
|
|
39
|
+
const list = me.orgs.map((o) => ` ${o.id} ${o.name}${o.slug ? ` (${o.slug})` : ""}`);
|
|
40
|
+
throw new Error(`Several orgs — pass --org-id <id>:\n${list.join("\n")}`);
|
|
41
|
+
}
|
|
42
|
+
orgId = me.orgs[0].id;
|
|
43
|
+
}
|
|
44
|
+
const org = me.orgs.find((o) => o.id === orgId);
|
|
45
|
+
orgLabel = org ? `${org.name}${org.slug ? ` (${org.slug})` : ""}` : String(orgId);
|
|
46
|
+
// Fail early with the actionable message rather than a raw FORBIDDEN from the server.
|
|
47
|
+
if (org && org.role !== "owner") {
|
|
48
|
+
throw new Error(`Billing is owner-only — your role in ${orgLabel} is "${org.role}".`);
|
|
49
|
+
}
|
|
50
|
+
const [status, charges] = await Promise.all([
|
|
51
|
+
api.billingStatus({ orgId: orgId }),
|
|
52
|
+
api.chargeHistory({ orgId: orgId }),
|
|
53
|
+
]);
|
|
54
|
+
// Live Xenarch fan-out — slowest and most failure-prone, so it's last and non-fatal.
|
|
55
|
+
let payments = null;
|
|
56
|
+
let paymentsError = null;
|
|
57
|
+
try {
|
|
58
|
+
payments = await api.orgPayments({ orgId: orgId });
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
paymentsError = String(e?.message ?? e);
|
|
62
|
+
}
|
|
63
|
+
if (opts.json) {
|
|
64
|
+
console.log(JSON.stringify({ org: orgLabel, status, charges, payments, paymentsError }, null, 2));
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const s = status;
|
|
68
|
+
const c = charges;
|
|
69
|
+
console.log(`\nBilling — ${orgLabel}\n`);
|
|
70
|
+
console.log("SUBSCRIPTION");
|
|
71
|
+
row("Status", `${s.status}${s.gated ? " (gated)" : ""}`);
|
|
72
|
+
row("Subscription", s.subscriptionId ?? "none yet");
|
|
73
|
+
if (s.spendingCapUsd != null) {
|
|
74
|
+
row("Budget", `${usd(s.spendingCapUsd)}${s.capPeriod ? ` / ${s.capPeriod.replace("per_", "")}` : ""}`);
|
|
75
|
+
}
|
|
76
|
+
if (s.collectionMessage)
|
|
77
|
+
row("⚠ Collection", s.collectionMessage);
|
|
78
|
+
console.log("\nMONEY");
|
|
79
|
+
row("Pending (owed now)", usd(s.accruedUsd));
|
|
80
|
+
row("Outstanding", usd(c.outstandingUsd));
|
|
81
|
+
row("Charged (lifetime)", usd(c.chargedUsd));
|
|
82
|
+
row("Collected (lifetime)", usd(c.collectedUsd));
|
|
83
|
+
if (s.creditsUsd > 0)
|
|
84
|
+
row("Credit remaining", usd(s.creditsUsd));
|
|
85
|
+
// Charges + payments span EVERY link; label the eras so a link change is visible
|
|
86
|
+
// rather than looking like history that vanished.
|
|
87
|
+
const eras = c.eras ?? [];
|
|
88
|
+
console.log(`\nCHARGES (${c.charges.length}${c.capped ? "+, capped" : ""})`);
|
|
89
|
+
if (c.charges.length === 0) {
|
|
90
|
+
console.log(" none yet");
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
if (eras.length > 1)
|
|
94
|
+
console.log(` across ${eras.length} payment links`);
|
|
95
|
+
for (const ch of c.charges.slice(0, 10)) {
|
|
96
|
+
const era = eras.length > 1 ? ` [${ch.subscriptionId.slice(0, 8)}…]` : "";
|
|
97
|
+
console.log(` #${String(ch.chargeSeq).padEnd(4)} ${date(ch.bookedAt)} ${ch.status.padEnd(8)} ${usd(ch.amountUsd)}${era}`);
|
|
98
|
+
}
|
|
99
|
+
if (c.charges.length > 10)
|
|
100
|
+
console.log(` … ${c.charges.length - 10} more`);
|
|
101
|
+
}
|
|
102
|
+
console.log("\nPAYMENTS (confirmed on-chain)");
|
|
103
|
+
if (paymentsError) {
|
|
104
|
+
console.log(` unavailable — ${paymentsError}`);
|
|
105
|
+
}
|
|
106
|
+
else if (!payments || payments.payments.length === 0) {
|
|
107
|
+
console.log(" none yet");
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
const ps = payments.payments;
|
|
111
|
+
const links = new Set(ps.map((p) => p.subscriptionId)).size;
|
|
112
|
+
if (links > 1)
|
|
113
|
+
console.log(` across ${links} payment links`);
|
|
114
|
+
for (const p of ps) {
|
|
115
|
+
const era = links > 1 ? ` [${p.subscriptionId.slice(0, 8)}…]` : "";
|
|
116
|
+
const block = p.blockNumber != null ? `block ${p.blockNumber}` : "";
|
|
117
|
+
console.log(` ${isoDate(p.createdAt)} ${usd(p.amountUsd).padEnd(10)} ${block}${era}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
console.log("");
|
|
121
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -9,11 +9,12 @@ import { installGate } from "./commands/gate.js";
|
|
|
9
9
|
import { claimAction } from "./commands/claim.js";
|
|
10
10
|
import { bindAction } from "./commands/bind.js";
|
|
11
11
|
import { doctorAction } from "./commands/doctor.js";
|
|
12
|
+
import { billingAction } from "./commands/billing.js";
|
|
12
13
|
import { readLocalBinding, resolveBinding } from "./lib/binding.js";
|
|
13
14
|
import { tidyAction, doneAction } from "./commands/tidy.js";
|
|
14
15
|
import { runProxy } from "./proxy.js";
|
|
15
16
|
import { deviceLogin } from "./auth.js";
|
|
16
|
-
import { api } from "./api.js";
|
|
17
|
+
import { api, formatError } from "./api.js";
|
|
17
18
|
// Single source of truth for the version: read package.json at runtime from the
|
|
18
19
|
// compiled file's location (dist/index.js -> ../package.json). A JSON import won't
|
|
19
20
|
// work here — tsconfig has rootDir "src", so importing ../package.json is outside
|
|
@@ -30,9 +31,15 @@ function requireLogin() {
|
|
|
30
31
|
process.exit(1);
|
|
31
32
|
}
|
|
32
33
|
}
|
|
34
|
+
// RTSC-261: prefer the structured payload, and print `hint` on its own dimmed
|
|
35
|
+
// line — the whole reason it's a separate field is that the fix shouldn't be
|
|
36
|
+
// buried in the middle of the diagnosis. `formatError` falls back to the old
|
|
37
|
+
// string cleanup, so unconverted backend sites print exactly as they did.
|
|
33
38
|
function fail(e) {
|
|
34
|
-
const
|
|
35
|
-
console.error(`✗ ${
|
|
39
|
+
const { code, message, hint } = formatError(e);
|
|
40
|
+
console.error(`✗ ${code ? `${code}: ` : ""}${message}`);
|
|
41
|
+
if (hint)
|
|
42
|
+
console.error(` → ${hint}`);
|
|
36
43
|
process.exit(1);
|
|
37
44
|
}
|
|
38
45
|
// --- auth ------------------------------------------------------------------
|
|
@@ -165,6 +172,15 @@ program
|
|
|
165
172
|
.action(async () => {
|
|
166
173
|
await doctorAction().catch(fail);
|
|
167
174
|
});
|
|
175
|
+
program
|
|
176
|
+
.command("billing")
|
|
177
|
+
.description("Show the org's billing: subscription, what's owed now, and the charge + on-chain payment history across every payment link ever used (owner only).")
|
|
178
|
+
.option("--org-id <id>", "Which org (defaults to your only one).")
|
|
179
|
+
.option("--json", "Emit the raw payload instead of the summary.")
|
|
180
|
+
.action(async (opts) => {
|
|
181
|
+
requireLogin();
|
|
182
|
+
await billingAction({ orgId: opts.orgId, json: opts.json }).catch(fail);
|
|
183
|
+
});
|
|
168
184
|
// --- org / project ---------------------------------------------------------
|
|
169
185
|
const org = program.command("org").description("Manage orgs.");
|
|
170
186
|
org
|
package/package.json
CHANGED