@retasc/cli 1.3.1 → 1.4.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 +9 -0
- package/dist/commands/billing.js +121 -0
- package/dist/config.js +149 -4
- package/dist/index.js +10 -0
- 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();
|
|
@@ -92,4 +98,7 @@ export const api = {
|
|
|
92
98
|
acceptInvite: (args) => withAuth(() => client().mutation(fns.acceptInvite, args)),
|
|
93
99
|
listInvites: (args) => withAuth(() => client().query(fns.listInvites, args)),
|
|
94
100
|
revokeInvite: (args) => withAuth(() => client().mutation(fns.revokeInvite, args)),
|
|
101
|
+
billingStatus: (args) => withAuth(() => client().query(fns.billingStatus, args)),
|
|
102
|
+
chargeHistory: (args) => withAuth(() => client().query(fns.chargeHistory, args)),
|
|
103
|
+
orgPayments: (args) => withAuth(() => client().action(fns.orgPayments, args)),
|
|
95
104
|
};
|
|
@@ -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.billingExempt ? "comp — not billed" : `${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/config.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, existsSync, chmodSync, } from "node:fs";
|
|
3
|
+
import { mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, existsSync, chmodSync, openSync, closeSync, writeSync, statSync, readdirSync, } from "node:fs";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
// Production defaults. Overridable via env for dev/testing.
|
|
6
6
|
// RETASC_DEPLOYMENT_URL — Convex deployment (.cloud) for management calls
|
|
@@ -101,11 +101,156 @@ export function saveConfig(cfg) {
|
|
|
101
101
|
}
|
|
102
102
|
throw err;
|
|
103
103
|
}
|
|
104
|
+
// Best-effort GC of temp siblings orphaned by a hard kill (SIGKILL / power loss)
|
|
105
|
+
// between writeFileSync(tmp) and renameSync above — otherwise they accumulate in
|
|
106
|
+
// ~/.retasc forever across crashes. Runs after our own rename so our temp is
|
|
107
|
+
// already gone. Age-gated (see sweepStaleTemps) so it never touches a concurrent
|
|
108
|
+
// writer's in-flight temp.
|
|
109
|
+
sweepStaleTemps(dir);
|
|
110
|
+
}
|
|
111
|
+
/** A temp sibling this much older than now was orphaned by a crash, not left by a
|
|
112
|
+
* live writer — any real writeFileSync→renameSync window is milliseconds. */
|
|
113
|
+
const TEMP_STALE_MS = 60_000;
|
|
114
|
+
/** Delete `.config.json.<uuid>.tmp` leftovers only once they're demonstrably stale,
|
|
115
|
+
* so an unconditional sweep can't race-delete a concurrent writer's fresh temp
|
|
116
|
+
* (which would reintroduce the very lost-update class this file guards against). */
|
|
117
|
+
function sweepStaleTemps(dir) {
|
|
118
|
+
let entries;
|
|
119
|
+
try {
|
|
120
|
+
entries = readdirSync(dir);
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return; // dir vanished / unreadable — nothing to sweep
|
|
124
|
+
}
|
|
125
|
+
const now = Date.now();
|
|
126
|
+
for (const name of entries) {
|
|
127
|
+
if (!name.startsWith(".config.json.") || !name.endsWith(".tmp"))
|
|
128
|
+
continue;
|
|
129
|
+
const p = join(dir, name);
|
|
130
|
+
try {
|
|
131
|
+
if (now - statSync(p).mtimeMs > TEMP_STALE_MS)
|
|
132
|
+
unlinkSync(p);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
/* raced another sweeper or a writer's own rename — fine, leave it */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// --- Cross-process config lock -------------------------------------------------
|
|
140
|
+
// saveConfig is byte-atomic (temp + rename) but that is NOT isolation: two
|
|
141
|
+
// concurrent load→modify→save sequences both read the old file and the last
|
|
142
|
+
// rename wins, silently dropping the other's fields (RTSC-250). The dangerous
|
|
143
|
+
// case: a `defaultProjectPrefix` write that began before a token refresh writes
|
|
144
|
+
// back the OLD single-use refreshToken, so the next refresh fails and a
|
|
145
|
+
// non-interactive MCP context can't re-run the device flow. An advisory lock file
|
|
146
|
+
// serializes the whole read-modify-write across processes, so every patcher
|
|
147
|
+
// re-reads the freshest config (including a just-rotated token) before writing.
|
|
148
|
+
//
|
|
149
|
+
// Assumes a coherent LOCAL clock: the staleness heuristic compares a lock file's
|
|
150
|
+
// mtime (set by the host that created it) against this host's Date.now(). That
|
|
151
|
+
// holds for the intended case — several processes on ONE machine sharing one
|
|
152
|
+
// ~/.retasc. On a network home with a skewed server clock it degrades to
|
|
153
|
+
// best-effort (over-eager or over-lazy stealing); we don't target that here.
|
|
154
|
+
// Any whole-file mutating writer MUST go through patchConfig so it takes the
|
|
155
|
+
// lock — saveConfig alone is not self-locking (logout's delete is the one benign
|
|
156
|
+
// exception: racing a refresh there is a user-intent race, not a durability bug).
|
|
157
|
+
/** A lock whose mtime is older than this belongs to a holder that died without
|
|
158
|
+
* releasing it (a crash leaves the O_EXCL file behind); break it so a crash
|
|
159
|
+
* can't wedge every future patch forever. Far larger than any real hold, which
|
|
160
|
+
* is a synchronous read+write of a tiny file (single-digit ms). */
|
|
161
|
+
const LOCK_STALE_MS = 30_000;
|
|
162
|
+
/** Absolute backstop: if staleness somehow never frees the lock, force it after
|
|
163
|
+
* this. Kept ABOVE the stale threshold so normal breaking is governed by
|
|
164
|
+
* staleness, not by a timer that could guillotine a merely-slow live holder. */
|
|
165
|
+
const LOCK_TIMEOUT_MS = 60_000;
|
|
166
|
+
const LOCK_BACKOFF_MS = 25;
|
|
167
|
+
/** Block this (single) thread for `ms` without busy-spinning. Node is
|
|
168
|
+
* single-threaded; the lock we wait on is released by ANOTHER process, so
|
|
169
|
+
* parking the thread is correct — nothing here could release it. */
|
|
170
|
+
function sleepSync(ms) {
|
|
171
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
172
|
+
}
|
|
173
|
+
/** The current holder's identity + age, or undefined if the lock just vanished. */
|
|
174
|
+
function readLockHolder(lockPath) {
|
|
175
|
+
try {
|
|
176
|
+
const nonce = readFileSync(lockPath, "utf8");
|
|
177
|
+
return { nonce, ageMs: Date.now() - statSync(lockPath).mtimeMs };
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return undefined; // gone between our failed create and this read → just retry
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/** Run `fn` holding an exclusive on-disk lock, so its read-modify-write of the
|
|
184
|
+
* config can't interleave with another process's. Each acquirer stamps a unique
|
|
185
|
+
* nonce into the lock file; steal and release only ever remove a lock whose nonce
|
|
186
|
+
* still matches the one we observed, so a holder that was judged stale and had its
|
|
187
|
+
* lock stolen can't later delete the DIFFERENT holder's lock (which would collapse
|
|
188
|
+
* mutual exclusion back into the lost-update this whole mechanism prevents). */
|
|
189
|
+
function withConfigLock(fn) {
|
|
190
|
+
const dir = configDir();
|
|
191
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
192
|
+
const lockPath = join(dir, ".config.json.lock");
|
|
193
|
+
const nonce = randomUUID();
|
|
194
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
195
|
+
let fd;
|
|
196
|
+
for (;;) {
|
|
197
|
+
try {
|
|
198
|
+
// "wx" = O_CREAT | O_EXCL | O_WRONLY — atomically fails if the lock is held.
|
|
199
|
+
fd = openSync(lockPath, "wx", 0o600);
|
|
200
|
+
writeSync(fd, nonce); // stamp our identity so steal/release can verify it
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
if (err.code !== "EEXIST")
|
|
205
|
+
throw err;
|
|
206
|
+
const holder = readLockHolder(lockPath);
|
|
207
|
+
if (!holder)
|
|
208
|
+
continue; // vanished → retry the create immediately
|
|
209
|
+
if (holder.ageMs > LOCK_STALE_MS || Date.now() > deadline) {
|
|
210
|
+
// Dead holder (stale) or backstop expired: break THIS holder's lock only.
|
|
211
|
+
// If a fresh acquirer replaced it since our read, the nonce differs and we
|
|
212
|
+
// leave it — never steal a live lock out from under a new owner.
|
|
213
|
+
breakLockIf(lockPath, holder.nonce);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
sleepSync(LOCK_BACKOFF_MS);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
return fn();
|
|
221
|
+
}
|
|
222
|
+
finally {
|
|
223
|
+
try {
|
|
224
|
+
closeSync(fd);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
/* already closed */
|
|
228
|
+
}
|
|
229
|
+
// Only remove the lock if it's still OURS. If we were stolen from while stalled,
|
|
230
|
+
// the file now holds another holder's nonce — leave it for them.
|
|
231
|
+
breakLockIf(lockPath, nonce);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/** Unlink the lock only if it still carries `nonce` — a nonce-checked delete, so
|
|
235
|
+
* we never remove a lock a different process now owns. */
|
|
236
|
+
function breakLockIf(lockPath, nonce) {
|
|
237
|
+
try {
|
|
238
|
+
if (readFileSync(lockPath, "utf8") === nonce)
|
|
239
|
+
unlinkSync(lockPath);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
/* already gone, replaced, or unreadable — nothing safe to do */
|
|
243
|
+
}
|
|
104
244
|
}
|
|
105
245
|
export function patchConfig(patch) {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
246
|
+
// Load AND save under the lock: re-reading inside the critical section is what
|
|
247
|
+
// makes this safe — a patch that only sets `defaultProjectPrefix` still picks up
|
|
248
|
+
// a refreshToken another process rotated a moment ago, instead of clobbering it.
|
|
249
|
+
return withConfigLock(() => {
|
|
250
|
+
const next = { ...loadConfig(), ...patch };
|
|
251
|
+
saveConfig(next);
|
|
252
|
+
return next;
|
|
253
|
+
});
|
|
109
254
|
}
|
|
110
255
|
export function isLoggedIn(cfg = loadConfig()) {
|
|
111
256
|
return Boolean(cfg.token);
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,7 @@ 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";
|
|
@@ -165,6 +166,15 @@ program
|
|
|
165
166
|
.action(async () => {
|
|
166
167
|
await doctorAction().catch(fail);
|
|
167
168
|
});
|
|
169
|
+
program
|
|
170
|
+
.command("billing")
|
|
171
|
+
.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).")
|
|
172
|
+
.option("--org-id <id>", "Which org (defaults to your only one).")
|
|
173
|
+
.option("--json", "Emit the raw payload instead of the summary.")
|
|
174
|
+
.action(async (opts) => {
|
|
175
|
+
requireLogin();
|
|
176
|
+
await billingAction({ orgId: opts.orgId, json: opts.json }).catch(fail);
|
|
177
|
+
});
|
|
168
178
|
// --- org / project ---------------------------------------------------------
|
|
169
179
|
const org = program.command("org").description("Manage orgs.");
|
|
170
180
|
org
|
package/package.json
CHANGED