@letterstory/cli 0.145.2 → 0.146.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/lib/cli.mjs +10 -1
- package/lib/commands/admin.mjs +23 -1
- package/lib/commands/auth.mjs +2 -2
- package/lib/commands/org.mjs +56 -3
- package/lib/oauth.mjs +65 -0
- package/package.json +1 -1
package/lib/cli.mjs
CHANGED
|
@@ -64,13 +64,16 @@ import {
|
|
|
64
64
|
} from "./commands.mjs";
|
|
65
65
|
|
|
66
66
|
// Matches cli/package.json; publish-cli.yml rewrites both to the published version.
|
|
67
|
-
|
|
67
|
+
// Matches cli/package.json; publish-cli.yml rewrites both to the published version.
|
|
68
|
+
export const VERSION = "0.146.0";
|
|
68
69
|
|
|
69
70
|
// Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
|
|
70
71
|
// can't accidentally swallow the id as --json's value. Every `type: "bool"` flag (and its
|
|
71
72
|
// `negate` spelling) in a command's ARGS map must be listed; boolean-flags.test.ts enforces it.
|
|
72
73
|
export const BOOLEAN_FLAGS = new Set([
|
|
73
74
|
"remap",
|
|
75
|
+
// login: one-link device sign-in instead of the loopback browser flow.
|
|
76
|
+
"device",
|
|
74
77
|
"json",
|
|
75
78
|
// sim seed: make the new seed active; collections it doesn't cover start empty.
|
|
76
79
|
"activate",
|
|
@@ -366,6 +369,7 @@ Usage:
|
|
|
366
369
|
|
|
367
370
|
Auth:
|
|
368
371
|
login [--url <url>] Sign in via your browser (default url: https://app.letterstory.com)
|
|
372
|
+
login --device [--url <url>] Sign in with one link, opened in any browser (for agents and remote shells)
|
|
369
373
|
login --key <ls_…> [--url <url>] Save a static API key instead (for CI/automation)
|
|
370
374
|
logout Forget saved credentials (revokes an OAuth session, if any)
|
|
371
375
|
config Show the resolved url + credential source
|
|
@@ -932,6 +936,7 @@ Admin (STAFF key — --staff-key / LETTERSTORY_STAFF_API_KEY; acts before an org
|
|
|
932
936
|
| reconcile [--org <org-id>] | forward
|
|
933
937
|
| payments [--org <org-id>] [--open] | payments retry <lago-invoice-id> | payments run [--dry]
|
|
934
938
|
| signups [--status open|card_saved|provisioned|paid|abandoned]
|
|
939
|
+
| cap <org-id> <dollars|off> [--why "…"]
|
|
935
940
|
Usage billing on Lago: the plan catalog, an org's
|
|
936
941
|
plan and gifted ratio, Lago's tokens against ours,
|
|
937
942
|
forwarding usage now, failed payments and the
|
|
@@ -1089,6 +1094,10 @@ Seers (event-driven signals -> drafts):
|
|
|
1089
1094
|
org export --split <dir> …as one file per record (for keeping config in git)
|
|
1090
1095
|
org join <dir> [--file <path>] Rebuild the bundle from a --split directory
|
|
1091
1096
|
org integrations Every integration and whether it's connected
|
|
1097
|
+
org billing [cap <dollars|off> [--why "…"] | request [--note "…"]]
|
|
1098
|
+
Usage billing: plan, this period against the
|
|
1099
|
+
allowance and spend cap, failed payments; set
|
|
1100
|
+
the cap (admins) or ask the admins for more
|
|
1092
1101
|
org linear [teams [--team <id|key>] | set --team <id|key> [--project <id>|none] | disconnect]
|
|
1093
1102
|
Where Letter files Linear issues (connect in Settings)
|
|
1094
1103
|
org diff <file|dir> What applying a bundle would change (reads only)
|
package/lib/commands/admin.mjs
CHANGED
|
@@ -5089,7 +5089,8 @@ const LAGO_USAGE = `Usage: admin billing lago <command>
|
|
|
5089
5089
|
payments [--org <org-id>] [--open] failed payments and what the retry loop did about each
|
|
5090
5090
|
payments retry <lago-invoice-id> charge one failed invoice again now
|
|
5091
5091
|
payments run [--dry] one pass of the failed-payment loop now (--dry: report only)
|
|
5092
|
-
signups [--status open|card_saved|provisioned|paid|abandoned] new paying orgs on their way onto Lago
|
|
5092
|
+
signups [--status open|card_saved|provisioned|paid|abandoned] new paying orgs on their way onto Lago
|
|
5093
|
+
cap <org-id> <dollars|off> [--why "…"] set an account's monthly spend cap on overage (off: no cap)`;
|
|
5093
5094
|
|
|
5094
5095
|
/** admin billing lago … — staff operations on Lago (docs/billing/lago-integration.md). */
|
|
5095
5096
|
async function cmdAdminBilling(ctx) {
|
|
@@ -5212,6 +5213,27 @@ async function cmdAdminBilling(ctx) {
|
|
|
5212
5213
|
);
|
|
5213
5214
|
return 0;
|
|
5214
5215
|
}
|
|
5216
|
+
case "cap": {
|
|
5217
|
+
const orgId = positionals[2];
|
|
5218
|
+
const value = positionals[3];
|
|
5219
|
+
if (!orgId || value === undefined) throw new CliError(LAGO_USAGE);
|
|
5220
|
+
const off = ["off", "none", "null"].includes(value.toLowerCase());
|
|
5221
|
+
const capUsd = off ? null : Number(value.replace(/^\$/, ""));
|
|
5222
|
+
if (capUsd !== null && (!Number.isFinite(capUsd) || capUsd < 0))
|
|
5223
|
+
throw new CliError(`Not a cap: ${value}. Give dollars (0 stops at the allowance) or off.`);
|
|
5224
|
+
const result = await staffRequest(
|
|
5225
|
+
ctx,
|
|
5226
|
+
"POST",
|
|
5227
|
+
`${LAGO}/cap`,
|
|
5228
|
+
compact({ orgId, cap_usd: capUsd, reason: flagStr(flags.why) })
|
|
5229
|
+
);
|
|
5230
|
+
printResult(io, flags, result, (r) =>
|
|
5231
|
+
r.spendCap
|
|
5232
|
+
? `Spend cap: $${r.spendCap.usd} of overage a month (alerts: ${r.alert}).`
|
|
5233
|
+
: "No spend cap: overage is on."
|
|
5234
|
+
);
|
|
5235
|
+
return 0;
|
|
5236
|
+
}
|
|
5215
5237
|
default:
|
|
5216
5238
|
throw new CliError(LAGO_USAGE);
|
|
5217
5239
|
}
|
package/lib/commands/auth.mjs
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { LetterstoryClient, resolveConfig, writeConfigFile, clearConfigFile, configPath } from "../client.mjs";
|
|
10
10
|
import { flagStr } from "./shared.mjs";
|
|
11
|
-
import { browserLogin, revokeToken } from "../oauth.mjs";
|
|
11
|
+
import { browserLogin, deviceLogin, revokeToken } from "../oauth.mjs";
|
|
12
12
|
|
|
13
13
|
export async function cmdLogin(ctx) {
|
|
14
14
|
const { flags, io } = ctx;
|
|
@@ -39,7 +39,7 @@ export async function cmdLogin(ctx) {
|
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
const tokens = await browserLogin({ url, io });
|
|
42
|
+
const tokens = flags.device ? await deviceLogin({ url, io }) : await browserLogin({ url, io });
|
|
43
43
|
const oauth = {
|
|
44
44
|
access_token: tokens.access_token,
|
|
45
45
|
refresh_token: tokens.refresh_token,
|
package/lib/commands/org.mjs
CHANGED
|
@@ -28,6 +28,11 @@ export const ARGS = {
|
|
|
28
28
|
describe_org_config_fields: {},
|
|
29
29
|
get_org_config_source: {},
|
|
30
30
|
get_billing_payments: {},
|
|
31
|
+
set_billing_spend_cap: {
|
|
32
|
+
cap_usd: { value: (_flags, positionals) => capFrom(requirePositional(positionals, 0, "dollars|off")) },
|
|
33
|
+
reason: { flag: "why" },
|
|
34
|
+
},
|
|
35
|
+
request_billing_tokens: { note: { flag: "note" } },
|
|
31
36
|
list_integrations: {},
|
|
32
37
|
get_linear_connection: {},
|
|
33
38
|
list_linear_targets: { team_id: { flag: "team" } },
|
|
@@ -90,9 +95,45 @@ async function orgIntegrations(ctx) {
|
|
|
90
95
|
return 0;
|
|
91
96
|
}
|
|
92
97
|
|
|
93
|
-
/**
|
|
98
|
+
/** A cap from the command line: dollars, or `off` (no cap). */
|
|
99
|
+
function capFrom(value) {
|
|
100
|
+
if (["off", "none", "null"].includes(String(value).toLowerCase())) return null;
|
|
101
|
+
const usd = Number(String(value).replace(/^\$/, ""));
|
|
102
|
+
if (!Number.isFinite(usd) || usd < 0)
|
|
103
|
+
throw new CliError(`Not a cap: ${value}. Give dollars (0 stops at the allowance) or off.`);
|
|
104
|
+
return usd;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const usd = (n) => `$${Number(n).toFixed(2)}`;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* `org billing`: the org's plan on Lago, this period against its allowance and spend cap, whether
|
|
111
|
+
* it is past due, and its failed payments (get_billing_payments). `org billing cap <dollars|off>`
|
|
112
|
+
* sets the cap (set_billing_spend_cap, admins); `org billing request` asks the admins for more
|
|
113
|
+
* (request_billing_tokens).
|
|
114
|
+
*/
|
|
94
115
|
async function orgBilling(ctx) {
|
|
95
|
-
const { client, flags, io } = ctx;
|
|
116
|
+
const { client, flags, io, positionals } = ctx;
|
|
117
|
+
if (positionals[0] === "cap") {
|
|
118
|
+
refuseUnknownFlags(ctx, ARGS.set_billing_spend_cap, []);
|
|
119
|
+
const args = argsFromFlags(ARGS.set_billing_spend_cap, flags, positionals.slice(1));
|
|
120
|
+
const result = await client.callTool("set_billing_spend_cap", args);
|
|
121
|
+
printResult(io, flags, result, (r) =>
|
|
122
|
+
r.spendCap
|
|
123
|
+
? `Spend cap: ${usd(r.spendCap.usd)} of overage a month${r.spendCap.usd === 0 ? " (work stops at the allowance)" : ""}.${r.alert === "failed" ? " The alerts couldn't be moved; the cap holds." : ""}`
|
|
124
|
+
: "No spend cap: overage is on."
|
|
125
|
+
);
|
|
126
|
+
return 0;
|
|
127
|
+
}
|
|
128
|
+
if (positionals[0] === "request") {
|
|
129
|
+
refuseUnknownFlags(ctx, ARGS.request_billing_tokens, []);
|
|
130
|
+
const result = await client.callTool(
|
|
131
|
+
"request_billing_tokens",
|
|
132
|
+
argsFromFlags(ARGS.request_billing_tokens, flags, [])
|
|
133
|
+
);
|
|
134
|
+
printResult(io, flags, result, (r) => `Asked ${r.emailedTo} admin${r.emailedTo === 1 ? "" : "s"} for more.`);
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
|
96
137
|
refuseUnknownFlags(ctx, {}, []);
|
|
97
138
|
const result = await client.callTool("get_billing_payments", {});
|
|
98
139
|
printResult(io, flags, result, (r) =>
|
|
@@ -100,6 +141,16 @@ async function orgBilling(ctx) {
|
|
|
100
141
|
r.plan
|
|
101
142
|
? `Plan: ${r.plan.name}${r.plan.ratio !== 1 ? ` (${r.plan.ratio}x)` : ""}`
|
|
102
143
|
: "Not billed by usage yet.",
|
|
144
|
+
...(r.usage
|
|
145
|
+
? [
|
|
146
|
+
`This period (${r.usage.periodFrom.slice(0, 10)} to ${r.usage.periodTo.slice(0, 10)}): ${r.usage.usedTokens} of ${r.usage.allowanceTokens} tokens included${r.usage.overageUsd > 0 ? `, ${usd(r.usage.overageUsd)} of overage` : ""}.`,
|
|
147
|
+
]
|
|
148
|
+
: []),
|
|
149
|
+
r.spendCap
|
|
150
|
+
? `Spend cap: ${usd(r.spendCap.usd)} of overage a month${r.usage?.availableTokens != null ? `, ${Math.max(0, r.usage.availableTokens)} tokens left under it` : ""}.`
|
|
151
|
+
: r.plan
|
|
152
|
+
? "No spend cap: overage is on."
|
|
153
|
+
: null,
|
|
103
154
|
...(r.pastDueAt ? [`Past due since ${r.pastDueAt}.`] : []),
|
|
104
155
|
...(r.payments.length
|
|
105
156
|
? r.payments.map(
|
|
@@ -107,7 +158,9 @@ async function orgBilling(ctx) {
|
|
|
107
158
|
` ${p.invoiceNumber ?? p.invoiceId} $${(p.amountCents / 100).toFixed(2)} ${p.status}${p.nextAttemptAt ? `, next try ${p.nextAttemptAt}` : ""}${p.payUrl ? ` pay: ${p.payUrl}` : ""}`
|
|
108
159
|
)
|
|
109
160
|
: ["No failed payments."]),
|
|
110
|
-
]
|
|
161
|
+
]
|
|
162
|
+
.filter((line) => line !== null)
|
|
163
|
+
.join("\n")
|
|
111
164
|
);
|
|
112
165
|
return 0;
|
|
113
166
|
}
|
package/lib/oauth.mjs
CHANGED
|
@@ -186,6 +186,71 @@ export async function browserLogin({ url, io, fetchImpl = globalThis.fetch }) {
|
|
|
186
186
|
}
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
export const DEVICE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
|
|
190
|
+
|
|
191
|
+
// RFC 8628 device sign-in: for a CLI whose browser is somewhere else (a cloud agent, a remote
|
|
192
|
+
// box). Prints one link for the person to open and Allow, then polls /token until they do.
|
|
193
|
+
export async function deviceLogin({
|
|
194
|
+
url,
|
|
195
|
+
io,
|
|
196
|
+
fetchImpl = globalThis.fetch,
|
|
197
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
198
|
+
}) {
|
|
199
|
+
const base = url.replace(/\/+$/, "");
|
|
200
|
+
const endpoint = `${base}/api/oauth/device_authorization`;
|
|
201
|
+
let res;
|
|
202
|
+
try {
|
|
203
|
+
res = await fetchImpl(endpoint, {
|
|
204
|
+
method: "POST",
|
|
205
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
206
|
+
body: new URLSearchParams({ client_id: CLIENT_ID }).toString(),
|
|
207
|
+
});
|
|
208
|
+
} catch (err) {
|
|
209
|
+
throw new CliError(`Could not reach ${endpoint}: ${err.message}`);
|
|
210
|
+
}
|
|
211
|
+
const start = await res.json().catch(() => null);
|
|
212
|
+
if (!res.ok || !start?.device_code) {
|
|
213
|
+
const detail = start?.error_description || start?.error || `HTTP ${res.status}`;
|
|
214
|
+
throw new CliError(`Login failed: ${detail}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
io.log(`Open this link, pick the organization and click Allow:\n ${start.verification_uri_complete}\n`);
|
|
218
|
+
io.log(`Code: ${start.user_code} (the page shows the same one)`);
|
|
219
|
+
io.log("Waiting for approval…");
|
|
220
|
+
|
|
221
|
+
let intervalMs = (start.interval ?? 5) * 1000;
|
|
222
|
+
const deadline = Date.now() + (start.expires_in ?? 600) * 1000;
|
|
223
|
+
while (Date.now() < deadline) {
|
|
224
|
+
await sleep(intervalMs);
|
|
225
|
+
let poll;
|
|
226
|
+
try {
|
|
227
|
+
poll = await fetchImpl(`${base}/api/oauth/token`, {
|
|
228
|
+
method: "POST",
|
|
229
|
+
headers: { "content-type": "application/x-www-form-urlencoded" },
|
|
230
|
+
body: new URLSearchParams({
|
|
231
|
+
grant_type: DEVICE_GRANT_TYPE,
|
|
232
|
+
client_id: CLIENT_ID,
|
|
233
|
+
device_code: start.device_code,
|
|
234
|
+
}).toString(),
|
|
235
|
+
});
|
|
236
|
+
} catch {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const json = await poll.json().catch(() => null);
|
|
240
|
+
if (poll.ok && json?.access_token) return json;
|
|
241
|
+
const error = json?.error;
|
|
242
|
+
if (error === "authorization_pending") continue;
|
|
243
|
+
if (error === "slow_down") {
|
|
244
|
+
intervalMs += 5000;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (error === "access_denied") throw new CliError("Sign-in was cancelled in the browser.");
|
|
248
|
+
if (error === "expired_token") throw new CliError("The link expired. Run `login --device` again.");
|
|
249
|
+
throw new CliError(`Login failed: ${json?.error_description || error || `HTTP ${poll.status}`}`);
|
|
250
|
+
}
|
|
251
|
+
throw new CliError("The link expired before it was approved. Run `login --device` again.");
|
|
252
|
+
}
|
|
253
|
+
|
|
189
254
|
export async function refreshAccessToken({ url, refreshToken, fetchImpl = globalThis.fetch }) {
|
|
190
255
|
return tokenRequest(
|
|
191
256
|
url,
|